diff --git a/packages/cupertino_ui/cupertino_ui_examples/.gitignore b/packages/cupertino_ui/cupertino_ui_examples/.gitignore new file mode 100644 index 000000000000..c31b751700e0 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/.gitignore @@ -0,0 +1,3 @@ +# Unused platform specific files +android/ +ios/ diff --git a/packages/cupertino_ui/cupertino_ui_examples/.metadata b/packages/cupertino_ui/cupertino_ui_examples/.metadata new file mode 100644 index 000000000000..579126128d1f --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "89172fc6152056af222e0afcf6e2d6ee4e9f6f8f" + channel: "master" + +project_type: package diff --git a/packages/cupertino_ui/cupertino_ui_examples/README.md b/packages/cupertino_ui/cupertino_ui_examples/README.md new file mode 100644 index 000000000000..e6c5b2bfb8b8 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/README.md @@ -0,0 +1,166 @@ +# cupertino_ui API Example Code +This directory contains the example code that is referenced in the documentation +in cupertino_ui's source code. + +These examples were originally located [in +flutter/flutter](https://github.com/flutter/flutter/tree/master/examples/api) +before the Cupertino library was decoupled and moved into its current home in +flutter/packages. + +The examples can be run individually by just specifying the path to the example +on the command line (or in the run configuration of an IDE). + +For example (no pun intended!), to run the first example from the +`CupertinoActivityIndicator` class in Chrome, you would run it like so from the +[api](.) +directory: + +``` +% flutter run -d chrome lib/activity_indicator/cupertino_activity_indicator.0.dart +``` + +All of these same examples are available on the API docs site. + + + + + +## Naming + +> `lib/file/class_name.n.dart` +> +> `lib/file/class_name.member_name.n.dart` + +The naming scheme corresponds to the files under [lib/src](../lib/src) where +each file is represented as a directory (without the `.dart` suffix), and each +sample in the file is a separate file in that directory. So, for the example +above, where the examples are from the +[lib/src/activity_indicator.dart](../lib/src/activity_indicator.dart) file, the +`CupertinoActivityIndicator` class, the first sample (hence the index "0") for +that symbol resides in the file named +[lib/activity_indicator/cupertino_activity_indicator.0.dart](lib/activity_indicator/cupertino_activity_indicator.0.dart). + +Symbol names are converted from "CamelCase" to "snake_case". Dots are left +between symbol names, so the first example for symbol +`InputDecoration.prefixIconConstraints` would be converted to +`input_decoration.prefix_icon_constraints.0.dart`. + +If the same example is linked to from multiple symbols, the source will be in +the canonical location for one of the symbols, and the link in the API docs +block for the other symbols will point to the first symbol's example location. + +## Authoring + +> For more detailed information about authoring examples, see +> [the snippets package](https://pub.dev/packages/snippets). + +When authoring examples, first place a block in the Dartdoc documentation for +the symbol you would like to attach it to. Here's what it might look like if you +wanted to add a new example to the `CupertinoActivityIndicator` class: + +```dart +/// {@tool dartpad} +/// Write a description of the example here. This description will appear in the +/// API web documentation to introduce the example. +/// +/// ** See code in cupertino_ui_examples/lib/activity_indicator/cupertino_activity_indicator.0.dart ** +/// {@end-tool} +``` + +The "See code in" line needs to be formatted exactly as above, with no wrapping +or newlines, one space after the "`**`" at the beginning, and one space before +the "`**`" at the end, and the words "See code in" at the beginning of the line. +This is what the snippets tool use when finding the example source code that you +are creating. + + + +You should also add tests for your sample code under +[`cupertino_ui_examples/test`](./test), that matches their location under [lib](./lib), +ending in `_test.dart`. See the section on [writing tests](#writing-tests) for +more information on what kinds of tests to write. + +The entire example should be in a single file, so that Dartpad can load it. + +Only packages that can be loaded by Dartpad may be imported. If you use one that +hasn't been used in an example before, you may have to add it to the +[pubspec.yaml](pubspec.yaml) in the [cupertino_ui_examples](./) directory. + +## Snippets + +There is another type of example that can also be authored, using `{@tool +snippet}`. Snippet examples are just written inline in the source, like so: + +```dart +/// {@tool dartpad} +/// Write a description of the example here. This description will appear in the +/// API web documentation to introduce the example. +/// +/// ```dart +/// // Sample code goes here, e.g.: +/// const Widget emptyBox = SizedBox(); +/// ``` +/// {@end-tool} +``` + +The source for these snippets isn't stored under the [`cupertino_ui_examples`](.) +directory, or available in Dartpad in the API docs, since they're not intended +to be runnable, they just show some incomplete snippet of example code. It must +compile (in the context of the sample analyzer), but doesn't need to do +anything. See [the snippets documentation]( +https://pub.dev/packages/snippets#snippet-tool) for more information about the +context that the analyzer uses. + +## Writing Tests + +Examples are required to have tests. There is already a "smoke test" that simply +builds and runs all the API examples, just to make sure that they start up +without crashing. Functionality tests are required the examples, and generally +just do what is normally done for writing tests. The one thing that makes it +more challenging to do for examples is that they can't really be written for +testability in any obvious way, since that would complicate the examples and +make them harder to explain. + +As an example, in regular framework code, you might include a parameter for a +`Platform` object that can be overridden by a test to supply a dummy platform, +but in the example. This would be unnecessarily complex for the example. In all +other ways, these are just normal tests. You don't need to re-test the +functionality of the widget being used in the example, but you should test the +functionality and integrity of the example itself. + +Tests go into a directory under [test](./test) that matches their location under +[lib](./lib). They are named the same as the example they are testing, with +`_test.dart` at the end, like other tests. For instance, an +`CupertinoActivityIndicator` example that resides in +[`lib/activity_indicator/cupertino_activity_indicator.0.dart`]( ./lib/activity_indicator/cupertino_activity_indicator.0.dart) would +have its tests in a file named +[`test/activity_indicator/cupertino_activity_indicator.0_test.dart`]( +./test/activity_indicator/cupertino_activity_indicator.0_test.dart) diff --git a/packages/cupertino_ui/cupertino_ui_examples/analysis_options.yaml b/packages/cupertino_ui/cupertino_ui_examples/analysis_options.yaml new file mode 100644 index 000000000000..ca26f0d08d76 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/analysis_options.yaml @@ -0,0 +1,12 @@ +# This file is also used by dev/bots/analyze_snippet_code.dart to analyze code snippets (`{@tool snippet}` sections). + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # Samples want to print things pretty often. + avoid_print: false + # Samples are sometimes incomplete and don't show usage of everything. + unreachable_from_main: false diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/activity_indicator/cupertino_activity_indicator.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/activity_indicator/cupertino_activity_indicator.0.dart new file mode 100644 index 000000000000..d17e14ee59b0 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/activity_indicator/cupertino_activity_indicator.0.dart @@ -0,0 +1,75 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoActivityIndicator]. + +void main() => runApp(const CupertinoIndicatorApp()); + +class CupertinoIndicatorApp extends StatelessWidget { + const CupertinoIndicatorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoIndicatorExample(), + ); + } +} + +class CupertinoIndicatorExample extends StatelessWidget { + const CupertinoIndicatorExample({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('CupertinoActivityIndicator Sample'), + ), + child: Center( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + Column( + mainAxisAlignment: .center, + children: [ + // Cupertino activity indicator with default properties. + CupertinoActivityIndicator(), + SizedBox(height: 10), + Text('Default'), + ], + ), + Column( + mainAxisAlignment: .center, + children: [ + // Cupertino activity indicator with custom radius and color. + CupertinoActivityIndicator( + radius: 20.0, + color: CupertinoColors.activeBlue, + ), + SizedBox(height: 10), + Text( + 'radius: 20.0\ncolor: CupertinoColors.activeBlue', + textAlign: .center, + ), + ], + ), + Column( + mainAxisAlignment: .center, + children: [ + // Cupertino activity indicator with custom radius and disabled + // animation. + CupertinoActivityIndicator(radius: 20.0, animating: false), + SizedBox(height: 10), + Text('radius: 20.0\nanimating: false', textAlign: .center), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/activity_indicator/cupertino_linear_activity_indicator.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/activity_indicator/cupertino_linear_activity_indicator.0.dart new file mode 100644 index 000000000000..7e154bc433d3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/activity_indicator/cupertino_linear_activity_indicator.0.dart @@ -0,0 +1,77 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoLinearActivityIndicator]. + +void main() => runApp(const CupertinoLinearActivityIndicatorApp()); + +class CupertinoLinearActivityIndicatorApp extends StatelessWidget { + const CupertinoLinearActivityIndicatorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoIndicatorExample(), + ); + } +} + +class CupertinoIndicatorExample extends StatelessWidget { + const CupertinoIndicatorExample({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('CupertinoLinearActivityIndicator Sample'), + ), + child: Padding( + padding: .all(8.0), + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + Column( + mainAxisAlignment: .center, + children: [ + CupertinoLinearActivityIndicator(progress: 0), + SizedBox(height: 10), + Text('Progress: 0'), + ], + ), + Column( + mainAxisAlignment: .center, + children: [ + CupertinoLinearActivityIndicator(progress: 0.2), + SizedBox(height: 10), + Text('Progress: 0.2', textAlign: .center), + ], + ), + Column( + mainAxisAlignment: .center, + children: [ + CupertinoLinearActivityIndicator(progress: 0.4, height: 10), + SizedBox(height: 10), + Text('Height: 10', textAlign: .center), + ], + ), + Column( + mainAxisAlignment: .center, + children: [ + CupertinoLinearActivityIndicator( + progress: 0.6, + color: CupertinoColors.activeGreen, + ), + SizedBox(height: 10), + Text('Color: green', textAlign: .center), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/bottom_tab_bar/cupertino_tab_bar.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/bottom_tab_bar/cupertino_tab_bar.0.dart new file mode 100644 index 000000000000..208239e396b2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/bottom_tab_bar/cupertino_tab_bar.0.dart @@ -0,0 +1,58 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoTabBar]. + +void main() => runApp(const CupertinoTabBarApp()); + +class CupertinoTabBarApp extends StatelessWidget { + const CupertinoTabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoTabBarExample(), + ); + } +} + +class CupertinoTabBarExample extends StatelessWidget { + const CupertinoTabBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.star_fill), + label: 'Favorites', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.clock_solid), + label: 'Recents', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.person_alt_circle_fill), + label: 'Contacts', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.circle_grid_3x3_fill), + label: 'Keypad', + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + return CupertinoTabView( + builder: (BuildContext context) { + return Center(child: Text('Content of tab $index')); + }, + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/button/cupertino_button.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/button/cupertino_button.0.dart new file mode 100644 index 000000000000..a67d59650b1f --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/button/cupertino_button.0.dart @@ -0,0 +1,54 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoButton]. + +void main() => runApp(const CupertinoButtonApp()); + +class CupertinoButtonApp extends StatelessWidget { + const CupertinoButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoButtonExample(), + ); + } +} + +class CupertinoButtonExample extends StatelessWidget { + const CupertinoButtonExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoButton Sample'), + ), + child: Center( + child: Column( + mainAxisSize: .min, + children: [ + const CupertinoButton(onPressed: null, child: Text('Disabled')), + const SizedBox(height: 30), + const CupertinoButton.filled( + onPressed: null, + child: Text('Disabled'), + ), + const SizedBox(height: 30), + CupertinoButton(onPressed: () {}, child: const Text('Enabled')), + const SizedBox(height: 30), + CupertinoButton.filled( + onPressed: () {}, + child: const Text('Enabled'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/checkbox/cupertino_checkbox.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/checkbox/cupertino_checkbox.0.dart new file mode 100644 index 000000000000..79cf10fbe9e2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/checkbox/cupertino_checkbox.0.dart @@ -0,0 +1,54 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoCheckbox]. + +void main() => runApp(const CupertinoCheckboxApp()); + +class CupertinoCheckboxApp extends StatelessWidget { + const CupertinoCheckboxApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('CupertinoCheckbox Example'), + ), + child: SafeArea(child: CupertinoCheckboxExample()), + ), + ); + } +} + +class CupertinoCheckboxExample extends StatefulWidget { + const CupertinoCheckboxExample({super.key}); + + @override + State createState() => + _CupertinoCheckboxExampleState(); +} + +class _CupertinoCheckboxExampleState extends State { + bool? isChecked = true; + + @override + Widget build(BuildContext context) { + return CupertinoCheckbox( + checkColor: CupertinoColors.white, + // Set tristate to true to make the checkbox display a null value + // in addition to the default true and false values. + tristate: true, + value: isChecked, + onChanged: (bool? value) { + setState(() { + isChecked = value; + }); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/context_menu/cupertino_context_menu.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/context_menu/cupertino_context_menu.0.dart new file mode 100644 index 000000000000..c4874a7758a2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/context_menu/cupertino_context_menu.0.dart @@ -0,0 +1,77 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoContextMenu]. + +void main() => runApp(const ContextMenuApp()); + +class ContextMenuApp extends StatelessWidget { + const ContextMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: ContextMenuExample(), + ); + } +} + +class ContextMenuExample extends StatelessWidget { + const ContextMenuExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoContextMenu Sample'), + ), + child: Center( + child: SizedBox.square( + dimension: 100, + child: CupertinoContextMenu( + actions: [ + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + isDefaultAction: true, + trailingIcon: CupertinoIcons.doc_on_clipboard_fill, + child: const Text('Copy'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + trailingIcon: CupertinoIcons.share, + child: const Text('Share'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + trailingIcon: CupertinoIcons.heart, + child: const Text('Favorite'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + isDestructiveAction: true, + trailingIcon: CupertinoIcons.delete, + child: const Text('Delete'), + ), + ], + child: const ColoredBox( + color: CupertinoColors.systemYellow, + child: FlutterLogo(size: 500.0), + ), + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/context_menu/cupertino_context_menu.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/context_menu/cupertino_context_menu.1.dart new file mode 100644 index 000000000000..8621bc465c9c --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/context_menu/cupertino_context_menu.1.dart @@ -0,0 +1,116 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoContextMenu]. + +final DecorationTween _tween = DecorationTween( + begin: BoxDecoration( + color: CupertinoColors.systemYellow, + boxShadow: const [], + borderRadius: .circular(20.0), + ), + end: BoxDecoration( + color: CupertinoColors.systemYellow, + boxShadow: CupertinoContextMenu.kEndBoxShadow, + borderRadius: .circular(20.0), + ), +); + +void main() => runApp(const ContextMenuApp()); + +class ContextMenuApp extends StatelessWidget { + const ContextMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: ContextMenuExample(), + ); + } +} + +class ContextMenuExample extends StatelessWidget { + const ContextMenuExample({super.key}); + + // Or just do this inline in the builder below? + static Animation _boxDecorationAnimation( + Animation animation, + ) { + return _tween.animate( + CurvedAnimation( + parent: animation, + curve: Interval(0.0, CupertinoContextMenu.animationOpensAt), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoContextMenu Sample'), + ), + child: Center( + child: SizedBox.square( + dimension: 100, + child: CupertinoContextMenu.builder( + actions: [ + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + isDefaultAction: true, + trailingIcon: CupertinoIcons.doc_on_clipboard_fill, + child: const Text('Copy'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + trailingIcon: CupertinoIcons.share, + child: const Text('Share'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + trailingIcon: CupertinoIcons.heart, + child: const Text('Favorite'), + ), + CupertinoContextMenuAction( + onPressed: () { + Navigator.pop(context); + }, + isDestructiveAction: true, + trailingIcon: CupertinoIcons.delete, + child: const Text('Delete'), + ), + ], + builder: (BuildContext context, Animation animation) { + final Animation boxDecorationAnimation = + _boxDecorationAnimation(animation); + + return Container( + decoration: + animation.value < CupertinoContextMenu.animationOpensAt + ? boxDecorationAnimation.value + : null, + child: Container( + decoration: BoxDecoration( + color: CupertinoColors.systemYellow, + borderRadius: .circular(20.0), + ), + child: const FlutterLogo(size: 500.0), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/date_picker/cupertino_date_picker.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/date_picker/cupertino_date_picker.0.dart new file mode 100644 index 000000000000..e9d1efa6091e --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/date_picker/cupertino_date_picker.0.dart @@ -0,0 +1,177 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoDatePicker]. + +void main() => runApp(const DatePickerApp()); + +class DatePickerApp extends StatelessWidget { + const DatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: DatePickerExample(), + ); + } +} + +class DatePickerExample extends StatefulWidget { + const DatePickerExample({super.key}); + + @override + State createState() => _DatePickerExampleState(); +} + +class _DatePickerExampleState extends State { + DateTime date = DateTime(2016, 10, 26); + DateTime time = DateTime(2016, 5, 10, 22, 35); + DateTime dateTime = DateTime(2016, 8, 3, 17, 45); + + // This function displays a CupertinoModalPopup with a reasonable fixed height + // which hosts CupertinoDatePicker. + void _showDialog(Widget child) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: 216, + padding: const .only(top: 6.0), + // The Bottom margin is provided to align the popup above the system + // navigation bar. + margin: .only(bottom: MediaQuery.of(context).viewInsets.bottom), + // Provide a background color for the popup. + color: CupertinoColors.systemBackground.resolveFrom(context), + // Use a SafeArea widget to avoid system overlaps. + child: SafeArea(top: false, child: child), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoDatePicker Sample'), + ), + child: DefaultTextStyle( + style: TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 22.0, + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + _DatePickerItem( + children: [ + const Text('Date'), + CupertinoButton( + // Display a CupertinoDatePicker in date picker mode. + onPressed: () => _showDialog( + CupertinoDatePicker( + initialDateTime: date, + mode: .date, + use24hFormat: true, + // This shows day of week alongside day of month + showDayOfWeek: true, + // This is called when the user changes the date. + onDateTimeChanged: (DateTime newDate) { + setState(() => date = newDate); + }, + ), + ), + // In this example, the date is formatted manually. You can + // use the intl package to format the value based on the + // user's locale settings. + child: Text( + '${date.month}-${date.day}-${date.year}', + style: const TextStyle(fontSize: 22.0), + ), + ), + ], + ), + _DatePickerItem( + children: [ + const Text('Time'), + CupertinoButton( + // Display a CupertinoDatePicker in time picker mode. + onPressed: () => _showDialog( + CupertinoDatePicker( + initialDateTime: time, + mode: .time, + use24hFormat: true, + // This is called when the user changes the time. + onDateTimeChanged: (DateTime newTime) { + setState(() => time = newTime); + }, + ), + ), + // In this example, the time value is formatted manually. + // You can use the intl package to format the value based on + // the user's locale settings. + child: Text( + '${time.hour}:${time.minute}', + style: const TextStyle(fontSize: 22.0), + ), + ), + ], + ), + _DatePickerItem( + children: [ + const Text('DateTime'), + CupertinoButton( + // Display a CupertinoDatePicker in dateTime picker mode. + onPressed: () => _showDialog( + CupertinoDatePicker( + initialDateTime: dateTime, + use24hFormat: true, + // This is called when the user changes the dateTime. + onDateTimeChanged: (DateTime newDateTime) { + setState(() => dateTime = newDateTime); + }, + ), + ), + // In this example, the time value is formatted manually. You + // can use the intl package to format the value based on the + // user's locale settings. + child: Text( + '${dateTime.month}-${dateTime.day}-${dateTime.year} ${dateTime.hour}:${dateTime.minute}', + style: const TextStyle(fontSize: 22.0), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +// This class simply decorates a row of widgets. +class _DatePickerItem extends StatelessWidget { + const _DatePickerItem({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: CupertinoColors.inactiveGray, width: 0.0), + bottom: BorderSide(color: CupertinoColors.inactiveGray, width: 0.0), + ), + ), + child: Padding( + padding: const .symmetric(horizontal: 16.0), + child: Row(mainAxisAlignment: .spaceBetween, children: children), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/date_picker/cupertino_timer_picker.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/date_picker/cupertino_timer_picker.0.dart new file mode 100644 index 000000000000..019c24280d68 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/date_picker/cupertino_timer_picker.0.dart @@ -0,0 +1,122 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoTimerPicker]. + +void main() => runApp(const TimerPickerApp()); + +class TimerPickerApp extends StatelessWidget { + const TimerPickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: TimerPickerExample(), + ); + } +} + +class TimerPickerExample extends StatefulWidget { + const TimerPickerExample({super.key}); + + @override + State createState() => _TimerPickerExampleState(); +} + +class _TimerPickerExampleState extends State { + Duration duration = const Duration(hours: 1, minutes: 23); + + // This shows a CupertinoModalPopup with a reasonable fixed height which hosts + // a CupertinoTimerPicker. + void _showDialog(Widget child) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: 216, + padding: const .only(top: 6.0), + // The bottom margin is provided to align the popup above the system + // navigation bar. + margin: .only(bottom: MediaQuery.of(context).viewInsets.bottom), + // Provide a background color for the popup. + color: CupertinoColors.systemBackground.resolveFrom(context), + // Use a SafeArea widget to avoid system overlaps. + child: SafeArea(top: false, child: child), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoTimerPicker Sample'), + ), + child: DefaultTextStyle( + style: TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 22.0, + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + _TimerPickerItem( + children: [ + const Text('Timer'), + CupertinoButton( + // Display a CupertinoTimerPicker with hour/minute mode. + onPressed: () => _showDialog( + CupertinoTimerPicker( + mode: .hm, + initialTimerDuration: duration, + // This is called when the user changes the timer's + // duration. + onTimerDurationChanged: (Duration newDuration) { + setState(() => duration = newDuration); + }, + ), + ), + // In this example, the timer's value is formatted manually. + // You can use the intl package to format the value based on + // the user's locale settings. + child: Text( + '$duration', + style: const TextStyle(fontSize: 22.0), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +// This class simply decorates a row of widgets. +class _TimerPickerItem extends StatelessWidget { + const _TimerPickerItem({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: CupertinoColors.inactiveGray, width: 0.0), + bottom: BorderSide(color: CupertinoColors.inactiveGray, width: 0.0), + ), + ), + child: Padding( + padding: const .symmetric(horizontal: 16.0), + child: Row(mainAxisAlignment: .spaceBetween, children: children), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_action_sheet.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_action_sheet.0.dart new file mode 100644 index 000000000000..21abc670a04b --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_action_sheet.0.dart @@ -0,0 +1,78 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoActionSheet]. + +void main() => runApp(const ActionSheetApp()); + +class ActionSheetApp extends StatelessWidget { + const ActionSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: ActionSheetExample(), + ); + } +} + +class ActionSheetExample extends StatelessWidget { + const ActionSheetExample({super.key}); + + // This shows a CupertinoModalPopup which hosts a CupertinoActionSheet. + void _showActionSheet(BuildContext context) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + /// This parameter indicates the action would be a default + /// default behavior, turns the action's text to bold text. + isDefaultAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Default Action'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Action'), + ), + CupertinoActionSheetAction( + /// This parameter indicates the action would perform + /// a destructive action such as delete or exit and turns + /// the action's text color to red. + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Destructive Action'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoActionSheet Sample'), + ), + child: Center( + child: CupertinoButton( + onPressed: () => _showActionSheet(context), + child: const Text('CupertinoActionSheet'), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_alert_dialog.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_alert_dialog.0.dart new file mode 100644 index 000000000000..c884726299e3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_alert_dialog.0.dart @@ -0,0 +1,71 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoAlertDialog]. + +void main() => runApp(const AlertDialogApp()); + +class AlertDialogApp extends StatelessWidget { + const AlertDialogApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: AlertDialogExample(), + ); + } +} + +class AlertDialogExample extends StatelessWidget { + const AlertDialogExample({super.key}); + + void _showAlertDialog(BuildContext context) { + showCupertinoDialog( + context: context, + builder: (BuildContext context) => CupertinoAlertDialog( + title: const Text('Alert'), + content: const Text('Proceed with destructive action?'), + actions: [ + CupertinoDialogAction( + /// This parameter indicates this action is the default, + /// and turns the action's text to bold text. + isDefaultAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('No'), + ), + CupertinoDialogAction( + /// This parameter indicates the action would perform + /// a destructive action such as deletion, and turns + /// the action's text color to red. + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoAlertDialog Sample'), + ), + child: Center( + child: CupertinoButton( + onPressed: () => _showAlertDialog(context), + child: const Text('CupertinoAlertDialog'), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_popup_surface.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_popup_surface.0.dart new file mode 100644 index 000000000000..113c6ae7bfc1 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/dialog/cupertino_popup_surface.0.dart @@ -0,0 +1,106 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoPopupSurface]. + +void main() => runApp(const PopupSurfaceApp()); + +class PopupSurfaceApp extends StatelessWidget { + const PopupSurfaceApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(home: PopupSurfaceExample()); + } +} + +class PopupSurfaceExample extends StatefulWidget { + const PopupSurfaceExample({super.key}); + + @override + State createState() => _PopupSurfaceExampleState(); +} + +class _PopupSurfaceExampleState extends State { + bool _shouldPaintSurface = true; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Row( + mainAxisSize: .min, + mainAxisAlignment: .spaceBetween, + children: [ + const Text('Paint surface'), + const SizedBox(width: 16.0), + CupertinoSwitch( + value: _shouldPaintSurface, + onChanged: (bool value) => + setState(() => _shouldPaintSurface = value), + ), + ], + ), + CupertinoButton( + onPressed: () => _showPopupSurface(context), + child: const Text('Show popup'), + ), + ], + ), + ), + ); + } + + void _showPopupSurface(BuildContext context) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return CupertinoPopupSurface( + isSurfacePainted: _shouldPaintSurface, + child: Container( + height: 240, + padding: const .all(8.0), + child: Column( + children: [ + Expanded( + child: Container( + alignment: .center, + decoration: _shouldPaintSurface + ? null + : BoxDecoration( + color: CupertinoTheme.of( + context, + ).scaffoldBackgroundColor, + borderRadius: .circular(8.0), + ), + child: const Text('This is a popup surface.'), + ), + ), + const SizedBox(height: 8.0), + SizedBox( + width: double.infinity, + child: CupertinoButton( + color: _shouldPaintSurface + ? null + : CupertinoTheme.of(context).scaffoldBackgroundColor, + onPressed: () => Navigator.pop(context), + child: const Text( + 'Close', + style: TextStyle(color: CupertinoColors.systemBlue), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/expansion_tile/cupertino_expansion_tile.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/expansion_tile/cupertino_expansion_tile.0.dart new file mode 100644 index 000000000000..4b660ea0a6a3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/expansion_tile/cupertino_expansion_tile.0.dart @@ -0,0 +1,124 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoExpansionTile] showing both +/// fade and scroll transition modes. + +void main() => runApp(const CupertinoExpansionTileApp()); + +class CupertinoExpansionTileApp extends StatelessWidget { + const CupertinoExpansionTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Cupertino Expansion Tile'), + ), + backgroundColor: CupertinoColors.systemGroupedBackground, + child: SafeArea(child: ExpansionTileExamples()), + ), + ); + } +} + +class ExpansionTileExamples extends StatelessWidget { + const ExpansionTileExamples({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + spacing: 10, + children: [ + TransitionTileSection( + title: 'Fade Transition', + transitionMode: ExpansionTileTransitionMode.fade, + ), + TransitionTileSection( + title: 'Scroll Transition', + transitionMode: ExpansionTileTransitionMode.scroll, + ), + ], + ); + } +} + +class TransitionTileSection extends StatefulWidget { + const TransitionTileSection({ + super.key, + required this.title, + required this.transitionMode, + }); + + final String title; + final ExpansionTileTransitionMode transitionMode; + + @override + State createState() => _TransitionTileSectionState(); +} + +class _TransitionTileSectionState extends State { + late ExpansibleController _controller; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _controller = ExpansibleController(); + _controller.addListener(() { + setState(() { + _isExpanded = _controller.isExpanded; + }); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoExpansionTile( + title: Text( + '${widget.title} - ${_isExpanded ? 'Collapse me' : 'Tap to expand'}', + style: const TextStyle(fontSize: 18, fontWeight: .w600), + ), + controller: _controller, + transitionMode: widget.transitionMode, + child: CupertinoListSection.insetGrouped( + children: const [ + CupertinoListTile( + leading: Icon(CupertinoIcons.person), + backgroundColor: CupertinoColors.white, + title: Text( + 'Profile', + style: TextStyle(color: CupertinoColors.black), + ), + ), + CupertinoListTile( + leading: Icon(CupertinoIcons.mail), + backgroundColor: CupertinoColors.white, + title: Text( + 'Messages', + style: TextStyle(color: CupertinoColors.black), + ), + ), + CupertinoListTile( + leading: Icon(CupertinoIcons.settings), + backgroundColor: CupertinoColors.white, + title: Text( + 'Settings', + style: TextStyle(color: CupertinoColors.black), + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/form_row/cupertino_form_row.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/form_row/cupertino_form_row.0.dart new file mode 100644 index 000000000000..e3204295bdd9 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/form_row/cupertino_form_row.0.dart @@ -0,0 +1,139 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoFormRow]. + +void main() => runApp(const CupertinoFormRowApp()); + +class CupertinoFormRowApp extends StatelessWidget { + const CupertinoFormRowApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoFormRowExample(), + ); + } +} + +class CupertinoFormRowExample extends StatefulWidget { + const CupertinoFormRowExample({super.key}); + + @override + State createState() => + _CupertinoFormRowExampleState(); +} + +class _CupertinoFormRowExampleState extends State { + bool airplaneMode = false; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoFormSection Sample'), + ), + // Add safe area widget to place the CupertinoFormSection below the navigation bar. + child: SafeArea( + child: CupertinoFormSection( + header: const Text('Connectivity'), + children: [ + CupertinoFormRow( + prefix: const PrefixWidget( + icon: CupertinoIcons.airplane, + title: 'Airplane Mode', + color: CupertinoColors.systemOrange, + ), + child: CupertinoSwitch( + value: airplaneMode, + onChanged: (bool value) { + setState(() { + airplaneMode = value; + }); + }, + ), + ), + const CupertinoFormRow( + prefix: PrefixWidget( + icon: CupertinoIcons.wifi, + title: 'Wi-Fi', + color: CupertinoColors.systemBlue, + ), + error: Text('Home network unavailable'), + child: Row( + mainAxisAlignment: .end, + children: [ + Text('Not connected'), + SizedBox(width: 5), + Icon(CupertinoIcons.forward), + ], + ), + ), + const CupertinoFormRow( + prefix: PrefixWidget( + icon: CupertinoIcons.bluetooth, + title: 'Bluetooth', + color: CupertinoColors.activeBlue, + ), + helper: Padding( + padding: .symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: .spaceBetween, + children: [Text('Headphone'), Text('Connected')], + ), + ), + child: Row( + mainAxisAlignment: .end, + children: [ + Text('On'), + SizedBox(width: 5), + Icon(CupertinoIcons.forward), + ], + ), + ), + const CupertinoFormRow( + prefix: PrefixWidget( + icon: CupertinoIcons.bluetooth, + title: 'Mobile Data', + color: CupertinoColors.systemGreen, + ), + child: Icon(CupertinoIcons.forward), + ), + ], + ), + ), + ); + } +} + +class PrefixWidget extends StatelessWidget { + const PrefixWidget({ + super.key, + required this.icon, + required this.title, + required this.color, + }); + + final IconData icon; + final String title; + final Color color; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + padding: const .all(4.0), + decoration: BoxDecoration(color: color, borderRadius: .circular(4.0)), + child: Icon(icon, color: CupertinoColors.white), + ), + const SizedBox(width: 15), + Text(title), + ], + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/list_section/list_section_base.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/list_section/list_section_base.0.dart new file mode 100644 index 000000000000..e0a6c2dd7ed2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/list_section/list_section_base.0.dart @@ -0,0 +1,86 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for base [CupertinoListSection] and [CupertinoListTile]. + +void main() => runApp(const CupertinoListSectionBaseApp()); + +class CupertinoListSectionBaseApp extends StatelessWidget { + const CupertinoListSectionBaseApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(home: ListSectionBaseExample()); + } +} + +class ListSectionBaseExample extends StatelessWidget { + const ListSectionBaseExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: CupertinoListSection( + header: const Text('My Reminders'), + children: [ + CupertinoListTile( + title: const Text('Open pull request'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeGreen, + ), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Open pull request'); + }, + ), + ), + ), + CupertinoListTile( + title: const Text('Push to master'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.systemRed, + ), + additionalInfo: const Text('Not available'), + ), + CupertinoListTile( + title: const Text('View last commit'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeOrange, + ), + additionalInfo: const Text('12 days ago'), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Last commit'); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _SecondPage extends StatelessWidget { + const _SecondPage({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold(child: Center(child: Text(text))); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/list_section/list_section_inset.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/list_section/list_section_inset.0.dart new file mode 100644 index 000000000000..27c8ad8c9ff2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/list_section/list_section_inset.0.dart @@ -0,0 +1,86 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for inset [CupertinoListSection] and [CupertinoListTile]. + +void main() => runApp(const CupertinoListSectionInsetApp()); + +class CupertinoListSectionInsetApp extends StatelessWidget { + const CupertinoListSectionInsetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(home: ListSectionInsetExample()); + } +} + +class ListSectionInsetExample extends StatelessWidget { + const ListSectionInsetExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: CupertinoListSection.insetGrouped( + header: const Text('My Reminders'), + children: [ + CupertinoListTile.notched( + title: const Text('Open pull request'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeGreen, + ), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Open pull request'); + }, + ), + ), + ), + CupertinoListTile.notched( + title: const Text('Push to master'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.systemRed, + ), + additionalInfo: const Text('Not available'), + ), + CupertinoListTile.notched( + title: const Text('View last commit'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeOrange, + ), + additionalInfo: const Text('12 days ago'), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Last commit'); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _SecondPage extends StatelessWidget { + const _SecondPage({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold(child: Center(child: Text(text))); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/list_tile/cupertino_list_tile.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/list_tile/cupertino_list_tile.0.dart new file mode 100644 index 000000000000..a13d1d034cba --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/list_tile/cupertino_list_tile.0.dart @@ -0,0 +1,63 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// Flutter code sample for [CupertinoListTile]. + +void main() => runApp(const CupertinoListTileApp()); + +class CupertinoListTileApp extends StatelessWidget { + const CupertinoListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(home: CupertinoListTileExample()); + } +} + +class CupertinoListTileExample extends StatelessWidget { + const CupertinoListTileExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoListTile Sample'), + ), + child: ListView( + children: const [ + CupertinoListTile(title: Text('One-line CupertinoListTile')), + CupertinoListTile( + leading: FlutterLogo(), + title: Text('One-line with leading widget'), + ), + CupertinoListTile( + title: Text('One-line with trailing widget'), + trailing: Icon(Icons.more_vert), + ), + CupertinoListTile( + leading: FlutterLogo(), + title: Text('One-line with both widgets'), + trailing: Icon(Icons.more_vert), + ), + CupertinoListTile( + leading: FlutterLogo(size: 56.0), + title: Text('Two-line CupertinoListTile'), + subtitle: Text('Here is a subtitle'), + trailing: Icon(Icons.more_vert), + additionalInfo: Icon(Icons.info), + ), + CupertinoListTile( + key: Key('CupertinoListTile with background color'), + leading: FlutterLogo(size: 56.0), + title: Text('CupertinoListTile with background color'), + backgroundColor: Colors.lightBlue, + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/menu_anchor/menu_anchor.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/menu_anchor/menu_anchor.0.dart new file mode 100644 index 000000000000..49e3890d1224 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/menu_anchor/menu_anchor.0.dart @@ -0,0 +1,86 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for a basic [CupertinoMenuAnchor]. +void main() => runApp(const CupertinoMenuAnchorApp()); + +class CupertinoMenuAnchorApp extends StatelessWidget { + const CupertinoMenuAnchorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + home: CupertinoPageScaffold(child: CupertinoMenuAnchorExample()), + ); + } +} + +class CupertinoMenuAnchorExample extends StatefulWidget { + const CupertinoMenuAnchorExample({super.key}); + + @override + State createState() => + _CupertinoMenuAnchorExampleState(); +} + +class _CupertinoMenuAnchorExampleState + extends State { + // Optional: Create a focus node to allow focus traversal between the menu + // button and the menu overlay. + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + AnimationStatus _animationStatus = AnimationStatus.dismissed; + + @override + void dispose() { + _buttonFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Center( + child: CupertinoMenuAnchor( + onAnimationStatusChanged: (AnimationStatus status) { + // Since we are only checking the animation status when the button + // is pressed, we don't need to call setState here. + _animationStatus = status; + }, + childFocusNode: _buttonFocusNode, + menuChildren: [ + CupertinoMenuItem( + onPressed: () {}, + subtitle: const Text('Subtitle'), + trailing: const Icon(CupertinoIcons.star), + child: const Text('Menu Item'), + ), + ], + builder: + ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return CupertinoButton( + sizeStyle: CupertinoButtonSize.small, + focusNode: _buttonFocusNode, + onPressed: () { + if (_animationStatus.isForwardOrCompleted) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Icon(CupertinoIcons.ellipsis_vertical_circle), + ); + }, + ), + ), + ], + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/menu_anchor/menu_anchor.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/menu_anchor/menu_anchor.1.dart new file mode 100644 index 000000000000..63d234cf1677 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/menu_anchor/menu_anchor.1.dart @@ -0,0 +1,135 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for a [CupertinoMenuAnchor] that shows a menu with three +/// [CupertinoMenuItem]s and one [CupertinoMenuDivider]. +void main() => runApp(const CupertinoMenuAnchorApp()); + +class CupertinoMenuAnchorApp extends StatelessWidget { + const CupertinoMenuAnchorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('CupertinoMenuAnchor Example'), + ), + child: CupertinoMenuAnchorExample(), + ), + ); + } +} + +class CupertinoMenuAnchorExample extends StatefulWidget { + const CupertinoMenuAnchorExample({super.key}); + + @override + State createState() => + _CupertinoMenuAnchorExampleState(); +} + +class _CupertinoMenuAnchorExampleState + extends State { + // Optional: Create a focus node to allow focus traversal between the menu + // button and the menu overlay. + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + String _pressedItem = ''; + AnimationStatus _status = AnimationStatus.dismissed; + + @override + void dispose() { + _buttonFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + spacing: 20, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoMenuAnchor( + onAnimationStatusChanged: (AnimationStatus status) { + setState(() { + _status = status; + }); + }, + childFocusNode: _buttonFocusNode, + menuChildren: [ + CupertinoMenuItem( + onPressed: () { + setState(() { + _pressedItem = 'Regular Item'; + }); + }, + subtitle: const Text('Subtitle'), + child: const Text('Regular Item'), + ), + CupertinoMenuItem( + onPressed: () { + setState(() { + _pressedItem = 'Colorful Item'; + }); + }, + decoration: const WidgetStateProperty.fromMap(< + WidgetStatesConstraint, + BoxDecoration + >{ + WidgetState.dragged: BoxDecoration(color: Color(0xAEE48500)), + WidgetState.pressed: BoxDecoration(color: Color(0xA6E3002A)), + WidgetState.hovered: BoxDecoration(color: Color(0xA90069DA)), + WidgetState.focused: BoxDecoration(color: Color(0x9B00C8BE)), + WidgetState.any: BoxDecoration(color: Color(0x00000000)), + }), + child: const Text('Colorful Item'), + ), + const CupertinoMenuDivider(), + CupertinoMenuItem( + trailing: const Icon(CupertinoIcons.delete), + isDestructiveAction: true, + child: const Text('Destructive Item'), + onPressed: () { + setState(() { + _pressedItem = 'Destructive Item'; + }); + }, + ), + ], + builder: + ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return CupertinoButton( + sizeStyle: CupertinoButtonSize.medium, + focusNode: _buttonFocusNode, + onPressed: () { + if (_status.isForwardOrCompleted) { + controller.close(); + } else { + controller.open(); + } + }, + child: Text( + _status.isForwardOrCompleted ? 'Close Menu' : 'Open Menu', + ), + ); + }, + ), + Text( + _pressedItem.isEmpty + ? 'No items pressed' + : 'You Pressed: $_pressedItem', + style: CupertinoTheme.of(context).textTheme.textStyle, + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.0.dart new file mode 100644 index 000000000000..d5a3caff3d47 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.0.dart @@ -0,0 +1,50 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoNavigationBar]. + +void main() => runApp(const NavBarApp()); + +class NavBarApp extends StatelessWidget { + const NavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: NavBarExample(), + ); + } +} + +class NavBarExample extends StatefulWidget { + const NavBarExample({super.key}); + + @override + State createState() => _NavBarExampleState(); +} + +class _NavBarExampleState extends State { + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + // Try removing opacity to observe the lack of a blur effect and of sliding content. + backgroundColor: CupertinoColors.systemGrey.withValues(alpha: 0.5), + middle: const Text('CupertinoNavigationBar Sample'), + automaticBackgroundVisibility: false, + ), + child: Column( + children: [ + Container(height: 50, color: CupertinoColors.systemRed), + Container(height: 50, color: CupertinoColors.systemGreen), + Container(height: 50, color: CupertinoColors.systemBlue), + Container(height: 50, color: CupertinoColors.systemYellow), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.1.dart new file mode 100644 index 000000000000..a6d3f3922f1d --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.1.dart @@ -0,0 +1,73 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoNavigationBar] showing a +/// [CupertinoSearchTextField] with padding at the bottom of the navigation bar. + +void main() => runApp(const NavBarApp()); + +class NavBarApp extends StatelessWidget { + const NavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: NavBarExample(), + ); + } +} + +class NavBarExample extends StatefulWidget { + const NavBarExample({super.key}); + + @override + State createState() => _NavBarExampleState(); +} + +class _NavBarExampleState extends State { + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoNavigationBar Sample'), + bottom: _NavigationBarSearchField(), + automaticBackgroundVisibility: false, + ), + child: Column( + children: [ + Container(height: 50, color: CupertinoColors.systemRed), + Container(height: 50, color: CupertinoColors.systemGreen), + Container(height: 50, color: CupertinoColors.systemBlue), + Container(height: 50, color: CupertinoColors.systemYellow), + ], + ), + ); + } +} + +class _NavigationBarSearchField extends StatelessWidget + implements PreferredSizeWidget { + const _NavigationBarSearchField(); + + static const double padding = 8.0; + static const double searchFieldHeight = 35.0; + + @override + Widget build(BuildContext context) { + return const Padding( + padding: .symmetric(horizontal: padding, vertical: padding), + child: SizedBox( + height: searchFieldHeight, + child: CupertinoSearchTextField(), + ), + ); + } + + @override + Size get preferredSize => + const Size.fromHeight(searchFieldHeight + padding * 2); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.2.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.2.dart new file mode 100644 index 000000000000..15e38c95c6fd --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_navigation_bar.2.dart @@ -0,0 +1,65 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoNavigationBar.large]. + +void main() => runApp(const NavBarApp()); + +class NavBarApp extends StatelessWidget { + const NavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: NavBarExample(), + ); + } +} + +class NavBarExample extends StatefulWidget { + const NavBarExample({super.key}); + + @override + State createState() => _NavBarExampleState(); +} + +class _NavBarExampleState extends State { + int _count = 0; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar.large( + largeTitle: Text('Large Sample'), + ), + child: SafeArea( + child: Center( + child: Column( + children: [ + const Spacer(), + const Text('You have pushed the button this many times:'), + Text( + '$_count', + style: CupertinoTheme.of( + context, + ).textTheme.navLargeTitleTextStyle, + ), + const Spacer(), + Padding( + padding: const .all(15.0), + child: CupertinoButton.filled( + onPressed: () => setState(() => _count++), + child: const Text('Increment'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.0.dart new file mode 100644 index 000000000000..fd0891642295 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.0.dart @@ -0,0 +1,112 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSliverNavigationBar]. + +void main() => runApp(const SliverNavBarApp()); + +class SliverNavBarApp extends StatelessWidget { + const SliverNavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SliverNavBarExample(), + ); + } +} + +class SliverNavBarExample extends StatelessWidget { + const SliverNavBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + // A ScrollView that creates custom scroll effects using slivers. + child: CustomScrollView( + // A list of sliver widgets. + slivers: [ + const CupertinoSliverNavigationBar( + leading: Icon(CupertinoIcons.person_2), + // This title is visible in both collapsed and expanded states. + // When the "middle" parameter is omitted, the widget provided + // in the "largeTitle" parameter is used instead in the collapsed state. + largeTitle: Text('Contacts'), + trailing: Icon(CupertinoIcons.add_circled), + ), + // This widget fills the remaining space in the viewport. + // Drag the scrollable area to collapse the CupertinoSliverNavigationBar. + SliverFillRemaining( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + const Text('Drag me up', textAlign: .center), + CupertinoButton.filled( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const NextPage(); + }, + ), + ); + }, + child: const Text('Go to Next Page'), + ), + ], + ), + ), + ], + ), + ); + } +} + +class NextPage extends StatelessWidget { + const NextPage({super.key}); + + @override + Widget build(BuildContext context) { + final Brightness brightness = CupertinoTheme.brightnessOf(context); + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + backgroundColor: CupertinoColors.systemYellow, + border: Border( + bottom: BorderSide( + color: brightness == .light + ? CupertinoColors.black + : CupertinoColors.white, + ), + ), + // The middle widget is visible in both collapsed and expanded states. + middle: const Text('Contacts Group'), + // When the "middle" parameter is implemented, the largest title is only visible + // when the CupertinoSliverNavigationBar is fully expanded. + largeTitle: const Text('Family'), + ), + const SliverFillRemaining( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + Text('Drag me up', textAlign: .center), + // When the "leading" parameter is omitted on a route that has a previous page, + // the back button is automatically added to the leading position. + Text( + 'Tap on the leading button to navigate back', + textAlign: .center, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.1.dart new file mode 100644 index 000000000000..a9129124789f --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.1.dart @@ -0,0 +1,160 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSliverNavigationBar.search]. + +void main() => runApp(const SliverNavBarApp()); + +class SliverNavBarApp extends StatelessWidget { + const SliverNavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SliverNavBarExample(), + ); + } +} + +class SliverNavBarExample extends StatelessWidget { + const SliverNavBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + const CupertinoSliverNavigationBar( + leading: Icon(CupertinoIcons.person_2), + largeTitle: Text('Contacts'), + trailing: Icon(CupertinoIcons.add_circled), + ), + SliverFillRemaining( + child: Padding( + padding: const .symmetric(horizontal: 10.0), + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + const Text('Drag me up', textAlign: .center), + CupertinoButton.filled( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const NextPage(); + }, + ), + ); + }, + child: const Text('Bottom Automatic mode'), + ), + CupertinoButton.filled( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const NextPage( + bottomMode: NavigationBarBottomMode.always, + ); + }, + ), + ); + }, + child: const Text('Bottom Always mode'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class NextPage extends StatefulWidget { + const NextPage({ + super.key, + this.bottomMode = NavigationBarBottomMode.automatic, + }); + + final NavigationBarBottomMode bottomMode; + + @override + State createState() => _NextPageState(); +} + +class _NextPageState extends State { + bool searchIsActive = false; + late String text; + + @override + Widget build(BuildContext context) { + final Brightness brightness = CupertinoTheme.brightnessOf(context); + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar.search( + stretch: true, + backgroundColor: CupertinoColors.systemYellow, + border: Border( + bottom: BorderSide( + color: brightness == .light + ? CupertinoColors.black + : CupertinoColors.white, + ), + ), + middle: const Text('Contacts Group'), + largeTitle: const Text('Family'), + bottomMode: widget.bottomMode, + searchField: CupertinoSearchTextField( + autofocus: searchIsActive, + placeholder: searchIsActive ? 'Enter search text' : 'Search', + onChanged: (String value) { + setState(() { + if (value.isEmpty) { + text = 'Type in the search field to show text here'; + } else { + text = 'The text has changed to: $value'; + } + }); + }, + ), + onSearchableBottomTap: (bool value) { + text = 'Type in the search field to show text here'; + setState(() { + searchIsActive = value; + }); + }, + ), + SliverFillRemaining( + child: searchIsActive + ? ColoredBox( + color: CupertinoColors.extraLightBackgroundGray, + child: Center(child: Text(text, textAlign: .center)), + ) + : const Padding( + padding: .symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + Text('Drag me up', textAlign: .center), + Text( + 'Tap on the search field to open the search view', + textAlign: .center, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.2.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.2.dart new file mode 100644 index 000000000000..f75804f3b7fd --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/nav_bar/cupertino_sliver_nav_bar.2.dart @@ -0,0 +1,119 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSliverNavigationBar]. + +void main() => runApp(const SliverNavBarApp()); + +class SliverNavBarApp extends StatelessWidget { + const SliverNavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SliverNavBarExample(), + ); + } +} + +class SliverNavBarExample extends StatelessWidget { + const SliverNavBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + // A ScrollView that creates custom scroll effects using slivers. + child: CustomScrollView( + // A list of sliver widgets. + slivers: [ + const CupertinoSliverNavigationBar( + leading: Icon(CupertinoIcons.person_2), + // This title is visible in both collapsed and expanded states. + // When the "middle" parameter is omitted, the widget provided + // in the "largeTitle" parameter is used instead in the collapsed state. + largeTitle: Text('Contacts'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(100), + child: ColoredBox( + color: Color(0xff191970), + child: Text('Bottom Widget'), + ), + ), + trailing: Icon(CupertinoIcons.add_circled), + ), + // This widget fills the remaining space in the viewport. + // Drag the scrollable area to collapse the CupertinoSliverNavigationBar. + SliverFillRemaining( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + const Text('Drag me up', textAlign: .center), + CupertinoButton.filled( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const NextPage(); + }, + ), + ); + }, + child: const Text('Go to Next Page'), + ), + ], + ), + ), + ], + ), + ); + } +} + +class NextPage extends StatelessWidget { + const NextPage({super.key}); + + @override + Widget build(BuildContext context) { + final Brightness brightness = CupertinoTheme.brightnessOf(context); + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + backgroundColor: CupertinoColors.systemYellow, + border: Border( + bottom: BorderSide( + color: brightness == .light + ? CupertinoColors.black + : CupertinoColors.white, + ), + ), + // The middle widget is visible in both collapsed and expanded states. + middle: const Text('Contacts Group'), + // When the "middle" parameter is implemented, the largest title is only visible + // when the CupertinoSliverNavigationBar is fully expanded. + largeTitle: const Text('Family'), + ), + const SliverFillRemaining( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: [ + Text('Drag me up', textAlign: .center), + // When the "leading" parameter is omitted on a route that has a previous page, + // the back button is automatically added to the leading position. + Text( + 'Tap on the leading button to navigate back', + textAlign: .center, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/page_scaffold/cupertino_page_scaffold.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/page_scaffold/cupertino_page_scaffold.0.dart new file mode 100644 index 000000000000..250d43039f83 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/page_scaffold/cupertino_page_scaffold.0.dart @@ -0,0 +1,58 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoPageScaffold]. + +void main() => runApp(const PageScaffoldApp()); + +class PageScaffoldApp extends StatelessWidget { + const PageScaffoldApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: PageScaffoldExample(), + ); + } +} + +class PageScaffoldExample extends StatefulWidget { + const PageScaffoldExample({super.key}); + + @override + State createState() => _PageScaffoldExampleState(); +} + +class _PageScaffoldExampleState extends State { + int _count = 0; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + // Uncomment to change the background color + // backgroundColor: CupertinoColors.systemPink, + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoPageScaffold Sample'), + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Center(child: Text('You have pressed the button $_count times.')), + const SizedBox(height: 20.0), + Center( + child: CupertinoButton.filled( + onPressed: () => setState(() => _count++), + child: const Icon(CupertinoIcons.add), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/picker/cupertino_picker.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/picker/cupertino_picker.0.dart new file mode 100644 index 000000000000..49f400a0cabf --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/picker/cupertino_picker.0.dart @@ -0,0 +1,113 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoPicker]. + +const double _kItemExtent = 32.0; +const List _fruitNames = [ + 'Apple', + 'Mango', + 'Banana', + 'Orange', + 'Pineapple', + 'Strawberry', +]; + +void main() => runApp(const CupertinoPickerApp()); + +class CupertinoPickerApp extends StatelessWidget { + const CupertinoPickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoPickerExample(), + ); + } +} + +class CupertinoPickerExample extends StatefulWidget { + const CupertinoPickerExample({super.key}); + + @override + State createState() => _CupertinoPickerExampleState(); +} + +class _CupertinoPickerExampleState extends State { + int _selectedFruit = 0; + + // This shows a CupertinoModalPopup with a reasonable fixed height which hosts CupertinoPicker. + void _showDialog(Widget child) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => Container( + height: 216, + padding: const .only(top: 6.0), + // The Bottom margin is provided to align the popup above the system navigation bar. + margin: .only(bottom: MediaQuery.of(context).viewInsets.bottom), + // Provide a background color for the popup. + color: CupertinoColors.systemBackground.resolveFrom(context), + // Use a SafeArea widget to avoid system overlaps. + child: SafeArea(top: false, child: child), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoPicker Sample'), + ), + child: DefaultTextStyle( + style: TextStyle( + color: CupertinoColors.label.resolveFrom(context), + fontSize: 22.0, + ), + child: Center( + child: Row( + mainAxisAlignment: .center, + children: [ + const Text('Selected fruit: '), + CupertinoButton( + padding: .zero, + // Display a CupertinoPicker with list of fruits. + onPressed: () => _showDialog( + CupertinoPicker( + magnification: 1.22, + squeeze: 1.2, + useMagnifier: true, + itemExtent: _kItemExtent, + // This sets the initial item. + scrollController: FixedExtentScrollController( + initialItem: _selectedFruit, + ), + // This is called when selected item is changed. + onSelectedItemChanged: (int selectedItem) { + setState(() { + _selectedFruit = selectedItem; + }); + }, + children: [ + for (final String fruitName in _fruitNames) + Center(child: Text(fruitName)), + ], + ), + ), + // This displays the selected fruit name. + child: Text( + _fruitNames[_selectedFruit], + style: const TextStyle(fontSize: 22.0), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/radio/cupertino_radio.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/radio/cupertino_radio.0.dart new file mode 100644 index 000000000000..40842100e737 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/radio/cupertino_radio.0.dart @@ -0,0 +1,67 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoRadio]. + +void main() => runApp(const CupertinoRadioApp()); + +class CupertinoRadioApp extends StatelessWidget { + const CupertinoRadioApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('CupertinoRadio Example'), + ), + child: SafeArea(child: CupertinoRadioExample()), + ), + ); + } +} + +enum SingingCharacter { lafayette, jefferson } + +class CupertinoRadioExample extends StatefulWidget { + const CupertinoRadioExample({super.key}); + + @override + State createState() => _CupertinoRadioExampleState(); +} + +class _CupertinoRadioExampleState extends State { + SingingCharacter? _character = .lafayette; + + @override + Widget build(BuildContext context) { + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('Lafayette'), + leading: CupertinoRadio( + value: SingingCharacter.lafayette, + ), + ), + CupertinoListTile( + title: Text('Thomas Jefferson'), + leading: CupertinoRadio( + value: SingingCharacter.jefferson, + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/radio/cupertino_radio.toggleable.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/radio/cupertino_radio.toggleable.0.dart new file mode 100644 index 000000000000..ae897a3a9c5d --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/radio/cupertino_radio.toggleable.0.dart @@ -0,0 +1,70 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoRadio.toggleable]. + +void main() => runApp(const CupertinoRadioApp()); + +class CupertinoRadioApp extends StatelessWidget { + const CupertinoRadioApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('CupertinoRadio Toggleable Example'), + ), + child: SafeArea(child: CupertinoRadioExample()), + ), + ); + } +} + +enum SingingCharacter { mulligan, hamilton } + +class CupertinoRadioExample extends StatefulWidget { + const CupertinoRadioExample({super.key}); + + @override + State createState() => _CupertinoRadioExampleState(); +} + +class _CupertinoRadioExampleState extends State { + SingingCharacter? _character = .mulligan; + + @override + Widget build(BuildContext context) { + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('Hercules Mulligan'), + leading: CupertinoRadio( + value: SingingCharacter.mulligan, + // TRY THIS: Try setting the toggleable value to false and + // see how that changes the behavior of the widget. + toggleable: true, + ), + ), + CupertinoListTile( + title: Text('Eliza Hamilton'), + leading: CupertinoRadio( + value: SingingCharacter.hamilton, + toggleable: true, + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/refresh/cupertino_sliver_refresh_control.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/refresh/cupertino_sliver_refresh_control.0.dart new file mode 100644 index 000000000000..d7139b6cc205 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/refresh/cupertino_sliver_refresh_control.0.dart @@ -0,0 +1,73 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSliverRefreshControl]. + +void main() => runApp(const RefreshControlApp()); + +class RefreshControlApp extends StatelessWidget { + const RefreshControlApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: RefreshControlExample(), + ); + } +} + +class RefreshControlExample extends StatefulWidget { + const RefreshControlExample({super.key}); + + @override + State createState() => _RefreshControlExampleState(); +} + +class _RefreshControlExampleState extends State { + List colors = [ + CupertinoColors.systemYellow, + CupertinoColors.systemOrange, + CupertinoColors.systemPink, + ]; + List items = [ + Container(color: CupertinoColors.systemPink, height: 100.0), + Container(color: CupertinoColors.systemOrange, height: 100.0), + Container(color: CupertinoColors.systemYellow, height: 100.0), + ]; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoSliverRefreshControl Sample'), + ), + child: CustomScrollView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + const CupertinoSliverNavigationBar(largeTitle: Text('Scroll down')), + CupertinoSliverRefreshControl( + onRefresh: () async { + await Future.delayed(const Duration(milliseconds: 1000)); + setState(() { + items.insert( + 0, + Container(color: colors[items.length % 3], height: 100.0), + ); + }); + }, + ), + SliverList.builder( + itemCount: items.length, + itemBuilder: (BuildContext context, int index) => items[index], + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/route/show_cupertino_dialog.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/route/show_cupertino_dialog.0.dart new file mode 100644 index 000000000000..1ec8ed42c3f9 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/route/show_cupertino_dialog.0.dart @@ -0,0 +1,71 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [showCupertinoDialog]. + +void main() => runApp(const CupertinoDialogApp()); + +class CupertinoDialogApp extends StatelessWidget { + const CupertinoDialogApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + restorationScopeId: 'app', + home: CupertinoDialogExample(), + ); + } +} + +class CupertinoDialogExample extends StatelessWidget { + const CupertinoDialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Home')), + child: Center( + child: CupertinoButton( + onPressed: () { + Navigator.of(context).restorablePush(_dialogBuilder); + }, + child: const Text('Open Dialog'), + ), + ), + ); + } + + @pragma('vm:entry-point') + static Route _dialogBuilder( + BuildContext context, + Object? arguments, + ) { + return CupertinoDialogRoute( + context: context, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('Title'), + content: const Text('Content'), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Yes'), + ), + CupertinoDialogAction( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('No'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/route/show_cupertino_modal_popup.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/route/show_cupertino_modal_popup.0.dart new file mode 100644 index 000000000000..cd5f2f3fa25e --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/route/show_cupertino_modal_popup.0.dart @@ -0,0 +1,67 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [showCupertinoModalPopup]. + +void main() => runApp(const ModalPopupApp()); + +class ModalPopupApp extends StatelessWidget { + const ModalPopupApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + restorationScopeId: 'app', + home: ModalPopupExample(), + ); + } +} + +class ModalPopupExample extends StatelessWidget { + const ModalPopupExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Home')), + child: Center( + child: CupertinoButton( + onPressed: () { + Navigator.of(context).restorablePush(_modalBuilder); + }, + child: const Text('Open Modal'), + ), + ), + ); + } + + @pragma('vm:entry-point') + static Route _modalBuilder(BuildContext context, Object? arguments) { + return CupertinoModalPopupRoute( + builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + child: const Text('Action One'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: const Text('Action Two'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/scrollbar/cupertino_scrollbar.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/scrollbar/cupertino_scrollbar.0.dart new file mode 100644 index 000000000000..0986d8476c73 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/scrollbar/cupertino_scrollbar.0.dart @@ -0,0 +1,51 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoScrollbar]. + +void main() => runApp(const ScrollbarApp()); + +class ScrollbarApp extends StatelessWidget { + const ScrollbarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: ScrollbarExample(), + ); + } +} + +class ScrollbarExample extends StatelessWidget { + const ScrollbarExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoScrollbar Sample'), + ), + child: CupertinoScrollbar( + thickness: 6.0, + thicknessWhileDragging: 10.0, + radius: const .circular(34.0), + radiusWhileDragging: Radius.zero, + child: ListView.builder( + itemCount: 120, + itemBuilder: (BuildContext context, int index) { + return Center( + child: Padding( + padding: const .all(8.0), + child: Text('Item $index'), + ), + ); + }, + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/scrollbar/cupertino_scrollbar.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/scrollbar/cupertino_scrollbar.1.dart new file mode 100644 index 000000000000..1d835cdd2fc0 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/scrollbar/cupertino_scrollbar.1.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. + +import 'package:flutter/cupertino.dart'; + +/// Flutter code sample for [CupertinoScrollbar]. + +void main() => runApp(const ScrollbarApp()); + +class ScrollbarApp extends StatelessWidget { + const ScrollbarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: ScrollbarExample(), + ); + } +} + +class ScrollbarExample extends StatefulWidget { + const ScrollbarExample({super.key}); + + @override + State createState() => _ScrollbarExampleState(); +} + +class _ScrollbarExampleState extends State { + final ScrollController _controllerOne = ScrollController(); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoScrollbar Sample'), + ), + child: CupertinoScrollbar( + thickness: 6.0, + thicknessWhileDragging: 10.0, + radius: const .circular(34.0), + radiusWhileDragging: Radius.zero, + controller: _controllerOne, + thumbVisibility: true, + child: ListView.builder( + controller: _controllerOne, + itemCount: 120, + itemBuilder: (BuildContext context, int index) { + return Center( + child: Padding( + padding: const .all(8.0), + child: Text('Item $index'), + ), + ); + }, + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/search_field/cupertino_search_field.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/search_field/cupertino_search_field.0.dart new file mode 100644 index 000000000000..3334921aac88 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/search_field/cupertino_search_field.0.dart @@ -0,0 +1,62 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSearchTextField]. + +void main() => runApp(const SearchTextFieldApp()); + +class SearchTextFieldApp extends StatelessWidget { + const SearchTextFieldApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SearchTextFieldExample(), + ); + } +} + +class SearchTextFieldExample extends StatefulWidget { + const SearchTextFieldExample({super.key}); + + @override + State createState() => _SearchTextFieldExampleState(); +} + +class _SearchTextFieldExampleState extends State { + late TextEditingController textController; + + @override + void initState() { + super.initState(); + textController = TextEditingController(text: 'initial text'); + } + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoSearchTextField Sample'), + ), + child: Center( + child: Padding( + padding: const .all(16.0), + child: CupertinoSearchTextField( + controller: textController, + placeholder: 'Search', + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/search_field/cupertino_search_field.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/search_field/cupertino_search_field.1.dart new file mode 100644 index 000000000000..5566a989a28d --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/search_field/cupertino_search_field.1.dart @@ -0,0 +1,77 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSearchTextField]. + +void main() => runApp(const SearchTextFieldApp()); + +class SearchTextFieldApp extends StatelessWidget { + const SearchTextFieldApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SearchTextFieldExample(), + ); + } +} + +class SearchTextFieldExample extends StatefulWidget { + const SearchTextFieldExample({super.key}); + + @override + State createState() => _SearchTextFieldExampleState(); +} + +class _SearchTextFieldExampleState extends State { + String text = ''; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoSearchTextField Sample'), + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text(text), + Padding( + padding: const .all(16.0), + child: SearchTextField( + fieldValue: (String value) { + setState(() { + text = value; + }); + }, + ), + ), + ], + ), + ), + ); + } +} + +class SearchTextField extends StatelessWidget { + const SearchTextField({super.key, required this.fieldValue}); + + final ValueChanged fieldValue; + + @override + Widget build(BuildContext context) { + return CupertinoSearchTextField( + onChanged: (String value) { + fieldValue('The text has changed to: $value'); + }, + onSubmitted: (String value) { + fieldValue('Submitted text: $value'); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/segmented_control/cupertino_segmented_control.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/segmented_control/cupertino_segmented_control.0.dart new file mode 100644 index 000000000000..6202ef975fe8 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/segmented_control/cupertino_segmented_control.0.dart @@ -0,0 +1,145 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSegmentedControl]. + +enum Sky { midnight, viridian, cerulean } + +Map skyColors = { + Sky.midnight: const Color(0xff191970), + Sky.viridian: const Color(0xff40826d), + Sky.cerulean: const Color(0xff007ba7), +}; + +void main() => runApp(const SegmentedControlApp()); + +class SegmentedControlApp extends StatelessWidget { + const SegmentedControlApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SegmentedControlExample(), + ); + } +} + +class SegmentedControlExample extends StatefulWidget { + const SegmentedControlExample({super.key}); + + @override + State createState() => + _SegmentedControlExampleState(); +} + +class _SegmentedControlExampleState extends State { + Sky _selectedSegment = .midnight; + bool _toggleOne = false; + bool _toggleAll = true; + Set _disabledChildren = {}; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: skyColors[_selectedSegment], + navigationBar: CupertinoNavigationBar( + // This Cupertino segmented control has the enum "Sky" as the type. + middle: CupertinoSegmentedControl( + disabledChildren: _disabledChildren, + selectedColor: skyColors[_selectedSegment], + // Provide horizontal padding around the children. + padding: const .symmetric(horizontal: 12), + // This represents a currently selected segmented control. + groupValue: _selectedSegment, + // Callback that sets the selected segmented control. + onValueChanged: (Sky value) { + setState(() { + _selectedSegment = value; + }); + }, + children: const { + Sky.midnight: Padding( + padding: .symmetric(horizontal: 20), + child: Text('Midnight'), + ), + Sky.viridian: Padding( + padding: .symmetric(horizontal: 20), + child: Text('Viridian'), + ), + Sky.cerulean: Padding( + padding: .symmetric(horizontal: 20), + child: Text('Cerulean'), + ), + }, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text( + 'Selected Segment: ${_selectedSegment.name}', + style: const TextStyle(color: CupertinoColors.white), + ), + const SizedBox(height: 20), + Row( + mainAxisSize: .min, + children: [ + const Text( + 'Disable one segment', + style: TextStyle(color: CupertinoColors.white), + ), + CupertinoSwitch( + value: _toggleOne, + onChanged: (bool value) { + setState(() { + _toggleOne = value; + if (value) { + _toggleAll = false; + _disabledChildren = {Sky.midnight}; + } else { + _toggleAll = true; + _disabledChildren = {}; + } + }); + }, + ), + ], + ), + Row( + mainAxisSize: .min, + children: [ + const Text( + 'Toggle all segments', + style: TextStyle(color: CupertinoColors.white), + ), + CupertinoSwitch( + value: _toggleAll, + onChanged: (bool value) { + setState(() { + _toggleAll = value; + if (value) { + _toggleOne = false; + _disabledChildren = {}; + } else { + _disabledChildren = { + Sky.midnight, + Sky.viridian, + Sky.cerulean, + }; + } + }); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/segmented_control/cupertino_sliding_segmented_control.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/segmented_control/cupertino_sliding_segmented_control.0.dart new file mode 100644 index 000000000000..3b71049d6262 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/segmented_control/cupertino_sliding_segmented_control.0.dart @@ -0,0 +1,119 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSlidingSegmentedControl]. + +enum Sky { midnight, viridian, cerulean } + +Map skyColors = { + Sky.midnight: const Color(0xff191970), + Sky.viridian: const Color(0xff40826d), + Sky.cerulean: const Color(0xff007ba7), +}; + +void main() => runApp(const SegmentedControlApp()); + +class SegmentedControlApp extends StatelessWidget { + const SegmentedControlApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: SegmentedControlExample(), + ); + } +} + +class SegmentedControlExample extends StatefulWidget { + const SegmentedControlExample({super.key}); + + @override + State createState() => + _SegmentedControlExampleState(); +} + +class _SegmentedControlExampleState extends State { + Sky _selectedSegment = .midnight; + bool _isMomentary = false; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: skyColors[_selectedSegment], + navigationBar: CupertinoNavigationBar( + // This Cupertino segmented control has the enum "Sky" as the type. + middle: CupertinoSlidingSegmentedControl( + backgroundColor: CupertinoColors.systemGrey2, + thumbColor: skyColors[_selectedSegment]!, + // This represents the currently selected segmented control. + groupValue: _selectedSegment, + isMomentary: _isMomentary, + // Callback that sets the selected segmented control. + onValueChanged: (Sky? value) { + if (value != null) { + setState(() { + _selectedSegment = value; + }); + } + }, + children: const { + Sky.midnight: Padding( + padding: .symmetric(horizontal: 20), + child: Text( + 'Midnight', + style: TextStyle(color: CupertinoColors.white), + ), + ), + Sky.viridian: Padding( + padding: .symmetric(horizontal: 20), + child: Text( + 'Viridian', + style: TextStyle(color: CupertinoColors.white), + ), + ), + Sky.cerulean: Padding( + padding: .symmetric(horizontal: 20), + child: Text( + 'Cerulean', + style: TextStyle(color: CupertinoColors.white), + ), + ), + }, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text( + 'Selected Segment: ${_selectedSegment.name}', + style: const TextStyle(color: CupertinoColors.white), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: .center, + children: [ + const Text( + 'Momentary mode: ', + style: TextStyle(color: CupertinoColors.white), + ), + CupertinoSwitch( + value: _isMomentary, + onChanged: (bool value) { + setState(() { + _isMomentary = value; + }); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.0.dart new file mode 100644 index 000000000000..5bb14c08e44e --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.0.dart @@ -0,0 +1,90 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSheetRoute]. + +void main() { + runApp(const CupertinoSheetApp()); +} + +class CupertinoSheetApp extends StatelessWidget { + const CupertinoSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(title: 'Cupertino Sheet', home: HomePage()); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoSheetRoute( + scrollableBuilder: + (BuildContext context, ScrollController controller) => + const _SheetScaffold(), + ), + ); + }, + child: const Text('Open Bottom Sheet'), + ), + ], + ), + ), + ); + } +} + +class _SheetScaffold extends StatelessWidget { + const _SheetScaffold(); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + const Text('CupertinoSheetRoute'), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).maybePop(); + }, + child: const Text('Go Back'), + ), + const Text('You can also close this sheet by dragging downwards'), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoSheetRoute( + scrollableBuilder: + (BuildContext context, ScrollController controller) => + const _SheetScaffold(), + ), + ); + }, + child: const Text('Push Another Sheet'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.1.dart new file mode 100644 index 000000000000..c149b926a864 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.1.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/cupertino.dart'; + +/// Flutter code sample for [showCupertinoSheet]. + +void main() { + runApp(const CupertinoSheetApp()); +} + +class CupertinoSheetApp extends StatelessWidget { + const CupertinoSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(title: 'Cupertino Sheet', home: HomePage()); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + CupertinoButton.filled( + onPressed: () { + showCupertinoSheet( + context: context, + useNestedNavigation: true, + scrollableBuilder: + (BuildContext context, ScrollController controller) => + const _SheetScaffold(), + ); + }, + child: const Text('Open Bottom Sheet'), + ), + ], + ), + ), + ); + } +} + +class _SheetScaffold extends StatelessWidget { + const _SheetScaffold(); + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold( + child: _SheetBody(title: 'CupertinoSheetRoute'), + ); + } +} + +class _SheetBody extends StatelessWidget { + const _SheetBody({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text(title), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).maybePop(); + }, + child: const Text('Go Back'), + ), + CupertinoButton.filled( + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + child: const Text('Pop Whole Sheet'), + ), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) => const _SheetNextPage(), + ), + ); + }, + child: const Text('Push Nested Page'), + ), + CupertinoButton.filled( + onPressed: () { + showCupertinoSheet( + context: context, + useNestedNavigation: true, + scrollableBuilder: + (BuildContext context, ScrollController controller) => + const _SheetScaffold(), + ); + }, + child: const Text('Push Another Sheet'), + ), + ], + ), + ); + } +} + +class _SheetNextPage extends StatelessWidget { + const _SheetNextPage(); + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold( + backgroundColor: CupertinoColors.activeOrange, + child: _SheetBody(title: 'Next Page'), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.2.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.2.dart new file mode 100644 index 000000000000..3912fed8a86a --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.2.dart @@ -0,0 +1,296 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSheetRoute] with restorable state and nested navigation. + +void main() => runApp(const RestorableSheetExampleApp()); + +class RestorableSheetExampleApp extends StatelessWidget { + const RestorableSheetExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + restorationScopeId: 'sheet-app', + title: 'Restorable Sheet', + home: RestorableSheet(restorationId: 'sheet'), + ); + } +} + +class RestorableSheet extends StatefulWidget { + const RestorableSheet({super.key, this.restorationId}); + + final String? restorationId; + + @override + State createState() => _RestorableSheetState(); +} + +@pragma('vm:entry-point') +class _RestorableSheetState extends State + with RestorationMixin { + final RestorableInt _counter = RestorableInt(0); + late RestorableRouteFuture _restorableSheetRouteFuture; + + @override + void initState() { + super.initState(); + _restorableSheetRouteFuture = RestorableRouteFuture( + onComplete: _changeCounter, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _counterSheetBuilder, + arguments: _counter.value, + ); + }, + ); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_counter, 'count'); + registerForRestoration(_restorableSheetRouteFuture, 'sheet_route_future'); + } + + @override + void dispose() { + _counter.dispose(); + super.dispose(); + } + + @pragma('vm:entry-point') + static Route _counterSheetBuilder( + BuildContext context, + Object? arguments, + ) { + return CupertinoSheetRoute( + scrollableBuilder: (BuildContext context, ScrollController controller) { + return Navigator( + restorationScopeId: 'nested-nav', + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return PopScope( + canPop: settings.name != '/', + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + return; + } + Navigator.of(context).pop(); + }, + child: CounterSheetScaffold(counter: arguments! as int), + ); + }, + ); + }, + ); + }, + ); + } + + void _changeCounter(int? newCounter) { + if (newCounter != null) { + setState(() { + _counter.value = newCounter; + }); + } + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + const Text('Counter current value:'), + Text('${_counter.value}'), + CupertinoButton( + child: const Text('Open Sheet'), + onPressed: () { + _restorableSheetRouteFuture.present(); + }, + ), + ], + ), + ), + ); + } +} + +class CounterSheetScaffold extends StatefulWidget { + const CounterSheetScaffold({super.key, required this.counter}); + + final int counter; + + @override + State createState() => _CounterSheetScaffoldState(); +} + +class _CounterSheetScaffoldState extends State + with RestorationMixin { + late RestorableInt _counter; + late RestorableRouteFuture _multiplicationRouteFuture; + + @override + void initState() { + super.initState(); + _counter = RestorableInt(widget.counter); + _multiplicationRouteFuture = RestorableRouteFuture( + onComplete: _changeCounter, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _multiplicationRouteBuilder, + arguments: _counter.value, + ); + }, + ); + } + + @pragma('vm:entry-point') + static Route _multiplicationRouteBuilder( + BuildContext context, + Object? arguments, + ) { + return CupertinoPageRoute( + settings: const RouteSettings(name: '/multiplication'), + builder: (BuildContext context) { + return MultiplicationPage(counter: arguments! as int); + }, + ); + } + + void _changeCounter(int? newCounter) { + if (newCounter != null) { + setState(() { + _counter.value = newCounter; + }); + } + } + + @override + String? get restorationId => 'sheet_scaffold'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_counter, 'sheet_counter'); + registerForRestoration(_multiplicationRouteFuture, 'multiplication_route'); + if (!_counter.enabled) { + _counter = RestorableInt(widget.counter); + } + } + + @override + void dispose() { + _counter.dispose(); + _multiplicationRouteFuture.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text('Current Count: ${_counter.value}'), + Row( + mainAxisAlignment: .center, + children: [ + CupertinoButton( + onPressed: () { + setState(() => _counter.value = _counter.value - 1); + }, + child: const Text('Decrease'), + ), + CupertinoButton( + onPressed: () { + setState(() => _counter.value = _counter.value + 1); + }, + child: const Text('Increase'), + ), + ], + ), + CupertinoButton( + onPressed: () => _multiplicationRouteFuture.present(), + child: const Text('Go to Multiplication Page'), + ), + CupertinoButton( + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(_counter.value), + child: const Text('Pop Sheet'), + ), + ], + ), + ), + ); + } +} + +class MultiplicationPage extends StatefulWidget { + const MultiplicationPage({super.key, required this.counter}); + + final int counter; + + @override + State createState() => _MultiplicationPageState(); +} + +class _MultiplicationPageState extends State + with RestorationMixin { + late final RestorableInt _counter = RestorableInt(widget.counter); + + @override + String? get restorationId => 'multiplication_page'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_counter, 'multi_counter'); + } + + @override + void dispose() { + _counter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + const Text('Current Count'), + Text(_counter.value.toString()), + CupertinoButton( + onPressed: () { + setState(() => _counter.value = _counter.value * 2); + }, + child: const Text('Double it'), + ), + CupertinoButton( + onPressed: () => Navigator.pop(context, _counter.value), + child: const Text('Pass it on to the last sheet'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.3.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.3.dart new file mode 100644 index 000000000000..08869001196c --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/sheet/cupertino_sheet.3.dart @@ -0,0 +1,98 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSheetRoute]. + +void main() { + runApp(const CupertinoSheetApp()); +} + +class CupertinoSheetApp extends StatelessWidget { + const CupertinoSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + title: 'Scrollable Cupertino Sheet', + home: HomePage(), + ); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Scrollable Cupertino Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoSheetRoute( + scrollableBuilder: + (BuildContext context, ScrollController controller) => + _ScrollableSheetBody(scrollController: controller), + ), + ); + }, + child: const Text('Open Sheet'), + ), + ], + ), + ), + ); + } +} + +class _ScrollableSheetBody extends StatelessWidget { + const _ScrollableSheetBody({required this.scrollController}); + + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + backgroundColor: CupertinoColors.systemGrey3, + middle: const Text('Scrollable Sheet'), + automaticBackgroundVisibility: false, + leading: CupertinoButton( + padding: .zero, + child: const Text('Close'), + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + ), + ), + child: CustomScrollView( + controller: scrollController, + primary: false, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate(( + BuildContext context, + int index, + ) { + return Container( + alignment: .center, + height: 100, + child: const Text('Scroll Me'), + ); + }, childCount: 20), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/slider/cupertino_slider.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/slider/cupertino_slider.0.dart new file mode 100644 index 000000000000..879c8f8ec6ba --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/slider/cupertino_slider.0.dart @@ -0,0 +1,86 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSlider]. + +void main() => runApp(const CupertinoSliderApp()); + +class CupertinoSliderApp extends StatelessWidget { + const CupertinoSliderApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoSliderExample(), + ); + } +} + +class CupertinoSliderExample extends StatefulWidget { + const CupertinoSliderExample({super.key}); + + @override + State createState() => _CupertinoSliderExampleState(); +} + +class _CupertinoSliderExampleState extends State { + double _currentSliderValue = 0.0; + String? _sliderStatus; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoSlider Sample'), + ), + child: Center( + child: Column( + mainAxisSize: .min, + children: [ + // Display the current slider value. + Text('$_currentSliderValue'), + CupertinoSlider( + key: const Key('slider'), + value: _currentSliderValue, + // This allows the slider to jump between divisions. + // If null, the slide movement is continuous. + divisions: 5, + // The maximum slider value + max: 100, + activeColor: CupertinoColors.systemPurple, + thumbColor: CupertinoColors.systemPurple, + // This is called when sliding is started. + onChangeStart: (double value) { + setState(() { + _sliderStatus = 'Sliding'; + }); + }, + // This is called when sliding has ended. + onChangeEnd: (double value) { + setState(() { + _sliderStatus = 'Finished sliding'; + }); + }, + // This is called when slider value is changed. + onChanged: (double value) { + setState(() { + _currentSliderValue = value; + }); + }, + ), + Text( + _sliderStatus ?? '', + style: CupertinoTheme.of( + context, + ).textTheme.textStyle.copyWith(fontSize: 12), + ), + ], + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/switch/cupertino_switch.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/switch/cupertino_switch.0.dart new file mode 100644 index 000000000000..e18fa1329164 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/switch/cupertino_switch.0.dart @@ -0,0 +1,54 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoSwitch]. + +void main() => runApp(const CupertinoSwitchApp()); + +class CupertinoSwitchApp extends StatelessWidget { + const CupertinoSwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoSwitchExample(), + ); + } +} + +class CupertinoSwitchExample extends StatefulWidget { + const CupertinoSwitchExample({super.key}); + + @override + State createState() => _CupertinoSwitchExampleState(); +} + +class _CupertinoSwitchExampleState extends State { + bool switchValue = true; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoSwitch Sample'), + ), + child: Center( + child: CupertinoSwitch( + // This bool value toggles the switch. + value: switchValue, + activeTrackColor: CupertinoColors.activeBlue, + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + switchValue = value; + }); + }, + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/tab_scaffold/cupertino_tab_controller.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/tab_scaffold/cupertino_tab_controller.0.dart new file mode 100644 index 000000000000..dc248b4766e3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/tab_scaffold/cupertino_tab_controller.0.dart @@ -0,0 +1,72 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoTabController]. + +void main() => runApp(const TabControllerApp()); + +class TabControllerApp extends StatelessWidget { + const TabControllerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: TabControllerExample(), + ); + } +} + +class TabControllerExample extends StatefulWidget { + const TabControllerExample({super.key}); + + @override + State createState() => _TabControllerExampleState(); +} + +class _TabControllerExampleState extends State { + final CupertinoTabController controller = CupertinoTabController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoTabScaffold( + controller: controller, + tabBar: CupertinoTabBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.square_grid_2x2_fill), + label: 'Browse', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.star_circle_fill), + label: 'Starred', + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Text('Content of tab $index'), + const SizedBox(height: 10), + CupertinoButton( + onPressed: () => controller.index = 0, + child: const Text('Go to first tab'), + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/tab_scaffold/cupertino_tab_scaffold.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/tab_scaffold/cupertino_tab_scaffold.0.dart new file mode 100644 index 000000000000..61e50873ee6a --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/tab_scaffold/cupertino_tab_scaffold.0.dart @@ -0,0 +1,85 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoTabScaffold]. + +void main() => runApp(const TabScaffoldApp()); + +class TabScaffoldApp extends StatelessWidget { + const TabScaffoldApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: TabScaffoldExample(), + ); + } +} + +class TabScaffoldExample extends StatefulWidget { + const TabScaffoldExample({super.key}); + + @override + State createState() => _TabScaffoldExampleState(); +} + +class _TabScaffoldExampleState extends State { + @override + Widget build(BuildContext context) { + return CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(CupertinoIcons.search_circle_fill), + label: 'Explore', + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + return CupertinoTabView( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 1 of tab $index'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Next page'), + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 2 of tab $index'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Back'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/text_field/cupertino_text_field.0.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/text_field/cupertino_text_field.0.dart new file mode 100644 index 000000000000..2df26382fd2e --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/text_field/cupertino_text_field.0.dart @@ -0,0 +1,55 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoTextField]. + +void main() => runApp(const CupertinoTextFieldApp()); + +class CupertinoTextFieldApp extends StatelessWidget { + const CupertinoTextFieldApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: CupertinoTextFieldExample(), + ); + } +} + +class CupertinoTextFieldExample extends StatefulWidget { + const CupertinoTextFieldExample({super.key}); + + @override + State createState() => + _CupertinoTextFieldExampleState(); +} + +class _CupertinoTextFieldExampleState extends State { + late TextEditingController _textController; + + @override + void initState() { + super.initState(); + _textController = TextEditingController(text: 'initial text'); + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoTextField Sample'), + ), + child: Center(child: CupertinoTextField(controller: _textController)), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/lib/text_form_field_row/cupertino_text_form_field_row.1.dart b/packages/cupertino_ui/cupertino_ui_examples/lib/text_form_field_row/cupertino_text_form_field_row.1.dart new file mode 100644 index 000000000000..89f88aac5826 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/lib/text_form_field_row/cupertino_text_form_field_row.1.dart @@ -0,0 +1,58 @@ +// 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/cupertino.dart'; + +/// Flutter code sample for [CupertinoTextFormFieldRow]. + +void main() => runApp(const FormSectionApp()); + +class FormSectionApp extends StatelessWidget { + const FormSectionApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: .light), + home: FromSectionExample(), + ); + } +} + +class FromSectionExample extends StatelessWidget { + const FromSectionExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('CupertinoFormSection Sample'), + ), + // Add safe area widget to place the CupertinoFormSection below the navigation bar. + child: SafeArea( + child: Form( + autovalidateMode: .always, + onChanged: () { + Form.maybeOf(primaryFocus!.context!)?.save(); + }, + child: CupertinoFormSection.insetGrouped( + header: const Text('SECTION 1'), + children: List.generate(5, (int index) { + return CupertinoTextFormFieldRow( + prefix: const Text('Enter text'), + placeholder: 'Enter text', + validator: (String? value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + return null; + }, + ); + }), + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/pubspec.yaml b/packages/cupertino_ui/cupertino_ui_examples/pubspec.yaml new file mode 100644 index 000000000000..41f1f0fa3e37 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/pubspec.yaml @@ -0,0 +1,37 @@ +name: cupertino_ui_examples +description: API code samples for the cupertino_ui package. +publish_to: 'none' + +version: 1.0.0 + +environment: + sdk: ^3.10.0-0 +resolution: workspace + +dependencies: + flutter: + sdk: flutter + + collection: any + vector_math: any + web: any + test: any + +dev_dependencies: + integration_test: + sdk: flutter + flutter_driver: + sdk: flutter + flutter_goldens: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_test: + sdk: flutter + flutter_web_plugins: + sdk: flutter + flutter_lints: ^6.0.0 + + +flutter: + uses-material-design: true diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/activity_indicator/cupertino_activity_indicator.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/activity_indicator/cupertino_activity_indicator.0_test.dart new file mode 100644 index 000000000000..70c380b660bb --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/activity_indicator/cupertino_activity_indicator.0_test.dart @@ -0,0 +1,55 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/activity_indicator/cupertino_activity_indicator.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Default and customized cupertino activity indicators', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoIndicatorApp()); + + // Cupertino activity indicator with default properties. + final Finder firstIndicator = find.byType(CupertinoActivityIndicator).at(0); + expect( + tester.widget(firstIndicator).animating, + true, + ); + expect( + tester.widget(firstIndicator).radius, + 10.0, + ); + + // Cupertino activity indicator with custom radius and color. + final Finder secondIndicator = find + .byType(CupertinoActivityIndicator) + .at(1); + expect( + tester.widget(secondIndicator).animating, + true, + ); + expect( + tester.widget(secondIndicator).radius, + 20.0, + ); + expect( + tester.widget(secondIndicator).color, + CupertinoColors.activeBlue, + ); + + // Cupertino activity indicator with custom radius and disabled animation. + final Finder thirdIndicator = find.byType(CupertinoActivityIndicator).at(2); + expect( + tester.widget(thirdIndicator).animating, + false, + ); + expect( + tester.widget(thirdIndicator).radius, + 20.0, + ); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/activity_indicator/cupertino_linear_activity_indicator.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/activity_indicator/cupertino_linear_activity_indicator.0_test.dart new file mode 100644 index 000000000000..808856912910 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/activity_indicator/cupertino_linear_activity_indicator.0_test.dart @@ -0,0 +1,54 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/activity_indicator/cupertino_linear_activity_indicator.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Default and customized cupertino activity indicators', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.CupertinoLinearActivityIndicatorApp(), + ); + + final Finder firstIndicator = find + .byType(CupertinoLinearActivityIndicator) + .first; + final CupertinoLinearActivityIndicator firstWidget = tester + .widget(firstIndicator); + expect(firstWidget.progress, 0); + expect(firstWidget.height, 4.5); + expect(firstWidget.color, isNull); + + final Finder secondIndicator = find + .byType(CupertinoLinearActivityIndicator) + .at(1); + final CupertinoLinearActivityIndicator secondWidget = tester + .widget(secondIndicator); + expect(secondWidget.progress, 0.2); + expect(secondWidget.height, 4.5); + expect(secondWidget.color, isNull); + + final Finder thirdIndicator = find + .byType(CupertinoLinearActivityIndicator) + .at(2); + final CupertinoLinearActivityIndicator thirdWidget = tester + .widget(thirdIndicator); + expect(thirdWidget.progress, 0.4); + expect(thirdWidget.height, 10); + expect(thirdWidget.color, isNull); + + final Finder lastIndicator = find + .byType(CupertinoLinearActivityIndicator) + .last; + final CupertinoLinearActivityIndicator lastWidget = tester + .widget(lastIndicator); + expect(lastWidget.progress, 0.6); + expect(lastWidget.height, 4.5); + expect(lastWidget.color, CupertinoColors.activeGreen); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/bottom_tab_bar/cupertino_tab_bar.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/bottom_tab_bar/cupertino_tab_bar.0_test.dart new file mode 100644 index 000000000000..d4b8067f9a75 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/bottom_tab_bar/cupertino_tab_bar.0_test.dart @@ -0,0 +1,22 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/bottom_tab_bar/cupertino_tab_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can switch between tabs', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoTabBarApp()); + + expect(find.byType(CupertinoTabBar), findsOneWidget); + expect(find.text('Content of tab 0'), findsOneWidget); + + await tester.tap(find.text('Contacts')); + await tester.pump(); + + expect(find.text('Content of tab 2'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/button/cupertino_button.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/button/cupertino_button.0_test.dart new file mode 100644 index 000000000000..1c430ca7f5a6 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/button/cupertino_button.0_test.dart @@ -0,0 +1,30 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/button/cupertino_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has 4 CupertinoButton variants', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoButtonApp()); + + expect(find.byType(CupertinoButton), findsNWidgets(4)); + expect( + find.ancestor( + of: find.text('Enabled'), + matching: find.byType(CupertinoButton), + ), + findsNWidgets(2), + ); + expect( + find.ancestor( + of: find.text('Disabled'), + matching: find.byType(CupertinoButton), + ), + findsNWidgets(2), + ); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/checkbox/cupertino_checkbox.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/checkbox/cupertino_checkbox.0_test.dart new file mode 100644 index 000000000000..4e912434d521 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/checkbox/cupertino_checkbox.0_test.dart @@ -0,0 +1,40 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/checkbox/cupertino_checkbox.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Checkbox can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoCheckboxApp()); + + CupertinoCheckbox checkbox = tester.widget(find.byType(CupertinoCheckbox)); + + // Verify the initial state of the checkbox. + expect(checkbox.value, isTrue); + expect(checkbox.tristate, isTrue); + + // Tap the checkbox and verify the state change. + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pump(); + checkbox = tester.widget(find.byType(CupertinoCheckbox)); + + expect(checkbox.value, isNull); + + // Tap the checkbox and verify the state change. + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pump(); + checkbox = tester.widget(find.byType(CupertinoCheckbox)); + + expect(checkbox.value, isFalse); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pump(); + checkbox = tester.widget(find.byType(CupertinoCheckbox)); + + expect(checkbox.value, isTrue); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/context_menu/cupertino_context_menu.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/context_menu/cupertino_context_menu.0_test.dart new file mode 100644 index 000000000000..17422cd444b1 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/context_menu/cupertino_context_menu.0_test.dart @@ -0,0 +1,25 @@ +// 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:cupertino_ui_examples/context_menu/cupertino_context_menu.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open cupertino context menu', (WidgetTester tester) async { + await tester.pumpWidget(const example.ContextMenuApp()); + + final Offset logo = tester.getCenter(find.byType(FlutterLogo)); + expect(find.text('Favorite'), findsNothing); + + await tester.startGesture(logo); + await tester.pumpAndSettle(); + expect(find.text('Favorite'), findsOneWidget); + + await tester.tap(find.text('Favorite')); + await tester.pumpAndSettle(); + expect(find.text('Favorite'), findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/context_menu/cupertino_context_menu.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/context_menu/cupertino_context_menu.1_test.dart new file mode 100644 index 000000000000..05cc22208b10 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/context_menu/cupertino_context_menu.1_test.dart @@ -0,0 +1,25 @@ +// 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:cupertino_ui_examples/context_menu/cupertino_context_menu.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open cupertino context menu', (WidgetTester tester) async { + await tester.pumpWidget(const example.ContextMenuApp()); + + final Offset logo = tester.getCenter(find.byType(FlutterLogo)); + expect(find.text('Favorite'), findsNothing); + + await tester.startGesture(logo); + await tester.pumpAndSettle(); + expect(find.text('Favorite'), findsOneWidget); + + await tester.tap(find.text('Favorite')); + await tester.pumpAndSettle(); + expect(find.text('Favorite'), findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/date_picker/cupertino_date_picker.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/date_picker/cupertino_date_picker.0_test.dart new file mode 100644 index 000000000000..2cbcda88237a --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/date_picker/cupertino_date_picker.0_test.dart @@ -0,0 +1,103 @@ +// 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:cupertino_ui_examples/date_picker/cupertino_date_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset _kRowOffset = Offset(0.0, -50.0); + +void main() { + testWidgets('Can change date, time and dateTime using CupertinoDatePicker', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DatePickerApp()); + // Open the date picker. + await tester.tap(find.text('10-26-2016')); + await tester.pumpAndSettle(); + + // Drag month, day and year wheels to change the picked date. + await tester.drag( + find.text('October'), + _kRowOffset, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.drag( + find.textContaining('26').last, + _kRowOffset, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.drag( + find.text('2016'), + _kRowOffset, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Close the date picker. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('12-28-2018'), findsOneWidget); + + // Open the time picker. + await tester.tap(find.text('22:35')); + await tester.pumpAndSettle(); + + // Drag hour and minute wheels to change the picked time. + await tester.drag( + find.text('22'), + const Offset(0.0, 50.0), + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.drag( + find.text('35'), + const Offset(0.0, 50.0), + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Close the time picker. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('20:33'), findsOneWidget); + + // Open the dateTime picker. + await tester.tap(find.text('8-3-2016 17:45')); + await tester.pumpAndSettle(); + + // Drag hour and minute wheels to change the picked time. + await tester.drag( + find.text('17'), + const Offset(0.0, 50.0), + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.drag( + find.text('45'), + const Offset(0.0, 50.0), + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Close the dateTime picker. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('8-3-2016 15:43'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/date_picker/cupertino_timer_picker.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/date_picker/cupertino_timer_picker.0_test.dart new file mode 100644 index 000000000000..1485f996138a --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/date_picker/cupertino_timer_picker.0_test.dart @@ -0,0 +1,44 @@ +// 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:cupertino_ui_examples/date_picker/cupertino_timer_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset _kRowOffset = Offset(0.0, -50.0); + +void main() { + testWidgets('Can pick a duration from CupertinoTimerPicker', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TimerPickerApp()); + + // Launch the timer picker. + await tester.tap(find.text('1:23:00.000000')); + await tester.pumpAndSettle(); + + // Drag hour, minute to change the time. + await tester.drag( + find.text('1'), + _kRowOffset, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.drag( + find.text('23'), + _kRowOffset, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Close the timer picker. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('3:25:00.000000'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_action_sheet.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_action_sheet.0_test.dart new file mode 100644 index 000000000000..fac97cde86f2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_action_sheet.0_test.dart @@ -0,0 +1,28 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/dialog/cupertino_action_sheet.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Perform an action on CupertinoActionSheet', ( + WidgetTester tester, + ) async { + const String actionText = 'Destructive Action'; + await tester.pumpWidget(const example.ActionSheetApp()); + + // Launch the CupertinoActionSheet. + await tester.tap(find.byType(CupertinoButton)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(find.text(actionText), findsOneWidget); + + // Tap on an action to close the CupertinoActionSheet. + await tester.tap(find.text(actionText)); + await tester.pumpAndSettle(); + expect(find.text(actionText), findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_alert_dialog.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_alert_dialog.0_test.dart new file mode 100644 index 000000000000..afed9bd8705c --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_alert_dialog.0_test.dart @@ -0,0 +1,28 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/dialog/cupertino_alert_dialog.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Perform an action on CupertinoAlertDialog', ( + WidgetTester tester, + ) async { + const String actionText = 'Yes'; + await tester.pumpWidget(const example.AlertDialogApp()); + + // Launch the CupertinoAlertDialog. + await tester.tap(find.byType(CupertinoButton)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(find.text(actionText), findsOneWidget); + + // Tap on an action to close the CupertinoAlertDialog. + await tester.tap(find.text(actionText)); + await tester.pumpAndSettle(); + expect(find.text(actionText), findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_popup_surface.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_popup_surface.0_test.dart new file mode 100644 index 000000000000..5c90ddddce3e --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/dialog/cupertino_popup_surface.0_test.dart @@ -0,0 +1,76 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/dialog/cupertino_popup_surface.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoPopupSurface displays expected widgets in init state', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PopupSurfaceApp()); + + final Finder cupertinoButton = find.byType(CupertinoButton); + expect(cupertinoButton, findsOneWidget); + + final Finder cupertinoSwitch = find.byType(CupertinoSwitch); + expect(cupertinoSwitch, findsOneWidget); + }); + + testWidgets('CupertinoPopupSurface is displayed with painted surface', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PopupSurfaceApp()); + + // CupertinoSwitch is toggled on by default. + expect( + tester.widget(find.byType(CupertinoSwitch)).value, + isTrue, + ); + + // Tap on the CupertinoButton to show the CupertinoPopupSurface. + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + + // Make sure CupertinoPopupSurface is showing. + final Finder cupertinoPopupSurface = find.byType(CupertinoPopupSurface); + expect(cupertinoPopupSurface, findsOneWidget); + + // Confirm that CupertinoPopupSurface is painted with a ColoredBox. + final Finder coloredBox = find.descendant( + of: cupertinoPopupSurface, + matching: find.byType(ColoredBox), + ); + expect(coloredBox, findsOneWidget); + }); + + testWidgets('CupertinoPopupSurface is displayed without painted surface', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PopupSurfaceApp()); + + // Toggling off CupertinoSwitch and confirm its state. + final Finder cupertinoSwitch = find.byType(CupertinoSwitch); + await tester.tap(cupertinoSwitch); + await tester.pumpAndSettle(); + expect(tester.widget(cupertinoSwitch).value, isFalse); + + // Tap on the CupertinoButton to show the CupertinoPopupSurface. + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + + // Make sure CupertinoPopupSurface is showing. + final Finder cupertinoPopupSurface = find.byType(CupertinoPopupSurface); + expect(cupertinoPopupSurface, findsOneWidget); + + // Confirm that CupertinoPopupSurface is not painted with a ColoredBox. + final Finder coloredBox = find.descendant( + of: cupertinoPopupSurface, + matching: find.byType(ColoredBox), + ); + expect(coloredBox, findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/expansion_tile/cupertino_expansion_tile.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/expansion_tile/cupertino_expansion_tile.0_test.dart new file mode 100644 index 000000000000..e07135e296a3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/expansion_tile/cupertino_expansion_tile.0_test.dart @@ -0,0 +1,56 @@ +// 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:cupertino_ui_examples/expansion_tile/cupertino_expansion_tile.0.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoExpansionTile transition modes test', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const CupertinoExpansionTileApp()); + + // Check initial labels. + expect(find.text('Fade Transition - Tap to expand'), findsOneWidget); + expect(find.text('Scroll Transition - Tap to expand'), findsOneWidget); + + // Tap to expand the Fade Transition tile. + await tester.tap(find.text('Fade Transition - Tap to expand')); + await tester.pumpAndSettle(); + + // Check Fade is expanded. + expect(find.text('Fade Transition - Collapse me'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); + expect(find.text('Messages'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + + // Tap to collapse the Fade Transition tile. + await tester.tap(find.text('Fade Transition - Collapse me')); + await tester.pumpAndSettle(); + + // Ensure Fade is collapsed. + expect(find.text('Profile'), findsNothing); + expect(find.text('Messages'), findsNothing); + expect(find.text('Settings'), findsNothing); + + // Tap to expand Scroll Transition tile. + await tester.tap(find.text('Scroll Transition - Tap to expand')); + await tester.pumpAndSettle(); + + // Check Scroll is expanded. + expect(find.text('Scroll Transition - Collapse me'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); + expect(find.text('Messages'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + + // Tap to collapse the Scroll Transition tile. + await tester.tap(find.text('Scroll Transition - Collapse me')); + await tester.pumpAndSettle(); + + // Ensure Scroll is collapsed. + expect(find.text('Profile'), findsNothing); + expect(find.text('Messages'), findsNothing); + expect(find.text('Settings'), findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/form_row/cupertino_form_row.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/form_row/cupertino_form_row.0_test.dart new file mode 100644 index 000000000000..60297562583f --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/form_row/cupertino_form_row.0_test.dart @@ -0,0 +1,33 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/form_row/cupertino_form_row.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Cupertino form section displays cupertino form rows', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoFormRowApp()); + + expect(find.byType(CupertinoFormSection), findsOneWidget); + expect(find.byType(CupertinoFormRow), findsNWidgets(4)); + expect( + find.widgetWithText(CupertinoFormSection, 'Connectivity'), + findsOneWidget, + ); + expect( + find.widgetWithText(CupertinoFormRow, 'Airplane Mode'), + findsOneWidget, + ); + expect(find.widgetWithText(CupertinoFormRow, 'Wi-Fi'), findsOneWidget); + expect(find.widgetWithText(CupertinoFormRow, 'Bluetooth'), findsOneWidget); + expect( + find.widgetWithText(CupertinoFormRow, 'Mobile Data'), + findsOneWidget, + ); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/list_section/list_section_base.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/list_section/list_section_base.0_test.dart new file mode 100644 index 000000000000..34027fdb0c08 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/list_section/list_section_base.0_test.dart @@ -0,0 +1,31 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/list_section/list_section_base.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has exactly 1 CupertinoListSection base widget', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoListSectionBaseApp()); + + final Finder listSectionFinder = find.byType(CupertinoListSection); + expect(listSectionFinder, findsOneWidget); + + final CupertinoListSection listSectionWidget = tester + .widget(listSectionFinder); + expect(listSectionWidget.type, equals(CupertinoListSectionType.base)); + }); + + testWidgets('CupertinoListSection has 3 CupertinoListTile children', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoListSectionBaseApp()); + + expect(find.byType(CupertinoListTile), findsNWidgets(3)); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/list_section/list_section_inset.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/list_section/list_section_inset.0_test.dart new file mode 100644 index 000000000000..72c99d48a3e5 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/list_section/list_section_inset.0_test.dart @@ -0,0 +1,34 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/list_section/list_section_inset.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has exactly 1 CupertinoListSection inset grouped widget', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoListSectionInsetApp()); + + final Finder listSectionFinder = find.byType(CupertinoListSection); + expect(listSectionFinder, findsOneWidget); + + final CupertinoListSection listSectionWidget = tester + .widget(listSectionFinder); + expect( + listSectionWidget.type, + equals(CupertinoListSectionType.insetGrouped), + ); + }); + + testWidgets('CupertinoListSection has 3 CupertinoListTile children', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoListSectionInsetApp()); + + expect(find.byType(CupertinoListTile), findsNWidgets(3)); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/list_tile/cupertino_list_tile.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/list_tile/cupertino_list_tile.0_test.dart new file mode 100644 index 000000000000..389574d99ed3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/list_tile/cupertino_list_tile.0_test.dart @@ -0,0 +1,42 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:cupertino_ui_examples/list_tile/cupertino_list_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoListTile respects properties', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoListTileApp()); + + expect(find.text('CupertinoListTile Sample'), findsOne); + expect(find.byType(CupertinoListTile), findsNWidgets(6)); + + // Verify if the CupertinoListTile contains the expected widgets. + expect(find.byType(FlutterLogo), findsNWidgets(4)); + expect(find.text('One-line with leading widget'), findsOne); + expect(find.text('One-line with trailing widget'), findsOne); + expect(find.text('One-line CupertinoListTile'), findsOne); + expect(find.text('One-line with both widgets'), findsOne); + expect(find.text('Two-line CupertinoListTile'), findsOne); + expect(find.text('Here is a subtitle'), findsOne); + expect(find.text('CupertinoListTile with background color'), findsOne); + expect(find.byIcon(Icons.more_vert), findsNWidgets(3)); + expect(find.byIcon(Icons.info), findsOne); + + final Finder tileWithBackgroundFinder = find.byKey( + const Key('CupertinoListTile with background color'), + ); + expect( + tester + .firstWidget(tileWithBackgroundFinder) + .backgroundColor, + Colors.lightBlue, + ); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/menu_anchor/menu_anchor.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/menu_anchor/menu_anchor.0_test.dart new file mode 100644 index 000000000000..f8349f5089ac --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/menu_anchor/menu_anchor.0_test.dart @@ -0,0 +1,49 @@ +// 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/cupertino.dart'; +// Import the sample app. +import 'package:cupertino_ui_examples/menu_anchor/menu_anchor.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Menu opens and displays a Menu Item', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoMenuAnchorApp()); + + // The button should be present with initial label. + expect( + find.byIcon(CupertinoIcons.ellipsis_vertical_circle), + findsOneWidget, + ); + + // Tap the button to open the menu. + await tester.tap(find.byIcon(CupertinoIcons.ellipsis_vertical_circle)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Menu item should be visible. + expect(find.text('Menu Item'), findsOneWidget); + expect(find.text('Subtitle'), findsOneWidget); + expect(find.byIcon(CupertinoIcons.star), findsOneWidget); + }); + + testWidgets('Menu toggles open and close', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoMenuAnchorApp()); + + await tester.tap(find.byIcon(CupertinoIcons.ellipsis_vertical_circle)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Menu Item'), findsOneWidget); + + await tester.tap(find.byIcon(CupertinoIcons.ellipsis_vertical_circle)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Menu Item'), findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/menu_anchor/menu_anchor.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/menu_anchor/menu_anchor.1_test.dart new file mode 100644 index 000000000000..3eb6b7f021c0 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/menu_anchor/menu_anchor.1_test.dart @@ -0,0 +1,82 @@ +// 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/cupertino.dart'; +// Import the sample app. +import 'package:cupertino_ui_examples/menu_anchor/menu_anchor.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Opens menu and shows all items', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoMenuAnchorApp()); + + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Regular Item'), findsOneWidget); + expect(find.text('Colorful Item'), findsOneWidget); + expect(find.text('Destructive Item'), findsOneWidget); + expect(find.byType(CupertinoMenuDivider), findsOneWidget); + expect(find.byIcon(CupertinoIcons.delete), findsOneWidget); + }); + + testWidgets('Selecting each item updates the pressed label', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoMenuAnchorApp()); + + // Regular Item + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Regular Item')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('You Pressed: Regular Item'), findsOneWidget); + + // Colorful Item + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Colorful Item')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('You Pressed: Colorful Item'), findsOneWidget); + + // Destructive Item + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Destructive Item')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('You Pressed: Destructive Item'), findsOneWidget); + }); + + testWidgets('Tapping the button toggles menu open and close', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoMenuAnchorApp()); + + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Regular Item'), findsOneWidget); + + await tester.tap(find.text('Close Menu')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Regular Item'), findsNothing); + expect(find.text('Colorful Item'), findsNothing); + expect(find.text('Destructive Item'), findsNothing); + expect(find.text('Open Menu'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.0_test.dart new file mode 100644 index 000000000000..3b0a74891c51 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.0_test.dart @@ -0,0 +1,25 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/nav_bar/cupertino_navigation_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoNavigationBar is semi transparent', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavBarApp()); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + final CupertinoNavigationBar cupertinoNavigationBar = tester + .widget(navBarFinder); + expect( + cupertinoNavigationBar.backgroundColor, + CupertinoColors.systemGrey.withValues(alpha: 0.5), + ); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.1_test.dart new file mode 100644 index 000000000000..a2860b39d9aa --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.1_test.dart @@ -0,0 +1,28 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/nav_bar/cupertino_navigation_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoNavigationBar with bottom widget', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavBarApp()); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + final Finder searchFieldFinder = find.byType(CupertinoSearchTextField); + + expect(navBarFinder, findsOneWidget); + expect(searchFieldFinder, findsOneWidget); + + // The bottom widget is bounded by the navigation bar. + expect( + tester.getBottomLeft(searchFieldFinder).dy, + lessThan(tester.getBottomLeft(navBarFinder).dy), + ); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.2_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.2_test.dart new file mode 100644 index 000000000000..111f28416fa3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_navigation_bar.2_test.dart @@ -0,0 +1,24 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/nav_bar/cupertino_navigation_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoNavigationBar is large', (WidgetTester tester) async { + await tester.pumpWidget(const example.NavBarApp()); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + expect(find.text('Large Sample'), findsOneWidget); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + await tester.tap(find.text('Increment')); + await tester.pump(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.0_test.dart new file mode 100644 index 000000000000..1c9b0cdda693 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.0_test.dart @@ -0,0 +1,89 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/nav_bar/cupertino_sliver_nav_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset dragUp = Offset(0.0, -150.0); + +void setWindowToPortrait( + WidgetTester tester, { + Size size = const Size(2400.0, 3000.0), +}) { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); +} + +void main() { + testWidgets( + 'Collapse and expand CupertinoSliverNavigationBar changes title position', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Large title is visible and at lower position. + expect(tester.getBottomLeft(find.text('Contacts').first).dy, 88.0); + await tester.fling(find.text('Drag me up'), dragUp, 500.0); + await tester.pumpAndSettle(); + + // Large title is hidden and at higher position. + expect( + tester.getBottomLeft(find.text('Contacts').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + }, + ); + + testWidgets( + 'Middle widget is visible in both collapsed and expanded states', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page that has both middle and large titles. + final Finder nextButton = find.text('Go to Next Page'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + // Both middle and large titles are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + + await tester.fling(find.text('Drag me up'), dragUp, 500.0); + await tester.pumpAndSettle(); + + // Large title is hidden and middle title is visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect( + tester.getBottomLeft(find.text('Family').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + }, + ); + + testWidgets( + 'CupertinoSliverNavigationBar with previous route has back button', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page that has back button + final Finder nextButton = find.text('Go to Next Page'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + expect(nextButton, findsNothing); + + // Go back to the previous page. + final Finder backButton = find.byType(CupertinoButton); + expect(backButton, findsOneWidget); + await tester.tap(backButton); + await tester.pumpAndSettle(); + expect(nextButton, findsOneWidget); + }, + ); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.1_test.dart new file mode 100644 index 000000000000..a930f7e90a2e --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.1_test.dart @@ -0,0 +1,222 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/nav_bar/cupertino_sliver_nav_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset titleDragUp = Offset(0.0, -100.0); +const Offset bottomDragUp = Offset(0.0, -50.0); + +void setWindowToPortrait( + WidgetTester tester, { + Size size = const Size(2400.0, 3000.0), +}) { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); +} + +void main() { + testWidgets( + 'Collapse and expand CupertinoSliverNavigationBar changes title position', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Large title is visible and at lower position. + expect(tester.getBottomLeft(find.text('Contacts').first).dy, 88.0); + await tester.fling(find.text('Drag me up'), titleDragUp, 500.0); + await tester.pumpAndSettle(); + + // Large title is hidden and at higher position. + expect( + tester.getBottomLeft(find.text('Contacts').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + }, + ); + + testWidgets('Search field is hidden in bottom automatic mode', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page with bottom automatic mode. + final Finder nextButton = find.text('Bottom Automatic mode'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + // Middle, large title, and search field are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 96.0); + expect( + tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, + 132.0, + ); + + await tester.fling(find.text('Drag me up'), bottomDragUp, 50.0); + await tester.pumpAndSettle(); + + // Search field is hidden, but large title and middle title are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 96.0); + expect( + tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, + 96.0, + ); + + await tester.fling(find.text('Drag me up'), titleDragUp, 50.0); + await tester.pumpAndSettle(); + + // Large title and search field are hidden and middle title is visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect( + tester.getBottomLeft(find.text('Family').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + expect( + tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, + 44.0, + ); + }); + + testWidgets('Search field is always shown in bottom always mode', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page with bottom always mode. + final Finder nextButton = find.text('Bottom Always mode'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + // Middle, large title, and search field are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 96.0); + expect( + tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, + 132.0, + ); + + await tester.fling(find.text('Drag me up'), titleDragUp, 50.0); + await tester.pumpAndSettle(); + + // Large title is hidden, but search field and middle title are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect( + tester.getBottomLeft(find.text('Family').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 44.0); + expect( + tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, + 80.0, + ); + }); + + testWidgets('Opens the search view when the search field is tapped', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page with a search field. + final Finder nextButton = find.text('Bottom Automatic mode'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(CupertinoSearchTextField, 'Search'), + findsOneWidget, + ); + expect( + find.text('Tap on the search field to open the search view'), + findsOneWidget, + ); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Tap on the search field to open the search view. + await tester.tap( + find.byType(CupertinoSearchTextField), + warnIfMissed: false, + ); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(CupertinoSearchTextField, 'Enter search text'), + findsOneWidget, + ); + expect( + find.text('Tap on the search field to open the search view'), + findsNothing, + ); + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'a'); + await tester.pumpAndSettle(); + + expect(find.text('The text has changed to: a'), findsOneWidget); + + // Tap on the 'Cancel' button to close the search view. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(CupertinoSearchTextField, 'Search'), + findsOneWidget, + ); + expect( + find.text('Tap on the search field to open the search view'), + findsOneWidget, + ); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + }); + + testWidgets( + 'CupertinoSliverNavigationBar with previous route has back button', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to the first page. + final Finder nextButton1 = find.text('Bottom Automatic mode'); + expect(nextButton1, findsOneWidget); + await tester.tap(nextButton1); + await tester.pumpAndSettle(); + expect(nextButton1, findsNothing); + + // Go back to the previous page. + final Finder backButton1 = find.byType(CupertinoButton).first; + expect(backButton1, findsOneWidget); + await tester.tap(backButton1); + await tester.pumpAndSettle(); + expect(nextButton1, findsOneWidget); + + // Navigate to the second page. + final Finder nextButton2 = find.text('Bottom Always mode'); + expect(nextButton2, findsOneWidget); + await tester.tap(nextButton2); + await tester.pumpAndSettle(); + expect(nextButton2, findsNothing); + + // Go back to the previous page. + final Finder backButton2 = find.byType(CupertinoButton).first; + expect(backButton2, findsOneWidget); + await tester.tap(backButton2); + await tester.pumpAndSettle(); + expect(nextButton2, findsOneWidget); + }, + ); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.2_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.2_test.dart new file mode 100644 index 000000000000..bc982d4f570b --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/nav_bar/cupertino_sliver_nav_bar.2_test.dart @@ -0,0 +1,107 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/nav_bar/cupertino_sliver_nav_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset dragUp = Offset(0.0, -150.0); + +void setWindowToPortrait( + WidgetTester tester, { + Size size = const Size(2400.0, 3000.0), +}) { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); +} + +void main() { + testWidgets('CupertinoSliverNavigationBar bottom widget', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + final Finder preferredSize = find.byType(PreferredSize); + final Finder coloredBox = find.descendant( + of: preferredSize, + matching: find.byType(ColoredBox), + ); + final Finder text = find.text('Bottom Widget'); + + expect(preferredSize, findsOneWidget); + expect(coloredBox, findsOneWidget); + expect(text, findsOneWidget); + }); + + testWidgets( + 'Collapse and expand CupertinoSliverNavigationBar changes title position', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Large title is visible and at lower position. + expect(tester.getBottomLeft(find.text('Contacts').first).dy, 88.0); + await tester.fling(find.text('Drag me up'), dragUp, 500.0); + await tester.pumpAndSettle(); + + // Large title is hidden and at higher position. + expect( + tester.getBottomLeft(find.text('Contacts').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + }, + ); + + testWidgets( + 'Middle widget is visible in both collapsed and expanded states', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page that has both middle and large titles. + final Finder nextButton = find.text('Go to Next Page'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + // Both middle and large titles are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + + await tester.fling(find.text('Drag me up'), dragUp, 500.0); + await tester.pumpAndSettle(); + + // Large title is hidden and middle title is visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect( + tester.getBottomLeft(find.text('Family').first).dy, + 36.0 + 8.0, + ); // Static part + _kNavBarBottomPadding. + }, + ); + + testWidgets( + 'CupertinoSliverNavigationBar with previous route has back button', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page that has a back button. + final Finder nextButton = find.text('Go to Next Page'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + expect(nextButton, findsNothing); + + // Go back to the previous page. + final Finder backButton = find.byType(CupertinoButton); + expect(backButton, findsOneWidget); + await tester.tap(backButton); + await tester.pumpAndSettle(); + expect(nextButton, findsOneWidget); + }, + ); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/page_scaffold/cupertino_page_scaffold.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/page_scaffold/cupertino_page_scaffold.0_test.dart new file mode 100644 index 000000000000..db65849a3162 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/page_scaffold/cupertino_page_scaffold.0_test.dart @@ -0,0 +1,20 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/page_scaffold/cupertino_page_scaffold.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can increment counter', (WidgetTester tester) async { + await tester.pumpWidget(const example.PageScaffoldApp()); + + expect(find.byType(CupertinoPageScaffold), findsOneWidget); + expect(find.text('You have pressed the button 0 times.'), findsOneWidget); + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.text('You have pressed the button 1 times.'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/picker/cupertino_picker.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/picker/cupertino_picker.0_test.dart new file mode 100644 index 000000000000..eb9c7495ee9b --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/picker/cupertino_picker.0_test.dart @@ -0,0 +1,51 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/picker/cupertino_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset _kRowOffset = Offset(0.0, -50.0); + +void main() { + testWidgets('Change selected fruit using CupertinoPicker', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoPickerApp()); + + // Open the Cupertino picker. + await tester.tap(find.widgetWithText(CupertinoButton, 'Apple')); + await tester.pumpAndSettle(); + + // Test the initial item. + CupertinoPicker picker = tester.widget( + find.byType(CupertinoPicker), + ); + expect(picker.scrollController!.initialItem, 0); + + // Drag the wheel to change fruit selection. + await tester.drag( + find.text('Mango'), + _kRowOffset, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Close the Cupertino picker. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CupertinoButton, 'Banana'), findsOneWidget); + + // Test if the initial item has updated. + await tester.tap(find.widgetWithText(CupertinoButton, 'Banana')); + await tester.pumpAndSettle(); + + picker = tester.widget(find.byType(CupertinoPicker)); + expect(picker.scrollController!.initialItem, 2); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/radio/cupertino_radio.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/radio/cupertino_radio.0_test.dart new file mode 100644 index 000000000000..6f0b530644ef --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/radio/cupertino_radio.0_test.dart @@ -0,0 +1,32 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/radio/cupertino_radio.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has 2 CupertinoRadio widgets', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoRadioApp()); + + expect( + find.byType(CupertinoRadio), + findsNWidgets(2), + ); + + RadioGroup group = tester.widget( + find.byType(RadioGroup), + ); + expect(group.groupValue, example.SingingCharacter.lafayette); + + await tester.tap( + find.byType(CupertinoRadio).last, + ); + await tester.pumpAndSettle(); + + group = tester.widget(find.byType(RadioGroup)); + expect(group.groupValue, example.SingingCharacter.jefferson); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/radio/cupertino_radio.toggleable.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/radio/cupertino_radio.toggleable.0_test.dart new file mode 100644 index 000000000000..7d0dc4039161 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/radio/cupertino_radio.toggleable.0_test.dart @@ -0,0 +1,42 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/radio/cupertino_radio.toggleable.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has 2 CupertinoRadio widgets that can be toggled off', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoRadioApp()); + + expect( + find.byType(CupertinoRadio), + findsNWidgets(2), + ); + + RadioGroup group = tester.widget( + find.byType(RadioGroup), + ); + expect(group.groupValue, example.SingingCharacter.mulligan); + + await tester.tap( + find.byType(CupertinoRadio).last, + ); + await tester.pumpAndSettle(); + + group = tester.widget(find.byType(RadioGroup)); + expect(group.groupValue, example.SingingCharacter.hamilton); + + await tester.tap( + find.byType(CupertinoRadio).last, + ); + await tester.pumpAndSettle(); + + group = tester.widget(find.byType(RadioGroup)); + expect(group.groupValue, null); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/refresh/cupertino_sliver_refresh_control.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/refresh/cupertino_sliver_refresh_control.0_test.dart new file mode 100644 index 000000000000..fd610048e144 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/refresh/cupertino_sliver_refresh_control.0_test.dart @@ -0,0 +1,27 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/refresh/cupertino_sliver_refresh_control.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can pull down to reveal CupertinoSliverRefreshControl', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RefreshControlApp()); + + expect(find.byType(CupertinoSliverRefreshControl), findsNothing); + expect(find.byType(Container), findsNWidgets(3)); + + final Finder firstItem = find.byType(Container).first; + await tester.drag(firstItem, const Offset(0.0, 150.0), touchSlopY: 0); + await tester.pump(); + expect(find.byType(CupertinoSliverRefreshControl), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoSliverRefreshControl), findsNothing); + expect(find.byType(Container), findsNWidgets(4)); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/route/show_cupertino_dialog.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/route/show_cupertino_dialog.0_test.dart new file mode 100644 index 000000000000..415a0574f896 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/route/show_cupertino_dialog.0_test.dart @@ -0,0 +1,27 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/route/show_cupertino_dialog.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino dialog', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoDialogApp()); + + final Finder dialogTitle = find.text('Title'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + await tester.tap(find.text('Yes')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/route/show_cupertino_modal_popup.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/route/show_cupertino_modal_popup.0_test.dart new file mode 100644 index 000000000000..fd02e89de188 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/route/show_cupertino_modal_popup.0_test.dart @@ -0,0 +1,27 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/route/show_cupertino_modal_popup.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino modal dialog', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ModalPopupApp()); + + final Finder actionOne = find.text('Action One'); + expect(actionOne, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(actionOne, findsOneWidget); + + await tester.tap(find.text('Action One')); + await tester.pumpAndSettle(); + expect(actionOne, findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/scrollbar/cupertino_scrollbar.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/scrollbar/cupertino_scrollbar.0_test.dart new file mode 100644 index 000000000000..ec1ac8c609d3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/scrollbar/cupertino_scrollbar.0_test.dart @@ -0,0 +1,29 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/scrollbar/cupertino_scrollbar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('List view displays CupertinoScrollbar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ScrollbarApp()); + + expect(find.text('Item 0'), findsOneWidget); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + ); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pumpAndSettle(); + expect(find.text('Item 0'), findsNothing); + + final Finder scrollbar = find.byType(CupertinoScrollbar); + expect(scrollbar, findsOneWidget); + expect(tester.getTopLeft(scrollbar).dy, 0.0); + expect(tester.getBottomLeft(scrollbar).dy, 600.0); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/scrollbar/cupertino_scrollbar.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/scrollbar/cupertino_scrollbar.1_test.dart new file mode 100644 index 000000000000..8683776c975b --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/scrollbar/cupertino_scrollbar.1_test.dart @@ -0,0 +1,29 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/scrollbar/cupertino_scrollbar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('List view displays CupertinoScrollbar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ScrollbarApp()); + + expect(find.text('Item 0'), findsOneWidget); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + ); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pumpAndSettle(); + expect(find.text('Item 0'), findsNothing); + + final Finder scrollbar = find.byType(CupertinoScrollbar); + expect(scrollbar, findsOneWidget); + expect(tester.getTopLeft(scrollbar).dy, 0.0); + expect(tester.getBottomLeft(scrollbar).dy, 600.0); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/search_field/cupertino_search_field.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/search_field/cupertino_search_field.0_test.dart new file mode 100644 index 000000000000..943c7ec566d3 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/search_field/cupertino_search_field.0_test.dart @@ -0,0 +1,27 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/search_field/cupertino_search_field.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoTextField has initial text', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SearchTextFieldApp()); + + expect(find.byType(CupertinoSearchTextField), findsOneWidget); + expect(find.text('initial text'), findsOneWidget); + + await tester.tap(find.byIcon(CupertinoIcons.xmark_circle_fill)); + await tester.pump(); + expect(find.text('initial text'), findsNothing); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'photos'); + await tester.pump(); + expect(find.text('photos'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/search_field/cupertino_search_field.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/search_field/cupertino_search_field.1_test.dart new file mode 100644 index 000000000000..b0a14ff5fb43 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/search_field/cupertino_search_field.1_test.dart @@ -0,0 +1,31 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/search_field/cupertino_search_field.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Value changed callback updates entered text', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SearchTextFieldApp()); + + expect(find.byType(CupertinoSearchTextField), findsOneWidget); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'photos'); + await tester.pump(); + expect(find.text('The text has changed to: photos'), findsOneWidget); + + await tester.enterText( + find.byType(CupertinoSearchTextField), + 'photos from vacation', + ); + await tester.showKeyboard(find.byType(CupertinoTextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(find.text('Submitted text: photos from vacation'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/segmented_control/cupertino_segmented_control.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/segmented_control/cupertino_segmented_control.0_test.dart new file mode 100644 index 000000000000..a850f286c352 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/segmented_control/cupertino_segmented_control.0_test.dart @@ -0,0 +1,107 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/segmented_control/cupertino_segmented_control.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Verify initial state', (WidgetTester tester) async { + await tester.pumpWidget(const example.SegmentedControlApp()); + + // Midnight is the default selected segment. + expect(find.text('Selected Segment: midnight'), findsOneWidget); + + // All segments are enabled and can be selected. + await tester.tap(find.text('Viridian')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: viridian'), findsOneWidget); + + await tester.tap(find.text('Cerulean')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: cerulean'), findsOneWidget); + + await tester.tap(find.text('Midnight')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: midnight'), findsOneWidget); + + // Verify that the first CupertinoSwitch is off. + final Finder firstSwitchFinder = find.byType(CupertinoSwitch).first; + final CupertinoSwitch firstSwitch = tester.widget( + firstSwitchFinder, + ); + expect(firstSwitch.value, false); + + // Verify that the second CupertinoSwitch is on. + final Finder secondSwitchFinder = find.byType(CupertinoSwitch).last; + final CupertinoSwitch secondSwitch = tester.widget( + secondSwitchFinder, + ); + expect(secondSwitch.value, true); + }); + + testWidgets('Can change a selected segmented control', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SegmentedControlApp()); + + expect(find.text('Selected Segment: midnight'), findsOneWidget); + + await tester.tap(find.text('Cerulean')); + await tester.pumpAndSettle(); + + expect(find.text('Selected Segment: cerulean'), findsOneWidget); + }); + + testWidgets('Can not select on a disabled segment', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SegmentedControlApp()); + + // Toggle on the first CupertinoSwitch to disable the first segment. + final Finder firstSwitchFinder = find.byType(CupertinoSwitch).first; + await tester.tap(firstSwitchFinder); + await tester.pumpAndSettle(); + final CupertinoSwitch firstSwitch = tester.widget( + firstSwitchFinder, + ); + expect(firstSwitch.value, true); + + // Tap on the second segment then tap back on the first segment. + // Verify that the selected segment is still the second segment. + await tester.tap(find.text('Viridian')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: viridian'), findsOneWidget); + + await tester.tap(find.text('Midnight')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: viridian'), findsOneWidget); + }); + + testWidgets('Can not select on all disabled segments', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SegmentedControlApp()); + + // Toggle off the second CupertinoSwitch to disable all segments. + final Finder secondSwitchFinder = find.byType(CupertinoSwitch).last; + await tester.tap(secondSwitchFinder); + await tester.pumpAndSettle(); + final CupertinoSwitch secondSwitch = tester.widget( + secondSwitchFinder, + ); + expect(secondSwitch.value, false); + + // Tap on the second segment and verify that the selected segment is still the first segment. + await tester.tap(find.text('Viridian')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: midnight'), findsOneWidget); + + // Tap on the third segment and verify that the selected segment is still the first segment. + await tester.tap(find.text('Cerulean')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: midnight'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/segmented_control/cupertino_sliding_segmented_control.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/segmented_control/cupertino_sliding_segmented_control.0_test.dart new file mode 100644 index 000000000000..c8c9400cca20 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/segmented_control/cupertino_sliding_segmented_control.0_test.dart @@ -0,0 +1,48 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/segmented_control/cupertino_sliding_segmented_control.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can change a selected segmented control', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SegmentedControlApp()); + + expect(find.text('Selected Segment: midnight'), findsOneWidget); + await tester.tap(find.text('Cerulean')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: cerulean'), findsOneWidget); + }); + + testWidgets('Can toggle momentary mode', (WidgetTester tester) async { + await tester.pumpWidget(const example.SegmentedControlApp()); + + // Verify momentary mode is initially off. + expect(find.text('Momentary mode: '), findsOneWidget); + final CupertinoSwitch momentarySwitch = tester.widget( + find.byType(CupertinoSwitch), + ); + expect(momentarySwitch.value, isFalse); + + // Toggle momentary mode on. + await tester.tap(find.byType(CupertinoSwitch)); + await tester.pumpAndSettle(); + + // Verify switch is now on. + final CupertinoSwitch updatedSwitch = tester.widget( + find.byType(CupertinoSwitch), + ); + expect(updatedSwitch.value, isTrue); + + // In momentary mode, tapping a segment should change the selection. + expect(find.text('Selected Segment: midnight'), findsOneWidget); + await tester.tap(find.text('Cerulean')); + await tester.pumpAndSettle(); + expect(find.text('Selected Segment: cerulean'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.0_test.dart new file mode 100644 index 000000000000..bff3493238be --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.0_test.dart @@ -0,0 +1,27 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/sheet/cupertino_sheet.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + final Finder dialogTitle = find.text('CupertinoSheetRoute'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + await tester.tap(find.text('Go Back')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.1_test.dart new file mode 100644 index 000000000000..2f050a6a19da --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.1_test.dart @@ -0,0 +1,63 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/sheet/cupertino_sheet.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + final Finder dialogTitle = find.text('CupertinoSheetRoute'); + final Finder nextPageTitle = find.text('Next Page'); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + expect(nextPageTitle, findsNothing); + + await tester.tap(find.text('Push Nested Page')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsOneWidget); + + await tester.tap(find.text('Push Another Sheet')); + await tester.pumpAndSettle(); + // Both titles are on the screen, though one is covered by the second sheet. + expect(dialogTitle, findsOneWidget); + expect(nextPageTitle, findsOneWidget); + + await tester.tap(find.text('Pop Whole Sheet').last); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsOneWidget); + + await tester.tap(find.text('Pop Whole Sheet')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsNothing); + }); + + testWidgets('Go Back button uses maybePop and handles edge cases', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.text('CupertinoSheetRoute'), findsOneWidget); + + await tester.tap(find.text('Go Back')); + await tester.pumpAndSettle(); + + expect(find.text('CupertinoSheetRoute'), findsNothing); + expect(find.text('Open Bottom Sheet'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.2_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.2_test.dart new file mode 100644 index 000000000000..5e4f43228e37 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.2_test.dart @@ -0,0 +1,50 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/sheet/cupertino_sheet.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RestorableSheetExampleApp()); + + final Finder dialogTitle = find.text('Current Count: 0'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + await tester.tap(find.text('Pop Sheet')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + }); + + testWidgets('State restoration keeps the counter at the right value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RestorableSheetExampleApp()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + + expect(find.text('Current Count: 0'), findsOneWidget); + + await tester.tap(find.text('Increase')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Increase')); + await tester.pumpAndSettle(); + + expect(find.text('Current Count: 2'), findsOneWidget); + + await tester.restartAndRestore(); + + expect(find.text('Current Count: 2'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.3_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.3_test.dart new file mode 100644 index 000000000000..60cece20a61b --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/sheet/cupertino_sheet.3_test.dart @@ -0,0 +1,65 @@ +// 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/rendering.dart'; +import 'package:cupertino_ui_examples/sheet/cupertino_sheet.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + final Finder dialogTitle = find.text('Scrollable Sheet'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + }); + + testWidgets('Drag on nav bar triggers drag only', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + final Finder dialogTitle = find.text('Scrollable Sheet'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + final RenderBox box = + tester.renderObject(find.text('Scrollable Sheet')) as RenderBox; + final Offset navbarOffset = box.localToGlobal(Offset.zero); + final double initialSheetHeight = navbarOffset.dy; + + final TestGesture gesture = await tester.startGesture(navbarOffset); + await gesture.moveBy(const Offset(0, -50)); + await tester.pump(); + + // Upwards drag triggers stretch, and not scroll. + final double currentSheetHeight = box.localToGlobal(Offset.zero).dy; + expect(currentSheetHeight, lessThan(initialSheetHeight)); + + await gesture.moveBy(const Offset(0, 50)); + await tester.pump(); + + await gesture.moveBy(const Offset(0, 200)); + await tester.pump(); + + final double finalSheetHeight = box.localToGlobal(Offset.zero).dy; + expect(finalSheetHeight, greaterThan(initialSheetHeight)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/slider/cupertino_slider.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/slider/cupertino_slider.0_test.dart new file mode 100644 index 000000000000..db7d72e9ce27 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/slider/cupertino_slider.0_test.dart @@ -0,0 +1,35 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/slider/cupertino_slider.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future dragSlider(WidgetTester tester, Key sliderKey) { + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + const double unit = CupertinoThumbPainter.radius; + const double delta = 3.0 * unit; + return tester.dragFrom( + topLeft + const Offset(unit, unit), + const Offset(delta, 0.0), + ); + } + + testWidgets('Can change value using CupertinoSlider', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSliderApp()); + + // Check for the initial slider value. + expect(find.text('0.0'), findsOneWidget); + + await dragSlider(tester, const Key('slider')); + await tester.pumpAndSettle(); + + // Check for the updated slider value. + expect(find.text('40.0'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/switch/cupertino_switch.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/switch/cupertino_switch.0_test.dart new file mode 100644 index 000000000000..618c2f519442 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/switch/cupertino_switch.0_test.dart @@ -0,0 +1,27 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/switch/cupertino_switch.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Toggling cupertino switch updates icon', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoSwitchApp()); + + final Finder switchFinder = find.byType(CupertinoSwitch); + CupertinoSwitch cupertinoSwitch = tester.widget( + switchFinder, + ); + expect(cupertinoSwitch.value, true); + + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + cupertinoSwitch = tester.widget(switchFinder); + expect(cupertinoSwitch.value, false); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/tab_scaffold/cupertino_tab_controller.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/tab_scaffold/cupertino_tab_controller.0_test.dart new file mode 100644 index 000000000000..a59d373d8ec2 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/tab_scaffold/cupertino_tab_controller.0_test.dart @@ -0,0 +1,25 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/tab_scaffold/cupertino_tab_controller.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can switch tabs using CupertinoTabController', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TabControllerApp()); + + expect(find.text('Content of tab 0'), findsOneWidget); + await tester.tap(find.byIcon(CupertinoIcons.star_circle_fill)); + await tester.pumpAndSettle(); + expect(find.text('Content of tab 1'), findsOneWidget); + + await tester.tap(find.text('Go to first tab')); + await tester.pumpAndSettle(); + expect(find.text('Content of tab 0'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/tab_scaffold/cupertino_tab_scaffold.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/tab_scaffold/cupertino_tab_scaffold.0_test.dart new file mode 100644 index 000000000000..d47cfe9cd559 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/tab_scaffold/cupertino_tab_scaffold.0_test.dart @@ -0,0 +1,28 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/tab_scaffold/cupertino_tab_scaffold.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can use CupertinoTabView as the root widget', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TabScaffoldApp()); + + expect(find.text('Page 1 of tab 0'), findsOneWidget); + await tester.tap(find.byIcon(CupertinoIcons.search_circle_fill)); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsOneWidget); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + await tester.tap(find.text('Back')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/text_field/cupertino_text_field.0_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/text_field/cupertino_text_field.0_test.dart new file mode 100644 index 000000000000..db057e498edc --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/text_field/cupertino_text_field.0_test.dart @@ -0,0 +1,24 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/text_field/cupertino_text_field.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoTextField has initial text', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CupertinoTextFieldApp()); + + expect(find.byType(CupertinoTextField), findsOneWidget); + expect(find.text('initial text'), findsOneWidget); + + await tester.enterText(find.byType(CupertinoTextField), 'new text'); + await tester.pump(); + + expect(find.text('new text'), findsOneWidget); + }); +} diff --git a/packages/cupertino_ui/cupertino_ui_examples/test/text_form_field_row/cupertino_text_form_field_row.1_test.dart b/packages/cupertino_ui/cupertino_ui_examples/test/text_form_field_row/cupertino_text_form_field_row.1_test.dart new file mode 100644 index 000000000000..6953dd1c5a69 --- /dev/null +++ b/packages/cupertino_ui/cupertino_ui_examples/test/text_form_field_row/cupertino_text_form_field_row.1_test.dart @@ -0,0 +1,33 @@ +// 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/cupertino.dart'; +import 'package:cupertino_ui_examples/text_form_field_row/cupertino_text_form_field_row.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can enter text in CupertinoTextFormFieldRow', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.FormSectionApp()); + + expect(find.byType(CupertinoFormSection), findsOneWidget); + expect(find.byType(CupertinoTextFormFieldRow), findsNWidgets(5)); + + expect( + find.widgetWithText(CupertinoTextFormFieldRow, 'abcd'), + findsNothing, + ); + await tester.enterText( + find.byType(CupertinoTextFormFieldRow).first, + 'abcd', + ); + await tester.pump(); + expect( + find.widgetWithText(CupertinoTextFormFieldRow, 'abcd'), + findsOneWidget, + ); + }); +} diff --git a/packages/cupertino_ui/lib/cupertino_ui.dart b/packages/cupertino_ui/lib/cupertino_ui.dart index 44384ed9496e..a44b2206137a 100644 --- a/packages/cupertino_ui/lib/cupertino_ui.dart +++ b/packages/cupertino_ui/lib/cupertino_ui.dart @@ -5,6 +5,73 @@ /// The Flutter Cupertino Design library. /// /// To use, import `package:cupertino_ui/cupertino_ui.dart`. +/// +/// This library is designed for apps that run on iOS. For apps that may also +/// run on other operating systems, we encourage use of other widgets, for +/// example the [Material +/// Design](https://docs.flutter.dev/ui/widgets/material) set. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=3PdUaidHc-E} +/// +/// See also: +/// +/// * [flutter.dev/widgets/cupertino](https://docs.flutter.dev/ui/widgets/cupertino) +/// for a catalog of all Cupertino widgets. +/// * [flutter.dev/widgets](https://docs.flutter.dev/ui/widgets) +/// for a catalog of commonly-used Flutter widgets. library cupertino_ui; -export 'package:flutter/cupertino.dart'; +export 'package:flutter/widgets.dart'; + +export 'src/activity_indicator.dart'; +export 'src/adaptive_text_selection_toolbar.dart'; +export 'src/app.dart'; +export 'src/bottom_tab_bar.dart'; +export 'src/button.dart'; +export 'src/checkbox.dart'; +export 'src/colors.dart'; +export 'src/constants.dart'; +export 'src/context_menu.dart'; +export 'src/context_menu_action.dart'; +export 'src/cupertino_focus_halo.dart'; +export 'src/date_picker.dart'; +export 'src/debug.dart'; +export 'src/desktop_text_selection.dart'; +export 'src/desktop_text_selection_toolbar.dart'; +export 'src/desktop_text_selection_toolbar_button.dart'; +export 'src/dialog.dart'; +export 'src/expansion_tile.dart'; +export 'src/form_row.dart'; +export 'src/form_section.dart'; +export 'src/icon_theme_data.dart'; +export 'src/icons.dart'; +export 'src/interface_level.dart'; +export 'src/list_section.dart'; +export 'src/list_tile.dart'; +export 'src/localizations.dart'; +export 'src/magnifier.dart'; +export 'src/menu_anchor.dart'; +export 'src/nav_bar.dart'; +export 'src/page_scaffold.dart'; +export 'src/picker.dart'; +export 'src/radio.dart'; +export 'src/refresh.dart'; +export 'src/route.dart'; +export 'src/scrollbar.dart'; +export 'src/search_field.dart'; +export 'src/segmented_control.dart'; +export 'src/sheet.dart'; +export 'src/slider.dart'; +export 'src/sliding_segmented_control.dart'; +export 'src/spell_check_suggestions_toolbar.dart'; +export 'src/switch.dart'; +export 'src/tab_scaffold.dart'; +export 'src/tab_view.dart'; +export 'src/text_field.dart'; +export 'src/text_form_field_row.dart'; +export 'src/text_selection.dart'; +export 'src/text_selection_toolbar.dart'; +export 'src/text_selection_toolbar_button.dart'; +export 'src/text_theme.dart'; +export 'src/theme.dart'; +export 'src/thumb_painter.dart'; diff --git a/packages/cupertino_ui/lib/src/activity_indicator.dart b/packages/cupertino_ui/lib/src/activity_indicator.dart new file mode 100644 index 000000000000..e741ab3d46ba --- /dev/null +++ b/packages/cupertino_ui/lib/src/activity_indicator.dart @@ -0,0 +1,299 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; + +const double _kDefaultIndicatorRadius = 10.0; + +// Extracted from iOS 13.2 Beta. +const Color _kActiveTickColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFF3C3C44), + darkColor: Color(0xFFEBEBF5), +); + +/// An iOS-style activity indicator that spins clockwise. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=AENVH-ZqKDQ} +/// +/// {@tool dartpad} +/// This example shows how [CupertinoActivityIndicator] can be customized. +/// +/// ** See code in examples/api/lib/cupertino/activity_indicator/cupertino_activity_indicator.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoLinearActivityIndicator], which displays progress along a line. +/// * +class CupertinoActivityIndicator extends StatefulWidget { + /// Creates an iOS-style activity indicator that spins clockwise. + const CupertinoActivityIndicator({ + super.key, + this.color, + this.animating = true, + this.radius = _kDefaultIndicatorRadius, + }) : assert(radius > 0.0), + progress = 1.0; + + /// Creates a non-animated iOS-style activity indicator that displays + /// a partial count of ticks based on the value of [progress]. + /// + /// When provided, the value of [progress] must be between 0.0 (zero ticks + /// will be shown) and 1.0 (all ticks will be shown) inclusive. Defaults + /// to 1.0. + const CupertinoActivityIndicator.partiallyRevealed({ + super.key, + this.color, + this.radius = _kDefaultIndicatorRadius, + this.progress = 1.0, + }) : assert(radius > 0.0), + assert(progress >= 0.0), + assert(progress <= 1.0), + animating = false; + + /// Color of the activity indicator. + /// + /// Defaults to color extracted from native iOS. + final Color? color; + + /// Whether the activity indicator is running its animation. + /// + /// Defaults to true. + final bool animating; + + /// Radius of the spinner widget. + /// + /// Defaults to 10 pixels. Must be positive. + final double radius; + + /// Determines the percentage of spinner ticks that will be shown. Typical usage would + /// display all ticks, however, this allows for more fine-grained control such as + /// during pull-to-refresh when the drag-down action shows one tick at a time as + /// the user continues to drag down. + /// + /// Defaults to one. Must be between zero and one, inclusive. + final double progress; + + @override + State createState() => _CupertinoActivityIndicatorState(); +} + +class _CupertinoActivityIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: const Duration(seconds: 1), vsync: this); + + if (widget.animating) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(CupertinoActivityIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.animating != oldWidget.animating) { + if (widget.animating) { + _controller.repeat(); + } else { + _controller.stop(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: widget.radius * 2, + child: CustomPaint( + painter: _CupertinoActivityIndicatorPainter( + position: _controller, + activeColor: widget.color ?? CupertinoDynamicColor.resolve(_kActiveTickColor, context), + radius: widget.radius, + progress: widget.progress, + ), + ), + ); + } +} + +const double _kTwoPI = math.pi * 2.0; + +/// Alpha values extracted from the native component (for both dark and light mode) to +/// draw the spinning ticks. +const List _kAlphaValues = [47, 47, 47, 47, 72, 97, 122, 147]; + +/// The alpha value that is used to draw the partially revealed ticks. +const int _partiallyRevealedAlpha = 147; + +class _CupertinoActivityIndicatorPainter extends CustomPainter { + _CupertinoActivityIndicatorPainter({ + required this.position, + required this.activeColor, + required this.radius, + required this.progress, + }) : tickFundamentalShape = RRect.fromLTRBXY( + -radius / _kDefaultIndicatorRadius, + -radius / 3.0, + radius / _kDefaultIndicatorRadius, + -radius, + radius / _kDefaultIndicatorRadius, + radius / _kDefaultIndicatorRadius, + ), + super(repaint: position); + + final Animation position; + final Color activeColor; + final double radius; + final double progress; + + // Use a RRect instead of RSuperellipse since this shape is really small + // and should make little visual difference. + final RRect tickFundamentalShape; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint(); + final int tickCount = _kAlphaValues.length; + + canvas.save(); + canvas.translate(size.width / 2.0, size.height / 2.0); + + final int activeTick = (tickCount * position.value).floor(); + + for (var i = 0; i < tickCount * progress; ++i) { + final int t = (i - activeTick) % tickCount; + paint.color = activeColor.withAlpha( + progress < 1 ? _partiallyRevealedAlpha : _kAlphaValues[t], + ); + canvas.drawRRect(tickFundamentalShape, paint); + canvas.rotate(_kTwoPI / tickCount); + } + + canvas.restore(); + } + + @override + bool shouldRepaint(_CupertinoActivityIndicatorPainter oldPainter) { + return oldPainter.position != position || + oldPainter.activeColor != activeColor || + oldPainter.progress != progress; + } +} + +/// An iOS-style linear activity indicator. +/// +/// The [CupertinoLinearActivityIndicator] is a linear progress bar that +/// displays a colored bar to indicate the progress of an ongoing task. +/// +/// {@tool dartpad} +/// This example shows how [CupertinoLinearActivityIndicator] can be customized. +/// +/// ** See code in examples/api/lib/cupertino/activity_indicator/cupertino_linear_activity_indicator.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoActivityIndicator], which is an iOS-style activity indicator that spins clockwise. +/// * +class CupertinoLinearActivityIndicator extends StatelessWidget { + /// Creates a linear iOS-style activity indicator. + const CupertinoLinearActivityIndicator({ + super.key, + required this.progress, + this.height = 4.5, + this.color, + }) : assert(height > 0), + assert(progress >= 0.0 && progress <= 1.0); + + /// The current progress of the linear activity indicator. + /// + /// This value must be between 0.0 and 1.0. A value of 0.0 means no progress + /// and 1.0 means that progress is complete. + final double progress; + + /// The height of the line used to draw the linear activity indicator. + /// + /// Defaults to 4.5 units. Must be positive. + final double height; + + /// The color of the progress bar. + /// + /// This color represents the portion of the bar that indicates progress. + /// + /// Defaults to [CupertinoColors.activeBlue] if no color is specified. + final Color? color; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(minHeight: height, minWidth: double.infinity), + child: CustomPaint( + painter: _CupertinoLinearActivityIndicator(progress: progress, color: color), + ), + ); + } +} + +class _CupertinoLinearActivityIndicator extends CustomPainter { + _CupertinoLinearActivityIndicator({required this.progress, this.color}) + : _backgroundPaint = Paint() + ..color = CupertinoColors.systemFill + ..style = PaintingStyle.fill, + _progressPaint = Paint() + ..color = color ?? CupertinoColors.activeBlue + ..style = PaintingStyle.fill; + + final double progress; + + final Color? color; + + /// The background paint used to draw the full width of the progress bar. + /// + /// This paint object is created once and reused to fill the background + /// with a system fill color. + final Paint _backgroundPaint; + + /// The paint used to draw the progress portion of the progress bar. + /// + /// This paint object is created once and reused to fill the progress area. + final Paint _progressPaint; + + @override + void paint(Canvas canvas, Size size) { + // Draw the background of the progress bar. + canvas.drawRRect( + BorderRadius.all(Radius.circular(size.height / 2)).toRRect(Offset.zero & size), + _backgroundPaint, + ); + + // Draw the progress portion of the bar. + if (progress > 0) { + canvas.drawRRect( + BorderRadius.all( + Radius.circular(size.height / 2), + ).toRRect(Offset.zero & Size(clampDouble(progress, 0.0, 1.0) * size.width, size.height)), + _progressPaint, + ); + } + } + + @override + bool shouldRepaint(_CupertinoLinearActivityIndicator old) => + old.progress != progress || old.color != color; +} diff --git a/packages/cupertino_ui/lib/src/adaptive_text_selection_toolbar.dart b/packages/cupertino_ui/lib/src/adaptive_text_selection_toolbar.dart new file mode 100644 index 000000000000..8f3198bd7147 --- /dev/null +++ b/packages/cupertino_ui/lib/src/adaptive_text_selection_toolbar.dart @@ -0,0 +1,237 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'desktop_text_selection_toolbar.dart'; +import 'desktop_text_selection_toolbar_button.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_button.dart'; + +/// The default Cupertino context menu for text selection for the current +/// platform with the given children. +/// +/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.platforms} +/// Builds the mobile Cupertino context menu on all mobile platforms, not just +/// iOS, and builds the desktop Cupertino context menu on all desktop platforms, +/// not just MacOS. For a widget that builds the native-looking context menu for +/// all platforms, see [AdaptiveTextSelectionToolbar]. +/// {@endtemplate} +/// +/// See also: +/// +/// * [AdaptiveTextSelectionToolbar], which does the same thing as this widget +/// but for all platforms, not just the Cupertino-styled platforms. +/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds +/// the Cupertino button Widgets for the current platform given +/// [ContextMenuButtonItem]s. +class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget { + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// given [children]. + /// + /// See also: + /// + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// * [CupertinoAdaptiveTextSelectionToolbar.buttonItems], which takes a list + /// of [ContextMenuButtonItem]s instead of [children] widgets. + /// {@endtemplate} + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// * [CupertinoAdaptiveTextSelectionToolbar.editable], which builds the + /// default Cupertino children for an editable field. + /// {@endtemplate} + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + /// * [CupertinoAdaptiveTextSelectionToolbar.editableText], which builds the + /// default Cupertino children for an [EditableText]. + /// {@endtemplate} + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + /// * [CupertinoAdaptiveTextSelectionToolbar.selectable], which builds the + /// Cupertino children for content that is selectable but not editable. + /// {@endtemplate} + const CupertinoAdaptiveTextSelectionToolbar({ + super.key, + required this.children, + required this.anchors, + }) : buttonItems = null; + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] whose + /// children will be built from the given [buttonItems]. + /// + /// See also: + /// + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// * [CupertinoAdaptiveTextSelectionToolbar.new], which takes the children + /// directly as a list of widgets. + /// {@endtemplate} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + const CupertinoAdaptiveTextSelectionToolbar.buttonItems({ + super.key, + required this.buttonItems, + required this.anchors, + }) : children = null; + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// default children for an editable field. + /// + /// If a callback is null, then its corresponding button will not be built. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.editable], which is similar to this but + /// includes Material and Cupertino toolbars. + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + CupertinoAdaptiveTextSelectionToolbar.editable({ + super.key, + required ClipboardStatus clipboardStatus, + required VoidCallback? onCopy, + required VoidCallback? onCut, + required VoidCallback? onPaste, + required VoidCallback? onSelectAll, + required VoidCallback? onLookUp, + required VoidCallback? onSearchWeb, + required VoidCallback? onShare, + required VoidCallback? onLiveTextInput, + required this.anchors, + }) : children = null, + buttonItems = EditableText.getEditableButtonItems( + clipboardStatus: clipboardStatus, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onSelectAll: onSelectAll, + onLookUp: onLookUp, + onSearchWeb: onSearchWeb, + onShare: onShare, + onLiveTextInput: onLiveTextInput, + ); + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// default children for an [EditableText]. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.editableText], which is similar to this + /// but includes Material and Cupertino toolbars. + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + CupertinoAdaptiveTextSelectionToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : children = null, + buttonItems = editableTextState.contextMenuButtonItems, + anchors = editableTextState.contextMenuAnchors; + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// default children for selectable, but not editable, content. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.selectable], which is similar to this but + /// includes Material and Cupertino toolbars. + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + CupertinoAdaptiveTextSelectionToolbar.selectable({ + super.key, + required VoidCallback onCopy, + required VoidCallback onSelectAll, + required SelectionGeometry selectionGeometry, + required this.anchors, + }) : children = null, + buttonItems = SelectableRegion.getSelectableButtonItems( + selectionGeometry: selectionGeometry, + onCopy: onCopy, + onSelectAll: onSelectAll, + onShare: null, // See https://github.com/flutter/flutter/issues/141775. + ); + + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors} + final TextSelectionToolbarAnchors anchors; + + /// The children of the toolbar, typically buttons. + final List? children; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets for the current platform. + final List? buttonItems; + + /// Returns a List of Widgets generated by turning [buttonItems] into the + /// default context menu buttons for Cupertino on the current platform. + /// + /// This is useful when building a text selection toolbar with the default + /// button appearance for the given platform, but where the toolbar and/or the + /// button actions and labels may be custom. + /// + /// Does not build Material buttons. On non-Apple platforms, Cupertino buttons + /// will still be used, because the Cupertino library does not access the + /// Material library. To get the native-looking buttons on every platform, + /// use [AdaptiveTextSelectionToolbar.getAdaptiveButtons] in the Material + /// library. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the Material + /// equivalent of this class and builds only the Material buttons. It + /// includes a live example of using `getAdaptiveButtons`. + static Iterable getAdaptiveButtons( + BuildContext context, + List buttonItems, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem(buttonItem: buttonItem); + }); + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoDesktopTextSelectionToolbarButton.buttonItem(buttonItem: buttonItem); + }); + } + } + + @override + Widget build(BuildContext context) { + // If there aren't any buttons to build, build an empty toolbar. + if ((children ?? buttonItems)?.isEmpty ?? true) { + return const SizedBox.shrink(); + } + + final List resultChildren = + children ?? getAdaptiveButtons(context, buttonItems!).toList(); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor, + children: resultChildren, + ); + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + return CupertinoDesktopTextSelectionToolbar( + anchor: anchors.primaryAnchor, + children: resultChildren, + ); + } + } +} diff --git a/packages/cupertino_ui/lib/src/app.dart b/packages/cupertino_ui/lib/src/app.dart new file mode 100644 index 000000000000..86fe08e600a6 --- /dev/null +++ b/packages/cupertino_ui/lib/src/app.dart @@ -0,0 +1,762 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'page_scaffold.dart'; +/// @docImport 'tab_view.dart'; +library; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'icons.dart'; +import 'interface_level.dart'; +import 'localizations.dart'; +import 'route.dart'; +import 'scrollbar.dart'; +import 'theme.dart'; + +/// An application that uses Cupertino design. +/// +/// A convenience widget that wraps a number of widgets that are commonly +/// required for an iOS-design targeting application. It builds upon a +/// [WidgetsApp] by iOS specific defaulting such as fonts and scrolling +/// physics. +/// +/// The [CupertinoApp] configures the top-level [Navigator] to search for routes +/// in the following order: +/// +/// 1. For the `/` route, the [home] property, if non-null, is used. +/// +/// 2. Otherwise, the [routes] table is used, if it has an entry for the route. +/// +/// 3. Otherwise, [onGenerateRoute] is called, if provided. It should return a +/// non-null value for any _valid_ route not handled by [home] and [routes]. +/// +/// 4. Finally if all else fails [onUnknownRoute] is called. +/// +/// If [home], [routes], [onGenerateRoute], and [onUnknownRoute] are all null, +/// and [builder] is not null, then no [Navigator] is created. +/// +/// This widget also configures the observer of the top-level [Navigator] (if +/// any) to perform [Hero] animations. +/// +/// The [CupertinoApp] widget isn't a required ancestor for other Cupertino +/// widgets, but many Cupertino widgets could depend on the [CupertinoTheme] +/// widget, which the [CupertinoApp] composes. If you use Material widgets, a +/// [MaterialApp] also creates the needed dependencies for Cupertino widgets. +/// +/// {@template flutter.cupertino.CupertinoApp.defaultSelectionStyle} +/// The [CupertinoApp] automatically creates a [DefaultSelectionStyle] with +/// selectionColor sets to [CupertinoThemeData.primaryColor] with 0.2 opacity +/// and cursorColor sets to [CupertinoThemeData.primaryColor]. +/// {@endtemplate} +/// +/// Use this widget with caution on Android since it may produce behaviors +/// Android users are not expecting such as: +/// +/// * Pages will be dismissible via a back swipe. +/// * Scrolling past extremities will trigger iOS-style spring overscrolls. +/// * The San Francisco font family is unavailable on Android and can result +/// in undefined font behavior. +/// +/// {@tool snippet} +/// This example shows how to create a [CupertinoApp] that disables the "debug" +/// banner with a [home] route that will be displayed when the app is launched. +/// +/// ![The CupertinoApp displays a CupertinoPageScaffold](https://flutter.github.io/assets-for-api-docs/assets/cupertino/basic_cupertino_app.png) +/// +/// ```dart +/// const CupertinoApp( +/// home: CupertinoPageScaffold( +/// navigationBar: CupertinoNavigationBar( +/// middle: Text('Home'), +/// ), +/// child: Center(child: Icon(CupertinoIcons.share)), +/// ), +/// debugShowCheckedModeBanner: false, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// This example shows how to create a [CupertinoApp] that uses the [routes] +/// `Map` to define the "home" route and an "about" route. +/// +/// ```dart +/// CupertinoApp( +/// routes: { +/// '/': (BuildContext context) { +/// return const CupertinoPageScaffold( +/// navigationBar: CupertinoNavigationBar( +/// middle: Text('Home Route'), +/// ), +/// child: Center(child: Icon(CupertinoIcons.share)), +/// ); +/// }, +/// '/about': (BuildContext context) { +/// return const CupertinoPageScaffold( +/// navigationBar: CupertinoNavigationBar( +/// middle: Text('About Route'), +/// ), +/// child: Center(child: Icon(CupertinoIcons.share)), +/// ); +/// } +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// This example shows how to create a [CupertinoApp] that defines a [theme] that +/// will be used for Cupertino widgets in the app. +/// +/// ![The CupertinoApp displays a CupertinoPageScaffold with orange-colored icons](https://flutter.github.io/assets-for-api-docs/assets/cupertino/theme_cupertino_app.png) +/// +/// ```dart +/// const CupertinoApp( +/// theme: CupertinoThemeData( +/// brightness: Brightness.dark, +/// primaryColor: CupertinoColors.systemOrange, +/// ), +/// home: CupertinoPageScaffold( +/// navigationBar: CupertinoNavigationBar( +/// middle: Text('CupertinoApp Theme'), +/// ), +/// child: Center(child: Icon(CupertinoIcons.share)), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoPageScaffold], which provides a standard page layout default +/// with nav bars. +/// * [Navigator], which is used to manage the app's stack of pages. +/// * [CupertinoPageRoute], which defines an app page that transitions in an +/// iOS-specific way. +/// * [WidgetsApp], which defines the basic app elements but does not depend +/// on the Cupertino library. +class CupertinoApp extends StatefulWidget { + /// Creates a CupertinoApp. + /// + /// At least one of [home], [routes], [onGenerateRoute], or [builder] must be + /// non-null. If only [routes] is given, it must include an entry for the + /// [Navigator.defaultRouteName] (`/`), since that is the route used when the + /// application is launched with an intent that specifies an otherwise + /// unsupported route. + /// + /// This class creates an instance of [WidgetsApp]. + const CupertinoApp({ + super.key, + this.navigatorKey, + this.home, + this.theme, + Map this.routes = const {}, + this.initialRoute, + this.onGenerateRoute, + this.onGenerateInitialRoutes, + this.onUnknownRoute, + this.onNavigationNotification, + List this.navigatorObservers = const [], + this.builder, + this.title, + this.onGenerateTitle, + this.color, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const [Locale('en', 'US')], + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + this.restorationScopeId, + this.scrollBehavior, + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'CupertinoApp never introduces its own MediaQuery; the View widget takes care of that. ' + 'This feature was deprecated after v3.7.0-29.0.pre.', + ) + this.useInheritedMediaQuery = false, + }) : routeInformationProvider = null, + routeInformationParser = null, + routerDelegate = null, + backButtonDispatcher = null, + routerConfig = null; + + /// Creates a [CupertinoApp] that uses the [Router] instead of a [Navigator]. + /// + /// {@macro flutter.widgets.WidgetsApp.router} + const CupertinoApp.router({ + super.key, + this.routeInformationProvider, + this.routeInformationParser, + this.routerDelegate, + this.backButtonDispatcher, + this.routerConfig, + this.theme, + this.builder, + this.title, + this.onGenerateTitle, + this.onNavigationNotification, + this.color, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const [Locale('en', 'US')], + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + this.restorationScopeId, + this.scrollBehavior, + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'CupertinoApp never introduces its own MediaQuery; the View widget takes care of that. ' + 'This feature was deprecated after v3.7.0-29.0.pre.', + ) + this.useInheritedMediaQuery = false, + }) : assert(routerDelegate != null || routerConfig != null), + navigatorObservers = null, + navigatorKey = null, + onGenerateRoute = null, + home = null, + onGenerateInitialRoutes = null, + onUnknownRoute = null, + routes = null, + initialRoute = null; + + /// {@macro flutter.widgets.widgetsApp.navigatorKey} + final GlobalKey? navigatorKey; + + /// {@macro flutter.widgets.widgetsApp.home} + final Widget? home; + + /// The top-level [CupertinoTheme] styling. + /// + /// A null [theme] or unspecified [theme] attributes will default to iOS + /// system values. + final CupertinoThemeData? theme; + + /// The application's top-level routing table. + /// + /// When a named route is pushed with [Navigator.pushNamed], the route name is + /// looked up in this map. If the name is present, the associated + /// [WidgetBuilder] is used to construct a [CupertinoPageRoute] that + /// performs an appropriate transition, including [Hero] animations, to the + /// new route. + /// + /// {@macro flutter.widgets.widgetsApp.routes} + final Map? routes; + + /// {@macro flutter.widgets.widgetsApp.initialRoute} + final String? initialRoute; + + /// {@macro flutter.widgets.widgetsApp.onGenerateRoute} + final RouteFactory? onGenerateRoute; + + /// {@macro flutter.widgets.widgetsApp.onGenerateInitialRoutes} + final InitialRouteListFactory? onGenerateInitialRoutes; + + /// {@macro flutter.widgets.widgetsApp.onUnknownRoute} + final RouteFactory? onUnknownRoute; + + /// {@macro flutter.widgets.widgetsApp.onNavigationNotification} + final NotificationListenerCallback? onNavigationNotification; + + /// {@macro flutter.widgets.widgetsApp.navigatorObservers} + final List? navigatorObservers; + + /// {@macro flutter.widgets.widgetsApp.routeInformationProvider} + final RouteInformationProvider? routeInformationProvider; + + /// {@macro flutter.widgets.widgetsApp.routeInformationParser} + final RouteInformationParser? routeInformationParser; + + /// {@macro flutter.widgets.widgetsApp.routerDelegate} + final RouterDelegate? routerDelegate; + + /// {@macro flutter.widgets.widgetsApp.backButtonDispatcher} + final BackButtonDispatcher? backButtonDispatcher; + + /// {@macro flutter.widgets.widgetsApp.routerConfig} + final RouterConfig? routerConfig; + + /// {@macro flutter.widgets.widgetsApp.builder} + final TransitionBuilder? builder; + + /// {@macro flutter.widgets.widgetsApp.title} + /// + /// This value is passed unmodified to [WidgetsApp.title]. + final String? title; + + /// {@macro flutter.widgets.widgetsApp.onGenerateTitle} + /// + /// This value is passed unmodified to [WidgetsApp.onGenerateTitle]. + final GenerateAppTitle? onGenerateTitle; + + /// {@macro flutter.widgets.widgetsApp.color} + final Color? color; + + /// {@macro flutter.widgets.widgetsApp.locale} + final Locale? locale; + + /// {@macro flutter.widgets.widgetsApp.localizationsDelegates} + final Iterable>? localizationsDelegates; + + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleListResolutionCallback? localeListResolutionCallback; + + /// {@macro flutter.widgets.LocaleResolutionCallback} + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleResolutionCallback? localeResolutionCallback; + + /// {@macro flutter.widgets.widgetsApp.supportedLocales} + /// + /// It is passed along unmodified to the [WidgetsApp] built by this widget. + final Iterable supportedLocales; + + /// Turns on a performance overlay. + /// + /// See also: + /// + /// * + final bool showPerformanceOverlay; + + /// Turns on checkerboarding of raster cache images. + final bool checkerboardRasterCacheImages; + + /// Turns on checkerboarding of layers rendered to offscreen bitmaps. + final bool checkerboardOffscreenLayers; + + /// Turns on an overlay that shows the accessibility information + /// reported by the framework. + final bool showSemanticsDebugger; + + /// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner} + final bool debugShowCheckedModeBanner; + + /// {@macro flutter.widgets.widgetsApp.shortcuts} + /// {@tool snippet} + /// This example shows how to add a single shortcut for + /// [LogicalKeyboardKey.select] to the default shortcuts without needing to + /// add your own [Shortcuts] widget. + /// + /// Alternatively, you could insert a [Shortcuts] widget with just the mapping + /// you want to add between the [WidgetsApp] and its child and get the same + /// effect. + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return WidgetsApp( + /// shortcuts: { + /// ... WidgetsApp.defaultShortcuts, + /// const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(), + /// }, + /// color: const Color(0xFFFF0000), + /// builder: (BuildContext context, Widget? child) { + /// return const Placeholder(); + /// }, + /// ); + /// } + /// ``` + /// {@end-tool} + /// {@macro flutter.widgets.widgetsApp.shortcuts.seeAlso} + final Map? shortcuts; + + /// {@macro flutter.widgets.widgetsApp.actions} + /// {@tool snippet} + /// This example shows how to add a single action handling an + /// [ActivateAction] to the default actions without needing to + /// add your own [Actions] widget. + /// + /// Alternatively, you could insert a [Actions] widget with just the mapping + /// you want to add between the [WidgetsApp] and its child and get the same + /// effect. + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return WidgetsApp( + /// actions: >{ + /// ... WidgetsApp.defaultActions, + /// ActivateAction: CallbackAction( + /// onInvoke: (Intent intent) { + /// // Do something here... + /// return null; + /// }, + /// ), + /// }, + /// color: const Color(0xFFFF0000), + /// builder: (BuildContext context, Widget? child) { + /// return const Placeholder(); + /// }, + /// ); + /// } + /// ``` + /// {@end-tool} + /// {@macro flutter.widgets.widgetsApp.actions.seeAlso} + final Map>? actions; + + /// {@macro flutter.widgets.widgetsApp.restorationScopeId} + final String? restorationScopeId; + + /// {@macro flutter.material.materialApp.scrollBehavior} + /// + /// When null, defaults to [CupertinoScrollBehavior]. + /// + /// See also: + /// + /// * [ScrollConfiguration], which controls how [Scrollable] widgets behave + /// in a subtree. + final ScrollBehavior? scrollBehavior; + + /// {@macro flutter.widgets.widgetsApp.useInheritedMediaQuery} + @Deprecated( + 'This setting is now ignored. ' + 'CupertinoApp never introduces its own MediaQuery; the View widget takes care of that. ' + 'This feature was deprecated after v3.7.0-29.0.pre.', + ) + final bool useInheritedMediaQuery; + + @override + State createState() => _CupertinoAppState(); + + /// The [HeroController] used for Cupertino page transitions. + /// + /// Used by [CupertinoTabView] and [CupertinoApp]. + static HeroController createCupertinoHeroController() => HeroController(); // Linear tweening. +} + +/// Describes how [Scrollable] widgets behave for [CupertinoApp]s. +/// +/// {@macro flutter.widgets.scrollBehavior} +/// +/// Setting a [CupertinoScrollBehavior] will result in descendant [Scrollable] widgets +/// using [BouncingScrollPhysics] by default. No [GlowingOverscrollIndicator] is +/// applied when using a [CupertinoScrollBehavior] either, regardless of platform. +/// When executing on desktop platforms, a [CupertinoScrollbar] is applied to the child. +/// +/// See also: +/// +/// * [ScrollBehavior], the default scrolling behavior extended by this class. +class CupertinoScrollBehavior extends ScrollBehavior { + /// Creates a CupertinoScrollBehavior that uses [BouncingScrollPhysics] and + /// adds [CupertinoScrollbar]s on desktop platforms. + const CupertinoScrollBehavior(); + + @override + Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) { + // When modifying this function, consider modifying the implementation in + // the base class as well. + switch (getPlatform(context)) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + assert(details.controller != null); + return CupertinoScrollbar(controller: details.controller, child: child); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + return child; + } + } + + @override + Widget buildOverscrollIndicator(BuildContext context, Widget child, ScrollableDetails details) { + // No overscroll indicator. + // When modifying this function, consider modifying the implementation in + // the base class as well. + return child; + } + + @override + ScrollPhysics getScrollPhysics(BuildContext context) { + // When modifying this function, consider modifying the implementation in + // the base class ScrollBehavior as well. + if (getPlatform(context) == TargetPlatform.macOS) { + return const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast); + } + return const BouncingScrollPhysics(); + } + + @override + MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) => + MultitouchDragStrategy.averageBoundaryPointers; +} + +class _CupertinoAppState extends State { + late HeroController _heroController; + bool get _usesRouter => widget.routerDelegate != null || widget.routerConfig != null; + + @override + void initState() { + super.initState(); + _heroController = CupertinoApp.createCupertinoHeroController(); + } + + @override + void dispose() { + _heroController.dispose(); + super.dispose(); + } + + // Combine the default localization for Cupertino with the ones contributed + // by the localizationsDelegates parameter, if any. Only the first delegate + // of a particular LocalizationsDelegate.type is loaded so the + // localizationsDelegate parameter can be used to override + // _CupertinoLocalizationsDelegate. + Iterable> get _localizationsDelegates { + return >[ + ...?widget.localizationsDelegates, + DefaultCupertinoLocalizations.delegate, + ]; + } + + Widget _exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticsLabel, + required GlobalKey key, + }) { + return _CupertinoInspectorButton.filled( + onPressed: onPressed, + semanticsLabel: semanticsLabel, + icon: CupertinoIcons.xmark, + buttonKey: key, + ); + } + + Widget _moveExitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticsLabel, + bool usesDefaultAlignment = true, + }) { + return _CupertinoInspectorButton.iconOnly( + onPressed: onPressed, + semanticsLabel: semanticsLabel, + icon: usesDefaultAlignment ? CupertinoIcons.arrow_right : CupertinoIcons.arrow_left, + ); + } + + Widget _tapBehaviorButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticsLabel, + required bool selectionOnTapEnabled, + }) { + return _CupertinoInspectorButton.toggle( + onPressed: onPressed, + semanticsLabel: semanticsLabel, + // This unicode icon is also used for the Material-styled button and for + // DevTools. It should be updated in all 3 places if changed. + icon: const IconData(0x1F74A), + toggledOn: selectionOnTapEnabled, + ); + } + + WidgetsApp _buildWidgetApp(BuildContext context) { + final CupertinoThemeData effectiveThemeData = CupertinoTheme.of(context); + final Color color = CupertinoDynamicColor.resolve( + widget.color ?? effectiveThemeData.primaryColor, + context, + ); + + if (_usesRouter) { + return WidgetsApp.router( + key: GlobalObjectKey(this), + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + routerConfig: widget.routerConfig, + backButtonDispatcher: widget.backButtonDispatcher, + onNavigationNotification: widget.onNavigationNotification, + builder: widget.builder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: effectiveThemeData.textTheme.textStyle, + color: color, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + ); + } + + return WidgetsApp( + key: GlobalObjectKey(this), + navigatorKey: widget.navigatorKey, + navigatorObservers: widget.navigatorObservers!, + pageRouteBuilder: (RouteSettings settings, WidgetBuilder builder) { + return CupertinoPageRoute(settings: settings, builder: builder); + }, + home: widget.home, + routes: widget.routes!, + initialRoute: widget.initialRoute, + onGenerateRoute: widget.onGenerateRoute, + onGenerateInitialRoutes: widget.onGenerateInitialRoutes, + onUnknownRoute: widget.onUnknownRoute, + onNavigationNotification: widget.onNavigationNotification, + builder: widget.builder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: effectiveThemeData.textTheme.textStyle, + color: color, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + ); + } + + @override + Widget build(BuildContext context) { + final CupertinoThemeData effectiveThemeData = (widget.theme ?? const CupertinoThemeData()) + .resolveFrom(context); + + // Prefer theme brightness if set, otherwise check system brightness. + final Brightness brightness = + effectiveThemeData.brightness ?? MediaQuery.platformBrightnessOf(context); + + SystemChrome.setSystemUIOverlayStyle( + brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + ); + + return ScrollConfiguration( + behavior: widget.scrollBehavior ?? const CupertinoScrollBehavior(), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: CupertinoTheme( + data: effectiveThemeData, + child: DefaultSelectionStyle( + selectionColor: effectiveThemeData.primaryColor.withOpacity(0.2), + cursorColor: effectiveThemeData.primaryColor, + child: HeroControllerScope( + controller: _heroController, + child: Builder(builder: _buildWidgetApp), + ), + ), + ), + ), + ); + } +} + +class _CupertinoInspectorButton extends InspectorButton { + const _CupertinoInspectorButton.filled({ + required super.onPressed, + required super.semanticsLabel, + required super.icon, + super.buttonKey, + }) : super.filled(); + + const _CupertinoInspectorButton.toggle({ + required super.onPressed, + required super.semanticsLabel, + required super.icon, + super.toggledOn, + }) : super.toggle(); + + const _CupertinoInspectorButton.iconOnly({ + required super.onPressed, + required super.semanticsLabel, + required super.icon, + }) : super.iconOnly(); + + @override + Widget build(BuildContext context) { + final buttonIcon = Icon( + icon, + semanticLabel: semanticsLabel, + size: iconSizeForVariant, + color: foregroundColor(context), + ); + + return Padding( + key: buttonKey, + padding: const EdgeInsets.all( + (kMinInteractiveDimensionCupertino - InspectorButton.buttonSize) / 2, + ), + child: variant == InspectorButtonVariant.toggle && !toggledOn! + ? CupertinoButton.tinted( + minSize: InspectorButton.buttonSize, + onPressed: onPressed, + padding: EdgeInsets.zero, + child: buttonIcon, + ) + : CupertinoButton( + minSize: InspectorButton.buttonSize, + onPressed: onPressed, + padding: EdgeInsets.zero, + color: backgroundColor(context), + child: buttonIcon, + ), + ); + } + + @override + Color foregroundColor(BuildContext context) { + final Color primaryColor = CupertinoTheme.of(context).primaryColor; + final Color secondaryColor = CupertinoTheme.of(context).primaryContrastingColor; + switch (variant) { + case InspectorButtonVariant.filled: + return secondaryColor; + case InspectorButtonVariant.iconOnly: + return primaryColor; + case InspectorButtonVariant.toggle: + return !toggledOn! ? primaryColor : secondaryColor; + } + } + + @override + Color backgroundColor(BuildContext context) { + final Color primaryColor = CupertinoTheme.of(context).primaryColor; + switch (variant) { + case InspectorButtonVariant.filled: + case InspectorButtonVariant.toggle: + return primaryColor; + case InspectorButtonVariant.iconOnly: + return const Color(0x00000000); + } + } +} diff --git a/packages/cupertino_ui/lib/src/bottom_tab_bar.dart b/packages/cupertino_ui/lib/src/bottom_tab_bar.dart new file mode 100644 index 000000000000..82722f77035b --- /dev/null +++ b/packages/cupertino_ui/lib/src/bottom_tab_bar.dart @@ -0,0 +1,306 @@ +// 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. + +/// @docImport 'tab_scaffold.dart'; +/// @docImport 'tab_view.dart'; +library; + +import 'dart:ui' show ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'localizations.dart'; +import 'theme.dart'; + +// Standard iOS 10 tab bar height. +const double _kTabBarHeight = 50.0; + +const Color _kDefaultTabBarBorderColor = CupertinoDynamicColor.withBrightness( + color: Color(0x4D000000), + darkColor: Color(0x29000000), +); +const Color _kDefaultTabBarInactiveColor = CupertinoColors.inactiveGray; + +/// An iOS-styled bottom navigation tab bar. +/// +/// Displays multiple tabs using [BottomNavigationBarItem] with one tab being +/// active, the first tab by default. +/// +/// This [StatelessWidget] doesn't store the active tab itself. You must +/// listen to the [onTap] callbacks and call `setState` with a new [currentIndex] +/// for the new selection to reflect. This can also be done automatically +/// by wrapping this with a [CupertinoTabScaffold]. +/// +/// Tab changes typically trigger a switch between [Navigator]s, each with its +/// own navigation stack, per standard iOS design. This can be done by using +/// [CupertinoTabView]s inside each tab builder in [CupertinoTabScaffold]. +/// +/// If the given [backgroundColor]'s opacity is not 1.0 (which is the case by +/// default), it will produce a blurring effect to the content behind it. +/// +/// When used as [CupertinoTabScaffold.tabBar], by default [CupertinoTabBar] +/// disables text scaling to match the native iOS behavior. To override +/// this behavior, wrap each of the `navigationBar`'s components inside a +/// [MediaQuery] with the desired [TextScaler]. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoTabBar] placed in a [CupertinoTabScaffold]. +/// +/// ** See code in examples/api/lib/cupertino/bottom_tab_bar/cupertino_tab_bar.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoTabScaffold], which hosts the [CupertinoTabBar] at the bottom. +/// * [BottomNavigationBarItem], an item in a [CupertinoTabBar]. +/// * +class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget { + /// Creates a tab bar in the iOS style. + const CupertinoTabBar({ + super.key, + required this.items, + this.onTap, + this.currentIndex = 0, + this.backgroundColor, + this.activeColor, + this.inactiveColor = _kDefaultTabBarInactiveColor, + this.iconSize = 30.0, + this.height = _kTabBarHeight, + this.border = const Border( + top: BorderSide( + color: _kDefaultTabBarBorderColor, + width: 0.0, // 0.0 means one physical pixel + ), + ), + }) : assert(items.length >= 2, "Tabs need at least 2 items to conform to Apple's HIG"), + assert(0 <= currentIndex && currentIndex < items.length), + assert(height >= 0.0); + + /// The interactive items laid out within the bottom navigation bar. + final List items; + + /// The callback that is called when a item is tapped. + /// + /// The widget creating the bottom navigation bar needs to keep track of the + /// current index and call `setState` to rebuild it with the newly provided + /// index. + final ValueChanged? onTap; + + /// The index into [items] of the current active item. + /// + /// Must be between 0 and the number of tabs minus 1, inclusive. + final int currentIndex; + + /// The background color of the tab bar. If it contains transparency, the + /// tab bar will automatically produce a blurring effect to the content + /// behind it. + /// + /// Defaults to [CupertinoTheme]'s `barBackgroundColor` when null. + final Color? backgroundColor; + + /// The foreground color of the icon and title for the [BottomNavigationBarItem] + /// of the selected tab. + /// + /// Defaults to [CupertinoTheme]'s `primaryColor` if null. + final Color? activeColor; + + /// The foreground color of the icon and title for the [BottomNavigationBarItem]s + /// in the unselected state. + /// + /// Defaults to a [CupertinoDynamicColor] that matches the disabled foreground + /// color of the native `UITabBar` component. + final Color inactiveColor; + + /// The size of all of the [BottomNavigationBarItem] icons. + /// + /// This value is used to configure the [IconTheme] for the navigation bar. + /// When a [BottomNavigationBarItem.icon] widget is not an [Icon] the widget + /// should configure itself to match the icon theme's size and color. + final double iconSize; + + /// The height of the [CupertinoTabBar]. + /// + /// Defaults to 50. + final double height; + + /// The border of the [CupertinoTabBar]. + /// + /// The default value is a one physical pixel top border with grey color. + final Border? border; + + @override + Size get preferredSize => Size.fromHeight(height); + + /// Indicates whether the tab bar is fully opaque or can have contents behind + /// it show through it. + bool opaque(BuildContext context) { + final Color backgroundColor = + this.backgroundColor ?? CupertinoTheme.of(context).barBackgroundColor; + return CupertinoDynamicColor.resolve(backgroundColor, context).alpha == 0xFF; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final double bottomPadding = MediaQuery.viewPaddingOf(context).bottom; + + final Color backgroundColor = CupertinoDynamicColor.resolve( + this.backgroundColor ?? CupertinoTheme.of(context).barBackgroundColor, + context, + ); + + BorderSide resolveBorderSide(BorderSide side) { + return side == BorderSide.none + ? side + : side.copyWith(color: CupertinoDynamicColor.resolve(side.color, context)); + } + + // Return the border as is when it's a subclass. + final Border? resolvedBorder = border == null || border.runtimeType != Border + ? border + : Border( + top: resolveBorderSide(border!.top), + left: resolveBorderSide(border!.left), + bottom: resolveBorderSide(border!.bottom), + right: resolveBorderSide(border!.right), + ); + + final Color inactive = CupertinoDynamicColor.resolve(inactiveColor, context); + Widget result = DecoratedBox( + decoration: BoxDecoration(border: resolvedBorder, color: backgroundColor), + child: SizedBox( + height: height + bottomPadding, + child: IconTheme.merge( + // Default with the inactive state. + data: IconThemeData(color: inactive, size: iconSize), + child: DefaultTextStyle( + // Default with the inactive state. + style: CupertinoTheme.of(context).textTheme.tabLabelTextStyle.copyWith(color: inactive), + child: Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: Semantics( + explicitChildNodes: true, + child: Row( + // Align bottom since we want the labels to be aligned. + crossAxisAlignment: CrossAxisAlignment.end, + children: _buildTabItems(context), + ), + ), + ), + ), + ), + ), + ); + + if (!opaque(context)) { + // For non-opaque backgrounds, apply a blur effect. + result = ClipRect( + child: BackdropFilter(filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), child: result), + ); + } + + return result; + } + + List _buildTabItems(BuildContext context) { + final result = []; + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + + for (var index = 0; index < items.length; index += 1) { + final active = index == currentIndex; + result.add( + _wrapActiveItem( + context, + Expanded( + // Make tab items part of the EditableText tap region so that + // switching tabs doesn't unfocus text fields. + child: TextFieldTapRegion( + child: Semantics( + selected: active, + hint: localizations.tabSemanticsLabel(tabIndex: index + 1, tabCount: items.length), + child: MouseRegion( + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap == null + ? null + : () { + onTap!(index); + }, + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: _buildSingleTabItem(items[index], active), + ), + ), + ), + ), + ), + ), + ), + active: active, + ), + ); + } + + return result; + } + + List _buildSingleTabItem(BottomNavigationBarItem item, bool active) { + return [ + Expanded(child: Center(child: active ? item.activeIcon : item.icon)), + if (item.label != null) Text(item.label!, semanticsLabel: item.semanticsLabel), + ]; + } + + /// Change the active tab item's icon and title colors to active. + Widget _wrapActiveItem(BuildContext context, Widget item, {required bool active}) { + if (!active) { + return item; + } + + final Color activeColor = CupertinoDynamicColor.resolve( + this.activeColor ?? CupertinoTheme.of(context).primaryColor, + context, + ); + return IconTheme.merge( + data: IconThemeData(color: activeColor), + child: DefaultTextStyle.merge( + style: TextStyle(color: activeColor), + child: item, + ), + ); + } + + /// Create a clone of the current [CupertinoTabBar] but with provided + /// parameters overridden. + CupertinoTabBar copyWith({ + Key? key, + List? items, + Color? backgroundColor, + Color? activeColor, + Color? inactiveColor, + double? iconSize, + double? height, + Border? border, + int? currentIndex, + ValueChanged? onTap, + }) { + return CupertinoTabBar( + key: key ?? this.key, + items: items ?? this.items, + backgroundColor: backgroundColor ?? this.backgroundColor, + activeColor: activeColor ?? this.activeColor, + inactiveColor: inactiveColor ?? this.inactiveColor, + iconSize: iconSize ?? this.iconSize, + height: height ?? this.height, + border: border ?? this.border, + currentIndex: currentIndex ?? this.currentIndex, + onTap: onTap ?? this.onTap, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/button.dart b/packages/cupertino_ui/lib/src/button.dart new file mode 100644 index 000000000000..919a0e8512c1 --- /dev/null +++ b/packages/cupertino_ui/lib/src/button.dart @@ -0,0 +1,609 @@ +// 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. + +/// @docImport 'nav_bar.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// Measured against iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). + +/// The size of a [CupertinoButton]. +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +enum CupertinoButtonSize { + /// Displays a smaller button with round sides and smaller text (uses [CupertinoTextThemeData.actionSmallTextStyle]). + small, + + /// Displays a medium sized button with round sides and regular-sized text. + medium, + + /// Displays a (classic) large button with rounded edges and regular-sized text. + large, +} + +/// The style of a [CupertinoButton] that changes the style of the button's background. +/// +/// Based on the iOS Human Interface Guidelines (https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +enum _CupertinoButtonStyle { + /// No background or border, primary foreground color. + plain, + + /// Translucent background, primary foreground color. + tinted, + + /// Solid background, contrasting foreground color. + filled, +} + +/// An iOS-style button. +/// +/// Takes in a text or an icon that fades out and in on touch. May optionally have a +/// background. +/// +/// The [padding] defaults to 16.0 pixels. When using a [CupertinoButton] within +/// a fixed height parent, like a [CupertinoNavigationBar], a smaller, or even +/// [EdgeInsets.zero], should be used to prevent clipping larger [child] +/// widgets. +/// +/// Preserves any parent [IconThemeData] but overwrites its [IconThemeData.color] +/// with the [CupertinoThemeData.primaryColor] (or +/// [CupertinoThemeData.primaryContrastingColor] if the button is disabled). +/// +/// {@tool dartpad} +/// This sample shows produces an enabled and disabled [CupertinoButton] and +/// [CupertinoButton.filled]. +/// +/// ** See code in examples/api/lib/cupertino/button/cupertino_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * +class CupertinoButton extends StatefulWidget { + /// Creates an iOS-style button. + const CupertinoButton({ + super.key, + required this.child, + this.sizeStyle = CupertinoButtonSize.large, + this.padding, + this.color, + this.foregroundColor, + this.disabledColor = CupertinoColors.quaternarySystemFill, + @Deprecated( + 'Use minimumSize instead. ' + 'This feature was deprecated after v3.28.0-3.0.pre.', + ) + this.minSize, + this.minimumSize, + this.pressedOpacity = 0.4, + this.borderRadius, + this.alignment = Alignment.center, + this.focusColor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.mouseCursor, + this.onLongPress, + required this.onPressed, + }) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)), + assert(minimumSize == null || minSize == null), + _style = _CupertinoButtonStyle.plain; + + /// Creates an iOS-style button with a tinted background. + /// + /// The background color is derived from the [CupertinoTheme]'s `primaryColor` + transparency. + /// The foreground color is the [CupertinoTheme]'s `primaryColor`. + /// + /// To specify a custom background color, use the [color] argument of the + /// default constructor. + /// + /// To match the iOS "grey" button style, set [color] to [CupertinoColors.systemGrey]. + const CupertinoButton.tinted({ + super.key, + required this.child, + this.sizeStyle = CupertinoButtonSize.large, + this.padding, + this.color, + this.foregroundColor, + this.disabledColor = CupertinoColors.tertiarySystemFill, + @Deprecated( + 'Use minimumSize instead. ' + 'This feature was deprecated after v3.28.0-3.0.pre.', + ) + this.minSize, + this.minimumSize, + this.pressedOpacity = 0.4, + this.borderRadius, + this.alignment = Alignment.center, + this.focusColor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.mouseCursor, + this.onLongPress, + required this.onPressed, + }) : assert(minimumSize == null || minSize == null), + _style = _CupertinoButtonStyle.tinted; + + /// Creates an iOS-style button with a filled background. + /// + /// The background color is derived from the [color] argument. + /// The foreground color is the [CupertinoTheme]'s `primaryContrastingColor`. + const CupertinoButton.filled({ + super.key, + required this.child, + this.sizeStyle = CupertinoButtonSize.large, + this.padding, + this.color, + this.disabledColor = CupertinoColors.tertiarySystemFill, + this.foregroundColor, + @Deprecated( + 'Use minimumSize instead. ' + 'This feature was deprecated after v3.28.0-3.0.pre.', + ) + this.minSize, + this.minimumSize, + this.pressedOpacity = 0.4, + this.borderRadius, + this.alignment = Alignment.center, + this.focusColor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.mouseCursor, + this.onLongPress, + required this.onPressed, + }) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)), + assert(minimumSize == null || minSize == null), + _style = _CupertinoButtonStyle.filled; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + /// The amount of space to surround the child inside the bounds of the button. + /// + /// Defaults to 16.0 pixels. + final EdgeInsetsGeometry? padding; + + /// The color of the button's background. + /// + /// Defaults to null which produces a button with no background or border. + /// + /// Defaults to the [CupertinoTheme]'s `primaryColor` when the + /// [CupertinoButton.filled] constructor is used. + final Color? color; + + /// The color of the button's background when the button is disabled. + /// + /// Ignored if the [CupertinoButton] doesn't also have a [color]. + /// + /// Defaults to [CupertinoColors.quaternarySystemFill] when [color] is + /// specified. + final Color disabledColor; + + /// The color of the button's text and icons. + /// + /// Defaults to the [CupertinoTheme]'s `primaryColor` when the + /// [CupertinoButton.filled] constructor is used. + final Color? foregroundColor; + + /// The callback that is called when the button is tapped or otherwise activated. + /// + /// If [onPressed] and [onLongPress] callbacks are null, then the button will be disabled. + final VoidCallback? onPressed; + + /// If [onPressed] and [onLongPress] callbacks are null, then the button will be disabled. + final VoidCallback? onLongPress; + + /// Minimum size of the button. + /// + /// Defaults to kMinInteractiveDimensionCupertino which the iOS Human + /// Interface Guidelines recommends as the minimum tappable area. + @Deprecated( + 'Use minimumSize instead. ' + 'This feature was deprecated after v3.28.0-3.0.pre.', + ) + final double? minSize; + + /// The minimum size of the button. + /// + /// Defaults to a button with a height and a width of + /// [kMinInteractiveDimensionCupertino], which the iOS Human + /// Interface Guidelines recommends as the minimum tappable area. + final Size? minimumSize; + + /// The opacity that the button will fade to when it is pressed. + /// The button will have an opacity of 1.0 when it is not pressed. + /// + /// This defaults to 0.4. If null, opacity will not change on pressed if using + /// your own custom effects is desired. + final double? pressedOpacity; + + /// The radius of the button's corners when it has a background color. + /// + /// Defaults to [kCupertinoButtonSizeBorderRadius], based on [sizeStyle]. + final BorderRadius? borderRadius; + + /// The size of the button. + /// + /// Defaults to [CupertinoButtonSize.large]. + final CupertinoButtonSize sizeStyle; + + /// The alignment of the button's [child]. + /// + /// Typically buttons are sized to be just big enough to contain the child and its + /// [padding]. If the button's size is constrained to a fixed size, for example by + /// enclosing it with a [SizedBox], this property defines how the child is aligned + /// within the available space. + /// + /// Always defaults to [Alignment.center]. + final AlignmentGeometry alignment; + + /// The color to use for the focus highlight for keyboard interactions. + /// + /// Defaults to a slightly transparent [color]. If [color] is null, defaults + /// to a slightly transparent [CupertinoColors.activeBlue]. Slightly + /// transparent in this context means the color is used with an opacity of + /// 0.80, a brightness of 0.69 and a saturation of 0.835. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The cursor for a mouse pointer when it enters or is hovering over the widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]: + /// * [WidgetState.disabled]. + /// * [WidgetState.pressed]. + /// * [WidgetState.focused]. + /// + /// If null, then [MouseCursor.defer] is used when the button is disabled. + /// When the button is enabled, [SystemMouseCursors.click] is used on Web + /// and [MouseCursor.defer] is used on other platforms. + /// + /// See also: + /// + /// * [WidgetStateMouseCursor], a [MouseCursor] that implements + /// [WidgetStateProperty] which is used in APIs that need to accept + /// either a [MouseCursor] or a [WidgetStateProperty]. + final MouseCursor? mouseCursor; + + final _CupertinoButtonStyle _style; + + /// Whether the button is enabled or disabled. Buttons are disabled by default. To + /// enable a button, set [onPressed] or [onLongPress] to a non-null value. + bool get enabled => onPressed != null || onLongPress != null; + + /// The distance a button needs to be moved after being pressed for its opacity to change. + /// + /// The opacity changes when the position moved is this distance away from the button. + static double tapMoveSlop() { + return switch (defaultTargetPlatform) { + TargetPlatform.iOS || + TargetPlatform.android || + TargetPlatform.fuchsia => kCupertinoButtonTapMoveSlop, + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 0.0, + }; + } + + @override + State createState() => _CupertinoButtonState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'disabled')); + } +} + +class _CupertinoButtonState extends State with SingleTickerProviderStateMixin { + // Eyeballed values. Feel free to tweak. + static const Duration kFadeOutDuration = Duration(milliseconds: 120); + static const Duration kFadeInDuration = Duration(milliseconds: 180); + final Tween _opacityTween = Tween(begin: 1.0); + + late AnimationController _animationController; + late Animation _opacityAnimation; + + late bool isFocused; + + static final WidgetStateProperty _defaultCursor = + WidgetStateProperty.resolveWith((Set states) { + return !states.contains(WidgetState.disabled) && kIsWeb + ? SystemMouseCursors.click + : MouseCursor.defer; + }); + + @override + void initState() { + super.initState(); + isFocused = false; + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + value: 0.0, + vsync: this, + ); + _opacityAnimation = _animationController + .drive(CurveTween(curve: Curves.decelerate)) + .drive(_opacityTween); + _setTween(); + } + + @override + void didUpdateWidget(CupertinoButton old) { + super.didUpdateWidget(old); + _setTween(); + } + + void _setTween() { + _opacityTween.end = widget.pressedOpacity ?? 1.0; + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + bool _buttonHeldDown = false; + bool _tapInProgress = false; + + void _handleTapDown(TapDownDetails event) { + setState(() { + _tapInProgress = true; + }); + if (!_buttonHeldDown) { + _buttonHeldDown = true; + _animate(); + } + } + + void _handleTapUp(TapUpDetails event) { + setState(() { + _tapInProgress = false; + }); + if (_buttonHeldDown) { + _buttonHeldDown = false; + _animate(); + } + final renderObject = context.findRenderObject()! as RenderBox; + final Offset localPosition = renderObject.globalToLocal(event.globalPosition); + if (renderObject.paintBounds.inflate(CupertinoButton.tapMoveSlop()).contains(localPosition)) { + _handleTap(); + } + } + + void _handleTapCancel() { + setState(() { + _tapInProgress = false; + }); + if (_buttonHeldDown) { + _buttonHeldDown = false; + _animate(); + } + } + + void _handleTapMove(TapMoveDetails event) { + final renderObject = context.findRenderObject()! as RenderBox; + final Offset localPosition = renderObject.globalToLocal(event.globalPosition); + final bool buttonShouldHeldDown = renderObject.paintBounds + .inflate(CupertinoButton.tapMoveSlop()) + .contains(localPosition); + if (_tapInProgress && buttonShouldHeldDown != _buttonHeldDown) { + _buttonHeldDown = buttonShouldHeldDown; + _animate(); + } + } + + void _handleTap([Intent? _]) { + if (widget.onPressed != null) { + widget.onPressed!(); + context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent()); + } + } + + void _animate() { + if (_animationController.isAnimating) { + return; + } + final bool wasHeldDown = _buttonHeldDown; + final TickerFuture ticker = _buttonHeldDown + ? _animationController.animateTo( + 1.0, + duration: kFadeOutDuration, + curve: Curves.easeInOutCubicEmphasized, + ) + : _animationController.animateTo( + 0.0, + duration: kFadeInDuration, + curve: Curves.easeOutCubic, + ); + ticker.then((void value) { + if (mounted && wasHeldDown != _buttonHeldDown) { + _animate(); + } + }); + } + + void _onShowFocusHighlight(bool showHighlight) { + setState(() { + isFocused = showHighlight; + }); + } + + late final Map> _actionMap = >{ + ActivateIntent: CallbackAction(onInvoke: _handleTap), + }; + + @override + Widget build(BuildContext context) { + final bool enabled = widget.enabled; + final Size? minimumSize = widget.minimumSize == null + ? widget.minSize == null + ? null + : Size(widget.minSize!, widget.minSize!) + : widget.minimumSize!; + final CupertinoThemeData themeData = CupertinoTheme.of(context); + final Color primaryColor = themeData.primaryColor; + final Color? backgroundColor = + (widget.color == null + ? widget._style != _CupertinoButtonStyle.plain + ? primaryColor + : null + : CupertinoDynamicColor.maybeResolve(widget.color, context)) + ?.withOpacity( + widget._style == _CupertinoButtonStyle.tinted + ? CupertinoTheme.brightnessOf(context) == Brightness.light + ? kCupertinoButtonTintedOpacityLight + : kCupertinoButtonTintedOpacityDark + : widget.color?.opacity ?? 1.0, + ); + final Color effectiveForegroundColor = + widget.foregroundColor ?? + switch ((widget._style, enabled)) { + (_CupertinoButtonStyle.filled, _) => themeData.primaryContrastingColor, + (_, true) => primaryColor, + (_, false) => CupertinoDynamicColor.resolve(CupertinoColors.tertiaryLabel, context), + }; + + final Color effectiveFocusOutlineColor = + widget.focusColor ?? + HSLColor.fromColor( + (backgroundColor ?? CupertinoColors.activeBlue).withOpacity( + kCupertinoFocusColorOpacity, + ), + ) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + + final TextStyle textStyle = + (widget.sizeStyle == CupertinoButtonSize.small + ? themeData.textTheme.actionSmallTextStyle + : themeData.textTheme.actionTextStyle) + .copyWith(color: effectiveForegroundColor); + final IconThemeData iconTheme = IconTheme.of(context).copyWith( + color: effectiveForegroundColor, + size: textStyle.fontSize != null + ? textStyle.fontSize! * 1.2 + : kCupertinoButtonDefaultIconSize, + ); + + final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + + final states = { + if (!enabled) WidgetState.disabled, + if (_tapInProgress) WidgetState.pressed, + if (isFocused) WidgetState.focused, + }; + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs(widget.mouseCursor, states) ?? + _defaultCursor.resolve(states); + + final shapeDecoration = ShapeDecoration( + shape: RoundedSuperellipseBorder( + side: enabled && isFocused + ? BorderSide( + color: effectiveFocusOutlineColor, + width: 3.5, + strokeAlign: BorderSide.strokeAlignOutside, + ) + : BorderSide.none, + borderRadius: widget.borderRadius ?? kCupertinoButtonSizeBorderRadius[widget.sizeStyle], + ), + color: backgroundColor != null && !enabled + ? CupertinoDynamicColor.resolve(widget.disabledColor, context) + : backgroundColor, + ); + + return MouseRegion( + cursor: effectiveMouseCursor, + child: FocusableActionDetector( + actions: _actionMap, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: widget.onFocusChange, + onShowFocusHighlight: _onShowFocusHighlight, + enabled: enabled, + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(postAcceptSlopTolerance: null), + (TapGestureRecognizer instance) { + instance.onTapDown = enabled ? _handleTapDown : null; + instance.onTapUp = enabled ? _handleTapUp : null; + instance.onTapCancel = enabled ? _handleTapCancel : null; + instance.onTapMove = enabled ? _handleTapMove : null; + instance.gestureSettings = gestureSettings; + }, + ), + if (widget.onLongPress != null) + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(), + (LongPressGestureRecognizer instance) { + instance.onLongPress = widget.onLongPress; + instance.gestureSettings = gestureSettings; + }, + ), + }, + child: Semantics( + button: true, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: + minimumSize?.width ?? + kCupertinoButtonMinSize[widget.sizeStyle] ?? + kMinInteractiveDimensionCupertino, + minHeight: + minimumSize?.height ?? + kCupertinoButtonMinSize[widget.sizeStyle] ?? + kMinInteractiveDimensionCupertino, + ), + child: FadeTransition( + opacity: _opacityAnimation, + child: DecoratedBox( + decoration: shapeDecoration, + child: Padding( + padding: widget.padding ?? kCupertinoButtonPadding[widget.sizeStyle]!, + child: Align( + alignment: widget.alignment, + widthFactor: 1.0, + heightFactor: 1.0, + child: DefaultTextStyle( + style: textStyle, + child: IconTheme(data: iconTheme, child: widget.child), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/checkbox.dart b/packages/cupertino_ui/lib/src/checkbox.dart new file mode 100644 index 000000000000..90d2e76e5e62 --- /dev/null +++ b/packages/cupertino_ui/lib/src/checkbox.dart @@ -0,0 +1,665 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'slider.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'theme.dart'; + +// Examples can assume: +// bool _throwShotAway = false; +// late StateSetter setState; + +// Eyeballed from a checkbox on a physical Macbook Pro running macOS version 14.5. +const Color _kDisabledCheckColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(64, 0, 0, 0), + darkColor: Color.fromARGB(64, 255, 255, 255), +); +const Color _kDisabledBorderColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(13, 0, 0, 0), + darkColor: Color.fromARGB(13, 0, 0, 0), +); +const CupertinoDynamicColor _kDefaultBorderColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(255, 209, 209, 214), + darkColor: Color.fromARGB(50, 128, 128, 128), +); +const CupertinoDynamicColor _kDefaultFillColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.activeBlue, + darkColor: Color.fromARGB(255, 50, 100, 215), +); +const Color _kDefaultCheckColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: Color.fromARGB(255, 222, 232, 248), +); +const double _kPressedOverlayOpacity = 0.15; +// In dark mode, the fill color of a checkbox is an opacity gradient of the +// background color. +const List _kDarkGradientOpacities = [0.14, 0.29]; +const List _kDisabledDarkGradientOpacities = [0.08, 0.14]; + +/// A macOS style checkbox. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ua54JU7k1Us} +/// +/// The checkbox itself does not maintain any state. Instead, when the state of +/// the checkbox changes, the widget calls the [onChanged] callback. Most +/// widgets that use a checkbox will listen for the [onChanged] callback and +/// rebuild the checkbox with a new [value] to update the visual appearance of +/// the checkbox. +/// +/// The checkbox can optionally display three values - true, false, and null - +/// if [tristate] is true. When [value] is null a dash is displayed. By default +/// [tristate] is false and the checkbox's [value] must be true or false. +/// +/// In the Apple Human Interface Guidelines (HIG), checkboxes are encouraged for +/// use on macOS, but is silent about their use on iOS. If a multi-selection +/// component is needed on iOS, the HIG encourages the developer to use switches +/// ([CupertinoSwitch] in Flutter) instead, or to find a creative custom +/// solution. +/// +/// Visually, the checkbox is a square of [CupertinoCheckbox.width] pixels. +/// However, the widget's tap target and layout size depend on the platform: +/// * On desktop devices, the tap target matches the visual size. +/// * On mobile devices, the tap target expands to a square of +/// [kMinInteractiveDimensionCupertino] pixels to meet accessibility +/// guidelines. +/// +/// {@tool dartpad} +/// This example shows a toggleable [CupertinoCheckbox]. +/// +/// ** See code in examples/api/lib/cupertino/checkbox/cupertino_checkbox.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Checkbox], the Material Design equivalent. +/// * [CupertinoSwitch], a widget with semantics similar to [CupertinoCheckbox]. +/// * [CupertinoSlider], for selecting a value in a range. +/// * +class CupertinoCheckbox extends StatefulWidget { + /// Creates a macOS-styled checkbox. + /// + /// The checkbox itself does not maintain any state. Instead, when the state of + /// the checkbox changes, the widget calls the [onChanged] callback. Most + /// widgets that use a checkbox will listen for the [onChanged] callback and + /// rebuild the checkbox with a new [value] to update the visual appearance of + /// the checkbox. + /// + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked. The [value] + /// can only be null if [tristate] is true. + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. + const CupertinoCheckbox({ + super.key, + required this.value, + this.tristate = false, + required this.onChanged, + this.mouseCursor, + this.activeColor, + @Deprecated( + 'Use fillColor instead. ' + 'fillColor now manages the background color in all states. ' + 'This feature was deprecated after v3.24.0-0.2.pre.', + ) + this.inactiveColor, + this.fillColor, + this.checkColor, + this.focusColor, + this.focusNode, + this.autofocus = false, + this.side, + this.shape, + this.tapTargetSize, + this.semanticLabel, + }) : assert(tristate || value != null); + + /// Whether this checkbox is checked. + /// + /// When [tristate] is true, a value of null corresponds to the mixed state. + /// When [tristate] is false, this value must not be null. This is asserted in + /// debug mode. + final bool? value; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox with the new + /// value. + /// + /// If this callback is null, the checkbox will be displayed as disabled + /// and will not respond to input gestures. + /// + /// When the checkbox is tapped, if [tristate] is false (the default) then + /// the [onChanged] callback will be applied to `!value`. If [tristate] is + /// true this callback cycle from false to true to null and back to false + /// again. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CupertinoCheckbox( + /// value: _throwShotAway, + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue!; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged? onChanged; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// When [value] is null and [tristate] is true, [WidgetState.selected] is + /// included as a state. + /// + /// If null, then [SystemMouseCursors.basic] is used when this checkbox is + /// disabled. When the checkbox is enabled, [SystemMouseCursors.click] is used + /// on Web, and [SystemMouseCursors.basic] is used on other platforms. + /// + /// See also: + /// + /// * [WidgetStateMouseCursor], a [MouseCursor] that implements + /// [WidgetStateProperty] which is used in APIs that need to accept + /// either a [MouseCursor] or a [WidgetStateProperty]. + final MouseCursor? mouseCursor; + + /// The color to use when this checkbox is checked. + /// + /// If [fillColor] returns a non-null color in the [WidgetState.selected] + /// state, [fillColor] will be used instead of [activeColor]. + /// + /// Defaults to [CupertinoColors.activeBlue]. + final Color? activeColor; + + /// {@template flutter.cupertino.CupertinoCheckbox.fillColor} + /// The color used to fill this checkbox. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [fillColor] based on the current [WidgetState] + /// of the [CupertinoCheckbox], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// CupertinoCheckbox( + /// value: true, + /// onChanged: (_){}, + /// fillColor: WidgetStateProperty.resolveWith((Set states) { + /// if (states.contains(WidgetState.disabled)) { + /// return Colors.orange.withValues(alpha: .32); + /// } + /// return Colors.orange; + /// }) + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If [fillColor] resolves to null for the requested state, then the fill color + /// falls back to [activeColor] if the state includes [WidgetState.selected], + /// [CupertinoColors.white] at 50% opacity if checkbox is disabled, + /// and [CupertinoColors.white] otherwise. + final WidgetStateProperty? fillColor; + + /// The color used if the checkbox is inactive. + /// + /// Currently [inactiveColor] is not used. Instead, [fillColor] controls the + /// color of the background in all states, including when unselected. + @Deprecated( + 'Use fillColor instead. ' + 'fillColor now manages the background color in all states. ' + 'This feature was deprecated after v3.24.0-0.2.pre.', + ) + final Color? inactiveColor; + + /// The color to use for the check icon when this checkbox is checked. + /// + /// If null, then the value of [CupertinoColors.white] is used if the checkbox + /// is enabled. If the checkbox is disabled, a grey-black color is used. + final Color? checkColor; + + /// If true, the checkbox's [value] can be true, false, or null. + /// + /// [CupertinoCheckbox] displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null, and + /// [onChanged] will only toggle between true and false. + final bool tristate; + + /// The color for the checkbox's border shadow when it has the input focus. + /// + /// If null, then a paler form of the [activeColor] will be used. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The color and width of the checkbox's border. + /// + /// This property can be a [WidgetStateBorderSide] that can + /// specify different border color and widths depending on the + /// checkbox's state. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// * [WidgetState.error]. + /// + /// If this property is not a [WidgetStateBorderSide] and it is + /// non-null, then it is only rendered when the checkbox's value is + /// false. The difference in interpretation is for backwards + /// compatibility. + /// + /// If this property is null and the checkbox's value is false, then the side + /// defaults to a one pixel wide grey-black border. + final BorderSide? side; + + /// The shape of the checkbox. + /// + /// If this property is null then the shape defaults to a + /// [RoundedRectangleBorder] with a circular corner radius of 4.0. + final OutlinedBorder? shape; + + /// The tap target and layout size of the checkbox. + /// + /// If this property is null, the tap target size defaults to a square of + /// [CupertinoCheckbox.width] pixels on desktop devices and + /// [kMinInteractiveDimensionCupertino] pixels on mobile devices. + final Size? tapTargetSize; + + /// The semantic label for the checkbox that will be announced by screen readers. + /// + /// This is announced by assistive technologies (e.g TalkBack/VoiceOver). + /// + /// This label does not show in the UI. + final String? semanticLabel; + + /// The width of a checkbox widget. + static const double width = 14.0; + + @override + State createState() => _CupertinoCheckboxState(); +} + +class _CupertinoCheckboxState extends State + with TickerProviderStateMixin, ToggleableStateMixin { + final _CheckboxPainter _painter = _CheckboxPainter(); + bool? _previousValue; + + bool focused = false; + + @override + void initState() { + super.initState(); + _previousValue = widget.value; + } + + @override + void didUpdateWidget(CupertinoCheckbox oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _previousValue = oldWidget.value; + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + @override + ValueChanged? get onChanged => widget.onChanged; + + @override + bool get tristate => widget.tristate; + + @override + bool? get value => widget.value; + + WidgetStateProperty get _defaultFillColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return CupertinoColors.white.withOpacity(0.5); + } + if (states.contains(WidgetState.selected)) { + return widget.activeColor ?? CupertinoDynamicColor.resolve(_kDefaultFillColor, context); + } + return CupertinoColors.white; + }); + } + + WidgetStateProperty get _defaultCheckColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return widget.checkColor ?? CupertinoDynamicColor.resolve(_kDisabledCheckColor, context); + } + if (states.contains(WidgetState.selected)) { + return widget.checkColor ?? CupertinoDynamicColor.resolve(_kDefaultCheckColor, context); + } + return CupertinoColors.white; + }); + } + + WidgetStateProperty get _defaultSide { + return WidgetStateProperty.resolveWith((Set states) { + if ((states.contains(WidgetState.selected) || states.contains(WidgetState.focused)) && + !states.contains(WidgetState.disabled)) { + return const BorderSide(width: 0.0, color: CupertinoColors.transparent); + } + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: CupertinoDynamicColor.resolve(_kDisabledBorderColor, context)); + } + return BorderSide(color: CupertinoDynamicColor.resolve(_kDefaultBorderColor, context)); + }); + } + + BorderSide? _resolveSide(BorderSide? side, Set states) { + if (side is WidgetStateBorderSide) { + return WidgetStateProperty.resolveAs(side, states); + } + if (!states.contains(WidgetState.selected)) { + return side; + } + return null; + } + + @override + Widget build(BuildContext context) { + // Colors need to be resolved in selected and non selected states separately. + // The `states` getter constructs a new set every time, making it safe to edit in place. + final Set activeStates = states..add(WidgetState.selected); + final Set inactiveStates = states..remove(WidgetState.selected); + + // Since the states getter always makes a new set, make a copy to use + // throughout the lifecycle of this build method. + final Set currentStates = states; + + final Color effectiveActiveColor = + widget.fillColor?.resolve(activeStates) ?? _defaultFillColor.resolve(activeStates); + + final Color effectiveInactiveColor = + widget.fillColor?.resolve(inactiveStates) ?? _defaultFillColor.resolve(inactiveStates); + + final BorderSide effectiveBorderSide = + _resolveSide(widget.side, currentStates) ?? _defaultSide.resolve(currentStates); + + final Color effectiveFocusOverlayColor = + widget.focusColor ?? + HSLColor.fromColor(effectiveActiveColor.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + + final WidgetStateProperty effectiveMouseCursor = + WidgetStateProperty.resolveWith((Set states) { + return WidgetStateProperty.resolveAs(widget.mouseCursor, states) ?? + (kIsWeb && !states.contains(WidgetState.disabled) + ? SystemMouseCursors.click + : SystemMouseCursors.basic); + }); + + final Size effectiveSize = + widget.tapTargetSize ?? + switch (defaultTargetPlatform) { + TargetPlatform.iOS || + TargetPlatform.android || + TargetPlatform.fuchsia => const Size.square(kMinInteractiveDimensionCupertino), + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => const Size.square(CupertinoCheckbox.width), + }; + + return Semantics( + label: widget.semanticLabel, + checked: widget.value ?? false, + mixed: widget.tristate ? widget.value == null : null, + child: buildToggleable( + mouseCursor: effectiveMouseCursor, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + size: effectiveSize, + painter: _painter + ..position = position + ..reaction = reaction + ..focusColor = effectiveFocusOverlayColor + ..downPosition = downPosition + ..isFocused = currentStates.contains(WidgetState.focused) + ..isHovered = currentStates.contains(WidgetState.hovered) + ..activeColor = effectiveActiveColor + ..inactiveColor = effectiveInactiveColor + ..checkColor = _defaultCheckColor.resolve(currentStates) + ..value = value + ..previousValue = _previousValue + ..isActive = widget.onChanged != null + ..shape = + widget.shape ?? + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))) + ..side = effectiveBorderSide + ..brightness = CupertinoTheme.of(context).brightness, + ), + ); + } +} + +class _CheckboxPainter extends ToggleablePainter { + Color get checkColor => _checkColor!; + Color? _checkColor; + set checkColor(Color value) { + if (_checkColor == value) { + return; + } + _checkColor = value; + notifyListeners(); + } + + bool? get value => _value; + bool? _value; + set value(bool? value) { + if (_value == value) { + return; + } + _value = value; + notifyListeners(); + } + + bool? get previousValue => _previousValue; + bool? _previousValue; + set previousValue(bool? value) { + if (_previousValue == value) { + return; + } + _previousValue = value; + notifyListeners(); + } + + OutlinedBorder get shape => _shape!; + OutlinedBorder? _shape; + set shape(OutlinedBorder value) { + if (_shape == value) { + return; + } + _shape = value; + notifyListeners(); + } + + BorderSide get side => _side!; + BorderSide? _side; + set side(BorderSide value) { + if (_side == value) { + return; + } + _side = value; + notifyListeners(); + } + + Brightness? get brightness => _brightness; + Brightness? _brightness; + set brightness(Brightness? value) { + if (_brightness == value) { + return; + } + _brightness = value; + notifyListeners(); + } + + Rect _outerRectAt(Offset origin) { + const double size = CupertinoCheckbox.width; + final rect = Rect.fromLTWH(origin.dx, origin.dy, size, size); + return rect; + } + + // The checkbox's border color if value == false, or its fill color when + // value == true or null. + Color _colorAt(bool value) { + return value && isActive ? activeColor : inactiveColor; + } + + // White stroke used to paint the check and dash. + Paint _createStrokePaint() { + return Paint() + ..color = checkColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..strokeCap = StrokeCap.round; + } + + // Draw a gradient from the top to the bottom of the checkbox. + void _drawFillGradient(Canvas canvas, Rect outer, Color topColor, Color bottomColor) { + final fillGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // Eyeballed from a checkbox on a physical Macbook Pro running macOS version 14.5. + colors: [topColor, bottomColor], + ); + final gradientPaint = Paint()..shader = fillGradient.createShader(outer); + if (shape.preferPaintInterior) { + shape.paintInterior(canvas, outer, gradientPaint); + } else { + canvas.drawPath(shape.getOuterPath(outer), gradientPaint); + } + } + + void _drawBox(Canvas canvas, Rect outer, Paint paint, BorderSide? side, bool value) { + // Draw a gradient in dark mode except when the checkbox is enabled and checked. + if (brightness == Brightness.dark && !(isActive && value)) { + _drawFillGradient( + canvas, + outer, + paint.color.withOpacity( + isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0], + ), + paint.color.withOpacity( + isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1], + ), + ); + } else if (shape.preferPaintInterior) { + shape.paintInterior(canvas, outer, paint); + } else { + canvas.drawPath(shape.getOuterPath(outer), paint); + } + if (side != null) { + shape.copyWith(side: side).paint(canvas, outer); + } + } + + void _drawCheck(Canvas canvas, Offset origin, Paint paint) { + final path = Path(); + // The ratios for the offsets below were found from looking at the checkbox + // examples on in the HIG docs. The distance from the needed point to the + // edge was measured, then divided by the total width. + const start = Offset(CupertinoCheckbox.width * 0.22, CupertinoCheckbox.width * 0.54); + const mid = Offset(CupertinoCheckbox.width * 0.40, CupertinoCheckbox.width * 0.75); + const end = Offset(CupertinoCheckbox.width * 0.78, CupertinoCheckbox.width * 0.25); + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.moveTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.lineTo(origin.dx + end.dx, origin.dy + end.dy); + canvas.drawPath(path, paint); + } + + void _drawDash(Canvas canvas, Offset origin, Paint paint) { + // From measuring the checkbox example in the HIG docs, the dash was found + // to be half the total width, centered in the middle. + const start = Offset(CupertinoCheckbox.width * 0.25, CupertinoCheckbox.width * 0.5); + const end = Offset(CupertinoCheckbox.width * 0.75, CupertinoCheckbox.width * 0.5); + canvas.drawLine(origin + start, origin + end, paint); + } + + @override + void paint(Canvas canvas, Size size) { + final Paint strokePaint = _createStrokePaint(); + final origin = size / 2.0 - const Size.square(CupertinoCheckbox.width) / 2.0 as Offset; + final Rect outer = _outerRectAt(origin); + final paint = Paint()..color = _colorAt(value ?? true); + + switch (value) { + case false: + _drawBox(canvas, outer, paint, side, value ?? true); + case true: + _drawBox(canvas, outer, paint, side, value ?? true); + _drawCheck(canvas, origin, strokePaint); + case null: + _drawBox(canvas, outer, paint, side, value ?? true); + _drawDash(canvas, origin, strokePaint); + } + // The checkbox's opacity changes when pressed. + if (downPosition != null) { + final pressedPaint = Paint() + ..color = brightness == Brightness.light + ? CupertinoColors.black.withOpacity(_kPressedOverlayOpacity) + : CupertinoColors.white.withOpacity(_kPressedOverlayOpacity); + if (shape.preferPaintInterior) { + shape.paintInterior(canvas, outer, pressedPaint); + } else { + canvas.drawPath(shape.getOuterPath(outer), pressedPaint); + } + } + if (isFocused) { + final Rect focusOuter = outer.inflate(1); + final borderPaint = Paint() + ..color = focusColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5; + _drawBox(canvas, focusOuter, borderPaint, side, value ?? true); + } + } +} diff --git a/packages/cupertino_ui/lib/src/colors.dart b/packages/cupertino_ui/lib/src/colors.dart new file mode 100644 index 000000000000..9e0da72bde55 --- /dev/null +++ b/packages/cupertino_ui/lib/src/colors.dart @@ -0,0 +1,1286 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'button.dart'; +/// @docImport 'nav_bar.dart'; +library; + +import 'dart:ui' show Brightness, Color, ColorSpace; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'interface_level.dart'; +import 'theme.dart'; + +// Examples can assume: +// late Widget child; +// late BuildContext context; + +/// A palette of [Color] constants that describe colors commonly used when +/// matching the iOS platform aesthetics. +/// +/// ## Color palettes +/// +/// ### Basic Colors +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_basic_colors.png) +/// +/// ### Active Colors +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_active_colors.png) +/// +/// ### System Colors +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_system_colors_1.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_system_colors_2.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_system_colors_3.png) +/// +/// ### Label Colors +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_label_colors.png) +/// +/// ### Background Colors +/// ![](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_background_colors.png) +/// +abstract final class CupertinoColors { + /// iOS 13's default blue color. Used to indicate active elements such as + /// buttons, selected tabs and your own chat bubbles. + /// + /// This is SystemBlue in the iOS palette. + static const CupertinoDynamicColor activeBlue = systemBlue; + + /// iOS 13's default green color. Used to indicate active accents such as + /// the switch in its on state and some accent buttons such as the call button + /// and Apple Map's 'Go' button. + /// + /// This is SystemGreen in the iOS palette. + static const CupertinoDynamicColor activeGreen = systemGreen; + + /// iOS 13's orange color. + /// + /// This is SystemOrange in the iOS palette. + static const CupertinoDynamicColor activeOrange = systemOrange; + + /// Opaque white color. Used for backgrounds and fonts against dark backgrounds. + /// + /// This is SystemWhiteColor in the iOS palette. + /// + /// See also: + /// + /// * [Colors.white], the same color, in the Material Design palette. + /// * [black], opaque black in the [CupertinoColors] palette. + static const Color white = Color(0xFFFFFFFF); + + /// Opaque black color. Used for texts against light backgrounds. + /// + /// This is SystemBlackColor in the iOS palette. + /// + /// See also: + /// + /// * [Colors.black], the same color, in the Material Design palette. + /// * [white], opaque white in the [CupertinoColors] palette. + static const Color black = Color(0xFF000000); + + /// A fully-transparent color, completely invisible. + /// + /// See also: + /// + /// * [Colors.transparent], the same color, in the Material Design palette. + static const Color transparent = Color(0x00000000); + + /// Used in iOS 10 for light background fills such as the chat bubble background. + /// + /// This is SystemLightGrayColor in the iOS palette. + static const Color lightBackgroundGray = Color(0xFFE5E5EA); + + /// Used in iOS 12 for very light background fills in tables between cell groups. + /// + /// This is SystemExtraLightGrayColor in the iOS palette. + static const Color extraLightBackgroundGray = Color(0xFFEFEFF4); + + /// Used in iOS 12 for very dark background fills in tables between cell groups + /// in dark mode. + // Value derived from screenshot from the dark themed Apple Watch app. + static const Color darkBackgroundGray = Color(0xFF171717); + + /// Used in iOS 13 for unselected selectables such as tab bar items in their + /// inactive state or de-emphasized subtitles and details text. + /// + /// Not the same grey as disabled buttons etc. + /// + /// This is the disabled color in the iOS palette. + static const CupertinoDynamicColor inactiveGray = CupertinoDynamicColor.withBrightness( + debugLabel: 'inactiveGray', + color: Color(0xFF999999), + darkColor: Color(0xFF757575), + ); + + /// Used for iOS 13 for destructive actions such as the delete actions in + /// table view cells and dialogs. + /// + /// Not the same red as the camera shutter or springboard icon notifications + /// or the foreground red theme in various native apps such as HealthKit. + /// + /// This is SystemRed in the iOS palette. + static const CupertinoDynamicColor destructiveRed = systemRed; + + /// A blue color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemBlue](https://developer.apple.com/documentation/uikit/uicolor/3173141-systemblue), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemBlue = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemBlue', + color: Color.fromARGB(255, 0, 122, 255), + darkColor: Color.fromARGB(255, 10, 132, 255), + highContrastColor: Color.fromARGB(255, 0, 64, 221), + darkHighContrastColor: Color.fromARGB(255, 64, 156, 255), + ); + + /// A green color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemGreen](https://developer.apple.com/documentation/uikit/uicolor/3173144-systemgreen), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGreen = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGreen', + color: Color.fromARGB(255, 52, 199, 89), + darkColor: Color.fromARGB(255, 48, 209, 88), + highContrastColor: Color.fromARGB(255, 36, 138, 61), + darkHighContrastColor: Color.fromARGB(255, 48, 219, 91), + ); + + /// A mint color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemMint](https://developer.apple.com/documentation/uikit/uicolor/3852741-systemmint), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemMint = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemMint', + color: Color.fromARGB(255, 0, 199, 190), + darkColor: Color.fromARGB(255, 99, 230, 226), + highContrastColor: Color.fromARGB(255, 12, 129, 123), + darkHighContrastColor: Color.fromARGB(255, 102, 212, 207), + ); + + /// An indigo color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemIndigo](https://developer.apple.com/documentation/uikit/uicolor/3173146-systemindigo), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemIndigo = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemIndigo', + color: Color.fromARGB(255, 88, 86, 214), + darkColor: Color.fromARGB(255, 94, 92, 230), + highContrastColor: Color.fromARGB(255, 54, 52, 163), + darkHighContrastColor: Color.fromARGB(255, 125, 122, 255), + ); + + /// An orange color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemOrange](https://developer.apple.com/documentation/uikit/uicolor/3173147-systemorange), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemOrange = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemOrange', + color: Color.fromARGB(255, 255, 149, 0), + darkColor: Color.fromARGB(255, 255, 159, 10), + highContrastColor: Color.fromARGB(255, 201, 52, 0), + darkHighContrastColor: Color.fromARGB(255, 255, 179, 64), + ); + + /// A pink color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemPink](https://developer.apple.com/documentation/uikit/uicolor/3173148-systempink), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemPink = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemPink', + color: Color.fromARGB(255, 255, 45, 85), + darkColor: Color.fromARGB(255, 255, 55, 95), + highContrastColor: Color.fromARGB(255, 211, 15, 69), + darkHighContrastColor: Color.fromARGB(255, 255, 100, 130), + ); + + /// A brown color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemBrown](https://developer.apple.com/documentation/uikit/uicolor/3173142-systembrown), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemBrown = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemBrown', + color: Color.fromARGB(255, 162, 132, 94), + darkColor: Color.fromARGB(255, 172, 142, 104), + highContrastColor: Color.fromARGB(255, 127, 101, 69), + darkHighContrastColor: Color.fromARGB(255, 181, 148, 105), + ); + + /// A purple color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemPurple](https://developer.apple.com/documentation/uikit/uicolor/3173149-systempurple), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemPurple = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemPurple', + color: Color.fromARGB(255, 175, 82, 222), + darkColor: Color.fromARGB(255, 191, 90, 242), + highContrastColor: Color.fromARGB(255, 137, 68, 171), + darkHighContrastColor: Color.fromARGB(255, 218, 143, 255), + ); + + /// A red color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemRed](https://developer.apple.com/documentation/uikit/uicolor/3173150-systemred), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemRed = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemRed', + color: Color.fromARGB(255, 255, 59, 48), + darkColor: Color.fromARGB(255, 255, 69, 58), + highContrastColor: Color.fromARGB(255, 215, 0, 21), + darkHighContrastColor: Color.fromARGB(255, 255, 105, 97), + ); + + /// A teal color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemTeal](https://developer.apple.com/documentation/uikit/uicolor/3173151-systemteal), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemTeal = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemTeal', + color: Color.fromARGB(255, 90, 200, 250), + darkColor: Color.fromARGB(255, 100, 210, 255), + highContrastColor: Color.fromARGB(255, 0, 113, 164), + darkHighContrastColor: Color.fromARGB(255, 112, 215, 255), + ); + + /// A cyan color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemCyan](https://developer.apple.com/documentation/uikit/uicolor/3852740-systemcyan), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemCyan = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemCyan', + color: Color.fromARGB(255, 50, 173, 230), + darkColor: Color.fromARGB(255, 100, 210, 255), + highContrastColor: Color.fromARGB(255, 0, 113, 164), + darkHighContrastColor: Color.fromARGB(255, 112, 215, 255), + ); + + /// A yellow color that can adapt to the given [BuildContext]. + /// + /// See also: + /// + /// * [UIColor.systemYellow](https://developer.apple.com/documentation/uikit/uicolor/3173152-systemyellow), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemYellow = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemYellow', + color: Color.fromARGB(255, 255, 204, 0), + darkColor: Color.fromARGB(255, 255, 214, 10), + highContrastColor: Color.fromARGB(255, 160, 90, 0), + darkHighContrastColor: Color.fromARGB(255, 255, 212, 38), + ); + + /// The base grey color. + /// + /// See also: + /// + /// * [UIColor.systemGray](https://developer.apple.com/documentation/uikit/uicolor/3173143-systemgray), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGrey = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGrey', + color: Color.fromARGB(255, 142, 142, 147), + darkColor: Color.fromARGB(255, 142, 142, 147), + highContrastColor: Color.fromARGB(255, 108, 108, 112), + darkHighContrastColor: Color.fromARGB(255, 174, 174, 178), + ); + + /// A second-level shade of grey. + /// + /// See also: + /// + /// * [UIColor.systemGray2](https://developer.apple.com/documentation/uikit/uicolor/3255071-systemgray2), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGrey2 = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGrey2', + color: Color.fromARGB(255, 174, 174, 178), + darkColor: Color.fromARGB(255, 99, 99, 102), + highContrastColor: Color.fromARGB(255, 142, 142, 147), + darkHighContrastColor: Color.fromARGB(255, 124, 124, 128), + ); + + /// A third-level shade of grey. + /// + /// See also: + /// + /// * [UIColor.systemGray3](https://developer.apple.com/documentation/uikit/uicolor/3255072-systemgray3), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGrey3 = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGrey3', + color: Color.fromARGB(255, 199, 199, 204), + darkColor: Color.fromARGB(255, 72, 72, 74), + highContrastColor: Color.fromARGB(255, 174, 174, 178), + darkHighContrastColor: Color.fromARGB(255, 84, 84, 86), + ); + + /// A fourth-level shade of grey. + /// + /// See also: + /// + /// * [UIColor.systemGray4](https://developer.apple.com/documentation/uikit/uicolor/3255073-systemgray4), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGrey4 = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGrey4', + color: Color.fromARGB(255, 209, 209, 214), + darkColor: Color.fromARGB(255, 58, 58, 60), + highContrastColor: Color.fromARGB(255, 188, 188, 192), + darkHighContrastColor: Color.fromARGB(255, 68, 68, 70), + ); + + /// A fifth-level shade of grey. + /// + /// See also: + /// + /// * [UIColor.systemGray5](https://developer.apple.com/documentation/uikit/uicolor/3255074-systemgray5), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGrey5 = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGrey5', + color: Color.fromARGB(255, 229, 229, 234), + darkColor: Color.fromARGB(255, 44, 44, 46), + highContrastColor: Color.fromARGB(255, 216, 216, 220), + darkHighContrastColor: Color.fromARGB(255, 54, 54, 56), + ); + + /// A sixth-level shade of grey. + /// + /// See also: + /// + /// * [UIColor.systemGray6](https://developer.apple.com/documentation/uikit/uicolor/3255075-systemgray6), + /// the `UIKit` equivalent. + static const CupertinoDynamicColor systemGrey6 = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'systemGrey6', + color: Color.fromARGB(255, 242, 242, 247), + darkColor: Color.fromARGB(255, 28, 28, 30), + highContrastColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastColor: Color.fromARGB(255, 36, 36, 38), + ); + + /// The color for text labels containing primary content, equivalent to + /// [UIColor.label](https://developer.apple.com/documentation/uikit/uicolor/3173131-label). + static const CupertinoDynamicColor label = CupertinoDynamicColor( + debugLabel: 'label', + color: Color.fromARGB(255, 0, 0, 0), + darkColor: Color.fromARGB(255, 255, 255, 255), + highContrastColor: Color.fromARGB(255, 0, 0, 0), + darkHighContrastColor: Color.fromARGB(255, 255, 255, 255), + elevatedColor: Color.fromARGB(255, 0, 0, 0), + darkElevatedColor: Color.fromARGB(255, 255, 255, 255), + highContrastElevatedColor: Color.fromARGB(255, 0, 0, 0), + darkHighContrastElevatedColor: Color.fromARGB(255, 255, 255, 255), + ); + + /// The color for text labels containing secondary content, equivalent to + /// [UIColor.secondaryLabel](https://developer.apple.com/documentation/uikit/uicolor/3173136-secondarylabel). + static const CupertinoDynamicColor secondaryLabel = CupertinoDynamicColor( + debugLabel: 'secondaryLabel', + color: Color.fromARGB(153, 60, 60, 67), + darkColor: Color.fromARGB(153, 235, 235, 245), + highContrastColor: Color.fromARGB(173, 60, 60, 67), + darkHighContrastColor: Color.fromARGB(173, 235, 235, 245), + elevatedColor: Color.fromARGB(153, 60, 60, 67), + darkElevatedColor: Color.fromARGB(153, 235, 235, 245), + highContrastElevatedColor: Color.fromARGB(173, 60, 60, 67), + darkHighContrastElevatedColor: Color.fromARGB(173, 235, 235, 245), + ); + + /// The color for text labels containing tertiary content, equivalent to + /// [UIColor.tertiaryLabel](https://developer.apple.com/documentation/uikit/uicolor/3173153-tertiarylabel). + static const CupertinoDynamicColor tertiaryLabel = CupertinoDynamicColor( + debugLabel: 'tertiaryLabel', + color: Color.fromARGB(76, 60, 60, 67), + darkColor: Color.fromARGB(76, 235, 235, 245), + highContrastColor: Color.fromARGB(96, 60, 60, 67), + darkHighContrastColor: Color.fromARGB(96, 235, 235, 245), + elevatedColor: Color.fromARGB(76, 60, 60, 67), + darkElevatedColor: Color.fromARGB(76, 235, 235, 245), + highContrastElevatedColor: Color.fromARGB(96, 60, 60, 67), + darkHighContrastElevatedColor: Color.fromARGB(96, 235, 235, 245), + ); + + /// The color for text labels containing quaternary content, equivalent to + /// [UIColor.quaternaryLabel](https://developer.apple.com/documentation/uikit/uicolor/3173135-quaternarylabel). + static const CupertinoDynamicColor quaternaryLabel = CupertinoDynamicColor( + debugLabel: 'quaternaryLabel', + color: Color.fromARGB(45, 60, 60, 67), + darkColor: Color.fromARGB(40, 235, 235, 245), + highContrastColor: Color.fromARGB(66, 60, 60, 67), + darkHighContrastColor: Color.fromARGB(61, 235, 235, 245), + elevatedColor: Color.fromARGB(45, 60, 60, 67), + darkElevatedColor: Color.fromARGB(40, 235, 235, 245), + highContrastElevatedColor: Color.fromARGB(66, 60, 60, 67), + darkHighContrastElevatedColor: Color.fromARGB(61, 235, 235, 245), + ); + + /// An overlay fill color for thin and small shapes, equivalent to + /// [UIColor.systemFill](https://developer.apple.com/documentation/uikit/uicolor/3255070-systemfill). + static const CupertinoDynamicColor systemFill = CupertinoDynamicColor( + debugLabel: 'systemFill', + color: Color.fromARGB(51, 120, 120, 128), + darkColor: Color.fromARGB(91, 120, 120, 128), + highContrastColor: Color.fromARGB(71, 120, 120, 128), + darkHighContrastColor: Color.fromARGB(112, 120, 120, 128), + elevatedColor: Color.fromARGB(51, 120, 120, 128), + darkElevatedColor: Color.fromARGB(91, 120, 120, 128), + highContrastElevatedColor: Color.fromARGB(71, 120, 120, 128), + darkHighContrastElevatedColor: Color.fromARGB(112, 120, 120, 128), + ); + + /// An overlay fill color for medium-size shapes, equivalent to + /// [UIColor.secondarySystemFill](https://developer.apple.com/documentation/uikit/uicolor/3255069-secondarysystemfill). + static const CupertinoDynamicColor secondarySystemFill = CupertinoDynamicColor( + debugLabel: 'secondarySystemFill', + color: Color.fromARGB(40, 120, 120, 128), + darkColor: Color.fromARGB(81, 120, 120, 128), + highContrastColor: Color.fromARGB(61, 120, 120, 128), + darkHighContrastColor: Color.fromARGB(102, 120, 120, 128), + elevatedColor: Color.fromARGB(40, 120, 120, 128), + darkElevatedColor: Color.fromARGB(81, 120, 120, 128), + highContrastElevatedColor: Color.fromARGB(61, 120, 120, 128), + darkHighContrastElevatedColor: Color.fromARGB(102, 120, 120, 128), + ); + + /// An overlay fill color for large shapes, equivalent to + /// [UIColor.tertiarySystemFill](https://developer.apple.com/documentation/uikit/uicolor/3255076-tertiarysystemfill). + static const CupertinoDynamicColor tertiarySystemFill = CupertinoDynamicColor( + debugLabel: 'tertiarySystemFill', + color: Color.fromARGB(30, 118, 118, 128), + darkColor: Color.fromARGB(61, 118, 118, 128), + highContrastColor: Color.fromARGB(51, 118, 118, 128), + darkHighContrastColor: Color.fromARGB(81, 118, 118, 128), + elevatedColor: Color.fromARGB(30, 118, 118, 128), + darkElevatedColor: Color.fromARGB(61, 118, 118, 128), + highContrastElevatedColor: Color.fromARGB(51, 118, 118, 128), + darkHighContrastElevatedColor: Color.fromARGB(81, 118, 118, 128), + ); + + /// An overlay fill color for large areas containing complex content, equivalent + /// to [UIColor.quaternarySystemFill](https://developer.apple.com/documentation/uikit/uicolor/3255068-quaternarysystemfill). + static const CupertinoDynamicColor quaternarySystemFill = CupertinoDynamicColor( + debugLabel: 'quaternarySystemFill', + color: Color.fromARGB(20, 116, 116, 128), + darkColor: Color.fromARGB(45, 118, 118, 128), + highContrastColor: Color.fromARGB(40, 116, 116, 128), + darkHighContrastColor: Color.fromARGB(66, 118, 118, 128), + elevatedColor: Color.fromARGB(20, 116, 116, 128), + darkElevatedColor: Color.fromARGB(45, 118, 118, 128), + highContrastElevatedColor: Color.fromARGB(40, 116, 116, 128), + darkHighContrastElevatedColor: Color.fromARGB(66, 118, 118, 128), + ); + + /// The color for placeholder text in controls or text views, equivalent to + /// [UIColor.placeholderText](https://developer.apple.com/documentation/uikit/uicolor/3173134-placeholdertext). + static const CupertinoDynamicColor placeholderText = CupertinoDynamicColor( + debugLabel: 'placeholderText', + color: Color.fromARGB(76, 60, 60, 67), + darkColor: Color.fromARGB(76, 235, 235, 245), + highContrastColor: Color.fromARGB(96, 60, 60, 67), + darkHighContrastColor: Color.fromARGB(96, 235, 235, 245), + elevatedColor: Color.fromARGB(76, 60, 60, 67), + darkElevatedColor: Color.fromARGB(76, 235, 235, 245), + highContrastElevatedColor: Color.fromARGB(96, 60, 60, 67), + darkHighContrastElevatedColor: Color.fromARGB(96, 235, 235, 245), + ); + + /// The color for the main background of your interface, equivalent to + /// [UIColor.systemBackground](https://developer.apple.com/documentation/uikit/uicolor/3173140-systembackground). + /// + /// Typically used for designs that have a white primary background in a light environment. + static const CupertinoDynamicColor systemBackground = CupertinoDynamicColor( + debugLabel: 'systemBackground', + color: Color.fromARGB(255, 255, 255, 255), + darkColor: Color.fromARGB(255, 0, 0, 0), + highContrastColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastColor: Color.fromARGB(255, 0, 0, 0), + elevatedColor: Color.fromARGB(255, 255, 255, 255), + darkElevatedColor: Color.fromARGB(255, 28, 28, 30), + highContrastElevatedColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastElevatedColor: Color.fromARGB(255, 36, 36, 38), + ); + + /// The color for content layered on top of the main background, equivalent to + /// [UIColor.secondarySystemBackground](https://developer.apple.com/documentation/uikit/uicolor/3173137-secondarysystembackground). + /// + /// Typically used for designs that have a white primary background in a light environment. + static const CupertinoDynamicColor secondarySystemBackground = CupertinoDynamicColor( + debugLabel: 'secondarySystemBackground', + color: Color.fromARGB(255, 242, 242, 247), + darkColor: Color.fromARGB(255, 28, 28, 30), + highContrastColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastColor: Color.fromARGB(255, 36, 36, 38), + elevatedColor: Color.fromARGB(255, 242, 242, 247), + darkElevatedColor: Color.fromARGB(255, 44, 44, 46), + highContrastElevatedColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastElevatedColor: Color.fromARGB(255, 54, 54, 56), + ); + + /// The color for content layered on top of secondary backgrounds, equivalent + /// to [UIColor.tertiarySystemBackground](https://developer.apple.com/documentation/uikit/uicolor/3173154-tertiarysystembackground). + /// + /// Typically used for designs that have a white primary background in a light environment. + static const CupertinoDynamicColor tertiarySystemBackground = CupertinoDynamicColor( + debugLabel: 'tertiarySystemBackground', + color: Color.fromARGB(255, 255, 255, 255), + darkColor: Color.fromARGB(255, 44, 44, 46), + highContrastColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastColor: Color.fromARGB(255, 54, 54, 56), + elevatedColor: Color.fromARGB(255, 255, 255, 255), + darkElevatedColor: Color.fromARGB(255, 58, 58, 60), + highContrastElevatedColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastElevatedColor: Color.fromARGB(255, 68, 68, 70), + ); + + /// The color for the main background of your grouped interface, equivalent to + /// [UIColor.systemGroupedBackground](https://developer.apple.com/documentation/uikit/uicolor/3173145-systemgroupedbackground). + /// + /// Typically used for grouped content, including table views and platter-based designs. + static const CupertinoDynamicColor systemGroupedBackground = CupertinoDynamicColor( + debugLabel: 'systemGroupedBackground', + color: Color.fromARGB(255, 242, 242, 247), + darkColor: Color.fromARGB(255, 0, 0, 0), + highContrastColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastColor: Color.fromARGB(255, 0, 0, 0), + elevatedColor: Color.fromARGB(255, 242, 242, 247), + darkElevatedColor: Color.fromARGB(255, 28, 28, 30), + highContrastElevatedColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastElevatedColor: Color.fromARGB(255, 36, 36, 38), + ); + + /// The color for content layered on top of the main background of your grouped interface, + /// equivalent to [UIColor.secondarySystemGroupedBackground](https://developer.apple.com/documentation/uikit/uicolor/3173138-secondarysystemgroupedbackground). + /// + /// Typically used for grouped content, including table views and platter-based designs. + static const CupertinoDynamicColor secondarySystemGroupedBackground = CupertinoDynamicColor( + debugLabel: 'secondarySystemGroupedBackground', + color: Color.fromARGB(255, 255, 255, 255), + darkColor: Color.fromARGB(255, 28, 28, 30), + highContrastColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastColor: Color.fromARGB(255, 36, 36, 38), + elevatedColor: Color.fromARGB(255, 255, 255, 255), + darkElevatedColor: Color.fromARGB(255, 44, 44, 46), + highContrastElevatedColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastElevatedColor: Color.fromARGB(255, 54, 54, 56), + ); + + /// The color for content layered on top of secondary backgrounds of your grouped interface, + /// equivalent to [UIColor.tertiarySystemGroupedBackground](https://developer.apple.com/documentation/uikit/uicolor/3173155-tertiarysystemgroupedbackground). + /// + /// Typically used for grouped content, including table views and platter-based designs. + static const CupertinoDynamicColor tertiarySystemGroupedBackground = CupertinoDynamicColor( + debugLabel: 'tertiarySystemGroupedBackground', + color: Color.fromARGB(255, 242, 242, 247), + darkColor: Color.fromARGB(255, 44, 44, 46), + highContrastColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastColor: Color.fromARGB(255, 54, 54, 56), + elevatedColor: Color.fromARGB(255, 242, 242, 247), + darkElevatedColor: Color.fromARGB(255, 58, 58, 60), + highContrastElevatedColor: Color.fromARGB(255, 235, 235, 240), + darkHighContrastElevatedColor: Color.fromARGB(255, 68, 68, 70), + ); + + /// The color for thin borders or divider lines that allows some underlying content to be visible, + /// equivalent to [UIColor.separator](https://developer.apple.com/documentation/uikit/uicolor/3173139-separator). + static const CupertinoDynamicColor separator = CupertinoDynamicColor( + debugLabel: 'separator', + color: Color.fromARGB(73, 60, 60, 67), + darkColor: Color.fromARGB(153, 84, 84, 88), + highContrastColor: Color.fromARGB(94, 60, 60, 67), + darkHighContrastColor: Color.fromARGB(173, 84, 84, 88), + elevatedColor: Color.fromARGB(73, 60, 60, 67), + darkElevatedColor: Color.fromARGB(153, 210, 210, 210), + highContrastElevatedColor: Color.fromARGB(94, 60, 60, 67), + darkHighContrastElevatedColor: Color.fromARGB(173, 84, 84, 88), + ); + + /// The color for borders or divider lines that hide any underlying content, + /// equivalent to [UIColor.opaqueSeparator](https://developer.apple.com/documentation/uikit/uicolor/3173133-opaqueseparator). + static const CupertinoDynamicColor opaqueSeparator = CupertinoDynamicColor( + debugLabel: 'opaqueSeparator', + color: Color.fromARGB(255, 198, 198, 200), + darkColor: Color.fromARGB(255, 56, 56, 58), + highContrastColor: Color.fromARGB(255, 198, 198, 200), + darkHighContrastColor: Color.fromARGB(255, 56, 56, 58), + elevatedColor: Color.fromARGB(255, 198, 198, 200), + darkElevatedColor: Color.fromARGB(255, 56, 56, 58), + highContrastElevatedColor: Color.fromARGB(255, 198, 198, 200), + darkHighContrastElevatedColor: Color.fromARGB(255, 56, 56, 58), + ); + + /// The color for links, equivalent to + /// [UIColor.link](https://developer.apple.com/documentation/uikit/uicolor/3173132-link). + static const CupertinoDynamicColor link = CupertinoDynamicColor( + debugLabel: 'link', + color: Color.fromARGB(255, 0, 122, 255), + darkColor: Color.fromARGB(255, 9, 132, 255), + highContrastColor: Color.fromARGB(255, 0, 122, 255), + darkHighContrastColor: Color.fromARGB(255, 9, 132, 255), + elevatedColor: Color.fromARGB(255, 0, 122, 255), + darkElevatedColor: Color.fromARGB(255, 9, 132, 255), + highContrastElevatedColor: Color.fromARGB(255, 0, 122, 255), + darkHighContrastElevatedColor: Color.fromARGB(255, 9, 132, 255), + ); +} + +/// A [Color] subclass that represents a family of colors, and the correct effective +/// color in the color family. +/// +/// When used as a regular color, [CupertinoDynamicColor] is equivalent to the +/// effective color (i.e. [CupertinoDynamicColor.value] will come from the effective +/// color), which is determined by the [BuildContext] it is last resolved against. +/// If it has never been resolved, the light, normal contrast, base elevation variant +/// [CupertinoDynamicColor.color] will be the default effective color. +/// +/// Sometimes manually resolving a [CupertinoDynamicColor] is not necessary, because +/// the Cupertino Library provides built-in support for it. +/// +/// ### Using [CupertinoDynamicColor] in a Cupertino widget +/// +/// When a Cupertino widget is provided with a [CupertinoDynamicColor], either +/// directly in its constructor, or from an [InheritedWidget] it depends on (for example, +/// [DefaultTextStyle]), the widget will automatically resolve the color using +/// [CupertinoDynamicColor.resolve] against its own [BuildContext], on a best-effort +/// basis. +/// +/// {@tool snippet} +/// By default a [CupertinoButton] has no background color. The following sample +/// code shows how to build a [CupertinoButton] that appears white in light mode, +/// and changes automatically to black in dark mode. +/// +/// ```dart +/// CupertinoButton( +/// // CupertinoDynamicColor works out of box in a CupertinoButton. +/// color: const CupertinoDynamicColor.withBrightness( +/// color: CupertinoColors.white, +/// darkColor: CupertinoColors.black, +/// ), +/// onPressed: () { }, +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// ### Using a [CupertinoDynamicColor] from a [CupertinoTheme] +/// +/// When referring to a [CupertinoTheme] color, generally the color will already +/// have adapted to the ambient [BuildContext], because [CupertinoTheme.of] +/// implicitly resolves all the colors used in the retrieved [CupertinoThemeData], +/// before returning it. +/// +/// {@tool snippet} +/// The following code sample creates a [Container] with the `primaryColor` of the +/// current theme. If `primaryColor` is a [CupertinoDynamicColor], the container +/// will be adaptive, thanks to [CupertinoTheme.of]: it will switch to `primaryColor`'s +/// dark variant once dark mode is turned on, and turns to primaryColor`'s high +/// contrast variant when [MediaQueryData.highContrast] is requested in the ambient +/// [MediaQuery], etc. +/// +/// ```dart +/// Container( +/// // Container is not a Cupertino widget, but CupertinoTheme.of implicitly +/// // resolves colors used in the retrieved CupertinoThemeData. +/// color: CupertinoTheme.of(context).primaryColor, +/// ) +/// ``` +/// {@end-tool} +/// +/// ### Manually Resolving a [CupertinoDynamicColor] +/// +/// When used to configure a non-Cupertino widget, or wrapped in an object opaque +/// to the receiving Cupertino component, a [CupertinoDynamicColor] may need to be +/// manually resolved using [CupertinoDynamicColor.resolve], before it can used +/// to paint. For example, to use a custom [Border] in a [CupertinoNavigationBar], +/// the colors used in the [Border] have to be resolved manually before being passed +/// to [CupertinoNavigationBar]'s constructor. +/// +/// {@tool snippet} +/// +/// The following code samples demonstrate two cases where you have to manually +/// resolve a [CupertinoDynamicColor]. +/// +/// ```dart +/// CupertinoNavigationBar( +/// // CupertinoNavigationBar does not know how to resolve colors used in +/// // a Border class. +/// border: Border( +/// bottom: BorderSide( +/// color: CupertinoDynamicColor.resolve(CupertinoColors.systemBlue, context), +/// ), +/// ), +/// ) +/// ``` +/// +/// ```dart +/// Container( +/// // Container is not a Cupertino widget. +/// color: CupertinoDynamicColor.resolve(CupertinoColors.systemBlue, context), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoUserInterfaceLevel], an [InheritedWidget] that may affect color +/// resolution of a [CupertinoDynamicColor]. +/// * [CupertinoTheme.of], a static method that retrieves the ambient [CupertinoThemeData], +/// and then resolves [CupertinoDynamicColor]s used in the retrieved data. +@immutable +class CupertinoDynamicColor with Diagnosticable implements Color { + /// Creates an adaptive [Color] that changes its effective color based on the + /// [BuildContext] given. The default effective color is [color]. + const CupertinoDynamicColor({ + String? debugLabel, + required Color color, + required Color darkColor, + required Color highContrastColor, + required Color darkHighContrastColor, + required Color elevatedColor, + required Color darkElevatedColor, + required Color highContrastElevatedColor, + required Color darkHighContrastElevatedColor, + }) : this._( + color, + color, + darkColor, + highContrastColor, + darkHighContrastColor, + elevatedColor, + darkElevatedColor, + highContrastElevatedColor, + darkHighContrastElevatedColor, + null, + debugLabel, + ); + + /// Creates an adaptive [Color] that changes its effective color based on the + /// given [BuildContext]'s brightness (from [MediaQueryData.platformBrightness] + /// or [CupertinoThemeData.brightness]) and accessibility contrast setting + /// ([MediaQueryData.highContrast]). The default effective color is [color]. + const CupertinoDynamicColor.withBrightnessAndContrast({ + String? debugLabel, + required Color color, + required Color darkColor, + required Color highContrastColor, + required Color darkHighContrastColor, + }) : this( + debugLabel: debugLabel, + color: color, + darkColor: darkColor, + highContrastColor: highContrastColor, + darkHighContrastColor: darkHighContrastColor, + elevatedColor: color, + darkElevatedColor: darkColor, + highContrastElevatedColor: highContrastColor, + darkHighContrastElevatedColor: darkHighContrastColor, + ); + + /// Creates an adaptive [Color] that changes its effective color based on the given + /// [BuildContext]'s brightness (from [MediaQueryData.platformBrightness] or + /// [CupertinoThemeData.brightness]). The default effective color is [color]. + const CupertinoDynamicColor.withBrightness({ + String? debugLabel, + required Color color, + required Color darkColor, + }) : this( + debugLabel: debugLabel, + color: color, + darkColor: darkColor, + highContrastColor: color, + darkHighContrastColor: darkColor, + elevatedColor: color, + darkElevatedColor: darkColor, + highContrastElevatedColor: color, + darkHighContrastElevatedColor: darkColor, + ); + + const CupertinoDynamicColor._( + this._effectiveColor, + this.color, + this.darkColor, + this.highContrastColor, + this.darkHighContrastColor, + this.elevatedColor, + this.darkElevatedColor, + this.highContrastElevatedColor, + this.darkHighContrastElevatedColor, + this._debugResolveContext, + this._debugLabel, + ); + + /// The current effective color. + /// + /// Defaults to [color] if this [CupertinoDynamicColor] has never been + /// resolved. + final Color _effectiveColor; + + final String? _debugLabel; + + final Element? _debugResolveContext; + + /// The color to use when the [BuildContext] implies a combination of light mode, + /// normal contrast, and base interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.light], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.light]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `false`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.base]. + final Color color; + + /// The color to use when the [BuildContext] implies a combination of dark mode, + /// normal contrast, and base interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.dark], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.dark]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `false`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.base]. + final Color darkColor; + + /// The color to use when the [BuildContext] implies a combination of light mode, + /// high contrast, and base interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.light], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.light]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `true`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.base]. + final Color highContrastColor; + + /// The color to use when the [BuildContext] implies a combination of dark mode, + /// high contrast, and base interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.dark], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.dark]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `true`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.base]. + final Color darkHighContrastColor; + + /// The color to use when the [BuildContext] implies a combination of light mode, + /// normal contrast, and elevated interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.light], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.light]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `false`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.elevated]. + final Color elevatedColor; + + /// The color to use when the [BuildContext] implies a combination of dark mode, + /// normal contrast, and elevated interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.dark], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.dark]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `false`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.elevated]. + final Color darkElevatedColor; + + /// The color to use when the [BuildContext] implies a combination of light mode, + /// high contrast, and elevated interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.light], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.light]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `true`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.elevated]. + final Color highContrastElevatedColor; + + /// The color to use when the [BuildContext] implies a combination of dark mode, + /// high contrast, and elevated interface elevation. + /// + /// In other words, this color will be the effective color of the [CupertinoDynamicColor] + /// after it is resolved against a [BuildContext] that: + /// - has a [CupertinoTheme] whose [CupertinoThemeData.brightness] is [Brightness.dark], + /// or a [MediaQuery] whose [MediaQueryData.platformBrightness] is [Brightness.dark]. + /// - has a [MediaQuery] whose [MediaQueryData.highContrast] is `true`. + /// - has a [CupertinoUserInterfaceLevel] that indicates [CupertinoUserInterfaceLevelData.elevated]. + final Color darkHighContrastElevatedColor; + + /// Resolves the given [Color] by calling [resolveFrom]. + /// + /// If the given color is already a concrete [Color], it will be returned as is. + /// If the given color is a [CupertinoDynamicColor], but the given [BuildContext] + /// lacks the dependencies required to the color resolution, the default trait + /// value will be used ([Brightness.light] platform brightness, normal contrast, + /// [CupertinoUserInterfaceLevelData.base] elevation level). + /// + /// See also: + /// + /// * [maybeResolve], which is similar to this function, but will allow a + /// null `resolvable` color. + static Color resolve(Color resolvable, BuildContext context) { + return (resolvable is CupertinoDynamicColor) ? resolvable.resolveFrom(context) : resolvable; + } + + /// Resolves the given [Color] by calling [resolveFrom]. + /// + /// If the given color is already a concrete [Color], it will be returned as is. + /// If the given color is null, returns null. + /// If the given color is a [CupertinoDynamicColor], but the given [BuildContext] + /// lacks the dependencies required to the color resolution, the default trait + /// value will be used ([Brightness.light] platform brightness, normal contrast, + /// [CupertinoUserInterfaceLevelData.base] elevation level). + /// + /// See also: + /// + /// * [resolve], which is similar to this function, but returns a + /// non-nullable value, and does not allow a null `resolvable` color. + static Color? maybeResolve(Color? resolvable, BuildContext context) { + return (resolvable is CupertinoDynamicColor) ? resolvable.resolveFrom(context) : resolvable; + } + + bool get _isPlatformBrightnessDependent { + return color != darkColor || + elevatedColor != darkElevatedColor || + highContrastColor != darkHighContrastColor || + highContrastElevatedColor != darkHighContrastElevatedColor; + } + + bool get _isHighContrastDependent { + return color != highContrastColor || + darkColor != darkHighContrastColor || + elevatedColor != highContrastElevatedColor || + darkElevatedColor != darkHighContrastElevatedColor; + } + + bool get _isInterfaceElevationDependent { + return color != elevatedColor || + darkColor != darkElevatedColor || + highContrastColor != highContrastElevatedColor || + darkHighContrastColor != darkHighContrastElevatedColor; + } + + /// Resolves this [CupertinoDynamicColor] using the provided [BuildContext]. + /// + /// Calling this method will create a new [CupertinoDynamicColor] that is + /// almost identical to this [CupertinoDynamicColor], except the effective + /// color is changed to adapt to the given [BuildContext]. + /// + /// For example, if the given [BuildContext] indicates the widgets in the + /// subtree should be displayed in dark mode (the surrounding + /// [CupertinoTheme]'s [CupertinoThemeData.brightness] or [MediaQuery]'s + /// [MediaQueryData.platformBrightness] is [Brightness.dark]), with a high + /// accessibility contrast (the surrounding [MediaQuery]'s + /// [MediaQueryData.highContrast] is `true`), and an elevated interface + /// elevation (the surrounding [CupertinoUserInterfaceLevel]'s `data` is + /// [CupertinoUserInterfaceLevelData.elevated]), the resolved + /// [CupertinoDynamicColor] will be the same as this [CupertinoDynamicColor], + /// except its effective color will be the `darkHighContrastElevatedColor` + /// variant from the original [CupertinoDynamicColor]. + /// + /// Calling this function may create dependencies on the closest instance of + /// some [InheritedWidget]s that enclose the given [BuildContext]. E.g., if + /// [darkColor] is different from [color], this method will call + /// [CupertinoTheme.maybeBrightnessOf] in an effort to determine the + /// brightness. If [color] is different from [highContrastColor], this method + /// will call [MediaQuery.maybeHighContrastOf] in an effort to determine the + /// high contrast setting. + /// + /// If any of the required dependencies are missing from the given context, + /// the default value of that trait will be used ([Brightness.light] platform + /// brightness, normal contrast, [CupertinoUserInterfaceLevelData.base] + /// elevation level). + CupertinoDynamicColor resolveFrom(BuildContext context) { + final Brightness brightness = _isPlatformBrightnessDependent + ? CupertinoTheme.maybeBrightnessOf(context) ?? Brightness.light + : Brightness.light; + + final CupertinoUserInterfaceLevelData level = _isInterfaceElevationDependent + ? CupertinoUserInterfaceLevel.maybeOf(context) ?? CupertinoUserInterfaceLevelData.base + : CupertinoUserInterfaceLevelData.base; + + final bool highContrast = + _isHighContrastDependent && (MediaQuery.maybeHighContrastOf(context) ?? false); + + final Color resolved = switch ((brightness, level, highContrast)) { + (Brightness.light, CupertinoUserInterfaceLevelData.base, false) => color, + (Brightness.light, CupertinoUserInterfaceLevelData.base, true) => highContrastColor, + (Brightness.light, CupertinoUserInterfaceLevelData.elevated, false) => elevatedColor, + (Brightness.light, CupertinoUserInterfaceLevelData.elevated, true) => + highContrastElevatedColor, + (Brightness.dark, CupertinoUserInterfaceLevelData.base, false) => darkColor, + (Brightness.dark, CupertinoUserInterfaceLevelData.base, true) => darkHighContrastColor, + (Brightness.dark, CupertinoUserInterfaceLevelData.elevated, false) => darkElevatedColor, + (Brightness.dark, CupertinoUserInterfaceLevelData.elevated, true) => + darkHighContrastElevatedColor, + }; + + Element? debugContext; + assert(() { + debugContext = context as Element; + return true; + }()); + return CupertinoDynamicColor._( + resolved, + color, + darkColor, + highContrastColor, + darkHighContrastColor, + elevatedColor, + darkElevatedColor, + highContrastElevatedColor, + darkHighContrastElevatedColor, + debugContext, + _debugLabel, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is CupertinoDynamicColor && + other.value == value && + other.color == color && + other.darkColor == darkColor && + other.highContrastColor == highContrastColor && + other.darkHighContrastColor == darkHighContrastColor && + other.elevatedColor == elevatedColor && + other.darkElevatedColor == darkElevatedColor && + other.highContrastElevatedColor == highContrastElevatedColor && + other.darkHighContrastElevatedColor == darkHighContrastElevatedColor; + } + + @override + int get hashCode => Object.hash( + value, + color, + darkColor, + highContrastColor, + elevatedColor, + darkElevatedColor, + darkHighContrastColor, + darkHighContrastElevatedColor, + highContrastElevatedColor, + ); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + String toString(String name, Color color) { + final marker = color == _effectiveColor ? '*' : ''; + return '$marker$name = $color$marker'; + } + + final xs = [ + toString('color', color), + if (_isPlatformBrightnessDependent) toString('darkColor', darkColor), + if (_isHighContrastDependent) toString('highContrastColor', highContrastColor), + if (_isPlatformBrightnessDependent && _isHighContrastDependent) + toString('darkHighContrastColor', darkHighContrastColor), + if (_isInterfaceElevationDependent) toString('elevatedColor', elevatedColor), + if (_isPlatformBrightnessDependent && _isInterfaceElevationDependent) + toString('darkElevatedColor', darkElevatedColor), + if (_isHighContrastDependent && _isInterfaceElevationDependent) + toString('highContrastElevatedColor', highContrastElevatedColor), + if (_isPlatformBrightnessDependent && + _isHighContrastDependent && + _isInterfaceElevationDependent) + toString('darkHighContrastElevatedColor', darkHighContrastElevatedColor), + ]; + + return '${_debugLabel ?? objectRuntimeType(this, 'CupertinoDynamicColor')}(${xs.join(', ')}, resolved by: ${_debugResolveContext?.widget ?? "UNRESOLVED"})'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + if (_debugLabel != null) { + properties.add(MessageProperty('debugLabel', _debugLabel)); + } + properties.add(createCupertinoColorProperty('color', color)); + if (_isPlatformBrightnessDependent) { + properties.add(createCupertinoColorProperty('darkColor', darkColor)); + } + if (_isHighContrastDependent) { + properties.add(createCupertinoColorProperty('highContrastColor', highContrastColor)); + } + if (_isPlatformBrightnessDependent && _isHighContrastDependent) { + properties.add(createCupertinoColorProperty('darkHighContrastColor', darkHighContrastColor)); + } + if (_isInterfaceElevationDependent) { + properties.add(createCupertinoColorProperty('elevatedColor', elevatedColor)); + } + if (_isPlatformBrightnessDependent && _isInterfaceElevationDependent) { + properties.add(createCupertinoColorProperty('darkElevatedColor', darkElevatedColor)); + } + if (_isHighContrastDependent && _isInterfaceElevationDependent) { + properties.add( + createCupertinoColorProperty('highContrastElevatedColor', highContrastElevatedColor), + ); + } + if (_isPlatformBrightnessDependent && + _isHighContrastDependent && + _isInterfaceElevationDependent) { + properties.add( + createCupertinoColorProperty( + 'darkHighContrastElevatedColor', + darkHighContrastElevatedColor, + ), + ); + } + + if (_debugResolveContext != null) { + properties.add(DiagnosticsProperty('last resolved', _debugResolveContext)); + } + } + + @Deprecated( + 'Use component accessors like .r or .g, or toARGB32 for an explicit conversion. ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + int get value => _effectiveColor.value; + + @override + int toARGB32() => _effectiveColor.toARGB32(); + + @Deprecated( + 'Use (*.a * 255.0).round().clamp(0, 255). ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + int get alpha => _effectiveColor.alpha; + + @Deprecated( + 'Use (*.b * 255.0).round().clamp(0, 255). ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + int get blue => _effectiveColor.blue; + + @override + double computeLuminance() => _effectiveColor.computeLuminance(); + + @Deprecated( + 'Use (*.g * 255.0).round().clamp(0, 255). ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + int get green => _effectiveColor.green; + + @Deprecated( + 'Use .a. ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + double get opacity => _effectiveColor.opacity; + + @Deprecated( + 'Use (*.r * 255.0).round().clamp(0, 255). ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + int get red => _effectiveColor.red; + + @override + Color withAlpha(int a) => _effectiveColor.withAlpha(a); + + @override + Color withBlue(int b) => _effectiveColor.withBlue(b); + + @override + Color withGreen(int g) => _effectiveColor.withGreen(g); + + @Deprecated( + 'Use .withValues() to avoid precision loss. ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + @override + Color withOpacity(double opacity) => _effectiveColor.withOpacity(opacity); + + @override + Color withRed(int r) => _effectiveColor.withRed(r); + + @override + double get a => _effectiveColor.a; + + @override + double get r => _effectiveColor.r; + + @override + double get g => _effectiveColor.g; + + @override + double get b => _effectiveColor.b; + + @override + ColorSpace get colorSpace => _effectiveColor.colorSpace; + + @override + Color withValues({ + double? alpha, + double? red, + double? green, + double? blue, + ColorSpace? colorSpace, + }) => _effectiveColor.withValues( + alpha: alpha, + red: red, + green: green, + blue: blue, + colorSpace: colorSpace, + ); +} + +/// Creates a diagnostics property for [CupertinoDynamicColor]. +DiagnosticsProperty createCupertinoColorProperty( + String name, + Color? value, { + bool showName = true, + Object? defaultValue = kNoDefaultValue, + DiagnosticsTreeStyle style = DiagnosticsTreeStyle.singleLine, + DiagnosticLevel level = DiagnosticLevel.info, +}) { + if (value is CupertinoDynamicColor) { + return DiagnosticsProperty( + name, + value, + description: value._debugLabel, + showName: showName, + defaultValue: defaultValue, + style: style, + level: level, + ); + } else { + return ColorProperty( + name, + value, + showName: showName, + defaultValue: defaultValue, + style: style, + level: level, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/constants.dart b/packages/cupertino_ui/lib/src/constants.dart new file mode 100644 index 000000000000..3ecc2224255d --- /dev/null +++ b/packages/cupertino_ui/lib/src/constants.dart @@ -0,0 +1,94 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'checkbox.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'switch.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'button.dart'; + +/// The minimum dimension of any interactive region according to the iOS Human +/// Interface Guidelines. +/// +/// This is used to avoid small regions that are hard for the user to interact +/// with. It applies to both dimensions of a region, so a square of size +/// kMinInteractiveDimension x kMinInteractiveDimension is the smallest +/// acceptable region that should respond to gestures. +/// +/// See also: +/// +/// * [kMinInteractiveDimension] +/// * +const double kMinInteractiveDimensionCupertino = 44.0; + +/// The relative values needed to transform a color to it's equivalent focus +/// outline color. +/// +/// These are used to draw a focus ring around [CupertinoSwitch], +/// [CupertinoCheckbox], [CupertinoRadio] and [CupertinoButton]. +/// +/// See also: +/// +/// * +const double kCupertinoFocusColorOpacity = 0.80, + kCupertinoFocusColorBrightness = 0.69, + kCupertinoFocusColorSaturation = 0.835; + +/// Opacity values for the background of a [CupertinoButton.tinted]. +/// +/// See also: +/// +/// * +const double kCupertinoButtonTintedOpacityLight = 0.12, kCupertinoButtonTintedOpacityDark = 0.26; + +/// The default value for [IconThemeData.size] of [CupertinoButton.child]. +/// +/// Set to match the most-frequent size of icons in iOS (matches md/lg). +/// +/// Used only when the [CupertinoTextThemeData.actionTextStyle] or [CupertinoTextThemeData.actionSmallTextStyle] +/// has a null [TextStyle.fontSize]. +const double kCupertinoButtonDefaultIconSize = 20.0; + +/// The padding values for the different [CupertinoButtonSize]s. +/// +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +const Map kCupertinoButtonPadding = + { + CupertinoButtonSize.small: EdgeInsets.symmetric(vertical: 6, horizontal: 12), + CupertinoButtonSize.medium: EdgeInsets.symmetric(vertical: 10, horizontal: 15), + CupertinoButtonSize.large: EdgeInsets.symmetric(vertical: 16, horizontal: 20), + }; + +/// The border radius values for the different [CupertinoButtonSize]s. +/// +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +const Map kCupertinoButtonSizeBorderRadius = + { + CupertinoButtonSize.small: BorderRadius.all(Radius.circular(40)), + CupertinoButtonSize.medium: BorderRadius.all(Radius.circular(40)), + CupertinoButtonSize.large: BorderRadius.all(Radius.circular(12)), + }; + +/// The minimum size of a [CupertinoButton] based on the [CupertinoButtonSize]. +/// +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +const Map kCupertinoButtonMinSize = { + CupertinoButtonSize.small: 28, + CupertinoButtonSize.medium: 32, + CupertinoButtonSize.large: 44, +}; + +/// The distance a button needs to be moved after being pressed for its opacity to change. +/// +/// The opacity changes when the position moved is this distance away from the button. +/// This variable is effective on mobile platforms. For desktop platforms, a distance of 0 is used. +/// +/// This value was obtained through actual testing on an iOS 18.1 simulator. +const double kCupertinoButtonTapMoveSlop = 70.0; diff --git a/packages/cupertino_ui/lib/src/context_menu.dart b/packages/cupertino_ui/lib/src/context_menu.dart new file mode 100644 index 000000000000..fabb65f4c44d --- /dev/null +++ b/packages/cupertino_ui/lib/src/context_menu.dart @@ -0,0 +1,1554 @@ +// 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. + +/// @docImport 'context_menu_action.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' show HapticFeedback; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'localizations.dart'; +import 'scrollbar.dart'; + +// The scale of the child at the time that the CupertinoContextMenu opens. +// This value was eyeballed from a physical device running iOS 13.1.2. +const double _kOpenScale = 1.15; + +// The smallest possible scale of the child, used if opening the +// CupertinoContextMenu would cause it to go outside the safe area. This value +// was eyeballed from the Xcode iPhone simulator running iOS 16.1. +const double _kMinScaleFactor = 1.02; + +// The ratio for the borderRadius of the context menu preview image. This value +// was eyeballed by overlapping the CupertinoContextMenu with a context menu +// from iOS 16.0 in the Xcode iPhone simulator. +const double _previewBorderRadiusRatio = 12.0; + +// The duration of the transition used when a modal popup is shown. Eyeballed +// from a physical device running iOS 13.1.2. +const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); + +// The duration it takes for the CupertinoContextMenu to open. +// This value was eyeballed from the Xcode simulator running iOS 16.0. +const Duration _previewLongPressTimeout = Duration(milliseconds: 800); + +// The total length of the combined animations until the menu is fully open. +final int _animationDuration = + _previewLongPressTimeout.inMilliseconds + _kModalPopupTransitionDuration.inMilliseconds; + +// The final box shadow for the opening child widget. +// This value was eyeballed from the Xcode simulator running iOS 16.0. +const List _endBoxShadow = [ + BoxShadow(color: Color(0x40000000), blurRadius: 10.0, spreadRadius: 0.5), +]; + +const Color _borderColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFA9A9AF), + darkColor: Color(0xFF57585A), +); + +const Color _kBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFF1F1F1), + darkColor: Color(0xFF212122), +); + +typedef _DismissCallback = void Function(BuildContext context, double scale, double opacity); + +/// A function that builds the child and handles the transition between the +/// default child and the preview when the CupertinoContextMenu is open. +typedef CupertinoContextMenuBuilder = + Widget Function(BuildContext context, Animation animation); + +// Given a GlobalKey, return the Rect of the corresponding RenderBox's +// paintBounds in global coordinates. +Rect _getRect(GlobalKey globalKey) { + assert(globalKey.currentContext != null); + final renderBoxContainer = globalKey.currentContext!.findRenderObject()! as RenderBox; + return Rect.fromPoints( + renderBoxContainer.localToGlobal(renderBoxContainer.paintBounds.topLeft), + renderBoxContainer.localToGlobal(renderBoxContainer.paintBounds.bottomRight), + ); +} + +// The context menu arranges itself slightly differently based on the location +// on the screen of [CupertinoContextMenu.child] before the +// [CupertinoContextMenu] opens. +enum _ContextMenuLocation { center, left, right } + +/// A full-screen modal route that opens when the [child] is long-pressed. +/// +/// When open, the [CupertinoContextMenu] shows the child in a large full-screen +/// [Overlay] with a list of buttons specified by [actions]. The child/preview is +/// placed in an [Expanded] widget so that it will grow to fill the Overlay if +/// its size is unconstrained. +/// +/// When closed, the [CupertinoContextMenu] displays the child as if the +/// [CupertinoContextMenu] were not there. Sizing and positioning is unaffected. +/// The menu can be closed like other [PopupRoute]s, such as by tapping the +/// background or by calling `Navigator.pop(context)`. Unlike [PopupRoute], it can +/// also be closed by swiping downwards. +/// +/// {@tool dartpad} +/// This sample shows a very simple [CupertinoContextMenu] for the Flutter logo. +/// Long press on it to open. +/// +/// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows a similar CupertinoContextMenu, this time using [builder] +/// to add a border radius to the widget. +/// +/// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * +class CupertinoContextMenu extends StatefulWidget { + /// Create a context menu. + /// + /// The [actions] parameter cannot be empty. + CupertinoContextMenu({ + super.key, + required this.actions, + required Widget this.child, + this.enableHapticFeedback = false, + }) : assert(actions.isNotEmpty), + builder = ((BuildContext context, Animation animation) => child); + + /// Creates a context menu with a custom [builder] controlling the widget. + /// + /// Use instead of the default constructor when it is needed to have a more + /// custom animation. + /// + /// The [actions] parameter cannot be empty. + CupertinoContextMenu.builder({ + super.key, + required this.actions, + required this.builder, + this.enableHapticFeedback = false, + }) : assert(actions.isNotEmpty), + child = null; + + /// Exposes the default border radius for matching iOS 16.0 behavior. This + /// value was eyeballed from the iOS simulator running iOS 16.0. + /// + /// {@tool snippet} + /// + /// Below is example code in order to match the default border radius for an + /// iOS 16.0 open preview. + /// + /// ```dart + /// CupertinoContextMenu.builder( + /// actions: [ + /// CupertinoContextMenuAction( + /// child: const Text('Action one'), + /// onPressed: () {}, + /// ), + /// ], + /// builder:(BuildContext context, Animation animation) { + /// final Animation borderRadiusAnimation = BorderRadiusTween( + /// begin: BorderRadius.zero, + /// end: BorderRadius.circular(CupertinoContextMenu.kOpenBorderRadius), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// CupertinoContextMenu.animationOpensAt, + /// 1.0, + /// ), + /// ), + /// ); + /// + /// final Animation boxDecorationAnimation = DecorationTween( + /// begin: const BoxDecoration( + /// boxShadow: [], + /// ), + /// end: const BoxDecoration( + /// boxShadow: CupertinoContextMenu.kEndBoxShadow, + /// ), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// 0.0, + /// CupertinoContextMenu.animationOpensAt, + /// ), + /// ) + /// ); + /// + /// return Container( + /// decoration: + /// animation.value < CupertinoContextMenu.animationOpensAt ? boxDecorationAnimation.value : null, + /// child: FittedBox( + /// fit: BoxFit.cover, + /// child: ClipRSuperellipse( + /// borderRadius: borderRadiusAnimation.value ?? BorderRadius.zero, + /// child: SizedBox( + /// height: 150, + /// width: 150, + /// child: Image.network('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg'), + /// ), + /// ), + /// ) + /// ); + /// }, + /// ) + /// ``` + /// + /// {@end-tool} + static const double kOpenBorderRadius = _previewBorderRadiusRatio; + + /// Exposes the final box shadow of the opening animation of the child widget + /// to match the default behavior of the native iOS widget. This value was + /// eyeballed from the iOS simulator running iOS 16.0. + static const List kEndBoxShadow = _endBoxShadow; + + /// The point at which the CupertinoContextMenu begins to animate + /// into the open position. + /// + /// A value between 0.0 and 1.0 corresponding to a point in [builder]'s + /// animation. When passing in an animation to [builder] the range before + /// [animationOpensAt] will correspond to the animation when the widget is + /// pressed and held, and the range after is the animation as the menu is + /// fully opening. For an example, see the documentation for [builder]. + static final double animationOpensAt = + _previewLongPressTimeout.inMilliseconds / _animationDuration; + + /// The background color of a [CupertinoContextMenuAction] and a + /// [CupertinoContextMenu] sheet. + static const Color kBackgroundColor = _kBackgroundColor; + + /// A function that returns a widget to be used alternatively from [child]. + /// + /// The widget returned by the function will be shown at all times: when the + /// [CupertinoContextMenu] is closed, when it is in the middle of opening, + /// and when it is fully open. This will overwrite the default animation that + /// matches the behavior of an iOS 16.0 context menu. + /// + /// This builder can be used instead of the child when the intended child has + /// a property that would conflict with the default animation, such as a + /// border radius or a shadow, or if a more custom animation is needed. + /// + /// In addition to the current [BuildContext], the function is also called + /// with an [Animation]. The complete animation goes from 0 to 1 when + /// the CupertinoContextMenu opens, and from 1 to 0 when it closes, and it can + /// be used to animate the widget in sync with this opening and closing. + /// + /// The animation works in two stages. The first happens on press and hold of + /// the widget from 0 to [animationOpensAt], and the second stage for when the + /// widget fully opens up to the menu, from [animationOpensAt] to 1. + /// + /// {@tool snippet} + /// + /// Below is an example of using [builder] to show an image tile setup to be + /// opened in the default way to match a native iOS 16.0 app. The behavior + /// will match what will happen if the simple child image was passed as just + /// the [child] parameter, instead of [builder]. This can be manipulated to + /// add more customizability to the widget's animation. + /// + /// ```dart + /// CupertinoContextMenu.builder( + /// actions: [ + /// CupertinoContextMenuAction( + /// child: const Text('Action one'), + /// onPressed: () {}, + /// ), + /// ], + /// builder:(BuildContext context, Animation animation) { + /// final Animation borderRadiusAnimation = BorderRadiusTween( + /// begin: BorderRadius.zero, + /// end: BorderRadius.circular(CupertinoContextMenu.kOpenBorderRadius), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// CupertinoContextMenu.animationOpensAt, + /// 1.0, + /// ), + /// ), + /// ); + /// + /// final Animation boxDecorationAnimation = DecorationTween( + /// begin: const BoxDecoration( + /// boxShadow: [], + /// ), + /// end: const BoxDecoration( + /// boxShadow: CupertinoContextMenu.kEndBoxShadow, + /// ), + /// ).animate( + /// CurvedAnimation( + /// parent: animation, + /// curve: Interval( + /// 0.0, + /// CupertinoContextMenu.animationOpensAt, + /// ), + /// ), + /// ); + /// + /// return Container( + /// decoration: + /// animation.value < CupertinoContextMenu.animationOpensAt ? boxDecorationAnimation.value : null, + /// child: FittedBox( + /// fit: BoxFit.cover, + /// child: ClipRSuperellipse( + /// borderRadius: borderRadiusAnimation.value ?? BorderRadius.zero, + /// child: SizedBox( + /// height: 150, + /// width: 150, + /// child: Image.network('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg'), + /// ), + /// ), + /// ), + /// ); + /// }, + /// ) + /// ``` + /// + /// {@end-tool} + /// + /// {@tool dartpad} + /// Additionally below is an example of a real world use case for [builder]. + /// + /// If a widget is passed to the [child] parameter with properties that + /// conflict with the default animation, in this case the border radius, + /// unwanted behaviors can arise. Here a boxed shadow will wrap the widget as + /// it is expanded. To handle this, a more custom animation and widget can be + /// passed to the builder, using values exposed by [CupertinoContextMenu], + /// like [CupertinoContextMenu.kEndBoxShadow], to match the native iOS + /// animation as close as desired. + /// + /// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.1.dart ** + /// {@end-tool} + final CupertinoContextMenuBuilder builder; + + // TODO(mitchgoodwin): deprecate [child] with builder refactor https://github.com/flutter/flutter/issues/116306 + + /// The widget that can be "opened" with the [CupertinoContextMenu]. + /// + /// When the [CupertinoContextMenu] is long-pressed, the menu will open and + /// this widget will be moved to the new route and placed inside of an + /// [Expanded] widget. This allows the child to resize to fit in its place in + /// the new route, if it doesn't size itself. + /// + /// When the [CupertinoContextMenu] is "closed", this widget acts like a + /// [Container], i.e. it does not constrain its child's size or affect its + /// position. + final Widget? child; + + /// The actions that are shown in the menu. + /// + /// These actions are typically [CupertinoContextMenuAction]s. + /// + /// This parameter must not be empty. + final List actions; + + /// If true, clicking on the [CupertinoContextMenuAction]s will + /// produce haptic feedback. + /// + /// Uses [HapticFeedback.heavyImpact] when activated. + /// Defaults to false. + final bool enableHapticFeedback; + + @override + State createState() => _CupertinoContextMenuState(); +} + +class _CupertinoContextMenuState extends State with TickerProviderStateMixin { + final GlobalKey _childGlobalKey = GlobalKey(); + bool _childHidden = false; + // Animates the child while it's opening. + late AnimationController _openController; + Rect? _decoyChildEndRect; + late double _scaleFactor; + OverlayEntry? _lastOverlayEntry; + _ContextMenuRoute? _route; + final double _midpoint = CupertinoContextMenu.animationOpensAt / 2; + late final TapGestureRecognizer _tapGestureRecognizer; + + @override + void initState() { + super.initState(); + _openController = AnimationController( + duration: _previewLongPressTimeout, + vsync: this, + upperBound: CupertinoContextMenu.animationOpensAt, + ); + _openController.addStatusListener(_onDecoyAnimationStatusChange); + _tapGestureRecognizer = TapGestureRecognizer() + ..onTapCancel = _onTapCancel + ..onTapDown = _onTapDown + ..onTapUp = _onTapUp + ..onTap = _onTap; + } + + void _listenerCallback() { + if (_openController.status != AnimationStatus.reverse && _openController.value >= _midpoint) { + if (widget.enableHapticFeedback) { + HapticFeedback.heavyImpact(); + } + _tapGestureRecognizer.resolve(GestureDisposition.accepted); + _openController.removeListener(_listenerCallback); + } + } + + // Determine the _ContextMenuLocation based on the location of the original + // child in the screen. + // + // The location of the original child is used to determine how to horizontally + // align the content of the open CupertinoContextMenu. For example, if the + // child is near the center of the screen, it will also appear in the center + // of the screen when the menu is open, and the actions will be centered below + // it. + _ContextMenuLocation get _contextMenuLocation { + final Rect childRect = _getRect(_childGlobalKey); + final double screenWidth = MediaQuery.widthOf(context); + + final double center = screenWidth / 2; + final bool centerDividesChild = childRect.left < center && childRect.right > center; + final double distanceFromCenter = (center - childRect.center.dx).abs(); + if (centerDividesChild && distanceFromCenter <= childRect.width / 4) { + return _ContextMenuLocation.center; + } + + if (childRect.center.dx > center) { + return _ContextMenuLocation.right; + } + + return _ContextMenuLocation.left; + } + + // Constrain the size of the expanded child so that it does not go outside the + // safe area. + // + // See https://github.com/flutter/flutter/issues/122951. + static double _getScaleFactor(Rect childRect, EdgeInsets padding, Size size) { + final double leftMaxScale = 2 * (childRect.center.dx - padding.left) / childRect.width; + final double topMaxScale = 2 * (childRect.center.dy - padding.top) / childRect.height; + final double rightMaxScale = + 2 * (size.width - padding.right - childRect.center.dx) / childRect.width; + final double bottomMaxScale = + 2 * (size.height - padding.bottom - childRect.center.dy) / childRect.height; + final double minWidth = math.min(leftMaxScale, rightMaxScale); + final double minHeight = math.min(topMaxScale, bottomMaxScale); + + // Return the smallest scale factor that keeps the child mostly onscreen. + return clampDouble(math.min(minWidth, minHeight), _kMinScaleFactor, _kOpenScale); + } + + /// The default preview builder if none is provided. It makes a rectangle + /// around the child widget with rounded borders, matching the iOS 16 opened + /// context menu eyeballed on the Xcode iOS simulator. + static Widget _defaultPreviewBuilder( + BuildContext context, + Animation animation, + Widget child, + ) { + return FittedBox( + fit: BoxFit.cover, + child: ClipRSuperellipse( + borderRadius: BorderRadius.circular(_previewBorderRadiusRatio * animation.value), + child: child, + ), + ); + } + + // Push the new route and open the CupertinoContextMenu overlay. + void _openContextMenu() { + setState(() { + _childHidden = true; + }); + + _route = _ContextMenuRoute( + actions: widget.actions, + barrierLabel: CupertinoLocalizations.of(context).menuDismissLabel, + filter: ui.ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + contextMenuLocation: _contextMenuLocation, + previousChildRect: _decoyChildEndRect!, + scaleFactor: _scaleFactor, + builder: (BuildContext context, Animation animation) { + if (widget.child == null) { + final Animation localAnimation = Tween( + begin: CupertinoContextMenu.animationOpensAt, + end: 1, + ).animate(animation); + return widget.builder(context, localAnimation); + } + return _defaultPreviewBuilder(context, animation, widget.child!); + }, + ); + Navigator.of(context, rootNavigator: true).push(_route!); + _route!.animation!.addStatusListener(_routeAnimationStatusListener); + } + + void _removeContextMenuDecoy() { + // Keep the decoy on the screen for one extra frame. We have to do this + // because _ContextMenuRoute renders its first frame offscreen. + // Otherwise there would be a visible flash when nothing is rendered for + // one frame. + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + if (mounted) { + _closeContextMenu(); + _openController.reset(); + } + }, debugLabel: 'removeContextMenuDecoy'); + } + + void _closeContextMenu() { + _lastOverlayEntry?.remove(); + _lastOverlayEntry?.dispose(); + _lastOverlayEntry = null; + } + + void _onDecoyAnimationStatusChange(AnimationStatus animationStatus) { + switch (animationStatus) { + case AnimationStatus.dismissed: + if (_route == null) { + setState(() { + _childHidden = false; + }); + } + _closeContextMenu(); + case AnimationStatus.completed: + _openContextMenu(); + _removeContextMenuDecoy(); + case AnimationStatus.forward: + case AnimationStatus.reverse: + if (!ModalRoute.of(context)!.isCurrent) { + _removeContextMenuDecoy(); + } + return; + } + } + + // Watch for when _ContextMenuRoute is closed and return to the state where + // the CupertinoContextMenu just behaves as a Container. + void _routeAnimationStatusListener(AnimationStatus status) { + if (!status.isDismissed) { + return; + } + if (mounted) { + setState(() { + _childHidden = false; + }); + } + _route!.animation!.removeStatusListener(_routeAnimationStatusListener); + _route = null; + } + + void _onTapCompleted() { + _openController.removeListener(_listenerCallback); + if (_openController.isAnimating && _openController.value < _midpoint) { + _openController.reverse(); + } + } + + void _onTap() { + _onTapCompleted(); + } + + void _onTapCancel() { + _onTapCompleted(); + } + + void _onTapUp(TapUpDetails details) { + _onTapCompleted(); + } + + void _onTapDown(TapDownDetails details) { + _openController.addListener(_listenerCallback); + setState(() { + _childHidden = true; + }); + + final Rect childRect = _getRect(_childGlobalKey); + _scaleFactor = _getScaleFactor( + childRect, + MediaQuery.paddingOf(context), + MediaQuery.sizeOf(context), + ); + _decoyChildEndRect = Rect.fromCenter( + center: childRect.center, + width: childRect.width * _scaleFactor, + height: childRect.height * _scaleFactor, + ); + + // Create a decoy child in an overlay directly on top of the original child. + // TODO(justinmc): There is a known inconsistency with native here, due to + // doing the bounce animation using a decoy in the top level Overlay. The + // decoy will pop on top of the AppBar if the child is partially behind it, + // such as a top item in a partially scrolled view. However, if we don't use + // an overlay, then the decoy will appear behind its neighboring widget when + // it expands. This may be solvable by adding a widget to Scaffold that's + // underneath the AppBar. + _lastOverlayEntry = OverlayEntry( + builder: (BuildContext context) { + return _DecoyChild( + beginRect: childRect, + controller: _openController, + endRect: _decoyChildEndRect, + builder: widget.builder, + child: widget.child, + ); + }, + ); + Overlay.of(context, rootOverlay: true, debugRequiredFor: widget).insert(_lastOverlayEntry!); + _openController.forward(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: Listener( + onPointerDown: _tapGestureRecognizer.addPointer, + child: TickerMode( + enabled: !_childHidden, + child: Visibility.maintain( + key: _childGlobalKey, + visible: !_childHidden, + child: widget.builder(context, _openController), + ), + ), + ), + ); + } + + @override + void dispose() { + _closeContextMenu(); + _tapGestureRecognizer.dispose(); + _openController.dispose(); + super.dispose(); + } +} + +// A floating copy of the CupertinoContextMenu's child. +// +// When the child is pressed, but before the CupertinoContextMenu opens, it does +// an animation where it slowly grows. This is implemented by hiding the +// original child and placing _DecoyChild on top of it in an Overlay. The use of +// an Overlay allows the _DecoyChild to appear on top of siblings of the +// original child. +class _DecoyChild extends StatefulWidget { + const _DecoyChild({ + this.beginRect, + required this.controller, + this.endRect, + this.child, + this.builder, + }); + + final Rect? beginRect; + final AnimationController controller; + final Rect? endRect; + final Widget? child; + final CupertinoContextMenuBuilder? builder; + + @override + _DecoyChildState createState() => _DecoyChildState(); +} + +class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin { + late Animation _rect; + late Animation _boxDecoration; + late final CurvedAnimation _boxDecorationCurvedAnimation; + + @override + void initState() { + super.initState(); + + const beginPause = 1.0; + const openAnimationLength = 5.0; + const double totalOpenAnimationLength = beginPause + openAnimationLength; + final double endPause = + ((totalOpenAnimationLength * _animationDuration) / + _previewLongPressTimeout.inMilliseconds) - + totalOpenAnimationLength; + + // The timing on the animation was eyeballed from the Xcode iOS simulator + // running iOS 16.0. + // Because the animation no longer goes from 0.0 to 1.0, but to a number + // depending on the ratio between the press animation time and the opening + // animation time, a pause needs to be added to the end of the tween + // sequence that completes that ratio. This is to allow the animation to + // fully complete as expected without doing crazy math to the _kOpenScale + // value. This change was necessary from the inclusion of the builder and + // the complete animation value that it passes along. + _rect = TweenSequence(>[ + TweenSequenceItem( + tween: RectTween( + begin: widget.beginRect, + end: widget.beginRect, + ).chain(CurveTween(curve: Curves.linear)), + weight: beginPause, + ), + TweenSequenceItem( + tween: RectTween( + begin: widget.beginRect, + end: widget.endRect, + ).chain(CurveTween(curve: Curves.easeOutSine)), + weight: openAnimationLength, + ), + TweenSequenceItem( + tween: RectTween( + begin: widget.endRect, + end: widget.endRect, + ).chain(CurveTween(curve: Curves.linear)), + weight: endPause, + ), + ]).animate(widget.controller); + + _boxDecorationCurvedAnimation = CurvedAnimation( + parent: widget.controller, + curve: Interval(0.0, CupertinoContextMenu.animationOpensAt), + ); + _boxDecoration = DecorationTween( + begin: const BoxDecoration(boxShadow: []), + end: const BoxDecoration(boxShadow: _endBoxShadow), + ).animate(_boxDecorationCurvedAnimation); + } + + Widget _buildAnimation(BuildContext context, Widget? child) { + return Positioned.fromRect( + rect: _rect.value!, + child: Container(decoration: _boxDecoration.value, child: widget.child), + ); + } + + Widget _buildBuilder(BuildContext context, Widget? child) { + return Positioned.fromRect( + rect: _rect.value!, + child: widget.builder!(context, widget.controller), + ); + } + + @override + void dispose() { + _boxDecorationCurvedAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + AnimatedBuilder( + builder: widget.child != null ? _buildAnimation : _buildBuilder, + animation: widget.controller, + ), + ], + ); + } +} + +// The open CupertinoContextMenu modal. +class _ContextMenuRoute extends PopupRoute { + // Build a _ContextMenuRoute. + _ContextMenuRoute({ + required List actions, + required _ContextMenuLocation contextMenuLocation, + this.barrierLabel, + CupertinoContextMenuBuilder? builder, + super.filter, + required Rect previousChildRect, + required double scaleFactor, + super.settings, + }) : assert(actions.isNotEmpty), + _actions = actions, + _builder = builder, + _contextMenuLocation = contextMenuLocation, + _previousChildRect = previousChildRect, + _scaleFactor = scaleFactor; + + // Barrier color for a Cupertino modal barrier. + static const Color _kModalBarrierColor = Color(0x6604040F); + + final List _actions; + final CupertinoContextMenuBuilder? _builder; + final GlobalKey _childGlobalKey = GlobalKey(); + final _ContextMenuLocation _contextMenuLocation; + bool _externalOffstage = false; + bool _internalOffstage = false; + final double _scaleFactor; + Orientation? _lastOrientation; + // The Rect of the child at the moment that the CupertinoContextMenu opens. + final Rect _previousChildRect; + double? _scale = 1.0; + final GlobalKey _sheetGlobalKey = GlobalKey(); + + static final CurveTween _curve = CurveTween(curve: Curves.easeOutBack); + static final CurveTween _curveReverse = CurveTween(curve: Curves.easeInBack); + static final RectTween _rectTween = RectTween(); + static final Animatable _rectAnimatable = _rectTween.chain(_curve); + static final RectTween _rectTweenReverse = RectTween(); + static final Animatable _rectAnimatableReverse = _rectTweenReverse.chain(_curveReverse); + static final RectTween _sheetRectTween = RectTween(); + final Animatable _sheetRectAnimatable = _sheetRectTween.chain(_curve); + final Animatable _sheetRectAnimatableReverse = _sheetRectTween.chain(_curveReverse); + static final Tween _sheetScaleTween = Tween(); + static final Animatable _sheetScaleAnimatable = _sheetScaleTween.chain(_curve); + static final Animatable _sheetScaleAnimatableReverse = _sheetScaleTween.chain( + _curveReverse, + ); + final Tween _opacityTween = Tween(begin: 0.0, end: 1.0); + late Animation _sheetOpacity; + + @override + final String? barrierLabel; + + @override + Color get barrierColor => _kModalBarrierColor; + + @override + bool get barrierDismissible => true; + + @override + bool get semanticsDismissible => false; + + @override + Duration get transitionDuration => _kModalPopupTransitionDuration; + + CurvedAnimation? _curvedAnimation; + + CurvedAnimation? _sheetOpacityCurvedAnimation; + + // Getting the RenderBox doesn't include the scale from the Transform.scale, + // so it's manually accounted for here. + static Rect _getScaledRect(GlobalKey globalKey, double scale) { + final Rect childRect = _getRect(globalKey); + final Size sizeScaled = childRect.size * scale; + final offsetScaled = Offset( + childRect.left + (childRect.size.width - sizeScaled.width) / 2, + childRect.top + (childRect.size.height - sizeScaled.height) / 2, + ); + return offsetScaled & sizeScaled; + } + + // Get the alignment for the _ContextMenuSheet's Transform.scale based on the + // contextMenuLocation and orientation. + static AlignmentDirectional getSheetAlignment( + _ContextMenuLocation contextMenuLocation, + Orientation orientation, + ) { + return switch (contextMenuLocation) { + _ContextMenuLocation.center when orientation == Orientation.landscape => + AlignmentDirectional.topStart, + _ContextMenuLocation.center => AlignmentDirectional.topCenter, + _ContextMenuLocation.right => AlignmentDirectional.topEnd, + _ContextMenuLocation.left => AlignmentDirectional.topStart, + }; + } + + // The place to start the sheetRect animation from. + static Rect _getSheetRectBegin( + Orientation? orientation, + _ContextMenuLocation contextMenuLocation, + Rect childRect, + Rect sheetRect, + ) { + switch (contextMenuLocation) { + case _ContextMenuLocation.center: + final Offset target = orientation == Orientation.portrait + ? childRect.bottomCenter + : childRect.topCenter; + final Offset centered = target - Offset(sheetRect.width / 2, 0.0); + return centered & sheetRect.size; + case _ContextMenuLocation.right: + final Offset target = orientation == Orientation.portrait + ? childRect.bottomRight + : childRect.topRight; + return (target - Offset(sheetRect.width, 0.0)) & sheetRect.size; + case _ContextMenuLocation.left: + final Offset target = orientation == Orientation.portrait + ? childRect.bottomLeft + : childRect.topLeft; + return target & sheetRect.size; + } + } + + void _onDismiss(BuildContext context, double scale, double opacity) { + _scale = scale; + _opacityTween.end = opacity; + _sheetOpacityCurvedAnimation = CurvedAnimation( + parent: animation!, + curve: const Interval(0.9, 1.0), + ); + _sheetOpacity = _opacityTween.animate(_sheetOpacityCurvedAnimation!); + Navigator.of(context).pop(); + } + + // Take measurements on the child and _ContextMenuSheet and update the + // animation tweens to match. + void _updateTweenRects() { + final Rect childRect = _scale == null + ? _getRect(_childGlobalKey) + : _getScaledRect(_childGlobalKey, _scale!); + _rectTween.begin = _previousChildRect; + _rectTween.end = childRect; + + // When opening, the transition happens from the end of the child's bounce + // animation to the final state. When closing, it goes from the final state + // to the original position before the bounce. + final childRectOriginal = Rect.fromCenter( + center: _previousChildRect.center, + width: _previousChildRect.width / _scaleFactor, + height: _previousChildRect.height / _scaleFactor, + ); + + final Rect sheetRect = _getRect(_sheetGlobalKey); + final Rect sheetRectBegin = _getSheetRectBegin( + _lastOrientation, + _contextMenuLocation, + childRectOriginal, + sheetRect, + ); + _sheetRectTween.begin = sheetRectBegin; + _sheetRectTween.end = sheetRect; + _sheetScaleTween.begin = 0.0; + _sheetScaleTween.end = _scale; + + _rectTweenReverse.begin = childRectOriginal; + _rectTweenReverse.end = childRect; + } + + void _setOffstageInternally() { + super.offstage = _externalOffstage || _internalOffstage; + // It's necessary to call changedInternalState to get the backdrop to + // update. + changedInternalState(); + } + + @override + bool didPop(T? result) { + _updateTweenRects(); + return super.didPop(result); + } + + @override + set offstage(bool value) { + _externalOffstage = value; + _setOffstageInternally(); + } + + @override + TickerFuture didPush() { + _internalOffstage = true; + _setOffstageInternally(); + + // Render one frame offstage in the final position so that we can take + // measurements of its layout and then animate to them. + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _updateTweenRects(); + _internalOffstage = false; + _setOffstageInternally(); + }, debugLabel: 'renderContextMenuRouteOffstage'); + return super.didPush(); + } + + @override + Animation createAnimation() { + final Animation animation = super.createAnimation(); + if (_curvedAnimation?.parent != animation) { + _curvedAnimation?.dispose(); + _curvedAnimation = CurvedAnimation(parent: animation, curve: Curves.linear); + } + _sheetOpacity = _opacityTween.animate(_curvedAnimation!); + return animation; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + // This is usually used to build the "page", which is then passed to + // buildTransitions as child, the idea being that buildTransitions will + // animate the entire page into the scene. In the case of _ContextMenuRoute, + // two individual pieces of the page are animated into the scene in + // buildTransitions, and a SizedBox.shrink() is returned here. + return const SizedBox.shrink(); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + _lastOrientation = orientation; + + // While the animation is running, render everything in a Stack so that + // they're movable. + if (!animation.isCompleted) { + final reverse = animation.status == AnimationStatus.reverse; + final Rect rect = reverse + ? _rectAnimatableReverse.evaluate(animation)! + : _rectAnimatable.evaluate(animation)!; + final Rect sheetRect = reverse + ? _sheetRectAnimatableReverse.evaluate(animation)! + : _sheetRectAnimatable.evaluate(animation)!; + final double sheetScale = reverse + ? _sheetScaleAnimatableReverse.evaluate(animation) + : _sheetScaleAnimatable.evaluate(animation); + return Stack( + children: [ + Positioned.fromRect( + rect: sheetRect, + child: FadeTransition( + opacity: _sheetOpacity, + child: Transform.scale( + alignment: getSheetAlignment(_contextMenuLocation, orientation), + scale: sheetScale, + child: _ContextMenuSheet( + key: _sheetGlobalKey, + actions: _actions, + contextMenuLocation: _contextMenuLocation, + orientation: orientation, + ), + ), + ), + ), + Positioned.fromRect( + key: _childGlobalKey, + rect: rect, + child: _builder!(context, animation), + ), + ], + ); + } + + // When the animation is done, just render everything in a static layout + // in the final position. + return _ContextMenuRouteStatic( + actions: _actions, + childGlobalKey: _childGlobalKey, + contextMenuLocation: _contextMenuLocation, + onDismiss: _onDismiss, + orientation: orientation, + sheetGlobalKey: _sheetGlobalKey, + childRect: _previousChildRect, + child: _builder!(context, animation), + ); + }, + ); + } + + @override + void dispose() { + _curvedAnimation?.dispose(); + _sheetOpacityCurvedAnimation?.dispose(); + super.dispose(); + } +} + +// The final state of the _ContextMenuRoute after animating in and before +// animating out. +class _ContextMenuRouteStatic extends StatefulWidget { + const _ContextMenuRouteStatic({ + this.actions, + required this.child, + this.childGlobalKey, + required this.contextMenuLocation, + this.onDismiss, + required this.orientation, + this.sheetGlobalKey, + required this.childRect, + }); + + final List? actions; + final Widget child; + final GlobalKey? childGlobalKey; + final _ContextMenuLocation contextMenuLocation; + final _DismissCallback? onDismiss; + final Orientation orientation; + final GlobalKey? sheetGlobalKey; + final Rect childRect; + + @override + _ContextMenuRouteStaticState createState() => _ContextMenuRouteStaticState(); +} + +class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic> + with TickerProviderStateMixin { + // The child is scaled down as it is dragged down until it hits this minimum + // value. + static const double _kMinScale = 0.8; + // The CupertinoContextMenuSheet disappears at this scale. + static const double _kSheetScaleThreshold = 0.9; + static const double _kPadding = 20.0; + static const double _kDamping = 400.0; + static const Duration _kMoveControllerDuration = Duration(milliseconds: 600); + + late Offset _dragOffset; + double _lastScale = 1.0; + late final AnimationController _moveController; + late final CurvedAnimation _moveCurvedAnimation; + late final AnimationController _sheetController; + late final CurvedAnimation _sheetCurvedAnimation; + late Animation _moveAnimation; + late Animation _sheetScaleAnimation; + late Animation _sheetOpacityAnimation; + + // The scale of the child changes as a function of the distance it is dragged. + static double _getScale(Orientation orientation, double maxDragDistance, double dy) { + final double dyDirectional = dy <= 0.0 ? dy : -dy; + return math.max(_kMinScale, (maxDragDistance + dyDirectional) / maxDragDistance); + } + + void _onPanStart(DragStartDetails details) { + _moveController.value = 1.0; + _setDragOffset(Offset.zero); + } + + void _onPanUpdate(DragUpdateDetails details) { + _setDragOffset(_dragOffset + details.delta); + } + + void _onPanEnd(DragEndDetails details) { + // If flung, animate a bit before handling the potential dismiss. + if (details.velocity.pixelsPerSecond.dy.abs() >= kMinFlingVelocity) { + final bool flingIsAway = details.velocity.pixelsPerSecond.dy > 0; + final double finalPosition = flingIsAway ? _moveAnimation.value.dy + 100.0 : 0.0; + + if (flingIsAway && _sheetController.status != AnimationStatus.forward) { + _sheetController.forward(); + } else if (!flingIsAway && _sheetController.status != AnimationStatus.reverse) { + _sheetController.reverse(); + } + + _moveAnimation = Tween( + begin: Offset(0.0, _moveAnimation.value.dy), + end: Offset(0.0, finalPosition), + ).animate(_moveController); + _moveController.reset(); + _moveController.duration = const Duration(milliseconds: 64); + _moveController.forward(); + _moveController.addStatusListener(_flingStatusListener); + return; + } + + // Dismiss if the drag is enough to scale down all the way. + if (_lastScale == _kMinScale) { + widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); + return; + } + + // Otherwise animate back home. + _moveController.addListener(_moveListener); + _moveController.reverse(); + } + + void _moveListener() { + // When the scale passes the threshold, animate the sheet back in. + if (_lastScale > _kSheetScaleThreshold) { + _moveController.removeListener(_moveListener); + if (!_sheetController.isDismissed) { + _sheetController.reverse(); + } + } + } + + void _flingStatusListener(AnimationStatus status) { + if (!status.isCompleted) { + return; + } + + // Reset the duration back to its original value. + _moveController.duration = _kMoveControllerDuration; + + _moveController.removeStatusListener(_flingStatusListener); + // If it was a fling back to the start, it has reset itself, and it should + // not be dismissed. + if (_moveAnimation.value.dy == 0.0) { + return; + } + widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); + } + + void _setDragOffset(Offset dragOffset) { + // Allow horizontal and negative vertical movement, but damp it. + final double endX = _kPadding * dragOffset.dx / _kDamping; + final double endY = dragOffset.dy >= 0.0 + ? dragOffset.dy + : _kPadding * dragOffset.dy / _kDamping; + setState(() { + _dragOffset = dragOffset; + _moveAnimation = Tween( + begin: Offset.zero, + end: Offset(clampDouble(endX, -_kPadding, _kPadding), endY), + ).animate(_moveCurvedAnimation); + + // Fade the _ContextMenuSheet out or in, if needed. + if (_lastScale <= _kSheetScaleThreshold && + _sheetController.status != AnimationStatus.forward && + _sheetScaleAnimation.value != 0.0) { + _sheetController.forward(); + } else if (_lastScale > _kSheetScaleThreshold && + _sheetController.status != AnimationStatus.reverse && + _sheetScaleAnimation.value != 1.0) { + _sheetController.reverse(); + } + }); + } + + // The order and alignment of the _ContextMenuSheet and the child depend on + // both the orientation of the screen as well as the position on the screen of + // the original child. + Widget _getChild(Orientation orientation, _ContextMenuLocation contextMenuLocation) { + final Size screenSize = MediaQuery.sizeOf(context); + final EdgeInsets padding = MediaQuery.paddingOf(context); + final screenBounds = Rect.fromLTWH( + 0, + 0, + screenSize.width - padding.left - padding.right, + screenSize.height - padding.top - padding.bottom, + ); + + final Widget sheet = AnimatedBuilder( + animation: _sheetController, + builder: _buildSheetAnimation, + child: _ContextMenuSheet( + key: widget.sheetGlobalKey, + actions: widget.actions!, + contextMenuLocation: widget.contextMenuLocation, + orientation: widget.orientation, + ), + ); + final Widget child = _ContextMenuAlignedChildren( + targetRect: widget.childRect, + screenBounds: screenBounds, + sheet: sheet, + contextMenuLocation: contextMenuLocation, + orientation: widget.orientation, + child: AnimatedBuilder( + animation: _moveController, + builder: _buildChildAnimation, + child: widget.child, + ), + ); + + return child; + } + + // Build the animation for the _ContextMenuSheet. + Widget _buildSheetAnimation(BuildContext context, Widget? child) { + return Transform.scale( + alignment: _ContextMenuRoute.getSheetAlignment( + widget.contextMenuLocation, + widget.orientation, + ), + scale: _sheetScaleAnimation.value, + child: FadeTransition(opacity: _sheetOpacityAnimation, child: child), + ); + } + + // Build the animation for the child. + Widget _buildChildAnimation(BuildContext context, Widget? child) { + _lastScale = _getScale( + widget.orientation, + MediaQuery.heightOf(context), + _moveAnimation.value.dy, + ); + return Transform.scale(key: widget.childGlobalKey, scale: _lastScale, child: child); + } + + // Build the animation for the overall draggable dismissible content. + Widget _buildAnimation(BuildContext context, Widget? child) { + return Transform.translate(offset: _moveAnimation.value, child: child); + } + + @override + void initState() { + super.initState(); + _moveController = AnimationController( + duration: _kMoveControllerDuration, + value: 1.0, + vsync: this, + ); + _moveCurvedAnimation = CurvedAnimation(parent: _moveController, curve: Curves.elasticIn); + _sheetController = AnimationController( + duration: const Duration(milliseconds: 100), + reverseDuration: const Duration(milliseconds: 300), + vsync: this, + ); + _sheetCurvedAnimation = CurvedAnimation( + parent: _sheetController, + curve: Curves.linear, + reverseCurve: Curves.easeInBack, + ); + _sheetScaleAnimation = Tween(begin: 1.0, end: 0.0).animate(_sheetCurvedAnimation); + _sheetOpacityAnimation = Tween(begin: 1.0, end: 0.0).animate(_sheetController); + _setDragOffset(Offset.zero); + } + + @override + void dispose() { + _moveController.dispose(); + _moveCurvedAnimation.dispose(); + _sheetController.dispose(); + _sheetCurvedAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Widget child = _getChild(widget.orientation, widget.contextMenuLocation); + + return SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onPanEnd: _onPanEnd, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + child: AnimatedBuilder( + animation: _moveController, + builder: _buildAnimation, + child: child, + ), + ), + ), + ); + } +} + +// The menu that displays when CupertinoContextMenu is open. It consists of a +// list of actions that are typically CupertinoContextMenuActions. +class _ContextMenuSheet extends StatefulWidget { + _ContextMenuSheet({ + super.key, + required this.actions, + required this.contextMenuLocation, + required this.orientation, + }) : assert(actions.isNotEmpty); + + final List actions; + final _ContextMenuLocation contextMenuLocation; + final Orientation orientation; + + @override + State<_ContextMenuSheet> createState() => _ContextMenuSheetState(); +} + +class _ContextMenuSheetState extends State<_ContextMenuSheet> { + late final ScrollController _controller; + static const double _kMenuWidth = 250.0; + // Eyeballed on a context menu on an iOS 15 simulator running iOS 17.5. + static const double _kScrollbarMainAxisMargin = 13.0; + + @override + void initState() { + super.initState(); + // Link the scrollbar to the scroll view by providing both the same scroll + // controller. Using SingleChildScrollview.primary might conflict with users + // already using the PrimaryScrollController. + _controller = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _kMenuWidth, + child: IntrinsicHeight( + child: ClipRSuperellipse( + borderRadius: const BorderRadius.all(Radius.circular(13.0)), + child: ColoredBox( + color: CupertinoDynamicColor.resolve(CupertinoContextMenu.kBackgroundColor, context), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: CupertinoScrollbar( + mainAxisMargin: _kScrollbarMainAxisMargin, + controller: _controller, + child: SingleChildScrollView( + controller: _controller, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + widget.actions.first, + for (final Widget action in widget.actions.skip(1)) + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: CupertinoDynamicColor.resolve(_borderColor, context), + width: 0.4, + ), + ), + ), + position: DecorationPosition.foreground, + child: action, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +enum _ContextMenuChild { child, menuSheet } + +class _ContextMenuAlignedChildren extends StatelessWidget { + const _ContextMenuAlignedChildren({ + required this.targetRect, + required this.screenBounds, + required this.child, + required this.sheet, + required this.orientation, + required this.contextMenuLocation, + }); + final Rect targetRect; + final Rect screenBounds; + final Widget child; + final Widget sheet; + final Orientation orientation; + final _ContextMenuLocation contextMenuLocation; + + @override + Widget build(BuildContext context) { + return CustomMultiChildLayout( + delegate: _ContextMenuAlignedChildrenDelegate( + targetRect: targetRect, + screenBounds: screenBounds, + orientation: orientation, + contextMenuLocation: contextMenuLocation, + ), + children: [ + LayoutId(id: _ContextMenuChild.child, child: child), + LayoutId(id: _ContextMenuChild.menuSheet, child: sheet), + ], + ); + } +} + +class _ContextMenuAlignedChildrenDelegate extends MultiChildLayoutDelegate { + _ContextMenuAlignedChildrenDelegate({ + required this.targetRect, + required this.screenBounds, + required this.orientation, + required this.contextMenuLocation, + }); + final Rect targetRect; + final Rect screenBounds; + final Orientation orientation; + final _ContextMenuLocation contextMenuLocation; + + @override + void performLayout(Size size) { + final constraints = BoxConstraints.loose(size); + + final double availableHeightForChild = + screenBounds.height - _ContextMenuRouteStaticState._kPadding; + final double availableWidth = screenBounds.width - _ContextMenuRouteStaticState._kPadding * 2; + final double availableWidthForChild = switch (orientation) { + Orientation.portrait => availableWidth, + Orientation.landscape => availableWidth - _ContextMenuSheetState._kMenuWidth, + }; + assert(availableWidthForChild >= 0.0); + assert(availableHeightForChild >= 0.0); + + final Size childSize = layoutChild( + _ContextMenuChild.child, + constraints.copyWith(maxHeight: availableHeightForChild, maxWidth: availableWidthForChild), + ); + + // In portrait orientation, the child is atop the menu, while in landscape + // orientation, the child is beside the menu. + final double availableHeightForMenu = switch (orientation) { + Orientation.portrait => + availableHeightForChild - (childSize.height + _ContextMenuRouteStaticState._kPadding), + Orientation.landscape => availableHeightForChild, + }; + + final Size menuSize = layoutChild( + _ContextMenuChild.menuSheet, + constraints.copyWith(maxHeight: availableHeightForMenu), + ); + + final double initialChildLeft; + final double initialChildTop; + final double maxClampedLeft; + final double maxClampedTop; + final Offset secondChildOffset; + final bool menuBeforeChild; + switch (orientation) { + case Orientation.portrait: + menuBeforeChild = false; + final double totalHeight = + childSize.height + menuSize.height + _ContextMenuRouteStaticState._kPadding; + final double totalWidth = childSize.width + _ContextMenuRouteStaticState._kPadding; + initialChildLeft = targetRect.center.dx - childSize.width / 2; + initialChildTop = targetRect.center.dy - childSize.height; + final double secondChildDx = switch (contextMenuLocation) { + _ContextMenuLocation.center => childSize.width / 2 - menuSize.width / 2, + _ContextMenuLocation.left => 0.0, + _ContextMenuLocation.right => childSize.width - menuSize.width, + }; + secondChildOffset = Offset( + secondChildDx, + childSize.height + _ContextMenuRouteStaticState._kPadding, + ); + maxClampedLeft = screenBounds.right - totalWidth; + maxClampedTop = screenBounds.bottom - totalHeight; + case Orientation.landscape: + menuBeforeChild = contextMenuLocation == _ContextMenuLocation.right; + final double totalWidth = + childSize.width + menuSize.width + _ContextMenuRouteStaticState._kPadding; + initialChildLeft = screenBounds.center.dx - totalWidth / 2; + initialChildTop = screenBounds.center.dy - math.max(childSize.height, menuSize.height) / 2; + final double secondChildDx = menuBeforeChild ? menuSize.width : childSize.width; + secondChildOffset = Offset(secondChildDx + _ContextMenuRouteStaticState._kPadding, 0.0); + maxClampedLeft = screenBounds.right - totalWidth; + maxClampedTop = screenBounds.bottom; + } + + // Clamp the position to ensure it stays within screen bounds. + final double clampedLeft = clampDouble( + initialChildLeft, + screenBounds.left + _ContextMenuRouteStaticState._kPadding, + maxClampedLeft, + ); + final double clampedTop = clampDouble( + initialChildTop, + screenBounds.top + _ContextMenuRouteStaticState._kPadding, + maxClampedTop, + ); + final firstPosition = Offset(clampedLeft, clampedTop); + final Offset secondPosition = firstPosition + secondChildOffset; + + positionChild(_ContextMenuChild.child, menuBeforeChild ? secondPosition : firstPosition); + positionChild(_ContextMenuChild.menuSheet, menuBeforeChild ? firstPosition : secondPosition); + } + + @override + bool shouldRelayout(_ContextMenuAlignedChildrenDelegate oldDelegate) { + return oldDelegate.targetRect != targetRect || + oldDelegate.screenBounds != screenBounds || + oldDelegate.orientation != orientation || + oldDelegate.contextMenuLocation != contextMenuLocation; + } +} diff --git a/packages/cupertino_ui/lib/src/context_menu_action.dart b/packages/cupertino_ui/lib/src/context_menu_action.dart new file mode 100644 index 000000000000..c9932f394d93 --- /dev/null +++ b/packages/cupertino_ui/lib/src/context_menu_action.dart @@ -0,0 +1,140 @@ +// 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 'colors.dart'; +import 'context_menu.dart'; + +/// A button in a _ContextMenuSheet. +/// +/// A typical use case is to pass a [Text] as the [child] here, but be sure to +/// use [TextOverflow.ellipsis] for the [Text.overflow] field if the text may be +/// long, as without it the text will wrap to the next line. +class CupertinoContextMenuAction extends StatefulWidget { + /// Construct a CupertinoContextMenuAction. + const CupertinoContextMenuAction({ + super.key, + required this.child, + this.isDefaultAction = false, + this.isDestructiveAction = false, + this.onPressed, + this.trailingIcon, + }); + + /// The widget that will be placed inside the action. + final Widget child; + + /// Indicates whether this action should receive the style of an emphasized, + /// default action. + final bool isDefaultAction; + + /// Indicates whether this action should receive the style of a destructive + /// action. + final bool isDestructiveAction; + + /// Called when the action is pressed. + final VoidCallback? onPressed; + + /// An optional icon to display to the right of the child. + /// + /// Will be colored in the same way as the [TextStyle] used for [child] (for + /// example, if using [isDestructiveAction]). + final IconData? trailingIcon; + + @override + State createState() => _CupertinoContextMenuActionState(); +} + +class _CupertinoContextMenuActionState extends State { + static const Color _kBackgroundColorPressed = CupertinoDynamicColor.withBrightness( + color: Color(0xFFDDDDDD), + darkColor: Color(0xFF3F3F40), + ); + static const double _kButtonHeight = 43; + static const TextStyle _kActionSheetActionStyle = TextStyle( + fontFamily: 'CupertinoSystemText', + inherit: false, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: CupertinoColors.black, + textBaseline: TextBaseline.alphabetic, + ); + + final GlobalKey _globalKey = GlobalKey(); + bool _isPressed = false; + + void onTapDown(TapDownDetails details) { + setState(() { + _isPressed = true; + }); + } + + void onTapUp(TapUpDetails details) { + setState(() { + _isPressed = false; + }); + } + + void onTapCancel() { + setState(() { + _isPressed = false; + }); + } + + TextStyle get _textStyle { + if (widget.isDefaultAction) { + return _kActionSheetActionStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + fontWeight: FontWeight.w600, + ); + } + if (widget.isDestructiveAction) { + return _kActionSheetActionStyle.copyWith(color: CupertinoColors.destructiveRed); + } + return _kActionSheetActionStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + ); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: widget.onPressed != null && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + key: _globalKey, + onTapDown: onTapDown, + onTapUp: onTapUp, + onTapCancel: onTapCancel, + onTap: widget.onPressed, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kButtonHeight), + child: Semantics( + button: true, + child: ColoredBox( + color: _isPressed + ? CupertinoDynamicColor.resolve(_kBackgroundColorPressed, context) + : CupertinoDynamicColor.resolve(CupertinoContextMenu.kBackgroundColor, context), + child: Padding( + padding: const EdgeInsets.fromLTRB(15.5, 8.0, 17.5, 8.0), + child: DefaultTextStyle( + style: _textStyle, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: widget.child), + if (widget.trailingIcon != null) + Icon(widget.trailingIcon, color: _textStyle.color, size: 21.0), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/cupertino_focus_halo.dart b/packages/cupertino_ui/lib/src/cupertino_focus_halo.dart new file mode 100644 index 000000000000..1dd47a36ef3a --- /dev/null +++ b/packages/cupertino_ui/lib/src/cupertino_focus_halo.dart @@ -0,0 +1,144 @@ +// 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. + +/// @docImport 'dart:ui'; +library; + +import 'package:flutter/widgets.dart'; +import 'colors.dart'; +import 'constants.dart'; + +/// Applies an iOS-style focus border around its child when any of child focus nodes gain focus. +/// +/// The shape of the focus halo does not automatically adapt to the child widget +/// it encloses. You are responsible for specifying a shape that correctly +/// matches the child's geometry by using the appropriate constructor, such as +/// [CupertinoFocusHalo.withRect] or [CupertinoFocusHalo.withRRect]. +/// +/// See also: +/// +/// * +class CupertinoFocusHalo extends StatefulWidget { + /// Creates a rectangular [CupertinoFocusHalo] around the child. + /// + /// For example, to highlight a rectangular section of the widget tree when any button inside that + /// section has focus, one could write: + /// + /// ```dart + /// CupertinoFocusHalo.withRect( + /// child: Column( + /// children: [ + /// CupertinoButton(child: const Text('Child 1'), onPressed: () {}), + /// CupertinoButton(child: const Text('Child 2'), onPressed: () {}), + /// ], + /// ), + /// ) + /// ``` + const CupertinoFocusHalo.withRect({required this.child, super.key}) + : _borderRadius = BorderRadius.zero, + _shapeBuilder = RoundedRectangleBorder.new; + + /// Creates a rounded rectangular [CupertinoFocusHalo] around the child + /// + /// For example, to highlight a rounded rectangular section of the widget tree when any button inside that + /// section has focus, one could write: + /// + /// ```dart + /// CupertinoFocusHalo.withRRect( + /// borderRadius: BorderRadius.circular(10.0), + /// child: Column( + /// children: [ + /// CupertinoButton(child: const Text('Child 1'), onPressed: () {}), + /// CupertinoButton(child: const Text('Child 2'), onPressed: () {}), + /// ], + /// ), + /// ) + /// ``` + const CupertinoFocusHalo.withRRect({ + required this.child, + required BorderRadiusGeometry borderRadius, + super.key, + }) : _borderRadius = borderRadius, + _shapeBuilder = RoundedRectangleBorder.new; + + /// Creates a rounded superellipse-shaped [CupertinoFocusHalo] around the child + /// + /// For example, to highlight a rounded superellipse-shaped section of the widget tree when any button inside that + /// section has focus, one could write: + /// + /// ```dart + /// CupertinoFocusHalo.withRoundedSuperellipse( + /// borderRadius: BorderRadius.circular(10.0), + /// child: Column( + /// children: [ + /// CupertinoButton(child: const Text('Child 1'), onPressed: () {}), + /// CupertinoButton(child: const Text('Child 2'), onPressed: () {}), + /// ], + /// ), + /// ) + /// ``` + /// + /// See also: + /// + /// * [RSuperellipse] and [RoundedSuperellipseBorder] for more introduction on + /// the rounded superellipse shape. + const CupertinoFocusHalo.withRoundedSuperellipse({ + required this.child, + required BorderRadiusGeometry borderRadius, + super.key, + }) : _borderRadius = borderRadius, + _shapeBuilder = RoundedSuperellipseBorder.new; + + final BorderRadiusGeometry _borderRadius; + + final ShapeBorder Function({BorderRadiusGeometry borderRadius, BorderSide side}) _shapeBuilder; + + /// The child to draw the focused border around. + /// + /// Since [CupertinoFocusHalo] can't request focus to itself, this [child] should + /// contain widget(s) that can request focus. + /// + /// The child widget is responsible for its own visual shape, for example by + /// using an appropriate clipping. + final Widget child; + + @override + State createState() => _CupertinoFocusHaloState(); +} + +class _CupertinoFocusHaloState extends State { + bool _childHasFocus = false; + + Color get _effectiveFocusOutlineColor => + HSLColor.fromColor(CupertinoColors.activeBlue.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + + @override + Widget build(BuildContext context) { + return Focus( + canRequestFocus: false, + skipTraversal: true, + includeSemantics: false, + onFocusChange: (bool hasFocus) { + setState(() { + _childHasFocus = hasFocus; + }); + }, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: ShapeDecoration( + shape: widget._shapeBuilder( + side: _childHasFocus + ? BorderSide(color: _effectiveFocusOutlineColor, width: 3.5) + : BorderSide.none, + borderRadius: widget._borderRadius, + ), + ), + child: widget.child, + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/date_picker.dart b/packages/cupertino_ui/lib/src/date_picker.dart new file mode 100644 index 000000000000..64a97529904d --- /dev/null +++ b/packages/cupertino_ui/lib/src/date_picker.dart @@ -0,0 +1,2952 @@ +// 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. + +/// @docImport 'route.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'localizations.dart'; +import 'picker.dart'; +import 'theme.dart'; + +// Values derived from https://developer.apple.com/design/resources/ and on iOS +// simulators with "Debug View Hierarchy". +const double _kItemExtent = 32.0; +// From the picker's intrinsic content size constraint. +const double _kPickerWidth = 320.0; +const double _kPickerHeight = 216.0; +const bool _kUseMagnifier = true; +const double _kMagnification = 2.35 / 2.1; +const double _kDatePickerPadSize = 12.0; +// The density of a date picker is different from a generic picker. +// Eyeballed from iOS. +const double _kSqueeze = 1.25; + +const TextStyle _kDefaultPickerTextStyle = TextStyle(letterSpacing: -0.83); + +// The item height is 32 and the magnifier height is 34, from +// iOS simulators with "Debug View Hierarchy". +// And the magnified fontSize by [_kTimerPickerMagnification] conforms to the +// iOS 14 native style by eyeball test. +const double _kTimerPickerMagnification = 34 / 32; +// Minimum horizontal padding between [CupertinoTimerPicker] +// +// It shouldn't actually be hard-coded for direct use, and the perfect solution +// should be to calculate the values that match the magnified values by +// offAxisFraction and _kSqueeze. +// Such calculations are complex, so we'll hard-code them for now. +const double _kTimerPickerMinHorizontalPadding = 30; +// Half of the horizontal padding value between the timer picker's columns. +const double _kTimerPickerHalfColumnPadding = 4; +// The horizontal padding between the timer picker's number label and its +// corresponding unit label. +const double _kTimerPickerLabelPadSize = 6; +const double _kTimerPickerLabelFontSize = 17.0; + +// The width of each column of the countdown time picker. +const double _kTimerPickerColumnIntrinsicWidth = 106; + +TextStyle _themeTextStyle(BuildContext context, {bool isValid = true}) { + final TextStyle style = CupertinoTheme.of(context).textTheme.dateTimePickerTextStyle; + return isValid + ? style.copyWith(color: CupertinoDynamicColor.maybeResolve(style.color, context)) + : style.copyWith(color: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context)); +} + +void _animateColumnControllerToItem(FixedExtentScrollController controller, int targetItem) { + controller.animateToItem( + targetItem, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 200), + ); +} + +const Widget _startSelectionOverlay = CupertinoPickerDefaultSelectionOverlay(capEndEdge: false); +const Widget _centerSelectionOverlay = CupertinoPickerDefaultSelectionOverlay( + capStartEdge: false, + capEndEdge: false, +); +const Widget _endSelectionOverlay = CupertinoPickerDefaultSelectionOverlay(capStartEdge: false); + +/// Defines a function signature for creating a widget that serves as a selection overlay, +/// given the current context, the selected item's index, and the total number of columns. +typedef SelectionOverlayBuilder = + Widget? Function(BuildContext context, {required int columnCount, required int selectedIndex}); + +// Lays out the date picker based on how much space each single column needs. +// +// Each column is a child of this delegate, indexed from 0 to number of columns - 1. +// Each column will be padded horizontally by 12.0 both left and right. +// +// The picker will be placed in the center, and the leftmost and rightmost +// column will be extended equally to the remaining width. +class _DatePickerLayoutDelegate extends MultiChildLayoutDelegate { + _DatePickerLayoutDelegate({ + required this.columnWidths, + required this.textDirectionFactor, + required this.maxWidth, + }); + + // The list containing widths of all columns. + final List columnWidths; + + // textDirectionFactor is 1 if text is written left to right, and -1 if right to left. + final int textDirectionFactor; + + // The max width the children should reach to avoid bending outwards. + final double maxWidth; + + @override + void performLayout(Size size) { + double remainingWidth = maxWidth < size.width ? maxWidth : size.width; + + double currentHorizontalOffset = (size.width - remainingWidth) / 2; + + for (var i = 0; i < columnWidths.length; i++) { + remainingWidth -= columnWidths[i] + _kDatePickerPadSize * 2; + } + + for (var i = 0; i < columnWidths.length; i++) { + final int index = textDirectionFactor == 1 ? i : columnWidths.length - i - 1; + + double childWidth = columnWidths[index] + _kDatePickerPadSize * 2; + if (index == 0 || index == columnWidths.length - 1) { + childWidth += remainingWidth / 2; + } + + // We can't actually assert here because it would break things badly for + // semantics, which will expect that we laid things out here. + assert(() { + if (childWidth < 0) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError( + 'Insufficient horizontal space to render the ' + 'CupertinoDatePicker because the parent is too narrow at ' + '${size.width}px.\n' + 'An additional ${-remainingWidth}px is needed to avoid ' + 'overlapping columns.', + ), + ), + ); + } + return true; + }()); + layoutChild(index, BoxConstraints.tight(Size(math.max(0.0, childWidth), size.height))); + positionChild(index, Offset(currentHorizontalOffset, 0.0)); + + currentHorizontalOffset += childWidth; + } + } + + @override + bool shouldRelayout(_DatePickerLayoutDelegate oldDelegate) { + return columnWidths != oldDelegate.columnWidths || + textDirectionFactor != oldDelegate.textDirectionFactor; + } +} + +/// Different display modes of [CupertinoDatePicker]. +/// +/// See also: +/// +/// * [CupertinoDatePicker], the class that implements different display modes +/// of the iOS-style date picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. +enum CupertinoDatePickerMode { + /// Mode that shows the date in hour, minute, and (optional) an AM/PM designation. + /// The AM/PM designation is shown only if [CupertinoDatePicker] does not use 24h format. + /// Column order is subject to internationalization. + /// + /// Example: ` 4 | 14 | PM `. + time, + + /// Mode that shows the date in month, day of month, and year. + /// Name of month is spelled in full. + /// Column order is subject to internationalization. + /// + /// Example: ` July | 13 | 2012 `. + date, + + /// Mode that shows the date as day of the week, month, day of month and + /// the time in hour, minute, and (optional) an AM/PM designation. + /// The AM/PM designation is shown only if [CupertinoDatePicker] does not use 24h format. + /// Column order is subject to internationalization. + /// + /// Example: ` Fri Jul 13 | 4 | 14 | PM ` + dateAndTime, + + /// Mode that shows the date in month and year. + /// Name of month is spelled in full. + /// Column order is subject to internationalization. + /// + /// Example: ` July | 2012 `. + monthYear, +} + +// Different types of column in CupertinoDatePicker. +enum _PickerColumnType { + // Day of month column in date mode. + dayOfMonth, + // Month column in date mode. + month, + // Year column in date mode. + year, + // Medium date column in dateAndTime mode. + date, + // Hour column in time and dateAndTime mode. + hour, + // minute column in time and dateAndTime mode. + minute, + // AM/PM column in time and dateAndTime mode. + dayPeriod, + // Time separator column in time and dateAndTime mode. + timeSeparator, +} + +/// A date picker widget in iOS style. +/// +/// There are several modes of the date picker listed in [CupertinoDatePickerMode]. +/// +/// The class will display its children as consecutive columns. Its children +/// order is based on internationalization, or the [dateOrder] property if specified. +/// +/// Example of the picker in date mode: +/// +/// * US-English: `| July | 13 | 2012 |` +/// * Vietnamese: `| 13 | Tháng 7 | 2012 |` +/// +/// Can be used with [showCupertinoModalPopup] to display the picker modally at +/// the bottom of the screen. +/// +/// Sizes itself to its parent and may not render correctly if not given the +/// full screen width. Content texts are shown with +/// [CupertinoTextThemeData.dateTimePickerTextStyle]. +/// +/// {@tool dartpad} +/// This sample shows how to implement CupertinoDatePicker with different picker modes. +/// We can provide initial dateTime value for the picker to display. When user changes +/// the drag the date or time wheels, the picker will call onDateTimeChanged callback. +/// +/// CupertinoDatePicker can be displayed directly on a screen or in a popup. +/// +/// ** See code in examples/api/lib/cupertino/date_picker/cupertino_date_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. +/// * +class CupertinoDatePicker extends StatefulWidget { + /// Constructs an iOS style date picker. + /// + /// [mode] is one of the mode listed in [CupertinoDatePickerMode] and defaults + /// to [CupertinoDatePickerMode.dateAndTime]. + /// + /// [onDateTimeChanged] is the callback called when the selected date or time + /// changes. When in [CupertinoDatePickerMode.time] mode, the year, month and + /// day will be the same as [initialDateTime]. When in + /// [CupertinoDatePickerMode.date] mode, this callback will always report the + /// start time of the currently selected day. When in + /// [CupertinoDatePickerMode.monthYear] mode, the day and time will be the + /// start time of the first day of the month. + /// + /// [initialDateTime] is the initial date time of the picker. Defaults to the + /// present date and time. The present must conform to the intervals set in + /// [minimumDate], [maximumDate], [minimumYear], and [maximumYear]. + /// + /// [minimumDate] is the minimum selectable [DateTime] of the picker. When set + /// to null, the picker does not limit the minimum [DateTime] the user can pick. + /// In [CupertinoDatePickerMode.time] mode, [minimumDate] should typically be + /// on the same date as [initialDateTime], as the picker will not limit the + /// minimum time the user can pick if it's set to a date earlier than that. + /// + /// [maximumDate] is the maximum selectable [DateTime] of the picker. When set + /// to null, the picker does not limit the maximum [DateTime] the user can pick. + /// In [CupertinoDatePickerMode.time] mode, [maximumDate] should typically be + /// on the same date as [initialDateTime], as the picker will not limit the + /// maximum time the user can pick if it's set to a date later than that. + /// + /// [minimumYear] is the minimum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Defaults to 1. + /// + /// [maximumYear] is the maximum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Null if there's no limit. + /// + /// [minuteInterval] is the granularity of the minute spinner. Must be a + /// positive integer factor of 60. + /// + /// [use24hFormat] decides whether 24 hour format is used. Defaults to false. + /// + /// [dateOrder] determines the order of the columns inside [CupertinoDatePicker] + /// in [CupertinoDatePickerMode.date] and [CupertinoDatePickerMode.monthYear] + /// mode. When using monthYear mode, both [DatePickerDateOrder.dmy] and + /// [DatePickerDateOrder.mdy] will result in the month|year order. + /// Defaults to the locale's default date format/order. + CupertinoDatePicker({ + super.key, + this.mode = CupertinoDatePickerMode.dateAndTime, + required this.onDateTimeChanged, + DateTime? initialDateTime, + this.minimumDate, + this.maximumDate, + this.minimumYear = 1, + this.maximumYear, + this.minuteInterval = 1, + this.use24hFormat = false, + this.dateOrder, + this.backgroundColor, + this.showDayOfWeek = false, + this.showTimeSeparator = false, + this.itemExtent = _kItemExtent, + this.selectionOverlayBuilder, + this.selectableDayPredicate, + this.changeReportingBehavior = ChangeReportingBehavior.onScrollUpdate, + }) : initialDateTime = initialDateTime ?? DateTime.now(), + assert(itemExtent > 0, 'item extent should be greater than 0'), + assert( + minuteInterval > 0 && 60 % minuteInterval == 0, + 'minute interval is not a positive integer factor of 60', + ), + assert( + mode != CupertinoDatePickerMode.dateAndTime || + minimumDate == null || + !(initialDateTime ?? DateTime.now()).isBefore(minimumDate), + 'initial date is before minimum date', + ), + assert( + mode != CupertinoDatePickerMode.dateAndTime || + maximumDate == null || + !(initialDateTime ?? DateTime.now()).isAfter(maximumDate), + 'initial date is after maximum date', + ), + assert( + (mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) || + (minimumYear >= 1 && (initialDateTime ?? DateTime.now()).year >= minimumYear), + 'initial year is not greater than minimum year, or minimum year is not positive', + ), + assert( + (mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) || + maximumYear == null || + (initialDateTime ?? DateTime.now()).year <= maximumYear, + 'initial year is not smaller than maximum year', + ), + assert( + (mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) || + minimumDate == null || + !minimumDate.isAfter(initialDateTime ?? DateTime.now()), + 'initial date ${initialDateTime ?? DateTime.now()} is not greater than or equal to minimumDate $minimumDate', + ), + assert( + (mode != CupertinoDatePickerMode.date && mode != CupertinoDatePickerMode.monthYear) || + maximumDate == null || + !maximumDate.isBefore(initialDateTime ?? DateTime.now()), + 'initial date ${initialDateTime ?? DateTime.now()} is not less than or equal to maximumDate $maximumDate', + ), + assert( + (mode == CupertinoDatePickerMode.date) || !showDayOfWeek, + 'showDayOfWeek is only supported in date mode', + ), + assert( + (initialDateTime ?? DateTime.now()).minute % minuteInterval == 0, + 'initial minute is not divisible by minute interval', + ), + assert( + !showTimeSeparator || + mode == CupertinoDatePickerMode.dateAndTime || + mode == CupertinoDatePickerMode.time, + 'showTimeSeparator is only supported in time or dateAndTime modes', + ), + assert( + selectableDayPredicate == null || + initialDateTime == null || + selectableDayPredicate(initialDateTime), + '$initialDateTime must satisfy provided selectableDayPredicate.', + ); + + /// The mode of the date picker as one of [CupertinoDatePickerMode]. Defaults + /// to [CupertinoDatePickerMode.dateAndTime]. Value cannot change after + /// initial build. + final CupertinoDatePickerMode mode; + + /// The initial date and/or time of the picker. Defaults to the present date + /// and time. The present must conform to the intervals set in [minimumDate], + /// [maximumDate], [minimumYear], and [maximumYear]. + /// + /// Changing this value after the initial build will not affect the currently + /// selected date time. + final DateTime initialDateTime; + + /// The minimum selectable date that the picker can settle on. + /// + /// When non-null, the user can still scroll the picker to [DateTime]s earlier + /// than [minimumDate], but the [onDateTimeChanged] will not be called on + /// these [DateTime]s. Once let go, the picker will scroll back to [minimumDate]. + /// + /// In [CupertinoDatePickerMode.time] mode, a time becomes unselectable if the + /// [DateTime] produced by combining that particular time and the date part of + /// [initialDateTime] is earlier than [minimumDate]. So typically [minimumDate] + /// needs to be set to a [DateTime] that is on the same date as [initialDateTime]. + /// + /// Defaults to null. When set to null, the picker does not impose a limit on + /// the earliest [DateTime] the user can select. + final DateTime? minimumDate; + + /// The maximum selectable date that the picker can settle on. + /// + /// When non-null, the user can still scroll the picker to [DateTime]s later + /// than [maximumDate], but the [onDateTimeChanged] will not be called on + /// these [DateTime]s. Once let go, the picker will scroll back to [maximumDate]. + /// + /// In [CupertinoDatePickerMode.time] mode, a time becomes unselectable if the + /// [DateTime] produced by combining that particular time and the date part of + /// [initialDateTime] is later than [maximumDate]. So typically [maximumDate] + /// needs to be set to a [DateTime] that is on the same date as [initialDateTime]. + /// + /// Defaults to null. When set to null, the picker does not impose a limit on + /// the latest [DateTime] the user can select. + final DateTime? maximumDate; + + /// Minimum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Defaults to 1. + final int minimumYear; + + /// Maximum year that the picker can be scrolled to in + /// [CupertinoDatePickerMode.date] mode. Null if there's no limit. + final int? maximumYear; + + /// The granularity of the minutes spinner, if it is shown in the current mode. + /// Must be an integer factor of 60. + final int minuteInterval; + + /// Whether to use 24 hour format. Defaults to false. + final bool use24hFormat; + + /// Determines the order of the columns inside [CupertinoDatePicker] in + /// [CupertinoDatePickerMode.date] and [CupertinoDatePickerMode.monthYear] + /// mode. When using monthYear mode, both [DatePickerDateOrder.dmy] and + /// [DatePickerDateOrder.mdy] will result in the month|year order. + /// Defaults to the locale's default date format/order. + final DatePickerDateOrder? dateOrder; + + /// Callback called when the selected date and/or time changes. If the new + /// selected [DateTime] is not valid, or is not in the [minimumDate] through + /// [maximumDate] range, this callback will not be called. + /// + /// The timing of this callback is controlled by [changeReportingBehavior]. + final ValueChanged onDateTimeChanged; + + /// Background color of date picker. + /// + /// Defaults to null, which disables background painting entirely. + final Color? backgroundColor; + + /// Whether to show the day of week alongside the day in [CupertinoDatePickerMode.date] mode. + /// + /// Defaults to false. + final bool showDayOfWeek; + + /// Whether to show the time separator between hour and minute in the time + /// [CupertinoDatePickerMode.time] and datetime [CupertinoDatePickerMode.dateAndTime] + /// picker modes. + /// + /// Throws an error if set to true in [CupertinoDatePickerMode.date] + /// and [CupertinoDatePickerMode.monthYear] mode. + /// + /// Defaults to false. + final bool showTimeSeparator; + + /// Function to provide full control over which [DateTime] can be selected. + final SelectableDayPredicate? selectableDayPredicate; + + /// {@macro flutter.cupertino.picker.itemExtent} + /// + /// Defaults to a value that matches the default iOS date picker wheel. + final double itemExtent; + + /// A function that returns a widget that is overlaid on the picker + /// to highlight the currently selected entry. + /// + /// If unspecified, it defaults to a [CupertinoPickerDefaultSelectionOverlay] + /// which is a gray rounded rectangle overlay in iOS 14 style. + /// + /// If the selection overlay builder returns null, no overlay will be drawn. + /// + /// {@tool snippet} + /// + /// This example shows how to recreate the default selection overlay + /// with selectionOverlayBuilder. + /// + /// ```dart + /// CupertinoDatePicker( + /// onDateTimeChanged: (DateTime newDateTime) {}, + /// mode: CupertinoDatePickerMode.date, + /// initialDateTime: DateTime(2018, 9, 15), + /// selectionOverlayBuilder: ( + /// BuildContext context, { + /// required int selectedIndex, + /// required int columnCount, + /// }) { + /// if (selectedIndex == 0) { + /// return const CupertinoPickerDefaultSelectionOverlay( + /// capEndEdge: false, + /// ); + /// } else if (selectedIndex == columnCount - 1) { + /// return const CupertinoPickerDefaultSelectionOverlay( + /// capStartEdge: false, + /// ); + /// } + /// return const CupertinoPickerDefaultSelectionOverlay( + /// capStartEdge: false, + /// capEndEdge: false, + /// ); + /// }, + /// ) + /// ``` + /// {@end-tool} + final SelectionOverlayBuilder? selectionOverlayBuilder; + + /// The behavior of reporting the selected date. + /// + /// This determines when the [onDateTimeChanged] callback is called. + /// + /// Native iOS 18 behavior is [ChangeReportingBehavior.onScrollEnd], which + /// calls the callback only when the scrolling stops. + /// + /// Defaults to [ChangeReportingBehavior.onScrollUpdate]. + final ChangeReportingBehavior changeReportingBehavior; + + @override + State createState() { + // ignore: no_logic_in_create_state, https://github.com/flutter/flutter/issues/70499 + return switch (mode) { + // The `time` mode and `dateAndTime` mode of the picker share the time + // columns, so they are placed together to one state. + // The `date` mode has different children and is implemented in a different + // state. + CupertinoDatePickerMode.time => _CupertinoDatePickerDateTimeState(), + CupertinoDatePickerMode.dateAndTime => _CupertinoDatePickerDateTimeState(), + CupertinoDatePickerMode.date => _CupertinoDatePickerDateState(dateOrder: dateOrder), + CupertinoDatePickerMode.monthYear => _CupertinoDatePickerMonthYearState(dateOrder: dateOrder), + }; + } + + // Estimate the minimum width that each column needs to layout its content. + static double _getColumnWidth( + _PickerColumnType columnType, + CupertinoLocalizations localizations, + BuildContext context, + bool showDayOfWeek, { + bool standaloneMonth = false, + }) { + final longTexts = []; + + switch (columnType) { + case _PickerColumnType.date: + for (var i = 1; i <= 12; i++) { + final String date = localizations.datePickerMediumDate(DateTime(2018, i, 25)); + longTexts.add(date); + } + case _PickerColumnType.hour: + for (var i = 0; i < 24; i++) { + final String hour = localizations.datePickerHour(i); + longTexts.add(hour); + } + case _PickerColumnType.minute: + for (var i = 0; i < 60; i++) { + final String minute = localizations.datePickerMinute(i); + longTexts.add(minute); + } + case _PickerColumnType.dayPeriod: + longTexts.add(localizations.anteMeridiemAbbreviation); + longTexts.add(localizations.postMeridiemAbbreviation); + case _PickerColumnType.dayOfMonth: + var longestDayOfMonth = 1; + for (var i = 1; i <= 31; i++) { + final String dayOfMonth = localizations.datePickerDayOfMonth(i); + longTexts.add(dayOfMonth); + longestDayOfMonth = i; + } + if (showDayOfWeek) { + for (var wd = 1; wd < DateTime.daysPerWeek; wd++) { + final String dayOfMonth = localizations.datePickerDayOfMonth(longestDayOfMonth, wd); + longTexts.add(dayOfMonth); + } + } + case _PickerColumnType.month: + for (var i = 1; i <= 12; i++) { + final String month = standaloneMonth + ? localizations.datePickerStandaloneMonth(i) + : localizations.datePickerMonth(i); + longTexts.add(month); + } + case _PickerColumnType.year: + longTexts.add(localizations.datePickerYear(2018)); + case _PickerColumnType.timeSeparator: + longTexts.add(':'); + } + + assert( + longTexts.isNotEmpty && longTexts.every((String text) => text.isNotEmpty), + 'column type is not appropriate', + ); + + return getColumnWidth(texts: longTexts, context: context); + } + + /// Returns the width of column in the picker. + /// + /// This method is intended for testing only. It calculates the width of the + /// widest column in the picker based on the provided list of texts and the + /// given [BuildContext]. + @visibleForTesting + static double getColumnWidth({ + required List texts, + required BuildContext context, + TextStyle? textStyle, + }) { + return texts + .map( + (String text) => TextPainter.computeMaxIntrinsicWidth( + text: TextSpan(style: textStyle ?? _themeTextStyle(context), text: text), + textDirection: Directionality.of(context), + ), + ) + .reduce(math.max); + } +} + +typedef _ColumnBuilder = + Widget Function( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ); + +class _CupertinoDatePickerDateTimeState extends State { + // Fraction of the farthest column's vanishing point vs its width. Eyeballed + // vs iOS. + static const double _kMaximumOffAxisFraction = 0.45; + + late int textDirectionFactor; + late CupertinoLocalizations localizations; + + // Alignment based on text direction. The variable name is self descriptive, + // however, when text direction is rtl, alignment is reversed. + late Alignment alignCenterLeft; + late Alignment alignCenterRight; + + // Read this out when the state is initially created. Changes in initialDateTime + // in the widget after first build is ignored. + late DateTime initialDateTime; + + // The difference in days between the initial date and the currently selected date. + // 0 if the current mode does not involve a date. + int get selectedDayFromInitial { + switch (widget.mode) { + case CupertinoDatePickerMode.dateAndTime: + return dateController.hasClients ? dateController.selectedItem : 0; + case CupertinoDatePickerMode.time: + return 0; + case CupertinoDatePickerMode.date: + case CupertinoDatePickerMode.monthYear: + break; + } + assert(false, '$runtimeType is only meant for dateAndTime mode or time mode'); + return 0; + } + + // The controller of the date column. + late FixedExtentScrollController dateController; + + // The current selection of the hour picker. Values range from 0 to 23. + int get selectedHour => _selectedHour(selectedAmPm, _selectedHourIndex); + int get _selectedHourIndex => + hourController.hasClients ? hourController.selectedItem % 24 : initialDateTime.hour; + // Calculates the selected hour given the selected indices of the hour picker + // and the meridiem picker. + int _selectedHour(int selectedAmPm, int selectedHour) { + return _isHourRegionFlipped(selectedAmPm) ? (selectedHour + 12) % 24 : selectedHour; + } + + // The controller of the hour column. + late FixedExtentScrollController hourController; + + // The current selection of the minute picker. Values range from 0 to 59. + int get selectedMinute { + return minuteController.hasClients + ? minuteController.selectedItem * widget.minuteInterval % 60 + : initialDateTime.minute; + } + + // The controller of the minute column. + late FixedExtentScrollController minuteController; + + // Whether the current meridiem selection is AM or PM. + // + // We can't use the selectedItem of meridiemController as the source of truth + // because the meridiem picker can be scrolled **animatedly** by the hour picker + // (e.g. if you scroll from 12 to 1 in 12h format), but the meridiem change + // should take effect immediately, **before** the animation finishes. + late int selectedAmPm; + // Whether the physical-region-to-meridiem mapping is flipped. + bool get isHourRegionFlipped => _isHourRegionFlipped(selectedAmPm); + bool _isHourRegionFlipped(int selectedAmPm) => selectedAmPm != meridiemRegion; + // The index of the 12-hour region the hour picker is currently in. + // + // Used to determine whether the meridiemController should start animating. + // Valid values are 0 and 1. + // + // The AM/PM correspondence of the two regions flips when the meridiem picker + // scrolls. This variable is to keep track of the selected "physical" + // (meridiem picker invariant) region of the hour picker. The "physical" region + // of an item of index `i` is `i ~/ 12`. + late int meridiemRegion; + // The current selection of the AM/PM picker. + // + // - 0 means AM + // - 1 means PM + late FixedExtentScrollController meridiemController; + + bool isDatePickerScrolling = false; + bool isHourPickerScrolling = false; + bool isMinutePickerScrolling = false; + bool isMeridiemPickerScrolling = false; + + bool get isScrolling { + return isDatePickerScrolling || + isHourPickerScrolling || + isMinutePickerScrolling || + isMeridiemPickerScrolling; + } + + // The estimated width of columns. + final Map estimatedColumnWidths = {}; + + @override + void initState() { + super.initState(); + initialDateTime = widget.initialDateTime; + + // Initially each of the "physical" regions is mapped to the meridiem region + // with the same number, e.g., the first 12 items are mapped to the first 12 + // hours of a day. Such mapping is flipped when the meridiem picker is scrolled + // by the user, the first 12 items are mapped to the last 12 hours of a day. + selectedAmPm = initialDateTime.hour ~/ 12; + meridiemRegion = selectedAmPm; + + meridiemController = FixedExtentScrollController(initialItem: selectedAmPm); + hourController = FixedExtentScrollController(initialItem: initialDateTime.hour); + minuteController = FixedExtentScrollController( + initialItem: initialDateTime.minute ~/ widget.minuteInterval, + ); + dateController = FixedExtentScrollController(); + + PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); + } + + void _handleSystemFontsChange() { + setState(() { + // System fonts change might cause the text layout width to change. + // Clears cached width to ensure that they get recalculated with the + // new system fonts. + estimatedColumnWidths.clear(); + }); + } + + @override + void dispose() { + dateController.dispose(); + hourController.dispose(); + minuteController.dispose(); + meridiemController.dispose(); + + PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); + super.dispose(); + } + + @override + void didUpdateWidget(CupertinoDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + + assert(oldWidget.mode == widget.mode, "The $runtimeType's mode cannot change once it's built."); + + if (!widget.use24hFormat && oldWidget.use24hFormat) { + // Thanks to the physical and meridiem region mapping, the only thing we + // need to update is the meridiem controller, if it's not previously attached. + meridiemController.dispose(); + meridiemController = FixedExtentScrollController(initialItem: selectedAmPm); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; + localizations = CupertinoLocalizations.of(context); + + alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; + alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; + + estimatedColumnWidths.clear(); + } + + // Lazily calculate the column width of the column being displayed only. + double _getEstimatedColumnWidth(_PickerColumnType columnType) { + estimatedColumnWidths[columnType.index] ??= CupertinoDatePicker._getColumnWidth( + columnType, + localizations, + context, + widget.showDayOfWeek, + ); + + return estimatedColumnWidths[columnType.index]!; + } + + // Gets the current date time of the picker. + DateTime get selectedDateTime { + return DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day + selectedDayFromInitial, + selectedHour, + selectedMinute, + ); + } + + // Only reports datetime change when the date time is valid. + void _onSelectedItemChange(int index) { + final bool isDateInvalid = + (widget.minimumDate?.isAfter(selectedDateTime) ?? false) || + (widget.maximumDate?.isBefore(selectedDateTime) ?? false); + + if (isDateInvalid) { + return; + } else if (!_isSelectableDate(selectedDateTime)) { + return; + } + + widget.onDateTimeChanged(selectedDateTime); + } + + /// Returns whether the given date is selectable. + bool _isSelectableDate(DateTime date) { + return widget.selectableDayPredicate?.call(date) ?? true; + } + + // Builds the date column. The date is displayed in medium date format (e.g. Fri Aug 31). + Widget _buildMediumDatePicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isDatePickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isDatePickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker.builder( + scrollController: dateController, + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + _onSelectedItemChange(index); + }, + itemBuilder: (BuildContext context, int index) { + final rangeStart = DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day + index, + ); + + // Exclusive. + final rangeEnd = DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day + index + 1, + ); + + final now = DateTime.now(); + + if (widget.minimumDate?.isBefore(rangeEnd) == false) { + return null; + } + if (widget.maximumDate?.isAfter(rangeStart) == false) { + return null; + } + + final String dateText = rangeStart == DateTime(now.year, now.month, now.day) + ? localizations.todayLabel + : localizations.datePickerMediumDate(rangeStart); + + final bool isDisabled = !_isSelectableDate(rangeStart); + final Widget child = itemPositioningBuilder( + context, + Text(dateText, style: _themeTextStyle(context, isValid: !isDisabled)), + ); + return isDisabled ? ExcludeSemantics(child: child) : child; + }, + selectionOverlay: selectionOverlay, + ), + ); + } + + // With the meridiem picker set to `meridiemIndex`, and the hour picker set to + // `hourIndex`, is it possible to change the value of the minute picker, so + // that the resulting date stays in the valid range. + bool _isValidHour(int meridiemIndex, int hourIndex) { + final rangeStart = DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day + selectedDayFromInitial, + _selectedHour(meridiemIndex, hourIndex), + ); + + // The end value of the range is exclusive, i.e. [rangeStart, rangeEnd). + final DateTime rangeEnd = rangeStart.add(const Duration(hours: 1)); + + return (widget.minimumDate?.isBefore(rangeEnd) ?? true) && + !(widget.maximumDate?.isBefore(rangeStart) ?? false); + } + + Widget _buildHourPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isHourPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isHourPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker( + scrollController: hourController, + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + final regionChanged = meridiemRegion != index ~/ 12; + final bool debugIsFlipped = isHourRegionFlipped; + + if (regionChanged) { + meridiemRegion = index ~/ 12; + selectedAmPm = 1 - selectedAmPm; + } + + if (!widget.use24hFormat && regionChanged) { + // Scroll the meridiem column to adjust AM/PM. + // + // _onSelectedItemChanged will be called when the animation finishes. + // + // Animation values obtained by comparing with iOS version. + meridiemController.animateToItem( + selectedAmPm, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } else { + _onSelectedItemChange(index); + } + + assert(debugIsFlipped == isHourRegionFlipped); + }, + looping: true, + selectionOverlay: selectionOverlay, + children: List.generate(24, (int index) { + final int hour = isHourRegionFlipped ? (index + 12) % 24 : index; + final int displayHour = widget.use24hFormat ? hour : (hour + 11) % 12 + 1; + final bool isDisabled = !_isValidHour(selectedAmPm, index); + + final Widget child = itemPositioningBuilder( + context, + Text( + localizations.datePickerHour(displayHour), + semanticsLabel: localizations.datePickerHourSemanticsLabel(displayHour), + style: _themeTextStyle(context, isValid: !isDisabled), + ), + ); + return isDisabled ? ExcludeSemantics(child: child) : child; + }), + ), + ); + } + + Widget _buildMinutePicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isMinutePickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isMinutePickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker( + scrollController: minuteController, + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: _onSelectedItemChange, + looping: true, + selectionOverlay: selectionOverlay, + children: List.generate(60 ~/ widget.minuteInterval, (int index) { + final int minute = index * widget.minuteInterval; + + final date = DateTime( + initialDateTime.year, + initialDateTime.month, + initialDateTime.day + selectedDayFromInitial, + selectedHour, + minute, + ); + + final bool isInvalidMinute = + (widget.minimumDate?.isAfter(date) ?? false) || + (widget.maximumDate?.isBefore(date) ?? false); + + final Widget child = itemPositioningBuilder( + context, + Text( + localizations.datePickerMinute(minute), + semanticsLabel: localizations.datePickerMinuteSemanticsLabel(minute), + style: _themeTextStyle(context, isValid: !isInvalidMinute), + ), + ); + return isInvalidMinute ? ExcludeSemantics(child: child) : child; + }), + ), + ); + } + + Widget _buildAmPmPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isMeridiemPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isMeridiemPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker( + scrollController: meridiemController, + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + selectedAmPm = index; + assert(selectedAmPm == 0 || selectedAmPm == 1); + _onSelectedItemChange(index); + }, + selectionOverlay: selectionOverlay, + children: List.generate(2, (int index) { + final bool isDisabled = !_isValidHour(index, _selectedHourIndex); + final Widget child = itemPositioningBuilder( + context, + Text( + index == 0 + ? localizations.anteMeridiemAbbreviation + : localizations.postMeridiemAbbreviation, + style: _themeTextStyle(context, isValid: !isDisabled), + ), + ); + return isDisabled ? ExcludeSemantics(child: child) : child; + }), + ), + ); + } + + // Builds the time separator column. + Widget _buildTimeSeparatorWidget( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return ExcludeSemantics( + child: CupertinoPicker( + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + onSelectedItemChanged: (int index) {}, + selectionOverlay: selectionOverlay, + children: List.generate(1, (int index) { + return itemPositioningBuilder(context, Text(':', style: _themeTextStyle(context))); + }), + ), + ); + } + + // Scrolls to the first selectable date if the current date is not selectable. + void _scrollToFirstSelectableDate() { + if (!_isSelectableDate(selectedDateTime)) { + const daysThreshold = 1; + final DateTime targetDate = selectedDateTime.add(const Duration(days: daysThreshold)); + + _scrollToDate( + targetDate, + selectedDateTime, + false, + focusedIndex: dateController.selectedItem + daysThreshold, + ); + } + } + + // One or more pickers have just stopped scrolling. + void _pickerDidStopScrolling() { + // Call setState to update the greyed out date/hour/minute/meridiem. + setState(() {}); + + if (isScrolling) { + return; + } + + // Whenever scrolling lands on an invalid entry, the picker + // automatically scrolls to a valid one. + final DateTime selectedDate = selectedDateTime; + + final bool minCheck = widget.minimumDate?.isAfter(selectedDate) ?? false; + final bool maxCheck = widget.maximumDate?.isBefore(selectedDate) ?? false; + + _scrollToFirstSelectableDate(); + if (minCheck || maxCheck) { + // We have minCheck === !maxCheck. + final DateTime targetDate = minCheck ? widget.minimumDate! : widget.maximumDate!; + _scrollToDate(targetDate, selectedDate, minCheck); + } + } + + void _scrollToDate(DateTime newDate, DateTime fromDate, bool minCheck, {int? focusedIndex}) { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + if (fromDate.year != newDate.year || + fromDate.month != newDate.month || + fromDate.day != newDate.day) { + _animateColumnControllerToItem(dateController, focusedIndex ?? selectedDayFromInitial); + } + + if (fromDate.hour != newDate.hour) { + final bool needsMeridiemChange = + !widget.use24hFormat && fromDate.hour ~/ 12 != newDate.hour ~/ 12; + // In AM/PM mode, the pickers should not scroll all the way to the other hour region. + if (needsMeridiemChange) { + _animateColumnControllerToItem(meridiemController, 1 - meridiemController.selectedItem); + + // Keep the target item index in the current 12-h region. + final int newItem = + (hourController.selectedItem ~/ 12) * 12 + + (hourController.selectedItem + newDate.hour - fromDate.hour) % 12; + _animateColumnControllerToItem(hourController, newItem); + } else { + _animateColumnControllerToItem( + hourController, + hourController.selectedItem + newDate.hour - fromDate.hour, + ); + } + } + + if (fromDate.minute != newDate.minute) { + final double positionDouble = newDate.minute / widget.minuteInterval; + final int position = minCheck ? positionDouble.ceil() : positionDouble.floor(); + _animateColumnControllerToItem(minuteController, position); + } + }, debugLabel: 'DatePicker.scrollToDate'); + } + + @override + Widget build(BuildContext context) { + // Widths of the columns in this picker, ordered from left to right. + final columnWidths = [ + _getEstimatedColumnWidth(_PickerColumnType.hour), + _getEstimatedColumnWidth(_PickerColumnType.minute), + ]; + + // Swap the hours and minutes if RTL to ensure they are in the correct position. + final pickerBuilders = Directionality.of(context) == TextDirection.rtl + ? <_ColumnBuilder>[_buildMinutePicker, _buildHourPicker] + : <_ColumnBuilder>[_buildHourPicker, _buildMinutePicker]; + + // Adds time separator column if the picker is showing time separator. + if (widget.showTimeSeparator) { + columnWidths.insert(1, _getEstimatedColumnWidth(_PickerColumnType.timeSeparator)); + pickerBuilders.insert(1, _buildTimeSeparatorWidget); + } + // Adds am/pm column if the picker is not using 24h format. + if (!widget.use24hFormat) { + switch (localizations.datePickerDateTimeOrder) { + case DatePickerDateTimeOrder.date_time_dayPeriod: + case DatePickerDateTimeOrder.time_dayPeriod_date: + pickerBuilders.add(_buildAmPmPicker); + columnWidths.add(_getEstimatedColumnWidth(_PickerColumnType.dayPeriod)); + case DatePickerDateTimeOrder.date_dayPeriod_time: + case DatePickerDateTimeOrder.dayPeriod_time_date: + pickerBuilders.insert(0, _buildAmPmPicker); + columnWidths.insert(0, _getEstimatedColumnWidth(_PickerColumnType.dayPeriod)); + } + } + + // Adds medium date column if the picker's mode is date and time. + if (widget.mode == CupertinoDatePickerMode.dateAndTime) { + switch (localizations.datePickerDateTimeOrder) { + case DatePickerDateTimeOrder.time_dayPeriod_date: + case DatePickerDateTimeOrder.dayPeriod_time_date: + pickerBuilders.add(_buildMediumDatePicker); + columnWidths.add(_getEstimatedColumnWidth(_PickerColumnType.date)); + case DatePickerDateTimeOrder.date_time_dayPeriod: + case DatePickerDateTimeOrder.date_dayPeriod_time: + pickerBuilders.insert(0, _buildMediumDatePicker); + columnWidths.insert(0, _getEstimatedColumnWidth(_PickerColumnType.date)); + } + } + + final pickers = []; + double totalColumnWidths = 4 * _kDatePickerPadSize; + + for (final (int i, double width) in columnWidths.indexed) { + final (bool firstColumn, bool lastColumn) = (i == 0, i == columnWidths.length - 1); + var offAxisFraction = 0.0; + Widget? selectionOverlay = _centerSelectionOverlay; + + if (widget.selectionOverlayBuilder != null) { + selectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: i, + columnCount: columnWidths.length, + ); + } else { + if (firstColumn) { + selectionOverlay = _startSelectionOverlay; + } else if (lastColumn) { + selectionOverlay = _endSelectionOverlay; + } + } + + if (firstColumn) { + offAxisFraction = -_kMaximumOffAxisFraction * textDirectionFactor; + } else if (i >= 2 || columnWidths.length == 2) { + offAxisFraction = _kMaximumOffAxisFraction * textDirectionFactor; + } + + var padding = const EdgeInsets.only(right: _kDatePickerPadSize); + if (lastColumn) { + padding = padding.flipped; + } + if (textDirectionFactor == -1) { + padding = padding.flipped; + } + + totalColumnWidths += width + (2 * _kDatePickerPadSize); + + pickers.add( + LayoutId( + id: i, + child: pickerBuilders[i](offAxisFraction, (BuildContext context, Widget? child) { + late final Widget constrained = ConstrainedBox( + constraints: BoxConstraints(maxWidth: width + _kDatePickerPadSize), + child: child, + ); + + return Padding( + padding: padding, + child: Align( + alignment: lastColumn ? alignCenterLeft : alignCenterRight, + child: firstColumn || lastColumn ? constrained : child, + ), + ); + }, selectionOverlay), + ), + ); + } + + final double maxPickerWidth = totalColumnWidths > _kPickerWidth + ? totalColumnWidths + : _kPickerWidth; + + return MediaQuery.withNoTextScaling( + child: DefaultTextStyle.merge( + style: _kDefaultPickerTextStyle, + child: CustomMultiChildLayout( + delegate: _DatePickerLayoutDelegate( + columnWidths: columnWidths, + textDirectionFactor: textDirectionFactor, + maxWidth: maxPickerWidth, + ), + children: pickers, + ), + ), + ); + } +} + +class _CupertinoDatePickerDateState extends State { + _CupertinoDatePickerDateState({required this.dateOrder}); + + final DatePickerDateOrder? dateOrder; + + late int textDirectionFactor; + late CupertinoLocalizations localizations; + + // Alignment based on text direction. The variable name is self descriptive, + // however, when text direction is rtl, alignment is reversed. + late Alignment alignCenterLeft; + late Alignment alignCenterRight; + + // The currently selected values of the picker. + late int selectedDay; + late int selectedMonth; + late int selectedYear; + + // The controller of the day picker. There are cases where the selected value + // of the picker is invalid (e.g. February 30th 2018), and this dayController + // is responsible for jumping to a valid value. + late FixedExtentScrollController dayController; + late FixedExtentScrollController monthController; + late FixedExtentScrollController yearController; + + bool isDayPickerScrolling = false; + bool isMonthPickerScrolling = false; + bool isYearPickerScrolling = false; + + bool get isScrolling => isDayPickerScrolling || isMonthPickerScrolling || isYearPickerScrolling; + + // Estimated width of columns. + Map estimatedColumnWidths = {}; + + @override + void initState() { + super.initState(); + selectedDay = widget.initialDateTime.day; + selectedMonth = widget.initialDateTime.month; + selectedYear = widget.initialDateTime.year; + + dayController = FixedExtentScrollController(initialItem: selectedDay - 1); + monthController = FixedExtentScrollController(initialItem: selectedMonth - 1); + yearController = FixedExtentScrollController(initialItem: selectedYear); + + PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); + } + + void _handleSystemFontsChange() { + setState(() { + // System fonts change might cause the text layout width to change. + _refreshEstimatedColumnWidths(); + }); + } + + @override + void dispose() { + dayController.dispose(); + monthController.dispose(); + yearController.dispose(); + + PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; + localizations = CupertinoLocalizations.of(context); + + alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; + alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; + + _refreshEstimatedColumnWidths(); + } + + void _refreshEstimatedColumnWidths() { + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index] = CupertinoDatePicker._getColumnWidth( + _PickerColumnType.dayOfMonth, + localizations, + context, + widget.showDayOfWeek, + ); + estimatedColumnWidths[_PickerColumnType.month.index] = CupertinoDatePicker._getColumnWidth( + _PickerColumnType.month, + localizations, + context, + widget.showDayOfWeek, + ); + estimatedColumnWidths[_PickerColumnType.year.index] = CupertinoDatePicker._getColumnWidth( + _PickerColumnType.year, + localizations, + context, + widget.showDayOfWeek, + ); + } + + // The DateTime of the last day of a given month in a given year. + // Let `DateTime` handle the year/month overflow. + DateTime _lastDayInMonth(int year, int month) => DateTime(year, month + 1, 0); + + Widget _buildDayPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + final int daysInCurrentMonth = _lastDayInMonth(selectedYear, selectedMonth).day; + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isDayPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isDayPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker( + scrollController: dayController, + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + selectedDay = index + 1; + if (_isCurrentDateValid) { + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay)); + } + }, + looping: true, + selectionOverlay: selectionOverlay, + children: List.generate(31, (int index) { + final int day = index + 1; + final int? dayOfWeek = widget.showDayOfWeek + ? DateTime(selectedYear, selectedMonth, day).weekday + : null; + final bool isInvalidDay = + (day > daysInCurrentMonth) || + (widget.minimumDate?.year == selectedYear && + widget.minimumDate!.month == selectedMonth && + widget.minimumDate!.day > day) || + (widget.maximumDate?.year == selectedYear && + widget.maximumDate!.month == selectedMonth && + widget.maximumDate!.day < day); + final Widget child = itemPositioningBuilder( + context, + Text( + localizations.datePickerDayOfMonth(day, dayOfWeek), + style: _themeTextStyle(context, isValid: !isInvalidDay), + ), + ); + return isInvalidDay ? ExcludeSemantics(child: child) : child; + }), + ), + ); + } + + Widget _buildMonthPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isMonthPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isMonthPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker( + scrollController: monthController, + offAxisFraction: offAxisFraction, + itemExtent: widget.itemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + selectedMonth = index + 1; + if (_isCurrentDateValid) { + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay)); + } + }, + looping: true, + selectionOverlay: selectionOverlay, + children: List.generate(12, (int index) { + final int month = index + 1; + final bool isInvalidMonth = + (widget.minimumDate?.year == selectedYear && widget.minimumDate!.month > month) || + (widget.maximumDate?.year == selectedYear && widget.maximumDate!.month < month); + final String monthName = (widget.mode == CupertinoDatePickerMode.monthYear) + ? localizations.datePickerStandaloneMonth(month) + : localizations.datePickerMonth(month); + + final Widget child = itemPositioningBuilder( + context, + Text(monthName, style: _themeTextStyle(context, isValid: !isInvalidMonth)), + ); + return isInvalidMonth ? ExcludeSemantics(child: child) : child; + }), + ), + ); + } + + Widget _buildYearPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isYearPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isYearPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker.builder( + scrollController: yearController, + itemExtent: widget.itemExtent, + offAxisFraction: offAxisFraction, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + selectedYear = index; + if (_isCurrentDateValid) { + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay)); + } + }, + itemBuilder: (BuildContext context, int year) { + if (year < widget.minimumYear) { + return null; + } + + if (widget.maximumYear != null && year > widget.maximumYear!) { + return null; + } + + final bool isValidYear = + (widget.minimumDate == null || widget.minimumDate!.year <= year) && + (widget.maximumDate == null || widget.maximumDate!.year >= year); + + final Widget child = itemPositioningBuilder( + context, + Text( + localizations.datePickerYear(year), + style: _themeTextStyle(context, isValid: isValidYear), + ), + ); + return isValidYear ? child : ExcludeSemantics(child: child); + }, + selectionOverlay: selectionOverlay, + ), + ); + } + + bool get _isCurrentDateValid { + // The current date selection represents a range [minSelectedData, maxSelectDate]. + final minSelectedDate = DateTime(selectedYear, selectedMonth, selectedDay); + final maxSelectedDate = DateTime(selectedYear, selectedMonth, selectedDay + 1); + + final bool minCheck = widget.minimumDate?.isBefore(maxSelectedDate) ?? true; + final bool maxCheck = widget.maximumDate?.isBefore(minSelectedDate) ?? false; + + return minCheck && !maxCheck && minSelectedDate.day == selectedDay; + } + + // One or more pickers have just stopped scrolling. + void _pickerDidStopScrolling() { + // Call setState to update the greyed out days/months/years, as the currently + // selected year/month may have changed. + setState(() {}); + + if (isScrolling) { + return; + } + + // Whenever scrolling lands on an invalid entry, the picker + // automatically scrolls to a valid one. + final minSelectDate = DateTime(selectedYear, selectedMonth, selectedDay); + final maxSelectDate = DateTime(selectedYear, selectedMonth, selectedDay + 1); + + final bool minCheck = widget.minimumDate?.isBefore(maxSelectDate) ?? true; + final bool maxCheck = widget.maximumDate?.isBefore(minSelectDate) ?? false; + + if (!minCheck || maxCheck) { + // We have minCheck === !maxCheck. + final DateTime targetDate = minCheck ? widget.maximumDate! : widget.minimumDate!; + _scrollToDate(targetDate); + return; + } + + // Some months have less days (e.g. February). Go to the last day of that month + // if the selectedDay exceeds the maximum. + if (minSelectDate.day != selectedDay) { + final DateTime lastDay = _lastDayInMonth(selectedYear, selectedMonth); + _scrollToDate(lastDay); + } + } + + void _scrollToDate(DateTime newDate) { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + if (selectedYear != newDate.year) { + _animateColumnControllerToItem(yearController, newDate.year); + } + + if (selectedMonth != newDate.month) { + _animateColumnControllerToItem(monthController, newDate.month - 1); + } + + if (selectedDay != newDate.day) { + _animateColumnControllerToItem(dayController, newDate.day - 1); + } + }, debugLabel: 'DatePicker.scrollToDate'); + } + + @override + Widget build(BuildContext context) { + var pickerBuilders = <_ColumnBuilder>[]; + var columnWidths = []; + + final DatePickerDateOrder datePickerDateOrder = dateOrder ?? localizations.datePickerDateOrder; + + switch (datePickerDateOrder) { + case DatePickerDateOrder.mdy: + pickerBuilders = <_ColumnBuilder>[_buildMonthPicker, _buildDayPicker, _buildYearPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.month.index]!, + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]!, + estimatedColumnWidths[_PickerColumnType.year.index]!, + ]; + case DatePickerDateOrder.dmy: + pickerBuilders = <_ColumnBuilder>[_buildDayPicker, _buildMonthPicker, _buildYearPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]!, + estimatedColumnWidths[_PickerColumnType.month.index]!, + estimatedColumnWidths[_PickerColumnType.year.index]!, + ]; + case DatePickerDateOrder.ymd: + pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildMonthPicker, _buildDayPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.year.index]!, + estimatedColumnWidths[_PickerColumnType.month.index]!, + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]!, + ]; + case DatePickerDateOrder.ydm: + pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildDayPicker, _buildMonthPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.year.index]!, + estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]!, + estimatedColumnWidths[_PickerColumnType.month.index]!, + ]; + } + + final pickers = []; + double totalColumnWidths = 4 * _kDatePickerPadSize; + + for (final (int i, double width) in columnWidths.indexed) { + final (bool firstColumn, bool lastColumn) = (i == 0, i == columnWidths.length - 1); + final double offAxisFraction = (i - 1) * 0.3 * textDirectionFactor; + + var padding = const EdgeInsets.only(right: _kDatePickerPadSize); + if (textDirectionFactor == -1) { + padding = const EdgeInsets.only(left: _kDatePickerPadSize); + } + + Widget? selectionOverlay = _centerSelectionOverlay; + + if (widget.selectionOverlayBuilder != null) { + selectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: i, + columnCount: columnWidths.length, + ); + } else { + if (firstColumn) { + selectionOverlay = _startSelectionOverlay; + } else if (lastColumn) { + selectionOverlay = _endSelectionOverlay; + } + } + + totalColumnWidths += width + (2 * _kDatePickerPadSize); + + pickers.add( + LayoutId( + id: i, + child: pickerBuilders[i](offAxisFraction, (BuildContext context, Widget? child) { + return Padding( + padding: firstColumn ? EdgeInsets.zero : padding, + child: Align( + alignment: lastColumn ? alignCenterLeft : alignCenterRight, + child: SizedBox( + width: width + _kDatePickerPadSize, + child: Align( + alignment: firstColumn ? alignCenterLeft : alignCenterRight, + child: child, + ), + ), + ), + ); + }, selectionOverlay), + ), + ); + } + + final double maxPickerWidth = totalColumnWidths > _kPickerWidth + ? totalColumnWidths + : _kPickerWidth; + + return MediaQuery.withNoTextScaling( + child: DefaultTextStyle.merge( + style: _kDefaultPickerTextStyle, + child: CustomMultiChildLayout( + delegate: _DatePickerLayoutDelegate( + columnWidths: columnWidths, + textDirectionFactor: textDirectionFactor, + maxWidth: maxPickerWidth, + ), + children: pickers, + ), + ), + ); + } +} + +class _CupertinoDatePickerMonthYearState extends State { + _CupertinoDatePickerMonthYearState({required this.dateOrder}); + + final DatePickerDateOrder? dateOrder; + + late int textDirectionFactor; + late CupertinoLocalizations localizations; + + // Alignment based on text direction. The variable name is self descriptive, + // however, when text direction is rtl, alignment is reversed. + late Alignment alignCenterLeft; + late Alignment alignCenterRight; + + // The currently selected values of the picker. + late int selectedYear; + late int selectedMonth; + + // The controller of the day picker. There are cases where the selected value + // of the picker is invalid (e.g. February 30th 2018), and this monthController + // is responsible for jumping to a valid value. + late FixedExtentScrollController monthController; + late FixedExtentScrollController yearController; + + bool isMonthPickerScrolling = false; + bool isYearPickerScrolling = false; + + bool get isScrolling => isMonthPickerScrolling || isYearPickerScrolling; + + // Estimated width of columns. + Map estimatedColumnWidths = {}; + + @override + void initState() { + super.initState(); + selectedMonth = widget.initialDateTime.month; + selectedYear = widget.initialDateTime.year; + + monthController = FixedExtentScrollController(initialItem: selectedMonth - 1); + yearController = FixedExtentScrollController(initialItem: selectedYear); + + PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); + } + + void _handleSystemFontsChange() { + setState(() { + // System fonts change might cause the text layout width to change. + _refreshEstimatedColumnWidths(); + }); + } + + @override + void dispose() { + monthController.dispose(); + yearController.dispose(); + + PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + textDirectionFactor = Directionality.of(context) == TextDirection.ltr ? 1 : -1; + localizations = CupertinoLocalizations.of(context); + + alignCenterLeft = textDirectionFactor == 1 ? Alignment.centerLeft : Alignment.centerRight; + alignCenterRight = textDirectionFactor == 1 ? Alignment.centerRight : Alignment.centerLeft; + + _refreshEstimatedColumnWidths(); + } + + void _refreshEstimatedColumnWidths() { + estimatedColumnWidths[_PickerColumnType.month.index] = CupertinoDatePicker._getColumnWidth( + _PickerColumnType.month, + localizations, + context, + false, + standaloneMonth: widget.mode == CupertinoDatePickerMode.monthYear, + ); + estimatedColumnWidths[_PickerColumnType.year.index] = CupertinoDatePicker._getColumnWidth( + _PickerColumnType.year, + localizations, + context, + false, + ); + } + + Widget _buildMonthPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isMonthPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isMonthPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker( + scrollController: monthController, + offAxisFraction: offAxisFraction, + itemExtent: _kItemExtent, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + selectedMonth = index + 1; + if (_isCurrentDateValid) { + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth)); + } + }, + looping: true, + selectionOverlay: selectionOverlay, + children: List.generate(12, (int index) { + final int month = index + 1; + final bool isInvalidMonth = + (widget.minimumDate?.year == selectedYear && widget.minimumDate!.month > month) || + (widget.maximumDate?.year == selectedYear && widget.maximumDate!.month < month); + final String monthName = (widget.mode == CupertinoDatePickerMode.monthYear) + ? localizations.datePickerStandaloneMonth(month) + : localizations.datePickerMonth(month); + + final Widget child = itemPositioningBuilder( + context, + Text(monthName, style: _themeTextStyle(context, isValid: !isInvalidMonth)), + ); + return isInvalidMonth ? ExcludeSemantics(child: child) : child; + }), + ), + ); + } + + Widget _buildYearPicker( + double offAxisFraction, + TransitionBuilder itemPositioningBuilder, + Widget? selectionOverlay, + ) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification is ScrollStartNotification) { + isYearPickerScrolling = true; + } else if (notification is ScrollEndNotification) { + isYearPickerScrolling = false; + _pickerDidStopScrolling(); + } + + return false; + }, + child: CupertinoPicker.builder( + scrollController: yearController, + itemExtent: _kItemExtent, + offAxisFraction: offAxisFraction, + useMagnifier: _kUseMagnifier, + magnification: _kMagnification, + backgroundColor: widget.backgroundColor, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + selectedYear = index; + if (_isCurrentDateValid) { + widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth)); + } + }, + itemBuilder: (BuildContext context, int year) { + if (year < widget.minimumYear) { + return null; + } + + if (widget.maximumYear != null && year > widget.maximumYear!) { + return null; + } + + final bool isValidYear = + (widget.minimumDate == null || widget.minimumDate!.year <= year) && + (widget.maximumDate == null || widget.maximumDate!.year >= year); + + final Widget child = itemPositioningBuilder( + context, + Text( + localizations.datePickerYear(year), + style: _themeTextStyle(context, isValid: isValidYear), + ), + ); + return isValidYear ? child : ExcludeSemantics(child: child); + }, + selectionOverlay: selectionOverlay, + ), + ); + } + + bool get _isCurrentDateValid { + // The current date selection represents a range [minSelectedData, maxSelectDate]. + final minSelectedDate = DateTime(selectedYear, selectedMonth); + final maxSelectedDate = DateTime(selectedYear, selectedMonth, widget.initialDateTime.day + 1); + + final bool minCheck = widget.minimumDate?.isBefore(maxSelectedDate) ?? true; + final bool maxCheck = widget.maximumDate?.isBefore(minSelectedDate) ?? false; + + return minCheck && !maxCheck; + } + + // One or more pickers have just stopped scrolling. + void _pickerDidStopScrolling() { + // Call setState to update the greyed out days/months/years, as the currently + // selected year/month may have changed. + setState(() {}); + + if (isScrolling) { + return; + } + + // Whenever scrolling lands on an invalid entry, the picker + // automatically scrolls to a valid one. + final minSelectDate = DateTime(selectedYear, selectedMonth); + final maxSelectDate = DateTime(selectedYear, selectedMonth, widget.initialDateTime.day + 1); + + final bool minCheck = widget.minimumDate?.isBefore(maxSelectDate) ?? true; + final bool maxCheck = widget.maximumDate?.isBefore(minSelectDate) ?? false; + + if (!minCheck || maxCheck) { + // We have minCheck === !maxCheck. + final DateTime targetDate = minCheck ? widget.maximumDate! : widget.minimumDate!; + _scrollToDate(targetDate); + return; + } + } + + void _scrollToDate(DateTime newDate) { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + if (selectedYear != newDate.year) { + _animateColumnControllerToItem(yearController, newDate.year); + } + + if (selectedMonth != newDate.month) { + _animateColumnControllerToItem(monthController, newDate.month - 1); + } + }, debugLabel: 'DatePicker.scrollToDate'); + } + + @override + Widget build(BuildContext context) { + var pickerBuilders = <_ColumnBuilder>[]; + var columnWidths = []; + + final DatePickerDateOrder datePickerDateOrder = dateOrder ?? localizations.datePickerDateOrder; + + switch (datePickerDateOrder) { + case DatePickerDateOrder.mdy: + case DatePickerDateOrder.dmy: + pickerBuilders = <_ColumnBuilder>[_buildMonthPicker, _buildYearPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.month.index]!, + estimatedColumnWidths[_PickerColumnType.year.index]!, + ]; + case DatePickerDateOrder.ymd: + case DatePickerDateOrder.ydm: + pickerBuilders = <_ColumnBuilder>[_buildYearPicker, _buildMonthPicker]; + columnWidths = [ + estimatedColumnWidths[_PickerColumnType.year.index]!, + estimatedColumnWidths[_PickerColumnType.month.index]!, + ]; + } + + final pickers = []; + double totalColumnWidths = 3 * _kDatePickerPadSize; + + for (final (int i, double width) in columnWidths.indexed) { + final (bool firstColumn, bool lastColumn) = (i == 0, i == columnWidths.length - 1); + final double offAxisFraction = textDirectionFactor * (firstColumn ? -0.3 : 0.5); + + totalColumnWidths += width + (2 * _kDatePickerPadSize); + + Widget? selectionOverlay = _centerSelectionOverlay; + + if (widget.selectionOverlayBuilder != null) { + selectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: i, + columnCount: columnWidths.length, + ); + } else { + if (firstColumn) { + selectionOverlay = _startSelectionOverlay; + } else if (lastColumn) { + selectionOverlay = _endSelectionOverlay; + } + } + + pickers.add( + LayoutId( + id: i, + child: pickerBuilders[i](offAxisFraction, (BuildContext context, Widget? child) { + final Widget contents = Align( + alignment: lastColumn ? alignCenterLeft : alignCenterRight, + child: SizedBox( + width: width + _kDatePickerPadSize, + child: Align( + alignment: firstColumn ? alignCenterLeft : alignCenterRight, + child: child, + ), + ), + ); + if (firstColumn) { + return contents; + } + + const padding = EdgeInsets.only(right: _kDatePickerPadSize); + return Padding( + padding: textDirectionFactor == -1 ? padding.flipped : padding, + child: contents, + ); + }, selectionOverlay), + ), + ); + } + + final double maxPickerWidth = totalColumnWidths > _kPickerWidth + ? totalColumnWidths + : _kPickerWidth; + + return MediaQuery.withNoTextScaling( + child: DefaultTextStyle.merge( + style: _kDefaultPickerTextStyle, + child: CustomMultiChildLayout( + delegate: _DatePickerLayoutDelegate( + columnWidths: columnWidths, + textDirectionFactor: textDirectionFactor, + maxWidth: maxPickerWidth, + ), + children: pickers, + ), + ), + ); + } +} + +// The iOS date picker and timer picker has their width fixed to 320.0 in all +// modes. The only exception is the hms mode (which doesn't have a native counterpart), +// with a fixed width of 330.0 px. +// +// For date pickers, if the maximum width given to the picker is greater than +// 320.0, the leftmost and rightmost column will be extended equally so that the +// widths match, and the picker is in the center. +// +// For timer pickers, if the maximum width given to the picker is greater than +// its intrinsic width, it will keep its intrinsic size and position itself in the +// parent using its alignment parameter. +// +// If the maximum width given to the picker is smaller than 320.0, the picker's +// layout will be broken. + +/// Different modes of [CupertinoTimerPicker]. +/// +/// See also: +/// +/// * [CupertinoTimerPicker], the class that implements the iOS-style timer picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. +enum CupertinoTimerPickerMode { + /// Mode that shows the timer duration in hour and minute. + /// + /// Examples: 16 hours | 14 min. + hm, + + /// Mode that shows the timer duration in minute and second. + /// + /// Examples: 14 min | 43 sec. + ms, + + /// Mode that shows the timer duration in hour, minute, and second. + /// + /// Examples: 16 hours | 14 min | 43 sec. + hms, +} + +/// A countdown timer picker in iOS style. +/// +/// This picker shows a countdown duration with hour, minute and second spinners. +/// The duration is bound between 0 and 23 hours 59 minutes 59 seconds. +/// +/// There are several modes of the timer picker listed in [CupertinoTimerPickerMode]. +/// +/// The picker has a fixed size of 320 x 216, in logical pixels, with the exception +/// of [CupertinoTimerPickerMode.hms], which is 330 x 216. If the parent widget +/// provides more space than it needs, the picker will position itself according +/// to its [alignment] property. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoTimerPicker] that returns a countdown duration. +/// +/// ** See code in examples/api/lib/cupertino/date_picker/cupertino_timer_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoDatePicker], the class that implements different display modes +/// of the iOS-style date picker. +/// * [CupertinoPicker], the class that implements a content agnostic spinner UI. +/// * +class CupertinoTimerPicker extends StatefulWidget { + /// Constructs an iOS style countdown timer picker. + /// + /// [mode] is one of the modes listed in [CupertinoTimerPickerMode] and + /// defaults to [CupertinoTimerPickerMode.hms]. + /// + /// [onTimerDurationChanged] is the callback called when the selected duration + /// changes. + /// + /// [initialTimerDuration] defaults to 0 second and is limited from 0 second + /// to 23 hours 59 minutes 59 seconds. + /// + /// [minuteInterval] is the granularity of the minute spinner. Must be a + /// positive integer factor of 60. + /// + /// [secondInterval] is the granularity of the second spinner. Must be a + /// positive integer factor of 60. + CupertinoTimerPicker({ + super.key, + this.mode = CupertinoTimerPickerMode.hms, + this.initialTimerDuration = Duration.zero, + this.minuteInterval = 1, + this.secondInterval = 1, + this.alignment = Alignment.center, + this.backgroundColor, + this.itemExtent = _kItemExtent, + required this.onTimerDurationChanged, + this.changeReportingBehavior = ChangeReportingBehavior.onScrollUpdate, + this.selectionOverlayBuilder, + }) : assert(initialTimerDuration >= Duration.zero), + assert(initialTimerDuration < const Duration(days: 1)), + assert(minuteInterval > 0 && 60 % minuteInterval == 0), + assert(secondInterval > 0 && 60 % secondInterval == 0), + assert(initialTimerDuration.inMinutes % minuteInterval == 0), + assert(initialTimerDuration.inSeconds % secondInterval == 0), + assert(itemExtent > 0, 'item extent should be greater than 0'); + + /// The mode of the timer picker. + final CupertinoTimerPickerMode mode; + + /// The initial duration of the countdown timer. + final Duration initialTimerDuration; + + /// The granularity of the minute spinner. Must be a positive integer factor + /// of 60. + final int minuteInterval; + + /// The granularity of the second spinner. Must be a positive integer factor + /// of 60. + final int secondInterval; + + /// Callback called when the timer duration changes. + /// + /// The timing of this callback is controlled by [changeReportingBehavior]. + final ValueChanged onTimerDurationChanged; + + /// Defines how the timer picker should be positioned within its parent. + /// + /// Defaults to [Alignment.center]. + final AlignmentGeometry alignment; + + /// Background color of timer picker. + /// + /// Defaults to null, which disables background painting entirely. + final Color? backgroundColor; + + /// {@macro flutter.cupertino.picker.itemExtent} + /// + /// Defaults to a value that matches the default iOS timer picker wheel. + final double itemExtent; + + /// A function that returns a widget that is overlaid on the picker + /// to highlight the currently selected entry. + /// + /// If unspecified, it defaults to a [CupertinoPickerDefaultSelectionOverlay] + /// which is a gray rounded rectangle overlay in iOS 14 style. + /// + /// If the selection overlay builder returns null, no overlay will be drawn. + /// + /// {@tool snippet} + /// + /// This example shows how to recreate the default selection overlay + /// with selectionOverlayBuilder. + /// + /// ```dart + /// CupertinoTimerPicker( + /// onTimerDurationChanged: (Duration newDateTime) {}, + /// selectionOverlayBuilder: ( + /// BuildContext context, { + /// required int selectedIndex, + /// required int columnCount, + /// }) { + /// if (selectedIndex == 0) { + /// return const CupertinoPickerDefaultSelectionOverlay( + /// capEndEdge: false, + /// ); + /// } else if (selectedIndex == columnCount - 1) { + /// return const CupertinoPickerDefaultSelectionOverlay( + /// capStartEdge: false, + /// ); + /// } + /// return const CupertinoPickerDefaultSelectionOverlay( + /// capStartEdge: false, + /// capEndEdge: false, + /// ); + /// }, + /// ) + /// ``` + /// {@end-tool} + final SelectionOverlayBuilder? selectionOverlayBuilder; + + /// The behavior of reporting the selected duration. + /// + /// This determines when the [onTimerDurationChanged] callback is called. + /// + /// Native iOS 18 behavior is [ChangeReportingBehavior.onScrollEnd], which + /// calls the callback only when the scrolling stops. + /// + /// Defaults to [ChangeReportingBehavior.onScrollUpdate]. + final ChangeReportingBehavior changeReportingBehavior; + + @override + State createState() => _CupertinoTimerPickerState(); +} + +class _CupertinoTimerPickerState extends State { + late TextDirection textDirection; + late CupertinoLocalizations localizations; + + int get textDirectionFactor => switch (textDirection) { + TextDirection.ltr => 1, + TextDirection.rtl => -1, + }; + + // The currently selected values of the picker. + int? selectedHour; + late int selectedMinute; + int? selectedSecond; + + // On iOS the selected values won't be reported until the scrolling fully stops. + // The values below are the latest selected values when the picker comes to a full stop. + int? lastSelectedHour; + int? lastSelectedMinute; + int? lastSelectedSecond; + + final TextPainter textPainter = TextPainter(); + final List numbers = List.generate(10, (int i) => '${9 - i}'); + late double numberLabelWidth; + late double numberLabelHeight; + late double numberLabelBaseline; + + late double hourLabelWidth; + late double minuteLabelWidth; + late double secondLabelWidth; + + late double totalWidth; + late double pickerColumnWidth; + + FixedExtentScrollController? _hourScrollController; + FixedExtentScrollController? _minuteScrollController; + FixedExtentScrollController? _secondScrollController; + + @override + void initState() { + super.initState(); + + selectedMinute = widget.initialTimerDuration.inMinutes % 60; + + if (widget.mode != CupertinoTimerPickerMode.ms) { + selectedHour = widget.initialTimerDuration.inHours; + } + + if (widget.mode != CupertinoTimerPickerMode.hm) { + selectedSecond = widget.initialTimerDuration.inSeconds % 60; + } + + PaintingBinding.instance.systemFonts.addListener(_handleSystemFontsChange); + } + + void _handleSystemFontsChange() { + setState(() { + // System fonts change might cause the text layout width to change. + textPainter.markNeedsLayout(); + _measureLabelMetrics(); + }); + } + + @override + void dispose() { + PaintingBinding.instance.systemFonts.removeListener(_handleSystemFontsChange); + textPainter.dispose(); + + _hourScrollController?.dispose(); + _minuteScrollController?.dispose(); + _secondScrollController?.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(CupertinoTimerPicker oldWidget) { + super.didUpdateWidget(oldWidget); + + assert( + oldWidget.mode == widget.mode, + "The CupertinoTimerPicker's mode cannot change once it's built", + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + textDirection = Directionality.of(context); + localizations = CupertinoLocalizations.of(context); + + _measureLabelMetrics(); + } + + void _measureLabelMetrics() { + textPainter.textDirection = textDirection; + final TextStyle textStyle = _textStyleFrom(context, _kTimerPickerMagnification); + + double maxWidth = double.negativeInfinity; + String? widestNumber; + + // Assumes that: + // - 2-digit numbers are always wider than 1-digit numbers. + // - There's at least one number in 1-9 that's wider than or equal to 0. + // - The widest 2-digit number is composed of 2 same 1-digit numbers + // that has the biggest width. + // - If two different 1-digit numbers are of the same width, their corresponding + // 2 digit numbers are of the same width. + for (final String input in numbers) { + textPainter.text = TextSpan(text: input, style: textStyle); + textPainter.layout(); + + if (textPainter.maxIntrinsicWidth > maxWidth) { + maxWidth = textPainter.maxIntrinsicWidth; + widestNumber = input; + } + } + + textPainter.text = TextSpan(text: '$widestNumber$widestNumber', style: textStyle); + + textPainter.layout(); + numberLabelWidth = textPainter.maxIntrinsicWidth; + numberLabelHeight = textPainter.height; + numberLabelBaseline = textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); + + minuteLabelWidth = _measureLabelsMaxWidth(localizations.timerPickerMinuteLabels, textStyle); + + if (widget.mode != CupertinoTimerPickerMode.ms) { + hourLabelWidth = _measureLabelsMaxWidth(localizations.timerPickerHourLabels, textStyle); + } + + if (widget.mode != CupertinoTimerPickerMode.hm) { + secondLabelWidth = _measureLabelsMaxWidth(localizations.timerPickerSecondLabels, textStyle); + } + } + + // Measures all possible time text labels and return maximum width. + double _measureLabelsMaxWidth(List labels, TextStyle style) { + double maxWidth = double.negativeInfinity; + for (var i = 0; i < labels.length; i++) { + final String? label = labels[i]; + if (label == null) { + continue; + } + + textPainter.text = TextSpan(text: label, style: style); + textPainter.layout(); + textPainter.maxIntrinsicWidth; + if (textPainter.maxIntrinsicWidth > maxWidth) { + maxWidth = textPainter.maxIntrinsicWidth; + } + } + + return maxWidth; + } + + // Builds a text label with scale factor 1.0 and font weight semi-bold. + // `pickerPadding ` is the additional padding the corresponding picker has to apply + // around the `Text`, in order to extend its separators towards the closest + // horizontal edge of the encompassing widget. + Widget _buildLabel(String text, EdgeInsetsDirectional pickerPadding) { + final padding = EdgeInsetsDirectional.only( + start: numberLabelWidth + _kTimerPickerLabelPadSize + pickerPadding.start, + ); + + return IgnorePointer( + child: Padding( + padding: padding.resolve(textDirection), + child: Align( + alignment: AlignmentDirectional.centerStart.resolve(textDirection), + child: SizedBox( + height: numberLabelHeight, + child: Baseline( + baseline: numberLabelBaseline, + baselineType: TextBaseline.alphabetic, + child: Text( + text, + style: const TextStyle( + fontSize: _kTimerPickerLabelFontSize, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + softWrap: false, + ), + ), + ), + ), + ), + ); + } + + // The picker has to be wider than its content, since the separators + // are part of the picker. + Widget _buildPickerNumberLabel(String text, EdgeInsetsDirectional padding) { + return SizedBox( + width: _kTimerPickerColumnIntrinsicWidth + padding.horizontal, + child: Padding( + padding: padding.resolve(textDirection), + child: Align( + alignment: AlignmentDirectional.centerStart.resolve(textDirection), + child: SizedBox( + width: numberLabelWidth, + child: Align( + alignment: AlignmentDirectional.centerEnd.resolve(textDirection), + child: Text(text, softWrap: false, maxLines: 1, overflow: TextOverflow.visible), + ), + ), + ), + ), + ); + } + + Widget _buildHourPicker(EdgeInsetsDirectional additionalPadding, Widget? selectionOverlay) { + _hourScrollController ??= FixedExtentScrollController(initialItem: selectedHour!); + return CupertinoPicker( + scrollController: _hourScrollController, + magnification: _kMagnification, + offAxisFraction: _calculateOffAxisFraction(additionalPadding.start, 0), + itemExtent: widget.itemExtent, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + setState(() { + selectedHour = index; + widget.onTimerDurationChanged( + Duration(hours: selectedHour!, minutes: selectedMinute, seconds: selectedSecond ?? 0), + ); + }); + }, + selectionOverlay: selectionOverlay, + children: List.generate(24, (int index) { + final String label = localizations.timerPickerHourLabel(index) ?? ''; + final String semanticsLabel = textDirectionFactor == 1 + ? localizations.timerPickerHour(index) + label + : label + localizations.timerPickerHour(index); + + return Semantics( + label: semanticsLabel, + excludeSemantics: true, + child: _buildPickerNumberLabel(localizations.timerPickerHour(index), additionalPadding), + ); + }), + ); + } + + Widget _buildHourColumn(EdgeInsetsDirectional additionalPadding, Widget? selectionOverlay) { + additionalPadding = EdgeInsetsDirectional.only( + start: math.max(additionalPadding.start, 0), + end: math.max(additionalPadding.end, 0), + ); + + return Stack( + children: [ + NotificationListener( + onNotification: (ScrollEndNotification notification) { + setState(() { + lastSelectedHour = selectedHour; + }); + return false; + }, + child: _buildHourPicker(additionalPadding, selectionOverlay), + ), + _buildLabel( + localizations.timerPickerHourLabel(lastSelectedHour ?? selectedHour!) ?? '', + additionalPadding, + ), + ], + ); + } + + Widget _buildMinutePicker(EdgeInsetsDirectional additionalPadding, Widget? selectionOverlay) { + _minuteScrollController ??= FixedExtentScrollController( + initialItem: selectedMinute ~/ widget.minuteInterval, + ); + return CupertinoPicker( + scrollController: _minuteScrollController, + magnification: _kMagnification, + offAxisFraction: _calculateOffAxisFraction( + additionalPadding.start, + widget.mode == CupertinoTimerPickerMode.ms ? 0 : 1, + ), + itemExtent: widget.itemExtent, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + looping: true, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + setState(() { + selectedMinute = index * widget.minuteInterval; + widget.onTimerDurationChanged( + Duration( + hours: selectedHour ?? 0, + minutes: selectedMinute, + seconds: selectedSecond ?? 0, + ), + ); + }); + }, + selectionOverlay: selectionOverlay, + children: List.generate(60 ~/ widget.minuteInterval, (int index) { + final int minute = index * widget.minuteInterval; + final String label = localizations.timerPickerMinuteLabel(minute) ?? ''; + final String semanticsLabel = textDirectionFactor == 1 + ? localizations.timerPickerMinute(minute) + label + : label + localizations.timerPickerMinute(minute); + + return Semantics( + label: semanticsLabel, + excludeSemantics: true, + child: _buildPickerNumberLabel( + localizations.timerPickerMinute(minute), + additionalPadding, + ), + ); + }), + ); + } + + Widget _buildMinuteColumn(EdgeInsetsDirectional additionalPadding, Widget? selectionOverlay) { + additionalPadding = EdgeInsetsDirectional.only( + start: math.max(additionalPadding.start, 0), + end: math.max(additionalPadding.end, 0), + ); + + return Stack( + children: [ + NotificationListener( + onNotification: (ScrollEndNotification notification) { + setState(() { + lastSelectedMinute = selectedMinute; + }); + return false; + }, + child: _buildMinutePicker(additionalPadding, selectionOverlay), + ), + _buildLabel( + localizations.timerPickerMinuteLabel(lastSelectedMinute ?? selectedMinute) ?? '', + additionalPadding, + ), + ], + ); + } + + Widget _buildSecondPicker(EdgeInsetsDirectional additionalPadding, Widget? selectionOverlay) { + _secondScrollController ??= FixedExtentScrollController( + initialItem: selectedSecond! ~/ widget.secondInterval, + ); + return CupertinoPicker( + scrollController: _secondScrollController, + magnification: _kMagnification, + offAxisFraction: _calculateOffAxisFraction( + additionalPadding.start, + widget.mode == CupertinoTimerPickerMode.ms ? 1 : 2, + ), + itemExtent: widget.itemExtent, + backgroundColor: widget.backgroundColor, + squeeze: _kSqueeze, + looping: true, + changeReportingBehavior: widget.changeReportingBehavior, + onSelectedItemChanged: (int index) { + setState(() { + selectedSecond = index * widget.secondInterval; + widget.onTimerDurationChanged( + Duration(hours: selectedHour ?? 0, minutes: selectedMinute, seconds: selectedSecond!), + ); + }); + }, + selectionOverlay: selectionOverlay, + children: List.generate(60 ~/ widget.secondInterval, (int index) { + final int second = index * widget.secondInterval; + final String label = localizations.timerPickerSecondLabel(second) ?? ''; + final String semanticsLabel = textDirectionFactor == 1 + ? localizations.timerPickerSecond(second) + label + : label + localizations.timerPickerSecond(second); + + return Semantics( + label: semanticsLabel, + excludeSemantics: true, + child: _buildPickerNumberLabel( + localizations.timerPickerSecond(second), + additionalPadding, + ), + ); + }), + ); + } + + Widget _buildSecondColumn(EdgeInsetsDirectional additionalPadding, Widget? selectionOverlay) { + additionalPadding = EdgeInsetsDirectional.only( + start: math.max(additionalPadding.start, 0), + end: math.max(additionalPadding.end, 0), + ); + + return Stack( + children: [ + NotificationListener( + onNotification: (ScrollEndNotification notification) { + setState(() { + lastSelectedSecond = selectedSecond; + }); + return false; + }, + child: _buildSecondPicker(additionalPadding, selectionOverlay), + ), + _buildLabel( + localizations.timerPickerSecondLabel(lastSelectedSecond ?? selectedSecond!) ?? '', + additionalPadding, + ), + ], + ); + } + + // Returns [CupertinoTextThemeData.pickerTextStyle] and magnifies the fontSize + // by [magnification]. + TextStyle _textStyleFrom(BuildContext context, [double magnification = 1.0]) { + final TextStyle textStyle = CupertinoTheme.of(context).textTheme.pickerTextStyle; + return textStyle.copyWith( + color: CupertinoDynamicColor.maybeResolve(textStyle.color, context), + fontSize: textStyle.fontSize! * magnification, + ); + } + + // Calculate the number label center point by padding start and position to + // get a reasonable offAxisFraction. + double _calculateOffAxisFraction(double paddingStart, int position) { + final double centerPoint = paddingStart + (numberLabelWidth / 2); + + // Compute the offAxisFraction needed to be straight within the pickerColumn. + final double pickerColumnOffAxisFraction = 0.5 - centerPoint / pickerColumnWidth; + // Position is to calculate the reasonable offAxisFraction in the picker. + final double timerPickerOffAxisFraction = + 0.5 - (centerPoint + pickerColumnWidth * position) / totalWidth; + return (pickerColumnOffAxisFraction - timerPickerOffAxisFraction) * textDirectionFactor; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // The timer picker can be divided into columns corresponding to hour, + // minute, and second. Each column consists of a scrollable and a fixed + // label on top of it. + List columns; + + if (widget.mode == CupertinoTimerPickerMode.hms) { + // Pad the widget to make it as wide as `_kPickerWidth`. + pickerColumnWidth = + _kTimerPickerColumnIntrinsicWidth + (_kTimerPickerHalfColumnPadding * 2); + totalWidth = pickerColumnWidth * 3; + } else { + // The default totalWidth for 2-column modes. + totalWidth = _kPickerWidth; + pickerColumnWidth = totalWidth / 2; + } + + if (constraints.maxWidth < totalWidth) { + totalWidth = constraints.maxWidth; + pickerColumnWidth = totalWidth / (widget.mode == CupertinoTimerPickerMode.hms ? 3 : 2); + } + + final double baseLabelContentWidth = numberLabelWidth + _kTimerPickerLabelPadSize; + final double minuteLabelContentWidth = baseLabelContentWidth + minuteLabelWidth; + + switch (widget.mode) { + case CupertinoTimerPickerMode.hm: + // Pad the widget to make it as wide as `_kPickerWidth`. + final double hourLabelContentWidth = baseLabelContentWidth + hourLabelWidth; + double hourColumnStartPadding = + pickerColumnWidth - hourLabelContentWidth - _kTimerPickerHalfColumnPadding; + if (hourColumnStartPadding < _kTimerPickerMinHorizontalPadding) { + hourColumnStartPadding = _kTimerPickerMinHorizontalPadding; + } + + double minuteColumnEndPadding = + pickerColumnWidth - minuteLabelContentWidth - _kTimerPickerHalfColumnPadding; + if (minuteColumnEndPadding < _kTimerPickerMinHorizontalPadding) { + minuteColumnEndPadding = _kTimerPickerMinHorizontalPadding; + } + + Widget? hourSelectionOverlay = _startSelectionOverlay; + Widget? minuteSelectionOverlay = _endSelectionOverlay; + + if (widget.selectionOverlayBuilder != null) { + hourSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 0, + columnCount: 2, + ); + minuteSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 1, + columnCount: 2, + ); + } + + columns = [ + _buildHourColumn( + EdgeInsetsDirectional.only( + start: hourColumnStartPadding, + end: pickerColumnWidth - hourColumnStartPadding - hourLabelContentWidth, + ), + hourSelectionOverlay, + ), + _buildMinuteColumn( + EdgeInsetsDirectional.only( + start: pickerColumnWidth - minuteColumnEndPadding - minuteLabelContentWidth, + end: minuteColumnEndPadding, + ), + minuteSelectionOverlay, + ), + ]; + case CupertinoTimerPickerMode.ms: + final double secondLabelContentWidth = baseLabelContentWidth + secondLabelWidth; + double secondColumnEndPadding = + pickerColumnWidth - secondLabelContentWidth - _kTimerPickerHalfColumnPadding; + if (secondColumnEndPadding < _kTimerPickerMinHorizontalPadding) { + secondColumnEndPadding = _kTimerPickerMinHorizontalPadding; + } + + double minuteColumnStartPadding = + pickerColumnWidth - minuteLabelContentWidth - _kTimerPickerHalfColumnPadding; + if (minuteColumnStartPadding < _kTimerPickerMinHorizontalPadding) { + minuteColumnStartPadding = _kTimerPickerMinHorizontalPadding; + } + + Widget? minuteSelectionOverlay = _startSelectionOverlay; + Widget? secondSelectionOverlay = _endSelectionOverlay; + + if (widget.selectionOverlayBuilder != null) { + minuteSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 0, + columnCount: 2, + ); + secondSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 1, + columnCount: 2, + ); + } + + columns = [ + _buildMinuteColumn( + EdgeInsetsDirectional.only( + start: minuteColumnStartPadding, + end: pickerColumnWidth - minuteColumnStartPadding - minuteLabelContentWidth, + ), + minuteSelectionOverlay, + ), + _buildSecondColumn( + EdgeInsetsDirectional.only( + start: pickerColumnWidth - secondColumnEndPadding - minuteLabelContentWidth, + end: secondColumnEndPadding, + ), + secondSelectionOverlay, + ), + ]; + case CupertinoTimerPickerMode.hms: + final double hourColumnEndPadding = + pickerColumnWidth - + baseLabelContentWidth - + hourLabelWidth - + _kTimerPickerMinHorizontalPadding; + final double minuteColumnPadding = (pickerColumnWidth - minuteLabelContentWidth) / 2; + final double secondColumnStartPadding = + pickerColumnWidth - + baseLabelContentWidth - + secondLabelWidth - + _kTimerPickerMinHorizontalPadding; + + Widget? hourSelectionOverlay = _startSelectionOverlay; + Widget? minuteSelectionOverlay = _centerSelectionOverlay; + Widget? secondSelectionOverlay = _endSelectionOverlay; + + if (widget.selectionOverlayBuilder != null) { + hourSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 0, + columnCount: 3, + ); + minuteSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 1, + columnCount: 3, + ); + secondSelectionOverlay = widget.selectionOverlayBuilder!( + context, + selectedIndex: 2, + columnCount: 3, + ); + } + + columns = [ + _buildHourColumn( + EdgeInsetsDirectional.only( + start: _kTimerPickerMinHorizontalPadding, + end: math.max(hourColumnEndPadding, 0), + ), + hourSelectionOverlay, + ), + _buildMinuteColumn( + EdgeInsetsDirectional.only(start: minuteColumnPadding, end: minuteColumnPadding), + minuteSelectionOverlay, + ), + _buildSecondColumn( + EdgeInsetsDirectional.only( + start: math.max(secondColumnStartPadding, 0), + end: _kTimerPickerMinHorizontalPadding, + ), + secondSelectionOverlay, + ), + ]; + } + + Widget contents = SizedBox( + width: totalWidth, + height: _kPickerHeight, + child: DefaultTextStyle( + style: _textStyleFrom(context), + child: Row( + children: columns + .map((Widget child) => Expanded(child: child)) + .toList(growable: false), + ), + ), + ); + final Color? color = CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context); + if (color != null) { + contents = ColoredBox(color: color, child: contents); + } + + final CupertinoThemeData themeData = CupertinoTheme.of(context); + + // Text scaling is fixed to match the native iOS date picker. + return MediaQuery.withNoTextScaling( + child: CupertinoTheme( + data: themeData.copyWith( + textTheme: themeData.textTheme.copyWith( + pickerTextStyle: _textStyleFrom(context, _kTimerPickerMagnification), + ), + ), + child: Align(alignment: widget.alignment, child: contents), + ), + ); + }, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/debug.dart b/packages/cupertino_ui/lib/src/debug.dart new file mode 100644 index 000000000000..bbdb2b29a151 --- /dev/null +++ b/packages/cupertino_ui/lib/src/debug.dart @@ -0,0 +1,52 @@ +// 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/widgets.dart'; + +import 'localizations.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Asserts that the given context has a [Localizations] ancestor that contains +/// a [CupertinoLocalizations] delegate. +/// +/// To call this function, use the following pattern, typically in the +/// relevant Widget's build method: +/// +/// ```dart +/// assert(debugCheckHasCupertinoLocalizations(context)); +/// ``` +/// +/// Always place this before any early returns, so that the invariant is checked +/// in all cases. This prevents bugs from hiding until a particular codepath is +/// hit. +/// +/// Does nothing if asserts are disabled. Always returns true. +bool debugCheckHasCupertinoLocalizations(BuildContext context) { + assert(() { + if (Localizations.of(context, CupertinoLocalizations) == null) { + throw FlutterError.fromParts([ + ErrorSummary('No CupertinoLocalizations found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require CupertinoLocalizations ' + 'to be provided by a Localizations widget ancestor.', + ), + ErrorDescription( + 'The cupertino library uses Localizations to generate messages, ' + 'labels, and abbreviations.', + ), + ErrorHint( + 'To introduce a CupertinoLocalizations, either use a ' + 'CupertinoApp at the root of your application to include them ' + 'automatically, or add a Localization widget with a ' + 'CupertinoLocalizations delegate.', + ), + ...context.describeMissingAncestor(expectedAncestorType: CupertinoLocalizations), + ]); + } + return true; + }()); + return true; +} diff --git a/packages/cupertino_ui/lib/src/desktop_text_selection.dart b/packages/cupertino_ui/lib/src/desktop_text_selection.dart new file mode 100644 index 000000000000..0cfe4c5533c8 --- /dev/null +++ b/packages/cupertino_ui/lib/src/desktop_text_selection.dart @@ -0,0 +1,216 @@ +// 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' show ValueListenable, clampDouble; +import 'package:flutter/widgets.dart'; + +import 'desktop_text_selection_toolbar.dart'; +import 'desktop_text_selection_toolbar_button.dart'; +import 'localizations.dart'; + +/// MacOS Cupertino styled text selection handle controls. +/// +/// Specifically does not manage the toolbar, which is left to +/// [EditableText.contextMenuBuilder]. +class _CupertinoDesktopTextSelectionHandleControls extends CupertinoDesktopTextSelectionControls + with TextSelectionHandleControls {} + +/// Desktop Cupertino styled text selection controls. +/// +/// The [cupertinoDesktopTextSelectionControls] global variable has a +/// suitable instance of this class. +class CupertinoDesktopTextSelectionControls extends TextSelectionControls { + /// Desktop has no text selection handles. + @override + Size getHandleSize(double textLineHeight) { + return Size.zero; + } + + /// Builder for the MacOS-style copy/paste text selection toolbar. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List endpoints, + TextSelectionDelegate delegate, + ValueListenable? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return _CupertinoDesktopTextSelectionControlsToolbar( + clipboardStatus: clipboardStatus, + endpoints: endpoints, + globalEditableRegion: globalEditableRegion, + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, + selectionMidpoint: selectionMidpoint, + lastSecondaryTapDownPosition: lastSecondaryTapDownPosition, + textLineHeight: textLineHeight, + ); + } + + /// Builds the text selection handles, but desktop has none. + @override + Widget buildHandle( + BuildContext context, + TextSelectionHandleType type, + double textLineHeight, [ + VoidCallback? onTap, + ]) { + return const SizedBox.shrink(); + } + + /// Gets the position for the text selection handles, but desktop has none. + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + return Offset.zero; + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void handleSelectAll(TextSelectionDelegate delegate) { + super.handleSelectAll(delegate); + delegate.hideToolbar(); + } +} + +// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is +// deleted, when users should migrate back to +// cupertinoDesktopTextSelectionControls. +// See https://github.com/flutter/flutter/pull/124262 +/// Text selection handle controls that follow MacOS design conventions. +final TextSelectionControls cupertinoDesktopTextSelectionHandleControls = + _CupertinoDesktopTextSelectionHandleControls(); + +/// Text selection controls that follows MacOS design conventions. +final TextSelectionControls cupertinoDesktopTextSelectionControls = + CupertinoDesktopTextSelectionControls(); + +// Generates the child that's passed into CupertinoDesktopTextSelectionToolbar. +class _CupertinoDesktopTextSelectionControlsToolbar extends StatefulWidget { + const _CupertinoDesktopTextSelectionControlsToolbar({ + required this.clipboardStatus, + required this.endpoints, + required this.globalEditableRegion, + required this.handleCopy, + required this.handleCut, + required this.handlePaste, + required this.handleSelectAll, + required this.selectionMidpoint, + required this.textLineHeight, + required this.lastSecondaryTapDownPosition, + }); + + final ValueListenable? clipboardStatus; + final List endpoints; + final Rect globalEditableRegion; + final VoidCallback? handleCopy; + final VoidCallback? handleCut; + final VoidCallback? handlePaste; + final VoidCallback? handleSelectAll; + final Offset? lastSecondaryTapDownPosition; + final Offset selectionMidpoint; + final double textLineHeight; + + @override + _CupertinoDesktopTextSelectionControlsToolbarState createState() => + _CupertinoDesktopTextSelectionControlsToolbarState(); +} + +class _CupertinoDesktopTextSelectionControlsToolbarState + extends State<_CupertinoDesktopTextSelectionControlsToolbar> { + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + + @override + void didUpdateWidget(_CupertinoDesktopTextSelectionControlsToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clipboardStatus != widget.clipboardStatus) { + oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + } + + @override + void dispose() { + widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Don't render the menu until the state of the clipboard is known. + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) { + return const SizedBox.shrink(); + } + + assert(debugCheckHasMediaQuery(context)); + final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); + + final midpointAnchor = Offset( + clampDouble( + widget.selectionMidpoint.dx - widget.globalEditableRegion.left, + mediaQueryPadding.left, + MediaQuery.widthOf(context) - mediaQueryPadding.right, + ), + widget.selectionMidpoint.dy - widget.globalEditableRegion.top, + ); + + final items = []; + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + final Widget onePhysicalPixelVerticalDivider = SizedBox( + width: 1.0 / MediaQuery.devicePixelRatioOf(context), + ); + + void addToolbarButton(String text, VoidCallback onPressed) { + if (items.isNotEmpty) { + items.add(onePhysicalPixelVerticalDivider); + } + + items.add(CupertinoDesktopTextSelectionToolbarButton.text(onPressed: onPressed, text: text)); + } + + if (widget.handleCut != null) { + addToolbarButton(localizations.cutButtonLabel, widget.handleCut!); + } + if (widget.handleCopy != null) { + addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!); + } + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.pasteable) { + addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!); + } + if (widget.handleSelectAll != null) { + addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!); + } + + // If there is no option available, build an empty widget. + if (items.isEmpty) { + return const SizedBox.shrink(); + } + + return CupertinoDesktopTextSelectionToolbar( + anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor, + children: items, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/desktop_text_selection_toolbar.dart b/packages/cupertino_ui/lib/src/desktop_text_selection_toolbar.dart new file mode 100644 index 000000000000..b578290b268a --- /dev/null +++ b/packages/cupertino_ui/lib/src/desktop_text_selection_toolbar.dart @@ -0,0 +1,151 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'adaptive_text_selection_toolbar.dart'; +/// @docImport 'desktop_text_selection_toolbar_button.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; + +// The minimum padding from all edges of the selection toolbar to all edges of +// the screen. +const double _kToolbarScreenPadding = 8.0; + +// These values were measured from a screenshot of the native context menu on +// macOS 13.2 on a Macbook Pro. +const double _kToolbarSaturationBoost = 3; +const double _kToolbarBlurSigma = 20; +const double _kToolbarWidth = 222.0; +const Radius _kToolbarBorderRadius = Radius.circular(8.0); +const EdgeInsets _kToolbarPadding = EdgeInsets.all(6.0); +const List _kToolbarShadow = [ + BoxShadow( + color: Color.fromARGB(60, 0, 0, 0), + blurRadius: 10.0, + spreadRadius: 0.5, + offset: Offset(0.0, 4.0), + ), +]; + +// These values were measured from a screenshot of the native context menu on +// macOS 13.2 on a Macbook Pro. +const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFB8B8B8), + darkColor: Color(0xFF5B5B5B), +); +const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xB2FFFFFF), + darkColor: Color(0xB2303030), +); + +/// A macOS-style text selection toolbar. +/// +/// Typically displays buttons for text manipulation, e.g. copying and pasting +/// text. +/// +/// Tries to position itself as closely as possible to [anchor] while remaining +/// fully inside the viewport. +/// +/// See also: +/// +/// * [CupertinoAdaptiveTextSelectionToolbar], where this is used to build the +/// toolbar for desktop platforms. +/// * [AdaptiveTextSelectionToolbar], where this is used to build the toolbar on +/// macOS. +/// * [DesktopTextSelectionToolbar], which is similar but builds a +/// Material-style desktop toolbar. +class CupertinoDesktopTextSelectionToolbar extends StatelessWidget { + /// Creates a const instance of CupertinoTextSelectionToolbar. + const CupertinoDesktopTextSelectionToolbar({ + super.key, + required this.anchor, + required this.children, + }) : assert(children.length > 0); + + /// Creates a 5x5 matrix that increases saturation when used with [ColorFilter.matrix]. + /// + /// The numbers were taken from this comment: + /// [Cupertino blurs should boost saturation](https://github.com/flutter/flutter/issues/29483#issuecomment-477334981). + static List _matrixWithSaturation(double saturation) { + final double r = 0.213 * (1 - saturation); + final double g = 0.715 * (1 - saturation); + final double b = 0.072 * (1 - saturation); + + return [ + r + saturation, g, b, 0, 0, // + r, g + saturation, b, 0, 0, // + r, g, b + saturation, 0, 0, // + 0, 0, 0, 1, 0, // + ]; + } + + /// {@macro flutter.material.DesktopTextSelectionToolbar.anchor} + final Offset anchor; + + /// {@macro flutter.material.TextSelectionToolbar.children} + /// + /// See also: + /// * [CupertinoDesktopTextSelectionToolbarButton], which builds a default + /// macOS-style text selection toolbar text button. + final List children; + + // Builds a toolbar just like the default Mac toolbar, with the right color + // background, padding, and rounded corners. + static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { + return Container( + width: _kToolbarWidth, + clipBehavior: Clip.hardEdge, + decoration: const ShapeDecoration( + shadows: _kToolbarShadow, + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(_kToolbarBorderRadius)), + ), + child: BackdropFilter( + filter: ImageFilter.compose( + outer: ColorFilter.matrix(_matrixWithSaturation(_kToolbarSaturationBoost)), + inner: ImageFilter.blur(sigmaX: _kToolbarBlurSigma, sigmaY: _kToolbarBlurSigma), + ), + child: DecoratedBox( + decoration: ShapeDecoration( + color: _kToolbarBackgroundColor.resolveFrom(context), + shape: RoundedSuperellipseBorder( + side: BorderSide(color: _kToolbarBorderColor.resolveFrom(context)), + borderRadius: const BorderRadius.all(_kToolbarBorderRadius), + ), + ), + child: Padding(padding: _kToolbarPadding, child: child), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + final double paddingAbove = MediaQuery.paddingOf(context).top + _kToolbarScreenPadding; + final localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove); + + return Padding( + padding: EdgeInsets.fromLTRB( + _kToolbarScreenPadding, + paddingAbove, + _kToolbarScreenPadding, + _kToolbarScreenPadding, + ), + child: CustomSingleChildLayout( + delegate: DesktopTextSelectionToolbarLayoutDelegate(anchor: anchor - localAdjustment), + child: _defaultToolbarBuilder( + context, + Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/desktop_text_selection_toolbar_button.dart b/packages/cupertino_ui/lib/src/desktop_text_selection_toolbar_button.dart new file mode 100644 index 000000000000..caeedab390ce --- /dev/null +++ b/packages/cupertino_ui/lib/src/desktop_text_selection_toolbar_button.dart @@ -0,0 +1,123 @@ +// 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/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'colors.dart'; +import 'text_selection_toolbar_button.dart'; +import 'theme.dart'; + +// These values were measured from a screenshot of the native context menu on +// macOS 13.2 on a Macbook Pro. +const TextStyle _kToolbarButtonFontStyle = TextStyle( + inherit: false, + fontSize: 14.0, + letterSpacing: -0.15, + fontWeight: FontWeight.w400, +); + +// This value was measured from a screenshot of the native context menu on +// macOS 13.2 on a Macbook Pro. +const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 5.0); + +/// A button in the style of the Mac context menu buttons. +class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget { + /// Creates an instance of CupertinoDesktopTextSelectionToolbarButton. + const CupertinoDesktopTextSelectionToolbarButton({ + super.key, + required this.onPressed, + required Widget this.child, + }) : buttonItem = null, + text = null; + + /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is + /// a [Text] widget styled like the default Mac context menu button. + const CupertinoDesktopTextSelectionToolbarButton.text({ + super.key, + required this.onPressed, + required this.text, + }) : buttonItem = null, + child = null; + + /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from + /// the given [ContextMenuButtonItem]. + CupertinoDesktopTextSelectionToolbarButton.buttonItem({ + super.key, + required ContextMenuButtonItem this.buttonItem, + }) : onPressed = buttonItem.onPressed, + text = null, + child = null; + + /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed} + final VoidCallback? onPressed; + + /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child} + final Widget? child; + + /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed} + final ContextMenuButtonItem? buttonItem; + + /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.text} + final String? text; + + @override + State createState() => + _CupertinoDesktopTextSelectionToolbarButtonState(); +} + +class _CupertinoDesktopTextSelectionToolbarButtonState + extends State { + bool _isHovered = false; + + void _onEnter(PointerEnterEvent event) { + setState(() { + _isHovered = true; + }); + } + + void _onExit(PointerExitEvent event) { + setState(() { + _isHovered = false; + }); + } + + @override + Widget build(BuildContext context) { + final Widget child = + widget.child ?? + Text( + widget.text ?? + CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!), + overflow: TextOverflow.ellipsis, + style: _kToolbarButtonFontStyle.copyWith( + color: _isHovered + ? CupertinoTheme.of(context).primaryContrastingColor + : const CupertinoDynamicColor.withBrightness( + color: CupertinoColors.black, + darkColor: CupertinoColors.white, + ).resolveFrom(context), + ), + ); + + return SizedBox( + width: double.infinity, + child: MouseRegion( + onEnter: _onEnter, + onExit: _onExit, + child: CupertinoButton( + alignment: Alignment.centerLeft, + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + color: _isHovered ? CupertinoTheme.of(context).primaryColor : null, + minSize: 0.0, + onPressed: widget.onPressed, + padding: _kToolbarButtonPadding, + pressedOpacity: 0.7, + child: child, + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/dialog.dart b/packages/cupertino_ui/lib/src/dialog.dart new file mode 100644 index 000000000000..1f0335b0567f --- /dev/null +++ b/packages/cupertino_ui/lib/src/dialog.dart @@ -0,0 +1,2725 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'button.dart'; +/// @docImport 'route.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' show ImageFilter, SemanticsRole, lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'cupertino_focus_halo.dart'; +import 'interface_level.dart'; +import 'localizations.dart'; +import 'scrollbar.dart'; +import 'theme.dart'; + +// TODO(abarth): These constants probably belong somewhere more general. + +// Used XD to flutter plugin(https://github.com/AdobeXD/xd-to-flutter-plugin/) +// to derive values of TextStyle(height and letterSpacing) from +// Adobe XD template for iOS 13, which can be found in +// Apple Design Resources(https://developer.apple.com/design/resources/). +// However the values are not exactly the same as native, so eyeballing is needed. +const TextStyle _kCupertinoDialogTitleStyle = TextStyle( + fontFamily: 'CupertinoSystemText', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w600, + height: 1.3, + letterSpacing: -0.5, + textBaseline: TextBaseline.alphabetic, +); + +const TextStyle _kCupertinoDialogContentStyle = TextStyle( + fontFamily: 'CupertinoSystemText', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + height: 1.35, + letterSpacing: -0.2, + textBaseline: TextBaseline.alphabetic, +); + +const TextStyle _kCupertinoDialogActionStyle = TextStyle( + fontFamily: 'CupertinoSystemText', + inherit: false, + fontSize: 16.8, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, +); + +// CupertinoActionSheet-specific text styles. +const TextStyle _kActionSheetActionStyle = TextStyle( + // The fontSize and fontWeight may be adjusted when the text is rendered. + fontFamily: 'CupertinoSystemDisplay', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, +); + +const TextStyle _kActionSheetContentStyle = TextStyle( + fontFamily: 'CupertinoSystemText', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + // The `color` is configured by _kActionSheetContentTextColor to be dynamic on + // context. +); + +// Generic constants shared between Dialog and ActionSheet. +const double _kCornerRadius = 14.0; +const double _kDividerThickness = 0.3; + +// Dialog specific constants. +// iOS dialogs have a normal display width and another display width that is +// used when the device is in accessibility mode. Each of these widths are +// listed below. +const double _kCupertinoDialogWidth = 270.0; +const double _kAccessibilityCupertinoDialogWidth = 310.0; +const double _kDialogEdgePadding = 20.0; +const double _kDialogMinButtonHeight = 45.0; +const double _kDialogMinButtonFontSize = 10.0; +// The min height for a button excluding dividers. Derived by comparing on iOS +// 17 simulators. +const double _kDialogActionsSectionMinHeight = 67.8; + +// ActionSheet specific constants. +const double _kActionSheetEdgePadding = 8.0; +const double _kActionSheetCancelButtonPadding = 8.0; +const double _kActionSheetContentHorizontalPadding = 16.0; +const double _kActionSheetContentVerticalPadding = 13.5; +const double _kActionSheetActionsSectionMinHeight = 84.0; +const double _kActionSheetButtonHorizontalPadding = 10.0; + +// According to experimenting on the simulator, the height of action sheet +// buttons is proportional to the font size down to a minimal height. +const double _kActionSheetButtonMinHeight = 57.17; +const double _kActionSheetButtonVerticalPaddingFactor = 0.4; +const double _kActionSheetButtonVerticalPaddingBase = 1.8; + +// A translucent color that is painted on top of the blurred backdrop as the +// dialog's background color +// Extracted from https://developer.apple.com/design/resources/. +const Color _kDialogColor = CupertinoDynamicColor.withBrightness( + color: Color(0xCCF2F2F2), + darkColor: Color(0xCC2D2D2D), +); + +// Translucent light gray that is painted on top of the blurred backdrop as the +// background color of a pressed button. +// Eyeballed from iOS 13 beta simulator. +const Color _kDialogPressedColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFE1E1E1), + darkColor: Color(0xFF404040), +); + +// Translucent light gray that is painted on top of the blurred backdrop as the +// background color of a pressed button. +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetPressedColor = CupertinoDynamicColor.withBrightness( + color: Color(0xCAE0E0E0), + darkColor: Color(0xC1515151), +); + +const Color _kActionSheetCancelColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFFFFFFF), + darkColor: Color(0xFF2C2C2C), +); +const Color _kActionSheetCancelPressedColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFECECEC), + darkColor: Color(0xFF494949), +); + +// Translucent, very light gray that is painted on top of the blurred backdrop +// as the action sheet's background color. +// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/39272. Use +// System Materials once we have them. +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xC8FCFCFC), + darkColor: Color(0xBE292929), +); + +// The gray color used for text that appears in the title area. +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetContentTextColor = CupertinoDynamicColor.withBrightness( + color: Color(0x851D1D1D), + darkColor: Color(0x96F1F1F1), +); + +// Translucent gray that is painted on top of the blurred backdrop in the gap +// areas between the content section and actions section, as well as between +// buttons. +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetButtonDividerColor = CupertinoDynamicColor.withBrightness( + color: Color(0xD4C9C9C9), + darkColor: Color(0xD57D7D7D), +); + +// The alert dialog layout policy changes depending on whether the user is using +// a "regular" font size vs a "large" font size. This is a spectrum. There are +// many "regular" font sizes and many "large" font sizes. But depending on which +// policy is currently being used, a dialog is laid out differently. +// +// Empirically, the jump from one policy to the other occurs at the following text +// scale factors: +// Largest regular scale factor: 1.3529411764705883 +// Smallest large scale factor: 1.6470588235294117 +// +// The following constant represents a division in text scale factor beyond which +// we want to change how the dialog is laid out. +const double _kMaxRegularTextScaleFactor = 1.4; + +// Accessibility mode on iOS is determined by the text scale factor that the +// user has selected. +bool _isInAccessibilityMode(BuildContext context) { + const defaultFontSize = 14.0; + final double? scaledFontSize = MediaQuery.maybeTextScalerOf(context)?.scale(defaultFontSize); + return scaledFontSize != null && scaledFontSize > defaultFontSize * _kMaxRegularTextScaleFactor; +} + +/// An iOS-style alert dialog. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=75CsnyRXf5I} +/// +/// An alert dialog informs the user about situations that require +/// acknowledgment. An alert dialog has an optional title, optional content, +/// and an optional list of actions. The title is displayed above the content +/// and the actions are displayed below the content. +/// +/// This dialog styles its title and content (typically a message) to match the +/// standard iOS title and message dialog text style. These default styles can +/// be overridden by explicitly defining [TextStyle]s for [Text] widgets that +/// are part of the title or content. +/// +/// To display action buttons that look like standard iOS dialog buttons, +/// provide [CupertinoDialogAction]s for the [actions] given to this dialog. +/// +/// Typically passed as the child widget to [showDialog], which displays the +/// dialog. +/// +/// {@tool dartpad} +/// This sample shows how to use a [CupertinoAlertDialog]. +/// The [CupertinoAlertDialog] shows an alert with a set of two choices +/// when [CupertinoButton] is pressed. +/// +/// ** See code in examples/api/lib/cupertino/dialog/cupertino_alert_dialog.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoPopupSurface], which is a generic iOS-style popup surface that +/// holds arbitrary content to create custom popups. +/// * [CupertinoDialogAction], which is an iOS-style dialog button. +/// * [AlertDialog], a Material Design alert dialog. +/// * +class CupertinoAlertDialog extends StatefulWidget { + /// Creates an iOS-style alert dialog. + const CupertinoAlertDialog({ + super.key, + this.title, + this.content, + this.actions = const [], + this.scrollController, + this.actionScrollController, + this.insetAnimationDuration = const Duration(milliseconds: 100), + this.insetAnimationCurve = Curves.decelerate, + }); + + /// The (optional) title of the dialog is displayed in a large font at the top + /// of the dialog. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// The (optional) content of the dialog is displayed in the center of the + /// dialog in a lighter font. + /// + /// Typically a [Text] widget. + final Widget? content; + + /// The (optional) set of actions that are displayed at the bottom of the + /// dialog. + /// + /// Typically this is a list of [CupertinoDialogAction] widgets. + final List actions; + + /// A scroll controller that can be used to control the scrolling of the + /// [content] in the dialog. + /// + /// Defaults to null, which means the [CupertinoDialogAction] will create a + /// scroll controller internally. + /// + /// See also: + /// + /// * [actionScrollController], which can be used for controlling the actions + /// section when there are many actions. + final ScrollController? scrollController; + + /// A scroll controller that can be used to control the scrolling of the + /// actions in the dialog. + /// + /// Defaults to null, which means the [CupertinoDialogAction] will create an + /// action scroll controller internally. + /// + /// See also: + /// + /// * [scrollController], which can be used for controlling the [content] + /// section when it is long. + final ScrollController? actionScrollController; + + /// {@macro flutter.material.dialog.insetAnimationDuration} + final Duration insetAnimationDuration; + + /// {@macro flutter.material.dialog.insetAnimationCurve} + final Curve insetAnimationCurve; + + @override + State createState() => _CupertinoAlertDialogState(); +} + +class _CupertinoAlertDialogState extends State { + // The index of the action button that the user is holding on. + // + // Null if the user is not holding on any buttons. + int? _pressedIndex; + + ScrollController? _backupScrollController; + + ScrollController? _backupActionScrollController; + + ScrollController get _effectiveScrollController => + widget.scrollController ?? (_backupScrollController ??= ScrollController()); + + ScrollController get _effectiveActionScrollController => + widget.actionScrollController ?? (_backupActionScrollController ??= ScrollController()); + + Widget? _buildContent(BuildContext context) { + final bool hasContent = widget.title != null || widget.content != null; + if (!hasContent) { + return null; + } + + const defaultFontSize = 14.0; + final double effectiveTextScaleFactor = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / defaultFontSize; + + final Widget child = _CupertinoAlertContentSection( + title: widget.title, + message: widget.content, + scrollController: _effectiveScrollController, + titlePadding: EdgeInsets.only( + left: _kDialogEdgePadding, + right: _kDialogEdgePadding, + bottom: widget.content == null ? _kDialogEdgePadding : 1.0, + top: _kDialogEdgePadding * effectiveTextScaleFactor, + ), + messagePadding: EdgeInsets.only( + left: _kDialogEdgePadding, + right: _kDialogEdgePadding, + bottom: _kDialogEdgePadding * effectiveTextScaleFactor, + top: widget.title == null ? _kDialogEdgePadding : 1.0, + ), + titleTextStyle: _kCupertinoDialogTitleStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + ), + messageTextStyle: _kCupertinoDialogContentStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + ), + ); + + return ColoredBox(color: CupertinoDynamicColor.resolve(_kDialogColor, context), child: child); + } + + void _onPressedUpdate(int actionIndex, bool isPressed) { + if (isPressed) { + setState(() { + _pressedIndex = actionIndex; + }); + } else { + if (_pressedIndex == actionIndex) { + setState(() { + _pressedIndex = null; + }); + } + } + } + + Widget? _buildActions() { + if (widget.actions.isEmpty) { + return null; + } else { + return _CupertinoAlertActionSection( + scrollController: _effectiveActionScrollController, + actions: widget.actions, + pressedIndex: _pressedIndex, + onPressedUpdate: _onPressedUpdate, + ); + } + } + + Widget _buildBody(BuildContext context) { + final Color backgroundColor = CupertinoDynamicColor.resolve(_kDialogColor, context); + final Color dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context); + // Remove view padding here because the `Scrollbar` widget uses the view + // padding as padding, which is unwanted. + // https://github.com/flutter/flutter/issues/150544 + return MediaQuery.removePadding( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Widget? contentSection = _buildContent(context); + final Widget? actionsSection = _buildActions(); + if (actionsSection == null) { + return contentSection ?? + const LimitedBox(maxWidth: 0, child: SizedBox(width: double.infinity, height: 0)); + } + final Widget scrolledActionsSection = _OverscrollBackground( + color: backgroundColor, + child: actionsSection, + ); + if (contentSection == null) { + return scrolledActionsSection; + } + // It is observed on the simulator that the minimal height varies + // depending on whether the device is in accessibility mode. + final double actionsMinHeight = _isInAccessibilityMode(context) + ? constraints.maxHeight / 2 + _kDividerThickness + : _kDialogActionsSectionMinHeight + _kDividerThickness; + return _PriorityColumn( + top: contentSection, + bottom: Column( + children: [ + SizedBox( + width: double.infinity, + child: _Divider( + dividerColor: dividerColor, + hiddenColor: backgroundColor, + hidden: false, + ), + ), + Flexible(child: scrolledActionsSection), + ], + ), + bottomMinHeight: actionsMinHeight, + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + final bool isInAccessibilityMode = _isInAccessibilityMode(context); + return CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: MediaQuery.withClampedTextScaling( + // iOS does not shrink dialog content below a 1.0 scale factor + minScaleFactor: 1.0, + child: ScrollConfiguration( + // A CupertinoScrollbar is built-in below. + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return AnimatedPadding( + padding: + MediaQuery.viewInsetsOf(context) + + const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0), + duration: widget.insetAnimationDuration, + curve: widget.insetAnimationCurve, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: _kDialogEdgePadding), + child: SizedBox( + width: isInAccessibilityMode + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth, + child: _ActionSheetGestureDetector( + child: CupertinoPopupSurface( + isSurfacePainted: false, + child: Semantics( + role: SemanticsRole.alertDialog, + namesRoute: true, + scopesRoute: true, + explicitChildNodes: true, + label: localizations.alertDialogLabel, + child: _buildBody(context), + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + @override + void dispose() { + _backupScrollController?.dispose(); + _backupActionScrollController?.dispose(); + super.dispose(); + } +} + +/// An iOS-style component for creating modal overlays like dialogs and action +/// sheets. +/// +/// By default, [CupertinoPopupSurface] generates a rounded rectangle surface +/// that applies two effects to the background content: +/// +/// 1. Background filter: Saturates and then blurs content behind the surface. +/// 2. Overlay color: Covers the filtered background with a transparent +/// surface color. The color adapts to the CupertinoTheme's brightness: +/// light gray when the ambient [CupertinoTheme] brightness is +/// [Brightness.light], and dark gray when [Brightness.dark]. +/// +/// The blur strength can be changed by setting [blurSigma] to a positive value, +/// or removed by setting the [blurSigma] to 0. +/// +/// The saturation effect can be removed for debugging by setting +/// [debugIsVibrancePainted] to false. The saturation effect is not supported on +/// web with the skwasm renderer and will not be applied regardless of the value +/// of [debugIsVibrancePainted]. +/// +/// The surface color can be disabled by setting [isSurfacePainted] to false, +/// which is useful for more complicated layouts, such as rendering divider gaps +/// in [CupertinoAlertDialog] or rendering custom surface colors. +/// +/// {@tool dartpad} +/// This sample shows how to use a [CupertinoPopupSurface]. The [CupertinoPopupSurface] +/// shows a modal popup from the bottom of the screen. +/// Toggle the switch to configure its surface color. +/// +/// ** See code in examples/api/lib/cupertino/dialog/cupertino_popup_surface.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoAlertDialog], which is a dialog with a title, content, and +/// actions. +/// * +class CupertinoPopupSurface extends StatelessWidget { + /// Creates an iOS-style rounded rectangle popup surface. + const CupertinoPopupSurface({ + super.key, + this.blurSigma = defaultBlurSigma, + this.isSurfacePainted = true, + required this.child, + }) : assert(blurSigma >= 0, 'CupertinoPopupSurface requires a non-negative blur sigma.'); + + /// The strength of the gaussian blur applied to the area beneath this + /// surface. + /// + /// Defaults to [defaultBlurSigma]. Setting [blurSigma] to 0 will remove the + /// blur filter. + final double blurSigma; + + /// Whether or not to paint a translucent white on top of this surface's + /// blurred background. [isSurfacePainted] should be true for a typical popup + /// that contains content without any dividers. A popup that requires dividers + /// should set [isSurfacePainted] to false and then paint its own surface area. + /// + /// Some popups, like iOS's volume control popup, choose to render a blurred + /// area without any white paint covering it. To achieve this effect, + /// [isSurfacePainted] should be set to false. + /// + /// Defaults to true. + final bool isSurfacePainted; + + /// The widget below this widget in the tree. + // Because [CupertinoPopupSurface] is composed of proxy boxes, which mimic + // the size of their child, a [child] is required to ensure that this surface + // has a size. + final Widget child; + + /// The default strength of the blur applied to widgets underlying a + /// [CupertinoPopupSurface]. + /// + /// Eyeballed from the iOS 17 simulator. + static const double defaultBlurSigma = 30.0; + + /// The default corner radius of a [CupertinoPopupSurface]. + static const BorderRadius _clipper = BorderRadius.all(Radius.circular(13)); + + // The [ColorFilter] matrix used to saturate widgets underlying a + // [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is + // [Brightness.light]. + // + // To derive this matrix, the saturation matrix was taken from + // https://docs.rainmeter.net/tips/colormatrix-guide/ and was tweaked to + // resemble the iOS 17 simulator. + // + // The matrix can be derived from the following function: + // static List get _lightSaturationMatrix { + // const double lightLumR = 0.26; + // const double lightLumG = 0.4; + // const double lightLumB = 0.17; + // const double saturation = 2.0; + // const double sr = (1 - saturation) * lightLumR; + // const double sg = (1 - saturation) * lightLumG; + // const double sb = (1 - saturation) * lightLumB; + // return [ + // sr + saturation, sg, sb, 0.0, 0.0, + // sr, sg + saturation, sb, 0.0, 0.0, + // sr, sg, sb + saturation, 0.0, 0.0, + // 0.0, 0.0, 0.0, 1.0, 0.0, + // ]; + // } + static const List _lightSaturationMatrix = [ + 1.74, + -0.40, + -0.17, + 0.00, + 0.00, + -0.26, + 1.60, + -0.17, + 0.00, + 0.00, + -0.26, + -0.40, + 1.83, + 0.00, + 0.00, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + ]; + + // The [ColorFilter] matrix used to saturate widgets underlying a + // [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is + // [Brightness.dark]. + // + // To derive this matrix, the saturation matrix was taken from + // https://docs.rainmeter.net/tips/colormatrix-guide/ and was tweaked to + // resemble the iOS 17 simulator. + // + // The matrix can be derived from the following function: + // static List get _darkSaturationMatrix { + // const double additive = 0.3; + // const double darkLumR = 0.45; + // const double darkLumG = 0.8; + // const double darkLumB = 0.16; + // const double saturation = 1.7; + // const double sr = (1 - saturation) * darkLumR; + // const double sg = (1 - saturation) * darkLumG; + // const double sb = (1 - saturation) * darkLumB; + // return [ + // sr + saturation, sg, sb, 0.0, additive, + // sr, sg + saturation, sb, 0.0, additive, + // sr, sg, sb + saturation, 0.0, additive, + // 0.0, 0.0, 0.0, 1.0, 0.0, + // ]; + // } + static const List _darkSaturationMatrix = [ + 1.39, + -0.56, + -0.11, + 0.00, + 0.30, + -0.32, + 1.14, + -0.11, + 0.00, + 0.30, + -0.32, + -0.56, + 1.59, + 0.00, + 0.30, + 0.00, + 0.00, + 0.00, + 1.00, + 0.00, + ]; + + /// Whether or not the area beneath this surface should be saturated with a + /// [ColorFilter]. + /// + /// The appearance of the [ColorFilter] is determined by the [Brightness] + /// value obtained from the ambient [CupertinoTheme]. + /// + /// The vibrance is always painted if asserts are disabled. + /// + /// Defaults to true. + static bool debugIsVibrancePainted = true; + + // TODO(davidhicks980): Set `bounded` to true on ImageFilterConfig.blur after + // https://github.com/flutter/flutter/issues/182066 is resolved. + ImageFilterConfig? _buildFilter(Brightness? brightness) { + var isVibrancePainted = true; + assert(() { + isVibrancePainted = debugIsVibrancePainted; + return true; + }()); + if (!isVibrancePainted) { + if (blurSigma == 0) { + return null; + } + return ImageFilterConfig.blur(sigmaX: blurSigma, sigmaY: blurSigma); + } + + final colorFilter = ImageFilterConfig(switch (brightness) { + Brightness.dark => const ColorFilter.matrix(_darkSaturationMatrix), + Brightness.light || null => const ColorFilter.matrix(_lightSaturationMatrix), + }); + + if (blurSigma == 0) { + return colorFilter; + } + + return ImageFilterConfig.compose( + inner: colorFilter, + outer: ImageFilterConfig.blur(sigmaX: blurSigma, sigmaY: blurSigma), + ); + } + + @override + Widget build(BuildContext context) { + final ImageFilterConfig? filter = _buildFilter(CupertinoTheme.maybeBrightnessOf(context)); + Widget contents = child; + + if (isSurfacePainted) { + contents = ColoredBox( + color: CupertinoDynamicColor.resolve(_kDialogColor, context), + child: contents, + ); + } + + if (filter != null) { + return ClipRSuperellipse( + borderRadius: _clipper, + child: BackdropFilter(filterConfig: filter, child: contents), + ); + } + + return ClipRSuperellipse(borderRadius: _clipper, child: contents); + } +} + +typedef _HitTester = HitTestResult Function(Offset location); + +// Recognizes taps with possible sliding during the tap. +// +// This recognizer only tracks one pointer at a time (called the primary +// pointer), and other pointers added while the primary pointer is alive are +// ignored and can not be used by other gestures either. After the primary +// pointer ends, the pointer added next becomes the new primary pointer (which +// starts a new gesture sequence). +// +// This recognizer only allows [kPrimaryMouseButton]. +class _SlidingTapGestureRecognizer extends VerticalDragGestureRecognizer { + _SlidingTapGestureRecognizer({super.debugOwner}) { + dragStartBehavior = DragStartBehavior.down; + } + + /// Called whenever the primary pointer moves regardless of whether drag has + /// started. + /// + /// The parameter is the global position of the primary pointer. + /// + /// This is similar to `onUpdate`, but allows the caller to track the primary + /// pointer's location before the drag starts, which is useful to enhance + /// responsiveness. + ValueSetter? onResponsiveUpdate; + + /// Called whenever the primary pointer is lifted regardless of whether drag + /// has started. + /// + /// The parameter is the global position of the primary pointer. + /// + /// This is similar to `onEnd`, but allows know the primary pointer's final + /// location even if the drag never started, which is useful to enhance + /// responsiveness. + ValueSetter? onResponsiveEnd; + + int? _primaryPointer; + + @override + void addAllowedPointer(PointerDownEvent event) { + _primaryPointer ??= event.pointer; + super.addAllowedPointer(event); + } + + @override + void rejectGesture(int pointer) { + if (pointer == _primaryPointer) { + _primaryPointer = null; + } + super.rejectGesture(pointer); + } + + @override + void handleEvent(PointerEvent event) { + if (event.pointer == _primaryPointer) { + if (event is PointerMoveEvent) { + onResponsiveUpdate?.call(event.position); + } + // Sliding tap needs to handle 'up' events differently compared to typical + // drag gestures. If there's another gesture recognizer (like scrolling) + // competing and the pointer hasn't moved beyond the tolerance limit + // (slop), this gesture must still be accepted. + // + // Simply calling `accept()` here to handle this won't work because it + // would break backward compatibility with legacy buttons (see + // https://github.com/flutter/flutter/issues/150980 for more details). + // Legacy buttons recognize taps using `GestureDetector.onTap`, which + // neither accepts nor rejects for short taps. Instead, they wait for the + // default resolution as the last contender in the gesture arena. + // + // Therefore, this gesture should also follow the same strategy of not + // immediately accepting or rejecting. This allows tap gestures to take + // precedence for being inner, while sliding taps can take precedence over + // scroll gestures when the latter give up. + if (event is PointerUpEvent) { + stopTrackingPointer(_primaryPointer!); + onResponsiveEnd?.call(event.position); + _primaryPointer = null; + // Do not call `super.handleEvent`, which gives up the pointer and thus + // rejects the gesture. + return; + } + if (event is PointerCancelEvent) { + _primaryPointer = null; + } + } + super.handleEvent(event); + } + + @override + String get debugDescription => 'tap slide'; +} + +// A region (typically a button) that can receive entering, exiting, and +// updating events of a "sliding tap" gesture. +// +// Some Cupertino widgets, such as action sheets or dialogs, allow the user to +// select buttons using "sliding taps", where the user can drag around after +// pressing on the screen, and whichever button the drag ends in is selected. +// +// This class is used to define the regions that sliding taps recognize. This +// class must be provided to a `MetaData` widget as `data`, and is typically +// implemented by a widget state class. When an eligible dragging gesture +// enters, leaves, or ends this `MetaData` widget, corresponding methods of this +// class will be called. +// +// Multiple `_SlideTarget`s might be nested. +// `_TargetSelectionGestureRecognizer` uses a simple algorithm that only +// compares if the inner-most slide target has changed (which suffices our use +// case). Semantically, this means that all outer targets will be treated as +// having the identical area as the inner-most one, i.e. when the pointer enters +// or leaves a slide target, the corresponding method will be called on all +// targets that nest it. +abstract class _SlideTarget { + // A pointer has entered this region. + // + // This includes: + // + // * The pointer has moved into this region from outside. + // * The point has contacted the screen in this region. In this case, this + // method is called as soon as the pointer down event occurs regardless of + // whether the gesture wins the arena immediately. + // + // The `fromPointerDown` should be true if this callback is triggered by a + // PointerDownEvent, i.e. the second case from the list above. + // + // The return value of this method is used as the `innerEnabled` for the next + // target, while `innerEnabled` of the innermost target is true. + bool didEnter({required bool fromPointerDown, required bool innerEnabled}); + + // A pointer has exited this region. + // + // This includes: + // * The pointer has moved out of this region. + // * The pointer is no longer in contact with the screen. + // * The pointer is canceled. + // * The gesture loses the arena. + // * The gesture ends. In this case, this method is called immediately + // before [didConfirm]. + void didLeave(); + + // The drag gesture is completed in this region. + // + // This method is called immediately after a [didLeave]. + void didConfirm(); +} + +// Recognizes sliding taps and thereupon interacts with +// `_SlideTarget`s. +// +// TODO(dkwingsmt): It should recompute hit testing when the app is updated, +// or better, share code with `MouseTracker`. +// https://github.com/flutter/flutter/issues/155266 +class _TargetSelectionGestureRecognizer extends GestureRecognizer { + _TargetSelectionGestureRecognizer({super.debugOwner, required this.hitTest}) + : _slidingTap = _SlidingTapGestureRecognizer(debugOwner: debugOwner) { + _slidingTap + ..onDown = _onDown + ..onResponsiveUpdate = _onUpdate + ..onResponsiveEnd = _onEnd + ..onCancel = _onCancel; + } + + final _HitTester hitTest; + + final List<_SlideTarget> _currentTargets = <_SlideTarget>[]; + final _SlidingTapGestureRecognizer _slidingTap; + + @override + void acceptGesture(int pointer) { + _slidingTap.acceptGesture(pointer); + } + + @override + void rejectGesture(int pointer) { + _slidingTap.rejectGesture(pointer); + } + + @override + void addPointer(PointerDownEvent event) { + _slidingTap.addPointer(event); + } + + @override + void addPointerPanZoom(PointerPanZoomStartEvent event) { + _slidingTap.addPointerPanZoom(event); + } + + @override + void dispose() { + _slidingTap.dispose(); + super.dispose(); + } + + // Collect the `_SlideTarget`s that are currently hit by the + // pointer, check whether the current target have changed, and invoke their + // methods if necessary. + // + // The `fromPointerDown` should be true if this update is triggered by a + // PointerDownEvent. + void _updateDrag(Offset pointerPosition, {required bool fromPointerDown}) { + final HitTestResult result = hitTest(pointerPosition); + + // A slide target might nest other targets, therefore multiple targets might + // be found. + final foundTargets = <_SlideTarget>[]; + for (final HitTestEntry entry in result.path) { + if (entry.target case final RenderMetaData target) { + if (target.metaData is _SlideTarget) { + foundTargets.add(target.metaData as _SlideTarget); + } + } + } + + // Compare whether the active target has changed by simply comparing the + // first (inner-most) avatar of the nest, ignoring the cases where + // _currentTargets intersect with foundTargets (see _SlideTarget's + // document for more explanation). + if (_currentTargets.firstOrNull != foundTargets.firstOrNull) { + for (final _SlideTarget target in _currentTargets) { + target.didLeave(); + } + _currentTargets + ..clear() + ..addAll(foundTargets); + var enabled = true; + for (final _SlideTarget target in _currentTargets) { + enabled = target.didEnter(fromPointerDown: fromPointerDown, innerEnabled: enabled); + } + } + } + + void _onDown(DragDownDetails details) { + _updateDrag(details.globalPosition, fromPointerDown: true); + } + + void _onUpdate(Offset globalPosition) { + _updateDrag(globalPosition, fromPointerDown: false); + } + + void _onEnd(Offset globalPosition) { + _updateDrag(globalPosition, fromPointerDown: false); + for (final _SlideTarget target in _currentTargets) { + target.didConfirm(); + } + _currentTargets.clear(); + } + + void _onCancel() { + for (final _SlideTarget target in _currentTargets) { + target.didLeave(); + } + _currentTargets.clear(); + } + + @override + String get debugDescription => 'target selection'; +} + +// The gesture detector used by action sheets. +// +// This gesture detector only recognizes one gesture, +// `_TargetSelectionGestureRecognizer`. +// +// This widget's child might contain another VerticalDragGestureRecognizer if +// the actions section or the content section scrolls. Conveniently, Flutter's +// gesture algorithm makes the inner gesture take priority. +class _ActionSheetGestureDetector extends StatelessWidget { + const _ActionSheetGestureDetector({this.child}); + + final Widget? child; + + HitTestResult _hitTest(BuildContext context, Offset globalPosition) { + final int viewId = View.of(context).viewId; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, globalPosition, viewId); + return result; + } + + @override + Widget build(BuildContext context) { + final gestures = {}; + gestures[_TargetSelectionGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_TargetSelectionGestureRecognizer>( + () => _TargetSelectionGestureRecognizer( + debugOwner: this, + hitTest: (Offset globalPosition) => _hitTest(context, globalPosition), + ), + (_TargetSelectionGestureRecognizer instance) {}, + ); + + return RawGestureDetector(excludeFromSemantics: true, gestures: gestures, child: child); + } +} + +/// An iOS-style action sheet. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=U-ao8p4A82k} +/// +/// An action sheet is a specific style of alert that presents the user +/// with a set of two or more choices related to the current context. +/// An action sheet can have a title, an additional message, and a list +/// of actions. The title is displayed above the message and the actions +/// are displayed below this content. +/// +/// This action sheet styles its title and message to match standard iOS action +/// sheet title and message text style. +/// +/// To display action buttons that look like standard iOS action sheet buttons, +/// provide [CupertinoActionSheetAction]s for the [actions] given to this action +/// sheet. +/// +/// To include a iOS-style cancel button separate from the other buttons, +/// provide an [CupertinoActionSheetAction] for the [cancelButton] given to this +/// action sheet. +/// +/// An action sheet is typically passed as the child widget to +/// [showCupertinoModalPopup], which displays the action sheet by sliding it up +/// from the bottom of the screen. +/// +/// {@tool dartpad} +/// This sample shows how to use a [CupertinoActionSheet]. +/// The [CupertinoActionSheet] shows a modal popup that slides in from the +/// bottom when [CupertinoButton] is pressed. +/// +/// ** See code in examples/api/lib/cupertino/dialog/cupertino_action_sheet.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoActionSheetAction], which is an iOS-style action sheet button. +/// * +class CupertinoActionSheet extends StatefulWidget { + /// Creates an iOS-style action sheet. + /// + /// An action sheet must have a non-null value for at least one of the + /// following arguments: [actions], [title], [message], or [cancelButton]. + /// + /// Generally, action sheets are used to give the user a choice between + /// two or more choices for the current context. + const CupertinoActionSheet({ + super.key, + this.title, + this.message, + this.actions, + this.messageScrollController, + this.actionScrollController, + this.cancelButton, + }) : assert( + actions != null || title != null || message != null || cancelButton != null, + 'An action sheet must have a non-null value for at least one of the following arguments: ' + 'actions, title, message, or cancelButton', + ); + + /// An optional title of the action sheet. When the [message] is non-null, + /// the font of the [title] is bold. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// An optional descriptive message that provides more details about the + /// reason for the alert. + /// + /// Typically a [Text] widget. + final Widget? message; + + /// The set of actions that are displayed for the user to select. + /// + /// This must be a list of [CupertinoActionSheetAction] widgets. + final List? actions; + + /// A scroll controller that can be used to control the scrolling of the + /// [message] in the action sheet. + /// + /// Defaults to null, which means the [CupertinoActionSheet] will create a + /// scroll controller internally. + final ScrollController? messageScrollController; + + /// A scroll controller that can be used to control the scrolling of the + /// [actions] in the action sheet. + /// + /// Defaults to null, which means the [CupertinoActionSheet] will create an + /// action scroll controller internally. + final ScrollController? actionScrollController; + + /// The optional cancel button that is grouped separately from the other + /// actions. + /// + /// This must be a [CupertinoActionSheetAction] widget. + final Widget? cancelButton; + + @override + State createState() => _CupertinoActionSheetState(); +} + +class _CupertinoActionSheetState extends State { + int? _pressedIndex; + static const int _kCancelButtonIndex = -1; + + ScrollController? _backupMessageScrollController; + + ScrollController? _backupActionScrollController; + + ScrollController get _effectiveMessageScrollController => + widget.messageScrollController ?? (_backupMessageScrollController ??= ScrollController()); + + ScrollController get _effectiveActionScrollController => + widget.actionScrollController ?? (_backupActionScrollController ??= ScrollController()); + + @override + void dispose() { + _backupMessageScrollController?.dispose(); + _backupActionScrollController?.dispose(); + super.dispose(); + } + + bool get hasContent => widget.title != null || widget.message != null; + + Widget? _buildContent(BuildContext context) { + if (!hasContent) { + return null; + } + final TextStyle textStyle = _kActionSheetContentStyle.copyWith( + color: CupertinoDynamicColor.resolve(_kActionSheetContentTextColor, context), + ); + return ColoredBox( + color: CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context), + child: _CupertinoAlertContentSection( + title: widget.title, + message: widget.message, + scrollController: _effectiveMessageScrollController, + titlePadding: EdgeInsets.only( + left: _kActionSheetContentHorizontalPadding, + right: _kActionSheetContentHorizontalPadding, + bottom: widget.message == null ? _kActionSheetContentVerticalPadding : 0.0, + top: _kActionSheetContentVerticalPadding, + ), + messagePadding: EdgeInsets.only( + left: _kActionSheetContentHorizontalPadding, + right: _kActionSheetContentHorizontalPadding, + bottom: _kActionSheetContentVerticalPadding, + top: widget.title == null ? _kActionSheetContentVerticalPadding : 0.0, + ), + titleTextStyle: widget.message == null + ? textStyle + : textStyle.copyWith(fontWeight: FontWeight.w600), + messageTextStyle: widget.title == null + ? textStyle.copyWith(fontWeight: FontWeight.w600) + : textStyle, + additionalPaddingBetweenTitleAndMessage: const EdgeInsets.only(top: 4.0), + ), + ); + } + + void _onPressedUpdate(int actionIndex, bool state) { + if (!state) { + if (_pressedIndex == actionIndex) { + setState(() { + _pressedIndex = null; + }); + } + } else { + setState(() { + _pressedIndex = actionIndex; + }); + } + } + + Widget _buildCancelButton() { + assert(widget.cancelButton != null); + final double cancelPadding = + (widget.actions != null || widget.message != null || widget.title != null) + ? _kActionSheetCancelButtonPadding + : 0.0; + + return Padding( + padding: EdgeInsets.only(top: cancelPadding), + child: CupertinoFocusHalo.withRRect( + borderRadius: kCupertinoButtonSizeBorderRadius[CupertinoButtonSize.large]!, + child: _ActionSheetButtonBackground( + isCancel: true, + pressed: _pressedIndex == _kCancelButtonIndex, + onPressStateChange: (bool state) { + _onPressedUpdate(_kCancelButtonIndex, state); + }, + child: widget.cancelButton!, + ), + ), + ); + } + + // Given data point (x1, y1) and (x2, y2), derive the y corresponding to x + // using linear interpolation between the two data points, and extrapolates + // flatly beyond these points. + // + // (x2, y2) + // _____________ + // / + // / + // _________/ + // (x1, y1) + static double _lerp(double x, double x1, double y1, double x2, double y2) { + if (x <= x1) { + return y1; + } else if (x >= x2) { + return y2; + } else { + return lerpDouble(y1, y2, (x - x1) / (x2 - x1))!; + } + } + + // Derive the top padding, which is the distance between the top of a + // full-height action sheet and the top of the safe area. + // + // The algorithm and its values are derived from measuring on the simulator. + double _topPadding(BuildContext context) { + if (MediaQuery.orientationOf(context) == Orientation.landscape) { + return _kActionSheetEdgePadding; + } + + // The top padding in portrait mode is in general close to the top view + // padding, but not always equal: + // + // | view padding | action sheet padding | ratio + // No notch (eg. iPhone SE) | 20.0 | 20.0 | 1.0 + // Notch (eg. iPhone 13) | 47.0 | 47.0 | 1.0 + // Capsule (eg. iPhone 15) | 59.0 | 54.0 | 0.915 + // + // Currently, we cannot determine why the result changes on "capsules." + // Therefore, we'll hard code this rule, given the limited types of actual + // devices. To provide an algorithm that accepts arbitrary view padding, this + // function calculates the ratio as a continuous curve with linear + // interpolation. + + // The x for lerp is the top view padding, while the y is ratio of + // action sheet padding versus top view padding. + const viewPaddingData1 = 47.0; + const paddingRatioData1 = 1.0; + const viewPaddingData2 = 59.0; + const double paddingRatioData2 = 54.0 / 59.0; + + final double currentViewPadding = MediaQuery.viewPaddingOf(context).top; + + final double currentPaddingRatio = _lerp( + /* x= */ currentViewPadding, + /* x1, y1= */ viewPaddingData1, + paddingRatioData1, + /* x2, y2= */ viewPaddingData2, + paddingRatioData2, + ); + final double padding = (currentPaddingRatio * currentViewPadding).roundToDouble(); + // In case there is no view padding, there should still be some space + // between the action sheet and the edge. + return math.max(padding, _kDialogEdgePadding); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + /* + * ╭─────────────────╮ ↑ ↑ + * │ The title │ Content section | + * │ The message │ ↓ | + * ├─────────────────┤ ↑ Main sheet + * │ Action 1 │ | | + * ├─────────────────┤ Actions section | + * │ Action 2 │ | | + * ╰─────────────────╯ ↓ ↓ + * ╭─────────────────╮ + * │ Cancel │ + * ╰─────────────────╯ + */ + + final children = [ + Flexible( + child: ClipRSuperellipse( + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: CupertinoPopupSurface.defaultBlurSigma, + sigmaY: CupertinoPopupSurface.defaultBlurSigma, + ), + child: _ActionSheetMainSheet( + pressedIndex: _pressedIndex, + onPressedUpdate: _onPressedUpdate, + scrollController: _effectiveActionScrollController, + contentSection: _buildContent(context), + actions: widget.actions ?? List.empty(), + dividerColor: CupertinoDynamicColor.resolve(_kActionSheetButtonDividerColor, context), + ), + ), + ), + ), + if (widget.cancelButton != null) _buildCancelButton(), + ]; + final double actionSheetWidth = switch (MediaQuery.orientationOf(context)) { + Orientation.portrait => MediaQuery.widthOf(context), + Orientation.landscape => MediaQuery.heightOf(context), + }; + + return SafeArea( + minimum: const EdgeInsets.only(bottom: _kActionSheetEdgePadding), + child: ScrollConfiguration( + // A CupertinoScrollbar is built-in below + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Semantics( + namesRoute: true, + scopesRoute: true, + explicitChildNodes: true, + role: SemanticsRole.dialog, + label: 'Alert', + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: Padding( + padding: EdgeInsets.only( + left: _kActionSheetEdgePadding, + right: _kActionSheetEdgePadding, + top: _topPadding(context), + // The bottom padding is set on SafeArea.minimum, allowing it to + // be consumed by bottom view padding. + ), + child: SizedBox( + width: actionSheetWidth - _kActionSheetEdgePadding * 2, + child: _ActionSheetGestureDetector( + child: Semantics( + explicitChildNodes: true, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// The content of a typical action button in a [CupertinoActionSheet]. +/// +/// This widget draws the content of a button, i.e. the text, while the +/// background of the button is drawn by [CupertinoActionSheet]. When +/// [focusNode] has focus, this widget will draw the background of color +/// [focusColor]. +/// +/// See also: +/// +/// * [CupertinoActionSheet], an alert that presents the user with a set of two or +/// more choices related to the current context. +class CupertinoActionSheetAction extends StatefulWidget { + /// Creates an action for an iOS-style action sheet. + const CupertinoActionSheetAction({ + super.key, + required this.onPressed, + this.isDefaultAction = false, + this.isDestructiveAction = false, + this.mouseCursor, + this.focusNode, + this.focusColor, + required this.child, + }); + + /// The callback that is called when the button is selected. + /// + /// The button can be selected by either by tapping on this button or by + /// pressing elsewhere and sliding onto this button before releasing. + final VoidCallback onPressed; + + /// Whether this action is the default choice in the action sheet. + /// + /// Default buttons have bold text. + final bool isDefaultAction; + + /// Whether this action might change or delete data. + /// + /// Destructive buttons have red text. + final bool isDestructiveAction; + + /// The cursor that will be shown when hovering over the button. + /// + /// If null, defaults to [SystemMouseCursors.click] on web and + /// [MouseCursor.defer] on other platforms. + final MouseCursor? mouseCursor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The color of the background that highlights active focus. + /// + /// A transparency of [kCupertinoButtonTintedOpacityLight] (light mode) or + /// [kCupertinoButtonTintedOpacityDark] (dark mode) is automatically applied to this color. + /// + /// When [focusColor] is null, defaults to [CupertinoColors.activeBlue]. + final Color? focusColor; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + State createState() => _CupertinoActionSheetActionState(); +} + +class _CupertinoActionSheetActionState extends State + implements _SlideTarget { + bool _showHighlight = false; + + // |_SlideTarget| + @override + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + return innerEnabled; + } + + // |_SlideTarget| + @override + void didLeave() {} + + // |_SlideTarget| + @override + void didConfirm() { + widget.onPressed(); + } + + void _onShowFocusHighlight(bool showHighlight) { + setState(() { + _showHighlight = showHighlight; + }); + } + + late final Map> _actionMap = >{ + ActivateIntent: CallbackAction(onInvoke: _handleTap), + }; + + void _handleTap([Intent? _]) { + widget.onPressed(); + context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent()); + } + + Color get effectiveFocusBackgroundColor => HSLColor.fromColor( + (widget.focusColor ?? CupertinoColors.activeBlue).withOpacity( + CupertinoTheme.brightnessOf(context) == Brightness.light + ? kCupertinoButtonTintedOpacityLight + : kCupertinoButtonTintedOpacityDark, + ), + ).toColor(); + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: widget.mouseCursor ?? (kIsWeb ? SystemMouseCursors.click : MouseCursor.defer), + child: MetaData( + metaData: this, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kActionSheetButtonMinHeight), + child: FocusableActionDetector( + actions: _actionMap, + focusNode: widget.focusNode, + onShowFocusHighlight: _onShowFocusHighlight, + child: Semantics( + button: true, + onTap: widget.onPressed, + child: _showHighlight + ? DecoratedBox( + decoration: BoxDecoration(color: effectiveFocusBackgroundColor), + child: _ActionSheetActionContent( + isDestructiveAction: widget.isDestructiveAction, + isDefaultAction: widget.isDefaultAction, + child: widget.child, + ), + ) + : _ActionSheetActionContent( + isDestructiveAction: widget.isDestructiveAction, + isDefaultAction: widget.isDefaultAction, + child: widget.child, + ), + ), + ), + ), + ), + ); + } +} + +class _ActionSheetActionContent extends StatelessWidget { + const _ActionSheetActionContent({ + required this.isDestructiveAction, + required this.isDefaultAction, + required this.child, + }); + + final bool isDestructiveAction; + final bool isDefaultAction; + final Widget child; + + // Calculates the font size for action sheet buttons. + // + // The `contextBodySize` is the body font size specified by context. The + // return value is the button font size, including the effect of context font + // scale factor. Divide by context font scale factor before using in a `Text`. + static double _buttonFontSize(double contextBodySize) { + // It is observed that the native action sheet buttons use font sizes that + // deviate from standard HIG specifications in a non-linear way. The following + // table shows the regular body font size vs the button font size: + // + // Text scale | xs | s | m | l | xl | xxl | xxxl | ax1 | ax2 | ax3 | ax4 | ax5 + // Body font | 14 | 15 | 16 | 17 | 19 | 21 | 23 | 28 | 33 | 40 | 47 | 53 + // Button font | 21 | 21 | 21 | 21 | 23 | 24 | 24 | 28 | 33 | 40 | 47 | 53 + + // For very small or very large text, simple rules can be observed. + // For mid-sized text, piecewise linear interpolation is used. + return switch (contextBodySize) { + <= 17 => 21.0, + <= 19 => lerpDouble(21.0, 23.0, (contextBodySize - 17.0) / (19.0 - 17.0))!, + <= 21 => lerpDouble(23.0, 24.0, (contextBodySize - 19.0) / (21.0 - 19.0))!, + <= 24 => 24.0, + _ => contextBodySize, + }; + } + + @override + Widget build(BuildContext context) { + // The context scale factor is derived from the current body size and the + // standard body size in "large". + const higLargeBodySize = 17.0; + final double contextBodySize = MediaQuery.textScalerOf(context).scale(higLargeBodySize); + final double contextScaleFactor = contextBodySize / higLargeBodySize; + final double fontSize = _buttonFontSize(contextBodySize); + + TextStyle style = _kActionSheetActionStyle.copyWith( + // `Text` will scale the provided font size inside, so its parameter is + // unscaled first. + fontSize: fontSize / contextScaleFactor, + color: isDestructiveAction + ? CupertinoDynamicColor.resolve(CupertinoColors.systemRed, context) + : CupertinoTheme.of(context).primaryColor, + ); + + if (isDefaultAction) { + style = style.copyWith(fontWeight: FontWeight.w600); + } + final double verticalPadding = + _kActionSheetButtonVerticalPaddingBase + + fontSize * _kActionSheetButtonVerticalPaddingFactor; + + return Padding( + padding: EdgeInsets.fromLTRB( + _kActionSheetButtonHorizontalPadding, + verticalPadding, + _kActionSheetButtonHorizontalPadding, + verticalPadding, + ), + child: DefaultTextStyle( + style: style, + textAlign: TextAlign.center, + child: Center(child: child), + ), + ); + } +} + +// Renders the background of a button (both the pressed background and the idle +// background) and reports its state to the parent with `onPressStateChange`. +// +// Although this class doesn't keep any states, it's still a stateful widget +// because the state is used as a persistent object across rebuilds to provide +// to [MetaData.data]. +class _ActionSheetButtonBackground extends StatefulWidget { + const _ActionSheetButtonBackground({ + this.isCancel = false, + required this.pressed, + this.onPressStateChange, + required this.child, + }); + + final bool isCancel; + + /// Whether the user is holding on this button. + final bool pressed; + + /// Called when the user taps down or lifts up on the button. + /// + /// The boolean value is true if the user is tapping down on the button. + final ValueSetter? onPressStateChange; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + _ActionSheetButtonBackgroundState createState() => _ActionSheetButtonBackgroundState(); +} + +class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackground> + implements _SlideTarget { + void _emitVibration() { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + HapticFeedback.selectionClick(); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + + // |_SlideTarget| + @override + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + // Action sheet doesn't support disabled buttons, therefore `innerEnabled` + // is always true. + assert(innerEnabled); + widget.onPressStateChange?.call(true); + if (!fromPointerDown) { + _emitVibration(); + } + return innerEnabled; + } + + // |_SlideTarget| + @override + void didLeave() { + widget.onPressStateChange?.call(false); + } + + // |_SlideTarget| + @override + void didConfirm() { + widget.onPressStateChange?.call(false); + } + + @override + Widget build(BuildContext context) { + late final Widget child; + if (!widget.isCancel) { + child = ColoredBox( + color: CupertinoDynamicColor.resolve( + widget.pressed ? _kActionSheetPressedColor : _kActionSheetBackgroundColor, + context, + ), + child: widget.child, + ); + } else { + const borderRadius = BorderRadius.all(Radius.circular(_kCornerRadius)); + + child = ClipRSuperellipse( + borderRadius: borderRadius, + child: DecoratedBox( + decoration: BoxDecoration( + color: CupertinoDynamicColor.resolve( + widget.pressed ? _kActionSheetCancelPressedColor : _kActionSheetCancelColor, + context, + ), + ), + child: widget.child, + ), + ); + } + + return MetaData(metaData: this, child: child); + } +} + +// The divider of an action sheet or an alert dialog. +// +// The divider can function as either a horizontal divider (in a column) or a +// vertical divider (in a row) without widget-layer configuration. Instead, this +// is determined during the layout phase based on the constraints. This approach +// is necessary to allow the alert dialog to provide a list of widgets to the +// layout widget, which doesn't know its layout mode until the layout phase. +// +// The constraints provided to this widget should match the column container's +// width or the row container's height, while being unlimited in the other +// dimension. This unlimited dimension will result in the divider's thickness. +// +// If the divider is not `hidden`, then it displays the `dividerColor`. +// Otherwise it displays the background color. +class _Divider extends StatelessWidget { + const _Divider({required this.dividerColor, required this.hiddenColor, required this.hidden}); + + final Color dividerColor; + final Color hiddenColor; + final bool hidden; + + @override + Widget build(BuildContext context) { + // The LimitedBox turns unconstrained dimension (typically the main axis of + // a flex container) to the divider thickness. + return LimitedBox( + maxHeight: _kDividerThickness, + maxWidth: _kDividerThickness, + // The constrained box prevents the divider from collapsing to nothing. + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _kDividerThickness, + minWidth: _kDividerThickness, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: hidden ? CupertinoDynamicColor.resolve(hiddenColor, context) : dividerColor, + ), + ), + ), + ); + } +} + +// Fills the overscroll area at the top or bottom of a scrollable widget with a +// solid color. +// +// This is necessary for action sheets and alert dialogs, because their actions +// section's background is rendered by the buttons, so that a button's +// background can be _replaced_ by a different color when the button is pressed. +class _OverscrollBackground extends StatefulWidget { + const _OverscrollBackground({required this.color, required this.child}); + + // The color for the overscroll part. + // + // This value must be a resolved color instead of, for example, a + // CupertinoDynamicColor. + final Color color; + final Widget child; + + @override + _OverscrollBackgroundState createState() => _OverscrollBackgroundState(); +} + +class _OverscrollBackgroundState extends State<_OverscrollBackground> { + double _topOverscroll = 0; + double _bottomOverscroll = 0; + + bool _onScrollUpdate(ScrollUpdateNotification notification) { + final ScrollMetrics metrics = notification.metrics; + setState(() { + // The sizes of the overscroll should not be longer than the height of the + // actions section. + _topOverscroll = math.min( + math.max(metrics.minScrollExtent - metrics.pixels, 0), + metrics.viewportDimension, + ); + _bottomOverscroll = math.min( + math.max(metrics.pixels - metrics.maxScrollExtent, 0), + metrics.viewportDimension, + ); + }); + return false; + } + + @override + Widget build(BuildContext context) { + final Widget overscroll = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DecoratedBox( + decoration: BoxDecoration(color: widget.color), + child: SizedBox(height: _topOverscroll), + ), + DecoratedBox( + decoration: BoxDecoration(color: widget.color), + child: SizedBox(height: _bottomOverscroll), + ), + ], + ); + return Stack( + children: [ + Positioned.fill(child: overscroll), + NotificationListener( + onNotification: _onScrollUpdate, + child: widget.child, + ), + ], + ); + } +} + +typedef _PressedUpdateHandler = void Function(int actionIndex, bool state); + +// The list of actions in an action sheet. +// +// This excludes the divider between the action section and the content section. +class _ActionSheetActionSection extends StatelessWidget { + const _ActionSheetActionSection({ + required this.actions, + required this.pressedIndex, + required this.dividerColor, + required this.backgroundColor, + required this.onPressedUpdate, + required this.scrollController, + }); + + final List? actions; + final _PressedUpdateHandler onPressedUpdate; + final int? pressedIndex; + final Color dividerColor; + final Color backgroundColor; + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + if (actions == null || actions!.isEmpty) { + return const LimitedBox(maxWidth: 0, child: SizedBox(width: double.infinity, height: 0)); + } + final column = []; + for (var actionIndex = 0; actionIndex < actions!.length; actionIndex += 1) { + if (actionIndex != 0) { + column.add( + _Divider( + dividerColor: dividerColor, + hiddenColor: _kActionSheetBackgroundColor, + hidden: pressedIndex == actionIndex - 1 || pressedIndex == actionIndex, + ), + ); + } + column.add( + _ActionSheetButtonBackground( + pressed: pressedIndex == actionIndex, + onPressStateChange: (bool state) { + onPressedUpdate(actionIndex, state); + }, + child: actions![actionIndex], + ), + ); + } + + return CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: column), + ), + ); + } +} + +// The part of an action sheet without the cancel button. +class _ActionSheetMainSheet extends StatelessWidget { + const _ActionSheetMainSheet({ + required this.pressedIndex, + required this.onPressedUpdate, + required this.scrollController, + required this.actions, + required this.contentSection, + required this.dividerColor, + }); + + final int? pressedIndex; + final _PressedUpdateHandler onPressedUpdate; + final ScrollController scrollController; + final List actions; + final Widget? contentSection; + final Color dividerColor; + + Widget _scrolledActionsSection(BuildContext context) { + final Color backgroundColor = CupertinoDynamicColor.resolve( + _kActionSheetBackgroundColor, + context, + ); + return _OverscrollBackground( + color: backgroundColor, + child: CupertinoFocusHalo.withRRect( + borderRadius: kCupertinoButtonSizeBorderRadius[CupertinoButtonSize.large]!.copyWith( + topLeft: Radius.zero, + topRight: Radius.zero, + ), + child: _ActionSheetActionSection( + actions: actions, + scrollController: scrollController, + dividerColor: dividerColor, + backgroundColor: backgroundColor, + pressedIndex: pressedIndex, + onPressedUpdate: onPressedUpdate, + ), + ), + ); + } + + Widget _dividerAndActionsSection(BuildContext context) { + final Color backgroundColor = CupertinoDynamicColor.resolve( + _kActionSheetBackgroundColor, + context, + ); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Divider(dividerColor: dividerColor, hiddenColor: backgroundColor, hidden: false), + Flexible(child: _scrolledActionsSection(context)), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (actions.isEmpty) { + return contentSection ?? _empty; + } + if (contentSection == null) { + return _scrolledActionsSection(context); + } + + return _PriorityColumn( + top: contentSection!, + bottom: _dividerAndActionsSection(context), + bottomMinHeight: _kActionSheetActionsSectionMinHeight + _kDividerThickness, + ); + } + + static const Widget _empty = LimitedBox( + maxWidth: 0, + child: SizedBox(width: double.infinity, height: 0), + ); +} + +// The "content section" of a CupertinoAlertDialog. +// +// If title is missing, then only content is added. If content is +// missing, then only title is added. If both are missing, then it returns +// a SingleChildScrollView with a zero-sized Container. +class _CupertinoAlertContentSection extends StatelessWidget { + const _CupertinoAlertContentSection({ + this.title, + this.message, + required this.scrollController, + this.titlePadding, + this.messagePadding, + this.titleTextStyle, + this.messageTextStyle, + this.additionalPaddingBetweenTitleAndMessage, + }) : assert(title == null || titlePadding != null && titleTextStyle != null), + assert(message == null || messagePadding != null && messageTextStyle != null); + + // The (optional) title of the dialog is displayed in a large font at the top + // of the dialog. + // + // Typically a Text widget. + final Widget? title; + + // The (optional) message of the dialog is displayed in the center of the + // dialog in a lighter font. + // + // Typically a Text widget. + final Widget? message; + + // A scroll controller that can be used to control the scrolling of the + // content in the dialog. + final ScrollController scrollController; + + // Paddings used around title and message. + // CupertinoAlertDialog and CupertinoActionSheet have different paddings. + final EdgeInsets? titlePadding; + final EdgeInsets? messagePadding; + + // Additional padding to be inserted between title and message. + // Only used for CupertinoActionSheet. + final EdgeInsets? additionalPaddingBetweenTitleAndMessage; + + // Text styles used for title and message. + // CupertinoAlertDialog and CupertinoActionSheet have different text styles. + final TextStyle? titleTextStyle; + final TextStyle? messageTextStyle; + + @override + Widget build(BuildContext context) { + if (title == null && message == null) { + return SingleChildScrollView(controller: scrollController, child: const SizedBox.shrink()); + } + + final titleContentGroup = [ + if (title != null) + Padding( + padding: titlePadding!, + child: DefaultTextStyle( + style: titleTextStyle!, + textAlign: TextAlign.center, + child: title!, + ), + ), + if (message != null) + Padding( + padding: messagePadding!, + child: DefaultTextStyle( + style: messageTextStyle!, + textAlign: TextAlign.center, + child: message!, + ), + ), + ]; + + // Add padding between the widgets if necessary. + if (additionalPaddingBetweenTitleAndMessage != null && titleContentGroup.length > 1) { + titleContentGroup.insert(1, Padding(padding: additionalPaddingBetweenTitleAndMessage!)); + } + + return CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: titleContentGroup), + ), + ); + } +} + +// The "actions section" of a [CupertinoAlertDialog]. +// +// The `actions` must not be empty. +class _CupertinoAlertActionSection extends StatelessWidget { + const _CupertinoAlertActionSection({ + required this.actions, + required this.onPressedUpdate, + required this.pressedIndex, + required this.scrollController, + }) : assert(actions.length != 0); + + // A list of action buttons. + // + // This list must not include the dividers between the buttons. If the list + // is empty, then this widget returns an empty box. + final List actions; + + final _PressedUpdateHandler onPressedUpdate; + final int? pressedIndex; + + // A scroll controller that can be used to control the scrolling of the + // actions in the dialog. + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + final Color dialogColor = CupertinoDynamicColor.resolve(_kDialogColor, context); + final Color dialogPressedColor = CupertinoDynamicColor.resolve(_kDialogPressedColor, context); + final Color dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context); + + final column = []; + for (var actionIndex = 0; actionIndex < actions.length; actionIndex += 1) { + if (actionIndex != 0) { + column.add( + _Divider( + dividerColor: dividerColor, + hiddenColor: dialogColor, + hidden: pressedIndex == actionIndex - 1 || pressedIndex == actionIndex, + ), + ); + } + column.add( + _AlertDialogButtonBackground( + idleColor: dialogColor, + pressedColor: dialogPressedColor, + pressed: pressedIndex == actionIndex, + onPressStateChange: (bool state) { + onPressedUpdate(actionIndex, state); + }, + child: actions[actionIndex], + ), + ); + } + + return CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: _AlertDialogActionsLayout(dividerThickness: _kDividerThickness, children: column), + ), + ); + } +} + +// Renders the background of a button (both the pressed background and the idle +// background) and reports its state to the parent with `onPressStateChange`. +class _AlertDialogButtonBackground extends StatefulWidget { + const _AlertDialogButtonBackground({ + required this.idleColor, + required this.pressedColor, + required this.pressed, + required this.onPressStateChange, + required this.child, + }); + + /// Called whether the user is holding on this button. + final bool pressed; + + /// Called when the user taps down or lifts up on the button. + /// + /// The boolean value is true if the user is tapping down on the button. + final ValueSetter? onPressStateChange; + + final Color idleColor; + final Color pressedColor; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + _AlertDialogButtonBackgroundState createState() => _AlertDialogButtonBackgroundState(); +} + +class _AlertDialogButtonBackgroundState extends State<_AlertDialogButtonBackground> + implements _SlideTarget { + void _emitVibration() { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + HapticFeedback.selectionClick(); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + + // |_SlideTarget| + @override + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + widget.onPressStateChange?.call(innerEnabled); + if (innerEnabled && !fromPointerDown) { + _emitVibration(); + } + return innerEnabled; + } + + // |_SlideTarget| + @override + void didLeave() { + widget.onPressStateChange?.call(false); + } + + // |_SlideTarget| + @override + void didConfirm() { + widget.onPressStateChange?.call(false); + } + + @override + Widget build(BuildContext context) { + final Color backgroundColor = widget.pressed ? widget.pressedColor : widget.idleColor; + return MetaData( + metaData: this, + child: MergeSemantics( + child: Container( + decoration: BoxDecoration(color: CupertinoDynamicColor.resolve(backgroundColor, context)), + child: widget.child, + ), + ), + ); + } +} + +/// A button typically used in a [CupertinoAlertDialog]. +/// +/// See also: +/// +/// * [CupertinoAlertDialog], a dialog that informs the user about situations +/// that require acknowledgment. +class CupertinoDialogAction extends StatefulWidget { + /// Creates an action for an iOS-style dialog. + const CupertinoDialogAction({ + super.key, + this.onPressed, + this.isDefaultAction = false, + this.isDestructiveAction = false, + this.textStyle, + this.mouseCursor, + required this.child, + }); + + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + /// Set to true if button is the default choice in the dialog. + /// + /// Default buttons have bold text. Similar to + /// [UIAlertController.preferredAction](https://developer.apple.com/documentation/uikit/uialertcontroller/1620102-preferredaction), + /// but more than one action can have this attribute set to true in the same + /// [CupertinoAlertDialog]. + /// + /// This parameters defaults to false. + final bool isDefaultAction; + + /// Whether this action destroys an object. + /// + /// For example, an action that deletes an email is destructive. + /// + /// Defaults to false. + final bool isDestructiveAction; + + /// [TextStyle] to apply to any text that appears in this button. + /// + /// Dialog actions have a built-in text resizing policy for long text. To + /// ensure that this resizing policy always works as expected, [textStyle] + /// must be used if a text size is desired other than that specified in + /// [_kCupertinoDialogActionStyle]. + final TextStyle? textStyle; + + /// The cursor that will be shown when hovering over the button. + /// + /// If null, defaults to [SystemMouseCursors.click] on web and + /// [MouseCursor.defer] on other platforms. + final MouseCursor? mouseCursor; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + State createState() => _CupertinoDialogActionState(); +} + +class _CupertinoDialogActionState extends State implements _SlideTarget { + // The button is enabled when it has [onPressed]. + bool get enabled => widget.onPressed != null; + + // |_SlideTarget| + @override + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + return enabled; + } + + // |_SlideTarget| + @override + void didLeave() {} + + // |_SlideTarget| + @override + void didConfirm() { + widget.onPressed?.call(); + } + + // Dialog action content shrinks to fit, up to a certain point, and if it still + // cannot fit at the minimum size, the text content is ellipsized. + // + // This policy only applies when the device is not in accessibility mode. + Widget _buildContentWithRegularSizingPolicy({ + required BuildContext context, + required TextStyle textStyle, + required Widget content, + required double padding, + }) { + final bool isInAccessibilityMode = _isInAccessibilityMode(context); + final double dialogWidth = isInAccessibilityMode + ? _kAccessibilityCupertinoDialogWidth + : _kCupertinoDialogWidth; + // The fontSizeRatio is the ratio of the current text size (including any + // iOS scale factor) vs the minimum text size that we allow in action + // buttons. This ratio information is used to automatically scale down action + // button text to fit the available space. + final double fontSizeRatio = + MediaQuery.textScalerOf(context).scale(textStyle.fontSize!) / _kDialogMinButtonFontSize; + + return FittedBox( + fit: BoxFit.scaleDown, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: fontSizeRatio * (dialogWidth - (2 * padding))), + child: Semantics( + button: true, + onTap: widget.onPressed, + child: DefaultTextStyle( + style: textStyle, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: content, + ), + ), + ), + ); + } + + // Dialog action content is permitted to be as large as it wants when in + // accessibility mode. If text is used as the content, the text wraps instead + // of ellipsizing. + Widget _buildContentWithAccessibilitySizingPolicy({ + required TextStyle textStyle, + required Widget content, + }) { + return DefaultTextStyle(style: textStyle, textAlign: TextAlign.center, child: content); + } + + @override + Widget build(BuildContext context) { + TextStyle style = _kCupertinoDialogActionStyle + .copyWith( + color: CupertinoDynamicColor.resolve( + widget.isDestructiveAction + ? CupertinoColors.systemRed + : CupertinoTheme.of(context).primaryColor, + context, + ), + ) + .merge(widget.textStyle); + + if (widget.isDefaultAction) { + style = style.copyWith(fontWeight: FontWeight.w600); + } + + if (!enabled) { + style = style.copyWith(color: style.color!.withOpacity(0.5)); + } + final double fontSize = style.fontSize ?? kDefaultFontSize; + final double fontSizeToScale = fontSize == 0.0 ? kDefaultFontSize : fontSize; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(fontSizeToScale) / fontSizeToScale; + final double padding = 8.0 * effectiveTextScale; + // Apply a sizing policy to the action button's content based on whether or + // not the device is in accessibility mode. + // TODO(mattcarroll): The following logic is not entirely correct. It is also + // the case that if content text does not contain a space, it should also + // wrap instead of ellipsizing. We are consciously not implementing that + // now due to complexity. + final Widget sizedContent = _isInAccessibilityMode(context) + ? _buildContentWithAccessibilitySizingPolicy(textStyle: style, content: widget.child) + : _buildContentWithRegularSizingPolicy( + context: context, + textStyle: style, + content: widget.child, + padding: padding, + ); + + return MouseRegion( + cursor: + widget.mouseCursor ?? (enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer), + child: MetaData( + metaData: this, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kDialogMinButtonHeight), + child: Padding( + padding: EdgeInsets.all(padding), + child: Center(child: sizedContent), + ), + ), + ), + ); + } +} + +// iOS style dialog action button layout. +// +// [_AlertDialogActionsLayout] does not provide any scrolling +// behavior for its buttons. It only handles the sizing and layout of buttons. +// Scrolling behavior can be composed on top of this widget, if desired. +// +// The layout operates in two modes: +// +// 1. Horizontal Mode: If there are exactly two buttons and they fit in a single +// row, the buttons are rendered side by side with a vertical divider between +// them. +// 2. Vertical Mode: In all other cases, the buttons are arranged in a column, +// separated by horizontal dividers. +// +// The `children` parameter must be a non-empty list containing button widgets +// and divider widgets in an alternating sequence. Therefore, the list must have +// an odd length. +class _AlertDialogActionsLayout extends MultiChildRenderObjectWidget { + const _AlertDialogActionsLayout({required double dividerThickness, required super.children}) + : _dividerThickness = dividerThickness; + + final double _dividerThickness; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderAlertDialogActionsLayout( + dividerThickness: _dividerThickness, + textDirection: Directionality.of(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderAlertDialogActionsLayout renderObject) { + renderObject + ..dividerThickness = _dividerThickness + ..textDirection = Directionality.of(context); + } +} + +class _RenderAlertDialogActionsLayout extends RenderFlex { + _RenderAlertDialogActionsLayout({ + List? children, + required double dividerThickness, + super.textDirection, + }) : _dividerThickness = dividerThickness, + super( + direction: Axis.vertical, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) { + addAll(children); + } + + // The thickness of the divider between buttons. + double get dividerThickness => _dividerThickness; + double _dividerThickness; + set dividerThickness(double newValue) { + if (newValue != _dividerThickness) { + _dividerThickness = newValue; + markNeedsLayout(); + } + } + + double horizontalSlotWidthFor({required double overallWidth}) => + (overallWidth - dividerThickness) / 2; + + @override + double computeMinIntrinsicHeight(double width) { + if (!_useHorizontalLayout(width)) { + return super.computeMinIntrinsicHeight(width); + } + + final double slotWidth = horizontalSlotWidthFor(overallWidth: width); + double height = 0; + _forEachSlot((RenderBox slot) { + height = math.max(height, slot.getMinIntrinsicHeight(slotWidth)); + }); + return height; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (!_useHorizontalLayout(width)) { + return super.computeMaxIntrinsicHeight(width); + } + + final double slotWidth = horizontalSlotWidthFor(overallWidth: width); + double height = 0; + _forEachSlot((RenderBox slot) { + height = math.max(height, slot.getMaxIntrinsicHeight(slotWidth)); + }); + return height; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + if (!_debugHasValidConstraints(constraints)) { + return Size.zero; + } + + final double overallWidth = constraints.maxWidth; + if (!_useHorizontalLayout(overallWidth)) { + return super.computeDryLayout(constraints); + } + + final double height = getMinIntrinsicHeight(overallWidth); + return Size(overallWidth, height); + } + + @override + void performLayout() { + if (firstChild == null) { + size = constraints.smallest; + return; + } + + if (!_debugHasValidConstraints(constraints)) { + size = constraints.smallest; + return; + } + + final double overallWidth = constraints.maxWidth; + if (!_useHorizontalLayout(overallWidth)) { + return super.performLayout(); + } + + final double slotWidth = horizontalSlotWidthFor(overallWidth: overallWidth); + final double height = getMinIntrinsicHeight(overallWidth); + size = Size(overallWidth, height); + + final ltr = textDirection == TextDirection.ltr; + RenderBox slot = firstChild!; + double x = ltr ? 0 : (overallWidth - slotWidth); + while (true) { + slot.layout(BoxConstraints.tight(Size(slotWidth, height)), parentUsesSize: true); + (slot.parentData! as FlexParentData).offset = Offset(x, 0); + if (ltr) { + x += slot.size.width; + } else { + x -= slot.size.width; + } + + final RenderBox? divider = childAfter(slot); + if (divider == null) { + break; + } + divider.layout(BoxConstraints.tight(Size(dividerThickness, height))); + (divider.parentData! as FlexParentData).offset = Offset(x, 0); + if (ltr) { + x += dividerThickness; + } else { + x -= dividerThickness; + } + slot = childAfter(divider)!; + } + } + + bool _debugHasValidConstraints(BoxConstraints constraints) { + assert(() { + ErrorSummary? errorSummary; + if (constraints.maxWidth == double.infinity) { + errorSummary = ErrorSummary('The incoming width constraints are unbounded.'); + } + if (errorSummary != null) { + throw FlutterError.fromParts([ + errorSummary, + ErrorDescription('The incoming constraints are: $constraints'), + ]); + } + return true; + }()); + return true; + } + + bool _useHorizontalLayout(double overallWidth) { + // Horizontal layout only applies to cases of 3 children: 2 action buttons + // and 1 divider. + if (childCount != 3) { + return false; + } + final double slotWidth = horizontalSlotWidthFor(overallWidth: overallWidth); + RenderBox child = firstChild!; + while (true) { + // If both children fit into a half-row slot, use the horizontal layout. + // Max intrinsic widths are used here, which, according to + // [TextPainter.maxIntrinsicWidth], allows text to be displayed at their + // full font size. + if (child.getMaxIntrinsicWidth(double.infinity) > slotWidth) { + return false; + } + final RenderBox? divider = childAfter(child); + if (divider == null) { + break; + } + child = childAfter(divider)!; + } + return true; + } + + void _forEachSlot(ValueSetter action) { + assert(childCount.isOdd); + RenderBox slot = firstChild!; + while (true) { + action(slot); + final RenderBox? divider = childAfter(slot); + if (divider == null) { + break; + } + slot = childAfter(divider)!; + } + } +} + +typedef _TwoChildrenHeights = ({double topChildHeight, double bottomChildHeight}); + +// A column layout with two widgets, where the top widget expands vertically as +// needed, and the bottom widget has a minimum height. +// +// Both child widgets stretch horizontally to the parent's maximum width +// constraint, with vertical space allocated in this priority: +// +// 1. The `bottom` widget receives its requested height, up to a +// `bottomMaxHeight` limit and the container's constraint. +// 2. The `top` widget receives its requested height, up to the remaining space +// in the container. +// 3. The `bottom` widget receives its requested height, up to any remaining +// space in the container. +// +// This mirrors the behavior seen in iOS components like action sheets and +// alerts. +// +// Implementing this layout with simple compositing widgets is challenging +// because: +// +// * The bottom widget should take more than `bottomMinHeight` if the top +// widget is short. +// * The bottom widget should take less than `bottomMinHeight` if it is +// naturally shorter. +class _PriorityColumn extends MultiChildRenderObjectWidget { + _PriorityColumn({required Widget top, required Widget bottom, required this.bottomMinHeight}) + : super(children: [top, bottom]); + + final double bottomMinHeight; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderPriorityColumn(bottomMinHeight: bottomMinHeight); + } + + @override + void updateRenderObject(BuildContext context, _RenderPriorityColumn renderObject) { + renderObject.bottomMinHeight = bottomMinHeight; + } +} + +class _RenderPriorityColumn extends RenderFlex { + _RenderPriorityColumn({List? children, required double bottomMinHeight}) + : _bottomMinHeight = bottomMinHeight, + super( + direction: Axis.vertical, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) { + addAll(children); + } + + double get bottomMinHeight => _bottomMinHeight; + double _bottomMinHeight; + set bottomMinHeight(double newValue) { + if (newValue != _bottomMinHeight) { + _bottomMinHeight = newValue; + markNeedsLayout(); + } + } + + @override + double computeMinIntrinsicHeight(double width) { + assert(childCount == 2); + return firstChild!.getMinIntrinsicHeight(width) + lastChild!.getMinIntrinsicHeight(width); + } + + @override + double computeMaxIntrinsicHeight(double width) { + assert(childCount == 2); + return firstChild!.getMaxIntrinsicHeight(width) + lastChild!.getMaxIntrinsicHeight(width); + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + final double width = constraints.maxWidth; + final double maxHeight = constraints.maxHeight; + final (:double topChildHeight, :double bottomChildHeight) = _childrenHeights(width, maxHeight); + return Size(width, topChildHeight + bottomChildHeight); + } + + @override + void performLayout() { + final double width = constraints.maxWidth; + final double maxHeight = constraints.maxHeight; + final (:double topChildHeight, :double bottomChildHeight) = _childrenHeights(width, maxHeight); + size = Size(width, topChildHeight + bottomChildHeight); + + firstChild!.layout(BoxConstraints.tight(Size(width, topChildHeight)), parentUsesSize: true); + (firstChild!.parentData! as FlexParentData).offset = Offset.zero; + + lastChild!.layout(BoxConstraints.tight(Size(width, bottomChildHeight)), parentUsesSize: true); + (lastChild!.parentData! as FlexParentData).offset = Offset(0, topChildHeight); + } + + _TwoChildrenHeights _childrenHeights(double width, double maxHeight) { + assert(childCount == 2); + final double topIntrinsic = firstChild!.getMinIntrinsicHeight(width); + final double bottomIntrinsic = lastChild!.getMinIntrinsicHeight(width); + // Try to layout both children as their intrinsic height. + if (topIntrinsic + bottomIntrinsic <= maxHeight) { + return (topChildHeight: topIntrinsic, bottomChildHeight: bottomIntrinsic); + } + // _bottomMinHeight is only effective when bottom actually needs that much. + final double effectiveBottomMinHeight = math.min(_bottomMinHeight, bottomIntrinsic); + // Try to layout top as intrinsics, as long as the bottom has at least + // effectiveBottomMinHeight. + if (maxHeight - topIntrinsic >= effectiveBottomMinHeight) { + return (topChildHeight: topIntrinsic, bottomChildHeight: maxHeight - topIntrinsic); + } + // Try to layout bottom as effectiveBottomMinHeight, as long as top has at + // least 0. + if (maxHeight >= effectiveBottomMinHeight) { + return ( + topChildHeight: maxHeight - effectiveBottomMinHeight, + bottomChildHeight: effectiveBottomMinHeight, + ); + } + return (topChildHeight: 0, bottomChildHeight: maxHeight); + } +} diff --git a/packages/cupertino_ui/lib/src/expansion_tile.dart b/packages/cupertino_ui/lib/src/expansion_tile.dart new file mode 100644 index 000000000000..be1bade8178b --- /dev/null +++ b/packages/cupertino_ui/lib/src/expansion_tile.dart @@ -0,0 +1,259 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'list_section.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'icons.dart'; +import 'list_tile.dart'; +import 'localizations.dart'; +import 'theme.dart'; + +/// The curve of the animation used to expand or collapse the +/// [CupertinoExpansionTile]. +/// +/// Eyeballed from an iPhone 15 simulator running iOS 17.5. +const Curve _kAnimationCurve = Curves.easeInOut; + +/// The duration of the animation used to expand or collapse the +/// [CupertinoExpansionTile]. +/// +/// Eyeballed from an iPhone 15 simulator running iOS 17.5. +const Duration _kAnimationDuration = Duration(milliseconds: 250); + +/// The font size of the rotating trailing icon in the header of a +/// [CupertinoExpansionTile]. +/// +/// Eyeballed from an iPhone 15 simulator running iOS 17.5. +const double _kIconFontSize = 15.0; + +/// The height of the header in a [CupertinoExpansionTile], which is the default +/// [CupertinoListTile]. +const double _kHeaderHeight = 44.0; + +/// Defines how a [CupertinoExpansionTile] should transition its child between +/// its collapsed state and its expanded state. +enum ExpansionTileTransitionMode { + /// Transition by fading a fully extended [CupertinoExpansionTile.child]. + /// + /// When the [CupertinoExpansionTile] expands, the child appears fully extended + /// and fades into view. When the [CupertinoExpansionTile] collapses, the child + /// remains fully extended and fades out of view. + fade, + + /// Transition by scrolling [CupertinoExpansionTile.child] under the header. + /// + /// When the [CupertinoExpansionTile] expands, the child scrolls from under the + /// header until it becomes fully extended. When the [CupertinoExpansionTile] + /// collapses, the child scrolls under the header until it is fully collapsed. + scroll, +} + +/// A single-line [CupertinoListTile] with an expansion arrow icon that expands +/// or collapses the tile to reveal or hide the [child]. +/// +/// {@tool dartpad} +/// This example shows how to use [CupertinoExpansionTile] with different transition modes. +/// +/// ** See code in examples/api/lib/cupertino/expansion_tile/cupertino_expansion_tile.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ExpansionTile], the Material Design equivalent. +/// * [CupertinoListSection], useful for creating an expansion tile [child]. +/// * [CupertinoListTile], the header of a [CupertinoExpansionTile]. +/// * +class CupertinoExpansionTile extends StatefulWidget { + /// Creates a single-line [CupertinoListTile] with an expansion arrow icon + /// that expands or collapses the tile to reveal or hide the [child]. + const CupertinoExpansionTile({ + super.key, + required this.title, + required this.child, + this.controller, + this.transitionMode = ExpansionTileTransitionMode.fade, + }); + + /// Used to convey the central information. + /// + /// Usually a [Text]. + final Widget title; + + /// Programmatically expands and collapses the [CupertinoExpansionTile]. + /// + /// In cases where control over the tile's state is needed from a + /// callback triggered by a widget within the tile, [ExpansibleController.of] + /// may be more convenient than supplying a controller. + final ExpansibleController? controller; + + /// The body of the [CupertinoExpansionTile]. + final Widget child; + + /// How the [CupertinoExpansionTile] should transition its child between its + /// collapsed state and its expanded state. + /// + /// Defaults to [ExpansionTileTransitionMode.fade]. + final ExpansionTileTransitionMode transitionMode; + + @override + State createState() => _CupertinoExpansionTileState(); +} + +class _CupertinoExpansionTileState extends State { + final GlobalKey _headerKey = GlobalKey(); + final OverlayPortalController _fadeController = OverlayPortalController(); + static final Animatable _quarterTween = Tween(begin: 0.0, end: 0.25); + + late ExpansibleController _tileController; + late Animation _iconTurns; + + @override + void initState() { + super.initState(); + _tileController = widget.controller ?? ExpansibleController(); + } + + @override + void didUpdateWidget(CupertinoExpansionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (oldWidget.controller == null) { + _tileController.dispose(); + } + _tileController = widget.controller ?? ExpansibleController(); + } + } + + @override + void dispose() { + if (widget.controller == null) { + _tileController.dispose(); + } + super.dispose(); + } + + Widget? _buildIcon(BuildContext context, Animation animation) { + _iconTurns = animation.drive(_quarterTween.chain(CurveTween(curve: _kAnimationCurve))); + return RotationTransition( + turns: _iconTurns, + child: SizedBox.square( + dimension: CupertinoTheme.of(context).textTheme.textStyle.fontSize, + child: const Center( + child: Icon( + CupertinoIcons.right_chevron, + color: CupertinoColors.activeBlue, + size: _kIconFontSize, + fontWeight: FontWeight.w900, + ), + ), + ), + ); + } + + void _onHeaderTap() { + if (_tileController.isExpanded) { + _tileController.collapse(); + } else { + _tileController.expand(); + } + _fadeController.show(); + } + + Widget _buildHeader(BuildContext context, Animation animation) { + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + final String onTapHint = _tileController.isExpanded + ? localizations.expansionTileExpandedTapHint + : localizations.expansionTileCollapsedTapHint; + String? semanticsHint; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + semanticsHint = _tileController.isExpanded + ? '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}' + : '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}'; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } + return Semantics( + hint: semanticsHint, + onTapHint: onTapHint, + child: CupertinoListTile( + key: _headerKey, + onTap: _onHeaderTap, + title: widget.title, + trailing: _buildIcon(context, animation), + backgroundColorActivated: CupertinoColors.transparent, + ), + ); + } + + Widget _buildExpansible( + BuildContext context, + Widget header, + Widget body, + Animation animation, + ) { + final Widget child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + header, + if (animation.isAnimating && widget.transitionMode == ExpansionTileTransitionMode.fade) + Opacity(opacity: 0.0, child: body) + else + body, + ], + ); + if (widget.transitionMode == ExpansionTileTransitionMode.scroll) { + return child; + } + assert(widget.transitionMode == ExpansionTileTransitionMode.fade); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return OverlayPortal( + controller: _fadeController, + overlayChildBuilder: (BuildContext context) { + final BuildContext headerContext = _headerKey.currentContext!; + final overlay = Overlay.of(headerContext).context.findRenderObject()! as RenderBox; + final headerBox = headerContext.findRenderObject()! as RenderBox; + final Offset headerOffset = headerBox.localToGlobal(Offset.zero, ancestor: overlay); + return Positioned( + top: headerOffset.dy + _kHeaderHeight, + left: headerOffset.dx, + child: ConstrainedBox( + constraints: constraints, + child: Visibility( + visible: animation.isAnimating, + child: FadeTransition(opacity: animation, child: widget.child), + ), + ), + ); + }, + child: child, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Expansible( + controller: _tileController, + duration: _kAnimationDuration, + curve: _kAnimationCurve, + headerBuilder: _buildHeader, + bodyBuilder: (BuildContext context, Animation animation) => widget.child, + expansibleBuilder: _buildExpansible, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/form_row.dart b/packages/cupertino_ui/lib/src/form_row.dart new file mode 100644 index 000000000000..dfe9165ba13d --- /dev/null +++ b/packages/cupertino_ui/lib/src/form_row.dart @@ -0,0 +1,154 @@ +// 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. + +/// @docImport 'form_section.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +// Content padding determined via SwiftUI's `Form` view in the iOS 14.2 SDK. +const EdgeInsetsGeometry _kDefaultPadding = EdgeInsetsDirectional.fromSTEB(20.0, 6.0, 6.0, 6.0); + +/// An iOS-style form row. +/// +/// Creates an iOS-style split form row with a standard prefix and child widget. +/// Also provides a space for error and helper widgets that appear underneath. +/// +/// The [child] parameter is required. This widget is displayed at the end of +/// the row. +/// +/// The [prefix] parameter is optional and is displayed at the start of the +/// row. Standard iOS guidelines encourage passing a [Text] widget to [prefix] +/// to detail the nature of the row's [child] widget. +/// +/// The [padding] parameter is used to pad the contents of the row. It defaults +/// to the standard iOS padding. If no edge insets are intended, explicitly pass +/// [EdgeInsets.zero] to [padding]. +/// +/// The [helper] and [error] parameters are both optional widgets targeted at +/// displaying more information about the row. Both widgets are placed +/// underneath the [prefix] and [child], and will expand the row's height to +/// accommodate for their presence. When a [Text] is given to [error], it will +/// be shown in [CupertinoColors.destructiveRed] coloring and +/// medium-weighted font. +/// +/// {@tool dartpad} +/// Creates a [CupertinoFormSection] containing a [CupertinoFormRow] with [prefix], +/// [child], [helper] and [error] specified. +/// +/// ** See code in examples/api/lib/cupertino/form_row/cupertino_form_row.0.dart ** +/// {@end-tool} +/// +class CupertinoFormRow extends StatelessWidget { + /// Creates an iOS-style split form row with a standard prefix and child widget. + /// Also provides a space for error and helper widgets that appear underneath. + /// + /// The [child] parameter is required. This widget is displayed at the end of + /// the row. + /// + /// The [prefix] parameter is optional and is displayed at the start of the + /// row. Standard iOS guidelines encourage passing a [Text] widget to [prefix] + /// to detail the nature of the row's [child] widget. + /// + /// The [padding] parameter is used to pad the contents of the row. It defaults + /// to the standard iOS padding. If no edge insets are intended, explicitly + /// pass [EdgeInsets.zero] to [padding]. + /// + /// The [helper] and [error] parameters are both optional widgets targeted at + /// displaying more information about the row. Both widgets are placed + /// underneath the [prefix] and [child], and will expand the row's height to + /// accommodate for their presence. When a [Text] is given to [error], it will + /// be shown in [CupertinoColors.destructiveRed] coloring and + /// medium-weighted font. + const CupertinoFormRow({ + super.key, + required this.child, + this.prefix, + this.padding, + this.helper, + this.error, + }); + + /// A widget that is displayed at the start of the row. + /// + /// The [prefix] parameter is displayed at the start of the row. Standard iOS + /// guidelines encourage passing a [Text] widget to [prefix] to detail the + /// nature of the row's [child] widget. If null, the [child] widget will take + /// up all horizontal space in the row. + final Widget? prefix; + + /// Content padding for the row. + /// + /// Defaults to the standard iOS padding for form rows. If no edge insets are + /// intended, explicitly pass [EdgeInsets.zero] to [padding]. + final EdgeInsetsGeometry? padding; + + /// A widget that is displayed underneath the [prefix] and [child] widgets. + /// + /// The [helper] appears in primary label coloring, and is meant to inform the + /// user about interaction with the child widget. The row becomes taller in + /// order to display the [helper] widget underneath [prefix] and [child]. If + /// null, the row is shorter. + final Widget? helper; + + /// A widget that is displayed underneath the [prefix] and [child] widgets. + /// + /// The [error] widget is primarily used to inform users of input errors. When + /// a [Text] is given to [error], it will be shown in + /// [CupertinoColors.destructiveRed] coloring and medium-weighted font. The + /// row becomes taller in order to display the [helper] widget underneath + /// [prefix] and [child]. If null, the row is shorter. + final Widget? error; + + /// Child widget. + /// + /// The [child] widget is primarily used for input. It end-aligned and + /// horizontally flexible, taking up the entire space trailing past the + /// [prefix] widget. + final Widget child; + + @override + Widget build(BuildContext context) { + final CupertinoThemeData theme = CupertinoTheme.of(context); + final TextStyle textStyle = theme.textTheme.textStyle.copyWith( + color: CupertinoDynamicColor.maybeResolve(theme.textTheme.textStyle.color, context), + ); + + return Padding( + padding: padding ?? _kDefaultPadding, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (prefix != null) DefaultTextStyle(style: textStyle, child: prefix!), + Flexible( + child: Align(alignment: AlignmentDirectional.centerEnd, child: child), + ), + ], + ), + if (helper != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: DefaultTextStyle(style: textStyle, child: helper!), + ), + if (error != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: DefaultTextStyle( + style: const TextStyle( + color: CupertinoColors.destructiveRed, + fontWeight: FontWeight.w500, + ), + child: error!, + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/form_section.dart b/packages/cupertino_ui/lib/src/form_section.dart new file mode 100644 index 000000000000..a623a3fa338c --- /dev/null +++ b/packages/cupertino_ui/lib/src/form_section.dart @@ -0,0 +1,249 @@ +// 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. + +/// @docImport 'form_row.dart'; +/// @docImport 'text_form_field_row.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'list_section.dart'; + +// Used for iOS "Inset Grouped" margin, determined from SwiftUI's Forms in +// iOS 14.2 SDK. +const EdgeInsetsDirectional _kFormDefaultInsetGroupedRowsMargin = EdgeInsetsDirectional.fromSTEB( + 20.0, + 0.0, + 20.0, + 10.0, +); + +/// An iOS-style form section. +/// +/// The base constructor for [CupertinoFormSection] constructs an +/// edge-to-edge style section which includes an iOS-style header, rows, +/// the dividers between rows, and borders on top and bottom of the rows. +/// +/// The [CupertinoFormSection.insetGrouped] constructor creates a round-edged and +/// padded section that is commonly seen in notched-displays like iPhone X and +/// beyond. Creates an iOS-style header, rows, and the dividers +/// between rows. Does not create borders on top and bottom of the rows. +/// +/// The [header] parameter sets the form section header. The section header lies +/// above the [children] rows, with margins that match the iOS style. +/// +/// The [footer] parameter sets the form section footer. The section footer +/// lies below the [children] rows. +/// +/// The [children] parameter is required and sets the list of rows shown in +/// the section. The [children] parameter takes a list, as opposed to a more +/// efficient builder function that lazy builds, because forms are intended to +/// be short in row count. It is recommended that only [CupertinoFormRow] and +/// [CupertinoTextFormFieldRow] widgets be included in the [children] list in +/// order to retain the iOS look. +/// +/// The [margin] parameter sets the spacing around the content area of the +/// section encapsulating [children]. +/// +/// The [decoration] parameter sets the decoration around [children]. +/// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. +/// If null, defaults to 10.0 circular radius when constructing with +/// [CupertinoFormSection.insetGrouped]. Defaults to zero radius for the +/// standard [CupertinoFormSection] constructor. +/// +/// The [backgroundColor] parameter sets the background color behind the section. +/// If null, defaults to [CupertinoColors.systemGroupedBackground]. +/// +/// {@macro flutter.material.Material.clipBehavior} +/// +/// See also: +/// +/// * [CupertinoFormRow], an iOS-style list tile, a typical child of +/// [CupertinoFormSection]. +/// * [CupertinoListSection], an iOS-style list section. +class CupertinoFormSection extends StatelessWidget { + /// Creates a section that mimics standard iOS forms. + /// + /// The base constructor for [CupertinoFormSection] constructs an + /// edge-to-edge style section which includes an iOS-style header, + /// rows, the dividers between rows, and borders on top and bottom of the rows. + /// + /// The [header] parameter sets the form section header. The section header + /// lies above the [children] rows, with margins that match the iOS style. + /// + /// The [footer] parameter sets the form section footer. The section footer + /// lies below the [children] rows. + /// + /// The [children] parameter is required and sets the list of rows shown in + /// the section. The [children] parameter takes a list, as opposed to a more + /// efficient builder function that lazy builds, because forms are intended to + /// be short in row count. It is recommended that only [CupertinoFormRow] and + /// [CupertinoTextFormFieldRow] widgets be included in the [children] list in + /// order to retain the iOS look. + /// + /// The [margin] parameter sets the spacing around the content area of the + /// section encapsulating [children], and defaults to zero padding. + /// + /// The [decoration] parameter sets the decoration around [children]. + /// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. + /// If null, defaults to 10.0 circular radius when constructing with + /// [CupertinoFormSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoFormSection] constructor. + /// + /// The [backgroundColor] parameter sets the background color behind the + /// section. If null, defaults to [CupertinoColors.systemGroupedBackground]. + /// + /// {@macro flutter.material.Material.clipBehavior} + const CupertinoFormSection({ + super.key, + required this.children, + this.header, + this.footer, + this.margin = EdgeInsets.zero, + this.backgroundColor = CupertinoColors.systemGroupedBackground, + this.decoration, + this.clipBehavior = Clip.none, + }) : _type = CupertinoListSectionType.base, + assert(children.length > 0); + + /// Creates a section that mimics standard "Inset Grouped" iOS forms. + /// + /// The [CupertinoFormSection.insetGrouped] constructor creates a round-edged and + /// padded section that is commonly seen in notched-displays like iPhone X and + /// beyond. Creates an iOS-style header, rows, and the dividers + /// between rows. Does not create borders on top and bottom of the rows. + /// + /// The [header] parameter sets the form section header. The section header + /// lies above the [children] rows, with margins that match the iOS style. + /// + /// The [footer] parameter sets the form section footer. The section footer + /// lies below the [children] rows. + /// + /// The [children] parameter is required and sets the list of rows shown in + /// the section. The [children] parameter takes a list, as opposed to a more + /// efficient builder function that lazy builds, because forms are intended to + /// be short in row count. It is recommended that only [CupertinoFormRow] and + /// [CupertinoTextFormFieldRow] widgets be included in the [children] list in + /// order to retain the iOS look. + /// + /// The [margin] parameter sets the spacing around the content area of the + /// section encapsulating [children], and defaults to the standard + /// notched-style iOS form padding. + /// + /// The [decoration] parameter sets the decoration around [children]. + /// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. + /// If null, defaults to 10.0 circular radius when constructing with + /// [CupertinoFormSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoFormSection] constructor. + /// + /// The [backgroundColor] parameter sets the background color behind the + /// section. If null, defaults to [CupertinoColors.systemGroupedBackground]. + /// + /// {@macro flutter.material.Material.clipBehavior} + const CupertinoFormSection.insetGrouped({ + super.key, + required this.children, + this.header, + this.footer, + this.margin = _kFormDefaultInsetGroupedRowsMargin, + this.backgroundColor = CupertinoColors.systemGroupedBackground, + this.decoration, + this.clipBehavior = Clip.none, + }) : _type = CupertinoListSectionType.insetGrouped, + assert(children.length > 0); + + final CupertinoListSectionType _type; + + /// Sets the form section header. The section header lies above the + /// [children] rows. + final Widget? header; + + /// Sets the form section footer. The section footer lies below the + /// [children] rows. + final Widget? footer; + + /// Margin around the content area of the section encapsulating [children]. + /// + /// Defaults to zero padding if constructed with standard + /// [CupertinoFormSection] constructor. Defaults to the standard notched-style + /// iOS margin when constructing with [CupertinoFormSection.insetGrouped]. + final EdgeInsetsGeometry margin; + + /// The list of rows in the section. + /// + /// This takes a list, as opposed to a more efficient builder function that + /// lazy builds, because forms are intended to be short in row count. It is + /// recommended that only [CupertinoFormRow] and [CupertinoTextFormFieldRow] + /// widgets be included in the [children] list in order to retain the iOS look. + final List children; + + /// Sets the decoration around [children]. + /// + /// If null, background color defaults to + /// [CupertinoColors.secondarySystemGroupedBackground]. + /// + /// If null, border radius defaults to 10.0 circular radius when constructing + /// with [CupertinoFormSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoFormSection] constructor. + final BoxDecoration? decoration; + + /// Sets the background color behind the section. + /// + /// Defaults to [CupertinoColors.systemGroupedBackground]. + final Color backgroundColor; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final Widget? headerWidget = header == null + ? null + : DefaultTextStyle( + style: TextStyle( + fontSize: 13.0, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + child: header!, + ); + + final Widget? footerWidget = footer == null + ? null + : DefaultTextStyle( + style: TextStyle( + fontSize: 13.0, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + child: footer!, + ); + + switch (_type) { + case CupertinoListSectionType.base: + return CupertinoListSection( + header: headerWidget, + footer: footerWidget, + margin: margin, + backgroundColor: backgroundColor, + decoration: decoration, + clipBehavior: clipBehavior, + hasLeading: false, + children: children, + ); + case CupertinoListSectionType.insetGrouped: + return CupertinoListSection.insetGrouped( + header: headerWidget, + footer: footerWidget, + margin: margin, + backgroundColor: backgroundColor, + decoration: decoration, + clipBehavior: clipBehavior, + hasLeading: false, + children: children, + ); + } + } +} diff --git a/packages/cupertino_ui/lib/src/icon_theme_data.dart b/packages/cupertino_ui/lib/src/icon_theme_data.dart new file mode 100644 index 000000000000..8aa981af4dbd --- /dev/null +++ b/packages/cupertino_ui/lib/src/icon_theme_data.dart @@ -0,0 +1,64 @@ +// 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 'colors.dart'; + +/// An [IconThemeData] subclass that automatically resolves its [color] when retrieved +/// using [IconTheme.of]. +class CupertinoIconThemeData extends IconThemeData with Diagnosticable { + /// Creates a [CupertinoIconThemeData]. + const CupertinoIconThemeData({ + super.size, + super.fill, + super.weight, + super.grade, + super.opticalSize, + super.color, + super.opacity, + super.shadows, + super.applyTextScaling, + }); + + /// Called by [IconTheme.of] to resolve [color] against the given [BuildContext]. + @override + IconThemeData resolve(BuildContext context) { + final Color? resolvedColor = CupertinoDynamicColor.maybeResolve(color, context); + return resolvedColor == color ? this : copyWith(color: resolvedColor); + } + + /// Creates a copy of this icon theme but with the given fields replaced with + /// the new values. + @override + CupertinoIconThemeData copyWith({ + double? size, + double? fill, + double? weight, + double? grade, + double? opticalSize, + Color? color, + double? opacity, + List? shadows, + bool? applyTextScaling, + }) { + return CupertinoIconThemeData( + size: size ?? this.size, + fill: fill ?? this.fill, + weight: weight ?? this.weight, + grade: grade ?? this.grade, + opticalSize: opticalSize ?? this.opticalSize, + color: color ?? this.color, + opacity: opacity ?? this.opacity, + shadows: shadows ?? this.shadows, + applyTextScaling: applyTextScaling ?? this.applyTextScaling, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(createCupertinoColorProperty('color', color, defaultValue: null)); + } +} diff --git a/packages/cupertino_ui/lib/src/icons.dart b/packages/cupertino_ui/lib/src/icons.dart new file mode 100644 index 000000000000..277fe77247a7 --- /dev/null +++ b/packages/cupertino_ui/lib/src/icons.dart @@ -0,0 +1,9806 @@ +// 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/widgets.dart'; + +/// Identifiers for the supported Cupertino icons. +/// +/// Use with the [Icon] class to show specific icons. +/// +/// Icons are identified by their name as listed below. +/// +/// To use this class, make sure you add a dependency on `cupertino_icons` in your +/// project's `pubspec.yaml` file. This ensures that the CupertinoIcons font is +/// included in your application. This font is used to display the icons. For example: +/// +/// ```yaml +/// name: my_awesome_application +/// +/// dependencies: +/// cupertino_icons: ^1.0.0 +/// ``` +/// +/// {@tool snippet} +/// +/// This example shows how to create a [Row] of Cupertino [Icon]s in different colors and +/// sizes. The first [Icon] uses a [Icon.semanticLabel] to announce in accessibility +/// modes like VoiceOver. +/// +/// ![The following code snippet would generate a row of icons consisting of a pink heart, a green bell, and a blue umbrella, each progressively bigger than the last.](https://flutter.github.io/assets-for-api-docs/assets/cupertino/cupertino_icon.png) +/// +/// ```dart +/// const Row( +/// mainAxisAlignment: MainAxisAlignment.spaceAround, +/// children: [ +/// Icon( +/// CupertinoIcons.heart_fill, +/// color: Colors.pink, +/// size: 24.0, +/// semanticLabel: 'Text to announce in accessibility modes', +/// ), +/// Icon( +/// CupertinoIcons.bell_fill, +/// color: Colors.green, +/// size: 30.0, +/// ), +/// Icon( +/// CupertinoIcons.umbrella_fill, +/// color: Colors.blue, +/// size: 36.0, +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// For versions 0.1.3 and below, see this [glyph map](https://raw.githubusercontent.com/flutter/packages/main/third_party/packages/cupertino_icons/map.png). +/// +/// See also: +/// +/// * [Icon], used to show these icons. +@staticIconProvider +abstract final class CupertinoIcons { + /// The icon font used for Cupertino icons. + static const String iconFont = 'CupertinoIcons'; + + /// The dependent package providing the Cupertino icons font. + static const String iconFontPackage = 'cupertino_icons'; + + // =========================================================================== + // BEGIN LEGACY PRE SF SYMBOLS NAMES + // We need to leave them as-is with the same codepoints for backward + // compatibility with cupertino_icons <0.1.3. + + /// chevron_left — Cupertino icon for a thin left chevron. + /// This is the same icon as [chevron_left] in cupertino_icons 1.0.0+. + static const IconData left_chevron = IconData( + 0xf3d2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + matchTextDirection: true, + ); + + /// chevron_right — Cupertino icon for a thin right chevron. + /// This is the same icon as [chevron_right] in cupertino_icons 1.0.0+. + static const IconData right_chevron = IconData( + 0xf3d3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + matchTextDirection: true, + ); + + /// square_arrow_up — Cupertino icon for an iOS style share icon with an arrow pointing up from a box. This icon is not filled in. + /// This is the same icon as [square_arrow_up] and [share_up] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [share_solid], which is similar, but filled in. + /// * [share_up], for another (pre-iOS 7) version of this icon. + static const IconData share = IconData( + 0xf4ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// square_arrow_up_fill — Cupertino icon for an iOS style share icon with an arrow pointing up from a box. This icon is filled in. + /// This is the same icon as [square_arrow_up_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [share], which is similar, but not filled in. + /// * [share_up], for another (pre-iOS 7) version of this icon. + static const IconData share_solid = IconData( + 0xf4cb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// book — Cupertino icon for a book silhouette spread open. This icon is not filled in. + /// See also: + /// + /// * [book_solid], which is similar, but filled in. + static const IconData book = IconData(0xf3e7, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// book_fill — Cupertino icon for a book silhouette spread open. This icon is filled in. + /// This is the same icon as [book_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [book], which is similar, but not filled in. + static const IconData book_solid = IconData( + 0xf3e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// bookmark — Cupertino icon for a book silhouette spread open containing a bookmark in the upper right. This icon is not filled in. + /// + /// See also: + /// + /// * [bookmark_solid], which is similar, but filled in. + static const IconData bookmark = IconData( + 0xf3e9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// bookmark_fill — Cupertino icon for a book silhouette spread open containing a bookmark in the upper right. This icon is filled in. + /// This is the same icon as [bookmark_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [bookmark], which is similar, but not filled in. + static const IconData bookmark_solid = IconData( + 0xf3ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// info_circle — Cupertino icon for a letter 'i' in a circle. + /// This is the same icon as [info_circle] in cupertino_icons 1.0.0+. + static const IconData info = IconData(0xf44c, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// arrowshape_turn_up_left — Cupertino icon for a curved up and left pointing arrow. + /// This is the same icon as [arrowshape_turn_up_left] in cupertino_icons 1.0.0+. + /// + /// For another version of this icon, see [reply_thick_solid]. + static const IconData reply = IconData( + 0xf4c6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// chat_bubble — Cupertino icon for a chat bubble. + /// This is the same icon as [chat_bubble] in cupertino_icons 1.0.0+. + static const IconData conversation_bubble = IconData( + 0xf3fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person_crop_circle — Cupertino icon for a person's silhouette in a circle. + /// This is the same icon as [person_crop_circle] in cupertino_icons 1.0.0+. + static const IconData profile_circled = IconData( + 0xf419, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// plus_circle — Cupertino icon for a '+' sign in a circle. + /// This is the same icon as [plus_circle] in cupertino_icons 1.0.0+. + static const IconData plus_circled = IconData( + 0xf48a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// minus_circle — Cupertino icon for a '-' sign in a circle. + /// This is the same icon as [minus_circle] in cupertino_icons 1.0.0+. + static const IconData minus_circled = IconData( + 0xf463, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// flag — Cupertino icon for a right facing flag and pole outline. + static const IconData flag = IconData(0xf42c, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// search — Cupertino icon for a magnifier loop outline. + static const IconData search = IconData( + 0xf4a5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// checkmark — Cupertino icon for a checkmark. + /// This is the same icon as [checkmark] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [check_mark_circled], which consists of this check mark and a circle surrounding it. + static const IconData check_mark = IconData( + 0xf3fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// checkmark_circle — Cupertino icon for a checkmark in a circle. The circle is not filled in. + /// This is the same icon as [checkmark_circle] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [check_mark_circled_solid], which is similar, but filled in. + /// * [check_mark], which is the check mark without a circle. + static const IconData check_mark_circled = IconData( + 0xf3fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// checkmark_circle_fill — Cupertino icon for a checkmark in a circle. The circle is filled in. + /// This is the same icon as [checkmark_circle_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [check_mark_circled], which is similar, but not filled in. + static const IconData check_mark_circled_solid = IconData( + 0xf3ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// circle — Cupertino icon for an empty circle (a ring). An un-selected radio button. + /// + /// See also: + /// + /// * [circle_filled], which is similar but filled in. + static const IconData circle = IconData( + 0xf401, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// circle_fill — Cupertino icon for a filled circle. The circle is surrounded by a ring. A selected radio button. + /// This is the same icon as [circle_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [circle], which is similar but not filled in. + static const IconData circle_filled = IconData( + 0xf400, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// chevron_back — Cupertino icon for a thicker left chevron used in iOS for the navigation bar back button. + /// This is the same icon as [chevron_back] in cupertino_icons 1.0.0+. + static const IconData back = IconData( + 0xf3cf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + matchTextDirection: true, + ); + + /// chevron_forward — Cupertino icon for a thicker right chevron that's the reverse of [back]. + /// This is the same icon as [chevron_forward] in cupertino_icons 1.0.0+. + static const IconData forward = IconData( + 0xf3d1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + matchTextDirection: true, + ); + + /// house — Cupertino icon for an outline of a simple front-facing house. + /// This is the same icon as [house] in cupertino_icons 1.0.0+. + static const IconData home = IconData(0xf447, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// cart — Cupertino icon for a right-facing shopping cart outline. + /// This is the same icon as [cart] in cupertino_icons 1.0.0+. + static const IconData shopping_cart = IconData( + 0xf3f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// ellipsis — Cupertino icon for three solid dots. + static const IconData ellipsis = IconData( + 0xf46a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// phone — Cupertino icon for a phone handset outline. + static const IconData phone = IconData( + 0xf4b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// phone_fill — Cupertino icon for a phone handset. + /// This is the same icon as [phone_fill] in cupertino_icons 1.0.0+. + static const IconData phone_solid = IconData( + 0xf4b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_down — Cupertino icon for a solid down arrow. + /// This is the same icon as [arrow_down] in cupertino_icons 1.0.0+. + static const IconData down_arrow = IconData( + 0xf35d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_up — Cupertino icon for a solid up arrow. + /// This is the same icon as [arrow_up] in cupertino_icons 1.0.0+. + static const IconData up_arrow = IconData( + 0xf366, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// battery_100 — Cupertino icon for a charging battery. + /// This is the same icon as [battery_100], [battery_full] and [battery_75_percent] in cupertino_icons 1.0.0+. + static const IconData battery_charging = IconData( + 0xf111, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// battery_0 — Cupertino icon for an empty battery. + /// This is the same icon as [battery_0] in cupertino_icons 1.0.0+. + static const IconData battery_empty = IconData( + 0xf112, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// battery_100 — Cupertino icon for a full battery. + /// This is the same icon as [battery_100], [battery_charging] and [battery_75_percent] in cupertino_icons 1.0.0+. + static const IconData battery_full = IconData( + 0xf113, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// battery_100 — Cupertino icon for a 75% charged battery. + /// This is the same icon as [battery_100], [battery_charging] and [battery_full] in cupertino_icons 1.0.0+. + static const IconData battery_75_percent = IconData( + 0xf114, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// battery_25 — Cupertino icon for a 25% charged battery. + /// This is the same icon as [battery_25] in cupertino_icons 1.0.0+. + static const IconData battery_25_percent = IconData( + 0xf115, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// bluetooth — Cupertino icon for the Bluetooth logo. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + static const IconData bluetooth = IconData( + 0xf116, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_counterclockwise — Cupertino icon for a restart arrow, pointing downwards. + /// This is the same icon as [arrow_counterclockwise] in cupertino_icons 1.0.0+. + static const IconData restart = IconData( + 0xf21c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrowshape_turn_up_left_2 — Cupertino icon for two curved up and left pointing arrows. + /// This is the same icon as [arrowshape_turn_up_left_2] in cupertino_icons 1.0.0+. + static const IconData reply_all = IconData( + 0xf21d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrowshape_turn_up_left_2_fill — Cupertino icon for a curved up and left pointing arrow. + /// This is the same icon as [arrowshape_turn_up_left_2_fill] in cupertino_icons 1.0.0+. + /// + /// For another version of this icon, see [reply]. + static const IconData reply_thick_solid = IconData( + 0xf21e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// square_arrow_up — Cupertino icon for an iOS style share icon with an arrow pointing upwards to the right from a box. + /// This is the same icon as [square_arrow_up] and [share_up] in cupertino_icons 1.0.0+. + /// + /// For another version of this icon (introduced in iOS 7), see [share]. + static const IconData share_up = IconData( + 0xf220, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// shuffle_medium — Cupertino icon for two thin right-facing intertwined arrows. + /// This is the same icon as [shuffle_medium] and [shuffle_thick] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [shuffle_medium], with slightly thicker arrows. + /// * [shuffle_thick], with thicker, bold arrows. + static const IconData shuffle = IconData( + 0xf4a9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// shuffle — Cupertino icon for an two medium thickness right-facing intertwined arrows. + /// This is the same icon as [shuffle] and [shuffle_thick] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [shuffle], with thin arrows. + /// * [shuffle_thick], with thicker, bold arrows. + static const IconData shuffle_medium = IconData( + 0xf4a8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// shuffle_medium — Cupertino icon for two thick right-facing intertwined arrows. + /// This is the same icon as [shuffle_medium] and [shuffle] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [shuffle], with thin arrows. + /// * [shuffle_medium], with slightly thinner arrows. + static const IconData shuffle_thick = IconData( + 0xf221, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// camera — Cupertino icon for a camera for still photographs. This icon is filled in. + /// This is the same icon as [camera] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [photo_camera], which is similar, but not filled in. + /// * [video_camera_solid], for the moving picture equivalent. + static const IconData photo_camera = IconData( + 0xf3f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// camera_fill — Cupertino icon for a camera for still photographs. This icon is not filled in. + /// This is the same icon as [camera_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [photo_camera_solid], which is similar, but filled in. + /// * [video_camera], for the moving picture equivalent. + static const IconData photo_camera_solid = IconData( + 0xf3f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// videocam — Cupertino icon for a camera for moving pictures. This icon is not filled in. + /// This is the same icon as [videocam] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [video_camera_solid], which is similar, but filled in. + /// * [photo_camera], for the still photograph equivalent. + static const IconData video_camera = IconData( + 0xf4cc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// videocam_fill — Cupertino icon for a camera for moving pictures. This icon is filled in. + /// This is the same icon as [videocam_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [video_camera], which is similar, but not filled in. + /// * [photo_camera_solid], for the still photograph equivalent. + static const IconData video_camera_solid = IconData( + 0xf4cd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// camera_rotate — Cupertino icon for a camera containing two circular arrows pointing at each other, which indicate switching. This icon is not filled in. + /// This is the same icon as [camera_rotate] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [switch_camera_solid], which is similar, but filled in. + static const IconData switch_camera = IconData( + 0xf49e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// camera_rotate_fill — Cupertino icon for a camera containing two circular arrows pointing at each other, which indicate switching. This icon is filled in. + /// This is the same icon as [camera_rotate_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [switch_camera], which is similar, but not filled in. + static const IconData switch_camera_solid = IconData( + 0xf49f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// rectangle_stack — Cupertino icon for a collection of folders, which store collections of files, i.e. an album. This icon is not filled in. + /// This is the same icon as [rectangle_stack] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [collections_solid], which is similar, but filled in. + static const IconData collections = IconData( + 0xf3c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// rectangle_stack_fill — Cupertino icon for a collection of folders, which store collections of files, i.e. an album. This icon is filled in. + /// This is the same icon as [rectangle_stack_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [collections], which is similar, but not filled in. + static const IconData collections_solid = IconData( + 0xf3ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// folder_open — Cupertino icon for a single folder, which stores multiple files. This icon is not filled in. + /// This is the same icon as [folder_open] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [folder_solid], which is similar, but filled in. + /// * [folder_open], which is the pre-iOS 7 version of this icon. + static const IconData folder = IconData( + 0xf434, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// folder_fill — Cupertino icon for a single folder, which stores multiple files. This icon is filled in. + /// This is the same icon as [folder_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [folder], which is similar, but not filled in. + /// * [folder_open], which is the pre-iOS 7 version of this icon and not filled in. + static const IconData folder_solid = IconData( + 0xf435, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// folder — Cupertino icon for a single folder that indicates being opened. A folder like this typically stores multiple files. + /// This is the same icon as [folder] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [folder], which is the equivalent of this icon for iOS versions later than or equal to iOS 7. + static const IconData folder_open = IconData( + 0xf38a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// trash — Cupertino icon for a trash bin for removing items. This icon is not filled in. + /// This is the same icon as [trash] and [delete_simple] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [delete_solid], which is similar, but filled in. + static const IconData delete = IconData( + 0xf4c4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// trash_fill — Cupertino icon for a trash bin for removing items. This icon is filled in. + /// This is the same icon as [trash_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [delete], which is similar, but not filled in. + static const IconData delete_solid = IconData( + 0xf4c5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// trash — Cupertino icon for a trash bin with minimal detail for removing items. + /// This is the same icon as [trash] and [delete] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [delete], which is the iOS 7 equivalent of this icon with richer detail. + static const IconData delete_simple = IconData( + 0xf37f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// pen — Cupertino icon for a simple pen. + /// + /// See also: + /// + /// * [pencil], which is similar, but has less detail and looks like a pencil. + static const IconData pen = IconData(0xf2bf, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// pencil — Cupertino icon for a simple pencil. + /// + /// See also: + /// + /// * [pen], which is similar, but has more detail and looks like a pen. + static const IconData pencil = IconData( + 0xf37e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// square_pencil — Cupertino icon for a box for writing and a pen on top (that indicates the writing). This icon is not filled in. + /// This is the same icon as [square_pencil] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [create_solid], which is similar, but filled in. + /// * [pencil], which is just a pencil. + /// * [pen], which is just a pen. + static const IconData create = IconData( + 0xf417, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// square_pencil_fill — Cupertino icon for a box for writing and a pen on top (that indicates the writing). This icon is filled in. + /// This is the same icon as [square_pencil_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [create], which is similar, but not filled in. + /// * [pencil], which is just a pencil. + /// * [pen], which is just a pen. + static const IconData create_solid = IconData( + 0xf417, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_clockwise — Cupertino icon for an arrow on a circular path with its end pointing at its start. + /// This is the same icon as [arrow_clockwise], [refresh_thin] and [refresh_thick] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [refresh_circled], which is this icon put in a circle. + /// * [refresh_thin], which is an arrow of the same concept, but thinner and with a smaller gap in between its end and start. + /// * [refresh_thick], which is similar, but rotated 45 degrees clockwise and thicker. + /// * [refresh_bold], which is similar, but rotated 90 degrees clockwise and much thicker. + static const IconData refresh = IconData( + 0xf49a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_clockwise_circle — Cupertino icon for an arrow on a circular path with its end pointing at its start surrounded by a circle. This is icon is not filled in. + /// This is the same icon as [arrow_clockwise_circle] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [refresh_circled_solid], which is similar, but filled in. + /// * [refresh], which is the arrow of this icon without a circle. + static const IconData refresh_circled = IconData( + 0xf49b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_clockwise_circle_fill — Cupertino icon for an arrow on a circular path with its end pointing at its start surrounded by a circle. This is icon is filled in. + /// This is the same icon as [arrow_clockwise_circle_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [refresh_circled], which is similar, but not filled in. + /// * [refresh], which is the arrow of this icon filled in without a circle. + static const IconData refresh_circled_solid = IconData( + 0xf49c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_clockwise — Cupertino icon for an arrow on a circular path with its end pointing at its start. + /// This is the same icon as [arrow_clockwise], [refresh] and [refresh_thick] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [refresh], which is an arrow of the same concept, but thicker and with a larger gap in between its end and start. + static const IconData refresh_thin = IconData( + 0xf49d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_clockwise — Cupertino icon for an arrow on a circular path with its end pointing at its start. + /// This is the same icon as [arrow_clockwise], [refresh_thin] and [refresh] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [refresh], which is similar, but rotated 45 degrees anti-clockwise and thinner. + /// * [refresh_bold], which is similar, but rotated 45 degrees clockwise and thicker. + static const IconData refresh_thick = IconData( + 0xf3a8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_counterclockwise — Cupertino icon for an arrow on a circular path with its end pointing at its start. + /// This is the same icon as [arrow_counterclockwise] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [refresh_thick], which is similar, but rotated 45 degrees anti-clockwise and thinner. + /// * [refresh], which is similar, but rotated 90 degrees anti-clockwise and much thinner. + static const IconData refresh_bold = IconData( + 0xf21c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// xmark — Cupertino icon for a cross of two diagonal lines from edge to edge crossing in an angle of 90 degrees, which is used for dismissal. + /// This is the same icon as [xmark] and [clear] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [clear_circled], which uses this cross as a blank space in a filled out circled. + /// * [clear], which uses a thinner cross and is the iOS 7 equivalent of this icon. + static const IconData clear_thick = IconData( + 0xf2d7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// xmark_circle_fill — Cupertino icon for a cross of two diagonal lines from edge to edge crossing in an angle of 90 degrees, which is used for dismissal, used as a blank space in a circle. + /// This is the same icon as [xmark_circle_fill] and [clear_circled_solid] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [clear], which is equivalent to the cross of this icon without a circle. + /// * [clear_circled_solid], which is similar, but uses a thinner cross. + static const IconData clear_thick_circled = IconData( + 0xf36e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// xmark — Cupertino icon for a cross of two diagonal lines from edge to edge crossing in an angle of 90 degrees, which is used for dismissal. + /// This is the same icon as [xmark] and [clear_thick] in cupertino_icons 1.0.0+. + /// + /// + /// See also: + /// + /// * [clear_circled], which consists of this cross and a circle surrounding it. + /// * [clear], which uses a thicker cross and is the pre-iOS 7 equivalent of this icon. + static const IconData clear = IconData( + 0xf404, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// xmark_circle — Cupertino icon for a cross of two diagonal lines from edge to edge crossing in an angle of 90 degrees, which is used for dismissal, surrounded by circle. This icon is not filled in. + /// This is the same icon as [xmark_circle] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [clear_circled_solid], which is similar, but filled in. + /// * [clear], which is the standalone cross of this icon. + static const IconData clear_circled = IconData( + 0xf405, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// xmark_circle_fill — Cupertino icon for a cross of two diagonal lines from edge to edge crossing in an angle of 90 degrees, which is used for dismissal, used as a blank space in a circle. This icon is filled in. + /// This is the same icon as [xmark_circle_fill] and [clear_thick_circled] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [clear_circled], which is similar, but not filled in. + static const IconData clear_circled_solid = IconData( + 0xf406, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// plus — Cupertino icon for an two straight lines, one horizontal and one vertical, meeting in the middle, which is the equivalent of a plus sign. + /// This is the same icon as [plus] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [plus_circled], which is the pre-iOS 7 version of this icon with a thicker cross. + /// * [add_circled], which consists of the plus and a circle around it. + static const IconData add = IconData(0xf489, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// plus_circle — Cupertino icon for an two straight lines, one horizontal and one vertical, meeting in the middle, which is the equivalent of a plus sign, surrounded by a circle. This icon is not filled in. + /// This is the same icon as [plus_circle] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [plus_circled], which is the pre-iOS 7 version of this icon with a thicker cross and a filled in circle. + /// * [add_circled_solid], which is similar, but filled in. + static const IconData add_circled = IconData( + 0xf48a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// plus_circle_fill — Cupertino icon for an two straight lines, one horizontal and one vertical, meeting in the middle, which is the equivalent of a plus sign, surrounded by a circle. This icon is not filled in. + /// This is the same icon as [plus_circle_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [plus_circled], which is the pre-iOS 7 version of this icon with a thicker cross. + /// * [add_circled], which is similar, but not filled in. + static const IconData add_circled_solid = IconData( + 0xf48b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// gear_alt — Cupertino icon for a gear with eight cogs. This icon is not filled in. + /// This is the same icon as [gear_alt] and [gear_big] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [gear_solid], which is similar, but filled in. + /// * [gear_big], which is the pre-iOS 7 version of this icon and appears bigger because of fewer and bigger cogs. + /// * [settings], which is another cogwheel with a different design. + static const IconData gear = IconData(0xf43c, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// gear_alt_fill — Cupertino icon for a gear with eight cogs. This icon is filled in. + /// This is the same icon as [gear_alt_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [gear], which is similar, but not filled in. + /// * [settings_solid], which is another cogwheel with a different design. + static const IconData gear_solid = IconData( + 0xf43d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// gear_alt — Cupertino icon for a gear with six cogs. + /// This is the same icon as [gear_alt] and [gear] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [gear], which is the iOS 7 version of this icon and appears smaller because of more and larger cogs. + /// * [settings_solid], which is another cogwheel with a different design. + static const IconData gear_big = IconData( + 0xf2f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// settings — Cupertino icon for a cogwheel with many cogs and decoration in the middle. This icon is not filled in. + /// + /// See also: + /// + /// * [settings_solid], which is similar, but filled in. + /// * [gear], which is another cogwheel with a different design. + static const IconData settings = IconData( + 0xf411, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// settings_solid — Cupertino icon for a cogwheel with many cogs and decoration in the middle. This icon is filled in. + /// + /// See also: + /// + /// * [settings], which is similar, but not filled in. + /// * [gear_solid], which is another cogwheel with a different design. + static const IconData settings_solid = IconData( + 0xf412, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// music_note — Cupertino icon for a symbol representing a solid single musical note. + /// + /// See also: + /// + /// * [double_music_note], which is similar, but with 2 connected notes. + static const IconData music_note = IconData( + 0xf46b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// music_note_2 — Cupertino icon for a symbol representing 2 connected musical notes. + /// This is the same icon as [music_note_2] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [music_note], which is similar, but with a single note. + static const IconData double_music_note = IconData( + 0xf46c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// play — Cupertino icon for a triangle facing to the right. This icon is not filled in. + /// This is the same icon as [play] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [play_arrow_solid], which is similar, but filled in. + static const IconData play_arrow = IconData( + 0xf487, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// play_fill — Cupertino icon for a triangle facing to the right. This icon is filled in. + /// This is the same icon as [play_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [play_arrow], which is similar, but not filled in. + static const IconData play_arrow_solid = IconData( + 0xf488, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// pause — Cupertino icon for an two vertical rectangles. This icon is not filled in. + /// + /// See also: + /// + /// * [pause_solid], which is similar, but filled in. + static const IconData pause = IconData( + 0xf477, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// pause_fill — Cupertino icon for an two vertical rectangles. This icon is filled in. + /// This is the same icon as [pause_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [pause], which is similar, but not filled in. + static const IconData pause_solid = IconData( + 0xf478, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// infinite — Cupertino icon for the infinity symbol. + /// This is the same icon as [infinite] and [loop_thick] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [loop_thick], which is similar, but thicker. + static const IconData loop = IconData(0xf449, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// infinite — Cupertino icon for the infinity symbol. + /// This is the same icon as [infinite] and [loop] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [loop], which is similar, but thinner. + static const IconData loop_thick = IconData( + 0xf44a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// speaker_1_fill — Cupertino icon for a speaker with a single small sound wave. + /// This is the same icon as [speaker_1_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [volume_mute], which is similar, but has no sound waves. + /// * [volume_off], which is similar, but with an additional larger sound wave and a diagonal line crossing the whole icon. + /// * [volume_up], which has an additional larger sound wave next to the small one. + static const IconData volume_down = IconData( + 0xf3b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// speaker_fill — Cupertino icon for a speaker symbol. + /// This is the same icon as [speaker_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [volume_down], which is similar, but adds a small sound wave. + /// * [volume_off], which is similar, but adds a small and a large sound wave and a diagonal line crossing the whole icon. + /// * [volume_up], which is similar, but has a small and a large sound wave. + static const IconData volume_mute = IconData( + 0xf3b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// speaker_slash_fill — Cupertino icon for a speaker with a small and a large sound wave and a diagonal line crossing the whole icon. + /// This is the same icon as [speaker_slash_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [volume_down], which is similar, but not crossed out and only has the small wave. + /// * [volume_mute], which is similar, but not crossed out. + /// * [volume_up], which is the version of this icon that is not crossed out. + static const IconData volume_off = IconData( + 0xf3b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// speaker_3_fill — Cupertino icon for a speaker with a small and a large sound wave. + /// This is the same icon as [speaker_3_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [volume_down], which is similar, but only has the small sound wave. + /// * [volume_mute], which is similar, but has no sound waves. + /// * [volume_off], which is the crossed out version of this icon. + static const IconData volume_up = IconData( + 0xf3ba, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_up_left_arrow_down_right — Cupertino icon for all four corners of a square facing inwards. + /// This is the same icon as [arrow_up_left_arrow_down_right] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [fullscreen_exit], which is similar, but has the corners facing outwards. + static const IconData fullscreen = IconData( + 0xf386, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// arrow_down_right_arrow_up_left — Cupertino icon for all four corners of a square facing outwards. + /// This is the same icon as [arrow_down_right_arrow_up_left] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [fullscreen], which is similar, but has the corners facing inwards. + static const IconData fullscreen_exit = IconData( + 0xf37d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// mic_slash — Cupertino icon for a filled in microphone with a diagonal line crossing it. + /// This is the same icon as [mic_slash] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [mic], which is similar, but not filled in and without a diagonal line. + /// * [mic_solid], which is similar, but without a diagonal line. + static const IconData mic_off = IconData( + 0xf45f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// mic — Cupertino icon for a microphone. + /// + /// See also: + /// + /// * [mic_solid], which is similar, but filled in. + /// * [mic_off], which is similar, but filled in and with a diagonal line crossing the icon. + static const IconData mic = IconData(0xf460, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// mic_fill — Cupertino icon for a filled in microphone. + /// This is the same icon as [mic_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [mic], which is similar, but not filled in. + /// * [mic_off], which is similar, but with a diagonal line crossing the icon. + static const IconData mic_solid = IconData( + 0xf461, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// time — Cupertino icon for a circle with a dotted clock face inside with hands showing 10:30. + /// This is the same icon as [time] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [clock_solid], which is similar, but filled in. + /// * [time], which is similar, but without dots on the clock face. + /// * [time_solid], which is similar, but filled in and without dots on the clock face. + static const IconData clock = IconData( + 0xf4be, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// clock_fill — Cupertino icon for a filled in circle with a dotted clock face inside with hands showing 10:30. + /// This is the same icon as [clock_fill] and [time_solid] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [clock], which is similar, but not filled in. + /// * [time], which is similar, but not filled in and without dots on the clock face. + /// * [time_solid], which is similar, but without dots on the clock face. + static const IconData clock_solid = IconData( + 0xf4bf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// clock — Cupertino icon for a circle with a 90 degree angle shape in the center, resembling a clock with hands showing 09:00. + /// This is the same icon as [clock] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [time_solid], which is similar, but filled in. + /// * [clock], which is similar, but with dots on the clock face. + /// * [clock_solid], which is similar, but filled in and with dots on the clock face. + static const IconData time = IconData(0xf402, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// clock_fill — Cupertino icon for a filled in circle with a 90 degree angle shape in the center, resembling a clock with hands showing 09:00. + /// This is the same icon as [clock_fill] and [clock_solid] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [time], which is similar, but not filled in. + /// * [clock], which is similar, but not filled in and with dots on the clock face. + /// * [clock_solid], which is similar, but with dots on the clock face. + static const IconData time_solid = IconData( + 0xf403, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// lock — Cupertino icon for an unlocked padlock. + /// This is the same icon as [lock] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [padlock_solid], which is similar, but filled in. + static const IconData padlock = IconData( + 0xf4c8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// lock_fill — Cupertino icon for an unlocked padlock. + /// This is the same icon as [lock_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [padlock], which is similar, but not filled in. + static const IconData padlock_solid = IconData( + 0xf4c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// eye — Cupertino icon for an open eye. + /// + /// See also: + /// + /// * [eye_solid], which is similar, but filled in. + static const IconData eye = IconData(0xf424, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// eye_fill — Cupertino icon for an open eye. + /// This is the same icon as [eye_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [eye], which is similar, but not filled in. + static const IconData eye_solid = IconData( + 0xf425, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person — Cupertino icon for a single person. This icon is not filled in. + /// + /// See also: + /// + /// * [person_solid], which is similar, but filled in. + /// * [person_add], which has an additional plus sign next to the person. + /// * [group], which consists of three people. + static const IconData person = IconData( + 0xf47d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person_fill — Cupertino icon for a single person. This icon is filled in. + /// This is the same icon as [person_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [person], which is similar, but not filled in. + /// * [person_add_solid], which has an additional plus sign next to the person. + /// * [group_solid], which consists of three people. + static const IconData person_solid = IconData( + 0xf47e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person_badge_plus — Cupertino icon for a single person with a plus sign next to it. This icon is not filled in. + /// This is the same icon as [person_badge_plus] in cupertino_icons 1.0.0+.x + /// + /// See also: + /// + /// * [person_add_solid], which is similar, but filled in. + /// * [person], which is just the person. + /// * [group], which consists of three people. + static const IconData person_add = IconData( + 0xf47f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person_badge_plus_fill — Cupertino icon for a single person with a plus sign next to it. This icon is filled in. + /// This is the same icon as [person_badge_plus_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [person_add], which is similar, but not filled in. + /// * [person_solid], which is just the person. + /// * [group_solid], which consists of three people. + static const IconData person_add_solid = IconData( + 0xf480, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person_3 — Cupertino icon for a group of three people. This icon is not filled in. + /// This is the same icon as [person_3] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [group_solid], which is similar, but filled in. + /// * [person], which is just a single person. + static const IconData group = IconData( + 0xf47b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// person_3_fill — Cupertino icon for a group of three people. This icon is filled in. + /// This is the same icon as [person_3_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [group], which is similar, but not filled in. + /// * [person_solid], which is just a single person. + static const IconData group_solid = IconData( + 0xf47c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// envelope — Cupertino icon for the outline of a closed mail envelope. + /// This is the same icon as [envelope] in cupertino_icons 1.0.0+. + static const IconData mail = IconData(0xf422, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// envelope_fill — Cupertino icon for a closed mail envelope. This icon is filled in. + /// This is the same icon as [envelope_fill] in cupertino_icons 1.0.0+. + static const IconData mail_solid = IconData( + 0xf423, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// location — Cupertino icon for a location pin. + static const IconData location = IconData( + 0xf6ee, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// placemark_fill — Cupertino icon for a location pin. This icon is filled in. + /// This is the same icon as [placemark_fill] in cupertino_icons 1.0.0+. + static const IconData location_solid = IconData( + 0xf456, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// tags — Cupertino icon for the outline of a sticker tag. + /// This is the same icon as [tags] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [tags], similar but with 2 overlapping tags. + static const IconData tag = IconData(0xf48c, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// tag_fill — Cupertino icon for a sticker tag. This icon is filled in. + /// This is the same icon as [tag_fill] and [tags_solid] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [tags_solid], similar but with 2 overlapping tags. + static const IconData tag_solid = IconData( + 0xf48d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// tag — Cupertino icon for outlines of 2 overlapping sticker tags. + /// This is the same icon as [tag] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [tag], similar but with only one tag. + static const IconData tags = IconData(0xf48e, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// tag_fill — Cupertino icon for 2 overlapping sticker tags. This icon is filled in. + /// This is the same icon as [tag_fill] and [tag_solid] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [tag_solid], similar but with only one tag. + static const IconData tags_solid = IconData( + 0xf48f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// bus — Cupertino icon for a filled in bus. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + static const IconData bus = IconData(0xf36d, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// car_fill — Cupertino icon for a filled in car. + /// This is the same icon as [car_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [car_detailed], similar, but a more detailed and realistic representation. + static const IconData car = IconData(0xf36f, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// car_detailed — Cupertino icon for a filled in detailed, realistic car. + /// + /// See also: + /// + /// * [car], similar, but a more simple representation. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + static const IconData car_detailed = IconData( + 0xf2c1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// train_style_one — Cupertino icon for a filled in train with a window divided in half and two headlights. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + /// + /// See also: + /// + /// * [train_style_two], similar, but with a full, undivided window and a single, centered headlight. + static const IconData train_style_one = IconData( + 0xf3af, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// train_style_two — Cupertino icon for a filled in train with a window and a single, centered headlight. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + /// + /// See also: + /// + /// * [train_style_one], similar, but with a with a window divided in half and two headlights. + static const IconData train_style_two = IconData( + 0xf3b4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// paw — Cupertino icon for an outlined paw. + /// + /// See also: + /// + /// * [paw_solid], similar, but filled in. + static const IconData paw = IconData(0xf479, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// paw — Cupertino icon for a filled in paw. + /// This is the same icon as [paw] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [paw], similar, but not filled in. + static const IconData paw_solid = IconData( + 0xf47a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// gamecontroller — Cupertino icon for an outlined game controller. + /// This is the same icon as [gamecontroller] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [game_controller_solid], similar, but filled in. + static const IconData game_controller = IconData( + 0xf43a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// gamecontroller_fill — Cupertino icon for a filled in game controller. + /// This is the same icon as [gamecontroller_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [game_controller], similar, but not filled in. + static const IconData game_controller_solid = IconData( + 0xf43b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// lab_flask — Cupertino icon for an outlined lab flask. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + /// + /// See also: + /// + /// * [lab_flask_solid], similar, but filled in. + static const IconData lab_flask = IconData( + 0xf430, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// lab_flask_solid — Cupertino icon for a filled in lab flask. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + /// + /// See also: + /// + /// * [lab_flask], similar, but not filled in. + static const IconData lab_flask_solid = IconData( + 0xf431, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// heart — Cupertino icon for an outlined heart shape. Can be used to indicate like or favorite states. + /// + /// See also: + /// + /// * [heart_solid], same shape, but filled in. + static const IconData heart = IconData( + 0xf442, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// heart_solid — Cupertino icon for a filled heart shape. Can be used to indicate like or favorite states. + /// This is the same icon as [heart_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [heart], same shape, but not filled in. + static const IconData heart_solid = IconData( + 0xf443, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// bell — Cupertino icon for an outlined bell. Can be used to represent notifications. + /// + /// See also: + /// + /// * [bell_solid], same shape, but filled in. + static const IconData bell = IconData(0xf3e1, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// bell_fill — Cupertino icon for a filled bell. Can be used represent notifications. + /// This is the same icon as [bell_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [bell], same shape, but not filled in. + static const IconData bell_solid = IconData( + 0xf3e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// news — Cupertino icon for an outlined folded newspaper icon. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + /// + /// See also: + /// + /// * [news_solid], same shape, but filled in. + static const IconData news = IconData(0xf471, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// news_solid — Cupertino icon for a filled folded newspaper icon. + /// This icon is available in cupertino_icons 1.0.0+ for backward + /// compatibility but not part of Apple icons' aesthetics. + /// + /// See also: + /// + /// * [news], same shape, but not filled in. + static const IconData news_solid = IconData( + 0xf472, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// sun_max — Cupertino icon for an outlined brightness icon. + /// This is the same icon as [sun_max] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [brightness_solid], same shape, but filled in. + static const IconData brightness = IconData( + 0xf4B6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// sun_max_fill — Cupertino icon for a filled in brightness icon. + /// This is the same icon as [sun_max_fill] in cupertino_icons 1.0.0+. + /// + /// See also: + /// + /// * [brightness], same shape, but not filled in. + static const IconData brightness_solid = IconData( + 0xf4B7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + // END LEGACY PRE SF SYMBOLS NAMES + // =========================================================================== + + // =========================================================================== + // BEGIN GENERATED SF SYMBOLS NAMES + /// — Cupertino icon named "airplane". Available on cupertino_icons package 1.0.0+ only. + static const IconData airplane = IconData( + 0xf4d4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "alarm". Available on cupertino_icons package 1.0.0+ only. + static const IconData alarm = IconData( + 0xf4d5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "alarm_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData alarm_fill = IconData( + 0xf4d6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData alt = IconData(0xf4d7, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "ant". Available on cupertino_icons package 1.0.0+ only. + static const IconData ant = IconData(0xf4d8, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "ant_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData ant_circle = IconData( + 0xf4d9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ant_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData ant_circle_fill = IconData( + 0xf4da, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ant_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData ant_fill = IconData( + 0xf4db, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "antenna_radiowaves_left_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData antenna_radiowaves_left_right = IconData( + 0xf4dc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "app". Available on cupertino_icons package 1.0.0+ only. + static const IconData app = IconData(0xf4dd, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "app_badge". Available on cupertino_icons package 1.0.0+ only. + static const IconData app_badge = IconData( + 0xf4de, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "app_badge_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData app_badge_fill = IconData( + 0xf4df, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "app_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData app_fill = IconData( + 0xf4e0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "archivebox". Available on cupertino_icons package 1.0.0+ only. + static const IconData archivebox = IconData( + 0xf4e1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "archivebox_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData archivebox_fill = IconData( + 0xf4e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_2_circlepath". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_2_circlepath = IconData( + 0xf4e3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_2_circlepath_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_2_circlepath_circle = IconData( + 0xf4e4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_2_circlepath_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_2_circlepath_circle_fill = IconData( + 0xf4e5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_2_squarepath". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_2_squarepath = IconData( + 0xf4e6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_3_trianglepath". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_3_trianglepath = IconData( + 0xf4e7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_branch". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_branch = IconData( + 0xf4e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_clockwise". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [refresh] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [refresh_thin] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [refresh_thick] which is available in cupertino_icons 0.1.3. + static const IconData arrow_clockwise = IconData( + 0xf49a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_clockwise_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [refresh_circled] which is available in cupertino_icons 0.1.3. + static const IconData arrow_clockwise_circle = IconData( + 0xf49b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_clockwise_circle_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [refresh_circled_solid] which is available in cupertino_icons 0.1.3. + static const IconData arrow_clockwise_circle_fill = IconData( + 0xf49c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_counterclockwise". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [restart] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [refresh_bold] which is available in cupertino_icons 0.1.3. + static const IconData arrow_counterclockwise = IconData( + 0xf21c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_counterclockwise_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_counterclockwise_circle = IconData( + 0xf4e9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_counterclockwise_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_counterclockwise_circle_fill = IconData( + 0xf4ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [down_arrow] which is available in cupertino_icons 0.1.3. + static const IconData arrow_down = IconData( + 0xf35d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_circle = IconData( + 0xf4eb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_circle_fill = IconData( + 0xf4ec, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_doc". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_doc = IconData( + 0xf4ed, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_doc_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_doc_fill = IconData( + 0xf4ee, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_left = IconData( + 0xf4ef, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_left_circle = IconData( + 0xf4f0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_left_circle_fill = IconData( + 0xf4f1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_left_square = IconData( + 0xf4f2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_left_square_fill = IconData( + 0xf4f3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_right = IconData( + 0xf4f4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_right_arrow_up_left". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [fullscreen_exit] which is available in cupertino_icons 0.1.3. + static const IconData arrow_down_right_arrow_up_left = IconData( + 0xf37d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_right_circle = IconData( + 0xf4f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_right_circle_fill = IconData( + 0xf4f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_right_square = IconData( + 0xf4f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_right_square_fill = IconData( + 0xf4f8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_square = IconData( + 0xf4f9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_square_fill = IconData( + 0xf4fa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_to_line". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_to_line = IconData( + 0xf4fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_down_to_line_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_down_to_line_alt = IconData( + 0xf4fc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left = IconData( + 0xf4fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_circle = IconData( + 0xf4fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_circle_fill = IconData( + 0xf4ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_right = IconData( + 0xf500, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_right_circle = IconData( + 0xf501, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_right_circle_fill = IconData( + 0xf502, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_right_square = IconData( + 0xf503, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_right_square_fill = IconData( + 0xf504, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_square = IconData( + 0xf505, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_square_fill = IconData( + 0xf506, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_to_line". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_to_line = IconData( + 0xf507, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_left_to_line_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_left_to_line_alt = IconData( + 0xf508, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_merge". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_merge = IconData( + 0xf509, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right = IconData( + 0xf50a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_arrow_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_arrow_left = IconData( + 0xf50b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_arrow_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_arrow_left_circle = IconData( + 0xf50c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_arrow_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_arrow_left_circle_fill = IconData( + 0xf50d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_arrow_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_arrow_left_square = IconData( + 0xf50e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_arrow_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_arrow_left_square_fill = IconData( + 0xf50f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_circle = IconData( + 0xf510, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_circle_fill = IconData( + 0xf511, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_square = IconData( + 0xf512, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_square_fill = IconData( + 0xf513, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_to_line". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_to_line = IconData( + 0xf514, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_right_to_line_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_right_to_line_alt = IconData( + 0xf515, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_swap". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_swap = IconData( + 0xf516, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_down_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_down_left = IconData( + 0xf517, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_down_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_down_right = IconData( + 0xf518, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_left_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_left_down = IconData( + 0xf519, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_left_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_left_up = IconData( + 0xf51a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_right_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_right_down = IconData( + 0xf51b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_right_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_right_up = IconData( + 0xf51c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_up_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_up_left = IconData( + 0xf51d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_turn_up_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_turn_up_right = IconData( + 0xf51e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [up_arrow] which is available in cupertino_icons 0.1.3. + static const IconData arrow_up = IconData( + 0xf366, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_arrow_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_arrow_down = IconData( + 0xf51f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_arrow_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_arrow_down_circle = IconData( + 0xf520, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_arrow_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_arrow_down_circle_fill = IconData( + 0xf521, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_arrow_down_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_arrow_down_square = IconData( + 0xf522, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_arrow_down_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_arrow_down_square_fill = IconData( + 0xf523, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_bin". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_bin = IconData( + 0xf524, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_bin_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_bin_fill = IconData( + 0xf525, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_circle = IconData( + 0xf526, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_circle_fill = IconData( + 0xf527, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_doc". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_doc = IconData( + 0xf528, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_doc_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_doc_fill = IconData( + 0xf529, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_down = IconData( + 0xf52a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_down_circle = IconData( + 0xf52b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_down_circle_fill = IconData( + 0xf52c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_down_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_down_square = IconData( + 0xf52d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_down_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_down_square_fill = IconData( + 0xf52e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_left = IconData( + 0xf52f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_left_arrow_down_right". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [fullscreen] which is available in cupertino_icons 0.1.3. + static const IconData arrow_up_left_arrow_down_right = IconData( + 0xf386, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_left_circle = IconData( + 0xf530, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_left_circle_fill = IconData( + 0xf531, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_left_square = IconData( + 0xf532, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_left_square_fill = IconData( + 0xf533, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right = IconData( + 0xf534, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right_circle = IconData( + 0xf535, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right_circle_fill = IconData( + 0xf536, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right_diamond". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right_diamond = IconData( + 0xf537, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right_diamond_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right_diamond_fill = IconData( + 0xf538, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right_square = IconData( + 0xf539, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_right_square_fill = IconData( + 0xf53a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_square = IconData( + 0xf53b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_square_fill = IconData( + 0xf53c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_to_line". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_to_line = IconData( + 0xf53d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_up_to_line_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_up_to_line_alt = IconData( + 0xf53e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_down = IconData( + 0xf53f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_down_circle = IconData( + 0xf540, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_down_circle_fill = IconData( + 0xf541, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_down_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_down_square = IconData( + 0xf542, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_down_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_down_square_fill = IconData( + 0xf543, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_left = IconData( + 0xf544, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_left_circle = IconData( + 0xf545, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_left_circle_fill = IconData( + 0xf546, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_left_square = IconData( + 0xf547, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_left_square_fill = IconData( + 0xf548, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_right = IconData( + 0xf549, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_right_circle = IconData( + 0xf54a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_right_circle_fill = IconData( + 0xf54b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_right_square = IconData( + 0xf54c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_right_square_fill = IconData( + 0xf54d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_up = IconData( + 0xf54e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_up_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_up_circle = IconData( + 0xf54f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_up_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_up_circle_fill = IconData( + 0xf550, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_up_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_up_square = IconData( + 0xf551, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrow_uturn_up_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrow_uturn_up_square_fill = IconData( + 0xf552, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_left". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [reply] which is available in cupertino_icons 0.1.3. + static const IconData arrowshape_turn_up_left = IconData( + 0xf4c6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_left_2". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [reply_all] which is available in cupertino_icons 0.1.3. + static const IconData arrowshape_turn_up_left_2 = IconData( + 0xf21d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_left_2_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [reply_thick_solid] which is available in cupertino_icons 0.1.3. + static const IconData arrowshape_turn_up_left_2_fill = IconData( + 0xf21e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_left_circle = IconData( + 0xf553, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_left_circle_fill = IconData( + 0xf554, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_left_fill = IconData( + 0xf555, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_right = IconData( + 0xf556, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_right_circle = IconData( + 0xf557, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_right_circle_fill = IconData( + 0xf558, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowshape_turn_up_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowshape_turn_up_right_fill = IconData( + 0xf559, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_down = IconData( + 0xf55a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_down_circle = IconData( + 0xf55b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_down_circle_fill = IconData( + 0xf55c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_down_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_down_fill = IconData( + 0xf55d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_down_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_down_square = IconData( + 0xf55e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_down_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_down_square_fill = IconData( + 0xf55f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_left = IconData( + 0xf560, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_left_circle = IconData( + 0xf561, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_left_circle_fill = IconData( + 0xf562, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_left_fill = IconData( + 0xf563, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_left_square = IconData( + 0xf564, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_left_square_fill = IconData( + 0xf565, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_right = IconData( + 0xf566, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_right_circle = IconData( + 0xf567, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_right_circle_fill = IconData( + 0xf568, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_right_fill = IconData( + 0xf569, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_right_square = IconData( + 0xf56a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_right_square_fill = IconData( + 0xf56b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_up = IconData( + 0xf56c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_up_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_up_circle = IconData( + 0xf56d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_up_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_up_circle_fill = IconData( + 0xf56e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_up_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_up_fill = IconData( + 0xf56f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_up_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_up_square = IconData( + 0xf570, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "arrowtriangle_up_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData arrowtriangle_up_square_fill = IconData( + 0xf571, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "asterisk_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData asterisk_circle = IconData( + 0xf572, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "asterisk_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData asterisk_circle_fill = IconData( + 0xf573, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "at". Available on cupertino_icons package 1.0.0+ only. + static const IconData at = IconData(0xf574, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "at_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData at_badge_minus = IconData( + 0xf575, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "at_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData at_badge_plus = IconData( + 0xf576, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "at_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData at_circle = IconData( + 0xf8af, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "at_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData at_circle_fill = IconData( + 0xf8b0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "backward". Available on cupertino_icons package 1.0.0+ only. + static const IconData backward = IconData( + 0xf577, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "backward_end". Available on cupertino_icons package 1.0.0+ only. + static const IconData backward_end = IconData( + 0xf578, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "backward_end_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData backward_end_alt = IconData( + 0xf579, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "backward_end_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData backward_end_alt_fill = IconData( + 0xf57a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "backward_end_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData backward_end_fill = IconData( + 0xf57b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "backward_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData backward_fill = IconData( + 0xf57c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "badge_plus_radiowaves_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData badge_plus_radiowaves_right = IconData( + 0xf57d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bag". Available on cupertino_icons package 1.0.0+ only. + static const IconData bag = IconData(0xf57e, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "bag_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData bag_badge_minus = IconData( + 0xf57f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bag_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData bag_badge_plus = IconData( + 0xf580, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bag_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bag_fill = IconData( + 0xf581, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bag_fill_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData bag_fill_badge_minus = IconData( + 0xf582, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bag_fill_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData bag_fill_badge_plus = IconData( + 0xf583, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bandage". Available on cupertino_icons package 1.0.0+ only. + static const IconData bandage = IconData( + 0xf584, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bandage_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bandage_fill = IconData( + 0xf585, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "barcode". Available on cupertino_icons package 1.0.0+ only. + static const IconData barcode = IconData( + 0xf586, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "barcode_viewfinder". Available on cupertino_icons package 1.0.0+ only. + static const IconData barcode_viewfinder = IconData( + 0xf587, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bars". Available on cupertino_icons package 1.0.0+ only. + static const IconData bars = IconData(0xf8b1, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "battery_0". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [battery_empty] which is available in cupertino_icons 0.1.3. + static const IconData battery_0 = IconData( + 0xf112, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "battery_100". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [battery_charging] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [battery_full] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [battery_75_percent] which is available in cupertino_icons 0.1.3. + static const IconData battery_100 = IconData( + 0xf113, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "battery_25". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [battery_25_percent] which is available in cupertino_icons 0.1.3. + static const IconData battery_25 = IconData( + 0xf115, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bed_double". Available on cupertino_icons package 1.0.0+ only. + static const IconData bed_double = IconData( + 0xf588, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bed_double_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bed_double_fill = IconData( + 0xf589, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bell_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData bell_circle = IconData( + 0xf58a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bell_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bell_circle_fill = IconData( + 0xf58b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bell_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [bell_solid] which is available in cupertino_icons 0.1.3. + static const IconData bell_fill = IconData( + 0xf3e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bell_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData bell_slash = IconData( + 0xf58c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bell_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bell_slash_fill = IconData( + 0xf58d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bin_xmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData bin_xmark = IconData( + 0xf58e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bin_xmark_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bin_xmark_fill = IconData( + 0xf58f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bitcoin". Available on cupertino_icons package 1.0.0+ only. + static const IconData bitcoin = IconData( + 0xf8b2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bitcoin_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData bitcoin_circle = IconData( + 0xf8b3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bitcoin_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bitcoin_circle_fill = IconData( + 0xf8b4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bold". Available on cupertino_icons package 1.0.0+ only. + static const IconData bold = IconData(0xf590, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "bold_italic_underline". Available on cupertino_icons package 1.0.0+ only. + static const IconData bold_italic_underline = IconData( + 0xf591, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bold_underline". Available on cupertino_icons package 1.0.0+ only. + static const IconData bold_underline = IconData( + 0xf592, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt = IconData(0xf593, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "bolt_badge_a". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_badge_a = IconData( + 0xf594, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_badge_a_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_badge_a_fill = IconData( + 0xf595, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_circle = IconData( + 0xf596, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_circle_fill = IconData( + 0xf597, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_fill = IconData( + 0xf598, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_horizontal". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_horizontal = IconData( + 0xf599, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_horizontal_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_horizontal_circle = IconData( + 0xf59a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_horizontal_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_horizontal_circle_fill = IconData( + 0xf59b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_horizontal_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_horizontal_fill = IconData( + 0xf59c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_slash = IconData( + 0xf59d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bolt_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bolt_slash_fill = IconData( + 0xf59e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "book_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData book_circle = IconData( + 0xf59f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "book_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData book_circle_fill = IconData( + 0xf5a0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "book_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [book_solid] which is available in cupertino_icons 0.1.3. + static const IconData book_fill = IconData( + 0xf3e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bookmark_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [bookmark_solid] which is available in cupertino_icons 0.1.3. + static const IconData bookmark_fill = IconData( + 0xf3ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "briefcase". Available on cupertino_icons package 1.0.0+ only. + static const IconData briefcase = IconData( + 0xf5a1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "briefcase_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData briefcase_fill = IconData( + 0xf5a2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_left = IconData( + 0xf5a3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_left_bubble_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_left_bubble_right = IconData( + 0xf5a4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_left_bubble_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_left_bubble_right_fill = IconData( + 0xf5a5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_left_fill = IconData( + 0xf5a6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_middle_bottom". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_middle_bottom = IconData( + 0xf5a7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_middle_bottom_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_middle_bottom_fill = IconData( + 0xf5a8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_middle_top". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_middle_top = IconData( + 0xf5a9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_middle_top_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_middle_top_fill = IconData( + 0xf5aa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_right = IconData( + 0xf5ab, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "bubble_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData bubble_right_fill = IconData( + 0xf5ac, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "building_2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData building_2_fill = IconData( + 0xf8b5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "burn". Available on cupertino_icons package 1.0.0+ only. + static const IconData burn = IconData(0xf5ad, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "burst". Available on cupertino_icons package 1.0.0+ only. + static const IconData burst = IconData( + 0xf5ae, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "burst_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData burst_fill = IconData( + 0xf5af, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "calendar". Available on cupertino_icons package 1.0.0+ only. + static const IconData calendar = IconData( + 0xf5b0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "calendar_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData calendar_badge_minus = IconData( + 0xf5b1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "calendar_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData calendar_badge_plus = IconData( + 0xf5b2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "calendar_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData calendar_circle = IconData( + 0xf5b3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "calendar_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData calendar_circle_fill = IconData( + 0xf5b4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "calendar_today". Available on cupertino_icons package 1.0.0+ only. + static const IconData calendar_today = IconData( + 0xf8b6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [photo_camera] which is available in cupertino_icons 0.1.3. + static const IconData camera = IconData( + 0xf3f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData camera_circle = IconData( + 0xf5b5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData camera_circle_fill = IconData( + 0xf5b6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [photo_camera_solid] which is available in cupertino_icons 0.1.3. + static const IconData camera_fill = IconData( + 0xf3f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_on_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData camera_on_rectangle = IconData( + 0xf5b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_on_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData camera_on_rectangle_fill = IconData( + 0xf5b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_rotate". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [switch_camera] which is available in cupertino_icons 0.1.3. + static const IconData camera_rotate = IconData( + 0xf49e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_rotate_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [switch_camera_solid] which is available in cupertino_icons 0.1.3. + static const IconData camera_rotate_fill = IconData( + 0xf49f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "camera_viewfinder". Available on cupertino_icons package 1.0.0+ only. + static const IconData camera_viewfinder = IconData( + 0xf5b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "capslock". Available on cupertino_icons package 1.0.0+ only. + static const IconData capslock = IconData( + 0xf5ba, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "capslock_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData capslock_fill = IconData( + 0xf5bb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "capsule". Available on cupertino_icons package 1.0.0+ only. + static const IconData capsule = IconData( + 0xf5bc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "capsule_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData capsule_fill = IconData( + 0xf5bd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "captions_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData captions_bubble = IconData( + 0xf5be, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "captions_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData captions_bubble_fill = IconData( + 0xf5bf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "car_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [car] which is available in cupertino_icons 0.1.3. + static const IconData car_fill = IconData( + 0xf36f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cart". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [shopping_cart] which is available in cupertino_icons 0.1.3. + static const IconData cart = IconData(0xf3f7, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "cart_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData cart_badge_minus = IconData( + 0xf5c0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cart_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData cart_badge_plus = IconData( + 0xf5c1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cart_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cart_fill = IconData( + 0xf5c2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cart_fill_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData cart_fill_badge_minus = IconData( + 0xf5c3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cart_fill_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData cart_fill_badge_plus = IconData( + 0xf5c4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar = IconData( + 0xf5c5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar_alt_fill = IconData( + 0xf8b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar_circle = IconData( + 0xf8b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar_circle_fill = IconData( + 0xf8b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar_fill = IconData( + 0xf5c6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar_square = IconData( + 0xf8ba, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_bar_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_bar_square_fill = IconData( + 0xf8bb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_pie". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_pie = IconData( + 0xf5c7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chart_pie_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chart_pie_fill = IconData( + 0xf5c8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chat_bubble". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [conversation_bubble] which is available in cupertino_icons 0.1.3. + static const IconData chat_bubble = IconData( + 0xf3fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chat_bubble_2". Available on cupertino_icons package 1.0.0+ only. + static const IconData chat_bubble_2 = IconData( + 0xf8bc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chat_bubble_2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chat_bubble_2_fill = IconData( + 0xf8bd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chat_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chat_bubble_fill = IconData( + 0xf8be, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chat_bubble_text". Available on cupertino_icons package 1.0.0+ only. + static const IconData chat_bubble_text = IconData( + 0xf8bf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chat_bubble_text_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chat_bubble_text_fill = IconData( + 0xf8c0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [check_mark] which is available in cupertino_icons 0.1.3. + static const IconData checkmark = IconData( + 0xf3fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_alt = IconData( + 0xf8c1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_alt_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_alt_circle = IconData( + 0xf8c2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_alt_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_alt_circle_fill = IconData( + 0xf8c3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [check_mark_circled] which is available in cupertino_icons 0.1.3. + static const IconData checkmark_circle = IconData( + 0xf3fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_circle_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [check_mark_circled_solid] which is available in cupertino_icons 0.1.3. + static const IconData checkmark_circle_fill = IconData( + 0xf3ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_rectangle = IconData( + 0xf5c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_rectangle_fill = IconData( + 0xf5ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_seal". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_seal = IconData( + 0xf5cb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_seal_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_seal_fill = IconData( + 0xf5cc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_shield". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_shield = IconData( + 0xf5cd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_shield_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_shield_fill = IconData( + 0xf5ce, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_square = IconData( + 0xf5cf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "checkmark_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData checkmark_square_fill = IconData( + 0xf5d0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_back". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [back] which is available in cupertino_icons 0.1.3. + static const IconData chevron_back = IconData( + 0xf3cf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_compact_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_compact_down = IconData( + 0xf5d1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_compact_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_compact_left = IconData( + 0xf5d2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_compact_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_compact_right = IconData( + 0xf5d3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_compact_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_compact_up = IconData( + 0xf5d4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_down = IconData( + 0xf5d5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_down_circle = IconData( + 0xf5d6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_down_circle_fill = IconData( + 0xf5d7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_down_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_down_square = IconData( + 0xf5d8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_down_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_down_square_fill = IconData( + 0xf5d9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_forward". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [forward] which is available in cupertino_icons 0.1.3. + static const IconData chevron_forward = IconData( + 0xf3d1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [left_chevron] which is available in cupertino_icons 0.1.3. + static const IconData chevron_left = IconData( + 0xf3d2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left_2". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_left_2 = IconData( + 0xf5da, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_left_circle = IconData( + 0xf5db, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_left_circle_fill = IconData( + 0xf5dc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left_slash_chevron_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_left_slash_chevron_right = IconData( + 0xf5dd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_left_square = IconData( + 0xf5de, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_left_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_left_square_fill = IconData( + 0xf5df, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_right". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [right_chevron] which is available in cupertino_icons 0.1.3. + static const IconData chevron_right = IconData( + 0xf3d3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_right_2". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_right_2 = IconData( + 0xf5e0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_right_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_right_circle = IconData( + 0xf5e1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_right_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_right_circle_fill = IconData( + 0xf5e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_right_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_right_square = IconData( + 0xf5e3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_right_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_right_square_fill = IconData( + 0xf5e4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_up = IconData( + 0xf5e5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_up_chevron_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_up_chevron_down = IconData( + 0xf5e6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_up_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_up_circle = IconData( + 0xf5e7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_up_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_up_circle_fill = IconData( + 0xf5e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_up_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_up_square = IconData( + 0xf5e9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "chevron_up_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData chevron_up_square_fill = IconData( + 0xf5ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_bottomthird_split". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_bottomthird_split = IconData( + 0xf5eb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [circle_filled] which is available in cupertino_icons 0.1.3. + static const IconData circle_fill = IconData( + 0xf400, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_grid_3x3". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_grid_3x3 = IconData( + 0xf5ec, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_grid_3x3_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_grid_3x3_fill = IconData( + 0xf5ed, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_grid_hex". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_grid_hex = IconData( + 0xf5ee, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_grid_hex_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_grid_hex_fill = IconData( + 0xf5ef, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_lefthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_lefthalf_fill = IconData( + 0xf5f0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "circle_righthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData circle_righthalf_fill = IconData( + 0xf5f1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "clear_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData clear_fill = IconData( + 0xf5f3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "clock_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [clock_solid] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [time_solid] which is available in cupertino_icons 0.1.3. + static const IconData clock_fill = IconData( + 0xf403, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud = IconData( + 0xf5f4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_bolt". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_bolt = IconData( + 0xf5f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_bolt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_bolt_fill = IconData( + 0xf5f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_bolt_rain". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_bolt_rain = IconData( + 0xf5f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_bolt_rain_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_bolt_rain_fill = IconData( + 0xf5f8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_download". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_download = IconData( + 0xf8c4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_download_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_download_fill = IconData( + 0xf8c5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_drizzle". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_drizzle = IconData( + 0xf5f9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_drizzle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_drizzle_fill = IconData( + 0xf5fa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_fill = IconData( + 0xf5fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_fog". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_fog = IconData( + 0xf5fc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_fog_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_fog_fill = IconData( + 0xf5fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_hail". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_hail = IconData( + 0xf5fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_hail_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_hail_fill = IconData( + 0xf5ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_heavyrain". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_heavyrain = IconData( + 0xf600, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_heavyrain_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_heavyrain_fill = IconData( + 0xf601, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_moon". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_moon = IconData( + 0xf602, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_moon_bolt". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_moon_bolt = IconData( + 0xf603, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_moon_bolt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_moon_bolt_fill = IconData( + 0xf604, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_moon_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_moon_fill = IconData( + 0xf605, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_moon_rain". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_moon_rain = IconData( + 0xf606, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_moon_rain_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_moon_rain_fill = IconData( + 0xf607, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_rain". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_rain = IconData( + 0xf608, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_rain_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_rain_fill = IconData( + 0xf609, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sleet". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sleet = IconData( + 0xf60a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sleet_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sleet_fill = IconData( + 0xf60b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_snow". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_snow = IconData( + 0xf60c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_snow_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_snow_fill = IconData( + 0xf60d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sun". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sun = IconData( + 0xf60e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sun_bolt". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sun_bolt = IconData( + 0xf60f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sun_bolt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sun_bolt_fill = IconData( + 0xf610, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sun_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sun_fill = IconData( + 0xf611, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sun_rain". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sun_rain = IconData( + 0xf612, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_sun_rain_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_sun_rain_fill = IconData( + 0xf613, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_upload". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_upload = IconData( + 0xf8c6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cloud_upload_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cloud_upload_fill = IconData( + 0xf8c7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "color_filter". Available on cupertino_icons package 1.0.0+ only. + static const IconData color_filter = IconData( + 0xf8c8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "color_filter_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData color_filter_fill = IconData( + 0xf8c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "command". Available on cupertino_icons package 1.0.0+ only. + static const IconData command = IconData( + 0xf614, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "compass". Available on cupertino_icons package 1.0.0+ only. + static const IconData compass = IconData( + 0xf8ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "compass_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData compass_fill = IconData( + 0xf8cb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "control". Available on cupertino_icons package 1.0.0+ only. + static const IconData control = IconData( + 0xf615, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "creditcard". Available on cupertino_icons package 1.0.0+ only. + static const IconData creditcard = IconData( + 0xf616, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "creditcard_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData creditcard_fill = IconData( + 0xf617, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "crop". Available on cupertino_icons package 1.0.0+ only. + static const IconData crop = IconData(0xf618, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "crop_rotate". Available on cupertino_icons package 1.0.0+ only. + static const IconData crop_rotate = IconData( + 0xf619, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cube". Available on cupertino_icons package 1.0.0+ only. + static const IconData cube = IconData(0xf61a, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "cube_box". Available on cupertino_icons package 1.0.0+ only. + static const IconData cube_box = IconData( + 0xf61b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cube_box_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cube_box_fill = IconData( + 0xf61c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cube_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData cube_fill = IconData( + 0xf61d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "cursor_rays". Available on cupertino_icons package 1.0.0+ only. + static const IconData cursor_rays = IconData( + 0xf61e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "decrease_indent". Available on cupertino_icons package 1.0.0+ only. + static const IconData decrease_indent = IconData( + 0xf61f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "decrease_quotelevel". Available on cupertino_icons package 1.0.0+ only. + static const IconData decrease_quotelevel = IconData( + 0xf620, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "delete_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData delete_left = IconData( + 0xf621, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "delete_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData delete_left_fill = IconData( + 0xf622, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "delete_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData delete_right = IconData( + 0xf623, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "delete_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData delete_right_fill = IconData( + 0xf624, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "desktopcomputer". Available on cupertino_icons package 1.0.0+ only. + static const IconData desktopcomputer = IconData( + 0xf625, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "device_desktop". Available on cupertino_icons package 1.0.0+ only. + static const IconData device_desktop = IconData( + 0xf8cc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "device_laptop". Available on cupertino_icons package 1.0.0+ only. + static const IconData device_laptop = IconData( + 0xf8cd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "device_phone_landscape". Available on cupertino_icons package 1.0.0+ only. + static const IconData device_phone_landscape = IconData( + 0xf8ce, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "device_phone_portrait". Available on cupertino_icons package 1.0.0+ only. + static const IconData device_phone_portrait = IconData( + 0xf8cf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "dial". Available on cupertino_icons package 1.0.0+ only. + static const IconData dial = IconData(0xf626, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "dial_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData dial_fill = IconData( + 0xf627, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "divide". Available on cupertino_icons package 1.0.0+ only. + static const IconData divide = IconData( + 0xf628, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "divide_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData divide_circle = IconData( + 0xf629, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "divide_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData divide_circle_fill = IconData( + 0xf62a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "divide_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData divide_square = IconData( + 0xf62b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "divide_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData divide_square_fill = IconData( + 0xf62c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc = IconData(0xf62d, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "doc_append". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_append = IconData( + 0xf62e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_chart". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_chart = IconData( + 0xf8d0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_chart_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_chart_fill = IconData( + 0xf8d1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_checkmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_checkmark = IconData( + 0xf8d2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_checkmark_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_checkmark_fill = IconData( + 0xf8d3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_circle = IconData( + 0xf62f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_circle_fill = IconData( + 0xf630, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_fill = IconData( + 0xf631, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_on_clipboard". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_on_clipboard = IconData( + 0xf632, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_on_clipboard_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_on_clipboard_fill = IconData( + 0xf633, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_on_doc". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_on_doc = IconData( + 0xf634, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_on_doc_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_on_doc_fill = IconData( + 0xf635, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_person". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_person = IconData( + 0xf8d4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_person_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_person_fill = IconData( + 0xf8d5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_plaintext". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_plaintext = IconData( + 0xf636, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_richtext". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_richtext = IconData( + 0xf637, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_text". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_text = IconData( + 0xf638, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_text_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_text_fill = IconData( + 0xf639, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_text_search". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_text_search = IconData( + 0xf63a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "doc_text_viewfinder". Available on cupertino_icons package 1.0.0+ only. + static const IconData doc_text_viewfinder = IconData( + 0xf63b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "dot_radiowaves_left_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData dot_radiowaves_left_right = IconData( + 0xf63c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "dot_radiowaves_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData dot_radiowaves_right = IconData( + 0xf63d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "dot_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData dot_square = IconData( + 0xf63e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "dot_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData dot_square_fill = IconData( + 0xf63f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "download_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData download_circle = IconData( + 0xf8d6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "download_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData download_circle_fill = IconData( + 0xf8d7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "drop". Available on cupertino_icons package 1.0.0+ only. + static const IconData drop = IconData(0xf8d8, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "drop_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData drop_fill = IconData( + 0xf8d9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "drop_triangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData drop_triangle = IconData( + 0xf640, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "drop_triangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData drop_triangle_fill = IconData( + 0xf641, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ear". Available on cupertino_icons package 1.0.0+ only. + static const IconData ear = IconData(0xf642, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "eject". Available on cupertino_icons package 1.0.0+ only. + static const IconData eject = IconData( + 0xf643, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eject_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData eject_fill = IconData( + 0xf644, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipses_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipses_bubble = IconData( + 0xf645, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipses_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipses_bubble_fill = IconData( + 0xf646, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipsis_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipsis_circle = IconData( + 0xf647, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipsis_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipsis_circle_fill = IconData( + 0xf648, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipsis_vertical". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipsis_vertical = IconData( + 0xf8da, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipsis_vertical_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipsis_vertical_circle = IconData( + 0xf8db, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ellipsis_vertical_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData ellipsis_vertical_circle_fill = IconData( + 0xf8dc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [mail] which is available in cupertino_icons 0.1.3. + static const IconData envelope = IconData( + 0xf422, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_badge". Available on cupertino_icons package 1.0.0+ only. + static const IconData envelope_badge = IconData( + 0xf649, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_badge_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData envelope_badge_fill = IconData( + 0xf64a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData envelope_circle = IconData( + 0xf64b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData envelope_circle_fill = IconData( + 0xf64c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [mail_solid] which is available in cupertino_icons 0.1.3. + static const IconData envelope_fill = IconData( + 0xf423, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_open". Available on cupertino_icons package 1.0.0+ only. + static const IconData envelope_open = IconData( + 0xf64d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "envelope_open_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData envelope_open_fill = IconData( + 0xf64e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "equal". Available on cupertino_icons package 1.0.0+ only. + static const IconData equal = IconData( + 0xf64f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "equal_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData equal_circle = IconData( + 0xf650, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "equal_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData equal_circle_fill = IconData( + 0xf651, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "equal_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData equal_square = IconData( + 0xf652, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "equal_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData equal_square_fill = IconData( + 0xf653, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "escape". Available on cupertino_icons package 1.0.0+ only. + static const IconData escape = IconData( + 0xf654, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark = IconData( + 0xf655, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_bubble = IconData( + 0xf656, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_bubble_fill = IconData( + 0xf657, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_circle = IconData( + 0xf658, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_circle_fill = IconData( + 0xf659, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_octagon". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_octagon = IconData( + 0xf65a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_octagon_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_octagon_fill = IconData( + 0xf65b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_shield". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_shield = IconData( + 0xf65c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_shield_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_shield_fill = IconData( + 0xf65d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_square = IconData( + 0xf65e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_square_fill = IconData( + 0xf65f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_triangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_triangle = IconData( + 0xf660, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "exclamationmark_triangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData exclamationmark_triangle_fill = IconData( + 0xf661, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eye_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [eye_solid] which is available in cupertino_icons 0.1.3. + static const IconData eye_fill = IconData( + 0xf425, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eye_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData eye_slash = IconData( + 0xf662, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eye_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData eye_slash_fill = IconData( + 0xf663, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eyedropper". Available on cupertino_icons package 1.0.0+ only. + static const IconData eyedropper = IconData( + 0xf664, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eyedropper_full". Available on cupertino_icons package 1.0.0+ only. + static const IconData eyedropper_full = IconData( + 0xf665, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eyedropper_halffull". Available on cupertino_icons package 1.0.0+ only. + static const IconData eyedropper_halffull = IconData( + 0xf666, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "eyeglasses". Available on cupertino_icons package 1.0.0+ only. + static const IconData eyeglasses = IconData( + 0xf667, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "f_cursive". Available on cupertino_icons package 1.0.0+ only. + static const IconData f_cursive = IconData( + 0xf668, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "f_cursive_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData f_cursive_circle = IconData( + 0xf669, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "f_cursive_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData f_cursive_circle_fill = IconData( + 0xf66a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "film". Available on cupertino_icons package 1.0.0+ only. + static const IconData film = IconData(0xf66b, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "film_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData film_fill = IconData( + 0xf66c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flag_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData flag_circle = IconData( + 0xf66d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flag_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData flag_circle_fill = IconData( + 0xf66e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flag_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData flag_fill = IconData( + 0xf66f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flag_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData flag_slash = IconData( + 0xf670, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flag_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData flag_slash_fill = IconData( + 0xf671, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flame". Available on cupertino_icons package 1.0.0+ only. + static const IconData flame = IconData( + 0xf672, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flame_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData flame_fill = IconData( + 0xf673, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "floppy_disk". Available on cupertino_icons package 1.0.0+ only. + static const IconData floppy_disk = IconData( + 0xf8dd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flowchart". Available on cupertino_icons package 1.0.0+ only. + static const IconData flowchart = IconData( + 0xf674, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "flowchart_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData flowchart_fill = IconData( + 0xf675, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_badge_minus = IconData( + 0xf676, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_badge_person_crop". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_badge_person_crop = IconData( + 0xf677, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_badge_plus = IconData( + 0xf678, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_circle = IconData( + 0xf679, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_circle_fill = IconData( + 0xf67a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [folder_solid] which is available in cupertino_icons 0.1.3. + static const IconData folder_fill = IconData( + 0xf435, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_fill_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_fill_badge_minus = IconData( + 0xf67b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_fill_badge_person_crop". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_fill_badge_person_crop = IconData( + 0xf67c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "folder_fill_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData folder_fill_badge_plus = IconData( + 0xf67d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "forward_end". Available on cupertino_icons package 1.0.0+ only. + static const IconData forward_end = IconData( + 0xf67f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "forward_end_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData forward_end_alt = IconData( + 0xf680, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "forward_end_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData forward_end_alt_fill = IconData( + 0xf681, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "forward_end_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData forward_end_fill = IconData( + 0xf682, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "forward_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData forward_fill = IconData( + 0xf683, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "function". Available on cupertino_icons package 1.0.0+ only. + static const IconData function = IconData( + 0xf684, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "fx". Available on cupertino_icons package 1.0.0+ only. + static const IconData fx = IconData(0xf685, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "gamecontroller". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [game_controller] which is available in cupertino_icons 0.1.3. + static const IconData gamecontroller = IconData( + 0xf43a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gamecontroller_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData gamecontroller_alt_fill = IconData( + 0xf8de, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gamecontroller_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [game_controller_solid] which is available in cupertino_icons 0.1.3. + static const IconData gamecontroller_fill = IconData( + 0xf43b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gauge". Available on cupertino_icons package 1.0.0+ only. + static const IconData gauge = IconData( + 0xf686, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gauge_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData gauge_badge_minus = IconData( + 0xf687, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gauge_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData gauge_badge_plus = IconData( + 0xf688, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gear_alt". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [gear] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [gear_big] which is available in cupertino_icons 0.1.3. + static const IconData gear_alt = IconData( + 0xf43c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gear_alt_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [gear_solid] which is available in cupertino_icons 0.1.3. + static const IconData gear_alt_fill = IconData( + 0xf43d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gift". Available on cupertino_icons package 1.0.0+ only. + static const IconData gift = IconData(0xf689, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "gift_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData gift_alt = IconData( + 0xf68a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gift_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData gift_alt_fill = IconData( + 0xf68b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gift_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData gift_fill = IconData( + 0xf68c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "globe". Available on cupertino_icons package 1.0.0+ only. + static const IconData globe = IconData( + 0xf68d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward = IconData( + 0xf68e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_10". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_10 = IconData( + 0xf68f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_15". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_15 = IconData( + 0xf690, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_30". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_30 = IconData( + 0xf691, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_45". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_45 = IconData( + 0xf692, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_60". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_60 = IconData( + 0xf693, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_75". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_75 = IconData( + 0xf694, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_90". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_90 = IconData( + 0xf695, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "gobackward_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData gobackward_minus = IconData( + 0xf696, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward = IconData( + 0xf697, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_10". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_10 = IconData( + 0xf698, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_15". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_15 = IconData( + 0xf699, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_30". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_30 = IconData( + 0xf69a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_45". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_45 = IconData( + 0xf69b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_60". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_60 = IconData( + 0xf69c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_75". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_75 = IconData( + 0xf69d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_90". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_90 = IconData( + 0xf69e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "goforward_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData goforward_plus = IconData( + 0xf69f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "graph_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData graph_circle = IconData( + 0xf8df, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "graph_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData graph_circle_fill = IconData( + 0xf8e0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "graph_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData graph_square = IconData( + 0xf8e1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "graph_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData graph_square_fill = IconData( + 0xf8e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "greaterthan". Available on cupertino_icons package 1.0.0+ only. + static const IconData greaterthan = IconData( + 0xf6a0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "greaterthan_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData greaterthan_circle = IconData( + 0xf6a1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "greaterthan_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData greaterthan_circle_fill = IconData( + 0xf6a2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "greaterthan_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData greaterthan_square = IconData( + 0xf6a3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "greaterthan_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData greaterthan_square_fill = IconData( + 0xf6a4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "grid". Available on cupertino_icons package 1.0.0+ only. + static const IconData grid = IconData(0xf6a5, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "grid_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData grid_circle = IconData( + 0xf6a6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "grid_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData grid_circle_fill = IconData( + 0xf6a7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "guitars". Available on cupertino_icons package 1.0.0+ only. + static const IconData guitars = IconData( + 0xf6a8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hammer". Available on cupertino_icons package 1.0.0+ only. + static const IconData hammer = IconData( + 0xf6a9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hammer_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hammer_fill = IconData( + 0xf6aa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_draw". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_draw = IconData( + 0xf6ab, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_draw_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_draw_fill = IconData( + 0xf6ac, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_point_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_point_left = IconData( + 0xf6ad, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_point_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_point_left_fill = IconData( + 0xf6ae, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_point_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_point_right = IconData( + 0xf6af, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_point_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_point_right_fill = IconData( + 0xf6b0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_raised". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_raised = IconData( + 0xf6b1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_raised_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_raised_fill = IconData( + 0xf6b2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_raised_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_raised_slash = IconData( + 0xf6b3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_raised_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_raised_slash_fill = IconData( + 0xf6b4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_thumbsdown". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_thumbsdown = IconData( + 0xf6b5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_thumbsdown_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_thumbsdown_fill = IconData( + 0xf6b6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_thumbsup". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_thumbsup = IconData( + 0xf6b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hand_thumbsup_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hand_thumbsup_fill = IconData( + 0xf6b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hare". Available on cupertino_icons package 1.0.0+ only. + static const IconData hare = IconData(0xf6b9, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "hare_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hare_fill = IconData( + 0xf6ba, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "headphones". Available on cupertino_icons package 1.0.0+ only. + static const IconData headphones = IconData( + 0xf6bb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData heart_circle = IconData( + 0xf6bc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData heart_circle_fill = IconData( + 0xf6bd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [heart_solid] which is available in cupertino_icons 0.1.3. + static const IconData heart_fill = IconData( + 0xf443, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData heart_slash = IconData( + 0xf6be, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_slash_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData heart_slash_circle = IconData( + 0xf6bf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_slash_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData heart_slash_circle_fill = IconData( + 0xf6c0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "heart_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData heart_slash_fill = IconData( + 0xf6c1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "helm". Available on cupertino_icons package 1.0.0+ only. + static const IconData helm = IconData(0xf6c2, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "hexagon". Available on cupertino_icons package 1.0.0+ only. + static const IconData hexagon = IconData( + 0xf6c3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hexagon_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hexagon_fill = IconData( + 0xf6c4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hifispeaker". Available on cupertino_icons package 1.0.0+ only. + static const IconData hifispeaker = IconData( + 0xf6c5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hifispeaker_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hifispeaker_fill = IconData( + 0xf6c6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hourglass". Available on cupertino_icons package 1.0.0+ only. + static const IconData hourglass = IconData( + 0xf6c7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hourglass_bottomhalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hourglass_bottomhalf_fill = IconData( + 0xf6c8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hourglass_tophalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData hourglass_tophalf_fill = IconData( + 0xf6c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "house". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [home] which is available in cupertino_icons 0.1.3. + static const IconData house = IconData( + 0xf447, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "house_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData house_alt = IconData( + 0xf8e3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "house_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData house_alt_fill = IconData( + 0xf8e4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "house_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData house_fill = IconData( + 0xf6ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "hurricane". Available on cupertino_icons package 1.0.0+ only. + static const IconData hurricane = IconData( + 0xf6cb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "increase_indent". Available on cupertino_icons package 1.0.0+ only. + static const IconData increase_indent = IconData( + 0xf6cc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "increase_quotelevel". Available on cupertino_icons package 1.0.0+ only. + static const IconData increase_quotelevel = IconData( + 0xf6cd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "infinite". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [loop] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [loop_thick] which is available in cupertino_icons 0.1.3. + static const IconData infinite = IconData( + 0xf449, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "info_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [info] which is available in cupertino_icons 0.1.3. + static const IconData info_circle = IconData( + 0xf44c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "info_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData info_circle_fill = IconData( + 0xf6cf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "italic". Available on cupertino_icons package 1.0.0+ only. + static const IconData italic = IconData( + 0xf6d0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "keyboard". Available on cupertino_icons package 1.0.0+ only. + static const IconData keyboard = IconData( + 0xf6d1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "keyboard_chevron_compact_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData keyboard_chevron_compact_down = IconData( + 0xf6d2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "largecircle_fill_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData largecircle_fill_circle = IconData( + 0xf6d3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lasso". Available on cupertino_icons package 1.0.0+ only. + static const IconData lasso = IconData( + 0xf6d4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "layers". Available on cupertino_icons package 1.0.0+ only. + static const IconData layers = IconData( + 0xf8e5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "layers_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData layers_alt = IconData( + 0xf8e6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "layers_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData layers_alt_fill = IconData( + 0xf8e7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "layers_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData layers_fill = IconData( + 0xf8e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "leaf_arrow_circlepath". Available on cupertino_icons package 1.0.0+ only. + static const IconData leaf_arrow_circlepath = IconData( + 0xf6d5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lessthan". Available on cupertino_icons package 1.0.0+ only. + static const IconData lessthan = IconData( + 0xf6d6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lessthan_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData lessthan_circle = IconData( + 0xf6d7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lessthan_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lessthan_circle_fill = IconData( + 0xf6d8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lessthan_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData lessthan_square = IconData( + 0xf6d9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lessthan_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lessthan_square_fill = IconData( + 0xf6da, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "light_max". Available on cupertino_icons package 1.0.0+ only. + static const IconData light_max = IconData( + 0xf6db, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "light_min". Available on cupertino_icons package 1.0.0+ only. + static const IconData light_min = IconData( + 0xf6dc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lightbulb". Available on cupertino_icons package 1.0.0+ only. + static const IconData lightbulb = IconData( + 0xf6dd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lightbulb_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lightbulb_fill = IconData( + 0xf6de, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lightbulb_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData lightbulb_slash = IconData( + 0xf6df, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lightbulb_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lightbulb_slash_fill = IconData( + 0xf6e0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "line_horizontal_3". Available on cupertino_icons package 1.0.0+ only. + static const IconData line_horizontal_3 = IconData( + 0xf6e1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "line_horizontal_3_decrease". Available on cupertino_icons package 1.0.0+ only. + static const IconData line_horizontal_3_decrease = IconData( + 0xf6e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "line_horizontal_3_decrease_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData line_horizontal_3_decrease_circle = IconData( + 0xf6e3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "line_horizontal_3_decrease_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData line_horizontal_3_decrease_circle_fill = IconData( + 0xf6e4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "link". Available on cupertino_icons package 1.0.0+ only. + static const IconData link = IconData(0xf6e5, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "link_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData link_circle = IconData( + 0xf6e6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "link_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData link_circle_fill = IconData( + 0xf6e7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "list_bullet". Available on cupertino_icons package 1.0.0+ only. + static const IconData list_bullet = IconData( + 0xf6e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "list_bullet_below_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData list_bullet_below_rectangle = IconData( + 0xf6e9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "list_bullet_indent". Available on cupertino_icons package 1.0.0+ only. + static const IconData list_bullet_indent = IconData( + 0xf6ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "list_dash". Available on cupertino_icons package 1.0.0+ only. + static const IconData list_dash = IconData( + 0xf6eb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "list_number". Available on cupertino_icons package 1.0.0+ only. + static const IconData list_number = IconData( + 0xf6ec, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "list_number_rtl". Available on cupertino_icons package 1.0.0+ only. + static const IconData list_number_rtl = IconData( + 0xf6ed, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_circle = IconData( + 0xf6ef, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_circle_fill = IconData( + 0xf6f0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_fill = IconData( + 0xf6f1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_north". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_north = IconData( + 0xf6f2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_north_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_north_fill = IconData( + 0xf6f3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_north_line". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_north_line = IconData( + 0xf6f4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_north_line_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_north_line_fill = IconData( + 0xf6f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_slash = IconData( + 0xf6f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "location_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData location_slash_fill = IconData( + 0xf6f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [padlock] which is available in cupertino_icons 0.1.3. + static const IconData lock = IconData(0xf4c8, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "lock_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_circle = IconData( + 0xf6f8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_circle_fill = IconData( + 0xf6f9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [padlock_solid] which is available in cupertino_icons 0.1.3. + static const IconData lock_fill = IconData( + 0xf4c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_open". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_open = IconData( + 0xf6fa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_open_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_open_fill = IconData( + 0xf6fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_rotation". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_rotation = IconData( + 0xf6fc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_rotation_open". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_rotation_open = IconData( + 0xf6fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_shield". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_shield = IconData( + 0xf6fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_shield_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_shield_fill = IconData( + 0xf6ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_slash = IconData( + 0xf700, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "lock_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData lock_slash_fill = IconData( + 0xf701, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "macwindow". Available on cupertino_icons package 1.0.0+ only. + static const IconData macwindow = IconData( + 0xf702, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "map". Available on cupertino_icons package 1.0.0+ only. + static const IconData map = IconData(0xf703, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "map_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData map_fill = IconData( + 0xf704, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "map_pin". Available on cupertino_icons package 1.0.0+ only. + static const IconData map_pin = IconData( + 0xf705, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "map_pin_ellipse". Available on cupertino_icons package 1.0.0+ only. + static const IconData map_pin_ellipse = IconData( + 0xf706, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "map_pin_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData map_pin_slash = IconData( + 0xf707, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "memories". Available on cupertino_icons package 1.0.0+ only. + static const IconData memories = IconData( + 0xf708, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "memories_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData memories_badge_minus = IconData( + 0xf709, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "memories_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData memories_badge_plus = IconData( + 0xf70a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "metronome". Available on cupertino_icons package 1.0.0+ only. + static const IconData metronome = IconData( + 0xf70b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "mic_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData mic_circle = IconData( + 0xf70c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "mic_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData mic_circle_fill = IconData( + 0xf70d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "mic_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [mic_solid] which is available in cupertino_icons 0.1.3. + static const IconData mic_fill = IconData( + 0xf461, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "mic_slash". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [mic_off] which is available in cupertino_icons 0.1.3. + static const IconData mic_slash = IconData( + 0xf45f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "mic_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData mic_slash_fill = IconData( + 0xf70e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus = IconData( + 0xf70f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [minus_circled] which is available in cupertino_icons 0.1.3. + static const IconData minus_circle = IconData( + 0xf463, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus_circle_fill = IconData( + 0xf710, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus_rectangle = IconData( + 0xf711, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus_rectangle_fill = IconData( + 0xf712, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_slash_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus_slash_plus = IconData( + 0xf713, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus_square = IconData( + 0xf714, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "minus_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData minus_square_fill = IconData( + 0xf715, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_dollar". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_dollar = IconData( + 0xf8e9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_dollar_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_dollar_circle = IconData( + 0xf8ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_dollar_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_dollar_circle_fill = IconData( + 0xf8eb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_euro". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_euro = IconData( + 0xf8ec, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_euro_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_euro_circle = IconData( + 0xf8ed, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_euro_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_euro_circle_fill = IconData( + 0xf8ee, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_pound". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_pound = IconData( + 0xf8ef, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_pound_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_pound_circle = IconData( + 0xf8f0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_pound_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_pound_circle_fill = IconData( + 0xf8f1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_rubl". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_rubl = IconData( + 0xf8f2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_rubl_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_rubl_circle = IconData( + 0xf8f3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_rubl_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_rubl_circle_fill = IconData( + 0xf8f4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_yen". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_yen = IconData( + 0xf8f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_yen_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_yen_circle = IconData( + 0xf8f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "money_yen_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData money_yen_circle_fill = IconData( + 0xf8f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon = IconData(0xf716, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "moon_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_circle = IconData( + 0xf717, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_circle_fill = IconData( + 0xf718, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_fill = IconData( + 0xf719, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon_stars". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_stars = IconData( + 0xf71a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon_stars_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_stars_fill = IconData( + 0xf71b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon_zzz". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_zzz = IconData( + 0xf71c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "moon_zzz_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData moon_zzz_fill = IconData( + 0xf71d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "move". Available on cupertino_icons package 1.0.0+ only. + static const IconData move = IconData(0xf8f8, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "multiply". Available on cupertino_icons package 1.0.0+ only. + static const IconData multiply = IconData( + 0xf71e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "multiply_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData multiply_circle = IconData( + 0xf71f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "multiply_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData multiply_circle_fill = IconData( + 0xf720, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "multiply_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData multiply_square = IconData( + 0xf721, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "multiply_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData multiply_square_fill = IconData( + 0xf722, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_albums". Available on cupertino_icons package 1.0.0+ only. + static const IconData music_albums = IconData( + 0xf8f9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_albums_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData music_albums_fill = IconData( + 0xf8fa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_house". Available on cupertino_icons package 1.0.0+ only. + static const IconData music_house = IconData( + 0xf723, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_house_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData music_house_fill = IconData( + 0xf724, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_mic". Available on cupertino_icons package 1.0.0+ only. + static const IconData music_mic = IconData( + 0xf725, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_note_2". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [double_music_note] which is available in cupertino_icons 0.1.3. + static const IconData music_note_2 = IconData( + 0xf46c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "music_note_list". Available on cupertino_icons package 1.0.0+ only. + static const IconData music_note_list = IconData( + 0xf726, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "nosign". Available on cupertino_icons package 1.0.0+ only. + static const IconData nosign = IconData( + 0xf727, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "number". Available on cupertino_icons package 1.0.0+ only. + static const IconData number = IconData( + 0xf728, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "number_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData number_circle = IconData( + 0xf729, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "number_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData number_circle_fill = IconData( + 0xf72a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "number_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData number_square = IconData( + 0xf72b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "number_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData number_square_fill = IconData( + 0xf72c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "option". Available on cupertino_icons package 1.0.0+ only. + static const IconData option = IconData( + 0xf72d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "paintbrush". Available on cupertino_icons package 1.0.0+ only. + static const IconData paintbrush = IconData( + 0xf72e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "paintbrush_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData paintbrush_fill = IconData( + 0xf72f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pano". Available on cupertino_icons package 1.0.0+ only. + static const IconData pano = IconData(0xf730, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "pano_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData pano_fill = IconData( + 0xf731, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "paperclip". Available on cupertino_icons package 1.0.0+ only. + static const IconData paperclip = IconData( + 0xf732, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "paperplane". Available on cupertino_icons package 1.0.0+ only. + static const IconData paperplane = IconData( + 0xf733, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "paperplane_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData paperplane_fill = IconData( + 0xf734, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "paragraph". Available on cupertino_icons package 1.0.0+ only. + static const IconData paragraph = IconData( + 0xf735, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pause_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData pause_circle = IconData( + 0xf736, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pause_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData pause_circle_fill = IconData( + 0xf737, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pause_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [pause_solid] which is available in cupertino_icons 0.1.3. + static const IconData pause_fill = IconData( + 0xf478, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pause_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData pause_rectangle = IconData( + 0xf738, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pause_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData pause_rectangle_fill = IconData( + 0xf739, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pencil_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData pencil_circle = IconData( + 0xf73a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pencil_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData pencil_circle_fill = IconData( + 0xf73b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pencil_ellipsis_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData pencil_ellipsis_rectangle = IconData( + 0xf73c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pencil_outline". Available on cupertino_icons package 1.0.0+ only. + static const IconData pencil_outline = IconData( + 0xf73d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pencil_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData pencil_slash = IconData( + 0xf73e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "percent". Available on cupertino_icons package 1.0.0+ only. + static const IconData percent = IconData( + 0xf73f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_2". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_2 = IconData( + 0xf740, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_2_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_2_alt = IconData( + 0xf8fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_2_fill = IconData( + 0xf741, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_2_square_stack". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_2_square_stack = IconData( + 0xf742, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_2_square_stack_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_2_square_stack_fill = IconData( + 0xf743, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_3". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [group] which is available in cupertino_icons 0.1.3. + static const IconData person_3 = IconData( + 0xf47b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_3_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [group_solid] which is available in cupertino_icons 0.1.3. + static const IconData person_3_fill = IconData( + 0xf47c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_alt = IconData( + 0xf8fc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_alt_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_alt_circle = IconData( + 0xf8fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_alt_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_alt_circle_fill = IconData( + 0xf8fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_badge_minus = IconData( + 0xf744, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_badge_minus_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_badge_minus_fill = IconData( + 0xf745, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_badge_plus". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [person_add] which is available in cupertino_icons 0.1.3. + static const IconData person_badge_plus = IconData( + 0xf47f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_badge_plus_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [person_add_solid] which is available in cupertino_icons 0.1.3. + static const IconData person_badge_plus_fill = IconData( + 0xf480, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_circle = IconData( + 0xf746, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_circle_fill = IconData( + 0xf747, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [profile_circled] which is available in cupertino_icons 0.1.3. + static const IconData person_crop_circle = IconData( + 0xf419, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_badge_checkmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_badge_checkmark = IconData( + 0xf748, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_badge_exclam". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_badge_exclam = IconData( + 0xf749, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_badge_minus = IconData( + 0xf74a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_badge_plus = IconData( + 0xf74b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_badge_xmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_badge_xmark = IconData( + 0xf74c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_fill = IconData( + 0xf74d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_fill_badge_checkmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_fill_badge_checkmark = IconData( + 0xf74e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_fill_badge_exclam". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_fill_badge_exclam = IconData( + 0xf74f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_fill_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_fill_badge_minus = IconData( + 0xf750, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_fill_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_fill_badge_plus = IconData( + 0xf751, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_circle_fill_badge_xmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_circle_fill_badge_xmark = IconData( + 0xf752, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_rectangle = IconData( + 0xf753, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_rectangle_fill = IconData( + 0xf754, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_square = IconData( + 0xf755, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_crop_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData person_crop_square_fill = IconData( + 0xf756, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "person_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [person_solid] which is available in cupertino_icons 0.1.3. + static const IconData person_fill = IconData( + 0xf47e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "personalhotspot". Available on cupertino_icons package 1.0.0+ only. + static const IconData personalhotspot = IconData( + 0xf757, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "perspective". Available on cupertino_icons package 1.0.0+ only. + static const IconData perspective = IconData( + 0xf758, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_arrow_down_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_arrow_down_left = IconData( + 0xf759, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_arrow_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_arrow_right = IconData( + 0xf75a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_arrow_up_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_arrow_up_right = IconData( + 0xf75b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_badge_plus = IconData( + 0xf75c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_circle = IconData( + 0xf75d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_circle_fill = IconData( + 0xf75e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_down = IconData( + 0xf75f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_down_circle = IconData( + 0xf760, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_down_circle_fill = IconData( + 0xf761, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_down_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_down_fill = IconData( + 0xf762, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [phone_solid] which is available in cupertino_icons 0.1.3. + static const IconData phone_fill = IconData( + 0xf4b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_fill_arrow_down_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_fill_arrow_down_left = IconData( + 0xf763, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_fill_arrow_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_fill_arrow_right = IconData( + 0xf764, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_fill_arrow_up_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_fill_arrow_up_right = IconData( + 0xf765, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "phone_fill_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData phone_fill_badge_plus = IconData( + 0xf766, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "photo". Available on cupertino_icons package 1.0.0+ only. + static const IconData photo = IconData( + 0xf767, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "photo_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData photo_fill = IconData( + 0xf768, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "photo_fill_on_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData photo_fill_on_rectangle_fill = IconData( + 0xf769, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "photo_on_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData photo_on_rectangle = IconData( + 0xf76a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "piano". Available on cupertino_icons package 1.0.0+ only. + static const IconData piano = IconData( + 0xf8ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pin". Available on cupertino_icons package 1.0.0+ only. + static const IconData pin = IconData(0xf76b, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "pin_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData pin_fill = IconData( + 0xf76c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pin_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData pin_slash = IconData( + 0xf76d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "pin_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData pin_slash_fill = IconData( + 0xf76e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "placemark". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [location] which is available in cupertino_icons 0.1.3. + static const IconData placemark = IconData( + 0xf455, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "placemark_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [location_solid] which is available in cupertino_icons 0.1.3. + static const IconData placemark_fill = IconData( + 0xf456, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "play". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [play_arrow] which is available in cupertino_icons 0.1.3. + static const IconData play = IconData(0xf487, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "play_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData play_circle = IconData( + 0xf76f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "play_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData play_circle_fill = IconData( + 0xf770, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "play_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [play_arrow_solid] which is available in cupertino_icons 0.1.3. + static const IconData play_fill = IconData( + 0xf488, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "play_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData play_rectangle = IconData( + 0xf771, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "play_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData play_rectangle_fill = IconData( + 0xf772, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "playpause". Available on cupertino_icons package 1.0.0+ only. + static const IconData playpause = IconData( + 0xf773, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "playpause_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData playpause_fill = IconData( + 0xf774, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [add] which is available in cupertino_icons 0.1.3. + static const IconData plus = IconData(0xf489, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "plus_app". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_app = IconData( + 0xf775, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_app_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_app_fill = IconData( + 0xf776, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_bubble = IconData( + 0xf777, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_bubble_fill = IconData( + 0xf778, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [plus_circled] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [add_circled] which is available in cupertino_icons 0.1.3. + static const IconData plus_circle = IconData( + 0xf48a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_circle_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [add_circled_solid] which is available in cupertino_icons 0.1.3. + static const IconData plus_circle_fill = IconData( + 0xf48b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_rectangle = IconData( + 0xf779, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_rectangle_fill = IconData( + 0xf77a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_rectangle_fill_on_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_rectangle_fill_on_rectangle_fill = IconData( + 0xf77b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_rectangle_on_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_rectangle_on_rectangle = IconData( + 0xf77c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_slash_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_slash_minus = IconData( + 0xf77d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_square = IconData( + 0xf77e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_square_fill = IconData( + 0xf77f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_square_fill_on_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_square_fill_on_square_fill = IconData( + 0xf780, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plus_square_on_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData plus_square_on_square = IconData( + 0xf781, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plusminus". Available on cupertino_icons package 1.0.0+ only. + static const IconData plusminus = IconData( + 0xf782, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plusminus_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData plusminus_circle = IconData( + 0xf783, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "plusminus_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData plusminus_circle_fill = IconData( + 0xf784, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "power". Available on cupertino_icons package 1.0.0+ only. + static const IconData power = IconData( + 0xf785, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "printer". Available on cupertino_icons package 1.0.0+ only. + static const IconData printer = IconData( + 0xf786, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "printer_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData printer_fill = IconData( + 0xf787, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "projective". Available on cupertino_icons package 1.0.0+ only. + static const IconData projective = IconData( + 0xf788, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "purchased". Available on cupertino_icons package 1.0.0+ only. + static const IconData purchased = IconData( + 0xf789, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "purchased_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData purchased_circle = IconData( + 0xf78a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "purchased_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData purchased_circle_fill = IconData( + 0xf78b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "qrcode". Available on cupertino_icons package 1.0.0+ only. + static const IconData qrcode = IconData( + 0xf78c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "qrcode_viewfinder". Available on cupertino_icons package 1.0.0+ only. + static const IconData qrcode_viewfinder = IconData( + 0xf78d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question". Available on cupertino_icons package 1.0.0+ only. + static const IconData question = IconData( + 0xf78e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData question_circle = IconData( + 0xf78f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData question_circle_fill = IconData( + 0xf790, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question_diamond". Available on cupertino_icons package 1.0.0+ only. + static const IconData question_diamond = IconData( + 0xf791, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question_diamond_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData question_diamond_fill = IconData( + 0xf792, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData question_square = IconData( + 0xf793, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "question_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData question_square_fill = IconData( + 0xf794, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "quote_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData quote_bubble = IconData( + 0xf795, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "quote_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData quote_bubble_fill = IconData( + 0xf796, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "radiowaves_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData radiowaves_left = IconData( + 0xf797, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "radiowaves_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData radiowaves_right = IconData( + 0xf798, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rays". Available on cupertino_icons package 1.0.0+ only. + static const IconData rays = IconData(0xf799, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "recordingtape". Available on cupertino_icons package 1.0.0+ only. + static const IconData recordingtape = IconData( + 0xf79a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle = IconData( + 0xf79b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_3_offgrid". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_3_offgrid = IconData( + 0xf79c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_3_offgrid_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_3_offgrid_fill = IconData( + 0xf79d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_arrow_up_right_arrow_down_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_arrow_up_right_arrow_down_left = IconData( + 0xf79e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_arrow_up_right_arrow_down_left_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_arrow_up_right_arrow_down_left_slash = IconData( + 0xf79f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_badge_checkmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_badge_checkmark = IconData( + 0xf7a0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_badge_xmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_badge_xmark = IconData( + 0xf7a1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_compress_vertical". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_compress_vertical = IconData( + 0xf7a2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_dock". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_dock = IconData( + 0xf7a3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_expand_vertical". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_expand_vertical = IconData( + 0xf7a4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_fill = IconData( + 0xf7a5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_fill_badge_checkmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_fill_badge_checkmark = IconData( + 0xf7a6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_fill_badge_xmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_fill_badge_xmark = IconData( + 0xf7a7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_fill_on_rectangle_angled_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_fill_on_rectangle_angled_fill = IconData( + 0xf7a8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_fill_on_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_fill_on_rectangle_fill = IconData( + 0xf7a9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_grid_1x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_grid_1x2 = IconData( + 0xf7aa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_grid_1x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_grid_1x2_fill = IconData( + 0xf7ab, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_grid_2x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_grid_2x2 = IconData( + 0xf7ac, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_grid_2x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_grid_2x2_fill = IconData( + 0xf7ad, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_grid_3x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_grid_3x2 = IconData( + 0xf7ae, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_grid_3x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_grid_3x2_fill = IconData( + 0xf7af, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_on_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_on_rectangle = IconData( + 0xf7b0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_on_rectangle_angled". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_on_rectangle_angled = IconData( + 0xf7b1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_paperclip". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_paperclip = IconData( + 0xf7b2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_split_3x1". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_split_3x1 = IconData( + 0xf7b3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_split_3x1_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_split_3x1_fill = IconData( + 0xf7b4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_split_3x3". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_split_3x3 = IconData( + 0xf7b5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_split_3x3_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_split_3x3_fill = IconData( + 0xf7b6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [collections] which is available in cupertino_icons 0.1.3. + static const IconData rectangle_stack = IconData( + 0xf3c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_badge_minus = IconData( + 0xf7b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_badge_person_crop". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_badge_person_crop = IconData( + 0xf7b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_badge_plus = IconData( + 0xf7b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [collections_solid] which is available in cupertino_icons 0.1.3. + static const IconData rectangle_stack_fill = IconData( + 0xf3ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_fill_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_fill_badge_minus = IconData( + 0xf7ba, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_fill_badge_person_crop". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_fill_badge_person_crop = IconData( + 0xf7bb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_fill_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_fill_badge_plus = IconData( + 0xf7bc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_person_crop". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_person_crop = IconData( + 0xf7bd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rectangle_stack_person_crop_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rectangle_stack_person_crop_fill = IconData( + 0xf7be, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "repeat". Available on cupertino_icons package 1.0.0+ only. + static const IconData repeat = IconData( + 0xf7bf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "repeat_1". Available on cupertino_icons package 1.0.0+ only. + static const IconData repeat_1 = IconData( + 0xf7c0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "resize". Available on cupertino_icons package 1.0.0+ only. + static const IconData resize = IconData( + 0xf900, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "resize_h". Available on cupertino_icons package 1.0.0+ only. + static const IconData resize_h = IconData( + 0xf901, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "resize_v". Available on cupertino_icons package 1.0.0+ only. + static const IconData resize_v = IconData( + 0xf902, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "return_icon". Available on cupertino_icons package 1.0.0+ only. + static const IconData return_icon = IconData( + 0xf7c1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rhombus". Available on cupertino_icons package 1.0.0+ only. + static const IconData rhombus = IconData( + 0xf7c2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rhombus_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rhombus_fill = IconData( + 0xf7c3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rocket". Available on cupertino_icons package 1.0.0+ only. + static const IconData rocket = IconData( + 0xf903, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rocket_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rocket_fill = IconData( + 0xf904, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rosette". Available on cupertino_icons package 1.0.0+ only. + static const IconData rosette = IconData( + 0xf7c4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rotate_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData rotate_left = IconData( + 0xf7c5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rotate_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rotate_left_fill = IconData( + 0xf7c6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rotate_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData rotate_right = IconData( + 0xf7c7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "rotate_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData rotate_right_fill = IconData( + 0xf7c8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "scissors". Available on cupertino_icons package 1.0.0+ only. + static const IconData scissors = IconData( + 0xf7c9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "scissors_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData scissors_alt = IconData( + 0xf905, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "scope". Available on cupertino_icons package 1.0.0+ only. + static const IconData scope = IconData( + 0xf7ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "scribble". Available on cupertino_icons package 1.0.0+ only. + static const IconData scribble = IconData( + 0xf7cb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "search_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData search_circle = IconData( + 0xf7cc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "search_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData search_circle_fill = IconData( + 0xf7cd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "selection_pin_in_out". Available on cupertino_icons package 1.0.0+ only. + static const IconData selection_pin_in_out = IconData( + 0xf7ce, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shield". Available on cupertino_icons package 1.0.0+ only. + static const IconData shield = IconData( + 0xf7cf, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shield_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData shield_fill = IconData( + 0xf7d0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shield_lefthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData shield_lefthalf_fill = IconData( + 0xf7d1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shield_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData shield_slash = IconData( + 0xf7d2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shield_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData shield_slash_fill = IconData( + 0xf7d3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shift". Available on cupertino_icons package 1.0.0+ only. + static const IconData shift = IconData( + 0xf7d4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "shift_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData shift_fill = IconData( + 0xf7d5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sidebar_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData sidebar_left = IconData( + 0xf7d6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sidebar_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData sidebar_right = IconData( + 0xf7d7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "signature". Available on cupertino_icons package 1.0.0+ only. + static const IconData signature = IconData( + 0xf7d8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "skew". Available on cupertino_icons package 1.0.0+ only. + static const IconData skew = IconData(0xf7d9, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "slash_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData slash_circle = IconData( + 0xf7da, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "slash_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData slash_circle_fill = IconData( + 0xf7db, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "slider_horizontal_3". Available on cupertino_icons package 1.0.0+ only. + static const IconData slider_horizontal_3 = IconData( + 0xf7dc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "slider_horizontal_below_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData slider_horizontal_below_rectangle = IconData( + 0xf7dd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "slowmo". Available on cupertino_icons package 1.0.0+ only. + static const IconData slowmo = IconData( + 0xf7de, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smallcircle_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData smallcircle_circle = IconData( + 0xf7df, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smallcircle_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData smallcircle_circle_fill = IconData( + 0xf7e0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smallcircle_fill_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData smallcircle_fill_circle = IconData( + 0xf7e1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smallcircle_fill_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData smallcircle_fill_circle_fill = IconData( + 0xf7e2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smiley". Available on cupertino_icons package 1.0.0+ only. + static const IconData smiley = IconData( + 0xf7e3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smiley_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData smiley_fill = IconData( + 0xf7e4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smoke". Available on cupertino_icons package 1.0.0+ only. + static const IconData smoke = IconData( + 0xf7e5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "smoke_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData smoke_fill = IconData( + 0xf7e6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "snow". Available on cupertino_icons package 1.0.0+ only. + static const IconData snow = IconData(0xf7e7, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "sort_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData sort_down = IconData( + 0xf906, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sort_down_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData sort_down_circle = IconData( + 0xf907, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sort_down_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sort_down_circle_fill = IconData( + 0xf908, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sort_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData sort_up = IconData( + 0xf909, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sort_up_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData sort_up_circle = IconData( + 0xf90a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sort_up_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sort_up_circle_fill = IconData( + 0xf90b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sparkles". Available on cupertino_icons package 1.0.0+ only. + static const IconData sparkles = IconData( + 0xf7e8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker = IconData( + 0xf7e9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_1". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_1 = IconData( + 0xf7ea, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_1_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [volume_down] which is available in cupertino_icons 0.1.3. + static const IconData speaker_1_fill = IconData( + 0xf3b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_2". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_2 = IconData( + 0xf7eb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_2_fill = IconData( + 0xf7ec, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_3". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_3 = IconData( + 0xf7ed, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_3_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [volume_up] which is available in cupertino_icons 0.1.3. + static const IconData speaker_3_fill = IconData( + 0xf3ba, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [volume_mute] which is available in cupertino_icons 0.1.3. + static const IconData speaker_fill = IconData( + 0xf3b8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_slash = IconData( + 0xf7ee, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_slash_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [volume_off] which is available in cupertino_icons 0.1.3. + static const IconData speaker_slash_fill = IconData( + 0xf3b9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_slash_fill_rtl". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_slash_fill_rtl = IconData( + 0xf7ef, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_slash_rtl". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_slash_rtl = IconData( + 0xf7f0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_zzz". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_zzz = IconData( + 0xf7f1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_zzz_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_zzz_fill = IconData( + 0xf7f2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_zzz_fill_rtl". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_zzz_fill_rtl = IconData( + 0xf7f3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speaker_zzz_rtl". Available on cupertino_icons package 1.0.0+ only. + static const IconData speaker_zzz_rtl = IconData( + 0xf7f4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "speedometer". Available on cupertino_icons package 1.0.0+ only. + static const IconData speedometer = IconData( + 0xf7f5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sportscourt". Available on cupertino_icons package 1.0.0+ only. + static const IconData sportscourt = IconData( + 0xf7f6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sportscourt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sportscourt_fill = IconData( + 0xf7f7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square". Available on cupertino_icons package 1.0.0+ only. + static const IconData square = IconData( + 0xf7f8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_down = IconData( + 0xf7f9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_down_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_down_fill = IconData( + 0xf7fa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_down_on_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_down_on_square = IconData( + 0xf7fb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_down_on_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_down_on_square_fill = IconData( + 0xf7fc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_left". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_left = IconData( + 0xf90c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_left_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_left_fill = IconData( + 0xf90d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_right = IconData( + 0xf90e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_right_fill = IconData( + 0xf90f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_up". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [share] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [share_up] which is available in cupertino_icons 0.1.3. + static const IconData square_arrow_up = IconData( + 0xf4ca, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_up_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [share_solid] which is available in cupertino_icons 0.1.3. + static const IconData square_arrow_up_fill = IconData( + 0xf4cb, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_up_on_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_up_on_square = IconData( + 0xf7fd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_arrow_up_on_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_arrow_up_on_square_fill = IconData( + 0xf7fe, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_favorites". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_favorites = IconData( + 0xf910, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_favorites_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_favorites_alt = IconData( + 0xf911, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_favorites_alt_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_favorites_alt_fill = IconData( + 0xf912, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_favorites_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_favorites_fill = IconData( + 0xf913, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_fill = IconData( + 0xf7ff, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_fill_line_vertical_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_fill_line_vertical_square = IconData( + 0xf800, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_fill_line_vertical_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_fill_line_vertical_square_fill = IconData( + 0xf801, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_fill_on_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_fill_on_circle_fill = IconData( + 0xf802, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_fill_on_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_fill_on_square_fill = IconData( + 0xf803, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_grid_2x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_grid_2x2 = IconData( + 0xf804, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_grid_2x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_grid_2x2_fill = IconData( + 0xf805, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_grid_3x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_grid_3x2 = IconData( + 0xf806, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_grid_3x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_grid_3x2_fill = IconData( + 0xf807, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_grid_4x3_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_grid_4x3_fill = IconData( + 0xf808, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_lefthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_lefthalf_fill = IconData( + 0xf809, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_line_vertical_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_line_vertical_square = IconData( + 0xf80a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_line_vertical_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_line_vertical_square_fill = IconData( + 0xf80b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_list". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_list = IconData( + 0xf914, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_list_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_list_fill = IconData( + 0xf915, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_on_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_on_circle = IconData( + 0xf80c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_on_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_on_square = IconData( + 0xf80d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_pencil". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [create] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [create_solid] which is available in cupertino_icons 0.1.3. + static const IconData square_pencil = IconData( + 0xf417, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_pencil_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [create] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [create_solid] which is available in cupertino_icons 0.1.3. + static const IconData square_pencil_fill = IconData( + 0xf417, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_righthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_righthalf_fill = IconData( + 0xf80e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_split_1x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_split_1x2 = IconData( + 0xf80f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_split_1x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_split_1x2_fill = IconData( + 0xf810, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_split_2x1". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_split_2x1 = IconData( + 0xf811, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_split_2x1_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_split_2x1_fill = IconData( + 0xf812, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_split_2x2". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_split_2x2 = IconData( + 0xf813, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_split_2x2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_split_2x2_fill = IconData( + 0xf814, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack = IconData( + 0xf815, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_down_dottedline". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_down_dottedline = IconData( + 0xf816, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_down_right". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_down_right = IconData( + 0xf817, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_down_right_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_down_right_fill = IconData( + 0xf818, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_up = IconData( + 0xf819, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_up_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_up_fill = IconData( + 0xf81a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_up_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_up_slash = IconData( + 0xf81b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_3d_up_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_3d_up_slash_fill = IconData( + 0xf81c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "square_stack_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData square_stack_fill = IconData( + 0xf81d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "squares_below_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData squares_below_rectangle = IconData( + 0xf81e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "star". Available on cupertino_icons package 1.0.0+ only. + static const IconData star = IconData(0xf81f, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "star_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData star_circle = IconData( + 0xf820, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "star_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData star_circle_fill = IconData( + 0xf821, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "star_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData star_fill = IconData( + 0xf822, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "star_lefthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData star_lefthalf_fill = IconData( + 0xf823, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "star_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData star_slash = IconData( + 0xf824, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "star_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData star_slash_fill = IconData( + 0xf825, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "staroflife". Available on cupertino_icons package 1.0.0+ only. + static const IconData staroflife = IconData( + 0xf826, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "staroflife_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData staroflife_fill = IconData( + 0xf827, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "stop". Available on cupertino_icons package 1.0.0+ only. + static const IconData stop = IconData(0xf828, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "stop_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData stop_circle = IconData( + 0xf829, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "stop_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData stop_circle_fill = IconData( + 0xf82a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "stop_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData stop_fill = IconData( + 0xf82b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "stopwatch". Available on cupertino_icons package 1.0.0+ only. + static const IconData stopwatch = IconData( + 0xf82c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "stopwatch_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData stopwatch_fill = IconData( + 0xf82d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "strikethrough". Available on cupertino_icons package 1.0.0+ only. + static const IconData strikethrough = IconData( + 0xf82e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_club". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_club = IconData( + 0xf82f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_club_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_club_fill = IconData( + 0xf830, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_diamond". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_diamond = IconData( + 0xf831, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_diamond_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_diamond_fill = IconData( + 0xf832, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_heart". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_heart = IconData( + 0xf833, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_heart_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_heart_fill = IconData( + 0xf834, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_spade". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_spade = IconData( + 0xf835, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "suit_spade_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData suit_spade_fill = IconData( + 0xf836, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sum". Available on cupertino_icons package 1.0.0+ only. + static const IconData sum = IconData(0xf837, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "sun_dust". Available on cupertino_icons package 1.0.0+ only. + static const IconData sun_dust = IconData( + 0xf838, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_dust_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sun_dust_fill = IconData( + 0xf839, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_haze". Available on cupertino_icons package 1.0.0+ only. + static const IconData sun_haze = IconData( + 0xf83a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_haze_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sun_haze_fill = IconData( + 0xf83b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_max". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [brightness] which is available in cupertino_icons 0.1.3. + static const IconData sun_max = IconData( + 0xf4b6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_max_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [brightness_solid] which is available in cupertino_icons 0.1.3. + static const IconData sun_max_fill = IconData( + 0xf4b7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_min". Available on cupertino_icons package 1.0.0+ only. + static const IconData sun_min = IconData( + 0xf83c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sun_min_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sun_min_fill = IconData( + 0xf83d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sunrise". Available on cupertino_icons package 1.0.0+ only. + static const IconData sunrise = IconData( + 0xf83e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sunrise_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sunrise_fill = IconData( + 0xf83f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sunset". Available on cupertino_icons package 1.0.0+ only. + static const IconData sunset = IconData( + 0xf840, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "sunset_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData sunset_fill = IconData( + 0xf841, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "t_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData t_bubble = IconData( + 0xf842, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "t_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData t_bubble_fill = IconData( + 0xf843, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "table". Available on cupertino_icons package 1.0.0+ only. + static const IconData table = IconData( + 0xf844, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "table_badge_more". Available on cupertino_icons package 1.0.0+ only. + static const IconData table_badge_more = IconData( + 0xf845, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "table_badge_more_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData table_badge_more_fill = IconData( + 0xf846, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "table_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData table_fill = IconData( + 0xf847, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tag_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData tag_circle = IconData( + 0xf848, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tag_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tag_circle_fill = IconData( + 0xf849, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tag_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [tag_solid] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [tags_solid] which is available in cupertino_icons 0.1.3. + static const IconData tag_fill = IconData( + 0xf48d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_aligncenter". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_aligncenter = IconData( + 0xf84a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_alignleft". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_alignleft = IconData( + 0xf84b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_alignright". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_alignright = IconData( + 0xf84c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_append". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_append = IconData( + 0xf84d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_badge_checkmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_badge_checkmark = IconData( + 0xf84e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_badge_minus = IconData( + 0xf84f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_badge_plus = IconData( + 0xf850, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_badge_star". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_badge_star = IconData( + 0xf851, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_badge_xmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_badge_xmark = IconData( + 0xf852, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_bubble". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_bubble = IconData( + 0xf853, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_bubble_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_bubble_fill = IconData( + 0xf854, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_cursor". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_cursor = IconData( + 0xf855, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_insert". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_insert = IconData( + 0xf856, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_justify". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_justify = IconData( + 0xf857, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_justifyleft". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_justifyleft = IconData( + 0xf858, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_justifyright". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_justifyright = IconData( + 0xf859, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "text_quote". Available on cupertino_icons package 1.0.0+ only. + static const IconData text_quote = IconData( + 0xf85a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textbox". Available on cupertino_icons package 1.0.0+ only. + static const IconData textbox = IconData( + 0xf85b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat = IconData( + 0xf85c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_123". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_123 = IconData( + 0xf85d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_abc". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_abc = IconData( + 0xf85e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_abc_dottedunderline". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_abc_dottedunderline = IconData( + 0xf85f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_alt". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_alt = IconData( + 0xf860, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_size". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_size = IconData( + 0xf861, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_subscript". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_subscript = IconData( + 0xf862, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "textformat_superscript". Available on cupertino_icons package 1.0.0+ only. + static const IconData textformat_superscript = IconData( + 0xf863, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "thermometer". Available on cupertino_icons package 1.0.0+ only. + static const IconData thermometer = IconData( + 0xf864, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "thermometer_snowflake". Available on cupertino_icons package 1.0.0+ only. + static const IconData thermometer_snowflake = IconData( + 0xf865, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "thermometer_sun". Available on cupertino_icons package 1.0.0+ only. + static const IconData thermometer_sun = IconData( + 0xf866, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ticket". Available on cupertino_icons package 1.0.0+ only. + static const IconData ticket = IconData( + 0xf916, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "ticket_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData ticket_fill = IconData( + 0xf917, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tickets". Available on cupertino_icons package 1.0.0+ only. + static const IconData tickets = IconData( + 0xf918, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tickets_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tickets_fill = IconData( + 0xf919, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "timelapse". Available on cupertino_icons package 1.0.0+ only. + static const IconData timelapse = IconData( + 0xf867, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "timer". Available on cupertino_icons package 1.0.0+ only. + static const IconData timer = IconData( + 0xf868, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "timer_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData timer_fill = IconData( + 0xf91a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "today". Available on cupertino_icons package 1.0.0+ only. + static const IconData today = IconData( + 0xf91b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "today_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData today_fill = IconData( + 0xf91c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tornado". Available on cupertino_icons package 1.0.0+ only. + static const IconData tornado = IconData( + 0xf869, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tortoise". Available on cupertino_icons package 1.0.0+ only. + static const IconData tortoise = IconData( + 0xf86a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tortoise_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tortoise_fill = IconData( + 0xf86b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tram_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tram_fill = IconData( + 0xf86c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "trash". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [delete] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [delete_simple] which is available in cupertino_icons 0.1.3. + static const IconData trash = IconData( + 0xf4c4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "trash_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData trash_circle = IconData( + 0xf86d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "trash_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData trash_circle_fill = IconData( + 0xf86e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "trash_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [delete_solid] which is available in cupertino_icons 0.1.3. + static const IconData trash_fill = IconData( + 0xf4c5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "trash_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData trash_slash = IconData( + 0xf86f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "trash_slash_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData trash_slash_fill = IconData( + 0xf870, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray = IconData(0xf871, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "tray_2". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_2 = IconData( + 0xf872, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_2_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_2_fill = IconData( + 0xf873, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_arrow_down". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_arrow_down = IconData( + 0xf874, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_arrow_down_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_arrow_down_fill = IconData( + 0xf875, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_arrow_up". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_arrow_up = IconData( + 0xf876, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_arrow_up_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_arrow_up_fill = IconData( + 0xf877, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_fill = IconData( + 0xf878, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_full". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_full = IconData( + 0xf879, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tray_full_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tray_full_fill = IconData( + 0xf87a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tree". Available on cupertino_icons package 1.0.0+ only. + static const IconData tree = IconData(0xf91d, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "triangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData triangle = IconData( + 0xf87b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "triangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData triangle_fill = IconData( + 0xf87c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "triangle_lefthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData triangle_lefthalf_fill = IconData( + 0xf87d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "triangle_righthalf_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData triangle_righthalf_fill = IconData( + 0xf87e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tropicalstorm". Available on cupertino_icons package 1.0.0+ only. + static const IconData tropicalstorm = IconData( + 0xf87f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tuningfork". Available on cupertino_icons package 1.0.0+ only. + static const IconData tuningfork = IconData( + 0xf880, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tv". Available on cupertino_icons package 1.0.0+ only. + static const IconData tv = IconData(0xf881, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "tv_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData tv_circle = IconData( + 0xf882, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tv_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tv_circle_fill = IconData( + 0xf883, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tv_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tv_fill = IconData( + 0xf884, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tv_music_note". Available on cupertino_icons package 1.0.0+ only. + static const IconData tv_music_note = IconData( + 0xf885, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "tv_music_note_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData tv_music_note_fill = IconData( + 0xf886, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "uiwindow_split_2x1". Available on cupertino_icons package 1.0.0+ only. + static const IconData uiwindow_split_2x1 = IconData( + 0xf887, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "umbrella". Available on cupertino_icons package 1.0.0+ only. + static const IconData umbrella = IconData( + 0xf888, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "umbrella_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData umbrella_fill = IconData( + 0xf889, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "underline". Available on cupertino_icons package 1.0.0+ only. + static const IconData underline = IconData( + 0xf88a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "upload_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData upload_circle = IconData( + 0xf91e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "upload_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData upload_circle_fill = IconData( + 0xf91f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "videocam". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [video_camera] which is available in cupertino_icons 0.1.3. + static const IconData videocam = IconData( + 0xf4cc, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "videocam_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData videocam_circle = IconData( + 0xf920, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "videocam_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData videocam_circle_fill = IconData( + 0xf921, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "videocam_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [video_camera_solid] which is available in cupertino_icons 0.1.3. + static const IconData videocam_fill = IconData( + 0xf4cd, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "view_2d". Available on cupertino_icons package 1.0.0+ only. + static const IconData view_2d = IconData( + 0xf88b, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "view_3d". Available on cupertino_icons package 1.0.0+ only. + static const IconData view_3d = IconData( + 0xf88c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "viewfinder". Available on cupertino_icons package 1.0.0+ only. + static const IconData viewfinder = IconData( + 0xf88d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "viewfinder_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData viewfinder_circle = IconData( + 0xf88e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "viewfinder_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData viewfinder_circle_fill = IconData( + 0xf88f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wand_rays". Available on cupertino_icons package 1.0.0+ only. + static const IconData wand_rays = IconData( + 0xf890, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wand_rays_inverse". Available on cupertino_icons package 1.0.0+ only. + static const IconData wand_rays_inverse = IconData( + 0xf891, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wand_stars". Available on cupertino_icons package 1.0.0+ only. + static const IconData wand_stars = IconData( + 0xf892, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wand_stars_inverse". Available on cupertino_icons package 1.0.0+ only. + static const IconData wand_stars_inverse = IconData( + 0xf893, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform = IconData( + 0xf894, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform_circle". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform_circle = IconData( + 0xf895, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform_circle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform_circle_fill = IconData( + 0xf896, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform_path". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform_path = IconData( + 0xf897, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform_path_badge_minus". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform_path_badge_minus = IconData( + 0xf898, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform_path_badge_plus". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform_path_badge_plus = IconData( + 0xf899, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "waveform_path_ecg". Available on cupertino_icons package 1.0.0+ only. + static const IconData waveform_path_ecg = IconData( + 0xf89a, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wifi". Available on cupertino_icons package 1.0.0+ only. + static const IconData wifi = IconData(0xf89b, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "wifi_exclamationmark". Available on cupertino_icons package 1.0.0+ only. + static const IconData wifi_exclamationmark = IconData( + 0xf89c, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wifi_slash". Available on cupertino_icons package 1.0.0+ only. + static const IconData wifi_slash = IconData( + 0xf89d, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wind". Available on cupertino_icons package 1.0.0+ only. + static const IconData wind = IconData(0xf89e, fontFamily: iconFont, fontPackage: iconFontPackage); + + /// — Cupertino icon named "wind_snow". Available on cupertino_icons package 1.0.0+ only. + static const IconData wind_snow = IconData( + 0xf89f, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wrench". Available on cupertino_icons package 1.0.0+ only. + static const IconData wrench = IconData( + 0xf8a0, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "wrench_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData wrench_fill = IconData( + 0xf8a1, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [clear_thick] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [clear] which is available in cupertino_icons 0.1.3. + static const IconData xmark = IconData( + 0xf404, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_circle". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [clear_circled] which is available in cupertino_icons 0.1.3. + static const IconData xmark_circle = IconData( + 0xf405, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_circle_fill". Available on cupertino_icons package 1.0.0+ only. + /// This is the same icon as [clear_thick_circled] which is available in cupertino_icons 0.1.3. + /// This is the same icon as [clear_circled_solid] which is available in cupertino_icons 0.1.3. + static const IconData xmark_circle_fill = IconData( + 0xf36e, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_octagon". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_octagon = IconData( + 0xf8a2, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_octagon_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_octagon_fill = IconData( + 0xf8a3, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_rectangle". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_rectangle = IconData( + 0xf8a4, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_rectangle_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_rectangle_fill = IconData( + 0xf8a5, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_seal". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_seal = IconData( + 0xf8a6, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_seal_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_seal_fill = IconData( + 0xf8a7, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_shield". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_shield = IconData( + 0xf8a8, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_shield_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_shield_fill = IconData( + 0xf8a9, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_square". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_square = IconData( + 0xf8aa, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "xmark_square_fill". Available on cupertino_icons package 1.0.0+ only. + static const IconData xmark_square_fill = IconData( + 0xf8ab, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "zoom_in". Available on cupertino_icons package 1.0.0+ only. + static const IconData zoom_in = IconData( + 0xf8ac, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "zoom_out". Available on cupertino_icons package 1.0.0+ only. + static const IconData zoom_out = IconData( + 0xf8ad, + fontFamily: iconFont, + fontPackage: iconFontPackage, + ); + + /// — Cupertino icon named "zzz". Available on cupertino_icons package 1.0.0+ only. + static const IconData zzz = IconData(0xf8ae, fontFamily: iconFont, fontPackage: iconFontPackage); + // END GENERATED SF SYMBOLS NAMES + // =========================================================================== +} diff --git a/packages/cupertino_ui/lib/src/interface_level.dart b/packages/cupertino_ui/lib/src/interface_level.dart new file mode 100644 index 000000000000..238a09cb5a5f --- /dev/null +++ b/packages/cupertino_ui/lib/src/interface_level.dart @@ -0,0 +1,106 @@ +// 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'; + +/// Indicates the visual level for a piece of content. Equivalent to `UIUserInterfaceLevel` +/// from `UIKit`. +/// +/// See also: +/// +/// * `UIUserInterfaceLevel`, the UIKit equivalent: https://developer.apple.com/documentation/uikit/uiuserinterfacelevel. +enum CupertinoUserInterfaceLevelData { + /// The level for your window's main content. + base, + + /// The level for content visually above [base]. + elevated, +} + +/// Establishes a subtree in which [CupertinoUserInterfaceLevel.of] resolves to +/// the given visual elevation from the [CupertinoUserInterfaceLevelData]. This +/// can be used to apply style differences based on a widget's elevation. +/// +/// Querying the current elevation status using [CupertinoUserInterfaceLevel.of] +/// will cause your widget to rebuild automatically whenever the +/// [CupertinoUserInterfaceLevelData] changes. +/// +/// If no [CupertinoUserInterfaceLevel] is in scope then the +/// [CupertinoUserInterfaceLevel.of] method will throw an exception. +/// Alternatively, [CupertinoUserInterfaceLevel.maybeOf] can be used, which +/// returns null instead of throwing if no [CupertinoUserInterfaceLevel] is in +/// scope. +/// +/// See also: +/// +/// * [CupertinoUserInterfaceLevelData], specifies the visual level for the content +/// in the subtree [CupertinoUserInterfaceLevel] established. +class CupertinoUserInterfaceLevel extends InheritedWidget { + /// Creates a [CupertinoUserInterfaceLevel] to change descendant Cupertino widget's + /// visual level. + const CupertinoUserInterfaceLevel({ + super.key, + required CupertinoUserInterfaceLevelData data, + required super.child, + }) : _data = data; + + final CupertinoUserInterfaceLevelData _data; + + @override + bool updateShouldNotify(CupertinoUserInterfaceLevel oldWidget) => oldWidget._data != _data; + + /// The data from the closest instance of this class that encloses the given + /// context. + /// + /// You can use this function to query the user interface elevation level within + /// the given [BuildContext]. When that information changes, your widget will + /// be scheduled to be rebuilt, keeping your widget up-to-date. + /// + /// See also: + /// + /// * [maybeOf], which is similar, but will return null if no + /// [CupertinoUserInterfaceLevel] encloses the given context. + static CupertinoUserInterfaceLevelData of(BuildContext context) { + final CupertinoUserInterfaceLevel? query = context + .dependOnInheritedWidgetOfExactType(); + if (query != null) { + return query._data; + } + throw FlutterError( + 'CupertinoUserInterfaceLevel.of() called with a context that does not contain a CupertinoUserInterfaceLevel.\n' + 'No CupertinoUserInterfaceLevel ancestor could be found starting from the context that was passed ' + 'to CupertinoUserInterfaceLevel.of(). This can happen because you do not have a WidgetsApp or ' + 'MaterialApp widget (those widgets introduce a CupertinoUserInterfaceLevel), or it can happen ' + 'if the context you use comes from a widget above those widgets.\n' + 'The context used was:\n' + ' $context', + ); + } + + /// The data from the closest instance of this class that encloses the given + /// context, if there is one. + /// + /// Returns null if no [CupertinoUserInterfaceLevel] encloses the given context. + /// + /// You can use this function to query the user interface elevation level within + /// the given [BuildContext]. When that information changes, your widget will + /// be scheduled to be rebuilt, keeping your widget up-to-date. + /// + /// See also: + /// + /// * [of], which is similar, but will throw an exception if no + /// [CupertinoUserInterfaceLevel] encloses the given context. + static CupertinoUserInterfaceLevelData? maybeOf(BuildContext context) { + final CupertinoUserInterfaceLevel? query = context + .dependOnInheritedWidgetOfExactType(); + return query?._data; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('user interface level', _data)); + } +} diff --git a/packages/cupertino_ui/lib/src/list_section.dart b/packages/cupertino_ui/lib/src/list_section.dart new file mode 100644 index 000000000000..c8c913915c98 --- /dev/null +++ b/packages/cupertino_ui/lib/src/list_section.dart @@ -0,0 +1,519 @@ +// 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. + +/// @docImport 'form_row.dart'; +/// @docImport 'form_section.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'text_form_field_row.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +// Margin on top of the list section. This was eyeballed from iOS 14.4 Simulator +// and should be always present on top of the edge-to-edge variant. +const double _kMarginTop = 22.0; + +// Standard header margin, determined from SwiftUI's Forms in iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultHeaderMargin = EdgeInsetsDirectional.fromSTEB( + 20.0, + 0.0, + 20.0, + 6.0, +); + +// Header margin for inset grouped variant, determined from iOS 14.4 Simulator. +const EdgeInsetsDirectional _kInsetGroupedDefaultHeaderMargin = EdgeInsetsDirectional.fromSTEB( + 20.0, + 16.0, + 20.0, + 6.0, +); + +// Standard footer margin, determined from SwiftUI's Forms in iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultFooterMargin = EdgeInsetsDirectional.fromSTEB( + 20.0, + 0.0, + 20.0, + 0.0, +); + +// Footer margin for inset grouped variant, determined from iOS 14.4 Simulator. +const EdgeInsetsDirectional _kInsetGroupedDefaultFooterMargin = EdgeInsetsDirectional.fromSTEB( + 20.0, + 0.0, + 20.0, + 10.0, +); + +// Margin around children in edge-to-edge variant, determined from iOS 14.4 +// Simulator. +const EdgeInsets _kDefaultRowsMargin = EdgeInsets.only(bottom: 8.0); + +// Used for iOS "Inset Grouped" margin, determined from SwiftUI's Forms in +// iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultInsetGroupedRowsMargin = EdgeInsetsDirectional.fromSTEB( + 20.0, + 20.0, + 20.0, + 10.0, +); + +// Used for iOS "Inset Grouped" margin, determined from SwiftUI's Forms in +// iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultInsetGroupedRowsMarginWithHeader = + EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); + +// Used for iOS "Inset Grouped" border radius, estimated from SwiftUI's Forms in +// iOS 14.2 SDK. +// TODO(edrisian): This should be a rounded rectangle once that shape is added. +const BorderRadius _kDefaultInsetGroupedBorderRadius = BorderRadius.all(Radius.circular(10.0)); + +// The margin of divider used in base list section. Estimated from iOS 14.4 SDK +// Settings app. +const double _kBaseDividerMargin = 20.0; + +// Additional margin of divider used in base list section with list tiles with +// leading widgets. Estimated from iOS 14.4 SDK Settings app. +const double _kBaseAdditionalDividerMargin = 44.0; + +// The margin of divider used in inset grouped version of list section. +// Estimated from iOS 14.4 SDK Reminders app. +const double _kInsetDividerMargin = 14.0; + +// Additional margin of divider used in inset grouped version of list section. +// Estimated from iOS 14.4 SDK Reminders app. +const double _kInsetAdditionalDividerMargin = 42.0; + +// Additional margin of divider used in inset grouped version of list section +// when there is no leading widgets. Estimated from iOS 14.4 SDK Notes app. +const double _kInsetAdditionalDividerMarginWithoutLeading = 14.0; + +// Color of header and footer text in edge-to-edge variant. +const Color _kHeaderFooterColor = CupertinoDynamicColor( + color: Color.fromRGBO(108, 108, 108, 1.0), + darkColor: Color.fromRGBO(142, 142, 146, 1.0), + highContrastColor: Color.fromRGBO(74, 74, 77, 1.0), + darkHighContrastColor: Color.fromRGBO(176, 176, 183, 1.0), + elevatedColor: Color.fromRGBO(108, 108, 108, 1.0), + darkElevatedColor: Color.fromRGBO(142, 142, 146, 1.0), + highContrastElevatedColor: Color.fromRGBO(108, 108, 108, 1.0), + darkHighContrastElevatedColor: Color.fromRGBO(142, 142, 146, 1.0), +); + +/// Denotes what type of the list section a [CupertinoListSection] is. +/// +/// This is for internal use only. +enum CupertinoListSectionType { + /// A basic form of [CupertinoListSection]. + base, + + /// An inset-grouped style of [CupertinoListSection]. + insetGrouped, +} + +/// An iOS-style list section. +/// +/// The [CupertinoListSection] is a container for children widgets. These are +/// most often [CupertinoListTile]s. +/// +/// The base constructor for [CupertinoListSection] constructs an +/// edge-to-edge style section which includes an iOS-style header, the dividers +/// between rows, and borders on top and bottom of the rows. An example of such +/// list section are sections in iOS Settings app. +/// +/// The [CupertinoListSection.insetGrouped] constructor creates a round-edged +/// and padded section that is seen in iOS Notes and Reminders apps. It creates +/// an iOS-style header, and the dividers between rows. Does not create borders +/// on top and bottom of the rows. +/// +/// The section [header] lies above the [children] rows, with margins and style +/// that match the iOS style. +/// +/// The section [footer] lies below the [children] rows and is used to provide +/// additional information for current list section. +/// +/// The [children] is the list of widgets to be displayed in this list section. +/// Typically, the children are of type [CupertinoListTile], however these is +/// not enforced. +/// +/// The [margin] is used to provide spacing around the content area of the +/// section encapsulating [children]. +/// +/// The [decoration] of [children] specifies how they should be decorated. If it +/// is not provided in constructor, the background color of [children] defaults +/// to [CupertinoColors.secondarySystemGroupedBackground] and border radius of +/// children group defaults to 10.0 circular radius when constructing with +/// [CupertinoListSection.insetGrouped]. Defaults to zero radius for the +/// standard [CupertinoListSection] constructor. +/// +/// The [dividerMargin] and [additionalDividerMargin] specify the starting +/// margin of the divider between list tiles. The [dividerMargin] is always +/// present, but [additionalDividerMargin] is only added to the [dividerMargin] +/// if `hasLeading` is set to true in the constructor, which is the default +/// value. +/// +/// The [backgroundColor] of the section defaults to +/// [CupertinoColors.systemGroupedBackground]. +/// +/// {@macro flutter.material.Material.clipBehavior} +/// +/// {@tool dartpad} +/// Creates a base [CupertinoListSection] containing [CupertinoListTile]s with +/// `leading`, `title`, `additionalInfo` and `trailing` widgets. +/// +/// ** See code in examples/api/lib/cupertino/list_section/list_section_base.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// Creates an "Inset Grouped" [CupertinoListSection] containing +/// notched [CupertinoListTile]s with `leading`, `title`, `additionalInfo` and +/// `trailing` widgets. +/// +/// ** See code in examples/api/lib/cupertino/list_section/list_section_inset.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoListTile], an iOS-style list tile, a typical child of +/// [CupertinoListSection]. +/// * [CupertinoFormSection], an iOS-style form section. +class CupertinoListSection extends StatelessWidget { + /// Creates a section that mimics standard iOS forms. + /// + /// The base constructor for [CupertinoListSection] constructs an + /// edge-to-edge style section which includes an iOS-style header, the dividers + /// between rows, and borders on top and bottom of the rows. An example of such + /// list section are sections in iOS Settings app. + /// + /// The [header] parameter sets the form section header. The section header + /// lies above the [children] rows, with margins that match the iOS style. + /// + /// The [footer] parameter sets the form section footer. The section footer + /// lies below the [children] rows. + /// + /// The [children] parameter is required and sets the list of rows shown in + /// the section. The [children] parameter takes a list, as opposed to a more + /// efficient builder function that lazy builds, because forms are intended to + /// be short in row count. It is recommended that only [CupertinoFormRow] and + /// [CupertinoTextFormFieldRow] widgets be included in the [children] list in + /// order to retain the iOS look. + /// + /// The [margin] parameter sets the spacing around the content area of the + /// section encapsulating [children], and defaults to zero padding. + /// + /// The [decoration] parameter sets the decoration around [children]. + /// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. + /// If null, defaults to 10.0 circular radius when constructing with + /// [CupertinoListSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoListSection] constructor. + /// + /// The [backgroundColor] parameter sets the background color behind the + /// section. If null, defaults to [CupertinoColors.systemGroupedBackground]. + /// + /// The [dividerMargin] parameter sets the starting offset of the divider + /// between rows. + /// + /// The [additionalDividerMargin] parameter adds additional margin to existing + /// [dividerMargin] when [hasLeading] is set to true. By default, it offsets + /// for the width of leading and space between leading and title of + /// [CupertinoListTile], but it can be overwritten for custom look. + /// + /// The [hasLeading] parameter specifies whether children [CupertinoListTile] + /// widgets contain leading or not. Used for calculating correct starting + /// margin for the divider between rows. + /// + /// The [topMargin] is used to specify the margin above the list section. It + /// matches the iOS look by default. + /// + /// {@macro flutter.material.Material.clipBehavior} + const CupertinoListSection({ + super.key, + this.children, + this.header, + this.footer, + this.margin = _kDefaultRowsMargin, + this.backgroundColor = CupertinoColors.systemGroupedBackground, + this.decoration, + this.clipBehavior = Clip.none, + this.dividerMargin = _kBaseDividerMargin, + double? additionalDividerMargin, + this.topMargin = _kMarginTop, + bool hasLeading = true, + this.separatorColor, + }) : assert((children != null && children.length > 0) || header != null), + type = CupertinoListSectionType.base, + additionalDividerMargin = + additionalDividerMargin ?? (hasLeading ? _kBaseAdditionalDividerMargin : 0.0); + + /// Creates a section that mimics standard "Inset Grouped" iOS list section. + /// + /// The [CupertinoListSection.insetGrouped] constructor creates a round-edged + /// and padded section that is seen in iOS Notes and Reminders apps. It creates + /// an iOS-style header, and the dividers between rows. Does not create borders + /// on top and bottom of the rows. + /// + /// The [header] parameter sets the form section header. The section header + /// lies above the [children] rows, with margins that match the iOS style. + /// + /// The [footer] parameter sets the form section footer. The section footer + /// lies below the [children] rows. + /// + /// The [children] parameter is required and sets the list of rows shown in + /// the section. The [children] parameter takes a list, as opposed to a more + /// efficient builder function that lazy builds, because forms are intended to + /// be short in row count. It is recommended that only [CupertinoListTile] + /// widget be included in the [children] list in order to retain the iOS look. + /// + /// The [margin] parameter sets the spacing around the content area of the + /// section encapsulating [children], and defaults to the standard + /// notched-style iOS form padding. + /// + /// The [decoration] parameter sets the decoration around [children]. + /// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. + /// If null, defaults to 10.0 circular radius when constructing with + /// [CupertinoListSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoListSection] constructor. + /// + /// The [backgroundColor] parameter sets the background color behind the + /// section. If null, defaults to [CupertinoColors.systemGroupedBackground]. + /// + /// The [dividerMargin] parameter sets the starting offset of the divider + /// between rows. + /// + /// The [additionalDividerMargin] parameter adds additional margin to existing + /// [dividerMargin] when [hasLeading] is set to true. By default, it offsets + /// for the width of leading and space between leading and title of + /// [CupertinoListTile], but it can be overwritten for custom look. + /// + /// The [hasLeading] parameter specifies whether children [CupertinoListTile] + /// widgets contain leading or not. Used for calculating correct starting + /// margin for the divider between rows. + /// + /// {@macro flutter.material.Material.clipBehavior} + const CupertinoListSection.insetGrouped({ + super.key, + this.children, + this.header, + this.footer, + EdgeInsetsGeometry? margin, + this.backgroundColor = CupertinoColors.systemGroupedBackground, + this.decoration, + this.clipBehavior = Clip.hardEdge, + this.dividerMargin = _kInsetDividerMargin, + double? additionalDividerMargin, + this.topMargin, + bool hasLeading = true, + this.separatorColor, + }) : assert((children != null && children.length > 0) || header != null), + type = CupertinoListSectionType.insetGrouped, + additionalDividerMargin = + additionalDividerMargin ?? + (hasLeading + ? _kInsetAdditionalDividerMargin + : _kInsetAdditionalDividerMarginWithoutLeading), + margin = + margin ?? + (header == null + ? _kDefaultInsetGroupedRowsMargin + : _kDefaultInsetGroupedRowsMarginWithHeader); + + /// The type of list section, either base or inset grouped. + /// + /// This member is public for testing purposes only and cannot be set + /// manually. Instead, use a corresponding constructors. + @visibleForTesting + final CupertinoListSectionType type; + + /// Sets the form section header. The section header lies above the [children] + /// rows. Usually a [Text] widget. + final Widget? header; + + /// Sets the form section footer. The section footer lies below the [children] + /// rows. Usually a [Text] widget. + final Widget? footer; + + /// Margin around the content area of the section encapsulating [children]. + /// + /// Defaults to zero padding if constructed with standard + /// [CupertinoListSection] constructor. Defaults to the standard notched-style + /// iOS margin when constructing with [CupertinoListSection.insetGrouped]. + final EdgeInsetsGeometry margin; + + /// The list of rows in the section. Usually a list of [CupertinoListTile]s. + /// + /// This takes a list, as opposed to a more efficient builder function that + /// lazy builds, because such lists are intended to be short in row count. + /// It is recommended that only [CupertinoListTile] widget be included in the + /// [children] list in order to retain the iOS look. + final List? children; + + /// Sets the decoration around [children]. + /// + /// If null, background color defaults to + /// [CupertinoColors.secondarySystemGroupedBackground]. + /// + /// If null, border radius defaults to 10.0 circular radius when constructing + /// with [CupertinoListSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoListSection] constructor. + final BoxDecoration? decoration; + + /// Sets the background color behind the section. + /// + /// Defaults to [CupertinoColors.systemGroupedBackground]. + final Color backgroundColor; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// The starting offset of a margin between two list tiles. + final double dividerMargin; + + /// Additional starting inset of the divider used between rows. This is used + /// when adding a leading icon to children and a divider should start at the + /// text inset instead of the icon. + final double additionalDividerMargin; + + /// Margin above the list section. Only used in edge-to-edge variant and it + /// matches iOS style by default. + final double? topMargin; + + /// Sets the color for the dividers between rows, and borders on top and + /// bottom of the rows. + /// + /// If null, defaults to [CupertinoColors.separator]. + final Color? separatorColor; + + @override + Widget build(BuildContext context) { + final Color dividerColor = separatorColor ?? CupertinoColors.separator.resolveFrom(context); + final double dividerHeight = 1.0 / MediaQuery.devicePixelRatioOf(context); + + // Long divider is used for wrapping the top and bottom of rows. + // Only used in CupertinoListSectionType.base mode. + final Widget longDivider = Container(color: dividerColor, height: dividerHeight); + + // Short divider is used between rows. + final Widget shortDivider = Container( + margin: EdgeInsetsDirectional.only(start: dividerMargin + additionalDividerMargin), + color: dividerColor, + height: dividerHeight, + ); + + TextStyle style = CupertinoTheme.of(context).textTheme.textStyle; + + Widget? headerWidget, footerWidget; + switch (type) { + case CupertinoListSectionType.base: + style = style.merge( + TextStyle( + fontSize: 13.0, + color: CupertinoDynamicColor.resolve(_kHeaderFooterColor, context), + ), + ); + if (header != null) { + headerWidget = DefaultTextStyle(style: style, child: header!); + } + if (footer != null) { + footerWidget = DefaultTextStyle(style: style, child: footer!); + } + case CupertinoListSectionType.insetGrouped: + if (header != null) { + headerWidget = DefaultTextStyle( + style: style.merge(const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold)), + child: header!, + ); + } + if (footer != null) { + footerWidget = DefaultTextStyle(style: style, child: footer!); + } + } + + Widget? decoratedChildrenGroup; + if (children != null && children!.isNotEmpty) { + // We construct childrenWithDividers as follows: + // Insert a short divider between all rows. + // If it is a `CupertinoListSectionType.base` type, add a long divider + // to the top and bottom of the rows. + final childrenWithDividers = []; + + if (type == CupertinoListSectionType.base) { + childrenWithDividers.add(longDivider); + } + + children!.sublist(0, children!.length - 1).forEach((Widget widget) { + childrenWithDividers.add(widget); + childrenWithDividers.add(shortDivider); + }); + + childrenWithDividers.add(children!.last); + if (type == CupertinoListSectionType.base) { + childrenWithDividers.add(longDivider); + } + + final BorderRadius childrenGroupBorderRadius = switch (type) { + CupertinoListSectionType.insetGrouped => _kDefaultInsetGroupedBorderRadius, + CupertinoListSectionType.base => BorderRadius.zero, + }; + + decoratedChildrenGroup = DecoratedBox( + decoration: + decoration ?? + ShapeDecoration( + color: CupertinoDynamicColor.resolve( + decoration?.color ?? CupertinoColors.secondarySystemGroupedBackground, + context, + ), + shape: RoundedSuperellipseBorder(borderRadius: childrenGroupBorderRadius), + ), + child: Column(children: childrenWithDividers), + ); + + decoratedChildrenGroup = Padding( + padding: margin, + child: clipBehavior == Clip.none + ? decoratedChildrenGroup + : ClipRSuperellipse( + borderRadius: childrenGroupBorderRadius, + clipBehavior: clipBehavior, + child: decoratedChildrenGroup, + ), + ); + } + + return DecoratedBox( + decoration: BoxDecoration(color: CupertinoDynamicColor.resolve(backgroundColor, context)), + child: Column( + children: [ + if (type == CupertinoListSectionType.base) SizedBox(height: topMargin), + if (headerWidget != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: type == CupertinoListSectionType.base + ? _kDefaultHeaderMargin + : _kInsetGroupedDefaultHeaderMargin, + child: headerWidget, + ), + ), + ?decoratedChildrenGroup, + if (footerWidget != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: type == CupertinoListSectionType.base + ? _kDefaultFooterMargin + : _kInsetGroupedDefaultFooterMargin, + child: footerWidget, + ), + ), + ], + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/list_tile.dart b/packages/cupertino_ui/lib/src/list_tile.dart new file mode 100644 index 000000000000..811367684044 --- /dev/null +++ b/packages/cupertino_ui/lib/src/list_tile.dart @@ -0,0 +1,413 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'button.dart'; +/// @docImport 'list_section.dart'; +/// @docImport 'switch.dart'; +library; + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'icons.dart'; +import 'theme.dart'; + +// These constants were eyeballed from iOS 14.4 Settings app for base, Notes for +// notched without leading, and Reminders app for notched with leading. +const double _kLeadingSize = 28.0; +const double _kNotchedLeadingSize = 30.0; +const double _kMinHeight = _kLeadingSize + 2 * 8.0; +const double _kMinHeightWithSubtitle = _kLeadingSize + 2 * 10.0; +const double _kNotchedMinHeight = _kNotchedLeadingSize + 2 * 12.0; +const double _kNotchedMinHeightWithoutLeading = _kNotchedLeadingSize + 2 * 10.0; +const EdgeInsetsDirectional _kPadding = EdgeInsetsDirectional.only(start: 20.0, end: 14.0); +const EdgeInsetsDirectional _kPaddingWithSubtitle = EdgeInsetsDirectional.only( + start: 20.0, + end: 14.0, +); +const EdgeInsets _kNotchedPadding = EdgeInsets.symmetric(horizontal: 14.0); +const EdgeInsetsDirectional _kNotchedPaddingWithoutLeading = EdgeInsetsDirectional.fromSTEB( + 28.0, + 10.0, + 14.0, + 10.0, +); +const double _kLeadingToTitle = 16.0; +const double _kNotchedLeadingToTitle = 12.0; +const double _kNotchedTitleToSubtitle = 3.0; +const double _kAdditionalInfoToTrailing = 6.0; +const double _kNotchedTitleWithSubtitleFontSize = 16.0; +const double _kSubtitleFontSize = 12.0; +const double _kNotchedSubtitleFontSize = 14.0; + +enum _CupertinoListTileType { base, notched } + +/// An iOS-style list tile. +/// +/// The [CupertinoListTile] is a Cupertino equivalent of Material [ListTile]. +/// It comes in two forms, an old-fashioned edge-to-edge variant known from iOS +/// Settings app and in a new, "Inset Grouped" form, known from either iOS Notes +/// or Reminders app. The first is constructed using default constructor, and +/// the latter using named constructor [CupertinoListTile.notched]. +/// +/// The [title], [subtitle], and [additionalInfo] are usually [Text] widgets. +/// They are all limited to one line so it is a responsibility of the caller to +/// take care of text wrapping. +/// +/// The size of [leading] is by default constrained to match the iOS size, +/// depending of the type of list tile. This can however be overridden by +/// providing [leadingSize]. The [trailing] widget is not constrained and is +/// therefore a responsibility of the caller to ensure reasonable size of the +/// [trailing] widget. +/// +/// The background color of the tile can be set with [backgroundColor] for the +/// state before tile was tapped and with [backgroundColorActivated] for the +/// state after the tile was tapped. By default, both values are set to match +/// the default iOS appearance. +/// +/// The [padding] and [leadingToTitle] are by default set to match iOS but can +/// be overwritten if necessary. +/// +/// The [onTap] callback provides an option to react to taps anywhere inside the +/// list tile. This can be used to navigate routes and according to iOS +/// behavior it should not be used for example to toggle the [CupertinoSwitch] +/// in the trailing widget. +/// +/// {@tool dartpad} +/// This example uses a [ListView] to demonstrate different configurations of +/// [CupertinoListTile]s. +/// +/// ** See code in examples/api/lib/cupertino/list_tile/cupertino_list_tile.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoListSection], an iOS-style list that is a typical container for +/// [CupertinoListTile]. +/// * [ListTile], a Material Design list tile. +class CupertinoListTile extends StatefulWidget { + /// Creates an edge-to-edge iOS-style list tile like the tiles in iOS Settings + /// app. + /// + /// The [title] parameter is required. It is used to convey the most important + /// information of list tile. It is typically a [Text]. + /// + /// The [subtitle] parameter is used to display additional information. It is + /// placed below the [title]. + /// + /// The [additionalInfo] parameter is used to display additional information. + /// It is placed at the end of the tile, before the [trailing] if supplied. + /// + /// The [leading] parameter is typically an [Icon] or an [Image] and it comes + /// at the start of the tile. If omitted in all list tiles, a `hasLeading` of + /// enclosing [CupertinoListSection] should be set to `false` to ensure + /// correct margin of divider between tiles. + /// + /// The [trailing] parameter is typically a [CupertinoListTileChevron], an + /// [Icon], or a [CupertinoButton]. It is placed at the very end of the tile. + /// + /// The [onTap] parameter is used to provide an action that is called when the + /// tile is tapped. It is mainly used for navigating to a new route. It should + /// not be used to toggle a trailing [CupertinoSwitch] and similar use cases + /// because when tile is tapped, it switches the background color and remains + /// changed. This is according to iOS behavior. + /// + /// The [backgroundColor] provides a custom background color for the tile in + /// a state before tapped. By default, it matches the theme's background color + /// which is by default a [CupertinoColors.systemBackground]. + /// + /// The [backgroundColorActivated] provides a custom background color for the + /// tile after it was tapped. By default, it matches the theme's background + /// color which is by default a [CupertinoColors.systemGrey4]. + /// + /// The [padding] parameter sets the padding of the content inside the tile. + /// It defaults to a value that matches the iOS look, depending on a type of + /// [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingSize] constrains the width and height of the leading widget. + /// By default, it is set to a value that matches the iOS look, depending on a + /// type of [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingToTitle] specifies the horizontal space between [leading] and + /// [title] widgets. By default, it is set to a value that matched the iOS + /// look, depending on a type of [CupertinoListTile]. For native look, it + /// should not be provided. + const CupertinoListTile({ + super.key, + required this.title, + this.subtitle, + this.additionalInfo, + this.leading, + this.trailing, + this.onTap, + this.backgroundColor, + this.backgroundColorActivated, + this.padding, + this.leadingSize = _kLeadingSize, + this.leadingToTitle = _kLeadingToTitle, + }) : _type = _CupertinoListTileType.base; + + /// Creates a notched iOS-style list tile like the tiles in iOS Notes app or + /// Reminders app. + /// + /// The [title] parameter is required. It is used to convey the most important + /// information of list tile. It is typically a [Text]. + /// + /// The [subtitle] parameter is used to display additional information. It is + /// placed below the [title]. + /// + /// The [additionalInfo] parameter is used to display additional information. + /// It is placed at the end of the tile, before the [trailing] if supplied. + /// + /// The [leading] parameter is typically an [Icon] or an [Image] and it comes + /// at the start of the tile. If omitted in all list tiles, a `hasLeading` of + /// enclosing [CupertinoListSection] should be set to `false` to ensure + /// correct margin of divider between tiles. For Notes-like tile appearance, + /// the [leading] can be left `null`. + /// + /// The [trailing] parameter is typically a [CupertinoListTileChevron], an + /// [Icon], or a [CupertinoButton]. It is placed at the very end of the tile. + /// For Notes-like tile appearance, the [trailing] can be left `null`. + /// + /// The [onTap] parameter is used to provide an action that is called when the + /// tile is tapped. It is mainly used for navigating to a new route. It should + /// not be used to toggle a trailing [CupertinoSwitch] and similar use cases + /// because when tile is tapped, it switches the background color and remains + /// changed. This is according to iOS behavior. + /// + /// The [backgroundColor] provides a custom background color for the tile in + /// a state before tapped. By default, it matches the theme's background color + /// which is by default a [CupertinoColors.systemBackground]. + /// + /// The [backgroundColorActivated] provides a custom background color for the + /// tile after it was tapped. By default, it matches the theme's background + /// color which is by default a [CupertinoColors.systemGrey4]. + /// + /// The [padding] parameter sets the padding of the content inside the tile. + /// It defaults to a value that matches the iOS look, depending on a type of + /// [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingSize] constrains the width and height of the leading widget. + /// By default, it is set to a value that matches the iOS look, depending on a + /// type of [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingToTitle] specifies the horizontal space between [leading] and + /// [title] widgets. By default, it is set to a value that matched the iOS + /// look, depending on a type of [CupertinoListTile]. For native look, it + /// should not be provided. + const CupertinoListTile.notched({ + super.key, + required this.title, + this.subtitle, + this.additionalInfo, + this.leading, + this.trailing, + this.onTap, + this.backgroundColor, + this.backgroundColorActivated, + this.padding, + this.leadingSize = _kNotchedLeadingSize, + this.leadingToTitle = _kNotchedLeadingToTitle, + }) : _type = _CupertinoListTileType.notched; + + final _CupertinoListTileType _type; + + /// A [title] is used to convey the central information. Usually a [Text]. + final Widget title; + + /// A [subtitle] is used to display additional information. It is located + /// below [title]. Usually a [Text] widget. + final Widget? subtitle; + + /// Similar to [subtitle], an [additionalInfo] is used to display additional + /// information. However, instead of being displayed below [title], it is + /// displayed on the right, before [trailing]. Usually a [Text] widget. + final Widget? additionalInfo; + + /// A widget displayed at the start of the [CupertinoListTile]. This is + /// typically an `Icon` or an `Image`. + final Widget? leading; + + /// A widget displayed at the end of the [CupertinoListTile]. This is usually + /// a right chevron icon (e.g. `CupertinoListTileChevron`), or an `Icon`. + final Widget? trailing; + + /// The [onTap] function is called when a user taps on [CupertinoListTile]. If + /// left `null`, the [CupertinoListTile] will not react on taps. If this is a + /// `Future Function()`, then the [CupertinoListTile] remains activated + /// until the returned future is awaited. This is according to iOS behavior. + /// However, if this function is a `void Function()`, then the tile is active + /// only for the duration of invocation. + final FutureOr Function()? onTap; + + /// The [backgroundColor] of the tile in normal state. Once the tile is + /// tapped, the background color switches to [backgroundColorActivated]. It is + /// set to match the iOS look by default. + final Color? backgroundColor; + + /// The [backgroundColorActivated] is the background color of the tile after + /// the tile was tapped. It is set to match the iOS look by default. + final Color? backgroundColorActivated; + + /// Padding of the content inside [CupertinoListTile]. + final EdgeInsetsGeometry? padding; + + /// The [leadingSize] is used to constrain the width and height of [leading] + /// widget. + final double leadingSize; + + /// The horizontal space between [leading] widget and [title]. + final double leadingToTitle; + + @override + State createState() => _CupertinoListTileState(); +} + +class _CupertinoListTileState extends State { + bool _tapped = false; + + @override + Widget build(BuildContext context) { + final TextStyle textStyle = CupertinoTheme.of(context).textTheme.textStyle; + final TextStyle coloredStyle = textStyle.copyWith( + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ); + + final bool baseType = switch (widget._type) { + _CupertinoListTileType.base => true, + _CupertinoListTileType.notched => false, + }; + final Widget title = DefaultTextStyle( + style: baseType || widget.subtitle == null + ? textStyle + : textStyle.copyWith( + fontWeight: FontWeight.w600, + fontSize: widget.leading == null ? _kNotchedTitleWithSubtitleFontSize : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: widget.title, + ); + + final EdgeInsetsGeometry padding = + widget.padding ?? + switch (widget._type) { + _CupertinoListTileType.base when widget.subtitle != null => _kPaddingWithSubtitle, + _CupertinoListTileType.notched when widget.leading != null => _kNotchedPadding, + _CupertinoListTileType.base => _kPadding, + _CupertinoListTileType.notched => _kNotchedPaddingWithoutLeading, + }; + + // The color for default state tile is set to either what user provided or + // null and it will resolve to the correct color provided by context. But if + // the tile was tapped, it is set to what user provided or if null to the + // default color that matched the iOS-style. + Color backgroundColor = widget.backgroundColor ?? CupertinoColors.transparent; + if (_tapped) { + backgroundColor = + widget.backgroundColorActivated ?? CupertinoColors.systemGrey4.resolveFrom(context); + } + + final double minHeight = switch (widget._type) { + _CupertinoListTileType.base when widget.subtitle != null => _kMinHeightWithSubtitle, + _CupertinoListTileType.notched when widget.leading != null => _kNotchedMinHeight, + _CupertinoListTileType.base => _kMinHeight, + _CupertinoListTileType.notched => _kNotchedMinHeightWithoutLeading, + }; + + final Widget child = ConstrainedBox( + constraints: BoxConstraints(minWidth: double.infinity, minHeight: minHeight), + child: ColoredBox( + color: backgroundColor, + child: Padding( + padding: padding, + child: Row( + children: [ + if (widget.leading case final Widget leading) ...[ + SizedBox.square( + dimension: widget.leadingSize, + child: Center(child: leading), + ), + SizedBox(width: widget.leadingToTitle), + ] else + SizedBox(height: widget.leadingSize), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + if (widget.subtitle case final Widget subtitle) ...[ + const SizedBox(height: _kNotchedTitleToSubtitle), + DefaultTextStyle( + style: coloredStyle.copyWith( + fontSize: baseType ? _kSubtitleFontSize : _kNotchedSubtitleFontSize, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: subtitle, + ), + ], + ], + ), + ), + if (widget.additionalInfo case final Widget additionalInfo) ...[ + DefaultTextStyle(style: coloredStyle, maxLines: 1, child: additionalInfo), + if (widget.trailing != null) const SizedBox(width: _kAdditionalInfoToTrailing), + ], + ?widget.trailing, + ], + ), + ), + ), + ); + + if (widget.onTap == null) { + return child; + } + + return GestureDetector( + onTapDown: (_) => setState(() { + _tapped = true; + }), + onTapCancel: () => setState(() { + _tapped = false; + }), + onTap: () async { + await widget.onTap!(); + if (mounted) { + setState(() { + _tapped = false; + }); + } + }, + behavior: HitTestBehavior.opaque, + child: child, + ); + } +} + +/// A typical iOS trailing widget used to denote that a `CupertinoListTile` is a +/// button with an action. +/// +/// The [CupertinoListTileChevron] is meant as a convenience implementation of +/// trailing right chevron. +class CupertinoListTileChevron extends StatelessWidget { + /// Creates a typical widget used to denote that a `CupertinoListTile` is a + /// button with action. + const CupertinoListTileChevron({super.key}); + + @override + Widget build(BuildContext context) { + return Icon( + CupertinoIcons.right_chevron, + size: CupertinoTheme.of(context).textTheme.textStyle.fontSize, + color: CupertinoColors.systemGrey2.resolveFrom(context), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/localizations.dart b/packages/cupertino_ui/lib/src/localizations.dart new file mode 100644 index 000000000000..88113a747906 --- /dev/null +++ b/packages/cupertino_ui/lib/src/localizations.dart @@ -0,0 +1,593 @@ +// 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. + +/// @docImport 'bottom_tab_bar.dart'; +/// @docImport 'date_picker.dart'; +/// @docImport 'expansion_tile.dart'; +/// @docImport 'nav_bar.dart'; +/// @docImport 'search_field.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Determines the order of the columns inside [CupertinoDatePicker] in +/// time and date time mode. +enum DatePickerDateTimeOrder { + /// Order of the columns, from left to right: date, hour, minute, am/pm. + /// + /// Example: Fri Aug 31 | 02 | 08 | PM. + date_time_dayPeriod, + + /// Order of the columns, from left to right: date, am/pm, hour, minute. + /// + /// Example: Fri Aug 31 | PM | 02 | 08. + date_dayPeriod_time, + + /// Order of the columns, from left to right: hour, minute, am/pm, date. + /// + /// Example: 02 | 08 | PM | Fri Aug 31. + time_dayPeriod_date, + + /// Order of the columns, from left to right: am/pm, hour, minute, date. + /// + /// Example: PM | 02 | 08 | Fri Aug 31. + dayPeriod_time_date, +} + +/// Determines the order of the columns inside [CupertinoDatePicker] in date mode. +enum DatePickerDateOrder { + /// Order of the columns, from left to right: day, month, year. + /// + /// Example: 12 | March | 1996. + dmy, + + /// Order of the columns, from left to right: month, day, year. + /// + /// Example: March | 12 | 1996. + mdy, + + /// Order of the columns, from left to right: year, month, day. + /// + /// Example: 1996 | March | 12. + ymd, + + /// Order of the columns, from left to right: year, day, month. + /// + /// Example: 1996 | 12 | March. + ydm, +} + +/// Defines the localized resource values used by the Cupertino widgets. +/// +/// See also: +/// +/// * [DefaultCupertinoLocalizations], the default, English-only, implementation +/// of this interface. +abstract class CupertinoLocalizations { + /// Year that is shown in [CupertinoDatePicker] spinner corresponding to the + /// given year index. + /// + /// Examples: datePickerYear(1) in: + /// + /// - US English: 2018 + /// - Korean: 2018년 + // The global version uses date symbols data from the intl package. + String datePickerYear(int yearIndex); + + /// Month that is shown in [CupertinoDatePicker] spinner corresponding to + /// the given month index. + /// + /// Examples: datePickerMonth(1) in: + /// + /// - US English: January + /// - Korean: 1월 + /// - Russian: января + // The global version uses date symbols data from the intl package. + String datePickerMonth(int monthIndex); + + /// Month that is shown in [CupertinoDatePicker] spinner corresponding to + /// the given month index in [CupertinoDatePickerMode.monthYear] mode. + /// + /// This is distinct from [datePickerMonth] because in some languages, like Russian, + /// the name of a month takes a different form depending + /// on whether it is preceded by a day or whether it stands alone. + /// + /// Examples: datePickerMonth(1) in: + /// + /// - US English: January + /// - Korean: 1월 + /// - Russian: Январь + // The global version uses date symbols data from the intl package. + String datePickerStandaloneMonth(int monthIndex); + + /// Day of month that is shown in [CupertinoDatePicker] spinner corresponding + /// to the given day index. + /// + /// If weekDay is provided then it will also show weekday name alongside the numerical day. + /// + /// Examples: datePickerDayOfMonth(1) in: + /// + /// - US English: 1 + /// - Korean: 1일 + /// Examples: datePickerDayOfMonth(1, 1) in: + /// + /// - US English: Mon 1 + // The global version uses date symbols data from the intl package. + String datePickerDayOfMonth(int dayIndex, [int? weekDay]); + + /// The medium-width date format that is shown in [CupertinoDatePicker] + /// spinner. Abbreviates month and days of week. + /// + /// Examples: + /// + /// - US English: Wed Sep 27 + /// - Russian: ср сент. 27 + // The global version is based on intl package's DateFormat.MMMEd. + String datePickerMediumDate(DateTime date); + + /// Hour that is shown in [CupertinoDatePicker] spinner corresponding + /// to the given hour value. + /// + /// Examples: datePickerHour(1) in: + /// + /// - US English: 1 + /// - Arabic: ٠١ + // The global version uses date symbols data from the intl package. + String datePickerHour(int hour); + + /// Semantics label for the given hour value in [CupertinoDatePicker]. + // The global version uses the translated string from the arb file. + String? datePickerHourSemanticsLabel(int hour); + + /// Minute that is shown in [CupertinoDatePicker] spinner corresponding + /// to the given minute value. + /// + /// Examples: datePickerMinute(1) in: + /// + /// - US English: 01 + /// - Arabic: ٠١ + // The global version uses date symbols data from the intl package. + String datePickerMinute(int minute); + + /// Semantics label for the given minute value in [CupertinoDatePicker]. + // The global version uses the translated string from the arb file. + String? datePickerMinuteSemanticsLabel(int minute); + + /// The order of the date elements that will be shown in [CupertinoDatePicker]. + // The global version uses the translated string from the arb file. + DatePickerDateOrder get datePickerDateOrder; + + /// The order of the time elements that will be shown in [CupertinoDatePicker]. + // The global version uses the translated string from the arb file. + DatePickerDateTimeOrder get datePickerDateTimeOrder; + + /// The abbreviation for ante meridiem (before noon) shown in the time picker. + // The global version uses the translated string from the arb file. + String get anteMeridiemAbbreviation; + + /// The abbreviation for post meridiem (after noon) shown in the time picker. + // The global version uses the translated string from the arb file. + String get postMeridiemAbbreviation; + + /// Label shown in date pickers when the date is today. + // The global version uses the translated string from the arb file. + String get todayLabel; + + /// The term used by the system to announce dialog alerts. + // The global version uses the translated string from the arb file. + String get alertDialogLabel; + + /// The accessibility label used on a tab in a [CupertinoTabBar]. + /// + /// This message describes the index of the selected tab and how many tabs + /// there are, e.g. 'tab, 1 of 2' in United States English. + /// + /// `tabIndex` and `tabCount` must be greater than or equal to one. + String tabSemanticsLabel({required int tabIndex, required int tabCount}); + + /// Hour that is shown in [CupertinoTimerPicker] corresponding to + /// the given hour value. + /// + /// Examples: timerPickerHour(1) in: + /// + /// - US English: 1 + /// - Arabic: ١ + // The global version uses date symbols data from the intl package. + String timerPickerHour(int hour); + + /// Minute that is shown in [CupertinoTimerPicker] corresponding to + /// the given minute value. + /// + /// Examples: timerPickerMinute(1) in: + /// + /// - US English: 1 + /// - Arabic: ١ + // The global version uses date symbols data from the intl package. + String timerPickerMinute(int minute); + + /// Second that is shown in [CupertinoTimerPicker] corresponding to + /// the given second value. + /// + /// Examples: timerPickerSecond(1) in: + /// + /// - US English: 1 + /// - Arabic: ١ + // The global version uses date symbols data from the intl package. + String timerPickerSecond(int second); + + /// Label that appears next to the hour picker in + /// [CupertinoTimerPicker] when selected hour value is `hour`. + /// This function will deal with pluralization based on the `hour` parameter. + // The global version uses the translated string from the arb file. + String? timerPickerHourLabel(int hour); + + /// All possible hour labels that appears next to the hour picker in + /// [CupertinoTimerPicker] + List get timerPickerHourLabels; + + /// Label that appears next to the minute picker in + /// [CupertinoTimerPicker] when selected minute value is `minute`. + /// This function will deal with pluralization based on the `minute` parameter. + // The global version uses the translated string from the arb file. + String? timerPickerMinuteLabel(int minute); + + /// All possible minute labels that appears next to the minute picker in + /// [CupertinoTimerPicker] + List get timerPickerMinuteLabels; + + /// Label that appears next to the minute picker in + /// [CupertinoTimerPicker] when selected minute value is `second`. + /// This function will deal with pluralization based on the `second` parameter. + // The global version uses the translated string from the arb file. + String? timerPickerSecondLabel(int second); + + /// All possible second labels that appears next to the second picker in + /// [CupertinoTimerPicker] + List get timerPickerSecondLabels; + + /// The term used for cutting. + // The global version uses the translated string from the arb file. + String get cutButtonLabel; + + /// The term used for copying. + // The global version uses the translated string from the arb file. + String get copyButtonLabel; + + /// The term used for pasting. + // The global version uses the translated string from the arb file. + String get pasteButtonLabel; + + /// The term used for clearing a field. + // The global version uses the translated string from the arb file. + String get clearButtonLabel; + + /// Label that appears in the Cupertino toolbar when the spell checker + /// couldn't find any replacements for the current word. + // The global version uses the translated string from the arb file. + String get noSpellCheckReplacementsLabel; + + /// The term used for selecting everything. + // The global version uses the translated string from the arb file. + String get selectAllButtonLabel; + + /// The term used for looking up a selection. + // The global version uses the translated string from the arb file. + String get lookUpButtonLabel; + + /// The term used for launching a web search on a selection. + // The global version uses the translated string from the arb file. + String get searchWebButtonLabel; + + /// The term used for launching a web search on a selection. + // The global version uses the translated string from the arb file. + String get shareButtonLabel; + + /// The default placeholder used in [CupertinoSearchTextField]. + // The global version uses the translated string from the arb file. + String get searchTextFieldPlaceholderLabel; + + /// Label read out by accessibility tools (VoiceOver) for a modal + /// barrier to indicate that a tap dismisses the barrier. + /// + /// A modal barrier can for example be found behind an alert or popup to block + /// user interaction with elements behind it. + String get modalBarrierDismissLabel; + + /// Label read out by accessibility tools (VoiceOver) for a context menu to + /// indicate that a tap outside dismisses the context menu. + String get menuDismissLabel; + + /// The label for the cancel button in modal views, used in [CupertinoNavigationBar] and [CupertinoSliverNavigationBar]. + String get cancelButtonLabel; + + /// The label for the back button, used in [CupertinoNavigationBar] and [CupertinoSliverNavigationBar]. + String get backButtonLabel; + + /// The semantics hint to describe the tap action on an expanded + /// [CupertinoExpansionTile] on iOS and macOS. This is appended to the [collapsedHint] + /// hint to provide a more detailed description of the action, e.g. "Expanded + /// double tap to collapse". + String get expansionTileExpandedHint => 'double tap to collapse'; + + /// The semantics hint to describe the tap action on a collapsed + /// [CupertinoExpansionTile] on iOS and macOS. This is appended to the [expandedHint] + /// hint to provide a more detailed description of the action, e.g. "Collapsed + /// double tap to expand". + String get expansionTileCollapsedHint => 'double tap to expand'; + + /// The semantics hint to describe the tap action on an expanded [CupertinoExpansionTile]. + String get expansionTileExpandedTapHint => 'Collapse'; + + /// The semantics hint to describe the tap action on a collapsed [CupertinoExpansionTile]. + String get expansionTileCollapsedTapHint => 'Expand for more details'; + + /// The semantics hint to describe the [CupertinoExpansionTile] expanded state. + String get expandedHint => 'Collapsed'; + + /// The semantics hint to describe the [CupertinoExpansionTile] collapsed state. + String get collapsedHint => 'Expanded'; + + /// The `CupertinoLocalizations` from the closest [Localizations] instance + /// that encloses the given context. + /// + /// If no [CupertinoLocalizations] are available in the given `context`, this + /// method throws an exception. + /// + /// This method is just a convenient shorthand for: + /// `Localizations.of(context, CupertinoLocalizations)!`. + /// + /// References to the localized resources defined by this class are typically + /// written in terms of this method. For example: + /// + /// ```dart + /// CupertinoLocalizations.of(context).anteMeridiemAbbreviation; + /// ``` + static CupertinoLocalizations of(BuildContext context) { + assert(debugCheckHasCupertinoLocalizations(context)); + return Localizations.of(context, CupertinoLocalizations)!; + } +} + +class _CupertinoLocalizationsDelegate extends LocalizationsDelegate { + const _CupertinoLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => locale.languageCode == 'en'; + + @override + Future load(Locale locale) => DefaultCupertinoLocalizations.load(locale); + + @override + bool shouldReload(_CupertinoLocalizationsDelegate old) => false; + + @override + String toString() => 'DefaultCupertinoLocalizations.delegate(en_US)'; +} + +/// US English strings for the Cupertino widgets. +class DefaultCupertinoLocalizations implements CupertinoLocalizations { + /// Constructs an object that defines the cupertino widgets' localized strings + /// for US English (only). + /// + /// [LocalizationsDelegate] implementations typically call the static [load] + /// function, rather than constructing this class directly. + const DefaultCupertinoLocalizations(); + + /// Short version of days of week. + static const List _shortWeekdays = [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + + static const List _shortMonths = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + static const List _months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + @override + String datePickerYear(int yearIndex) => yearIndex.toString(); + + @override + String datePickerMonth(int monthIndex) => _months[monthIndex - 1]; + + @override + String datePickerStandaloneMonth(int monthIndex) => _months[monthIndex - 1]; + + @override + String datePickerDayOfMonth(int dayIndex, [int? weekDay]) { + if (weekDay != null) { + return ' ${_shortWeekdays[weekDay - DateTime.monday]} $dayIndex '; + } + + return dayIndex.toString(); + } + + @override + String datePickerHour(int hour) => hour.toString(); + + @override + String datePickerHourSemanticsLabel(int hour) => "$hour o'clock"; + + @override + String datePickerMinute(int minute) => minute.toString().padLeft(2, '0'); + + @override + String datePickerMinuteSemanticsLabel(int minute) { + if (minute == 1) { + return '1 minute'; + } + return '$minute minutes'; + } + + @override + String datePickerMediumDate(DateTime date) { + return '${_shortWeekdays[date.weekday - DateTime.monday]} ' + '${_shortMonths[date.month - DateTime.january]} ' + '${date.day.toString().padRight(2)}'; + } + + @override + DatePickerDateOrder get datePickerDateOrder => DatePickerDateOrder.mdy; + + @override + DatePickerDateTimeOrder get datePickerDateTimeOrder => + DatePickerDateTimeOrder.date_time_dayPeriod; + + @override + String get anteMeridiemAbbreviation => 'AM'; + + @override + String get postMeridiemAbbreviation => 'PM'; + + @override + String get todayLabel => 'Today'; + + @override + String get alertDialogLabel => 'Alert'; + + @override + String tabSemanticsLabel({required int tabIndex, required int tabCount}) { + assert(tabIndex >= 1); + assert(tabCount >= 1); + return 'Tab $tabIndex of $tabCount'; + } + + @override + String timerPickerHour(int hour) => hour.toString(); + + @override + String timerPickerMinute(int minute) => minute.toString(); + + @override + String timerPickerSecond(int second) => second.toString(); + + @override + String timerPickerHourLabel(int hour) => hour == 1 ? 'hour' : 'hours'; + + @override + List get timerPickerHourLabels => const ['hour', 'hours']; + + @override + String timerPickerMinuteLabel(int minute) => 'min.'; + + @override + List get timerPickerMinuteLabels => const ['min.']; + + @override + String timerPickerSecondLabel(int second) => 'sec.'; + + @override + List get timerPickerSecondLabels => const ['sec.']; + + @override + String get cutButtonLabel => 'Cut'; + + @override + String get copyButtonLabel => 'Copy'; + + @override + String get pasteButtonLabel => 'Paste'; + + @override + String get clearButtonLabel => 'Clear'; + + @override + String get noSpellCheckReplacementsLabel => 'No Replacements Found'; + + @override + String get selectAllButtonLabel => 'Select All'; + + @override + String get lookUpButtonLabel => 'Look Up'; + + @override + String get searchWebButtonLabel => 'Search Web'; + + @override + String get shareButtonLabel => 'Share...'; + + @override + String get searchTextFieldPlaceholderLabel => 'Search'; + + @override + String get modalBarrierDismissLabel => 'Dismiss'; + + @override + String get menuDismissLabel => 'Dismiss menu'; + + @override + String get cancelButtonLabel => 'Cancel'; + + @override + String get backButtonLabel => 'Back'; + + @override + String get expansionTileExpandedHint => 'double tap to collapse'; + + @override + String get expansionTileCollapsedHint => 'double tap to expand'; + + @override + String get expansionTileExpandedTapHint => 'Collapse'; + + @override + String get expansionTileCollapsedTapHint => 'Expand for more details'; + + @override + String get expandedHint => 'Collapsed'; + + @override + String get collapsedHint => 'Expanded'; + + /// Creates an object that provides US English resource values for the + /// cupertino library widgets. + /// + /// The [locale] parameter is ignored. + /// + /// This method is typically used to create a [LocalizationsDelegate]. + static Future load(Locale locale) { + return SynchronousFuture(const DefaultCupertinoLocalizations()); + } + + /// A [LocalizationsDelegate] that uses [DefaultCupertinoLocalizations.load] + /// to create an instance of this class. + static const LocalizationsDelegate delegate = + _CupertinoLocalizationsDelegate(); +} diff --git a/packages/cupertino_ui/lib/src/magnifier.dart b/packages/cupertino_ui/lib/src/magnifier.dart new file mode 100644 index 000000000000..e45af5c22b6e --- /dev/null +++ b/packages/cupertino_ui/lib/src/magnifier.dart @@ -0,0 +1,369 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +/// A [CupertinoMagnifier] used for magnifying text in cases where a user's +/// finger may be blocking the point of interest, like a selection handle. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use [CupertinoTextMagnifier]. +/// +/// ** See code in examples/api/lib/widgets/magnifier/cupertino_text_magnifier.0.dart ** +/// {@end-tool} +/// +/// Delegates styling to [CupertinoMagnifier] with its position depending on +/// [magnifierInfo]. +/// +/// Specifically, the [CupertinoTextMagnifier] follows the following rules. +/// [CupertinoTextMagnifier]: +/// - is positioned horizontally inside the screen width, with [horizontalScreenEdgePadding] padding. +/// - is hidden if a gesture is detected [hideBelowThreshold] units below the line +/// that the magnifier is on, shown otherwise. +/// - follows the x coordinate of the gesture directly (with respect to rule 1). +/// - has some vertical drag resistance; i.e. if a gesture is detected k units below the field, +/// then has vertical offset [dragResistance] * k. +class CupertinoTextMagnifier extends StatefulWidget { + /// Constructs a [RawMagnifier] in the Cupertino style, positioning with respect to + /// [magnifierInfo]. + /// + /// The default constructor parameters and constants were eyeballed on + /// an iPhone XR iOS v15.5. + const CupertinoTextMagnifier({ + super.key, + this.animationCurve = Curves.easeOut, + required this.controller, + this.dragResistance = 10.0, + this.hideBelowThreshold = 48.0, + this.horizontalScreenEdgePadding = 10.0, + required this.magnifierInfo, + }); + + /// The curve used for the in / out animations. + final Curve animationCurve; + + /// This magnifier's controller. + /// + /// The [CupertinoTextMagnifier] requires a [MagnifierController] + /// in order to show / hide itself without removing itself from the + /// overlay. + final MagnifierController controller; + + /// A drag resistance on the downward Y position of the lens. + final double dragResistance; + + /// The difference in Y between the gesture position and the caret center + /// so that the magnifier hides itself. + final double hideBelowThreshold; + + /// The padding on either edge of the screen that any part of the magnifier + /// cannot exist past. + /// + /// This includes any part of the magnifier, not just the center; for example, + /// the left edge of the magnifier cannot be outside the [horizontalScreenEdgePadding].v + /// + /// If the screen has width w, then the magnifier is bound to + /// `_kHorizontalScreenEdgePadding, w - _kHorizontalScreenEdgePadding`. + final double horizontalScreenEdgePadding; + + /// [CupertinoTextMagnifier] will determine its own positioning + /// based on the [MagnifierInfo] of this notifier. + final ValueNotifier magnifierInfo; + + /// The duration that the magnifier drags behind its final position. + static const Duration _kDragAnimationDuration = Duration(milliseconds: 45); + + @override + State createState() => _CupertinoTextMagnifierState(); +} + +class _CupertinoTextMagnifierState extends State + with SingleTickerProviderStateMixin { + // Initialize to dummy values for the event that the initial call to + // _determineMagnifierPositionAndFocalPoint calls hide, and thus does not + // set these values. + Offset _currentAdjustedMagnifierPosition = Offset.zero; + double _verticalFocalPointAdjustment = 0; + late final AnimationController _ioAnimationController; + late final Animation _ioAnimation; + late final CurvedAnimation _ioCurvedAnimation; + + @override + void initState() { + super.initState(); + _ioAnimationController = AnimationController( + value: 0, + vsync: this, + duration: CupertinoMagnifier._kInOutAnimationDuration, + )..addListener(() => setState(() {})); + + widget.controller.animationController = _ioAnimationController; + widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); + _ioCurvedAnimation = CurvedAnimation( + parent: _ioAnimationController, + curve: widget.animationCurve, + ); + _ioAnimation = Tween(begin: 0.0, end: 1.0).animate(_ioCurvedAnimation); + } + + @override + void dispose() { + widget.controller.animationController = null; + _ioAnimationController.dispose(); + _ioCurvedAnimation.dispose(); + widget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); + super.dispose(); + } + + @override + void didUpdateWidget(CupertinoTextMagnifier oldWidget) { + if (oldWidget.magnifierInfo != widget.magnifierInfo) { + oldWidget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); + widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); + } + super.didUpdateWidget(oldWidget); + } + + @override + void didChangeDependencies() { + _determineMagnifierPositionAndFocalPoint(); + super.didChangeDependencies(); + } + + void _determineMagnifierPositionAndFocalPoint() { + final MagnifierInfo textEditingContext = widget.magnifierInfo.value; + + // The exact Y of the center of the current line. + final double verticalCenterOfCurrentLine = textEditingContext.caretRect.center.dy; + + // If the magnifier is currently showing, but we have dragged out of threshold, + // we should hide it. + if (verticalCenterOfCurrentLine - textEditingContext.globalGesturePosition.dy < + -widget.hideBelowThreshold) { + // Only signal a hide if we are currently showing. + if (widget.controller.shown) { + widget.controller.hide(removeFromOverlay: false); + } + return; + } + + // If we are gone, but got to this point, we shouldn't be: show. + if (!widget.controller.shown) { + _ioAnimationController.forward(); + } + + // Never go above the center of the line, but have some resistance + // going downward if the drag goes too far. + final double verticalPositionOfLens = math.max( + verticalCenterOfCurrentLine, + verticalCenterOfCurrentLine - + (verticalCenterOfCurrentLine - textEditingContext.globalGesturePosition.dy) / + widget.dragResistance, + ); + + // The raw position, tracking the gesture directly. + final rawMagnifierPosition = Offset( + textEditingContext.globalGesturePosition.dx - CupertinoMagnifier.kDefaultSize.width / 2, + verticalPositionOfLens - + (CupertinoMagnifier.kDefaultSize.height - CupertinoMagnifier.kMagnifierAboveFocalPoint), + ); + + final Rect screenRect = Offset.zero & MediaQuery.sizeOf(context); + + // Adjust the magnifier position so that it never exists outside the horizontal + // padding. + final Offset adjustedMagnifierPosition = MagnifierController.shiftWithinBounds( + bounds: Rect.fromLTRB( + screenRect.left + widget.horizontalScreenEdgePadding, + // iOS doesn't reposition for Y, so we should expand the threshold + // so we can send the whole magnifier out of bounds if need be. + screenRect.top - + (CupertinoMagnifier.kDefaultSize.height + CupertinoMagnifier.kMagnifierAboveFocalPoint), + screenRect.right - widget.horizontalScreenEdgePadding, + screenRect.bottom + + (CupertinoMagnifier.kDefaultSize.height + CupertinoMagnifier.kMagnifierAboveFocalPoint), + ), + rect: rawMagnifierPosition & CupertinoMagnifier.kDefaultSize, + ).topLeft; + + setState(() { + _currentAdjustedMagnifierPosition = adjustedMagnifierPosition; + // The lens should always point to the center of the line. + _verticalFocalPointAdjustment = verticalCenterOfCurrentLine - verticalPositionOfLens; + }); + } + + @override + Widget build(BuildContext context) { + final CupertinoThemeData themeData = CupertinoTheme.of(context); + return AnimatedPositioned( + duration: CupertinoTextMagnifier._kDragAnimationDuration, + curve: widget.animationCurve, + left: _currentAdjustedMagnifierPosition.dx, + top: _currentAdjustedMagnifierPosition.dy, + child: CupertinoMagnifier( + inOutAnimation: _ioAnimation, + additionalFocalPointOffset: Offset(0, _verticalFocalPointAdjustment), + borderSide: BorderSide(color: themeData.primaryColor, width: 2.0), + ), + ); + } +} + +/// A [RawMagnifier] used for magnifying text in cases where a user's +/// finger may be blocking the point of interest, like a selection handle. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use [CupertinoMagnifier]. +/// +/// ** See code in examples/api/lib/widgets/magnifier/cupertino_magnifier.0.dart ** +/// {@end-tool} +/// +/// [CupertinoMagnifier] is a wrapper around [RawMagnifier] that handles styling +/// and transitions. +/// +/// {@macro flutter.widgets.magnifier.intro} +/// +/// See also: +/// +/// * [RawMagnifier], the backing implementation. +/// * [CupertinoTextMagnifier], a widget that positions [CupertinoMagnifier] based on +/// [MagnifierInfo]. +/// * [MagnifierController], the controller for this magnifier. +class CupertinoMagnifier extends StatelessWidget { + /// Creates a [RawMagnifier] in the Cupertino style. + /// + /// The default constructor parameters and constants were eyeballed on + /// an iPhone 16 iOS v18.1. + const CupertinoMagnifier({ + super.key, + this.size = kDefaultSize, + this.borderRadius = const BorderRadius.all(Radius.elliptical(60, 50)), + this.additionalFocalPointOffset = Offset.zero, + this.shadows = const [ + BoxShadow( + color: Color.fromARGB(25, 0, 0, 0), + blurRadius: 11, + spreadRadius: 0.2, + blurStyle: BlurStyle.outer, + ), + ], + this.clipBehavior = Clip.none, + this.borderSide = const BorderSide(color: Color.fromARGB(255, 0, 124, 255), width: 2.0), + this.inOutAnimation, + this.magnificationScale = 1.0, + }) : assert(magnificationScale > 0, 'The magnification scale should be greater than zero.'); + + /// A list of shadows cast by the [Magnifier]. + /// + /// If the shadows use a [BlurStyle] that paints inside the shape, or if they + /// are offset, then a [clipBehavior] that enables clipping (such as + /// [Clip.hardEdge]) is recommended, otherwise the shadow will occlude the + /// magnifier (the shadow is drawn above the magnifier so as to not be + /// included in the magnified image). + /// + /// A shadow that uses [BlurStyle.outer] and is not offset does not need + /// clipping. + /// + /// By default, the [shadows] are not offset and use [BlurStyle.outer], and + /// correspondingly the default [clipBehavior] is [Clip.none]. + final List shadows; + + /// Whether and how to clip the [shadows] that render inside the loupe. + /// + /// Defaults to [Clip.none], which is useful if the shadow will not paint + /// where the magnified image appears, or if doing so is intentional (e.g. to + /// blur the edges of the magnified image). + /// + /// The default configuration of [CupertinoMagnifier] does not render inside + /// the loupe (the shadows are not offset and use [BlurStyle.outer]). + /// + /// Other values (e.g. [Clip.hardEdge]) are recommended when the [shadows] + /// have an offset. + /// + /// See the discussion at [shadows]. + final Clip clipBehavior; + + /// The border, or "rim", of this magnifier. + /// + /// This border is drawn on a [RoundedRectangleBorder] with radius + /// [borderRadius], and increases the [size] of the magnifier by the + /// [BorderSide.width]. + final BorderSide borderSide; + + /// The vertical offset that the magnifier is along the Y axis above + /// the focal point. + static const double kMagnifierAboveFocalPoint = -26; + + /// The default size of the magnifier. + /// + /// This is public so that positioners can choose to depend on it, although + /// it is overridable. + static const Size kDefaultSize = Size(80, 47.5); + + /// The duration that this magnifier animates in / out for. + /// + /// The animation is a translation and a fade. The translation + /// begins at the focal point, and ends at [kMagnifierAboveFocalPoint]. + /// The opacity begins at 0 and ends at 1. + static const Duration _kInOutAnimationDuration = Duration(milliseconds: 150); + + /// The size of this magnifier. + /// + /// The size does not include the [borderSide] or [shadows]. + final Size size; + + /// The border radius of this magnifier. + /// + /// The magnifier's shape is a [RoundedRectangleBorder] with this radius. + final BorderRadius borderRadius; + + /// This [RawMagnifier]'s controller. + /// + /// Since [CupertinoMagnifier] has no knowledge of shown / hidden state, + /// this animation should be driven by an external actor. + final Animation? inOutAnimation; + + /// Any additional focal point offset, applied over the regular focal + /// point offset defined in [kMagnifierAboveFocalPoint]. + final Offset additionalFocalPointOffset; + + /// The magnification scale for the magnifier. + /// + /// Defaults to 1.0, which indicates that the magnifier does not apply any magnification. + final double magnificationScale; + + @override + Widget build(BuildContext context) { + var focalPointOffset = Offset(0, (kDefaultSize.height / 2) - kMagnifierAboveFocalPoint); + focalPointOffset.scale(1, inOutAnimation?.value ?? 1); + focalPointOffset += additionalFocalPointOffset; + + return Transform.translate( + offset: Offset.lerp( + const Offset(0, -kMagnifierAboveFocalPoint), + Offset.zero, + inOutAnimation?.value ?? 1, + )!, + child: RawMagnifier( + size: size, + focalPointOffset: focalPointOffset, + decoration: MagnifierDecoration( + opacity: inOutAnimation?.value ?? 1, + shape: RoundedRectangleBorder(borderRadius: borderRadius, side: borderSide), + shadows: shadows, + ), + clipBehavior: clipBehavior, + magnificationScale: magnificationScale, + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/menu_anchor.dart b/packages/cupertino_ui/lib/src/menu_anchor.dart new file mode 100644 index 000000000000..4e4cb1bdc149 --- /dev/null +++ b/packages/cupertino_ui/lib/src/menu_anchor.dart @@ -0,0 +1,3040 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'dialog.dart'; +import 'theme.dart'; + +// Dismiss is handled by RawMenuAnchor +const Map _kMenuTraversalShortcuts = { + SingleActivator(LogicalKeyboardKey.arrowUp): _FocusUpIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _FocusDownIntent(), + SingleActivator(LogicalKeyboardKey.home): _FocusFirstIntent(), + SingleActivator(LogicalKeyboardKey.end): _FocusLastIntent(), +}; + +bool get _isCupertino { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return true; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + } +} + +// TODO(davidhicks980): Move typography constants to a more appropriate +// location. https://github.com/flutter/flutter/issues/182933 + +/// The font family for menu items at smaller text scales. +const String _kBodyFont = 'CupertinoSystemText'; + +/// The font family for menu items at larger text scales. +const String _kDisplayFont = 'CupertinoSystemDisplay'; + +/// Base font size used for text-scaling calculations. +/// +/// On iOS the text scale changes in increments of 1/17 (≈5.88%), as +/// observed on the iOS 18.5 simulator. Each step (1/17 of the base font size) +/// is referred to as one "unit" in the documentation for [CupertinoMenuAnchor] +const double _kCupertinoMobileBaseFontSize = 17.0; + +/// Returns an integer that represents the current text scale factor normalized +/// to the base font size. +/// +/// Normalizing to the base font size simplifies storage of nonlinear layout +/// spacing that depends on the text scale factor. +/// +/// The returned value is positive when the text scale factor is larger than the +/// base font size, negative when smaller, and zero when equal. +double _normalizeTextScale(TextScaler textScaler) { + if (textScaler == TextScaler.noScaling) { + return 0; + } + + return textScaler.scale(_kCupertinoMobileBaseFontSize) - _kCupertinoMobileBaseFontSize; +} + +/// The CupertinoMenuAnchor layout policy changes depending on whether the user is using +/// a "regular" font size vs a "large" font size. This is a spectrum. There are +/// many "regular" font sizes and many "large" font sizes. But depending on which +/// policy is currently being used, a menu is laid out differently. +/// +/// Empirically, the jump from one policy to the other occurs at the following text +/// scale factors: +/// * Max "regular" scale factor ≈ 23/17 ≈ 1.352... (normalized text scale: 6) +/// * Min "large" scale factor ≈ 28/17 ≈ 1.647... (normalized text scale: 11) +/// +/// The following constant represents a division in text scale factor beyond which +/// we want to change how the menu is laid out. +// This explanation was ported from cupertino/dialog.dart. +const double _kMinimumNormalizedLargeTextScale = 11; + +/// The minimum normalized text scale factor supported on iOS. +const double _kMinimumTextScaleFactor = 1 - 3 / _kCupertinoMobileBaseFontSize; + +/// The minimum normalized text scale factor supported on iOS. +const double _kMaximumTextScaleFactor = 1 + 36 / _kCupertinoMobileBaseFontSize; + +// Large text mode on iOS is determined by the text scale factor that the +// user has selected. +bool _largeTextModeEnabled(BuildContext context) { + final TextScaler? textScaler = MediaQuery.maybeTextScalerOf(context); + if (textScaler == null) { + return false; + } + + return _normalizeTextScale(textScaler) >= _kMinimumNormalizedLargeTextScale; +} + +/// The width of a Cupertino menu +// Measured on: +// - iPadOS 18.5 Simulator +// - iPad Pro 11-inch +// - iPad Pro 13-inch +// - iOS 18.5 Simulator +// - iPhone 16 Pro +enum _CupertinoMenuWidth { + iPadOS(points: 262), + iPadOSAccessible(points: 343), + iOS(points: 250), + iOSAccessible(points: 370); + + const _CupertinoMenuWidth({required this.points}); + + // Determines the appropriate menu width based on screen width and + // the large text mode setting. + // + // A screen width threshold of 768 points is used to differentiate between + // mobile and tablet devices. + factory _CupertinoMenuWidth.fromScreenWidth({ + required double screenWidth, + required bool isLargeTextModeEnabled, + }) { + final bool isMobile = screenWidth < _kTabletWidthThreshold; + return switch ((isMobile, isLargeTextModeEnabled)) { + (false, false) => _CupertinoMenuWidth.iPadOS, + (false, true) => _CupertinoMenuWidth.iPadOSAccessible, + (true, false) => _CupertinoMenuWidth.iOS, + (true, true) => _CupertinoMenuWidth.iOSAccessible, + }; + } + + final double points; + static const double _kTabletWidthThreshold = 768.0; +} + +// TODO(davidhicks980): DynamicType should be moved to text_theme.dart when all +// styles are implemented. https://github.com/flutter/flutter/issues/179828 +// +// After that, we should deduplicate the same table in menu_anchor_test.dart +// +// Obtained from +// https://developer.apple.com/design/human-interface-guidelines/typography#Specifications +// +// Note: SF Display doesn't have tracking values on HID guidelines, so the +// tracking values for SF Pro were used +enum _DynamicTypeStyle { + body([ + TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont), + TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont), + TextStyle(fontSize: 16, height: 21 / 16, letterSpacing: -0.31, fontFamily: _kBodyFont), + TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont), + TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.44, fontFamily: _kBodyFont), + TextStyle(fontSize: 21, height: 26 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont), + TextStyle(fontSize: 23, height: 29 / 23, letterSpacing: -0.10, fontFamily: _kDisplayFont), + TextStyle(fontSize: 28, height: 34 / 28, letterSpacing: 0.38, fontFamily: _kDisplayFont), + TextStyle(fontSize: 33, height: 40 / 33, letterSpacing: 0.40, fontFamily: _kDisplayFont), + TextStyle(fontSize: 40, height: 48 / 40, letterSpacing: 0.37, fontFamily: _kDisplayFont), + TextStyle(fontSize: 47, height: 56 / 47, letterSpacing: 0.37, fontFamily: _kDisplayFont), + TextStyle(fontSize: 53, height: 62 / 53, letterSpacing: 0.31, fontFamily: _kDisplayFont), + ]), + + subhead([ + TextStyle(fontSize: 12, height: 16 / 12, letterSpacing: 0, fontFamily: _kBodyFont), + TextStyle(fontSize: 13, height: 18 / 13, letterSpacing: -0.08, fontFamily: _kBodyFont), + TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont), + TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont), + TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont), + TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.45, fontFamily: _kBodyFont), + TextStyle(fontSize: 21, height: 28 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont), + TextStyle(fontSize: 25, height: 31 / 25, letterSpacing: 0.15, fontFamily: _kDisplayFont), + TextStyle(fontSize: 30, height: 37 / 30, letterSpacing: 0.40, fontFamily: _kDisplayFont), + TextStyle(fontSize: 36, height: 43 / 36, letterSpacing: 0.37, fontFamily: _kDisplayFont), + TextStyle(fontSize: 42, height: 50 / 42, letterSpacing: 0.37, fontFamily: _kDisplayFont), + TextStyle(fontSize: 49, height: 58 / 49, letterSpacing: 0.33, fontFamily: _kDisplayFont), + ]); + + const _DynamicTypeStyle(this.styles); + + // A list of text style for iOS's various scales, which are: xSmall, small, + // medium, large, xLarge, xxLarge, xxxLarge, ax1, ax2, ax3, ax4, ax5. + final List styles; + + TextStyle resolveTextStyle(TextScaler textScaler) { + // Assert the length here instead of in the constructor since .length isn't + // accessible there. + assert(styles.length == _kScaleCount); + final double units = _normalizeTextScale(textScaler); + for (var i = 0; i < styles.length; i++) { + final int bodyUnits = _normalizedBodyScales[i]; + if (units > bodyUnits) { + continue; + } + + if (units == bodyUnits) { + return styles[i]; + } + + if (i == 0) { + return styles.first; + } + + return TextStyle.lerp( + styles[i - 1], + styles[i], + _interpolateUnits(units, _normalizedBodyScales[i - 1], bodyUnits), + )!; + } + + return styles.last; + } + + static const int _kScaleCount = 12; + static final List _normalizedBodyScales = UnmodifiableListView([ + for (final TextStyle style in _DynamicTypeStyle.body.styles) + (style.fontSize! - _kCupertinoMobileBaseFontSize).toInt(), + ]); + static double _interpolateUnits(double units, int minimum, int maximum) { + final double t = (units - minimum) / (maximum - minimum); + return ui.lerpDouble(0, 1, t)!; + } +} + +double _computeSquaredDistanceToRect(Offset point, Rect rect) { + final double dx = point.dx - ui.clampDouble(point.dx, rect.left, rect.right); + final double dy = point.dy - ui.clampDouble(point.dy, rect.top, rect.bottom); + return dx * dx + dy * dy; +} + +/// Returns the nearest multiple of `to` to `value`. +double _roundToDivisible(double value, {required double to}) { + if (to == 0) { + return value; + } + return (value / to).round() * to; +} + +/// Implement [CupertinoMenuEntry] to define how a menu item should be drawn in +/// a menu. +abstract interface class CupertinoMenuEntry { + /// Whether this menu item has a leading widget. + /// + /// If [hasLeading] returns true, siblings of this menu item that are missing + /// a leading widget will have leading space added to align the leading edges + /// of all menu items. + bool hasLeading(BuildContext context); + + /// Whether this menu item is a divider. + /// + /// When true, a divider will not be drawn above or below this menu item. + /// Otherwise, adjacent menu items will be separated by a divider. + bool get isDivider; +} + +class _AnchorScope extends InheritedWidget { + const _AnchorScope({required this.hasLeading, required super.child}); + final bool hasLeading; + + @override + bool updateShouldNotify(_AnchorScope oldWidget) { + return hasLeading != oldWidget.hasLeading; + } +} + +/// Signature for the callback called in response to a [CupertinoMenuAnchor] +/// changing its [AnimationStatus]. +typedef CupertinoMenuAnimationStatusChangedCallback = void Function(AnimationStatus status); + +/// A widget used to mark the "anchor" for a menu, defining the rectangle used +/// to position the menu, which can be done with an explicit location, or +/// with an alignment. +/// +/// The [CupertinoMenuAnchor] is typically used to wrap a button that opens a +/// menu when pressed. The menu is displayed as a popup overlay that is positioned +/// relative to the anchor rectangle, and will automatically reposition itself to remain +/// fully visible within the screen bounds. +/// +/// A [MenuController] must be used to open and close the menu, and can be +/// obtained from the [builder] callback, or provided to [controller] parameter. +/// Calling [MenuController.open] will open the menu, and calling +/// [MenuController.close] will close the menu. The [onOpen] callback is invoked +/// when the menu popup is mounted and the menu status changes _from_ +/// [AnimationStatus.dismissed]. The [onClose] callback is invoked when the menu +/// popup is unmounted and the menu status changes _to_ +/// [AnimationStatus.dismissed]. The [onAnimationStatusChanged] callback is +/// invoked every time the [AnimationStatus] of the menu animation changes. +/// +/// ## Usage +/// {@tool sample} +/// This example demonstrates a simple [CupertinoMenuAnchor] that wraps +/// a button. +/// +/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example demonstrates a [CupertinoMenuAnchor] that wraps a button and +/// shows a menu with three [CupertinoMenuItem]s and one [CupertinoMenuDivider]. +/// +/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoMenuItem], a Cupertino-themed menu item used in a +/// [CupertinoMenuAnchor]. +/// * [CupertinoMenuDivider], a large divider used to separate +/// [CupertinoMenuItem]s. +/// * [CupertinoMenuEntry], an interface that can be implemented to customize +/// the appearance of menu items in a [CupertinoMenuAnchor]. +class CupertinoMenuAnchor extends StatefulWidget { + /// Creates a [CupertinoMenuAnchor]. + const CupertinoMenuAnchor({ + super.key, + this.controller, + this.onOpen, + this.onClose, + this.onAnimationStatusChanged, + this.constraints, + this.constrainCrossAxis = false, + this.consumeOutsideTaps = false, + this.enableSwipe = true, + this.enableLongPressToOpen = false, + this.useRootOverlay = false, + this.overlayPadding = const EdgeInsets.all(8), + required this.menuChildren, + this.builder, + this.child, + this.childFocusNode, + }) : assert( + enableSwipe || !enableLongPressToOpen, + 'enableLongPressToOpen cannot be true if enableSwipe is false', + ); + + /// An optional controller that allows opening and closing of the menu from + /// other widgets. + final MenuController? controller; + + /// A callback that is invoked when the menu begins opening. + /// + /// Defaults to null. + final VoidCallback? onOpen; + + /// A callback that is invoked when the menu finishes closing. + /// + /// Defaults to null. + final VoidCallback? onClose; + + /// An optional callback that is invoked when the [AnimationStatus] of the + /// menu changes. + /// + /// This callback provides a way to determine when the menu is opening or + /// closing. This is necessary because the [MenuController.isOpen] property + /// remains true throughout the opening, opened, and closing phases, and + /// therefore cannot be used on its own to determine the current animation + /// direction. + /// + /// Defaults to null. + final CupertinoMenuAnimationStatusChangedCallback? onAnimationStatusChanged; + + /// The constraints to apply to the menu scrollable. + final BoxConstraints? constraints; + + /// Whether the menu's cross axis should be constrained by the overlay. + /// + /// If true, when the menu is wider than the overlay, the menu width will + /// shrink to fit the overlay bounds. + /// + /// If false, the menu will grow to fit the size of its contents. If the menu + /// is wider than the overlay, it will be clipped to the overlay's bounds. + /// + /// Defaults to false. + final bool constrainCrossAxis; + + /// Whether or not a tap event that closes the menu will be permitted to + /// continue on to the gesture arena. + /// + /// If false, then tapping outside of a menu when the menu is open will both + /// close the menu, and allow the tap to participate in the gesture arena. If + /// true, then it will only close the menu, and the tap event will be + /// consumed. + /// + /// Defaults to false. + final bool consumeOutsideTaps; + + /// Whether or not swiping is enabled on the menu. + /// + /// When swiping is enabled, a [MultiDragGestureRecognizer] is added around + /// the widget built by [builder] and menu items. The + /// [MultiDragGestureRecognizer] allows for users to press, move, and activate + /// adjacent menu items in a single gesture. Swiping also scales the menu + /// panel when users drag their pointer away from the menu. + /// + /// Disabling swiping can be useful if the menu swipe effects interfere with + /// another swipe gesture, such as in the case of dragging a menu anchor + /// around the screen. + /// + /// Defaults to true. + final bool enableSwipe; + + /// Whether or not the menu should open in response to a long-press on the + /// anchor. + /// + /// When a menu is opened via long-press, the menu can be swiped in the same + /// gesture to select and activate menu items. + /// + /// Because long-press-to-open relies on the swipe gesture, [enableSwipe] must + /// be true if [enableLongPressToOpen] is true. + /// + /// If the widget built by [builder] is disabled, [enableLongPressToOpen] + /// should be set to false to prevent the menu from opening on long-press. + /// + /// Defaults to false, which disables the behavior. + final bool enableLongPressToOpen; + + /// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay} + final bool useRootOverlay; + + /// The padding inside the overlay between its boundary and the menu content. + /// + /// If the menu width is larger than the available space in the overlay minus + /// the [overlayPadding] and [constrainCrossAxis] is false, the menu will be + /// positioned against the starting edge of the overlay (left when the ambient + /// [Directionality] is [TextDirection.ltr], and right when the ambient + /// [Directionality] is [TextDirection.rtl]). If [constrainCrossAxis] is true, + /// the menu width will shrink to fit within the overlay bounds minus the + /// [overlayPadding]. + /// + /// Defaults to `EdgeInsets.all(8)`. + final EdgeInsetsGeometry overlayPadding; + + /// A list of menu items to display in the menu. + final List menuChildren; + + /// The widget that this [CupertinoMenuAnchor] surrounds. + /// + /// Typically, this is a button that calls [MenuController.open] when pressed. + /// + /// The [builder] will rebuild when the menu's [AnimationStatus] changes. + /// + /// If null, the [CupertinoMenuAnchor] will be the size that its parent + /// allocates for it. + final RawMenuAnchorChildBuilder? builder; + + /// An optional child to be passed to the [builder]. + /// + /// Supply this child if there is a portion of the widget tree built in + /// [builder] that doesn't depend on the `controller` or `context` supplied to + /// the [builder]. It will be more efficient, since Flutter doesn't then need + /// to rebuild this child when those change. + final Widget? child; + + /// The [childFocusNode] attribute is the optional [FocusNode] also associated + /// the [child] or [builder] widget that opens the menu. + /// + /// The focus node should be attached to the widget that should receive focus + /// if keyboard focus traversal moves the focus off of the submenu with the + /// arrow keys. + /// + /// If not supplied, then focus will not traverse from the menu to the + /// controlling button after the menu opens. + final FocusNode? childFocusNode; + + /// Returns whether any ancestor [CupertinoMenuAnchor] has menu items with + /// leading widgets. + /// + /// This can be used by menu items to determine whether they need to + /// allocate space for a leading widget to align with sibling menu items. + static bool? maybeHasLeadingOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_AnchorScope>()?.hasLeading; + } + + @override + State createState() => _CupertinoMenuAnchorState(); + + @override + List debugDescribeChildren() { + return menuChildren.map((Widget child) => child.toDiagnosticsNode()).toList(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('childFocusNode', childFocusNode)); + properties.add(DiagnosticsProperty('constraints', constraints)); + properties.add( + FlagProperty( + 'constrainCrossAxis', + value: constrainCrossAxis, + ifTrue: 'constrains cross axis', + ), + ); + properties.add( + FlagProperty( + 'enableSwipe', + value: enableSwipe, + ifTrue: 'swipe enabled', + ifFalse: 'swipe disabled', + ), + ); + properties.add( + FlagProperty( + 'consumeOutsideTaps', + value: consumeOutsideTaps, + ifTrue: 'consumes outside taps', + ), + ); + properties.add( + FlagProperty('useRootOverlay', value: useRootOverlay, ifTrue: 'uses root overlay'), + ); + properties.add(DiagnosticsProperty('overlayPadding', overlayPadding)); + } +} + +class _CupertinoMenuAnchorState extends State with TickerProviderStateMixin { + static const Duration _kLongPressToOpenDuration = Duration(milliseconds: 400); + static const Tolerance _kSpringTolerance = Tolerance(velocity: 0.1); + + // Approximated from the iOS 18.5 Simulator. + static final SpringDescription forwardSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 337), + bounce: 0.2, + ); + + // Approximated from the iOS 18.5 Simulator. + static final SpringDescription reverseSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 409), + ); + + late final AnimationController _animationController; + final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: 'Menu Scope'); + final ValueNotifier _swipeDistanceNotifier = ValueNotifier(0); + bool? _hasLeadingWidget; + + MenuController get _menuController => widget.controller ?? _internalMenuController!; + MenuController? _internalMenuController; + bool get isOpenOrOpening => _animationStatus.isForwardOrCompleted; + bool get enableSwipe => + widget.enableSwipe && + switch (_animationStatus) { + AnimationStatus.forward || AnimationStatus.completed || AnimationStatus.dismissed => true, + AnimationStatus.reverse => false, + }; + AnimationStatus _animationStatus = AnimationStatus.dismissed; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internalMenuController = MenuController(); + } + + _animationController = AnimationController.unbounded(vsync: this); + _animationController.addStatusListener(_handleAnimationStatusChange); + } + + @override + void didUpdateWidget(CupertinoMenuAnchor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (widget.controller != null) { + _internalMenuController = null; + } else { + assert(_internalMenuController == null); + _internalMenuController = MenuController(); + } + } + + if (oldWidget.menuChildren != widget.menuChildren) { + _hasLeadingWidget = _resolveHasLeading(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _hasLeadingWidget ??= _resolveHasLeading(); + } + + @override + void dispose() { + _menuScopeNode.dispose(); + _animationController + ..stop() + ..dispose(); + _internalMenuController = null; + _swipeDistanceNotifier.dispose(); + super.dispose(); + } + + bool _resolveHasLeading() { + return widget.menuChildren.any((Widget element) { + return switch (element) { + final CupertinoMenuEntry entry => entry.hasLeading(context), + _ => false, + }; + }); + } + + void _handleAnimationStatusChange(AnimationStatus status) { + setState(() { + _animationStatus = status; + }); + + widget.onAnimationStatusChanged?.call(status); + } + + void _handleSwipeDistanceChange(double distance) { + if (!_menuController.isOpen) { + return; + } + + // Because we are triggering a nested ticker, it's easiest to pass a + // listenable down the tree. Otherwise, it would be more idiomatic to use + // an inherited widget. + _swipeDistanceNotifier.value = distance; + } + + void _handleAnchorSwipeStart() { + if (isOpenOrOpening || !widget.enableLongPressToOpen) { + return; + } + _menuController.open(); + } + + void _handleCloseRequested(VoidCallback hideMenu) { + if (_animationStatus case AnimationStatus.reverse || AnimationStatus.dismissed) { + return; + } + + _animationController + .animateBackWith( + ClampedSimulation( + SpringSimulation( + reverseSpring, + _animationController.value, + 0.0, + 0.0, + tolerance: _kSpringTolerance, + ), + xMin: 0.0, + xMax: 1.0, + ), + ) + .whenComplete(hideMenu); + } + + void _handleOpenRequested(ui.Offset? position, VoidCallback showOverlay) { + showOverlay(); + + if (_animationStatus case AnimationStatus.completed || AnimationStatus.forward) { + return; + } + + _animationController.animateWith( + SpringSimulation(forwardSpring, _animationController.value, 1, 0.5), + ); + + FocusScope.of(context).setFirstFocus(_menuScopeNode); + } + + Widget _buildMenuOverlay(BuildContext childContext, RawMenuOverlayInfo info) { + return ExcludeSemantics( + excluding: !isOpenOrOpening, + child: IgnorePointer( + ignoring: !isOpenOrOpening, + child: ExcludeFocus( + excluding: !isOpenOrOpening, + child: _MenuOverlay( + constrainCrossAxis: widget.constrainCrossAxis, + visibilityAnimation: _animationController.view, + swipeDistanceListenable: _swipeDistanceNotifier, + constraints: widget.constraints, + consumeOutsideTaps: widget.consumeOutsideTaps, + overlaySize: info.overlaySize, + anchorRect: info.anchorRect, + anchorPosition: info.position, + tapRegionGroupId: info.tapRegionGroupId, + focusScopeNode: _menuScopeNode, + overlayPadding: widget.overlayPadding, + children: widget.menuChildren, + ), + ), + ), + ); + } + + Widget _buildChild(BuildContext context, MenuController controller, Widget? child) { + final Widget anchor = + widget.builder?.call(context, _menuController, widget.child) ?? + widget.child ?? + const SizedBox.shrink(); + + if (!widget.enableLongPressToOpen || !enableSwipe) { + return anchor; + } + + return _SwipeSurface( + onStart: _handleAnchorSwipeStart, + delay: _kLongPressToOpenDuration, + child: anchor, + ); + } + + @override + Widget build(BuildContext context) { + return _SwipeRegion( + onDistanceChanged: _handleSwipeDistanceChange, + enabled: enableSwipe, + child: _AnchorScope( + hasLeading: _hasLeadingWidget!, + child: RawMenuAnchor( + useRootOverlay: widget.useRootOverlay, + onCloseRequested: _handleCloseRequested, + onOpenRequested: _handleOpenRequested, + overlayBuilder: _buildMenuOverlay, + builder: _buildChild, + controller: _menuController, + childFocusNode: widget.childFocusNode, + consumeOutsideTaps: widget.consumeOutsideTaps, + onClose: widget.onClose, + onOpen: widget.onOpen, + ), + ), + ); + } +} + +class _MenuOverlay extends StatefulWidget { + const _MenuOverlay({ + required this.children, + required this.focusScopeNode, + required this.consumeOutsideTaps, + required this.constrainCrossAxis, + required this.constraints, + required this.overlaySize, + required this.overlayPadding, + required this.anchorRect, + required this.anchorPosition, + required this.tapRegionGroupId, + required this.visibilityAnimation, + required this.swipeDistanceListenable, + }); + + final List children; + final FocusScopeNode focusScopeNode; + final bool consumeOutsideTaps; + final bool constrainCrossAxis; + final BoxConstraints? constraints; + final Size overlaySize; + final EdgeInsetsGeometry overlayPadding; + final Rect anchorRect; + final Offset? anchorPosition; + final Object tapRegionGroupId; + final Animation visibilityAnimation; + final ValueListenable swipeDistanceListenable; + + @override + State<_MenuOverlay> createState() => _MenuOverlayState(); +} + +class _MenuOverlayState extends State<_MenuOverlay> + with TickerProviderStateMixin, WidgetsBindingObserver { + static const _kAttachmentOffset = Offset(0, 8); + static final Map> _kActions = >{ + _FocusDownIntent: _FocusDownAction(), + _FocusUpIntent: _FocusUpAction(), + _FocusFirstIntent: _FocusFirstAction(), + _FocusLastIntent: _FocusLastAction(), + }; + late final AnimationController _swipeAnimationController; + final ScrollController _scrollController = ScrollController(); + final ProxyAnimation _scaleAnimation = ProxyAnimation(); + final ProxyAnimation _fadeAnimation = ProxyAnimation(); + final ProxyAnimation _sizeAnimation = ProxyAnimation(); + late Alignment _attachmentPointAlignment; + late ui.Offset _attachmentPoint; + late Alignment _menuAlignment; + List _children = []; + ui.TextDirection? _textDirection; + + // The actual distance the user has swiped away from the menu. + double _swipeTargetDistance = 0; + + // The effective distance the user has swiped away from the menu, after + // applying velocity and deceleration. + double _swipeCurrentDistance = 0; + + // The accumulated velocity of the swipe gesture, used to determine how fast + // the menu scales to _swipeTargetDistance + double _swipeVelocity = 0; + + // A ticker used to drive the swipe animation. + Ticker? _swipeTicker; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _swipeAnimationController = AnimationController.unbounded(value: 1, vsync: this); + widget.swipeDistanceListenable.addListener(_handleSwipeDistanceChanged); + _resolveChildren(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ui.TextDirection newTextDirection = Directionality.of(context); + if (_textDirection != newTextDirection) { + _textDirection = newTextDirection; + _resolvePosition(); + } + + _resolveMotion(); + } + + @override + void didUpdateWidget(_MenuOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.swipeDistanceListenable != widget.swipeDistanceListenable) { + oldWidget.swipeDistanceListenable.removeListener(_handleSwipeDistanceChanged); + widget.swipeDistanceListenable.addListener(_handleSwipeDistanceChanged); + } + + if (oldWidget.visibilityAnimation != widget.visibilityAnimation) { + _resolveMotion(); + } + + if (oldWidget.anchorRect != widget.anchorRect || + oldWidget.anchorPosition != widget.anchorPosition || + oldWidget.overlaySize != widget.overlaySize) { + _resolvePosition(); + } + + if (oldWidget.children != widget.children) { + _resolveChildren(); + } + } + + @override + void didChangeAccessibilityFeatures() { + super.didChangeAccessibilityFeatures(); + _resolveMotion(); + } + + @override + void dispose() { + _scrollController.dispose(); + widget.swipeDistanceListenable.removeListener(_handleSwipeDistanceChanged); + _swipeTicker + ?..stop() + ..dispose(); + _swipeAnimationController + ..stop() + ..dispose(); + _scaleAnimation.parent = null; + _fadeAnimation.parent = null; + _sizeAnimation.parent = null; + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + void _resolveChildren() { + if (widget.children.isEmpty) { + _children = []; + return; + } + + final children = []; + Widget child = widget.children.first; + for (var i = 0; i < widget.children.length; i++) { + children.add(child); + if (child == widget.children.last) { + break; + } + + if (child case CupertinoMenuEntry(isDivider: true)) { + child = widget.children[i + 1]; + continue; + } + + child = widget.children[i + 1]; + if (child case CupertinoMenuEntry(isDivider: true)) { + continue; + } + + children.add(const _CupertinoMenuImplicitDivider()); + } + + _children = children; + } + + void _resolveMotion() { + // Behavior of reduce motion is based on iOS 18.5 simulator. Because the + // disableAnimations accessibility feature is not present on iOS, all + // animations are disabled when disableAnimations is enabled. + final ui.AccessibilityFeatures accessibilityFeatures = View.of( + context, + ).platformDispatcher.accessibilityFeatures; + + switch (accessibilityFeatures) { + case ui.AccessibilityFeatures(disableAnimations: true): + _scaleAnimation.parent = kAlwaysCompleteAnimation; + _fadeAnimation.parent = kAlwaysCompleteAnimation; + _sizeAnimation.parent = kAlwaysCompleteAnimation; + case ui.AccessibilityFeatures(reduceMotion: true): + // Swipe scaling works with reduced motion. + _scaleAnimation.parent = _swipeAnimationController.view.drive( + Tween(begin: 0.8, end: 1), + ); + _sizeAnimation.parent = kAlwaysCompleteAnimation; + _fadeAnimation.parent = widget.visibilityAnimation.drive( + CurveTween(curve: Curves.easeIn).chain(const _ClampTween(begin: 0, end: 1)), + ); + case _: + _scaleAnimation.parent = _AnimationProduct( + first: widget.visibilityAnimation, + next: _swipeAnimationController.view.drive(Tween(begin: 0.8, end: 1)), + ); + _sizeAnimation.parent = widget.visibilityAnimation.drive(Tween(begin: 0.8, end: 1)); + _fadeAnimation.parent = widget.visibilityAnimation.drive( + CurveTween(curve: Curves.easeIn).chain(const _ClampTween(begin: 0, end: 1)), + ); + } + } + + // Position was determined using iOS 18.5 simulator (phone + tablet). + // + // Layout needs to be resolved outside of the layout delegate because the + // ScaleTransition widget is dependent on the attachment point alignment. + void _resolvePosition() { + final ui.Offset anchorMidpoint; + if (widget.anchorPosition != null) { + anchorMidpoint = widget.anchorRect.topLeft + widget.anchorPosition!; + } else { + anchorMidpoint = widget.anchorRect.center; + } + + final double xMidpointRatio = anchorMidpoint.dx / widget.overlaySize.width; + final double yMidpointRatio = anchorMidpoint.dy / widget.overlaySize.height; + + // Slightly favor placing the menu below the anchor when it is near the vertical + // center of the screen. + final double dy = yMidpointRatio < 0.55 ? 1 : -1; + final double dx = switch (xMidpointRatio) { + < 0.4 => -1.0, // Left + > 0.6 => 1.0, // Right + _ => 0.0, // Center + }; + + _menuAlignment = Alignment(dx, -dy); + final Offset transformOrigin; + if (widget.anchorPosition != null) { + _attachmentPoint = widget.anchorRect.topLeft + widget.anchorPosition!; + transformOrigin = _attachmentPoint; + } else { + final ui.Offset offset = _kAttachmentOffset * dy; + _attachmentPoint = Alignment(dx, dy).withinRect(widget.anchorRect) + offset; + transformOrigin = Alignment(0, dy).withinRect(widget.anchorRect) + offset; + } + + final double xOriginRatio = transformOrigin.dx / widget.overlaySize.width; + final double yOriginRatio = transformOrigin.dy / widget.overlaySize.height; + + // The alignment of the menu growth point relative to the overlay. + _attachmentPointAlignment = Alignment(xOriginRatio * 2 - 1, yOriginRatio * 2 - 1); + } + + void _handleOutsideTap(PointerDownEvent event) { + MenuController.maybeOf(context)!.close(); + } + + void _handleSwipeDistanceChanged() { + _swipeTargetDistance = ui.clampDouble(widget.swipeDistanceListenable.value, 0, 150); + if (_swipeCurrentDistance == _swipeTargetDistance) { + return; + } + + _swipeTicker ??= createTicker(_updateSwipeScale); + if (!_swipeTicker!.isActive) { + _swipeTicker!.start(); + } + } + + // The menu will scale between 80% and 100% of its size based on the distance + // the user has dragged their pointer away from the menu edges. + void _updateSwipeScale(Duration elapsed) { + const maxVelocity = 20.0; + const minVelocity = 8.0; + const maxSwipeDistance = 150.0; + const accelerationRate = 0.12; + + // The distance below which velocity begins to decelerate. + // + // When the swipe distance to target is less than this value, the animation + // velocity reduces proportionally to create smooth arrival at the target. + // Higher values mean the animation begins to decelerate sooner, resulting to + // a smoother animation curve. + const decelerationDistanceThreshold = 80.0; + + // The distance at which the animation will snap to the target distance without + // any animation. + const remainingDistanceSnapThreshold = 1.0; + + // When the user's pointer is within this distance of the menu edges, the + // swipe animation will terminate. + const terminationDistanceThreshold = 5.0; + + final double distance = _swipeTargetDistance - _swipeCurrentDistance; + final double absoluteDistance = distance.abs(); + + // As the distance between the current position and the target position increases, + // the proximity factor approaches 1.0, which increases acceleration. + // + // Conversely, as the current position nears the target within the deceleration + // zone, the proximity factor approaches 0.0, which decreases acceleration + // and smoothes the end of the animation. + final double proximityFactor = math.min(absoluteDistance / decelerationDistanceThreshold, 1.0); + + _swipeVelocity += accelerationRate * proximityFactor; + _swipeVelocity = ui.clampDouble(_swipeVelocity, minVelocity, maxVelocity); + + final double finalVelocity = _swipeVelocity * proximityFactor; + final double distanceReduction = distance.sign * finalVelocity; + _swipeCurrentDistance += distanceReduction; + + if (absoluteDistance < remainingDistanceSnapThreshold) { + _swipeCurrentDistance = _swipeTargetDistance; + _swipeVelocity = 0; + if (_swipeTargetDistance < terminationDistanceThreshold) { + _swipeTicker!.stop(); + } + } + + _swipeAnimationController.value = 1 - _swipeCurrentDistance / maxSwipeDistance; + } + + Widget _buildAlign(BuildContext context, Widget? child) { + return Align( + heightFactor: _sizeAnimation.value, + widthFactor: 1.0, + alignment: Alignment.topCenter, + child: child, + ); + } + + @override + Widget build(BuildContext context) { + final BoxConstraints constraints; + if (widget.constraints != null) { + constraints = widget.constraints!; + } else { + final bool isLargeTextModeEnabled = _largeTextModeEnabled(context); + final double screenWidth = MediaQuery.widthOf(context); + final menuWidth = _CupertinoMenuWidth.fromScreenWidth( + isLargeTextModeEnabled: isLargeTextModeEnabled, + screenWidth: screenWidth, + ); + constraints = BoxConstraints.tightFor(width: menuWidth.points); + } + + Widget child = _SwipeSurface( + child: TapRegion( + groupId: widget.tapRegionGroupId, + consumeOutsideTaps: widget.consumeOutsideTaps, + onTapOutside: _handleOutsideTap, + child: Actions( + actions: _kActions, + child: Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: FocusScope( + node: widget.focusScopeNode, + descendantsAreFocusable: true, + descendantsAreTraversable: true, + canRequestFocus: true, + // A custom shadow painter is used to make the underlying colors + // appear more vibrant. + child: CustomPaint( + painter: _ShadowPainter( + brightness: CupertinoTheme.maybeBrightnessOf(context) ?? ui.Brightness.light, + repaint: _fadeAnimation, + ), + // The FadeTransition widget needs to wrap Semantics so + // that the semantics widget senses that the menu is the + // same opacity as the menu items. Otherwise, "a menu + // cannot be empty" error is thrown due to the menu items + // being transparent while the menu semantics are still + // present. + child: FadeTransition( + opacity: _fadeAnimation, + alwaysIncludeSemantics: true, + child: CupertinoPopupSurface( + child: AnimatedBuilder( + animation: _sizeAnimation, + builder: _buildAlign, + child: Semantics( + explicitChildNodes: true, + scopesRoute: true, + child: ConstrainedBox( + constraints: constraints, + child: SingleChildScrollView( + clipBehavior: Clip.none, + child: Column(mainAxisSize: MainAxisSize.min, children: _children), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + // The menu content can grow beyond the size of the overlay, but will be + // clipped by the overlay's bounds. + if (!widget.constrainCrossAxis) { + child = UnconstrainedBox( + clipBehavior: Clip.hardEdge, + alignment: AlignmentDirectional.centerStart, + constrainedAxis: Axis.vertical, + child: child, + ); + } + + return ConstrainedBox( + constraints: BoxConstraints.loose(widget.overlaySize), + child: ScaleTransition( + scale: _scaleAnimation, + alignment: _attachmentPointAlignment, + child: ValueListenableBuilder( + valueListenable: _sizeAnimation, + child: child, + builder: (BuildContext context, double value, Widget? child) { + final ui.Rect effectiveAnchorRect = widget.anchorPosition != null + ? _attachmentPoint & Size.zero + : widget.anchorRect; + final List? displayFeatures = MediaQuery.maybeDisplayFeaturesOf( + context, + ); + return CustomSingleChildLayout( + delegate: _MenuLayoutDelegate( + anchorRect: effectiveAnchorRect, + attachmentPoint: _attachmentPoint, + avoidBounds: displayFeatures != null ? avoidBounds(displayFeatures) : {}, + heightFactor: value, + menuAlignment: _menuAlignment, + overlayPadding: widget.overlayPadding.resolve(_textDirection), + ), + child: child, + ); + }, + ), + ), + ); + } + + static Set avoidBounds(List displayFeatures) { + final bounds = {}; + for (final feature in displayFeatures) { + if (feature.bounds.shortestSide > 0 || + feature.state == ui.DisplayFeatureState.postureHalfOpened) { + bounds.add(feature.bounds); + } + } + return bounds; + } +} + +class _ShadowPainter extends CustomPainter { + const _ShadowPainter({required this.brightness, required this.repaint}) : super(repaint: repaint); + static const Radius _kRadius = Radius.circular(13); + static const double _kShadowOpacity = 0.12; + double get shadowAnimation => ui.clampDouble(repaint.value, 0, 1); + final Animation repaint; + final ui.Brightness brightness; + + @override + void paint(Canvas canvas, Size size) { + assert(shadowAnimation >= 0 && shadowAnimation <= 1); + final center = Offset(size.width / 2, size.height / 2); + final rect = Rect.fromCenter(center: center, width: size.width, height: size.height); + final roundedRect = RSuperellipse.fromRectAndRadius(rect, _kRadius); + + final double blurSigma = shadowAnimation * 50; + final shadowPaint = Paint() + ..maskFilter = MaskFilter.blur(BlurStyle.normal, blurSigma) + ..color = ui.Color.fromRGBO(0, 0, 10, shadowAnimation * shadowAnimation * _kShadowOpacity); + + final maskPath = Path() + ..fillType = ui.PathFillType.evenOdd + // Extra large rect to ensure the shadow is fully visible. + ..addRect(rect.inflate(200)) + ..addRRect(RRect.fromRectAndRadius(rect, _kRadius)); + + // Clip the shadow underneath the menu shape to make the shadow appear more + // vibrant. + canvas + ..save() + ..clipPath(maskPath) + ..drawRSuperellipse(roundedRect.inflate(50), shadowPaint) + ..restore(); + } + + @override + bool shouldRepaint(_ShadowPainter oldDelegate) { + return oldDelegate.brightness != brightness || oldDelegate.repaint != repaint; + } + + @override + bool shouldRebuildSemantics(_ShadowPainter oldDelegate) => false; +} + +class _MenuLayoutDelegate extends SingleChildLayoutDelegate { + const _MenuLayoutDelegate({ + required this.anchorRect, + required this.attachmentPoint, + required this.avoidBounds, + required this.heightFactor, + required this.menuAlignment, + required this.overlayPadding, + }); + + // Rectangle anchoring the menu + final ui.Rect anchorRect; + + // The offset of the menu from the top-left corner of the overlay. + final ui.Offset attachmentPoint; + + // List of rectangles that the menu should not overlap. Unusable screen area. + final Set avoidBounds; + + // The factor by which to multiply the height of the child. + final double heightFactor; + + // The resolved alignment of the menu attachment point relative to the menu surface. + final Alignment menuAlignment; + + // Unsafe bounds used when constraining and positioning the menu. + // + // Used to prevent the menu from being obstructed by system UI. + final EdgeInsets overlayPadding; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus padding. + return BoxConstraints.loose(constraints.biggest).deflate(overlayPadding); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final double inverseHeightFactor = heightFactor > 0.01 ? 1 / heightFactor : 0; + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + final double finalHeight = math.min(childSize.height * inverseHeightFactor, size.height); + final finalSize = Size(childSize.width, finalHeight); + final ui.Offset desiredPosition = attachmentPoint - menuAlignment.alongSize(finalSize); + final ui.Rect screen = _findClosestScreen(size, anchorRect.center, avoidBounds); + final ui.Offset finalPosition = _positionChild(screen, finalSize, desiredPosition, anchorRect); + + // If the menu sits above the anchor when fully open, grow upward: + // keep the bottom (attachment) fixed by shifting the top-left during animation. + final bool growsUp = finalPosition.dy + finalSize.height <= anchorRect.center.dy; + if (growsUp) { + final double dy = finalHeight - childSize.height; + return Offset(finalPosition.dx, finalPosition.dy + dy); + } + + final initialPosition = Offset(finalPosition.dx, anchorRect.bottom); + return Offset.lerp(initialPosition, finalPosition, heightFactor)!; + } + + Offset _positionChild(Rect screen, Size childSize, Offset position, ui.Rect anchor) { + double x = position.dx; + double y = position.dy; + + bool overLeftEdge(double x) => x < screen.left + overlayPadding.left; + bool overRightEdge(double x) => x > screen.right - childSize.width - overlayPadding.right; + bool overTopEdge(double y) => y < screen.top + overlayPadding.top; + bool overBottomEdge(double y) => y > screen.bottom - childSize.height - overlayPadding.bottom; + + // Layout horizontally first to determine if the menu can be placed on + // either side of the anchor without overlapping. + bool hasHorizontalAnchorOverlap = childSize.width >= screen.width; + if (hasHorizontalAnchorOverlap) { + x = screen.left + overlayPadding.left; + } else { + if (overLeftEdge(x)) { + // Flip the X position across the horizontal midpoint of the anchor so + // that the menu is to the right of the anchor. + final double flipX = anchor.center.dx * 2 - position.dx - childSize.width; + hasHorizontalAnchorOverlap = overRightEdge(flipX); + if (hasHorizontalAnchorOverlap || overLeftEdge(flipX)) { + x = screen.left + overlayPadding.left; + } else { + x = flipX; + } + } else if (overRightEdge(x)) { + // Flip the X position across the horizontal midpoint of the anchor so + // that the menu is to the left of the anchor. + final double flipX = anchor.center.dx * 2 - position.dx - childSize.width; + hasHorizontalAnchorOverlap = overLeftEdge(flipX); + if (hasHorizontalAnchorOverlap || overRightEdge(flipX)) { + x = screen.right - childSize.width - overlayPadding.right; + } else { + x = flipX; + } + } + } + + if (childSize.height >= screen.height) { + // Menu is too big to fit on screen. Fit as much as possible. + return Offset(x, screen.top + overlayPadding.top); + } + + // Behavior in this scenario could not be determined on iOS 18.5 + // simulator, so this logic is based on what seems most reasonable. + if (hasHorizontalAnchorOverlap && !anchor.isEmpty) { + // If both horizontal screen edges overlap, shift the menu upwards or + // downwards by the minimum amount needed to avoid overlapping the anchor. + // + // NOTE: Menus that are deliberately overlapping the anchor will stop + // overlapping the anchor, but only when the screen's width is smaller + // than the menu's width. + final double below = anchor.bottom - y; + final double above = y + childSize.height - anchor.top; + if (below > 0 && above > 0) { + if (below > above) { + y = anchor.top - childSize.height; + } else { + y = anchor.bottom; + } + } + } + + if (overTopEdge(y)) { + // Flip the Y position across the vertical midpoint of the anchor so that + // the menu is below the anchor. + final double flipY = anchor.center.dy * 2 - position.dy - childSize.height; + if (overTopEdge(flipY) || overBottomEdge(flipY)) { + y = screen.top + overlayPadding.top; + } else { + y = flipY; + } + } else if (overBottomEdge(y)) { + // Flip the Y position across the vertical midpoint of the anchor so that + // the menu is above the anchor. + final double flipY = anchor.center.dy * 2 - position.dy - childSize.height; + if (overTopEdge(flipY) || overBottomEdge(flipY)) { + y = screen.bottom - childSize.height - overlayPadding.bottom; + } else { + y = flipY; + } + } + + return Offset(x, y); + } + + // Finds the closest screen to the anchor point. + // + // This algorithm is different than the algorithms for PopupMenuButton and MenuAnchor, + // since those widgets calculate the closest screen based on the center of the + // overlay. + Rect _findClosestScreen(Size parentSize, Offset point, Set avoidBounds) { + final Iterable screens = DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & parentSize, + avoidBounds, + ); + + Rect? closest; + double closestSquaredDistance = 0; + for (final screen in screens) { + if (screen.contains(point)) { + return screen; + } + + if (closest == null) { + closest = screen; + closestSquaredDistance = _computeSquaredDistanceToRect(point, closest); + continue; + } + + final double squaredDistance = _computeSquaredDistanceToRect(point, screen); + if (squaredDistance < closestSquaredDistance) { + closest = screen; + closestSquaredDistance = squaredDistance; + } + } + + return closest!; + } + + @override + bool shouldRelayout(_MenuLayoutDelegate oldDelegate) { + return anchorRect != oldDelegate.anchorRect || + attachmentPoint != oldDelegate.attachmentPoint || + !setEquals(avoidBounds, oldDelegate.avoidBounds) || + heightFactor != oldDelegate.heightFactor || + menuAlignment != oldDelegate.menuAlignment || + overlayPadding != oldDelegate.overlayPadding; + } +} + +class _FocusUpIntent extends DirectionalFocusIntent { + const _FocusUpIntent() : super(TraversalDirection.up); +} + +class _FocusDownIntent extends DirectionalFocusIntent { + const _FocusDownIntent() : super(TraversalDirection.down); +} + +class _FocusUpAction extends ContextAction { + _FocusUpAction(); + + @override + void invoke(DirectionalFocusIntent intent, [BuildContext? context]) { + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); + if (_isCupertino && !kIsWeb) { + // Don't wrap on iOS or macOS. + policy.inDirection(primaryFocus!, intent.direction); + return; + } + + final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true); + final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true); + if (lastFocus.context != null) { + if (primaryFocus == lastFocus.enclosingScope || primaryFocus == firstFocus) { + policy.requestFocusCallback(lastFocus); + return; + } + } + + policy.inDirection(primaryFocus!, intent.direction); + } +} + +class _FocusDownAction extends ContextAction { + _FocusDownAction(); + + @override + void invoke(DirectionalFocusIntent intent, [BuildContext? context]) { + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); + if (_isCupertino && !kIsWeb) { + // Don't wrap on iOS or macOS. + policy.inDirection(primaryFocus!, intent.direction); + return; + } + + final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true); + final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true); + if (firstFocus?.context != null) { + if (primaryFocus == firstFocus!.enclosingScope || primaryFocus == lastFocus) { + policy.requestFocusCallback(firstFocus); + return; + } + } + + policy.inDirection(primaryFocus!, intent.direction); + } +} + +class _FocusFirstIntent extends Intent { + const _FocusFirstIntent(); +} + +class _FocusFirstAction extends ContextAction<_FocusFirstIntent> { + _FocusFirstAction(); + + @override + void invoke(_FocusFirstIntent intent, [BuildContext? context]) { + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); + final FocusNode? firstFocus = policy.findFirstFocus(primaryFocus!, ignoreCurrentFocus: true); + if (firstFocus == null || firstFocus.context == null) { + return; + } + policy.requestFocusCallback(firstFocus); + } +} + +class _FocusLastIntent extends Intent { + const _FocusLastIntent(); +} + +class _FocusLastAction extends ContextAction<_FocusLastIntent> { + _FocusLastAction(); + + @override + void invoke(_FocusLastIntent intent, [BuildContext? context]) { + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(context!) ?? ReadingOrderTraversalPolicy(); + final FocusNode lastFocus = policy.findLastFocus(primaryFocus!, ignoreCurrentFocus: true); + if (lastFocus.context == null) { + return; + } + policy.requestFocusCallback(lastFocus); + } +} + +/// A horizontal divider placed between each menu item in a +/// [CupertinoMenuAnchor] on iOS 18 and before. +/// +/// To create a menu item that does not show an automatic divider, implement +/// [CupertinoMenuEntry] and return true from [CupertinoMenuEntry.isDivider]. +/// +/// The default thickness of the divider is 1 physical pixel. +class _CupertinoMenuImplicitDivider extends StatelessWidget { + /// Draws a [_CupertinoMenuImplicitDivider] below a [child]. + const _CupertinoMenuImplicitDivider(); + + /// The default color applied to the [_CupertinoMenuImplicitDivider] with + /// [ui.BlendMode.overlay]. + /// + /// On all platforms except web, this color is applied to the divider before + /// the [kDividerColor] is applied, and is used to create a subtle translucent effect + /// against the menu background. + // The following colors were measured from the iOS 17.2 simulator, and opacity was + // extrapolated: + // Dark mode on black Color.fromRGBO(97, 97, 97) + // Dark mode on white Color.fromRGBO(132, 132, 132) + // Light mode on black Color.fromRGBO(147, 147, 147) + // Light mode on white Color.fromRGBO(187, 187, 187) + // + // Colors were also compared atop a red, green, and blue backgrounds. + static const CupertinoDynamicColor kOverlayColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(140, 140, 140, 0.3), + darkColor: Color.fromRGBO(255, 255, 255, 0.25), + ); + + /// The default color applied to the [_CupertinoMenuImplicitDivider], atop the + /// [kOverlayColor], with [BlendMode.srcOver]. + /// + /// This color is used to make the divider more opaque. + static const CupertinoDynamicColor kDividerColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(0, 0, 0, 0.25), + darkColor: Color.fromRGBO(255, 255, 255, 0.25), + ); + + @override + Widget build(BuildContext context) { + final double pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; + final double displacement = 1 / pixelRatio; + return CustomPaint( + size: Size(double.infinity, displacement), + painter: _CupertinoDividerPainter( + color: CupertinoDynamicColor.resolve(kDividerColor, context), + overlayColor: CupertinoDynamicColor.resolve(kOverlayColor, context), + // Only anti-alias on devices with a low pixel density. + antiAlias: pixelRatio < 1.0, + ), + ); + } +} + +/// A large horizontal divider that is used to separate [CupertinoMenuItem]s in +/// a [CupertinoMenuAnchor]. +/// +/// The divider has a height of 8 logical pixels. The [color] parameter can be +/// provided to customize the color of the divider. +/// +/// See also: +/// +/// * [CupertinoMenuItem], a Cupertino-style menu item. +/// * [CupertinoMenuAnchor], a widget that creates a Cupertino-style popup menu. +/// * [CupertinoMenuEntry], an interface that can be used to control whether +/// dividers are shown before or after a menu item. +class CupertinoMenuDivider extends StatelessWidget implements CupertinoMenuEntry { + /// Creates a large horizontal divider for a [CupertinoMenuAnchor]. + const CupertinoMenuDivider({super.key, this.color = kDefaultColor}); + + /// The color of the divider. + /// + /// Defaults to [CupertinoMenuDivider.kDefaultColor]. + final Color color; + + @override + bool get isDivider => true; + + @override + bool hasLeading(BuildContext context) => false; + + /// Default color for a [CupertinoMenuDivider]. + // The following colors were measured from debug mode on the iOS 18.5 simulator, + static const CupertinoDynamicColor kDefaultColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(0, 0, 0, 0.08), + darkColor: Color.fromRGBO(0, 0, 0, 0.16), + ); + + static const double _kDividerHeight = 8.0; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: CupertinoDynamicColor.resolve(color, context), + child: const SizedBox(height: _kDividerHeight, width: double.infinity), + ); + } +} + +// Draws an aliased line that approximates the appearance of an iOS 18.5 menu +// divider using blend modes. +class _CupertinoDividerPainter extends CustomPainter { + const _CupertinoDividerPainter({ + required this.color, + required this.overlayColor, + this.antiAlias = false, + }); + + final Color color; + final Color overlayColor; + final bool antiAlias; + + @override + void paint(Canvas canvas, Size size) { + final Offset p1 = size.centerLeft(Offset.zero); + final Offset p2 = size.centerRight(Offset.zero); + + // BlendMode.overlay is not supported on the web. + if (!kIsWeb) { + final overlayPainter = Paint() + ..style = PaintingStyle.stroke + ..color = overlayColor + ..isAntiAlias = antiAlias + ..blendMode = BlendMode.overlay; + canvas.drawLine(p1, p2, overlayPainter); + } + + final colorPainter = Paint() + ..style = PaintingStyle.stroke + ..color = color + ..isAntiAlias = antiAlias; + canvas.drawLine(p1, p2, colorPainter); + } + + @override + bool shouldRepaint(_CupertinoDividerPainter oldDelegate) { + return color != oldDelegate.color || + overlayColor != oldDelegate.overlayColor || + antiAlias != oldDelegate.antiAlias; + } +} + +/// A menu item for use in a [CupertinoMenuAnchor]. +/// +/// ## Layout +/// The menu item is unconstrained by default and will grow to fit the size of +/// its container. To constrain the size of a [CupertinoMenuItem], the +/// [constraints] parameter can be set. When set, the [constraints] apply to the +/// total area occupied by the content and its [padding]. This means that +/// [padding] will only affect the size of this menu item if this item's minimum +/// constraints are less than the sum of its [padding] and the size of its +/// contents. +/// +/// The [leading] and [trailing] widgets display before and after the [child] +/// widget, respectively. The [leadingWidth] and [trailingWidth] parameters +/// control the horizontal space that these widgets occupy. The +/// [leadingMidpointAlignment] and [trailingMidpointAlignment] parameters control the alignment +/// of the leading and trailing widgets within their respective spaces. +/// +/// ## Input +/// In order to respond to user input, an [onPressed] callback must be provided. +/// If absent, user input callbacks ([onFocusChange], [onHover], and +/// [onPressed]) will be ignored. The [behavior] parameter can be used to +/// control whether hit tests can travel behind the menu item, and the +/// [mouseCursor] parameter can be used to change the cursor that appears when +/// the user hovers over the menu. +/// +/// The [requestCloseOnActivate] parameter can be set to false to prevent the +/// menu from closing when the item is activated. By default, the menu will +/// close when an item is pressed. +/// +/// The [requestFocusOnHover] parameter, when true, focuses the menu item when +/// the item is hovered. +/// +/// ## Visuals +/// The [decoration] parameter can be used to change the background color of the +/// menu item when hovered, focused, pressed, or swiped. If these parameters are +/// not set, the menu item will use [CupertinoMenuItem.kDefaultDecoration]. +/// +/// The [isDestructiveAction] parameter should be set to true if the menu item +/// will perform a destructive action, and will color the text of the menu item +/// [CupertinoColors.systemRed]. +/// +/// {@tool sample} +/// This example demonstrates a simple [CupertinoMenuAnchor] that wraps +/// a button. +/// +/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example demonstrates a [CupertinoMenuAnchor] that wraps a button and +/// shows a menu with three [CupertinoMenuItem]s and one [CupertinoMenuDivider]. +/// +/// ** See code in examples/api/lib/cupertino/menu_anchor/menu_anchor.1.dart ** +/// {@end-tool} +/// +/// See also: +/// * [CupertinoMenuAnchor], a Cupertino-style widget that shows a menu of +/// actions in a popup +/// * [RawMenuAnchor], a lower-level widget that creates a region with a submenu +/// that is the basis for [CupertinoMenuAnchor]. +/// * [PlatformMenuBar], which creates a menu bar that is rendered by the host +/// platform instead of by Flutter (on macOS, for example). +class CupertinoMenuItem extends StatelessWidget implements CupertinoMenuEntry { + /// Creates a [CupertinoMenuItem] + /// + /// The [child] parameter is required and must not be null. + const CupertinoMenuItem({ + super.key, + required this.child, + this.subtitle, + this.leading, + this.leadingWidth, + this.leadingMidpointAlignment, + this.trailing, + this.trailingWidth, + this.trailingMidpointAlignment, + this.padding, + this.constraints, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHover, + this.onPressed, + this.decoration, + this.mouseCursor, + this.behavior = HitTestBehavior.opaque, + this.requestCloseOnActivate = true, + this.requestFocusOnHover = true, + this.isDestructiveAction = false, + }); + + /// The widget displayed in the center of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The padding applied to this menu item. + final EdgeInsetsGeometry? padding; + + /// The widget shown before the label. Typically an [Icon]. + final Widget? leading; + + /// The widget shown after the label. Typically an [Icon]. + final Widget? trailing; + + /// A widget displayed underneath the [child]. Typically a [Text] widget. + final Widget? subtitle; + + /// Called when this menu is tapped or otherwise activated. + /// + /// If a callback is not provided, then the button will be disabled. + final VoidCallback? onPressed; + + /// Triggered when a pointer moves into a position within this widget without + /// buttons pressed. + /// + /// Usually this is only fired for pointers which report their location when + /// not down (e.g. mouse pointers). Certain devices also fire this event on + /// single taps in accessibility mode. + /// + /// This callback is not triggered by the movement of the widget. + /// + /// The time that this callback is triggered is during the callback of a + /// pointer event, which is always between frames. + final ValueChanged? onHover; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged? onFocusChange; + + /// Whether hovering should request focus for this widget. + /// + /// Defaults to true. + final bool requestFocusOnHover; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The decoration to paint behind the menu item. + /// + /// If null, defaults to [CupertinoMenuItem.kDefaultDecoration]. + final WidgetStateProperty? decoration; + + /// The mouse cursor to display on hover. + final WidgetStateProperty? mouseCursor; + + /// How the menu item should respond to hit tests. + final HitTestBehavior behavior; + + /// Determines if the menu will be closed when a [CupertinoMenuItem] is pressed. + /// + /// Defaults to true. + final bool requestCloseOnActivate; + + /// Whether pressing this item will perform a destructive action + /// + /// Defaults to false. If true, the default color of this item's label and + /// icon will be [CupertinoColors.systemRed]. + final bool isDestructiveAction; + + /// The horizontal space in which the [leading] widget can be placed. + final double? leadingWidth; + + /// The horizontal space in which the [trailing] widget can be placed. + final double? trailingWidth; + + /// The alignment of the center point of the leading widget within the + /// [leadingWidth] of the menu item. + final AlignmentGeometry? leadingMidpointAlignment; + + /// The alignment of the center point of the trailing widget within the + /// [trailingWidth] of the menu item. + final AlignmentGeometry? trailingMidpointAlignment; + + /// The [BoxConstraints] to apply to the menu item. + /// + /// Because [padding] is applied to the menu item prior to [constraints], the [padding] + /// will only affect the size of the menu item if the vertical [padding] + /// plus the height of the menu item's children exceeds the + /// [BoxConstraints.minHeight]. + final BoxConstraints? constraints; + + @override + bool hasLeading(BuildContext context) => leading != null; + + @override + bool get isDivider => false; + + /// The decoration of a [CupertinoMenuItem] when pressed. + // Pressed colors were sampled from the iOS simulator and are based on the + // following: + // + // Dark mode on white background rgb(111, 111, 111) + // Dark mode on black rgb(61, 61, 61) + // Light mode on black rgb(177, 177, 177) + // Light mode on white rgb(225, 225, 225) + // + // Blend mode is used to mimic the visual effect of the iOS + // menu item. As a result, the default pressed color does not match the + // reported colors on the iOS 18.5 simulator. + static const WidgetStateProperty kDefaultDecoration = + WidgetStateProperty.fromMap({ + WidgetState.dragged: BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.1), + darkColor: Color.fromRGBO(255, 255, 255, 0.1), + ), + ), + WidgetState.pressed: BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.1), + darkColor: Color.fromRGBO(255, 255, 255, 0.1), + ), + ), + WidgetState.focused: BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.075), + darkColor: Color.fromRGBO(255, 255, 255, 0.075), + ), + ), + WidgetState.hovered: BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.05), + darkColor: Color.fromRGBO(255, 255, 255, 0.05), + ), + ), + WidgetState.any: BoxDecoration(), + }); + + static final WidgetStateProperty _kDefaultCursor = + WidgetStateProperty.resolveWith((Set states) { + return !states.contains(WidgetState.disabled) && kIsWeb + ? SystemMouseCursors.click + : MouseCursor.defer; + }); + + // Measured from the iOS 18.5 simulator debug view. + static const Color _kDefaultTextColor = CupertinoDynamicColor.withBrightness( + color: Color.from(alpha: 0.96, red: 0, green: 0, blue: 0), + darkColor: Color.from(alpha: 0.96, red: 1, green: 1, blue: 1), + ); + + /// The default [Color] applied to a [CupertinoMenuItem]'s [subtitle] + /// widget, if a subtitle is provided. + /// + /// A custom blend mode is applied to the subtitle to mimic the visual effect + /// of the iOS menu subtitle. As a result, the _kDefaultSubtitleTextColor does + /// not match the reported color on the iOS 18.5 simulator. + static const Color _kDefaultSubtitleTextColor = CupertinoDynamicColor.withBrightness( + color: Color.from(alpha: 0.55, red: 0, green: 0, blue: 0), + darkColor: Color.from(alpha: 0.4, red: 1, green: 1, blue: 1), + ); + + /// The maximum number of lines for the [child] widget when + /// [MediaQuery.textScalerOf] returns a [TextScaler] that is less than or + /// equal to 1.25. + /// + /// Measured from the iOS 18.5 simulator debug view. + static const int _kDefaultMaxLines = 2; + + /// The maximum number of lines for the [child] widget when + /// [MediaQuery.textScalerOf] returns a [TextScaler] that is greater than + /// 1.25. + static const int _kDefaultLargeTextModeMaxLines = 100; + + static const TextStyle _kLeadingDefaultTextStyle = TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ); + + static const IconThemeData _kLeadingDefaultIconTheme = IconThemeData( + size: 15, + weight: 600, + applyTextScaling: true, + ); + + static const TextStyle _kTrailingDefaultTextStyle = TextStyle(fontSize: 21); + + static const IconThemeData _kTrailingDefaultIconTheme = IconThemeData( + size: 21, + applyTextScaling: true, + ); + + /// Resolves the title [TextStyle] in response to + /// [CupertinoThemeData.brightness], [isDestructiveAction], and [enabled]. + // + // Approximated from the iOS and iPadOS 18.5 simulators. + TextStyle _resolveDefaultTextStyle(BuildContext context, TextScaler textScaler) { + Color color; + if (onPressed == null) { + color = CupertinoColors.systemGrey; + } else if (isDestructiveAction) { + color = CupertinoColors.systemRed; + } else { + color = _kDefaultTextColor; + } + + return _DynamicTypeStyle.body + .resolveTextStyle(textScaler) + .copyWith( + // Font size will be scaled by TextScaler. + fontSize: 17, + color: CupertinoDynamicColor.resolve(color, context), + ); + } + + TextStyle _resolveDefaultSubtitleStyle(BuildContext context, TextScaler textScaler) { + final isDark = CupertinoTheme.maybeBrightnessOf(context) == Brightness.dark; + + return _DynamicTypeStyle.subhead + .resolveTextStyle(textScaler) + .copyWith( + // Font size will be scaled by TextScaler. + fontSize: 15, + textBaseline: TextBaseline.alphabetic, + foreground: Paint() + // Per iOS 18.5 simulator: + // Dark mode: linearDodge is used on iOS to achieve a lighter color. + // This is approximated with BlendMode.plus. + // For light mode: plusDarker is used on iOS to achieve a darker color. + // HardLight is used as an approximation. + ..blendMode = isDark ? BlendMode.plus : BlendMode.hardLight + ..color = CupertinoDynamicColor.resolve(_kDefaultSubtitleTextColor, context), + ); + } + + void _handleSelect(BuildContext context) { + if (requestCloseOnActivate) { + MenuController.maybeOf(context)?.close(); + } + + onPressed?.call(); + } + + @override + Widget build(BuildContext context) { + final TextScaler textScaler = + MediaQuery.maybeTextScalerOf(context) ?? + TextScaler.linear(MediaQuery.maybeTextScaleFactorOf(context) ?? 1); + final TextStyle defaultTextStyle = _resolveDefaultTextStyle(context, textScaler); + final bool isLargeTextModeEnabled = _largeTextModeEnabled(context); + Widget? leadingWidget; + Widget? trailingWidget; + if (leading != null) { + leadingWidget = DefaultTextStyle.merge( + style: _kLeadingDefaultTextStyle, + child: IconTheme.merge(data: _kLeadingDefaultIconTheme, child: leading!), + ); + } + + if (trailing != null && !isLargeTextModeEnabled) { + trailingWidget = DefaultTextStyle.merge( + style: _kTrailingDefaultTextStyle, + child: IconTheme.merge(data: _kTrailingDefaultIconTheme, child: trailing!), + ); + } + + return MediaQuery.withClampedTextScaling( + minScaleFactor: _kMinimumTextScaleFactor, + maxScaleFactor: _kMaximumTextScaleFactor, + child: _CupertinoMenuItemInteractionHandler( + mouseCursor: mouseCursor ?? _kDefaultCursor, + requestFocusOnHover: requestFocusOnHover, + onPressed: onPressed != null ? () => _handleSelect(context) : null, + onHover: onHover, + onFocusChange: onFocusChange, + autofocus: autofocus, + focusNode: focusNode, + decoration: decoration ?? kDefaultDecoration, + behavior: behavior, + child: DefaultTextStyle.merge( + maxLines: isLargeTextModeEnabled ? _kDefaultLargeTextModeMaxLines : _kDefaultMaxLines, + overflow: TextOverflow.ellipsis, + softWrap: true, + style: TextStyle(color: defaultTextStyle.color), + child: IconTheme.merge( + data: IconThemeData(color: defaultTextStyle.color), + child: _CupertinoMenuItemLabel( + padding: padding, + constraints: constraints, + trailing: trailingWidget, + leading: leadingWidget, + leadingMidpointAlignment: leadingMidpointAlignment, + trailingMidpointAlignment: trailingMidpointAlignment, + leadingWidth: leadingWidth, + trailingWidth: trailingWidth, + subtitle: subtitle != null + ? DefaultTextStyle.merge( + style: _resolveDefaultSubtitleStyle(context, textScaler), + child: subtitle!, + ) + : null, + child: DefaultTextStyle.merge(style: defaultTextStyle, child: child), + ), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('child', child)); + properties.add( + FlagProperty( + 'requestCloseOnActivate', + value: requestCloseOnActivate, + ifTrue: 'closes on press', + ifFalse: 'does not close on press', + defaultValue: true, + ), + ); + + properties.add( + FlagProperty( + 'requestFocusOnHover', + value: requestFocusOnHover, + ifFalse: 'does not request focus on hover', + ifTrue: 'requests focus on hover', + defaultValue: true, + ), + ); + + properties.add(EnumProperty('hitTestBehavior', behavior)); + properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); + properties.add(FlagProperty('enabled', value: onPressed != null, ifFalse: 'DISABLED')); + + if (subtitle != null) { + properties.add(DiagnosticsProperty('subtitle', subtitle)); + } + if (leading != null) { + properties.add(DiagnosticsProperty('leading', leading)); + } + if (trailing != null) { + properties.add(DiagnosticsProperty('trailing', trailing)); + } + } +} + +class _CupertinoMenuItemLabel extends StatelessWidget { + const _CupertinoMenuItemLabel({ + required this.child, + this.subtitle, + this.leading, + this.leadingWidth, + AlignmentGeometry? leadingMidpointAlignment, + this.trailing, + this.trailingWidth, + AlignmentGeometry? trailingMidpointAlignment, + BoxConstraints? constraints, + this.padding, + }) : _leadingAlignment = leadingMidpointAlignment, + _trailingAlignment = trailingMidpointAlignment, + _constraints = constraints; + + static const double _kDefaultHorizontalWidth = 16; + + // The leading and trailing widths scale roughly linearly with the normalized + // text scale once quantized to the nearest physical pixel. Each linear + // regression will return a value within 1 physical pixel of the observed + // value at each text scale factor. + // + // This behavior was measured on several iOS and iPadOS 18.5 simulators using + // the debug view. + static const double _kLeadingWidthSlope = -311 / 1000; + static const double _kLeadingWidthYIntercept = 10; + + static const double _kLeadingMidpointSlope = 118 / 1000000; + static const double _kLeadingMidpointYIntercept = 73 / 125; + + static const double _kTrailingWidthSlope = 1 / 10; + static const double _kTrailingWidthYIntercept = 22; + + static const double _kFirstBaselineToTopSlope = 14 / 11; + static const double _kLastBaselineToBottomSlope = 71 / 100; + + final Widget? leading; + final double? leadingWidth; + final AlignmentGeometry? _leadingAlignment; + + final Widget? trailing; + final double? trailingWidth; + final AlignmentGeometry? _trailingAlignment; + + final Widget child; + final Widget? subtitle; + final EdgeInsetsGeometry? padding; + final BoxConstraints? _constraints; + + double _resolveLeadingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) { + final double units = _normalizeTextScale(textScaler); + final double value = _kLeadingWidthSlope * units + _kLeadingWidthYIntercept; + return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio); + } + + double _resolveTrailingWidth(TextScaler textScaler, double pixelRatio, double lineHeight) { + final double units = _normalizeTextScale(textScaler); + final double value = _kTrailingWidthSlope * units + _kTrailingWidthYIntercept; + return _roundToDivisible(value + lineHeight, to: 1 / pixelRatio); + } + + AlignmentGeometry _resolveTrailingAlignment(double trailingWidth) { + final double horizontalOffset = trailingWidth / 2 + 6; + final double horizontalRatio = (trailingWidth - horizontalOffset) / trailingWidth; + final double horizontalAlignment = (horizontalRatio * 2) - 1; + return AlignmentDirectional(horizontalAlignment, 0.0); + } + + AlignmentGeometry _resolveLeadingAlignment(double leadingWidth, TextScaler textScaler) { + final double units = _normalizeTextScale(textScaler); + final double horizontalRatio = _kLeadingMidpointSlope * units + _kLeadingMidpointYIntercept; + final double horizontalAlignment = (horizontalRatio * 2) - 1; + return AlignmentDirectional(horizontalAlignment, 0.0); + } + + double _resolveFirstBaselineToTop(double lineHeight, double pixelRatio) { + return _roundToDivisible(lineHeight * _kFirstBaselineToTopSlope, to: 1 / pixelRatio); + } + + double _resolveLastBaselineToBottom(double lineHeight, double pixelRatio) { + return _roundToDivisible(lineHeight * _kLastBaselineToBottomSlope, to: 1 / pixelRatio); + } + + EdgeInsets _resolvePadding(double minimumHeight, double lineHeight) { + final double padding = math.max(0, minimumHeight - lineHeight); + return EdgeInsets.symmetric(vertical: padding / 2); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr; + final TextScaler textScaler = MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; + final double pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; + final TextStyle dynamicBodyText = _DynamicTypeStyle.body.resolveTextStyle(textScaler); + assert(dynamicBodyText.fontSize != null && dynamicBodyText.height != null); + final double lineHeight = dynamicBodyText.fontSize! * dynamicBodyText.height!; + final bool showLeadingWidget = + leading != null || (CupertinoMenuAnchor.maybeHasLeadingOf(context) ?? false); + + // TODO(davidhicks980): Use last baseline layout when supported. + // (https://github.com/flutter/flutter/issues/4614) + + // The actual menu item layout uses first and last baselines to position the + // text, but Flutter does not support last baseline alignment. + // + // To approximate the padding, subtract the default height of a single line + // of text from the height of a single-line menu item, and divide the result + // in half to get an estimated top and bottom padding. The downside to this + // approach is that child and subtitle text with different line heights may + // appear to have uneven padding. + final double minimumHeight = + _resolveFirstBaselineToTop(lineHeight, pixelRatio) + + _resolveLastBaselineToBottom(lineHeight, pixelRatio); + final BoxConstraints constraints = _constraints ?? BoxConstraints(minHeight: minimumHeight); + + final EdgeInsetsGeometry resolvedPadding = + padding ?? _resolvePadding(minimumHeight, lineHeight); + + final double resolvedLeadingWidth = + leadingWidth ?? + (showLeadingWidget + ? _resolveLeadingWidth(textScaler, pixelRatio, lineHeight) + : _kDefaultHorizontalWidth); + + final double resolvedTrailingWidth = + trailingWidth ?? + (trailing != null + ? _resolveTrailingWidth(textScaler, pixelRatio, lineHeight) + : _kDefaultHorizontalWidth); + + return ConstrainedBox( + constraints: constraints, + child: Padding( + padding: resolvedPadding, + child: Stack( + children: [ + if (showLeadingWidget) + Positioned.directional( + textDirection: textDirection, + start: 0, + top: 0, + bottom: 0, + width: resolvedLeadingWidth, + child: _AlignMidpoint( + alignment: + _leadingAlignment ?? + _resolveLeadingAlignment(resolvedLeadingWidth, textScaler), + child: leading, + ), + ), + Padding( + padding: EdgeInsetsDirectional.only( + start: resolvedLeadingWidth, + end: resolvedTrailingWidth, + ), + child: subtitle == null + ? Align(alignment: AlignmentDirectional.centerStart, child: child) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [child, const SizedBox(height: 1), subtitle!], + ), + ), + if (trailing != null) + // On iOS, the trailing widget is constrained to a maximum height + // of minimumHeight - 12 and a maximum width of + // resolvedTrailingWidth - 20. These constraints were omitted for + // more flexibility. + Positioned.directional( + textDirection: textDirection, + end: 0, + top: 0, + bottom: 0, + width: resolvedTrailingWidth, + child: _AlignMidpoint( + alignment: _trailingAlignment ?? _resolveTrailingAlignment(resolvedTrailingWidth), + child: trailing, + ), + ), + ], + ), + ), + ); + } +} + +/// A widget that positions the midpoint of its child at an alignment within +/// itself. +/// +/// Almost identical to [Align], but aligns the midpoint of the child rather +/// than the top-left corner. +/// +/// This layout behavior was observed on the iOS 18.5 simulator +/// (https://developer.apple.com/documentation/uikit/uiview/centerxanchor) +class _AlignMidpoint extends SingleChildRenderObjectWidget { + /// Creates a widget that positions its child's center point at a specific + /// [alignment]. + /// + /// The [alignment] parameter is required and must not + /// be null. + const _AlignMidpoint({required this.alignment, required super.child}); + + /// The alignment for positioning the child's horizontal midpoint. + final AlignmentGeometry alignment; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderAlignMidpoint( + alignment: alignment, + textDirection: Directionality.maybeOf(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderAlignMidpoint renderObject) { + renderObject + ..alignment = alignment + ..textDirection = Directionality.maybeOf(context); + } +} + +class _RenderAlignMidpoint extends RenderPositionedBox { + _RenderAlignMidpoint({super.alignment, super.textDirection}); + + @override + void alignChild() { + assert(child != null); + assert(!child!.debugNeedsLayout); + assert(child!.hasSize); + assert(hasSize); + final childParentData = child!.parentData! as BoxParentData; + final ui.Offset offset = resolvedAlignment.alongSize(size) - child!.size.center(Offset.zero); + final double dx = ui.clampDouble(offset.dx, 0.0, size.width - child!.size.width); + final double dy = ui.clampDouble(offset.dy, 0.0, size.height - child!.size.height); + + childParentData.offset = Offset(dx, dy); + } +} + +class _CupertinoMenuItemInteractionHandler extends StatefulWidget { + const _CupertinoMenuItemInteractionHandler({ + required this.onHover, + required this.onPressed, + required this.onFocusChange, + required this.focusNode, + required this.autofocus, + required this.requestFocusOnHover, + required this.behavior, + required this.mouseCursor, + required this.decoration, + required this.child, + }); + + final ValueChanged? onHover; + final VoidCallback? onPressed; + final ValueChanged? onFocusChange; + final FocusNode? focusNode; + final bool autofocus; + final bool requestFocusOnHover; + final HitTestBehavior behavior; + final WidgetStateProperty mouseCursor; + final WidgetStateProperty decoration; + final Widget child; + + @override + State<_CupertinoMenuItemInteractionHandler> createState() => + _CupertinoMenuItemInteractionHandlerState(); +} + +class _CupertinoMenuItemInteractionHandlerState + extends State<_CupertinoMenuItemInteractionHandler> { + late final Map> _actions = >{ + ActivateIntent: CallbackAction(onInvoke: _handleActivation), + ButtonActivateIntent: CallbackAction(onInvoke: _handleActivation), + }; + Map? _gestures; + DeviceGestureSettings? _gestureSettings; + + // If a focus node isn't given to the widget, then we have to manage our own. + FocusNode? _internalFocusNode; + FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!; + final WidgetStatesController _statesController = WidgetStatesController(); + + bool get isHovered => _statesController.value.contains(WidgetState.hovered); + set isHovered(bool value) { + _statesController.update(WidgetState.hovered, value); + } + + bool get isPressed => _statesController.value.contains(WidgetState.pressed); + set isPressed(bool value) { + _statesController.update(WidgetState.pressed, value); + } + + bool get isSwiped => _statesController.value.contains(WidgetState.dragged); + set isSwiped(bool value) { + _statesController.update(WidgetState.dragged, value); + } + + bool get isFocused => _statesController.value.contains(WidgetState.focused); + set isFocused(bool value) { + _statesController.update(WidgetState.focused, value); + } + + bool get isEnabled => !_statesController.value.contains(WidgetState.disabled); + set isEnabled(bool value) { + _statesController.update(WidgetState.disabled, !value); + } + + @override + void initState() { + super.initState(); + if (widget.focusNode == null) { + _internalFocusNode = FocusNode(); + } + + isEnabled = widget.onPressed != null; + isFocused = _focusNode.hasPrimaryFocus; + } + + @override + void didUpdateWidget(_CupertinoMenuItemInteractionHandler oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + if (widget.focusNode != null) { + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } else { + assert(_internalFocusNode == null); + _internalFocusNode = FocusNode(); + } + + isFocused = _focusNode.hasPrimaryFocus; + } + + if (widget.onPressed != oldWidget.onPressed) { + if (widget.onPressed == null) { + isEnabled = isHovered = isPressed = isSwiped = isFocused = false; + } else { + isEnabled = true; + } + } + } + + @override + void dispose() { + _statesController.dispose(); + _internalFocusNode?.dispose(); + _internalFocusNode = null; + super.dispose(); + } + + void _handleFocusChange([bool? focused]) { + isFocused = _focusNode.hasPrimaryFocus; + widget.onFocusChange?.call(isFocused); + } + + void _handleActivation([Intent? intent]) { + isSwiped = isPressed = false; + widget.onPressed?.call(); + } + + void _handleTapDown(TapDownDetails details) { + isPressed = true; + } + + void _handleTapUp(TapUpDetails? details) { + isPressed = false; + widget.onPressed?.call(); + } + + void _handleTapCancel() { + isPressed = false; + } + + void _handlePointerExit(PointerExitEvent event) { + if (isHovered) { + isHovered = isFocused = false; + widget.onHover?.call(false); + } + } + + // TextButton.onHover and MouseRegion.onHover can't be used without triggering + // focus on scroll. + void _handlePointerHover(PointerHoverEvent event) { + if (!isHovered) { + isHovered = true; + widget.onHover?.call(true); + if (widget.requestFocusOnHover) { + _focusNode.requestFocus(); + + // Without invalidating the focus policy, switching to directional focus + // may not originate at this node. + FocusTraversalGroup.of(context).invalidateScopeData(FocusScope.of(context)); + } + } + } + + void _handleDismissMenu() { + Actions.invoke(context, const DismissIntent()); + } + + void _handleSwipeEnter() { + if (!isEnabled) { + return; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + HapticFeedback.selectionClick(); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + break; + } + + isSwiped = true; + } + + void _handleSwipeExit() { + if (mounted) { + isSwiped = false; + } + } + + void _handleSwipeCompleted() { + if (mounted && isEnabled) { + _handleActivation(); + } + } + + Widget _buildStatefulAppearance(BuildContext context, Set value, Widget? child) { + final MouseCursor cursor = widget.mouseCursor.resolve(value); + final BoxDecoration decoration = widget.decoration.resolve(value); + final bool hasBackground = decoration.color != null || decoration.gradient != null; + return MouseRegion( + onHover: isEnabled ? _handlePointerHover : null, + onExit: isEnabled ? _handlePointerExit : null, + hitTestBehavior: HitTestBehavior.deferToChild, + cursor: cursor, + child: DecoratedBox( + decoration: decoration.copyWith( + color: CupertinoDynamicColor.maybeResolve(decoration.color, context), + backgroundBlendMode: kIsWeb || !hasBackground || decoration.backgroundBlendMode != null + ? decoration.backgroundBlendMode + : CupertinoTheme.maybeBrightnessOf(context) == Brightness.light + ? BlendMode.multiply + : BlendMode.plus, + ), + child: child, + ), + ); + } + + @override + Widget build(BuildContext context) { + final DeviceGestureSettings? newGestureSettings = MediaQuery.maybeGestureSettingsOf(context); + if (_gestureSettings != newGestureSettings) { + _gestureSettings = newGestureSettings; + _gestures = null; + } + + _gestures ??= { + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onTapCancel = _handleTapCancel + ..gestureSettings = _gestureSettings; + }, + ), + }; + + return MergeSemantics( + child: Semantics.fromProperties( + properties: SemanticsProperties( + enabled: isEnabled, + onDismiss: isEnabled ? _handleDismissMenu : null, + ), + child: Actions( + actions: isEnabled ? _actions : >{}, + child: Focus( + autofocus: isEnabled && widget.autofocus, + focusNode: _focusNode, + canRequestFocus: isEnabled, + skipTraversal: !isEnabled, + onFocusChange: _handleFocusChange, + child: _SwipeTarget( + onEnter: _handleSwipeEnter, + onExit: _handleSwipeExit, + onCompletion: _handleSwipeCompleted, + child: ValueListenableBuilder>( + valueListenable: _statesController, + builder: _buildStatefulAppearance, + child: RawGestureDetector( + behavior: widget.behavior, + gestures: isEnabled ? _gestures! : const {}, + child: widget.child, + ), + ), + ), + ), + ), + ), + ); + } +} + +/// A widget that triggers callbacks when a pointer enters or leaves while down. +/// +/// An ancestor [_SwipeRegion] must be present in order for callbacks to be triggered. +class _SwipeTarget extends StatelessWidget { + const _SwipeTarget({ + required this.onEnter, + required this.onExit, + required this.onCompletion, + required this.child, + }); + + /// A pointer has entered this region while down. + /// + /// This includes: + /// + /// * The pointer has moved into this region from outside. + /// * The point has contacted the screen in this region. In this case, this + /// method is called as soon as the pointer down event occurs regardless of + /// whether the gesture wins the arena immediately. + final VoidCallback? onEnter; + + /// A pointer has exited this region while down. + /// + /// This includes: + /// * The pointer has moved out of this region. + /// * The pointer is no longer in contact with the screen. + /// * The pointer is canceled. + /// * The gesture loses the arena. + /// * The gesture ends. In this case, this method is called immediately + /// before [onCompletion]. + final VoidCallback? onExit; + + /// The drag gesture completed in this region. + /// + /// This method is called immediately after [onExit]. + final VoidCallback? onCompletion; + + /// The widget below this widget in the tree. + final Widget child; + + /// Whether this target stops underlying widgets from being swiped. + /// + /// When true, targets that are obscured by this widget will not receive + /// swipe enter or exit events. When false, swipe events will continue + /// to propagate to targets behind this one. + bool get isOpaque => true; + + @override + Widget build(BuildContext context) { + return MetaData(metaData: this, child: child); + } +} + +class _SwipeScope extends InheritedWidget { + const _SwipeScope({required super.child, required this.state}); + final _SwipeRegionState state; + + @override + bool updateShouldNotify(_SwipeScope oldWidget) { + return state != oldWidget.state; + } +} + +class _SwipeRegion extends StatefulWidget { + const _SwipeRegion({this.enabled = true, required this.onDistanceChanged, required this.child}); + final bool enabled; + final ValueChanged onDistanceChanged; + final Widget child; + + static _SwipeRegionState? of(BuildContext context) { + final _SwipeScope? scope = context.dependOnInheritedWidgetOfExactType<_SwipeScope>(); + return scope?.state; + } + + @override + State<_SwipeRegion> createState() => _SwipeRegionState(); +} + +class _SwipeRegionState extends State<_SwipeRegion> { + final Set<_RenderSwipeSurface> _surfaces = <_RenderSwipeSurface>{}; + MultiDragGestureRecognizer? _recognizer; + bool get isSwiping => _position != null; + ui.Offset? _position; + + @override + void didUpdateWidget(_SwipeRegion oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.enabled != oldWidget.enabled) { + if (!widget.enabled) { + _recognizer?.dispose(); + _recognizer = null; + _position = null; + widget.onDistanceChanged(0); + } + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _recognizer?.gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + } + + @override + void dispose() { + assert(_surfaces.isEmpty); + _recognizer?.dispose(); + _recognizer = null; + super.dispose(); + } + + void attachSurface(_RenderSwipeSurface surface) { + _surfaces.add(surface); + } + + void detachSurface(_RenderSwipeSurface surface) { + _surfaces.remove(surface); + } + + void beginSwipe(PointerDownEvent event, {Duration delay = Duration.zero, VoidCallback? onStart}) { + if (isSwiping || !widget.enabled) { + return; + } + + _recognizer?.dispose(); + _recognizer = null; + + Drag handleStart(Offset position) { + onStart?.call(); + return _createSwipeHandle(position); + } + + // Use a MultiDragGestureRecognizer instead of a PanGestureRecognizer + // since the latter does not support delayed recognition. + if (delay == Duration.zero) { + _recognizer = ImmediateMultiDragGestureRecognizer( + allowedButtonsFilter: (int button) => button == kPrimaryButton, + )..onStart = handleStart; + } else { + _recognizer = DelayedMultiDragGestureRecognizer( + delay: delay, + allowedButtonsFilter: (int button) => button == kPrimaryButton, + )..onStart = handleStart; + } + + _recognizer!.gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + _recognizer!.addPointer(event); + } + + Drag _createSwipeHandle(ui.Offset position) { + assert(!isSwiping, 'A new swipe should not begin while a swipe is active.'); + _position = position; + return _SwipeHandle( + viewId: View.of(context).viewId, + initialPosition: position, + onSwipeUpdate: _handleSwipeUpdate, + onSwipeEnd: _handleSwipeEnd, + onSwipeCanceled: _handleSwipeCancel, + ); + } + + void _handleSwipeUpdate(DragUpdateDetails updateDetails) { + _position = _position! + updateDetails.delta; + + // We can't used expandToInclude() because the total menu area may not be + // rectangular. + double minimumSquaredDistance = double.maxFinite; + for (final _RenderSwipeSurface surface in _surfaces) { + final double squaredDistance = _computeSquaredDistanceToRect( + _position!, + surface.computeRect(), + ); + + if (squaredDistance.floor() == 0) { + widget.onDistanceChanged(0); + return; + } + + minimumSquaredDistance = math.min(squaredDistance, minimumSquaredDistance); + } + + final double distance = minimumSquaredDistance == 0 ? 0 : math.sqrt(minimumSquaredDistance); + widget.onDistanceChanged(distance); + } + + void _handleSwipeEnd(DragEndDetails updateDetails) { + _completeSwipe(); + } + + void _handleSwipeCancel() { + _completeSwipe(); + } + + void _completeSwipe() { + _recognizer?.dispose(); + _recognizer = null; + _position = null; + if (mounted) { + widget.onDistanceChanged(0); + } + } + + @override + Widget build(BuildContext context) { + return _SwipeScope(state: this, child: widget.child); + } +} + +/// An area that can initiate swiping. +/// +/// This widget registers with the nearest [_SwipeRegion] and exposes its position +/// as a [ui.Rect]. This [_SwipeSurface] will route [PointerDownEvent]s to its +/// [_SwipeRegion]. If a routed [PointerDownEvent] results in a swipe gesture, the +/// [_SwipeRegion] will use the combined [ui.Rect] of all registered [_SwipeSurface]s +/// to calculate the swiping distance. +class _SwipeSurface extends SingleChildRenderObjectWidget { + /// Creates a swipe surface that registers with a parent [_SwipeRegion]. + const _SwipeSurface({required super.child, this.delay = Duration.zero, this.onStart}); + + /// The delay before recognizing a swipe gesture. + final Duration delay; + final VoidCallback? onStart; + + @override + _RenderSwipeSurface createRenderObject(BuildContext context) { + return _RenderSwipeSurface(region: _SwipeRegion.of(context)!, delay: delay, onStart: onStart); + } + + @override + void updateRenderObject(BuildContext context, _RenderSwipeSurface renderObject) { + renderObject + ..region = _SwipeRegion.of(context)! + ..delay = delay + ..onStart = onStart; + } +} + +class _RenderSwipeSurface extends RenderProxyBoxWithHitTestBehavior { + _RenderSwipeSurface({ + required _SwipeRegionState region, + required this.delay, + required this.onStart, + }) : _region = region, + super(behavior: HitTestBehavior.opaque) { + _region.attachSurface(this); + } + + _SwipeRegionState get region => _region; + _SwipeRegionState _region; + set region(_SwipeRegionState value) { + if (_region != value) { + _region.detachSurface(this); + _region = value; + _region.attachSurface(this); + } + } + + Duration delay; + VoidCallback? onStart; + + ui.Rect computeRect() => localToGlobal(Offset.zero) & size; + + @override + void detach() { + _region.detachSurface(this); + super.detach(); + } + + @override + void dispose() { + _region.detachSurface(this); + super.dispose(); + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent) { + _region.beginSwipe(event, delay: delay, onStart: onStart); + } + } +} + +/// Handles swiping events for a [_SwipeRegion]. +class _SwipeHandle extends Drag { + /// Creates a [_SwipeHandle] that handles swiping events for a [_SwipeRegion]. + _SwipeHandle({ + required Offset initialPosition, + required this.viewId, + required this.onSwipeEnd, + required this.onSwipeUpdate, + required this.onSwipeCanceled, + }) : _position = initialPosition { + _updateSwipe(); + } + + final int viewId; + final List<_SwipeTarget> _enteredTargets = <_SwipeTarget>[]; + final GestureDragUpdateCallback onSwipeUpdate; + final GestureDragEndCallback onSwipeEnd; + final GestureDragCancelCallback onSwipeCanceled; + Offset _position; + + @override + void update(DragUpdateDetails details) { + final Offset oldPosition = _position; + _position += details.delta; + if (_position != oldPosition) { + _updateSwipe(); + onSwipeUpdate.call(details); + } + } + + @override + void end(DragEndDetails details) { + _leaveAllEntered(pointerUp: true); + onSwipeEnd.call(details); + } + + @override + void cancel() { + _leaveAllEntered(); + onSwipeCanceled(); + } + + void _updateSwipe() { + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, _position, viewId); + // Look for the RenderBoxes that corresponds to the hit target + final targets = <_SwipeTarget>[]; + for (final HitTestEntry entry in result.path) { + if (entry.target case RenderMetaData(:final _SwipeTarget metaData)) { + targets.add(metaData); + } + } + + _enteredTargets.removeWhere((target) { + if (!targets.contains(target)) { + target.onExit?.call(); + return true; + } + return false; + }); + + final hitTargets = <_SwipeTarget>{}; + final newlyEnteredTargets = <_SwipeTarget>[]; + var hitExistingTarget = false; + for (final target in targets) { + if (_enteredTargets.contains(target)) { + hitTargets.add(target); + hitExistingTarget = true; + continue; + } + + if (!hitExistingTarget) { + hitTargets.add(target); + newlyEnteredTargets.add(target); + } + + if (target.isOpaque) { + break; + } + } + + // Leave old targets. + // + // Disjoint siblings (1 -> 2) were removed above to preserve the expected + // "Leave 1" -> "Enter 2" order. For nested items (1 -> 1.1 -> 1.1.1), + // entering a nested item (1.1) that obscures a parent item (1) will result + // in "Leave 1" -> "Enter 1.1". Leaving the nested item will behave in the + // opposite order: "Leave 1.1" -> "Enter 1". + for (final _SwipeTarget target in _enteredTargets.reversed) { + if (!hitTargets.contains(target)) { + target.onExit?.call(); + } + } + + for (final _SwipeTarget target in newlyEnteredTargets.reversed) { + target.onEnter?.call(); + } + + _enteredTargets + ..clear() + ..addAll(hitTargets); + } + + void _leaveAllEntered({bool pointerUp = false}) { + for (var i = 0; i < _enteredTargets.length; i += 1) { + final _SwipeTarget target = _enteredTargets[i]; + target.onExit?.call(); + if (pointerUp) { + target.onCompletion?.call(); + } + } + _enteredTargets.clear(); + } +} + +// Multiplies the values of two animations. +// +// This class is used to animate the scale of the menu when the user drags +// outside of the menu area. +class _AnimationProduct extends CompoundAnimation { + _AnimationProduct({required super.first, required super.next}); + + @override + double get value => super.first.value * super.next.value; +} + +class _ClampTween extends Animatable { + const _ClampTween({required this.begin, required this.end}); + final double begin; + final double end; + + @override + double transform(double t) { + if (t < begin) { + return begin; + } + + if (t > end) { + return end; + } + + return t; + } +} diff --git a/packages/cupertino_ui/lib/src/nav_bar.dart b/packages/cupertino_ui/lib/src/nav_bar.dart new file mode 100644 index 000000000000..611e84caa075 --- /dev/null +++ b/packages/cupertino_ui/lib/src/nav_bar.dart @@ -0,0 +1,3548 @@ +// 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. + +/// @docImport 'refresh.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'icons.dart'; +import 'localizations.dart'; +import 'page_scaffold.dart'; +import 'route.dart'; +import 'search_field.dart'; +import 'sheet.dart'; +import 'theme.dart'; + +/// Modes that determine how to display the navigation bar's bottom in relation to scroll events. +enum NavigationBarBottomMode { + /// Enable hiding the bottom in response to scrolling. + /// + /// As scrolling starts, the large title stays pinned while the bottom resizes + /// until it is completely consumed. Then, the large title scrolls under the + /// persistent navigation bar. + automatic, + + /// Always display the bottom regardless of the scroll activity. + /// + /// When scrolled, the bottom stays pinned while the large title scrolls under + /// the persistent navigation bar. + always, +} + +/// Standard iOS navigation bar height without the status bar. +/// +/// This height is constant and independent of accessibility as it is in iOS. +const double _kNavBarPersistentHeight = kMinInteractiveDimensionCupertino; + +/// Size increase from expanding the navigation bar into an iOS-11-style large title +/// configuration in a [CustomScrollView]. +const double _kNavBarLargeTitleHeightExtension = 52.0; + +/// Number of logical pixels scrolled down before the title text is transferred +/// from the normal navigation bar to a big title below the navigation bar. +const double _kNavBarShowLargeTitleThreshold = 10.0; + +/// Number of logical pixels scrolled during which the navigation bar's background +/// fades in or out. +/// +/// Eyeballed on the native Settings app on an iPhone 15 simulator running iOS 17.4. +const double _kNavBarScrollUnderAnimationExtent = 10.0; + +const double _kNavBarEdgePadding = 16.0; + +const double _kNavBarBottomPadding = 8.0; + +const double _kNavBarBackButtonTapWidth = 50.0; + +// The minimum text scale to apply to contents of the nav bar which can scale to +// a size less than the default, such as the large title. +// +// Eyeballed on an iPhone 15 simulator running iOS 17.5. +const double _kMinScaleFactor = 0.9; + +// The maximum text scale to apply to contents of the nav bar, except the large +// title which can grow larger but is damped. +// +// Calculated on an iPhone 15 simulator running iOS 17.5. +const double _kMaxScaleFactor = 1.235; + +// The damping ratio applied to reduce the rate at which the large title scales. +// +// Eyeballed on an iPhone 15 simulator running iOS 17.5. +const double _kLargeTitleScaleDampingRatio = 3.0; + +/// The width of the 'Cancel' button if the search field in a +/// [CupertinoSliverNavigationBar.search] is active. +/// +/// Eyeballed on an iPhone 15 simulator running iOS 17.5. +const double _kSearchFieldCancelButtonWidth = 67.0; + +/// The height of the unscaled search field used in +/// a [CupertinoSliverNavigationBar.search]. +const double _kSearchFieldHeight = 36.0; + +/// The duration of the animation when the search field in +/// [CupertinoSliverNavigationBar.search] is tapped. +const Duration _kNavBarSearchDuration = Duration(milliseconds: 300); + +/// The curve of the animation when the search field in +/// [CupertinoSliverNavigationBar.search] is tapped. +const Curve _kNavBarSearchCurve = Curves.easeInOut; + +/// Title text transfer fade. +const Duration _kNavBarTitleFadeDuration = Duration(milliseconds: 150); + +const Color _kDefaultNavBarBorderColor = Color(0x4D000000); + +const Border _kDefaultNavBarBorder = Border( + bottom: BorderSide( + color: _kDefaultNavBarBorderColor, + width: 0.0, // 0.0 means one physical pixel + ), +); + +const Border _kTransparentNavBarBorder = Border( + bottom: BorderSide(color: Color(0x00000000), width: 0.0), +); + +/// The curve of the animation of the top nav bar regardless of push/pop +/// direction in the hero transition between two nav bars. +/// +/// Eyeballed on an iPhone 15 Pro simulator running iOS 17.5. +const Curve _kTopNavBarHeaderTransitionCurve = Cubic(0.0, 0.45, 0.45, 0.98); + +/// The curve of the animation of the bottom nav bar regardless of push/pop +/// direction in the hero transition between two nav bars. +/// +/// Eyeballed on an iPhone 15 Pro simulator running iOS 17.5. +const Curve _kBottomNavBarHeaderTransitionCurve = Cubic(0.05, 0.90, 0.90, 0.95); + +// There's a single tag for all instances of navigation bars because they can +// all transition between each other (per Navigator) via Hero transitions. +const _HeroTag _defaultHeroTag = _HeroTag(null); + +@immutable +class _HeroTag { + const _HeroTag(this.navigator); + + final NavigatorState? navigator; + + // Let the Hero tag be described in tree dumps. + @override + String toString() => 'Default Hero tag for Cupertino navigation bars with navigator $navigator'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is _HeroTag && other.navigator == navigator; + } + + @override + int get hashCode => identityHashCode(navigator); +} + +// An `AnimatedWidget` that imposes a fixed size on its child widget, and +// shifts the child widget in the parent stack, driven by its `offsetAnimation` +// property. +class _FixedSizeSlidingTransition extends AnimatedWidget { + const _FixedSizeSlidingTransition({ + required this.isLTR, + required this.offsetAnimation, + required this.width, + required this.height, + required this.child, + }) : super(listenable: offsetAnimation); + + // Whether the writing direction used in the navigation bar transition is + // left-to-right. + final bool isLTR; + + // The fixed width to impose on `child`. + final double width; + + // The fixed height to impose on `child`. + final double height; + + // The animated offset from the top-leading corner of the stack. + // + // When `isLTR` is true, the `Offset` is the position of the child widget in + // the stack render box's regular coordinate space. + // + // When `isLTR` is false, the coordinate system is flipped around the + // horizontal axis and the origin is set to the top right corner of the render + // boxes. In other words, this parameter describes the offset from the top + // right corner of the stack, to the top right corner of the child widget, and + // the x-axis runs right to left. + final Animation offsetAnimation; + + final Widget child; + + @override + Widget build(BuildContext context) { + return Positioned( + top: offsetAnimation.value.dy, + left: isLTR ? offsetAnimation.value.dx : null, + right: isLTR ? null : offsetAnimation.value.dx, + width: width, + height: height, + child: child, + ); + } +} + +/// Returns `child` wrapped with background and a bottom border if background color +/// is opaque. Otherwise, also blur with [BackdropFilter]. +/// +/// When `updateSystemUiOverlay` is true, the nav bar will update the OS +/// status bar's color theme based on the background color of the nav bar. +Widget _wrapWithBackground({ + Border? border, + required Color backgroundColor, + Brightness? brightness, + required Widget child, + bool updateSystemUiOverlay = true, + bool enableBackgroundFilterBlur = true, +}) { + var result = child; + if (updateSystemUiOverlay) { + final bool isDark = backgroundColor.computeLuminance() < 0.179; + final Brightness newBrightness = brightness ?? (isDark ? Brightness.dark : Brightness.light); + final SystemUiOverlayStyle overlayStyle = switch (newBrightness) { + Brightness.dark => SystemUiOverlayStyle.light, + Brightness.light => SystemUiOverlayStyle.dark, + }; + // [SystemUiOverlayStyle.light] and [SystemUiOverlayStyle.dark] set some system + // navigation bar properties, + // Before https://github.com/flutter/flutter/pull/104827 those properties + // had no effect, now they are used if there is no AnnotatedRegion on the + // bottom of the screen. + // For backward compatibility, create a `SystemUiOverlayStyle` without the + // system navigation bar properties. + result = AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: overlayStyle.statusBarColor, + statusBarBrightness: overlayStyle.statusBarBrightness, + statusBarIconBrightness: overlayStyle.statusBarIconBrightness, + systemStatusBarContrastEnforced: overlayStyle.systemStatusBarContrastEnforced, + ), + child: result, + ); + } + final childWithBackground = DecoratedBox( + decoration: BoxDecoration(border: border, color: backgroundColor), + child: result, + ); + + return ClipRect( + child: BackdropFilter( + enabled: backgroundColor.alpha != 0xFF && enableBackgroundFilterBlur, + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: childWithBackground, + ), + ); +} + +double _dampScaleFactor(double scaledFontSize, double unscaledFontSize, double dampingRatio) { + final double scaleFactor = scaledFontSize / unscaledFontSize; + return scaleFactor < 1.0 + ? math.max(_kMinScaleFactor, scaleFactor) + : 1.0 + ((scaleFactor - 1.0) / dampingRatio); +} + +// Whether the current route supports nav bar hero transitions from or to. +bool _isTransitionable(BuildContext context) { + final ModalRoute? route = ModalRoute.of(context); + + // Fullscreen dialogs never transitions their nav bar with other push-style + // pages' nav bars or with other fullscreen dialog pages on the way in or on + // the way out. + return route is PageRoute && + !route.fullscreenDialog && + !CupertinoSheetRoute.hasParentSheet(context); +} + +/// An iOS-styled navigation bar. +/// +/// The navigation bar is a toolbar that minimally consists of a widget, +/// normally a page title. +/// +/// It also supports [leading] and [trailing] widgets on either end of the +/// toolbar, typically for actions and navigation. +/// +/// The [leading] widget will automatically be a back chevron icon button (or a +/// cancel button in case of a fullscreen dialog) to pop the current route if none +/// is provided and [automaticallyImplyLeading] is true (true by default). +/// +/// This toolbar should be placed at top of the screen where it will +/// automatically account for the OS's status bar. +/// +/// If the given [backgroundColor]'s opacity is not 1.0 (which is the case by +/// default), it will produce a blurring effect to the content behind it. +/// +/// ### Layout options +/// +/// While the [CupertinoSliverNavigationBar] can dynamically change size and +/// layout in response to scrolling, this static version can reflect the same +/// large (expanded) layout, or the small (collapsed) layout. +/// +/// The default constructor will display the collapsed version of the +/// [CupertinoSliverNavigationBar]. The [middle] widget will automatically be a +/// title text from the current [CupertinoPageRoute] if none is provided and +/// [automaticallyImplyMiddle] is true (true by default). +/// +/// Using the [CupertinoNavigationBar.large] constructor will display the +/// expanded version of [CupertinoSliverNavigationBar]. The [largeTitle] widget +/// will automatically be a title text from the current [CupertinoPageRoute] if +/// none is provided and `automaticallyImplyTitle` is true (true by default). +/// +/// ### Transitions +/// +/// When [transitionBetweenRoutes] is true, this navigation bar will transition +/// on top of the routes instead of inside them if the route being transitioned +/// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar] +/// with [transitionBetweenRoutes] set to true. If [transitionBetweenRoutes] is +/// true, none of the [Widget] parameters can contain a key in its subtree since +/// that widget will exist in multiple places in the tree simultaneously. +/// +/// By default, only one [CupertinoNavigationBar] or [CupertinoSliverNavigationBar] +/// should be present in each [PageRoute] to support the default transitions. +/// Use [transitionBetweenRoutes] or [heroTag] to customize the transition +/// behavior for multiple navigation bars per route. +/// +/// When used in a [CupertinoPageScaffold], [CupertinoPageScaffold.navigationBar] +/// disables text scaling to match the native iOS behavior. To override +/// this behavior, wrap each of the `navigationBar`'s components inside a +/// [MediaQuery] with the desired [TextScaler]. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoNavigationBar] placed in a [CupertinoPageScaffold]. +/// Since [backgroundColor]'s opacity is not 1.0, there is a blur effect and +/// content slides underneath. +/// +/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows the resulting layout from [CupertinoNavigationBar.large] +/// constructor, showing a large title similar to the expanded state of +/// [CupertinoSliverNavigationBar]. +/// +/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoPageScaffold], a page layout helper typically hosting the +/// [CupertinoNavigationBar]. +/// * [CupertinoSliverNavigationBar] for a navigation bar to be placed in a +/// scrolling list and that supports iOS-11-style large titles. +/// * +class CupertinoNavigationBar extends StatefulWidget implements ObstructingPreferredSizeWidget { + /// Creates a static iOS style navigation bar, with a centered [middle] title. + /// + /// Similar to the collapsed state of [CupertinoSliverNavigationBar], which + /// can dynamically change size in response to scrolling. + /// + /// See also: + /// + /// * [CupertinoNavigationBar.large], which creates a static iOS style + /// navigation bar with a [largeTitle], similar to the expanded state of + /// [CupertinoSliverNavigationBar]. + const CupertinoNavigationBar({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.automaticallyImplyMiddle = true, + this.previousPageTitle, + this.middle, + this.trailing, + this.border = _kDefaultNavBarBorder, + this.backgroundColor, + this.automaticBackgroundVisibility = true, + this.enableBackgroundFilterBlur = true, + this.brightness, + this.padding, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, + this.bottom, + }) : largeTitle = null, + assert( + !transitionBetweenRoutes || identical(heroTag, _defaultHeroTag), + 'Cannot specify a heroTag override if this navigation bar does not ' + 'transition due to transitionBetweenRoutes = false.', + ); + + /// Creates a static iOS style navigation bar, with a left aligned [largeTitle]. + /// + /// Similar to the expanded state of [CupertinoSliverNavigationBar], which + /// can dynamically change size in response to scrolling. + /// + /// See also: + /// + /// * [CupertinoNavigationBar]'s base constructor, which creates a static + /// iOS style navigation bar with [middle], similar to the collapsed state + /// of [CupertinoSliverNavigationBar]. + const CupertinoNavigationBar.large({ + super.key, + this.largeTitle, + this.leading, + this.automaticallyImplyLeading = true, + bool automaticallyImplyTitle = true, + this.previousPageTitle, + this.trailing, + this.border = _kDefaultNavBarBorder, + this.backgroundColor, + this.automaticBackgroundVisibility = true, + this.enableBackgroundFilterBlur = true, + this.brightness, + this.padding, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, + this.bottom, + }) : middle = null, + automaticallyImplyMiddle = automaticallyImplyTitle, + assert( + !transitionBetweenRoutes || identical(heroTag, _defaultHeroTag), + 'Cannot specify a heroTag override if this navigation bar does not ' + 'transition due to transitionBetweenRoutes = false.', + ); + + /// The navigation bar's title, when using [CupertinoNavigationBar.large]. + /// + /// If null and `automaticallyImplyTitle` is true, an appropriate [Text] + /// title will be created if the current route is a [CupertinoPageRoute] and + /// has a `title`. + /// + /// This property is null for the base [CupertinoNavigationBar] constructor, + /// which shows a collapsed navigation bar and uses [middle] for the title + /// instead. + /// + /// See also: + /// + /// * [CupertinoSliverNavigationBar.largeTitle], a similar property + /// in the expanded state of [CupertinoSliverNavigationBar], which can + /// dynamically change size in response to scrolling. + final Widget? largeTitle; + + /// {@template flutter.cupertino.CupertinoNavigationBar.leading} + /// Widget to place at the start of the navigation bar. Normally a back button + /// for a normal page or a cancel button for full page dialogs. + /// + /// If null and [automaticallyImplyLeading] is true, an appropriate button + /// will be automatically created. + /// {@endtemplate} + final Widget? leading; + + /// {@template flutter.cupertino.CupertinoNavigationBar.automaticallyImplyLeading} + /// Controls whether we should try to imply the leading widget if null. + /// + /// If true and [leading] is null, automatically try to deduce what the [leading] + /// widget should be. If [leading] widget is not null, this parameter has no effect. + /// + /// Specifically this navigation bar will: + /// + /// 1. Show a 'Cancel' button if the current route is a `fullscreenDialog`. + /// 2. Show a back chevron with [previousPageTitle] if [previousPageTitle] is + /// not null. + /// 3. Show a back chevron with the previous route's `title` if the current + /// route is a [CupertinoPageRoute] and the previous route is also a + /// [CupertinoPageRoute]. + /// {@endtemplate} + final bool automaticallyImplyLeading; + + /// Controls whether we should try to imply the middle widget if null. + /// + /// If true and [middle] is null, automatically fill in a [Text] widget with + /// the current route's `title` if the route is a [CupertinoPageRoute]. + /// If [middle] widget is not null, this parameter has no effect. + final bool automaticallyImplyMiddle; + + /// {@template flutter.cupertino.CupertinoNavigationBar.previousPageTitle} + /// Manually specify the previous route's title when automatically implying + /// the leading back button. + /// + /// Overrides the text shown with the back chevron instead of automatically + /// showing the previous [CupertinoPageRoute]'s `title` when + /// [automaticallyImplyLeading] is true. + /// + /// Has no effect when [leading] is not null or if [automaticallyImplyLeading] + /// is false. + /// {@endtemplate} + final String? previousPageTitle; + + /// The navigation bar's default title. + /// + /// If null and [automaticallyImplyMiddle] is true, an appropriate [Text] + /// title will be created if the current route is a [CupertinoPageRoute] and + /// has a `title`. + /// + /// This property is null for the [CupertinoNavigationBar.large] constructor, + /// which shows an expanded navigation bar and uses [largeTitle] instead. + /// + /// See also: + /// + /// * [CupertinoSliverNavigationBar.middle], a similar property + /// in the collapsed state of [CupertinoSliverNavigationBar], which can + /// dynamically change size in response to scrolling. + final Widget? middle; + + /// {@template flutter.cupertino.CupertinoNavigationBar.trailing} + /// Widget to place at the end of the navigation bar. Normally additional actions + /// taken on the page such as a search or edit function. + /// {@endtemplate} + final Widget? trailing; + + /// {@template flutter.cupertino.CupertinoNavigationBar.backgroundColor} + /// The background color of the navigation bar. If it contains transparency, the + /// tab bar will automatically produce a blurring effect to the content + /// behind it. This behavior can be disabled by setting [enableBackgroundFilterBlur] + /// to false. + /// + /// By default, the navigation bar's background is visible only when scrolled under. + /// This behavior can be controlled with [automaticBackgroundVisibility]. + /// + /// Defaults to [CupertinoTheme]'s `barBackgroundColor` if null. + /// {@endtemplate} + final Color? backgroundColor; + + /// {@template flutter.cupertino.CupertinoNavigationBar.automaticBackgroundVisibility} + /// Whether the navigation bar appears transparent when no content is scrolled under. + /// + /// If this is true, the navigation bar's background color will be transparent + /// until the content scrolls under it. If false, the navigation bar will always + /// use [backgroundColor] as its background color. + /// + /// If the navigation bar is not a child of a [CupertinoPageScaffold], this has no effect. + /// + /// This value defaults to true. + /// {@endtemplate} + final bool automaticBackgroundVisibility; + + /// {@template flutter.cupertino.CupertinoNavigationBar.brightness} + /// The brightness of the specified [backgroundColor]. + /// + /// Setting this value changes the style of the system status bar. Typically + /// used to increase the contrast ratio of the system status bar over + /// [backgroundColor]. + /// + /// If set to null, the value of the property will be inferred from the relative + /// luminance of [backgroundColor]. + /// {@endtemplate} + final Brightness? brightness; + + /// {@template flutter.cupertino.CupertinoNavigationBar.padding} + /// Padding for the contents of the navigation bar. + /// + /// If null, the navigation bar will adopt the following defaults: + /// + /// * Vertically, contents will be sized to the same height as the navigation + /// bar itself minus the status bar. + /// * Horizontally, padding will be 16 pixels according to iOS specifications + /// unless the leading widget is an automatically inserted back button, in + /// which case the padding will be 0. + /// + /// Vertical padding won't change the height of the nav bar. + /// {@endtemplate} + final EdgeInsetsDirectional? padding; + + /// {@template flutter.cupertino.CupertinoNavigationBar.border} + /// The border of the navigation bar. By default renders a single pixel bottom border side. + /// + /// If a border is null, the navigation bar will not display a border. + /// {@endtemplate} + final Border? border; + + /// {@template flutter.cupertino.CupertinoNavigationBar.transitionBetweenRoutes} + /// Whether to transition between navigation bars. + /// + /// When [transitionBetweenRoutes] is true, this navigation bar will transition + /// on top of the routes instead of inside it if the route being transitioned + /// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar] + /// with [transitionBetweenRoutes] set to true. + /// + /// This transition will also occur on edge back swipe gestures like on iOS + /// but only if the previous page below has `maintainState` set to true on the + /// [PageRoute]. + /// + /// When set to true, only one navigation bar can be present per route unless + /// [heroTag] is also set. + /// + /// This value defaults to true. + /// {@endtemplate} + final bool transitionBetweenRoutes; + + /// {@template flutter.cupertino.CupertinoNavigationBar.enableBackgroundFilterBlur} + /// Whether to have a blur effect when a non-opaque background color is used. + /// + /// When [enableBackgroundFilterBlur] is set to false, the blur effect will be + /// disabled. The behaviour of [enableBackgroundFilterBlur] will only be respected when + /// [automaticBackgroundVisibility] is false or until content scrolls under the navbar. + /// + /// This value defaults to true. + /// {@endtemplate} + final bool enableBackgroundFilterBlur; + + /// {@template flutter.cupertino.CupertinoNavigationBar.heroTag} + /// Tag for the navigation bar's Hero widget if [transitionBetweenRoutes] is true. + /// + /// Defaults to a common tag between all [CupertinoNavigationBar] and + /// [CupertinoSliverNavigationBar] instances of the same [Navigator]. With the + /// default tag, all navigation bars of the same navigator can transition + /// between each other as long as there's only one navigation bar per route. + /// + /// This [heroTag] can be overridden to manually handle having multiple + /// navigation bars per route or to transition between multiple + /// [Navigator]s. + /// + /// To disable Hero transitions for this navigation bar, set + /// [transitionBetweenRoutes] to false. + /// {@endtemplate} + final Object heroTag; + + /// A widget to place at the bottom of the navigation bar. + /// + /// Only widgets that implement [PreferredSizeWidget] can be used at the + /// bottom of a navigation bar. + /// + /// {@tool dartpad} + /// This example shows a [CupertinoSearchTextField] at the bottom of a + /// [CupertinoNavigationBar]. + /// + /// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_navigation_bar.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [PreferredSize], which can be used to give an arbitrary widget a preferred size. + final PreferredSizeWidget? bottom; + + /// True if the navigation bar's background color has no transparency. + @override + bool shouldFullyObstruct(BuildContext context) { + final Color backgroundColor = + CupertinoDynamicColor.maybeResolve(this.backgroundColor, context) ?? + CupertinoTheme.of(context).barBackgroundColor; + return backgroundColor.alpha == 0xFF; + } + + @override + Size get preferredSize { + final double bottomHeight = bottom?.preferredSize.height ?? 0.0; + + final double effectiveLargeHeight = largeTitle != null + ? _kNavBarLargeTitleHeightExtension + : 0.0; + + return Size.fromHeight(_kNavBarPersistentHeight + bottomHeight + effectiveLargeHeight); + } + + @override + State createState() => _CupertinoNavigationBarState(); +} + +// A state class exists for the nav bar so that the keys of its sub-components +// don't change when rebuilding the nav bar, causing the sub-components to +// lose their own states. +class _CupertinoNavigationBarState extends State { + late _NavigationBarStaticComponentsKeys keys; + + ScrollNotificationObserverState? _scrollNotificationObserver; + double _scrollAnimationValue = 0.0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollNotificationObserver?.removeListener(_handleScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_handleScrollNotification); + } + + @override + void dispose() { + if (_scrollNotificationObserver != null) { + _scrollNotificationObserver!.removeListener(_handleScrollNotification); + _scrollNotificationObserver = null; + } + super.dispose(); + } + + @override + void initState() { + super.initState(); + keys = _NavigationBarStaticComponentsKeys(); + } + + void _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification && notification.depth == 0) { + final ScrollMetrics metrics = notification.metrics; + final double oldScrollAnimationValue = _scrollAnimationValue; + var scrollExtent = 0.0; + switch (metrics.axisDirection) { + case AxisDirection.up: + // Scroll view is reversed + scrollExtent = metrics.extentAfter; + case AxisDirection.down: + scrollExtent = metrics.extentBefore; + case AxisDirection.right: + case AxisDirection.left: + // Scrolled under is only supported in the vertical axis, and should + // not be altered based on horizontal notifications of the same + // predicate since it could be a 2D scroller. + break; + } + + if (scrollExtent >= 0 && scrollExtent < _kNavBarScrollUnderAnimationExtent) { + setState(() { + _scrollAnimationValue = clampDouble( + scrollExtent / _kNavBarScrollUnderAnimationExtent, + 0, + 1, + ); + }); + } else if (scrollExtent > _kNavBarScrollUnderAnimationExtent && + oldScrollAnimationValue != 1.0) { + setState(() { + _scrollAnimationValue = 1.0; + }); + } else if (scrollExtent <= 0 && oldScrollAnimationValue != 0.0) { + setState(() { + _scrollAnimationValue = 0.0; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // The static navigation bar does not expand or collapse (see CupertinoSliverNavigationBar), + // it will either display the collapsed nav bar with middle, or the expanded with largeTitle. + assert(widget.middle == null || widget.largeTitle == null); + + final Color backgroundColor = + CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ?? + CupertinoTheme.of(context).barBackgroundColor; + + final Color? parentPageScaffoldBackgroundColor = CupertinoPageScaffoldBackgroundColor.maybeOf( + context, + ); + + final Border? initialBorder = + widget.automaticBackgroundVisibility && parentPageScaffoldBackgroundColor != null + ? _kTransparentNavBarBorder + : widget.border; + final Border? effectiveBorder = widget.border == null + ? null + : Border.lerp(initialBorder, widget.border, _scrollAnimationValue); + + final Color effectiveBackgroundColor = + widget.automaticBackgroundVisibility && parentPageScaffoldBackgroundColor != null + ? Color.lerp(parentPageScaffoldBackgroundColor, backgroundColor, _scrollAnimationValue) ?? + backgroundColor + : backgroundColor; + + final double bottomHeight = widget.bottom?.preferredSize.height ?? 0.0; + final double persistentHeight = + _kNavBarPersistentHeight + bottomHeight + MediaQuery.paddingOf(context).top; + final double largeHeight = persistentHeight + _kNavBarLargeTitleHeightExtension; + + final components = _NavigationBarStaticComponents( + keys: keys, + route: ModalRoute.of(context), + userLeading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + automaticallyImplyTitle: widget.automaticallyImplyMiddle, + previousPageTitle: widget.previousPageTitle, + userMiddle: widget.middle, + userTrailing: widget.trailing, + padding: widget.padding, + userLargeTitle: widget.largeTitle, + userBottom: widget.bottom, + large: widget.largeTitle != null, + staticBar: true, // This one does not scroll + context: context, + ); + + // Standard persistent components + Widget navBar = _PersistentNavigationBar( + components: components, + padding: widget.padding, + middleVisible: widget.largeTitle == null, + ); + + if (widget.largeTitle != null) { + // Large nav bar + navBar = ConstrainedBox( + constraints: BoxConstraints(maxHeight: largeHeight), + child: Column( + children: [ + navBar, + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding, + ), + child: Semantics( + header: true, + child: DefaultTextStyle( + style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: _LargeTitle( + height: _kNavBarLargeTitleHeightExtension, + child: components.largeTitle, + ), + ), + ), + ), + ), + if (widget.bottom != null) + SizedBox(height: bottomHeight, child: components.navBarBottom), + ], + ), + ); + } else { + // Small nav bar + navBar = ConstrainedBox( + constraints: BoxConstraints(maxHeight: persistentHeight), + child: Column( + children: [ + navBar, + if (widget.bottom != null) + SizedBox(height: bottomHeight, child: components.navBarBottom), + ], + ), + ); + } + + navBar = _wrapWithBackground( + border: effectiveBorder, + backgroundColor: effectiveBackgroundColor, + brightness: widget.brightness, + enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur, + child: DefaultTextStyle(style: CupertinoTheme.of(context).textTheme.textStyle, child: navBar), + ); + + if (!widget.transitionBetweenRoutes || !_isTransitionable(context)) { + // Lint ignore to maintain backward compatibility. + return navBar; + } + + return Builder( + // Get the context that might have a possibly changed CupertinoTheme. + builder: (BuildContext context) { + return Hero( + tag: widget.heroTag == _defaultHeroTag ? _HeroTag(Navigator.of(context)) : widget.heroTag, + createRectTween: _linearTranslateWithLargestRectSizeTween, + placeholderBuilder: _navBarHeroLaunchPadBuilder, + flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, + transitionOnUserGestures: true, + child: _TransitionableNavigationBar( + componentsKeys: keys, + backgroundColor: effectiveBackgroundColor, + backButtonTextStyle: CupertinoTheme.of(context).textTheme.navActionTextStyle, + titleTextStyle: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + largeTitleTextStyle: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + border: effectiveBorder, + hasUserMiddle: widget.middle != null, + largeExpanded: widget.largeTitle != null, + searchable: false, + automaticBackgroundVisibility: widget.automaticBackgroundVisibility, + child: navBar, + ), + ); + }, + ); + } +} + +/// An iOS-styled navigation bar with iOS-11-style large titles using slivers. +/// +/// The [CupertinoSliverNavigationBar] must be placed in a sliver group such +/// as the [CustomScrollView]. +/// +/// This navigation bar consists of two sections, a pinned static section on top +/// and a sliding section containing iOS-11-style large title below it. +/// +/// It should be placed at top of the screen and automatically accounts for +/// the iOS status bar. +/// +/// This navigation bar is expanded only in portrait orientation. In landscape +/// mode, the navigation bar remains permanently collapsed. The navigation bar +/// also collapses when scrolling in portrait mode. +/// +/// Minimally, a [largeTitle] widget will appear in the middle of the app bar +/// when the sliver is collapsed and transfer to the area below in larger font +/// when the sliver is expanded. This expanded view will only trigger in +/// portrait orientation, while in landscape mode the bar will stay in its +/// collapsed view. +/// +/// For advanced uses, an optional [middle] widget +/// can be supplied to show a different widget in the middle of the navigation +/// bar when the sliver is collapsed. +/// +/// Like [CupertinoNavigationBar], it also supports a [leading] and [trailing] +/// widget on the static section on top that remains while scrolling. +/// +/// The [leading] widget will automatically be a back chevron icon button (or a +/// cancel button in case of a fullscreen dialog) to pop the current route if none +/// is provided and [automaticallyImplyLeading] is true (true by default). +/// +/// The [largeTitle] widget will automatically be a title text from the current +/// [CupertinoPageRoute] if none is provided and [automaticallyImplyTitle] is +/// true (true by default). +/// +/// When [transitionBetweenRoutes] is true, this navigation bar will transition +/// on top of the routes instead of inside them if the route being transitioned +/// to also has a [CupertinoNavigationBar] or a [CupertinoSliverNavigationBar] +/// with [transitionBetweenRoutes] set to true. If [transitionBetweenRoutes] is +/// true, none of the [Widget] parameters can contain any [GlobalKey]s in their +/// subtrees since those widgets will exist in multiple places in the tree +/// simultaneously. +/// +/// By default, only one [CupertinoNavigationBar] or [CupertinoSliverNavigationBar] +/// should be present in each [PageRoute] to support the default transitions. +/// Use [transitionBetweenRoutes] or [heroTag] to customize the transition +/// behavior for multiple navigation bars per route. +/// +/// The [stretch] parameter determines whether the nav bar should stretch to +/// fill the over-scroll area. The nav bar can still expand and contract as the +/// user scrolls, but it will also stretch when the user over-scrolls if the +/// [stretch] value is `true`. Defaults to `false`. +/// +/// {@tool dartpad} +/// This example shows [CupertinoSliverNavigationBar] in action inside a [CustomScrollView]. +/// +/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// To add a widget to the bottom of the nav bar, wrap it with [PreferredSize] and provide its fully extended size. +/// +/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling +/// pages. +/// * [CustomScrollView], a ScrollView that creates custom scroll effects using slivers. +/// * +class CupertinoSliverNavigationBar extends StatefulWidget { + /// Creates a navigation bar for scrolling lists. + /// + /// If [automaticallyImplyTitle] is false, then the [largeTitle] argument is + /// required. + const CupertinoSliverNavigationBar({ + super.key, + this.largeTitle, + this.leading, + this.automaticallyImplyLeading = true, + this.automaticallyImplyTitle = true, + this.alwaysShowMiddle = true, + this.previousPageTitle, + this.middle, + this.trailing, + this.border = _kDefaultNavBarBorder, + this.backgroundColor, + this.automaticBackgroundVisibility = true, + this.enableBackgroundFilterBlur = true, + this.brightness, + this.padding, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, + this.stretch = false, + this.bottom, + this.bottomMode, + }) : assert( + automaticallyImplyTitle || largeTitle != null, + 'No largeTitle has been provided but automaticallyImplyTitle is also ' + 'false. Either provide a largeTitle or set automaticallyImplyTitle to ' + 'true.', + ), + assert( + bottomMode == null || bottom != null, + 'A bottomMode was provided without a corresponding bottom.', + ), + onSearchableBottomTap = null, + searchField = null, + _searchable = false; + + /// A navigation bar for scrolling lists that integrates a provided search + /// field directly into the navigation bar. + /// + /// This search-enabled navigation bar is functionally equivalent to + /// the standard [CupertinoSliverNavigationBar] constructor, but with the + /// addition of [searchField], which sits at the bottom of the navigation bar. + /// + /// When the search field is tapped, [leading], [trailing], [middle], and + /// [largeTitle] all collapse, causing the search field to animate to the + /// 'top' of the navigation bar. A 'Cancel' button is presented next to the + /// active [searchField], which when tapped, closes the search view, bringing + /// the navigation bar back to its initial state. + /// + /// If [automaticallyImplyTitle] is false, then the [largeTitle] argument is + /// required. + /// + /// {@tool dartpad} + /// This example demonstrates how to use a + /// [CupertinoSliverNavigationBar.search] to manage a search view. + /// + /// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart ** + /// {@end-tool} + const CupertinoSliverNavigationBar.search({ + super.key, + required Widget this.searchField, + this.largeTitle, + this.leading, + this.automaticallyImplyLeading = true, + this.automaticallyImplyTitle = true, + this.alwaysShowMiddle = true, + this.previousPageTitle, + this.middle, + this.trailing, + this.border = _kDefaultNavBarBorder, + this.backgroundColor, + this.automaticBackgroundVisibility = true, + this.enableBackgroundFilterBlur = true, + this.brightness, + this.padding, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, + this.stretch = false, + this.bottomMode = NavigationBarBottomMode.automatic, + this.onSearchableBottomTap, + }) : assert( + automaticallyImplyTitle || largeTitle != null, + 'No largeTitle has been provided but automaticallyImplyTitle is also ' + 'false. Either provide a largeTitle or set automaticallyImplyTitle to ' + 'true.', + ), + bottom = null, + _searchable = true; + + /// The navigation bar's title. + /// + /// This text will appear in the top static navigation bar when collapsed and + /// below the navigation bar, in a larger font, when expanded. + /// + /// A suitable [DefaultTextStyle] is provided around this widget as it is + /// moved around, to change its font size. + /// + /// If [middle] is null, then the [largeTitle] widget will be inserted into + /// the tree in two places when transitioning from the collapsed state to the + /// expanded state. It is therefore imperative that this subtree not contain + /// any [GlobalKey]s, and that it not rely on maintaining state (for example, + /// animations will not survive the transition from one location to the other, + /// and may in fact be visible in two places at once during the transition). + /// + /// If null and [automaticallyImplyTitle] is true, an appropriate [Text] + /// title will be created if the current route is a [CupertinoPageRoute] and + /// has a `title`. + /// + /// This parameter must either be non-null or the route must have a title + /// ([CupertinoPageRoute.title]) and [automaticallyImplyTitle] must be true. + final Widget? largeTitle; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.leading} + /// + /// This widget is visible in both collapsed and expanded states. + final Widget? leading; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.automaticallyImplyLeading} + final bool automaticallyImplyLeading; + + /// Controls whether we should try to imply the [largeTitle] widget if null. + /// + /// If true and [largeTitle] is null, automatically fill in a [Text] widget + /// with the current route's `title` if the route is a [CupertinoPageRoute]. + /// If [largeTitle] widget is not null, this parameter has no effect. + final bool automaticallyImplyTitle; + + /// Controls whether [middle] widget should always be visible (even in + /// expanded state). + /// + /// If true (default) and [middle] is not null, [middle] widget is always + /// visible. If false, [middle] widget is visible only in collapsed state if + /// it is provided. + /// + /// This should be set to false if you only want to show [largeTitle] in + /// expanded state and [middle] in collapsed state. + final bool alwaysShowMiddle; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.previousPageTitle} + final String? previousPageTitle; + + /// A widget to place in the middle of the static navigation bar instead of + /// the [largeTitle]. + /// + /// If [alwaysShowMiddle] is true, this widget is visible in both the + /// collapsed and expanded states of the navigation bar. Else, it is visible + /// only in the collapsed state. + /// + /// If null, [largeTitle] will be displayed in the navigation bar's collapsed + /// state. + final Widget? middle; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.trailing} + /// + /// This widget is visible in both collapsed and expanded states. + final Widget? trailing; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.backgroundColor} + final Color? backgroundColor; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.automaticBackgroundVisibility} + final bool automaticBackgroundVisibility; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.enableBackgroundFilterBlur} + final bool enableBackgroundFilterBlur; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.brightness} + final Brightness? brightness; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.padding} + final EdgeInsetsDirectional? padding; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.border} + final Border? border; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.transitionBetweenRoutes} + final bool transitionBetweenRoutes; + + /// {@macro flutter.cupertino.CupertinoNavigationBar.heroTag} + final Object heroTag; + + /// A widget to place at the bottom of the large title or static navigation + /// bar if there is no large title. + /// + /// Only widgets that implement [PreferredSizeWidget] can be used at the + /// bottom of a navigation bar. + /// + /// See also: + /// + /// * [PreferredSize], which can be used to give an arbitrary widget a preferred size. + final PreferredSizeWidget? bottom; + + /// Modes that determine how to display the navigation bar's [bottom], or the + /// search field in a [CupertinoSliverNavigationBar.search]. + /// + /// If null, defaults to [NavigationBarBottomMode.automatic] if either a + /// [bottom] is provided or this is a [CupertinoSliverNavigationBar.search]. + final NavigationBarBottomMode? bottomMode; + + /// Called when the search field in [CupertinoSliverNavigationBar.search] + /// is tapped, toggling between an active and an inactive search state. + final ValueChanged? onSearchableBottomTap; + + /// True if the navigation bar's background color has no transparency. + bool get opaque => backgroundColor?.alpha == 0xFF; + + /// Whether the nav bar should stretch to fill the over-scroll area. + /// + /// The nav bar can still expand and contract as the user scrolls, but it will + /// also stretch when the user over-scrolls if the [stretch] value is `true`. + /// + /// When set to `true`, the nav bar will prevent subsequent slivers from + /// accessing overscrolls. This may be undesirable for using overscroll-based + /// widgets like the [CupertinoSliverRefreshControl]. + /// + /// Defaults to `false`. + final bool stretch; + + /// The search field used in [CupertinoSliverNavigationBar.search]. + /// + /// The provided search field is constrained to a fixed height of 35 pixels in + /// its inactive state, and [kMinInteractiveDimensionCupertino] pixels in its + /// active state. + /// + /// Typically a [CupertinoSearchTextField]. + final Widget? searchField; + + /// True if the [CupertinoSliverNavigationBar.search] constructor is used. + final bool _searchable; + + @override + State createState() => _CupertinoSliverNavigationBarState(); +} + +// A state class exists for the nav bar so that the keys of its sub-components +// don't change when rebuilding the nav bar, causing the sub-components to +// lose their own states. +class _CupertinoSliverNavigationBarState extends State + with TickerProviderStateMixin { + late _NavigationBarStaticComponentsKeys keys; + ScrollableState? _scrollableState; + Widget? effectiveMiddle; + late AnimationController _animationController; + late CurvedAnimation _searchAnimation; + late Animation persistentHeightAnimation; + late Animation largeTitleHeightAnimation; + late double scaledSearchFieldHeight; + late double scaledLargeTitleHeight; + bool searchIsActive = false; + bool isPortrait = true; + + @override + void initState() { + super.initState(); + keys = _NavigationBarStaticComponentsKeys(); + _animationController = AnimationController(vsync: this, duration: _kNavBarSearchDuration); + _searchAnimation = CurvedAnimation(parent: _animationController, curve: _kNavBarSearchCurve); + } + + @override + void didUpdateWidget(CupertinoSliverNavigationBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.middle != oldWidget.middle) { + _updateEffectiveMiddle(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait; + _updateEffectiveMiddle(); + _computeScaledHeights(); + _setupSearchableAnimation(); + _scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange); + _scrollableState = Scrollable.maybeOf(context); + _scrollableState?.position.isScrollingNotifier.addListener(_handleScrollChange); + } + + @override + void dispose() { + if (_scrollableState?.position != null) { + _scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange); + } + _searchAnimation.dispose(); + _animationController.dispose(); + super.dispose(); + } + + double get _bottomHeight { + assert(!widget._searchable || widget.bottom == null); + if (widget._searchable) { + return scaledSearchFieldHeight + _kNavBarBottomPadding; + } else if (widget.bottom != null) { + return widget.bottom!.preferredSize.height; + } + return 0.0; + } + + void _updateEffectiveMiddle() { + effectiveMiddle = widget.middle ?? (isPortrait ? null : widget.largeTitle); + } + + void _computeScaledHeights() { + final TextScaler textScaler = MediaQuery.textScalerOf(context); + scaledSearchFieldHeight = + _kSearchFieldHeight * + _dampScaleFactor( + textScaler.scale(_kSearchFieldHeight), + _kSearchFieldHeight, + _kMaxScaleFactor, + ); + scaledLargeTitleHeight = isPortrait + ? _kNavBarLargeTitleHeightExtension * + _dampScaleFactor( + textScaler.scale(_kNavBarLargeTitleHeightExtension), + _kNavBarLargeTitleHeightExtension, + _kLargeTitleScaleDampingRatio, + ) + : 0.0; + } + + void _setupSearchableAnimation() { + final persistentHeightTween = Tween(begin: _kNavBarPersistentHeight, end: 0.0); + persistentHeightAnimation = persistentHeightTween.animate(_animationController) + ..addStatusListener(_handleSearchFieldStatusChanged); + final largeTitleHeightTween = Tween(begin: scaledLargeTitleHeight, end: 0.0); + largeTitleHeightAnimation = largeTitleHeightTween.animate(_animationController); + } + + void _handleScrollChange() { + final ScrollPosition? position = _scrollableState?.position; + if (position == null || !position.hasPixels || position.pixels <= 0.0) { + return; + } + + double? target; + final double bottomScrollOffset = widget.bottomMode == NavigationBarBottomMode.always + ? 0.0 + : _bottomHeight; + final bool canScrollBottom = + (widget._searchable || widget.bottom != null) && bottomScrollOffset > 0.0; + + // Snap the scroll view to a target determined by the navigation bar's + // position. + if (canScrollBottom && position.pixels < bottomScrollOffset) { + target = position.pixels > bottomScrollOffset / 2 ? bottomScrollOffset : 0.0; + } else if (position.pixels > bottomScrollOffset && + position.pixels < bottomScrollOffset + scaledLargeTitleHeight) { + target = position.pixels > bottomScrollOffset + (scaledLargeTitleHeight / 2) + ? bottomScrollOffset + scaledLargeTitleHeight + : bottomScrollOffset; + } + + // If the target is not null and within the scrollable range, animate to it. + if (target != null && target <= position.maxScrollExtent) { + position.animateTo( + target, + // Eyeballed on an iPhone 16 simulator running iOS 18. + duration: const Duration(milliseconds: 300), + curve: Curves.fastEaseInToSlowEaseOut, + ); + } + } + + void _handleSearchFieldStatusChanged(AnimationStatus status) { + // If the search animation is stopped, rebuild so that the leading, middle, + // and trailing widgets that were collapsed while the search field was + // active are re-expanded. Otherwise, rebuild to update this widget with the + // animation controller's values. + setState(() { + switch (status) { + case AnimationStatus.forward: + searchIsActive = true; + case AnimationStatus.reverse: + searchIsActive = false; + case AnimationStatus.completed: + case AnimationStatus.dismissed: + } + }); + } + + void _onSearchFieldTap() { + if (widget.onSearchableBottomTap != null) { + widget.onSearchableBottomTap!(!searchIsActive); + } + _animationController.toggle(); + } + + @override + Widget build(BuildContext context) { + final components = _NavigationBarStaticComponents( + keys: keys, + route: ModalRoute.of(context), + userLeading: widget.leading != null + ? Visibility(visible: !searchIsActive, child: widget.leading!) + : null, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + automaticallyImplyTitle: widget.automaticallyImplyTitle, + previousPageTitle: widget.previousPageTitle, + userMiddle: _animationController.isAnimating ? const Text('') : effectiveMiddle, + userTrailing: widget.trailing != null + ? Visibility(visible: !searchIsActive, child: widget.trailing!) + : null, + userLargeTitle: widget.largeTitle, + userBottom: + (widget._searchable + ? searchIsActive + ? _ActiveSearchableBottom( + animationController: _animationController, + animation: persistentHeightAnimation, + searchField: widget.searchField, + searchFieldHeight: scaledSearchFieldHeight, + onSearchFieldTap: _onSearchFieldTap, + ) + : _InactiveSearchableBottom( + animationController: _animationController, + animation: persistentHeightAnimation, + searchField: widget.searchField, + searchFieldHeight: scaledSearchFieldHeight, + onSearchFieldTap: _onSearchFieldTap, + ) + : widget.bottom) ?? + const SizedBox.shrink(), + padding: widget.padding, + large: isPortrait, + staticBar: false, // This one scrolls. + context: context, + ); + + return MediaQuery.withNoTextScaling( + child: AnimatedBuilder( + animation: _searchAnimation, + builder: (BuildContext context, Widget? child) { + return SliverPersistentHeader( + pinned: true, // iOS navigation bars are always pinned. + delegate: _LargeTitleNavigationBarSliverDelegate( + keys: keys, + components: components, + userMiddle: effectiveMiddle, + backgroundColor: + CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ?? + CupertinoTheme.of(context).barBackgroundColor, + automaticBackgroundVisibility: widget.automaticBackgroundVisibility, + brightness: widget.brightness, + border: widget.border, + padding: widget.padding, + actionsForegroundColor: CupertinoTheme.of(context).primaryColor, + transitionBetweenRoutes: widget.transitionBetweenRoutes, + heroTag: widget.heroTag, + persistentHeight: persistentHeightAnimation.value + MediaQuery.paddingOf(context).top, + largeTitleHeight: largeTitleHeightAnimation.value, + alwaysShowMiddle: widget.alwaysShowMiddle && effectiveMiddle != null, + stretchConfiguration: widget.stretch && !searchIsActive + ? OverScrollHeaderStretchConfiguration() + : null, + enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur, + bottomMode: searchIsActive + ? NavigationBarBottomMode.always + : widget.bottomMode ?? NavigationBarBottomMode.automatic, + bottomHeight: _bottomHeight, + controller: _animationController, + searchable: widget._searchable, + ), + ); + }, + ), + ); + } +} + +class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDelegate + with DiagnosticableTreeMixin { + _LargeTitleNavigationBarSliverDelegate({ + required this.keys, + required this.components, + required this.userMiddle, + required this.backgroundColor, + required this.automaticBackgroundVisibility, + required this.brightness, + required this.border, + required this.padding, + required this.actionsForegroundColor, + required this.transitionBetweenRoutes, + required this.heroTag, + required this.persistentHeight, + required this.largeTitleHeight, + required this.alwaysShowMiddle, + required this.stretchConfiguration, + required this.enableBackgroundFilterBlur, + required this.bottomMode, + required this.bottomHeight, + required this.controller, + required this.searchable, + }); + + final _NavigationBarStaticComponentsKeys keys; + final _NavigationBarStaticComponents components; + final Widget? userMiddle; + final Color backgroundColor; + final bool automaticBackgroundVisibility; + final Brightness? brightness; + final Border? border; + final EdgeInsetsDirectional? padding; + final Color actionsForegroundColor; + final bool transitionBetweenRoutes; + final Object heroTag; + final double persistentHeight; + final double largeTitleHeight; + final bool alwaysShowMiddle; + final bool enableBackgroundFilterBlur; + final NavigationBarBottomMode bottomMode; + final double bottomHeight; + final AnimationController controller; + final bool searchable; + + @override + double get minExtent => + persistentHeight + (bottomMode == NavigationBarBottomMode.always ? bottomHeight : 0.0); + + @override + double get maxExtent => persistentHeight + largeTitleHeight + bottomHeight; + + @override + OverScrollHeaderStretchConfiguration? stretchConfiguration; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final double largeTitleThreshold = maxExtent - minExtent - _kNavBarShowLargeTitleThreshold; + final bool showLargeTitle = shrinkOffset < largeTitleThreshold; + + // Calculate how much the bottom should shrink. + final double bottomShrinkFactor = clampDouble(shrinkOffset / bottomHeight, 0, 1); + + final double shrinkAnimationValue = clampDouble( + (shrinkOffset - largeTitleThreshold - _kNavBarScrollUnderAnimationExtent) / + _kNavBarScrollUnderAnimationExtent, + 0, + 1, + ); + + final persistentNavigationBar = _PersistentNavigationBar( + components: components, + padding: padding, + // If a user specified middle exists, always show it. Otherwise, show + // title when sliver is collapsed. + middleVisible: alwaysShowMiddle ? null : !showLargeTitle, + ); + + final Color? parentPageScaffoldBackgroundColor = CupertinoPageScaffoldBackgroundColor.maybeOf( + context, + ); + + final Border? initialBorder = + automaticBackgroundVisibility && parentPageScaffoldBackgroundColor != null + ? _kTransparentNavBarBorder + : border; + final Border? effectiveBorder = border == null + ? null + : Border.lerp(initialBorder, border, shrinkAnimationValue); + + final Color effectiveBackgroundColor = + automaticBackgroundVisibility && parentPageScaffoldBackgroundColor != null + ? Color.lerp(parentPageScaffoldBackgroundColor, backgroundColor, shrinkAnimationValue) ?? + backgroundColor + : backgroundColor; + + final Widget navBar = _wrapWithBackground( + border: effectiveBorder, + backgroundColor: effectiveBackgroundColor, + brightness: brightness, + enableBackgroundFilterBlur: enableBackgroundFilterBlur, + child: DefaultTextStyle( + style: CupertinoTheme.of(context).textTheme.textStyle, + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Positioned( + top: persistentHeight, + left: 0.0, + right: 0.0, + bottom: bottomMode == NavigationBarBottomMode.automatic + ? bottomHeight * (1.0 - bottomShrinkFactor) + : 0.0, + child: ClipRect( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding, + ), + child: SafeArea( + top: false, + bottom: false, + child: AnimatedOpacity( + // Fade the large title as the search field animates from its expanded to its collapsed state. + opacity: showLargeTitle && !controller.isForwardOrCompleted ? 1.0 : 0.0, + duration: _kNavBarTitleFadeDuration, + child: Semantics( + header: true, + child: DefaultTextStyle( + style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: _LargeTitle( + height: largeTitleHeight, + child: components.largeTitle, + ), + ), + ), + ), + ), + ), + ), + ), + Positioned(left: 0.0, right: 0.0, top: 0.0, child: persistentNavigationBar), + if (bottomMode == NavigationBarBottomMode.automatic) + Positioned( + left: 0.0, + right: 0.0, + bottom: 0.0, + child: SizedBox( + height: bottomHeight * (1.0 - bottomShrinkFactor), + child: ClipRect(child: components.navBarBottom), + ), + ), + ], + ), + ), + if (bottomMode == NavigationBarBottomMode.always) + SizedBox(height: bottomHeight, child: components.navBarBottom), + ], + ), + ), + ); + + if (!transitionBetweenRoutes || !_isTransitionable(context)) { + return navBar; + } + + return Hero( + tag: heroTag == _defaultHeroTag ? _HeroTag(Navigator.of(context)) : heroTag, + createRectTween: _linearTranslateWithLargestRectSizeTween, + flightShuttleBuilder: _navBarHeroFlightShuttleBuilder, + placeholderBuilder: _navBarHeroLaunchPadBuilder, + transitionOnUserGestures: true, + // This is all the way down here instead of being at the top level of + // CupertinoSliverNavigationBar like CupertinoNavigationBar because it + // needs to wrap the top level RenderBox rather than a RenderSliver. + child: _TransitionableNavigationBar( + componentsKeys: keys, + backgroundColor: effectiveBackgroundColor, + backButtonTextStyle: CupertinoTheme.of(context).textTheme.navActionTextStyle, + titleTextStyle: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + largeTitleTextStyle: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + border: effectiveBorder, + hasUserMiddle: userMiddle != null && (alwaysShowMiddle || !showLargeTitle), + largeExpanded: showLargeTitle, + searchable: searchable, + automaticBackgroundVisibility: automaticBackgroundVisibility, + child: navBar, + ), + ); + } + + @override + bool shouldRebuild(_LargeTitleNavigationBarSliverDelegate oldDelegate) { + return components != oldDelegate.components || + userMiddle != oldDelegate.userMiddle || + backgroundColor != oldDelegate.backgroundColor || + automaticBackgroundVisibility != oldDelegate.automaticBackgroundVisibility || + border != oldDelegate.border || + padding != oldDelegate.padding || + actionsForegroundColor != oldDelegate.actionsForegroundColor || + transitionBetweenRoutes != oldDelegate.transitionBetweenRoutes || + persistentHeight != oldDelegate.persistentHeight || + largeTitleHeight != oldDelegate.largeTitleHeight || + alwaysShowMiddle != oldDelegate.alwaysShowMiddle || + heroTag != oldDelegate.heroTag || + enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur || + bottomMode != oldDelegate.bottomMode || + bottomHeight != oldDelegate.bottomHeight || + controller != oldDelegate.controller || + searchable != oldDelegate.searchable; + } +} + +/// The large title of the navigation bar. +/// +/// Magnifies on over-scroll when [CupertinoSliverNavigationBar.stretch] +/// parameter is true. +class _LargeTitle extends SingleChildRenderObjectWidget { + const _LargeTitle({super.child, required this.height}); + + final double height; + + @override + _RenderLargeTitle createRenderObject(BuildContext context) { + return _RenderLargeTitle( + alignment: AlignmentDirectional.bottomStart.resolve(Directionality.of(context)), + height: height, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderLargeTitle renderObject) { + renderObject + ..alignment = AlignmentDirectional.bottomStart.resolve(Directionality.of(context)) + ..height = height; + } +} + +class _RenderLargeTitle extends RenderShiftedBox { + _RenderLargeTitle({required Alignment alignment, required double height}) + : _alignment = alignment, + _height = height, + super(null); + + Alignment get alignment => _alignment; + Alignment _alignment; + set alignment(Alignment value) { + if (_alignment == value) { + return; + } + _alignment = value; + + markNeedsLayout(); + } + + double get height => _height; + double _height; + set height(double value) { + if (_height == value) { + return; + } + _height = value; + + markNeedsLayout(); + } + + double _scale = 1.0; + + static double _computeTitleScale(Size childSize, BoxConstraints constraints, double height) { + final double maxHeight = height - _kNavBarBottomPadding; + final double scale = 1.0 + 0.03 * (constraints.maxHeight - maxHeight) / maxHeight; + final double maxScale = childSize.width != 0.0 + ? clampDouble(constraints.maxWidth / childSize.width, 1.0, 1.1) + : 1.1; + return clampDouble(scale, 1.0, maxScale); + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + final double? distance = child?.getDistanceToActualBaseline(baseline); + if (distance == null) { + return null; + } + final childParentData = child!.parentData! as BoxParentData; + return childParentData.offset.dy + distance * _scale; + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final BoxConstraints childConstraints = constraints.widthConstraints().loosen(); + final double? result = child.getDryBaseline(childConstraints, baseline); + if (result == null) { + return null; + } + final Size childSize = child.getDryLayout(childConstraints); + final double scale = _computeTitleScale(childSize, constraints, height); + final Size scaledChildSize = childSize * scale; + return result * scale + + alignment.alongOffset(constraints.biggest - scaledChildSize as Offset).dy; + } + + @override + void performLayout() { + final RenderBox? child = this.child; + size = constraints.biggest; + + if (child == null) { + return; + } + + final BoxConstraints childConstraints = constraints.widthConstraints().loosen(); + child.layout(childConstraints, parentUsesSize: true); + _scale = _computeTitleScale(child.size, constraints, height); + final childParentData = child.parentData! as BoxParentData; + childParentData.offset = alignment.alongOffset(size - (child.size * _scale) as Offset); + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + assert(child == this.child); + + super.applyPaintTransform(child, transform); + + transform.scaleByDouble(_scale, _scale, _scale, 1); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = this.child; + + if (child == null) { + layer = null; + } else { + final childParentData = child.parentData! as BoxParentData; + + layer = context.pushTransform( + needsCompositing, + offset + childParentData.offset, + Matrix4.diagonal3Values(_scale, _scale, 1.0), + (PaintingContext context, Offset offset) => context.paintChild(child, offset), + oldLayer: layer as TransformLayer?, + ); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = this.child; + + if (child == null) { + return false; + } + + final Offset childOffset = (child.parentData! as BoxParentData).offset; + + final transform = Matrix4.identity() + ..scaleByDouble(1.0 / _scale, 1.0 / _scale, 1.0, 1) + ..translateByDouble(-childOffset.dx, -childOffset.dy, 0, 1); + + return result.addWithRawTransform( + transform: transform, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + return child.hitTest(result, position: transformed); + }, + ); + } +} + +/// The top part of the navigation bar that's never scrolled away. +/// +/// Consists of the entire navigation bar without background and border when used +/// without large titles. With large titles, it's the top static half that +/// doesn't scroll. +class _PersistentNavigationBar extends StatelessWidget { + const _PersistentNavigationBar({required this.components, this.padding, this.middleVisible}); + + final _NavigationBarStaticComponents components; + + final EdgeInsetsDirectional? padding; + + /// Whether the middle widget has a visible animated opacity. A null value + /// means the middle opacity will not be animated. + final bool? middleVisible; + + @override + Widget build(BuildContext context) { + Widget? middle = components.middle; + + if (middle != null) { + middle = DefaultTextStyle( + style: CupertinoTheme.of(context).textTheme.navTitleTextStyle, + child: Semantics(header: true, child: middle), + ); + // When the middle's visibility can change on the fly like with large title + // slivers, wrap with animated opacity. + middle = middleVisible == null + ? middle + : AnimatedOpacity( + opacity: middleVisible! ? 1.0 : 0.0, + duration: _kNavBarTitleFadeDuration, + child: middle, + ); + } + + Widget? leading = components.leading; + final Widget? backChevron = components.backChevron; + final Widget? backLabel = components.backLabel; + + if (leading == null && + backChevron != null && + backLabel != null && + !CupertinoSheetRoute.hasParentSheet(context)) { + leading = CupertinoNavigationBarBackButton._assemble(backChevron, backLabel); + } else { + leading = Align(widthFactor: 1.0, child: leading); + } + + Widget paddedToolbar = NavigationToolbar( + leading: leading, + middle: middle, + trailing: components.trailing, + middleSpacing: 6.0, + ); + + if (padding != null) { + paddedToolbar = Padding( + padding: EdgeInsets.only(top: padding!.top, bottom: padding!.bottom), + child: paddedToolbar, + ); + } + + return SizedBox( + height: _kNavBarPersistentHeight + MediaQuery.paddingOf(context).top, + child: SafeArea( + top: !CupertinoSheetRoute.hasParentSheet(context), + bottom: false, + child: paddedToolbar, + ), + ); + } +} + +// A collection of keys always used when building static routes' nav bars's +// components with _NavigationBarStaticComponents and read in +// _NavigationBarTransition in Hero flights in order to reference the components' +// RenderBoxes for their positions. +// +// These keys should never re-appear inside the Hero flights. +@immutable +class _NavigationBarStaticComponentsKeys { + _NavigationBarStaticComponentsKeys() + : navBarBoxKey = GlobalKey(debugLabel: 'Navigation bar render box'), + leadingKey = GlobalKey(debugLabel: 'Leading'), + backChevronKey = GlobalKey(debugLabel: 'Back chevron'), + backLabelKey = GlobalKey(debugLabel: 'Back label'), + middleKey = GlobalKey(debugLabel: 'Middle'), + trailingKey = GlobalKey(debugLabel: 'Trailing'), + largeTitleKey = GlobalKey(debugLabel: 'Large title'), + navBarBottomKey = GlobalKey(debugLabel: 'Navigation bar bottom'); + + final GlobalKey navBarBoxKey; + final GlobalKey leadingKey; + final GlobalKey backChevronKey; + final GlobalKey backLabelKey; + final GlobalKey middleKey; + final GlobalKey trailingKey; + final GlobalKey largeTitleKey; + final GlobalKey navBarBottomKey; +} + +// Based on various user Widgets and other parameters, construct KeyedSubtree +// components that are used in common by the CupertinoNavigationBar and +// CupertinoSliverNavigationBar. The KeyedSubtrees are inserted into static +// routes and the KeyedSubtrees' child are reused in the Hero flights. +@immutable +class _NavigationBarStaticComponents { + _NavigationBarStaticComponents({ + required _NavigationBarStaticComponentsKeys keys, + required ModalRoute? route, + required Widget? userLeading, + required bool automaticallyImplyLeading, + required bool automaticallyImplyTitle, + required String? previousPageTitle, + required Widget? userMiddle, + required Widget? userTrailing, + required Widget? userLargeTitle, + required Widget? userBottom, + required EdgeInsetsDirectional? padding, + required bool large, + required bool staticBar, + required BuildContext context, + }) : leading = createLeading( + leadingKey: keys.leadingKey, + userLeading: userLeading, + route: route, + automaticallyImplyLeading: automaticallyImplyLeading, + padding: padding, + context: context, + ), + backChevron = createBackChevron( + backChevronKey: keys.backChevronKey, + userLeading: userLeading, + route: route, + automaticallyImplyLeading: automaticallyImplyLeading, + context: context, + ), + backLabel = createBackLabel( + backLabelKey: keys.backLabelKey, + userLeading: userLeading, + route: route, + previousPageTitle: previousPageTitle, + automaticallyImplyLeading: automaticallyImplyLeading, + context: context, + ), + middle = createMiddle( + middleKey: keys.middleKey, + userMiddle: userMiddle, + userLargeTitle: userLargeTitle, + route: route, + automaticallyImplyTitle: automaticallyImplyTitle, + large: large, + staticBar: staticBar, + context: context, + ), + trailing = createTrailing( + trailingKey: keys.trailingKey, + userTrailing: userTrailing, + padding: padding, + context: context, + ), + largeTitle = createLargeTitle( + largeTitleKey: keys.largeTitleKey, + userLargeTitle: userLargeTitle, + route: route, + automaticImplyTitle: automaticallyImplyTitle, + large: large, + context: context, + ), + navBarBottom = createNavBarBottom( + navBarBottomKey: keys.navBarBottomKey, + userBottom: userBottom, + context: context, + ); + + static Widget? _derivedTitle({ + required bool automaticallyImplyTitle, + ModalRoute? currentRoute, + }) { + // Auto use the CupertinoPageRoute's title if middle not provided. + if (automaticallyImplyTitle && + currentRoute is CupertinoRouteTransitionMixin && + currentRoute.title != null) { + return Text(currentRoute.title!); + } + + return null; + } + + final KeyedSubtree? leading; + static KeyedSubtree? createLeading({ + required GlobalKey leadingKey, + required Widget? userLeading, + required ModalRoute? route, + required bool automaticallyImplyLeading, + required EdgeInsetsDirectional? padding, + required BuildContext context, + }) { + Widget? leadingContent; + + if (userLeading != null) { + leadingContent = userLeading; + } else if (automaticallyImplyLeading && + route is PageRoute && + route.canPop && + route.fullscreenDialog) { + leadingContent = CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + route.navigator!.maybePop(); + }, + child: Text(CupertinoLocalizations.of(context).cancelButtonLabel), + ); + } + + if (leadingContent == null) { + return null; + } + + return KeyedSubtree( + key: leadingKey, + child: Padding( + padding: EdgeInsetsDirectional.only(start: padding?.start ?? _kNavBarEdgePadding), + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _clampedTextScaler(context)), + child: IconTheme.merge(data: const IconThemeData(size: 32.0), child: leadingContent), + ), + ), + ); + } + + final KeyedSubtree? backChevron; + static KeyedSubtree? createBackChevron({ + required GlobalKey backChevronKey, + required Widget? userLeading, + required ModalRoute? route, + required bool automaticallyImplyLeading, + required BuildContext context, + }) { + if (userLeading != null || + !automaticallyImplyLeading || + route == null || + !route.canPop || + (route is PageRoute && route.fullscreenDialog)) { + return null; + } + + return KeyedSubtree( + key: backChevronKey, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _clampedTextScaler(context)), + child: const _BackChevron(), + ), + ); + } + + /// This widget is not decorated with a font since the font style could + /// animate during transitions. + final KeyedSubtree? backLabel; + static KeyedSubtree? createBackLabel({ + required GlobalKey backLabelKey, + required Widget? userLeading, + required ModalRoute? route, + required bool automaticallyImplyLeading, + required String? previousPageTitle, + required BuildContext context, + }) { + if (userLeading != null || + !automaticallyImplyLeading || + route == null || + !route.canPop || + (route is PageRoute && route.fullscreenDialog)) { + return null; + } + + return KeyedSubtree( + key: backLabelKey, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _clampedTextScaler(context)), + child: _BackLabel(specifiedPreviousTitle: previousPageTitle, route: route), + ), + ); + } + + /// This widget is not decorated with a font since the font style could + /// animate during transitions. + final KeyedSubtree? middle; + static KeyedSubtree? createMiddle({ + required GlobalKey middleKey, + required Widget? userMiddle, + required Widget? userLargeTitle, + required bool large, + required bool staticBar, + required bool automaticallyImplyTitle, + required ModalRoute? route, + required BuildContext context, + }) { + var middleContent = userMiddle; + + if (large && staticBar) { + // Static bar only displays the middle, or the large, not both. + // A scrolling bar creates both middle and large to transition between. + return null; + } + + if (large) { + middleContent ??= userLargeTitle; + } + + middleContent ??= _derivedTitle( + automaticallyImplyTitle: automaticallyImplyTitle, + currentRoute: route, + ); + + if (middleContent == null) { + return null; + } + + return KeyedSubtree( + key: middleKey, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _clampedTextScaler(context)), + child: middleContent, + ), + ); + } + + final KeyedSubtree? trailing; + static KeyedSubtree? createTrailing({ + required GlobalKey trailingKey, + required Widget? userTrailing, + required EdgeInsetsDirectional? padding, + required BuildContext context, + }) { + if (userTrailing == null) { + return null; + } + + return KeyedSubtree( + key: trailingKey, + child: Padding( + padding: EdgeInsetsDirectional.only(end: padding?.end ?? _kNavBarEdgePadding), + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _clampedTextScaler(context)), + child: IconTheme.merge(data: const IconThemeData(size: 32.0), child: userTrailing), + ), + ), + ); + } + + /// This widget is not decorated with a font since the font style could + /// animate during transitions. + final KeyedSubtree? largeTitle; + static KeyedSubtree? createLargeTitle({ + required GlobalKey largeTitleKey, + required Widget? userLargeTitle, + required bool large, + required bool automaticImplyTitle, + required ModalRoute? route, + required BuildContext context, + }) { + if (!large) { + return null; + } + + final Widget? largeTitleContent = + userLargeTitle ?? + _derivedTitle(automaticallyImplyTitle: automaticImplyTitle, currentRoute: route); + + assert( + largeTitleContent != null, + 'largeTitle was not provided and there was no title from the route.', + ); + + return KeyedSubtree( + key: largeTitleKey, + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + _dampScaleFactor( + MediaQuery.textScalerOf(context).scale(_kNavBarLargeTitleHeightExtension), + _kNavBarLargeTitleHeightExtension, + _kLargeTitleScaleDampingRatio, + ), + ), + ), + child: largeTitleContent!, + ), + ); + } + + final KeyedSubtree? navBarBottom; + static KeyedSubtree? createNavBarBottom({ + required GlobalKey navBarBottomKey, + required Widget? userBottom, + required BuildContext context, + }) { + return KeyedSubtree( + key: navBarBottomKey, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: MediaQuery.textScalerOf(context)), + child: userBottom ?? const SizedBox.shrink(), + ), + ); + } + + static TextScaler _clampedTextScaler(BuildContext context) { + return MediaQuery.textScalerOf( + context, + ).clamp(minScaleFactor: 1.0, maxScaleFactor: _kMaxScaleFactor); + } +} + +/// A nav bar back button typically used in [CupertinoNavigationBar]. +/// +/// This is automatically inserted into [CupertinoNavigationBar] and +/// [CupertinoSliverNavigationBar]'s `leading` slot when +/// `automaticallyImplyLeading` is true. +/// +/// When manually inserted, the [CupertinoNavigationBarBackButton] should only +/// be used in routes that can be popped unless a custom [onPressed] is +/// provided. +/// +/// Shows a back chevron and the previous route's title when available from +/// the previous [CupertinoPageRoute.title]. If [previousPageTitle] is specified, +/// it will be shown instead. +class CupertinoNavigationBarBackButton extends StatelessWidget { + /// Construct a [CupertinoNavigationBarBackButton] that can be used to pop + /// the current route. + const CupertinoNavigationBarBackButton({ + super.key, + this.color, + this.previousPageTitle, + this.onPressed, + }) : _backChevron = null, + _backLabel = null; + + // Allow the back chevron and label to be separately created (and keyed) + // because they animate separately during page transitions. + const CupertinoNavigationBarBackButton._assemble(this._backChevron, this._backLabel) + : previousPageTitle = null, + color = null, + onPressed = null; + + /// The [Color] of the back button. + /// + /// Can be used to override the color of the back button chevron and label. + /// + /// Defaults to [CupertinoTheme]'s `primaryColor` if null. + final Color? color; + + /// An override for showing the previous route's title. If null, it will be + /// automatically derived from [CupertinoPageRoute.title] if the current and + /// previous routes are both [CupertinoPageRoute]s. + final String? previousPageTitle; + + /// An override callback to perform instead of the default behavior which is + /// to pop the [Navigator]. + /// + /// It can, for instance, be used to pop the platform's navigation stack + /// via [SystemNavigator] instead of Flutter's [Navigator] in add-to-app + /// situations. + /// + /// Defaults to null. + final VoidCallback? onPressed; + + final Widget? _backChevron; + + final Widget? _backLabel; + + @override + Widget build(BuildContext context) { + final ModalRoute? currentRoute = ModalRoute.of(context); + if (onPressed == null) { + assert( + currentRoute?.canPop ?? false, + 'CupertinoNavigationBarBackButton should only be used in routes that can be popped', + ); + } + + TextStyle actionTextStyle = CupertinoTheme.of(context).textTheme.navActionTextStyle; + if (color != null) { + actionTextStyle = actionTextStyle.copyWith( + color: CupertinoDynamicColor.maybeResolve(color, context), + ); + } + + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + return CupertinoButton( + padding: EdgeInsets.zero, + child: Semantics( + container: true, + excludeSemantics: true, + label: localizations.backButtonLabel, + button: true, + child: DefaultTextStyle( + style: actionTextStyle, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: _kNavBarBackButtonTapWidth), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding(padding: EdgeInsetsDirectional.only(start: 8.0)), + _backChevron ?? const _BackChevron(), + const Padding(padding: EdgeInsetsDirectional.only(start: 6.0)), + Flexible( + child: + _backLabel ?? + _BackLabel(specifiedPreviousTitle: previousPageTitle, route: currentRoute), + ), + ], + ), + ), + ), + ), + onPressed: () { + if (onPressed != null) { + onPressed!(); + } else { + Navigator.maybePop(context); + } + }, + ); + } +} + +class _BackChevron extends StatelessWidget { + const _BackChevron(); + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + final TextStyle textStyle = DefaultTextStyle.of(context).style; + + // Replicate the Icon logic here to get a tightly sized icon and add + // custom non-square padding. + Widget iconWidget = Padding( + padding: const EdgeInsetsDirectional.only(start: 6, end: 2), + child: Text.rich( + TextSpan( + text: String.fromCharCode(CupertinoIcons.back.codePoint), + style: TextStyle( + inherit: false, + color: textStyle.color, + fontSize: 30.0, + fontFamily: CupertinoIcons.back.fontFamily, + package: CupertinoIcons.back.fontPackage, + ), + ), + ), + ); + switch (textDirection) { + case TextDirection.rtl: + iconWidget = Transform( + transform: Matrix4.identity()..scaleByDouble(-1.0, 1.0, 1.0, 1), + alignment: Alignment.center, + transformHitTests: false, + child: iconWidget, + ); + case TextDirection.ltr: + break; + } + + return KeyedSubtree(key: StandardComponentType.backButton.key, child: iconWidget); + } +} + +/// A widget that shows next to the back chevron when `automaticallyImplyLeading` +/// is true. +class _BackLabel extends StatelessWidget { + const _BackLabel({required this.specifiedPreviousTitle, required this.route}); + + final String? specifiedPreviousTitle; + final ModalRoute? route; + + // `child` is never passed in into ValueListenableBuilder so it's always + // null here and unused. + Widget _buildPreviousTitleWidget(BuildContext context, String? previousTitle, Widget? child) { + if (previousTitle == null) { + return const SizedBox.shrink(); + } + + var textWidget = Text(previousTitle, maxLines: 1, overflow: TextOverflow.ellipsis); + + if (previousTitle.length > 12) { + textWidget = Text(CupertinoLocalizations.of(context).backButtonLabel); + } + + return Align(alignment: AlignmentDirectional.centerStart, widthFactor: 1.0, child: textWidget); + } + + @override + Widget build(BuildContext context) { + if (specifiedPreviousTitle != null) { + return _buildPreviousTitleWidget(context, specifiedPreviousTitle, null); + } else if (route is CupertinoRouteTransitionMixin && !route!.isFirst) { + final cupertinoRoute = route! as CupertinoRouteTransitionMixin; + // There is no timing issue because the previousTitle Listenable changes + // happen during route modifications before the ValueListenableBuilder + // is built. + return ValueListenableBuilder( + valueListenable: cupertinoRoute.previousTitle, + builder: _buildPreviousTitleWidget, + ); + } else { + return const SizedBox.shrink(); + } + } +} + +/// The 'Cancel' button next to the search field in a +/// [CupertinoSliverNavigationBar.search]. +class _CancelButton extends StatelessWidget { + const _CancelButton({this.opacity = 1.0, required this.onPressed}); + + final void Function()? onPressed; + final double opacity; + + @override + Widget build(BuildContext context) { + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + return MediaQuery.withNoTextScaling( + child: Align( + alignment: Alignment.centerLeft, + child: Opacity( + opacity: opacity, + child: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + child: Text(localizations.cancelButtonLabel, maxLines: 1, overflow: TextOverflow.clip), + ), + ), + ), + ); + } +} + +/// The bottom of a [CupertinoSliverNavigationBar.search] when the search field +/// is inactive. +class _InactiveSearchableBottom extends StatelessWidget { + const _InactiveSearchableBottom({ + required this.animationController, + required this.searchField, + required this.animation, + required this.searchFieldHeight, + required this.onSearchFieldTap, + }); + + final AnimationController animationController; + final Widget? searchField; + final Animation animation; + final double searchFieldHeight; + final void Function()? onSearchFieldTap; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + child: GestureDetector( + onTap: onSearchFieldTap, + child: AbsorbPointer( + child: FocusableActionDetector( + descendantsAreFocusable: false, + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + end: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding, + ), + child: SizedBox(height: searchFieldHeight, child: searchField), + ), + ), + ), + ), + builder: (BuildContext context, Widget? child) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Row( + children: [ + SizedBox( + width: + constraints.maxWidth - + (_kSearchFieldCancelButtonWidth * animationController.value), + child: child, + ), + // A decoy 'Cancel' button used in the collapsed-to-expanded animation. + SizedBox( + width: animationController.value * _kSearchFieldCancelButtonWidth, + child: Padding( + padding: const EdgeInsets.only(bottom: _kNavBarBottomPadding), + child: _CancelButton(opacity: 0.4, onPressed: () {}), + ), + ), + ], + ); + }, + ); + }, + ); + } +} + +/// The bottom of a [CupertinoSliverNavigationBar.search] when the search field +/// is active. +class _ActiveSearchableBottom extends StatelessWidget { + const _ActiveSearchableBottom({ + required this.animationController, + required this.searchField, + required this.animation, + required this.searchFieldHeight, + required this.onSearchFieldTap, + }); + + final AnimationController animationController; + final Widget? searchField; + final Animation animation; + final double searchFieldHeight; + final void Function()? onSearchFieldTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding, + ), + child: Row( + spacing: 12.0, // Eyeballed on an iPhone 15 simulator running iOS 17.5. + children: [ + Expanded( + child: SizedBox( + height: searchFieldHeight, + child: searchField ?? const SizedBox.shrink(), + ), + ), + AnimatedBuilder( + animation: animation, + child: FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate(animationController), + child: _CancelButton(onPressed: onSearchFieldTap), + ), + builder: (BuildContext context, Widget? child) { + return SizedBox( + width: animationController.value * _kSearchFieldCancelButtonWidth, + child: child, + ); + }, + ), + ], + ), + ); + } +} + +/// This should always be the first child of Hero widgets. +/// +/// This class helps each Hero transition obtain the start or end navigation +/// bar's box size and the inner components of the navigation bar that will +/// move around. +/// +/// It should be wrapped around the biggest [RenderBox] of the static +/// navigation bar in each route. +class _TransitionableNavigationBar extends StatelessWidget { + _TransitionableNavigationBar({ + required this.componentsKeys, + required this.backgroundColor, + required this.backButtonTextStyle, + required this.titleTextStyle, + required this.largeTitleTextStyle, + required this.border, + required this.hasUserMiddle, + required this.largeExpanded, + required this.searchable, + required this.automaticBackgroundVisibility, + required this.child, + }) : assert(!largeExpanded || largeTitleTextStyle != null), + super(key: componentsKeys.navBarBoxKey); + + final _NavigationBarStaticComponentsKeys componentsKeys; + final Color? backgroundColor; + final TextStyle backButtonTextStyle; + final TextStyle titleTextStyle; + final TextStyle? largeTitleTextStyle; + final Border? border; + final bool hasUserMiddle; + final bool largeExpanded; + final bool searchable; + final bool automaticBackgroundVisibility; + final Widget child; + + RenderBox get renderBox { + final box = componentsKeys.navBarBoxKey.currentContext!.findRenderObject()! as RenderBox; + assert( + box.attached, + '_TransitionableNavigationBar.renderBox should be called when building ' + 'hero flight shuttles when the from and the to nav bar boxes are already ' + 'laid out and painted.', + ); + return box; + } + + bool get userGestureInProgress { + return Navigator.of(componentsKeys.navBarBoxKey.currentContext!).userGestureInProgress; + } + + @override + Widget build(BuildContext context) { + assert(() { + var inHero = false; + context.visitAncestorElements((Element ancestor) { + if (ancestor is ComponentElement) { + assert( + ancestor.widget.runtimeType != _NavigationBarTransition, + '_TransitionableNavigationBar should never re-appear inside ' + '_NavigationBarTransition. Keyed _TransitionableNavigationBar should ' + 'only serve as anchor points in routes rather than appearing inside ' + 'Hero flights themselves.', + ); + if (ancestor.widget.runtimeType == Hero) { + inHero = true; + } + } + return true; + }); + assert( + inHero, + '_TransitionableNavigationBar should only be added as the immediate ' + 'child of Hero widgets.', + ); + return true; + }()); + return child; + } +} + +/// This class represents the widget that will be in the Hero flight instead of +/// the 2 static navigation bars by taking inner components from both. +/// +/// The `topNavBar` parameter is the nav bar that was on top regardless of +/// push/pop direction. +/// +/// Similarly, the `bottomNavBar` parameter is the nav bar that was at the +/// bottom regardless of the push/pop direction. +/// +/// If [MediaQueryData.padding] is still present in this widget's +/// [BuildContext], that padding will become part of the transitional navigation +/// bar as well. +/// +/// [MediaQueryData.padding] should be consistent between the from/to routes and +/// the Hero overlay. Inconsistent [MediaQueryData.padding] will produce +/// undetermined results. +class _NavigationBarTransition extends StatelessWidget { + _NavigationBarTransition({ + required this.animation, + required this.topNavBar, + required this.bottomNavBar, + }) : heightTween = Tween( + begin: bottomNavBar.renderBox.size.height, + end: topNavBar.renderBox.size.height, + ); + + final Animation animation; + final _TransitionableNavigationBar topNavBar; + final _TransitionableNavigationBar bottomNavBar; + + final Tween heightTween; + + @override + Widget build(BuildContext context) { + final componentsTransition = _NavigationBarComponentsTransition( + animation: animation, + bottomNavBar: bottomNavBar, + topNavBar: topNavBar, + directionality: Directionality.of(context), + ); + + final children = [ + ?componentsTransition.bottomNavBarBackground, + ?componentsTransition.bottomBackChevron, + ?componentsTransition.bottomBackLabel, + ?componentsTransition.bottomLeading, + ?componentsTransition.bottomMiddle, + ?componentsTransition.bottomLargeTitle, + ?componentsTransition.bottomTrailing, + ?componentsTransition.bottomNavBarBottom, + // Draw top components on top of the bottom components. + ?componentsTransition.topNavBarBackground, + ?componentsTransition.topLeading, + ?componentsTransition.topBackChevron, + ?componentsTransition.topBackLabel, + ?componentsTransition.topMiddle, + ?componentsTransition.topLargeTitle, + ?componentsTransition.topTrailing, + ?componentsTransition.topNavBarBottom, + ]; + + // The text scaling is disabled to avoid odd transitions between pages. + return MediaQuery.withNoTextScaling( + child: SizedBox( + height: math.max(heightTween.begin!, heightTween.end!) + MediaQuery.paddingOf(context).top, + width: double.infinity, + child: Stack(children: children), + ), + ); + } +} + +/// This class helps create widgets that are in transition based on static +/// components from the bottom and top navigation bars. +/// +/// It animates these transitional components both in terms of position and +/// their appearance. +/// +/// Instead of running the transitional components through their normal static +/// navigation bar layout logic, this creates transitional widgets that are based +/// on these widgets' existing render objects' layout and position. +/// +/// This is possible because this widget is only used during Hero transitions +/// where both the from and to routes are already built and laid out. +/// +/// The components' existing layout constraints and positions are then +/// replicated using [Positioned] or [PositionedTransition] wrappers. +/// +/// This class should never return [KeyedSubtree]s created by +/// _NavigationBarStaticComponents directly. Since widgets from +/// _NavigationBarStaticComponents are still present in the widget tree during the +/// hero transitions, it would cause global key duplications. Instead, return +/// only the [KeyedSubtree]s' child. +@immutable +class _NavigationBarComponentsTransition { + _NavigationBarComponentsTransition({ + required this.animation, + required _TransitionableNavigationBar bottomNavBar, + required _TransitionableNavigationBar topNavBar, + required TextDirection directionality, + }) : bottomComponents = bottomNavBar.componentsKeys, + topComponents = topNavBar.componentsKeys, + bottomNavBarBox = bottomNavBar.renderBox, + topNavBarBox = topNavBar.renderBox, + bottomBackButtonTextStyle = bottomNavBar.backButtonTextStyle, + topBackButtonTextStyle = topNavBar.backButtonTextStyle, + bottomTitleTextStyle = bottomNavBar.titleTextStyle, + topTitleTextStyle = topNavBar.titleTextStyle, + bottomLargeTitleTextStyle = bottomNavBar.largeTitleTextStyle, + topLargeTitleTextStyle = topNavBar.largeTitleTextStyle, + bottomHasUserMiddle = bottomNavBar.hasUserMiddle, + topHasUserMiddle = topNavBar.hasUserMiddle, + bottomLargeExpanded = bottomNavBar.largeExpanded, + topLargeExpanded = topNavBar.largeExpanded, + bottomBackgroundColor = bottomNavBar.backgroundColor, + topBackgroundColor = topNavBar.backgroundColor, + bottomBorder = bottomNavBar.border, + topBorder = topNavBar.border, + bottomAutomaticBackgroundVisibility = bottomNavBar.automaticBackgroundVisibility, + userGestureInProgress = + topNavBar.userGestureInProgress || bottomNavBar.userGestureInProgress, + searchable = topNavBar.searchable && bottomNavBar.searchable, + transitionBox = + // paintBounds are based on offset zero so it's ok to expand the Rects. + bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds), + forwardDirection = directionality == TextDirection.ltr ? 1.0 : -1.0; + + static final Animatable fadeOut = Tween(begin: 1.0, end: 0.0); + static final Animatable fadeIn = Tween(begin: 0.0, end: 1.0); + + final Animation animation; + final _NavigationBarStaticComponentsKeys bottomComponents; + final _NavigationBarStaticComponentsKeys topComponents; + + // These render boxes that are the ancestors of all the bottom and top + // components are used to determine the components' relative positions inside + // their respective navigation bars. + final RenderBox bottomNavBarBox; + final RenderBox topNavBarBox; + + final TextStyle bottomBackButtonTextStyle; + final TextStyle topBackButtonTextStyle; + final TextStyle bottomTitleTextStyle; + final TextStyle topTitleTextStyle; + final TextStyle? bottomLargeTitleTextStyle; + final TextStyle? topLargeTitleTextStyle; + + final bool bottomHasUserMiddle; + final bool topHasUserMiddle; + final bool bottomLargeExpanded; + final bool topLargeExpanded; + final bool userGestureInProgress; + final bool searchable; + final bool bottomAutomaticBackgroundVisibility; + + final Color? bottomBackgroundColor; + final Color? topBackgroundColor; + final Border? bottomBorder; + final Border? topBorder; + + // This is the outer box in which all the components will be fitted. The + // sizing component of RelativeRects will be based on this rect's size. + final Rect transitionBox; + + // x-axis unity number representing the direction of growth for text. + final double forwardDirection; + + // Take a widget in its original ancestor navigation bar render box and + // translate it into a RelativeBox in the transition navigation bar box. + RelativeRect positionInTransitionBox(GlobalKey key, {required RenderBox from}) { + final componentBox = key.currentContext!.findRenderObject()! as RenderBox; + assert(componentBox.attached); + + return RelativeRect.fromRect( + componentBox.localToGlobal(Offset.zero, ancestor: from) & componentBox.size, + transitionBox, + ); + } + + // Create an animated widget that moves the given child widget between its + // original position in its ancestor navigation bar to another widget's + // position in that widget's navigation bar. + // + // Anchor their positions based on the vertical middle of their respective + // render boxes' leading edge. + // + // This method assumes there's no other transforms other than translations + // when converting a rect from the original navigation bar's coordinate space + // to the other navigation bar's coordinate space, to avoid performing + // floating point operations on the size of the child widget, so that the + // incoming constraints used for sizing the child widget will be exactly the + // same. + _FixedSizeSlidingTransition slideFromLeadingEdge({ + required GlobalKey fromKey, + required RenderBox fromNavBarBox, + required GlobalKey toKey, + required RenderBox toNavBarBox, + Curve curve = const Interval(0.0, 1.0), + required Widget child, + }) { + final fromBox = fromKey.currentContext!.findRenderObject()! as RenderBox; + final toBox = toKey.currentContext!.findRenderObject()! as RenderBox; + + final bool isLTR = forwardDirection > 0; + + // The animation moves the fromBox so its anchor (left-center or right-center + // depending on the writing direction) aligns with toBox's anchor. + final fromAnchorLocal = Offset(isLTR ? 0 : fromBox.size.width, fromBox.size.height / 2); + final toAnchorLocal = Offset(isLTR ? 0 : toBox.size.width, toBox.size.height / 2); + final Offset fromAnchorInFromBox = fromBox.localToGlobal( + fromAnchorLocal, + ancestor: fromNavBarBox, + ); + final Offset toAnchorInToBox = toBox.localToGlobal(toAnchorLocal, ancestor: toNavBarBox); + + // We can't get ahold of the render box of the stack (i.e., `transitionBox`) + // we place components on yet, but we know the stack needs to be top-leading + // aligned with both fromNavBarBox and toNavBarBox to make the transition + // look smooth. Also use the top-leading point as the origin for ease of + // calculation. + + // The offset to move fromAnchor to toAnchor, in transitionBox's top-leading + // coordinates. + final Offset translation = isLTR + ? toAnchorInToBox - fromAnchorInFromBox + : Offset(toNavBarBox.size.width - toAnchorInToBox.dx, toAnchorInToBox.dy) - + Offset(fromNavBarBox.size.width - fromAnchorInFromBox.dx, fromAnchorInFromBox.dy); + + final RelativeRect fromBoxMargin = positionInTransitionBox(fromKey, from: fromNavBarBox); + final fromOriginInTransitionBox = Offset( + isLTR ? fromBoxMargin.left : fromBoxMargin.right, + fromBoxMargin.top, + ); + + final anchorMovementInTransitionBox = Tween( + begin: fromOriginInTransitionBox, + end: fromOriginInTransitionBox + translation, + ); + + return _FixedSizeSlidingTransition( + isLTR: isLTR, + offsetAnimation: animation + .drive(CurveTween(curve: curve)) + .drive(anchorMovementInTransitionBox), + width: fromNavBarBox.size.width, + height: fromBox.size.height, + child: child, + ); + } + + Animation fadeInFrom(double t, {Curve curve = Curves.easeIn}) { + return animation.drive(fadeIn.chain(CurveTween(curve: Interval(t, 1.0, curve: curve)))); + } + + Animation fadeOutBy(double t, {Curve curve = Curves.easeOut}) { + return animation.drive(fadeOut.chain(CurveTween(curve: Interval(0.0, t, curve: curve)))); + } + + // The parent of the hero animation, which is the route animation. + Animation get routeAnimation { + // The hero animation is a CurvedAnimation. + assert(animation is CurvedAnimation); + return (animation as CurvedAnimation).parent; + } + + Widget? get bottomNavBarBackground { + if (bottomBackgroundColor == null || + (bottomLargeExpanded && bottomAutomaticBackgroundVisibility)) { + return null; + } + final Curve animationCurve = animation.status == AnimationStatus.forward + ? Curves.fastEaseInToSlowEaseOut + : Curves.fastEaseInToSlowEaseOut.flipped; + + final Animation pageTransitionAnimation = routeAnimation.drive( + CurveTween(curve: userGestureInProgress ? Curves.linear : animationCurve), + ); + + final RelativeRect from = positionInTransitionBox( + bottomComponents.navBarBoxKey, + from: bottomNavBarBox, + ); + + final positionTween = RelativeRectTween( + end: from.shift(Offset(forwardDirection * -bottomNavBarBox.size.width, 0.0)), + begin: from, + ); + + return PositionedTransition( + rect: pageTransitionAnimation.drive(positionTween), + child: _wrapWithBackground( + // Don't update the system status bar color mid-flight. + updateSystemUiOverlay: false, + backgroundColor: bottomBackgroundColor!, + border: topBorder, + child: SizedBox(height: bottomNavBarBox.size.height, width: double.infinity), + ), + ); + } + + Widget? get bottomLeading { + final bottomLeading = bottomComponents.leadingKey.currentWidget as KeyedSubtree?; + + if (bottomLeading == null) { + return null; + } + + return Positioned.fromRelativeRect( + rect: positionInTransitionBox(bottomComponents.leadingKey, from: bottomNavBarBox), + child: FadeTransition(opacity: fadeOutBy(0.4), child: bottomLeading.child), + ); + } + + Widget? get bottomBackChevron { + final bottomBackChevron = bottomComponents.backChevronKey.currentWidget as KeyedSubtree?; + + if (bottomBackChevron == null) { + return null; + } + + return Positioned.fromRelativeRect( + rect: positionInTransitionBox(bottomComponents.backChevronKey, from: bottomNavBarBox), + child: FadeTransition( + opacity: fadeOutBy(0.6), + child: DefaultTextStyle(style: bottomBackButtonTextStyle, child: bottomBackChevron.child), + ), + ); + } + + Widget? get bottomBackLabel { + final bottomBackLabel = bottomComponents.backLabelKey.currentWidget as KeyedSubtree?; + + if (bottomBackLabel == null) { + return null; + } + + final RelativeRect from = positionInTransitionBox( + bottomComponents.backLabelKey, + from: bottomNavBarBox, + ); + + // Transition away by sliding horizontally to the leading edge off of the screen. + final positionTween = RelativeRectTween( + begin: from, + end: from.shift(Offset(forwardDirection * (-bottomNavBarBox.size.width / 2.0), 0.0)), + ); + + return PositionedTransition( + rect: animation.drive(positionTween), + child: FadeTransition( + opacity: fadeOutBy(0.2), + child: DefaultTextStyle(style: bottomBackButtonTextStyle, child: bottomBackLabel.child), + ), + ); + } + + Widget? get bottomMiddle { + final bottomMiddle = bottomComponents.middleKey.currentWidget as KeyedSubtree?; + final topBackLabel = topComponents.backLabelKey.currentWidget as KeyedSubtree?; + final topLeading = topComponents.leadingKey.currentWidget as KeyedSubtree?; + + // The middle component is non-null when the nav bar is a large title + // nav bar but would be invisible when expanded, therefore don't show it here. + if (!bottomHasUserMiddle && bottomLargeExpanded) { + return null; + } + + if (bottomMiddle != null && topBackLabel != null) { + // Move from current position to the top page's back label position. + return slideFromLeadingEdge( + fromKey: bottomComponents.middleKey, + fromNavBarBox: bottomNavBarBox, + toKey: topComponents.backLabelKey, + toNavBarBox: topNavBarBox, + child: FadeTransition( + // A custom middle widget like a segmented control fades away faster. + opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7), + child: Align( + // As the text shrinks, make sure it's still anchored to the leading + // edge of a constantly sized outer box. + alignment: AlignmentDirectional.centerStart, + child: DefaultTextStyleTransition( + style: animation.drive( + TextStyleTween(begin: bottomTitleTextStyle, end: topBackButtonTextStyle), + ), + child: bottomMiddle.child, + ), + ), + ), + ); + } + + // When the top page has a leading widget override (one of the few ways to + // not have a top back label), don't move the bottom middle widget and just + // fade. + if (bottomMiddle != null && topLeading != null) { + return Positioned.fromRelativeRect( + rect: positionInTransitionBox(bottomComponents.middleKey, from: bottomNavBarBox), + child: FadeTransition( + opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7), + // Keep the font when transitioning into a non-back label leading. + child: DefaultTextStyle(style: bottomTitleTextStyle, child: bottomMiddle.child), + ), + ); + } + + return null; + } + + Widget? get bottomLargeTitle { + final bottomLargeTitle = bottomComponents.largeTitleKey.currentWidget as KeyedSubtree?; + final topBackLabel = topComponents.backLabelKey.currentWidget as KeyedSubtree?; + + if (bottomLargeTitle == null || !bottomLargeExpanded) { + return null; + } + + if (topBackLabel != null) { + // Move from current position to the top page's back label position. + return slideFromLeadingEdge( + fromKey: bottomComponents.largeTitleKey, + fromNavBarBox: bottomNavBarBox, + toKey: topComponents.backLabelKey, + toNavBarBox: topNavBarBox, + curve: Interval(0.0, animation.status == AnimationStatus.forward ? 0.7 : 1.0), + child: FadeTransition( + opacity: fadeOutBy(0.6), + child: Align( + // As the text shrinks, make sure it's still anchored to the leading + // edge of a constantly sized outer box. + alignment: AlignmentDirectional.centerStart, + child: DefaultTextStyleTransition( + style: animation.drive( + TextStyleTween(begin: bottomLargeTitleTextStyle, end: topBackButtonTextStyle), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: bottomLargeTitle.child, + ), + ), + ), + ); + } + + // Unlike bottom middle, the bottom large title moves when it can't + // transition to the top back label position. + final RelativeRect from = positionInTransitionBox( + bottomComponents.largeTitleKey, + from: bottomNavBarBox, + ); + + final positionTween = RelativeRectTween( + begin: from, + end: from.shift(Offset(forwardDirection * bottomNavBarBox.size.width / 4.0, 0.0)), + ); + + // Just shift slightly towards the trailing edge instead of moving to the + // back label position. + return PositionedTransition( + rect: animation.drive(positionTween), + child: FadeTransition( + opacity: fadeOutBy(0.4), + // Keep the font when transitioning into a non-back-label leading. + child: DefaultTextStyle(style: bottomLargeTitleTextStyle!, child: bottomLargeTitle.child), + ), + ); + } + + Widget? get bottomTrailing { + final bottomTrailing = bottomComponents.trailingKey.currentWidget as KeyedSubtree?; + + if (bottomTrailing == null) { + return null; + } + + return Positioned.fromRelativeRect( + rect: positionInTransitionBox(bottomComponents.trailingKey, from: bottomNavBarBox), + child: FadeTransition(opacity: fadeOutBy(0.6), child: bottomTrailing.child), + ); + } + + Widget? get bottomNavBarBottom { + final bottomNavBarBottom = bottomComponents.navBarBottomKey.currentWidget as KeyedSubtree?; + + if (bottomNavBarBottom == null) { + return null; + } + + final RelativeRect from = positionInTransitionBox( + bottomComponents.navBarBottomKey, + from: bottomNavBarBox, + ); + // Shift in from the leading edge of the screen. + final positionTween = RelativeRectTween( + begin: from, + end: from.shift(Offset(forwardDirection * -bottomNavBarBox.size.width, 0.0)), + ); + + Widget child = bottomNavBarBottom.child; + final Curve animationCurve = animation.status == AnimationStatus.forward + ? _kBottomNavBarHeaderTransitionCurve + : _kBottomNavBarHeaderTransitionCurve.flipped; + + // Fade out only if this is not a CupertinoSliverNavigationBar.search to + // CupertinoSliverNavigationBar.search transition. + if (!searchable) { + child = FadeTransition( + opacity: fadeOutBy(0.8, curve: animationCurve), + child: child, + ); + } + + return PositionedTransition( + rect: + // The bottom widget animates linearly during a backswipe by a user gesture. + userGestureInProgress + ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) + : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), + + child: ClipRect(child: child), + ); + } + + Widget? get topNavBarBackground { + if (topBackgroundColor == null) { + return null; + } + final Curve animationCurve = animation.status == AnimationStatus.forward + ? Curves.fastEaseInToSlowEaseOut + : Curves.fastEaseInToSlowEaseOut.flipped; + + final Animation pageTransitionAnimation = routeAnimation.drive( + CurveTween(curve: userGestureInProgress ? Curves.linear : animationCurve), + ); + + final RelativeRect to = positionInTransitionBox(topComponents.navBarBoxKey, from: topNavBarBox); + + final positionTween = RelativeRectTween( + begin: to.shift(Offset(forwardDirection * topNavBarBox.size.width, 0.0)), + end: to, + ); + + return PositionedTransition( + rect: pageTransitionAnimation.drive(positionTween), + child: _wrapWithBackground( + // Don't update the system status bar color mid-flight. + updateSystemUiOverlay: false, + backgroundColor: topBackgroundColor!, + border: topBorder, + child: SizedBox(height: topNavBarBox.size.height, width: double.infinity), + ), + ); + } + + Widget? get topLeading { + final topLeading = topComponents.leadingKey.currentWidget as KeyedSubtree?; + + if (topLeading == null) { + return null; + } + + return Positioned.fromRelativeRect( + rect: positionInTransitionBox(topComponents.leadingKey, from: topNavBarBox), + child: FadeTransition(opacity: fadeInFrom(0.6), child: topLeading.child), + ); + } + + Widget? get topBackChevron { + final topBackChevron = topComponents.backChevronKey.currentWidget as KeyedSubtree?; + final bottomBackChevron = bottomComponents.backChevronKey.currentWidget as KeyedSubtree?; + + if (topBackChevron == null) { + return null; + } + + final RelativeRect to = positionInTransitionBox( + topComponents.backChevronKey, + from: topNavBarBox, + ); + var from = to; + + Widget child = topBackChevron.child; + // Values eyeballed from an iPhone 15 simulator running iOS 17.5. + const Curve forwardScaleCurve = Interval(0.0, 0.2); + const Curve backwardScaleCurve = Interval(0.8, 1.0); + const Curve forwardPositionCurve = Interval(0.0, 0.5); + const Curve backwardPositionCurve = Interval(0.5, 1.0); + final Curve effectiveScaleCurve; + final Curve effectivePositionCurve; + + if (animation.status == AnimationStatus.forward) { + effectiveScaleCurve = forwardScaleCurve; + effectivePositionCurve = forwardPositionCurve; + } else { + effectiveScaleCurve = backwardScaleCurve; + effectivePositionCurve = backwardPositionCurve; + } + + // If it's the first page with a back chevron, shrink and shift in slightly + // from the right. + if (bottomBackChevron == null) { + final topBackChevronBox = + topComponents.backChevronKey.currentContext!.findRenderObject()! as RenderBox; + from = to.shift(Offset(forwardDirection * topBackChevronBox.size.width * 2.0, 0.0)); + child = ScaleTransition( + scale: routeAnimation.drive(CurveTween(curve: effectiveScaleCurve)), + child: child, + ); + } + + final positionTween = RelativeRectTween(begin: from, end: to); + + return PositionedTransition( + rect: routeAnimation.drive(CurveTween(curve: effectivePositionCurve)).drive(positionTween), + child: FadeTransition( + opacity: routeAnimation.drive( + CurveTween( + curve: Interval( + // Fades faster going back from the first page with a back chevron. + bottomBackChevron == null && animation.status != AnimationStatus.forward ? 0.9 : 0.4, + 1.0, + ), + ), + ), + child: DefaultTextStyle(style: topBackButtonTextStyle, child: child), + ), + ); + } + + Widget? get topBackLabel { + final bottomMiddle = bottomComponents.middleKey.currentWidget as KeyedSubtree?; + final bottomLargeTitle = bottomComponents.largeTitleKey.currentWidget as KeyedSubtree?; + final topBackLabel = topComponents.backLabelKey.currentWidget as KeyedSubtree?; + + if (topBackLabel == null) { + return null; + } + + final RenderAnimatedOpacity? topBackLabelOpacity = topComponents.backLabelKey.currentContext + ?.findAncestorRenderObjectOfType(); + + Animation? midClickOpacity; + if (topBackLabelOpacity != null && topBackLabelOpacity.opacity.value < 1.0) { + midClickOpacity = animation.drive( + Tween(begin: 0.0, end: topBackLabelOpacity.opacity.value), + ); + } + + // Pick up from an incoming transition from the large title. This is + // duplicated here from the bottomLargeTitle transition widget because the + // content text might be different. For instance, if the bottomLargeTitle + // text is too long, the topBackLabel will say 'Back' instead of the original + // text. + if (bottomLargeTitle != null && bottomLargeExpanded) { + return slideFromLeadingEdge( + fromKey: bottomComponents.largeTitleKey, + fromNavBarBox: bottomNavBarBox, + toKey: topComponents.backLabelKey, + toNavBarBox: topNavBarBox, + curve: Interval(0.0, animation.status == AnimationStatus.forward ? 0.7 : 1.0), + child: FadeTransition( + opacity: midClickOpacity ?? fadeInFrom(0.4), + child: DefaultTextStyleTransition( + style: animation.drive( + TextStyleTween(begin: bottomLargeTitleTextStyle, end: topBackButtonTextStyle), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: topBackLabel.child, + ), + ), + ); + } + + // The topBackLabel always comes from the large title first if available + // and expanded instead of middle. + if (bottomMiddle != null) { + return slideFromLeadingEdge( + fromKey: bottomComponents.middleKey, + fromNavBarBox: bottomNavBarBox, + toKey: topComponents.backLabelKey, + toNavBarBox: topNavBarBox, + child: FadeTransition( + opacity: midClickOpacity ?? fadeInFrom(0.3), + child: DefaultTextStyleTransition( + style: animation.drive( + TextStyleTween(begin: bottomTitleTextStyle, end: topBackButtonTextStyle), + ), + child: topBackLabel.child, + ), + ), + ); + } + + return null; + } + + Widget? get topMiddle { + final topMiddle = topComponents.middleKey.currentWidget as KeyedSubtree?; + + if (topMiddle == null) { + return null; + } + + // The middle component is non-null when the nav bar is a large title + // nav bar but would be invisible when expanded, therefore don't show it here. + if (!topHasUserMiddle && topLargeExpanded) { + return null; + } + + final RelativeRect to = positionInTransitionBox(topComponents.middleKey, from: topNavBarBox); + final toBox = topComponents.middleKey.currentContext!.findRenderObject()! as RenderBox; + + final bool isLTR = forwardDirection > 0; + + // Anchor is the top-leading point of toBox, in transition box's top-leading + // coordinate space. + final toAnchorInTransitionBox = Offset(isLTR ? to.left : to.right, to.top); + + // Shift in from the trailing edge of the screen. + final anchorMovementInTransitionBox = Tween( + begin: Offset( + // the "width / 2" here makes the middle widget's horizontal center on + // the trailing edge of the top nav bar. + topNavBarBox.size.width - toBox.size.width / 2, + to.top, + ), + end: toAnchorInTransitionBox, + ); + + return _FixedSizeSlidingTransition( + isLTR: isLTR, + offsetAnimation: animation.drive(anchorMovementInTransitionBox), + width: toBox.size.width, + height: toBox.size.height, + child: FadeTransition( + opacity: fadeInFrom(0.25), + child: DefaultTextStyle(style: topTitleTextStyle, child: topMiddle.child), + ), + ); + } + + Widget? get topTrailing { + final topTrailing = topComponents.trailingKey.currentWidget as KeyedSubtree?; + + if (topTrailing == null) { + return null; + } + + return Positioned.fromRelativeRect( + rect: positionInTransitionBox(topComponents.trailingKey, from: topNavBarBox), + child: FadeTransition(opacity: fadeInFrom(0.4), child: topTrailing.child), + ); + } + + Widget? get topLargeTitle { + final topLargeTitle = topComponents.largeTitleKey.currentWidget as KeyedSubtree?; + + if (topLargeTitle == null || !topLargeExpanded) { + return null; + } + + final RelativeRect to = positionInTransitionBox( + topComponents.largeTitleKey, + from: topNavBarBox, + ); + + // Shift in from the trailing edge of the screen. + final positionTween = RelativeRectTween( + begin: to.shift(Offset(forwardDirection * topNavBarBox.size.width, 0.0)), + end: to, + ); + + final Curve animationCurve = animation.status == AnimationStatus.forward + ? _kTopNavBarHeaderTransitionCurve + : _kTopNavBarHeaderTransitionCurve.flipped; + + return PositionedTransition( + rect: + // The large title animates linearly during a backswipe by a user gesture. + userGestureInProgress + ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) + : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), + child: FadeTransition( + opacity: fadeInFrom(0.0, curve: animationCurve), + child: DefaultTextStyle( + style: topLargeTitleTextStyle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: topLargeTitle.child, + ), + ), + ); + } + + Widget? get topNavBarBottom { + final topNavBarBottom = topComponents.navBarBottomKey.currentWidget as KeyedSubtree?; + + if (topNavBarBottom == null) { + return null; + } + + final RelativeRect to = positionInTransitionBox( + topComponents.navBarBottomKey, + from: topNavBarBox, + ); + // Shift in from the trailing edge of the screen. + final positionTween = RelativeRectTween( + begin: to.shift(Offset(forwardDirection * topNavBarBox.size.width, 0.0)), + end: to, + ); + + Widget child = topNavBarBottom.child; + + final Curve animationCurve = animation.status == AnimationStatus.forward + ? _kTopNavBarHeaderTransitionCurve + : _kTopNavBarHeaderTransitionCurve.flipped; + + // Fade in only if this is not a CupertinoSliverNavigationBar.search to + // CupertinoSliverNavigationBar.search transition. + if (!searchable) { + child = FadeTransition( + opacity: fadeInFrom(0.0, curve: animationCurve), + child: child, + ); + } + + return PositionedTransition( + rect: + // The bottom widget animates linearly during a backswipe by a user gesture. + userGestureInProgress + ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) + : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), + child: ClipRect(child: child), + ); + } +} + +/// Navigation bars' hero rect tween that will move between the static bars +/// but keep a constant size that's the bigger of both navigation bars. +RectTween _linearTranslateWithLargestRectSizeTween(Rect? begin, Rect? end) { + final largestSize = Size( + math.max(begin!.size.width, end!.size.width), + math.max(begin.size.height, end.size.height), + ); + return RectTween(begin: begin.topLeft & largestSize, end: end.topLeft & largestSize); +} + +Widget _navBarHeroLaunchPadBuilder(BuildContext context, Size heroSize, Widget child) { + assert(child is _TransitionableNavigationBar); + // Tree reshaping is fine here because the Heroes' child is always a + // _TransitionableNavigationBar which has a GlobalKey. + + // Keeping the Hero subtree here is needed (instead of just swapping out the + // anchor nav bars for fixed size boxes during flights) because the nav bar + // and their specific component children may serve as anchor points again if + // another mid-transition flight diversion is triggered. + + // This is ok performance-wise because static nav bars are generally cheap to + // build and layout but expensive to GPU render (due to clips and blurs) which + // we're skipping here. + return Visibility( + maintainSize: true, + maintainAnimation: true, + maintainState: true, + visible: false, + child: child, + ); +} + +/// Navigation bars' hero flight shuttle builder. +Widget _navBarHeroFlightShuttleBuilder( + BuildContext flightContext, + Animation animation, + HeroFlightDirection flightDirection, + BuildContext fromHeroContext, + BuildContext toHeroContext, +) { + assert(fromHeroContext.widget is Hero); + assert(toHeroContext.widget is Hero); + + final fromHeroWidget = fromHeroContext.widget as Hero; + final toHeroWidget = toHeroContext.widget as Hero; + + assert(fromHeroWidget.child is _TransitionableNavigationBar); + assert(toHeroWidget.child is _TransitionableNavigationBar); + + final fromNavBar = fromHeroWidget.child as _TransitionableNavigationBar; + final toNavBar = toHeroWidget.child as _TransitionableNavigationBar; + + assert( + fromNavBar.componentsKeys.navBarBoxKey.currentContext!.owner != null, + 'The from nav bar to Hero must have been mounted in the previous frame', + ); + assert( + toNavBar.componentsKeys.navBarBoxKey.currentContext!.owner != null, + 'The to nav bar to Hero must have been mounted in the previous frame', + ); + + switch (flightDirection) { + case HeroFlightDirection.push: + return _NavigationBarTransition( + animation: animation, + bottomNavBar: fromNavBar, + topNavBar: toNavBar, + ); + case HeroFlightDirection.pop: + return _NavigationBarTransition( + animation: animation, + bottomNavBar: toNavBar, + topNavBar: fromNavBar, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/page_scaffold.dart b/packages/cupertino_ui/lib/src/page_scaffold.dart new file mode 100644 index 000000000000..e96233cb64bc --- /dev/null +++ b/packages/cupertino_ui/lib/src/page_scaffold.dart @@ -0,0 +1,317 @@ +// 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. + +/// @docImport 'button.dart'; +/// @docImport 'nav_bar.dart'; +/// @docImport 'route.dart'; +/// @docImport 'tab_scaffold.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +/// Implements a single iOS application page's layout. +/// +/// The scaffold lays out the navigation bar on top and the content between or +/// behind the navigation bar. +/// +/// When tapping a status bar at the top of the CupertinoPageScaffold, an +/// animation will complete for the current primary [ScrollView], scrolling to +/// the beginning. This is done using the [PrimaryScrollController] that +/// encloses the [ScrollView]. The [ScrollView.primary] flag is used to connect +/// a [ScrollView] to the enclosing [PrimaryScrollController]. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoPageScaffold] with a [Center] as a [child]. +/// The [CupertinoButton] is connected to a callback that increments a counter. +/// The [backgroundColor] can be changed. +/// +/// ** See code in examples/api/lib/cupertino/page_scaffold/cupertino_page_scaffold.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoTabScaffold], a similar widget for tabbed applications. +/// * [CupertinoPageRoute], a modal page route that typically hosts a +/// [CupertinoPageScaffold] with support for iOS-style page transitions. +class CupertinoPageScaffold extends StatefulWidget { + /// Creates a layout for pages with a navigation bar at the top. + const CupertinoPageScaffold({ + super.key, + this.navigationBar, + this.backgroundColor, + this.resizeToAvoidBottomInset = true, + required this.child, + }); + + /// The [navigationBar], typically a [CupertinoNavigationBar], is drawn at the + /// top of the screen. + /// + /// If translucent, the main content may slide behind it. + /// Otherwise, the main content's top margin will be offset by its height. + /// + /// The scaffold assumes the navigation bar will account for the [MediaQuery] + /// top padding, also consume it if the navigation bar is opaque. + /// + /// By default [navigationBar] disables text scaling to match the native iOS + /// behavior. To override such behavior, wrap each of the [navigationBar]'s + /// components inside a [MediaQuery] with the desired [TextScaler]. + // TODO(xster): document its page transition animation when ready + final ObstructingPreferredSizeWidget? navigationBar; + + /// Widget to show in the main content area. + /// + /// Content can slide under the [navigationBar] when they're translucent. + /// In that case, the child's [BuildContext]'s [MediaQuery] will have a + /// top padding indicating the area of obstructing overlap from the + /// [navigationBar]. + final Widget child; + + /// The color of the widget that underlies the entire scaffold. + /// + /// By default uses [CupertinoTheme]'s `scaffoldBackgroundColor` when null. + final Color? backgroundColor; + + /// Whether the [child] should size itself to avoid the window's bottom inset. + /// + /// For example, if there is an onscreen keyboard displayed above the + /// scaffold, the body can be resized to avoid overlapping the keyboard, which + /// prevents widgets inside the body from being obscured by the keyboard. + /// + /// Defaults to true. + final bool resizeToAvoidBottomInset; + + @override + State createState() => _CupertinoPageScaffoldState(); +} + +class _CupertinoPageScaffoldState extends State with WidgetsBindingObserver { + final GlobalKey _statusBarKey = GlobalKey(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void deactivate() { + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void handleStatusBarTap() { + super.handleStatusBarTap(); + final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context); + if (primaryScrollController != null && + primaryScrollController.hasClients && + // TODO(LongCatIsLooong): the iOS embedder used to send status bar tap + // evets as fake touches at Offset.zero, such that at most one Scaffold + // (usually the foreground CupertinoPageScaffold) can handle the status + // bar tap event, thanks to hit-testing and gesture disambiguation. + // To keep that behavior, this widget performs an additional hit-test here + // to make sure the status bar tap is only handled if this scaffold is + // hit-testable (thus in the foreground). + // Switch to a better solution when available: + // https://github.com/flutter/flutter/issues/182403 + _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) { + primaryScrollController.animateTo( + 0.0, + // Eyeballed from iOS. + duration: const Duration(milliseconds: 500), + curve: Curves.linearToEaseOut, + ); + } + } + + @override + Widget build(BuildContext context) { + Widget paddedContent = widget.child; + + final Color backgroundColor = + CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ?? + CupertinoTheme.of(context).scaffoldBackgroundColor; + + final MediaQueryData existingMediaQuery = MediaQuery.of(context); + if (widget.navigationBar != null) { + // TODO(xster): Use real size after partial layout instead of preferred size. + // https://github.com/flutter/flutter/issues/12912 + final double topPadding = + widget.navigationBar!.preferredSize.height + existingMediaQuery.padding.top; + + // Propagate bottom padding and include viewInsets if appropriate + final double bottomPadding = widget.resizeToAvoidBottomInset + ? existingMediaQuery.viewInsets.bottom + : 0.0; + + final EdgeInsets newViewInsets = widget.resizeToAvoidBottomInset + // The insets are consumed by the scaffolds and no longer exposed to + // the descendant subtree. + ? existingMediaQuery.viewInsets.copyWith(bottom: 0.0) + : existingMediaQuery.viewInsets; + + final bool fullObstruction = widget.navigationBar!.shouldFullyObstruct(context); + + // If navigation bar is opaquely obstructing, directly shift the main content + // down. If translucent, let main content draw behind navigation bar but hint the + // obstructed area. + if (fullObstruction) { + paddedContent = MediaQuery( + data: existingMediaQuery + // If the navigation bar is opaque, the top media query padding is fully consumed by the navigation bar. + .removePadding(removeTop: true) + .copyWith(viewInsets: newViewInsets), + child: Padding( + padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), + child: paddedContent, + ), + ); + } else { + paddedContent = MediaQuery( + data: existingMediaQuery.copyWith( + padding: existingMediaQuery.padding.copyWith(top: topPadding), + viewInsets: newViewInsets, + ), + child: Padding( + padding: EdgeInsets.only(bottom: bottomPadding), + child: paddedContent, + ), + ); + } + } else if (widget.resizeToAvoidBottomInset) { + // If there is no navigation bar, still may need to add padding in order + // to support resizeToAvoidBottomInset. + paddedContent = MediaQuery( + data: existingMediaQuery.copyWith( + viewInsets: existingMediaQuery.viewInsets.copyWith(bottom: 0), + ), + child: Padding( + padding: EdgeInsets.only(bottom: existingMediaQuery.viewInsets.bottom), + child: paddedContent, + ), + ); + } + + return ScrollNotificationObserver( + child: DecoratedBox( + decoration: BoxDecoration(color: backgroundColor), + child: CupertinoPageScaffoldBackgroundColor( + color: backgroundColor, + child: Stack( + children: [ + // The main content being at the bottom is added to the stack first. + paddedContent, + if (widget.navigationBar != null) + Positioned( + top: 0.0, + left: 0.0, + right: 0.0, + child: MediaQuery.withNoTextScaling(child: widget.navigationBar!), + ), + // Add a touch handler the size of the status bar on top of all contents + // to handle scroll to top by status bar taps. + Positioned( + top: 0.0, + left: 0.0, + right: 0.0, + height: existingMediaQuery.padding.top, + child: _HitTestableAtOrigin(_statusBarKey), + ), + ], + ), + ), + ), + ); + } +} + +/// [InheritedWidget] indicating what the current scaffold background color is for its children. +/// +/// This is used by the [CupertinoNavigationBar] and the [CupertinoSliverNavigationBar] widgets +/// to paint themselves with the parent page scaffold color when no content is scrolled under. +class CupertinoPageScaffoldBackgroundColor extends InheritedWidget { + /// Constructs a new [CupertinoPageScaffoldBackgroundColor]. + const CupertinoPageScaffoldBackgroundColor({ + required super.child, + required this.color, + super.key, + }); + + /// The background color defined in [CupertinoPageScaffold]. + final Color color; + + @override + bool updateShouldNotify(CupertinoPageScaffoldBackgroundColor oldWidget) { + return color != oldWidget.color; + } + + /// Retrieve the [CupertinoPageScaffold] background color from the context. + static Color? maybeOf(BuildContext context) { + final CupertinoPageScaffoldBackgroundColor? scaffoldBackgroundColor = context + .dependOnInheritedWidgetOfExactType(); + return scaffoldBackgroundColor?.color; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('page scaffold background color', color)); + } +} + +/// Widget that has a preferred size and reports whether it fully obstructs +/// widgets behind it. +/// +/// Used by [CupertinoPageScaffold] to either shift away fully obstructed content +/// or provide a padding guide to partially obstructed content. +abstract class ObstructingPreferredSizeWidget implements PreferredSizeWidget { + /// If true, this widget fully obstructs widgets behind it by the specified + /// size. + /// + /// If false, this widget partially obstructs. + bool shouldFullyObstruct(BuildContext context); +} + +final class _HitTestableAtOrigin extends StatelessWidget { + const _HitTestableAtOrigin(this.globalKey); + + final GlobalKey globalKey; + + /// Whether the render box of the [_HitTestableAtOrigin] widget associated + /// with the given global `key` is hit-testable at [Offset.zero]. + /// + /// This is used by the `handleStatusBarTap` implementation to avoid sending + /// status bar tap events to scroll views in offscreen subtrees. + static bool hitTestableAtOrigin(GlobalKey key) { + final context = key.currentContext as Element?; + if (context == null) { + assert(false, 'BuildContext associated with $key is not mounted.'); + return false; + } + final renderObject = context.renderObject! as RenderMetaData; + final int viewId = View.of(context).viewId; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId); + return result.path.any((HitTestEntry entry) => entry.target == renderObject); + } + + @override + Widget build(BuildContext context) { + return MetaData( + key: globalKey, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/picker.dart b/packages/cupertino_ui/lib/src/picker.dart new file mode 100644 index 000000000000..399fc01f690e --- /dev/null +++ b/packages/cupertino_ui/lib/src/picker.dart @@ -0,0 +1,632 @@ +// 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. + +/// @docImport 'route.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +// Eyeballed values comparing with a native picker to produce the right +// curvatures and densities. +const double _kDefaultDiameterRatio = 1.07; +const double _kDefaultPerspective = 0.003; +const double _kSqueeze = 1.45; + +// Opacity fraction value that dims the wheel above and below the "magnifier" +// lens. +const double _kOverAndUnderCenterOpacity = 0.447; + +// The duration and curve of the tap-to-scroll gesture's animation when a picker +// item is tapped. +// +// Eyeballed from an iPhone 15 Pro simulator running iOS 17.5. +const Duration _kCupertinoPickerTapToScrollDuration = Duration(milliseconds: 300); +const Curve _kCupertinoPickerTapToScrollCurve = Curves.easeInOut; + +/// An iOS-styled picker. +/// +/// Displays its children widgets on a wheel for selection and +/// calls back when the currently selected item changes. +/// +/// By default, the first child in `children` will be the initially selected child. +/// The index of a different child can be specified in [scrollController], to make +/// that child the initially selected child. +/// +/// Can be used with [showCupertinoModalPopup] to display the picker modally at the +/// bottom of the screen. When calling [showCupertinoModalPopup], be sure to set +/// `semanticsDismissible` to true to enable dismissing the modal via semantics. +/// +/// Sizes itself to its parent. All children are sized to the same size based +/// on [itemExtent]. +/// +/// By default, descendent texts are shown with [CupertinoTextThemeData.pickerTextStyle]. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoPicker] that displays a list of fruits on a wheel for +/// selection. +/// +/// ** See code in examples/api/lib/cupertino/picker/cupertino_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListWheelScrollView], the generic widget backing this picker without +/// the iOS design specific chrome. +/// * +class CupertinoPicker extends StatefulWidget { + /// Creates a picker from a concrete list of children. + /// + /// The [itemExtent] must be greater than zero. + /// + /// The [backgroundColor] defaults to null, which disables background painting entirely. + /// (i.e. the picker is going to have a completely transparent background), to match + /// the native UIPicker and UIDatePicker. Also, if it has transparency, no gradient + /// effect will be rendered. + /// + /// The [scrollController] argument can be used to specify a custom + /// [FixedExtentScrollController] for programmatically reading or changing + /// the current picker index or for selecting an initial index value. + /// + /// The [looping] argument decides whether the child list loops and can be + /// scrolled infinitely. If set to true, scrolling past the end of the list + /// will loop the list back to the beginning. If set to false, the list will + /// stop scrolling when you reach the end or the beginning. + CupertinoPicker({ + super.key, + this.diameterRatio = _kDefaultDiameterRatio, + this.backgroundColor, + this.offAxisFraction = 0.0, + this.useMagnifier = false, + this.magnification = 1.0, + this.scrollController, + this.squeeze = _kSqueeze, + this.changeReportingBehavior = ChangeReportingBehavior.onScrollUpdate, + required this.itemExtent, + required this.onSelectedItemChanged, + required List children, + this.selectionOverlay = const CupertinoPickerDefaultSelectionOverlay(), + bool looping = false, + }) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage), + assert(magnification > 0), + assert(itemExtent > 0), + assert(squeeze > 0), + childDelegate = looping + ? ListWheelChildLoopingListDelegate(children: children) + : ListWheelChildListDelegate(children: children); + + /// Creates a picker from an [IndexedWidgetBuilder] callback where the builder + /// is dynamically invoked during layout. + /// + /// A child is lazily created when it starts becoming visible in the viewport. + /// All of the children provided by the builder are cached and reused, so + /// normally the builder is only called once for each index (except when + /// rebuilding - the cache is cleared). + /// + /// The [childCount] argument reflects the number of children that will be + /// provided by the [itemBuilder]. + /// {@macro flutter.widgets.ListWheelChildBuilderDelegate.childCount} + /// + /// The [itemExtent] argument must be positive. + /// + /// The [backgroundColor] defaults to null, which disables background painting entirely. + /// (i.e. the picker is going to have a completely transparent background), to match + /// the native UIPicker and UIDatePicker. + CupertinoPicker.builder({ + super.key, + this.diameterRatio = _kDefaultDiameterRatio, + this.backgroundColor, + this.offAxisFraction = 0.0, + this.useMagnifier = false, + this.magnification = 1.0, + this.scrollController, + this.squeeze = _kSqueeze, + this.changeReportingBehavior = ChangeReportingBehavior.onScrollUpdate, + required this.itemExtent, + required this.onSelectedItemChanged, + required NullableIndexedWidgetBuilder itemBuilder, + int? childCount, + this.selectionOverlay = const CupertinoPickerDefaultSelectionOverlay(), + }) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage), + assert(magnification > 0), + assert(itemExtent > 0), + assert(squeeze > 0), + childDelegate = ListWheelChildBuilderDelegate(builder: itemBuilder, childCount: childCount); + + /// Relative ratio between this picker's height and the simulated cylinder's diameter. + /// + /// Smaller values creates more pronounced curvatures in the scrollable wheel. + /// + /// For more details, see [ListWheelScrollView.diameterRatio]. + /// + /// Defaults to 1.1 to visually mimic iOS. + final double diameterRatio; + + /// Background color behind the children. + /// + /// Defaults to null, which disables background painting entirely. + /// (i.e. the picker is going to have a completely transparent background), to match + /// the native UIPicker and UIDatePicker. + /// + /// Any alpha value less 255 (fully opaque) will cause the removal of the + /// wheel list edge fade gradient from rendering of the widget. + final Color? backgroundColor; + + /// {@macro flutter.rendering.RenderListWheelViewport.offAxisFraction} + final double offAxisFraction; + + /// {@macro flutter.rendering.RenderListWheelViewport.useMagnifier} + final bool useMagnifier; + + /// {@macro flutter.rendering.RenderListWheelViewport.magnification} + final double magnification; + + /// A [FixedExtentScrollController] to read and control the current item, and + /// to set the initial item. + /// + /// If null, an implicit one will be created internally. + final FixedExtentScrollController? scrollController; + + /// {@template flutter.cupertino.picker.itemExtent} + /// The uniform height of all children. + /// + /// All children will be given the [BoxConstraints] to match this exact + /// height. Must be a positive value. + /// {@endtemplate} + final double itemExtent; + + /// {@macro flutter.rendering.RenderListWheelViewport.squeeze} + /// + /// Defaults to `1.45` to visually mimic iOS. + final double squeeze; + + /// The behavior of reporting the selected item index. + /// + /// This determines when the [onSelectedItemChanged] callback is called. + /// + /// Native iOS 18 behavior is [ChangeReportingBehavior.onScrollEnd], which + /// calls the callback only when the scrolling stops. + /// + /// Defaults to [ChangeReportingBehavior.onScrollUpdate]. + final ChangeReportingBehavior changeReportingBehavior; + + /// Called when the selected item changes. + /// + /// The timing of this callback is controlled by [changeReportingBehavior]. + final ValueChanged? onSelectedItemChanged; + + /// A delegate that lazily instantiates children. + final ListWheelChildDelegate childDelegate; + + /// A widget overlaid on the picker to highlight the currently selected entry. + /// + /// The [selectionOverlay] widget drawn above the [CupertinoPicker]'s picker + /// wheel. + /// It is vertically centered in the picker and is constrained to have the + /// same height as the center row. + /// + /// If unspecified, it defaults to a [CupertinoPickerDefaultSelectionOverlay] + /// which is a gray rounded rectangle overlay in iOS 14 style. + /// This property can be set to null to remove the overlay. + final Widget? selectionOverlay; + + @override + State createState() => _CupertinoPickerState(); +} + +class _CupertinoPickerState extends State { + // Initial value set to skip haptic feedback for the first item. + late int _lastHapticIndex = _effectiveController.initialItem; + int? _lastMiddlePosition; + FixedExtentScrollController? _controller; + bool _enableHapticFeedback = true; + + FixedExtentScrollController get _effectiveController => widget.scrollController ?? _controller!; + + @override + void initState() { + super.initState(); + if (widget.scrollController == null) { + _controller = FixedExtentScrollController(); + } + + _effectiveController.addListener(_handleScroll); + } + + @override + void didUpdateWidget(CupertinoPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.scrollController != null && oldWidget.scrollController == null) { + _controller?.dispose(); + _controller = null; + widget.scrollController!.addListener(_handleScroll); + } else if (widget.scrollController == null && oldWidget.scrollController != null) { + assert(_controller == null); + oldWidget.scrollController!.removeListener(_handleScroll); + _controller = FixedExtentScrollController(); + _controller!.addListener(_handleScroll); + } + } + + @override + void dispose() { + _controller?.dispose(); + if (widget.scrollController != null) { + widget.scrollController!.removeListener(_handleScroll); + } + super.dispose(); + } + + void _handleHapticFeedback(int index) { + if (!_enableHapticFeedback) { + return; + } + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + if (index != _lastHapticIndex) { + _lastHapticIndex = index; + HapticFeedback.selectionClick(); + SystemSound.play(SystemSoundType.tick); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // No haptic feedback on these platforms. + return; + } + } + + void _handleScroll() { + final int index = _effectiveController.selectedItem; + + // This switches from the current index to the next index + // when we pass the middle of the item. + final double fractionalOffset = _effectiveController.offset / widget.itemExtent; + final int currentPosition = fractionalOffset.floor(); + + final double currentItemOffset = fractionalOffset - index; + // Check that we either passed in the middle of the new item or + // that we are very close. + // The second check is to avoid the rounding error when the + // scroll position is very close to the middle of the item, + // but not exactly in the middle. + if (currentPosition != _lastMiddlePosition || currentItemOffset.abs() <= 0.1) { + // Middle is checked with currentPosition, but we pass the real index + // to avoid multiple haptics for the same item and to avoid + // calling haptic feedback when overscrolling. + _handleHapticFeedback(index); + } + + _lastMiddlePosition = currentPosition; + } + + Future _handleChildTap(int index) async { + _enableHapticFeedback = false; + await _effectiveController.animateToItem( + index, + duration: _kCupertinoPickerTapToScrollDuration, + curve: _kCupertinoPickerTapToScrollCurve, + ); + _enableHapticFeedback = true; + _lastHapticIndex = _effectiveController.selectedItem; + } + + /// Draws the selectionOverlay. + Widget _buildSelectionOverlay(Widget selectionOverlay) { + final double height = widget.itemExtent * widget.magnification; + + return IgnorePointer( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints.expand(height: height), + child: selectionOverlay, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final TextStyle textStyle = CupertinoTheme.of(context).textTheme.pickerTextStyle; + final Color? resolvedBackgroundColor = CupertinoDynamicColor.maybeResolve( + widget.backgroundColor, + context, + ); + + assert(RenderListWheelViewport.defaultPerspective == _kDefaultPerspective); + final Widget result = DefaultTextStyle( + style: textStyle.copyWith( + color: CupertinoDynamicColor.maybeResolve(textStyle.color, context), + ), + child: Stack( + children: [ + Positioned.fill( + child: _CupertinoPickerSemantics( + scrollController: _effectiveController, + child: ListWheelScrollView.useDelegate( + controller: _effectiveController, + physics: const FixedExtentScrollPhysics(), + diameterRatio: widget.diameterRatio, + offAxisFraction: widget.offAxisFraction, + useMagnifier: widget.useMagnifier, + magnification: widget.magnification, + overAndUnderCenterOpacity: _kOverAndUnderCenterOpacity, + itemExtent: widget.itemExtent, + squeeze: widget.squeeze, + onSelectedItemChanged: widget.onSelectedItemChanged, + dragStartBehavior: DragStartBehavior.down, + changeReportingBehavior: widget.changeReportingBehavior, + childDelegate: _CupertinoPickerListWheelChildDelegateWrapper( + widget.childDelegate, + onTappedChild: _handleChildTap, + ), + ), + ), + ), + if (widget.selectionOverlay != null) _buildSelectionOverlay(widget.selectionOverlay!), + ], + ), + ); + + return DecoratedBox( + decoration: BoxDecoration(color: resolvedBackgroundColor), + child: result, + ); + } +} + +/// A default selection overlay for [CupertinoPicker]s. +/// +/// It draws a gray rounded rectangle to match the picker visuals introduced in +/// iOS 14. +/// +/// This widget is typically only used in [CupertinoPicker.selectionOverlay]. +/// In an iOS 14 multi-column picker, the selection overlay is a single rounded +/// rectangle that spans the entire multi-column picker. +/// To achieve the same effect using [CupertinoPickerDefaultSelectionOverlay], +/// the additional margin and corner radii on the left or the right side can be +/// disabled by turning off [capStartEdge] and [capEndEdge], so this selection +/// overlay visually connects with selection overlays of adjoining +/// [CupertinoPicker]s (i.e., other "column"s). +/// +/// See also: +/// +/// * [CupertinoPicker], which uses this widget as its default [CupertinoPicker.selectionOverlay]. +class CupertinoPickerDefaultSelectionOverlay extends StatelessWidget { + /// Creates an iOS 14 style selection overlay that highlights the magnified + /// area (or the currently selected item, depending on how you described it + /// elsewhere) of a [CupertinoPicker]. + /// + /// The [background] argument default value is + /// [CupertinoColors.tertiarySystemFill]. + /// + /// The [capStartEdge] and [capEndEdge] arguments decide whether to add a + /// default margin and use rounded corners on the left and right side of the + /// rectangular overlay, and they both default to true. + const CupertinoPickerDefaultSelectionOverlay({ + super.key, + this.background = CupertinoColors.tertiarySystemFill, + this.capStartEdge = true, + this.capEndEdge = true, + }); + + /// Whether to use the default use rounded corners and margin on the start side. + final bool capStartEdge; + + /// Whether to use the default use rounded corners and margin on the end side. + final bool capEndEdge; + + /// The color to fill in the background of the [CupertinoPickerDefaultSelectionOverlay]. + /// It Support for use [CupertinoDynamicColor]. + /// + /// Typically this should not be set to a fully opaque color, as the currently + /// selected item of the underlying [CupertinoPicker] should remain visible. + /// Defaults to [CupertinoColors.tertiarySystemFill]. + final Color background; + + /// Default margin of the 'SelectionOverlay'. + static const double _defaultSelectionOverlayHorizontalMargin = 9; + + /// Default radius of the 'SelectionOverlay'. + static const double _defaultSelectionOverlayRadius = 8; + + @override + Widget build(BuildContext context) { + const radius = Radius.circular(_defaultSelectionOverlayRadius); + + return Container( + margin: EdgeInsetsDirectional.only( + start: capStartEdge ? _defaultSelectionOverlayHorizontalMargin : 0, + end: capEndEdge ? _defaultSelectionOverlayHorizontalMargin : 0, + ), + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadiusDirectional.horizontal( + start: capStartEdge ? radius : Radius.zero, + end: capEndEdge ? radius : Radius.zero, + ), + ), + color: CupertinoDynamicColor.resolve(background, context), + ), + ); + } +} + +// Turns the scroll semantics of the ListView into a single adjustable semantics +// node. This is done by removing all of the child semantics of the scroll +// wheel and using the scroll indexes to look up the current, previous, and +// next semantic label. This label is then turned into the value of a new +// adjustable semantic node, with adjustment callbacks wired to move the +// scroll controller. +class _CupertinoPickerSemantics extends SingleChildRenderObjectWidget { + const _CupertinoPickerSemantics({super.child, required this.scrollController}); + + final FixedExtentScrollController scrollController; + + @override + RenderObject createRenderObject(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + return _RenderCupertinoPickerSemantics(scrollController, Directionality.of(context)); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderCupertinoPickerSemantics renderObject, + ) { + assert(debugCheckHasDirectionality(context)); + renderObject + ..textDirection = Directionality.of(context) + ..controller = scrollController; + } +} + +class _RenderCupertinoPickerSemantics extends RenderProxyBox { + _RenderCupertinoPickerSemantics(FixedExtentScrollController controller, this._textDirection) { + _updateController(null, controller); + } + + FixedExtentScrollController get controller => _controller; + late FixedExtentScrollController _controller; + set controller(FixedExtentScrollController value) => _updateController(_controller, value); + + // This method exists to allow controller to be non-null. It is only called with a null oldValue from constructor. + void _updateController(FixedExtentScrollController? oldValue, FixedExtentScrollController value) { + if (value == oldValue) { + return; + } + if (oldValue != null) { + oldValue.removeListener(_handleScrollUpdate); + } else { + _currentIndex = value.initialItem; + } + value.addListener(_handleScrollUpdate); + _controller = value; + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (textDirection == value) { + return; + } + _textDirection = value; + markNeedsSemanticsUpdate(); + } + + int _currentIndex = 0; + + void _handleIncrease() { + controller.jumpToItem(_currentIndex + 1); + } + + void _handleDecrease() { + controller.jumpToItem(_currentIndex - 1); + } + + void _handleScrollUpdate() { + if (controller.selectedItem == _currentIndex) { + return; + } + _currentIndex = controller.selectedItem; + markNeedsSemanticsUpdate(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + config.isSemanticBoundary = true; + config.textDirection = textDirection; + } + + @override + void assembleSemanticsNode( + SemanticsNode node, + SemanticsConfiguration config, + Iterable children, + ) { + if (children.isEmpty) { + return super.assembleSemanticsNode(node, config, children); + } + final SemanticsNode scrollable = children.first; + final indexedChildren = {}; + scrollable.visitChildren((SemanticsNode child) { + assert(child.indexInParent != null); + indexedChildren[child.indexInParent!] = child; + return true; + }); + if (indexedChildren[_currentIndex] == null) { + return node.updateWith(config: config); + } + final String currentLabel = indexedChildren[_currentIndex]!.label; + // If the current item has an empty label (e.g., wrapped with ExcludeSemantics), + // don't set any semantics configuration to avoid assertion errors. + // The semantics system requires that if "value" is empty, "increasedValue" + // and "decreasedValue" must also be empty, and no increase/decrease actions + // should be set. + if (currentLabel.isEmpty) { + return node.updateWith(config: config); + } + config.value = currentLabel; + final SemanticsNode? previousChild = indexedChildren[_currentIndex - 1]; + final SemanticsNode? nextChild = indexedChildren[_currentIndex + 1]; + // Only set increase/decrease actions if the adjacent item has a non-empty label. + // Items wrapped with ExcludeSemantics will have empty labels and should not + // be navigable via accessibility actions. + if (nextChild != null && nextChild.label.isNotEmpty) { + config.increasedValue = nextChild.label; + config.onIncrease = _handleIncrease; + } + if (previousChild != null && previousChild.label.isNotEmpty) { + config.decreasedValue = previousChild.label; + config.onDecrease = _handleDecrease; + } + node.updateWith(config: config); + } + + @override + void dispose() { + super.dispose(); + controller.removeListener(_handleScrollUpdate); + } +} + +class _CupertinoPickerListWheelChildDelegateWrapper implements ListWheelChildDelegate { + _CupertinoPickerListWheelChildDelegateWrapper(this._wrapped, {required this.onTappedChild}); + final ListWheelChildDelegate _wrapped; + final void Function(int index) onTappedChild; + + @override + Widget? build(BuildContext context, int index) { + final Widget? child = _wrapped.build(context, index); + if (child == null) { + return child; + } + return GestureDetector( + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + onTap: () => onTappedChild(index), + child: child, + ); + } + + @override + int? get estimatedChildCount => _wrapped.estimatedChildCount; + + @override + bool shouldRebuild(covariant _CupertinoPickerListWheelChildDelegateWrapper oldDelegate) => + _wrapped.shouldRebuild(oldDelegate._wrapped); + + @override + int trueIndexOf(int index) => _wrapped.trueIndexOf(index); +} diff --git a/packages/cupertino_ui/lib/src/radio.dart b/packages/cupertino_ui/lib/src/radio.dart new file mode 100644 index 000000000000..aef72b01fe3d --- /dev/null +++ b/packages/cupertino_ui/lib/src/radio.dart @@ -0,0 +1,608 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'checkbox.dart'; +/// @docImport 'slider.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; +// enum SingingCharacter { lafayette } +// late SingingCharacter? _character; +// late StateSetter setState; + +const Size _size = Size(18.0, 18.0); +const double _kOuterRadius = 7.0; +const double _kInnerRadius = 2.975; + +// Eyeballed from a radio on a physical Macbook Pro running macOS version 14.5. +final Color _kDisabledOuterColor = CupertinoColors.white.withOpacity(0.50); +const Color _kDisabledInnerColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(64, 0, 0, 0), + darkColor: Color.fromARGB(64, 255, 255, 255), +); +const Color _kDisabledBorderColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(64, 0, 0, 0), + darkColor: Color.fromARGB(64, 0, 0, 0), +); +const CupertinoDynamicColor _kDefaultBorderColor = CupertinoDynamicColor.withBrightness( + color: Color.fromARGB(255, 209, 209, 214), + darkColor: Color.fromARGB(64, 0, 0, 0), +); +const CupertinoDynamicColor _kDefaultInnerColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: Color.fromARGB(255, 222, 232, 248), +); +const CupertinoDynamicColor _kDefaultOuterColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.activeBlue, + darkColor: Color.fromARGB(255, 50, 100, 215), +); +const double _kPressedOverlayOpacity = 0.15; +const double _kCheckmarkStrokeWidth = 2.0; +const double _kFocusOutlineStrokeWidth = 3.0; +const double _kBorderOutlineStrokeWidth = 0.3; +// In dark mode, the outer color of a radio is an opacity gradient of the +// background color. +const List _kDarkGradientOpacities = [0.14, 0.29]; +const List _kDisabledDarkGradientOpacities = [0.08, 0.14]; + +/// A widget that builds a [RawRadio] with a macOS-style UI. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=D0xwcz2IqAY} +/// +/// Used to select between a number of mutually exclusive values. When one radio +/// button in a group is selected, the other radio buttons in the group are +/// deselected. The values are of type `T`, the type parameter of the +/// [CupertinoRadio] class. Enums are commonly used for this purpose. +/// +/// This widget typically has a [RadioGroup] ancestor, which takes in a +/// [RadioGroup.groupValue], and the [CupertinoRadio] under it with matching +/// [value] will be selected. +/// +/// {@tool dartpad} +/// Here is an example of CupertinoRadio widgets wrapped in CupertinoListTiles. +/// +/// The currently selected character is passed into `RadioGroup.groupValue`, which is +/// maintained by the example's `State`. In this case, the first [CupertinoRadio] +/// will start off selected because `_character` is initialized to +/// `SingingCharacter.lafayette`. +/// +/// If the second radio button is pressed, the example's state is updated +/// with `setState`, updating `_character` to `SingingCharacter.jefferson`. +/// This causes the buttons to rebuild with the updated `RadioGroup.groupValue`, and +/// therefore the selection of the second button. +/// +/// ** See code in examples/api/lib/cupertino/radio/cupertino_radio.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoSlider], for selecting a value in a range. +/// * [CupertinoCheckbox] and [CupertinoSwitch], for toggling a particular value on or off. +/// * [Radio], the Material Design equivalent. +/// * +class CupertinoRadio extends StatefulWidget { + /// Creates a macOS-styled radio button. + /// + /// The following arguments are required: + /// + /// * [value] and [groupValue] together determine whether the radio button is + /// selected. + /// * [onChanged] is called when the user selects this radio button. + const CupertinoRadio({ + super.key, + required this.value, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, + this.mouseCursor, + this.toggleable = false, + this.activeColor, + this.inactiveColor, + this.fillColor, + this.focusColor, + this.focusNode, + this.autofocus = false, + this.useCheckmarkStyle = false, + this.enabled, + this.groupRegistry, + }); + + /// {@macro flutter.widget.RawRadio.value} + final T value; + + /// {@macro flutter.material.Radio.groupValue} + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + final T? groupValue; + + /// {@macro flutter.material.Radio.onChanged} + /// + /// For example: + /// + /// ```dart + /// CupertinoRadio( + /// value: SingingCharacter.lafayette, + /// // ignore: deprecated_member_use + /// groupValue: _character, + /// // ignore: deprecated_member_use + /// onChanged: (SingingCharacter? newValue) { + /// setState(() { + /// _character = newValue; + /// }); + /// }, + /// ) + /// ``` + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + final ValueChanged? onChanged; + + /// {@macro flutter.widget.RawRadio.mouseCursor} + /// + /// If null, then [SystemMouseCursors.basic] is used when this radio button is disabled. + /// When this radio button is enabled, [SystemMouseCursors.click] is used on Web, and + /// [SystemMouseCursors.basic] is used on other platforms. + /// + /// See also: + /// + /// * [WidgetStateMouseCursor], a [MouseCursor] that implements + /// `WidgetStateProperty` which is used in APIs that need to accept + /// either a [MouseCursor] or a [WidgetStateProperty]. + final MouseCursor? mouseCursor; + + /// {@macro flutter.widget.RawRadio.toggleable} + /// + /// {@tool dartpad} + /// This example shows how to enable deselecting a radio button by setting the + /// [toggleable] attribute. + /// + /// ** See code in examples/api/lib/cupertino/radio/cupertino_radio.toggleable.0.dart ** + /// {@end-tool} + final bool toggleable; + + /// Controls whether the radio displays in a checkbox style or the default iOS + /// radio style. + /// + /// Defaults to false. + final bool useCheckmarkStyle; + + /// The color to use when this radio button is selected. + /// + /// Defaults to [CupertinoColors.activeBlue]. + final Color? activeColor; + + /// The color to use when this radio button is not selected. + /// + /// Defaults to [CupertinoColors.white]. + final Color? inactiveColor; + + /// The color that fills the inner circle of the radio button when selected. + /// + /// Defaults to [CupertinoColors.white]. + final Color? fillColor; + + /// The color for the radio's border when it has the input focus. + /// + /// If null, then a paler form of the [activeColor] will be used. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.widget.RawRadio.groupRegistry} + /// + /// Unless provided, the [BuildContext] will be used to look up the ancestor + /// [RadioGroupRegistry]. + final RadioGroupRegistry? groupRegistry; + + /// {@macro flutter.material.Radio.enabled} + final bool? enabled; + + @override + State> createState() => _CupertinoRadioState(); +} + +class _CupertinoRadioState extends State> { + FocusNode get _effectiveFocusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + FocusNode? _internalFocusNode; + + bool get _enabled => + widget.enabled ?? + (widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf(context) != null); + + _RadioRegistry? _internalRadioRegistry; + RadioGroupRegistry get _effectiveRegistry { + if (widget.groupRegistry != null) { + return widget.groupRegistry!; + } + + final RadioGroupRegistry? inheritedRegistry = RadioGroup.maybeOf(context); + if (inheritedRegistry != null) { + return inheritedRegistry; + } + + // Handles deprecated API. + return _internalRadioRegistry ??= _RadioRegistry(this); + } + + @override + void dispose() { + _internalFocusNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + !(widget.enabled ?? false) || + widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf(context) != null, + 'Radio is enabled but has no CupertinoRadio.onChange, ' + 'CupertinoRadio.groupRegistry, or RadioGroup above', + ); + final WidgetStateProperty effectiveMouseCursor = + WidgetStateProperty.resolveWith((Set states) { + return WidgetStateProperty.resolveAs(widget.mouseCursor, states) ?? + (!states.contains(WidgetState.disabled) && kIsWeb + ? SystemMouseCursors.click + : SystemMouseCursors.basic); + }); + + return RawRadio( + value: widget.value, + groupRegistry: _effectiveRegistry, + mouseCursor: effectiveMouseCursor, + toggleable: widget.toggleable, + focusNode: _effectiveFocusNode, + autofocus: widget.autofocus, + enabled: _enabled, + builder: (BuildContext context, ToggleableStateMixin state) { + return _RadioPaint( + activeColor: widget.activeColor, + inactiveColor: widget.inactiveColor, + fillColor: widget.fillColor, + focusColor: widget.focusColor, + useCheckmarkStyle: widget.useCheckmarkStyle, + isActive: _enabled, + toggleableState: state, + focused: _effectiveFocusNode.hasFocus, + ); + }, + ); + } +} + +/// A registry for deprecated API. +// TODO(chunhtai): Remove this once deprecated API is removed. +class _RadioRegistry extends RadioGroupRegistry { + _RadioRegistry(this.state); + final _CupertinoRadioState state; + @override + T? get groupValue => state.widget.groupValue; + + @override + ValueChanged get onChanged => state.widget.onChanged!; + + @override + void registerClient(RadioClient radio) {} + + @override + void unregisterClient(RadioClient radio) {} +} + +class _RadioPaint extends StatefulWidget { + const _RadioPaint({ + required this.focused, + required this.toggleableState, + required this.activeColor, + required this.inactiveColor, + required this.fillColor, + required this.focusColor, + required this.useCheckmarkStyle, + required this.isActive, + }); + + final ToggleableStateMixin toggleableState; + final Color? activeColor; + final Color? inactiveColor; + final Color? fillColor; + final Color? focusColor; + final bool useCheckmarkStyle; + final bool isActive; + + final bool focused; + @override + State createState() => _RadioPaintState(); +} + +class _RadioPaintState extends State<_RadioPaint> { + final _RadioPainter _painter = _RadioPainter(); + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + WidgetStateProperty get _defaultOuterColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return CupertinoDynamicColor.resolve(_kDisabledOuterColor, context); + } + if (states.contains(WidgetState.selected)) { + return widget.activeColor ?? CupertinoDynamicColor.resolve(_kDefaultOuterColor, context); + } + return widget.inactiveColor ?? CupertinoColors.white; + }); + } + + WidgetStateProperty get _defaultInnerColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDisabledInnerColor, context); + } + if (states.contains(WidgetState.selected)) { + return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDefaultInnerColor, context); + } + return CupertinoColors.white; + }); + } + + WidgetStateProperty get _defaultBorderColor { + return WidgetStateProperty.resolveWith((Set states) { + if ((states.contains(WidgetState.selected) || states.contains(WidgetState.focused)) && + !states.contains(WidgetState.disabled)) { + return CupertinoColors.transparent; + } + if (states.contains(WidgetState.disabled)) { + return CupertinoDynamicColor.resolve(_kDisabledBorderColor, context); + } + return CupertinoDynamicColor.resolve(_kDefaultBorderColor, context); + }); + } + + @override + Widget build(BuildContext context) { + // Colors need to be resolved in selected and non selected states separately. + final Set activeStates = widget.toggleableState.states..add(WidgetState.selected); + final Set inactiveStates = widget.toggleableState.states + ..remove(WidgetState.selected); + + // Since the states getter always makes a new set, make a copy to use + // throughout the lifecycle of this build method. + final Set currentStates = widget.toggleableState.states; + + final Color effectiveActiveColor = _defaultOuterColor.resolve(activeStates); + + final Color effectiveInactiveColor = _defaultOuterColor.resolve(inactiveStates); + + final Color effectiveFocusOverlayColor = + widget.focusColor ?? + HSLColor.fromColor(effectiveActiveColor.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + + final Color effectiveFillColor = _defaultInnerColor.resolve(currentStates); + + final Color effectiveBorderColor = _defaultBorderColor.resolve(currentStates); + + return CustomPaint( + size: _size, + painter: _painter + ..position = widget.toggleableState.position + ..reaction = widget.toggleableState.reaction + ..focusColor = effectiveFocusOverlayColor + ..downPosition = widget.toggleableState.downPosition + ..isFocused = widget.focused + ..activeColor = effectiveActiveColor + ..inactiveColor = effectiveInactiveColor + ..fillColor = effectiveFillColor + ..value = widget.toggleableState.value + ..checkmarkStyle = widget.useCheckmarkStyle + ..isActive = widget.isActive + ..borderColor = effectiveBorderColor + ..brightness = CupertinoTheme.of(context).brightness, + ); + } +} + +class _RadioPainter extends ToggleablePainter { + bool? get value => _value; + bool? _value; + set value(bool? value) { + if (_value == value) { + return; + } + _value = value; + notifyListeners(); + } + + Color get fillColor => _fillColor!; + Color? _fillColor; + set fillColor(Color value) { + if (value == _fillColor) { + return; + } + _fillColor = value; + notifyListeners(); + } + + bool get checkmarkStyle => _checkmarkStyle; + bool _checkmarkStyle = false; + set checkmarkStyle(bool value) { + if (value == _checkmarkStyle) { + return; + } + _checkmarkStyle = value; + notifyListeners(); + } + + Brightness? get brightness => _brightness; + Brightness? _brightness; + set brightness(Brightness? value) { + if (_brightness == value) { + return; + } + _brightness = value; + notifyListeners(); + } + + Color get borderColor => _borderColor!; + Color? _borderColor; + set borderColor(Color value) { + if (_borderColor == value) { + return; + } + _borderColor = value; + notifyListeners(); + } + + void _drawPressedOverlay(Canvas canvas, Offset center, double radius) { + final pressedPaint = Paint() + ..color = brightness == Brightness.light + ? CupertinoColors.black.withOpacity(_kPressedOverlayOpacity) + : CupertinoColors.white.withOpacity(_kPressedOverlayOpacity); + canvas.drawCircle(center, radius, pressedPaint); + } + + void _drawFillGradient( + Canvas canvas, + Offset center, + double radius, + Color topColor, + Color bottomColor, + ) { + final fillGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [topColor, bottomColor], + ); + final circleRect = Rect.fromCircle(center: center, radius: radius); + final gradientPaint = Paint()..shader = fillGradient.createShader(circleRect); + canvas.drawPath(Path()..addOval(circleRect), gradientPaint); + } + + void _drawOuterBorder(Canvas canvas, Offset center) { + final borderPaint = Paint() + ..style = PaintingStyle.stroke + ..color = borderColor + ..strokeWidth = _kBorderOutlineStrokeWidth; + canvas.drawCircle(center, _kOuterRadius, borderPaint); + } + + @override + void paint(Canvas canvas, Size size) { + final Offset center = (Offset.zero & size).center; + + if (checkmarkStyle) { + if (value ?? false) { + final path = Path(); + final checkPaint = Paint() + ..color = activeColor + ..style = PaintingStyle.stroke + ..strokeWidth = _kCheckmarkStrokeWidth + ..strokeCap = StrokeCap.round; + final double width = _size.width; + final origin = Offset(center.dx - (width / 2), center.dy - (width / 2)); + final start = Offset(width * 0.25, width * 0.52); + final mid = Offset(width * 0.46, width * 0.75); + final end = Offset(width * 0.85, width * 0.29); + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy); + canvas.drawPath(path, checkPaint); + path.moveTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.lineTo(origin.dx + end.dx, origin.dy + end.dy); + canvas.drawPath(path, checkPaint); + } + } else { + if (value ?? false) { + final outerPaint = Paint()..color = activeColor; + // Draw a gradient in dark mode if the radio is disabled. + if (brightness == Brightness.dark && !isActive) { + _drawFillGradient( + canvas, + center, + _kOuterRadius, + outerPaint.color.withOpacity( + isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0], + ), + outerPaint.color.withOpacity( + isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1], + ), + ); + } else { + canvas.drawCircle(center, _kOuterRadius, outerPaint); + } + // The outer circle's opacity changes when the radio is pressed. + if (downPosition != null) { + _drawPressedOverlay(canvas, center, _kOuterRadius); + } + final innerPaint = Paint()..color = fillColor; + canvas.drawCircle(center, _kInnerRadius, innerPaint); + // Draw an outer border if the radio is disabled and selected. + if (!isActive) { + _drawOuterBorder(canvas, center); + } + } else { + final paint = Paint(); + paint.color = isActive ? inactiveColor : _kDisabledOuterColor; + if (brightness == Brightness.dark) { + _drawFillGradient( + canvas, + center, + _kOuterRadius, + paint.color.withOpacity( + isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0], + ), + paint.color.withOpacity( + isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1], + ), + ); + } else { + canvas.drawCircle(center, _kOuterRadius, paint); + } + // The entire circle's opacity changes when the radio is pressed. + if (downPosition != null) { + _drawPressedOverlay(canvas, center, _kOuterRadius); + } + _drawOuterBorder(canvas, center); + } + } + if (isFocused) { + final focusPaint = Paint() + ..style = PaintingStyle.stroke + ..color = focusColor + ..strokeWidth = _kFocusOutlineStrokeWidth; + canvas.drawCircle(center, _kOuterRadius + _kFocusOutlineStrokeWidth / 2, focusPaint); + } + } +} diff --git a/packages/cupertino_ui/lib/src/refresh.dart b/packages/cupertino_ui/lib/src/refresh.dart new file mode 100644 index 000000000000..a5fb7d1cdc76 --- /dev/null +++ b/packages/cupertino_ui/lib/src/refresh.dart @@ -0,0 +1,589 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'nav_bar.dart'; +library; + +import 'dart:math'; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'activity_indicator.dart'; + +const double _kActivityIndicatorRadius = 14.0; +const double _kActivityIndicatorMargin = 16.0; + +class _CupertinoSliverRefresh extends SingleChildRenderObjectWidget { + const _CupertinoSliverRefresh({ + this.refreshIndicatorLayoutExtent = 0.0, + this.hasLayoutExtent = false, + super.child, + }) : assert(refreshIndicatorLayoutExtent >= 0.0); + + // The amount of space the indicator should occupy in the sliver in a + // resting state when in the refreshing mode. + final double refreshIndicatorLayoutExtent; + + // _RenderCupertinoSliverRefresh will paint the child in the available + // space either way but this instructs the _RenderCupertinoSliverRefresh + // on whether to also occupy any layoutExtent space or not. + final bool hasLayoutExtent; + + @override + _RenderCupertinoSliverRefresh createRenderObject(BuildContext context) { + return _RenderCupertinoSliverRefresh( + refreshIndicatorExtent: refreshIndicatorLayoutExtent, + hasLayoutExtent: hasLayoutExtent, + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderCupertinoSliverRefresh renderObject, + ) { + renderObject + ..refreshIndicatorLayoutExtent = refreshIndicatorLayoutExtent + ..hasLayoutExtent = hasLayoutExtent; + } +} + +// RenderSliver object that gives its child RenderBox object space to paint +// in the overscrolled gap and may or may not hold that overscrolled gap +// around the RenderBox depending on whether [layoutExtent] is set. +// +// The [layoutExtentOffsetCompensation] field keeps internal accounting to +// prevent scroll position jumps as the [layoutExtent] is set and unset. +class _RenderCupertinoSliverRefresh extends RenderSliver + with RenderObjectWithChildMixin { + _RenderCupertinoSliverRefresh({ + required double refreshIndicatorExtent, + required bool hasLayoutExtent, + RenderBox? child, + }) : assert(refreshIndicatorExtent >= 0.0), + _refreshIndicatorExtent = refreshIndicatorExtent, + _hasLayoutExtent = hasLayoutExtent { + this.child = child; + } + + // The amount of layout space the indicator should occupy in the sliver in a + // resting state when in the refreshing mode. + double get refreshIndicatorLayoutExtent => _refreshIndicatorExtent; + double _refreshIndicatorExtent; + set refreshIndicatorLayoutExtent(double value) { + assert(value >= 0.0); + if (value == _refreshIndicatorExtent) { + return; + } + _refreshIndicatorExtent = value; + markNeedsLayout(); + } + + // The child box will be laid out and painted in the available space either + // way but this determines whether to also occupy any + // [SliverGeometry.layoutExtent] space or not. + bool get hasLayoutExtent => _hasLayoutExtent; + bool _hasLayoutExtent; + set hasLayoutExtent(bool value) { + if (value == _hasLayoutExtent) { + return; + } + _hasLayoutExtent = value; + markNeedsLayout(); + } + + // This keeps track of the previously applied scroll offsets to the scrollable + // so that when [refreshIndicatorLayoutExtent] or [hasLayoutExtent] changes, + // the appropriate delta can be applied to keep everything in the same place + // visually. + double layoutExtentOffsetCompensation = 0.0; + + @override + void performLayout() { + final SliverConstraints constraints = this.constraints; + // Only pulling to refresh from the top is currently supported. + assert(constraints.axisDirection == AxisDirection.down); + assert(constraints.growthDirection == GrowthDirection.forward); + + // The new layout extent this sliver should now have. + final double layoutExtent = (_hasLayoutExtent ? 1.0 : 0.0) * _refreshIndicatorExtent; + // If the new layoutExtent instructive changed, the SliverGeometry's + // layoutExtent will take that value (on the next performLayout run). Shift + // the scroll offset first so it doesn't make the scroll position suddenly jump. + if (layoutExtent != layoutExtentOffsetCompensation) { + geometry = SliverGeometry( + scrollOffsetCorrection: layoutExtent - layoutExtentOffsetCompensation, + ); + layoutExtentOffsetCompensation = layoutExtent; + // Return so we don't have to do temporary accounting and adjusting the + // child's constraints accounting for this one transient frame using a + // combination of existing layout extent, new layout extent change and + // the overlap. + return; + } + + final bool active = constraints.overlap < 0.0 || layoutExtent > 0.0; + final double overscrolledExtent = constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0; + // Layout the child giving it the space of the currently dragged overscroll + // which may or may not include a sliver layout extent space that it will + // keep after the user lets go during the refresh process. + child!.layout( + constraints.asBoxConstraints( + maxExtent: + layoutExtent + // Plus only the overscrolled portion immediately preceding this + // sliver. + + + overscrolledExtent, + ), + parentUsesSize: true, + ); + if (active) { + geometry = SliverGeometry( + scrollExtent: layoutExtent, + paintOrigin: -overscrolledExtent - constraints.scrollOffset, + paintExtent: max( + // Check child size (which can come from overscroll) because + // layoutExtent may be zero. Check layoutExtent also since even + // with a layoutExtent, the indicator builder may decide to not + // build anything. + max(child!.size.height, layoutExtent) - constraints.scrollOffset, + 0.0, + ), + maxPaintExtent: max(max(child!.size.height, layoutExtent) - constraints.scrollOffset, 0.0), + layoutExtent: max(layoutExtent - constraints.scrollOffset, 0.0), + ); + } else { + // If we never started overscrolling, return no geometry. + geometry = SliverGeometry.zero; + } + } + + @override + void paint(PaintingContext paintContext, Offset offset) { + if (constraints.overlap < 0.0 || constraints.scrollOffset + child!.size.height > 0) { + paintContext.paintChild(child!, offset); + } + } + + // Nothing special done here because this sliver always paints its child + // exactly between paintOrigin and paintExtent. + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) {} +} + +/// The current state of the refresh control. +/// +/// Passed into the [RefreshControlIndicatorBuilder] builder function so +/// users can show different UI in different modes. +enum RefreshIndicatorMode { + /// Initial state, when not being overscrolled into, or after the overscroll + /// is canceled or after done and the sliver retracted away. + inactive, + + /// While being overscrolled but not far enough yet to trigger the refresh. + drag, + + /// Dragged far enough that the onRefresh callback will run and the dragged + /// displacement is not yet at the final refresh resting state. + armed, + + /// While the onRefresh task is running. + refresh, + + /// While the indicator is animating away after refreshing. + done, +} + +/// Signature for a builder that can create a different widget to show in the +/// refresh indicator space depending on the current state of the refresh +/// control and the space available. +/// +/// The `refreshTriggerPullDistance` and `refreshIndicatorExtent` parameters are +/// the same values passed into the [CupertinoSliverRefreshControl]. +/// +/// The `pulledExtent` parameter is the currently available space either from +/// overscrolling or as held by the sliver during refresh. +typedef RefreshControlIndicatorBuilder = + Widget Function( + BuildContext context, + RefreshIndicatorMode refreshState, + double pulledExtent, + double refreshTriggerPullDistance, + double refreshIndicatorExtent, + ); + +/// A callback function that's invoked when the [CupertinoSliverRefreshControl] is +/// pulled a `refreshTriggerPullDistance`. Must return a [Future]. Upon +/// completion of the [Future], the [CupertinoSliverRefreshControl] enters the +/// [RefreshIndicatorMode.done] state and will start to go away. +typedef RefreshCallback = Future Function(); + +/// A sliver widget implementing the iOS-style pull to refresh content control. +/// +/// When inserted as the first sliver in a scroll view or behind other slivers +/// that still lets the scrollable overscroll in front of this sliver (such as +/// the [CupertinoSliverNavigationBar], this widget will: +/// +/// * Let the user draw inside the overscrolled area via the passed in [builder]. +/// * Trigger the provided [onRefresh] function when overscrolled far enough to +/// pass [refreshTriggerPullDistance]. +/// * Continue to hold [refreshIndicatorExtent] amount of space for the [builder] +/// to keep drawing inside of as the [Future] returned by [onRefresh] processes. +/// * Scroll away once the [onRefresh] [Future] completes. +/// +/// The [builder] function will be informed of the current [RefreshIndicatorMode] +/// when invoking it, except in the [RefreshIndicatorMode.inactive] state when +/// no space is available and nothing needs to be built. The [builder] function +/// will otherwise be continuously invoked as the amount of space available +/// changes from overscroll, as the sliver scrolls away after the [onRefresh] +/// task is done, etc. +/// +/// Only one refresh can be triggered until the previous refresh has completed +/// and the indicator sliver has retracted at least 90% of the way back. +/// +/// Can only be used in downward-scrolling vertical lists that overscrolls. In +/// other words, refreshes can't be triggered with [Scrollable]s using +/// [ClampingScrollPhysics] which is the default on Android. To allow overscroll +/// on Android, use an overscrolling physics such as [BouncingScrollPhysics]. +/// This can be done via: +/// +/// * Providing a [BouncingScrollPhysics] (possibly in combination with a +/// [AlwaysScrollableScrollPhysics]) while constructing the scrollable. +/// * By inserting a [ScrollConfiguration] with [BouncingScrollPhysics] above +/// the scrollable. +/// * By using [CupertinoApp], which always uses a [ScrollConfiguration] +/// with [BouncingScrollPhysics] regardless of platform. +/// +/// In a typical application, this sliver should be inserted between the app bar +/// sliver such as [CupertinoSliverNavigationBar] and your main scrollable +/// content's sliver. +/// +/// {@tool dartpad} +/// When the user scrolls past [refreshTriggerPullDistance], +/// this sample shows the default iOS pull to refresh indicator for 1 second and +/// adds a new item to the top of the list view. +/// +/// ** See code in examples/api/lib/cupertino/refresh/cupertino_sliver_refresh_control.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CustomScrollView], a typical sliver holding scroll view this control +/// should go into. +/// * +/// * [RefreshIndicator], a Material Design version of the pull-to-refresh +/// paradigm. This widget works differently than [RefreshIndicator] because +/// instead of being an overlay on top of the scrollable, the +/// [CupertinoSliverRefreshControl] is part of the scrollable and actively occupies +/// scrollable space. +class CupertinoSliverRefreshControl extends StatefulWidget { + /// Create a new refresh control for inserting into a list of slivers. + /// + /// The [refreshTriggerPullDistance] and [refreshIndicatorExtent] arguments + /// must be greater than or equal to 0. + /// + /// The [builder] argument may be null, in which case no indicator UI will be + /// shown but the [onRefresh] will still be invoked. By default, [builder] + /// shows a [CupertinoActivityIndicator]. + /// + /// The [onRefresh] argument will be called when pulled far enough to trigger + /// a refresh. + const CupertinoSliverRefreshControl({ + super.key, + this.refreshTriggerPullDistance = _defaultRefreshTriggerPullDistance, + this.refreshIndicatorExtent = _defaultRefreshIndicatorExtent, + this.builder = buildRefreshIndicator, + this.onRefresh, + }) : assert(refreshTriggerPullDistance > 0.0), + assert(refreshIndicatorExtent >= 0.0), + assert( + refreshTriggerPullDistance >= refreshIndicatorExtent, + 'The refresh indicator cannot take more space in its final state ' + 'than the amount initially created by overscrolling.', + ); + + /// The amount of overscroll the scrollable must be dragged to trigger a reload. + /// + /// Must be larger than zero and larger than [refreshIndicatorExtent]. + /// Defaults to 100 pixels when not specified. + /// + /// When overscrolled past this distance, [onRefresh] will be called if not + /// null and the [builder] will build in the [RefreshIndicatorMode.armed] state. + final double refreshTriggerPullDistance; + + /// The amount of space the refresh indicator sliver will keep holding while + /// [onRefresh]'s [Future] is still running. + /// + /// Must be a positive number, but can be zero, in which case the sliver will + /// start retracting back to zero as soon as the refresh is started. Defaults + /// to 60 pixels when not specified. + /// + /// Must be smaller than [refreshTriggerPullDistance], since the sliver + /// shouldn't grow further after triggering the refresh. + final double refreshIndicatorExtent; + + /// A builder that's called as this sliver's size changes, and as the state + /// changes. + /// + /// Can be set to null, in which case nothing will be drawn in the overscrolled + /// space. + /// + /// Will not be called when the available space is zero such as before any + /// overscroll. + final RefreshControlIndicatorBuilder? builder; + + /// Callback invoked when pulled by [refreshTriggerPullDistance]. + /// + /// If provided, must return a [Future] which will keep the indicator in the + /// [RefreshIndicatorMode.refresh] state until the [Future] completes. + /// + /// Can be null, in which case a single frame of [RefreshIndicatorMode.armed] + /// state will be drawn before going immediately to the [RefreshIndicatorMode.done] + /// where the sliver will start retracting. + final RefreshCallback? onRefresh; + + static const double _defaultRefreshTriggerPullDistance = 100.0; + static const double _defaultRefreshIndicatorExtent = 60.0; + + /// Retrieve the current state of the CupertinoSliverRefreshControl. The same as the + /// state that gets passed into the [builder] function. Used for testing. + @visibleForTesting + static RefreshIndicatorMode state(BuildContext context) { + final _CupertinoSliverRefreshControlState state = context + .findAncestorStateOfType<_CupertinoSliverRefreshControlState>()!; + return state.refreshState; + } + + /// Builds a refresh indicator that reflects the standard iOS pull-to-refresh + /// behavior. Specifically, this entails presenting an activity indicator that + /// changes depending on the current refreshState. As the user initially drags + /// down, the indicator will gradually reveal individual ticks until the refresh + /// becomes armed. At this point, the animated activity indicator will begin rotating. + /// Once the refresh has completed, the activity indicator shrinks away as the + /// space allocation animates back to closed. + static Widget buildRefreshIndicator( + BuildContext context, + RefreshIndicatorMode refreshState, + double pulledExtent, + double refreshTriggerPullDistance, + double refreshIndicatorExtent, + ) { + final double percentageComplete = clampDouble( + pulledExtent / refreshTriggerPullDistance, + 0.0, + 1.0, + ); + + // Place the indicator at the top of the sliver that opens up. We're using a + // Stack/Positioned widget because the CupertinoActivityIndicator does some + // internal translations based on the current size (which grows as the user drags) + // that makes Padding calculations difficult. Rather than be reliant on the + // internal implementation of the activity indicator, the Positioned widget allows + // us to be explicit where the widget gets placed. The indicator should appear + // over the top of the dragged widget, hence the use of Clip.none. + return Center( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: _kActivityIndicatorMargin, + left: 0.0, + right: 0.0, + child: _buildIndicatorForRefreshState( + refreshState, + _kActivityIndicatorRadius, + percentageComplete, + ), + ), + ], + ), + ); + } + + static Widget _buildIndicatorForRefreshState( + RefreshIndicatorMode refreshState, + double radius, + double percentageComplete, + ) { + switch (refreshState) { + case RefreshIndicatorMode.drag: + // While we're dragging, we draw individual ticks of the spinner while simultaneously + // easing the opacity in. The opacity curve values here were derived using + // Xcode through inspecting a native app running on iOS 13.5. + const Curve opacityCurve = Interval(0.0, 0.35, curve: Curves.easeInOut); + return Opacity( + opacity: opacityCurve.transform(percentageComplete), + child: CupertinoActivityIndicator.partiallyRevealed( + radius: radius, + progress: percentageComplete, + ), + ); + case RefreshIndicatorMode.armed: + case RefreshIndicatorMode.refresh: + // Once we're armed or performing the refresh, we just show the normal spinner. + return CupertinoActivityIndicator(radius: radius); + case RefreshIndicatorMode.done: + // When the user lets go, the standard transition is to shrink the spinner. + return CupertinoActivityIndicator(radius: radius * percentageComplete); + case RefreshIndicatorMode.inactive: + // Anything else doesn't show anything. + return const SizedBox.shrink(); + } + } + + @override + State createState() => _CupertinoSliverRefreshControlState(); +} + +class _CupertinoSliverRefreshControlState extends State { + // Reset the state from done to inactive when only this fraction of the + // original `refreshTriggerPullDistance` is left. + static const double _inactiveResetOverscrollFraction = 0.1; + + late RefreshIndicatorMode refreshState; + // [Future] returned by the widget's `onRefresh`. + Future? refreshTask; + // The amount of space available from the inner indicator box's perspective. + // + // The value is the sum of the sliver's layout extent and the overscroll + // (which partially gets transferred into the layout extent when the refresh + // triggers). + // + // The value of latestIndicatorBoxExtent doesn't change when the sliver scrolls + // away without retracting; it is independent from the sliver's scrollOffset. + double latestIndicatorBoxExtent = 0.0; + bool hasSliverLayoutExtent = false; + + @override + void initState() { + super.initState(); + refreshState = RefreshIndicatorMode.inactive; + } + + // A state machine transition calculator. Multiple states can be transitioned + // through per single call. + RefreshIndicatorMode transitionNextState() { + RefreshIndicatorMode nextState; + + void goToDone() { + nextState = RefreshIndicatorMode.done; + // Either schedule the RenderSliver to re-layout on the next frame + // when not currently in a frame or schedule it on the next frame. + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) { + setState(() => hasSliverLayoutExtent = false); + } else { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + setState(() => hasSliverLayoutExtent = false); + }, debugLabel: 'Refresh.goToDone'); + } + } + + switch (refreshState) { + case RefreshIndicatorMode.inactive: + if (latestIndicatorBoxExtent <= 0) { + return RefreshIndicatorMode.inactive; + } else { + nextState = RefreshIndicatorMode.drag; + } + continue drag; + drag: + case RefreshIndicatorMode.drag: + if (latestIndicatorBoxExtent == 0) { + return RefreshIndicatorMode.inactive; + } else if (latestIndicatorBoxExtent < widget.refreshTriggerPullDistance) { + return RefreshIndicatorMode.drag; + } else { + if (widget.onRefresh != null) { + HapticFeedback.mediumImpact(); + // Call onRefresh after this frame finished since the function is + // user supplied and we're always here in the middle of the sliver's + // performLayout. + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + refreshTask = widget.onRefresh!() + ..whenComplete(() { + if (mounted) { + setState(() => refreshTask = null); + // Trigger one more transition because by this time, BoxConstraint's + // maxHeight might already be resting at 0 in which case no + // calls to [transitionNextState] will occur anymore and the + // state may be stuck in a non-inactive state. + refreshState = transitionNextState(); + } + }); + setState(() => hasSliverLayoutExtent = true); + }, debugLabel: 'Refresh.transition'); + } + return RefreshIndicatorMode.armed; + } + case RefreshIndicatorMode.armed: + if (refreshState == RefreshIndicatorMode.armed && refreshTask == null) { + goToDone(); + continue done; + } + + if (latestIndicatorBoxExtent > widget.refreshIndicatorExtent) { + return RefreshIndicatorMode.armed; + } else { + nextState = RefreshIndicatorMode.refresh; + } + continue refresh; + refresh: + case RefreshIndicatorMode.refresh: + if (refreshTask != null) { + return RefreshIndicatorMode.refresh; + } else { + goToDone(); + } + continue done; + done: + case RefreshIndicatorMode.done: + // Let the transition back to inactive trigger before strictly going + // to 0.0 since the last bit of the animation can take some time and + // can feel sluggish if not going all the way back to 0.0 prevented + // a subsequent pull-to-refresh from starting. + if (latestIndicatorBoxExtent > + widget.refreshTriggerPullDistance * _inactiveResetOverscrollFraction) { + return RefreshIndicatorMode.done; + } else { + nextState = RefreshIndicatorMode.inactive; + } + } + + return nextState; + } + + @override + Widget build(BuildContext context) { + return _CupertinoSliverRefresh( + refreshIndicatorLayoutExtent: widget.refreshIndicatorExtent, + hasLayoutExtent: hasSliverLayoutExtent, + // A LayoutBuilder lets the sliver's layout changes be fed back out to + // its owner to trigger state changes. + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + latestIndicatorBoxExtent = constraints.maxHeight; + refreshState = transitionNextState(); + if (widget.builder != null && latestIndicatorBoxExtent > 0) { + return widget.builder!( + context, + refreshState, + latestIndicatorBoxExtent, + widget.refreshTriggerPullDistance, + widget.refreshIndicatorExtent, + ); + } + return const LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand()); + }, + ), + ); + } +} diff --git a/packages/cupertino_ui/lib/src/route.dart b/packages/cupertino_ui/lib/src/route.dart new file mode 100644 index 000000000000..b990e163454b --- /dev/null +++ b/packages/cupertino_ui/lib/src/route.dart @@ -0,0 +1,1599 @@ +// 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. + +/// @docImport 'dart:ui'; +/// +/// @docImport 'package:flutter/material.dart'; +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'button.dart'; +/// @docImport 'dialog.dart'; +/// @docImport 'nav_bar.dart'; +/// @docImport 'page_scaffold.dart'; +/// @docImport 'tab_scaffold.dart'; +library; + +import 'dart:math'; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'interface_level.dart'; +import 'localizations.dart'; + +const double _kBackGestureWidth = 20.0; +const double _kMinFlingVelocity = 1.0; // Screen widths per second. + +// The duration for a page to animate when the user releases it mid-swipe. +const Duration _kDroppedSwipePageAnimationDuration = Duration(milliseconds: 350); + +/// Barrier color used for a barrier visible during transitions for Cupertino +/// page routes. +/// +/// This barrier color is only used for full-screen page routes with +/// `fullscreenDialog: false`. +/// +/// By default, `fullscreenDialog` Cupertino route transitions have no +/// `barrierColor`, and [CupertinoDialogRoute]s and [CupertinoModalPopupRoute]s +/// have a `barrierColor` defined by [kCupertinoModalBarrierColor]. +/// +/// A relatively rigorous eyeball estimation. +const Color _kCupertinoPageTransitionBarrierColor = Color(0x18000000); + +/// Barrier color for a Cupertino modal barrier. +/// +/// Extracted from https://developer.apple.com/design/resources/. +const Color kCupertinoModalBarrierColor = CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x7A000000), +); + +// The duration of the transition used when a modal popup is shown. +const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); + +// Offset from offscreen to the right to fully on screen. +final Animatable _kRightMiddleTween = Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, +); + +// Offset from fully on screen to 1/3 offscreen to the left. +final Animatable _kMiddleLeftTween = Tween( + begin: Offset.zero, + end: const Offset(-1.0 / 3.0, 0.0), +); + +// Offset from offscreen below to fully on screen. +final Animatable _kBottomUpTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, +); + +/// A mixin that replaces the entire screen with an iOS transition for a +/// [PageRoute]. +/// +/// {@template flutter.cupertino.cupertinoRouteTransitionMixin} +/// The page slides in from the right and exits in reverse. The page also shifts +/// to the left in parallax when another page enters to cover it. +/// +/// The page slides in from the bottom and exits in reverse with no parallax +/// effect for fullscreen dialogs. +/// {@endtemplate} +/// +/// See also: +/// +/// * [MaterialRouteTransitionMixin], which is a mixin that provides +/// platform-appropriate transitions for a [PageRoute]. +/// * [CupertinoPageRoute], which is a [PageRoute] that leverages this mixin. +mixin CupertinoRouteTransitionMixin on PageRoute { + /// Builds the primary contents of the route. + @protected + Widget buildContent(BuildContext context); + + /// {@template flutter.cupertino.CupertinoRouteTransitionMixin.title} + /// A title string for this route. + /// + /// Used to auto-populate [CupertinoNavigationBar] and + /// [CupertinoSliverNavigationBar]'s `middle`/`largeTitle` widgets when + /// one is not manually supplied. + /// {@endtemplate} + String? get title; + + ValueNotifier? _previousTitle; + + /// The title string of the previous [CupertinoPageRoute]. + /// + /// The [ValueListenable]'s value is readable after the route is installed + /// onto a [Navigator]. The [ValueListenable] will also notify its listeners + /// if the value changes (such as by replacing the previous route). + /// + /// The [ValueListenable] itself will be null before the route is installed. + /// Its content value will be null if the previous route has no title or + /// is not a [CupertinoPageRoute]. + /// + /// See also: + /// + /// * [ValueListenableBuilder], which can be used to listen and rebuild + /// widgets based on a ValueListenable. + ValueListenable get previousTitle { + assert( + _previousTitle != null, + 'Cannot read the previousTitle for a route that has not yet been installed', + ); + return _previousTitle!; + } + + @override + void dispose() { + _previousTitle?.dispose(); + super.dispose(); + } + + @override + void didChangePrevious(Route? previousRoute) { + final String? previousTitleString = previousRoute is CupertinoRouteTransitionMixin + ? previousRoute.title + : null; + if (_previousTitle == null) { + _previousTitle = ValueNotifier(previousTitleString); + } else { + _previousTitle!.value = previousTitleString; + } + super.didChangePrevious(previousRoute); + } + + /// The duration of the page transition. + /// + /// A relatively rigorous eyeball estimation. + static const Duration kTransitionDuration = Duration(milliseconds: 500); + + @override + Duration get transitionDuration => kTransitionDuration; + + @override + Color? get barrierColor => fullscreenDialog ? null : _kCupertinoPageTransitionBarrierColor; + + @override + String? get barrierLabel => null; + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + // Don't perform outgoing animation if the next route is a fullscreen dialog. + final bool nextRouteIsNotFullscreen = + (nextRoute is! PageRoute) || !nextRoute.fullscreenDialog; + + // If the next route has a delegated transition, then this route is able to + // use that delegated transition to smoothly sync with the next route's + // transition. + final bool nextRouteHasDelegatedTransition = + nextRoute is ModalRoute && nextRoute.delegatedTransition != null; + + // Otherwise if the next route has the same route transition mixin as this + // one, then this route will already be synced with its transition. + return nextRouteIsNotFullscreen && + ((nextRoute is CupertinoRouteTransitionMixin) || nextRouteHasDelegatedTransition); + } + + @override + bool canTransitionFrom(TransitionRoute previousRoute) { + // Suppress previous route from transitioning if this is a fullscreenDialog route. + return previousRoute is PageRoute && !fullscreenDialog; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final Widget child = buildContent(context); + return Semantics(scopesRoute: true, explicitChildNodes: true, child: child); + } + + // Called by _CupertinoBackGestureDetector when a pop ("back") drag start + // gesture is detected. The returned controller handles all of the subsequent + // drag events. + static _CupertinoBackGestureController _startPopGesture(PageRoute route) { + assert(route.popGestureEnabled); + + return _CupertinoBackGestureController( + navigator: route.navigator!, + getIsCurrent: () => route.isCurrent, + getIsActive: () => route.isActive, + controller: route.controller!, // protected access + ); + } + + /// Returns a [CupertinoFullscreenDialogTransition] if [route] is a full + /// screen dialog, otherwise a [CupertinoPageTransition] is returned. + /// + /// Used by [CupertinoPageRoute.buildTransitions]. + /// + /// This method can be applied to any [PageRoute], not just + /// [CupertinoPageRoute]. It's typically used to provide a Cupertino style + /// horizontal transition for material widgets when the target platform + /// is [TargetPlatform.iOS]. + /// + /// See also: + /// + /// * [CupertinoPageTransitionsBuilder], which uses this method to define a + /// [PageTransitionsBuilder] for the [PageTransitionsTheme]. + static Widget buildPageTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + // Check if the route has an animation that's currently participating + // in a back swipe gesture. + // + // In the middle of a back gesture drag, let the transition be linear to + // match finger motions. + final bool linearTransition = route.popGestureInProgress; + if (route.fullscreenDialog) { + return CupertinoFullscreenDialogTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: linearTransition, + child: child, + ); + } else { + return CupertinoPageTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: linearTransition, + child: _CupertinoBackGestureDetector( + enabledCallback: () => route.popGestureEnabled, + onStartPopGesture: () => _startPopGesture(route), + child: child, + ), + ); + } + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return buildPageTransitions(this, context, animation, secondaryAnimation, child); + } +} + +/// A modal route that replaces the entire screen with an iOS transition. +/// +/// {@macro flutter.cupertino.cupertinoRouteTransitionMixin} +/// +/// By default, when a modal route is replaced by another, the previous route +/// remains in memory. To free all the resources when this is not necessary, set +/// [maintainState] to false. +/// +/// The type `T` specifies the return type of the route which can be supplied as +/// the route is popped from the stack via [Navigator.pop] when an optional +/// `result` can be provided. +/// +/// If `barrierDismissible` is true, then pressing the escape key on the keyboard +/// will cause the current route to be popped with null as the value. +/// +/// See also: +/// +/// * [CupertinoRouteTransitionMixin], for a mixin that provides iOS transition +/// for this modal route. +/// * [MaterialPageRoute], for an adaptive [PageRoute] that uses a +/// platform-appropriate transition. +/// * [CupertinoPageScaffold], for applications that have one page with a fixed +/// navigation bar on top. +/// * [CupertinoTabScaffold], for applications that have a tab bar at the +/// bottom with multiple pages. +/// * [CupertinoPage], for a [Page] version of this class. +class CupertinoPageRoute extends PageRoute with CupertinoRouteTransitionMixin { + /// Creates a page route for use in an iOS designed app. + /// + /// The [builder], [maintainState], and [fullscreenDialog] arguments must not + /// be null. + CupertinoPageRoute({ + required this.builder, + this.title, + super.settings, + super.requestFocus, + this.maintainState = true, + super.fullscreenDialog, + super.allowSnapshotting = true, + super.barrierDismissible = false, + }) { + assert(opaque); + } + + @override + DelegatedTransitionBuilder? get delegatedTransition => + CupertinoPageTransition.delegatedTransition; + + /// Builds the primary contents of the route. + final WidgetBuilder builder; + + @override + Widget buildContent(BuildContext context) => builder(context); + + @override + final String? title; + + @override + final bool maintainState; + + @override + String get debugLabel => '${super.debugLabel}(${settings.name})'; +} + +// A page-based version of CupertinoPageRoute. +// +// This route uses the builder from the page to build its content. This ensures +// the content is up to date after page updates. +class _PageBasedCupertinoPageRoute extends PageRoute with CupertinoRouteTransitionMixin { + _PageBasedCupertinoPageRoute({required CupertinoPage page, super.allowSnapshotting = true}) + : super(settings: page) { + assert(opaque); + } + + @override + DelegatedTransitionBuilder? get delegatedTransition => + fullscreenDialog ? null : CupertinoPageTransition.delegatedTransition; + + CupertinoPage get _page => settings as CupertinoPage; + + @override + Widget buildContent(BuildContext context) => _page.child; + + @override + String? get title => _page.title; + + @override + bool get maintainState => _page.maintainState; + + @override + bool get fullscreenDialog => _page.fullscreenDialog; + + @override + String get debugLabel => '${super.debugLabel}(${_page.name})'; +} + +/// A page that creates a cupertino style [PageRoute]. +/// +/// {@macro flutter.cupertino.cupertinoRouteTransitionMixin} +/// +/// By default, when a created modal route is replaced by another, the previous +/// route remains in memory. To free all the resources when this is not +/// necessary, set [maintainState] to false. +/// +/// The type `T` specifies the return type of the route which can be supplied as +/// the route is popped from the stack via [Navigator.transitionDelegate] by +/// providing the optional `result` argument to the +/// [RouteTransitionRecord.markForPop] in the [TransitionDelegate.resolve]. +/// +/// See also: +/// +/// * [CupertinoPageRoute], for a [PageRoute] version of this class. +class CupertinoPage extends Page { + /// Creates a cupertino page. + const CupertinoPage({ + required this.child, + this.maintainState = true, + this.title, + this.fullscreenDialog = false, + this.allowSnapshotting = true, + super.canPop, + super.onPopInvoked, + super.key, + super.name, + super.arguments, + super.restorationId, + }); + + /// The content to be shown in the [Route] created by this page. + final Widget child; + + /// {@macro flutter.cupertino.CupertinoRouteTransitionMixin.title} + final String? title; + + /// {@macro flutter.widgets.ModalRoute.maintainState} + final bool maintainState; + + /// {@macro flutter.widgets.PageRoute.fullscreenDialog} + final bool fullscreenDialog; + + /// {@macro flutter.widgets.TransitionRoute.allowSnapshotting} + final bool allowSnapshotting; + + @override + Route createRoute(BuildContext context) { + return _PageBasedCupertinoPageRoute(page: this, allowSnapshotting: allowSnapshotting); + } +} + +/// Provides an iOS-style page transition animation. +/// +/// The page slides in from the right and exits in reverse. It also shifts to the left in +/// a parallax motion when another page enters to cover it. +class CupertinoPageTransition extends StatefulWidget { + /// Creates an iOS-style page transition. + /// + const CupertinoPageTransition({ + super.key, + required this.primaryRouteAnimation, + required this.secondaryRouteAnimation, + required this.child, + required this.linearTransition, + }); + + /// The widget below this widget in the tree. + final Widget child; + + /// * `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0 + /// when this screen is being pushed. + final Animation primaryRouteAnimation; + + /// * `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0 + /// when another screen is being pushed on top of this one. + final Animation secondaryRouteAnimation; + + /// * `linearTransition` is whether to perform the transitions linearly. + /// Used to precisely track back gesture drags. + final bool linearTransition; + + /// The Cupertino styled [DelegatedTransitionBuilder] provided to the previous + /// route. + /// + /// {@macro flutter.widgets.delegatedTransition} + static Widget? delegatedTransition( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) { + final animation = CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + ); + final Animation delegatedPositionAnimation = animation.drive(_kMiddleLeftTween); + animation.dispose(); + + assert(debugCheckHasDirectionality(context)); + final TextDirection textDirection = Directionality.of(context); + return SlideTransition( + position: delegatedPositionAnimation, + textDirection: textDirection, + transformHitTests: false, + child: child, + ); + } + + @override + State createState() => _CupertinoPageTransitionState(); +} + +class _CupertinoPageTransitionState extends State { + // When this page is coming in to cover another page. + late Animation _primaryPositionAnimation; + // When this page is becoming covered by another page. + late Animation _secondaryPositionAnimation; + // Shadow of page which is coming in to cover another page. + late Animation _primaryShadowAnimation; + // Curve of primary page which is coming in to cover another page. + CurvedAnimation? _primaryPositionCurve; + // Curve of secondary page which is becoming covered by another page. + CurvedAnimation? _secondaryPositionCurve; + // Curve of primary page's shadow. + CurvedAnimation? _primaryShadowCurve; + + @override + void initState() { + super.initState(); + _setupAnimation(); + } + + @override + void didUpdateWidget(covariant CupertinoPageTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.primaryRouteAnimation != widget.primaryRouteAnimation || + oldWidget.secondaryRouteAnimation != widget.secondaryRouteAnimation || + oldWidget.linearTransition != widget.linearTransition) { + _disposeCurve(); + _setupAnimation(); + } + } + + @override + void dispose() { + _disposeCurve(); + super.dispose(); + } + + void _disposeCurve() { + _primaryPositionCurve?.dispose(); + _secondaryPositionCurve?.dispose(); + _primaryShadowCurve?.dispose(); + _primaryPositionCurve = null; + _secondaryPositionCurve = null; + _primaryShadowCurve = null; + } + + void _setupAnimation() { + if (!widget.linearTransition) { + _primaryPositionCurve = CurvedAnimation( + parent: widget.primaryRouteAnimation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ); + _secondaryPositionCurve = CurvedAnimation( + parent: widget.secondaryRouteAnimation, + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + ); + _primaryShadowCurve = CurvedAnimation( + parent: widget.primaryRouteAnimation, + curve: Curves.linearToEaseOut, + ); + } + _primaryPositionAnimation = (_primaryPositionCurve ?? widget.primaryRouteAnimation).drive( + _kRightMiddleTween, + ); + _secondaryPositionAnimation = (_secondaryPositionCurve ?? widget.secondaryRouteAnimation).drive( + _kMiddleLeftTween, + ); + _primaryShadowAnimation = (_primaryShadowCurve ?? widget.primaryRouteAnimation).drive( + _CupertinoEdgeShadowDecoration.kTween, + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + final TextDirection textDirection = Directionality.of(context); + return SlideTransition( + position: _secondaryPositionAnimation, + textDirection: textDirection, + transformHitTests: false, + child: SlideTransition( + position: _primaryPositionAnimation, + textDirection: textDirection, + child: DecoratedBoxTransition(decoration: _primaryShadowAnimation, child: widget.child), + ), + ); + } +} + +/// An iOS-style transition used for summoning fullscreen dialogs. +/// +/// For example, used when creating a new calendar event by bringing in the next +/// screen from the bottom. +class CupertinoFullscreenDialogTransition extends StatefulWidget { + /// Creates an iOS-style transition used for summoning fullscreen dialogs. + /// + const CupertinoFullscreenDialogTransition({ + super.key, + required this.primaryRouteAnimation, + required this.secondaryRouteAnimation, + required this.child, + required this.linearTransition, + }); + + /// * `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0 + /// when this screen is being pushed. + final Animation primaryRouteAnimation; + + /// * `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0 + /// when another screen is being pushed on top of this one. + final Animation secondaryRouteAnimation; + + /// * `linearTransition` is whether to perform the transitions linearly. + /// Used to precisely track back gesture drags. + final bool linearTransition; + + /// The widget below this widget in the tree. + final Widget child; + + @override + State createState() => + _CupertinoFullscreenDialogTransitionState(); +} + +class _CupertinoFullscreenDialogTransitionState extends State { + /// When this page is coming in to cover another page. + late Animation _primaryPositionAnimation; + + /// When this page is becoming covered by another page. + late Animation _secondaryPositionAnimation; + + /// Curve of primary page which is coming in to cover another page. + CurvedAnimation? _primaryPositionCurve; + + /// Curve of secondary page which is becoming covered by another page. + CurvedAnimation? _secondaryPositionCurve; + + @override + void initState() { + super.initState(); + _setupAnimation(); + } + + @override + void didUpdateWidget(covariant CupertinoFullscreenDialogTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.primaryRouteAnimation != widget.primaryRouteAnimation || + oldWidget.secondaryRouteAnimation != widget.secondaryRouteAnimation || + oldWidget.linearTransition != widget.linearTransition) { + _disposeCurve(); + _setupAnimation(); + } + } + + @override + void dispose() { + _disposeCurve(); + super.dispose(); + } + + void _disposeCurve() { + _primaryPositionCurve?.dispose(); + _secondaryPositionCurve?.dispose(); + _primaryPositionCurve = null; + _secondaryPositionCurve = null; + } + + void _setupAnimation() { + _primaryPositionAnimation = (_primaryPositionCurve = CurvedAnimation( + parent: widget.primaryRouteAnimation, + curve: Curves.linearToEaseOut, + // The curve must be flipped so that the reverse animation doesn't play + // an ease-in curve, which iOS does not use. + reverseCurve: Curves.linearToEaseOut.flipped, + )).drive(_kBottomUpTween); + _secondaryPositionAnimation = + (widget.linearTransition + ? widget.secondaryRouteAnimation + : _secondaryPositionCurve = CurvedAnimation( + parent: widget.secondaryRouteAnimation, + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + )) + .drive(_kMiddleLeftTween); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + final TextDirection textDirection = Directionality.of(context); + return SlideTransition( + position: _secondaryPositionAnimation, + textDirection: textDirection, + transformHitTests: false, + child: SlideTransition(position: _primaryPositionAnimation, child: widget.child), + ); + } +} + +/// This is the widget side of [_CupertinoBackGestureController]. +/// +/// This widget provides a gesture recognizer which, when it determines the +/// route can be closed with a back gesture, creates the controller and +/// feeds it the input from the gesture recognizer. +/// +/// The gesture data is converted from absolute coordinates to logical +/// coordinates by this widget. +/// +/// The type `T` specifies the return type of the route with which this gesture +/// detector is associated. +class _CupertinoBackGestureDetector extends StatefulWidget { + const _CupertinoBackGestureDetector({ + super.key, + required this.enabledCallback, + required this.onStartPopGesture, + required this.child, + }); + + final Widget child; + + final ValueGetter enabledCallback; + + final ValueGetter<_CupertinoBackGestureController> onStartPopGesture; + + @override + _CupertinoBackGestureDetectorState createState() => _CupertinoBackGestureDetectorState(); +} + +class _CupertinoBackGestureDetectorState extends State<_CupertinoBackGestureDetector> { + _CupertinoBackGestureController? _backGestureController; + + late HorizontalDragGestureRecognizer _recognizer; + + @override + void initState() { + super.initState(); + _recognizer = HorizontalDragGestureRecognizer(debugOwner: this) + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..onCancel = _handleDragCancel; + } + + @override + void dispose() { + _recognizer.dispose(); + + // If this is disposed during a drag, call navigator.didStopUserGesture. + if (_backGestureController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_backGestureController?.navigator.mounted ?? false) { + _backGestureController?.navigator.didStopUserGesture(); + } + _backGestureController = null; + }); + } + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + assert(mounted); + assert(_backGestureController == null); + _backGestureController = widget.onStartPopGesture(); + } + + void _handleDragUpdate(DragUpdateDetails details) { + assert(mounted); + assert(_backGestureController != null); + _backGestureController!.dragUpdate( + _convertToLogical(details.primaryDelta! / context.size!.width), + ); + } + + void _handleDragEnd(DragEndDetails details) { + assert(mounted); + assert(_backGestureController != null); + _backGestureController!.dragEnd( + _convertToLogical(details.velocity.pixelsPerSecond.dx / context.size!.width), + ); + _backGestureController = null; + } + + void _handleDragCancel() { + assert(mounted); + // This can be called even if start is not called, paired with the "down" event + // that we don't consider here. + _backGestureController?.dragEnd(0.0); + _backGestureController = null; + } + + void _handlePointerDown(PointerDownEvent event) { + if (widget.enabledCallback()) { + _recognizer.addPointer(event); + } + } + + double _convertToLogical(double value) { + return switch (Directionality.of(context)) { + TextDirection.rtl => -value, + TextDirection.ltr => value, + }; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + // For devices with notches, the drag area needs to be larger on the side + // that has the notch. + final double dragAreaWidth = switch (Directionality.of(context)) { + TextDirection.rtl => MediaQuery.paddingOf(context).right, + TextDirection.ltr => MediaQuery.paddingOf(context).left, + }; + return Stack( + fit: StackFit.passthrough, + children: [ + widget.child, + PositionedDirectional( + start: 0.0, + width: max(dragAreaWidth, _kBackGestureWidth), + top: 0.0, + bottom: 0.0, + child: Listener(onPointerDown: _handlePointerDown, behavior: HitTestBehavior.translucent), + ), + ], + ); + } +} + +/// A controller for an iOS-style back gesture. +/// +/// This is created by a [CupertinoPageRoute] in response from a gesture caught +/// by a [_CupertinoBackGestureDetector] widget, which then also feeds it input +/// from the gesture. It controls the animation controller owned by the route, +/// based on the input provided by the gesture detector. +/// +/// This class works entirely in logical coordinates (0.0 is new page dismissed, +/// 1.0 is new page on top). +/// +/// The type `T` specifies the return type of the route with which this gesture +/// detector controller is associated. +class _CupertinoBackGestureController { + /// Creates a controller for an iOS-style back gesture. + _CupertinoBackGestureController({ + required this.navigator, + required this.controller, + required this.getIsActive, + required this.getIsCurrent, + }) { + navigator.didStartUserGesture(); + } + + final AnimationController controller; + final NavigatorState navigator; + final ValueGetter getIsActive; + final ValueGetter getIsCurrent; + + /// The drag gesture has changed by [delta]. The total range of the drag + /// should be 0.0 to 1.0. + void dragUpdate(double delta) { + controller.value -= delta; + } + + /// The drag gesture has ended with a horizontal motion of [velocity] as a + /// fraction of screen width per second. + void dragEnd(double velocity) { + // Fling in the appropriate direction. + // + // This curve has been determined through rigorously eyeballing native iOS + // animations. + const Curve animationCurve = Curves.fastEaseInToSlowEaseOut; + final bool isCurrent = getIsCurrent(); + final bool animateForward; + + if (!isCurrent) { + // If the page has already been navigated away from, then the animation + // direction depends on whether or not it's still in the navigation stack, + // regardless of velocity or drag position. For example, if a route is + // being slowly dragged back by just a few pixels, but then a programmatic + // pop occurs, the route should still be animated off the screen. + // See https://github.com/flutter/flutter/issues/141268. + animateForward = getIsActive(); + } else if (velocity.abs() >= _kMinFlingVelocity) { + // If the user releases the page before mid screen with sufficient velocity, + // or after mid screen, we should animate the page out. Otherwise, the page + // should be animated back in. + animateForward = velocity <= 0; + } else { + animateForward = controller.value > 0.5; + } + + if (animateForward) { + controller.animateTo( + 1.0, + duration: _kDroppedSwipePageAnimationDuration, + curve: animationCurve, + ); + } else { + if (isCurrent) { + // This route is destined to pop at this point. Reuse navigator's pop. + navigator.pop(); + } + + // The popping may have finished inline if already at the target destination. + if (controller.isAnimating) { + controller.animateBack( + 0.0, + duration: _kDroppedSwipePageAnimationDuration, + curve: animationCurve, + ); + } + } + + if (controller.isAnimating) { + // Keep the userGestureInProgress in true state so we don't change the + // curve of the page transition mid-flight since CupertinoPageTransition + // depends on userGestureInProgress. + late AnimationStatusListener animationStatusCallback; + animationStatusCallback = (AnimationStatus status) { + navigator.didStopUserGesture(); + controller.removeStatusListener(animationStatusCallback); + }; + controller.addStatusListener(animationStatusCallback); + } else { + navigator.didStopUserGesture(); + } + } +} + +// A custom [Decoration] used to paint an extra shadow on the start edge of the +// box it's decorating. It's like a [BoxDecoration] with only a gradient except +// it paints on the start side of the box instead of behind the box. +class _CupertinoEdgeShadowDecoration extends Decoration { + const _CupertinoEdgeShadowDecoration._([this._colors]); + + static DecorationTween kTween = DecorationTween( + begin: const _CupertinoEdgeShadowDecoration._(), // No decoration initially. + end: const _CupertinoEdgeShadowDecoration._( + // Eyeballed gradient used to mimic a drop shadow on the start side only. + [Color(0x04000000), CupertinoColors.transparent], + ), + ); + + // Colors used to paint a gradient at the start edge of the box it is + // decorating. + // + // The first color in the list is used at the start of the gradient, which + // is located at the start edge of the decorated box. + // + // If this is null, no shadow is drawn. + // + // The list must have at least two colors in it (otherwise it would not be a + // gradient). + final List? _colors; + + // Linearly interpolate between two edge shadow decorations decorations. + // + // The `t` argument represents position on the timeline, with 0.0 meaning + // that the interpolation has not started, returning `a` (or something + // equivalent to `a`), 1.0 meaning that the interpolation has finished, + // returning `b` (or something equivalent to `b`), and values in between + // meaning that the interpolation is at the relevant point on the timeline + // between `a` and `b`. The interpolation can be extrapolated beyond 0.0 and + // 1.0, so negative values and values greater than 1.0 are valid (and can + // easily be generated by curves such as [Curves.elasticInOut]). + // + // Values for `t` are usually obtained from an [Animation], such as + // an [AnimationController]. + // + // See also: + // + // * [Decoration.lerp]. + static _CupertinoEdgeShadowDecoration? lerp( + _CupertinoEdgeShadowDecoration? a, + _CupertinoEdgeShadowDecoration? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + if (a == null) { + return b!._colors == null + ? b + : _CupertinoEdgeShadowDecoration._( + b._colors!.map((Color color) => Color.lerp(null, color, t)!).toList(), + ); + } + if (b == null) { + return a._colors == null + ? a + : _CupertinoEdgeShadowDecoration._( + a._colors.map((Color color) => Color.lerp(null, color, 1.0 - t)!).toList(), + ); + } + assert(b._colors != null || a._colors != null); + // If it ever becomes necessary, we could allow decorations with different + // length' here, similarly to how it is handled in [LinearGradient.lerp]. + assert(b._colors == null || a._colors == null || a._colors.length == b._colors.length); + return _CupertinoEdgeShadowDecoration._([ + for (int i = 0; i < b._colors!.length; i += 1) Color.lerp(a._colors?[i], b._colors[i], t)!, + ]); + } + + @override + _CupertinoEdgeShadowDecoration lerpFrom(Decoration? a, double t) { + if (a is _CupertinoEdgeShadowDecoration) { + return _CupertinoEdgeShadowDecoration.lerp(a, this, t)!; + } + return _CupertinoEdgeShadowDecoration.lerp(null, this, t)!; + } + + @override + _CupertinoEdgeShadowDecoration lerpTo(Decoration? b, double t) { + if (b is _CupertinoEdgeShadowDecoration) { + return _CupertinoEdgeShadowDecoration.lerp(this, b, t)!; + } + return _CupertinoEdgeShadowDecoration.lerp(this, null, t)!; + } + + @override + _CupertinoEdgeShadowPainter createBoxPainter([VoidCallback? onChanged]) { + return _CupertinoEdgeShadowPainter(this, onChanged); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is _CupertinoEdgeShadowDecoration && other._colors == _colors; + } + + @override + int get hashCode => _colors.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('colors', _colors)); + } +} + +/// A [BoxPainter] used to draw the page transition shadow using gradients. +class _CupertinoEdgeShadowPainter extends BoxPainter { + _CupertinoEdgeShadowPainter(this._decoration, super.onChanged) + : assert(_decoration._colors == null || _decoration._colors.length > 1); + + final _CupertinoEdgeShadowDecoration _decoration; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final List? colors = _decoration._colors; + if (colors == null) { + return; + } + + // The following code simulates drawing a [LinearGradient] configured as + // follows: + // + // LinearGradient( + // begin: AlignmentDirectional(0.90, 0.0), // Spans 5% of the page. + // colors: _decoration._colors, + // ) + // + // A performance evaluation on Feb 8, 2021 showed, that drawing the gradient + // manually as implemented below is more performant than relying on + // [LinearGradient.createShader] because compiling that shader takes a long + // time. On an iPhone XR, the implementation below reduced the worst frame + // time for a cupertino page transition of a newly installed app from ~95ms + // down to ~30ms, mainly because there's no longer a need to compile a + // shader for the LinearGradient. + // + // The implementation below divides the width of the shadow into multiple + // bands of equal width, one for each color interval defined by + // `_decoration._colors`. Band x is filled with a gradient going from + // `_decoration._colors[x]` to `_decoration._colors[x + 1]` by drawing a + // bunch of 1px wide rects. The rects change their color by lerping between + // the two colors that define the interval of the band. + + // Shadow spans 5% of the page. + final double shadowWidth = 0.05 * configuration.size!.width; + final double shadowHeight = configuration.size!.height; + final double bandWidth = shadowWidth / (colors.length - 1); + + final TextDirection? textDirection = configuration.textDirection; + assert(textDirection != null); + final (double shadowDirection, double start) = switch (textDirection!) { + TextDirection.rtl => (1, offset.dx + configuration.size!.width), + TextDirection.ltr => (-1, offset.dx), + }; + + var bandColorIndex = 0; + for (var dx = 0; dx < shadowWidth; dx += 1) { + if (dx ~/ bandWidth != bandColorIndex) { + bandColorIndex += 1; + } + final paint = Paint() + ..color = Color.lerp( + colors[bandColorIndex], + colors[bandColorIndex + 1], + (dx % bandWidth) / bandWidth, + )!; + final double x = start + shadowDirection * dx; + canvas.drawRect(Rect.fromLTWH(x - 1.0, offset.dy, 1.0, shadowHeight), paint); + } + } +} + +// The stiffness used by dialogs and action sheets. +// +// The stiffness value is obtained by examining the properties of +// `CASpringAnimation` in Xcode. The damping value is derived similarly, with +// additional precision calculated based on `_kStandardStiffness` to ensure a +// damping ratio of 1 (critically damped): damping = 2 * sqrt(stiffness) +const double _kStandardStiffness = 522.35; +const double _kStandardDamping = 45.7099552; +const SpringDescription _kStandardSpring = SpringDescription( + mass: 1, + stiffness: _kStandardStiffness, + damping: _kStandardDamping, +); +// The iOS spring animation duration is 0.404 seconds, based on the properties +// of `CASpringAnimation` in Xcode. At this point, the spring's position +// `x(0.404)` is approximately 0.9990000, suggesting that iOS uses a position +// tolerance of 1e-3 (matching the default `_epsilonDefault` value). +// +// However, the spring's velocity `dx(0.404)` is about 0.02, indicating that iOS +// may not consider velocity when determining the animation's end condition. To +// account for this, a larger velocity tolerance is applied here for added +// safety. +const Tolerance _kStandardTolerance = Tolerance(velocity: 0.03); + +/// A route that shows a modal iOS-style popup that slides up from the +/// bottom of the screen. +/// +/// Such a popup is an alternative to a menu or a dialog and prevents the user +/// from interacting with the rest of the app. +/// +/// It is used internally by [showCupertinoModalPopup] or can be directly pushed +/// onto the [Navigator] stack to enable state restoration. See +/// [showCupertinoModalPopup] for a state restoration app example. +/// +/// The `barrierColor` argument determines the [Color] of the barrier underneath +/// the popup. When unspecified, the barrier color defaults to a light opacity +/// black scrim based on iOS's dialog screens. To correctly have iOS resolve +/// to the appropriate modal colors, pass in +/// `CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context)`. +/// +/// The `barrierDismissible` argument determines whether clicking outside the +/// popup results in dismissal. It is `true` by default. +/// +/// The `semanticsDismissible` argument is used to determine whether the +/// semantics of the modal barrier are included in the semantics tree. +/// +/// The `routeSettings` argument is used to provide [RouteSettings] to the +/// created Route. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// See also: +/// +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * [CupertinoActionSheet], which is the widget usually returned by the +/// `builder` argument. +/// * +class CupertinoModalPopupRoute extends PopupRoute { + /// A route that shows a modal iOS-style popup that slides up from the + /// bottom of the screen. + CupertinoModalPopupRoute({ + required this.builder, + this.barrierLabel = 'Dismiss', + this.barrierColor = kCupertinoModalBarrierColor, + bool barrierDismissible = true, + bool semanticsDismissible = false, + super.filter, + super.settings, + super.requestFocus, + this.anchorPoint, + }) : _barrierDismissible = barrierDismissible, + _semanticsDismissible = semanticsDismissible; + + /// A builder that builds the widget tree for the [CupertinoModalPopupRoute]. + /// + /// The [builder] argument typically builds a [CupertinoActionSheet] widget. + /// + /// Content below the widget is dimmed with a [ModalBarrier]. The widget built + /// by the [builder] does not share a context with the route it was originally + /// built from. Use a [StatefulBuilder] or a custom [StatefulWidget] if the + /// widget needs to update dynamically. + final WidgetBuilder builder; + + final bool _barrierDismissible; + + final bool _semanticsDismissible; + + @override + final String barrierLabel; + + @override + final Color? barrierColor; + + @override + bool get barrierDismissible => _barrierDismissible; + + @override + bool get semanticsDismissible => _semanticsDismissible; + + @override + Duration get transitionDuration => _kModalPopupTransitionDuration; + + /// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint} + final Offset? anchorPoint; + + @override + Simulation createSimulation({required bool forward}) { + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); + final end = forward ? 1.0 : 0.0; + return SpringSimulation( + _kStandardSpring, + controller!.value, + end, + 0, + tolerance: _kStandardTolerance, + snapToEnd: true, + ); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: DisplayFeatureSubScreen( + anchorPoint: anchorPoint, + child: Builder(builder: builder), + ), + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return Align( + alignment: Alignment.bottomCenter, + child: FractionalTranslation(translation: _offsetTween.evaluate(animation), child: child), + ); + } + + static final Tween _offsetTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ); +} + +/// Shows a modal iOS-style popup that slides up from the bottom of the screen. +/// +/// Such a popup is an alternative to a menu or a dialog and prevents the user +/// from interacting with the rest of the app. +/// +/// The `context` argument is used to look up the [Navigator] for the popup. +/// It is only used when the method is called. Its corresponding widget can be +/// safely removed from the tree before the popup is closed. +/// +/// The `barrierColor` argument determines the [Color] of the barrier underneath +/// the popup. When unspecified, the barrier color defaults to a light opacity +/// black scrim based on iOS's dialog screens. +/// +/// The `barrierDismissible` argument determines whether clicking outside the +/// popup results in dismissal. It is `true` by default. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// popup to the [Navigator] furthest from or nearest to the given `context`. It +/// is `true` by default. +/// +/// The `semanticsDismissible` argument is used to determine whether the +/// semantics of the modal barrier are included in the semantics tree. +/// +/// The `routeSettings` argument is used to provide [RouteSettings] to the +/// created Route. +/// +/// The `builder` argument typically builds a [CupertinoActionSheet] widget. +/// Content below the widget is dimmed with a [ModalBarrier]. The widget built +/// by the `builder` does not share a context with the location that +/// [showCupertinoModalPopup] is originally called from. Use a +/// [StatefulBuilder] or a custom [StatefulWidget] if the widget needs to +/// update dynamically. +/// +/// The [requestFocus] parameter is used to specify whether the popup should +/// request focus when shown. +/// {@macro flutter.widgets.navigator.Route.requestFocus} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// Returns a `Future` that resolves to the value that was passed to +/// [Navigator.pop] when the popup was closed. +/// +/// ### State Restoration in Modals +/// +/// Using this method will not enable state restoration for the modal. In order +/// to enable state restoration for a modal, use [Navigator.restorablePush] +/// or [Navigator.restorablePushNamed] with [CupertinoModalPopupRoute]. +/// +/// For more information about state restoration, see [RestorationManager]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a restorable Cupertino modal route. +/// This is accomplished by enabling state restoration by specifying +/// [CupertinoApp.restorationScopeId] and using [Navigator.restorablePush] to +/// push [CupertinoModalPopupRoute] when the [CupertinoButton] is tapped. +/// +/// {@macro flutter.widgets.RestorationManager} +/// +/// ** See code in examples/api/lib/cupertino/route/show_cupertino_modal_popup.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * [CupertinoActionSheet], which is the widget usually returned by the +/// `builder` argument to [showCupertinoModalPopup]. +/// * +Future showCupertinoModalPopup({ + required BuildContext context, + required WidgetBuilder builder, + ImageFilter? filter, + Color barrierColor = kCupertinoModalBarrierColor, + bool barrierDismissible = true, + bool useRootNavigator = true, + bool semanticsDismissible = false, + RouteSettings? routeSettings, + Offset? anchorPoint, + bool? requestFocus, +}) { + return Navigator.of(context, rootNavigator: useRootNavigator).push( + CupertinoModalPopupRoute( + builder: builder, + filter: filter, + barrierColor: CupertinoDynamicColor.resolve(barrierColor, context), + barrierDismissible: barrierDismissible, + semanticsDismissible: semanticsDismissible, + settings: routeSettings, + anchorPoint: anchorPoint, + requestFocus: requestFocus, + ), + ); +} + +Widget _buildCupertinoDialogTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, +) { + return child; +} + +/// Displays an iOS-style dialog above the current contents of the app, with +/// iOS-style entrance and exit animations, modal barrier color, and modal +/// barrier behavior (by default, the dialog is not dismissible with a tap on +/// the barrier). +/// +/// This function takes a `builder` which typically builds a [CupertinoAlertDialog] +/// widget. Content below the dialog is dimmed with a [ModalBarrier]. The widget +/// returned by the `builder` does not share a context with the location that +/// [showCupertinoDialog] is originally called from. Use a [StatefulBuilder] or +/// a custom [StatefulWidget] if the dialog needs to update dynamically. +/// +/// The `context` argument is used to look up the [Navigator] for the dialog. +/// It is only used when the method is called. Its corresponding widget can +/// be safely removed from the tree before the dialog is closed. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// dialog to the [Navigator] furthest from or nearest to the given `context`. +/// By default, `useRootNavigator` is `true` and the dialog route created by +/// this method is pushed to the root navigator. +/// +/// {@macro flutter.material.dialog.requestFocus} +/// {@macro flutter.widgets.navigator.Route.requestFocus} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// If the application has multiple [Navigator] objects, it may be necessary to +/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the +/// dialog rather than just `Navigator.pop(context, result)`. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the dialog was closed. +/// +/// ### State Restoration in Dialogs +/// +/// Using this method will not enable state restoration for the dialog. In order +/// to enable state restoration for a dialog, use [Navigator.restorablePush] +/// or [Navigator.restorablePushNamed] with [CupertinoDialogRoute]. +/// +/// For more information about state restoration, see [RestorationManager]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a restorable Cupertino dialog. This is +/// accomplished by enabling state restoration by specifying +/// [CupertinoApp.restorationScopeId] and using [Navigator.restorablePush] to +/// push [CupertinoDialogRoute] when the [CupertinoButton] is tapped. +/// +/// {@macro flutter.widgets.RestorationManager} +/// +/// ** See code in examples/api/lib/cupertino/route/show_cupertino_dialog.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoAlertDialog], an iOS-style alert dialog. +/// * [showDialog], which displays a Material-style dialog. +/// * [showGeneralDialog], which allows for customization of the dialog popup. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * +Future showCupertinoDialog({ + required BuildContext context, + required WidgetBuilder builder, + String? barrierLabel, + Color? barrierColor, + bool useRootNavigator = true, + bool barrierDismissible = false, + RouteSettings? routeSettings, + Offset? anchorPoint, + bool? requestFocus, +}) { + return Navigator.of(context, rootNavigator: useRootNavigator).push( + CupertinoDialogRoute( + builder: builder, + context: context, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + barrierColor: barrierColor, + settings: routeSettings, + anchorPoint: anchorPoint, + requestFocus: requestFocus, + ), + ); +} + +/// A dialog route that shows an iOS-style dialog. +/// +/// It is used internally by [showCupertinoDialog] or can be directly pushed +/// onto the [Navigator] stack to enable state restoration. See +/// [showCupertinoDialog] for a state restoration app example. +/// +/// This function takes a `builder` which typically builds a [Dialog] widget. +/// Content below the dialog is dimmed with a [ModalBarrier]. The widget +/// returned by the `builder` does not share a context with the location that +/// `showDialog` is originally called from. Use a [StatefulBuilder] or a +/// custom [StatefulWidget] if the dialog needs to update dynamically. +/// +/// The `context` argument is used to look up +/// [CupertinoLocalizations.modalBarrierDismissLabel], which provides the +/// modal with a localized accessibility label that will be used for the +/// modal's barrier. However, a custom `barrierLabel` can be passed in as well. +/// +/// The `barrierDismissible` argument is used to indicate whether tapping on the +/// barrier will dismiss the dialog. It is `true` by default and cannot be `null`. +/// +/// The `barrierColor` argument is used to specify the color of the modal +/// barrier that darkens everything below the dialog. If `null`, then +/// [CupertinoDynamicColor.resolve] is used to compute the modal color. +/// +/// The `settings` argument define the settings for this route. See +/// [RouteSettings] for details. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// See also: +/// +/// * [showCupertinoDialog], which is a way to display +/// an iOS-style dialog. +/// * [showGeneralDialog], which allows for customization of the dialog popup. +/// * [showDialog], which displays a Material dialog. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +class CupertinoDialogRoute extends RawDialogRoute { + /// A dialog route that shows an iOS-style dialog. + CupertinoDialogRoute({ + required WidgetBuilder builder, + required BuildContext context, + super.barrierDismissible, + Color? barrierColor, + String? barrierLabel, + // This transition duration was eyeballed comparing with iOS + super.transitionDuration = const Duration(milliseconds: 250), + this.transitionBuilder, + super.settings, + super.requestFocus, + super.anchorPoint, + }) : super( + pageBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return builder(context); + }, + transitionBuilder: transitionBuilder ?? _buildCupertinoDialogTransitions, + barrierLabel: barrierLabel ?? CupertinoLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: + barrierColor ?? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context), + ); + + /// Custom transition builder + RouteTransitionsBuilder? transitionBuilder; + + CurvedAnimation? _fadeAnimation; + + @override + Simulation createSimulation({required bool forward}) { + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); + final end = forward ? 1.0 : 0.0; + return SpringSimulation( + _kStandardSpring, + controller!.value, + end, + 0, + tolerance: _kStandardTolerance, + snapToEnd: true, + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (transitionBuilder != null) { + return super.buildTransitions(context, animation, secondaryAnimation, child); + } + + if (animation.status == AnimationStatus.reverse) { + return FadeTransition(opacity: animation, child: child); + } + return FadeTransition( + opacity: animation, + child: ScaleTransition(scale: animation.drive(_dialogScaleTween), child: child), + ); + } + + @override + void dispose() { + _fadeAnimation?.dispose(); + super.dispose(); + } + + // The curve and initial scale values were mostly eyeballed from iOS, however + // they reuse the same animation curve that was modeled after native page + // transitions. + static final Tween _dialogScaleTween = Tween(begin: 1.3, end: 1.0); +} + +/// A [PageTransitionsBuilder] that provides an iOS-style page transition +/// animation. +/// +/// The page slides in from the right and exits in reverse. It also shifts +/// to the left in a parallax motion when another page enters to cover it. +/// This transition is commonly seen in native iOS applications. +/// +/// In a [CupertinoApp], this transition is used automatically when navigating +/// with [CupertinoPageRoute]. +/// +/// See also: +/// +/// * [CupertinoPageRoute], which uses this transition style by default for +/// Cupertino apps. +/// * [CupertinoPageTransition], the widget that implements the iOS page +/// transition animation. +/// * [MaterialPageRoute], an adaptive [PageRoute] that can use this builder +/// through [PageTransitionsTheme]. +/// * [PageTransitionsTheme], which defines the page transitions used by +/// [MaterialPageRoute] for different target platforms. +class CupertinoPageTransitionsBuilder extends PageTransitionsBuilder { + /// Constructs a page transition animation that matches the iOS transition. + const CupertinoPageTransitionsBuilder(); + + @override + Duration get transitionDuration => CupertinoRouteTransitionMixin.kTransitionDuration; + + @override + DelegatedTransitionBuilder? get delegatedTransition => + CupertinoPageTransition.delegatedTransition; + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return CupertinoRouteTransitionMixin.buildPageTransitions( + route, + context, + animation, + secondaryAnimation, + child, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/scrollbar.dart b/packages/cupertino_ui/lib/src/scrollbar.dart new file mode 100644 index 000000000000..cf2b5d80a54e --- /dev/null +++ b/packages/cupertino_ui/lib/src/scrollbar.dart @@ -0,0 +1,231 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; + +// All values eyeballed. +const double _kScrollbarMinLength = 36.0; +const double _kScrollbarMinOverscrollLength = 8.0; +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); + +// Extracted from iOS 13.1 beta using Debug View Hierarchy. +const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: Color(0x59000000), + darkColor: Color(0x80FFFFFF), +); + +// This is the amount of space from the top of a vertical scrollbar to the +// top edge of the scrollable, measured when the vertical scrollbar overscrolls +// to the top. +// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 3.0; + +/// An iOS style scrollbar. +/// +/// To add a scrollbar to a [ScrollView], wrap the scroll view widget in +/// a [CupertinoScrollbar] widget. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc} +/// +/// {@macro flutter.widgets.Scrollbar} +/// +/// When dragging a [CupertinoScrollbar] thumb, the thickness and radius will +/// animate from [thickness] and [radius] to [thicknessWhileDragging] and +/// [radiusWhileDragging], respectively. +/// +/// {@tool dartpad} +/// This sample shows a [CupertinoScrollbar] that fades in and out of view as scrolling occurs. +/// The scrollbar will fade into view as the user scrolls, and fade out when scrolling stops. +/// The `thickness` of the scrollbar will animate from 6 pixels to the `thicknessWhileDragging` of 10 +/// when it is dragged by the user. The `radius` of the scrollbar thumb corners will animate from 34 +/// to the `radiusWhileDragging` of 0 when the scrollbar is being dragged by the user. +/// +/// ** See code in examples/api/lib/cupertino/scrollbar/cupertino_scrollbar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// When [thumbVisibility] is true, the scrollbar thumb will remain visible without the +/// fade animation. This requires that a [ScrollController] is provided to controller, +/// or that the [PrimaryScrollController] is available. +/// +/// ** See code in examples/api/lib/cupertino/scrollbar/cupertino_scrollbar.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListView], which displays a linear, scrollable list of children. +/// * [GridView], which displays a 2 dimensional, scrollable array of children. +/// * [Scrollbar], a Material Design scrollbar. +/// * [RawScrollbar], a basic scrollbar that fades in and out, extended +/// by this class to add more animations and behaviors. +class CupertinoScrollbar extends RawScrollbar { + /// Creates an iOS style scrollbar that wraps the given [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + const CupertinoScrollbar({ + super.key, + required super.child, + super.controller, + bool? thumbVisibility, + double super.thickness = defaultThickness, + this.thicknessWhileDragging = defaultThicknessWhileDragging, + Radius super.radius = defaultRadius, + this.radiusWhileDragging = defaultRadiusWhileDragging, + ScrollNotificationPredicate? notificationPredicate, + super.scrollbarOrientation, + super.mainAxisMargin = _kScrollbarMainAxisMargin, + }) : assert(thickness < double.infinity), + assert(thicknessWhileDragging < double.infinity), + super( + thumbVisibility: thumbVisibility ?? false, + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + pressDuration: const Duration(milliseconds: 100), + notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + /// Default value for [thickness] if it's not specified in [CupertinoScrollbar]. + static const double defaultThickness = 3; + + /// Default value for [thicknessWhileDragging] if it's not specified in + /// [CupertinoScrollbar]. + static const double defaultThicknessWhileDragging = 8.0; + + /// Default value for [radius] if it's not specified in [CupertinoScrollbar]. + static const Radius defaultRadius = Radius.circular(1.5); + + /// Default value for [radiusWhileDragging] if it's not specified in + /// [CupertinoScrollbar]. + static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); + + /// The thickness of the scrollbar when it's being dragged by the user. + /// + /// When the user starts dragging the scrollbar, the thickness will animate + /// from [thickness] to this value, then animate back when the user stops + /// dragging the scrollbar. + final double thicknessWhileDragging; + + /// The radius of the scrollbar edges when the scrollbar is being dragged by + /// the user. + /// + /// When the user starts dragging the scrollbar, the radius will animate + /// from [radius] to this value, then animate back when the user stops + /// dragging the scrollbar. + final Radius radiusWhileDragging; + + @override + RawScrollbarState createState() => _CupertinoScrollbarState(); +} + +class _CupertinoScrollbarState extends RawScrollbarState { + late AnimationController _thicknessAnimationController; + + double get _thickness { + return widget.thickness! + + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness!); + } + + Radius get _radius { + return Radius.lerp( + widget.radius, + widget.radiusWhileDragging, + _thicknessAnimationController.value, + )!; + } + + @override + void initState() { + super.initState(); + _thicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _thicknessAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context) + ..textDirection = Directionality.of(context) + ..thickness = _thickness + ..mainAxisMargin = widget.mainAxisMargin + ..crossAxisMargin = _kScrollbarCrossAxisMargin + ..radius = _radius + ..padding = MediaQuery.paddingOf(context) + ..minLength = _kScrollbarMinLength + ..minOverscrollLength = _kScrollbarMinOverscrollLength + ..scrollbarOrientation = widget.scrollbarOrientation; + } + + double _pressStartAxisPosition = 0.0; + + // Long press event callbacks handle the gesture where the user long presses + // on the scrollbar thumb and then drags the scrollbar without releasing. + + @override + void handleThumbPressStart(Offset localPosition) { + super.handleThumbPressStart(localPosition); + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _pressStartAxisPosition = switch (direction) { + Axis.vertical => localPosition.dy, + Axis.horizontal => localPosition.dx, + }; + } + + @override + void handleThumbPress() { + if (getScrollbarDirection() == null) { + return; + } + super.handleThumbPress(); + _thicknessAnimationController.forward().then((_) => HapticFeedback.mediumImpact()); + } + + @override + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _thicknessAnimationController.reverse(); + super.handleThumbPressEnd(localPosition, velocity); + final (double axisPosition, double axisVelocity) = switch (direction) { + Axis.horizontal => (localPosition.dx, velocity.pixelsPerSecond.dx), + Axis.vertical => (localPosition.dy, velocity.pixelsPerSecond.dy), + }; + if (axisPosition != _pressStartAxisPosition && axisVelocity.abs() < 10) { + HapticFeedback.mediumImpact(); + } + } + + @override + void handleTrackTapDown(TapDownDetails details) { + // On iOS, tapping the track does not page towards the position of the tap. + if (ScrollConfiguration.of(context).getPlatform(context) != TargetPlatform.iOS) { + super.handleTrackTapDown(details); + } + } + + @override + void dispose() { + _thicknessAnimationController.dispose(); + super.dispose(); + } +} diff --git a/packages/cupertino_ui/lib/src/search_field.dart b/packages/cupertino_ui/lib/src/search_field.dart new file mode 100644 index 000000000000..aeb8588e1eff --- /dev/null +++ b/packages/cupertino_ui/lib/src/search_field.dart @@ -0,0 +1,591 @@ +// 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 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'colors.dart'; +import 'icons.dart'; +import 'localizations.dart'; +import 'text_field.dart'; + +export 'package:flutter/services.dart' show SmartDashesType, SmartQuotesType; + +// The fraction of the height of the search text field after which its contents +// completely fade out when resized on scroll. +// +// Eyeballed on an iPhone 15 simulator running iOS 17.5. +const double _kMinHeightBeforeTotalTransparency = 4 / 5; + +// The maximum icon size of the prefix icon of a focused search field before it +// is hidden in higher accessibility text scale modes. +// +// Eyeballed on an iPhone 15 simulator running iOS 17.5. +const double _kMaxPrefixIconSize = 30.0; + +/// A [CupertinoTextField] that mimics the look and behavior of UIKit's +/// `UISearchTextField`. +/// +/// This control defaults to showing the basic parts of a `UISearchTextField`, +/// like the 'Search' placeholder, prefix-ed Search icon, and suffix-ed +/// X-Mark icon. +/// +/// To control the text that is displayed in the text field, use the +/// [controller]. For example, to set the initial value of the text field, use +/// a [controller] that already contains some text such as: +/// +/// {@tool dartpad} +/// This examples shows how to provide initial text to a [CupertinoSearchTextField] +/// using the [controller] property. +/// +/// ** See code in examples/api/lib/cupertino/search_field/cupertino_search_field.0.dart ** +/// {@end-tool} +/// +/// It is recommended to pass a [ValueChanged] to both [onChanged] and +/// [onSubmitted] parameters in order to be notified once the value of the +/// field changes or is submitted by the keyboard: +/// +/// {@tool dartpad} +/// This examples shows how to be notified of field changes or submitted text from +/// a [CupertinoSearchTextField]. +/// +/// ** See code in examples/api/lib/cupertino/search_field/cupertino_search_field.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * +class CupertinoSearchTextField extends StatefulWidget { + /// Creates a [CupertinoTextField] that mimics the look and behavior of + /// UIKit's `UISearchTextField`. + /// + /// Similar to [CupertinoTextField], to provide a prefilled text entry, pass + /// in a [TextEditingController] with an initial value to the [controller] + /// parameter. + /// + /// The [onChanged] parameter takes a [ValueChanged] which is invoked + /// upon a change in the text field's value. + /// + /// The [onSubmitted] parameter takes a [ValueChanged] which is + /// invoked when the keyboard submits. + /// + /// To provide a hint placeholder text that appears when the text entry is + /// empty, pass a [String] to the [placeholder] parameter. This defaults to + /// 'Search'. + // TODO(DanielEdrisian): Localize the 'Search' placeholder. + /// + /// The [style] and [placeholderStyle] properties allow changing the style of + /// the text and placeholder of the text field. [placeholderStyle] defaults + /// to the gray [CupertinoColors.secondaryLabel] iOS color. + /// + /// To set the text field's background color and border radius, pass a + /// [BoxDecoration] to the [decoration] parameter. This defaults to the + /// default translucent tertiarySystemFill iOS color and 9 px corner radius. + // TODO(DanielEdrisian): Must make border radius continuous, see + // https://github.com/flutter/flutter/issues/13914. + /// + /// The [itemColor] and [itemSize] properties allow changing the icon color + /// and icon size of the search icon (prefix) and X-Mark (suffix). + /// They default to [CupertinoColors.secondaryLabel] and `20.0`. + /// + /// The [padding], [prefixInsets], and [suffixInsets] let you set the padding + /// insets for text, the search icon (prefix), and the X-Mark icon (suffix). + /// They default to values that replicate the `UISearchTextField` look. These + /// default fields were determined using the comparison tool in + /// https://github.com/flutter/platform_tests/. + /// + /// To customize the prefix icon, pass a [Widget] to [prefixIcon]. This + /// defaults to the search icon. + /// + /// To customize the suffix icon, pass an [Icon] to [suffixIcon]. This + /// defaults to the X-Mark. + /// + /// To dictate when the X-Mark (suffix) should be visible, a.k.a. only on when + /// editing, not editing, on always, or on never, pass a + /// [OverlayVisibilityMode] to [suffixMode]. This defaults to only on when + /// editing. + /// + /// To customize the X-Mark (suffix) action, pass a [VoidCallback] to + /// [onSuffixTap]. This defaults to clearing the text. + const CupertinoSearchTextField({ + super.key, + this.controller, + this.onChanged, + this.onSubmitted, + this.style, + this.placeholder, + this.placeholderStyle, + this.decoration, + this.backgroundColor, + this.borderRadius, + this.keyboardType = TextInputType.text, + this.padding = const EdgeInsetsDirectional.fromSTEB(5.5, 8, 5.5, 8), + this.itemColor = CupertinoColors.secondaryLabel, + this.itemSize = 20.0, + this.prefixInsets = const EdgeInsetsDirectional.fromSTEB(6, 8, 0, 8), + this.prefixIcon = const Icon(CupertinoIcons.search), + this.suffixInsets = const EdgeInsetsDirectional.fromSTEB(0, 8, 5, 8), + this.suffixIcon = const Icon(CupertinoIcons.xmark_circle_fill), + this.suffixMode = OverlayVisibilityMode.editing, + this.onSuffixTap, + this.restorationId, + this.focusNode, + this.smartQuotesType, + this.smartDashesType, + this.enableIMEPersonalizedLearning = true, + this.autofocus = false, + this.onTap, + this.autocorrect = true, + this.enabled, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius = const Radius.circular(2.0), + this.cursorOpacityAnimates = true, + this.cursorColor, + }) : assert( + !((decoration != null) && (backgroundColor != null)), + 'Cannot provide both a background color and a decoration\n' + 'The backgroundColor argument is just a shorthand for ' + '"decoration: BoxDecoration(color: backgroundColor)".\n' + 'To use both a backgroundColor and other decoration properties, set the color in the BoxDecoration instead.', + ), + assert( + !((decoration != null) && (borderRadius != null)), + 'Cannot provide both a border radius and a decoration\n' + 'The borderRadius argument is just a shorthand for ' + '"decoration: BoxDecoration(borderRadius: borderRadius)".\n' + 'To use both a radius and other decoration properties, set the radius in the BoxDecoration instead.', + ); + + /// Controls the text being edited. + /// + /// Similar to [CupertinoTextField], to provide a prefilled text entry, pass + /// in a [TextEditingController] with an initial value to the [controller] + /// parameter. Defaults to creating its own [TextEditingController]. + final TextEditingController? controller; + + /// Invoked upon user input. + final ValueChanged? onChanged; + + /// Invoked upon keyboard submission. + final ValueChanged? onSubmitted; + + /// Allows changing the style of the text. + /// + /// Defaults to the gray [CupertinoColors.secondaryLabel] iOS color. + final TextStyle? style; + + /// A hint placeholder text that appears when the text entry is empty. + /// + /// Defaults to 'Search' localized in each supported language. + final String? placeholder; + + /// Sets the style of the placeholder of the text field. + /// + /// Defaults to the gray [CupertinoColors.secondaryLabel] iOS color. + final TextStyle? placeholderStyle; + + /// Sets the decoration for the text field. + /// + /// This property is automatically set using the [backgroundColor] and + /// [borderRadius] properties, which both have default values. Therefore, + /// [decoration] has a default value upon building the widget. It is designed + /// to mimic the look of a `UISearchTextField`. + final BoxDecoration? decoration; + + /// Set the [decoration] property's background color. + /// + /// Can't be set along with the [decoration]. Defaults to the translucent + /// [CupertinoColors.tertiarySystemFill] iOS color. + final Color? backgroundColor; + + /// Sets the [decoration] property's border radius. + /// + /// Can't be set along with the [decoration]. Defaults to 9 px circular + /// corner radius. + // TODO(DanielEdrisian): Must make border radius continuous, see + // https://github.com/flutter/flutter/issues/13914. + final BorderRadius? borderRadius; + + /// The keyboard type for this search field. + /// + /// Defaults to [TextInputType.text]. + final TextInputType? keyboardType; + + /// Sets the padding insets for the text and placeholder. + /// + /// Defaults to padding that replicates the `UISearchTextField` look. The + /// inset values were determined using the comparison tool in + /// https://github.com/flutter/platform_tests/. + final EdgeInsetsGeometry padding; + + /// Sets the color for the suffix and prefix icons. + /// + /// Defaults to [CupertinoColors.secondaryLabel]. + final Color itemColor; + + /// Sets the base icon size for the suffix and prefix icons. + /// + /// The size of the icon is scaled using the accessibility font scale + /// settings. Defaults to `20.0`. + final double itemSize; + + /// Sets the padding insets for the suffix. + /// + /// Defaults to padding that replicates the `UISearchTextField` suffix look. + /// The inset values were determined using the comparison tool in + /// https://github.com/flutter/platform_tests/. + final EdgeInsetsGeometry prefixInsets; + + /// Sets a prefix widget. + /// + /// Defaults to an [Icon] widget with the [CupertinoIcons.search] icon. + final Widget prefixIcon; + + /// Sets the padding insets for the prefix. + /// + /// Defaults to padding that replicates the `UISearchTextField` prefix look. + /// The inset values were determined using the comparison tool in + /// https://github.com/flutter/platform_tests/. + final EdgeInsetsGeometry suffixInsets; + + /// Sets the suffix widget's icon. + /// + /// Defaults to the X-Mark [CupertinoIcons.xmark_circle_fill]. "To change the + /// functionality of the suffix icon, provide a custom onSuffixTap callback + /// and specify an intuitive suffixIcon. + final Icon suffixIcon; + + /// Dictates when the X-Mark (suffix) should be visible. + /// + /// Defaults to only on when editing. + final OverlayVisibilityMode suffixMode; + + /// Sets the X-Mark (suffix) action. + /// + /// Defaults to clearing the text. The suffix action is customizable + /// so that users can override it with other functionality, that isn't + /// necessarily clearing text. + final VoidCallback? onSuffixTap; + + /// {@macro flutter.material.textfield.restorationId} + final String? restorationId; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.material.textfield.onTap} + final VoidCallback? onTap; + + /// Whether to enable autocorrection. + /// + /// Defaults to true. + final bool autocorrect; + + /// Whether to allow the platform to automatically format quotes. + /// + /// This flag only affects iOS, where it is equivalent to [`UITextSmartQuotesType`](https://developer.apple.com/documentation/uikit/uitextsmartquotestype?language=objc). + /// + /// When set to [SmartQuotesType.enabled], it passes + /// [`UITextSmartQuotesTypeYes`](https://developer.apple.com/documentation/uikit/uitextsmartquotestype/uitextsmartquotestypeyes?language=objc), + /// and when set to [SmartQuotesType.disabled], it passes + /// [`UITextSmartQuotesTypeNo`](https://developer.apple.com/documentation/uikit/uitextsmartquotestype/uitextsmartquotestypeno?language=objc). + /// + /// If set to null, [SmartQuotesType.enabled] will be used. + /// + /// As an example of what this does, a standard vertical double quote + /// character will be automatically replaced by a left or right double quote + /// depending on its position in a word. + /// + /// Defaults to null. + /// + /// See also: + /// + /// * [smartDashesType] + /// * + final SmartQuotesType? smartQuotesType; + + /// Whether to allow the platform to automatically format dashes. + /// + /// This flag only affects iOS versions 11 and above, where it is equivalent to [`UITextSmartDashesType`](https://developer.apple.com/documentation/uikit/uitextsmartdashestype?language=objc). + /// + /// When set to [SmartDashesType.enabled], it passes + /// [`UITextSmartDashesTypeYes`](https://developer.apple.com/documentation/uikit/uitextsmartdashestype/uitextsmartdashestypeyes?language=objc), + /// and when set to [SmartDashesType.disabled], it passes + /// [`UITextSmartDashesTypeNo`](https://developer.apple.com/documentation/uikit/uitextsmartdashestype/uitextsmartdashestypeno?language=objc). + /// + /// If set to null, [SmartDashesType.enabled] will be used. + /// + /// As an example of what this does, two consecutive hyphen characters will be + /// automatically replaced with one en dash, and three consecutive hyphens + /// will become one em dash. + /// + /// Defaults to null. + /// + /// See also: + /// + /// * [smartQuotesType] + /// * + final SmartDashesType? smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + /// Disables the text field when false. + /// + /// Text fields in disabled states have a light grey background and don't + /// respond to touch events including the [prefixIcon] and [suffixIcon] button. + final bool? enabled; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius cursorRadius; + + /// {@macro flutter.widgets.editableText.cursorOpacityAnimates} + final bool cursorOpacityAnimates; + + /// The color to use when painting the cursor. + final Color? cursorColor; + + @override + State createState() => _CupertinoSearchTextFieldState(); +} + +class _CupertinoSearchTextFieldState extends State with RestorationMixin { + /// Default value for the border radius. Radius value was determined using the + /// comparison tool in https://github.com/flutter/platform_tests/. + final BorderRadius _kDefaultBorderRadius = const BorderRadius.all(Radius.circular(9.0)); + + RestorableTextEditingController? _controller; + FocusNode? _focusNode; + + TextEditingController get _effectiveController => widget.controller ?? _controller!.value; + FocusNode get _effectiveFocusNode => widget.focusNode ?? _focusNode!; + + ScrollNotificationObserverState? _scrollNotificationObserver; + late double _scaledIconSize; + double _fadeExtent = 0.0; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _createLocalController(); + } + if (widget.focusNode == null) { + _focusNode = FocusNode(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollNotificationObserver?.removeListener(_handleScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_handleScrollNotification); + } + + @override + void didUpdateWidget(CupertinoSearchTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + if (widget.focusNode == null && oldWidget.focusNode != null) { + _focusNode = FocusNode(); + } else if (widget.focusNode != null && oldWidget.focusNode == null) { + _focusNode!.dispose(); + _focusNode = null; + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + @override + void dispose() { + if (_scrollNotificationObserver != null) { + _scrollNotificationObserver!.removeListener(_handleScrollNotification); + _scrollNotificationObserver = null; + } + if (widget.focusNode == null) { + _focusNode?.dispose(); + } + if (widget.controller == null) { + _controller?.dispose(); + } + super.dispose(); + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + void _defaultOnSuffixTap() { + final bool textChanged = _effectiveController.text.isNotEmpty; + _effectiveController.clear(); + if (widget.onChanged != null && textChanged) { + widget.onChanged!(_effectiveController.text); + } + } + + void _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification) { + final double currentHeight = context.size?.height ?? 0.0; + setState(() { + _fadeExtent = _calculateScrollOpacity( + currentHeight, + _scaledIconSize + math.max(widget.prefixInsets.vertical, widget.suffixInsets.vertical), + ); + }); + } + } + + static double _calculateScrollOpacity(double currentHeight, double maxHeight) { + final double thresholdHeight = maxHeight * _kMinHeightBeforeTotalTransparency; + if (currentHeight >= maxHeight) { + return 0.0; + } else if (currentHeight <= thresholdHeight) { + return 1.0; + } else { + final double range = maxHeight - thresholdHeight; + final double progress = (currentHeight - thresholdHeight) / range; + return 1.0 - progress; + } + } + + // Animate the top padding so that the contents of the search field + // move upwards when the search text field is resized on scroll. + EdgeInsetsGeometry _animatedInsets(BuildContext context, EdgeInsetsGeometry insets) { + final EdgeInsets currentInsets = insets.resolve(Directionality.of(context)); + final EdgeInsetsGeometry? animatedInsets = EdgeInsetsGeometry.lerp( + insets, + currentInsets.copyWith(top: currentInsets.top / 2), + _fadeExtent, + ); + return animatedInsets ?? insets; + } + + @override + Widget build(BuildContext context) { + final String placeholder = + widget.placeholder ?? CupertinoLocalizations.of(context).searchTextFieldPlaceholderLabel; + final Color defaultPlaceholderColor = CupertinoDynamicColor.resolve( + CupertinoColors.secondaryLabel, + context, + ); + final TextStyle placeholderStyle = + widget.placeholderStyle ?? + TextStyle( + color: defaultPlaceholderColor.withAlpha( + (255 * (defaultPlaceholderColor.a * (1 - _fadeExtent))).round(), + ), + ); + + // The icon size will be scaled by a factor of the accessibility text scale, + // to follow the behavior of `UISearchTextField`. + _scaledIconSize = MediaQuery.textScalerOf(context).scale(widget.itemSize); + + // If decoration was not provided, create a decoration with the provided + // background color and border radius. + final BoxDecoration decoration = + widget.decoration ?? + BoxDecoration( + color: widget.backgroundColor ?? CupertinoColors.tertiarySystemFill, + borderRadius: widget.borderRadius ?? _kDefaultBorderRadius, + ); + + final Color iconColor = CupertinoDynamicColor.resolve(widget.itemColor, context); + final suffixIconThemeData = IconThemeData(color: iconColor, size: _scaledIconSize); + final prefixIconThemeData = IconThemeData( + color: iconColor, + size: _scaledIconSize >= _kMaxPrefixIconSize && _effectiveFocusNode.hasFocus + ? 0.0 + : _scaledIconSize, + ); + + final Widget prefix = Opacity( + opacity: 1.0 - _fadeExtent, + child: Padding( + padding: _animatedInsets(context, widget.prefixInsets), + child: IconTheme(data: prefixIconThemeData, child: widget.prefixIcon), + ), + ); + + final Widget suffix = Opacity( + opacity: 1.0 - _fadeExtent, + child: Padding( + padding: _animatedInsets(context, widget.suffixInsets), + child: CupertinoButton( + onPressed: widget.onSuffixTap ?? _defaultOnSuffixTap, + minSize: 0, + padding: EdgeInsets.zero, + child: IconTheme(data: suffixIconThemeData, child: widget.suffixIcon), + ), + ), + ); + + return CupertinoTextField( + controller: _effectiveController, + decoration: decoration, + style: widget.style, + prefix: prefix, + suffix: suffix, + keyboardType: widget.keyboardType, + onTap: widget.onTap, + enabled: widget.enabled ?? true, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorOpacityAnimates: widget.cursorOpacityAnimates, + cursorColor: widget.cursorColor, + suffixMode: widget.suffixMode, + placeholder: placeholder, + placeholderStyle: placeholderStyle, + padding: _animatedInsets(context, widget.padding), + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + focusNode: _effectiveFocusNode, + autofocus: widget.autofocus, + autocorrect: widget.autocorrect, + smartQuotesType: widget.smartQuotesType, + smartDashesType: widget.smartDashesType, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + textInputAction: TextInputAction.search, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/segmented_control.dart b/packages/cupertino_ui/lib/src/segmented_control.dart new file mode 100644 index 000000000000..75033391c40c --- /dev/null +++ b/packages/cupertino_ui/lib/src/segmented_control.dart @@ -0,0 +1,875 @@ +// 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. + +/// @docImport 'switch.dart'; +library; + +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Minimum padding from edges of the segmented control to edges of +// encompassing widget. +const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(horizontal: 16.0); + +// Minimum height of the segmented control. +const double _kMinSegmentedControlHeight = 28.0; + +// The default color used for the text of the disabled segment. +const Color _kDisableTextColor = Color.fromARGB(115, 122, 122, 122); + +// The duration of the fade animation used to transition when a new widget +// is selected. +const Duration _kFadeDuration = Duration(milliseconds: 165); + +/// An iOS-style segmented control. +/// +/// Displays the widgets provided in the [Map] of [children] in a +/// horizontal list. Used to select between a number of mutually exclusive +/// options. When one option in the segmented control is selected, the other +/// options in the segmented control cease to be selected. +/// +/// A segmented control can feature any [Widget] as one of the values in its +/// [Map] of [children]. The type T is the type of the keys used +/// to identify each widget and determine which widget is selected. As +/// required by the [Map] class, keys must be of consistent types +/// and must be comparable. The ordering of the keys will determine the order +/// of the widgets in the segmented control. +/// +/// When the state of the segmented control changes, the widget calls the +/// [onValueChanged] callback. The map key associated with the newly selected +/// widget is returned in the [onValueChanged] callback. Typically, widgets +/// that use a segmented control will listen for the [onValueChanged] callback +/// and rebuild the segmented control with a new [groupValue] to update which +/// option is currently selected. +/// +/// The [children] will be displayed in the order of the keys in the [Map]. +/// The height of the segmented control is determined by the height of the +/// tallest widget provided as a value in the [Map] of [children]. +/// The width of each child in the segmented control will be equal to the width +/// of widest child, unless the combined width of the children is wider than +/// the available horizontal space. In this case, the available horizontal space +/// is divided by the number of provided [children] to determine the width of +/// each widget. The selection area for each of the widgets in the [Map] of +/// [children] will then be expanded to fill the calculated space, so each +/// widget will appear to have the same dimensions. +/// +/// A segmented control may optionally be created with custom colors. The +/// [unselectedColor], [selectedColor], [borderColor], and [pressedColor] +/// arguments can be used to override the segmented control's colors from +/// [CupertinoTheme] defaults. The [disabledColor] and [disabledTextColor] +/// set the background and text colors of the segment when it is disabled. +/// +/// The segmented control can be disabled by adding children to the [Set] of +/// [disabledChildren]. If the child is not present in the [Set], it is enabled +/// by default. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoSegmentedControl] with an enum type. +/// +/// The callback provided to [onValueChanged] should update the state of +/// the parent [StatefulWidget] using the [State.setState] method, so that +/// the parent gets rebuilt. +/// +/// This example also demonstrates how to use the [disabledChildren] property by +/// toggling each [CupertinoSwitch] to enable or disable the segments. +/// +/// ** See code in examples/api/lib/cupertino/segmented_control/cupertino_segmented_control.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoSegmentedControl], a segmented control widget in the style used +/// up until iOS 13. +/// * +class CupertinoSegmentedControl extends StatefulWidget { + /// Creates an iOS-style segmented control bar. + /// + /// The [children] argument must be an ordered [Map] such as a + /// [LinkedHashMap]. Further, the length of the [children] list must be + /// greater than one. + /// + /// Each widget value in the map of [children] must have an associated key + /// that uniquely identifies this widget. This key is what will be returned + /// in the [onValueChanged] callback when a new value from the [children] map + /// is selected. + /// + /// The [groupValue] is the currently selected value for the segmented control. + /// If no [groupValue] is provided, or the [groupValue] is null, no widget will + /// appear as selected. The [groupValue] must be either null or one of the keys + /// in the [children] map. + CupertinoSegmentedControl({ + super.key, + required this.children, + required this.onValueChanged, + this.groupValue, + this.unselectedColor, + this.selectedColor, + this.borderColor, + this.pressedColor, + this.disabledColor, + this.disabledTextColor, + this.padding, + this.disabledChildren = const {}, + }) : assert(children.length >= 2), + assert( + groupValue == null || children.keys.any((T child) => child == groupValue), + 'The groupValue must be either null or one of the keys in the children map.', + ); + + /// The identifying keys and corresponding widget values in the + /// segmented control. + /// + /// The map must have more than one entry. + /// This attribute must be an ordered [Map] such as a [LinkedHashMap]. + final Map children; + + /// The identifier of the widget that is currently selected. + /// + /// This must be one of the keys in the [Map] of [children]. + /// If this attribute is null, no widget will be initially selected. + final T? groupValue; + + /// The callback that is called when a new option is tapped. + /// + /// The segmented control passes the newly selected widget's associated key + /// to the callback but does not actually change state until the parent + /// widget rebuilds the segmented control with the new [groupValue]. + final ValueChanged onValueChanged; + + /// The color used to fill the backgrounds of unselected widgets and as the + /// text color of the selected widget. + /// + /// Defaults to [CupertinoTheme]'s `primaryContrastingColor` if null. + final Color? unselectedColor; + + /// The color used to fill the background of the selected widget and as the text + /// color of unselected widgets. + /// + /// Defaults to [CupertinoTheme]'s `primaryColor` if null. + final Color? selectedColor; + + /// The color used as the border around each widget. + /// + /// Defaults to [CupertinoTheme]'s `primaryColor` if null. + final Color? borderColor; + + /// The color used to fill the background of the widget the user is + /// temporarily interacting with through a long press or drag. + /// + /// Defaults to the selectedColor at 20% opacity if null. + final Color? pressedColor; + + /// The color used to fill the background of the segment when it is disabled. + /// + /// If null, this color will be 50% opacity of the [selectedColor] when + /// the segment is selected. If the segment is unselected, this color will be + /// set to [unselectedColor]. + final Color? disabledColor; + + /// The color used for the text of the segment when it is disabled. + final Color? disabledTextColor; + + /// The CupertinoSegmentedControl will be placed inside this padding. + /// + /// Defaults to EdgeInsets.symmetric(horizontal: 16.0) + final EdgeInsetsGeometry? padding; + + /// The set of identifying keys that correspond to the segments that should be disabled. + /// + /// All segments are enabled by default. + final Set disabledChildren; + + @override + State> createState() => _SegmentedControlState(); +} + +/// A wrapper widget that implements RadioClient for each segment button. +class _SegmentButton extends StatefulWidget { + const _SegmentButton({ + super.key, + required this.value, + required this.child, + required this.enabled, + }); + + final T value; + final Widget child; + final bool enabled; + + @override + State<_SegmentButton> createState() => _SegmentButtonState(); +} + +class _SegmentButtonState extends State<_SegmentButton> with RadioClient { + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'CupertinoSegmentedControl<$T>[${widget.value}]'); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + registry = widget.enabled ? RadioGroup.maybeOf(context) : null; + } + + @override + void didUpdateWidget(_SegmentButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled != widget.enabled) { + registry = widget.enabled ? RadioGroup.maybeOf(context) : null; + } + } + + @override + void dispose() { + registry = null; + _focusNode.dispose(); + super.dispose(); + } + + @override + T get radioValue => widget.value; + + @override + FocusNode get focusNode => _focusNode; + + @override + bool get tristate => false; + + @override + bool get enabled => widget.enabled; + + void requestFocus() { + if (widget.enabled) { + _focusNode.requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + canRequestFocus: widget.enabled, + onKeyEvent: (FocusNode node, KeyEvent event) => KeyEventResult.ignored, + child: widget.child, + ); + } +} + +class _SegmentedControlState extends State> + with TickerProviderStateMixin> { + T? _pressedKey; + + final List _selectionControllers = []; + final List _childTweens = []; + final Map>> _segmentKeys = + >>{}; + + late ColorTween _forwardBackgroundColorTween; + late ColorTween _reverseBackgroundColorTween; + late ColorTween _textColorTween; + + Color? _selectedColor; + Color? _unselectedColor; + Color? _borderColor; + Color? _pressedColor; + Color? _selectedDisabledColor; + Color? _unselectedDisabledColor; + Color? _disabledTextColor; + + AnimationController createAnimationController() { + return AnimationController(duration: _kFadeDuration, vsync: this)..addListener(() { + setState(() { + // State of background/text colors has changed + }); + }); + } + + bool _updateColors() { + assert(mounted, 'This should only be called after didUpdateDependencies'); + var changed = false; + final Color disabledTextColor = widget.disabledTextColor ?? _kDisableTextColor; + if (_disabledTextColor != disabledTextColor) { + changed = true; + _disabledTextColor = disabledTextColor; + } + final Color selectedColor = widget.selectedColor ?? CupertinoTheme.of(context).primaryColor; + if (_selectedColor != selectedColor) { + changed = true; + _selectedColor = selectedColor; + } + final Color unselectedColor = + widget.unselectedColor ?? CupertinoTheme.of(context).primaryContrastingColor; + if (_unselectedColor != unselectedColor) { + changed = true; + _unselectedColor = unselectedColor; + } + final Color selectedDisabledColor = widget.disabledColor ?? selectedColor.withOpacity(0.5); + final Color unselectedDisabledColor = widget.disabledColor ?? unselectedColor; + if (_selectedDisabledColor != selectedDisabledColor || + _unselectedDisabledColor != unselectedDisabledColor) { + changed = true; + _selectedDisabledColor = selectedDisabledColor; + _unselectedDisabledColor = unselectedDisabledColor; + } + final Color borderColor = widget.borderColor ?? CupertinoTheme.of(context).primaryColor; + if (_borderColor != borderColor) { + changed = true; + _borderColor = borderColor; + } + final Color pressedColor = + widget.pressedColor ?? CupertinoTheme.of(context).primaryColor.withOpacity(0.2); + if (_pressedColor != pressedColor) { + changed = true; + _pressedColor = pressedColor; + } + + _forwardBackgroundColorTween = ColorTween(begin: _pressedColor, end: _selectedColor); + _reverseBackgroundColorTween = ColorTween(begin: _unselectedColor, end: _selectedColor); + _textColorTween = ColorTween(begin: _selectedColor, end: _unselectedColor); + return changed; + } + + void _updateAnimationControllers() { + assert(mounted, 'This should only be called after didUpdateDependencies'); + for (final AnimationController controller in _selectionControllers) { + controller.dispose(); + } + _selectionControllers.clear(); + _childTweens.clear(); + + for (final T key in widget.children.keys) { + final AnimationController animationController = createAnimationController(); + if (widget.groupValue == key) { + _childTweens.add(_reverseBackgroundColorTween); + animationController.value = 1.0; + } else { + _childTweens.add(_forwardBackgroundColorTween); + } + _selectionControllers.add(animationController); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_updateColors()) { + _updateAnimationControllers(); + } + } + + @override + void didUpdateWidget(CupertinoSegmentedControl oldWidget) { + super.didUpdateWidget(oldWidget); + + if (_updateColors() || oldWidget.children.length != widget.children.length) { + _updateAnimationControllers(); + } + + if (oldWidget.groupValue != widget.groupValue) { + var index = 0; + for (final T key in widget.children.keys) { + if (widget.groupValue == key) { + _childTweens[index] = _forwardBackgroundColorTween; + _selectionControllers[index].forward(); + } else { + _childTweens[index] = _reverseBackgroundColorTween; + _selectionControllers[index].reverse(); + } + index += 1; + } + } + } + + @override + void dispose() { + for (final AnimationController animationController in _selectionControllers) { + animationController.dispose(); + } + super.dispose(); + } + + void _onTapDown(T currentKey) { + if (_pressedKey == null && currentKey != widget.groupValue) { + setState(() { + _pressedKey = currentKey; + }); + } + } + + void _onTapCancel() { + setState(() { + _pressedKey = null; + }); + } + + void _onTap(T currentKey) { + if (currentKey != _pressedKey) { + return; + } + if (!widget.disabledChildren.contains(currentKey)) { + _segmentKeys[currentKey]?.currentState?.requestFocus(); + + if (currentKey != widget.groupValue) { + widget.onValueChanged(currentKey); + } + } + setState(() { + _pressedKey = null; + }); + } + + Color? getTextColor(int index, T currentKey) { + if (widget.disabledChildren.contains(currentKey)) { + return _disabledTextColor; + } + if (_selectionControllers[index].isAnimating) { + return _textColorTween.evaluate(_selectionControllers[index]); + } + if (widget.groupValue == currentKey) { + return _unselectedColor; + } + return _selectedColor; + } + + Color? getBackgroundColor(int index, T currentKey) { + if (widget.disabledChildren.contains(currentKey)) { + return widget.groupValue == currentKey ? _selectedDisabledColor : _unselectedDisabledColor; + } + if (_selectionControllers[index].isAnimating) { + return _childTweens[index].evaluate(_selectionControllers[index]); + } + if (widget.groupValue == currentKey) { + return _selectedColor; + } + if (_pressedKey == currentKey) { + return _pressedColor; + } + return _unselectedColor; + } + + @override + Widget build(BuildContext context) { + final gestureChildren = []; + final backgroundColors = []; + var index = 0; + int? selectedIndex; + int? pressedIndex; + for (final T currentKey in widget.children.keys) { + selectedIndex = (widget.groupValue == currentKey) ? index : selectedIndex; + pressedIndex = (_pressedKey == currentKey) ? index : pressedIndex; + + final TextStyle textStyle = DefaultTextStyle.of( + context, + ).style.copyWith(color: getTextColor(index, currentKey)); + final iconTheme = IconThemeData(color: getTextColor(index, currentKey)); + + Widget child = Center(child: widget.children[currentKey]); + + final bool isEnabled = !widget.disabledChildren.contains(currentKey); + + final GlobalKey<_SegmentButtonState> segmentKey = _segmentKeys.putIfAbsent( + currentKey, + () => GlobalKey<_SegmentButtonState>(), + ); + + child = _SegmentButton( + key: segmentKey, + value: currentKey, + enabled: isEnabled, + child: MouseRegion( + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: isEnabled + ? (TapDownDetails event) { + _onTapDown(currentKey); + } + : null, + onTapCancel: isEnabled ? _onTapCancel : null, + onTap: () { + if (isEnabled) { + _segmentKeys[currentKey]?.currentState?.requestFocus(); + } + _onTap(currentKey); + }, + child: IconTheme( + data: iconTheme, + child: DefaultTextStyle( + style: textStyle, + child: Semantics( + button: true, + inMutuallyExclusiveGroup: true, + selected: widget.groupValue == currentKey, + child: child, + ), + ), + ), + ), + ), + ); + + backgroundColors.add(getBackgroundColor(index, currentKey)!); + gestureChildren.add(child); + index += 1; + } + + final Widget box = _SegmentedControlRenderWidget( + selectedIndex: selectedIndex, + pressedIndex: pressedIndex, + backgroundColors: backgroundColors, + borderColor: _borderColor!, + children: gestureChildren, + ); + + return Actions( + actions: >{VoidCallbackIntent: VoidCallbackAction()}, + child: RadioGroup( + groupValue: widget.groupValue, + onChanged: (T? value) { + if (value != null && !widget.disabledChildren.contains(value)) { + widget.onValueChanged(value); + } + }, + child: Padding( + padding: widget.padding ?? _kHorizontalItemPadding, + child: UnconstrainedBox(constrainedAxis: Axis.horizontal, child: box), + ), + ), + ); + } +} + +class _SegmentedControlRenderWidget extends MultiChildRenderObjectWidget { + const _SegmentedControlRenderWidget({ + super.key, + super.children, + required this.selectedIndex, + required this.pressedIndex, + required this.backgroundColors, + required this.borderColor, + }); + + final int? selectedIndex; + final int? pressedIndex; + final List backgroundColors; + final Color borderColor; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSegmentedControl( + textDirection: Directionality.of(context), + selectedIndex: selectedIndex, + pressedIndex: pressedIndex, + backgroundColors: backgroundColors, + borderColor: borderColor, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSegmentedControl renderObject) { + renderObject + ..textDirection = Directionality.of(context) + ..selectedIndex = selectedIndex + ..pressedIndex = pressedIndex + ..backgroundColors = backgroundColors + ..borderColor = borderColor; + } +} + +class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData { + RSuperellipse? surroundingRect; +} + +typedef _NextChild = RenderBox? Function(RenderBox child); + +class _RenderSegmentedControl extends RenderBox + with + ContainerRenderObjectMixin>, + RenderBoxContainerDefaultsMixin> { + _RenderSegmentedControl({ + required int? selectedIndex, + required int? pressedIndex, + required TextDirection textDirection, + required List backgroundColors, + required Color borderColor, + }) : _textDirection = textDirection, + _selectedIndex = selectedIndex, + _pressedIndex = pressedIndex, + _backgroundColors = backgroundColors, + _borderColor = borderColor; + + int? get selectedIndex => _selectedIndex; + int? _selectedIndex; + set selectedIndex(int? value) { + if (_selectedIndex == value) { + return; + } + _selectedIndex = value; + markNeedsPaint(); + } + + int? get pressedIndex => _pressedIndex; + int? _pressedIndex; + set pressedIndex(int? value) { + if (_pressedIndex == value) { + return; + } + _pressedIndex = value; + markNeedsPaint(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + List get backgroundColors => _backgroundColors; + List _backgroundColors; + set backgroundColors(List value) { + if (_backgroundColors == value) { + return; + } + _backgroundColors = value; + markNeedsPaint(); + } + + Color get borderColor => _borderColor; + Color _borderColor; + set borderColor(Color value) { + if (_borderColor == value) { + return; + } + _borderColor = value; + markNeedsPaint(); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + var minWidth = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final double childWidth = child.getMinIntrinsicWidth(height); + minWidth = math.max(minWidth, childWidth); + child = childParentData.nextSibling; + } + return minWidth * childCount; + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + var maxWidth = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final double childWidth = child.getMaxIntrinsicWidth(height); + maxWidth = math.max(maxWidth, childWidth); + child = childParentData.nextSibling; + } + return maxWidth * childCount; + } + + @override + double computeMinIntrinsicHeight(double width) { + RenderBox? child = firstChild; + var minHeight = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final double childHeight = child.getMinIntrinsicHeight(width); + minHeight = math.max(minHeight, childHeight); + child = childParentData.nextSibling; + } + return minHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + RenderBox? child = firstChild; + var maxHeight = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final double childHeight = child.getMaxIntrinsicHeight(width); + maxHeight = math.max(maxHeight, childHeight); + child = childParentData.nextSibling; + } + return maxHeight; + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SegmentedControlContainerBoxParentData) { + child.parentData = _SegmentedControlContainerBoxParentData(); + } + } + + void _layoutRects(_NextChild nextChild, RenderBox? leftChild, RenderBox? rightChild) { + var child = leftChild; + var start = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final childOffset = Offset(start, 0.0); + childParentData.offset = childOffset; + final childRect = Rect.fromLTWH(start, 0.0, child.size.width, child.size.height); + final RSuperellipse rChildRect; + if (child == leftChild) { + rChildRect = RSuperellipse.fromRectAndCorners( + childRect, + topLeft: const Radius.circular(3.0), + bottomLeft: const Radius.circular(3.0), + ); + } else if (child == rightChild) { + rChildRect = RSuperellipse.fromRectAndCorners( + childRect, + topRight: const Radius.circular(3.0), + bottomRight: const Radius.circular(3.0), + ); + } else { + rChildRect = RSuperellipse.fromRectAndCorners(childRect); + } + childParentData.surroundingRect = rChildRect; + start += child.size.width; + child = nextChild(child); + } + } + + Size _calculateChildSize(BoxConstraints constraints) { + double maxHeight = _kMinSegmentedControlHeight; + double childWidth = constraints.minWidth / childCount; + RenderBox? child = firstChild; + while (child != null) { + childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity)); + child = childAfter(child); + } + childWidth = math.min(childWidth, constraints.maxWidth / childCount); + child = firstChild; + while (child != null) { + final double boxHeight = child.getMaxIntrinsicHeight(childWidth); + maxHeight = math.max(maxHeight, boxHeight); + child = childAfter(child); + } + return Size(childWidth, maxHeight); + } + + Size _computeOverallSizeFromChildSize(Size childSize) { + return constraints.constrain(Size(childSize.width * childCount, childSize.height)); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final Size childSize = _calculateChildSize(constraints); + final childConstraints = BoxConstraints.tight(childSize); + + BaselineOffset baselineOffset = BaselineOffset.noBaseline; + for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { + baselineOffset = baselineOffset.minOf( + BaselineOffset(child.getDryBaseline(childConstraints, baseline)), + ); + } + return baselineOffset.offset; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final Size childSize = _calculateChildSize(constraints); + return _computeOverallSizeFromChildSize(childSize); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final Size childSize = _calculateChildSize(constraints); + + final childConstraints = BoxConstraints.tightFor( + width: childSize.width, + height: childSize.height, + ); + + RenderBox? child = firstChild; + while (child != null) { + child.layout(childConstraints, parentUsesSize: true); + child = childAfter(child); + } + + switch (textDirection) { + case TextDirection.rtl: + _layoutRects(childBefore, lastChild, firstChild); + case TextDirection.ltr: + _layoutRects(childAfter, firstChild, lastChild); + } + + size = _computeOverallSizeFromChildSize(childSize); + } + + @override + void paint(PaintingContext context, Offset offset) { + RenderBox? child = firstChild; + var index = 0; + while (child != null) { + _paintChild(context, offset, child, index); + child = childAfter(child); + index += 1; + } + } + + void _paintChild(PaintingContext context, Offset offset, RenderBox child, int childIndex) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + + context.canvas.drawRSuperellipse( + childParentData.surroundingRect!.shift(offset), + Paint() + ..color = backgroundColors[childIndex] + ..style = PaintingStyle.fill, + ); + context.canvas.drawRSuperellipse( + childParentData.surroundingRect!.shift(offset), + Paint() + ..color = borderColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke, + ); + + context.paintChild(child, childParentData.offset + offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? child = lastChild; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + if (childParentData.surroundingRect!.outerRect.contains(position)) { + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset localOffset) { + assert(localOffset == position - childParentData.offset); + return child!.hitTest(result, position: localOffset); + }, + ); + } + child = childParentData.previousSibling; + } + return false; + } +} diff --git a/packages/cupertino_ui/lib/src/sheet.dart b/packages/cupertino_ui/lib/src/sheet.dart new file mode 100644 index 000000000000..c2f173777026 --- /dev/null +++ b/packages/cupertino_ui/lib/src/sheet.dart @@ -0,0 +1,1375 @@ +// 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/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'interface_level.dart'; +import 'route.dart'; +import 'theme.dart'; + +// Smoothing factor applied to the device's top padding (which approximates the corner radius) +// to achieve a smoother end to the corner radius animation. A value of 1.0 would use +// the full top padding. Values less than 1.0 reduce the effective corner radius, improving +// the animation's appearance. Determined through empirical testing. +const double _kDeviceCornerRadiusSmoothingFactor = 0.9; + +// Threshold in logical pixels. If the calculated device corner radius (after applying +// the smoothing factor) is below this value, the corner radius transition animation will +// start from zero. This prevents abrupt transitions for devices with small or negligible +// corner radii. This value, combined with the smoothing factor, corresponds roughly +// to double the targeted radius of 12. Determined through testing and visual inspection. +const double _kRoundedDeviceCornersThreshold = 20.0; + +// The distance from the top of the open sheet to the top of the screen, as a ratio +// of the total height of the screen. Found from eyeballing a simulator running +// iOS 18.0. +const double _kTopGapRatio = 0.08; + +// The minimum distance (i.e., maximum upward stretch) from the top of the sheet +// to the top of the screen, as a ratio of total screen height. This value represents +// how far the sheet can be temporarily pulled upward before snapping back. +// Determined through visual tuning to feel natural on +// running iOS 18.0 simulators. +const double _kStretchedTopGapRatio = 0.072; + +// Tween for animating a Cupertino sheet onto the screen. +// +// Begins fully offscreen below the screen and ends onscreen with a small gap at +// the top of the screen. Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kBottomUpTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, +); + +// Offset change for when a new sheet covers another sheet. '0.0' represents the +// top of the space available for the new sheet, but because the previous sheet +// was lowered slightly, the new sheet needs to go slightly higher than that. +// Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kBottomUpTweenWhenCoveringOtherSheet = Tween( + begin: const Offset(0.0, 1.0), + end: const Offset(0.0, -0.02), +); + +// Tween that animates a sheet slightly up when it is covered by a new sheet. +// Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kMidUpTween = Tween( + begin: Offset.zero, + end: const Offset(0.0, -0.005), +); + +// Offset from top of screen to slightly down when a fullscreen page is covered +// by a sheet. Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kTopDownTween = Tween( + begin: Offset.zero, + end: const Offset(0.0, 0.07), +); + +// Opacity of the overlay color put over the sheet as it moves into the background. +// Used to distinguish the sheet from the background. Value derived from eyeballing +// a simulator running iOS 18.0. +final Animatable _kOpacityTween = Tween(begin: 0.0, end: 0.10); + +// The minimum velocity needed for a drag downwards to dismiss the sheet. Eyeballed +// from a comparison against a simulator running iOS 18.0. +const double _kMinFlingVelocity = 2.0; // Screen heights per second. + +// The duration for a page to animate when the user releases it mid-swipe. Eyeballed +// from a comparison against a simulator running iOS 18.0. +const Duration _kDroppedSheetDragAnimationDuration = Duration(milliseconds: 300); + +// Amount the sheet in the background scales down. Found by measuring the width +// of the sheet in the background and comparing against the screen width on the +// iOS simulator showing an iPhone 16 pro running iOS 18.0. The scale transition +// will go from a default of 1.0 to 1.0 - _kSheetScaleFactor. +const double _kSheetScaleFactor = 0.0835; + +final Animatable _kScaleTween = Tween(begin: 1.0, end: 1.0 - _kSheetScaleFactor); + +// The signature for a method called on the start of a drag. +typedef _DragStartCallback = void Function(); + +// The signature for a method called to trigger a change based on a moving drag gesture. +typedef _DragUpdateCallback = void Function(double delta); + +// The signature for a method called on the end of a drag, passing the velocity at +// the end of the drag along. +typedef _DragEndCallback = void Function(double velocity); + +// The signature for a method that checks if the sheet is currently dragged downwards. +typedef _GetSheetDragged = bool Function(); + +/// Shows a Cupertino-style sheet widget that slides up from the bottom of the +/// screen and stacks the previous route behind the new sheet. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=5H-WvH5O29I} +/// +/// This is a convenience method for displaying [CupertinoSheetRoute] for most +/// use cases. The Widget returned from `scrollableBuilder` will be used to display +/// the content on the [CupertinoSheetRoute]. If the content of the sheet has a +/// scrollable view, the [ScrollController] provided by `scrollableBuilder` can be +/// used to enable the drag-to-dimiss gesture to work with the scrolling of the +/// content. See [CupertinoSheetRoute.scrollableBuilder] for an example. +/// +/// `useNestedNavigation` allows new routes to be pushed inside of a [CupertinoSheetRoute] +/// by adding a new [Navigator] inside of the [CupertinoSheetRoute]. +/// +/// When `useNestedNavigation` is set to `true`, any route pushed to the stack +/// from within the context of the [CupertinoSheetRoute] will display within that +/// sheet. System back gestures and programmatic pops on the initial route in a +/// sheet will also be intercepted to pop the whole [CupertinoSheetRoute]. If +/// a custom [Navigator] setup is needed, like for example to enable named routes +/// or the pages API, then it is recommended to directly push a [CupertinoSheetRoute] +/// to the stack with whatever configuration needed. See [CupertinoSheetRoute] for +/// an example that manually sets up nested navigation. +/// +/// The whole sheet can be popped at once by either dragging down on the sheet, +/// or calling [CupertinoSheetRoute.popSheet]. +/// +/// When `enableDrag` is set to `true` (the default), users can dismiss the sheet +/// by dragging it down or by calling [CupertinoSheetRoute.popSheet]. When +/// `enableDrag` is `false`, users cannot dismiss the sheet by dragging, and it +/// can only be closed by calling [CupertinoSheetRoute.popSheet]. +/// +/// The `topGap` parameter can be used to customize the gap between the top of +/// the screen and the top of the sheet as a ratio of the screen height. +/// It should be a value between 0.0 and 0.9, where 0.0 means no gap and 0.9 +/// means the sheet takes up only the bottom 10% of the screen. If not provided, defaults +/// to 0.08 (8% of screen height). +/// +/// When `showDragHandle` is set to `true`, then a drag handle will be placed at +/// the top of the sheet. This flag will default to false. +/// +/// iOS sheet widgets are generally designed to be tightly coupled to the context +/// of the widget that opened the sheet. As such, it is not recommended to push +/// a non-sheet route that covers the sheet without first popping the sheet. If +/// necessary however, it can be done by pushing to the root [Navigator]. +/// +/// If `useNestedNavigation` is `false` (the default), then a [CupertinoSheetRoute] +/// will be shown with no [Navigator] widget. Multiple calls to `showCupertinoSheet` +/// can still be made to show multiple stacked sheets, if desired. +/// +/// `showCupertinoSheet` always pushes the [CupertinoSheetRoute] to the root +/// [Navigator]. This is to ensure the previous route animates correctly. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the sheet was closed. +/// +/// {@tool dartpad} +/// This example shows how to navigate to use [showCupertinoSheet] to display a +/// Cupertino sheet widget with nested navigation. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoSheetRoute] the basic route version of the sheet view. +/// * [showCupertinoDialog] which displays an iOS-styled dialog. +/// * +Future showCupertinoSheet({ + required BuildContext context, + @Deprecated( + 'Use scrollableBuilder instead. ' + 'This feature was deprecated after v3.33.0-0.2.pre.', + ) + WidgetBuilder? pageBuilder, + @Deprecated( + 'Use scrollableBuilder instead. ' + 'This feature was deprecated after v3.40.0-0.2.pre.', + ) + WidgetBuilder? builder, + ScrollableWidgetBuilder? scrollableBuilder, + bool useNestedNavigation = false, + bool enableDrag = true, + RouteSettings? settings, + double? topGap, + bool showDragHandle = false, +}) { + assert(topGap == null || (topGap >= 0.0 && topGap <= 0.9), 'topGap must be between 0.0 and 0.9'); + assert(pageBuilder != null || builder != null || scrollableBuilder != null); + assert( + (pageBuilder == null && builder == null && scrollableBuilder != null) || + scrollableBuilder == null, + ); + + final WidgetBuilder? effectiveBuilder = builder ?? pageBuilder; + final nestedNavigatorKey = GlobalKey(); + if (!useNestedNavigation) { + final PageRoute route = CupertinoSheetRoute( + builder: effectiveBuilder, + scrollableBuilder: scrollableBuilder, + settings: settings, + enableDrag: enableDrag, + topGap: topGap, + ); + + return Navigator.of(context, rootNavigator: true).push(route); + } else { + Widget nestedNavigationContent(WidgetBuilder builder) { + return NavigatorPopHandler( + onPopWithResult: (T? result) { + nestedNavigatorKey.currentState!.maybePop(); + }, + child: Navigator( + key: nestedNavigatorKey, + initialRoute: '/', + onGenerateInitialRoutes: (NavigatorState navigator, String initialRouteName) { + return >[ + CupertinoPageRoute( + builder: (BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + return; + } + Navigator.of(context, rootNavigator: true).pop(result); + }, + child: builder(context), + ); + }, + ), + ]; + }, + ), + ); + } + + final route = CupertinoSheetRoute( + scrollableBuilder: (BuildContext context, ScrollController controller) => + nestedNavigationContent( + scrollableBuilder != null + ? (BuildContext context) => scrollableBuilder(context, controller) + : effectiveBuilder!, + ), + settings: settings, + enableDrag: enableDrag, + topGap: topGap, + ); + return Navigator.of(context, rootNavigator: true).push(route); + } +} + +/// Provides an iOS-style sheet transition. +/// +/// The page slides up and stops below the top of the screen. When covered by +/// another sheet view, it will slide slightly up and scale down to appear +/// stacked behind the new sheet. +class CupertinoSheetTransition extends StatefulWidget { + /// Creates an iOS style sheet transition. + const CupertinoSheetTransition({ + super.key, + required this.primaryRouteAnimation, + required this.secondaryRouteAnimation, + required this.child, + required this.linearTransition, + this.topGap = _kTopGapRatio, + }); + + /// `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0 when + /// this screen is being pushed. + final Animation primaryRouteAnimation; + + /// `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0 when + /// another screen is being pushed on top of this one. + final Animation secondaryRouteAnimation; + + /// The widget below this widget in the tree. + final Widget child; + + /// Whether to perform the transition linearly. + /// + /// Used to respond to a drag gesture. + final bool linearTransition; + + /// The gap between the top of the screen and the top of the sheet as a ratio + /// of the screen height. + /// + ///{@template flutter.cupertino.CupertinoSheetTransition.topGap} + /// This value should be between 0.0 and 0.9, where 0.0 means no gap (sheet + /// extends to the top of the screen) and 0.9 means the sheet covers only the + /// bottom 10% of the screen. A value of 0.08 represents 8% of the screen height. + /// + /// If not provided, defaults to a value of 0.08. + /// {@endtemplate} + final double topGap; + + /// The primary delegated transition. Will slide a non [CupertinoSheetRoute] page down. + /// + /// Provided to the previous route to coordinate transitions between routes. + /// + /// If a [CupertinoSheetRoute] already exists in the stack, then it will + /// slide the previous sheet upwards instead. + static Widget delegateTransition( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) { + if (CupertinoSheetRoute.hasParentSheet(context)) { + return _delegatedCoverSheetSecondaryTransition(secondaryAnimation, child); + } + final bool linear = Navigator.of(context).userGestureInProgress; + + final Curve curve = linear ? Curves.linear : Curves.linearToEaseOut; + final Curve reverseCurve = linear ? Curves.linear : Curves.easeInToLinear; + final curvedAnimation = CurvedAnimation( + curve: curve, + reverseCurve: reverseCurve, + parent: secondaryAnimation, + ); + + final double deviceCornerRadius = + (MediaQuery.maybeViewPaddingOf(context)?.top ?? 0) * _kDeviceCornerRadiusSmoothingFactor; + final bool roundedDeviceCorners = deviceCornerRadius > _kRoundedDeviceCornersThreshold; + + final Animatable decorationTween = Tween( + begin: BorderRadius.vertical( + top: Radius.circular(roundedDeviceCorners ? deviceCornerRadius : 0), + ), + end: const BorderRadius.all(Radius.circular(12)), + ); + + final Animation radiusAnimation = curvedAnimation.drive(decorationTween); + final Animation opacityAnimation = curvedAnimation.drive(_kOpacityTween); + final Animation slideAnimation = curvedAnimation.drive(_kTopDownTween); + final Animation scaleAnimation = curvedAnimation.drive(_kScaleTween); + curvedAnimation.dispose(); + + final isDarkMode = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final overlayColor = isDarkMode ? const Color(0xFFc8c8c8) : const Color(0xFF000000); + + final Widget? contrastedChild = child != null && !secondaryAnimation.isDismissed + ? Stack( + children: [ + child, + FadeTransition( + opacity: opacityAnimation, + child: ColoredBox(color: overlayColor, child: const SizedBox.expand()), + ), + ], + ) + : child; + + final double topGapHeight = MediaQuery.sizeOf(context).height * _kTopGapRatio; + + return Stack( + children: [ + AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarBrightness: Brightness.dark, + statusBarIconBrightness: Brightness.light, + ), + child: SizedBox(height: topGapHeight, width: double.infinity), + ), + SlideTransition( + position: slideAnimation, + child: ScaleTransition( + scale: scaleAnimation, + filterQuality: FilterQuality.medium, + alignment: Alignment.topCenter, + child: AnimatedBuilder( + animation: radiusAnimation, + child: child, + builder: (BuildContext context, Widget? child) { + return ClipRSuperellipse( + borderRadius: !secondaryAnimation.isDismissed + ? radiusAnimation.value + : BorderRadius.zero, + child: contrastedChild, + ); + }, + ), + ), + ), + ], + ); + } + + static Widget _delegatedCoverSheetSecondaryTransition( + Animation secondaryAnimation, + Widget? child, + ) { + const Curve curve = Curves.linearToEaseOut; + const Curve reverseCurve = Curves.easeInToLinear; + final curvedAnimation = CurvedAnimation( + curve: curve, + reverseCurve: reverseCurve, + parent: secondaryAnimation, + ); + + final Animation slideAnimation = curvedAnimation.drive(_kMidUpTween); + final Animation scaleAnimation = curvedAnimation.drive(_kScaleTween); + curvedAnimation.dispose(); + + return SlideTransition( + position: slideAnimation, + transformHitTests: false, + child: ScaleTransition( + scale: scaleAnimation, + filterQuality: FilterQuality.medium, + alignment: Alignment.topCenter, + child: ClipRSuperellipse( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: child, + ), + ), + ); + } + + @override + State createState() => _CupertinoSheetTransitionState(); +} + +class _CupertinoSheetTransitionState extends State + with SingleTickerProviderStateMixin { + // Controls the top padding animation when the sheet is being slightly stretched upward. + late AnimationController _stretchDragController; + + // Animates the top padding of the sheet based on the _stretchDragController’s value. + late Animation _stretchDragAnimation; + + // The offset animation when this page is being covered by another sheet. + late Animation _secondaryPositionAnimation; + + // The scale animation when this page is being covered by another sheet. + late Animation _secondaryScaleAnimation; + + // Curve of primary page which is coming in to cover another route. + CurvedAnimation? _primaryPositionCurve; + + // Curve of secondary page which is becoming covered by another sheet. + CurvedAnimation? _secondaryPositionCurve; + + @override + void initState() { + super.initState(); + + _stretchDragController = AnimationController( + duration: const Duration(microseconds: 1), + vsync: this, + ); + _setupAnimation(); + } + + @override + void didUpdateWidget(covariant CupertinoSheetTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.primaryRouteAnimation != widget.primaryRouteAnimation || + oldWidget.secondaryRouteAnimation != widget.secondaryRouteAnimation) { + _disposeCurve(); + _setupAnimation(); + } + } + + @override + void dispose() { + _disposeCurve(); + _stretchDragController.dispose(); + super.dispose(); + } + + void _setupAnimation() { + _primaryPositionCurve = CurvedAnimation( + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + parent: widget.primaryRouteAnimation, + ); + _secondaryPositionCurve = CurvedAnimation( + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + parent: widget.secondaryRouteAnimation, + ); + // Maintain the same stretch distance (0.008 of screen height) regardless of custom topGap. + const double stretchDistance = _kTopGapRatio - _kStretchedTopGapRatio; + final double stretchedTopGap = widget.topGap - stretchDistance; + _stretchDragAnimation = _stretchDragController.drive( + Tween(begin: widget.topGap, end: stretchedTopGap), + ); + _secondaryPositionAnimation = _secondaryPositionCurve!.drive(_kMidUpTween); + _secondaryScaleAnimation = _secondaryPositionCurve!.drive(_kScaleTween); + } + + void _disposeCurve() { + _primaryPositionCurve?.dispose(); + _secondaryPositionCurve?.dispose(); + _primaryPositionCurve = null; + _secondaryPositionCurve = null; + } + + Widget _coverSheetPrimaryTransition( + BuildContext context, + Animation animation, + bool linearTransition, + Widget? child, + ) { + final Animatable offsetTween = CupertinoSheetRoute.hasParentSheet(context) + ? _kBottomUpTweenWhenCoveringOtherSheet + : _kBottomUpTween; + + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: linearTransition ? Curves.linear : Curves.fastEaseInToSlowEaseOut, + reverseCurve: linearTransition ? Curves.linear : Curves.fastEaseInToSlowEaseOut.flipped, + ); + + final Animation positionAnimation = curvedAnimation.drive(offsetTween); + + curvedAnimation.dispose(); + + return SlideTransition(position: positionAnimation, child: child); + } + + Widget _coverSheetSecondaryTransition(Animation secondaryAnimation, Widget? child) { + return SlideTransition( + position: _secondaryPositionAnimation, + transformHitTests: false, + child: ScaleTransition( + scale: _secondaryScaleAnimation, + filterQuality: FilterQuality.medium, + alignment: Alignment.topCenter, + child: child, + ), + ); + } + + @override + Widget build(BuildContext context) { + return _StretchDragControllerProvider( + controller: _stretchDragController, + child: SizedBox.expand( + child: AnimatedBuilder( + animation: _stretchDragAnimation, + builder: (BuildContext context, Widget? child) { + return Padding( + padding: EdgeInsets.only( + top: MediaQuery.heightOf(context) * _stretchDragAnimation.value, + ), + child: _coverSheetSecondaryTransition( + widget.secondaryRouteAnimation, + _coverSheetPrimaryTransition( + context, + widget.primaryRouteAnimation, + widget.linearTransition, + widget.child, + ), + ), + ); + }, + ), + ), + ); + } +} + +// Internally used to provide the controller for upward stretch animation. +class _StretchDragControllerProvider extends InheritedWidget { + const _StretchDragControllerProvider({required this.controller, required super.child}); + + final AnimationController controller; + + static _StretchDragControllerProvider? maybeOf(BuildContext context) { + return context.getInheritedWidgetOfExactType<_StretchDragControllerProvider>(); + } + + @override + bool updateShouldNotify(_StretchDragControllerProvider oldWidget) { + return false; + } +} + +/// Route for displaying an iOS sheet styled page. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=5H-WvH5O29I} +/// +/// The `CupertinoSheetRoute` will slide up from the bottom of the screen and stop +/// below the top of the screen. If the previous route is a non-sheet route, then +/// it will animate downwards to stack behind the new sheet. If the previous route +/// is a sheet route, then it will animate slightly upwards to look like it is laying +/// on top of the previous stack of sheets. +/// +/// Typically called by [showCupertinoSheet], which provides some boilerplate for +/// pushing the `CupertinoSheetRoute` to the root navigator and providing simple +/// nested navigation. +/// +/// The sheet will be dismissed by dragging downwards on the screen, or a call to +/// [CupertinoSheetRoute.popSheet]. +/// +/// Any time a CupertinoSheetRoute contains a large scrollable that might conflict +/// with the dismiss drag gesture, pass the provided [ScrollController] from `scrollableBuilder` +/// to the scrollable. A scrollable widget used within the sheet that does not use +/// this [ScrollController] will still scroll in response to user gestures, but +/// the drag to dismiss behavior of the sheet will not trigger. If there is no +/// scrollable area within the sheet, this parameter can be ignored. See below +/// for an example. +/// +/// {@tool dartpad} +/// This example shows how to navigate to [CupertinoSheetRoute] by using it the +/// same as a regular route. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to show a Cupertino Sheet with nested navigation manually +/// set up in order to enable restorable state. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to show a Cupertino Sheet with scrollable content. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.3.dart ** +/// {@end-tool} +/// +/// See also: +/// * [showCupertinoSheet], which is a convenience method for pushing a +/// `CupertinoSheetRoute`, with optional nested navigation built in. +class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTransitionMixin { + /// Creates a page route that displays an iOS styled sheet. + CupertinoSheetRoute({ + super.settings, + @Deprecated( + 'Use scrollableBuilder instead. ' + 'This feature was deprecated after v3.40.0-0.2.pre.', + ) + this.builder, + this.scrollableBuilder, + this.enableDrag = true, + this.showDragHandle = false, + double? topGap, + }) : assert( + topGap == null || (topGap >= 0.0 && topGap <= 0.9), + 'topGap must be between 0.0 and 0.9', + ), + assert( + builder != null || scrollableBuilder != null, + 'Either scrollableBuilder or builder must not be null', + ), + _topGap = topGap; + + /// Builds the primary contents of the sheet route. + @Deprecated( + 'Use scrollableBuilder instead. ' + 'This feature was deprecated after v3.40.0-0.2.pre.', + ) + final WidgetBuilder? builder; + + /// Builds the primary contents of the sheet route with a provided [ScrollController]. + /// + /// If the scrollable content built by this builder uses the provided [ScrollController], + /// then when a downward drag is applied to the scrollable area while the content + /// is scrolled to the top, the drag to dismiss behavior of the sheet will be triggered. + /// + /// {@tool dartpad} + /// This example shows how to show a Cupertino Sheet with scrollable content. + /// + /// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.3.dart ** + /// {@end-tool} + final ScrollableWidgetBuilder? scrollableBuilder; + + ScrollableWidgetBuilder get _effectiveBuilder { + return scrollableBuilder ?? + (BuildContext context, ScrollController controller) => builder!(context); + } + + @override + final bool enableDrag; + + // The gap between the top of the screen and the top of the sheet. + final double? _topGap; + + @override + double get topGap => _topGap ?? _kTopGapRatio; + + @override + bool get _hasCustomTopGap => _topGap != null; + + /// Shows a drag handle at the top of the sheet. + /// + /// Defaults to false. + final bool showDragHandle; + + Widget _sheetWithDragHandle(BuildContext context, ScrollController controller) { + if (!showDragHandle) { + return _effectiveBuilder(context, controller); + } + + // Values derived from Apple's Figma files and a simulator running iOS 18.2. + const dragHandleTopPadding = 5.0; + const dragHandleHeight = 5.0; + const dragHandleWidth = 36.0; + const dragHandlePadding = 15.0; + + return Stack( + fit: StackFit.expand, + children: [ + MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: const EdgeInsets.only(top: dragHandlePadding)), + child: _effectiveBuilder(context, controller), + ), + const Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsetsGeometry.only(top: dragHandleTopPadding), + child: DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadiusGeometry.all(Radius.circular(dragHandleWidth / 2)), + ), + color: CupertinoColors.tertiaryLabel, + ), + child: SizedBox(height: dragHandleHeight, width: dragHandleWidth), + ), + ), + ), + ], + ); + } + + @override + Widget buildContent(BuildContext context) { + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: ClipRSuperellipse( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: _CupertinoSheetScope( + child: _CupertinoDraggableScrollableSheet( + enabledCallback: () => enableDrag, + onStartPopGesture: () => + _CupertinoSheetRouteTransitionMixin._startPopGesture(this, topGap), + builder: _sheetWithDragHandle, + ), + ), + ), + ), + ); + } + + /// Checks if a Cupertino sheet view exists in the widget tree above the current + /// context. + static bool hasParentSheet(BuildContext context) { + return _CupertinoSheetScope.maybeOf(context) != null; + } + + /// Pops the entire [CupertinoSheetRoute], if a sheet route exists in the stack. + /// + /// Used if to pop an entire sheet at once, if there is nested navigation within + /// that sheet. + static void popSheet(BuildContext context) { + if (hasParentSheet(context)) { + Navigator.of(context, rootNavigator: true).pop(); + } + } + + @override + Color? get barrierColor => CupertinoColors.transparent; + + @override + bool get barrierDismissible => false; + + @override + String? get barrierLabel => null; + + @override + bool get maintainState => true; + + @override + bool get opaque => false; +} + +// Internally used to see if another sheet is in the tree already. +class _CupertinoSheetScope extends InheritedWidget { + const _CupertinoSheetScope({required super.child}); + + static _CupertinoSheetScope? maybeOf(BuildContext context) { + return context.getInheritedWidgetOfExactType<_CupertinoSheetScope>(); + } + + @override + bool updateShouldNotify(_CupertinoSheetScope oldWidget) => false; +} + +/// A mixin that replaces the entire screen with an iOS sheet transition for a +/// [PageRoute]. +/// +/// See also: +/// +/// * [CupertinoSheetRoute], which is a [PageRoute] that leverages this mixin. +mixin _CupertinoSheetRouteTransitionMixin on PageRoute { + /// Builds the primary contents of the route. + @protected + Widget buildContent(BuildContext context); + + @override + Duration get transitionDuration => const Duration(milliseconds: 500); + + @override + DelegatedTransitionBuilder? get delegatedTransition { + if (_hasCustomTopGap) { + return null; + } + return CupertinoSheetTransition.delegateTransition; + } + + /// Determines whether the content can be dragged. + /// + /// If `true`, dragging is enabled; otherwise, it remains fixed. + bool get enableDrag; + + /// The gap between the top of the screen and the top of the sheet as a ratio + /// of the screen height. + /// + /// {@macro flutter.cupertino.CupertinoSheetTransition.topGap} + double get topGap; + + /// Whether a custom top gap has been set. + bool get _hasCustomTopGap; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return buildContent(context); + } + + static _CupertinoDragGestureController _startPopGesture( + ModalRoute route, + double topGap, + ) { + return _CupertinoDragGestureController( + topGap: topGap, + navigator: route.navigator!, + getIsCurrent: () => route.isCurrent, + getIsActive: () => route.isActive, + popDragController: route.controller!, // protected access + ); + } + + /// Returns a [CupertinoSheetTransition]. + static Widget buildPageTransitions( + ModalRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + bool enableDrag, + double topGap, + ) { + final bool linearTransition = route.popGestureInProgress; + return CupertinoSheetTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: linearTransition, + topGap: topGap, + child: _CupertinoDragGestureDetector( + enabledCallback: () => enableDrag, + onStartPopGesture: () => _startPopGesture(route, topGap), + child: child, + ), + ); + } + + @override + bool canTransitionFrom(TransitionRoute previousRoute) { + return !_hasCustomTopGap; + } + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + if (this is CupertinoSheetRoute && _hasCustomTopGap) { + return false; + } + return nextRoute is _CupertinoSheetRouteTransitionMixin; + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return buildPageTransitions( + this, + context, + animation, + secondaryAnimation, + child, + enableDrag, + topGap, + ); + } +} + +class _CupertinoDragGestureDetector extends StatefulWidget { + const _CupertinoDragGestureDetector({ + super.key, + required this.enabledCallback, + required this.onStartPopGesture, + required this.child, + }); + + final Widget child; + + final ValueGetter enabledCallback; + + final ValueGetter<_CupertinoDragGestureController> onStartPopGesture; + + @override + _CupertinoDragGestureDetectorState createState() => _CupertinoDragGestureDetectorState(); +} + +class _CupertinoDragGestureDetectorState extends State<_CupertinoDragGestureDetector> { + _CupertinoDragGestureController? _dragGestureController; + + late VerticalDragGestureRecognizer _recognizer; + _StretchDragControllerProvider? _stretchDragController; + + static VelocityTracker _cupertinoVelocityBuilder(PointerEvent event) => + IOSScrollViewFlingVelocityTracker(event.kind); + + double get sheetHeight => context.size!.height; + + @override + void initState() { + super.initState(); + assert(_stretchDragController == null); + _stretchDragController = _StretchDragControllerProvider.maybeOf(context); + _recognizer = VerticalDragGestureRecognizer(debugOwner: this) + ..velocityTrackerBuilder = _cupertinoVelocityBuilder + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..onCancel = _handleDragCancel; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _stretchDragController = _StretchDragControllerProvider.maybeOf(context); + } + + @override + void dispose() { + _recognizer.dispose(); + + // If this is disposed during a drag, call navigator.didStopUserGesture. + if (_dragGestureController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_dragGestureController?.navigator.mounted ?? false) { + _dragGestureController?.navigator.didStopUserGesture(); + } + _dragGestureController = null; + }); + } + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + assert(mounted); + assert(_dragGestureController == null); + _dragGestureController = widget.onStartPopGesture(); + } + + void _handleDragUpdate(DragUpdateDetails details) { + assert(mounted); + assert(_dragGestureController != null); + if (_stretchDragController == null) { + return; + } + final double delta = sheetHeight > 0 ? details.primaryDelta! / sheetHeight : 0.0; + _dragGestureController!.dragUpdate( + // Divide by size of the sheet. + delta, + _stretchDragController!.controller, + ); + } + + void _handleDragEnd(DragEndDetails details) { + assert(mounted); + assert(_dragGestureController != null); + if (_stretchDragController == null) { + _dragGestureController = null; + return; + } + final double velocity = sheetHeight > 0 + ? details.velocity.pixelsPerSecond.dy / sheetHeight + : 0.0; + _dragGestureController!.dragEnd(velocity, _stretchDragController!.controller); + _dragGestureController = null; + } + + void _handleDragCancel() { + assert(mounted); + // This can be called even if start is not called, paired with the "down" event + // that we don't consider here. + if (_stretchDragController == null) { + _dragGestureController = null; + return; + } + _dragGestureController?.dragEnd(0.0, _stretchDragController!.controller); + _dragGestureController = null; + } + + void _handlePointerDown(PointerDownEvent event) { + if (widget.enabledCallback()) { + _recognizer.addPointer(event); + } + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handlePointerDown, + behavior: HitTestBehavior.translucent, + child: widget.child, + ); + } +} + +class _CupertinoDragGestureController { + /// Creates a controller for an iOS-style back gesture. + _CupertinoDragGestureController({ + required this.navigator, + required this.popDragController, + required this.getIsActive, + required this.getIsCurrent, + required this.topGap, + }) { + navigator.didStartUserGesture(); + } + + final AnimationController popDragController; + final NavigatorState navigator; + final ValueGetter getIsActive; + final ValueGetter getIsCurrent; + final double topGap; + + /// The drag gesture has changed by [delta]. The total range of the drag + /// should be 0.0 to 1.0. + void dragUpdate(double delta, AnimationController? upController) { + if (upController != null && + popDragController.value == 1.0 && + (upController.value > 0 || delta < 0)) { + // Divide by stretchable range (when dragging upward at max extent). + // Maintain the same stretch distance regardless of custom topGap. + const double stretchDistance = _kTopGapRatio - _kStretchedTopGapRatio; + upController.value -= delta / stretchDistance; + } else { + popDragController.value -= delta; + } + } + + bool isDragged() { + return popDragController.value != 1.0; + } + + /// The drag gesture has ended with a vertical motion of [velocity] as a + /// fraction of screen height per second. + void dragEnd(double velocity, AnimationController? upController) { + // If the sheet is in a stretched state (dragged upward beyond max size), + // reverse the stretch to return to the normal max height. + if (upController != null && upController.value > 0) { + upController.animateBack( + 0.0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + navigator.didStopUserGesture(); + return; + } + + // Fling in the appropriate direction. + // + // This curve has been determined through rigorously eyeballing native iOS + // animations on a simulator running iOS 18.0. + const Curve animationCurve = Curves.easeOut; + final bool isCurrent = getIsCurrent(); + final bool animateForward; + + if (!isCurrent) { + // If the page has already been navigated away from, then the animation + // direction depends on whether or not it's still in the navigation stack, + // regardless of velocity or drag position. For example, if a route is + // being slowly dragged back by just a few pixels, but then a programmatic + // pop occurs, the route should still be animated off the screen. + // See https://github.com/flutter/flutter/issues/141268. + animateForward = getIsActive(); + } else if (velocity.abs() >= _kMinFlingVelocity) { + // If the user releases the page before mid screen with sufficient velocity, + // or after mid screen, we should animate the page out. Otherwise, the page + // should be animated back in. + animateForward = velocity <= 0; + } else { + // If the drag is dropped with low velocity, the sheet will pop if the + // the drag goes a little past the halfway point on the screen. This is + // eyeballed on a simulator running iOS 18.0. + animateForward = popDragController.value > 0.52; + } + + if (animateForward) { + popDragController.animateTo( + 1.0, + duration: _kDroppedSheetDragAnimationDuration, + curve: animationCurve, + ); + } else { + if (isCurrent) { + // This route is destined to pop at this point. Reuse navigator's pop. + navigator.pop(); + } + + if (popDragController.isAnimating) { + popDragController.animateBack( + 0.0, + duration: _kDroppedSheetDragAnimationDuration, + curve: animationCurve, + ); + } + } + + if (popDragController.isAnimating) { + // Keep the userGestureInProgress in true state so we don't change the + // curve of the page transition mid-flight since CupertinoPageTransition + // depends on userGestureInProgress. + // late AnimationStatusListener animationStatusCallback; + void animationStatusCallback(AnimationStatus status) { + navigator.didStopUserGesture(); + popDragController.removeStatusListener(animationStatusCallback); + } + + popDragController.addStatusListener(animationStatusCallback); + } else { + navigator.didStopUserGesture(); + } + } +} + +class _CupertinoSheetScrollController extends ScrollController { + _CupertinoSheetScrollController({ + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.sheetIsDraggedDown, + }); + + final _DragStartCallback onDragStart; + final _DragEndCallback onDragUpdate; + final _DragUpdateCallback onDragEnd; + final _GetSheetDragged sheetIsDraggedDown; + + @override + _CupertinoSheetScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + return _CupertinoSheetScrollPosition( + physics: physics.applyTo(const AlwaysScrollableScrollPhysics()), + context: context, + oldPosition: oldPosition, + onDragStart: onDragStart, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd, + sheetIsDraggedDown: sheetIsDraggedDown, + ); + } +} + +/// A scroll position that manages scroll activities for +/// [_CupertinoSheetScrollController]. +/// +/// This class is a concrete subclass of [ScrollPosition] logic that handles a +/// single [ScrollContext], such as a [Scrollable]. An instance of this class +/// manages [ScrollActivity] instances, which in response to user gestures either +/// changes the visible content offset in the [Scrollable]'s [Viewport], or reports +/// the delta change or velocity of the user's gestures back to +/// [_CupertinoDragGestureController] through [_CupertinoSheetScrollController]. +class _CupertinoSheetScrollPosition extends ScrollPositionWithSingleContext { + // This class is modified version of _DraggableScrollableSheetScrollPosition + // from DraggableScrollableSheet. If a change needs to be made to this, check and + // see if the original class needs the change as well. + _CupertinoSheetScrollPosition({ + required super.physics, + required super.context, + super.oldPosition, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.sheetIsDraggedDown, + }); + + VoidCallback? _dragCancelCallback; + final Set _ballisticControllers = {}; + bool get listShouldScroll => pixels > 0.0; + + final _DragStartCallback onDragStart; + final _DragEndCallback onDragUpdate; + final _DragUpdateCallback onDragEnd; + + final _GetSheetDragged sheetIsDraggedDown; + + @override + void absorb(ScrollPosition other) { + super.absorb(other); + assert(_dragCancelCallback == null); + + if (other is! _CupertinoSheetScrollPosition) { + return; + } + + if (other._dragCancelCallback != null) { + _dragCancelCallback = other._dragCancelCallback; + other._dragCancelCallback = null; + } + } + + @override + void beginActivity(ScrollActivity? newActivity) { + // Cancel the running ballistic simulations + for (final AnimationController ballisticController in _ballisticControllers) { + ballisticController.stop(); + } + super.beginActivity(newActivity); + } + + @override + void dispose() { + for (final AnimationController ballisticController in _ballisticControllers) { + ballisticController.dispose(); + } + _ballisticControllers.clear(); + super.dispose(); + } + + @override + void applyUserOffset(double delta) { + onDragStart(); + if (!listShouldScroll && (delta > 0 || sheetIsDraggedDown())) { + onDragUpdate(delta); + } else { + super.applyUserOffset(delta); + } + } + + @override + void goBallistic(double velocity) { + // End drag gesture. + if ((velocity == 0.0) || + (velocity < 0.0 && listShouldScroll) || + (velocity > 0.0 && pixels != maxScrollExtent)) { + onDragEnd(0.0); + super.goBallistic(velocity); + return; + } + _dragCancelCallback?.call(); + _dragCancelCallback = null; + if (velocity < 0.0 && !listShouldScroll) { + onDragEnd(velocity); + super.goBallistic(0); + return; + } + onDragEnd(0.0); + super.goBallistic(velocity); + } + + @override + Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { + // Save this so we can call it later if we have to [goBallistic] on our own. + _dragCancelCallback = dragCancelCallback; + return super.drag(details, dragCancelCallback); + } +} + +class _CupertinoDraggableScrollableSheet extends StatefulWidget { + const _CupertinoDraggableScrollableSheet({ + super.key, + required this.enabledCallback, + required this.onStartPopGesture, + required this.builder, + }); + + final ScrollableWidgetBuilder builder; + + final ValueGetter enabledCallback; + + final ValueGetter<_CupertinoDragGestureController> onStartPopGesture; + + @override + _CupertinoDraggableScrollableSheetState createState() => + _CupertinoDraggableScrollableSheetState(); +} + +class _CupertinoDraggableScrollableSheetState + extends State<_CupertinoDraggableScrollableSheet> { + late _CupertinoSheetScrollController _scrollController; + _CupertinoDragGestureController? _dragGestureController; + + @override + void initState() { + super.initState(); + _scrollController = _CupertinoSheetScrollController( + onDragStart: _dragStart, + onDragUpdate: _dragUpdate, + onDragEnd: _handleDragEnd, + sheetIsDraggedDown: () => _dragGestureController?.isDragged() ?? false, + ); + } + + @override + void dispose() { + // If this is disposed during a drag, call navigator.didStopUserGesture. + if (_dragGestureController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_dragGestureController?.navigator.mounted ?? false) { + _dragGestureController?.navigator.didStopUserGesture(); + } + _dragGestureController = null; + }); + } + _scrollController.dispose(); + super.dispose(); + } + + void _dragStart() { + assert(mounted); + _dragGestureController ??= widget.onStartPopGesture(); + } + + void _dragUpdate(double delta) { + assert(mounted); + if (_dragGestureController != null) { + _dragGestureController!.dragUpdate( + delta / (context.size!.height - (context.size!.height * _kTopGapRatio)), + null, + ); + } + } + + void _handleDragEnd(double velocity) { + assert(mounted); + if (_dragGestureController != null) { + _dragGestureController!.dragEnd(-velocity / context.size!.height, null); + _dragGestureController = null; + } + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _scrollController); + } +} diff --git a/packages/cupertino_ui/lib/src/slider.dart b/packages/cupertino_ui/lib/src/slider.dart new file mode 100644 index 000000000000..1d6ded7ad685 --- /dev/null +++ b/packages/cupertino_ui/lib/src/slider.dart @@ -0,0 +1,673 @@ +// 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/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; +import 'thumb_painter.dart'; + +typedef _SliderValueChanged = void Function(double value, bool isFastDrag)?; + +/// Defines the threshold for determining a "fast" slider drag. +/// +/// Measured in slider extent per second. +/// +/// For example, a threshold of 1.0 means that the user must drag with +/// a velocity that will move the slider from start to end in 1 second. +/// +/// A threshold of 0.5 means that the user must drag with a velocity +/// that will move the slider 50% in 1 second. +/// +/// This value is estimated using a physical iPhone 15 Pro running iOS 18. +const double _kVelocityThreshold = 1.0; + +// Examples can assume: +// int _cupertinoSliderValue = 1; +// void setState(VoidCallback fn) { } + +/// An iOS-style slider. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs} +/// +/// Used to select from a range of values. +/// +/// A slider can be used to select from either a continuous or a discrete set of +/// values. The default is use a continuous range of values from [min] to [max]. +/// To use discrete values, use a non-null value for [divisions], which +/// indicates the number of discrete intervals. For example, if [min] is 0.0 and +/// [max] is 50.0 and [divisions] is 5, then the slider can take on the values +/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0. +/// +/// The slider itself does not maintain any state. Instead, when the state of +/// the slider changes, the widget calls the [onChanged] callback. Most widgets +/// that use a slider will listen for the [onChanged] callback and rebuild the +/// slider with a new [value] to update the visual appearance of the slider. +/// +/// {@tool dartpad} +/// This example shows how to show the current slider value as it changes. +/// +/// ** See code in examples/api/lib/cupertino/slider/cupertino_slider.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * +class CupertinoSlider extends StatefulWidget { + /// Creates an iOS-style slider. + /// + /// The slider itself does not maintain any state. Instead, when the state of + /// the slider changes, the widget calls the [onChanged] callback. Most widgets + /// that use a slider will listen for the [onChanged] callback and rebuild the + /// slider with a new [value] to update the visual appearance of the slider. + /// + /// * [value] determines currently selected value for this slider. + /// * [onChanged] is called when the user selects a new value for the slider. + /// * [onChangeStart] is called when the user starts to select a new value for + /// the slider. + /// * [onChangeEnd] is called when the user is done selecting a new value for + /// the slider. + const CupertinoSlider({ + super.key, + required this.value, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.activeColor, + this.thumbColor = CupertinoColors.white, + }) : assert(value >= min && value <= max), + assert(divisions == null || divisions > 0); + + /// The currently selected value for this slider. + /// + /// The slider's thumb is drawn at a position that corresponds to this value. + final double value; + + /// Called when the user selects a new value for the slider. + /// + /// The slider passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the slider with the new + /// value. + /// + /// If null, the slider will be displayed as disabled. + /// + /// The callback provided to onChanged should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CupertinoSlider( + /// value: _cupertinoSliderValue.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// onChanged: (double newValue) { + /// setState(() { + /// _cupertinoSliderValue = newValue.round(); + /// }); + /// }, + /// ) + /// ``` + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when the user starts + /// changing the value. + /// * [onChangeEnd] for a callback that is called when the user stops + /// changing the value. + final ValueChanged? onChanged; + + /// Called when the user starts selecting a new value for the slider. + /// + /// This callback shouldn't be used to update the slider [value] (use + /// [onChanged] for that), but rather to be notified when the user has started + /// selecting a new value by starting a drag. + /// + /// The value passed will be the last [value] that the slider had before the + /// change began. + /// + /// {@tool snippet} + /// + /// ```dart + /// CupertinoSlider( + /// value: _cupertinoSliderValue.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// onChanged: (double newValue) { + /// setState(() { + /// _cupertinoSliderValue = newValue.round(); + /// }); + /// }, + /// onChangeStart: (double startValue) { + /// print('Started change at $startValue'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeEnd] for a callback that is called when the value change is + /// complete. + final ValueChanged? onChangeStart; + + /// Called when the user is done selecting a new value for the slider. + /// + /// This callback shouldn't be used to update the slider [value] (use + /// [onChanged] for that), but rather to know when the user has completed + /// selecting a new [value] by ending a drag. + /// + /// {@tool snippet} + /// + /// ```dart + /// CupertinoSlider( + /// value: _cupertinoSliderValue.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// onChanged: (double newValue) { + /// setState(() { + /// _cupertinoSliderValue = newValue.round(); + /// }); + /// }, + /// onChangeEnd: (double newValue) { + /// print('Ended change on $newValue'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when a value change + /// begins. + final ValueChanged? onChangeEnd; + + /// The minimum value the user can select. + /// + /// Defaults to 0.0. + final double min; + + /// The maximum value the user can select. + /// + /// Defaults to 1.0. + final double max; + + /// The number of discrete divisions. + /// + /// If null, the slider is continuous. + final int? divisions; + + /// The color to use for the portion of the slider that has been selected. + /// + /// Defaults to the [CupertinoTheme]'s primary color if null. + final Color? activeColor; + + /// The color to use for the thumb of the slider. + /// + /// Defaults to [CupertinoColors.white]. + final Color thumbColor; + + @override + State createState() => _CupertinoSliderState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('value', value)); + properties.add(DoubleProperty('min', min)); + properties.add(DoubleProperty('max', max)); + } +} + +class _CupertinoSliderState extends State with TickerProviderStateMixin { + void _handleChanged(double value, bool isFastDrag) { + assert(widget.onChanged != null); + final double lerpValue = lerpDouble(widget.min, widget.max, value)!; + final bool isAtEdge = lerpValue == widget.max || lerpValue == widget.min; + + if (lerpValue != widget.value) { + if (isAtEdge) { + _emitHapticFeedback(isFastDrag); + } + widget.onChanged!(lerpValue); + } + } + + void _handleDragStart(double value) { + assert(widget.onChangeStart != null); + widget.onChangeStart!(lerpDouble(widget.min, widget.max, value)!); + } + + void _handleDragEnd(double value) { + assert(widget.onChangeEnd != null); + widget.onChangeEnd!(lerpDouble(widget.min, widget.max, value)!); + } + + void _emitHapticFeedback(bool isFastDrag) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + // The values are estimated using a physical iPhone 15 Pro running iOS 18. + if (isFastDrag) { + HapticFeedback.mediumImpact(); + } else { + HapticFeedback.selectionClick(); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + + @override + Widget build(BuildContext context) { + return _CupertinoSliderRenderObjectWidget( + value: (widget.value - widget.min) / (widget.max - widget.min), + divisions: widget.divisions, + activeColor: CupertinoDynamicColor.resolve( + widget.activeColor ?? CupertinoTheme.of(context).primaryColor, + context, + ), + thumbColor: widget.thumbColor, + onChanged: widget.onChanged != null ? _handleChanged : null, + onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, + onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, + vsync: this, + ); + } +} + +class _CupertinoSliderRenderObjectWidget extends LeafRenderObjectWidget { + const _CupertinoSliderRenderObjectWidget({ + required this.value, + this.divisions, + required this.activeColor, + required this.thumbColor, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + required this.vsync, + }); + + final double value; + final int? divisions; + final Color activeColor; + final Color thumbColor; + final _SliderValueChanged onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + final TickerProvider vsync; + + @override + _RenderCupertinoSlider createRenderObject(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + return _RenderCupertinoSlider( + value: value, + divisions: divisions, + activeColor: activeColor, + thumbColor: CupertinoDynamicColor.resolve(thumbColor, context), + trackColor: CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context), + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + vsync: vsync, + textDirection: Directionality.of(context), + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderCupertinoSlider renderObject) { + assert(debugCheckHasDirectionality(context)); + renderObject + ..value = value + ..divisions = divisions + ..activeColor = activeColor + ..thumbColor = CupertinoDynamicColor.resolve(thumbColor, context) + ..trackColor = CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context) + ..onChanged = onChanged + ..onChangeStart = onChangeStart + ..onChangeEnd = onChangeEnd + ..textDirection = Directionality.of(context); + // Ticker provider cannot change since there's a 1:1 relationship between + // the _SliderRenderObjectWidget object and the _SliderState object. + } +} + +const double _kPadding = 8.0; +const double _kSliderHeight = 2.0 * (CupertinoThumbPainter.radius + _kPadding); +const double _kSliderWidth = 176.0; // Matches Material Design slider. +const Duration _kDiscreteTransitionDuration = Duration(milliseconds: 500); + +const double _kAdjustmentUnit = 0.1; // Matches iOS implementation of material slider. + +class _RenderCupertinoSlider extends RenderConstrainedBox implements MouseTrackerAnnotation { + _RenderCupertinoSlider({ + required double value, + int? divisions, + required Color activeColor, + required Color thumbColor, + required Color trackColor, + _SliderValueChanged onChanged, + this.onChangeStart, + this.onChangeEnd, + required TickerProvider vsync, + required TextDirection textDirection, + MouseCursor cursor = MouseCursor.defer, + }) : assert(value >= 0.0 && value <= 1.0), + _cursor = cursor, + _value = value, + _divisions = divisions, + _activeColor = activeColor, + _thumbColor = thumbColor, + _trackColor = trackColor, + _onChanged = onChanged, + _textDirection = textDirection, + super( + additionalConstraints: const BoxConstraints.tightFor( + width: _kSliderWidth, + height: _kSliderHeight, + ), + ) { + _drag = HorizontalDragGestureRecognizer() + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd; + _position = AnimationController( + value: value, + duration: _kDiscreteTransitionDuration, + vsync: vsync, + )..addListener(markNeedsPaint); + } + + double get value => _value; + double _value; + set value(double newValue) { + assert(newValue >= 0.0 && newValue <= 1.0); + if (newValue == _value) { + return; + } + _value = newValue; + if (divisions != null) { + _position.animateTo(newValue, curve: Curves.fastOutSlowIn); + } else { + _position.value = newValue; + } + markNeedsSemanticsUpdate(); + } + + int? get divisions => _divisions; + int? _divisions; + set divisions(int? value) { + if (value == _divisions) { + return; + } + _divisions = value; + markNeedsPaint(); + } + + Color get activeColor => _activeColor; + Color _activeColor; + set activeColor(Color value) { + if (value == _activeColor) { + return; + } + _activeColor = value; + markNeedsPaint(); + } + + Color get thumbColor => _thumbColor; + Color _thumbColor; + set thumbColor(Color value) { + if (value == _thumbColor) { + return; + } + _thumbColor = value; + markNeedsPaint(); + } + + Color get trackColor => _trackColor; + Color _trackColor; + set trackColor(Color value) { + if (value == _trackColor) { + return; + } + _trackColor = value; + markNeedsPaint(); + } + + _SliderValueChanged get onChanged => _onChanged; + _SliderValueChanged _onChanged; + set onChanged(_SliderValueChanged value) { + if (value == _onChanged) { + return; + } + final bool wasInteractive = isInteractive; + _onChanged = value; + if (wasInteractive != isInteractive) { + markNeedsSemanticsUpdate(); + } + } + + ValueChanged? onChangeStart; + ValueChanged? onChangeEnd; + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsPaint(); + } + + late AnimationController _position; + + late HorizontalDragGestureRecognizer _drag; + double _currentDragValue = 0.0; + + double get _discretizedCurrentDragValue { + double dragValue = clampDouble(_currentDragValue, 0.0, 1.0); + if (divisions != null) { + dragValue = (dragValue * divisions!).round() / divisions!; + } + return dragValue; + } + + double get _trackLeft => _kPadding; + double get _trackRight => size.width - _kPadding; + double get _thumbCenter { + final double visualPosition = switch (textDirection) { + TextDirection.rtl => 1.0 - _value, + TextDirection.ltr => _value, + }; + return lerpDouble( + _trackLeft + CupertinoThumbPainter.radius, + _trackRight - CupertinoThumbPainter.radius, + visualPosition, + )!; + } + + bool get isInteractive => onChanged != null; + + void _handleDragStart(DragStartDetails details) => _startInteraction(details); + + Duration? _lastUpdateTimestamp; + + void _handleDragUpdate(DragUpdateDetails details) { + if (!isInteractive) { + return; + } + final double extent = math.max( + _kPadding, + size.width - 2.0 * (_kPadding + CupertinoThumbPainter.radius), + ); + final double valueDelta = details.primaryDelta! / extent; + _currentDragValue += switch (textDirection) { + TextDirection.rtl => -valueDelta, + TextDirection.ltr => valueDelta, + }; + + // Default to false if no source timestamp is available. + var isFast = false; + final Duration? currentTimestamp = details.sourceTimeStamp; + if (currentTimestamp != null && _lastUpdateTimestamp != null) { + final int timeDelta = (currentTimestamp - _lastUpdateTimestamp!).inMilliseconds; + final double velocity = valueDelta.abs() * 1000.0 / timeDelta; + // Velocity is in units of slider extent per second. + // Value of 0.5 means the user is dragging at 50% of the slider extent per second. + isFast = velocity > _kVelocityThreshold; + } + _lastUpdateTimestamp = currentTimestamp; + onChanged!(_discretizedCurrentDragValue, isFast); + } + + void _handleDragEnd(DragEndDetails details) => _endInteraction(); + + void _startInteraction(DragStartDetails details) { + if (isInteractive) { + onChangeStart?.call(_discretizedCurrentDragValue); + _currentDragValue = _value; + _lastUpdateTimestamp = details.sourceTimeStamp; + onChanged!(_discretizedCurrentDragValue, false); + } + } + + void _endInteraction() { + onChangeEnd?.call(_discretizedCurrentDragValue); + _currentDragValue = 0.0; + _lastUpdateTimestamp = null; + } + + @override + bool hitTestSelf(Offset position) { + return (position.dx - _thumbCenter).abs() < CupertinoThumbPainter.radius + _kPadding; + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent && isInteractive) { + _drag.addPointer(event); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + final (double visualPosition, Color leftColor, Color rightColor) = switch (textDirection) { + TextDirection.rtl => (1.0 - _position.value, _activeColor, trackColor), + TextDirection.ltr => (_position.value, trackColor, _activeColor), + }; + + final double trackCenter = offset.dy + size.height / 2.0; + final double trackLeft = offset.dx + _trackLeft; + final double trackTop = trackCenter - 1.0; + final double trackBottom = trackCenter + 1.0; + final double trackRight = offset.dx + _trackRight; + final double trackActive = offset.dx + _thumbCenter; + + final Canvas canvas = context.canvas; + if (visualPosition > 0.0) { + final paint = Paint()..color = rightColor; + // Use RRect instead of RSuperellipse here since the radius is too + // small to make enough visual difference. + canvas.drawRRect( + RRect.fromLTRBXY(trackLeft, trackTop, trackActive, trackBottom, 1.0, 1.0), + paint, + ); + } + + if (visualPosition < 1.0) { + final paint = Paint()..color = leftColor; + // Use RRect instead of RSuperellipse here since the radius is too + // small to make enough visual difference. + canvas.drawRRect( + RRect.fromLTRBXY(trackActive, trackTop, trackRight, trackBottom, 1.0, 1.0), + paint, + ); + } + + final thumbCenter = Offset(trackActive, trackCenter); + CupertinoThumbPainter( + color: thumbColor, + ).paint(canvas, Rect.fromCircle(center: thumbCenter, radius: CupertinoThumbPainter.radius)); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + + config.isSemanticBoundary = isInteractive; + config.isSlider = true; + if (isInteractive) { + config.textDirection = textDirection; + config.onIncrease = _increaseAction; + config.onDecrease = _decreaseAction; + config.value = '${(value * 100).round()}%'; + config.increasedValue = + '${(clampDouble(value + _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; + config.decreasedValue = + '${(clampDouble(value - _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; + } + } + + double get _semanticActionUnit => divisions != null ? 1.0 / divisions! : _kAdjustmentUnit; + + void _increaseAction() { + if (isInteractive) { + onChanged!(clampDouble(value + _semanticActionUnit, 0.0, 1.0), false); + } + } + + void _decreaseAction() { + if (isInteractive) { + onChanged!(clampDouble(value - _semanticActionUnit, 0.0, 1.0), false); + } + } + + @override + MouseCursor get cursor => _cursor; + MouseCursor _cursor; + set cursor(MouseCursor value) { + if (_cursor != value) { + _cursor = value; + // A repaint is needed in order to trigger a device update of + // [MouseTracker] so that this new value can be found. + markNeedsPaint(); + } + } + + @override + PointerEnterEventListener? onEnter; + + PointerHoverEventListener? onHover; + + @override + PointerExitEventListener? onExit; + + @override + bool get validForMouseTracker => false; + + @override + void dispose() { + _drag.dispose(); + _position.dispose(); + super.dispose(); + } +} diff --git a/packages/cupertino_ui/lib/src/sliding_segmented_control.dart b/packages/cupertino_ui/lib/src/sliding_segmented_control.dart new file mode 100644 index 000000000000..e97fa82fc9d1 --- /dev/null +++ b/packages/cupertino_ui/lib/src/sliding_segmented_control.dart @@ -0,0 +1,1524 @@ +// 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. + +/// @docImport 'dart:collection'; +library; + +import 'dart:math' as math; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; + +// Extracted from https://developer.apple.com/design/resources/. +// Default values have been updated to match iOS 17 figma file: https://www.figma.com/community/file/1248375255495415511. + +// Minimum padding from edges of the segmented control to edges of +// encompassing widget. +const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(vertical: 2, horizontal: 3); + +// The corner radius of the segmented control. +const Radius _kCornerRadius = Radius.circular(9); + +// The corner radius of the thumb. +const Radius _kThumbRadius = Radius.circular(7); +// The amount of space by which to expand the thumb from the size of the currently +// selected child. +const EdgeInsets _kThumbInsets = EdgeInsets.symmetric(horizontal: 1); + +// Minimum height of the segmented control. +const double _kMinSegmentedControlHeight = 28.0; + +const Color _kSeparatorColor = Color(0x4D8E8E93); + +const CupertinoDynamicColor _kThumbColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFFFFFFF), + darkColor: Color(0xFF636366), +); + +// The amount of space by which to inset each separator. +const EdgeInsets _kSeparatorInset = EdgeInsets.symmetric(vertical: 5); +const double _kSeparatorWidth = 1; +const Radius _kSeparatorRadius = Radius.circular(_kSeparatorWidth / 2); + +// The minimum scale factor of the thumb, when being pressed on for a sufficient +// amount of time. +const double _kMinThumbScale = 0.95; + +// The peak scale factor of the thumb during the pressing animation of a +// momentary variant. +const double _kMaxThumbScaleForMomentary = 1.05; + +// The minimum horizontal distance between the edges of the separator and the +// closest child. +const double _kSegmentMinPadding = 10; + +// The threshold value used in hasDraggedTooFar, for checking against the square +// L2 distance from the location of the current drag pointer, to the closest +// vertex of the CupertinoSlidingSegmentedControl's Rect. +// +// Both the mechanism and the value are speculated. +const double _kTouchYDistanceThreshold = 50.0 * 50.0; + +// The minimum opacity of an unselected segment, when the user presses on the +// segment and it starts to fadeout. +// +// Inspected from iOS 17.5 simulator. +const double _kContentPressedMinOpacity = 0.2; + +// Inspected from iOS 17.5 simulator. +const double _kFontSize = 13.0; + +// Inspected from iOS 17.5 simulator. +const FontWeight _kFontWeight = FontWeight.w500; + +// Inspected from iOS 17.5 simulator. +const FontWeight _kHighlightedFontWeight = FontWeight.w600; + +// Inspected from iOS 17.5 simulator +const Color _kDisabledContentColor = Color.fromARGB(115, 122, 122, 122); + +// The spring animation used when the thumb changes its rect. +final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation( + const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799), + 0, + 1, + 0, // Every time a new spring animation starts the previous animation stops. +); + +const Duration _kSpringAnimationDuration = Duration(milliseconds: 412); + +const Duration _kOpacityAnimationDuration = Duration(milliseconds: 470); + +const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200); + +class _Segment extends StatefulWidget { + const _Segment({ + required ValueKey key, + required this.child, + required this.pressed, + required this.highlighted, + required this.isDragging, + required this.enabled, + required this.segmentLocation, + required this.isMomentary, + }) : super(key: key); + + final Widget child; + + final bool pressed; + final bool highlighted; + final bool enabled; + final _SegmentLocation segmentLocation; + final bool isMomentary; + + // Whether the thumb of the parent widget (CupertinoSlidingSegmentedControl) + // is currently being dragged. + final bool isDragging; + + bool get shouldFadeoutContent => pressed && !highlighted && enabled && !isMomentary; + bool get shouldScaleContent => pressed && enabled && (highlighted && isDragging || isMomentary); + bool get shouldHighlightContent => highlighted && !isMomentary; + + @override + _SegmentState createState() => _SegmentState(); +} + +class _SegmentState extends State<_Segment> with TickerProviderStateMixin<_Segment> { + late final AnimationController highlightPressScaleController; + late Animation highlightPressScaleAnimation; + + @override + void initState() { + super.initState(); + highlightPressScaleController = AnimationController( + duration: _kOpacityAnimationDuration, + value: widget.shouldScaleContent ? 1 : 0, + vsync: this, + ); + + highlightPressScaleAnimation = highlightPressScaleController.drive( + Tween(begin: 1.0, end: _kMinThumbScale), + ); + } + + @override + void didUpdateWidget(_Segment oldWidget) { + super.didUpdateWidget(oldWidget); + assert(oldWidget.key == widget.key); + + if (oldWidget.shouldScaleContent != widget.shouldScaleContent) { + final Animatable scaleAnimation = widget.isMomentary && widget.shouldScaleContent + ? TweenSequence(>[ + TweenSequenceItem( + tween: Tween( + begin: highlightPressScaleAnimation.value, + end: _kMaxThumbScaleForMomentary, + ), + weight: 50, + ), + TweenSequenceItem( + tween: Tween(begin: _kMaxThumbScaleForMomentary, end: 1.0), + weight: 50, + ), + ]) + : Tween( + begin: highlightPressScaleAnimation.value, + end: widget.shouldScaleContent ? _kMinThumbScale : 1.0, + ); + highlightPressScaleAnimation = highlightPressScaleController.drive(scaleAnimation); + highlightPressScaleController.animateWith(_kThumbSpringAnimationSimulation); + } + } + + @override + void dispose() { + highlightPressScaleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Alignment scaleAlignment = switch (widget.segmentLocation) { + _SegmentLocation.leftmost => Alignment.centerLeft, + _SegmentLocation.rightmost => Alignment.centerRight, + _SegmentLocation.inbetween => Alignment.center, + }; + + return MetaData( + // Expand the hitTest area of this widget. + behavior: HitTestBehavior.opaque, + child: IndexedStack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + opacity: widget.shouldFadeoutContent ? _kContentPressedMinOpacity : 1, + duration: _kOpacityAnimationDuration, + curve: Curves.ease, + child: AnimatedDefaultTextStyle( + style: DefaultTextStyle.of(context).style.merge( + TextStyle( + fontWeight: widget.shouldHighlightContent + ? _kHighlightedFontWeight + : _kFontWeight, + fontSize: _kFontSize, + color: widget.enabled ? null : _kDisabledContentColor, + ), + ), + duration: _kHighlightAnimationDuration, + curve: Curves.ease, + child: ScaleTransition( + alignment: scaleAlignment, + scale: highlightPressScaleAnimation, + child: widget.child, + ), + ), + ), + // The entire widget will assume the size of this widget, so when a + // segment's "highlight" animation plays the size of the parent stays + // the same and will always be greater than equal to that of the + // visible child (at index 0), to keep the size of the entire + // SegmentedControl widget consistent throughout the animation. + DefaultTextStyle.merge( + style: const TextStyle(fontWeight: _kHighlightedFontWeight, fontSize: _kFontSize), + child: widget.child, + ), + ], + ), + ); + } +} + +// Fadeout the separator when either adjacent segment is highlighted. +class _SegmentSeparator extends StatefulWidget { + const _SegmentSeparator({required ValueKey key, required this.highlighted}) + : super(key: key); + + final bool highlighted; + + @override + _SegmentSeparatorState createState() => _SegmentSeparatorState(); +} + +class _SegmentSeparatorState extends State<_SegmentSeparator> + with TickerProviderStateMixin<_SegmentSeparator> { + late final AnimationController separatorOpacityController; + + @override + void initState() { + super.initState(); + + separatorOpacityController = AnimationController( + duration: _kSpringAnimationDuration, + value: widget.highlighted ? 0 : 1, + vsync: this, + ); + } + + @override + void didUpdateWidget(_SegmentSeparator oldWidget) { + super.didUpdateWidget(oldWidget); + assert(oldWidget.key == widget.key); + + if (oldWidget.highlighted != widget.highlighted) { + separatorOpacityController.animateTo( + widget.highlighted ? 0 : 1, + duration: _kSpringAnimationDuration, + curve: Curves.ease, + ); + } + } + + @override + void dispose() { + separatorOpacityController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: separatorOpacityController, + child: const SizedBox(width: _kSeparatorWidth), + builder: (BuildContext context, Widget? child) { + return Padding( + padding: _kSeparatorInset, + child: DecoratedBox( + decoration: BoxDecoration( + color: _kSeparatorColor.withOpacity( + _kSeparatorColor.opacity * separatorOpacityController.value, + ), + // Use RRect instead of RSuperellipse here since the radius is too + // small to make enough visual difference. + borderRadius: const BorderRadius.all(_kSeparatorRadius), + ), + child: child, + ), + ); + }, + ); + } +} + +/// An iOS 13 style segmented control. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=esnBf6V4C34} +/// +/// Displays the widgets provided in the [Map] of [children] in a horizontal list. +/// It allows the user to select between a number of mutually exclusive options, +/// by tapping or dragging within the segmented control. +/// +/// A segmented control can feature any [Widget] as one of the values in its +/// [Map] of [children]. The type T is the type of the [Map] keys used to identify +/// each widget and determine which widget is selected. As required by the [Map] +/// class, keys must be of consistent types and must be comparable. The [children] +/// argument must be an ordered [Map] such as a [LinkedHashMap], the ordering of +/// the keys will determine the order of the widgets in the segmented control. +/// +/// The widget calls the [onValueChanged] callback *when a valid user gesture +/// completes on an unselected segment*. The map key associated with the newly +/// selected widget is returned in the [onValueChanged] callback. Typically, +/// widgets that use a segmented control will listen for the [onValueChanged] +/// callback and rebuild the segmented control with a new [groupValue] to update +/// which option is currently selected. +/// +/// The [children] will be displayed in the order of the keys in the [Map], +/// along the current [TextDirection]. Each child widget will have the same size. +/// The height of the segmented control is determined by the height of the +/// tallest child widget. The width of each child will be the intrinsic width of +/// the widest child, or the available horizontal space divided by the number of +/// [children], which ever is smaller. +/// +/// A segmented control may optionally be created with custom colors. The +/// [thumbColor], [backgroundColor] arguments can be used to override the +/// segmented control's colors from its defaults. +/// +/// {@tool dartpad} +/// This example shows a [CupertinoSlidingSegmentedControl] with an enum type. +/// +/// The callback provided to [onValueChanged] should update the state of +/// the parent [StatefulWidget] using the [State.setState] method, so that +/// the parent gets rebuilt; for example: +/// +/// ** See code in examples/api/lib/cupertino/segmented_control/cupertino_sliding_segmented_control.0.dart ** +/// {@end-tool} +/// See also: +/// +/// * +class CupertinoSlidingSegmentedControl extends StatefulWidget { + /// Creates an iOS-style segmented control bar. + /// + /// The [children] argument must be an ordered [Map] such as a + /// [LinkedHashMap]. Further, the length of the [children] list must be + /// greater than one. + /// + /// Each widget value in the map of [children] must have an associated key + /// that uniquely identifies this widget. This key is what will be returned + /// in the [onValueChanged] callback when a new value from the [children] map + /// is selected. + /// + /// The [groupValue] is the currently selected value for the segmented control. + /// If no [groupValue] is provided, or the [groupValue] is null, no widget will + /// appear as selected. The [groupValue] must be either null or one of the keys + /// in the [children] map. + CupertinoSlidingSegmentedControl({ + super.key, + required this.children, + required this.onValueChanged, + this.disabledChildren = const {}, + this.groupValue, + this.thumbColor = _kThumbColor, + this.padding = _kHorizontalItemPadding, + this.backgroundColor = CupertinoColors.tertiarySystemFill, + this.proportionalWidth = false, + this.isMomentary = false, + }) : assert(children.length >= 2), + assert( + groupValue == null || children.keys.contains(groupValue), + 'The groupValue must be either null or one of the keys in the children map.', + ); + + /// The identifying keys and corresponding widget values in the + /// segmented control. + /// + /// This attribute must be an ordered [Map] such as a [LinkedHashMap]. Each + /// widget is typically a single-line [Text] widget or an [Icon] widget. + /// + /// The map must have more than one entry. + final Map children; + + /// The set of identifying keys that correspond to the segments that should be + /// disabled. + /// + /// Disabled children cannot be selected by dragging, but they can be selected + /// programmatically. For example, if the [groupValue] is set to a disabled + /// segment, the segment is still selected but the segment content looks disabled. + /// + /// If an enabled segment is selected by dragging gesture and becomes disabled + /// before dragging finishes, [onValueChanged] will be triggered when finger is + /// released and the disabled segment is selected. + /// + /// By default, all segments are selectable. + final Set disabledChildren; + + /// The identifier of the widget that is currently selected. + /// + /// This must be one of the keys in the [Map] of [children]. + /// If this attribute is null, no widget will be initially selected. + final T? groupValue; + + /// The callback that is called when a new option is tapped. + /// + /// The segmented control passes the newly selected widget's associated key + /// to the callback but does not actually change state until the parent + /// widget rebuilds the segmented control with the new [groupValue]. + /// + /// The callback provided to [onValueChanged] should update the state of + /// the parent [StatefulWidget] using the [State.setState] method, so that + /// the parent gets rebuilt; for example: + /// + /// {@tool snippet} + /// + /// ```dart + /// class SegmentedControlExample extends StatefulWidget { + /// const SegmentedControlExample({super.key}); + /// + /// @override + /// State createState() => SegmentedControlExampleState(); + /// } + /// + /// class SegmentedControlExampleState extends State { + /// final Map children = const { + /// 0: Text('Child 1'), + /// 1: Text('Child 2'), + /// }; + /// + /// int? currentValue; + /// + /// @override + /// Widget build(BuildContext context) { + /// return CupertinoSlidingSegmentedControl( + /// children: children, + /// onValueChanged: (int? newValue) { + /// setState(() { + /// currentValue = newValue; + /// }); + /// }, + /// groupValue: currentValue, + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + final ValueChanged onValueChanged; + + /// The color used to paint the rounded rect behind the [children] and the separators. + /// + /// The default value is [CupertinoColors.tertiarySystemFill]. The background + /// will not be painted if null is specified. + final Color backgroundColor; + + /// Determine whether segments have proportional widths based on their content. + /// + /// If false, all segments will have the same width, determined by the longest + /// segment. If true, each segment's width will be determined by its individual + /// content. + /// + /// If the max width of parent constraints is smaller than the width that the + /// segmented control needs, The segment widths will scale down proportionally + /// to ensure the segment control fits within the boundaries; similarly, if + /// the min width of parent constraints is larger, the segment width will scales + /// up to meet the min width requirement. + /// + /// Defaults to false. + final bool proportionalWidth; + + /// The color used to paint the interior of the thumb that appears behind the + /// currently selected item. + /// + /// The default value is a [CupertinoDynamicColor] that appears white in light + /// mode and becomes a gray color in dark mode. + final Color thumbColor; + + /// The amount of space by which to inset the [children]. + /// + /// Defaults to `EdgeInsets.symmetric(vertical: 2, horizontal: 3)`. + final EdgeInsetsGeometry padding; + + /// Determines whether segments provide only momentary feedback when pressed + /// rather than maintaining a persistent selected state. + /// + /// When true, segments behave more like buttons that trigger actions rather + /// than options that can be selected and remain in that state. + /// + /// Defaults to false. + /// + /// {@tool dartpad} + /// This example shows a [CupertinoSlidingSegmentedControl] with [isMomentary] set + /// to true, providing feedback to the user when the segment is selected with a + /// text scaling effect. + /// + /// ** See code in examples/api/lib/cupertino/segmented_control/cupertino_sliding_segmented_control.0.dart ** + /// {@end-tool} + final bool isMomentary; + + @override + State> createState() => _SegmentedControlState(); +} + +/// A wrapper widget that implements RadioClient for each segment button in sliding segmented control. +class _SlidingSegmentButton extends StatefulWidget { + const _SlidingSegmentButton({ + super.key, + required this.value, + required this.child, + required this.enabled, + }); + + final T value; + final Widget child; + final bool enabled; + + @override + State<_SlidingSegmentButton> createState() => _SlidingSegmentButtonState(); +} + +class _SlidingSegmentButtonState extends State<_SlidingSegmentButton> with RadioClient { + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'CupertinoSlidingSegmentedControl<$T>[${widget.value}]'); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + registry = widget.enabled ? RadioGroup.maybeOf(context) : null; + } + + @override + void didUpdateWidget(_SlidingSegmentButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.enabled != widget.enabled) { + registry = widget.enabled ? RadioGroup.maybeOf(context) : null; + } + } + + @override + void dispose() { + registry = null; + _focusNode.dispose(); + super.dispose(); + } + + @override + bool get enabled => widget.enabled; + + @override + T get radioValue => widget.value; + + @override + FocusNode get focusNode => _focusNode; + + @override + bool get tristate => false; + + void requestFocus() { + if (widget.enabled) { + _focusNode.requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + canRequestFocus: widget.enabled, + onKeyEvent: (FocusNode node, KeyEvent event) => KeyEventResult.ignored, + child: widget.child, + ); + } +} + +class _SegmentedControlState extends State> + with TickerProviderStateMixin> { + late final AnimationController thumbController = AnimationController( + duration: _kSpringAnimationDuration, + value: 0, + vsync: this, + ); + Animatable? thumbAnimatable; + + late final AnimationController thumbScaleController = AnimationController( + duration: _kSpringAnimationDuration, + value: 0, + vsync: this, + ); + late Animation thumbScaleAnimation = thumbScaleController.drive( + Tween(begin: 1, end: _kMinThumbScale), + ); + + final TapGestureRecognizer tap = TapGestureRecognizer(); + final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); + final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); + final GlobalKey segmentedControlRenderWidgetKey = GlobalKey(); + final Map>> _segmentKeys = + >>{}; + + @override + void initState() { + super.initState(); + + // If the long press or horizontal drag recognizer gets accepted, we know for + // sure the gesture is meant for the segmented control. Hand everything to + // the drag gesture recognizer. + final team = GestureArenaTeam(); + longPress.team = team; + drag.team = team; + team.captain = drag; + + drag + ..onDown = onDown + ..onUpdate = onUpdate + ..onEnd = onEnd + ..onCancel = onCancel; + + tap.onTapUp = onTapUp; + + // Empty callback to enable the long press recognizer. + longPress.onLongPress = () {}; + + highlighted = widget.groupValue; + } + + @override + void didUpdateWidget(CupertinoSlidingSegmentedControl oldWidget) { + super.didUpdateWidget(oldWidget); + + // Temporarily ignore highlight changes from the widget when the thumb is + // being dragged. When the drag gesture finishes the widget will be forced + // to build (see the onEnd method), and didUpdateWidget will be called again. + if (!isThumbDragging && highlighted != widget.groupValue) { + thumbController.animateWith(_kThumbSpringAnimationSimulation); + thumbAnimatable = null; + highlighted = widget.groupValue; + } + } + + @override + void dispose() { + thumbScaleController.dispose(); + thumbController.dispose(); + + drag.dispose(); + tap.dispose(); + longPress.dispose(); + + super.dispose(); + } + + // Whether the current drag gesture started on a selected segment. When this + // flag is false, the `onUpdate` method does not update `highlighted`. + // Otherwise the thumb can be dragged around in an ongoing drag gesture. + bool? _startedOnSelectedSegment; + + // Whether the current drag gesture started on a disabled segment. When this + // flag is true, drag gestures will be ignored. + bool _startedOnDisabledSegment = false; + + // Whether an ongoing horizontal drag gesture that started on the thumb is + // present. When true, defer/ignore changes to the `highlighted` variable + // from other sources (except for semantics) until the gesture ends, preventing + // them from interfering with the active drag gesture. + bool get isThumbDragging => (_startedOnSelectedSegment ?? false) && !_startedOnDisabledSegment; + + // Converts local coordinate to segments. + T segmentForXPosition(double dx) { + final BuildContext currentContext = segmentedControlRenderWidgetKey.currentContext!; + final renderBox = currentContext.findRenderObject()! as _RenderSegmentedControl; + + final int numOfChildren = widget.children.length; + assert(renderBox.hasSize); + assert(numOfChildren >= 2); + + int segmentIndex = renderBox.getClosestSegmentIndex(dx); + + switch (Directionality.of(context)) { + case TextDirection.ltr: + break; + case TextDirection.rtl: + segmentIndex = numOfChildren - 1 - segmentIndex; + } + return widget.children.keys.elementAt(segmentIndex); + } + + bool _hasDraggedTooFar(DragUpdateDetails details) { + final renderBox = context.findRenderObject()! as RenderBox; + assert(renderBox.hasSize); + final Size size = renderBox.size; + final Offset offCenter = details.localPosition - Offset(size.width / 2, size.height / 2); + final l2 = + math.pow(math.max(0.0, offCenter.dx.abs() - size.width / 2), 2) + + math.pow(math.max(0.0, offCenter.dy.abs() - size.height / 2), 2) + as double; + return l2 > _kTouchYDistanceThreshold; + } + + // The thumb shrinks when the user presses on it, and starts expanding when + // the user lets go. + // This animation must be synced with the segment scale animation (see the + // _Segment widget) to make the overall animation look natural when the thumb + // is not sliding. + void _playThumbScaleAnimation({required bool isExpanding}) { + thumbScaleAnimation = thumbScaleController.drive( + Tween(begin: thumbScaleAnimation.value, end: isExpanding ? 1 : _kMinThumbScale), + ); + thumbScaleController.animateWith(_kThumbSpringAnimationSimulation); + } + + void onHighlightChangedByGesture(T newValue) { + if (highlighted == newValue) { + return; + } + + setState(() { + highlighted = newValue; + }); + // Additionally, start the thumb animation if the highlighted segment + // changes. If the thumbController is already running, the render object's + // paint method will create a new tween to drive the animation with. + // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/74356: + // the current thumb will be painted at the same location twice (before and + // after the new animation starts). + thumbController.animateWith(_kThumbSpringAnimationSimulation); + thumbAnimatable = null; + } + + void onPressedChangedByGesture(T? newValue) { + if (pressed != newValue) { + setState(() { + pressed = newValue; + }); + } + } + + void onTapUp(TapUpDetails details) { + // No gesture should interfere with an ongoing thumb drag. + if (isThumbDragging) { + return; + } + + final T segment = segmentForXPosition(details.localPosition.dx); + onPressedChangedByGesture(null); + + if (!widget.disabledChildren.contains(segment)) { + _segmentKeys[segment]?.currentState?.requestFocus(); + + if (segment != widget.groupValue) { + widget.onValueChanged(segment); + } + } + } + + void onDown(DragDownDetails details) { + final T touchDownSegment = segmentForXPosition(details.localPosition.dx); + _startedOnSelectedSegment = touchDownSegment == highlighted; + _startedOnDisabledSegment = widget.disabledChildren.contains(touchDownSegment); + if (widget.disabledChildren.contains(touchDownSegment)) { + return; + } + onPressedChangedByGesture(touchDownSegment); + + if (isThumbDragging) { + _playThumbScaleAnimation(isExpanding: false); + } + } + + void onUpdate(DragUpdateDetails details) { + // If drag gesture starts on disabled segment, no update needed. + if (_startedOnDisabledSegment) { + return; + } + + // If drag gesture starts on enabled segment and dragging on disabled segment, + // no update needed. + final T touchDownSegment = segmentForXPosition(details.localPosition.dx); + if (widget.disabledChildren.contains(touchDownSegment)) { + return; + } + if (isThumbDragging) { + onPressedChangedByGesture(touchDownSegment); + onHighlightChangedByGesture(touchDownSegment); + } else { + final T? segment = _hasDraggedTooFar(details) + ? null + : segmentForXPosition(details.localPosition.dx); + onPressedChangedByGesture(segment); + } + } + + void onEnd(DragEndDetails details) { + final T? pressed = this.pressed; + if (isThumbDragging) { + _playThumbScaleAnimation(isExpanding: true); + if (highlighted != widget.groupValue) { + _segmentKeys[highlighted]?.currentState?.requestFocus(); + widget.onValueChanged(highlighted); + } + } else if (pressed != null) { + onHighlightChangedByGesture(pressed); + assert(pressed == highlighted); + if (highlighted != widget.groupValue) { + _segmentKeys[highlighted]?.currentState?.requestFocus(); + widget.onValueChanged(highlighted); + } + } + + onPressedChangedByGesture(null); + _startedOnSelectedSegment = null; + } + + void onCancel() { + if (isThumbDragging) { + _playThumbScaleAnimation(isExpanding: true); + } + onPressedChangedByGesture(null); + _startedOnSelectedSegment = null; + } + + // The segment the sliding thumb is currently located at, or animating to. It + // may have a different value from widget.groupValue, since this widget does + // not report a selection change via `onValueChanged` until the user stops + // interacting with the widget (onTapUp). For example, the user can drag the + // thumb around, and the `onValueChanged` callback will not be invoked until + // the thumb is let go. + T? highlighted; + + // The segment the user is currently pressing. + T? pressed; + + @override + Widget build(BuildContext context) { + assert(widget.children.length >= 2); + var children = []; + var isPreviousSegmentHighlighted = false; + + var index = 0; + int? highlightedIndex; + for (final MapEntry entry in widget.children.entries) { + final isHighlighted = highlighted == entry.key; + if (isHighlighted) { + highlightedIndex = index; + } + + if (index != 0) { + children.add( + _SegmentSeparator( + // Let separators be TextDirection-invariant. If the TextDirection + // changes, the separators should mostly stay where they were. + key: ValueKey(index), + highlighted: isPreviousSegmentHighlighted || isHighlighted, + ), + ); + } + + final TextDirection textDirection = Directionality.of(context); + final _SegmentLocation segmentLocation = switch (textDirection) { + TextDirection.ltr when index == 0 => _SegmentLocation.leftmost, + TextDirection.ltr when index == widget.children.length - 1 => _SegmentLocation.rightmost, + TextDirection.rtl when index == widget.children.length - 1 => _SegmentLocation.leftmost, + TextDirection.rtl when index == 0 => _SegmentLocation.rightmost, + TextDirection.ltr || TextDirection.rtl => _SegmentLocation.inbetween, + }; + final GlobalKey<_SlidingSegmentButtonState> segmentKey = _segmentKeys.putIfAbsent( + entry.key, + () => GlobalKey<_SlidingSegmentButtonState>(), + ); + + children.add( + _SlidingSegmentButton( + key: segmentKey, + value: entry.key, + enabled: !widget.disabledChildren.contains(entry.key), + child: Semantics( + button: true, + onTap: () { + if (widget.disabledChildren.contains(entry.key)) { + return; + } + _segmentKeys[entry.key]?.currentState?.requestFocus(); + widget.onValueChanged(entry.key); + }, + inMutuallyExclusiveGroup: true, + selected: widget.groupValue == entry.key, + child: MouseRegion( + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: _Segment( + key: ValueKey(entry.key), + highlighted: isHighlighted, + pressed: pressed == entry.key, + isDragging: isThumbDragging, + enabled: !widget.disabledChildren.contains(entry.key), + segmentLocation: segmentLocation, + isMomentary: widget.isMomentary, + child: entry.value, + ), + ), + ), + ), + ); + + index += 1; + isPreviousSegmentHighlighted = isHighlighted; + } + + assert((highlightedIndex == null) == (highlighted == null)); + + switch (Directionality.of(context)) { + case TextDirection.ltr: + break; + case TextDirection.rtl: + children = children.reversed.toList(growable: false); + if (highlightedIndex != null) { + highlightedIndex = index - 1 - highlightedIndex; + } + } + + return Actions( + actions: >{VoidCallbackIntent: VoidCallbackAction()}, + child: RadioGroup( + groupValue: widget.groupValue, + onChanged: (T? value) { + if (value != null && !widget.disabledChildren.contains(value)) { + widget.onValueChanged(value); + } + }, + child: UnconstrainedBox( + constrainedAxis: Axis.horizontal, + child: Container( + // Clip the thumb shadow if it is outside of the segmented control. This + // behavior is eyeballed by the iOS 17.5 simulator. + clipBehavior: Clip.antiAlias, + padding: widget.padding.resolve(Directionality.of(context)), + decoration: ShapeDecoration( + shape: const RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(_kCornerRadius), + ), + color: CupertinoDynamicColor.resolve(widget.backgroundColor, context), + ), + child: AnimatedBuilder( + animation: thumbScaleAnimation, + builder: (BuildContext context, Widget? child) { + return _SegmentedControlRenderWidget( + key: segmentedControlRenderWidgetKey, + highlightedIndex: widget.isMomentary ? null : highlightedIndex, + thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor, context), + thumbScale: thumbScaleAnimation.value, + proportionalWidth: widget.proportionalWidth, + state: this, + children: children, + ); + }, + ), + ), + ), + ), + ); + } +} + +class _SegmentedControlRenderWidget extends MultiChildRenderObjectWidget { + const _SegmentedControlRenderWidget({ + super.key, + super.children, + required this.highlightedIndex, + required this.thumbColor, + required this.thumbScale, + required this.proportionalWidth, + required this.state, + }); + + final int? highlightedIndex; + final Color thumbColor; + final double thumbScale; + final bool proportionalWidth; + final _SegmentedControlState state; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSegmentedControl( + highlightedIndex: highlightedIndex, + thumbColor: thumbColor, + thumbScale: thumbScale, + proportionalWidth: proportionalWidth, + state: state, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSegmentedControl renderObject) { + assert(renderObject.state == state); + renderObject + ..thumbColor = thumbColor + ..thumbScale = thumbScale + ..highlightedIndex = highlightedIndex + ..proportionalWidth = proportionalWidth; + } +} + +class _SegmentedControlContainerBoxParentData extends ContainerBoxParentData {} + +enum _SegmentLocation { leftmost, rightmost, inbetween } + +// The behavior of a UISegmentedControl as observed on iOS 13.1: +// +// 1. Tap up inside events will set the current selected index to the index of the +// segment at the tap up location instantaneously (there might be animation but +// the index change seems to happen before animation finishes), unless the tap +// down event from the same touch event didn't happen within the segmented +// control, in which case the touch event will be ignored entirely (will be +// referring to these touch events as invalid touch events below). +// +// 2. A valid tap up event will also trigger the sliding CASpringAnimation (even +// when it lands on the current segment), starting from the current `frame` +// of the thumb. The previous sliding animation, if still playing, will be +// removed and its velocity reset to 0. The sliding animation has a fixed +// duration, regardless of the distance or transform. +// +// 3. When the sliding animation plays two other animations take place. In one animation +// the content of the current segment gradually becomes "highlighted", turning the +// font weight to semibold (CABasicAnimation, timingFunction = default, duration = 0.2). +// The other is the separator fadein/fadeout animation (duration = 0.41). +// +// 4. A tap down event on the segment pointed to by the current selected +// index will trigger a CABasicAnimation that shrinks the thumb to 95% of its +// original size, even if the sliding animation is still playing. The +/// corresponding tap up event inverts the process (eyeballed). +// +// 5. A tap down event on other segments will trigger a CABasicAnimation +// (timingFunction = default, duration = 0.47.) that fades out the content +// from its current alpha, eventually reducing the alpha of that segment to +// 20% unless interrupted by a tap up event or the pointer moves out of the +// region (either outside of the segmented control's vicinity or to a +// different segment). The reverse animation has the same duration and timing +// function. +class _RenderSegmentedControl extends RenderBox + with + ContainerRenderObjectMixin>, + RenderBoxContainerDefaultsMixin> { + _RenderSegmentedControl({ + required int? highlightedIndex, + required Color thumbColor, + required double thumbScale, + required bool proportionalWidth, + required this.state, + }) : _highlightedIndex = highlightedIndex, + _thumbColor = thumbColor, + _thumbScale = thumbScale, + _proportionalWidth = proportionalWidth; + + final _SegmentedControlState state; + + // The current **Unscaled** Thumb Rect in this RenderBox's coordinate space. + Rect? currentThumbRect; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + state.thumbController.addListener(markNeedsPaint); + } + + @override + void detach() { + state.thumbController.removeListener(markNeedsPaint); + super.detach(); + } + + double get thumbScale => _thumbScale; + double _thumbScale; + set thumbScale(double value) { + if (_thumbScale == value) { + return; + } + + _thumbScale = value; + if (state.highlighted != null) { + markNeedsPaint(); + } + } + + int? get highlightedIndex => _highlightedIndex; + int? _highlightedIndex; + set highlightedIndex(int? value) { + if (_highlightedIndex == value) { + return; + } + + _highlightedIndex = value; + markNeedsPaint(); + } + + Color get thumbColor => _thumbColor; + Color _thumbColor; + set thumbColor(Color value) { + if (_thumbColor == value) { + return; + } + _thumbColor = value; + markNeedsPaint(); + } + + bool get proportionalWidth => _proportionalWidth; + bool _proportionalWidth; + set proportionalWidth(bool value) { + if (_proportionalWidth == value) { + return; + } + _proportionalWidth = value; + markNeedsLayout(); + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + // No gesture should interfere with an ongoing thumb drag. + if (event is PointerDownEvent && !state.isThumbDragging) { + state.tap.addPointer(event); + state.longPress.addPointer(event); + state.drag.addPointer(event); + } + } + + // Intrinsic Dimensions + double get separatorWidth => _kSeparatorInset.horizontal + _kSeparatorWidth; + double get totalSeparatorWidth => separatorWidth * (childCount ~/ 2); + + int getClosestSegmentIndex(double dx) { + var index = 0; + RenderBox? child = firstChild; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final double clampX = clampDouble( + dx, + childParentData.offset.dx, + child.size.width + childParentData.offset.dx, + ); + + if (dx <= clampX) { + break; + } + + index++; + child = nonSeparatorChildAfter(child); + } + + final int segmentCount = childCount ~/ 2 + 1; + // When the thumb is dragging out of bounds, the return result must be + // smaller than segment count. + return min(index, segmentCount - 1); + } + + RenderBox? nonSeparatorChildAfter(RenderBox child) { + final RenderBox? nextChild = childAfter(child); + return nextChild == null ? null : childAfter(nextChild); + } + + @override + double computeMinIntrinsicWidth(double height) { + final int childCount = this.childCount ~/ 2 + 1; + RenderBox? child = firstChild; + double maxMinChildWidth = 0; + while (child != null) { + final double childWidth = child.getMinIntrinsicWidth(height); + maxMinChildWidth = math.max(maxMinChildWidth, childWidth); + child = nonSeparatorChildAfter(child); + } + return (maxMinChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final int childCount = this.childCount ~/ 2 + 1; + RenderBox? child = firstChild; + double maxMaxChildWidth = 0; + while (child != null) { + final double childWidth = child.getMaxIntrinsicWidth(height); + maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth); + child = nonSeparatorChildAfter(child); + } + return (maxMaxChildWidth + 2 * _kSegmentMinPadding) * childCount + totalSeparatorWidth; + } + + @override + double computeMinIntrinsicHeight(double width) { + RenderBox? child = firstChild; + double maxMinChildHeight = _kMinSegmentedControlHeight; + while (child != null) { + final double childHeight = child.getMinIntrinsicHeight(width); + maxMinChildHeight = math.max(maxMinChildHeight, childHeight); + child = nonSeparatorChildAfter(child); + } + return maxMinChildHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + RenderBox? child = firstChild; + double maxMaxChildHeight = _kMinSegmentedControlHeight; + while (child != null) { + final double childHeight = child.getMaxIntrinsicHeight(width); + maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight); + child = nonSeparatorChildAfter(child); + } + return maxMaxChildHeight; + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SegmentedControlContainerBoxParentData) { + child.parentData = _SegmentedControlContainerBoxParentData(); + } + } + + double _getMaxChildWidth(BoxConstraints constraints) { + final int childCount = this.childCount ~/ 2 + 1; + double childWidth = (constraints.minWidth - totalSeparatorWidth) / childCount; + RenderBox? child = firstChild; + while (child != null) { + childWidth = math.max( + childWidth, + child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding, + ); + child = nonSeparatorChildAfter(child); + } + return math.min(childWidth, (constraints.maxWidth - totalSeparatorWidth) / childCount); + } + + double _getMaxChildHeight(BoxConstraints constraints, double childWidth) { + double maxHeight = _kMinSegmentedControlHeight; + RenderBox? child = firstChild; + while (child != null) { + final double boxHeight = child.getMaxIntrinsicHeight(childWidth); + maxHeight = math.max(maxHeight, boxHeight); + child = nonSeparatorChildAfter(child); + } + return maxHeight; + } + + List _getChildWidths(BoxConstraints constraints) { + if (!proportionalWidth) { + final double maxChildWidth = _getMaxChildWidth(constraints); + final int segmentCount = childCount ~/ 2 + 1; + return List.filled(segmentCount, maxChildWidth); + } + + final segmentWidths = []; + RenderBox? child = firstChild; + while (child != null) { + final double childWidth = + child.getMaxIntrinsicWidth(double.infinity) + 2 * _kSegmentMinPadding; + child = nonSeparatorChildAfter(child); + segmentWidths.add(childWidth); + } + + final double totalWidth = segmentWidths.sum; + + // If the sum of the children's width is larger than the allowed max width, + // each segment width should scale down until the overall size can fit in + // the parent constraints; similarly, if the sum of the children's width is + // smaller than the allowed min width, each segment width should scale up + // until the overall size can fit in the parent constraints. + final double allowedMaxWidth = constraints.maxWidth - totalSeparatorWidth; + final double allowedMinWidth = constraints.minWidth - totalSeparatorWidth; + + final double scale = clampDouble(totalWidth, allowedMinWidth, allowedMaxWidth) / totalWidth; + if (scale != 1) { + for (var i = 0; i < segmentWidths.length; i++) { + segmentWidths[i] = segmentWidths[i] * scale; + } + } + return segmentWidths; + } + + Size _computeOverallSize(BoxConstraints constraints) { + final double maxChildHeight = _getMaxChildHeight(constraints, constraints.maxWidth); + return constraints.constrain( + Size(_getChildWidths(constraints).sum + totalSeparatorWidth, maxChildHeight), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final List segmentWidths = _getChildWidths(constraints); + final double childHeight = _getMaxChildHeight(constraints, constraints.maxWidth); + + var index = 0; + BaselineOffset baselineOffset = BaselineOffset.noBaseline; + RenderBox? child = firstChild; + while (child != null) { + final childConstraints = BoxConstraints.tight(Size(segmentWidths[index], childHeight)); + baselineOffset = baselineOffset.minOf( + BaselineOffset(child.getDryBaseline(childConstraints, baseline)), + ); + + child = nonSeparatorChildAfter(child); + index++; + } + + return baselineOffset.offset; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeOverallSize(constraints); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final List segmentWidths = _getChildWidths(constraints); + + final double childHeight = _getMaxChildHeight(constraints, double.infinity); + final separatorConstraints = BoxConstraints(minHeight: childHeight, maxHeight: childHeight); + RenderBox? child = firstChild; + var index = 0; + double start = 0; + while (child != null) { + final childConstraints = BoxConstraints.tight(Size(segmentWidths[index ~/ 2], childHeight)); + child.layout(index.isEven ? childConstraints : separatorConstraints, parentUsesSize: true); + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + final childOffset = Offset(start, 0); + childParentData.offset = childOffset; + start += child.size.width; + assert( + index.isEven || child.size.width == _kSeparatorWidth + _kSeparatorInset.horizontal, + '${child.size.width} != ${_kSeparatorWidth + _kSeparatorInset.horizontal}', + ); + child = childAfter(child); + index += 1; + } + size = _computeOverallSize(constraints); + } + + // This method is used to convert the original unscaled thumb rect painted in + // the previous frame, to a Rect that is within the valid boundary defined by + // the child segments. + // + // The overall size does not include that of the thumb. That is, if the thumb + // is located at the first or the last segment, the thumb can get cut off if + // one of the values in _kThumbInsets is positive. + Rect? moveThumbRectInBound(Rect? thumbRect, List children) { + assert(hasSize); + assert(children.length >= 2); + if (thumbRect == null) { + return null; + } + + final Offset firstChildOffset = + (children.first.parentData! as _SegmentedControlContainerBoxParentData).offset; + final double leftMost = firstChildOffset.dx; + final double rightMost = + (children.last.parentData! as _SegmentedControlContainerBoxParentData).offset.dx + + children.last.size.width; + assert(rightMost > leftMost); + + // Ignore the horizontal position and the height of `thumbRect`, and + // calculates them from `children`. + return Rect.fromLTRB( + math.max(thumbRect.left, leftMost - _kThumbInsets.left), + firstChildOffset.dy - _kThumbInsets.top, + math.min(thumbRect.right, rightMost + _kThumbInsets.right), + firstChildOffset.dy + children.first.size.height + _kThumbInsets.bottom, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final List children = getChildrenAsList(); + + // Children contains both segment and separator and the order is segment -> + // separator -> segment. So to paint separators, index should start from 1 and + // the step should be 2. + for (var index = 1; index < childCount; index += 2) { + _paintSeparator(context, offset, children[index]); + } + + final int? highlightedChildIndex = highlightedIndex; + // Paint thumb if there's a highlighted segment. + if (highlightedChildIndex != null) { + final RenderBox selectedChild = children[highlightedChildIndex * 2]; + + final childParentData = selectedChild.parentData! as _SegmentedControlContainerBoxParentData; + final Rect newThumbRect = _kThumbInsets.inflateRect( + childParentData.offset & selectedChild.size, + ); + + // Update thumb animation's tween, in case the end rect changed (e.g., a + // new segment is added during the animation). + if (state.thumbController.isAnimating) { + final Animatable? thumbTween = state.thumbAnimatable; + if (thumbTween == null) { + // This is the first frame of the animation. + final Rect startingRect = + moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect; + state.thumbAnimatable = RectTween(begin: startingRect, end: newThumbRect); + } else if (newThumbRect != thumbTween.transform(1)) { + // The thumbTween of the running sliding animation needs updating, + // without restarting the animation. + final Rect startingRect = + moveThumbRectInBound(currentThumbRect, children) ?? newThumbRect; + state.thumbAnimatable = RectTween( + begin: startingRect, + end: newThumbRect, + ).chain(CurveTween(curve: Interval(state.thumbController.value, 1))); + } + } else { + state.thumbAnimatable = null; + } + + final Rect unscaledThumbRect = + state.thumbAnimatable?.evaluate(state.thumbController) ?? newThumbRect; + currentThumbRect = unscaledThumbRect; + + final _SegmentLocation childLocation; + if (highlightedChildIndex == 0) { + childLocation = _SegmentLocation.leftmost; + } else if (highlightedChildIndex == children.length ~/ 2) { + childLocation = _SegmentLocation.rightmost; + } else { + childLocation = _SegmentLocation.inbetween; + } + + final double delta = switch (childLocation) { + _SegmentLocation.leftmost => unscaledThumbRect.width - unscaledThumbRect.width * thumbScale, + _SegmentLocation.rightmost => + unscaledThumbRect.width * thumbScale - unscaledThumbRect.width, + _SegmentLocation.inbetween => 0, + }; + + final thumbRect = Rect.fromCenter( + center: unscaledThumbRect.center - Offset(delta / 2, 0), + width: unscaledThumbRect.width * thumbScale, + height: unscaledThumbRect.height * thumbScale, + ); + + _paintThumb(context, offset, thumbRect); + } else { + currentThumbRect = null; + } + + for (var index = 0; index < children.length; index += 2) { + // Children contains both segment and separator and the order is segment -> + // separator -> segment. So to paint separators, index should start from 0 and + // the step should be 2. + _paintChild(context, offset, children[index]); + } + } + + // Paint the separator to the right of the given child. + final Paint separatorPaint = Paint(); + void _paintSeparator(PaintingContext context, Offset offset, RenderBox child) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + context.paintChild(child, offset + childParentData.offset); + } + + void _paintChild(PaintingContext context, Offset offset, RenderBox child) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + context.paintChild(child, childParentData.offset + offset); + } + + void _paintThumb(PaintingContext context, Offset offset, Rect thumbRect) { + // Colors extracted from https://developer.apple.com/design/resources/. + const thumbShadow = [ + BoxShadow(color: Color(0x1F000000), offset: Offset(0, 3), blurRadius: 8), + BoxShadow(color: Color(0x0A000000), offset: Offset(0, 3), blurRadius: 1), + ]; + + final thumbShape = RSuperellipse.fromRectAndRadius(thumbRect.shift(offset), _kThumbRadius); + + for (final shadow in thumbShadow) { + context.canvas.drawRSuperellipse(thumbShape.shift(shadow.offset), shadow.toPaint()); + } + + context.canvas.drawRSuperellipse( + thumbShape.inflate(0.5), + Paint()..color = const Color(0x0A000000), + ); + + context.canvas.drawRSuperellipse(thumbShape, Paint()..color = thumbColor); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? child = lastChild; + while (child != null) { + final childParentData = child.parentData! as _SegmentedControlContainerBoxParentData; + if ((childParentData.offset & child.size).contains(position)) { + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset localOffset) { + assert(localOffset == position - childParentData.offset); + return child!.hitTest(result, position: localOffset); + }, + ); + } + child = childParentData.previousSibling; + } + return false; + } +} diff --git a/packages/cupertino_ui/lib/src/spell_check_suggestions_toolbar.dart b/packages/cupertino_ui/lib/src/spell_check_suggestions_toolbar.dart new file mode 100644 index 000000000000..86ac8ca619ab --- /dev/null +++ b/packages/cupertino_ui/lib/src/spell_check_suggestions_toolbar.dart @@ -0,0 +1,152 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'localizations.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_button.dart'; + +/// iOS only shows 3 spell check suggestions in the toolbar. +const int _kMaxSuggestions = 3; + +/// The default spell check suggestions toolbar for iOS. +/// +/// Tries to position itself below the [anchors], but if it doesn't fit, then it +/// readjusts to fit above bottom view insets. +/// +/// See also: +/// * [SpellCheckSuggestionsToolbar], which is similar but for both the +/// Material and Cupertino libraries. +class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [CupertinoSpellCheckSuggestionsToolbar]. + /// + /// [buttonItems] must not contain more than three items. + const CupertinoSpellCheckSuggestionsToolbar({ + super.key, + required this.anchors, + required this.buttonItems, + }) : assert(buttonItems.length <= _kMaxSuggestions); + + /// Constructs a [CupertinoSpellCheckSuggestionsToolbar] with the default + /// children for an [EditableText]. + /// + /// See also: + /// * [SpellCheckSuggestionsToolbar.editableText], which is similar but + /// builds an Android-style toolbar. + CupertinoSpellCheckSuggestionsToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : buttonItems = buildButtonItems(editableTextState) ?? [], + anchors = editableTextState.contextMenuAnchors; + + /// The location on which to anchor the menu. + final TextSelectionToolbarAnchors anchors; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets and displayed in the spell check suggestions toolbar. + /// + /// Must not contain more than three items. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + /// * [SpellCheckSuggestionsToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s used to build the Material style spell check + /// suggestions toolbar. + final List buttonItems; + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List? buildButtonItems(EditableTextState editableTextState) { + // Determine if composing region is misspelled. + final SuggestionSpan? spanAtCursorIndex = editableTextState.findSuggestionSpanAtCursorIndex( + editableTextState.currentTextEditingValue.selection.baseOffset, + ); + + if (spanAtCursorIndex == null) { + return null; + } + if (spanAtCursorIndex.suggestions.isEmpty) { + assert(debugCheckHasCupertinoLocalizations(editableTextState.context)); + final CupertinoLocalizations localizations = CupertinoLocalizations.of( + editableTextState.context, + ); + return [ + ContextMenuButtonItem(onPressed: null, label: localizations.noSpellCheckReplacementsLabel), + ]; + } + + final buttonItems = []; + + // Build suggestion buttons. + for (final String suggestion in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) { + buttonItems.add( + ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText(editableTextState, suggestion, spanAtCursorIndex.range); + }, + label: suggestion, + ), + ); + } + return buttonItems; + } + + static void _replaceText( + EditableTextState editableTextState, + String text, + TextRange replacementRange, + ) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText); + + final TextEditingValue newValue = editableTextState.textEditingValue + .replaced(replacementRange, text) + .copyWith(selection: TextSelection.collapsed(offset: replacementRange.start + text.length)); + editableTextState.userUpdateTextEditingValue(newValue, SelectionChangedCause.toolbar); + + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + if (editableTextState.mounted) { + editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent); + } + }, debugLabel: 'SpellCheckSuggestions.bringIntoView'); + editableTextState.hideToolbar(); + } + + /// Builds the toolbar buttons based on the [buttonItems]. + List _buildToolbarButtons(BuildContext context) { + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem(buttonItem: buttonItem); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (buttonItems.isEmpty) { + return const SizedBox.shrink(); + } + + final List children = _buildToolbarButtons(context); + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: children, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/switch.dart b/packages/cupertino_ui/lib/src/switch.dart new file mode 100644 index 000000000000..4f3d95f9a028 --- /dev/null +++ b/packages/cupertino_ui/lib/src/switch.dart @@ -0,0 +1,1402 @@ +// 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. + +// Examples can assume: +// bool _giveVerse = false; +// bool _lights = false; +// void setState(VoidCallback fn) { } + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'list_tile.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'theme.dart'; + +// Hand coded defaults eyeballed from an iOS simulator running iOS version 17.5. +const double _kDisabledOpacity = 0.5; +const double _kThumbRadius = 14.0; +const double _kTrackHeight = 31.0; +const double _kTrackWidth = 51.0; +const Size _kSwitchSize = Size(59.0, 39.0); +const double _kThumbExtensionFactor = 7.0; +const List _kSwitchBoxShadows = [ + BoxShadow(color: Color(0x26000000), offset: Offset(0, 3), blurRadius: 8.0), + BoxShadow(color: Color(0x0F000000), offset: Offset(0, 3), blurRadius: 1.0), +]; +// The drag distance (as a fraction of the track width) beyond which the switch +// must be dragged to commit the state change. +// This threshold is used when the user is dragging to a new state. +const double _kDragCommitThreshold = 0.7; + +// The drag distance (as a fraction of the track width) past which the user must +// drag back to reverse a state change that has already been visually committed +// during the drag. +// This threshold is used when the user is dragging back to the original state. +const double _kDragReverseThreshold = 0.2; + +// Label sizes and padding taken from xcode inspector. +// See https://github.com/flutter/flutter/issues/4830#issuecomment-528495360. +const double _kOnLabelWidth = 1.0; +const double _kOnLabelHeight = 10.0; +const double _kOnLabelPaddingHorizontal = 11.0; +const double _kOffLabelWidth = 1.0; +const double _kOffLabelPaddingHorizontal = 12.0; +const double _kOffLabelRadius = 5.0; +const CupertinoDynamicColor _kOffLabelColor = CupertinoDynamicColor.withBrightnessAndContrast( + debugLabel: 'offSwitchLabel', + // Source: https://github.com/flutter/flutter/pull/39993#discussion_r321946033 + color: Color.fromARGB(255, 179, 179, 179), + // Source: https://github.com/flutter/flutter/pull/39993#issuecomment-535196665 + darkColor: Color.fromARGB(255, 179, 179, 179), + // Source: https://github.com/flutter/flutter/pull/127776#discussion_r1244208264 + highContrastColor: Color.fromARGB(255, 255, 255, 255), + darkHighContrastColor: Color.fromARGB(255, 255, 255, 255), +); + +/// An iOS-style switch. +/// +/// Used to toggle the on/off state of a single setting. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=24tg_N4sdMQ} +/// +/// The switch itself does not maintain its toggle state. Instead, when the +/// toggle state of the switch changes, the widget calls the [onChanged] +/// callback. Most widgets that use a switch will listen for the [onChanged] +/// callback and rebuild the switch with a new [value] to update the visual +/// appearance of the switch. +/// +/// {@tool dartpad} +/// This example shows a toggleable [CupertinoSwitch]. When the thumb slides to +/// the other side of the track, the switch is toggled between on/off. +/// +/// ** See code in examples/api/lib/cupertino/switch/cupertino_switch.0.dart ** +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// This sample shows how to use a [CupertinoSwitch] in a [CupertinoListTile]. The +/// [MergeSemantics] is used to turn the entire [CupertinoListTile] into a single item +/// for accessibility tools. +/// +/// ```dart +/// MergeSemantics( +/// child: CupertinoListTile( +/// title: const Text('Lights'), +/// trailing: CupertinoSwitch( +/// value: _lights, +/// onChanged: (bool value) { setState(() { _lights = value; }); }, +/// ), +/// onTap: () { setState(() { _lights = !_lights; }); }, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Switch], the Material Design equivalent. +/// * +class CupertinoSwitch extends StatefulWidget { + /// Creates an iOS-style switch. + /// + /// The following arguments are required: + /// + /// * [value] determines whether this switch is on or off. + /// * [onChanged] is called when the user toggles the switch on or off. + /// + /// The [dragStartBehavior] parameter defaults to [DragStartBehavior.start]. + const CupertinoSwitch({ + super.key, + required this.value, + required this.onChanged, + @Deprecated( + 'Use activeTrackColor instead. ' + 'This feature was deprecated after v3.24.0-0.2.pre.', + ) + Color? activeColor, + @Deprecated( + 'Use inactiveTrackColor instead. ' + 'This feature was deprecated after v3.24.0-0.2.pre.', + ) + Color? trackColor, + Color? activeTrackColor, + Color? inactiveTrackColor, + this.thumbColor, + this.inactiveThumbColor, + this.applyTheme, + this.focusColor, + this.onLabelColor, + this.offLabelColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.trackOutlineColor, + this.trackOutlineWidth, + this.thumbIcon, + this.mouseCursor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.dragStartBehavior = DragStartBehavior.start, + }) : assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null), + assert(activeTrackColor == null || activeColor == null), + assert(inactiveTrackColor == null || trackColor == null), + activeTrackColor = activeTrackColor ?? activeColor, + inactiveTrackColor = inactiveTrackColor ?? trackColor; + + /// Whether this switch is on or off. + final bool value; + + /// Called when the user toggles the switch on or off. + /// + /// The switch passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the switch with the new + /// value. + /// + /// If null, the switch will be displayed as disabled, which has a reduced opacity. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CupertinoSwitch( + /// value: _giveVerse, + /// onChanged: (bool newValue) { + /// setState(() { + /// _giveVerse = newValue; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged? onChanged; + + /// The color to use for the track when the switch is on. + /// + /// If null and [applyTheme] is false, defaults to [CupertinoColors.systemGreen] + /// in accordance to native iOS behavior. Otherwise, defaults to + /// [CupertinoThemeData.primaryColor]. + /// + /// See also: + /// + /// * [inactiveTrackColor], the color to use for the track when the switch is off. + @Deprecated( + 'Use activeTrackColor instead. ' + 'This feature was deprecated after v3.24.0-0.2.pre.', + ) + Color? get activeColor => activeTrackColor; + + /// The color to use for the track when the switch is on. + /// + /// If null and [applyTheme] is false, defaults to [CupertinoColors.systemGreen] + /// in accordance to native iOS behavior. Otherwise, defaults to + /// [CupertinoThemeData.primaryColor]. + /// + /// See also: + /// + /// * [inactiveTrackColor], the color to use for the track when the switch is off. + final Color? activeTrackColor; + + /// The color to use for the track when the switch is off. + /// + /// Defaults to [CupertinoColors.secondarySystemFill] when null. + /// + /// See also: + /// + /// * [inactiveTrackColor], the color to use for the track when the switch is off. + @Deprecated( + 'Use inactiveTrackColor instead. ' + 'This feature was deprecated after v3.24.0-0.2.pre.', + ) + Color? get trackColor => inactiveTrackColor; + + /// The color to use for the track when the switch is off. + /// + /// Defaults to [CupertinoColors.secondarySystemFill] when null. + /// + /// See also: + /// + /// * [activeTrackColor], the color to use for the track when the switch is on. + final Color? inactiveTrackColor; + + /// The color to use for the thumb when the switch is on. + /// + /// If this color is not opaque, it is blended against + /// [CupertinoThemeData.scaffoldBackgroundColor], so as not to see through the + /// thumb to the track underneath. + /// + /// Defaults to [CupertinoColors.white] when null. + /// + /// See also: + /// + /// * [inactiveThumbColor], the color to use for the thumb when the switch is off. + final Color? thumbColor; + + /// The color to use on the thumb when the switch is off. + /// + /// If this color is not opaque, it is blended against + /// [CupertinoThemeData.scaffoldBackgroundColor], so as not to see through the + /// thumb to the track underneath. + /// + /// If null, defaults to [thumbColor]. If that is also null, + /// [CupertinoColors.white] is used. + /// + /// See also: + /// + /// * [thumbColor], the color to use for the thumb when the switch is on. + final Color? inactiveThumbColor; + + /// The color to use for the focus highlight for keyboard interactions. + /// + /// Defaults to [activeColor] with an opacity of 0.80, a brightness of 0.69 + /// and a saturation of 0.835. + final Color? focusColor; + + /// The color to use for the accessibility label when the switch is on. + /// + /// Defaults to [CupertinoColors.white] when null. + final Color? onLabelColor; + + /// The color to use for the accessibility label when the switch is off. + /// + /// Defaults to [Color.fromARGB(255, 179, 179, 179)] + /// (or [Color.fromARGB(255, 255, 255, 255)] in high contrast) when null. + final Color? offLabelColor; + + /// {@macro flutter.material.switch.activeThumbImage} + final ImageProvider? activeThumbImage; + + /// {@macro flutter.material.switch.onActiveThumbImageError} + final ImageErrorListener? onActiveThumbImageError; + + /// {@macro flutter.material.switch.inactiveThumbImage} + final ImageProvider? inactiveThumbImage; + + /// {@macro flutter.material.switch.onInactiveThumbImageError} + final ImageErrorListener? onInactiveThumbImageError; + + /// The outline color of this [CupertinoSwitch]'s track. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [trackOutlineColor] based on the current + /// [WidgetState] of the [CupertinoSwitch], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// CupertinoSwitch( + /// value: true, + /// onChanged: (bool value) { }, + /// trackOutlineColor: WidgetStateProperty.resolveWith((Set states) { + /// if (states.contains(WidgetState.disabled)) { + /// return CupertinoColors.activeOrange.withValues(alpha: .48); + /// } + /// return null; // Use the default color. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// + /// The [CupertinoSwitch] track has no outline by default. + final WidgetStateProperty? trackOutlineColor; + + /// The outline width of this [CupertinoSwitch]'s track. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [trackOutlineWidth] based on the current + /// [WidgetState] of the [CupertinoSwitch], providing a different outline width when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// CupertinoSwitch( + /// value: true, + /// onChanged: (bool value) { }, + /// trackOutlineWidth: WidgetStateProperty.resolveWith((Set states) { + /// if (states.contains(WidgetState.disabled)) { + /// return 5.0; + /// } + /// return null; // Use the default width. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// + /// Since a [CupertinoSwitch] has no track outline by default, this parameter + /// is set only if [trackOutlineColor] is provided. + /// + /// Defaults to 2.0 if a [trackOutlineColor] is provided. + final WidgetStateProperty? trackOutlineWidth; + + /// The icon to use on the thumb of this switch. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [thumbIcon] based on the current + /// [WidgetState] of the [CupertinoSwitch], providing a different [Icon] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// CupertinoSwitch( + /// value: true, + /// onChanged: (bool value) { }, + /// thumbIcon: WidgetStateProperty.resolveWith((Set states) { + /// if (states.contains(WidgetState.disabled)) { + /// return const Icon(Icons.close); + /// } + /// return null; // All other states will use the default thumbIcon. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// + /// If null, then the [CupertinoSwitch] does not have any icons on the thumb. + final WidgetStateProperty? thumbIcon; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [mouseCursor] based on the current + /// [WidgetState] of the [CupertinoSwitch], providing a different [mouseCursor] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// CupertinoSwitch( + /// value: true, + /// onChanged: (bool value) { }, + /// mouseCursor: WidgetStateProperty.resolveWith((Set states) { + /// if (states.contains(WidgetState.disabled)) { + /// return SystemMouseCursors.click; + /// } + /// return SystemMouseCursors.basic; // All other states will use the default mouseCursor. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// + /// If null, then [MouseCursor.defer] is used when the switch is disabled. + /// When the switch is enabled, [SystemMouseCursors.click] is used on Web, and + /// [MouseCursor.defer] is used on other platforms. + /// + /// See also: + /// + /// * [WidgetStateMouseCursor], a [MouseCursor] that implements + /// `WidgetStateProperty` which is used in APIs that need to accept + /// either a [MouseCursor] or a [WidgetStateProperty]. + final WidgetStateProperty? mouseCursor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@template flutter.cupertino.CupertinoSwitch.applyTheme} + /// Whether to apply the ambient [CupertinoThemeData]. + /// + /// If true, the track uses [CupertinoThemeData.primaryColor] for the track + /// when the switch is on. + /// + /// Defaults to [CupertinoThemeData.applyThemeToAll]. + /// {@endtemplate} + final bool? applyTheme; + + /// {@template flutter.cupertino.CupertinoSwitch.dragStartBehavior} + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag behavior used to move the + /// switch from on to off will begin at the position where the drag gesture won + /// the arena. If set to [DragStartBehavior.down] it will begin at the position + /// where a down event was first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for + /// the different behaviors. + /// + /// {@endtemplate} + final DragStartBehavior dragStartBehavior; + + @override + State createState() => _CupertinoSwitchState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off', showName: true), + ); + properties.add( + ObjectFlagProperty>('onChanged', onChanged, ifNull: 'disabled'), + ); + } +} + +class _CupertinoSwitchState extends State + with TickerProviderStateMixin, ToggleableStateMixin { + final _SwitchPainter _painter = _SwitchPainter(); + + // The global position where the user first touched the screen. This value to + // calculate the initial drag delta. + Offset _dragStartPosition = Offset.zero; + + // The cumulative horizontal drag delta, normalized as a fraction of the + // track width. + double _dragDelta = 0; + + // The transient value of the switch determined by _dragDelta during a + // drag. + bool? _dragValue; + + @override + void initState() { + super.initState(); + positionController.duration = const Duration(milliseconds: 200); + reactionController.duration = const Duration(milliseconds: 300); + position + ..curve = Curves.ease + ..reverseCurve = Curves.ease.flipped; + } + + @override + void didUpdateWidget(CupertinoSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + animateToValue(); + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + @override + ValueChanged? get onChanged => widget.onChanged != null ? _handleChanged : null; + + @override + bool get tristate => false; + + @override + bool? get value => widget.value; + + WidgetStateProperty get _widgetThumbColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.selected)) { + return widget.thumbColor; + } + return widget.inactiveThumbColor; + }); + } + + WidgetStateProperty get _widgetTrackColor { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.selected)) { + return widget.activeTrackColor; + } + return widget.inactiveTrackColor; + }); + } + + WidgetStateProperty get _defaultMouseCursor => + WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.disabled)) { + return MouseCursor.defer; + } + return kIsWeb ? SystemMouseCursors.click : MouseCursor.defer; + }); + + Color? _resolveTrackColor(Color? trackColor, Set states) { + if (trackColor is WidgetStateColor) { + return WidgetStateProperty.resolveAs(trackColor, states); + } + return trackColor; + } + + Color? _resolveThumbColor(Color? thumbColor, Set states) { + if (thumbColor is WidgetStateColor) { + return WidgetStateProperty.resolveAs(thumbColor, states); + } + return thumbColor; + } + + double get _trackInnerLength { + const double trackInnerStart = _kTrackHeight / 2.0; + const double trackInnerEnd = _kTrackWidth - trackInnerStart; + const double trackInnerLength = trackInnerEnd - trackInnerStart; + return trackInnerLength; + } + + void _handleOnTapDown(TapDownDetails details) { + if (isInteractive) { + _dragStartPosition = details.globalPosition; + } + } + + void _handleDragStart(DragStartDetails details) { + if (isInteractive) { + reactionController.forward(); + + if (_dragStartPosition != Offset.zero) { + final double delta = (details.globalPosition - _dragStartPosition).dx / _kTrackWidth; + _dragDelta = switch (Directionality.of(context)) { + TextDirection.rtl => -delta, + TextDirection.ltr => delta, + }; + } + + _dragValue = value; + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (isInteractive) { + final double delta = details.primaryDelta! / _kTrackWidth; + _dragDelta += switch (Directionality.of(context)) { + TextDirection.rtl => -delta, + TextDirection.ltr => delta, + }; + + final valueChangedWhileDragging = widget.value != _dragValue; + + final double threshold = valueChangedWhileDragging + ? _kDragReverseThreshold + : _kDragCommitThreshold; + final double effectiveThreshold = widget.value ? -threshold : threshold; + + final bool newDragValue = _dragDelta >= effectiveThreshold; + + if (_dragValue != newDragValue) { + _emitVibration(); + + if (newDragValue) { + positionController.forward(); + } else { + positionController.reverse(); + } + + _dragValue = newDragValue; + } + } + } + + bool _needsPositionAnimation = false; + + void _handleDragEnd(DragEndDetails details) { + if (_dragValue != widget.value) { + widget.onChanged?.call(!widget.value); + // Wait to finish the animation until widget.value has changed to + // !widget.value as part of the widget.onChanged call above. + setState(() { + _needsPositionAnimation = true; + }); + } + + _dragStartPosition = Offset.zero; + _dragDelta = 0; + _dragValue = null; + + reactionController.reverse(); + } + + void _handleChanged(bool? value) { + assert(value != null); + assert(widget.onChanged != null); + widget.onChanged?.call(value!); + _emitVibration(); + } + + void _emitVibration() { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + HapticFeedback.lightImpact(); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + + @override + Widget build(BuildContext context) { + if (_needsPositionAnimation) { + _needsPositionAnimation = false; + animateToValue(); + } + + final CupertinoThemeData theme = CupertinoTheme.of(context); + + final Color activeColor = CupertinoDynamicColor.resolve( + widget.activeTrackColor ?? + ((widget.applyTheme ?? theme.applyThemeToAll) ? theme.primaryColor : null) ?? + CupertinoColors.systemGreen, + context, + ); + + final (Color onLabelColor, Color offLabelColor)? onOffLabelColors = + MediaQuery.onOffSwitchLabelsOf(context) + ? ( + CupertinoDynamicColor.resolve(widget.onLabelColor ?? CupertinoColors.white, context), + CupertinoDynamicColor.resolve(widget.offLabelColor ?? _kOffLabelColor, context), + ) + : null; + + // Colors need to be resolved in selected and non selected states separately + // so that they can be lerped between. + final Set activeStates = states..add(WidgetState.selected); + final Set inactiveStates = states..remove(WidgetState.selected); + + final Color effectiveActiveThumbColor = + _resolveThumbColor(widget.thumbColor, activeStates) ?? + _widgetThumbColor.resolve(activeStates) ?? + CupertinoColors.white; + + final Color effectiveInactiveThumbColor = + _resolveThumbColor(widget.inactiveThumbColor, inactiveStates) ?? + _widgetThumbColor.resolve(inactiveStates) ?? + effectiveActiveThumbColor; + + final Color effectiveActiveTrackColor = _widgetTrackColor.resolve(activeStates) ?? activeColor; + + final Color? effectiveActiveTrackOutlineColor = widget.trackOutlineColor?.resolve(activeStates); + + final double? effectiveActiveTrackOutlineWidth = widget.trackOutlineWidth?.resolve( + activeStates, + ); + + final Color effectiveInactiveTrackColor = + _resolveTrackColor(widget.trackColor, inactiveStates) ?? + CupertinoDynamicColor.resolve(CupertinoColors.secondarySystemFill, context); + + final Color? effectiveInactiveTrackOutlineColor = widget.trackOutlineColor?.resolve( + inactiveStates, + ); + + final double? effectiveInactiveTrackOutlineWidth = widget.trackOutlineWidth?.resolve( + inactiveStates, + ); + + final Icon? effectiveActiveIcon = widget.thumbIcon?.resolve(activeStates); + + final Icon? effectiveInactiveIcon = widget.thumbIcon?.resolve(inactiveStates); + + final Color effectiveActiveIconColor = effectiveActiveIcon?.color ?? CupertinoColors.black; + + final Color effectiveInactiveIconColor = effectiveInactiveIcon?.color ?? CupertinoColors.black; + + final activePressedStates = activeStates..add(WidgetState.pressed); + final Color effectiveActivePressedThumbColor = + _resolveThumbColor(widget.thumbColor, activePressedStates) ?? + _widgetThumbColor.resolve(activePressedStates) ?? + CupertinoColors.white; + + final inactivePressedStates = inactiveStates..add(WidgetState.pressed); + final Color effectiveInactivePressedThumbColor = + _resolveThumbColor(widget.thumbColor, inactivePressedStates) ?? + _widgetThumbColor.resolve(inactivePressedStates) ?? + CupertinoColors.white; + + final WidgetStateProperty effectiveMouseCursor = + widget.mouseCursor ?? _defaultMouseCursor; + + return Semantics( + toggled: widget.value, + child: GestureDetector( + excludeFromSemantics: true, + onTapDown: _handleOnTapDown, + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + dragStartBehavior: widget.dragStartBehavior, + child: Opacity( + opacity: onChanged == null ? _kDisabledOpacity : 1, + child: buildToggleable( + mouseCursor: effectiveMouseCursor, + focusNode: widget.focusNode, + onFocusChange: widget.onFocusChange, + autofocus: widget.autofocus, + size: _kSwitchSize, + painter: _painter + ..position = position + ..reaction = reaction + ..reactionFocusFade = reactionFocusFade + ..reactionHoverFade = reactionHoverFade + ..focusColor = CupertinoDynamicColor.resolve( + widget.focusColor ?? + HSLColor.fromColor(activeColor.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(), + context, + ) + ..downPosition = downPosition + ..isFocused = states.contains(WidgetState.focused) + ..isHovered = states.contains(WidgetState.hovered) + ..activeColor = effectiveActiveThumbColor + ..inactiveColor = effectiveInactiveThumbColor + ..activePressedColor = effectiveActivePressedThumbColor + ..onOffLabelColors = onOffLabelColors + ..inactivePressedColor = effectiveInactivePressedThumbColor + ..activeThumbImage = widget.activeThumbImage + ..onActiveThumbImageError = widget.onActiveThumbImageError + ..inactiveThumbImage = widget.inactiveThumbImage + ..onInactiveThumbImageError = widget.onInactiveThumbImageError + ..activeTrackColor = effectiveActiveTrackColor + ..activeTrackOutlineColor = effectiveActiveTrackOutlineColor + ..activeTrackOutlineWidth = effectiveActiveTrackOutlineWidth + ..inactiveTrackColor = effectiveInactiveTrackColor + ..inactiveTrackOutlineColor = effectiveInactiveTrackOutlineColor + ..inactiveTrackOutlineWidth = effectiveInactiveTrackOutlineWidth + ..configuration = createLocalImageConfiguration(context) + ..isInteractive = isInteractive + ..trackInnerLength = _trackInnerLength + ..textDirection = Directionality.of(context) + ..activeIconColor = effectiveActiveIconColor + ..inactiveIconColor = effectiveInactiveIconColor + ..activeIcon = effectiveActiveIcon + ..inactiveIcon = effectiveInactiveIcon + ..iconTheme = IconTheme.of(context) + ..surfaceColor = theme.scaffoldBackgroundColor + ..positionController = positionController, + ), + ), + ), + ); + } +} + +class _SwitchPainter extends ToggleablePainter { + AnimationController get positionController => _positionController!; + AnimationController? _positionController; + set positionController(AnimationController value) { + if (value == _positionController) { + return; + } + _positionController = value; + _colorAnimation?.dispose(); + _colorAnimation = CurvedAnimation( + parent: positionController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); + notifyListeners(); + } + + CurvedAnimation? _colorAnimation; + + Icon? get activeIcon => _activeIcon; + Icon? _activeIcon; + set activeIcon(Icon? value) { + if (value == _activeIcon) { + return; + } + _activeIcon = value; + notifyListeners(); + } + + Icon? get inactiveIcon => _inactiveIcon; + Icon? _inactiveIcon; + set inactiveIcon(Icon? value) { + if (value == _inactiveIcon) { + return; + } + _inactiveIcon = value; + notifyListeners(); + } + + IconThemeData? get iconTheme => _iconTheme; + IconThemeData? _iconTheme; + set iconTheme(IconThemeData? value) { + if (value == _iconTheme) { + return; + } + _iconTheme = value; + notifyListeners(); + } + + Color get activeIconColor => _activeIconColor!; + Color? _activeIconColor; + set activeIconColor(Color value) { + if (value == _activeIconColor) { + return; + } + _activeIconColor = value; + notifyListeners(); + } + + Color get inactiveIconColor => _inactiveIconColor!; + Color? _inactiveIconColor; + set inactiveIconColor(Color value) { + if (value == _inactiveIconColor) { + return; + } + _inactiveIconColor = value; + notifyListeners(); + } + + Color get activePressedColor => _activePressedColor!; + Color? _activePressedColor; + set activePressedColor(Color value) { + if (value == _activePressedColor) { + return; + } + _activePressedColor = value; + notifyListeners(); + } + + Color get inactivePressedColor => _inactivePressedColor!; + Color? _inactivePressedColor; + set inactivePressedColor(Color value) { + if (value == _inactivePressedColor) { + return; + } + _inactivePressedColor = value; + notifyListeners(); + } + + ImageProvider? get activeThumbImage => _activeThumbImage; + ImageProvider? _activeThumbImage; + set activeThumbImage(ImageProvider? value) { + if (value == _activeThumbImage) { + return; + } + _activeThumbImage = value; + notifyListeners(); + } + + ImageErrorListener? get onActiveThumbImageError => _onActiveThumbImageError; + ImageErrorListener? _onActiveThumbImageError; + set onActiveThumbImageError(ImageErrorListener? value) { + if (value == _onActiveThumbImageError) { + return; + } + _onActiveThumbImageError = value; + notifyListeners(); + } + + ImageProvider? get inactiveThumbImage => _inactiveThumbImage; + ImageProvider? _inactiveThumbImage; + set inactiveThumbImage(ImageProvider? value) { + if (value == _inactiveThumbImage) { + return; + } + _inactiveThumbImage = value; + notifyListeners(); + } + + ImageErrorListener? get onInactiveThumbImageError => _onInactiveThumbImageError; + ImageErrorListener? _onInactiveThumbImageError; + set onInactiveThumbImageError(ImageErrorListener? value) { + if (value == _onInactiveThumbImageError) { + return; + } + _onInactiveThumbImageError = value; + notifyListeners(); + } + + Color get activeTrackColor => _activeTrackColor!; + Color? _activeTrackColor; + set activeTrackColor(Color value) { + if (value == _activeTrackColor) { + return; + } + _activeTrackColor = value; + notifyListeners(); + } + + Color? get activeTrackOutlineColor => _activeTrackOutlineColor; + Color? _activeTrackOutlineColor; + set activeTrackOutlineColor(Color? value) { + if (value == _activeTrackOutlineColor) { + return; + } + _activeTrackOutlineColor = value; + notifyListeners(); + } + + Color? get inactiveTrackOutlineColor => _inactiveTrackOutlineColor; + Color? _inactiveTrackOutlineColor; + set inactiveTrackOutlineColor(Color? value) { + if (value == _inactiveTrackOutlineColor) { + return; + } + _inactiveTrackOutlineColor = value; + notifyListeners(); + } + + double? get activeTrackOutlineWidth => _activeTrackOutlineWidth; + double? _activeTrackOutlineWidth; + set activeTrackOutlineWidth(double? value) { + if (value == _activeTrackOutlineWidth) { + return; + } + _activeTrackOutlineWidth = value; + notifyListeners(); + } + + double? get inactiveTrackOutlineWidth => _inactiveTrackOutlineWidth; + double? _inactiveTrackOutlineWidth; + set inactiveTrackOutlineWidth(double? value) { + if (value == _inactiveTrackOutlineWidth) { + return; + } + _inactiveTrackOutlineWidth = value; + notifyListeners(); + } + + Color get inactiveTrackColor => _inactiveTrackColor!; + Color? _inactiveTrackColor; + set inactiveTrackColor(Color value) { + if (value == _inactiveTrackColor) { + return; + } + _inactiveTrackColor = value; + notifyListeners(); + } + + ImageConfiguration get configuration => _configuration!; + ImageConfiguration? _configuration; + set configuration(ImageConfiguration value) { + if (value == _configuration) { + return; + } + _configuration = value; + notifyListeners(); + } + + TextDirection get textDirection => _textDirection!; + TextDirection? _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + notifyListeners(); + } + + Color get surfaceColor => _surfaceColor!; + Color? _surfaceColor; + set surfaceColor(Color value) { + if (value == _surfaceColor) { + return; + } + _surfaceColor = value; + notifyListeners(); + } + + bool get isInteractive => _isInteractive!; + bool? _isInteractive; + set isInteractive(bool value) { + if (value == _isInteractive) { + return; + } + _isInteractive = value; + notifyListeners(); + } + + double get trackInnerLength => _trackInnerLength!; + double? _trackInnerLength; + set trackInnerLength(double value) { + if (value == _trackInnerLength) { + return; + } + _trackInnerLength = value; + notifyListeners(); + } + + (Color onLabelColor, Color offLabelColor)? get onOffLabelColors => _onOffLabelColors; + (Color onLabelColor, Color offLabelColor)? _onOffLabelColors; + set onOffLabelColors((Color onLabelColor, Color offLabelColor)? value) { + if (value == _onOffLabelColors) { + return; + } + _onOffLabelColors = value; + notifyListeners(); + } + + final TextPainter _textPainter = TextPainter(); + Color? _cachedThumbColor; + ImageProvider? _cachedThumbImage; + ImageErrorListener? _cachedThumbErrorListener; + BoxPainter? _cachedThumbPainter; + + ShapeDecoration _createDefaultThumbDecoration( + Color color, + ImageProvider? image, + ImageErrorListener? errorListener, + ) { + return ShapeDecoration( + color: color, + image: image == null ? null : DecorationImage(image: image, onError: errorListener), + shape: const StadiumBorder(), + ); + } + + bool _isPainting = false; + + void _handleDecorationChanged() { + // If the image decoration is available synchronously, we'll get called here + // during paint. There's no reason to mark ourselves as needing paint if we + // are already in the middle of painting. (In fact, doing so would trigger + // an assert). + if (!_isPainting) { + notifyListeners(); + } + } + + bool _stopPressAnimation = false; + late double? _pressedThumbExtension; + + @override + void paint(Canvas canvas, Size size) { + final double currentValue = position.value; + + final double visualPosition = switch (textDirection) { + TextDirection.rtl => 1.0 - currentValue, + TextDirection.ltr => currentValue, + }; + if (reaction.status == AnimationStatus.reverse && !_stopPressAnimation) { + _stopPressAnimation = true; + } else { + _stopPressAnimation = false; + } + + _pressedThumbExtension = reaction.value * _kThumbExtensionFactor; + final thumbSize = Size(_kThumbRadius * 2 + _pressedThumbExtension!, _kThumbRadius * 2); + + final double colorValue = _colorAnimation!.value; + final Color trackColor = Color.lerp(inactiveTrackColor, activeTrackColor, position.value)!; + final Color? trackOutlineColor = + inactiveTrackOutlineColor == null || activeTrackOutlineColor == null + ? null + : Color.lerp(inactiveTrackOutlineColor, activeTrackOutlineColor, colorValue); + final double? trackOutlineWidth = lerpDouble( + inactiveTrackOutlineWidth, + activeTrackOutlineWidth, + colorValue, + ); + + final Color lerpedThumbColor; + if (!reaction.isDismissed) { + lerpedThumbColor = Color.lerp(inactivePressedColor, activePressedColor, colorValue)!; + } else if (positionController.status == AnimationStatus.forward) { + lerpedThumbColor = Color.lerp(inactivePressedColor, activeColor, colorValue)!; + } else if (positionController.status == AnimationStatus.reverse) { + lerpedThumbColor = Color.lerp(inactiveColor, activePressedColor, colorValue)!; + } else { + lerpedThumbColor = Color.lerp(inactiveColor, activeColor, colorValue)!; + } + + // Blend the thumb color against a `surfaceColor` background in case the + // thumbColor is not opaque. This way we do not see through the thumb to the + // track underneath. + final Color thumbColor = Color.alphaBlend(lerpedThumbColor, surfaceColor); + + final Icon? thumbIcon = currentValue < 0.5 ? inactiveIcon : activeIcon; + + final ImageProvider? thumbImage = currentValue < 0.5 ? inactiveThumbImage : activeThumbImage; + + final ImageErrorListener? thumbErrorListener = currentValue < 0.5 + ? onInactiveThumbImageError + : onActiveThumbImageError; + + final paint = Paint()..color = trackColor; + + final Offset trackPaintOffset = _computeTrackPaintOffset(size); + final Offset thumbPaintOffset = _computeThumbPaintOffset( + trackPaintOffset, + thumbSize, + visualPosition, + ); + + final trackRect = Rect.fromLTWH( + trackPaintOffset.dx, + trackPaintOffset.dy, + _kTrackWidth, + _kTrackHeight, + ); + + _paintTrackWith( + canvas, + paint, + trackPaintOffset, + trackOutlineColor, + trackOutlineWidth, + trackRect, + ); + + final double currentReactionValue = reaction.value; + if (_onOffLabelColors != null) { + final (Color onLabelColor, Color offLabelColor) = onOffLabelColors!; + + final double leftLabelOpacity = visualPosition * (1.0 - currentReactionValue); + final double rightLabelOpacity = (1.0 - visualPosition) * (1.0 - currentReactionValue); + final (double onLabelOpacity, double offLabelOpacity) = switch (textDirection) { + TextDirection.ltr => (leftLabelOpacity, rightLabelOpacity), + TextDirection.rtl => (rightLabelOpacity, leftLabelOpacity), + }; + + final (Offset onLabelOffset, Offset offLabelOffset) = switch (textDirection) { + TextDirection.ltr => ( + trackRect.centerLeft.translate(_kOnLabelPaddingHorizontal, 0), + trackRect.centerRight.translate(-_kOffLabelPaddingHorizontal, 0), + ), + TextDirection.rtl => ( + trackRect.centerRight.translate(-_kOnLabelPaddingHorizontal, 0), + trackRect.centerLeft.translate(_kOffLabelPaddingHorizontal, 0), + ), + }; + + // Draws '|' label. + final onLabelRect = Rect.fromCenter( + center: onLabelOffset, + width: _kOnLabelWidth, + height: _kOnLabelHeight, + ); + final onLabelPaint = Paint() + ..color = onLabelColor.withOpacity(onLabelOpacity) + ..style = PaintingStyle.fill; + canvas.drawRect(onLabelRect, onLabelPaint); + + // Draws 'O' label. + final offLabelPaint = Paint() + ..color = offLabelColor.withOpacity(offLabelOpacity) + ..style = PaintingStyle.stroke + ..strokeWidth = _kOffLabelWidth; + canvas.drawCircle(offLabelOffset, _kOffLabelRadius, offLabelPaint); + } + _paintThumbWith( + thumbPaintOffset, + canvas, + colorValue, + thumbColor, + thumbImage, + thumbErrorListener, + thumbIcon, + thumbSize, + ); + } + + /// Computes canvas offset for track's upper left corner. + static Offset _computeTrackPaintOffset(Size canvasSize) { + final double horizontalOffset = (canvasSize.width - _kTrackWidth) / 2.0; + final double verticalOffset = (canvasSize.height - _kTrackHeight) / 2.0; + + return Offset(horizontalOffset, verticalOffset); + } + + /// Computes canvas offset for thumb's upper left corner as if it were a + /// square. + Offset _computeThumbPaintOffset(Offset trackPaintOffset, Size thumbSize, double visualPosition) { + // How much the thumb radius extends beyond the track. + const double trackRadius = _kTrackHeight / 2; + final double additionalThumbRadius = thumbSize.height / 2 - trackRadius; + + final double horizontalProgress = visualPosition * (trackInnerLength - _pressedThumbExtension!); + final double thumbHorizontalOffset = + trackPaintOffset.dx + + trackRadius + + (_pressedThumbExtension! / 2) - + thumbSize.width / 2 + + horizontalProgress; + final double thumbVerticalOffset = trackPaintOffset.dy - additionalThumbRadius; + return Offset(thumbHorizontalOffset, thumbVerticalOffset); + } + + void _paintTrackWith( + Canvas canvas, + Paint paint, + Offset trackPaintOffset, + Color? trackOutlineColor, + double? trackOutlineWidth, + Rect trackRect, + ) { + const double trackRadius = _kTrackHeight / 2; + final trackRRect = RRect.fromRectAndRadius(trackRect, const Radius.circular(trackRadius)); + + canvas.drawRRect(trackRRect, paint); + + // Paint the track outline. + if (trackOutlineColor != null) { + final outlineTrackRect = Rect.fromLTWH( + trackPaintOffset.dx + 1, + trackPaintOffset.dy + 1, + _kTrackWidth - 2, + _kTrackHeight - 2, + ); + final outlineTrackRRect = RRect.fromRectAndRadius( + outlineTrackRect, + const Radius.circular(trackRadius), + ); + + final outlinePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = trackOutlineWidth ?? 2.0 + ..color = trackOutlineColor; + + canvas.drawRRect(outlineTrackRRect, outlinePaint); + } + + if (isFocused) { + final RRect focusedOutline = trackRRect.inflate(1.75); + final focusedPaint = Paint() + ..style = PaintingStyle.stroke + ..color = focusColor + ..strokeWidth = 3.5; + canvas.drawRRect(focusedOutline, focusedPaint); + } + canvas.clipRRect(trackRRect); + } + + void _paintThumbWith( + Offset thumbPaintOffset, + Canvas canvas, + double currentValue, + Color thumbColor, + ImageProvider? thumbImage, + ImageErrorListener? thumbErrorListener, + Icon? thumbIcon, + Size thumbSize, + ) { + try { + _isPainting = true; + if (_cachedThumbPainter == null || + thumbColor != _cachedThumbColor || + thumbImage != _cachedThumbImage || + thumbErrorListener != _cachedThumbErrorListener) { + _cachedThumbColor = thumbColor; + _cachedThumbImage = thumbImage; + _cachedThumbErrorListener = thumbErrorListener; + _cachedThumbPainter?.dispose(); + _cachedThumbPainter = _createDefaultThumbDecoration( + thumbColor, + thumbImage, + thumbErrorListener, + ).createBoxPainter(_handleDecorationChanged); + } + final BoxPainter thumbPainter = _cachedThumbPainter!; + + _paintCupertinoThumbShadowAndBorder(canvas, thumbPaintOffset, thumbSize); + + thumbPainter.paint(canvas, thumbPaintOffset, configuration.copyWith(size: thumbSize)); + + if (thumbIcon != null && thumbIcon.icon != null) { + final Color iconColor = Color.lerp(inactiveIconColor, activeIconColor, currentValue)!; + final double iconSize = thumbIcon.size ?? 16.0; + final IconData iconData = thumbIcon.icon!; + final double? iconWeight = thumbIcon.weight ?? iconTheme?.weight; + final double? iconFill = thumbIcon.fill ?? iconTheme?.fill; + final double? iconGrade = thumbIcon.grade ?? iconTheme?.grade; + final double? iconOpticalSize = thumbIcon.opticalSize ?? iconTheme?.opticalSize; + final List? iconShadows = thumbIcon.shadows ?? iconTheme?.shadows; + + final textSpan = TextSpan( + text: String.fromCharCode(iconData.codePoint), + style: TextStyle( + fontVariations: [ + if (iconFill != null) FontVariation('FILL', iconFill), + if (iconWeight != null) FontVariation('wght', iconWeight), + if (iconGrade != null) FontVariation('GRAD', iconGrade), + if (iconOpticalSize != null) FontVariation('opsz', iconOpticalSize), + ], + color: iconColor, + fontSize: iconSize, + inherit: false, + fontFamily: iconData.fontFamily, + package: iconData.fontPackage, + shadows: iconShadows, + ), + ); + _textPainter + ..textDirection = textDirection + ..text = textSpan; + _textPainter.layout(); + final double additionalHorizontalOffset = (thumbSize.width - iconSize) / 2; + final double additionalVerticalOffset = (thumbSize.height - iconSize) / 2; + final Offset offset = + thumbPaintOffset + Offset(additionalHorizontalOffset, additionalVerticalOffset); + + _textPainter.paint(canvas, offset); + } + } finally { + _isPainting = false; + } + } + + void _paintCupertinoThumbShadowAndBorder(Canvas canvas, Offset thumbPaintOffset, Size thumbSize) { + final thumbBounds = RRect.fromLTRBR( + thumbPaintOffset.dx, + thumbPaintOffset.dy, + thumbPaintOffset.dx + thumbSize.width, + thumbPaintOffset.dy + thumbSize.height, + Radius.circular(thumbSize.height / 2.0), + ); + for (final BoxShadow shadow in _kSwitchBoxShadows) { + canvas.drawRRect(thumbBounds.shift(shadow.offset), shadow.toPaint()); + } + canvas.drawRRect(thumbBounds.inflate(0.5), Paint()..color = const Color(0x0A000000)); + } + + @override + void dispose() { + _textPainter.dispose(); + _cachedThumbPainter?.dispose(); + _cachedThumbPainter = null; + _cachedThumbColor = null; + _cachedThumbImage = null; + _cachedThumbErrorListener = null; + _colorAnimation?.dispose(); + super.dispose(); + } +} diff --git a/packages/cupertino_ui/lib/src/tab_scaffold.dart b/packages/cupertino_ui/lib/src/tab_scaffold.dart new file mode 100644 index 000000000000..a5810ab979ac --- /dev/null +++ b/packages/cupertino_ui/lib/src/tab_scaffold.dart @@ -0,0 +1,544 @@ +// 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. + +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'page_scaffold.dart'; +/// @docImport 'route.dart'; +/// @docImport 'tab_view.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'bottom_tab_bar.dart'; +import 'colors.dart'; +import 'theme.dart'; + +/// Coordinates tab selection between a [CupertinoTabBar] and a [CupertinoTabScaffold]. +/// +/// The [index] property is the index of the selected tab. Changing its value +/// updates the actively displayed tab of the [CupertinoTabScaffold] the +/// [CupertinoTabController] controls, as well as the currently selected tab item of +/// its [CupertinoTabBar]. +/// +/// {@tool dartpad} +/// This samples shows how [CupertinoTabController] can be used to switch tabs in +/// [CupertinoTabScaffold]. +/// +/// ** See code in examples/api/lib/cupertino/tab_scaffold/cupertino_tab_controller.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoTabScaffold], a tabbed application root layout that can be +/// controlled by a [CupertinoTabController]. +/// * [RestorableCupertinoTabController], which is a restorable version +/// of this controller. +class CupertinoTabController extends ChangeNotifier { + /// Creates a [CupertinoTabController] to control the tab index of [CupertinoTabScaffold] + /// and [CupertinoTabBar]. + /// + /// The [initialIndex] defaults to 0. The value must be greater than or equal + /// to 0, and less than the total number of tabs. + CupertinoTabController({int initialIndex = 0}) : _index = initialIndex, assert(initialIndex >= 0); + + bool _isDisposed = false; + + /// The index of the currently selected tab. + /// + /// Changing the value of [index] updates the actively displayed tab of the + /// [CupertinoTabScaffold] controlled by this [CupertinoTabController], as well + /// as the currently selected tab item of its [CupertinoTabScaffold.tabBar]. + /// + /// The value must be greater than or equal to 0, and less than the total + /// number of tabs. + int get index => _index; + int _index; + set index(int value) { + assert(value >= 0); + if (_index == value) { + return; + } + _index = value; + notifyListeners(); + } + + @mustCallSuper + @override + void dispose() { + super.dispose(); + _isDisposed = true; + } +} + +/// Implements a tabbed iOS application's root layout and behavior structure. +/// +/// The scaffold lays out the tab bar at the bottom and the content between or +/// behind the tab bar. +/// +/// A [tabBar] and a [tabBuilder] are required. The [CupertinoTabScaffold] +/// will automatically listen to the provided [CupertinoTabBar]'s tap callbacks +/// to change the active tab. +/// +/// A [controller] can be used to provide an initially selected tab index and manage +/// subsequent tab changes. If a controller is not specified, the scaffold will +/// create its own [CupertinoTabController] and manage it internally. Otherwise +/// it's up to the owner of [controller] to call `dispose` on it after finish +/// using it. +/// +/// Tabs' contents are built with the provided [tabBuilder] at the active +/// tab index. The [tabBuilder] must be able to build the same number of +/// pages as there are [tabBar] items. Inactive tabs will be moved [Offstage] +/// and their animations disabled. +/// +/// Adding/removing tabs, or changing the order of tabs is supported but not +/// recommended. Doing so is against the iOS human interface guidelines, and +/// [CupertinoTabScaffold] may lose some tabs' state in the process. +/// +/// Use [CupertinoTabView] as the root widget of each tab to support tabs with +/// parallel navigation state and history. Since each [CupertinoTabView] contains +/// a [Navigator], rebuilding the [CupertinoTabView] with a different +/// [WidgetBuilder] instance in [CupertinoTabView.builder] will not recreate +/// the [CupertinoTabView]'s navigation stack or update its UI. To update the +/// contents of the [CupertinoTabView] after it's built, trigger a rebuild +/// (via [State.setState], for instance) from its descendant rather than from +/// its ancestor. +/// +/// {@tool dartpad} +/// A sample code implementing a typical iOS information architecture with tabs. +/// +/// ** See code in examples/api/lib/cupertino/tab_scaffold/cupertino_tab_scaffold.0.dart ** +/// {@end-tool} +/// +/// To push a route above all tabs instead of inside the currently selected one +/// (such as when showing a dialog on top of this scaffold), use +/// `Navigator.of(rootNavigator: true)` from inside the [BuildContext] of a +/// [CupertinoTabView]. +/// +/// See also: +/// +/// * [CupertinoTabBar], the bottom tab bar inserted in the scaffold. +/// * [CupertinoTabController], the selection state of this widget. +/// * [CupertinoTabView], the typical root content of each tab that holds its own +/// [Navigator] stack. +/// * [CupertinoPageRoute], a route hosting modal pages with iOS style transitions. +/// * [CupertinoPageScaffold], typical contents of an iOS modal page implementing +/// layout with a navigation bar on top. +/// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/bars/tab-bars/). +class CupertinoTabScaffold extends StatefulWidget { + /// Creates a layout for applications with a tab bar at the bottom. + CupertinoTabScaffold({ + super.key, + required this.tabBar, + required this.tabBuilder, + this.controller, + this.backgroundColor, + this.resizeToAvoidBottomInset = true, + this.restorationId, + }) : assert( + controller == null || controller.index < tabBar.items.length, + "The CupertinoTabController's current index ${controller.index} is " + 'out of bounds for the tab bar with ${tabBar.items.length} tabs', + ); + + /// The [tabBar] is a [CupertinoTabBar] drawn at the bottom of the screen + /// that lets the user switch between different tabs in the main content area + /// when present. + /// + /// The [CupertinoTabBar.currentIndex] is only used to initialize a + /// [CupertinoTabController] when no [controller] is provided. Subsequently + /// providing a different [CupertinoTabBar.currentIndex] does not affect the + /// scaffold or the tab bar's active tab index. To programmatically change + /// the active tab index, use a [CupertinoTabController]. + /// + /// If [CupertinoTabBar.onTap] is provided, it will still be called. + /// [CupertinoTabScaffold] automatically also listen to the + /// [CupertinoTabBar]'s `onTap` to change the [controller]'s `index` + /// and change the actively displayed tab in [CupertinoTabScaffold]'s own + /// main content area. + /// + /// If translucent, the main content may slide behind it. + /// Otherwise, the main content's bottom margin will be offset by its height. + /// + /// By default [tabBar] disables text scaling to match the native iOS behavior. + /// To override this behavior, wrap each of the [tabBar]'s items inside a + /// [MediaQuery] with the desired [TextScaler]. + final CupertinoTabBar tabBar; + + /// Controls the currently selected tab index of the [tabBar], as well as the + /// active tab index of the [tabBuilder]. Providing a different [controller] + /// will also update the scaffold's current active index to the new controller's + /// index value. + /// + /// Defaults to null. + final CupertinoTabController? controller; + + /// An [IndexedWidgetBuilder] that's called when tabs become active. + /// + /// The widgets built by [IndexedWidgetBuilder] are typically a + /// [CupertinoTabView] in order to achieve the parallel hierarchical + /// information architecture seen on iOS apps with tab bars. + /// + /// When the tab becomes inactive, its content is cached in the widget tree + /// [Offstage] and its animations disabled. + /// + /// Content can slide under the [tabBar] when they're translucent. + /// In that case, the child's [BuildContext]'s [MediaQuery] will have a + /// bottom padding indicating the area of obstructing overlap from the + /// [tabBar]. + final IndexedWidgetBuilder tabBuilder; + + /// The color of the widget that underlies the entire scaffold. + /// + /// By default uses [CupertinoTheme]'s `scaffoldBackgroundColor` when null. + final Color? backgroundColor; + + /// Whether the body should size itself to avoid the window's bottom inset. + /// + /// For example, if there is an onscreen keyboard displayed above the + /// scaffold, the body can be resized to avoid overlapping the keyboard, which + /// prevents widgets inside the body from being obscured by the keyboard. + /// + /// Defaults to true. + final bool resizeToAvoidBottomInset; + + /// Restoration ID to save and restore the state of the [CupertinoTabScaffold]. + /// + /// This property only has an effect when no [controller] has been provided: + /// If it is non-null (and no [controller] has been provided), the scaffold + /// will persist and restore the currently selected tab index. If a + /// [controller] has been provided, it is the responsibility of the owner of + /// that controller to persist and restore it, e.g. by using a + /// [RestorableCupertinoTabController]. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + @override + State createState() => _CupertinoTabScaffoldState(); +} + +class _CupertinoTabScaffoldState extends State with RestorationMixin { + RestorableCupertinoTabController? _internalController; + CupertinoTabController get _controller => widget.controller ?? _internalController!.value; + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + _restoreInternalController(); + } + + void _restoreInternalController() { + if (_internalController != null) { + registerForRestoration(_internalController!, 'controller'); + _internalController!.value.addListener(_onCurrentIndexChange); + } + } + + @override + void initState() { + super.initState(); + _updateTabController(); + } + + void _updateTabController([CupertinoTabController? oldWidgetController]) { + if (widget.controller == null && _internalController == null) { + // No widget-provided controller: create an internal controller. + _internalController = RestorableCupertinoTabController( + initialIndex: widget.tabBar.currentIndex, + ); + if (!restorePending) { + _restoreInternalController(); // Also adds the listener to the controller. + } + } + if (widget.controller != null && _internalController != null) { + // Use the widget-provided controller. + unregisterFromRestoration(_internalController!); + _internalController!.dispose(); + _internalController = null; + } + if (oldWidgetController != widget.controller) { + // The widget-provided controller has changed: move listeners. + if (oldWidgetController?._isDisposed == false) { + oldWidgetController!.removeListener(_onCurrentIndexChange); + } + widget.controller?.addListener(_onCurrentIndexChange); + } + } + + void _onCurrentIndexChange() { + assert( + _controller.index >= 0 && _controller.index < widget.tabBar.items.length, + "The $runtimeType's current index ${_controller.index} is " + 'out of bounds for the tab bar with ${widget.tabBar.items.length} tabs', + ); + + // The value of `_controller.index` has already been updated at this point. + // Calling `setState` to rebuild using `_controller.index`. + setState(() {}); + } + + @override + void didUpdateWidget(CupertinoTabScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _updateTabController(oldWidget.controller); + } else if (_controller.index >= widget.tabBar.items.length) { + // If a new [tabBar] with less than (_controller.index + 1) items is provided, + // clamp the current index. + _controller.index = widget.tabBar.items.length - 1; + } + } + + @override + Widget build(BuildContext context) { + final MediaQueryData existingMediaQuery = MediaQuery.of(context); + MediaQueryData newMediaQuery = MediaQuery.of(context); + + Widget content = _TabSwitchingView( + currentTabIndex: _controller.index, + tabCount: widget.tabBar.items.length, + tabBuilder: widget.tabBuilder, + ); + EdgeInsets contentPadding = EdgeInsets.zero; + + if (widget.resizeToAvoidBottomInset) { + // Remove the view inset and add it back as a padding in the inner content. + newMediaQuery = newMediaQuery.removeViewInsets(removeBottom: true); + contentPadding = EdgeInsets.only(bottom: existingMediaQuery.viewInsets.bottom); + } + + // Only pad the content with the height of the tab bar if the tab + // isn't already entirely obstructed by a keyboard or other view insets. + // Don't double pad. + if (!widget.resizeToAvoidBottomInset || + widget.tabBar.preferredSize.height > existingMediaQuery.viewInsets.bottom) { + // TODO(xster): Use real size after partial layout instead of preferred size. + // https://github.com/flutter/flutter/issues/12912 + final double bottomPadding = + widget.tabBar.preferredSize.height + existingMediaQuery.padding.bottom; + + // If tab bar opaque, directly stop the main content higher. If + // translucent, let main content draw behind the tab bar but hint the + // obstructed area. + if (widget.tabBar.opaque(context)) { + contentPadding = EdgeInsets.only(bottom: bottomPadding); + newMediaQuery = newMediaQuery.removePadding(removeBottom: true); + } else { + newMediaQuery = newMediaQuery.copyWith( + padding: newMediaQuery.padding.copyWith(bottom: bottomPadding), + ); + } + } + + content = MediaQuery( + data: newMediaQuery, + child: Padding(padding: contentPadding, child: content), + ); + + return DecoratedBox( + decoration: BoxDecoration( + color: + CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ?? + CupertinoTheme.of(context).scaffoldBackgroundColor, + ), + child: Stack( + children: [ + // The main content being at the bottom is added to the stack first. + content, + MediaQuery.withNoTextScaling( + child: Align( + alignment: Alignment.bottomCenter, + // Override the tab bar's currentIndex to the current tab and hook in + // our own listener to update the [_controller.currentIndex] on top of a possibly user + // provided callback. + child: widget.tabBar.copyWith( + currentIndex: _controller.index, + onTap: (int newIndex) { + _controller.index = newIndex; + // Chain the user's original callback. + widget.tabBar.onTap?.call(newIndex); + }, + ), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + if (widget.controller?._isDisposed == false) { + _controller.removeListener(_onCurrentIndexChange); + } + _internalController?.dispose(); + super.dispose(); + } +} + +/// A widget laying out multiple tabs with only one active tab being built +/// at a time and on stage. Off stage tabs' animations are stopped. +class _TabSwitchingView extends StatefulWidget { + const _TabSwitchingView({ + required this.currentTabIndex, + required this.tabCount, + required this.tabBuilder, + }) : assert(tabCount > 0); + + final int currentTabIndex; + final int tabCount; + final IndexedWidgetBuilder tabBuilder; + + @override + _TabSwitchingViewState createState() => _TabSwitchingViewState(); +} + +class _TabSwitchingViewState extends State<_TabSwitchingView> { + final List shouldBuildTab = []; + final List tabFocusNodes = []; + + // When focus nodes are no longer needed, we need to dispose of them, but we + // can't be sure that nothing else is listening to them until this widget is + // disposed of, so when they are no longer needed, we move them to this list, + // and dispose of them when we dispose of this widget. + final List discardedNodes = []; + + @override + void initState() { + super.initState(); + shouldBuildTab.addAll(List.filled(widget.tabCount, false)); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _focusActiveTab(); + } + + @override + void didUpdateWidget(_TabSwitchingView oldWidget) { + super.didUpdateWidget(oldWidget); + + // Only partially invalidate the tabs cache to avoid breaking the current + // behavior. We assume that the only possible change is either: + // - new tabs are appended to the tab list, or + // - some trailing tabs are removed. + // If the above assumption is not true, some tabs may lose their state. + final int lengthDiff = widget.tabCount - shouldBuildTab.length; + if (lengthDiff > 0) { + shouldBuildTab.addAll(List.filled(lengthDiff, false)); + } else if (lengthDiff < 0) { + shouldBuildTab.removeRange(widget.tabCount, shouldBuildTab.length); + } + _focusActiveTab(); + } + + // Will focus the active tab if the FocusScope above it has focus already. If + // not, then it will just mark it as the preferred focus for that scope. + void _focusActiveTab() { + if (tabFocusNodes.length != widget.tabCount) { + if (tabFocusNodes.length > widget.tabCount) { + discardedNodes.addAll(tabFocusNodes.sublist(widget.tabCount)); + tabFocusNodes.removeRange(widget.tabCount, tabFocusNodes.length); + } else { + tabFocusNodes.addAll( + List.generate( + widget.tabCount - tabFocusNodes.length, + (int index) => FocusScopeNode( + debugLabel: '$CupertinoTabScaffold Tab ${index + tabFocusNodes.length}', + ), + ), + ); + } + } + FocusScope.of(context).setFirstFocus(tabFocusNodes[widget.currentTabIndex]); + } + + @override + void dispose() { + for (final FocusScopeNode focusScopeNode in tabFocusNodes) { + focusScopeNode.dispose(); + } + for (final FocusScopeNode focusScopeNode in discardedNodes) { + focusScopeNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: List.generate(widget.tabCount, (int index) { + final active = index == widget.currentTabIndex; + shouldBuildTab[index] = active || shouldBuildTab[index]; + + return HeroMode( + enabled: active, + child: Offstage( + offstage: !active, + child: TickerMode( + enabled: active, + child: FocusScope( + node: tabFocusNodes[index], + child: Builder( + builder: (BuildContext context) { + return shouldBuildTab[index] + ? widget.tabBuilder(context, index) + : const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + }), + ); + } +} + +/// A [RestorableProperty] that knows how to store and restore a +/// [CupertinoTabController]. +/// +/// The [CupertinoTabController] is accessible via the [value] getter. During +/// state restoration, the property will restore [CupertinoTabController.index] +/// to the value it had when the restoration data it is getting restored from +/// was collected. +class RestorableCupertinoTabController extends RestorableChangeNotifier { + /// Creates a [RestorableCupertinoTabController] to control the tab index of + /// [CupertinoTabScaffold] and [CupertinoTabBar]. + /// + /// The `initialIndex` defaults to zero. The value must be greater than or + /// equal to zero, and less than the total number of tabs. + RestorableCupertinoTabController({int initialIndex = 0}) + : assert(initialIndex >= 0), + _initialIndex = initialIndex; + + final int _initialIndex; + + @override + CupertinoTabController createDefaultValue() { + return CupertinoTabController(initialIndex: _initialIndex); + } + + @override + CupertinoTabController fromPrimitives(Object? data) { + assert(data != null); + return CupertinoTabController(initialIndex: data! as int); + } + + @override + Object? toPrimitives() { + return value.index; + } +} diff --git a/packages/cupertino_ui/lib/src/tab_view.dart b/packages/cupertino_ui/lib/src/tab_view.dart new file mode 100644 index 000000000000..56652831bd7f --- /dev/null +++ b/packages/cupertino_ui/lib/src/tab_view.dart @@ -0,0 +1,255 @@ +// 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. + +/// @docImport 'tab_scaffold.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'app.dart' show CupertinoApp; +import 'route.dart'; + +/// A single tab view with its own [Navigator] state and history. +/// +/// A typical tab view is used as the content of each tab in a +/// [CupertinoTabScaffold] where multiple tabs with parallel navigation states +/// and history can co-exist. +/// +/// [CupertinoTabView] configures the top-level [Navigator] to search for routes +/// in the following order: +/// +/// 1. For the `/` route, the [builder] property, if non-null, is used. +/// +/// 2. Otherwise, the [routes] table is used, if it has an entry for the route, +/// including `/` if [builder] is not specified. +/// +/// 3. Otherwise, [onGenerateRoute] is called, if provided. It should return a +/// non-null value for any _valid_ route not handled by [builder] and [routes]. +/// +/// 4. Finally if all else fails [onUnknownRoute] is called. +/// +/// These navigation properties are not shared with any sibling [CupertinoTabView] +/// nor any ancestor or descendant [Navigator] instances. +/// +/// To push a route above this [CupertinoTabView] instead of inside it (such +/// as when showing a dialog on top of all tabs), use +/// `Navigator.of(rootNavigator: true)`. +/// +/// See also: +/// +/// * [CupertinoTabScaffold], a typical host that supports switching between tabs. +/// * [CupertinoPageRoute], a typical modal page route pushed onto the +/// [CupertinoTabView]'s [Navigator]. +class CupertinoTabView extends StatefulWidget { + /// Creates the content area for a tab in a [CupertinoTabScaffold]. + const CupertinoTabView({ + super.key, + this.builder, + this.navigatorKey, + this.defaultTitle, + this.routes, + this.onGenerateRoute, + this.onUnknownRoute, + this.navigatorObservers = const [], + this.restorationScopeId, + }); + + /// The widget builder for the default route of the tab view + /// ([Navigator.defaultRouteName], which is `/`). + /// + /// If a [builder] is specified, then [routes] must not include an entry for `/`, + /// as [builder] takes its place. + /// + /// Rebuilding a [CupertinoTabView] with a different [builder] will not clear + /// its current navigation stack or update its descendant. Instead, trigger a + /// rebuild from a descendant in its subtree. This can be done via methods such + /// as: + /// + /// * Calling [State.setState] on a descendant [StatefulWidget]'s [State] + /// * Modifying an [InheritedWidget] that a descendant registered itself + /// as a dependent to. + final WidgetBuilder? builder; + + /// A key to use when building this widget's [Navigator]. + /// + /// If a [navigatorKey] is specified, the [Navigator] can be directly + /// manipulated without first obtaining it from a [BuildContext] via + /// [Navigator.of]: from the [navigatorKey], use the [GlobalKey.currentState] + /// getter. + /// + /// If this is changed, a new [Navigator] will be created, losing all the + /// tab's state in the process; in that case, the [navigatorObservers] + /// must also be changed, since the previous observers will be attached to the + /// previous navigator. + final GlobalKey? navigatorKey; + + /// The title of the default route. + final String? defaultTitle; + + /// This tab view's routing table. + /// + /// When a named route is pushed with [Navigator.pushNamed] inside this tab view, + /// the route name is looked up in this map. If the name is present, + /// the associated [WidgetBuilder] is used to construct a + /// [CupertinoPageRoute] that performs an appropriate transition to the new + /// route. + /// + /// If the tab view only has one page, then you can specify it using [builder] instead. + /// + /// If [builder] is specified, then it implies an entry in this table for the + /// [Navigator.defaultRouteName] route (`/`), and it is an error to + /// redundantly provide such a route in the [routes] table. + /// + /// If a route is requested that is not specified in this table (or by + /// [builder]), then the [onGenerateRoute] callback is called to build the page + /// instead. + /// + /// This routing table is not shared with any routing tables of ancestor or + /// descendant [Navigator]s. + final Map? routes; + + /// The route generator callback used when the tab view is navigated to a named route. + /// + /// This is used if [routes] does not contain the requested route. + final RouteFactory? onGenerateRoute; + + /// Called when [onGenerateRoute] also fails to generate a route. + /// + /// This callback is typically used for error handling. For example, this + /// callback might always generate a "not found" page that describes the route + /// that wasn't found. + /// + /// The default implementation pushes a route that displays an ugly error + /// message. + final RouteFactory? onUnknownRoute; + + /// The list of observers for the [Navigator] created in this tab view. + /// + /// This list of observers is not shared with ancestor or descendant [Navigator]s. + final List navigatorObservers; + + /// Restoration ID to save and restore the state of the [Navigator] built by + /// this [CupertinoTabView]. + /// + /// {@macro flutter.widgets.navigator.restorationScopeId} + final String? restorationScopeId; + + @override + State createState() => _CupertinoTabViewState(); +} + +class _CupertinoTabViewState extends State { + late HeroController _heroController; + late List _navigatorObservers; + + @override + void initState() { + super.initState(); + _heroController = CupertinoApp.createCupertinoHeroController(); + _updateObservers(); + } + + @override + void didUpdateWidget(CupertinoTabView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.navigatorKey != oldWidget.navigatorKey || + widget.navigatorObservers != oldWidget.navigatorObservers) { + _updateObservers(); + } + } + + @override + void dispose() { + _heroController.dispose(); + super.dispose(); + } + + void _updateObservers() { + _navigatorObservers = List.of(widget.navigatorObservers) + ..add(_heroController); + } + + GlobalKey? _ownedNavigatorKey; + GlobalKey get _navigatorKey { + if (widget.navigatorKey != null) { + return widget.navigatorKey!; + } + _ownedNavigatorKey ??= GlobalKey(); + return _ownedNavigatorKey!; + } + + // Whether this tab is currently the active tab. + bool get _isActive => TickerMode.of(context); + + @override + Widget build(BuildContext context) { + final Widget child = Navigator( + key: _navigatorKey, + onGenerateRoute: _onGenerateRoute, + onUnknownRoute: _onUnknownRoute, + observers: _navigatorObservers, + restorationScopeId: widget.restorationScopeId, + ); + + // Handle system back gestures only if the tab is currently active. + return NavigatorPopHandler( + enabled: _isActive, + onPop: () { + if (!_isActive) { + return; + } + _navigatorKey.currentState!.maybePop(); + }, + child: child, + ); + } + + Route? _onGenerateRoute(RouteSettings settings) { + final String? name = settings.name; + final WidgetBuilder? routeBuilder; + String? title; + if (name == Navigator.defaultRouteName && widget.builder != null) { + routeBuilder = widget.builder; + title = widget.defaultTitle; + } else { + routeBuilder = widget.routes?[name]; + } + if (routeBuilder != null) { + return CupertinoPageRoute(builder: routeBuilder, title: title, settings: settings); + } + return widget.onGenerateRoute?.call(settings); + } + + Route? _onUnknownRoute(RouteSettings settings) { + assert(() { + if (widget.onUnknownRoute == null) { + throw FlutterError( + 'Could not find a generator for route $settings in the $runtimeType.\n' + 'Generators for routes are searched for in the following order:\n' + ' 1. For the "/" route, the "builder" property, if non-null, is used.\n' + ' 2. Otherwise, the "routes" table is used, if it has an entry for ' + 'the route.\n' + ' 3. Otherwise, onGenerateRoute is called. It should return a ' + 'non-null value for any valid route not handled by "builder" and "routes".\n' + ' 4. Finally if all else fails onUnknownRoute is called.\n' + 'Unfortunately, onUnknownRoute was not set.', + ); + } + return true; + }()); + final Route? result = widget.onUnknownRoute!(settings); + assert(() { + if (result == null) { + throw FlutterError( + 'The onUnknownRoute callback returned null.\n' + 'When the $runtimeType requested the route $settings from its ' + 'onUnknownRoute callback, the callback returned null. Such callbacks ' + 'must never return null.', + ); + } + return true; + }()); + return result; + } +} diff --git a/packages/cupertino_ui/lib/src/text_field.dart b/packages/cupertino_ui/lib/src/text_field.dart new file mode 100644 index 000000000000..719ca2437b93 --- /dev/null +++ b/packages/cupertino_ui/lib/src/text_field.dart @@ -0,0 +1,1956 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'colors.dart'; +import 'desktop_text_selection.dart'; +import 'icons.dart'; +import 'localizations.dart'; +import 'magnifier.dart'; +import 'spell_check_suggestions_toolbar.dart'; +import 'text_selection.dart'; +import 'theme.dart'; + +export 'package:flutter/services.dart' + show SmartDashesType, SmartQuotesType, TextCapitalization, TextInputAction, TextInputType; + +const TextStyle _kDefaultPlaceholderStyle = TextStyle( + fontWeight: FontWeight.w400, + color: CupertinoColors.placeholderText, +); + +// Value inspected from Xcode 11 & iOS 13.0 Simulator. +const BorderSide _kDefaultRoundedBorderSide = BorderSide( + color: CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), + ), + width: 0.0, +); +const Border _kDefaultRoundedBorder = Border( + top: _kDefaultRoundedBorderSide, + bottom: _kDefaultRoundedBorderSide, + left: _kDefaultRoundedBorderSide, + right: _kDefaultRoundedBorderSide, +); + +const BoxDecoration _kDefaultRoundedBorderDecoration = BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: CupertinoColors.black, + ), + border: _kDefaultRoundedBorder, + borderRadius: BorderRadius.all(Radius.circular(5.0)), +); + +const Color _kDisabledBackground = CupertinoDynamicColor.withBrightness( + color: Color(0xFFFAFAFA), + darkColor: Color(0xFF050505), +); + +// Value inspected from Xcode 12 & iOS 14.0 Simulator. +// Note it may not be consistent with https://developer.apple.com/design/resources/. +const CupertinoDynamicColor _kClearButtonColor = CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), +); + +// An eyeballed value that moves the cursor slightly left of where it is +// rendered for text on Android so it's positioning more accurately matches the +// native iOS text cursor positioning. +// +// This value is in device pixels, not logical pixels as is typically used +// throughout the codebase. +const int _iOSHorizontalCursorOffsetPixels = -2; + +/// Visibility of text field overlays based on the state of the current text entry. +/// +/// Used to toggle the visibility behavior of the optional decorating widgets +/// surrounding the [EditableText] such as the clear text button. +enum OverlayVisibilityMode { + /// Overlay will never appear regardless of the text entry state. + never, + + /// Overlay will only appear when the current text entry is not empty. + /// + /// This includes prefilled text that the user did not type in manually. But + /// does not include text in placeholders. + editing, + + /// Overlay will only appear when the current text entry is empty. + /// + /// This also includes not having prefilled text that the user did not type + /// in manually. Texts in placeholders are ignored. + notEditing, + + /// Always show the overlay regardless of the text entry state. + always, +} + +class _CupertinoTextFieldSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _CupertinoTextFieldSelectionGestureDetectorBuilder({required _CupertinoTextFieldState state}) + : _state = state, + super(delegate: state); + + final _CupertinoTextFieldState _state; + + @override + void onSingleTapUp(TapDragUpDetails details) { + // Because TextSelectionGestureDetector listens to taps that happen on + // widgets in front of it, tapping the clear button will also trigger + // this handler. If the clear button widget recognizes the up event, + // then do not handle it. + if (_state._clearGlobalKey.currentContext != null) { + final renderBox = _state._clearGlobalKey.currentContext!.findRenderObject()! as RenderBox; + final Offset localOffset = renderBox.globalToLocal(details.globalPosition); + if (renderBox.hitTest(BoxHitTestResult(), position: localOffset)) { + return; + } + } + super.onSingleTapUp(details); + _state.widget.onTap?.call(); + } + + @override + void onDragSelectionEnd(TapDragEndDetails details) { + _state._requestKeyboard(); + super.onDragSelectionEnd(details); + } +} + +/// An iOS-style text field. +/// +/// A text field lets the user enter text, either with a hardware keyboard or with +/// an onscreen keyboard. +/// +/// This widget corresponds to both a `UITextField` and an editable `UITextView` +/// on iOS. +/// +/// The text field calls the [onChanged] callback whenever the user changes the +/// text in the field. If the user indicates that they are done typing in the +/// field (e.g., by pressing a button on the soft keyboard), the text field +/// calls the [onSubmitted] callback. +/// +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// {@tool dartpad} +/// This example shows how to set the initial value of the [CupertinoTextField] using +/// a [controller] that already contains some text. +/// +/// ** See code in examples/api/lib/cupertino/text_field/cupertino_text_field.0.dart ** +/// {@end-tool} +/// +/// The [controller] can also control the selection and composing region (and to +/// observe changes to the text, selection, and composing region). +/// +/// The text field has an overridable [decoration] that, by default, draws a +/// rounded rectangle border around the text field. If you set the [decoration] +/// property to null, the decoration will be removed entirely. +/// +/// {@macro flutter.material.textfield.wantKeepAlive} +/// +/// Remember to call [TextEditingController.dispose] when it is no longer +/// needed. This will ensure we discard any resources used by the object. +/// +/// {@macro flutter.widgets.editableText.showCaretOnScreen} +/// +/// ## Scrolling Considerations +/// +/// If this [CupertinoTextField] is not a descendant of [Scaffold] and is being +/// used within a [Scrollable] or nested [Scrollable]s, consider placing a +/// [ScrollNotificationObserver] above the root [Scrollable] that contains this +/// [CupertinoTextField] to ensure proper scroll coordination for +/// [CupertinoTextField] and its components like [TextSelectionOverlay]. +/// +/// See also: +/// +/// * +/// * [TextField], an alternative text field widget that follows the Material +/// Design UI conventions. +/// * [EditableText], which is the raw text editing control at the heart of a +/// [TextField]. +/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). +/// * +class CupertinoTextField extends StatefulWidget { + /// Creates an iOS-style text field. + /// + /// To provide a prefilled text entry, pass in a [TextEditingController] with + /// an initial value to the [controller] parameter. + /// + /// To provide a hint placeholder text that appears when the text entry is + /// empty, pass a [String] to the [placeholder] parameter. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. In this mode, the intrinsic height of the widget will + /// grow as the number of lines of text grows. By default, it is `1`, meaning + /// this is a single-line text field and will scroll horizontally when + /// it overflows. [maxLines] must not be zero. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// If specified, the [maxLength] property must be greater than zero. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [EditableText.defaultSelectionHeightStyle] and + /// [EditableText.defaultSelectionWidthStyle], respectively. + /// + /// The [autocorrect], [autofocus], [clearButtonMode], [dragStartBehavior], + /// [expands], [obscureText], [prefixMode], [readOnly], [scrollPadding], + /// [suffixMode], [textAlign], [selectionHeightStyle], [selectionWidthStyle], + /// [enableSuggestions], and [enableIMEPersonalizedLearning] properties must + /// not be null. + /// + /// {@macro flutter.widgets.editableText.accessibility} + /// + /// See also: + /// + /// * [minLines], which is the minimum number of lines to occupy when the + /// content spans fewer lines. + /// * [expands], to allow the widget to size itself to its parent's height. + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const CupertinoTextField({ + super.key, + this.groupId = EditableText, + this.controller, + this.focusNode, + this.undoController, + this.decoration = _kDefaultRoundedBorderDecoration, + this.padding = const EdgeInsets.all(7.0), + this.placeholder, + this.placeholderStyle = const TextStyle( + fontWeight: FontWeight.w400, + color: CupertinoColors.placeholderText, + ), + this.prefix, + this.prefixMode = OverlayVisibilityMode.always, + this.suffix, + this.suffixMode = OverlayVisibilityMode.always, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.clearButtonMode = OverlayVisibilityMode.never, + this.clearButtonSemanticLabel, + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.showCursor, + this.autofocus = false, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onTapOutside, + this.onTapUpOutside, + this.inputFormatters, + this.enabled = true, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius = const Radius.circular(2.0), + this.cursorOpacityAnimates = true, + this.cursorColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.selectAllOnFocus, + this.selectionControls, + this.onTap, + this.scrollController, + this.scrollPhysics, + this.autofillHints = const [], + this.contentInsertionConfiguration, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled, + this.enableIMEPersonalizedLearning = true, + this.enableInlinePrediction, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = + smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = + smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength > 0), + // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + keyboardType = + keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText); + + /// Creates a borderless iOS-style text field. + /// + /// To provide a prefilled text entry, pass in a [TextEditingController] with + /// an initial value to the [controller] parameter. + /// + /// To provide a hint placeholder text that appears when the text entry is + /// empty, pass a [String] to the [placeholder] parameter. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. In this mode, the intrinsic height of the widget will + /// grow as the number of lines of text grows. By default, it is `1`, meaning + /// this is a single-line text field and will scroll horizontally when + /// it overflows. [maxLines] must not be zero. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// If specified, the [maxLength] property must be greater than zero. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively. + /// + /// See also: + /// + /// * [minLines], which is the minimum number of lines to occupy when the + /// content spans fewer lines. + /// * [expands], to allow the widget to size itself to its parent's height. + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const CupertinoTextField.borderless({ + super.key, + this.groupId = EditableText, + this.controller, + this.focusNode, + this.undoController, + this.decoration, + this.padding = const EdgeInsets.all(7.0), + this.placeholder, + this.placeholderStyle = _kDefaultPlaceholderStyle, + this.prefix, + this.prefixMode = OverlayVisibilityMode.always, + this.suffix, + this.suffixMode = OverlayVisibilityMode.always, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.clearButtonMode = OverlayVisibilityMode.never, + this.clearButtonSemanticLabel, + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.showCursor, + this.autofocus = false, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onTapOutside, + this.onTapUpOutside, + this.inputFormatters, + this.enabled = true, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius = const Radius.circular(2.0), + this.cursorOpacityAnimates = true, + this.cursorColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.selectAllOnFocus, + this.selectionControls, + this.onTap, + this.scrollController, + this.scrollPhysics, + this.autofillHints = const [], + this.contentInsertionConfiguration, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.enableInlinePrediction, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = + smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = + smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength > 0), + // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + keyboardType = + keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText); + + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Controls the [BoxDecoration] of the box behind the text input. + /// + /// Defaults to having a rounded rectangle grey border and can be null to have + /// no box decoration. + final BoxDecoration? decoration; + + /// Padding around the text entry area between the [prefix] and [suffix] + /// or the clear button when [clearButtonMode] is not never. + /// + /// Defaults to a padding of 6 pixels on all sides and can be null. + final EdgeInsetsGeometry padding; + + /// A lighter colored placeholder hint that appears on the first line of the + /// text field when the text entry is empty. + /// + /// Defaults to having no placeholder text. + /// + /// The text style of the placeholder text matches that of the text field's + /// main text entry except a lighter font weight and a grey font color. + final String? placeholder; + + /// The style to use for the placeholder text. + /// + /// The [placeholderStyle] is merged with the [style] [TextStyle] when applied + /// to the [placeholder] text. To avoid merging with [style], specify + /// [TextStyle.inherit] as false. + /// + /// Defaults to the [style] property with w300 font weight and grey color. + /// + /// If specifically set to null, placeholder's style will be the same as [style]. + final TextStyle? placeholderStyle; + + /// An optional [Widget] to display before the text. + final Widget? prefix; + + /// Controls the visibility of the [prefix] widget based on the state of + /// text entry when the [prefix] argument is not null. + /// + /// Defaults to [OverlayVisibilityMode.always]. + /// + /// Has no effect when [prefix] is null. + final OverlayVisibilityMode prefixMode; + + /// An optional [Widget] to display after the text. + final Widget? suffix; + + /// Controls the visibility of the [suffix] widget based on the state of + /// text entry when the [suffix] argument is not null. + /// + /// Defaults to [OverlayVisibilityMode.always]. + /// + /// Has no effect when [suffix] is null. + final OverlayVisibilityMode suffixMode; + + /// Controls the vertical alignment of the [prefix] and the [suffix] widget in relation to content. + /// + /// Defaults to [CrossAxisAlignment.center]. + /// + /// Has no effect when both the [prefix] and [suffix] are null. + final CrossAxisAlignment crossAxisAlignment; + + /// Show an iOS-style clear button to clear the current text entry. + /// + /// Can be made to appear depending on various text states of the + /// [TextEditingController]. + /// + /// Will only appear if no [suffix] widget is appearing. + /// + /// Defaults to [OverlayVisibilityMode.never]. + final OverlayVisibilityMode clearButtonMode; + + /// The semantic label for the clear button used by screen readers. + /// + /// This will be used by screen reading software to identify the clear button + /// widget. Defaults to "Clear". + final String? clearButtonSemanticLabel; + + /// {@macro flutter.widgets.editableText.keyboardType} + final TextInputType keyboardType; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is + /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + final TextInputAction? textInputAction; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// The style to use for the text being edited. + /// + /// Also serves as a base for the [placeholder] text's style. + /// + /// Defaults to the standard iOS font style from [CupertinoTheme] if null. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign textAlign; + + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final ToolbarOptions? toolbarOptions; + + /// {@macro flutter.material.InputDecorator.textAlignVertical} + final TextAlignVertical? textAlignVertical; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool? showCursor; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.obscuringCharacter} + final String obscuringCharacter; + + /// {@macro flutter.widgets.editableText.obscureText} + final bool obscureText; + + /// {@macro flutter.widgets.editableText.autocorrect} + final bool? autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} + final SmartDashesType smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} + final SmartQuotesType smartQuotesType; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// {@macro flutter.widgets.editableText.maxLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; + + /// {@macro flutter.widgets.editableText.minLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; + + /// {@macro flutter.widgets.editableText.expands} + final bool expands; + + /// The maximum number of characters (Unicode grapheme clusters) to allow in + /// the text field. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The TextField enforces the length with a + /// [LengthLimitingTextInputFormatter], which is evaluated after the supplied + /// [inputFormatters], if any. + /// + /// This value must be either null or greater than zero. If set to null + /// (the default), there is no limit to the number of characters allowed. + /// + /// Whitespace characters (e.g. newline, space, tab) are included in the + /// character count. + /// + /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} + final int? maxLength; + + /// Determines how the [maxLength] limit should be enforced. + /// + /// If [MaxLengthEnforcement.none] is set, additional input beyond [maxLength] + /// will not be enforced by the limit. + /// + /// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement} + /// + /// {@macro flutter.services.textFormatter.maxLengthEnforcement} + final MaxLengthEnforcement? maxLengthEnforcement; + + /// {@macro flutter.widgets.editableText.onChanged} + final ValueChanged? onChanged; + + /// {@macro flutter.widgets.editableText.onEditingComplete} + final VoidCallback? onEditingComplete; + + /// {@macro flutter.widgets.editableText.onSubmitted} + /// + /// See also: + /// + /// * [TextInputAction.next] and [TextInputAction.previous], which + /// automatically shift the focus to the next/previous focusable item when + /// the user is done editing. + final ValueChanged? onSubmitted; + + /// {@macro flutter.widgets.editableText.onTapOutside} + final TapRegionCallback? onTapOutside; + + /// {@macro flutter.widgets.editableText.onTapUpOutside} + final TapRegionCallback? onTapUpOutside; + + /// {@macro flutter.widgets.editableText.inputFormatters} + final List? inputFormatters; + + /// Disables the text field when false. + /// + /// Text fields in disabled states have a light grey background and don't + /// respond to touch events including the [prefix], [suffix] and the clear + /// button. + /// + /// Defaults to true. + final bool enabled; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius cursorRadius; + + /// {@macro flutter.widgets.editableText.cursorOpacityAnimates} + final bool cursorOpacityAnimates; + + /// The color to use when painting the cursor. + /// + /// Defaults to the [DefaultSelectionStyle.cursorColor]. If that color is + /// null, it uses the [CupertinoThemeData.primaryColor] of the ambient theme, + /// which itself defaults to [CupertinoColors.activeBlue] in the light theme + /// and [CupertinoColors.activeOrange] in the dark theme. + final Color? cursorColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle? selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle? selectionWidthStyle; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// If null, defaults to [Brightness.light]. + final Brightness? keyboardAppearance; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectAllOnFocus} + final bool? selectAllOnFocus; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.editableText.scrollController} + final ScrollController? scrollController; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// {@macro flutter.material.textfield.onTap} + final GestureTapCallback? onTap; + + /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// {@macro flutter.material.textfield.restorationId} + final String? restorationId; + + /// {@macro flutter.widgets.editableText.scribbleEnabled} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool scribbleEnabled; + + /// {@macro flutter.widgets.editableText.stylusHandwritingEnabled} + final bool stylusHandwritingEnabled; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + /// {@macro flutter.services.TextInputConfiguration.enableInlinePrediction} + final bool? enableInlinePrediction; + + /// {@macro flutter.widgets.editableText.contentInsertionConfiguration} + final ContentInsertionConfiguration? contentInsertionConfiguration; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [CupertinoAdaptiveTextSelectionToolbar], which is built by default. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return CupertinoAdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + } + + /// Configuration for the text field magnifier. + /// + /// By default (when this property is set to null), a [CupertinoTextMagnifier] + /// is used on mobile platforms, and nothing on desktop platforms. To suppress + /// the magnifier on all platforms, consider passing + /// [TextMagnifierConfiguration.disabled] explicitly. + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the magnifier that this text field uses. + /// + /// ** See code in examples/api/lib/widgets/text_magnifier/text_magnifier.0.dart ** + /// {@end-tool} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.EditableText.spellCheckConfiguration} + /// + /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this + /// configuration, then [cupertinoMisspelledTextStyle] is used by default. + final SpellCheckConfiguration? spellCheckConfiguration; + + /// The [TextStyle] used to indicate misspelled words in the Cupertino style. + /// + /// See also: + /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to + /// mark misspelled words with. + /// * [TextField.materialMisspelledTextStyle], the style configured + /// to mark misspelled words with in the Material style. + static const TextStyle cupertinoMisspelledTextStyle = TextStyle( + decoration: TextDecoration.underline, + decorationColor: CupertinoColors.systemRed, + decorationStyle: TextDecorationStyle.dotted, + ); + + /// The color of the selection highlight when the spell check menu is visible. + /// + /// Eyeballed from a screenshot taken on an iPhone 11 running iOS 16.2. + @visibleForTesting + static const Color kMisspelledSelectionColor = Color(0x62ff9699); + + /// Default builder for the spell check suggestions toolbar in the Cupertino + /// style. + /// + /// See also: + /// * [spellCheckConfiguration], where this is typically specified for + /// [CupertinoTextField]. + /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + /// parameter for which this is the default value for [CupertinoTextField]. + /// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], which is like + /// this but specifies the default for [CupertinoTextField]. + @visibleForTesting + static Widget defaultSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return CupertinoSpellCheckSuggestionsToolbar.editableText(editableTextState: editableTextState); + } + + /// {@macro flutter.widgets.undoHistory.controller} + final UndoHistoryController? undoController; + + @override + State createState() => _CupertinoTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller, defaultValue: null), + ); + properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); + properties.add( + DiagnosticsProperty( + 'undoController', + undoController, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty('decoration', decoration)); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(StringProperty('placeholder', placeholder)); + properties.add(DiagnosticsProperty('placeholderStyle', placeholderStyle)); + properties.add( + DiagnosticsProperty('prefix', prefix == null ? null : prefixMode), + ); + properties.add( + DiagnosticsProperty('suffix', suffix == null ? null : suffixMode), + ); + properties.add(DiagnosticsProperty('clearButtonMode', clearButtonMode)); + properties.add( + DiagnosticsProperty('clearButtonSemanticLabel', clearButtonSemanticLabel), + ); + properties.add( + DiagnosticsProperty( + 'keyboardType', + keyboardType, + defaultValue: TextInputType.text, + ), + ); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add( + DiagnosticsProperty('obscuringCharacter', obscuringCharacter, defaultValue: '•'), + ); + properties.add(DiagnosticsProperty('obscureText', obscureText, defaultValue: false)); + properties.add(DiagnosticsProperty('autocorrect', autocorrect, defaultValue: null)); + properties.add( + EnumProperty( + 'smartDashesType', + smartDashesType, + defaultValue: obscureText ? SmartDashesType.disabled : SmartDashesType.enabled, + ), + ); + properties.add( + EnumProperty( + 'smartQuotesType', + smartQuotesType, + defaultValue: obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled, + ), + ); + properties.add( + DiagnosticsProperty('enableSuggestions', enableSuggestions, defaultValue: true), + ); + properties.add(IntProperty('maxLines', maxLines, defaultValue: 1)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add(DiagnosticsProperty('expands', expands, defaultValue: false)); + properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); + properties.add( + EnumProperty( + 'maxLengthEnforcement', + maxLengthEnforcement, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); + properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, defaultValue: null)); + properties.add( + DiagnosticsProperty('cursorOpacityAnimates', cursorOpacityAnimates, defaultValue: true), + ); + properties.add(createCupertinoColorProperty('cursorColor', cursorColor, defaultValue: null)); + properties.add( + FlagProperty( + 'selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled', + ), + ); + properties.add( + DiagnosticsProperty( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'scrollController', + scrollController, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null), + ); + properties.add(EnumProperty('textAlign', textAlign, defaultValue: TextAlign.start)); + properties.add( + DiagnosticsProperty( + 'textAlignVertical', + textAlignVertical, + defaultValue: null, + ), + ); + properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); + properties.add( + DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge), + ); + properties.add( + DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true), + ); + properties.add( + DiagnosticsProperty( + 'stylusHandwritingEnabled', + stylusHandwritingEnabled, + defaultValue: EditableText.defaultStylusHandwritingEnabled, + ), + ); + properties.add( + DiagnosticsProperty( + 'enableIMEPersonalizedLearning', + enableIMEPersonalizedLearning, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'enableInlinePrediction', + enableInlinePrediction, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'spellCheckConfiguration', + spellCheckConfiguration, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty>( + 'contentCommitMimeTypes', + contentInsertionConfiguration?.allowedMimeTypes ?? const [], + defaultValue: contentInsertionConfiguration == null + ? const [] + : kDefaultContentInsertionMimeTypes, + ), + ); + } + + static final TextMagnifierConfiguration _iosMagnifierConfiguration = TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier magnifierInfo, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return CupertinoTextMagnifier(controller: controller, magnifierInfo: magnifierInfo); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return null; + } + }, + ); + + /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// had any missing values replaced with their defaults for the iOS platform. + static SpellCheckConfiguration inferIOSSpellCheckConfiguration( + SpellCheckConfiguration? configuration, + ) { + if (configuration == null || configuration == const SpellCheckConfiguration.disabled()) { + return const SpellCheckConfiguration.disabled(); + } + + return configuration.copyWith( + misspelledTextStyle: + configuration.misspelledTextStyle ?? CupertinoTextField.cupertinoMisspelledTextStyle, + misspelledSelectionColor: + configuration.misspelledSelectionColor ?? CupertinoTextField.kMisspelledSelectionColor, + spellCheckSuggestionsToolbarBuilder: + configuration.spellCheckSuggestionsToolbarBuilder ?? + CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ); + } +} + +class _CupertinoTextFieldState extends State + with RestorationMixin, AutomaticKeepAliveClientMixin + implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { + final GlobalKey _clearGlobalKey = GlobalKey(); + + RestorableTextEditingController? _controller; + TextEditingController get _effectiveController => widget.controller ?? _controller!.value; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); + + MaxLengthEnforcement get _effectiveMaxLengthEnforcement => + widget.maxLengthEnforcement ?? + LengthLimitingTextInputFormatter.getDefaultMaxLengthEnforcement(); + + bool _showSelectionHandles = false; + + late _CupertinoTextFieldSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + bool get forcePressEnabled => true; + + @override + final GlobalKey editableTextKey = GlobalKey(); + + @override + bool get selectionEnabled => widget.selectionEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = _CupertinoTextFieldSelectionGestureDetectorBuilder( + state: this, + ); + if (widget.controller == null) { + _createLocalController(); + } + _effectiveFocusNode.canRequestFocus = widget.enabled; + _effectiveFocusNode.addListener(_handleFocusChanged); + } + + @override + void didUpdateWidget(CupertinoTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + _effectiveFocusNode.canRequestFocus = widget.enabled; + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + _controller!.value.addListener(updateKeepAlive); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); + _focusNode?.dispose(); + _controller?.dispose(); + super.dispose(); + } + + EditableTextState get _editableText => editableTextKey.currentState!; + + void _requestKeyboard() { + _editableText.requestKeyboard(); + } + + void _handleFocusChanged() { + setState(() { + // Rebuild the widget on focus change to show/hide the text selection + // highlight. + }); + } + + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + // When the text field is activated by something that doesn't trigger the + // selection toolbar, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar || + !_selectionGestureDetectorBuilder.shouldShowSelectionHandles) { + return false; + } + + // On iOS, we don't show handles when the selection is collapsed. + if (_effectiveController.selection.isCollapsed) { + return false; + } + + if (cause == SelectionChangedCause.keyboard) { + return false; + } + + if (cause == SelectionChangedCause.stylusHandwriting) { + return true; + } + + if (_effectiveController.text.isNotEmpty) { + return true; + } + + return false; + } + + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.longPress) { + _editableText.bringIntoView(selection.extent); + } + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (cause == SelectionChangedCause.drag) { + _editableText.hideToolbar(); + } + } + } + + @override + bool get wantKeepAlive => _controller?.value.text.isNotEmpty ?? false; + + static bool _shouldShowAttachment({ + required OverlayVisibilityMode attachment, + required bool hasText, + }) { + return switch (attachment) { + OverlayVisibilityMode.never => false, + OverlayVisibilityMode.always => true, + OverlayVisibilityMode.editing => hasText, + OverlayVisibilityMode.notEditing => !hasText, + }; + } + + // True if any surrounding decoration widgets will be shown. + bool get _hasDecoration { + return widget.placeholder != null || + widget.clearButtonMode != OverlayVisibilityMode.never || + widget.prefix != null || + widget.suffix != null; + } + + // Provide default behavior if widget.textAlignVertical is not set. + // CupertinoTextField has top alignment by default, unless it has decoration + // like a prefix or suffix, in which case it's aligned to the center. + TextAlignVertical get _textAlignVertical { + if (widget.textAlignVertical != null) { + return widget.textAlignVertical!; + } + return _hasDecoration ? TextAlignVertical.center : TextAlignVertical.top; + } + + void _onClearButtonTapped() { + final bool hadText = _effectiveController.text.isNotEmpty; + _effectiveController.clear(); + if (hadText) { + // Tapping the clear button is also considered a "user initiated" change + // (instead of a programmatical one), so call `onChanged` if the text + // changed as a result. + widget.onChanged?.call(_effectiveController.text); + } + } + + Widget _buildClearButton() { + final String clearLabel = + widget.clearButtonSemanticLabel ?? CupertinoLocalizations.of(context).clearButtonLabel; + + return Semantics( + button: true, + label: clearLabel, + child: GestureDetector( + key: _clearGlobalKey, + onTap: widget.enabled ? _onClearButtonTapped : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: Icon( + CupertinoIcons.clear_thick_circled, + size: 18.0, + color: CupertinoDynamicColor.resolve(_kClearButtonColor, context), + ), + ), + ), + ); + } + + Widget _addTextDependentAttachments( + Widget editableText, + TextStyle textStyle, + TextStyle placeholderStyle, + ) { + // If there are no surrounding widgets, just return the core editable text + // part. + if (!_hasDecoration) { + return editableText; + } + + // Otherwise, listen to the current state of the text entry. + return ValueListenableBuilder( + valueListenable: _effectiveController, + child: editableText, + builder: (BuildContext context, TextEditingValue text, Widget? child) { + final bool hasText = text.text.isNotEmpty; + final String? placeholderText = widget.placeholder; + final Widget? placeholder = placeholderText == null + ? null + // Make the placeholder invisible when hasText is true. + : Visibility( + maintainAnimation: true, + maintainSize: true, + maintainState: true, + visible: !hasText, + child: SizedBox( + width: double.infinity, + child: Padding( + padding: widget.padding, + child: Text( + placeholderText, + // This is to make sure the text field is always tall enough + // to accommodate the first line of the placeholder, so the + // text does not shrink vertically as you type (however in + // rare circumstances, the height may still change when + // there's no placeholder text). + maxLines: hasText ? 1 : widget.maxLines, + overflow: placeholderStyle.overflow, + style: placeholderStyle, + textAlign: widget.textAlign, + ), + ), + ), + ); + + final Widget? prefixWidget = + _shouldShowAttachment(attachment: widget.prefixMode, hasText: hasText) + ? widget.prefix + : null; + + // Show user specified suffix if applicable and fall back to clear button. + final bool showUserSuffix = _shouldShowAttachment( + attachment: widget.suffixMode, + hasText: hasText, + ); + final bool showClearButton = _shouldShowAttachment( + attachment: widget.clearButtonMode, + hasText: hasText, + ); + final Widget? suffixWidget = switch ((showUserSuffix, showClearButton)) { + (false, false) => null, + (true, false) => widget.suffix, + (true, true) => widget.suffix ?? _buildClearButton(), + (false, true) => _buildClearButton(), + }; + return Row( + crossAxisAlignment: widget.crossAxisAlignment, + children: [ + // Insert a prefix at the front if the prefix visibility mode matches + // the current text state. + ?prefixWidget, + // In the middle part, stack the placeholder on top of the main EditableText + // if needed. + Expanded( + child: Directionality( + textDirection: widget.textDirection ?? Directionality.of(context), + child: _BaselineAlignedStack( + placeholder: placeholder, + editableText: editableText, + textAlignVertical: _textAlignVertical, + editableTextBaseline: textStyle.textBaseline ?? TextBaseline.alphabetic, + placeholderBaseline: placeholderStyle.textBaseline ?? TextBaseline.alphabetic, + ), + ), + ), + ?suffixWidget, + ], + ); + }, + ); + } + + // AutofillClient implementation start. + @override + String get autofillId => _editableText.autofillId; + + @override + void autofill(TextEditingValue newEditingValue) => _editableText.autofill(newEditingValue); + + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: _effectiveController.value, + hintText: widget.placeholder, + ) + : AutofillConfiguration.disabled; + + return _editableText.textInputConfiguration.copyWith( + autofillConfiguration: autofillConfiguration, + ); + } + // AutofillClient implementation end. + + @override + Widget build(BuildContext context) { + super.build(context); // See AutomaticKeepAliveClientMixin. + assert(debugCheckHasDirectionality(context)); + final TextEditingController controller = _effectiveController; + + TextSelectionControls? textSelectionControls = widget.selectionControls; + VoidCallback? handleDidGainAccessibilityFocus; + VoidCallback? handleDidLoseAccessibilityFocus; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + textSelectionControls ??= cupertinoTextSelectionHandleControls; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + } + + final bool enabled = widget.enabled; + final cursorOffset = Offset( + _iOSHorizontalCursorOffsetPixels / MediaQuery.devicePixelRatioOf(context), + 0, + ); + final formatters = [ + ...?widget.inputFormatters, + if (widget.maxLength != null) + LengthLimitingTextInputFormatter( + widget.maxLength, + maxLengthEnforcement: _effectiveMaxLengthEnforcement, + ), + ]; + final CupertinoThemeData themeData = CupertinoTheme.of(context); + + final TextStyle? resolvedStyle = widget.style?.copyWith( + color: CupertinoDynamicColor.maybeResolve(widget.style?.color, context), + backgroundColor: CupertinoDynamicColor.maybeResolve(widget.style?.backgroundColor, context), + ); + + final TextStyle textStyle = themeData.textTheme.textStyle.merge(resolvedStyle); + + final TextStyle? resolvedPlaceholderStyle = widget.placeholderStyle?.copyWith( + color: CupertinoDynamicColor.maybeResolve(widget.placeholderStyle?.color, context), + backgroundColor: CupertinoDynamicColor.maybeResolve( + widget.placeholderStyle?.backgroundColor, + context, + ), + ); + + final TextStyle placeholderStyle = textStyle.merge(resolvedPlaceholderStyle); + + final Brightness keyboardAppearance = + widget.keyboardAppearance ?? CupertinoTheme.brightnessOf(context); + final Color cursorColor = + CupertinoDynamicColor.maybeResolve( + widget.cursorColor ?? DefaultSelectionStyle.of(context).cursorColor, + context, + ) ?? + themeData.primaryColor; + + final Color disabledColor = CupertinoDynamicColor.resolve(_kDisabledBackground, context); + + final Color? decorationColor = CupertinoDynamicColor.maybeResolve( + widget.decoration?.color, + context, + ); + + final BoxBorder? border = widget.decoration?.border; + var resolvedBorder = border as Border?; + if (border is Border) { + BorderSide resolveBorderSide(BorderSide side) { + return side == BorderSide.none + ? side + : side.copyWith(color: CupertinoDynamicColor.resolve(side.color, context)); + } + + resolvedBorder = border.runtimeType != Border + ? border + : Border( + top: resolveBorderSide(border.top), + left: resolveBorderSide(border.left), + bottom: resolveBorderSide(border.bottom), + right: resolveBorderSide(border.right), + ); + } + + // Use the default disabled color only if the box decoration was not set. + final BoxDecoration? effectiveDecoration = widget.decoration?.copyWith( + border: resolvedBorder, + color: enabled + ? decorationColor + : (widget.decoration == _kDefaultRoundedBorderDecoration + ? disabledColor + : widget.decoration?.color), + ); + + final Color selectionColor = + CupertinoDynamicColor.maybeResolve( + DefaultSelectionStyle.of(context).selectionColor, + context, + ) ?? + CupertinoTheme.of(context).primaryColor.withOpacity(0.2); + + // Set configuration as disabled if not otherwise specified. If specified, + // ensure that configuration uses Cupertino text style for misspelled words + // unless a custom style is specified. + final SpellCheckConfiguration spellCheckConfiguration = + CupertinoTextField.inferIOSSpellCheckConfiguration(widget.spellCheckConfiguration); + + final Widget paddedEditable = Padding( + padding: widget.padding, + child: RepaintBoundary( + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + controller: controller, + undoController: widget.undoController, + readOnly: widget.readOnly || !enabled, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, + focusNode: _effectiveFocusNode, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: textStyle, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + magnifierConfiguration: + widget.magnifierConfiguration ?? CupertinoTextField._iosMagnifierConfiguration, + // Only show the selection highlight when the text field is focused. + selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null, + selectionControls: widget.selectionEnabled ? textSelectionControls : null, + groupId: widget.groupId, + onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onTapOutside: widget.onTapOutside, + inputFormatters: formatters, + rendererIgnoresPointer: true, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorColor: cursorColor, + cursorOpacityAnimates: widget.cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: true, + autocorrectionTextRectColor: selectionColor, + backgroundCursorColor: CupertinoDynamicColor.resolve( + CupertinoColors.inactiveGray, + context, + ), + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + scrollPadding: widget.scrollPadding, + keyboardAppearance: keyboardAppearance, + dragStartBehavior: widget.dragStartBehavior, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + enableInteractiveSelection: widget.enableInteractiveSelection, + selectAllOnFocus: widget.selectAllOnFocus, + autofillClient: this, + clipBehavior: widget.clipBehavior, + restorationId: 'editable', + scribbleEnabled: widget.scribbleEnabled, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + enableInlinePrediction: widget.enableInlinePrediction, + contentInsertionConfiguration: widget.contentInsertionConfiguration, + contextMenuBuilder: widget.contextMenuBuilder, + spellCheckConfiguration: spellCheckConfiguration, + ), + ), + ), + ); + + return Semantics( + enabled: enabled, + onTap: !enabled || widget.readOnly + ? null + : () { + if (!controller.selection.isValid) { + controller.selection = TextSelection.collapsed(offset: controller.text.length); + } + _requestKeyboard(); + }, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + onDidLoseAccessibilityFocus: handleDidLoseAccessibilityFocus, + onFocus: enabled + ? () { + assert( + _effectiveFocusNode.canRequestFocus, + 'Received SemanticsAction.focus from the engine. However, the FocusNode ' + 'of this text field cannot gain focus. This likely indicates a bug. ' + 'If this text field cannot be focused (e.g. because it is not ' + 'enabled), then its corresponding semantics node must be configured ' + 'such that the assistive technology cannot request focus on it.', + ); + + if (_effectiveFocusNode.canRequestFocus && !_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.requestFocus(); + } else if (!widget.readOnly) { + // If the platform requested focus, that means that previously the + // platform believed that the text field did not have focus (even + // though Flutter's widget system believed otherwise). This likely + // means that the on-screen keyboard is hidden, or more generally, + // there is no current editing session in this field. To correct + // that, keyboard must be requested. + // + // A concrete scenario where this can happen is when the user + // dismisses the keyboard on the web. The editing session is + // closed by the engine, but the text field widget stays focused + // in the framework. + _requestKeyboard(); + } + } + : null, + child: TextFieldTapRegion( + child: IgnorePointer( + ignoring: !enabled, + child: Container( + decoration: effectiveDecoration, + color: !enabled && effectiveDecoration == null ? disabledColor : null, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: Align( + alignment: Alignment(-1.0, _textAlignVertical.y), + widthFactor: 1.0, + heightFactor: 1.0, + child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle), + ), + ), + ), + ), + ), + ); + } +} + +enum _BaselineAlignedStackSlot { placeholder, editableText } + +class _BaselineAlignedStack + extends SlottedMultiChildRenderObjectWidget<_BaselineAlignedStackSlot, RenderBox> { + const _BaselineAlignedStack({ + required this.editableTextBaseline, + required this.placeholderBaseline, + required this.textAlignVertical, + required this.editableText, + this.placeholder, + }); + + final TextBaseline editableTextBaseline; + final TextBaseline placeholderBaseline; + final TextAlignVertical textAlignVertical; + final Widget editableText; + final Widget? placeholder; + + @override + Iterable<_BaselineAlignedStackSlot> get slots => _BaselineAlignedStackSlot.values; + + @override + Widget? childForSlot(_BaselineAlignedStackSlot slot) { + return switch (slot) { + _BaselineAlignedStackSlot.placeholder => placeholder, + _BaselineAlignedStackSlot.editableText => editableText, + }; + } + + @override + _RenderBaselineAlignedStack createRenderObject(BuildContext context) { + return _RenderBaselineAlignedStack( + textAlignVertical: textAlignVertical, + editableTextBaseline: editableTextBaseline, + placeholderBaseline: placeholderBaseline, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderBaselineAlignedStack renderObject) { + renderObject + ..textAlignVertical = textAlignVertical + ..editableTextBaseline = editableTextBaseline + ..placeholderBaseline = placeholderBaseline; + } +} + +class _BaselineAlignedStackParentData extends ContainerBoxParentData {} + +class _RenderBaselineAlignedStack extends RenderBox + with SlottedContainerRenderObjectMixin<_BaselineAlignedStackSlot, RenderBox> { + _RenderBaselineAlignedStack({ + required TextAlignVertical textAlignVertical, + required TextBaseline editableTextBaseline, + required TextBaseline placeholderBaseline, + }) : _textAlignVertical = textAlignVertical, + _editableTextBaseline = editableTextBaseline, + _placeholderBaseline = placeholderBaseline; + + TextAlignVertical get textAlignVertical => _textAlignVertical; + TextAlignVertical _textAlignVertical; + set textAlignVertical(TextAlignVertical value) { + if (_textAlignVertical == value) { + return; + } + _textAlignVertical = value; + markNeedsLayout(); + } + + TextBaseline get editableTextBaseline => _editableTextBaseline; + TextBaseline _editableTextBaseline; + set editableTextBaseline(TextBaseline value) { + if (_editableTextBaseline == value) { + return; + } + _editableTextBaseline = value; + markNeedsLayout(); + } + + TextBaseline get placeholderBaseline => _placeholderBaseline; + TextBaseline _placeholderBaseline; + set placeholderBaseline(TextBaseline value) { + if (_placeholderBaseline == value) { + return; + } + _placeholderBaseline = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _BaselineAlignedStackParentData) { + child.parentData = _BaselineAlignedStackParentData(); + } + } + + RenderBox? get _placeholderChild { + return childForSlot(_BaselineAlignedStackSlot.placeholder); + } + + RenderBox get _editableTextChild { + final RenderBox? child = childForSlot(_BaselineAlignedStackSlot.editableText); + assert(child != null); + return child!; + } + + @override + double computeMinIntrinsicHeight(double width) { + return math.max( + _placeholderChild?.getMinIntrinsicHeight(width) ?? 0.0, + _editableTextChild.getMinIntrinsicHeight(width), + ); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return math.max( + _placeholderChild?.getMaxIntrinsicHeight(width) ?? 0.0, + _editableTextChild.getMaxIntrinsicHeight(width), + ); + } + + @override + double computeMinIntrinsicWidth(double height) { + return math.max( + _placeholderChild?.getMinIntrinsicWidth(height) ?? 0.0, + _editableTextChild.getMinIntrinsicWidth(height), + ); + } + + @override + double computeMaxIntrinsicWidth(double height) { + return math.max( + _placeholderChild?.getMaxIntrinsicWidth(height) ?? 0.0, + _editableTextChild.getMaxIntrinsicWidth(height), + ); + } + + @override + void performLayout() { + assert(constraints.hasTightWidth); + final RenderBox? placeholder = _placeholderChild; + final RenderBox editableText = _editableTextChild; + + final editableTextParentData = editableText.parentData! as _BaselineAlignedStackParentData; + final placeholderParentData = placeholder?.parentData as _BaselineAlignedStackParentData?; + + size = _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.layoutChild, + getBaseline: ChildLayoutHelper.getBaseline, + ); + + final double editableTextBaselineValue = editableText.getDistanceToBaseline( + editableTextBaseline, + )!; + final double? placeholderBaselineValue = placeholder?.getDistanceToBaseline( + placeholderBaseline, + ); + + assert(placeholder != null || placeholderBaselineValue == null); + final Offset baselineDiff = placeholderBaselineValue != null + ? Offset(0.0, editableTextBaselineValue - placeholderBaselineValue) + : Offset.zero; + final verticalAlignment = Alignment(0.0, textAlignVertical.y); + + editableTextParentData.offset = verticalAlignment.alongOffset( + size - editableText.size as Offset, + ); + // Baseline-align the placeholder to the editable text. + placeholderParentData?.offset = editableTextParentData.offset + baselineDiff; + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? placeholder = _placeholderChild; + final RenderBox editableText = _editableTextChild; + + if (placeholder != null) { + final placeholderParentData = placeholder.parentData! as _BaselineAlignedStackParentData; + context.paintChild(placeholder, offset + placeholderParentData.offset); + } + + final editableTextParentData = editableText.parentData! as _BaselineAlignedStackParentData; + context.paintChild(editableText, offset + editableTextParentData.offset); + } + + @override + Size computeDryLayout(covariant BoxConstraints constraints) { + return _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + getBaseline: ChildLayoutHelper.getDryBaseline, + ); + } + + Size _computeSize({ + required BoxConstraints constraints, + required ChildLayouter layoutChild, + required ChildBaselineGetter getBaseline, + }) { + double width = constraints.minWidth; + double height = constraints.minHeight; + + final RenderBox editableText = _editableTextChild; + final Size editableTextSize = layoutChild(editableText, constraints); + final double editableTextBaselineValue = getBaseline( + editableText, + constraints, + editableTextBaseline, + )!; + final double editableTextDescent = editableTextSize.height - editableTextBaselineValue; + + Size? placeholderSize; + double? placeholderBaselineValue; + final RenderBox? placeholder = _placeholderChild; + if (placeholder != null) { + placeholderSize = layoutChild(placeholder, constraints); + width = math.max(width, placeholderSize.width); + placeholderBaselineValue = getBaseline(placeholder, constraints, placeholderBaseline); + final double placeholderDescent = placeholderSize.height - placeholderBaselineValue!; + // The size is the sum of the placeholder's max ascent and descent and the + // editable text's max ascent and descent. + final double maxExtentBaseline = + math.max(editableTextBaselineValue, placeholderBaselineValue) + + math.max(editableTextDescent, placeholderDescent); + height = math.max(height, maxExtentBaseline); + } + + height = math.max(height, editableTextSize.height); + width = math.max(width, editableTextSize.width); + final size = Size(width, height); + assert(size.isFinite); + return constraints.constrain(size); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox editableText = _editableTextChild; + final editableTextParentData = editableText.parentData! as _BaselineAlignedStackParentData; + + return result.addWithPaintOffset( + offset: editableTextParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - editableTextParentData.offset); + return editableText.hitTest(result, position: transformed); + }, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/text_form_field_row.dart b/packages/cupertino_ui/lib/src/text_form_field_row.dart new file mode 100644 index 000000000000..db19f8d0becc --- /dev/null +++ b/packages/cupertino_ui/lib/src/text_form_field_row.dart @@ -0,0 +1,398 @@ +// 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:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'colors.dart'; +import 'form_row.dart'; +import 'text_field.dart'; + +/// Creates a [CupertinoFormRow] containing a [FormField] that wraps +/// a [CupertinoTextField]. +/// +/// A [Form] ancestor is not required. The [Form] allows one to +/// save, reset, or validate multiple fields at once. To use without a [Form], +/// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to +/// save or reset the form field. +/// +/// When a [controller] is specified, its [TextEditingController.text] +/// defines the [initialValue]. If this [FormField] is part of a scrolling +/// container that lazily constructs its children, like a [ListView] or a +/// [CustomScrollView], then a [controller] should be specified. +/// The controller's lifetime should be managed by a stateful widget ancestor +/// of the scrolling container. +/// +/// The [prefix] parameter is displayed at the start of the row. Standard iOS +/// guidelines encourage passing a [Text] widget to [prefix] to detail the +/// nature of the input. +/// +/// The [padding] parameter is used to pad the contents of the row. It is +/// directly passed to [CupertinoFormRow]. If the [padding] +/// parameter is null, [CupertinoFormRow] constructs its own default +/// padding (which is the standard form row padding in iOS.) If no edge +/// insets are intended, explicitly pass [EdgeInsets.zero] to [padding]. +/// +/// If a [controller] is not specified, [initialValue] can be used to give +/// the automatically generated controller an initial value. +/// +/// Consider calling [TextEditingController.dispose] of the [controller], if one +/// is specified, when it is no longer needed. This will ensure we discard any +/// resources used by the object. +/// +/// For documentation about the various parameters, see the +/// [CupertinoTextField] class and [CupertinoTextField.borderless], +/// the constructor. +/// +/// {@tool snippet} +/// +/// Creates a [CupertinoTextFormFieldRow] with a leading text and validator +/// function. +/// +/// If the user enters valid text, the CupertinoTextField appears normally +/// without any warnings to the user. +/// +/// If the user enters invalid text, the error message returned from the +/// validator function is displayed in dark red underneath the input. +/// +/// ```dart +/// CupertinoTextFormFieldRow( +/// prefix: const Text('Username'), +/// onSaved: (String? value) { +/// // This optional block of code can be used to run +/// // code when the user saves the form. +/// }, +/// validator: (String? value) { +/// return (value != null && value.contains('@')) ? 'Do not use the @ char.' : null; +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to move the focus to the next field when the user +/// presses the SPACE key. +/// +/// ** See code in examples/api/lib/cupertino/text_form_field_row/cupertino_text_form_field_row.1.dart ** +/// {@end-tool} +class CupertinoTextFormFieldRow extends FormField { + /// Creates a [CupertinoFormRow] containing a [FormField] that wraps + /// a [CupertinoTextField]. + /// + /// When a [controller] is specified, [initialValue] must be null (the + /// default). If [controller] is null, then a [TextEditingController] + /// will be constructed automatically and its `text` will be initialized + /// to [initialValue] or the empty string. + /// + /// The [prefix] parameter is displayed at the start of the row. Standard iOS + /// guidelines encourage passing a [Text] widget to [prefix] to detail the + /// nature of the input. + /// + /// The [padding] parameter is used to pad the contents of the row. It is + /// directly passed to [CupertinoFormRow]. If the [padding] + /// parameter is null, [CupertinoFormRow] constructs its own default + /// padding (which is the standard form row padding in iOS.) If no edge + /// insets are intended, explicitly pass [EdgeInsets.zero] to [padding]. + /// + /// For documentation about the various parameters, see the + /// [CupertinoTextField] class and [CupertinoTextField.borderless], + /// the constructor. + CupertinoTextFormFieldRow({ + super.key, + this.prefix, + this.padding, + this.controller, + String? initialValue, + FocusNode? focusNode, + BoxDecoration? decoration, + TextInputType? keyboardType, + TextCapitalization textCapitalization = TextCapitalization.none, + TextInputAction? textInputAction, + TextStyle? style, + StrutStyle? strutStyle, + TextDirection? textDirection, + TextAlign textAlign = TextAlign.start, + TextAlignVertical? textAlignVertical, + bool autofocus = false, + bool readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + ToolbarOptions? toolbarOptions, + bool? showCursor, + String obscuringCharacter = '•', + bool obscureText = false, + bool autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + bool enableSuggestions = true, + int? maxLines = 1, + int? minLines, + bool expands = false, + int? maxLength, + this.onChanged, + GestureTapCallback? onTap, + VoidCallback? onEditingComplete, + ValueChanged? onFieldSubmitted, + super.onSaved, + super.validator, + List? inputFormatters, + bool? enabled, + double cursorWidth = 2.0, + double? cursorHeight, + Color? cursorColor, + Brightness? keyboardAppearance, + EdgeInsets scrollPadding = const EdgeInsets.all(20.0), + bool enableInteractiveSelection = true, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + Iterable? autofillHints, + AutovalidateMode super.autovalidateMode = AutovalidateMode.disabled, + String? placeholder, + TextStyle? placeholderStyle = const TextStyle( + fontWeight: FontWeight.w400, + color: CupertinoColors.placeholderText, + ), + EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder, + SpellCheckConfiguration? spellCheckConfiguration, + ui.BoxHeightStyle? selectionHeightStyle, + ui.BoxWidthStyle? selectionWidthStyle, + super.restorationId, + }) : assert(initialValue == null || controller == null), + assert(obscuringCharacter.length == 1), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength > 0), + super( + initialValue: controller?.text ?? initialValue ?? '', + builder: (FormFieldState field) { + final state = field as _CupertinoTextFormFieldRowState; + + void onChangedHandler(String value) { + field.didChange(value); + onChanged?.call(value); + } + + return CupertinoFormRow( + prefix: prefix, + padding: padding, + error: (field.errorText == null) ? null : Text(field.errorText!), + child: UnmanagedRestorationScope( + bucket: field.bucket, + child: CupertinoTextField.borderless( + restorationId: restorationId, + controller: state._effectiveController, + focusNode: focusNode, + keyboardType: keyboardType, + decoration: decoration, + textInputAction: textInputAction, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textAlignVertical: textAlignVertical, + textCapitalization: textCapitalization, + textDirection: textDirection, + autofocus: autofocus, + toolbarOptions: toolbarOptions, + readOnly: readOnly, + showCursor: showCursor, + obscuringCharacter: obscuringCharacter, + obscureText: obscureText, + autocorrect: autocorrect, + smartDashesType: smartDashesType, + smartQuotesType: smartQuotesType, + enableSuggestions: enableSuggestions, + maxLines: maxLines, + minLines: minLines, + expands: expands, + maxLength: maxLength, + onChanged: onChangedHandler, + onTap: onTap, + onEditingComplete: onEditingComplete, + onSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + enabled: enabled ?? true, + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorColor: cursorColor, + scrollPadding: scrollPadding, + scrollPhysics: scrollPhysics, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: enableInteractiveSelection, + selectionControls: selectionControls, + autofillHints: autofillHints, + placeholder: placeholder, + placeholderStyle: placeholderStyle, + contextMenuBuilder: contextMenuBuilder, + spellCheckConfiguration: spellCheckConfiguration, + selectionHeightStyle: + selectionHeightStyle ?? EditableText.defaultSelectionHeightStyle, + selectionWidthStyle: + selectionWidthStyle ?? EditableText.defaultSelectionWidthStyle, + ), + ), + ); + }, + ); + + /// A widget that is displayed at the start of the row. + /// + /// The [prefix] widget is displayed at the start of the row. Standard iOS + /// guidelines encourage passing a [Text] widget to [prefix] to detail the + /// nature of the input. + final Widget? prefix; + + /// Content padding for the row. + /// + /// The [padding] widget is passed to [CupertinoFormRow]. If the [padding] + /// parameter is null, [CupertinoFormRow] constructs its own default + /// padding, which is the standard form row padding in iOS. + /// + /// If no edge insets are intended, explicitly pass [EdgeInsets.zero] to + /// [padding]. + final EdgeInsetsGeometry? padding; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController] and + /// initialize its [TextEditingController.text] with [initialValue]. + final TextEditingController? controller; + + /// {@macro flutter.material.TextFormField.onChanged} + final ValueChanged? onChanged; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return CupertinoAdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + } + + @override + FormFieldState createState() => _CupertinoTextFormFieldRowState(); +} + +class _CupertinoTextFormFieldRowState extends FormFieldState { + RestorableTextEditingController? _controller; + + TextEditingController get _effectiveController => + _cupertinoTextFormFieldRow.controller ?? _controller!.value; + + CupertinoTextFormFieldRow get _cupertinoTextFormFieldRow => + super.widget as CupertinoTextFormFieldRow; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + super.restoreState(oldBucket, initialRestore); + if (_controller != null) { + _registerController(); + } + // This makes sure to update the internal [FormFieldState] value to sync up with + // text editing controller value. + setValue(_effectiveController.text); + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + void initState() { + super.initState(); + if (_cupertinoTextFormFieldRow.controller == null) { + _createLocalController( + widget.initialValue != null ? TextEditingValue(text: widget.initialValue!) : null, + ); + } else { + _cupertinoTextFormFieldRow.controller!.addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(CupertinoTextFormFieldRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (_cupertinoTextFormFieldRow.controller != oldWidget.controller) { + oldWidget.controller?.removeListener(_handleControllerChanged); + _cupertinoTextFormFieldRow.controller?.addListener(_handleControllerChanged); + + if (oldWidget.controller != null && _cupertinoTextFormFieldRow.controller == null) { + _createLocalController(oldWidget.controller!.value); + } + + if (_cupertinoTextFormFieldRow.controller != null) { + setValue(_cupertinoTextFormFieldRow.controller!.text); + if (oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + } + } + } + + @override + void dispose() { + _cupertinoTextFormFieldRow.controller?.removeListener(_handleControllerChanged); + _controller?.dispose(); + super.dispose(); + } + + @override + void didChange(String? value) { + super.didChange(value); + + if (value != null && _effectiveController.text != value) { + _effectiveController.value = TextEditingValue(text: value); + } + } + + @override + void reset() { + // Set the controller value before calling super.reset() to let + // _handleControllerChanged suppress the change. + _effectiveController.value = TextEditingValue(text: widget.initialValue ?? ''); + super.reset(); + _cupertinoTextFormFieldRow.onChanged?.call(_effectiveController.text); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController.text != value) { + didChange(_effectiveController.text); + } + } +} diff --git a/packages/cupertino_ui/lib/src/text_selection.dart b/packages/cupertino_ui/lib/src/text_selection.dart new file mode 100644 index 000000000000..1c8e2b0b482a --- /dev/null +++ b/packages/cupertino_ui/lib/src/text_selection.dart @@ -0,0 +1,323 @@ +// 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 'package:flutter/foundation.dart' show ValueListenable, clampDouble; +import 'package:flutter/widgets.dart'; + +import 'localizations.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_button.dart'; +import 'theme.dart'; + +// Read off from the output on iOS 12. This color does not vary with the +// application's theme color. +const double _kSelectionHandleOverlap = 1.5; +// Extracted from https://developer.apple.com/design/resources/. +const double _kSelectionHandleRadius = 6; + +// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the +// screen. Eyeballed value. +const double _kArrowScreenPadding = 26.0; + +/// Draws a single text selection handle with a bar and a ball. +class _CupertinoTextSelectionHandlePainter extends CustomPainter { + const _CupertinoTextSelectionHandlePainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + const halfStrokeWidth = 1.0; + final paint = Paint()..color = color; + final circle = Rect.fromCircle( + center: const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius), + radius: _kSelectionHandleRadius, + ); + final line = Rect.fromPoints( + const Offset( + _kSelectionHandleRadius - halfStrokeWidth, + 2 * _kSelectionHandleRadius - _kSelectionHandleOverlap, + ), + Offset(_kSelectionHandleRadius + halfStrokeWidth, size.height), + ); + final path = Path() + ..addOval(circle) + // Draw line so it slightly overlaps the circle. + ..addRect(line); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_CupertinoTextSelectionHandlePainter oldPainter) => color != oldPainter.color; +} + +/// iOS Cupertino styled text selection handle controls. +/// +/// Specifically does not manage the toolbar, which is left to +/// [EditableText.contextMenuBuilder]. +@Deprecated( + 'Use `CupertinoTextSelectionControls`. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', +) +class CupertinoTextSelectionHandleControls extends CupertinoTextSelectionControls + with TextSelectionHandleControls {} + +/// iOS Cupertino styled text selection controls. +/// +/// The [cupertinoTextSelectionControls] global variable has a +/// suitable instance of this class. +class CupertinoTextSelectionControls extends TextSelectionControls { + /// Returns the size of the Cupertino handle. + @override + Size getHandleSize(double textLineHeight) { + return Size( + _kSelectionHandleRadius * 2, + textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap, + ); + } + + /// Builder for iOS-style copy/paste text selection toolbar. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List endpoints, + TextSelectionDelegate delegate, + ValueListenable? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return _CupertinoTextSelectionControlsToolbar( + clipboardStatus: clipboardStatus, + endpoints: endpoints, + globalEditableRegion: globalEditableRegion, + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, + selectionMidpoint: selectionMidpoint, + textLineHeight: textLineHeight, + ); + } + + /// Builder for iOS text selection edges. + @override + Widget buildHandle( + BuildContext context, + TextSelectionHandleType type, + double textLineHeight, [ + VoidCallback? onTap, + ]) { + // iOS selection handles do not respond to taps. + final Size desiredSize; + final Widget handle; + + final Widget customPaint = CustomPaint( + painter: _CupertinoTextSelectionHandlePainter( + CupertinoTheme.of(context).selectionHandleColor, + ), + ); + + // [buildHandle]'s widget is positioned at the selection cursor's bottom + // baseline. We transform the handle such that the SizedBox is superimposed + // on top of the text selection endpoints. + switch (type) { + case TextSelectionHandleType.left: + desiredSize = getHandleSize(textLineHeight); + handle = SizedBox.fromSize(size: desiredSize, child: customPaint); + return handle; + case TextSelectionHandleType.right: + desiredSize = getHandleSize(textLineHeight); + handle = SizedBox.fromSize(size: desiredSize, child: customPaint); + return Transform( + transform: Matrix4.identity() + ..translateByDouble(desiredSize.width / 2, desiredSize.height / 2, 0, 1) + ..rotateZ(math.pi) + ..translateByDouble(-desiredSize.width / 2, -desiredSize.height / 2, 0, 1), + child: handle, + ); + // iOS should draw an invisible box so the handle can still receive gestures + // on collapsed selections. + case TextSelectionHandleType.collapsed: + return SizedBox.fromSize(size: getHandleSize(textLineHeight)); + } + } + + /// Gets anchor for cupertino-style text selection handles. + /// + /// See [TextSelectionControls.getHandleAnchor]. + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + final Size handleSize = getHandleSize(textLineHeight); + + switch (type) { + // The circle is at the top for the left handle, and the anchor point is + // all the way at the bottom of the line. + case TextSelectionHandleType.left: + return Offset(handleSize.width / 2, handleSize.height); + // The right handle is vertically flipped, and the anchor point is near + // the top of the circle to give slight overlap. + case TextSelectionHandleType.right: + return Offset( + handleSize.width / 2, + handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap, + ); + // A collapsed handle anchors itself so that it's centered. + case TextSelectionHandleType.collapsed: + return Offset( + handleSize.width / 2, + textLineHeight + (handleSize.height - textLineHeight) / 2, + ); + } + } +} + +// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is +// deleted, when users should migrate back to cupertinoTextSelectionControls. +// See https://github.com/flutter/flutter/pull/124262 +/// Text selection handle controls that follow iOS design conventions. +final TextSelectionControls cupertinoTextSelectionHandleControls = + CupertinoTextSelectionHandleControls(); + +/// Text selection controls that follow iOS design conventions. +final TextSelectionControls cupertinoTextSelectionControls = CupertinoTextSelectionControls(); + +// Generates the child that's passed into CupertinoTextSelectionToolbar. +class _CupertinoTextSelectionControlsToolbar extends StatefulWidget { + const _CupertinoTextSelectionControlsToolbar({ + required this.clipboardStatus, + required this.endpoints, + required this.globalEditableRegion, + required this.handleCopy, + required this.handleCut, + required this.handlePaste, + required this.handleSelectAll, + required this.selectionMidpoint, + required this.textLineHeight, + }); + + final ValueListenable? clipboardStatus; + final List endpoints; + final Rect globalEditableRegion; + final VoidCallback? handleCopy; + final VoidCallback? handleCut; + final VoidCallback? handlePaste; + final VoidCallback? handleSelectAll; + final Offset selectionMidpoint; + final double textLineHeight; + + @override + _CupertinoTextSelectionControlsToolbarState createState() => + _CupertinoTextSelectionControlsToolbarState(); +} + +class _CupertinoTextSelectionControlsToolbarState + extends State<_CupertinoTextSelectionControlsToolbar> { + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + + @override + void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clipboardStatus != widget.clipboardStatus) { + oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + } + + @override + void dispose() { + widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Don't render the menu until the state of the clipboard is known. + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) { + return const SizedBox.shrink(); + } + + assert(debugCheckHasMediaQuery(context)); + final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); + + // The toolbar should appear below the TextField when there is not enough + // space above the TextField to show it, assuming there's always enough + // space at the bottom in this case. + final double anchorX = clampDouble( + widget.selectionMidpoint.dx + widget.globalEditableRegion.left, + _kArrowScreenPadding + mediaQueryPadding.left, + MediaQuery.widthOf(context) - mediaQueryPadding.right - _kArrowScreenPadding, + ); + + final double topAmountInEditableRegion = + widget.endpoints.first.point.dy - widget.textLineHeight; + final double anchorTop = + math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top; + + // The y-coordinate has to be calculated instead of directly quoting + // selectionMidpoint.dy, since the caller + // (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is + // going to be facing up or down. + final anchorAbove = Offset(anchorX, anchorTop); + final anchorBelow = Offset( + anchorX, + widget.endpoints.last.point.dy + widget.globalEditableRegion.top, + ); + + final items = []; + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + final Widget onePhysicalPixelVerticalDivider = SizedBox( + width: 1.0 / MediaQuery.devicePixelRatioOf(context), + ); + + void addToolbarButton(String text, VoidCallback onPressed) { + if (items.isNotEmpty) { + items.add(onePhysicalPixelVerticalDivider); + } + + items.add(CupertinoTextSelectionToolbarButton.text(onPressed: onPressed, text: text)); + } + + if (widget.handleCut != null) { + addToolbarButton(localizations.cutButtonLabel, widget.handleCut!); + } + if (widget.handleCopy != null) { + addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!); + } + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.pasteable) { + addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!); + } + if (widget.handleSelectAll != null) { + addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!); + } + + // If there is no option available, build an empty widget. + if (items.isEmpty) { + return const SizedBox.shrink(); + } + + return CupertinoTextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: items, + ); + } +} diff --git a/packages/cupertino_ui/lib/src/text_selection_toolbar.dart b/packages/cupertino_ui/lib/src/text_selection_toolbar.dart new file mode 100644 index 000000000000..c23ac7a4fa9d --- /dev/null +++ b/packages/cupertino_ui/lib/src/text_selection_toolbar.dart @@ -0,0 +1,1315 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:collection'; +import 'dart:math' as math show pi; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' show Brightness, clampDouble; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'text_selection_toolbar_button.dart'; +import 'theme.dart'; + +// The radius of the toolbar RRect shape. +// Value extracted from https://developer.apple.com/design/resources/. +const Radius _kToolbarBorderRadius = Radius.circular(8.0); + +// Vertical distance between the tip of the arrow and the line of text the arrow +// is pointing to. The value used here is eyeballed. +const double _kToolbarContentDistance = 8.0; + +// The size of the arrow pointing to the anchor. Eyeballed value. +const Size _kToolbarArrowSize = Size(14.0, 7.0); + +// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the +// screen. Eyeballed value. +const double _kArrowScreenPadding = 26.0; + +// The size and thickness of the chevron icon used for navigating between toolbar pages. +// Eyeballed values. +const double _kToolbarChevronSize = 10.0; +const double _kToolbarChevronThickness = 2.0; + +// Color was measured from a screenshot of iOS 16.0.2 +// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507. +const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFF6F6F6), + darkColor: Color(0xFF222222), +); + +// Color was measured from a screenshot of iOS 16.0.2. +const CupertinoDynamicColor _kToolbarDividerColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFD6D6D6), + darkColor: Color(0xFF424242), +); + +const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.black, + darkColor: CupertinoColors.white, +); + +const Duration _kToolbarTransitionDuration = Duration(milliseconds: 125); + +/// The type for a Function that builds a toolbar's container with the given +/// child. +/// +/// The anchor is provided in global coordinates. +/// +/// See also: +/// +/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is of this type. +/// * [TextSelectionToolbar.toolbarBuilder], which is similar, but for an +/// Material-style toolbar. +typedef CupertinoToolbarBuilder = + Widget Function(BuildContext context, Offset anchorAbove, Offset anchorBelow, Widget child); + +/// An iOS-style text selection toolbar. +/// +/// Typically displays buttons for text manipulation, e.g. copying and pasting +/// text. +/// +/// Tries to position itself above [anchorAbove], but if it doesn't fit, then +/// it positions itself below [anchorBelow]. +/// +/// If any children don't fit in the menu, an overflow menu will automatically +/// be created. +/// +/// See also: +/// +/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current +/// platform. +/// * [TextSelectionToolbar], which is similar, but builds an Android-style +/// toolbar. +class CupertinoTextSelectionToolbar extends StatelessWidget { + /// Creates an instance of CupertinoTextSelectionToolbar. + const CupertinoTextSelectionToolbar({ + super.key, + required this.anchorAbove, + required this.anchorBelow, + required this.children, + this.toolbarBuilder = _defaultToolbarBuilder, + }) : assert(children.length > 0); + + /// {@macro flutter.material.TextSelectionToolbar.anchorAbove} + final Offset anchorAbove; + + /// {@macro flutter.material.TextSelectionToolbar.anchorBelow} + final Offset anchorBelow; + + /// {@macro flutter.material.TextSelectionToolbar.children} + /// + /// See also: + /// * [CupertinoTextSelectionToolbarButton], which builds a default + /// Cupertino-style text selection toolbar text button. + final List children; + + /// {@macro flutter.material.TextSelectionToolbar.toolbarBuilder} + /// + /// The given anchor and isAbove can be used to position an arrow, as in the + /// default Cupertino toolbar. + final CupertinoToolbarBuilder toolbarBuilder; + + /// Minimal padding from all edges of the selection toolbar to all edges of the + /// viewport. + /// + /// See also: + /// + /// * [SpellCheckSuggestionsToolbar], which uses this same value for its + /// padding from the edges of the viewport. + /// * [TextSelectionToolbar], which uses this same value as well. + static const double kToolbarScreenPadding = 8.0; + + // Builds a toolbar just like the default iOS toolbar, with the right color + // background and a rounded cutout with an arrow. + static Widget _defaultToolbarBuilder( + BuildContext context, + Offset anchorAbove, + Offset anchorBelow, + Widget child, + ) { + return _CupertinoTextSelectionToolbarShape( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + shadowColor: CupertinoTheme.brightnessOf(context) == Brightness.light + ? CupertinoColors.black.withOpacity(0.2) + : null, + child: ColoredBox(color: _kToolbarBackgroundColor.resolveFrom(context), child: child), + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); + + final double paddingAbove = mediaQueryPadding.top + kToolbarScreenPadding; + + // The arrow, which points to the anchor, has some margin so it can't get + // too close to the horizontal edges of the screen. + final double leftMargin = _kArrowScreenPadding + mediaQueryPadding.left; + final double rightMargin = + MediaQuery.widthOf(context) - mediaQueryPadding.right - _kArrowScreenPadding; + + final anchorAboveAdjusted = Offset( + clampDouble(anchorAbove.dx, leftMargin, rightMargin), + anchorAbove.dy - _kToolbarContentDistance - paddingAbove, + ); + final anchorBelowAdjusted = Offset( + clampDouble(anchorBelow.dx, leftMargin, rightMargin), + anchorBelow.dy + _kToolbarContentDistance - paddingAbove, + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + kToolbarScreenPadding, + paddingAbove, + kToolbarScreenPadding, + kToolbarScreenPadding, + ), + child: CustomSingleChildLayout( + delegate: TextSelectionToolbarLayoutDelegate( + anchorAbove: anchorAboveAdjusted, + anchorBelow: anchorBelowAdjusted, + ), + child: _CupertinoTextSelectionToolbarContent( + anchorAbove: anchorAboveAdjusted, + anchorBelow: anchorBelowAdjusted, + toolbarBuilder: toolbarBuilder, + children: children, + ), + ), + ); + } +} + +// Clips the child so that it has the shape of the default iOS text selection +// toolbar, with rounded corners and an arrow pointing at the anchor. +// +// The anchor should be in global coordinates. +class _CupertinoTextSelectionToolbarShape extends SingleChildRenderObjectWidget { + const _CupertinoTextSelectionToolbarShape({ + required Offset anchorAbove, + required Offset anchorBelow, + Color? shadowColor, + super.child, + }) : _anchorAbove = anchorAbove, + _anchorBelow = anchorBelow, + _shadowColor = shadowColor; + + final Offset _anchorAbove; + final Offset _anchorBelow; + final Color? _shadowColor; + + @override + _RenderCupertinoTextSelectionToolbarShape createRenderObject(BuildContext context) => + _RenderCupertinoTextSelectionToolbarShape(_anchorAbove, _anchorBelow, _shadowColor, null); + + @override + void updateRenderObject( + BuildContext context, + _RenderCupertinoTextSelectionToolbarShape renderObject, + ) { + renderObject + ..anchorAbove = _anchorAbove + ..anchorBelow = _anchorBelow + ..shadowColor = _shadowColor; + } +} + +// Clips the child into the shape of the default iOS text selection toolbar. +// +// The shape is a rounded rectangle with a protruding arrow pointing at the +// given anchor in the direction indicated by isAbove. +// +// In order to allow the child to render itself independent of isAbove, its +// height is clipped on both the top and the bottom, leaving the arrow remaining +// on the necessary side. +class _RenderCupertinoTextSelectionToolbarShape extends RenderShiftedBox { + _RenderCupertinoTextSelectionToolbarShape( + this._anchorAbove, + this._anchorBelow, + this._shadowColor, + super.child, + ); + + @override + bool get isRepaintBoundary => true; + + Offset get anchorAbove => _anchorAbove; + Offset _anchorAbove; + set anchorAbove(Offset value) { + if (value == _anchorAbove) { + return; + } + _anchorAbove = value; + markNeedsLayout(); + } + + Offset get anchorBelow => _anchorBelow; + Offset _anchorBelow; + set anchorBelow(Offset value) { + if (value == _anchorBelow) { + return; + } + _anchorBelow = value; + markNeedsLayout(); + } + + Color? get shadowColor => _shadowColor; + Color? _shadowColor; + set shadowColor(Color? value) { + if (value == _shadowColor) { + return; + } + _shadowColor = value; + markNeedsPaint(); + } + + bool _isAbove(double childHeight) => anchorAbove.dy >= childHeight - _kToolbarArrowSize.height; + + BoxConstraints _constraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: _kToolbarArrowSize.width + _kToolbarBorderRadius.x * 2, + ).enforce(constraints.loosen()); + } + + Offset _computeChildOffset(Size childSize) { + return Offset(0.0, _isAbove(childSize.height) ? -_kToolbarArrowSize.height : 0.0); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final BoxConstraints enforcedConstraint = _constraintsForChild(constraints); + final double? result = child.getDryBaseline(enforcedConstraint, baseline); + return result == null + ? null + : result + _computeChildOffset(child.getDryLayout(enforcedConstraint)).dy; + } + + @override + void performLayout() { + final RenderBox? child = this.child; + if (child == null) { + return; + } + + child.layout(_constraintsForChild(constraints), parentUsesSize: true); + // The buttons are padded on both top and bottom sufficiently to have + // the arrow clipped out of it on either side. By + // using this approach, the buttons don't need any special padding that + // depends on isAbove. + // The height of one arrow will be clipped off of the child, so adjust the + // size and position to remove that piece from the layout. + final childParentData = child.parentData! as BoxParentData; + childParentData.offset = _computeChildOffset(child.size); + size = Size(child.size.width, child.size.height - _kToolbarArrowSize.height); + } + + // Returns the RRect inside which the child is painted. + RRect _shapeRRect(RenderBox child) { + final Rect rect = + Offset(0.0, _kToolbarArrowSize.height) & + Size(child.size.width, child.size.height - _kToolbarArrowSize.height * 2); + return RRect.fromRectAndRadius(rect, _kToolbarBorderRadius).scaleRadii(); + } + + // Adds the given `rrect` to the current `path`, starting from the last point + // in `path` and ends after the last corner of the rrect (closest corner to + // `startAngle` in the counterclockwise direction), without closing the path. + // + // The `startAngle` argument must be a multiple of pi / 2, with 0 being the + // positive half of the x-axis, and pi / 2 being the negative half of the + // y-axis. + // + // For instance, if `startAngle` equals pi/2 then this method draws a line + // segment to the bottom-left corner of `rrect` from the last point in `path`, + // and follows the `rrect` path clockwise until the bottom-right corner is + // added, then this method returns the mutated path without closing it. + static Path _addRRectToPath(Path path, RRect rrect, {required double startAngle}) { + const double halfPI = math.pi / 2; + assert(startAngle % halfPI == 0.0); + final Rect rect = rrect.outerRect; + + final rrectCorners = <(Offset, Radius)>[ + (rect.bottomRight, -rrect.brRadius), + (rect.bottomLeft, Radius.elliptical(rrect.blRadiusX, -rrect.blRadiusY)), + (rect.topLeft, rrect.tlRadius), + (rect.topRight, Radius.elliptical(-rrect.trRadiusX, rrect.trRadiusY)), + ]; + + // Add the 4 corners to the path clockwise. Convert radians to quadrants + // to avoid fp arithmetics. The order is br -> bl -> tl -> tr if the starting + // angle is 0. + final int startQuadrantIndex = startAngle ~/ halfPI; + for (var i = startQuadrantIndex; i < rrectCorners.length + startQuadrantIndex; i += 1) { + final (Offset vertex, Radius rectCenterOffset) = rrectCorners[i % rrectCorners.length]; + final otherVertex = Offset( + vertex.dx + 2 * rectCenterOffset.x, + vertex.dy + 2 * rectCenterOffset.y, + ); + final rect = Rect.fromPoints(vertex, otherVertex); + path.arcTo(rect, halfPI * i, halfPI, false); + } + return path; + } + + // The path is described in the toolbar child's coordinate system. + Path _clipPath(RenderBox child, RRect rrect) { + final path = Path(); + // If there isn't enough width for the arrow + radii, ignore the arrow. + // Because of the constraints we gave children in performLayout, this should + // only happen if the parent isn't wide enough which should be very rare, and + // when that happens the arrow won't be too useful anyways. + if (_kToolbarBorderRadius.x * 2 + _kToolbarArrowSize.width > size.width) { + return path..addRRect(rrect); + } + + final bool isAbove = _isAbove(child.size.height); + final Offset localAnchor = globalToLocal(isAbove ? _anchorAbove : _anchorBelow); + final double arrowTipX = clampDouble( + localAnchor.dx, + _kToolbarBorderRadius.x + _kToolbarArrowSize.width / 2, + size.width - _kToolbarArrowSize.width / 2 - _kToolbarBorderRadius.x, + ); + + // Draw the path clockwise, starting from the beginning side of the arrow. + if (isAbove) { + final double arrowBaseY = child.size.height - _kToolbarArrowSize.height; + final double arrowTipY = child.size.height; + path + ..moveTo( + arrowTipX + _kToolbarArrowSize.width / 2, + arrowBaseY, + ) // right side of the arrow triangle + ..lineTo(arrowTipX, arrowTipY) // The tip of the arrow + ..lineTo( + arrowTipX - _kToolbarArrowSize.width / 2, + arrowBaseY, + ); // left side of the arrow triangle + } else { + final double arrowBaseY = _kToolbarArrowSize.height; + const arrowTipY = 0.0; + path + ..moveTo( + arrowTipX - _kToolbarArrowSize.width / 2, + arrowBaseY, + ) // right side of the arrow triangle + ..lineTo(arrowTipX, arrowTipY) // The tip of the arrow + ..lineTo( + arrowTipX + _kToolbarArrowSize.width / 2, + arrowBaseY, + ); // left side of the arrow triangle + } + final double startAngle = isAbove ? math.pi / 2 : -math.pi / 2; + return _addRRectToPath(path, rrect, startAngle: startAngle)..close(); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = this.child; + if (child == null) { + return; + } + + final childParentData = child.parentData! as BoxParentData; + + final RRect rrect = _shapeRRect(child); + final Path clipPath = _clipPath(child, rrect); + + // If configured, paint the shadow beneath the shape. + if (_shadowColor != null) { + final boxShadow = BoxShadow(color: _shadowColor!, blurRadius: 15.0); + final RRect shadowRRect = RRect.fromLTRBR( + rrect.left, + rrect.top, + rrect.right, + rrect.bottom + _kToolbarArrowSize.height, + _kToolbarBorderRadius, + ).shift(offset + childParentData.offset + boxShadow.offset); + context.canvas.drawRRect(shadowRRect, boxShadow.toPaint()); + } + + _clipPathLayer.layer = context.pushClipPath( + needsCompositing, + offset + childParentData.offset, + Offset.zero & child.size, + clipPath, + (PaintingContext innerContext, Offset innerOffset) => + innerContext.paintChild(child, innerOffset), + oldLayer: _clipPathLayer.layer, + ); + } + + final LayerHandle _clipPathLayer = LayerHandle(); + Paint? _debugPaint; + + @override + void dispose() { + _clipPathLayer.layer = null; + super.dispose(); + } + + @override + void debugPaintSize(PaintingContext context, Offset offset) { + assert(() { + final RenderBox? child = this.child; + if (child == null) { + return true; + } + + final ui.Paint debugPaint = _debugPaint ??= Paint() + ..shader = ui.Gradient.linear( + Offset.zero, + const Offset(10.0, 10.0), + const [ + CupertinoColors.transparent, + Color(0xFFFF00FF), + Color(0xFFFF00FF), + CupertinoColors.transparent, + ], + const [0.25, 0.25, 0.75, 0.75], + TileMode.repeated, + ) + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + final childParentData = child.parentData! as BoxParentData; + final Path clipPath = _clipPath(child, _shapeRRect(child)); + context.canvas.drawPath(clipPath.shift(offset + childParentData.offset), debugPaint); + return true; + }()); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = this.child; + if (child == null) { + return false; + } + + // Positions outside of the clipped area of the child are not counted as + // hits. + final childParentData = child.parentData! as BoxParentData; + final hitBox = Rect.fromLTWH( + childParentData.offset.dx, + childParentData.offset.dy + _kToolbarArrowSize.height, + child.size.width, + child.size.height - _kToolbarArrowSize.height * 2, + ); + if (!hitBox.contains(position)) { + return false; + } + + return super.hitTestChildren(result, position: position); + } +} + +// A toolbar containing the given children. If they overflow the width +// available, then the menu will be paginated with the overflowing children +// displayed on subsequent pages. +// +// The anchor should be in global coordinates. +class _CupertinoTextSelectionToolbarContent extends StatefulWidget { + const _CupertinoTextSelectionToolbarContent({ + required this.anchorAbove, + required this.anchorBelow, + required this.toolbarBuilder, + required this.children, + }) : assert(children.length > 0); + + final Offset anchorAbove; + final Offset anchorBelow; + final List children; + final CupertinoToolbarBuilder toolbarBuilder; + + @override + _CupertinoTextSelectionToolbarContentState createState() => + _CupertinoTextSelectionToolbarContentState(); +} + +class _CupertinoTextSelectionToolbarContentState + extends State<_CupertinoTextSelectionToolbarContent> + with TickerProviderStateMixin { + // Controls the fading of the buttons within the menu during page transitions. + late AnimationController _controller; + int? _nextPage; + int _page = 0; + + final GlobalKey _toolbarItemsKey = GlobalKey(); + + void _onHorizontalDragEnd(DragEndDetails details) { + final double? velocity = details.primaryVelocity; + + if (velocity != null && velocity != 0) { + if (velocity > 0) { + _handlePreviousPage(); + } else { + _handleNextPage(); + } + } + } + + void _handleNextPage() { + final renderToolbar = _toolbarItemsKey.currentContext?.findRenderObject() as RenderBox?; + + if (renderToolbar is _RenderCupertinoTextSelectionToolbarItems && renderToolbar.hasNextPage) { + _controller.reverse(); + _controller.addStatusListener(_statusListener); + _nextPage = _page + 1; + } + } + + void _handlePreviousPage() { + final renderToolbar = _toolbarItemsKey.currentContext?.findRenderObject() as RenderBox?; + + if (renderToolbar is _RenderCupertinoTextSelectionToolbarItems && + renderToolbar.hasPreviousPage) { + _controller.reverse(); + _controller.addStatusListener(_statusListener); + _nextPage = _page - 1; + } + } + + void _statusListener(AnimationStatus status) { + if (!status.isDismissed) { + return; + } + + setState(() { + _page = _nextPage!; + _nextPage = null; + }); + _controller.forward(); + _controller.removeStatusListener(_statusListener); + } + + @override + void initState() { + super.initState(); + _controller = AnimationController( + value: 1.0, + vsync: this, + // This was eyeballed on a physical iOS device running iOS 13. + duration: _kToolbarTransitionDuration, + ); + } + + @override + void didUpdateWidget(_CupertinoTextSelectionToolbarContent oldWidget) { + super.didUpdateWidget(oldWidget); + // If the children are changing, the current page should be reset. + if (widget.children != oldWidget.children) { + _page = 0; + _nextPage = null; + _controller.forward(); + _controller.removeStatusListener(_statusListener); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Color chevronColor = _kToolbarTextColor.resolveFrom(context); + + // Wrap the children and the chevron painters in Center with widthFactor + // and heightFactor of 1.0 so _CupertinoTextSelectionToolbarItems can get + // the natural size of the buttons and then expand vertically as needed. + final Widget backButton = Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: CupertinoTextSelectionToolbarButton( + onPressed: _handlePreviousPage, + child: IgnorePointer( + child: CustomPaint( + painter: _LeftCupertinoChevronPainter(color: chevronColor), + size: const Size.square(_kToolbarChevronSize), + ), + ), + ), + ); + final Widget nextButton = Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: CupertinoTextSelectionToolbarButton( + onPressed: _handleNextPage, + child: IgnorePointer( + child: CustomPaint( + painter: _RightCupertinoChevronPainter(color: chevronColor), + size: const Size.square(_kToolbarChevronSize), + ), + ), + ), + ); + final List children = widget.children.map((Widget child) { + return Center(widthFactor: 1.0, heightFactor: 1.0, child: child); + }).toList(); + + return widget.toolbarBuilder( + context, + widget.anchorAbove, + widget.anchorBelow, + FadeTransition( + opacity: _controller, + child: AnimatedSize( + duration: _kToolbarTransitionDuration, + curve: Curves.decelerate, + child: GestureDetector( + onHorizontalDragEnd: _onHorizontalDragEnd, + child: _CupertinoTextSelectionToolbarItems( + key: _toolbarItemsKey, + page: _page, + backButton: backButton, + dividerColor: _kToolbarDividerColor.resolveFrom(context), + dividerWidth: 1.0 / MediaQuery.devicePixelRatioOf(context), + nextButton: nextButton, + children: children, + ), + ), + ), + ), + ); + } +} + +// These classes help to test the chevrons. As _CupertinoChevronPainter must be +// private, it's possible to check the runtimeType of each chevron to know if +// they should be pointing left or right. +class _LeftCupertinoChevronPainter extends _CupertinoChevronPainter { + _LeftCupertinoChevronPainter({required super.color}) : super(isLeft: true); +} + +class _RightCupertinoChevronPainter extends _CupertinoChevronPainter { + _RightCupertinoChevronPainter({required super.color}) : super(isLeft: false); +} + +abstract class _CupertinoChevronPainter extends CustomPainter { + _CupertinoChevronPainter({required this.color, required this.isLeft}); + + final Color color; + + /// If this is true the chevron will point left, else it will point right. + final bool isLeft; + + @override + void paint(Canvas canvas, Size size) { + assert(size.height == size.width, 'size must have the same height and width: $size'); + + final double iconSize = size.height; + + // The chevron is half of a square rotated 45˚, so it needs a margin of 1/4 + // its size on each side to be centered horizontally. + // + // If pointing left, it means the left half of a square is being used and + // the offset is positive. If pointing right, the right half is being used + // and the offset is negative. + final centerOffset = Offset(iconSize / 4 * (isLeft ? 1 : -1), 0); + + final Offset firstPoint = Offset(iconSize / 2, 0) + centerOffset; + final Offset middlePoint = Offset(isLeft ? 0 : iconSize, iconSize / 2) + centerOffset; + final Offset lowerPoint = Offset(iconSize / 2, iconSize) + centerOffset; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _kToolbarChevronThickness + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + // `drawLine` is used here because it's testable. When using `drawPath`, + // there's no way to test that the chevron points to the correct side. + canvas.drawLine(firstPoint, middlePoint, paint); + canvas.drawLine(middlePoint, lowerPoint, paint); + } + + @override + bool shouldRepaint(_CupertinoChevronPainter oldDelegate) => + oldDelegate.color != color || oldDelegate.isLeft != isLeft; +} + +// The custom RenderObjectWidget that, together with +// _RenderCupertinoTextSelectionToolbarItems and +// _CupertinoTextSelectionToolbarItemsElement, paginates the menu items. +class _CupertinoTextSelectionToolbarItems extends RenderObjectWidget { + _CupertinoTextSelectionToolbarItems({ + super.key, + required this.page, + required this.children, + required this.backButton, + required this.dividerColor, + required this.dividerWidth, + required this.nextButton, + }) : assert(children.isNotEmpty); + + final Widget backButton; + final List children; + final Color dividerColor; + final double dividerWidth; + final Widget nextButton; + final int page; + + @override + _RenderCupertinoTextSelectionToolbarItems createRenderObject(BuildContext context) { + return _RenderCupertinoTextSelectionToolbarItems( + dividerColor: dividerColor, + dividerWidth: dividerWidth, + page: page, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderCupertinoTextSelectionToolbarItems renderObject, + ) { + renderObject + ..page = page + ..dividerColor = dividerColor + ..dividerWidth = dividerWidth; + } + + @override + _CupertinoTextSelectionToolbarItemsElement createElement() => + _CupertinoTextSelectionToolbarItemsElement(this); +} + +// The custom RenderObjectElement that helps paginate the menu items. +class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement { + _CupertinoTextSelectionToolbarItemsElement(_CupertinoTextSelectionToolbarItems super.widget); + + late List _children; + final Map<_CupertinoTextSelectionToolbarItemsSlot, Element> slotToChild = + <_CupertinoTextSelectionToolbarItemsSlot, Element>{}; + + // We keep a set of forgotten children to avoid O(n^2) work walking _children + // repeatedly to remove children. + final Set _forgottenChildren = HashSet(); + + @override + _RenderCupertinoTextSelectionToolbarItems get renderObject => + super.renderObject as _RenderCupertinoTextSelectionToolbarItems; + + void _updateRenderObject(RenderBox? child, _CupertinoTextSelectionToolbarItemsSlot slot) { + switch (slot) { + case _CupertinoTextSelectionToolbarItemsSlot.backButton: + renderObject.backButton = child; + case _CupertinoTextSelectionToolbarItemsSlot.nextButton: + renderObject.nextButton = child; + } + } + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + if (slot is _CupertinoTextSelectionToolbarItemsSlot) { + assert(child is RenderBox); + _updateRenderObject(child as RenderBox, slot); + assert(renderObject.slottedChildren.containsKey(slot)); + return; + } + if (slot is IndexedSlot) { + assert(renderObject.debugValidateChild(child)); + renderObject.insert(child as RenderBox, after: slot.value?.renderObject as RenderBox?); + return; + } + assert(false, 'slot must be _CupertinoTextSelectionToolbarItemsSlot or IndexedSlot'); + } + + // This is not reachable for children that don't have an IndexedSlot. + @override + void moveRenderObjectChild( + RenderObject child, + IndexedSlot oldSlot, + IndexedSlot newSlot, + ) { + assert(child.parent == renderObject); + renderObject.move(child as RenderBox, after: newSlot.value.renderObject as RenderBox?); + } + + static bool _shouldPaint(Element child) { + return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint; + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + // Check if the child is in a slot. + if (slot is _CupertinoTextSelectionToolbarItemsSlot) { + assert(child is RenderBox); + assert(renderObject.slottedChildren.containsKey(slot)); + _updateRenderObject(null, slot); + assert(!renderObject.slottedChildren.containsKey(slot)); + return; + } + + // Otherwise look for it in the list of children. + assert(slot is IndexedSlot); + assert(child.parent == renderObject); + renderObject.remove(child as RenderBox); + } + + @override + void visitChildren(ElementVisitor visitor) { + slotToChild.values.forEach(visitor); + for (final Element child in _children) { + if (!_forgottenChildren.contains(child)) { + visitor(child); + } + } + } + + @override + void forgetChild(Element child) { + assert(slotToChild.containsValue(child) || _children.contains(child)); + assert(!_forgottenChildren.contains(child)); + // Handle forgetting a child in children or in a slot. + if (slotToChild.containsKey(child.slot)) { + final slot = child.slot! as _CupertinoTextSelectionToolbarItemsSlot; + slotToChild.remove(slot); + } else { + _forgottenChildren.add(child); + } + super.forgetChild(child); + } + + // Mount or update slotted child. + void _mountChild(Widget widget, _CupertinoTextSelectionToolbarItemsSlot slot) { + final Element? oldChild = slotToChild[slot]; + final Element? newChild = updateChild(oldChild, widget, slot); + if (oldChild != null) { + slotToChild.remove(slot); + } + if (newChild != null) { + slotToChild[slot] = newChild; + } + } + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + // Mount slotted children. + final toolbarItems = widget as _CupertinoTextSelectionToolbarItems; + _mountChild(toolbarItems.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton); + _mountChild(toolbarItems.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton); + + // Mount list children. + Element? previousChild; + _children = List.generate(toolbarItems.children.length, (int i) { + final Element result = inflateWidget( + toolbarItems.children[i], + IndexedSlot(i, previousChild), + ); + previousChild = result; + return result; + }, growable: false); + } + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + // Visit slot children. + for (final Element child in slotToChild.values) { + if (_shouldPaint(child) && !_forgottenChildren.contains(child)) { + visitor(child); + } + } + // Visit list children. + _children + .where((Element child) => !_forgottenChildren.contains(child) && _shouldPaint(child)) + .forEach(visitor); + } + + @override + void update(_CupertinoTextSelectionToolbarItems newWidget) { + super.update(newWidget); + assert(widget == newWidget); + + // Update slotted children. + final toolbarItems = widget as _CupertinoTextSelectionToolbarItems; + _mountChild(toolbarItems.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton); + _mountChild(toolbarItems.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton); + + // Update list children. + _children = updateChildren( + _children, + toolbarItems.children, + forgottenChildren: _forgottenChildren, + ); + _forgottenChildren.clear(); + } +} + +// The custom RenderBox that helps paginate the menu items. +class _RenderCupertinoTextSelectionToolbarItems extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderCupertinoTextSelectionToolbarItems({ + required Color dividerColor, + required double dividerWidth, + required int page, + }) : _dividerColor = dividerColor, + _dividerWidth = dividerWidth, + _page = page, + super(); + + final Map<_CupertinoTextSelectionToolbarItemsSlot, RenderBox> slottedChildren = + <_CupertinoTextSelectionToolbarItemsSlot, RenderBox>{}; + + late bool hasNextPage; + late bool hasPreviousPage; + + RenderBox? _updateChild( + RenderBox? oldChild, + RenderBox? newChild, + _CupertinoTextSelectionToolbarItemsSlot slot, + ) { + if (oldChild != null) { + dropChild(oldChild); + slottedChildren.remove(slot); + } + if (newChild != null) { + slottedChildren[slot] = newChild; + adoptChild(newChild); + } + return newChild; + } + + int _page; + int get page => _page; + set page(int value) { + if (value == _page) { + return; + } + _page = value; + markNeedsLayout(); + } + + Color _dividerColor; + Color get dividerColor => _dividerColor; + set dividerColor(Color value) { + if (value == _dividerColor) { + return; + } + _dividerColor = value; + markNeedsLayout(); + } + + double _dividerWidth; + double get dividerWidth => _dividerWidth; + set dividerWidth(double value) { + if (value == _dividerWidth) { + return; + } + _dividerWidth = value; + markNeedsLayout(); + } + + RenderBox? _backButton; + RenderBox? get backButton => _backButton; + set backButton(RenderBox? value) { + _backButton = _updateChild( + _backButton, + value, + _CupertinoTextSelectionToolbarItemsSlot.backButton, + ); + } + + RenderBox? _nextButton; + RenderBox? get nextButton => _nextButton; + set nextButton(RenderBox? value) { + _nextButton = _updateChild( + _nextButton, + value, + _CupertinoTextSelectionToolbarItemsSlot.nextButton, + ); + } + + @override + void performLayout() { + if (firstChild == null) { + size = constraints.smallest; + return; + } + + // First pass: determine the height of the tallest child. + var greatestHeight = 0.0; + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final double childHeight = child.getMaxIntrinsicHeight(constraints.maxWidth); + if (childHeight > greatestHeight) { + greatestHeight = childHeight; + } + }); + + // Layout slotted children. + final slottedConstraints = BoxConstraints( + maxWidth: constraints.maxWidth, + minHeight: greatestHeight, + maxHeight: greatestHeight, + ); + _backButton!.layout(slottedConstraints, parentUsesSize: true); + _nextButton!.layout(slottedConstraints, parentUsesSize: true); + + final double subsequentPageButtonsWidth = _backButton!.size.width + _nextButton!.size.width; + var currentButtonPosition = 0.0; + late double toolbarWidth; // The width of the whole widget. + var currentPage = 0; + var i = -1; + visitChildren((RenderObject renderObjectChild) { + i++; + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + childParentData.shouldPaint = false; + + // Skip slotted children and children on pages after the visible page. + if (child == _backButton || child == _nextButton || currentPage > _page) { + return; + } + + // If this is the last child on the first page, it's ok to fit without a forward button. + // Note childCount doesn't include slotted children which come before the list ones. + double paginationButtonsWidth = currentPage == 0 + ? i == childCount + 1 + ? 0.0 + : _nextButton!.size.width + : subsequentPageButtonsWidth; + + // The width of the menu is set by the first page. + child.layout( + BoxConstraints( + maxWidth: constraints.maxWidth - paginationButtonsWidth, + minHeight: greatestHeight, + maxHeight: greatestHeight, + ), + parentUsesSize: true, + ); + + // If this child causes the current page to overflow, move to the next + // page and relayout the child. + final double currentWidth = currentButtonPosition + paginationButtonsWidth + child.size.width; + if (currentWidth > constraints.maxWidth) { + currentPage++; + currentButtonPosition = _backButton!.size.width + dividerWidth; + paginationButtonsWidth = _backButton!.size.width + _nextButton!.size.width; + child.layout( + BoxConstraints( + maxWidth: constraints.maxWidth - paginationButtonsWidth, + minHeight: greatestHeight, + maxHeight: greatestHeight, + ), + parentUsesSize: true, + ); + } + childParentData.offset = Offset(currentButtonPosition, 0.0); + currentButtonPosition += child.size.width + dividerWidth; + childParentData.shouldPaint = currentPage == page; + + if (currentPage == page) { + toolbarWidth = currentButtonPosition; + } + }); + + // It shouldn't be possible to navigate beyond the last page. + assert(page <= currentPage); + + // Position page nav buttons. + if (currentPage > 0) { + final nextButtonParentData = _nextButton!.parentData! as ToolbarItemsParentData; + final backButtonParentData = _backButton!.parentData! as ToolbarItemsParentData; + // The forward button only shows when there's a page after this one. + if (page != currentPage) { + nextButtonParentData.offset = Offset(toolbarWidth, 0.0); + nextButtonParentData.shouldPaint = true; + toolbarWidth += nextButton!.size.width; + } + if (page > 0) { + backButtonParentData.offset = Offset.zero; + backButtonParentData.shouldPaint = true; + // No need to add the width of the back button to toolbarWidth here. It's + // already been taken care of when laying out the children to + // accommodate the back button. + } + } else { + // No divider for the next button when there's only one page. + toolbarWidth -= dividerWidth; + } + + // Update previous/next page values so that we can check in the horizontal + // drag gesture callback if it's possible to navigate. + hasNextPage = page != currentPage; + hasPreviousPage = page > 0; + + size = constraints.constrain(Size(toolbarWidth, greatestHeight)); + } + + @override + void paint(PaintingContext context, Offset offset) { + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + + if (childParentData.shouldPaint) { + final Offset childOffset = childParentData.offset + offset; + context.paintChild(child, childOffset); + + // backButton is a slotted child and is not in the children list, so its + // childParentData.nextSibling is null. So either when there's a + // nextSibling or when child is the backButton, draw a divider to the + // child's right. + if (childParentData.nextSibling != null || child == backButton) { + context.canvas.drawLine( + Offset(child.size.width, 0) + childOffset, + Offset(child.size.width, child.size.height) + childOffset, + Paint()..color = dividerColor, + ); + } + } + }); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + // Returns true if the single child is hit by the given position. + static bool hitTestChild(RenderBox? child, BoxHitTestResult result, {required Offset position}) { + if (child == null) { + return false; + } + final childParentData = child.parentData! as ToolbarItemsParentData; + if (!childParentData.shouldPaint) { + return false; + } + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + // Hit test list children. + RenderBox? child = lastChild; + while (child != null) { + final childParentData = child.parentData! as ToolbarItemsParentData; + + // Don't hit test children that aren't shown. + if (!childParentData.shouldPaint) { + child = childParentData.previousSibling; + continue; + } + + if (hitTestChild(child, result, position: position)) { + return true; + } + child = childParentData.previousSibling; + } + + // Hit test slot children. + if (hitTestChild(backButton, result, position: position)) { + return true; + } + if (hitTestChild(nextButton, result, position: position)) { + return true; + } + + return false; + } + + @override + void attach(PipelineOwner owner) { + // Attach list children. + super.attach(owner); + + // Attach slot children. + for (final RenderBox child in slottedChildren.values) { + child.attach(owner); + } + } + + @override + void detach() { + // Detach list children. + super.detach(); + + // Detach slot children. + for (final RenderBox child in slottedChildren.values) { + child.detach(); + } + } + + @override + void redepthChildren() { + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + redepthChild(child); + }); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + // Visit the slotted children. + if (_backButton != null) { + visitor(_backButton!); + } + if (_nextButton != null) { + visitor(_nextButton!); + } + // Visit the list children. + super.visitChildren(visitor); + } + + // Visit only the children that should be painted. + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + if (childParentData.shouldPaint) { + visitor(renderObjectChild); + } + }); + } + + @override + List debugDescribeChildren() { + final value = []; + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + if (child == backButton) { + value.add(child.toDiagnosticsNode(name: 'back button')); + } else if (child == nextButton) { + value.add(child.toDiagnosticsNode(name: 'next button')); + + // List children. + } else { + value.add(child.toDiagnosticsNode(name: 'menu item')); + } + }); + return value; + } +} + +// The slots that can be occupied by widgets in +// _CupertinoTextSelectionToolbarItems, excluding the list of children. +enum _CupertinoTextSelectionToolbarItemsSlot { backButton, nextButton } diff --git a/packages/cupertino_ui/lib/src/text_selection_toolbar_button.dart b/packages/cupertino_ui/lib/src/text_selection_toolbar_button.dart new file mode 100644 index 000000000000..c7caee330a14 --- /dev/null +++ b/packages/cupertino_ui/lib/src/text_selection_toolbar_button.dart @@ -0,0 +1,236 @@ +// 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'; + +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'localizations.dart'; + +const TextStyle _kToolbarButtonFontStyle = TextStyle( + inherit: false, + fontSize: 15.0, + letterSpacing: -0.15, + fontWeight: FontWeight.w400, +); + +const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.black, + darkColor: CupertinoColors.white, +); + +const CupertinoDynamicColor _kToolbarPressedColor = CupertinoDynamicColor.withBrightness( + color: Color(0x10000000), + darkColor: Color(0x10FFFFFF), +); + +// Value measured from screenshot of iOS 16.0.2 +const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 18.0, horizontal: 16.0); + +/// A button in the style of the iOS text selection toolbar buttons. +class CupertinoTextSelectionToolbarButton extends StatefulWidget { + /// Create an instance of [CupertinoTextSelectionToolbarButton]. + const CupertinoTextSelectionToolbarButton({super.key, this.onPressed, required Widget this.child}) + : text = null, + buttonItem = null; + + /// Create an instance of [CupertinoTextSelectionToolbarButton] whose child is + /// a [Text] widget styled like the default iOS text selection toolbar button. + const CupertinoTextSelectionToolbarButton.text({super.key, this.onPressed, required this.text}) + : buttonItem = null, + child = null; + + /// Create an instance of [CupertinoTextSelectionToolbarButton] from the given + /// [ContextMenuButtonItem]. + CupertinoTextSelectionToolbarButton.buttonItem({ + super.key, + required ContextMenuButtonItem this.buttonItem, + }) : child = null, + text = null, + onPressed = buttonItem.onPressed; + + /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.child} + /// The child of this button. + /// + /// Usually a [Text] or an [Icon]. + /// {@endtemplate} + final Widget? child; + + /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed} + /// Called when this button is pressed. + /// {@endtemplate} + final VoidCallback? onPressed; + + /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed} + /// The buttonItem used to generate the button when using + /// [CupertinoTextSelectionToolbarButton.buttonItem]. + /// {@endtemplate} + final ContextMenuButtonItem? buttonItem; + + /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.text} + /// The text used in the button's label when using + /// [CupertinoTextSelectionToolbarButton.text]. + /// {@endtemplate} + final String? text; + + /// Returns the default button label String for the button of the given + /// [ContextMenuButtonItem]'s [ContextMenuButtonType]. + static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) { + if (buttonItem.label != null) { + return buttonItem.label!; + } + + assert(debugCheckHasCupertinoLocalizations(context)); + final CupertinoLocalizations localizations = CupertinoLocalizations.of(context); + return switch (buttonItem.type) { + ContextMenuButtonType.cut => localizations.cutButtonLabel, + ContextMenuButtonType.copy => localizations.copyButtonLabel, + ContextMenuButtonType.paste => localizations.pasteButtonLabel, + ContextMenuButtonType.selectAll => localizations.selectAllButtonLabel, + ContextMenuButtonType.lookUp => localizations.lookUpButtonLabel, + ContextMenuButtonType.searchWeb => localizations.searchWebButtonLabel, + ContextMenuButtonType.share => localizations.shareButtonLabel, + ContextMenuButtonType.liveTextInput || + ContextMenuButtonType.delete || + ContextMenuButtonType.custom => '', + }; + } + + @override + State createState() => _CupertinoTextSelectionToolbarButtonState(); +} + +class _CupertinoTextSelectionToolbarButtonState extends State { + bool isPressed = false; + + void _onTapDown(TapDownDetails details) { + setState(() => isPressed = true); + } + + void _onTapUp(TapUpDetails details) { + setState(() => isPressed = false); + widget.onPressed?.call(); + } + + void _onTapCancel() { + setState(() => isPressed = false); + } + + @override + Widget build(BuildContext context) { + final Widget content = _getContentWidget(context); + final Widget child = CupertinoButton( + color: isPressed ? _kToolbarPressedColor.resolveFrom(context) : CupertinoColors.transparent, + disabledColor: CupertinoColors.transparent, + // This CupertinoButton does not actually handle the onPressed callback, + // this is only here to correctly enable/disable the button (see + // GestureDetector comment below). + onPressed: widget.onPressed, + padding: _kToolbarButtonPadding, + // There's no foreground fade on iOS toolbar anymore, just the background + // is darkened. + pressedOpacity: 1.0, + child: content, + ); + + if (widget.onPressed != null) { + // As it's needed to change the CupertinoButton's backgroundColor when + // pressed, not its opacity, this GestureDetector handles both the + // onPressed callback and the backgroundColor change. + return GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: child, + ); + } else { + return child; + } + } + + Widget _getContentWidget(BuildContext context) { + if (widget.child != null) { + return widget.child!; + } + final Widget textWidget = Text( + widget.text ?? + CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!), + overflow: TextOverflow.ellipsis, + style: _kToolbarButtonFontStyle.copyWith( + color: widget.onPressed != null + ? _kToolbarTextColor.resolveFrom(context) + : CupertinoColors.inactiveGray, + ), + ); + switch (widget.buttonItem?.type) { + case ContextMenuButtonType.cut: + case ContextMenuButtonType.copy: + case ContextMenuButtonType.paste: + case ContextMenuButtonType.selectAll: + case ContextMenuButtonType.delete: + case ContextMenuButtonType.lookUp: + case ContextMenuButtonType.searchWeb: + case ContextMenuButtonType.share: + case ContextMenuButtonType.custom: + case null: + return textWidget; + case ContextMenuButtonType.liveTextInput: + return SizedBox.square( + dimension: 13.0, + child: CustomPaint( + painter: _LiveTextIconPainter(color: _kToolbarTextColor.resolveFrom(context)), + ), + ); + } + } +} + +class _LiveTextIconPainter extends CustomPainter { + _LiveTextIconPainter({required this.color}); + + final Color color; + + final Paint _painter = Paint() + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + @override + void paint(Canvas canvas, Size size) { + _painter.color = color; + canvas.save(); + canvas.translate(size.width / 2.0, size.height / 2.0); + + final origin = Offset(-size.width / 2.0, -size.height / 2.0); + // Path for the one corner. + final path = Path() + ..moveTo(origin.dx, origin.dy + 3.5) + ..lineTo(origin.dx, origin.dy + 1.0) + ..arcToPoint(Offset(origin.dx + 1.0, origin.dy), radius: const Radius.circular(1)) + ..lineTo(origin.dx + 3.5, origin.dy); + + // Rotate to draw corner four times. + final rotationMatrix = Matrix4.identity()..rotateZ(pi / 2.0); + for (var i = 0; i < 4; i += 1) { + canvas.drawPath(path, _painter); + canvas.transform(rotationMatrix.storage); + } + + // Draw three lines. + canvas.drawLine(const Offset(-3.0, -3.0), const Offset(3.0, -3.0), _painter); + canvas.drawLine(const Offset(-3.0, 0.0), const Offset(3.0, 0.0), _painter); + canvas.drawLine(const Offset(-3.0, 3.0), const Offset(1.0, 3.0), _painter); + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _LiveTextIconPainter oldDelegate) { + return oldDelegate.color != color; + } +} diff --git a/packages/cupertino_ui/lib/src/text_theme.dart b/packages/cupertino_ui/lib/src/text_theme.dart new file mode 100644 index 000000000000..879c735717cb --- /dev/null +++ b/packages/cupertino_ui/lib/src/text_theme.dart @@ -0,0 +1,448 @@ +// 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. + +/// @docImport 'interface_level.dart'; +/// @docImport 'theme.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; + +// Please update _TextThemeDefaultsBuilder accordingly after changing the default +// color here, as their implementation depends on the default value of the color +// field. +// +// Values derived from https://developer.apple.com/design/resources/. +const TextStyle _kDefaultTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemText', + fontSize: 17.0, + letterSpacing: -0.41, + color: CupertinoColors.label, + decoration: TextDecoration.none, +); + +// Please update _TextThemeDefaultsBuilder accordingly after changing the default +// color here, as their implementation depends on the default value of the color +// field. +// +// Values derived from https://developer.apple.com/design/resources/. +// See [iOS 17 + iPadOS 17 UI Kit](https://www.figma.com/community/file/1248375255495415511) for details. +const TextStyle _kDefaultActionTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemText', + fontSize: 17.0, + letterSpacing: -0.41, + color: CupertinoColors.activeBlue, + decoration: TextDecoration.none, +); + +// Please update _TextThemeDefaultsBuilder accordingly after changing the default +// color here, as their implementation depends on the default value of the color +// field. +// +// Values derived from https://developer.apple.com/design/resources/. +// See [iOS 17 + iPadOS 17 UI Kit](https://www.figma.com/community/file/1248375255495415511) for details. +const TextStyle _kDefaultActionSmallTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemText', + fontSize: 15.0, + letterSpacing: -0.23, + color: CupertinoColors.activeBlue, + decoration: TextDecoration.none, +); + +// Please update _TextThemeDefaultsBuilder accordingly after changing the default +// color here, as their implementation depends on the default value of the color +// field. +// +// Values derived from https://developer.apple.com/design/resources/. +const TextStyle _kDefaultTabLabelTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemText', + fontSize: 10.0, + fontWeight: FontWeight.w500, + letterSpacing: -0.24, + color: CupertinoColors.inactiveGray, +); + +const TextStyle _kDefaultMiddleTitleTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemText', + fontSize: 17.0, + fontWeight: FontWeight.w600, + letterSpacing: -0.41, + color: CupertinoColors.label, +); + +const TextStyle _kDefaultLargeTitleTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemDisplay', + fontSize: 34.0, + fontWeight: FontWeight.w700, + letterSpacing: 0.38, + color: CupertinoColors.label, +); + +// Please update _TextThemeDefaultsBuilder accordingly after changing the default +// color here, as their implementation depends on the default value of the color +// field. +// +// Inspected on iOS 13 simulator with "Debug View Hierarchy". +// Value extracted from off-center labels. Centered labels have a font size of 25pt. +// +// The letterSpacing sourced from iOS 14 simulator screenshots for comparison. +// See also: +// +// * https://github.com/flutter/flutter/pull/65501#discussion_r486557093 +const TextStyle _kDefaultPickerTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemDisplay', + fontSize: 21.0, + fontWeight: FontWeight.w400, + letterSpacing: -0.6, + color: CupertinoColors.label, +); + +// Please update _TextThemeDefaultsBuilder accordingly after changing the default +// color here, as their implementation depends on the default value of the color +// field. +// +// Inspected on iOS 13 simulator with "Debug View Hierarchy". +// Value extracted from off-center labels. Centered labels have a font size of 25pt. +const TextStyle _kDefaultDateTimePickerTextStyle = TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemDisplay', + fontSize: 21, + letterSpacing: 0.4, + fontWeight: FontWeight.normal, + color: CupertinoColors.label, +); + +TextStyle? _resolveTextStyle(TextStyle? style, BuildContext context) { + // This does not resolve the shadow color, foreground, background, etc. + return style?.copyWith( + color: CupertinoDynamicColor.maybeResolve(style.color, context), + backgroundColor: CupertinoDynamicColor.maybeResolve(style.backgroundColor, context), + decorationColor: CupertinoDynamicColor.maybeResolve(style.decorationColor, context), + ); +} + +/// Cupertino typography theme in a [CupertinoThemeData]. +@immutable +class CupertinoTextThemeData with Diagnosticable { + /// Create a [CupertinoTextThemeData]. + /// + /// The [primaryColor] is used to derive TextStyle defaults of other attributes + /// such as [navActionTextStyle] and [actionTextStyle]. It must not be null when + /// either [navActionTextStyle] or [actionTextStyle] is null. Defaults to + /// [CupertinoColors.systemBlue]. + /// + /// Other [TextStyle] parameters default to default iOS text styles when + /// unspecified. + const CupertinoTextThemeData({ + Color primaryColor = CupertinoColors.systemBlue, + TextStyle? textStyle, + TextStyle? actionTextStyle, + TextStyle? actionSmallTextStyle, + TextStyle? tabLabelTextStyle, + TextStyle? navTitleTextStyle, + TextStyle? navLargeTitleTextStyle, + TextStyle? navActionTextStyle, + TextStyle? pickerTextStyle, + TextStyle? dateTimePickerTextStyle, + }) : this._raw( + const _TextThemeDefaultsBuilder(CupertinoColors.label, CupertinoColors.inactiveGray), + primaryColor, + textStyle, + actionTextStyle, + actionSmallTextStyle, + tabLabelTextStyle, + navTitleTextStyle, + navLargeTitleTextStyle, + navActionTextStyle, + pickerTextStyle, + dateTimePickerTextStyle, + ); + + const CupertinoTextThemeData._raw( + this._defaults, + this._primaryColor, + this._textStyle, + this._actionTextStyle, + this._actionSmallTextStyle, + this._tabLabelTextStyle, + this._navTitleTextStyle, + this._navLargeTitleTextStyle, + this._navActionTextStyle, + this._pickerTextStyle, + this._dateTimePickerTextStyle, + ) : assert((_navActionTextStyle != null && _actionTextStyle != null) || _primaryColor != null); + + final _TextThemeDefaultsBuilder _defaults; + final Color? _primaryColor; + + final TextStyle? _textStyle; + + /// The [TextStyle] of general text content for Cupertino widgets. + TextStyle get textStyle => _textStyle ?? _defaults.textStyle; + + final TextStyle? _actionTextStyle; + + /// The [TextStyle] of interactive text content such as text in a button without background. + TextStyle get actionTextStyle { + return _actionTextStyle ?? _defaults.actionTextStyle(primaryColor: _primaryColor); + } + + final TextStyle? _actionSmallTextStyle; + + /// The [TextStyle] of interactive text content such as text in a small button. + TextStyle get actionSmallTextStyle { + return _actionSmallTextStyle ?? _defaults.actionSmallTextStyle(primaryColor: _primaryColor); + } + + final TextStyle? _tabLabelTextStyle; + + /// The [TextStyle] of unselected tabs. + TextStyle get tabLabelTextStyle => _tabLabelTextStyle ?? _defaults.tabLabelTextStyle; + + final TextStyle? _navTitleTextStyle; + + /// The [TextStyle] of titles in standard navigation bars. + TextStyle get navTitleTextStyle => _navTitleTextStyle ?? _defaults.navTitleTextStyle; + + final TextStyle? _navLargeTitleTextStyle; + + /// The [TextStyle] of large titles in sliver navigation bars. + TextStyle get navLargeTitleTextStyle => + _navLargeTitleTextStyle ?? _defaults.navLargeTitleTextStyle; + + final TextStyle? _navActionTextStyle; + + /// The [TextStyle] of interactive text content in navigation bars. + TextStyle get navActionTextStyle { + return _navActionTextStyle ?? _defaults.navActionTextStyle(primaryColor: _primaryColor); + } + + final TextStyle? _pickerTextStyle; + + /// The [TextStyle] of pickers. + TextStyle get pickerTextStyle => _pickerTextStyle ?? _defaults.pickerTextStyle; + + final TextStyle? _dateTimePickerTextStyle; + + /// The [TextStyle] of date time pickers. + TextStyle get dateTimePickerTextStyle => + _dateTimePickerTextStyle ?? _defaults.dateTimePickerTextStyle; + + /// Returns a copy of the current [CupertinoTextThemeData] with all the colors + /// resolved against the given [BuildContext]. + /// + /// If any of the [InheritedWidget]s required to resolve this + /// [CupertinoTextThemeData] is not found in [context], any unresolved + /// [CupertinoDynamicColor]s will use the default trait value + /// ([Brightness.light] platform brightness, normal contrast, + /// [CupertinoUserInterfaceLevelData.base] elevation level). + CupertinoTextThemeData resolveFrom(BuildContext context) { + return CupertinoTextThemeData._raw( + _defaults.resolveFrom(context), + CupertinoDynamicColor.maybeResolve(_primaryColor, context), + _resolveTextStyle(_textStyle, context), + _resolveTextStyle(_actionTextStyle, context), + _resolveTextStyle(_actionSmallTextStyle, context), + _resolveTextStyle(_tabLabelTextStyle, context), + _resolveTextStyle(_navTitleTextStyle, context), + _resolveTextStyle(_navLargeTitleTextStyle, context), + _resolveTextStyle(_navActionTextStyle, context), + _resolveTextStyle(_pickerTextStyle, context), + _resolveTextStyle(_dateTimePickerTextStyle, context), + ); + } + + /// Returns a copy of the current [CupertinoTextThemeData] instance with + /// specified overrides. + CupertinoTextThemeData copyWith({ + Color? primaryColor, + TextStyle? textStyle, + TextStyle? actionTextStyle, + TextStyle? actionSmallTextStyle, + TextStyle? tabLabelTextStyle, + TextStyle? navTitleTextStyle, + TextStyle? navLargeTitleTextStyle, + TextStyle? navActionTextStyle, + TextStyle? pickerTextStyle, + TextStyle? dateTimePickerTextStyle, + }) { + return CupertinoTextThemeData._raw( + _defaults, + primaryColor ?? _primaryColor, + textStyle ?? _textStyle, + actionTextStyle ?? _actionTextStyle, + actionSmallTextStyle ?? _actionSmallTextStyle, + tabLabelTextStyle ?? _tabLabelTextStyle, + navTitleTextStyle ?? _navTitleTextStyle, + navLargeTitleTextStyle ?? _navLargeTitleTextStyle, + navActionTextStyle ?? _navActionTextStyle, + pickerTextStyle ?? _pickerTextStyle, + dateTimePickerTextStyle ?? _dateTimePickerTextStyle, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultData = CupertinoTextThemeData(); + properties.add( + DiagnosticsProperty('textStyle', textStyle, defaultValue: defaultData.textStyle), + ); + properties.add( + DiagnosticsProperty( + 'actionTextStyle', + actionTextStyle, + defaultValue: defaultData.actionTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'actionSmallTextStyle', + actionSmallTextStyle, + defaultValue: defaultData.actionSmallTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'tabLabelTextStyle', + tabLabelTextStyle, + defaultValue: defaultData.tabLabelTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'navTitleTextStyle', + navTitleTextStyle, + defaultValue: defaultData.navTitleTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'navLargeTitleTextStyle', + navLargeTitleTextStyle, + defaultValue: defaultData.navLargeTitleTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'navActionTextStyle', + navActionTextStyle, + defaultValue: defaultData.navActionTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'pickerTextStyle', + pickerTextStyle, + defaultValue: defaultData.pickerTextStyle, + ), + ); + properties.add( + DiagnosticsProperty( + 'dateTimePickerTextStyle', + dateTimePickerTextStyle, + defaultValue: defaultData.dateTimePickerTextStyle, + ), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is CupertinoTextThemeData && + other._defaults == _defaults && + other._primaryColor == _primaryColor && + other._textStyle == _textStyle && + other._actionTextStyle == _actionTextStyle && + other._actionSmallTextStyle == _actionSmallTextStyle && + other._tabLabelTextStyle == _tabLabelTextStyle && + other._navTitleTextStyle == _navTitleTextStyle && + other._navLargeTitleTextStyle == _navLargeTitleTextStyle && + other._navActionTextStyle == _navActionTextStyle && + other._pickerTextStyle == _pickerTextStyle && + other._dateTimePickerTextStyle == _dateTimePickerTextStyle; + } + + @override + int get hashCode => Object.hash( + _defaults, + _primaryColor, + _textStyle, + _actionTextStyle, + _actionSmallTextStyle, + _tabLabelTextStyle, + _navTitleTextStyle, + _navLargeTitleTextStyle, + _navActionTextStyle, + _pickerTextStyle, + _dateTimePickerTextStyle, + ); +} + +@immutable +class _TextThemeDefaultsBuilder { + const _TextThemeDefaultsBuilder(this.labelColor, this.inactiveGrayColor); + + final Color labelColor; + final Color inactiveGrayColor; + + static TextStyle _applyLabelColor(TextStyle original, Color color) { + return original.color == color ? original : original.copyWith(color: color); + } + + TextStyle get textStyle => _applyLabelColor(_kDefaultTextStyle, labelColor); + TextStyle get tabLabelTextStyle => + _applyLabelColor(_kDefaultTabLabelTextStyle, inactiveGrayColor); + TextStyle get navTitleTextStyle => _applyLabelColor(_kDefaultMiddleTitleTextStyle, labelColor); + TextStyle get navLargeTitleTextStyle => + _applyLabelColor(_kDefaultLargeTitleTextStyle, labelColor); + TextStyle get pickerTextStyle => _applyLabelColor(_kDefaultPickerTextStyle, labelColor); + TextStyle get dateTimePickerTextStyle => + _applyLabelColor(_kDefaultDateTimePickerTextStyle, labelColor); + + TextStyle actionTextStyle({Color? primaryColor}) => + _kDefaultActionTextStyle.copyWith(color: primaryColor); + TextStyle actionSmallTextStyle({Color? primaryColor}) => + _kDefaultActionSmallTextStyle.copyWith(color: primaryColor); + TextStyle navActionTextStyle({Color? primaryColor}) => + actionTextStyle(primaryColor: primaryColor); + + _TextThemeDefaultsBuilder resolveFrom(BuildContext context) { + final Color resolvedLabelColor = CupertinoDynamicColor.resolve(labelColor, context); + final Color resolvedInactiveGray = CupertinoDynamicColor.resolve(inactiveGrayColor, context); + return resolvedLabelColor == labelColor && resolvedInactiveGray == CupertinoColors.inactiveGray + ? this + : _TextThemeDefaultsBuilder(resolvedLabelColor, resolvedInactiveGray); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is _TextThemeDefaultsBuilder && + other.labelColor == labelColor && + other.inactiveGrayColor == inactiveGrayColor; + } + + @override + int get hashCode => Object.hash(labelColor, inactiveGrayColor); +} diff --git a/packages/cupertino_ui/lib/src/theme.dart b/packages/cupertino_ui/lib/src/theme.dart new file mode 100644 index 000000000000..a010e5800409 --- /dev/null +++ b/packages/cupertino_ui/lib/src/theme.dart @@ -0,0 +1,703 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'button.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'icon_theme_data.dart'; +import 'text_theme.dart'; + +export 'package:flutter/foundation.dart' show Brightness; + +// Values derived from https://developer.apple.com/design/resources/. +const _CupertinoThemeDefaults _kDefaultTheme = _CupertinoThemeDefaults( + null, + CupertinoColors.systemBlue, + CupertinoColors.white, + CupertinoDynamicColor.withBrightness( + color: Color(0xF0F9F9F9), + darkColor: Color(0xF01D1D1D), + // Values extracted from navigation bar. For toolbar or tabbar the dark color is 0xF0161616. + ), + CupertinoColors.systemBackground, + CupertinoColors.systemBlue, + false, + _CupertinoTextThemeDefaults(CupertinoColors.label, CupertinoColors.inactiveGray), +); + +/// Applies a visual styling theme to descendant Cupertino widgets. +/// +/// Affects the color and text styles of Cupertino widgets whose styling +/// are not overridden when constructing the respective widgets instances. +/// +/// Descendant widgets can retrieve the current [CupertinoThemeData] by calling +/// [CupertinoTheme.of]. An [InheritedWidget] dependency is created when +/// an ancestor [CupertinoThemeData] is retrieved via [CupertinoTheme.of]. +/// +/// The [CupertinoTheme] widget implies an [IconTheme] widget, whose +/// [IconTheme.data] has the same color as [CupertinoThemeData.primaryColor] +/// +/// See also: +/// +/// * [CupertinoThemeData], specifies the theme's visual styling. +/// * [CupertinoApp], which will automatically add a [CupertinoTheme] based on the +/// value of [CupertinoApp.theme]. +/// * [Theme], a Material theme which will automatically add a [CupertinoTheme] +/// with a [CupertinoThemeData] derived from the Material [ThemeData]. +class CupertinoTheme extends StatelessWidget { + /// Creates a [CupertinoTheme] to change descendant Cupertino widgets' styling. + const CupertinoTheme({super.key, required this.data, required this.child}); + + /// The [CupertinoThemeData] styling for this theme. + final CupertinoThemeData data; + + /// Retrieves the [CupertinoThemeData] from the closest ancestor [CupertinoTheme] + /// widget, or a default [CupertinoThemeData] if no [CupertinoTheme] ancestor + /// exists. + /// + /// Resolves all the colors defined in that [CupertinoThemeData] against the + /// given [BuildContext] on a best-effort basis. + static CupertinoThemeData of(BuildContext context) { + final InheritedCupertinoTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType(); + return (inheritedTheme?.theme.data ?? const CupertinoThemeData()).resolveFrom(context); + } + + /// Retrieves the [Brightness] to use for descendant Cupertino widgets, based + /// on the value of [CupertinoThemeData.brightness] in the given [context]. + /// + /// If no [CupertinoTheme] can be found in the given [context], or its `brightness` + /// is null, it will fall back to [MediaQueryData.platformBrightness]. + /// + /// Throws an exception if no valid [CupertinoTheme] or [MediaQuery] widgets + /// exist in the ancestry tree. + /// + /// See also: + /// + /// * [maybeBrightnessOf], which returns null if no valid [CupertinoTheme] or + /// [MediaQuery] exists, instead of throwing. + /// * [CupertinoThemeData.brightness], the property takes precedence over + /// [MediaQueryData.platformBrightness] for descendant Cupertino widgets. + static Brightness brightnessOf(BuildContext context) { + final InheritedCupertinoTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType(); + return inheritedTheme?.theme.data.brightness ?? MediaQuery.platformBrightnessOf(context); + } + + /// Retrieves the [Brightness] to use for descendant Cupertino widgets, based + /// on the value of [CupertinoThemeData.brightness] in the given [context]. + /// + /// If no [CupertinoTheme] can be found in the given [context], it will fall + /// back to [MediaQueryData.platformBrightness]. + /// + /// Returns null if no valid [CupertinoTheme] or [MediaQuery] widgets exist in + /// the ancestry tree. + /// + /// See also: + /// + /// * [CupertinoThemeData.brightness], the property takes precedence over + /// [MediaQueryData.platformBrightness] for descendant Cupertino widgets. + /// * [brightnessOf], which throws if no valid [CupertinoTheme] or + /// [MediaQuery] exists, instead of returning null. + static Brightness? maybeBrightnessOf(BuildContext context) { + final InheritedCupertinoTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType(); + return inheritedTheme?.theme.data.brightness ?? MediaQuery.maybePlatformBrightnessOf(context); + } + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + Widget build(BuildContext context) { + return InheritedCupertinoTheme( + theme: this, + child: IconTheme( + data: CupertinoIconThemeData(color: data.primaryColor), + child: child, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + data.debugFillProperties(properties); + } +} + +/// Provides a [CupertinoTheme] to all descendants. +class InheritedCupertinoTheme extends InheritedTheme { + /// Creates an [InheritedTheme] that provides a [CupertinoTheme] to all + /// descendants. + const InheritedCupertinoTheme({super.key, required this.theme, required super.child}); + + /// The [CupertinoTheme] that is provided to widgets lower in the tree. + final CupertinoTheme theme; + + @override + Widget wrap(BuildContext context, Widget child) { + return CupertinoTheme(data: theme.data, child: child); + } + + @override + bool updateShouldNotify(InheritedCupertinoTheme oldWidget) => theme.data != oldWidget.theme.data; +} + +/// Styling specifications for a [CupertinoTheme]. +/// +/// All constructor parameters can be null, in which case a +/// [CupertinoColors.activeBlue] based default iOS theme styling is used. +/// +/// Parameters can also be partially specified, in which case some parameters +/// will cascade down to other dependent parameters to create a cohesive +/// visual effect. For instance, if a [primaryColor] is specified, it would +/// cascade down to affect some fonts in [textTheme] if [textTheme] is not +/// specified. +/// +/// See also: +/// +/// * [CupertinoTheme], in which this [CupertinoThemeData] is inserted. +/// * [ThemeData], a Material equivalent that also configures Cupertino +/// styling via a [CupertinoThemeData] subclass [MaterialBasedCupertinoThemeData]. +@immutable +class CupertinoThemeData extends NoDefaultCupertinoThemeData with Diagnosticable { + /// Creates a [CupertinoTheme] styling specification. + /// + /// Unspecified parameters default to a reasonable iOS default style. + const CupertinoThemeData({ + Brightness? brightness, + Color? primaryColor, + Color? primaryContrastingColor, + CupertinoTextThemeData? textTheme, + Color? barBackgroundColor, + Color? scaffoldBackgroundColor, + Color? selectionHandleColor, + bool? applyThemeToAll, + }) : this.raw( + brightness, + primaryColor, + primaryContrastingColor, + textTheme, + barBackgroundColor, + scaffoldBackgroundColor, + selectionHandleColor, + applyThemeToAll, + ); + + /// Same as the default constructor but with positional arguments to avoid + /// forgetting any and to specify all arguments. + /// + /// Used by subclasses to get the superclass's defaulting behaviors. + @protected + const CupertinoThemeData.raw( + Brightness? brightness, + Color? primaryColor, + Color? primaryContrastingColor, + CupertinoTextThemeData? textTheme, + Color? barBackgroundColor, + Color? scaffoldBackgroundColor, + Color? selectionHandleColor, + bool? applyThemeToAll, + ) : this._rawWithDefaults( + brightness, + primaryColor, + primaryContrastingColor, + textTheme, + barBackgroundColor, + scaffoldBackgroundColor, + selectionHandleColor, + applyThemeToAll, + _kDefaultTheme, + ); + + const CupertinoThemeData._rawWithDefaults( + Brightness? brightness, + Color? primaryColor, + Color? primaryContrastingColor, + CupertinoTextThemeData? textTheme, + Color? barBackgroundColor, + Color? scaffoldBackgroundColor, + Color? selectionHandleColor, + bool? applyThemeToAll, + this._defaults, + ) : super( + brightness: brightness, + primaryColor: primaryColor, + primaryContrastingColor: primaryContrastingColor, + textTheme: textTheme, + barBackgroundColor: barBackgroundColor, + scaffoldBackgroundColor: scaffoldBackgroundColor, + selectionHandleColor: selectionHandleColor, + applyThemeToAll: applyThemeToAll, + ); + + final _CupertinoThemeDefaults _defaults; + + @override + Color get primaryColor => super.primaryColor ?? _defaults.primaryColor; + + @override + Color get primaryContrastingColor => + super.primaryContrastingColor ?? _defaults.primaryContrastingColor; + + @override + CupertinoTextThemeData get textTheme { + return super.textTheme ?? + _defaults.textThemeDefaults.createDefaults(primaryColor: primaryColor); + } + + @override + Color get barBackgroundColor => super.barBackgroundColor ?? _defaults.barBackgroundColor; + + @override + Color get scaffoldBackgroundColor => + super.scaffoldBackgroundColor ?? _defaults.scaffoldBackgroundColor; + + @override + Color get selectionHandleColor => super.selectionHandleColor ?? _defaults.selectionHandleColor; + + @override + bool get applyThemeToAll => super.applyThemeToAll ?? _defaults.applyThemeToAll; + + @override + NoDefaultCupertinoThemeData noDefault() { + return NoDefaultCupertinoThemeData( + brightness: super.brightness, + primaryColor: super.primaryColor, + primaryContrastingColor: super.primaryContrastingColor, + textTheme: super.textTheme, + barBackgroundColor: super.barBackgroundColor, + scaffoldBackgroundColor: super.scaffoldBackgroundColor, + selectionHandleColor: super.selectionHandleColor, + applyThemeToAll: super.applyThemeToAll, + ); + } + + @override + CupertinoThemeData resolveFrom(BuildContext context) { + Color? convertColor(Color? color) => CupertinoDynamicColor.maybeResolve(color, context); + + return CupertinoThemeData._rawWithDefaults( + brightness, + convertColor(super.primaryColor), + convertColor(super.primaryContrastingColor), + super.textTheme?.resolveFrom(context), + convertColor(super.barBackgroundColor), + convertColor(super.scaffoldBackgroundColor), + convertColor(super.selectionHandleColor), + applyThemeToAll, + _defaults.resolveFrom(context, super.textTheme == null), + ); + } + + @override + CupertinoThemeData copyWith({ + Brightness? brightness, + Color? primaryColor, + Color? primaryContrastingColor, + CupertinoTextThemeData? textTheme, + Color? barBackgroundColor, + Color? scaffoldBackgroundColor, + Color? selectionHandleColor, + bool? applyThemeToAll, + }) { + return CupertinoThemeData._rawWithDefaults( + brightness ?? super.brightness, + primaryColor ?? super.primaryColor, + primaryContrastingColor ?? super.primaryContrastingColor, + textTheme ?? super.textTheme, + barBackgroundColor ?? super.barBackgroundColor, + scaffoldBackgroundColor ?? super.scaffoldBackgroundColor, + selectionHandleColor ?? super.selectionHandleColor, + applyThemeToAll ?? super.applyThemeToAll, + _defaults, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultData = CupertinoThemeData(); + properties.add(EnumProperty('brightness', brightness, defaultValue: null)); + properties.add( + createCupertinoColorProperty( + 'primaryColor', + primaryColor, + defaultValue: defaultData.primaryColor, + ), + ); + properties.add( + createCupertinoColorProperty( + 'primaryContrastingColor', + primaryContrastingColor, + defaultValue: defaultData.primaryContrastingColor, + ), + ); + properties.add( + createCupertinoColorProperty( + 'barBackgroundColor', + barBackgroundColor, + defaultValue: defaultData.barBackgroundColor, + ), + ); + properties.add( + createCupertinoColorProperty( + 'scaffoldBackgroundColor', + scaffoldBackgroundColor, + defaultValue: defaultData.scaffoldBackgroundColor, + ), + ); + properties.add( + createCupertinoColorProperty( + 'selectionHandleColor', + selectionHandleColor, + defaultValue: defaultData.selectionHandleColor, + ), + ); + properties.add( + DiagnosticsProperty( + 'applyThemeToAll', + applyThemeToAll, + defaultValue: defaultData.applyThemeToAll, + ), + ); + textTheme.debugFillProperties(properties); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is CupertinoThemeData && + other.brightness == brightness && + other.primaryColor == primaryColor && + other.primaryContrastingColor == primaryContrastingColor && + other.textTheme == textTheme && + other.barBackgroundColor == barBackgroundColor && + other.scaffoldBackgroundColor == scaffoldBackgroundColor && + other.selectionHandleColor == selectionHandleColor && + other.applyThemeToAll == applyThemeToAll; + } + + @override + int get hashCode => Object.hash( + brightness, + primaryColor, + primaryContrastingColor, + textTheme, + barBackgroundColor, + scaffoldBackgroundColor, + selectionHandleColor, + applyThemeToAll, + ); +} + +/// Styling specifications for a cupertino theme without default values for +/// unspecified properties. +/// +/// Unlike [CupertinoThemeData] instances of this class do not return default +/// values for properties that have been left unspecified in the constructor. +/// Instead, unspecified properties will return null. This is used by +/// Material's [ThemeData.cupertinoOverrideTheme]. +/// +/// See also: +/// +/// * [CupertinoThemeData], which uses reasonable default values for +/// unspecified theme properties. +@immutable +class NoDefaultCupertinoThemeData { + /// Creates a [NoDefaultCupertinoThemeData] styling specification. + /// + /// Unspecified properties default to null. + const NoDefaultCupertinoThemeData({ + this.brightness, + this.primaryColor, + this.primaryContrastingColor, + this.textTheme, + this.barBackgroundColor, + this.scaffoldBackgroundColor, + this.selectionHandleColor, + this.applyThemeToAll, + }); + + /// The brightness override for Cupertino descendants. + /// + /// Defaults to null. If a non-null [Brightness] is specified, the value will + /// take precedence over the ambient [MediaQueryData.platformBrightness], when + /// determining the brightness of descendant Cupertino widgets. + /// + /// If coming from a Material [Theme] and unspecified, [brightness] will be + /// derived from the Material [ThemeData]'s [brightness]. + /// + /// See also: + /// + /// * [MaterialBasedCupertinoThemeData], a [CupertinoThemeData] that defers + /// [brightness] to its Material [Theme] parent if it's unspecified. + /// + /// * [CupertinoTheme.brightnessOf], a method used to retrieve the overall + /// [Brightness] from a [BuildContext], for Cupertino widgets. + final Brightness? brightness; + + /// A color used on interactive elements of the theme. + /// + /// This color is generally used on text and icons in buttons and tappable + /// elements. Defaults to [CupertinoColors.activeBlue]. + /// + /// If coming from a Material [Theme] and unspecified, [primaryColor] will be + /// derived from the Material [ThemeData]'s `colorScheme.primary`. However, in + /// iOS styling, the [primaryColor] is more sparsely used than in Material + /// Design where the [primaryColor] can appear on non-interactive surfaces like + /// the [AppBar] background, [TextField] borders etc. + /// + /// See also: + /// + /// * [MaterialBasedCupertinoThemeData], a [CupertinoThemeData] that defers + /// [primaryColor] to its Material [Theme] parent if it's unspecified. + final Color? primaryColor; + + /// A color that must be easy to see when rendered on a [primaryColor] background. + /// + /// For example, this color is used for a [CupertinoButton]'s text and icons + /// when the button's background is [primaryColor]. + /// + /// If coming from a Material [Theme] and unspecified, [primaryContrastingColor] + /// will be derived from the Material [ThemeData]'s `colorScheme.onPrimary`. + /// + /// See also: + /// + /// * [MaterialBasedCupertinoThemeData], a [CupertinoThemeData] that defers + /// [primaryContrastingColor] to its Material [Theme] parent if it's unspecified. + final Color? primaryContrastingColor; + + /// Text styles used by Cupertino widgets. + /// + /// Derived from [primaryColor] if unspecified. + final CupertinoTextThemeData? textTheme; + + /// Background color of the top nav bar and bottom tab bar. + /// + /// Defaults to a light gray in light mode, or a dark translucent gray color in + /// dark mode. + final Color? barBackgroundColor; + + /// Background color of the scaffold. + /// + /// Defaults to [CupertinoColors.systemBackground]. + final Color? scaffoldBackgroundColor; + + /// The color of the selection handles on the text field. + /// + /// Defaults to [CupertinoColors.systemBlue]. + final Color? selectionHandleColor; + + /// Flag to apply this theme to all descendant Cupertino widgets. + /// + /// Certain Cupertino widgets previously didn't use theming, matching past + /// versions of iOS. For example, [CupertinoSwitch]s always used + /// [CupertinoColors.systemGreen] when active. + /// + /// Today, however, these widgets can indeed be themed on iOS. Moreover on + /// macOS, the accent color is reflected in these widgets. Turning this flag + /// on ensures that descendant Cupertino widgets will be themed accordingly. + /// + /// This flag currently applies to the following widgets: + /// - [CupertinoSwitch] & [Switch.adaptive] + /// + /// Defaults to false. + final bool? applyThemeToAll; + + /// Returns an instance of the theme data whose property getters only return + /// the construction time specifications with no derived values. + /// + /// Used in Material themes to let unspecified properties fallback to Material + /// theme properties instead of iOS defaults. + NoDefaultCupertinoThemeData noDefault() => this; + + /// Returns a new theme data with all its colors resolved against the + /// given [BuildContext]. + /// + /// Called by [CupertinoTheme.of] to resolve colors defined in the retrieved + /// [CupertinoThemeData]. + @protected + NoDefaultCupertinoThemeData resolveFrom(BuildContext context) { + Color? convertColor(Color? color) => CupertinoDynamicColor.maybeResolve(color, context); + + return NoDefaultCupertinoThemeData( + brightness: brightness, + primaryColor: convertColor(primaryColor), + primaryContrastingColor: convertColor(primaryContrastingColor), + textTheme: textTheme?.resolveFrom(context), + barBackgroundColor: convertColor(barBackgroundColor), + scaffoldBackgroundColor: convertColor(scaffoldBackgroundColor), + selectionHandleColor: convertColor(selectionHandleColor), + applyThemeToAll: applyThemeToAll, + ); + } + + /// Creates a copy of the theme data with specified attributes overridden. + /// + /// Only the current instance's specified attributes are copied instead of + /// derived values. For instance, if the current [textTheme] is implied from + /// the current [primaryColor] because it was not specified, copying with a + /// different [primaryColor] will also change the copy's implied [textTheme]. + NoDefaultCupertinoThemeData copyWith({ + Brightness? brightness, + Color? primaryColor, + Color? primaryContrastingColor, + CupertinoTextThemeData? textTheme, + Color? barBackgroundColor, + Color? scaffoldBackgroundColor, + Color? selectionHandleColor, + bool? applyThemeToAll, + }) { + return NoDefaultCupertinoThemeData( + brightness: brightness ?? this.brightness, + primaryColor: primaryColor ?? this.primaryColor, + primaryContrastingColor: primaryContrastingColor ?? this.primaryContrastingColor, + textTheme: textTheme ?? this.textTheme, + barBackgroundColor: barBackgroundColor ?? this.barBackgroundColor, + scaffoldBackgroundColor: scaffoldBackgroundColor ?? this.scaffoldBackgroundColor, + selectionHandleColor: selectionHandleColor ?? this.selectionHandleColor, + applyThemeToAll: applyThemeToAll ?? this.applyThemeToAll, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is NoDefaultCupertinoThemeData && + other.brightness == brightness && + other.primaryColor == primaryColor && + other.primaryContrastingColor == primaryContrastingColor && + other.textTheme == textTheme && + other.barBackgroundColor == barBackgroundColor && + other.scaffoldBackgroundColor == scaffoldBackgroundColor && + other.applyThemeToAll == applyThemeToAll; + } + + @override + int get hashCode => Object.hash( + brightness, + primaryColor, + primaryContrastingColor, + textTheme, + barBackgroundColor, + scaffoldBackgroundColor, + applyThemeToAll, + ); +} + +@immutable +class _CupertinoThemeDefaults { + const _CupertinoThemeDefaults( + this.brightness, + this.primaryColor, + this.primaryContrastingColor, + this.barBackgroundColor, + this.scaffoldBackgroundColor, + this.selectionHandleColor, + this.applyThemeToAll, + this.textThemeDefaults, + ); + + final Brightness? brightness; + final Color primaryColor; + final Color primaryContrastingColor; + final Color barBackgroundColor; + final Color scaffoldBackgroundColor; + final Color selectionHandleColor; + final bool applyThemeToAll; + final _CupertinoTextThemeDefaults textThemeDefaults; + + _CupertinoThemeDefaults resolveFrom(BuildContext context, bool resolveTextTheme) { + Color convertColor(Color color) => CupertinoDynamicColor.resolve(color, context); + + return _CupertinoThemeDefaults( + brightness, + convertColor(primaryColor), + convertColor(primaryContrastingColor), + convertColor(barBackgroundColor), + convertColor(scaffoldBackgroundColor), + convertColor(selectionHandleColor), + applyThemeToAll, + resolveTextTheme ? textThemeDefaults.resolveFrom(context) : textThemeDefaults, + ); + } +} + +@immutable +class _CupertinoTextThemeDefaults { + const _CupertinoTextThemeDefaults(this.labelColor, this.inactiveGray); + + final Color labelColor; + final Color inactiveGray; + + _CupertinoTextThemeDefaults resolveFrom(BuildContext context) { + return _CupertinoTextThemeDefaults( + CupertinoDynamicColor.resolve(labelColor, context), + CupertinoDynamicColor.resolve(inactiveGray, context), + ); + } + + CupertinoTextThemeData createDefaults({required Color primaryColor}) { + return _DefaultCupertinoTextThemeData( + primaryColor: primaryColor, + labelColor: labelColor, + inactiveGray: inactiveGray, + ); + } +} + +// CupertinoTextThemeData with no text styles explicitly specified. +// The implementation of this class may need to be updated when any of the default +// text styles changes. +class _DefaultCupertinoTextThemeData extends CupertinoTextThemeData { + const _DefaultCupertinoTextThemeData({ + required this.labelColor, + required this.inactiveGray, + required super.primaryColor, + }); + + final Color labelColor; + final Color inactiveGray; + + @override + TextStyle get textStyle => super.textStyle.copyWith(color: labelColor); + + @override + TextStyle get tabLabelTextStyle => super.tabLabelTextStyle.copyWith(color: inactiveGray); + + @override + TextStyle get navTitleTextStyle => super.navTitleTextStyle.copyWith(color: labelColor); + + @override + TextStyle get navLargeTitleTextStyle => super.navLargeTitleTextStyle.copyWith(color: labelColor); + + @override + TextStyle get pickerTextStyle => super.pickerTextStyle.copyWith(color: labelColor); + + @override + TextStyle get dateTimePickerTextStyle => + super.dateTimePickerTextStyle.copyWith(color: labelColor); +} diff --git a/packages/cupertino_ui/lib/src/thumb_painter.dart b/packages/cupertino_ui/lib/src/thumb_painter.dart new file mode 100644 index 000000000000..2a1b3887dd40 --- /dev/null +++ b/packages/cupertino_ui/lib/src/thumb_painter.dart @@ -0,0 +1,70 @@ +// 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. + +/// @docImport 'slider.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:flutter/painting.dart'; + +import 'colors.dart'; + +const Color _kThumbBorderColor = Color(0x0A000000); + +const List _kSwitchBoxShadows = [ + BoxShadow(color: Color(0x26000000), offset: Offset(0, 3), blurRadius: 8.0), + BoxShadow(color: Color(0x0F000000), offset: Offset(0, 3), blurRadius: 1.0), +]; + +const List _kSliderBoxShadows = [ + BoxShadow(color: Color(0x26000000), offset: Offset(0, 3), blurRadius: 8.0), + BoxShadow(color: Color(0x29000000), offset: Offset(0, 1), blurRadius: 1.0), + BoxShadow(color: Color(0x1A000000), offset: Offset(0, 3), blurRadius: 1.0), +]; + +/// Paints an iOS-style slider thumb or switch thumb. +/// +/// Used by [CupertinoSlider]. +class CupertinoThumbPainter { + /// Creates an object that paints an iOS-style slider thumb. + const CupertinoThumbPainter({ + this.color = CupertinoColors.white, + this.shadows = _kSliderBoxShadows, + }); + + /// Creates an object that paints an iOS-style switch thumb. + const CupertinoThumbPainter.switchThumb({ + Color color = CupertinoColors.white, + List shadows = _kSwitchBoxShadows, + }) : this(color: color, shadows: shadows); + + /// The color of the interior of the thumb. + final Color color; + + /// The list of [BoxShadow] to paint below the thumb. + final List shadows; + + /// Half the default diameter of the thumb. + static const double radius = 14.0; + + /// The default amount the thumb should be extended horizontally when pressed. + static const double extension = 7.0; + + /// Paints the thumb onto the given canvas in the given rectangle. + /// + /// Consider using [radius] and [extension] when deciding how large a + /// rectangle to use for the thumb. + void paint(Canvas canvas, Rect rect) { + // Paint RRects instead of RSuperellipses here, because practically + // [CupertinoSlider] only draws circular thumbs. + final thumbShape = RRect.fromRectAndRadius(rect, Radius.circular(rect.shortestSide / 2.0)); + + for (final BoxShadow shadow in shadows) { + canvas.drawRRect(thumbShape.shift(shadow.offset), shadow.toPaint()); + } + + canvas.drawRRect(thumbShape.inflate(0.5), Paint()..color = _kThumbBorderColor); + canvas.drawRRect(thumbShape, Paint()..color = color); + } +} diff --git a/packages/cupertino_ui/pubspec.yaml b/packages/cupertino_ui/pubspec.yaml index df913bbfc049..2594dc66367c 100644 --- a/packages/cupertino_ui/pubspec.yaml +++ b/packages/cupertino_ui/pubspec.yaml @@ -3,11 +3,15 @@ description: The official Flutter Cupertino Design Library, implementing the iOS version: 0.0.1 repository: https://github.com/flutter/packages/tree/main/packages/cupertino_ui issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A%20cupertino%22 +publish_to: 'none' environment: sdk: ^3.9.0 flutter: ">=3.35.0" +workspace: + - cupertino_ui_examples + dependencies: flutter: sdk: flutter diff --git a/packages/cupertino_ui/test/cupertino/README.md b/packages/cupertino_ui/test/cupertino/README.md new file mode 100644 index 000000000000..dea158134e42 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/README.md @@ -0,0 +1,10 @@ +# Tests for the Cupertino package + +Avoid importing the Material 'package:flutter/material.dart' in these tests as +we're trying to test the Cupertino package in standalone scenarios. + +The 'material' subdirectory contains tests for cross-interactions of Material +Cupertino widgets in hybridized apps. + +Some tests may also be replicated in the Material tests when Material reuses +Cupertino components on iOS such as page transitions and text editing. diff --git a/packages/cupertino_ui/test/cupertino/action_sheet_test.dart b/packages/cupertino_ui/test/cupertino/action_sheet_test.dart new file mode 100644 index 000000000000..8fb23eb6fd42 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/action_sheet_test.dart @@ -0,0 +1,2645 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Overall appearance is correct for the light theme', (WidgetTester tester) async { + await tester.pumpWidget( + TestScaffoldApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + actionSheet: CupertinoActionSheet( + message: const Text('The title'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + // This golden file also verifies the structure of an action sheet that + // has a message, no title, and no overscroll for any sections (in contrast + // to cupertinoActionSheet.dark-theme.png). + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoActionSheet.overall-light-theme.png'), + ); + + await gesture.up(); + }); + + testWidgets('Overall appearance is correct for the dark theme', (WidgetTester tester) async { + await tester.pumpWidget( + TestScaffoldApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + actionSheet: CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: List.generate( + 20, + (int i) => CupertinoActionSheetAction(onPressed: () {}, child: Text('Button $i')), + ), + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + // This golden file also verifies the structure of an action sheet that + // has both a message and a title, and an overscrolled action section (in + // contrast to cupertinoActionSheet.light-theme.png). + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoActionSheet.overall-dark-theme.png'), + ); + + await gesture.up(); + }); + + testWidgets('Button appearance is correct with text scaling', (WidgetTester tester) async { + // Verifies layout of action button in various text scaling by drawing + // buttons in all 12 iOS text scales in one golden image. + + // The following function returns a CupertinoActionSheetAction that: + // * Has a fixed width + // * Is unconstrained in height + // * Is aligned center in a grid of fixed height + // * Is surrounded by a black border + const double buttonWidth = 400; + const double rowHeight = 100; + Widget testButton(double contextBodySize) { + const standardHigBody = 17.0; + final double contextScaleFactor = contextBodySize / standardHigBody; + return OverrideMediaQuery( + transformer: (MediaQueryData data) { + return data.copyWith(textScaler: TextScaler.linear(contextScaleFactor)); + }, + child: SizedBox( + height: rowHeight, + child: Center( + child: UnconstrainedBox( + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: buttonWidth), + child: DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + child: CupertinoActionSheetAction(onPressed: () {}, child: const Text('Button')), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: Column( + children: [ + Row(children: [/*xs*/ testButton(14), /*s*/ testButton(15)]), + Row(children: [/*m*/ testButton(16), /*l*/ testButton(17)]), + Row(children: [/*xl*/ testButton(19), /*xxl*/ testButton(21)]), + Row(children: [/*xxxl*/ testButton(23), /*ax1*/ testButton(28)]), + Row(children: [/*ax2*/ testButton(33), /*ax3*/ testButton(40)]), + Row(children: [/*ax4*/ testButton(47), /*ax5*/ testButton(53)]), + ], + ), + ), + ), + ), + ); + + final Iterable buttons = tester.widgetList( + find.text('Button', findRichText: true), + ); + final Iterable sizes = buttons.map((RichText text) { + return text.textScaler.scale(text.text.style!.fontSize!); + }); + expect( + sizes, + [ + 21, + 21, + 21, + 21, + 23, + 24, + 24, + 28, + 33, + 40, + 47, + 53, + ].map((double size) => moreOrLessEquals(size, epsilon: 0.001)), + ); + + await expectLater( + find.byType(Column), + matchesGoldenFile('cupertinoActionSheet.textScaling.png'), + ); + }); + + testWidgets('Verify that a tap on modal barrier dismisses an action sheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + const CupertinoActionSheet(title: Text('Action Sheet')), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(find.text('Action Sheet'), findsOneWidget); + + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pump(); + expect(find.text('Action Sheet'), findsNothing); + }); + + testWidgets('Verify that a tap on title section (not buttons) does not dismiss an action sheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + const CupertinoActionSheet(title: Text('Action Sheet')), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + + expect(find.text('Action Sheet'), findsOneWidget); + + await tester.tap(find.text('Action Sheet')); + await tester.pump(); + expect(find.text('Action Sheet'), findsOneWidget); + }); + + testWidgets('Action sheet destructive text style', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + CupertinoActionSheetAction( + isDestructiveAction: true, + child: const Text('Ok'), + onPressed: () {}, + ), + ), + ); + + final DefaultTextStyle widget = tester.widget(find.widgetWithText(DefaultTextStyle, 'Ok')); + + expect( + widget.style.color, + const CupertinoDynamicColor.withBrightnessAndContrast( + color: Color.fromARGB(255, 255, 59, 48), + darkColor: Color.fromARGB(255, 255, 69, 58), + highContrastColor: Color.fromARGB(255, 215, 0, 21), + darkHighContrastColor: Color.fromARGB(255, 255, 105, 97), + ), + ); + }); + + testWidgets('Action sheet dark mode', (WidgetTester tester) async { + final Widget action = CupertinoActionSheetAction(child: const Text('action'), onPressed: () {}); + + Brightness brightness = Brightness.light; + late StateSetter stateSetter; + + TextStyle actionTextStyle(String text) { + return tester + .widget( + find.descendant( + of: find.widgetWithText(CupertinoActionSheetAction, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + stateSetter = setter; + return CupertinoTheme( + data: CupertinoThemeData( + brightness: brightness, + primaryColor: const CupertinoDynamicColor.withBrightnessAndContrast( + color: Color.fromARGB(255, 0, 122, 255), + darkColor: Color.fromARGB(255, 10, 132, 255), + highContrastColor: Color.fromARGB(255, 0, 64, 221), + darkHighContrastColor: Color.fromARGB(255, 64, 156, 255), + ), + ), + child: CupertinoActionSheet(actions: [action]), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(actionTextStyle('action').color!.value, const Color.fromARGB(255, 0, 122, 255).value); + + stateSetter(() { + brightness = Brightness.dark; + }); + await tester.pump(); + + expect(actionTextStyle('action').color!.value, const Color.fromARGB(255, 10, 132, 255).value); + }); + + testWidgets('Action sheet default text style', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + CupertinoActionSheetAction( + isDefaultAction: true, + child: const Text('Ok'), + onPressed: () {}, + ), + ), + ); + + final DefaultTextStyle widget = tester.widget(find.widgetWithText(DefaultTextStyle, 'Ok')); + + expect(widget.style.fontWeight, equals(FontWeight.w600)); + }); + + testWidgets('Action sheet text styles are correct when both title and message are included', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + const CupertinoActionSheet(title: Text('Action Sheet'), message: Text('An action sheet')), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final DefaultTextStyle titleStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Action Sheet'), + ); + final DefaultTextStyle messageStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'An action sheet'), + ); + + expect(titleStyle.style.fontWeight, FontWeight.w600); + expect(messageStyle.style.fontWeight, FontWeight.w400); + }); + + testWidgets('Action sheet text styles are correct when title but no message is included', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + const CupertinoActionSheet(title: Text('Action Sheet')), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final DefaultTextStyle titleStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'Action Sheet'), + ); + + expect(titleStyle.style.fontWeight, FontWeight.w400); + }); + + testWidgets('Action sheet text styles are correct when message but no title is included', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + const CupertinoActionSheet(message: Text('An action sheet')), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final DefaultTextStyle messageStyle = tester.firstWidget( + find.widgetWithText(DefaultTextStyle, 'An action sheet'), + ); + + expect(messageStyle.style.fontWeight, FontWeight.w600); + }); + + testWidgets('Content section but no actions', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message.'), + messageScrollController: scrollController, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Content section should be at the bottom left of action sheet + // (minus padding). + expect( + tester.getBottomLeft(find.byType(ClipRSuperellipse)), + tester.getBottomLeft(find.byType(CupertinoActionSheet)) - const Offset(-8.0, 8.0), + ); + + // Check that the dialog size is the same as the content section size + // (minus padding). + expect( + tester.getSize(find.byType(ClipRSuperellipse)).height, + tester.getSize(find.byType(CupertinoActionSheet)).height - 16.0, + ); + + expect( + tester.getSize(find.byType(ClipRSuperellipse)).width, + tester.getSize(find.byType(CupertinoActionSheet)).width - 16.0, + ); + }); + + testWidgets('Actions but no content section', (WidgetTester tester) async { + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + actionScrollController: actionScrollController, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final Finder finder = find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_ActionSheetActionSection'; + }); + + // Check that the title/message section is not displayed (action section is + // at the top of the action sheet + padding). + expect( + tester.getTopLeft(finder), + tester.getTopLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, 8.0), + ); + + expect( + tester.getTopLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, 8.0), + tester.getTopLeft(find.widgetWithText(CupertinoActionSheetAction, 'One')), + ); + expect( + tester.getBottomLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, -8.0), + tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two')), + ); + }); + + testWidgets('Action section is scrollable', (WidgetTester tester) async { + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message.'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Three'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Four'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Five'), onPressed: () {}), + ], + actionScrollController: actionScrollController, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Check that the action buttons list is scrollable. + expect(actionScrollController.offset, 0.0); + actionScrollController.jumpTo(100.0); + expect(actionScrollController.offset, 100.0); + actionScrollController.jumpTo(0.0); + + // Check that the action buttons are aligned vertically. + expect( + tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'One')).dx, + equals(400.0), + ); + expect( + tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dx, + equals(400.0), + ); + expect( + tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Three')).dx, + equals(400.0), + ); + expect( + tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Four')).dx, + equals(400.0), + ); + expect( + tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Five')).dx, + equals(400.0), + ); + + // Check that the action buttons are the correct heights. + expect( + tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'One')).height, + equals(95.4), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Two')).height, + equals(95.4), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Three')).height, + equals(95.4), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Four')).height, + equals(95.4), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Five')).height, + equals(95.4), + ); + }); + + testWidgets('Content section is scrollable', (WidgetTester tester) async { + final messageScrollController = ScrollController(); + addTearDown(messageScrollController.dispose); + late double screenHeight; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + screenHeight = MediaQuery.heightOf(context); + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + messageScrollController: messageScrollController, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(messageScrollController.offset, 0.0); + messageScrollController.jumpTo(100.0); + expect(messageScrollController.offset, 100.0); + // Set the scroll position back to zero. + messageScrollController.jumpTo(0.0); + + // Expect the action sheet to take all available height. + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, screenHeight); + }); + + testWidgets('CupertinoActionSheet scrollbars controllers should be different', ( + WidgetTester tester, + ) async { + // https://github.com/flutter/flutter/pull/81278 + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [CupertinoActionSheetAction(child: const Text('One'), onPressed: () {})], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final List scrollbars = find + .descendant( + of: find.byType(CupertinoActionSheet), + matching: find.byType(CupertinoScrollbar), + ) + .evaluate() + .map((Element e) => e.widget as CupertinoScrollbar) + .toList(); + + expect(scrollbars.length, 2); + expect(scrollbars[0].controller != scrollbars[1].controller, isTrue); + }); + + testWidgets('Actions section correctly renders overscrolls', (WidgetTester tester) async { + // Verifies that when the actions section overscrolls, the overscroll part + // is correctly covered with background. + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: List.generate( + 12, + (int i) => + CupertinoActionSheetAction(onPressed: () {}, child: Text('Button ${'*' * i}')), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button *'))); + await tester.pumpAndSettle(); + // The button should be pressed now, since the scrolling gesture has not + // taken over. + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.overscroll.0.png'), + ); + // The dragging gesture must be dispatched in at least two segments. + // After the first movement, the gesture is started, but the delta is still + // zero. The second movement gives the delta. + await gesture.moveBy(const Offset(0, 40)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, 100)); + // Test the top overscroll. Use `pump` not `pumpAndSettle` to verify the + // rendering result of the immediate next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.overscroll.1.png'), + ); + + await gesture.moveBy(const Offset(0, -300)); + // Test the bottom overscroll. Use `pump` not `pumpAndSettle` to verify the + // rendering result of the immediate next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.overscroll.2.png'), + ); + await gesture.up(); + }); + + testWidgets('Actions section correctly renders overscrolls with very far scrolls', ( + WidgetTester tester, + ) async { + // When the scroll is really far, the overscroll might be longer than the + // actions section, causing overflow if not controlled. + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + message: Text('message' * 300), + actions: List.generate( + 4, + (int i) => CupertinoActionSheetAction(onPressed: () {}, child: Text('Button $i')), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, 40)); // A short drag to start the gesture. + await tester.pumpAndSettle(); + // The drag is far enough to make the overscroll longer than the section. + await gesture.moveBy(const Offset(0, 1000)); + await tester.pump(); + // The buttons should be out of the screen + expect( + tester.getTopLeft(find.text('Button 0')).dy, + greaterThan(tester.getBottomLeft(find.byType(CupertinoActionSheet)).dy), + ); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.long-overscroll.0.png'), + ); + }); + + testWidgets('Takes maximum vertical space with one action and long content', ( + WidgetTester tester, + ) async { + // Ensure that if the actions section is shorter than + // _kActionSheetActionsSectionMinHeight, the content section can be assigned + // with the remaining vertical space to fill up the maximal height. + + late double screenHeight; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + screenHeight = MediaQuery.heightOf(context); + return CupertinoActionSheet( + message: Text('content ' * 1000), + actions: [ + CupertinoActionSheetAction(onPressed: () {}, child: const Text('Button 0')), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // Expect the action sheet to take all available height. + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, screenHeight); + }); + + testWidgets('Taps on button calls onPressed', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + await tester.tap(find.text('One')); + + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('One'), findsNothing); + }); + + testWidgets('Can tap after scrolling', (WidgetTester tester) async { + int? wasPressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: List.generate( + 20, + (int i) => CupertinoActionSheetAction( + onPressed: () { + expect(wasPressed, null); + wasPressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Button 19').hitTestable(), findsNothing); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await tester.pumpAndSettle(); + // The dragging gesture must be dispatched in at least two segments. + // The first movement starts the gesture without setting a delta. + await gesture.moveBy(const Offset(0, -20)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, -1000)); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Button 19').hitTestable(), findsOne); + + await tester.tap(find.text('Button 19')); + await tester.pumpAndSettle(); + expect(wasPressed, 19); + }); + + testWidgets('Taps at the padding of buttons calls onPressed', (WidgetTester tester) async { + // Ensures that the entire button responds to hit tests, not just the text + // part. + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + await tester.tapAt(tester.getTopLeft(find.text('One')) - const Offset(20, 0)); + + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on a button can be slided to other buttons', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { + expect(pressed, null); + pressed = 1; + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () { + expect(pressed, null); + pressed = 2; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(pressed, null); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Two'))); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.press-drag.png'), + ); + + await gesture.up(); + expect(pressed, 1); + await tester.pumpAndSettle(); + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on the content can be slided to other buttons', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('The title'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, false); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('The title'))); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('Cancel'))); + await tester.pumpAndSettle(); + await gesture.up(); + expect(wasPressed, true); + await tester.pumpAndSettle(); + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on the barrier can not be slided to buttons', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('The title'), + cancelButton: CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, false); + + // Press on the barrier. + final TestGesture gesture = await tester.startGesture(const Offset(100, 100)); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('Cancel'))); + await tester.pumpAndSettle(); + await gesture.up(); + expect(wasPressed, false); + await tester.pumpAndSettle(); + expect(find.text('Cancel'), findsOne); + }); + + testWidgets('Sliding taps can still yield to scrolling after horizontal movement', ( + WidgetTester tester, + ) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + message: Text('Long message' * 200), + actions: List.generate( + 10, + (int i) => CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Starts on a button. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + // Move horizontally. + await gesture.moveBy(const Offset(-10, 2)); + await gesture.moveBy(const Offset(-100, 2)); + await tester.pumpAndSettle(); + // Scroll up. + await gesture.moveBy(const Offset(0, -40)); + await gesture.moveBy(const Offset(0, -1000)); + await tester.pumpAndSettle(); + // Stop scrolling. + await gesture.up(); + await tester.pumpAndSettle(); + // The actions section should have been scrolled up and Button 9 is visible. + await tester.tap(find.text('Button 9')); + expect(pressed, 9); + }); + + testWidgets('Sliding taps is responsive even before the drag starts', ( + WidgetTester tester, + ) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + message: Text('Long message' * 200), + actions: List.generate( + 10, + (int i) => CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Find the location right within the upper edge of button 1. + final Offset start = + tester.getTopLeft(find.widgetWithText(CupertinoActionSheetAction, 'Button 1')) + + const Offset(30, 5); + // Verify that the start location is within button 1. + await tester.tapAt(start); + expect(pressed, 1); + pressed = null; + + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + // Move slightly upwards without starting the drag + await gesture.moveBy(const Offset(0, -10)); + await tester.pumpAndSettle(); + // Stop scrolling. + await gesture.up(); + await tester.pumpAndSettle(); + expect(pressed, 0); + }); + + testWidgets('Sliding taps only recognizes the primary pointer', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('The title'), + actions: List.generate( + 8, + (int i) => CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Start gesture 1 at button 0 + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await gesture1.moveBy(const Offset(0, 20)); // Starts the gesture + await tester.pumpAndSettle(); + + // Start gesture 2 at button 1. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await gesture2.moveBy(const Offset(0, 20)); // Starts the gesture + await tester.pumpAndSettle(); + + // Move gesture 1 to button 2 and release. + await gesture1.moveTo(tester.getCenter(find.text('Button 2'))); + await tester.pumpAndSettle(); + await gesture1.up(); + await tester.pumpAndSettle(); + + expect(pressed, 2); + pressed = null; + + // Tap at button 3, which becomes the new primary pointer and is recognized. + await tester.tap(find.text('Button 3')); + await tester.pumpAndSettle(); + expect(pressed, 3); + pressed = null; + + // Move gesture 2 to button 4 and release. + await gesture2.moveTo(tester.getCenter(find.text('Button 4'))); + await tester.pumpAndSettle(); + await gesture2.up(); + await tester.pumpAndSettle(); + + // Non-primary pointers should not be recognized. + expect(pressed, null); + }); + + testWidgets('Non-primary pointers can trigger scroll', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: List.generate( + 12, + (int i) => CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Start gesture 1 at button 0 + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Button 11')).dy, greaterThan(400)); + + // Start gesture 2 at button 1 and scrolls. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await gesture2.moveBy(const Offset(0, -20)); + await gesture2.moveBy(const Offset(0, -500)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Button 11')).dy, lessThan(400)); + + // Release gesture 1, which should not trigger any buttons. + await gesture1.up(); + await tester.pumpAndSettle(); + + expect(pressed, null); + }); + + testWidgets('Taps on legacy button calls onPressed and renders correctly', ( + WidgetTester tester, + ) async { + // Legacy buttons are implemented with [GestureDetector.onTap]. Apps that + // use customized legacy buttons should continue to work. + // + // Regression test for https://github.com/flutter/flutter/issues/150980 . + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + LegacyAction( + child: const Text('Legacy'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, isFalse); + + // Push the legacy button and hold for a while to activate the pressing effect. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Legacy'))); + await tester.pump(const Duration(seconds: 1)); + expect(wasPressed, isFalse); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.legacyButton.png'), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(wasPressed, isTrue); + expect(find.text('Legacy'), findsNothing); + }); + + testWidgets('Action sheet width is correct when given infinite horizontal space', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Row( + children: [ + CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(tester.getSize(find.byType(CupertinoActionSheet)).width, 600.0); + }); + + testWidgets('Action sheet height is correct when given infinite vertical space', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Column( + children: [ + CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, moreOrLessEquals(130.64)); + }); + + testWidgets('1 action button with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [CupertinoActionSheetAction(child: const Text('One'), onPressed: () {})], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // Action section is size of one action button. + expect(findScrollableActionsSectionRenderBox(tester).size.height, 57.17); + }); + + testWidgets('2 action buttons with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, moreOrLessEquals(84.0)); + }); + + testWidgets('3 action buttons with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Three'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, moreOrLessEquals(84.0)); + }); + + testWidgets('4+ action buttons with cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Three'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Four'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, moreOrLessEquals(84.0)); + }); + + testWidgets('1 action button without cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [CupertinoActionSheetAction(child: const Text('One'), onPressed: () {})], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, 57.17); + }); + + testWidgets('2+ action buttons without cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: Text('Very long content' * 200), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(findScrollableActionsSectionRenderBox(tester).size.height, moreOrLessEquals(84.0)); + }); + + testWidgets('Action sheet with just cancel button is correct', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // The action sheet consists of only a cancel button, so the height should + // be cancel button height + padding. + const double expectedHeight = + 57.17 // button height + + + 8 // bottom edge padding + + + 8; // top edge padding, since the screen has no top view padding + expect(tester.getSize(find.byType(CupertinoActionSheet)).height, expectedHeight); + expect(tester.getSize(find.byType(CupertinoActionSheet)).width, 600.0); + }); + + testWidgets('Cancel button tap calls onPressed', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return CupertinoActionSheet( + cancelButton: CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Cancel'))); + await tester.pumpAndSettle(); + // Verify that the cancel button shows the pressed color. + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.pressedCancel.png'), + ); + + await gesture.up(); + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Cancel'), findsNothing); + }); + + testWidgets('Layout is correct when cancel button is present', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect( + tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Cancel')).dy, + moreOrLessEquals(592.0), + ); + expect( + tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'One')).dy, + moreOrLessEquals(469.36), + ); + expect( + tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dy, + moreOrLessEquals(526.83), + ); + }); + + // Verify that on a phone with the given `viewSize` and `viewPadding`, the the + // main sheet of a full-height action sheet will have a size of + // `expectedSize`. + // + // The `viewSize` and `viewPadding` can be captured on simulator. Changing + // `expectedSize` should be accompanied by screenshot comparison. + Future verifyMaximumSize( + WidgetTester tester, { + required Size viewSize, + required EdgeInsets viewPadding, + required Size expectedSize, + }) async { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance; + await binding.setSurfaceSize(viewSize); + addTearDown(() => binding.setSurfaceSize(null)); + + await tester.pumpWidget( + OverrideMediaQuery( + transformer: (MediaQueryData data) { + return data.copyWith(size: viewSize, viewPadding: viewPadding, padding: viewPadding); + }, + child: createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + actions: List.generate( + 20, + (int i) => CupertinoActionSheetAction(onPressed: () {}, child: Text('Button $i')), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final Finder mainSheet = find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_ActionSheetMainSheet'; + }); + expect(tester.getSize(mainSheet), expectedSize); + } + + testWidgets('The maximum size is correct on iPhone SE gen 3', (WidgetTester tester) async { + const double expectedHeight = + 667 // View height + - + 20 // Top view padding + - + 20 // Top widget padding + - + 8; // Bottom edge padding + await verifyMaximumSize( + tester, + viewSize: const Size(375, 667), + viewPadding: const EdgeInsets.fromLTRB(0, 20, 0, 0), + expectedSize: const Size(359, expectedHeight), + ); + }); + + testWidgets('The maximum size is correct on iPhone 13 Pro', (WidgetTester tester) async { + const double expectedHeight = + 844 // View height + - + 47 // Top view padding + - + 47 // Top widget padding + - + 34; // Bottom view padding + await verifyMaximumSize( + tester, + viewSize: const Size(390, 844), + viewPadding: const EdgeInsets.fromLTRB(0, 47, 0, 34), + expectedSize: const Size(374, expectedHeight), + ); + }); + + testWidgets('The maximum size is correct on iPhone 15 Plus', (WidgetTester tester) async { + const double expectedHeight = + 932 // View height + - + 59 // Top view padding + - + 54 // Top widget padding + - + 34; // Bottom view padding + await verifyMaximumSize( + tester, + viewSize: const Size(430, 932), + viewPadding: const EdgeInsets.fromLTRB(0, 59, 0, 34), + expectedSize: const Size(414, expectedHeight), + ); + }); + + testWidgets('The maximum size is correct on iPhone 13 Pro landscape', ( + WidgetTester tester, + ) async { + const double expectedWidth = + 390 // View height + - + 8 * 2; // Edge padding + const double expectedHeight = + 390 // View height + - + 8 // Top edge padding + - + 21; // Bottom view padding + await verifyMaximumSize( + tester, + viewSize: const Size(844, 390), + viewPadding: const EdgeInsets.fromLTRB(47, 0, 47, 21), + expectedSize: const Size(expectedWidth, expectedHeight), + ); + }); + + testWidgets('Action buttons shows pressed color as soon as the pointer is down', ( + WidgetTester tester, + ) async { + // Verifies that the the pressed color is not delayed for some milliseconds, + // a symptom if the color relies on a tap gesture timing out. + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture pointer = await tester.startGesture(tester.getCenter(find.text('Two'))); + // Just `pump`, not `pumpAndSettle`, as we want to verify the very next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.pressed.png'), + ); + await pointer.up(); + }); + + testWidgets('Enter/exit animation is correct', (WidgetTester tester) async { + final enterRecorder = AnimationSheetBuilder(frameSize: const Size(600, 600)); + addTearDown(enterRecorder.dispose); + + final Widget target = createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ); + await tester.pumpWidget(enterRecorder.record(target)); + + // Enter animation + await tester.tap(find.text('Go')); + await tester.pumpFrames(enterRecorder.record(target), const Duration(milliseconds: 400)); + + await expectLater( + enterRecorder.collate(5), + matchesGoldenFile('cupertinoActionSheet.enter.png'), + ); + + final exitRecorder = AnimationSheetBuilder(frameSize: const Size(600, 600)); + addTearDown(exitRecorder.dispose); + await tester.pumpWidget(exitRecorder.record(target)); + + // Exit animation + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpFrames(exitRecorder.record(target), const Duration(milliseconds: 450)); + + // Action sheet has disappeared + expect(find.byType(CupertinoActionSheet), findsNothing); + + await expectLater(exitRecorder.collate(5), matchesGoldenFile('cupertinoActionSheet.exit.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Animation is correct if entering is canceled halfway', (WidgetTester tester) async { + final recorder = AnimationSheetBuilder(frameSize: const Size(600, 600)); + addTearDown(recorder.dispose); + + final Widget target = createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ); + await tester.pumpWidget(recorder.record(target)); + + // Enter animation + await tester.tap(find.text('Go')); + await tester.pumpFrames(recorder.record(target), const Duration(milliseconds: 200)); + + // Exit animation + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpFrames(recorder.record(target), const Duration(milliseconds: 450)); + + // Action sheet has disappeared + expect(find.byType(CupertinoActionSheet), findsNothing); + + await expectLater( + recorder.collate(5), + matchesGoldenFile('cupertinoActionSheet.interrupted-enter.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Action sheet semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: 'Alert', + role: SemanticsRole.dialog, + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics(label: 'The title'), + TestSemantics(label: 'The message'), + ], + ), + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'One', + ), + TestSemantics( + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'Two', + ), + ], + ), + TestSemantics( + flags: [SemanticsFlag.isButton, SemanticsFlag.isFocusable], + actions: [SemanticsAction.tap, SemanticsAction.focus], + label: 'Cancel', + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets( + 'Conflicting scrollbars are not applied by ScrollBehavior to CupertinoActionSheet', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/83819 + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message.'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + actionScrollController: actionScrollController, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // The inherited ScrollBehavior should not apply scrollbars since they are + // already built in to the widget. + expect(find.byType(RawScrollbar), findsNothing); + // Built in CupertinoScrollbars should only number 2: one for the actions, + // one for the content. + expect(find.byType(CupertinoScrollbar), findsNWidgets(2)); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('Hovering over Cupertino action sheet action updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('Message'), + actions: [CupertinoActionSheetAction(child: const Text('One'), onPressed: () {})], + ), + ), + ); + await tester.tap(find.text('Go')); + await tester.pump(); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset actionSheetAction = tester.getCenter(find.text('One')); + await gesture.moveTo(actionSheetAction); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CupertinoActionSheet action cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + mouseCursor: customCursor, + onPressed: () {}, + child: const Text('One'), + ), + ], + ), + ), + ); + await tester.tap(find.text('Go')); + await tester.pump(); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset actionSheetAction = tester.getCenter(find.text('One')); + await gesture.moveTo(actionSheetAction); + await tester.pumpAndSettle(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets( + 'Action sheets emits haptic vibration on sliding into a button', + (WidgetTester tester) async { + var vibrationCount = 0; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + expect(methodCall.arguments, 'HapticFeedbackType.selectionClick'); + vibrationCount += 1; + } + return null; + }); + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Three'), onPressed: () {}), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + // Tapping down on a button should not emit vibration. + expect(vibrationCount, 0); + + await gesture.moveTo(tester.getCenter(find.text('Two'))); + await tester.pumpAndSettle(); + expect(vibrationCount, 1); + + await gesture.moveTo(tester.getCenter(find.text('Three'))); + await tester.pumpAndSettle(); + expect(vibrationCount, 2); + + await gesture.up(); + expect(vibrationCount, 2); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'CupertinoActionSheet appearance changes correctly when actions or cancel button is focused', + (WidgetTester tester) async { + final focusNodeOne = FocusNode(debugLabel: 'CupertinoActionSheetAction One'); + final focusNodeTwo = FocusNode(debugLabel: 'CupertinoActionSheetAction Two'); + final focusNodeCancel = FocusNode(debugLabel: 'CupertinoActionSheetAction Cancel'); + + addTearDown(focusNodeOne.dispose); + addTearDown(focusNodeTwo.dispose); + addTearDown(focusNodeCancel.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeOne, + child: const Text('One'), + ), + CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeTwo, + child: const Text('Two'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeCancel, + child: const Text('Cancel'), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final Finder decoratedBoxBetweenTraversalGroupAndButtonBackgroundFinder = find.ancestor( + of: find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_ActionSheetButtonBackground'; + }), + matching: find.descendant( + of: find.byType(CupertinoFocusHalo), + matching: find.byType(DecoratedBox), + ), + ); + + final Finder actionsDecoratedBoxFinder = find.ancestor( + of: find.widgetWithText(CupertinoActionSheetAction, 'One'), + matching: decoratedBoxBetweenTraversalGroupAndButtonBackgroundFinder, + ); + + final Finder cancelDecoratedBoxFinder = find.ancestor( + of: find.widgetWithText(CupertinoActionSheetAction, 'Cancel'), + matching: decoratedBoxBetweenTraversalGroupAndButtonBackgroundFinder, + ); + + ShapeBorder findBorder(Finder decoratedBoxFinder) { + final box = tester.widget(decoratedBoxFinder) as DecoratedBox; + final decoration = box.decoration as ShapeDecoration; + + return decoration.shape; + } + + BorderSide getExpectedBorderSide({required bool hasFocus}) => hasFocus + ? BorderSide( + color: + HSLColor.fromColor( + CupertinoColors.activeBlue.withOpacity(kCupertinoFocusColorOpacity), + ) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(), + width: 3.5, + ) + : BorderSide.none; + + ShapeBorder getExpectedActionHaloBorder({required bool hasFocus}) => RoundedRectangleBorder( + side: getExpectedBorderSide(hasFocus: hasFocus), + borderRadius: kCupertinoButtonSizeBorderRadius[CupertinoButtonSize.large]!.copyWith( + topLeft: Radius.zero, + topRight: Radius.zero, + ), + ); + + ShapeBorder getExpectedCancelHaloBorder({required bool hasFocus}) => RoundedRectangleBorder( + side: getExpectedBorderSide(hasFocus: hasFocus), + borderRadius: kCupertinoButtonSizeBorderRadius[CupertinoButtonSize.large]!, + ); + + expect(actionsDecoratedBoxFinder, findsOneWidget); + expect(cancelDecoratedBoxFinder, findsOneWidget); + + expect(findBorder(actionsDecoratedBoxFinder), getExpectedActionHaloBorder(hasFocus: false)); + expect(findBorder(cancelDecoratedBoxFinder), getExpectedCancelHaloBorder(hasFocus: false)); + + focusNodeOne.requestFocus(); + await tester.pumpAndSettle(); + + expect(findBorder(actionsDecoratedBoxFinder), getExpectedActionHaloBorder(hasFocus: true)); + expect(findBorder(cancelDecoratedBoxFinder), getExpectedCancelHaloBorder(hasFocus: false)); + + focusNodeTwo.requestFocus(); + await tester.pumpAndSettle(); + + expect(findBorder(actionsDecoratedBoxFinder), getExpectedActionHaloBorder(hasFocus: true)); + expect(findBorder(cancelDecoratedBoxFinder), getExpectedCancelHaloBorder(hasFocus: false)); + + focusNodeCancel.requestFocus(); + await tester.pumpAndSettle(); + + expect(findBorder(actionsDecoratedBoxFinder), getExpectedActionHaloBorder(hasFocus: false)); + expect(findBorder(cancelDecoratedBoxFinder), getExpectedCancelHaloBorder(hasFocus: true)); + + focusNodeCancel.unfocus(); + await tester.pumpAndSettle(); + + expect(findBorder(actionsDecoratedBoxFinder), getExpectedActionHaloBorder(hasFocus: false)); + expect(findBorder(cancelDecoratedBoxFinder), getExpectedCancelHaloBorder(hasFocus: false)); + }, + ); + + testWidgets('CupertinoActionSheetActions in CupertinoActionSheet can be focused and unfocused', ( + WidgetTester tester, + ) async { + final focusNodeOne = FocusNode(debugLabel: 'CupertinoActionSheetAction One'); + final focusNodeTwo = FocusNode(debugLabel: 'CupertinoActionSheetAction Two'); + final focusNodeCancel = FocusNode(debugLabel: 'CupertinoActionSheetAction Cancel'); + + addTearDown(focusNodeOne.dispose); + addTearDown(focusNodeTwo.dispose); + addTearDown(focusNodeCancel.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeOne, + child: const Text('One'), + ), + CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeTwo, + child: const Text('Two'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeCancel, + child: const Text('Cancel'), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + focusNodeOne.requestFocus(); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isTrue); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + focusNodeTwo.requestFocus(); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isTrue); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + focusNodeTwo.unfocus(); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + focusNodeCancel.requestFocus(); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isTrue); + + focusNodeCancel.unfocus(); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + }); + + testWidgets( + 'CupertinoActionSheetActions in CupertinoActionSheet can be traversed with keyboard', + (WidgetTester tester) async { + final focusNodeOne = FocusNode(debugLabel: 'CupertinoActionSheetAction One'); + final focusNodeTwo = FocusNode(debugLabel: 'CupertinoActionSheetAction Two'); + final focusNodeCancel = FocusNode(debugLabel: 'CupertinoActionSheetAction Cancel'); + + addTearDown(focusNodeOne.dispose); + addTearDown(focusNodeTwo.dispose); + addTearDown(focusNodeCancel.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeOne, + child: const Text('One'), + ), + CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeTwo, + child: const Text('Two'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNodeCancel, + child: const Text('Cancel'), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isTrue); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isTrue); + expect(focusNodeCancel.hasPrimaryFocus, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(focusNodeOne.hasPrimaryFocus, isFalse); + expect(focusNodeTwo.hasPrimaryFocus, isFalse); + expect(focusNodeCancel.hasPrimaryFocus, isTrue); + }, + ); + + testWidgets( + 'CupertinoActionSheetAction in CupertinoActionSheet actions can be selected with keyboard', + (WidgetTester tester) async { + var isOneSelected = false; + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + isOneSelected = true; + }, + child: const Text('One'), + ), + CupertinoActionSheetAction(onPressed: () {}, child: const Text('Two')), + ], + cancelButton: CupertinoActionSheetAction(onPressed: () {}, child: const Text('Cancel')), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(isOneSelected, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + expect(isOneSelected, isTrue); + }, + ); + + testWidgets( + 'CupertinoActionSheetAction as CupertinoActionSheet cancel button can be selected with keyboard', + (WidgetTester tester) async { + var isCancelSelected = false; + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction(onPressed: () {}, child: const Text('One')), + CupertinoActionSheetAction(onPressed: () {}, child: const Text('Two')), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () { + isCancelSelected = true; + }, + child: const Text('Cancel'), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(isCancelSelected, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + expect(isCancelSelected, isTrue); + }, + ); + + testWidgets('CupertinoActionSheetAction has correct focused appearance in light theme', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'CupertinoActionSheetAction'); + + addTearDown(focusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + final Color defaultLightFocusColor = HSLColor.fromColor( + CupertinoColors.activeBlue.withOpacity(kCupertinoButtonTintedOpacityLight), + ).toColor(); + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction(onPressed: () {}, child: const Text('One')), + CupertinoActionSheetAction(onPressed: () {}, child: const Text('Two')), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNode, + child: const Text('Cancel'), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final Finder decoratedBoxFinder = find.descendant( + of: find.byType(CupertinoActionSheetAction), + matching: find.byType(DecoratedBox), + ); + + expect(decoratedBoxFinder, findsNothing); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(decoratedBoxFinder, findsOneWidget); + + final decoration = tester.widget(decoratedBoxFinder).decoration as BoxDecoration; + + expect(decoration.color, defaultLightFocusColor); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + + expect(decoratedBoxFinder, findsNothing); + }); + + testWidgets('CupertinoActionSheetAction has correct focused appearance in dark theme', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'CupertinoActionSheetAction'); + + addTearDown(focusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + final Color defaultDarkFocusColor = HSLColor.fromColor( + CupertinoColors.activeBlue.withOpacity(kCupertinoButtonTintedOpacityDark), + ).toColor(); + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction(onPressed: () {}, child: const Text('One')), + CupertinoActionSheetAction(onPressed: () {}, child: const Text('Two')), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNode, + child: const Text('Cancel'), + ), + ), + brightness: Brightness.dark, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final Finder decoratedBoxFinder = find.descendant( + of: find.byType(CupertinoActionSheetAction), + matching: find.byType(DecoratedBox), + ); + + expect(decoratedBoxFinder, findsNothing); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(decoratedBoxFinder, findsOneWidget); + + final decoration = tester.widget(decoratedBoxFinder).decoration as BoxDecoration; + + expect(decoration.color, defaultDarkFocusColor); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + + expect(decoratedBoxFinder, findsNothing); + }); + + testWidgets('CupertinoActionSheetAction has correct focused appearance with custom focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'CupertinoActionSheetAction'); + + addTearDown(focusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const focusColor = Color(0xffffaaaa); + + final Color defaultDarkFocusColor = HSLColor.fromColor( + focusColor.withOpacity(kCupertinoButtonTintedOpacityDark), + ).toColor(); + + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: [ + CupertinoActionSheetAction(onPressed: () {}, child: const Text('One')), + CupertinoActionSheetAction(onPressed: () {}, child: const Text('Two')), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () {}, + focusNode: focusNode, + focusColor: focusColor, + child: const Text('Cancel'), + ), + ), + brightness: Brightness.dark, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final Finder decoratedBoxFinder = find.descendant( + of: find.byType(CupertinoActionSheetAction), + matching: find.byType(DecoratedBox), + ); + + expect(decoratedBoxFinder, findsNothing); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(decoratedBoxFinder, findsOneWidget); + + final decoration = tester.widget(decoratedBoxFinder).decoration as BoxDecoration; + + expect(decoration.color, defaultDarkFocusColor); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + + expect(decoratedBoxFinder, findsNothing); + }); +} + +RenderBox findScrollableActionsSectionRenderBox(WidgetTester tester) { + final RenderObject actionsSection = tester.renderObject( + find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_ActionSheetActionSection'; + }), + ); + assert(actionsSection is RenderBox); + return actionsSection as RenderBox; +} + +Widget createAppWithButtonThatLaunchesActionSheet( + Widget actionSheet, { + Brightness brightness = Brightness.light, +}) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Center( + child: Builder( + builder: (BuildContext context) { + return CupertinoButton( + onPressed: () { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return actionSheet; + }, + ); + }, + child: const Text('Go'), + ); + }, + ), + ), + ); +} + +// Shows an app that has a button with text "Go", and clicking this button +// displays the `actionSheet` and hides the button. +// +// The `theme` will be applied to the app and determines the background. +class TestScaffoldApp extends StatefulWidget { + const TestScaffoldApp({super.key, required this.theme, required this.actionSheet}); + final CupertinoThemeData theme; + final Widget actionSheet; + + @override + TestScaffoldAppState createState() => TestScaffoldAppState(); +} + +class TestScaffoldAppState extends State { + bool _pressedButton = false; + + @override + Widget build(BuildContext context) { + return CupertinoApp( + // Hide the debug banner. Because this CupertinoApp is captured in golden + // test as a whole. The debug banner contains tilted text, whose + // anti-alias might cause false negative result. + // https://github.com/flutter/flutter/pull/150442 + debugShowCheckedModeBanner: false, + theme: widget.theme, + home: Builder( + builder: (BuildContext context) => CupertinoPageScaffold( + child: Center( + child: _pressedButton + ? Container() + : CupertinoButton( + onPressed: () { + setState(() { + _pressedButton = true; + }); + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return widget.actionSheet; + }, + ); + }, + child: const Text('Go'), + ), + ), + ), + ), + ); + } +} + +Widget boilerplate(Widget child) { + return Directionality(textDirection: TextDirection.ltr, child: child); +} + +typedef MediaQueryTransformer = MediaQueryData Function(MediaQueryData); + +class OverrideMediaQuery extends StatelessWidget { + const OverrideMediaQuery({super.key, required this.transformer, required this.child}); + + final MediaQueryTransformer transformer; + final Widget child; + + @override + Widget build(BuildContext context) { + final MediaQueryData currentData = MediaQuery.of(context); + return MediaQuery(data: transformer(currentData), child: child); + } +} + +// Old-style action sheet buttons, which are implemented with +// `GestureDetector.onTap`. +class LegacyAction extends StatelessWidget { + const LegacyAction({super.key, required this.onPressed, required this.child}); + + final VoidCallback onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 57), + child: Container( + alignment: AlignmentDirectional.center, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 10.0), + child: child, + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/activity_indicator_test.dart b/packages/cupertino_ui/test/cupertino/activity_indicator_test.dart new file mode 100644 index 000000000000..9d2fc426f2f3 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/activity_indicator_test.dart @@ -0,0 +1,247 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Activity indicator animate property works', (WidgetTester tester) async { + await tester.pumpWidget(buildCupertinoActivityIndicator()); + expect(SchedulerBinding.instance.transientCallbackCount, equals(1)); + + await tester.pumpWidget(buildCupertinoActivityIndicator(false)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + await tester.pumpWidget(Container()); + + await tester.pumpWidget(buildCupertinoActivityIndicator(false)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + await tester.pumpWidget(buildCupertinoActivityIndicator()); + expect(SchedulerBinding.instance.transientCallbackCount, equals(1)); + }); + + testWidgets('Activity indicator dark mode', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + Center( + child: MediaQuery( + data: const MediaQueryData(), + child: RepaintBoundary( + key: key, + child: const ColoredBox( + color: CupertinoColors.white, + child: CupertinoActivityIndicator(animating: false, radius: 35), + ), + ), + ), + ), + ); + + await expectLater(find.byKey(key), matchesGoldenFile('activityIndicator.paused.light.png')); + + await tester.pumpWidget( + Center( + child: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: RepaintBoundary( + key: key, + child: const ColoredBox( + color: CupertinoColors.black, + child: CupertinoActivityIndicator(animating: false, radius: 35), + ), + ), + ), + ), + ); + + await expectLater(find.byKey(key), matchesGoldenFile('activityIndicator.paused.dark.png')); + }); + + testWidgets('Activity indicator 0% in progress', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: key, + child: const ColoredBox( + color: CupertinoColors.white, + child: CupertinoActivityIndicator.partiallyRevealed(progress: 0), + ), + ), + ), + ); + + await expectLater(find.byKey(key), matchesGoldenFile('activityIndicator.inprogress.0.0.png')); + }); + + testWidgets('Activity indicator 30% in progress', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: key, + child: const ColoredBox( + color: CupertinoColors.white, + child: CupertinoActivityIndicator.partiallyRevealed(progress: 0.5), + ), + ), + ), + ); + + await expectLater(find.byKey(key), matchesGoldenFile('activityIndicator.inprogress.0.3.png')); + }); + + testWidgets('Activity indicator 100% in progress', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: key, + child: const ColoredBox( + color: CupertinoColors.white, + child: CupertinoActivityIndicator.partiallyRevealed(), + ), + ), + ), + ); + + await expectLater(find.byKey(key), matchesGoldenFile('activityIndicator.inprogress.1.0.png')); + }); + + // Regression test for https://github.com/flutter/flutter/issues/41345. + testWidgets('has the correct corner radius', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoActivityIndicator(animating: false, radius: 100)); + + // An earlier implementation for the activity indicator started drawing + // the ticks at 9 o'clock, however, in order to support partially revealed + // indicator (https://github.com/flutter/flutter/issues/29159), the + // first tick was changed to be at 12 o'clock. + expect( + find.byType(CupertinoActivityIndicator), + paints..rrect(rrect: const RRect.fromLTRBXY(-10, -100 / 3, 10, -100, 10, 10)), + ); + }); + + testWidgets('Can specify color', (WidgetTester tester) async { + final Key key = UniqueKey(); + const color = Color(0xFF5D3FD3); + await tester.pumpWidget( + Center( + child: RepaintBoundary( + key: key, + child: const ColoredBox( + color: CupertinoColors.white, + child: CupertinoActivityIndicator(animating: false, color: color, radius: 100), + ), + ), + ), + ); + + expect( + find.byType(CupertinoActivityIndicator), + paints..rrect( + rrect: const RRect.fromLTRBXY(-10, -100 / 3, 10, -100, 10, 10), + // The value of 47 comes from the alpha that is applied to the first + // tick. + color: color.withAlpha(47), + ), + ); + }); + + group('CupertinoLinearActivityIndicator', () { + testWidgets('draws the linear activity indicator', (WidgetTester tester) async { + await tester.pumpWidget(const Center(child: CupertinoLinearActivityIndicator(progress: 0.2))); + + expect( + find.byType(CupertinoLinearActivityIndicator), + paints + ..rrect( + color: CupertinoColors.systemFill, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.0, 0.0, 800, 4.5), + const Radius.circular(2.25), + ), + ) + ..rrect( + color: CupertinoColors.activeBlue, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.0, 0.0, 160, 4.5), + const Radius.circular(2.25), + ), + ), + ); + }); + + testWidgets('draws the linear activity indicator with a custom height and color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const Center( + child: CupertinoLinearActivityIndicator( + progress: 0.5, + height: 10, + color: CupertinoColors.activeGreen, + ), + ), + ); + + expect( + find.byType(CupertinoLinearActivityIndicator), + paints + ..rrect( + color: CupertinoColors.systemFill, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.0, 0.0, 800, 10), + const Radius.circular(5), + ), + ) + ..rrect( + color: CupertinoColors.activeGreen, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.0, 0.0, 400, 10), + const Radius.circular(5), + ), + ), + ); + }); + }); + + testWidgets('CupertinoActivityIndicator does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: SizedBox.shrink(child: CupertinoActivityIndicator())), + ), + ); + expect(tester.getSize(find.byType(CupertinoActivityIndicator)), Size.zero); + }); + + testWidgets('CupertinoLinearActivityIndicator does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoLinearActivityIndicator(progress: 0.5)), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoLinearActivityIndicator)), Size.zero); + }); +} + +Widget buildCupertinoActivityIndicator([bool? animating]) { + return MediaQuery( + data: const MediaQueryData(), + child: CupertinoActivityIndicator(animating: animating ?? true), + ); +} diff --git a/packages/cupertino_ui/test/cupertino/adaptive_text_selection_toolbar_test.dart b/packages/cupertino_ui/test/cupertino/adaptive_text_selection_toolbar_test.dart new file mode 100644 index 000000000000..2eed7b8aa5f4 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/adaptive_text_selection_toolbar_test.dart @@ -0,0 +1,294 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import '../widgets/text_selection_toolbar_utils.dart'; +import 'live_text_utils.dart'; + +void main() { + final mockClipboard = MockClipboard(); + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + testWidgets( + 'Builds the right toolbar on each platform, including web, and shows buttonItems', + (WidgetTester tester) async { + const buttonText = 'Click me'; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoAdaptiveTextSelectionToolbar.buttonItems( + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + buttonItems: [ + ContextMenuButtonItem(label: buttonText, onPressed: () {}), + ], + ), + ), + ), + ); + + expect(find.text(buttonText), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget); + expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing); + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget); + } + }, + variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382 + ); + + testWidgets( + 'Can build children directly as well', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoAdaptiveTextSelectionToolbar( + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + children: [Container(key: key)], + ), + ), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }, + skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382 + ); + + testWidgets( + 'Can build from EditableTextState', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + controller: controller, + backgroundCursorColor: const Color(0xff00ffff), + focusNode: focusNode, + style: const TextStyle(), + cursorColor: const Color(0xff00ffff), + selectionControls: cupertinoTextSelectionHandleControls, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return CupertinoAdaptiveTextSelectionToolbar.editableText( + key: key, + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(find.byKey(key), findsNothing); + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + expect(find.byKey(key), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget); + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget); + } + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'Can build for editable text from raw parameters', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoAdaptiveTextSelectionToolbar.editable( + key: key, + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + clipboardStatus: ClipboardStatus.pasteable, + onCopy: () {}, + onCut: () {}, + onPaste: () {}, + onSelectAll: () {}, + onLiveTextInput: () {}, + onLookUp: () {}, + onSearchWeb: () {}, + onShare: () {}, + ), + ), + ), + ); + + expect(find.byKey(key), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(6)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Share...'), findsOneWidget); + expect(findCupertinoOverflowNextButton(), findsOneWidget); + + await tapCupertinoOverflowNextButton(tester); + + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4)); + expect(findCupertinoOverflowBackButton(), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(6)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(findCupertinoOverflowNextButton(), findsOneWidget); + + await tapCupertinoOverflowNextButton(tester); + + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4)); + expect(findCupertinoOverflowBackButton(), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share...'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(8)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Share...'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + } + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.all(), + ); + + testWidgets('Builds the correct button per-platform', (WidgetTester tester) async { + const buttonText = 'Click me'; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Builder( + builder: (BuildContext context) { + return Column( + children: CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons( + context, + [ + ContextMenuButtonItem(label: buttonText, onPressed: () {}), + ], + ).toList(), + ); + }, + ), + ), + ), + ); + + expect(find.text(buttonText), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget); + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing); + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'Builds empty toolbar when children and buttonItems are null', + (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoAdaptiveTextSelectionToolbar( + anchors: TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + children: null, + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CupertinoAdaptiveTextSelectionToolbar)), Size.zero); + expect(tester.takeException(), isNull); + }, + skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382 + variant: TargetPlatformVariant.all(), + ); +} diff --git a/packages/cupertino_ui/test/cupertino/app_test.dart b/packages/cupertino_ui/test/cupertino/app_test.dart new file mode 100644 index 000000000000..fb4211c33abd --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/app_test.dart @@ -0,0 +1,767 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Heroes work', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: ListView( + children: [ + const Hero(tag: 'a', child: Text('foo')), + Builder( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('next'), + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const Hero(tag: 'a', child: Text('foo')); + }, + ), + ); + }, + ); + }, + ), + ], + ), + ), + ); + + await tester.tap(find.text('next')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // During the hero transition, the hero widget is lifted off of both + // page routes and exists as its own overlay on top of both routes. + expect(find.widgetWithText(CupertinoPageRoute, 'foo'), findsNothing); + expect(find.widgetWithText(Navigator, 'foo'), findsOneWidget); + }); + + testWidgets('Has default cupertino localizations', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return Column( + children: [ + Text(CupertinoLocalizations.of(context).selectAllButtonLabel), + Text( + CupertinoLocalizations.of(context).datePickerMediumDate(DateTime(2018, 10, 4)), + ), + ], + ); + }, + ), + ), + ); + + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Thu Oct 4 '), findsOneWidget); + }); + + testWidgets('Can use dynamic color', (WidgetTester tester) async { + const dynamicColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFF000000), + darkColor: Color(0xFF000001), + ); + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.light), + title: '', + color: dynamicColor, + home: Placeholder(), + ), + ); + + expect(tester.widget(find.byType(Title)).color.value, 0xFF000000); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + color: dynamicColor, + title: '', + home: Placeholder(), + ), + ); + + expect(tester.widget<Title>(find.byType(Title)).color.value, 0xFF000001); + }); + + testWidgets('Can customize initial routes', (WidgetTester tester) async { + final navigatorKey = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + CupertinoApp( + navigatorKey: navigatorKey, + onGenerateInitialRoutes: (String initialRoute) { + expect(initialRoute, '/abc'); + return <Route<void>>[ + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Text('non-regular page one'); + }, + ), + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Text('non-regular page two'); + }, + ), + ]; + }, + initialRoute: '/abc', + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) => const Text('regular page one'), + '/abc': (BuildContext context) => const Text('regular page two'), + }, + ), + ); + expect(find.text('non-regular page two'), findsOneWidget); + expect(find.text('non-regular page one'), findsNothing); + expect(find.text('regular page one'), findsNothing); + expect(find.text('regular page two'), findsNothing); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.text('non-regular page two'), findsNothing); + expect(find.text('non-regular page one'), findsOneWidget); + expect(find.text('regular page one'), findsNothing); + expect(find.text('regular page two'), findsNothing); + }); + + testWidgets('CupertinoApp.navigatorKey can be updated', (WidgetTester tester) async { + final key1 = GlobalKey<NavigatorState>(); + await tester.pumpWidget(CupertinoApp(navigatorKey: key1, home: const Placeholder())); + expect(key1.currentState, isA<NavigatorState>()); + final key2 = GlobalKey<NavigatorState>(); + await tester.pumpWidget(CupertinoApp(navigatorKey: key2, home: const Placeholder())); + expect(key2.currentState, isA<NavigatorState>()); + expect(key1.currentState, isNull); + }); + + testWidgets('CupertinoApp.router works', (WidgetTester tester) async { + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + addTearDown(provider.dispose); + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + await tester.pumpWidget( + CupertinoApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + ), + ); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('CupertinoApp.router works with onNavigationNotification', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/139903. + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + addTearDown(provider.dispose); + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + + var navigationCount = 0; + + await tester.pumpWidget( + CupertinoApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + onNavigationNotification: (NavigationNotification? notification) { + navigationCount += 1; + return true; + }, + ), + ); + expect(find.text('initial'), findsOneWidget); + + expect(navigationCount, greaterThan(0)); + final navigationCountAfterBuild = navigationCount; + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + + expect(navigationCount, greaterThan(navigationCountAfterBuild)); + }); + + testWidgets('CupertinoApp.router route information parser is optional', ( + WidgetTester tester, + ) async { + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + delegate.routeInformation = RouteInformation(uri: Uri.parse('initial')); + await tester.pumpWidget(CupertinoApp.router(routerDelegate: delegate)); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets( + 'CupertinoApp.router throw if route information provider is provided but no route information parser', + (WidgetTester tester) async { + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + delegate.routeInformation = RouteInformation(uri: Uri.parse('initial')); + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + addTearDown(provider.dispose); + await tester.pumpWidget( + CupertinoApp.router(routeInformationProvider: provider, routerDelegate: delegate), + ); + expect(tester.takeException(), isAssertionError); + }, + ); + + testWidgets( + 'CupertinoApp.router throw if route configuration is provided along with other delegate', + (WidgetTester tester) async { + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + delegate.routeInformation = RouteInformation(uri: Uri.parse('initial')); + final routerConfig = RouterConfig<RouteInformation>(routerDelegate: delegate); + await tester.pumpWidget( + CupertinoApp.router(routerDelegate: delegate, routerConfig: routerConfig), + ); + expect(tester.takeException(), isAssertionError); + }, + ); + + testWidgets('CupertinoApp.router router config works', (WidgetTester tester) async { + late SimpleNavigatorRouterDelegate delegate; + addTearDown(() => delegate.dispose()); + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + addTearDown(provider.dispose); + final routerConfig = RouterConfig<RouteInformation>( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ), + backButtonDispatcher: RootBackButtonDispatcher(), + ); + await tester.pumpWidget(CupertinoApp.router(routerConfig: routerConfig)); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('CupertinoApp has correct default ScrollBehavior', (WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + expect(ScrollConfiguration.of(capturedContext).runtimeType, CupertinoScrollBehavior); + }); + + testWidgets('CupertinoApp has correct default multitouchDragStrategy', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + + final ScrollBehavior scrollBehavior = ScrollConfiguration.of(capturedContext); + expect(scrollBehavior.runtimeType, CupertinoScrollBehavior); + expect( + scrollBehavior.getMultitouchDragStrategy(capturedContext), + MultitouchDragStrategy.averageBoundaryPointers, + ); + }); + + testWidgets('CupertinoApp has correct default KeyboardDismissBehavior', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + + expect( + ScrollConfiguration.of(capturedContext).getKeyboardDismissBehavior(capturedContext), + ScrollViewKeyboardDismissBehavior.manual, + ); + }); + + testWidgets('CupertinoApp can override default KeyboardDismissBehavior', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + CupertinoApp( + scrollBehavior: const CupertinoScrollBehavior().copyWith( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + ), + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + + expect( + ScrollConfiguration.of(capturedContext).getKeyboardDismissBehavior(capturedContext), + ScrollViewKeyboardDismissBehavior.onDrag, + ); + }); + + testWidgets('A ScrollBehavior can be set for CupertinoApp', (WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + CupertinoApp( + scrollBehavior: const MockScrollBehavior(), + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + final ScrollBehavior scrollBehavior = ScrollConfiguration.of(capturedContext); + expect(scrollBehavior.runtimeType, MockScrollBehavior); + expect( + scrollBehavior.getScrollPhysics(capturedContext).runtimeType, + NeverScrollableScrollPhysics, + ); + }); + + testWidgets( + 'When `useInheritedMediaQuery` is true an existing MediaQuery is used if one is available', + (WidgetTester tester) async { + late BuildContext capturedContext; + final uniqueKey = UniqueKey(); + await tester.pumpWidget( + MediaQuery( + key: uniqueKey, + data: const MediaQueryData(), + child: CupertinoApp( + useInheritedMediaQuery: true, + builder: (BuildContext context, Widget? child) { + capturedContext = context; + return const Placeholder(); + }, + color: const Color(0xFF123456), + ), + ), + ); + expect(capturedContext.dependOnInheritedWidgetOfExactType<MediaQuery>()?.key, uniqueKey); + }, + ); + + testWidgets('CupertinoApp uses the dark SystemUIOverlayStyle when the background is light', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.light), + home: CupertinoPageScaffold(child: Text('Hello')), + ), + ); + + expect(SystemChrome.latestStyle, SystemUiOverlayStyle.dark); + }); + + testWidgets('CupertinoApp uses the light SystemUIOverlayStyle when the background is dark', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoPageScaffold(child: Text('Hello')), + ), + ); + + expect(SystemChrome.latestStyle, SystemUiOverlayStyle.light); + }); + + testWidgets( + 'CupertinoApp uses the dark SystemUIOverlayStyle when theme brightness is null and the system is in light mode', + (WidgetTester tester) async { + // The theme brightness is null by default. + // The system is in light mode by default. + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: CupertinoApp( + builder: (BuildContext context, Widget? child) { + return const Placeholder(); + }, + ), + ), + ); + + expect(SystemChrome.latestStyle, SystemUiOverlayStyle.dark); + }, + ); + + testWidgets( + 'CupertinoApp uses the light SystemUIOverlayStyle when theme brightness is null and the system is in dark mode', + (WidgetTester tester) async { + // The theme brightness is null by default. + // Simulates setting the system to dark mode. + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: CupertinoApp( + builder: (BuildContext context, Widget? child) { + return const Placeholder(); + }, + ), + ), + ); + + expect(SystemChrome.latestStyle, SystemUiOverlayStyle.light); + }, + ); + + testWidgets('Text color is correctly resolved when CupertinoThemeData.brightness is null', ( + WidgetTester tester, + ) async { + debugBrightnessOverride = Brightness.dark; + + await tester.pumpWidget(const CupertinoApp(home: CupertinoPageScaffold(child: Text('Hello')))); + + final RenderParagraph paragraph = tester.renderObject(find.text('Hello')); + final textColor = paragraph.text.style!.color! as CupertinoDynamicColor; + + // App with non-null brightness, so resolving color + // doesn't depend on the MediaQuery.platformBrightness. + late BuildContext capturedContext; + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + + return const Placeholder(); + }, + ), + ), + ); + + // We expect the string representations of the colors to have darkColor indicated (*) as effective color. + // (color = Color(0xff000000), *darkColor = Color(0xffffffff)*, resolved by: Builder) + expect(textColor.toString(), CupertinoColors.label.resolveFrom(capturedContext).toString()); + + debugBrightnessOverride = null; + }); + + testWidgets('Cursor color is resolved when CupertinoThemeData.brightness is null', ( + WidgetTester tester, + ) async { + debugBrightnessOverride = Brightness.dark; + + RenderEditable findRenderEditable(WidgetTester tester) { + final RenderObject root = tester.renderObject(find.byType(EditableText)); + expect(root, isNotNull); + + RenderEditable? renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable!; + } + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(primaryColor: CupertinoColors.activeOrange), + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return EditableText( + backgroundCursorColor: DefaultSelectionStyle.of(context).selectionColor!, + cursorColor: DefaultSelectionStyle.of(context).cursorColor!, + controller: controller, + focusNode: focusNode, + style: const TextStyle(), + ); + }, + ), + ), + ), + ); + + final RenderEditable editableText = findRenderEditable(tester); + final Color cursorColor = editableText.cursorColor!; + + // Cursor color should be equal to the dark variant of the primary color. + // Alpha value needs to be 0, because cursor is not visible by default. + expect(cursorColor, CupertinoColors.activeOrange.darkColor.withAlpha(0)); + + debugBrightnessOverride = null; + }); + + testWidgets( + 'Assert in buildScrollbar that controller != null when using it', + (WidgetTester tester) async { + const ScrollBehavior defaultBehavior = CupertinoScrollBehavior(); + late BuildContext capturedContext; + + await tester.pumpWidget( + ScrollConfiguration( + // Avoid the default ones here. + behavior: const CupertinoScrollBehavior().copyWith(scrollbars: false), + child: SingleChildScrollView( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(height: 1000.0); + }, + ), + ), + ), + ); + + const details = ScrollableDetails(direction: AxisDirection.down); + final Widget child = Container(); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // Does not throw if we aren't using it. + defaultBehavior.buildScrollbar(capturedContext, child, details); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect( + () { + defaultBehavior.buildScrollbar(capturedContext, child, details); + }, + throwsA( + isA<AssertionError>().having( + (AssertionError error) => error.toString(), + 'description', + contains('details.controller != null'), + ), + ), + ); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('CupertinoApp does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const Center( + child: SizedBox.shrink(child: CupertinoApp(home: Text('X'))), + ), + ); + expect(tester.getSize(find.byType(CupertinoApp)), Size.zero); + }); +} + +class MockScrollBehavior extends ScrollBehavior { + const MockScrollBehavior(); + + @override + ScrollPhysics getScrollPhysics(BuildContext context) => const NeverScrollableScrollPhysics(); +} + +typedef SimpleRouterDelegateBuilder = + Widget Function(BuildContext context, RouteInformation information); +typedef SimpleNavigatorRouterDelegatePopPage<T> = + bool Function(Route<T> route, T result, SimpleNavigatorRouterDelegate delegate); + +class SimpleRouteInformationParser extends RouteInformationParser<RouteInformation> { + SimpleRouteInformationParser(); + + @override + Future<RouteInformation> parseRouteInformation(RouteInformation information) { + return SynchronousFuture<RouteInformation>(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleNavigatorRouterDelegate extends RouterDelegate<RouteInformation> + with PopNavigatorRouterDelegateMixin<RouteInformation>, ChangeNotifier { + SimpleNavigatorRouterDelegate({required this.builder, this.onPopPage}); + + @override + GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); + + RouteInformation get routeInformation => _routeInformation; + late RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleNavigatorRouterDelegatePopPage<void>? onPopPage; + + @override + Future<void> setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture<void>(null); + } + + bool _handlePopPage(Route<void> route, void data) { + return onPopPage!(route, data, this); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onPopPage: _handlePopPage, + pages: <Page<void>>[ + // We need at least two pages for the pop to propagate through. + // Otherwise, the navigator will bubble the pop to the system navigator. + const CupertinoPage<void>(child: Text('base')), + CupertinoPage<void>( + key: ValueKey<String?>(routeInformation.uri.toString()), + child: builder(context, routeInformation), + ), + ], + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/bottom_tab_bar_test.dart b/packages/cupertino_ui/test/cupertino/bottom_tab_bar_test.dart new file mode 100644 index 000000000000..5b3d734b9363 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/bottom_tab_bar_test.dart @@ -0,0 +1,750 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../image_data.dart'; +import '../widgets/semantics_tester.dart'; + +Future<void> pumpWidgetWithBoilerplate(WidgetTester tester, Widget widget) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultCupertinoLocalizations.delegate, + ], + child: Directionality(textDirection: TextDirection.ltr, child: widget), + ), + ); +} + +Future<void> main() async { + testWidgets('Need at least 2 tabs', (WidgetTester tester) async { + await expectLater( + () => pumpWidgetWithBoilerplate( + tester, + CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + ], + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('items.length'), + ), + ), + ); + }); + + testWidgets('Active and inactive colors', (WidgetTester tester) async { + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + activeColor: const Color(0xFF123456), + inactiveColor: const Color(0xFF654321), + ), + ), + ); + + final RichText actualInactive = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + expect(actualInactive.text.style!.color, const Color(0xFF654321)); + + final RichText actualActive = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + expect(actualActive.text.style!.color, const Color(0xFF123456)); + }); + + testWidgets('BottomNavigationBar.label will create a text widget', (WidgetTester tester) async { + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + ), + ), + ); + + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsOneWidget); + }); + + testWidgets('Active and inactive colors dark mode', (WidgetTester tester) async { + const dynamicActiveColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFF000000), + darkColor: Color(0xFF000001), + ); + + const dynamicInactiveColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFF000002), + darkColor: Color(0xFF000003), + ); + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + activeColor: dynamicActiveColor, + inactiveColor: dynamicInactiveColor, + ), + ), + ); + + RichText actualInactive = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + expect(actualInactive.text.style!.color!.value, 0xFF000002); + + RichText actualActive = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + expect(actualActive.text.style!.color!.value, 0xFF000000); + + final RenderDecoratedBox renderDecoratedBox = tester.renderObject( + find.descendant(of: find.byType(BackdropFilter), matching: find.byType(DecoratedBox)), + ); + + // Border color is resolved correctly. + final decoration1 = renderDecoratedBox.decoration as BoxDecoration; + expect(decoration1.border!.top.color.value, 0x4D000000); + + // Switch to dark mode. + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + activeColor: dynamicActiveColor, + inactiveColor: dynamicInactiveColor, + ), + ), + ); + + actualInactive = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + expect(actualInactive.text.style!.color!.value, 0xFF000003); + + actualActive = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + expect(actualActive.text.style!.color!.value, 0xFF000001); + + // Border color is resolved correctly. + final decoration2 = renderDecoratedBox.decoration as BoxDecoration; + expect(decoration2.border!.top.color.value, 0x29000000); + }); + + testWidgets('Tabs respects themes', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + ), + ), + ); + + RichText actualInactive = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + expect(actualInactive.text.style!.color!.value, 0xFF999999); + + RichText actualActive = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + expect(actualActive.text.style!.color, CupertinoColors.activeBlue); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + ), + ), + ); + + actualInactive = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + expect(actualInactive.text.style!.color!.value, 0xFF757575); + + actualActive = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + + expect(actualActive.text.style!.color, isSameColorAs(CupertinoColors.activeBlue.darkColor)); + }); + + testWidgets('Use active icon', (WidgetTester tester) async { + final activeIcon = MemoryImage(Uint8List.fromList(kBlueSquarePng)); + final inactiveIcon = MemoryImage(Uint8List.fromList(kTransparentImage)); + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(inactiveIcon), + activeIcon: ImageIcon(activeIcon), + label: 'Tab 2', + ), + ], + currentIndex: 1, + activeColor: const Color(0xFF123456), + inactiveColor: const Color(0xFF654321), + ), + ), + ); + + final Image image = tester.widget( + find.descendant( + of: find.widgetWithText(GestureDetector, 'Tab 2'), + matching: find.byType(Image), + ), + ); + + expect(image.color, const Color(0xFF123456)); + expect(image.image, activeIcon); + }); + + testWidgets('Adjusts height to account for bottom padding', (WidgetTester tester) async { + final tabBar = CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Aka', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Shiro', + ), + ], + ); + + // Verify height with no bottom padding. + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabScaffold( + tabBar: tabBar, + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabBar)).height, 50.0); + + // Verify height with bottom padding. + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(viewPadding: EdgeInsets.only(bottom: 40.0)), + child: CupertinoTabScaffold( + tabBar: tabBar, + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabBar)).height, 90.0); + }); + + testWidgets('Set custom height', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/51704 + const tabBarHeight = 56.0; + final tabBar = CupertinoTabBar( + height: tabBarHeight, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Aka', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Shiro', + ), + ], + ); + + // Verify height with no bottom padding. + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabScaffold( + tabBar: tabBar, + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabBar)).height, tabBarHeight); + + // Verify height with bottom padding. + const bottomPadding = 40.0; + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(viewPadding: EdgeInsets.only(bottom: bottomPadding)), + child: CupertinoTabScaffold( + tabBar: tabBar, + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabBar)).height, tabBarHeight + bottomPadding); + }); + + testWidgets('Ensure bar height will not change when toggle keyboard', ( + WidgetTester tester, + ) async { + const tabBarHeight = 56.0; + final tabBar = CupertinoTabBar( + height: tabBarHeight, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Aka', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Shiro', + ), + ], + ); + + const bottomPadding = 34.0; + + // Test the height is correct when keyboard not showing. + // So viewInset should be 0.0. + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(bottom: bottomPadding), + viewPadding: EdgeInsets.only(bottom: bottomPadding), + ), + child: CupertinoTabScaffold( + tabBar: tabBar, + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabBar)).height, tabBarHeight + bottomPadding); + + // Now show keyboard, and test the bar height will not change. + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: bottomPadding), + viewInsets: EdgeInsets.only(bottom: 336.0), + ), + child: CupertinoTabScaffold( + tabBar: tabBar, + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + + // Expect the bar height should not change. + expect(tester.getSize(find.byType(CupertinoTabBar)).height, tabBarHeight + bottomPadding); + }); + + testWidgets('Opaque background does not add blur effects', (WidgetTester tester) async { + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + ), + ), + ); + + expect(find.byType(BackdropFilter), findsOneWidget); + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + backgroundColor: const Color(0xFFFFFFFF), // Opaque white. + ), + ), + ); + + expect(find.byType(BackdropFilter), findsNothing); + }); + + testWidgets('Tap callback', (WidgetTester tester) async { + late int callbackTab; + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + currentIndex: 1, + onTap: (int tab) { + callbackTab = tab; + }, + ), + ), + ); + + await tester.tap(find.text('Tab 1')); + expect(callbackTab, 0); + + await tester.tap(find.text('Tab 2')); + expect(callbackTab, 1); + }); + + testWidgets('tabs announce semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + ), + ), + ); + + expect( + semantics, + includesNodeWith( + label: 'Tab 1', + hint: 'Tab 1 of 2', + flags: <SemanticsFlag>[SemanticsFlag.hasSelectedState, SemanticsFlag.isSelected], + textDirection: TextDirection.ltr, + ), + ); + + expect( + semantics, + includesNodeWith(label: 'Tab 2', hint: 'Tab 2 of 2', textDirection: TextDirection.ltr), + ); + + semantics.dispose(); + }); + + testWidgets('Label of items should be nullable', (WidgetTester tester) async { + final iconProvider = MemoryImage(Uint8List.fromList(kTransparentImage)); + final itemsTapped = <int>[]; + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem(icon: ImageIcon(iconProvider)), + ], + onTap: (int index) => itemsTapped.add(index), + ), + ), + ); + + expect(find.text('Tab 1'), findsOneWidget); + + final Finder finder = find.byWidgetPredicate( + (Widget widget) => widget is Image && widget.image == iconProvider, + ); + + await tester.tap(finder); + expect(itemsTapped, <int>[1]); + }); + + testWidgets('Hide border hides the top border of the tabBar', (WidgetTester tester) async { + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox)); + final boxDecoration = decoratedBox.decoration as BoxDecoration; + expect(boxDecoration.border, isNotNull); + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + backgroundColor: const Color(0xFFFFFFFF), // Opaque white. + border: null, + ), + ), + ); + + final DecoratedBox decoratedBoxHiddenBorder = tester.widget(find.byType(DecoratedBox)); + final boxDecorationHiddenBorder = decoratedBoxHiddenBorder.decoration as BoxDecoration; + expect(boxDecorationHiddenBorder.border, isNull); + }); + + testWidgets('Hovering over tab bar item updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: Center( + child: CupertinoTabBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(CupertinoIcons.alarm), label: 'Tab 1'), + BottomNavigationBarItem(icon: Icon(CupertinoIcons.app_badge), label: 'Tab 2'), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset tabItem = tester.getCenter(find.text('Tab 1')); + await gesture.moveTo(tabItem); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CupertinoTabBar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoTabBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(CupertinoIcons.add), label: 'X'), + BottomNavigationBarItem(icon: Icon(CupertinoIcons.add_circled), label: 'Y'), + ], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabBar)), Size.zero); + }); + + testWidgets('CupertinoTabBar item semanticsLabel overrides label for accessibility', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await pumpWidgetWithBoilerplate( + tester, + MediaQuery( + data: const MediaQueryData(), + child: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'A', + semanticsLabel: 'Custom A label', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'B', + ), + ], + ), + ), + ); + + // Tab A should use the custom semanticsLabel + expect( + semantics, + includesNodeWith( + label: 'Custom A label', + hint: 'Tab 1 of 2', + flags: <SemanticsFlag>[SemanticsFlag.hasSelectedState, SemanticsFlag.isSelected], + textDirection: TextDirection.ltr, + ), + ); + + // Tab B should use the default label since no semanticsLabel is provided + expect( + semantics, + includesNodeWith(label: 'B', hint: 'Tab 2 of 2', textDirection: TextDirection.ltr), + ); + + semantics.dispose(); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/button_test.dart b/packages/cupertino_ui/test/cupertino/button_test.dart new file mode 100644 index 000000000000..ba0e9b8c78dd --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/button_test.dart @@ -0,0 +1,1169 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +const TextStyle testStyle = TextStyle(fontSize: 10.0, letterSpacing: 0.0); + +void main() { + testWidgets('Default layout minimum size', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton(onPressed: null, child: Text('X', style: testStyle)), + ), + ); + final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); + expect( + buttonBox.size, + // 1 10px character + 20px * 2 = 50.0 + const Size(50.0, 44.0), + ); + }); + + testWidgets('Minimum size parameter', (WidgetTester tester) async { + const minSize = 60.0; + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton( + onPressed: null, + minSize: minSize, + child: Text('X', style: testStyle), + ), + ), + ); + final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); + expect( + buttonBox.size, + // 1 10px character + 20px * 2 = 50.0 (is smaller than minSize: 60.0) + const Size.square(minSize), + ); + }); + + testWidgets('Minimum size minimumSize parameter', (WidgetTester tester) async { + const size = Size(60.0, 100.0); + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton(onPressed: null, minimumSize: size, child: SizedBox.shrink()), + ), + ); + final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); + expect(buttonBox.size, size); + }); + + testWidgets('Size grows with text', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton(onPressed: null, child: Text('XXXX', style: testStyle)), + ), + ); + final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); + expect( + buttonBox.size.width, + // 4 10px character + 20px * 2 = 80.0 + 80.0, + ); + }); + + testWidgets('OnLongPress works!', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + onPressed: null, + onLongPress: () { + value = !value; + }, + child: const Text('XXXX', style: testStyle), + ), + ), + ); + await tester.pump(); + final Finder cupertinoBtn = find.byType(CupertinoButton); + await tester.longPress(cupertinoBtn); + expect(value, isTrue); + }); + + testWidgets('button is disabled if onLongPress and onPressed are both null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton(onPressed: null, child: Text('XXXX', style: testStyle)), + ), + ); + + expect(find.byType(CupertinoButton), findsOneWidget); + final CupertinoButton button = tester.widget(find.byType(CupertinoButton)); + expect(button.enabled, isFalse); + }); + + // TODO(LongCatIsLoong): Uncomment once https://github.com/flutter/flutter/issues/44115 + // is fixed. + /* + testWidgets( + 'CupertinoButton.filled default color contrast meets guideline', + (WidgetTester tester) async { + // The native color combination systemBlue text over white background fails + // to pass the color contrast guideline. + //await tester.pumpWidget( + // CupertinoTheme( + // data: const CupertinoThemeData(), + // child: Directionality( + // textDirection: TextDirection.ltr, + // child: CupertinoButton.filled( + // child: const Text('Button'), + // onPressed: () {}, + // ), + // ), + // ), + //); + //await expectLater(tester, meetsGuideline(textContrastGuideline)); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoPageScaffold( + child: CupertinoButton.filled( + child: const Text('Button'), + onPressed: () {}, + ), + ), + ), + ); + + await expectLater(tester, meetsGuideline(textContrastGuideline)); + }); + */ + + testWidgets('Button child alignment', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton(onPressed: () {}, child: const Text('button')), + ), + ); + + Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); // default + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton( + alignment: Alignment.centerLeft, + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.centerLeft); + }); + + testWidgets('Button size changes depending on size property', (WidgetTester tester) async { + const Widget child = Text('X', style: testStyle); + + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton( + onPressed: null, + sizeStyle: CupertinoButtonSize.small, + child: child, + ), + ), + ); + final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); + expect(buttonBox.size, const Size(34.0, 28.0)); + + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton( + onPressed: null, + sizeStyle: CupertinoButtonSize.medium, + child: child, + ), + ), + ); + expect(buttonBox.size, const Size(40.0, 32.0)); + + await tester.pumpWidget( + boilerplate(child: const CupertinoButton(onPressed: null, child: child)), + ); + expect(buttonBox.size, const Size(50.0, 44.0)); + }); + + testWidgets('Custom padding', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton( + onPressed: null, + padding: EdgeInsets.all(100.0), + child: Text('X', style: testStyle), + ), + ), + ); + final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); + expect(buttonBox.size, const Size.square(210.0)); + }); + + testWidgets('Button takes taps', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoButton( + child: const Text('Tap me'), + onPressed: () { + setState(() { + value = true; + }); + }, + ), + ); + }, + ), + ); + + expect(value, isFalse); + // No animating by default. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + await tester.tap(find.byType(CupertinoButton)); + expect(value, isTrue); + // Animates. + expect(SchedulerBinding.instance.transientCallbackCount, equals(1)); + }); + + testWidgets("Disabled button doesn't animate", (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate(child: const CupertinoButton(onPressed: null, child: Text('Tap me'))), + ); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + await tester.tap(find.byType(CupertinoButton)); + // Still doesn't animate. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Enabled button animates', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: CupertinoButton(child: const Text('Tap me'), onPressed: () {}), + ), + ); + + await tester.tap(find.byType(CupertinoButton)); + // Enter animation. + await tester.pump(); + FadeTransition transition = tester.firstWidget(find.byType(FadeTransition)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, moreOrLessEquals(0.403, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 100)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, moreOrLessEquals(0.400, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, moreOrLessEquals(0.650, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, moreOrLessEquals(0.894, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, moreOrLessEquals(0.988, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, moreOrLessEquals(1.0, epsilon: 0.001)); + }); + + testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: CupertinoButton(child: const Text('Tap me'), onPressed: () {}), + ), + ); + + // Keep a "down" gesture on the button + final Offset center = tester.getCenter(find.byType(CupertinoButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Check opacity + final FadeTransition opacity = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(FadeTransition)), + ); + expect(opacity.opacity.value, 0.4); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('pressedOpacity parameter', (WidgetTester tester) async { + const pressedOpacity = 0.5; + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + pressedOpacity: pressedOpacity, + child: const Text('Tap me'), + onPressed: () {}, + ), + ), + ); + + // Keep a "down" gesture on the button + final Offset center = tester.getCenter(find.byType(CupertinoButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Check opacity + final FadeTransition opacity = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(FadeTransition)), + ); + expect(opacity.opacity.value, pressedOpacity); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Cupertino button is semantically a button', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + boilerplate( + child: Center( + child: CupertinoButton(onPressed: () {}, child: const Text('ABC')), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: SemanticsAction.tap.index | SemanticsAction.focus.index, + label: 'ABC', + flags: <SemanticsFlag>[SemanticsFlag.isButton, SemanticsFlag.isFocusable], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Can specify colors', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + color: const Color(0x000000FF), + disabledColor: const Color(0x0000FF00), + onPressed: () {}, + child: const Text('Skeuomorph me'), + ), + ), + ); + + var decoration = + tester.widget<DecoratedBox>(find.widgetWithText(DecoratedBox, 'Skeuomorph me')).decoration + as ShapeDecoration; + + expect(decoration.color, const Color(0x000000FF)); + + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton( + color: Color(0x000000FF), + disabledColor: Color(0x0000FF00), + onPressed: null, + child: Text('Skeuomorph me'), + ), + ), + ); + + decoration = + tester.widget<DecoratedBox>(find.widgetWithText(DecoratedBox, 'Skeuomorph me')).decoration + as ShapeDecoration; + + expect(decoration.color, const Color(0x0000FF00)); + }); + + testWidgets('Can specify dynamic colors', (WidgetTester tester) async { + const Color bgColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFF123456), + darkColor: Color(0xFF654321), + ); + + const Color inactive = CupertinoDynamicColor.withBrightness( + color: Color(0xFF111111), + darkColor: Color(0xFF222222), + ); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: boilerplate( + child: CupertinoButton( + color: bgColor, + disabledColor: inactive, + onPressed: () {}, + child: const Text('Skeuomorph me'), + ), + ), + ), + ); + + var decoration = + tester.widget<DecoratedBox>(find.widgetWithText(DecoratedBox, 'Skeuomorph me')).decoration + as ShapeDecoration; + + expect(decoration.color!.value, 0xFF654321); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: boilerplate( + child: const CupertinoButton( + color: bgColor, + disabledColor: inactive, + onPressed: null, + child: Text('Skeuomorph me'), + ), + ), + ), + ); + + decoration = + tester.widget<DecoratedBox>(find.widgetWithText(DecoratedBox, 'Skeuomorph me')).decoration + as ShapeDecoration; + + // Disabled color. + expect(decoration.color!.value, 0xFF111111); + }); + + testWidgets('Button respects themes', (WidgetTester tester) async { + late TextStyle textStyle; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + expect(textStyle.color, isSameColorAs(CupertinoColors.activeBlue)); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton.tinted( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + expect(textStyle.color, CupertinoColors.activeBlue); + var decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.activeBlue.withOpacity(0.12))); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton.filled( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + expect(textStyle.color, isSameColorAs(CupertinoColors.white)); + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.activeBlue)); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoButton( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + expect(textStyle.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoButton.tinted( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + expect(textStyle.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.activeBlue.darkColor.withOpacity(0.26))); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoButton.filled( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + expect(textStyle.color, isSameColorAs(CupertinoColors.white)); + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton.filled( + color: CupertinoColors.systemRed, + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }, + ), + ), + ), + ); + + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.systemRed)); + }); + + testWidgets("All CupertinoButton const maps keys' match the available style sizes", ( + WidgetTester tester, + ) async { + for (final CupertinoButtonSize size in CupertinoButtonSize.values) { + expect(kCupertinoButtonPadding[size], isNotNull); + expect(kCupertinoButtonSizeBorderRadius[size], isNotNull); + expect(kCupertinoButtonMinSize[size], isNotNull); + } + }); + + testWidgets('Hovering over Cupertino button updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton.filled(onPressed: () {}, child: const Text('Tap me')), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset button = tester.getCenter(find.byType(CupertinoButton)); + await gesture.moveTo(button); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('Button can be focused and has default colors', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Button'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final defaultFocusBorder = BorderSide( + color: HSLColor.fromColor(CupertinoColors.activeBlue.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(), + width: 3.5, + strokeAlign: BorderSide.strokeAlignOutside, + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton( + onPressed: () {}, + focusNode: focusNode, + autofocus: true, + child: const Text('Tap me'), + ), + ), + ), + ); + + expect(focusNode.hasPrimaryFocus, isTrue); + + // The button has no border. + expect( + _findBorder( + tester, + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(DecoratedBox)), + ), + BorderSide.none, + ); + await tester.pump(); + + // When focused, the button has a light blue border outline by default. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + _findBorder( + tester, + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(DecoratedBox)), + ), + defaultFocusBorder, + ); + }); + + testWidgets('Button configures focus color', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Button'); + addTearDown(focusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color focusColor = CupertinoColors.systemGreen; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton( + onPressed: () {}, + focusNode: focusNode, + autofocus: true, + focusColor: focusColor, + child: const Text('Tap me'), + ), + ), + ), + ); + + expect(focusNode.hasPrimaryFocus, isTrue); + focusNode.requestFocus(); + await tester.pump(); + await tester.pumpAndSettle(); + final BorderSide borderSide = _findBorder( + tester, + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(DecoratedBox)), + ); + expect(borderSide.color, focusColor); + }); + + testWidgets('CupertinoButton.onFocusChange callback', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'CupertinoButton'); + addTearDown(focusNode.dispose); + + var focused = false; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton( + onPressed: () {}, + focusNode: focusNode, + onFocusChange: (bool value) { + focused = value; + }, + child: const Text('Tap me'), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focused, isTrue); + expect(focusNode.hasFocus, isTrue); + + focusNode.unfocus(); + await tester.pump(); + expect(focused, isFalse); + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('IconThemeData falls back to default value when the TextStyle has a null size', ( + WidgetTester tester, + ) async { + const defaultIconTheme = IconThemeData(size: kCupertinoButtonDefaultIconSize); + + IconThemeData? actualIconTheme; + + // Large size. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData(actionTextStyle: TextStyle()), + ), + home: Center( + child: CupertinoButton( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + actualIconTheme = IconTheme.of(context); + + return const Placeholder(); + }, + ), + ), + ), + ), + ); + expect(actualIconTheme?.size, defaultIconTheme.size); + + // Small size. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData(actionSmallTextStyle: TextStyle()), + ), + home: Center( + child: CupertinoButton( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + actualIconTheme = IconTheme.of(context); + + return const Placeholder(); + }, + ), + ), + ), + ), + ); + }); + + testWidgets('Button can be activated by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoButton( + onPressed: () { + setState(() { + value = !value; + }); + }, + autofocus: true, + child: const Text('Tap me'), + ); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + // On web, buttons don't respond to the enter key. + expect(value, kIsWeb ? isTrue : isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(value, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(value, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(value, isTrue); + }); + + testWidgets('Press and move on button and animation works', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: CupertinoButton(onPressed: () {}, child: const Text('Tap me')), + ), + ); + final TestGesture gesture = await tester.startGesture( + tester.getTopLeft(find.byType(CupertinoButton)), + ); + addTearDown(gesture.removePointer); + // Check opacity. + final FadeTransition opacity = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(FadeTransition)), + ); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 0.4); + final double moveDistance = CupertinoButton.tapMoveSlop(); + await gesture.moveBy(Offset(0, -moveDistance + 1)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 0.4); + await gesture.moveBy(const Offset(0, -2)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 1.0); + await gesture.moveBy(const Offset(0, 1)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 0.4); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Drag outside button within ListView does not leave the button pressed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + child: ListView( + children: <Widget>[CupertinoButton(onPressed: () {}, child: const Text('Tap me'))], + ), + ), + ); + final FadeTransition opacity = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(FadeTransition)), + ); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + await gesture.down(tester.getTopLeft(find.byType(CupertinoButton))); + await gesture.moveBy(const Offset(1, 1)); + await gesture.moveBy(Offset(0, -CupertinoButton.tapMoveSlop() - 5)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 1.0); + }); + + testWidgets('onPressed trigger takes into account MoveSlop.', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + onPressed: () { + value = true; + }, + child: const Text('Tap me'), + ), + ), + ); + TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CupertinoButton))); + await gesture.moveTo( + tester.getBottomRight(find.byType(CupertinoButton)) + + Offset(0, CupertinoButton.tapMoveSlop()), + ); + await gesture.up(); + expect(value, isFalse); + + gesture = await tester.startGesture(tester.getTopLeft(find.byType(CupertinoButton))); + await gesture.moveTo( + tester.getBottomRight(find.byType(CupertinoButton)) + + Offset(0, CupertinoButton.tapMoveSlop()), + ); + await gesture.moveBy(const Offset(0, -1)); + await gesture.up(); + expect(value, isTrue); + }); + + testWidgets('Mouse cursor resolves in enabled/disabled/pressed/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Button'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + addTearDown(focusNode.dispose); + Widget buildButton({required bool enabled, MouseCursor? cursor}) { + return CupertinoApp( + home: Center( + child: CupertinoButton( + focusNode: focusNode, + onPressed: enabled ? () {} : null, + mouseCursor: cursor, + child: const Text('Tap Me'), + ), + ), + ); + } + + // Test default mouse cursor + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + await tester.pumpWidget(buildButton(enabled: true, cursor: const _ButtonMouseCursor())); + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoButton))); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoButton))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test disabled state mouse cursor + await tester.pumpWidget(buildButton(enabled: false, cursor: const _ButtonMouseCursor())); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoButton))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + + // Test focused state mouse cursor + await tester.pumpWidget(buildButton(enabled: true, cursor: const _ButtonMouseCursor())); + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.copy, + ); + focusNode.unfocus(); + + // Test pressed state mouse cursor + await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.down(tester.getCenter(find.byType(CupertinoButton))); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + await gesture.up(); + await gesture.removePointer(); + }); + + testWidgets('CupertinoButton foregroundColor applies to its text', (WidgetTester tester) async { + const customForegroundColor = Color(0xFF5500FF); + + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + onPressed: () {}, + foregroundColor: customForegroundColor, + child: const Text('Button'), + ), + ), + ); + + // Check that the text has the custom foreground color + final RichText text = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(RichText)), + ); + expect(text.text.style?.color, customForegroundColor); + }); + + testWidgets('CupertinoButton foregroundColor applies to its icon', (WidgetTester tester) async { + const customForegroundColor = Color(0xFF5500FF); + + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + onPressed: () {}, + foregroundColor: customForegroundColor, + child: const Icon(IconData(0xE000)), + ), + ), + ); + + // Check that the icon has the custom foreground color + final IconTheme iconTheme = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(IconTheme)), + ); + expect(iconTheme.data.color, customForegroundColor); + }); + + testWidgets( + "CupertinoButton uses the theme's primaryColor when foregroundColor is not specified", + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton(onPressed: () {}, child: const Text('Button')), + ), + ), + ); + + // The default color should be the primary color from the theme + final BuildContext context = tester.element(find.text('Button')); + final Color primaryColor = CupertinoTheme.of(context).primaryColor; + + final RichText text = tester.widget(find.byType(RichText)); + expect(text.text.style?.color, primaryColor); + }, + ); + + testWidgets('CupertinoButton.filled foregroundColor applies to its text', ( + WidgetTester tester, + ) async { + const customForegroundColor = Color(0xFF5500FF); + + await tester.pumpWidget( + boilerplate( + child: CupertinoButton.filled( + onPressed: () {}, + foregroundColor: customForegroundColor, + child: const Text('Button'), + ), + ), + ); + + // Check that the text has the custom foreground color + final RichText text = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(RichText)), + ); + expect(text.text.style?.color, customForegroundColor); + }); + + testWidgets('CupertinoButton foregroundColor applies to its text when disabled', ( + WidgetTester tester, + ) async { + const customForegroundColor = Color(0xFF5500FF); + + await tester.pumpWidget( + boilerplate( + child: const CupertinoButton( + onPressed: null, // disabled button + foregroundColor: customForegroundColor, + child: Text('Button'), + ), + ), + ); + + // Check that the text has the custom foreground color even when disabled + final RichText text = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(RichText)), + ); + expect(text.text.style?.color, customForegroundColor); + }); + + testWidgets('CupertinoButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoButton(child: const Text('X'), onPressed: () {}), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoButton)), Size.zero); + }); +} + +Widget boilerplate({required Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); +} + +class _ButtonMouseCursor extends WidgetStateMouseCursor { + const _ButtonMouseCursor(); + + @override + MouseCursor resolve(Set<WidgetState> states) { + return const WidgetStateProperty<MouseCursor>.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + WidgetState.pressed: SystemMouseCursors.grab, + WidgetState.focused: SystemMouseCursors.copy, + WidgetState.any: SystemMouseCursors.basic, + }).resolve(states); + } + + @override + String get debugDescription => '_ButtonMouseCursor()'; +} + +BorderSide _findBorder(WidgetTester tester, Finder finder) { + final decoration = tester.widget<DecoratedBox>(finder).decoration as ShapeDecoration; + return (decoration.shape as RoundedSuperellipseBorder).side; +} diff --git a/packages/cupertino_ui/test/cupertino/checkbox_test.dart b/packages/cupertino_ui/test/cupertino/checkbox_test.dart new file mode 100644 index 000000000000..4c7e5fcf8bbb --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/checkbox_test.dart @@ -0,0 +1,1050 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('CupertinoCheckbox semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoCheckbox(value: false, onChanged: (bool? b) {})), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoCheckbox(value: true, onChanged: (bool? b) {})), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoCheckbox(value: false, onChanged: null))), + ); + + expect( + tester.getSemantics(find.byType(CupertinoCheckbox)), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + // isFocusable is delayed by 1 frame. + isFocusable: true, + hasFocusAction: true, + ), + ); + + await tester.pump(); + // isFocusable should be false now after the 1 frame delay. + expect( + tester.getSemantics(find.byType(CupertinoCheckbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true), + ); + + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoCheckbox(value: true, onChanged: null))), + ); + + expect( + tester.getSemantics(find.byType(CupertinoCheckbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true, isChecked: true), + ); + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoCheckbox(value: null, tristate: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoCheckbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true, isCheckStateMixed: true), + ); + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoCheckbox(value: true, tristate: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoCheckbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true, isChecked: true), + ); + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoCheckbox(value: false, tristate: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoCheckbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true), + ); + + handle.dispose(); + }); + + testWidgets('Can wrap CupertinoCheckbox with Semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp( + home: Semantics( + label: 'foo', + textDirection: TextDirection.ltr, + child: CupertinoCheckbox(value: false, onChanged: (bool? b) {}), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + label: 'foo', + textDirection: TextDirection.ltr, + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('CupertinoCheckbox tristate: true', (WidgetTester tester) async { + bool? checkBoxValue; + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + tristate: true, + value: checkBoxValue, + onChanged: (bool? value) { + setState(() { + checkBoxValue = value; + }); + }, + ); + }, + ), + ), + ); + + expect(tester.widget<CupertinoCheckbox>(find.byType(CupertinoCheckbox)).value, null); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(CupertinoCheckbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + + checkBoxValue = true; + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + checkBoxValue = null; + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); + + testWidgets('has semantics for tristate', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoCheckbox(tristate: true, value: null, onChanged: (bool? newValue) {}), + ), + ); + + expect( + semantics.nodesWith( + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isCheckStateMixed, + ], + actions: <SemanticsAction>[SemanticsAction.focus, SemanticsAction.tap], + ), + hasLength(1), + ); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoCheckbox(tristate: true, value: true, onChanged: (bool? newValue) {}), + ), + ); + + expect( + semantics.nodesWith( + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isChecked, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + hasLength(1), + ); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoCheckbox(tristate: true, value: false, onChanged: (bool? newValue) {}), + ), + ); + + expect( + semantics.nodesWith( + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + hasLength(1), + ); + + semantics.dispose(); + }); + + testWidgets('has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + bool? checkboxValue = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: checkboxValue, + onChanged: (bool? value) { + setState(() { + checkboxValue = value; + }); + }, + ); + }, + ), + ), + ); + + await tester.tap(find.byType(CupertinoCheckbox)); + final RenderObject object = tester.firstRenderObject(find.byType(CupertinoCheckbox)); + + expect(checkboxValue, true); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + semanticsTester.dispose(); + }); + + testWidgets('Checkbox can configure a semantic label', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoCheckbox( + value: false, + onChanged: (bool? b) {}, + semanticLabel: 'checkbox', + ), + ), + ), + ); + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + label: 'checkbox', + ), + ); + + // If wrapped with semantics, both the parent semantic label and the + // checkbox's semantic label are used in annotation. + await tester.pumpWidget( + CupertinoApp( + home: Semantics( + label: 'foo', + textDirection: TextDirection.ltr, + child: CupertinoCheckbox( + value: false, + onChanged: (bool? b) {}, + semanticLabel: 'checkbox', + ), + ), + ), + ); + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + label: 'foo\ncheckbox', + textDirection: TextDirection.ltr, + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('Checkbox can be toggled by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + autofocus: true, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + // On web, switches don't respond to the enter key. + expect(value, kIsWeb ? isTrue : isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(value, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isTrue); + }); + + testWidgets('Checkbox respects shape and side on mobile', (WidgetTester tester) async { + const roundedRectangleBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ); + + const side = BorderSide(width: 4, color: Color(0xfff44336)); + + Widget buildApp() { + return CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: false, + onChanged: (bool? newValue) {}, + shape: roundedRectangleBorder, + side: side, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect( + tester.widget<CupertinoCheckbox>(find.byType(CupertinoCheckbox)).shape, + roundedRectangleBorder, + ); + expect(tester.widget<CupertinoCheckbox>(find.byType(CupertinoCheckbox)).side, side); + expect( + find.byType(CupertinoCheckbox), + paints..drrect( + color: const Color(0xfff44336), + outer: RRect.fromLTRBR(15.0, 15.0, 29.0, 29.0, const Radius.circular(5)), + inner: RRect.fromLTRBR(19.0, 19.0, 25.0, 25.0, const Radius.circular(1)), + ), + ); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets('Checkbox respects shape and side on desktop', (WidgetTester tester) async { + const roundedRectangleBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ); + + const side = BorderSide(width: 4, color: Color(0xfff44336)); + + Widget buildApp() { + return CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: false, + onChanged: (bool? newValue) {}, + shape: roundedRectangleBorder, + side: side, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect( + tester.widget<CupertinoCheckbox>(find.byType(CupertinoCheckbox)).shape, + roundedRectangleBorder, + ); + expect(tester.widget<CupertinoCheckbox>(find.byType(CupertinoCheckbox)).side, side); + expect( + find.byType(CupertinoCheckbox), + paints..drrect( + color: const Color(0xfff44336), + outer: RRect.fromLTRBR(0.0, 0.0, 14.0, 14.0, const Radius.circular(5)), + inner: RRect.fromLTRBR(4.0, 4.0, 10.0, 10.0, const Radius.circular(1)), + ), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Checkbox respects tap target size', (WidgetTester tester) async { + Widget buildApp() { + return CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: false, + onChanged: (bool? newValue) {}, + tapTargetSize: const Size.square(20.0), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoCheckbox), + paints..drrect( + outer: RRect.fromLTRBR(3.0, 3.0, 17.0, 17.0, const Radius.circular(4)), + inner: RRect.fromLTRBR(4.0, 4.0, 16.0, 16.0, const Radius.circular(3)), + ), + ); + }); + + testWidgets('Checkbox configures mouse cursor', (WidgetTester tester) async { + Widget buildApp({MouseCursor? mouseCursor, bool enabled = true, bool value = true}) { + return CupertinoApp( + home: Center( + child: CupertinoCheckbox( + value: value, + onChanged: enabled ? (bool? value) {} : null, + mouseCursor: mouseCursor, + ), + ), + ); + } + + await tester.pumpWidget(buildApp(value: false)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoCheckbox))); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoCheckbox))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test disabled checkbox. + await tester.pumpWidget(buildApp(enabled: false, value: false)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test mouse cursor can be configured. + await tester.pumpWidget(buildApp(mouseCursor: SystemMouseCursors.grab)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + }); + + testWidgets('Mouse cursor resolves in selected/focused/disabled states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + + Widget buildCheckbox({required bool value, required bool enabled}) { + return CupertinoApp( + home: Center( + child: CupertinoCheckbox( + value: value, + onChanged: enabled ? (bool? value) {} : null, + mouseCursor: const _CheckboxMouseCursor(), + focusNode: focusNode, + ), + ), + ); + } + + // Test unselected case. + await tester.pumpWidget(buildCheckbox(value: false, enabled: true)); + final TestGesture gesture1 = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture1.removePointer); + await gesture1.addPointer(location: tester.getCenter(find.byType(CupertinoCheckbox))); + await tester.pump(); + await gesture1.moveTo(tester.getCenter(find.byType(CupertinoCheckbox))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test selected case. + await tester.pumpWidget(buildCheckbox(value: true, enabled: true)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test focused case. + await tester.pumpWidget(buildCheckbox(value: true, enabled: true)); + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Test disabled case. + await tester.pumpWidget(buildCheckbox(value: true, enabled: false)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('Checkbox default colors, and size in light mode', (WidgetTester tester) async { + Widget buildCheckbox({bool value = true}) { + return CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoCheckbox(value: value, onChanged: (bool? newValue) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox()); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.light_theme.selected.png'), + ); + await tester.pumpWidget(buildCheckbox(value: false)); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.light_theme.unselected.png'), + ); + }); + + testWidgets('Checkbox default colors, and size in dark mode', (WidgetTester tester) async { + Widget buildCheckbox({bool value = true}) { + return CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary( + child: CupertinoCheckbox(value: value, onChanged: (bool? newValue) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox()); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.dark_theme.selected.png'), + ); + await tester.pumpWidget(buildCheckbox(value: false)); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.dark_theme.unselected.png'), + ); + }); + + testWidgets('Disabled checkbox default colors, and size in light mode', ( + WidgetTester tester, + ) async { + Widget buildCheckbox({bool value = true}) { + return CupertinoApp( + home: Center( + child: RepaintBoundary(child: CupertinoCheckbox(value: value, onChanged: null)), + ), + ); + } + + await tester.pumpWidget(buildCheckbox()); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.disabled_light_theme.selected.png'), + ); + await tester.pumpWidget(buildCheckbox(value: false)); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.disabled_light_theme.unselected.png'), + ); + }); + + testWidgets('Disabled checkbox default colors, and size in dark mode', ( + WidgetTester tester, + ) async { + Widget buildCheckbox({bool value = true}) { + return CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary(child: CupertinoCheckbox(value: value, onChanged: null)), + ), + ); + } + + await tester.pumpWidget(buildCheckbox()); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.disabled_dark_theme.selected.png'), + ); + await tester.pumpWidget(buildCheckbox(value: false)); + await expectLater( + find.byType(CupertinoCheckbox), + matchesGoldenFile('checkbox.disabled_dark_theme.unselected.png'), + ); + }); + + testWidgets('Checkbox fill color resolves in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledFillColor = Color(0xFF000001); + const activeDisabledFillColor = Color(0xFF000002); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return activeDisabledFillColor; + } + return activeEnabledFillColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildApp({required bool enabled}) { + return CupertinoApp( + home: CupertinoCheckbox( + value: true, + fillColor: fillColor, + onChanged: enabled ? (bool? value) {} : null, + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(CupertinoCheckbox)); + } + + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeEnabledFillColor)); + + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeDisabledFillColor)); + }); + + testWidgets('Checkbox fill color take precedence over active/inactive colors', ( + WidgetTester tester, + ) async { + const activeEnabledFillColor = Color(0xFF000001); + const activeDisabledFillColor = Color(0xFF000002); + const activeColor = Color(0xFF000003); + const inactiveColor = Color(0xFF000004); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return activeDisabledFillColor; + } + return activeEnabledFillColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildApp({required bool enabled}) { + return CupertinoApp( + home: CupertinoCheckbox( + value: true, + fillColor: fillColor, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: enabled ? (bool? value) {} : null, + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(CupertinoCheckbox)); + } + + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeEnabledFillColor)); + + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeDisabledFillColor)); + }); + + testWidgets('Checkbox fill color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'checkbox'); + addTearDown(focusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredFillColor = Color(0xFF000001); + const focusedFillColor = Color(0xFF000002); + const transparentColor = Color(0x00000000); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredFillColor; + } + if (states.contains(WidgetState.focused)) { + return focusedFillColor; + } + return transparentColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildApp({required bool enabled}) { + return CupertinoApp( + home: CupertinoCheckbox( + focusNode: focusNode, + value: enabled, + fillColor: fillColor, + onChanged: enabled ? (bool? value) {} : null, + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(CupertinoCheckbox)); + } + + await tester.pumpWidget(buildApp(enabled: true)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect(getCheckboxRenderer(), paints..rrect(color: focusedFillColor)); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoCheckbox))); + await tester.pumpAndSettle(); + + expect(getCheckboxRenderer(), paints..rrect(color: hoveredFillColor)); + }); + + testWidgets('Checkbox configures focus color', (WidgetTester tester) async { + const defaultCheckColor = Color(0xffffffff); + const defaultActiveFillColor = Color(0xff007aff); + final Color defaultFocusColor = + HSLColor.fromColor(CupertinoColors.activeBlue.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + const testFocusColor = Color(0xffaabbcc); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final node = FocusNode(); + addTearDown(node.dispose); + + Widget buildApp({Color? focusColor, bool autofocus = false, FocusNode? focusNode}) { + return CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoCheckbox( + value: true, + onChanged: (bool? newValue) {}, + autofocus: autofocus, + focusNode: focusNode, + focusColor: focusColor, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp(focusNode: node, autofocus: true)); + await tester.pump(); + expect(node.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoCheckbox), + paints + ..rrect(color: defaultActiveFillColor) + ..rrect() + ..path(color: defaultCheckColor) + ..rrect(color: defaultFocusColor, strokeWidth: 3.5, style: PaintingStyle.stroke), + reason: 'Checkbox shows the correct focus color', + ); + + await tester.pumpWidget(buildApp(focusColor: testFocusColor, focusNode: node, autofocus: true)); + await tester.pump(); + expect(node.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoCheckbox), + paints + ..rrect(color: defaultActiveFillColor) + ..rrect() + ..path(color: defaultCheckColor) + ..rrect(color: testFocusColor, strokeWidth: 3.5, style: PaintingStyle.stroke), + reason: 'Checkbox can configure a focus color', + ); + }); + + testWidgets('Checkbox is darkened when pressed in light mode', (WidgetTester tester) async { + const defaultCheckColor = Color(0xffffffff); + const defaultActiveFillColor = Color(0xff007aff); + const defaultInactiveFillColor = Color(0xffffffff); + const pressedDarkShadow = Color(0x26ffffff); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoCheckbox(value: false, onChanged: (_) {})), + ), + ); + + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoCheckbox)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoCheckbox), + paints + ..rrect(color: defaultInactiveFillColor) + ..drrect() + ..rrect(color: pressedDarkShadow), + reason: 'Inactive pressed checkbox is slightly darkened', + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoCheckbox(value: true, onChanged: (_) {})), + ), + ); + + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoCheckbox)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoCheckbox), + paints + ..rrect(color: defaultActiveFillColor) + ..rrect() + ..path(color: defaultCheckColor) + ..rrect(color: pressedDarkShadow), + reason: 'Active pressed checkbox is slightly darkened', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pump(); + }); + + testWidgets('Checkbox is lightened when pressed in dark mode', (WidgetTester tester) async { + const checkColor = Color(0xffdee8f8); + const defaultActiveFillColor = Color(0xff3264d7); + const defaultInactiveFillColor = Color(0xff000000); + const pressedLightShadow = Color(0x26ffffff); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoCheckbox(value: false, onChanged: (_) {})), + ), + ); + + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoCheckbox)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoCheckbox), + paints + ..rrect(color: defaultInactiveFillColor) + ..drrect() + ..rrect(color: pressedLightShadow), + reason: 'Inactive pressed checkbox is slightly lightened', + ); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoCheckbox(value: true, onChanged: (_) {})), + ), + ); + + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoCheckbox)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoCheckbox), + paints + ..rrect(color: defaultActiveFillColor) + ..rrect() + ..path(color: checkColor) + ..rrect(color: pressedLightShadow), + reason: 'Active pressed checkbox is slightly lightened', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pump(); + }); + + testWidgets('CupertinoCheckbox does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoCheckbox(value: true, onChanged: (_) {})), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoCheckbox)), Size.zero); + }); +} + +class _CheckboxMouseCursor extends WidgetStateMouseCursor { + const _CheckboxMouseCursor(); + + @override + MouseCursor resolve(Set<WidgetState> states) { + return const WidgetStateProperty<MouseCursor>.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + WidgetState.focused: SystemMouseCursors.grab, + WidgetState.selected: SystemMouseCursors.click, + WidgetState.any: SystemMouseCursors.basic, + }).resolve(states); + } + + @override + String get debugDescription => '_CheckboxMouseCursor()'; +} diff --git a/packages/cupertino_ui/test/cupertino/colors_test.dart b/packages/cupertino_ui/test/cupertino/colors_test.dart new file mode 100644 index 000000000000..d28f67ecfc99 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/colors_test.dart @@ -0,0 +1,549 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class DependentWidget extends StatelessWidget { + const DependentWidget({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + final Color resolved = CupertinoDynamicColor.resolve(color, context); + return DecoratedBox( + decoration: BoxDecoration(color: resolved), + child: const SizedBox.expand(), + ); + } +} + +const Color color0 = Color(0xFF000000); +const Color color1 = Color(0xFF000001); +const Color color2 = Color(0xFF000002); +const Color color3 = Color(0xFF000003); +const Color color4 = Color(0xFF000004); +const Color color5 = Color(0xFF000005); +const Color color6 = Color(0xFF000006); +const Color color7 = Color(0xFF000007); + +// A color that depends on color vibrancy, accessibility contrast, as well as user +// interface elevation. +const CupertinoDynamicColor dynamicColor = CupertinoDynamicColor( + color: color0, + darkColor: color1, + elevatedColor: color2, + highContrastColor: color3, + darkElevatedColor: color4, + darkHighContrastColor: color5, + highContrastElevatedColor: color6, + darkHighContrastElevatedColor: color7, +); + +// A color that uses [color0] in every circumstance. +const Color notSoDynamicColor1 = CupertinoDynamicColor( + color: color0, + darkColor: color0, + darkHighContrastColor: color0, + darkElevatedColor: color0, + darkHighContrastElevatedColor: color0, + highContrastColor: color0, + highContrastElevatedColor: color0, + elevatedColor: color0, +); + +// A color that uses [color1] for light mode, and [color0] for dark mode. +const Color vibrancyDependentColor1 = CupertinoDynamicColor( + color: color1, + elevatedColor: color1, + highContrastColor: color1, + highContrastElevatedColor: color1, + darkColor: color0, + darkHighContrastColor: color0, + darkElevatedColor: color0, + darkHighContrastElevatedColor: color0, +); + +// A color that uses [color1] for normal contrast mode, and [color0] for high +// contrast mode. +const Color contrastDependentColor1 = CupertinoDynamicColor( + color: color1, + darkColor: color1, + elevatedColor: color1, + darkElevatedColor: color1, + highContrastColor: color0, + darkHighContrastColor: color0, + highContrastElevatedColor: color0, + darkHighContrastElevatedColor: color0, +); + +// A color that uses [color1] for base interface elevation, and [color0] for elevated +// interface elevation. +const Color elevationDependentColor1 = CupertinoDynamicColor( + color: color1, + darkColor: color1, + highContrastColor: color1, + darkHighContrastColor: color1, + elevatedColor: color0, + darkElevatedColor: color0, + highContrastElevatedColor: color0, + darkHighContrastElevatedColor: color0, +); + +void main() { + test('== works as expected', () { + expect( + dynamicColor, + const CupertinoDynamicColor( + color: color0, + darkColor: color1, + elevatedColor: color2, + highContrastColor: color3, + darkElevatedColor: color4, + darkHighContrastColor: color5, + highContrastElevatedColor: color6, + darkHighContrastElevatedColor: color7, + ), + ); + + expect(notSoDynamicColor1, isNot(vibrancyDependentColor1)); + + expect(notSoDynamicColor1, isNot(contrastDependentColor1)); + + expect( + vibrancyDependentColor1, + isNot( + const CupertinoDynamicColor( + color: color0, + elevatedColor: color0, + highContrastColor: color0, + highContrastElevatedColor: color0, + darkColor: color0, + darkHighContrastColor: color0, + darkElevatedColor: color0, + darkHighContrastElevatedColor: color0, + ), + ), + ); + }); + + test('CupertinoDynamicColor.toString() works', () { + expect( + dynamicColor.toString(), + contains( + 'CupertinoDynamicColor(*color = ${const Color(0xff000000)}*, ' + 'darkColor = ${const Color(0xff000001)}, ' + 'highContrastColor = ${const Color(0xff000003)}, ' + 'darkHighContrastColor = ${const Color(0xff000005)}, ' + 'elevatedColor = ${const Color(0xff000002)}, ' + 'darkElevatedColor = ${const Color(0xff000004)}, ' + 'highContrastElevatedColor = ${const Color(0xff000006)}, ' + 'darkHighContrastElevatedColor = ${const Color(0xff000007)}', + ), + ); + expect( + notSoDynamicColor1.toString(), + contains('CupertinoDynamicColor(*color = ${const Color(0xff000000)}*'), + ); + expect( + vibrancyDependentColor1.toString(), + contains( + 'CupertinoDynamicColor(*color = ${const Color(0xff000001)}*, darkColor = ${const Color(0xff000000)}', + ), + ); + expect( + contrastDependentColor1.toString(), + contains( + 'CupertinoDynamicColor(*color = ${const Color(0xff000001)}*, highContrastColor = ${const Color(0xff000000)}', + ), + ); + expect( + elevationDependentColor1.toString(), + contains( + 'CupertinoDynamicColor(*color = ${const Color(0xff000001)}*, elevatedColor = ${const Color(0xff000000)}', + ), + ); + + expect( + const CupertinoDynamicColor.withBrightnessAndContrast( + color: color0, + darkColor: color1, + highContrastColor: color2, + darkHighContrastColor: color3, + ).toString(), + contains( + 'CupertinoDynamicColor(*color = ${const Color(0xff000000)}*, ' + 'darkColor = ${const Color(0xff000001)}, ' + 'highContrastColor = ${const Color(0xff000002)}, ' + 'darkHighContrastColor = ${const Color(0xff000003)}', + ), + ); + }); + + test('can resolve null color', () { + expect(CupertinoDynamicColor.maybeResolve(null, _NullElement.instance), isNull); + }); + + test('withVibrancy constructor creates colors that may depend on vibrancy', () { + expect( + vibrancyDependentColor1, + const CupertinoDynamicColor.withBrightness(color: color1, darkColor: color0), + ); + }); + + test( + 'withVibrancyAndContrast constructor creates colors that may depend on contrast and vibrancy', + () { + expect( + contrastDependentColor1, + const CupertinoDynamicColor.withBrightnessAndContrast( + color: color1, + darkColor: color1, + highContrastColor: color0, + darkHighContrastColor: color0, + ), + ); + + expect( + const CupertinoDynamicColor( + color: color0, + darkColor: color1, + highContrastColor: color2, + darkHighContrastColor: color3, + elevatedColor: color0, + darkElevatedColor: color1, + highContrastElevatedColor: color2, + darkHighContrastElevatedColor: color3, + ), + const CupertinoDynamicColor.withBrightnessAndContrast( + color: color0, + darkColor: color1, + highContrastColor: color2, + darkHighContrastColor: color3, + ), + ); + }, + ); + + testWidgets('Dynamic colors that are not actually dynamic should not claim dependencies', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const DependentWidget(color: notSoDynamicColor1)); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color0)); + }); + + testWidgets( + 'Dynamic colors that are only dependent on vibrancy should not claim unnecessary dependencies, ' + 'and its resolved color should change when its dependency changes', + (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: DependentWidget(color: vibrancyDependentColor1), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color1)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color0))); + + // Changing color vibrancy works. + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: DependentWidget(color: vibrancyDependentColor1), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color0)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color1))); + + // CupertinoTheme should take precedence over MediaQuery. + await tester.pumpWidget( + const CupertinoTheme( + data: CupertinoThemeData(brightness: Brightness.light), + child: MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: DependentWidget(color: vibrancyDependentColor1), + ), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color1)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color0))); + }, + ); + + testWidgets( + 'Dynamic colors that are only dependent on accessibility contrast should not claim unnecessary dependencies, ' + 'and its resolved color should change when its dependency changes', + (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: DependentWidget(color: contrastDependentColor1), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color1)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color0))); + + // Changing accessibility contrast works. + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(highContrast: true), + child: DependentWidget(color: contrastDependentColor1), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color0)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color1))); + }, + ); + + testWidgets( + 'Dynamic colors that are only dependent on elevation level should not claim unnecessary dependencies, ' + 'and its resolved color should change when its dependency changes', + (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: DependentWidget(color: elevationDependentColor1), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color1)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color0))); + + // Changing UI elevation works. + await tester.pumpWidget( + const CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: DependentWidget(color: elevationDependentColor1), + ), + ); + + expect(tester.takeException(), null); + expect(find.byType(DependentWidget), paints..rect(color: color0)); + expect(find.byType(DependentWidget), isNot(paints..rect(color: color1))); + }, + ); + + testWidgets('Dynamic color with all 3 dependencies works', (WidgetTester tester) async { + const Color dynamicRainbowColor1 = CupertinoDynamicColor( + color: color0, + darkColor: color1, + highContrastColor: color2, + darkHighContrastColor: color3, + darkElevatedColor: color4, + highContrastElevatedColor: color5, + darkHighContrastElevatedColor: color6, + elevatedColor: color7, + ); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color0)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color1)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color2)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark, highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color3)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color4)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color5)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark, highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color6)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: DependentWidget(color: dynamicRainbowColor1), + ), + ), + ); + expect(find.byType(DependentWidget), paints..rect(color: color7)); + }); + + testWidgets('CupertinoDynamicColor toARGB32() is the same as value', (WidgetTester tester) async { + late CupertinoDynamicColor color; + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark, primaryColor: dynamicColor), + home: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor as CupertinoDynamicColor; + return const Placeholder(); + }, + ), + ), + ); + + expect(color.value, color.toARGB32()); + }); + + testWidgets('CupertinoDynamicColor used in a CupertinoTheme', (WidgetTester tester) async { + late CupertinoDynamicColor color; + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark, primaryColor: dynamicColor), + home: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor as CupertinoDynamicColor; + return const Placeholder(); + }, + ), + ), + ); + + expect(color.value, dynamicColor.darkColor.value); + + // Changing dependencies works. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light, primaryColor: dynamicColor), + home: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor as CupertinoDynamicColor; + return const Placeholder(); + }, + ), + ), + ); + + expect(color.value, dynamicColor.color.value); + + // Having a dependency below the CupertinoTheme widget works. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(primaryColor: dynamicColor), + home: MediaQuery( + data: const MediaQueryData(), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor as CupertinoDynamicColor; + return const Placeholder(); + }, + ), + ), + ), + ), + ); + + expect(color.value, dynamicColor.color.value); + + // Changing dependencies works. + await tester.pumpWidget( + CupertinoApp( + // No brightness is explicitly specified here so it should defer to MediaQuery. + theme: const CupertinoThemeData(primaryColor: dynamicColor), + home: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark, highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor as CupertinoDynamicColor; + return const Placeholder(); + }, + ), + ), + ), + ), + ); + + expect(color.value, dynamicColor.darkHighContrastElevatedColor.value); + }); +} + +class _NullElement extends Element { + _NullElement() : super(const _NullWidget()); + + static _NullElement instance = _NullElement(); + + @override + bool get debugDoingBuild => throw UnimplementedError(); +} + +class _NullWidget extends Widget { + const _NullWidget(); + + @override + Element createElement() => throw UnimplementedError(); +} diff --git a/packages/cupertino_ui/test/cupertino/context_menu_action_test.dart b/packages/cupertino_ui/test/cupertino/context_menu_action_test.dart new file mode 100644 index 000000000000..a77de1472957 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/context_menu_action_test.dart @@ -0,0 +1,202 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Constants taken from _ContextMenuActionState. + const kBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFF1F1F1), + darkColor: Color(0xFF212122), + ); + const kBackgroundColorPressed = CupertinoDynamicColor.withBrightness( + color: Color(0xFFDDDDDD), + darkColor: Color(0xFF3F3F40), + ); + const Color kDestructiveActionColor = CupertinoColors.destructiveRed; + const FontWeight kDefaultActionWeight = FontWeight.w600; + + Widget getApp({ + VoidCallback? onPressed, + bool isDestructiveAction = false, + bool isDefaultAction = false, + Brightness? brightness, + }) { + final actionKey = UniqueKey(); + final action = CupertinoContextMenuAction( + key: actionKey, + onPressed: onPressed, + trailingIcon: CupertinoIcons.home, + isDestructiveAction: isDestructiveAction, + isDefaultAction: isDefaultAction, + child: const Text('I am a CupertinoContextMenuAction'), + ); + + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness ?? Brightness.light), + home: CupertinoPageScaffold(child: Center(child: action)), + ); + } + + TextStyle getTextStyle(WidgetTester tester) { + final Finder finder = find.descendant( + of: find.byType(CupertinoContextMenuAction), + matching: find.byType(DefaultTextStyle), + ); + expect(finder, findsOneWidget); + final DefaultTextStyle defaultStyle = tester.widget(finder); + return defaultStyle.style; + } + + Icon getIcon(WidgetTester tester) { + final Finder finder = find.descendant( + of: find.byType(CupertinoContextMenuAction), + matching: find.byType(Icon), + ); + expect(finder, findsOneWidget); + final Icon icon = tester.widget(finder); + return icon; + } + + testWidgets('responds to taps', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + getApp( + onPressed: () { + wasPressed = true; + }, + ), + ); + + expect(wasPressed, false); + await tester.tap(find.byType(CupertinoContextMenuAction)); + expect(wasPressed, true); + }); + + testWidgets('turns grey when pressed and held', (WidgetTester tester) async { + await tester.pumpWidget(getApp()); + expect(find.byType(CupertinoContextMenuAction), paints..rect(color: kBackgroundColor.color)); + + final Offset actionCenterLight = tester.getCenter(find.byType(CupertinoContextMenuAction)); + final TestGesture gestureLight = await tester.startGesture(actionCenterLight); + await tester.pump(); + expect( + find.byType(CupertinoContextMenuAction), + paints..rect(color: kBackgroundColorPressed.color), + ); + + await gestureLight.up(); + await tester.pump(); + expect(find.byType(CupertinoContextMenuAction), paints..rect(color: kBackgroundColor.color)); + + await tester.pumpWidget(getApp(brightness: Brightness.dark)); + expect( + find.byType(CupertinoContextMenuAction), + paints..rect(color: kBackgroundColor.darkColor), + ); + + final Offset actionCenterDark = tester.getCenter(find.byType(CupertinoContextMenuAction)); + final TestGesture gestureDark = await tester.startGesture(actionCenterDark); + await tester.pump(); + expect( + find.byType(CupertinoContextMenuAction), + paints..rect(color: kBackgroundColorPressed.darkColor), + ); + + await gestureDark.up(); + await tester.pump(); + expect( + find.byType(CupertinoContextMenuAction), + paints..rect(color: kBackgroundColor.darkColor), + ); + }); + + testWidgets('icon and textStyle colors are correct out of the box', (WidgetTester tester) async { + await tester.pumpWidget(getApp()); + expect(getTextStyle(tester).color, CupertinoColors.label); + expect(getIcon(tester).color, CupertinoColors.label); + }); + + testWidgets('icon and textStyle colors are correct for destructive actions', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(getApp(isDestructiveAction: true)); + expect(getTextStyle(tester).color, kDestructiveActionColor); + expect(getIcon(tester).color, kDestructiveActionColor); + }); + + testWidgets('textStyle is correct for defaultAction for Brightness.light', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(getApp(isDefaultAction: true)); + expect(getTextStyle(tester).fontWeight, kDefaultActionWeight); + final Element context = tester.element(find.byType(CupertinoContextMenuAction)); + // The dynamic color should have been resolved. + expect(getTextStyle(tester).color, CupertinoColors.label.resolveFrom(context)); + }); + + testWidgets('textStyle is correct for defaultAction for Brightness.dark', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/144492. + await tester.pumpWidget(getApp(isDefaultAction: true, brightness: Brightness.dark)); + expect(getTextStyle(tester).fontWeight, kDefaultActionWeight); + final Element context = tester.element(find.byType(CupertinoContextMenuAction)); + // The dynamic color should have been resolved. + expect(getTextStyle(tester).color, CupertinoColors.label.resolveFrom(context)); + }); + + testWidgets('Hovering over Cupertino context menu action updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + /// Cupertino context menu action without "onPressed" callback. + await tester.pumpWidget(getApp()); + final Offset contextMenuAction = tester.getCenter( + find.text('I am a CupertinoContextMenuAction'), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: contextMenuAction); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // / Cupertino context menu action with "onPressed" callback. + await tester.pumpWidget(getApp(onPressed: () {})); + await gesture.moveTo(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(contextMenuAction); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CupertinoContextMenuAction does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoContextMenuAction(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoContextMenuAction)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/context_menu_test.dart b/packages/cupertino_ui/test/cupertino/context_menu_test.dart new file mode 100644 index 000000000000..14f9c99e5ee8 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/context_menu_test.dart @@ -0,0 +1,1556 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:clock/clock.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + const kOpenScale = 1.15; + const kMinScaleFactor = 1.02; + + Widget getChild({double width = 300.0, double height = 100.0}) { + return Container(width: width, height: height, color: CupertinoColors.activeOrange); + } + + List<Widget> getActions({int number = 10}) { + return List<Widget>.generate( + number, + (int index) => CupertinoContextMenuAction(child: Text('Action $index')), + ); + } + + Widget getBuilder(BuildContext context, Animation<double> animation) { + return getChild(); + } + + Widget getContextMenu({ + Alignment alignment = Alignment.center, + Size screenSize = const Size(800.0, 600.0), + Widget? child, + }) { + return CupertinoApp( + home: CupertinoPageScaffold( + child: MediaQuery( + data: MediaQueryData(size: screenSize), + child: Align( + alignment: alignment, + child: CupertinoContextMenu( + actions: <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction $alignment')), + ], + child: child ?? getChild(), + ), + ), + ), + ), + ); + } + + Widget getBuilderContextMenu({ + Alignment alignment = Alignment.center, + Size screenSize = const Size(800.0, 600.0), + CupertinoContextMenuBuilder? builder, + }) { + return CupertinoApp( + home: CupertinoPageScaffold( + child: MediaQuery( + data: MediaQueryData(size: screenSize), + child: Align( + alignment: alignment, + child: CupertinoContextMenu.builder( + actions: <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction $alignment')), + ], + builder: builder ?? getBuilder, + ), + ), + ), + ), + ); + } + + // Finds the child widget that is rendered inside of _DecoyChild. + Finder findDecoyChild(Widget child) { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + matching: find.byWidget(child), + ); + } + + // Finds the child widget rendered inside of _ContextMenuRouteStatic. + Finder findStatic() { + return find.descendant( + of: find.byType(CupertinoApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_ContextMenuRouteStatic', + ), + ); + } + + Finder findStaticChild(Widget child) { + return find.descendant(of: findStatic(), matching: find.byWidget(child)); + } + + Finder findStaticChildColor(WidgetTester tester) { + return find.descendant( + of: findStatic(), + matching: find.byWidgetPredicate( + (Widget widget) => widget is ColoredBox && widget.color != CupertinoColors.activeOrange, + ), + ); + } + + Finder findFittedBox() { + return find.descendant(of: findStatic(), matching: find.byType(FittedBox)); + } + + Finder findStaticDefaultPreview() { + return find.descendant(of: findFittedBox(), matching: find.byType(ClipRSuperellipse)); + } + + group('CupertinoContextMenu before and during opening', () { + testWidgets('An unopened CupertinoContextMenu renders child in the same place as without', ( + WidgetTester tester, + ) async { + // Measure the child in the scene with no CupertinoContextMenu. + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold(child: Center(child: child)), + ), + ); + final Rect childRect = tester.getRect(find.byWidget(child)); + + // When wrapped in a CupertinoContextMenu, the child is rendered in the same Rect. + await tester.pumpWidget(getContextMenu(child: child)); + expect(find.byWidget(child), findsOneWidget); + expect(tester.getRect(find.byWidget(child)), childRect); + }); + + testWidgets('Can open CupertinoContextMenu by tap and hold', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsNothing, + ); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + Rect decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales by _kOpenSize. + await tester.pump(const Duration(milliseconds: 800)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kOpenScale); + + // Then the CupertinoContextMenu opens. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + }); + + testWidgets('CupertinoContextMenu is in the correct position when within a nested navigator', ( + WidgetTester tester, + ) async { + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: Align( + alignment: Alignment.bottomRight, + child: SizedBox( + width: 700, + height: 500, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + builder: (BuildContext context) => Align( + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction')), + ], + child: child, + ), + ), + ); + }, + ), + ), + ), + ), + ), + ), + ); + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsNothing, + ); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + Rect decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales by _kOpenSize. + await tester.pump(const Duration(milliseconds: 800)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kOpenScale); + + // Then the CupertinoContextMenu opens. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + }); + + testWidgets('_DecoyChild preserves the child color', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + backgroundColor: CupertinoColors.black, + child: MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: Center( + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction')), + ], + child: child, + ), + ), + ), + ), + ), + ); + + // Expect no _DecoyChild to be present before the gesture. + final Finder decoyChild = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_DecoyChild', + ); + expect(decoyChild, findsNothing); + + // Start press gesture on the child. + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + // Find the _DecoyChild by runtimeType, + // find the Container descendant with the BoxDecoration, + // then read the boxDecoration property. + final Finder decoyChildDescendant = find.descendant( + of: decoyChild, + matching: find.byType(Container), + ); + final boxDecoration = + (tester.firstWidget(decoyChildDescendant) as Container).decoration as BoxDecoration?; + const expectedColors = <Color?>[null, Color(0x00000000)]; + + // `Color(0x00000000)` -> Is `CupertinoColors.transparent`. + // `null` -> Default when no color argument is given in `BoxDecoration`. + // Any other color won't preserve the child's property. + expect(expectedColors, contains(boxDecoration?.color)); + + // End the gesture. + await gesture.up(); + await tester.pumpAndSettle(); + + // Expect no _DecoyChild to be present after ending the gesture. + final Finder decoyChildAfterEnding = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_DecoyChild', + ); + expect(decoyChildAfterEnding, findsNothing); + }); + + testWidgets( + 'CupertinoContextMenu with a basic builder opens and closes the same as when providing a child', + (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget( + getBuilderContextMenu( + builder: (BuildContext context, Animation<double> animation) { + return child; + }, + ), + ); + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsNothing, + ); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + Rect decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales by _kOpenSize. + await tester.pump(const Duration(milliseconds: 800)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kOpenScale); + + // Then the CupertinoContextMenu opens. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + }, + ); + + testWidgets('CupertinoContextMenu with a builder can change the animation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + getBuilderContextMenu( + builder: (BuildContext context, Animation<double> animation) { + return Container( + width: 300.0, + height: 100.0, + decoration: BoxDecoration( + color: CupertinoColors.activeOrange, + borderRadius: BorderRadius.circular(25.0 * animation.value), + ), + ); + }, + ), + ); + + final Widget child = find + .descendant(of: find.byType(TickerMode), matching: find.byType(Container)) + .evaluate() + .single + .widget; + final Rect childRect = tester.getRect(find.byWidget(child)); + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsNothing, + ); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + Finder findBuilderDecoyChild() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + matching: find.byType(Container), + ); + } + + final decoyContainer = tester.firstElement(findBuilderDecoyChild()).widget as Container; + final decoyDecoration = decoyContainer.decoration as BoxDecoration?; + expect(decoyDecoration?.borderRadius, equals(BorderRadius.zero)); + + expect(findBuilderDecoyChild(), findsOneWidget); + + // After a small delay, the _DecoyChild has begun to animate with a different border radius. + await tester.pump(const Duration(milliseconds: 500)); + final decoyLaterContainer = tester.firstElement(findBuilderDecoyChild()).widget as Container; + final decoyLaterDecoration = decoyLaterContainer.decoration as BoxDecoration?; + expect(decoyLaterDecoration?.borderRadius, isNot(equals(BorderRadius.zero))); + + // Finish gesture to release resources. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Hovering over Cupertino context menu updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction One')), + ], + child: child, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset contextMenu = tester.getCenter(find.byWidget(child)); + await gesture.moveTo(contextMenu); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CupertinoContextMenu is in the correct position when within a Transform.scale', ( + WidgetTester tester, + ) async { + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: Transform.scale( + scale: 0.5, + child: Align( + //alignment: Alignment.bottomRight, + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction')), + ], + child: child, + ), + ), + ), + ), + ), + ), + ); + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsNothing, + ); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + Rect decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales by _kOpenSize. + await tester.pump(const Duration(milliseconds: 800)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kOpenScale); + + // Then the CupertinoContextMenu opens. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + }); + }); + + group('CupertinoContextMenu when open', () { + testWidgets('Last action does not have border', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction One')), + ], + child: child, + ), + ), + ), + ), + ); + + // Open the CupertinoContextMenu + final TestGesture firstGesture = await tester.startGesture( + tester.getCenter(find.byWidget(child)), + ); + await tester.pumpAndSettle(); + await firstGesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + // Both the background color and the action colors are found. + expect(findStaticChildColor(tester), findsNWidgets(2)); + + // Close the CupertinoContextMenu. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction One')), + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction Two')), + ], + child: child, + ), + ), + ), + ), + ); + + // Open the CupertinoContextMenu + final TestGesture secondGesture = await tester.startGesture( + tester.getCenter(find.byWidget(child)), + ); + await tester.pumpAndSettle(); + await secondGesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + expect(findStaticChildColor(tester), findsNWidgets(3)); + }); + + testWidgets('Can close CupertinoContextMenu by background tap', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + + // Open the CupertinoContextMenu + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + // Tap and ensure that the CupertinoContextMenu is closed. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + }); + + testWidgets('Can close CupertinoContextMenu by dragging down', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + + // Open the CupertinoContextMenu + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + // Drag down not far enough and it bounces back and doesn't close. + expect(findStaticChild(child), findsOneWidget); + Offset staticChildCenter = tester.getCenter(findStaticChild(child)); + TestGesture swipeGesture = await tester.startGesture(staticChildCenter); + await swipeGesture.moveBy( + const Offset(0.0, 100.0), + timeStamp: const Duration(milliseconds: 100), + ); + await tester.pump(); + await swipeGesture.up(); + await tester.pump(); + expect(tester.getCenter(findStaticChild(child)).dy, greaterThan(staticChildCenter.dy)); + await tester.pumpAndSettle(); + expect(tester.getCenter(findStaticChild(child)), equals(staticChildCenter)); + expect(findStatic(), findsOneWidget); + + // Drag down far enough and it does close. + expect(findStaticChild(child), findsOneWidget); + staticChildCenter = tester.getCenter(findStaticChild(child)); + swipeGesture = await tester.startGesture(staticChildCenter); + await swipeGesture.moveBy( + const Offset(0.0, 200.0), + timeStamp: const Duration(milliseconds: 100), + ); + await tester.pump(); + await swipeGesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + }); + + testWidgets('Can close CupertinoContextMenu by flinging down', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + + // Open the CupertinoContextMenu + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + // Fling up and nothing happens. + expect(findStaticChild(child), findsOneWidget); + await tester.fling(findStaticChild(child), const Offset(0.0, -100.0), 1000.0); + await tester.pumpAndSettle(); + expect(findStaticChild(child), findsOneWidget); + + // Fling down to close the menu. + expect(findStaticChild(child), findsOneWidget); + await tester.fling(findStaticChild(child), const Offset(0.0, 100.0), 1000.0); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + }); + + testWidgets("Backdrop is added using ModalRoute's filter parameter", ( + WidgetTester tester, + ) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + expect(find.byType(BackdropFilter), findsNothing); + + // Open the CupertinoContextMenu + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + expect(find.byType(BackdropFilter), findsOneWidget); + }); + + testWidgets('Preview widget should have the correct border radius', ( + WidgetTester tester, + ) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + + // Open the CupertinoContextMenu. + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + // Check border radius. + expect(findStaticDefaultPreview(), findsOneWidget); + final previewWidget = tester.firstWidget(findStaticDefaultPreview()) as ClipRSuperellipse; + expect(previewWidget.borderRadius, equals(const BorderRadius.all(Radius.circular(12.0)))); + }); + + testWidgets('CupertinoContextMenu width is correct', (WidgetTester tester) async { + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsNothing, + ); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + Rect decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales by _kOpenSize. + await tester.pump(const Duration(milliseconds: 800)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kOpenScale); + + // Then the CupertinoContextMenu opens. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + // The CupertinoContextMenu has the correct width and height. + final CupertinoContextMenu widget = tester.widget(find.byType(CupertinoContextMenu)); + for (final Widget action in widget.actions) { + // The value of the height is 80 because of the font and icon size. + expect(tester.getSize(find.byWidget(action)).width, 250); + } + }); + + testWidgets('CupertinoContextMenu minimizes scaling offscreen', (WidgetTester tester) async { + const portraitScreenSize = Size(600.0, 800.0); + await binding.setSurfaceSize(portraitScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + final Widget child = getChild(); + + // Pump a CupertinoContextMenu on the top-left of the screen and open it. + await tester.pumpWidget(getContextMenu(alignment: Alignment.topLeft, child: child)); + await tester.pump(); + Rect childRect = tester.getRect(find.byWidget(child)); + // Start a press on the child. + final TestGesture gesture1 = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + Rect decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales. Since the context menu is fully + // top-left aligned, the minimum scale factor is used so that the menu + // animates minimally off the screen. + await tester.pump(const Duration(milliseconds: 900)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kMinScaleFactor); + + // Open and then close the CupertinoContextMenu. + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(599.0, 799.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + + // Pump a CupertinoContextMenu on the bottom-right of the screen and open it. + await tester.pumpWidget(getContextMenu(alignment: Alignment.bottomRight, child: child)); + await tester.pump(); + childRect = tester.getRect(find.byWidget(child)); + // Start a press on the child. + final TestGesture gesture2 = await tester.startGesture(childRect.center); + await tester.pump(); + + // The _DecoyChild is showing directly on top of the child. + expect(findDecoyChild(child), findsOneWidget); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, equals(decoyChildRect)); + + expect( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), + findsOneWidget, + ); + + // After a small delay, the _DecoyChild has begun to animate. + await tester.pump(const Duration(milliseconds: 400)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + + // Eventually the decoy fully scales. Since the context menu is fully + // bottom-right aligned, the minimum scale factor is used so that the menu + // animates minimally off the screen. + await tester.pump(const Duration(milliseconds: 900)); + decoyChildRect = tester.getRect(findDecoyChild(child)); + expect(childRect, isNot(equals(decoyChildRect))); + expect(decoyChildRect.width, childRect.width * kMinScaleFactor); + + // Open and then close the CupertinoContextMenu. + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + await gesture1.up(); + await gesture2.up(); + }); + + testWidgets("ContextMenu route animation doesn't throw exception on dismiss", ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/124597. + final List<int> items = List<int>.generate(2, (int index) => index).toList(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListView( + children: items + .map( + (int index) => CupertinoContextMenu( + actions: <CupertinoContextMenuAction>[ + CupertinoContextMenuAction( + child: const Text('DELETE'), + onPressed: () { + setState(() { + items.remove(index); + Navigator.of(context).pop(); + }); + Navigator.of(context).pop(); + }, + ), + ], + child: Text('Item $index'), + ), + ) + .toList(), + ); + }, + ), + ), + ), + ); + + // Open the CupertinoContextMenu. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Item 1'))); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Tap the delete action. + await tester.tap(find.text('DELETE')); + await tester.pumpAndSettle(); + + // The CupertinoContextMenu should be closed with no exception. + expect(find.text('DELETE'), findsNothing); + expect(tester.takeException(), null); + }); + }); + + group("Open layout differs depending on child's position on screen", () { + testWidgets('Portrait', (WidgetTester tester) async { + const portraitScreenSize = Size(600.0, 800.0); + await binding.setSurfaceSize(portraitScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + // Pump a CupertinoContextMenu in the center of the screen and open it. + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(screenSize: portraitScreenSize, child: child)); + expect(find.byType(CupertinoContextMenuAction), findsNothing); + Rect childRect = tester.getRect(find.byWidget(child)); + TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The position of the action is in the center of the screen. + expect(find.byType(CupertinoContextMenuAction), findsOneWidget); + final Offset center = tester.getTopLeft(find.byType(CupertinoContextMenuAction)); + + // Close the CupertinoContextMenu. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + + // Pump a CupertinoContextMenu on the left of the screen and open it. + await tester.pumpWidget( + getContextMenu( + alignment: Alignment.centerLeft, + screenSize: portraitScreenSize, + child: child, + ), + ); + expect(find.byType(CupertinoContextMenuAction), findsNothing); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byWidget(child)); + gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The position of the action is on the left of the screen. + expect(find.byType(CupertinoContextMenuAction), findsOneWidget); + final Offset left = tester.getTopLeft(find.byType(CupertinoContextMenuAction)); + expect(left.dx, lessThan(center.dx)); + + // Close the CupertinoContextMenu. + await tester.tapAt(const Offset(559.0, 799.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + + // Pump a CupertinoContextMenu on the right of the screen and open it. + await tester.pumpWidget( + getContextMenu( + alignment: Alignment.centerRight, + screenSize: portraitScreenSize, + child: child, + ), + ); + expect(find.byType(CupertinoContextMenuAction), findsNothing); + childRect = tester.getRect(find.byWidget(child)); + gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The position of the action is on the right of the screen. + expect(find.byType(CupertinoContextMenuAction), findsOneWidget); + final Offset right = tester.getTopLeft(find.byType(CupertinoContextMenuAction)); + expect(right.dx, greaterThan(center.dx)); + }); + + testWidgets('Landscape', (WidgetTester tester) async { + // Pump a CupertinoContextMenu in the center of the screen and open it. + final Widget child = getChild(); + await tester.pumpWidget(getContextMenu(child: child)); + expect(find.byType(CupertinoContextMenuAction), findsNothing); + Rect childRect = tester.getRect(find.byWidget(child)); + TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Landscape doesn't support a centered action list, so the action is on + // the left side of the screen. + expect(find.byType(CupertinoContextMenuAction), findsOneWidget); + final Offset center = tester.getTopLeft(find.byType(CupertinoContextMenuAction)); + + // Close the CupertinoContextMenu. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + + // Pump a CupertinoContextMenu on the left of the screen and open it. + await tester.pumpWidget(getContextMenu(alignment: Alignment.centerLeft, child: child)); + expect(find.byType(CupertinoContextMenuAction), findsNothing); + childRect = tester.getRect(find.byWidget(child)); + gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The position of the action is on the right of the screen, which is the + // same as for center aligned children in landscape. + expect(find.byType(CupertinoContextMenuAction), findsOneWidget); + final Offset left = tester.getTopLeft(find.byType(CupertinoContextMenuAction)); + expect(left.dx, equals(center.dx)); + + // Close the CupertinoContextMenu. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + + // Pump a CupertinoContextMenu on the right of the screen and open it. + await tester.pumpWidget(getContextMenu(alignment: Alignment.centerRight, child: child)); + expect(find.byType(CupertinoContextMenuAction), findsNothing); + childRect = tester.getRect(find.byWidget(child)); + gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The position of the action is on the left of the screen. + expect(find.byType(CupertinoContextMenuAction), findsOneWidget); + final Offset right = tester.getTopLeft(find.byType(CupertinoContextMenuAction)); + expect(right.dx, lessThan(left.dx)); + }); + }); + + testWidgets('Conflicting gesture detectors', (WidgetTester tester) async { + int? onPointerDownTime; + int? onPointerUpTime; + var insideTapTriggered = false; + // The required duration of the route to be pushed in is [500, 900]ms. + // 500ms is calculated from kPressTimeout+_previewLongPressTimeout/2. + // 900ms is calculated from kPressTimeout+_previewLongPressTimeout. + const pressDuration = Duration(milliseconds: 501); + + int now() => clock.now().millisecondsSinceEpoch; + + await tester.pumpWidget( + Listener( + onPointerDown: (PointerDownEvent event) => onPointerDownTime = now(), + onPointerUp: (PointerUpEvent event) => onPointerUpTime = now(), + child: CupertinoApp( + home: Align( + child: CupertinoContextMenu( + actions: const <CupertinoContextMenuAction>[ + CupertinoContextMenuAction(child: Text('CupertinoContextMenuAction')), + ], + child: GestureDetector( + onTap: () => insideTapTriggered = true, + child: Container( + width: 200, + height: 200, + key: const Key('container'), + color: const Color(0xFF00FF00), + ), + ), + ), + ), + ), + ), + ); + + // Start a press on the child. + final TestGesture gesture = await tester.createGesture(); + await gesture.down(tester.getCenter(find.byKey(const Key('container')))); + // Simulate the actual situation: + // the user keeps pressing and requesting frames. + // If there is only one frame, + // the animation is mutant and cannot drive the value of the animation controller. + for (var i = 0; i < 100; i++) { + await tester.pump(pressDuration ~/ 100); + } + await gesture.up(); + // Await pushing route. + await tester.pumpAndSettle(); + + // Judge whether _ContextMenuRouteStatic present on the screen. + final Finder routeStatic = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_ContextMenuRouteStatic', + ); + + // The insideTap and the route should not be triggered at the same time. + if (insideTapTriggered) { + // Calculate the actual duration. + final int actualDuration = onPointerUpTime! - onPointerDownTime!; + + expect( + routeStatic, + findsNothing, + reason: + 'When actualDuration($actualDuration) is in the range of 500ms~900ms, ' + 'which means the route is pushed, ' + 'but insideTap should not be triggered at the same time.', + ); + } else { + // The route should be pushed when the insideTap is not triggered. + expect(routeStatic, findsOneWidget); + } + }); + + testWidgets('CupertinoContextMenu scrolls correctly', (WidgetTester tester) async { + const numMenuItems = 100; + final Widget child = getChild(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: MediaQuery( + data: const MediaQueryData(size: Size(100, 100)), + child: CupertinoContextMenu( + actions: List<CupertinoContextMenuAction>.generate(numMenuItems, (int index) { + return CupertinoContextMenuAction(child: Text('Item $index'), onPressed: () {}); + }), + child: child, + ), + ), + ), + ), + ); + + // Open the CupertinoContextMenu. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byWidget(child))); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoContextMenu), findsOneWidget); + + // Verify the first items are visible. + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + + // Find the scrollable part of the context menu. + final Finder scrollableFinder = find.byType(Scrollable); + expect(scrollableFinder, findsOneWidget); + + // Verify a scrollbar is displayed. + expect(find.byType(CupertinoScrollbar), findsOneWidget); + + // Scroll to the bottom. + await tester.drag(scrollableFinder, const Offset(0, -500)); + await tester.pumpAndSettle(); + + // Verify the last item is visible. + expect(find.text('Item ${numMenuItems - 1}'), findsOneWidget); + + // Scroll back to the top. + await tester.drag(scrollableFinder, const Offset(0, 500)); + await tester.pumpAndSettle(); + + // Verify the first items are still visible. + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + }); + + testWidgets('Pushing a new route removes overlay', (WidgetTester tester) async { + final Widget child = getChild(); + const page = 'Page 2'; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return Center( + child: CupertinoContextMenu( + actions: const <Widget>[CupertinoContextMenuAction(child: Text('Test'))], + child: GestureDetector( + onTap: () { + Navigator.of(context).push( + CupertinoPageRoute<Widget>( + builder: (BuildContext context) => + const CupertinoPageScaffold(child: Text(page)), + ), + ); + }, + child: child, + ), + ), + ); + }, + ), + ), + ); + + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsNothing); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text(page), findsNothing); + + await tester.pump(const Duration(milliseconds: 300)); + await gesture.up(); + + // Kickstart the route transition. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // As the transition starts, the overlay has been removed. + // Only the child transitioning out is shown. + expect(find.text(page), findsOneWidget); + expect(find.byWidget(child), findsOneWidget); + }); + + testWidgets('Removing context menu from widget tree removes overlay', ( + WidgetTester tester, + ) async { + final Widget child = getChild(); + var ctxMenuRemoved = false; + late StateSetter setState; + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return Center( + child: ctxMenuRemoved + ? const SizedBox() + : CupertinoContextMenu( + actions: <Widget>[ + CupertinoContextMenuAction(child: const Text('Test'), onPressed: () {}), + ], + child: child, + ), + ); + }, + ), + ), + ); + + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + setState(() { + ctxMenuRemoved = true; + }); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byWidget(child), findsNothing); + }); + + testWidgets('CupertinoContextMenu goldens in portrait orientation', (WidgetTester tester) async { + const portraitScreenSize = Size(800.0, 900.0); + await binding.setSurfaceSize(portraitScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + final Widget leftChild = getChild(width: 200, height: 300); + final Widget rightChild = getChild(width: 200, height: 300); + final Widget centerChild = getChild(width: 200, height: 300); + final children = <Widget>[leftChild, centerChild, rightChild]; + + await tester.pumpWidget( + CupertinoApp( + home: GridView.count( + crossAxisCount: 3, + children: children.map((Widget child) { + return CupertinoContextMenu(actions: getActions(), child: child); + }).toList(), + ), + ), + ); + + Future<void> expectGolden(String name, Widget child) async { + // Open the child's CupertinoContextMenu. + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + await expectLater(findStatic(), matchesGoldenFile('context_menu.portrait.$name.png')); + + // Tap and ensure that the CupertinoContextMenu is closed. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + } + + await expectGolden('left', leftChild); + await expectGolden('center', centerChild); + await expectGolden('right', rightChild); + }); + + testWidgets('CupertinoContextMenu goldens in landscape orientation', (WidgetTester tester) async { + const landscapeScreenSize = Size(800.0, 600.0); + await binding.setSurfaceSize(landscapeScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + final Widget leftChild = getChild(width: 200, height: 300); + final Widget rightChild = getChild(width: 200, height: 300); + final Widget centerChild = getChild(width: 200, height: 300); + final children = <Widget>[leftChild, centerChild, rightChild]; + + await tester.pumpWidget( + CupertinoApp( + home: GridView.count( + crossAxisCount: 3, + children: children.map((Widget child) { + return CupertinoContextMenu(actions: getActions(), child: child); + }).toList(), + ), + ), + ); + + Future<void> expectGolden(String name, Widget child) async { + // Open the child's CupertinoContextMenu. + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + await expectLater(findStatic(), matchesGoldenFile('context_menu.landscape.$name.png')); + + // Tap and ensure that the CupertinoContextMenu is closed. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + expect(findStatic(), findsNothing); + } + + await expectGolden('left', leftChild); + await expectGolden('center', centerChild); + await expectGolden('right', rightChild); + }); + + group('CupertinoContextMenu sheet shrink animation alignment - ', () { + Future<void> testShrinkAlignment({ + required WidgetTester tester, + required Alignment alignment, + required Size screenSize, + required AlignmentDirectional expectedAlignment, + }) async { + final Widget child = getChild(); + await tester.pumpWidget( + getContextMenu(alignment: alignment, screenSize: screenSize, child: child), + ); + + // Open the CupertinoContextMenu. + final Rect childRect = tester.getRect(find.byWidget(child)); + final TestGesture openGesture = await tester.startGesture(childRect.center); + await tester.pumpAndSettle(); + await openGesture.up(); + await tester.pumpAndSettle(); + expect(findStatic(), findsOneWidget); + + final Finder sheetFinder = find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_ContextMenuSheet', + ); + expect(sheetFinder, findsOneWidget); + final Rect initialSheetRect = tester.getRect(sheetFinder); + final Finder staticChildFinder = findStaticChild(child); + expect(staticChildFinder, findsOneWidget); + await tester.pump(); + + // Drag down enough to trigger the shrink animation. + await tester.fling(staticChildFinder, Offset(0.0, childRect.height / 2), 1000.0); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // The sheet has shrunk. + expect(sheetFinder, findsOneWidget); + final Rect shrunkSheetRect = tester.getRect(sheetFinder); + expect(shrunkSheetRect.width, lessThan(initialSheetRect.width)); + expect(shrunkSheetRect.height, lessThan(initialSheetRect.height)); + + // Verify alignment based on how the rect has shrunk. + switch (expectedAlignment) { + case AlignmentDirectional.topStart: + expect( + shrunkSheetRect.left, + moreOrLessEquals(initialSheetRect.left, epsilon: Tolerance.defaultTolerance.distance), + ); + case AlignmentDirectional.topCenter: + expect( + shrunkSheetRect.center.dx, + moreOrLessEquals( + initialSheetRect.center.dx, + epsilon: Tolerance.defaultTolerance.distance, + ), + ); + case AlignmentDirectional.topEnd: + expect( + shrunkSheetRect.right, + moreOrLessEquals(initialSheetRect.right, epsilon: Tolerance.defaultTolerance.distance), + ); + default: + fail('Unhandled alignment: $expectedAlignment'); + } + await tester.pumpAndSettle(); + } + + testWidgets('Portrait', (WidgetTester tester) async { + const portraitScreenSize = Size(600.0, 800.0); + await binding.setSurfaceSize(portraitScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + await testShrinkAlignment( + tester: tester, + alignment: Alignment.centerLeft, + screenSize: portraitScreenSize, + expectedAlignment: AlignmentDirectional.topStart, + ); + await testShrinkAlignment( + tester: tester, + alignment: Alignment.center, + screenSize: portraitScreenSize, + expectedAlignment: AlignmentDirectional.topCenter, + ); + await testShrinkAlignment( + tester: tester, + alignment: Alignment.centerRight, + screenSize: portraitScreenSize, + expectedAlignment: AlignmentDirectional.topEnd, + ); + }); + + testWidgets('Landscape', (WidgetTester tester) async { + const landscapeScreenSize = Size(800.0, 600.0); + await binding.setSurfaceSize(landscapeScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + await testShrinkAlignment( + tester: tester, + alignment: Alignment.centerLeft, + screenSize: landscapeScreenSize, + expectedAlignment: AlignmentDirectional.topStart, + ); + await testShrinkAlignment( + tester: tester, + alignment: Alignment.center, + screenSize: landscapeScreenSize, + expectedAlignment: AlignmentDirectional.topStart, + ); + await testShrinkAlignment( + tester: tester, + alignment: Alignment.centerRight, + screenSize: landscapeScreenSize, + expectedAlignment: AlignmentDirectional.topEnd, + ); + }); + }); + + testWidgets('CupertinoContextMenu respects available screen width - Portrait', ( + WidgetTester tester, + ) async { + const portraitScreenSize = Size(300.0, 350.0); + await binding.setSurfaceSize(portraitScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + final Widget child = getChild(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: portraitScreenSize), + child: CupertinoApp( + home: Center( + child: CupertinoContextMenu( + actions: <Widget>[ + CupertinoContextMenuAction(child: const Text('Test'), onPressed: () {}), + ], + child: child, + ), + ), + ), + ), + ); + + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), null); + + // Verify the child width is constrained correctly. + expect(findStatic(), findsOneWidget); + final Size fittedBoxSize = tester.getSize(findFittedBox()); + // availableWidth = 300.0 (screen width) - 2 * 20.0 (padding) = 260.0 + expect(fittedBoxSize.width, 260.0); + }); + + testWidgets('CupertinoContextMenu respects available screen width - Landscape', ( + WidgetTester tester, + ) async { + const landscapeScreenSize = Size(350.0, 300.0); + await binding.setSurfaceSize(landscapeScreenSize); + addTearDown(() => binding.setSurfaceSize(null)); + + final Widget child = getChild(width: 500); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: landscapeScreenSize), + child: CupertinoApp( + home: Center( + child: CupertinoContextMenu( + actions: <Widget>[ + CupertinoContextMenuAction(child: const Text('Test'), onPressed: () {}), + ], + child: child, + ), + ), + ), + ), + ); + + expect(find.byWidget(child), findsOneWidget); + final Rect childRect = tester.getRect(find.byWidget(child)); + + // Start a press on the child. + final TestGesture gesture = await tester.startGesture(childRect.center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), null); + + // Verify the child width is constrained correctly. + expect(findStatic(), findsOneWidget); + final Size fittedBoxSize = tester.getSize(findFittedBox()); + // availableWidth = 350.0 (screen width) - 2 * 20.0 (padding) = 310.0 + // availableWidthForChild = 310.0 - 250.0 (menu width) = 60.0 + expect(fittedBoxSize.width, 60.0); + }); + + testWidgets('CupertinoContextMenu does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoContextMenu( + actions: const <Widget>[Text('X'), Text('Y')], + child: const Text('Y'), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoContextMenu)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/cupertino_focus_halo_test.dart b/packages/cupertino_ui/test/cupertino/cupertino_focus_halo_test.dart new file mode 100644 index 000000000000..3c31932b5d9a --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/cupertino_focus_halo_test.dart @@ -0,0 +1,314 @@ +// 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/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ShapeBorder _getExpectedRectHaloBorder({required bool hasFocus}) => + RoundedRectangleBorder(side: _getExpectedBorderSide(hasFocus: hasFocus)); + +ShapeBorder _getExpectedRRectHaloBorder({ + required bool hasFocus, + required BorderRadius borderRadius, +}) { + return RoundedRectangleBorder( + borderRadius: borderRadius, + side: _getExpectedBorderSide(hasFocus: hasFocus), + ); +} + +ShapeBorder _getExpectedSuperellipseHaloBorder({ + required bool hasFocus, + required BorderRadius borderRadius, +}) { + return RoundedSuperellipseBorder( + borderRadius: borderRadius, + side: _getExpectedBorderSide(hasFocus: hasFocus), + ); +} + +BorderSide _getExpectedBorderSide({required bool hasFocus}) { + if (!hasFocus) { + return BorderSide.none; + } + + return BorderSide( + color: HSLColor.fromColor(CupertinoColors.activeBlue.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(), + width: 3.5, + ); +} + +ShapeBorder _findBorder(GlobalKey groupKey, WidgetTester tester) { + final Finder groupDecoratedBoxFinder = find.descendant( + of: find.byKey(groupKey), + matching: find.byType(DecoratedBox), + ); + + final box = tester.widget(groupDecoratedBoxFinder) as DecoratedBox; + final decoration = box.decoration as ShapeDecoration; + + return decoration.shape; +} + +void main() { + testWidgets( + 'CupertinoTraversalGroup appearance changes correctly with default focus color when focus is changed', + (WidgetTester tester) async { + final group1Child1FocusNode = FocusNode(debugLabel: 'group1Child1'); + final group1Child2FocusNode = FocusNode(debugLabel: 'group1Child2'); + final group2Child1FocusNode = FocusNode(debugLabel: 'group2Child1'); + + final GlobalKey group1Key = GlobalKey(); + final GlobalKey group2Key = GlobalKey(); + + addTearDown(group1Child1FocusNode.dispose); + addTearDown(group1Child2FocusNode.dispose); + addTearDown(group2Child1FocusNode.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoFocusHalo.withRect( + key: group1Key, + child: Column( + children: <Widget>[ + Focus( + focusNode: group1Child1FocusNode, + child: const SizedBox(height: 100, width: 100), + ), + Focus( + focusNode: group1Child2FocusNode, + child: const SizedBox(height: 100, width: 100), + ), + ], + ), + ), + CupertinoFocusHalo.withRect( + key: group2Key, + child: Focus( + focusNode: group2Child1FocusNode, + child: const SizedBox(height: 100, width: 100), + ), + ), + ], + ), + ), + ); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + group1Child1FocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: true)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + group1Child2FocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: true)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + group2Child1FocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: true)); + + group2Child1FocusNode.unfocus(); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + }, + ); + + testWidgets( + 'CupertinoTraversalGroup appearance changes correctly with default focus color when focus is traversed', + (WidgetTester tester) async { + final group1Child1FocusNode = FocusNode(debugLabel: 'group1Child1'); + final group1Child2FocusNode = FocusNode(debugLabel: 'group1Child2'); + final group2Child1FocusNode = FocusNode(debugLabel: 'group2Child1'); + + final GlobalKey group1Key = GlobalKey(); + final GlobalKey group2Key = GlobalKey(); + + addTearDown(group1Child1FocusNode.dispose); + addTearDown(group1Child2FocusNode.dispose); + addTearDown(group2Child1FocusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoFocusHalo.withRect( + key: group1Key, + child: Column( + children: <Widget>[ + Focus( + focusNode: group1Child1FocusNode, + child: const SizedBox(height: 100, width: 100), + ), + Focus( + focusNode: group1Child2FocusNode, + child: const SizedBox(height: 100, width: 100), + ), + ], + ), + ), + CupertinoFocusHalo.withRect( + key: group2Key, + child: Focus( + focusNode: group2Child1FocusNode, + child: const SizedBox(height: 100, width: 100), + ), + ), + ], + ), + ), + ); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: true)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: true)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(_findBorder(group1Key, tester), _getExpectedRectHaloBorder(hasFocus: false)); + expect(_findBorder(group2Key, tester), _getExpectedRectHaloBorder(hasFocus: true)); + }, + ); + + testWidgets('CupertinoFocusHalo.withRect draws a correct shape', (WidgetTester tester) async { + final focusNode = FocusNode(); + final GlobalKey haloKey = GlobalKey(); + + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFocusHalo.withRect( + key: haloKey, + child: Focus(focusNode: focusNode, child: const SizedBox(width: 100, height: 50)), + ), + ), + ), + ); + + expect(_findBorder(haloKey, tester), _getExpectedRectHaloBorder(hasFocus: false)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(_findBorder(haloKey, tester), _getExpectedRectHaloBorder(hasFocus: true)); + }); + + testWidgets('CupertinoFocusHalo.withRRect draws a correct shape', (WidgetTester tester) async { + final focusNode = FocusNode(); + final GlobalKey haloKey = GlobalKey(); + const borderRadius = BorderRadius.all(Radius.circular(12.0)); + + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFocusHalo.withRRect( + key: haloKey, + borderRadius: borderRadius, + child: Focus(focusNode: focusNode, child: const SizedBox(width: 100, height: 50)), + ), + ), + ), + ); + + expect( + _findBorder(haloKey, tester), + _getExpectedRRectHaloBorder(hasFocus: false, borderRadius: borderRadius), + ); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect( + _findBorder(haloKey, tester), + _getExpectedRRectHaloBorder(hasFocus: true, borderRadius: borderRadius), + ); + }); + + testWidgets('CupertinoFocusHalo.withRoundedSuperellipse draws a correct shape', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final GlobalKey haloKey = GlobalKey(); + const borderRadius = BorderRadius.all(Radius.circular(12.0)); + + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFocusHalo.withRoundedSuperellipse( + key: haloKey, + borderRadius: borderRadius, + child: Focus(focusNode: focusNode, child: const SizedBox(width: 100, height: 50)), + ), + ), + ), + ); + + expect( + _findBorder(haloKey, tester), + _getExpectedSuperellipseHaloBorder(hasFocus: false, borderRadius: borderRadius), + ); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect( + _findBorder(haloKey, tester), + _getExpectedSuperellipseHaloBorder(hasFocus: true, borderRadius: borderRadius), + ); + }); + + testWidgets('CupertinoFocusHalo does not crash at zero area', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoFocusHalo.withRect( + child: Focus(focusNode: focusNode, child: const Text('X')), + ), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(CupertinoFocusHalo)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/date_picker_test.dart b/packages/cupertino_ui/test/cupertino/date_picker_test.dart new file mode 100644 index 000000000000..124fe59c2dc4 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/date_picker_test.dart @@ -0,0 +1,2813 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// A number of the hit tests below say "warnIfMissed: false". This is because +// the way the CupertinoPicker works, the hits don't actually reach the labels, +// the scroll view intercepts them. + +// scrolling by this offset will move the picker to the next item +const Offset _kRowOffset = Offset(0.0, -50.0); + +void main() { + group('Countdown timer picker', () { + testWidgets('initialTimerDuration falls within limit', (WidgetTester tester) async { + expect(() { + CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + initialTimerDuration: const Duration(days: 1), + ); + }, throwsAssertionError); + + expect(() { + CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + initialTimerDuration: const Duration(seconds: -1), + ); + }, throwsAssertionError); + }); + + testWidgets('minuteInterval is positive and is a factor of 60', (WidgetTester tester) async { + expect(() { + CupertinoTimerPicker(onTimerDurationChanged: (_) {}, minuteInterval: 0); + }, throwsAssertionError); + expect(() { + CupertinoTimerPicker(onTimerDurationChanged: (_) {}, minuteInterval: -1); + }, throwsAssertionError); + expect(() { + CupertinoTimerPicker(onTimerDurationChanged: (_) {}, minuteInterval: 7); + }, throwsAssertionError); + }); + + testWidgets('secondInterval is positive and is a factor of 60', (WidgetTester tester) async { + expect(() { + CupertinoTimerPicker(onTimerDurationChanged: (_) {}, secondInterval: 0); + }, throwsAssertionError); + expect(() { + CupertinoTimerPicker(onTimerDurationChanged: (_) {}, secondInterval: -1); + }, throwsAssertionError); + expect(() { + CupertinoTimerPicker(onTimerDurationChanged: (_) {}, secondInterval: 7); + }, throwsAssertionError); + }); + + testWidgets('background color default value', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp(home: CupertinoTimerPicker(onTimerDurationChanged: (_) {})), + ); + + final Iterable<CupertinoPicker> pickers = tester.allWidgets.whereType<CupertinoPicker>(); + expect(pickers.any((CupertinoPicker picker) => picker.backgroundColor != null), false); + }); + + testWidgets('background color can be null', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp(home: CupertinoTimerPicker(onTimerDurationChanged: (_) {})), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('specified background color is applied', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + backgroundColor: CupertinoColors.black, + ), + ), + ); + + final Iterable<CupertinoPicker> pickers = tester.allWidgets.whereType<CupertinoPicker>(); + expect( + pickers.any((CupertinoPicker picker) => picker.backgroundColor != CupertinoColors.black), + false, + ); + }); + + testWidgets('specified item extent value is applied', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp(home: CupertinoTimerPicker(itemExtent: 42, onTimerDurationChanged: (_) {})), + ); + + final Iterable<CupertinoPicker> pickers = tester.allWidgets.whereType<CupertinoPicker>(); + expect(pickers.any((CupertinoPicker picker) => picker.itemExtent != 42), false); + }); + + testWidgets('columns are ordered correctly when text direction is ltr', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + initialTimerDuration: const Duration(hours: 12, minutes: 30, seconds: 59), + ), + ), + ); + + Offset lastOffset = tester.getTopLeft(find.text('12')); + + expect(tester.getTopLeft(find.text('hours')).dx > lastOffset.dx, true); + lastOffset = tester.getTopLeft(find.text('hours')); + + expect(tester.getTopLeft(find.text('30')).dx > lastOffset.dx, true); + lastOffset = tester.getTopLeft(find.text('30')); + + expect(tester.getTopLeft(find.text('min.')).dx > lastOffset.dx, true); + lastOffset = tester.getTopLeft(find.text('min.')); + + expect(tester.getTopLeft(find.text('59')).dx > lastOffset.dx, true); + lastOffset = tester.getTopLeft(find.text('59')); + + expect(tester.getTopLeft(find.text('sec.')).dx > lastOffset.dx, true); + }); + + testWidgets('columns are ordered correctly when text direction is rtl', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + initialTimerDuration: const Duration(hours: 12, minutes: 30, seconds: 59), + ), + ), + ), + ); + + Offset lastOffset = tester.getTopLeft(find.text('12')); + + expect(tester.getTopLeft(find.text('hours')).dx > lastOffset.dx, false); + lastOffset = tester.getTopLeft(find.text('hours')); + + expect(tester.getTopLeft(find.text('30')).dx > lastOffset.dx, false); + lastOffset = tester.getTopLeft(find.text('30')); + + expect(tester.getTopLeft(find.text('min.')).dx > lastOffset.dx, false); + lastOffset = tester.getTopLeft(find.text('min.')); + + expect(tester.getTopLeft(find.text('59')).dx > lastOffset.dx, false); + lastOffset = tester.getTopLeft(find.text('59')); + + expect(tester.getTopLeft(find.text('sec.')).dx > lastOffset.dx, false); + }); + + testWidgets('width of picker is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: SizedBox.square( + dimension: 400.0, + child: CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + initialTimerDuration: const Duration(hours: 12, minutes: 30, seconds: 59), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('sec.')).dx - tester.getCenter(find.text('12')).dx; + + await tester.pumpWidget( + CupertinoApp( + home: SizedBox( + height: 400.0, + width: 800.0, + child: CupertinoTimerPicker( + onTimerDurationChanged: (_) {}, + initialTimerDuration: const Duration(hours: 12, minutes: 30, seconds: 59), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('sec.')).dx - tester.getCenter(find.text('12')).dx, + distance, + ); + }); + + testWidgets('onScrollEnd behavior reports changes correctly', (WidgetTester tester) async { + final selectedDurations = <Duration>[]; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoTimerPicker( + initialTimerDuration: const Duration(hours: 1, minutes: 30, seconds: 15), + changeReportingBehavior: ChangeReportingBehavior.onScrollEnd, + onTimerDurationChanged: (Duration duration) => selectedDurations.add(duration), + ), + ), + ), + ), + ); + final Offset initialOffset = tester.getTopLeft(find.text('30')); + + final TestGesture scrollGesture = await tester.startGesture(initialOffset); + // Should not report changes until the gesture ends. + await scrollGesture.moveBy(const Offset(0.0, 32.0)); + expect(selectedDurations, isEmpty); + + await scrollGesture.moveBy(const Offset(0.0, 32.0)); + expect(selectedDurations, isEmpty); + + await scrollGesture.up(); + await tester.pumpAndSettle(); + + // Only reports the last change. + expect(selectedDurations, hasLength(1)); + expect(selectedDurations.first, const Duration(hours: 1, minutes: 28, seconds: 15)); + }); + }); + + testWidgets('showDayOfWeek is only supported in date mode', (WidgetTester tester) async { + expect( + () => CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime _) {}, + showDayOfWeek: true, + ), + returnsNormally, + ); + + expect( + () => CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime _) {}, + showDayOfWeek: true, + ), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message ?? 'Unknown error', + 'message', + contains('showDayOfWeek is only supported in date mode'), + ), + ), + ); + + expect( + () => CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (DateTime _) {}, + showDayOfWeek: true, + ), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message ?? 'Unknown error', + 'message', + contains('showDayOfWeek is only supported in date mode'), + ), + ), + ); + + expect( + () => CupertinoDatePicker(onDateTimeChanged: (DateTime _) {}, showDayOfWeek: true), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message ?? 'Unknown error', + 'message', + contains('showDayOfWeek is only supported in date mode'), + ), + ), + ); + }); + + testWidgets('picker honors minuteInterval and secondInterval', (WidgetTester tester) async { + late Duration duration; + await tester.pumpWidget( + CupertinoApp( + home: SizedBox.square( + dimension: 400.0, + child: CupertinoTimerPicker( + minuteInterval: 10, + secondInterval: 12, + initialTimerDuration: const Duration(hours: 10, minutes: 40, seconds: 48), + onTimerDurationChanged: (Duration d) { + duration = d; + }, + ), + ), + ), + ); + await tester.drag(find.text('40'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.drag(find.text('48'), -_kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(duration, const Duration(hours: 10, minutes: 50, seconds: 36)); + }); + + group('Date picker', () { + testWidgets('initial date is set to default value', (WidgetTester tester) async { + final picker = CupertinoDatePicker(onDateTimeChanged: (_) {}); + expect(picker.initialDateTime, isNotNull); + }); + + testWidgets('background color default value', (WidgetTester tester) async { + await tester.pumpWidget(CupertinoApp(home: CupertinoDatePicker(onDateTimeChanged: (_) {}))); + + final Iterable<CupertinoPicker> pickers = tester.allWidgets.whereType<CupertinoPicker>(); + expect(pickers.any((CupertinoPicker picker) => picker.backgroundColor != null), false); + }); + + testWidgets('background color can be null', (WidgetTester tester) async { + await tester.pumpWidget(CupertinoApp(home: CupertinoDatePicker(onDateTimeChanged: (_) {}))); + + expect(tester.takeException(), isNull); + }); + + testWidgets('specified background color is applied', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoDatePicker( + onDateTimeChanged: (_) {}, + backgroundColor: CupertinoColors.black, + ), + ), + ); + + final Iterable<CupertinoPicker> pickers = tester.allWidgets.whereType<CupertinoPicker>(); + expect( + pickers.any((CupertinoPicker picker) => picker.backgroundColor != CupertinoColors.black), + false, + ); + }); + + testWidgets('specified item extent value is applied', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp(home: CupertinoDatePicker(itemExtent: 55, onDateTimeChanged: (_) {})), + ); + + final Iterable<CupertinoPicker> pickers = tester.allWidgets.whereType<CupertinoPicker>(); + expect(pickers.any((CupertinoPicker picker) => picker.itemExtent != 55), false); + }); + + testWidgets('initial date honors minuteInterval', (WidgetTester tester) async { + late DateTime newDateTime; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime d) => newDateTime = d, + initialDateTime: DateTime(2018, 10, 10, 10, 3), + minuteInterval: 3, + ), + ), + ), + ), + ); + + // Drag the minute picker to the next slot (03 -> 06). + // The `initialDateTime` and the `minuteInterval` values are specifically chosen + // so that `find.text` finds exactly one widget. + await tester.drag(find.text('03'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + + expect(newDateTime.minute, 6); + }); + + test('initial date honors minimumDate & maximumDate', () { + expect(() { + CupertinoDatePicker( + onDateTimeChanged: (DateTime d) {}, + initialDateTime: DateTime(2018, 10, 10), + minimumDate: DateTime(2018, 10, 11), + ); + }, throwsAssertionError); + + expect(() { + CupertinoDatePicker( + onDateTimeChanged: (DateTime d) {}, + initialDateTime: DateTime(2018, 10, 10), + maximumDate: DateTime(2018, 10, 9), + ); + }, throwsAssertionError); + }); + + testWidgets('changing initialDateTime after first build does not do anything', ( + WidgetTester tester, + ) async { + late DateTime selectedDateTime; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime dateTime) => selectedDateTime = dateTime, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ), + ); + + await tester.drag( + find.text('10'), + const Offset(0.0, 32.0), + pointer: 1, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(selectedDateTime, DateTime(2018, 1, 1, 9, 30)); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime dateTime) => selectedDateTime = dateTime, + // Change the initial date, but it shouldn't affect the present state. + initialDateTime: DateTime(2016, 4, 5, 15), + ), + ), + ), + ), + ); + + await tester.drag( + find.text('9'), + const Offset(0.0, 32.0), + pointer: 1, + touchSlopY: 0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Moving up an hour is still based on the original initial date time. + expect(selectedDateTime, DateTime(2018, 1, 1, 8, 30)); + }); + + testWidgets('date picker has expected string', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 9, 15), + ), + ), + ), + ), + ); + + expect(find.text('September'), findsOneWidget); + expect(find.text('9'), findsOneWidget); + expect(find.text('2018'), findsOneWidget); + }); + + testWidgets('datetime picker has expected string', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 9, 15, 3, 14), + ), + ), + ), + ), + ); + + expect(find.text('Sat Sep 15'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('14'), findsOneWidget); + expect(find.text('AM'), findsOneWidget); + }); + + testWidgets('monthYear picker has expected string', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 9), + ), + ), + ), + ), + ); + + expect(find.text('September'), findsOneWidget); + expect(find.text('2018'), findsOneWidget); + }); + + testWidgets('width of picker in date and time mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoDatePicker( + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('Mon Jan 1 ')).dx - tester.getCenter(find.text('AM')).dx; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + height: 400.0, + width: 800.0, + child: CupertinoDatePicker( + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('Mon Jan 1 ')).dx - tester.getCenter(find.text('AM')).dx, + distance, + ); + }); + + testWidgets('width of picker in date mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('January')).dx - tester.getCenter(find.text('2018')).dx; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + height: 400.0, + width: 800.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('January')).dx - tester.getCenter(find.text('2018')).dx, + distance, + ); + }); + + testWidgets('width of picker in time mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('10')).dx - tester.getCenter(find.text('AM')).dx; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + height: 400.0, + width: 800.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('10')).dx - tester.getCenter(find.text('AM')).dx, + moreOrLessEquals(distance), + ); + }); + + testWidgets('width of picker in monthYear mode is consistent', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018), + ), + ), + ), + ), + ); + + // Distance between the first column and the last column. + final double distance = + tester.getCenter(find.text('January')).dx - tester.getCenter(find.text('2018')).dx; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + height: 400.0, + width: 800.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018), + ), + ), + ), + ), + ); + + // Distance between the first and the last column should be the same. + expect( + tester.getCenter(find.text('January')).dx - tester.getCenter(find.text('2018')).dx, + distance, + ); + }); + + testWidgets('wheel does not bend outwards', (WidgetTester tester) async { + final Widget dateWidget = CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 1, 1, 10, 30), + ); + + const centerMonth = 'January'; + const visibleMonthsExceptTheCenter = <String>[ + 'September', + 'October', + 'November', + 'December', + 'February', + 'March', + 'April', + 'May', + ]; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center(child: SizedBox(height: 200.0, width: 300.0, child: dateWidget)), + ), + ), + ); + + // The wheel does not bend outwards. + for (final month in visibleMonthsExceptTheCenter) { + expect( + tester.getBottomLeft(find.text(centerMonth)).dx, + lessThan(tester.getBottomLeft(find.text(month)).dx), + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center(child: SizedBox(height: 200.0, width: 3000.0, child: dateWidget)), + ), + ), + ); + + // The wheel does not bend outwards at large widths. + for (final month in visibleMonthsExceptTheCenter) { + expect( + tester.getBottomLeft(find.text(centerMonth)).dx, + lessThan(tester.getBottomLeft(find.text(month)).dx), + ); + } + }); + + testWidgets('non-selectable dates are greyed out, ' + 'when minimum date is unconstrained', (WidgetTester tester) async { + final maximum = DateTime(2018, 6, 15); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + maximumDate: maximum, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 6, 15), + ), + ), + ), + ), + ); + + // unconstrained bounds are not affected. + expect( + tester.widget<Text>(find.text('14')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + + // the selected day is not affected. + expect( + tester.widget<Text>(find.text('15')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + + // out of bounds and should be greyed out. + expect( + tester.widget<Text>(find.text('16')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + }); + + testWidgets('non-selectable dates are greyed out, ' + 'when maximum date is unconstrained', (WidgetTester tester) async { + final minimum = DateTime(2018, 6, 15); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + minimumDate: minimum, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 6, 15), + ), + ), + ), + ), + ); + + // out of bounds and should be greyed out. + expect( + tester.widget<Text>(find.text('14')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + // the selected day is not affected. + expect( + tester.widget<Text>(find.text('15')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + + // unconstrained bounds are not affected. + expect( + tester.widget<Text>(find.text('16')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + }); + + testWidgets('non-selectable dates are greyed out, ' + 'months should be taken into account when greying out days', (WidgetTester tester) async { + final minimum = DateTime(2018, 5, 15); + final maximum = DateTime(2018, 7, 15); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + minimumDate: minimum, + maximumDate: maximum, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 6, 15), + ), + ), + ), + ), + ); + + // days of a different min/max month are not affected. + expect( + tester.widget<Text>(find.text('14')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + expect( + tester.widget<Text>(find.text('16')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + }); + + testWidgets('non-selectable dates are greyed out, ' + 'years should be taken into account when greying out days', (WidgetTester tester) async { + final minimum = DateTime(2017, 6, 15); + final maximum = DateTime(2019, 6, 15); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + minimumDate: minimum, + maximumDate: maximum, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 6, 15), + ), + ), + ), + ), + ); + + // days of a different min/max year are not affected. + expect( + tester.widget<Text>(find.text('14')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + expect( + tester.widget<Text>(find.text('16')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + }); + + testWidgets('picker automatically scrolls away from invalid date on month change', ( + WidgetTester tester, + ) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2018, 3, 30), + ), + ), + ), + ), + ); + + await tester.drag( + find.text('March'), + const Offset(0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + + // Momentarily, the 2018 and the incorrect 30 of February is aligned. + expect(tester.getTopLeft(find.text('2018')).dy, tester.getTopLeft(find.text('30')).dy); + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pump(); // Once to start the DrivenScrollActivity. + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 2, 28)); + expect(tester.getTopLeft(find.text('2018')).dy, tester.getTopLeft(find.text('28')).dy); + }); + + testWidgets('date picker automatically scrolls away from invalid date, ' + "and onDateTimeChanged doesn't report these dates", (WidgetTester tester) async { + late DateTime date; + // 2016 is a leap year. + final minimum = DateTime(2016, 2, 29); + final maximum = DateTime(2018, 12, 31); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + minimumDate: minimum, + maximumDate: maximum, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + // Callback doesn't transiently go into invalid dates. + expect(newDate.isAtSameMomentAs(minimum) || newDate.isAfter(minimum), isTrue); + expect(newDate.isAtSameMomentAs(maximum) || newDate.isBefore(maximum), isTrue); + }, + initialDateTime: DateTime(2017, 2, 28), + ), + ), + ), + ), + ); + + // 2017 has 28 days in Feb so 29 is greyed out. + expect( + tester.widget<Text>(find.text('29')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + await tester.drag( + find.text('2017'), + const Offset(0.0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pumpAndSettle(); // Now the autoscrolling should happen. + + expect(date, DateTime(2016, 2, 29)); + + // 2016 has 29 days in Feb so 29 is not greyed out. + expect( + tester.widget<Text>(find.text('29')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + + await tester.drag( + find.text('2016'), + const Offset(0.0, -32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pumpAndSettle(); + + expect(date, DateTime(2017, 2, 28)); + + expect( + tester.widget<Text>(find.text('29')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + }); + + testWidgets('dateTime picker automatically scrolls away from invalid date, ' + "and onDateTimeChanged doesn't report these dates", (WidgetTester tester) async { + late DateTime date; + final minimum = DateTime(2019, 11, 11, 3, 30); + final maximum = DateTime(2019, 11, 11, 14, 59, 59); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + minimumDate: minimum, + maximumDate: maximum, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + // Callback doesn't transiently go into invalid dates. + expect(minimum.isAfter(newDate), isFalse); + expect(maximum.isBefore(newDate), isFalse); + }, + initialDateTime: DateTime(2019, 11, 11, 4), + ), + ), + ), + ), + ); + + // 3:00 is valid but 2:00 should be invalid. + expect( + tester.widget<Text>(find.text('3')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + + expect( + tester.widget<Text>(find.text('2')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + // 'PM' is greyed out. + expect( + tester.widget<Text>(find.text('PM')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + await tester.drag( + find.text('AM'), + const Offset(0.0, -32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pumpAndSettle(); // Now the autoscrolling should happen. + + expect(date, DateTime(2019, 11, 11, 14, 59)); + + // 3'o clock and 'AM' are now greyed out. + expect( + tester.widget<Text>(find.text('AM')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + expect( + tester.widget<Text>(find.text('3')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + await tester.drag( + find.text('PM'), + const Offset(0.0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pumpAndSettle(); + + // Returns to min date. + expect(date, DateTime(2019, 11, 11, 3, 30)); + }); + + testWidgets('time picker automatically scrolls away from invalid date, ' + "and onDateTimeChanged doesn't report these dates", (WidgetTester tester) async { + late DateTime date; + final minimum = DateTime(2019, 11, 11, 3, 30); + final maximum = DateTime(2019, 11, 11, 14, 59, 59); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + minimumDate: minimum, + maximumDate: maximum, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + // Callback doesn't transiently go into invalid dates. + expect(minimum.isAfter(newDate), isFalse); + expect(maximum.isBefore(newDate), isFalse); + }, + initialDateTime: DateTime(2019, 11, 11, 4), + ), + ), + ), + ), + ); + + // 3:00 is valid but 2:00 should be invalid. + expect( + tester.widget<Text>(find.text('3')).style!.color, + isNot(isSameColorAs(CupertinoColors.inactiveGray.color)), + ); + + expect( + tester.widget<Text>(find.text('2')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + // 'PM' is greyed out. + expect( + tester.widget<Text>(find.text('PM')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + await tester.drag( + find.text('AM'), + const Offset(0.0, -32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pumpAndSettle(); // Now the autoscrolling should happen. + + expect(date, DateTime(2019, 11, 11, 14, 59)); + + // 3'o clock and 'AM' are now greyed out. + expect( + tester.widget<Text>(find.text('AM')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + expect( + tester.widget<Text>(find.text('3')).style!.color, + isSameColorAs(CupertinoColors.inactiveGray.color), + ); + + await tester.drag( + find.text('PM'), + const Offset(0.0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pumpAndSettle(); + + // Returns to min date. + expect(date, DateTime(2019, 11, 11, 3, 30)); + }); + + testWidgets('monthYear picker automatically scrolls away from invalid date, ' + "and onDateTimeChanged doesn't report these dates", (WidgetTester tester) async { + late DateTime date; + final minimum = DateTime(2016, 2); + final maximum = DateTime(2018, 12); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + minimumDate: minimum, + maximumDate: maximum, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + // Callback doesn't transiently go into invalid dates. + expect(newDate.isAtSameMomentAs(minimum) || newDate.isAfter(minimum), isTrue); + expect(newDate.isAtSameMomentAs(maximum) || newDate.isBefore(maximum), isTrue); + }, + initialDateTime: DateTime(2017, 2), + ), + ), + ), + ), + ); + + await tester.drag( + find.text('2017'), + const Offset(0.0, 100.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pumpAndSettle(); // Now the autoscrolling should happen. + + expect(date, DateTime(2016, 2)); + + await tester.drag( + find.text('2016'), + const Offset(0.0, -100.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pumpAndSettle(); + + expect(date, DateTime(2018, 12)); + + await tester.drag( + find.text('2016'), + const Offset(0.0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); // Once to trigger the post frame animate call. + await tester.pumpAndSettle(); + + expect(date, DateTime(2017, 12)); + }); + + testWidgets('picker automatically scrolls away from invalid date on day change', ( + WidgetTester tester, + ) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2018, 2, 27), // 2018 has 28 days in Feb. + ), + ), + ), + ), + ); + + await tester.drag( + find.text('27'), + const Offset(0.0, -32.0), + pointer: 1, + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + expect(date, DateTime(2018, 2, 28)); + + await tester.drag( + find.text('28'), + const Offset(0.0, -32.0), + pointer: 1, + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); // Once to trigger the post frame animate call. + + // Callback doesn't transiently go into invalid dates. + expect(date, DateTime(2018, 2, 28)); + // Momentarily, the invalid 29th of Feb is dragged into the middle. + expect(tester.getTopLeft(find.text('2018')).dy, tester.getTopLeft(find.text('29')).dy); + + await tester.pump(); // Once to start the DrivenScrollActivity. + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 2, 28)); + expect(tester.getTopLeft(find.text('2018')).dy, tester.getTopLeft(find.text('28')).dy); + }); + + testWidgets( + 'date picker should only take into account the date part of minimumDate and maximumDate', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/49606. + late DateTime date; + final minDate = DateTime(2020, 1, 1, 12); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + minimumDate: minDate, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2020, 1, 12), + ), + ), + ), + ), + ); + + // Scroll to 2019. + await tester.drag( + find.text('2020'), + const Offset(0.0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pumpAndSettle(); + expect(date.year, minDate.year); + expect(date.month, minDate.month); + expect(date.day, minDate.day); + }, + ); + + testWidgets( + 'date picker does not display previous day of minimumDate if it is set at midnight', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/72932 + final minDate = DateTime(2019, 12, 31); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + minimumDate: minDate, + onDateTimeChanged: (DateTime newDate) {}, + initialDateTime: minDate.add(const Duration(days: 1)), + ), + ), + ), + ), + ); + + expect(find.text('Mon Dec 30'), findsNothing); + }, + ); + + group('Picker handles initial noon/midnight times', () { + testWidgets('midnight', (WidgetTester tester) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2019, 1, 1, 0, 15), + ), + ), + ), + ), + ); + + // 0:15 -> 0:16 + await tester.drag(find.text('15'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 0, 16)); + }); + + testWidgets('noon', (WidgetTester tester) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2019, 1, 1, 12, 15), + ), + ), + ), + ), + ); + + // 12:15 -> 12:16 + await tester.drag(find.text('15'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 12, 16)); + }); + + testWidgets('noon in 24 hour time', (WidgetTester tester) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + use24hFormat: true, + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2019, 1, 1, 12, 25), + ), + ), + ), + ), + ); + + // 12:25 -> 12:26 + await tester.drag(find.text('25'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 12, 26)); + }); + }); + + testWidgets('picker persists am/pm value when scrolling hours', (WidgetTester tester) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2019, 1, 1, 3), + ), + ), + ), + ), + ); + + // 3:00 -> 15:00 + await tester.drag(find.text('AM'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 15)); + + // 15:00 -> 16:00 + await tester.drag(find.text('3'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 16)); + + // 16:00 -> 4:00 + await tester.drag(find.text('PM'), -_kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 4)); + + // 4:00 -> 3:00 + await tester.drag(find.text('4'), -_kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2019, 1, 1, 3)); + }); + + testWidgets( + 'picker automatically scrolls the am/pm column when the hour column changes enough', + (WidgetTester tester) async { + late DateTime date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + initialDateTime: DateTime(2018, 1, 1, 11, 59), + ), + ), + ), + ), + ); + + const deltaOffset = Offset(0.0, -18.0); + + // 11:59 -> 12:59 + await tester.drag(find.text('11'), _kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 12, 59)); + + // 12:59 -> 11:59 + await tester.drag(find.text('12'), -_kRowOffset, warnIfMissed: false); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 11, 59)); + + // 11:59 -> 9:59 + await tester.drag( + find.text('11'), + -((_kRowOffset - deltaOffset) * 2 + deltaOffset), + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 9, 59)); + + // 9:59 -> 15:59 + await tester.drag( + find.text('9'), + (_kRowOffset - deltaOffset) * 6 + deltaOffset, + warnIfMissed: false, + ); // see top of file + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(date, DateTime(2018, 1, 1, 15, 59)); + }, + ); + + testWidgets('date picker given too narrow space horizontally shows message', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + // This is too small to draw the picker out fully. + width: 100, + child: CupertinoDatePicker( + initialDateTime: DateTime(2019, 1, 1, 4), + onDateTimeChanged: (_) {}, + ), + ), + ), + ), + ); + + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + expect( + exception.toString(), + contains('Insufficient horizontal space to render the CupertinoDatePicker'), + ); + }); + + testWidgets('DatePicker golden tests', (WidgetTester tester) async { + Widget buildApp(CupertinoDatePickerMode mode) { + return CupertinoApp( + home: Center( + child: SizedBox( + width: 500, + height: 400, + child: RepaintBoundary( + child: CupertinoDatePicker( + key: ValueKey<CupertinoDatePickerMode>(mode), + mode: mode, + initialDateTime: DateTime(2019, 1, 1, 4, 12, 30), + onDateTimeChanged: (_) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(CupertinoDatePickerMode.time)); + await expectLater( + find.byType(CupertinoDatePicker), + matchesGoldenFile('date_picker_test.time.initial.png'), + ); + + await tester.pumpWidget(buildApp(CupertinoDatePickerMode.date)); + await expectLater( + find.byType(CupertinoDatePicker), + matchesGoldenFile('date_picker_test.date.initial.png'), + ); + + await tester.pumpWidget(buildApp(CupertinoDatePickerMode.monthYear)); + await expectLater( + find.byType(CupertinoDatePicker), + matchesGoldenFile('date_picker_test.monthyear.initial.png'), + ); + + await tester.pumpWidget(buildApp(CupertinoDatePickerMode.dateAndTime)); + await expectLater( + find.byType(CupertinoDatePicker), + matchesGoldenFile('date_picker_test.datetime.initial.png'), + ); + + // Slightly drag the hour component to make the current hour off-center. + await tester.drag( + find.text('4'), + Offset(0, _kRowOffset.dy / 2), + warnIfMissed: false, + ); // see top of file + await tester.pump(); + + await expectLater( + find.byType(CupertinoDatePicker), + matchesGoldenFile('date_picker_test.datetime.drag.png'), + ); + }); + + testWidgets('DatePicker displays the date in correct order', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + dateOrder: DatePickerDateOrder.ydm, + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime newDate) {}, + initialDateTime: DateTime(2018, 1, 14, 10, 30), + ), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.text('2018')).dx, + lessThan(tester.getTopLeft(find.text('14')).dx), + ); + + expect( + tester.getTopLeft(find.text('14')).dx, + lessThan(tester.getTopLeft(find.text('January')).dx), + ); + }); + + testWidgets('monthYear DatePicker displays the date in correct order', ( + WidgetTester tester, + ) async { + Widget buildApp(DatePickerDateOrder order) { + return CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + key: ValueKey<DatePickerDateOrder>(order), + dateOrder: order, + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (DateTime newDate) {}, + initialDateTime: DateTime(2018, 1, 14, 10, 30), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(DatePickerDateOrder.dmy)); + expect( + tester.getTopLeft(find.text('January')).dx, + lessThan(tester.getTopLeft(find.text('2018')).dx), + ); + + await tester.pumpWidget(buildApp(DatePickerDateOrder.mdy)); + expect( + tester.getTopLeft(find.text('January')).dx, + lessThan(tester.getTopLeft(find.text('2018')).dx), + ); + + await tester.pumpWidget(buildApp(DatePickerDateOrder.ydm)); + expect( + tester.getTopLeft(find.text('2018')).dx, + lessThan(tester.getTopLeft(find.text('January')).dx), + ); + + await tester.pumpWidget(buildApp(DatePickerDateOrder.ymd)); + expect( + tester.getTopLeft(find.text('2018')).dx, + lessThan(tester.getTopLeft(find.text('January')).dx), + ); + }); + + testWidgets('DatePicker displays hours and minutes correctly in RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: SizedBox( + width: 500, + height: 400, + child: CupertinoDatePicker( + initialDateTime: DateTime(2019, 1, 1, 4), + onDateTimeChanged: (_) {}, + ), + ), + ), + ), + ), + ); + + final double hourLeft = tester.getTopLeft(find.text('4')).dx; + final double minuteLeft = tester.getTopLeft(find.text('00')).dx; + expect(hourLeft, lessThan(minuteLeft)); + }); + + testWidgets('onScrollEnd behavior reports changes correctly', (WidgetTester tester) async { + final selectedDateTime = <DateTime>[]; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + changeReportingBehavior: ChangeReportingBehavior.onScrollEnd, + onDateTimeChanged: (DateTime dateTime) => selectedDateTime.add(dateTime), + initialDateTime: DateTime(2025), + ), + ), + ), + ), + ); + final Offset initialOffset = tester.getTopLeft(find.text('2025')); + + final TestGesture scrollGesture = await tester.startGesture(initialOffset); + // Should not report changes until the gesture ends. + await scrollGesture.moveBy(const Offset(0.0, -32.0)); + expect(selectedDateTime, isEmpty); + + await scrollGesture.moveBy(const Offset(0.0, -32.0)); + expect(selectedDateTime, isEmpty); + + await scrollGesture.up(); + await tester.pumpAndSettle(); + + // Only reports the last change. + expect(selectedDateTime, hasLength(1)); + expect(selectedDateTime.first, DateTime(2027)); + }); + }); + + testWidgets('TimerPicker golden tests', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + // Also check if the picker respects the theme. + theme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData( + pickerTextStyle: TextStyle(color: Color(0xFF663311), fontSize: 21), + ), + ), + home: Center( + child: SizedBox( + width: 320, + height: 216, + child: RepaintBoundary( + child: CupertinoTimerPicker( + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: const Duration(hours: 23, minutes: 59), + onTimerDurationChanged: (_) {}, + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byType(CupertinoTimerPicker), + matchesGoldenFile('timer_picker_test.datetime.initial.png'), + ); + + // Slightly drag the minute component to make the current minute off-center. + await tester.drag( + find.text('59'), + Offset(0, _kRowOffset.dy / 2), + warnIfMissed: false, + ); // see top of file + await tester.pump(); + + await expectLater( + find.byType(CupertinoTimerPicker), + matchesGoldenFile('timer_picker_test.datetime.drag.png'), + ); + }); + + testWidgets('TimerPicker only changes hour label after scrolling stops', ( + WidgetTester tester, + ) async { + Duration? duration; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: 320, + height: 216, + child: CupertinoTimerPicker( + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: const Duration(hours: 2, minutes: 30), + onTimerDurationChanged: (Duration d) { + duration = d; + }, + ), + ), + ), + ), + ); + + expect(duration, isNull); + expect(find.text('hour'), findsNothing); + expect(find.text('hours'), findsOneWidget); + + await tester.drag( + find.text('2'), + Offset(0, -_kRowOffset.dy), + warnIfMissed: false, + ); // see top of file + // Duration should change but not the label. + expect(duration!.inHours, 1); + expect(find.text('hour'), findsNothing); + expect(find.text('hours'), findsOneWidget); + await tester.pumpAndSettle(); + + // Now the label should change. + expect(duration!.inHours, 1); + expect(find.text('hours'), findsNothing); + expect(find.text('hour'), findsOneWidget); + }); + + testWidgets('TimerPicker has intrinsic width and height', (WidgetTester tester) async { + const key = Key('key'); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTimerPicker( + key: key, + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: const Duration(hours: 2, minutes: 30), + onTimerDurationChanged: (Duration d) {}, + ), + ), + ); + + expect( + tester.getSize(find.descendant(of: find.byKey(key), matching: find.byType(Row))), + const Size(320, 216), + ); + + // Different modes shouldn't share state. + await tester.pumpWidget(const Placeholder()); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTimerPicker( + key: key, + mode: CupertinoTimerPickerMode.ms, + initialTimerDuration: const Duration(minutes: 30, seconds: 3), + onTimerDurationChanged: (Duration d) {}, + ), + ), + ); + + expect( + tester.getSize(find.descendant(of: find.byKey(key), matching: find.byType(Row))), + const Size(320, 216), + ); + + // Different modes shouldn't share state. + await tester.pumpWidget(const Placeholder()); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTimerPicker( + key: key, + initialTimerDuration: const Duration(hours: 5, minutes: 17, seconds: 19), + onTimerDurationChanged: (Duration d) {}, + ), + ), + ); + + expect( + tester.getSize(find.descendant(of: find.byKey(key), matching: find.byType(Row))), + const Size(342, 216), + ); + }); + + testWidgets('scrollController can be removed or added', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + late int lastSelectedItem; + void onSelectedItemChanged(int index) { + lastSelectedItem = index; + } + + final scrollController1 = FixedExtentScrollController(); + addTearDown(scrollController1.dispose); + await tester.pumpWidget( + _buildPicker(controller: scrollController1, onSelectedItemChanged: onSelectedItemChanged), + ); + + tester.binding.pipelineOwner.semanticsOwner!.performAction(1, SemanticsAction.increase); + await tester.pumpAndSettle(); + expect(lastSelectedItem, 1); + + await tester.pumpWidget(_buildPicker(onSelectedItemChanged: onSelectedItemChanged)); + + tester.binding.pipelineOwner.semanticsOwner!.performAction(1, SemanticsAction.increase); + await tester.pumpAndSettle(); + expect(lastSelectedItem, 2); + + final scrollController2 = FixedExtentScrollController(); + addTearDown(scrollController2.dispose); + await tester.pumpWidget( + _buildPicker(controller: scrollController2, onSelectedItemChanged: onSelectedItemChanged), + ); + + tester.binding.pipelineOwner.semanticsOwner!.performAction(1, SemanticsAction.increase); + await tester.pumpAndSettle(); + expect(lastSelectedItem, 3); + + handle.dispose(); + }); + + testWidgets('CupertinoDataPicker does not provide invalid MediaQuery', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/47989. + Brightness brightness = Brightness.light; + late StateSetter setState; + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData( + dateTimePickerTextStyle: TextStyle( + color: CupertinoDynamicColor.withBrightness( + color: Color(0xFFFFFFFF), + darkColor: Color(0xFF000000), + ), + ), + ), + ), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return MediaQuery( + data: MediaQuery.of(context).copyWith(platformBrightness: brightness), + child: CupertinoDatePicker( + initialDateTime: DateTime(2019), + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime date) {}, + ), + ); + }, + ), + ), + ); + + expect( + tester.widget<Text>(find.text('2019')).style!.color, + isSameColorAs(const Color(0xFFFFFFFF)), + ); + + setState(() { + brightness = Brightness.dark; + }); + await tester.pump(); + + expect( + tester.widget<Text>(find.text('2019')).style!.color, + isSameColorAs(const Color(0xFF000000)), + ); + }); + + testWidgets('picker exports semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + debugResetSemanticsIdCounter(); + int? lastSelectedItem; + await tester.pumpWidget( + _buildPicker( + onSelectedItemChanged: (int index) { + lastSelectedItem = index; + }, + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoPicker)), + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + hasIncreaseAction: true, + increasedValue: '1', + value: '0', + textDirection: TextDirection.ltr, + ), + ], + ), + ); + + tester.binding.pipelineOwner.semanticsOwner!.performAction(1, SemanticsAction.increase); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.byType(CupertinoPicker)), + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + hasIncreaseAction: true, + hasDecreaseAction: true, + increasedValue: '2', + decreasedValue: '0', + value: '1', + textDirection: TextDirection.ltr, + ), + ], + ), + ); + expect(lastSelectedItem, 1); + handle.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/98567 + testWidgets('picker semantics action test', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + debugResetSemanticsIdCounter(); + final initialDate = DateTime(2018, 6, 8); + late DateTime? date; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime newDate) => date = newDate, + initialDateTime: initialDate, + maximumDate: initialDate.add(const Duration(days: 2)), + minimumDate: initialDate.subtract(const Duration(days: 2)), + ), + ), + ), + ), + ); + + tester.binding.pipelineOwner.semanticsOwner!.performAction(4, SemanticsAction.decrease); + await tester.pumpAndSettle(); + + expect(date, DateTime(2018, 6, 7)); + + handle.dispose(); + }); + + testWidgets('CupertinoDatePicker semantics excludes disabled dates', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + debugResetSemanticsIdCounter(); + final minimumDate = DateTime(2018, 6, 10); + final maximumDate = DateTime(2018, 6, 20); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + minimumDate: minimumDate, + maximumDate: maximumDate, + initialDateTime: minimumDate, // Start at minimum date + onDateTimeChanged: (DateTime newDateTime) {}, + mode: CupertinoDatePickerMode.date, + ), + ), + ), + ), + ); + + // Find the day picker column semantics node + // The day picker should have increase action (to go to day 11) but NO decrease action + // (because day 9 is disabled and wrapped with ExcludeSemantics) + final SemanticsNode rootNode = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + + // Find semantics node with value '10' (the current day) + SemanticsNode? findNodeWithValue(SemanticsNode node, String value) { + if (node.value == value) { + return node; + } + SemanticsNode? result; + node.visitChildren((SemanticsNode child) { + result ??= findNodeWithValue(child, value); + return result == null; + }); + return result; + } + + final SemanticsNode? dayPickerNode = findNodeWithValue(rootNode, '10'); + expect(dayPickerNode, isNotNull, reason: 'Should find day picker at day 10'); + + // At the minimum date (day 10), the day picker should NOT have a decrease action + // because day 9 is disabled (wrapped with ExcludeSemantics) + final SemanticsData data = dayPickerNode!.getSemanticsData(); + expect( + data.hasAction(SemanticsAction.decrease), + isFalse, + reason: 'Day picker at minimum date should not have decrease action (day 9 is disabled)', + ); + expect( + data.hasAction(SemanticsAction.increase), + isTrue, + reason: 'Day picker at minimum date should have increase action (day 11 is valid)', + ); + + handle.dispose(); + }); + + testWidgets('DatePicker adapts to CupertinoApp dark mode', (WidgetTester tester) async { + Widget buildDatePicker(Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime neData) {}, + initialDateTime: DateTime(2018, 10, 10), + ), + ); + } + + // CupertinoDatePicker with light theme. + await tester.pumpWidget(buildDatePicker(Brightness.light)); + RenderParagraph paragraph = tester.renderObject(find.text('October').first); + final Color expectedLight = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoDatePicker)), + ); + expect(paragraph.text.style!.color, expectedLight); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoDatePicker with dark theme. + await tester.pumpWidget(buildDatePicker(Brightness.dark)); + paragraph = tester.renderObject(find.text('October').first); + final Color expectedDark = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoDatePicker)), + ); + expect(paragraph.text.style!.color, expectedDark); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); + + testWidgets('TimerPicker adapts to CupertinoApp dark mode', (WidgetTester tester) async { + Widget buildTimerPicker(Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoTimerPicker( + mode: CupertinoTimerPickerMode.hm, + onTimerDurationChanged: (Duration newDuration) {}, + initialTimerDuration: const Duration(hours: 12, minutes: 30, seconds: 59), + ), + ); + } + + // CupertinoTimerPicker with light theme. + await tester.pumpWidget(buildTimerPicker(Brightness.light)); + RenderParagraph paragraph = tester.renderObject(find.text('hours')); + final Color expectedLight = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoTimerPicker)), + ); + expect(paragraph.text.style!.color, expectedLight); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoTimerPicker with light theme. + await tester.pumpWidget(buildTimerPicker(Brightness.dark)); + paragraph = tester.renderObject(find.text('hours')); + final Color expectedDark = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoTimerPicker)), + ); + expect(paragraph.text.style!.color, expectedDark); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); + + testWidgets('TimerPicker minDate - maxDate with minuteInterval', (WidgetTester tester) async { + late DateTime date; + final minimum = DateTime(2022, 6, 14, 3, 31); + final initial = DateTime(2022, 6, 14, 3, 40); + final maximum = DateTime(2022, 6, 14, 3, 49); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + initialDateTime: initial, + minimumDate: minimum, + maximumDate: maximum, + minuteInterval: 5, + use24hFormat: true, + onDateTimeChanged: (DateTime newDate) { + date = newDate; + }, + ), + ), + ), + ), + ); + + // Drag picker minutes to min date + await tester.drag( + find.text('40'), + const Offset(0.0, 32.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); + await tester.pumpAndSettle(); + + // Returns to min date. + expect(date, DateTime(2022, 6, 14, 3, 35)); + + // Drag picker minutes to max date + await tester.drag( + find.text('50'), + const Offset(0.0, -64.0), + touchSlopY: 0.0, + warnIfMissed: false, + ); + await tester.pumpAndSettle(); + + // Returns to max date. + expect(date, DateTime(2022, 6, 14, 3, 45)); + }); + + testWidgets('date picker has expected day of week', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2018, 9, 15), + showDayOfWeek: true, + ), + ), + ), + ), + ); + + expect(find.text('September'), findsOneWidget); + expect(find.textContaining('Sat').last, findsOneWidget); + expect(find.textContaining('15').last, findsOneWidget); + expect(find.text('2018'), findsOneWidget); + }); + + testWidgets('CupertinoDatePicker selectionOverlayBuilder with monthYear mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoDatePickerMode.monthYear + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(2)); + }); + + testWidgets('CupertinoDatePicker selectionOverlayBuilder with date mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoDatePickerMode.date + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(3)); + }); + + testWidgets('CupertinoDatePicker selectionOverlayBuilder with time mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoDatePickerMode.time + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(3)); + }); + + testWidgets('CupertinoDatePicker selectionOverlayBuilder with dateAndTime mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoDatePickerMode.dateAndTime + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(4)); + }); + + testWidgets('CupertinoTimerPicker selectionOverlayBuilder with hms mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoTimerPickerMode.hms + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTimerPicker( + onTimerDurationChanged: (Duration duration) {}, + initialTimerDuration: const Duration(hours: 1, minutes: 1, seconds: 1), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(3)); + }); + + testWidgets('CupertinoTimerPicker selectionOverlayBuilder with ms mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoTimerPickerMode.ms + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTimerPicker( + onTimerDurationChanged: (Duration duration) {}, + mode: CupertinoTimerPickerMode.ms, + initialTimerDuration: const Duration(hours: 1, minutes: 1, seconds: 1), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(2)); + }); + + testWidgets('CupertinoTimerPicker selectionOverlayBuilder with hm mode', ( + WidgetTester tester, + ) async { + final Widget selectionOverlay = Container(color: const Color(0x12345678)); + + // For mode = CupertinoTimerPickerMode.hm + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTimerPicker( + onTimerDurationChanged: (Duration duration) {}, + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: const Duration(hours: 1, minutes: 1, seconds: 1), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return selectionOverlay; + }, + ), + ), + ), + ); + + // Find the selection overlay. + expect(find.byWidget(selectionOverlay), findsExactly(2)); + }); + + testWidgets('CupertinoDatePicker selectionOverlayBuilder returns null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return null; + }, + ), + ), + ), + ); + + expect(find.byType(CupertinoPicker), isNot(paints..rrect())); + }); + + testWidgets('CupertinoTimerPicker selectionOverlayBuilder returns null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTimerPicker( + onTimerDurationChanged: (Duration duration) {}, + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: const Duration(hours: 1, minutes: 1, seconds: 1), + selectionOverlayBuilder: + (BuildContext context, {required int selectedIndex, required int columnCount}) { + return null; + }, + ), + ), + ), + ); + + expect(find.byType(CupertinoPicker), isNot(paints..rrect())); + }); + + testWidgets('CupertinoTimerPicker selectionOverlayBuilder is explicitly passed null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTimerPicker( + onTimerDurationChanged: (Duration duration) {}, + mode: CupertinoTimerPickerMode.hm, + initialTimerDuration: const Duration(hours: 1, minutes: 1, seconds: 1), + ), + ), + ), + ); + + expect(find.byType(CupertinoPickerDefaultSelectionOverlay), findsExactly(2)); + }); + + testWidgets('CupertinoDatePicker selectionOverlayBuilder is explicitly passed null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + ), + ), + ), + ); + + expect(find.byType(CupertinoPickerDefaultSelectionOverlay), findsExactly(4)); + }); + + testWidgets('CupertinoDatePicker accommodates widest text using table codepoints', ( + WidgetTester tester, + ) async { + // |---------| + // | 0x2002 | // EN SPACE - 1/2 Advance + // | 0x2005 | // FOUR-PER-EM SPACE - 1/4 Advance + // |---------| + final testWords = <String>[ + '\u2002' * 10, // Output: 10 * 1/2 = 5 + '\u2005' * 20, // Output: 20 * 1/4 = 5 + ]; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + onDateTimeChanged: (DateTime date) {}, + initialDateTime: DateTime(2018, 9, 15), + ), + ), + ), + ); + + final BuildContext context = tester.element(find.byType(CupertinoDatePicker)); + + const textStyle = TextStyle( + fontSize: 21, + letterSpacing: 0.4, + fontWeight: FontWeight.normal, + color: CupertinoColors.label, + ); + + final List<double> widths = testWords + .map((String word) => getColumnWidth(word, textStyle, context)) + .toList(); + + final double largestWidth = widths.reduce(math.max); + + final double testWidth = CupertinoDatePicker.getColumnWidth( + texts: testWords, + context: context, + textStyle: textStyle, + ); + + expect(testWidth, equals(largestWidth)); + expect(widths.indexOf(largestWidth), equals(1)); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/39998 + + test('showTimeSeparator is only supported in time or dateAndTime mode', () async { + expect( + () => CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime _) {}, + showTimeSeparator: true, + ), + returnsNormally, + ); + + expect( + () => CupertinoDatePicker(onDateTimeChanged: (DateTime _) {}, showTimeSeparator: true), + returnsNormally, + ); + + expect( + () => CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime _) {}, + showTimeSeparator: true, + ), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message ?? 'Unknown error', + 'message', + contains('showTimeSeparator is only supported in time or dateAndTime modes'), + ), + ), + ); + + expect( + () => CupertinoDatePicker( + mode: CupertinoDatePickerMode.monthYear, + onDateTimeChanged: (DateTime _) {}, + showTimeSeparator: true, + ), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message ?? 'Unknown error', + 'message', + contains('showTimeSeparator is only supported in time or dateAndTime modes'), + ), + ), + ); + }); + + testWidgets('Time separator widget should be rendered when flag is set to true', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime dateTime) {}, + showTimeSeparator: true, + ), + ), + ), + ); + + expect(find.text(':'), findsOneWidget); + }); + + testWidgets('Time separator widget should not be rendered when flag is set to false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + onDateTimeChanged: (DateTime _) {}, + ), + ), + ), + ); + + expect(find.text(':'), findsNothing); + }); + + test('CupertinoDatePicker selectableDayPredicate parameter validation', () async { + expect(() => CupertinoDatePicker(onDateTimeChanged: (DateTime _) {}), returnsNormally); + + expect( + () => CupertinoDatePicker( + initialDateTime: DateTime(2025), + onDateTimeChanged: (DateTime _) {}, + selectableDayPredicate: (DateTime date) { + return date.year == 2025; + }, + ), + returnsNormally, + ); + + expect( + () => CupertinoDatePicker( + onDateTimeChanged: (DateTime _) {}, + selectableDayPredicate: (DateTime date) { + return date.year == 2025; + }, + ), + returnsNormally, + ); + + expect( + () => CupertinoDatePicker( + initialDateTime: DateTime(2025, 7, 4), + onDateTimeChanged: (DateTime _) {}, + selectableDayPredicate: (DateTime date) { + return date.month == 6; + }, + ), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message ?? 'Unknown error', + 'message', + contains('must satisfy provided selectableDayPredicate.'), + ), + ), + ); + }); + + testWidgets('DatePicker with workdays predicate test case', (WidgetTester tester) async { + // Set initial date time to a work day. + final initialDateTime = DateTime(2025, 6, 13); + var selectedDate = initialDateTime; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + initialDateTime: initialDateTime, + selectableDayPredicate: (DateTime date) { + return date.weekday >= DateTime.monday && date.weekday <= DateTime.friday; + }, + onDateTimeChanged: (DateTime dateTime) { + selectedDate = dateTime; + }, + ), + ), + ), + ); + + // Scrolling to Saturday should trigger automatic scroll to the next workday (Monday). + await tester.drag(find.text('Sat Jun 14'), const Offset(0.0, -100.0)); + expect(selectedDate, DateTime(2025, 6, 16)); + }); + + testWidgets('DatePicker with weekend predicate test case', (WidgetTester tester) async { + // Set initial date time to a weekend day. + final initialDateTime = DateTime(2025, 6, 14); + var selectedDate = initialDateTime; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + initialDateTime: initialDateTime, + selectableDayPredicate: (DateTime date) { + return date.weekday == DateTime.saturday || date.weekday == DateTime.sunday; + }, + onDateTimeChanged: (DateTime dateTime) { + selectedDate = dateTime; + }, + ), + ), + ), + ); + + // Pressing on the friday day item should trigger automatic scroll back to + // saturday. + await tester.press(find.text('Fri Jun 13')); + await tester.pump(); + + expect(selectedDate, DateTime(2025, 6, 14)); + }); + + testWidgets('DatePicker with custom predicate test case', (WidgetTester tester) async { + // Set initial date time to a work day. + final initialDateTime = DateTime(2025, 6, 16); + var selectedDate = initialDateTime; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDatePicker( + initialDateTime: initialDateTime, + selectableDayPredicate: (DateTime date) { + return date.day >= 16; + }, + onDateTimeChanged: (DateTime dateTime) { + selectedDate = dateTime; + }, + ), + ), + ), + ); + + await tester.drag(find.text('Sun Jun 15'), const Offset(0.0, 64.0)); + await tester.pump(); + + expect(selectedDate, initialDateTime); + }); + + // Regression test for https://github.com/flutter/flutter/issues/161773 + testWidgets('CupertinoDatePicker date value baseline alignment', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 400.0, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (_) {}, + initialDateTime: DateTime(2025, 2, 14), + ), + ), + ), + ), + ); + + Offset lastOffset = tester.getTopLeft(find.text('November')); + expect(tester.getTopLeft(find.text('11')).dy, lastOffset.dy); + + lastOffset = tester.getTopLeft(find.text('11')); + expect(tester.getTopLeft(find.text('2022')).dy, lastOffset.dy); + }); + + testWidgets('CupertinoTimerPicker does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoTimerPicker(onTimerDurationChanged: (_) {})), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTimerPicker)), Size.zero); + }); +} + +Widget _buildPicker({ + FixedExtentScrollController? controller, + required ValueChanged<int> onSelectedItemChanged, +}) { + return Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 100.0, + onSelectedItemChanged: onSelectedItemChanged, + children: List<Widget>.generate(100, (int index) { + return Center(child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString()))); + }), + ), + ); +} + +double getColumnWidth(String text, TextStyle textStyle, BuildContext context) { + return TextPainter.computeMaxIntrinsicWidth( + text: TextSpan(text: text, style: textStyle), + textDirection: Directionality.of(context), + ); +} diff --git a/packages/cupertino_ui/test/cupertino/debug_test.dart b/packages/cupertino_ui/test/cupertino/debug_test.dart new file mode 100644 index 000000000000..38d6ca3b457e --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/debug_test.dart @@ -0,0 +1,33 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('debugCheckHasCupertinoLocalizations throws', (WidgetTester tester) async { + final GlobalKey noLocalizationsAvailable = GlobalKey(); + final GlobalKey localizationsAvailable = GlobalKey(); + + await tester.pumpWidget( + Container( + key: noLocalizationsAvailable, + child: CupertinoApp(home: Container(key: localizationsAvailable)), + ), + ); + + expect( + () => debugCheckHasCupertinoLocalizations(noLocalizationsAvailable.currentContext!), + throwsA( + isAssertionError.having( + (AssertionError e) => e.message, + 'message', + contains('No CupertinoLocalizations found'), + ), + ), + ); + + expect(debugCheckHasCupertinoLocalizations(localizationsAvailable.currentContext!), isTrue); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/desktop_text_selection_toolbar_button_test.dart b/packages/cupertino_ui/test/cupertino/desktop_text_selection_toolbar_button_test.dart new file mode 100644 index 000000000000..d1a190b328ea --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/desktop_text_selection_toolbar_button_test.dart @@ -0,0 +1,145 @@ +// 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/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('can press', (WidgetTester tester) async { + var pressed = false; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbarButton( + onPressed: () { + pressed = true; + }, + child: const Text('Tap me'), + ), + ), + ), + ); + + expect(pressed, false); + + await tester.tap(find.byType(CupertinoDesktopTextSelectionToolbarButton)); + expect(pressed, true); + }); + + testWidgets('keeps contrast with background on hover', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbarButton.text(text: 'Tap me', onPressed: () {}), + ), + ), + ); + + final BuildContext context = tester.element( + find.byType(CupertinoDesktopTextSelectionToolbarButton), + ); + + // The Text color is a CupertinoDynamicColor so we have to compare the color + // values instead of just comparing the colors themselves. + expect( + (tester.firstWidget(find.text('Tap me')) as Text).style!.color!.value, + CupertinoColors.black.value, + ); + + // Hover gesture + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton))); + await tester.pumpAndSettle(); + + // The color here should be a standard Color, there's no need to use value. + expect( + (tester.firstWidget(find.text('Tap me')) as Text).style!.color, + CupertinoTheme.of(context).primaryContrastingColor, + ); + }); + + testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbarButton( + onPressed: () {}, + child: const Text('Tap me'), + ), + ), + ), + ); + + // Original at full opacity. + FadeTransition opacity = tester.widget( + find.descendant( + of: find.byType(CupertinoDesktopTextSelectionToolbarButton), + matching: find.byType(FadeTransition), + ), + ); + expect(opacity.opacity.value, 1.0); + + // Make a "down" gesture on the button. + final Offset center = tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Opacity reduces during the down gesture. + opacity = tester.widget( + find.descendant( + of: find.byType(CupertinoDesktopTextSelectionToolbarButton), + matching: find.byType(FadeTransition), + ), + ); + expect(opacity.opacity.value, 0.7); + + // Release the down gesture. + await gesture.up(); + await tester.pumpAndSettle(); + + // Opacity is back to normal. + opacity = tester.widget( + find.descendant( + of: find.byType(CupertinoDesktopTextSelectionToolbarButton), + matching: find.byType(FadeTransition), + ), + ); + expect(opacity.opacity.value, 1.0); + }); + + testWidgets('passing null to onPressed disables the button', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbarButton(onPressed: null, child: Text('Tap me')), + ), + ), + ); + + expect(find.byType(CupertinoButton), findsOneWidget); + final CupertinoButton button = tester.widget(find.byType(CupertinoButton)); + expect(button.enabled, isFalse); + }); + + testWidgets('CupertinoDesktopTextSelectionToolbarButton does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoDesktopTextSelectionToolbarButton(onPressed: null, child: Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoDesktopTextSelectionToolbarButton)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/desktop_text_selection_toolbar_test.dart b/packages/cupertino_ui/test/cupertino/desktop_text_selection_toolbar_test.dart new file mode 100644 index 000000000000..d349f4d58b9f --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/desktop_text_selection_toolbar_test.dart @@ -0,0 +1,148 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('has correct backdrop filters', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbar( + anchor: Offset.zero, + children: <Widget>[ + CupertinoDesktopTextSelectionToolbarButton( + child: const Text('Tap me'), + onPressed: () {}, + ), + ], + ), + ), + ), + ); + + final BackdropFilter toolbarFilter = tester.firstWidget<BackdropFilter>( + find.descendant( + of: find.byType(CupertinoDesktopTextSelectionToolbar), + matching: find.byType(BackdropFilter), + ), + ); + + expect( + toolbarFilter.filter.runtimeType, + // _ComposeImageFilter is internal so we can't test if its filters are + // for blur and saturation, but checking if it's a _ComposeImageFilter + // should be enough. Outer and inner parameters don't matter, we just need + // a new _ComposeImageFilter to get its runtimeType. + ImageFilter.compose(outer: ImageFilter.blur(), inner: ImageFilter.blur()).runtimeType, + ); + }); + + testWidgets('has shadow', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbar( + anchor: Offset.zero, + children: <Widget>[ + CupertinoDesktopTextSelectionToolbarButton( + child: const Text('Tap me'), + onPressed: () {}, + ), + ], + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.firstWidget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoDesktopTextSelectionToolbar), + matching: find.byType(DecoratedBox), + ), + ); + + expect((decoratedBox.decoration as ShapeDecoration).shadows, isNotNull); + }); + + testWidgets('is translucent', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbar( + anchor: Offset.zero, + children: <Widget>[ + CupertinoDesktopTextSelectionToolbarButton( + child: const Text('Tap me'), + onPressed: () {}, + ), + ], + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester + .widgetList<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoDesktopTextSelectionToolbar), + matching: find.byType(DecoratedBox), + ), + ) + // The second DecoratedBox should be the one with color. + .elementAt(1); + + expect((decoratedBox.decoration as ShapeDecoration).color!.opacity, lessThan(1.0)); + }); + + testWidgets('positions itself at the anchor', (WidgetTester tester) async { + // An arbitrary point on the screen to position at. + const anchor = Offset(30.0, 40.0); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoDesktopTextSelectionToolbar( + anchor: anchor, + children: <Widget>[ + CupertinoDesktopTextSelectionToolbarButton( + child: const Text('Tap me'), + onPressed: () {}, + ), + ], + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)), + // Greater than due to padding internal to the toolbar. + greaterThan(anchor), + ); + }); + + testWidgets('CupertinoDesktopTextSelectionToolbar does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoDesktopTextSelectionToolbar( + anchor: const Offset(10, 10), + children: const <Widget>[Text('X')], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoDesktopTextSelectionToolbar)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/dialog_test.dart b/packages/cupertino_ui/test/cupertino/dialog_test.dart new file mode 100644 index 000000000000..669e85ce2de6 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/dialog_test.dart @@ -0,0 +1,2373 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Overall appearance is correct for the light theme', (WidgetTester tester) async { + await tester.pumpWidget( + TestScaffoldApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + dialog: CupertinoAlertDialog( + content: const Text('The content'), + actions: <Widget>[ + CupertinoDialogAction(child: const Text('One'), onPressed: () {}), + CupertinoDialogAction(child: const Text('Two'), onPressed: () {}), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + // This golden file also verifies the structure of an alert dialog that + // has a content, no title, and no overscroll for any sections (in contrast + // to cupertinoAlertDialog.dark-theme.png). + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoAlertDialog.overall-light-theme.png'), + ); + + await gesture.up(); + }); + + testWidgets('Overall appearance is correct for the dark theme', (WidgetTester tester) async { + await tester.pumpWidget( + TestScaffoldApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + dialog: CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: List<Widget>.generate( + 20, + (int i) => CupertinoDialogAction(onPressed: () {}, child: Text('Button $i')), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + // This golden file also verifies the structure of an action sheet that + // has both a message and a title, and an overscrolled action section (in + // contrast to cupertinoAlertDialog.light-theme.png). + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoAlertDialog.overall-dark-theme.png'), + ); + + await gesture.up(); + }); + + testWidgets('Taps on button calls onPressed', (WidgetTester tester) async { + var didDelete = false; + + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: <Widget>[ + const CupertinoDialogAction(child: Text('Cancel')), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + didDelete = true; + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(didDelete, isFalse); + + await tester.tap(find.text('Delete')); + await tester.pump(); + + expect(didDelete, isTrue); + expect(find.text('Delete'), findsNothing); + }); + + testWidgets('Can tap after scrolling', (WidgetTester tester) async { + int? wasPressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: List<Widget>.generate( + 20, + (int i) => CupertinoDialogAction( + onPressed: () { + expect(wasPressed, null); + wasPressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Button 19').hitTestable(), findsNothing); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await tester.pumpAndSettle(); + // The dragging gesture must be dispatched in at least two segments. + // The first movement starts the gesture without setting a delta. + await gesture.moveBy(const Offset(0, -20)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, -1000)); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Button 19').hitTestable(), findsOne); + + await tester.tap(find.text('Button 19')); + await tester.pumpAndSettle(); + expect(wasPressed, 19); + }); + + testWidgets('Taps at the padding of buttons calls onPressed', (WidgetTester tester) async { + // Ensures that the entire button responds to hit tests, not just the text + // part. + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: <Widget>[ + CupertinoDialogAction( + child: const Text('One'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + await tester.tapAt(tester.getTopLeft(find.text('One')) - const Offset(20, 0)); + + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on a button can be slided to other buttons', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: <Widget>[ + CupertinoDialogAction( + child: const Text('One'), + onPressed: () { + expect(pressed, null); + pressed = 1; + Navigator.pop(context); + }, + ), + CupertinoDialogAction( + child: const Text('Two'), + onPressed: () { + expect(pressed, null); + pressed = 2; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(pressed, null); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Two'))); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.press-drag.png'), + ); + + await gesture.up(); + expect(pressed, 1); + await tester.pumpAndSettle(); + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on the content can be slided to other buttons', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: <Widget>[ + CupertinoDialogAction( + child: const Text('One'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, false); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('The title'))); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + await gesture.up(); + expect(wasPressed, true); + await tester.pumpAndSettle(); + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on the barrier can not be slided to buttons', (WidgetTester tester) async { + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: <Widget>[ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, false); + + // Press on the barrier. + final TestGesture gesture = await tester.startGesture(const Offset(100, 100)); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('Cancel'))); + await tester.pumpAndSettle(); + await gesture.up(); + expect(wasPressed, false); + await tester.pumpAndSettle(); + expect(find.text('Cancel'), findsOne); + }); + + testWidgets('Sliding taps can still yield to scrolling after horizontal movement', ( + WidgetTester tester, + ) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + content: Text('Long message' * 200), + actions: List<Widget>.generate( + 10, + (int i) => CupertinoDialogAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Starts on a button. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + // Move horizontally. + await gesture.moveBy(const Offset(-10, 2)); + await gesture.moveBy(const Offset(-100, 2)); + await tester.pumpAndSettle(); + // Scroll up. + await gesture.moveBy(const Offset(0, -40)); + await gesture.moveBy(const Offset(0, -1000)); + await tester.pumpAndSettle(); + // Stop scrolling. + await gesture.up(); + await tester.pumpAndSettle(); + // The actions section should have been scrolled up and Button 9 is visible. + await tester.tap(find.text('Button 9')); + expect(pressed, 9); + }); + + testWidgets('Sliding taps is responsive even before the drag starts', ( + WidgetTester tester, + ) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + content: Text('Long message' * 200), + actions: List<Widget>.generate( + 10, + (int i) => CupertinoDialogAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Find the location right within the upper edge of button 1. + final Offset start = + tester.getTopLeft(find.widgetWithText(CupertinoDialogAction, 'Button 1')) + + const Offset(30, 5); + // Verify that the start location is within button 1. + await tester.tapAt(start); + expect(pressed, 1); + pressed = null; + + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + // Move slightly upwards without starting the drag + await gesture.moveBy(const Offset(0, -10)); + await tester.pumpAndSettle(); + // Stop scrolling. + await gesture.up(); + await tester.pumpAndSettle(); + expect(pressed, 0); + }); + + testWidgets('Sliding taps only recognizes the primary pointer', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: List<Widget>.generate( + 8, + (int i) => CupertinoDialogAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Start gesture 1 at button 0 + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await gesture1.moveBy(const Offset(0, 20)); // Starts the gesture + await tester.pumpAndSettle(); + + // Start gesture 2 at button 1. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await gesture2.moveBy(const Offset(0, 20)); // Starts the gesture + await tester.pumpAndSettle(); + + // Move gesture 1 to button 2 and release. + await gesture1.moveTo(tester.getCenter(find.text('Button 2'))); + await tester.pumpAndSettle(); + await gesture1.up(); + await tester.pumpAndSettle(); + + expect(pressed, 2); + pressed = null; + + // Tap at button 3, which becomes the new primary pointer and is recognized. + await tester.tap(find.text('Button 3')); + await tester.pumpAndSettle(); + expect(pressed, 3); + pressed = null; + + // Move gesture 2 to button 4 and release. + await gesture2.moveTo(tester.getCenter(find.text('Button 4'))); + await tester.pumpAndSettle(); + await gesture2.up(); + await tester.pumpAndSettle(); + + // Non-primary pointers should not be recognized. + expect(pressed, null); + }); + + testWidgets('Non-primary pointers can trigger scroll', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: List<Widget>.generate( + 12, + (int i) => CupertinoDialogAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Start gesture 1 at button 0 + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Button 11')).dy, greaterThan(400)); + + // Start gesture 2 at button 1 and scrolls. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await gesture2.moveBy(const Offset(0, -20)); + await gesture2.moveBy(const Offset(0, -500)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Button 11')).dy, lessThan(400)); + + // Release gesture 1, which should not trigger any buttons. + await gesture1.up(); + await tester.pumpAndSettle(); + + expect(pressed, null); + }); + + testWidgets('Taps on legacy button calls onPressed and renders correctly', ( + WidgetTester tester, + ) async { + // Legacy buttons are implemented with [GestureDetector.onTap]. Apps that + // use customized legacy buttons should continue to work. + var wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: <Widget>[ + LegacyAction( + child: const Text('Legacy'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + CupertinoDialogAction(child: const Text('One'), onPressed: () {}), + CupertinoDialogAction(child: const Text('Two'), onPressed: () {}), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, isFalse); + + // Push the legacy button and hold for a while to activate the pressing effect. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Legacy'))); + await tester.pump(const Duration(seconds: 1)); + expect(wasPressed, isFalse); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.legacyButton.png'), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(wasPressed, isTrue); + expect(find.text('Legacy'), findsNothing); + }); + + testWidgets('Dialog not barrier dismissible by default', (WidgetTester tester) async { + await tester.pumpWidget(createAppWithCenteredButton(const Text('Go'))); + + final BuildContext context = tester.element(find.text('Go')); + + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog'), + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog'), findsOneWidget); + + // Tap on the barrier, which shouldn't do anything this time. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog'), findsOneWidget); + }); + + testWidgets('Dialog configurable to be barrier dismissible', (WidgetTester tester) async { + await tester.pumpWidget(createAppWithCenteredButton(const Text('Go'))); + + final BuildContext context = tester.element(find.text('Go')); + + showCupertinoDialog<void>( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog'), + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog'), findsOneWidget); + + // Tap off the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog'), findsNothing); + }); + + testWidgets('Dialog destructive action style', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate(const CupertinoDialogAction(isDestructiveAction: true, child: Text('Ok'))), + ); + + final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); + + expect(widget.style.color!.withAlpha(255), CupertinoColors.systemRed.color); + }); + + testWidgets('Dialog default action style', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoTheme( + data: const CupertinoThemeData(primaryColor: CupertinoColors.systemGreen), + child: boilerplate(const CupertinoDialogAction(child: Text('Ok'))), + ), + ); + + final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); + + expect(widget.style.color!.withAlpha(255), CupertinoColors.systemGreen.color); + expect(widget.style.fontFamily, 'CupertinoSystemText'); + }); + + testWidgets('Dialog dark theme', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('Content'), + actions: <Widget>[ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () {}, + child: const Text('Cancel'), + ), + const CupertinoDialogAction(child: Text('OK')), + ], + ), + ), + ), + ); + + final RichText cancelText = tester.widget<RichText>( + find.descendant(of: find.text('Cancel'), matching: find.byType(RichText)), + ); + + expect( + cancelText.text.style!.color!.value, + 0xFF0A84FF, // dark elevated color of systemBlue. + ); + + expect(find.byType(CupertinoAlertDialog), paints..rect(color: const Color(0xCC2D2D2D))); + }); + + testWidgets('Has semantic annotations', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoAlertDialog( + title: Text('The Title'), + content: Text('Content'), + actions: <Widget>[ + CupertinoDialogAction(child: Text('Cancel')), + CupertinoDialogAction(child: Text('OK')), + ], + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + role: SemanticsRole.alertDialog, + label: 'Alert', + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics(label: 'The Title'), + TestSemantics(label: 'Content'), + ], + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isButton], + label: 'Cancel', + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isButton], + label: 'OK', + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Dialog default action style', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate(const CupertinoDialogAction(isDefaultAction: true, child: Text('Ok'))), + ); + + final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); + + expect(widget.style.fontWeight, equals(FontWeight.w600)); + }); + + testWidgets('Dialog default and destructive action styles', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + const CupertinoDialogAction( + isDefaultAction: true, + isDestructiveAction: true, + child: Text('Ok'), + ), + ), + ); + + final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); + + expect(widget.style.color!.withAlpha(255), CupertinoColors.systemRed.color); + expect(widget.style.fontWeight, equals(FontWeight.w600)); + }); + + testWidgets('Dialog disabled action style', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(const CupertinoDialogAction(child: Text('Ok')))); + + final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); + + expect(widget.style.color!.opacity, greaterThanOrEqualTo(127 / 255)); + expect(widget.style.color!.opacity, lessThanOrEqualTo(128 / 255)); + }); + + testWidgets('Dialog enabled action style', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate(CupertinoDialogAction(child: const Text('Ok'), onPressed: () {})), + ); + + final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle)); + + expect(widget.style.color!.opacity, equals(1.0)); + }); + + testWidgets('Pressing on disabled buttons does not trigger highlight', ( + WidgetTester tester, + ) async { + var pressedEnable = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: <Widget>[ + const CupertinoDialogAction(child: Text('Disabled')), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + pressedEnable = true; + Navigator.pop(context); + }, + child: const Text('Enabled'), + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Disabled'))); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // This should look exactly like an idle dialog. + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.press_disabled.png'), + ); + + // Verify that gestures that started on a disabled button can slide onto an + // enabled button. + await gesture.moveTo(tester.getCenter(find.text('Enabled'))); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.press_disabled_slide_to_enabled.png'), + ); + + expect(pressedEnable, false); + await gesture.up(); + expect(pressedEnable, true); + }); + + testWidgets('Action buttons shows pressed highlight as soon as the pointer is down', ( + WidgetTester tester, + ) async { + // Verifies that the the pressed color is not delayed for some milliseconds, + // a symptom if the color relies on a tap gesture timing out. + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: <Widget>[ + CupertinoDialogAction(child: const Text('One'), onPressed: () {}), + CupertinoDialogAction(child: const Text('Two'), onPressed: () {}), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture pointer = await tester.startGesture(tester.getCenter(find.text('Two'))); + // Just `pump`, not `pumpAndSettle`, as we want to verify the very next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.pressed.png'), + ); + await pointer.up(); + }); + + testWidgets('Message is scrollable, has correct padding with large text sizes', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: CupertinoAlertDialog( + title: const Text('The Title'), + content: Text('Very long content ' * 20), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('Cancel')), + CupertinoDialogAction(isDestructiveAction: true, child: Text('OK')), + ], + scrollController: scrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + scrollController.jumpTo(100.0); + expect(scrollController.offset, 100.0); + // Set the scroll position back to zero. + scrollController.jumpTo(0.0); + + await tester.pumpAndSettle(); + + // Expect the modal dialog box to take all available height. + expect( + tester.getSize(find.byType(ClipRSuperellipse)), + equals(const Size(310.0, 560.0 - 24.0 * 2)), + ); + + // Check sizes/locations of the text. The text is large so these 2 buttons are stacked. + // Visually the "Cancel" button and "OK" button are the same height when using the + // regular font. However, when using the test font, "Cancel" becomes 2 lines which + // is why the height we're verifying for "Cancel" is larger than "OK". + + expect(tester.getSize(find.text('The Title')), equals(const Size(270.0, 132.0))); + expect(tester.getTopLeft(find.text('The Title')), equals(const Offset(265.0, 80.0 + 24.0))); + expect( + tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Cancel')), + equals(const Size(310.0, 148.0)), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoDialogAction, 'OK')), + equals(const Size(310.0, 98.0)), + ); + }); + + testWidgets('Dialog respects small constraints.', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return Center( + child: ConstrainedBox( + // Constrain the dialog to a tiny size and ensure it respects + // these exact constraints. + constraints: BoxConstraints.tight(const Size(200.0, 100.0)), + child: CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('Option 1')), + CupertinoDialogAction(child: Text('Option 2')), + CupertinoDialogAction(child: Text('Option 3')), + ], + scrollController: scrollController, + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + const topAndBottomMargin = 40.0; + const double topAndBottomPadding = 24.0 * 2; + const double leftAndRightPadding = 40.0 * 2; + final Finder modalFinder = find.byType(ClipRSuperellipse); + expect( + tester.getSize(modalFinder), + equals( + const Size(200.0 - leftAndRightPadding, 100.0 - topAndBottomMargin - topAndBottomPadding), + ), + ); + }); + + testWidgets('Button list is scrollable, has correct position with large text sizes.', ( + WidgetTester tester, + ) async { + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content.'), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('One')), + CupertinoDialogAction(child: Text('Two')), + CupertinoDialogAction(child: Text('Three')), + CupertinoDialogAction(child: Text('Chocolate Brownies')), + CupertinoDialogAction(isDestructiveAction: true, child: Text('Cancel')), + ], + actionScrollController: actionScrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + + // Check that the action buttons list is scrollable. + expect(actionScrollController.offset, 0.0); + actionScrollController.jumpTo(100.0); + expect(actionScrollController.offset, 100.0); + actionScrollController.jumpTo(0.0); + + // Check that the action buttons are aligned vertically. + expect(tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'One')).dx, equals(400.0)); + expect(tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'Two')).dx, equals(400.0)); + expect(tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'Three')).dx, equals(400.0)); + expect( + tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'Chocolate Brownies')).dx, + equals(400.0), + ); + expect( + tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'Cancel')).dx, + equals(400.0), + ); + + // Check that the action buttons are the correct heights. + expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'One')).height, equals(98.0)); + expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Two')).height, equals(98.0)); + expect( + tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Three')).height, + equals(98.0), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Chocolate Brownies')).height, + equals(248.0), + ); + expect( + tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Cancel')).height, + equals(148.0), + ); + }); + + testWidgets('Title Section is empty, Button section is not empty.', (WidgetTester tester) async { + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withNoTextScaling( + child: CupertinoAlertDialog( + actions: const <Widget>[ + CupertinoDialogAction(child: Text('One')), + CupertinoDialogAction(child: Text('Two')), + ], + actionScrollController: actionScrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + + // Check that the dialog size is the same as the actions section size. This + // ensures that an empty content section doesn't accidentally render some + // empty space in the dialog. + final Finder contentSectionFinder = find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + }); + + final Finder modalBoundaryFinder = find.byType(ClipRSuperellipse); + + expect(tester.getSize(contentSectionFinder), tester.getSize(modalBoundaryFinder)); + + // Check that the title/message section is not displayed + expect(actionScrollController.offset, 0.0); + expect(tester.getTopLeft(find.widgetWithText(CupertinoDialogAction, 'One')).dy, equals(270.75)); + + // Check that the button's vertical size is the same. + expect( + tester.getSize(find.widgetWithText(CupertinoDialogAction, 'One')).height, + equals(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Two')).height), + ); + }); + + testWidgets('Button section is empty, Title section is not empty.', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withNoTextScaling( + child: CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content.'), + scrollController: scrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + + // Check that there's no button action section. + expect(scrollController.offset, 0.0); + expect(find.widgetWithText(CupertinoDialogAction, 'One'), findsNothing); + + // Check that the dialog size is the same as the content section size. This + // ensures that an empty button section doesn't accidentally render some + // empty space in the dialog. + final Finder contentSectionFinder = find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertContentSection'; + }); + + final Finder modalBoundaryFinder = find.byType(ClipRSuperellipse); + + expect(tester.getSize(contentSectionFinder), tester.getSize(modalBoundaryFinder)); + }); + + testWidgets('Actions section height for 1 button is height of button.', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const <Widget>[CupertinoDialogAction(child: Text('OK'))], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(okButtonBox.size.width, actionsSectionBox.size.width); + expect(okButtonBox.size.height, actionsSectionBox.size.height); + }); + + testWidgets('Actions section height for 2 side-by-side buttons is height of tallest button.', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + late double dividerWidth; // Will be set when the dialog builder runs. Needs a BuildContext. + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + dividerWidth = 0.3; + return CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('OK')), + CupertinoDialogAction(isDestructiveAction: true, child: Text('Cancel')), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK'); + final RenderBox cancelButtonBox = findActionButtonRenderBoxByTitle(tester, 'Cancel'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(okButtonBox.size.width, cancelButtonBox.size.width); + + expect( + actionsSectionBox.size.width, + okButtonBox.size.width + cancelButtonBox.size.width + dividerWidth, + ); + + expect( + actionsSectionBox.size.height, + max(okButtonBox.size.height, cancelButtonBox.size.height), + ); + }); + + testWidgets( + 'Actions section height for 2 stacked buttons with enough room is height of both buttons.', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const dividerThickness = 0.3; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('The message'), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('OK')), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('This is too long to fit'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK'); + final RenderBox longButtonBox = findActionButtonRenderBoxByTitle( + tester, + 'This is too long to fit', + ); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(okButtonBox.size.width, longButtonBox.size.width); + + expect(okButtonBox.size.width, actionsSectionBox.size.width); + + expect( + okButtonBox.size.height + dividerThickness + longButtonBox.size.height, + actionsSectionBox.size.height, + ); + }, + ); + + testWidgets( + 'Actions section height for 2 stacked buttons without enough room and regular font is 1.5 buttons tall.', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The Title'), + content: Text('The message\n' * 40), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('OK')), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('This is too long to fit'), + ), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(actionsSectionBox.size.height, 67.8); + }, + ); + + testWidgets( + 'Actions section height for 2 stacked buttons without enough room and large accessibility font is 50% of dialog height.', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: CupertinoAlertDialog( + title: const Text('The Title'), + content: Text('The message\n' * 20), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('This button is multi line')), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text('This button is multi line'), + ), + ], + scrollController: scrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + // The two multiline buttons with large text are taller than 50% of the + // dialog height, but with the accessibility layout policy, the 2 buttons + // should be in a scrollable area equal to half the dialog height. + expect(actionsSectionBox.size.height, 280.0 - 24.0); + }, + ); + + testWidgets('Actions section height for 3 buttons without enough room is 1.5 buttons tall.', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The Title'), + content: Text('The message\n' * 40), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('Option 1')), + CupertinoDialogAction(child: Text('Option 2')), + CupertinoDialogAction(child: Text('Option 3')), + ], + scrollController: scrollController, + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pumpAndSettle(); + + final RenderBox option1ButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 1'); + final RenderBox option2ButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 2'); + final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester); + + expect(option1ButtonBox.size.width, option2ButtonBox.size.width); + expect(option1ButtonBox.size.width, actionsSectionBox.size.width); + + // Expected Height = button 1 + divider + 1/2 button 2 = 67.80000000000001 + const expectedHeight = 67.80000000000001; + expect(actionsSectionBox.size.height, moreOrLessEquals(expectedHeight)); + }); + + testWidgets('Actions section correctly renders overscrolls', (WidgetTester tester) async { + // Verifies that when the actions section overscrolls, the overscroll part + // is correctly covered with background. + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: List<Widget>.generate( + 12, + (int i) => CupertinoDialogAction(onPressed: () {}, child: Text('Button ${'*' * i}')), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button *'))); + await tester.pumpAndSettle(); + // The button should be pressed now, since the scrolling gesture has not + // taken over. + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.overscroll.0.png'), + ); + // The dragging gesture must be dispatched in at least two segments. + // After the first movement, the gesture is started, but the delta is still + // zero. The second movement gives the delta. + await gesture.moveBy(const Offset(0, 40)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, 100)); + // Test the top overscroll. Use `pump` not `pumpAndSettle` to verify the + // rendering result of the immediate next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.overscroll.1.png'), + ); + + await gesture.moveBy(const Offset(0, -300)); + // Test the bottom overscroll. Use `pump` not `pumpAndSettle` to verify the + // rendering result of the immediate next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.overscroll.2.png'), + ); + await gesture.up(); + }); + + testWidgets('Actions section correctly renders overscrolls with very far scrolls', ( + WidgetTester tester, + ) async { + // When the scroll is really far, the overscroll might be longer than the + // actions section, causing overflow if not controlled. + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + content: Text('content' * 1000), + actions: List<Widget>.generate( + 4, + (int i) => CupertinoActionSheetAction(onPressed: () {}, child: Text('Button $i')), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, 40)); // A short drag to start the gesture. + await tester.pumpAndSettle(); + // The drag is far enough to make the overscroll longer than the section. + await gesture.moveBy(const Offset(0, 1000)); + await tester.pumpAndSettle(); + // The buttons should be out of the screen + expect( + tester.getTopLeft(find.text('Button 0')).dy, + greaterThan(tester.getBottomLeft(find.byType(ClipRSuperellipse)).dy), + ); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.long-overscroll.0.png'), + ); + }); + + testWidgets('ScaleTransition animation for showCupertinoDialog()', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Builder( + builder: (BuildContext context) { + return CupertinoButton( + onPressed: () { + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: <Widget>[ + const CupertinoDialogAction(child: Text('Cancel')), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + + // Enter animation. + await tester.pump(); + Transform transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.3, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.205, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.100, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.043, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.017, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.006, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], moreOrLessEquals(1.002, epsilon: 0.001)); + + await tester.tap(find.text('Delete')); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // No scaling on exit animation. + expect(find.byType(Transform), findsNothing); + }); + + testWidgets('FadeTransition animation for showCupertinoDialog()', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Builder( + builder: (BuildContext context) { + return CupertinoButton( + onPressed: () { + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: <Widget>[ + const CupertinoDialogAction(child: Text('Cancel')), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + + // Enter animation. + await tester.pump(); + final Finder fadeTransitionFinder = find.ancestor( + of: find.byType(CupertinoAlertDialog), + matching: find.byType(FadeTransition), + ); + FadeTransition transition = tester.firstWidget(fadeTransitionFinder); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.316, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.665, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.856, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.942, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.977, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.991, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.997, epsilon: 0.001)); + + await tester.tap(find.text('Delete')); + + // Exit animation, look at reverse FadeTransition. + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.997, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.681, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.333, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.143, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.057, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.022, epsilon: 0.001)); + }); + + testWidgets('Actions are accessible by key', (WidgetTester tester) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return const CupertinoAlertDialog( + title: Text('The Title'), + content: Text('The message'), + actions: <Widget>[ + CupertinoDialogAction(key: Key('option_1'), child: Text('Option 1')), + CupertinoDialogAction(key: Key('option_2'), child: Text('Option 2')), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + expect(find.byKey(const Key('option_1')), findsOneWidget); + expect(find.byKey(const Key('option_2')), findsOneWidget); + expect(find.byKey(const Key('option_3')), findsNothing); + }); + + testWidgets('Dialog widget insets by MediaQuery viewInsets', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: MediaQuery( + data: MediaQueryData(), + child: CupertinoAlertDialog(content: Placeholder(fallbackHeight: 200.0)), + ), + ), + ); + + final Rect placeholderRectWithoutInsets = tester.getRect(find.byType(Placeholder)); + + await tester.pumpWidget( + const CupertinoApp( + home: MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.fromLTRB(40.0, 30.0, 20.0, 10.0)), + child: CupertinoAlertDialog(content: Placeholder(fallbackHeight: 200.0)), + ), + ), + ); + + // no change yet because padding is animated + expect(tester.getRect(find.byType(Placeholder)), placeholderRectWithoutInsets); + + await tester.pump(const Duration(seconds: 1)); + + // once animation settles the dialog is padded by the new viewInsets + expect( + tester.getRect(find.byType(Placeholder)), + placeholderRectWithoutInsets.translate(10, 10), + ); + }); + + testWidgets('showCupertinoDialog - custom barrierLabel', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return Center( + child: CupertinoButton( + child: const Text('X'), + onPressed: () { + showCupertinoDialog<void>( + context: context, + barrierLabel: 'Custom label', + builder: (BuildContext context) { + return const CupertinoAlertDialog( + title: Text('Title'), + content: Text('Content'), + actions: <Widget>[ + CupertinoDialogAction(child: Text('Yes')), + CupertinoDialogAction(child: Text('No')), + ], + ); + }, + ); + }, + ), + ); + }, + ), + ), + ); + + expect( + semantics, + isNot( + includesNodeWith(label: 'Custom label', flags: <SemanticsFlag>[SemanticsFlag.namesRoute]), + ), + ); + semantics.dispose(); + }); + + testWidgets('showCupertinoDialog - custom barrierColor', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return Center( + child: Column( + children: <Widget>[ + CupertinoButton( + child: const Text('Custom BarrierColor'), + onPressed: () { + showCupertinoDialog<void>( + context: context, + barrierColor: const Color(0xFFF44336), + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('Title'), + content: const Text('Content'), + actions: <Widget>[ + const CupertinoDialogAction(child: Text('Yes')), + CupertinoDialogAction( + child: const Text('No'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + }, + ), + ], + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Custom BarrierColor')); + await tester.pumpAndSettle(); + expect( + tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, + equals(const Color(0xFFF44336)), + ); + + await tester.tap(find.text('No')); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate( + (Widget widget) => widget is ModalBarrier && widget.color == const Color(0xFFF44336), + ), + findsNothing, + ); + }); + + testWidgets('CupertinoDialogRoute is state restorable', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(restorationScopeId: 'app', home: _RestorableDialogTestWidget()), + ); + + expect(find.byType(CupertinoAlertDialog), findsNothing); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAlertDialog), findsOneWidget); + final TestRestorationData restorationData = await tester.getRestorationData(); + + await tester.restartAndRestore(); + + expect(find.byType(CupertinoAlertDialog), findsOneWidget); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAlertDialog), findsNothing); + + await tester.restoreFrom(restorationData); + expect(find.byType(CupertinoAlertDialog), findsOneWidget); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + testWidgets( + 'Conflicting scrollbars are not applied by ScrollBehavior to CupertinoAlertDialog', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/83819 + final actionScrollController = ScrollController(); + addTearDown(actionScrollController.dispose); + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withNoTextScaling( + child: CupertinoAlertDialog( + title: const Text('Test Title'), + content: const Text('Test Content'), + actions: const <Widget>[ + CupertinoDialogAction(child: Text('One')), + CupertinoDialogAction(child: Text('Two')), + ], + actionScrollController: actionScrollController, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pump(); + + // The inherited ScrollBehavior should not apply scrollbars since they are + // already built in to the widget. + expect(find.byType(RawScrollbar), findsNothing); + // Built in CupertinoScrollbars should only number 2: one for the actions, + // one for the content. + expect(find.byType(CupertinoScrollbar), findsNWidgets(2)); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('CupertinoAlertDialog scrollbars controllers should be different', ( + WidgetTester tester, + ) async { + // https://github.com/flutter/flutter/pull/81278 + await tester.pumpWidget( + const CupertinoApp( + home: MediaQuery( + data: MediaQueryData(), + child: CupertinoAlertDialog( + actions: <Widget>[CupertinoDialogAction(child: Text('OK'))], + content: Placeholder(fallbackHeight: 200.0), + ), + ), + ), + ); + + final List<CupertinoScrollbar> scrollbars = find + .descendant( + of: find.byType(CupertinoAlertDialog), + matching: find.byType(CupertinoScrollbar), + ) + .evaluate() + .map((Element e) => e.widget as CupertinoScrollbar) + .toList(); + + expect(scrollbars.length, 2); + expect(scrollbars[0].controller != scrollbars[1].controller, isTrue); + }); + + group('showCupertinoDialog avoids overlapping display features', () { + testWidgets('positioning using anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.00)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning using Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('default positioning', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(Placeholder)), Offset.zero); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(390.0, 600.0)); + }); + }); + + testWidgets('Hovering over Cupertino alert dialog action updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: RepaintBoundary( + child: CupertinoAlertDialog( + title: const Text('Title'), + content: const Text('text'), + actions: <Widget>[ + CupertinoDialogAction(onPressed: () {}, child: const Text('NO')), + CupertinoDialogAction(onPressed: () {}, child: const Text('OK')), + ], + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset dialogAction = tester.getCenter(find.text('OK')); + await gesture.moveTo(dialogAction); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CupertinoAlertDialog divider spans full width and applies color', ( + WidgetTester tester, + ) async { + const kCupertinoDialogWidth = 270.0; + const kDividerThickness = 0.3; + const expectedSize = Size(kCupertinoDialogWidth, kDividerThickness); + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: CupertinoAlertDialog( + title: const Text('The Title'), + content: const Text('Content'), + actions: <Widget>[ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () {}, + child: const Text('Cancel'), + ), + const CupertinoDialogAction(child: Text('OK')), + ], + ), + ), + ), + ); + + final Finder decoratedBoxFinder = find.byType(DecoratedBox); + + expect(decoratedBoxFinder, findsAny, reason: 'There should exist at least one DecoratedBox'); + + final Iterable<Element> elements = decoratedBoxFinder.evaluate().where(( + Element decoratedBoxElement, + ) { + final decoratedBox = decoratedBoxElement.widget as DecoratedBox; + return (decoratedBox.decoration is BoxDecoration?) && + (decoratedBox.decoration as BoxDecoration?)?.color == + CupertinoDynamicColor.resolve(CupertinoColors.separator, decoratedBoxElement) && + tester.getSize(find.byWidget(decoratedBox)) == expectedSize; + }); + + expect(elements.length, 1, reason: 'No DecoratedBox matches the specified criteria.'); + }); + + testWidgets('Check for Directionality', (WidgetTester tester) async { + Future<void> pumpWidget({required bool isLTR}) async { + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: isLTR ? TextDirection.ltr : TextDirection.rtl, + child: const CupertinoAlertDialog( + actions: <CupertinoDialogAction>[ + CupertinoDialogAction(isDefaultAction: true, child: Text('No')), + CupertinoDialogAction(child: Text('Yes')), + ], + ), + ), + ), + ); + } + + await pumpWidget(isLTR: true); + Offset yesButton = tester.getCenter(find.text('Yes')); + Offset noButton = tester.getCenter(find.text('No')); + expect(yesButton.dx > noButton.dx, true); + await pumpWidget(isLTR: false); + yesButton = tester.getCenter(find.text('Yes')); + noButton = tester.getCenter(find.text('No')); + expect(yesButton.dx > noButton.dx, false); + }); + + testWidgets('CupertinoDialogAction.mouseCursor can customize the mouse cursor', ( + WidgetTester tester, + ) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoAlertDialog( + actions: <CupertinoDialogAction>[ + CupertinoDialogAction( + mouseCursor: customCursor, + child: const Text('Yes'), + onPressed: () {}, + ), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset actionSheetAction = tester.getCenter(find.text('Yes')); + await gesture.moveTo(actionSheetAction); + await tester.pumpAndSettle(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('CupertinoDialogAction does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoDialogAction(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoDialogAction)), Size.zero); + }); + + testWidgets('CupertinoActionSheetAction does not crash at zero area', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = Size.zero; + final focusNode = FocusNode(); + addTearDown(tester.view.reset); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoActionSheetAction( + focusNode: focusNode, + onPressed: () {}, + child: const Text('X'), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoActionSheetAction)), Size.zero); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + }); + + testWidgets('CupertinoPopupSurface does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoPopupSurface(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoPopupSurface)), Size.zero); + }); + + testWidgets('CupertinoAlertDialog does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: SizedBox.shrink(child: CupertinoAlertDialog())), + ), + ); + expect(tester.getSize(find.byType(CupertinoAlertDialog)), Size.zero); + }); +} + +RenderBox findActionButtonRenderBoxByTitle(WidgetTester tester, String title) { + final RenderObject buttonBox = tester.renderObject( + find.widgetWithText(CupertinoDialogAction, title), + ); + assert(buttonBox is RenderBox); + return buttonBox as RenderBox; +} + +RenderBox findScrollableActionsSectionRenderBox(WidgetTester tester) { + final RenderObject actionsSection = tester.renderObject( + find.byElementPredicate((Element element) { + return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection'; + }), + ); + assert(actionsSection is RenderBox); + return actionsSection as RenderBox; +} + +Widget createAppWithButtonThatLaunchesDialog({required WidgetBuilder dialogBuilder}) { + return CupertinoApp( + home: Center( + child: Builder( + builder: (BuildContext context) { + return CupertinoButton( + onPressed: () { + showCupertinoDialog<void>(context: context, builder: dialogBuilder); + }, + child: const Text('Go'), + ); + }, + ), + ), + ); +} + +Widget boilerplate(Widget child) { + return Directionality(textDirection: TextDirection.ltr, child: child); +} + +Widget createAppWithCenteredButton(Widget child) { + return CupertinoApp( + home: Center(child: CupertinoButton(onPressed: null, child: child)), + ); +} + +@pragma('vm:entry-point') +class _RestorableDialogTestWidget extends StatelessWidget { + const _RestorableDialogTestWidget(); + + @pragma('vm:entry-point') + static Route<Object?> _dialogBuilder(BuildContext context, Object? arguments) { + return CupertinoDialogRoute<void>( + context: context, + builder: (BuildContext context) { + return const CupertinoAlertDialog( + title: Text('Title'), + content: Text('Content'), + actions: <Widget>[ + CupertinoDialogAction(child: Text('Yes')), + CupertinoDialogAction(child: Text('No')), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Home')), + child: Center( + child: CupertinoButton( + onPressed: () { + Navigator.of(context).restorablePush(_dialogBuilder); + }, + child: const Text('X'), + ), + ), + ); + } +} + +// Shows an app that has a button with text "Go", and clicking this button +// displays the `dialog` and hides the button. +// +// The `theme` will be applied to the app and determines the background. +class TestScaffoldApp extends StatefulWidget { + const TestScaffoldApp({super.key, required this.theme, required this.dialog}); + + final CupertinoThemeData theme; + final Widget dialog; + + @override + TestScaffoldAppState createState() => TestScaffoldAppState(); +} + +class TestScaffoldAppState extends State<TestScaffoldApp> { + bool _pressedButton = false; + + @override + Widget build(BuildContext context) { + return CupertinoApp( + // Hide the debug banner. Because this CupertinoApp is captured in golden + // test as a whole. The debug banner contains tilted text, whose + // anti-alias might cause false negative result. + // https://github.com/flutter/flutter/pull/150442 + debugShowCheckedModeBanner: false, + theme: widget.theme, + home: Builder( + builder: (BuildContext context) => CupertinoPageScaffold( + child: Center( + child: _pressedButton + ? Container() + : CupertinoButton( + onPressed: () { + setState(() { + _pressedButton = true; + }); + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return widget.dialog; + }, + ); + }, + child: const Text('Go'), + ), + ), + ), + ), + ); + } +} + +// Old-style action sheet buttons, which are implemented with +// `GestureDetector.onTap`. +class LegacyAction extends StatelessWidget { + const LegacyAction({super.key, required this.onPressed, required this.child}); + + final VoidCallback onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 45), + child: Container( + alignment: AlignmentDirectional.center, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 10.0), + child: child, + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/editable_text_utils.dart b/packages/cupertino_ui/test/cupertino/editable_text_utils.dart new file mode 100644 index 000000000000..bbafd7298d76 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/editable_text_utils.dart @@ -0,0 +1,147 @@ +// 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/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// On web, the context menu (aka toolbar) is provided by the browser. +const bool isContextMenuProvidedByPlatform = isBrowser; + +/// Returns the [RenderEditable] at the given [index], or the first if not +/// given. +RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) { + final RenderObject root = tester.renderObject(find.byType(EditableText).at(index)); + expect(root, isNotNull); + + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable; +} + +/// Converts a list of local [TextSelectionPoint]s to global coordinates. +List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { + return points.map<TextSelectionPoint>((TextSelectionPoint point) { + return TextSelectionPoint(box.localToGlobal(point.point), point.direction); + }).toList(); +} + +/// Returns the global position of the character at the given [offset] in the +/// [EditableText] found at the given [index]. +Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) { + final RenderEditable renderEditable = findRenderEditable(tester, index: index); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(TextSelection.collapsed(offset: offset)), + renderEditable, + ); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0); +} + +/// Mimic key press events by sending key down and key up events via the [tester]. +Future<void> sendKeys( + WidgetTester tester, + List<LogicalKeyboardKey> keys, { + bool shift = false, + bool wordModifier = false, + bool lineModifier = false, + bool shortcutModifier = false, + required TargetPlatform targetPlatform, +}) async { + final targetPlatformString = targetPlatform.toString(); + final String platform = targetPlatformString + .substring(targetPlatformString.indexOf('.') + 1) + .toLowerCase(); + if (shift) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform); + } + if (shortcutModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (wordModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.altLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (lineModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.altLeft, + platform: platform, + ); + } + for (final key in keys) { + await tester.sendKeyEvent(key, platform: platform); + await tester.pump(); + } + if (lineModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.altLeft, + platform: platform, + ); + } + if (wordModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.altLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (shortcutModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (shift) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform); + } + if (shift || wordModifier || lineModifier) { + await tester.pump(); + } +} + +/// A [TextEditingController] that builds a [WidgetSpan] with 100 height for +/// testing overflow behavior. +class OverflowWidgetTextEditingController extends TextEditingController { + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + return TextSpan( + style: style, + children: <InlineSpan>[ + const TextSpan(text: 'Hi'), + WidgetSpan(child: Container(color: const Color(0xffff0000), height: 100.0)), + ], + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/expansion_tile_test.dart b/packages/cupertino_ui/test/cupertino/expansion_tile_test.dart new file mode 100644 index 000000000000..1c4f05c7b904 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/expansion_tile_test.dart @@ -0,0 +1,400 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const expansionDuration = Duration(milliseconds: 250); + const infinitesimalDuration = Duration(microseconds: 1); + testWidgets('Toggles expansion on tap', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile(title: Text('Title'), child: Text('Content')), + ), + ), + ); + + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Title')); + await tester.pump(); + // The child animating its height and a clone fading in. + expect(find.text('Content'), findsNWidgets(2)); + + await tester.tap(find.text('Title')); + await tester.pump(); + expect(find.text('Content'), findsNothing); + }); + + testWidgets('Can be controlled by ExpansibleController', (WidgetTester tester) async { + final controller = ExpansibleController(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile( + controller: controller, + title: const Text('Title'), + child: const Text('Content'), + ), + ), + ), + ); + + expect(controller.isExpanded, isFalse); + expect(find.text('Content'), findsNothing); + + controller.expand(); + await tester.pump(); + expect(controller.isExpanded, isTrue); + expect(find.text('Content'), findsOneWidget); + + controller.collapse(); + await tester.pump(); + expect(controller.isExpanded, isFalse); + expect(find.text('Content'), findsNothing); + + controller.dispose(); + }); + + testWidgets('Controller can set the tile to be initially expanded', (WidgetTester tester) async { + final controller = ExpansibleController(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile( + controller: controller, + title: const Text('Title'), + child: const Text('Content'), + ), + ), + ), + ); + + controller.expand(); + await tester.pump(); + + expect(controller.isExpanded, isTrue); + expect(find.text('Content'), findsOneWidget); + + await tester.tap(find.text('Title')); + await tester.pump(); + expect(controller.isExpanded, isFalse); + expect(find.text('Content'), findsNothing); + + controller.dispose(); + }); + + testWidgets('Nested expansion tile', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoExpansionTile( + title: Text('Outer'), + child: CupertinoExpansionTile(title: Text('Inner'), child: Text('Content')), + ), + ), + ), + ); + + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Outer')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Inner')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsOneWidget); + + await tester.tap(find.text('Inner')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsNothing); + + await tester.tap(find.text('Outer')); + await tester.pump(); + await tester.pump(expansionDuration + infinitesimalDuration); + expect(find.text('Content'), findsNothing); + }); + + testWidgets('Default expansion animation and icon rotation', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoExpansionTile( + title: Text('Title'), + child: SizedBox(height: 50.0, child: ColoredBox(color: Color(0xffff0000))), + ), + ), + ), + ), + ); + + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.collapsed.png'), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.forward.png'), + ); + + await tester.pumpAndSettle(); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.expanded.png'), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.default.reverse.png'), + ); + }); + + testWidgets('Expansion animation in scroll mode', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoExpansionTile( + title: Text('Title'), + transitionMode: ExpansionTileTransitionMode.scroll, + child: SizedBox(height: 50.0, child: ColoredBox(color: Color(0xffff0000))), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.scroll_mode.forward.png'), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('Title')); + await tester.pump(); + + // Pump until halfway through the animation. + await tester.pump(expansionDuration ~/ 2); + await expectLater( + find.byType(CupertinoExpansionTile), + matchesGoldenFile('expansion_tile.scroll_mode.reverse.png'), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('Nested CupertinoListTile Semantics', (WidgetTester tester) async { + final controller = ExpansibleController(); + addTearDown(controller.dispose); + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + const CupertinoExpansionTile( + title: Text('First Expansion Tile'), + child: SizedBox(height: 50.0), + ), + CupertinoExpansionTile( + controller: controller, + title: const Text('Second Expansion Tile'), + child: const SizedBox(height: 50.0), + ), + ], + ), + ), + ); + + controller.expand(); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.byType(CupertinoListTile).first), + matchesSemantics( + hasTapAction: true, + label: 'First Expansion Tile', + textDirection: TextDirection.ltr, + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoListTile).last), + matchesSemantics( + hasTapAction: true, + label: 'Second Expansion Tile', + textDirection: TextDirection.ltr, + ), + ); + handle.dispose(); + }); + + testWidgets('Semantics with the onTapHint is an ancestor of CupertinoListTile', ( + WidgetTester tester, + ) async { + final controller = ExpansibleController(); + addTearDown(controller.dispose); + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultCupertinoLocalizations(); + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + const CupertinoExpansionTile( + title: Text('First Expansion Tile'), + child: SizedBox(height: 100, width: 100), + ), + CupertinoExpansionTile( + controller: controller, + title: const Text('Second Expansion Tile'), + child: const SizedBox(height: 100, width: 100), + ), + ], + ), + ), + ); + + controller.expand(); + await tester.pumpAndSettle(); + + SemanticsNode semantics = tester.getSemantics( + find + .ancestor(of: find.byType(CupertinoListTile).first, matching: find.byType(Semantics)) + .first, + ); + expect(semantics, isNotNull); + // The onTapHint is passed to semantics properties's hintOverrides. + expect(semantics.hintOverrides, isNotNull); + // The hint should be the opposite of the current state. + // The first CupertinoExpansionTile is collapsed, so the hint should be + // "double tap to expand". + expect(semantics.hintOverrides!.onTapHint, localizations.expansionTileCollapsedTapHint); + + semantics = tester.getSemantics( + find + .ancestor(of: find.byType(CupertinoListTile).last, matching: find.byType(Semantics)) + .first, + ); + + expect(semantics, isNotNull); + // The onTapHint is passed to semantics properties's hintOverrides. + expect(semantics.hintOverrides, isNotNull); + // The hint should be the opposite of the current state. + // The second CupertinoExpansionTile is expanded, so the hint should be + // "double tap to collapse". + expect(semantics.hintOverrides!.onTapHint, localizations.expansionTileExpandedTapHint); + handle.dispose(); + }); + + testWidgets( + 'Semantics hint for iOS and macOS', + (WidgetTester tester) async { + final controller = ExpansibleController(); + addTearDown(controller.dispose); + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultCupertinoLocalizations(); + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + const CupertinoExpansionTile( + title: Text('First Expansion Tile'), + child: SizedBox(height: 100, width: 100), + ), + CupertinoExpansionTile( + controller: controller, + title: const Text('Second Expansion Tile'), + child: const SizedBox(height: 100, width: 100), + ), + ], + ), + ), + ); + + controller.expand(); + await tester.pumpAndSettle(); + + SemanticsNode semantics = tester.getSemantics( + find + .ancestor(of: find.byType(CupertinoListTile).first, matching: find.byType(Semantics)) + .first, + ); + + expect(semantics, isNotNull); + expect( + semantics.hint, + '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}', + ); + + semantics = tester.getSemantics( + find + .ancestor(of: find.byType(CupertinoListTile).last, matching: find.byType(Semantics)) + .first, + ); + + expect(semantics, isNotNull); + expect( + semantics.hint, + '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}', + ); + handle.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('CupertinoExpansionTile does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = ExpansibleController(); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoExpansionTile( + controller: controller, + title: const Text('X'), + child: const Text('Y'), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoExpansionTile)), Size.zero); + controller.expand(); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/form_row_test.dart b/packages/cupertino_ui/test/cupertino/form_row_test.dart new file mode 100644 index 000000000000..f021003990a1 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/form_row_test.dart @@ -0,0 +1,202 @@ +// 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/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Shows prefix', (WidgetTester tester) async { + const Widget prefix = Text('Enter Value'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoFormRow(prefix: prefix, child: CupertinoTextField()), + ), + ), + ); + + expect(prefix, tester.widget(find.byType(Text))); + }); + + testWidgets('Shows child', (WidgetTester tester) async { + const Widget child = CupertinoTextField(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoFormRow(child: child)), + ), + ); + + expect(child, tester.widget(find.byType(CupertinoTextField))); + }); + + testWidgets('RTL puts prefix after child', (WidgetTester tester) async { + const Widget prefix = Text('Enter Value'); + const Widget child = CupertinoTextField(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoFormRow(prefix: prefix, child: child), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byType(Text)).dx > + tester.getTopLeft(find.byType(CupertinoTextField)).dx, + true, + ); + }); + + testWidgets('LTR puts child after prefix', (WidgetTester tester) async { + const Widget prefix = Text('Enter Value'); + const Widget child = CupertinoTextField(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoFormRow(prefix: prefix, child: child), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byType(Text)).dx > + tester.getTopLeft(find.byType(CupertinoTextField)).dx, + false, + ); + }); + + testWidgets('Shows error widget', (WidgetTester tester) async { + const Widget error = Text('Error'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoFormRow(error: error, child: CupertinoTextField()), + ), + ), + ); + + expect(error, tester.widget(find.byType(Text))); + }); + + testWidgets('Shows helper widget', (WidgetTester tester) async { + const Widget helper = Text('Helper'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoFormRow(helper: helper, child: CupertinoTextField()), + ), + ), + ); + + expect(helper, tester.widget(find.byType(Text))); + }); + + testWidgets('Shows helper text above error text', (WidgetTester tester) async { + const Widget helper = Text('Helper'); + const Widget error = CupertinoActivityIndicator(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoFormRow(helper: helper, error: error, child: CupertinoTextField()), + ), + ), + ); + + expect( + tester.getTopLeft(find.byType(CupertinoActivityIndicator)).dy > + tester.getTopLeft(find.byType(Text)).dy, + true, + ); + }); + + testWidgets('Shows helper in label color and error text in red color', ( + WidgetTester tester, + ) async { + const Widget helper = Text('Helper'); + const Widget error = Text('Error'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoFormRow(helper: helper, error: error, child: CupertinoTextField()), + ), + ), + ); + + final DefaultTextStyle helperTextStyle = tester.widget(find.byType(DefaultTextStyle).first); + + expect(helperTextStyle.style.color, CupertinoColors.label); + + final DefaultTextStyle errorTextStyle = tester.widget(find.byType(DefaultTextStyle).last); + + expect(errorTextStyle.style.color, CupertinoColors.destructiveRed); + }); + + testWidgets('CupertinoFormRow adapts to CupertinoApp dark mode', (WidgetTester tester) async { + const Widget prefix = Text('Prefix'); + const Widget helper = Text('Helper'); + + Widget buildFormRow(Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: const Center( + child: CupertinoFormRow(prefix: prefix, helper: helper, child: CupertinoTextField()), + ), + ); + } + + // CupertinoFormRow with light theme. + await tester.pumpWidget(buildFormRow(Brightness.light)); + RenderParagraph helperParagraph = tester.renderObject(find.text('Helper')); + final Color expectedLight = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoFormRow)), + ); + expect(helperParagraph.text.style!.color, expectedLight); + // Text style should not return unresolved color. + expect(helperParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + RenderParagraph prefixParagraph = tester.renderObject(find.text('Prefix')); + expect(prefixParagraph.text.style!.color, expectedLight); + // Text style should not return unresolved color. + expect(prefixParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoFormRow with light theme. + await tester.pumpWidget(buildFormRow(Brightness.dark)); + helperParagraph = tester.renderObject(find.text('Helper')); + final Color expectedDark = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoFormRow)), + ); + expect(helperParagraph.text.style!.color, expectedDark); + // Text style should not return unresolved color. + expect(helperParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + prefixParagraph = tester.renderObject(find.text('Prefix')); + expect(prefixParagraph.text.style!.color, expectedDark); + // Text style should not return unresolved color. + expect(prefixParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); + + testWidgets('CupertinoFormRow does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoFormRow(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoFormRow)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/form_section_test.dart b/packages/cupertino_ui/test/cupertino/form_section_test.dart new file mode 100644 index 000000000000..1192a5b2bf76 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/form_section_test.dart @@ -0,0 +1,231 @@ +// 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/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Shows header', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + header: const Text('Header'), + children: <Widget>[CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + expect(find.text('Header'), findsOneWidget); + }); + + testWidgets('Shows footer', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + footer: const Text('Footer'), + children: <Widget>[CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + expect(find.text('Footer'), findsOneWidget); + }); + + testWidgets('Shows long dividers in edge-to-edge section part 1', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoFormSection(children: <Widget>[CupertinoTextFormFieldRow()])), + ), + ); + + // Since the children list is reconstructed with dividers in it, the column + // retrieved should have 3 items for an input [children] param with 1 child. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 3); + }); + + testWidgets('Shows long dividers in edge-to-edge section part 2', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + children: <Widget>[CupertinoTextFormFieldRow(), CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + // Since the children list is reconstructed with dividers in it, the column + // retrieved should have 5 items for an input [children] param with 2 + // children. Two long dividers, two rows, and one short divider. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 5); + }); + + testWidgets('Does not show long dividers in insetGrouped section part 1', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection.insetGrouped(children: <Widget>[CupertinoTextFormFieldRow()]), + ), + ), + ); + + // Since the children list is reconstructed without long dividers in it, the + // column retrieved should have 1 item for an input [children] param with 1 + // child. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 1); + }); + + testWidgets('Does not show long dividers in insetGrouped section part 2', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + restorationScopeId: 'App', + home: Center( + child: CupertinoFormSection.insetGrouped( + children: <Widget>[CupertinoTextFormFieldRow(), CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + // Since the children list is reconstructed with short dividers in it, the + // column retrieved should have 3 items for an input [children] param with 2 + // children. Two long dividers, two rows, and one short divider. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 3); + }); + + testWidgets('Sets background color for section', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoFormSection( + backgroundColor: backgroundColor, + children: <Widget>[CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox).first); + final boxDecoration = decoratedBox.decoration as BoxDecoration; + expect(boxDecoration.color, backgroundColor); + }); + + testWidgets('Setting clipBehavior clips children section', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + clipBehavior: Clip.antiAlias, + children: <Widget>[CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + expect(find.byType(ClipRSuperellipse), findsOneWidget); + }); + + testWidgets('Not setting clipBehavior does not produce a RenderClipRSuperellipse object', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoFormSection(children: <Widget>[CupertinoTextFormFieldRow()])), + ), + ); + + final Iterable<RenderClipRSuperellipse> renderClips = tester.allRenderObjects + .whereType<RenderClipRSuperellipse>(); + expect(renderClips, isEmpty); + }); + + testWidgets('Does not double up padding on header', (WidgetTester tester) async { + const Widget header = Text('Header'); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + header: header, + children: <Widget>[CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + expect(tester.getTopLeft(find.byWidget(header)), const Offset(20, 22)); + }); + + testWidgets('Does not double up padding on footer', (WidgetTester tester) async { + const Widget footer = Text('Footer'); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + footer: footer, + children: <Widget>[CupertinoTextFormFieldRow()], + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byWidget(footer)), + offsetMoreOrLessEquals(const Offset(20, 65), epsilon: 1), + ); + }); + + testWidgets('Sets custom margin', (WidgetTester tester) async { + final Widget child = CupertinoTextFormFieldRow(); + + const double margin = 35; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoFormSection( + margin: const EdgeInsets.all(margin), + children: <Widget>[child], + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byWidget(child)), + offsetMoreOrLessEquals(const Offset(margin, 22 + margin), epsilon: 1), + ); + }); + + testWidgets('CupertinoFormSection does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoFormSection(children: const <Widget>[Text('X'), Text('Y')]), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoFormSection)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/icon_theme_data_test.dart b/packages/cupertino_ui/test/cupertino/icon_theme_data_test.dart new file mode 100644 index 000000000000..4b7f2d67a2e6 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/icon_theme_data_test.dart @@ -0,0 +1,53 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('IconTheme.of works', (WidgetTester tester) async { + const data = IconThemeData( + size: 16.0, + fill: 0.0, + weight: 400.0, + grade: 0.0, + opticalSize: 48.0, + color: Color(0xAAAAAAAA), + opacity: 0.5, + applyTextScaling: true, + ); + + late IconThemeData retrieved; + await tester.pumpWidget( + IconTheme( + data: data, + child: Builder( + builder: (BuildContext context) { + retrieved = IconTheme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(retrieved, data); + + await tester.pumpWidget( + IconTheme( + data: const CupertinoIconThemeData(color: CupertinoColors.systemBlue), + child: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Builder( + builder: (BuildContext context) { + retrieved = IconTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(retrieved.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/list_section_test.dart b/packages/cupertino_ui/test/cupertino/list_section_test.dart new file mode 100644 index 000000000000..3539e0eea60b --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/list_section_test.dart @@ -0,0 +1,272 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows header', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + header: const Text('Header'), + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + expect(find.text('Header'), findsOneWidget); + }); + + testWidgets('shows footer', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + footer: const Text('Footer'), + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + expect(find.text('Footer'), findsOneWidget); + }); + + testWidgets('shows long dividers in edge-to-edge section part 1', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + // Since the children list is reconstructed with dividers in it, the column + // retrieved should have 3 items for an input [children] param with 1 child. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 3); + }); + + testWidgets('shows long dividers in edge-to-edge section part 2', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const <Widget>[ + CupertinoListTile(title: Text('CupertinoListTile')), + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + // Since the children list is reconstructed with dividers in it, the column + // retrieved should have 5 items for an input [children] param with 2 + // children. Two long dividers, two rows, and one short divider. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 5); + }); + + testWidgets('does not show long dividers in insetGrouped section part 1', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection.insetGrouped( + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + // Since the children list is reconstructed without long dividers in it, the + // column retrieved should have 1 item for an input [children] param with 1 + // child. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 1); + }); + + testWidgets('does not show long dividers in insetGrouped section part 2', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection.insetGrouped( + children: const <Widget>[ + CupertinoListTile(title: Text('CupertinoListTile')), + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + // Since the children list is reconstructed with short dividers in it, the + // column retrieved should have 3 items for an input [children] param with 2 + // children. Two long dividers, two rows, and one short divider. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 3); + }); + + testWidgets('sets background color for section', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + backgroundColor: backgroundColor, + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox).first); + final boxDecoration = decoratedBox.decoration as BoxDecoration; + expect(boxDecoration.color, backgroundColor); + }); + + testWidgets('setting clipBehavior clips children section', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + clipBehavior: Clip.antiAlias, + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + expect(find.byType(ClipRSuperellipse), findsOneWidget); + }); + + testWidgets('not setting clipBehavior does not clip children section', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + expect(find.byType(ClipRSuperellipse), findsNothing); + }); + + testWidgets('CupertinoListSection respects separatorColor', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + separatorColor: const Color.fromARGB(255, 143, 193, 51), + children: const <Widget>[ + CupertinoListTile(title: Text('CupertinoListTile')), + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + for (final Widget e in childrenColumn.children) { + if (e is Container) { + expect(e.color, const Color.fromARGB(255, 143, 193, 51)); + } + } + }); + + testWidgets('CupertinoListSection.separatorColor defaults CupertinoColors.separator', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const <Widget>[ + CupertinoListTile(title: Text('CupertinoListTile')), + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + final BuildContext context = tester.element(find.byType(CupertinoListSection)); + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + for (final Widget e in childrenColumn.children) { + if (e is Container) { + expect(e.color, CupertinoColors.separator.resolveFrom(context)); + } + } + }); + + testWidgets('does not show margin by default', (WidgetTester tester) async { + const Widget child = CupertinoListTile(title: Text('CupertinoListTile')); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + header: const Text('Header'), + children: const <Widget>[child], + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byWidget(child)), + offsetMoreOrLessEquals(const Offset(0, 41), epsilon: 1), + ); + }); + + testWidgets('shows custom margin', (WidgetTester tester) async { + const Widget child = CupertinoListTile(title: Text('CupertinoListTile')); + const double margin = 10; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + header: const Text('Header'), + margin: const EdgeInsets.all(margin), + children: const <Widget>[child], + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byWidget(child)), + offsetMoreOrLessEquals(const Offset(margin, 41 + margin), epsilon: 1), + ); + }); + + testWidgets('CupertinoListSection does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoListSection(header: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoListSection)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/list_tile_test.dart b/packages/cupertino_ui/test/cupertino/list_tile_test.dart new file mode 100644 index 000000000000..d89b1f989eb5 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/list_tile_test.dart @@ -0,0 +1,573 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoListTile(title: title)), + ), + ); + + expect(tester.widget<Text>(find.byType(Text)), title); + expect(find.text('CupertinoListTile'), findsOneWidget); + }); + + testWidgets('shows subtitle', (WidgetTester tester) async { + const Widget subtitle = Text('CupertinoListTile subtitle'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile(title: Icon(CupertinoIcons.add), subtitle: subtitle), + ), + ), + ); + + expect(tester.widget<Text>(find.byType(Text)), subtitle); + expect(find.text('CupertinoListTile subtitle'), findsOneWidget); + }); + + testWidgets('shows additionalInfo', (WidgetTester tester) async { + const Widget additionalInfo = Text('Not Connected'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile(title: Icon(CupertinoIcons.add), additionalInfo: additionalInfo), + ), + ), + ); + + expect(tester.widget<Text>(find.byType(Text)), additionalInfo); + expect(find.text('Not Connected'), findsOneWidget); + }); + + testWidgets('shows trailing', (WidgetTester tester) async { + const Widget trailing = CupertinoListTileChevron(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile(title: Icon(CupertinoIcons.add), trailing: trailing), + ), + ), + ); + + expect( + tester.widget<CupertinoListTileChevron>(find.byType(CupertinoListTileChevron)), + trailing, + ); + }); + + testWidgets('shows leading', (WidgetTester tester) async { + const Widget leading = Icon(CupertinoIcons.add); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile(leading: leading, title: Text('CupertinoListTile')), + ), + ), + ); + + expect(tester.widget<Icon>(find.byType(Icon)), leading); + }); + + testWidgets('sets backgroundColor', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemRed; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: const <Widget>[ + CupertinoListTile(title: Text('CupertinoListTile'), backgroundColor: backgroundColor), + ], + ), + ), + ), + ); + + final ColoredBox coloredBox = tester.widget<ColoredBox>( + find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)), + ); + expect(coloredBox.color, backgroundColor); + }); + + testWidgets('does not change backgroundColor when tapped if onTap is not provided', ( + WidgetTester tester, + ) async { + const Color backgroundColor = CupertinoColors.systemBlue; + const Color backgroundColorActivated = CupertinoColors.systemRed; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: const <Widget>[ + CupertinoListTile( + title: Text('CupertinoListTile'), + backgroundColor: backgroundColor, + backgroundColorActivated: backgroundColorActivated, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(CupertinoListTile)); + await tester.pump(); + + final ColoredBox coloredBox = tester.widget<ColoredBox>( + find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)), + ); + expect(coloredBox.color, backgroundColor); + }); + + testWidgets('changes backgroundColor when tapped if onTap is provided', ( + WidgetTester tester, + ) async { + const Color backgroundColor = CupertinoColors.systemBlue; + const Color backgroundColorActivated = CupertinoColors.systemRed; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: <Widget>[ + CupertinoListTile( + title: const Text('CupertinoListTile'), + backgroundColor: backgroundColor, + backgroundColorActivated: backgroundColorActivated, + onTap: () async { + await Future<void>.delayed(const Duration(milliseconds: 1), () {}); + }, + ), + ], + ), + ), + ), + ); + + ColoredBox coloredBox = tester.widget<ColoredBox>( + find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)), + ); + expect(coloredBox.color, backgroundColor); + + // Pump only one frame so the color change persists. + await tester.tap(find.byType(CupertinoListTile)); + await tester.pump(); + + coloredBox = tester.widget<ColoredBox>( + find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)), + ); + expect(coloredBox.color, backgroundColorActivated); + + // Pump the rest of the frames to complete the test. + await tester.pumpAndSettle(); + }); + + testWidgets('does not contain GestureDetector if onTap is not provided', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: const <Widget>[CupertinoListTile(title: Text('CupertinoListTile'))], + ), + ), + ), + ); + + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('contains GestureDetector if onTap is provided', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: <Widget>[ + CupertinoListTile(title: const Text('CupertinoListTile'), onTap: () async {}), + ], + ), + ), + ), + ); + + expect(find.byType(GestureDetector), findsOneWidget); + }); + + testWidgets('resets the background color when navigated back', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + const Color backgroundColorActivated = CupertinoColors.systemRed; + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + final Widget secondPage = Center( + child: CupertinoButton( + child: const Text('Go back'), + onPressed: () => Navigator.of(context).pop<void>(), + ), + ); + return Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListTile( + title: const Text('CupertinoListTile'), + backgroundColor: backgroundColor, + backgroundColorActivated: backgroundColorActivated, + onTap: () => Navigator.of(context).push( + CupertinoPageRoute<Widget>(builder: (BuildContext context) => secondPage), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + // Navigate to second page. + await tester.tap(find.byType(CupertinoListTile)); + await tester.pumpAndSettle(); + + // Go back to first page. + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + + final ColoredBox coloredBox = tester.widget<ColoredBox>( + find.descendant(of: find.byType(CupertinoListTile), matching: find.byType(ColoredBox)), + ); + expect(coloredBox.color, backgroundColor); + }); + + group('alignment of widgets for left-to-right', () { + testWidgets('leading is on the left of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget leading = Icon(CupertinoIcons.add); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile(title: title, leading: leading), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopLeft(find.byType(Text)); + final Offset foundLeading = tester.getTopRight(find.byType(Icon)); + + expect(foundTitle.dx > foundLeading.dx, true); + }); + + testWidgets('subtitle is placed below title and aligned on left', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile title'); + const Widget subtitle = Text('CupertinoListTile subtitle'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile(title: title, subtitle: subtitle), + ), + ), + ), + ); + + final Offset foundTitle = tester.getBottomLeft(find.text('CupertinoListTile title')); + final Offset foundSubtitle = tester.getTopLeft(find.text('CupertinoListTile subtitle')); + + expect(foundTitle.dx, equals(foundSubtitle.dx)); + expect(foundTitle.dy < foundSubtitle.dy, isTrue); + }); + + testWidgets('additionalInfo is on the right of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile(title: title, additionalInfo: additionalInfo), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopRight(find.text('CupertinoListTile')); + final Offset foundInfo = tester.getTopLeft(find.text('Not Connected')); + + expect(foundTitle.dx < foundInfo.dx, isTrue); + }); + + testWidgets('trailing is on the right of additionalInfo', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + const Widget trailing = CupertinoListTileChevron(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile( + title: title, + additionalInfo: additionalInfo, + trailing: trailing, + ), + ), + ), + ), + ); + + final Offset foundInfo = tester.getTopRight(find.text('Not Connected')); + final Offset foundTrailing = tester.getTopLeft(find.byType(CupertinoListTileChevron)); + + expect(foundInfo.dx < foundTrailing.dx, isTrue); + }); + }); + + group('alignment of widgets for right-to-left', () { + testWidgets('leading is on the right of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget leading = Icon(CupertinoIcons.add); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile(title: title, leading: leading), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopRight(find.byType(Text)); + final Offset foundLeading = tester.getTopLeft(find.byType(Icon)); + + expect(foundTitle.dx < foundLeading.dx, true); + }); + + testWidgets('subtitle is placed below title and aligned on right', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile title'); + const Widget subtitle = Text('CupertinoListTile subtitle'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile(title: title, subtitle: subtitle), + ), + ), + ), + ); + + final Offset foundTitle = tester.getBottomRight(find.text('CupertinoListTile title')); + final Offset foundSubtitle = tester.getTopRight(find.text('CupertinoListTile subtitle')); + + expect(foundTitle.dx, equals(foundSubtitle.dx)); + expect(foundTitle.dy < foundSubtitle.dy, isTrue); + }); + + testWidgets('additionalInfo is on the left of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile(title: title, additionalInfo: additionalInfo), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopLeft(find.text('CupertinoListTile')); + final Offset foundInfo = tester.getTopRight(find.text('Not Connected')); + + expect(foundTitle.dx, greaterThanOrEqualTo(foundInfo.dx)); + }); + + testWidgets('trailing is on the left of additionalInfo', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + const Widget trailing = CupertinoListTileChevron(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile( + title: title, + additionalInfo: additionalInfo, + trailing: trailing, + ), + ), + ), + ), + ); + + final Offset foundInfo = tester.getTopLeft(find.text('Not Connected')); + final Offset foundTrailing = tester.getTopRight(find.byType(CupertinoListTileChevron)); + + expect(foundInfo.dx, greaterThanOrEqualTo(foundTrailing.dx)); + }); + }); + + testWidgets('onTap with delay does not throw an exception', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + var showTile = true; + + Future<void> onTap() async { + showTile = false; + await Future<void>.delayed(const Duration(seconds: 1), () => showTile = true); + } + + Widget buildCupertinoListTile() { + return CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[if (showTile) CupertinoListTile(onTap: onTap, title: title)], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildCupertinoListTile()); + expect(showTile, isTrue); + await tester.tap(find.byType(CupertinoListTile)); + expect(showTile, isFalse); + await tester.pumpWidget(buildCupertinoListTile()); + await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(tester.takeException(), null); + }); + + testWidgets('title does not overflow', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoListTile(title: Text('CupertinoListTile' * 10)), + ), + ), + ); + + expect(tester.takeException(), null); + }); + + testWidgets('subtitle does not overflow', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoListTile(title: const Text(''), subtitle: Text('CupertinoListTile' * 10)), + ), + ), + ); + + expect(tester.takeException(), null); + }); + + testWidgets('Leading and trailing animate on listtile long press', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoListTile( + title: const Text(''), + onTap: () => setState(() { + value = !value; + }), + leading: CupertinoSwitch(value: value, onChanged: (_) {}), + trailing: CupertinoSwitch(value: value, onChanged: (_) {}), + ); + }, + ), + ), + ), + ); + + final firstPosition = + (tester.state(find.byType(CupertinoSwitch).first) as dynamic).position as CurvedAnimation; + final lastPosition = + (tester.state(find.byType(CupertinoSwitch).last) as dynamic).position as CurvedAnimation; + + expect(firstPosition.value, 0.0); + expect(lastPosition.value, 0.0); + + await tester.longPress(find.byType(CupertinoListTile)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 65)); + + expect(firstPosition.value, greaterThan(0.0)); + expect(lastPosition.value, greaterThan(0.0)); + + expect(firstPosition.value, lessThan(1.0)); + expect(lastPosition.value, lessThan(1.0)); + + await tester.pumpAndSettle(); + expect(firstPosition.value, 1.0); + expect(lastPosition.value, 1.0); + }); + + testWidgets('CupertinoListTile does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoListTile(title: Text('X'), trailing: CupertinoListTileChevron()), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoListTile)), Size.zero); + }); + + testWidgets('CupertinoListTileChevron does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: SizedBox.shrink(child: CupertinoListTileChevron())), + ), + ); + expect(tester.getSize(find.byType(CupertinoListTileChevron)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/live_text_utils.dart b/packages/cupertino_ui/test/cupertino/live_text_utils.dart new file mode 100644 index 000000000000..1e19a41ba76b --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/live_text_utils.dart @@ -0,0 +1,78 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A mock class to control the return result of Live Text input functions. +class LiveTextInputTester { + /// Creates a [LiveTextInputTester] and installs a mock handler on + /// [SystemChannels.platform]. + LiveTextInputTester() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + _handler, + ); + } + + /// Whether the mock Live Text input is enabled. + bool mockLiveTextInputEnabled = false; + + Future<Object?> _handler(MethodCall methodCall) async { + // Need to set Clipboard.hasStrings method handler because when showing the tool bar, + // the Clipboard.hasStrings will also be invoked. If this isn't handled, + // an exception will be thrown. + if (methodCall.method == 'Clipboard.hasStrings') { + return <String, bool>{'value': true}; + } + if (methodCall.method == 'LiveText.isLiveTextInputAvailable') { + return mockLiveTextInputEnabled; + } + return false; + } + + /// Removes the mock handler from [SystemChannels.platform]. + void dispose() { + assert( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.checkMockMessageHandler( + SystemChannels.platform.name, + _handler, + ), + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + } +} + +/// A function to find the live text button. +/// +/// LiveText button is displayed either using a custom painter, +/// a Text with an empty label, or a Text with the 'Scan text' label. +Finder findLiveTextButton() { + final bool isMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.fuchsia || + defaultTargetPlatform == TargetPlatform.iOS; + if (isMobile) { + return find.byWidgetPredicate((Widget widget) { + return (widget is CustomPaint && + '${widget.painter?.runtimeType}' == '_LiveTextIconPainter') || + (widget is Text && + widget.data == 'Scan text'); // Android and Fuchsia when inside a MaterialApp. + }); + } + if (defaultTargetPlatform == TargetPlatform.macOS) { + return find.ancestor( + of: find.text(''), + matching: find.byType(CupertinoDesktopTextSelectionToolbarButton), + ); + } + return find.byWidgetPredicate((Widget widget) { + return widget is Text && (widget.data == '' || widget.data == 'Scan text'); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/localizations_test.dart b/packages/cupertino_ui/test/cupertino/localizations_test.dart new file mode 100644 index 000000000000..affb0e0ed8c9 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/localizations_test.dart @@ -0,0 +1,78 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('English translations exist for all CupertinoLocalization properties', ( + WidgetTester tester, + ) async { + const CupertinoLocalizations localizations = DefaultCupertinoLocalizations(); + + expect(localizations.datePickerYear(2018), isNotNull); + expect(localizations.datePickerMonth(1), isNotNull); + expect(localizations.datePickerDayOfMonth(1), isNotNull); + expect(localizations.datePickerDayOfMonth(1, 1), isNotNull); + expect(localizations.datePickerHour(0), isNotNull); + expect(localizations.datePickerHourSemanticsLabel(0), isNotNull); + expect(localizations.datePickerMinute(0), isNotNull); + expect(localizations.datePickerMinuteSemanticsLabel(0), isNotNull); + expect(localizations.datePickerMediumDate(DateTime.now()), isNotNull); + expect(localizations.datePickerDateOrder, isNotNull); + expect(localizations.datePickerDateTimeOrder, isNotNull); + + expect(localizations.anteMeridiemAbbreviation, isNotNull); + expect(localizations.postMeridiemAbbreviation, isNotNull); + + expect(localizations.timerPickerHour(0), isNotNull); + expect(localizations.timerPickerMinute(0), isNotNull); + expect(localizations.timerPickerSecond(0), isNotNull); + expect(localizations.timerPickerHourLabel(0), isNotNull); + expect(localizations.timerPickerMinuteLabel(0), isNotNull); + expect(localizations.timerPickerSecondLabel(0), isNotNull); + + expect(localizations.modalBarrierDismissLabel, isNotNull); + expect(localizations.searchTextFieldPlaceholderLabel, isNotNull); + expect(localizations.noSpellCheckReplacementsLabel, isNotNull); + expect(localizations.clearButtonLabel, isNotNull); + expect(localizations.cancelButtonLabel, isNotNull); + expect(localizations.backButtonLabel, isNotNull); + + expect(localizations.expansionTileExpandedHint, isNotNull); + expect(localizations.expansionTileCollapsedHint, isNotNull); + expect(localizations.expansionTileExpandedTapHint, isNotNull); + expect(localizations.expansionTileCollapsedTapHint, isNotNull); + expect(localizations.expandedHint, isNotNull); + expect(localizations.collapsedHint, isNotNull); + }); + + testWidgets('CupertinoLocalizations.of throws', (WidgetTester tester) async { + final GlobalKey noLocalizationsAvailable = GlobalKey(); + final GlobalKey localizationsAvailable = GlobalKey(); + + await tester.pumpWidget( + Container( + key: noLocalizationsAvailable, + child: CupertinoApp(home: Container(key: localizationsAvailable)), + ), + ); + + expect( + () => CupertinoLocalizations.of(noLocalizationsAvailable.currentContext!), + throwsA( + isAssertionError.having( + (AssertionError e) => e.message, + 'message', + contains('No CupertinoLocalizations found'), + ), + ), + ); + + expect( + CupertinoLocalizations.of(localizationsAvailable.currentContext!), + isA<CupertinoLocalizations>(), + ); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/magnifier_test.dart b/packages/cupertino_ui/test/cupertino/magnifier_test.dart new file mode 100644 index 000000000000..ebdae3221f8c --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/magnifier_test.dart @@ -0,0 +1,387 @@ +// 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. + +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final basicOffset = Offset( + CupertinoMagnifier.kDefaultSize.width / 2, + CupertinoMagnifier.kDefaultSize.height - CupertinoMagnifier.kMagnifierAboveFocalPoint, + ); + const reasonableTextField = Rect.fromLTRB(0, 100, 200, 200); + final magnifierController = MagnifierController(); + + // Make sure that your gesture in magnifierInfo is within the line in magnifierInfo, + // or else the magnifier status will stay hidden and this will not complete. + Future<void> showCupertinoMagnifier( + BuildContext context, + WidgetTester tester, + ValueNotifier<MagnifierInfo> magnifierInfo, + ) async { + final Future<void> magnifierShown = magnifierController.show( + context: context, + builder: (BuildContext context) => + CupertinoTextMagnifier(controller: magnifierController, magnifierInfo: magnifierInfo), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + await magnifierShown; + } + + tearDown(() async { + magnifierController.removeFromOverlay(); + }); + + group('CupertinoTextEditingMagnifier', () { + testWidgets('Magnifier border color inherits from parent CupertinoTheme', ( + WidgetTester tester, + ) async { + final Key fakeTextFieldKey = UniqueKey(); + + await tester.pumpWidget( + CupertinoApp( + home: SizedBox.square( + key: fakeTextFieldKey, + dimension: 10, + child: CupertinoTheme( + data: const CupertinoThemeData(primaryColor: CupertinoColors.activeGreen), + child: Builder( + builder: (BuildContext context) { + return const Placeholder(); + }, + ), + ), + ), + ), + ); + final BuildContext context = tester.element(find.byType(Placeholder)); + + // Magnifier should be positioned directly over the red square. + final tapPointRenderBox = tester.firstRenderObject(find.byKey(fakeTextFieldKey)) as RenderBox; + final Rect fakeTextFieldRect = + tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size; + + final magnifier = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: fakeTextFieldRect, + fieldBounds: fakeTextFieldRect, + caretRect: fakeTextFieldRect, + // The tap position is dragBelow units below the text field. + globalGesturePosition: fakeTextFieldRect.center, + ), + ); + addTearDown(magnifier.dispose); + + await showCupertinoMagnifier(context, tester, magnifier); + + // Magnifier border color should inherit from CupertinoTheme.of(context).primaryColor. + final Color magnifierBorderColor = tester + .widget<CupertinoMagnifier>(find.byType(CupertinoMagnifier)) + .borderSide + .color; + expect(magnifierBorderColor, equals(CupertinoColors.activeGreen)); + }); + + group('position', () { + Offset getMagnifierPosition(WidgetTester tester) { + final AnimatedPositioned animatedPositioned = tester.firstWidget( + find.byType(AnimatedPositioned), + ); + return Offset(animatedPositioned.left ?? 0, animatedPositioned.top ?? 0); + } + + testWidgets('should be at gesture position if does not violate any positioning rules', ( + WidgetTester tester, + ) async { + final Key fakeTextFieldKey = UniqueKey(); + final Key outerKey = UniqueKey(); + + await tester.pumpWidget( + CupertinoApp( + key: outerKey, + theme: const CupertinoThemeData(primaryColor: Color(0xFF6750A4)), + // The CupertinoApp adds a `CupertinoUserInterfaceLevel` widget, + // which has effect on the color of the background behind the child. + // So enforce a consistent background color that fills the background. + home: ColoredBox( + color: const Color.fromARGB(255, 0, 255, 179), + child: Center( + child: Container( + key: fakeTextFieldKey, + width: 10, + height: 10, + color: const Color(0xFFF44336), + child: const Placeholder(), + ), + ), + ), + ), + ); + final BuildContext context = tester.element(find.byType(Placeholder)); + + // Magnifier should be positioned directly over the red square. + final tapPointRenderBox = + tester.firstRenderObject(find.byKey(fakeTextFieldKey)) as RenderBox; + final Rect fakeTextFieldRect = + tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size; + + final magnifier = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: fakeTextFieldRect, + fieldBounds: fakeTextFieldRect, + caretRect: fakeTextFieldRect, + // The tap position is dragBelow units below the text field. + globalGesturePosition: fakeTextFieldRect.center, + ), + ); + addTearDown(magnifier.dispose); + + await showCupertinoMagnifier(context, tester, magnifier); + + // Should show two red squares; original, and one in the magnifier, + // directly ontop of one another. + await expectLater( + find.byKey(outerKey), + matchesGoldenFile('cupertino_magnifier.position.default.png'), + ); + }); + + testWidgets('should never horizontally be outside of Screen Padding', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(color: Color.fromARGB(7, 0, 129, 90), home: Placeholder()), + ); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is far out of the right side of the app. + globalGesturePosition: Offset(MediaQuery.sizeOf(context).width + 100, 0), + ), + ); + addTearDown(magnifierInfo.dispose); + await showCupertinoMagnifier(context, tester, magnifierInfo); + + // Should be less than the right edge, since we have padding. + expect(getMagnifierPosition(tester).dx, lessThan(MediaQuery.sizeOf(context).width)); + }); + + testWidgets('should have some vertical drag', (WidgetTester tester) async { + final double dragPositionBelowTextField = reasonableTextField.center.dy + 30; + + await tester.pumpWidget( + const CupertinoApp(color: Color.fromARGB(7, 0, 129, 90), home: Placeholder()), + ); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is dragBelow units below the text field. + globalGesturePosition: Offset( + MediaQuery.sizeOf(context).width / 2, + dragPositionBelowTextField, + ), + ), + ); + addTearDown(magnifierInfo.dispose); + await showCupertinoMagnifier(context, tester, magnifierInfo); + + // The magnifier Y should be greater than the text field, since we "dragged" it down. + expect( + getMagnifierPosition(tester).dy + basicOffset.dy, + greaterThan(reasonableTextField.center.dy), + ); + expect( + getMagnifierPosition(tester).dy + basicOffset.dy, + lessThan(dragPositionBelowTextField), + ); + }); + }); + + group('status', () { + testWidgets('should hide if gesture is far below the text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(color: Color.fromARGB(7, 0, 129, 90), home: Placeholder()), + ); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is dragBelow units below the text field. + globalGesturePosition: Offset( + MediaQuery.sizeOf(context).width / 2, + reasonableTextField.top, + ), + ), + ); + addTearDown(magnifierInfo.dispose); + + // Show the magnifier initially, so that we get it in a not hidden state. + await showCupertinoMagnifier(context, tester, magnifierInfo); + + // Move the gesture to one that should hide it. + magnifierInfo.value = MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: magnifierInfo.value.globalGesturePosition + const Offset(0, 100), + ); + await tester.pumpAndSettle(); + + expect(magnifierController.shown, false); + expect(magnifierController.overlayEntry, isNotNull); + }); + + testWidgets('should re-show if gesture moves back up', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(color: Color.fromARGB(7, 0, 129, 90), home: Placeholder()), + ); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // The tap position is dragBelow units below the text field. + globalGesturePosition: Offset( + MediaQuery.sizeOf(context).width / 2, + reasonableTextField.top, + ), + ), + ); + addTearDown(magnifierInfo.dispose); + + // Show the magnifier initially, so that we get it in a not hidden state. + await showCupertinoMagnifier(context, tester, magnifierInfo); + + // Move the gesture to one that should hide it. + magnifierInfo.value = MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: magnifierInfo.value.globalGesturePosition + const Offset(0, 100), + ); + await tester.pumpAndSettle(); + + expect(magnifierController.shown, false); + expect(magnifierController.overlayEntry, isNotNull); + + // Return the gesture to one that shows it. + magnifierInfo.value = MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: Offset( + MediaQuery.sizeOf(context).width / 2, + reasonableTextField.top, + ), + ); + await tester.pumpAndSettle(); + + expect(magnifierController.shown, true); + expect(magnifierController.overlayEntry, isNotNull); + }); + }); + + group('magnificationScale', () { + testWidgets('Throws assertion error when magnificationScale is zero', ( + WidgetTester tester, + ) async { + expect( + () => CupertinoPageScaffold(child: CupertinoMagnifier(magnificationScale: 0)), + throwsAssertionError, + ); + }); + + testWidgets('Throws assertion error when magnificationScale is negative', ( + WidgetTester tester, + ) async { + expect( + () => CupertinoPageScaffold(child: CupertinoMagnifier(magnificationScale: -1)), + throwsAssertionError, + ); + }); + + testWidgets('CupertinoMagnifier magnification scale defaults to 1', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoPageScaffold(child: CupertinoMagnifier())), + ); + + expect( + tester.widget(find.byType(RawMagnifier)), + isA<RawMagnifier>().having( + (RawMagnifier t) => t.magnificationScale, + 'magnificationScale', + 1, + ), + ); + }); + + testWidgets('Magnification scale argument is passed to the RawMagnifier', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold(child: CupertinoMagnifier(magnificationScale: 2)), + ), + ); + + expect( + tester.widget(find.byType(RawMagnifier)), + isA<RawMagnifier>().having( + (RawMagnifier t) => t.magnificationScale, + 'magnificationScale', + 2, + ), + ); + }); + }); + }); + + testWidgets('CupertinoMagnifier does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: SizedBox.shrink(child: CupertinoMagnifier(magnificationScale: 2))), + ), + ); + expect(tester.getSize(find.byType(CupertinoMagnifier)), Size.zero); + }); + + testWidgets('RawMagnifier does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.shrink( + child: RawMagnifier(size: Size.square(2), child: Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(RawMagnifier)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/menu_anchor_test.dart b/packages/cupertino_ui/test/cupertino/menu_anchor_test.dart new file mode 100644 index 000000000000..01bb15044744 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/menu_anchor_test.dart @@ -0,0 +1,6889 @@ +// 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:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + late MenuController controller; + final selected = <Tag>[]; + final opened = <Tag>[]; + final closed = <Tag>[]; + + void onPressed(Tag item) { + selected.add(item); + } + + void onOpen(Tag item) { + opened.add(item); + } + + void onClose(Tag item) { + opened.remove(item); + closed.add(item); + } + + setUp(() { + selected.clear(); + opened.clear(); + closed.clear(); + controller = MenuController(); + }); + + Future<void> Function(int frames) createFramePumper(WidgetTester tester) { + return (int frames) async { + for (var i = 0; i < frames; i += 1) { + await tester.pump(const Duration(milliseconds: 16)); + } + }; + } + + Future<void> changeSurfaceSize(WidgetTester tester, Size size) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + } + + T findMenuPanelAncestor<T extends Widget>(WidgetTester tester) { + return tester.firstWidget<T>( + find.ancestor(of: find.byType(ClipRSuperellipse), matching: find.byType(T)), + ); + } + + double getScale(WidgetTester tester) { + return findMenuPanelAncestor<ScaleTransition>(tester).scale.value; + } + + List<Widget> findMenuChildren(WidgetTester tester) { + return tester + .firstWidget<Column>( + find.descendant(of: find.byType(ClipRSuperellipse), matching: find.byType(Column)), + ) + .children; + } + + List<RenderObject> findAncestorRenderTheaters(RenderObject child) { + final results = <RenderObject>[]; + RenderObject? node = child; + while (node != null) { + if (node.runtimeType.toString() == '_RenderTheater') { + results.add(node); + } + final RenderObject? parent = node.parent; + node = parent is RenderObject ? parent : null; + } + return results; + } + + Matcher sizeCloseTo(Size size, num distance) { + return within( + distance: distance, + from: size, + distanceFunction: (Size a, Size b) { + final double deltaWidth = (a.width - b.width).abs(); + final double deltaHeight = (a.height - b.height).abs(); + return math.max<double>(deltaWidth, deltaHeight); + }, + ); + } + + RenderParagraph? findDescendantParagraph(WidgetTester tester, Finder finder) { + return find + .descendant(of: finder, matching: find.byType(RichText)) + .evaluate() + .first + .renderObject + as RenderParagraph?; + } + + testWidgets("MenuController.isOpen is true when a menu's overlay is shown", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[Text(Tag.a.text)], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(find.text(Tag.a.text), findsOneWidget); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(find.text(Tag.a.text), findsNothing); + }); + + testWidgets('MenuController.open() and .close() toggle overlay visibility', ( + WidgetTester tester, + ) async { + final nestedController = MenuController(); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[Text(Tag.a.text)], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + // Create the menu. The menu is closed, so no menu items should be found in + // the widget tree. + expect(controller.isOpen, isFalse); + expect(find.text(Tag.anchor.text), findsOne); + expect(find.text(Tag.a.text), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isFalse); + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + + // Close the menu + controller.close(); + await tester.pump(); + await tester.pumpAndSettle(); + + // All menus should be closed. + expect(controller.isOpen, isFalse); + expect(find.text(Tag.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsNothing); + }); + + testWidgets('MenuController can be changed', (WidgetTester tester) async { + final controller = MenuController(); + final groupController = MenuController(); + + final newController = MenuController(); + final newGroupController = MenuController(); + + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: CupertinoMenuAnchor( + controller: groupController, + menuChildren: <Widget>[Text(Tag.a.text)], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(find.text(Tag.a.text), findsOneWidget); + expect(controller.isOpen, isTrue); + expect(groupController.isOpen, isTrue); + expect(newController.isOpen, isFalse); + expect(newGroupController.isOpen, isFalse); + + // Swap the controllers. + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: newController, + child: CupertinoMenuAnchor( + controller: newGroupController, + menuChildren: <Widget>[Text(Tag.a.text)], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + expect(find.text(Tag.a.text), findsOneWidget); + expect(controller.isOpen, isFalse); + expect(groupController.isOpen, isFalse); + expect(newController.isOpen, isTrue); + expect(newGroupController.isOpen, isTrue); + + // Close the new controller. + newController.close(); + await tester.pump(); + + expect(newController.isOpen, isFalse); + expect(newGroupController.isOpen, isFalse); + expect(find.text(Tag.a.text), findsNothing); + }); + + testWidgets('MenuController is detached on update', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: const <Widget>[SizedBox.shrink()], + child: const SizedBox.shrink(), + ), + ), + ); + + // Should not throw because the controller is attached to the menu. + controller.closeChildren(); + + await tester.pumpWidget( + const App( + CupertinoMenuAnchor(menuChildren: <Widget>[SizedBox.shrink()], child: SizedBox.shrink()), + ), + ); + + var serializedException = ''; + runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) { + serializedException = exception.toString(); + }); + + expect(serializedException, contains('_anchor != null')); + }); + + testWidgets('MenuController is detached on dispose', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: const <SizedBox>[], + child: const SizedBox(), + ), + ), + ); + + // Should not throw because the controller is attached to the menu. + controller.closeChildren(); + + await tester.pumpWidget(const App(SizedBox())); + + var serializedException = ''; + runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) { + serializedException = exception.toString(); + }); + + expect(serializedException, contains('_anchor != null')); + }); + + // Inspired by a test from the Closure Library: + // https://github.com/google/closure-library/blob/b312823ec5f84239ff1db7526f4a75cba0420a33/closure/goog/ui/menubutton_test.js#L392 + testWidgets('Intents are not blocked by a closed anchor', (WidgetTester tester) async { + final invokedIntents = <Intent>[]; + final anchorFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + + await tester.pumpWidget( + App( + Actions( + actions: <Type, Action<Intent>>{ + DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>( + onInvoke: (DirectionalFocusIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + NextFocusIntent: CallbackAction<NextFocusIntent>( + onInvoke: (NextFocusIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + PreviousFocusIntent: CallbackAction<PreviousFocusIntent>( + onInvoke: (PreviousFocusIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + DismissIntent: CallbackAction<DismissIntent>( + onInvoke: (DismissIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + }, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[Text(Tag.a.text)], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ), + ); + + anchorFocusNode.requestFocus(); + await tester.pump(); + Actions.invoke(anchorFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.up)); + Actions.invoke(anchorFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.down)); + Actions.invoke(anchorFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.left)); + Actions.invoke( + anchorFocusNode.context!, + const DirectionalFocusIntent(TraversalDirection.right), + ); + Actions.invoke(anchorFocusNode.context!, const NextFocusIntent()); + Actions.invoke(anchorFocusNode.context!, const PreviousFocusIntent()); + Actions.invoke(anchorFocusNode.context!, const DismissIntent()); + await tester.pump(); + + expect( + invokedIntents, + equals(const <Intent>[ + DirectionalFocusIntent(TraversalDirection.up), + DirectionalFocusIntent(TraversalDirection.down), + DirectionalFocusIntent(TraversalDirection.left), + DirectionalFocusIntent(TraversalDirection.right), + NextFocusIntent(), + PreviousFocusIntent(), + DismissIntent(), + ]), + ); + }); + + testWidgets('Actions that wrap the menu are invoked by the anchor and the overlay', ( + WidgetTester tester, + ) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + + var invokedAnchor = false; + var invokedOverlay = false; + + await tester.pumpWidget( + App( + Actions( + actions: <Type, Action<Intent>>{ + VoidCallbackIntent: CallbackAction<VoidCallbackIntent>( + onInvoke: (VoidCallbackIntent intent) { + intent.callback(); + return null; + }, + ), + }, + child: CupertinoMenuAnchor( + childFocusNode: anchorFocusNode, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + Actions.invoke( + anchorFocusNode.context!, + VoidCallbackIntent(() { + invokedAnchor = true; + }), + ); + Actions.invoke( + aFocusNode.context!, + VoidCallbackIntent(() { + invokedOverlay = true; + }), + ); + + await tester.pump(); + + expect(invokedAnchor, isTrue); + expect(invokedOverlay, isTrue); + }); + + testWidgets('DismissMenuAction closes menu', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + // Test from the anchor. + controller.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + + anchorFocusNode.requestFocus(); + await tester.pump(); + + const ActionDispatcher().invokeAction( + DismissMenuAction(controller: controller), + const DismissIntent(), + anchorFocusNode.context, + ); + await tester.pump(); + + expect(controller.isOpen, isFalse); + + // Test from the menu item. + controller.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + + aFocusNode.requestFocus(); + await tester.pump(); + + const ActionDispatcher().invokeAction( + DismissMenuAction(controller: controller), + const DismissIntent(), + aFocusNode.context, + ); + + await tester.pump(); + + expect(controller.isOpen, isFalse); + }); + + testWidgets('Menus close and consume tap when consumeOutsideTap is true', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoButton( + child: Text(Tag.outside.text), + onPressed: () { + selected.add(Tag.outside); + }, + ), + CupertinoMenuAnchor( + consumeOutsideTaps: true, + onOpen: () { + onOpen(Tag.anchor); + }, + onClose: () { + onClose(Tag.anchor); + }, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ], + ), + ), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + // Doesn't consume tap when the menu is closed. + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + + expect(selected, equals(<NestedTag>[Tag.outside])); + selected.clear(); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, equals(<NestedTag>[Tag.anchor])); + + expect(closed, isEmpty); + expect(selected, equals(<NestedTag>[Tag.anchor])); + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + + // When the menu is open, outside taps are consumed. As a result, tapping + // outside the menu will close it and not select the outside button. + expect(selected, isEmpty); + expect(opened, isEmpty); + expect(closed, equals(<NestedTag>[Tag.anchor])); + + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgets('Menus close and do not consume tap when consumeOutsideTaps is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoButton( + child: Text(Tag.outside.text), + onPressed: () { + selected.add(Tag.outside); + }, + ), + CupertinoMenuAnchor( + onOpen: () { + onOpen(Tag.anchor); + }, + onClose: () { + onClose(Tag.anchor); + }, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ], + ), + ), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + + // Doesn't consume tap when the menu is closed. + expect(selected, equals(<Tag>[Tag.outside])); + + selected.clear(); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(opened, equals(<Tag>[Tag.anchor])); + expect(closed, isEmpty); + expect(selected, equals(<Tag>[Tag.anchor])); + + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + // Because consumeOutsideTaps is false, outsideButton is expected to + // receive a tap. + expect(opened, isEmpty); + expect(closed, equals(<Tag>[Tag.anchor])); + expect(selected, equals(<Tag>[Tag.outside])); + + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgets('onOpen is called when the menu starts opening', (WidgetTester tester) async { + var opened = 0; + var closed = 0; + await tester.pumpWidget( + CupertinoApp( + home: CupertinoMenuAnchor( + controller: controller, + onOpen: () { + opened += 1; + }, + onClose: () { + closed += 1; + }, + menuChildren: const <Widget>[], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + // onOpen is called immediately when the menu starts opening. + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, equals(1)); + + await tester.pump(const Duration(milliseconds: 50)); + + // Start closing the menu. + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + // Menu is still open because closing animation hasn't finished. + expect(opened, equals(1)); + expect(closed, equals(0)); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + // onOpen doesn't get called again because the menu never closed. + expect(opened, equals(1)); + expect(closed, equals(0)); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pumpAndSettle(); + + expect(opened, equals(1)); + expect(closed, equals(1)); + + controller.open(); + await tester.pump(); + + expect(opened, equals(2)); + expect(closed, equals(1)); + + await tester.pumpAndSettle(); + + expect(opened, equals(2)); + expect(closed, equals(1)); + }); + + testWidgets('onClose is called when the menu finishes closing', (WidgetTester tester) async { + var closed = true; + await tester.pumpWidget( + CupertinoApp( + home: CupertinoMenuAnchor( + controller: controller, + onOpen: () { + closed = false; + }, + onClose: () { + closed = true; + }, + menuChildren: const <Widget>[], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(closed, isFalse); + + await tester.pumpAndSettle(); + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(closed, isFalse); + + await tester.pumpAndSettle(); + + expect(closed, isTrue); + + controller.open(); + await tester.pump(); + + expect(closed, isFalse); + + controller.close(); + await tester.pump(); + + expect(closed, isTrue); + }); + + test('debugFillProperties', () { + final builder = DiagnosticPropertiesBuilder(); + final menuAnchor = CupertinoMenuAnchor( + menuChildren: const <Text>[Text('Menu Item')], + constraints: const BoxConstraints.tightFor(width: 200), + overlayPadding: const EdgeInsets.all(12), + useRootOverlay: true, + enableSwipe: false, + consumeOutsideTaps: true, + controller: MenuController(), + onOpen: () {}, + onClose: () {}, + constrainCrossAxis: true, + child: const Text('Anchor Child'), + ); + + menuAnchor.debugFillProperties(builder); + + final List<String> descriptions = builder.properties + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + descriptions, + containsAll(<dynamic>[ + 'constraints: BoxConstraints(w=200.0, 0.0<=h<=Infinity)', + 'constrains cross axis', + 'swipe disabled', + 'consumes outside taps', + 'uses root overlay', + 'overlayPadding: EdgeInsets.all(12.0)', + ]), + ); + }); + + testWidgets('Tab traversal is not handled', (WidgetTester tester) async { + final bFocusNode = FocusNode(); + final bbFocusNode = FocusNode(); + addTearDown(bFocusNode.dispose); + addTearDown(bbFocusNode.dispose); + final invokedIntents = <Intent>[]; + final defaultTraversalShortcuts = <ShortcutActivator, Intent>{ + LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(), + }; + + await tester.pumpWidget( + App( + Row( + children: <Widget>[ + Actions( + actions: <Type, Action<Intent>>{ + NextFocusIntent: CallbackAction<NextFocusIntent>( + onInvoke: (NextFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + PreviousFocusIntent: CallbackAction<PreviousFocusIntent>( + onInvoke: (PreviousFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + }, + child: Column( + children: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuAnchor( + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.a.text)), + Shortcuts( + shortcuts: defaultTraversalShortcuts, + child: CupertinoMenuItem( + onPressed: () {}, + focusNode: bbFocusNode, + child: Text(Tag.b.b.text), + ), + ), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.c.text)), + ], + child: Shortcuts( + shortcuts: defaultTraversalShortcuts, + child: AnchorButton(Tag.b, focusNode: bFocusNode), + ), + ), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.c.text)), + ], + ), + ), + ], + ), + ), + ); + + bFocusNode.requestFocus(); + await tester.pump(); + + expect(primaryFocus, equals(bFocusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(primaryFocus, equals(bFocusNode)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + expect(primaryFocus, equals(bFocusNode)); + + // Open and move focus to nested menu + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + bbFocusNode.requestFocus(); + await tester.pump(); + + expect(primaryFocus, equals(bbFocusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(primaryFocus, equals(bbFocusNode)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + expect(primaryFocus, equals(bbFocusNode)); + expect( + invokedIntents, + equals(const <Intent>[ + NextFocusIntent(), + PreviousFocusIntent(), + NextFocusIntent(), + PreviousFocusIntent(), + ]), + ); + }); + + testWidgets('Menu closes on view size change', (WidgetTester tester) async { + var opened = false; + var closed = false; + + Widget build(Size size) { + return Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(size: size), + child: App( + CupertinoMenuAnchor( + onOpen: () { + opened = true; + closed = false; + }, + onClose: () { + opened = false; + closed = true; + }, + controller: controller, + menuChildren: <Widget>[Text(Tag.a.text)], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(build(const Size(800, 600))); + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, isTrue); + expect(closed, isFalse); + + const smallSize = Size(200, 200); + await tester.pumpWidget(build(smallSize)); + await tester.pump(); + + expect(opened, isFalse); + expect(closed, isTrue); + }); + + testWidgets('Menu closes on ancestor scroll', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + App( + SingleChildScrollView( + controller: scrollController, + child: CupertinoMenuAnchor( + onOpen: () { + onOpen(Tag.anchor); + }, + onClose: () { + onClose(Tag.anchor); + }, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.c.text)), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.d.text)), + ], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, isNotEmpty); + expect(closed, isEmpty); + opened.clear(); + + scrollController.jumpTo(1000); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isNotEmpty); + }); + + testWidgets('Menus do not close on root menu internal scroll', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/122168. + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + var rootOpened = false; + const largeButtonConstraints = BoxConstraints.tightFor(width: 200, height: 300); + + await tester.pumpWidget( + App( + SingleChildScrollView( + controller: scrollController, + child: Container( + height: 900, + alignment: Alignment.topLeft, + child: CupertinoMenuAnchor( + onOpen: () { + rootOpened = true; + }, + onClose: () { + rootOpened = false; + }, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + constraints: largeButtonConstraints, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + onPressed: () {}, + constraints: largeButtonConstraints, + child: Text(Tag.b.text), + ), + CupertinoMenuItem( + onPressed: () {}, + constraints: largeButtonConstraints, + child: Text(Tag.c.text), + ), + CupertinoMenuItem( + onPressed: () {}, + constraints: largeButtonConstraints, + child: Text(Tag.d.text), + ), + ], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(rootOpened, true); + + // Hover the first submenu anchor. + final pointer = TestPointer(tester.nextPointer, ui.PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(tester.getCenter(find.text(Tag.a.text)))); + await tester.pump(); + + // Menus do not close on internal scroll. + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0))); + await tester.pump(); + expect(rootOpened, true); + + // Menus close on external scroll. + scrollController.jumpTo(700); + await tester.pump(); + await tester.pumpAndSettle(); + expect(rootOpened, false); + }); + + // Copied from MenuAnchor tests. + // + // Regression test for https://github.com/flutter/flutter/issues/157606. + testWidgets('Menu builder rebuilds when isOpen state changes', (WidgetTester tester) async { + var isOpen = false; + var openCount = 0; + var closeCount = 0; + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + builder: (BuildContext context, MenuController controller, Widget? child) { + isOpen = controller.isOpen; + return CupertinoButton.filled( + child: Text(isOpen ? 'close' : 'open'), + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + ); + }, + onOpen: () => openCount++, + onClose: () => closeCount++, + ), + ), + ); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 0); + expect(closeCount, 0); + + await tester.tap(find.text('open')); + await tester.pump(); + + expect(find.text('close'), findsOneWidget); + expect(isOpen, true); + expect(openCount, 1); + expect(closeCount, 0); + + await tester.tap(find.text('close')); + await tester.pump(); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 1); + expect(closeCount, 1); + }); + + // Copied from [MenuAnchor] tests. + // + // Regression test for https://github.com/flutter/flutter/issues/155034. + testWidgets('Content is shown in the root overlay when useRootOverlay is true', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + final overlayKey = UniqueKey(); + late final OverlayEntry overlayEntry; + + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + await tester.pumpWidget( + App( + Overlay( + key: overlayKey, + initialEntries: <OverlayEntry>[ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: CupertinoMenuAnchor( + useRootOverlay: true, + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + ], + child: const AnchorButton(Tag.anchor), + ), + ); + }, + ), + ], + ), + ), + ); + + expect(find.text(Tag.a.text), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(Tag.a.text), findsOneWidget); + + // Expect two overlays: the root overlay created by WidgetsApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Iterable<Overlay> overlays = tester.widgetList<Overlay>(find.byType(Overlay)); + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + final Overlay rootOverlay = overlays.firstWhere((Overlay overlay) => overlay != nonRootOverlay); + + final RenderObject menuTheater = findAncestorRenderTheaters( + tester.renderObject(find.text(Tag.a.text)), + ).first; + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect(menuTheater, tester.renderObject(find.byWidget(rootOverlay))); + }); + + testWidgets('Content is shown in the nearest ancestor overlay when useRootOverlay is false', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + final overlayKey = UniqueKey(); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + await tester.pumpWidget( + App( + Overlay( + key: overlayKey, + initialEntries: <OverlayEntry>[ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + ], + child: const AnchorButton(Tag.anchor), + ), + ); + }, + ), + ], + ), + ), + ); + + expect(find.text(Tag.a.text), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(Tag.a.text), findsOneWidget); + + // Expect two overlays: the root overlay created by WidgetsApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + final RenderObject menuTheater = findAncestorRenderTheaters( + tester.renderObject(find.text(Tag.a.text)), + ).first; + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the nearest overlay. + expect(menuTheater, tester.renderObject(find.byWidget(nonRootOverlay))); + }); + + testWidgets('Swiping scales the menu', (WidgetTester tester) async { + final Future<void> Function(int frames) pumpFrames = createFramePumper(tester); + await changeSurfaceSize(tester, const Size(2000, 2000)); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Offset startPosition = tester.getCenter(find.text(Tag.a.text)); + await gesture.down(startPosition); + await tester.pump(); + + final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse)); + + // Check that all corners of the menu are not scaled + await gesture.moveTo(menuRect.topLeft); + await tester.pump(); + await pumpFrames(10); + expect(getScale(tester), closeTo(1.0, 0.01)); + + await gesture.moveTo(menuRect.topRight); + await tester.pump(); + await pumpFrames(10); + expect(getScale(tester), closeTo(1.0, 0.01)); + + await gesture.moveTo(menuRect.bottomLeft); + await tester.pump(); + await pumpFrames(10); + expect(getScale(tester), closeTo(1.0, 0.01)); + + await gesture.moveTo(menuRect.bottomRight); + await tester.pump(); + await pumpFrames(10); + expect(getScale(tester), closeTo(1.0, 0.01)); + + // Move outside the menu bounds to trigger scaling + await gesture.moveTo(menuRect.topLeft - const Offset(50, 50)); + await pumpFrames(3); + + double topLeftScale = getScale(tester); + expect(topLeftScale, closeTo(0.98, 0.1)); + + await pumpFrames(3); + + topLeftScale = getScale(tester); + expect(topLeftScale, closeTo(0.96, 0.1)); + + await pumpFrames(3); + + topLeftScale = getScale(tester); + expect(topLeftScale, closeTo(0.94, 0.1)); + + await gesture.moveTo(menuRect.bottomRight + const Offset(50, 50)); + await pumpFrames(10); + + // Check that scale is roughly the same around the menu + expect(getScale(tester), closeTo(topLeftScale, 0.05)); + + // Test maximum distance scaling + await gesture.moveTo(menuRect.topLeft - const Offset(200, 200)); + await pumpFrames(20); + + // Check that the minimum scale is 0.8 (20% reduction) + expect(getScale(tester), closeTo(0.8, 0.1)); + + await gesture.moveTo(menuRect.bottomRight + const Offset(200, 200)); + await pumpFrames(10); + + expect(getScale(tester), closeTo(0.8, 0.1)); + + await gesture.up(); + await tester.pump(); + }); + + testWidgets('Swiping minimum scale is 80 percent', (WidgetTester tester) async { + final Future<void> Function(int frames) pumpFrames = createFramePumper(tester); + await changeSurfaceSize(tester, const Size(2000, 2000)); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Offset startPosition = tester.getCenter(find.text(Tag.a.text)); + await gesture.down(startPosition); + await tester.pump(); + + final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse)); + + // Move far outside menu bounds to scale to minimum + await gesture.moveTo(menuRect.topLeft - const Offset(500, 500)); + await pumpFrames(30); + + // Verify minimum scale is exactly 0.8 (80%) + expect(getScale(tester), moreOrLessEquals(0.8, epsilon: 0.01)); + + // Try different far positions to ensure consistent minimum scale + await gesture.moveTo(menuRect.bottomRight + const Offset(1000, 1000)); + await pumpFrames(30); + + expect(getScale(tester), moreOrLessEquals(0.8, epsilon: 0.01)); + + await gesture.up(); + await tester.pump(); + }); + + testWidgets('Menu scale rebounds to full size when swipe returns to menu bounds', ( + WidgetTester tester, + ) async { + final Future<void> Function(int frames) pumpFrames = createFramePumper(tester); + + await changeSurfaceSize(tester, const Size(2000, 2000)); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byType(ClipRSuperellipse)); + + await gesture.down(child.bottomRight - const Offset(5, 5)); + await pumpFrames(15); + + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01)); + + await gesture.moveBy(const Offset(100, 100)); + await pumpFrames(40); + + expect(getScale(tester), closeTo(0.85, 0.1)); + + await gesture.moveBy(-const Offset(100, 100)); + await pumpFrames(40); + + expect(getScale(tester), closeTo(1.0, 0.01)); + + await gesture.moveTo(child.topLeft + const Offset(5, 5)); + await pumpFrames(15); + + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01)); + + await gesture.moveBy(const Offset(-100, -100)); + await pumpFrames(15); + + expect(getScale(tester), closeTo(0.85, 0.1)); + + await gesture.moveTo(child.center); + await pumpFrames(40); + + expect(getScale(tester), closeTo(1.0, 0.01)); + }); + + testWidgets('Menu scale rebounds to full size when swipe gesture ends', ( + WidgetTester tester, + ) async { + final Future<void> Function(int frames) pumpFrames = createFramePumper(tester); + + await changeSurfaceSize(tester, const Size(2000, 2000)); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Offset startPosition = tester.getCenter(find.text(Tag.a.text)); + await gesture.down(startPosition); + await tester.pump(); + + final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse)); + + // Start with full scale + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01)); + + // Move outside menu bounds to trigger scaling + await gesture.moveTo(menuRect.topLeft - const Offset(100, 100)); + await pumpFrames(15); + + final double scaledValue = getScale(tester); + expect(scaledValue, lessThan(1.0)); + expect(scaledValue, greaterThan(0.8)); + + // End the gesture while still outside menu bounds + await gesture.up(); + await pumpFrames(25); + + // Verify scale rebounds back to full size after gesture ends + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01)); + + // Test from a different scaled position + final TestGesture gesture2 = await tester.createGesture(); + addTearDown(gesture2.removePointer); + + await gesture2.down(startPosition); + await tester.pump(); + + // Move to maximum scale distance + await gesture2.moveTo(menuRect.bottomRight + const Offset(200, 200)); + await pumpFrames(30); + + // Should be at minimum scale + expect(getScale(tester), closeTo(0.8, 0.01)); + + // End gesture at maximum distance + await gesture2.up(); + await tester.pump(); + + // Allow rebound animation to complete + await pumpFrames(25); + + // Should rebound to full scale + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.01)); + }); + + testWidgets('Swipe can be disabled', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(1000, 1000)); + Widget buildWidget({required bool enableSwipe}) { + return App( + CupertinoMenuAnchor( + controller: controller, + enableSwipe: enableSwipe, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ); + } + + await tester.pumpWidget(buildWidget(enableSwipe: false)); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Offset startPosition = tester.getCenter(find.text(Tag.a.text)); + await gesture.down(startPosition); + await tester.pump(); + + final Rect menuRect = tester.getRect(find.byType(ClipRSuperellipse)); + // Move far outside the menu bounds + await gesture.moveTo(menuRect.topLeft - const Offset(200, 200)); + await tester.pump(); + + // Scale should remain 1.0 when swiping is disabled + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.001)); + + await gesture.moveTo(menuRect.bottomRight + const Offset(200, 200)); + await tester.pump(); + + // Scale should still remain 1.0 + expect(getScale(tester), moreOrLessEquals(1.0, epsilon: 0.001)); + + // Move to menu item and verify no special swipe behavior occurs + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(const Duration(milliseconds: 500)); + + // Menu should still be open since swipe is disabled + expect(controller.isOpen, isTrue); + + await gesture.up(); + await tester.pump(); + }); + + testWidgets('Mobile menu width (< 768 px)', (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(765, 900)), + child: App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse)); + expect(popupSize.width, moreOrLessEquals(250, epsilon: 0.1)); + }); + + testWidgets('Tablet menu width (>= 768 px)', (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(768, 400)), + child: App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse)); + expect(popupSize.width, moreOrLessEquals(262, epsilon: 0.1)); + }); + + testWidgets('Large text mode mobile menu width (< 768 px)', (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(765, 900), textScaleFactor: 1 + 11 / 17), + child: App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse)); + expect(popupSize.width, moreOrLessEquals(370, epsilon: 0.1)); + }); + + testWidgets('Large text mode tablet menu width (>= 768 px)', (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(768, 400), textScaleFactor: 1 + 11 / 17), + child: App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + final Size popupSize = tester.getSize(find.byType(ClipRSuperellipse)); + expect(popupSize.width, moreOrLessEquals(343, epsilon: 0.1)); + }); + + testWidgets('Menu scale animation respects reduceMotion', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + controller.open(); + await tester.pump(); + + final double baselineScale = getScale(tester); + expect(baselineScale, lessThan(1)); + + controller.close(); + await tester.pumpAndSettle(); + + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(reduceMotion: true); + addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue); + + await tester.pump(); + + controller.open(); + await tester.pump(); + + final double reducedScale = getScale(tester); + expect(reducedScale, moreOrLessEquals(1, epsilon: 0.01)); + }); + + testWidgets('Menu fade animation is disabled when animations are off', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + controller.open(); + await tester.pump(); + + final FadeTransition baselineFade = tester.widget<FadeTransition>( + find.ancestor(of: find.byType(ClipRSuperellipse), matching: find.byType(FadeTransition)), + ); + + expect(baselineFade.opacity.value, lessThan(0.5)); + + controller.close(); + await tester.pump(); + + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(disableAnimations: true); + addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue); + await tester.pump(); + + controller.open(); + await tester.pump(); + + final FadeTransition disabledFade = tester.widget<FadeTransition>( + find.ancestor(of: find.byType(ClipRSuperellipse), matching: find.byType(FadeTransition)), + ); + expect(disabledFade.opacity.value, moreOrLessEquals(1, epsilon: 0.01)); + }); + + group('Focus', () { + testWidgets( + '[Browser] Focus wraps on all platforms', + skip: !isBrowser, // [intended] Web wraps focus regardless of platform. + (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final lastItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(lastItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem( + onPressed: () {}, + focusNode: lastItemFocusNode, + child: Text(Tag.c.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + firstItemFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + + // Arrow up from first item should wrap to last item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, lastItemFocusNode); + + // Arrow down from last item should wrap to first item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + }, + ); + + testWidgets( + '[Not Browser] Focus wraps when traversing with arrow keys on non-Apple platforms', + skip: isBrowser, // [intended] Browser behavior is tested above. + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final lastItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(lastItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem( + onPressed: () {}, + focusNode: lastItemFocusNode, + child: Text(Tag.c.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + firstItemFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + + // Arrow up from first item should wrap to last item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, lastItemFocusNode); + + // Arrow down from last item should wrap to first item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + }, + ); + + testWidgets( + '[Not Browser] Focus does not wrap when traversing with arrow keys on Apple platforms', + skip: isBrowser, // [intended] Browser behavior is tested above. + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final lastItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(lastItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem( + onPressed: () {}, + focusNode: lastItemFocusNode, + child: Text(Tag.c.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + firstItemFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + + // Arrow up from first item should not move focus on Apple platforms + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + + lastItemFocusNode.requestFocus(); + await tester.pump(); + + // Arrow down from last item should not move focus on Apple platforms + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, lastItemFocusNode); + }, + ); + + testWidgets('Menu items can be activated with enter key', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + var itemActivated = false; + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + focusNode: aFocusNode, + onPressed: () { + itemActivated = true; + }, + child: Text(Tag.a.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + aFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + expect(itemActivated, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(itemActivated, isTrue); + }); + + testWidgets('Menu closes with escape key', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + aFocusNode.requestFocus(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(FocusManager.instance.primaryFocus, aFocusNode); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(controller.isOpen, isFalse); + }); + + testWidgets('Up and down arrow keys move focus between menu items', ( + WidgetTester tester, + ) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + final bFocusNode = FocusNode(); + final cFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + addTearDown(bFocusNode.dispose); + addTearDown(cFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)), + CupertinoMenuItem(onPressed: () {}, focusNode: bFocusNode, child: Text(Tag.b.text)), + CupertinoMenuItem(onPressed: () {}, focusNode: cFocusNode, child: Text(Tag.c.text)), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + aFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + + // Arrow down should move to next item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, bFocusNode); + + // Arrow down should move to next item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, cFocusNode); + + // Arrow up should move to previous item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, bFocusNode); + + // Arrow up should move to previous item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + }); + + testWidgets('Focus returns to button after menu closes', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + childFocusNode: anchorFocusNode, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + anchorFocusNode.requestFocus(); + await tester.pump(); + await tester.pumpAndSettle(); + + controller.open(); + await tester.pump(); + + aFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + + // Close menu with escape + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(FocusManager.instance.primaryFocus, anchorFocusNode); + }); + + testWidgets('Left and right arrow keys do not move focus in menu', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final aFocusNode = FocusNode(); + final bFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + addTearDown(bFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: aFocusNode, child: Text(Tag.a.text)), + CupertinoMenuItem(onPressed: () {}, focusNode: bFocusNode, child: Text(Tag.b.text)), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + aFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + + // Left arrow should not change focus + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + + // Right arrow should not change focus + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + }); + + testWidgets('Down key after menu opens focuses the first menu item', ( + WidgetTester tester, + ) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final secondItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(secondItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + onPressed: () {}, + focusNode: secondItemFocusNode, + child: Text(Tag.b.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + // Focus the anchor button first + anchorFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, anchorFocusNode); + + // Open the menu + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + // Press down arrow key - should focus first menu item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + }); + + testWidgets('Up key after open focuses the last menu item', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final lastItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(lastItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem( + onPressed: () {}, + focusNode: lastItemFocusNode, + child: Text(Tag.c.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + // Focus the anchor button first + anchorFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, anchorFocusNode); + + // Open the menu + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + // Press up arrow key - should focus last menu item + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, lastItemFocusNode); + }); + + testWidgets('Home key moves focus to first menu item', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final middleItemFocusNode = FocusNode(); + final lastItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(middleItemFocusNode.dispose); + addTearDown(lastItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + onPressed: () {}, + focusNode: middleItemFocusNode, + child: Text(Tag.b.text), + ), + CupertinoMenuItem( + onPressed: () {}, + focusNode: lastItemFocusNode, + child: Text(Tag.c.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + lastItemFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, lastItemFocusNode); + + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + }); + + testWidgets('End key moves focus to last menu item', (WidgetTester tester) async { + final anchorFocusNode = FocusNode(); + final firstItemFocusNode = FocusNode(); + final middleItemFocusNode = FocusNode(); + final lastItemFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(firstItemFocusNode.dispose); + addTearDown(middleItemFocusNode.dispose); + addTearDown(lastItemFocusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + focusNode: firstItemFocusNode, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + onPressed: () {}, + focusNode: middleItemFocusNode, + child: Text(Tag.b.text), + ), + CupertinoMenuItem( + onPressed: () {}, + focusNode: lastItemFocusNode, + child: Text(Tag.c.text), + ), + ], + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + firstItemFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, firstItemFocusNode); + + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, lastItemFocusNode); + }); + }); + + group('Layout', () { + /// Returns the rects of the menu's contents. If [clipped] is true, the + /// rect is taken after UnconstrainedBox clips its contents. + List<Rect> collectOverlays({bool clipped = true}) { + final menuRects = <Rect>[]; + final Finder finder = clipped + ? find.byType(UnconstrainedBox) + : find.byType(ClipRSuperellipse); + for (final Element candidate in finder.evaluate().toList()) { + final box = candidate.renderObject! as RenderBox; + final Offset topLeft = box.localToGlobal(box.size.topLeft(Offset.zero)); + menuRects.add(topLeft & box.size); + } + return menuRects; + } + + testWidgets('LTR menu default layout', (WidgetTester tester) async { + const size = Size(2000, 2000); + await changeSurfaceSize(tester, size); + + Widget buildApp({required AlignmentGeometry alignment}) { + return App( + CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + constraints: BoxConstraints.tight(const Size(50, 50)), + menuChildren: <Widget>[ + Container( + width: 50, + height: 50, + color: const Color(0xFF0000FF), + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton( + Tag.anchor, + constraints: BoxConstraints.tightFor(width: 50, height: 50), + ), + ), + alignment: alignment, + ); + } + + await tester.pumpWidget(buildApp(alignment: Alignment.topCenter)); + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) { + for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) { + await tester.pumpWidget(buildApp(alignment: Alignment(horizontal, vertical))); + final double x = switch (horizontal) { + < -0.2 => -1.0, + > 0.2 => 1.0, + _ => 0.0, + }; + + final y = vertical < 0.2 ? 1.0 : -1.0; + final alignment = Alignment(x, y); + final menuAlignment = Alignment(x, -y); + final ui.Rect anchorRect = tester.getRect( + find.widgetWithText(CupertinoButton, Tag.anchor.text), + ); + + final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text)); + final ui.Offset position = alignment.withinRect(anchorRect).translate(0, 8 * y); + + expect( + position, + offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01), + reason: + 'Anchor alignment: ${Alignment(horizontal, vertical)} \n' + 'Menu rect: $surface \n', + ); + } + } + }); + + testWidgets('RTL menu default layout', (WidgetTester tester) async { + const size = Size(2000, 2000); + await changeSurfaceSize(tester, size); + + Widget buildApp({required AlignmentGeometry alignment}) { + return App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + constraints: BoxConstraints.tight(const Size(50, 50)), + menuChildren: <Widget>[ + Container( + width: 50, + height: 50, + color: const Color(0xFF0000FF), + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton( + Tag.anchor, + constraints: BoxConstraints.tightFor(width: 50, height: 50), + ), + ), + alignment: alignment, + ); + } + + await tester.pumpWidget(buildApp(alignment: Alignment.topCenter)); + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) { + for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) { + await tester.pumpWidget(buildApp(alignment: Alignment(horizontal, vertical))); + final double x = switch (horizontal) { + < -0.2 => -1.0, + > 0.2 => 1.0, + _ => 0.0, + }; + + final y = vertical < 0.2 ? 1.0 : -1.0; + final alignment = Alignment(x, y); + final menuAlignment = Alignment(x, -y); + final ui.Rect anchorRect = tester.getRect( + find.widgetWithText(CupertinoButton, Tag.anchor.text), + ); + + final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text)); + final ui.Offset position = alignment.withinRect(anchorRect).translate(0, 8 * y); + + expect( + position, + offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01), + reason: + 'Anchor alignment: ${Alignment(horizontal, vertical)} \n' + 'Menu rect: $surface \n', + ); + } + } + }); + + testWidgets('LTR menu positioned layout', (WidgetTester tester) async { + const size = Size(2000, 2000); + await changeSurfaceSize(tester, size); + + Widget buildApp() { + return App( + textDirection: TextDirection.ltr, + CupertinoMenuAnchor( + controller: controller, + overlayPadding: EdgeInsets.zero, + constraints: BoxConstraints.tight(const Size(50, 50)), + menuChildren: <Widget>[ + Container( + width: 50, + height: 50, + color: const Color(0xFF0000FF), + child: Text(Tag.a.text), + ), + ], + child: const Stack( + children: <Widget>[ColoredBox(color: Color(0xFF00FF00), child: SizedBox.expand())], + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) { + for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) { + final double x = switch (horizontal) { + < -0.2 => -1.0, + > 0.2 => 1.0, + _ => 0.0, + }; + + final y = vertical < 0.2 ? 1.0 : -1.0; + final ui.Offset position = Alignment(x, y).alongSize(size); + + controller.open(position: position); + await tester.pump(); + + final menuAlignment = Alignment(x, y); + final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text)); + + expect( + position, + offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01), + reason: + 'Anchor alignment: ${Alignment(horizontal, vertical)} \n' + 'Menu rect: $surface \n', + ); + } + } + }); + + testWidgets('RTL menu positioned layout', (WidgetTester tester) async { + const size = Size(2000, 2000); + await changeSurfaceSize(tester, size); + + Widget buildApp() { + return App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + controller: controller, + overlayPadding: EdgeInsets.zero, + constraints: BoxConstraints.tight(const Size(50, 50)), + menuChildren: <Widget>[ + Container( + width: 50, + height: 50, + color: const Color(0xFF0000FF), + child: Text(Tag.a.text), + ), + ], + child: const Stack( + children: <Widget>[ColoredBox(color: Color(0xFF00FF00), child: SizedBox.expand())], + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + + for (var horizontal = -0.8; horizontal <= 0.8; horizontal += 0.15) { + for (var vertical = -0.8; vertical <= 0.8; vertical += 0.15) { + final double x = switch (horizontal) { + < -0.2 => -1.0, + > 0.2 => 1.0, + _ => 0.0, + }; + + final y = vertical < 0.2 ? 1.0 : -1.0; + final ui.Offset position = Alignment(x, y).alongSize(size); + + controller.open(position: position); + await tester.pump(); + + final menuAlignment = Alignment(x, y); + final ui.Rect surface = tester.getRect(find.widgetWithText(Container, Tag.a.text)); + + expect( + position, + offsetMoreOrLessEquals(menuAlignment.withinRect(surface), epsilon: 0.01), + reason: + 'Anchor alignment: ${Alignment(horizontal, vertical)} \n' + 'Menu rect: $surface \n', + ); + } + } + }); + + testWidgets('LTR constrained menu placement with unconstrained crossaxis', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(200, 200)); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[Container(color: const Color(0xFFFF0000), height: 40)], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final List<ui.Rect> overlays = collectOverlays(clipped: false); + expect(overlays, hasLength(1)); + + // The unclipped menu surface can grow beyond the screen. Since this + // example is LTR, the left edge of the screen should be flush with the + // left edge of the menu surface. + // + // In this demo, the screen width is 200, the surface width is 250, and + // the content width is 300. The surface width should equal 250, starting + // the left edge (0px). + expect( + overlays.first, + rectMoreOrLessEquals(const Rect.fromLTRB(-0.0, 132.5, 262.0, 172.5), epsilon: 0.01), + ); + }); + + testWidgets('RTL constrained menu placement with unconstrained crossaxis', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(200, 200)); + + await tester.pumpWidget( + App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[Container(color: const Color(0xFFFF0000), height: 40)], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final List<ui.Rect> overlays = collectOverlays(clipped: false); + expect(overlays, hasLength(1)); + + // The unclipped menu surface can grow beyond the screen. Since we are + // RTL, the right edge of the screen should be flush with the right edge + // of the menu surface. + // + // In this demo, the screen width is 200, the surface width is 250, and + // the content width is 300. The surface width should equal 250, ending + // at the right edge (200px). + expect( + overlays.first, + rectMoreOrLessEquals(const Rect.fromLTRB(-62.0, 132.5, 200.0, 172.5), epsilon: 0.01), + ); + }); + + testWidgets('LTR constrained menu placement with constrained crossaxis', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(200, 200)); + const constraints = BoxConstraints.tightFor(width: 300, height: 40); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + constrainCrossAxis: true, + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + constraints: constraints, + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton(Tag.anchor, constraints: constraints), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final List<ui.Rect> overlays = collectOverlays(clipped: false); + + expect(overlays, hasLength(1)); + + // The unclipped menu surface will not grow beyond the screen. + expect( + overlays.first, + rectMoreOrLessEquals(const Rect.fromLTRB(0.0, 132.5, 200.0, 172.5), epsilon: 0.01), + ); + }); + + testWidgets('RTL constrained menu placement with constrained crossaxis', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(200, 200)); + const constraints = BoxConstraints.tightFor(width: 300, height: 40); + + await tester.pumpWidget( + App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + constrainCrossAxis: true, + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + constraints: constraints, + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton(Tag.anchor, constraints: constraints), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pumpAndSettle(); + + final List<ui.Rect> overlays = collectOverlays(clipped: false); + + expect(overlays, hasLength(1)); + + // The unclipped menu surface will not grow beyond the screen. + expect( + overlays.first, + rectMoreOrLessEquals(const Rect.fromLTRB(0.0, 132.5, 200.0, 172.5), epsilon: 0.01), + ); + }); + + testWidgets('Constraints applied to anchor do not affect overlay', (WidgetTester tester) async { + await tester.pumpWidget( + App( + alignment: Alignment.topLeft, + ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 60, height: 60), + child: CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[Container(color: const Color(0xFFFF0000), height: 100)], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pumpAndSettle(); + + expect(collectOverlays().first.size, sizeCloseTo(const Size(262.0, 100.0), 0.01)); + }); + + testWidgets('Default overlay padding', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 600)); + + // Default padding is 8 pixels on all sides + await tester.pumpWidget( + App( + textDirection: TextDirection.ltr, + CupertinoMenuAnchor( + controller: controller, + constrainCrossAxis: true, + constraints: BoxConstraints.tight(const Size(200, 200)), + menuChildren: const <Widget>[SizedBox()], + child: const ColoredBox(color: CupertinoColors.systemOrange, child: SizedBox.expand()), + ), + ), + ); + + controller.open(position: Offset.zero); + await tester.pump(); + await tester.pumpAndSettle(); + + final Finder overlayFinder = find.byType(ClipRSuperellipse); + expect( + tester.getTopLeft(overlayFinder), + offsetMoreOrLessEquals(const Offset(8, 8), epsilon: 0.01), + ); + + controller.open(position: const Offset(800, 600)); + await tester.pump(); + + expect( + tester.getBottomRight(overlayFinder), + offsetMoreOrLessEquals(const Offset(800 - 8, 600 - 8), epsilon: 0.01), + ); + }); + + testWidgets('LTR overlay padding', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 600)); + const overlayPadding = EdgeInsetsDirectional.fromSTEB(21, 11, 650, 400); + + // Padding should stack + await tester.pumpWidget( + App( + textDirection: TextDirection.ltr, + CupertinoMenuAnchor( + controller: controller, + constrainCrossAxis: true, + overlayPadding: overlayPadding, + constraints: BoxConstraints.tight(const Size(200, 200)), + menuChildren: const <Widget>[SizedBox()], + child: Container( + padding: overlayPadding - const EdgeInsetsDirectional.all(2), + color: CupertinoColors.systemOrange, + child: const SizedBox.expand(), + ), + ), + ), + ); + + controller.open(position: Offset.zero); + await tester.pump(); + await tester.pumpAndSettle(); + + final Rect overlay = tester.getRect(find.byType(ClipRSuperellipse)); + + expect( + overlay.topLeft, + offsetMoreOrLessEquals(Offset(overlayPadding.start, overlayPadding.top), epsilon: 0.01), + ); + + expect(overlay.size, sizeCloseTo(const Size(129, 189), 0.01)); + }); + + testWidgets('RTL overlay padding', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 600)); + const overlayPadding = EdgeInsetsDirectional.fromSTEB(21, 11, 650, 400); + + // Padding should stack + await tester.pumpWidget( + App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + controller: controller, + constrainCrossAxis: true, + overlayPadding: overlayPadding, + constraints: BoxConstraints.tight(const Size(200, 200)), + menuChildren: const <Widget>[SizedBox()], + child: Container( + padding: overlayPadding - const EdgeInsetsDirectional.all(2), + color: CupertinoColors.systemOrange, + child: const SizedBox.expand(), + ), + ), + ), + ); + + controller.open(position: Offset.zero); + await tester.pump(); + await tester.pumpAndSettle(); + + final Rect overlay = tester.getRect(find.byType(ClipRSuperellipse)); + expect( + overlay.topLeft, + offsetMoreOrLessEquals( + Offset(800 - (overlayPadding.start + 129), overlayPadding.top), + epsilon: 0.1, + ), + ); + + expect(overlay.size, sizeCloseTo(const Size(129, 189), 0.01)); + }); + + testWidgets('App and overlay padding', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 600)); + const appPadding = EdgeInsetsDirectional.fromSTEB(31, 7, 27, 50); + const overlayPadding = EdgeInsetsDirectional.fromSTEB(21, 11, 600, 400); + + // Overlay padding should stack with App padding + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Container( + color: CupertinoColors.systemGrey6, + padding: appPadding, + child: App( + textDirection: TextDirection.ltr, + CupertinoMenuAnchor( + controller: controller, + constrainCrossAxis: true, + overlayPadding: overlayPadding, + constraints: BoxConstraints.tight(const Size(200, 200)), + menuChildren: const <Widget>[SizedBox()], + child: Container( + padding: overlayPadding - const EdgeInsetsDirectional.all(2), + color: CupertinoColors.systemOrange, + child: const SizedBox.expand(), + ), + ), + ), + ), + ), + ); + + controller.open(position: Offset.zero); + await tester.pump(); + await tester.pumpAndSettle(); + + final Rect overlay = tester.getRect(find.byType(ClipRSuperellipse)); + expect( + overlay.topLeft, + offsetMoreOrLessEquals( + Offset(appPadding.start + overlayPadding.start, appPadding.top + overlayPadding.top), + epsilon: 0.01, + ), + ); + + expect(overlay.size, sizeCloseTo(const Size(121, 132), 0.01)); + }); + + testWidgets('App and anchor padding', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 600)); + + // Out of App: + // - overlay position affected + // - anchor position affected + // In App: + // - anchor position affected + // + // Padding inside the App DOES NOT affect the overlay position but + // DOES affect the anchor position. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Container( + color: CupertinoColors.systemGrey6, + padding: const EdgeInsets.fromLTRB(31, 7, 550, 0), + child: App( + alignment: Alignment.topLeft, + Container( + color: CupertinoColors.systemGrey3, + padding: const EdgeInsets.fromLTRB(21, 11, 17, 0), + child: const CupertinoMenuAnchor( + constrainCrossAxis: true, + overlayPadding: EdgeInsets.zero, + constraints: BoxConstraints.tightFor(width: 250, height: 250), + menuChildren: <Widget>[SizedBox.square(dimension: 250)], + child: AnchorButton( + Tag.anchor, + constraints: BoxConstraints.tightFor(width: 125, height: 50), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final Offset overlay = collectOverlays(clipped: false).first.topLeft; + final Offset anchor = tester.getTopLeft(find.widgetWithText(AnchorButton, Tag.anchor.text)); + + expect(anchor, offsetMoreOrLessEquals(const Offset(31 + 21, 7 + 11), epsilon: 0.01)); + expect(overlay, offsetMoreOrLessEquals(const Offset(31, 7 + 11 + 50 + 8), epsilon: 0.01)); + }); + + testWidgets('Menu is positioned around display features', (WidgetTester tester) async { + // A 20-pixel wide vertical display feature, similar to a + // foldable with a visible hinge. Splits the display into two + // "virtual screens". + const displayFeature = ui.DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 1000), + type: ui.DisplayFeatureType.cutout, + state: ui.DisplayFeatureState.unknown, + ); + + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData( + platformBrightness: Brightness.dark, + displayFeatures: <ui.DisplayFeature>[displayFeature], + ), + child: ColoredBox( + color: const Color(0xFF004CFF), + child: Stack( + children: <Widget>[ + // Pink box for visualizing the display feature. + Positioned.fromRect( + rect: displayFeature.bounds, + child: const ColoredBox(color: Color(0xF7FF2190)), + ), + const Positioned( + left: 400, + top: 0, + child: CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[SizedBox(width: 100, height: 50)], + child: AnchorButton(Tag.anchor), + ), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final double menuLeft = collectOverlays().first.left; + + // Since the display feature splits the display into 2 sub-screens, the + // menu should be positioned to fit against the second virtual screen. The + // menu is positioned with its left edge at the right edge of the display + // feature, which is at 410 pixels. + expect(menuLeft, moreOrLessEquals(410, epsilon: 0.01)); + }); + + testWidgets('Menu constraints are applied to menu surface', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 75, maxHeight: 100), + menuChildren: <Widget>[SizedBox(key: Tag.a.key, height: 150, width: 50)], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + final ui.Rect overlay = collectOverlays().first; + expect(overlay.size, sizeCloseTo(const Size(75, 100), 1)); + + // Width and height should be maintained + expect(tester.getSize(find.byKey(Tag.a.key)), sizeCloseTo(const Size(50, 150), 1)); + + // The container should be centered in the overlay. + expect( + tester.getTopLeft(find.byKey(Tag.a.key)), + offsetMoreOrLessEquals(overlay.topLeft + const Offset(12.5, 0), epsilon: 0.01), + ); + }); + + testWidgets('Menu is positioned in the root overlay when useRootOverlay is true', ( + WidgetTester tester, + ) async { + // The menu should not overflow the bottom of the root overlay, so the + // menu should be placed below the anchor button. + final entry = OverlayEntry( + builder: (BuildContext context) { + return const Positioned( + bottom: 0, + child: CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + useRootOverlay: true, + menuChildren: <Widget>[SizedBox(height: 100)], + child: AnchorButton(Tag.anchor), + ), + ); + }, + ); + + // Overlay entries leak if they are not disposed. + addTearDown(() { + entry.remove(); + entry.dispose(); + }); + + await tester.pumpWidget( + App( + Stack( + children: <Widget>[ + Positioned( + height: 200, + width: 200, + child: ColoredBox( + color: const Color(0xFFFF0000), + child: Overlay(initialEntries: <OverlayEntry>[entry]), + ), + ), + ], + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final [ui.Rect menu] = collectOverlays(); + final Rect anchor = tester.getRect(find.widgetWithText(CupertinoButton, Tag.anchor.text)); + + expect( + menu.topLeft, + offsetMoreOrLessEquals(anchor.bottomLeft + const Offset(0, 8), epsilon: 0.01), + ); + }); + + testWidgets( + 'Menu is positioned within the closest ancestor overlay when useRootOverlay is false', + (WidgetTester tester) async { + // The menu should overflow the bottom of the nearest ancestor overlay, so + // the menu should be placed above the anchor button. + final entry = OverlayEntry( + builder: (BuildContext context) { + return const Positioned( + bottom: 0, + child: CupertinoMenuAnchor( + overlayPadding: EdgeInsets.zero, + menuChildren: <Widget>[SizedBox(height: 100)], + child: AnchorButton(Tag.anchor), + ), + ); + }, + ); + + // Overlay entries leak if they are not disposed. + addTearDown(() { + entry.remove(); + entry.dispose(); + }); + + await tester.pumpWidget( + App( + Stack( + children: <Widget>[ + Positioned( + height: 200, + width: 200, + child: ColoredBox( + color: const Color(0xFFFF0000), + child: Overlay(initialEntries: <OverlayEntry>[entry]), + ), + ), + ], + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.pumpAndSettle(); + + final [ui.Rect menu] = collectOverlays(); + final Rect anchor = tester.getRect(find.widgetWithText(CupertinoButton, Tag.anchor.text)); + + expect( + menu.bottomLeft + const Offset(0, 8), + offsetMoreOrLessEquals(anchor.topLeft, epsilon: 0.01), + ); + }, + ); + }); + + group('CupertinoMenuEntryMixin', () { + App buildApp(List<Widget> children) { + return App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: children, + child: const AnchorButton(Tag.anchor), + ), + ); + } + + Widget entry({required bool isDivider, Widget? child}) { + return DebugCupertinoMenuEntry(key: UniqueKey(), isDivider: isDivider, child: child); + } + + testWidgets('Implicit dividers are drawn between menu items when isDivider is false ', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildApp(<Widget>[ + entry(isDivider: false, child: Text(Tag.a.text)), + entry(isDivider: false, child: Text(Tag.b.text)), + entry(isDivider: false, child: Text(Tag.c.text)), + ]), + ); + + controller.open(); + await tester.pumpAndSettle(); + + // Borders are drawn below menu items. + List<Widget> children = findMenuChildren(tester); + expect(children.length, 5); + expect(children[0], isA<DebugCupertinoMenuEntry>()); + expect(children[2], isA<DebugCupertinoMenuEntry>()); + expect(children[4], isA<DebugCupertinoMenuEntry>()); + + // First item should never have a leading separator and bottom item should + // never have a trailing separator. + await tester.pumpWidget( + buildApp(<Widget>[ + entry(isDivider: true, child: Text(Tag.a.text)), + entry(isDivider: false, child: Text(Tag.b.text)), + entry(isDivider: false, child: Text(Tag.c.text)), + ]), + ); + + children = findMenuChildren(tester); + expect(children.length, 4); + expect(children[0], isA<DebugCupertinoMenuEntry>()); + expect(children[1], isA<DebugCupertinoMenuEntry>()); + expect(children[3], isA<DebugCupertinoMenuEntry>()); + + await tester.pumpWidget( + buildApp(<Widget>[ + entry(isDivider: false, child: Text(Tag.a.text)), + entry(isDivider: true, child: Text(Tag.b.text)), + entry(isDivider: false, child: Text(Tag.c.text)), + ]), + ); + + children = findMenuChildren(tester); + // item 1: leading == false so no separator should be drawn before it + expect(children.length, 3); + expect(children[0], isA<DebugCupertinoMenuEntry>()); + expect(children[1], isA<DebugCupertinoMenuEntry>()); + expect(children[2], isA<DebugCupertinoMenuEntry>()); + + await tester.pumpWidget( + buildApp(<Widget>[ + entry(isDivider: false, child: Text(Tag.a.text)), + entry(isDivider: false, child: Text(Tag.b.text)), + entry(isDivider: true, child: Text(Tag.c.text)), + ]), + ); + + children = findMenuChildren(tester); + // item 1: trailing == false so no separator should be drawn after it + expect(children.length, 4); + expect(children[0], isA<DebugCupertinoMenuEntry>()); + expect(children[2], isA<DebugCupertinoMenuEntry>()); + expect(children[3], isA<DebugCupertinoMenuEntry>()); + + children.clear(); + }); + + testWidgets( + 'Implicit dividers are drawn between widgets that do not mixin CupertinoMenuEntryMixin', + (WidgetTester tester) async { + await tester.pumpWidget( + buildApp(<Widget>[ + entry(isDivider: false, child: Text(Tag.a.text)), + SizedBox(child: Text(Tag.b.text)), + entry(isDivider: false, child: Text(Tag.c.text)), + ]), + ); + + controller.open(); + await tester.pumpAndSettle(); + + // Borders are drawn below menu items. + final List<Widget> children = findMenuChildren(tester); + expect(children.length, 5); + expect(children[0], isA<DebugCupertinoMenuEntry>()); + expect(children[2], isA<SizedBox>()); + expect(children[4], isA<DebugCupertinoMenuEntry>()); + }, + ); + + testWidgets('hasLeading aligns sibling CupertinoMenuItems', (WidgetTester tester) async { + Widget buildApp({bool hasLeading = false}) { + return App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(key: Tag.a.key, onPressed: () {}, child: Text(Tag.a.text)), + DebugCupertinoMenuEntry(hasLeading: hasLeading), + ], + ), + ); + } + + await tester.pumpWidget(buildApp()); + + controller.open(); + await tester.pumpAndSettle(); + + final Offset childOffsetWithoutLeading = tester.getTopLeft(find.text(Tag.a.text)); + + await tester.pumpWidget(buildApp(hasLeading: true)); + + final Offset childOffsetWithLeading = tester.getTopLeft(find.text(Tag.a.text)); + + expect( + childOffsetWithLeading - childOffsetWithoutLeading, + offsetMoreOrLessEquals(const Offset(16, 0.0), epsilon: 0.01), + ); + }); + }); + + group('CupertinoMenuDivider', () { + testWidgets('dimensions', (WidgetTester tester) async { + await tester.pumpWidget(const App(alignment: Alignment.topLeft, CupertinoMenuDivider())); + + expect( + tester.getRect(find.byType(CupertinoMenuDivider)), + rectMoreOrLessEquals(const Rect.fromLTRB(0.0, 0.0, 800.0, 8.0), epsilon: 0.01), + ); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(key: Tag.a.key, onPressed: () {}, child: Text(Tag.a.text)), + const CupertinoMenuDivider(), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + + expect( + tester.getRect(find.byType(CupertinoMenuDivider)), + rectMoreOrLessEquals( + ui.Rect.fromLTWH(menuItemRect.left, menuItemRect.bottom, menuItemRect.width, 8.0), + epsilon: 0.01, + ), + ); + }); + + testWidgets('color', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: CupertinoMenuDivider(key: Tag.a.key), + ), + ); + + final Finder coloredBoxFinder = find.descendant( + of: find.byKey(Tag.a.key), + matching: find.byType(ColoredBox), + ); + + expect( + tester.widget<ColoredBox>(coloredBoxFinder).color, + isSameColorAs(const Color.fromRGBO(0, 0, 0, 0.08)), + ); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoMenuDivider(key: Tag.a.key), + ), + ); + + expect( + tester.widget<ColoredBox>(coloredBoxFinder).color, + isSameColorAs(const Color.fromRGBO(0, 0, 0, 0.16)), + ); + }); + + testWidgets('no adjacent borders are drawn', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + DebugCupertinoMenuEntry(key: UniqueKey(), isDivider: true), + const CupertinoMenuDivider(), + DebugCupertinoMenuEntry(key: UniqueKey(), isDivider: true), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoMenuDivider), findsOneWidget); + expect(findMenuChildren(tester), hasLength(3)); + }); + }); + + group('CupertinoMenuItem', () { + const defaultLightTextColor = ui.Color.from(alpha: 0.96, red: 0, green: 0, blue: 0); + const defaultDarkTextColor = ui.Color.from(alpha: 0.96, red: 1, green: 1, blue: 1); + const defaultSubtitleTextColor = ui.Color.from(alpha: 0.55, red: 0, green: 0, blue: 0); + const defaultSubtitleDarkTextColor = ui.Color.from(alpha: 0.4, red: 1, green: 1, blue: 1); + + group('Appearance', () { + testWidgets('leading style', (WidgetTester tester) async { + RenderParagraph? findIcon() => + findDescendantParagraph(tester, find.byIcon(CupertinoIcons.check_mark)); + RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text)); + + Widget buildApp({ + TextScaler textScaler = TextScaler.noScaling, + ui.Brightness brightness = ui.Brightness.light, + }) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Stack( + children: <Widget>[ + Text(Tag.a.text), + const Icon(CupertinoIcons.check_mark), + ], + ), + child: const Text('Menu Item'), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph? icon = findIcon(); + final RenderParagraph? text = findText(); + final TextStyle iconStyle = icon!.text.style!; + final TextStyle textStyle = text!.text.style!; + + expect(icon.textSize, equals(const Size(15.0, 15.0))); + expect(icon.textDirection, equals(TextDirection.ltr)); + expect(icon.maxLines, isNull); + expect(iconStyle.color, isSameColorAs(defaultLightTextColor)); + expect(iconStyle.fontSize, equals(15.0)); + expect(iconStyle.leadingDistribution, equals(TextLeadingDistribution.even)); + expect( + iconStyle.fontVariations, + equals(<FontVariation>[ + const FontVariation('FILL', 0.0), + const FontVariation.weight(600.0), + const FontVariation('GRAD', 0.0), + const FontVariation.opticalSize(48.0), + ]), + ); + + expect(text.textScaler, equals(TextScaler.noScaling)); + expect(text.textDirection, equals(TextDirection.ltr)); + expect(text.maxLines, equals(2)); + expect(textStyle.fontSize, equals(15)); + expect(textStyle.color, isSameColorAs(defaultLightTextColor)); + expect(textStyle.fontWeight, equals(FontWeight.w600)); + + await tester.pumpWidget( + buildApp(textScaler: AccessibilityTextSize.xxxLarge, brightness: ui.Brightness.dark), + ); + + final RenderParagraph? icon6x = findIcon(); + final RenderParagraph? text6x = findText(); + final TextStyle iconStyle6x = icon6x!.text.style!; + final TextStyle textStyle6x = text.text.style!; + + expect(iconStyle6x.fontSize, closeTo(20, 0.5)); + expect(iconStyle6x.color, isSameColorAs(defaultDarkTextColor)); + expect(text6x!.textScaler, equals(AccessibilityTextSize.xxxLarge)); + expect(textStyle6x.fontSize, equals(15)); + expect(textStyle6x.color, isSameColorAs(defaultDarkTextColor)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax1)); + + expect(find.byIcon(CupertinoIcons.check_mark), findsOneWidget); + expect(find.text(Tag.a.text), findsOneWidget); + }); + + testWidgets('trailing style', (WidgetTester tester) async { + RenderParagraph? findIcon() => + findDescendantParagraph(tester, find.byIcon(CupertinoIcons.trash)); + RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text)); + + Widget buildApp({ + TextScaler textScaler = TextScaler.noScaling, + ui.Brightness brightness = ui.Brightness.light, + }) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + trailing: Stack( + children: <Widget>[Text(Tag.a.text), const Icon(CupertinoIcons.trash)], + ), + child: const Text('Menu Item'), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph? icon = findIcon(); + final RenderParagraph? text = findText(); + final TextStyle iconStyle = icon!.text.style!; + final TextStyle textStyle = text!.text.style!; + + expect(icon.textDirection, equals(TextDirection.ltr)); + expect(icon.maxLines, isNull); + expect(iconStyle.color, isSameColorAs(defaultLightTextColor)); + expect(iconStyle.fontSize, closeTo(21, 0.5)); + expect(iconStyle.leadingDistribution, equals(TextLeadingDistribution.even)); + expect( + iconStyle.fontVariations, + equals(<FontVariation>[ + const FontVariation('FILL', 0.0), + const FontVariation.weight(400.0), + const FontVariation('GRAD', 0.0), + const FontVariation.opticalSize(48.0), + ]), + ); + + expect( + Offset.zero & text.textSize, + rectMoreOrLessEquals(Offset.zero & const Size(20.6, 21), epsilon: 0.1), + ); + expect(text.textScaler, equals(AccessibilityTextSize.large)); + expect(text.textDirection, equals(TextDirection.ltr)); + expect(text.maxLines, equals(2)); + expect(textStyle.fontSize, closeTo(21, 0.5)); + expect(textStyle.color, isSameColorAs(defaultLightTextColor)); + expect(textStyle.fontWeight, equals(null)); + + await tester.pumpWidget( + buildApp(textScaler: AccessibilityTextSize.xxxLarge, brightness: ui.Brightness.dark), + ); + + final RenderParagraph? icon6x = findIcon(); + final RenderParagraph? text6x = findText(); + final TextStyle iconStyle6x = icon6x!.text.style!; + final TextStyle textStyle6x = text.text.style!; + + expect(iconStyle6x.fontSize, closeTo(28.5, 0.5)); + expect(iconStyle6x.color, isSameColorAs(defaultDarkTextColor)); + expect(text6x!.textScaler, equals(AccessibilityTextSize.xxxLarge)); + expect(textStyle6x.fontSize, closeTo(21, 0.5)); + expect(textStyle6x.color, isSameColorAs(defaultDarkTextColor)); + + await tester.pumpWidget( + buildApp(textScaler: AccessibilityTextSize.ax1, brightness: ui.Brightness.dark), + ); + + expect(find.byIcon(CupertinoIcons.trash), findsNothing); + expect(find.text(Tag.a.text), findsNothing); + }); + + testWidgets('child style', (WidgetTester tester) async { + RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text)); + + Widget buildApp({ + TextScaler textScaler = TextScaler.noScaling, + ui.Brightness brightness = ui.Brightness.light, + }) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph? text = findText(); + final TextStyle textStyle = text!.text.style!; + + expect(text.textScaler, equals(TextScaler.noScaling)); + expect(text.textDirection, equals(TextDirection.ltr)); + expect(text.maxLines, equals(2)); + expect(textStyle.fontSize, equals(17)); + expect(textStyle.color, isSameColorAs(defaultLightTextColor)); + expect(textStyle.fontWeight, equals(null)); + + for (final TextScaler size in AccessibilityTextSize.values) { + await tester.pumpWidget(buildApp(textScaler: size)); + + final TextStyle expectedTextStyle = DynamicTypeStyle.body.resolveTextStyle(size); + final RenderParagraph textSized = findText()!; + final TextStyle textStyle = textSized.text.style!; + expect(textSized.textScaler, equals(size)); + expect(textStyle.fontSize, equals(17)); + expect(textStyle.letterSpacing, equals(expectedTextStyle.letterSpacing)); + expect(textStyle.height, equals(expectedTextStyle.height)); + expect(textStyle.fontFamily, equals(expectedTextStyle.fontFamily)); + } + + await tester.pumpWidget(buildApp(brightness: ui.Brightness.dark)); + + expect(findText()!.text.style!.color, isSameColorAs(defaultDarkTextColor)); + }); + + testWidgets('subtitle style', (WidgetTester tester) async { + RenderParagraph? findText() => findDescendantParagraph(tester, find.text(Tag.a.text)); + + Widget buildApp({ + TextScaler textScaler = TextScaler.noScaling, + ui.Brightness brightness = ui.Brightness.light, + }) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + subtitle: Text(Tag.a.text), + child: const Text('Menu Item'), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph? text = findText(); + final TextStyle textStyle = text!.text.style!; + + expect(text.textScaler, equals(TextScaler.noScaling)); + expect(text.textDirection, equals(TextDirection.ltr)); + expect(text.maxLines, equals(2)); + expect(textStyle.fontSize, equals(15)); + expect(textStyle.fontWeight, isNull); + expect(textStyle.color, isNull); + expect( + textStyle.foreground, + isA<ui.Paint>() + .having( + (ui.Paint paint) => paint.color, + 'color', + isSameColorAs(defaultSubtitleTextColor), + ) + .having( + (ui.Paint paint) => paint.blendMode, + 'blendMode', + equals(BlendMode.hardLight), + ), + ); + + for (final TextScaler size in AccessibilityTextSize.values) { + await tester.pumpWidget(buildApp(textScaler: size)); + + final TextStyle expectedTextStyle = DynamicTypeStyle.subhead.resolveTextStyle(size); + final RenderParagraph textSized = findText()!; + final TextStyle textStyle = textSized.text.style!; + expect(textSized.textScaler, equals(size)); + expect(textStyle.fontSize, equals(15)); + expect(textStyle.letterSpacing, equals(expectedTextStyle.letterSpacing)); + expect(textStyle.height, equals(expectedTextStyle.height)); + expect(textStyle.fontFamily, equals(expectedTextStyle.fontFamily)); + } + + await tester.pumpWidget(buildApp(brightness: ui.Brightness.dark)); + + final RenderParagraph? darkText = findText(); + final TextStyle darkTextStyle = darkText!.text.style!; + + expect( + darkTextStyle.foreground, + isA<ui.Paint>() + .having( + (ui.Paint paint) => paint.color, + 'color', + isSameColorAs(defaultSubtitleDarkTextColor), + ) + .having((ui.Paint paint) => paint.blendMode, 'blendMode', equals(BlendMode.plus)), + ); + }); + + testWidgets('isDestructiveAction style', (WidgetTester tester) async { + RenderParagraph? findLeading() { + return findDescendantParagraph(tester, find.byIcon(CupertinoIcons.left_chevron)); + } + + RenderParagraph? findTrailing() { + return findDescendantParagraph(tester, find.byIcon(CupertinoIcons.right_chevron)); + } + + RenderParagraph? findChild() { + return findDescendantParagraph(tester, find.text(Tag.a.text)); + } + + RenderParagraph? findSubtitle() { + return findDescendantParagraph(tester, find.text(Tag.b.text)); + } + + Widget buildApp([ui.Brightness brightness = ui.Brightness.light]) { + return CupertinoApp( + home: CupertinoTheme( + data: CupertinoThemeData(brightness: brightness), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + isDestructiveAction: true, + subtitle: Text(Tag.b.text), + leading: const Icon(CupertinoIcons.left_chevron), + trailing: const Icon(CupertinoIcons.right_chevron), + child: Text(Tag.a.text), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + controller.open(); + await tester.pumpAndSettle(); + + expect(findTrailing()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed)); + expect(findLeading()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed)); + expect(findChild()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed)); + expect( + findSubtitle()!.text.style!.foreground!.color, + isSameColorAs(defaultSubtitleTextColor), + ); + + await tester.pumpWidget(buildApp(ui.Brightness.dark)); + + expect( + findTrailing()!.text.style!.color, + isSameColorAs(CupertinoColors.systemRed.darkColor), + ); + expect( + findLeading()!.text.style!.color, + isSameColorAs(CupertinoColors.systemRed.darkColor), + ); + expect(findChild()!.text.style!.color, isSameColorAs(CupertinoColors.systemRed.darkColor)); + expect( + findSubtitle()!.text.style!.foreground!.color, + isSameColorAs(defaultSubtitleDarkTextColor), + ); + }); + + testWidgets('allows adjacent borders', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + DebugCupertinoMenuEntry(key: UniqueKey()), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + DebugCupertinoMenuEntry(key: UniqueKey()), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + expect(findMenuChildren(tester), hasLength(5)); + }); + + testWidgets('disabled items should not interact', (WidgetTester tester) async { + // Test various interactions to ensure that disabled items do not + // respond. + var interactions = 0; + final focusNode = FocusNode(); + focusNode.addListener(() { + interactions++; + }); + + addTearDown(focusNode.dispose); + + BoxDecoration getItemDecoration() { + return tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoMenuItem), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + } + + RenderParagraph? findChild() { + return findDescendantParagraph(tester, find.text(Tag.a.text)); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + focusNode: focusNode, + onFocusChange: (bool value) { + interactions++; + }, + onHover: (bool value) { + interactions++; + }, + child: Text(Tag.a.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + // Test focus + focusNode.requestFocus(); + await tester.pump(); + + void checkAppearance() { + expect(getItemDecoration(), equals(const BoxDecoration())); + expect(findChild()!.text.style!.color, isSameColorAs(CupertinoColors.systemGrey)); + } + + // Test hover + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + checkAppearance(); + + // Test press + await gesture.down(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + checkAppearance(); + + // Test pan + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + await gesture.up(); + await tester.pump(); + + checkAppearance(); + expect(controller.isOpen, isTrue); + expect(interactions, 0); + }); + + testWidgets('hover color', (WidgetTester tester) async { + const hoverColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.05), + darkColor: Color.fromRGBO(255, 255, 255, 0.05), + ); + const customHoverColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(75, 0, 0, 1), + darkColor: Color.fromRGBO(150, 0, 0, 1), + ); + + const decoration = WidgetStateProperty<BoxDecoration>.fromMap( + <WidgetStatesConstraint, BoxDecoration>{ + WidgetState.hovered: BoxDecoration(color: customHoverColor), + WidgetState.any: BoxDecoration(), + }, + ); + + BoxDecoration getItemDecoration(Tag tag) { + return tester + .widget<DecoratedBox>( + find.descendant( + of: find.widgetWithText(CupertinoMenuItem, tag.text), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + } + + Widget buildApp(ui.Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + requestFocusOnHover: false, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + requestFocusOnHover: false, + onPressed: () {}, + decoration: decoration, + child: Text(Tag.b.text), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildApp(ui.Brightness.light)); + controller.open(); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(hoverColor.color)); + + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + await gesture.moveTo(tester.getCenter(find.text(Tag.b.text))); + await tester.pump(); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + + expect(getItemDecoration(Tag.b).color, isSameColorAs(customHoverColor.color)); + + await tester.pumpWidget(buildApp(ui.Brightness.dark)); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + + expect(getItemDecoration(Tag.b).color, isSameColorAs(customHoverColor.darkColor)); + + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(hoverColor.darkColor)); + + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + }); + + testWidgets('pressed color', (WidgetTester tester) async { + const pressedColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.1), + darkColor: Color.fromRGBO(255, 255, 255, 0.1), + ); + + const customPressedColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(75, 0, 0, 1), + darkColor: Color.fromRGBO(150, 0, 0, 1), + ); + + const decoration = + WidgetStateProperty<BoxDecoration>.fromMap(<WidgetStatesConstraint, BoxDecoration>{ + WidgetState.pressed: BoxDecoration(color: customPressedColor), + WidgetState.any: BoxDecoration(), + }); + + BoxDecoration getItemDecoration(Tag tag) { + return tester + .widget<DecoratedBox>( + find.descendant( + of: find.widgetWithText(CupertinoMenuItem, tag.text), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + } + + Widget buildApp(ui.Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + requestFocusOnHover: false, + requestCloseOnActivate: false, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + onPressed: () {}, + requestFocusOnHover: false, + requestCloseOnActivate: false, + decoration: decoration, + child: Text(Tag.b.text), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildApp(ui.Brightness.light)); + controller.open(); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + await gesture.down(tester.getCenter(find.text(Tag.a.text))); + await tester.pumpAndSettle(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(pressedColor.color)); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + // Release the press + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + // Press the second item with a custom pressed color + await gesture.down(tester.getCenter(find.text(Tag.b.text))); + await tester.pumpAndSettle(); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b).color, isSameColorAs(customPressedColor.color)); + + await gesture.up(); + await tester.pumpAndSettle(); + + controller.close(); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(ui.Brightness.dark)); + + controller.open(); + await tester.pumpAndSettle(); + + expect(controller.isOpen, isTrue); + + await gesture.down(tester.getCenter(find.text(Tag.a.text))); + await tester.pumpAndSettle(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(pressedColor.darkColor)); + }); + + testWidgets('focused color', (WidgetTester tester) async { + const focusedColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.075), + darkColor: Color.fromRGBO(255, 255, 255, 0.075), + ); + + const customFocusedColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(0, 75, 0, 1), + darkColor: Color.fromRGBO(0, 150, 0, 1), + ); + + const decoration = + WidgetStateProperty<BoxDecoration>.fromMap(<WidgetStatesConstraint, BoxDecoration>{ + WidgetState.focused: BoxDecoration(color: customFocusedColor), + WidgetState.any: BoxDecoration(), + }); + + BoxDecoration getItemDecoration(Tag tag) { + return tester + .widget<DecoratedBox>( + find.descendant( + of: find.widgetWithText(CupertinoMenuItem, tag.text), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + } + + final focusNodeA = FocusNode(); + final focusNodeB = FocusNode(); + addTearDown(() { + focusNodeA.dispose(); + focusNodeB.dispose(); + }); + + Widget buildApp(ui.Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: focusNodeA, child: Text(Tag.a.text)), + CupertinoMenuItem( + focusNode: focusNodeB, + onPressed: () {}, + decoration: decoration, + child: Text(Tag.b.text), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildApp(ui.Brightness.light)); + controller.open(); + await tester.pumpAndSettle(); + + // Verify initial state + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + // Focus the first item + focusNodeA.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(focusedColor.color)); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + // Focus the second item with a custom focused color + focusNodeB.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b).color, isSameColorAs(customFocusedColor.color)); + + await tester.pumpWidget(buildApp(ui.Brightness.dark)); + + // Verify dark mode focused colors + focusNodeA.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(focusedColor.darkColor)); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + focusNodeB.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(getItemDecoration(Tag.b).color, isSameColorAs(customFocusedColor.darkColor)); + }); + + testWidgets('swiped color', (WidgetTester tester) async { + const swipedColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(50, 50, 50, 0.1), + darkColor: Color.fromRGBO(255, 255, 255, 0.1), + ); + + const customSwipedColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(75, 0, 0, 1), + darkColor: Color.fromRGBO(150, 0, 0, 1), + ); + + const decoration = WidgetStateProperty<BoxDecoration>.fromMap( + <WidgetStatesConstraint, BoxDecoration>{ + WidgetState.dragged: BoxDecoration(color: customSwipedColor), + WidgetState.any: BoxDecoration(), + }, + ); + + BoxDecoration getItemDecoration(Tag tag) { + return tester + .widget<DecoratedBox>( + find.descendant( + of: find.widgetWithText(CupertinoMenuItem, tag.text), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + } + + Widget buildApp(ui.Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + requestFocusOnHover: false, + requestCloseOnActivate: false, + child: Text(Tag.a.text), + ), + CupertinoMenuItem( + onPressed: () {}, + requestFocusOnHover: false, + requestCloseOnActivate: false, + decoration: decoration, + child: Text(Tag.b.text), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildApp(ui.Brightness.light)); + controller.open(); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(); + addTearDown(gesture.removePointer); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + // Start press + await gesture.down(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + // Move to trigger swipe gesture + await gesture.moveBy(const Offset(30, 0)); + await tester.pump(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(swipedColor.color)); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + // Swipe into the second item + await gesture.moveTo(tester.getCenter(find.text(Tag.b.text))); + await tester.pump(); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b).color, isSameColorAs(customSwipedColor.color)); + + // Release the press + await gesture.up(); + await tester.pump(); + + expect(getItemDecoration(Tag.a), equals(const BoxDecoration())); + expect(getItemDecoration(Tag.b), equals(const BoxDecoration())); + + controller.close(); + await tester.pump(); + + await tester.pumpWidget(buildApp(ui.Brightness.dark)); + + controller.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + + // Verify dark mode swiped colors + await gesture.down(tester.getCenter(find.text(Tag.a.text))); + await gesture.moveBy(const Offset(30, 0)); + await tester.pump(); + + expect(getItemDecoration(Tag.a).color, isSameColorAs(swipedColor.darkColor)); + + await gesture.moveTo(tester.getCenter(find.text(Tag.b.text))); + await tester.pump(); + + expect(getItemDecoration(Tag.b).color, isSameColorAs(customSwipedColor.darkColor)); + + await gesture.up(); + }); + + testWidgets('mouse cursor can be set and is inherited', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CupertinoMenuItem( + onPressed: () {}, + mouseCursor: const WidgetStatePropertyAll<MouseCursor>(SystemMouseCursors.text), + child: Text(Tag.a.text), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: tester.getCenter(find.text(Tag.a.text))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: CupertinoMenuItem( + onPressed: () {}, + child: MouseRegion(cursor: SystemMouseCursors.basic, child: Container()), + ), + ), + ), + ); + + // The cursor should defer to it's child. + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + }); + + group('Layout', () { + Alignment offsetAlongSize(ui.Offset offset, ui.Size size) { + final double x = (offset.dx / size.width) * 2 - 1; + final double y = (offset.dy / size.height) * 2 - 1; + return Alignment(x, y); + } + + double lineHeight(TextStyle style) { + return style.height! * style.fontSize!; + } + + testWidgets('LTR hasLeading shift', (WidgetTester tester) async { + // When no menu item has a leading widget, leadingWidth defaults to 16. + // If leadingWidth is set, the default is ignored. + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect a1 = tester.getRect(find.text(Tag.a.text)); + final Rect b1 = tester.getRect(find.text(Tag.b.text)); + final Rect c1 = tester.getRect(find.text(Tag.c.text)); + + expect(a1.left, b1.left); + expect(a1.left - c1.left, closeTo(16 - 3, 0.01)); + + // When any menu item has a leading widget, leadingWidth defaults to 32 + // for all menu items on this menu layer. If leadingWidth is set on an + // item, that item ignores the default leading width. + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuItem( + onPressed: () {}, + leading: const Icon(CupertinoIcons.left_chevron), + child: Text(Tag.b.text), + ), + CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)), + ], + ), + ), + ); + + final Rect a2 = tester.getRect(find.text(Tag.a.text)); + final Rect b2 = tester.getRect(find.text(Tag.b.text)); + final Rect c2 = tester.getRect(find.text(Tag.c.text)); + + expect(a2.left, b2.left); + expect(a2.left - c2.left, closeTo(32 - 3, 0.01)); + expect(a2.left - a1.left, closeTo(32 - 16, 0.01)); + }); + + testWidgets('RTL hasLeading shift', (WidgetTester tester) async { + // When no menu item has a leading widget, leadingWidth defaults to 16. + // If leadingWidth is set, the default is ignored. + await tester.pumpWidget( + App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.b.text)), + CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect a1 = tester.getRect(find.text(Tag.a.text)); + final Rect b1 = tester.getRect(find.text(Tag.b.text)); + final Rect c1 = tester.getRect(find.text(Tag.c.text)); + + expect(a1.right, b1.right); + expect(a1.right - c1.right, closeTo(-16 + 3, 0.01)); + + // When any menu item has a leading widget, leadingWidth defaults to 32 + // for all menu items on this menu layer. If leadingWidth is set on an + // item, that item ignores the default leading width. + await tester.pumpWidget( + App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuItem( + onPressed: () {}, + leading: const Icon(CupertinoIcons.left_chevron), + child: Text(Tag.b.text), + ), + CupertinoMenuItem(onPressed: () {}, leadingWidth: 3, child: Text(Tag.c.text)), + ], + ), + ), + ); + await tester.pumpAndSettle(); + final Rect a2 = tester.getRect(find.text(Tag.a.text)); + final Rect b2 = tester.getRect(find.text(Tag.b.text)); + final Rect c2 = tester.getRect(find.text(Tag.c.text)); + + expect(a2.right, b2.right); + expect(a2.right - c2.right, closeTo(-32 + 3, 0.01)); + expect(a2.right - a1.right, closeTo(-32 + 16, 0.01)); + }); + + group('Child ', () { + testWidgets('LTR child layout', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final double childLineHeight = lineHeight(DynamicTypeStyle.body.large); + final Rect childRect = tester.getRect(find.text(Tag.child.text)); + final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(childRect.height, closeTo(childLineHeight, 0.1)); + expect(childRect.top, closeTo(menuItemRect.top + 10.83, 0.1)); + expect(childRect.left, closeTo(menuItemRect.left + 16, 0.1)); + }); + + testWidgets('RTL child layout', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.rtl, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final double childLineHeight = lineHeight(DynamicTypeStyle.body.large); + final Rect childRect = tester.getRect(find.text(Tag.child.text)); + final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(childRect.height, closeTo(childLineHeight, 0.1)); + expect(childRect.top, closeTo(menuItemRect.top + 10.83, 0.1)); + expect(childRect.right, closeTo(menuItemRect.right - 16, 0.1)); + }); + + testWidgets('child text overflow', (WidgetTester tester) async { + final String longText = 'Very long subtitle ' * 100; + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(longText))], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Size childText = tester.getSize(find.text(longText)); + final TextStyle childStyle = DynamicTypeStyle.body.large; + expect(childText.height, closeTo(lineHeight(childStyle) * 2, 1)); // 2 lines of text + }); + + testWidgets('child text overflow in large text mode', (WidgetTester tester) async { + final String longText = 'Very long text ' * 1000; + + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData(textScaler: AccessibilityTextSize.ax1), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(longText)), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph paragraph = findDescendantParagraph(tester, find.text(longText))!; + final double childLineHeight = lineHeight(DynamicTypeStyle.body.ax1); + + expect(paragraph.maxLines, equals(100)); + expect(tester.getSize(find.text(longText)).height, closeTo(childLineHeight * 100, 1)); + }); + + testWidgets('LTR child with leading and trailing', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leadingWidth: 33, + trailingWidth: 47, + leading: Icon(CupertinoIcons.star, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.heart, key: Tag.trailing.key), + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect leading = tester.getRect(find.byKey(Tag.leading.key)); + final Rect trailing = tester.getRect(find.byKey(Tag.trailing.key)); + final Rect child = tester.getRect(find.text(Tag.child.text)); + final Rect menuItem = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(child.left, closeTo(menuItem.left + 33, 0.1)); + expect(child.right, lessThanOrEqualTo(menuItem.right - 47)); + expect(child.left, greaterThan(leading.right)); + expect(child.right, lessThan(trailing.left)); + expect(leading.center.dy, closeTo(child.center.dy, 0.1)); + expect(trailing.center.dy, closeTo(child.center.dy, 0.1)); + }); + + testWidgets('RTL child with leading and trailing', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.rtl, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leadingWidth: 33, + trailingWidth: 47, + leading: Icon(CupertinoIcons.star, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.heart, key: Tag.trailing.key), + child: Text(Tag.child.text), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect leading = tester.getRect(find.byKey(Tag.leading.key)); + final Rect trailing = tester.getRect(find.byKey(Tag.trailing.key)); + final Rect child = tester.getRect(find.text(Tag.child.text)); + final Rect menuItem = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(child.right, closeTo(menuItem.right - 33, 0.1)); + expect(child.right, lessThan(leading.right)); + expect(child.left, greaterThan(trailing.left)); + expect(leading.center.dy, closeTo(child.center.dy, 0.1)); + expect(trailing.center.dy, closeTo(child.center.dy, 0.1)); + }); + + testWidgets('child text overflow with maxLines', (WidgetTester tester) async { + final String longText = 'Very long text ' * 1000; + + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData(textScaler: AccessibilityTextSize.xxxLarge), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + child: Text(longText, key: Tag.a.key), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph paragraph = findDescendantParagraph(tester, find.byKey(Tag.a.key))!; + expect(paragraph.maxLines, equals(2)); + expect(paragraph.size.height, closeTo(58, 1)); // 2 lines of text + expect(tester.getSize(find.byType(CupertinoMenuItem)).height, closeTo(87, 1)); + }); + + testWidgets('child text overflow with large text mode', (WidgetTester tester) async { + final String longText = 'Very long text ' * 1000; + + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData(textScaler: AccessibilityTextSize.ax1), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + child: Text(longText, key: Tag.a.key), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph paragraph = findDescendantParagraph(tester, find.byKey(Tag.a.key))!; + expect(paragraph.maxLines, equals(100)); + expect(paragraph.size.height, closeTo(3400, 1)); // 100 lines of text + expect(tester.getSize(find.byType(CupertinoMenuItem)).height, closeTo(3433, 1)); + }); + + testWidgets('child adjusts to dynamic type', (WidgetTester tester) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + final double undersizedLineHeight = lineHeight(DynamicTypeStyle.body.xSmall); + Size childSize = tester.getSize(find.text(Tag.child.text)); + + expect(childSize.height, closeTo(undersizedLineHeight, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + final double xSmallLineHeight = lineHeight(DynamicTypeStyle.body.xSmall); + childSize = tester.getSize(find.text(Tag.child.text)); + + expect(childSize.height, closeTo(xSmallLineHeight, 0.1)); + + await tester.pumpWidget(buildApp()); + + final double largeLineHeight = lineHeight(DynamicTypeStyle.body.large); + childSize = tester.getSize(find.text(Tag.child.text)); + + expect(childSize.height, closeTo(largeLineHeight, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + final double ax5LineHeight = lineHeight(DynamicTypeStyle.body.ax5); + childSize = tester.getSize(find.text(Tag.child.text)); + + expect(childSize.height, closeTo(ax5LineHeight * 2, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + final double oversizedLineHeight = lineHeight(DynamicTypeStyle.body.ax5); + childSize = tester.getSize(find.text(Tag.child.text)); + + expect(childSize.height, closeTo(oversizedLineHeight * 2, 0.1)); + }); + }); + + group('Leading ', () { + testWidgets('LTR leading position', (WidgetTester tester) async { + await tester.pumpWidget( + App( + textDirection: TextDirection.ltr, + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: const Text('Subtitle'), + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect leadingRect = tester.getRect(find.byKey(Tag.leading.key)); + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double leadingWidth = childRect.left - menuItemRect.left; + final leadingSize = ui.Size(leadingWidth, menuItemRect.height); + final Rect leadingWidgetRect = tester + .getRect(find.byKey(Tag.leading.key)) + .translate(-menuItemRect.left, -menuItemRect.top); + final Alignment leadingAlignment = offsetAlongSize(leadingWidgetRect.center, leadingSize); + + expect(leadingAlignment.x, closeTo(0.1680, 0.01)); + expect(leadingAlignment.y, closeTo(0, 0.01)); + expect(leadingRect.left, greaterThan(menuItemRect.left)); + expect(leadingRect.right, lessThan(childRect.right)); + }); + + testWidgets('RTL leading position', (WidgetTester tester) async { + await tester.pumpWidget( + App( + textDirection: TextDirection.rtl, + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect leadingRect = tester.getRect(find.byKey(Tag.leading.key)); + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double leadingWidth = menuItemRect.right - childRect.right; + final leadingSize = ui.Size(leadingWidth, menuItemRect.height); + final Rect leadingWidgetRect = tester + .getRect(find.byKey(Tag.leading.key)) + .translate(-childRect.right, -menuItemRect.top); + final Alignment leadingAlignment = offsetAlongSize(leadingWidgetRect.center, leadingSize); + + expect(leadingAlignment.x, closeTo(-0.168, 0.01)); + expect(leadingAlignment.y, closeTo(0, 0.01)); + expect(leadingRect.right, lessThan(menuItemRect.right)); + expect(leadingRect.left, greaterThan(childRect.left)); + }); + + testWidgets('leadingMidpointAlignment adjusts to dynamic type', ( + WidgetTester tester, + ) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Alignment leadingAlignment() { + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double leadingWidth = childRect.left - menuItemRect.left; + final leadingSize = ui.Size(leadingWidth, menuItemRect.height); + final Rect leadingWidgetRect = tester + .getRect(find.byKey(Tag.leading.key)) + .translate(-menuItemRect.left, -menuItemRect.top); + return offsetAlongSize(leadingWidgetRect.center, leadingSize); + } + + Alignment alignment = leadingAlignment(); + expect(alignment.x, closeTo(0.1673, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + alignment = leadingAlignment(); + expect(alignment.x, closeTo(0.1673, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + alignment = leadingAlignment(); + expect(alignment.x, closeTo(0.1765, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + alignment = leadingAlignment(); + expect(alignment.x, closeTo(0.1765, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + }); + + testWidgets('leadingWidth adjusts to dynamic type', (WidgetTester tester) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Rect childRect() => tester.getRect(find.text(Tag.child.text)); + Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem)); + double leadingWidth() => childRect().left - menuItemRect().left; + + expect(leadingWidth(), closeTo(30.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + expect(leadingWidth(), closeTo(30.0, 0.1)); + + await tester.pumpWidget(buildApp()); + + expect(leadingWidth(), closeTo(32.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + expect(leadingWidth(), closeTo(61.0, 0.5)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + expect(leadingWidth(), closeTo(61.0, 0.5)); + }); + testWidgets('leadingWidth is quantized to pixel ratio', (WidgetTester tester) async { + Rect childRect() => tester.getRect(find.text(Tag.child.text)); + Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem)); + + Widget buildApp({ + TextScaler textScaler = AccessibilityTextSize.large, + double devicePixelRatio = 2.0, + }) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: textScaler, devicePixelRatio: devicePixelRatio), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.medium)); + controller.open(); + await tester.pumpAndSettle(); + + final double leadingWidth2x = childRect().left - menuItemRect().left; + expect(leadingWidth2x - leadingWidth2x.floorToDouble(), closeTo(1 / 2, 0.01)); + + await tester.pumpWidget( + buildApp(devicePixelRatio: 3.0, textScaler: AccessibilityTextSize.medium), + ); + + final double leadingWidth3x = childRect().left - menuItemRect().left; + expect(leadingWidth3x - leadingWidth3x.floorToDouble(), closeTo(1 / 3, 0.01)); + }); + + testWidgets('custom leadingWidth', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leadingWidth: 60, + child: Text(Tag.child.text, key: Tag.child.key), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byKey(Tag.child.key)); + final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + + final double leadingSpace = child.left - menuItemRect.left; + expect(leadingSpace, moreOrLessEquals(60, epsilon: 1)); + }); + + testWidgets('custom leadingMidpointAlignment', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: EdgeInsets.zero, + leadingWidth: 60, + leadingMidpointAlignment: const Alignment(0.5, 0.5), + leading: Container( + color: CupertinoColors.systemBlue, + width: 5, + height: 5, + key: Tag.leading.key, + ), + onPressed: () {}, + child: Text('TTT', key: Tag.child.key), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.byKey(Tag.child.key)); + final double leadingWidth = childRect.left - menuItemRect.left; + final leadingSize = ui.Size(leadingWidth, menuItemRect.height); + final Rect leadingWidgetRect = tester + .getRect(find.byKey(Tag.leading.key)) + .shift(-menuItemRect.topLeft); + + final Alignment alignment = offsetAlongSize(leadingWidgetRect.center, leadingSize); + + expect(alignment.x, closeTo(0.5, 0.01)); + expect(alignment.y, closeTo(0.5, 0.01)); + }); + }); + + group('Trailing ', () { + testWidgets('LTR trailing position', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect trailingRect = tester.getRect(find.byKey(Tag.trailing.key)); + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double trailingWidth = menuItemRect.right - childRect.right; + final trailingSize = ui.Size(trailingWidth, menuItemRect.height); + final Rect trailingWidgetRect = tester + .getRect(find.byKey(Tag.trailing.key)) + .translate(-childRect.right, -menuItemRect.top); + final Alignment trailingAlignment = offsetAlongSize( + trailingWidgetRect.center, + trailingSize, + ); + + expect(trailingAlignment.x, closeTo(-0.2727, 0.01)); + expect(trailingAlignment.y, closeTo(0, 0.01)); + expect(trailingRect.right, lessThan(menuItemRect.right)); + expect(trailingRect.left, greaterThan(childRect.left)); + }); + + testWidgets('RTL trailing position', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.rtl, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect trailingRect = tester.getRect(find.byKey(Tag.trailing.key)); + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double trailingWidth = childRect.left - menuItemRect.left; + final trailingSize = ui.Size(trailingWidth, menuItemRect.height); + final Rect trailingWidgetRect = tester + .getRect(find.byKey(Tag.trailing.key)) + .translate(-menuItemRect.left, -menuItemRect.top); + final Alignment trailingAlignment = offsetAlongSize( + trailingWidgetRect.center, + trailingSize, + ); + + expect(trailingAlignment.x, closeTo(0.2727, 0.01)); + expect(trailingAlignment.y, closeTo(0, 0.01)); + expect(trailingRect.left, greaterThan(menuItemRect.left)); + expect(trailingRect.right, lessThan(childRect.right)); + }); + + testWidgets('trailingMidpointAlignment adjusts to dynamic type', ( + WidgetTester tester, + ) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Alignment getTrailingAlignment() { + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double trailingWidth = menuItemRect.right - childRect.right; + final trailingSize = ui.Size(trailingWidth, menuItemRect.height); + final Rect trailingWidgetRect = tester + .getRect(find.byKey(Tag.trailing.key)) + .translate(-childRect.right, -menuItemRect.top); + return offsetAlongSize(trailingWidgetRect.center, trailingSize); + } + + Alignment alignment = getTrailingAlignment(); + expect(alignment.x, closeTo(-0.2963, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + alignment = getTrailingAlignment(); + expect(alignment.x, closeTo(-0.2963, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + + await tester.pumpWidget(buildApp()); + + alignment = getTrailingAlignment(); + expect(alignment.x, closeTo(-0.2727, 0.01)); + expect(alignment.y, closeTo(0, 0.01)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + expect(find.byKey(Tag.trailing.key), findsNothing); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + expect(find.byKey(Tag.trailing.key), findsNothing); + }); + + testWidgets('trailingWidth adjusts to dynamic type', (WidgetTester tester) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Rect childRect() => tester.getRect(find.text(Tag.child.text)); + Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem)); + double trailingWidth() => menuItemRect().right - childRect().right; + + expect(trailingWidth(), closeTo(40.5, 0.25)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + expect(trailingWidth(), closeTo(40.5, 0.25)); + + await tester.pumpWidget(buildApp()); + + expect(trailingWidth(), closeTo(44.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + expect(trailingWidth(), closeTo(16.0, 0.5)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + expect(trailingWidth(), closeTo(16.0, 0.5)); + }); + + testWidgets('trailingWidth is quantized to pixel ratio', (WidgetTester tester) async { + Widget buildApp({ + TextScaler textScaler = AccessibilityTextSize.large, + double devicePixelRatio = 2.0, + }) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: textScaler, devicePixelRatio: devicePixelRatio), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + controller.open(); + await tester.pumpAndSettle(); + + Rect childRect() => tester.getRect(find.text(Tag.child.text)); + Rect menuItemRect() => tester.getRect(find.byType(CupertinoMenuItem)); + + final double trailingWidth2x = menuItemRect().right - childRect().right; + expect(trailingWidth2x - trailingWidth2x.floorToDouble(), closeTo(1 / 2, 0.01)); + + await tester.pumpWidget( + buildApp(devicePixelRatio: 3.0, textScaler: AccessibilityTextSize.xSmall), + ); + + final double trailingWidth3x = menuItemRect().right - childRect().right; + expect(trailingWidth3x - trailingWidth3x.floorToDouble(), closeTo(2 / 3, 0.01)); + }); + + testWidgets('custom trailingWidth', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + trailingWidth: 60, + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + child: Center(key: Tag.child.key, child: Text(Tag.child.text)), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byKey(Tag.child.key)); + final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final double trailingSpace = menuItemRect.right - child.right; + + expect(trailingSpace, moreOrLessEquals(60, epsilon: 1)); + }); + + testWidgets('custom trailingMidpointAlignment', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: EdgeInsets.zero, + trailingWidth: 60, + trailingMidpointAlignment: const Alignment(0.5, 0.5), + trailing: Container( + color: CupertinoColors.systemRed, + width: 5, + height: 5, + key: Tag.trailing.key, + ), + onPressed: () {}, + child: Center(key: Tag.child.key, child: Text(Tag.child.text)), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final ui.Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final ui.Rect childRect = tester.getRect(find.byKey(Tag.child.key)); + final double trailingWidth = menuItemRect.right - childRect.right; + final trailingSize = ui.Size(trailingWidth, menuItemRect.height); + final Rect trailingWidgetRect = tester + .getRect(find.byKey(Tag.trailing.key)) + .translate(-childRect.right, -menuItemRect.top); + final Alignment trailingAlignment = offsetAlongSize( + trailingWidgetRect.center, + trailingSize, + ); + + expect(trailingAlignment.x, closeTo(0.5, 0.01)); + expect(trailingAlignment.y, closeTo(0.5, 0.01)); + }); + }); + + group('Subtitle ', () { + testWidgets('default layout', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final double largeSubtitleLineHeight = lineHeight(DynamicTypeStyle.subhead.large); + final Rect subtitleRect = tester.getRect(find.text(Tag.subtitle.text)); + final Rect childRect = tester.getRect(find.text(Tag.child.text)); + + expect(subtitleRect.height, closeTo(largeSubtitleLineHeight, 0.1)); + expect(subtitleRect.width, equals(childRect.width)); + expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1)); + expect(subtitleRect.left, equals(childRect.left)); + expect(subtitleRect.right, equals(childRect.right)); + }); + + testWidgets('subtitle text overflow', (WidgetTester tester) async { + final String longText = 'Very long subtitle ' * 100; + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + subtitle: Text(longText), + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Size subtitleText = tester.getSize(find.text(longText)); + final TextStyle subtitleStyle = DynamicTypeStyle.subhead.large; + expect(subtitleText.height, closeTo(lineHeight(subtitleStyle) * 2, 1)); // 2 lines of text + }); + + testWidgets('subtitle text overflow in large text mode', (WidgetTester tester) async { + final String longText = 'Very long text ' * 100; + + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData(textScaler: AccessibilityTextSize.ax1), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + subtitle: Text(longText), + child: Text(Tag.child.text), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final RenderParagraph paragraph = findDescendantParagraph(tester, find.text(longText))!; + expect(paragraph.maxLines, equals(100)); + expect(tester.getSize(find.text(longText)).height, closeTo(3100, 1)); + }); + + testWidgets('subtitle adjusts to dynamic type', (WidgetTester tester) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leading: Icon(CupertinoIcons.left_chevron, key: Tag.leading.key), + trailing: Icon(CupertinoIcons.right_chevron, key: Tag.trailing.key), + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Rect subtitleRect = tester.getRect(find.text(Tag.subtitle.text)); + Rect childRect = tester.getRect(find.text(Tag.child.text)); + final double undersizedSubtitleLineHeight = lineHeight(DynamicTypeStyle.subhead.xSmall); + expect(subtitleRect.height, closeTo(undersizedSubtitleLineHeight, 0.1)); + expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1)); + expect(subtitleRect.left, equals(childRect.left)); + expect(subtitleRect.right, equals(childRect.right)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + childRect = tester.getRect(find.text(Tag.child.text)); + subtitleRect = tester.getRect(find.text(Tag.subtitle.text)); + final double xSmallSubtitleLineHeight = lineHeight(DynamicTypeStyle.subhead.xSmall); + expect(subtitleRect.height, closeTo(xSmallSubtitleLineHeight, 0.1)); + expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1)); + expect(subtitleRect.left, equals(childRect.left)); + expect(subtitleRect.right, equals(childRect.right)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + await tester.pumpAndSettle(); + + childRect = tester.getRect(find.text(Tag.child.text)); + subtitleRect = tester.getRect(find.text(Tag.subtitle.text)); + expect(subtitleRect.height, closeTo(110, 3)); + expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1)); + expect(subtitleRect.left, equals(childRect.left)); + expect(subtitleRect.right, equals(childRect.right)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + childRect = tester.getRect(find.text(Tag.child.text)); + subtitleRect = tester.getRect(find.text(Tag.subtitle.text)); + expect(subtitleRect.height, closeTo(110, 3)); + expect(subtitleRect.top, closeTo(childRect.bottom + 1, 0.1)); + expect(subtitleRect.left, equals(childRect.left)); + expect(subtitleRect.right, equals(childRect.right)); + }); + }); + + group('Padding', () { + testWidgets('default padding', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + leadingWidth: 0, + trailingWidth: 0, + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + final Rect childRect = tester.getRect(find.text(Tag.child.text)); + final Rect subtitleRect = tester.getRect(find.text(Tag.subtitle.text)); + + expect(childRect.top - menuItemRect.top, closeTo(10.8, 0.1)); + expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(10.8, 0.1)); + expect(childRect.left - menuItemRect.left, closeTo(0.0, 0.1)); + expect(menuItemRect.right - childRect.right, closeTo(0.0, 0.1)); + }); + + testWidgets('padding adjusts to dynamic type', (WidgetTester tester) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + subtitle: Text(Tag.subtitle.text), + child: Text(Tag.child.text), + ), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Rect getMenuItemRect() => tester.getRect(find.byType(CupertinoMenuItem)); + Rect getChildRect() => tester.getRect(find.text(Tag.child.text)); + Rect getSubtitleRect() => tester.getRect(find.text(Tag.subtitle.text)); + + Rect childRect = getChildRect(); + Rect menuItemRect = getMenuItemRect(); + Rect subtitleRect = getSubtitleRect(); + + expect(childRect.top - menuItemRect.top, closeTo(9.3, 0.1)); + expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(9.3, 0.1)); + expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1)); + expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + childRect = getChildRect(); + menuItemRect = getMenuItemRect(); + subtitleRect = getSubtitleRect(); + + expect(childRect.top - menuItemRect.top, closeTo(9.3, 0.1)); + expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(9.3, 0.1)); + expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1)); + expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + childRect = getChildRect(); + menuItemRect = getMenuItemRect(); + subtitleRect = getSubtitleRect(); + + expect(childRect.top - menuItemRect.top, closeTo(30.5, 0.1)); + expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(30.5, 0.1)); + expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1)); + expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + childRect = getChildRect(); + menuItemRect = getMenuItemRect(); + subtitleRect = getSubtitleRect(); + + expect(childRect.top - menuItemRect.top, closeTo(30.5, 0.1)); + expect(menuItemRect.bottom - subtitleRect.bottom, closeTo(30.5, 0.1)); + expect(childRect.left - menuItemRect.left, closeTo(16.0, 0.1)); + expect(menuItemRect.right - childRect.right, closeTo(16.0, 0.1)); + }); + + testWidgets('LTR custom padding', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: const EdgeInsets.fromLTRB(7, 17, 13, 11), + onPressed: () {}, + child: Center(key: Tag.child.key, child: Text(Tag.child.text)), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byKey(Tag.child.key)); + final Rect item = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(child.top - item.top, closeTo(17.0, 0.1)); + expect(item.bottom - child.bottom, closeTo(11.0, 0.1)); + + // Padding is applied in addition to the leading and trailing width. + expect(child.left - item.left, closeTo(7.0 + 16, 0.1)); + expect(item.right - child.right, closeTo(13.0 + 16, 0.1)); + }); + + testWidgets('RTL custom padding', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.rtl, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: const EdgeInsetsDirectional.fromSTEB(7, 17, 13, 11), + onPressed: () {}, + child: Center(key: Tag.child.key, child: Text(Tag.child.text)), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byKey(Tag.child.key)); + final Rect item = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(child.top - item.top, closeTo(17.0, 0.1)); + expect(item.bottom - child.bottom, closeTo(11.0, 0.1)); + expect(item.right - child.right, closeTo(7.0 + 16, 0.1)); + expect(child.left - item.left, closeTo(13.0 + 16, 0.1)); + }); + + testWidgets('LTR custom padding is added to leading and trailing width', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: const EdgeInsetsDirectional.only(start: 7, end: 13), + leadingWidth: 19, + trailingWidth: 23, + onPressed: () {}, + child: Center(key: Tag.child.key, child: Text(Tag.child.text)), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byKey(Tag.child.key)); + final Rect item = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(item.right - child.right, closeTo(13.0 + 23, 0.1)); + expect(child.left - item.left, closeTo(7.0 + 19, 0.1)); + }); + + testWidgets('RTL custom padding is added to leading and trailing width', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + Directionality( + textDirection: TextDirection.rtl, + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: const EdgeInsetsDirectional.only(start: 7, end: 13), + leadingWidth: 19, + trailingWidth: 23, + onPressed: () {}, + child: Center(key: Tag.child.key, child: Text(Tag.child.text)), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect child = tester.getRect(find.byKey(Tag.child.key)); + final Rect item = tester.getRect(find.byType(CupertinoMenuItem)); + + expect(item.right - child.right, closeTo(7.0 + 19, 0.1)); + expect(child.left - item.left, closeTo(13.0 + 23, 0.1)); + }); + + testWidgets('padding is applied before constraints', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + padding: const EdgeInsets.fromLTRB(30, 7, 30, 11), + constraints: const BoxConstraints(minHeight: 100, maxWidth: 50), + onPressed: () {}, + child: Text(Tag.child.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Size menuItemRect = tester.getSize(find.byType(CupertinoMenuItem)); + + expect(menuItemRect.height, equals(100)); + expect(menuItemRect.width, equals(50)); + }); + }); + + group('Constraints', () { + testWidgets('custom constraints applied', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + constraints: const BoxConstraints(minHeight: 80, maxWidth: 100), + onPressed: () {}, + child: const Text('Child'), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Size item = tester.getSize(find.byType(CupertinoMenuItem)); + + expect(item.height, greaterThanOrEqualTo(80)); + expect(item.width, greaterThanOrEqualTo(100)); + }); + + testWidgets('custom constraints are constrained by menu constraints', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + constraints: const BoxConstraints(minWidth: 500), + onPressed: () {}, + child: const Text('Child'), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Size item = tester.getSize(find.byType(CupertinoMenuItem)); + + expect(item.width, equals(262)); + }); + + testWidgets('minimum height constraint adjusts to dynamic type', ( + WidgetTester tester, + ) async { + Widget buildApp({TextScaler textScaler = AccessibilityTextSize.large}) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: textScaler, devicePixelRatio: 2.0), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.undersized)); + controller.open(); + await tester.pumpAndSettle(); + + Rect menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + expect(menuItemRect.height, closeTo(37.5, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.xSmall)); + + menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + expect(menuItemRect.height, closeTo(37.5, 0.1)); + + await tester.pumpWidget(buildApp()); + + menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + expect(menuItemRect.height, closeTo(43.5, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.ax5)); + + menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + expect(menuItemRect.height, closeTo(123.0, 0.1)); + + await tester.pumpWidget(buildApp(textScaler: AccessibilityTextSize.oversized)); + + menuItemRect = tester.getRect(find.byType(CupertinoMenuItem)); + expect(menuItemRect.height, closeTo(123.0, 0.1)); + }); + + testWidgets('minimum height constraint is quantized to pixel ratio', ( + WidgetTester tester, + ) async { + Widget buildApp({ + TextScaler textScaler = AccessibilityTextSize.large, + required double devicePixelRatio, + }) { + return App( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: textScaler, devicePixelRatio: devicePixelRatio), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(devicePixelRatio: 2.0)); + controller.open(); + await tester.pumpAndSettle(); + + final double minimumHeight2x = tester.getSize(find.byType(CupertinoMenuItem)).height; + + expect(minimumHeight2x - minimumHeight2x.floorToDouble(), closeTo(1 / 2, 0.01)); + + await tester.pumpWidget(buildApp(devicePixelRatio: 3.0)); + + final double minimumHeight3x = tester.getSize(find.byType(CupertinoMenuItem)).height; + + expect(minimumHeight3x - minimumHeight3x.floorToDouble(), closeTo(2 / 3, 0.01)); + }); + + testWidgets('unconstrained width outside of menu', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 800)); + await tester.pumpWidget( + App( + Column( + children: <Widget>[ + Center( + child: CupertinoMenuItem(onPressed: () {}, child: Text(Tag.child.text)), + ), + ], + ), + ), + ); + final ui.Size size = tester.getSize(find.byType(CupertinoMenuItem)); + expect(size.width, equals(800)); + expect(size.height, closeTo(43.5, 0.25)); + }); + }); + }); + + testWidgets('onFocusChange is called on enabled items', (WidgetTester tester) async { + final focusChanges = <bool>[]; + final disabledFocusChanges = <bool>[]; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + focusNode: focusNode, + onFocusChange: focusChanges.add, + onPressed: () {}, + child: Text(Tag.a.text), + ), + CupertinoMenuItem(onFocusChange: disabledFocusChanges.add, child: Text(Tag.b.text)), + CupertinoMenuItem(child: Text(Tag.c.text), onPressed: () {}), + ], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + // Move focus to first item + focusNode.requestFocus(); + await tester.pump(); + + // Move focus away + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + // Move focus back + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + // Close menu, should lose focus + controller.close(); + await tester.pumpAndSettle(); + + expect(focusChanges, <bool>[true, false, true, false]); + expect(disabledFocusChanges, isEmpty); + }); + + testWidgets('onHover is called on enabled items', (WidgetTester tester) async { + final hovered = <(Tag, bool)>[]; + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + onHover: (bool value) { + hovered.add((Tag.a, value)); + }, + child: Text(Tag.a.text), + ), + + // Disabled item -- should not request focus + CupertinoMenuItem( + onHover: (bool value) { + hovered.add((Tag.b, value)); + }, + child: Text(Tag.b.text, key: Tag.b.key), + ), + + CupertinoMenuItem( + onPressed: () {}, + onHover: (bool value) { + hovered.add((Tag.c, value)); + }, + child: Text(Tag.c.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + + // (Tag.a, true) + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + await gesture.moveTo(tester.getCenter(find.text(Tag.b.text))); + await tester.pump(); + + // (Tag.a, false) + // (Tag.c, true) + await gesture.moveTo(tester.getCenter(find.text(Tag.c.text))); + await tester.pump(); + + // (Tag.c, false) + // (Tag.a, true) + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + expect(hovered, <(Tag, bool)>[ + (Tag.a, true), + (Tag.a, false), + (Tag.c, true), + (Tag.c, false), + (Tag.a, true), + ]); + }); + + testWidgets('onPressed is called when set', (WidgetTester tester) async { + var pressed = 0; + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () { + pressed += 1; + }, + child: Text(Tag.a.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Tap when partially open + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(pressed, 1); + + controller.open(); + await tester.pumpAndSettle(); + + // Tap when fully open + await tester.tap(find.text(Tag.a.text)); + await tester.pumpAndSettle(); + + expect(pressed, 2); + + controller.open(); + await tester.pumpAndSettle(); + + controller.close(); + + await tester.pump(); + + // Do not tap if closing. + await tester.tap(find.text(Tag.a.text), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(pressed, 2); + }); + + testWidgets('HitTestBehavior can be set', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text)), + CupertinoMenuItem( + onPressed: () {}, + behavior: HitTestBehavior.translucent, + child: Text(Tag.b.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final RawGestureDetector first = tester.firstWidget( + find.widgetWithText(RawGestureDetector, Tag.a.text), + ); + + // Test default + expect(first.behavior, HitTestBehavior.opaque); + + final RawGestureDetector second = tester.firstWidget( + find.widgetWithText(RawGestureDetector, Tag.b.text), + ); + + // Test custom + expect(second.behavior, HitTestBehavior.translucent); + }); + + testWidgets('respects requestFocusOnHover property', (WidgetTester tester) async { + final focusChanges = <(Tag, bool)>[]; + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onFocusChange: (bool value) { + focusChanges.add((Tag.a, value)); + }, + onPressed: () {}, + child: Text(Tag.a.text), + ), + + // Disabled item -- should not request focus + CupertinoMenuItem( + onFocusChange: (bool value) { + focusChanges.add((Tag.b, value)); + }, + child: Text(Tag.b.text, key: Tag.b.key), + ), + + CupertinoMenuItem( + onFocusChange: (bool value) { + focusChanges.add((Tag.c, value)); + }, + onPressed: () {}, + child: Text(Tag.c.text), + ), + + // requestFocusOnHover is false -- should not request focus + CupertinoMenuItem( + requestFocusOnHover: false, + onFocusChange: (bool value) { + focusChanges.add((Tag.d, value)); + }, + onPressed: () {}, + child: Text(Tag.d.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + + // (Tag.a, true) + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + await gesture.moveTo(tester.getCenter(find.text(Tag.b.text))); + await tester.pump(); + + // (Tag.a, false) + // (Tag.c, true) + await gesture.moveTo(tester.getCenter(find.text(Tag.c.text))); + await tester.pump(); + + await gesture.moveTo(tester.getCenter(find.text(Tag.d.text))); + await tester.pump(); + + // (Tag.c, false) + // (Tag.a, true) + await gesture.moveTo(tester.getCenter(find.text(Tag.a.text))); + await tester.pump(); + + expect(focusChanges, <(Tag, bool)>[ + (Tag.a, true), + (Tag.a, false), + (Tag.c, true), + (Tag.c, false), + (Tag.a, true), + ]); + }); + + testWidgets('respects closeOnActivate property', (WidgetTester tester) async { + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + requestCloseOnActivate: false, + onPressed: () {}, + child: Text(Tag.a.text), + ), + ], + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + // Taps the CupertinoMenuItem which should close the menu + await tester.tap(find.text(Tag.a.text)); + await tester.pumpAndSettle(); + + expect(controller.isOpen, isTrue); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(key: UniqueKey(), onPressed: () {}, child: Text(Tag.a.text)), + ], + ), + ), + ); + // Taps the CupertinoMenuItem which should close the menu + await tester.tap(find.byType(CupertinoMenuItem)); + await tester.pumpAndSettle(); + + expect(controller.isOpen, isFalse); + }); + + testWidgets('Focus node can be changed', (WidgetTester tester) async { + final focusNode1 = FocusNode(debugLabel: 'Node 1'); + final focusNode2 = FocusNode(debugLabel: 'Node 2'); + addTearDown(focusNode1.dispose); + addTearDown(focusNode2.dispose); + + Widget buildApp(FocusNode? focusNode) { + return App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem(onPressed: () {}, focusNode: focusNode, child: Text(Tag.a.text)), + ], + child: const AnchorButton(Tag.anchor), + ), + ); + } + + await tester.pumpWidget(buildApp(focusNode1)); + + controller.open(); + + await tester.pumpAndSettle(); + + focusNode1.requestFocus(); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + await tester.pumpWidget(buildApp(focusNode2)); + await tester.pump(); + + focusNode2.requestFocus(); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isFalse); + expect(focusNode2.hasPrimaryFocus, isTrue); + + await tester.pumpWidget(buildApp(null)); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isFalse); + expect(focusNode2.hasPrimaryFocus, isFalse); + }); + + testWidgets('Autofocus works', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + autofocus: true, + focusNode: focusNode, + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + controller.open(); + // Wait for focus effect to resolve (microtasks) + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('Changing DeviceGestureSettings does not throw', (WidgetTester tester) async { + // There is no simple way to verify that the touch slop is being respected other than + // attempting a gesture that would be affected by it. This test ensures that no exceptions + // are thrown when the touch slop is changed during a gesture. + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData(gestureSettings: DeviceGestureSettings(touchSlop: 30.0)), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + // Simulate a tap down at the center of the widget. + final Offset center = tester.getCenter(find.byType(CupertinoMenuItem)); + final TestGesture gesture = await tester.startGesture(center); + addTearDown(gesture.removePointer); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.pumpWidget( + App( + MediaQuery( + data: const MediaQueryData(gestureSettings: DeviceGestureSettings(touchSlop: 100.0)), + child: CupertinoMenuAnchor( + controller: controller, + menuChildren: <Widget>[CupertinoMenuItem(onPressed: () {}, child: Text(Tag.a.text))], + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + group('Semantics', () { + testWidgets('CupertinoMenuItem default semantics', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: CupertinoMenuItem( + onPressed: () {}, + constraints: BoxConstraints.tight(const Size(250, 48.0)), + child: Text(Tag.a.text), + ), + ), + ), + ); + final SemanticsHandle handle = tester.ensureSemantics(); + + // The flags should not have SemanticsFlag.isButton + expect( + tester.getSemantics(find.widgetWithText(CupertinoMenuItem, Tag.a.text)), + matchesSemantics( + hasTapAction: true, + hasDismissAction: true, + hasFocusAction: true, + isEnabled: true, + isFocusable: true, + hasEnabledState: true, + textDirection: TextDirection.rtl, + rect: const Rect.fromLTRB(0.0, 0.0, 250, 48), + ), + ); + + handle.dispose(); + }); + + testWidgets('CupertinoMenuItem disabled semantics', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoMenuItem( + constraints: BoxConstraints.tight(const Size(250, 48.0)), + child: Text(Tag.a.text), + ), + ), + ), + ); + + final SemanticsHandle handle = tester.ensureSemantics(); + + // The flags should not have SemanticsFlag.isButton + expect( + tester.getSemantics(find.widgetWithText(CupertinoMenuItem, Tag.a.text)), + matchesSemantics( + hasEnabledState: true, + textDirection: TextDirection.ltr, + rect: const Rect.fromLTRB(0.0, 0.0, 250, 48), + ), + ); + + handle.dispose(); + }); + + testWidgets( + 'CupertinoMenuAnchor semantics', + // [intended] Web inserts overlay contents as a sibling to the anchor rather than a child. + skip: kIsWeb, + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + constraints: BoxConstraints.tight(const Size(250, 48.0)), + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.byType(AnchorButton)); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'anchor', + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 5, + children: <TestSemantics>[ + TestSemantics( + id: 6, + children: <TestSemantics>[ + TestSemantics( + id: 7, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 8, + flags: <SemanticsFlag>[ + SemanticsFlag.hasImplicitScrolling, + ], + children: <TestSemantics>[ + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.dismiss, + SemanticsAction.focus, + ], + label: 'a', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ); + semantics.dispose(); + }, + ); + + testWidgets( + 'CupertinoMenuAnchor semantics (web)', + // [intended] Web inserts overlay contents as a sibling to the anchor rather than a child. + skip: !kIsWeb, + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + App( + CupertinoMenuAnchor( + menuChildren: <Widget>[ + CupertinoMenuItem( + onPressed: () {}, + constraints: BoxConstraints.tight(const Size(250, 48.0)), + child: Text(Tag.a.text), + ), + ], + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.byType(AnchorButton)); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'anchor', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + id: 5, + children: <TestSemantics>[ + TestSemantics( + id: 6, + children: <TestSemantics>[ + TestSemantics( + id: 7, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 8, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.dismiss, + SemanticsAction.focus, + ], + label: 'a', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ); + semantics.dispose(); + }, + ); + }); +} + +// ********* UTILITIES ********* // +/// Allows the creation of arbitrarily-nested tags in tests. +abstract class Tag { + const Tag(); + + static const NestedTag anchor = NestedTag('anchor'); + static const NestedTag outside = NestedTag('outside'); + static const NestedTag a = NestedTag('a'); + static const NestedTag b = NestedTag('b'); + static const NestedTag c = NestedTag('c'); + static const NestedTag d = NestedTag('d'); + + static const NestedTag child = NestedTag('child'); + static const NestedTag subtitle = NestedTag('subtitle'); + static const NestedTag leading = NestedTag('leading'); + static const NestedTag trailing = NestedTag('trailing'); + + String get text; + int get level; + + @override + String toString() { + return 'Tag($text, level: $level)'; + } +} + +class NestedTag extends Tag { + const NestedTag(String name, {Tag? prefix, this.level = 0}) + : assert( + // Limit the nesting level to prevent stack overflow. + level < 9, + 'NestedTag.level must be less than 9 (was $level).', + ), + _name = name, + _prefix = prefix; + + final String _name; + final Tag? _prefix; + + @override + final int level; + + NestedTag get a => NestedTag('a', prefix: this, level: level + 1); + NestedTag get b => NestedTag('b', prefix: this, level: level + 1); + NestedTag get c => NestedTag('c', prefix: this, level: level + 1); + + @override + String get text { + if (level == 0 || _prefix == null) { + return _name; + } + return '${_prefix.text}.$_name'; + } + + Key get key => ValueKey<String>('${text}_Key'); +} + +class AnchorButton extends StatelessWidget { + const AnchorButton( + this.tag, { + super.key, + this.onPressed, + this.constraints, + this.autofocus = false, + this.focusNode, + }); + + final Tag tag; + final void Function(Tag)? onPressed; + final bool autofocus; + final BoxConstraints? constraints; + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) { + final MenuController? controller = MenuController.maybeOf(context); + return CupertinoButton.filled( + minimumSize: constraints?.biggest, + onPressed: () { + onPressed?.call(tag); + if (controller != null) { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + }, + focusNode: focusNode, + autofocus: autofocus, + child: Text(tag.text), + ); + } +} + +class App extends StatelessWidget { + const App(this.child, {super.key, this.textDirection, this.alignment = Alignment.center}); + final Widget child; + final TextDirection? textDirection; + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + return CupertinoApp( + home: ColoredBox( + color: const Color(0xff000000), + child: Directionality( + textDirection: textDirection ?? Directionality.maybeOf(context) ?? TextDirection.ltr, + child: Align(alignment: alignment, child: child), + ), + ), + ); + } +} + +abstract class AccessibilityTextSize { + static const TextScaler xSmall = TextScaler.linear(1 - 3 / 17); + static const TextScaler small = TextScaler.linear(1 - 2 / 17); + static const TextScaler medium = TextScaler.linear(1 - 1 / 17); + static const TextScaler large = TextScaler.noScaling; + static const TextScaler xLarge = TextScaler.linear(1 + 2 / 17); + static const TextScaler xxLarge = TextScaler.linear(1 + 4 / 17); + static const TextScaler xxxLarge = TextScaler.linear(1 + 6 / 17); + static const TextScaler ax1 = TextScaler.linear(1 + 11 / 17); + static const TextScaler ax2 = TextScaler.linear(1 + 16 / 17); + static const TextScaler ax3 = TextScaler.linear(1 + 23 / 17); + static const TextScaler ax4 = TextScaler.linear(1 + 30 / 17); + static const TextScaler ax5 = TextScaler.linear(1 + 36 / 17); + + // For testing + static const TextScaler oversized = TextScaler.linear(1 + 46 / 17); + static const TextScaler undersized = TextScaler.linear(1 - 10 / 17); + + static List<TextScaler> get values => <TextScaler>[ + xSmall, + small, + medium, + large, + xLarge, + xxLarge, + xxxLarge, + ax1, + ax2, + ax3, + ax4, + ax5, + ]; +} + +/// The font family for menu items at smaller text scales. +const String _kBodyFont = 'CupertinoSystemText'; + +/// The font family for menu items at larger text scales. +const String _kDisplayFont = 'CupertinoSystemDisplay'; + +enum DynamicTypeStyle { + body( + xSmall: TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont), + small: TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont), + medium: TextStyle(fontSize: 16, height: 21 / 16, letterSpacing: -0.31, fontFamily: _kBodyFont), + large: TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont), + xLarge: TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.44, fontFamily: _kBodyFont), + xxLarge: TextStyle(fontSize: 21, height: 26 / 21, letterSpacing: -0.36, fontFamily: _kBodyFont), + xxxLarge: TextStyle( + fontSize: 23, + height: 29 / 23, + letterSpacing: -0.10, + fontFamily: _kDisplayFont, + ), + ax1: TextStyle(fontSize: 28, height: 34 / 28, letterSpacing: 0.38, fontFamily: _kDisplayFont), + ax2: TextStyle(fontSize: 33, height: 40 / 33, letterSpacing: 0.40, fontFamily: _kDisplayFont), + ax3: TextStyle(fontSize: 40, height: 48 / 40, letterSpacing: 0.37, fontFamily: _kDisplayFont), + ax4: TextStyle(fontSize: 47, height: 56 / 47, letterSpacing: 0.37, fontFamily: _kDisplayFont), + ax5: TextStyle(fontSize: 53, height: 62 / 53, letterSpacing: 0.31, fontFamily: _kDisplayFont), + ), + subhead( + xSmall: TextStyle(fontSize: 12, height: 16 / 12, letterSpacing: 0, fontFamily: _kBodyFont), + small: TextStyle(fontSize: 13, height: 18 / 13, letterSpacing: -0.08, fontFamily: _kBodyFont), + medium: TextStyle(fontSize: 14, height: 19 / 14, letterSpacing: -0.15, fontFamily: _kBodyFont), + large: TextStyle(fontSize: 15, height: 20 / 15, letterSpacing: -0.23, fontFamily: _kBodyFont), + xLarge: TextStyle(fontSize: 17, height: 22 / 17, letterSpacing: -0.43, fontFamily: _kBodyFont), + xxLarge: TextStyle(fontSize: 19, height: 24 / 19, letterSpacing: -0.45, fontFamily: _kBodyFont), + xxxLarge: TextStyle( + fontSize: 21, + height: 28 / 21, + letterSpacing: -0.36, + fontFamily: _kBodyFont, + ), + ax1: TextStyle(fontSize: 25, height: 31 / 25, letterSpacing: 0.15, fontFamily: _kDisplayFont), + ax2: TextStyle(fontSize: 30, height: 37 / 30, letterSpacing: 0.40, fontFamily: _kDisplayFont), + ax3: TextStyle(fontSize: 36, height: 43 / 36, letterSpacing: 0.37, fontFamily: _kDisplayFont), + ax4: TextStyle(fontSize: 42, height: 50 / 42, letterSpacing: 0.37, fontFamily: _kDisplayFont), + ax5: TextStyle(fontSize: 49, height: 58 / 49, letterSpacing: 0.33, fontFamily: _kDisplayFont), + ); + + const DynamicTypeStyle({ + required this.xSmall, + required this.small, + required this.medium, + required this.large, + required this.xLarge, + required this.xxLarge, + required this.xxxLarge, + required this.ax1, + required this.ax2, + required this.ax3, + required this.ax4, + required this.ax5, + }); + + final TextStyle xSmall; + final TextStyle small; + final TextStyle medium; + final TextStyle large; + final TextStyle xLarge; + final TextStyle xxLarge; + final TextStyle xxxLarge; + final TextStyle ax1; + final TextStyle ax2; + final TextStyle ax3; + final TextStyle ax4; + final TextStyle ax5; + + TextStyle resolveTextStyle(TextScaler textScaler) { + final double units = (textScaler.scale(17) - 17).roundToDouble(); + return switch (units) { + <= -3 => xSmall, + == -2 => small, + == -1 => medium, + == 0 => large, + == 2 => xLarge, + == 4 => xxLarge, + == 6 => xxxLarge, + == 11 => ax1, + == 16 => ax2, + == 23 => ax3, + == 30 => ax4, + == 36 => ax5, + _ => ax5, + }; + } +} + +class DebugCupertinoMenuEntry extends StatelessWidget implements CupertinoMenuEntry { + const DebugCupertinoMenuEntry({ + super.key, + bool hasLeading = false, + this.isDivider = false, + this.child, + }) : _hasLeading = hasLeading; + + final bool _hasLeading; + + @override + bool hasLeading(BuildContext context) { + return _hasLeading; + } + + @override + final bool isDivider; + + final Widget? child; + + @override + Widget build(BuildContext context) { + return child ?? + Container( + height: 30, + width: 100, + color: isDivider ? CupertinoColors.systemMint : CupertinoColors.systemOrange, + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/nav_bar_test.dart b/packages/cupertino_ui/test/cupertino/nav_bar_test.dart new file mode 100644 index 000000000000..11311e7930d9 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/nav_bar_test.dart @@ -0,0 +1,3361 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +int count = 0; + +void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); +} + +void main() { + testWidgets('Middle still in center with asymmetrical actions', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar( + leading: CupertinoButton(onPressed: null, child: Text('Something')), + middle: Text('Title'), + ), + ), + ); + + // Expect the middle of the title to be exactly in the middle of the screen. + expect(tester.getCenter(find.text('Title')).dx, 400.0); + }); + + testWidgets('largeTitle is aligned with asymmetrical actions', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar.large( + leading: CupertinoButton(onPressed: null, child: Text('Something')), + largeTitle: Text('Title'), + ), + ), + ); + + expect(tester.getCenter(find.text('Title')).dx, greaterThan(110.0)); + expect(tester.getCenter(find.text('Title')).dx, lessThan(111.0)); + }); + + testWidgets('Middle still in center with back button', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar(middle: Text('Title'))), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoNavigationBar(middle: Text('Page 2')); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + // Expect the middle of the title to be exactly in the middle of the screen. + expect(tester.getCenter(find.text('Page 2')).dx, 400.0); + }); + + testWidgets('largeTitle still aligned with back button', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar.large(largeTitle: Text('Title'))), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoNavigationBar.large(largeTitle: Text('Page 2')); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(tester.getCenter(find.text('Page 2')).dx, greaterThan(129.0)); + expect(tester.getCenter(find.text('Page 2')).dx, lessThan(130.0)); + }); + + testWidgets( + 'Opaque background does not add blur effects, non-opaque background adds blur effects', + (WidgetTester tester) async { + const background = CupertinoDynamicColor.withBrightness( + color: Color(0xFFE5E5E5), + darkColor: Color(0xF3E5E5E5), + ); + + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + backgroundColor: background, + ), + child: ListView(controller: scrollController, children: const <Widget>[Placeholder()]), + ), + ), + ); + + scrollController.jumpTo(100.0); + await tester.pump(); + + expect( + tester.widget(find.byType(BackdropFilter)), + isA<BackdropFilter>().having( + (BackdropFilter filter) => filter.enabled, + 'filter enabled', + false, + ), + ); + expect(find.byType(CupertinoNavigationBar), paints..rect(color: background.color)); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + backgroundColor: background, + ), + child: ListView(controller: scrollController, children: const <Widget>[Placeholder()]), + ), + ), + ); + + scrollController.jumpTo(100.0); + await tester.pump(); + + expect( + tester.widget(find.byType(BackdropFilter)), + isA<BackdropFilter>().having((BackdropFilter f) => f.enabled, 'filter enabled', true), + ); + expect(find.byType(CupertinoNavigationBar), paints..rect(color: background.darkColor)); + }, + ); + + testWidgets("Background doesn't add blur effect when no content is scrolled under", ( + WidgetTester test, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await test.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Title')), + child: ListView(controller: scrollController, children: const <Widget>[Placeholder()]), + ), + ), + ); + + expect( + test.widget(find.byType(BackdropFilter)), + isA<BackdropFilter>().having( + (BackdropFilter filter) => filter.enabled, + 'filter enabled', + false, + ), + ); + + scrollController.jumpTo(100.0); + await test.pump(); + + expect( + test.widget(find.byType(BackdropFilter)), + isA<BackdropFilter>().having( + (BackdropFilter filter) => filter.enabled, + 'filter enabled', + true, + ), + ); + }); + + testWidgets('Blur affect is disabled when enableBackgroundFilterBlur is false', ( + WidgetTester test, + ) async { + await test.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + backgroundColor: const Color(0xFFFFFFFF).withOpacity(0), + middle: const Text('Title'), + automaticBackgroundVisibility: false, + enableBackgroundFilterBlur: false, + ), + child: const Column(children: <Widget>[Placeholder()]), + ), + ), + ); + + expect( + test.widget(find.byType(BackdropFilter)), + isA<BackdropFilter>().having( + (BackdropFilter filter) => filter.enabled, + 'filter enabled', + false, + ), + ); + }); + + testWidgets('Nav bar displays correctly', (WidgetTester tester) async { + final navigator = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + CupertinoApp( + navigatorKey: navigator, + home: const CupertinoNavigationBar(middle: Text('Page 1')), + ), + ); + navigator.currentState!.push<void>( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoNavigationBar(middle: Text('Page 2')); + }, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoNavigationBarBackButton), findsOneWidget); + // Pops the page 2 + navigator.currentState!.pop(); + await tester.pump(); + // Needs another pump to trigger the rebuild; + await tester.pump(); + // The back button should still persist; + expect(find.byType(CupertinoNavigationBarBackButton), findsOneWidget); + // The app does not crash + expect(tester.takeException(), isNull); + }); + + testWidgets('Can specify custom padding', (WidgetTester tester) async { + final Key middleBox = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topCenter, + child: CupertinoNavigationBar( + leading: const CupertinoButton(onPressed: null, child: Text('Cheetah')), + // Let the box take all the vertical space to test vertical padding but let + // the nav bar position it horizontally. + middle: Align(key: middleBox, widthFactor: 1.0, child: const Text('Title')), + trailing: const CupertinoButton(onPressed: null, child: Text('Puma')), + padding: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + top: 3.0, + bottom: 4.0, + ), + ), + ), + ), + ); + + expect(tester.getRect(find.byKey(middleBox)).top, 3.0); + // 44 is the standard height of the nav bar. + expect( + tester.getRect(find.byKey(middleBox)).bottom, + // 44 is the standard height of the nav bar. + 44.0 - 4.0, + ); + + expect(tester.getTopLeft(find.widgetWithText(CupertinoButton, 'Cheetah')).dx, 10.0); + expect(tester.getTopRight(find.widgetWithText(CupertinoButton, 'Puma')).dx, 800.0 - 20.0); + + // Title is still exactly centered. + expect(tester.getCenter(find.text('Title')).dx, 400.0); + }); + + // Assert that two SystemUiOverlayStyle instances have the same values for + // status bar properties and that the first instance has no system navigation + // bar properties set. + void expectSameStatusBarStyle(SystemUiOverlayStyle style, SystemUiOverlayStyle expectedStyle) { + expect(style.statusBarColor, expectedStyle.statusBarColor); + expect(style.statusBarBrightness, expectedStyle.statusBarBrightness); + expect(style.statusBarIconBrightness, expectedStyle.statusBarIconBrightness); + expect(style.systemStatusBarContrastEnforced, expectedStyle.systemStatusBarContrastEnforced); + expect(style.systemNavigationBarColor, isNull); + expect(style.systemNavigationBarContrastEnforced, isNull); + expect(style.systemNavigationBarDividerColor, isNull); + expect(style.systemNavigationBarIconBrightness, isNull); + } + + // Regression test for https://github.com/flutter/flutter/issues/119270 + testWidgets('System navigation bar properties are not overridden', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar(backgroundColor: Color(0xF0F9F9F9))), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.dark); + }); + + testWidgets('Can specify custom brightness', (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar( + backgroundColor: Color(0xF0F9F9F9), + brightness: Brightness.dark, + ), + ), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.light); + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar( + backgroundColor: Color(0xF01D1D1D), + brightness: Brightness.light, + ), + ), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.dark); + + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Title'), + backgroundColor: Color(0xF0F9F9F9), + brightness: Brightness.dark, + ), + ], + ), + ), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.light); + + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Title'), + backgroundColor: Color(0xF01D1D1D), + brightness: Brightness.light, + ), + ], + ), + ), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.dark); + }); + + testWidgets('Padding works in RTL', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topCenter, + child: CupertinoNavigationBar( + leading: CupertinoButton(onPressed: null, child: Text('Cheetah')), + // Let the box take all the vertical space to test vertical padding but let + // the nav bar position it horizontally. + middle: Text('Title'), + trailing: CupertinoButton(onPressed: null, child: Text('Puma')), + padding: EdgeInsetsDirectional.only(start: 10.0, end: 20.0), + ), + ), + ), + ), + ); + + expect(tester.getTopRight(find.widgetWithText(CupertinoButton, 'Cheetah')).dx, 800.0 - 10.0); + expect(tester.getTopLeft(find.widgetWithText(CupertinoButton, 'Puma')).dx, 20.0); + + // Title is still exactly centered. + expect(tester.getCenter(find.text('Title')).dx, 400.0); + }); + + testWidgets('Nav bar uses theme defaults', (WidgetTester tester) async { + count = 0x000000; + await tester.pumpWidget( + CupertinoApp( + home: CupertinoNavigationBar( + leading: CupertinoButton( + onPressed: () {}, + child: _ExpectStyles(color: CupertinoColors.systemBlue.color, index: 0x000001), + ), + middle: const _ExpectStyles(color: CupertinoColors.black, index: 0x000100), + trailing: CupertinoButton( + onPressed: () {}, + child: _ExpectStyles(color: CupertinoColors.systemBlue.color, index: 0x010000), + ), + ), + ), + ); + expect(count, 0x010101); + }); + + testWidgets('Nav bar respects themes', (WidgetTester tester) async { + count = 0x000000; + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoNavigationBar( + leading: CupertinoButton( + onPressed: () {}, + child: _ExpectStyles(color: CupertinoColors.systemBlue.darkColor, index: 0x000001), + ), + middle: const _ExpectStyles(color: CupertinoColors.white, index: 0x000100), + trailing: CupertinoButton( + onPressed: () {}, + child: _ExpectStyles(color: CupertinoColors.systemBlue.darkColor, index: 0x010000), + ), + ), + ), + ); + expect(count, 0x010101); + }); + + testWidgets('Theme active color can be overridden', (WidgetTester tester) async { + count = 0x000000; + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(primaryColor: Color(0xFF001122)), + home: CupertinoNavigationBar( + leading: CupertinoButton( + onPressed: () {}, + child: const _ExpectStyles(color: Color(0xFF001122), index: 0x000001), + ), + middle: const _ExpectStyles(color: Color(0xFF000000), index: 0x000100), + trailing: CupertinoButton( + onPressed: () {}, + child: const _ExpectStyles(color: Color(0xFF001122), index: 0x010000), + ), + ), + ), + ); + expect(count, 0x010101); + }); + + testWidgets('Nav bar static components respect MediaQueryData', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/174642 + const value = 10.0; + + void expectCustomMediaQueryData(BuildContext context) { + expect(MediaQuery.platformBrightnessOf(context), Brightness.dark); + expect(MediaQuery.devicePixelRatioOf(context), value); + expect(MediaQuery.viewInsetsOf(context), const EdgeInsets.all(value)); + } + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData( + devicePixelRatio: value, + viewInsets: EdgeInsets.all(value), + platformBrightness: Brightness.dark, + ), + child: CupertinoNavigationBar( + leading: Builder( + builder: (BuildContext context) { + expectCustomMediaQueryData(context); + return CupertinoButton(onPressed: () {}, child: const Text('leading')); + }, + ), + middle: Builder( + builder: (BuildContext context) { + expectCustomMediaQueryData(context); + return CupertinoButton(onPressed: () {}, child: const Text('middle')); + }, + ), + trailing: Builder( + builder: (BuildContext context) { + expectCustomMediaQueryData(context); + return CupertinoButton(onPressed: () {}, child: const Text('trailing')); + }, + ), + ), + ), + ), + ); + expect(find.text('leading'), findsOneWidget); + expect(find.text('middle'), findsOneWidget); + expect(find.text('trailing'), findsOneWidget); + }); + + testWidgets('No slivers with no large titles', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Title')), + child: Center(), + ), + ), + ); + + expect(find.byType(SliverPersistentHeader), findsNothing); + }); + + testWidgets('Media padding is applied to CupertinoSliverNavigationBar', ( + WidgetTester tester, + ) async { + final Key leadingKey = GlobalKey(); + final Key middleKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + final Key titleKey = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: 10.0, left: 20.0, bottom: 30.0, right: 40.0), + ), + child: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar( + leading: Placeholder(key: leadingKey), + middle: Placeholder(key: middleKey), + largeTitle: Text('Large Title', key: titleKey), + trailing: Placeholder(key: trailingKey), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ), + ); + + // Media padding applied to leading (T,L), middle (T), trailing (T, R). + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(16.0 + 20.0, 10.0)); + expect(tester.getRect(find.byKey(middleKey)).top, 10.0); + expect(tester.getTopRight(find.byKey(trailingKey)), const Offset(800.0 - 16.0 - 40.0, 10.0)); + + // Top and left padding is applied to large title. + expect(tester.getTopLeft(find.byKey(titleKey)), const Offset(16.0 + 20.0, 54.0 + 10.0)); + }); + + testWidgets('Large title nav bar scrolls', (WidgetTester tester) async { + setWindowToPortrait(tester); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: Text('Title')), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0); + expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0); + + expect(find.text('Title'), findsNWidgets(2)); // Though only one is visible. + + List<Element> titles = tester.elementList(find.text('Title')).toList() + ..sort((Element a, Element b) { + final aParagraph = a.renderObject! as RenderParagraph; + final bParagraph = b.renderObject! as RenderParagraph; + return aParagraph.text.style!.fontSize!.compareTo(bParagraph.text.style!.fontSize!); + }); + + Iterable<double> opacities = titles.map<double>((Element element) { + final RenderAnimatedOpacity renderOpacity = element + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + return renderOpacity.opacity.value; + }); + + expect(opacities, <double>[ + 0.0, // Initially the smaller font title is invisible. + 1.0, // The larger font title is visible. + ]); + + expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0); + expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 52.0); + + scrollController.jumpTo(600.0); + await tester.pump(); // Once to trigger the opacity animation. + await tester.pump(const Duration(milliseconds: 300)); + + titles = tester.elementList(find.text('Title')).toList() + ..sort((Element a, Element b) { + final aParagraph = a.renderObject! as RenderParagraph; + final bParagraph = b.renderObject! as RenderParagraph; + return aParagraph.text.style!.fontSize!.compareTo(bParagraph.text.style!.fontSize!); + }); + + opacities = titles.map<double>((Element element) { + final RenderAnimatedOpacity renderOpacity = element + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + return renderOpacity.opacity.value; + }); + + expect(opacities, <double>[ + 1.0, // Smaller font title now visible + 0.0, // Larger font title invisible. + ]); + + // The persistent toolbar doesn't move or change size. + expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0); + expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0); + + expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0); + // The OverflowBox is squished with the text in it. + expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 0.0); + }); + + testWidgets('User specified middle is always visible in sliver', (WidgetTester tester) async { + setWindowToPortrait(tester); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final Key segmentedControlsKey = UniqueKey(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + CupertinoSliverNavigationBar( + middle: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200.0), + child: CupertinoSegmentedControl<int>( + key: segmentedControlsKey, + children: const <int, Widget>{0: Text('Option A'), 1: Text('Option B')}, + onValueChanged: (int selected) {}, + groupValue: 0, + ), + ), + largeTitle: const Text('Title'), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0); + expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0); + + expect(find.text('Title'), findsOneWidget); + expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0); + + expect(tester.getTopLeft(find.widgetWithText(ClipRect, 'Title').first).dy, 44.0); + expect(tester.getSize(find.widgetWithText(ClipRect, 'Title').first).height, 52.0); + + scrollController.jumpTo(600.0); + await tester.pump(); // Once to trigger the opacity animation. + await tester.pump(const Duration(milliseconds: 300)); + + expect(tester.getCenter(find.byKey(segmentedControlsKey)).dx, 400.0); + // The large title is invisible now. + expect( + tester + .renderObject<RenderAnimatedOpacity>(find.widgetWithText(AnimatedOpacity, 'Title')) + .opacity + .value, + 0.0, + ); + }); + + testWidgets( + 'User specified middle is only visible when sliver is collapsed if alwaysShowMiddle is false', + (WidgetTester tester) async { + setWindowToPortrait(tester); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large'), + middle: Text('Middle'), + alwaysShowMiddle: false, + ), + SliverToBoxAdapter(child: SizedBox(height: 1200.0)), + ], + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + expect(find.text('Middle'), findsOneWidget); + + // Initially (in expanded state) middle widget is not visible. + RenderAnimatedOpacity middleOpacity = tester + .element(find.text('Middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + expect(middleOpacity.opacity.value, 0.0); + + scrollController.jumpTo(600.0); + await tester.pumpAndSettle(); + + // Middle widget is visible when nav bar is collapsed. + middleOpacity = tester + .element(find.text('Middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + expect(middleOpacity.opacity.value, 1.0); + + scrollController.jumpTo(0.0); + await tester.pumpAndSettle(); + + // Middle widget is not visible when nav bar is again expanded. + middleOpacity = tester + .element(find.text('Middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + expect(middleOpacity.opacity.value, 0.0); + }, + ); + + testWidgets('Small title can be overridden', (WidgetTester tester) async { + setWindowToPortrait(tester); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + middle: Text('Different title'), + largeTitle: Text('Title'), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0); + expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0); + + expect(find.text('Title'), findsOneWidget); + expect(find.text('Different title'), findsOneWidget); + + RenderAnimatedOpacity largeTitleOpacity = tester + .element(find.text('Title')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + // Large title initially visible. + expect(largeTitleOpacity.opacity.value, 1.0); + // Middle widget not even wrapped with RenderOpacity, i.e. is always visible. + expect( + tester.element(find.text('Different title')).findAncestorRenderObjectOfType<RenderOpacity>(), + isNull, + ); + + expect( + tester.getBottomLeft(find.text('Title')).dy, + 44.0 + 52.0 - 8.0, + ); // Static part + extension - padding. + + scrollController.jumpTo(600.0); + await tester.pump(); // Once to trigger the opacity animation. + await tester.pump(const Duration(milliseconds: 300)); + + largeTitleOpacity = tester + .element(find.text('Title')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + // Large title no longer visible. + expect(largeTitleOpacity.opacity.value, 0.0); + + // The persistent toolbar doesn't move or change size. + expect(tester.getTopLeft(find.byType(NavigationToolbar)).dy, 0.0); + expect(tester.getSize(find.byType(NavigationToolbar)).height, 44.0); + + expect(tester.getBottomLeft(find.text('Title')).dy, 44.0); // Extension gone. + }); + + testWidgets('Auto back/cancel button', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar(middle: Text('Home page'))), + ); + + expect(find.byType(CupertinoButton), findsNothing); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoNavigationBar(middle: Text('Page 2')); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.byType(CupertinoButton), findsOneWidget); + expect(find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), findsOneWidget); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + fullscreenDialog: true, + builder: (BuildContext context) { + return const CupertinoNavigationBar(middle: Text('Dialog page')); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Test popping goes back correctly. + await tester.tap(find.text('Cancel')); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.text(String.fromCharCode(CupertinoIcons.back.codePoint))); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.text('Home page'), findsOneWidget); + }); + + testWidgets('Navigation bars in a CupertinoSheetRoute have no back button', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar(middle: Text('Home page'))), + ); + + expect(find.byType(CupertinoButton), findsNothing); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Page 2')), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + // No back button is found. + expect(find.byType(CupertinoButton), findsNothing); + expect(find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), findsNothing); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[CupertinoSliverNavigationBar(largeTitle: Text('Page 3'))], + ), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + // No back button is found. + expect(find.byType(CupertinoButton), findsNothing); + expect(find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), findsNothing); + }); + + testWidgets('Long back label turns into "back"', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(previousPageTitle: '012345678901'), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.widgetWithText(CupertinoButton, '012345678901'), findsOneWidget); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(previousPageTitle: '0123456789012'), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.widgetWithText(CupertinoButton, 'Back'), findsOneWidget); + }); + + testWidgets('Border should be displayed by default', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar(middle: Text('Title'))), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.border, isNotNull); + + final BorderSide side = decoration.border!.bottom; + expect(side, isNotNull); + }); + + testWidgets('Overrides border color', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar( + automaticBackgroundVisibility: false, + middle: Text('Title'), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + ), + ), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.border, isNotNull); + + final BorderSide side = decoration.border!.bottom; + expect(side, isNotNull); + expect(side.color, const Color(0xFFAABBCC)); + }); + + testWidgets('Border should not be displayed when null', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoNavigationBar(middle: Text('Title'), border: null)), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.border, isNull); + }); + + testWidgets('Border is displayed by default in sliver nav bar', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[CupertinoSliverNavigationBar(largeTitle: Text('Large Title'))], + ), + ), + ), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.border, isNotNull); + + final BorderSide bottom = decoration.border!.bottom; + expect(bottom, isNotNull); + }); + + testWidgets('Border is not displayed when null in sliver nav bar', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar(largeTitle: Text('Large Title'), border: null), + ], + ), + ), + ), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.border, isNull); + }); + + testWidgets('CupertinoSliverNavigationBar has semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar(largeTitle: Text('Large Title'), border: null), + ], + ), + ), + ), + ); + + expect( + semantics.nodesWith( + label: 'Large Title', + flags: <SemanticsFlag>[SemanticsFlag.isHeader], + textDirection: TextDirection.ltr, + ), + hasLength(1), + ); + + semantics.dispose(); + }); + + testWidgets('CupertinoNavigationBar has semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Fixed Title')), + child: Container(), + ), + ), + ); + + expect( + semantics.nodesWith( + label: 'Fixed Title', + flags: <SemanticsFlag>[SemanticsFlag.isHeader], + textDirection: TextDirection.ltr, + ), + hasLength(1), + ); + + semantics.dispose(); + }); + + testWidgets('Large CupertinoNavigationBar has semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar.large(largeTitle: Text('Fixed Title')), + child: Container(), + ), + ), + ); + + expect( + semantics.nodesWith( + label: 'Fixed Title', + flags: <SemanticsFlag>[SemanticsFlag.isHeader], + textDirection: TextDirection.ltr, + ), + hasLength(1), + ); + + semantics.dispose(); + }); + + testWidgets('Border can be overridden in sliver nav bar', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar( + automaticBackgroundVisibility: false, + largeTitle: Text('Large Title'), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + ), + ], + ), + ), + ), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.border, isNotNull); + + final BorderSide top = decoration.border!.top; + expect(top, isNotNull); + expect(top, BorderSide.none); + final BorderSide bottom = decoration.border!.bottom; + expect(bottom, isNotNull); + expect(bottom.color, const Color(0xFFAABBCC)); + }); + + testWidgets('Static standard title golden', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Bling bling')), + child: Center(), + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.standard_title.png'), + ); + }); + + testWidgets('Static large title golden', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar.large(largeTitle: Text('Bling bling')), + child: Center(), + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.large_title.png'), + ); + }); + + testWidgets('Sliver large title golden', (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: Text('Bling bling')), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.sliver.large_title.png'), + ); + }); + + testWidgets('Sliver middle title golden', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: RepaintBoundary( + child: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + middle: Text('Bling bling'), + largeTitle: Text('Bling bling'), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ), + ); + await tester.drag(find.byType(Scrollable), const Offset(0.0, -250.0)); + await tester.pump(); + + await expectLater( + find.byType(RepaintBoundary).last, + matchesGoldenFile('nav_bar_test.sliver.middle_title.png'), + ); + }); + + testWidgets( + 'Nav bar background is transparent if `automaticBackgroundVisibility` is true and has no content scrolled under it', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + backgroundColor: const Color(0xFFFFFFFF), + navigationBar: const CupertinoNavigationBar( + backgroundColor: Color(0xFFE5E5E5), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + middle: Text('Title'), + ), + child: ListView(controller: scrollController, children: const <Widget>[Placeholder()]), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + final BorderSide side = decoration.border!.bottom; + expect(side.color.opacity, 0.0); + + // Appears transparent since the background color is the same as the scaffold. + expect(find.byType(CupertinoNavigationBar), paints..rect(color: const Color(0xFFFFFFFF))); + + scrollController.jumpTo(100.0); + await tester.pump(); + + final decoratedBoxAfterScroll = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBoxAfterScroll.decoration.runtimeType, BoxDecoration); + + final BorderSide borderAfterScroll = + (decoratedBoxAfterScroll.decoration as BoxDecoration).border!.bottom; + + expect(borderAfterScroll.color.opacity, 1.0); + + expect(find.byType(CupertinoNavigationBar), paints..rect(color: const Color(0xFFE5E5E5))); + }, + ); + + testWidgets( + 'automaticBackgroundVisibility parameter has no effect if nav bar is not a child of CupertinoPageScaffold', + (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoNavigationBar( + backgroundColor: Color(0xFFE5E5E5), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + middle: Text('Title'), + ), + ), + ); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + final BorderSide side = decoration.border!.bottom; + expect(side.color, const Color(0xFFAABBCC)); + + expect(find.byType(CupertinoNavigationBar), paints..rect(color: const Color(0xFFE5E5E5))); + }, + ); + + testWidgets('Nav bar background is always visible if `automaticBackgroundVisibility` is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + automaticBackgroundVisibility: false, + backgroundColor: Color(0xFFE5E5E5), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + middle: Text('Title'), + ), + child: Placeholder(), + ), + ), + ); + + var decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + var decoration = decoratedBox.decoration as BoxDecoration; + BorderSide side = decoration.border!.bottom; + expect(side.color, const Color(0xFFAABBCC)); + + expect(find.byType(CupertinoNavigationBar), paints..rect(color: const Color(0xFFE5E5E5))); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + automaticBackgroundVisibility: false, + backgroundColor: Color(0xFFE5E5E5), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + largeTitle: Text('Title'), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + decoration = decoratedBox.decoration as BoxDecoration; + side = decoration.border!.bottom; + expect(side.color, const Color(0xFFAABBCC)); + + expect(find.byType(CupertinoSliverNavigationBar), paints..rect(color: const Color(0xFFE5E5E5))); + }); + + testWidgets( + 'CupertinoSliverNavigationBar background is transparent if `automaticBackgroundVisibility` is true and has no content scrolled under it', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + backgroundColor: const Color(0xFFFFFFFF), + child: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + backgroundColor: Color(0xFFE5E5E5), + border: Border(bottom: BorderSide(color: Color(0xFFAABBCC), width: 0.0)), + largeTitle: Text('Title'), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + final decoratedBox = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + final BorderSide side = decoration.border!.bottom; + expect(side.color.opacity, 0.0); + + // Appears transparent since the background color is the same as the scaffold. + expect( + find.byType(CupertinoSliverNavigationBar), + paints..rect(color: const Color(0xFFFFFFFF)), + ); + + scrollController.jumpTo(400.0); + await tester.pump(); + + final decoratedBoxAfterScroll = + tester + .widgetList( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(DecoratedBox), + ), + ) + .first + as DecoratedBox; + expect(decoratedBoxAfterScroll.decoration.runtimeType, BoxDecoration); + + final BorderSide borderAfterScroll = + (decoratedBoxAfterScroll.decoration as BoxDecoration).border!.bottom; + + expect(borderAfterScroll.color.opacity, 1.0); + + expect( + find.byType(CupertinoSliverNavigationBar), + paints..rect(color: const Color(0xFFE5E5E5)), + ); + }, + ); + + testWidgets('NavBar draws a light system bar for a dark background', (WidgetTester tester) async { + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return const CupertinoNavigationBar( + middle: Text('Test'), + backgroundColor: Color(0xFF000000), + ); + }, + ); + }, + ), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.light); + }); + + testWidgets('NavBar draws a dark system bar for a light background', (WidgetTester tester) async { + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return const CupertinoNavigationBar( + middle: Text('Test'), + backgroundColor: Color(0xFFFFFFFF), + ); + }, + ); + }, + ), + ); + expectSameStatusBarStyle(SystemChrome.latestStyle!, SystemUiOverlayStyle.dark); + }); + + testWidgets( + 'CupertinoNavigationBarBackButton shows an error when manually added outside a route', + (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoNavigationBarBackButton()); + + final dynamic exception = tester.takeException(); + expect(exception, isAssertionError); + expect( + exception.toString(), + contains( + 'CupertinoNavigationBarBackButton should only be used in routes that can be popped', + ), + ); + }, + ); + + testWidgets( + 'CupertinoNavigationBarBackButton shows an error when placed in a route that cannot be popped', + (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoNavigationBarBackButton())); + + final dynamic exception = tester.takeException(); + expect(exception, isAssertionError); + expect( + exception.toString(), + contains( + 'CupertinoNavigationBarBackButton should only be used in routes that can be popped', + ), + ); + }, + ); + + testWidgets( + 'CupertinoNavigationBarBackButton with a custom onPressed callback can be placed anywhere', + (WidgetTester tester) async { + var backPressed = false; + + await tester.pumpWidget( + CupertinoApp(home: CupertinoNavigationBarBackButton(onPressed: () => backPressed = true)), + ); + + expect(tester.takeException(), isNull); + expect(find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), findsOneWidget); + + await tester.tap(find.byType(CupertinoNavigationBarBackButton)); + + expect(backPressed, true); + }, + ); + + testWidgets('Manually inserted CupertinoNavigationBarBackButton still automatically ' + 'show previous page title when possible', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'A Phone', + builder: (BuildContext context) { + return const CupertinoNavigationBarBackButton(); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.widgetWithText(CupertinoButton, 'An iPod'), findsOneWidget); + }); + + testWidgets('CupertinoNavigationBarBackButton onPressed overrides default pop behavior', ( + WidgetTester tester, + ) async { + var backPressed = false; + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'A Phone', + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + leading: CupertinoNavigationBarBackButton(onPressed: () => backPressed = true), + ), + child: const Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + await tester.tap(find.byType(CupertinoNavigationBarBackButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + // The second page is still on top and didn't pop. + expect(find.text('A Phone'), findsOneWidget); + // Custom onPressed called. + expect(backPressed, true); + }); + + testWidgets('Nav bar contents text scale', (WidgetTester tester) async { + setWindowToPortrait(tester); + const scaleFactor = 1.18; + const dampingRatio = 3.0; + const iconSize = 10.0; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: const CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large Title'), + leading: Text('leading'), + trailing: Text('trailing'), + middle: Text('middle'), + searchField: CupertinoSearchTextField( + placeholder: 'Search', + prefixIcon: Icon(CupertinoIcons.add), + suffixIcon: Icon(CupertinoIcons.xmark), + suffixMode: OverlayVisibilityMode.always, + itemSize: iconSize, + ), + ), + SliverFillRemaining(), + ], + ), + ), + ); + }, + ), + ), + ); + + final Iterable<RichText> barItems = tester.widgetList<RichText>( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(RichText), + ), + ); + + expect(barItems.length, greaterThan(0)); + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == 'Large Title' && + t.textScaler == const TextScaler.linear(1.0 + ((scaleFactor - 1.0) / dampingRatio)), + ), + isTrue, + ); + for (final text in <String>['Search', 'leading', 'middle', 'trailing']) { + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == text && t.textScaler == const TextScaler.linear(scaleFactor), + ), + isTrue, + ); + } + for (final icon in <IconData>[CupertinoIcons.add, CupertinoIcons.xmark]) { + expect(tester.getSize(find.byIcon(icon)), const Size.square(scaleFactor * iconSize)); + } + }); + + testWidgets('Persistent nav bar text scaling clamps to upper bound', (WidgetTester tester) async { + setWindowToPortrait(tester); + const scaleFactor = 10.0; + const maxScaleFactor = 1.235; + const dampingRatio = 3.0; + const iconSize = 10.0; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: const CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large Title'), + leading: Text('leading'), + trailing: Text('trailing'), + middle: Text('middle'), + searchField: CupertinoSearchTextField( + placeholder: 'Search', + prefixIcon: Icon(CupertinoIcons.add), + suffixIcon: Icon(CupertinoIcons.xmark), + suffixMode: OverlayVisibilityMode.always, + itemSize: iconSize, + ), + ), + SliverFillRemaining(), + ], + ), + ), + ); + }, + ), + ), + ); + + final Iterable<RichText> barItems = tester.widgetList<RichText>( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(RichText), + ), + ); + + expect(barItems.length, greaterThan(0)); + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == 'Large Title' && + t.textScaler == const TextScaler.linear(1.0 + ((scaleFactor - 1.0) / dampingRatio)), + ), + isTrue, + ); + for (final text in <String>['leading', 'middle', 'trailing']) { + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == text && + t.textScaler == const TextScaler.linear(maxScaleFactor), + ), + isTrue, + ); + } + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == 'Search' && + t.textScaler == const TextScaler.linear(scaleFactor), + ), + isTrue, + ); + for (final icon in <IconData>[CupertinoIcons.add, CupertinoIcons.xmark]) { + expect(tester.getSize(find.byIcon(icon)), const Size.square(scaleFactor * iconSize)); + } + }); + + testWidgets('Nav bar text scaling clamps to lower bounds', (WidgetTester tester) async { + setWindowToPortrait(tester); + const scaleFactor = 0.5; + const minScaleFactor = 0.9; + const iconSize = 10.0; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: const CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large Title'), + leading: Text('leading'), + trailing: Text('trailing'), + middle: Text('middle'), + searchField: CupertinoSearchTextField( + placeholder: 'Search', + prefixIcon: Icon(CupertinoIcons.add), + suffixIcon: Icon(CupertinoIcons.xmark), + suffixMode: OverlayVisibilityMode.always, + itemSize: iconSize, + ), + ), + SliverFillRemaining(), + ], + ), + ), + ); + }, + ), + ), + ); + + final Iterable<RichText> barItems = tester.widgetList<RichText>( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(RichText), + ), + ); + + expect(barItems.length, greaterThan(0)); + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == 'Large Title' && + t.textScaler == const TextScaler.linear(minScaleFactor), + ), + isTrue, + ); + for (final text in <String>['leading', 'middle', 'trailing']) { + expect( + barItems.any( + (RichText t) => t.text.toPlainText() == text && t.textScaler == TextScaler.noScaling, + ), + isTrue, + ); + } + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == 'Search' && + t.textScaler == const TextScaler.linear(scaleFactor), + ), + isTrue, + ); + for (final icon in <IconData>[CupertinoIcons.add, CupertinoIcons.xmark]) { + expect(tester.getSize(find.byIcon(icon)), const Size.square(scaleFactor * iconSize)); + } + }); + + testWidgets('Active search view cancel button does not text scale', (WidgetTester tester) async { + setWindowToPortrait(tester); + const scaleFactor = 10.0; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: const CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large Title'), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(), + ], + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.byType(CupertinoSearchTextField), warnIfMissed: false); + await tester.pumpAndSettle(); + + final Iterable<RichText> barItems = tester.widgetList<RichText>( + find.descendant( + of: find.byType(CupertinoSliverNavigationBar), + matching: find.byType(RichText), + ), + ); + + expect(barItems.length, greaterThan(0)); + expect( + barItems.any( + (RichText t) => + t.text.toPlainText() == 'Search' && + t.textScaler == const TextScaler.linear(scaleFactor), + ), + isTrue, + ); + expect( + barItems.any( + (RichText t) => t.text.toPlainText() == 'Cancel' && t.textScaler == TextScaler.noScaling, + ), + isTrue, + ); + }); + + testWidgets( + 'CupertinoSliverNavigationBar stretches upon over-scroll and bounces back once over-scroll ends', + (WidgetTester tester) async { + const trailingText = Text('Bar Button'); + const titleText = Text('Large Title'); + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + trailing: trailingText, + largeTitle: titleText, + stretch: true, + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder trailingTextFinder = find.byWidget(trailingText).first; + final Finder titleTextFinder = find.byWidget(titleText).first; + + final Offset initialTrailingTextToLargeTitleOffset = + tester.getTopLeft(trailingTextFinder) - tester.getTopLeft(titleTextFinder); + + // Drag for overscroll + await tester.drag(find.byType(Scrollable), const Offset(0.0, 150.0)); + await tester.pump(); + + final Offset stretchedTrailingTextToLargeTitleOffset = + tester.getTopLeft(trailingTextFinder) - tester.getTopLeft(titleTextFinder); + + expect( + stretchedTrailingTextToLargeTitleOffset.dy.abs(), + greaterThan(initialTrailingTextToLargeTitleOffset.dy.abs()), + ); + + // Ensure overscroll retracts to original size after releasing gesture + await tester.pumpAndSettle(); + + final Offset finalTrailingTextToLargeTitleOffset = + tester.getTopLeft(trailingTextFinder) - tester.getTopLeft(titleTextFinder); + + expect( + finalTrailingTextToLargeTitleOffset.dy.abs(), + initialTrailingTextToLargeTitleOffset.dy.abs(), + ); + }, + ); + + testWidgets( + 'CupertinoSliverNavigationBar does not stretch upon over-scroll if stretch parameter is false', + (WidgetTester tester) async { + const trailingText = Text('Bar Button'); + const titleText = Text('Large Title'); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar(trailing: trailingText, largeTitle: titleText), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder trailingTextFinder = find.byWidget(trailingText).first; + final Finder titleTextFinder = find.byWidget(titleText).first; + + final Offset initialTrailingTextToLargeTitleOffset = + tester.getTopLeft(trailingTextFinder) - tester.getTopLeft(titleTextFinder); + + // Drag for overscroll + await tester.drag(find.byType(Scrollable), const Offset(0.0, 150.0)); + await tester.pump(); + + final Offset stretchedTrailingTextToLargeTitleOffset = + tester.getTopLeft(trailingTextFinder) - tester.getTopLeft(titleTextFinder); + + expect( + stretchedTrailingTextToLargeTitleOffset.dy.abs(), + initialTrailingTextToLargeTitleOffset.dy.abs(), + ); + + // Ensure overscroll is zero after releasing gesture + await tester.pumpAndSettle(); + + final Offset finalTrailingTextToLargeTitleOffset = + tester.getTopLeft(trailingTextFinder) - tester.getTopLeft(titleTextFinder); + + expect( + finalTrailingTextToLargeTitleOffset.dy.abs(), + initialTrailingTextToLargeTitleOffset.dy.abs(), + ); + }, + ); + + testWidgets('Null NavigationBar border transition', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/71389 + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Page 1'), border: null), + child: Placeholder(), + ), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Page 2'), border: null), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.text(String.fromCharCode(CupertinoIcons.back.codePoint))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets( + 'CupertinoSliverNavigationBar magnifies upon over-scroll and shrinks back once over-scroll ends', + (WidgetTester tester) async { + setWindowToPortrait(tester); + const titleText = Text('Large Title'); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: titleText, stretch: true), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder titleTextFinder = find.byWidget(titleText).first; + + // Gets the height of the large title + final Offset initialLargeTitleTextOffset = + tester.getBottomLeft(titleTextFinder) - tester.getTopLeft(titleTextFinder); + + // Drag for overscroll + await tester.drag(find.byType(Scrollable), const Offset(0.0, 150.0)); + await tester.pump(); + + final Offset magnifiedTitleTextOffset = + tester.getBottomLeft(titleTextFinder) - tester.getTopLeft(titleTextFinder); + + expect(magnifiedTitleTextOffset.dy.abs(), greaterThan(initialLargeTitleTextOffset.dy.abs())); + + // Ensure title text retracts to original size after releasing gesture + await tester.pumpAndSettle(); + + final Offset finalTitleTextOffset = + tester.getBottomLeft(titleTextFinder) - tester.getTopLeft(titleTextFinder); + + expect(finalTitleTextOffset.dy.abs(), initialLargeTitleTextOffset.dy.abs()); + }, + ); + + testWidgets('CupertinoSliverNavigationBar large title text does not get clipped when magnified', ( + WidgetTester tester, + ) async { + const titleText = Text('Very very very long large title'); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: titleText, stretch: true), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder titleTextFinder = find.byWidget(titleText).first; + + // Gets the width of the large title + final Offset initialLargeTitleTextOffset = + tester.getBottomLeft(titleTextFinder) - tester.getBottomRight(titleTextFinder); + + // Drag for overscroll + await tester.drag(find.byType(Scrollable), const Offset(0.0, 150.0)); + await tester.pump(); + + final Offset magnifiedTitleTextOffset = + tester.getBottomLeft(titleTextFinder) - tester.getBottomRight(titleTextFinder); + + expect(magnifiedTitleTextOffset.dx.abs(), equals(initialLargeTitleTextOffset.dx.abs())); + }); + + testWidgets('CupertinoSliverNavigationBar large title can be hit tested when magnified', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: Text('Large title'), stretch: true), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder largeTitleFinder = find.text('Large title').first; + + // Drag for overscroll + await tester.drag(find.byType(Scrollable), const Offset(0.0, 250.0)); + + // Hold position of the scroll view, so the Scrollable unblocks the hit-testing + scrollController.position.hold(() {}); + await tester.pumpAndSettle(); + + expect(largeTitleFinder.hitTestable(), findsOneWidget); + }); + + testWidgets('NavigationBarBottomMode.automatic mode for bottom', (WidgetTester tester) async { + const persistentHeight = 44.0; + const largeTitleHeight = 44.0; + const bottomHeight = 10.0; + final controller = ScrollController(); + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: controller, + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + expect(controller.offset, 0.0); + + final Finder largeTitleFinder = find + .ancestor(of: find.text('Large title').first, matching: find.byType(Padding)) + .first; + final Finder bottomFinder = find.byType(Placeholder); + + // The persistent navigation bar, large title, and search field are all + // visible. + expect(tester.getTopLeft(largeTitleFinder).dy, persistentHeight); + expect(tester.getBottomLeft(largeTitleFinder).dy, persistentHeight + largeTitleHeight); + expect(tester.getTopLeft(bottomFinder).dy, 96.0); + expect(tester.getBottomLeft(bottomFinder).dy, 96.0 + bottomHeight); + + // Scroll the length of the navigation bar search text field. + controller.jumpTo(bottomHeight); + await tester.pump(); + + // The search field is hidden, but the large title remains visible. + expect(tester.getBottomLeft(largeTitleFinder).dy, persistentHeight + largeTitleHeight); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + + // Scroll until the large title scrolls under the persistent navigation bar. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -400.0), 10.0); + await tester.pump(); + + // The large title and search field are both hidden. + expect(tester.getBottomLeft(largeTitleFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + + controller.dispose(); + }); + + testWidgets('NavigationBarBottomMode.always mode for bottom', (WidgetTester tester) async { + const persistentHeight = 44.0; + const largeTitleHeight = 44.0; + const bottomHeight = 10.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.always, + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder largeTitleFinder = find + .ancestor(of: find.text('Large title').first, matching: find.byType(Padding)) + .first; + final Finder bottomFinder = find.byType(Placeholder); + + // The persistent navigation bar, large title, and search field are all + // visible. + expect(tester.getTopLeft(largeTitleFinder).dy, persistentHeight); + expect(tester.getBottomLeft(largeTitleFinder).dy, persistentHeight + largeTitleHeight); + expect(tester.getTopLeft(bottomFinder).dy, 96.0); + expect(tester.getBottomLeft(bottomFinder).dy, 96.0 + bottomHeight); + + // Scroll until the large title scrolls under the persistent navigation bar. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -400.0), 10.0); + await tester.pump(); + + // Only the large title is hidden. + expect(tester.getBottomLeft(largeTitleFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + expect(tester.getTopLeft(bottomFinder).dy, persistentHeight); + expect(tester.getBottomLeft(bottomFinder).dy, persistentHeight + bottomHeight); + }); + + testWidgets('Disallow providing a bottomMode without a corresponding bottom', ( + WidgetTester tester, + ) async { + expect( + () => const CupertinoSliverNavigationBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(10.0), child: Placeholder()), + bottomMode: NavigationBarBottomMode.automatic, + ), + returnsNormally, + ); + + expect( + () => const CupertinoSliverNavigationBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(10.0), child: Placeholder()), + ), + returnsNormally, + ); + + expect( + () => CupertinoSliverNavigationBar(bottomMode: NavigationBarBottomMode.automatic), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.message, + 'message', + contains('A bottomMode was provided without a corresponding bottom.'), + ), + ), + ); + }); + + testWidgets('Overscroll when stretched does not resize bottom in automatic mode', ( + WidgetTester tester, + ) async { + const bottomHeight = 10.0; + const bottomDisplacement = 96.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar( + stretch: true, + largeTitle: Text('Large title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + SliverToBoxAdapter(child: Container(height: 1200.0)), + ], + ), + ), + ), + ); + + final Finder bottomFinder = find.byType(Placeholder); + expect(tester.getTopLeft(bottomFinder).dy, bottomDisplacement); + expect( + tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, + bottomHeight, + ); + + // Overscroll to stretch the navigation bar. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, 50.0), 10.0); + await tester.pump(); + + // The bottom stretches without resizing. + expect(tester.getTopLeft(bottomFinder).dy, greaterThan(bottomDisplacement)); + expect( + tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, + bottomHeight, + ); + }); + + testWidgets( + 'Large title snaps up to persistent nav bar when partially scrolled over halfway up', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const largeTitleHeight = 52.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.text('middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + + // The middle widget is initially invisible. + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll a little over the halfway point. + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1)); + await scrollGesture.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap to the persistent app bar. + expect(scrollController.position.pixels, largeTitleHeight); + expect(renderOpacity?.opacity.value, 1.0); + }, + ); + + testWidgets( + 'Large title snaps back to extended height when partially scrolled halfway up or less', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const largeTitleHeight = 52.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.text('middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to the halfway point. + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2))); + await scrollGesture.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap back to its extended height. + expect(scrollController.position.pixels, 0.0); + expect(renderOpacity?.opacity.value, 0.0); + }, + ); + + testWidgets( + 'Large title and bottom snap up when partially scrolled over halfway up in automatic mode', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const largeTitleHeight = 52.0; + const bottomHeight = 100.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.text('middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + final Finder bottomFinder = find.byType(Placeholder); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to just past the halfway point of the bottom widget. + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture1.moveBy(const Offset(0.0, -(bottomHeight / 2) - 1)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // Expect the bottom to snap up to the large title. + expect(scrollController.position.pixels, bottomHeight); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + expect(renderOpacity?.opacity.value, 0.0); + + // Scroll to just past the halfway point of the large title. + final TestGesture scrollGesture2 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture2.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1)); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap up to the persistent nav bar. + expect(scrollController.position.pixels, bottomHeight + largeTitleHeight); + expect(renderOpacity?.opacity.value, 1.0); + }, + ); + + testWidgets( + 'Large title and bottom snap down when partially scrolled halfway up or less in automatic mode', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const largeTitleHeight = 52.0; + const bottomHeight = 100.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.text('middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + final Finder bottomFinder = find.byType(Placeholder); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to the halfway point of the bottom widget. + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture1.moveBy(const Offset(0.0, -bottomHeight / 2)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // Expect the bottom to snap back to its extended height. + expect(scrollController.position.pixels, 0.0); + expect( + tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, + bottomHeight, + ); + expect(renderOpacity?.opacity.value, 0.0); + + // Scroll to the halfway point of the large title. + final TestGesture scrollGesture2 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture2.moveBy(const Offset(0.0, -(bottomHeight + largeTitleHeight / 2))); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap back to its extended height, which is the + // same scroll offset as the fully-shrunk bottom widget. + expect(scrollController.position.pixels, bottomHeight); + expect(renderOpacity?.opacity.value, 0.0); + }, + ); + + testWidgets( + 'Large title and bottom snap up when partially scrolled over halfway up in always mode', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const largeTitleHeight = 52.0; + const bottomHeight = 100.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.always, + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.text('middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + final Finder bottomFinder = find.byType(Placeholder); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.position.pixels, 0.0); + + // Scroll to just past the halfway point of the large title. + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture1.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap up to the persistent nav bar. + expect(scrollController.position.pixels, largeTitleHeight); + expect(renderOpacity?.opacity.value, 1.0); + + // Scroll to just past the halfway point of the bottom widget. + final TestGesture scrollGesture2 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture2.moveBy(const Offset(0.0, -(bottomHeight / 2) - 1)); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + // The bottom widget is still fully extended. + expect(scrollController.position.pixels, largeTitleHeight + (bottomHeight / 2) + 1); + expect( + tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, + bottomHeight, + ); + expect(renderOpacity?.opacity.value, 1.0); + }, + ); + + testWidgets( + 'Large title and bottom snap down when partially scrolled halfway up or less in always mode', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + const largeTitleHeight = 52.0; + setWindowToPortrait(tester); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + bottom: PreferredSize(preferredSize: Size.fromHeight(100.0), child: Placeholder()), + bottomMode: NavigationBarBottomMode.always, + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.text('middle')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to the halfway point of the large title. + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture1.moveBy(const Offset(0.0, -largeTitleHeight / 2)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // Expect the large title and bottom to snap back to their extended height. + expect(scrollController.position.pixels, 0.0); + expect(renderOpacity?.opacity.value, 0.0); + }, + ); + + testWidgets('CupertinoNavigationBar with bottom widget', (WidgetTester tester) async { + const persistentHeight = 44.0; + const bottomHeight = 10.0; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Middle'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + ), + child: Container(), + ), + ), + ); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + final CupertinoNavigationBar navBar = tester.widget<CupertinoNavigationBar>(navBarFinder); + + final Finder columnFinder = find.descendant(of: navBarFinder, matching: find.byType(Column)); + expect(columnFinder, findsOneWidget); + final Column column = tester.widget<Column>(columnFinder); + + expect(column.children.length, 2); + expect( + find.descendant(of: find.byWidget(column.children.first), matching: find.text('Middle')), + findsOneWidget, + ); + expect( + find.descendant(of: find.byWidget(column.children.last), matching: find.byType(Placeholder)), + findsOneWidget, + ); + expect(navBar.preferredSize.height, persistentHeight + bottomHeight); + }); + + testWidgets('CupertinoNavigationBar has correct height', (WidgetTester tester) async { + const persistentHeight = 44.0; + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Middle')), + child: Placeholder(), + ), + ), + ); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + + final RenderBox navBarBox = tester.renderObject(navBarFinder); + final CupertinoNavigationBar navBar = tester.widget<CupertinoNavigationBar>(navBarFinder); + + // preferredSize should only include persistent height when no large title. + expect(navBar.preferredSize.height, persistentHeight); + // The box size height should match the preferred size height. + expect(navBarBox.size.height, persistentHeight); + }); + + testWidgets('CupertinoNavigationBar with bottom widget has correct height', ( + WidgetTester tester, + ) async { + const persistentHeight = 44.0; + const bottomHeight = 20.0; + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Middle'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + ), + child: Placeholder(), + ), + ), + ); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + + final RenderBox navBarBox = tester.renderObject(navBarFinder); + final CupertinoNavigationBar navBar = tester.widget<CupertinoNavigationBar>(navBarFinder); + + // preferredSize should include persistent height and bottom height + expect(navBar.preferredSize.height, persistentHeight + bottomHeight); + // The box size height should match the preferred size height. + expect(navBarBox.size.height, persistentHeight + bottomHeight); + }); + + testWidgets('CupertinoNavigationBar.large has correct height', (WidgetTester tester) async { + const persistentHeight = 44.0; + const largeTitleExtension = 52.0; + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar.large(largeTitle: Text('Large Title')), + child: Placeholder(), + ), + ), + ); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + + final RenderBox navBarBox = tester.renderObject(navBarFinder); + final CupertinoNavigationBar navBar = tester.widget<CupertinoNavigationBar>(navBarFinder); + + // preferredSize should include both persistent height and large title extension + expect(navBar.preferredSize.height, persistentHeight + largeTitleExtension); + // The box size height should match the preferred size height. + expect(navBarBox.size.height, persistentHeight + largeTitleExtension); + }); + + testWidgets('CupertinoNavigationBar.large with bottom widget has correct height', ( + WidgetTester tester, + ) async { + const persistentHeight = 44.0; + const largeTitleExtension = 52.0; + const bottomHeight = 20.0; + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar.large( + largeTitle: Text('Large Title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + ), + child: Placeholder(), + ), + ), + ); + + final Finder navBarFinder = find.byType(CupertinoNavigationBar); + expect(navBarFinder, findsOneWidget); + + final RenderBox navBarBox = tester.renderObject(navBarFinder); + final CupertinoNavigationBar navBar = tester.widget<CupertinoNavigationBar>(navBarFinder); + + // preferredSize should include persistent height, large title extension, and bottom height + expect(navBar.preferredSize.height, persistentHeight + largeTitleExtension + bottomHeight); + // The box size height should match the preferred size height. + expect(navBarBox.size.height, persistentHeight + largeTitleExtension + bottomHeight); + }); + + testWidgets('CupertinoSliverNavigationBar.search field collapses nav bar on tap', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + leading: Icon(CupertinoIcons.person_2), + trailing: Icon(CupertinoIcons.add_circled), + largeTitle: Text('Large title'), + middle: Text('middle'), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final Finder searchFieldFinder = find.byType(CupertinoSearchTextField); + final Finder largeTitleFinder = find + .ancestor(of: find.text('Large title').first, matching: find.byType(Padding)) + .first; + final Finder middleFinder = find + .ancestor(of: find.text('middle').first, matching: find.byType(Padding)) + .first; + + // Initially, all widgets are visible. + expect(find.byIcon(CupertinoIcons.person_2), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add_circled), findsOneWidget); + expect(largeTitleFinder, findsOneWidget); + expect(middleFinder.hitTestable(), findsOneWidget); + expect(searchFieldFinder, findsOneWidget); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Tap the search field. + await tester.tap(searchFieldFinder, warnIfMissed: false); + await tester.pump(); + // Pump for the duration of the search field animation. + await tester.pump(const Duration(microseconds: 1) + const Duration(milliseconds: 300)); + + // After tapping, the leading and trailing widgets are removed from the + // widget tree, the large title is collapsed, and middle is hidden + // underneath the navigation bar. + expect(find.byIcon(CupertinoIcons.person_2), findsNothing); + expect(find.byIcon(CupertinoIcons.add_circled), findsNothing); + expect(tester.getBottomRight(largeTitleFinder).dy, 0.0); + expect(middleFinder.hitTestable(), findsNothing); + + // Search field and 'Cancel' button are visible. + expect(searchFieldFinder, findsOneWidget); + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Tap the 'Cancel' button to exit the search view. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pump(); + // Pump for the duration of the search field animation. + await tester.pump(const Duration(microseconds: 1) + const Duration(milliseconds: 300)); + + // All widgets are visible again. + expect(find.byIcon(CupertinoIcons.person_2), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add_circled), findsOneWidget); + expect(largeTitleFinder, findsOneWidget); + expect(middleFinder.hitTestable(), findsOneWidget); + expect(searchFieldFinder, findsOneWidget); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + }); + + testWidgets('CupertinoSliverNavigationBar.search golden tests', (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + const CupertinoApp( + home: RepaintBoundary( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large title'), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 300.0)), + ], + ), + ), + ), + ); + + await expectLater( + find.byType(CupertinoSliverNavigationBar), + matchesGoldenFile('nav_bar.search.inactive.png'), + ); + + // Tap the search field. + await tester.tap(find.byType(CupertinoSearchTextField), warnIfMissed: false); + await tester.pump(); + // Pump to the end of the animation. + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byType(CupertinoSliverNavigationBar), + matchesGoldenFile('nav_bar.search.active.png'), + ); + + // Tap the 'Cancel' button to exit the search view. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pump(); + // Pump for the duration of the search field animation. + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byType(CupertinoSliverNavigationBar), + matchesGoldenFile('nav_bar.search.inactive.png'), + ); + }); + + testWidgets('onSearchableBottomTap callback', (WidgetTester tester) async { + setWindowToPortrait(tester); + const activeSearchColor = Color(0x0000000A); + const inactiveSearchColor = Color(0x0000000B); + var isSearchActive = false; + var text = ''; + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + onChanged: (String value) { + setState(() { + text = 'The text has changed to: $value'; + }); + }, + ), + onSearchableBottomTap: (bool value) { + setState(() { + isSearchActive = value; + }); + }, + largeTitle: const Text('Large title'), + middle: const Text('middle'), + bottomMode: NavigationBarBottomMode.always, + ), + SliverFillRemaining( + child: ColoredBox( + color: isSearchActive ? activeSearchColor : inactiveSearchColor, + child: Text(text), + ), + ), + ], + ); + }, + ), + ), + ); + + // Initially, all widgets are visible. + expect(find.text('Large title'), findsOneWidget); + expect(find.text('middle'), findsOneWidget); + expect(find.widgetWithText(CupertinoSearchTextField, 'Search'), findsOneWidget); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ColoredBox && widget.color == inactiveSearchColor; + }), + findsOneWidget, + ); + + // Tap the search field. + await tester.tap(find.widgetWithText(CupertinoSearchTextField, 'Search'), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Search field and 'Cancel' button should be visible. + expect(isSearchActive, true); + expect(find.widgetWithText(CupertinoSearchTextField, 'Search'), findsOneWidget); + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ColoredBox && widget.color == activeSearchColor; + }), + findsOneWidget, + ); + + // Enter text into search field to search. + await tester.enterText(find.widgetWithText(CupertinoSearchTextField, 'Search'), 'aaa'); + await tester.pumpAndSettle(); + + // The entered text is shown in the search view. + expect(find.text('The text has changed to: aaa'), findsOne); + }); + + testWidgets( + 'CupertinoSliverNavigationBar.search large title and cancel buttons fade during search animation', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large title'), + middle: Text('Middle'), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + // Initially, all widgets are visible. + final RenderAnimatedOpacity largeTitleOpacity = tester + .element(find.text('Large title')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + // The opacity of the decoy 'Cancel' button, which is always semi-transparent. + final RenderOpacity decoyCancelOpacity = tester + .element(find.widgetWithText(CupertinoButton, 'Cancel')) + .findAncestorRenderObjectOfType<RenderOpacity>()!; + + expect(largeTitleOpacity.opacity.value, 1.0); + expect(decoyCancelOpacity.opacity, 0.4); + + // Tap the search field, and pump up until partway through the animation. + await tester.tap( + find.widgetWithText(CupertinoSearchTextField, 'Search'), + warnIfMissed: false, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // During the inactive-to-active search animation, the large title fades + // out and the 'Cancel' button remains at a constant semi-transparent + // value. + expect(largeTitleOpacity.opacity.value, lessThan(1.0)); + expect(largeTitleOpacity.opacity.value, greaterThan(0.0)); + expect(decoyCancelOpacity.opacity, 0.4); + + // At the end of the animation, the large title has completely faded out. + await tester.pump(const Duration(milliseconds: 300)); + expect(largeTitleOpacity.opacity.value, 0.0); + expect(decoyCancelOpacity.opacity, 0.4); + + // The opacity of the tappable 'Cancel' button. + final RenderAnimatedOpacity cancelOpacity = tester + .element(find.widgetWithText(CupertinoButton, 'Cancel')) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + + expect(cancelOpacity.opacity.value, 1.0); + + // Tap the 'Cancel' button, and pump up until partway through the animation. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // During the active-to-inactive search animation, the large title fades + // in and the 'Cancel' button fades out. + expect(largeTitleOpacity.opacity.value, lessThan(1.0)); + expect(largeTitleOpacity.opacity.value, greaterThan(0.0)); + expect(cancelOpacity.opacity.value, lessThan(1.0)); + expect(cancelOpacity.opacity.value, greaterThan(0.0)); + + // At the end of the animation, the large title has completely faded in + // and the 'Cancel' button has completely faded out. + await tester.pump(const Duration(milliseconds: 300)); + expect(largeTitleOpacity.opacity.value, 1.0); + expect(cancelOpacity.opacity.value, 0.0); + }, + ); + + testWidgets('Large title is hidden if middle is provided in landscape mode', ( + WidgetTester tester, + ) async { + const largeTitle = 'Large title'; + const middle = 'Middle'; + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text(largeTitle), + middle: Text(middle), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + expect(find.text(largeTitle), findsNothing); + expect(find.text(middle), findsOneWidget); + expect(find.byType(CupertinoSearchTextField), findsOneWidget); + }); + + testWidgets('Large title is shown in middle position in landscape mode', ( + WidgetTester tester, + ) async { + const largeTitle = 'Large title'; + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text(largeTitle), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + expect(find.text(largeTitle), findsOneWidget); + expect(find.byType(CupertinoSearchTextField), findsOneWidget); + }); + + testWidgets( + 'CupertinoSliverNavigationBar does not enter infinite animation loop when target exceeds maxScrollExtent and prevents buttons from being tapped', + (WidgetTester tester) async { + const largeTitleHeight = 52.0; + + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + tester.view.devicePixelRatio = 1; + tester.binding.platformDispatcher.textScaleFactorTestValue = 1; + addTearDown(tester.view.reset); + addTearDown(tester.binding.platformDispatcher.clearTextScaleFactorTestValue); + setWindowToPortrait(tester, size: const Size(402, 874)); + + var count = 0; + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: Text('Large Title')), + SliverToBoxAdapter( + child: SizedBox( + // This height will trigger the issue if the target exceeds maxScrollExtent. + height: 805, + child: Center( + child: CupertinoButton( + child: const Text('Press me!'), + onPressed: () => count++, + ), + ), + ), + ), + ], + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify the button is present. + expect(find.widgetWithText(CupertinoButton, 'Press me!'), findsOneWidget); + + // Tap the button. + await tester.tap(find.widgetWithText(CupertinoButton, 'Press me!')); + await tester.pump(); + + // Check if the counter has increased. + expect(count, 1); + + // Scroll a little over the halfway point. + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(Scrollable)), + ); + await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1)); + await scrollGesture.up(); + + // The crux of this test: this should NOT time out or throw an error. + await tester.pumpAndSettle(); + + // Tap the button. + await tester.tap(find.widgetWithText(CupertinoButton, 'Press me!')); + await tester.pump(); + + // Check if the counter has increased. + expect(count, 2); + }, + ); + + testWidgets('Sliver nav bar middle can be updated', (WidgetTester tester) async { + setWindowToPortrait(tester); + var middle = 'First'; + late StateSetter setState; + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: const Text('Large title'), + middle: Text(middle), + ), + const SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ); + }, + ), + ), + ); + + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsNothing); + + setState(() { + middle = 'Second'; + }); + await tester.pump(); + + expect(find.text('First'), findsNothing); + expect(find.text('Second'), findsOneWidget); + }); + + testWidgets('CupertinoNavigationBar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: SizedBox.shrink(child: CupertinoNavigationBar())), + ), + ); + expect(tester.getSize(find.byType(CupertinoNavigationBar)), Size.zero); + }); +} + +class _ExpectStyles extends StatelessWidget { + const _ExpectStyles({required this.color, required this.index}); + + final Color color; + final int index; + + @override + Widget build(BuildContext context) { + final TextStyle style = DefaultTextStyle.of(context).style; + expect(style.color, isSameColorAs(color)); + expect(style.fontFamily, 'CupertinoSystemText'); + expect(style.fontSize, 17.0); + expect(style.letterSpacing, -0.41); + count += index; + return Container(); + } +} diff --git a/packages/cupertino_ui/test/cupertino/nav_bar_transition_test.dart b/packages/cupertino_ui/test/cupertino/nav_bar_transition_test.dart new file mode 100644 index 000000000000..16a5e018f62d --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/nav_bar_transition_test.dart @@ -0,0 +1,1902 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +@TestOn('!chrome') +library; + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future<void> startTransitionBetween( + WidgetTester tester, { + Widget? from, + Widget? to, + String? fromTitle, + String? toTitle, + TextDirection textDirection = TextDirection.ltr, + CupertinoThemeData? theme, + TextScaler textScaler = TextScaler.noScaling, +}) async { + await tester.pumpWidget( + CupertinoApp( + theme: theme, + builder: (BuildContext context, Widget? navigator) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: Directionality(textDirection: textDirection, child: navigator!), + ); + }, + home: const Placeholder(), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: fromTitle, + builder: (BuildContext context) => scaffoldForNavBar(from)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: toTitle, + builder: (BuildContext context) => scaffoldForNavBar(to)!, + ), + ); + + await tester.pump(); +} + +CupertinoPageScaffold? scaffoldForNavBar(Widget? navBar) { + switch (navBar) { + case CupertinoNavigationBar? _: + return CupertinoPageScaffold( + navigationBar: navBar ?? const CupertinoNavigationBar(), + child: const Placeholder(), + ); + case CupertinoSliverNavigationBar(): + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + navBar, + // Add filler so it's scrollable. + const SliverToBoxAdapter(child: Placeholder(fallbackHeight: 1000.0)), + ], + ), + ); + default: + assert(false, 'Unexpected nav bar type ${navBar.runtimeType}'); + return null; + } +} + +Finder flying(WidgetTester tester, Finder finder) { + final ContainerRenderObjectMixin<RenderBox, StackParentData> theater = tester.renderObject( + find.byType(Overlay), + ); + final Finder lastOverlayFinder = find.byElementPredicate((Element element) { + return element is RenderObjectElement && element.renderObject == theater.lastChild; + }); + + assert( + find + .descendant( + of: lastOverlayFinder, + matching: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_NavigationBarTransition', + ), + ) + .evaluate() + .length == + 1, + 'The last overlay in the navigator was not a flying hero', + ); + + return find.descendant(of: lastOverlayFinder, matching: finder); +} + +void checkBackgroundBoxOffset(WidgetTester tester, int boxIndex, Offset offset) { + final Widget transitionBackgroundBox = tester + .widget<Stack>(flying(tester, find.byType(Stack))) + .children[boxIndex]; + final Offset testOffset = tester.getBottomRight( + find.descendant(of: find.byWidget(transitionBackgroundBox), matching: find.byType(SizedBox)), + ); + expect(testOffset.dx, moreOrLessEquals(offset.dx, epsilon: 0.01)); + expect(testOffset.dy, moreOrLessEquals(offset.dy, epsilon: 0.01)); +} + +void checkOpacity(WidgetTester tester, Finder finder, double opacity) { + expect( + tester + .firstRenderObject<RenderAnimatedOpacity>( + find.ancestor(of: finder, matching: find.byType(FadeTransition)), + ) + .opacity + .value, + moreOrLessEquals(opacity, epsilon: 0.001), + ); +} + +void setWindowToPortrait(WidgetTester tester, {Size size = const Size(2400.0, 3000.0)}) { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); +} + +void main() { + testWidgets('Bottom middle moves between middle and back label', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + // There's 2 of them. One from the top route's back label and one from the + // bottom route's middle widget. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + + // Since they have the same text, they should be more or less at the same + // place. + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first), + const Offset(342.547737105096302912, 13.5), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last), + const Offset(342.547737105096302912, 13.5), + ); + }); + + testWidgets('Bottom middle moves between middle and back label RTL', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1', textDirection: TextDirection.rtl); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + // Same as LTR but more to the right now. + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first), + const Offset(357.912261979376353338, 13.5), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last), + const Offset(357.912261979376353338, 13.5), + ); + }); + + testWidgets('Bottom middle never changes size during the animation', (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1080.0 / 2.75, 600)); + addTearDown(() async { + await tester.binding.setSurfaceSize(const Size(800.0, 600.0)); + }); + + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + final Size size = tester.getSize(find.text('Page 1')); + + for (var i = 0; i < 150; i++) { + await tester.pump(const Duration(milliseconds: 1)); + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + expect(tester.getSize(flying(tester, find.text('Page 1')).first), size); + expect(tester.getSize(flying(tester, find.text('Page 1')).last), size); + } + }); + + testWidgets('Bottom middle and top back label transitions their font', ( + WidgetTester tester, + ) async { + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + // The transition's stack is ordered. The bottom middle is inserted first. + final RenderParagraph bottomMiddle = tester.renderObject( + flying(tester, find.text('Page 1')).first, + ); + expect(bottomMiddle.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect(bottomMiddle.text.style!.fontWeight, const FontWeight(595)); + expect(bottomMiddle.text.style!.fontFamily, 'CupertinoSystemText'); + expect(bottomMiddle.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.9404401779174805); + + // The top back label is styled exactly the same way. But the opacity tweens + // are flipped. + final RenderParagraph topBackLabel = tester.renderObject( + flying(tester, find.text('Page 1')).last, + ); + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect(topBackLabel.text.style!.fontWeight, const FontWeight(595)); + expect(topBackLabel.text.style!.fontFamily, 'CupertinoSystemText'); + expect(topBackLabel.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0); + + // Move animation further a bit. + await tester.pump(const Duration(milliseconds: 200)); + expect(bottomMiddle.text.style!.color, isSameColorAs(const Color(0xff005ec5))); + expect(bottomMiddle.text.style!.fontWeight, const FontWeight(445)); + expect(bottomMiddle.text.style!.fontFamily, 'CupertinoSystemText'); + expect(bottomMiddle.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.0); + + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff005ec5))); + expect(topBackLabel.text.style!.fontWeight, const FontWeight(445)); + expect(topBackLabel.text.style!.fontFamily, 'CupertinoSystemText'); + expect(topBackLabel.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.5292819738388062); + }); + + testWidgets('Font transitions respect themes', (WidgetTester tester) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + theme: const CupertinoThemeData(brightness: Brightness.dark), + ); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + // The transition's stack is ordered. The bottom middle is inserted first. + final RenderParagraph bottomMiddle = tester.renderObject( + flying(tester, find.text('Page 1')).first, + ); + expect(bottomMiddle.text.style!.color, isSameColorAs(const Color(0xfff8fbff))); + expect(bottomMiddle.text.style!.fontWeight, const FontWeight(595)); + expect(bottomMiddle.text.style!.fontFamily, 'CupertinoSystemText'); + expect(bottomMiddle.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.9404401779174805); + + // The top back label is styled exactly the same way. But the opacity tweens + // are flipped. + final RenderParagraph topBackLabel = tester.renderObject( + flying(tester, find.text('Page 1')).last, + ); + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xfff8fbff))); + expect(topBackLabel.text.style!.fontWeight, const FontWeight(595)); + expect(topBackLabel.text.style!.fontFamily, 'CupertinoSystemText'); + expect(topBackLabel.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0); + + // Move animation further a bit. + await tester.pump(const Duration(milliseconds: 200)); + expect(bottomMiddle.text.style!.color, isSameColorAs(const Color(0xff409fff))); + expect(bottomMiddle.text.style!.fontWeight, const FontWeight(445)); + expect(bottomMiddle.text.style!.fontFamily, 'CupertinoSystemText'); + expect(bottomMiddle.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.0); + + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff409fff))); + expect(topBackLabel.text.style!.fontWeight, const FontWeight(445)); + expect(topBackLabel.text.style!.fontFamily, 'CupertinoSystemText'); + expect(topBackLabel.text.style!.letterSpacing, -0.41); + + checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.5292819738388062); + }); + + testWidgets('Fullscreen dialogs do not create heroes', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 1', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + fullscreenDialog: true, + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Only the first (non-fullscreen-dialog) page has a Hero. + expect(find.byType(Hero), findsOneWidget); + // No Hero transition happened. + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + }); + + testWidgets('Turning off transition works', (WidgetTester tester) async { + await startTransitionBetween( + tester, + from: const CupertinoNavigationBar(transitionBetweenRoutes: false, middle: Text('Page 1')), + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // Only the second page that doesn't have the transitionBetweenRoutes + // override off has a Hero. + expect(find.byType(Hero), findsOneWidget); + expect(find.descendant(of: find.byType(Hero), matching: find.text('Page 2')), findsOneWidget); + + // No Hero transition happened. + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + }); + + testWidgets('Navigation bars in a CupertinoSheetRoute have no hero transitions', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? navigator) { + return navigator!; + }, + home: const Placeholder(), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoSheetRoute<void>( + builder: (BuildContext context) => + scaffoldForNavBar(const CupertinoNavigationBar(middle: Text('Page 1')))!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoSheetRoute<void>( + builder: (BuildContext context) => + scaffoldForNavBar(const CupertinoSliverNavigationBar(largeTitle: Text('Page 2')))!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(Hero), findsNothing); + + // No Hero transition happened. + expect(() => flying(tester, find.text('Page 1')), throwsAssertionError); + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + }); + + testWidgets('Popping mid-transition is symmetrical', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + void checkColorAndPositionAt50ms() { + // The transition's stack is ordered. The bottom middle is inserted first. + final RenderParagraph bottomMiddle = tester.renderObject( + flying(tester, find.text('Page 1')).first, + ); + expect(bottomMiddle.text.style!.color, isSameColorAs(const Color(0xff000306))); + + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first), + const Offset(342.547737105096302912, 13.5), + ); + + // The top back label is styled exactly the same way. But the opacity tweens + // are flipped. + final RenderParagraph topBackLabel = tester.renderObject( + flying(tester, find.text('Page 1')).last, + ); + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last), + const Offset(342.547737105096302912, 13.5), + ); + } + + checkColorAndPositionAt50ms(); + + // Advance more. + await tester.pump(const Duration(milliseconds: 100)); + + // Pop and reverse the same amount of time. + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Check that everything's the same as on the way in. + checkColorAndPositionAt50ms(); + }); + + testWidgets('Popping mid-transition is symmetrical RTL', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1', textDirection: TextDirection.rtl); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + void checkColorAndPositionAt50ms() { + // The transition's stack is ordered. The bottom middle is inserted first. + final RenderParagraph bottomMiddle = tester.renderObject( + flying(tester, find.text('Page 1')).first, + ); + expect(bottomMiddle.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first), + const Offset(357.912261979376353338, 13.5), + ); + + // The top back label is styled exactly the same way. But the opacity tweens + // are flipped. + final RenderParagraph topBackLabel = tester.renderObject( + flying(tester, find.text('Page 1')).last, + ); + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last), + const Offset(357.912261979376353338, 13.5), + ); + } + + checkColorAndPositionAt50ms(); + + // Advance more. + await tester.pump(const Duration(milliseconds: 100)); + + // Pop and reverse the same amount of time. + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Check that everything's the same as on the way in. + checkColorAndPositionAt50ms(); + }); + + testWidgets('There should be no global keys in the hero flight', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + expect( + flying( + tester, + find.byWidgetPredicate((Widget widget) => widget.key != null && widget.key is GlobalKey), + ), + findsNothing, + ); + }); + + testWidgets('DartPerformanceMode is latency mid-animation', (WidgetTester tester) async { + DartPerformanceMode? mode; + + // before the animation starts, no requests are active. + mode = SchedulerBinding.instance.debugGetRequestedPerformanceMode(); + expect(mode, isNull); + + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + // mid-transition, latency mode is expected. + await tester.pump(const Duration(milliseconds: 50)); + mode = SchedulerBinding.instance.debugGetRequestedPerformanceMode(); + expect(mode, equals(DartPerformanceMode.latency)); + + // end of transition, go back to no requests active. + await tester.pump(const Duration(milliseconds: 500)); + mode = SchedulerBinding.instance.debugGetRequestedPerformanceMode(); + expect(mode, isNull); + }); + + testWidgets('Multiple nav bars tags do not conflict if in different navigators', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(CupertinoIcons.search), label: 'Tab 1'), + BottomNavigationBarItem(icon: Icon(CupertinoIcons.settings), label: 'Tab 2'), + ], + ), + tabBuilder: (BuildContext context, int tab) { + return CupertinoTabView( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Tab ${tab + 1} Page 1')), + child: Center( + child: CupertinoButton( + child: const Text('Next'), + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + title: 'Tab ${tab + 1} Page 2', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(find.text('Tab 1 Page 1', skipOffstage: false), findsOneWidget); + expect(find.text('Tab 2 Page 1'), findsOneWidget); + + // At this point, there are 2 nav bars seeded with the same _defaultHeroTag. + // But they're inside different navigators. + + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + // One is inside the flight shuttle and another is invisible in the + // incoming route in case a new flight needs to be created midflight. + expect(find.text('Tab 2 Page 2'), findsNWidgets(2)); + + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Tab 2 Page 2'), findsOneWidget); + // Offstaged by tab 2's navigator. + expect(find.text('Tab 2 Page 1', skipOffstage: false), findsOneWidget); + // Offstaged by the CupertinoTabScaffold. + expect(find.text('Tab 1 Page 1', skipOffstage: false), findsOneWidget); + // Never navigated to tab 1 page 2. + expect(find.text('Tab 1 Page 2', skipOffstage: false), findsNothing); + }); + + testWidgets('Bottom nav bar transition background box', (WidgetTester tester) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + to: const CupertinoNavigationBar(), + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + // The top nav bar background box is the first component in the stack. + checkBackgroundBoxOffset(tester, 0, const Offset(609.14, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 0, const Offset(362.91, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 0, const Offset(192.14, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 0, const Offset(95.30, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 0, const Offset(46.12, 44.0)); + }); + + testWidgets('Top nav bar transition background box', (WidgetTester tester) async { + await startTransitionBetween( + tester, + // Only the large title and background box are in the bottom nav bar. + from: const CupertinoNavigationBar(automaticallyImplyLeading: false), + to: const CupertinoNavigationBar(), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + // The component stack only contains the bottom box background (at index 0) + // and the large title (at index 1). + checkBackgroundBoxOffset(tester, 2, const Offset(1409.14, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 2, const Offset(1162.91, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 2, const Offset(992.14, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 2, const Offset(895.30, 44.0)); + + await tester.pump(const Duration(milliseconds: 50)); + checkBackgroundBoxOffset(tester, 2, const Offset(846.12, 44.0)); + }); + + testWidgets('Extended large title removes bottom nav bar transition background box ', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar(largeTitle: Text('Page 1')), + SliverToBoxAdapter(child: SizedBox(height: 1200.0)), + ], + ), + ), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final int numComponents = tester + .widget<Stack>(flying(tester, find.byType(Stack))) + .children + .length; + + await tester.pumpAndSettle(); + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); + + scrollController.jumpTo(600.0); + await tester.pumpAndSettle(); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // The bottom nav bar transition background box has been added. + expect( + tester.widget<Stack>(flying(tester, find.byType(Stack))).children.length, + equals(numComponents + 1), + ); + }); + + testWidgets( + 'Opaque extended large title background keeps bottom nav bar transition background box ', + (WidgetTester tester) async { + setWindowToPortrait(tester); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Page 1'), + automaticBackgroundVisibility: false, + ), + SliverToBoxAdapter(child: SizedBox(height: 1200.0)), + ], + ), + ), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final int numComponents = tester + .widget<Stack>(flying(tester, find.byType(Stack))) + .children + .length; + + await tester.pumpAndSettle(); + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); + + scrollController.jumpTo(600.0); + await tester.pumpAndSettle(); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // The bottom nav bar transition background box has been added. + expect( + tester.widget<Stack>(flying(tester, find.byType(Stack))).children.length, + equals(numComponents), + ); + }, + ); + + testWidgets('Hero flight removed at the end of page transition', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + await tester.pump(const Duration(milliseconds: 50)); + + // There's 2 of them. One from the top route's back label and one from the + // bottom route's middle widget. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + + // End the transition. + await tester.pump(const Duration(milliseconds: 500)); + + expect(() => flying(tester, find.text('Page 1')), throwsAssertionError); + }); + + testWidgets('Exact widget is reused to build inside the transition', (WidgetTester tester) async { + const Widget userMiddle = Placeholder(); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(middle: userMiddle), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.byWidget(userMiddle)), findsOneWidget); + }); + + testWidgets('Middle is not shown if alwaysShowMiddle is false and the nav bar is expanded', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + const Widget userMiddle = Placeholder(); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(middle: userMiddle, alwaysShowMiddle: false), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.byWidget(userMiddle)), findsNothing); + }); + + testWidgets('Middle is shown if alwaysShowMiddle is false but the nav bar is collapsed', ( + WidgetTester tester, + ) async { + const Widget userMiddle = Placeholder(); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: scrollController, + slivers: const <Widget>[ + CupertinoSliverNavigationBar( + largeTitle: Text('Page 1'), + middle: userMiddle, + alwaysShowMiddle: false, + ), + SliverToBoxAdapter(child: SizedBox(height: 1200.0)), + ], + ), + ), + ), + ); + + scrollController.jumpTo(600.0); + await tester.pumpAndSettle(); + + // Middle widget is visible when nav bar is collapsed. + final RenderAnimatedOpacity userMiddleOpacity = tester + .element(find.byWidget(userMiddle)) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + expect(userMiddleOpacity.opacity.value, 1.0); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.byWidget(userMiddle)), findsOneWidget); + }); + + testWidgets('First appearance of back chevron fades in from the right', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(CupertinoApp(home: scaffoldForNavBar(null))); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 1', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final Finder backChevron = flying( + tester, + find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), + ); + + expect( + backChevron, + // Only one exists from the top page. The bottom page has no back chevron. + findsOneWidget, + ); + // Come in from the right and fade in. + checkOpacity(tester, backChevron, 0.0); + expect(tester.getTopLeft(backChevron).dx, moreOrLessEquals(80.54, epsilon: 0.01)); + expect(tester.getTopLeft(backChevron).dy, moreOrLessEquals(14.5, epsilon: 0.01)); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, backChevron, 0.167); + expect(tester.getTopLeft(backChevron).dx, moreOrLessEquals(14.0, epsilon: 0.01)); + expect(tester.getTopLeft(backChevron).dy, moreOrLessEquals(7.0, epsilon: 0.01)); + }); + + testWidgets('First appearance of back chevron fades in from the left in RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? navigator) { + return Directionality(textDirection: TextDirection.rtl, child: navigator!); + }, + home: scaffoldForNavBar(null), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 1', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final Finder backChevron = flying( + tester, + find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), + ); + + expect( + backChevron, + // Only one exists from the top page. The bottom page has no back chevron. + findsOneWidget, + ); + + // Come in from the right and fade in. + checkOpacity(tester, backChevron, 0.0); + expect(tester.getTopRight(backChevron).dx, moreOrLessEquals(706.66, epsilon: 0.01)); + expect(tester.getTopRight(backChevron).dy, moreOrLessEquals(14.5, epsilon: 0.01)); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, backChevron, 0.167); + expect(tester.getTopRight(backChevron).dx, moreOrLessEquals(760.41, epsilon: 0.01)); + expect(tester.getTopRight(backChevron).dy, moreOrLessEquals(7.0, epsilon: 0.01)); + }); + + testWidgets('Back chevron fades out and in when both pages have it', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1'); + + await tester.pump(const Duration(milliseconds: 50)); + + final Finder backChevrons = flying( + tester, + find.text(String.fromCharCode(CupertinoIcons.back.codePoint)), + ); + + expect(backChevrons, findsNWidgets(2)); + + checkOpacity(tester, backChevrons.first, 0.9280824661254883); + checkOpacity(tester, backChevrons.last, 0.0); + // Both overlap at the same place. + expect(tester.getTopLeft(backChevrons.first), const Offset(14.0, 7.0)); + expect(tester.getTopLeft(backChevrons.last), const Offset(14.0, 7.0)); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, backChevrons.first, 0.0); + checkOpacity(tester, backChevrons.last, 0.167); + // Still in the same place. + expect(tester.getTopLeft(backChevrons.first), const Offset(14.0, 7.0)); + expect(tester.getTopLeft(backChevrons.last), const Offset(14.0, 7.0)); + }); + + testWidgets('Bottom middle just fades if top page has a custom leading', ( + WidgetTester tester, + ) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + to: const CupertinoSliverNavigationBar(leading: Text('custom')), + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // There's just 1 in flight because there's no back label on the top page. + expect(flying(tester, find.text('Page 1')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('Page 1')), 0.9404401779174805); + + // The middle widget doesn't move. + expect(tester.getCenter(flying(tester, find.text('Page 1'))), const Offset(400.0, 22.0)); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, flying(tester, find.text('Page 1')), 0.0); + expect(tester.getCenter(flying(tester, find.text('Page 1'))), const Offset(400.0, 22.0)); + }); + + testWidgets('Bottom leading fades in place', (WidgetTester tester) async { + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(leading: Text('custom')), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('custom')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('custom')), 0.8948725312948227); + expect(tester.getTopLeft(flying(tester, find.text('custom'))), const Offset(16.0, 13.5)); + + await tester.pump(const Duration(milliseconds: 150)); + checkOpacity(tester, flying(tester, find.text('custom')), 0.0); + expect(tester.getTopLeft(flying(tester, find.text('custom'))), const Offset(16.0, 13.5)); + }); + + testWidgets('Bottom trailing fades in place', (WidgetTester tester) async { + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(trailing: Text('custom')), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('custom')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('custom')), 0.9280824661254883); + expect( + tester.getTopLeft(flying(tester, find.text('custom'))), + const Offset(684.459999084472656250, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 150)); + checkOpacity(tester, flying(tester, find.text('custom')), 0.0); + expect( + tester.getTopLeft(flying(tester, find.text('custom'))), + const Offset(684.459999084472656250, 13.5), + ); + }); + + testWidgets('Bottom back label fades and slides to the left', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1', toTitle: 'Page 2'); + + await tester.pump(const Duration(milliseconds: 500)); + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 3', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // 'Page 1' appears once on Page 2 as the back label. + expect(flying(tester, find.text('Page 1')), findsOneWidget); + + // Back label fades out faster. + checkOpacity(tester, flying(tester, find.text('Page 1')), 0.7952219992876053); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1'))), + const Offset(41.3003370761871337891, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, flying(tester, find.text('Page 1')), 0.0); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1'))), + const Offset(-258.642192125320434570, 13.5), + ); + }); + + testWidgets('Bottom back label fades and slides to the right in RTL', ( + WidgetTester tester, + ) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + toTitle: 'Page 2', + textDirection: TextDirection.rtl, + ); + + await tester.pump(const Duration(milliseconds: 500)); + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 3', + builder: (BuildContext context) => scaffoldForNavBar(null)!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // 'Page 1' appears once on Page 2 as the back label. + expect(flying(tester, find.text('Page 1')), findsOneWidget); + + // Back label fades out faster. + checkOpacity(tester, flying(tester, find.text('Page 1')), 0.7952219992876053); + expect( + tester.getTopRight(flying(tester, find.text('Page 1'))), + const Offset(758.699662923812866211, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, flying(tester, find.text('Page 1')), 0.0); + expect( + tester.getTopRight(flying(tester, find.text('Page 1'))), + // >1000. It's now off the screen. + const Offset(1058.64219212532043457, 13.5), + ); + }); + + testWidgets('Bottom large title moves to top back label', (WidgetTester tester) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // There's 2, one from the bottom large title fading out and one from the + // bottom back label fading in. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + + checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.9280824661254883); + checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.0); + + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dx, + moreOrLessEquals(17.3, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dy, + moreOrLessEquals(52.2, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dx, + moreOrLessEquals(17.3, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dy, + moreOrLessEquals(52.2, epsilon: 0.01), + ); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, flying(tester, find.text('Page 1')).first, 0.0); + checkOpacity(tester, flying(tester, find.text('Page 1')).last, 0.4604858811944723); + + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dx, + moreOrLessEquals(51.6, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).first).dy, + moreOrLessEquals(11.5, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dx, + moreOrLessEquals(51.6, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Page 1')).last).dy, + moreOrLessEquals(11.5, epsilon: 0.01), + ); + }); + + testWidgets('Bottom CupertinoSliverNavigationBar.bottom fades and slides out from the left', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(30.0), child: Placeholder()), + ), + fromTitle: 'Page 1', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // There's 2, one from the bottom large title fading out and one from the + // bottom back label fading in. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + expect(flying(tester, find.byType(Placeholder)), findsOneWidget); + + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.777); + + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(-156.62, epsilon: 0.01), + ); + + await tester.pump(const Duration(milliseconds: 200)); + + // Halfway through the transition, the bottom is only slightly visible. + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.011); + + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(-751.94, epsilon: 0.01), + ); + }); + + testWidgets('Bottom CupertinoNavigationBar.bottom fades and slides out from the left', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: const CupertinoNavigationBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(30.0), child: Placeholder()), + ), + fromTitle: 'Page 1', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // There's 2, one from the bottom large title fading out and one from the + // bottom back label fading in. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + expect(flying(tester, find.byType(Placeholder)), findsOneWidget); + + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.777); + + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(-156.62, epsilon: 0.01), + ); + + await tester.pump(const Duration(milliseconds: 200)); + + // Halfway through the transition, the bottom is only slightly visible. + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.011); + + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(-751.94, epsilon: 0.01), + ); + }); + + testWidgets( + 'CupertinoSliverNavigationBar searchable-to-searchable transition clips its contents mid-transition when scrolled', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? navigator) { + return navigator!; + }, + home: const Placeholder(), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 1', + builder: (BuildContext context) => scaffoldForNavBar( + const CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + suffixMode: OverlayVisibilityMode.always, + suffixIcon: Icon(CupertinoIcons.mic_solid), + ), + ), + )!, + ), + ); + + await tester.pumpAndSettle(); + + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture1.moveBy(const Offset(0, -300)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'Page 2', + builder: (BuildContext context) => scaffoldForNavBar( + const CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + suffixMode: OverlayVisibilityMode.always, + suffixIcon: Icon(CupertinoIcons.mic_solid), + ), + ), + )!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsNWidgets(4)); + expect(find.byIcon(CupertinoIcons.search), findsNWidgets(4)); + expect( + find.ancestor( + of: find.byType(CupertinoSearchTextField), + matching: find.byElementPredicate((Element element) { + final Widget widget = element.widget; + if (widget is ClipRect && widget.clipBehavior == Clip.hardEdge) { + final RenderObject? renderObject = element.renderObject; + return renderObject != null && !renderObject.debugNeedsPaint; + } + return false; + }), + ), + // Two ClipRects for the top and bottom search fields in transition. + findsNWidgets(2), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + }, + ); + + testWidgets('Long title turns into the word back mid transition', (WidgetTester tester) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(), + fromTitle: 'A title too long to fit', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('A title too long to fit')), findsOneWidget); + // Automatically changed to the word 'Back' in the back label. + expect(flying(tester, find.text('Back')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('A title too long to fit')), 0.9280824661254883); + checkOpacity(tester, flying(tester, find.text('Back')), 0.0); + expect( + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dx, + moreOrLessEquals(17.3, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dy, + moreOrLessEquals(52.2, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dx, + moreOrLessEquals(17.3, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dy, + moreOrLessEquals(52.2, epsilon: 0.01), + ); + + await tester.pump(const Duration(milliseconds: 200)); + checkOpacity(tester, flying(tester, find.text('A title too long to fit')), 0.0); + checkOpacity(tester, flying(tester, find.text('Back')), 0.4604858811944723); + expect( + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dx, + moreOrLessEquals(51.6, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('A title too long to fit'))).dy, + moreOrLessEquals(11.5, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dx, + moreOrLessEquals(51.6, epsilon: 0.01), + ); + expect( + tester.getTopLeft(flying(tester, find.text('Back'))).dy, + moreOrLessEquals(11.5, epsilon: 0.01), + ); + }); + + testWidgets('Bottom large title and top back label transitions their font', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(), + fromTitle: 'Page 1', + ); + + // Be mid-transition. + await tester.pump(const Duration(milliseconds: 50)); + + // The transition's stack is ordered. The bottom large title is inserted first. + final RenderParagraph bottomLargeTitle = tester.renderObject( + flying(tester, find.text('Page 1')).first, + ); + expect(bottomLargeTitle.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect(bottomLargeTitle.text.style!.fontWeight, const FontWeight(692)); + expect(bottomLargeTitle.text.style!.fontFamily, 'CupertinoSystemDisplay'); + expect(bottomLargeTitle.text.style!.letterSpacing, moreOrLessEquals(0.35967791542410854)); + + // The top back label is styled exactly the same way. + final RenderParagraph topBackLabel = tester.renderObject( + flying(tester, find.text('Page 1')).last, + ); + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff000306))); + expect(topBackLabel.text.style!.fontWeight, const FontWeight(692)); + expect(topBackLabel.text.style!.fontFamily, 'CupertinoSystemDisplay'); + expect(topBackLabel.text.style!.letterSpacing, moreOrLessEquals(0.35967791542410854)); + + // Move animation further a bit. + await tester.pump(const Duration(milliseconds: 200)); + expect(bottomLargeTitle.text.style!.color, isSameColorAs(const Color(0xff005ec5))); + expect(bottomLargeTitle.text.style!.fontWeight, const FontWeight(467)); + expect(bottomLargeTitle.text.style!.fontFamily, 'CupertinoSystemText'); + expect(bottomLargeTitle.text.style!.letterSpacing, moreOrLessEquals(-0.23270857974886894)); + + expect(topBackLabel.text.style!.color, isSameColorAs(const Color(0xff005ec5))); + expect(topBackLabel.text.style!.fontWeight, const FontWeight(467)); + expect(topBackLabel.text.style!.fontFamily, 'CupertinoSystemText'); + expect(topBackLabel.text.style!.letterSpacing, moreOrLessEquals(-0.23270857974886894)); + }); + + testWidgets('Top middle fades in and slides in from the right', (WidgetTester tester) async { + await startTransitionBetween(tester, toTitle: 'Page 2'); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 2')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(739.940336465835571289, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 150)); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.29867843724787235); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(504.880443334579467773, 13.5), + ); + }); + + testWidgets('Top middle never changes size during the animation', (WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1080.0 / 2.75, 600)); + addTearDown(() async { + await tester.binding.setSurfaceSize(const Size(800.0, 600.0)); + }); + + await startTransitionBetween(tester, toTitle: 'Page 2'); + + Size? previousSize; + + for (var i = 0; i < 150; i++) { + await tester.pump(const Duration(milliseconds: 1)); + expect(flying(tester, find.text('Page 2')), findsOneWidget); + final Size size = tester.getSize(flying(tester, find.text('Page 2'))); + if (previousSize != null) { + expect(size, previousSize); + } + previousSize = size; + } + }); + + testWidgets('Top middle fades in and slides in from the left in RTL', ( + WidgetTester tester, + ) async { + await startTransitionBetween(tester, toTitle: 'Page 2', textDirection: TextDirection.rtl); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 2')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0); + expect( + tester.getTopRight(flying(tester, find.text('Page 2'))), + const Offset(60.0596635341644287109, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 150)); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.29867843724787235); + expect( + tester.getTopRight(flying(tester, find.text('Page 2'))), + const Offset(295.119556665420532227, 13.5), + ); + }); + + testWidgets('Top large title fades in and slides in from the right', (WidgetTester tester) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + to: const CupertinoSliverNavigationBar(), + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 2')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.193); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(661.64, epsilon: 0.01), + ); + expect(tester.getTopLeft(flying(tester, find.text('Page 2'))).dy, 54.0); + + await tester.pump(const Duration(milliseconds: 150)); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.899); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(96.57, epsilon: 0.01), + ); + expect(tester.getTopLeft(flying(tester, find.text('Page 2'))).dy, 54.0); + }); + + testWidgets('Top large title fades in and slides in from the left in RTL', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + to: const CupertinoSliverNavigationBar(), + toTitle: 'Page 2', + textDirection: TextDirection.rtl, + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 2')), findsOneWidget); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.193); + expect( + tester.getTopRight(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(138.36, epsilon: 0.01), + ); + expect(tester.getTopRight(flying(tester, find.text('Page 2'))).dy, 54.0); + + await tester.pump(const Duration(milliseconds: 150)); + + checkOpacity(tester, flying(tester, find.text('Page 2')), 0.899); + expect( + tester.getTopRight(flying(tester, find.text('Page 2'))).dx, + moreOrLessEquals(703.43, epsilon: 0.01), + ); + expect(tester.getTopRight(flying(tester, find.text('Page 2'))).dy, 54.0); + }); + + testWidgets('Top CupertinoSliverNavigationBar.bottom is aligned with top large title animation', ( + WidgetTester tester, + ) async { + const horizontalPadding = 16.0; // _kNavBarEdgePadding + const height = 30.0; + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + toTitle: 'Page 2', + to: const CupertinoSliverNavigationBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(height), child: Placeholder()), + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 2')), findsOneWidget); + expect(flying(tester, find.byType(Placeholder)), findsOneWidget); + + final double largeTitleOpacity = tester + .firstRenderObject<RenderAnimatedOpacity>( + find.ancestor( + of: flying(tester, find.text('Page 2')), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; + + checkOpacity(tester, flying(tester, find.byType(Placeholder)), largeTitleOpacity); + + Offset largeTitleOffset = tester.getTopLeft(flying(tester, find.text('Page 2'))); + + // The nav bar bottom is horizontally aligned to the large title. + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(largeTitleOffset.dx - horizontalPadding, epsilon: 0.01), + ); + + await tester.pump(const Duration(milliseconds: 150)); + + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.899); + + largeTitleOffset = tester.getTopLeft(flying(tester, find.text('Page 2'))); + + // The nav bar bottom is horizontally aligned to the large title. + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(largeTitleOffset.dx - horizontalPadding, epsilon: 0.01), + ); + }); + + testWidgets('Top CupertinoNavigationBar.bottom fades and slides in to the right', ( + WidgetTester tester, + ) async { + await startTransitionBetween( + tester, + toTitle: 'Page 2', + to: const CupertinoNavigationBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(30.0), child: Placeholder()), + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.text('Page 2')), findsOneWidget); + expect(flying(tester, find.byType(Placeholder)), findsOneWidget); + + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.193); + + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(645.64, epsilon: 0.01), + ); + + await tester.pump(const Duration(milliseconds: 150)); + + checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.899); + + expect( + tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx, + moreOrLessEquals(80.57, epsilon: 0.01), + ); + }); + + testWidgets('Searchable-to-searchable transition does not fade', (WidgetTester tester) async { + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar.search(searchField: CupertinoSearchTextField()), + to: const CupertinoSliverNavigationBar.search(searchField: CupertinoSearchTextField()), + fromTitle: 'Page 1', + toTitle: 'Page 2', + ); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(flying(tester, find.byType(CupertinoSearchTextField)), findsNWidgets(2)); + + // Either no FadeTransition ancestor is found, or one is found but there is no fade. + expect( + find.ancestor( + of: find.byType(CupertinoSearchTextField).first, + matching: find.byType(FadeTransition), + ), + findsNothing, + ); + checkOpacity(tester, flying(tester, find.byType(CupertinoSearchTextField).last), 1.0); + + await tester.pump(const Duration(milliseconds: 150)); + + // Either no FadeTransition ancestor is found, or one is found but there is no fade. + expect( + find.ancestor( + of: find.byType(CupertinoSearchTextField).first, + matching: find.byType(FadeTransition), + ), + findsNothing, + ); + checkOpacity(tester, flying(tester, find.byType(CupertinoSearchTextField).last), 1.0); + }); + + testWidgets('Components are not unnecessarily rebuilt during transitions', ( + WidgetTester tester, + ) async { + var bottomBuildTimes = 0; + var topBuildTimes = 0; + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: CupertinoNavigationBar( + middle: Builder( + builder: (BuildContext context) { + bottomBuildTimes++; + return const Text('Page 1'); + }, + ), + ), + to: CupertinoSliverNavigationBar( + largeTitle: Builder( + builder: (BuildContext context) { + topBuildTimes++; + return const Text('Page 2'); + }, + ), + ), + ); + + expect(bottomBuildTimes, 1); + // RenderSliverPersistentHeader.layoutChild causes 2 builds. + expect(topBuildTimes, 2); + + await tester.pump(); + + // The shuttle builder builds the component widgets one more time. + expect(bottomBuildTimes, 2); + expect(topBuildTimes, 3); + + // Subsequent animation needs to use reprojection of children. + await tester.pump(); + expect(bottomBuildTimes, 2); + expect(topBuildTimes, 3); + + await tester.pump(const Duration(milliseconds: 100)); + expect(bottomBuildTimes, 2); + expect(topBuildTimes, 3); + + // Finish animations. + await tester.pump(const Duration(milliseconds: 400)); + + expect(bottomBuildTimes, 2); + expect(topBuildTimes, 3); + }); + + testWidgets('Back swipe gesture transitions', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1', toTitle: 'Page 2'); + + // Go to the next page. + await tester.pump(const Duration(milliseconds: 600)); + + // Start the gesture at the edge of the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + // Trigger the swipe. + await gesture.moveBy(const Offset(100.0, 0.0)); + + // Back gestures should trigger and draw the hero transition in the very same + // frame (since the "from" route has already moved to reveal the "to" route). + await tester.pump(); + + // Page 2, which is the middle of the top route, start to fly back to the right. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(353.810205429792404175, 13.5), + ); + + // Page 1 is in transition in 2 places. Once as the top back label and once + // as the bottom middle. + expect(flying(tester, find.text('Page 1')), findsNWidgets(2)); + + // Past the halfway point now. + await gesture.moveBy(const Offset(500.0, 0.0)); + await gesture.up(); + + await tester.pump(); + // Transition continues. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(655.435583114624023438, 13.5), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(721.4629859924316, 13.5), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // Cleans up properly + expect(() => flying(tester, find.text('Page 1')), throwsAssertionError); + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + // Just the bottom route's middle now. + expect(find.text('Page 1'), findsOneWidget); + }); + + testWidgets('textScaleFactor is set to 1.0 on transition', (WidgetTester tester) async { + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + textScaler: const TextScaler.linear(99), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + final TextScaler scaler = tester + .firstWidget<RichText>(flying(tester, find.byType(RichText))) + .textScaler; + final fontSizes = List<double>.generate(100, (int index) => index / 3 + 1); + expect(fontSizes.map(scaler.scale), fontSizes); + }); + + testWidgets('Back swipe gesture cancels properly with transition', (WidgetTester tester) async { + await startTransitionBetween(tester, fromTitle: 'Page 1', toTitle: 'Page 2'); + + // Go to the next page. + await tester.pump(const Duration(milliseconds: 600)); + + // Start the gesture at the edge of the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + // Trigger the swipe. + await gesture.moveBy(const Offset(100.0, 0.0)); + + // Back gestures should trigger and draw the hero transition in the very same + // frame (since the "from" route has already moved to reveal the "to" route). + await tester.pump(); + + // Page 2, which is the middle of the top route, start to fly back to the right. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(353.810205429792404175, 13.5), + ); + + await gesture.up(); + await tester.pump(); + + // Transition continues from the point we let off. + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(353.810205429792404175, 13.5), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect( + tester.getTopLeft(flying(tester, find.text('Page 2'))), + const Offset(351.52365279197693, 13.5), + ); + + // Finish the snap back animation. + await tester.pump(const Duration(milliseconds: 500)); + + // Cleans up properly + expect(() => flying(tester, find.text('Page 1')), throwsAssertionError); + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + // Back to page 2. + expect(find.text('Page 2'), findsOneWidget); + }); + + testWidgets('Bottom large title is shown mid-transition when top has no leading', ( + WidgetTester tester, + ) async { + setWindowToPortrait(tester); + await startTransitionBetween( + tester, + from: const CupertinoSliverNavigationBar(largeTitle: Text('Page 1')), + to: const CupertinoSliverNavigationBar( + largeTitle: Text('Page 2'), + automaticallyImplyLeading: false, + ), + ); + + // Go to the next page. + await tester.pump(const Duration(milliseconds: 600)); + + // Start the gesture at the edge of the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + // Trigger the swipe. + await gesture.moveBy(const Offset(200.0, 0.0)); + + // Back gestures should trigger and draw the hero transition in the very same + // frame (since the "from" route has already moved to reveal the "to" route). + await tester.pump(); + expect(flying(tester, find.text('Page 1')), findsOneWidget); + }); + + testWidgets('Back label is not clipped mid-transition', (WidgetTester tester) async { + const label = 'backbackback'; + await startTransitionBetween( + tester, + fromTitle: 'Page 1', + toTitle: 'Page 2', + from: const CupertinoNavigationBar(), + to: const CupertinoNavigationBar(previousPageTitle: label), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // The variant in transition and the static variant. + expect(find.text(label), findsNWidgets(2)); + + // At the end of the transition, the label in transition and the static + // label both have the same fully extended width. + expect( + tester.getTopRight(find.text(label).first).dx, + tester.getTopRight(find.text(label).last).dx, + ); + + // End the transition. + await tester.pumpAndSettle(); + expect(() => flying(tester, find.text('Page 2')), throwsAssertionError); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/navigator_utils.dart b/packages/cupertino_ui/test/cupertino/navigator_utils.dart new file mode 100644 index 000000000000..899cda78a1b7 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/navigator_utils.dart @@ -0,0 +1,18 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Simulates a system back, like a back gesture on Android. +/// +/// Sends the same platform channel message that the engine sends when it +/// receives a system back. +Future<void> simulateSystemBack() { + return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.navigation.name, + const JSONMessageCodec().encodeMessage(<String, dynamic>{'method': 'popRoute'}), + (ByteData? _) {}, + ); +} diff --git a/packages/cupertino_ui/test/cupertino/page_test.dart b/packages/cupertino_ui/test/cupertino/page_test.dart new file mode 100644 index 000000000000..5d655432fc0d --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/page_test.dart @@ -0,0 +1,650 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('test iOS page transition (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 is moving to the left. + expect(widget1TransientTopLeft.dx, lessThan(widget1InitialTopLeft.dx)); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy, equals(widget1InitialTopLeft.dy)); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy, equals(widget2TopLeft.dy)); + // Page 2 is coming in from the right. + expect(widget2TopLeft.dx, greaterThan(widget1InitialTopLeft.dx)); + + // Will need to be changed if the animation curve or duration changes. + expect(widget1TransientTopLeft.dx, moreOrLessEquals(158, epsilon: 1.0)); + + await tester.pumpAndSettle(); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 is coming back from the left. + expect(widget1TransientTopLeft.dx, lessThan(widget1InitialTopLeft.dx)); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy, equals(widget1InitialTopLeft.dy)); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy, equals(widget2TopLeft.dy)); + // Page 2 is leaving towards the right. + expect(widget2TopLeft.dx, greaterThan(widget1InitialTopLeft.dx)); + + // Will need to be changed if the animation curve or duration changes. + expect(widget1TransientTopLeft.dx, moreOrLessEquals(220, epsilon: 1.0)); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + + // Page 1 is back where it started. + expect(widget1InitialTopLeft, equals(widget1TransientTopLeft)); + }); + + testWidgets('test iOS page transition (RTL)', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + localizationsDelegates: const <LocalizationsDelegate<dynamic>>[ + RtlOverrideWidgetsDelegate(), + ], + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + await tester.pump(); // to load the localization, since it doesn't use a synchronous future + + final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 is moving to the right. + expect(widget1TransientTopLeft.dx, greaterThan(widget1InitialTopLeft.dx)); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy, equals(widget1InitialTopLeft.dy)); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy, equals(widget2TopLeft.dy)); + // Page 2 is coming in from the left. + expect(widget2TopLeft.dx, lessThan(widget1InitialTopLeft.dx)); + + await tester.pumpAndSettle(); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 is coming back from the right. + expect(widget1TransientTopLeft.dx, greaterThan(widget1InitialTopLeft.dx)); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy, equals(widget1InitialTopLeft.dy)); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy, equals(widget2TopLeft.dy)); + // Page 2 is leaving towards the left. + expect(widget2TopLeft.dx, lessThan(widget1InitialTopLeft.dx)); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + + // Page 1 is back where it started. + expect(widget1InitialTopLeft, equals(widget1TransientTopLeft)); + }); + + testWidgets('test iOS fullscreen dialog transition', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: Text('Page 1')))); + + final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const Center(child: Text('Page 2')); + }, + fullscreenDialog: true, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 doesn't move. + expect(widget1TransientTopLeft, equals(widget1InitialTopLeft)); + // Fullscreen dialogs transitions vertically only. + expect(widget1InitialTopLeft.dx, equals(widget2TopLeft.dx)); + // Page 2 is coming in from the bottom. + expect(widget2TopLeft.dy, greaterThan(widget1InitialTopLeft.dy)); + + await tester.pumpAndSettle(); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 doesn't move. + expect(widget1TransientTopLeft, equals(widget1InitialTopLeft)); + // Fullscreen dialogs transitions vertically only. + expect(widget1InitialTopLeft.dx, equals(widget2TopLeft.dx)); + // Page 2 is leaving towards the bottom. + expect(widget2TopLeft.dy, greaterThan(widget1InitialTopLeft.dy)); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + + // Page 1 is back where it started. + expect(widget1InitialTopLeft, equals(widget1TransientTopLeft)); + }); + + testWidgets('test only edge swipes work (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from the middle to the right. + TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0)); + await gesture.moveBy(const Offset(300.0, 0.0)); + await tester.pump(); + + // Nothing should happen. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from the right to the left. + gesture = await tester.startGesture(const Offset(795.0, 200.0)); + await gesture.moveBy(const Offset(-300.0, 0.0)); + await tester.pump(); + + // Nothing should happen. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from the right to the further right. + gesture = await tester.startGesture(const Offset(795.0, 200.0)); + await gesture.moveBy(const Offset(300.0, 0.0)); + await tester.pump(); + + // Nothing should happen. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Now drag from the left edge. + gesture = await tester.startGesture(const Offset(5.0, 200.0)); + await gesture.moveBy(const Offset(300.0, 0.0)); + await tester.pump(); + + // Page 1 is now visible. + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), isOnstage); + }); + + testWidgets('test edge swipes work with media query padding (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? navigator) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(left: 40)), + child: navigator!, + ); + }, + home: const Placeholder(), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) => const Center(child: Text('Page 1')), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) => const Center(child: Text('Page 2')), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Now drag from the left edge. + final TestGesture gesture = await tester.startGesture(const Offset(35.0, 200.0)); + await gesture.moveBy(const Offset(300.0, 0.0)); + await tester.pump(); + await tester.pumpAndSettle(); + + // Page 1 is now visible. + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), isOnstage); + }); + + testWidgets('test edge swipes work with media query padding (RLT)', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? navigator) { + return Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(right: 40)), + child: navigator!, + ), + ); + }, + home: const Placeholder(), + ), + ); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) => const Center(child: Text('Page 1')), + ), + ); + + await tester.pump(); + await tester.pumpAndSettle(); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + builder: (BuildContext context) => const Center(child: Text('Page 2')), + ), + ); + + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Now drag from the left edge. + final TestGesture gesture = await tester.startGesture(const Offset(765.0, 200.0)); + await gesture.moveBy(const Offset(-300.0, 0.0)); + await tester.pump(); + await tester.pumpAndSettle(); + + // Page 1 is now visible. + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), isOnstage); + }); + + testWidgets('test only edge swipes work (RTL)', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + localizationsDelegates: const <LocalizationsDelegate<dynamic>>[ + RtlOverrideWidgetsDelegate(), + ], + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + await tester.pump(); // to load the localization, since it doesn't use a synchronous future + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pumpAndSettle(); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from the middle to the left. + TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0)); + await gesture.moveBy(const Offset(-300.0, 0.0)); + await tester.pump(); + + // Nothing should happen. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from the left to the right. + gesture = await tester.startGesture(const Offset(5.0, 200.0)); + await gesture.moveBy(const Offset(300.0, 0.0)); + await tester.pump(); + + // Nothing should happen. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from the left to the further left. + gesture = await tester.startGesture(const Offset(5.0, 200.0)); + await gesture.moveBy(const Offset(-300.0, 0.0)); + await tester.pump(); + + // Nothing should happen. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Now drag from the right edge. + gesture = await tester.startGesture(const Offset(795.0, 200.0)); + await gesture.moveBy(const Offset(-300.0, 0.0)); + await tester.pump(); + + // Page 1 is now visible. + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), isOnstage); + }); + + testWidgets('test edge swipe then drop back at starting point works', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + // Bring it exactly back such that there's nothing to animate when releasing. + await gesture.moveBy(const Offset(-300, 0)); + await gesture.up(); + await tester.pump(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + }); + + testWidgets('CupertinoPage does not lose its state when transitioning out', ( + WidgetTester tester, + ) async { + final navigator = GlobalKey<NavigatorState>(); + await tester.pumpWidget(KeepsStateTestWidget(navigatorKey: navigator)); + expect(find.text('subpage'), findsOneWidget); + expect(find.text('home'), findsNothing); + + navigator.currentState!.pop(); + await tester.pump(); + + expect(find.text('subpage'), findsOneWidget); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('CupertinoPage restores its state', (WidgetTester tester) async { + await tester.pumpWidget( + RootRestorationScope( + restorationId: 'root', + child: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onPopPage: (Route<dynamic> route, dynamic result) { + return false; + }, + pages: const <Page<Object?>>[ + CupertinoPage<void>( + restorationId: 'p1', + child: TestRestorableWidget(restorationId: 'p1'), + ), + ], + restorationScopeId: 'nav', + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return TestRestorableWidget(restorationId: settings.name!); + }, + ); + }, + ), + ), + ), + ), + ); + + expect(find.text('p1'), findsOneWidget); + expect(find.text('count: 0'), findsOneWidget); + + await tester.tap(find.text('increment')); + await tester.pump(); + expect(find.text('count: 1'), findsOneWidget); + + tester.state<NavigatorState>(find.byType(Navigator)).restorablePushNamed('p2'); + await tester.pumpAndSettle(); + + expect(find.text('p1'), findsNothing); + expect(find.text('p2'), findsOneWidget); + + await tester.tap(find.text('increment')); + await tester.pump(); + await tester.tap(find.text('increment')); + await tester.pump(); + expect(find.text('count: 2'), findsOneWidget); + + await tester.restartAndRestore(); + + expect(find.text('p2'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); + + expect(find.text('p1'), findsOneWidget); + expect(find.text('count: 1'), findsOneWidget); + }); +} + +class RtlOverrideWidgetsDelegate extends LocalizationsDelegate<WidgetsLocalizations> { + const RtlOverrideWidgetsDelegate(); + @override + bool isSupported(Locale locale) => true; + @override + Future<WidgetsLocalizations> load(Locale locale) async => const RtlOverrideWidgetsLocalization(); + @override + bool shouldReload(LocalizationsDelegate<WidgetsLocalizations> oldDelegate) => false; +} + +class RtlOverrideWidgetsLocalization extends DefaultWidgetsLocalizations { + const RtlOverrideWidgetsLocalization(); + @override + TextDirection get textDirection => TextDirection.rtl; +} + +class KeepsStateTestWidget extends StatefulWidget { + const KeepsStateTestWidget({super.key, this.navigatorKey}); + + final Key? navigatorKey; + + @override + State<KeepsStateTestWidget> createState() => _KeepsStateTestWidgetState(); +} + +class _KeepsStateTestWidgetState extends State<KeepsStateTestWidget> { + String? _subpage = 'subpage'; + + @override + Widget build(BuildContext context) { + return CupertinoApp( + home: Navigator( + key: widget.navigatorKey, + pages: <Page<void>>[ + const CupertinoPage<void>(child: Text('home')), + if (_subpage != null) CupertinoPage<void>(child: Text(_subpage!)), + ], + onPopPage: (Route<dynamic> route, dynamic result) { + if (!route.didPop(result)) { + return false; + } + setState(() { + _subpage = null; + }); + return true; + }, + ), + ); + } +} + +class TestRestorableWidget extends StatefulWidget { + const TestRestorableWidget({super.key, required this.restorationId}); + + final String restorationId; + + @override + State<StatefulWidget> createState() => _TestRestorableWidgetState(); +} + +class _TestRestorableWidgetState extends State<TestRestorableWidget> with RestorationMixin { + @override + String? get restorationId => widget.restorationId; + + final RestorableInt counter = RestorableInt(0); + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(counter, 'counter'); + } + + @override + void dispose() { + super.dispose(); + counter.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + Text(widget.restorationId), + Text('count: ${counter.value}'), + CupertinoButton( + onPressed: () { + setState(() { + counter.value++; + }); + }, + child: const Text('increment'), + ), + ], + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/picker_test.dart b/packages/cupertino_ui/test/cupertino/picker_test.dart new file mode 100644 index 000000000000..b26f5bcc3699 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/picker_test.dart @@ -0,0 +1,926 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/rendering_tester.dart'; +import '../widgets/semantics_tester.dart'; + +class SpyFixedExtentScrollController extends FixedExtentScrollController { + /// Override for test visibility only. + @override + bool get hasListeners => super.hasListeners; +} + +void main() { + testWidgets('Picker respects theme styling', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: List<Widget>.generate(3, (int index) { + return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); + }), + ), + ), + ), + ), + ); + + final RenderParagraph paragraph = tester.renderObject(find.text('1')); + + expect(paragraph.text.style!.color, isSameColorAs(CupertinoColors.black)); + expect( + paragraph.text.style!.copyWith(color: CupertinoColors.black), + const TextStyle( + inherit: false, + fontFamily: 'CupertinoSystemDisplay', + fontSize: 21.0, + fontWeight: FontWeight.w400, + letterSpacing: -0.6, + color: CupertinoColors.black, + ), + ); + }); + + testWidgets('Picker semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: List<Widget>.generate(13, (int index) { + return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); + }), + ), + ), + ), + ); + expect( + semantics, + includesNodeWith( + value: '0', + increasedValue: '1', + actions: <SemanticsAction>[SemanticsAction.increase], + ), + ); + + final hourListController = + tester.widget<ListWheelScrollView>(find.byType(ListWheelScrollView)).controller! + as FixedExtentScrollController; + + hourListController.jumpToItem(11); + await tester.pumpAndSettle(); + expect( + semantics, + includesNodeWith( + value: '11', + increasedValue: '12', + decreasedValue: '10', + actions: <SemanticsAction>[SemanticsAction.increase, SemanticsAction.decrease], + ), + ); + semantics.dispose(); + }); + + testWidgets('Picker semantics excludes current item with empty label', ( + WidgetTester tester, + ) async { + // When the current item has an empty label (e.g., wrapped with ExcludeSemantics), + // the picker should not set any value, increasedValue, decreasedValue, or actions. + final semantics = SemanticsTester(tester); + final controller = FixedExtentScrollController(initialItem: 1); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: const <Widget>[ + Text('0'), + // Item at index 1 is excluded from semantics (simulating a disabled item). + ExcludeSemantics(child: Text('1')), + Text('2'), + ], + ), + ), + ), + ); + + // When the current item (index 1) has an empty label due to ExcludeSemantics, + // the picker should not have any value or actions set. + expect(semantics, isNot(includesNodeWith(value: '1'))); + // Also verify that no increase/decrease actions are set for this item. + expect( + semantics, + isNot(includesNodeWith(actions: <SemanticsAction>[SemanticsAction.increase])), + ); + expect( + semantics, + isNot(includesNodeWith(actions: <SemanticsAction>[SemanticsAction.decrease])), + ); + + // Scroll to item 0 which has a valid label. + controller.jumpToItem(0); + await tester.pumpAndSettle(); + + // Now the picker should have value '0' but no increase action + // because the next item (1) has an empty label. + expect(semantics, includesNodeWith(value: '0')); + expect( + semantics, + isNot(includesNodeWith(value: '0', actions: <SemanticsAction>[SemanticsAction.increase])), + ); + + // Scroll to item 2 which has a valid label. + controller.jumpToItem(2); + await tester.pumpAndSettle(); + + // Now the picker should have value '2' but no decrease action + // because the previous item (1) has an empty label. + expect(semantics, includesNodeWith(value: '2')); + expect( + semantics, + isNot(includesNodeWith(value: '2', actions: <SemanticsAction>[SemanticsAction.decrease])), + ); + + semantics.dispose(); + }); + + group('layout', () { + // Regression test for https://github.com/flutter/flutter/issues/22999 + testWidgets('CupertinoPicker.builder test', (WidgetTester tester) async { + Widget buildFrame(int childCount) { + return Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker.builder( + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + itemBuilder: (BuildContext context, int index) { + return Text('$index'); + }, + childCount: childCount, + ), + ); + } + + await tester.pumpWidget(buildFrame(1)); + expect(tester.renderObject(find.text('0')).attached, true); + + await tester.pumpWidget(buildFrame(2)); + expect(tester.renderObject(find.text('0')).attached, true); + expect(tester.renderObject(find.text('1')).attached, true); + }); + + testWidgets('selected item is in the middle', (WidgetTester tester) async { + final controller = FixedExtentScrollController(initialItem: 1); + addTearDown(controller.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: List<Widget>.generate(3, (int index) { + return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); + }), + ), + ), + ), + ), + ); + + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '1').first), const Offset(0.0, 125.0)); + + controller.jumpToItem(0); + await tester.pump(); + + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '1').first), + offsetMoreOrLessEquals(const Offset(0.0, 170.0), epsilon: 0.5), + ); + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0').first), const Offset(0.0, 125.0)); + }); + }); + + testWidgets('picker dark mode', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + backgroundColor: const CupertinoDynamicColor.withBrightness( + color: Color( + 0xFF123456, + ), // Set alpha channel to FF to disable under magnifier painting. + darkColor: Color(0xFF654321), + ), + itemExtent: 15.0, + children: const <Widget>[Text('1'), Text('1')], + onSelectedItemChanged: (int i) {}, + ), + ), + ), + ), + ); + + expect( + find.byType(CupertinoPicker), + paints..rsuperellipse(color: const Color.fromARGB(30, 118, 118, 128)), + ); + expect(find.byType(CupertinoPicker), paints..rect(color: const Color(0xFF123456))); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + backgroundColor: const CupertinoDynamicColor.withBrightness( + color: Color(0xFF123456), + darkColor: Color(0xFF654321), + ), + itemExtent: 15.0, + children: const <Widget>[Text('1'), Text('1')], + onSelectedItemChanged: (int i) {}, + ), + ), + ), + ), + ); + + expect( + find.byType(CupertinoPicker), + paints..rsuperellipse(color: const Color.fromARGB(61, 118, 118, 128)), + ); + expect(find.byType(CupertinoPicker), paints..rect(color: const Color(0xFF654321))); + }); + + testWidgets('picker selectionOverlay', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + itemExtent: 15.0, + onSelectedItemChanged: (int i) {}, + selectionOverlay: const CupertinoPickerDefaultSelectionOverlay( + background: Color(0x12345678), + ), + children: const <Widget>[Text('1'), Text('1')], + ), + ), + ), + ), + ); + + expect(find.byType(CupertinoPicker), paints..rsuperellipse(color: const Color(0x12345678))); + }); + + testWidgets('CupertinoPicker.selectionOverlay is nullable', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + itemExtent: 15.0, + onSelectedItemChanged: (int i) {}, + selectionOverlay: null, + children: const <Widget>[Text('1'), Text('1')], + ), + ), + ), + ), + ); + + expect(find.byType(CupertinoPicker), isNot(paints..rsuperellipse())); + }); + + group('scroll', () { + testWidgets( + 'scrolling calls onSelectedItemChanged and triggers haptic feedback when scroll passes middle of item', + (WidgetTester tester) async { + final selectedItems = <int>[]; + final systemCalls = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + systemCalls.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + itemExtent: 100.0, + onSelectedItemChanged: (int index) { + selectedItems.add(index); + }, + children: List<Widget>.generate(100, (int index) { + return Center( + child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), + ); + }), + ), + ), + ); + // Drag to almost the middle of the next item. + await tester.drag( + find.text('0'), + const Offset(0.0, -90.0), + warnIfMissed: false, + ); // has an IgnorePointer + // Expect that the item changed, but haptics were not triggered yet, + // since we are not in the middle of the item. + expect(selectedItems, <int>[1]); + expect(systemCalls, isEmpty); + + // Let the scroll settle and end up in the middle of the item. + await tester.pumpAndSettle(); + expect(systemCalls, hasLength(2)); + // Check that the haptic feedback and ticking sound were triggered. + expect( + systemCalls[0], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), + ); + expect(systemCalls[1], isMethodCall('SystemSound.play', arguments: 'SystemSoundType.tick')); + + // Overscroll a little to pass the middle of the item. + await tester.drag( + find.text('0'), + const Offset(0.0, 110.0), + warnIfMissed: false, + ); // has an IgnorePointer + expect(selectedItems, <int>[1, 0]); + expect(systemCalls, hasLength(4)); + expect( + systemCalls[2], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), + ); + expect(systemCalls[3], isMethodCall('SystemSound.play', arguments: 'SystemSoundType.tick')); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('scrolling with new behavior calls onSelectedItemChanged only when scroll ends', ( + WidgetTester tester, + ) async { + final selectedItems = <int>[]; + final systemCalls = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + systemCalls.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + itemExtent: 100.0, + changeReportingBehavior: ChangeReportingBehavior.onScrollEnd, + onSelectedItemChanged: (int index) { + selectedItems.add(index); + }, + children: List<Widget>.generate(100, (int index) { + return Center( + child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), + ); + }), + ), + ), + ); + + final Offset initialOffset = tester.getTopLeft(find.text('0')); + // Drag to almost the middle of the next item. + final TestGesture scrollGesture = await tester.startGesture(initialOffset); + // Item 0 is still closest to the center. No updates. + await scrollGesture.moveBy(const Offset(0.0, -49.0)); + expect(selectedItems.isEmpty, true); + + // Now item 1 is closest to the center. + await scrollGesture.moveBy(const Offset(0.0, -1.0)); + expect(selectedItems, <int>[]); + + // Now item 1 is still closest to the center for another full itemExtent (100px). + await scrollGesture.moveBy(const Offset(0.0, -99.0)); + expect(selectedItems, <int>[]); + + await scrollGesture.moveBy(const Offset(0.0, -1.0)); + await scrollGesture.up(); + await tester.pumpAndSettle(); + expect(selectedItems, <int>[2]); + + await scrollGesture.down(initialOffset); + await scrollGesture.moveBy(const Offset(0.0, 100.0)); + expect(selectedItems, <int>[2]); + + await scrollGesture.up(); + expect(selectedItems, <int>[2, 1]); + }); + + testWidgets( + 'does not trigger haptics or sounds when scrolling by tapping on the item', + (WidgetTester tester) async { + final selectedItems = <int>[]; + final systemCalls = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + systemCalls.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + itemExtent: 100.0, + onSelectedItemChanged: (int index) { + selectedItems.add(index); + }, + children: List<Widget>.generate(100, (int index) { + return Center( + child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), + ); + }), + ), + ), + ); + + await tester.tap(find.text('2'), warnIfMissed: false); // has an IgnorePointer + await tester.pumpAndSettle(const Duration(milliseconds: 10)); + + // Expect that the item changed, but haptics were not triggered. + expect(selectedItems, <int>[1, 2]); + expect(systemCalls, isEmpty); + + await tester.drag(find.text('2'), const Offset(0.0, -30.0), warnIfMissed: false); + await tester.pumpAndSettle(const Duration(milliseconds: 10)); + // Expect that moving within the item does not trigger haptics after animating scroll. + expect(selectedItems, <int>[1, 2]); + expect(systemCalls, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'do not trigger haptic or sounds on non-iOS devices', + (WidgetTester tester) async { + final selectedItems = <int>[]; + final systemCalls = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + systemCalls.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + itemExtent: 100.0, + onSelectedItemChanged: (int index) { + selectedItems.add(index); + }, + children: List<Widget>.generate(100, (int index) { + return Center( + child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), + ); + }), + ), + ), + ); + + await tester.drag( + find.text('0'), + const Offset(0.0, -100.0), + warnIfMissed: false, + ); // has an IgnorePointer + + // Allow the scroll to settle in the middle of the item. + await tester.pumpAndSettle(); + + expect(selectedItems, <int>[1]); + expect(systemCalls, isEmpty); + }, + variant: TargetPlatformVariant( + TargetPlatform.values + .where((TargetPlatform platform) => platform != TargetPlatform.iOS) + .toSet(), + ), + ); + + testWidgets( + 'a drag in between items settles back', + (WidgetTester tester) async { + final controller = FixedExtentScrollController(initialItem: 10); + addTearDown(controller.dispose); + final selectedItems = <int>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 100.0, + onSelectedItemChanged: (int index) { + selectedItems.add(index); + }, + children: List<Widget>.generate(100, (int index) { + return Center( + child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), + ); + }), + ), + ), + ); + + // Drag it by a bit but not enough to move to the next item. + await tester.drag( + find.text('10'), + const Offset(0.0, 30.0), + pointer: 1, + touchSlopY: 0.0, + warnIfMissed: false, + ); // has an IgnorePointer + + // The item that was in the center now moved a bit. + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '10')), const Offset(200.0, 250.0)); + + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '10')).dy, + moreOrLessEquals(250.0, epsilon: 0.5), + ); + expect(selectedItems.isEmpty, true); + + // Drag it by enough to move to the next item. + await tester.drag( + find.text('10'), + const Offset(0.0, 70.0), + pointer: 1, + touchSlopY: 0.0, + warnIfMissed: false, + ); // has an IgnorePointer + + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '10')).dy, + // It's down by 100.0 now. + moreOrLessEquals(340.0, epsilon: 0.5), + ); + expect(selectedItems, <int>[9]); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'a big fling that overscrolls springs back', + (WidgetTester tester) async { + final controller = FixedExtentScrollController(initialItem: 10); + addTearDown(controller.dispose); + final selectedItems = <int>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 100.0, + onSelectedItemChanged: (int index) { + selectedItems.add(index); + }, + children: List<Widget>.generate(100, (int index) { + return Center( + child: SizedBox(width: 400.0, height: 100.0, child: Text(index.toString())), + ); + }), + ), + ), + ); + + // A wild throw appears. + await tester.fling( + find.text('10'), + const Offset(0.0, 10000.0), + 1000.0, + warnIfMissed: false, // has an IgnorePointer + ); + + if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) { + // Should have been flung far enough that even the first item goes off + // screen and gets removed. + expect(find.widgetWithText(SizedBox, '0').evaluate().isEmpty, true); + } + + expect( + selectedItems, + // This specific throw was fast enough that each scroll update landed + // on every second item. + <int>[8, 6, 4, 2, 0], + ); + + // Let it spring back. + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + // Should have sprung back to the middle now. + moreOrLessEquals(250.0), + ); + expect( + selectedItems, + // Falling back to 0 shouldn't produce more callbacks. + <int>[8, 6, 4, 2, 0], + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + }); + + testWidgets('Picker adapts to CupertinoApp dark mode', (WidgetTester tester) async { + Widget buildCupertinoPicker(Brightness brightness) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: List<Widget>.generate(3, (int index) { + return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); + }), + ), + ), + ), + ); + } + + // CupertinoPicker with light theme. + await tester.pumpWidget(buildCupertinoPicker(Brightness.light)); + RenderParagraph paragraph = tester.renderObject(find.text('1')); + final Color expectedLight = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoPicker)), + ); + expect(paragraph.text.style!.color, expectedLight); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoPicker with dark theme. + await tester.pumpWidget(buildCupertinoPicker(Brightness.dark)); + paragraph = tester.renderObject(find.text('1')); + final Color expectedDark = CupertinoColors.label.resolveFrom( + tester.element(find.byType(CupertinoPicker)), + ); + expect(paragraph.text.style!.color, expectedDark); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); + + group('CupertinoPickerDefaultSelectionOverlay', () { + testWidgets('should be using directional decoration', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: CupertinoPicker( + itemExtent: 15.0, + onSelectedItemChanged: (int i) {}, + selectionOverlay: const CupertinoPickerDefaultSelectionOverlay( + background: Color(0x12345678), + ), + children: const <Widget>[Text('1'), Text('1')], + ), + ), + ); + + final Finder selectionContainer = find.byType(Container); + final Container container = tester.firstWidget<Container>(selectionContainer); + final EdgeInsetsGeometry? margin = container.margin; + final BorderRadiusGeometry? borderRadius = + ((container.decoration as ShapeDecoration?)?.shape as RoundedSuperellipseBorder?) + ?.borderRadius; + + expect(margin, isA<EdgeInsetsDirectional>()); + expect(borderRadius, isA<BorderRadiusDirectional>()); + }); + }); + + testWidgets('Scroll controller is detached upon dispose', (WidgetTester tester) async { + final controller = SpyFixedExtentScrollController(); + addTearDown(controller.dispose); + expect(controller.hasListeners, false); + expect(controller.positions.length, 0); + + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: Center( + child: CupertinoPicker( + scrollController: controller, + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: List<Widget>.generate(3, (int index) { + return SizedBox(width: 300.0, child: Text(index.toString())); + }), + ), + ), + ), + ), + ); + expect(controller.hasListeners, true); + expect(controller.positions.length, 1); + + await tester.pumpWidget(const SizedBox.expand()); + expect(controller.hasListeners, false); + expect(controller.positions.length, 0); + }); + + testWidgets('Registers taps and does not crash with certain diameterRatio', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/126491 + + final children = List<int>.generate(100, (int index) => index); + final paintedChildren = <int>[]; + final tappedChildren = <int>{}; + + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: Center( + child: SizedBox( + height: 120, + child: CupertinoPicker( + itemExtent: 55, + diameterRatio: 0.9, + onSelectedItemChanged: (int index) {}, + children: children + .map<Widget>( + (int index) => GestureDetector( + key: ValueKey<int>(index), + onTap: () { + tappedChildren.add(index); + }, + child: SizedBox.square( + dimension: 55, + child: CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + paintedChildren.add(index); + }, + ), + ), + ), + ), + ) + .toList(), + ), + ), + ), + ), + ), + ); + + // Children are painted two times for whatever reason + expect(paintedChildren, <int>[0, 1, 0, 1]); + + // Expect hitting 0 and 1, which are painted + await tester.tap(find.byKey(const ValueKey<int>(0))); + expect(tappedChildren, const <int>[0]); + + await tester.tap(find.byKey(const ValueKey<int>(1))); + expect(tappedChildren, const <int>[0, 1]); + + // The third child is not painted, so is not hit + await tester.tap(find.byKey(const ValueKey<int>(2)), warnIfMissed: false); + expect(tappedChildren, const <int>[0, 1]); + }); + + testWidgets('Tapping on child in a CupertinoPicker selects that child', ( + WidgetTester tester, + ) async { + var selectedItem = 0; + const tapScrollDuration = Duration(milliseconds: 300); + // The tap animation is set to 300ms, but add an extra 1µs to complete the scroll animation. + const infinitesimalPause = Duration(microseconds: 1); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPicker( + itemExtent: 10.0, + onSelectedItemChanged: (int i) { + selectedItem = i; + }, + children: const <Widget>[Text('0'), Text('1'), Text('2'), Text('3')], + ), + ), + ); + + expect(selectedItem, equals(0)); + // Tap on the item at index 1. + await tester.tap(find.text('1')); + await tester.pump(); + await tester.pump(tapScrollDuration + infinitesimalPause); + expect(selectedItem, equals(1)); + + // Skip to the item at index 3. + await tester.tap(find.text('3')); + await tester.pump(); + await tester.pump(tapScrollDuration + infinitesimalPause); + expect(selectedItem, equals(3)); + + // Tap on the item at index 0. + await tester.tap(find.text('0')); + await tester.pump(); + await tester.pump(tapScrollDuration + infinitesimalPause); + expect(selectedItem, equals(0)); + + // Skip to the item at index 2. + await tester.tap(find.text('2')); + await tester.pump(); + await tester.pump(tapScrollDuration + infinitesimalPause); + expect(selectedItem, equals(2)); + }); + + testWidgets('CupertinoPickerDefaultSelectionOverlay does not crash at zero area', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoPickerDefaultSelectionOverlay())), + ); + expect(tester.getSize(find.byType(CupertinoPickerDefaultSelectionOverlay)), Size.zero); + }); + + testWidgets('CupertinoPicker does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoPicker( + itemExtent: 2.0, + onSelectedItemChanged: (_) {}, + children: const <Widget>[Text('X'), Text('Y')], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoPicker)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/popup_surface_test.dart b/packages/cupertino_ui/test/cupertino/popup_surface_test.dart new file mode 100644 index 000000000000..d31514b006a7 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/popup_surface_test.dart @@ -0,0 +1,365 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FilterTest extends StatelessWidget { + const _FilterTest(Widget child, {this.brightness = Brightness.light}) : _child = child; + final Brightness brightness; + final Widget _child; + + @override + Widget build(BuildContext context) { + final Size size = MediaQuery.sizeOf(context); + final double tileHeight = size.height / 4; + final double tileWidth = size.width / 8; + return CupertinoApp( + home: Stack( + fit: StackFit.expand, + children: <Widget>[ + // 512 color tiles + // 4 alpha levels (0.416, 0.25, 0.5, 0.75) + for (int a = 0; a < 4; a++) + for (int h = 0; h < 8; h++) // 8 hues + for (int s = 0; s < 4; s++) // 4 saturation levels + for (int b = 0; b < 4; b++) // 4 brightness levels + Positioned( + left: h * tileWidth + b * tileWidth / 4, + top: a * tileHeight + s * tileHeight / 4, + height: tileHeight, + width: tileWidth, + child: ColoredBox( + color: HSVColor.fromAHSV( + 0.5 + a / 8, + h * 45, + 0.5 + s / 8, + 0.5 + b / 8, + ).toColor(), + ), + ), + Padding( + padding: const EdgeInsets.all(32), + child: CupertinoTheme( + data: CupertinoThemeData(brightness: brightness), + child: _child, + ), + ), + ], + ), + ); + } +} + +void main() { + void disableVibranceForTest() { + CupertinoPopupSurface.debugIsVibrancePainted = false; + addTearDown(() { + CupertinoPopupSurface.debugIsVibrancePainted = true; + }); + } + + // Golden displays the color filter effect of the CupertinoPopupSurface + // when the ambient brightness is light. + testWidgets( + 'Brightness.light color filter', + (WidgetTester tester) async { + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(blurSigma: 0, isSurfacePainted: false, child: SizedBox()), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.color-filter.light.png'), + ); + }, + skip: kIsWasm, // https://github.com/flutter/flutter/issues/152026 + ); + + // Golden displays the color filter effect of the CupertinoPopupSurface + // when the ambient brightness is dark. + testWidgets( + 'Brightness.dark color filter', + (WidgetTester tester) async { + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(blurSigma: 0, isSurfacePainted: false, child: SizedBox()), + brightness: Brightness.dark, + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.color-filter.dark.png'), + ); + }, + skip: kIsWasm, // https://github.com/flutter/flutter/issues/152026 + ); + + // Golden displays color tiles without CupertinoPopupSurface being + // displayed. + testWidgets('Setting debugIsVibrancePainted to false removes the color filter', ( + WidgetTester tester, + ) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(blurSigma: 0, isSurfacePainted: false, child: SizedBox()), + ), + ); + + // The BackdropFilter widget should not be mounted when blurSigma is 0 and + // CupertinoPopupSurface.debugIsVibrancePainted is false. + expect( + find.descendant( + of: find.byType(CupertinoPopupSurface), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.color-filter.removed.png'), + ); + }); + + // Golden displays the surface color of the CupertinoPopupSurface + // in light mode. + testWidgets('Brightness.light surface color', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest(CupertinoPopupSurface(blurSigma: 0, child: SizedBox())), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.surface-color.light.png'), + ); + }); + + // Golden displays the surface color of the CupertinoPopupSurface + // in dark mode. + testWidgets('Brightness.dark surface color', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(blurSigma: 0, child: SizedBox()), + brightness: Brightness.dark, + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.surface-color.dark.png'), + ); + }); + + // Golden displays a CupertinoPopupSurface with the color removed. The result + // should only display color tiles. + testWidgets('Setting isSurfacePainted to false removes the surface color', ( + WidgetTester tester, + ) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(blurSigma: 0, isSurfacePainted: false, child: SizedBox()), + brightness: Brightness.dark, + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.surface-color.removed.png'), + ); + }); + + // Goldens display a CupertinoPopupSurface with no vibrance or surface + // color, with blur sigmas of 5 and 30 (default). + testWidgets( + 'Positive blurSigma applies blur', + (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(isSurfacePainted: false, blurSigma: 5, child: SizedBox()), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.blur.5.png'), + ); + + await tester.pumpWidget( + const _FilterTest(CupertinoPopupSurface(isSurfacePainted: false, child: SizedBox())), + ); + + await expectLater( + find.byType(CupertinoApp), + // 30 is the default blur sigma + matchesGoldenFile('cupertinoPopupSurface.blur.30.png'), + ); + }, + skip: kIsWasm, // https://github.com/flutter/flutter/issues/152026 + ); + + // Golden displays a CupertinoPopupSurface with a blur sigma of 0. Because + // the blur sigma is 0 and vibrance and surface are not painted, no popup + // surface is displayed. + testWidgets('Setting blurSigma to zero removes blur', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(isSurfacePainted: false, blurSigma: 0, child: SizedBox()), + ), + ); + + // The BackdropFilter widget should not be mounted when blurSigma is 0 and + // CupertinoPopupSurface.isVibrancePainted is false. + expect( + find.descendant( + of: find.byType(CupertinoPopupSurface), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.blur.0.png'), + ); + + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface(isSurfacePainted: false, blurSigma: 0, child: SizedBox()), + ), + ); + }); + + testWidgets('Setting a blurSigma to a negative number throws', (WidgetTester tester) async { + try { + disableVibranceForTest(); + await tester.pumpWidget( + _FilterTest( + CupertinoPopupSurface(isSurfacePainted: false, blurSigma: -1, child: const SizedBox()), + ), + ); + + fail('CupertinoPopupSurface did not throw when provided a negative blur sigma.'); + } on AssertionError catch (error) { + expect( + error.toString(), + contains('CupertinoPopupSurface requires a non-negative blur sigma.'), + ); + } + }); + + // Regression test for https://github.com/flutter/flutter/issues/154887. + testWidgets( + "Applying a FadeTransition to the CupertinoPopupSurface doesn't cause transparency", + (WidgetTester tester) async { + final controller = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + addTearDown(controller.dispose); + controller.forward(); + + await tester.pumpWidget( + _FilterTest( + FadeTransition( + opacity: controller, + child: const CupertinoPopupSurface(child: SizedBox()), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // Golden should display a CupertinoPopupSurface with no transparency + // directly underneath the surface. A small amount of transparency should be + // present on the upper-left corner of the screen. + // + // If transparency (gray and white grid) is present underneath the surface, + // the blendmode is being incorrectly applied. + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.blendmode-fix.0.png'), + ); + + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + // Golden displays a CupertinoPopupSurface with all enabled features. + // + // CupertinoPopupSurface uses ImageFilter.compose, which applies an inner + // filter first, followed by an outer filter (e.g. result = + // outer(inner(source))). + // + // For CupertinoPopupSurface, this means that the pixels underlying the + // surface are first saturated with a ColorFilter, and the resulting saturated + // pixels are blurred with an ImageFilter.blur. This test verifies that this + // order does not change. + testWidgets('Saturation is applied before blur', (WidgetTester tester) async { + await tester.pumpWidget(const _FilterTest(CupertinoPopupSurface(child: SizedBox()))); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.composition.png'), + ); + + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + Stack( + fit: StackFit.expand, + children: <Widget>[ + CupertinoPopupSurface(isSurfacePainted: false, blurSigma: 0, child: SizedBox()), + CupertinoPopupSurface(child: SizedBox()), + ], + ), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.composition.png'), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/182066. + testWidgets('CupertinoPopupSurface uses unbounded blur', (WidgetTester tester) async { + void expectContainsUnboundedBlur() { + var foundBlur = false; + for (final Layer layer in tester.layers) { + if (layer is BackdropFilterLayer) { + if (layer.toString().contains('blur')) { + expect(layer.toString(), isNot(contains('bounds: '))); + foundBlur = true; + } + } + } + expect(foundBlur, isTrue); + } + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoPopupSurface(child: Text('X'))), + ), + ); + expectContainsUnboundedBlur(); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/radio_test.dart b/packages/cupertino_ui/test/cupertino/radio_test.dart new file mode 100644 index 000000000000..23f4e8e8542e --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/radio_test.dart @@ -0,0 +1,992 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Radio control test', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>(key: key, value: 1, groupValue: 2, onChanged: log.add), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + log.clear(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + activeColor: CupertinoColors.systemGreen, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(key: key, value: 1, groupValue: 2)), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + }); + + testWidgets('Radio disabled', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + key: key, + value: 1, + groupValue: 2, + enabled: false, + onChanged: log.add, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[])); + }); + + testWidgets('Radio can be toggled when toggleable is set', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + toggleable: true, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + log.clear(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + toggleable: true, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int?>[null])); + log.clear(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>(key: key, value: 1, onChanged: log.add, toggleable: true), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + }); + + testWidgets('Radio selected semantics - platform adaptive', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 1, onChanged: (int? i) {})), + ), + ); + + final bool isApple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + expect( + semantics, + includesNodeWith( + flags: <SemanticsFlag>[ + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isChecked, + if (isApple) SemanticsFlag.hasSelectedState, + if (isApple) SemanticsFlag.isSelected, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform != TargetPlatform.iOS) SemanticsAction.focus, + ], + ), + ); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Radio semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + isInMutuallyExclusiveGroup: true, + ), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 2, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + isInMutuallyExclusiveGroup: true, + isChecked: true, + ), + ); + + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 2))), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isFocusable: true, + isInMutuallyExclusiveGroup: true, + hasFocusAction: true, + ), + ); + + await tester.pump(); + + // Now the isFocusable should be gone. + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isInMutuallyExclusiveGroup: true, + ), + ); + + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoRadio<int>(value: 2, groupValue: 2))), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + isInMutuallyExclusiveGroup: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('has semantic events', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + dynamic semanticEvent; + int? radioValue = 2; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + key: key, + value: 1, + groupValue: radioValue, + onChanged: (int? i) { + radioValue = i; + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + final RenderObject object = tester.firstRenderObject(find.byKey(key)); + + expect(radioValue, 1); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semantics.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('Radio can be controlled by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 1; + const radioKey0 = Key('radio0'); + const radioKey1 = Key('radio1'); + const radioKey2 = Key('radio2'); + final focusNode2 = FocusNode(debugLabel: 'radio2'); + addTearDown(focusNode2.dispose); + Widget buildApp({bool enabled = true}) { + return CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SizedBox( + width: 200, + height: 100, + child: Row( + children: <Widget>[ + CupertinoRadio<int>( + key: radioKey0, + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + groupValue: groupValue, + autofocus: true, + ), + CupertinoRadio<int>( + key: radioKey1, + value: 1, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + groupValue: groupValue, + ), + CupertinoRadio<int>( + key: radioKey2, + value: 2, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + groupValue: groupValue, + focusNode: focusNode2, + ), + ], + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + // On web, radios don't respond to the enter key. + expect(groupValue, kIsWeb ? equals(1) : equals(0)); + + focusNode2.requestFocus(); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(groupValue, equals(2)); + }); + + testWidgets('Show a checkmark when useCheckmarkStyle is true', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 1, onChanged: (int? i) {})), + ), + ); + await tester.pumpAndSettle(); + + // Has no checkmark when useCheckmarkStyle is false + expect( + tester.firstRenderObject<RenderBox>(find.byType(CupertinoRadio<int>)), + isNot(paints..path()), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 2, + useCheckmarkStyle: true, + onChanged: (int? i) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Has no checkmark when group value doesn't match the value + expect( + tester.firstRenderObject<RenderBox>(find.byType(CupertinoRadio<int>)), + isNot(paints..path()), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 1, + useCheckmarkStyle: true, + onChanged: (int? i) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Draws a path to show the checkmark when toggled on + expect(tester.firstRenderObject<RenderBox>(find.byType(CupertinoRadio<int>)), paints..path()); + }); + + testWidgets('Do not crash when widget disappears while pointer is down', ( + WidgetTester tester, + ) async { + final Key key = UniqueKey(); + + Widget buildRadio(bool show) { + return CupertinoApp( + home: Center( + child: show + ? CupertinoRadio<bool>(key: key, value: true, groupValue: false, onChanged: (_) {}) + : Container(), + ), + ); + } + + await tester.pumpWidget(buildRadio(true)); + final Offset center = tester.getCenter(find.byKey(key)); + // Put a pointer down on the screen. + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); + // While the pointer is down, the widget disappears. + await tester.pumpWidget(buildRadio(false)); + expect(find.byKey(key), findsNothing); + // Release pointer after widget disappeared. + await gesture.up(); + }); + + testWidgets('Radio has correct default active/inactive/fill/border colors in light mode', ( + WidgetTester tester, + ) async { + Widget buildRadio({required int value, required int groupValue}) { + return CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoRadio<int>( + value: value, + groupValue: groupValue, + onChanged: (int? i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.light_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.light_theme.unselected.png'), + ); + }); + + testWidgets('Radio has correct default active/inactive/fill/border colors in dark mode', ( + WidgetTester tester, + ) async { + Widget buildRadio({required int value, required int groupValue, bool enabled = true}) { + return CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary( + child: CupertinoRadio<int>( + value: value, + groupValue: groupValue, + onChanged: enabled ? (int? i) {} : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.dark_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.dark_theme.unselected.png'), + ); + }); + + testWidgets( + 'Disabled radio has correct default active/inactive/fill/border colors in light mode', + (WidgetTester tester) async { + Widget buildRadio({required int value, required int groupValue}) { + return CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoRadio<int>(value: value, groupValue: groupValue), + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.disabled_light_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.disabled_light_theme.unselected.png'), + ); + }, + ); + + testWidgets( + 'Disabled radio has correct default active/inactive/fill/border colors in dark mode', + (WidgetTester tester) async { + Widget buildRadio({required int value, required int groupValue}) { + return CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary( + child: CupertinoRadio<int>(value: value, groupValue: groupValue), + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(value: 1, groupValue: 1)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.disabled_dark_theme.selected.png'), + ); + await tester.pumpWidget(buildRadio(value: 1, groupValue: 2)); + await expectLater( + find.byType(CupertinoRadio<int>), + matchesGoldenFile('radio.disabled_dark_theme.unselected.png'), + ); + }, + ); + + testWidgets('Radio can set inactive/active/fill colors', (WidgetTester tester) async { + const inactiveBorderColor = Color(0xffd1d1d6); + const activeColor = Color(0x0000000A); + const fillColor = Color(0x0000000B); + const inactiveColor = Color(0x0000000C); + const innerRadius = 2.975; + const outerRadius = 7.0; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 2, + onChanged: (int? i) {}, + activeColor: activeColor, + fillColor: fillColor, + inactiveColor: inactiveColor, + ), + ), + ), + ); + + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: inactiveColor) + ..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor), + reason: 'Unselected radio button should use inactive and border colors', + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 1, + onChanged: (int? i) {}, + activeColor: activeColor, + fillColor: fillColor, + inactiveColor: inactiveColor, + ), + ), + ), + ); + + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: fillColor), + reason: 'Selected radio button should use active and fill colors', + ); + }); + + testWidgets('Radio is slightly darkened when pressed in light mode', (WidgetTester tester) async { + const activeInnerColor = Color(0xffffffff); + const activeOuterColor = Color(0xff007aff); + const inactiveBorderColor = Color(0xffd1d1d6); + const inactiveOuterColor = Color(0xffffffff); + const innerRadius = 2.975; + const outerRadius = 7.0; + const pressedShadowColor = Color(0x26ffffff); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoRadio<int>)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: inactiveOuterColor) + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor), + reason: 'Unselected pressed radio button is slightly darkened', + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 2, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoRadio<int>)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor), + reason: 'Selected pressed radio button is slightly darkened', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pump(); + }); + + testWidgets('Radio is slightly lightened when pressed in dark mode', (WidgetTester tester) async { + const activeInnerColor = Color(0xffffffff); + const activeOuterColor = Color(0xff007aff); + const inactiveBorderColor = Color(0x40000000); + const innerRadius = 2.975; + const outerRadius = 7.0; + const pressedShadowColor = Color(0x26ffffff); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoRadio<int>)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoRadio<int>), + paints + ..path() + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor), + reason: 'Unselected pressed radio button is slightly lightened', + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 2, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(CupertinoRadio<int>)), + ); + await tester.pump(); + + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor), + reason: 'Selected pressed radio button is slightly lightened', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pump(); + }); + + testWidgets('Radio is focusable and has correct focus colors', (WidgetTester tester) async { + const activeInnerColor = Color(0xffffffff); + const activeOuterColor = Color(0xff007aff); + final Color defaultFocusColor = + HSLColor.fromColor(CupertinoColors.activeBlue.withOpacity(kCupertinoFocusColorOpacity)) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + const innerRadius = 2.975; + const outerRadius = 7.0; + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 1, + onChanged: (int? i) {}, + focusNode: node, + autofocus: true, + ), + ), + ), + ); + + await tester.pump(); + expect(node.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor) + ..circle(strokeWidth: 3.0, style: PaintingStyle.stroke, color: defaultFocusColor), + reason: 'Radio is focusable and shows the default focus color', + ); + }); + + testWidgets('Radio can configure a focus color', (WidgetTester tester) async { + const activeInnerColor = Color(0xffffffff); + const activeOuterColor = Color(0xff007aff); + const focusColor = Color(0x0000000A); + const innerRadius = 2.975; + const outerRadius = 7.0; + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 1, + onChanged: (int? i) {}, + focusColor: focusColor, + focusNode: node, + autofocus: true, + ), + ), + ), + ); + + await tester.pump(); + expect(node.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoRadio<int>), + paints + ..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor) + ..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor) + ..circle(strokeWidth: 3.0, style: PaintingStyle.stroke, color: focusColor), + reason: 'Radio configures the color of the focus outline', + ); + }); + + testWidgets('Radio configures mouse cursor', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 1, + onChanged: (int? i) {}, + mouseCursor: SystemMouseCursors.forbidden, + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoRadio<int>))); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoRadio<int>))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('Mouse cursor resolves in disabled/hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoRadio<int>( + value: 1, + groupValue: 1, + onChanged: (int? i) {}, + mouseCursor: const _RadioMouseCursor(), + focusNode: focusNode, + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoRadio<int>))); + await tester.pump(); + + // Test hovered case. + await gesture.moveTo(tester.getCenter(find.byType(CupertinoRadio<int>))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test focused case. + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test disabled case. + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoRadio<int>(value: 1, groupValue: 1, mouseCursor: _RadioMouseCursor()), + ), + ), + ); + + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + focusNode.dispose(); + }); + + testWidgets('Radio default mouse cursor', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 1, onChanged: (int? i) {})), + ), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoRadio<int>))); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoRadio<int>))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + // Regression tests for https://github.com/flutter/flutter/issues/170422 + group('Radio accessibility announcements on various platforms', () { + testWidgets('Unselected radio should be vocalized via hint on iOS/macOS platform', ( + WidgetTester tester, + ) async { + const WidgetsLocalizations localizations = DefaultWidgetsLocalizations(); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 2, groupValue: 1, onChanged: (int? i) {})), + ), + ); + + final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last); + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + expect(semanticNode.hint, localizations.radioButtonUnselectedLabel); + } else { + expect(semanticNode.hint, anyOf(isNull, isEmpty)); + } + }); + + testWidgets('Selected radio should be vocalized via the selected flag on all platforms', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 1, onChanged: (int? i) {})), + ), + ); + + final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last); + // Radio semantics should not have hint. + expect(semanticNode.hint, anyOf(isNull, isEmpty)); + }); + }); + + testWidgets('CupertinoRadio does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: SizedBox.shrink(child: CupertinoRadio<bool>(value: false))), + ), + ); + expect(tester.getSize(find.byType(CupertinoRadio<bool>)), Size.zero); + }); +} + +class _RadioMouseCursor extends WidgetStateMouseCursor { + const _RadioMouseCursor(); + + @override + MouseCursor resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.forbidden; + } + if (states.contains(WidgetState.focused)) { + return SystemMouseCursors.basic; + } + return SystemMouseCursors.click; + } + + @override + String get debugDescription => '_RadioMouseCursor()'; +} diff --git a/packages/cupertino_ui/test/cupertino/refresh_test.dart b/packages/cupertino_ui/test/cupertino/refresh_test.dart new file mode 100644 index 000000000000..3d605577b65c --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/refresh_test.dart @@ -0,0 +1,1940 @@ +// 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. + +@TestOn('!chrome') +library; + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late FakeBuilder mockHelper; + + setUp(() { + mockHelper = FakeBuilder(); + }); + + var testListLength = 10; + SliverList buildAListOfStuff() { + return SliverList.builder( + itemCount: testListLength, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text(index.toString()))); + }, + ); + } + + void uiTestGroup() { + testWidgets( + "doesn't invoke anything without user interaction", + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + expect(mockHelper.invocations, isEmpty); + + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')), Offset.zero); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'calls the indicator builder when starting to overscroll', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + // Drag down but not enough to trigger the refresh. + await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0); + await tester.pump(); + + // The function is referenced once while passing into CupertinoSliverRefreshControl + // and is called. + expect( + mockHelper.invocations.first, + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: 50, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ); + expect(mockHelper.invocations, hasLength(1)); + + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')), const Offset(0.0, 50.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + "don't call the builder if overscroll doesn't move slivers like on Android", + (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ), + ); + + // Drag down but not enough to trigger the refresh. + await tester.drag(find.text('0'), const Offset(0.0, 50.0)); + await tester.pump(); + + expect(mockHelper.invocations, isEmpty); + + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')), Offset.zero); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'let the builder update as canceled drag scrolls away', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + // Drag down but not enough to trigger the refresh. + await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pump(const Duration(milliseconds: 20)); + await tester.pump(const Duration(seconds: 3)); + + expect( + mockHelper.invocations, + containsAllInOrder(<void>[ + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: 50, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: moreOrLessEquals(48.07979523362715), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ) + else + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: moreOrLessEquals(48.36801747187993), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: moreOrLessEquals(43.98499220391114), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ) + else + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: moreOrLessEquals(44.63031931875867), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ]), + ); + // The builder isn't called again when the sliver completely goes away. + expect(mockHelper.invocations, hasLength(3)); + + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')), Offset.zero); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'drag past threshold triggers refresh task', + (WidgetTester tester) async { + final platformCallLog = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + platformCallLog.add(methodCall); + return null; + }); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(Offset.zero); + await gesture.moveBy(const Offset(0.0, 99.0)); + await tester.pump(); + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await gesture.moveBy(const Offset(0.0, -3.0)); + } else { + await gesture.moveBy(const Offset(0.0, -30.0)); + } + await tester.pump(); + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await gesture.moveBy(const Offset(0.0, 90.0)); + } else { + await gesture.moveBy(const Offset(0.0, 50.0)); + } + await tester.pump(); + + expect( + mockHelper.invocations, + containsAllInOrder(<void>[ + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: 99, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: moreOrLessEquals(96), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ) + else + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: moreOrLessEquals(86.78169), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: moreOrLessEquals(112.51104), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ) + else + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: moreOrLessEquals(105.80452021305739), + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ]), + ); + // The refresh callback is triggered after the frame. + expect(mockHelper.invocations.last, const RefreshTaskInvocation()); + expect(mockHelper.invocations, hasLength(4)); + + expect( + platformCallLog.last, + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'refreshing task keeps the sliver expanded forever until done', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); + await tester.pump(); + // Let it start snapping back. + await tester.pump(const Duration(milliseconds: 50)); + + expect( + mockHelper.invocations, + containsAllInOrder(<Matcher>[ + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: 150, + refreshTriggerPullDistance: 100, // Default value. + refreshIndicatorExtent: 60, // Default value. + ), + equals(const RefreshTaskInvocation()), + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: moreOrLessEquals(124.87933920045268), + refreshTriggerPullDistance: 100, // Default value. + refreshIndicatorExtent: 60, // Default value. + ) + else + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: moreOrLessEquals(127.10396988577114), + refreshTriggerPullDistance: 100, // Default value. + refreshIndicatorExtent: 60, // Default value. + ), + ]), + ); + + // Reaches refresh state and sliver's at 60.0 in height after a while. + await tester.pump(const Duration(seconds: 1)); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.refresh, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + + // Stays in that state forever until future completes. + await tester.pump(const Duration(seconds: 1000)); + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')), const Offset(0.0, 60.0)); + + mockHelper.refreshCompleter.complete(null); + await tester.pump(); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + expect(mockHelper.invocations, hasLength(5)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'refreshing task keeps the sliver expanded forever until completes with error', + (WidgetTester tester) async { + final error = FlutterError('Oops'); + double errorCount = 0; + final TargetPlatform? platform = + debugDefaultTargetPlatformOverride; // Will not be correct within the zone. + + runZonedGuarded( + () async { + mockHelper.refreshCompleter = Completer<void>.sync(); + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); + await tester.pump(); + // Let it start snapping back. + await tester.pump(const Duration(milliseconds: 50)); + + expect( + mockHelper.invocations, + containsAllInOrder(<Matcher>[ + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: 150, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + equals(const RefreshTaskInvocation()), + if (platform == TargetPlatform.macOS) + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: moreOrLessEquals(124.87933920045268), + refreshTriggerPullDistance: 100, // Default value. + refreshIndicatorExtent: 60, // Default value. + ) + else + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: moreOrLessEquals(127.10396988577114), + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ]), + ); + + // Reaches refresh state and sliver's at 60.0 in height after a while. + await tester.pump(const Duration(seconds: 1)); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.refresh, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + + // Stays in that state forever until future completes. + await tester.pump(const Duration(seconds: 1000)); + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')), const Offset(0.0, 60.0)); + + mockHelper.refreshCompleter.completeError(error); + await tester.pump(); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + expect(mockHelper.invocations, hasLength(5)); + }, + (Object e, StackTrace stack) { + expect(e, error); + expect(errorCount, 0); + errorCount++; + }, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'expanded refreshing sliver scrolls normally', + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); + await tester.pump(); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: 150, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + + // Given a box constraint of 150, the Center will occupy all that height. + expect( + tester.getRect(find.widgetWithText(Center, '-1')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0), + ); + + await tester.drag( + find.text('0'), + const Offset(0.0, -300.0), + touchSlopY: 0, + warnIfMissed: false, + ); // hits the list + await tester.pump(); + + // Refresh indicator still being told to layout the same way. + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.refresh, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + + // Now the sliver is scrolled off screen. + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect( + tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, + moreOrLessEquals(-210.0), + ); + expect( + tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, + moreOrLessEquals(-150.0), + ); + expect(tester.getTopLeft(find.widgetWithText(Center, '0')).dy, moreOrLessEquals(-150.0)); + } else { + expect( + tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, + moreOrLessEquals(-175.38461538461536), + ); + expect( + tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, + moreOrLessEquals(-115.38461538461536), + ); + expect( + tester.getTopLeft(find.widgetWithText(Center, '0')).dy, + moreOrLessEquals(-115.38461538461536), + ); + } + + // Scroll the top of the refresh indicator back to overscroll, it will + // snap to the size of the refresh indicator and stay there. + await tester.drag( + find.text('1'), + const Offset(0.0, 200.0), + warnIfMissed: false, + ); // hits the list + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + expect( + tester.getRect(find.widgetWithText(Center, '-1')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0), + ); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'expanded refreshing sliver goes away when done', + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); + await tester.pump(); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: 150, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + expect( + tester.getRect(find.widgetWithText(Center, '-1')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0), + ); + expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); + + // Rebuilds the sliver with a layout extent now. + await tester.pump(); + // Let it snap back to occupy the indicator's final sliver space only. + await tester.pump(const Duration(seconds: 2)); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.refresh, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + expect( + tester.getRect(find.widgetWithText(Center, '-1')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0), + ); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), + ); + + mockHelper.refreshCompleter.complete(null); + await tester.pump(); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 60, + refreshIndicatorExtent: 60, // Default value. + refreshTriggerPullDistance: 100, // Default value. + ), + ), + ); + + await tester.pump(const Duration(seconds: 5)); + expect(find.text('-1'), findsNothing); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'builder still called when sliver snapped back more than 90%', + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); + await tester.pump(); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: 150, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + expect( + tester.getRect(find.widgetWithText(Center, '-1')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0), + ); + expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); + + // Rebuilds the sliver with a layout extent now. + await tester.pump(); + // Let it snap back to occupy the indicator's final sliver space only. + await tester.pump(const Duration(seconds: 2)); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.refresh, + pulledExtent: 60, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + expect( + tester.getRect(find.widgetWithText(Center, '-1')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0), + ); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), + ); + + mockHelper.refreshCompleter.complete(null); + await tester.pump(); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 60, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + + // Waiting for refresh control to reach approximately 5% of height + await tester.pump(const Duration(milliseconds: 400)); + + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect( + tester.getRect(find.widgetWithText(Center, '0')).top, + moreOrLessEquals(3.9543032206542765, epsilon: 4e-1), + ); + expect( + tester.getRect(find.widgetWithText(Center, '-1')).height, + moreOrLessEquals(3.9543032206542765, epsilon: 4e-1), + ); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.inactive, + pulledExtent: 3.9543032206542765, // ~5% of 60.0 + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + } else { + expect( + tester.getRect(find.widgetWithText(Center, '0')).top, + moreOrLessEquals(3.0, epsilon: 4e-1), + ); + expect( + tester.getRect(find.widgetWithText(Center, '-1')).height, + moreOrLessEquals(3.0, epsilon: 4e-1), + ); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.inactive, + pulledExtent: 2.6980688300546443, // ~5% of 60.0 + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + } + expect(find.text('-1'), findsOneWidget); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'retracting sliver during done cannot be pulled to refresh again until fully retracted', + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), pointer: 1, touchSlopY: 0.0); + await tester.pump(); + expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); + + mockHelper.refreshCompleter.complete(null); + await tester.pump(); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 150.0, // Still overscrolled here. + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + + // Let it start going away but not fully. + await tester.pump(const Duration(milliseconds: 100)); + // The refresh indicator is still building. + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 90.13497854600749, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + expect( + tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy, + moreOrLessEquals(90.13497854600749), + ); + } else { + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 91.31180913199277, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + expect( + tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy, + moreOrLessEquals(91.311809131992776), + ); + } + + // Start another drag by an amount that would have been enough to + // trigger another refresh if it were in the right state. + await tester.drag( + find.text('0'), + const Offset(0.0, 150.0), + pointer: 1, + touchSlopY: 0.0, + warnIfMissed: false, + ); + await tester.pump(); + + // Instead, it's still in the done state because the sliver never + // fully retracted. + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 118.29756539042118, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + } else { + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 147.3772721631821, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + } + + // Now let it fully go away. + await tester.pump(const Duration(seconds: 5)); + expect(find.text('-1'), findsNothing); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + ); + + // Start another drag. It's now in drag mode. + await tester.drag(find.text('0'), const Offset(0.0, 40.0), pointer: 1, touchSlopY: 0.0); + await tester.pump(); + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: 40, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'sliver held in overscroll when task finishes completes normally', + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(Offset.zero); + // Start a refresh. + await gesture.moveBy(const Offset(0.0, 150.0)); + await tester.pump(); + expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); + + // Complete the task while held down. + mockHelper.refreshCompleter.complete(null); + await tester.pump(); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 150.0, // Still overscrolled here. + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 150.0, 800.0, 350.0), + ); + + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + expect(find.text('-1'), findsNothing); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'sliver scrolled away when task completes properly removes itself', + (WidgetTester tester) async { + if (testListLength < 4) { + // This test only makes sense when the list is long enough that + // the indicator can be scrolled away while refreshing. + return; + } + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + // Start a refresh. + await tester.drag(find.text('0'), const Offset(0.0, 150.0)); + await tester.pump(); + expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); + + await tester.drag(find.text('0'), const Offset(0.0, -300.0)); + await tester.pump(); + + // Refresh indicator still being told to layout the same way. + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 60, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + + // Now the sliver is scrolled off screen. + expect( + tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, + moreOrLessEquals(-175.38461538461536), + ); + expect( + tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, + moreOrLessEquals(-115.38461538461536), + ); + + // Complete the task while scrolled away. + mockHelper.refreshCompleter.complete(null); + // The sliver is instantly gone since there is no overscroll physics + // simulation. + await tester.pump(); + + // The next item's position is not disturbed. + expect( + tester.getTopLeft(find.widgetWithText(Center, '0')).dy, + moreOrLessEquals(-115.38461538461536), + ); + + // Scrolling past the first item still results in a new overscroll. + // The layout extent is gone. + await tester.drag(find.text('1'), const Offset(0.0, 120.0)); + await tester.pump(); + + expect( + mockHelper.invocations, + contains( + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: 4.615384615384642, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ), + ); + + // Snaps away normally. + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + expect(find.text('-1'), findsNothing); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + "don't do anything unless it can be overscrolled at the start of the list", + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + buildAListOfStuff(), + CupertinoSliverRefreshControl( + // it's in the middle now. + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.fling(find.byType(SizedBox).first, const Offset(0.0, 200.0), 2000.0); + await tester.fling( + find.byType(SizedBox).first, + const Offset(0.0, -200.0), + 3000.0, + warnIfMissed: false, + ); // IgnorePointer is enabled while scroll is ballistic. + + expect(mockHelper.invocations, isEmpty); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'without an onRefresh, builder is called with arm for one frame then sliver goes away', + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0); + await tester.pump(); + + expect( + mockHelper.invocations.first, + matchesBuilder( + refreshState: RefreshIndicatorMode.armed, + pulledExtent: 150.0, + refreshTriggerPullDistance: 100.0, // Default value. + refreshIndicatorExtent: 60.0, // Default value. + ), + ); + + await tester.pump(const Duration(milliseconds: 10)); + + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect( + mockHelper.invocations.last, + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: moreOrLessEquals(148.36088180097366), + refreshTriggerPullDistance: 100.0, // Default value. + refreshIndicatorExtent: 60.0, // Default value. + ), + ); + } else { + expect( + mockHelper.invocations.last, + matchesBuilder( + refreshState: RefreshIndicatorMode.done, + pulledExtent: moreOrLessEquals(148.6463892921364), + refreshTriggerPullDistance: 100.0, // Default value. + refreshIndicatorExtent: 60.0, // Default value. + ), + ); + } + + await tester.pump(const Duration(seconds: 5)); + expect(find.text('-1'), findsNothing); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Should not crash when dragged', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + CupertinoSliverRefreshControl( + onRefresh: () async => Future<void>.delayed(const Duration(days: 2000)), + ), + ], + ), + ), + ); + + await tester.dragFrom(const Offset(100, 10), const Offset(0.0, 50.0), touchSlopY: 0); + await tester.pump(); + + await tester.dragFrom(const Offset(100, 10), const Offset(0, 500), touchSlopY: 0); + await tester.pump(); + + expect(tester.takeException(), isNull); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + // Test to make sure the refresh sliver's overscroll isn't eaten by the + // nav bar sliver https://github.com/flutter/flutter/issues/74516. + testWidgets( + 'properly displays when the refresh sliver is behind the large title nav bar sliver', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + const CupertinoSliverNavigationBar(largeTitle: Text('Title')), + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + final double initialFirstCellY = tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy; + + // Drag down but not enough to trigger the refresh. + await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0); + await tester.pump(); + + expect( + mockHelper.invocations.first, + matchesBuilder( + refreshState: RefreshIndicatorMode.drag, + pulledExtent: 50, + refreshTriggerPullDistance: 100, // default value. + refreshIndicatorExtent: 60, // default value. + ), + ); + expect(mockHelper.invocations, hasLength(1)); + + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, initialFirstCellY + 50); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + } + + void stateMachineTestGroup() { + testWidgets( + 'starts in inactive state', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + expect( + CupertinoSliverRefreshControl.state( + tester.element(find.byType(LayoutBuilder, skipOffstage: false)), + ), + RefreshIndicatorMode.inactive, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'goes to drag and returns to inactive in a small drag', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl(builder: mockHelper.builder), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 20.0)); + await tester.pump(); + + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.drag, + ); + + await tester.pump(const Duration(seconds: 2)); + + expect( + CupertinoSliverRefreshControl.state( + tester.element(find.byType(LayoutBuilder, skipOffstage: false)), + ), + RefreshIndicatorMode.inactive, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'goes to armed the frame it passes the threshold', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + refreshTriggerPullDistance: 80.0, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(Offset.zero); + await gesture.moveBy(const Offset(0.0, 79.0)); + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.drag, + ); + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await gesture.moveBy( + const Offset(0.0, 20.0), + ); // Overscrolling, need to move more than 1px. + } else { + await gesture.moveBy( + const Offset(0.0, 3.0), + ); // Overscrolling, need to move more than 1px. + } + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.armed, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'goes to refresh the frame it crossed back the refresh threshold', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + refreshTriggerPullDistance: 90.0, + refreshIndicatorExtent: 50.0, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(Offset.zero); + await gesture.moveBy(const Offset(0.0, 90.0)); // Arm it. + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.armed, + ); + + await gesture.moveBy( + const Offset(0.0, -80.0), + ); // Overscrolling, need to move more than -40. + await tester.pump(); + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(10.0), // Below 50 now. + ); + } else { + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(49.775111111111116), // Below 50 now. + ); + } + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.refresh, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'goes to done internally as soon as the task finishes', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 100.0), touchSlopY: 0.0); + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.armed, + ); + // The sliver scroll offset correction is applied on the next frame. + await tester.pump(); + + await tester.pump(const Duration(seconds: 2)); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.refresh, + ); + expect( + tester.getRect(find.widgetWithText(SizedBox, '0')), + const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), + ); + + mockHelper.refreshCompleter.complete(null); + // The task completed between frames. The internal state goes to done + // right away even though the sliver gets a new offset correction the + // next frame. + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.done, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'goes back to inactive when retracting back past 10% of arming distance', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(Offset.zero); + await gesture.moveBy(const Offset(0.0, 150.0)); + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.armed, + ); + + mockHelper.refreshCompleter.complete(null); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.done, + ); + await tester.pump(); + + // Now back in overscroll mode. + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await gesture.moveBy(const Offset(0.0, -125.0)); + } else { + await gesture.moveBy(const Offset(0.0, -200.0)); + } + await tester.pump(); + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(25.0)); + } else { + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(27.944444444444457), + ); + } + // Need to bring it to 100 * 0.1 to reset to inactive. + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.done, + ); + + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await gesture.moveBy(const Offset(0.0, -16.0)); + } else { + await gesture.moveBy(const Offset(0.0, -35.0)); + } + await tester.pump(); + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + expect(tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(9.0)); + } else { + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(9.313890708161875), + ); + } + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.inactive, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'goes back to inactive if already scrolled away when task completes', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: mockHelper.builder, + onRefresh: mockHelper.refreshTask, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(Offset.zero); + await gesture.moveBy(const Offset(0.0, 150.0)); + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.armed, + ); + await tester.pump(); // Sliver scroll offset correction is applied one frame later. + + await gesture.moveBy(const Offset(0.0, -300.0)); + var indicatorDestinationPosition = -145.0332383665717; + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + indicatorDestinationPosition = -150.0; + } + await tester.pump(); + // The refresh indicator is offscreen now. + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(indicatorDestinationPosition), + ); + expect( + CupertinoSliverRefreshControl.state( + tester.element(find.byType(LayoutBuilder, skipOffstage: false)), + ), + RefreshIndicatorMode.refresh, + ); + + mockHelper.refreshCompleter.complete(null); + // The sliver layout extent is removed on next frame. + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state( + tester.element(find.byType(LayoutBuilder, skipOffstage: false)), + ), + RefreshIndicatorMode.inactive, + ); + // Nothing moved. + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(indicatorDestinationPosition), + ); + await tester.pump(const Duration(seconds: 2)); + // Everything stayed as is. + expect( + tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, + moreOrLessEquals(indicatorDestinationPosition), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + "don't have to build any indicators or occupy space during refresh", + (WidgetTester tester) async { + mockHelper.refreshIndicator = const Center(child: Text('-1')); + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverRefreshControl( + builder: null, + onRefresh: mockHelper.refreshTask, + refreshIndicatorExtent: 0.0, + ), + buildAListOfStuff(), + ], + ), + ), + ); + + await tester.drag(find.text('0'), const Offset(0.0, 150.0)); + await tester.pump(); + expect( + CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), + RefreshIndicatorMode.armed, + ); + + await tester.pump(); + await tester.pump(const Duration(seconds: 5)); + // In refresh mode but has no UI. + expect( + CupertinoSliverRefreshControl.state( + tester.element(find.byType(LayoutBuilder, skipOffstage: false)), + ), + RefreshIndicatorMode.refresh, + ); + expect( + tester.getRect(find.widgetWithText(Center, '0')), + const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + ); + + mockHelper.refreshCompleter.complete(null); + await tester.pump(); + // Goes to inactive right away since the sliver is already collapsed. + expect( + CupertinoSliverRefreshControl.state( + tester.element(find.byType(LayoutBuilder, skipOffstage: false)), + ), + RefreshIndicatorMode.inactive, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return CupertinoSliverRefreshControl.buildRefreshIndicator( + context, + RefreshIndicatorMode.drag, + 10, + 100, + 10, + ); + }, + ), + ), + ); + expect( + tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, + 10.0 / 100.0, + ); + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return CupertinoSliverRefreshControl.buildRefreshIndicator( + context, + RefreshIndicatorMode.drag, + 26, + 100, + 10, + ); + }, + ), + ), + ); + expect( + tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, + 26.0 / 100.0, + ); + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return CupertinoSliverRefreshControl.buildRefreshIndicator( + context, + RefreshIndicatorMode.drag, + 100, + 100, + 10, + ); + }, + ), + ), + ); + expect( + tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, + 100.0 / 100.0, + ); + }); + + testWidgets('indicator should not become larger when overscrolled', ( + WidgetTester tester, + ) async { + // test for https://github.com/flutter/flutter/issues/79841 + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) { + return CupertinoSliverRefreshControl.buildRefreshIndicator( + context, + RefreshIndicatorMode.done, + 120, + 100, + 10, + ); + }, + ), + ), + ); + + expect( + tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).radius, + 14.0, + ); + }); + } + + group('UI tests long list', uiTestGroup); + + // Test the internal state machine directly to make sure the UI aren't just + // correct by coincidence. + group('state machine test long list', stateMachineTestGroup); + + // Retest everything and make sure that it still works when the whole list + // is smaller than the viewport size. + testListLength = 2; + group('UI tests short list', uiTestGroup); + + // Test the internal state machine directly to make sure the UI aren't just + // correct by coincidence. + group('state machine test short list', stateMachineTestGroup); + + testWidgets('Does not crash when paintExtent > remainingPaintExtent', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/46871. + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const CupertinoSliverRefreshControl(), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) => const SizedBox(height: 100), + ), + ], + ), + ), + ); + + // Drag the content down far enough so that + // geometry.paintExtent > constraints.maxPaintExtent + await tester.dragFrom(const Offset(10, 10), const Offset(0, 500)); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} + +class FakeBuilder { + Completer<void> refreshCompleter = Completer<void>.sync(); + final List<MockHelperInvocation> invocations = <MockHelperInvocation>[]; + + Widget refreshIndicator = Container(); + + Widget builder( + BuildContext context, + RefreshIndicatorMode refreshState, + double pulledExtent, + double refreshTriggerPullDistance, + double refreshIndicatorExtent, + ) { + if (pulledExtent < 0.0) { + throw TestFailure('The pulledExtent should never be less than 0.0'); + } + if (refreshTriggerPullDistance < 0.0) { + throw TestFailure('The refreshTriggerPullDistance should never be less than 0.0'); + } + if (refreshIndicatorExtent < 0.0) { + throw TestFailure('The refreshIndicatorExtent should never be less than 0.0'); + } + invocations.add( + BuilderInvocation( + refreshState: refreshState, + pulledExtent: pulledExtent, + refreshTriggerPullDistance: refreshTriggerPullDistance, + refreshIndicatorExtent: refreshIndicatorExtent, + ), + ); + return refreshIndicator; + } + + Future<void> refreshTask() { + invocations.add(const RefreshTaskInvocation()); + return refreshCompleter.future; + } +} + +abstract class MockHelperInvocation { + const MockHelperInvocation(); +} + +@immutable +class RefreshTaskInvocation extends MockHelperInvocation { + const RefreshTaskInvocation(); +} + +@immutable +class BuilderInvocation extends MockHelperInvocation { + const BuilderInvocation({ + required this.refreshState, + required this.pulledExtent, + required this.refreshIndicatorExtent, + required this.refreshTriggerPullDistance, + }); + + final RefreshIndicatorMode refreshState; + final double pulledExtent; + final double refreshTriggerPullDistance; + final double refreshIndicatorExtent; + + @override + String toString() => + '{refreshState: $refreshState, pulledExtent: $pulledExtent, refreshTriggerPullDistance: $refreshTriggerPullDistance, refreshIndicatorExtent: $refreshIndicatorExtent}'; +} + +Matcher matchesBuilder({ + required RefreshIndicatorMode refreshState, + required dynamic pulledExtent, + required dynamic refreshTriggerPullDistance, + required dynamic refreshIndicatorExtent, +}) { + return isA<BuilderInvocation>() + .having( + (BuilderInvocation invocation) => invocation.refreshState, + 'refreshState', + refreshState, + ) + .having( + (BuilderInvocation invocation) => invocation.pulledExtent, + 'pulledExtent', + pulledExtent, + ) + .having( + (BuilderInvocation invocation) => invocation.refreshTriggerPullDistance, + 'refreshTriggerPullDistance', + refreshTriggerPullDistance, + ) + .having( + (BuilderInvocation invocation) => invocation.refreshIndicatorExtent, + 'refreshIndicatorExtent', + refreshIndicatorExtent, + ); +} diff --git a/packages/cupertino_ui/test/cupertino/route_test.dart b/packages/cupertino_ui/test/cupertino/route_test.dart new file mode 100644 index 000000000000..a82079439bae --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/route_test.dart @@ -0,0 +1,3522 @@ +// 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. + +@TestOn('!chrome') +library; + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + late MockNavigatorObserver navigatorObserver; + + setUp(() { + navigatorObserver = MockNavigatorObserver(); + }); + + testWidgets('Middle auto-populates with title', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // There should be a Text widget with the title in the nav bar even though + // we didn't specify anything in the nav bar constructor. + expect(find.widgetWithText(CupertinoNavigationBar, 'An iPod'), findsOneWidget); + + // As a title, it should also be centered. + expect(tester.getCenter(find.text('An iPod')).dx, 400.0); + }); + + testWidgets('Large title auto-populates with title', (WidgetTester tester) async { + // Set window orientation to portrait. + tester.view.physicalSize = const Size(2400.0, 3000.0); + addTearDown(tester.view.reset); + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + child: CustomScrollView(slivers: <Widget>[CupertinoSliverNavigationBar()]), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // There should be 2 Text widget with the title in the nav bar. One in the + // large title position and one in the middle position (though the middle + // position Text is initially invisible while the sliver is expanded). + expect(find.widgetWithText(CupertinoSliverNavigationBar, 'An iPod'), findsNWidgets(2)); + + final List<Element> titles = tester.elementList(find.text('An iPod')).toList() + ..sort((Element a, Element b) { + final aParagraph = a.renderObject! as RenderParagraph; + final bParagraph = b.renderObject! as RenderParagraph; + return aParagraph.text.style!.fontSize!.compareTo(bParagraph.text.style!.fontSize!); + }); + + final Iterable<double> opacities = titles.map<double>((Element element) { + final RenderAnimatedOpacity renderOpacity = element + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>()!; + return renderOpacity.opacity.value; + }); + + expect(opacities, <double>[ + 0.0, // Initially the smaller font title is invisible. + 1.0, // The larger font title is visible. + ]); + + // Check that the large font title is at the right spot. + expect(tester.getTopLeft(find.byWidget(titles[1].widget)), const Offset(16.0, 54.0)); + + // The smaller, initially invisible title, should still be positioned in the + // center. + expect(tester.getCenter(find.byWidget(titles[0].widget)).dx, 400.0); + }); + + testWidgets('Leading auto-populates with back button with previous title', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'A Phone', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.widgetWithText(CupertinoNavigationBar, 'A Phone'), findsOneWidget); + expect(tester.getCenter(find.text('A Phone')).dx, 400.0); + + // Also shows the previous page's title next to the back button. + expect(find.widgetWithText(CupertinoButton, 'An iPod'), findsOneWidget); + // 3 paddings + 1 test font character at font size 34.0. + // The epsilon is needed since the text theme has a negative letter spacing thus. + expect( + tester.getTopLeft(find.text('An iPod')).dx, + moreOrLessEquals(8.0 + 4.0 + 34.0 + 6.0, epsilon: 0.5), + ); + }); + + testWidgets('Previous title is correct on first transition frame', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + CupertinoPageRoute<void>( + title: 'A Phone', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + // Trigger the route push + await tester.pump(); + // Draw the first frame. + await tester.pump(); + + // Also shows the previous page's title next to the back button. + expect(find.widgetWithText(CupertinoButton, 'An iPod'), findsOneWidget); + }); + + testWidgets('Previous title stays up to date with changing routes', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + final route2 = CupertinoPageRoute<void>( + title: 'An iPod', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ); + + final route3 = CupertinoPageRoute<void>( + title: 'A Phone', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ); + + tester.state<NavigatorState>(find.byType(Navigator)).push(route2); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester.state<NavigatorState>(find.byType(Navigator)).push(route3); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .replace( + oldRoute: route2, + newRoute: CupertinoPageRoute<void>( + title: 'An Internet communicator', + builder: (BuildContext context) { + return const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(), + child: Placeholder(), + ); + }, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.widgetWithText(CupertinoNavigationBar, 'A Phone'), findsOneWidget); + expect(tester.getCenter(find.text('A Phone')).dx, 400.0); + + // After swapping the route behind the top one, the previous label changes + // from An iPod to Back (since An Internet communicator is too long to + // fit in the back button). + expect(find.widgetWithText(CupertinoButton, 'Back'), findsOneWidget); + // The epsilon is needed since the text theme has a negative letter spacing thus. + expect( + tester.getTopLeft(find.text('Back')).dx, + moreOrLessEquals(8.0 + 4.0 + 34.0 + 6.0, epsilon: 0.5), + ); + }); + + testWidgets('Back swipe dismiss interrupted by route push', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/28728 + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('route'))); + }, + ), + ); + }, + child: const Text('push'), + ), + ), + ), + ), + ); + + // Check the basic iOS back-swipe dismiss transition. Dragging the pushed + // route halfway across the screen will trigger the iOS dismiss animation + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + + TestGesture gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(400, 0)); + await gesture.up(); + await tester.pump(); + expect( + // The 'route' route has been dragged to the right, halfway across the screen + tester.getTopLeft( + find.ancestor(of: find.text('route'), matching: find.byType(CupertinoPageScaffold)), + ), + const Offset(400, 0), + ); + expect( + // The 'push' route is sliding in from the left. + tester + .getTopLeft( + find.ancestor(of: find.text('push'), matching: find.byType(CupertinoPageScaffold)), + ) + .dx, + lessThan(0), + ); + await tester.pumpAndSettle(); + expect(find.text('push'), findsOneWidget); + expect( + tester.getTopLeft( + find.ancestor(of: find.text('push'), matching: find.byType(CupertinoPageScaffold)), + ), + Offset.zero, + ); + expect(find.text('route'), findsNothing); + + // Run the dismiss animation 60%, which exposes the route "push" button, + // and then press the button. + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + + gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(400, 0)); // Drag halfway. + await gesture.up(); + // Trigger the snapping animation. + // Since the back swipe drag was brought to >=50% of the screen, it will + // self snap to finish the pop transition as the gesture is lifted. + // + // This drag drop animation is 400ms when dropped exactly halfway + // (800 / [pixel distance remaining], see + // _CupertinoBackGestureController.dragEnd). It follows a curve that is very + // steep initially. + await tester.pump(); + expect( + tester.getTopLeft( + find.ancestor(of: find.text('route'), matching: find.byType(CupertinoPageScaffold)), + ), + const Offset(400, 0), + ); + // Let the dismissing snapping animation go 60%. + await tester.pump(const Duration(milliseconds: 210)); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('route'), matching: find.byType(CupertinoPageScaffold)), + ) + .dx, + moreOrLessEquals(789, epsilon: 1), + ); + + // Use the navigator to push a route instead of tapping the 'push' button. + // The topmost route (the one that's animating away), ignores input while + // the pop is underway because route.navigator.userGestureInProgress. + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('route'))); + }, + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + expect(tester.state<NavigatorState>(find.byType(Navigator)).userGestureInProgress, false); + }); + + testWidgets('Back swipe less than halfway is interrupted by route pop', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/141268 + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 2'))); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsOneWidget); + + // Start a back gesture and move it less than 50% across the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0)); + await gesture.moveBy(const Offset(100.0, 0.0)); + await tester.pump(); + expect( + // The second route has been dragged to the right. + tester.getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ), + const Offset(100.0, 0.0), + ); + expect( + // The first route is sliding in from the left. + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dx, + lessThan(0), + ); + + // Programmatically pop and observe that Page 2 was popped as if there were + // no back gesture. + Navigator.pop<void>(scaffoldKey.currentContext!); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ), + Offset.zero, + ); + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('Back swipe more than halfway is interrupted by route pop', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/141268 + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 2'))); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsOneWidget); + + // Start a back gesture and move it more than 50% across the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0)); + await gesture.moveBy(const Offset(500.0, 0.0)); + await tester.pump(); + expect( + // The second route has been dragged to the right. + tester.getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ), + const Offset(500.0, 0.0), + ); + expect( + // The first route is sliding in from the left. + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dx, + lessThan(0), + ); + + // Programmatically pop and observe that Page 2 was popped as if there were + // no back gesture. + Navigator.pop<void>(scaffoldKey.currentContext!); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ), + Offset.zero, + ); + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('Back swipe less than halfway is interrupted by route push', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/141268 + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 2'))); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsOneWidget); + + // Start a back gesture and move it less than 50% across the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0)); + await gesture.moveBy(const Offset(100.0, 0.0)); + await tester.pump(); + expect( + // The second route has been dragged to the right. + tester.getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ), + const Offset(100.0, 0.0), + ); + expect( + // The first route is sliding in from the left. + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dx, + lessThan(0), + ); + + // Programmatically push and observe that Page 3 was pushed as if there were + // no back gesture. + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 3'))); + }, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsNothing); + expect( + tester.getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ), + Offset.zero, + ); + }); + + testWidgets('Back swipe more than halfway is interrupted by route push', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/141268 + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 2'))); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsOneWidget); + + // Start a back gesture and move it more than 50% across the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0)); + await gesture.moveBy(const Offset(500.0, 0.0)); + await tester.pump(); + expect( + // The second route has been dragged to the right. + tester.getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ), + const Offset(500.0, 0.0), + ); + expect( + // The first route is sliding in from the left. + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dx, + lessThan(0), + ); + + // Programmatically push and observe that Page 3 was pushed as if there were + // no back gesture. + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 3'))); + }, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), findsNothing); + expect( + tester.getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ), + Offset.zero, + ); + }); + + testWidgets('Fullscreen route animates correct transform values over time', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('Button'), + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + fullscreenDialog: true, + builder: (BuildContext context) { + return Column( + children: <Widget>[ + const Placeholder(), + CupertinoButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop<void>(context); + }, + ), + ], + ); + }, + ), + ); + }, + ); + }, + ), + ), + ); + + // Enter animation. + await tester.tap(find.text('Button')); + await tester.pump(); + + // We use a higher number of intervals since the animation has to scale the + // entire screen. + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(475.6, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(350.0, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(237.4, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(149.2, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(89.5, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(54.4, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(33.2, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(20.4, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(12.6, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(7.4, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(3, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(0, epsilon: 0.1)); + + // Give time to the animation to finish and update its status to + // AnimationState.completed, so the reverse curved can be used in the next + // step. + await tester.pumpAndSettle(const Duration(milliseconds: 1)); + + // Exit animation + await tester.tap(find.text('Close')); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(156.3, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(308.1, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(411.03, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(484.35, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(530.67, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(557.61, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(573.88, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(583.86, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(590.26, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(594.58, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(597.66, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dy, moreOrLessEquals(600.0, epsilon: 0.1)); + }); + + Future<void> testParallax(WidgetTester tester, {required bool fromFullscreenDialog}) async { + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) => CupertinoPageRoute<void>( + fullscreenDialog: fromFullscreenDialog, + settings: settings, + builder: (BuildContext context) { + return Column( + children: <Widget>[ + const Placeholder(), + CupertinoButton( + child: const Text('Button'), + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop<void>(context); + }, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ); + + // Enter animation. + await tester.tap(find.text('Button')); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(0.0, epsilon: 0.1)); + await tester.pump(); + + // We use a higher number of intervals since the animation has to scale the + // entire screen. + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-55.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-111.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-161.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-200.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-226.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-242.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-251.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-257.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-261.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-263.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-265.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-266.0, epsilon: 1.0)); + + // Give time to the animation to finish and update its status to + // AnimationState.completed, so the reverse curved can be used in the next + // step. + await tester.pumpAndSettle(const Duration(milliseconds: 1)); + + // Exit animation + await tester.tap(find.text('Close')); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-197.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-129.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-83.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 360)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-0.0, epsilon: 1.0)); + } + + testWidgets('CupertinoPageRoute has parallax when non fullscreenDialog route is pushed on top', ( + WidgetTester tester, + ) async { + await testParallax(tester, fromFullscreenDialog: false); + }); + + testWidgets( + 'FullscreenDialog CupertinoPageRoute has parallax when non fullscreenDialog route is pushed on top', + (WidgetTester tester) async { + await testParallax(tester, fromFullscreenDialog: true); + }, + ); + + group('Interrupted push', () { + Future<void> testParallax(WidgetTester tester, {required bool fromFullscreenDialog}) async { + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) => CupertinoPageRoute<void>( + fullscreenDialog: fromFullscreenDialog, + settings: settings, + builder: (BuildContext context) { + return Column( + children: <Widget>[ + const Placeholder(), + CupertinoButton( + child: const Text('Button'), + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop<void>(context); + }, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ); + + // Enter animation. + await tester.tap(find.text('Button')); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(0.0, epsilon: 0.1)); + await tester.pump(); + + // The push animation duration is 500ms. We let it run for 400ms before + // interrupting and popping it. + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-55.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-111.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-161.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-200.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-226.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-242.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-251.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-257.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-261.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-263.0, epsilon: 1.0), + ); + + // Exit animation + await tester.tap(find.text('Close')); + await tester.pump(); + + // When the push animation is interrupted, the forward curved is used for + // the reversed animation to avoid discontinuities. + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-261.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-257.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-251.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-242.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-226.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-200.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-161.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect( + tester.getTopLeft(find.byType(Placeholder)).dx, + moreOrLessEquals(-111.0, epsilon: 1.0), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(-55.0, epsilon: 1.0)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(0.0, epsilon: 1.0)); + } + + testWidgets( + 'CupertinoPageRoute has parallax when non fullscreenDialog route is pushed on top and gets popped before the end of the animation', + (WidgetTester tester) async { + await testParallax(tester, fromFullscreenDialog: false); + }, + ); + + testWidgets( + 'FullscreenDialog CupertinoPageRoute has parallax when non fullscreenDialog route is pushed on top and gets popped before the end of the animation', + (WidgetTester tester) async { + await testParallax(tester, fromFullscreenDialog: true); + }, + ); + }); + + Future<void> testNoParallax(WidgetTester tester, {required bool fromFullscreenDialog}) async { + await tester.pumpWidget( + CupertinoApp( + onGenerateRoute: (RouteSettings settings) => CupertinoPageRoute<void>( + fullscreenDialog: fromFullscreenDialog, + builder: (BuildContext context) { + return Column( + children: <Widget>[ + const Placeholder(), + CupertinoButton( + child: const Text('Button'), + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + fullscreenDialog: true, + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop<void>(context); + }, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ); + + // Enter animation. + await tester.tap(find.text('Button')); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, moreOrLessEquals(0.0, epsilon: 0.1)); + await tester.pump(); + + // We use a higher number of intervals since the animation has to scale the + // entire screen. + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + // Exit animation + await tester.tap(find.text('Close')); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 40)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + + await tester.pump(const Duration(milliseconds: 360)); + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + } + + testWidgets('CupertinoPageRoute has no parallax when fullscreenDialog route is pushed on top', ( + WidgetTester tester, + ) async { + await testNoParallax(tester, fromFullscreenDialog: false); + }); + + testWidgets( + 'FullscreenDialog CupertinoPageRoute has no parallax when fullscreenDialog route is pushed on top', + (WidgetTester tester) async { + await testNoParallax(tester, fromFullscreenDialog: true); + }, + ); + + testWidgets('Animated push/pop is not linear', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Text('1'))); + + final route2 = CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('2')); + }, + ); + + tester.state<NavigatorState>(find.byType(Navigator)).push(route2); + // The whole transition is 500ms based on CupertinoPageRoute.transitionDuration. + // Break it up into small chunks. + // + // The screen width is 800. + // The top left corner of the text 1 will go from 0 to -800 / 3 = - 266.67. + // The top left corner of the text 2 will go from 800 to 0. + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-69, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(609, epsilon: 1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-136, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(362, epsilon: 1)); + + await tester.pump(const Duration(milliseconds: 50)); + // Translation slows down as time goes on. + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-191, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(192, epsilon: 1)); + + // Finish the rest of the animation + await tester.pump(const Duration(milliseconds: 350)); + // Give time to the animation to finish and update its status to + // AnimationState.completed, so the reverse curved can be used in the next + // step. + await tester.pumpAndSettle(const Duration(milliseconds: 1)); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + // The top left corner of the text 1 will go from -800 / 3 = - 266.67 to 0. + // The top left corner of the text 2 will go from 0 to 800. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-197, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(190, epsilon: 1)); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-129, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(437, epsilon: 1)); + + await tester.pump(const Duration(milliseconds: 50)); + // Translation slows down as time goes on. + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-74, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(607, epsilon: 1)); + }); + + testWidgets('Dragged pop gesture is linear', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Text('1'))); + + final route2 = CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('2')); + }, + ); + + tester.state<NavigatorState>(find.byType(Navigator)).push(route2); + + await tester.pumpAndSettle(); + + expect(find.text('1'), findsNothing); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(0)); + + final TestGesture swipeGesture = await tester.startGesture(const Offset(5, 100)); + + await swipeGesture.moveBy(const Offset(100, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-233, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(100)); + expect(tester.state<NavigatorState>(find.byType(Navigator)).userGestureInProgress, true); + + await swipeGesture.moveBy(const Offset(100, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-200)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(200)); + + // Moving by the same distance each time produces linear movements on both + // routes. + await swipeGesture.moveBy(const Offset(100, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-166, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(300)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/137033. + testWidgets('Update pages during a drag gesture will not stuck', (WidgetTester tester) async { + await tester.pumpWidget(const _TestPageUpdate()); + + // Tap this button will update the pages in two seconds. + await tester.tap(find.text('Update Pages')); + await tester.pump(); + + // Start swiping. + final TestGesture swipeGesture = await tester.startGesture(const Offset(5, 100)); + await swipeGesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + expect( + tester.stateList<NavigatorState>(find.byType(Navigator)).last.userGestureInProgress, + true, + ); + + // Wait for pages to update. + await tester.pump(const Duration(seconds: 3)); + + // Verify pages are updated. + expect(find.text('New page'), findsOneWidget); + // Verify `userGestureInProgress` is set to false. + expect( + tester.stateList<NavigatorState>(find.byType(Navigator)).last.userGestureInProgress, + false, + ); + }); + + testWidgets('Pop gesture snapping is not linear', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Text('1'))); + + final route2 = CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('2')); + }, + ); + + tester.state<NavigatorState>(find.byType(Navigator)).push(route2); + + await tester.pumpAndSettle(); + + final TestGesture swipeGesture = await tester.startGesture(const Offset(5, 100)); + + await swipeGesture.moveBy(const Offset(500, 0)); + await swipeGesture.up(); + await tester.pump(); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-100)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(500)); + expect(tester.state<NavigatorState>(find.byType(Navigator)).userGestureInProgress, true); + + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-61, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(614, epsilon: 1)); + + await tester.pump(const Duration(milliseconds: 50)); + // Rate of change is slowing down. + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(-26, epsilon: 1)); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(721, epsilon: 1)); + + await tester.pumpAndSettle(); + expect(tester.state<NavigatorState>(find.byType(Navigator)).userGestureInProgress, false); + }); + + testWidgets('Snapped drags forwards and backwards should signal didStart/StopUserGesture', ( + WidgetTester tester, + ) async { + final GlobalKey<NavigatorState> navigatorKey = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[navigatorObserver], + navigatorKey: navigatorKey, + home: const Text('1'), + ), + ); + + final route2 = CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('2')); + }, + ); + + navigatorKey.currentState!.push(route2); + await tester.pumpAndSettle(); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didPush); + + await tester.dragFrom(const Offset(5, 100), const Offset(100, 0)); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didStartUserGesture); + await tester.pump(); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(100)); + expect(navigatorKey.currentState!.userGestureInProgress, true); + + // Didn't drag far enough to snap into dismissing this route. + // Each 100px distance takes 100ms to snap back. + await tester.pump(const Duration(milliseconds: 351)); + // Back to the page covering the whole screen. + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(0)); + expect(navigatorKey.currentState!.userGestureInProgress, false); + + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didStopUserGesture); + expect(navigatorObserver.invocations.removeLast(), isNot(NavigatorInvocation.didPop)); + + await tester.dragFrom(const Offset(5, 100), const Offset(500, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.text('2')).dx, moreOrLessEquals(500)); + expect(navigatorKey.currentState!.userGestureInProgress, true); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didPop); + + // Did go far enough to snap out of this route. + await tester.pump(const Duration(milliseconds: 351)); + // Back to the page covering the whole screen. + expect(find.text('2'), findsNothing); + // First route covers the whole screen. + expect(tester.getTopLeft(find.text('1')).dx, moreOrLessEquals(0)); + expect(navigatorKey.currentState!.userGestureInProgress, false); + }); + + /// Regression test for https://github.com/flutter/flutter/issues/29596. + testWidgets('test edge swipe then drop back at ending point works', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[navigatorObserver], + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); + // The width of the page. + await gesture.moveBy(const Offset(800, 0)); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didStartUserGesture); + await gesture.up(); + await tester.pump(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didStopUserGesture); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didPop); + }); + + testWidgets('test edge swipe then drop back at starting point works', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[navigatorObserver], + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); + // Move right a bit + await gesture.moveBy(const Offset(300, 0)); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didStartUserGesture); + expect(tester.state<NavigatorState>(find.byType(Navigator)).userGestureInProgress, true); + await tester.pump(); + + // Move back to where we started. + await gesture.moveBy(const Offset(-300, 0)); + await gesture.up(); + await tester.pump(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + expect(navigatorObserver.invocations.removeLast(), NavigatorInvocation.didStopUserGesture); + expect(navigatorObserver.invocations.removeLast(), isNot(NavigatorInvocation.didPop)); + expect(tester.state<NavigatorState>(find.byType(Navigator)).userGestureInProgress, false); + }); + + group('Cupertino page transitions', () { + CupertinoPageRoute<void> buildRoute({required bool fullscreenDialog}) { + return CupertinoPageRoute<void>( + fullscreenDialog: fullscreenDialog, + builder: (_) => const SizedBox(), + ); + } + + testWidgets('when route is not fullscreenDialog, it has a barrierColor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const CupertinoApp(home: SizedBox.expand())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push(buildRoute(fullscreenDialog: false)); + await tester.pumpAndSettle(); + + expect( + tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, + const Color(0x18000000), + ); + }); + + testWidgets('when route is a fullscreenDialog, it has no barrierColor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const CupertinoApp(home: SizedBox.expand())); + + tester.state<NavigatorState>(find.byType(Navigator)).push(buildRoute(fullscreenDialog: true)); + await tester.pumpAndSettle(); + + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, isNull); + }); + + testWidgets('when route is not fullscreenDialog, it has a _CupertinoEdgeShadowDecoration', ( + WidgetTester tester, + ) async { + PaintPattern paintsShadowRect({required double dx, required Color color}) { + return paints..everything((Symbol methodName, List<dynamic> arguments) { + if (methodName != #drawRect) { + return true; + } + final rect = arguments[0] as Rect; + final Color paintColor = (arguments[1] as Paint).color; + // _CupertinoEdgeShadowDecoration draws the shadows with a series of + // differently colored 1px-wide rects. Skip rects that aren't being + // drawn by the _CupertinoEdgeShadowDecoration. + if (rect.top != 0 || rect.width != 1.0 || rect.height != 600) { + return true; + } + // Skip calls for rects until the one with the given position offset + if ((rect.left - dx).abs() >= 1) { + return true; + } + if (paintColor.value == color.value) { + return true; + } + throw ''' + For a rect with an expected left-side position: $dx (drawn at ${rect.left}): + Expected a rect with color: $color, + And drew a rect with color: $paintColor. + '''; + }); + } + + await tester.pumpWidget(const CupertinoApp(home: SizedBox.expand())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push(buildRoute(fullscreenDialog: false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + final RenderBox box = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + + // Animation starts with effectively no shadow + expect(box, paintsShadowRect(dx: 795, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 785, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 775, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 765, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 755, color: CupertinoColors.transparent)); + + await tester.pump(const Duration(milliseconds: 100)); + + // Part-way through the transition, the shadow is approaching the full gradient + expect(box, paintsShadowRect(dx: 296, color: const Color(0x03000000))); + expect(box, paintsShadowRect(dx: 286, color: const Color(0x02000000))); + expect(box, paintsShadowRect(dx: 276, color: const Color(0x01000000))); + expect(box, paintsShadowRect(dx: 266, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 266, color: CupertinoColors.transparent)); + + await tester.pumpAndSettle(); + + // At the end of the transition, the shadow is a gradient between + // 0x04000000 and 0x00000000 and is now offscreen + expect(box, paintsShadowRect(dx: -1, color: const Color(0x04000000))); + expect(box, paintsShadowRect(dx: -10, color: const Color(0x03000000))); + expect(box, paintsShadowRect(dx: -20, color: const Color(0x02000000))); + expect(box, paintsShadowRect(dx: -30, color: const Color(0x01000000))); + expect(box, paintsShadowRect(dx: -40, color: CupertinoColors.transparent)); + + // Start animation in reverse + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(box, paintsShadowRect(dx: 498, color: const Color(0x04000000))); + expect(box, paintsShadowRect(dx: 488, color: const Color(0x03000000))); + expect(box, paintsShadowRect(dx: 478, color: const Color(0x02000000))); + expect(box, paintsShadowRect(dx: 468, color: const Color(0x01000000))); + expect(box, paintsShadowRect(dx: 458, color: CupertinoColors.transparent)); + + await tester.pump(const Duration(milliseconds: 150)); + + // At the end of the animation, the shadow approaches full transparency + expect(box, paintsShadowRect(dx: 794, color: const Color(0x01000000))); + expect(box, paintsShadowRect(dx: 784, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 774, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 764, color: CupertinoColors.transparent)); + expect(box, paintsShadowRect(dx: 754, color: CupertinoColors.transparent)); + }); + + testWidgets( + 'when route is fullscreenDialog, it has no visible _CupertinoEdgeShadowDecoration', + (WidgetTester tester) async { + PaintPattern paintsNoShadows() { + return paints..everything((Symbol methodName, List<dynamic> arguments) { + if (methodName != #drawRect) { + return true; + } + final rect = arguments[0] as Rect; + + // _CupertinoEdgeShadowDecoration draws the shadows with a series of + // differently colored 1px rects. Skip all rects not drawn by a + // _CupertinoEdgeShadowDecoration. + if (rect.width == 1.0) { + final bool isOnScreen = rect.left >= 0 && rect.right <= 600.0; + + if (isOnScreen) { + throw ''' + Expected: no visible rects on-screen. + Found: $rect. + '''; + } + } + return true; + }); + } + + await tester.pumpWidget(const CupertinoApp(home: SizedBox.expand())); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push(buildRoute(fullscreenDialog: true)); + await tester.pump(); + + final RenderBox box = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + + await tester.pumpAndSettle(); + expect(box, paintsNoShadows()); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + + await tester.pumpAndSettle(); + expect(box, paintsNoShadows()); + }, + ); + }); + + testWidgets('ModalPopup overlay dark mode', (WidgetTester tester) async { + late StateSetter stateSetter; + Brightness brightness = Brightness.light; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + stateSetter = setter; + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color!.value, 0x33000000); + + stateSetter(() { + brightness = Brightness.dark; + }); + await tester.pump(); + + // TODO(LongCatIsLooong): The background overlay SHOULD switch to dark color. + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color!.value, 0x33000000); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color!.value, 0x7A000000); + }); + + testWidgets('During back swipe the route ignores input', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/39989 + + final GlobalKey homeScaffoldKey = GlobalKey(); + final GlobalKey pageScaffoldKey = GlobalKey(); + var homeTapCount = 0; + var pageTapCount = 0; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: homeScaffoldKey, + child: GestureDetector( + onTap: () { + homeTapCount += 1; + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(homeScaffoldKey)); + expect(homeTapCount, 1); + expect(pageTapCount, 0); + + Navigator.push<void>( + homeScaffoldKey.currentContext!, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + key: pageScaffoldKey, + child: Padding( + padding: const EdgeInsets.all(16), + child: GestureDetector( + onTap: () { + pageTapCount += 1; + }, + ), + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.byKey(pageScaffoldKey)); + expect(homeTapCount, 1); + expect(pageTapCount, 1); + + // Start the basic iOS back-swipe dismiss transition. Drag the pushed + // "page" route halfway across the screen. The underlying "home" will + // start sliding in from the left. + + final TestGesture gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(400, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(400, 0)); + expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); + + // Tapping on the "page" route doesn't trigger the GestureDetector because + // it's being dragged. + await tester.tap(find.byKey(pageScaffoldKey), warnIfMissed: false); + expect(homeTapCount, 1); + expect(pageTapCount, 1); + }); + + testWidgets('showCupertinoModalPopup uses root navigator by default', ( + WidgetTester tester, + ) async { + final rootObserver = PopupObserver(); + final nestedObserver = PopupObserver(); + + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('tap')); + + expect(rootObserver.popupCount, 1); + expect(nestedObserver.popupCount, 0); + }); + + testWidgets('back swipe to screen edges does not dismiss the hero animation', ( + WidgetTester tester, + ) async { + final navigator = GlobalKey<NavigatorState>(); + final container = UniqueKey(); + await tester.pumpWidget( + CupertinoApp( + navigatorKey: navigator, + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Hero( + tag: 'tag', + transitionOnUserGestures: true, + child: SizedBox(key: container, height: 150.0, width: 150.0), + ), + ), + ); + }, + '/page2': (BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(100.0, 0.0, 0.0, 0.0), + child: Hero( + tag: 'tag', + transitionOnUserGestures: true, + child: SizedBox(key: container, height: 150.0, width: 150.0), + ), + ), + ), + ); + }, + }, + ), + ); + + var box = tester.renderObject(find.byKey(container)) as RenderBox; + final double initialPosition = box.localToGlobal(Offset.zero).dx; + + navigator.currentState!.pushNamed('/page2'); + await tester.pumpAndSettle(); + box = tester.renderObject(find.byKey(container)) as RenderBox; + final double finalPosition = box.localToGlobal(Offset.zero).dx; + + final TestGesture gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + box = tester.renderObject(find.byKey(container)) as RenderBox; + final double firstPosition = box.localToGlobal(Offset.zero).dx; + // Checks the hero is in-transit. + expect(finalPosition, greaterThan(firstPosition)); + expect(firstPosition, greaterThan(initialPosition)); + + // Goes back to final position. + await gesture.moveBy(const Offset(-200, 0)); + await tester.pump(); + box = tester.renderObject(find.byKey(container)) as RenderBox; + final double secondPosition = box.localToGlobal(Offset.zero).dx; + // There will be a small difference. + expect(finalPosition - secondPosition, lessThan(0.001)); + + await gesture.moveBy(const Offset(400, 0)); + await tester.pump(); + box = tester.renderObject(find.byKey(container)) as RenderBox; + final double thirdPosition = box.localToGlobal(Offset.zero).dx; + // Checks the hero is still in-transit and moves further away from the first + // position. + expect(finalPosition, greaterThan(thirdPosition)); + expect(thirdPosition, greaterThan(initialPosition)); + expect(firstPosition, greaterThan(thirdPosition)); + }); + + testWidgets('showCupertinoModalPopup uses nested navigator if useRootNavigator is false', ( + WidgetTester tester, + ) async { + final rootObserver = PopupObserver(); + final nestedObserver = PopupObserver(); + + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + useRootNavigator: false, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('tap')); + + expect(rootObserver.popupCount, 0); + expect(nestedObserver.popupCount, 1); + }); + + testWidgets('showCupertinoDialog uses root navigator by default', (WidgetTester tester) async { + final rootObserver = DialogObserver(); + final nestedObserver = DialogObserver(); + + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('tap')); + + expect(rootObserver.dialogCount, 1); + expect(nestedObserver.dialogCount, 0); + }); + + testWidgets('showCupertinoDialog uses nested navigator if useRootNavigator is false', ( + WidgetTester tester, + ) async { + final rootObserver = DialogObserver(); + final nestedObserver = DialogObserver(); + + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoDialog<void>( + context: context, + useRootNavigator: false, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('tap')); + + expect(rootObserver.dialogCount, 0); + expect(nestedObserver.dialogCount, 1); + }); + + testWidgets('showCupertinoModalPopup does not allow for semantics dismiss by default', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + CupertinoApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Push the route. + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect( + semantics, + isNot( + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Dismiss', + ), + ), + ); + debugDefaultTargetPlatformOverride = null; + semantics.dispose(); + }); + + testWidgets('showCupertinoModalPopup allows for semantics dismiss when set', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + CupertinoApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + semanticsDismissible: true, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Push the route. + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss', + ), + ); + debugDefaultTargetPlatformOverride = null; + semantics.dispose(); + }); + + testWidgets('showCupertinoModalPopup passes RouteSettings to PopupRoute', ( + WidgetTester tester, + ) async { + final routeSettingsObserver = RouteSettingsObserver(); + + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[routeSettingsObserver], + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + routeSettings: const RouteSettings(name: '/modal'), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('tap')); + + expect(routeSettingsObserver.routeName, '/modal'); + }); + + testWidgets('showCupertinoModalPopup transparent barrier color is transparent', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + barrierColor: CupertinoColors.transparent, + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, null); + }); + + testWidgets('showCupertinoModalPopup null barrier color must be default gray barrier color', ( + WidgetTester tester, + ) async { + // Barrier color for a Cupertino modal barrier. + // Extracted from https://developer.apple.com/design/resources/. + const Color kModalBarrierColor = CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x7A000000), + ); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, kModalBarrierColor); + }); + + testWidgets('showCupertinoModalPopup custom barrier color', (WidgetTester tester) async { + const customColor = Color(0x11223344); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const SizedBox(), + barrierColor: customColor, + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, customColor); + }); + + testWidgets('showCupertinoModalPopup barrier dismissible', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const Text('Visible'), + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + await tester.tapAt( + tester.getTopLeft( + find.ancestor(of: find.text('tap'), matching: find.byType(CupertinoPageScaffold)), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Visible'), findsNothing); + }); + + testWidgets('showCupertinoModalPopup barrier not dismissible', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) => const Text('Visible'), + barrierDismissible: false, + ); + }, + child: const Text('tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + await tester.tapAt( + tester.getTopLeft( + find.ancestor(of: find.text('tap'), matching: find.byType(CupertinoPageScaffold)), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Visible'), findsOneWidget); + }); + + testWidgets('CupertinoPage works', (WidgetTester tester) async { + final LocalKey pageKey = UniqueKey(); + final detector = TransitionDetector(); + var myPages = <Page<void>>[ + CupertinoPage<void>( + key: pageKey, + title: 'title one', + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(key: UniqueKey()), + child: const Text('first'), + ), + ), + ]; + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test shouldn't call this. + return true; + }, + transitionDelegate: detector, + ), + ); + + expect(detector.hasTransition, isFalse); + expect(find.widgetWithText(CupertinoNavigationBar, 'title one'), findsOneWidget); + expect(find.text('first'), findsOneWidget); + + myPages = <Page<void>>[ + CupertinoPage<void>( + key: pageKey, + title: 'title two', + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(key: UniqueKey()), + child: const Text('second'), + ), + ), + ]; + + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test shouldn't call this. + return true; + }, + transitionDelegate: detector, + ), + ); + + // There should be no transition because the page has the same key. + expect(detector.hasTransition, isFalse); + // The content does update. + expect(find.text('first'), findsNothing); + expect(find.widgetWithText(CupertinoNavigationBar, 'title one'), findsNothing); + expect(find.text('second'), findsOneWidget); + expect(find.widgetWithText(CupertinoNavigationBar, 'title two'), findsOneWidget); + }); + + testWidgets('CupertinoPage can toggle MaintainState', (WidgetTester tester) async { + final LocalKey pageKeyOne = UniqueKey(); + final LocalKey pageKeyTwo = UniqueKey(); + final detector = TransitionDetector(); + var myPages = <Page<void>>[ + CupertinoPage<void>(key: pageKeyOne, maintainState: false, child: const Text('first')), + CupertinoPage<void>(key: pageKeyTwo, child: const Text('second')), + ]; + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test shouldn't call this. + return true; + }, + transitionDelegate: detector, + ), + ); + + expect(detector.hasTransition, isFalse); + // Page one does not maintain state. + expect(find.text('first', skipOffstage: false), findsNothing); + expect(find.text('second'), findsOneWidget); + + myPages = <Page<void>>[ + CupertinoPage<void>(key: pageKeyOne, child: const Text('first')), + CupertinoPage<void>(key: pageKeyTwo, child: const Text('second')), + ]; + + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test shouldn't call this. + return true; + }, + transitionDelegate: detector, + ), + ); + // There should be no transition because the page has the same key. + expect(detector.hasTransition, isFalse); + // Page one sets the maintain state to be true, its widget tree should be + // built. + expect(find.text('first', skipOffstage: false), findsOneWidget); + expect(find.text('second'), findsOneWidget); + }); + + testWidgets('Popping routes should cancel down events', (WidgetTester tester) async { + await tester.pumpWidget(const _TestPostRouteCancel()); + + final TestGesture gesture = await tester.createGesture(); + await gesture.down(tester.getCenter(find.text('PointerCancelEvents: 0'))); + await gesture.up(); + + await tester.pumpAndSettle(); + expect(find.byType(CupertinoButton), findsNothing); + expect(find.text('Hold'), findsOneWidget); + + await gesture.down(tester.getCenter(find.text('Hold'))); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.text('Hold'), findsNothing); + expect(find.byType(CupertinoButton), findsOneWidget); + expect(find.text('PointerCancelEvents: 1'), findsOneWidget); + }); + + testWidgets('Popping routes during back swipe should not crash', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/63984#issuecomment-675679939 + + final r = CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('child'))); + }, + ); + + late NavigatorState navigator; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Builder( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('Home'), + onPressed: () { + navigator = Navigator.of(context); + navigator.push<void>(r); + }, + ); + }, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(); + await gesture.down(tester.getCenter(find.byType(CupertinoButton))); + await gesture.up(); + + await tester.pumpAndSettle(); + + await gesture.down(const Offset(3, 300)); + + // Need 2 events to form a valid drag + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(const Offset(30, 300), timeStamp: const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(const Offset(50, 300), timeStamp: const Duration(milliseconds: 200)); + + // Pause a while so that the route is popped when the drag is canceled + await tester.pump(const Duration(milliseconds: 1000)); + await gesture.moveTo(const Offset(51, 300), timeStamp: const Duration(milliseconds: 1200)); + + // Remove the drag + navigator.removeRoute(r); + await tester.pump(); + }); + + testWidgets('CupertinoModalPopupRoute is state restorable', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(restorationScopeId: 'app', home: _RestorableModalTestWidget()), + ); + + expect(find.byType(CupertinoActionSheet), findsNothing); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoActionSheet), findsOneWidget); + final TestRestorationData restorationData = await tester.getRestorationData(); + + await tester.restartAndRestore(); + + expect(find.byType(CupertinoActionSheet), findsOneWidget); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoActionSheet), findsNothing); + + await tester.restoreFrom(restorationData); + expect(find.byType(CupertinoActionSheet), findsOneWidget); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + group('showCupertinoDialog avoids overlapping display features', () { + testWidgets('positioning with anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning with Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // Since this is RTL, it should place the dialog on the right screen + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning by default', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showCupertinoDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(Placeholder)), Offset.zero); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(390.0, 600.0)); + }); + }); + + group('showCupertinoModalPopup avoids overlapping display features', () { + testWidgets('positioning using anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 410); + expect(tester.getBottomRight(find.byType(Placeholder)).dx, 800); + }); + + testWidgets('positioning using Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // This is RTL, so it should place the dialog on the right screen + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 410); + expect(tester.getBottomRight(find.byType(Placeholder)).dx, 800); + }); + + testWidgets('default positioning', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showCupertinoModalPopup<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + expect(tester.getBottomRight(find.byType(Placeholder)).dx, 390.0); + }); + }); + + testWidgets('Fullscreen route does not leak CurveAnimation', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('Button'), + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + fullscreenDialog: true, + builder: (BuildContext context) { + return Column( + children: <Widget>[ + const Placeholder(), + CupertinoButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop<void>(context); + }, + ), + ], + ); + }, + ), + ); + }, + ); + }, + ), + ), + ); + + // Enter animation. + await tester.tap(find.text('Button')); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 400)); + + // Exit animation + await tester.tap(find.text('Close')); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 400)); + }); + + testWidgets('CupertinoModalPopupRoute does not leak CurveAnimation', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoModalPopup<void>( + context: context, + semanticsDismissible: true, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Push the route. + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + }); + + testWidgets('CupertinoDialogRoute does not leak CurveAnimation', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, Animation<double> _, Animation<double> _) { + return GestureDetector( + onTap: () async { + await showCupertinoDialog<void>( + context: context, + useRootNavigator: false, + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('tap')); + await tester.pumpAndSettle(); + }); + + testWidgets('fullscreen routes do not transition previous route', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + initialRoute: '/', + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/') { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Page 1')), + child: Container(), + ); + }, + ); + } + return CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Page 2')), + child: Container(), + ); + }, + fullscreenDialog: true, + ); + }, + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + final double pageTitleDX = tester.getTopLeft(find.text('Page 1')).dx; + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Second page transition has started. + expect(find.text('Page 2'), findsOneWidget); + + // First page has not moved. + expect(tester.getTopLeft(find.text('Page 1')).dx, equals(pageTitleDX)); + }); + + testWidgets( + 'Setting CupertinoDialogRoute.requestFocus to false does not request focus on the dialog', + (WidgetTester tester) async { + late BuildContext savedContext; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const dialogText = 'Dialog Text'; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return CupertinoTextField(focusNode: focusNode); + }, + ), + ), + ); + await tester.pump(); + + FocusNode? getCupertinoTextFieldFocusNode() { + return tester + .widget<Focus>( + find.descendant(of: find.byType(CupertinoTextField), matching: find.byType(Focus)), + ) + .focusNode; + } + + // Initially, there is no dialog and the text field has no focus. + expect(find.text(dialogText), findsNothing); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, false); + + // Request focus on the text field. + focusNode.requestFocus(); + await tester.pump(); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, true); + + // Bring up dialog. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + CupertinoDialogRoute<void>( + context: savedContext, + builder: (BuildContext context) => const Text(dialogText), + ), + ); + await tester.pump(); + + // The dialog is showing and the text field has lost focus. + expect(find.text(dialogText), findsOneWidget); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, false); + + // Dismiss the dialog. + navigator.pop(); + await tester.pump(); + + // The dialog is dismissed and the focus is shifted back to the text field. + expect(find.text(dialogText), findsNothing); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, true); + + // Bring up dialog again with requestFocus to false. + navigator.push( + CupertinoDialogRoute<void>( + context: savedContext, + requestFocus: false, + builder: (BuildContext context) => const Text(dialogText), + ), + ); + await tester.pump(); + + // The dialog is showing and the text field still has focus. + expect(find.text(dialogText), findsOneWidget); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, true); + }, + ); + + testWidgets( + 'Setting CupertinoModalPopupRoute.requestFocus to false does not request focus on the popup', + (WidgetTester tester) async { + late BuildContext savedContext; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const dialogText = 'Popup Text'; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return CupertinoTextField(focusNode: focusNode); + }, + ), + ), + ); + await tester.pump(); + + FocusNode? getCupertinoTextFieldFocusNode() { + return tester + .widget<Focus>( + find.descendant(of: find.byType(CupertinoTextField), matching: find.byType(Focus)), + ) + .focusNode; + } + + // Initially, there is no popup and the text field has no focus. + expect(find.text(dialogText), findsNothing); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, false); + + // Request focus on the text field. + focusNode.requestFocus(); + await tester.pump(); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, true); + + // Bring up popup. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + CupertinoModalPopupRoute<void>(builder: (BuildContext context) => const Text(dialogText)), + ); + await tester.pump(); + + // The popup is showing and the text field has lost focus. + expect(find.text(dialogText), findsOneWidget); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, false); + + // Dismiss the popup. + navigator.pop(); + await tester.pump(); + + // The popup is dismissed and the focus is shifted back to the text field. + expect(find.text(dialogText), findsNothing); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, true); + + // Bring up popup again with requestFocus to false. + navigator.push( + CupertinoModalPopupRoute<void>( + requestFocus: false, + builder: (BuildContext context) => const Text(dialogText), + ), + ); + await tester.pump(); + + // The popup is showing and the text field still has focus. + expect(find.text(dialogText), findsOneWidget); + expect(getCupertinoTextFieldFocusNode()?.hasFocus, true); + }, + ); + + testWidgets( + 'Setting CupertinoPageRoute.requestFocus to false does not request focus on the page', + (WidgetTester tester) async { + late BuildContext savedContext; + const pageTwoText = 'Page Two'; + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + await tester.pump(); + + // Check page two is not on the screen. + expect(find.text(pageTwoText), findsNothing); + + // Navigate to page two with text. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const Text(pageTwoText); + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance route transition animation. + + // The page two is showing and the text widget has focus. + Element textOnPageTwo = tester.element(find.text(pageTwoText)); + FocusScopeNode focusScopeNode = FocusScope.of(textOnPageTwo); + expect(focusScopeNode.hasFocus, isTrue); + + // Navigate back to page one. + navigator.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance route transition animation. + + // Navigate to page two again with requestFocus set to false. + navigator.push( + CupertinoPageRoute<void>( + requestFocus: false, + builder: (BuildContext context) { + return const Text(pageTwoText); + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance route transition animation. + + // The page two is showing and the text widget is not focused. + textOnPageTwo = tester.element(find.text(pageTwoText)); + focusScopeNode = FocusScope.of(textOnPageTwo); + expect(focusScopeNode.hasFocus, isFalse); + }, + ); + + testWidgets('requestFocus works correctly in showCupertinoModalPopup.', ( + WidgetTester tester, + ) async { + final navigatorKey = GlobalKey<NavigatorState>(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + navigatorKey: navigatorKey, + home: CupertinoTextField(focusNode: focusNode), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + + showCupertinoModalPopup<void>( + context: navigatorKey.currentContext!, + requestFocus: true, + builder: (BuildContext context) => const Text('popup'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('popup'))).hasFocus, true); + expect(focusNode.hasFocus, false); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + + showCupertinoModalPopup<void>( + context: navigatorKey.currentContext!, + requestFocus: false, + builder: (BuildContext context) => const Text('popup'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('popup'))).hasFocus, false); + expect(focusNode.hasFocus, true); + }); + + testWidgets('requestFocus works correctly in showCupertinoDialog.', (WidgetTester tester) async { + final navigatorKey = GlobalKey<NavigatorState>(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + navigatorKey: navigatorKey, + home: CupertinoTextField(focusNode: focusNode), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + + showCupertinoDialog<void>( + context: navigatorKey.currentContext!, + requestFocus: true, + builder: (BuildContext context) => const Text('dialog'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('dialog'))).hasFocus, true); + expect(focusNode.hasFocus, false); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + + showCupertinoDialog<void>( + context: navigatorKey.currentContext!, + requestFocus: false, + builder: (BuildContext context) => const Text('dialog'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('dialog'))).hasFocus, false); + expect(focusNode.hasFocus, true); + }); + + group('CupertinoPageTransitionsBuilder', () { + testWidgets('builds a CupertinoPageTransition', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => CupertinoPageScaffold( + child: CupertinoButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const CupertinoPageScaffold(child: Text('page b')), + }; + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + routes: routes, + ), + ); + + expect(find.byType(CupertinoPageTransition), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + }); + + testWidgets('has correct transitionDuration of 500ms', (WidgetTester tester) async { + const builder = CupertinoPageTransitionsBuilder(); + + // CupertinoRouteTransitionMixin.kTransitionDuration is 500ms + expect(builder.transitionDuration, const Duration(milliseconds: 500)); + }); + + testWidgets('has delegatedTransition', (WidgetTester tester) async { + const builder = CupertinoPageTransitionsBuilder(); + + expect(builder.delegatedTransition, isNotNull); + expect(builder.delegatedTransition, CupertinoPageTransition.delegatedTransition); + }); + }); +} + +class MockNavigatorObserver extends NavigatorObserver { + final List<NavigatorInvocation> invocations = <NavigatorInvocation>[]; + + @override + void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) { + invocations.add(NavigatorInvocation.didStartUserGesture); + } + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) { + invocations.add(NavigatorInvocation.didPop); + } + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + invocations.add(NavigatorInvocation.didPush); + } + + @override + void didStopUserGesture() { + invocations.add(NavigatorInvocation.didStopUserGesture); + } +} + +enum NavigatorInvocation { didStartUserGesture, didPop, didPush, didStopUserGesture } + +class PopupObserver extends NavigatorObserver { + int popupCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is CupertinoModalPopupRoute) { + popupCount++; + } + super.didPush(route, previousRoute); + } +} + +class DialogObserver extends NavigatorObserver { + int dialogCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is CupertinoDialogRoute) { + dialogCount++; + } + super.didPush(route, previousRoute); + } +} + +class RouteSettingsObserver extends NavigatorObserver { + String? routeName; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is CupertinoModalPopupRoute) { + routeName = route.settings.name; + } + super.didPush(route, previousRoute); + } +} + +class TransitionDetector extends DefaultTransitionDelegate<void> { + bool hasTransition = false; + @override + Iterable<RouteTransitionRecord> resolve({ + required List<RouteTransitionRecord> newPageRouteHistory, + required Map<RouteTransitionRecord?, RouteTransitionRecord> locationToExitingPageRoute, + required Map<RouteTransitionRecord?, List<RouteTransitionRecord>> pageRouteToPagelessRoutes, + }) { + hasTransition = true; + return super.resolve( + newPageRouteHistory: newPageRouteHistory, + locationToExitingPageRoute: locationToExitingPageRoute, + pageRouteToPagelessRoutes: pageRouteToPagelessRoutes, + ); + } +} + +Widget buildNavigator({ + required List<Page<dynamic>> pages, + required FlutterView view, + PopPageCallback? onPopPage, + GlobalKey<NavigatorState>? key, + TransitionDelegate<dynamic>? transitionDelegate, +}) { + return MediaQuery( + data: MediaQueryData.fromView(view), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultCupertinoLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + key: key, + pages: pages, + onPopPage: onPopPage, + transitionDelegate: transitionDelegate ?? const DefaultTransitionDelegate<dynamic>(), + ), + ), + ), + ); +} + +// A test target to updating pages in navigator. +// +// It contains 3 routes: +// +// * The initial route, 'home'. +// * The 'old' route, displays a button showing 'Update pages'. Tap the button +// will update pages. +// * The 'new' route, displays the new page. +class _TestPageUpdate extends StatefulWidget { + const _TestPageUpdate(); + + @override + State<StatefulWidget> createState() => _TestPageUpdateState(); +} + +class _TestPageUpdateState extends State<_TestPageUpdate> { + bool updatePages = false; + + @override + Widget build(BuildContext context) { + final GlobalKey<State<StatefulWidget>> navKey = GlobalKey(); + return CupertinoApp( + home: Navigator( + key: navKey, + pages: updatePages + ? <Page<dynamic>>[ + const CupertinoPage<dynamic>(name: '/home', child: Text('home')), + const CupertinoPage<dynamic>(name: '/home/new', child: Text('New page')), + ] + : <Page<dynamic>>[ + const CupertinoPage<dynamic>(name: '/home', child: Text('home')), + CupertinoPage<dynamic>(name: '/home/old', child: buildMainPage()), + ], + onPopPage: (_, _) { + return false; + }, + ), + ); + } + + Widget buildMainPage() { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + const Text('Main'), + CupertinoButton( + onPressed: () { + Future<void>.delayed(const Duration(seconds: 2), () { + setState(() { + updatePages = true; + }); + }); + }, + child: const Text('Update Pages'), + ), + ], + ), + ), + ); + } +} + +// A test target for post-route cancel events. +// +// It contains 2 routes: +// +// * The initial route, 'home', displays a button showing 'PointerCancelEvents: #', +// where # is the number of cancel events received. Tapping the button pushes +// route 'sub'. +// * The 'sub' route, displays a text showing 'Hold'. Holding the button (a down +// event) will pop this route after 1 second. +// +// Holding the 'Hold' button at the moment of popping will force the navigator to +// cancel the down event, increasing the Home counter by 1. +class _TestPostRouteCancel extends StatefulWidget { + const _TestPostRouteCancel(); + + @override + State<StatefulWidget> createState() => _TestPostRouteCancelState(); +} + +class _TestPostRouteCancelState extends State<_TestPostRouteCancel> { + int counter = 0; + + Widget _buildHome(BuildContext context) { + return Center( + child: CupertinoButton( + child: Text('PointerCancelEvents: $counter'), + onPressed: () => Navigator.pushNamed<void>(context, 'sub'), + ), + ); + } + + Widget _buildSub(BuildContext context) { + return Listener( + onPointerDown: (_) { + Future<void>.delayed(const Duration(seconds: 1)).then((_) { + Navigator.pop(context); + }); + }, + onPointerCancel: (_) { + setState(() { + counter += 1; + }); + }, + child: const Center( + child: Text('Hold', style: TextStyle(color: CupertinoColors.activeBlue)), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoApp( + initialRoute: 'home', + onGenerateRoute: (RouteSettings settings) => CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) => switch (settings.name) { + 'home' => _buildHome(context), + 'sub' => _buildSub(context), + _ => throw UnimplementedError(), + }, + ), + ); + } +} + +@pragma('vm:entry-point') +class _RestorableModalTestWidget extends StatelessWidget { + const _RestorableModalTestWidget(); + + @pragma('vm:entry-point') + static Route<void> _modalBuilder(BuildContext context, Object? arguments) { + return CupertinoModalPopupRoute<void>( + builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('Title'), + message: const Text('Message'), + actions: <Widget>[ + CupertinoActionSheetAction( + child: const Text('Action One'), + onPressed: () { + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: const Text('Action Two'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Home')), + child: Center( + child: CupertinoButton( + onPressed: () { + Navigator.of(context).restorablePush(_modalBuilder); + }, + child: const Text('X'), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/scaffold_test.dart b/packages/cupertino_ui/test/cupertino/scaffold_test.dart new file mode 100644 index 000000000000..a9096e6df5ba --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/scaffold_test.dart @@ -0,0 +1,625 @@ +// 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:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../image_data.dart'; + +/// Integration tests testing both [CupertinoPageScaffold] and [CupertinoTabScaffold]. +void main() { + testWidgets('Contents are behind translucent bar', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + // Default nav bar is translucent. + navigationBar: CupertinoNavigationBar(middle: Text('Title')), + child: Center(), + ), + ), + ); + + expect(tester.getTopLeft(find.byType(Center)), Offset.zero); + }); + + testWidgets('Opaque bar pushes contents down', (WidgetTester tester) async { + late BuildContext childContext; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(top: 20)), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Opaque'), + backgroundColor: Color(0xFFF8F8F8), + ), + child: Builder( + builder: (BuildContext context) { + childContext = context; + return Container(); + }, + ), + ), + ), + ), + ); + + expect(MediaQuery.of(childContext).padding.top, 0); + // The top of the [Container] is 44 px from the top of the screen because + // it's pushed down by the opaque navigation bar whose height is 44 px, + // and the 20 px [MediaQuery] top padding is fully absorbed by the navigation bar. + expect(tester.getRect(find.byType(Container)), const Rect.fromLTRB(0, 44, 800, 600)); + }); + + testWidgets('dark mode and obstruction work', (WidgetTester tester) async { + const Color dynamicColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFF8F8F8), + darkColor: Color(0xEE333333), + ); + + const backgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFFFFFFF), + darkColor: Color(0xFF000000), + ); + + late BuildContext childContext; + Widget scaffoldWithBrightness(Brightness brightness) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData( + platformBrightness: brightness, + viewInsets: const EdgeInsets.only(top: 20), + ), + child: CupertinoPageScaffold( + backgroundColor: backgroundColor, + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + backgroundColor: dynamicColor, + ), + child: Builder( + builder: (BuildContext context) { + childContext = context; + return Container(); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(scaffoldWithBrightness(Brightness.light)); + + expect(MediaQuery.of(childContext).padding.top, 0); + expect(find.byType(CupertinoPageScaffold), paints..rect(color: backgroundColor.color)); + + await tester.pumpWidget(scaffoldWithBrightness(Brightness.dark)); + + expect(MediaQuery.of(childContext).padding.top, greaterThan(0)); + expect(find.byType(CupertinoPageScaffold), paints..rect(color: backgroundColor.darkColor)); + }); + + testWidgets('Contents padding from viewInsets', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100.0)), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Opaque'), + backgroundColor: Color(0xFFF8F8F8), + ), + child: Container(), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(Container)).height, 600.0 - 44.0 - 100.0); + + late BuildContext childContext; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100.0)), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Transparent')), + child: Builder( + builder: (BuildContext context) { + childContext = context; + return Container(); + }, + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(Container)).height, 600.0 - 100.0); + // The shouldn't see a media query view inset because it was consumed by + // the scaffold. + expect(MediaQuery.of(childContext).viewInsets.bottom, 0); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100.0)), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Title')), + resizeToAvoidBottomInset: false, + child: Container(), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(Container)).height, 600.0); + }); + + testWidgets( + 'Contents bottom padding are not consumed by viewInsets when resizeToAvoidBottomInset overridden', + (WidgetTester tester) async { + const Widget child = CupertinoPageScaffold( + resizeToAvoidBottomInset: false, + navigationBar: CupertinoNavigationBar( + middle: Text('Opaque'), + backgroundColor: Color(0xFFF8F8F8), + ), + child: Placeholder(), + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ), + ); + + final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: child, + ), + ), + ); + final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); + expect(initialPoint, finalPoint); + }, + ); + + testWidgets('Contents are between opaque bars', (WidgetTester tester) async { + const page1Center = Center(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + backgroundColor: CupertinoColors.white, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + return index == 0 + ? const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + backgroundColor: CupertinoColors.white, + middle: Text('Title'), + ), + child: page1Center, + ) + : const Stack(); + }, + ), + ), + ); + + expect(tester.getSize(find.byWidget(page1Center)).height, 600.0 - 44.0 - 50.0); + }); + + testWidgets('Contents have automatic sliver padding between translucent bars', ( + WidgetTester tester, + ) async { + const content = SizedBox(height: 600.0, width: 600.0); + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.symmetric(vertical: 20.0)), + child: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + return index == 0 + ? CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Title')), + child: ListView(children: const <Widget>[content]), + ) + : const Stack(); + }, + ), + ), + ), + ); + + // List content automatically padded by nav bar and top media query padding. + expect(tester.getTopLeft(find.byWidget(content)).dy, 20.0 + 44.0); + + // Overscroll to the bottom. + await tester.drag( + find.byWidget(content), + const Offset(0.0, -400.0), + warnIfMissed: false, + ); // can't be hit (it's empty) but we're aiming for the list really so it doesn't matter + // Let it bounce back. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // List content automatically padded by tab bar and bottom media query padding. + expect(tester.getBottomLeft(find.byWidget(content)).dy, 600 - 20.0 - 50.0); + }); + + testWidgets('iOS independent tab navigation', (WidgetTester tester) async { + // A full on iOS information architecture app with 2 tabs, and 2 pages + // in each with independent navigation states. + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 1', + ), + BottomNavigationBarItem( + icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), + label: 'Tab 2', + ), + ], + ), + tabBuilder: (BuildContext context, int index) { + // For 1-indexed readability. + ++index; + return CupertinoTabView( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Page 1 of tab $index')), + child: Center( + child: CupertinoButton( + child: const Text('Next'), + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 2 of tab $index'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Back'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ); + + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 1 of tab 2'), findsNothing); // Lazy building so not built yet. + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(find.text('Page 1 of tab 1'), findsNothing); // It's offstage now. + expect(find.text('Page 1 of tab 1', skipOffstage: false), findsOneWidget); + expect(find.text('Page 1 of tab 2'), findsOneWidget); + + // Navigate in tab 2. + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.text('Page 2 of tab 2'), isOnstage); + expect(find.text('Page 1 of tab 1', skipOffstage: false), isOffstage); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + // Independent navigation stacks. + expect(find.text('Page 1 of tab 1'), isOnstage); + expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage); + + // Navigate in tab 1. + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.text('Page 2 of tab 1'), isOnstage); + expect(find.text('Page 2 of tab 2', skipOffstage: false), isOffstage); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(find.text('Page 2 of tab 2'), isOnstage); + expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage); + + // Pop in tab 2 + await tester.tap(find.text('Back')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + + expect(find.text('Page 1 of tab 2'), isOnstage); + expect(find.text('Page 2 of tab 1', skipOffstage: false), isOffstage); + }); + + testWidgets('Decorated with white background by default', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoPageScaffold(child: Center()))); + + final decoratedBox = tester.widgetList(find.byType(DecoratedBox)).elementAt(1) as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Overrides background color', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold(backgroundColor: Color(0xFF010203), child: Center()), + ), + ); + + final decoratedBox = tester.widgetList(find.byType(DecoratedBox)).elementAt(1) as DecoratedBox; + expect(decoratedBox.decoration.runtimeType, BoxDecoration); + + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.color, const Color(0xFF010203)); + }); + + testWidgets('resizeToAvoidBottomInset is supported even when no navigationBar', ( + WidgetTester tester, + ) async { + Widget buildFrame(bool showNavigationBar, bool showKeyboard) { + return CupertinoApp( + home: MediaQuery( + data: MediaQueryData( + viewPadding: const EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: showKeyboard ? 300 : 20), + ), + child: CupertinoPageScaffold( + navigationBar: showNavigationBar + ? const CupertinoNavigationBar(middle: Text('Title')) + : null, + child: Builder( + builder: (BuildContext context) => Center( + child: CupertinoTextField(placeholder: MediaQuery.viewInsetsOf(context).toString()), + ), + ), + ), + ), + ); + } + + // CupertinoPageScaffold should consume the viewInsets in all cases + final expectedViewInsets = EdgeInsets.zero.toString(); + + // When there is a nav bar and no keyboard. + await tester.pumpWidget(buildFrame(true, false)); + final Offset positionNoInsetWithNavBar = tester.getTopLeft(find.byType(CupertinoTextField)); + expect( + (find.byType(CupertinoTextField).evaluate().first.widget as CupertinoTextField).placeholder, + expectedViewInsets, + ); + + // When there is a nav bar and keyboard, the CupertinoTextField moves up. + await tester.pumpWidget(buildFrame(true, true)); + await tester.pumpAndSettle(); + final Offset positionWithInsetWithNavBar = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(positionWithInsetWithNavBar.dy, lessThan(positionNoInsetWithNavBar.dy)); + expect( + (find.byType(CupertinoTextField).evaluate().first.widget as CupertinoTextField).placeholder, + expectedViewInsets, + ); + + // When there is no nav bar and no keyboard, the CupertinoTextField is still + // centered. + await tester.pumpWidget(buildFrame(false, false)); + final Offset positionNoInsetNoNavBar = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(positionNoInsetNoNavBar, equals(positionNoInsetWithNavBar)); + expect( + (find.byType(CupertinoTextField).evaluate().first.widget as CupertinoTextField).placeholder, + expectedViewInsets, + ); + + // When there is a keyboard but no nav bar, the CupertinoTextField also + // moves up to the same position as when there is a keyboard and nav bar. + await tester.pumpWidget(buildFrame(false, true)); + await tester.pumpAndSettle(); + final Offset positionWithInsetNoNavBar = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(positionWithInsetNoNavBar.dy, lessThan(positionNoInsetNoNavBar.dy)); + expect(positionWithInsetNoNavBar, equals(positionWithInsetWithNavBar)); + expect( + (find.byType(CupertinoTextField).evaluate().first.widget as CupertinoTextField).placeholder, + expectedViewInsets, + ); + }); + + testWidgets('textScaleFactor is set to 1.0', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 99, + maxScaleFactor: 99, + child: const CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('middle'), + leading: Text('leading'), + trailing: Text('trailing'), + ), + child: Text('content'), + ), + ); + }, + ), + ), + ); + final Iterable<RichText> richTextList = tester.widgetList<RichText>( + find.descendant(of: find.byType(CupertinoNavigationBar), matching: find.byType(RichText)), + ); + + expect(richTextList.length, greaterThan(0)); + expect(richTextList.any((RichText text) => text.textScaleFactor != 1), isFalse); + + expect( + tester + .widget<RichText>( + find.descendant(of: find.text('content'), matching: find.byType(RichText)), + ) + .textScaler, + const TextScaler.linear(99.0), + ); + }); + + testWidgets('Tap the status bar scrolls to top', (WidgetTester tester) async { + final scrollController = ScrollController(initialScrollOffset: 1000); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: Builder( + builder: (BuildContext context) { + return PrimaryScrollController( + controller: scrollController, + child: const CupertinoPageScaffold( + child: SingleChildScrollView(primary: true, child: SizedBox(height: 12345)), + ), + ); + }, + ), + ), + ), + ); + + tester.simulateStatusBarTap(); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + }); + + testWidgets('status bar tap only scrolls the foregrounded primary controller', ( + WidgetTester tester, + ) async { + final app = CupertinoApp( + initialRoute: 'a', + onGenerateInitialRoutes: (initialRoute) { + return [ + CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + ]; + }, + onGenerateRoute: (_) => throw UnimplementedError(), + ); + await tester.pumpWidget(app); + + final Iterable<ScrollableState> scrollables = tester.stateList<ScrollableState>( + find.descendant( + of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false), + matching: find.byType(Scrollable, skipOffstage: false), + skipOffstage: false, + ), + ); + + final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList(); + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 1000); + + tester.simulateStatusBarTap(); + await tester.pumpAndSettle(); + + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 0); + }); + + testWidgets('CupertinoPageScaffold does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoPageScaffold(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoPageScaffold)), Size.zero); + }); +} + +class _ScaffoldWithPrimaryScrollView extends StatefulWidget { + @override + State<StatefulWidget> createState() => _ScaffoldWithPrimaryScrollViewState(); +} + +class _ScaffoldWithPrimaryScrollViewState extends State<_ScaffoldWithPrimaryScrollView> { + final ScrollController controller = ScrollController(initialScrollOffset: 1000); + @override + Widget build(BuildContext context) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: PrimaryScrollController( + controller: controller, + child: const CupertinoPageScaffold( + child: SingleChildScrollView(primary: true, child: SizedBox(height: 2000)), + ), + ), + ); + } +} diff --git a/packages/cupertino_ui/test/cupertino/scrollbar_paint_test.dart b/packages/cupertino_ui/test/cupertino/scrollbar_paint_test.dart new file mode 100644 index 000000000000..6bb632cc026f --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/scrollbar_paint_test.dart @@ -0,0 +1,140 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const Color _kScrollbarColor = Color(0x59000000); + +// The `y` offset has to be larger than `ScrollDragController._bigThresholdBreakDistance` +// to prevent [motionStartDistanceThreshold] from affecting the actual drag distance. +const Offset _kGestureOffset = Offset(0, -25); +const Radius _kScrollbarRadius = Radius.circular(1.5); + +void main() { + testWidgets('Paints iOS spec', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(), + child: CupertinoScrollbar( + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(_kGestureOffset); + // Move back to original position. + await gesture.moveBy(Offset.zero.translate(-_kGestureOffset.dx, -_kGestureOffset.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH( + 800.0 - 3 - 3, // Screen width - margin - thickness. + 3.0, // Initial position is the top margin. + 3, // Thickness. + // Fraction in viewport * scrollbar height - top, bottom margin. + 600.0 / 4000.0 * (600.0 - 2 * 3), + ), + _kScrollbarRadius, + ), + ), + ); + }); + + testWidgets('Paints iOS spec with nav bar', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(0, 20, 0, 34)), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + backgroundColor: Color(0x11111111), + ), + child: CupertinoScrollbar( + child: ListView(children: const <Widget>[SizedBox(width: 4000, height: 4000)]), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView))); + await gesture.moveBy(_kGestureOffset); + // Move back to original position. + await gesture.moveBy(Offset(-_kGestureOffset.dx, -_kGestureOffset.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH( + 800.0 - 3 - 3, // Screen width - margin - thickness. + 44 + 20 + 3.0, // nav bar height + top margin + 3, // Thickness. + // Fraction visible * (viewport size - padding - margin) + // where Fraction visible = (viewport size - padding) / content size + (600.0 - 34 - 44 - 20) / 4000.0 * (600.0 - 2 * 3 - 34 - 44 - 20), + ), + _kScrollbarRadius, + ), + ), + ); + }); + + testWidgets("should not paint when there isn't enough space", (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(0, 20, 0, 34)), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + backgroundColor: Color(0x11111111), + ), + child: CupertinoScrollbar( + child: ListView( + physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()), + children: const <Widget>[SizedBox(width: 10, height: 10)], + ), + ), + ), + ), + ), + ); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView))); + await gesture.moveBy(_kGestureOffset); + // Move back to original position. + await gesture.moveBy(Offset(-_kGestureOffset.dx, -_kGestureOffset.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + + // The scrollbar should not appear even when overscrolled. + final TestGesture overscrollGesture = await tester.startGesture( + tester.getCenter(find.byType(ListView)), + ); + await overscrollGesture.moveBy(_kGestureOffset); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/scrollbar_test.dart b/packages/cupertino_ui/test/cupertino/scrollbar_test.dart new file mode 100644 index 000000000000..91ca7f422444 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/scrollbar_test.dart @@ -0,0 +1,1361 @@ +// 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:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const CupertinoDynamicColor _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: Color(0x59000000), + darkColor: Color(0x80FFFFFF), +); + +void main() { + const kScrollbarTimeToFade = Duration(milliseconds: 1200); + const kScrollbarFadeDuration = Duration(milliseconds: 250); + const kScrollbarResizeDuration = Duration(milliseconds: 100); + const kLongPressDuration = Duration(milliseconds: 100); + + testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(), + child: CupertinoScrollbar( + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(const Offset(0.0, -10.0)); + await tester.pump(); + // Scrollbar fully showing + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 3)); + // Still there. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + await gesture.up(); + await tester.pump(kScrollbarTimeToFade); + await tester.pump(kScrollbarFadeDuration * 0.5); + + // Opacity going down now. + expect( + find.byType(CupertinoScrollbar), + paints..rrect(color: _kScrollbarColor.color.withAlpha(69)), + ); + }); + + testWidgets('Scrollbar dark mode', (WidgetTester tester) async { + Brightness brightness = Brightness.light; + late StateSetter setState; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MediaQuery( + data: MediaQueryData(platformBrightness: brightness), + child: const CupertinoScrollbar( + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ); + }, + ), + ), + ); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(const Offset(0.0, 10.0)); + await tester.pump(); + // Scrollbar fully showing + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + setState(() { + brightness = Brightness.dark; + }); + await tester.pump(); + + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.darkColor)); + }); + + testWidgets('Scrollbar thumb can be dragged with long press', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: const CupertinoScrollbar( + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit. + const scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + // Scroll down by swiping up. + await scrollGesture.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // Scrollbar thumb is fully showing and scroll offset has moved by + // scrollAmount. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + expect(scrollController.offset, scrollAmount); + await scrollGesture.up(); + await tester.pump(); + + var hapticFeedbackCalls = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + hapticFeedbackCalls += 1; + } + return null; + }); + + // Long press on the scrollbar thumb and expect a vibration after it resizes. + expect(hapticFeedbackCalls, 0); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0)); + await tester.pump(kLongPressDuration); + expect(hapticFeedbackCalls, 0); + await tester.pump(kScrollbarResizeDuration); + // Allow the haptic feedback some slack. + await tester.pump(const Duration(milliseconds: 1)); + expect(hapticFeedbackCalls, 1); + + // Drag the thumb down to scroll down. + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pump(const Duration(milliseconds: 100)); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + // The scrollbar thumb is still fully visible. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + // Let the thumb fade out so all timers have resolved. + await tester.pump(kScrollbarTimeToFade); + await tester.pump(kScrollbarFadeDuration); + }); + + testWidgets('Scrollbar thumb can be dragged with long press - reverse', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: const CupertinoScrollbar( + child: SingleChildScrollView( + reverse: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit. + const scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + // Scroll up by swiping down. + await scrollGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // Scrollbar thumb is fully showing and scroll offset has moved by + // scrollAmount. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + expect(scrollController.offset, scrollAmount); + await scrollGesture.up(); + await tester.pump(); + + var hapticFeedbackCalls = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + hapticFeedbackCalls += 1; + } + return null; + }); + + // Long press on the scrollbar thumb and expect a vibration after it resizes. + expect(hapticFeedbackCalls, 0); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 550.0)); + await tester.pump(kLongPressDuration); + expect(hapticFeedbackCalls, 0); + await tester.pump(kScrollbarResizeDuration); + // Allow the haptic feedback some slack. + await tester.pump(const Duration(milliseconds: 1)); + expect(hapticFeedbackCalls, 1); + + // Drag the thumb up to scroll up. + await dragScrollbarGesture.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pump(const Duration(milliseconds: 100)); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + // The scrollbar thumb is still fully visible. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + // Let the thumb fade out so all timers have resolved. + await tester.pump(kScrollbarTimeToFade); + await tester.pump(kScrollbarFadeDuration); + }); + + testWidgets('Scrollbar changes thickness and radius when dragged', (WidgetTester tester) async { + const double thickness = 20; + const double thicknessWhileDragging = 40; + const double radius = 10; + const double radiusWhileDragging = 20; + + const double inset = 3; + const double scaleFactor = 2; + final Size screenSize = tester.view.physicalSize / tester.view.devicePixelRatio; + + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: CupertinoScrollbar( + thickness: thickness, + thicknessWhileDragging: thicknessWhileDragging, + radius: const Radius.circular(radius), + radiusWhileDragging: const Radius.circular(radiusWhileDragging), + child: SingleChildScrollView( + child: SizedBox( + width: screenSize.width * scaleFactor, + height: screenSize.height * scaleFactor, + ), + ), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit to cause the scrollbar thumb to be shown; + // undo the scroll to put the thumb back at the top. + const scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await scrollGesture.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await scrollGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pump(); + await scrollGesture.up(); + await tester.pump(); + + // Long press on the scrollbar thumb and expect it to grow + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(780.0, 50.0)); + await tester.pump(kLongPressDuration); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + Rect.fromLTWH( + screenSize.width - inset - thickness, + inset, + thickness, + (screenSize.height - 2 * inset) / scaleFactor, + ), + const Radius.circular(radius), + ), + ), + ); + await tester.pump(kScrollbarResizeDuration ~/ 2); + const double midpointThickness = (thickness + thicknessWhileDragging) / 2; + const double midpointRadius = (radius + radiusWhileDragging) / 2; + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + Rect.fromLTWH( + screenSize.width - inset - midpointThickness, + inset, + midpointThickness, + (screenSize.height - 2 * inset) / scaleFactor, + ), + const Radius.circular(midpointRadius), + ), + ), + ); + await tester.pump(kScrollbarResizeDuration ~/ 2); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + Rect.fromLTWH( + screenSize.width - inset - thicknessWhileDragging, + inset, + thicknessWhileDragging, + (screenSize.height - 2 * inset) / scaleFactor, + ), + const Radius.circular(radiusWhileDragging), + ), + ), + ); + + // Let the thumb fade out so all timers have resolved. + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + await tester.pump(kScrollbarTimeToFade); + await tester.pump(kScrollbarFadeDuration); + }); + + testWidgets( + 'When thumbVisibility is true, must pass a controller or find PrimaryScrollController', + (WidgetTester tester) async { + Widget viewWithScroll() { + return const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(), + child: CupertinoScrollbar( + thumbVisibility: true, + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + }, + ); + + testWidgets( + 'When thumbVisibility is true, must pass a controller or find PrimaryScrollController that is attached to a scroll view', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + controller: controller, + thumbVisibility: true, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + final FlutterExceptionHandler? handler = FlutterError.onError; + FlutterErrorDetails? error; + FlutterError.onError = (FlutterErrorDetails details) { + error = details; + }; + + await tester.pumpWidget(viewWithScroll()); + expect(error, isNotNull); + + FlutterError.onError = handler; + }, + ); + + testWidgets( + 'When thumbVisibility is true, must pass a controller or find PrimaryScrollController', + (WidgetTester tester) async { + Widget viewWithScroll() { + return const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(), + child: CupertinoScrollbar( + thumbVisibility: true, + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + }, + ); + + testWidgets( + 'When thumbVisibility is true, must pass a controller or find PrimaryScrollController that is attached to a scroll view', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + controller: controller, + thumbVisibility: true, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + final FlutterExceptionHandler? handler = FlutterError.onError; + FlutterErrorDetails? error; + FlutterError.onError = (FlutterErrorDetails details) { + error = details; + }; + + await tester.pumpWidget(viewWithScroll()); + expect(error, isNotNull); + + FlutterError.onError = handler; + }, + ); + + testWidgets( + 'On first render with thumbVisibility: true, the thumb shows with PrimaryScrollController', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: Builder( + builder: (BuildContext context) { + return const CupertinoScrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rect()); + }, + ); + + testWidgets('On first render with thumbVisibility: true, the thumb shows', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: CupertinoScrollbar( + thumbVisibility: true, + controller: controller, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + // The scrollbar measures its size on the first frame + // and renders starting in the second, + // + // so pumpAndSettle a frame to allow it to appear. + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + }); + + testWidgets( + 'On first render with thumbVisibility: true, the thumb shows with PrimaryScrollController', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: Builder( + builder: (BuildContext context) { + return const CupertinoScrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rect()); + }, + ); + + testWidgets('On first render with thumbVisibility: true, the thumb shows', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: CupertinoScrollbar( + thumbVisibility: true, + controller: controller, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + // The scrollbar measures its size on the first frame + // and renders starting in the second, + // + // so pumpAndSettle a frame to allow it to appear. + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + }); + + testWidgets('On first render with thumbVisibility: false, the thumb is hidden', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: CupertinoScrollbar( + controller: controller, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rect())); + }); + + testWidgets( + 'With thumbVisibility: true, fling a scroll. While it is still scrolling, set thumbVisibility: false. The thumb should not fade out until the scrolling stops.', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + var thumbVisibility = true; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: <Widget>[ + CupertinoScrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + child: const Text('change thumbVisibility'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + }, + ); + + testWidgets( + 'With thumbVisibility: false, set thumbVisibility: true. The thumb should be always shown directly', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + var thumbVisibility = false; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: <Widget>[ + CupertinoScrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + child: const Text('change thumbVisibility'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + }, + ); + + testWidgets( + 'With thumbVisibility: false, fling a scroll. While it is still scrolling, set thumbVisibility: true. ' + 'The thumb should not fade even after the scrolling stops', + (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + var thumbVisibility = false; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: <Widget>[ + CupertinoScrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + child: const Text('change thumbVisibility'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pump(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + // Wait for the timer delay to expire. + await tester.pump(const Duration(milliseconds: 600)); // kScrollbarTimeToFade + await tester.pumpAndSettle(); + // Scrollbar thumb is showing after scroll finishes and timer ends. + expect(find.byType(CupertinoScrollbar), paints..rrect()); + }, + ); + + testWidgets('Toggling thumbVisibility while not scrolling fades the thumb in/out. ' + 'This works even when you have never scrolled at all yet', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + var thumbVisibility = true; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: <Widget>[ + CupertinoScrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + child: const Text('change thumbVisibility'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + }); + + testWidgets('Scrollbar thumb can be dragged with long press - horizontal axis', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit. + const scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + // Scroll right by swiping left. + await scrollGesture.moveBy(const Offset(-scrollAmount, 0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // Scrollbar thumb is fully showing and scroll offset has moved by + // scrollAmount. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + expect(scrollController.offset, scrollAmount); + await scrollGesture.up(); + await tester.pump(); + + var hapticFeedbackCalls = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + hapticFeedbackCalls += 1; + } + return null; + }); + + // Long press on the scrollbar thumb and expect a vibration after it resizes. + expect(hapticFeedbackCalls, 0); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(50.0, 596.0)); + await tester.pump(kLongPressDuration); + expect(hapticFeedbackCalls, 0); + await tester.pump(kScrollbarResizeDuration); + // Allow the haptic feedback some slack. + await tester.pump(const Duration(milliseconds: 1)); + expect(hapticFeedbackCalls, 1); + + // Drag the thumb down to scroll back to the left. + await dragScrollbarGesture.moveBy(const Offset(scrollAmount, 0.0)); + await tester.pump(const Duration(milliseconds: 100)); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + // The scrollbar thumb is still fully visible. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + // Let the thumb fade out so all timers have resolved. + await tester.pump(kScrollbarTimeToFade); + await tester.pump(kScrollbarFadeDuration); + }); + + testWidgets('Scrollbar thumb can be dragged with long press - horizontal axis, reverse', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + reverse: true, + controller: scrollController, + scrollDirection: Axis.horizontal, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit. + const scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + // Scroll right by swiping right. + await scrollGesture.moveBy(const Offset(scrollAmount, 0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // Scrollbar thumb is fully showing and scroll offset has moved by + // scrollAmount. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + expect(scrollController.offset, scrollAmount); + await scrollGesture.up(); + await tester.pump(); + + var hapticFeedbackCalls = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + hapticFeedbackCalls += 1; + } + return null; + }); + + // Long press on the scrollbar thumb and expect a vibration after it resizes. + expect(hapticFeedbackCalls, 0); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(750.0, 596.0)); + await tester.pump(kLongPressDuration); + expect(hapticFeedbackCalls, 0); + await tester.pump(kScrollbarResizeDuration); + // Allow the haptic feedback some slack. + await tester.pump(const Duration(milliseconds: 1)); + expect(hapticFeedbackCalls, 1); + + // Drag the thumb to scroll back to the right. + await dragScrollbarGesture.moveBy(const Offset(-scrollAmount, 0.0)); + await tester.pump(const Duration(milliseconds: 100)); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + // The scrollbar thumb is still fully visible. + expect(find.byType(CupertinoScrollbar), paints..rrect(color: _kScrollbarColor.color)); + + // Let the thumb fade out so all timers have resolved. + await tester.pump(kScrollbarTimeToFade); + await tester.pump(kScrollbarFadeDuration); + }); + + testWidgets( + 'Tapping the track area pages the Scroll View except on iOS', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 1000.0, height: 1000.0), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor.color, + rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)), + ), + ); + + // Tap on the track area below the thumb. + await tester.tapAt(const Offset(796.0, 550.0)); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 400.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor.color, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(794.0, 240.6, 797.0, 597.0), + const Radius.circular(1.5), + ), + ), + ); + + // Tap on the track area above the thumb. + await tester.tapAt(const Offset(796.0, 50.0)); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor.color, + rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)), + ), + ); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Tapping the track area does not page the Scroll View on iOS', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 1000.0, height: 1000.0), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor.color, + rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)), + ), + ); + + // Tap on the track area below the thumb. + await tester.tapAt(const Offset(796.0, 550.0)); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor.color, + rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)), + ), + ); + + // Tap on the track area above the thumb. + await tester.tapAt(const Offset(796.0, 50.0)); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + color: _kScrollbarColor.color, + rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)), + ), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('Throw if interactive with the bar when no position attached', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + controller: scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1000.0, width: 1000.0), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final ScrollPosition position = scrollController.position; + scrollController.detach(position); + + final FlutterExceptionHandler? handler = FlutterError.onError; + FlutterErrorDetails? error; + FlutterError.onError = (FlutterErrorDetails details) { + error = details; + }; + + // long press the thumb + await tester.startGesture(const Offset(796.0, 50.0)); + await tester.pump(kLongPressDuration); + + expect(error, isNotNull); + + scrollController.attach(position); + FlutterError.onError = handler; + }); + + testWidgets('Interactive scrollbars should have a valid scroll controller', ( + WidgetTester tester, + ) async { + final primaryScrollController = ScrollController(); + addTearDown(primaryScrollController.dispose); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: primaryScrollController, + child: CupertinoScrollbar( + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(height: 1000.0, width: 1000.0), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + var exception = tester.takeException() as AssertionError?; + // The scrollbar is not visible and cannot be interacted with, so no assertion. + expect(exception, isNull); + // Scroll to trigger the scrollbar to come into view. + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(const Offset(0.0, -20.0)); + exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + expect( + exception.message, + contains("The Scrollbar's ScrollController has no ScrollPosition attached."), + ); + }); + + testWidgets('Simultaneous dragging and pointer scrolling does not cause a crash', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/70105 + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: PrimaryScrollController( + controller: scrollController, + child: CupertinoScrollbar( + thumbVisibility: true, + controller: scrollController, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + const scrollAmount = 10.0; + + await tester.pumpAndSettle(); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(794.0, 3.0, 797.0, 92.1), + const Radius.circular(1.5), + ), + color: _kScrollbarColor.color, + ), + ); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0)); + await tester.pump(kLongPressDuration); + await tester.pump(kScrollbarResizeDuration); + + // Drag the thumb down to scroll down. + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(10.0)); + final double previousOffset = scrollController.offset; + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1), + const Radius.circular(4.0), + ), + color: _kScrollbarColor.color, + ), + ); + + // Execute a pointer scroll while dragging (drag gesture has not come up yet) + final pointer = TestPointer(1, ui.PointerDeviceKind.mouse); + pointer.hover(const Offset(793.0, 15.0)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0))); + await tester.pumpAndSettle(); + + if (!kIsWeb) { + // Scrolling while holding the drag on the scrollbar and still hovered over + // the scrollbar should not have changed the scroll offset. + expect(pointer.location, const Offset(793.0, 15.0)); + expect(scrollController.offset, previousOffset); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1), + const Radius.circular(4.0), + ), + color: _kScrollbarColor.color, + ), + ); + } else { + expect(pointer.location, const Offset(793.0, 15.0)); + expect(scrollController.offset, previousOffset + 20.0); + } + + // Drag is still being held, move pointer to be hovering over another area + // of the scrollable (not over the scrollbar) and execute another pointer scroll + pointer.hover(tester.getCenter(find.byType(SingleChildScrollView))); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0))); + await tester.pumpAndSettle(); + // Scrolling while holding the drag on the scrollbar changed the offset + expect(pointer.location, const Offset(400.0, 300.0)); + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(789.0, 3.0, 797.0, 92.1), + const Radius.circular(4.0), + ), + color: _kScrollbarColor.color, + ), + ); + + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(794.0, 3.0, 797.0, 92.1), + const Radius.circular(1.5), + ), + color: _kScrollbarColor.color, + ), + ); + }); + + testWidgets('CupertinoScrollbar scrollOrientation works correctly', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: PrimaryScrollController( + controller: scrollController, + child: CupertinoScrollbar( + thumbVisibility: true, + controller: scrollController, + scrollbarOrientation: ScrollbarOrientation.left, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 9.0, 600.0)) + ..line(p1: const Offset(9.0, 0.0), p2: const Offset(9.0, 600.0), strokeWidth: 1.0) + ..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(3.0, 3.0, 6.0, 92.1), + const Radius.circular(1.5), + ), + color: _kScrollbarColor.color, + ), + ); + }); + + testWidgets('CupertinoScrollbar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoScrollbar(child: CustomScrollView())), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoScrollbar)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/search_field_test.dart b/packages/cupertino_ui/test/cupertino/search_field_test.dart new file mode 100644 index 000000000000..918939d82c9d --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/search_field_test.dart @@ -0,0 +1,904 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('default search field has a border radius', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoSearchTextField()))); + + final decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoSearchTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.borderRadius, const BorderRadius.all(Radius.circular(9))); + }); + + testWidgets('decoration overrides default background color', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + decoration: BoxDecoration(color: Color.fromARGB(1, 1, 1, 1)), + ), + ), + ), + ); + + final decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoSearchTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.color, const Color.fromARGB(1, 1, 1, 1)); + }); + + testWidgets('decoration overrides default border radius', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + decoration: BoxDecoration(borderRadius: BorderRadius.zero), + ), + ), + ), + ); + + final decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoSearchTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.borderRadius, BorderRadius.zero); + }); + + testWidgets('text entries are padded by default', (WidgetTester tester) async { + final controller = TextEditingController(text: 'initial'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(controller: controller)), + ), + ); + + expect( + tester.getTopLeft(find.text('initial')) - + tester.getTopLeft(find.byType(CupertinoSearchTextField)), + const Offset(31.5, 9.5), + ); + }); + + testWidgets('can change keyboard type', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(keyboardType: TextInputType.number)), + ), + ); + await tester.tap(find.byType(CupertinoSearchTextField)); + await tester.showKeyboard(find.byType(CupertinoSearchTextField)); + expect( + (tester.testTextInput.setClientArgs!['inputType'] as Map<String, dynamic>)['name'], + equals('TextInputType.number'), + ); + }); + + testWidgets('can control text content via controller', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(controller: controller)), + ), + ); + + controller.text = 'controller text'; + await tester.pump(); + + expect(find.text('controller text'), findsOneWidget); + + controller.text = ''; + await tester.pump(); + + expect(find.text('controller text'), findsNothing); + }); + + testWidgets('placeholder color', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoSearchTextField()), + ), + ); + + Text placeholder = tester.widget(find.text('Search')); + expect(placeholder.style!.color!.value, CupertinoColors.secondaryLabel.darkColor.value); + + await tester.pumpAndSettle(); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.light), + home: Center(child: CupertinoSearchTextField()), + ), + ); + + placeholder = tester.widget(find.text('Search')); + expect(placeholder.style!.color!.value, CupertinoColors.secondaryLabel.color.value); + }); + + testWidgets("placeholderStyle modifies placeholder's style and doesn't affect text's style", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + placeholder: 'placeholder', + style: TextStyle(color: Color(0x00FFFFFF), fontWeight: FontWeight.w300), + placeholderStyle: TextStyle(color: Color(0xAAFFFFFF), fontWeight: FontWeight.w600), + ), + ), + ), + ); + + final Text placeholder = tester.widget(find.text('placeholder')); + expect(placeholder.style!.color, const Color(0xAAFFFFFF)); + expect(placeholder.style!.fontWeight, FontWeight.w600); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'input'); + await tester.pump(); + + final EditableText inputText = tester.widget(find.text('input')); + expect(inputText.style.color, const Color(0x00FFFFFF)); + expect(inputText.style.fontWeight, FontWeight.w300); + }); + + testWidgets('prefix widget is in front of the text', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(controller: controller)), + ), + ); + + expect( + tester.getTopRight(find.byIcon(CupertinoIcons.search)).dx + 5.5, + tester.getTopLeft(find.byType(EditableText)).dx, + ); + + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + tester.getTopLeft(find.byType(CupertinoSearchTextField)).dx + + tester.getSize(find.byIcon(CupertinoIcons.search)).width + + 11.5, + ); + }); + + testWidgets('suffix widget is after the text', (WidgetTester tester) async { + final controller = TextEditingController(text: 'Hi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(controller: controller)), + ), + ); + + expect( + tester.getTopRight(find.byType(EditableText)).dx + 5.5, + tester.getTopLeft(find.byIcon(CupertinoIcons.xmark_circle_fill)).dx, + ); + + expect( + tester.getTopRight(find.byType(EditableText)).dx, + tester.getTopRight(find.byType(CupertinoSearchTextField)).dx - + tester.getSize(find.byIcon(CupertinoIcons.xmark_circle_fill)).width - + 10.5, + ); + }); + + testWidgets('prefix widget visibility', (WidgetTester tester) async { + const prefixIcon = Key('prefix'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + prefixIcon: SizedBox(key: prefixIcon, width: 50, height: 50), + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.search), findsNothing); + expect(find.byKey(prefixIcon), findsOneWidget); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'text input'); + await tester.pump(); + + expect(find.text('text input'), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsNothing); + expect(find.byKey(prefixIcon), findsOneWidget); + }); + + testWidgets('suffix widget respects visibility mode', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(suffixMode: OverlayVisibilityMode.notEditing)), + ), + ); + + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsOneWidget); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'text input'); + await tester.pump(); + + expect(find.text('text input'), findsOneWidget); + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing); + }); + + testWidgets('Default prefix and suffix insets are aligned', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoSearchTextField()))); + + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'text input'); + await tester.pump(); + + expect(find.text('text input'), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsOneWidget); + + expect(tester.getTopLeft(find.byIcon(CupertinoIcons.search)), const Offset(6.0, 290.0)); + expect( + tester.getTopLeft(find.byIcon(CupertinoIcons.xmark_circle_fill)), + const Offset(775.0, 290.0), + ); + + expect(tester.getBottomRight(find.byIcon(CupertinoIcons.search)), const Offset(26.0, 310.0)); + expect( + tester.getBottomRight(find.byIcon(CupertinoIcons.xmark_circle_fill)), + const Offset(795.0, 310.0), + ); + }); + + testWidgets('clear button shows with right visibility mode', (WidgetTester tester) async { + var controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + controller: controller, + placeholder: 'placeholder does not affect clear button', + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'text input'); + await tester.pump(); + + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsOneWidget); + expect(find.text('text input'), findsOneWidget); + + controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + controller: controller, + placeholder: 'placeholder does not affect clear button', + suffixMode: OverlayVisibilityMode.notEditing, + ), + ), + ), + ); + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsOneWidget); + + controller.text = 'input'; + await tester.pump(); + + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing); + }); + + testWidgets('clear button removes text', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(controller: controller)), + ), + ); + + controller.text = 'text entry'; + await tester.pump(); + + await tester.tap(find.byIcon(CupertinoIcons.xmark_circle_fill)); + await tester.pump(); + + expect(controller.text, ''); + expect(find.text('Search'), findsOneWidget); + expect(find.text('text entry'), findsNothing); + expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing); + }); + + testWidgets('tapping clear button also calls onChanged when text not empty', ( + WidgetTester tester, + ) async { + var value = 'text entry'; + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + controller: controller, + placeholder: 'placeholder', + onChanged: (String newValue) => value = newValue, + ), + ), + ), + ); + + controller.text = value; + await tester.pump(); + + await tester.tap(find.byIcon(CupertinoIcons.xmark_circle_fill)); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(find.text('text entry'), findsNothing); + expect(value, isEmpty); + }); + + testWidgets('RTL puts attachments to the right places', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center(child: CupertinoSearchTextField(suffixMode: OverlayVisibilityMode.always)), + ), + ), + ); + + expect(tester.getTopLeft(find.byIcon(CupertinoIcons.search)).dx, 800.0 - 26.0); + + expect(tester.getTopRight(find.byIcon(CupertinoIcons.xmark_circle_fill)).dx, 25.0); + }); + + testWidgets('Can modify prefix and suffix insets', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + suffixMode: OverlayVisibilityMode.always, + prefixInsets: EdgeInsets.zero, + suffixInsets: EdgeInsets.zero, + ), + ), + ), + ); + + expect(tester.getTopLeft(find.byIcon(CupertinoIcons.search)).dx, 0.0); + + expect(tester.getTopRight(find.byIcon(CupertinoIcons.xmark_circle_fill)).dx, 800.0); + }); + + testWidgets('custom suffix onTap overrides default clearing behavior', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'Text'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSearchTextField(controller: controller, onSuffixTap: () {}), + ), + ), + ); + + await tester.pump(); + + await tester.tap(find.byIcon(CupertinoIcons.xmark_circle_fill)); + await tester.pump(); + + expect(controller.text, isNotEmpty); + expect(find.text('Text'), findsOneWidget); + }); + + testWidgets('onTap is properly forwarded to the inner text field', (WidgetTester tester) async { + var onTapCallCount = 0; + + // onTap can be null. + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoSearchTextField()))); + + // onTap callback is called if not null. + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSearchTextField( + onTap: () { + onTapCallCount++; + }, + ), + ), + ), + ); + + expect(onTapCallCount, 0); + await tester.tap(find.byType(CupertinoTextField)); + expect(onTapCallCount, 1); + }); + + testWidgets('autocorrect is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoSearchTextField(autocorrect: false))), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.autocorrect, false); + }); + + testWidgets('enabled is properly forwarded to the inner text field', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoSearchTextField(enabled: false))), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.enabled, false); + }); + + testWidgets('textInputAction is set to TextInputAction.search by default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoSearchTextField()))); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.textInputAction, TextInputAction.search); + }); + + testWidgets('autofocus:true gives focus to the widget', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(focusNode: focusNode, autofocus: true)), + ), + ); + + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('smartQuotesType is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(smartQuotesType: SmartQuotesType.disabled)), + ), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.smartQuotesType, SmartQuotesType.disabled); + }); + + testWidgets('smartDashesType is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(smartDashesType: SmartDashesType.disabled)), + ), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.smartDashesType, SmartDashesType.disabled); + }); + + testWidgets('enableIMEPersonalizedLearning is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(enableIMEPersonalizedLearning: false)), + ), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.enableIMEPersonalizedLearning, false); + }); + + testWidgets('cursorWidth is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoSearchTextField(cursorWidth: 1))), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.cursorWidth, 1); + }); + + testWidgets('cursorHeight is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoSearchTextField(cursorHeight: 10))), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.cursorHeight, 10); + }); + + testWidgets('cursorRadius is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(cursorRadius: Radius.circular(1.0))), + ), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.cursorRadius, const Radius.circular(1.0)); + }); + + testWidgets('cursorOpacityAnimates is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(cursorOpacityAnimates: false)), + ), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.cursorOpacityAnimates, false); + }); + + testWidgets('cursorColor is properly forwarded to the inner text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoSearchTextField(cursorColor: Color.fromARGB(255, 255, 0, 0))), + ), + ); + + final CupertinoTextField textField = tester.widget(find.byType(CupertinoTextField)); + expect(textField.cursorColor, const Color.fromARGB(255, 255, 0, 0)); + }); + + testWidgets('Icons and placeholder fade while resizing on scroll', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + SliverResizingHeader( + child: CupertinoSearchTextField(suffixMode: OverlayVisibilityMode.always), + ), + SliverFillRemaining(), + ], + ), + ), + ), + ); + + final Finder searchTextFieldFinder = find.byType(CupertinoSearchTextField); + expect(searchTextFieldFinder, findsOneWidget); + + final Finder prefixIconFinder = find.descendant( + of: searchTextFieldFinder, + matching: find.byIcon(CupertinoIcons.search), + ); + final Finder suffixIconFinder = find.descendant( + of: searchTextFieldFinder, + matching: find.byIcon(CupertinoIcons.xmark_circle_fill), + ); + final Finder placeholderFinder = find.descendant( + of: searchTextFieldFinder, + matching: find.text('Search'), + ); + expect(prefixIconFinder, findsOneWidget); + expect(suffixIconFinder, findsOneWidget); + expect(placeholderFinder, findsOneWidget); + + // Initially, the icons are fully opaque. + expect( + tester + .widget<Opacity>(find.ancestor(of: prefixIconFinder, matching: find.byType(Opacity))) + .opacity, + equals(1.0), + ); + expect( + tester + .widget<Opacity>(find.ancestor(of: suffixIconFinder, matching: find.byType(Opacity))) + .opacity, + equals(1.0), + ); + // The default placeholder color is semi-transparent. + expect(tester.widget<Text>(placeholderFinder).style?.color?.a, equals(0.6)); + + final double searchTextFieldHeight = tester.getSize(searchTextFieldFinder).height; + + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture1.moveBy(Offset(0, -searchTextFieldHeight / 5)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // The icons and placeholder text start to fade. + expect( + tester + .widget<Opacity>(find.ancestor(of: prefixIconFinder, matching: find.byType(Opacity))) + .opacity, + greaterThan(0.0), + ); + expect( + tester + .widget<Opacity>(find.ancestor(of: prefixIconFinder, matching: find.byType(Opacity))) + .opacity, + lessThan(1.0), + ); + expect( + tester + .widget<Opacity>(find.ancestor(of: suffixIconFinder, matching: find.byType(Opacity))) + .opacity, + greaterThan(0.0), + ); + expect( + tester + .widget<Opacity>(find.ancestor(of: suffixIconFinder, matching: find.byType(Opacity))) + .opacity, + lessThan(1.0), + ); + expect(tester.widget<Text>(placeholderFinder).style?.color?.a, greaterThan(0.0)); + expect(tester.widget<Text>(placeholderFinder).style?.color?.a, lessThan(1.0)); + + final TestGesture scrollGesture2 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture2.moveBy(Offset(0, -4 * searchTextFieldHeight / 5)); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + // The icons and placeholder text have faded completely. + expect( + tester + .widget<Opacity>(find.ancestor(of: prefixIconFinder, matching: find.byType(Opacity))) + .opacity, + equals(0.0), + ); + expect( + tester + .widget<Opacity>(find.ancestor(of: suffixIconFinder, matching: find.byType(Opacity))) + .opacity, + equals(0.0), + ); + expect(tester.widget<Text>(placeholderFinder).style?.color?.a, equals(0.0)); + }); + + testWidgets('Top padding animates while resizing on scroll', (WidgetTester tester) async { + const TextDirection direction = TextDirection.ltr; + await tester.pumpWidget( + const Directionality( + textDirection: direction, + child: CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + SliverResizingHeader(child: CupertinoSearchTextField()), + SliverFillRemaining(), + ], + ), + ), + ), + ), + ); + + final Finder searchTextFieldFinder = find.byType(CupertinoSearchTextField); + expect(searchTextFieldFinder, findsOneWidget); + + final double initialPadding = tester + .widget<CupertinoTextField>( + find.descendant(of: searchTextFieldFinder, matching: find.byType(CupertinoTextField)), + ) + .padding + .resolve(direction) + .top; + expect(initialPadding, equals(8.0)); + + final double searchTextFieldHeight = tester.getSize(searchTextFieldFinder).height; + + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture.moveBy(Offset(0, -searchTextFieldHeight / 5)); + await scrollGesture.up(); + await tester.pumpAndSettle(); + + expect( + tester + .widget<CupertinoTextField>( + find.descendant(of: searchTextFieldFinder, matching: find.byType(CupertinoTextField)), + ) + .padding + .resolve(direction) + .top, + lessThan(initialPadding), + ); + }); + + testWidgets('Fades and animates insets on scroll if search field starts out collapsed', ( + WidgetTester tester, + ) async { + const TextDirection direction = TextDirection.ltr; + const double scrollOffset = 200; + await tester.pumpWidget( + const Directionality( + textDirection: direction, + child: CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: <Widget>[ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large title'), + searchField: CupertinoSearchTextField(), + ), + SliverToBoxAdapter(child: SizedBox(height: 1000)), + ], + ), + ), + ), + ), + ); + + final Finder searchTextFieldFinder = find.byType(CupertinoSearchTextField); + expect(searchTextFieldFinder, findsOneWidget); + + final double searchTextFieldHeight = tester.getSize(searchTextFieldFinder).height; + await tester.tap(find.widgetWithText(CupertinoSearchTextField, 'Search'), warnIfMissed: false); + + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture1.moveBy(const Offset(0, -scrollOffset)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + expect(find.text('Cancel'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + final TestGesture scrollGesture2 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture2.moveBy(Offset(0, scrollOffset - searchTextFieldHeight / 2)); + await scrollGesture2.up(); + await tester.pump(); + + final Finder prefixIconFinder = find.descendant( + of: searchTextFieldFinder, + matching: find.byIcon(CupertinoIcons.search), + ); + + // The prefix icon has faded. + expect(prefixIconFinder, findsOneWidget); + expect( + tester + .widget<Opacity>(find.ancestor(of: prefixIconFinder, matching: find.byType(Opacity))) + .opacity, + lessThan(1.0), + ); + }); + + testWidgets('Focused search field hides prefix in higher accessibility text scale modes', ( + WidgetTester tester, + ) async { + var scaleFactor = 3.0; + const iconSize = 10.0; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + late StateSetter setState; + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: CupertinoPageScaffold( + child: Center( + child: CupertinoSearchTextField( + placeholder: 'Search', + focusNode: focusNode, + prefixIcon: const Icon(CupertinoIcons.add), + suffixIcon: const Icon(CupertinoIcons.xmark), + suffixMode: OverlayVisibilityMode.always, + itemSize: iconSize, + ), + ), + ), + ); + }, + ), + ), + ); + + final Iterable<RichText> barItems = tester.widgetList<RichText>( + find.descendant(of: find.byType(CupertinoSearchTextField), matching: find.byType(RichText)), + ); + expect(barItems.length, greaterThan(0)); + + for (final icon in <IconData>[CupertinoIcons.add, CupertinoIcons.xmark]) { + expect(tester.getSize(find.byIcon(icon)), Size.square(scaleFactor * iconSize)); + } + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // The prefix icon shrinks at higher accessibility text scale modes. + expect(tester.getSize(find.byIcon(CupertinoIcons.add)), Size.zero); + expect(tester.getSize(find.byIcon(CupertinoIcons.xmark)), Size.square(scaleFactor * iconSize)); + + setState(() { + scaleFactor = 2.9; + }); + await tester.pumpAndSettle(); + + // Below the threshold, the prefix icon is displayed. + for (final icon in <IconData>[CupertinoIcons.add, CupertinoIcons.xmark]) { + expect(tester.getSize(find.byIcon(icon)), Size.square(scaleFactor * iconSize)); + } + }); + + testWidgets('CupertinoSearchTextField does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'X'); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSearchTextField(controller: controller)), + ), + ); + expect(tester.getSize(find.byType(CupertinoSearchTextField)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/segmented_control_test.dart b/packages/cupertino_ui/test/cupertino/segmented_control_test.dart new file mode 100644 index 000000000000..831120d398ea --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/segmented_control_test.dart @@ -0,0 +1,1911 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +RenderBox getRenderSegmentedControl(WidgetTester tester) { + return tester.allRenderObjects.firstWhere((RenderObject currentObject) { + return currentObject.toStringShort().contains('_RenderSegmentedControl'); + }) + as RenderBox; +} + +StatefulBuilder setupSimpleSegmentedControl() { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + var sharedValue = 0; + + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ); +} + +Widget boilerplate({required Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); +} + +int getChildCount(WidgetTester tester) { + return (getRenderSegmentedControl(tester) + as RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>>) + .getChildrenAsList() + .length; +} + +ui.RSuperellipse getSurroundingShape(WidgetTester tester, {int child = 0}) { + return ((getRenderSegmentedControl(tester) + as RenderBoxContainerDefaultsMixin< + RenderBox, + ContainerBoxParentData<RenderBox> + >) + .getChildrenAsList()[child] + .parentData! + as dynamic) + .surroundingRect + as ui.RSuperellipse; +} + +Size getChildSize(WidgetTester tester, {int child = 0}) { + return (getRenderSegmentedControl(tester) + as RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>>) + .getChildrenAsList()[child] + .size; +} + +Color getBorderColor(WidgetTester tester) { + return (getRenderSegmentedControl(tester) as dynamic).borderColor as Color; +} + +int? getSelectedIndex(WidgetTester tester) { + return (getRenderSegmentedControl(tester) as dynamic).selectedIndex as int?; +} + +Color getBackgroundColor(WidgetTester tester, int childIndex) { + // Using dynamic so the test can access a private class. + // ignore: avoid_dynamic_calls + return (getRenderSegmentedControl(tester) as dynamic).backgroundColors[childIndex] as Color; +} + +void main() { + testWidgets('Tap changes toggle state', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + children[2] = const Text('Child 3'); + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(sharedValue, 0); + + await tester.tap(find.byKey(const ValueKey<String>('Segmented Control'))); + + expect(sharedValue, 1); + }); + + testWidgets('Need at least 2 children', (WidgetTester tester) async { + await expectLater( + () => tester.pumpWidget( + boilerplate( + child: CupertinoSegmentedControl<int>( + children: const <int, Widget>{}, + onValueChanged: (int newValue) {}, + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('children.length'), + ), + ), + ); + + await expectLater( + () => tester.pumpWidget( + boilerplate( + child: CupertinoSegmentedControl<int>( + children: const <int, Widget>{0: Text('Child 1')}, + onValueChanged: (int newValue) {}, + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('children.length'), + ), + ), + ); + }); + + testWidgets('Padding works', (WidgetTester tester) async { + const key = Key('Container'); + + final children = <int, Widget>{}; + children[0] = const SizedBox(height: double.infinity, child: Text('Child 1')); + children[1] = const SizedBox(height: double.infinity, child: Text('Child 2')); + + Future<void> verifyPadding({EdgeInsets? padding}) async { + final EdgeInsets effectivePadding = padding ?? const EdgeInsets.symmetric(horizontal: 16); + final Rect segmentedControlRect = tester.getRect(find.byKey(key)); + expect( + tester.getTopLeft(find.byWidget(children[0]!)), + segmentedControlRect.topLeft.translate( + effectivePadding.topLeft.dx, + effectivePadding.topLeft.dy, + ), + ); + expect( + tester.getBottomLeft(find.byWidget(children[0]!)), + segmentedControlRect.bottomLeft.translate( + effectivePadding.bottomLeft.dx, + effectivePadding.bottomLeft.dy, + ), + ); + + expect( + tester.getTopRight(find.byWidget(children[1]!)), + segmentedControlRect.topRight.translate( + effectivePadding.topRight.dx, + effectivePadding.topRight.dy, + ), + ); + expect( + tester.getBottomRight(find.byWidget(children[1]!)), + segmentedControlRect.bottomRight.translate( + effectivePadding.bottomRight.dx, + effectivePadding.bottomRight.dy, + ), + ); + } + + await tester.pumpWidget( + boilerplate( + child: CupertinoSegmentedControl<int>( + key: key, + children: children, + onValueChanged: (int newValue) {}, + ), + ), + ); + + // Default padding works. + await verifyPadding(); + + // Switch to Child 2 padding should remain the same. + await tester.tap(find.text('Child 2')); + await tester.pumpAndSettle(); + + await verifyPadding(); + + await tester.pumpWidget( + boilerplate( + child: CupertinoSegmentedControl<int>( + key: key, + padding: const EdgeInsets.fromLTRB(1, 3, 5, 7), + children: children, + onValueChanged: (int newValue) {}, + ), + ), + ); + + // Custom padding works. + await verifyPadding(padding: const EdgeInsets.fromLTRB(1, 3, 5, 7)); + + // Switch back to Child 1 padding should remain the same. + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + + await verifyPadding(padding: const EdgeInsets.fromLTRB(1, 3, 5, 7)); + }); + + testWidgets('Value attribute must be the key of one of the children widgets', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + await expectLater( + () => tester.pumpWidget( + boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) {}, + groupValue: 2, + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('children'), + ), + ), + ); + }); + + testWidgets('Widgets have correct default text/icon styles, change correctly on selection', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Icon(IconData(1)); + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + + DefaultTextStyle textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1')); + IconTheme iconTheme = tester.widget(find.widgetWithIcon(IconTheme, const IconData(1))); + + expect(textStyle.style.color, isSameColorAs(CupertinoColors.white)); + expect(iconTheme.data.color, CupertinoColors.activeBlue); + + await tester.tap(find.widgetWithIcon(IconTheme, const IconData(1))); + await tester.pumpAndSettle(); + + textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1')); + iconTheme = tester.widget(find.widgetWithIcon(IconTheme, const IconData(1))); + + expect(textStyle.style.color, CupertinoColors.activeBlue); + expect(iconTheme.data.color, isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Segmented controls respects themes', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Icon(IconData(1)); + + var sharedValue = 0; + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ); + }, + ), + ), + ); + + DefaultTextStyle textStyle = tester.widget( + find.widgetWithText(DefaultTextStyle, 'Child 1').first, + ); + IconThemeData iconTheme = IconTheme.of(tester.element(find.byIcon(const IconData(1)))); + + expect(textStyle.style.color, isSameColorAs(CupertinoColors.white)); + expect(iconTheme.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + + await tester.tap(find.byIcon(const IconData(1))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1').first); + iconTheme = IconTheme.of(tester.element(find.byIcon(const IconData(1)))); + + expect(textStyle.style.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + expect(iconTheme.color, isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('SegmentedControl is correct when user provides custom colors', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Icon(IconData(1)); + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + unselectedColor: CupertinoColors.lightBackgroundGray, + selectedColor: CupertinoColors.activeGreen.color, + borderColor: CupertinoColors.black, + pressedColor: const Color(0x638CFC7B), + ), + ); + }, + ), + ); + + DefaultTextStyle textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1')); + IconTheme iconTheme = tester.widget(find.widgetWithIcon(IconTheme, const IconData(1))); + + expect(getBorderColor(tester), CupertinoColors.black); + expect(textStyle.style.color, CupertinoColors.lightBackgroundGray); + expect(iconTheme.data.color, CupertinoColors.activeGreen.color); + expect(getBackgroundColor(tester, 0), CupertinoColors.activeGreen.color); + expect(getBackgroundColor(tester, 1), CupertinoColors.lightBackgroundGray); + + await tester.tap(find.widgetWithIcon(IconTheme, const IconData(1))); + await tester.pumpAndSettle(); + + textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1')); + iconTheme = tester.widget(find.widgetWithIcon(IconTheme, const IconData(1))); + + expect(textStyle.style.color, CupertinoColors.activeGreen.color); + expect(iconTheme.data.color, CupertinoColors.lightBackgroundGray); + expect(getBackgroundColor(tester, 0), CupertinoColors.lightBackgroundGray); + expect(getBackgroundColor(tester, 1), CupertinoColors.activeGreen.color); + + final Offset center = tester.getCenter(find.text('Child 1')); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(getBackgroundColor(tester, 0), const Color(0x638CFC7B)); + expect(getBackgroundColor(tester, 1), CupertinoColors.activeGreen.color); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Widgets are centered within segments', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 200.0, + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) {}, + ), + ), + ), + ), + ); + + // Widgets are centered taking into account 16px of horizontal padding + expect(tester.getCenter(find.text('Child 1')), const Offset(58.0, 100.0)); + expect(tester.getCenter(find.text('Child 2')), const Offset(142.0, 100.0)); + }); + + testWidgets('Tap calls onValueChanged', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + var value = false; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + value = true; + }, + ), + ); + }, + ), + ); + + expect(value, isFalse); + + await tester.tap(find.text('Child 2')); + + expect(value, isTrue); + }); + + testWidgets('State does not change if onValueChanged does not call setState()', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + const sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) {}, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Background color of child should change on selection, ' + 'and should not change when tapped again', (WidgetTester tester) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + await tester.tap(find.text('Child 2')); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + }); + + testWidgets('Children can be non-Text or Icon widgets (in this case, ' + 'a Container or Placeholder widget)', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = Container(constraints: const BoxConstraints.tightFor(width: 50.0, height: 50.0)); + children[2] = const Placeholder(); + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + }); + + testWidgets('Passed in value is child initially selected', (WidgetTester tester) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + expect(getSelectedIndex(tester), 0); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Null input for value results in no child initially selected', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + int? sharedValue; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(getSelectedIndex(tester), null); + + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Long press changes background color of not-selected child', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + final Offset center = tester.getCenter(find.text('Child 2')); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), const Color(0x33007aff)); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Long press does not change background color of currently-selected child', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + final Offset center = tester.getCenter(find.text('Child 1')); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Height of segmented control is determined by tallest widget', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = Container(constraints: const BoxConstraints.tightFor(height: 100.0)); + children[1] = Container(constraints: const BoxConstraints.tightFor(height: 400.0)); + children[2] = Container(constraints: const BoxConstraints.tightFor(height: 200.0)); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) {}, + ), + ); + }, + ), + ); + + final RenderBox buttonBox = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + expect(buttonBox.size.height, 400.0); + }); + + testWidgets('Width of each segmented control segment is determined by widest widget', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = Container(constraints: const BoxConstraints.tightFor(width: 50.0)); + children[1] = Container(constraints: const BoxConstraints.tightFor(width: 100.0)); + children[2] = Container(constraints: const BoxConstraints.tightFor(width: 200.0)); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) {}, + ), + ); + }, + ), + ); + + final RenderBox segmentedControl = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + // Subtract the 16.0px from each side. Remaining width should be allocated + // to each child equally. + final double childWidth = (segmentedControl.size.width - 32.0) / 3; + + expect(childWidth, 200.0); + + expect(childWidth, getSurroundingShape(tester).width); + expect(childWidth, getSurroundingShape(tester, child: 1).width); + expect(childWidth, getSurroundingShape(tester, child: 2).width); + }); + + testWidgets('Width is finite in unbounded space', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: Row( + children: <Widget>[ + CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) {}, + ), + ], + ), + ); + }, + ), + ); + + final RenderBox segmentedControl = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + expect(segmentedControl.size.width.isFinite, isTrue); + }); + + testWidgets('Directionality test - RTL should reverse order of widgets', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) {}, + ), + ), + ), + ); + + expect( + tester.getTopRight(find.text('Child 1')).dx > tester.getTopRight(find.text('Child 2')).dx, + isTrue, + ); + }); + + testWidgets('Correct initial selection and toggling behavior - RTL', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ), + ); + }, + ), + ); + + expect(getBackgroundColor(tester, 0), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + await tester.tap(find.text('Child 2')); + await tester.pumpAndSettle(); + + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + }); + + testWidgets('Segmented control semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ), + ); + }, + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + role: SemanticsRole.radioGroup, + children: <TestSemantics>[ + TestSemantics( + label: 'Child 1', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + TestSemantics( + label: 'Child 2', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + // Declares that it is selectable, but not currently selected. + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + role: SemanticsRole.radioGroup, + children: <TestSemantics>[ + TestSemantics( + label: 'Child 1', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + // Declares that it is selectable, but not currently selected. + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + TestSemantics( + label: 'Child 2', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + SemanticsFlag.isFocused, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Non-centered taps work on smaller widgets', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + + var sharedValue = 1; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(sharedValue, 1); + + final double childWidth = getChildSize(tester).width; + final Offset centerOfSegmentedControl = tester.getCenter(find.text('Child 1')); + + // Tap just inside segment bounds + await tester.tapAt( + Offset(centerOfSegmentedControl.dx + (childWidth / 2) - 10.0, centerOfSegmentedControl.dy), + ); + + expect(sharedValue, 0); + }); + + testWidgets('Hit-tests report accurate local position in segments', (WidgetTester tester) async { + final children = <int, Widget>{}; + late TapDownDetails tapDownDetails; + children[0] = GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) { + tapDownDetails = details; + }, + child: const SizedBox(width: 200, height: 200), + ); + children[1] = const Text('Child 2'); + + var sharedValue = 1; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(sharedValue, 1); + + final Offset segment0GlobalOffset = tester.getTopLeft(find.byWidget(children[0]!)); + await tester.tapAt(segment0GlobalOffset + const Offset(7, 11)); + + expect(tapDownDetails.localPosition, const Offset(7, 11)); + expect(tapDownDetails.globalPosition, segment0GlobalOffset + const Offset(7, 11)); + }); + + testWidgets('Segment still hittable with a child that has no hitbox', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/57326. + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const SizedBox(); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(sharedValue, 0); + + final Offset centerOfTwo = tester.getCenter(find.byWidget(children[1]!)); + // Tap within the bounds of children[1], but not at the center. + // children[1] is a SizedBox thus not hittable by itself. + await tester.tapAt(centerOfTwo + const Offset(10, 0)); + + expect(sharedValue, 1); + }); + + testWidgets('Animation is correct when the selected segment changes', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + await tester.tap(find.text('Child 2')); + + await tester.pump(); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.activeBlue)); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x33007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff3d9aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x64007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff7bbaff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x95007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xffb9daff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0xc7007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xfff7faff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0xf8007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.activeBlue)); + }); + + testWidgets('Animation is correct when widget is rebuilt', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + await tester.tap(find.text('Child 2')); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.activeBlue)); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x33007aff))); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + duration: const Duration(milliseconds: 40), + ); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff3d9aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x64007aff))); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + duration: const Duration(milliseconds: 40), + ); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff7bbaff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x95007aff))); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + duration: const Duration(milliseconds: 40), + ); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xffb9daff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0xc7007aff))); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + duration: const Duration(milliseconds: 40), + ); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xfff7faff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0xf8007aff))); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + duration: const Duration(milliseconds: 40), + ); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.activeBlue)); + }); + + testWidgets('Multiple segments are pressed', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('B'))); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + expect(getBackgroundColor(tester, 1), const Color(0x33007aff)); + expect(getBackgroundColor(tester, 2), isSameColorAs(CupertinoColors.white)); + + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('C'))); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + // Press on C has no effect while B is held down. + expect(getBackgroundColor(tester, 1), const Color(0x33007aff)); + expect(getBackgroundColor(tester, 2), isSameColorAs(CupertinoColors.white)); + + // Finish gesture to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Transition is triggered while a transition is already occurring', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + await tester.tap(find.text('B')); + + await tester.pump(); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.activeBlue)); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x33007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff3d9aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x64007aff))); + + // While A to B transition is occurring, press on C. + await tester.tap(find.text('C')); + + await tester.pump(); + + // A and B are now both transitioning to white. + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff3d9aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0xffc1deff))); + expect(getBackgroundColor(tester, 2), isSameColorAs(const Color(0x33007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + // B background color has reached unselected state. + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff7bbaff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 2), isSameColorAs(const Color(0x64007aff))); + + await tester.pump(const Duration(milliseconds: 100)); + // A background color has reached unselected state. + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 2), isSameColorAs(const Color(0xe0007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + // C background color has reached selected state. + expect(getBackgroundColor(tester, 2), isSameColorAs(CupertinoColors.activeBlue)); + }); + + testWidgets('Segment is selected while it is transitioning to unselected state', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + await tester.tap(find.text('Child 2')); + + await tester.pump(); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.activeBlue)); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x33007aff))); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff3d9aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x64007aff))); + + // While A to B transition is occurring, press on A again. + await tester.tap(find.text('Child 1')); + + await tester.pump(); + + // Both transitions start to reverse. + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xcd007aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0xffc1deff))); + + await tester.pump(const Duration(milliseconds: 40)); + // A and B finish transitioning. + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.activeBlue)); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Add segment while animation is running', (WidgetTester tester) async { + var children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + if (sharedValue == 1) { + children = Map<int, Widget>.of(children); + children[3] = const Text('D'); + } + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + await tester.tap(find.text('B')); + + await tester.pump(); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 3), isSameColorAs(CupertinoColors.white)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 3), isSameColorAs(CupertinoColors.white)); + + await tester.pump(const Duration(milliseconds: 150)); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), CupertinoColors.activeBlue); + expect(getBackgroundColor(tester, 3), isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Remove segment while animation is running', (WidgetTester tester) async { + var children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + if (sharedValue == 1) { + children.remove(2); + children = Map<int, Widget>.of(children); + } + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(getChildCount(tester), 3); + + await tester.tap(find.text('B')); + + await tester.pump(); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x33007aff))); + expect(getChildCount(tester), 2); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 1), isSameColorAs(const Color(0x64007aff))); + + await tester.pump(const Duration(milliseconds: 150)); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.activeBlue)); + }); + + testWidgets('Remove currently animating segment', (WidgetTester tester) async { + var children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + int? sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + if (sharedValue == 1) { + children.remove(1); + children = Map<int, Widget>.of(children); + sharedValue = null; + } + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(getChildCount(tester), 3); + + await tester.tap(find.text('B')); + + await tester.pump(); + expect(getChildCount(tester), 2); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff3d9aff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + await tester.pump(const Duration(milliseconds: 40)); + expect(getBackgroundColor(tester, 0), isSameColorAs(const Color(0xff7bbaff))); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + }); + + // Regression test: https://github.com/flutter/flutter/issues/43414. + testWidgets("Quick double tap doesn't break the internal state", (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + await tester.tap(find.text('B')); + // sharedValue has been updated but widget.groupValue is not. + expect(sharedValue, 1); + + // Land the second tap before the widget gets a chance to rebuild. + final TestGesture secondTap = await tester.startGesture(tester.getCenter(find.text('B'))); + await tester.pump(); + + await secondTap.up(); + expect(sharedValue, 1); + + await tester.tap(find.text('C')); + expect(sharedValue, 2); + }); + + testWidgets('Golden Test Placeholder Widget', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = Container(); + children[1] = const Placeholder(); + children[2] = Container(); + + const currentValue = 0; + + await tester.pumpWidget( + RepaintBoundary( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: SizedBox( + width: 800.0, + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) {}, + groupValue: currentValue, + ), + ), + ); + }, + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('segmented_control_test.0.png'), + ); + }); + + testWidgets('Golden Test Pressed State', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + + const currentValue = 0; + + await tester.pumpWidget( + RepaintBoundary( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: SizedBox( + width: 800.0, + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) {}, + groupValue: currentValue, + ), + ), + ); + }, + ), + ), + ); + + final Offset center = tester.getCenter(find.text('B')); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('segmented_control_test.1.png'), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Hovering over Cupertino segmented control updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('A'); + children[1] = const Text('B'); + children[2] = const Text('C'); + + const currentValue = 0; + + await tester.pumpWidget( + RepaintBoundary( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: SizedBox( + width: 800.0, + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: (int newValue) {}, + groupValue: currentValue, + ), + ), + ); + }, + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset firstChild = tester.getCenter(find.text('A')); + await gesture.moveTo(firstChild); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('Tap on disabled segment should not change its state', (WidgetTester tester) async { + final children = <int, Widget>{ + 0: const Text('Child 1'), + 1: const Text('Child 2'), + 2: const Text('Child 3'), + }; + + final disabledChildren = <int>{1}; + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + disabledChildren: disabledChildren, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(sharedValue, 0); + + await tester.tap(find.text('Child 2')); + await tester.pumpAndSettle(); + + expect(sharedValue, 0); + }); + + testWidgets('Background color of disabled segment should be different than enabled segment', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{0: const Text('Child 1'), 1: const Text('Child 2')}; + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + disabledChildren: const <int>{0}, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + // Colors are different for disabled and enabled segments in initial state. + // By default, the first segment is selected (and also is disabled in this test), + // it should have a blue background (selected color) with 50% opacity + expect( + getBackgroundColor(tester, 0), + isSameColorAs(CupertinoColors.systemBlue.withOpacity(0.5)), + ); + expect(getBackgroundColor(tester, 1), isSameColorAs(CupertinoColors.white)); + + // Tap on disabled segment should not change its color + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + + expect( + getBackgroundColor(tester, 0), + isSameColorAs(CupertinoColors.systemBlue.withOpacity(0.5)), + ); + + // When tapping on another enabled segment, the first disabled segment is not selected anymore, + // it should have a white background (same to unselected color). + await tester.tap(find.text('Child 2')); + await tester.pumpAndSettle(); + + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.white)); + }); + + testWidgets('Custom disabled color of disabled segment is showing as desired', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{ + 0: const Text('Child 1'), + 1: const Text('Child 2'), + 2: const Text('Child 3'), + }; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + disabledChildren: const <int>{0}, + onValueChanged: (int newValue) {}, + disabledColor: CupertinoColors.systemGrey2, + ), + ); + }, + ), + ); + + expect(getBackgroundColor(tester, 0), isSameColorAs(CupertinoColors.systemGrey2)); + }); + + testWidgets('Segmented control can use arrow keys', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + children[2] = const Text('Child 3'); + + var sharedValue = 0; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + ), + ); + }, + ), + ); + + expect(sharedValue, 0); + + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + expect(sharedValue, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(sharedValue, 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(sharedValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(sharedValue, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(sharedValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(sharedValue, 1); + }); + + testWidgets('Segmented control skips disabled segments with keyboard', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + children[2] = const Text('Child 3'); + + var sharedValue = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: CupertinoSegmentedControl<int>( + children: children, + onValueChanged: (int newValue) { + setState(() { + sharedValue = newValue; + }); + }, + groupValue: sharedValue, + disabledChildren: const <int>{1}, + ), + ); + }, + ), + ); + + expect(sharedValue, 0); + + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(sharedValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(sharedValue, 0); + }); + + testWidgets('CupertinoSegmentedControl does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoSegmentedControl<int>( + onValueChanged: (_) {}, + children: const <int, Widget>{1: Text('X'), 2: Text('Y')}, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoSegmentedControl<int>)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/sheet_test.dart b/packages/cupertino_ui/test/cupertino/sheet_test.dart new file mode 100644 index 000000000000..00052154d0bb --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/sheet_test.dart @@ -0,0 +1,2275 @@ +// 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/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'navigator_utils.dart'; + +// Matches _kTopGapRatio in cupertino/sheet.dart. +const double _kTopGapRatio = 0.08; + +void main() { + testWidgets('Sheet route does not cover the whole screen', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + }); + + testWidgets('showDragHandle adds a drag handle to the top of the sheet', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + showDragHandle: true, + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + final Finder dragHandleFinder = find.byWidgetPredicate((Widget widget) { + return widget is DecoratedBox && + widget.decoration is ShapeDecoration && + (widget.decoration as ShapeDecoration).color == CupertinoColors.tertiaryLabel; + }); + expect(dragHandleFinder, findsOneWidget); + }); + + testWidgets('showDragHandle adds a MediaQuery padding so content can render below the handle', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + showDragHandle: true, + builder: (BuildContext context) { + return const CupertinoPageScaffold( + child: SafeArea(child: Text('Page 2')), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + final Finder dragHandleFinder = find.byWidgetPredicate((Widget widget) { + return widget is DecoratedBox && + widget.decoration is ShapeDecoration && + (widget.decoration as ShapeDecoration).color == CupertinoColors.tertiaryLabel; + }); + + final Offset dragHandleOffset = tester.getTopLeft(dragHandleFinder); + final Offset sheetContentOffset = tester.getTopLeft(find.text('Page 2')); + expect(sheetContentOffset.dy, greaterThan(dragHandleOffset.dy)); + }); + + testWidgets('Previous route moves slight downward when sheet route is pushed', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + // Previous page is still visible behind the new sheet. + expect(find.text('Page 1'), findsOneWidget); + final Offset pageOneOffset = tester.getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ); + expect(pageOneOffset.dy, greaterThan(0.0)); + expect(pageOneOffset.dx, greaterThan(0.0)); + expect(find.text('Page 2'), findsOneWidget); + final double pageTwoYOffset = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + expect(pageTwoYOffset, greaterThan(pageOneOffset.dy)); + }); + + testWidgets('If a sheet covers another sheet, then the previous sheet moves slightly upwards', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 3')); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + final double previousPageTwoDY = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 3'), findsOneWidget); + expect(previousPageTwoDY, greaterThan(0.0)); + expect( + previousPageTwoDY, + greaterThan( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + ), + ); + }); + + testWidgets('by default showCupertinoSheet does not enable nested navigation', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: <Widget>[ + const Text('Page 3'), + CupertinoButton(onPressed: () {}, child: const Text('Pop Page 3')), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsOneWidget); + // New route should be at the top of the screen. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + }); + + testWidgets('useNestedNavigation set to true enables nested navigation', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + context, + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: <Widget>[ + const Text('Page 3'), + CupertinoButton(onPressed: () {}, child: const Text('Pop Page 3')), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + final double pageTwoDY = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + expect(pageTwoDY, greaterThan(0.0)); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsOneWidget); + + // New route should be at the same height as the previous route. + final double pageThreeDY = tester + .getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + expect(pageThreeDY, greaterThan(0.0)); + expect(pageThreeDY, equals(pageTwoDY)); + }); + + testWidgets('useNestedNavigation handles programmatic pops', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('Go Back'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + // The first page is at the top of the screen. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + // The first page, which is behind the top sheet but still partially visibile, is moved downwards. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Go Back')); + await tester.pumpAndSettle(); + + // The first page would correctly transition back and sit at the top of the screen. + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('useNestedNavigation handles system pop gestures', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: <Widget>[ + const Text('Page 3'), + CupertinoButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Go back'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + // The first page is at the top of the screen. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + // The first page, which is behind the top sheet but still partially visibile, is moved downwards. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 3'), findsOneWidget); + + // Simulate a system back gesture. + await simulateSystemBack(); + await tester.pumpAndSettle(); + + // Go back to the first page within the sheet. + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + // The first page is still stacked behind the sheet. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + // The first page would correctly transition back and sit at the top of the screen. + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('sheet has route settings', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + initialRoute: '/', + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/') { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('Page 1')), + child: Container(), + ); + }, + ); + } + return CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text('Page: ${settings.name}')), + child: Container(), + ); + }, + ); + }, + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pumpAndSettle(); + + expect(find.text('Page: /next'), findsOneWidget); + }); + + testWidgets('sheet with RouteSettings', (WidgetTester tester) async { + late RouteSettings currentRouteSetting; + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + navigatorObservers: <NavigatorObserver>[ + _ClosureNavigatorObserver( + onDidChange: (Route<dynamic> newRoute) { + currentRouteSetting = newRoute.settings; + }, + ), + ], + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + settings: const RouteSettings(name: 'simpleroute'), + context: scaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Hello')); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(currentRouteSetting.name, '/'); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Hello'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + expect(find.text('Hello'), findsOneWidget); + expect(currentRouteSetting.name, 'simpleroute'); + + Navigator.of(scaffoldKey.currentContext!).pop(); + await tester.pumpAndSettle(); + expect(currentRouteSetting.name, '/'); + }); + + testWidgets('content does not go below the bottom of the screen', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold(child: Container()); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(Container)).height, 600.0 - (600.0 * _kTopGapRatio)); + }); + + testWidgets('nested navbars remove MediaQuery top padding', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + final GlobalKey appBarKey = GlobalKey(); + final GlobalKey sheetBarKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(0, 20, 0, 0)), + child: CupertinoPageScaffold( + key: scaffoldKey, + navigationBar: CupertinoNavigationBar( + key: appBarKey, + middle: const Text('Navbar'), + backgroundColor: const Color(0xFFF8F8F8), + ), + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + key: sheetBarKey, + middle: const Text('Navbar'), + ), + child: Container(), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ), + ); + final double homeNavBardHeight = tester.getSize(find.byKey(appBarKey)).height; + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final double sheetNavBarHeight = tester.getSize(find.byKey(sheetBarKey)).height; + + expect(sheetNavBarHeight, lessThan(homeNavBardHeight)); + }); + + testWidgets('Previous route corner radius goes to same when sheet route is popped', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon(CupertinoIcons.back), + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.byType(Icon), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + // Previous page is still visible behind the new sheet. + expect(find.text('Page 1'), findsOneWidget); + final Offset pageOneOffset = tester.getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ); + expect(pageOneOffset.dy, greaterThan(0.0)); + expect(pageOneOffset.dx, greaterThan(0.0)); + expect(find.byType(Icon), findsOneWidget); + + // Pop Sheet Route + await tester.tap(find.byType(Icon)); + await tester.pumpAndSettle(); + + expect(find.byType(ClipRSuperellipse), findsNothing); + expect(find.byType(ClipRRect), findsNothing); + }); + + testWidgets('Sheet transition does not interfere after popping', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + localizationsDelegates: const <LocalizationsDelegate<dynamic>>[ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: CupertinoPageScaffold( + key: homeKey, + child: CupertinoListTile( + onTap: () { + showCupertinoSheet<void>( + context: homeKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetKey, + child: const Center(child: Text('Page 2')), + ); + }, + ); + }, + title: const Text('ListItem 0'), + trailing: Builder( + builder: (context) { + return CupertinoContextMenu( + actions: [ + CupertinoContextMenuAction( + child: const Text('Item 0'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + child: const Text('Button'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('ListItem 0')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final TestGesture gestureOne = await tester.startGesture(const Offset(100, 200)); + await gestureOne.moveBy(const Offset(0, 350)); + await tester.pump(); + + await gestureOne.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('ListItem 0'), findsOneWidget); + + final Offset contextMenuButton = tester.getCenter(find.text('Button')); + expect(find.text('Item 0'), findsNothing); + + final TestGesture gestureTwo = await tester.startGesture(contextMenuButton); + await tester.pumpAndSettle(); + expect(find.text('Item 0'), findsOneWidget); + expect(tester.takeException(), isNull); + + await tester.tap(find.text('Item 0')); + await tester.pumpAndSettle(); + + await gestureTwo.up(); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + expect(tester.takeException(), isNull); + }); + + group('drag dismiss gesture', () { + Widget dragGestureApp(GlobalKey homeScaffoldKey, GlobalKey sheetScaffoldKey) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: homeScaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: homeScaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetScaffoldKey, + child: const Center(child: Text('Page 2')), + ); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + } + + testWidgets('partial drag and drop does not pop the sheet', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialPosition = box.localToGlobal(Offset.zero).dy; + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + // Partial drag down + await gesture.moveBy(const Offset(0, 200)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double middlePosition = box.localToGlobal(Offset.zero).dy; + expect(middlePosition, greaterThan(initialPosition)); + + // Release gesture. Sheet should not pop and slide back up. + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalPosition = box.localToGlobal(Offset.zero).dy; + + expect(finalPosition, lessThan(middlePosition)); + expect(finalPosition, equals(initialPosition)); + }); + + testWidgets('dropping the drag further down the page pops the sheet', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + await gesture.moveBy(const Offset(0, 350)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('dismissing with a drag pops all nested routes', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 3'))); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: homeKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: homeKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetKey, + child: sheetScaffoldContent(context), + ); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 3'), findsOneWidget); + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + await gesture.moveBy(const Offset(0, 350)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsNothing); + }); + + testWidgets('Popping the sheet during drag should not crash', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(); + + await gesture.down(const Offset(100, 200)); + + // Need 2 events to form a valid drag + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(const Offset(100, 300), timeStamp: const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(const Offset(100, 500), timeStamp: const Duration(milliseconds: 200)); + + Navigator.of(homeKey.currentContext!).pop(); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsOneWidget); + }); + + testWidgets('Sheet should not block nested scroll', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return ListView( + children: const <Widget>[ + Text('Top of Scroll'), + SizedBox(width: double.infinity, height: 100), + Text('Middle of Scroll'), + SizedBox(width: double.infinity, height: 100), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: homeKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: homeKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Top of Scroll'), findsOneWidget); + final double startPosition = tester.getTopLeft(find.text('Middle of Scroll')).dy; + + final TestGesture gesture = await tester.createGesture(); + + await gesture.down(const Offset(100, 100)); + + // Need 2 events to form a valid drag. + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(const Offset(100, 80), timeStamp: const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(const Offset(100, 50), timeStamp: const Duration(milliseconds: 200)); + + await tester.pumpAndSettle(); + + final double endPosition = tester.getTopLeft(find.text('Middle of Scroll')).dy; + + // Final position should be higher. + expect(endPosition, lessThan(startPosition)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('drag dismiss uses route navigator instead of root navigator', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey nestedNavigatorKey = GlobalKey<NavigatorState>(); + final GlobalKey sheetKey = GlobalKey(); + var wasPopped = false; + var rootNavigatorPopped = false; + + await tester.pumpWidget( + CupertinoApp( + home: PopScope( + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + rootNavigatorPopped = true; + } + }, + child: CupertinoPageScaffold( + key: homeKey, + child: Navigator( + key: nestedNavigatorKey, + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + context, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return PopScope( + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + wasPopped = true; + } + }, + child: CupertinoPageScaffold( + key: sheetKey, + child: const Center(child: Text('Page 2')), + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(wasPopped, false); + expect(rootNavigatorPopped, false); + + // Start drag gesture and drag down far enough to trigger dismissal + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + await gesture.moveBy(const Offset(0, 350)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Verify the sheet was dismissed and the PopScope callback was triggered + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 1'), findsOneWidget); + // Verify that the nested navigator was used (sheet PopScope triggered) + // but the root navigator was NOT used (root PopScope not triggered) + expect(wasPopped, true); + expect(rootNavigatorPopped, false); + }); + + testWidgets('dragging does not move the sheet when enableDrag is false', ( + WidgetTester tester, + ) async { + Widget nonDragGestureApp(GlobalKey homeScaffoldKey, GlobalKey sheetScaffoldKey) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: homeScaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: homeScaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetScaffoldKey, + child: const Center(child: Text('Page 2')), + ); + }, + enableDrag: false, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + } + + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(nonDragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialPosition = box.localToGlobal(Offset.zero).dy; + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + // Partial drag down + await gesture.moveBy(const Offset(0, 200)); + await tester.pump(); + + // Release gesture. Sheet should not move. + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double middlePosition = box.localToGlobal(Offset.zero).dy; + + expect(middlePosition, equals(initialPosition)); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalPosition = box.localToGlobal(Offset.zero).dy; + + expect(finalPosition, equals(middlePosition)); + expect(finalPosition, equals(initialPosition)); + }); + + testWidgets('partial upward drag stretches and returns without popping', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialPosition = box.localToGlobal(Offset.zero).dy; + + final TestGesture gesture = await tester.startGesture(const Offset(100, 400)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double stretchedPosition = box.localToGlobal(Offset.zero).dy; + expect(stretchedPosition, lessThan(initialPosition)); + + await gesture.up(); + await tester.pumpAndSettle(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalPosition = box.localToGlobal(Offset.zero).dy; + expect(finalPosition, initialPosition); + }); + }); + + group('draggable scrollable CupertinoSheetRoute', () { + Widget draggableScrollableApp(GlobalKey homeScaffoldKey, GlobalKey sheetScaffoldKey) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: homeScaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: homeScaffoldKey.currentContext!, + scrollableBuilder: (BuildContext context, ScrollController controller) { + return CupertinoPageScaffold( + key: sheetScaffoldKey, + child: CustomScrollView( + controller: controller, + primary: false, + slivers: <Widget>[ + SliverList( + delegate: SliverChildBuilderDelegate(( + BuildContext context, + int index, + ) { + return Container( + alignment: Alignment.center, + height: 100, + child: Text('Scroll Item $index'), + ); + }, childCount: 20), + ), + ], + ), + ); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + } + + testWidgets('Can be scrolled when at full height', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(draggableScrollableApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + final double initialScrollPosition = box.localToGlobal(Offset.zero).dy; + + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + // Do a small drag first to win the gesture arena. + await gesture.moveBy(const Offset(0, -30)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + final double finalScrollPosition = box.localToGlobal(Offset.zero).dy; + + expect(finalSheetPosition, equals(initialSheetPosition)); + expect(finalScrollPosition, lessThan(initialScrollPosition)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Sheet slides down on downwards drag when scrollable content is at the top', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(draggableScrollableApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + final double initialScrollPosition = box.localToGlobal(Offset.zero).dy; + + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + // Do a small drag first to win the gesture arena. + await gesture.moveBy(const Offset(0, 30)); + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + final double finalScrollPosition = box.localToGlobal(Offset.zero).dy; + + expect(finalSheetPosition, greaterThan(initialSheetPosition)); + // Scroll should move down with sheet. + expect(finalScrollPosition, greaterThan(initialScrollPosition)); + expect( + finalScrollPosition - initialScrollPosition, + closeTo(finalSheetPosition - initialSheetPosition, 0.0005), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'While the drag gesture continues, the sheet switches between scrolling and dismiss animation correctly', + (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(draggableScrollableApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + final double initialScrollPosition = box.localToGlobal(Offset.zero).dy; + + // Sheet will scroll on upwards drag. + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + // Do a small drag first to win the gesture arena. + await gesture.moveBy(const Offset(0, -30)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + double currentSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + double currentScrollPosition = box.localToGlobal(Offset.zero).dy; + + // Sheet has not moved, but the scroll was triggered. + expect(currentSheetPosition, equals(initialSheetPosition)); + expect(currentScrollPosition, lessThan(initialScrollPosition)); + + // Drag back down the same amount. + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + currentSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + currentScrollPosition = box.localToGlobal(Offset.zero).dy; + + // Sheet still has not moved, and the scroll returns to it's original spot. + expect(currentSheetPosition, equals(initialSheetPosition)); + expect(currentScrollPosition, equals(initialScrollPosition)); + + // Drag downwards further. + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + currentSheetPosition = box.localToGlobal(Offset.zero).dy; + + box = tester.renderObject(find.text('Scroll Item 3')) as RenderBox; + currentScrollPosition = box.localToGlobal(Offset.zero).dy; + + // Entire sheet will have dragged down. Scrollable content will have moved down with it. + expect(currentSheetPosition, greaterThan(initialSheetPosition)); + expect(currentScrollPosition, greaterThan(initialScrollPosition)); + expect( + currentScrollPosition - initialScrollPosition, + equals(currentSheetPosition - initialSheetPosition), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Fling while scrolled down does not trigger drag pop', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(draggableScrollableApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + // Scroll down some. + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + // Do a small drag first to win the gesture arena. + await gesture.moveBy(const Offset(0, -30)); + await gesture.moveBy(const Offset(0, -400)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Trigger fling up. + await tester.flingFrom(const Offset(100, 400), const Offset(0, 300), 500); + await tester.pumpAndSettle(); + + // Scrollable sheet should still be open. + expect(find.text('Scroll Item 3'), findsOneWidget); + }); + + testWidgets('Fling while scrolled to the top causes the drag fling', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(draggableScrollableApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + // Trigger fling up. + await tester.flingFrom(const Offset(100, 400), const Offset(0, 300), 500); + await tester.pumpAndSettle(); + + // Scrollable sheet should not be open. + expect(find.text('Scroll Item 3'), findsNothing); + }); + }); + + testWidgets('CupertinoSheet causes SystemUiOverlayStyle changes', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + navigationBar: const CupertinoNavigationBar(middle: Text('SystemUiOverlayStyle')), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.light); + expect(SystemChrome.latestStyle!.statusBarIconBrightness, Brightness.dark); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.dark); + expect(SystemChrome.latestStyle!.statusBarIconBrightness, Brightness.light); + + // Returning to the previous page reverts the system UI. + Navigator.of(scaffoldKey.currentContext!).pop(); + await tester.pumpAndSettle(); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.light); + expect(SystemChrome.latestStyle!.statusBarIconBrightness, Brightness.dark); + }); + + testWidgets( + 'content placed in safe area of showCupertinoSheet is rendered within the safe area bounds', + (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return const SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + SizedBox(height: 80, width: double.infinity, child: Text('Top container')), + SizedBox(height: 80, width: double.infinity, child: Text('Bottom container')), + ], + ), + ); + } + + const double bottomPadding = 50; + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.fromLTRB(0, 20, 0, bottomPadding), + viewPadding: const EdgeInsets.fromLTRB(0, 20, 0, bottomPadding), + ), + child: CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final double pageHeight = tester + .getRect( + find.ancestor( + of: find.text('Top container'), + matching: find.byType(CupertinoPageScaffold), + ), + ) + .bottom; + expect( + pageHeight - + tester + .getBottomLeft( + find + .ancestor(of: find.text('Bottom container'), matching: find.byType(SizedBox)) + .first, + ) + .dy, + bottomPadding, + ); + }, + ); + + group('topGap parameter tests', () { + testWidgets('sheet uses default topGap when not specified', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final double sheetTopOffset = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + // Should use default topGap ratio (8% of screen height = 0.08 * 600.0 = 48.0) + expect(sheetTopOffset, equals(600.0 * _kTopGapRatio)); + }); + + testWidgets('sheet with custom topGap uses custom positioning', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + topGap: 0.0, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final double sheetTopOffset = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + expect(sheetTopOffset, equals(0.0)); + }); + + testWidgets('showCupertinoSheet accepts topGap parameter', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + topGap: 0.15, + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final double sheetTopOffset = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + expect(sheetTopOffset, equals(600.0 * 0.15)); + }); + + testWidgets('custom topGap disables delegated transitions', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 3')); + }, + topGap: 0.1, // Custom topGap should disable transitions + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final double pageTwoYBeforePage3 = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + final double pageTwoYAfterPage3 = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + // Page 2 should remain at the same position because custom topGap disables transitions + expect(pageTwoYAfterPage3, equals(pageTwoYBeforePage3)); + + final double pageThreeY = tester + .getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + expect(pageThreeY, equals(600.0 * 0.1)); + }); + + testWidgets('default topGap allows delegated transitions', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: <Widget>[ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + CupertinoSheetRoute<void>( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 3')); + }, + // No topGap specified - should use default and allow transitions + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final double pageTwoYBeforePage3 = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + final double pageTwoYAfterPage3 = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + // Page 2 should move upward because default topGap allows delegated transitions + expect(pageTwoYAfterPage3, lessThan(pageTwoYBeforePage3)); + }); + + testWidgets('topGap affects drag gesture calculations', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget dragGestureAppWithTopGap(double topGap) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet<void>( + context: scaffoldKey.currentContext!, + topGap: topGap, + pageBuilder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 2'))); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + } + + // Test with custom topGap of 0.3 + await tester.pumpWidget(dragGestureAppWithTopGap(0.3)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final double sheetTopOffset = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + expect(sheetTopOffset, equals(600.0 * 0.3)); + + // Test that drag still works with custom topGap + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + final double draggedPosition = tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + // Sheet should move down when dragged + expect(draggedPosition, greaterThan(sheetTopOffset)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + testWidgets('didUpdateWidget in sheet transition does not try and use multiple tickers', ( + WidgetTester tester, + ) async { + final animation = AnimationController(vsync: const TestVSync()); + final secondaryAnimation = AnimationController(vsync: const TestVSync()); + + await tester.pumpWidget( + CupertinoSheetTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: false, + child: const SizedBox(height: 100, width: 100), + ), + ); + + final newAnimation = AnimationController(vsync: const TestVSync()); + + // Should not throw an exception. + await tester.pumpWidget( + CupertinoSheetTransition( + primaryRouteAnimation: newAnimation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: false, + child: const SizedBox(height: 100, width: 100), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + animation.dispose(); + secondaryAnimation.dispose(); + newAnimation.dispose(); + }); +} + +class _ClosureNavigatorObserver extends NavigatorObserver { + _ClosureNavigatorObserver({required this.onDidChange}); + + final void Function(Route<dynamic> newRoute) onDidChange; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => onDidChange(route); + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => onDidChange(previousRoute!); + + @override + void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => + onDidChange(previousRoute!); + + @override + void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => onDidChange(newRoute!); +} diff --git a/packages/cupertino_ui/test/cupertino/slider_test.dart b/packages/cupertino_ui/test/cupertino/slider_test.dart new file mode 100644 index 000000000000..3e26cb8487fa --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/slider_test.dart @@ -0,0 +1,826 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +const CupertinoDynamicColor _kSystemFill = CupertinoDynamicColor( + color: Color.fromARGB(51, 120, 120, 128), + darkColor: Color.fromARGB(91, 120, 120, 128), + highContrastColor: Color.fromARGB(71, 120, 120, 128), + darkHighContrastColor: Color.fromARGB(112, 120, 120, 128), + elevatedColor: Color.fromARGB(51, 120, 120, 128), + darkElevatedColor: Color.fromARGB(91, 120, 120, 128), + highContrastElevatedColor: Color.fromARGB(71, 120, 120, 128), + darkHighContrastElevatedColor: Color.fromARGB(112, 120, 120, 128), +); + +void main() { + Future<void> dragSlider(WidgetTester tester, Key sliderKey) { + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + const double unit = CupertinoThumbPainter.radius; + const double delta = 3.0 * unit; + return tester.dragFrom(topLeft + const Offset(unit, unit), const Offset(delta, 0.0)); + } + + testWidgets('Slider does not move when tapped (LTR)', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey), warnIfMissed: false); + expect(value, equals(0.0)); + await tester.pump(); // No animation should start. + // Check the transientCallbackCount before tearing down the widget to ensure + // that no animation is running. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider does not move when tapped (RTL)', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey), warnIfMissed: false); + expect(value, equals(0.0)); + await tester.pump(); // No animation should start. + // Check the transientCallbackCount before tearing down the widget to ensure + // that no animation is running. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider calls onChangeStart once when interaction begins', ( + WidgetTester tester, + ) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + var numberOfTimesOnChangeStartIsCalled = 0; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeStart: (double value) { + numberOfTimesOnChangeStartIsCalled++; + }, + ), + ); + }, + ), + ), + ), + ); + + await dragSlider(tester, sliderKey); + + expect(numberOfTimesOnChangeStartIsCalled, equals(1)); + + await tester.pump(); // No animation should start. + // Check the transientCallbackCount before tearing down the widget to ensure + // that no animation is running. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider calls onChangeEnd once after interaction has ended', ( + WidgetTester tester, + ) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + var numberOfTimesOnChangeEndIsCalled = 0; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double value) { + numberOfTimesOnChangeEndIsCalled++; + }, + ), + ); + }, + ), + ), + ), + ); + + await dragSlider(tester, sliderKey); + + expect(numberOfTimesOnChangeEndIsCalled, equals(1)); + + await tester.pump(); // No animation should start. + // Check the transientCallbackCount before tearing down the widget to ensure + // that no animation is running. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider moves when dragged (LTR)', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + late double startValue; + late double endValue; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeStart: (double value) { + startValue = value; + }, + onChangeEnd: (double value) { + endValue = value; + }, + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + const double unit = CupertinoThumbPainter.radius; + const double delta = 3.0 * unit; + await tester.dragFrom(topLeft + const Offset(unit, unit), const Offset(delta, 0.0)); + + final Size size = tester.getSize(find.byKey(sliderKey)); + final double finalValue = delta / (size.width - 2.0 * (8.0 + CupertinoThumbPainter.radius)); + expect(startValue, equals(0.0)); + expect(value, equals(finalValue)); + expect(endValue, equals(finalValue)); + + await tester.pump(); // No animation should start. + // Check the transientCallbackCount before tearing down the widget to ensure + // that no animation is running. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets( + 'Slider emits haptic feedback when hitting edge', + (WidgetTester tester) async { + final hapticLog = <MethodCall>[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + hapticLog.add(methodCall); + return null; + }); + + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + // No haptic feedback should be emitted when the slider is created. + expect(hapticLog, hasLength(0)); + + const double unit = CupertinoThumbPainter.radius; + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + Offset thumbCenter = topLeft + const Offset(unit, unit); + const delta = Offset(50.0, 0.0); + await tester.dragFrom(thumbCenter, delta); + await tester.pump(); + + thumbCenter += delta; + + // No haptic feedback should be emitted when the slider is moved. + expect(hapticLog, hasLength(0)); + + // Move the slider to the end quickly. + await tester.timedDragFrom( + thumbCenter, + const Offset(1000.0, 0.0), + const Duration(milliseconds: 100), + ); + + // Medium haptic feedback should be emitted when the slider is quickly moved to the end. + expect(hapticLog, hasLength(1)); + expect( + hapticLog.last, + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'), + ); + + // Move the slider to the start slowly. + thumbCenter = tester.getTopRight(find.byKey(sliderKey)) - const Offset(unit, -unit); + await tester.timedDragFrom( + thumbCenter, + -Offset(thumbCenter.dx - topLeft.dx - unit * 2, 0), + const Duration(milliseconds: 1100), + ); + + expect(value, equals(0.0)); + + // Selection click should be emitted when the slider is slowly moved to the start. + expect(hapticLog, hasLength(2)); + expect( + hapticLog.last, + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Slider does not emit haptic feedback on non-iOS platforms', + (WidgetTester tester) async { + final hapticLog = <MethodCall>[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + hapticLog.add(methodCall); + return null; + }); + + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + const double unit = CupertinoThumbPainter.radius; + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + final Offset thumbCenter = topLeft + const Offset(unit, unit); + + // Move the slider to the end. + await tester.dragFrom(thumbCenter, const Offset(1000.0, 0.0)); + + expect(value, equals(1.0)); + expect(hapticLog, hasLength(0)); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('Slider moves when dragged (RTL)', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + late double startValue; + late double endValue; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeStart: (double value) { + setState(() { + startValue = value; + }); + }, + onChangeEnd: (double value) { + setState(() { + endValue = value; + }); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + + final Offset bottomRight = tester.getBottomRight(find.byKey(sliderKey)); + const double unit = CupertinoThumbPainter.radius; + const double delta = 3.0 * unit; + await tester.dragFrom(bottomRight - const Offset(unit, unit), const Offset(-delta, 0.0)); + + final Size size = tester.getSize(find.byKey(sliderKey)); + final double finalValue = delta / (size.width - 2.0 * (8.0 + CupertinoThumbPainter.radius)); + expect(startValue, equals(0.0)); + expect(value, equals(finalValue)); + expect(endValue, equals(finalValue)); + + await tester.pump(); // No animation should start. + // Check the transientCallbackCount before tearing down the widget to ensure + // that no animation is running. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider Semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoSlider(value: 0.5, onChanged: (double v) {}), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + value: '50%', + increasedValue: '60%', + decreasedValue: '40%', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isSlider], + actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + // Disable slider + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoSlider(value: 0.5, onChanged: null), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics(id: 1, flags: <SemanticsFlag>[SemanticsFlag.isSlider]), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Slider Semantics can be updated', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + var value = 0.5; + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoSlider(value: value, onChanged: (double v) {}), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoSlider)), + matchesSemantics( + isSlider: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '50%', + increasedValue: '60%', + decreasedValue: '40%', + textDirection: TextDirection.ltr, + ), + ); + + value = 0.6; + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoSlider(value: value, onChanged: (double v) {}), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(CupertinoSlider)), + matchesSemantics( + isSlider: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '60%', + increasedValue: '70%', + decreasedValue: '50%', + textDirection: TextDirection.ltr, + ), + ); + + handle.dispose(); + }); + + testWidgets('Slider respects themes', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoSlider(onChanged: (double value) {}, value: 0.5)), + ), + ); + expect( + find.byType(CupertinoSlider), + // First line it paints is blue. + paints..rrect(color: CupertinoColors.systemBlue.color), + ); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoSlider(onChanged: (double value) {}, value: 0.5)), + ), + ); + + expect( + find.byType(CupertinoSlider), + paints..rrect(color: CupertinoColors.systemBlue.darkColor), + ); + }); + + testWidgets('Themes can be overridden', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: CupertinoSlider( + activeColor: CupertinoColors.activeGreen, + onChanged: (double value) {}, + value: 0.5, + ), + ), + ), + ); + expect( + find.byType(CupertinoSlider), + paints..rrect(color: CupertinoColors.systemGreen.darkColor), + ); + }); + + testWidgets('Themes can be overridden by dynamic colors', (WidgetTester tester) async { + const activeColor = CupertinoDynamicColor( + color: Color(0x00000001), + darkColor: Color(0x00000002), + elevatedColor: Color(0x00000003), + highContrastColor: Color(0x00000004), + darkElevatedColor: Color(0x00000005), + darkHighContrastColor: Color(0x00000006), + highContrastElevatedColor: Color(0x00000007), + darkHighContrastElevatedColor: Color(0x00000008), + ); + + Widget withTraits( + Brightness brightness, + CupertinoUserInterfaceLevelData level, + bool highContrast, + ) { + return CupertinoTheme( + data: CupertinoThemeData(brightness: brightness), + child: CupertinoUserInterfaceLevel( + data: level, + child: MediaQuery( + data: MediaQueryData(highContrast: highContrast), + child: Center( + child: CupertinoSlider( + activeColor: activeColor, + onChanged: (double value) {}, + value: 0.5, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + CupertinoApp(home: withTraits(Brightness.light, CupertinoUserInterfaceLevelData.base, false)), + ); + expect(find.byType(CupertinoSlider), paints..rrect(color: activeColor.color)); + + await tester.pumpWidget( + CupertinoApp(home: withTraits(Brightness.dark, CupertinoUserInterfaceLevelData.base, false)), + ); + expect(find.byType(CupertinoSlider), paints..rrect(color: activeColor.darkColor)); + + await tester.pumpWidget( + CupertinoApp( + home: withTraits(Brightness.dark, CupertinoUserInterfaceLevelData.elevated, false), + ), + ); + expect(find.byType(CupertinoSlider), paints..rrect(color: activeColor.darkElevatedColor)); + + await tester.pumpWidget( + CupertinoApp(home: withTraits(Brightness.dark, CupertinoUserInterfaceLevelData.base, true)), + ); + expect(find.byType(CupertinoSlider), paints..rrect(color: activeColor.darkHighContrastColor)); + + await tester.pumpWidget( + CupertinoApp( + home: withTraits(Brightness.dark, CupertinoUserInterfaceLevelData.elevated, true), + ), + ); + expect( + find.byType(CupertinoSlider), + paints..rrect(color: activeColor.darkHighContrastElevatedColor), + ); + + await tester.pumpWidget( + CupertinoApp(home: withTraits(Brightness.light, CupertinoUserInterfaceLevelData.base, true)), + ); + expect(find.byType(CupertinoSlider), paints..rrect(color: activeColor.highContrastColor)); + + await tester.pumpWidget( + CupertinoApp( + home: withTraits(Brightness.light, CupertinoUserInterfaceLevelData.elevated, false), + ), + ); + expect(find.byType(CupertinoSlider), paints..rrect(color: activeColor.elevatedColor)); + + await tester.pumpWidget( + CupertinoApp( + home: withTraits(Brightness.light, CupertinoUserInterfaceLevelData.elevated, true), + ), + ); + expect( + find.byType(CupertinoSlider), + paints..rrect(color: activeColor.highContrastElevatedColor), + ); + }); + + testWidgets('track color is dynamic', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: Center( + child: CupertinoSlider( + activeColor: CupertinoColors.activeGreen, + onChanged: (double value) {}, + value: 0, + ), + ), + ), + ); + + expect(find.byType(CupertinoSlider), paints..rrect(color: _kSystemFill.color)); + + expect(find.byType(CupertinoSlider), isNot(paints..rrect(color: _kSystemFill.darkColor))); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: CupertinoSlider( + activeColor: CupertinoColors.activeGreen, + onChanged: (double value) {}, + value: 0, + ), + ), + ), + ); + + expect(find.byType(CupertinoSlider), paints..rrect(color: _kSystemFill.darkColor)); + + expect(find.byType(CupertinoSlider), isNot(paints..rrect(color: _kSystemFill.color))); + }); + + testWidgets('Thumb color can be overridden', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSlider( + thumbColor: CupertinoColors.systemPurple, + onChanged: (double value) {}, + value: 0, + ), + ), + ), + ); + + expect( + find.byType(CupertinoSlider), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: CupertinoColors.systemPurple.color), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSlider( + thumbColor: CupertinoColors.activeOrange, + onChanged: (double value) {}, + value: 0, + ), + ), + ), + ); + + expect( + find.byType(CupertinoSlider), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: CupertinoColors.activeOrange.color), + ); + }); + + testWidgets('Hovering over Cupertino slider thumb updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSlider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + await gesture.moveTo(topLeft + const Offset(15, 0)); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CupertinoSlider does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoSlider(value: 0.0, onChanged: (_) {})), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoSlider)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/sliding_segmented_control_test.dart b/packages/cupertino_ui/test/cupertino/sliding_segmented_control_test.dart new file mode 100644 index 000000000000..28f9073fb6ad --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/sliding_segmented_control_test.dart @@ -0,0 +1,2278 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:collection'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +RenderBox getRenderSegmentedControl(WidgetTester tester) { + return tester.allRenderObjects.firstWhere((RenderObject currentObject) { + return currentObject.toStringShort().contains('_RenderSegmentedControl'); + }) + as RenderBox; +} + +Rect currentUnscaledThumbRect(WidgetTester tester, {bool useGlobalCoordinate = false}) { + final dynamic renderSegmentedControl = getRenderSegmentedControl(tester); + // Using dynamic to access private class in test. + // ignore: avoid_dynamic_calls + final local = renderSegmentedControl.currentThumbRect as Rect; + if (!useGlobalCoordinate) { + return local; + } + + final segmentedControl = renderSegmentedControl as RenderBox; + return local.shift(segmentedControl.localToGlobal(Offset.zero)); +} + +int? getHighlightedIndex(WidgetTester tester) { + return (getRenderSegmentedControl(tester) as dynamic).highlightedIndex as int?; +} + +Color getThumbColor(WidgetTester tester) { + return (getRenderSegmentedControl(tester) as dynamic).thumbColor as Color; +} + +double currentThumbScale(WidgetTester tester) { + return (getRenderSegmentedControl(tester) as dynamic).thumbScale as double; +} + +Widget setupSimpleSegmentedControl() { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + + return boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ); +} + +StateSetter? setState; +int? groupValue = 0; +void defaultCallback(int? newValue) { + setState!(() { + groupValue = newValue; + }); +} + +Widget boilerplate({required WidgetBuilder builder}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return builder(context); + }, + ), + ), + ); +} + +void main() { + setUp(() { + setState = null; + groupValue = 0; + }); + + testWidgets('Need at least 2 children', (WidgetTester tester) async { + groupValue = null; + await expectLater( + () => tester.pumpWidget( + CupertinoSlidingSegmentedControl<int>( + children: const <int, Widget>{}, + groupValue: groupValue, + onValueChanged: defaultCallback, + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('children.length'), + ), + ), + ); + + await expectLater( + () => tester.pumpWidget( + CupertinoSlidingSegmentedControl<int>( + children: const <int, Widget>{0: Text('Child 1')}, + groupValue: groupValue, + onValueChanged: defaultCallback, + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('children.length'), + ), + ), + ); + + groupValue = -1; + await expectLater( + () => tester.pumpWidget( + CupertinoSlidingSegmentedControl<int>( + children: const <int, Widget>{0: Text('Child 1'), 1: Text('Child 2'), 2: Text('Child 3')}, + groupValue: groupValue, + onValueChanged: defaultCallback, + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains('groupValue must be either null or one of the keys in the children map'), + ), + ), + ); + }); + + testWidgets('Padding works', (WidgetTester tester) async { + const key = Key('Container'); + + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + + Future<void> verifyPadding({EdgeInsets? padding}) async { + final EdgeInsets effectivePadding = + padding ?? const EdgeInsets.symmetric(vertical: 2, horizontal: 3); + final Rect segmentedControlRect = tester.getRect(find.byKey(key)); + + expect( + tester.getTopLeft( + find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData)), + ), + segmentedControlRect.topLeft + effectivePadding.topLeft, + ); + expect( + tester.getBottomLeft( + find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData)), + ), + segmentedControlRect.bottomLeft + effectivePadding.bottomLeft, + ); + + expect( + tester.getTopRight( + find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(MetaData)), + ), + segmentedControlRect.topRight + effectivePadding.topRight, + ); + expect( + tester.getBottomRight( + find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(MetaData)), + ), + segmentedControlRect.bottomRight + effectivePadding.bottomRight, + ); + } + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: key, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + // Default padding works. + await verifyPadding(); + + // Switch to Child 2 padding should remain the same. + await tester.tap(find.text('Child 2')); + await tester.pumpAndSettle(); + + await verifyPadding(); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: key, + padding: const EdgeInsets.fromLTRB(1, 3, 5, 7), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + // Custom padding works. + await verifyPadding(padding: const EdgeInsets.fromLTRB(1, 3, 5, 7)); + + // Switch back to Child 1 padding should remain the same. + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + + await verifyPadding(padding: const EdgeInsets.fromLTRB(1, 3, 5, 7)); + }); + + testWidgets('Tap changes toggle state', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2'), 2: Text('Child 3')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + await tester.tap(find.text('Child 2')); + + expect(groupValue, 1); + + // Tapping the currently selected item should not change groupValue. + await tester.tap(find.text('Child 2')); + + expect(groupValue, 1); + }); + + testWidgets('Segmented controls respect theme', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Icon(IconData(1))}; + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ), + ); + + DefaultTextStyle textStyle = tester.widget( + find.widgetWithText(DefaultTextStyle, 'Child 1').first, + ); + + expect(textStyle.style.fontWeight, FontWeight.w600); + + await tester.tap(find.byIcon(const IconData(1))); + await tester.pump(); + await tester.pumpAndSettle(); + + textStyle = tester.widget(find.widgetWithText(DefaultTextStyle, 'Child 1').first); + + expect(groupValue, 1); + expect(textStyle.style.fontWeight, FontWeight.w500); + }); + + testWidgets('SegmentedControl dark mode', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Icon(IconData(1))}; + + Brightness brightness = Brightness.light; + late StateSetter setState; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MediaQuery( + data: MediaQueryData(platformBrightness: brightness), + child: boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + thumbColor: CupertinoColors.systemGreen, + backgroundColor: CupertinoColors.systemRed, + ); + }, + ), + ); + }, + ), + ); + + final decoration = + tester + .widget<Container>( + find.descendant( + of: find.byType(UnconstrainedBox), + matching: find.byType(Container), + ), + ) + .decoration! + as ShapeDecoration; + + expect(getThumbColor(tester).value, CupertinoColors.systemGreen.color.value); + expect(decoration.color!.value, CupertinoColors.systemRed.color.value); + + setState(() { + brightness = Brightness.dark; + }); + await tester.pump(); + + final decorationDark = + tester + .widget<Container>( + find.descendant( + of: find.byType(UnconstrainedBox), + matching: find.byType(Container), + ), + ) + .decoration! + as ShapeDecoration; + + expect(getThumbColor(tester).value, CupertinoColors.systemGreen.darkColor.value); + expect(decorationDark.color!.value, CupertinoColors.systemRed.darkColor.value); + }); + + testWidgets('Children can be non-Text or Icon widgets (in this case, ' + 'a Container or Placeholder widget)', (WidgetTester tester) async { + const children = <int, Widget>{ + 0: Text('Child 1'), + 1: SizedBox(width: 50, height: 50), + 2: Placeholder(), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + }); + + testWidgets('Passed in value is child initially selected', (WidgetTester tester) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + expect(getHighlightedIndex(tester), 0); + }); + + testWidgets('Null input for value results in no child initially selected', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + + groupValue = null; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ); + }, + ), + ); + + expect(getHighlightedIndex(tester), null); + }); + + testWidgets('Disabled segment can be selected programmatically', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2'), 2: Text('Child 3')}; + + groupValue = 0; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + disabledChildren: const <int>{0}, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ); + }, + ), + ); + + expect(getHighlightedIndex(tester), 0); + }); + + testWidgets('Long press not-selected child interactions', (WidgetTester tester) async { + const children = <int, Widget>{ + 0: Text('Child 1'), + 1: Text('Child 2'), + 2: Text('Child 3'), + 3: Text('Child 4'), + 4: Text('Child 5'), + }; + + // Child 3 is initially selected. + groupValue = 2; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + double getChildOpacityByName(String childName) { + return tester + .renderObject<RenderAnimatedOpacity>( + find.ancestor(matching: find.byType(AnimatedOpacity), of: find.text(childName)), + ) + .opacity + .value; + } + + // Opacity 1 with no interaction. + expect(getChildOpacityByName('Child 1'), 1); + + final Offset center = tester.getCenter(find.text('Child 1')); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Opacity drops to 0.2. + expect(getChildOpacityByName('Child 1'), 0.2); + + // Move down slightly, slightly outside of the segmented control. + await gesture.moveBy(const Offset(0, 50)); + await tester.pumpAndSettle(); + expect(getChildOpacityByName('Child 1'), 0.2); + + // Move further down and far away from the segmented control. + await gesture.moveBy(const Offset(0, 200)); + await tester.pumpAndSettle(); + expect(getChildOpacityByName('Child 1'), 1); + + // Move to child 5. + await gesture.moveTo(tester.getCenter(find.text('Child 5'))); + await tester.pumpAndSettle(); + expect(getChildOpacityByName('Child 1'), 1); + expect(getChildOpacityByName('Child 5'), 0.2); + + // Move to child 2. + await gesture.moveTo(tester.getCenter(find.text('Child 2'))); + await tester.pumpAndSettle(); + expect(getChildOpacityByName('Child 1'), 1); + expect(getChildOpacityByName('Child 5'), 1); + expect(getChildOpacityByName('Child 2'), 0.2); + }); + + testWidgets('Long press does not change the opacity of currently-selected child', ( + WidgetTester tester, + ) async { + double getChildOpacityByName(String childName) { + return tester + .renderObject<RenderAnimatedOpacity>( + find.ancestor(matching: find.byType(AnimatedOpacity), of: find.text(childName)), + ) + .opacity + .value; + } + + await tester.pumpWidget(setupSimpleSegmentedControl()); + + final Offset center = tester.getCenter(find.text('Child 1')); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getChildOpacityByName('Child 1'), 1); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Height of segmented control is determined by tallest widget', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{ + 0: Container(constraints: const BoxConstraints.tightFor(height: 100.0)), + 1: Container(constraints: const BoxConstraints.tightFor(height: 400.0)), + 2: Container(constraints: const BoxConstraints.tightFor(height: 200.0)), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final RenderBox buttonBox = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + expect( + buttonBox.size.height, + 400.0 + 2 * 2, // 2 px padding on both sides. + ); + }); + + testWidgets('Width of each segmented control segment is determined by widest widget by default', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{ + 0: Container(constraints: const BoxConstraints.tightFor(width: 50.0)), + 1: Container(constraints: const BoxConstraints.tightFor(width: 100.0)), + 2: Container(constraints: const BoxConstraints.tightFor(width: 200.0)), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final RenderBox segmentedControl = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + // Subtract the 8.0px for horizontal padding separator. Remaining width should be allocated + // to each child equally. + final double childWidth = (segmentedControl.size.width - 8) / 3; + + expect(childWidth, 200.0 + 10 * 2); + }); + + testWidgets('If proportionalWidth is true, the width of each segmented ' + 'control segment is determined by its own content', (WidgetTester tester) async { + final children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 100, child: Text('Second')), + 2: const SizedBox(width: 70, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor(of: find.byWidget(children[index]!), matching: find.byType(MetaData)), + ); + } + + final Size firstChildSize = getChildSize(0); + expect(firstChildSize.width, 50 + 10.0 * 2); + + final Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, 100 + 10.0 * 2); + + final Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 70 + 10.0 * 2); + + // Overall segment control width is the sum of the segment widths + horizontal paddings + 2 separator width. + final RenderBox segmentedControl = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + final double childWidthSum = + firstChildSize.width + secondChildSize.width + thirdChildSize.width; + expect(segmentedControl.size.width, childWidthSum + 6.0 + 2.0); + }); + + testWidgets('proportionalWidth rebuild', (WidgetTester tester) async { + final children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 200, child: Text('Second')), + 2: const SizedBox(width: 70, child: Text('Third')), + }; + var proportionalWidth = false; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + proportionalWidth: proportionalWidth, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor(of: find.byWidget(children[index]!), matching: find.byType(MetaData)), + ); + } + + Size firstChildSize = getChildSize(0); + expect(firstChildSize.width, 200 + 10.0 * 2); + + Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, 200 + 10.0 * 2); + + Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 200 + 10.0 * 2); + + setState!(() { + proportionalWidth = true; + }); + await tester.pump(); + + firstChildSize = getChildSize(0); + expect(firstChildSize.width, 50 + 10.0 * 2); + + secondChildSize = getChildSize(1); + expect(secondChildSize.width, 200 + 10.0 * 2); + + thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 70 + 10.0 * 2); + }); + + testWidgets('If proportionalWidth is true, the width of each segmented ' + 'control segment is updated when children change', (WidgetTester tester) async { + var children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 100, child: Text('Second')), + 2: const SizedBox(width: 70, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor(of: find.byWidget(children[index]!), matching: find.byType(MetaData)), + ); + } + + Size firstChildSize = getChildSize(0); + expect(firstChildSize.width, 50 + 10.0 * 2); + + Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, 100 + 10.0 * 2); + + Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 70 + 10.0 * 2); + + setState!(() { + children = <int, Widget>{ + 0: const SizedBox(), + 1: const SizedBox(width: 220, child: Text('Second')), + 2: const SizedBox(width: 170, child: Text('Third')), + }; + }); + await tester.pump(); + + firstChildSize = getChildSize(0); + expect(firstChildSize.width, 0 + 10.0 * 2); + + secondChildSize = getChildSize(1); + expect(secondChildSize.width, 220 + 10.0 * 2); + + thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, 170 + 10.0 * 2); + }); + + testWidgets('If proportionalWidth is true and the overall segment control width ' + 'is larger than the max width of the parent constraints, each segment scales down', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{ + 0: const SizedBox(width: 50, child: Text('First')), + 1: const SizedBox(width: 100, child: Text('Second')), + 2: const SizedBox(width: 200, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ), + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor(of: find.byWidget(children[index]!), matching: find.byType(MetaData)), + ); + } + + // Without constraints, the overall size should be 410: 50 + 100 + 200 + // + 10.0 * 6(horizontal padding). To fit in 194(allowed max width - padding), + // each segment width should scale down to original width * (194 - separator) / 413.5. + final Size firstChildSize = getChildSize(0); + const double maxAllowedTotal = 200 - 6 - 2; + const double originalTotal = 410; + expect(firstChildSize.width, (50 + 10.0 * 2) * maxAllowedTotal / originalTotal); + + final Size secondChildSize = getChildSize(1); + expect(secondChildSize.width, (100 + 10.0 * 2) * maxAllowedTotal / originalTotal); + + final Size thirdChildSize = getChildSize(2); + expect(thirdChildSize.width, (200 + 10.0 * 2) * maxAllowedTotal / originalTotal); + }); + + testWidgets('If proportionalWidth is true and the overall segment control width ' + 'is smaller than the min width of the parent constraints, each segment scales up', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{ + 0: const SizedBox(width: 20, child: Text('First')), + 1: const SizedBox(width: 30, child: Text('Second')), + 2: const SizedBox(width: 50, child: Text('Third')), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 200), + child: CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + proportionalWidth: true, + onValueChanged: defaultCallback, + ), + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor(of: find.byWidget(children[index]!), matching: find.byType(MetaData)), + ); + } + + // Without constraints, the overall size should be 160.0: 20 + 30 + 50 + // + 10.0 * 6(horizontal padding). To fit in 194(allowed max width - padding), + // each segment width should scale up to original width * (194 - separator) / 155.5. + final Size firstChildSize = getChildSize(0); + const double constraintsMinWidth = 200 - 6 - 2; + const originalTotal = 160.0; + expect( + firstChildSize.width, + moreOrLessEquals((20 + 10.0 * 2) * constraintsMinWidth / originalTotal), + ); + + final Size secondChildSize = getChildSize(1); + expect( + secondChildSize.width, + moreOrLessEquals((30 + 10.0 * 2) * constraintsMinWidth / originalTotal), + ); + + final Size thirdChildSize = getChildSize(2); + expect( + thirdChildSize.width, + moreOrLessEquals((50 + 10.0 * 2) * constraintsMinWidth / originalTotal), + ); + }); + + testWidgets('Width is finite in unbounded space', (WidgetTester tester) async { + const children = <int, Widget>{0: SizedBox(width: 50), 1: SizedBox(width: 70)}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return Row( + children: <Widget>[ + CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ), + ], + ); + }, + ), + ); + + final RenderBox segmentedControl = tester.renderObject( + find.byKey(const ValueKey<String>('Segmented Control')), + ); + + expect( + segmentedControl.size.width, + 70 * 2 + 10.0 * 4 + 3 * 2 + 1, // 2 children + 4 child padding + 2 outer padding + 1 separator + ); + }); + + testWidgets('Directionality test - RTL should reverse order of widgets', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ), + ), + ); + + expect( + tester.getTopRight(find.text('Child 1')).dx > tester.getTopRight(find.text('Child 2')).dx, + isTrue, + ); + }); + + testWidgets('Correct initial selection and toggling behavior - RTL', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ), + ), + ); + + // highlightedIndex is 1 instead of 0 because of RTL. + expect(getHighlightedIndex(tester), 1); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect(getHighlightedIndex(tester), 0); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect(getHighlightedIndex(tester), 0); + }); + + testWidgets('Segmented control semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + role: SemanticsRole.radioGroup, + children: <TestSemantics>[ + TestSemantics( + label: 'Child 1', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + TestSemantics( + label: 'Child 2', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + // Declares that it is selectable, but not currently selected. + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + role: SemanticsRole.radioGroup, + children: <TestSemantics>[ + TestSemantics( + label: 'Child 1', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + // Declares that it is selectable, but not currently selected. + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + TestSemantics( + label: 'Child 2', + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + SemanticsFlag.isFocused, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Non-centered taps work on smaller widgets', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const SizedBox(); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + final Offset centerOfTwo = tester.getCenter(find.byWidget(children[1]!)); + // Tap within the bounds of children[1], but not at the center. + // children[1] is a SizedBox thus not hittable by itself. + await tester.tapAt(centerOfTwo + const Offset(10, 0)); + + expect(groupValue, 1); + }); + + testWidgets('Non-centered taps work on proportional segments', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const SizedBox(width: 50, height: 30); + children[1] = const SizedBox(); + children[2] = const SizedBox(width: 100, height: 30); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + proportionalWidth: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + final Rect firstChild = tester.getRect( + find.ancestor(of: find.byWidget(children[0]!), matching: find.byType(MetaData)), + ); + expect(firstChild.width, 50.0 + 10.0 * 2); + + final Rect secondChild = tester.getRect( + find.ancestor(of: find.byWidget(children[1]!), matching: find.byType(MetaData)), + ); + expect(secondChild.width, 0.0 + 10.0 * 2); + + final Rect thirdChild = tester.getRect( + find.ancestor(of: find.byWidget(children[2]!), matching: find.byType(MetaData)), + ); + expect(thirdChild.width, 100.0 + 10.0 * 2); + + final Finder child0 = find.ancestor( + of: find.byWidget(children[0]!), + matching: find.byType(MetaData), + ); + final Offset centerOfChild0 = tester.getCenter(child0); + await tester.tapAt(centerOfChild0 + Offset(firstChild.width / 2 + 1, 0)); + expect(groupValue, 1); + + await tester.tapAt( + centerOfChild0 + Offset(firstChild.width / 2 + 1 + secondChild.width + 1, 0), + ); + expect(groupValue, 2); + }); + + testWidgets('Hit-tests report accurate local position in segments', (WidgetTester tester) async { + final children = <int, Widget>{}; + late TapDownDetails tapDownDetails; + children[0] = GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) { + tapDownDetails = details; + }, + child: const SizedBox(width: 200, height: 200), + ); + children[1] = const Text('Child 2'); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + final Offset segment0GlobalOffset = tester.getTopLeft(find.byWidget(children[0]!)); + await tester.tapAt(segment0GlobalOffset + const Offset(7, 11)); + + expect(tapDownDetails.localPosition, const Offset(7, 11)); + expect(tapDownDetails.globalPosition, segment0GlobalOffset + const Offset(7, 11)); + }); + + testWidgets('Hit-tests report accurate local position in proportional segments', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + late TapDownDetails tapDownDetails; + children[0] = GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) { + tapDownDetails = details; + }, + child: const SizedBox(width: 200, height: 200), + ); + children[1] = const Text('Child 2'); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + proportionalWidth: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(groupValue, 0); + + final Offset segment0GlobalOffset = tester.getTopLeft(find.byWidget(children[0]!)); + await tester.tapAt(segment0GlobalOffset + const Offset(7, 11)); + + expect(tapDownDetails.localPosition, const Offset(7, 11)); + expect(tapDownDetails.globalPosition, segment0GlobalOffset + const Offset(7, 11)); + }); + + testWidgets('Thumb animation is correct when the selected segment changes', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(setupSimpleSegmentedControl()); + + final Rect initialRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true); + expect(currentThumbScale(tester), 1); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Child 2'))); + await tester.pump(); + + // Does not move until tapUp. + expect(currentThumbScale(tester), 1); + expect(currentUnscaledThumbRect(tester, useGlobalCoordinate: true), initialRect); + + // Tap up and the sliding animation should play. + await gesture.up(); + await tester.pump(); + // 10 ms isn't long enough for this gesture to be recognized as a longpress. + await tester.pump(const Duration(milliseconds: 10)); + + expect(currentThumbScale(tester), 1); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + greaterThan(initialRect.center.dx), + ); + + await tester.pumpAndSettle(); + + expect(currentThumbScale(tester), 1); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center, + // We're using a critically damped spring so expect the value of the + // animation controller to not be 1. + offsetMoreOrLessEquals(tester.getCenter(find.text('Child 2')), epsilon: 0.01), + ); + + // Press the currently selected widget. + await gesture.down(tester.getCenter(find.text('Child 2'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + + // The thumb shrinks but does not moves towards left; the shrink alignment + // is Alignment.centerRight, with ltr text direction because "Child 2" is + // the trailing item. + expect(currentThumbScale(tester), lessThan(1)); + double centerDelta = + tester.getSize(find.text('Child 2')).width * (1 - currentThumbScale(tester)) / 2; + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dy, + moreOrLessEquals(tester.getCenter(find.text('Child 2')).dy, epsilon: 0.01), + ); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + moreOrLessEquals(tester.getCenter(find.text('Child 2')).dx - centerDelta, epsilon: 0.01), + ); + + await tester.pumpAndSettle(); + expect(currentThumbScale(tester), moreOrLessEquals(0.95, epsilon: 0.01)); + centerDelta = tester.getSize(find.text('Child 2')).width * (1 - currentThumbScale(tester)); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dy, + moreOrLessEquals(tester.getCenter(find.text('Child 2')).dy, epsilon: 0.01), + ); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + moreOrLessEquals(tester.getCenter(find.text('Child 2')).dx - centerDelta / 2, epsilon: 0.01), + ); + + // Drag to Child 1. + await gesture.moveTo(tester.getCenter(find.text('Child 1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + + // Moved slightly to the left + expect(currentThumbScale(tester), moreOrLessEquals(0.95, epsilon: 0.01)); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + lessThan(tester.getCenter(find.text('Child 2')).dx), + ); + + await tester.pumpAndSettle(); + expect(currentThumbScale(tester), moreOrLessEquals(0.95, epsilon: 0.01)); + centerDelta = tester.getSize(find.text('Child 1')).width * (1 - currentThumbScale(tester)); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dy, + moreOrLessEquals(tester.getCenter(find.text('Child 1')).dy, epsilon: 0.01), + ); + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + moreOrLessEquals(tester.getCenter(find.text('Child 1')).dx + centerDelta / 2, epsilon: 0.01), + ); + + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(currentThumbScale(tester), greaterThan(0.95)); + + await tester.pumpAndSettle(); + expect(currentThumbScale(tester), moreOrLessEquals(1, epsilon: 0.01)); + }); + + testWidgets('Thumb does not go out of bounds in animation', (WidgetTester tester) async { + const children = <int, Widget>{ + 0: Text('Child 1', maxLines: 1), + 1: Text('wiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiide Child 2', maxLines: 1), + 2: SizedBox(height: 400), + }; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final Rect initialThumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true); + + // Starts animating towards 1. + setState!(() { + groupValue = 1; + }); + await tester.pump(const Duration(milliseconds: 10)); + + const newChildren = <int, Widget>{0: Text('C1', maxLines: 1), 1: Text('C2', maxLines: 1)}; + + // Now let the segments shrink. + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: newChildren, + groupValue: 1, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final RenderBox renderSegmentedControl = getRenderSegmentedControl(tester); + final Offset segmentedControlOrigin = renderSegmentedControl.localToGlobal(Offset.zero); + + // Expect the segmented control to be much narrower. + expect(segmentedControlOrigin.dx, greaterThan(initialThumbRect.left)); + + final Rect thumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true); + expect(initialThumbRect.size.height, 400); + expect(thumbRect.size.height, lessThan(100)); + // The new thumbRect should fit in the segmentedControl. The -1 and the +1 + // are to account for the thumb's vertical EdgeInsets. + expect(segmentedControlOrigin.dx - 1, lessThanOrEqualTo(thumbRect.left)); + expect( + segmentedControlOrigin.dx + renderSegmentedControl.size.width + 1, + greaterThanOrEqualTo(thumbRect.right), + ); + }); + + testWidgets('Transition is triggered while a transition is already occurring', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + await tester.tap(find.text('B')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + // Between A and B. + final Rect initialThumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true); + expect(initialThumbRect.center.dx, greaterThan(tester.getCenter(find.text('A')).dx)); + expect(initialThumbRect.center.dx, lessThan(tester.getCenter(find.text('B')).dx)); + + // While A to B transition is occurring, press on C. + await tester.tap(find.text('C')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final Rect secondThumbRect = currentUnscaledThumbRect(tester, useGlobalCoordinate: true); + + // Between the initial Rect and B. + expect(secondThumbRect.center.dx, greaterThan(initialThumbRect.center.dx)); + expect(secondThumbRect.center.dx, lessThan(tester.getCenter(find.text('B')).dx)); + + await tester.pump(const Duration(milliseconds: 500)); + + // Eventually moves to C. + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center, + offsetMoreOrLessEquals(tester.getCenter(find.text('C')), epsilon: 0.01), + ); + }); + + testWidgets('Insert segment while animation is running', (WidgetTester tester) async { + final Map<int, Widget> children = SplayTreeMap<int, Widget>((int a, int b) => a - b); + + children[0] = const Text('A'); + children[2] = const Text('C'); + children[3] = const Text('D'); + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + await tester.tap(find.text('D')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + children[1] = const Text('B'); + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + await tester.pumpAndSettle(); + // Eventually moves to D. + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center, + offsetMoreOrLessEquals(tester.getCenter(find.text('D')), epsilon: 0.01), + ); + }); + + testWidgets('change selection programmatically when dragging', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + + var callbackCalled = false; + + void onValueChanged(int? newValue) { + callbackCalled = true; + } + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: onValueChanged, + ); + }, + ), + ); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Change selection programmatically. + setState!(() { + groupValue = 1; + }); + await tester.pump(); + await tester.pumpAndSettle(); + + // The ongoing drag gesture should veto the programmatic change. + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dy, + moreOrLessEquals(tester.getCenter(find.text('A')).dy, epsilon: 0.01), + ); + final double centerDelta = + tester.getSize(find.text('A')).width * (1 - currentThumbScale(tester)) / 2; + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + moreOrLessEquals(tester.getCenter(find.text('A')).dx + centerDelta, epsilon: 0.01), + ); + + // Move the pointer to 'B'. The onValueChanged callback will be called but + // since the parent widget thinks we're already at 'B', it will not trigger + // a rebuild for us. + await gesture.moveTo(tester.getCenter(find.text('B'))); + await gesture.up(); + + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center, + offsetMoreOrLessEquals(tester.getCenter(find.text('B')), epsilon: 0.01), + ); + + expect(callbackCalled, isFalse); + }); + + testWidgets('Disable "highlighted" segment during drag, highlight stays', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + var disabledChildren = <int>{}; + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + disabledChildren: disabledChildren, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Move pointer to B. + await gesture.moveTo(tester.getCenter(find.text('B'))); + await tester.pumpAndSettle(); + + // Disable B. + setState!(() { + disabledChildren = <int>{1}; + }); + await tester.pumpAndSettle(); + + // During dragging, we can still see the "highlighted" segment. + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center, + offsetMoreOrLessEquals(tester.getCenter(find.text('B')), epsilon: 0.01), + ); + + await gesture.up(); + + await tester.pump(); + await tester.pumpAndSettle(); + + // When dragging stops, highlight stays. + expect(getHighlightedIndex(tester), 1); + }); + + testWidgets('Disable "highlighted" segment during drag, onValueChanged is still called', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + var disabledChildren = <int>{}; + + var callbackCalled = 0; + + void onValueChanged(int? newValue) { + callbackCalled += 1; + setState!(() { + groupValue = newValue; + }); + } + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + disabledChildren: disabledChildren, + groupValue: groupValue, + onValueChanged: onValueChanged, + ); + }, + ), + ); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Drag to B. + await gesture.moveTo(tester.getCenter(find.text('B'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // Disable B. + setState!(() { + disabledChildren = <int>{1}; + }); + await tester.pumpAndSettle(); + + // Stop dragging. + await gesture.up(); + + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getHighlightedIndex(tester), 1); + expect(callbackCalled, 1); + }); + + testWidgets('Dragging out of bound does not cause out of range exception', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCC')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + proportionalWidth: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + Size getChildSize(int index) { + return tester.getSize( + find.ancestor(of: find.byWidget(children[index]!), matching: find.byType(MetaData)), + ); + } + + expect(getChildSize(0).width, 33.0); + expect(getChildSize(2).width, 59.0); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Dragging to left until out of bound. + await gesture.moveTo(const Offset(-100, 0)); + await tester.pump(); + expect(getHighlightedIndex(tester), 0); + + // Move the pointer to the last child and continue dragging until out of bound. + final Offset thirdChild = tester.getCenter(find.text('CCC')); + await gesture.moveTo(thirdChild); + await tester.pump(); + + await gesture.moveTo(thirdChild + const Offset(100, 0)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getHighlightedIndex(tester), 2); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Disallow new gesture when dragging', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + + var callbackCalled = false; + + void onValueChanged(int? newValue) { + callbackCalled = true; + } + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: onValueChanged, + ); + }, + ), + ); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Tap a different segment. + await tester.tap(find.text('C')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dy, + moreOrLessEquals(tester.getCenter(find.text('A')).dy, epsilon: 0.01), + ); + double centerDelta = tester.getSize(find.text('A')).width * (1 - currentThumbScale(tester)) / 2; + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + moreOrLessEquals(tester.getCenter(find.text('A')).dx + centerDelta, epsilon: 0.01), + ); + + // A different drag. + await tester.drag(find.text('A'), const Offset(300, 0)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dy, + moreOrLessEquals(tester.getCenter(find.text('A')).dy, epsilon: 0.01), + ); + centerDelta = tester.getSize(find.text('A')).width * (1 - currentThumbScale(tester)) / 2; + expect( + currentUnscaledThumbRect(tester, useGlobalCoordinate: true).center.dx, + moreOrLessEquals(tester.getCenter(find.text('A')).dx + centerDelta, epsilon: 0.01), + ); + + await gesture.up(); + expect(callbackCalled, isFalse); + }); + + testWidgets('gesture outlives the widget', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/63338. + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + // Start dragging. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + await tester.pumpWidget(const Placeholder()); + + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + await tester.pump(); + + await gesture.up(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('computeDryLayout is pure', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/73362. + const children = <int, Widget>{0: Text('A'), 1: Text('B'), 2: Text('C')}; + + const Key key = ValueKey<int>(1); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 10, + child: CupertinoSlidingSegmentedControl<int>( + key: key, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ), + ), + ), + ), + ); + + final RenderBox renderBox = getRenderSegmentedControl(tester); + + final Size size = renderBox.getDryLayout(const BoxConstraints()); + expect(size.width, greaterThan(10)); + expect(tester.takeException(), isNull); + }); + + testWidgets('Has consistent size, independent of groupValue', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/62063. + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + groupValue = null; + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final RenderBox renderBox = getRenderSegmentedControl(tester); + final Size size = renderBox.size; + + for (final int value in children.keys) { + setState!(() { + groupValue = value; + }); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(renderBox.size, size); + } + }); + + testWidgets('ScrollView + SlidingSegmentedControl interaction', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('Child 1'), 1: Text('Child 2')}; + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ListView( + controller: scrollController, + children: <Widget>[ + const SizedBox(height: 100), + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + const SizedBox(height: 1000), + ], + ), + ), + ); + + // Tapping still works. + await tester.tap(find.text('Child 2')); + await tester.pump(); + + expect(groupValue, 1); + + // Vertical drag works for the scroll view. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Child 1'))); + // The first moveBy doesn't actually move the scrollable. It's there to make + // sure VerticalDragGestureRecognizer wins the arena. This is due to + // startBehavior being set to DragStartBehavior.start. + await gesture.moveBy(const Offset(0, -100)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + + expect(scrollController.offset, 100); + + // Does not affect the segmented control. + expect(groupValue, 1); + + await gesture.moveBy(const Offset(0, 100)); + await gesture.up(); + await tester.pump(); + + expect(scrollController.offset, 0); + expect(groupValue, 1); + + // Long press vertical drag is recognized by the segmented control. + await gesture.down(tester.getCenter(find.text('Child 1'))); + await tester.pump(const Duration(milliseconds: 600)); + await gesture.moveBy(const Offset(0, -100)); + await gesture.moveBy(const Offset(0, -100)); + await tester.pump(); + + // Should not scroll. + expect(scrollController.offset, 0); + expect(groupValue, 1); + + await gesture.moveBy(const Offset(0, 100)); + await gesture.moveBy(const Offset(0, 100)); + await gesture.up(); + await tester.pump(); + + expect(scrollController.offset, 0); + expect(groupValue, 0); + + // Horizontal drag is recognized by the segmentedControl. + await gesture.down(tester.getCenter(find.text('Child 1'))); + await gesture.moveBy(const Offset(50, 0)); + await gesture.moveTo(tester.getCenter(find.text('Child 2'))); + await gesture.up(); + await tester.pump(); + + expect(scrollController.offset, 0); + expect(groupValue, 1); + }); + + testWidgets( + 'Hovering over Cupertino sliding segmented control updates cursor to clickable on Web', + (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + key: const ValueKey<String>('Segmented Control'), + children: children, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset firstChild = tester.getCenter(find.text('A')); + await gesture.moveTo(firstChild); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }, + ); + + testWidgets('CupertinoSlidingSegmentedControl defaults - no selection', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + Widget buildSlidingSegmentedControl({Brightness? brightness}) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness ?? Brightness.light), + home: CupertinoPageScaffold( + child: Center( + child: CupertinoSlidingSegmentedControl<int>( + children: children, + onValueChanged: defaultCallback, + ), + ), + ), + ); + } + + // Light theme + await tester.pumpWidget(buildSlidingSegmentedControl()); + + await expectLater( + find.byType(CupertinoSlidingSegmentedControl<int>), + matchesGoldenFile('cupertino_sliding_segmented_control.light_theme.png'), + ); + + // Dark theme + await tester.pumpWidget(buildSlidingSegmentedControl(brightness: Brightness.dark)); + + await expectLater( + find.byType(CupertinoSlidingSegmentedControl<int>), + matchesGoldenFile('cupertino_sliding_segmented_control.dark_theme.png'), + ); + }); + + testWidgets('CupertinoSlidingSegmentedControl defaults - group value is not null', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + Widget buildSlidingSegmentedControl({Brightness? brightness}) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness ?? Brightness.light), + home: CupertinoPageScaffold( + child: Center( + child: CupertinoSlidingSegmentedControl<int>( + groupValue: 1, + children: children, + onValueChanged: defaultCallback, + ), + ), + ), + ); + } + + // Light theme + await tester.pumpWidget(buildSlidingSegmentedControl()); + + await expectLater( + find.byType(CupertinoSlidingSegmentedControl<int>), + matchesGoldenFile('cupertino_sliding_segmented_control.with_selection.light_theme.png'), + ); + + // Dark theme + await tester.pumpWidget(buildSlidingSegmentedControl(brightness: Brightness.dark)); + + await expectLater( + find.byType(CupertinoSlidingSegmentedControl<int>), + matchesGoldenFile('cupertino_sliding_segmented_control.with_selection.dark_theme.png'), + ); + }); + + testWidgets('CupertinoSlidingSegmentedControl defaults - disabled', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + Widget buildSlidingSegmentedControl({Brightness? brightness}) { + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness ?? Brightness.light), + home: CupertinoPageScaffold( + child: Center( + child: CupertinoSlidingSegmentedControl<int>( + disabledChildren: const <int>{0}, + children: children, + onValueChanged: defaultCallback, + ), + ), + ), + ); + } + + // Light theme + await tester.pumpWidget(buildSlidingSegmentedControl()); + + await expectLater( + find.byType(CupertinoSlidingSegmentedControl<int>), + matchesGoldenFile('cupertino_sliding_segmented_control.disabled.light_theme.png'), + ); + + // Dark theme + await tester.pumpWidget(buildSlidingSegmentedControl(brightness: Brightness.dark)); + + await expectLater( + find.byType(CupertinoSlidingSegmentedControl<int>), + matchesGoldenFile('cupertino_sliding_segmented_control.disabled.dark_theme.png'), + ); + }); + + testWidgets('Segment can be disabled', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + groupValue = 1; + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + disabledChildren: const <int>{0}, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(getHighlightedIndex(tester), 1); + + // Tap disabled segment + await tester.tap(find.text('A')); + await tester.pumpAndSettle(); + + expect(getHighlightedIndex(tester), 1); // The highlighted index doesn't change + + // Tap enabled segment + await tester.tap(find.text('CCCC')); + await tester.pumpAndSettle(); + + expect(getHighlightedIndex(tester), 2); + + // Tap disabled segment + await tester.tap(find.text('A')); + await tester.pumpAndSettle(); + + expect(getHighlightedIndex(tester), 2); // The highlighted index doesn't change + }); + + testWidgets('Several segments can be disabled', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + var onValueChangedCalled = 0; + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + disabledChildren: const <int>{0, 1, 2}, + children: children, + groupValue: groupValue, + onValueChanged: (int? value) { + onValueChangedCalled += 1; + defaultCallback.call(value); + }, + ); + }, + ), + ); + + // All segments are disabled, so onValueChangedCalled should always be 0. + await tester.tap(find.text('A')); + await tester.pumpAndSettle(); + + expect(onValueChangedCalled, 0); + + await tester.tap(find.text('CCCC')); + await tester.pumpAndSettle(); + + expect(onValueChangedCalled, 0); + + await tester.tap(find.text('BB')); + await tester.pumpAndSettle(); + + expect(onValueChangedCalled, 0); + }); + + testWidgets('CupertinoSlidingSegmentedControl can be momentary', (WidgetTester tester) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + groupValue = 1; + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + isMomentary: true, + children: children, + groupValue: groupValue, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + expect(getHighlightedIndex(tester), null); + + // Tap first segment. + await tester.tap(find.text('A')); + await tester.pumpAndSettle(); + + // The highlighted index doesn't change. + expect(getHighlightedIndex(tester), null); + }); + + testWidgets('CupertinoSlidingSegmentedControl with momentary scales up selected segment', ( + WidgetTester tester, + ) async { + const children = <int, Widget>{0: Text('A'), 1: Text('BB'), 2: Text('CCCC')}; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + isMomentary: true, + children: children, + onValueChanged: defaultCallback, + ); + }, + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + final Finder scaleTransition = find.ancestor( + of: find.text('A'), + matching: find.byType(ScaleTransition), + ); + + await tester.pumpAndSettle(); + double scale = tester.widget<ScaleTransition>(scaleTransition).scale.value; + expect(scale, greaterThan(1.0)); + + await gesture.up(); + + await tester.pumpAndSettle(); + scale = tester.widget<ScaleTransition>(scaleTransition).scale.value; + expect(scale, moreOrLessEquals(1.0)); + }); + + testWidgets('Sliding segmented control can use arrow keys', (WidgetTester tester) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + children[2] = const Text('Child 3'); + + groupValue = 0; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + onValueChanged: defaultCallback, + groupValue: groupValue, + ); + }, + ), + ); + + expect(groupValue, 0); + + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + expect(groupValue, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(groupValue, 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(groupValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(groupValue, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(groupValue, 2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(groupValue, 1); + }); + + testWidgets('Sliding segmented control skips disabled segments with keyboard', ( + WidgetTester tester, + ) async { + final children = <int, Widget>{}; + children[0] = const Text('Child 1'); + children[1] = const Text('Child 2'); + children[2] = const Text('Child 3'); + + groupValue = 0; + + await tester.pumpWidget( + boilerplate( + builder: (BuildContext context) { + return CupertinoSlidingSegmentedControl<int>( + children: children, + onValueChanged: defaultCallback, + groupValue: groupValue, + disabledChildren: const <int>{1}, + ); + }, + ), + ); + + expect(groupValue, 0); + + await tester.tap(find.text('Child 1')); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(groupValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(groupValue, 0); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/spell_check_suggestions_toolbar_test.dart b/packages/cupertino_ui/test/cupertino/spell_check_suggestions_toolbar_test.dart new file mode 100644 index 000000000000..9c351a6fb160 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/spell_check_suggestions_toolbar_test.dart @@ -0,0 +1,122 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'more than three suggestions throws an error', + (WidgetTester tester) async { + Future<void> pumpToolbar(List<String> suggestions) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoSpellCheckSuggestionsToolbar( + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + buttonItems: suggestions.map((String string) { + return ContextMenuButtonItem(onPressed: () {}, label: string); + }).toList(), + ), + ), + ), + ); + } + + await pumpToolbar(<String>['hello', 'yellow', 'yell']); + expect(() async { + await pumpToolbar(<String>['hello', 'yellow', 'yell', 'yeller']); + }, throwsAssertionError); + }, + skip: kIsWeb, // [intended] + ); + + test('buildSuggestionButtons only considers the first three suggestions', () { + final editableTextState = _FakeEditableTextState( + suggestions: <String>['hello', 'yellow', 'yell', 'yeller'], + ); + final List<ContextMenuButtonItem>? buttonItems = + CupertinoSpellCheckSuggestionsToolbar.buildButtonItems(editableTextState); + expect(buttonItems, isNotNull); + final Iterable<String?> labels = buttonItems!.map((ContextMenuButtonItem buttonItem) { + return buttonItem.label; + }); + expect(labels, hasLength(3)); + expect(labels, contains('hello')); + expect(labels, contains('yellow')); + expect(labels, contains('yell')); + expect(labels, isNot(contains('yeller'))); + }); + + testWidgets( + 'buildButtonItems builds a disabled "No Replacements Found" button when no suggestions', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget(CupertinoApp(home: _FakeEditableText(focusNode, controller))); + final _FakeEditableTextState editableTextState = tester.state(find.byType(_FakeEditableText)); + final List<ContextMenuButtonItem>? buttonItems = + CupertinoSpellCheckSuggestionsToolbar.buildButtonItems(editableTextState); + + expect(buttonItems, isNotNull); + expect(buttonItems, hasLength(1)); + expect(buttonItems!.first.label, 'No Replacements Found'); + expect(buttonItems.first.onPressed, isNull); + }, + ); + + testWidgets('CupertinoSpellCheckSuggestionsToolbar does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoSpellCheckSuggestionsToolbar( + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset(1, 1)), + buttonItems: const <ContextMenuButtonItem>[], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoSpellCheckSuggestionsToolbar)), Size.zero); + }); +} + +class _FakeEditableText extends EditableText { + /// The parameters focusNode and controller are needed here so the can be + /// safely disposed after the test is completed. + _FakeEditableText(FocusNode focusNode, TextEditingController controller) + : super( + controller: controller, + focusNode: focusNode, + backgroundCursorColor: CupertinoColors.white, + cursorColor: CupertinoColors.white, + style: const TextStyle(), + ); + + @override + _FakeEditableTextState createState() => _FakeEditableTextState(); +} + +class _FakeEditableTextState extends EditableTextState { + _FakeEditableTextState({this.suggestions}); + + final List<String>? suggestions; + @override + TextEditingValue get currentTextEditingValue => TextEditingValue.empty; + + @override + SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) { + return SuggestionSpan(const TextRange(start: 0, end: 0), suggestions ?? <String>[]); + } +} diff --git a/packages/cupertino_ui/test/cupertino/switch_test.dart b/packages/cupertino_ui/test/cupertino/switch_test.dart new file mode 100644 index 000000000000..418c4906895e --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/switch_test.dart @@ -0,0 +1,2168 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/list_tile_tester.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Switch can toggle on tap', (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(value, isFalse); + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + }); + + testWidgets('CupertinoSwitch can be toggled by keyboard shortcuts', (WidgetTester tester) async { + var value = true; + Widget buildApp({bool enabled = true}) { + return CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoSwitch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(value, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isTrue); + }); + + testWidgets( + 'Switch emits light haptic vibration on tap', + (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + + final log = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + log.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + await tester.tap(find.byKey(switchKey)); + await tester.pump(); + + expect(log, hasLength(1)); + expect( + log.single, + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.lightImpact'), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Using other widgets that rebuild the switch will not cause vibrations', + (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + final Key switchKey2 = UniqueKey(); + var value = false; + var value2 = false; + final log = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + log.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Column( + children: <Widget>[ + CupertinoSwitch( + key: switchKey, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + CupertinoSwitch( + key: switchKey2, + value: value2, + onChanged: (bool newValue) { + setState(() { + value2 = newValue; + }); + }, + ), + ], + ), + ); + }, + ), + ), + ); + + await tester.tap(find.byKey(switchKey)); + await tester.pump(); + + expect(log, hasLength(1)); + expect( + log[0], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.lightImpact'), + ); + + await tester.tap(find.byKey(switchKey2)); + await tester.pump(); + + expect(log, hasLength(2)); + expect( + log[1], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.lightImpact'), + ); + + await tester.tap(find.byKey(switchKey)); + await tester.pump(); + + expect(log, hasLength(3)); + expect( + log[2], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.lightImpact'), + ); + + await tester.tap(find.byKey(switchKey2)); + await tester.pump(); + + expect(log, hasLength(4)); + expect( + log[3], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.lightImpact'), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('Haptic vibration triggers on drag', (WidgetTester tester) async { + var value = false; + final log = <MethodCall>[]; + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + log.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + expect(value, isTrue); + await tester.pump(); + + expect(log, hasLength(1)); + expect( + log[0], + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.lightImpact'), + ); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets( + 'No haptic vibration triggers from a programmatic value change', + (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + + final log = <MethodCall>[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + log.add(methodCall); + return null; + }); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Column( + children: <Widget>[ + CupertinoButton( + child: const Text('Button'), + onPressed: () { + setState(() { + value = !value; + }); + }, + ), + CupertinoSwitch( + key: switchKey, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ], + ), + ); + }, + ), + ), + ); + + expect(value, isFalse); + + await tester.tap(find.byType(CupertinoButton)); + expect(value, isTrue); + await tester.pump(); + + expect(log, hasLength(0)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('Switch can drag (LTR)', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(value, isFalse); + + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + + expect(value, isFalse); + + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + + expect(value, isFalse); + }); + + testWidgets('Switch can drag with dragStartBehavior', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(value, isFalse); + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + expect(value, isFalse); + + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + expect(value, isTrue); + await tester.pump(); + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + expect(value, isTrue); + await tester.pump(); + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + expect(value, isFalse); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + final Rect switchRect = tester.getRect(find.byType(CupertinoSwitch)); + expect(value, isFalse); + + TestGesture gesture = await tester.startGesture(switchRect.center); + // We have to execute the drag in two frames because the first update will + // just set the start position. + await gesture.moveBy(const Offset(20.0, 0.0)); + await gesture.moveBy(const Offset(36.0, 0.0)); + expect(value, isFalse); + await gesture.up(); + expect(value, isTrue); + await tester.pump(); + + gesture = await tester.startGesture(switchRect.center); + await gesture.moveBy(const Offset(20.0, 0.0)); + await gesture.moveBy(const Offset(36.0, 0.0)); + expect(value, isTrue); + await gesture.up(); + await tester.pump(); + + gesture = await tester.startGesture(switchRect.center); + await gesture.moveBy(const Offset(-20.0, 0.0)); + await gesture.moveBy(const Offset(-36.0, 0.0)); + expect(value, isTrue); + await gesture.up(); + expect(value, isFalse); + await tester.pump(); + }); + + testWidgets('Switch can drag (RTL)', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(value, isFalse); + + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + + expect(value, isFalse); + + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(CupertinoSwitch), const Offset(56.0, 0.0)); + + expect(value, isFalse); + }); + + testWidgets('can veto switch dragging result', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + // Once the value is true, it remains true, meaning the + // switch cannot be toggled off. + value = value || newValue; + }); + }, + ), + ); + }, + ), + ), + ); + + // Move a little to the right, not past the middle. + TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(CupertinoSwitch)).center, + ); + await gesture.moveBy(const Offset(21.0, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isFalse); + final position = + (tester.state(find.byType(CupertinoSwitch)) as dynamic).position as CurvedAnimation; + expect(position.value, 0.0); + await tester.pumpAndSettle(); + expect(value, isFalse); + expect(position.value, 0.0); + + // Move past the middle. + gesture = await tester.startGesture(tester.getRect(find.byType(CupertinoSwitch)).center); + await gesture.moveBy(const Offset(36.0, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(position.value, 0.0); + + // Wait for the toggle animation to finish. + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(position.value, 1.0); + + // Now move back to the left, the revert animation should play. + gesture = await tester.startGesture(tester.getRect(find.byType(CupertinoSwitch)).center); + await gesture.moveBy(const Offset(-36.0, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(position.value, 1.0); + + // Wait for the revert animation to finish. + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(position.value, 1.0); + }); + + testWidgets('Switch thumb snaps to the side on drag', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() => value = newValue); + }, + ), + ); + }, + ), + ), + ); + + Future<void> dragBy(TestGesture gesture, Offset offset) async { + // The distance required for a gesture to be considered a drag. + const double dragActivationDistance = kTouchSlop + 0.1; + await gesture.moveBy(const Offset(dragActivationDistance, 0)); + await gesture.moveBy(const Offset(-dragActivationDistance, 0)); + await gesture.moveBy(offset); + } + + final Rect switchRect = tester.getRect(find.byType(CupertinoSwitch)); + final position = + (tester.state(find.byType(CupertinoSwitch)) as dynamic).position as CurvedAnimation; + + // Move to the right, not past the middle. + TestGesture gesture = await tester.startGesture(switchRect.center); + await dragBy(gesture, const Offset(35, 0)); + expect(position.value, 0); + expect(value, false); + await tester.pumpAndSettle(); + expect(position.value, 0); + expect(value, false); + await gesture.up(); + await tester.pumpAndSettle(); + expect(position.value, 0); + expect(value, false); + + // Move to the right, past the middle. + gesture = await tester.startGesture(switchRect.center); + await dragBy(gesture, const Offset(36, 0)); + expect(position.value, 0); + expect(value, false); + await tester.pumpAndSettle(); + expect(position.value, 1); + expect(value, false); + await gesture.up(); + await tester.pumpAndSettle(); + expect(position.value, 1); + expect(value, true); + + // Move to the left, not past the middle. + gesture = await tester.startGesture(switchRect.center); + await dragBy(gesture, const Offset(-35, 0)); + expect(position.value, 1); + expect(value, true); + await tester.pumpAndSettle(); + expect(position.value, 1); + expect(value, true); + await gesture.up(); + await tester.pumpAndSettle(); + expect(position.value, 1); + expect(value, true); + + // Move to the left, past the middle. + gesture = await tester.startGesture(switchRect.center); + await dragBy(gesture, const Offset(-36, 0)); + expect(position.value, 1); + expect(value, true); + await tester.pumpAndSettle(); + expect(position.value, 0); + expect(value, true); + await gesture.up(); + await tester.pumpAndSettle(); + expect(position.value, 0); + expect(value, false); + }); + + testWidgets('Switch is translucent when disabled', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ), + ), + ); + + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity).first).opacity, 0.5); + }); + + testWidgets('Switch is using track color when set', (WidgetTester tester) async { + const trackColor = Color(0xFF00FF00); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + trackColor: trackColor, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ), + ), + ); + + expect(find.byType(CupertinoSwitch), findsOneWidget); + expect(tester.widget<CupertinoSwitch>(find.byType(CupertinoSwitch)).trackColor, trackColor); + expect(find.byType(CupertinoSwitch), paints..rrect(color: trackColor)); + }); + + testWidgets('Switch is using default thumb color', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: CupertinoSwitch(value: false, onChanged: null)), + ), + ); + + expect(find.byType(CupertinoSwitch), findsOneWidget); + expect(tester.widget<CupertinoSwitch>(find.byType(CupertinoSwitch)).thumbColor, null); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: CupertinoColors.white), + ); + }); + + testWidgets('Switch is using thumb color when set', (WidgetTester tester) async { + const thumbColor = Color(0xFF000000); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch(value: false, thumbColor: thumbColor, onChanged: null), + ), + ), + ); + + expect(find.byType(CupertinoSwitch), findsOneWidget); + expect(tester.widget<CupertinoSwitch>(find.byType(CupertinoSwitch)).thumbColor, thumbColor); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: thumbColor), + ); + }); + + testWidgets('Switch can set active/inactive thumb colors', (WidgetTester tester) async { + var value = false; + const activeThumbColor = Color(0xff00000A); + const inactiveThumbColor = Color(0xff00000B); + + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoPageScaffold( + child: Center( + child: CupertinoSwitch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + thumbColor: activeThumbColor, + inactiveThumbColor: inactiveThumbColor, + ), + ), + ); + }, + ), + ), + ), + ); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: inactiveThumbColor), + ); + await tester.drag(find.byType(CupertinoSwitch), const Offset(-56.0, 0.0)); + await tester.pump(); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: activeThumbColor), + ); + }); + + testWidgets('Switch is opaque when enabled', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) {}, + ), + ), + ), + ); + + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity).first).opacity, 1.0); + }); + + testWidgets('Switch turns translucent after becoming disabled', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) {}, + ), + ), + ), + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ), + ), + ); + + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity).first).opacity, 0.5); + }); + + testWidgets('Switch turns opaque after becoming enabled', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) {}, + ), + ), + ), + ); + + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity).first).opacity, 1.0); + }); + + testWidgets('Switch renders correctly before, during, and after being tapped', ( + WidgetTester tester, + ) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RepaintBoundary( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ); + + await expectLater(find.byKey(switchKey), matchesGoldenFile('switch.tap.off.png')); + + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + + // Kick off animation, then advance to intermediate frame. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + await expectLater(find.byKey(switchKey), matchesGoldenFile('switch.tap.turningOn.png')); + + await tester.pumpAndSettle(); + await expectLater(find.byKey(switchKey), matchesGoldenFile('switch.tap.on.png')); + }); + + PaintPattern onLabelPaintPattern({required int alpha, bool isRtl = false}) => paints + ..rect( + rect: Rect.fromLTWH(isRtl ? 43.5 : 14.5, 14.5, 1.0, 10.0), + color: const Color(0xffffffff).withAlpha(alpha), + style: PaintingStyle.fill, + ); + + PaintPattern offLabelPaintPattern({ + required int alpha, + bool highContrast = false, + bool isRtl = false, + }) => paints + ..circle( + x: isRtl ? 16.0 : 43.0, + y: 19.5, + radius: 5.0, + color: (highContrast ? const Color(0xffffffff) : const Color(0xffb3b3b3)).withAlpha(alpha), + strokeWidth: 1.0, + style: PaintingStyle.stroke, + ); + + testWidgets('Switch renders switch labels correctly before, during, and after being tapped', ( + WidgetTester tester, + ) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(onOffSwitchLabels: true), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RepaintBoundary( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderObject switchRenderObject = tester + .element(find.byType(CupertinoSwitch)) + .renderObject!; + + expect(switchRenderObject, offLabelPaintPattern(alpha: 255)); + expect(switchRenderObject, onLabelPaintPattern(alpha: 0)); + + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + + // Kick off animation, then advance to intermediate frame. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect(switchRenderObject, onLabelPaintPattern(alpha: 131)); + expect(switchRenderObject, offLabelPaintPattern(alpha: 124)); + + await tester.pumpAndSettle(); + expect(switchRenderObject, onLabelPaintPattern(alpha: 255)); + expect(switchRenderObject, offLabelPaintPattern(alpha: 0)); + }); + + testWidgets( + 'Switch renders switch labels correctly before, during, and after being tapped in high contrast', + (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(onOffSwitchLabels: true, highContrast: true), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RepaintBoundary( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderObject switchRenderObject = tester + .element(find.byType(CupertinoSwitch)) + .renderObject!; + + expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 255)); + expect(switchRenderObject, onLabelPaintPattern(alpha: 0)); + + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + + // Kick off animation, then advance to intermediate frame. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect(switchRenderObject, onLabelPaintPattern(alpha: 131)); + expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 124)); + + await tester.pumpAndSettle(); + expect(switchRenderObject, onLabelPaintPattern(alpha: 255)); + expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 0)); + }, + ); + + testWidgets( + 'Switch renders switch labels correctly before, during, and after being tapped with direction rtl', + (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(onOffSwitchLabels: true), + child: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RepaintBoundary( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderObject switchRenderObject = tester + .element(find.byType(CupertinoSwitch)) + .renderObject!; + + expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 255)); + expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 0)); + + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + + // Kick off animation, then advance to intermediate frame. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 131)); + expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 124)); + + await tester.pumpAndSettle(); + expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 255)); + expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 0)); + }, + ); + + testWidgets('Switch renders correctly in dark mode', (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RepaintBoundary( + child: CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + await expectLater(find.byKey(switchKey), matchesGoldenFile('switch.tap.off.dark.png')); + + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + + await tester.pumpAndSettle(); + await expectLater(find.byKey(switchKey), matchesGoldenFile('switch.tap.on.dark.png')); + }); + + testWidgets('Switch can apply the ambient theme and be opted out', (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + await tester.pumpWidget( + CupertinoTheme( + data: const CupertinoThemeData(primaryColor: Color(0xFFFFC107), applyThemeToAll: true), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RepaintBoundary( + child: Column( + children: <Widget>[ + CupertinoSwitch( + key: switchKey, + value: value, + dragStartBehavior: DragStartBehavior.down, + applyTheme: true, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + applyTheme: false, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + + await expectLater(find.byType(Column), matchesGoldenFile('switch.tap.off.themed.png')); + + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + + await tester.pumpAndSettle(); + await expectLater(find.byType(Column), matchesGoldenFile('switch.tap.on.themed.png')); + }); + + testWidgets('Hovering over switch updates cursor to clickable on Web', ( + WidgetTester tester, + ) async { + const value = false; + // Disabled CupertinoSwitch does not update cursor on Web. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return const Center( + child: CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ); + }, + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + final Offset cupertinoSwitch = tester.getCenter(find.byType(CupertinoSwitch)); + await gesture.addPointer(location: cupertinoSwitch); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Enabled CupertinoSwitch updates cursor when hovering on Web. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + onChanged: (bool newValue) {}, + ), + ); + }, + ), + ), + ); + + await gesture.moveTo(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(cupertinoSwitch); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('Switch configures mouse cursor', (WidgetTester tester) async { + const value = false; + const switchSize = Offset(51.0, 31.0); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + dragStartBehavior: DragStartBehavior.down, + mouseCursor: WidgetStateProperty.all(SystemMouseCursors.forbidden), + onChanged: (bool newValue) {}, + ), + ); + }, + ), + ), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + // The pointer is not pointing at the switch. + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoSwitch)) + switchSize); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + // The pointer now points at the switch. + await gesture.moveTo(tester.getCenter(find.byType(CupertinoSwitch))); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('CupertinoSwitch is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'CupertinoSwitch'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + const focusColor = Color(0xffff0000); + + Widget buildApp({bool enabled = true}) { + return Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: focusColor, + focusNode: focusNode, + autofocus: true, + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(color: const Color(0xff34c759)) + ..rrect(color: focusColor) + ..clipRRect() + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: const Color(0xffffffff)), + ); + + // Check the false value. + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(color: const Color(0x28787880)) + ..rrect(color: focusColor) + ..clipRRect() + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: const Color(0xffffffff)), + ); + + // Check what happens when disabled. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(color: const Color(0x28787880)) + ..clipRRect() + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: const Color(0xffffffff)), + ); + }); + + testWidgets('CupertinoSwitch.onFocusChange callback', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'CupertinoSwitch'); + addTearDown(focusNode.dispose); + var focused = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: true, + focusNode: focusNode, + onFocusChange: (bool value) { + focused = value; + }, + onChanged: (bool newValue) {}, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focused, isTrue); + expect(focusNode.hasFocus, isTrue); + + focusNode.unfocus(); + await tester.pump(); + expect(focused, isFalse); + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('Switch has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + var value = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: CupertinoSwitch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ); + }, + ), + ), + ); + await tester.tap(find.byType(CupertinoSwitch)); + final RenderObject object = tester.firstRenderObject(find.byType(CupertinoSwitch)); + + expect(value, true); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('Switch sends semantic events from parent if fully merged', ( + WidgetTester tester, + ) async { + dynamic semanticEvent; + var value = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + void onChanged(bool newValue) { + setState(() { + value = newValue; + }); + } + + return MergeSemantics( + child: TestListTile( + title: CupertinoSwitch(value: value, onChanged: onChanged), + onTap: () { + onChanged(!value); + }, + ), + ); + }, + ), + ), + ); + await tester.tap(find.byType(MergeSemantics)); + final RenderObject object = tester.firstRenderObject(find.byType(MergeSemantics)); + + expect(value, true); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('Track outline color resolves in active/enabled states', (WidgetTester tester) async { + const activeEnabledTrackOutlineColor = Color(0xFF000001); + const activeDisabledTrackOutlineColor = Color(0xFF000002); + const inactiveEnabledTrackOutlineColor = Color(0xFF000003); + const inactiveDisabledTrackOutlineColor = Color(0xFF000004); + + Color getTrackOutlineColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackOutlineColor; + } + return inactiveDisabledTrackOutlineColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackOutlineColor; + } + return inactiveEnabledTrackOutlineColor; + } + + final WidgetStateProperty<Color> trackOutlineColor = WidgetStateColor.resolveWith( + getTrackOutlineColor, + ); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Directionality( + textDirection: TextDirection.rtl, + child: CupertinoPageScaffold( + child: Center( + child: CupertinoSwitch( + trackOutlineColor: trackOutlineColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: inactiveDisabledTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Inactive disabled switch track outline should use this value', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: activeDisabledTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Active disabled switch track outline should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: inactiveEnabledTrackOutlineColor), + reason: 'Inactive enabled switch track outline should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: activeEnabledTrackOutlineColor), + reason: 'Active enabled switch track outline should match these colors', + ); + }); + + testWidgets('Switch track outline color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackOutlineColor = Color(0xFF000001); + const focusedTrackOutlineColor = Color(0xFF000002); + + Color getTrackOutlineColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackOutlineColor; + } + if (states.contains(WidgetState.focused)) { + return focusedTrackOutlineColor; + } + return const Color(0x00000000); + } + + final WidgetStateProperty<Color> trackOutlineColor = WidgetStateColor.resolveWith( + getTrackOutlineColor, + ); + + Widget buildSwitch() { + return Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: CupertinoSwitch( + focusNode: focusNode, + autofocus: true, + value: true, + trackOutlineColor: trackOutlineColor, + onChanged: (_) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: focusedTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline should match this color', + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoSwitch))); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: hoveredTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline should match this color', + ); + + focusNode.dispose(); + }); + + testWidgets('Track outline width resolves in active/enabled states', (WidgetTester tester) async { + const activeEnabledTrackOutlineWidth = 1.0; + const activeDisabledTrackOutlineWidth = 2.0; + const inactiveEnabledTrackOutlineWidth = 3.0; + const inactiveDisabledTrackOutlineWidth = 4.0; + + double getTrackOutlineWidth(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackOutlineWidth; + } + return inactiveDisabledTrackOutlineWidth; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackOutlineWidth; + } + return inactiveEnabledTrackOutlineWidth; + } + + final WidgetStateProperty<double> trackOutlineWidth = WidgetStateProperty.resolveWith( + getTrackOutlineWidth, + ); + const WidgetStateProperty<Color> trackOutlineColor = WidgetStatePropertyAll<Color>( + Color(0xFFFFFFFF), + ); + + Widget buildSwitch({required bool enabled, required bool active}) { + return CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoSwitch( + trackOutlineWidth: trackOutlineWidth, + trackOutlineColor: trackOutlineColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: inactiveDisabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Inactive disabled switch track outline width should be 4.0', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: activeDisabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active disabled switch track outline width should be 2.0', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: inactiveEnabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Inactive enabled switch track outline width should be 3.0', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: activeEnabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline width should be 1.0', + ); + }); + + testWidgets('Switch track outline width resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackOutlineWidth = 4.0; + const focusedTrackOutlineWidth = 6.0; + + double getTrackOutlineWidth(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackOutlineWidth; + } + if (states.contains(WidgetState.focused)) { + return focusedTrackOutlineWidth; + } + return 8.0; + } + + final WidgetStateProperty<double> trackOutlineWidth = WidgetStateProperty.resolveWith( + getTrackOutlineWidth, + ); + const WidgetStateProperty<Color> trackOutlineColor = WidgetStatePropertyAll<Color>( + Color(0xFFFFFFFF), + ); + + Widget buildSwitch() { + return CupertinoApp( + home: Center( + child: CupertinoSwitch( + focusNode: focusNode, + autofocus: true, + value: true, + trackOutlineWidth: trackOutlineWidth, + trackOutlineColor: trackOutlineColor, + onChanged: (_) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: focusedTrackOutlineWidth, style: PaintingStyle.stroke) + ..rrect(strokeWidth: 3.5, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline width should be 6.0', + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoSwitch))); + await tester.pumpAndSettle(); + + expect( + find.byType(CupertinoSwitch), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: hoveredTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline width should be 4.0', + ); + + focusNode.dispose(); + }); + + testWidgets('Switch can set icon', (WidgetTester tester) async { + WidgetStateProperty<Icon?> thumbIcon(Icon? activeIcon, Icon? inactiveIcon) { + return WidgetStateProperty.resolveWith<Icon?>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeIcon; + } + return inactiveIcon; + }); + } + + Widget buildSwitch({ + required bool enabled, + required bool active, + Icon? activeIcon, + Icon? inactiveIcon, + }) { + return Directionality( + textDirection: TextDirection.ltr, + child: CupertinoPageScaffold( + child: Center( + child: CupertinoSwitch( + thumbIcon: thumbIcon(activeIcon, inactiveIcon), + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + // The active icon shows when the switch is on. + await tester.pumpWidget( + buildSwitch(enabled: true, active: true, activeIcon: const Icon(CupertinoIcons.clear)), + ); + await tester.pumpAndSettle(); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..paragraph(offset: const Offset(31.5, 11.5)), + ); + + // The inactive icon shows when the switch is off. + await tester.pumpWidget( + buildSwitch(enabled: true, active: false, inactiveIcon: const Icon(CupertinoIcons.clear)), + ); + await tester.pumpAndSettle(); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect() + ..paragraph(offset: const Offset(11.5, 11.5)), + ); + + // The active icon doesn't show when the switch is off. + await tester.pumpWidget( + buildSwitch(enabled: true, active: false, activeIcon: const Icon(CupertinoIcons.checkmark)), + ); + await tester.pumpAndSettle(); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect(), + ); + + // The inactive icon doesn't show when the switch is on. + await tester.pumpWidget( + buildSwitch(enabled: true, active: true, inactiveIcon: const Icon(CupertinoIcons.checkmark)), + ); + await tester.pumpAndSettle(); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..restore(), + ); + + // No icons are shown. + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + expect( + find.byType(CupertinoSwitch), + paints + ..rrect() + ..rrect() + ..rrect() + ..restore(), + ); + }); + + group('with image', () { + late ui.Image image; + + setUp(() async { + image = await createTestImage(width: 100, height: 100); + }); + + testWidgets('Thumb images show up when set', (WidgetTester tester) async { + imageCache.clear(); + final provider1 = _TestImageProvider(); + final provider2 = _TestImageProvider(); + + expect(provider1.loadCallCount, 0); + expect(provider2.loadCallCount, 0); + + var value1 = true; + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CupertinoPageScaffold( + child: CupertinoSwitch( + activeThumbImage: provider1, + inactiveThumbImage: provider2, + value: value1, + onChanged: (bool val) { + setState(() { + value1 = val; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(provider1.loadCallCount, 1); + expect(provider2.loadCallCount, 0); + expect(imageCache.liveImageCount, 1); + await tester.tap(find.byType(CupertinoSwitch)); + await tester.pumpAndSettle(); + expect(provider1.loadCallCount, 1); + expect(provider2.loadCallCount, 1); + expect(imageCache.liveImageCount, 2); + }); + + testWidgets('Does not crash when imageProvider completes after switch is disposed', ( + WidgetTester tester, + ) async { + final imageProvider = DelayedImageProvider(image); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoSwitch( + value: true, + onChanged: null, + inactiveThumbImage: imageProvider, + ), + ), + ), + ), + ); + + expect(find.byType(CupertinoSwitch), findsOneWidget); + + // Dispose the switch by taking down the tree. + await tester.pumpWidget(Container()); + expect(find.byType(CupertinoSwitch), findsNothing); + + imageProvider.complete(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Does not crash when previous imageProvider completes after switch is disposed', ( + WidgetTester tester, + ) async { + final imageProvider1 = DelayedImageProvider(image); + final imageProvider2 = DelayedImageProvider(image); + + Future<void> buildSwitch(ImageProvider imageProvider) { + return tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoSwitch( + value: true, + onChanged: null, + inactiveThumbImage: imageProvider, + ), + ), + ), + ), + ); + } + + await buildSwitch(imageProvider1); + expect(find.byType(CupertinoSwitch), findsOneWidget); + // Replace the ImageProvider. + await buildSwitch(imageProvider2); + expect(find.byType(CupertinoSwitch), findsOneWidget); + + // Dispose the switch by taking down the tree. + await tester.pumpWidget(Container()); + expect(find.byType(CupertinoSwitch), findsNothing); + + // Completing the replaced ImageProvider shouldn't crash. + imageProvider1.complete(); + expect(tester.takeException(), isNull); + + imageProvider2.complete(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Switch uses inactive track color when set', (WidgetTester tester) async { + const inactiveTrackColor = Color(0xFF00FF00); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: false, + inactiveTrackColor: inactiveTrackColor, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ), + ), + ); + + expect(find.byType(CupertinoSwitch), findsOneWidget); + expect( + tester.widget<CupertinoSwitch>(find.byType(CupertinoSwitch)).inactiveTrackColor, + inactiveTrackColor, + ); + expect(find.byType(CupertinoSwitch), paints..rrect(color: inactiveTrackColor)); + }); + + testWidgets('Switch uses active track color when set', (WidgetTester tester) async { + const activeTrackColor = Color(0xFF00FF00); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: CupertinoSwitch( + value: true, + activeTrackColor: activeTrackColor, + dragStartBehavior: DragStartBehavior.down, + onChanged: null, + ), + ), + ), + ); + + expect(find.byType(CupertinoSwitch), findsOneWidget); + expect( + tester.widget<CupertinoSwitch>(find.byType(CupertinoSwitch)).activeTrackColor, + activeTrackColor, + ); + expect(find.byType(CupertinoSwitch), paints..rrect(color: activeTrackColor)); + }); + }); + + testWidgets('CupertinoSwitch does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoSwitch(value: false, onChanged: (_) {})), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoSwitch)), Size.zero); + }); +} + +class _TestImageProvider extends ImageProvider<Object> { + _TestImageProvider({ImageStreamCompleter? streamCompleter}) { + _streamCompleter = streamCompleter ?? OneFrameImageStreamCompleter(_completer.future); + } + + final Completer<ImageInfo> _completer = Completer<ImageInfo>(); + late ImageStreamCompleter _streamCompleter; + + bool get loadCalled => _loadCallCount > 0; + int get loadCallCount => _loadCallCount; + int _loadCallCount = 0; + + @override + Future<Object> obtainKey(ImageConfiguration configuration) { + return SynchronousFuture<_TestImageProvider>(this); + } + + @override + void resolveStreamForKey( + ImageConfiguration configuration, + ImageStream stream, + Object key, + ImageErrorListener handleError, + ) { + super.resolveStreamForKey(configuration, stream, key, handleError); + } + + @override + ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) { + _loadCallCount += 1; + return _streamCompleter; + } + + void complete(ui.Image image) { + _completer.complete(ImageInfo(image: image)); + } + + void fail(Object exception, StackTrace? stackTrace) { + _completer.completeError(exception, stackTrace); + } + + @override + String toString() => '${describeIdentity(this)}()'; +} + +class DelayedImageProvider extends ImageProvider<DelayedImageProvider> { + DelayedImageProvider(this.image); + + final ui.Image image; + + final Completer<ImageInfo> _completer = Completer<ImageInfo>(); + + @override + Future<DelayedImageProvider> obtainKey(ImageConfiguration configuration) { + return SynchronousFuture<DelayedImageProvider>(this); + } + + @override + ImageStreamCompleter loadImage(DelayedImageProvider key, ImageDecoderCallback decode) { + return OneFrameImageStreamCompleter(_completer.future); + } + + void complete() { + _completer.complete(ImageInfo(image: image)); + } + + @override + String toString() => '${describeIdentity(this)}()'; +} diff --git a/packages/cupertino_ui/test/cupertino/tab_scaffold_test.dart b/packages/cupertino_ui/test/cupertino/tab_scaffold_test.dart new file mode 100644 index 000000000000..18980fd10d5c --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/tab_scaffold_test.dart @@ -0,0 +1,1470 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/rendering_tester.dart' show TestCallbackPainter; +import '../widgets/widget_inspector_test_utils.dart'; +import 'navigator_utils.dart'; + +late List<int> selectedTabs; + +class MockCupertinoTabController extends CupertinoTabController { + MockCupertinoTabController({required super.initialIndex}); + + bool isDisposed = false; + int numOfListeners = 0; + + @override + void addListener(VoidCallback listener) { + numOfListeners++; + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + numOfListeners--; + super.removeListener(listener); + } + + @override + void dispose() { + isDisposed = true; + super.dispose(); + } +} + +BottomNavigationBarItem tabGenerator(int index) { + return BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab ${index + 1}'); +} + +void main() { + // Must be called before any testWidgets so the service is set before + // binding initialization triggers initServiceExtensions. + _TabScaffoldWidgetInspectorService.runTests(); + + setUp(() { + selectedTabs = <int>[]; + }); + + testWidgets('Tab switching', (WidgetTester tester) async { + final tabsPainted = <int>[]; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0]); + RichText tab1 = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + expect(tab1.text.style!.color, CupertinoColors.activeBlue); + RichText tab2 = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + expect(tab2.text.style!.color!.value, 0xFF999999); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(tabsPainted, const <int>[0, 1]); + tab1 = tester.widget(find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText))); + expect(tab1.text.style!.color!.value, 0xFF999999); + tab2 = tester.widget(find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText))); + expect(tab2.text.style!.color, CupertinoColors.activeBlue); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + expect(tabsPainted, const <int>[0, 1, 0]); + // CupertinoTabBar's onTap callbacks are passed on. + expect(selectedTabs, const <int>[1, 0]); + }); + + testWidgets('Tabs are lazy built and moved offstage when inactive', (WidgetTester tester) async { + final tabsBuilt = <int>[]; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + tabsBuilt.add(index); + return Text('Page ${index + 1}'); + }, + ), + ), + ); + + expect(tabsBuilt, const <int>[0]); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + // Both tabs are built but only one is onstage. + expect(tabsBuilt, const <int>[0, 0, 1]); + expect(find.text('Page 1', skipOffstage: false), isOffstage); + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + expect(tabsBuilt, const <int>[0, 0, 1, 0, 1]); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2', skipOffstage: false), isOffstage); + }); + + testWidgets('Last tab gets focus', (WidgetTester tester) async { + // 2 nodes for 2 tabs + final focusNodes = <FocusNode>[ + FocusNode(debugLabel: 'Node 1'), + FocusNode(debugLabel: 'Node 2'), + ]; + for (final focusNode in focusNodes) { + addTearDown(focusNode.dispose); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return CupertinoTextField(focusNode: focusNodes[index], autofocus: true); + }, + ), + ), + ); + + expect(focusNodes[0].hasFocus, isTrue); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(focusNodes[0].hasFocus, isFalse); + expect(focusNodes[1].hasFocus, isTrue); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + expect(focusNodes[0].hasFocus, isTrue); + expect(focusNodes[1].hasFocus, isFalse); + }); + + testWidgets('Do not affect focus order in the route', (WidgetTester tester) async { + final focusNodes = <FocusNode>[ + FocusNode(debugLabel: 'Node 1'), + FocusNode(debugLabel: 'Node 2'), + FocusNode(debugLabel: 'Node 3'), + FocusNode(debugLabel: 'Node 4'), + ]; + for (final focusNode in focusNodes) { + addTearDown(focusNode.dispose); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return Column( + children: <Widget>[ + CupertinoTextField(focusNode: focusNodes[index * 2], placeholder: 'TextField 1'), + CupertinoTextField( + focusNode: focusNodes[index * 2 + 1], + placeholder: 'TextField 2', + ), + ], + ); + }, + ), + ), + ); + + expect(focusNodes.any((FocusNode node) => node.hasFocus), isFalse); + + await tester.tap(find.widgetWithText(CupertinoTextField, 'TextField 2')); + + expect(focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)), 1); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + await tester.tap(find.widgetWithText(CupertinoTextField, 'TextField 1')); + + expect(focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)), 2); + + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + // Upon going back to tab 1, the item it tab 1 that previously had the focus + // (TextField 2) gets it back. + expect(focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)), 1); + }); + + testWidgets('Programmatic tab switching by changing the index of an existing controller', ( + WidgetTester tester, + ) async { + final controller = CupertinoTabController(initialIndex: 1); + addTearDown(controller.dispose); + final tabsPainted = <int>[]; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[1]); + + controller.index = 0; + await tester.pump(); + + expect(tabsPainted, const <int>[1, 0]); + // onTap is not called when changing tabs programmatically. + expect(selectedTabs, isEmpty); + + // Can still tap out of the programmatically selected tab. + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + expect(tabsPainted, const <int>[1, 0, 1]); + expect(selectedTabs, const <int>[1]); + }); + + testWidgets('Programmatic tab switching by passing in a new controller', ( + WidgetTester tester, + ) async { + final tabsPainted = <int>[]; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0]); + + final controller = CupertinoTabController(initialIndex: 1); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + controller: controller, // Programmatically change the tab now. + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0, 1]); + // onTap is not called when changing tabs programmatically. + expect(selectedTabs, isEmpty); + + // Can still tap out of the programmatically selected tab. + await tester.tap(find.text('Tab 1')); + await tester.pump(); + + expect(tabsPainted, const <int>[0, 1, 0]); + expect(selectedTabs, const <int>[0]); + }); + + testWidgets('Tab bar respects themes', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + + var tabDecoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTabBar), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(tabDecoration.color, isSameColorAs(const Color(0xF0F9F9F9))); // Inherited from theme. + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + // Pump again but with dark theme. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData( + brightness: Brightness.dark, + primaryColor: CupertinoColors.destructiveRed, + ), + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + + tabDecoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTabBar), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(tabDecoration.color, isSameColorAs(const Color(0xF01D1D1D))); + + final RichText tab1 = tester.widget( + find.descendant(of: find.text('Tab 1'), matching: find.byType(RichText)), + ); + // Tab 2 should still be selected after changing theme. + expect(tab1.text.style!.color!.value, 0xFF757575); + final RichText tab2 = tester.widget( + find.descendant(of: find.text('Tab 2'), matching: find.byType(RichText)), + ); + expect(tab2.text.style!.color, isSameColorAs(CupertinoColors.systemRed.darkColor)); + }); + + testWidgets('Tab contents are padded when there are view insets', (WidgetTester tester) async { + late BuildContext innerContext; + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 200)), + child: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + innerContext = context; + return const Placeholder(); + }, + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 400)); + // Don't generate more media query padding from the translucent bottom + // tab since the tab is behind the keyboard now. + expect(MediaQuery.of(innerContext).padding.bottom, 0); + }); + + testWidgets('Tab contents are not inset when resizeToAvoidBottomInset overridden', ( + WidgetTester tester, + ) async { + late BuildContext innerContext; + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 200)), + child: CupertinoTabScaffold( + resizeToAvoidBottomInset: false, + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + innerContext = context; + return const Placeholder(); + }, + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 600)); + // Media query padding shows up in the inner content because it wasn't masked + // by the view inset. + expect(MediaQuery.of(innerContext).padding.bottom, 50); + }); + + testWidgets( + 'Tab contents bottom padding are not consumed by viewInsets when resizeToAvoidBottomInset overridden', + (WidgetTester tester) async { + final Widget child = Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultCupertinoLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoTabScaffold( + resizeToAvoidBottomInset: false, + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ), + ); + + final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); + + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: child, + ), + ); + + final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); + + expect(initialPoint, finalPoint); + }, + ); + + testWidgets('Opaque tab bar consumes bottom padding while non opaque tab bar does not', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/43581. + Future<EdgeInsets> getContentPaddingWithTabBarColor(Color color) async { + late EdgeInsets contentPadding; + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 50)), + child: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + backgroundColor: color, + items: List<BottomNavigationBarItem>.generate(2, tabGenerator), + ), + tabBuilder: (BuildContext context, int index) { + contentPadding = MediaQuery.paddingOf(context); + return const Placeholder(); + }, + ), + ), + ), + ); + return contentPadding; + } + + expect(await getContentPaddingWithTabBarColor(const Color(0xAAFFFFFF)), isNot(EdgeInsets.zero)); + expect(await getContentPaddingWithTabBarColor(const Color(0xFFFFFFFF)), EdgeInsets.zero); + }); + + testWidgets('Tab and page scaffolds do not double stack view insets', ( + WidgetTester tester, + ) async { + late BuildContext innerContext; + + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 200)), + child: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) { + innerContext = context; + return const Placeholder(); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 400)); + expect(MediaQuery.of(innerContext).padding.bottom, 0); + }); + + testWidgets('Deleting tabs after selecting them should switch to the last available tab', ( + WidgetTester tester, + ) async { + final tabsBuilt = <int>[]; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(4, tabGenerator), + onTap: (int newTab) => selectedTabs.add(newTab), + ), + tabBuilder: (BuildContext context, int index) { + tabsBuilt.add(index); + return Text('Page ${index + 1}'); + }, + ), + ), + ); + + expect(tabsBuilt, const <int>[0]); + // selectedTabs list is appended to on onTap callbacks. We didn't tap + // any tabs yet. + expect(selectedTabs, const <int>[]); + tabsBuilt.clear(); + + await tester.tap(find.text('Tab 4')); + await tester.pump(); + + // Tabs 1 and 4 are built but only one is onstage. + expect(tabsBuilt, const <int>[0, 3]); + expect(selectedTabs, const <int>[3]); + expect(find.text('Page 1', skipOffstage: false), isOffstage); + expect(find.text('Page 4'), findsOneWidget); + tabsBuilt.clear(); + + // Delete 2 tabs while Page 4 is still selected. + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(2, tabGenerator), + onTap: (int newTab) => selectedTabs.add(newTab), + ), + tabBuilder: (BuildContext context, int index) { + tabsBuilt.add(index); + // Change the builder too. + return Text('Different page ${index + 1}'); + }, + ), + ), + ); + + expect(tabsBuilt, const <int>[0, 1]); + // We didn't tap on any additional tabs to invoke the onTap callback. We + // just deleted a tab. + expect(selectedTabs, const <int>[3]); + // Tab 1 was previously built so it's rebuilt again, albeit offstage. + expect(find.text('Different page 1', skipOffstage: false), isOffstage); + // Since all the tabs after tab 2 are deleted, tab 2 is now the last tab and + // the actively shown tab. + expect(find.text('Different page 2'), findsOneWidget); + // No more tab 4 since it's deleted. + expect(find.text('Different page 4', skipOffstage: false), findsNothing); + // We also changed the builder so no tabs should be built with the old + // builder. + expect(find.text('Page 1', skipOffstage: false), findsNothing); + expect(find.text('Page 2', skipOffstage: false), findsNothing); + expect(find.text('Page 4', skipOffstage: false), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/33455 + testWidgets('Adding new tabs does not crash the app', (WidgetTester tester) async { + final tabsPainted = <int>[]; + final controller = CupertinoTabController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(10, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0]); + + // Increase the num of tabs to 20. + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(20, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0, 0]); + + await tester.tap(find.text('Tab 19')); + await tester.pump(); + + // Tapping the tabs should still work. + expect(tabsPainted, const <int>[0, 0, 18]); + }); + + testWidgets('If a controller is initially provided then the parent stops doing so for rebuilds, ' + 'a new instance of CupertinoTabController should be created and used by the widget, ' + "while preserving the previous controller's tab index", (WidgetTester tester) async { + final tabsPainted = <int>[]; + final oldController = CupertinoTabController(); + addTearDown(oldController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(10, tabGenerator)), + controller: oldController, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0]); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(10, tabGenerator)), + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter( + onPaint: () { + tabsPainted.add(index); + }, + ), + child: Text('Page ${index + 1}'), + ); + }, + ), + ), + ); + + expect(tabsPainted, const <int>[0, 0]); + + await tester.tap(find.text('Tab 2')); + await tester.pump(); + + // Tapping the tabs should still work. + expect(tabsPainted, const <int>[0, 0, 1]); + + oldController.index = 10; + await tester.pump(); + + // Changing [index] of the oldController should not work. + expect(tabsPainted, const <int>[0, 0, 1]); + }); + + testWidgets('Do not call dispose on a controller that we do not own ' + 'but do remove from its listeners when done listening to it', (WidgetTester tester) async { + final mockController = MockCupertinoTabController(initialIndex: 0); + addTearDown(mockController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(2, tabGenerator)), + controller: mockController, + tabBuilder: (BuildContext context, int index) => const Placeholder(), + ), + ), + ); + + expect(mockController.numOfListeners, 1); + expect(mockController.isDisposed, isFalse); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(2, tabGenerator)), + tabBuilder: (BuildContext context, int index) => const Placeholder(), + ), + ), + ); + + expect(mockController.numOfListeners, 0); + expect(mockController.isDisposed, isFalse); + }); + + testWidgets('The owner can dispose the old controller', (WidgetTester tester) async { + var controller = CupertinoTabController(initialIndex: 2); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(3, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) => const Placeholder(), + ), + ), + ); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsOneWidget); + expect(find.text('Tab 3'), findsOneWidget); + + controller.dispose(); + controller = CupertinoTabController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(2, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) => const Placeholder(), + ), + ), + ); + + // Should not crash here. + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsOneWidget); + expect(find.text('Tab 3'), findsNothing); + }); + + testWidgets('A controller can control more than one CupertinoTabScaffold, ' + 'removal of listeners does not break the controller', (WidgetTester tester) async { + final tabsPainted0 = <int>[]; + final tabsPainted1 = <int>[]; + var controller = MockCupertinoTabController(initialIndex: 2); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Stack( + children: <Widget>[ + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter(onPaint: () => tabsPainted0.add(index)), + ); + }, + ), + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter(onPaint: () => tabsPainted1.add(index)), + ); + }, + ), + ], + ), + ), + ), + ); + expect(tabsPainted0, const <int>[2]); + expect(tabsPainted1, const <int>[2]); + expect(controller.numOfListeners, 2); + + controller.index = 0; + await tester.pump(); + expect(tabsPainted0, const <int>[2, 0]); + expect(tabsPainted1, const <int>[2, 0]); + + controller.index = 1; + // Removing one of the tabs works. + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Stack( + children: <Widget>[ + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter(onPaint: () => tabsPainted0.add(index)), + ); + }, + ), + ], + ), + ), + ), + ); + + expect(tabsPainted0, const <int>[2, 0, 1]); + expect(tabsPainted1, const <int>[2, 0]); + expect(controller.numOfListeners, 1); + + // Replacing controller works. + controller.dispose(); + controller = MockCupertinoTabController(initialIndex: 2); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Stack( + children: <Widget>[ + CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(3, tabGenerator), + ), + controller: controller, + tabBuilder: (BuildContext context, int index) { + return CustomPaint( + painter: TestCallbackPainter(onPaint: () => tabsPainted0.add(index)), + ); + }, + ), + ], + ), + ), + ), + ); + expect(tabsPainted0, const <int>[2, 0, 1, 2]); + expect(tabsPainted1, const <int>[2, 0]); + expect(controller.numOfListeners, 1); + }); + + testWidgets('Assert when current tab index >= number of tabs', (WidgetTester tester) async { + final controller = CupertinoTabController(initialIndex: 2); + addTearDown(controller.dispose); + + try { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(2, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'), + ), + ), + ); + } on AssertionError catch (e) { + expect(e.toString(), contains('controller.index < tabBar.items.length')); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(3, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'), + ), + ), + ); + + expect(tester.takeException(), null); + + controller.index = 10; + await tester.pump(); + + final message = tester.takeException().toString(); + expect(message, contains('current index ${controller.index}')); + expect(message, contains('with 3 tabs')); + }); + + testWidgets("Don't replace focus nodes for existing tabs when changing tab count", ( + WidgetTester tester, + ) async { + final controller = CupertinoTabController(initialIndex: 2); + addTearDown(controller.dispose); + + final scopes = <FocusScopeNode>[]; + for (var i = 0; i < 5; i++) { + final scope = FocusScopeNode(); + addTearDown(scope.dispose); + scopes.add(scope); + } + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(3, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) { + scopes[index] = FocusScope.of(context); + return Container(); + }, + ), + ), + ); + + for (var i = 0; i < 3; i++) { + controller.index = i; + await tester.pump(); + } + await tester.pump(); + + final newScopes = <FocusScopeNode>[]; + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(5, tabGenerator)), + controller: controller, + tabBuilder: (BuildContext context, int index) { + newScopes.add(FocusScope.of(context)); + return Container(); + }, + ), + ), + ); + for (var i = 0; i < 5; i++) { + controller.index = i; + await tester.pump(); + } + await tester.pump(); + + expect(scopes.sublist(0, 3), equals(newScopes.sublist(0, 3))); + }); + + testWidgets('Current tab index cannot go below zero or be null', (WidgetTester tester) async { + void expectAssertionError(VoidCallback callback, String errorMessage) { + try { + callback(); + } on AssertionError catch (e) { + expect(e.toString(), contains(errorMessage)); + } + } + + expectAssertionError(() => CupertinoTabController(initialIndex: -1), '>= 0'); + + final controller = CupertinoTabController(); + addTearDown(controller.dispose); + + expectAssertionError(() => controller.index = -1, '>= 0'); + }); + + testWidgets('Does not lose state when focusing on text input', (WidgetTester tester) async { + // Regression testing for https://github.com/flutter/flutter/issues/28457. + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const CupertinoTextField(); + }, + ), + ), + ), + ); + + final EditableTextState editableState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + + await tester.enterText(find.byType(CupertinoTextField), "don't lose me"); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100)), + child: CupertinoApp( + home: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const CupertinoTextField(); + }, + ), + ), + ), + ); + + // The exact same state instance is still there. + expect(tester.state<EditableTextState>(find.byType(EditableText)), editableState); + expect(find.text("don't lose me"), findsOneWidget); + }); + + testWidgets('textScaleFactor is set to 1.0', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 99, + maxScaleFactor: 99, + child: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: List<BottomNavigationBarItem>.generate(10, tabGenerator), + ), + tabBuilder: (BuildContext context, int index) => const Text('content'), + ), + ); + }, + ), + ), + ); + + final Iterable<RichText> barItems = tester.widgetList<RichText>( + find.descendant(of: find.byType(CupertinoTabBar), matching: find.byType(RichText)), + ); + + final Iterable<RichText> contents = tester.widgetList<RichText>( + find.descendant( + of: find.text('content'), + matching: find.byType(RichText), + skipOffstage: false, + ), + ); + + expect(barItems.length, greaterThan(0)); + expect( + barItems, + isNot(contains(predicate((RichText t) => t.textScaler != TextScaler.noScaling))), + ); + + expect(contents.length, greaterThan(0)); + expect( + contents, + isNot(contains(predicate((RichText t) => t.textScaler != const TextScaler.linear(99.0)))), + ); + }); + + testWidgets('state restoration', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + restorationScopeId: 'app', + home: CupertinoTabScaffold( + restorationId: 'scaffold', + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(4, tabGenerator)), + tabBuilder: (BuildContext context, int i) => Text('Content $i'), + ), + ), + ); + + expect(find.text('Content 0'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + + await tester.tap(find.text('Tab 3')); + await tester.pumpAndSettle(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + + await tester.restartAndRestore(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsOneWidget); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + + await tester.restoreFrom(data); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + }); + + testWidgets('switch from internal to external controller with state restoration', ( + WidgetTester tester, + ) async { + Widget buildWidget({CupertinoTabController? controller}) { + return CupertinoApp( + restorationScopeId: 'app', + home: CupertinoTabScaffold( + controller: controller, + restorationId: 'scaffold', + tabBar: CupertinoTabBar(items: List<BottomNavigationBarItem>.generate(4, tabGenerator)), + tabBuilder: (BuildContext context, int i) => Text('Content $i'), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.text('Content 0'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + + await tester.tap(find.text('Tab 3')); + await tester.pumpAndSettle(); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsOneWidget); + expect(find.text('Content 3'), findsNothing); + + final controller = CupertinoTabController(initialIndex: 3); + addTearDown(controller.dispose); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Content 0'), findsNothing); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsOneWidget); + + await tester.pumpWidget(buildWidget()); + + expect(find.text('Content 0'), findsOneWidget); + expect(find.text('Content 1'), findsNothing); + expect(find.text('Content 2'), findsNothing); + expect(find.text('Content 3'), findsNothing); + }); + + group('Android Predictive Back', () { + bool? lastFrameworkHandlesBack; + setUp(() async { + lastFrameworkHandlesBack = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'SystemNavigator.setFrameworkHandlesBack') { + expect(methodCall.arguments, isA<bool>()); + lastFrameworkHandlesBack = methodCall.arguments as bool; + } + return; + }, + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/lifecycle', + const StringCodec().encodeMessage(AppLifecycleState.resumed.toString()), + (ByteData? data) {}, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + testWidgets( + 'System back navigation inside of tabs', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 200)), + child: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return CupertinoTabView( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 1 of tab ${index + 1}'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Next page'), + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute<void>( + builder: (BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text('Page 2 of tab ${index + 1}'), + ), + child: Center( + child: CupertinoButton( + child: const Text('Back'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); + + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 2 of tab 1'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsNothing); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 2 of tab 1'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Next page')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsNothing); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 2'), findsOneWidget); + expect(find.text('Page 2 of tab 2'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Tab 1')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsNothing); + expect(find.text('Page 2 of tab 1'), findsOneWidget); + expect(lastFrameworkHandlesBack, isTrue); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 1'), findsOneWidget); + expect(find.text('Page 2 of tab 1'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 1 of tab 2'), findsOneWidget); + expect(find.text('Page 2 of tab 2'), findsNothing); + expect(lastFrameworkHandlesBack, isFalse); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + skip: kIsWeb, // [intended] frameworkHandlesBack not used on web. + ); + }); + + testWidgets('CupertinoTabScaffold does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink( + child: CupertinoTabScaffold( + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) => Text('$index'), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabScaffold)), Size.zero); + }); + + testWidgets('dark mode background color', (WidgetTester tester) async { + const backgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFF123456), + darkColor: Color(0xFF654321), + ); + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: CupertinoTabScaffold( + backgroundColor: backgroundColor, + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + + // The DecoratedBox with the smallest depth is the DecoratedBox of the + // CupertinoTabScaffold. + var tabDecoration = + tester + .firstWidget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTabScaffold), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(tabDecoration.color!.value, backgroundColor.color.value); + + // Dark mode + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoTabScaffold( + backgroundColor: backgroundColor, + tabBar: _buildTabBar(), + tabBuilder: (BuildContext context, int index) { + return const Placeholder(); + }, + ), + ), + ); + + tabDecoration = + tester + .firstWidget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTabScaffold), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(tabDecoration.color!.value, backgroundColor.darkColor.value); + }); +} + +class _TabScaffoldWidgetInspectorService extends TestWidgetInspectorService { + // These tests need access to protected members of WidgetInspectorService. + static void runTests() { + final service = _TabScaffoldWidgetInspectorService(); + final WidgetInspectorService previousInstance = WidgetInspectorService.instance; + WidgetInspectorService.instance = service; + + tearDown(() { + service.resetAllState(); + WidgetInspectorService.instance = previousInstance; + }); + + testWidgets('ext.flutter.inspector.getLayoutExplorerNode does not throw StackOverflowError', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/115228 + const group = 'test-group'; + const Key leafKey = ValueKey<String>('ColoredBox'); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(CupertinoIcons.home), label: 'Tab 1'), + BottomNavigationBarItem(icon: Icon(CupertinoIcons.search), label: 'Tab 2'), + ], + ), + tabBuilder: (BuildContext context, int index) { + return Builder( + builder: (BuildContext context) { + return ColoredBox(key: leafKey, color: CupertinoTheme.of(context).primaryColor); + }, + ); + }, + ), + ), + ); + + final Element leaf = tester.element(find.byKey(leafKey)); + service.setSelection(leaf, group); + final DiagnosticsNode diagnostic = leaf.toDiagnosticsNode(); + final String id = service.toId(diagnostic, group)!; + + await service.testExtension( + WidgetInspectorServiceExtensions.getLayoutExplorerNode.name, + <String, String>{'id': id, 'groupName': group, 'subtreeDepth': '1'}, + ); + }); + } +} + +CupertinoTabBar _buildTabBar({int selectedTab = 0}) { + return CupertinoTabBar( + items: <BottomNavigationBarItem>[tabGenerator(0), tabGenerator(1)], + currentIndex: selectedTab, + onTap: (int newTab) => selectedTabs.add(newTab), + ); +} diff --git a/packages/cupertino_ui/test/cupertino/tab_test.dart b/packages/cupertino_ui/test/cupertino/tab_test.dart new file mode 100644 index 000000000000..7246b366e4a0 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/tab_test.dart @@ -0,0 +1,337 @@ +// 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/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +void main() { + testWidgets('Use home', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp(home: CupertinoTabView(builder: (BuildContext context) => const Text('home'))), + ); + + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('Use routes', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + routes: <String, WidgetBuilder>{'/': (BuildContext context) => const Text('first route')}, + ), + ), + ); + + expect(find.text('first route'), findsOneWidget); + }); + + testWidgets('Use home and named routes', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('go to second page'), + onPressed: () { + Navigator.of(context).pushNamed('/2'); + }, + ); + }, + routes: <String, WidgetBuilder>{ + '/2': (BuildContext context) => const Text('second named route'), + }, + ), + ), + ); + + expect(find.text('go to second page'), findsOneWidget); + await tester.tap(find.text('go to second page')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('second named route'), findsOneWidget); + }); + + testWidgets('Use onGenerateRoute', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + onGenerateRoute: (RouteSettings settings) { + if (settings.name == Navigator.defaultRouteName) { + return CupertinoPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return const Text('generated home'); + }, + ); + } + return null; + }, + ), + ), + ); + + expect(find.text('generated home'), findsOneWidget); + }); + + testWidgets( + 'Use onUnknownRoute', + experimentalLeakTesting: LeakTesting.settings + .withIgnoredAll(), // leaking by design because of exception + (WidgetTester tester) async { + late String unknownForRouteCalled; + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + onUnknownRoute: (RouteSettings settings) { + unknownForRouteCalled = settings.name!; + return null; + }, + ), + ), + ); + + expect(tester.takeException(), isFlutterError); + expect(unknownForRouteCalled, '/'); + }, + ); + + testWidgets('Can use navigatorKey to navigate', (WidgetTester tester) async { + final GlobalKey<NavigatorState> key = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + navigatorKey: key, + builder: (BuildContext context) => const Text('first route'), + routes: <String, WidgetBuilder>{ + '/2': (BuildContext context) => const Text('second route'), + }, + ), + ), + ); + + key.currentState!.pushNamed('/2'); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('second route'), findsOneWidget); + }); + + testWidgets('Changing the key resets the navigator', (WidgetTester tester) async { + final GlobalKey<NavigatorState> key = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('go to second page'), + onPressed: () { + Navigator.of(context).pushNamed('/2'); + }, + ); + }, + routes: <String, WidgetBuilder>{ + '/2': (BuildContext context) => const Text('second route'), + }, + ), + ), + ); + + expect(find.text('go to second page'), findsOneWidget); + await tester.tap(find.text('go to second page')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('second route'), findsOneWidget); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + key: key, + builder: (BuildContext context) { + return CupertinoButton( + child: const Text('go to second page'), + onPressed: () { + Navigator.of(context).pushNamed('/2'); + }, + ); + }, + routes: <String, WidgetBuilder>{ + '/2': (BuildContext context) => const Text('second route'), + }, + ), + ), + ); + + // The stack is gone and we're back to a re-built page 1. + expect(find.text('go to second page'), findsOneWidget); + expect(find.text('second route'), findsNothing); + }); + + testWidgets('Throws FlutterError when onUnknownRoute is null', (WidgetTester tester) async { + final GlobalKey<NavigatorState> key = GlobalKey(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + navigatorKey: key, + builder: (BuildContext context) => const Text('first route'), + ), + ), + ); + late FlutterError error; + try { + key.currentState!.pushNamed('/2'); + } on FlutterError catch (e) { + error = e; + } + expect(error, isNotNull); + expect( + error.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' Could not find a generator for route RouteSettings("/2", null) in\n' + ' the _CupertinoTabViewState.\n' + ' Generators for routes are searched for in the following order:\n' + ' 1. For the "/" route, the "builder" property, if non-null, is\n' + ' used.\n' + ' 2. Otherwise, the "routes" table is used, if it has an entry for\n' + ' the route.\n' + ' 3. Otherwise, onGenerateRoute is called. It should return a\n' + ' non-null value for any valid route not handled by "builder" and\n' + ' "routes".\n' + ' 4. Finally if all else fails onUnknownRoute is called.\n' + ' Unfortunately, onUnknownRoute was not set.\n', + ), + ); + }); + + testWidgets('Throws FlutterError when onUnknownRoute returns null', (WidgetTester tester) async { + final key = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabView( + navigatorKey: key, + builder: (BuildContext context) => const Text('first route'), + onUnknownRoute: (_) => null, + ), + ), + ); + late FlutterError error; + try { + key.currentState!.pushNamed('/2'); + } on FlutterError catch (e) { + error = e; + } + expect(error, isNotNull); + expect( + error.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' The onUnknownRoute callback returned null.\n' + ' When the _CupertinoTabViewState requested the route\n' + ' RouteSettings("/2", null) from its onUnknownRoute callback, the\n' + ' callback returned null. Such callbacks must never return null.\n', + ), + ); + }); + + testWidgets('Navigator of CupertinoTabView restores state', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + restorationScopeId: 'app', + home: CupertinoTabView( + restorationScopeId: 'tab', + builder: (BuildContext context) => CupertinoButton( + child: const Text('home'), + onPressed: () { + Navigator.of(context).restorablePushNamed('/2'); + }, + ), + routes: <String, WidgetBuilder>{ + '/2': (BuildContext context) => const Text('second route'), + }, + ), + ), + ); + + expect(find.text('home'), findsOneWidget); + await tester.tap(find.text('home')); + await tester.pumpAndSettle(); + + expect(find.text('home'), findsNothing); + expect(find.text('second route'), findsOneWidget); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.restartAndRestore(); + + expect(find.text('home'), findsNothing); + expect(find.text('second route'), findsOneWidget); + + Navigator.of(tester.element(find.text('second route'))).pop(); + await tester.pumpAndSettle(); + + expect(find.text('home'), findsOneWidget); + expect(find.text('second route'), findsNothing); + + await tester.restoreFrom(data); + + expect(find.text('home'), findsNothing); + expect(find.text('second route'), findsOneWidget); + + Navigator.of(tester.element(find.text('second route'))).pop(); + await tester.pumpAndSettle(); + + expect(find.text('home'), findsOneWidget); + expect(find.text('second route'), findsNothing); + }); + + testWidgets('Handles Android back button', (WidgetTester tester) async { + final key = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoTabScaffold( + tabBar: CupertinoTabBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: '', icon: Text('1')), + BottomNavigationBarItem(label: '', icon: Text('2')), + ], + ), + tabBuilder: (_, int i) => PopScope<Object?>( + canPop: false, + child: CupertinoTabView( + navigatorKey: key, + builder: (BuildContext context) => const Text('first route'), + ), + ), + ), + ), + ); + + expect(find.text('first route'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + + // Navigator didn't pop, so first route is still visible + expect(find.text('first route'), findsOneWidget); + }); + + testWidgets('CupertinoTabView does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoTabView(builder: (context) => const Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTabView)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/text_field_cursor_test.dart b/packages/cupertino_ui/test/cupertino/text_field_cursor_test.dart new file mode 100644 index 000000000000..8b7e174769d3 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_field_cursor_test.dart @@ -0,0 +1,70 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +@TestOn('!chrome') +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Cursor animates', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final Finder textFinder = find.byType(CupertinoTextField); + await tester.tap(textFinder); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor!.opacity, 1.0); + + var walltimeMicrosecond = 0; + var lastVerifiedOpacity = 1.0; + + Future<void> verifyKeyFrame({required double opacity, required int at}) async { + const delta = 1; + assert(at - delta > walltimeMicrosecond); + await tester.pump(Duration(microseconds: at - delta - walltimeMicrosecond)); + + // Instead of verifying the opacity at each key frame, this function + // verifies the opacity immediately *before* each key frame to avoid + // fp precision issues. + expect( + renderEditable.cursorColor!.opacity, + closeTo(lastVerifiedOpacity, 0.01), + reason: 'opacity at ${at - delta} microseconds', + ); + + walltimeMicrosecond = at - delta; + lastVerifiedOpacity = opacity; + } + + await verifyKeyFrame(opacity: 1.0, at: 500000); + await verifyKeyFrame(opacity: 0.75, at: 537500); + await verifyKeyFrame(opacity: 0.5, at: 575000); + await verifyKeyFrame(opacity: 0.25, at: 612500); + await verifyKeyFrame(opacity: 0.0, at: 650000); + await verifyKeyFrame(opacity: 0.0, at: 850000); + await verifyKeyFrame(opacity: 0.25, at: 887500); + await verifyKeyFrame(opacity: 0.5, at: 925000); + await verifyKeyFrame(opacity: 0.75, at: 962500); + await verifyKeyFrame(opacity: 1.0, at: 1000000); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Cursor radius is 2.0', (WidgetTester tester) async { + const Widget widget = CupertinoApp(home: CupertinoTextField(maxLines: 3)); + await tester.pumpWidget(widget); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + }, variant: TargetPlatformVariant.all()); +} diff --git a/packages/cupertino_ui/test/cupertino/text_field_restoration_test.dart b/packages/cupertino_ui/test/cupertino/text_field_restoration_test.dart new file mode 100644 index 000000000000..07522174eb0d --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_field_restoration_test.dart @@ -0,0 +1,108 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String text = 'Hello World! How are you? Life is good!'; +const String alternativeText = 'Everything is awesome!!'; + +void main() { + testWidgets('CupertinoTextField restoration', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(restorationScopeId: 'app', home: TestWidget())); + + await restoreAndVerify(tester); + }); + + testWidgets('CupertinoTextField restoration with external controller', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(restorationScopeId: 'app', home: TestWidget(useExternal: true)), + ); + + await restoreAndVerify(tester); + }); +} + +Future<void> restoreAndVerify(WidgetTester tester) async { + expect(find.text(text), findsNothing); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0); + + await tester.enterText(find.byType(CupertinoTextField), text); + await skipPastScrollingAnimation(tester); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0); + + await tester.drag(find.byType(Scrollable), const Offset(0, -80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); + + await tester.restartAndRestore(); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.enterText(find.byType(CupertinoTextField), alternativeText); + await skipPastScrollingAnimation(tester); + await tester.drag(find.byType(Scrollable), const Offset(0, 80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsNothing); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, isNot(60)); + + await tester.restoreFrom(data); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); +} + +class TestWidget extends StatefulWidget { + const TestWidget({super.key, this.useExternal = false}); + + final bool useExternal; + + @override + TestWidgetState createState() => TestWidgetState(); +} + +class TestWidgetState extends State<TestWidget> with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + + @override + String get restorationId => 'widget'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'controller'); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Align( + child: SizedBox( + width: 50, + child: CupertinoTextField( + restorationId: 'text', + maxLines: 3, + controller: widget.useExternal ? controller.value : null, + ), + ), + ); + } +} + +Future<void> skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} diff --git a/packages/cupertino_ui/test/cupertino/text_field_test.dart b/packages/cupertino_ui/test/cupertino/text_field_test.dart new file mode 100644 index 000000000000..10c7b730c139 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_field_test.dart @@ -0,0 +1,10946 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Color, SemanticsInputType; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' + show + DragStartBehavior, + PointerDeviceKind, + kDoubleTapTimeout, + kLongPressTimeout, + kSecondaryMouseButton; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import '../widgets/semantics_tester.dart'; +import '../widgets/text_selection_toolbar_utils.dart'; +import 'editable_text_utils.dart'; +import 'live_text_utils.dart'; + +class MockTextSelectionControls extends TextSelectionControls { + @override + Widget buildHandle( + BuildContext context, + TextSelectionHandleType type, + double textLineHeight, [ + VoidCallback? onTap, + ]) { + throw UnimplementedError(); + } + + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset position, + List<TextSelectionPoint> endpoints, + TextSelectionDelegate delegate, + ValueListenable<ClipboardStatus>? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + throw UnimplementedError(); + } + + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + throw UnimplementedError(); + } + + @override + Size getHandleSize(double textLineHeight) { + throw UnimplementedError(); + } +} + +class PathBoundsMatcher extends Matcher { + const PathBoundsMatcher({ + this.rectMatcher, + this.topMatcher, + this.leftMatcher, + this.rightMatcher, + this.bottomMatcher, + }) : super(); + + final Matcher? rectMatcher; + final Matcher? topMatcher; + final Matcher? leftMatcher; + final Matcher? rightMatcher; + final Matcher? bottomMatcher; + + @override + bool matches(covariant Path item, Map<dynamic, dynamic> matchState) { + final Rect bounds = item.getBounds(); + + final matchers = <Matcher?>[rectMatcher, topMatcher, leftMatcher, rightMatcher, bottomMatcher]; + final values = <dynamic>[bounds, bounds.top, bounds.left, bounds.right, bounds.bottom]; + final failedMatcher = <Matcher, dynamic>{}; + + for (var idx = 0; idx < matchers.length; idx++) { + if (!(matchers[idx]?.matches(values[idx], matchState) ?? true)) { + failedMatcher[matchers[idx]!] = values[idx]; + } + } + + matchState['failedMatcher'] = failedMatcher; + return failedMatcher.isEmpty; + } + + @override + Description describe(Description description) => + description.add('The actual Rect does not match'); + + @override + Description describeMismatch( + covariant Path item, + Description mismatchDescription, + Map<dynamic, dynamic> matchState, + bool verbose, + ) { + final Description description = super.describeMismatch( + item, + mismatchDescription, + matchState, + verbose, + ); + final map = matchState['failedMatcher'] as Map<Matcher, dynamic>; + final Iterable<String> descriptions = map.entries.map<String>( + (MapEntry<Matcher, dynamic> entry) => entry.key + .describeMismatch(entry.value, StringDescription(), matchState, verbose) + .toString(), + ); + + // description is guaranteed to be non-null. + return description + ..add('mismatch Rect: ${item.getBounds()}').addAll(': ', ', ', '. ', descriptions); + } +} + +class PathPointsMatcher extends Matcher { + const PathPointsMatcher({this.includes = const <Offset>[], this.excludes = const <Offset>[]}) + : super(); + + final Iterable<Offset> includes; + final Iterable<Offset> excludes; + + @override + bool matches(covariant Path item, Map<dynamic, dynamic> matchState) { + final Offset? notIncluded = includes.cast<Offset?>().firstWhere( + (Offset? offset) => !item.contains(offset!), + orElse: () => null, + ); + final Offset? notExcluded = excludes.cast<Offset?>().firstWhere( + (Offset? offset) => item.contains(offset!), + orElse: () => null, + ); + + matchState['notIncluded'] = notIncluded; + matchState['notExcluded'] = notExcluded; + return (notIncluded ?? notExcluded) == null; + } + + @override + Description describe(Description description) => + description.add('must include these points $includes and must not include $excludes'); + + @override + Description describeMismatch( + covariant Path item, + Description mismatchDescription, + Map<dynamic, dynamic> matchState, + bool verbose, + ) { + final notIncluded = matchState['notIncluded'] as Offset?; + final notExcluded = matchState['notExcluded'] as Offset?; + final Description desc = super.describeMismatch(item, mismatchDescription, matchState, verbose); + + if ((notExcluded ?? notIncluded) != null) { + desc.add('Within the bounds of the path ${item.getBounds()}: '); + } + + if (notIncluded != null) { + desc.add('$notIncluded is not included. '); + } + if (notExcluded != null) { + desc.add('$notExcluded is not excluded. '); + } + return desc; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + + // Returns the first RenderEditable. + RenderEditable findRenderEditable(WidgetTester tester) { + final RenderObject root = tester.renderObject(find.byType(EditableText)); + expect(root, isNotNull); + + RenderEditable? renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable!; + } + + List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { + return points.map<TextSelectionPoint>((TextSelectionPoint point) { + return TextSelectionPoint(box.localToGlobal(point.point), point.direction); + }).toList(); + } + + Offset textOffsetToBottomLeftPosition(WidgetTester tester, int offset) { + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(TextSelection.collapsed(offset: offset)), + renderEditable, + ); + expect(endpoints.length, 1); + return endpoints[0].point; + } + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + + EditableText.debugDeterministicCursor = false; + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + testWidgets( + 'Live Text button shows and hides correctly when LiveTextStatus changes', + (WidgetTester tester) async { + final liveTextInputTester = LiveTextInputTester(); + addTearDown(liveTextInputTester.dispose); + + final controller = TextEditingController(text: ''); + addTearDown(controller.dispose); + const Key key = ValueKey<String>('TextField'); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final Widget app = CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: CupertinoTextField(key: key, controller: controller, focusNode: focusNode), + ), + ), + ); + + liveTextInputTester.mockLiveTextInputEnabled = true; + await tester.pumpWidget(app); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + await tester.pumpAndSettle(); + expect(findLiveTextButton(), kIsWeb ? findsNothing : findsOneWidget); + + liveTextInputTester.mockLiveTextInputEnabled = false; + await tester.longPress(textFinder); + await tester.pumpAndSettle(); + expect(findLiveTextButton(), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Look Up shows up on iOS only', + (WidgetTester tester) async { + String? lastLookUp; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'LookUp.invoke') { + expect(methodCall.arguments, isA<String>()); + lastLookUp = methodCall.arguments as String; + } + return null; + }, + ); + + final controller = TextEditingController(text: 'Test'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final isTargetPlatformiOS = defaultTargetPlatform == TargetPlatform.iOS; + + // Long press to put the cursor after the "s". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(find.text('Look Up'), isTargetPlatformiOS ? findsOneWidget : findsNothing); + + if (isTargetPlatformiOS) { + await tester.tap(find.text('Look Up')); + expect(lastLookUp, 'Test'); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Search Web shows up on iOS only', + (WidgetTester tester) async { + String? lastSearch; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'SearchWeb.invoke') { + expect(methodCall.arguments, isA<String>()); + lastSearch = methodCall.arguments as String; + } + return null; + }, + ); + + final controller = TextEditingController(text: 'Test'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final isTargetPlatformiOS = defaultTargetPlatform == TargetPlatform.iOS; + + // Long press to put the cursor after the "s". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(find.text('Search Web'), isTargetPlatformiOS ? findsOneWidget : findsNothing); + + if (isTargetPlatformiOS) { + await tester.tap(find.text('Search Web')); + expect(lastSearch, 'Test'); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Share shows up on iOS and Android', + (WidgetTester tester) async { + String? lastShare; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'Share.invoke') { + expect(methodCall.arguments, isA<String>()); + lastShare = methodCall.arguments as String; + } + return null; + }, + ); + + final controller = TextEditingController(text: 'Test'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Long press to put the cursor after the "s". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(find.text('Share...'), findsOneWidget); + + await tester.tap(find.text('Share...')); + expect(lastShare, 'Test'); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'can use the desktop cut/copy/paste buttons on Mac', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(400, 200)), + child: CupertinoTextField(controller: controller), + ), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.byType(CupertinoButton), findsNothing); + + // Paste it at the end. + await gesture.down(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream), + ); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Paste')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2blah1'); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Cut the first word. + await gesture.down(midBlah1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + await tester.tap(find.text('Cut')); + await tester.pumpAndSettle(); + expect(controller.text, ' blah2blah1'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0)); + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + skip: kIsWeb, // [intended] the web handles this on its own. + ); + + testWidgets('can get text selection color initially on desktop', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: RepaintBoundary( + child: CupertinoTextField( + key: const ValueKey<int>(1), + controller: controller, + focusNode: focusNode, + ), + ), + ), + ), + ); + + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 11); + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, true); + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_golden.text_selection_color.0.png'), + ); + }); + + testWidgets( + 'Activates the text field when receives semantics focus on desktops', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget(CupertinoApp(home: CupertinoTextField(focusNode: focusNode))); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.didGainAccessibilityFocus, + SemanticsAction.didLoseAccessibilityFocus, + ], + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.didGainAccessibilityFocus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.didLoseAccessibilityFocus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + semantics.dispose(); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('takes available space horizontally and takes intrinsic space vertically no-strut', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(strutStyle: StrutStyle.disabled), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(CupertinoTextField)), + const Size(200, 31), // 31 is the height of the default font + padding etc. + ); + }); + + testWidgets('sets cursorOpacityAnimates on EditableText correctly', (WidgetTester tester) async { + // True + + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField(autofocus: true))); + await tester.pump(); + EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.cursorOpacityAnimates, true); + + // False + + await tester.pumpWidget( + const CupertinoApp(home: CupertinoTextField(autofocus: true, cursorOpacityAnimates: false)), + ); + await tester.pump(); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.cursorOpacityAnimates, false); + }); + + testWidgets('takes available space horizontally and takes intrinsic space vertically', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(CupertinoTextField)), + const Size(200, 31), // 31 is the height of the default font (17) + decoration (12). + ); + }); + + testWidgets( + 'selection handles color respects CupertinoTheme', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/74890. + const expectedSelectionHandleColor = Color.fromARGB(255, 10, 200, 255); + + final controller = TextEditingController(text: 'Some text.'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(selectionHandleColor: CupertinoColors.destructiveRed), + home: Center( + child: CupertinoTheme( + data: const CupertinoThemeData(selectionHandleColor: expectedSelectionHandleColor), + child: CupertinoTextField(controller: controller), + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + final Iterable<RenderBox> boxes = tester.renderObjectList<RenderBox>( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), + matching: find.byType(CustomPaint), + ), + ); + expect(boxes.length, 2); + + for (final box in boxes) { + expect(box, paints..path(color: expectedSelectionHandleColor)); + } + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('uses DefaultSelectionStyle for selection and cursor colors if provided', ( + WidgetTester tester, + ) async { + const selectionColor = Color(0xFF000000); + const cursorColor = Color(0xFFFFFFFF); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: DefaultSelectionStyle( + selectionColor: selectionColor, + cursorColor: cursorColor, + child: CupertinoTextField(autofocus: true), + ), + ), + ), + ); + await tester.pump(); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.widget.selectionColor, selectionColor); + expect(state.widget.cursorColor, cursorColor); + }); + + testWidgets('Text field drops selection color when losing focus', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/103341. + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + final controller1 = TextEditingController(); + addTearDown(controller1.dispose); + const Color selectionColor = CupertinoColors.activeOrange; + const Color cursorColor = CupertinoColors.destructiveRed; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: DefaultSelectionStyle( + selectionColor: selectionColor, + cursorColor: cursorColor, + child: Column( + children: <Widget>[ + CupertinoTextField(key: key1, controller: controller1), + CupertinoTextField(key: key2), + ], + ), + ), + ), + ), + ); + + const selection = TextSelection(baseOffset: 0, extentOffset: 4); + final EditableTextState state1 = tester.state<EditableTextState>( + find.byType(EditableText).first, + ); + final EditableTextState state2 = tester.state<EditableTextState>( + find.byType(EditableText).last, + ); + + await tester.tap(find.byKey(key1)); + await tester.enterText(find.byKey(key1), 'abcd'); + await tester.pump(); + + await tester.tap(find.byKey(key2)); + await tester.enterText(find.byKey(key2), 'dcba'); + await tester.pumpAndSettle(); + + // Focus and selection is active on first TextField, so the second TextFields + // selectionColor should be dropped. + await tester.tap(find.byKey(key1)); + controller1.selection = const TextSelection(baseOffset: 0, extentOffset: 4); + await tester.pump(); + expect(controller1.selection, selection); + expect(state1.widget.selectionColor, selectionColor); + expect(state2.widget.selectionColor, null); + + // Focus and selection is active on second TextField, so the first TextField + // selectionColor should be dropped. + await tester.tap(find.byKey(key2)); + await tester.pump(); + expect(state1.widget.selectionColor, null); + expect(state2.widget.selectionColor, selectionColor); + }); + + testWidgets('multi-lined text fields are intrinsically taller no-strut', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(maxLines: 3, strutStyle: StrutStyle.disabled), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(CupertinoTextField)), + const Size( + 200, + 65, + ), // 65 is the height of the default font (17) * maxlines (3) + decoration height (12). + ); + }); + + testWidgets('multi-lined text fields are intrinsically taller', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(maxLines: 3), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CupertinoTextField)), const Size(200, 65)); + }); + + testWidgets( + 'strut height override', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField( + maxLines: 3, + strutStyle: StrutStyle(fontSize: 8, forceStrutHeight: true), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CupertinoTextField)), const Size(200, 38)); + }, + // TODO(mdebbar): Strut styles support. + skip: isBrowser, // https://github.com/flutter/flutter/issues/32243 + ); + + testWidgets( + 'strut forces field taller', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField( + maxLines: 3, + style: TextStyle(fontSize: 10), + strutStyle: StrutStyle(fontSize: 18, forceStrutHeight: true), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CupertinoTextField)), const Size(200, 68)); + }, + // TODO(mdebbar): Strut styles support. + skip: isBrowser, // https://github.com/flutter/flutter/issues/32243 + ); + + testWidgets('default text field has a border', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoTextField()))); + + var decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.borderRadius, const BorderRadius.all(Radius.circular(5))); + expect(decoration.border!.bottom.color.value, 0x33000000); + + // Dark mode. + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoTextField()), + ), + ); + + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.borderRadius, const BorderRadius.all(Radius.circular(5))); + expect(decoration.border!.bottom.color.value, 0x33FFFFFF); + }); + + testWidgets( + 'The second CupertinoTextField is clicked, triggers the onTapOutside callback of the previous CupertinoTextField', + (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey keyC = GlobalKey(); + var outsideClickA = false; + var outsideClickB = false; + var outsideClickC = false; + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: <Widget>[ + const Text('Outside'), + CupertinoTextField( + key: keyA, + groupId: 'Group A', + onTapOutside: (PointerDownEvent event) { + outsideClickA = true; + }, + ), + CupertinoTextField( + key: keyB, + groupId: 'Group B', + onTapOutside: (PointerDownEvent event) { + outsideClickB = true; + }, + ), + CupertinoTextField( + key: keyC, + groupId: 'Group C', + onTapOutside: (PointerDownEvent event) { + outsideClickC = true; + }, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + + Future<void> click(Finder finder) async { + await tester.tap(finder); + await tester.enterText(finder, 'Hello'); + await tester.pump(); + } + + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyA)); + await tester.showKeyboard(find.byKey(keyA)); + await tester.idle(); + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyB)); + expect(outsideClickA, true); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyC)); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, false); + + await tester.tap(find.text('Outside')); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, true); + }, + ); + + testWidgets('decoration can be overridden', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoTextField(decoration: null))), + ); + + expect( + find.descendant(of: find.byType(CupertinoTextField), matching: find.byType(DecoratedBox)), + findsNothing, + ); + }); + + testWidgets('text entries are padded by default', (WidgetTester tester) async { + final controller = TextEditingController(text: 'initial'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + expect( + tester.getTopLeft(find.text('initial')) - tester.getTopLeft(find.byType(CupertinoTextField)), + const Offset(7.0, 7.0), + ); + }); + + testWidgets('iOS cursor has offset', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final EditableText editableText = tester.firstWidget(find.byType(EditableText)); + expect(editableText.cursorOffset, const Offset(-2.0 / 3.0, 0)); + }); + + testWidgets( + 'Cursor radius is 2.0', + (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('Cupertino cursor android golden', (WidgetTester tester) async { + final Widget widget = CupertinoApp( + home: Center( + child: RepaintBoundary( + key: const ValueKey<int>(1), + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(400, 400)), + child: const CupertinoTextField(), + ), + ), + ), + ); + await tester.pumpWidget(widget); + + const testValue = 'A short phrase'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pump(); + + await tester.tapAt(textOffsetToPosition(tester, testValue.length)); + await tester.pumpAndSettle(); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_cursor_test.cupertino.0.png'), + ); + }); + + testWidgets( + 'Cupertino cursor golden', + (WidgetTester tester) async { + final Widget widget = CupertinoApp( + home: Center( + child: RepaintBoundary( + key: const ValueKey<int>(1), + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(400, 400)), + child: const CupertinoTextField(), + ), + ), + ), + ); + await tester.pumpWidget(widget); + + const testValue = 'A short phrase'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pump(); + + await tester.tapAt(textOffsetToPosition(tester, testValue.length)); + await tester.pumpAndSettle(); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile( + 'text_field_cursor_test.cupertino_${debugDefaultTargetPlatformOverride!.name.toLowerCase()}.1.png', + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('can control text content via controller', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + controller.text = 'controller text'; + await tester.pump(); + + expect(find.text('controller text'), findsOneWidget); + + controller.text = ''; + await tester.pump(); + + expect(find.text('controller text'), findsNothing); + }); + + testWidgets('placeholder respects textAlign', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField(placeholder: 'placeholder', textAlign: TextAlign.right), + ), + ), + ); + + final Text placeholder = tester.widget(find.text('placeholder')); + expect(placeholder.textAlign, TextAlign.right); + + await tester.enterText(find.byType(CupertinoTextField), 'input'); + await tester.pump(); + + final EditableText inputText = tester.widget(find.text('input')); + expect(placeholder.textAlign, inputText.textAlign); + }); + + testWidgets('placeholder dark mode', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center( + child: CupertinoTextField(placeholder: 'placeholder', textAlign: TextAlign.right), + ), + ), + ); + + final Text placeholder = tester.widget(find.text('placeholder')); + expect(placeholder.style!.color!.value, CupertinoColors.placeholderText.darkColor.value); + }); + + testWidgets('placeholders are lightly colored and disappears once typing starts', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(placeholder: 'placeholder')), + ), + ); + + final Text placeholder = tester.widget(find.text('placeholder')); + expect(placeholder.style!.color!.value, CupertinoColors.placeholderText.color.value); + + await tester.enterText(find.byType(CupertinoTextField), 'input'); + await tester.pump(); + final Element element = tester.element(find.text('placeholder')); + expect(Visibility.of(element), false); + }); + + testWidgets("placeholderStyle modifies placeholder's style and doesn't affect text's style", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + placeholder: 'placeholder', + style: TextStyle(color: Color(0x00FFFFFF), fontWeight: FontWeight.w300), + placeholderStyle: TextStyle(color: Color(0xAAFFFFFF), fontWeight: FontWeight.w600), + ), + ), + ), + ); + + final Text placeholder = tester.widget(find.text('placeholder')); + expect(placeholder.style!.color, const Color(0xAAFFFFFF)); + expect(placeholder.style!.fontWeight, FontWeight.w600); + + await tester.enterText(find.byType(CupertinoTextField), 'input'); + await tester.pump(); + + final EditableText inputText = tester.widget(find.text('input')); + expect(inputText.style.color, const Color(0x00FFFFFF)); + expect(inputText.style.fontWeight, FontWeight.w300); + }); + + testWidgets('prefix widget is in front of the text', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + focusNode: focusNode, + prefix: const Icon(CupertinoIcons.add), + controller: controller, + ), + ), + ), + ); + + expect( + tester.getTopRight(find.byIcon(CupertinoIcons.add)).dx + + 7.0, // 7px standard padding around input. + tester.getTopLeft(find.byType(EditableText)).dx, + ); + + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + tester.getTopLeft(find.byType(CupertinoTextField)).dx + + tester.getSize(find.byIcon(CupertinoIcons.add)).width + + 7.0, + ); + }); + + testWidgets('prefix widget respects visibility mode', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + prefix: Icon(CupertinoIcons.add), + prefixMode: OverlayVisibilityMode.editing, + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.add), findsNothing); + // The position should just be the edge of the whole text field plus padding. + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + tester.getTopLeft(find.byType(CupertinoTextField)).dx + 7.0, + ); + + await tester.enterText(find.byType(CupertinoTextField), 'text input'); + await tester.pump(); + + expect(find.text('text input'), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add), findsOneWidget); + + // Text is now moved to the right. + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + tester.getTopLeft(find.byType(CupertinoTextField)).dx + + tester.getSize(find.byIcon(CupertinoIcons.add)).width + + 7.0, + ); + }); + + testWidgets('suffix widget is after the text', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField(focusNode: focusNode, suffix: const Icon(CupertinoIcons.add)), + ), + ), + ); + + expect( + tester.getTopRight(find.byType(EditableText)).dx + 7.0, + tester.getTopLeft(find.byIcon(CupertinoIcons.add)).dx, // 7px standard padding around input. + ); + + expect( + tester.getTopRight(find.byType(EditableText)).dx, + tester.getTopRight(find.byType(CupertinoTextField)).dx - + tester.getSize(find.byIcon(CupertinoIcons.add)).width - + 7.0, + ); + }); + + testWidgets('suffix widget respects visibility mode', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + suffix: Icon(CupertinoIcons.add), + suffixMode: OverlayVisibilityMode.notEditing, + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.add), findsOneWidget); + + await tester.enterText(find.byType(CupertinoTextField), 'text input'); + await tester.pump(); + + expect(find.text('text input'), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add), findsNothing); + }); + + testWidgets('can customize padding', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(padding: EdgeInsets.zero)), + ), + ); + + expect( + tester.getSize(find.byType(EditableText)), + tester.getSize(find.byType(CupertinoTextField)), + ); + }); + + testWidgets('padding is in between prefix and suffix no-strut', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + padding: EdgeInsets.all(20.0), + prefix: SizedBox(height: 100.0, width: 100.0), + suffix: SizedBox(height: 50.0, width: 50.0), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + // Size of prefix + padding. + 100.0 + 20.0, + ); + + expect(tester.getTopLeft(find.byType(EditableText)).dy, 291.5); + + expect(tester.getTopRight(find.byType(EditableText)).dx, 800.0 - 50.0 - 20.0); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + padding: EdgeInsets.all(30.0), + prefix: SizedBox(height: 100.0, width: 100.0), + suffix: SizedBox(height: 50.0, width: 50.0), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ); + + expect(tester.getTopLeft(find.byType(EditableText)).dx, 100.0 + 30.0); + + // Since the highest component, the prefix box, is higher than + // the text + paddings, the text's vertical position isn't affected. + expect(tester.getTopLeft(find.byType(EditableText)).dy, 291.5); + + expect(tester.getTopRight(find.byType(EditableText)).dx, 800.0 - 50.0 - 30.0); + }); + + testWidgets('padding is in between prefix and suffix', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + padding: EdgeInsets.all(20.0), + prefix: SizedBox(height: 100.0, width: 100.0), + suffix: SizedBox(height: 50.0, width: 50.0), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byType(EditableText)).dx, + // Size of prefix + padding. + 100.0 + 20.0, + ); + + expect(tester.getTopLeft(find.byType(EditableText)).dy, 291.5); + + expect(tester.getTopRight(find.byType(EditableText)).dx, 800.0 - 50.0 - 20.0); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + padding: EdgeInsets.all(30.0), + prefix: SizedBox(height: 100.0, width: 100.0), + suffix: SizedBox(height: 50.0, width: 50.0), + ), + ), + ), + ); + + expect(tester.getTopLeft(find.byType(EditableText)).dx, 100.0 + 30.0); + + // Since the highest component, the prefix box, is higher than + // the text + paddings, the text's vertical position isn't affected. + expect(tester.getTopLeft(find.byType(EditableText)).dy, 291.5); + + expect(tester.getTopRight(find.byType(EditableText)).dx, 800.0 - 50.0 - 30.0); + }); + + testWidgets('clear button shows with right visibility mode', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + placeholder: 'placeholder does not affect clear button', + clearButtonMode: OverlayVisibilityMode.always, + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsOneWidget); + + expect( + tester.getTopRight(find.byType(EditableText)).dx, + 800.0 - 30.0 /* size of button */ - 7.0 /* padding */, + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + placeholder: 'placeholder does not affect clear button', + clearButtonMode: OverlayVisibilityMode.editing, + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsNothing); + expect(tester.getTopRight(find.byType(EditableText)).dx, 800.0 - 7.0 /* padding */); + + await tester.enterText(find.byType(CupertinoTextField), 'text input'); + await tester.pump(); + + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsOneWidget); + expect(find.text('text input'), findsOneWidget); + expect(tester.getTopRight(find.byType(EditableText)).dx, 800.0 - 30.0 - 7.0); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + placeholder: 'placeholder does not affect clear button', + clearButtonMode: OverlayVisibilityMode.notEditing, + ), + ), + ), + ); + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsNothing); + + controller.text = ''; + await tester.pump(); + + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsOneWidget); + }); + + testWidgets('clear button removes text', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + placeholder: 'placeholder', + clearButtonMode: OverlayVisibilityMode.editing, + ), + ), + ), + ); + + controller.text = 'text entry'; + await tester.pump(); + + await tester.tap(find.byIcon(CupertinoIcons.clear_thick_circled)); + await tester.pump(); + + expect(controller.text, ''); + expect(find.text('placeholder'), findsOneWidget); + expect(find.text('text entry'), findsNothing); + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsNothing); + }); + + testWidgets('tapping clear button also calls onChanged when text not empty', ( + WidgetTester tester, + ) async { + var value = 'text entry'; + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + placeholder: 'placeholder', + onChanged: (String newValue) => value = newValue, + clearButtonMode: OverlayVisibilityMode.always, + ), + ), + ), + ); + + controller.text = value; + await tester.pump(); + + await tester.tap(find.byIcon(CupertinoIcons.clear_thick_circled)); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(find.text('text entry'), findsNothing); + expect(value, isEmpty); + }); + + testWidgets('clear button yields precedence to suffix', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + clearButtonMode: OverlayVisibilityMode.always, + suffix: const Icon(CupertinoIcons.add_circled_solid), + suffixMode: OverlayVisibilityMode.editing, + ), + ), + ), + ); + + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add_circled_solid), findsNothing); + + expect( + tester.getTopRight(find.byType(EditableText)).dx, + 800.0 - 30.0 /* size of button */ - 7.0 /* padding */, + ); + + controller.text = 'non empty text'; + await tester.pump(); + + expect(find.byIcon(CupertinoIcons.clear_thick_circled), findsNothing); + expect(find.byIcon(CupertinoIcons.add_circled_solid), findsOneWidget); + + // Still just takes the space of one widget. + expect( + tester.getTopRight(find.byType(EditableText)).dx, + 800.0 - 24.0 /* size of button */ - 7.0 /* padding */, + ); + }); + + testWidgets('font style controls intrinsic height no-strut', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(strutStyle: StrutStyle.disabled)), + ), + ); + + expect(tester.getSize(find.byType(CupertinoTextField)).height, 31.0); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + style: TextStyle( + // A larger font. + fontSize: 50.0, + ), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CupertinoTextField)).height, 64.0); + }); + + testWidgets('font style controls intrinsic height', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoTextField()))); + + expect(tester.getSize(find.byType(CupertinoTextField)).height, 31.0); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + style: TextStyle( + // A larger font. + fontSize: 50.0, + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CupertinoTextField)).height, 64.0); + }); + + testWidgets('RTL puts attachments to the right places', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: CupertinoTextField( + padding: EdgeInsets.all(20.0), + prefix: Icon(CupertinoIcons.book), + clearButtonMode: OverlayVisibilityMode.always, + ), + ), + ), + ), + ); + + expect(tester.getTopLeft(find.byIcon(CupertinoIcons.book)).dx, 800.0 - 24.0); + + expect(tester.getTopRight(find.byIcon(CupertinoIcons.clear_thick_circled)).dx, 24.0); + }); + + testWidgets('text fields with no max lines can grow no-strut', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(maxLines: null, strutStyle: StrutStyle.disabled)), + ), + ); + + expect( + tester.getSize(find.byType(CupertinoTextField)).height, + 31.0, // Initially one line high. + ); + + await tester.enterText(find.byType(CupertinoTextField), '\n'); + await tester.pump(); + + expect( + tester.getSize(find.byType(CupertinoTextField)).height, + 48.0, // Initially one line high. + ); + }); + + testWidgets('text fields with no max lines can grow', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoTextField(maxLines: null))), + ); + + expect( + tester.getSize(find.byType(CupertinoTextField)).height, + 31.0, // Initially one line high. + ); + + await tester.enterText(find.byType(CupertinoTextField), '\n'); + await tester.pump(); + + expect( + tester.getSize(find.byType(CupertinoTextField)).height, + 48.0, // Initially one line high. + ); + }); + + testWidgets('cannot enter new lines onto single line TextField', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), 'abc\ndef'); + + expect(controller.text, 'abcdef'); + }); + + testWidgets( + 'toolbar colors change with theme brightness, but nothing else', + (WidgetTester tester) async { + final controller = TextEditingController(text: "j'aime la poutine"); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Column(children: <Widget>[CupertinoTextField(controller: controller)]), + ), + ); + + await tester.longPressAt(tester.getTopRight(find.text("j'aime la poutine"))); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + + Text text = tester.widget<Text>(find.text('Paste')); + expect(text.style!.color!.value, CupertinoColors.black.value); + expect(text.style!.fontSize, 15); + expect(text.style!.letterSpacing, -0.15); + expect(text.style!.fontWeight, FontWeight.w400); + + // Change the theme. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData( + brightness: Brightness.dark, + textTheme: CupertinoTextThemeData( + textStyle: TextStyle(fontSize: 100, fontWeight: FontWeight.w800), + ), + ), + home: Column(children: <Widget>[CupertinoTextField(controller: controller)]), + ), + ); + + await tester.longPressAt(tester.getTopRight(find.text("j'aime la poutine"))); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + + text = tester.widget<Text>(find.text('Paste')); + // The toolbar buttons' text are still the same style. + expect(text.style!.color!.value, CupertinoColors.white.value); + expect(text.style!.fontSize, 15); + expect(text.style!.letterSpacing, -0.15); + expect(text.style!.fontWeight, FontWeight.w400); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'text field toolbar options correctly changes options on Apple Platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoTextField( + autofocus: true, + controller: controller, + toolbarOptions: const ToolbarOptions(copy: true), + ), + ], + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Long press to put the cursor after the "w". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: index)); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // Selected text shows 'Copy'. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'text field toolbar options correctly changes options on non-Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoTextField( + controller: controller, + toolbarOptions: const ToolbarOptions(copy: true), + ), + ], + ), + ), + ); + + // Long press to select 'Atwater' + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // Tap elsewhere to hide the context menu so that subsequent taps don't + // collide with it. + await tester.tapAt(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream), + ); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // Selected text shows 'Copy'. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsNothing); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Read only text field', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'readonly'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[CupertinoTextField(controller: controller, readOnly: true)], + ), + ), + ); + // Read only text field cannot open keyboard. + await tester.showKeyboard(find.byType(CupertinoTextField)); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.longPressAt(tester.getTopRight(find.text('readonly'))); + + await tester.pumpAndSettle(); + + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsOneWidget); + + await tester.tap(find.text('Select All')); + await tester.pump(); + + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'copy paste', + (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoTextField(placeholder: 'field 1'), + CupertinoTextField(placeholder: 'field 2'), + ], + ), + ), + ); + + await tester.enterText( + find.widgetWithText(CupertinoTextField, 'field 1'), + "j'aime la poutine", + ); + await tester.pump(); + + // Tap an area inside the EditableText but with no text. + await tester.longPressAt(tester.getTopRight(find.text("j'aime la poutine"))); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.text('Select All')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.text('Cut')); + await tester.pump(); + + // Placeholder 1 is back since the text is cut. + expect(find.text('field 1'), findsOneWidget); + expect(find.text('field 2'), findsOneWidget); + + await tester.longPress( + find.text('field 2'), + warnIfMissed: false, + ); // can't actually hit placeholder + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.tap(find.text('Paste')); + await tester.pump(); + + expect(find.text('field 1'), findsOneWidget); + expect(find.text("j'aime la poutine"), findsOneWidget); + + final Element placeholder2Element = tester.element(find.text('field 2')); + expect(Visibility.of(placeholder2Element), false); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'tap moves cursor to the edge of the word it tapped on', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(50.0, 5.0)); + await tester.pump(); + + // We moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + // But don't trigger the toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'slow double tap does not trigger double tap', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset pos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r'. + + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(pos); + await tester.pump(); + + // Plain collapsed selection. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 7 : 6); + + // Toolbar shows on mobile. + if (isTargetPlatformIOS) { + expectCupertinoToolbarForCollapsedSelection(); + } else { + // After a tap, macOS does not show a selection toolbar for a collapsed selection. + expectNoCupertinoToolbar(); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Tapping on a collapsed selection toggles the toolbar', + (WidgetTester tester) async { + final controller = TextEditingController( + text: + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neigse Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller, maxLines: 2)), + ), + ); + + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final Offset begPos = textOffsetToPosition(tester, 0); + final Offset endPos = + textOffsetToPosition(tester, 35) + + const Offset( + 200.0, + 0.0, + ); // Index of 'Bonaventure|' + Offset(200.0,0), which is at the end of the first line. + final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'. + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'. + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(wPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(vPos); + await tester.pump(const Duration(milliseconds: 500)); + // First tap moved the cursor. Here we tap the position where 'v' is located. + // On iOS this will select the closest word edge, in this case the cursor is placed + // at the end of the word 'Bonaventure|'. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expect(find.byType(CupertinoButton), findsNothing); + + await tester.tapAt(vPos); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + // Second tap toggles the toolbar. Here we tap on 'v' again, and select the word edge. Since + // the selection has not changed we toggle the toolbar. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expectCupertinoToolbarForCollapsedSelection(); + + // Tap the 'v' position again to hide the toolbar. + await tester.tapAt(vPos); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expect(find.byType(CupertinoButton), findsNothing); + + // Long press at the end of the first line to move the cursor to the end of the first line + // where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR, + // the TextAffinity will be upstream and against the natural direction. The toolbar is also + // shown after a long press. + await tester.longPressAt(endPos); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 46); + expect(controller.selection.affinity, TextAffinity.upstream); + expectCupertinoToolbarForCollapsedSelection(); + + // Tap at the same position to toggle the toolbar. + await tester.tapAt(endPos); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 46); + expect(controller.selection.affinity, TextAffinity.upstream); + expectNoCupertinoToolbar(); + + // Tap at the beginning of the second line to move the cursor to the front of the first word on the + // second line, where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR, + // the TextAffinity will be downstream and following the natural direction. The toolbar will be hidden after this tap. + await tester.tapAt(begPos + Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 46); + expect(controller.selection.affinity, TextAffinity.downstream); + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Tapping on a non-collapsed selection toggles the toolbar and retains the selection', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'. + final Offset ePos = + textOffsetToPosition(tester, 35) + + const Offset( + 7.0, + 0.0, + ); // Index of 'Bonaventure|' + Offset(7.0,0), which taps slightly to the right of the end of the text. + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'. + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(wPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(vPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + await tester.tapAt(vPos); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 24, extentOffset: 35)); + + expectCupertinoToolbarForPartialSelection(); + + // Tap the selected word to hide the toolbar and retain the selection. + await tester.tapAt(vPos); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 24, extentOffset: 35)); + expect(find.byType(CupertinoButton), findsNothing); + + // Tap the selected word to show the toolbar and retain the selection. + await tester.tapAt(vPos); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 24, extentOffset: 35)); + + expectCupertinoToolbarForPartialSelection(); + + // Tap past the selected word to move the cursor and hide the toolbar. + await tester.tapAt(ePos); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'double tap selects word for non-Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Long press to select 'Atwater'. + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // Tap elsewhere to hide the context menu so that subsequent taps don't + // collide with it. + await tester.tapAt(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream), + ); + + // Double tap in the middle of 'Peel' to select the word. + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // The toolbar now shows up. + expectCupertinoToolbarForPartialSelection(); + + // Tap somewhere else to move the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: index)); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'double tap selects word for Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Long press to put the cursor after the "w". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: index)); + + // Double tap to select the word around the cursor. Move slightly left of + // the previous tap in order to avoid hitting the text selection toolbar + // on Mac. + await tester.tapAt(textOffsetToPosition(tester, index) - const Offset(1.0, 0.0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('double tap does not select word on read-only obscured field', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField(readOnly: true, obscureText: true, controller: controller), + ), + ), + ); + + // Long press to put the cursor after the "w". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Second tap doesn't select anything. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 35)); + + // Selected text shows nothing. + expect(find.byType(CupertinoButton), findsNothing); + }); + + testWidgets('Can double click + drag with a mouse to select word by word', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + }); + + testWidgets('Can double tap + drag to select word by word', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + // Toolbar should be hidden during a drag. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + + // Toolbar should re-appear after a drag. + await gesture.up(); + await tester.pump(); + expectCupertinoToolbarForPartialSelection(); + + // Skip the magnifier hide animation, so it can release resources. + await tester.pump(const Duration(milliseconds: 150)); + }); + + testWidgets('Readonly text field does not have tap action', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoTextField(maxLength: 10, readOnly: true))), + ); + + expect( + semantics, + isNot( + includesNodeWith(actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus]), + ), + ); + + semantics.dispose(); + }); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r'. + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'. + + await tester.tapAt(ePos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 12 : 9); + + await tester.tapAt(pPos); + await tester.pumpAndSettle(); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('double tap hold selects word', (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(150.0, 5.0), + ); + // Hold the press. + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + expectCupertinoToolbarForPartialSelection(); + + await gesture.up(); + await tester.pump(); + + // Still selected. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoToolbarForPartialSelection(); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'tap after a double tap select is not affected', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'. + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 12 : 9); + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(ePos); + await tester.pump(); + + // Plain collapsed selection at the edge of first word. In iOS 12, the + // first tap after a double tap ends up putting the cursor at where + // you tapped instead of the edge like every other single tap. This is + // likely a bug in iOS 12 and not present in other versions. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 7 : 6); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tapping a space selects the previous word on iOS', + (WidgetTester tester) async { + final controller = TextEditingController(text: ' blah blah \n blah'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller, maxLines: 2)), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the second space selects the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 1); + expect(controller.value.selection.extentOffset, 5); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the first space selects the space. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the last space selects all previous contiguous spaces on + // both lines and the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 6); + expect(controller.value.selection.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'double tapping a space selects the space on Mac', + (WidgetTester tester) async { + final controller = TextEditingController(text: ' blah blah'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 5); + expect(controller.value.selection.extentOffset, 6); + + // Tap at the end of the text to move the selection to the end. On some + // platforms, the context menu "Cut" button blocks this tap, so move it out + // of the way by an Offset. + await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the first space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + ); + + testWidgets( + 'double clicking a space selects the space on Mac', + (WidgetTester tester) async { + final controller = TextEditingController(text: ' blah blah'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 10), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await gesture.down(textOffsetToPosition(tester, 5)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.down(textOffsetToPosition(tester, 5)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 5); + expect(controller.value.selection.extentOffset, 6); + + // Put the cursor at the end of the field. + await gesture.down(textOffsetToPosition(tester, 10)); + await tester.pump(); + await gesture.up(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the first space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await gesture.down(textOffsetToPosition(tester, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.down(textOffsetToPosition(tester, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + ); + + testWidgets('An obscured CupertinoTextField is not selectable when disabled', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + obscureText: true, + enableInteractiveSelection: false, + ), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(150.0, 5.0), + ); + // Hold the press. + await tester.pump(const Duration(milliseconds: 500)); + + // Nothing is selected despite the double tap long press gesture. + expect(controller.selection, const TextSelection(baseOffset: 35, extentOffset: 35)); + + // The selection menu is not present. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pump(); + + // Still nothing selected and no selection menu. + expect(controller.selection, const TextSelection(baseOffset: 35, extentOffset: 35)); + expectNoCupertinoToolbar(); + }); + + testWidgets('A read-only obscured CupertinoTextField is not selectable', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField(controller: controller, obscureText: true, readOnly: true), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(150.0, 5.0), + ); + // Hold the press. + await tester.pump(const Duration(milliseconds: 500)); + + // Nothing is selected despite the double tap long press gesture. + expect(controller.selection, const TextSelection(baseOffset: 35, extentOffset: 35)); + + // The selection menu is not present. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pump(); + + // Still nothing selected and no selection menu. + expect(controller.selection, const TextSelection.collapsed(offset: 35)); + expectNoCupertinoToolbar(); + }); + + testWidgets('An obscured CupertinoTextField is selectable by default', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller, obscureText: true)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(150.0, 5.0), + ); + // Hold the press. + await tester.pumpAndSettle(); + + // The obscured text is treated as one word, should select all + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + + // Selected text shows paste toolbar button. + expect( + find.byType(CupertinoButton), + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(1), + ); + + await gesture.up(); + await tester.pump(); + + // Still selected. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + + expect( + find.byType(CupertinoButton), + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(1), + ); + }); + + testWidgets( + 'An obscured TextField has correct default context menu', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller, obscureText: true)), + ), + ); + + final Offset textFieldStart = tester.getCenter(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.longPressAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pumpAndSettle(); + + // Should only have paste option when whole obscure text is selected. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsNothing); + + // Tap to cancel selection. + final Offset textFieldEnd = tester.getTopRight(find.byType(CupertinoTextField)); + await tester.tapAt(textFieldEnd + const Offset(-10.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // Long tap at the end. + await tester.longPressAt(textFieldEnd + const Offset(-10.0, 5.0)); + await tester.pumpAndSettle(); + + // Should have paste and select all options when collapse. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'long press selects the word at the long press position and shows toolbar on non-Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.longPressAt(textFieldStart + const Offset(50.0, 5.0)); + await tester.pumpAndSettle(); + + // Select word, 'Atwater, on long press. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + + expectCupertinoToolbarForPartialSelection(); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'long press moves cursor to the exact long press position and shows toolbar on Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.longPressAt(textFieldStart + const Offset(50.0, 5.0)); + await tester.pumpAndSettle(); + + // Collapsed cursor for iOS long press. + expect( + controller.selection, + const TextSelection.collapsed(offset: 3, affinity: TextAffinity.upstream), + ); + + expectCupertinoToolbarForCollapsedSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press tap cannot initiate a double tap', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' + + await tester.longPressAt(ePos); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + + expectCupertinoToolbarForCollapsedSelection(); + + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 6); + + // Tap in a slightly different position to avoid hitting the context menu + // on desktop. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + final Offset secondTapPos = isTargetPlatformIOS ? ePos : ePos + const Offset(-1.0, 0.0); + await tester.tapAt(secondTapPos); + await tester.pump(); + + // The cursor does not move and the toolbar is toggled. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 6); + + // The toolbar from the long press is now dismissed by the second tap. + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press drag selects word by word and shows toolbar on lift on non-Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(50.0, 5.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on non-Apple platforms selects the word at the long press position. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + // Toolbar only shows up on long press up. + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection is extended word by word to the drag position. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 12, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + + // The selection is extended word by word to the drag position. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 23, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 23, affinity: TextAffinity.upstream), + ); + + // The toolbar now shows up. + expectCupertinoToolbarForPartialSelection(); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'long press drag on a focused TextField moves the cursor under the drag and shows toolbar on lift on Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(50.0, 5.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on iOS shows collapsed selection cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 3, affinity: TextAffinity.upstream), + ); + // Toolbar only shows up on long press up. + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection.collapsed(offset: 6, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expectCupertinoToolbarForCollapsedSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press drag can edge scroll on non-Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(1094.73, epsilon: 0.25)); + + final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final TestGesture gesture = await tester.startGesture(textfieldStart); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(950, 5)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 59)); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 66)); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); + + // The toolbar now shows up. + expectCupertinoToolbarForFullSelection(); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(785.40, epsilon: 1)); + + final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-310.30, epsilon: 1)); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'long press drag can edge scroll on Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final RenderEditable renderEditable = tester.renderObject<RenderEditable>( + find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last, + ); + + List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(1094.73, epsilon: 0.25)); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final TestGesture gesture = await tester.startGesture(textFieldStart + const Offset(300, 5)); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 18, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(600, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 54, affinity: TextAffinity.upstream), + ); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 61, affinity: TextAffinity.upstream), + ); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expectCupertinoToolbarForCollapsedSelection(); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(784.73, epsilon: 0.25)); + + final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-310.20, epsilon: 0.25)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long tap after a double tap select is not affected', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel' + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor to the beginning of the second word. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 12 : 9); + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.longPressAt(ePos); + await tester.pumpAndSettle(); + + // Plain collapsed selection at the exact tap position. + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + + // Long press toolbar. + expectCupertinoToolbarForCollapsedSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap after a long tap is not affected', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Use a position higher than wPos to avoid tapping the context menu on + // desktop. + final Offset pPos = + textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel' + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater' + + await tester.longPressAt(wPos); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 3); + expectCupertinoToolbarForCollapsedSelection(); + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + + // First tap moved the cursor. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 12 : 9); + + await tester.tapAt(pPos); + await tester.pumpAndSettle(); + + // Double tap selection. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap chains work', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textFieldStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + await tester.tapAt(textFieldStart + const Offset(50.0, 5.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + // Double tap selecting the same word somewhere else is fine. + await tester.tapAt(textFieldStart + const Offset(100.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap hides the toolbar, and retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expect(find.byType(CupertinoButton), findsNothing); + // Second tap shows the toolbar, and retains the selection. + await tester.tapAt(textFieldStart + const Offset(100.0, 5.0)); + // Wait for the consecutive tap timer to timeout so the next + // tap is not detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor and hides the toolbar. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + group('Triple tap/click', () { + const testValueA = + 'Now is the time for\n' // 20 + 'all good people\n' // 20 + 16 => 36 + 'to come to the aid\n' // 36 + 19 => 55 + 'of their country.'; // 55 + 17 => 72 + const testValueB = + 'Today is the time for\n' // 22 + 'all good people\n' // 22 + 16 => 38 + 'to come to the aid\n' // 38 + 19 => 57 + 'of their country.'; // 57 + 17 => 74 + testWidgets( + 'Can triple tap to select a paragraph on mobile platforms when tapping at a word edge', + (WidgetTester tester) async { + // TODO(Renzo-Olivares): Enable, currently broken because selection overlay blocks the TextSelectionGestureDetector. + final controller = TextEditingController(); + addTearDown(controller.dispose); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueA); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = + tester.getTopLeft(find.byType(CupertinoTextField)) + const Offset(110.0, 9.0); + + // Tap on text field to gain focus, and set selection to 'is|' on the first line. + final TestGesture gesture = await tester.startGesture(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 6); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. On iOS, tapping a whitespace selects the previous word. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, isTargetPlatformApple ? 4 : 6); + expect(controller.selection.extentOffset, isTargetPlatformApple ? 6 : 7); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + }, + variant: TargetPlatformVariant.mobile(), + skip: true, // https://github.com/flutter/flutter/issues/123415 + ); + + testWidgets( + 'Can triple tap to select a paragraph on mobile platforms', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueB); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueB); + + final Offset firstLinePos = + tester.getTopLeft(find.byType(CupertinoTextField)) + const Offset(50.0, 9.0); + + // Tap on text field to gain focus, and move the selection. + final TestGesture gesture = await tester.startGesture(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformApple ? 5 : 3); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 5); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 22); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets( + 'Triple click at the beginning of a line should not select the previous paragraph', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/132126 + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueB); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueB); + + final Offset thirdLinePos = textOffsetToPosition(tester, 38); + + // Click on text field to gain focus, and move the selection. + final TestGesture gesture = await tester.startGesture( + thirdLinePos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 38); + + // Here we click on same position again, to register a double click. This will select + // the word at the clicked position. + await gesture.down(thirdLinePos); + await gesture.up(); + + expect(controller.selection.baseOffset, 38); + expect(controller.selection.extentOffset, 40); + + // Here we click on same position again, to register a triple click. This will select + // the paragraph at the clicked position. + await gesture.down(thirdLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 38); + expect(controller.selection.extentOffset, 57); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'Triple click at the end of text should select the previous paragraph', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/132126. + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueB); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueB); + + final Offset endOfTextPos = textOffsetToPosition(tester, 74); + + // Click on text field to gain focus, and move the selection. + final TestGesture gesture = await tester.startGesture( + endOfTextPos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 74); + + // Here we click on same position again, to register a double click. + await gesture.down(endOfTextPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 74); + expect(controller.selection.extentOffset, 74); + + // Here we click on same position again, to register a triple click. This will select + // the paragraph at the clicked position. + await gesture.down(endOfTextPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 57); + expect(controller.selection.extentOffset, 74); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'triple tap chains work on Non-Apple mobile platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 3); + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + // Triple tap selecting the same paragraph somewhere else is fine. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap hides the toolbar and moves the selection. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 6); + expectNoCupertinoToolbar(); + + // Second tap shows the toolbar and selects the word. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + // Third tap shows the toolbar and selects the paragraph. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + expectCupertinoToolbarForFullSelection(); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor and hid the toolbar. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 9); + expect(find.byType(CupertinoButton), findsNothing); + // Second tap selects the word. + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoToolbarForPartialSelection(); + + // Third tap selects the paragraph and shows the toolbar. + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + expectCupertinoToolbarForFullSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'triple tap chains work on Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure\nThe fox jumped over the fence.', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 36)); + + // Triple tap selecting the same paragraph somewhere else is fine. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap hides the toolbar and retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 36)); + expect(find.byType(CupertinoButton), findsNothing); + + // Second tap shows the toolbar and selects the word. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + // Third tap shows the toolbar and selects the paragraph. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 36)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(150.0, 25.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor and hid the toolbar. + expect( + controller.selection, + const TextSelection.collapsed(offset: 50, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + // Second tap selects the word. + await tester.tapAt(textfieldStart + const Offset(150.0, 25.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 44, extentOffset: 50)); + expectCupertinoToolbarForPartialSelection(); + + // Third tap selects the paragraph and shows the toolbar. + await tester.tapAt(textfieldStart + const Offset(150.0, 25.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 36, extentOffset: 66)); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('triple click chains work', (WidgetTester tester) async { + final controller = TextEditingController(text: testValueA); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + final platformSelectsByLine = defaultTargetPlatform == TargetPlatform.linux; + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(200.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 12); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so the next + // tap is not detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + // Triple click selecting the same paragraph somewhere else is fine. + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First click moved the cursor. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 6); + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Second click selected the word. + expect(controller.selection, const TextSelection(baseOffset: 4, extentOffset: 6)); + + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so the tap count + // is reset. + await tester.pumpAndSettle(kDoubleTapTimeout); + // Third click selected the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First click moved the cursor. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 9); + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Second click selected the word. + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 10)); + + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Third click selects the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('triple click after a click on desktop platforms', (WidgetTester tester) async { + final controller = TextEditingController(text: testValueA); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + final platformSelectsByLine = defaultTargetPlatform == TargetPlatform.linux; + + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(50.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 3); + // First click moves the selection. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 9); + + // Double click selection to select a word. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 10)); + + // Triple click selection to select a paragraph. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'Can triple tap to select all on a single-line textfield on mobile platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: testValueB); + addTearDown(controller.dispose); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset firstLinePos = + tester.getTopLeft(find.byType(CupertinoTextField)) + const Offset(50.0, 9.0); + + // Tap on text field to gain focus, and set selection somewhere on the first word. + final TestGesture gesture = await tester.startGesture(firstLinePos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformApple ? 5 : 3); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 5); + + // Here we tap on same position again, to register a triple tap. This will select + // the entire text field if it is a single-line field. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 74); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets( + 'Can triple click to select all on a single-line textfield on desktop platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: testValueA); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on same position again, to register a triple tap. This will select + // the entire text field if it is a single-line field. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 72); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'Can triple click to select a line on Linux', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueA); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 19); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + testWidgets( + 'Can triple click to select a paragraph', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueA); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'Can triple click + drag to select line by line on Linux', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueA); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on the same position again, to register a triple tap. This will select + // the line at the tapped position. + await gesture.down(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 19); + + // Drag, down after the triple tap, to select line by line. + // Moving down will extend the selection to the second line. + await gesture.moveTo(firstLinePos + const Offset(0, 10.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 35); + + // Moving down will extend the selection to the third line. + await gesture.moveTo(firstLinePos + const Offset(0, 20.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 54); + + // Moving down will extend the selection to the last line. + await gesture.moveTo(firstLinePos + const Offset(0, 40.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 72); + + // Moving up will extend the selection to the third line. + await gesture.moveTo(firstLinePos + const Offset(0, 20.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 54); + + // Moving up will extend the selection to the second line. + await gesture.moveTo(firstLinePos + const Offset(0, 10.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 35); + + // Moving up will extend the selection to the first line. + await gesture.moveTo(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 19); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + testWidgets( + 'Can triple click + drag to select paragraph by paragraph', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValueA); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on the same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + + // Drag, down after the triple tap, to select paragraph by paragraph. + // Moving down will extend the selection to the second line. + await gesture.moveTo(firstLinePos + const Offset(0, 10.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 36); + + // Moving down will extend the selection to the third line. + await gesture.moveTo(firstLinePos + const Offset(0, 20.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 55); + + // Moving down will extend the selection to the last line. + await gesture.moveTo(firstLinePos + const Offset(0, 40.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 72); + + // Moving up will extend the selection to the third line. + await gesture.moveTo(firstLinePos + const Offset(0, 20.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 55); + + // Moving up will extend the selection to the second line. + await gesture.moveTo(firstLinePos + const Offset(0, 10.0)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 36); + + // Moving up will extend the selection to the first line. + await gesture.moveTo(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'Going past triple click retains the selection on Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: testValueA); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(200.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 12); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + // Clicking again retains the selection. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Tap count resets when going past a triple tap on Android, Fuchsia, and Linux', + (WidgetTester tester) async { + final controller = TextEditingController(text: testValueA); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + final platformSelectsByLine = defaultTargetPlatform == TargetPlatform.linux; + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(200.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 12); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + // Clicking again moves the caret to the tapped position. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 12); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // Clicking again moves the caret to the tapped position. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 12); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + }), + ); + + testWidgets( + 'Double click and triple click alternate on Windows', + (WidgetTester tester) async { + final controller = TextEditingController(text: testValueA); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(200.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 12); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + // Clicking again selects the word. + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the paragraph. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // Clicking again selects the paragraph. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(200.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the paragraph. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + }); + + testWidgets('force press selects word', (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final int pointerValue = tester.nextPointer; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + textFieldStart + const Offset(150.0, 5.0), + PointerDownEvent( + pointer: pointerValue, + position: textFieldStart + const Offset(150.0, 5.0), + pressure: 3.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + // We expect the force press to select a word at the given location. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + await gesture.up(); + await tester.pumpAndSettle(); + // Shows toolbar. + expectCupertinoToolbarForPartialSelection(); + }); + + testWidgets( + 'force press on unsupported devices falls back to tap', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel' + + final int pointerValue = tester.nextPointer; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + pPos, + PointerDownEvent( + pointer: pointerValue, + position: pPos, + // iPhone 6 and below report 0 across the board. + pressure: 0, + pressureMax: 0, + pressureMin: 0, + ), + ); + await gesture.up(); + // Fall back to a single tap which selects the edge of the word on iOS, and + // a precise position on macOS. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 12 : 9); + + await tester.pump(); + // Falling back to a single tap doesn't trigger a toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Cannot drag one handle past the other on non-Apple platform', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + // Provide a [TextSelectionControls] that builds selection handles. + final TextSelectionControls selectionControls = CupertinoTextSelectionHandleControls(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + selectionControls: selectionControls, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 7 : 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // On Mac, the toolbar blocks the drag on the right handle, so hide it. + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + editableTextState.hideToolbar(false); + await tester.pumpAndSettle(); + + // Drag the right handle until there's only 1 char selected. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 5); // Position of 'e'. + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 5); + + newHandlePos = textOffsetToPosition(tester, 2); // Position of 'c'. + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + // The selection doesn't move beyond the left handle. There's always at + // least 1 char selected. + expect(controller.selection.extentOffset, 5); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'Can drag one handle past the other on iOS', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + // Provide a [TextSelectionControls] that builds selection handles. + final TextSelectionControls selectionControls = CupertinoTextSelectionHandleControls(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + selectionControls: selectionControls, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformIOS ? 7 : 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // On Mac, the toolbar blocks the drag on the right handle, so hide it. + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + editableTextState.hideToolbar(false); + await tester.pumpAndSettle(); + + // Drag the right handle until there's only 1 char selected. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 5); // Position of 'e'. + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 5); + + newHandlePos = textOffsetToPosition(tester, 2); // Position of 'c'. + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + // The selection inverts moving beyond the left handle. + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 2); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'assertion error is not thrown when attempting to drag both selection handles', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/168578. + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 7); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to 'g'. + final Offset endHandlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 9); // Position of 'g'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 9); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point; + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await tester.pump(); + + // Drag the end handle to the end of the text after releasing the start handle. + newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await endHandleGesture.up(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Can only drag one handle at a time on iOS', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 7); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to the end of the text. + final Offset endHandlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point; + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await endHandleGesture.up(); + await tester.pump(); + + // The start handle does not cause the selection to change. + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Can only drag one selection handle at a time on Android web', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to the end of the text. + final Offset endHandlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point; + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await endHandleGesture.up(); + await tester.pump(); + + // Moving the start handle does not change the selection. + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + }, + skip: !kIsWeb, // [intended] on web only one selection handle can be dragged at a time. + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Can drag both selection handles at a time on Android', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to the end of the text. + final Offset endHandlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point; + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await endHandleGesture.up(); + await tester.pump(); + + // Moving the start handle changes the selection. + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + }, + skip: kIsWeb, // [intended] on web only one selection handle can be dragged at a time. + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Dragging between multiple lines keeps the contact point at the same place on the handle on Android', + (WidgetTester tester) async { + final controller = TextEditingController( + // 11 first line, 19 second line, 17 third line = length 49 + text: 'a big house\njumped over a mouse\nOne more line yay', + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: 3, + minLines: 3, + ), + ), + ), + ); + + // Double tap to select 'over'. + final Offset pos = textOffsetToPosition(tester, controller.text.indexOf('v')); + // The first tap. + TestGesture gesture = await tester.startGesture(pos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // The second tap. + await gesture.down(pos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final TextSelection selection = controller.selection; + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 23)); + + final RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 4 letters to the right. + // The adjustment moves the tap from the text position to the handle. + const endHandleAdjustment = Offset(1.0, 6.0); + Offset handlePos = endpoints[1].point + endHandleAdjustment; + Offset newHandlePos = textOffsetToPosition(tester, 27) + endHandleAdjustment; + await tester.pump(); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the right handle 1 line down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + final toNextLine = Offset(0.0, findRenderEditable(tester).preferredLineHeight + 3.0); + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 47)); + + // Drag the right handle back up 1 line. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + newHandlePos = handlePos - toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the left handle 4 letters to the left. + // The adjustment moves the tap from the text position to the handle. + const startHandleAdjustment = Offset(-1.0, 6.0); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = textOffsetToPosition(tester, 15) + startHandleAdjustment; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 15, extentOffset: 27)); + + // Drag the left handle 1 line up. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + // Move handle a sufficient global distance so it can be considered a drag + // by the selection handle's [PanGestureRecognizer]. + newHandlePos = handlePos - (toNextLine * 2); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 3, extentOffset: 27)); + + // Drag the left handle 1 line back down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + // Move handle up a small amount before dragging it down so the total global + // distance travelled can be accepted by the selection handle's [PanGestureRecognizer] as a drag. + // This way it can declare itself the winner before the [TapAndDragGestureRecognizer] that + // is on the selection overlay. + await tester.pump(); + await gesture.moveTo(handlePos - toNextLine); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 15, extentOffset: 27)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'Dragging between multiple lines keeps the contact point at the same place on the handle on iOS', + (WidgetTester tester) async { + final controller = TextEditingController( + // 11 first line, 19 second line, 17 third line = length 49 + text: 'a big house\njumped over a mouse\nOne more line yay', + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: 3, + minLines: 3, + ), + ), + ), + ); + + // Double tap to select 'over'. + final Offset pos = textOffsetToPosition(tester, controller.text.indexOf('v')); + // The first tap. + TestGesture gesture = await tester.startGesture(pos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // The second tap. + await gesture.down(pos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final TextSelection selection = controller.selection; + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 23)); + + final RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 4 letters to the right. + // The adjustment moves the tap from the text position to the handle. + const endHandleAdjustment = Offset(1.0, 6.0); + Offset handlePos = endpoints[1].point + endHandleAdjustment; + Offset newHandlePos = textOffsetToPosition(tester, 27) + endHandleAdjustment; + await tester.pump(); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the right handle 1 line down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final toNextLine = Offset(0.0, lineHeight + 3.0); + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 47)); + + // Drag the right handle back up 1 line. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + newHandlePos = handlePos - toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the left handle 4 letters to the left. + // The adjustment moves the tap from the text position to the handle. + final startHandleAdjustment = Offset(-1.0, -lineHeight + 6.0); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = textOffsetToPosition(tester, 15) + startHandleAdjustment; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + // On Apple platforms, dragging the base handle makes it the extent. + expect(controller.selection, const TextSelection(baseOffset: 27, extentOffset: 15)); + + // Drag the left handle 1 line up. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = handlePos - toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 27, extentOffset: 3)); + + // Drag the left handle 1 line back down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 27, extentOffset: 15)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('Selection updates on tap down (Desktop platforms)', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 5); + + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + await gesture.down(gPos); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // This should do nothing. The selection is set on tap down on desktop platforms. + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Selection updates on tap up (Mobile platforms)', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + await gesture.down(gPos); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 5); + + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + final TestGesture touchGesture = await tester.startGesture(ePos); + await touchGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + // On iOS, a tap to select, selects the word edge instead of the exact tap position. + expect(controller.selection.baseOffset, isTargetPlatformApple ? 7 : 5); + expect(controller.selection.extentOffset, isTargetPlatformApple ? 7 : 5); + + // Selection should stay the same since it is set on tap up for mobile platforms. + await touchGesture.down(gPos); + await tester.pump(); + expect(controller.selection.baseOffset, isTargetPlatformApple ? 7 : 5); + expect(controller.selection.extentOffset, isTargetPlatformApple ? 7 : 5); + + await touchGesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + // Skip past scrolling animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('g')); + }); + + testWidgets( + 'Cursor should not move on a quick touch drag when touch does not begin on previous selection (iOS)', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for [kDoubleTapTimeout] after the up event, so our next down + // event does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // The position we tap during a drag start is not on the collapsed selection, + // so the cursor should not move. + await gesture.down(textOffsetToPosition(tester, 7)); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS)', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|g'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for [kDoubleTapTimeout] after the up event, so our next down + // event does not register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS) - multiline', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + const testValue = 'abc\ndef\nghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and move to '|i'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS) - ListView', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/122519 + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + const testValue = 'abc\ndef\nghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and attempt move + // to '|g'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging (Android)', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + // Tap on text field to gain focus, and set selection to '|e'. + // We await for [kDoubleTapTimeout] after the up event, so our + // next down event does not register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|d', and move to '|g'. + await gesture.down(textOffsetToPosition(tester, testValue.indexOf('d'))); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { + var selectionChangedCount = 0; + const testValue = 'abc def ghi'; + final controller = TextEditingController(text: testValue); + addTearDown(controller.dispose); + + controller.addListener(() { + selectionChangedCount++; + }); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'. + final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'. + final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'. + + // Drag from 'c' to 'g'. + final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, isNonZero); + selectionChangedCount = 0; + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 8); + + // Tiny movement shouldn't cause text selection to change. + await gesture.moveTo(gPos + const Offset(2.0, 0.0)); + await tester.pumpAndSettle(); + expect(selectionChangedCount, 0); + + // Now a text selection change will occur after a significant movement. + await gesture.moveTo(hPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, 1); + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 9); + }); + + testWidgets('Tap does not show handles nor toolbar', (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Tap to trigger the text field. + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + }); + + testWidgets('Long press shows toolbar but not handles', (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Long press to trigger the text field. + await tester.longPress(find.byType(CupertinoTextField)); + await tester.pump(); + // A long press in Cupertino should position the cursor without any selection. + expect(controller.selection.isCollapsed, isTrue); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Double tap shows handles and toolbar if selection is not collapsed', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset hPos = textOffsetToPosition(tester, 9); // Position of 'h'. + + // Double tap on 'h' to select 'ghi'. + await tester.tapAt(hPos); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(hPos); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Double tap shows toolbar but not handles if selection is collapsed', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final Offset textEndPos = textOffsetToPosition(tester, 11); // Position at the end of text. + + // Double tap to place the cursor at the end. + await tester.tapAt(textEndPos); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textEndPos); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Mouse long press does not show handles nor toolbar', (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Long press to trigger the text field. + final Offset textFieldPos = tester.getCenter(find.byType(CupertinoTextField)); + final TestGesture gesture = await tester.startGesture( + textFieldPos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('Mouse double tap does not show handles nor toolbar', (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + + // Double tap at the end of text. + final Offset textEndPos = textOffsetToPosition(tester, 11); // Position at the end of text. + final TestGesture gesture = await tester.startGesture( + textEndPos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(); + await gesture.down(textEndPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + + final Offset hPos = textOffsetToPosition(tester, 9); // Position of 'h'. + + // Double tap on 'h' to select 'ghi'. + await gesture.down(hPos); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(); + await gesture.down(hPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + }); + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(onTap: () => tapCount++)), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(); + expect(tapCount, 1); + + // Wait out the double tap interval so the next tap doesn't end up being + // recognized as a double tap. + await tester.pump(const Duration(seconds: 1)); + + // Double tap count as one single tap. + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(); + expect(tapCount, 2); + }); + + testWidgets('onTap does not work when the text field is disabled', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(enabled: false, onTap: () => tapCount++)), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(CupertinoTextField), warnIfMissed: false); // disabled + await tester.pump(); + expect(tapCount, 0); + + // Wait out the double tap interval so the next tap doesn't end up being + // recognized as a double tap. + await tester.pump(const Duration(seconds: 1)); + + // Enabling the text field, now it should accept taps. + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(onTap: () => tapCount++)), + ), + ); + + await tester.tap(find.byType(CupertinoTextField)); + expect(tapCount, 1); + + await tester.pump(const Duration(seconds: 1)); + + // Disable it again. + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(enabled: false, onTap: () => tapCount++)), + ), + ); + await tester.tap(find.byType(CupertinoTextField), warnIfMissed: false); // disabled + await tester.pump(); + expect(tapCount, 1); + }); + + testWidgets('Focus test when the text field is disabled', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(focusNode: focusNode)), + ), + ); + + expect(focusNode.hasFocus, false); // initial status + + // Should accept requestFocus. + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + + // Disable the text field, now it should not accept requestFocus. + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(enabled: false, focusNode: focusNode)), + ), + ); + + // Should not accept requestFocus. + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, false); + }); + + testWidgets('text field respects theme', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoTextField()), + ), + ); + + final decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.border!.bottom.color.value, 0x33FFFFFF); + + await tester.enterText(find.byType(CupertinoTextField), 'smoked meat'); + await tester.pump(); + + expect( + tester + .renderObject<RenderEditable>( + find + .byElementPredicate((Element element) => element.renderObject is RenderEditable) + .last, + ) + .text! + .style! + .color, + isSameColorAs(CupertinoColors.white), + ); + }); + + testWidgets( + 'Check the toolbar appears below the TextField when there is not enough space above the TextField to show it', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/29808 + const testValue = 'abc def ghi'; + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Container( + padding: const EdgeInsets.all(30), + child: CupertinoTextField(controller: controller), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValue); + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 300), + ); // skip past the frame where the opacity is zero + + // Verify the selection toolbar position + Offset toolbarTopLeft = tester.getTopLeft(find.text('Paste')); + Offset textFieldTopLeft = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(textFieldTopLeft.dy, lessThan(toolbarTopLeft.dy)); + + await tester.pumpWidget( + CupertinoApp( + home: Container( + padding: const EdgeInsets.all(150), + child: CupertinoTextField(controller: controller), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), testValue); + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + renderEditable = findRenderEditable(tester); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // Verify the selection toolbar position + toolbarTopLeft = tester.getTopLeft(find.text('Paste')); + textFieldTopLeft = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(toolbarTopLeft.dy, lessThan(textFieldTopLeft.dy)); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('text field respects keyboardAppearance from theme', (WidgetTester tester) async { + final log = <MethodCall>[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, ( + MethodCall methodCall, + ) async { + log.add(methodCall); + return null; + }); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoTextField()), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + final MethodCall setClient = log.first; + expect(setClient.method, 'TextInput.setClient'); + expect( + ((setClient.arguments as List<dynamic>).last as Map<String, dynamic>)['keyboardAppearance'], + 'Brightness.dark', + ); + }); + + testWidgets('text field can override keyboardAppearance from theme', (WidgetTester tester) async { + final log = <MethodCall>[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, ( + MethodCall methodCall, + ) async { + log.add(methodCall); + return null; + }); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoTextField(keyboardAppearance: Brightness.light)), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + final MethodCall setClient = log.first; + expect(setClient.method, 'TextInput.setClient'); + expect( + ((setClient.arguments as List<dynamic>).last as Map<String, dynamic>)['keyboardAppearance'], + 'Brightness.light', + ); + }); + + testWidgets('cursorColor respects theme', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final Finder textFinder = find.byType(CupertinoTextField); + await tester.tap(textFinder); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor, CupertinoColors.activeBlue.color); + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoTextField(), + theme: CupertinoThemeData(brightness: Brightness.dark), + ), + ); + + await tester.pump(); + expect(renderEditable.cursorColor, CupertinoColors.activeBlue.darkColor); + + await tester.pumpWidget( + const CupertinoApp( + home: CupertinoTextField(), + theme: CupertinoThemeData(primaryColor: Color(0xFFF44336)), + ), + ); + + await tester.pump(); + expect(renderEditable.cursorColor, const Color(0xFFF44336)); + }); + + testWidgets('cursor can override color from theme', (WidgetTester tester) async { + const cursorColor = CupertinoDynamicColor.withBrightness( + color: Color(0x12345678), + darkColor: Color(0x87654321), + ); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(), + home: Center(child: CupertinoTextField(cursorColor: cursorColor)), + ), + ); + + EditableText editableText = tester.firstWidget(find.byType(EditableText)); + expect(editableText.cursorColor.value, 0x12345678); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoTextField(cursorColor: cursorColor)), + ), + ); + + editableText = tester.firstWidget(find.byType(EditableText)); + expect(editableText.cursorColor.value, 0x87654321); + }); + + testWidgets( + 'shows selection handles', + (WidgetTester tester) async { + const testText = 'lorem ipsum'; + final controller = TextEditingController(text: testText); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(), + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + final RenderEditable renderEditable = tester + .state<EditableTextState>(find.byType(EditableText)) + .renderEditable; + + await tester.tapAt(textOffsetToPosition(tester, 5)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List<Widget> transitions = find + .byType(FadeTransition) + .evaluate() + .map((Element e) => e.widget) + .toList(); + expect(transitions.length, 2); + final left = transitions[0] as FadeTransition; + final right = transitions[1] as FadeTransition; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'when CupertinoTextField would be blocked by keyboard, it is shown with enough space for the selection handle', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(), + home: Center( + child: ListView( + controller: scrollController, + children: <Widget>[ + Container(height: 583), // Push field almost off screen. + CupertinoTextField(controller: controller), + Container(height: 1000), + ], + ), + ), + ), + ); + + // Tap the TextField to put the cursor into it and bring it into view. + expect(scrollController.offset, 0.0); + await tester.tap(find.byType(CupertinoTextField)); + await tester.pumpAndSettle(); + + // The ListView has scrolled to keep the TextField and cursor handle + // visible. + expect(scrollController.offset, 27.0); + }, + ); + + testWidgets('disabled state golden', (WidgetTester tester) async { + final controller = TextEditingController(text: 'lorem'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: DecoratedBox( + decoration: const BoxDecoration(color: Color(0xFFFFFFFF)), + child: Center( + child: SizedBox.square( + dimension: 200.0, + child: RepaintBoundary( + key: const ValueKey<int>(1), + child: CupertinoTextField(controller: controller, enabled: false), + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_test.disabled.png'), + ); + }); + + testWidgets('Can drag the left handle while the right handle remains off-screen', ( + WidgetTester tester, + ) async { + // Text is longer than textfield width. + const testValue = 'aaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final controller = TextEditingController(text: testValue); + addTearDown(controller.dispose); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + scrollController: scrollController, + ), + ), + ), + ); + + // Double tap 'b' to show handles. + final Offset bPos = textOffsetToPosition(tester, testValue.indexOf('b')); + await tester.tapAt(bPos); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(bPos); + await tester.pumpAndSettle(); + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 28); + expect(selection.extentOffset, testValue.length); + + // Move to the left edge. + scrollController.jumpTo(0); + await tester.pumpAndSettle(); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Left handle should appear between textfield's left and right position. + final Offset textFieldLeftPosition = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(endpoints[0].point.dx - textFieldLeftPosition.dx, isPositive); + final Offset textFieldRightPosition = tester.getTopRight(find.byType(CupertinoTextField)); + expect(textFieldRightPosition.dx - endpoints[0].point.dx, isPositive); + // Right handle should remain off-screen. + expect(endpoints[1].point.dx - textFieldRightPosition.dx, isPositive); + + // Drag the left handle to the right by 25 offset. + const toOffset = 25; + final double beforeScrollOffset = scrollController.offset; + final Offset handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, toOffset); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // On Apple platforms, dragging the base handle makes it the extent. + expect(controller.selection.baseOffset, testValue.length); + expect(controller.selection.extentOffset, toOffset); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, toOffset); + expect(controller.selection.extentOffset, testValue.length); + } + + // The scroll area of text field should not move. + expect(scrollController.offset, beforeScrollOffset); + }); + + testWidgets('Can drag the right handle while the left handle remains off-screen', ( + WidgetTester tester, + ) async { + // Text is longer than textfield width. + const testValue = 'aaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final controller = TextEditingController(text: testValue); + addTearDown(controller.dispose); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + scrollController: scrollController, + ), + ), + ), + ); + + // Double tap 'a' to show handles. + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + await tester.tapAt(aPos); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(aPos); + await tester.pumpAndSettle(); + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 0); + expect(selection.extentOffset, 27); + + // Move to the right edge. + scrollController.jumpTo(800); + await tester.pumpAndSettle(); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Right handle should appear between textfield's left and right position. + final Offset textFieldLeftPosition = tester.getTopLeft(find.byType(CupertinoTextField)); + expect(endpoints[1].point.dx - textFieldLeftPosition.dx, isPositive); + final Offset textFieldRightPosition = tester.getTopRight(find.byType(CupertinoTextField)); + expect(textFieldRightPosition.dx - endpoints[1].point.dx, isPositive); + // Left handle should remain off-screen. + expect(endpoints[0].point.dx, isNegative); + + // Drag the right handle to the left by 50 offset. + const toOffset = 50; + final double beforeScrollOffset = scrollController.offset; + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, toOffset); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, toOffset); + + // The scroll area of text field should not move. + expect(scrollController.offset, beforeScrollOffset); + }); + + group( + 'Text selection toolbar', + () { + testWidgets('Collapsed selection works', (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + EditableText.debugDeterministicCursor = true; + TextEditingController controller; + EditableTextState state; + Offset bottomLeftSelectionPosition; + + controller = TextEditingController(text: 'a'); + // Top left collapsed selection. The toolbar should flip vertically, and + // the arrow should not point exactly to the caret because the caret is + // too close to the left. + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 200.0, + child: CupertinoTextField(controller: controller, maxLines: null), + ), + ), + ), + ), + ); + + state = tester.state<EditableTextState>(find.byType(EditableText)); + final double lineHeight = state.renderEditable.preferredLineHeight; + + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + cause: SelectionChangedCause.tap, + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + bottomLeftSelectionPosition = textOffsetToBottomLeftPosition(tester, 0); + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + excludes: <Offset>[ + // Arrow should not point to the selection handle. + bottomLeftSelectionPosition.translate(0, 8 + 0.1), + ], + includes: <Offset>[ + // Expected center of the arrow. The arrow should stay clear of + // the edges of the selection toolbar. + Offset(26.0, bottomLeftSelectionPosition.dy + 8.0 + 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01), + leftMatcher: moreOrLessEquals(8), + rightMatcher: lessThanOrEqualTo(400 - 8), + bottomMatcher: moreOrLessEquals( + bottomLeftSelectionPosition.dy + 8 + 44, + epsilon: 0.01, + ), + ), + ), + ); + + // Top Right collapsed selection. The toolbar should flip vertically, and + // the arrow should not point exactly to the caret because the caret is + // too close to the right. + controller.dispose(); + controller = TextEditingController(text: 'a' * 200); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + alignment: Alignment.topRight, + child: SizedBox.square( + dimension: 200, + child: CupertinoTextField(controller: controller, maxLines: null), + ), + ), + ), + ), + ); + + state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectPositionAt( + from: tester.getTopRight(find.byType(CupertinoApp)), + cause: SelectionChangedCause.tap, + ); + await tester.pumpAndSettle(); + + // -1 because we want to reach the end of the line, not the start of a new line. + bottomLeftSelectionPosition = textOffsetToBottomLeftPosition( + tester, + state.renderEditable.selection!.baseOffset - 1, + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + excludes: <Offset>[ + // Arrow should not point to the selection handle. + bottomLeftSelectionPosition.translate(0, 8 + 0.1), + ], + includes: <Offset>[ + // Expected center of the arrow. + Offset(400 - 26.0, bottomLeftSelectionPosition.dy + 8 + 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01), + rightMatcher: moreOrLessEquals(400.0 - 8), + bottomMatcher: moreOrLessEquals( + bottomLeftSelectionPosition.dy + 8 + 44, + epsilon: 0.01, + ), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + + // Normal centered collapsed selection. The toolbar arrow should point down, and + // it should point exactly to the caret. + controller.dispose(); + controller = TextEditingController(text: 'a' * 200); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox.square( + dimension: 200, + child: CupertinoTextField(controller: controller, maxLines: null), + ), + ), + ), + ), + ); + + state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectPositionAt( + from: tester.getCenter(find.byType(EditableText)), + cause: SelectionChangedCause.tap, + ); + await tester.pumpAndSettle(); + + bottomLeftSelectionPosition = textOffsetToBottomLeftPosition( + tester, + state.renderEditable.selection!.baseOffset, + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + includes: <Offset>[ + // Expected center of the arrow. + bottomLeftSelectionPosition.translate(0, -lineHeight - 8 - 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + bottomMatcher: moreOrLessEquals( + bottomLeftSelectionPosition.dy - 8 - lineHeight, + epsilon: 0.01, + ), + topMatcher: moreOrLessEquals( + bottomLeftSelectionPosition.dy - 8 - lineHeight - 44, + epsilon: 0.01, + ), + rightMatcher: lessThanOrEqualTo(400 - 8), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + }); + + testWidgets('selecting multiple words works', (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + EditableText.debugDeterministicCursor = true; + final TextEditingController controller; + final EditableTextState state; + + // Normal multiword collapsed selection. The toolbar arrow should point down, and + // it should point exactly to the caret. + controller = TextEditingController(text: List<String>.filled(20, 'a').join(' ')); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox.square( + dimension: 200, + child: CupertinoTextField(controller: controller, maxLines: null), + ), + ), + ), + ), + ); + + state = tester.state<EditableTextState>(find.byType(EditableText)); + final double lineHeight = state.renderEditable.preferredLineHeight; + + // Select the first 2 words. + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + to: textOffsetToPosition(tester, 4), + cause: SelectionChangedCause.tap, + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + final Offset selectionPosition = + (textOffsetToBottomLeftPosition(tester, 0) + + textOffsetToBottomLeftPosition(tester, 4)) / + 2; + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + includes: <Offset>[ + // Expected center of the arrow. + selectionPosition.translate(0, -lineHeight - 8 - 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + bottomMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight, epsilon: 0.01), + topMatcher: moreOrLessEquals( + selectionPosition.dy - 8 - lineHeight - 44, + epsilon: 0.01, + ), + rightMatcher: lessThanOrEqualTo(400 - 8), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + }); + + testWidgets('selecting multiline works', (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + EditableText.debugDeterministicCursor = true; + final TextEditingController controller; + final EditableTextState state; + + // Normal multiline collapsed selection. The toolbar arrow should point down, and + // it should point exactly to the horizontal center of the text field. + controller = TextEditingController(text: List<String>.filled(20, 'a a ').join('\n')); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox.square( + dimension: 200, + child: CupertinoTextField(controller: controller, maxLines: null), + ), + ), + ), + ), + ); + + state = tester.state<EditableTextState>(find.byType(EditableText)); + final double lineHeight = state.renderEditable.preferredLineHeight; + + // Select the first 2 words. + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + to: textOffsetToPosition(tester, 10), + cause: SelectionChangedCause.tap, + ); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + + final selectionPosition = Offset( + // Toolbar should be centered. + 200, + textOffsetToBottomLeftPosition(tester, 0).dy, + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathPointsMatcher( + includes: <Offset>[ + // Expected center of the arrow. + selectionPosition.translate(0, -lineHeight - 8 - 0.1), + ], + ), + ), + ); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..clipPath( + pathMatcher: PathBoundsMatcher( + bottomMatcher: moreOrLessEquals(selectionPosition.dy - 8 - lineHeight, epsilon: 0.01), + topMatcher: moreOrLessEquals( + selectionPosition.dy - 8 - lineHeight - 44, + epsilon: 0.01, + ), + rightMatcher: lessThanOrEqualTo(400 - 8), + leftMatcher: greaterThanOrEqualTo(8), + ), + ), + ); + }); + + // This is a regression test for + // https://github.com/flutter/flutter/issues/37046. + testWidgets('No exceptions when showing selection menu inside of nested Navigators', ( + WidgetTester tester, + ) async { + const testValue = '123456'; + final controller = TextEditingController(text: testValue); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: Column( + children: <Widget>[ + Container(height: 100, color: CupertinoColors.black), + Expanded( + child: Navigator( + onGenerateRoute: (_) => CupertinoPageRoute<void>( + builder: (_) => CupertinoTextField(controller: controller), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // No text selection toolbar. + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + + // Double tap on the text in the input. + await tester.pumpAndSettle(); + await tester.tapAt(textOffsetToPosition(tester, testValue.length ~/ 2)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(textOffsetToPosition(tester, testValue.length ~/ 2)); + await tester.pumpAndSettle(); + + // Now the text selection toolbar is showing and there were no exceptions. + expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget); + expect(tester.takeException(), null); + }); + + testWidgets('Drag selection hides the selection menu', (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + final Offset midBlah1 = textOffsetToPosition(tester, 2); + final Offset midBlah2 = textOffsetToPosition(tester, 8); + + // Right click the second word. + final TestGesture gesture = await tester.startGesture( + midBlah2, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The toolbar is shown. + expect(find.text('Paste'), findsOneWidget); + + // Drag the mouse to the first word. + final TestGesture gesture2 = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture2.moveTo(midBlah2); + await tester.pump(); + await gesture2.up(); + await tester.pumpAndSettle(); + + // The toolbar is hidden. + expect(find.text('Paste'), findsNothing); + }, variant: TargetPlatformVariant.desktop()); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + group('textAlignVertical position', () { + group('simple case', () { + testWidgets('align top (default)', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField(focusNode: focusNode, expands: true, maxLines: null), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The EditableText is at the top. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(207.0, epsilon: .0001), + ); + }); + + testWidgets('align center', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + textAlignVertical: TextAlignVertical.center, + focusNode: focusNode, + expands: true, + maxLines: null, + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The EditableText is at the center. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(291.5, epsilon: .0001), + ); + }); + + testWidgets('align bottom', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + textAlignVertical: TextAlignVertical.bottom, + focusNode: focusNode, + expands: true, + maxLines: null, + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The EditableText is at the bottom. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(376.0, epsilon: .0001), + ); + }); + + testWidgets('align as a double', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + textAlignVertical: const TextAlignVertical(y: 0.75), + focusNode: focusNode, + expands: true, + maxLines: null, + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The EditableText is near the bottom. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(354.875, epsilon: .0001), + ); + }); + }); + + group('tall prefix', () { + testWidgets('align center (default when prefix)', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + focusNode: focusNode, + expands: true, + maxLines: null, + prefix: const SizedBox(height: 100, width: 10), + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. This includes tapping on the + // prefix, because in this case it is transparent. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The EditableText is at the center. Same as without prefix. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(291.5, epsilon: .0001), + ); + }); + + testWidgets('align top', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + textAlignVertical: TextAlignVertical.top, + focusNode: focusNode, + expands: true, + maxLines: null, + prefix: const SizedBox(height: 100, width: 10), + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. This includes tapping on the + // prefix, because in this case it is transparent. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The prefix is at the top, and the EditableText is centered within its + // height. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(241.5, epsilon: .0001), + ); + }); + + testWidgets('align bottom', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + textAlignVertical: TextAlignVertical.bottom, + focusNode: focusNode, + expands: true, + maxLines: null, + prefix: const SizedBox(height: 100, width: 10), + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. This includes tapping on the + // prefix, because in this case it is transparent. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The prefix is at the bottom, and the EditableText is centered within + // its height. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(341.5, epsilon: .0001), + ); + }); + + testWidgets('align as a double', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const size = Size(200.0, 200.0); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + textAlignVertical: const TextAlignVertical(y: 0.75), + focusNode: focusNode, + expands: true, + maxLines: null, + prefix: const SizedBox(height: 100, width: 10), + ), + ), + ), + ), + ), + ); + + // Fills the whole container since expands is true. + expect(tester.getSize(find.byType(CupertinoTextField)), size); + + // Tapping anywhere inside focuses it. This includes tapping on the + // prefix, because in this case it is transparent. + expect(focusNode.hasFocus, false); + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + final Offset justInside = tester + .getBottomLeft(find.byType(CupertinoTextField)) + .translate(0.0, -1.0); + await tester.tapAt(justInside); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + expect(focusNode.hasFocus, true); + + // The EditableText is near the bottom. + expect( + tester.getTopLeft(find.byType(CupertinoTextField)).dy, + moreOrLessEquals(size.height, epsilon: .0001), + ); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(329.0, epsilon: .0001), + ); + }); + }); + + testWidgets('Long press on an autofocused field shows the selection menu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(autofocus: true), + ), + ), + ), + ); + // This extra pump allows the selection set by autofocus to propagate to + // the RenderEditable. + await tester.pump(); + + // Long press shows the selection menu. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), isContextMenuProvidedByPlatform ? findsNothing : findsOneWidget); + }); + + testWidgets('Placeholder and editable text with differing font sizes', ( + WidgetTester tester, + ) async { + const size = Size(200.0, 200.0); + TextAlignVertical alignment = TextAlignVertical.top; + late StateSetter setState; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: size.width, + height: size.height, + child: CupertinoTextField( + placeholder: 'hint text', + placeholderStyle: const TextStyle(fontSize: 30.0), + style: const TextStyle(fontSize: 20.0), + textAlignVertical: alignment, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), 'text'); + await tester.pump(); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(207.0, epsilon: .0001), + ); + + setState(() { + alignment = TextAlignVertical.center; + }); + await tester.pump(); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(290.0, epsilon: .0001), + ); + + setState(() { + alignment = TextAlignVertical.bottom; + }); + await tester.pump(); + expect( + tester.getTopLeft(find.byType(EditableText)).dy, + moreOrLessEquals(373.0, epsilon: .0001), + ); + }); + }); + + testWidgets("Arrow keys don't move input focus", (WidgetTester tester) async { + final controller1 = TextEditingController(); + final controller2 = TextEditingController(); + final controller3 = TextEditingController(); + final controller4 = TextEditingController(); + final controller5 = TextEditingController(); + final focusNode1 = FocusNode(debugLabel: 'Field 1'); + final focusNode2 = FocusNode(debugLabel: 'Field 2'); + final focusNode3 = FocusNode(debugLabel: 'Field 3'); + final focusNode4 = FocusNode(debugLabel: 'Field 4'); + final focusNode5 = FocusNode(debugLabel: 'Field 5'); + addTearDown(focusNode1.dispose); + addTearDown(focusNode2.dispose); + addTearDown(focusNode3.dispose); + addTearDown(focusNode4.dispose); + addTearDown(focusNode5.dispose); + addTearDown(controller1.dispose); + addTearDown(controller2.dispose); + addTearDown(controller3.dispose); + addTearDown(controller4.dispose); + addTearDown(controller5.dispose); + + // Lay out text fields in a "+" formation, and focus the center one. + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + SizedBox( + width: 100.0, + child: CupertinoTextField(controller: controller1, focusNode: focusNode1), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + SizedBox( + width: 100.0, + child: CupertinoTextField(controller: controller2, focusNode: focusNode2), + ), + SizedBox( + width: 100.0, + child: CupertinoTextField(controller: controller3, focusNode: focusNode3), + ), + SizedBox( + width: 100.0, + child: CupertinoTextField(controller: controller4, focusNode: focusNode4), + ), + ], + ), + SizedBox( + width: 100.0, + child: CupertinoTextField(controller: controller5, focusNode: focusNode5), + ), + ], + ), + ), + ), + ); + + focusNode3.requestFocus(); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Scrolling shortcuts are disabled in text fields', (WidgetTester tester) async { + var scrollInvoked = false; + await tester.pumpWidget( + CupertinoApp( + home: Actions( + actions: <Type, Action<Intent>>{ + ScrollIntent: CallbackAction<ScrollIntent>( + onInvoke: (Intent intent) { + scrollInvoked = true; + return null; + }, + ), + }, + child: ListView( + children: const <Widget>[ + Padding(padding: EdgeInsets.symmetric(vertical: 200)), + CupertinoTextField(), + Padding(padding: EdgeInsets.symmetric(vertical: 800)), + ], + ), + ), + ), + ); + await tester.pump(); + expect(scrollInvoked, isFalse); + + // Set focus on the text field. + await tester.tapAt(tester.getTopLeft(find.byType(CupertinoTextField))); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(scrollInvoked, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(scrollInvoked, isFalse); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Cupertino text field semantics', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(), + ), + ), + ), + ); + + expect( + tester.getSemantics( + find + .descendant(of: find.byType(CupertinoTextField), matching: find.byType(Semantics)) + .first, + ), + matchesSemantics( + isTextField: true, + isFocusable: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }); + + testWidgets('Disabled Cupertino text field semantics', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(enabled: false), + ), + ), + ), + ); + + expect( + tester.getSemantics( + find + .descendant(of: find.byType(CupertinoTextField), matching: find.byType(Semantics)) + .first, + ), + matchesSemantics( + hasEnabledState: true, + isTextField: true, + isFocusable: true, + isReadOnly: true, + ), + ); + }); + + testWidgets('Cupertino text field clear button semantics', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField(clearButtonMode: OverlayVisibilityMode.always), + ), + ), + ), + ); + + expect(find.bySemanticsLabel('Clear'), findsOneWidget); + + expect( + tester.getSemantics(find.bySemanticsLabel('Clear').first), + matchesSemantics(isButton: true, hasTapAction: true, label: 'Clear'), + ); + }); + + testWidgets('Cupertino text field clear semantic label', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size(200, 200)), + child: const CupertinoTextField( + clearButtonMode: OverlayVisibilityMode.always, + clearButtonSemanticLabel: 'Delete Text', + ), + ), + ), + ), + ); + + expect(find.bySemanticsLabel('Clear'), findsNothing); + + expect(find.bySemanticsLabel('Delete Text'), findsOneWidget); + + expect( + tester.getSemantics(find.bySemanticsLabel('Delete Text').first), + matchesSemantics(isButton: true, hasTapAction: true, label: 'Delete Text'), + ); + }); + + testWidgets('CrossAxisAlignment start positions the prefix and suffix at the top of the field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + padding: EdgeInsets.zero, // Preventing delta position.dy + prefix: Icon(CupertinoIcons.add), + suffix: Icon(CupertinoIcons.clear), + crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + ), + ); + + final CupertinoTextField cupertinoTextField = tester.widget<CupertinoTextField>( + find.byType(CupertinoTextField), + ); + + expect(find.widgetWithIcon(CupertinoTextField, CupertinoIcons.clear), findsOneWidget); + expect(find.widgetWithIcon(CupertinoTextField, CupertinoIcons.add), findsOneWidget); + expect(cupertinoTextField.crossAxisAlignment, CrossAxisAlignment.start); + + final double editableDy = tester.getTopLeft(find.byType(EditableText)).dy; + final double prefixDy = tester.getTopLeft(find.byIcon(CupertinoIcons.add)).dy; + final double suffixDy = tester.getTopLeft(find.byIcon(CupertinoIcons.clear)).dy; + + expect(prefixDy, editableDy); + expect(suffixDy, editableDy); + }); + + testWidgets('CrossAxisAlignment end positions the prefix and suffix at the bottom of the field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + padding: EdgeInsets.zero, // Preventing delta position.dy + prefix: SizedBox.square(dimension: 48, child: Icon(CupertinoIcons.add)), + suffix: SizedBox.square(dimension: 48, child: Icon(CupertinoIcons.clear)), + crossAxisAlignment: CrossAxisAlignment.end, + ), + ), + ), + ); + + final CupertinoTextField cupertinoTextField = tester.widget<CupertinoTextField>( + find.byType(CupertinoTextField), + ); + + expect(find.widgetWithIcon(CupertinoTextField, CupertinoIcons.clear), findsOneWidget); + expect(find.widgetWithIcon(CupertinoTextField, CupertinoIcons.add), findsOneWidget); + expect(cupertinoTextField.crossAxisAlignment, CrossAxisAlignment.end); + + final double editableDy = tester.getTopLeft(find.byType(EditableText)).dy; + final double prefixDy = tester.getTopLeft(find.byIcon(CupertinoIcons.add)).dy; + final double suffixDy = tester.getTopLeft(find.byIcon(CupertinoIcons.clear)).dy; + + expect(prefixDy, lessThan(editableDy)); + expect(suffixDy, lessThan(editableDy)); + }); + + testWidgets( + 'text selection style 1', + (WidgetTester tester) async { + final controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure\nhi\nwassssup!', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: RepaintBoundary( + child: Container( + width: 650.0, + height: 600.0, + decoration: const BoxDecoration(color: Color(0xff00ff00)), + child: Column( + children: <Widget>[ + CupertinoTextField( + autofocus: true, + key: const Key('field0'), + controller: controller, + style: const TextStyle(height: 4, color: ui.Color.fromARGB(100, 0, 0, 0)), + toolbarOptions: const ToolbarOptions(selectAll: true), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingTop, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); + + await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0)); + await tester.pumpAndSettle(const Duration(milliseconds: 150)); + // Tap the Select All button. + await tester.tapAt(textFieldStart + const Offset(20.0, 100.0)); + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('text_field_golden.TextSelectionStyle.1.png'), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + skip: kIsWeb, // [intended] the web has its own Select All. + ); + + testWidgets( + 'text selection style 2', + (WidgetTester tester) async { + final controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure\nhi\nwassssup!', + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: RepaintBoundary( + child: Container( + width: 650.0, + height: 600.0, + decoration: const BoxDecoration(color: Color(0xff00ff00)), + child: Column( + children: <Widget>[ + CupertinoTextField( + autofocus: true, + key: const Key('field0'), + controller: controller, + style: const TextStyle(height: 4, color: ui.Color.fromARGB(100, 0, 0, 0)), + toolbarOptions: const ToolbarOptions(selectAll: true), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingBottom, + selectionWidthStyle: ui.BoxWidthStyle.tight, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); + + await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0)); + await tester.pumpAndSettle(const Duration(milliseconds: 150)); + // Tap the Select All button. + await tester.tapAt(textFieldStart + const Offset(20.0, 100.0)); + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('text_field_golden.TextSelectionStyle.2.png'), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + skip: kIsWeb, // [intended] the web has its own Select All. + ); + + testWidgets('textSelectionControls is passed to EditableText', (WidgetTester tester) async { + final selectionControl = MockTextSelectionControls(); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(selectionControls: selectionControl)), + ), + ); + + final EditableText widget = tester.widget(find.byType(EditableText)); + expect(widget.selectionControls, equals(selectionControl)); + }); + + testWidgets('Do not add LengthLimiting formatter to the user supplied list', ( + WidgetTester tester, + ) async { + final formatters = <TextInputFormatter>[]; + + await tester.pumpWidget( + CupertinoApp(home: CupertinoTextField(maxLength: 5, inputFormatters: formatters)), + ); + + expect(formatters.isEmpty, isTrue); + }); + + group('MaxLengthEnforcement', () { + const maxLength = 5; + + Future<void> setupWidget(WidgetTester tester, MaxLengthEnforcement? enforcement) async { + final Widget widget = CupertinoApp( + home: Center( + child: CupertinoTextField(maxLength: maxLength, maxLengthEnforcement: enforcement), + ), + ); + + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + } + + testWidgets('using none enforcement.', (WidgetTester tester) async { + const MaxLengthEnforcement enforcement = MaxLengthEnforcement.none; + + await setupWidget(tester, enforcement); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: 'abc')); + expect(state.currentTextEditingValue.text, 'abc'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)), + ); + expect(state.currentTextEditingValue.text, 'abcdef'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6)); + + state.updateEditingValue(const TextEditingValue(text: 'abcdef')); + expect(state.currentTextEditingValue.text, 'abcdef'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); + + testWidgets('using enforced.', (WidgetTester tester) async { + const MaxLengthEnforcement enforcement = MaxLengthEnforcement.enforced; + + await setupWidget(tester, enforcement); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: 'abc')); + expect(state.currentTextEditingValue.text, 'abc'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)), + ); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue( + const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)), + ); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue(const TextEditingValue(text: 'abcdef')); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + }); + + testWidgets('using truncateAfterCompositionEnds.', (WidgetTester tester) async { + const MaxLengthEnforcement enforcement = MaxLengthEnforcement.truncateAfterCompositionEnds; + + await setupWidget(tester, enforcement); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: 'abc')); + expect(state.currentTextEditingValue.text, 'abc'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)), + ); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue( + const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)), + ); + expect(state.currentTextEditingValue.text, 'abcdef'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6)); + + state.updateEditingValue(const TextEditingValue(text: 'abcdef')); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); + + testWidgets('using default behavior for different platforms.', (WidgetTester tester) async { + await setupWidget(tester, null); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: '侬好啊')); + expect(state.currentTextEditingValue.text, '侬好啊'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: '侬好啊旁友', composing: TextRange(start: 3, end: 5)), + ); + expect(state.currentTextEditingValue.text, '侬好啊旁友'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue( + const TextEditingValue(text: '侬好啊旁友们', composing: TextRange(start: 3, end: 6)), + ); + if (kIsWeb || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.fuchsia) { + expect(state.currentTextEditingValue.text, '侬好啊旁友们'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6)); + } else { + expect(state.currentTextEditingValue.text, '侬好啊旁友'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + } + + state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友')); + expect(state.currentTextEditingValue.text, '侬好啊旁友'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); + }); + + testWidgets('disabled widget changes background color', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoTextField(enabled: false))), + ); + + var decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.color!.value, 0xFFFAFAFA); + + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoTextField()))); + + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.color!.value, CupertinoColors.white.value); + + await tester.pumpWidget( + const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.dark), + home: Center(child: CupertinoTextField(enabled: false)), + ), + ); + + decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.color!.value, 0xFF050505); + }); + + testWidgets('Disabled widget does not override background color', (WidgetTester tester) async { + const backgroundColor = Color(0x0000000A); + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoTextField( + enabled: false, + decoration: BoxDecoration(color: backgroundColor), + ), + ), + ), + ); + + final decoration = + tester + .widget<DecoratedBox>( + find.descendant( + of: find.byType(CupertinoTextField), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + expect(decoration.color!.value, backgroundColor.value); + }); + + // Regression test for https://github.com/flutter/flutter/issues/78097. + testWidgets('still gets disabled background color when decoration is null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoTextField(decoration: null, enabled: false))), + ); + + final Color disabledColor = tester + .widget<ColoredBox>( + find.descendant(of: find.byType(CupertinoTextField), matching: find.byType(ColoredBox)), + ) + .color; + expect(disabledColor, isSameColorAs(const Color(0xFFFAFAFA))); + }); + + testWidgets('autofill info has placeholder text', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(home: CupertinoTextField(placeholder: 'placeholder text')), + ); + await tester.tap(find.byType(CupertinoTextField)); + + expect( + tester.testTextInput.setClientArgs?['autofill'], + containsPair('hintText', 'placeholder text'), + ); + }); + + testWidgets('textDirection is passed to EditableText', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(textDirection: TextDirection.ltr)), + ), + ); + + final EditableText ltrWidget = tester.widget(find.byType(EditableText)); + expect(ltrWidget.textDirection, TextDirection.ltr); + + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(textDirection: TextDirection.rtl)), + ), + ); + + final EditableText rtlWidget = tester.widget(find.byType(EditableText)); + expect(rtlWidget.textDirection, TextDirection.rtl); + }); + + testWidgets('clipBehavior has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final CupertinoTextField textField = tester.firstWidget(find.byType(CupertinoTextField)); + expect(textField.clipBehavior, Clip.hardEdge); + }); + + testWidgets('Overflow clipBehavior none golden', (WidgetTester tester) async { + final controller = OverflowWidgetTextEditingController(); + addTearDown(controller.dispose); + final Widget widget = CupertinoApp( + home: RepaintBoundary( + key: const ValueKey<int>(1), + child: SizedBox.square( + dimension: 200.0, + child: Center( + child: SizedBox( + // Make sure the input field is not high enough for the WidgetSpan. + height: 50, + child: CupertinoTextField(controller: controller, clipBehavior: Clip.none), + ), + ), + ), + ), + ); + await tester.pumpWidget(widget); + + final CupertinoTextField textField = tester.firstWidget(find.byType(CupertinoTextField)); + expect(textField.clipBehavior, Clip.none); + + final EditableText editableText = tester.firstWidget(find.byType(EditableText)); + expect(editableText.clipBehavior, Clip.none); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('overflow_clipbehavior_none.cupertino.0.png'), + ); + }); + + testWidgets( + 'can shift + tap to select with a keyboard (Apple platforms)', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'can shift + tap to select with a keyboard (non-Apple platforms)', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('shift tapping an unfocused field', (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField(controller: controller, focusNode: focusNode), + ), + ), + ); + + expect(focusNode.hasFocus, isFalse); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(controller.selection.baseOffset, 35); + expect(controller.selection.extentOffset, 35); + + // Unfocus the field, but the selection remains. + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + expect(controller.selection.baseOffset, 35); + expect(controller.selection.extentOffset, 35); + + // Shift tap in the middle of the field. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + switch (defaultTargetPlatform) { + // Apple platforms start the selection from 0. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection.baseOffset, 0); + + // Other platforms start from the previous selection. + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 35); + } + expect(controller.selection.extentOffset, 20); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'can shift + tap + drag to select with a keyboard (Apple platforms)', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + if (isTargetPlatformIOS) { + await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); + } + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + if (isTargetPlatformIOS) { + await gesture.down(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + } + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'can shift + tap + drag to select with a keyboard (non-Apple platforms)', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + final bool isTargetPlatformMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.fuchsia; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + if (isTargetPlatformMobile) { + await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); + } + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + if (isTargetPlatformMobile) { + await gesture.down(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + } + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The original selection is not restored like on iOS + // and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'can shift + tap + drag to select with a keyboard, reversed (Apple platforms)', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + final isTargetPlatformIOS = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + if (isTargetPlatformIOS) { + await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); + } + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + if (isTargetPlatformIOS) { + await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + } + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'can shift + tap + drag to select with a keyboard, reversed (non-Apple platforms)', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + final bool isTargetPlatformMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.fuchsia; + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + if (isTargetPlatformMobile) { + await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); + } + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + if (isTargetPlatformMobile) { + await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + } + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The selection is not restored like it would be on + // iOS and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.windows, + }), + ); + + // Regression test for https://github.com/flutter/flutter/issues/101587. + testWidgets( + 'Right clicking menu behavior', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + final Offset midBlah2 = textOffsetToPosition(tester, 8); + + // Right click the second word. + final TestGesture gesture = await tester.startGesture( + midBlah2, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection, const TextSelection(baseOffset: 6, extentOffset: 11)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + } + + // Right click the first word. + await gesture.down(midBlah1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + } + }, + variant: TargetPlatformVariant.all(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Selection handles should not show when using a mouse on non-Apple platforms', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/168252. + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoCupertinoToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset secondBlah = textOffsetToPosition(tester, 8); + + // Right click the second word using a mouse. + final TestGesture gesture = await tester.startGesture( + secondBlah, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + } + + // Press select all. + await tester.tap(find.text('Select All'), kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 11)); + + // Selection handles are hidden. + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.selectionOverlay, isNotNull); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Selection handles should not show when using a mouse on Apple platforms using Flutter context menu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/168252. + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoCupertinoToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset firstBlah = textOffsetToPosition(tester, 5); + + // Click at the end of blah1. + await tester.tapAt(firstBlah, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + + // Right click the same position to reveal the context menu. + await tester.tapAt(firstBlah, kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + expectCupertinoToolbarForCollapsedSelection(); + + // Press select all. + await tester.tap(find.text('Select All'), kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 11)); + + // Selection handles are hidden. + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.selectionOverlay, isNotNull); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + group('Right click focus', () { + testWidgets('Can right click to focus multiple times', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/103228 + final focusNode1 = FocusNode(); + final focusNode2 = FocusNode(); + addTearDown(focusNode1.dispose); + addTearDown(focusNode2.dispose); + final key1 = UniqueKey(); + final key2 = UniqueKey(); + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: <Widget>[ + CupertinoTextField(key: key1, focusNode: focusNode1), + // This spacer prevents the context menu in one field from + // overlapping with the other field. + const SizedBox(height: 100.0), + CupertinoTextField(key: key2, focusNode: focusNode2), + ], + ), + ), + ); + + // Interact with the field to establish the input connection. + await tester.tapAt(tester.getCenter(find.byKey(key1)), buttons: kSecondaryMouseButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + + await tester.tapAt(tester.getCenter(find.byKey(key2)), buttons: kSecondaryMouseButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isFalse); + expect(focusNode2.hasFocus, isTrue); + + await tester.tapAt(tester.getCenter(find.byKey(key1)), buttons: kSecondaryMouseButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + }); + + testWidgets( + 'Can right click to focus on previously selected word on Apple platforms', + (WidgetTester tester) async { + final focusNode1 = FocusNode(); + final focusNode2 = FocusNode(); + addTearDown(focusNode1.dispose); + addTearDown(focusNode2.dispose); + final controller = TextEditingController(text: 'first second'); + addTearDown(controller.dispose); + final key1 = UniqueKey(); + await tester.pumpWidget( + CupertinoApp( + home: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + CupertinoTextField(key: key1, controller: controller, focusNode: focusNode1), + Focus(focusNode: focusNode2, child: const Text('focusable')), + ], + ), + ), + ); + + // Interact with the field to establish the input connection. + await tester.tapAt(tester.getCenter(find.byKey(key1)), buttons: kSecondaryMouseButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + + // Select the second word. + controller.selection = const TextSelection(baseOffset: 6, extentOffset: 12); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.baseOffset, 6); + expect(controller.selection.extentOffset, 12); + + // Unfocus the first field. + focusNode2.requestFocus(); + await tester.pumpAndSettle(); + + expect(focusNode1.hasFocus, isFalse); + expect(focusNode2.hasFocus, isTrue); + + // Right click the second word in the first field, which is still selected + // even though the selection is not visible. + await tester.tapAt(textOffsetToPosition(tester, 8), buttons: kSecondaryMouseButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.baseOffset, 6); + expect(controller.selection.extentOffset, 12); + + // Select everything. + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 12); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 12); + + // Unfocus the first field. + focusNode2.requestFocus(); + await tester.pumpAndSettle(); + + // Right click the first word in the first field. + await tester.tapAt(textOffsetToPosition(tester, 2), buttons: kSecondaryMouseButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + }); + + group('context menu', () { + testWidgets( + 'builds CupertinoAdaptiveTextSelectionToolbar by default', + (WidgetTester tester) async { + final controller = TextEditingController(text: ''); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[CupertinoTextField(controller: controller)], + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state<EditableTextState>(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'contextMenuBuilder is used in place of the default text selection toolbar', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final controller = TextEditingController(text: ''); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + CupertinoTextField( + controller: controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return Placeholder(key: key); + }, + ), + ], + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(find.byKey(key), findsNothing); + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state<EditableTextState>(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + expect(find.byKey(key), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'iOS uses the system context menu by default if supported', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + final controller = TextEditingController(text: 'one two three'); + addTearDown(controller.dispose); + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: CupertinoApp(home: CupertinoTextField(controller: controller)), + ), + ); + + // No context menu shown. + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'iOS system context menu does not hide selection handles on onSystemHide', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + final controller = TextEditingController(text: 'one two three'); + addTearDown(controller.dispose); + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: CupertinoApp(home: CupertinoTextField(controller: controller)), + ), + ); + + // No context menu shown. + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(SystemContextMenu), findsOneWidget); + + // Simulate system hiding the menu. + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{ + 'method': 'ContextMenu.onDismissSystemContextMenu', + }); + + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/platform', + messageBytes, + (ByteData? data) {}, + ); + + await tester.pumpAndSettle(); + + expect(find.byType(SystemContextMenu), findsNothing); + + // Selection handles are not hidden. + final Iterable<RenderBox> boxes = tester.renderObjectList<RenderBox>( + find.descendant( + of: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay', + ), + matching: find.byType(CustomPaint), + ), + ); + expect(boxes.length, 2); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + }); + + group('magnifier', () { + late ValueNotifier<MagnifierInfo> magnifierInfo; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + group('magnifier builder', () { + testWidgets('should build custom magnifier if given', (WidgetTester tester) async { + final Widget customMagnifier = Container(key: UniqueKey()); + final defaultCupertinoTextField = CupertinoTextField( + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: (_, _, _) => customMagnifier, + ), + ); + + await tester.pumpWidget(const CupertinoApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + expect( + defaultCupertinoTextField.magnifierConfiguration!.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isA<Widget>().having((Widget widget) => widget.key, 'key', equals(customMagnifier.key)), + ); + }); + + group('defaults', () { + testWidgets( + 'should build CupertinoMagnifier on iOS and Android', + (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final BuildContext context = tester.firstElement(find.byType(CupertinoTextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isA<CupertinoTextMagnifier>(), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + }); + + testWidgets( + 'should build nothing on all platforms but iOS and Android', + (WidgetTester tester) async { + await tester.pumpWidget(const CupertinoApp(home: CupertinoTextField())); + + final BuildContext context = tester.firstElement(find.byType(CupertinoTextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isNull, + ); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.android}, + ), + ); + }); + + testWidgets( + 'Can drag handles to show, unshow, and update magnifier', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Builder( + builder: (BuildContext context) => CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + _, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + + // Double tap the 'e' to select 'def'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + + final TextSelection selection = controller.selection; + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + + // Drag the right handle 2 letters to the right. + final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + + Offset? firstDragGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2)); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Can drag to show, unshow, and update magnifier', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + _, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(); + + // Tap at '|a' to move the selection to position 0. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Start a drag gesture to move the selection to the dragged position, showing + // the magnifier. + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 0)); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + Offset firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, 10)); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 10); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // The magnifier should hide when the drag ends. + await gesture.up(); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 10); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Start a double-tap select the word at the tapped position. + await gesture.down(textOffsetToPosition(tester, 1)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + await gesture.down(textOffsetToPosition(tester, 1)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 3); + + // Start a drag gesture to extend the selection word-by-word, showing the + // magnifier. + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 7); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, 10)); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // The magnifier should hide when the drag ends. + await gesture.up(); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Can long press to show, unshow, and update magnifier on non-Apple platforms', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + _, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(); + + // Tap at 'e' to move the cursor before the 'e'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 5 : 4); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Long press the 'e' to select 'def' and show the magnifier. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, testValue.indexOf('e')), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + final Offset firstLongPressGesturePosition = magnifierInfo.value.globalGesturePosition; + + // Move the gesture to 'h' to extend the selection to 'ghi'. + await gesture.moveTo(textOffsetToPosition(tester, testValue.indexOf('h'))); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstLongPressGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // End the long press to hide the magnifier. + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'Can long press to show, unshow, and update magnifier on iOS', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + final isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + _, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(); + + // Tap at 'e' to set the selection to position 5 on Android. + // Tap at 'e' to set the selection to the closest word edge, which is position 4 on iOS. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 5 : 7); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Long press the 'e' to move the cursor in front of the 'e' and show the magnifier. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, testValue.indexOf('e')), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 5); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + final Offset firstLongPressGesturePosition = magnifierInfo.value.globalGesturePosition; + + // Move the gesture to 'h' to update the magnifier and move the cursor to 'h'. + await gesture.moveTo(textOffsetToPosition(tester, testValue.indexOf('h'))); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 9); + expect(controller.selection.extentOffset, 9); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstLongPressGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // End the long press to hide the magnifier. + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can double tap and drag to show, unshow, and update magnifier', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + MagnifierController? magnifierController; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierController = controller; + return CupertinoTextMagnifier( + controller: controller, + magnifierInfo: localMagnifierInfo, + ); + }, + ), + ), + ), + ), + ); + + const testValue = 'one two three four five six seven'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(); + + // Tap at 'e' to set the selection to the closest word edge, which is position 3 on iOS. + final Offset initialPosition = textOffsetToPosition(tester, testValue.indexOf('e')); + await tester.tapAt(initialPosition); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 3); + expect(magnifierController, isNull); + + // Double tap the 'e' to select 'one'. + final TestGesture gesture = await tester.startGesture(initialPosition); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await gesture.down(initialPosition); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 3); + expect(magnifierController, isNull); + + // Drag immediately after the double tap to select 'one two three four' and show the magnifier. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 18); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, true); + + // Dragging down at the same position should hide the cupertino magnifier when it + // exceeds its `hideBelowThreshold`. + await gesture.moveTo(textOffsetToPosition(tester, 16) + const Offset(0.0, 50.0)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 18); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, false); + + // Keep draging to select 'one two three four five' while the position continues to + // exceed the `hideBelowThreshold` keeping the magnifier hidden. + await gesture.moveTo(textOffsetToPosition(tester, 20) + const Offset(0.0, 50.0)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 23); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, false); + + // Remove offset that is used to exceed threshold, this should reveal the magnifier. + await gesture.moveTo(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 23); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, true); + + // End the drag to hide the magnifier. + await gesture.up(); + await tester.pumpAndSettle(); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, false); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'cancelling long press hides magnifier', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/167879 + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CupertinoTextField)), + ); + + await tester.pumpAndSettle(kLongPressTimeout); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + // Cancel the long press to hide the magnifier. + await gesture.cancel(); + await tester.pumpAndSettle(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + testWidgets( + 'TextField cursor appears only when focused', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + focusNode: focusNode, + dragStartBehavior: DragStartBehavior.down, + ), + ), + ), + ); + + final Offset fieldCenter = tester.getCenter(find.byType(EditableText)); + final TestGesture gesture = await tester.startGesture(fieldCenter); + await gesture.moveBy(const Offset(30, 0)); + await tester.pumpAndSettle(); + + // The blinking cursor should NOT be shown. + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + expect(focusNode.hasFocus, isFalse); + expect(editableTextState.cursorCurrentlyVisible, isFalse); + + // Simulate long press again. + await tester.pump(); + await tester.longPress(find.byType(EditableText)); + await tester.pumpAndSettle(); + + // The blinking cursor should now be shown. + expect(focusNode.hasFocus, isTrue); + expect(editableTextState.cursorCurrentlyVisible, isTrue); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + }); + + group('TapRegion integration', () { + testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 100, + child: CupertinoTextField(autofocus: true, focusNode: focusNode), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + // Tap outside the border. + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isFalse); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets("Tapping outside doesn't lose focus on mobile", (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 100, + child: CupertinoTextField(autofocus: true, focusNode: focusNode), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + // Tap just outside the border, but not inside the EditableText. + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + // Focus is lost on mobile browsers, but not mobile apps. + expect(focusNode.hasPrimaryFocus, kIsWeb ? isFalse : isTrue); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets( + "tapping on toolbar doesn't lose focus", + (WidgetTester tester) async { + final TextEditingController controller; + final EditableTextState state; + + controller = TextEditingController(text: 'A B C'); + addTearDown(controller.dispose); + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox.square( + dimension: 200, + child: CupertinoTextField( + autofocus: true, + focusNode: focusNode, + controller: controller, + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + state = tester.state<EditableTextState>(find.byType(EditableText)); + + // Select the first 2 words. + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + to: textOffsetToPosition(tester, 2), + cause: SelectionChangedCause.tap, + ); + + final Offset midSelection = textOffsetToPosition(tester, 2); + + // Right click the selection. + final TestGesture gesture = await tester.startGesture( + midSelection, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Copy'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + }, + variant: TargetPlatformVariant.all(), + // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser. + skip: kIsWeb, + ); + + testWidgets("Tapping on border doesn't lose focus", (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox.square( + dimension: 100, + child: CupertinoTextField(autofocus: true, focusNode: focusNode), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + final Rect borderBox = tester.getRect(find.byType(CupertinoTextField)); + // Tap just inside the border, but not inside the EditableText. + await tester.tapAt(borderBox.topLeft + const Offset(1, 1)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }, variant: TargetPlatformVariant.all()); + }); + + testWidgets('Can drag handles to change selection correctly in multiline', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(color: Color(0xFF000000), fontSize: 34.0), + maxLines: 3, + ), + ), + ), + ); + + const testValue = + 'First line of text is\n' + 'Second line goes until\n' + 'Third line of stuff'; + + const cutValue = + 'First line of text is\n' + 'Second until\n' + 'Third line of stuff'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + + // Skip past scrolling animation. + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + // Check that the text spans multiple lines. + final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); + final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); + final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); + expect(firstPos.dx, secondPos.dx); + expect(firstPos.dx, thirdPos.dx); + expect(firstPos.dy, lessThan(secondPos.dy)); + expect(secondPos.dy, lessThan(thirdPos.dy)); + + // Double tap on the 'n' in 'until' to select the word. + final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until') + 1); + await tester.tapAt(untilPos); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(untilPos); + await tester.pumpAndSettle(); + + // Skip past the frame where the opacity is zero. + await tester.pump(const Duration(milliseconds: 200)); + + expect(controller.selection.baseOffset, 39); + expect(controller.selection.extentOffset, 44); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final offsetFromEndPointToMiddlePoint = Offset(0.0, -renderEditable.preferredLineHeight / 2); + + // Drag the left handle to just after 'Second', still on the second line. + Offset handlePos = endpoints[0].point + offsetFromEndPointToMiddlePoint; + Offset newHandlePos = + textOffsetToPosition(tester, testValue.indexOf('Second') + 6) + + offsetFromEndPointToMiddlePoint; + TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 28); + expect(controller.selection.extentOffset, 44); + + // Drag the right handle to just after 'goes', still on the second line. + handlePos = endpoints[1].point + offsetFromEndPointToMiddlePoint; + newHandlePos = + textOffsetToPosition(tester, testValue.indexOf('goes') + 4) + + offsetFromEndPointToMiddlePoint; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 28); + expect(controller.selection.extentOffset, 38); + + if (!isContextMenuProvidedByPlatform) { + await tester.tap(find.text('Cut')); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.text, cutValue); + } + }); + + testWidgets('placeholder style overflow works', (WidgetTester tester) async { + final String placeholder = 'hint text' * 20; + const placeholderStyle = TextStyle(fontSize: 14.0, overflow: TextOverflow.fade); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField(placeholder: placeholder, placeholderStyle: placeholderStyle), + ), + ), + ); + await tester.pumpAndSettle(); + final Finder placeholderFinder = find.text(placeholder); + final Text placeholderWidget = tester.widget(placeholderFinder); + expect(placeholderWidget.overflow, placeholderStyle.overflow); + expect(placeholderWidget.style!.overflow, placeholderStyle.overflow); + }); + + testWidgets( + 'tapping on a misspelled word on iOS hides the handles and shows red selection', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = true; + // The default derived color for the iOS text selection highlight. + const defaultSelectionColor = Color(0x33007aff); + final controller = TextEditingController(text: 'test test testt'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + spellCheckConfiguration: const SpellCheckConfiguration( + misspelledTextStyle: CupertinoTextField.cupertinoMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: + CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.spellCheckResults = SpellCheckResults(controller.value.text, const <SuggestionSpan>[ + SuggestionSpan(TextRange(start: 10, end: 15), <String>['test']), + ]); + + // Double tapping a non-misspelled word shows the normal blue selection and + // the selection handles. + expect(state.selectionOverlay, isNull); + await tester.tapAt(textOffsetToPosition(tester, 2)); + await tester.pump(const Duration(milliseconds: 50)); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + await tester.tapAt(textOffsetToPosition(tester, 2)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(state.selectionOverlay!.handlesAreVisible, isTrue); + expect(state.renderEditable.selectionColor, defaultSelectionColor); + + // Single tapping a non-misspelled word shows a collapsed cursor. + await tester.tapAt(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream), + ); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + expect(state.renderEditable.selectionColor, defaultSelectionColor); + + // Single tapping a misspelled word selects it in red with no handles. + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 10, extentOffset: 15)); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + expect(state.renderEditable.selectionColor, CupertinoTextField.kMisspelledSelectionColor); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + skip: kIsWeb, // [intended] + ); + + testWidgets( + 'text selection toolbar is hidden on tap down on desktop platforms', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + + TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 8), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget); + + gesture = await tester.startGesture( + textOffsetToPosition(tester, 2), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + // After the gesture is down but not up, the toolbar is already gone. + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + variant: TargetPlatformVariant.all(excluding: TargetPlatformVariant.mobile().values), + ); + + testWidgets( + 'Does not shrink in height when enters text when there is large single-line placeholder', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/133241. + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topCenter, + child: CupertinoTextField( + placeholderStyle: const TextStyle(fontSize: 100), + placeholder: 'p', + controller: controller, + ), + ), + ), + ); + + final Rect rectWithPlaceholder = tester.getRect(find.byType(CupertinoTextField)); + controller.value = const TextEditingValue(text: 'input'); + await tester.pump(); + + final Rect rectWithText = tester.getRect(find.byType(CupertinoTextField)); + expect(rectWithPlaceholder, rectWithText); + }, + ); + + testWidgets('Does not match the height of a multiline placeholder', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Align( + alignment: Alignment.topCenter, + child: CupertinoTextField( + placeholderStyle: const TextStyle(fontSize: 100), + placeholder: 'p' * 50, + maxLines: null, + controller: controller, + ), + ), + ), + ); + + final Rect rectWithPlaceholder = tester.getRect(find.byType(CupertinoTextField)); + controller.value = const TextEditingValue(text: 'input'); + await tester.pump(); + + final Rect rectWithText = tester.getRect(find.byType(CupertinoTextField)); + // The text field is still top aligned. + expect(rectWithPlaceholder.top, rectWithText.top); + // But after entering text the text field should shrink since the + // placeholder text is huge and multiline. + expect(rectWithPlaceholder.height, greaterThan(rectWithText.height)); + // But still should be taller than or the same height of the first line of + // placeholder. + expect(rectWithText.height, greaterThan(100)); + }); + + testWidgets('Placeholder is baseline aligned with text', (WidgetTester tester) async { + const placeholderTextContent = 'hint text'; + const actualTextContent = 'text'; + var currentPlaceholderFontSize = 1.0; + var currentTextFontSize = 1.0; + late StateSetter setState; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoTextField( + minLines: 4, + maxLines: 6, + placeholder: placeholderTextContent, + placeholderStyle: TextStyle(fontSize: currentPlaceholderFontSize), + style: TextStyle(fontSize: currentTextFontSize), + ); + }, + ), + ), + ), + ); + + Future<void> performBaselineAlignmentCheck(double placeholderSize, double textSize) async { + setState(() { + currentPlaceholderFontSize = placeholderSize; + currentTextFontSize = textSize; + }); + await tester.pump(); + await tester.enterText(find.byType(CupertinoTextField), actualTextContent); + await tester.pump(); + + expect(find.text(placeholderTextContent), findsOneWidget); + expect(find.text(actualTextContent), findsOneWidget); + + // The placeholder and text are baseline aligned, so some portion of them + // extends both above and below the baseline. + const ahemBaselineRatio = 0.8; // https://web-platform-tests.org/writing-tests/ahem.html + final double placeholderHeightAboveBaseline = placeholderSize * ahemBaselineRatio; + final double textHeightAboveBaseline = textSize * ahemBaselineRatio; + final double placeholderTopDy = tester.getTopLeft(find.text(placeholderTextContent)).dy; + final double textTopDy = tester.getTopLeft(find.text(actualTextContent)).dy; + + expect( + textTopDy, + closeTo(placeholderTopDy + placeholderHeightAboveBaseline - textHeightAboveBaseline, 1.0), + ); + } + + // Placeholder and text are baseline aligned when the placeholder is larger. + await performBaselineAlignmentCheck(40.0, 20.0); + + await tester.enterText(find.byType(CupertinoTextField), ''); + await tester.pump(); + + // Placeholder and text are baseline aligned when the text is larger. + await performBaselineAlignmentCheck(20.0, 40.0); + }); + + testWidgets('Editable text in text field with placeholder is hit-testable', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField(placeholder: 'placeholder')), + ), + ); + + expect(find.byType(CupertinoTextField), findsOneWidget); + expect(find.byType(EditableText).hitTestable(), findsOne); + }); + + testWidgets('Text field with placeholder has correct intrinsic height', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: IntrinsicHeight(child: CupertinoTextField(placeholder: 'placeholder')), + ), + ), + ); + + expect(find.byType(CupertinoTextField), findsOneWidget); + expect(tester.getSize(find.byType(CupertinoTextField)).height, greaterThan(0.0)); + }); + + testWidgets('Text field with placeholder has correct intrinsic width', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: IntrinsicWidth(child: CupertinoTextField(placeholder: 'placeholder')), + ), + ), + ); + + expect(find.byType(CupertinoTextField), findsOneWidget); + expect(tester.getSize(find.byType(CupertinoTextField)).width, greaterThan(0.0)); + }); + + testWidgets('Start the floating cursor on long tap', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final controller = TextEditingController(text: 'abcd'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: Center( + child: RepaintBoundary( + key: const ValueKey<int>(1), + child: CupertinoTextField( + cursorColor: const Color(0xFF6750A4), + autofocus: true, + controller: controller, + ), + ), + ), + ), + ), + ); + // Wait for autofocus. + await tester.pumpAndSettle(); + final Offset textFieldCenter = tester.getCenter(find.byType(CupertinoTextField)); + final TestGesture gesture = await tester.startGesture(textFieldCenter); + await tester.pump(kLongPressTimeout); + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_floating_cursor.regular_and_floating_both.cupertino.0.png'), + ); + await gesture.moveTo(Offset(10, textFieldCenter.dy)); + await tester.pump(); + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_floating_cursor.only_floating_cursor.cupertino.0.png'), + ); + await gesture.up(); + EditableText.debugDeterministicCursor = false; + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('when enabled listens to onFocus events and gains focus', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget(CupertinoApp(home: CupertinoTextField(focusNode: focusNode))); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + if (defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS) ...<SemanticsAction>[ + SemanticsAction.didGainAccessibilityFocus, + SemanticsAction.didLoseAccessibilityFocus, + ], + // TODO(gspencergoog): also test for the presence of SemanticsAction.focus when + // this iOS issue is addressed: https://github.com/flutter/flutter/issues/150030 + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'when disabled does not listen to onFocus events or gain focus', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp(home: CupertinoTextField(focusNode: focusNode, enabled: false)), + ); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isReadOnly, + ], + actions: <SemanticsAction>[ + if (defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == + TargetPlatform.macOS) ...<SemanticsAction>[ + SemanticsAction.didGainAccessibilityFocus, + SemanticsAction.didLoseAccessibilityFocus, + ], + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + semantics.dispose(); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'when receives SemanticsAction.focus while already focused, shows keyboard', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget(CupertinoApp(home: CupertinoTextField(focusNode: focusNode))); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log.single.method, 'TextInput.show'); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'when receives SemanticsAction.focus while focused but read-only, does not show keyboard', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + CupertinoApp(home: CupertinoTextField(focusNode: focusNode, readOnly: true)), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log, isEmpty); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'readOnly disallows SystemContextMenu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170521. + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + final controller = TextEditingController(text: 'abcdefghijklmnopqr'); + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + controller.dispose(); + }); + + var readOnly = true; + late StateSetter setState; + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoTextField(readOnly: readOnly, controller: controller); + }, + ), + ), + ), + ); + + final Duration waitDuration = SelectionOverlay.fadeDuration > kDoubleTapTimeout + ? SelectionOverlay.fadeDuration + : kDoubleTapTimeout; + + // Double tap to select the text. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // No error as in https://github.com/flutter/flutter/issues/170521. + + // The Flutter-drawn context menu is shown. The SystemContextMenu is not + // shown because readOnly is true. + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + + // Turn off readOnly and hide the context menu. + setState(() { + readOnly = false; + }); + await tester.tap(find.text('Copy')); + await tester.pump(waitDuration); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to show the context menu again. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // Now iOS is showing the SystemContextMenu while others continue to show + // the Flutter-drawn context menu. + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + expect(find.byType(SystemContextMenu), findsOneWidget); + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget); + } + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'Does not crash when editing value changes between consecutive scrolls', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/179164. + final controller = TextEditingController(text: 'text ' * 10000); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ); + + final Finder textField = find.byType(CupertinoTextField); + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + // Long press to select the first word and show the toolbar. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay?.toolbarIsVisible, true); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + + // Scroll down so selection is not visible, and toolbar is scheduled to be shown + // when the selection is once again visible. + final TestGesture gesture = await tester.startGesture(tester.getCenter(textField)); + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pump(); + await gesture.up(); + + // Scroll again before the post-frame callback from the first scroll is run to invalidate + // the data from the first scroll. + controller.value = const TextEditingValue(text: 'a different value'); + + await gesture.down(tester.getCenter(textField)); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // This test should reach the end without crashing. + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + // [intended] only applies to platforms where we supply the context menu. + skip: kIsWeb, + ); + + testWidgets( + 'toolbar should not reappear when editing value changes during a scroll', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/179164. + final controller = TextEditingController(text: 'text ' * 10000); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller, maxLines: null)), + ), + ); + + final Finder textField = find.byType(CupertinoTextField); + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + // Long press to select the first word and show the toolbar. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay?.toolbarIsVisible, true); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + + // Scroll down so selection is not visible, and toolbar is scheduled to be shown + // when the selection is once again visible. + final TestGesture gesture = await tester.startGesture(tester.getCenter(textField)); + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pump(); + await gesture.up(); + // Change the editing value before the post-frame callback from the scroll is run, + // this should invalidate the data from the scroll and cause the toolbar to not + // reappear. + controller.value = const TextEditingValue(text: 'a different value'); + // Pump and settle to allow postFrameCallbacks to complete. + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay?.toolbarIsVisible, false); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + // [intended] only applies to platforms where we supply the context menu. + skip: kIsWeb, + ); + + testWidgets('CupertinoTextField does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'X'); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextField(controller: controller)), + ), + ); + expect(tester.getSize(find.byType(CupertinoTextField)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + }); + + testWidgets('CupertinoTextField passes enableInlinePrediction to EditableText', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp(home: Center(child: CupertinoTextField(enableInlinePrediction: true))), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.enableInlinePrediction, true); + }); + + testWidgets('CupertinoTextField enableInlinePrediction defaults to null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoTextField()))); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.enableInlinePrediction, isNull); + }); + + testWidgets('CupertinoTextField.borderless passes enableInlinePrediction to EditableText', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextField.borderless(enableInlinePrediction: true)), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.enableInlinePrediction, true); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/text_form_field_row_restoration_test.dart b/packages/cupertino_ui/test/cupertino/text_form_field_row_restoration_test.dart new file mode 100644 index 000000000000..46ba3d797e2c --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_form_field_row_restoration_test.dart @@ -0,0 +1,246 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String text = 'Hello World! How are you? Life is good!'; +const String alternativeText = 'Everything is awesome!!'; + +void main() { + testWidgets('CupertinoTextFormFieldRow restoration', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp(restorationScopeId: 'app', home: RestorableTestWidget()), + ); + + await restoreAndVerify(tester); + }); + + testWidgets('CupertinoTextFormFieldRow restoration with external controller', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + restorationScopeId: 'root', + home: RestorableTestWidget(useExternalController: true), + ), + ); + + await restoreAndVerify(tester); + }); + + testWidgets('State restoration (No Form ancestor) - onUserInteraction error text validation', ( + WidgetTester tester, + ) async { + String? errorText(String? value) => '$value/error'; + late GlobalKey<FormFieldState<String>> formState; + + Widget builder() { + return CupertinoApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey<FormFieldState<String>>(); + return CupertinoTextFormFieldRow( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteraction, + restorationId: 'text_form_field', + initialValue: 'foo', + validator: errorText, + ); + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // No error text is visible yet. + expect(find.text(errorText('foo')!), findsNothing); + + await tester.enterText(find.byType(CupertinoTextFormFieldRow), 'bar'); + await tester.pumpAndSettle(); + expect(find.text(errorText('bar')!), findsOneWidget); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText('bar')!), findsOneWidget); + + // Resetting the form state should remove the error text. + formState.currentState!.reset(); + await tester.pumpAndSettle(); + expect(find.text(errorText('bar')!), findsNothing); + await tester.restartAndRestore(); + // Error text should still be removed after restart and restore. + expect(find.text(errorText('bar')!), findsNothing); + + await tester.restoreFrom(data); + expect(find.text(errorText('bar')!), findsOneWidget); + }); + + testWidgets( + 'State Restoration (No Form ancestor) - validator sets the error text only when validate is called', + (WidgetTester tester) async { + String? errorText(String? value) => '$value/error'; + late GlobalKey<FormFieldState<String>> formState; + + Widget builder(AutovalidateMode mode) { + return CupertinoApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey<FormFieldState<String>>(); + return CupertinoTextFormFieldRow( + key: formState, + restorationId: 'form_field', + autovalidateMode: mode, + initialValue: 'foo', + validator: errorText, + ); + }, + ), + ), + ), + ), + ); + } + + // Start off not autovalidating. + await tester.pumpWidget(builder(AutovalidateMode.disabled)); + + Future<void> checkErrorText(String testValue) async { + formState.currentState!.reset(); + await tester.pumpWidget(builder(AutovalidateMode.disabled)); + await tester.enterText(find.byType(CupertinoTextFormFieldRow), testValue); + await tester.pump(); + + // We have to manually validate if we're not autovalidating. + expect(find.text(errorText(testValue)!), findsNothing); + formState.currentState!.validate(); + await tester.pump(); + expect(find.text(errorText(testValue)!), findsOneWidget); + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText(testValue)!), findsOneWidget); + + formState.currentState!.reset(); + await tester.pumpAndSettle(); + expect(find.text(errorText(testValue)!), findsNothing); + + await tester.restoreFrom(data); + expect(find.text(errorText(testValue)!), findsOneWidget); + + // Try again with autovalidation. Should validate immediately. + formState.currentState!.reset(); + await tester.pumpWidget(builder(AutovalidateMode.always)); + await tester.enterText(find.byType(CupertinoTextFormFieldRow), testValue); + await tester.pump(); + + expect(find.text(errorText(testValue)!), findsOneWidget); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText(testValue)!), findsOneWidget); + } + + await checkErrorText('Test'); + await checkErrorText(''); + }, + ); +} + +Future<void> restoreAndVerify(WidgetTester tester) async { + expect(find.text(text), findsNothing); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0); + + await tester.enterText(find.byType(CupertinoTextFormFieldRow), text); + await skipPastScrollingAnimation(tester); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0); + + await tester.drag(find.byType(Scrollable), const Offset(0, -80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); + + await tester.restartAndRestore(); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.enterText(find.byType(CupertinoTextFormFieldRow), alternativeText); + await skipPastScrollingAnimation(tester); + await tester.drag(find.byType(Scrollable), const Offset(0, 80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsNothing); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, isNot(60)); + + await tester.restoreFrom(data); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); +} + +class RestorableTestWidget extends StatefulWidget { + const RestorableTestWidget({super.key, this.useExternalController = false}); + + final bool useExternalController; + + @override + RestorableTestWidgetState createState() => RestorableTestWidgetState(); +} + +class RestorableTestWidgetState extends State<RestorableTestWidget> with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + + @override + String get restorationId => 'widget'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'controller'); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Align( + child: SizedBox( + width: 50, + child: CupertinoTextFormFieldRow( + restorationId: 'text', + maxLines: 3, + controller: widget.useExternalController ? controller.value : null, + ), + ), + ); + } +} + +Future<void> skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} diff --git a/packages/cupertino_ui/test/cupertino/text_form_field_row_test.dart b/packages/cupertino_ui/test/cupertino/text_form_field_row_test.dart new file mode 100644 index 000000000000..e1efb3e73089 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_form_field_row_test.dart @@ -0,0 +1,670 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/src/services/spell_check.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'editable_text_utils.dart'; + +void main() { + testWidgets('Passes textAlign to underlying CupertinoTextField', (WidgetTester tester) async { + const TextAlign alignment = TextAlign.center; + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(textAlign: alignment)), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.textAlign, alignment); + }); + + testWidgets('Passes spellCheckConfiguration to underlying CupertinoTextField', ( + WidgetTester tester, + ) async { + final spellCheckConfig = SpellCheckConfiguration( + spellCheckService: DefaultSpellCheckService(), + misspelledSelectionColor: const Color.fromARGB(255, 255, 255, 0), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(spellCheckConfiguration: spellCheckConfig)), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.spellCheckConfiguration, spellCheckConfig); + }); + + testWidgets('Passes scrollPhysics to underlying TextField', (WidgetTester tester) async { + const scrollPhysics = ScrollPhysics(); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(scrollPhysics: scrollPhysics)), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.scrollPhysics, scrollPhysics); + }); + + testWidgets('Passes textAlignVertical to underlying CupertinoTextField', ( + WidgetTester tester, + ) async { + const TextAlignVertical textAlignVertical = TextAlignVertical.bottom; + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(textAlignVertical: textAlignVertical)), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.textAlignVertical, textAlignVertical); + }); + + testWidgets('Passes textInputAction to underlying CupertinoTextField', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(textInputAction: TextInputAction.next)), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.textInputAction, TextInputAction.next); + }); + + testWidgets('Passes onEditingComplete to underlying CupertinoTextField', ( + WidgetTester tester, + ) async { + void onEditingComplete() {} + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(onEditingComplete: onEditingComplete)), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.onEditingComplete, onEditingComplete); + }); + + testWidgets('Passes cursor attributes to underlying CupertinoTextField', ( + WidgetTester tester, + ) async { + const cursorWidth = 3.14; + const cursorHeight = 6.28; + const cursorRadius = Radius.circular(2); + const Color cursorColor = CupertinoColors.systemPurple; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorColor: cursorColor, + ), + ), + ), + ); + + final Finder textFieldFinder = find.byType(CupertinoTextField); + expect(textFieldFinder, findsOneWidget); + + final CupertinoTextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.cursorWidth, cursorWidth); + expect(textFieldWidget.cursorHeight, cursorHeight); + expect(textFieldWidget.cursorRadius, cursorRadius); + expect(textFieldWidget.cursorColor, cursorColor); + }); + + testWidgets('onFieldSubmit callbacks are called', (WidgetTester tester) async { + var called = false; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + onFieldSubmitted: (String value) { + called = true; + }, + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(CupertinoTextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(called, true); + }); + + testWidgets('onChanged callbacks are called', (WidgetTester tester) async { + late String value; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + onChanged: (String v) { + value = v; + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(CupertinoTextField), 'Soup'); + await tester.pump(); + expect(value, 'Soup'); + }); + + testWidgets('autovalidateMode is passed to super', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + autovalidateMode: AutovalidateMode.always, + validator: (String? value) { + validateCalled++; + return null; + }, + ), + ), + ), + ); + + expect(validateCalled, 1); + await tester.enterText(find.byType(CupertinoTextField), 'a'); + await tester.pump(); + expect(validateCalled, 2); + }); + + testWidgets('validate is called if widget is enabled', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + enabled: true, + autovalidateMode: AutovalidateMode.always, + validator: (String? value) { + validateCalled += 1; + return null; + }, + ), + ), + ), + ); + + expect(validateCalled, 1); + await tester.enterText(find.byType(CupertinoTextField), 'a'); + await tester.pump(); + expect(validateCalled, 2); + }); + + testWidgets('readonly text form field will hide cursor by default', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(initialValue: 'readonly', readOnly: true)), + ), + ); + + await tester.showKeyboard(find.byType(CupertinoTextFormFieldRow)); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.longPress(find.text('readonly')); + await tester.pump(); + + // Context menu should not have paste. + expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget); + expect(find.text('Paste'), findsNothing); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + // Make sure it does not paint caret for a period of time. + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + }, skip: isBrowser); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + onTap: () { + tapCount += 1; + }, + ), + ), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(CupertinoTextField)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(CupertinoTextField)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + // Regression test for https://github.com/flutter/flutter/issues/54472. + testWidgets('reset resets the text fields value to the initialValue', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(initialValue: 'initialValue')), + ), + ); + + await tester.enterText(find.byType(CupertinoTextFormFieldRow), 'changedValue'); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(CupertinoTextFormFieldRow), + ); + state.reset(); + + expect(find.text('changedValue'), findsNothing); + expect(find.text('initialValue'), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/54472. + testWidgets('didChange changes text fields value', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(initialValue: 'initialValue')), + ), + ); + + expect(find.text('initialValue'), findsOneWidget); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(CupertinoTextFormFieldRow), + ); + state.didChange('changedValue'); + + expect(find.text('initialValue'), findsNothing); + expect(find.text('changedValue'), findsOneWidget); + }); + + testWidgets('onChanged callbacks value and FormFieldState.value are sync', ( + WidgetTester tester, + ) async { + var called = false; + + late FormFieldState<String> state; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + onChanged: (String value) { + called = true; + expect(value, state.value); + }, + ), + ), + ), + ); + + state = tester.state<FormFieldState<String>>(find.byType(CupertinoTextFormFieldRow)); + + await tester.enterText(find.byType(CupertinoTextField), 'Soup'); + + expect(called, true); + }); + + testWidgets('autofillHints is passed to super', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + autofillHints: const <String>[AutofillHints.countryName], + ), + ), + ), + ); + + final CupertinoTextField widget = tester.widget(find.byType(CupertinoTextField)); + expect(widget.autofillHints, equals(const <String>[AutofillHints.countryName])); + }); + + testWidgets('autovalidateMode is passed to super', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextFormFieldRow( + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (String? value) { + validateCalled++; + return null; + }, + ), + ), + ), + ); + + expect(validateCalled, 0); + await tester.enterText(find.byType(CupertinoTextField), 'a'); + await tester.pump(); + expect(validateCalled, 1); + }); + + testWidgets('AutovalidateMode.always mode shows error from the start', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + initialValue: 'Value', + autovalidateMode: AutovalidateMode.always, + validator: (String? value) => 'Error', + ), + ), + ), + ); + + final Finder errorTextFinder = find.byType(Text); + expect(errorTextFinder, findsOneWidget); + + final Text errorText = tester.widget(errorTextFinder); + expect(errorText.data, 'Error'); + }); + + testWidgets('Shows error text upon invalid input', (WidgetTester tester) async { + final controller = TextEditingController(text: ''); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextFormFieldRow( + controller: controller, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (String? value) => 'Error', + ), + ), + ), + ); + + expect(find.byType(Text), findsNothing); + + controller.text = 'Value'; + + await tester.pumpAndSettle(); + + final Finder errorTextFinder = find.byType(Text); + expect(errorTextFinder, findsOneWidget); + + final Text errorText = tester.widget(errorTextFinder); + expect(errorText.data, 'Error'); + }); + + testWidgets('Shows prefix', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(prefix: const Text('Enter Value'))), + ), + ); + + final Finder errorTextFinder = find.byType(Text); + expect(errorTextFinder, findsOneWidget); + + final Text errorText = tester.widget(errorTextFinder); + expect(errorText.data, 'Enter Value'); + }); + + testWidgets('Passes textDirection to underlying CupertinoTextField', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(textDirection: TextDirection.ltr)), + ), + ); + + final Finder ltrTextFieldFinder = find.byType(CupertinoTextField); + expect(ltrTextFieldFinder, findsOneWidget); + + final CupertinoTextField ltrTextFieldWidget = tester.widget(ltrTextFieldFinder); + expect(ltrTextFieldWidget.textDirection, TextDirection.ltr); + + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(textDirection: TextDirection.rtl)), + ), + ); + + final Finder rtlTextFieldFinder = find.byType(CupertinoTextField); + expect(rtlTextFieldFinder, findsOneWidget); + + final CupertinoTextField rtlTextFieldWidget = tester.widget(rtlTextFieldFinder); + expect(rtlTextFieldWidget.textDirection, TextDirection.rtl); + }); + + testWidgets('CupertinoTextFormFieldRow onChanged is called when the form is reset', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/123009. + final stateKey = GlobalKey<FormFieldState<String>>(); + final formKey = GlobalKey<FormState>(); + var value = 'initialValue'; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: Form( + key: formKey, + child: CupertinoTextFormFieldRow( + key: stateKey, + initialValue: value, + onChanged: (String newValue) { + value = newValue; + }, + ), + ), + ), + ), + ); + + // Initial value is 'initialValue'. + expect(stateKey.currentState!.value, 'initialValue'); + expect(value, 'initialValue'); + + // Change value to 'changedValue'. + await tester.enterText(find.byType(CupertinoTextField), 'changedValue'); + expect(stateKey.currentState!.value, 'changedValue'); + expect(value, 'changedValue'); + + // Should be back to 'initialValue' when the form is reset. + formKey.currentState!.reset(); + await tester.pump(); + expect(stateKey.currentState!.value, 'initialValue'); + expect(value, 'initialValue'); + }); + + group('context menu', () { + testWidgets( + 'iOS uses the system context menu by default if supported', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + final controller = TextEditingController(text: 'one two three'); + addTearDown(controller.dispose); + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: CupertinoApp(home: CupertinoTextField(controller: controller)), + ), + ); + + // No context menu shown. + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + }); + + testWidgets( + 'readOnly disallows SystemContextMenu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170521. + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + final controller = TextEditingController(text: 'abcdefghijklmnopqr'); + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + controller.dispose(); + }); + + var readOnly = true; + late StateSetter setState; + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoTextFormFieldRow(readOnly: readOnly, controller: controller); + }, + ), + ), + ), + ); + + final Duration waitDuration = SelectionOverlay.fadeDuration > kDoubleTapTimeout + ? SelectionOverlay.fadeDuration + : kDoubleTapTimeout; + + // Double tap to select the text. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // No error as in https://github.com/flutter/flutter/issues/170521. + + // The Flutter-drawn context menu is shown. The SystemContextMenu is not + // shown because readOnly is true. + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + + // Turn off readOnly and hide the context menu. + setState(() { + readOnly = false; + }); + await tester.tap(find.text('Copy')); + await tester.pump(waitDuration); + + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to show the context menu again. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // Now iOS is showing the SystemContextMenu while others continue to show + // the Flutter-drawn context menu. + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + expect(find.byType(SystemContextMenu), findsOneWidget); + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget); + } + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets('CupertinoTextFormFieldRow does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'X'); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center(child: CupertinoTextFormFieldRow(controller: controller)), + ), + ); + expect(tester.getSize(find.byType(CupertinoTextFormFieldRow)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/text_selection_test.dart b/packages/cupertino_ui/test/cupertino/text_selection_test.dart new file mode 100644 index 000000000000..91a95eedfdf2 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_selection_test.dart @@ -0,0 +1,1220 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui' as ui show BoxHeightStyle; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import 'editable_text_utils.dart' show findRenderEditable, textOffsetToPosition; + +class _LongCupertinoLocalizationsDelegate extends LocalizationsDelegate<CupertinoLocalizations> { + const _LongCupertinoLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => locale.languageCode == 'en'; + + @override + Future<_LongCupertinoLocalizations> load(Locale locale) => + _LongCupertinoLocalizations.load(locale); + + @override + bool shouldReload(_LongCupertinoLocalizationsDelegate old) => false; + + @override + String toString() => '_LongCupertinoLocalizations.delegate(en_US)'; +} + +class _LongCupertinoLocalizations extends DefaultCupertinoLocalizations { + const _LongCupertinoLocalizations(); + + @override + String get cutButtonLabel => 'Cutttttttttttttttttttttttttttttttttttttttttttt'; + @override + String get copyButtonLabel => 'Copyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy'; + @override + String get pasteButtonLabel => 'Pasteeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + @override + String get selectAllButtonLabel => 'Select Allllllllllllllllllllllllllllllll'; + + static Future<_LongCupertinoLocalizations> load(Locale locale) { + return SynchronousFuture<_LongCupertinoLocalizations>(const _LongCupertinoLocalizations()); + } + + static const LocalizationsDelegate<CupertinoLocalizations> delegate = + _LongCupertinoLocalizationsDelegate(); +} + +const _LongCupertinoLocalizations _longLocalizations = _LongCupertinoLocalizations(); + +class _RichTextController extends TextEditingController { + _RichTextController({required this.textSpan}) : super(text: textSpan.toPlainText()); + + final TextSpan textSpan; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + return textSpan; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + + List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { + return points.map<TextSelectionPoint>((TextSelectionPoint point) { + return TextSelectionPoint(box.localToGlobal(point.point), point.direction); + }).toList(); + } + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + group('canSelectAll', () { + Widget createEditableText({Key? key, String? text, TextSelection? selection}) { + final controller = TextEditingController(text: text) + ..selection = selection ?? const TextSelection.collapsed(offset: -1); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + return CupertinoApp( + home: EditableText( + key: key, + controller: controller, + focusNode: focusNode, + style: const TextStyle(), + cursorColor: const Color.fromARGB(0, 0, 0, 0), + backgroundCursorColor: const Color.fromARGB(0, 0, 0, 0), + ), + ); + } + + testWidgets('should return false when there is no text', (WidgetTester tester) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget(createEditableText(key: key)); + expect(cupertinoTextSelectionControls.canSelectAll(key.currentState!), false); + }); + + testWidgets('should return true when there is text and collapsed selection', ( + WidgetTester tester, + ) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget(createEditableText(key: key, text: '123')); + expect(cupertinoTextSelectionControls.canSelectAll(key.currentState!), true); + }); + + testWidgets('should return false when there is text and partial uncollapsed selection', ( + WidgetTester tester, + ) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget( + createEditableText( + key: key, + text: '123', + selection: const TextSelection(baseOffset: 1, extentOffset: 2), + ), + ); + expect(cupertinoTextSelectionControls.canSelectAll(key.currentState!), false); + }); + + testWidgets('should return false when there is text and full selection', ( + WidgetTester tester, + ) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget( + createEditableText( + key: key, + text: '123', + selection: const TextSelection(baseOffset: 0, extentOffset: 3), + ), + ); + expect(cupertinoTextSelectionControls.canSelectAll(key.currentState!), false); + }); + }); + + group('cupertino handles', () { + testWidgets('draws custom handle correctly', (WidgetTester tester) async { + await tester.pumpWidget( + RepaintBoundary( + child: CupertinoTheme( + data: const CupertinoThemeData(selectionHandleColor: Color(0xFF9C27B0)), + child: Builder( + builder: (BuildContext context) { + return Container( + color: CupertinoColors.white, + height: 800, + width: 800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 250), + child: FittedBox( + child: cupertinoTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.right, + 10.0, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('text_selection.handle.custom.png'), + ); + }); + + testWidgets('draws transparent handle correctly', (WidgetTester tester) async { + await tester.pumpWidget( + RepaintBoundary( + child: CupertinoTheme( + data: const CupertinoThemeData(selectionHandleColor: Color(0x550000AA)), + child: Builder( + builder: (BuildContext context) { + return Container( + color: CupertinoColors.white, + height: 800, + width: 800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 250), + child: FittedBox( + child: cupertinoTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.right, + 10.0, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('text_selection.handle.transparent.png'), + ); + }); + }); + + group('Text selection menu overflow (iOS)', () { + Finder findOverflowNextButton() { + return find.byWidgetPredicate( + (Widget widget) => + widget is CustomPaint && + '${widget.painter?.runtimeType}' == '_RightCupertinoChevronPainter', + ); + } + + Finder findOverflowBackButton() => find.byWidgetPredicate( + (Widget widget) => + widget is CustomPaint && + '${widget.painter?.runtimeType}' == '_LeftCupertinoChevronPainter', + ); + + testWidgets( + 'All menu items show when they fit.', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Long press on an empty space to show the selection menu. + await tester.longPressAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Double tap to select a word and show the full selection menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.tapAt(textOffset); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tapAt(textOffset); + await tester.pumpAndSettle(); + + // The full menu is shown without the navigation buttons. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + "When a menu item doesn't fit, a second page is used.", + (WidgetTester tester) async { + // Set the screen size to more narrow, so that Paste can't fit. + tester.view.physicalSize = const Size(1000, 800); + addTearDown(tester.view.reset); + + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: CupertinoTextField(controller: controller)), + ), + ), + ), + ); + + Future<void> tapNextButton() async { + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + } + + Future<void> tapBackButton() async { + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + } + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Double tap to select a word and show the selection menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.tapAt(textOffset); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tapAt(textOffset); + await tester.pumpAndSettle(); + + // The last button is missing, and a next button is shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button shows both the overflow, back, and next buttons. + await tapNextButton(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button shows the next, back, and Look Up button + await tapNextButton(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button shows the back and Search Web button + await tapNextButton(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsOneWidget); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the back button thrice shows the first page again with the next button. + await tapBackButton(); + await tapBackButton(); + await tapBackButton(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'A smaller menu puts each button on its own page.', + (WidgetTester tester) async { + // Set the screen size to more narrow, so that two buttons can't fit on + // the same page. + tester.view.physicalSize = const Size(640, 800); + addTearDown(tester.view.reset); + + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: CupertinoTextField(controller: controller)), + ), + ), + ), + ); + + Future<void> tapNextButton() async { + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + } + + Future<void> tapBackButton() async { + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + } + + // Initially, the menu isn't shown at all. + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Double tap to select a word and show the selection menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.tapAt(textOffset); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tapAt(textOffset); + await tester.pumpAndSettle(); + + // Only the first button fits, and a next button is shown. + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(2)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button shows Copy. + await tapNextButton(); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(3)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button again shows Paste + await tapNextButton(); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(3)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button again shows the Look Up Button. + await tapNextButton(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button again shows the Search Web Button. + await tapNextButton(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tapping the next button again shows the last page and the Share button + await tapNextButton(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsOneWidget); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsNothing); + + // Tapping the back button 5 times shows the first page again. + await tapBackButton(); + await tapBackButton(); + await tapBackButton(); + await tapBackButton(); + await tapBackButton(); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(2)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(find.text('Look Up'), findsNothing); + expect(find.text('Search Web'), findsNothing); + expect(find.text('Share...'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Handles very long locale strings', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + locale: const Locale('en', 'us'), + localizationsDelegates: const <LocalizationsDelegate<dynamic>>[ + _LongCupertinoLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: CupertinoTextField(autofocus: true, controller: controller)), + ), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Initially, the menu isn't shown at all. + expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); + expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); + expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Long press on an empty space to show the selection menu, with only the + // paste button visible. + await tester.longPressAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); + expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); + expect(find.text(_longLocalizations.pasteButtonLabel), findsOneWidget); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + + // Tap next to go to the second and final page. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); + expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); + expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsOneWidget); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsNothing); + + // Tap select all to show the full selection menu. + await tester.tap(find.text(_longLocalizations.selectAllButtonLabel)); + await tester.pumpAndSettle(); + + // Only one button fits on each page. + expect(find.text(_longLocalizations.cutButtonLabel), findsOneWidget); + expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); + expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + + // Tap next to go to the second page. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); + expect(find.text(_longLocalizations.copyButtonLabel), findsOneWidget); + expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tap twice to go to the third page. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); + expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); + expect(find.text(_longLocalizations.pasteButtonLabel), findsOneWidget); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tap back to go to the second page again. + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); + expect(find.text(_longLocalizations.copyButtonLabel), findsOneWidget); + expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + // Tap back to go to the first page again. + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + expect(find.text(_longLocalizations.cutButtonLabel), findsOneWidget); + expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); + expect(find.text(_longLocalizations.pasteButtonLabel), findsNothing); + expect(find.text(_longLocalizations.selectAllButtonLabel), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'When selecting multiple lines over max lines', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc\ndef\nghi\njkl\nmno\npqr'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center( + child: CupertinoTextField( + autofocus: true, + padding: const EdgeInsets.all(8.0), + controller: controller, + maxLines: 2, + ), + ), + ), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select All'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Long press on an space to show the selection menu. + await tester.longPressAt(textOffsetToPosition(tester, 1)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // Tap to select all. + await tester.tap(find.text('Select All')); + await tester.pumpAndSettle(); + + // Only Cut, Copy, and Paste are shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsNothing); + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsNothing); + + // The menu appears at the top of the visible selection. + final Offset selectionOffset = tester.getTopLeft( + find.byType(CupertinoTextSelectionToolbarButton).first, + ); + final Offset textFieldOffset = tester.getTopLeft(find.byType(CupertinoTextField)); + + // 7.0 + 44.0 + 8.0 - 8.0 = _kToolbarArrowSize + text_button_height + _kToolbarContentDistance - padding + expect(selectionOffset.dy + 7.0 + 44.0 + 8.0 - 8.0, equals(textFieldOffset.dy)); + }, + skip: isBrowser, // [intended] the selection menu isn't required by web + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + }); + + testWidgets( + 'iOS selection handles scale with rich text (selection height style tight)', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = _RichTextController( + textSpan: const TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)), + TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)), + TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 100.0), + cursorColor: const Color.fromARGB(0, 0, 0, 0), + selectionHeightStyle: ui.BoxHeightStyle.tight, + selectionControls: cupertinoTextSelectionControls, + readOnly: true, + decoration: null, + padding: EdgeInsets.zero, + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + + // Double tap to select the second word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag the right handle 2 letters to the right. Placing the end handle on + // the third word. We use a small offset because the endpoint is on the very + // corner of the handle. + final TextSelection selection = controller.selection; + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 11); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Find start and end handles and verify their sizes. + expect(find.byType(Overlay), findsOneWidget); + expect( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + findsNWidgets(2), + ); + + final Iterable<RenderBox> handles = tester.renderObjectList( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + ); + + // The handle height is determined by the formula: + // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap . + // The text line height will be the value of the fontSize, of the span the handle touches. + // The constant _kSelectionHandleRadius has the value of 6. + // The constant _kSelectionHandleOverlap has the value of 1.5. + // In the case of the start handle, which is located on the word 'def', + // 50.0 + 6 * 2 - 1.5 = 60.5 . + expect(handles.first.size.height, 60.5); + expect(handles.last.size.height, 35.5); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'iOS selection handles scale with rich text (selection height style includeLineSpacingMiddle) (default)', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = _RichTextController( + textSpan: const TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)), + TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)), + TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 100.0), + cursorColor: const Color.fromARGB(0, 0, 0, 0), + selectionControls: cupertinoTextSelectionControls, + readOnly: true, + decoration: null, + padding: EdgeInsets.zero, + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + + // Double tap to select the second word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag the right handle 2 letters to the right. Placing the end handle on + // the third word. We use a small offset because the endpoint is on the very + // corner of the handle. + final TextSelection selection = controller.selection; + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 11); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Find start and end handles and verify their sizes. + expect(find.byType(Overlay), findsOneWidget); + expect( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + findsNWidgets(2), + ); + + final Iterable<RenderBox> handles = tester.renderObjectList( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + ); + + // The handle height is determined by the formula: + // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap . + // The text line height will be the value of the fontSize, of the largest word on the line. + // The constant _kSelectionHandleRadius has the value of 6. + // The constant _kSelectionHandleOverlap has the value of 1.5. + // In the case of the start handle, which is located on the word 'def', + // 100 + 6 * 2 - 1.5 = 110.5 . + // In this case both selection handles are the same size because the selection + // height style is set to BoxHeightStyle.max which means that the height of + // the selection highlight will be the height of the largest word on the line. + expect(handles.first.size.height, 110.5); + expect(handles.last.size.height, 110.5); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'iOS selection handles scale with rich text (grapheme clusters) (selection height style tight)', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = _RichTextController( + textSpan: const TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)), + TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)), + TextSpan(text: '👨‍👩‍👦 ', style: TextStyle(fontSize: 35.0)), + TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 100.0), + cursorColor: const Color.fromARGB(0, 0, 0, 0), + selectionHeightStyle: ui.BoxHeightStyle.tight, + selectionControls: cupertinoTextSelectionControls, + readOnly: true, + decoration: null, + padding: EdgeInsets.zero, + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + + // Double tap to select the second word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag the right handle 2 letters to the right. Placing the end handle on + // the third word. We use a small offset because the endpoint is on the very + // corner of the handle. + final TextSelection selection = controller.selection; + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 16); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 16); + + // Find start and end handles and verify their sizes. + expect(find.byType(Overlay), findsOneWidget); + expect( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + findsNWidgets(2), + ); + + final Iterable<RenderBox> handles = tester.renderObjectList( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + ); + + // The handle height is determined by the formula: + // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap . + // The text line height will be the value of the fontSize, of the span containing the grapheme cluster. + // The constant _kSelectionHandleRadius has the value of 6. + // The constant _kSelectionHandleOverlap has the value of 1.5. + // In the case of the end handle, which is located on the grapheme cluster '👨‍👩‍👦', + // 35.0 + 6 * 2 - 1.5 = 45.5 . + expect(handles.first.size.height, 60.5); + expect(handles.last.size.height, 45.5); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'iOS selection handles scale with rich text (grapheme clusters) (selection height style includeLineSpacingMiddle) (default)', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = _RichTextController( + textSpan: const TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'abc ', style: TextStyle(fontSize: 100.0)), + TextSpan(text: 'def ', style: TextStyle(fontSize: 50.0)), + TextSpan(text: '👨‍👩‍👦 ', style: TextStyle(fontSize: 35.0)), + TextSpan(text: 'hij', style: TextStyle(fontSize: 25.0)), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 100.0), + cursorColor: const Color.fromARGB(0, 0, 0, 0), + selectionControls: cupertinoTextSelectionControls, + readOnly: true, + decoration: null, + padding: EdgeInsets.zero, + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + + // Double tap to select the second word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag the right handle 2 letters to the right. Placing the end handle on + // the third word. We use a small offset because the endpoint is on the very + // corner of the handle. + final TextSelection selection = controller.selection; + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 16); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 16); + + // Find start and end handles and verify their sizes. + expect(find.byType(Overlay), findsOneWidget); + expect( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + findsNWidgets(2), + ); + + final Iterable<RenderBox> handles = tester.renderObjectList( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + ); + + // The handle height is determined by the formula: + // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap . + // The text line height will be the value of the fontSize, of the largest word on the line. + // The constant _kSelectionHandleRadius has the value of 6. + // The constant _kSelectionHandleOverlap has the value of 1.5. + // In the case of the end handle, which is located on the grapheme cluster '👨‍👩‍👦', + // 100.0 + 6 * 2 - 1.5 = 110.5 . + expect(handles.first.size.height, 110.5); + expect(handles.last.size.height, 110.5); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'iOS selection handles scaling falls back to preferredLineHeight when the current frame does not match the previous with a tight selection height style', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = _RichTextController( + textSpan: const TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'abc', style: TextStyle(fontSize: 40.0)), + TextSpan(text: 'def', style: TextStyle(fontSize: 50.0)), + ], + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + focusNode: focusNode, + style: const TextStyle(fontSize: 50.0), + cursorColor: const Color.fromARGB(0, 0, 0, 0), + selectionHeightStyle: ui.BoxHeightStyle.tight, + selectionControls: cupertinoTextSelectionControls, + readOnly: true, + decoration: null, + padding: EdgeInsets.zero, + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + + // Double tap to select the second word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 6); + + // Drag the right handle 2 letters to the right. Placing the end handle on + // the third word. We use a small offset because the endpoint is on the very + // corner of the handle. + final TextSelection selection = controller.selection; + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 3); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 3); + + // Find start and end handles and verify their sizes. + expect(find.byType(Overlay), findsOneWidget); + expect( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + findsNWidgets(2), + ); + + final Iterable<RenderBox> handles = tester.renderObjectList( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + ); + + // The handle height is determined by the formula: + // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap . + // The text line height will be the value of the fontSize. + // The constant _kSelectionHandleRadius has the value of 6. + // The constant _kSelectionHandleOverlap has the value of 1.5. + // In the case of the start handle, which is located on the word 'abc', + // 40.0 + 6 * 2 - 1.5 = 50.5 . + // + // We are now using the current frames selection and text in order to + // calculate the start and end handle heights (we fall back to preferredLineHeight + // when the current frame differs from the previous frame), where previously + // we would be using a mix of the previous and current frame. This could + // result in the start and end handle heights being calculated inaccurately + // if one of the handles falls between two varying text styles. + expect(handles.first.size.height, 50.5); + expect(handles.last.size.height, 50.5); // This is 60.5 with the previous frame. + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); +} diff --git a/packages/cupertino_ui/test/cupertino/text_selection_toolbar_button_test.dart b/packages/cupertino_ui/test/cupertino/text_selection_toolbar_button_test.dart new file mode 100644 index 000000000000..6b99a4fc2903 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_selection_toolbar_button_test.dart @@ -0,0 +1,102 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('can press', (WidgetTester tester) async { + var pressed = false; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextSelectionToolbarButton( + child: const Text('Tap me'), + onPressed: () { + pressed = true; + }, + ), + ), + ), + ); + + expect(pressed, false); + + await tester.tap(find.byType(CupertinoTextSelectionToolbarButton)); + expect(pressed, true); + }); + + testWidgets('background darkens when pressed', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextSelectionToolbarButton(child: const Text('Tap me'), onPressed: () {}), + ), + ), + ); + + // Original with transparent background. + DecoratedBox decoratedBox = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(DecoratedBox)), + ); + var decoration = decoratedBox.decoration as ShapeDecoration; + expect(decoration.color, CupertinoColors.transparent); + + // Make a "down" gesture on the button. + final Offset center = tester.getCenter(find.byType(CupertinoTextSelectionToolbarButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // When pressed, the background darkens. + decoratedBox = tester.widget( + find.descendant( + of: find.byType(CupertinoTextSelectionToolbarButton), + matching: find.byType(DecoratedBox), + ), + ); + decoration = decoratedBox.decoration as ShapeDecoration; + expect(decoration.color!.value, const Color(0x10000000).value); + + // Release the down gesture. + await gesture.up(); + await tester.pumpAndSettle(); + + // Color is back to transparent. + decoratedBox = tester.widget( + find.descendant( + of: find.byType(CupertinoTextSelectionToolbarButton), + matching: find.byType(DecoratedBox), + ), + ); + decoration = decoratedBox.decoration as ShapeDecoration; + expect(decoration.color, CupertinoColors.transparent); + }); + + testWidgets('passing null to onPressed disables the button', (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center(child: CupertinoTextSelectionToolbarButton(child: Text('Tap me'))), + ), + ); + + expect(find.byType(CupertinoButton), findsOneWidget); + final CupertinoButton button = tester.widget(find.byType(CupertinoButton)); + expect(button.enabled, isFalse); + }); + + testWidgets('CupertinoTextSelectionToolbarButton does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: SizedBox.shrink(child: CupertinoTextSelectionToolbarButton(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(CupertinoTextSelectionToolbarButton)), Size.zero); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/text_selection_toolbar_test.dart b/packages/cupertino_ui/test/cupertino/text_selection_toolbar_test.dart new file mode 100644 index 000000000000..8b535a7cb1d9 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_selection_toolbar_test.dart @@ -0,0 +1,635 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'editable_text_utils.dart' show textOffsetToPosition; + +// These constants are copied from cupertino/text_selection_toolbar.dart. +const double _kArrowScreenPadding = 26.0; +const double _kToolbarContentDistance = 8.0; +const Size _kToolbarArrowSize = Size(14.0, 7.0); + +// A custom text selection menu that just displays a single custom button. +class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionControls { + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List<TextSelectionPoint> endpoints, + TextSelectionDelegate delegate, + ValueListenable<ClipboardStatus>? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); + final double anchorX = (selectionMidpoint.dx + globalEditableRegion.left).clamp( + _kArrowScreenPadding + mediaQueryPadding.left, + MediaQuery.sizeOf(context).width - mediaQueryPadding.right - _kArrowScreenPadding, + ); + final anchorAbove = Offset( + anchorX, + endpoints.first.point.dy - textLineHeight + globalEditableRegion.top, + ); + final anchorBelow = Offset(anchorX, endpoints.last.point.dy + globalEditableRegion.top); + + return CupertinoTextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: <Widget>[ + CupertinoTextSelectionToolbarButton(onPressed: () {}, child: const Text('Custom button')), + ], + ); + } +} + +class TestBox extends SizedBox { + const TestBox({super.key}) : super(width: itemWidth, height: itemHeight); + + static const double itemHeight = 44.0; + static const double itemWidth = 100.0; +} + +const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness( + color: CupertinoColors.black, + darkColor: CupertinoColors.white, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Find by a runtimeType String, including private types. + Finder findPrivate(String type) { + return find.descendant( + of: find.byType(CupertinoApp), + matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type), + ); + } + + // Finding CupertinoTextSelectionToolbar won't give you the position as the user sees + // it because it's a full-sized Stack at the top level. This method finds the + // visible part of the toolbar for use in measurements. + Finder findToolbar() => findPrivate('_CupertinoTextSelectionToolbarContent'); + + // Check if the middle point of the chevron is pointing left or right. + // + // Offset.dx: a right or left margin (_kToolbarChevronSize / 4 => 2.5) to center the icon horizontally + // Offset.dy: always in the exact vertical center (_kToolbarChevronSize / 2 => 5) + PaintPattern overflowNextPaintPattern() => paints + ..line(p1: const Offset(2.5, 0), p2: const Offset(7.5, 5)) + ..line(p1: const Offset(7.5, 5), p2: const Offset(2.5, 10)); + PaintPattern overflowBackPaintPattern() => paints + ..line(p1: const Offset(7.5, 0), p2: const Offset(2.5, 5)) + ..line(p1: const Offset(2.5, 5), p2: const Offset(7.5, 10)); + + Finder findOverflowNextButton() { + return find.byWidgetPredicate( + (Widget widget) => + widget is CustomPaint && + '${widget.painter?.runtimeType}' == '_RightCupertinoChevronPainter', + ); + } + + Finder findOverflowBackButton() { + return find.byWidgetPredicate( + (Widget widget) => + widget is CustomPaint && + '${widget.painter?.runtimeType}' == '_LeftCupertinoChevronPainter', + ); + } + + testWidgets('chevrons point to the correct side', (WidgetTester tester) async { + // Add enough TestBoxes to need 3 pages. + final children = List<Widget>.generate(15, (int i) => const TestBox()); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ), + ), + ), + ); + + expect(findOverflowBackButton(), findsNothing); + expect(findOverflowNextButton(), findsOneWidget); + + expect(findOverflowNextButton(), overflowNextPaintPattern()); + + // Tap the overflow next button to show the next page of children. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsOneWidget); + + expect(findOverflowBackButton(), overflowBackPaintPattern()); + expect(findOverflowNextButton(), overflowNextPaintPattern()); + + // Tap the overflow next button to show the last page of children. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + + expect(findOverflowBackButton(), findsOneWidget); + expect(findOverflowNextButton(), findsNothing); + + expect(findOverflowBackButton(), overflowBackPaintPattern()); + }); + + testWidgets('paginates children if they overflow', (WidgetTester tester) async { + late StateSetter setState; + final children = List<Widget>.generate(7, (int i) => const TestBox()); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CupertinoTextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ); + }, + ), + ), + ), + ); + + // All children fit on the screen, so they are all rendered. + expect(find.byType(TestBox), findsNWidgets(children.length)); + expect(findOverflowNextButton(), findsNothing); + expect(findOverflowBackButton(), findsNothing); + + // Adding one more child makes the children overflow. + setState(() { + children.add(const TestBox()); + }); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(children.length - 1)); + expect(findOverflowNextButton(), findsOneWidget); + expect(findOverflowBackButton(), findsNothing); + + // Tap the overflow next button to show the next page of children. + // The next button is hidden as there's no next page. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(1)); + expect(findOverflowNextButton(), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + + // Tap the overflow back button to go back to the first page. + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(7)); + expect(findOverflowNextButton(), findsOneWidget); + expect(findOverflowBackButton(), findsNothing); + + // Adding 7 more children overflows onto a third page. + setState(() { + children.addAll(List<TestBox>.filled(6, const TestBox())); + }); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(7)); + expect(findOverflowNextButton(), findsOneWidget); + expect(findOverflowBackButton(), findsNothing); + + // Tap the overflow next button to show the second page of children. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + // With the back button, only six children fit on this page. + expect(find.byType(TestBox), findsNWidgets(6)); + expect(findOverflowNextButton(), findsOneWidget); + expect(findOverflowBackButton(), findsOneWidget); + + // Tap the overflow next button again to show the third page of children. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(1)); + expect(findOverflowNextButton(), findsNothing); + expect(findOverflowBackButton(), findsOneWidget); + + // Tap the overflow back button to go back to the second page. + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(6)); + expect(findOverflowNextButton(), findsOneWidget); + expect(findOverflowBackButton(), findsOneWidget); + + // Tap the overflow back button to go back to the first page. + await tester.tapAt(tester.getCenter(findOverflowBackButton())); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(7)); + expect(findOverflowNextButton(), findsOneWidget); + expect(findOverflowBackButton(), findsNothing); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('does not paginate if children fit with zero margin', (WidgetTester tester) async { + final children = List<Widget>.generate(7, (int i) => const TestBox()); + final double spacerWidth = 1.0 / tester.view.devicePixelRatio; + final double dividerWidth = 1.0 / tester.view.devicePixelRatio; + const borderRadius = 8.0; // Should match _kToolbarBorderRadius + final double width = + 7 * TestBox.itemWidth + 6 * (dividerWidth + 2 * spacerWidth) + 2 * borderRadius; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: width, + child: CupertinoTextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ), + ), + ), + ), + ); + + // All children fit on the screen, so they are all rendered. + expect(find.byType(TestBox), findsNWidgets(children.length)); + expect(findOverflowNextButton(), findsNothing); + expect(findOverflowBackButton(), findsNothing); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('correctly sizes large toolbar buttons', (WidgetTester tester) async { + final GlobalKey firstBoxKey = GlobalKey(); + final GlobalKey secondBoxKey = GlobalKey(); + final GlobalKey thirdBoxKey = GlobalKey(); + final GlobalKey fourthBoxKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: 420, + child: CupertinoTextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: <Widget>[ + SizedBox(key: firstBoxKey, width: 100), + SizedBox(key: secondBoxKey, width: 300), + SizedBox(key: thirdBoxKey, width: 100), + SizedBox(key: fourthBoxKey, width: 100), + ], + ), + ), + ), + ), + ); + + // The first page isn't wide enough to show the second button. + expect(find.byKey(firstBoxKey), findsOneWidget); + expect(find.byKey(secondBoxKey), findsNothing); + expect(find.byKey(thirdBoxKey), findsNothing); + expect(find.byKey(fourthBoxKey), findsNothing); + + // Show the next page. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + + // The second page should show only the second button. + expect(find.byKey(firstBoxKey), findsNothing); + expect(find.byKey(secondBoxKey), findsOneWidget); + expect(find.byKey(thirdBoxKey), findsNothing); + expect(find.byKey(fourthBoxKey), findsNothing); + + // The button's width shouldn't be limited by the first page's width. + expect(tester.getSize(find.byKey(secondBoxKey)).width, 300); + + // Show the next page. + await tester.tapAt(tester.getCenter(findOverflowNextButton())); + await tester.pumpAndSettle(); + + // The third page should show the last two items. + expect(find.byKey(firstBoxKey), findsNothing); + expect(find.byKey(secondBoxKey), findsNothing); + expect(find.byKey(thirdBoxKey), findsOneWidget); + expect(find.byKey(fourthBoxKey), findsOneWidget); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async { + late StateSetter setState; + const height = 50.0; + const anchorBelowY = 500.0; + var anchorAboveY = 0.0; + const paddingAbove = 12.0; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + final MediaQueryData data = MediaQuery.of(context); + // Add some custom vertical padding to make this test more strict. + // By default in the testing environment, _kToolbarContentDistance + // and the built-in padding from CupertinoApp can end up canceling + // each other out. + return MediaQuery( + data: data.copyWith(padding: data.viewPadding.copyWith(top: paddingAbove)), + child: CupertinoTextSelectionToolbar( + anchorAbove: Offset(50.0, anchorAboveY), + anchorBelow: const Offset(50.0, anchorBelowY), + children: <Widget>[ + Container(color: const Color(0xffff0000), width: 50.0, height: height), + Container(color: const Color(0xff00ff00), width: 50.0, height: height), + Container(color: const Color(0xff0000ff), width: 50.0, height: height), + ], + ), + ); + }, + ), + ), + ), + ); + + // When the toolbar doesn't fit above aboveAnchor, it positions itself below + // belowAnchor. + double toolbarY = tester.getTopLeft(findToolbar()).dy; + expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance)); + expect(find.byType(CustomSingleChildLayout), findsOneWidget); + final CustomSingleChildLayout layout = tester.widget(find.byType(CustomSingleChildLayout)); + final delegate = layout.delegate as TextSelectionToolbarLayoutDelegate; + expect(delegate.anchorBelow.dy, anchorBelowY - paddingAbove); + + // Even when it barely doesn't fit. + setState(() { + anchorAboveY = 70.0; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(findToolbar()).dy; + expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance)); + + // When it does fit above aboveAnchor, it positions itself there. + setState(() { + anchorAboveY = 80.0; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(findToolbar()).dy; + expect( + toolbarY, + equals(anchorAboveY - height + _kToolbarArrowSize.height - _kToolbarContentDistance), + ); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('Arrow points upwards if toolbar is below the anchor', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(top: 59.0)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 51.0), + child: CupertinoTextSelectionToolbar( + anchorAbove: const Offset(15.0, 117.0), + anchorBelow: const Offset(15.0, 140.0), + children: const <Widget>[SizedBox(height: 56.0)], + ), + ), + ); + }, + ), + ), + ); + + expect( + findPrivate('_CupertinoTextSelectionToolbarShape'), + paints + ..rrect() + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[const Offset(18.0, 49.0), const Offset(25.0, 42.0)], + excludes: <Offset>[const Offset(18.0, 0.0), const Offset(25.0, 7.0)], + ), + ), + ); + }); + + testWidgets('can create and use a custom toolbar', (WidgetTester tester) async { + final controller = TextEditingController(text: 'Select me custom menu'); + addTearDown(controller.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + selectionControls: _CustomCupertinoTextSelectionControls(), + ), + ), + ), + ); + + // The selection menu is not initially shown. + expect(find.text('Custom button'), findsNothing); + + // Long press on "custom" to select it. + final Offset customPos = textOffsetToPosition(tester, 11); + final TestGesture gesture = await tester.startGesture(customPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The custom selection menu is shown. + expect(find.text('Custom button'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + for (final themeBrightness in <Brightness?>[...Brightness.values, null]) { + for (final mediaBrightness in <Brightness?>[...Brightness.values, null]) { + testWidgets( + 'draws dark buttons in dark mode and light button in light mode when theme is $themeBrightness and MediaQuery is $mediaBrightness', + (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + theme: CupertinoThemeData(brightness: themeBrightness), + home: Center( + child: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(platformBrightness: mediaBrightness), + child: CupertinoTextSelectionToolbar( + anchorAbove: const Offset(100.0, 0.0), + anchorBelow: const Offset(100.0, 0.0), + children: <Widget>[ + CupertinoTextSelectionToolbarButton.text( + onPressed: () {}, + text: 'Button', + ), + ], + ), + ); + }, + ), + ), + ), + ); + + final Finder buttonFinder = find.byType(CupertinoButton); + expect(buttonFinder, findsOneWidget); + + final Finder textFinder = find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(Text), + ); + expect(textFinder, findsOneWidget); + final Text text = tester.widget(textFinder); + + // Theme brightness is preferred, otherwise MediaQuery brightness is + // used. If both are null, defaults to light. + final Brightness effectiveBrightness = + themeBrightness ?? mediaBrightness ?? Brightness.light; + + expect( + text.style!.color!.value, + effectiveBrightness == Brightness.dark + ? _kToolbarTextColor.darkColor.value + : _kToolbarTextColor.color.value, + ); + }, + // [intended] We do not use Flutter-rendered context menu on the Web. + skip: kIsWeb, + ); + } + } + + testWidgets('draws a shadow below the toolbar in light mode', (WidgetTester tester) async { + late StateSetter setState; + const height = 50.0; + var anchorAboveY = 0.0; + + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + final MediaQueryData data = MediaQuery.of(context); + // Add some custom vertical padding to make this test more strict. + // By default in the testing environment, _kToolbarContentDistance + // and the built-in padding from CupertinoApp can end up canceling + // each other out. + return MediaQuery( + data: data.copyWith(padding: data.viewPadding.copyWith(top: 12.0)), + child: CupertinoTextSelectionToolbar( + anchorAbove: Offset(50.0, anchorAboveY), + anchorBelow: const Offset(50.0, 500.0), + children: <Widget>[ + Container(color: const Color(0xffff0000), width: 50.0, height: height), + Container(color: const Color(0xff00ff00), width: 50.0, height: height), + Container(color: const Color(0xff0000ff), width: 50.0, height: height), + ], + ), + ); + }, + ), + ), + ), + ); + + final double dividerWidth = 1.0 / tester.view.devicePixelRatio; + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..rrect( + rrect: RRect.fromLTRBR( + 8.0, + 515.0, + 158.0 + 2 * dividerWidth, + 558.0, + const Radius.circular(8.0), + ), + color: const Color(0x33000000), + ), + ); + + // When the toolbar is above the content, the shadow sits around the arrow + // with no offset. + setState(() { + anchorAboveY = 80.0; + }); + await tester.pump(); + + expect( + find.byType(CupertinoTextSelectionToolbar), + paints..rrect( + rrect: RRect.fromLTRBR( + 8.0, + 29.0, + 158.0 + 2 * dividerWidth, + 72.0, + const Radius.circular(8.0), + ), + color: const Color(0x33000000), + ), + ); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('Basic golden tests', (WidgetTester tester) async { + final Key key = UniqueKey(); + Widget buildToolbar(Brightness brightness, Offset offset) { + final Widget toolbar = CupertinoTextSelectionToolbar( + anchorAbove: offset, + anchorBelow: offset, + children: <Widget>[ + CupertinoTextSelectionToolbarButton.text(onPressed: () {}, text: 'Lorem ipsum'), + CupertinoTextSelectionToolbarButton.text(onPressed: () {}, text: 'dolor sit amet'), + CupertinoTextSelectionToolbarButton.text( + onPressed: () {}, + text: 'Lorem ipsum \ndolor sit amet', + ), + CupertinoTextSelectionToolbarButton.buttonItem( + buttonItem: ContextMenuButtonItem(onPressed: () {}, type: ContextMenuButtonType.copy), + ), + ], + ); + return CupertinoApp( + theme: CupertinoThemeData(brightness: brightness), + home: Center( + child: SizedBox( + height: 200, + child: RepaintBoundary(key: key, child: toolbar), + ), + ), + ); + } + + // The String describes the location of the toolbar in relation to the + // content the arrow points to. + const toolbarLocation = <(String, Offset)>[ + ('BottomRight', Offset.zero), + ('BottomLeft', Offset(100000, 0)), + ('TopRight', Offset(0, 100)), + ('TopLeft', Offset(100000, 100)), + ]; + + debugDisableShadows = false; + addTearDown(() => debugDisableShadows = true); + for (final Brightness brightness in Brightness.values) { + for (final (String location, Offset offset) in toolbarLocation) { + await tester.pumpWidget(buildToolbar(brightness, offset)); + await expectLater( + find.byKey(key), + matchesGoldenFile('cupertino_selection_toolbar.$location.$brightness.png'), + ); + } + } + debugDisableShadows = true; + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. +} diff --git a/packages/cupertino_ui/test/cupertino/text_theme_test.dart b/packages/cupertino_ui/test/cupertino/text_theme_test.dart new file mode 100644 index 000000000000..810294e8f2e6 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/text_theme_test.dart @@ -0,0 +1,68 @@ +// 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/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('CupertinoTextTheme matches Apple Design resources', () { + // Check the default cupertino text theme against the style values + // Values derived from https://developer.apple.com/design/resources/. + + const theme = CupertinoTextThemeData(); + const FontWeight normal = FontWeight.normal; + const FontWeight regular = FontWeight.w400; + const FontWeight medium = FontWeight.w500; + const FontWeight semiBold = FontWeight.w600; + const FontWeight bold = FontWeight.w700; + + // TextStyle 17 -0.41 + expect(theme.textStyle.fontSize, 17); + expect(theme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(theme.textStyle.letterSpacing, -0.41); + expect(theme.textStyle.fontWeight, null); + + // ActionTextStyle 17 -0.41 + expect(theme.actionTextStyle.fontSize, 17); + expect(theme.actionTextStyle.fontFamily, 'CupertinoSystemText'); + expect(theme.actionTextStyle.letterSpacing, -0.41); + expect(theme.actionTextStyle.fontWeight, null); + + // ActionSmallTextStyle 15 -0.23 (aka "Subheadline/Regular") + expect(theme.actionSmallTextStyle.fontSize, 15); + expect(theme.actionSmallTextStyle.fontFamily, 'CupertinoSystemText'); + expect(theme.actionSmallTextStyle.letterSpacing, -0.23); + expect(theme.actionSmallTextStyle.fontWeight, null); + + // TextStyle 17 -0.41 + expect(theme.tabLabelTextStyle.fontSize, 10); + expect(theme.tabLabelTextStyle.fontFamily, 'CupertinoSystemText'); + expect(theme.tabLabelTextStyle.letterSpacing, -0.24); + expect(theme.tabLabelTextStyle.fontWeight, medium); + + // NavTitle SemiBold 17 -0.41 + expect(theme.navTitleTextStyle.fontSize, 17); + expect(theme.navTitleTextStyle.fontFamily, 'CupertinoSystemText'); + expect(theme.navTitleTextStyle.letterSpacing, -0.41); + expect(theme.navTitleTextStyle.fontWeight, semiBold); + + // NavLargeTitle Bold 34 0.41 + expect(theme.navLargeTitleTextStyle.fontSize, 34); + expect(theme.navLargeTitleTextStyle.fontFamily, 'CupertinoSystemDisplay'); + expect(theme.navLargeTitleTextStyle.letterSpacing, 0.38); + expect(theme.navLargeTitleTextStyle.fontWeight, bold); + + // Picker Regular 21 -0.6 + expect(theme.pickerTextStyle.fontSize, 21); + expect(theme.pickerTextStyle.fontFamily, 'CupertinoSystemDisplay'); + expect(theme.pickerTextStyle.letterSpacing, -0.6); + expect(theme.pickerTextStyle.fontWeight, regular); + + // DateTimePicker Normal 21 + expect(theme.dateTimePickerTextStyle.fontSize, 21); + expect(theme.dateTimePickerTextStyle.fontFamily, 'CupertinoSystemDisplay'); + expect(theme.dateTimePickerTextStyle.letterSpacing, 0.4); + expect(theme.dateTimePickerTextStyle.fontWeight, normal); + }); +} diff --git a/packages/cupertino_ui/test/cupertino/theme_test.dart b/packages/cupertino_ui/test/cupertino/theme_test.dart new file mode 100644 index 000000000000..7a144a373fc7 --- /dev/null +++ b/packages/cupertino_ui/test/cupertino/theme_test.dart @@ -0,0 +1,290 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +int buildCount = 0; +CupertinoThemeData? actualTheme; +IconThemeData? actualIconTheme; + +final Widget singletonThemeSubtree = Builder( + builder: (BuildContext context) { + buildCount++; + actualTheme = CupertinoTheme.of(context); + actualIconTheme = IconTheme.of(context); + return const Placeholder(); + }, +); + +Future<CupertinoThemeData> testTheme(WidgetTester tester, CupertinoThemeData theme) async { + await tester.pumpWidget(CupertinoTheme(data: theme, child: singletonThemeSubtree)); + return actualTheme!; +} + +Future<IconThemeData> testIconTheme(WidgetTester tester, CupertinoThemeData theme) async { + await tester.pumpWidget(CupertinoTheme(data: theme, child: singletonThemeSubtree)); + return actualIconTheme!; +} + +void main() { + setUp(() { + buildCount = 0; + actualTheme = null; + actualIconTheme = null; + }); + + testWidgets('Default theme has defaults', (WidgetTester tester) async { + final CupertinoThemeData theme = await testTheme(tester, const CupertinoThemeData()); + + expect(theme.brightness, isNull); + expect(theme.primaryColor, CupertinoColors.activeBlue); + expect(theme.textTheme.textStyle.fontSize, 17.0); + expect(theme.applyThemeToAll, false); + }); + + testWidgets('Theme attributes cascade', (WidgetTester tester) async { + final CupertinoThemeData theme = await testTheme( + tester, + const CupertinoThemeData(primaryColor: CupertinoColors.systemRed), + ); + + expect(theme.textTheme.actionTextStyle.color, isSameColorAs(CupertinoColors.systemRed.color)); + }); + + testWidgets('Dependent attribute can be overridden from cascaded value', ( + WidgetTester tester, + ) async { + final CupertinoThemeData theme = await testTheme( + tester, + const CupertinoThemeData( + brightness: Brightness.dark, + textTheme: CupertinoTextThemeData(textStyle: TextStyle(color: CupertinoColors.black)), + ), + ); + + // The brightness still cascaded down to the background color. + expect(theme.scaffoldBackgroundColor, isSameColorAs(CupertinoColors.black)); + // But not to the font color which we overrode. + expect(theme.textTheme.textStyle.color, isSameColorAs(CupertinoColors.black)); + }); + + testWidgets('Reading themes creates dependencies', (WidgetTester tester) async { + // Reading the theme creates a dependency. + CupertinoThemeData theme = await testTheme( + tester, + const CupertinoThemeData( + // Default brightness is light, + barBackgroundColor: Color(0x11223344), + textTheme: CupertinoTextThemeData(textStyle: TextStyle(fontFamily: 'Skeuomorphic')), + ), + ); + + expect(buildCount, 1); + expect(theme.textTheme.textStyle.fontFamily, 'Skeuomorphic'); + + // Changing another property also triggers a rebuild. + theme = await testTheme( + tester, + const CupertinoThemeData( + brightness: Brightness.light, + barBackgroundColor: Color(0x11223344), + textTheme: CupertinoTextThemeData(textStyle: TextStyle(fontFamily: 'Skeuomorphic')), + ), + ); + + expect(buildCount, 2); + // Re-reading the same value doesn't change anything. + expect(theme.textTheme.textStyle.fontFamily, 'Skeuomorphic'); + + theme = await testTheme( + tester, + const CupertinoThemeData( + brightness: Brightness.light, + barBackgroundColor: Color(0x11223344), + textTheme: CupertinoTextThemeData(textStyle: TextStyle(fontFamily: 'Flat')), + ), + ); + + expect(buildCount, 3); + expect(theme.textTheme.textStyle.fontFamily, 'Flat'); + }); + + testWidgets('copyWith works', (WidgetTester tester) async { + const originalTheme = CupertinoThemeData(brightness: Brightness.dark, applyThemeToAll: true); + + final CupertinoThemeData theme = await testTheme( + tester, + originalTheme.copyWith(primaryColor: CupertinoColors.systemGreen, applyThemeToAll: false), + ); + + expect(theme.brightness, Brightness.dark); + expect(theme.primaryColor, isSameColorAs(CupertinoColors.systemGreen.darkColor)); + // Now check calculated derivatives. + expect( + theme.textTheme.actionTextStyle.color, + isSameColorAs(CupertinoColors.systemGreen.darkColor), + ); + expect(theme.scaffoldBackgroundColor, isSameColorAs(CupertinoColors.black)); + + expect(theme.applyThemeToAll, false); + }); + + testWidgets("Theme has default IconThemeData, which is derived from the theme's primary color", ( + WidgetTester tester, + ) async { + const CupertinoDynamicColor primaryColor = CupertinoColors.systemRed; + const themeData = CupertinoThemeData(primaryColor: primaryColor); + + final IconThemeData resultingIconTheme = await testIconTheme(tester, themeData); + + expect(resultingIconTheme.color, isSameColorAs(primaryColor)); + + // Works in dark mode if primaryColor is a CupertinoDynamicColor. + final Color darkColor = (await testIconTheme( + tester, + themeData.copyWith(brightness: Brightness.dark), + )).color!; + + expect(darkColor, isSameColorAs(primaryColor.darkColor)); + }); + + testWidgets('IconTheme.of creates a dependency on iconTheme', (WidgetTester tester) async { + IconThemeData iconTheme = await testIconTheme( + tester, + const CupertinoThemeData(primaryColor: CupertinoColors.destructiveRed), + ); + + expect(buildCount, 1); + expect(iconTheme.color, CupertinoColors.destructiveRed); + + iconTheme = await testIconTheme( + tester, + const CupertinoThemeData(primaryColor: CupertinoColors.activeOrange), + ); + expect(buildCount, 2); + expect(iconTheme.color, CupertinoColors.activeOrange); + }); + + testWidgets('CupertinoTheme diagnostics', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CupertinoThemeData().debugFillProperties(builder); + + final Set<String> description = builder.properties + .map((DiagnosticsNode node) => node.name.toString()) + .toSet(); + + expect( + setEquals(description, <String>{ + 'brightness', + 'primaryColor', + 'primaryContrastingColor', + 'barBackgroundColor', + 'scaffoldBackgroundColor', + 'applyThemeToAll', + 'textStyle', + 'actionTextStyle', + 'actionSmallTextStyle', + 'tabLabelTextStyle', + 'navTitleTextStyle', + 'navLargeTitleTextStyle', + 'navActionTextStyle', + 'pickerTextStyle', + 'dateTimePickerTextStyle', + 'selectionHandleColor', + }), + isTrue, + ); + }); + + testWidgets('CupertinoTheme.toStringDeep uses single-line style', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/47651. + expect( + const CupertinoTheme( + data: CupertinoThemeData(primaryColor: CupertinoColors.transparent), + child: SizedBox(), + ).toStringDeep().trimRight(), + isNot(contains('\n')), + ); + }); + + testWidgets('CupertinoThemeData equality', (WidgetTester tester) async { + const a = CupertinoThemeData(brightness: Brightness.dark); + final CupertinoThemeData b = a.copyWith(); + final CupertinoThemeData c = a.copyWith(brightness: Brightness.light); + expect(a, equals(b)); + expect(b, equals(a)); + expect(a, isNot(equals(c))); + expect(c, isNot(equals(a))); + expect(b, isNot(equals(c))); + expect(c, isNot(equals(b))); + }); + + testWidgets('NoDefaultCupertinoThemeData equality', (WidgetTester tester) async { + const a = NoDefaultCupertinoThemeData(); + final NoDefaultCupertinoThemeData b = a.copyWith(); + final NoDefaultCupertinoThemeData c = a.copyWith(brightness: Brightness.light); + expect(a, equals(b)); + expect(a, isNot(c)); + }); + + late Brightness currentBrightness; + void colorMatches(Color? componentColor, Color expectedDynamicColor) { + if (expectedDynamicColor is CupertinoDynamicColor) { + switch (currentBrightness) { + case Brightness.light: + expect(componentColor, isSameColorAs(expectedDynamicColor.color)); + case Brightness.dark: + expect(componentColor, isSameColorAs(expectedDynamicColor.darkColor)); + } + } else { + expect(componentColor, isSameColorAs(expectedDynamicColor)); + } + } + + void dynamicColorsTestGroup() { + testWidgets('CupertinoTheme.of resolves colors', (WidgetTester tester) async { + final data = CupertinoThemeData( + brightness: currentBrightness, + primaryColor: CupertinoColors.systemRed, + ); + final CupertinoThemeData theme = await testTheme(tester, data); + + expect(data.primaryColor, isSameColorAs(CupertinoColors.systemRed)); + colorMatches(theme.primaryColor, CupertinoColors.systemRed); + }); + + testWidgets('CupertinoTheme.of resolves default values', (WidgetTester tester) async { + const CupertinoDynamicColor primaryColor = CupertinoColors.systemRed; + final data = CupertinoThemeData(brightness: currentBrightness, primaryColor: primaryColor); + + const barBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color(0xF0F9F9F9), + darkColor: Color(0xF01D1D1D), + ); + + final CupertinoThemeData theme = await testTheme(tester, data); + + colorMatches(theme.primaryContrastingColor, CupertinoColors.white); + colorMatches(theme.barBackgroundColor, barBackgroundColor); + colorMatches(theme.scaffoldBackgroundColor, CupertinoColors.systemBackground); + colorMatches(theme.selectionHandleColor, CupertinoColors.systemBlue); + colorMatches(theme.textTheme.textStyle.color, CupertinoColors.label); + colorMatches(theme.textTheme.actionTextStyle.color, primaryColor); + colorMatches(theme.textTheme.tabLabelTextStyle.color, CupertinoColors.inactiveGray); + colorMatches(theme.textTheme.navTitleTextStyle.color, CupertinoColors.label); + colorMatches(theme.textTheme.navLargeTitleTextStyle.color, CupertinoColors.label); + colorMatches(theme.textTheme.navActionTextStyle.color, primaryColor); + colorMatches(theme.textTheme.pickerTextStyle.color, CupertinoColors.label); + colorMatches(theme.textTheme.dateTimePickerTextStyle.color, CupertinoColors.label); + }); + } + + currentBrightness = Brightness.light; + group('light colors', dynamicColorsTestGroup); + + currentBrightness = Brightness.dark; + group('dark colors', dynamicColorsTestGroup); +} diff --git a/packages/material_ui/lib/material_ui.dart b/packages/material_ui/lib/material_ui.dart index 1f2465f1265b..f8c296947388 100644 --- a/packages/material_ui/lib/material_ui.dart +++ b/packages/material_ui/lib/material_ui.dart @@ -5,6 +5,197 @@ /// The Flutter Material Design library. /// /// To use, import `package:material_ui/material_ui.dart`. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=DL0Ix1lnC4w} +/// +/// See also: +/// +/// * [docs.flutter.dev/ui/widgets/material](https://docs.flutter.dev/ui/widgets/material) +/// for a catalog of commonly-used Material component widgets. +/// * [m3.material.io](https://m3.material.io/) for the Material 3 specification +/// * [m2.material.io](https://m2.material.io/) for the Material 2 specification library material_ui; -export 'package:flutter/material.dart'; +export 'package:flutter/widgets.dart'; + +export 'src/about.dart'; +export 'src/action_buttons.dart'; +export 'src/action_chip.dart'; +export 'src/action_icons_theme.dart'; +export 'src/adaptive_text_selection_toolbar.dart'; +export 'src/animated_icons.dart'; +export 'src/app.dart'; +export 'src/app_bar.dart'; +export 'src/app_bar_theme.dart'; +export 'src/arc.dart'; +export 'src/autocomplete.dart'; +export 'src/badge.dart'; +export 'src/badge_theme.dart'; +export 'src/banner.dart'; +export 'src/banner_theme.dart'; +export 'src/bottom_app_bar.dart'; +export 'src/bottom_app_bar_theme.dart'; +export 'src/bottom_navigation_bar.dart'; +export 'src/bottom_navigation_bar_theme.dart'; +export 'src/bottom_sheet.dart'; +export 'src/bottom_sheet_theme.dart'; +export 'src/button.dart'; +export 'src/button_bar.dart'; +export 'src/button_bar_theme.dart'; +export 'src/button_style.dart'; +export 'src/button_style_button.dart'; +export 'src/button_theme.dart'; +export 'src/calendar_date_picker.dart'; +export 'src/card.dart'; +export 'src/card_theme.dart'; +export 'src/carousel.dart'; +export 'src/carousel_theme.dart'; +export 'src/checkbox.dart'; +export 'src/checkbox_list_tile.dart'; +export 'src/checkbox_theme.dart'; +export 'src/chip.dart'; +export 'src/chip_theme.dart'; +export 'src/choice_chip.dart'; +export 'src/circle_avatar.dart'; +export 'src/color_scheme.dart'; +export 'src/colors.dart'; +export 'src/constants.dart'; +export 'src/curves.dart'; +export 'src/data_table.dart'; +export 'src/data_table_source.dart'; +export 'src/data_table_theme.dart'; +export 'src/date.dart'; +export 'src/date_picker.dart'; +export 'src/date_picker_theme.dart'; +export 'src/debug.dart'; +export 'src/desktop_text_selection.dart'; +export 'src/desktop_text_selection_toolbar.dart'; +export 'src/desktop_text_selection_toolbar_button.dart'; +export 'src/dialog.dart'; +export 'src/dialog_theme.dart'; +export 'src/divider.dart'; +export 'src/divider_theme.dart'; +export 'src/drawer.dart'; +export 'src/drawer_header.dart'; +export 'src/drawer_theme.dart'; +export 'src/dropdown.dart'; +export 'src/dropdown_menu.dart'; +export 'src/dropdown_menu_form_field.dart'; +export 'src/dropdown_menu_theme.dart'; +export 'src/elevated_button.dart'; +export 'src/elevated_button_theme.dart'; +export 'src/elevation_overlay.dart'; +export 'src/expand_icon.dart'; +export 'src/expansion_panel.dart'; +export 'src/expansion_tile.dart'; +export 'src/expansion_tile_theme.dart'; +export 'src/filled_button.dart'; +export 'src/filled_button_theme.dart'; +export 'src/filter_chip.dart'; +export 'src/flexible_space_bar.dart'; +export 'src/floating_action_button.dart'; +export 'src/floating_action_button_location.dart'; +export 'src/floating_action_button_theme.dart'; +export 'src/grid_tile.dart'; +export 'src/grid_tile_bar.dart'; +export 'src/icon_button.dart'; +export 'src/icon_button_theme.dart'; +export 'src/icons.dart'; +export 'src/ink_decoration.dart'; +export 'src/ink_highlight.dart'; +export 'src/ink_ripple.dart'; +export 'src/ink_sparkle.dart'; +export 'src/ink_splash.dart'; +export 'src/ink_well.dart'; +export 'src/input_border.dart'; +export 'src/input_chip.dart'; +export 'src/input_date_picker_form_field.dart'; +export 'src/input_decorator.dart'; +export 'src/list_tile.dart'; +export 'src/list_tile_theme.dart'; +export 'src/magnifier.dart'; +export 'src/material.dart'; +export 'src/material_button.dart'; +export 'src/material_localizations.dart'; +export 'src/material_state.dart'; +export 'src/material_state_mixin.dart'; +export 'src/menu_anchor.dart'; +export 'src/menu_bar_theme.dart'; +export 'src/menu_button_theme.dart'; +export 'src/menu_style.dart'; +export 'src/menu_theme.dart'; +export 'src/mergeable_material.dart'; +export 'src/motion.dart'; +export 'src/navigation_bar.dart'; +export 'src/navigation_bar_theme.dart'; +export 'src/navigation_drawer.dart'; +export 'src/navigation_drawer_theme.dart'; +export 'src/navigation_rail.dart'; +export 'src/navigation_rail_theme.dart'; +export 'src/no_splash.dart'; +export 'src/outlined_button.dart'; +export 'src/outlined_button_theme.dart'; +export 'src/page.dart'; +export 'src/page_transitions_theme.dart'; +export 'src/paginated_data_table.dart'; +export 'src/popup_menu.dart'; +export 'src/popup_menu_theme.dart'; +export 'src/predictive_back_page_transitions_builder.dart'; +export 'src/progress_indicator.dart'; +export 'src/progress_indicator_theme.dart'; +export 'src/radio.dart'; +export 'src/radio_list_tile.dart'; +export 'src/radio_theme.dart'; +export 'src/range_slider.dart'; +export 'src/range_slider_parts.dart'; +export 'src/refresh_indicator.dart'; +export 'src/reorderable_list.dart'; +export 'src/scaffold.dart'; +export 'src/scrollbar.dart'; +export 'src/scrollbar_theme.dart'; +export 'src/search.dart'; +export 'src/search_anchor.dart'; +export 'src/search_bar_theme.dart'; +export 'src/search_view_theme.dart'; +export 'src/segmented_button.dart'; +export 'src/segmented_button_theme.dart'; +export 'src/selectable_text.dart'; +export 'src/selection_area.dart'; +export 'src/shadows.dart'; +export 'src/slider.dart'; +export 'src/slider_parts.dart'; +export 'src/slider_theme.dart'; +export 'src/slider_value_indicator_shape.dart'; +export 'src/snack_bar.dart'; +export 'src/snack_bar_theme.dart'; +export 'src/spell_check_suggestions_toolbar.dart'; +export 'src/spell_check_suggestions_toolbar_layout_delegate.dart'; +export 'src/stepper.dart'; +export 'src/switch.dart'; +export 'src/switch_list_tile.dart'; +export 'src/switch_theme.dart'; +export 'src/tab_bar_theme.dart'; +export 'src/tab_controller.dart'; +export 'src/tab_indicator.dart'; +export 'src/tabs.dart'; +export 'src/text_button.dart'; +export 'src/text_button_theme.dart'; +export 'src/text_field.dart'; +export 'src/text_form_field.dart'; +export 'src/text_selection.dart'; +export 'src/text_selection_theme.dart'; +export 'src/text_selection_toolbar.dart'; +export 'src/text_selection_toolbar_text_button.dart'; +export 'src/text_theme.dart'; +export 'src/theme.dart'; +export 'src/theme_data.dart'; +export 'src/time.dart'; +export 'src/time_picker.dart'; +export 'src/time_picker_theme.dart'; +export 'src/toggle_buttons.dart'; +export 'src/toggle_buttons_theme.dart'; +export 'src/tooltip.dart'; +export 'src/tooltip_theme.dart'; +export 'src/tooltip_visibility.dart'; +export 'src/typography.dart'; +export 'src/user_accounts_drawer_header.dart'; diff --git a/packages/material_ui/lib/src/about.dart b/packages/material_ui/lib/src/about.dart new file mode 100644 index 000000000000..626a3647b270 --- /dev/null +++ b/packages/material_ui/lib/src/about.dart @@ -0,0 +1,1690 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'drawer.dart'; +/// @docImport 'list_tile_theme.dart'; +library; + +import 'dart:developer' show Flow, Timeline; +import 'dart:io' show Platform; + +import 'package:cupertino_ui/cupertino_ui.dart' show CupertinoDialogAction; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart' hide Flow; + +import 'app_bar.dart'; +import 'back_button.dart'; +import 'card.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'dialog.dart'; +import 'divider.dart'; +import 'floating_action_button_location.dart'; +import 'ink_decoration.dart'; +import 'list_tile.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'page.dart'; +import 'progress_indicator.dart'; +import 'scaffold.dart'; +import 'scrollbar.dart'; +import 'text_button.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// BuildContext context; + +/// A [ListTile] that shows an about box. +/// +/// This widget is often added to an app's [Drawer]. When tapped it shows +/// an about box dialog with [showAboutDialog]. +/// +/// The about box will include a button that shows licenses for software used by +/// the application. The licenses shown are those returned by the +/// [LicenseRegistry] API, which can be used to add more licenses to the list. +/// +/// If your application does not have a [Drawer], you should provide an +/// affordance to call [showAboutDialog] or (at least) [showLicensePage]. +/// +/// {@tool dartpad} +/// This sample shows two ways to open [AboutDialog]. The first one +/// uses an [AboutListTile], and the second uses the [showAboutDialog] function. +/// +/// ** See code in examples/api/lib/material/about/about_list_tile.0.dart ** +/// {@end-tool} +class AboutListTile extends StatelessWidget { + /// Creates a list tile for showing an about box. + /// + /// The arguments are all optional. The application name, if omitted, will be + /// derived from the nearest [Title] widget. The version, icon, and legalese + /// values default to the empty string. + const AboutListTile({ + super.key, + this.icon, + this.child, + this.applicationName, + this.applicationVersion, + this.applicationIcon, + this.applicationLegalese, + this.aboutBoxChildren, + this.dense, + }); + + /// The icon to show for this drawer item. + /// + /// By default no icon is shown. + /// + /// This is not necessarily the same as the image shown in the dialog box + /// itself; which is controlled by the [applicationIcon] property. + final Widget? icon; + + /// The label to show on this drawer item. + /// + /// Defaults to a text widget that says "About Foo" where "Foo" is the + /// application name specified by [applicationName]. + final Widget? child; + + /// The name of the application. + /// + /// This string is used in the default label for this drawer item (see + /// [child]) and as the caption of the [AboutDialog] that is shown. + /// + /// Defaults to the value of [Title.title], if a [Title] widget can be found. + /// Otherwise, defaults to [Platform.resolvedExecutable]. + final String? applicationName; + + /// The version of this build of the application. + /// + /// This string is shown under the application name in the [AboutDialog]. + /// + /// Defaults to the empty string. + final String? applicationVersion; + + /// The icon to show next to the application name in the [AboutDialog]. + /// + /// By default no icon is shown. + /// + /// Typically this will be an [ImageIcon] widget. It should honor the + /// [IconTheme]'s [IconThemeData.size]. + /// + /// This is not necessarily the same as the icon shown on the drawer item + /// itself, which is controlled by the [icon] property. + final Widget? applicationIcon; + + /// A string to show in small print in the [AboutDialog]. + /// + /// Typically this is a copyright notice. + /// + /// Defaults to the empty string. + final String? applicationLegalese; + + /// Widgets to add to the [AboutDialog] after the name, version, and legalese. + /// + /// This could include a link to a Web site, some descriptive text, credits, + /// or other information to show in the about box. + /// + /// Defaults to nothing. + final List<Widget>? aboutBoxChildren; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null, then its value is based on [ListTileThemeData.dense]. + /// + /// Dense list tiles default to a smaller height. + final bool? dense; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + return ListTile( + leading: icon, + title: + child ?? + Text( + MaterialLocalizations.of( + context, + ).aboutListTileTitle(applicationName ?? _defaultApplicationName(context)), + ), + dense: dense, + onTap: () { + showAboutDialog( + context: context, + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + children: aboutBoxChildren, + ); + }, + ); + } +} + +/// Displays an [AboutDialog], which describes the application and provides a +/// button to show licenses for software used by the application. +/// +/// The arguments correspond to the properties on [AboutDialog]. +/// +/// If the application has a [Drawer], consider using [AboutListTile] instead +/// of calling this directly. +/// +/// If you do not need an about box in your application, you should at least +/// provide an affordance to call [showLicensePage]. +/// +/// The licenses shown on the [LicensePage] are those returned by the +/// [LicenseRegistry] API, which can be used to add more licenses to the list. +/// +/// The [context], [barrierDismissible], [barrierColor], [barrierLabel], +/// [useRootNavigator], [routeSettings] and [anchorPoint] arguments are +/// passed to [showDialog], the documentation for which discusses how it is used. +void showAboutDialog({ + required BuildContext context, + String? applicationName, + String? applicationVersion, + Widget? applicationIcon, + String? applicationLegalese, + List<Widget>? children, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, +}) { + showDialog<void>( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + builder: (BuildContext context) { + return AboutDialog( + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + children: children, + ); + }, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + ); +} + +/// Displays either a Material or Cupertino [AboutDialog] depending on platform, +/// which describes the application and provides a button to show licenses +/// for software used by the application. +/// +/// The arguments correspond to the properties on [AboutDialog]. +/// +/// If the application has a [Drawer], consider using [AboutListTile] instead +/// of calling this directly. +/// +/// If you do not need an about box in your application, you should at least +/// provide an affordance to call [showLicensePage]. +/// +/// The licenses shown on the [LicensePage] are those returned by the +/// [LicenseRegistry] API, which can be used to add more licenses to the list. +/// +/// On most platforms this function will act the same as [showDialog], except +/// for iOS and macOS, in which case it will act the same as +/// [showCupertinoDialog]. +/// +/// The [context], [barrierDismissible], [barrierColor], [barrierLabel], +/// [useRootNavigator], [routeSettings] and [anchorPoint] arguments are +/// passed to [showAdaptiveDialog], the documentation for which discusses how it is used. +void showAdaptiveAboutDialog({ + required BuildContext context, + String? applicationName, + String? applicationVersion, + Widget? applicationIcon, + String? applicationLegalese, + List<Widget>? children, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, +}) { + showAdaptiveDialog<void>( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + builder: (BuildContext context) { + return AboutDialog.adaptive( + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + children: children, + ); + }, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + ); +} + +/// Displays a [LicensePage], which shows licenses for software used by the +/// application. +/// +/// The application arguments correspond to the properties on [LicensePage]. +/// +/// The `context` argument is used to look up the [Navigator] for the page. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// page to the [Navigator] furthest from or nearest to the given `context`. It +/// is `false` by default. +/// +/// If the application has a [Drawer], consider using [AboutListTile] instead +/// of calling this directly. +/// +/// The [AboutDialog] shown by [showAboutDialog] includes a button that calls +/// [showLicensePage]. +/// +/// The licenses shown on the [LicensePage] are those returned by the +/// [LicenseRegistry] API, which can be used to add more licenses to the list. +void showLicensePage({ + required BuildContext context, + String? applicationName, + String? applicationVersion, + Widget? applicationIcon, + String? applicationLegalese, + bool useRootNavigator = false, +}) { + final CapturedThemes themes = InheritedTheme.capture( + from: context, + to: Navigator.of(context, rootNavigator: useRootNavigator).context, + ); + Navigator.of(context, rootNavigator: useRootNavigator).push( + MaterialPageRoute<void>( + builder: (BuildContext context) => themes.wrap( + LicensePage( + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + ), + ), + ), + ); +} + +/// The amount of vertical space to separate chunks of text. +const double _textVerticalSeparation = 18.0; + +/// An about box. This is a dialog box with the application's icon, name, +/// version number, and copyright, plus a button to show licenses for software +/// used by the application. +/// +/// To show an [AboutDialog], use [showAboutDialog]. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=YFCSODyFxbE} +/// +/// If the application has a [Drawer], the [AboutListTile] widget can make the +/// process of showing an about dialog simpler. +/// +/// The [AboutDialog] shown by [showAboutDialog] includes a button that calls +/// [showLicensePage]. +/// +/// The licenses shown on the [LicensePage] are those returned by the +/// [LicenseRegistry] API, which can be used to add more licenses to the list. +class AboutDialog extends StatelessWidget { + /// Creates an about box. + /// + /// The arguments are all optional. The application name, if omitted, will be + /// derived from the nearest [Title] widget. The version, icon, and legalese + /// values default to the empty string. + const AboutDialog({ + super.key, + this.applicationName, + this.applicationVersion, + this.applicationIcon, + this.applicationLegalese, + this.children, + }); + + /// Creates an adaptive [AboutDialog] based on whether the target platform is + /// iOS or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// Typically passed as a child of [showAdaptiveAboutDialog], which will display + /// the [AboutDialog] differently based on platform. + /// + /// This constructor offers the same customization options as the default + /// [AboutDialog] constructor, allowing you to specify the application's + /// [applicationName], [applicationVersion], [applicationIcon], + /// [applicationLegalese], and additional [children] that appear in the dialog. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + const factory AboutDialog.adaptive({ + Key? key, + String? applicationName, + String? applicationVersion, + Widget? applicationIcon, + String? applicationLegalese, + List<Widget>? children, + }) = _AdaptiveAboutDialog; + + /// The name of the application. + /// + /// Defaults to the value of [Title.title], if a [Title] widget can be found. + /// Otherwise, defaults to [Platform.resolvedExecutable]. + final String? applicationName; + + /// The version of this build of the application. + /// + /// This string is shown under the application name. + /// + /// Defaults to the empty string. + final String? applicationVersion; + + /// The icon to show next to the application name. + /// + /// By default no icon is shown. + /// + /// Typically this will be an [ImageIcon] widget. It should honor the + /// [IconTheme]'s [IconThemeData.size]. + final Widget? applicationIcon; + + /// A string to show in small print. + /// + /// Typically this is a copyright notice. + /// + /// Defaults to the empty string. + final String? applicationLegalese; + + /// Widgets to add to the dialog box after the name, version, and legalese. + /// + /// This could include a link to a Web site, some descriptive text, credits, + /// or other information to show in the about box. + /// + /// Defaults to nothing. + final List<Widget>? children; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final String name = applicationName ?? _defaultApplicationName(context); + final String version = applicationVersion ?? _defaultApplicationVersion(context); + final Widget? icon = applicationIcon ?? _defaultApplicationIcon(context); + final ThemeData themeData = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return AlertDialog( + content: ListBody( + children: <Widget>[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + if (icon != null) IconTheme(data: themeData.iconTheme, child: icon), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: ListBody( + children: <Widget>[ + Text(name, style: themeData.textTheme.headlineSmall), + Text(version, style: themeData.textTheme.bodyMedium), + const SizedBox(height: _textVerticalSeparation), + Text(applicationLegalese ?? '', style: themeData.textTheme.bodySmall), + ], + ), + ), + ), + ], + ), + ...?children, + ], + ), + actions: <Widget>[ + TextButton( + child: Text( + themeData.useMaterial3 + ? localizations.viewLicensesButtonLabel + : localizations.viewLicensesButtonLabel.toUpperCase(), + ), + onPressed: () { + showLicensePage( + context: context, + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + ); + }, + ), + TextButton( + child: Text( + themeData.useMaterial3 + ? localizations.closeButtonLabel + : localizations.closeButtonLabel.toUpperCase(), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + scrollable: true, + ); + } +} + +class _AdaptiveAboutDialog extends AboutDialog { + const _AdaptiveAboutDialog({ + super.key, + super.applicationName, + super.applicationVersion, + super.applicationIcon, + super.applicationLegalese, + super.children, + }); + + List<Widget>? _actions(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + switch (themeData.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return <Widget>[ + CupertinoDialogAction( + child: Text( + themeData.useMaterial3 + ? localizations.viewLicensesButtonLabel + : localizations.viewLicensesButtonLabel.toUpperCase(), + ), + onPressed: () { + showLicensePage( + context: context, + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + ); + }, + ), + CupertinoDialogAction( + child: Text( + themeData.useMaterial3 + ? localizations.closeButtonLabel + : localizations.closeButtonLabel.toUpperCase(), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ]; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return <Widget>[ + TextButton( + child: Text( + themeData.useMaterial3 + ? localizations.viewLicensesButtonLabel + : localizations.viewLicensesButtonLabel.toUpperCase(), + ), + onPressed: () { + showLicensePage( + context: context, + applicationName: applicationName, + applicationVersion: applicationVersion, + applicationIcon: applicationIcon, + applicationLegalese: applicationLegalese, + ); + }, + ), + TextButton( + child: Text( + themeData.useMaterial3 + ? localizations.closeButtonLabel + : localizations.closeButtonLabel.toUpperCase(), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ]; + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final String name = applicationName ?? _defaultApplicationName(context); + final String version = applicationVersion ?? _defaultApplicationVersion(context); + final Widget? icon = applicationIcon ?? _defaultApplicationIcon(context); + final ThemeData themeData = Theme.of(context); + final List<Widget>? actions = _actions(context); + + return AlertDialog.adaptive( + content: ListBody( + children: <Widget>[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + if (icon != null) IconTheme(data: themeData.iconTheme, child: icon), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: ListBody( + children: <Widget>[ + Text(name, style: themeData.textTheme.headlineSmall), + Text(version, style: themeData.textTheme.bodyMedium), + const SizedBox(height: _textVerticalSeparation), + Text(applicationLegalese ?? '', style: themeData.textTheme.bodySmall), + ], + ), + ), + ), + ], + ), + ...?children, + ], + ), + actions: actions, + scrollable: true, + ); + } +} + +/// A page that shows licenses for software used by the application. +/// +/// To show a [LicensePage], use [showLicensePage]. +/// +/// The [AboutDialog] shown by [showAboutDialog] and [AboutListTile] includes +/// a button that calls [showLicensePage]. +/// +/// The licenses shown on the [LicensePage] are those returned by the +/// [LicenseRegistry] API, which can be used to add more licenses to the list. +class LicensePage extends StatefulWidget { + /// Creates a page that shows licenses for software used by the application. + /// + /// The arguments are all optional. The application name, if omitted, will be + /// derived from the nearest [Title] widget. The version and legalese values + /// default to the empty string. + /// + /// The licenses shown on the [LicensePage] are those returned by the + /// [LicenseRegistry] API, which can be used to add more licenses to the list. + const LicensePage({ + super.key, + this.applicationName, + this.applicationVersion, + this.applicationIcon, + this.applicationLegalese, + }); + + /// The name of the application. + /// + /// Defaults to the value of [Title.title], if a [Title] widget can be found. + /// Otherwise, defaults to [Platform.resolvedExecutable]. + final String? applicationName; + + /// The version of this build of the application. + /// + /// This string is shown under the application name. + /// + /// Defaults to the empty string. + final String? applicationVersion; + + /// The icon to show below the application name. + /// + /// By default no icon is shown. + /// + /// Typically this will be an [ImageIcon] widget. It should honor the + /// [IconTheme]'s [IconThemeData.size]. + final Widget? applicationIcon; + + /// A string to show in small print. + /// + /// Typically this is a copyright notice. + /// + /// Defaults to the empty string. + final String? applicationLegalese; + + @override + State<LicensePage> createState() => _LicensePageState(); +} + +class _LicensePageState extends State<LicensePage> { + final ValueNotifier<int?> selectedId = ValueNotifier<int?>(null); + + @override + void dispose() { + selectedId.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _MasterDetailFlow( + detailPageFABlessGutterWidth: _getGutterSize(context), + title: Text(MaterialLocalizations.of(context).licensesPageTitle), + detailPageBuilder: _packageLicensePage, + masterViewBuilder: _packagesView, + ); + } + + Widget _packageLicensePage(BuildContext _, Object? args, ScrollController? scrollController) { + assert(args is _DetailArguments); + final detailArguments = args! as _DetailArguments; + return _PackageLicensePage( + packageName: detailArguments.packageName, + licenseEntries: detailArguments.licenseEntries, + scrollController: scrollController, + ); + } + + Widget _packagesView(final BuildContext _, final bool isLateral) { + final Widget about = _AboutProgram( + name: widget.applicationName ?? _defaultApplicationName(context), + icon: widget.applicationIcon ?? _defaultApplicationIcon(context), + version: widget.applicationVersion ?? _defaultApplicationVersion(context), + legalese: widget.applicationLegalese, + ); + return _PackagesView(about: about, isLateral: isLateral, selectedId: selectedId); + } +} + +class _AboutProgram extends StatelessWidget { + const _AboutProgram({required this.name, required this.version, this.icon, this.legalese}); + + final String name; + final String version; + final Widget? icon; + final String? legalese; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: _getGutterSize(context), vertical: 24.0), + child: Column( + children: <Widget>[ + Text(name, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center), + if (icon != null) IconTheme(data: Theme.of(context).iconTheme, child: icon!), + if (version != '') + Padding( + padding: const EdgeInsets.only(bottom: _textVerticalSeparation), + child: Text( + version, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + if (legalese != null && legalese != '') + Text( + legalese!, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: _textVerticalSeparation), + Text( + 'Powered by Flutter', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _PackagesView extends StatefulWidget { + const _PackagesView({required this.about, required this.isLateral, required this.selectedId}); + + final Widget about; + final bool isLateral; + final ValueNotifier<int?> selectedId; + + @override + _PackagesViewState createState() => _PackagesViewState(); +} + +class _PackagesViewState extends State<_PackagesView> { + final Future<_LicenseData> licenses = LicenseRegistry.licenses + .fold<_LicenseData>( + _LicenseData(), + (_LicenseData prev, LicenseEntry license) => prev..addLicense(license), + ) + .then((_LicenseData licenseData) => licenseData..sortPackages()); + + @override + Widget build(BuildContext context) { + return FutureBuilder<_LicenseData>( + future: licenses, + builder: (BuildContext context, AsyncSnapshot<_LicenseData> snapshot) { + return LayoutBuilder( + key: ValueKey<ConnectionState>(snapshot.connectionState), + builder: (BuildContext context, BoxConstraints constraints) { + switch (snapshot.connectionState) { + case ConnectionState.done: + if (snapshot.hasError) { + assert(() { + FlutterError.reportError( + FlutterErrorDetails( + exception: snapshot.error!, + stack: snapshot.stackTrace, + context: ErrorDescription('while decoding the license file'), + ), + ); + return true; + }()); + return Center(child: Text(snapshot.error.toString())); + } + _initDefaultDetailPage(snapshot.data!, context); + return ValueListenableBuilder<int?>( + valueListenable: widget.selectedId, + builder: (BuildContext context, int? selectedId, Widget? _) { + return Center( + child: Material( + color: Theme.of(context).cardColor, + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600.0), + child: _packagesList( + context, + selectedId, + snapshot.data!, + widget.isLateral, + ), + ), + ), + ); + }, + ); + case ConnectionState.none: + case ConnectionState.active: + case ConnectionState.waiting: + return Material( + color: Theme.of(context).cardColor, + child: Column( + children: <Widget>[ + widget.about, + const Center(child: CircularProgressIndicator()), + ], + ), + ); + } + }, + ); + }, + ); + } + + void _initDefaultDetailPage(_LicenseData data, BuildContext context) { + if (data.packages.isEmpty) { + return; + } + final String packageName = data.packages[widget.selectedId.value ?? 0]; + final List<int> bindings = data.packageLicenseBindings[packageName]!; + _MasterDetailFlow.of(context).setInitialDetailPage( + _DetailArguments( + packageName, + bindings.map((int i) => data.licenses[i]).toList(growable: false), + ), + ); + } + + Widget _packagesList( + final BuildContext context, + final int? selectedId, + final _LicenseData data, + final bool drawSelection, + ) { + final EdgeInsets safeAreaPadding = MediaQuery.paddingOf(context); + final padding = EdgeInsets.only( + left: safeAreaPadding.left, + right: safeAreaPadding.right, + bottom: safeAreaPadding.bottom, + ); + return ListView.builder( + padding: padding, + itemCount: data.packages.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return widget.about; + } + final int packageIndex = index - 1; + final String packageName = data.packages[packageIndex]; + final List<int> bindings = data.packageLicenseBindings[packageName]!; + return _PackageListTile( + packageName: packageName, + index: packageIndex, + isSelected: drawSelection && packageIndex == (selectedId ?? 0), + numberLicenses: bindings.length, + onTap: () { + widget.selectedId.value = packageIndex; + _MasterDetailFlow.of(context).openDetailPage( + _DetailArguments( + packageName, + bindings.map((int i) => data.licenses[i]).toList(growable: false), + ), + ); + }, + ); + }, + ); + } +} + +class _PackageListTile extends StatelessWidget { + const _PackageListTile({ + required this.packageName, + this.index, + required this.isSelected, + required this.numberLicenses, + this.onTap, + }); + + final String packageName; + final int? index; + final bool isSelected; + final int numberLicenses; + final GestureTapCallback? onTap; + + @override + Widget build(BuildContext context) { + return Ink( + color: isSelected ? Theme.of(context).highlightColor : Theme.of(context).cardColor, + child: ListTile( + title: Text(packageName), + subtitle: Text(MaterialLocalizations.of(context).licensesPackageDetailText(numberLicenses)), + selected: isSelected, + onTap: onTap, + ), + ); + } +} + +/// This is a collection of licenses and the packages to which they apply. +/// [packageLicenseBindings] records the m+:n+ relationship between the license +/// and packages as a map of package names to license indexes. +class _LicenseData { + final List<LicenseEntry> licenses = <LicenseEntry>[]; + final Map<String, List<int>> packageLicenseBindings = <String, List<int>>{}; + final List<String> packages = <String>[]; + + // Special treatment for the first package since it should be the package + // for delivered application. + String? firstPackage; + + void addLicense(LicenseEntry entry) { + // Before the license can be added, we must first record the packages to + // which it belongs. + for (final String package in entry.packages) { + _addPackage(package); + // Bind this license to the package using the next index value. This + // creates a contract that this license must be inserted at this same + // index value. + packageLicenseBindings[package]!.add(licenses.length); + } + licenses.add(entry); // Completion of the contract above. + } + + /// Add a package and initialize package license binding. This is a no-op if + /// the package has been seen before. + void _addPackage(String package) { + if (!packageLicenseBindings.containsKey(package)) { + packageLicenseBindings[package] = <int>[]; + firstPackage ??= package; + packages.add(package); + } + } + + /// Sort the packages using some comparison method, or by the default manner, + /// which is to put the application package first, followed by every other + /// package in case-insensitive alphabetical order. + void sortPackages([int Function(String a, String b)? compare]) { + packages.sort( + compare ?? + (String a, String b) { + // Based on how LicenseRegistry currently behaves, the first package + // returned is the end user application license. This should be + // presented first in the list. So here we make sure that first package + // remains at the front regardless of alphabetical sorting. + if (a == firstPackage) { + return -1; + } + if (b == firstPackage) { + return 1; + } + return a.toLowerCase().compareTo(b.toLowerCase()); + }, + ); + } +} + +@immutable +class _DetailArguments { + const _DetailArguments(this.packageName, this.licenseEntries); + + final String packageName; + final List<LicenseEntry> licenseEntries; + + @override + bool operator ==(final Object other) { + if (other is _DetailArguments) { + return other.packageName == packageName; + } + return other == this; + } + + @override + int get hashCode => Object.hash(packageName, Object.hashAll(licenseEntries)); +} + +class _PackageLicensePage extends StatefulWidget { + const _PackageLicensePage({ + required this.packageName, + required this.licenseEntries, + required this.scrollController, + }); + + final String packageName; + final List<LicenseEntry> licenseEntries; + final ScrollController? scrollController; + + @override + _PackageLicensePageState createState() => _PackageLicensePageState(); +} + +class _PackageLicensePageState extends State<_PackageLicensePage> { + @override + void initState() { + super.initState(); + _initLicenses(); + } + + final List<Widget> _licenses = <Widget>[]; + bool _loaded = false; + + Future<void> _initLicenses() async { + var debugFlowId = -1; + assert(() { + final Flow flow = Flow.begin(); + Timeline.timeSync('_initLicenses()', () {}, flow: flow); + debugFlowId = flow.id; + return true; + }()); + for (final LicenseEntry license in widget.licenseEntries) { + if (!mounted) { + return; + } + assert(() { + Timeline.timeSync('_initLicenses()', () {}, flow: Flow.step(debugFlowId)); + return true; + }()); + final List<LicenseParagraph> paragraphs = await SchedulerBinding.instance + .scheduleTask<List<LicenseParagraph>>( + license.paragraphs.toList, + Priority.animation, + debugLabel: 'License', + ); + if (!mounted) { + return; + } + setState(() { + _licenses.add(const Padding(padding: EdgeInsets.all(18.0), child: Divider())); + for (final paragraph in paragraphs) { + if (paragraph.indent == LicenseParagraph.centeredIndent) { + _licenses.add( + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text( + paragraph.text, + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + ); + } else { + assert(paragraph.indent >= 0); + _licenses.add( + Padding( + padding: EdgeInsetsDirectional.only(top: 8.0, start: 16.0 * paragraph.indent), + child: Text(paragraph.text), + ), + ); + } + } + }); + } + setState(() { + _loaded = true; + }); + assert(() { + Timeline.timeSync('Build scheduled', () {}, flow: Flow.end(debugFlowId)); + return true; + }()); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final ThemeData theme = Theme.of(context); + final String title = widget.packageName; + final String subtitle = localizations.licensesPackageDetailText(widget.licenseEntries.length); + final double pad = _getGutterSize(context); + final EdgeInsets safeAreaPadding = MediaQuery.paddingOf(context); + final padding = EdgeInsets.only( + left: pad + safeAreaPadding.left, + right: pad + safeAreaPadding.right, + bottom: pad + safeAreaPadding.bottom, + ); + final listWidgets = <Widget>[ + ..._licenses, + if (!_loaded) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: Center(child: CircularProgressIndicator()), + ), + ]; + + final Widget page; + if (widget.scrollController == null) { + page = Scaffold( + appBar: AppBar( + title: _PackageLicensePageTitle( + title: title, + subtitle: subtitle, + theme: theme.useMaterial3 ? theme.textTheme : theme.primaryTextTheme, + titleTextStyle: theme.appBarTheme.titleTextStyle, + foregroundColor: theme.appBarTheme.foregroundColor, + ), + ), + body: Center( + child: Material( + color: theme.cardColor, + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600.0), + child: Localizations.override( + locale: const Locale('en', 'US'), + context: context, + child: ScrollConfiguration( + // A Scrollbar is built-in below. + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Scrollbar( + child: ListView(primary: true, padding: padding, children: listWidgets), + ), + ), + ), + ), + ), + ), + ); + } else { + page = CustomScrollView( + controller: widget.scrollController, + slivers: <Widget>[ + SliverAppBar( + automaticallyImplyLeading: false, + pinned: true, + backgroundColor: theme.cardColor, + title: _PackageLicensePageTitle( + title: title, + subtitle: subtitle, + theme: theme.textTheme, + titleTextStyle: theme.textTheme.titleLarge, + ), + ), + SliverPadding( + padding: padding, + sliver: SliverList.builder( + itemCount: listWidgets.length, + itemBuilder: (BuildContext context, int index) { + return Localizations.override( + locale: const Locale('en', 'US'), + context: context, + child: listWidgets[index], + ); + }, + ), + ), + ], + ); + } + return DefaultTextStyle(style: theme.textTheme.bodySmall!, child: page); + } +} + +class _PackageLicensePageTitle extends StatelessWidget { + const _PackageLicensePageTitle({ + required this.title, + required this.subtitle, + required this.theme, + this.titleTextStyle, + this.foregroundColor, + }); + + final String title; + final String subtitle; + final TextTheme theme; + final TextStyle? titleTextStyle; + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + final TextStyle? effectiveTitleTextStyle = titleTextStyle ?? theme.titleLarge; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Text(title, style: effectiveTitleTextStyle?.copyWith(color: foregroundColor)), + Text(subtitle, style: theme.titleSmall?.copyWith(color: foregroundColor)), + ], + ); + } +} + +String _defaultApplicationName(BuildContext context) { + // This doesn't handle the case of the application's title dynamically + // changing. In theory, we should make Title expose the current application + // title using an InheritedWidget, and so forth. However, in practice, if + // someone really wants their application title to change dynamically, they + // can provide an explicit applicationName to the widgets defined in this + // file, instead of relying on the default. + final Title? ancestorTitle = context.findAncestorWidgetOfExactType<Title>(); + return ancestorTitle?.title ?? Platform.resolvedExecutable.split(Platform.pathSeparator).last; +} + +String _defaultApplicationVersion(BuildContext context) { + // TODO(ianh): Get this from the embedder somehow. + return ''; +} + +Widget? _defaultApplicationIcon(BuildContext context) { + // TODO(ianh): Get this from the embedder somehow. + return null; +} + +const int _materialGutterThreshold = 720; +const double _wideGutterSize = 24.0; +const double _narrowGutterSize = 12.0; + +double _getGutterSize(BuildContext context) => + MediaQuery.widthOf(context) >= _materialGutterThreshold ? _wideGutterSize : _narrowGutterSize; + +/// Signature for the builder callback used by [_MasterDetailFlow]. +typedef _MasterViewBuilder = Widget Function(BuildContext context, bool isLateralUI); + +/// Signature for the builder callback used by [_MasterDetailFlow.detailPageBuilder]. +/// +/// scrollController is provided when the page destination is the draggable +/// sheet in the lateral UI. Otherwise, it is null. +typedef _DetailPageBuilder = + Widget Function(BuildContext context, Object? arguments, ScrollController? scrollController); + +/// Signature for the builder callback used by [_MasterDetailScaffold.actionBuilder]. +/// +/// Builds the actions that go in the app bars constructed for the master and +/// lateral UI pages. actionLevel indicates the intended destination of the +/// return actions. +typedef _ActionBuilder = List<Widget> Function(BuildContext context, _ActionLevel actionLevel); + +/// Describes which type of app bar the actions are intended for. +enum _ActionLevel { + /// Indicates the top app bar in the lateral UI. + top, + + /// Indicates the master view app bar in the lateral UI. + view, +} + +/// Describes which layout will be used by [_MasterDetailFlow]. +enum _LayoutMode { + /// Always use a lateral layout. + lateral, + + /// Always use a nested layout. + nested, +} + +const String _navMaster = 'master'; +const String _navDetail = 'detail'; + +enum _Focus { master, detail } + +/// A Master Detail Flow widget. Depending on screen width it builds either a +/// lateral or nested navigation flow between a master view and a detail page. +/// +/// If focus is on detail view, then switching to nested navigation will +/// populate the navigation history with the master page and the detail page on +/// top. Otherwise the focus is on the master view and just the master page +/// is shown. +class _MasterDetailFlow extends StatefulWidget { + /// Creates a master detail navigation flow which is either nested or + /// lateral depending on screen width. + const _MasterDetailFlow({ + required this.detailPageBuilder, + required this.masterViewBuilder, + this.detailPageFABlessGutterWidth, + this.title, + }); + + /// Builder for the master view for lateral navigation. + /// + /// This builder builds the master page required for nested navigation, also + /// builds the master view inside a [Scaffold] with an [AppBar]. + final _MasterViewBuilder masterViewBuilder; + + /// Builder for the detail page. + /// + /// If scrollController == null, the page is intended for nested navigation. The lateral detail + /// page is inside a [DraggableScrollableSheet] and should have a scrollable element that uses + /// the [ScrollController] provided. In fact, it is strongly recommended the entire lateral + /// page is scrollable. + final _DetailPageBuilder detailPageBuilder; + + /// Override the width of the gutter when there is no floating action button. + final double? detailPageFABlessGutterWidth; + + /// The title for the lateral UI [AppBar]. + /// + /// See [AppBar.title]. + final Widget? title; + + @override + _MasterDetailFlowState createState() => _MasterDetailFlowState(); + + // The master detail flow proxy from the closest instance of this class that encloses the given + // context. + // + // Typical usage is as follows: + // + // ```dart + // _MasterDetailFlow.of(context).openDetailPage(arguments); + // ``` + static _MasterDetailFlowProxy of(BuildContext context) { + _PageOpener? pageOpener = context.findAncestorStateOfType<_MasterDetailScaffoldState>(); + pageOpener ??= context.findAncestorStateOfType<_MasterDetailFlowState>(); + assert(() { + if (pageOpener == null) { + throw FlutterError( + 'Master Detail operation requested with a context that does not include a Master Detail ' + 'Flow.\nThe context used to open a detail page from the Master Detail Flow must be ' + 'that of a widget that is a descendant of a Master Detail Flow widget.', + ); + } + return true; + }()); + return _MasterDetailFlowProxy._(pageOpener!); + } +} + +/// Interface for interacting with the [_MasterDetailFlow]. +class _MasterDetailFlowProxy implements _PageOpener { + _MasterDetailFlowProxy._(this._pageOpener); + + final _PageOpener _pageOpener; + + /// Open detail page with arguments. + @override + void openDetailPage(Object arguments) => _pageOpener.openDetailPage(arguments); + + /// Set the initial page to be open for the lateral layout. This can be set at any time, but + /// will have no effect after any calls to openDetailPage. + @override + void setInitialDetailPage(Object arguments) => _pageOpener.setInitialDetailPage(arguments); +} + +abstract class _PageOpener { + void openDetailPage(Object arguments); + + void setInitialDetailPage(Object arguments); +} + +const int _materialWideDisplayThreshold = 840; + +class _MasterDetailFlowState extends State<_MasterDetailFlow> implements _PageOpener { + /// Tracks whether focus is on the detail or master views. Determines behavior when switching + /// from lateral to nested navigation. + _Focus focus = _Focus.master; + + /// Cache of arguments passed when opening a detail page. Used when rebuilding. + Object? _cachedDetailArguments; + + /// Record of the layout that was built. + _LayoutMode? _builtLayout; + + /// Key to access navigator in the nested layout. + final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>(); + + @override + void openDetailPage(Object arguments) { + _cachedDetailArguments = arguments; + switch (_builtLayout) { + case _LayoutMode.nested: + _navigatorKey.currentState!.pushNamed(_navDetail, arguments: arguments); + case _LayoutMode.lateral || null: + focus = _Focus.detail; + } + } + + @override + void setInitialDetailPage(Object arguments) { + _cachedDetailArguments = arguments; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double availableWidth = constraints.maxWidth; + if (availableWidth >= _materialWideDisplayThreshold) { + return _lateralUI(context); + } + return _nestedUI(context); + }, + ); + } + + Widget _nestedUI(BuildContext context) { + _builtLayout = _LayoutMode.nested; + final MaterialPageRoute<void> masterPageRoute = _masterPageRoute(context); + + return NavigatorPopHandler( + onPop: () { + _navigatorKey.currentState!.maybePop(); + }, + child: Navigator( + key: _navigatorKey, + initialRoute: 'initial', + onGenerateInitialRoutes: (NavigatorState navigator, String initialRoute) { + return switch (focus) { + _Focus.master => <Route<void>>[masterPageRoute], + _Focus.detail => <Route<void>>[ + masterPageRoute, + _detailPageRoute(_cachedDetailArguments), + ], + }; + }, + onGenerateRoute: (RouteSettings settings) { + switch (settings.name) { + case _navMaster: + // Matching state to navigation event. + focus = _Focus.master; + return masterPageRoute; + case _navDetail: + // Matching state to navigation event. + focus = _Focus.detail; + // Cache detail page settings. + _cachedDetailArguments = settings.arguments; + return _detailPageRoute(_cachedDetailArguments); + default: + throw Exception('Unknown route ${settings.name}'); + } + }, + ), + ); + } + + MaterialPageRoute<void> _masterPageRoute(BuildContext context) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext c) { + return BlockSemantics( + child: _MasterPage( + leading: Navigator.of(context).canPop() + ? BackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ) + : null, + title: widget.title, + masterViewBuilder: widget.masterViewBuilder, + ), + ); + }, + ); + } + + MaterialPageRoute<void> _detailPageRoute(Object? arguments) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return PopScope<void>( + onPopInvokedWithResult: (bool didPop, void result) { + // No need for setState() as rebuild happens on navigation pop. + focus = _Focus.master; + }, + child: BlockSemantics(child: widget.detailPageBuilder(context, arguments, null)), + ); + }, + ); + } + + Widget _lateralUI(BuildContext context) { + _builtLayout = _LayoutMode.lateral; + return _MasterDetailScaffold( + actionBuilder: (_, _) => const <Widget>[], + detailPageBuilder: (BuildContext context, Object? args, ScrollController? scrollController) => + widget.detailPageBuilder(context, args ?? _cachedDetailArguments, scrollController), + detailPageFABlessGutterWidth: widget.detailPageFABlessGutterWidth, + initialArguments: _cachedDetailArguments, + masterViewBuilder: (BuildContext context, bool isLateral) => + widget.masterViewBuilder(context, isLateral), + title: widget.title, + ); + } +} + +class _MasterPage extends StatelessWidget { + const _MasterPage({this.leading, this.title, this.masterViewBuilder}); + + final _MasterViewBuilder? masterViewBuilder; + final Widget? title; + final Widget? leading; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: title, leading: leading, actions: const <Widget>[]), + body: masterViewBuilder!(context, false), + ); + } +} + +const double _kCardElevation = 4.0; +const double _kMasterViewWidth = 320.0; +const double _kDetailPageFABlessGutterWidth = 40.0; +const double _kDetailPageFABGutterWidth = 84.0; + +class _MasterDetailScaffold extends StatefulWidget { + const _MasterDetailScaffold({ + required this.detailPageBuilder, + required this.masterViewBuilder, + this.actionBuilder, + this.initialArguments, + this.title, + this.detailPageFABlessGutterWidth, + }); + + final _MasterViewBuilder masterViewBuilder; + + /// Builder for the detail page. + /// + /// The detail page is inside a [DraggableScrollableSheet] and should have a scrollable element + /// that uses the [ScrollController] provided. In fact, it is strongly recommended the entire + /// lateral page is scrollable. + final _DetailPageBuilder detailPageBuilder; + final _ActionBuilder? actionBuilder; + final Object? initialArguments; + final Widget? title; + final double? detailPageFABlessGutterWidth; + + @override + _MasterDetailScaffoldState createState() => _MasterDetailScaffoldState(); +} + +class _MasterDetailScaffoldState extends State<_MasterDetailScaffold> implements _PageOpener { + late FloatingActionButtonLocation floatingActionButtonLocation; + late double detailPageFABGutterWidth; + late double detailPageFABlessGutterWidth; + late double masterViewWidth; + + final ValueNotifier<Object?> _detailArguments = ValueNotifier<Object?>(null); + + @override + void initState() { + super.initState(); + detailPageFABlessGutterWidth = + widget.detailPageFABlessGutterWidth ?? _kDetailPageFABlessGutterWidth; + detailPageFABGutterWidth = _kDetailPageFABGutterWidth; + masterViewWidth = _kMasterViewWidth; + floatingActionButtonLocation = FloatingActionButtonLocation.endTop; + } + + @override + void dispose() { + _detailArguments.dispose(); + super.dispose(); + } + + @override + void openDetailPage(Object arguments) { + SchedulerBinding.instance.addPostFrameCallback((_) => _detailArguments.value = arguments); + _MasterDetailFlow.of(context).openDetailPage(arguments); + } + + @override + void setInitialDetailPage(Object arguments) { + SchedulerBinding.instance.addPostFrameCallback((_) => _detailArguments.value = arguments); + _MasterDetailFlow.of(context).setInitialDetailPage(arguments); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: <Widget>[ + Scaffold( + floatingActionButtonLocation: floatingActionButtonLocation, + appBar: AppBar( + title: widget.title, + actions: widget.actionBuilder!(context, _ActionLevel.top), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: Row( + children: <Widget>[ + SizedBox( + width: masterViewWidth, + child: IconTheme( + data: Theme.of(context).primaryIconTheme, + child: Padding( + padding: const EdgeInsets.all(8), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: widget.actionBuilder!(context, _ActionLevel.view), + ), + ), + ), + ), + ), + ], + ), + ), + ), + body: Align(alignment: AlignmentDirectional.centerStart, child: _masterPanel(context)), + ), + // Detail view stacked above main scaffold and master view. + SafeArea( + child: Padding( + padding: EdgeInsetsDirectional.only( + start: masterViewWidth - _kCardElevation, + end: detailPageFABlessGutterWidth, + ), + child: ValueListenableBuilder<Object?>( + valueListenable: _detailArguments, + builder: (BuildContext context, Object? value, Widget? child) { + return AnimatedSwitcher( + transitionBuilder: (Widget child, Animation<double> animation) => + const FadeUpwardsPageTransitionsBuilder().buildTransitions<void>( + null, + null, + animation, + null, + child, + ), + duration: const Duration(milliseconds: 500), + child: SizedBox.expand( + key: ValueKey<Object?>(value ?? widget.initialArguments), + child: _DetailView( + builder: widget.detailPageBuilder, + arguments: value ?? widget.initialArguments, + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + + ConstrainedBox _masterPanel(BuildContext context, {bool needsScaffold = false}) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: masterViewWidth), + child: needsScaffold + ? Scaffold( + appBar: AppBar( + title: widget.title, + actions: widget.actionBuilder!(context, _ActionLevel.top), + ), + body: widget.masterViewBuilder(context, true), + ) + : widget.masterViewBuilder(context, true), + ); + } +} + +class _DetailView extends StatelessWidget { + const _DetailView({required _DetailPageBuilder builder, Object? arguments}) + : _builder = builder, + _arguments = arguments; + + final _DetailPageBuilder _builder; + final Object? _arguments; + + @override + Widget build(BuildContext context) { + if (_arguments == null) { + return const SizedBox.shrink(); + } + final double screenHeight = MediaQuery.heightOf(context); + final double minHeight = (screenHeight - kToolbarHeight) / screenHeight; + + return DraggableScrollableSheet( + initialChildSize: minHeight, + minChildSize: minHeight, + expand: false, + builder: (BuildContext context, ScrollController controller) { + return MouseRegion( + // TODO(TonicArtos): Remove MouseRegion workaround for pointer hover events passing through DraggableScrollableSheet once https://github.com/flutter/flutter/issues/59741 is resolved. + child: Card( + color: Theme.of(context).cardColor, + elevation: _kCardElevation, + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.fromLTRB(_kCardElevation, 0.0, _kCardElevation, 0.0), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(3.0)), + ), + child: _builder(context, _arguments, controller), + ), + ); + }, + ); + } +} diff --git a/packages/material_ui/lib/src/action_buttons.dart b/packages/material_ui/lib/src/action_buttons.dart new file mode 100644 index 000000000000..41022b9c74e6 --- /dev/null +++ b/packages/material_ui/lib/src/action_buttons.dart @@ -0,0 +1,395 @@ +// 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. + +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'app_bar.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'material.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'action_icons_theme.dart'; +import 'button_style.dart'; +import 'debug.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'material_localizations.dart'; +import 'scaffold.dart'; +import 'theme.dart'; + +abstract class _ActionButton extends IconButton { + /// Creates a Material Design icon button. + const _ActionButton({ + super.key, + super.color, + super.style, + super.onPressed, + required super.icon, + this.standardComponent, + }); + + /// An enum value to use to identify this button as a type of + /// [StandardComponentType]. + final StandardComponentType? standardComponent; + + /// This returns the appropriate tooltip text for this action button. + String _getTooltip(BuildContext context); + + /// This is the default function that is called when [onPressed] is set + /// to null. + void _onPressedCallback(BuildContext context); + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + return IconButton( + key: standardComponent?.key, + icon: icon, + style: style, + color: color, + tooltip: _getTooltip(context), + onPressed: () { + if (onPressed != null) { + onPressed!(); + } else { + _onPressedCallback(context); + } + }, + ); + } +} + +typedef _ActionIconBuilderCallback = WidgetBuilder? Function(ActionIconThemeData? actionIconTheme); +typedef _ActionIconDataCallback = IconData Function(BuildContext context); +typedef _AndroidSemanticsLabelCallback = + String Function(MaterialLocalizations materialLocalization); + +class _ActionIcon extends StatelessWidget { + const _ActionIcon({ + required this.iconBuilderCallback, + required this.getIcon, + required this.getAndroidSemanticsLabel, + }); + + final _ActionIconBuilderCallback iconBuilderCallback; + final _ActionIconDataCallback getIcon; + final _AndroidSemanticsLabelCallback getAndroidSemanticsLabel; + + @override + Widget build(BuildContext context) { + final ActionIconThemeData? actionIconTheme = ActionIconTheme.of(context); + final WidgetBuilder? iconBuilder = iconBuilderCallback(actionIconTheme); + if (iconBuilder != null) { + return iconBuilder(context); + } + + final IconData data = getIcon(context); + final String? semanticsLabel; + // This can't use the platform from Theme because it is the Android OS that + // expects the duplicated tooltip and label. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + semanticsLabel = getAndroidSemanticsLabel(MaterialLocalizations.of(context)); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.iOS: + case TargetPlatform.macOS: + semanticsLabel = null; + } + + return Icon(data, semanticLabel: semanticsLabel); + } +} + +/// A "back" icon that's appropriate for the current [TargetPlatform]. +/// +/// The current platform is determined by querying for the ambient [Theme]. +/// +/// See also: +/// +/// * [BackButton], an [IconButton] with a [BackButtonIcon] that calls +/// [Navigator.maybePop] to return to the previous route. +/// * [IconButton], which is a more general widget for creating buttons +/// with icons. +/// * [Icon], a Material Design icon. +/// * [ThemeData.platform], which specifies the current platform. +class BackButtonIcon extends StatelessWidget { + /// Creates an icon that shows the appropriate "back" image for + /// the current platform (as obtained from the [Theme]). + const BackButtonIcon({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionIcon( + iconBuilderCallback: (ActionIconThemeData? actionIconTheme) { + return actionIconTheme?.backButtonIconBuilder; + }, + getIcon: (BuildContext context) { + if (kIsWeb) { + // Always use 'Icons.arrow_back' as a back_button icon in web. + return Icons.arrow_back; + } + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return Icons.arrow_back; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return Icons.arrow_back_ios_new_rounded; + } + }, + getAndroidSemanticsLabel: (MaterialLocalizations materialLocalization) { + return materialLocalization.backButtonTooltip; + }, + ); + } +} + +/// A Material Design back icon button. +/// +/// A [BackButton] is an [IconButton] with a "back" icon appropriate for the +/// current [TargetPlatform]. When pressed, the back button calls +/// [Navigator.maybePop] to return to the previous route unless a custom +/// [onPressed] callback is provided. +/// +/// The [onPressed] callback can, for instance, be used to pop the platform's navigation stack +/// via [SystemNavigator] instead of Flutter's [Navigator] in add-to-app +/// situations. +/// +/// In Material Design 3, both [style]'s [ButtonStyle.iconColor] and [color] are +/// used to override the default icon color of [BackButton]. If both exist, the [ButtonStyle.iconColor] +/// will override [color] for states where [ButtonStyle.foregroundColor] resolves to non-null. +/// +/// When deciding to display a [BackButton], consider using +/// `ModalRoute.canPopOf(context)` to check whether the current route can be +/// popped. If that value is false (e.g., because the current route is the +/// initial route), the [BackButton] will not have any effect when pressed, +/// which could frustrate the user. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// See also: +/// +/// * [AppBar], which automatically uses a [BackButton] in its +/// [AppBar.leading] slot when the [Scaffold] has no [Drawer] and the +/// current [Route] is not the [Navigator]'s first route. +/// * [BackButtonIcon], which is useful if you need to create a back button +/// that responds differently to being pressed. +/// * [IconButton], which is a more general widget for creating buttons with +/// icons. +/// * [CloseButton], an alternative which may be more appropriate for leaf +/// node pages in the navigation tree. +class BackButton extends _ActionButton { + /// Creates an [IconButton] with the appropriate "back" icon for the current + /// target platform. + const BackButton({super.key, super.color, super.style, super.onPressed}) + : super(icon: const BackButtonIcon(), standardComponent: StandardComponentType.backButton); + + @override + void _onPressedCallback(BuildContext context) => Navigator.maybePop(context); + + @override + String _getTooltip(BuildContext context) { + return MaterialLocalizations.of(context).backButtonTooltip; + } +} + +/// A "close" icon that's appropriate for the current [TargetPlatform]. +/// +/// The current platform is determined by querying for the ambient [Theme]. +/// +/// See also: +/// +/// * [CloseButton], an [IconButton] with a [CloseButtonIcon] that calls +/// [Navigator.maybePop] to return to the previous route. +/// * [IconButton], which is a more general widget for creating buttons +/// with icons. +/// * [Icon], a Material Design icon. +/// * [ThemeData.platform], which specifies the current platform. +class CloseButtonIcon extends StatelessWidget { + /// Creates an icon that shows the appropriate "close" image for + /// the current platform (as obtained from the [Theme]). + const CloseButtonIcon({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionIcon( + iconBuilderCallback: (ActionIconThemeData? actionIconTheme) { + return actionIconTheme?.closeButtonIconBuilder; + }, + getIcon: (BuildContext context) => Icons.close, + getAndroidSemanticsLabel: (MaterialLocalizations materialLocalization) { + return materialLocalization.closeButtonTooltip; + }, + ); + } +} + +/// A Material Design close icon button. +/// +/// A [CloseButton] is an [IconButton] with a "close" icon. When pressed, the +/// close button calls [Navigator.maybePop] to return to the previous route. +/// +/// The [onPressed] callback can, for instance, be used to pop the platform's navigation stack +/// via [SystemNavigator] instead of Flutter's [Navigator] in add-to-app +/// situations. +/// +/// In Material Design 3, both [style]'s [ButtonStyle.iconColor] and [color] are +/// used to override the default icon color of [CloseButton]. If both exist, the [ButtonStyle.iconColor] +/// will override [color] for states where [ButtonStyle.foregroundColor] resolves to non-null. +/// +/// Use a [CloseButton] instead of a [BackButton] on fullscreen dialogs or +/// pages that may solicit additional actions to close. +/// +/// See also: +/// +/// * [AppBar], which automatically uses a [CloseButton] in its +/// [AppBar.leading] slot when appropriate. +/// * [BackButton], which is more appropriate for middle nodes in the +/// navigation tree or where pages can be popped instantaneously with +/// no user data consequence. +/// * [IconButton], to create other Material Design icon buttons. +class CloseButton extends _ActionButton { + /// Creates a Material Design close icon button. + const CloseButton({super.key, super.color, super.onPressed, super.style}) + : super(icon: const CloseButtonIcon(), standardComponent: StandardComponentType.closeButton); + + @override + void _onPressedCallback(BuildContext context) => Navigator.maybePop(context); + + @override + String _getTooltip(BuildContext context) { + return MaterialLocalizations.of(context).closeButtonTooltip; + } +} + +/// A "drawer" icon that's appropriate for the current [TargetPlatform]. +/// +/// The current platform is determined by querying for the ambient [Theme]. +/// +/// See also: +/// +/// * [DrawerButton], an [IconButton] with a [DrawerButtonIcon] that calls +/// [ScaffoldState.openDrawer] to open the [Scaffold.drawer]. +/// * [EndDrawerButton], an [IconButton] with an [EndDrawerButtonIcon] that +/// calls [ScaffoldState.openEndDrawer] to open the [Scaffold.endDrawer]. +/// * [IconButton], which is a more general widget for creating buttons +/// with icons. +/// * [Icon], a Material Design icon. +/// * [ThemeData.platform], which specifies the current platform. +class DrawerButtonIcon extends StatelessWidget { + /// Creates an icon that shows the appropriate "close" image for + /// the current platform (as obtained from the [Theme]). + const DrawerButtonIcon({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionIcon( + iconBuilderCallback: (ActionIconThemeData? actionIconTheme) { + return actionIconTheme?.drawerButtonIconBuilder; + }, + getIcon: (BuildContext context) => Icons.menu, + getAndroidSemanticsLabel: (MaterialLocalizations materialLocalization) { + return materialLocalization.openAppDrawerTooltip; + }, + ); + } +} + +/// A Material Design drawer icon button. +/// +/// A [DrawerButton] is an [IconButton] with a "drawer" icon. When pressed, the +/// close button calls [ScaffoldState.openDrawer] to the [Scaffold.drawer]. +/// +/// The default behaviour on press can be overridden with [onPressed]. +/// +/// See also: +/// +/// * [EndDrawerButton], an [IconButton] with an [EndDrawerButtonIcon] that +/// calls [ScaffoldState.openEndDrawer] to open the [Scaffold.endDrawer]. +/// * [IconButton], which is a more general widget for creating buttons +/// with icons. +/// * [Icon], a Material Design icon. +/// * [ThemeData.platform], which specifies the current platform. +class DrawerButton extends _ActionButton { + /// Creates a Material Design drawer icon button. + const DrawerButton({super.key, super.color, super.style, super.onPressed}) + : super(icon: const DrawerButtonIcon(), standardComponent: StandardComponentType.drawerButton); + + @override + void _onPressedCallback(BuildContext context) => Scaffold.of(context).openDrawer(); + + @override + String _getTooltip(BuildContext context) { + return MaterialLocalizations.of(context).openAppDrawerTooltip; + } +} + +/// A "end drawer" icon that's appropriate for the current [TargetPlatform]. +/// +/// The current platform is determined by querying for the ambient [Theme]. +/// +/// See also: +/// +/// * [DrawerButton], an [IconButton] with a [DrawerButtonIcon] that calls +/// [ScaffoldState.openDrawer] to open the [Scaffold.drawer]. +/// * [EndDrawerButton], an [IconButton] with an [EndDrawerButtonIcon] that +/// calls [ScaffoldState.openEndDrawer] to open the [Scaffold.endDrawer] +/// * [IconButton], which is a more general widget for creating buttons +/// with icons. +/// * [Icon], a Material Design icon. +/// * [ThemeData.platform], which specifies the current platform. +class EndDrawerButtonIcon extends StatelessWidget { + /// Creates an icon that shows the appropriate "end drawer" image for + /// the current platform (as obtained from the [Theme]). + const EndDrawerButtonIcon({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionIcon( + iconBuilderCallback: (ActionIconThemeData? actionIconTheme) { + return actionIconTheme?.endDrawerButtonIconBuilder; + }, + getIcon: (BuildContext context) => Icons.menu, + getAndroidSemanticsLabel: (MaterialLocalizations materialLocalization) { + return materialLocalization.openAppDrawerTooltip; + }, + ); + } +} + +/// A Material Design end drawer icon button. +/// +/// A [EndDrawerButton] is an [IconButton] with a "drawer" icon. When pressed, the +/// end drawer button calls [ScaffoldState.openEndDrawer] to open the [Scaffold.endDrawer]. +/// +/// The default behaviour on press can be overridden with [onPressed]. +/// +/// See also: +/// +/// * [DrawerButton], an [IconButton] with a [DrawerButtonIcon] that calls +/// [ScaffoldState.openDrawer] to open a drawer. +/// * [IconButton], which is a more general widget for creating buttons +/// with icons. +/// * [Icon], a Material Design icon. +/// * [ThemeData.platform], which specifies the current platform. +class EndDrawerButton extends _ActionButton { + /// Creates a Material Design end drawer icon button. + const EndDrawerButton({super.key, super.color, super.style, super.onPressed}) + : super(icon: const EndDrawerButtonIcon()); + + @override + void _onPressedCallback(BuildContext context) => Scaffold.of(context).openEndDrawer(); + + @override + String _getTooltip(BuildContext context) { + return MaterialLocalizations.of(context).openAppDrawerTooltip; + } +} diff --git a/packages/material_ui/lib/src/action_chip.dart b/packages/material_ui/lib/src/action_chip.dart new file mode 100644 index 000000000000..43ec50094575 --- /dev/null +++ b/packages/material_ui/lib/src/action_chip.dart @@ -0,0 +1,361 @@ +// 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. + +/// @docImport 'choice_chip.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'input_chip.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/widgets.dart'; + +import 'chip.dart'; +import 'chip_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +enum _ChipVariant { flat, elevated } + +/// A Material Design action chip. +/// +/// Action chips are a set of options which trigger an action related to primary +/// content. Action chips should appear dynamically and contextually in a UI. +/// +/// Action chips can be tapped to trigger an action or show progress and +/// confirmation. For Material 3, a disabled state is supported for Action +/// chips and is specified with [onPressed] being null. For previous versions +/// of Material Design, it is recommended to remove the Action chip from +/// the interface entirely rather than display a disabled chip. +/// +/// Action chips are displayed after primary content, such as below a card or +/// persistently at the bottom of a screen. +/// +/// The material button widgets, [ElevatedButton], [TextButton], and +/// [OutlinedButton], are an alternative to action chips, which should appear +/// statically and consistently in a UI. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// {@tool dartpad} +/// This example shows how to create an [ActionChip] with a leading icon. +/// The icon is updated when the [ActionChip] is pressed. +/// +/// ** See code in examples/api/lib/material/action_chip/action_chip.0.dart ** +/// {@end-tool} +/// +/// ## Material Design 3 +/// +/// [ActionChip] can be used for both the Assist and Suggestion chips from +/// Material Design 3. If [ThemeData.useMaterial3] is true, then [ActionChip] +/// will be styled to match the Material Design 3 Assist and Suggestion chips. +/// +/// ### Creating an Assist chip +/// +/// Assist chips are used to provide a quick way to perform an action. +/// To create an Action chip, set the icon property to the icon +/// that represents the action and set the label to the name of the action. +/// +/// +/// ### Creating a Suggestion chip +/// +/// Suggestion chips usually display generated suggestions for the user, +/// like a suggested response to a message. +/// +/// To create a Suggestion chip, set the label to the suggestion +/// and don't set the icon property. +// +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [CircleAvatar], which shows images or initials of people. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * <https://material.io/design/components/chips.html> +class ActionChip extends StatelessWidget + implements ChipAttributes, TappableChipAttributes, DisabledChipAttributes { + /// Create a chip that acts like a button. + /// + /// The [label], [autofocus], and [clipBehavior] arguments must not be null. + /// When [onPressed] is null, the [ActionChip] will be disabled. The [pressElevation] + /// and [elevation] must be null or non-negative. Typically, [pressElevation] + /// is greater than [elevation]. + const ActionChip({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.onPressed, + this.pressElevation, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.disabledColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.avatarBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + _chipVariant = _ChipVariant.flat; + + /// Create an elevated chip that acts like a button. + /// + /// The [label], [autofocus], and [clipBehavior] arguments must not be null. + /// When [onPressed] is null, the [ActionChip] will be disabled. The [pressElevation] + /// and [elevation] must be null or non-negative. Typically, [pressElevation] + /// is greater than [elevation]. + const ActionChip.elevated({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.onPressed, + this.pressElevation, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.disabledColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.avatarBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + _chipVariant = _ChipVariant.elevated; + + @override + final Widget? avatar; + @override + final Widget label; + @override + final TextStyle? labelStyle; + @override + final EdgeInsetsGeometry? labelPadding; + @override + final VoidCallback? onPressed; + @override + final double? pressElevation; + @override + final String? tooltip; + @override + final BorderSide? side; + @override + final OutlinedBorder? shape; + @override + final Clip clipBehavior; + @override + final FocusNode? focusNode; + @override + final bool autofocus; + @override + final WidgetStateProperty<Color?>? color; + @override + final Color? backgroundColor; + @override + final Color? disabledColor; + @override + final EdgeInsetsGeometry? padding; + @override + final VisualDensity? visualDensity; + @override + final MaterialTapTargetSize? materialTapTargetSize; + @override + final double? elevation; + @override + final Color? shadowColor; + @override + final Color? surfaceTintColor; + @override + final IconThemeData? iconTheme; + @override + final BoxConstraints? avatarBoxConstraints; + @override + final ChipAnimationStyle? chipAnimationStyle; + @override + final MouseCursor? mouseCursor; + + @override + bool get isEnabled => onPressed != null; + + final _ChipVariant _chipVariant; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ChipThemeData? defaults = Theme.of(context).useMaterial3 + ? _ActionChipDefaultsM3(context, isEnabled, _chipVariant) + : null; + return RawChip( + defaultProperties: defaults, + avatar: avatar, + label: label, + onPressed: onPressed, + pressElevation: pressElevation, + tooltip: tooltip, + labelStyle: labelStyle, + color: color, + backgroundColor: backgroundColor, + side: side, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + disabledColor: disabledColor, + padding: padding, + visualDensity: visualDensity, + isEnabled: isEnabled, + labelPadding: labelPadding, + materialTapTargetSize: materialTapTargetSize, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + iconTheme: iconTheme, + avatarBoxConstraints: avatarBoxConstraints, + chipAnimationStyle: chipAnimationStyle, + mouseCursor: mouseCursor, + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - ActionChip + +// 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. + +// dart format off +class _ActionChipDefaultsM3 extends ChipThemeData { + _ActionChipDefaultsM3(this.context, this.isEnabled, this._chipVariant) + : super( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + showCheckmark: true, + ); + + final BuildContext context; + final bool isEnabled; + final _ChipVariant _chipVariant; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + double? get elevation => _chipVariant == _ChipVariant.flat + ? 0.0 + : isEnabled ? 1.0 : 0.0; + + @override + double? get pressElevation => 1.0; + + @override + TextStyle? get labelStyle => _textTheme.labelLarge?.copyWith( + color: isEnabled + ? _colors.onSurface + : _colors.onSurface, + ); + + @override + WidgetStateProperty<Color?>? get color => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _chipVariant == _ChipVariant.flat + ? null + : _colors.onSurface.withOpacity(0.12); + } + return _chipVariant == _ChipVariant.flat + ? null + : _colors.surfaceContainerLow; + }); + + @override + Color? get shadowColor => _chipVariant == _ChipVariant.flat + ? Colors.transparent + : _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get checkmarkColor => null; + + @override + Color? get deleteIconColor => null; + + @override + BorderSide? get side => _chipVariant == _ChipVariant.flat + ? isEnabled + ? BorderSide(color: _colors.outlineVariant) + : BorderSide(color: _colors.onSurface.withOpacity(0.12)) + : const BorderSide(color: Colors.transparent); + + @override + IconThemeData? get iconTheme => IconThemeData( + color: isEnabled + ? _colors.primary + : _colors.onSurface, + size: 18.0, + ); + + @override + EdgeInsetsGeometry? get padding => const EdgeInsets.all(8.0); + + /// The label padding of the chip scales with the font size specified in the + /// [labelStyle], and the system font size settings that scale font sizes + /// globally. + /// + /// The chip at effective font size 14.0 starts with 8px on each side and as + /// the font size scales up to closer to 28.0, the label padding is linearly + /// interpolated from 8px to 4px. Once the label has a font size of 2 or + /// higher, label padding remains 4px. + @override + EdgeInsetsGeometry? get labelPadding { + final double fontSize = labelStyle?.fontSize ?? 14.0; + final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(fontSize) / 14.0; + return EdgeInsets.lerp( + const EdgeInsets.symmetric(horizontal: 8.0), + const EdgeInsets.symmetric(horizontal: 4.0), + clampDouble(fontSizeRatio - 1.0, 0.0, 1.0), + )!; + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - ActionChip diff --git a/packages/material_ui/lib/src/action_icons_theme.dart b/packages/material_ui/lib/src/action_icons_theme.dart new file mode 100644 index 000000000000..34af53d6e874 --- /dev/null +++ b/packages/material_ui/lib/src/action_icons_theme.dart @@ -0,0 +1,189 @@ +// 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 'action_buttons.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// A [ActionIconThemeData] that overrides the default icons of +/// [BackButton], [CloseButton], [DrawerButton], and [EndDrawerButton] with +/// [ActionIconTheme.of] or the overall [Theme]'s [ThemeData.actionIconTheme]. +@immutable +class ActionIconThemeData with Diagnosticable { + /// Creates an [ActionIconThemeData]. + /// + /// The builders [backButtonIconBuilder], [closeButtonIconBuilder], + /// [drawerButtonIconBuilder], [endDrawerButtonIconBuilder] may be null. + const ActionIconThemeData({ + this.backButtonIconBuilder, + this.closeButtonIconBuilder, + this.drawerButtonIconBuilder, + this.endDrawerButtonIconBuilder, + }); + + /// Overrides [BackButtonIcon]'s icon. + /// + /// If [backButtonIconBuilder] is null, then [BackButtonIcon] + /// fallbacks to the platform's default back button icon. + final WidgetBuilder? backButtonIconBuilder; + + /// Overrides [CloseButtonIcon]'s icon. + /// + /// If [closeButtonIconBuilder] is null, then [CloseButtonIcon] + /// fallbacks to the platform's default close button icon. + final WidgetBuilder? closeButtonIconBuilder; + + /// Overrides [DrawerButtonIcon]'s icon. + /// + /// If [drawerButtonIconBuilder] is null, then [DrawerButtonIcon] + /// fallbacks to the platform's default drawer button icon. + final WidgetBuilder? drawerButtonIconBuilder; + + /// Overrides [EndDrawerButtonIcon]'s icon. + /// + /// If [endDrawerButtonIconBuilder] is null, then [EndDrawerButtonIcon] + /// fallbacks to the platform's default end drawer button icon. + final WidgetBuilder? endDrawerButtonIconBuilder; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + ActionIconThemeData copyWith({ + WidgetBuilder? backButtonIconBuilder, + WidgetBuilder? closeButtonIconBuilder, + WidgetBuilder? drawerButtonIconBuilder, + WidgetBuilder? endDrawerButtonIconBuilder, + }) { + return ActionIconThemeData( + backButtonIconBuilder: backButtonIconBuilder ?? this.backButtonIconBuilder, + closeButtonIconBuilder: closeButtonIconBuilder ?? this.closeButtonIconBuilder, + drawerButtonIconBuilder: drawerButtonIconBuilder ?? this.drawerButtonIconBuilder, + endDrawerButtonIconBuilder: endDrawerButtonIconBuilder ?? this.endDrawerButtonIconBuilder, + ); + } + + /// Linearly interpolate between two action icon themes. + static ActionIconThemeData? lerp(ActionIconThemeData? a, ActionIconThemeData? b, double t) { + if (a == null && b == null) { + return null; + } + return ActionIconThemeData( + backButtonIconBuilder: t < 0.5 ? a?.backButtonIconBuilder : b?.backButtonIconBuilder, + closeButtonIconBuilder: t < 0.5 ? a?.closeButtonIconBuilder : b?.closeButtonIconBuilder, + drawerButtonIconBuilder: t < 0.5 ? a?.drawerButtonIconBuilder : b?.drawerButtonIconBuilder, + endDrawerButtonIconBuilder: t < 0.5 + ? a?.endDrawerButtonIconBuilder + : b?.endDrawerButtonIconBuilder, + ); + } + + @override + int get hashCode { + final values = <Object?>[ + backButtonIconBuilder, + closeButtonIconBuilder, + drawerButtonIconBuilder, + endDrawerButtonIconBuilder, + ]; + return Object.hashAll(values); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ActionIconThemeData && + other.backButtonIconBuilder == backButtonIconBuilder && + other.closeButtonIconBuilder == closeButtonIconBuilder && + other.drawerButtonIconBuilder == drawerButtonIconBuilder && + other.endDrawerButtonIconBuilder == endDrawerButtonIconBuilder; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetBuilder>( + 'backButtonIconBuilder', + backButtonIconBuilder, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetBuilder>( + 'closeButtonIconBuilder', + closeButtonIconBuilder, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetBuilder>( + 'drawerButtonIconBuilder', + drawerButtonIconBuilder, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetBuilder>( + 'endDrawerButtonIconBuilder', + endDrawerButtonIconBuilder, + defaultValue: null, + ), + ); + } +} + +/// An inherited widget that overrides the default icon of [BackButtonIcon], +/// [CloseButtonIcon], [DrawerButtonIcon], and [EndDrawerButtonIcon] in this +/// widget's subtree. +/// +/// {@tool dartpad} +/// This example shows how to define custom builders for drawer and back +/// buttons. +/// +/// ** See code in examples/api/lib/material/action_buttons/action_icon_theme.0.dart ** +/// {@end-tool} +class ActionIconTheme extends InheritedTheme { + /// Creates a theme that overrides the default icon of [BackButtonIcon], + /// [CloseButtonIcon], [DrawerButtonIcon], and [EndDrawerButtonIcon] in this + /// widget's subtree. + const ActionIconTheme({super.key, required this.data, required super.child}); + + /// Specifies the default icon overrides for descendant [BackButtonIcon], + /// [CloseButtonIcon], [DrawerButtonIcon], and [EndDrawerButtonIcon] widgets. + final ActionIconThemeData data; + + /// Retrieves the [ActionIconThemeData] from the closest ancestor [ActionIconTheme] + /// widget. + /// + /// If there is no enclosing [ActionIconTheme] widget, then + /// [ThemeData.actionIconTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ActionIconThemeData? theme = ActionIconTheme.of(context); + /// ``` + static ActionIconThemeData? of(BuildContext context) { + final ActionIconTheme? actionIconTheme = context + .dependOnInheritedWidgetOfExactType<ActionIconTheme>(); + return actionIconTheme?.data ?? Theme.of(context).actionIconTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ActionIconTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ActionIconTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/adaptive_text_selection_toolbar.dart b/packages/material_ui/lib/src/adaptive_text_selection_toolbar.dart new file mode 100644 index 000000000000..2cc4ab8aa0d2 --- /dev/null +++ b/packages/material_ui/lib/src/adaptive_text_selection_toolbar.dart @@ -0,0 +1,336 @@ +// 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. + +/// @docImport 'selectable_text.dart'; +/// @docImport 'selection_area.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/rendering.dart'; + +import 'debug.dart'; +import 'desktop_text_selection_toolbar.dart'; +import 'desktop_text_selection_toolbar_button.dart'; +import 'material_localizations.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_text_button.dart'; +import 'theme.dart'; + +/// The default context menu for text selection for the current platform. +/// +/// {@template flutter.material.AdaptiveTextSelectionToolbar.contextMenuBuilders} +/// Typically, this widget would be passed to `contextMenuBuilder` in a +/// supported parent widget, such as: +/// +/// * [EditableText.contextMenuBuilder] +/// * [TextField.contextMenuBuilder] +/// * [CupertinoTextField.contextMenuBuilder] +/// * [SelectionArea.contextMenuBuilder] +/// * [SelectableText.contextMenuBuilder] +/// {@endtemplate} +/// +/// See also: +/// +/// * [EditableText.getEditableButtonItems], which returns the default +/// [ContextMenuButtonItem]s for [EditableText] on the platform. +/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button +/// Widgets for the current platform given [ContextMenuButtonItem]s. +/// * [CupertinoAdaptiveTextSelectionToolbar], which does the same thing as this +/// widget but only for Cupertino context menus. +/// * [TextSelectionToolbar], the default toolbar for Android. +/// * [DesktopTextSelectionToolbar], the default toolbar for desktop platforms +/// other than MacOS. +/// * [CupertinoTextSelectionToolbar], the default toolbar for iOS. +/// * [CupertinoDesktopTextSelectionToolbar], the default toolbar for MacOS. +class AdaptiveTextSelectionToolbar extends StatelessWidget { + /// Create an instance of [AdaptiveTextSelectionToolbar] with the + /// given [children]. + /// + /// See also: + /// + /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// * [AdaptiveTextSelectionToolbar.buttonItems], which takes a list of + /// [ContextMenuButtonItem]s instead of [children] widgets. + /// {@endtemplate} + /// {@template flutter.material.AdaptiveTextSelectionToolbar.editable} + /// * [AdaptiveTextSelectionToolbar.editable], which builds the default + /// children for an editable field. + /// {@endtemplate} + /// {@template flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// * [AdaptiveTextSelectionToolbar.editableText], which builds the default + /// children for an [EditableText]. + /// {@endtemplate} + /// {@template flutter.material.AdaptiveTextSelectionToolbar.selectable} + /// * [AdaptiveTextSelectionToolbar.selectable], which builds the default + /// children for content that is selectable but not editable. + /// {@endtemplate} + const AdaptiveTextSelectionToolbar({super.key, required this.children, required this.anchors}) + : buttonItems = null; + + /// Create an instance of [AdaptiveTextSelectionToolbar] whose children will + /// be built from the given [buttonItems]. + /// + /// See also: + /// + /// {@template flutter.material.AdaptiveTextSelectionToolbar.new} + /// * [AdaptiveTextSelectionToolbar.new], which takes the children directly as + /// a list of widgets. + /// {@endtemplate} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + const AdaptiveTextSelectionToolbar.buttonItems({ + super.key, + required this.buttonItems, + required this.anchors, + }) : children = null; + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for an editable field. + /// + /// If an on* callback parameter is null, then its corresponding button will + /// not be built. + /// + /// These callbacks are called when their corresponding button is activated + /// and only then. For example, `onPaste` is called when the user taps the + /// "Paste" button in the context menu and not when the user pastes with the + /// keyboard. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + AdaptiveTextSelectionToolbar.editable({ + super.key, + required ClipboardStatus clipboardStatus, + required VoidCallback? onCopy, + required VoidCallback? onCut, + required VoidCallback? onPaste, + required VoidCallback? onSelectAll, + required VoidCallback? onLookUp, + required VoidCallback? onSearchWeb, + required VoidCallback? onShare, + required VoidCallback? onLiveTextInput, + required this.anchors, + }) : children = null, + buttonItems = EditableText.getEditableButtonItems( + clipboardStatus: clipboardStatus, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onSelectAll: onSelectAll, + onLookUp: onLookUp, + onSearchWeb: onSearchWeb, + onShare: onShare, + onLiveTextInput: onLiveTextInput, + ); + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for an [EditableText]. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + AdaptiveTextSelectionToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : children = null, + buttonItems = editableTextState.contextMenuButtonItems, + anchors = editableTextState.contextMenuAnchors; + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for selectable, but not editable, content. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + AdaptiveTextSelectionToolbar.selectable({ + super.key, + required VoidCallback onCopy, + required VoidCallback onSelectAll, + required VoidCallback? onShare, + required SelectionGeometry selectionGeometry, + required this.anchors, + }) : children = null, + buttonItems = SelectableRegion.getSelectableButtonItems( + selectionGeometry: selectionGeometry, + onCopy: onCopy, + onSelectAll: onSelectAll, + onShare: onShare, + ); + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for a [SelectableRegion]. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + AdaptiveTextSelectionToolbar.selectableRegion({ + super.key, + required SelectableRegionState selectableRegionState, + }) : children = null, + buttonItems = selectableRegionState.contextMenuButtonItems, + anchors = selectableRegionState.contextMenuAnchors; + + /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets for the current platform. + /// {@endtemplate} + final List<ContextMenuButtonItem>? buttonItems; + + /// The children of the toolbar, typically buttons. + final List<Widget>? children; + + /// {@template flutter.material.AdaptiveTextSelectionToolbar.anchors} + /// The location on which to anchor the menu. + /// {@endtemplate} + final TextSelectionToolbarAnchors anchors; + + /// Returns the default button label String for the button of the given + /// [ContextMenuButtonType] on any platform. + static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) { + if (buttonItem.label != null) { + return buttonItem.label!; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoTextSelectionToolbarButton.getButtonLabel(context, buttonItem); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return switch (buttonItem.type) { + ContextMenuButtonType.cut => localizations.cutButtonLabel, + ContextMenuButtonType.copy => localizations.copyButtonLabel, + ContextMenuButtonType.paste => localizations.pasteButtonLabel, + ContextMenuButtonType.selectAll => localizations.selectAllButtonLabel, + ContextMenuButtonType.delete => localizations.deleteButtonTooltip.toUpperCase(), + ContextMenuButtonType.lookUp => localizations.lookUpButtonLabel, + ContextMenuButtonType.searchWeb => localizations.searchWebButtonLabel, + ContextMenuButtonType.share => localizations.shareButtonLabel, + ContextMenuButtonType.liveTextInput => localizations.scanTextButtonLabel, + ContextMenuButtonType.custom => '', + }; + } + } + + /// Returns a List of Widgets generated by turning [buttonItems] into the + /// default context menu buttons for the current platform. + /// + /// This is useful when building a text selection toolbar with the default + /// button appearance for the given platform, but where the toolbar and/or the + /// button actions and labels may be custom. + /// + /// {@tool dartpad} + /// This sample demonstrates how to use `getAdaptiveButtons` to generate + /// default button widgets in a custom toolbar. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the + /// Cupertino equivalent of this class and builds only the Cupertino + /// buttons. + static Iterable<Widget> getAdaptiveButtons( + BuildContext context, + List<ContextMenuButtonItem> buttonItems, + ) { + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem(buttonItem: buttonItem); + }); + case TargetPlatform.fuchsia: + case TargetPlatform.android: + final buttons = <Widget>[]; + for (var i = 0; i < buttonItems.length; i++) { + final ContextMenuButtonItem buttonItem = buttonItems[i]; + buttons.add( + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(i, buttonItems.length), + onPressed: buttonItem.onPressed, + alignment: AlignmentDirectional.centerStart, + child: Text(getButtonLabel(context, buttonItem)), + ), + ); + } + return buttons; + case TargetPlatform.linux: + case TargetPlatform.windows: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return DesktopTextSelectionToolbarButton.text( + context: context, + onPressed: buttonItem.onPressed, + text: getButtonLabel(context, buttonItem), + ); + }); + case TargetPlatform.macOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoDesktopTextSelectionToolbarButton.text( + onPressed: buttonItem.onPressed, + text: getButtonLabel(context, buttonItem), + ); + }); + } + } + + @override + Widget build(BuildContext context) { + // If there aren't any buttons to build, build an empty toolbar. + if ((children ?? buttonItems)?.isEmpty ?? true) { + return const SizedBox.shrink(); + } + + final List<Widget> resultChildren = children != null + ? children! + : getAdaptiveButtons(context, buttonItems!).toList(); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: resultChildren, + ); + case TargetPlatform.android: + return TextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: resultChildren, + ); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return DesktopTextSelectionToolbar(anchor: anchors.primaryAnchor, children: resultChildren); + case TargetPlatform.macOS: + return CupertinoDesktopTextSelectionToolbar( + anchor: anchors.primaryAnchor, + children: resultChildren, + ); + } + } +} diff --git a/packages/material_ui/lib/src/animated_icons.dart b/packages/material_ui/lib/src/animated_icons.dart new file mode 100644 index 000000000000..5b73de70c057 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons.dart @@ -0,0 +1,42 @@ +// 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 widgets implementing Material Design animated icons. +/// @docImport 'package:flutter/semantics.dart'; +/// +/// @docImport 'icons.dart'; +/// @docImport 'theme.dart'; +library material_animated_icons; + +import 'dart:math' as math show pi; +import 'dart:ui' as ui show Canvas, Paint, Path, lerpDouble; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/widgets.dart'; + +// This package is split into multiple parts to enable a private API that is +// testable. + +// Public API. +part 'animated_icons/animated_icons.dart'; + +// Provides a public interface for referring to the private icon +// implementations. +part 'animated_icons/animated_icons_data.dart'; + +// Generated animated icon data files. +part 'animated_icons/data/add_event.g.dart'; +part 'animated_icons/data/arrow_menu.g.dart'; +part 'animated_icons/data/close_menu.g.dart'; +part 'animated_icons/data/ellipsis_search.g.dart'; +part 'animated_icons/data/event_add.g.dart'; +part 'animated_icons/data/home_menu.g.dart'; +part 'animated_icons/data/list_view.g.dart'; +part 'animated_icons/data/menu_arrow.g.dart'; +part 'animated_icons/data/menu_close.g.dart'; +part 'animated_icons/data/menu_home.g.dart'; +part 'animated_icons/data/pause_play.g.dart'; +part 'animated_icons/data/play_pause.g.dart'; +part 'animated_icons/data/search_ellipsis.g.dart'; +part 'animated_icons/data/view_list.g.dart'; diff --git a/packages/material_ui/lib/src/animated_icons/animated_icons.dart b/packages/material_ui/lib/src/animated_icons/animated_icons.dart new file mode 100644 index 000000000000..5ebfe61ea1c1 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/animated_icons.dart @@ -0,0 +1,304 @@ +// 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. + +// TODO(goderbauer): Clean up the part-of hack currently used for testing the private implementation. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +// The code for drawing animated icons is kept in a private API, as we are not +// yet ready for exposing a public API for (partial) vector graphics support. +// See: https://github.com/flutter/flutter/issues/1831 for details regarding +// generic vector graphics support in Flutter. + +/// Shows an animated icon at a given animation [progress]. +/// +/// The available icons are specified in [AnimatedIcons]. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=pJcbh8pbvJs} +/// +/// {@tool dartpad} +/// This example shows how to create an animated icon. The icon is animated +/// forward and reverse in a loop. +/// +/// ** See code in examples/api/lib/material/animated_icon/animated_icon.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Icons], for the list of available static Material Icons. +class AnimatedIcon extends StatelessWidget { + /// Creates an AnimatedIcon. + /// + /// The [size] and [color] default to the value given by the current + /// [IconTheme]. + const AnimatedIcon({ + super.key, + required this.icon, + required this.progress, + this.color, + this.size, + this.semanticLabel, + this.textDirection, + }); + + /// The animation progress for the animated icon. + /// + /// The value is clamped to be between 0 and 1. + /// + /// This determines the actual frame that is displayed. + final Animation<double> progress; + + /// The color to use when drawing the icon. + /// + /// Defaults to the current [IconTheme] color, if any. + /// + /// The given color will be adjusted by the opacity of the current + /// [IconTheme], if any. + /// + /// In material apps, if there is a [Theme] without any [IconTheme]s + /// specified, icon colors default to white if the theme is dark + /// and black if the theme is light. + /// + /// If no [IconTheme] and no [Theme] is specified, icons will default to black. + /// + /// See [Theme] to set the current theme and [ThemeData.brightness] + /// for setting the current theme's brightness. + final Color? color; + + /// The size of the icon in logical pixels. + /// + /// Icons occupy a square with width and height equal to size. + /// + /// Defaults to the current [IconTheme] size. + final double? size; + + /// The icon to display. Available icons are listed in [AnimatedIcons]. + final AnimatedIconData icon; + + /// Semantic label for the icon. + /// + /// Announced by assistive technologies (e.g TalkBack/VoiceOver). + /// This label does not show in the UI. + /// + /// See also: + /// + /// * [SemanticsProperties.label], which is set to [semanticLabel] in the + /// underlying [Semantics] widget. + final String? semanticLabel; + + /// The text direction to use for rendering the icon. + /// + /// If this is null, the ambient [Directionality] is used instead. + /// + /// If the text direction is [TextDirection.rtl], the icon will be mirrored + /// horizontally (e.g back arrow will point right). + final TextDirection? textDirection; + + static ui.Path _pathFactory() => ui.Path(); + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + final iconData = icon as _AnimatedIconData; + final IconThemeData iconTheme = IconTheme.of(context); + assert(iconTheme.isConcrete); + final double iconSize = size ?? iconTheme.size!; + final TextDirection textDirection = this.textDirection ?? Directionality.of(context); + final double iconOpacity = iconTheme.opacity!; + Color iconColor = color ?? iconTheme.color!; + if (iconOpacity != 1.0) { + iconColor = iconColor.withOpacity(iconColor.opacity * iconOpacity); + } + return Semantics( + label: semanticLabel, + child: CustomPaint( + size: Size(iconSize, iconSize), + painter: _AnimatedIconPainter( + paths: iconData.paths, + progress: progress, + color: iconColor, + scale: iconSize / iconData.size.width, + shouldMirror: textDirection == TextDirection.rtl && iconData.matchTextDirection, + uiPathFactory: _pathFactory, + ), + ), + ); + } +} + +typedef _UiPathFactory = ui.Path Function(); + +class _AnimatedIconPainter extends CustomPainter { + _AnimatedIconPainter({ + required this.paths, + required this.progress, + required this.color, + required this.scale, + required this.shouldMirror, + required this.uiPathFactory, + }) : super(repaint: progress); + + // This list is assumed to be immutable, changes to the contents of the list + // will not trigger a redraw as shouldRepaint will keep returning false. + final List<_PathFrames> paths; + final Animation<double> progress; + final Color color; + final double scale; + + /// If this is true the image will be mirrored horizontally. + final bool shouldMirror; + final _UiPathFactory uiPathFactory; + + @override + void paint(ui.Canvas canvas, Size size) { + // The RenderCustomPaint render object performs canvas.save before invoking + // this and canvas.restore after, so we don't need to do it here. + if (shouldMirror) { + canvas.rotate(math.pi); + canvas.translate(-size.width, -size.height); + } + canvas.scale(scale, scale); + + final double clampedProgress = clampDouble(progress.value, 0.0, 1.0); + for (final _PathFrames path in paths) { + path.paint(canvas, color, uiPathFactory, clampedProgress); + } + } + + @override + bool shouldRepaint(_AnimatedIconPainter oldDelegate) { + return oldDelegate.progress.value != progress.value || + oldDelegate.color != color + // We are comparing the paths list by reference, assuming the list is + // treated as immutable to be more efficient. + || + oldDelegate.paths != paths || + oldDelegate.scale != scale || + oldDelegate.uiPathFactory != uiPathFactory; + } + + @override + bool? hitTest(Offset position) => null; + + @override + bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; +} + +class _PathFrames { + const _PathFrames({required this.commands, required this.opacities}); + + final List<_PathCommand> commands; + final List<double> opacities; + + void paint(ui.Canvas canvas, Color color, _UiPathFactory uiPathFactory, double progress) { + final double opacity = _interpolate<double?>(opacities, progress, ui.lerpDouble)!; + final paint = ui.Paint() + ..style = PaintingStyle.fill + ..color = color.withOpacity(color.opacity * opacity); + final ui.Path path = uiPathFactory(); + for (final _PathCommand command in commands) { + command.apply(path, progress); + } + canvas.drawPath(path, paint); + } +} + +/// Paths are being built by a set of commands e.g moveTo, lineTo, etc... +/// +/// _PathCommand instances represents such a command, and can apply it to +/// a given Path. +abstract class _PathCommand { + const _PathCommand(); + + /// Applies the path command to [path]. + /// + /// For example if the object is a [_PathMoveTo] command it will invoke + /// [Path.moveTo] on [path]. + void apply(ui.Path path, double progress); +} + +class _PathMoveTo extends _PathCommand { + const _PathMoveTo(this.points); + + final List<Offset> points; + + @override + void apply(Path path, double progress) { + final Offset offset = _interpolate<Offset?>(points, progress, Offset.lerp)!; + path.moveTo(offset.dx, offset.dy); + } +} + +class _PathCubicTo extends _PathCommand { + const _PathCubicTo(this.controlPoints1, this.controlPoints2, this.targetPoints); + + final List<Offset> controlPoints2; + final List<Offset> controlPoints1; + final List<Offset> targetPoints; + + @override + void apply(Path path, double progress) { + final Offset controlPoint1 = _interpolate<Offset?>(controlPoints1, progress, Offset.lerp)!; + final Offset controlPoint2 = _interpolate<Offset?>(controlPoints2, progress, Offset.lerp)!; + final Offset targetPoint = _interpolate<Offset?>(targetPoints, progress, Offset.lerp)!; + path.cubicTo( + controlPoint1.dx, + controlPoint1.dy, + controlPoint2.dx, + controlPoint2.dy, + targetPoint.dx, + targetPoint.dy, + ); + } +} + +// ignore: unused_element +class _PathLineTo extends _PathCommand { + const _PathLineTo(this.points); + + final List<Offset> points; + + @override + void apply(Path path, double progress) { + final Offset point = _interpolate<Offset?>(points, progress, Offset.lerp)!; + path.lineTo(point.dx, point.dy); + } +} + +class _PathClose extends _PathCommand { + const _PathClose(); + + @override + void apply(Path path, double progress) { + path.close(); + } +} + +/// Interpolates a value given a set of values equally spaced in time. +/// +/// [interpolator] is the interpolation function used to interpolate between 2 +/// points of type T. +/// +/// This is currently done with linear interpolation between every 2 consecutive +/// points. Linear interpolation was smooth enough with the limited set of +/// animations we have tested, so we use it for simplicity. If we find this to +/// not be smooth enough we can try applying spline instead. +/// +/// [progress] is expected to be between 0.0 and 1.0. +T _interpolate<T>(List<T> values, double progress, _Interpolator<T> interpolator) { + assert(progress <= 1.0); + assert(progress >= 0.0); + if (values.length == 1) { + return values[0]; + } + final double targetIdx = ui.lerpDouble(0, values.length - 1, progress)!; + final int lowIdx = targetIdx.floor(); + final int highIdx = targetIdx.ceil(); + final double t = targetIdx - lowIdx; + return interpolator(values[lowIdx], values[highIdx], t); +} + +typedef _Interpolator<T> = T Function(T a, T b, double progress); diff --git a/packages/material_ui/lib/src/animated_icons/animated_icons_data.dart b/packages/material_ui/lib/src/animated_icons/animated_icons_data.dart new file mode 100644 index 000000000000..538e9f8157f2 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/animated_icons_data.dart @@ -0,0 +1,131 @@ +// 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. + +// This file serves as the interface between the public and private APIs for +// animated icons. +// The AnimatedIcons class is public and is used to specify available icons, +// while the _AnimatedIconData interface which used to deliver the icon data is +// kept private. + +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +/// Identifier for the supported Material Design animated icons. +/// +/// Use with [AnimatedIcon] class to show specific animated icons. +/// +/// {@tool dartpad} +/// This example shows how to create an animated icon. The icon is animated +/// forward and reverse in a loop. +/// +/// ** See code in examples/api/lib/material/animated_icon/animated_icons_data.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Icons], for the list of available static Material Icons. +abstract final class AnimatedIcons { + /// The Material Design add to event icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/add_event.mp4} + static const AnimatedIconData add_event = _$add_event; + + /// The Material Design arrow to menu icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/arrow_menu.mp4} + static const AnimatedIconData arrow_menu = _$arrow_menu; + + /// The Material Design close to menu icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/close_menu.mp4} + static const AnimatedIconData close_menu = _$close_menu; + + /// The Material Design ellipsis to search icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/ellipsis_search.mp4} + static const AnimatedIconData ellipsis_search = _$ellipsis_search; + + /// The Material Design event to add icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/event_add.mp4} + static const AnimatedIconData event_add = _$event_add; + + /// The Material Design home to menu icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/home_menu.mp4} + static const AnimatedIconData home_menu = _$home_menu; + + /// The Material Design list to view icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/list_view.mp4} + static const AnimatedIconData list_view = _$list_view; + + /// The Material Design menu to arrow icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/menu_arrow.mp4} + static const AnimatedIconData menu_arrow = _$menu_arrow; + + /// The Material Design menu to close icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/menu_close.mp4} + static const AnimatedIconData menu_close = _$menu_close; + + /// The Material Design menu to home icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/menu_home.mp4} + static const AnimatedIconData menu_home = _$menu_home; + + /// The Material Design pause to play icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/pause_play.mp4} + static const AnimatedIconData pause_play = _$pause_play; + + /// The Material Design play to pause icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/play_pause.mp4} + static const AnimatedIconData play_pause = _$play_pause; + + /// The Material Design search to ellipsis icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/search_ellipsis.mp4} + static const AnimatedIconData search_ellipsis = _$search_ellipsis; + + /// The Material Design view to list icon animation. + /// + /// {@animation 72 72 https://flutter.github.io/assets-for-api-docs/assets/widgets/view_list.mp4} + static const AnimatedIconData view_list = _$view_list; +} + +/// Vector graphics data for icons used by [AnimatedIcon]. +/// +/// Instances of this class are currently opaque because we have not committed to a specific +/// animated vector graphics format. +/// +/// See also: +/// +/// * [AnimatedIcons], a class that contains constants that implement this interface. +abstract class AnimatedIconData { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const AnimatedIconData(); + + /// Whether this icon should be mirrored horizontally when text direction is + /// right-to-left. + /// + /// See also: + /// + /// * [TextDirection], which discusses concerns regarding reading direction + /// in Flutter. + /// * [Directionality], a widget which determines the ambient directionality. + bool get matchTextDirection; +} + +class _AnimatedIconData extends AnimatedIconData { + const _AnimatedIconData(this.size, this.paths, {this.matchTextDirection = false}); + + final Size size; + final List<_PathFrames> paths; + + @override + final bool matchTextDirection; +} diff --git a/packages/material_ui/lib/src/animated_icons/data/add_event.g.dart b/packages/material_ui/lib/src/animated_icons/data/add_event.g.dart new file mode 100644 index 000000000000..1e9de6cf76e2 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/add_event.g.dart @@ -0,0 +1,3285 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$add_event = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.190476190476, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(18.192636718749995, 24.891542968750002), + Offset(18.175135177837678, 24.768938846121223), + Offset(18.131762833609578, 24.290016731469834), + Offset(18.189398131863207, 23.129814344209493), + Offset(19.14691116775322, 20.688226165351878), + Offset(22.277081692306755, 18.381965826572245), + Offset(25.153850173498547, 18.208885778660576), + Offset(26.987657743646544, 18.810016196193768), + Offset(28.229748817157525, 19.528141401396265), + Offset(29.223774647225785, 20.19509758103034), + Offset(30.007739613111152, 20.830494962200966), + Offset(30.626964119061235, 21.41670003990219), + Offset(31.072971936868854, 21.941254300113602), + Offset(31.381936148538408, 22.388826086603757), + Offset(31.595631995844528, 22.75496752966676), + Offset(31.74362009537025, 23.04237991891895), + Offset(31.845995957897536, 23.25665695166987), + Offset(31.916063614634442, 23.404284579714346), + Offset(31.962512649081546, 23.491750839098724), + Offset(31.99086595053382, 23.525019467195833), + Offset(32.00558109744533, 23.528032986218715), + Offset(32.01015625, 23.52890625), + ]), + _PathCubicTo( + <Offset>[ + Offset(18.192636718749995, 24.891542968750002), + Offset(18.175135177837678, 24.768938846121223), + Offset(18.131762833609578, 24.290016731469834), + Offset(18.189398131863207, 23.129814344209493), + Offset(19.14691116775322, 20.688226165351878), + Offset(22.277081692306755, 18.381965826572245), + Offset(25.153850173498547, 18.208885778660576), + Offset(26.987657743646544, 18.810016196193768), + Offset(28.229748817157525, 19.528141401396265), + Offset(29.223774647225785, 20.19509758103034), + Offset(30.007739613111152, 20.830494962200966), + Offset(30.626964119061235, 21.41670003990219), + Offset(31.072971936868854, 21.941254300113602), + Offset(31.381936148538408, 22.388826086603757), + Offset(31.595631995844528, 22.75496752966676), + Offset(31.74362009537025, 23.04237991891895), + Offset(31.845995957897536, 23.25665695166987), + Offset(31.916063614634442, 23.404284579714346), + Offset(31.962512649081546, 23.491750839098724), + Offset(31.99086595053382, 23.525019467195833), + Offset(32.00558109744533, 23.528032986218715), + Offset(32.01015625, 23.52890625), + ], + <Offset>[ + Offset(23.992636718749996, 24.891542968750002), + Offset(23.97384664920713, 24.89118970218503), + Offset(23.901054413892187, 24.886065942257186), + Offset(23.728661104347875, 24.849281041083934), + Offset(23.386158286870817, 24.64660763547994), + Offset(23.116959480006564, 24.121768211366486), + Offset(23.169707830342585, 23.691656086157646), + Offset(23.322360298939, 23.45580690452118), + Offset(23.48231873652916, 23.34351072659664), + Offset(23.629082531765302, 23.319033616339574), + Offset(23.74394094991015, 23.337067196646014), + Offset(23.829253355825543, 23.374789989582887), + Offset(23.889084353557546, 23.411693680396755), + Offset(23.930803165274646, 23.442370323371723), + Offset(23.960074268764437, 23.466992025443258), + Offset(23.980512394929132, 23.486331772113996), + Offset(23.994442088149636, 23.50119509189468), + Offset(24.003391328945682, 23.512265882811025), + Offset(24.008386840165617, 23.520115789343254), + Offset(24.010119497846063, 23.52522637938814), + Offset(24.010150449065325, 23.528032986218715), + Offset(24.01015625, 23.52890625), + ], + <Offset>[ + Offset(23.992636718749996, 24.891542968750002), + Offset(23.97384664920713, 24.89118970218503), + Offset(23.901054413892187, 24.886065942257186), + Offset(23.728661104347875, 24.849281041083934), + Offset(23.386158286870817, 24.64660763547994), + Offset(23.116959480006564, 24.121768211366486), + Offset(23.169707830342585, 23.691656086157646), + Offset(23.322360298939, 23.45580690452118), + Offset(23.48231873652916, 23.34351072659664), + Offset(23.629082531765302, 23.319033616339574), + Offset(23.74394094991015, 23.337067196646014), + Offset(23.829253355825543, 23.374789989582887), + Offset(23.889084353557546, 23.411693680396755), + Offset(23.930803165274646, 23.442370323371723), + Offset(23.960074268764437, 23.466992025443258), + Offset(23.980512394929132, 23.486331772113996), + Offset(23.994442088149636, 23.50119509189468), + Offset(24.003391328945682, 23.512265882811025), + Offset(24.008386840165617, 23.520115789343254), + Offset(24.010119497846063, 23.52522637938814), + Offset(24.010150449065325, 23.528032986218715), + Offset(24.01015625, 23.52890625), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(23.992636718749996, 24.891542968750002), + Offset(23.97384664920713, 24.89118970218503), + Offset(23.901054413892187, 24.886065942257186), + Offset(23.728661104347875, 24.849281041083934), + Offset(23.386158286870817, 24.64660763547994), + Offset(23.116959480006564, 24.121768211366486), + Offset(23.169707830342585, 23.691656086157646), + Offset(23.322360298939, 23.45580690452118), + Offset(23.48231873652916, 23.34351072659664), + Offset(23.629082531765302, 23.319033616339574), + Offset(23.74394094991015, 23.337067196646014), + Offset(23.829253355825543, 23.374789989582887), + Offset(23.889084353557546, 23.411693680396755), + Offset(23.930803165274646, 23.442370323371723), + Offset(23.960074268764437, 23.466992025443258), + Offset(23.980512394929132, 23.486331772113996), + Offset(23.994442088149636, 23.50119509189468), + Offset(24.003391328945682, 23.512265882811025), + Offset(24.008386840165617, 23.520115789343254), + Offset(24.010119497846063, 23.52522637938814), + Offset(24.010150449065325, 23.528032986218715), + Offset(24.01015625, 23.52890625), + ], + <Offset>[ + Offset(23.992636718749996, 19.091542968750005), + Offset(24.096097505270937, 19.09247823081558), + Offset(24.497103624679536, 19.11677436197458), + Offset(25.448127801222316, 19.310018068599263), + Offset(27.344539756998877, 20.407360516362342), + Offset(28.85676186480081, 23.281890423666677), + Offset(28.652478137839655, 25.675798429313613), + Offset(27.968151007266407, 27.121104349228723), + Offset(27.29768806172953, 28.090940807225007), + Offset(26.753018567074538, 28.913725731800056), + Offset(26.250513184355196, 29.600865859847012), + Offset(25.78734330550624, 30.17250075281858), + Offset(25.359523733840703, 30.595581263708063), + Offset(24.984347402042612, 30.89350330663548), + Offset(24.672098764540934, 31.102549752523352), + Offset(24.424464248124174, 31.249439472555114), + Offset(24.238980228374444, 31.352748961642575), + Offset(24.11137263204236, 31.424938168499786), + Offset(24.036751790410147, 31.47424159825918), + Offset(24.010326410038374, 31.505972832075898), + Offset(24.010150449065325, 31.523463634598716), + Offset(24.01015625, 31.52890625), + ], + <Offset>[ + Offset(23.992636718749996, 19.091542968750005), + Offset(24.096097505270937, 19.09247823081558), + Offset(24.497103624679536, 19.11677436197458), + Offset(25.448127801222316, 19.310018068599263), + Offset(27.344539756998877, 20.407360516362342), + Offset(28.85676186480081, 23.281890423666677), + Offset(28.652478137839655, 25.675798429313613), + Offset(27.968151007266407, 27.121104349228723), + Offset(27.29768806172953, 28.090940807225007), + Offset(26.753018567074538, 28.913725731800056), + Offset(26.250513184355196, 29.600865859847012), + Offset(25.78734330550624, 30.17250075281858), + Offset(25.359523733840703, 30.595581263708063), + Offset(24.984347402042612, 30.89350330663548), + Offset(24.672098764540934, 31.102549752523352), + Offset(24.424464248124174, 31.249439472555114), + Offset(24.238980228374444, 31.352748961642575), + Offset(24.11137263204236, 31.424938168499786), + Offset(24.036751790410147, 31.47424159825918), + Offset(24.010326410038374, 31.505972832075898), + Offset(24.010150449065325, 31.523463634598716), + Offset(24.01015625, 31.52890625), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(23.992636718749996, 19.091542968750005), + Offset(24.096097505270937, 19.09247823081558), + Offset(24.497103624679536, 19.11677436197458), + Offset(25.448127801222316, 19.310018068599263), + Offset(27.344539756998877, 20.407360516362342), + Offset(28.85676186480081, 23.281890423666677), + Offset(28.652478137839655, 25.675798429313613), + Offset(27.968151007266407, 27.121104349228723), + Offset(27.29768806172953, 28.090940807225007), + Offset(26.753018567074538, 28.913725731800056), + Offset(26.250513184355196, 29.600865859847012), + Offset(25.78734330550624, 30.17250075281858), + Offset(25.359523733840703, 30.595581263708063), + Offset(24.984347402042612, 30.89350330663548), + Offset(24.672098764540934, 31.102549752523352), + Offset(24.424464248124174, 31.249439472555114), + Offset(24.238980228374444, 31.352748961642575), + Offset(24.11137263204236, 31.424938168499786), + Offset(24.036751790410147, 31.47424159825918), + Offset(24.010326410038374, 31.505972832075898), + Offset(24.010150449065325, 31.523463634598716), + Offset(24.01015625, 31.52890625), + ], + <Offset>[ + Offset(18.19263671875, 19.091542968750005), + Offset(18.297386033901486, 18.970227374751772), + Offset(18.72781204439693, 18.52072515118723), + Offset(19.90886482873765, 17.59055137172482), + Offset(23.10529263788128, 16.448979046234285), + Offset(28.016884077101, 17.542088038872436), + Offset(30.636620480995617, 20.19302812181654), + Offset(31.63344845197395, 22.475313640901312), + Offset(32.0451181423579, 24.275571482024635), + Offset(32.34771068253502, 25.78978969649082), + Offset(32.5143118475562, 27.094293625401967), + Offset(32.58505406874193, 28.21441080313788), + Offset(32.54341131715201, 29.12514188342491), + Offset(32.43548038530638, 29.83995906986752), + Offset(32.307656491621024, 30.39052525674685), + Offset(32.18757194856529, 30.80548761936007), + Offset(32.09053409812234, 31.10821082141777), + Offset(32.024044917731125, 31.316956865403107), + Offset(31.990877599326076, 31.44587664801465), + Offset(31.991072862726128, 31.505765919883586), + Offset(32.00558109744533, 31.523463634598716), + Offset(32.01015625, 31.52890625), + ], + <Offset>[ + Offset(18.19263671875, 19.091542968750005), + Offset(18.297386033901486, 18.970227374751772), + Offset(18.72781204439693, 18.52072515118723), + Offset(19.90886482873765, 17.59055137172482), + Offset(23.10529263788128, 16.448979046234285), + Offset(28.016884077101, 17.542088038872436), + Offset(30.636620480995617, 20.19302812181654), + Offset(31.63344845197395, 22.475313640901312), + Offset(32.0451181423579, 24.275571482024635), + Offset(32.34771068253502, 25.78978969649082), + Offset(32.5143118475562, 27.094293625401967), + Offset(32.58505406874193, 28.21441080313788), + Offset(32.54341131715201, 29.12514188342491), + Offset(32.43548038530638, 29.83995906986752), + Offset(32.307656491621024, 30.39052525674685), + Offset(32.18757194856529, 30.80548761936007), + Offset(32.09053409812234, 31.10821082141777), + Offset(32.024044917731125, 31.316956865403107), + Offset(31.990877599326076, 31.44587664801465), + Offset(31.991072862726128, 31.505765919883586), + Offset(32.00558109744533, 31.523463634598716), + Offset(32.01015625, 31.52890625), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(18.19263671875, 19.091542968750005), + Offset(18.297386033901486, 18.970227374751772), + Offset(18.72781204439693, 18.52072515118723), + Offset(19.90886482873765, 17.59055137172482), + Offset(23.10529263788128, 16.448979046234285), + Offset(28.016884077101, 17.542088038872436), + Offset(30.636620480995617, 20.19302812181654), + Offset(31.63344845197395, 22.475313640901312), + Offset(32.0451181423579, 24.275571482024635), + Offset(32.34771068253502, 25.78978969649082), + Offset(32.5143118475562, 27.094293625401967), + Offset(32.58505406874193, 28.21441080313788), + Offset(32.54341131715201, 29.12514188342491), + Offset(32.43548038530638, 29.83995906986752), + Offset(32.307656491621024, 30.39052525674685), + Offset(32.18757194856529, 30.80548761936007), + Offset(32.09053409812234, 31.10821082141777), + Offset(32.024044917731125, 31.316956865403107), + Offset(31.990877599326076, 31.44587664801465), + Offset(31.991072862726128, 31.505765919883586), + Offset(32.00558109744533, 31.523463634598716), + Offset(32.01015625, 31.52890625), + ], + <Offset>[ + Offset(18.192636718749995, 24.891542968750002), + Offset(18.175135177837678, 24.768938846121223), + Offset(18.131762833609578, 24.290016731469834), + Offset(18.189398131863207, 23.129814344209493), + Offset(19.14691116775322, 20.688226165351878), + Offset(22.277081692306755, 18.381965826572245), + Offset(25.153850173498547, 18.208885778660576), + Offset(26.987657743646544, 18.810016196193768), + Offset(28.229748817157525, 19.528141401396265), + Offset(29.223774647225785, 20.19509758103034), + Offset(30.007739613111152, 20.830494962200966), + Offset(30.626964119061235, 21.41670003990219), + Offset(31.072971936868854, 21.941254300113602), + Offset(31.381936148538408, 22.388826086603757), + Offset(31.595631995844528, 22.75496752966676), + Offset(31.74362009537025, 23.04237991891895), + Offset(31.845995957897536, 23.25665695166987), + Offset(31.916063614634442, 23.404284579714346), + Offset(31.962512649081546, 23.491750839098724), + Offset(31.99086595053382, 23.525019467195833), + Offset(32.00558109744533, 23.528032986218715), + Offset(32.01015625, 23.52890625), + ], + <Offset>[ + Offset(18.192636718749995, 24.891542968750002), + Offset(18.175135177837678, 24.768938846121223), + Offset(18.131762833609578, 24.290016731469834), + Offset(18.189398131863207, 23.129814344209493), + Offset(19.14691116775322, 20.688226165351878), + Offset(22.277081692306755, 18.381965826572245), + Offset(25.153850173498547, 18.208885778660576), + Offset(26.987657743646544, 18.810016196193768), + Offset(28.229748817157525, 19.528141401396265), + Offset(29.223774647225785, 20.19509758103034), + Offset(30.007739613111152, 20.830494962200966), + Offset(30.626964119061235, 21.41670003990219), + Offset(31.072971936868854, 21.941254300113602), + Offset(31.381936148538408, 22.388826086603757), + Offset(31.595631995844528, 22.75496752966676), + Offset(31.74362009537025, 23.04237991891895), + Offset(31.845995957897536, 23.25665695166987), + Offset(31.916063614634442, 23.404284579714346), + Offset(31.962512649081546, 23.491750839098724), + Offset(31.99086595053382, 23.525019467195833), + Offset(32.00558109744533, 23.528032986218715), + Offset(32.01015625, 23.52890625), + ], + ), + _PathClose(), + _PathMoveTo(<Offset>[ + Offset(19.359999999999996, 37.6), + Offset(19.07437364316861, 37.499177937670424), + Offset(17.986934103582882, 37.051154681687954), + Offset(15.536736677617021, 35.61304326763691), + Offset(11.326880236819443, 30.7735984135526), + Offset(9.869566263871294, 21.377480537283333), + Offset(12.74113193535829, 14.964919748422403), + Offset(16.0704913221344, 11.713990403514046), + Offset(18.914329388866914, 9.893901895554396), + Offset(21.25283585091745, 8.565246523821894), + Offset(23.25484407901928, 7.610299138856277), + Offset(24.968395165544187, 6.916249550797959), + Offset(26.405174995011652, 6.49649745011436), + Offset(27.573815336148815, 6.274596001300022), + Offset(28.49870054201158, 6.167904420613963), + Offset(29.208394245687987, 6.121885514098405), + Offset(29.729906624130507, 6.1022310660712105), + Offset(30.08688450541718, 6.08845659062094), + Offset(30.299438656814726, 6.069069138689281), + Offset(30.384131484923635, 6.038341565542824), + Offset(30.396344518703998, 6.009138703240001), + Offset(30.4, 6.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(19.359999999999996, 37.6), + Offset(19.07437364316861, 37.499177937670424), + Offset(17.986934103582882, 37.051154681687954), + Offset(15.536736677617021, 35.61304326763691), + Offset(11.326880236819443, 30.7735984135526), + Offset(9.869566263871294, 21.377480537283333), + Offset(12.74113193535829, 14.964919748422403), + Offset(16.0704913221344, 11.713990403514046), + Offset(18.914329388866914, 9.893901895554396), + Offset(21.25283585091745, 8.565246523821894), + Offset(23.25484407901928, 7.610299138856277), + Offset(24.968395165544187, 6.916249550797959), + Offset(26.405174995011652, 6.49649745011436), + Offset(27.573815336148815, 6.274596001300022), + Offset(28.49870054201158, 6.167904420613963), + Offset(29.208394245687987, 6.121885514098405), + Offset(29.729906624130507, 6.1022310660712105), + Offset(30.08688450541718, 6.08845659062094), + Offset(30.299438656814726, 6.069069138689281), + Offset(30.384131484923635, 6.038341565542824), + Offset(30.396344518703998, 6.009138703240001), + Offset(30.4, 6.0), + ], + <Offset>[ + Offset(19.359999999999996, 35.28), + Offset(19.123273985594132, 35.179693349122644), + Offset(18.225353787897824, 34.74343804957491), + Offset(16.224523356366795, 33.397338078643045), + Offset(12.910232824870667, 29.077899565905565), + Offset(12.165487217788991, 21.04152942220341), + Offset(14.934240058357119, 15.758576685684787), + Offset(17.928807605465366, 13.180109381397063), + Offset(20.440477118947065, 11.792873927805745), + Offset(22.502410265041142, 10.803123370006087), + Offset(24.2574729727973, 10.115818604136678), + Offset(25.751631145416468, 9.635333856092236), + Offset(26.993350747124914, 9.370052483438883), + Offset(27.995233030856, 9.255049194605526), + Offset(28.78351034032218, 9.222127511445999), + Offset(29.385974986966005, 9.227128594274852), + Offset(29.82772188022043, 9.24285261397037), + Offset(30.130077026655854, 9.253525504896444), + Offset(30.310784636912537, 9.250719462255653), + Offset(30.384214249800557, 9.230640146617926), + Offset(30.396344518703998, 9.207310962592), + Offset(30.4, 9.2), + ], + <Offset>[ + Offset(19.359999999999996, 35.28), + Offset(19.123273985594132, 35.179693349122644), + Offset(18.225353787897824, 34.74343804957491), + Offset(16.224523356366795, 33.397338078643045), + Offset(12.910232824870667, 29.077899565905565), + Offset(12.165487217788991, 21.04152942220341), + Offset(14.934240058357119, 15.758576685684787), + Offset(17.928807605465366, 13.180109381397063), + Offset(20.440477118947065, 11.792873927805745), + Offset(22.502410265041142, 10.803123370006087), + Offset(24.2574729727973, 10.115818604136678), + Offset(25.751631145416468, 9.635333856092236), + Offset(26.993350747124914, 9.370052483438883), + Offset(27.995233030856, 9.255049194605526), + Offset(28.78351034032218, 9.222127511445999), + Offset(29.385974986966005, 9.227128594274852), + Offset(29.82772188022043, 9.24285261397037), + Offset(30.130077026655854, 9.253525504896444), + Offset(30.310784636912537, 9.250719462255653), + Offset(30.384214249800557, 9.230640146617926), + Offset(30.396344518703998, 9.207310962592), + Offset(30.4, 9.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(19.359999999999996, 35.28), + Offset(19.123273985594132, 35.179693349122644), + Offset(18.225353787897824, 34.74343804957491), + Offset(16.224523356366795, 33.397338078643045), + Offset(12.910232824870667, 29.077899565905565), + Offset(12.165487217788991, 21.04152942220341), + Offset(14.934240058357119, 15.758576685684787), + Offset(17.928807605465366, 13.180109381397063), + Offset(20.440477118947065, 11.792873927805745), + Offset(22.502410265041142, 10.803123370006087), + Offset(24.2574729727973, 10.115818604136678), + Offset(25.751631145416468, 9.635333856092236), + Offset(26.993350747124914, 9.370052483438883), + Offset(27.995233030856, 9.255049194605526), + Offset(28.78351034032218, 9.222127511445999), + Offset(29.385974986966005, 9.227128594274852), + Offset(29.82772188022043, 9.24285261397037), + Offset(30.130077026655854, 9.253525504896444), + Offset(30.310784636912537, 9.250719462255653), + Offset(30.384214249800557, 9.230640146617926), + Offset(30.396344518703998, 9.207310962592), + Offset(30.4, 9.2), + ], + <Offset>[ + Offset(28.639999999999993, 35.28), + Offset(28.401212339785253, 35.37529471882475), + Offset(27.45622031634999, 35.69711678683468), + Offset(25.08734411234227, 36.14848479364215), + Offset(19.693028215458817, 35.41130991811046), + Offset(13.509291678108683, 30.2252132378742), + Offset(11.759612309307578, 24.5310091776801), + Offset(12.064331693933298, 20.613374514720917), + Offset(12.844588989941675, 17.897464848126344), + Offset(13.550902880304374, 15.801421026500861), + Offset(14.235395111675697, 14.126334179248751), + Offset(14.875293924239358, 12.768277775581351), + Offset(15.499130613826821, 11.722755491891931), + Offset(16.07342025763398, 10.94071997343427), + Offset(16.566617976994035, 10.361366704688399), + Offset(16.965002666260215, 9.937451559386922), + Offset(17.26523568862379, 9.63411363833006), + Offset(17.469801369553835, 9.426295589851133), + Offset(17.584183342647055, 9.296103382646901), + Offset(17.61501992550015, 9.23097120612562), + Offset(17.603655481296002, 9.207310962592), + Offset(17.6, 9.2), + ], + <Offset>[ + Offset(28.639999999999993, 35.28), + Offset(28.401212339785253, 35.37529471882475), + Offset(27.45622031634999, 35.69711678683468), + Offset(25.08734411234227, 36.14848479364215), + Offset(19.693028215458817, 35.41130991811046), + Offset(13.509291678108683, 30.2252132378742), + Offset(11.759612309307578, 24.5310091776801), + Offset(12.064331693933298, 20.613374514720917), + Offset(12.844588989941675, 17.897464848126344), + Offset(13.550902880304374, 15.801421026500861), + Offset(14.235395111675697, 14.126334179248751), + Offset(14.875293924239358, 12.768277775581351), + Offset(15.499130613826821, 11.722755491891931), + Offset(16.07342025763398, 10.94071997343427), + Offset(16.566617976994035, 10.361366704688399), + Offset(16.965002666260215, 9.937451559386922), + Offset(17.26523568862379, 9.63411363833006), + Offset(17.469801369553835, 9.426295589851133), + Offset(17.584183342647055, 9.296103382646901), + Offset(17.61501992550015, 9.23097120612562), + Offset(17.603655481296002, 9.207310962592), + Offset(17.6, 9.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(28.639999999999993, 35.28), + Offset(28.401212339785253, 35.37529471882475), + Offset(27.45622031634999, 35.69711678683468), + Offset(25.08734411234227, 36.14848479364215), + Offset(19.693028215458817, 35.41130991811046), + Offset(13.509291678108683, 30.2252132378742), + Offset(11.759612309307578, 24.5310091776801), + Offset(12.064331693933298, 20.613374514720917), + Offset(12.844588989941675, 17.897464848126344), + Offset(13.550902880304374, 15.801421026500861), + Offset(14.235395111675697, 14.126334179248751), + Offset(14.875293924239358, 12.768277775581351), + Offset(15.499130613826821, 11.722755491891931), + Offset(16.07342025763398, 10.94071997343427), + Offset(16.566617976994035, 10.361366704688399), + Offset(16.965002666260215, 9.937451559386922), + Offset(17.26523568862379, 9.63411363833006), + Offset(17.469801369553835, 9.426295589851133), + Offset(17.584183342647055, 9.296103382646901), + Offset(17.61501992550015, 9.23097120612562), + Offset(17.603655481296002, 9.207310962592), + Offset(17.6, 9.2), + ], + <Offset>[ + Offset(28.639999999999993, 37.6), + Offset(28.35231199735973, 37.69477930737253), + Offset(27.21780063203505, 38.00483341894772), + Offset(24.39955743359249, 38.36418998263601), + Offset(18.109675627407594, 37.1070087657575), + Offset(11.213370724190986, 30.561164352954123), + Offset(9.566504186308748, 23.737352240417717), + Offset(10.206015410602333, 19.1472555368379), + Offset(11.318441259861526, 15.998492815874997), + Offset(12.301328466180678, 13.563544180316669), + Offset(13.232766217897677, 11.62081471396835), + Offset(14.092057944367081, 10.049193470287074), + Offset(14.91095486171356, 8.849200458567408), + Offset(15.652002562926794, 7.960266780128766), + Offset(16.281808178683434, 7.307143613856363), + Offset(16.787421924982198, 6.832208479210475), + Offset(17.167420432533866, 6.4934920904309), + Offset(17.426608848315166, 6.261226675575628), + Offset(17.572837362549244, 6.11445305908053), + Offset(17.614937160623228, 6.038672625050518), + Offset(17.603655481296002, 6.009138703240001), + Offset(17.6, 6.0), + ], + <Offset>[ + Offset(28.639999999999993, 37.6), + Offset(28.35231199735973, 37.69477930737253), + Offset(27.21780063203505, 38.00483341894772), + Offset(24.39955743359249, 38.36418998263601), + Offset(18.109675627407594, 37.1070087657575), + Offset(11.213370724190986, 30.561164352954123), + Offset(9.566504186308748, 23.737352240417717), + Offset(10.206015410602333, 19.1472555368379), + Offset(11.318441259861526, 15.998492815874997), + Offset(12.301328466180678, 13.563544180316669), + Offset(13.232766217897677, 11.62081471396835), + Offset(14.092057944367081, 10.049193470287074), + Offset(14.91095486171356, 8.849200458567408), + Offset(15.652002562926794, 7.960266780128766), + Offset(16.281808178683434, 7.307143613856363), + Offset(16.787421924982198, 6.832208479210475), + Offset(17.167420432533866, 6.4934920904309), + Offset(17.426608848315166, 6.261226675575628), + Offset(17.572837362549244, 6.11445305908053), + Offset(17.614937160623228, 6.038672625050518), + Offset(17.603655481296002, 6.009138703240001), + Offset(17.6, 6.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(28.639999999999993, 37.6), + Offset(28.35231199735973, 37.69477930737253), + Offset(27.21780063203505, 38.00483341894772), + Offset(24.39955743359249, 38.36418998263601), + Offset(18.109675627407594, 37.1070087657575), + Offset(11.213370724190986, 30.561164352954123), + Offset(9.566504186308748, 23.737352240417717), + Offset(10.206015410602333, 19.1472555368379), + Offset(11.318441259861526, 15.998492815874997), + Offset(12.301328466180678, 13.563544180316669), + Offset(13.232766217897677, 11.62081471396835), + Offset(14.092057944367081, 10.049193470287074), + Offset(14.91095486171356, 8.849200458567408), + Offset(15.652002562926794, 7.960266780128766), + Offset(16.281808178683434, 7.307143613856363), + Offset(16.787421924982198, 6.832208479210475), + Offset(17.167420432533866, 6.4934920904309), + Offset(17.426608848315166, 6.261226675575628), + Offset(17.572837362549244, 6.11445305908053), + Offset(17.614937160623228, 6.038672625050518), + Offset(17.603655481296002, 6.009138703240001), + Offset(17.6, 6.0), + ], + <Offset>[ + Offset(30.959999999999994, 37.6), + Offset(30.67179658590751, 37.74367964979805), + Offset(29.525517264148096, 38.24325310326266), + Offset(26.61526262258636, 39.051976661385794), + Offset(19.80537447505463, 38.69036135380872), + Offset(11.54932183927091, 32.857085306871824), + Offset(8.772847249046361, 25.930460363416543), + Offset(8.739896432719314, 21.005571820168864), + Offset(9.419469227610179, 17.524640545955144), + Offset(10.063451619996487, 14.813118594440363), + Offset(10.727246752617276, 12.62344360774637), + Offset(11.372973639072804, 10.832429450159353), + Offset(12.037399828389036, 9.43737621068067), + Offset(12.671549369621289, 8.381684474835954), + Offset(13.227585087851399, 7.591953412166962), + Offset(13.68217884480575, 7.009789220488495), + Offset(14.026798884634708, 6.591307346520823), + Offset(14.26153993403966, 6.304419196814299), + Offset(14.391187038982872, 6.125799039178341), + Offset(14.422638579548124, 6.0387553899274415), + Offset(14.405483221944, 6.009138703240001), + Offset(14.399999999999999, 6.0), + ], + <Offset>[ + Offset(30.959999999999994, 37.6), + Offset(30.67179658590751, 37.74367964979805), + Offset(29.525517264148096, 38.24325310326266), + Offset(26.61526262258636, 39.051976661385794), + Offset(19.80537447505463, 38.69036135380872), + Offset(11.54932183927091, 32.857085306871824), + Offset(8.772847249046361, 25.930460363416543), + Offset(8.739896432719314, 21.005571820168864), + Offset(9.419469227610179, 17.524640545955144), + Offset(10.063451619996487, 14.813118594440363), + Offset(10.727246752617276, 12.62344360774637), + Offset(11.372973639072804, 10.832429450159353), + Offset(12.037399828389036, 9.43737621068067), + Offset(12.671549369621289, 8.381684474835954), + Offset(13.227585087851399, 7.591953412166962), + Offset(13.68217884480575, 7.009789220488495), + Offset(14.026798884634708, 6.591307346520823), + Offset(14.26153993403966, 6.304419196814299), + Offset(14.391187038982872, 6.125799039178341), + Offset(14.422638579548124, 6.0387553899274415), + Offset(14.405483221944, 6.009138703240001), + Offset(14.399999999999999, 6.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(30.959999999999994, 37.6), + Offset(30.67179658590751, 37.74367964979805), + Offset(29.525517264148096, 38.24325310326266), + Offset(26.61526262258636, 39.051976661385794), + Offset(19.80537447505463, 38.69036135380872), + Offset(11.54932183927091, 32.857085306871824), + Offset(8.772847249046361, 25.930460363416543), + Offset(8.739896432719314, 21.005571820168864), + Offset(9.419469227610179, 17.524640545955144), + Offset(10.063451619996487, 14.813118594440363), + Offset(10.727246752617276, 12.62344360774637), + Offset(11.372973639072804, 10.832429450159353), + Offset(12.037399828389036, 9.43737621068067), + Offset(12.671549369621289, 8.381684474835954), + Offset(13.227585087851399, 7.591953412166962), + Offset(13.68217884480575, 7.009789220488495), + Offset(14.026798884634708, 6.591307346520823), + Offset(14.26153993403966, 6.304419196814299), + Offset(14.391187038982872, 6.125799039178341), + Offset(14.422638579548124, 6.0387553899274415), + Offset(14.405483221944, 6.009138703240001), + Offset(14.399999999999999, 6.0), + ], + <Offset>[ + Offset(30.959999999999994, 35.28), + Offset(30.720696928333034, 35.424195061250266), + Offset(29.763936948463037, 35.93553647114962), + Offset(27.303049301336138, 36.83627147239193), + Offset(21.388727063105854, 36.99466250616168), + Offset(13.845242793188607, 32.521134191791894), + Offset(10.96595537204519, 26.72411730067893), + Offset(10.598212716050279, 22.47169079805188), + Offset(10.945616957690326, 19.423612578206495), + Offset(11.313026034120181, 17.050995440624554), + Offset(11.729875646395296, 15.128963073026771), + Offset(12.156209618945082, 13.55151375545363), + Offset(12.625575580502298, 12.310931244005193), + Offset(13.092967064328475, 11.362137668141457), + Offset(13.512394886161998, 10.646176502998998), + Offset(13.859759586083767, 10.115032300664941), + Offset(14.124614140724631, 9.731928894419983), + Offset(14.304732455278332, 9.469488111089804), + Offset(14.402533019080684, 9.307449362744713), + Offset(14.422721344425048, 9.231053971002543), + Offset(14.405483221944, 9.207310962592), + Offset(14.399999999999999, 9.2), + ], + <Offset>[ + Offset(30.959999999999994, 35.28), + Offset(30.720696928333034, 35.424195061250266), + Offset(29.763936948463037, 35.93553647114962), + Offset(27.303049301336138, 36.83627147239193), + Offset(21.388727063105854, 36.99466250616168), + Offset(13.845242793188607, 32.521134191791894), + Offset(10.96595537204519, 26.72411730067893), + Offset(10.598212716050279, 22.47169079805188), + Offset(10.945616957690326, 19.423612578206495), + Offset(11.313026034120181, 17.050995440624554), + Offset(11.729875646395296, 15.128963073026771), + Offset(12.156209618945082, 13.55151375545363), + Offset(12.625575580502298, 12.310931244005193), + Offset(13.092967064328475, 11.362137668141457), + Offset(13.512394886161998, 10.646176502998998), + Offset(13.859759586083767, 10.115032300664941), + Offset(14.124614140724631, 9.731928894419983), + Offset(14.304732455278332, 9.469488111089804), + Offset(14.402533019080684, 9.307449362744713), + Offset(14.422721344425048, 9.231053971002543), + Offset(14.405483221944, 9.207310962592), + Offset(14.399999999999999, 9.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(30.959999999999994, 35.28), + Offset(30.720696928333034, 35.424195061250266), + Offset(29.763936948463037, 35.93553647114962), + Offset(27.303049301336138, 36.83627147239193), + Offset(21.388727063105854, 36.99466250616168), + Offset(13.845242793188607, 32.521134191791894), + Offset(10.96595537204519, 26.72411730067893), + Offset(10.598212716050279, 22.47169079805188), + Offset(10.945616957690326, 19.423612578206495), + Offset(11.313026034120181, 17.050995440624554), + Offset(11.729875646395296, 15.128963073026771), + Offset(12.156209618945082, 13.55151375545363), + Offset(12.625575580502298, 12.310931244005193), + Offset(13.092967064328475, 11.362137668141457), + Offset(13.512394886161998, 10.646176502998998), + Offset(13.859759586083767, 10.115032300664941), + Offset(14.124614140724631, 9.731928894419983), + Offset(14.304732455278332, 9.469488111089804), + Offset(14.402533019080684, 9.307449362744713), + Offset(14.422721344425048, 9.231053971002543), + Offset(14.405483221944, 9.207310962592), + Offset(14.399999999999999, 9.2), + ], + <Offset>[ + Offset(32.11999999999999, 35.28), + Offset(31.880439222606924, 35.44864523246303), + Offset(30.917795264519558, 36.05474631330709), + Offset(28.41090189583307, 37.180164811766815), + Offset(22.236576486929373, 37.786338800187295), + Offset(14.013218350728568, 33.669094668750745), + Offset(10.569126903413999, 27.820671362178345), + Offset(9.86515322710877, 23.400848939717363), + Offset(9.996130941564655, 20.18668644324657), + Offset(10.194087611028085, 17.675782647686404), + Offset(10.477115913755096, 15.63027751991578), + Offset(10.796667466297944, 13.94313174538977), + Offset(11.188798063840036, 12.605019120061824), + Offset(11.602740467675723, 11.57284651549505), + Offset(11.98528334074598, 10.788581402154296), + Offset(12.307138045995544, 10.20382267130395), + Offset(12.55430336677505, 9.780836522464943), + Offset(12.72219799814058, 9.49108437170914), + Offset(12.811707857297497, 9.313122352793618), + Offset(12.826572053887496, 9.231095353441006), + Offset(12.806397092268, 9.207310962592), + Offset(12.799999999999999, 9.2), + ], + <Offset>[ + Offset(32.11999999999999, 35.28), + Offset(31.880439222606924, 35.44864523246303), + Offset(30.917795264519558, 36.05474631330709), + Offset(28.41090189583307, 37.180164811766815), + Offset(22.236576486929373, 37.786338800187295), + Offset(14.013218350728568, 33.669094668750745), + Offset(10.569126903413999, 27.820671362178345), + Offset(9.86515322710877, 23.400848939717363), + Offset(9.996130941564655, 20.18668644324657), + Offset(10.194087611028085, 17.675782647686404), + Offset(10.477115913755096, 15.63027751991578), + Offset(10.796667466297944, 13.94313174538977), + Offset(11.188798063840036, 12.605019120061824), + Offset(11.602740467675723, 11.57284651549505), + Offset(11.98528334074598, 10.788581402154296), + Offset(12.307138045995544, 10.20382267130395), + Offset(12.55430336677505, 9.780836522464943), + Offset(12.72219799814058, 9.49108437170914), + Offset(12.811707857297497, 9.313122352793618), + Offset(12.826572053887496, 9.231095353441006), + Offset(12.806397092268, 9.207310962592), + Offset(12.799999999999999, 9.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(33.40180389404459, 35.28), + Offset(33.161958350959075, 35.47566275373077), + Offset(32.19281257718938, 36.186473589070786), + Offset(29.635082731741335, 37.56016810620365), + Offset(23.17345294642977, 38.66114376269145), + Offset(14.198831905693254, 34.93759484941898), + Offset(10.130630113449154, 29.032367281195906), + Offset(9.055120030995347, 24.427571805381206), + Offset(8.946945706382945, 21.029885625705216), + Offset(8.957656897307762, 18.366174608859787), + Offset(9.09281220375468, 16.18423166660815), + Offset(9.294368823728218, 14.37587093890534), + Offset(9.601154084759504, 12.929987210338286), + Offset(9.95603507578061, 11.805680499156674), + Offset(10.297819956647084, 10.945939293764894), + Offset(10.591486032148467, 10.301936328923558), + Offset(10.819104690129567, 9.834879615634355), + Offset(10.973492110538054, 9.51494831219075), + Offset(11.053840713230413, 9.319391025841519), + Offset(11.062821729674083, 9.231141081174425), + Offset(11.039401550947822, 9.207310962592), + Offset(11.031994628903998, 9.2), + ], + <Offset>[ + Offset(34.428397521973196, 34.24180389404461), + Offset(34.21020673189347, 34.4593355607496), + Offset(33.32066325711653, 35.25927432509759), + Offset(30.923309576566957, 36.87298746940196), + Offset(24.632342654683864, 38.60295054594897), + Offset(15.374910124648133, 35.80319600547601), + Offset(10.760851302536071, 30.35797174936804), + Offset(9.23795982776596, 25.90595743850577), + Offset(8.789604345416578, 22.554988601644673), + Offset(8.52658499233495, 19.920553987514175), + Offset(8.43280146906967, 17.749107953581934), + Offset(8.441678273181578, 15.93923637150114), + Offset(8.59282153680617, 14.476164419419169), + Offset(8.82577643484907, 13.325904473016532), + Offset(9.073786534329603, 12.438726553996867), + Offset(9.296891581635101, 11.770106385528296), + Offset(9.473160444080882, 11.283585469050152), + Offset(9.592286389851099, 10.950423910880021), + Offset(9.651046490767305, 10.748194770371416), + Offset(9.650275412603275, 10.659725961266831), + Offset(9.624219110228523, 10.638487680623824), + Offset(9.616003417967999, 10.631994628904), + ], + <Offset>[ + Offset(34.428397521973196, 32.96), + Offset(34.23722425316121, 33.17781643239746), + Offset(33.45239053288022, 33.984257012427776), + Offset(31.303312871003797, 35.648806633493706), + Offset(25.507147617188025, 37.666074086448575), + Offset(16.643410305316365, 35.61758245051132), + Offset(11.972547221553633, 30.79646853933288), + Offset(10.264682693429805, 26.71599063461919), + Offset(9.632803527875225, 23.60417383682638), + Offset(9.216976953508334, 21.156984701234496), + Offset(8.98675561576204, 19.133411663582347), + Offset(8.87441746669715, 17.441535014070865), + Offset(8.917789627082632, 16.063808398499702), + Offset(9.058610418510696, 14.972609864911643), + Offset(9.2311444259402, 14.126189938095761), + Offset(9.395005239254708, 13.48575839937537), + Offset(9.527203537250292, 13.018784145695633), + Offset(9.616150330332708, 12.699129798482545), + Offset(9.657315163815205, 12.5060619144385), + Offset(9.650321140336693, 12.423476285480243), + Offset(9.624219110228523, 12.405483221944), + Offset(9.616003417967999, 12.399999999999999), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(34.428397521973196, 32.96), + Offset(34.23722425316121, 33.17781643239746), + Offset(33.45239053288022, 33.984257012427776), + Offset(31.303312871003797, 35.648806633493706), + Offset(25.507147617188025, 37.666074086448575), + Offset(16.643410305316365, 35.61758245051132), + Offset(11.972547221553633, 30.79646853933288), + Offset(10.264682693429805, 26.71599063461919), + Offset(9.632803527875225, 23.60417383682638), + Offset(9.216976953508334, 21.156984701234496), + Offset(8.98675561576204, 19.133411663582347), + Offset(8.87441746669715, 17.441535014070865), + Offset(8.917789627082632, 16.063808398499702), + Offset(9.058610418510696, 14.972609864911643), + Offset(9.2311444259402, 14.126189938095761), + Offset(9.395005239254708, 13.48575839937537), + Offset(9.527203537250292, 13.018784145695633), + Offset(9.616150330332708, 12.699129798482545), + Offset(9.657315163815205, 12.5060619144385), + Offset(9.650321140336693, 12.423476285480243), + Offset(9.624219110228523, 12.405483221944), + Offset(9.616003417967999, 12.399999999999999), + ], + <Offset>[ + Offset(34.440000000000516, 16.720000000000006), + Offset(34.591126550559416, 16.94166886650632), + Offset(35.132869371152644, 17.831432940717747), + Offset(36.128900514825645, 20.142309978565798), + Offset(36.59909603898658, 25.80410060706257), + Offset(32.71653709715015, 33.277406702029566), + Offset(27.320334950142062, 36.36303498327675), + Offset(23.26556451587313, 36.98811704611288), + Offset(20.306340749954074, 36.90461043133784), + Offset(17.95280607783294, 36.82837183128392), + Offset(15.99262759869828, 36.67706213593723), + Offset(14.343470999981285, 36.47904216761644), + Offset(13.020649047420964, 36.181635138771405), + Offset(11.99362883202704, 35.83788975664637), + Offset(11.209538636398053, 35.50717592712125), + Offset(10.622540896042542, 35.22334805399359), + Offset(10.196203867594564, 35.003624161748164), + Offset(9.902669253773741, 34.854828207151854), + Offset(9.720825374512906, 34.7776709214224), + Offset(9.634935591827066, 34.76956676691874), + Offset(9.608224832915282, 34.792689037407996), + Offset(9.59999999999928, 34.8), + ], + <Offset>[ + Offset(34.440000000000516, 16.720000000000006), + Offset(34.591126550559416, 16.94166886650632), + Offset(35.132869371152644, 17.831432940717747), + Offset(36.128900514825645, 20.142309978565798), + Offset(36.59909603898658, 25.80410060706257), + Offset(32.71653709715015, 33.277406702029566), + Offset(27.320334950142062, 36.36303498327675), + Offset(23.26556451587313, 36.98811704611288), + Offset(20.306340749954074, 36.90461043133784), + Offset(17.95280607783294, 36.82837183128392), + Offset(15.99262759869828, 36.67706213593723), + Offset(14.343470999981285, 36.47904216761644), + Offset(13.020649047420964, 36.181635138771405), + Offset(11.99362883202704, 35.83788975664637), + Offset(11.209538636398053, 35.50717592712125), + Offset(10.622540896042542, 35.22334805399359), + Offset(10.196203867594564, 35.003624161748164), + Offset(9.902669253773741, 34.854828207151854), + Offset(9.720825374512906, 34.7776709214224), + Offset(9.634935591827066, 34.76956676691874), + Offset(9.608224832915282, 34.792689037407996), + Offset(9.59999999999928, 34.8), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(34.440000000000516, 15.438196105955406), + Offset(34.61814407182715, 15.660149738154173), + Offset(35.264596646916345, 16.556415628047922), + Offset(36.50890380926249, 18.918129142657538), + Offset(37.473901001490745, 24.867224147562176), + Offset(33.98503727781838, 33.091793147064884), + Offset(28.532030869159623, 36.80153177324159), + Offset(24.292287381536976, 37.798150242226306), + Offset(21.149539932412726, 37.95379566651955), + Offset(18.643198039006325, 38.06480254500424), + Offset(16.546581745390647, 38.061365845937644), + Offset(14.776210193496857, 37.98134081018617), + Offset(13.345617137697426, 37.76927911785194), + Offset(12.226462815688665, 37.48459514854148), + Offset(11.366896528008649, 37.19463931122015), + Offset(10.72065455366215, 36.93900006784067), + Offset(10.250246960763974, 36.73882283839365), + Offset(9.92653319425535, 36.60353409475438), + Offset(9.727094047560806, 36.53553806548948), + Offset(9.634981319560485, 36.53331709113216), + Offset(9.608224832915282, 36.55968457872818), + Offset(9.59999999999928, 36.568005371096), + ], + <Offset>[ + Offset(33.401803894045116, 14.400000000000006), + Offset(33.602061432789306, 14.600301456800754), + Offset(34.33858973602437, 15.417023900053458), + Offset(35.825162840489824, 17.618821405258995), + Offset(37.42362623889116, 23.39985413386847), + Offset(34.862120490952606, 31.914034813700184), + Offset(29.868603220438434, 36.17527971655787), + Offset(25.77996658097369, 37.62264260632878), + Offset(22.682275277103862, 38.12063391596769), + Offset(20.203826624420508, 38.5070662245178), + Offset(18.116472247756285, 38.73390685413197), + Offset(16.343492642578116, 38.847629686554015), + Offset(14.894735853778217, 38.79198251025913), + Offset(13.748794328144617, 38.62975923890631), + Offset(12.861108141441791, 38.43394711125329), + Offset(12.18971270364993, 38.249124050511625), + Offset(11.699441994938162, 38.10047354672682), + Offset(11.362224801685391, 38.0005685406703), + Offset(11.155954534110002, 37.954243937938855), + Offset(11.063566613565678, 37.96182831085034), + Offset(11.039401550947105, 37.99086129676), + Offset(11.031994628903279, 38.0), + ], + <Offset>[ + Offset(32.120000000000516, 14.400000000000006), + Offset(32.32054230443716, 14.573283935533016), + Offset(33.06357242335454, 15.285296624289762), + Offset(34.60098200458155, 17.238818110822155), + Offset(36.48674977939076, 22.52504917136431), + Offset(34.67650693598792, 30.64553463303195), + Offset(30.30710001040328, 34.96358379754031), + Offset(26.58999977708711, 36.59591974066494), + Offset(23.731460512285572, 37.27743473350904), + Offset(21.440257338140828, 37.81667426334442), + Offset(19.500775957756698, 38.17995270743961), + Offset(17.84579128514784, 38.41489049303844), + Offset(16.482379832858747, 38.46701441998266), + Offset(15.39549972003973, 38.39692525524468), + Offset(14.548571525540689, 38.27658921964269), + Offset(13.905364717497006, 38.15101039289202), + Offset(13.434640671583647, 38.046430453557406), + Offset(13.110930689287915, 37.97670460018869), + Offset(12.913821678177088, 37.94797526489096), + Offset(12.827316937779091, 37.96178258311692), + Offset(12.806397092267282, 37.99086129676), + Offset(12.79999999999928, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(32.120000000000516, 14.400000000000006), + Offset(32.32054230443716, 14.573283935533016), + Offset(33.06357242335454, 15.285296624289762), + Offset(34.60098200458155, 17.238818110822155), + Offset(36.48674977939076, 22.52504917136431), + Offset(34.67650693598792, 30.64553463303195), + Offset(30.30710001040328, 34.96358379754031), + Offset(26.58999977708711, 36.59591974066494), + Offset(23.731460512285572, 37.27743473350904), + Offset(21.440257338140828, 37.81667426334442), + Offset(19.500775957756698, 38.17995270743961), + Offset(17.84579128514784, 38.41489049303844), + Offset(16.482379832858747, 38.46701441998266), + Offset(15.39549972003973, 38.39692525524468), + Offset(14.548571525540689, 38.27658921964269), + Offset(13.905364717497006, 38.15101039289202), + Offset(13.434640671583647, 38.046430453557406), + Offset(13.110930689287915, 37.97670460018869), + Offset(12.913821678177088, 37.94797526489096), + Offset(12.827316937779091, 37.96178258311692), + Offset(12.806397092267282, 37.99086129676), + Offset(12.79999999999928, 38.0), + ], + <Offset>[ + Offset(15.88000000000052, 14.400000000000002), + Offset(16.0841501846027, 14.23098153855435), + Offset(16.909555998563246, 13.616358834085176), + Offset(19.091045681624482, 12.424311359573721), + Offset(24.616857845861503, 11.441581055005743), + Offset(32.32484913042846, 14.574087955608066), + Offset(35.862698571239974, 19.611826936548507), + Offset(36.85283262226824, 23.587705757348193), + Offset(37.024264738045005, 26.59440062294799), + Offset(37.105395261430175, 29.069653364478558), + Offset(37.039412214719505, 31.16155045099348), + Offset(36.87938142220778, 32.932238633932485), + Offset(36.59726506613041, 34.34978415518983), + Offset(36.25867207317826, 35.44700139229438), + Offset(35.92813316136494, 36.28292063146849), + Offset(35.64206627873214, 36.90794520394589), + Offset(35.41899150687777, 37.36172366092795), + Offset(35.26641308921645, 37.674356951517986), + Offset(35.185373943141684, 37.868553404206274), + Offset(35.1734070053048, 37.961203228978455), + Offset(35.19360290773128, 37.99086129676), + Offset(35.19999999999928, 38.0), + ], + <Offset>[ + Offset(15.88000000000052, 14.400000000000002), + Offset(16.0841501846027, 14.23098153855435), + Offset(16.909555998563246, 13.616358834085176), + Offset(19.091045681624482, 12.424311359573721), + Offset(24.616857845861503, 11.441581055005743), + Offset(32.32484913042846, 14.574087955608066), + Offset(35.862698571239974, 19.611826936548507), + Offset(36.85283262226824, 23.587705757348193), + Offset(37.024264738045005, 26.59440062294799), + Offset(37.105395261430175, 29.069653364478558), + Offset(37.039412214719505, 31.16155045099348), + Offset(36.87938142220778, 32.932238633932485), + Offset(36.59726506613041, 34.34978415518983), + Offset(36.25867207317826, 35.44700139229438), + Offset(35.92813316136494, 36.28292063146849), + Offset(35.64206627873214, 36.90794520394589), + Offset(35.41899150687777, 37.36172366092795), + Offset(35.26641308921645, 37.674356951517986), + Offset(35.185373943141684, 37.868553404206274), + Offset(35.1734070053048, 37.961203228978455), + Offset(35.19360290773128, 37.99086129676), + Offset(35.19999999999928, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(14.59819610595592, 14.400000000000002), + Offset(14.802631056250549, 14.203964017286612), + Offset(15.634538685893425, 13.484631558321478), + Offset(17.866864845716222, 12.044308065136882), + Offset(23.679981386361106, 10.56677609250158), + Offset(32.13923557546377, 13.305587774939834), + Offset(36.30119536120482, 18.400131017530946), + Offset(37.66286581838166, 22.560982891684347), + Offset(38.073449973226715, 25.751201440489343), + Offset(38.3418259751505, 28.379261403305176), + Offset(38.423715924719914, 30.60759630430111), + Offset(38.381680064777505, 32.49949944041691), + Offset(38.184909045210944, 34.02481606491337), + Offset(37.90537746507338, 35.21416740863276), + Offset(37.615596545463845, 36.1255627398579), + Offset(37.35771829257922, 36.80983154632628), + Offset(37.15419018352325, 37.30768056775854), + Offset(37.01511897681897, 37.650493011036374), + Offset(36.94324108720877, 37.862284731158375), + Offset(36.937157329518215, 37.96115750124504), + Offset(36.96059844905146, 37.99086129676), + Offset(36.96800537109528, 38.0), + ], + <Offset>[ + Offset(13.560000000000521, 15.438196105955402), + Offset(13.74278277489713, 15.22004665632446), + Offset(14.49514695789896, 14.410638469213454), + Offset(16.56755710831768, 12.72804903390955), + Offset(22.212611372667403, 10.617050855101162), + Offset(30.96147724209907, 12.428504561805607), + Offset(35.67494330452109, 17.06355866625214), + Offset(37.48735818248413, 21.073303692247634), + Offset(38.24028822267485, 24.218466095798203), + Offset(38.78408965466406, 26.818632817890993), + Offset(39.09625693291425, 29.037705801935473), + Offset(39.24796894114535, 30.932216991335657), + Offset(39.20761243761814, 32.475697348832576), + Offset(39.05054155543821, 33.691835896176805), + Offset(38.854904345496976, 34.631351126424754), + Offset(38.66784227525018, 35.340773396338506), + Offset(38.51584089185641, 35.85848553358435), + Offset(38.41215342273489, 36.21480140360634), + Offset(38.36194695965814, 36.43342424460918), + Offset(38.3656685492364, 36.532572207239845), + Offset(38.39177516708328, 36.55968457872818), + Offset(38.39999999999928, 36.568005371096), + ], + <Offset>[ + Offset(13.560000000000521, 16.720000000000002), + Offset(13.715765253629392, 16.501565784676608), + Offset(14.363419682135264, 15.685655781883277), + Offset(16.18755381388084, 13.952229869817812), + Offset(21.337806410163243, 11.553927314601557), + Offset(29.692977061430838, 12.614118116770292), + Offset(34.463247385503536, 16.625061876287294), + Offset(36.46063531682029, 20.26327049613421), + Offset(37.3970890402162, 23.169280860616492), + Offset(38.093697693490675, 25.582202104170673), + Offset(38.54230278622188, 27.65340209193506), + Offset(38.81522974762978, 29.42991834876593), + Offset(38.882644347341675, 30.888053369752043), + Offset(38.81770757177658, 32.045130504281694), + Offset(38.69754645388638, 32.943887742325856), + Offset(38.56972861763057, 33.62512138249143), + Offset(38.461797798687, 34.12328685693886), + Offset(38.38828948225328, 34.46609551600381), + Offset(38.35567828661024, 34.67555710054209), + Offset(38.36562282150298, 34.76882188302643), + Offset(38.39177516708328, 34.792689037407996), + Offset(38.39999999999928, 34.8), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(13.560000000000521, 16.720000000000002), + Offset(13.715765253629392, 16.501565784676608), + Offset(14.363419682135264, 15.685655781883277), + Offset(16.18755381388084, 13.952229869817812), + Offset(21.337806410163243, 11.553927314601557), + Offset(29.692977061430838, 12.614118116770292), + Offset(34.463247385503536, 16.625061876287294), + Offset(36.46063531682029, 20.26327049613421), + Offset(37.3970890402162, 23.169280860616492), + Offset(38.093697693490675, 25.582202104170673), + Offset(38.54230278622188, 27.65340209193506), + Offset(38.81522974762978, 29.42991834876593), + Offset(38.882644347341675, 30.888053369752043), + Offset(38.81770757177658, 32.045130504281694), + Offset(38.69754645388638, 32.943887742325856), + Offset(38.56972861763057, 33.62512138249143), + Offset(38.461797798687, 34.12328685693886), + Offset(38.38828948225328, 34.46609551600381), + Offset(38.35567828661024, 34.67555710054209), + Offset(38.36562282150298, 34.76882188302643), + Offset(38.39177516708328, 34.792689037407996), + Offset(38.39999999999928, 34.8), + ], + <Offset>[ + Offset(13.56000000000052, 32.96), + Offset(13.373462856650727, 32.73795790451107), + Offset(12.694481891930677, 31.839672206674575), + Offset(11.373047062632402, 29.462166192774887), + Offset(10.254338293804675, 23.423819248130823), + Offset(13.621530384006954, 14.965775922329756), + Offset(19.111490524511733, 11.069463315450594), + Offset(23.452421333503544, 10.000437650953089), + Offset(26.714054929655155, 9.876476634857061), + Offset(29.346676794624816, 9.917064180881326), + Offset(31.523900529775755, 10.114765834972257), + Offset(33.33257788852383, 10.396328211705992), + Offset(34.76541408254884, 10.773168136480383), + Offset(35.86778370882628, 11.181958151143158), + Offset(36.70387786571219, 11.5643261065016), + Offset(37.32666342868444, 11.888419821256292), + Offset(37.777091006057546, 12.138936021644747), + Offset(38.085941833582574, 12.31061311607528), + Offset(38.27625642592556, 12.404004835577497), + Offset(38.365043467364515, 12.422731815500718), + Offset(38.39177516708328, 12.405483221944), + Offset(38.39999999999928, 12.399999999999999), + ], + <Offset>[ + Offset(13.56000000000052, 32.96), + Offset(13.373462856650727, 32.73795790451107), + Offset(12.694481891930677, 31.839672206674575), + Offset(11.373047062632402, 29.462166192774887), + Offset(10.254338293804675, 23.423819248130823), + Offset(13.621530384006954, 14.965775922329756), + Offset(19.111490524511733, 11.069463315450594), + Offset(23.452421333503544, 10.000437650953089), + Offset(26.714054929655155, 9.876476634857061), + Offset(29.346676794624816, 9.917064180881326), + Offset(31.523900529775755, 10.114765834972257), + Offset(33.33257788852383, 10.396328211705992), + Offset(34.76541408254884, 10.773168136480383), + Offset(35.86778370882628, 11.181958151143158), + Offset(36.70387786571219, 11.5643261065016), + Offset(37.32666342868444, 11.888419821256292), + Offset(37.777091006057546, 12.138936021644747), + Offset(38.085941833582574, 12.31061311607528), + Offset(38.27625642592556, 12.404004835577497), + Offset(38.365043467364515, 12.422731815500718), + Offset(38.39177516708328, 12.405483221944), + Offset(38.39999999999928, 12.399999999999999), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(13.560000000000517, 34.2418038940446), + Offset(13.34644533538299, 34.01947703286322), + Offset(12.56275461616698, 33.114689519344395), + Offset(10.993043768195564, 30.686347028683148), + Offset(9.379533331300514, 24.360695707631216), + Offset(12.353030203338722, 15.151389477294442), + Offset(17.899794605494172, 10.630966525485752), + Offset(22.425698467839702, 9.190404454839666), + Offset(25.870855747196508, 8.827291399675353), + Offset(28.65628483345143, 8.680633467161003), + Offset(30.969946383083386, 8.730462124971844), + Offset(32.899838695008256, 8.894029569136269), + Offset(34.44044599227237, 9.18552415739985), + Offset(35.63494972516465, 9.535252759248047), + Offset(36.546519974101585, 9.876862722402706), + Offset(37.228549771064834, 10.172767807409217), + Offset(37.723047912888134, 10.403737344999264), + Offset(38.06207789310096, 10.561907228472755), + Offset(38.26998775287766, 10.646137691510413), + Offset(38.3649977396311, 10.658981491287307), + Offset(38.39177516708328, 10.638487680623824), + Offset(38.39999999999928, 10.631994628904), + ], + <Offset>[ + Offset(14.598196105955916, 35.28), + Offset(14.362527974420836, 35.07932531421663), + Offset(13.488761527058955, 34.25408124733886), + Offset(11.676784736968232, 31.985654766081694), + Offset(9.429808093900096, 25.828065721324922), + Offset(11.475946990204495, 16.329147810659144), + Offset(16.56322225421536, 11.257218582169475), + Offset(20.938019268402986, 9.365912090737192), + Offset(24.338120402505368, 8.660453150227216), + Offset(27.09565624803725, 8.238369787647443), + Offset(29.40005588071775, 8.057921116777507), + Offset(31.332556245926995, 8.027740692768422), + Offset(32.89132727619159, 8.16282076499266), + Offset(34.112618212708696, 8.390088668883216), + Offset(35.05230836066845, 8.637554922369567), + Offset(35.75949162107706, 8.862643824738257), + Offset(36.273852878713946, 9.042086636666099), + Offset(36.626386285670925, 9.164872782556836), + Offset(36.84112726632846, 9.227431819061039), + Offset(36.936412445625905, 9.230470271569121), + Offset(36.96059844905146, 9.207310962592), + Offset(36.96800537109528, 9.2), + ], + <Offset>[ + Offset(15.880000000000516, 35.28), + Offset(15.644047102772983, 35.10634283548437), + Offset(14.763778839728777, 34.385808523102554), + Offset(12.900965572876494, 32.365658060518534), + Offset(10.36668455340049, 26.702870683829083), + Offset(11.66156054516918, 17.597647991327378), + Offset(16.12472546425052, 12.468914501187037), + Offset(20.127986072289563, 10.392634956401036), + Offset(23.288935167323658, 9.503652332685864), + Offset(25.859225534316927, 8.928761748820826), + Offset(28.015752170717334, 8.611875263469875), + Offset(29.83025760335727, 8.460479886283995), + Offset(31.303683297111053, 8.487788855269121), + Offset(32.465912820813585, 8.62292265254484), + Offset(33.36484497656955, 8.794912813980165), + Offset(34.04383960722998, 8.960757482357865), + Offset(34.538654202068464, 9.096129729835509), + Offset(34.8776803980684, 9.188736723038446), + Offset(35.08326012226138, 9.233700492108937), + Offset(35.17266212141249, 9.23051599930254), + Offset(35.19360290773128, 9.207310962592), + Offset(35.19999999999928, 9.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(15.880000000000516, 35.28), + Offset(15.644047102772983, 35.10634283548437), + Offset(14.763778839728777, 34.385808523102554), + Offset(12.900965572876494, 32.365658060518534), + Offset(10.36668455340049, 26.702870683829083), + Offset(11.66156054516918, 17.597647991327378), + Offset(16.12472546425052, 12.468914501187037), + Offset(20.127986072289563, 10.392634956401036), + Offset(23.288935167323658, 9.503652332685864), + Offset(25.859225534316927, 8.928761748820826), + Offset(28.015752170717334, 8.611875263469875), + Offset(29.83025760335727, 8.460479886283995), + Offset(31.303683297111053, 8.487788855269121), + Offset(32.465912820813585, 8.62292265254484), + Offset(33.36484497656955, 8.794912813980165), + Offset(34.04383960722998, 8.960757482357865), + Offset(34.538654202068464, 9.096129729835509), + Offset(34.8776803980684, 9.188736723038446), + Offset(35.08326012226138, 9.233700492108937), + Offset(35.17266212141249, 9.23051599930254), + Offset(35.19360290773128, 9.207310962592), + Offset(35.19999999999928, 9.2), + ], + <Offset>[ + Offset(17.040000000000518, 35.28), + Offset(16.803789397046874, 35.13079300669713), + Offset(15.9176371557853, 34.50501836526003), + Offset(14.008818167373427, 32.70955139989342), + Offset(11.21453397722401, 27.494546977854696), + Offset(11.829536102709142, 18.74560846828623), + Offset(15.727896995619327, 13.565468562686451), + Offset(19.394926583348056, 11.321793098066518), + Offset(22.339449151197982, 10.266726197725939), + Offset(24.74028711122483, 9.553548955882674), + Offset(26.762992438077134, 9.113189710358885), + Offset(28.47071545071013, 8.852097876220133), + Offset(29.86690578044879, 8.781876731325752), + Offset(30.975686224160835, 8.833631499898434), + Offset(31.83773343115353, 8.937317713135464), + Offset(32.49121806714176, 9.049547852996874), + Offset(32.968343428118885, 9.14503735788047), + Offset(33.295145940930645, 9.210332983657782), + Offset(33.49243496047819, 9.239373482157845), + Offset(33.57651283087494, 9.230557381741002), + Offset(33.59451677805528, 9.207310962592), + Offset(33.599999999999284, 9.2), + ], + <Offset>[ + Offset(17.040000000000518, 35.28), + Offset(16.803789397046874, 35.13079300669713), + Offset(15.9176371557853, 34.50501836526003), + Offset(14.008818167373427, 32.70955139989342), + Offset(11.21453397722401, 27.494546977854696), + Offset(11.829536102709142, 18.74560846828623), + Offset(15.727896995619327, 13.565468562686451), + Offset(19.394926583348056, 11.321793098066518), + Offset(22.339449151197982, 10.266726197725939), + Offset(24.74028711122483, 9.553548955882674), + Offset(26.762992438077134, 9.113189710358885), + Offset(28.47071545071013, 8.852097876220133), + Offset(29.86690578044879, 8.781876731325752), + Offset(30.975686224160835, 8.833631499898434), + Offset(31.83773343115353, 8.937317713135464), + Offset(32.49121806714176, 9.049547852996874), + Offset(32.968343428118885, 9.14503735788047), + Offset(33.295145940930645, 9.210332983657782), + Offset(33.49243496047819, 9.239373482157845), + Offset(33.57651283087494, 9.230557381741002), + Offset(33.59451677805528, 9.207310962592), + Offset(33.599999999999284, 9.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(17.040000000000518, 35.28), + Offset(16.803789397046874, 35.13079300669713), + Offset(15.9176371557853, 34.50501836526003), + Offset(14.008818167373427, 32.70955139989342), + Offset(11.21453397722401, 27.494546977854696), + Offset(11.829536102709142, 18.74560846828623), + Offset(15.727896995619327, 13.565468562686451), + Offset(19.394926583348056, 11.321793098066518), + Offset(22.339449151197982, 10.266726197725939), + Offset(24.74028711122483, 9.553548955882674), + Offset(26.762992438077134, 9.113189710358885), + Offset(28.47071545071013, 8.852097876220133), + Offset(29.86690578044879, 8.781876731325752), + Offset(30.975686224160835, 8.833631499898434), + Offset(31.83773343115353, 8.937317713135464), + Offset(32.49121806714176, 9.049547852996874), + Offset(32.968343428118885, 9.14503735788047), + Offset(33.295145940930645, 9.210332983657782), + Offset(33.49243496047819, 9.239373482157845), + Offset(33.57651283087494, 9.230557381741002), + Offset(33.59451677805528, 9.207310962592), + Offset(33.599999999999284, 9.2), + ], + <Offset>[ + Offset(17.040000000000518, 37.6), + Offset(16.75488905462135, 37.45027759524491), + Offset(15.67921747147036, 36.81273499737307), + Offset(13.321031488623651, 34.92525658888729), + Offset(9.631181389172786, 29.190245825501734), + Offset(9.533615148791444, 19.08155958336615), + Offset(13.534788872620497, 12.771811625424068), + Offset(17.53661030001709, 9.855674120183501), + Offset(20.813301421117835, 8.36775416547459), + Offset(23.490712697101138, 7.315672109698481), + Offset(25.760363544299118, 6.607670245078484), + Offset(27.687479470837854, 6.133013570925856), + Offset(29.27873002833553, 5.908321698001231), + Offset(30.554268529453648, 5.85317830659293), + Offset(31.55292363284293, 5.883094622303428), + Offset(32.31363732586374, 5.944304772820427), + Offset(32.87052817202896, 6.00441580998131), + Offset(33.25195341969197, 6.045264069382277), + Offset(33.481088980380385, 6.0577231585914735), + Offset(33.57643006599802, 6.038258800665901), + Offset(33.59451677805528, 6.009138703240001), + Offset(33.599999999999284, 6.0), + ], + <Offset>[ + Offset(17.040000000000518, 37.6), + Offset(16.75488905462135, 37.45027759524491), + Offset(15.67921747147036, 36.81273499737307), + Offset(13.321031488623651, 34.92525658888729), + Offset(9.631181389172786, 29.190245825501734), + Offset(9.533615148791444, 19.08155958336615), + Offset(13.534788872620497, 12.771811625424068), + Offset(17.53661030001709, 9.855674120183501), + Offset(20.813301421117835, 8.36775416547459), + Offset(23.490712697101138, 7.315672109698481), + Offset(25.760363544299118, 6.607670245078484), + Offset(27.687479470837854, 6.133013570925856), + Offset(29.27873002833553, 5.908321698001231), + Offset(30.554268529453648, 5.85317830659293), + Offset(31.55292363284293, 5.883094622303428), + Offset(32.31363732586374, 5.944304772820427), + Offset(32.87052817202896, 6.00441580998131), + Offset(33.25195341969197, 6.045264069382277), + Offset(33.481088980380385, 6.0577231585914735), + Offset(33.57643006599802, 6.038258800665901), + Offset(33.59451677805528, 6.009138703240001), + Offset(33.599999999999284, 6.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(17.040000000000518, 37.6), + Offset(16.75488905462135, 37.45027759524491), + Offset(15.67921747147036, 36.81273499737307), + Offset(13.321031488623651, 34.92525658888729), + Offset(9.631181389172786, 29.190245825501734), + Offset(9.533615148791444, 19.08155958336615), + Offset(13.534788872620497, 12.771811625424068), + Offset(17.53661030001709, 9.855674120183501), + Offset(20.813301421117835, 8.36775416547459), + Offset(23.490712697101138, 7.315672109698481), + Offset(25.760363544299118, 6.607670245078484), + Offset(27.687479470837854, 6.133013570925856), + Offset(29.27873002833553, 5.908321698001231), + Offset(30.554268529453648, 5.85317830659293), + Offset(31.55292363284293, 5.883094622303428), + Offset(32.31363732586374, 5.944304772820427), + Offset(32.87052817202896, 6.00441580998131), + Offset(33.25195341969197, 6.045264069382277), + Offset(33.481088980380385, 6.0577231585914735), + Offset(33.57643006599802, 6.038258800665901), + Offset(33.59451677805528, 6.009138703240001), + Offset(33.599999999999284, 6.0), + ], + <Offset>[ + Offset(19.360000000000518, 37.6), + Offset(19.07437364316913, 37.49917793767044), + Offset(17.9869341035834, 37.05115468168801), + Offset(15.536736677617519, 35.61304326763707), + Offset(11.326880236819823, 30.773598413552957), + Offset(9.869566263871368, 21.377480537283848), + Offset(12.741131935358112, 14.964919748422895), + Offset(16.070491322134075, 11.713990403514464), + Offset(18.914329388866488, 9.893901895554741), + Offset(21.252835850916945, 8.565246523822175), + Offset(23.254844079018717, 7.610299138856501), + Offset(24.968395165543576, 6.916249550798135), + Offset(26.405174995011006, 6.496497450114491), + Offset(27.573815336148144, 6.274596001300116), + Offset(28.498700542010894, 6.167904420614027), + Offset(29.208394245687288, 6.1218855140984445), + Offset(29.7299066241298, 6.102231066071232), + Offset(30.08688450541647, 6.08845659062095), + Offset(30.29943865681401, 6.069069138689285), + Offset(30.384131484922918, 6.038341565542824), + Offset(30.39634451870328, 6.009138703240001), + Offset(30.39999999999928, 6.0), + ], + <Offset>[ + Offset(19.360000000000518, 37.6), + Offset(19.07437364316913, 37.49917793767044), + Offset(17.9869341035834, 37.05115468168801), + Offset(15.536736677617519, 35.61304326763707), + Offset(11.326880236819823, 30.773598413552957), + Offset(9.869566263871368, 21.377480537283848), + Offset(12.741131935358112, 14.964919748422895), + Offset(16.070491322134075, 11.713990403514464), + Offset(18.914329388866488, 9.893901895554741), + Offset(21.252835850916945, 8.565246523822175), + Offset(23.254844079018717, 7.610299138856501), + Offset(24.968395165543576, 6.916249550798135), + Offset(26.405174995011006, 6.496497450114491), + Offset(27.573815336148144, 6.274596001300116), + Offset(28.498700542010894, 6.167904420614027), + Offset(29.208394245687288, 6.1218855140984445), + Offset(29.7299066241298, 6.102231066071232), + Offset(30.08688450541647, 6.08845659062095), + Offset(30.29943865681401, 6.069069138689285), + Offset(30.384131484922918, 6.038341565542824), + Offset(30.39634451870328, 6.009138703240001), + Offset(30.39999999999928, 6.0), + ], + ), + _PathClose(), + _PathMoveTo(<Offset>[ + Offset(15.879999999999999, 16.720000000000002), + Offset(16.035249842176654, 16.55046612710212), + Offset(16.671136314247786, 15.924075466198163), + Offset(18.403259002874208, 14.640016548567434), + Offset(23.0335052578099, 13.137279902652425), + Offset(30.028928176510686, 14.910039070687475), + Offset(33.66959044824132, 18.81816999928563), + Offset(34.9945163389376, 22.121586779464756), + Offset(35.49811700796528, 24.6954285906963), + Offset(35.85582084730699, 26.831776518294085), + Offset(36.03678332094205, 28.656030985712853), + Offset(36.09614544233611, 30.213154328638034), + Offset(36.0090893140178, 31.476229121865174), + Offset(35.83725437847175, 32.46654819898878), + Offset(35.64332336305503, 33.22869754063639), + Offset(35.46448553745482, 33.80270212376941), + Offset(35.32117625078855, 34.22110211302876), + Offset(35.22322056797849, 34.50928803724247), + Offset(35.17402796304459, 34.6869030806399), + Offset(35.1733242404286, 34.768904647903355), + Offset(35.193602907732, 34.792689037407996), + Offset(35.2, 34.8), + ]), + _PathCubicTo( + <Offset>[ + Offset(15.879999999999999, 16.720000000000002), + Offset(16.035249842176654, 16.55046612710212), + Offset(16.671136314247786, 15.924075466198163), + Offset(18.403259002874208, 14.640016548567434), + Offset(23.0335052578099, 13.137279902652425), + Offset(30.028928176510686, 14.910039070687475), + Offset(33.66959044824132, 18.81816999928563), + Offset(34.9945163389376, 22.121586779464756), + Offset(35.49811700796528, 24.6954285906963), + Offset(35.85582084730699, 26.831776518294085), + Offset(36.03678332094205, 28.656030985712853), + Offset(36.09614544233611, 30.213154328638034), + Offset(36.0090893140178, 31.476229121865174), + Offset(35.83725437847175, 32.46654819898878), + Offset(35.64332336305503, 33.22869754063639), + Offset(35.46448553745482, 33.80270212376941), + Offset(35.32117625078855, 34.22110211302876), + Offset(35.22322056797849, 34.50928803724247), + Offset(35.17402796304459, 34.6869030806399), + Offset(35.1733242404286, 34.768904647903355), + Offset(35.193602907732, 34.792689037407996), + Offset(35.2, 34.8), + ], + <Offset>[ + Offset(32.12, 16.720000000000006), + Offset(32.27164196201112, 16.892768524080786), + Offset(32.82515273903908, 17.59301325640275), + Offset(33.91319532583128, 19.454523299815868), + Offset(34.90339719133917, 24.22074801901099), + Offset(32.380585982070144, 30.981485748111357), + Offset(28.113991887404627, 34.16992686027743), + Offset(24.731683493756478, 35.1298007627815), + Offset(22.20531278220585, 35.37846270125735), + Offset(20.190682924017636, 35.57879741715995), + Offset(18.498147063979243, 35.67443324215898), + Offset(17.062555305276174, 35.69580618774398), + Offset(15.894204080746132, 35.59345938665801), + Offset(14.974082025333214, 35.41647206193909), + Offset(14.263761727230776, 35.22236612881059), + Offset(13.727783976219687, 35.045767312715526), + Offset(13.33682541549443, 34.90580890565822), + Offset(13.067738168049956, 34.811635685913174), + Offset(12.902475698079991, 34.766324941324584), + Offset(12.827234172902884, 34.76948400204182), + Offset(12.806397092268, 34.792689037407996), + Offset(12.799999999999999, 34.8), + ], + <Offset>[ + Offset(32.12, 16.720000000000006), + Offset(32.27164196201112, 16.892768524080786), + Offset(32.82515273903908, 17.59301325640275), + Offset(33.91319532583128, 19.454523299815868), + Offset(34.90339719133917, 24.22074801901099), + Offset(32.380585982070144, 30.981485748111357), + Offset(28.113991887404627, 34.16992686027743), + Offset(24.731683493756478, 35.1298007627815), + Offset(22.20531278220585, 35.37846270125735), + Offset(20.190682924017636, 35.57879741715995), + Offset(18.498147063979243, 35.67443324215898), + Offset(17.062555305276174, 35.69580618774398), + Offset(15.894204080746132, 35.59345938665801), + Offset(14.974082025333214, 35.41647206193909), + Offset(14.263761727230776, 35.22236612881059), + Offset(13.727783976219687, 35.045767312715526), + Offset(13.33682541549443, 34.90580890565822), + Offset(13.067738168049956, 34.811635685913174), + Offset(12.902475698079991, 34.766324941324584), + Offset(12.827234172902884, 34.76948400204182), + Offset(12.806397092268, 34.792689037407996), + Offset(12.799999999999999, 34.8), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(32.12, 16.720000000000006), + Offset(32.27164196201112, 16.892768524080786), + Offset(32.82515273903908, 17.59301325640275), + Offset(33.91319532583128, 19.454523299815868), + Offset(34.90339719133917, 24.22074801901099), + Offset(32.380585982070144, 30.981485748111357), + Offset(28.113991887404627, 34.16992686027743), + Offset(24.731683493756478, 35.1298007627815), + Offset(22.20531278220585, 35.37846270125735), + Offset(20.190682924017636, 35.57879741715995), + Offset(18.498147063979243, 35.67443324215898), + Offset(17.062555305276174, 35.69580618774398), + Offset(15.894204080746132, 35.59345938665801), + Offset(14.974082025333214, 35.41647206193909), + Offset(14.263761727230776, 35.22236612881059), + Offset(13.727783976219687, 35.045767312715526), + Offset(13.33682541549443, 34.90580890565822), + Offset(13.067738168049956, 34.811635685913174), + Offset(12.902475698079991, 34.766324941324584), + Offset(12.827234172902884, 34.76948400204182), + Offset(12.806397092268, 34.792689037407996), + Offset(12.799999999999999, 34.8), + ], + <Offset>[ + Offset(32.12, 29.480000000000004), + Offset(32.00269007867074, 29.64993376109358), + Offset(31.51384447530691, 30.285454733024483), + Offset(30.130368592707512, 31.640901839282144), + Offset(26.194957957057433, 33.5470916810697), + Offset(19.753020735522814, 32.82921688105094), + Offset(16.051897210911072, 29.804813705334308), + Offset(14.51094393543618, 27.066146384424904), + Offset(13.811500266765027, 24.934116523874938), + Offset(13.31802364633732, 23.270474763146886), + Offset(12.983688148200141, 21.89407618311678), + Offset(12.754757415978641, 20.740842508625462), + Offset(12.65923744412319, 19.788906703373133), + Offset(12.656284704443689, 19.023979498758813), + Offset(12.697307836522478, 18.424139129234387), + Offset(12.751089899190589, 17.966930371745068), + Offset(12.798841506999857, 17.632390392212844), + Offset(12.83017930123726, 17.403756657397903), + Offset(12.840072807542027, 17.267248161709546), + Offset(12.826778966079804, 17.21184180612876), + Offset(12.806397092268, 17.202741610971998), + Offset(12.799999999999999, 17.2), + ], + <Offset>[ + Offset(32.12, 29.480000000000004), + Offset(32.00269007867074, 29.64993376109358), + Offset(31.51384447530691, 30.285454733024483), + Offset(30.130368592707512, 31.640901839282144), + Offset(26.194957957057433, 33.5470916810697), + Offset(19.753020735522814, 32.82921688105094), + Offset(16.051897210911072, 29.804813705334308), + Offset(14.51094393543618, 27.066146384424904), + Offset(13.811500266765027, 24.934116523874938), + Offset(13.31802364633732, 23.270474763146886), + Offset(12.983688148200141, 21.89407618311678), + Offset(12.754757415978641, 20.740842508625462), + Offset(12.65923744412319, 19.788906703373133), + Offset(12.656284704443689, 19.023979498758813), + Offset(12.697307836522478, 18.424139129234387), + Offset(12.751089899190589, 17.966930371745068), + Offset(12.798841506999857, 17.632390392212844), + Offset(12.83017930123726, 17.403756657397903), + Offset(12.840072807542027, 17.267248161709546), + Offset(12.826778966079804, 17.21184180612876), + Offset(12.806397092268, 17.202741610971998), + Offset(12.799999999999999, 17.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(32.12, 29.480000000000004), + Offset(32.00269007867074, 29.64993376109358), + Offset(31.51384447530691, 30.285454733024483), + Offset(30.130368592707512, 31.640901839282144), + Offset(26.194957957057433, 33.5470916810697), + Offset(19.753020735522814, 32.82921688105094), + Offset(16.051897210911072, 29.804813705334308), + Offset(14.51094393543618, 27.066146384424904), + Offset(13.811500266765027, 24.934116523874938), + Offset(13.31802364633732, 23.270474763146886), + Offset(12.983688148200141, 21.89407618311678), + Offset(12.754757415978641, 20.740842508625462), + Offset(12.65923744412319, 19.788906703373133), + Offset(12.656284704443689, 19.023979498758813), + Offset(12.697307836522478, 18.424139129234387), + Offset(12.751089899190589, 17.966930371745068), + Offset(12.798841506999857, 17.632390392212844), + Offset(12.83017930123726, 17.403756657397903), + Offset(12.840072807542027, 17.267248161709546), + Offset(12.826778966079804, 17.21184180612876), + Offset(12.806397092268, 17.202741610971998), + Offset(12.799999999999999, 17.2), + ], + <Offset>[ + Offset(15.879999999999997, 29.480000000000004), + Offset(15.766297958836272, 29.30763136411491), + Offset(15.359828050515612, 28.6165169428199), + Offset(14.620432269750436, 26.826395088033706), + Offset(14.325066023528167, 22.463623564711135), + Offset(17.401362929963348, 16.757770203627054), + Offset(21.607495771747768, 14.453056844342509), + Offset(24.7737767806173, 14.057932401108161), + Offset(27.10430449252446, 14.251082413313888), + Offset(28.983161569626667, 14.523453864281027), + Offset(30.522324405162944, 14.87567392667065), + Offset(31.78834755303858, 15.258190649519511), + Offset(32.77412267739486, 15.671676438580297), + Offset(33.51945705758222, 16.074055635808506), + Offset(34.076869472346736, 16.430470541060192), + Offset(34.487791460425726, 16.723865182798942), + Offset(34.78319234229397, 16.947683599583385), + Offset(34.98566170116579, 17.1014090087272), + Offset(35.11162507250663, 17.187826301024863), + Offset(35.17286903360552, 17.211262451990294), + Offset(35.193602907732, 17.202741610971998), + Offset(35.2, 17.2), + ], + <Offset>[ + Offset(15.879999999999997, 29.480000000000004), + Offset(15.766297958836272, 29.30763136411491), + Offset(15.359828050515612, 28.6165169428199), + Offset(14.620432269750436, 26.826395088033706), + Offset(14.325066023528167, 22.463623564711135), + Offset(17.401362929963348, 16.757770203627054), + Offset(21.607495771747768, 14.453056844342509), + Offset(24.7737767806173, 14.057932401108161), + Offset(27.10430449252446, 14.251082413313888), + Offset(28.983161569626667, 14.523453864281027), + Offset(30.522324405162944, 14.87567392667065), + Offset(31.78834755303858, 15.258190649519511), + Offset(32.77412267739486, 15.671676438580297), + Offset(33.51945705758222, 16.074055635808506), + Offset(34.076869472346736, 16.430470541060192), + Offset(34.487791460425726, 16.723865182798942), + Offset(34.78319234229397, 16.947683599583385), + Offset(34.98566170116579, 17.1014090087272), + Offset(35.11162507250663, 17.187826301024863), + Offset(35.17286903360552, 17.211262451990294), + Offset(35.193602907732, 17.202741610971998), + Offset(35.2, 17.2), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(15.879999999999997, 29.480000000000004), + Offset(15.766297958836272, 29.30763136411491), + Offset(15.359828050515612, 28.6165169428199), + Offset(14.620432269750436, 26.826395088033706), + Offset(14.325066023528167, 22.463623564711135), + Offset(17.401362929963348, 16.757770203627054), + Offset(21.607495771747768, 14.453056844342509), + Offset(24.7737767806173, 14.057932401108161), + Offset(27.10430449252446, 14.251082413313888), + Offset(28.983161569626667, 14.523453864281027), + Offset(30.522324405162944, 14.87567392667065), + Offset(31.78834755303858, 15.258190649519511), + Offset(32.77412267739486, 15.671676438580297), + Offset(33.51945705758222, 16.074055635808506), + Offset(34.076869472346736, 16.430470541060192), + Offset(34.487791460425726, 16.723865182798942), + Offset(34.78319234229397, 16.947683599583385), + Offset(34.98566170116579, 17.1014090087272), + Offset(35.11162507250663, 17.187826301024863), + Offset(35.17286903360552, 17.211262451990294), + Offset(35.193602907732, 17.202741610971998), + Offset(35.2, 17.2), + ], + <Offset>[ + Offset(15.879999999999999, 16.720000000000002), + Offset(16.035249842176654, 16.55046612710212), + Offset(16.671136314247786, 15.924075466198163), + Offset(18.403259002874208, 14.640016548567434), + Offset(23.0335052578099, 13.137279902652425), + Offset(30.028928176510686, 14.910039070687475), + Offset(33.66959044824132, 18.81816999928563), + Offset(34.9945163389376, 22.121586779464756), + Offset(35.49811700796528, 24.6954285906963), + Offset(35.85582084730699, 26.831776518294085), + Offset(36.03678332094205, 28.656030985712853), + Offset(36.09614544233611, 30.213154328638034), + Offset(36.0090893140178, 31.476229121865174), + Offset(35.83725437847175, 32.46654819898878), + Offset(35.64332336305503, 33.22869754063639), + Offset(35.46448553745482, 33.80270212376941), + Offset(35.32117625078855, 34.22110211302876), + Offset(35.22322056797849, 34.50928803724247), + Offset(35.17402796304459, 34.6869030806399), + Offset(35.1733242404286, 34.768904647903355), + Offset(35.193602907732, 34.792689037407996), + Offset(35.2, 34.8), + ], + <Offset>[ + Offset(15.879999999999999, 16.720000000000002), + Offset(16.035249842176654, 16.55046612710212), + Offset(16.671136314247786, 15.924075466198163), + Offset(18.403259002874208, 14.640016548567434), + Offset(23.0335052578099, 13.137279902652425), + Offset(30.028928176510686, 14.910039070687475), + Offset(33.66959044824132, 18.81816999928563), + Offset(34.9945163389376, 22.121586779464756), + Offset(35.49811700796528, 24.6954285906963), + Offset(35.85582084730699, 26.831776518294085), + Offset(36.03678332094205, 28.656030985712853), + Offset(36.09614544233611, 30.213154328638034), + Offset(36.0090893140178, 31.476229121865174), + Offset(35.83725437847175, 32.46654819898878), + Offset(35.64332336305503, 33.22869754063639), + Offset(35.46448553745482, 33.80270212376941), + Offset(35.32117625078855, 34.22110211302876), + Offset(35.22322056797849, 34.50928803724247), + Offset(35.17402796304459, 34.6869030806399), + Offset(35.1733242404286, 34.768904647903355), + Offset(35.193602907732, 34.792689037407996), + Offset(35.2, 34.8), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.95473429086978, 26.294643953040044), + Offset(37.72034201765203, 27.428150364066923), + Offset(36.77771521052353, 30.06052753469164), + Offset(32.86770633230866, 35.01652324483241), + Offset(24.048041351653396, 38.14205402438172), + Offset(17.355303440951563, 36.48390994993835), + Offset(13.758359800160616, 33.75237437842877), + Offset(11.83452659356501, 31.211189693616653), + Offset(10.801411310099256, 29.079099978817517), + Offset(10.259029387048772, 27.34450693137431), + Offset(9.993407713414326, 25.953297856794705), + Offset(9.883312535673422, 24.848018298461085), + Offset(9.857878923767148, 23.97971539572578), + Offset(9.874779651667918, 23.308516008091793), + Offset(9.908648574481903, 22.80257985546041), + Offset(9.94452503300979, 22.436790656274262), + Offset(9.974012714390186, 22.191221223059088), + Offset(9.992956927188366, 22.049937345519467), + Offset(9.999948151863302, 22.000362970565853), + Offset(10.0, 22.0), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.95473429086978, 26.294643953040044), + Offset(37.72034201765203, 27.428150364066923), + Offset(36.77771521052353, 30.06052753469164), + Offset(32.86770633230866, 35.01652324483241), + Offset(24.048041351653396, 38.14205402438172), + Offset(17.355303440951563, 36.48390994993835), + Offset(13.758359800160616, 33.75237437842877), + Offset(11.83452659356501, 31.211189693616653), + Offset(10.801411310099256, 29.079099978817517), + Offset(10.259029387048772, 27.34450693137431), + Offset(9.993407713414326, 25.953297856794705), + Offset(9.883312535673422, 24.848018298461085), + Offset(9.857878923767148, 23.97971539572578), + Offset(9.874779651667918, 23.308516008091793), + Offset(9.908648574481903, 22.80257985546041), + Offset(9.94452503300979, 22.436790656274262), + Offset(9.974012714390186, 22.191221223059088), + Offset(9.992956927188366, 22.049937345519467), + Offset(9.999948151863302, 22.000362970565853), + Offset(10.0, 22.0), + Offset(10.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.0, 26.0), + Offset(37.95473429086978, 26.294643953040044), + Offset(37.72034201765203, 27.428150364066923), + Offset(36.77771521052353, 30.06052753469164), + Offset(32.86770633230866, 35.01652324483241), + Offset(24.048041351653396, 38.14205402438172), + Offset(17.355303440951563, 36.48390994993835), + Offset(13.758359800160616, 33.75237437842877), + Offset(11.83452659356501, 31.211189693616653), + Offset(10.801411310099256, 29.079099978817517), + Offset(10.259029387048772, 27.34450693137431), + Offset(9.993407713414326, 25.953297856794705), + Offset(9.883312535673422, 24.848018298461085), + Offset(9.857878923767148, 23.97971539572578), + Offset(9.874779651667918, 23.308516008091793), + Offset(9.908648574481903, 22.80257985546041), + Offset(9.94452503300979, 22.436790656274262), + Offset(9.974012714390186, 22.191221223059088), + Offset(9.992956927188366, 22.049937345519467), + Offset(9.999948151863302, 22.000362970565853), + Offset(10.0, 22.0), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.960954773913805, 25.704467406525104), + Offset(9.86858956111531, 24.55067141543833), + Offset(10.036445688183749, 21.759653825642616), + Offset(12.402375412430613, 15.907095458007305), + Offset(19.994105175585858, 10.437079715056495), + Offset(26.88341391070376, 10.154931411525617), + Offset(31.101309765885755, 11.770061340753355), + Offset(33.659721355964926, 13.670925340326216), + Offset(35.24849714823581, 15.428459187162975), + Offset(36.254869847648365, 16.94180157425432), + Offset(36.89940519521972, 18.202988217233095), + Offset(37.31457484218441, 19.233229943465915), + Offset(37.58211592238032, 20.059679318758054), + Offset(37.7538270521331, 20.708762934698896), + Offset(37.86297500896845, 21.20394493961294), + Offset(37.93095458455382, 21.565147979219446), + Offset(37.971405851728875, 21.809151185892528), + Offset(37.99277889315371, 21.950088087914054), + Offset(37.999948142452794, 21.99963703077851), + Offset(38.0, 22.0), + Offset(38.0, 22.0), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.960954773913805, 25.704467406525104), + Offset(9.86858956111531, 24.55067141543833), + Offset(10.036445688183749, 21.759653825642616), + Offset(12.402375412430613, 15.907095458007305), + Offset(19.994105175585858, 10.437079715056495), + Offset(26.88341391070376, 10.154931411525617), + Offset(31.101309765885755, 11.770061340753355), + Offset(33.659721355964926, 13.670925340326216), + Offset(35.24849714823581, 15.428459187162975), + Offset(36.254869847648365, 16.94180157425432), + Offset(36.89940519521972, 18.202988217233095), + Offset(37.31457484218441, 19.233229943465915), + Offset(37.58211592238032, 20.059679318758054), + Offset(37.7538270521331, 20.708762934698896), + Offset(37.86297500896845, 21.20394493961294), + Offset(37.93095458455382, 21.565147979219446), + Offset(37.971405851728875, 21.809151185892528), + Offset(37.99277889315371, 21.950088087914054), + Offset(37.999948142452794, 21.99963703077851), + Offset(38.0, 22.0), + Offset(38.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.0, 26.0), + Offset(9.960954773913805, 25.704467406525104), + Offset(9.86858956111531, 24.55067141543833), + Offset(10.036445688183749, 21.759653825642616), + Offset(12.402375412430613, 15.907095458007305), + Offset(19.994105175585858, 10.437079715056495), + Offset(26.88341391070376, 10.154931411525617), + Offset(31.101309765885755, 11.770061340753355), + Offset(33.659721355964926, 13.670925340326216), + Offset(35.24849714823581, 15.428459187162975), + Offset(36.254869847648365, 16.94180157425432), + Offset(36.89940519521972, 18.202988217233095), + Offset(37.31457484218441, 19.233229943465915), + Offset(37.58211592238032, 20.059679318758054), + Offset(37.7538270521331, 20.708762934698896), + Offset(37.86297500896845, 21.20394493961294), + Offset(37.93095458455382, 21.565147979219446), + Offset(37.971405851728875, 21.809151185892528), + Offset(37.99277889315371, 21.950088087914054), + Offset(37.999948142452794, 21.99963703077851), + Offset(38.0, 22.0), + Offset(38.0, 22.0), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.045265709130224, 21.705356046959963), + Offset(10.279657982347967, 20.571849635933084), + Offset(11.222284789476467, 17.93947246530836), + Offset(15.132293667691345, 12.983476755167583), + Offset(23.951958648346604, 9.857945975618275), + Offset(30.644696559048437, 11.516090050061646), + Offset(34.24164019983938, 14.247625621571231), + Offset(36.16547340643499, 16.788810306383347), + Offset(37.19858868990075, 18.920900021182483), + Offset(37.74097061295122, 20.65549306862569), + Offset(38.006592286585665, 22.046702143205295), + Offset(38.116687464326574, 23.151981701538915), + Offset(38.142121076232854, 24.02028460427422), + Offset(38.12522034833208, 24.691483991908207), + Offset(38.09135142551809, 25.19742014453959), + Offset(38.05547496699022, 25.563209343725738), + Offset(38.025987285609816, 25.808778776940912), + Offset(38.00704307281163, 25.950062654480533), + Offset(38.0000518481367, 25.999637029434147), + Offset(38.0, 26.0), + Offset(38.0, 26.0), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.045265709130224, 21.705356046959963), + Offset(10.279657982347967, 20.571849635933084), + Offset(11.222284789476467, 17.93947246530836), + Offset(15.132293667691345, 12.983476755167583), + Offset(23.951958648346604, 9.857945975618275), + Offset(30.644696559048437, 11.516090050061646), + Offset(34.24164019983938, 14.247625621571231), + Offset(36.16547340643499, 16.788810306383347), + Offset(37.19858868990075, 18.920900021182483), + Offset(37.74097061295122, 20.65549306862569), + Offset(38.006592286585665, 22.046702143205295), + Offset(38.116687464326574, 23.151981701538915), + Offset(38.142121076232854, 24.02028460427422), + Offset(38.12522034833208, 24.691483991908207), + Offset(38.09135142551809, 25.19742014453959), + Offset(38.05547496699022, 25.563209343725738), + Offset(38.025987285609816, 25.808778776940912), + Offset(38.00704307281163, 25.950062654480533), + Offset(38.0000518481367, 25.999637029434147), + Offset(38.0, 26.0), + Offset(38.0, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.0, 22.0), + Offset(10.045265709130224, 21.705356046959963), + Offset(10.279657982347967, 20.571849635933084), + Offset(11.222284789476467, 17.93947246530836), + Offset(15.132293667691345, 12.983476755167583), + Offset(23.951958648346604, 9.857945975618275), + Offset(30.644696559048437, 11.516090050061646), + Offset(34.24164019983938, 14.247625621571231), + Offset(36.16547340643499, 16.788810306383347), + Offset(37.19858868990075, 18.920900021182483), + Offset(37.74097061295122, 20.65549306862569), + Offset(38.006592286585665, 22.046702143205295), + Offset(38.116687464326574, 23.151981701538915), + Offset(38.142121076232854, 24.02028460427422), + Offset(38.12522034833208, 24.691483991908207), + Offset(38.09135142551809, 25.19742014453959), + Offset(38.05547496699022, 25.563209343725738), + Offset(38.025987285609816, 25.808778776940912), + Offset(38.00704307281163, 25.950062654480533), + Offset(38.0000518481367, 25.999637029434147), + Offset(38.0, 26.0), + Offset(38.0, 26.0), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.70535604695996, 37.95473429086978), + Offset(20.571849635933077, 37.72034201765204), + Offset(17.93947246530836, 36.77771521052353), + Offset(12.983476755167583, 32.86770633230866), + Offset(9.857945975618275, 24.048041351653396), + Offset(11.516090050061646, 17.355303440951563), + Offset(14.247625621571231, 13.758359800160616), + Offset(16.788810306383347, 11.83452659356501), + Offset(18.920900021182486, 10.801411310099253), + Offset(20.655493068625688, 10.259029387048775), + Offset(22.04670214320529, 9.99340771341433), + Offset(23.151981701538915, 9.883312535673422), + Offset(24.02028460427422, 9.857878923767148), + Offset(24.691483991908207, 9.874779651667918), + Offset(25.197420144539585, 9.908648574481907), + Offset(25.56320934372574, 9.944525033009786), + Offset(25.808778776940912, 9.974012714390186), + Offset(25.950062654480533, 9.992956927188366), + Offset(25.999637029434147, 9.999948151863302), + Offset(26.0, 10.0), + Offset(26.0, 10.0), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.70535604695996, 37.95473429086978), + Offset(20.571849635933077, 37.72034201765204), + Offset(17.93947246530836, 36.77771521052353), + Offset(12.983476755167583, 32.86770633230866), + Offset(9.857945975618275, 24.048041351653396), + Offset(11.516090050061646, 17.355303440951563), + Offset(14.247625621571231, 13.758359800160616), + Offset(16.788810306383347, 11.83452659356501), + Offset(18.920900021182486, 10.801411310099253), + Offset(20.655493068625688, 10.259029387048775), + Offset(22.04670214320529, 9.99340771341433), + Offset(23.151981701538915, 9.883312535673422), + Offset(24.02028460427422, 9.857878923767148), + Offset(24.691483991908207, 9.874779651667918), + Offset(25.197420144539585, 9.908648574481907), + Offset(25.56320934372574, 9.944525033009786), + Offset(25.808778776940912, 9.974012714390186), + Offset(25.950062654480533, 9.992956927188366), + Offset(25.999637029434147, 9.999948151863302), + Offset(26.0, 10.0), + Offset(26.0, 10.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.0, 38.0), + Offset(21.70535604695996, 37.95473429086978), + Offset(20.571849635933077, 37.72034201765204), + Offset(17.93947246530836, 36.77771521052353), + Offset(12.983476755167583, 32.86770633230866), + Offset(9.857945975618275, 24.048041351653396), + Offset(11.516090050061646, 17.355303440951563), + Offset(14.247625621571231, 13.758359800160616), + Offset(16.788810306383347, 11.83452659356501), + Offset(18.920900021182486, 10.801411310099253), + Offset(20.655493068625688, 10.259029387048775), + Offset(22.04670214320529, 9.99340771341433), + Offset(23.151981701538915, 9.883312535673422), + Offset(24.02028460427422, 9.857878923767148), + Offset(24.691483991908207, 9.874779651667918), + Offset(25.197420144539585, 9.908648574481907), + Offset(25.56320934372574, 9.944525033009786), + Offset(25.808778776940912, 9.974012714390186), + Offset(25.950062654480533, 9.992956927188366), + Offset(25.999637029434147, 9.999948151863302), + Offset(26.0, 10.0), + Offset(26.0, 10.0), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.2955325934749, 9.960954773913809), + Offset(23.449328584561673, 9.868589561115314), + Offset(26.240346174357384, 10.036445688183749), + Offset(32.092904541992695, 12.402375412430613), + Offset(37.5629202849435, 19.994105175585858), + Offset(37.84506858847438, 26.88341391070376), + Offset(36.22993865924664, 31.101309765885755), + Offset(34.329074659673786, 33.659721355964926), + Offset(32.57154081283703, 35.24849714823581), + Offset(31.058198425745676, 36.25486984764837), + Offset(29.7970117827669, 36.89940519521973), + Offset(28.766770056534085, 37.31457484218441), + Offset(27.940320681241946, 37.58211592238032), + Offset(27.291237065301104, 37.7538270521331), + Offset(26.796055060387058, 37.862975008968455), + Offset(26.434852020780554, 37.93095458455382), + Offset(26.190848814107472, 37.971405851728875), + Offset(26.049911912085946, 37.99277889315371), + Offset(26.00036296922149, 37.999948142452794), + Offset(26.0, 38.0), + Offset(26.0, 38.0), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.2955325934749, 9.960954773913809), + Offset(23.449328584561673, 9.868589561115314), + Offset(26.240346174357384, 10.036445688183749), + Offset(32.092904541992695, 12.402375412430613), + Offset(37.5629202849435, 19.994105175585858), + Offset(37.84506858847438, 26.88341391070376), + Offset(36.22993865924664, 31.101309765885755), + Offset(34.329074659673786, 33.659721355964926), + Offset(32.57154081283703, 35.24849714823581), + Offset(31.058198425745676, 36.25486984764837), + Offset(29.7970117827669, 36.89940519521973), + Offset(28.766770056534085, 37.31457484218441), + Offset(27.940320681241946, 37.58211592238032), + Offset(27.291237065301104, 37.7538270521331), + Offset(26.796055060387058, 37.862975008968455), + Offset(26.434852020780554, 37.93095458455382), + Offset(26.190848814107472, 37.971405851728875), + Offset(26.049911912085946, 37.99277889315371), + Offset(26.00036296922149, 37.999948142452794), + Offset(26.0, 38.0), + Offset(26.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.0, 10.0), + Offset(22.2955325934749, 9.960954773913809), + Offset(23.449328584561673, 9.868589561115314), + Offset(26.240346174357384, 10.036445688183749), + Offset(32.092904541992695, 12.402375412430613), + Offset(37.5629202849435, 19.994105175585858), + Offset(37.84506858847438, 26.88341391070376), + Offset(36.22993865924664, 31.101309765885755), + Offset(34.329074659673786, 33.659721355964926), + Offset(32.57154081283703, 35.24849714823581), + Offset(31.058198425745676, 36.25486984764837), + Offset(29.7970117827669, 36.89940519521973), + Offset(28.766770056534085, 37.31457484218441), + Offset(27.940320681241946, 37.58211592238032), + Offset(27.291237065301104, 37.7538270521331), + Offset(26.796055060387058, 37.862975008968455), + Offset(26.434852020780554, 37.93095458455382), + Offset(26.190848814107472, 37.971405851728875), + Offset(26.049911912085946, 37.99277889315371), + Offset(26.00036296922149, 37.999948142452794), + Offset(26.0, 38.0), + Offset(26.0, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.29464395304004, 10.045265709130227), + Offset(27.428150364066923, 10.27965798234797), + Offset(30.06052753469164, 11.222284789476467), + Offset(35.01652324483241, 15.132293667691345), + Offset(38.14205402438172, 23.951958648346604), + Offset(36.48390994993835, 30.644696559048437), + Offset(33.75237437842877, 34.24164019983938), + Offset(31.211189693616653, 36.16547340643499), + Offset(29.07909997881752, 37.198588689900745), + Offset(27.344506931374305, 37.74097061295122), + Offset(25.953297856794702, 38.00659228658567), + Offset(24.848018298461085, 38.116687464326574), + Offset(23.97971539572578, 38.142121076232854), + Offset(23.308516008091793, 38.12522034833208), + Offset(22.802579855460408, 38.09135142551809), + Offset(22.436790656274265, 38.05547496699022), + Offset(22.191221223059088, 38.025987285609816), + Offset(22.049937345519467, 38.00704307281163), + Offset(22.000362970565853, 38.0000518481367), + Offset(22.0, 38.0), + Offset(22.0, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.29464395304004, 10.045265709130227), + Offset(27.428150364066923, 10.27965798234797), + Offset(30.06052753469164, 11.222284789476467), + Offset(35.01652324483241, 15.132293667691345), + Offset(38.14205402438172, 23.951958648346604), + Offset(36.48390994993835, 30.644696559048437), + Offset(33.75237437842877, 34.24164019983938), + Offset(31.211189693616653, 36.16547340643499), + Offset(29.07909997881752, 37.198588689900745), + Offset(27.344506931374305, 37.74097061295122), + Offset(25.953297856794702, 38.00659228658567), + Offset(24.848018298461085, 38.116687464326574), + Offset(23.97971539572578, 38.142121076232854), + Offset(23.308516008091793, 38.12522034833208), + Offset(22.802579855460408, 38.09135142551809), + Offset(22.436790656274265, 38.05547496699022), + Offset(22.191221223059088, 38.025987285609816), + Offset(22.049937345519467, 38.00704307281163), + Offset(22.000362970565853, 38.0000518481367), + Offset(22.0, 38.0), + Offset(22.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.0, 10.0), + Offset(26.29464395304004, 10.045265709130227), + Offset(27.428150364066923, 10.27965798234797), + Offset(30.06052753469164, 11.222284789476467), + Offset(35.01652324483241, 15.132293667691345), + Offset(38.14205402438172, 23.951958648346604), + Offset(36.48390994993835, 30.644696559048437), + Offset(33.75237437842877, 34.24164019983938), + Offset(31.211189693616653, 36.16547340643499), + Offset(29.07909997881752, 37.198588689900745), + Offset(27.344506931374305, 37.74097061295122), + Offset(25.953297856794702, 38.00659228658567), + Offset(24.848018298461085, 38.116687464326574), + Offset(23.97971539572578, 38.142121076232854), + Offset(23.308516008091793, 38.12522034833208), + Offset(22.802579855460408, 38.09135142551809), + Offset(22.436790656274265, 38.05547496699022), + Offset(22.191221223059088, 38.025987285609816), + Offset(22.049937345519467, 38.00704307281163), + Offset(22.000362970565853, 38.0000518481367), + Offset(22.0, 38.0), + Offset(22.0, 38.0), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 0.857142857143, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.95473429086978, 26.294643953040044), + Offset(37.72034201765203, 27.428150364066923), + Offset(36.77771521052353, 30.06052753469164), + Offset(32.86770633230866, 35.01652324483241), + Offset(24.048041351653396, 38.14205402438172), + Offset(17.355303440951563, 36.48390994993835), + Offset(13.758359800160616, 33.75237437842877), + Offset(11.83452659356501, 31.211189693616653), + Offset(10.801411310099256, 29.079099978817517), + Offset(10.259029387048772, 27.34450693137431), + Offset(9.993407713414326, 25.953297856794705), + Offset(9.883312535673422, 24.848018298461085), + Offset(9.857878923767148, 23.97971539572578), + Offset(9.874779651667918, 23.308516008091793), + Offset(9.908648574481903, 22.80257985546041), + Offset(9.94452503300979, 22.436790656274262), + Offset(9.974012714390186, 22.191221223059088), + Offset(9.992956927188366, 22.049937345519467), + Offset(9.999948151863302, 22.000362970565853), + Offset(10.0, 22.0), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.95473429086978, 26.294643953040044), + Offset(37.72034201765203, 27.428150364066923), + Offset(36.77771521052353, 30.06052753469164), + Offset(32.86770633230866, 35.01652324483241), + Offset(24.048041351653396, 38.14205402438172), + Offset(17.355303440951563, 36.48390994993835), + Offset(13.758359800160616, 33.75237437842877), + Offset(11.83452659356501, 31.211189693616653), + Offset(10.801411310099256, 29.079099978817517), + Offset(10.259029387048772, 27.34450693137431), + Offset(9.993407713414326, 25.953297856794705), + Offset(9.883312535673422, 24.848018298461085), + Offset(9.857878923767148, 23.97971539572578), + Offset(9.874779651667918, 23.308516008091793), + Offset(9.908648574481903, 22.80257985546041), + Offset(9.94452503300979, 22.436790656274262), + Offset(9.974012714390186, 22.191221223059088), + Offset(9.992956927188366, 22.049937345519467), + Offset(9.999948151863302, 22.000362970565853), + Offset(10.0, 22.0), + Offset(10.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.0, 26.0), + Offset(37.95473429086978, 26.294643953040044), + Offset(37.72034201765203, 27.428150364066923), + Offset(36.77771521052353, 30.06052753469164), + Offset(32.86770633230866, 35.01652324483241), + Offset(24.048041351653396, 38.14205402438172), + Offset(17.355303440951563, 36.48390994993835), + Offset(13.758359800160616, 33.75237437842877), + Offset(11.83452659356501, 31.211189693616653), + Offset(10.801411310099256, 29.079099978817517), + Offset(10.259029387048772, 27.34450693137431), + Offset(9.993407713414326, 25.953297856794705), + Offset(9.883312535673422, 24.848018298461085), + Offset(9.857878923767148, 23.97971539572578), + Offset(9.874779651667918, 23.308516008091793), + Offset(9.908648574481903, 22.80257985546041), + Offset(9.94452503300979, 22.436790656274262), + Offset(9.974012714390186, 22.191221223059088), + Offset(9.992956927188366, 22.049937345519467), + Offset(9.999948151863302, 22.000362970565853), + Offset(10.0, 22.0), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.960954773913805, 25.704467406525104), + Offset(9.86858956111531, 24.55067141543833), + Offset(10.036445688183749, 21.759653825642616), + Offset(12.402375412430613, 15.907095458007305), + Offset(19.994105175585858, 10.437079715056495), + Offset(26.88341391070376, 10.154931411525617), + Offset(31.101309765885755, 11.770061340753355), + Offset(33.659721355964926, 13.670925340326216), + Offset(35.24849714823581, 15.428459187162975), + Offset(36.254869847648365, 16.94180157425432), + Offset(36.89940519521972, 18.202988217233095), + Offset(37.31457484218441, 19.233229943465915), + Offset(37.58211592238032, 20.059679318758054), + Offset(37.7538270521331, 20.708762934698896), + Offset(37.86297500896845, 21.20394493961294), + Offset(37.93095458455382, 21.565147979219446), + Offset(37.971405851728875, 21.809151185892528), + Offset(37.99277889315371, 21.950088087914054), + Offset(37.999948142452794, 21.99963703077851), + Offset(38.0, 22.0), + Offset(38.0, 22.0), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.960954773913805, 25.704467406525104), + Offset(9.86858956111531, 24.55067141543833), + Offset(10.036445688183749, 21.759653825642616), + Offset(12.402375412430613, 15.907095458007305), + Offset(19.994105175585858, 10.437079715056495), + Offset(26.88341391070376, 10.154931411525617), + Offset(31.101309765885755, 11.770061340753355), + Offset(33.659721355964926, 13.670925340326216), + Offset(35.24849714823581, 15.428459187162975), + Offset(36.254869847648365, 16.94180157425432), + Offset(36.89940519521972, 18.202988217233095), + Offset(37.31457484218441, 19.233229943465915), + Offset(37.58211592238032, 20.059679318758054), + Offset(37.7538270521331, 20.708762934698896), + Offset(37.86297500896845, 21.20394493961294), + Offset(37.93095458455382, 21.565147979219446), + Offset(37.971405851728875, 21.809151185892528), + Offset(37.99277889315371, 21.950088087914054), + Offset(37.999948142452794, 21.99963703077851), + Offset(38.0, 22.0), + Offset(38.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.0, 26.0), + Offset(9.960954773913805, 25.704467406525104), + Offset(9.86858956111531, 24.55067141543833), + Offset(10.036445688183749, 21.759653825642616), + Offset(12.402375412430613, 15.907095458007305), + Offset(19.994105175585858, 10.437079715056495), + Offset(26.88341391070376, 10.154931411525617), + Offset(31.101309765885755, 11.770061340753355), + Offset(33.659721355964926, 13.670925340326216), + Offset(35.24849714823581, 15.428459187162975), + Offset(36.254869847648365, 16.94180157425432), + Offset(36.89940519521972, 18.202988217233095), + Offset(37.31457484218441, 19.233229943465915), + Offset(37.58211592238032, 20.059679318758054), + Offset(37.7538270521331, 20.708762934698896), + Offset(37.86297500896845, 21.20394493961294), + Offset(37.93095458455382, 21.565147979219446), + Offset(37.971405851728875, 21.809151185892528), + Offset(37.99277889315371, 21.950088087914054), + Offset(37.999948142452794, 21.99963703077851), + Offset(38.0, 22.0), + Offset(38.0, 22.0), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.045265709130224, 21.705356046959963), + Offset(10.279657982347967, 20.571849635933084), + Offset(11.222284789476467, 17.93947246530836), + Offset(15.132293667691345, 12.983476755167583), + Offset(23.951958648346604, 9.857945975618275), + Offset(30.644696559048437, 11.516090050061646), + Offset(34.24164019983938, 14.247625621571231), + Offset(36.16547340643499, 16.788810306383347), + Offset(37.19858868990075, 18.920900021182483), + Offset(37.74097061295122, 20.65549306862569), + Offset(38.006592286585665, 22.046702143205295), + Offset(38.116687464326574, 23.151981701538915), + Offset(38.142121076232854, 24.02028460427422), + Offset(38.12522034833208, 24.691483991908207), + Offset(38.09135142551809, 25.19742014453959), + Offset(38.05547496699022, 25.563209343725738), + Offset(38.025987285609816, 25.808778776940912), + Offset(38.00704307281163, 25.950062654480533), + Offset(38.0000518481367, 25.999637029434147), + Offset(38.0, 26.0), + Offset(38.0, 26.0), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.045265709130224, 21.705356046959963), + Offset(10.279657982347967, 20.571849635933084), + Offset(11.222284789476467, 17.93947246530836), + Offset(15.132293667691345, 12.983476755167583), + Offset(23.951958648346604, 9.857945975618275), + Offset(30.644696559048437, 11.516090050061646), + Offset(34.24164019983938, 14.247625621571231), + Offset(36.16547340643499, 16.788810306383347), + Offset(37.19858868990075, 18.920900021182483), + Offset(37.74097061295122, 20.65549306862569), + Offset(38.006592286585665, 22.046702143205295), + Offset(38.116687464326574, 23.151981701538915), + Offset(38.142121076232854, 24.02028460427422), + Offset(38.12522034833208, 24.691483991908207), + Offset(38.09135142551809, 25.19742014453959), + Offset(38.05547496699022, 25.563209343725738), + Offset(38.025987285609816, 25.808778776940912), + Offset(38.00704307281163, 25.950062654480533), + Offset(38.0000518481367, 25.999637029434147), + Offset(38.0, 26.0), + Offset(38.0, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.0, 22.0), + Offset(10.045265709130224, 21.705356046959963), + Offset(10.279657982347967, 20.571849635933084), + Offset(11.222284789476467, 17.93947246530836), + Offset(15.132293667691345, 12.983476755167583), + Offset(23.951958648346604, 9.857945975618275), + Offset(30.644696559048437, 11.516090050061646), + Offset(34.24164019983938, 14.247625621571231), + Offset(36.16547340643499, 16.788810306383347), + Offset(37.19858868990075, 18.920900021182483), + Offset(37.74097061295122, 20.65549306862569), + Offset(38.006592286585665, 22.046702143205295), + Offset(38.116687464326574, 23.151981701538915), + Offset(38.142121076232854, 24.02028460427422), + Offset(38.12522034833208, 24.691483991908207), + Offset(38.09135142551809, 25.19742014453959), + Offset(38.05547496699022, 25.563209343725738), + Offset(38.025987285609816, 25.808778776940912), + Offset(38.00704307281163, 25.950062654480533), + Offset(38.0000518481367, 25.999637029434147), + Offset(38.0, 26.0), + Offset(38.0, 26.0), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.039045226086195, 22.295532593474903), + Offset(38.131410438884686, 23.449328584561677), + Offset(37.96355431181625, 26.240346174357384), + Offset(35.597624587569385, 32.092904541992695), + Offset(28.005894824414142, 37.5629202849435), + Offset(21.11658608929624, 37.84506858847438), + Offset(16.898690234114245, 36.22993865924664), + Offset(14.340278644035072, 34.329074659673786), + Offset(12.751502851764192, 32.57154081283703), + Offset(11.745130152351626, 31.05819842574568), + Offset(11.10059480478027, 29.797011782766905), + Offset(10.68542515781559, 28.766770056534085), + Offset(10.41788407761968, 27.940320681241946), + Offset(10.246172947866903, 27.291237065301104), + Offset(10.137024991031543, 26.79605506038706), + Offset(10.06904541544619, 26.434852020780554), + Offset(10.028594148271123, 26.190848814107472), + Offset(10.007221106846284, 26.049911912085946), + Offset(10.000051857547207, 26.00036296922149), + Offset(10.0, 26.0), + Offset(10.0, 26.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 0.857142857143, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.70535604695996, 37.95473429086978), + Offset(20.571849635933077, 37.72034201765204), + Offset(17.93947246530836, 36.77771521052353), + Offset(12.983476755167583, 32.86770633230866), + Offset(9.857945975618275, 24.048041351653396), + Offset(11.516090050061646, 17.355303440951563), + Offset(14.247625621571231, 13.758359800160616), + Offset(16.788810306383347, 11.83452659356501), + Offset(18.920900021182486, 10.801411310099253), + Offset(20.655493068625688, 10.259029387048775), + Offset(22.04670214320529, 9.99340771341433), + Offset(23.151981701538915, 9.883312535673422), + Offset(24.02028460427422, 9.857878923767148), + Offset(24.691483991908207, 9.874779651667918), + Offset(25.197420144539585, 9.908648574481907), + Offset(25.56320934372574, 9.944525033009786), + Offset(25.808778776940912, 9.974012714390186), + Offset(25.950062654480533, 9.992956927188366), + Offset(25.999637029434147, 9.999948151863302), + Offset(26.0, 10.0), + Offset(26.0, 10.0), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.70535604695996, 37.95473429086978), + Offset(20.571849635933077, 37.72034201765204), + Offset(17.93947246530836, 36.77771521052353), + Offset(12.983476755167583, 32.86770633230866), + Offset(9.857945975618275, 24.048041351653396), + Offset(11.516090050061646, 17.355303440951563), + Offset(14.247625621571231, 13.758359800160616), + Offset(16.788810306383347, 11.83452659356501), + Offset(18.920900021182486, 10.801411310099253), + Offset(20.655493068625688, 10.259029387048775), + Offset(22.04670214320529, 9.99340771341433), + Offset(23.151981701538915, 9.883312535673422), + Offset(24.02028460427422, 9.857878923767148), + Offset(24.691483991908207, 9.874779651667918), + Offset(25.197420144539585, 9.908648574481907), + Offset(25.56320934372574, 9.944525033009786), + Offset(25.808778776940912, 9.974012714390186), + Offset(25.950062654480533, 9.992956927188366), + Offset(25.999637029434147, 9.999948151863302), + Offset(26.0, 10.0), + Offset(26.0, 10.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.0, 38.0), + Offset(21.70535604695996, 37.95473429086978), + Offset(20.571849635933077, 37.72034201765204), + Offset(17.93947246530836, 36.77771521052353), + Offset(12.983476755167583, 32.86770633230866), + Offset(9.857945975618275, 24.048041351653396), + Offset(11.516090050061646, 17.355303440951563), + Offset(14.247625621571231, 13.758359800160616), + Offset(16.788810306383347, 11.83452659356501), + Offset(18.920900021182486, 10.801411310099253), + Offset(20.655493068625688, 10.259029387048775), + Offset(22.04670214320529, 9.99340771341433), + Offset(23.151981701538915, 9.883312535673422), + Offset(24.02028460427422, 9.857878923767148), + Offset(24.691483991908207, 9.874779651667918), + Offset(25.197420144539585, 9.908648574481907), + Offset(25.56320934372574, 9.944525033009786), + Offset(25.808778776940912, 9.974012714390186), + Offset(25.950062654480533, 9.992956927188366), + Offset(25.999637029434147, 9.999948151863302), + Offset(26.0, 10.0), + Offset(26.0, 10.0), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.2955325934749, 9.960954773913809), + Offset(23.449328584561673, 9.868589561115314), + Offset(26.240346174357384, 10.036445688183749), + Offset(32.092904541992695, 12.402375412430613), + Offset(37.5629202849435, 19.994105175585858), + Offset(37.84506858847438, 26.88341391070376), + Offset(36.22993865924664, 31.101309765885755), + Offset(34.329074659673786, 33.659721355964926), + Offset(32.57154081283703, 35.24849714823581), + Offset(31.058198425745676, 36.25486984764837), + Offset(29.7970117827669, 36.89940519521973), + Offset(28.766770056534085, 37.31457484218441), + Offset(27.940320681241946, 37.58211592238032), + Offset(27.291237065301104, 37.7538270521331), + Offset(26.796055060387058, 37.862975008968455), + Offset(26.434852020780554, 37.93095458455382), + Offset(26.190848814107472, 37.971405851728875), + Offset(26.049911912085946, 37.99277889315371), + Offset(26.00036296922149, 37.999948142452794), + Offset(26.0, 38.0), + Offset(26.0, 38.0), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.2955325934749, 9.960954773913809), + Offset(23.449328584561673, 9.868589561115314), + Offset(26.240346174357384, 10.036445688183749), + Offset(32.092904541992695, 12.402375412430613), + Offset(37.5629202849435, 19.994105175585858), + Offset(37.84506858847438, 26.88341391070376), + Offset(36.22993865924664, 31.101309765885755), + Offset(34.329074659673786, 33.659721355964926), + Offset(32.57154081283703, 35.24849714823581), + Offset(31.058198425745676, 36.25486984764837), + Offset(29.7970117827669, 36.89940519521973), + Offset(28.766770056534085, 37.31457484218441), + Offset(27.940320681241946, 37.58211592238032), + Offset(27.291237065301104, 37.7538270521331), + Offset(26.796055060387058, 37.862975008968455), + Offset(26.434852020780554, 37.93095458455382), + Offset(26.190848814107472, 37.971405851728875), + Offset(26.049911912085946, 37.99277889315371), + Offset(26.00036296922149, 37.999948142452794), + Offset(26.0, 38.0), + Offset(26.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.0, 10.0), + Offset(22.2955325934749, 9.960954773913809), + Offset(23.449328584561673, 9.868589561115314), + Offset(26.240346174357384, 10.036445688183749), + Offset(32.092904541992695, 12.402375412430613), + Offset(37.5629202849435, 19.994105175585858), + Offset(37.84506858847438, 26.88341391070376), + Offset(36.22993865924664, 31.101309765885755), + Offset(34.329074659673786, 33.659721355964926), + Offset(32.57154081283703, 35.24849714823581), + Offset(31.058198425745676, 36.25486984764837), + Offset(29.7970117827669, 36.89940519521973), + Offset(28.766770056534085, 37.31457484218441), + Offset(27.940320681241946, 37.58211592238032), + Offset(27.291237065301104, 37.7538270521331), + Offset(26.796055060387058, 37.862975008968455), + Offset(26.434852020780554, 37.93095458455382), + Offset(26.190848814107472, 37.971405851728875), + Offset(26.049911912085946, 37.99277889315371), + Offset(26.00036296922149, 37.999948142452794), + Offset(26.0, 38.0), + Offset(26.0, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.29464395304004, 10.045265709130227), + Offset(27.428150364066923, 10.27965798234797), + Offset(30.06052753469164, 11.222284789476467), + Offset(35.01652324483241, 15.132293667691345), + Offset(38.14205402438172, 23.951958648346604), + Offset(36.48390994993835, 30.644696559048437), + Offset(33.75237437842877, 34.24164019983938), + Offset(31.211189693616653, 36.16547340643499), + Offset(29.07909997881752, 37.198588689900745), + Offset(27.344506931374305, 37.74097061295122), + Offset(25.953297856794702, 38.00659228658567), + Offset(24.848018298461085, 38.116687464326574), + Offset(23.97971539572578, 38.142121076232854), + Offset(23.308516008091793, 38.12522034833208), + Offset(22.802579855460408, 38.09135142551809), + Offset(22.436790656274265, 38.05547496699022), + Offset(22.191221223059088, 38.025987285609816), + Offset(22.049937345519467, 38.00704307281163), + Offset(22.000362970565853, 38.0000518481367), + Offset(22.0, 38.0), + Offset(22.0, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.29464395304004, 10.045265709130227), + Offset(27.428150364066923, 10.27965798234797), + Offset(30.06052753469164, 11.222284789476467), + Offset(35.01652324483241, 15.132293667691345), + Offset(38.14205402438172, 23.951958648346604), + Offset(36.48390994993835, 30.644696559048437), + Offset(33.75237437842877, 34.24164019983938), + Offset(31.211189693616653, 36.16547340643499), + Offset(29.07909997881752, 37.198588689900745), + Offset(27.344506931374305, 37.74097061295122), + Offset(25.953297856794702, 38.00659228658567), + Offset(24.848018298461085, 38.116687464326574), + Offset(23.97971539572578, 38.142121076232854), + Offset(23.308516008091793, 38.12522034833208), + Offset(22.802579855460408, 38.09135142551809), + Offset(22.436790656274265, 38.05547496699022), + Offset(22.191221223059088, 38.025987285609816), + Offset(22.049937345519467, 38.00704307281163), + Offset(22.000362970565853, 38.0000518481367), + Offset(22.0, 38.0), + Offset(22.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.0, 10.0), + Offset(26.29464395304004, 10.045265709130227), + Offset(27.428150364066923, 10.27965798234797), + Offset(30.06052753469164, 11.222284789476467), + Offset(35.01652324483241, 15.132293667691345), + Offset(38.14205402438172, 23.951958648346604), + Offset(36.48390994993835, 30.644696559048437), + Offset(33.75237437842877, 34.24164019983938), + Offset(31.211189693616653, 36.16547340643499), + Offset(29.07909997881752, 37.198588689900745), + Offset(27.344506931374305, 37.74097061295122), + Offset(25.953297856794702, 38.00659228658567), + Offset(24.848018298461085, 38.116687464326574), + Offset(23.97971539572578, 38.142121076232854), + Offset(23.308516008091793, 38.12522034833208), + Offset(22.802579855460408, 38.09135142551809), + Offset(22.436790656274265, 38.05547496699022), + Offset(22.191221223059088, 38.025987285609816), + Offset(22.049937345519467, 38.00704307281163), + Offset(22.000362970565853, 38.0000518481367), + Offset(22.0, 38.0), + Offset(22.0, 38.0), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.7044674065251, 38.0390452260862), + Offset(24.550671415438327, 38.13141043888469), + Offset(21.759653825642616, 37.96355431181625), + Offset(15.907095458007305, 35.597624587569385), + Offset(10.437079715056495, 28.005894824414142), + Offset(10.154931411525617, 21.11658608929624), + Offset(11.770061340753355, 16.898690234114245), + Offset(13.670925340326216, 14.340278644035072), + Offset(15.428459187162979, 12.751502851764188), + Offset(16.941801574254317, 11.74513015235163), + Offset(18.20298821723309, 11.100594804780274), + Offset(19.233229943465915, 10.68542515781559), + Offset(20.059679318758054, 10.41788407761968), + Offset(20.708762934698896, 10.246172947866903), + Offset(21.203944939612935, 10.137024991031547), + Offset(21.565147979219454, 10.069045415446187), + Offset(21.809151185892528, 10.028594148271123), + Offset(21.950088087914054, 10.007221106846284), + Offset(21.99963703077851, 10.000051857547207), + Offset(22.0, 10.0), + Offset(22.0, 10.0), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/arrow_menu.g.dart b/packages/material_ui/lib/src/animated_icons/data/arrow_menu.g.dart new file mode 100644 index 000000000000..11cc82d35763 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/arrow_menu.g.dart @@ -0,0 +1,1015 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$arrow_menu = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(39.94921875, 22.0), + Offset(39.999299444085175, 22.321134814951655), + Offset(40.123697128128484, 23.4571221977224), + Offset(40.127941008338254, 25.772984385261328), + Offset(39.36673145070893, 29.679631356759792), + Offset(36.03000494036236, 35.505984938867314), + Offset(30.483797891740366, 39.621714579092405), + Offset(23.719442126984134, 41.17597729654657), + Offset(17.808469902722187, 40.26388356552361), + Offset(13.571377486425117, 38.15001206462978), + Offset(10.73996518894534, 35.74540806580379), + Offset(8.908677157504997, 33.47648684184537), + Offset(7.746937054898215, 31.506333856475468), + Offset(7.021336564573183, 29.876779670456198), + Offset(6.575233382277915, 28.57846974488041), + Offset(6.306002369153553, 27.58317306758941), + Offset(6.147597126684428, 26.85785338221681), + Offset(6.058558318339831, 26.3706514388743), + Offset(6.013954859968965, 26.093012124442712), + Offset(6.000028695609693, 26.000194701816614), + Offset(6.0, 26.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(39.94921875, 22.0), + Offset(39.999299444085175, 22.321134814951655), + Offset(40.123697128128484, 23.4571221977224), + Offset(40.127941008338254, 25.772984385261328), + Offset(39.36673145070893, 29.679631356759792), + Offset(36.03000494036236, 35.505984938867314), + Offset(30.483797891740366, 39.621714579092405), + Offset(23.719442126984134, 41.17597729654657), + Offset(17.808469902722187, 40.26388356552361), + Offset(13.571377486425117, 38.15001206462978), + Offset(10.73996518894534, 35.74540806580379), + Offset(8.908677157504997, 33.47648684184537), + Offset(7.746937054898215, 31.506333856475468), + Offset(7.021336564573183, 29.876779670456198), + Offset(6.575233382277915, 28.57846974488041), + Offset(6.306002369153553, 27.58317306758941), + Offset(6.147597126684428, 26.85785338221681), + Offset(6.058558318339831, 26.3706514388743), + Offset(6.013954859968965, 26.093012124442712), + Offset(6.000028695609693, 26.000194701816614), + Offset(6.0, 26.0), + ], + <Offset>[ + Offset(12.562500000000004, 21.999999999999996), + Offset(12.563028263179644, 21.76974679627851), + Offset(12.601915221773575, 20.955868783506492), + Offset(12.859988895862408, 19.29992503795225), + Offset(13.868967368048864, 16.521093282496043), + Offset(17.119246052043927, 12.463158561907132), + Offset(22.085512547276693, 9.843640931525094), + Offset(27.97135871762438, 9.40112844480027), + Offset(33.018872763119475, 10.9710702242868), + Offset(36.55408111158449, 13.438130103560162), + Offset(38.838028231283474, 16.033718047868426), + Offset(40.24471329470705, 18.407336125221466), + Offset(41.07692592252046, 20.434907268146556), + Offset(41.54755362886814, 22.095337098743826), + Offset(41.7985009918588, 23.40952601819154), + Offset(41.921690170757245, 24.41237661448199), + Offset(41.975185653795926, 25.14085315878499), + Offset(41.994340767445394, 25.629108510947514), + Offset(41.999299914344064, 25.906972804802937), + Offset(41.99999903720365, 25.999805298117412), + Offset(42.0, 26.0), + ], + <Offset>[ + Offset(12.562500000000004, 21.999999999999996), + Offset(12.563028263179644, 21.76974679627851), + Offset(12.601915221773575, 20.955868783506492), + Offset(12.859988895862408, 19.29992503795225), + Offset(13.868967368048864, 16.521093282496043), + Offset(17.119246052043927, 12.463158561907132), + Offset(22.085512547276693, 9.843640931525094), + Offset(27.97135871762438, 9.40112844480027), + Offset(33.018872763119475, 10.9710702242868), + Offset(36.55408111158449, 13.438130103560162), + Offset(38.838028231283474, 16.033718047868426), + Offset(40.24471329470705, 18.407336125221466), + Offset(41.07692592252046, 20.434907268146556), + Offset(41.54755362886814, 22.095337098743826), + Offset(41.7985009918588, 23.40952601819154), + Offset(41.921690170757245, 24.41237661448199), + Offset(41.975185653795926, 25.14085315878499), + Offset(41.994340767445394, 25.629108510947514), + Offset(41.999299914344064, 25.906972804802937), + Offset(41.99999903720365, 25.999805298117412), + Offset(42.0, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.562500000000004, 21.999999999999996), + Offset(12.563028263179644, 21.76974679627851), + Offset(12.601915221773575, 20.955868783506492), + Offset(12.859988895862408, 19.29992503795225), + Offset(13.868967368048864, 16.521093282496043), + Offset(17.119246052043927, 12.463158561907132), + Offset(22.085512547276693, 9.843640931525094), + Offset(27.97135871762438, 9.40112844480027), + Offset(33.018872763119475, 10.9710702242868), + Offset(36.55408111158449, 13.438130103560162), + Offset(38.838028231283474, 16.033718047868426), + Offset(40.24471329470705, 18.407336125221466), + Offset(41.07692592252046, 20.434907268146556), + Offset(41.54755362886814, 22.095337098743826), + Offset(41.7985009918588, 23.40952601819154), + Offset(41.921690170757245, 24.41237661448199), + Offset(41.975185653795926, 25.14085315878499), + Offset(41.994340767445394, 25.629108510947514), + Offset(41.999299914344064, 25.906972804802937), + Offset(41.99999903720365, 25.999805298117412), + Offset(42.0, 26.0), + ], + <Offset>[ + Offset(12.562500000000004, 25.999999999999996), + Offset(12.482656306166858, 25.76893925832956), + Offset(12.239876568700678, 24.939451092644983), + Offset(11.936115204605002, 23.191770024922107), + Offset(12.03457162794638, 20.07566671241613), + Offset(14.027204238592981, 15.000731698561715), + Offset(18.235691601462104, 10.929402730985391), + Offset(24.00669715972937, 8.870601601606145), + Offset(29.46892180503868, 9.127744915172523), + Offset(33.625033100689926, 10.714038007122415), + Offset(36.54081321903698, 12.759148887275034), + Offset(38.511185280577465, 14.802494849349817), + Offset(39.815969078409935, 16.638858290947915), + Offset(40.668101748647686, 18.193214036822702), + Offset(41.21772917473674, 19.451912583754474), + Offset(41.566980772751144, 20.42813499995029), + Offset(41.783709535999954, 21.145438675115766), + Offset(41.9118174384751, 21.629959864025817), + Offset(41.978620736948194, 21.907026258707322), + Offset(41.99995577009031, 21.99980529835142), + Offset(42.0, 22.0), + ], + <Offset>[ + Offset(12.562500000000004, 25.999999999999996), + Offset(12.482656306166858, 25.76893925832956), + Offset(12.239876568700678, 24.939451092644983), + Offset(11.936115204605002, 23.191770024922107), + Offset(12.03457162794638, 20.07566671241613), + Offset(14.027204238592981, 15.000731698561715), + Offset(18.235691601462104, 10.929402730985391), + Offset(24.00669715972937, 8.870601601606145), + Offset(29.46892180503868, 9.127744915172523), + Offset(33.625033100689926, 10.714038007122415), + Offset(36.54081321903698, 12.759148887275034), + Offset(38.511185280577465, 14.802494849349817), + Offset(39.815969078409935, 16.638858290947915), + Offset(40.668101748647686, 18.193214036822702), + Offset(41.21772917473674, 19.451912583754474), + Offset(41.566980772751144, 20.42813499995029), + Offset(41.783709535999954, 21.145438675115766), + Offset(41.9118174384751, 21.629959864025817), + Offset(41.978620736948194, 21.907026258707322), + Offset(41.99995577009031, 21.99980529835142), + Offset(42.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.562500000000004, 25.999999999999996), + Offset(12.482656306166858, 25.76893925832956), + Offset(12.239876568700678, 24.939451092644983), + Offset(11.936115204605002, 23.191770024922107), + Offset(12.03457162794638, 20.07566671241613), + Offset(14.027204238592981, 15.000731698561715), + Offset(18.235691601462104, 10.929402730985391), + Offset(24.00669715972937, 8.870601601606145), + Offset(29.46892180503868, 9.127744915172523), + Offset(33.625033100689926, 10.714038007122415), + Offset(36.54081321903698, 12.759148887275034), + Offset(38.511185280577465, 14.802494849349817), + Offset(39.815969078409935, 16.638858290947915), + Offset(40.668101748647686, 18.193214036822702), + Offset(41.21772917473674, 19.451912583754474), + Offset(41.566980772751144, 20.42813499995029), + Offset(41.783709535999954, 21.145438675115766), + Offset(41.9118174384751, 21.629959864025817), + Offset(41.978620736948194, 21.907026258707322), + Offset(41.99995577009031, 21.99980529835142), + Offset(42.0, 22.0), + ], + <Offset>[ + Offset(39.94921875, 26.0), + Offset(39.91892748707239, 26.320327277002704), + Offset(39.76165847505559, 27.44070450686089), + Offset(39.204067317080856, 29.66482937223119), + Offset(37.532335710606446, 33.23420478667988), + Offset(32.93796312691141, 38.04355807552189), + Offset(26.633976945925774, 40.7074763785527), + Offset(19.754780569089124, 40.645450453352446), + Offset(14.258518944641395, 38.42055825640933), + Offset(10.642329475530556, 35.42591996819203), + Offset(8.442750176698839, 32.47083890521039), + Offset(7.175149143375414, 29.871645565973722), + Offset(6.485980210787691, 27.710284879276827), + Offset(6.141884684352732, 25.97465660853507), + Offset(5.994461565155852, 24.620856310443347), + Offset(5.951292971147453, 23.598931453057713), + Offset(5.956121008888456, 22.862438898547587), + Offset(5.976034989369527, 22.3715027919526), + Offset(5.9932756825730955, 22.093065578347097), + Offset(5.999985428496359, 22.00019470205062), + Offset(6.0, 22.0), + ], + <Offset>[ + Offset(39.94921875, 26.0), + Offset(39.91892748707239, 26.320327277002704), + Offset(39.76165847505559, 27.44070450686089), + Offset(39.204067317080856, 29.66482937223119), + Offset(37.532335710606446, 33.23420478667988), + Offset(32.93796312691141, 38.04355807552189), + Offset(26.633976945925774, 40.7074763785527), + Offset(19.754780569089124, 40.645450453352446), + Offset(14.258518944641395, 38.42055825640933), + Offset(10.642329475530556, 35.42591996819203), + Offset(8.442750176698839, 32.47083890521039), + Offset(7.175149143375414, 29.871645565973722), + Offset(6.485980210787691, 27.710284879276827), + Offset(6.141884684352732, 25.97465660853507), + Offset(5.994461565155852, 24.620856310443347), + Offset(5.951292971147453, 23.598931453057713), + Offset(5.956121008888456, 22.862438898547587), + Offset(5.976034989369527, 22.3715027919526), + Offset(5.9932756825730955, 22.093065578347097), + Offset(5.999985428496359, 22.00019470205062), + Offset(6.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.94921875, 26.0), + Offset(39.91892748707239, 26.320327277002704), + Offset(39.76165847505559, 27.44070450686089), + Offset(39.204067317080856, 29.66482937223119), + Offset(37.532335710606446, 33.23420478667988), + Offset(32.93796312691141, 38.04355807552189), + Offset(26.633976945925774, 40.7074763785527), + Offset(19.754780569089124, 40.645450453352446), + Offset(14.258518944641395, 38.42055825640933), + Offset(10.642329475530556, 35.42591996819203), + Offset(8.442750176698839, 32.47083890521039), + Offset(7.175149143375414, 29.871645565973722), + Offset(6.485980210787691, 27.710284879276827), + Offset(6.141884684352732, 25.97465660853507), + Offset(5.994461565155852, 24.620856310443347), + Offset(5.951292971147453, 23.598931453057713), + Offset(5.956121008888456, 22.862438898547587), + Offset(5.976034989369527, 22.3715027919526), + Offset(5.9932756825730955, 22.093065578347097), + Offset(5.999985428496359, 22.00019470205062), + Offset(6.0, 22.0), + ], + <Offset>[ + Offset(39.94921875, 22.0), + Offset(39.999299444085175, 22.321134814951655), + Offset(40.123697128128484, 23.4571221977224), + Offset(40.127941008338254, 25.772984385261328), + Offset(39.36673145070893, 29.679631356759792), + Offset(36.03000494036236, 35.505984938867314), + Offset(30.483797891740366, 39.621714579092405), + Offset(23.719442126984134, 41.17597729654657), + Offset(17.808469902722187, 40.26388356552361), + Offset(13.571377486425117, 38.15001206462978), + Offset(10.73996518894534, 35.74540806580379), + Offset(8.908677157504997, 33.47648684184537), + Offset(7.746937054898215, 31.506333856475468), + Offset(7.021336564573183, 29.876779670456198), + Offset(6.575233382277915, 28.57846974488041), + Offset(6.306002369153553, 27.58317306758941), + Offset(6.147597126684428, 26.85785338221681), + Offset(6.058558318339831, 26.3706514388743), + Offset(6.013954859968965, 26.093012124442712), + Offset(6.000028695609693, 26.000194701816614), + Offset(6.0, 26.0), + ], + <Offset>[ + Offset(39.94921875, 22.0), + Offset(39.999299444085175, 22.321134814951655), + Offset(40.123697128128484, 23.4571221977224), + Offset(40.127941008338254, 25.772984385261328), + Offset(39.36673145070893, 29.679631356759792), + Offset(36.03000494036236, 35.505984938867314), + Offset(30.483797891740366, 39.621714579092405), + Offset(23.719442126984134, 41.17597729654657), + Offset(17.808469902722187, 40.26388356552361), + Offset(13.571377486425117, 38.15001206462978), + Offset(10.73996518894534, 35.74540806580379), + Offset(8.908677157504997, 33.47648684184537), + Offset(7.746937054898215, 31.506333856475468), + Offset(7.021336564573183, 29.876779670456198), + Offset(6.575233382277915, 28.57846974488041), + Offset(6.306002369153553, 27.58317306758941), + Offset(6.147597126684428, 26.85785338221681), + Offset(6.058558318339831, 26.3706514388743), + Offset(6.013954859968965, 26.093012124442712), + Offset(6.000028695609693, 26.000194701816614), + Offset(6.0, 26.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(24.00120242935725, 7.98287044589657), + Offset(24.475307447859166, 8.003617986907148), + Offset(26.126659093516306, 8.177690670193112), + Offset(29.352785593371877, 8.997654274881697), + Offset(34.261760845645924, 11.73586028864448), + Offset(39.689530947675216, 19.233828052194387), + Offset(40.483245113546566, 28.57182115613015), + Offset(36.43819118187483, 37.011448735029944), + Offset(29.990575804517718, 41.869715613211284), + Offset(23.79684347484401, 43.53566841500789), + Offset(18.750267725016194, 43.37984126279926), + Offset(14.90588210305647, 42.37202895863352), + Offset(12.06653769423517, 41.068216372655385), + Offset(10.005013565343445, 39.76135295541911), + Offset(8.528791010746735, 38.595867902966916), + Offset(7.490777665658619, 37.63578444828991), + Offset(6.783719048719078, 36.90231589146764), + Offset(6.331695603851976, 36.39438332503497), + Offset(6.082250821137091, 36.09960487240796), + Offset(6.000171486902083, 36.000208945476956), + Offset(6.0, 36.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(24.00120242935725, 7.98287044589657), + Offset(24.475307447859166, 8.003617986907148), + Offset(26.126659093516306, 8.177690670193112), + Offset(29.352785593371877, 8.997654274881697), + Offset(34.261760845645924, 11.73586028864448), + Offset(39.689530947675216, 19.233828052194387), + Offset(40.483245113546566, 28.57182115613015), + Offset(36.43819118187483, 37.011448735029944), + Offset(29.990575804517718, 41.869715613211284), + Offset(23.79684347484401, 43.53566841500789), + Offset(18.750267725016194, 43.37984126279926), + Offset(14.90588210305647, 42.37202895863352), + Offset(12.06653769423517, 41.068216372655385), + Offset(10.005013565343445, 39.76135295541911), + Offset(8.528791010746735, 38.595867902966916), + Offset(7.490777665658619, 37.63578444828991), + Offset(6.783719048719078, 36.90231589146764), + Offset(6.331695603851976, 36.39438332503497), + Offset(6.082250821137091, 36.09960487240796), + Offset(6.000171486902083, 36.000208945476956), + Offset(6.0, 36.0), + ], + <Offset>[ + Offset(8.389135783884633, 23.59493709136918), + Offset(8.411539419733579, 23.280020314446016), + Offset(8.535460203545142, 22.174793784450976), + Offset(9.004955277979905, 19.95721448439121), + Offset(10.506583157969082, 16.301391783224325), + Offset(15.00736732713968, 11.07994257565777), + Offset(21.799606746441338, 7.957602889938901), + Offset(29.78681308270101, 8.138822767869717), + Offset(36.3635225612393, 11.3666818389531), + Offset(40.53777507406921, 15.843550126760437), + Offset(42.771588169072636, 20.33326716415372), + Offset(43.71871364987625, 24.29568645796705), + Offset(43.90383296365319, 27.574365631376406), + Offset(43.68696452483883, 30.181901809762802), + Offset(43.29833346247236, 32.192150096307955), + Offset(42.87820947711742, 33.69183670134356), + Offset(42.5074211611244, 34.76136730547495), + Offset(42.22832709352211, 35.46839060016271), + Offset(42.058590816793185, 35.86711275140609), + Offset(42.00012355171139, 35.99972219110006), + Offset(42.0, 36.0), + ], + <Offset>[ + Offset(8.389135783884633, 23.59493709136918), + Offset(8.411539419733579, 23.280020314446016), + Offset(8.535460203545142, 22.174793784450976), + Offset(9.004955277979905, 19.95721448439121), + Offset(10.506583157969082, 16.301391783224325), + Offset(15.00736732713968, 11.07994257565777), + Offset(21.799606746441338, 7.957602889938901), + Offset(29.78681308270101, 8.138822767869717), + Offset(36.3635225612393, 11.3666818389531), + Offset(40.53777507406921, 15.843550126760437), + Offset(42.771588169072636, 20.33326716415372), + Offset(43.71871364987625, 24.29568645796705), + Offset(43.90383296365319, 27.574365631376406), + Offset(43.68696452483883, 30.181901809762802), + Offset(43.29833346247236, 32.192150096307955), + Offset(42.87820947711742, 33.69183670134356), + Offset(42.5074211611244, 34.76136730547495), + Offset(42.22832709352211, 35.46839060016271), + Offset(42.058590816793185, 35.86711275140609), + Offset(42.00012355171139, 35.99972219110006), + Offset(42.0, 36.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(8.389135783884633, 23.59493709136918), + Offset(8.411539419733579, 23.280020314446016), + Offset(8.535460203545142, 22.174793784450976), + Offset(9.004955277979905, 19.95721448439121), + Offset(10.506583157969082, 16.301391783224325), + Offset(15.00736732713968, 11.07994257565777), + Offset(21.799606746441338, 7.957602889938901), + Offset(29.78681308270101, 8.138822767869717), + Offset(36.3635225612393, 11.3666818389531), + Offset(40.53777507406921, 15.843550126760437), + Offset(42.771588169072636, 20.33326716415372), + Offset(43.71871364987625, 24.29568645796705), + Offset(43.90383296365319, 27.574365631376406), + Offset(43.68696452483883, 30.181901809762802), + Offset(43.29833346247236, 32.192150096307955), + Offset(42.87820947711742, 33.69183670134356), + Offset(42.5074211611244, 34.76136730547495), + Offset(42.22832709352211, 35.46839060016271), + Offset(42.058590816793185, 35.86711275140609), + Offset(42.00012355171139, 35.99972219110006), + Offset(42.0, 36.0), + ], + <Offset>[ + Offset(11.217562908630821, 26.423364216115367), + Offset(11.168037593988725, 26.178591999833156), + Offset(11.026001684399013, 25.304842207741935), + Offset(10.901761927954412, 23.478879507162752), + Offset(11.261530717110908, 20.229502548330856), + Offset(13.752640365544114, 14.878055298029915), + Offset(18.8358022450215, 10.643838709326973), + Offset(25.888907364460923, 9.03678212759111), + Offset(32.44806665065668, 10.54863232194106), + Offset(37.11467529624949, 13.774157000481484), + Offset(40.00232975553069, 17.44688398809633), + Offset(41.592943181014675, 20.907309278000795), + Offset(42.34289639575017, 23.891503017731534), + Offset(42.59272365615382, 26.334482295166104), + Offset(42.57381482854062, 28.258313197161566), + Offset(42.4351505629611, 29.71645007881405), + Offset(42.26812747456164, 30.76853140456803), + Offset(42.125177049114, 31.469720812803587), + Offset(42.03274190981708, 31.867196273027055), + Offset(42.00006946781973, 31.999722191465697), + Offset(42.0, 32.0), + ], + <Offset>[ + Offset(11.217562908630821, 26.423364216115367), + Offset(11.168037593988725, 26.178591999833156), + Offset(11.026001684399013, 25.304842207741935), + Offset(10.901761927954412, 23.478879507162752), + Offset(11.261530717110908, 20.229502548330856), + Offset(13.752640365544114, 14.878055298029915), + Offset(18.8358022450215, 10.643838709326973), + Offset(25.888907364460923, 9.03678212759111), + Offset(32.44806665065668, 10.54863232194106), + Offset(37.11467529624949, 13.774157000481484), + Offset(40.00232975553069, 17.44688398809633), + Offset(41.592943181014675, 20.907309278000795), + Offset(42.34289639575017, 23.891503017731534), + Offset(42.59272365615382, 26.334482295166104), + Offset(42.57381482854062, 28.258313197161566), + Offset(42.4351505629611, 29.71645007881405), + Offset(42.26812747456164, 30.76853140456803), + Offset(42.125177049114, 31.469720812803587), + Offset(42.03274190981708, 31.867196273027055), + Offset(42.00006946781973, 31.999722191465697), + Offset(42.0, 32.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(11.217562908630821, 26.423364216115367), + Offset(11.168037593988725, 26.178591999833156), + Offset(11.026001684399013, 25.304842207741935), + Offset(10.901761927954412, 23.478879507162752), + Offset(11.261530717110908, 20.229502548330856), + Offset(13.752640365544114, 14.878055298029915), + Offset(18.8358022450215, 10.643838709326973), + Offset(25.888907364460923, 9.03678212759111), + Offset(32.44806665065668, 10.54863232194106), + Offset(37.11467529624949, 13.774157000481484), + Offset(40.00232975553069, 17.44688398809633), + Offset(41.592943181014675, 20.907309278000795), + Offset(42.34289639575017, 23.891503017731534), + Offset(42.59272365615382, 26.334482295166104), + Offset(42.57381482854062, 28.258313197161566), + Offset(42.4351505629611, 29.71645007881405), + Offset(42.26812747456164, 30.76853140456803), + Offset(42.125177049114, 31.469720812803587), + Offset(42.03274190981708, 31.867196273027055), + Offset(42.00006946781973, 31.999722191465697), + Offset(42.0, 32.0), + ], + <Offset>[ + Offset(26.829629554103438, 10.811297570642758), + Offset(27.231805622114308, 10.902189672294288), + Offset(28.61720057437018, 11.307739093484066), + Offset(31.249592243346385, 12.519319297653237), + Offset(35.01670840478775, 15.663971053751007), + Offset(38.43480398607965, 23.031940774566532), + Offset(37.51944061212673, 31.258056975518222), + Offset(32.54028546363474, 37.909408094751335), + Offset(26.0751198939351, 41.05166609619924), + Offset(20.373743697024292, 41.46627528872894), + Offset(15.981009311474246, 40.49345808674188), + Offset(12.780111634194903, 38.983651778667266), + Offset(10.505601126332149, 37.38535375901051), + Offset(8.910772696658444, 35.91393344082242), + Offset(7.804272376814996, 34.66203100382052), + Offset(7.047718751502295, 33.6603978257604), + Offset(6.544425362156314, 32.90947999056071), + Offset(6.228545559443866, 32.39571353767585), + Offset(6.056401914160979, 32.099688394028924), + Offset(6.000117403010417, 32.00020894584259), + Offset(6.0, 32.0), + ], + <Offset>[ + Offset(26.829629554103438, 10.811297570642758), + Offset(27.231805622114308, 10.902189672294288), + Offset(28.61720057437018, 11.307739093484066), + Offset(31.249592243346385, 12.519319297653237), + Offset(35.01670840478775, 15.663971053751007), + Offset(38.43480398607965, 23.031940774566532), + Offset(37.51944061212673, 31.258056975518222), + Offset(32.54028546363474, 37.909408094751335), + Offset(26.0751198939351, 41.05166609619924), + Offset(20.373743697024292, 41.46627528872894), + Offset(15.981009311474246, 40.49345808674188), + Offset(12.780111634194903, 38.983651778667266), + Offset(10.505601126332149, 37.38535375901051), + Offset(8.910772696658444, 35.91393344082242), + Offset(7.804272376814996, 34.66203100382052), + Offset(7.047718751502295, 33.6603978257604), + Offset(6.544425362156314, 32.90947999056071), + Offset(6.228545559443866, 32.39571353767585), + Offset(6.056401914160979, 32.099688394028924), + Offset(6.000117403010417, 32.00020894584259), + Offset(6.0, 32.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.829629554103438, 10.811297570642758), + Offset(27.231805622114308, 10.902189672294288), + Offset(28.61720057437018, 11.307739093484066), + Offset(31.249592243346385, 12.519319297653237), + Offset(35.01670840478775, 15.663971053751007), + Offset(38.43480398607965, 23.031940774566532), + Offset(37.51944061212673, 31.258056975518222), + Offset(32.54028546363474, 37.909408094751335), + Offset(26.0751198939351, 41.05166609619924), + Offset(20.373743697024292, 41.46627528872894), + Offset(15.981009311474246, 40.49345808674188), + Offset(12.780111634194903, 38.983651778667266), + Offset(10.505601126332149, 37.38535375901051), + Offset(8.910772696658444, 35.91393344082242), + Offset(7.804272376814996, 34.66203100382052), + Offset(7.047718751502295, 33.6603978257604), + Offset(6.544425362156314, 32.90947999056071), + Offset(6.228545559443866, 32.39571353767585), + Offset(6.056401914160979, 32.099688394028924), + Offset(6.000117403010417, 32.00020894584259), + Offset(6.0, 32.0), + ], + <Offset>[ + Offset(24.00120242935725, 7.98287044589657), + Offset(24.475307447859166, 8.003617986907148), + Offset(26.126659093516306, 8.177690670193112), + Offset(29.352785593371877, 8.997654274881697), + Offset(34.261760845645924, 11.73586028864448), + Offset(39.689530947675216, 19.233828052194387), + Offset(40.483245113546566, 28.57182115613015), + Offset(36.43819118187483, 37.011448735029944), + Offset(29.990575804517718, 41.869715613211284), + Offset(23.79684347484401, 43.53566841500789), + Offset(18.750267725016194, 43.37984126279926), + Offset(14.90588210305647, 42.37202895863352), + Offset(12.06653769423517, 41.068216372655385), + Offset(10.005013565343445, 39.76135295541911), + Offset(8.528791010746735, 38.595867902966916), + Offset(7.490777665658619, 37.63578444828991), + Offset(6.783719048719078, 36.90231589146764), + Offset(6.331695603851976, 36.39438332503497), + Offset(6.082250821137091, 36.09960487240796), + Offset(6.000171486902083, 36.000208945476956), + Offset(6.0, 36.0), + ], + <Offset>[ + Offset(24.00120242935725, 7.98287044589657), + Offset(24.475307447859166, 8.003617986907148), + Offset(26.126659093516306, 8.177690670193112), + Offset(29.352785593371877, 8.997654274881697), + Offset(34.261760845645924, 11.73586028864448), + Offset(39.689530947675216, 19.233828052194387), + Offset(40.483245113546566, 28.57182115613015), + Offset(36.43819118187483, 37.011448735029944), + Offset(29.990575804517718, 41.869715613211284), + Offset(23.79684347484401, 43.53566841500789), + Offset(18.750267725016194, 43.37984126279926), + Offset(14.90588210305647, 42.37202895863352), + Offset(12.06653769423517, 41.068216372655385), + Offset(10.005013565343445, 39.76135295541911), + Offset(8.528791010746735, 38.595867902966916), + Offset(7.490777665658619, 37.63578444828991), + Offset(6.783719048719078, 36.90231589146764), + Offset(6.331695603851976, 36.39438332503497), + Offset(6.082250821137091, 36.09960487240796), + Offset(6.000171486902083, 36.000208945476956), + Offset(6.0, 36.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(26.829629554103434, 37.188702429357235), + Offset(26.70295401712204, 37.21708146835027), + Offset(26.253437940457104, 37.31668297706097), + Offset(25.316159296509255, 37.51407939114749), + Offset(23.588416071597663, 37.80897683895165), + Offset(20.234451203252327, 37.9685842890323), + Offset(16.265106286835728, 37.25239920809595), + Offset(12.10312127594884, 35.1746313573166), + Offset(8.858593681675742, 32.111920483700914), + Offset(6.842177615689938, 28.881561047964333), + Offset(5.761956468124708, 25.926691322240227), + Offset(5.290469810431201, 23.40907221160189), + Offset(5.178751382636996, 21.349132840198116), + Offset(5.258916359304976, 19.710667345993283), + Offset(5.423821188616003, 18.440676377623248), + Offset(5.607765949538116, 17.48625665894383), + Offset(5.772423819137188, 16.80059820468561), + Offset(5.897326348355019, 16.34455713877282), + Offset(5.973614735709873, 16.086271507697752), + Offset(5.9999443342489265, 16.000180457509614), + Offset(6.0, 16.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(26.829629554103434, 37.188702429357235), + Offset(26.70295401712204, 37.21708146835027), + Offset(26.253437940457104, 37.31668297706097), + Offset(25.316159296509255, 37.51407939114749), + Offset(23.588416071597663, 37.80897683895165), + Offset(20.234451203252327, 37.9685842890323), + Offset(16.265106286835728, 37.25239920809595), + Offset(12.10312127594884, 35.1746313573166), + Offset(8.858593681675742, 32.111920483700914), + Offset(6.842177615689938, 28.881561047964333), + Offset(5.761956468124708, 25.926691322240227), + Offset(5.290469810431201, 23.40907221160189), + Offset(5.178751382636996, 21.349132840198116), + Offset(5.258916359304976, 19.710667345993283), + Offset(5.423821188616003, 18.440676377623248), + Offset(5.607765949538116, 17.48625665894383), + Offset(5.772423819137188, 16.80059820468561), + Offset(5.897326348355019, 16.34455713877282), + Offset(5.973614735709873, 16.086271507697752), + Offset(5.9999443342489265, 16.000180457509614), + Offset(6.0, 16.0), + ], + <Offset>[ + Offset(11.2175487664952, 21.576649926020245), + Offset(11.26591566746795, 21.307620185014063), + Offset(11.473792301821605, 20.377637880770784), + Offset(12.06502589368821, 18.578567196580664), + Offset(13.54646415229777, 15.801892832848761), + Offset(17.052481673761243, 12.16993860231353), + Offset(21.42462731807053, 9.9137145270952), + Offset(26.111676903911107, 9.066591789474842), + Offset(30.142827978195022, 9.351628521416771), + Offset(33.249292834290536, 10.17924910714327), + Offset(35.60828563949723, 11.183386110908502), + Offset(37.40014550074941, 12.188459616498097), + Offset(38.76213503811523, 13.111795882844753), + Offset(39.793775377370764, 13.915564474413902), + Offset(40.567255045797005, 14.584805428587808), + Offset(41.13536943122808, 15.116774671440997), + Offset(41.537143495419784, 15.5155379179047), + Offset(41.801600251554405, 15.788922082526666), + Offset(41.95043550915973, 15.946775613712157), + Offset(41.99989640116428, 15.999888404883473), + Offset(42.0, 16.0), + ], + <Offset>[ + Offset(11.2175487664952, 21.576649926020245), + Offset(11.26591566746795, 21.307620185014063), + Offset(11.473792301821605, 20.377637880770784), + Offset(12.06502589368821, 18.578567196580664), + Offset(13.54646415229777, 15.801892832848761), + Offset(17.052481673761243, 12.16993860231353), + Offset(21.42462731807053, 9.9137145270952), + Offset(26.111676903911107, 9.066591789474842), + Offset(30.142827978195022, 9.351628521416771), + Offset(33.249292834290536, 10.17924910714327), + Offset(35.60828563949723, 11.183386110908502), + Offset(37.40014550074941, 12.188459616498097), + Offset(38.76213503811523, 13.111795882844753), + Offset(39.793775377370764, 13.915564474413902), + Offset(40.567255045797005, 14.584805428587808), + Offset(41.13536943122808, 15.116774671440997), + Offset(41.537143495419784, 15.5155379179047), + Offset(41.801600251554405, 15.788922082526666), + Offset(41.95043550915973, 15.946775613712157), + Offset(41.99989640116428, 15.999888404883473), + Offset(42.0, 16.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(11.2175487664952, 21.576649926020245), + Offset(11.26591566746795, 21.307620185014063), + Offset(11.473792301821605, 20.377637880770784), + Offset(12.06502589368821, 18.578567196580664), + Offset(13.54646415229777, 15.801892832848761), + Offset(17.052481673761243, 12.16993860231353), + Offset(21.42462731807053, 9.9137145270952), + Offset(26.111676903911107, 9.066591789474842), + Offset(30.142827978195022, 9.351628521416771), + Offset(33.249292834290536, 10.17924910714327), + Offset(35.60828563949723, 11.183386110908502), + Offset(37.40014550074941, 12.188459616498097), + Offset(38.76213503811523, 13.111795882844753), + Offset(39.793775377370764, 13.915564474413902), + Offset(40.567255045797005, 14.584805428587808), + Offset(41.13536943122808, 15.116774671440997), + Offset(41.537143495419784, 15.5155379179047), + Offset(41.801600251554405, 15.788922082526666), + Offset(41.95043550915973, 15.946775613712157), + Offset(41.99989640116428, 15.999888404883473), + Offset(42.0, 16.0), + ], + <Offset>[ + Offset(8.38912164174901, 24.405077050766437), + Offset(8.395184821848037, 24.09310118692493), + Offset(8.459782153583708, 23.007417876572518), + Offset(8.787794358833729, 20.871982437428983), + Offset(9.907414129760891, 17.4624092987335), + Offset(13.082563535366404, 12.659581303110395), + Offset(17.494014200747685, 9.171905574648836), + Offset(22.5870000408063, 7.175387672861925), + Offset(27.221252362148654, 6.6195238529014375), + Offset(30.937448027657968, 6.914992133489694), + Offset(33.83674155554175, 7.597074418012536), + Offset(36.08060605872299, 8.412374916582504), + Offset(37.809259485732085, 9.226949916571378), + Offset(39.13181277597566, 9.970719038360135), + Offset(40.13100060158848, 10.608666338979862), + Offset(40.8691843334897, 11.125641311887346), + Offset(41.393512389333864, 11.518117486505904), + Offset(41.739705833610245, 11.789400976065625), + Offset(41.934926095887384, 11.946805681562672), + Offset(41.99986395082928, 11.999888405015101), + Offset(42.0, 12.0), + ], + <Offset>[ + Offset(8.38912164174901, 24.405077050766437), + Offset(8.395184821848037, 24.09310118692493), + Offset(8.459782153583708, 23.007417876572518), + Offset(8.787794358833729, 20.871982437428983), + Offset(9.907414129760891, 17.4624092987335), + Offset(13.082563535366404, 12.659581303110395), + Offset(17.494014200747685, 9.171905574648836), + Offset(22.5870000408063, 7.175387672861925), + Offset(27.221252362148654, 6.6195238529014375), + Offset(30.937448027657968, 6.914992133489694), + Offset(33.83674155554175, 7.597074418012536), + Offset(36.08060605872299, 8.412374916582504), + Offset(37.809259485732085, 9.226949916571378), + Offset(39.13181277597566, 9.970719038360135), + Offset(40.13100060158848, 10.608666338979862), + Offset(40.8691843334897, 11.125641311887346), + Offset(41.393512389333864, 11.518117486505904), + Offset(41.739705833610245, 11.789400976065625), + Offset(41.934926095887384, 11.946805681562672), + Offset(41.99986395082928, 11.999888405015101), + Offset(42.0, 12.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(8.38912164174901, 24.405077050766437), + Offset(8.395184821848037, 24.09310118692493), + Offset(8.459782153583708, 23.007417876572518), + Offset(8.787794358833729, 20.871982437428983), + Offset(9.907414129760891, 17.4624092987335), + Offset(13.082563535366404, 12.659581303110395), + Offset(17.494014200747685, 9.171905574648836), + Offset(22.5870000408063, 7.175387672861925), + Offset(27.221252362148654, 6.6195238529014375), + Offset(30.937448027657968, 6.914992133489694), + Offset(33.83674155554175, 7.597074418012536), + Offset(36.08060605872299, 8.412374916582504), + Offset(37.809259485732085, 9.226949916571378), + Offset(39.13181277597566, 9.970719038360135), + Offset(40.13100060158848, 10.608666338979862), + Offset(40.8691843334897, 11.125641311887346), + Offset(41.393512389333864, 11.518117486505904), + Offset(41.739705833610245, 11.789400976065625), + Offset(41.934926095887384, 11.946805681562672), + Offset(41.99986395082928, 11.999888405015101), + Offset(42.0, 12.0), + ], + <Offset>[ + Offset(24.001202429357242, 40.01712955410343), + Offset(23.83222317150212, 40.00256247026114), + Offset(23.23942779221921, 39.946462972862705), + Offset(22.038927761654776, 39.80749463199581), + Offset(19.949366049060785, 39.46949330483639), + Offset(16.264533064857485, 38.458226989829164), + Offset(12.33449316951288, 36.51059025564959), + Offset(8.578444412844032, 33.28342724070369), + Offset(5.937018065629374, 29.37981581518558), + Offset(4.53033280905737, 25.617304074310752), + Offset(3.9904123841692254, 22.340379629344262), + Offset(3.9709303684047867, 19.632987511686302), + Offset(4.225875830253855, 17.46428687392474), + Offset(4.596953757909873, 15.765821909939516), + Offset(4.987566744407481, 14.464537288015299), + Offset(5.341580851799733, 13.495123299390182), + Offset(5.628792713051272, 12.803177773286812), + Offset(5.835431930410859, 12.34503603231178), + Offset(5.958105322437522, 12.08630157554827), + Offset(5.999911883913924, 12.000180457641243), + Offset(6.0, 12.0), + ], + <Offset>[ + Offset(24.001202429357242, 40.01712955410343), + Offset(23.83222317150212, 40.00256247026114), + Offset(23.23942779221921, 39.946462972862705), + Offset(22.038927761654776, 39.80749463199581), + Offset(19.949366049060785, 39.46949330483639), + Offset(16.264533064857485, 38.458226989829164), + Offset(12.33449316951288, 36.51059025564959), + Offset(8.578444412844032, 33.28342724070369), + Offset(5.937018065629374, 29.37981581518558), + Offset(4.53033280905737, 25.617304074310752), + Offset(3.9904123841692254, 22.340379629344262), + Offset(3.9709303684047867, 19.632987511686302), + Offset(4.225875830253855, 17.46428687392474), + Offset(4.596953757909873, 15.765821909939516), + Offset(4.987566744407481, 14.464537288015299), + Offset(5.341580851799733, 13.495123299390182), + Offset(5.628792713051272, 12.803177773286812), + Offset(5.835431930410859, 12.34503603231178), + Offset(5.958105322437522, 12.08630157554827), + Offset(5.999911883913924, 12.000180457641243), + Offset(6.0, 12.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.001202429357242, 40.01712955410343), + Offset(23.83222317150212, 40.00256247026114), + Offset(23.23942779221921, 39.946462972862705), + Offset(22.038927761654776, 39.80749463199581), + Offset(19.949366049060785, 39.46949330483639), + Offset(16.264533064857485, 38.458226989829164), + Offset(12.33449316951288, 36.51059025564959), + Offset(8.578444412844032, 33.28342724070369), + Offset(5.937018065629374, 29.37981581518558), + Offset(4.53033280905737, 25.617304074310752), + Offset(3.9904123841692254, 22.340379629344262), + Offset(3.9709303684047867, 19.632987511686302), + Offset(4.225875830253855, 17.46428687392474), + Offset(4.596953757909873, 15.765821909939516), + Offset(4.987566744407481, 14.464537288015299), + Offset(5.341580851799733, 13.495123299390182), + Offset(5.628792713051272, 12.803177773286812), + Offset(5.835431930410859, 12.34503603231178), + Offset(5.958105322437522, 12.08630157554827), + Offset(5.999911883913924, 12.000180457641243), + Offset(6.0, 12.0), + ], + <Offset>[ + Offset(26.829629554103434, 37.188702429357235), + Offset(26.70295401712204, 37.21708146835027), + Offset(26.253437940457104, 37.31668297706097), + Offset(25.316159296509255, 37.51407939114749), + Offset(23.588416071597663, 37.80897683895165), + Offset(20.234451203252327, 37.9685842890323), + Offset(16.265106286835728, 37.25239920809595), + Offset(12.10312127594884, 35.1746313573166), + Offset(8.858593681675742, 32.111920483700914), + Offset(6.842177615689938, 28.881561047964333), + Offset(5.761956468124708, 25.926691322240227), + Offset(5.290469810431201, 23.40907221160189), + Offset(5.178751382636996, 21.349132840198116), + Offset(5.258916359304976, 19.710667345993283), + Offset(5.423821188616003, 18.440676377623248), + Offset(5.607765949538116, 17.48625665894383), + Offset(5.772423819137188, 16.80059820468561), + Offset(5.897326348355019, 16.34455713877282), + Offset(5.973614735709873, 16.086271507697752), + Offset(5.9999443342489265, 16.000180457509614), + Offset(6.0, 16.0), + ], + <Offset>[ + Offset(26.829629554103434, 37.188702429357235), + Offset(26.70295401712204, 37.21708146835027), + Offset(26.253437940457104, 37.31668297706097), + Offset(25.316159296509255, 37.51407939114749), + Offset(23.588416071597663, 37.80897683895165), + Offset(20.234451203252327, 37.9685842890323), + Offset(16.265106286835728, 37.25239920809595), + Offset(12.10312127594884, 35.1746313573166), + Offset(8.858593681675742, 32.111920483700914), + Offset(6.842177615689938, 28.881561047964333), + Offset(5.761956468124708, 25.926691322240227), + Offset(5.290469810431201, 23.40907221160189), + Offset(5.178751382636996, 21.349132840198116), + Offset(5.258916359304976, 19.710667345993283), + Offset(5.423821188616003, 18.440676377623248), + Offset(5.607765949538116, 17.48625665894383), + Offset(5.772423819137188, 16.80059820468561), + Offset(5.897326348355019, 16.34455713877282), + Offset(5.973614735709873, 16.086271507697752), + Offset(5.9999443342489265, 16.000180457509614), + Offset(6.0, 16.0), + ], + ), + _PathClose(), + ], + ), +], matchTextDirection: true); diff --git a/packages/material_ui/lib/src/animated_icons/data/close_menu.g.dart b/packages/material_ui/lib/src/animated_icons/data/close_menu.g.dart new file mode 100644 index 000000000000..bf90f882e342 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/close_menu.g.dart @@ -0,0 +1,1015 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$close_menu = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(22.262051805115647, 21.908449654595582), + Offset(24.88919842700383, 17.94756272667581), + Offset(28.423231797685485, 16.307213722363326), + Offset(31.75801074104301, 16.114437816710378), + Offset(34.549940992662854, 16.70741310193702), + Offset(36.75456537179234, 17.655783514919893), + Offset(38.441715273516245, 18.694992104934485), + Offset(39.70097192738563, 19.67449366114299), + Offset(40.617793553920194, 20.51263931960471), + Offset(41.26084924113239, 21.170496215429807), + Offset(41.68247655138362, 21.634692364647904), + Offset(41.92023728365015, 21.90732809306482), + Offset(41.99983686928147, 21.999805299557877), + Offset(42.0, 22.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(22.262051805115647, 21.908449654595582), + Offset(24.88919842700383, 17.94756272667581), + Offset(28.423231797685485, 16.307213722363326), + Offset(31.75801074104301, 16.114437816710378), + Offset(34.549940992662854, 16.70741310193702), + Offset(36.75456537179234, 17.655783514919893), + Offset(38.441715273516245, 18.694992104934485), + Offset(39.70097192738563, 19.67449366114299), + Offset(40.617793553920194, 20.51263931960471), + Offset(41.26084924113239, 21.170496215429807), + Offset(41.68247655138362, 21.634692364647904), + Offset(41.92023728365015, 21.90732809306482), + Offset(41.99983686928147, 21.999805299557877), + Offset(42.0, 22.0), + ], + <Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(21.773286636989344, 25.561023502210293), + Offset(19.560850614934246, 28.209111964173573), + Offset(16.64772019146621, 28.96869418114919), + Offset(13.944774246803789, 28.61099302263078), + Offset(11.716530993310267, 27.687745622141946), + Offset(9.984477784090515, 26.54816750788367), + Offset(8.678832846158931, 25.4028848331679), + Offset(7.7182562555573995, 24.367892904410397), + Offset(7.027497048073013, 23.503119065863647), + Offset(6.547674641076508, 22.834089300900743), + Offset(6.235000119645385, 22.366158988430406), + Offset(6.059083538826545, 22.092725360840227), + Offset(6.000119863487498, 22.00019470067613), + Offset(5.9999999999999964, 22.000000000000007), + ], + <Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(21.773286636989344, 25.561023502210293), + Offset(19.560850614934246, 28.209111964173573), + Offset(16.64772019146621, 28.96869418114919), + Offset(13.944774246803789, 28.61099302263078), + Offset(11.716530993310267, 27.687745622141946), + Offset(9.984477784090515, 26.54816750788367), + Offset(8.678832846158931, 25.4028848331679), + Offset(7.7182562555573995, 24.367892904410397), + Offset(7.027497048073013, 23.503119065863647), + Offset(6.547674641076508, 22.834089300900743), + Offset(6.235000119645385, 22.366158988430406), + Offset(6.059083538826545, 22.092725360840227), + Offset(6.000119863487498, 22.00019470067613), + Offset(5.9999999999999964, 22.000000000000007), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(21.773286636989344, 25.561023502210293), + Offset(19.560850614934246, 28.209111964173573), + Offset(16.64772019146621, 28.96869418114919), + Offset(13.944774246803789, 28.61099302263078), + Offset(11.716530993310267, 27.687745622141946), + Offset(9.984477784090515, 26.54816750788367), + Offset(8.678832846158931, 25.4028848331679), + Offset(7.7182562555573995, 24.367892904410397), + Offset(7.027497048073013, 23.503119065863647), + Offset(6.547674641076508, 22.834089300900743), + Offset(6.235000119645385, 22.366158988430406), + Offset(6.059083538826545, 22.092725360840227), + Offset(6.000119863487498, 22.00019470067613), + Offset(5.9999999999999964, 22.000000000000007), + ], + <Offset>[ + Offset(24.0, 22.0), + Offset(24.040185978508838, 22.000403768974525), + Offset(24.181019326543748, 22.00820884543142), + Offset(24.461936845635496, 22.054077506516684), + Offset(24.91719787006675, 22.22271328504796), + Offset(25.546020906725474, 22.73121343167271), + Offset(25.924910472907296, 23.45711910026985), + Offset(25.737948194884353, 26.091550345404418), + Offset(23.110801573005386, 30.05243727330644), + Offset(19.576768202341754, 31.692786277607382), + Offset(16.24198925903886, 31.88556218323219), + Offset(13.450059007427265, 31.292586898019646), + Offset(11.245434628207665, 30.344216485080107), + Offset(9.558284726386193, 29.305007895087492), + Offset(8.29902807271331, 28.325506338842494), + Offset(7.38220644607981, 27.487360680395284), + Offset(6.7391507588675985, 26.8295037845702), + Offset(6.3175234486163845, 26.36530763535209), + Offset(6.079762716249856, 26.0926719069357), + Offset(6.000163130618532, 26.000194700442123), + Offset(5.9999999999999964, 26.000000000000007), + ], + <Offset>[ + Offset(24.0, 22.0), + Offset(24.040185978508838, 22.000403768974525), + Offset(24.181019326543748, 22.00820884543142), + Offset(24.461936845635496, 22.054077506516684), + Offset(24.91719787006675, 22.22271328504796), + Offset(25.546020906725474, 22.73121343167271), + Offset(25.924910472907296, 23.45711910026985), + Offset(25.737948194884353, 26.091550345404418), + Offset(23.110801573005386, 30.05243727330644), + Offset(19.576768202341754, 31.692786277607382), + Offset(16.24198925903886, 31.88556218323219), + Offset(13.450059007427265, 31.292586898019646), + Offset(11.245434628207665, 30.344216485080107), + Offset(9.558284726386193, 29.305007895087492), + Offset(8.29902807271331, 28.325506338842494), + Offset(7.38220644607981, 27.487360680395284), + Offset(6.7391507588675985, 26.8295037845702), + Offset(6.3175234486163845, 26.36530763535209), + Offset(6.079762716249856, 26.0926719069357), + Offset(6.000163130618532, 26.000194700442123), + Offset(5.9999999999999964, 26.000000000000007), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.0, 22.0), + Offset(24.040185978508838, 22.000403768974525), + Offset(24.181019326543748, 22.00820884543142), + Offset(24.461936845635496, 22.054077506516684), + Offset(24.91719787006675, 22.22271328504796), + Offset(25.546020906725474, 22.73121343167271), + Offset(25.924910472907296, 23.45711910026985), + Offset(25.737948194884353, 26.091550345404418), + Offset(23.110801573005386, 30.05243727330644), + Offset(19.576768202341754, 31.692786277607382), + Offset(16.24198925903886, 31.88556218323219), + Offset(13.450059007427265, 31.292586898019646), + Offset(11.245434628207665, 30.344216485080107), + Offset(9.558284726386193, 29.305007895087492), + Offset(8.29902807271331, 28.325506338842494), + Offset(7.38220644607981, 27.487360680395284), + Offset(6.7391507588675985, 26.8295037845702), + Offset(6.3175234486163845, 26.36530763535209), + Offset(6.079762716249856, 26.0926719069357), + Offset(6.000163130618532, 26.000194700442123), + Offset(5.9999999999999964, 26.000000000000007), + ], + <Offset>[ + Offset(24.0, 22.0), + Offset(24.040185978508838, 22.000403768974525), + Offset(24.181019326543748, 22.00820884543142), + Offset(24.461936845635496, 22.054077506516684), + Offset(24.91719787006675, 22.22271328504796), + Offset(25.546020906725474, 22.73121343167271), + Offset(25.924910472907296, 23.45711910026985), + Offset(26.226713363010656, 22.438976497789707), + Offset(28.439149385074973, 19.790888035808678), + Offset(31.352279808561033, 19.031305818821522), + Offset(34.05522575327808, 19.389006977311787), + Offset(36.28346900677985, 20.312254377814725), + Offset(38.015522215909485, 21.45183249211633), + Offset(39.32116715374351, 22.597115166854078), + Offset(40.281743744541544, 23.632107095575087), + Offset(40.972502951927, 24.496880934136346), + Offset(41.45232535892349, 25.165910699099264), + Offset(41.76499988035462, 25.633841011569586), + Offset(41.940916461073456, 25.907274639160292), + Offset(41.9998801364125, 25.99980529932387), + Offset(42.0, 26.0), + ], + <Offset>[ + Offset(24.0, 22.0), + Offset(24.040185978508838, 22.000403768974525), + Offset(24.181019326543748, 22.00820884543142), + Offset(24.461936845635496, 22.054077506516684), + Offset(24.91719787006675, 22.22271328504796), + Offset(25.546020906725474, 22.73121343167271), + Offset(25.924910472907296, 23.45711910026985), + Offset(26.226713363010656, 22.438976497789707), + Offset(28.439149385074973, 19.790888035808678), + Offset(31.352279808561033, 19.031305818821522), + Offset(34.05522575327808, 19.389006977311787), + Offset(36.28346900677985, 20.312254377814725), + Offset(38.015522215909485, 21.45183249211633), + Offset(39.32116715374351, 22.597115166854078), + Offset(40.281743744541544, 23.632107095575087), + Offset(40.972502951927, 24.496880934136346), + Offset(41.45232535892349, 25.165910699099264), + Offset(41.76499988035462, 25.633841011569586), + Offset(41.940916461073456, 25.907274639160292), + Offset(41.9998801364125, 25.99980529932387), + Offset(42.0, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.0, 22.0), + Offset(24.040185978508838, 22.000403768974525), + Offset(24.181019326543748, 22.00820884543142), + Offset(24.461936845635496, 22.054077506516684), + Offset(24.91719787006675, 22.22271328504796), + Offset(25.546020906725474, 22.73121343167271), + Offset(25.924910472907296, 23.45711910026985), + Offset(26.226713363010656, 22.438976497789707), + Offset(28.439149385074973, 19.790888035808678), + Offset(31.352279808561033, 19.031305818821522), + Offset(34.05522575327808, 19.389006977311787), + Offset(36.28346900677985, 20.312254377814725), + Offset(38.015522215909485, 21.45183249211633), + Offset(39.32116715374351, 22.597115166854078), + Offset(40.281743744541544, 23.632107095575087), + Offset(40.972502951927, 24.496880934136346), + Offset(41.45232535892349, 25.165910699099264), + Offset(41.76499988035462, 25.633841011569586), + Offset(41.940916461073456, 25.907274639160292), + Offset(41.9998801364125, 25.99980529932387), + Offset(42.0, 26.0), + ], + <Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(22.262051805115647, 21.908449654595582), + Offset(24.88919842700383, 17.94756272667581), + Offset(28.423231797685485, 16.307213722363326), + Offset(31.75801074104301, 16.114437816710378), + Offset(34.549940992662854, 16.70741310193702), + Offset(36.75456537179234, 17.655783514919893), + Offset(38.441715273516245, 18.694992104934485), + Offset(39.70097192738563, 19.67449366114299), + Offset(40.617793553920194, 20.51263931960471), + Offset(41.26084924113239, 21.170496215429807), + Offset(41.68247655138362, 21.634692364647904), + Offset(41.92023728365015, 21.90732809306482), + Offset(41.99983686928147, 21.999805299557877), + Offset(42.0, 22.0), + ], + <Offset>[ + Offset(24.0, 26.0), + Offset(23.959814021491162, 25.999596231025475), + Offset(23.818980673456252, 25.99179115456858), + Offset(23.538063154364504, 25.945922493483316), + Offset(23.08280212993325, 25.77728671495204), + Offset(22.453979093274526, 25.26878656832729), + Offset(22.075089527092704, 24.54288089973015), + Offset(22.262051805115647, 21.908449654595582), + Offset(24.88919842700383, 17.94756272667581), + Offset(28.423231797685485, 16.307213722363326), + Offset(31.75801074104301, 16.114437816710378), + Offset(34.549940992662854, 16.70741310193702), + Offset(36.75456537179234, 17.655783514919893), + Offset(38.441715273516245, 18.694992104934485), + Offset(39.70097192738563, 19.67449366114299), + Offset(40.617793553920194, 20.51263931960471), + Offset(41.26084924113239, 21.170496215429807), + Offset(41.68247655138362, 21.634692364647904), + Offset(41.92023728365015, 21.90732809306482), + Offset(41.99983686928147, 21.999805299557877), + Offset(42.0, 22.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(9.857864376269049, 12.68629150101524), + Offset(10.0286848752889, 12.538401058257541), + Offset(10.632873267418944, 12.039156238673812), + Offset(11.869661541953974, 11.120999908012887), + Offset(14.01269232753313, 9.802147761233421), + Offset(17.63755687713399, 8.1644208393319), + Offset(21.40270587697591, 7.061028351517436), + Offset(25.372566440386116, 6.474033586994366), + Offset(29.043281052671396, 6.480169426635232), + Offset(32.12573791953512, 6.935455570385773), + Offset(34.627918413389764, 7.642591727654583), + Offset(36.617140423267614, 8.447772996169093), + Offset(38.17478812826869, 9.24831138340156), + Offset(39.37733102863164, 9.981782438812019), + Offset(40.28991005711473, 10.613640055506202), + Offset(40.96529784286365, 11.127522886831306), + Offset(41.445182774971684, 11.518668960278076), + Offset(41.761944370115955, 11.789503608737085), + Offset(41.94049711880572, 11.94681212872769), + Offset(41.99987560677617, 11.999888404963674), + Offset(42.0, 12.000000000000002), + ]), + _PathCubicTo( + <Offset>[ + Offset(9.857864376269049, 12.68629150101524), + Offset(10.0286848752889, 12.538401058257541), + Offset(10.632873267418944, 12.039156238673812), + Offset(11.869661541953974, 11.120999908012887), + Offset(14.01269232753313, 9.802147761233421), + Offset(17.63755687713399, 8.1644208393319), + Offset(21.40270587697591, 7.061028351517436), + Offset(25.372566440386116, 6.474033586994366), + Offset(29.043281052671396, 6.480169426635232), + Offset(32.12573791953512, 6.935455570385773), + Offset(34.627918413389764, 7.642591727654583), + Offset(36.617140423267614, 8.447772996169093), + Offset(38.17478812826869, 9.24831138340156), + Offset(39.37733102863164, 9.981782438812019), + Offset(40.28991005711473, 10.613640055506202), + Offset(40.96529784286365, 11.127522886831306), + Offset(41.445182774971684, 11.518668960278076), + Offset(41.761944370115955, 11.789503608737085), + Offset(41.94049711880572, 11.94681212872769), + Offset(41.99987560677617, 11.999888404963674), + Offset(42.0, 12.000000000000002), + ], + <Offset>[ + Offset(35.31370849898477, 38.14213562373095), + Offset(35.09801389245515, 38.37497866886739), + Offset(34.30089322953512, 39.165247572901634), + Offset(32.51039870948586, 40.61608372177527), + Offset(28.957340520209982, 42.55359796419574), + Offset(22.04434118430577, 43.893684084885464), + Offset(14.726425304958642, 42.43654640742306), + Offset(8.351729390869856, 38.19612535493764), + Offset(4.454339035895707, 32.774349970923794), + Offset(2.747425156507667, 27.742058829873816), + Offset(2.3511131772704275, 23.586488483141284), + Offset(2.632378123985845, 20.323627974288197), + Offset(3.211174431823274, 17.82419135491087), + Offset(3.873722104158137, 15.939445851429909), + Offset(4.5046582506767905, 14.539930053688956), + Offset(5.04509760688121, 13.523188766482994), + Offset(5.468398892380936, 12.81134891500736), + Offset(5.766254411966681, 12.346553370240805), + Offset(5.940767729461317, 12.086396848425768), + Offset(5.999875607960821, 12.000180458137994), + Offset(5.9999999999999964, 12.000000000000005), + ], + <Offset>[ + Offset(35.31370849898477, 38.14213562373095), + Offset(35.09801389245515, 38.37497866886739), + Offset(34.30089322953512, 39.165247572901634), + Offset(32.51039870948586, 40.61608372177527), + Offset(28.957340520209982, 42.55359796419574), + Offset(22.04434118430577, 43.893684084885464), + Offset(14.726425304958642, 42.43654640742306), + Offset(8.351729390869856, 38.19612535493764), + Offset(4.454339035895707, 32.774349970923794), + Offset(2.747425156507667, 27.742058829873816), + Offset(2.3511131772704275, 23.586488483141284), + Offset(2.632378123985845, 20.323627974288197), + Offset(3.211174431823274, 17.82419135491087), + Offset(3.873722104158137, 15.939445851429909), + Offset(4.5046582506767905, 14.539930053688956), + Offset(5.04509760688121, 13.523188766482994), + Offset(5.468398892380936, 12.81134891500736), + Offset(5.766254411966681, 12.346553370240805), + Offset(5.940767729461317, 12.086396848425768), + Offset(5.999875607960821, 12.000180458137994), + Offset(5.9999999999999964, 12.000000000000005), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.31370849898477, 38.14213562373095), + Offset(35.09801389245515, 38.37497866886739), + Offset(34.30089322953512, 39.165247572901634), + Offset(32.51039870948586, 40.61608372177527), + Offset(28.957340520209982, 42.55359796419574), + Offset(22.04434118430577, 43.893684084885464), + Offset(14.726425304958642, 42.43654640742306), + Offset(8.351729390869856, 38.19612535493764), + Offset(4.454339035895707, 32.774349970923794), + Offset(2.747425156507667, 27.742058829873816), + Offset(2.3511131772704275, 23.586488483141284), + Offset(2.632378123985845, 20.323627974288197), + Offset(3.211174431823274, 17.82419135491087), + Offset(3.873722104158137, 15.939445851429909), + Offset(4.5046582506767905, 14.539930053688956), + Offset(5.04509760688121, 13.523188766482994), + Offset(5.468398892380936, 12.81134891500736), + Offset(5.766254411966681, 12.346553370240805), + Offset(5.940767729461317, 12.086396848425768), + Offset(5.999875607960821, 12.000180458137994), + Offset(5.9999999999999964, 12.000000000000005), + ], + <Offset>[ + Offset(38.14213562373095, 35.31370849898476), + Offset(37.96874473807846, 35.58949766696003), + Offset(37.314903377782656, 36.53546757711095), + Offset(35.78763024434835, 38.32266848093839), + Offset(32.59639054276135, 40.89308149834275), + Offset(26.01425932270061, 43.4040413840886), + Offset(18.657038422281488, 43.17835535986942), + Offset(11.876406253974665, 40.08732947155056), + Offset(7.375914651927772, 35.506454639454425), + Offset(5.059269963117448, 31.006315803543533), + Offset(4.122657261213394, 27.17280017604343), + Offset(3.9519175659990786, 24.099712674208394), + Offset(4.164049984213197, 21.709037321182585), + Offset(4.535684705560126, 19.884291287482522), + Offset(4.940912694919316, 18.516069143293173), + Offset(5.3112827046202895, 17.514322126036596), + Offset(5.612029998461967, 16.80876934640633), + Offset(5.828148829911537, 16.346074476701837), + Offset(5.9562771427611025, 16.086366780575148), + Offset(5.9999080583135225, 16.000180458006366), + Offset(5.9999999999999964, 16.000000000000007), + ], + <Offset>[ + Offset(38.14213562373095, 35.31370849898476), + Offset(37.96874473807846, 35.58949766696003), + Offset(37.314903377782656, 36.53546757711095), + Offset(35.78763024434835, 38.32266848093839), + Offset(32.59639054276135, 40.89308149834275), + Offset(26.01425932270061, 43.4040413840886), + Offset(18.657038422281488, 43.17835535986942), + Offset(11.876406253974665, 40.08732947155056), + Offset(7.375914651927772, 35.506454639454425), + Offset(5.059269963117448, 31.006315803543533), + Offset(4.122657261213394, 27.17280017604343), + Offset(3.9519175659990786, 24.099712674208394), + Offset(4.164049984213197, 21.709037321182585), + Offset(4.535684705560126, 19.884291287482522), + Offset(4.940912694919316, 18.516069143293173), + Offset(5.3112827046202895, 17.514322126036596), + Offset(5.612029998461967, 16.80876934640633), + Offset(5.828148829911537, 16.346074476701837), + Offset(5.9562771427611025, 16.086366780575148), + Offset(5.9999080583135225, 16.000180458006366), + Offset(5.9999999999999964, 16.000000000000007), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.14213562373095, 35.31370849898476), + Offset(37.96874473807846, 35.58949766696003), + Offset(37.314903377782656, 36.53546757711095), + Offset(35.78763024434835, 38.32266848093839), + Offset(32.59639054276135, 40.89308149834275), + Offset(26.01425932270061, 43.4040413840886), + Offset(18.657038422281488, 43.17835535986942), + Offset(11.876406253974665, 40.08732947155056), + Offset(7.375914651927772, 35.506454639454425), + Offset(5.059269963117448, 31.006315803543533), + Offset(4.122657261213394, 27.17280017604343), + Offset(3.9519175659990786, 24.099712674208394), + Offset(4.164049984213197, 21.709037321182585), + Offset(4.535684705560126, 19.884291287482522), + Offset(4.940912694919316, 18.516069143293173), + Offset(5.3112827046202895, 17.514322126036596), + Offset(5.612029998461967, 16.80876934640633), + Offset(5.828148829911537, 16.346074476701837), + Offset(5.9562771427611025, 16.086366780575148), + Offset(5.9999080583135225, 16.000180458006366), + Offset(5.9999999999999964, 16.000000000000007), + ], + <Offset>[ + Offset(12.68629150101524, 9.857864376269049), + Offset(12.899415720912216, 9.75292005635018), + Offset(13.64688341566648, 9.409376242883125), + Offset(15.146893076816461, 8.82758466717601), + Offset(17.651742350084497, 8.141631295380437), + Offset(21.607475015528827, 7.674778138535036), + Offset(25.333318994298757, 7.802837303963798), + Offset(28.897243303490924, 8.365237703607285), + Offset(31.96485666870346, 9.212274095165863), + Offset(34.437582726144896, 10.19971254405549), + Offset(36.399462497332735, 11.228903420556733), + Offset(37.93667986528085, 12.22385769608929), + Offset(39.127663680658614, 13.133157349673274), + Offset(40.039293630033626, 13.926627874864629), + Offset(40.72616450135726, 14.58977914511042), + Offset(41.231482940602724, 15.118656246384909), + Offset(41.588813881052715, 15.516089391677047), + Offset(41.82383878806081, 15.789024715198115), + Offset(41.956006532105505, 15.946782060877066), + Offset(41.99990805712887, 15.999888404832046), + Offset(42.0, 16.0), + ], + <Offset>[ + Offset(12.68629150101524, 9.857864376269049), + Offset(12.899415720912216, 9.75292005635018), + Offset(13.64688341566648, 9.409376242883125), + Offset(15.146893076816461, 8.82758466717601), + Offset(17.651742350084497, 8.141631295380437), + Offset(21.607475015528827, 7.674778138535036), + Offset(25.333318994298757, 7.802837303963798), + Offset(28.897243303490924, 8.365237703607285), + Offset(31.96485666870346, 9.212274095165863), + Offset(34.437582726144896, 10.19971254405549), + Offset(36.399462497332735, 11.228903420556733), + Offset(37.93667986528085, 12.22385769608929), + Offset(39.127663680658614, 13.133157349673274), + Offset(40.039293630033626, 13.926627874864629), + Offset(40.72616450135726, 14.58977914511042), + Offset(41.231482940602724, 15.118656246384909), + Offset(41.588813881052715, 15.516089391677047), + Offset(41.82383878806081, 15.789024715198115), + Offset(41.956006532105505, 15.946782060877066), + Offset(41.99990805712887, 15.999888404832046), + Offset(42.0, 16.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.68629150101524, 9.857864376269049), + Offset(12.899415720912216, 9.75292005635018), + Offset(13.64688341566648, 9.409376242883125), + Offset(15.146893076816461, 8.82758466717601), + Offset(17.651742350084497, 8.141631295380437), + Offset(21.607475015528827, 7.674778138535036), + Offset(25.333318994298757, 7.802837303963798), + Offset(28.897243303490924, 8.365237703607285), + Offset(31.96485666870346, 9.212274095165863), + Offset(34.437582726144896, 10.19971254405549), + Offset(36.399462497332735, 11.228903420556733), + Offset(37.93667986528085, 12.22385769608929), + Offset(39.127663680658614, 13.133157349673274), + Offset(40.039293630033626, 13.926627874864629), + Offset(40.72616450135726, 14.58977914511042), + Offset(41.231482940602724, 15.118656246384909), + Offset(41.588813881052715, 15.516089391677047), + Offset(41.82383878806081, 15.789024715198115), + Offset(41.956006532105505, 15.946782060877066), + Offset(41.99990805712887, 15.999888404832046), + Offset(42.0, 16.0), + ], + <Offset>[ + Offset(9.857864376269049, 12.68629150101524), + Offset(10.0286848752889, 12.538401058257541), + Offset(10.632873267418944, 12.039156238673812), + Offset(11.869661541953974, 11.120999908012887), + Offset(14.01269232753313, 9.802147761233421), + Offset(17.63755687713399, 8.1644208393319), + Offset(21.40270587697591, 7.061028351517436), + Offset(25.372566440386116, 6.474033586994366), + Offset(29.043281052671396, 6.480169426635232), + Offset(32.12573791953512, 6.935455570385773), + Offset(34.627918413389764, 7.642591727654583), + Offset(36.617140423267614, 8.447772996169093), + Offset(38.17478812826869, 9.24831138340156), + Offset(39.37733102863164, 9.981782438812019), + Offset(40.28991005711473, 10.613640055506202), + Offset(40.96529784286365, 11.127522886831306), + Offset(41.445182774971684, 11.518668960278076), + Offset(41.761944370115955, 11.789503608737085), + Offset(41.94049711880572, 11.94681212872769), + Offset(41.99987560677617, 11.999888404963674), + Offset(42.0, 12.000000000000002), + ], + <Offset>[ + Offset(9.857864376269049, 12.68629150101524), + Offset(10.0286848752889, 12.538401058257541), + Offset(10.632873267418944, 12.039156238673812), + Offset(11.869661541953974, 11.120999908012887), + Offset(14.01269232753313, 9.802147761233421), + Offset(17.63755687713399, 8.1644208393319), + Offset(21.40270587697591, 7.061028351517436), + Offset(25.372566440386116, 6.474033586994366), + Offset(29.043281052671396, 6.480169426635232), + Offset(32.12573791953512, 6.935455570385773), + Offset(34.627918413389764, 7.642591727654583), + Offset(36.617140423267614, 8.447772996169093), + Offset(38.17478812826869, 9.24831138340156), + Offset(39.37733102863164, 9.981782438812019), + Offset(40.28991005711473, 10.613640055506202), + Offset(40.96529784286365, 11.127522886831306), + Offset(41.445182774971684, 11.518668960278076), + Offset(41.761944370115955, 11.789503608737085), + Offset(41.94049711880572, 11.94681212872769), + Offset(41.99987560677617, 11.999888404963674), + Offset(42.0, 12.000000000000002), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(12.686291501015244, 38.14213562373095), + Offset(12.335961696184835, 37.78957826421869), + Offset(11.186164512970027, 36.485148967548454), + Offset(9.272264829328229, 33.57462824174341), + Offset(7.396433901397504, 28.01370476875244), + Offset(8.455221168610253, 18.46855392229577), + Offset(14.400164412415048, 10.886305797611346), + Offset(23.385743674953297, 7.627722418507858), + Offset(31.513897019057687, 8.97811161083639), + Offset(37.00821523813391, 12.590468749760188), + Offset(40.228807248234226, 16.687449599224042), + Offset(41.900283080823314, 20.466101465000598), + Offset(42.622994421193994, 23.65567978529433), + Offset(42.809759514234806, 26.219169470442615), + Offset(42.724595353131164, 28.207893098186), + Offset(42.529420070562, 29.69761906859474), + Offset(42.319508324086705, 30.763041306693708), + Offset(42.1473924208978, 31.468700863761335), + Offset(42.038312568419684, 31.86713222464318), + Offset(42.00008112385512, 31.999722191105658), + Offset(42.0, 32.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(12.686291501015244, 38.14213562373095), + Offset(12.335961696184835, 37.78957826421869), + Offset(11.186164512970027, 36.485148967548454), + Offset(9.272264829328229, 33.57462824174341), + Offset(7.396433901397504, 28.01370476875244), + Offset(8.455221168610253, 18.46855392229577), + Offset(14.400164412415048, 10.886305797611346), + Offset(23.385743674953297, 7.627722418507858), + Offset(31.513897019057687, 8.97811161083639), + Offset(37.00821523813391, 12.590468749760188), + Offset(40.228807248234226, 16.687449599224042), + Offset(41.900283080823314, 20.466101465000598), + Offset(42.622994421193994, 23.65567978529433), + Offset(42.809759514234806, 26.219169470442615), + Offset(42.724595353131164, 28.207893098186), + Offset(42.529420070562, 29.69761906859474), + Offset(42.319508324086705, 30.763041306693708), + Offset(42.1473924208978, 31.468700863761335), + Offset(42.038312568419684, 31.86713222464318), + Offset(42.00008112385512, 31.999722191105658), + Offset(42.0, 32.0), + ], + <Offset>[ + Offset(38.14213562373096, 12.686291501015244), + Offset(38.423106864699406, 12.98109469595424), + Offset(39.356600322670786, 14.070275639966862), + Offset(40.967250034331684, 16.50336839208347), + Offset(42.74943078741556, 21.21917673678452), + Offset(42.63823566995957, 29.761096576655866), + Offset(38.576286786907716, 37.560546310389874), + Offset(31.46737791244584, 42.70887388266861), + Offset(24.151451365764814, 44.21721480604141), + Offset(18.383677101408267, 43.39836675000767), + Offset(14.25135866363073, 41.61077532101089), + Offset(11.404888461060231, 39.59803568464825), + Offset(9.477230898414673, 37.70410889647938), + Offset(8.1829838828817, 36.06733728866804), + Offset(7.320063260869414, 34.72856080387442), + Offset(6.750940467797069, 33.68514929600791), + Offset(6.383985215921761, 32.91668448571468), + Offset(6.159364334665909, 32.39705126344063), + Offset(6.0390642630099585, 32.09977238767511), + Offset(6.000081127145819, 32.000208946289966), + Offset(5.9999999999999964, 32.00000000000001), + ], + <Offset>[ + Offset(38.14213562373096, 12.686291501015244), + Offset(38.423106864699406, 12.98109469595424), + Offset(39.356600322670786, 14.070275639966862), + Offset(40.967250034331684, 16.50336839208347), + Offset(42.74943078741556, 21.21917673678452), + Offset(42.63823566995957, 29.761096576655866), + Offset(38.576286786907716, 37.560546310389874), + Offset(31.46737791244584, 42.70887388266861), + Offset(24.151451365764814, 44.21721480604141), + Offset(18.383677101408267, 43.39836675000767), + Offset(14.25135866363073, 41.61077532101089), + Offset(11.404888461060231, 39.59803568464825), + Offset(9.477230898414673, 37.70410889647938), + Offset(8.1829838828817, 36.06733728866804), + Offset(7.320063260869414, 34.72856080387442), + Offset(6.750940467797069, 33.68514929600791), + Offset(6.383985215921761, 32.91668448571468), + Offset(6.159364334665909, 32.39705126344063), + Offset(6.0390642630099585, 32.09977238767511), + Offset(6.000081127145819, 32.000208946289966), + Offset(5.9999999999999964, 32.00000000000001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.14213562373096, 12.686291501015244), + Offset(38.423106864699406, 12.98109469595424), + Offset(39.356600322670786, 14.070275639966862), + Offset(40.967250034331684, 16.50336839208347), + Offset(42.74943078741556, 21.21917673678452), + Offset(42.63823566995957, 29.761096576655866), + Offset(38.576286786907716, 37.560546310389874), + Offset(31.46737791244584, 42.70887388266861), + Offset(24.151451365764814, 44.21721480604141), + Offset(18.383677101408267, 43.39836675000767), + Offset(14.25135866363073, 41.61077532101089), + Offset(11.404888461060231, 39.59803568464825), + Offset(9.477230898414673, 37.70410889647938), + Offset(8.1829838828817, 36.06733728866804), + Offset(7.320063260869414, 34.72856080387442), + Offset(6.750940467797069, 33.68514929600791), + Offset(6.383985215921761, 32.91668448571468), + Offset(6.159364334665909, 32.39705126344063), + Offset(6.0390642630099585, 32.09977238767511), + Offset(6.000081127145819, 32.000208946289966), + Offset(5.9999999999999964, 32.00000000000001), + ], + <Offset>[ + Offset(35.31370849898477, 9.857864376269053), + Offset(35.6666086904478, 10.082523010563733), + Offset(36.86605884182839, 10.940227216666777), + Offset(39.07044338436947, 12.981703369305308), + Offset(41.99448322830801, 17.2910659716714), + Offset(43.892962631555136, 25.96298385428372), + Offset(41.540091288327545, 34.8743104910018), + Offset(35.36528363068592, 41.81091452294722), + Offset(28.066907276343148, 45.035264323073946), + Offset(21.806776879213544, 45.46775987631051), + Offset(17.0206170771626, 44.49715849707795), + Offset(13.530658929909972, 42.986412864621926), + Offset(11.038167466324122, 41.386971510121526), + Offset(9.277224751573414, 39.91475680326283), + Offset(8.044581894834794, 38.66239770301462), + Offset(7.193999381954086, 37.66053591853735), + Offset(6.6232789024796475, 36.90952038662189), + Offset(6.2625143790747195, 36.39572105079973), + Offset(6.064913170013508, 36.09968886605397), + Offset(6.000135211055188, 36.00020894592433), + Offset(5.9999999999999964, 36.00000000000001), + ], + <Offset>[ + Offset(35.31370849898477, 9.857864376269053), + Offset(35.6666086904478, 10.082523010563733), + Offset(36.86605884182839, 10.940227216666777), + Offset(39.07044338436947, 12.981703369305308), + Offset(41.99448322830801, 17.2910659716714), + Offset(43.892962631555136, 25.96298385428372), + Offset(41.540091288327545, 34.8743104910018), + Offset(35.36528363068592, 41.81091452294722), + Offset(28.066907276343148, 45.035264323073946), + Offset(21.806776879213544, 45.46775987631051), + Offset(17.0206170771626, 44.49715849707795), + Offset(13.530658929909972, 42.986412864621926), + Offset(11.038167466324122, 41.386971510121526), + Offset(9.277224751573414, 39.91475680326283), + Offset(8.044581894834794, 38.66239770301462), + Offset(7.193999381954086, 37.66053591853735), + Offset(6.6232789024796475, 36.90952038662189), + Offset(6.2625143790747195, 36.39572105079973), + Offset(6.064913170013508, 36.09968886605397), + Offset(6.000135211055188, 36.00020894592433), + Offset(5.9999999999999964, 36.00000000000001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.31370849898477, 9.857864376269053), + Offset(35.6666086904478, 10.082523010563733), + Offset(36.86605884182839, 10.940227216666777), + Offset(39.07044338436947, 12.981703369305308), + Offset(41.99448322830801, 17.2910659716714), + Offset(43.892962631555136, 25.96298385428372), + Offset(41.540091288327545, 34.8743104910018), + Offset(35.36528363068592, 41.81091452294722), + Offset(28.066907276343148, 45.035264323073946), + Offset(21.806776879213544, 45.46775987631051), + Offset(17.0206170771626, 44.49715849707795), + Offset(13.530658929909972, 42.986412864621926), + Offset(11.038167466324122, 41.386971510121526), + Offset(9.277224751573414, 39.91475680326283), + Offset(8.044581894834794, 38.66239770301462), + Offset(7.193999381954086, 37.66053591853735), + Offset(6.6232789024796475, 36.90952038662189), + Offset(6.2625143790747195, 36.39572105079973), + Offset(6.064913170013508, 36.09968886605397), + Offset(6.000135211055188, 36.00020894592433), + Offset(5.9999999999999964, 36.00000000000001), + ], + <Offset>[ + Offset(9.857864376269053, 35.31370849898477), + Offset(9.579463521933231, 34.89100657882818), + Offset(8.695623032127628, 33.35510054424837), + Offset(7.375458179366014, 30.052963218965253), + Offset(6.641486342289959, 24.085594003639322), + Offset(9.70994813020582, 14.670441199923623), + Offset(17.36396891383488, 8.200069978223272), + Offset(27.283649393193382, 6.729763058786467), + Offset(35.429352929636025, 9.796161127868931), + Offset(40.431315015939184, 14.659861876063037), + Offset(42.99806566176609, 19.573832775291095), + Offset(44.02605354967305, 23.854478644974275), + Offset(44.183930989103445, 27.338542398936475), + Offset(43.90400038292652, 30.066588985037402), + Offset(43.44911398709654, 32.141729997326195), + Offset(42.97247898471902, 33.673005691124175), + Offset(42.55880201064459, 34.75587720760092), + Offset(42.25054246530661, 35.46737065112043), + Offset(42.064161475423234, 35.867048703022036), + Offset(42.00013520776449, 35.99972219074003), + Offset(42.0, 36.0), + ], + <Offset>[ + Offset(9.857864376269053, 35.31370849898477), + Offset(9.579463521933231, 34.89100657882818), + Offset(8.695623032127628, 33.35510054424837), + Offset(7.375458179366014, 30.052963218965253), + Offset(6.641486342289959, 24.085594003639322), + Offset(9.70994813020582, 14.670441199923623), + Offset(17.36396891383488, 8.200069978223272), + Offset(27.283649393193382, 6.729763058786467), + Offset(35.429352929636025, 9.796161127868931), + Offset(40.431315015939184, 14.659861876063037), + Offset(42.99806566176609, 19.573832775291095), + Offset(44.02605354967305, 23.854478644974275), + Offset(44.183930989103445, 27.338542398936475), + Offset(43.90400038292652, 30.066588985037402), + Offset(43.44911398709654, 32.141729997326195), + Offset(42.97247898471902, 33.673005691124175), + Offset(42.55880201064459, 34.75587720760092), + Offset(42.25054246530661, 35.46737065112043), + Offset(42.064161475423234, 35.867048703022036), + Offset(42.00013520776449, 35.99972219074003), + Offset(42.0, 36.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.857864376269053, 35.31370849898477), + Offset(9.579463521933231, 34.89100657882818), + Offset(8.695623032127628, 33.35510054424837), + Offset(7.375458179366014, 30.052963218965253), + Offset(6.641486342289959, 24.085594003639322), + Offset(9.70994813020582, 14.670441199923623), + Offset(17.36396891383488, 8.200069978223272), + Offset(27.283649393193382, 6.729763058786467), + Offset(35.429352929636025, 9.796161127868931), + Offset(40.431315015939184, 14.659861876063037), + Offset(42.99806566176609, 19.573832775291095), + Offset(44.02605354967305, 23.854478644974275), + Offset(44.183930989103445, 27.338542398936475), + Offset(43.90400038292652, 30.066588985037402), + Offset(43.44911398709654, 32.141729997326195), + Offset(42.97247898471902, 33.673005691124175), + Offset(42.55880201064459, 34.75587720760092), + Offset(42.25054246530661, 35.46737065112043), + Offset(42.064161475423234, 35.867048703022036), + Offset(42.00013520776449, 35.99972219074003), + Offset(42.0, 36.0), + ], + <Offset>[ + Offset(12.686291501015244, 38.14213562373095), + Offset(12.335961696184835, 37.78957826421869), + Offset(11.186164512970027, 36.485148967548454), + Offset(9.272264829328229, 33.57462824174341), + Offset(7.396433901397504, 28.01370476875244), + Offset(8.455221168610253, 18.46855392229577), + Offset(14.400164412415048, 10.886305797611346), + Offset(23.385743674953297, 7.627722418507858), + Offset(31.513897019057687, 8.97811161083639), + Offset(37.00821523813391, 12.590468749760188), + Offset(40.228807248234226, 16.687449599224042), + Offset(41.900283080823314, 20.466101465000598), + Offset(42.622994421193994, 23.65567978529433), + Offset(42.809759514234806, 26.219169470442615), + Offset(42.724595353131164, 28.207893098186), + Offset(42.529420070562, 29.69761906859474), + Offset(42.319508324086705, 30.763041306693708), + Offset(42.1473924208978, 31.468700863761335), + Offset(42.038312568419684, 31.86713222464318), + Offset(42.00008112385512, 31.999722191105658), + Offset(42.0, 32.0), + ], + <Offset>[ + Offset(12.686291501015244, 38.14213562373095), + Offset(12.335961696184835, 37.78957826421869), + Offset(11.186164512970027, 36.485148967548454), + Offset(9.272264829328229, 33.57462824174341), + Offset(7.396433901397504, 28.01370476875244), + Offset(8.455221168610253, 18.46855392229577), + Offset(14.400164412415048, 10.886305797611346), + Offset(23.385743674953297, 7.627722418507858), + Offset(31.513897019057687, 8.97811161083639), + Offset(37.00821523813391, 12.590468749760188), + Offset(40.228807248234226, 16.687449599224042), + Offset(41.900283080823314, 20.466101465000598), + Offset(42.622994421193994, 23.65567978529433), + Offset(42.809759514234806, 26.219169470442615), + Offset(42.724595353131164, 28.207893098186), + Offset(42.529420070562, 29.69761906859474), + Offset(42.319508324086705, 30.763041306693708), + Offset(42.1473924208978, 31.468700863761335), + Offset(42.038312568419684, 31.86713222464318), + Offset(42.00008112385512, 31.999722191105658), + Offset(42.0, 32.0), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/ellipsis_search.g.dart b/packages/material_ui/lib/src/animated_icons/data/ellipsis_search.g.dart new file mode 100644 index 000000000000..c22e52e476f8 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/ellipsis_search.g.dart @@ -0,0 +1,5247 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$ellipsis_search = _AnimatedIconData(Size(96.0, 96.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(57.62369742449878, 56.510753652468935), + Offset(57.50386046799878, 56.38647829016894), + Offset(57.38402351149878, 56.26220292786894), + Offset(57.26418655499878, 56.13792756556894), + Offset(57.144349598398776, 56.01365220316894), + Offset(57.02451264189878, 55.88937684086894), + Offset(56.90467568539878, 55.76510147856894), + Offset(56.78483872889878, 55.64082611626894), + Offset(56.66500177229878, 55.516550753968936), + Offset(56.54516481579878, 55.39227539166894), + Offset(56.42532785929878, 55.26800002926894), + Offset(56.30549090279878, 55.143724666968936), + Offset(56.18565394629878, 55.01944930466894), + Offset(56.06581698969878, 54.895173942368935), + Offset(55.94598003319878, 54.77089858006894), + Offset(55.89372930109525, 54.63379537519879), + Offset(55.99467401074788, 54.46761572737509), + Offset(56.09561872053984, 54.30143607948509), + Offset(56.19656343029247, 54.13525643166141), + Offset(56.297508139945094, 53.969076783837714), + Offset(56.398452849697726, 53.80289713591403), + Offset(56.49939755945036, 53.63671748809034), + Offset(56.297960835674125, 53.25914221256317), + Offset(55.78881270857159, 52.69340023656423), + Offset(56.006714995568615, 52.88005225271881), + Offset(56.182356492271566, 53.03050427381422), + Offset(56.32344115337003, 53.15135539844181), + Offset(56.43452394733299, 53.246507348148306), + Offset(56.52071628832435, 53.320338487990085), + Offset(56.5859271296418, 53.37619717205798), + Offset(56.63368824673307, 53.417108669232874), + Offset(56.66699682004896, 53.44564032188472), + Offset(56.68847407317482, 53.46403743305161), + Offset(56.70040259814858, 53.474255238779065), + Offset(56.70477862272339, 53.478003679517734), + Offset(56.70335264548873, 53.47678220759329), + Offset(56.69766279856795, 53.471908365221054), + Offset(56.689065722138324, 53.46454423118308), + Offset(56.678761173675056, 53.45571750075846), + Offset(56.667817449874256, 53.44634326178278), + Offset(56.657150392580974, 53.43720601142315), + Offset(56.64761589840118, 53.429038898603), + Offset(56.639944407998925, 53.42246760846087), + Offset(56.63479944561546, 53.418060506474255), + Offset(56.632765532511705, 53.416318285285435), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + ]), + _PathCubicTo( + <Offset>[ + Offset(59.11722254046772, 54.78101039685442), + Offset(58.99738558396772, 54.65673503455442), + Offset(58.877548627467725, 54.532459672254426), + Offset(58.75771167096772, 54.40818430995442), + Offset(58.63787471436772, 54.28390894755442), + Offset(58.518037757867724, 54.159633585254426), + Offset(58.39820080136772, 54.03535822295442), + Offset(58.27836384486772, 53.911082860654425), + Offset(58.15852688826772, 53.78680749835442), + Offset(58.03868993176772, 53.66253213605442), + Offset(57.918852975267725, 53.538256773654425), + Offset(57.79901601876772, 53.41398141135442), + Offset(57.67917906226772, 53.28970604905442), + Offset(57.559342105667724, 53.16543068675442), + Offset(57.43950514916772, 53.04115532445442), + Offset(57.485490128564514, 52.790279302768965), + Offset(57.907338162449705, 52.252441786684095), + Offset(58.32918619648207, 51.71460427052383), + Offset(58.75103423046726, 51.176766754438965), + Offset(59.17288226435245, 50.63892923835408), + Offset(59.59473029833764, 50.101091722169215), + Offset(60.01657833232283, 49.56325420608434), + Offset(60.01932238869081, 48.94920461646341), + Offset(59.563472601893324, 48.32173455588634), + Offset(59.82481548777557, 48.45807534383447), + Offset(60.035472549991965, 48.56797368582478), + Offset(60.204683589459826, 48.656249922511655), + Offset(60.33791164477868, 48.72575408359739), + Offset(60.441287132232546, 48.77968436513692), + Offset(60.51949828685408, 48.820486587135186), + Offset(60.57678097234404, 48.850370570581376), + Offset(60.61672988153154, 48.871211642026296), + Offset(60.64248880031156, 48.88464989297123), + Offset(60.65679537406957, 48.892113534204164), + Offset(60.66204379484951, 48.89485159926458), + Offset(60.66033353741787, 48.89395936971321), + Offset(60.65350937308716, 48.890399250068874), + Offset(60.643198399323495, 48.885020085900294), + Offset(60.630839555044176, 48.87857256175406), + Offset(60.617714110608446, 48.87172510757334), + Offset(60.60492048840596, 48.86505076277184), + Offset(60.593485215014766, 48.85908505921234), + Offset(60.58428435040535, 48.85428503136227), + Offset(60.57811369779046, 48.85106584428997), + Offset(60.575674307513765, 48.84979323114302), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + ], + <Offset>[ + Offset(60.01637695941208, 52.53311272229554), + Offset(59.896540002912076, 52.408837359995545), + Offset(59.77670304641208, 52.28456199769555), + Offset(59.65686608991208, 52.160286635395536), + Offset(59.537029133312075, 52.03601127299554), + Offset(59.41719217681208, 51.91173591069554), + Offset(59.29735522031208, 51.78746054839554), + Offset(59.177518263812075, 51.663185186095546), + Offset(59.05768130721208, 51.538909823795535), + Offset(58.937844350712076, 51.41463446149554), + Offset(58.81800739421208, 51.29035909909554), + Offset(58.69817043771208, 51.16608373679554), + Offset(58.57833348121208, 51.041808374495545), + Offset(58.45849652461208, 50.917533012195534), + Offset(58.33865956811208, 50.79325764989554), + Offset(58.44378588577579, 50.39452751777087), + Offset(59.058828957998486, 49.37369990758731), + Offset(59.673872030373076, 48.35287229731656), + Offset(60.28891510269577, 47.332044687133006), + Offset(60.90395817491846, 46.31121707694944), + Offset(61.51900124724116, 45.290389466665886), + Offset(62.13404431956385, 44.26956185648232), + Offset(62.259712355426515, 43.348200728562944), + Offset(61.83595003585357, 42.64051158499319), + Offset(62.12344568343471, 42.711470130505994), + Offset(62.35518334223874, 42.76866670842857), + Offset(62.54132744639319, 42.81461006443314), + Offset(62.68788772451427, 42.85078349611087), + Offset(62.80160806784497, 42.87885150418633), + Offset(62.88764586631069, 42.90008701536577), + Offset(62.95066086939573, 42.91564013069819), + Offset(62.9946074933042, 42.92648686364521), + Offset(63.02294412474014, 42.933480799617264), + Offset(63.038682367138975, 42.93736525073486), + Offset(63.04445600098332, 42.9387902763426), + Offset(63.042574596918385, 42.938325915587605), + Offset(63.03506753378623, 42.936473051777625), + Offset(63.02372473381152, 42.93367346647948), + Offset(63.010129131515164, 42.93031785336868), + Offset(62.99569021485936, 42.92675409672298), + Offset(62.981616326115095, 42.92328043483138), + Offset(62.969036717078595, 42.92017558518285), + Offset(62.958915115642, 42.91767741130702), + Offset(62.952126961590935, 42.916001985810205), + Offset(62.94944345985745, 42.91533965446192), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + ], + <Offset>[ + Offset(60.01637695941208, 50.07184435071208), + Offset(59.896540002912076, 49.94756898841208), + Offset(59.77670304641208, 49.82329362611208), + Offset(59.65686608991208, 49.69901826381208), + Offset(59.537029133312075, 49.57474290141208), + Offset(59.41719217681208, 49.45046753911208), + Offset(59.29735522031208, 49.32619217681208), + Offset(59.177518263812075, 49.20191681451208), + Offset(59.05768130721208, 49.07764145221208), + Offset(58.937844350712076, 48.95336608991208), + Offset(58.81800739421208, 48.82909072751208), + Offset(58.69817043771208, 48.70481536521208), + Offset(58.57833348121208, 48.58054000291208), + Offset(58.45849652461208, 48.456264640612076), + Offset(58.33865956811208, 48.33198927831208), + Offset(58.44378588577579, 47.77137070715779), + Offset(59.058828957998486, 46.22170752948048), + Offset(59.673872030373076, 44.67204435170307), + Offset(60.28891510269577, 43.12238117402577), + Offset(60.90395817491846, 41.57271799634846), + Offset(61.51900124724116, 40.023054818571154), + Offset(62.13404431956385, 38.47339164089385), + Offset(62.259712355426515, 37.21554892931651), + Offset(61.83595003585357, 36.42002629848357), + Offset(62.12344568343471, 36.4193965118447), + Offset(62.35518334223874, 36.41888886839874), + Offset(62.54132744639319, 36.41848110198319), + Offset(62.68788772451427, 36.41816004771427), + Offset(62.80160806784497, 36.41791093245497), + Offset(62.88764586631069, 36.4177224584507), + Offset(62.95066086939573, 36.417584418075734), + Offset(62.9946074933042, 36.4174881488042), + Offset(63.02294412474014, 36.41742607471014), + Offset(63.038682367138975, 36.41739159858898), + Offset(63.04445600098332, 36.417378950893315), + Offset(63.042574596918385, 36.41738307228838), + Offset(63.03506753378623, 36.41739951722624), + Offset(63.02372473381152, 36.417424364711515), + Offset(63.010129131515164, 36.417454147175164), + Offset(62.99569021485936, 36.41748577699936), + Offset(62.981616326115095, 36.41751660719509), + Offset(62.969036717078595, 36.4175441640286), + Offset(62.958915115642, 36.41756633636199), + Offset(62.952126961590935, 36.41758120646094), + Offset(62.94944345985745, 36.41758708492745), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(60.01637695941208, 44.60068993176772), + Offset(59.896540002912076, 44.47641456946772), + Offset(59.77670304641208, 44.352139207167724), + Offset(59.65686608991208, 44.22786384486772), + Offset(59.537029133312075, 44.10358848246772), + Offset(59.41719217681208, 43.979313120167724), + Offset(59.29735522031208, 43.85503775786772), + Offset(59.177518263812075, 43.73076239556772), + Offset(59.05768130721208, 43.60648703326772), + Offset(58.937844350712076, 43.48221167096772), + Offset(58.81800739421208, 43.35793630856772), + Offset(58.69817043771208, 43.23366094626772), + Offset(58.57833348121208, 43.10938558396772), + Offset(58.45849652461208, 42.98511022166772), + Offset(58.33865956811208, 42.86083485936772), + Offset(58.44378588577579, 41.94035441423051), + Offset(59.058828957998486, 39.2151424482157), + Offset(59.673872030373076, 36.48993048207206), + Offset(60.28891510269577, 33.76471851605726), + Offset(60.90395817491846, 31.039506550042443), + Offset(61.51900124724116, 28.314294583927637), + Offset(62.13404431956385, 25.58908261791283), + Offset(62.259712355426515, 23.5832748633608), + Offset(61.83595003585357, 22.59250713958332), + Offset(62.12344568343471, 22.432743621605564), + Offset(62.35518334223874, 22.303965242071957), + Offset(62.54132744639319, 22.20052354762982), + Offset(62.68788772451427, 22.11907888637867), + Offset(62.80160806784497, 22.05588362666254), + Offset(62.88764586631069, 22.00807176767408), + Offset(62.95066086939573, 21.973053851184037), + Offset(62.9946074933042, 21.948632378031544), + Offset(63.02294412474014, 21.932885496421555), + Offset(63.038682367138975, 21.92413963541956), + Offset(63.04445600098332, 21.920931183179505), + Offset(63.042574596918385, 21.92197669372786), + Offset(63.03506753378623, 21.926148425807156), + Offset(63.02372473381152, 21.93245170602349), + Offset(63.010129131515164, 21.940006885624168), + Offset(62.99569021485936, 21.94803070142844), + Offset(62.981616326115095, 21.955851668445952), + Offset(62.969036717078595, 21.962842252864764), + Offset(62.958915115642, 21.968466903765343), + Offset(62.952126961590935, 21.972239132600446), + Offset(62.94944345985745, 21.973730374923758), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + ], + <Offset>[ + Offset(55.58154300555442, 40.16584435071208), + Offset(55.46170604905442, 40.04156898841208), + Offset(55.341869092554425, 39.917293626112084), + Offset(55.22203213605442, 39.79301826381208), + Offset(55.10219517945442, 39.66874290141208), + Offset(54.982358222954424, 39.544467539112084), + Offset(54.86252126645442, 39.42019217681208), + Offset(54.74268430995442, 39.29591681451208), + Offset(54.62284735335442, 39.17164145221208), + Offset(54.50301039685442, 39.04736608991208), + Offset(54.383173440354426, 38.92309072751208), + Offset(54.263336483854424, 38.79881536521208), + Offset(54.14349952735442, 38.67454000291208), + Offset(54.023662570754425, 38.55026464061208), + Offset(53.90382561425442, 38.42598927831208), + Offset(53.717253409954964, 37.21380954643979), + Offset(53.3794146437701, 33.53571324376249), + Offset(53.04157587771383, 29.857616940933074), + Offset(52.70373711162896, 26.17952063825577), + Offset(52.36589834544409, 22.50142433557846), + Offset(52.028059579359216, 18.823328032801157), + Offset(51.69022081327434, 15.145231730123854), + Offset(51.209599844133415, 12.533133381006508), + Offset(50.627574843376344, 11.384102561113572), + Offset(50.786079126264475, 11.095347340254705), + Offset(50.91384249150479, 10.86259439455874), + Offset(51.01646887208166, 10.675634757573192), + Offset(51.09727159719739, 10.528432370914274), + Offset(51.159968760166926, 10.414213797064974), + Offset(51.207403772355185, 10.327799050590698), + Offset(51.24214568222138, 10.264507966755737), + Offset(51.266374668526296, 10.220368804304208), + Offset(51.281997435281234, 10.191908024680146), + Offset(51.29067436255417, 10.176100830038976), + Offset(51.29385752619458, 10.170301900803324), + Offset(51.29282025622321, 10.172191547658386), + Offset(51.288681405188875, 10.179731500666236), + Offset(51.282427806600296, 10.19112399561152), + Offset(51.27493217593407, 10.204779162835159), + Offset(51.26697160279334, 10.219281339139357), + Offset(51.25921227961184, 10.233416888275087), + Offset(51.25227679406234, 10.246051610978604), + Offset(51.24669647592227, 10.256217557081996), + Offset(51.242953979299976, 10.263035451330936), + Offset(51.24147449075302, 10.265730709997449), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + ], + <Offset>[ + Offset(50.11037695941208, 40.16584435071208), + Offset(49.99054000291208, 40.04156898841208), + Offset(49.87070304641208, 39.917293626112084), + Offset(49.75086608991208, 39.79301826381208), + Offset(49.631029133312076, 39.66874290141208), + Offset(49.51119217681208, 39.544467539112084), + Offset(49.39135522031208, 39.42019217681208), + Offset(49.27151826381208, 39.29591681451208), + Offset(49.15168130721208, 39.17164145221208), + Offset(49.03184435071208, 39.04736608991208), + Offset(48.91200739421208, 38.92309072751208), + Offset(48.79217043771208, 38.79881536521208), + Offset(48.67233348121208, 38.67454000291208), + Offset(48.55249652461208, 38.55026464061208), + Offset(48.43265956811208, 38.42598927831208), + Offset(47.886224725057794, 37.21380954643979), + Offset(46.37283467228049, 33.53571324376249), + Offset(44.859444619603074, 29.857616940933074), + Offset(43.346054566925766, 26.17952063825577), + Offset(41.83266451414846, 22.50142433557846), + Offset(40.31927446147115, 18.823328032801157), + Offset(38.80588440879385, 15.145231730123854), + Offset(37.57729680711651, 12.533133381006508), + Offset(36.80002629848357, 11.384102561113572), + Offset(36.799396511844705, 11.095347340254705), + Offset(36.79888886839874, 10.86259439455874), + Offset(36.798481101983185, 10.675634757573192), + Offset(36.798160047714276, 10.528432370914274), + Offset(36.79791093245497, 10.414213797064974), + Offset(36.79772245845069, 10.327799050590698), + Offset(36.79758441807573, 10.264507966755737), + Offset(36.797488148804206, 10.220368804304208), + Offset(36.797426074710145, 10.191908024680146), + Offset(36.79739159858897, 10.176100830038976), + Offset(36.79737895089332, 10.170301900803324), + Offset(36.79738307228838, 10.172191547658386), + Offset(36.79739951722624, 10.179731500666236), + Offset(36.79742436471152, 10.19112399561152), + Offset(36.79745414717516, 10.204779162835159), + Offset(36.79748577699936, 10.219281339139357), + Offset(36.79751660719509, 10.233416888275087), + Offset(36.7975441640286, 10.246051610978604), + Offset(36.797566336361996, 10.256217557081996), + Offset(36.79758120646093, 10.263035451330936), + Offset(36.79758708492745, 10.265730709997449), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.63922254046772, 40.16584435071208), + Offset(44.51938558396772, 40.04156898841208), + Offset(44.39954862746772, 39.917293626112084), + Offset(44.27971167096772, 39.79301826381208), + Offset(44.15987471436772, 39.66874290141208), + Offset(44.04003775786772, 39.544467539112084), + Offset(43.92020080136772, 39.42019217681208), + Offset(43.80036384486772, 39.29591681451208), + Offset(43.68052688826772, 39.17164145221208), + Offset(43.56068993176772, 39.04736608991208), + Offset(43.440852975267724, 38.92309072751208), + Offset(43.32101601876772, 38.79881536521208), + Offset(43.20117906226772, 38.67454000291208), + Offset(43.08134210566772, 38.55026464061208), + Offset(42.96150514916772, 38.42598927831208), + Offset(42.05520843213051, 37.21380954643979), + Offset(39.36626959101571, 33.53571324376249), + Offset(36.677330749972064, 29.857616940933074), + Offset(33.988391908957254, 26.17952063825577), + Offset(31.299453067842446, 22.50142433557846), + Offset(28.610514226827636, 18.823328032801157), + Offset(25.92157538581283, 15.145231730123854), + Offset(23.945022741160805, 12.533133381006508), + Offset(22.97250713958332, 11.384102561113572), + Offset(22.812743621605566, 11.095347340254705), + Offset(22.68396524207196, 10.86259439455874), + Offset(22.580523547629824, 10.675634757573192), + Offset(22.499078886378673, 10.528432370914274), + Offset(22.43588362666254, 10.414213797064974), + Offset(22.38807176767408, 10.327799050590698), + Offset(22.35305385118404, 10.264507966755737), + Offset(22.328632378031546, 10.220368804304208), + Offset(22.312885496421558, 10.191908024680146), + Offset(22.304139635419563, 10.176100830038976), + Offset(22.300931183179507, 10.170301900803324), + Offset(22.301976693727863, 10.172191547658386), + Offset(22.30614842580716, 10.179731500666236), + Offset(22.31245170602349, 10.19112399561152), + Offset(22.32000688562417, 10.204779162835159), + Offset(22.328030701428442, 10.219281339139357), + Offset(22.335851668445954, 10.233416888275087), + Offset(22.342842252864767, 10.246051610978604), + Offset(22.348466903765345, 10.256217557081996), + Offset(22.35223913260045, 10.263035451330936), + Offset(22.35373037492376, 10.265730709997449), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + ], + <Offset>[ + Offset(40.20437695941208, 44.60068993176772), + Offset(40.08454000291208, 44.47641456946772), + Offset(39.96470304641208, 44.352139207167724), + Offset(39.84486608991208, 44.22786384486772), + Offset(39.72502913331208, 44.10358848246772), + Offset(39.60519217681208, 43.979313120167724), + Offset(39.48535522031208, 43.85503775786772), + Offset(39.36551826381208, 43.73076239556772), + Offset(39.24568130721208, 43.60648703326772), + Offset(39.12584435071208, 43.48221167096772), + Offset(39.006007394212084, 43.35793630856772), + Offset(38.88617043771208, 43.23366094626772), + Offset(38.76633348121208, 43.10938558396772), + Offset(38.64649652461208, 42.98511022166772), + Offset(38.52665956811208, 42.86083485936772), + Offset(37.32866356433979, 41.94035441423051), + Offset(33.686840386562494, 39.2151424482157), + Offset(30.045017208833077, 36.48993048207207), + Offset(26.403194031155767, 33.764718516057265), + Offset(22.761370853378462, 31.03950655004245), + Offset(19.119547675701156, 28.314294583927644), + Offset(15.477724498023854, 25.589082617912837), + Offset(12.894881258806514, 23.583274863360806), + Offset(11.764102561113575, 22.592507139583326), + Offset(11.475347340254707, 22.43274362160557), + Offset(11.242594394558743, 22.303965242071964), + Offset(11.055634757573195, 22.200523547629828), + Offset(10.908432370914277, 22.119078886378677), + Offset(10.794213797064977, 22.055883626662546), + Offset(10.707799050590701, 22.008071767674085), + Offset(10.64450796675574, 21.973053851184044), + Offset(10.60036880430421, 21.94863237803155), + Offset(10.571908024680148, 21.932885496421562), + Offset(10.556100830038979, 21.924139635419568), + Offset(10.550301900803326, 21.920931183179516), + Offset(10.552191547658389, 21.921976693727867), + Offset(10.559731500666238, 21.926148425807163), + Offset(10.571123995611522, 21.932451706023496), + Offset(10.584779162835162, 21.94000688562418), + Offset(10.59928133913936, 21.948030701428447), + Offset(10.61341688827509, 21.95585166844596), + Offset(10.626051610978607, 21.96284225286477), + Offset(10.636217557081999, 21.96846690376535), + Offset(10.643035451330938, 21.972239132600453), + Offset(10.645730709997451, 21.973730374923765), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + ], + <Offset>[ + Offset(40.20437695941208, 50.07184435071208), + Offset(40.08454000291208, 49.94756898841208), + Offset(39.96470304641208, 49.82329362611208), + Offset(39.84486608991208, 49.69901826381208), + Offset(39.72502913331208, 49.57474290141208), + Offset(39.60519217681208, 49.45046753911208), + Offset(39.48535522031208, 49.32619217681208), + Offset(39.36551826381208, 49.20191681451208), + Offset(39.24568130721208, 49.07764145221208), + Offset(39.12584435071208, 48.95336608991208), + Offset(39.006007394212084, 48.82909072751208), + Offset(38.88617043771208, 48.70481536521208), + Offset(38.76633348121208, 48.58054000291208), + Offset(38.64649652461208, 48.456264640612076), + Offset(38.52665956811208, 48.33198927831208), + Offset(37.32866356433979, 47.77137070715779), + Offset(33.686840386562494, 46.22170752948048), + Offset(30.045017208833077, 44.67204435170307), + Offset(26.403194031155767, 43.122381174025776), + Offset(22.761370853378462, 41.57271799634846), + Offset(19.119547675701156, 40.023054818571154), + Offset(15.477724498023854, 38.47339164089385), + Offset(12.894881258806514, 37.21554892931651), + Offset(11.764102561113575, 36.42002629848357), + Offset(11.475347340254707, 36.41939651184471), + Offset(11.242594394558743, 36.41888886839874), + Offset(11.055634757573195, 36.41848110198319), + Offset(10.908432370914277, 36.418160047714274), + Offset(10.794213797064977, 36.41791093245497), + Offset(10.707799050590701, 36.4177224584507), + Offset(10.64450796675574, 36.417584418075734), + Offset(10.60036880430421, 36.4174881488042), + Offset(10.571908024680148, 36.41742607471014), + Offset(10.556100830038979, 36.41739159858898), + Offset(10.550301900803326, 36.41737895089332), + Offset(10.552191547658389, 36.41738307228839), + Offset(10.559731500666238, 36.41739951722624), + Offset(10.571123995611522, 36.41742436471152), + Offset(10.584779162835162, 36.417454147175164), + Offset(10.59928133913936, 36.41748577699936), + Offset(10.61341688827509, 36.4175166071951), + Offset(10.626051610978607, 36.4175441640286), + Offset(10.636217557081999, 36.417566336362), + Offset(10.643035451330938, 36.41758120646094), + Offset(10.645730709997451, 36.41758708492745), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.20437695941208, 55.54301039685442), + Offset(40.08454000291208, 55.41873503455442), + Offset(39.96470304641208, 55.294459672254426), + Offset(39.84486608991208, 55.17018430995442), + Offset(39.72502913331208, 55.04590894755442), + Offset(39.60519217681208, 54.921633585254426), + Offset(39.48535522031208, 54.79735822295442), + Offset(39.36551826381208, 54.673082860654425), + Offset(39.24568130721208, 54.54880749835442), + Offset(39.12584435071208, 54.424532136054424), + Offset(39.006007394212084, 54.300256773654425), + Offset(38.88617043771208, 54.17598141135442), + Offset(38.76633348121208, 54.051706049054424), + Offset(38.64649652461208, 53.92743068675442), + Offset(38.52665956811208, 53.80315532445442), + Offset(37.32866356433979, 53.60239939205497), + Offset(33.686840386562494, 53.2282875009701), + Offset(30.045017208833077, 52.854175609813836), + Offset(26.403194031155767, 52.480063718728964), + Offset(22.761370853378462, 52.105951827644084), + Offset(19.119547675701156, 51.73183993645922), + Offset(15.477724498023854, 51.357728045374344), + Offset(12.894881258806514, 50.847851966333415), + Offset(11.764102561113575, 50.24757484337634), + Offset(11.475347340254707, 50.40607912626448), + Offset(11.242594394558743, 50.53384249150479), + Offset(11.055634757573195, 50.636468872081664), + Offset(10.908432370914277, 50.717271597197396), + Offset(10.794213797064977, 50.77996876016692), + Offset(10.707799050590701, 50.82740377235519), + Offset(10.64450796675574, 50.86214568222138), + Offset(10.60036880430421, 50.8863746685263), + Offset(10.571908024680148, 50.90199743528123), + Offset(10.556100830038979, 50.91067436255417), + Offset(10.550301900803326, 50.913857526194576), + Offset(10.552191547658389, 50.91282025622321), + Offset(10.559731500666238, 50.90868140518888), + Offset(10.571123995611522, 50.9024278066003), + Offset(10.584779162835162, 50.894932175934066), + Offset(10.59928133913936, 50.886971602793345), + Offset(10.61341688827509, 50.87921227961184), + Offset(10.626051610978607, 50.872276794062344), + Offset(10.636217557081999, 50.866696475922275), + Offset(10.643035451330938, 50.862953979299974), + Offset(10.645730709997451, 50.861474490753025), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + ], + <Offset>[ + Offset(44.63922254046772, 59.97784435071208), + Offset(44.51938558396772, 59.853568988412086), + Offset(44.39954862746772, 59.72929362611209), + Offset(44.27971167096772, 59.605018263812084), + Offset(44.15987471436772, 59.480742901412086), + Offset(44.04003775786772, 59.35646753911209), + Offset(43.92020080136772, 59.232192176812084), + Offset(43.80036384486772, 59.10791681451209), + Offset(43.68052688826772, 58.98364145221208), + Offset(43.56068993176772, 58.859366089912086), + Offset(43.440852975267724, 58.73509072751209), + Offset(43.32101601876772, 58.61081536521208), + Offset(43.20117906226772, 58.486540002912086), + Offset(43.08134210566772, 58.36226464061208), + Offset(42.96150514916772, 58.237989278312085), + Offset(42.05520843213051, 58.328931867875795), + Offset(39.36626959101571, 58.907701815198486), + Offset(36.67733074997207, 59.48647176247307), + Offset(33.98839190895726, 60.06524170979577), + Offset(31.299453067842453, 60.644011657118455), + Offset(28.610514226827643, 61.22278160434116), + Offset(25.921575385812837, 61.80155155166385), + Offset(23.945022741160813, 61.897964477626516), + Offset(22.97250713958333, 61.455950035853576), + Offset(22.812743621605573, 61.74344568343471), + Offset(22.683965242071967, 61.975183342238736), + Offset(22.58052354762983, 62.16132744639319), + Offset(22.49907888637868, 62.30788772451427), + Offset(22.435883626662548, 62.42160806784497), + Offset(22.388071767674088, 62.507645866310696), + Offset(22.353053851184047, 62.57066086939574), + Offset(22.328632378031553, 62.614607493304206), + Offset(22.312885496421565, 62.64294412474014), + Offset(22.30413963541957, 62.65868236713898), + Offset(22.300931183179518, 62.66445600098332), + Offset(22.30197669372787, 62.66257459691839), + Offset(22.306148425807166, 62.65506753378624), + Offset(22.3124517060235, 62.643724733811524), + Offset(22.32000688562418, 62.63012913151516), + Offset(22.32803070142845, 62.615690214859356), + Offset(22.33585166844596, 62.60161632611509), + Offset(22.342842252864774, 62.5890367170786), + Offset(22.348466903765352, 62.578915115642), + Offset(22.352239132600456, 62.57212696159094), + Offset(22.353730374923767, 62.56944345985745), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + ], + <Offset>[ + Offset(50.11037695941208, 59.97784435071208), + Offset(49.99054000291208, 59.853568988412086), + Offset(49.87070304641208, 59.72929362611209), + Offset(49.75086608991208, 59.605018263812084), + Offset(49.631029133312076, 59.480742901412086), + Offset(49.51119217681208, 59.35646753911209), + Offset(49.39135522031208, 59.232192176812084), + Offset(49.27151826381208, 59.10791681451209), + Offset(49.15168130721208, 58.98364145221208), + Offset(49.03184435071208, 58.859366089912086), + Offset(48.91200739421208, 58.73509072751209), + Offset(48.79217043771208, 58.61081536521208), + Offset(48.67233348121208, 58.486540002912086), + Offset(48.55249652461208, 58.36226464061208), + Offset(48.43265956811208, 58.237989278312085), + Offset(47.886224725057794, 58.328931867875795), + Offset(46.37283467228049, 58.907701815198486), + Offset(44.859444619603074, 59.48647176247307), + Offset(43.34605456692577, 60.06524170979577), + Offset(41.83266451414846, 60.644011657118455), + Offset(40.31927446147115, 61.22278160434116), + Offset(38.80588440879385, 61.80155155166385), + Offset(37.57729680711652, 61.897964477626516), + Offset(36.80002629848357, 61.455950035853576), + Offset(36.799396511844705, 61.74344568343471), + Offset(36.79888886839874, 61.975183342238736), + Offset(36.79848110198319, 62.16132744639319), + Offset(36.798160047714276, 62.30788772451427), + Offset(36.797910932454975, 62.42160806784497), + Offset(36.7977224584507, 62.507645866310696), + Offset(36.797584418075736, 62.57066086939574), + Offset(36.797488148804206, 62.614607493304206), + Offset(36.797426074710145, 62.64294412474014), + Offset(36.79739159858898, 62.65868236713898), + Offset(36.797378950893325, 62.66445600098332), + Offset(36.79738307228839, 62.66257459691839), + Offset(36.79739951722624, 62.65506753378624), + Offset(36.797424364711524, 62.643724733811524), + Offset(36.797454147175166, 62.63012913151516), + Offset(36.797485776999366, 62.615690214859356), + Offset(36.79751660719509, 62.60161632611509), + Offset(36.79754416402861, 62.5890367170786), + Offset(36.797566336361996, 62.578915115642), + Offset(36.79758120646094, 62.57212696159094), + Offset(36.79758708492745, 62.56944345985745), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(52.571645330995544, 59.97784435071208), + Offset(52.45180837449554, 59.853568988412086), + Offset(52.33197141799555, 59.72929362611209), + Offset(52.212134461495545, 59.605018263812084), + Offset(52.09229750489554, 59.480742901412086), + Offset(51.972460548395546, 59.35646753911209), + Offset(51.852623591895544, 59.232192176812084), + Offset(51.73278663539554, 59.10791681451209), + Offset(51.612949678795545, 58.98364145221208), + Offset(51.49311272229554, 58.859366089912086), + Offset(51.37327576579555, 58.73509072751209), + Offset(51.253438809295545, 58.61081536521208), + Offset(51.13360185279554, 58.486540002912086), + Offset(51.013764896195546, 58.36226464061208), + Offset(50.893927939695544, 58.237989278312085), + Offset(50.509381535670876, 58.328931867875795), + Offset(49.524827050387316, 58.907701815198486), + Offset(48.54027256521657, 59.48647176247307), + Offset(47.55571808003301, 60.06524170979577), + Offset(46.57116359474944, 60.644011657118455), + Offset(45.586609109565885, 61.22278160434116), + Offset(44.602054624382326, 61.80155155166385), + Offset(43.70994860636296, 61.897964477626516), + Offset(43.0205115849932, 61.455950035853576), + Offset(43.091470130506, 61.74344568343471), + Offset(43.14866670842858, 61.975183342238736), + Offset(43.19461006443314, 62.16132744639319), + Offset(43.23078349611088, 62.30788772451427), + Offset(43.25885150418633, 62.42160806784497), + Offset(43.28008701536578, 62.507645866310696), + Offset(43.295640130698196, 62.57066086939574), + Offset(43.30648686364522, 62.614607493304206), + Offset(43.313480799617274, 62.64294412474014), + Offset(43.317365250734866, 62.65868236713898), + Offset(43.31879027634261, 62.66445600098332), + Offset(43.318325915587614, 62.66257459691839), + Offset(43.31647305177763, 62.65506753378624), + Offset(43.313673466479486, 62.643724733811524), + Offset(43.31031785336869, 62.63012913151516), + Offset(43.30675409672299, 62.615690214859356), + Offset(43.30328043483139, 62.60161632611509), + Offset(43.300175585182856, 62.5890367170786), + Offset(43.29767741130703, 62.578915115642), + Offset(43.296001985810214, 62.57212696159094), + Offset(43.29533965446193, 62.56944345985745), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + ], + <Offset>[ + Offset(54.81954300555442, 59.07868993176772), + Offset(54.69970604905442, 58.95441456946772), + Offset(54.579869092554425, 58.830139207167726), + Offset(54.46003213605442, 58.70586384486772), + Offset(54.34019517945442, 58.58158848246772), + Offset(54.220358222954424, 58.457313120167726), + Offset(54.10052126645442, 58.33303775786772), + Offset(53.98068430995442, 58.208762395567724), + Offset(53.86084735335442, 58.08448703326772), + Offset(53.74101039685442, 57.96021167096772), + Offset(53.621173440354426, 57.835936308567724), + Offset(53.50133648385442, 57.71166094626772), + Offset(53.38149952735442, 57.58738558396772), + Offset(53.261662570754424, 57.46311022166772), + Offset(53.14182561425442, 57.33883485936772), + Offset(52.905133320668966, 57.37063611066451), + Offset(52.4035689294841, 57.756211019649704), + Offset(51.90200453842384, 58.14178592858207), + Offset(51.40044014733896, 58.527360837567265), + Offset(50.89887575615409, 58.91293574655245), + Offset(50.397311365069214, 59.298510655437646), + Offset(49.895746973984345, 59.68408556442283), + Offset(49.31095249426342, 59.657574510890804), + Offset(48.70173455588635, 59.18347260189332), + Offset(48.83807534383448, 59.44481548777557), + Offset(48.94797368582479, 59.65547254999196), + Offset(49.036249922511665, 59.82468358945982), + Offset(49.1057540835974, 59.957911644778676), + Offset(49.159684365136926, 60.06128713223254), + Offset(49.20048658713519, 60.13949828685408), + Offset(49.230370570581385, 60.196780972344044), + Offset(49.2512116420263, 60.236729881531545), + Offset(49.264649892971235, 60.26248880031156), + Offset(49.27211353420417, 60.276795374069565), + Offset(49.27485159926458, 60.28204379484951), + Offset(49.27395936971321, 60.28033353741787), + Offset(49.27039925006888, 60.273509373087165), + Offset(49.265020085900304, 60.2631983993235), + Offset(49.25857256175407, 60.25083955504418), + Offset(49.25172510757334, 60.237714110608444), + Offset(49.24505076277185, 60.22492048840596), + Offset(49.23908505921234, 60.21348521501477), + Offset(49.234285031362276, 60.204284350405345), + Offset(49.23106584428998, 60.198113697790454), + Offset(49.22979323114302, 60.19567430751376), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + ], + <Offset>[ + Offset(56.549286261168945, 57.59279225720884), + Offset(56.42944930466894, 57.468516894908845), + Offset(56.30961234816895, 57.34424153260885), + Offset(56.189775391668945, 57.219966170308844), + Offset(56.06993843506894, 57.095690807908845), + Offset(55.950101478568946, 56.97141544560885), + Offset(55.830264522068944, 56.847140083308844), + Offset(55.71042756556894, 56.72286472100885), + Offset(55.590590608968945, 56.59858935870884), + Offset(55.47075365246894, 56.474313996408846), + Offset(55.35091669596895, 56.35003863400885), + Offset(55.231079739468946, 56.22576327170884), + Offset(55.111242782968944, 56.101487909408846), + Offset(54.99140582636895, 55.97721254710884), + Offset(54.871568869868945, 55.852937184808845), + Offset(54.74864939309879, 55.78700441495241), + Offset(54.6187428701751, 55.85331485483892), + Offset(54.4888363473851, 55.9196252946648), + Offset(54.358929824561415, 55.985935734551305), + Offset(54.22902330163772, 56.052246174437805), + Offset(54.09911677881403, 56.11855661422432), + Offset(53.96921025599034, 56.18486705411082), + Offset(53.620890090363176, 55.95521797286034), + Offset(53.07340023656424, 55.42808991849018), + Offset(53.26005225271882, 55.64621405687709), + Offset(53.410504273814226, 55.82203437827575), + Offset(53.53135539844182, 55.9632626809513), + Offset(53.62650734814831, 56.07445857089216), + Offset(53.700338487990095, 56.16073866631195), + Offset(53.75619717205799, 56.226015900304674), + Offset(53.79710866923288, 56.27382564410085), + Offset(53.82564032188473, 56.307168129650464), + Offset(53.84403743305162, 56.328667249267596), + Offset(53.85425523877907, 56.34060791895026), + Offset(53.85800367951774, 56.34498839885754), + Offset(53.8567822075933, 56.34356096980227), + Offset(53.85190836522106, 56.337865329915914), + Offset(53.84454423118309, 56.32925950060268), + Offset(53.83571750075847, 56.31894446083879), + Offset(53.82634326178278, 56.30798959497809), + Offset(53.81720601142316, 56.29731167730551), + Offset(53.809038898603006, 56.28776747583528), + Offset(53.80246760846087, 56.2800881749101), + Offset(53.79806050647426, 56.274937974320686), + Offset(53.79631828528544, 56.27290199044266), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.549286261168945, 57.59279225720884), + Offset(56.42944930466894, 57.468516894908845), + Offset(56.30961234816895, 57.34424153260885), + Offset(56.189775391668945, 57.219966170308844), + Offset(56.06993843506894, 57.095690807908845), + Offset(55.950101478568946, 56.97141544560885), + Offset(55.830264522068944, 56.847140083308844), + Offset(55.71042756556894, 56.72286472100885), + Offset(55.590590608968945, 56.59858935870884), + Offset(55.47075365246894, 56.474313996408846), + Offset(55.35091669596895, 56.35003863400885), + Offset(55.231079739468946, 56.22576327170884), + Offset(55.111242782968944, 56.101487909408846), + Offset(54.99140582636895, 55.97721254710884), + Offset(54.871568869868945, 55.852937184808845), + Offset(54.74864939309879, 55.78700441495241), + Offset(54.6187428701751, 55.85331485483892), + Offset(54.4888363473851, 55.9196252946648), + Offset(54.358929824561415, 55.985935734551305), + Offset(54.22902330163772, 56.052246174437805), + Offset(54.09911677881403, 56.11855661422432), + Offset(53.96921025599034, 56.18486705411082), + Offset(53.620890090363176, 55.95521797286034), + Offset(53.07340023656424, 55.42808991849018), + Offset(53.26005225271882, 55.64621405687709), + Offset(53.410504273814226, 55.82203437827575), + Offset(53.53135539844182, 55.9632626809513), + Offset(53.62650734814831, 56.07445857089216), + Offset(53.700338487990095, 56.16073866631195), + Offset(53.75619717205799, 56.226015900304674), + Offset(53.79710866923288, 56.27382564410085), + Offset(53.82564032188473, 56.307168129650464), + Offset(53.84403743305162, 56.328667249267596), + Offset(53.85425523877907, 56.34060791895026), + Offset(53.85800367951774, 56.34498839885754), + Offset(53.8567822075933, 56.34356096980227), + Offset(53.85190836522106, 56.337865329915914), + Offset(53.84454423118309, 56.32925950060268), + Offset(53.83571750075847, 56.31894446083879), + Offset(53.82634326178278, 56.30798959497809), + Offset(53.81720601142316, 56.29731167730551), + Offset(53.809038898603006, 56.28776747583528), + Offset(53.80246760846087, 56.2800881749101), + Offset(53.79806050647426, 56.274937974320686), + Offset(53.79631828528544, 56.27290199044266), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + ], + <Offset>[ + Offset(57.62369742449878, 56.51075365246894), + Offset(57.50386046799878, 56.386478290168945), + Offset(57.38402351149878, 56.26220292786895), + Offset(57.26418655499878, 56.137927565568944), + Offset(57.144349598398776, 56.013652203168945), + Offset(57.02451264189878, 55.88937684086895), + Offset(56.90467568539878, 55.765101478568944), + Offset(56.78483872889878, 55.64082611626895), + Offset(56.66500177229878, 55.51655075396894), + Offset(56.54516481579878, 55.392275391668946), + Offset(56.42532785929878, 55.26800002926895), + Offset(56.30549090279878, 55.14372466696894), + Offset(56.18565394629878, 55.019449304668946), + Offset(56.06581698969878, 54.89517394236894), + Offset(55.94598003319878, 54.770898580068945), + Offset(55.89372930109525, 54.63379537519879), + Offset(55.994674010747886, 54.46761572737509), + Offset(56.09561872053984, 54.3014360794851), + Offset(56.19656343029247, 54.13525643166142), + Offset(56.2975081399451, 53.969076783837714), + Offset(56.398452849697726, 53.80289713591403), + Offset(56.49939755945036, 53.63671748809034), + Offset(56.297960835674125, 53.25914221256317), + Offset(55.7888127085716, 52.69340023656424), + Offset(56.006714995568615, 52.88005225271882), + Offset(56.182356492271566, 53.03050427381422), + Offset(56.32344115337003, 53.151355398441815), + Offset(56.43452394733299, 53.246507348148306), + Offset(56.52071628832435, 53.32033848799009), + Offset(56.5859271296418, 53.376197172057985), + Offset(56.63368824673307, 53.417108669232874), + Offset(56.66699682004896, 53.445640321884724), + Offset(56.68847407317482, 53.464037433051615), + Offset(56.700402598148585, 53.474255238779065), + Offset(56.70477862272339, 53.47800367951774), + Offset(56.703352645488735, 53.476782207593295), + Offset(56.697662798567954, 53.471908365221054), + Offset(56.689065722138324, 53.46454423118309), + Offset(56.678761173675056, 53.45571750075847), + Offset(56.66781744987426, 53.446343261782786), + Offset(56.65715039258098, 53.43720601142316), + Offset(56.64761589840118, 53.429038898603004), + Offset(56.639944407998925, 53.42246760846087), + Offset(56.634799445615464, 53.418060506474255), + Offset(56.632765532511705, 53.416318285285435), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + ], + <Offset>[ + Offset(57.62369742449878, 56.51075365246894), + Offset(57.50386046799878, 56.386478290168945), + Offset(57.38402351149878, 56.26220292786895), + Offset(57.26418655499878, 56.137927565568944), + Offset(57.144349598398776, 56.013652203168945), + Offset(57.02451264189878, 55.88937684086895), + Offset(56.90467568539878, 55.765101478568944), + Offset(56.78483872889878, 55.64082611626895), + Offset(56.66500177229878, 55.51655075396894), + Offset(56.54516481579878, 55.392275391668946), + Offset(56.42532785929878, 55.26800002926895), + Offset(56.30549090279878, 55.14372466696894), + Offset(56.18565394629878, 55.019449304668946), + Offset(56.06581698969878, 54.89517394236894), + Offset(55.94598003319878, 54.770898580068945), + Offset(55.89372930109525, 54.63379537519879), + Offset(55.994674010747886, 54.46761572737509), + Offset(56.09561872053984, 54.3014360794851), + Offset(56.19656343029247, 54.13525643166142), + Offset(56.2975081399451, 53.969076783837714), + Offset(56.398452849697726, 53.80289713591403), + Offset(56.49939755945036, 53.63671748809034), + Offset(56.297960835674125, 53.25914221256317), + Offset(55.7888127085716, 52.69340023656424), + Offset(56.006714995568615, 52.88005225271882), + Offset(56.182356492271566, 53.03050427381422), + Offset(56.32344115337003, 53.151355398441815), + Offset(56.43452394733299, 53.246507348148306), + Offset(56.52071628832435, 53.32033848799009), + Offset(56.5859271296418, 53.376197172057985), + Offset(56.63368824673307, 53.417108669232874), + Offset(56.66699682004896, 53.445640321884724), + Offset(56.68847407317482, 53.464037433051615), + Offset(56.700402598148585, 53.474255238779065), + Offset(56.70477862272339, 53.47800367951774), + Offset(56.703352645488735, 53.476782207593295), + Offset(56.697662798567954, 53.471908365221054), + Offset(56.689065722138324, 53.46454423118309), + Offset(56.678761173675056, 53.45571750075847), + Offset(56.66781744987426, 53.446343261782786), + Offset(56.65715039258098, 53.43720601142316), + Offset(56.64761589840118, 53.429038898603004), + Offset(56.639944407998925, 53.42246760846087), + Offset(56.634799445615464, 53.418060506474255), + Offset(56.632765532511705, 53.416318285285435), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + ], + ), + _PathClose(), + _PathMoveTo(<Offset>[ + Offset(50.11037695941208, 50.11515566052058), + Offset(49.99054000291208, 49.99088029822058), + Offset(49.87070304641208, 49.866604935920584), + Offset(49.75086608991208, 49.74232957362058), + Offset(49.631029133312076, 49.61805421122058), + Offset(49.51119217681208, 49.493778848920584), + Offset(49.39135522031208, 49.36950348662058), + Offset(49.27151826381208, 49.24522812432058), + Offset(49.15168130721208, 49.12095276202058), + Offset(49.03184435071208, 48.99667739972058), + Offset(48.91200739421208, 48.87240203732058), + Offset(48.79217043771208, 48.74812667502058), + Offset(48.67233348121208, 48.62385131272058), + Offset(48.55249652461208, 48.49957595042058), + Offset(48.43265956811208, 48.37530058812058), + Offset(47.886224725057794, 48.02143406108567), + Offset(46.37283467228049, 47.32255270277091), + Offset(44.859444619603074, 46.89224005129487), + Offset(43.346054566925766, 46.73049610683733), + Offset(41.83266451414846, 46.83732086930855), + Offset(40.31927446147115, 47.21271433859731), + Offset(38.80588440879385, 47.856676514909296), + Offset(37.57729680711651, 48.70078707684129), + Offset(36.80002629848357, 49.649293275456884), + Offset(36.799396511844705, 51.39862444641657), + Offset(36.79888886839874, 52.36567493589737), + Offset(36.798481101983185, 52.561543433342045), + Offset(36.798160047714276, 52.73365619281698), + Offset(36.79791093245497, 52.88590941777883), + Offset(36.79772245845069, 53.021274808915365), + Offset(36.79758441807573, 53.14247036493895), + Offset(36.797488148804206, 53.25181934443066), + Offset(36.797426074710145, 53.351374286002354), + Offset(36.79739159858897, 53.44294120991781), + Offset(36.79737895089332, 53.52811757525859), + Offset(36.79738307228838, 53.60832148263455), + Offset(36.79739951722624, 53.68481556964314), + Offset(36.79742436471152, 53.7587295593099), + Offset(36.79745414717516, 53.83107808312845), + Offset(36.79748577699936, 53.90277982470875), + Offset(36.79751660719509, 53.97463627018816), + Offset(36.7975441640286, 54.047410713622526), + Offset(36.797566336361996, 54.121768596383546), + Offset(36.79758120646093, 54.19832738700239), + Offset(36.79758708492745, 54.27764432912612), + Offset(36.79758712071989, 54.35877252649494), + Offset(36.79758712071989, 54.439911871108684), + Offset(36.79758712071989, 54.52105121572243), + Offset(36.79758712071989, 54.522707120719886), + ]), + _PathCubicTo( + <Offset>[ + Offset(50.08645981463666, 50.11515566052058), + Offset(49.96662285813666, 49.99088029822058), + Offset(49.846785901636665, 49.866604935920584), + Offset(49.72694894513666, 49.74232957362058), + Offset(49.60711198853666, 49.61805421122058), + Offset(49.48727503203666, 49.493778848920584), + Offset(49.36743807553666, 49.36950348662058), + Offset(49.24760111903666, 49.24522812432058), + Offset(49.12776416243666, 49.12095276202058), + Offset(49.00792720593666, 48.99667739972058), + Offset(48.888090249436665, 48.87240203732058), + Offset(48.76825329293666, 48.74812667502058), + Offset(48.64841633643666, 48.62385131272058), + Offset(48.528579379836664, 48.49957595042058), + Offset(48.40874242333666, 48.37530058812058), + Offset(47.748134816992604, 48.02143406108567), + Offset(45.764925398174555, 47.32255270277091), + Offset(43.63340675157196, 46.89224005129487), + Offset(41.35357887709607, 46.73049610683733), + Offset(38.92544177464233, 46.83732086930855), + Offset(36.34899544441074, 47.21271433859731), + Offset(33.624239886301304, 47.856676514909296), + Offset(31.234909988934966, 48.70078707684129), + Offset(29.494550483270046, 49.649293275456884), + Offset(28.52755585328232, 51.39862444641657), + Offset(27.99274184675299, 52.36567493589737), + Offset(27.883944986437346, 52.561543433342045), + Offset(27.788401120897287, 52.73365619281698), + Offset(27.703935759159105, 52.88590941777883), + Offset(27.628890333495022, 53.021274808915365), + Offset(27.561748066907615, 53.14247036493895), + Offset(27.50121256374279, 53.25181934443066), + Offset(27.446138611583994, 53.351374286002354), + Offset(27.39551864926441, 53.44294120991781), + Offset(27.348461571724304, 53.52811757525859), + Offset(27.30417642143305, 53.60832148263455), + Offset(27.261959043201006, 53.68481556964314), + Offset(27.22117949399494, 53.7587295593099), + Offset(27.181272090318803, 53.83107808312845), + Offset(27.141724722671132, 53.90277982470875), + Offset(27.102090684257846, 53.97463627018816), + Offset(27.061944627502523, 54.047410713622526), + Offset(27.020915803873248, 54.121768596383546), + Offset(26.978660256499385, 54.19832738700239), + Offset(26.934867640331532, 54.27764432912612), + Offset(26.890065739866895, 54.35877252649494), + Offset(26.845257628088735, 54.439911871108684), + Offset(26.80044951631057, 54.52105121572243), + Offset(26.79953506506211, 54.522707120719886), + ], + <Offset>[ + Offset(50.06706564959764, 50.095761495481554), + Offset(49.947228693097635, 49.97148613318156), + Offset(49.82739173659764, 49.84721077088156), + Offset(49.70755478009764, 49.722935408581556), + Offset(49.587717823497634, 49.59866004618156), + Offset(49.46788086699764, 49.47438468388156), + Offset(49.34804391049764, 49.350109321581556), + Offset(49.228206953997635, 49.22583395928156), + Offset(49.10836999739764, 49.101558596981555), + Offset(48.988533040897636, 48.97728323468156), + Offset(48.86869608439764, 48.85300787228156), + Offset(48.74885912789764, 48.728732509981555), + Offset(48.62902217139764, 48.60445714768156), + Offset(48.50918521479764, 48.48018178538155), + Offset(48.38934825829764, 48.35590642308156), + Offset(47.63616175837963, 47.909461002472696), + Offset(45.27199148434817, 46.82961878894062), + Offset(42.63925301353386, 45.89808631326019), + Offset(39.73794634587201, 45.11486357561327), + Offset(36.56807148124825, 44.47995057591447), + Offset(33.12962841986258, 43.993347314049146), + Offset(29.42261716161499, 43.65505379022298), + Offset(26.09208026716689, 43.55795735507321), + Offset(23.57078423835929, 43.725527030546125), + Offset(21.82019681522399, 44.69126540835824), + Offset(20.852132118393076, 45.225065207537455), + Offset(20.655446961021294, 45.333045407926), + Offset(20.48269090511957, 45.42794597703926), + Offset(20.329938213814554, 45.511911872434275), + Offset(20.194194600945227, 45.58657907636557), + Offset(20.07272166098446, 45.653443959015796), + Offset(19.9631788172331, 45.71378559792097), + Offset(19.86349838492615, 45.76873405934452), + Offset(19.771861154060407, 45.81928371469362), + Offset(19.686658130182877, 45.86631413371717), + Offset(19.606461097044544, 45.91060615824605), + Offset(19.529998528318746, 45.95285505476088), + Offset(19.456132860826976, 45.993682926121764), + Offset(19.38384252924341, 46.033648522053056), + Offset(19.312202675685725, 46.07325777772334), + Offset(19.240406520613522, 46.11295210654384), + Offset(19.167685822801204, 46.1531519089212), + Offset(19.093370918660103, 46.19422371115027), + Offset(19.016840503908618, 46.236507634411616), + Offset(18.93753395574349, 46.28031064453808), + Offset(18.856404467546696, 46.325111254174736), + Offset(18.775263760542785, 46.369918003542615), + Offset(18.69412305351876, 46.41472475293062), + Offset(18.692467120719886, 46.41563917637766), + ], + <Offset>[ + Offset(50.06706564959764, 50.07184435070614), + Offset(49.947228693097635, 49.94756898840614), + Offset(49.82739173659764, 49.82329362610614), + Offset(49.70755478009764, 49.69901826380614), + Offset(49.587717823497634, 49.57474290140614), + Offset(49.46788086699764, 49.45046753910614), + Offset(49.34804391049764, 49.32619217680614), + Offset(49.228206953997635, 49.20191681450614), + Offset(49.10836999739764, 49.07764145220614), + Offset(48.988533040897636, 48.95336608990614), + Offset(48.86869608439764, 48.82909072750614), + Offset(48.74885912789764, 48.70481536520614), + Offset(48.62902217139764, 48.58054000290614), + Offset(48.50918521479764, 48.456264640606136), + Offset(48.38934825829764, 48.33198927830614), + Offset(47.63616175837963, 47.7713707071586), + Offset(45.27199148434817, 46.22170752947073), + Offset(42.63925301353386, 44.672044351691675), + Offset(39.73794634587201, 43.12238117401274), + Offset(36.56807148124825, 41.572717996339655), + Offset(33.12962841986258, 40.02305481856121), + Offset(29.42261716161499, 38.47339164088308), + Offset(26.09208026716689, 37.21554892929752), + Offset(23.57078423835929, 36.42002629846431), + Offset(21.82019681522399, 36.41939651182523), + Offset(20.852132118393076, 36.41888886837908), + Offset(20.655446961021294, 36.41848110196339), + Offset(20.48269090511957, 36.41816004771427), + Offset(20.329938213814554, 36.41791093245497), + Offset(20.194194600945227, 36.4177224584507), + Offset(20.07272166098446, 36.41758441807573), + Offset(19.9631788172331, 36.4174881488042), + Offset(19.86349838492615, 36.417426074710136), + Offset(19.771861154060407, 36.41739159858898), + Offset(19.686658130182877, 36.417378950893315), + Offset(19.606461097044544, 36.41738307228839), + Offset(19.529998528318746, 36.41739951720605), + Offset(19.456132860826976, 36.41742436469134), + Offset(19.38384252924341, 36.417454147155), + Offset(19.312202675685725, 36.417485776979206), + Offset(19.240406520613522, 36.417516607174946), + Offset(19.167685822801204, 36.41754416400846), + Offset(19.093370918660103, 36.41756633636199), + Offset(19.016840503908618, 36.41758120646094), + Offset(18.93753395574349, 36.41758708492745), + Offset(18.856404467546696, 36.41758712071989), + Offset(18.775263760542785, 36.41758712071989), + Offset(18.69412305351876, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(50.06706564959764, 50.04792720593072), + Offset(49.947228693097635, 49.92365184363072), + Offset(49.82739173659764, 49.799376481330725), + Offset(49.70755478009764, 49.675101119030714), + Offset(49.587717823497634, 49.550825756630715), + Offset(49.46788086699764, 49.42655039433072), + Offset(49.34804391049764, 49.30227503203072), + Offset(49.228206953997635, 49.177999669730724), + Offset(49.10836999739764, 49.05372430743071), + Offset(48.988533040897636, 48.929448945130716), + Offset(48.86869608439764, 48.80517358273072), + Offset(48.74885912789764, 48.68089822043072), + Offset(48.62902217139764, 48.55662285813072), + Offset(48.50918521479764, 48.43234749583071), + Offset(48.38934825829764, 48.308072133530715), + Offset(47.63616175837963, 47.63328079909341), + Offset(45.27199148434817, 45.61379825536479), + Offset(42.63925301353386, 43.44600648366056), + Offset(39.73794634587201, 41.12990548418304), + Offset(36.56807148124825, 38.66549525683352), + Offset(33.12962841986258, 36.05277580150079), + Offset(29.42261716161499, 33.29174711839053), + Offset(26.09208026716689, 30.873162111115967), + Offset(23.57078423835929, 29.11455048325078), + Offset(21.82019681522399, 28.147555853262837), + Offset(20.852132118393076, 27.612742525999973), + Offset(20.655446961021294, 27.50394701174588), + Offset(20.48269090511957, 27.408404506536804), + Offset(20.329938213814554, 27.323940514395186), + Offset(20.194194600945227, 27.2488964636637), + Offset(20.07272166098446, 27.181755574389616), + Offset(19.9631788172331, 27.121221448636877), + Offset(19.86349838492615, 27.066148872338097), + Offset(19.771861154060407, 27.015530283259928), + Offset(19.686658130182877, 26.968474575656916), + Offset(19.606461097044544, 26.924190791705026), + Offset(19.529998528318746, 26.88197477619479), + Offset(19.456132860826976, 26.841196586461674), + Offset(19.38384252924341, 26.80129053948502), + Offset(19.312202675685725, 26.761744526458134), + Offset(19.240406520613522, 26.72211184147367), + Offset(19.167685822801204, 26.68196713796564), + Offset(19.093370918660103, 26.64093966853734), + Offset(19.016840503908618, 26.598685477488807), + Offset(18.93753395574349, 26.5548942211387), + Offset(18.856404467546696, 26.5100936830677), + Offset(18.775263760542785, 26.465286933679703), + Offset(18.69412305351876, 26.42048018431182), + Offset(18.692467120719886, 26.419565760864774), + ], + <Offset>[ + Offset(50.08645981463666, 50.02853304089169), + Offset(49.96662285813666, 49.90425767859169), + Offset(49.846785901636665, 49.779982316291694), + Offset(49.72694894513666, 49.6557069539917), + Offset(49.60711198853666, 49.5314315915917), + Offset(49.48727503203666, 49.4071562292917), + Offset(49.36743807553666, 49.28288086699169), + Offset(49.24760111903666, 49.15860550469169), + Offset(49.12776416243666, 49.034330142391696), + Offset(49.00792720593666, 48.9100547800917), + Offset(48.888090249436665, 48.7857794176917), + Offset(48.76825329293666, 48.66150405539169), + Offset(48.64841633643666, 48.53722869309169), + Offset(48.528579379836664, 48.412953330791694), + Offset(48.40874242333666, 48.2886779684917), + Offset(47.748134816992604, 47.521307740480445), + Offset(45.76492539817846, 45.1208643415384), + Offset(43.63340675156854, 42.45185274562246), + Offset(41.35357887709607, 39.51427295295898), + Offset(38.92544177464233, 36.30812496343945), + Offset(36.34899544441074, 32.83340877695263), + Offset(33.624239886301304, 29.090124393704222), + Offset(31.234909988934966, 25.730332389347893), + Offset(29.494550483270046, 23.19078423834003), + Offset(28.52755585328232, 21.440196815204512), + Offset(27.99274184675299, 20.472132118373413), + Offset(27.883944986437346, 20.275446961001492), + Offset(27.788401120897287, 20.102690905119566), + Offset(27.703935759159105, 19.94993821381455), + Offset(27.628890333495022, 19.814194600945225), + Offset(27.56174806690762, 19.692721660984457), + Offset(27.50121256374279, 19.583178817233097), + Offset(27.446138611583994, 19.483498384926143), + Offset(27.395518649284597, 19.391861154060404), + Offset(27.348461571724304, 19.306658130182875), + Offset(27.30417642143305, 19.22646109704454), + Offset(27.261959043201006, 19.14999852829856), + Offset(27.22117949401511, 19.076132860806798), + Offset(27.1812720903188, 19.003842529223242), + Offset(27.141724722671132, 18.93220267566557), + Offset(27.102090684257846, 18.860406520593376), + Offset(27.061944627502523, 18.787685822781068), + Offset(27.020915803893374, 18.7133709186601), + Offset(26.978660256499385, 18.636840503908616), + Offset(26.934867640331532, 18.557533955743487), + Offset(26.890065739866895, 18.476404467546697), + Offset(26.84525762810885, 18.395263760542782), + Offset(26.80044951631057, 18.314123053518756), + Offset(26.79953506506211, 18.312467120719884), + ], + <Offset>[ + Offset(50.11037695941208, 50.02853304089169), + Offset(49.99054000291208, 49.90425767859169), + Offset(49.87070304641208, 49.779982316291694), + Offset(49.75086608991208, 49.6557069539917), + Offset(49.631029133312076, 49.5314315915917), + Offset(49.51119217681208, 49.4071562292917), + Offset(49.39135522031208, 49.28288086699169), + Offset(49.27151826381208, 49.15860550469169), + Offset(49.15168130721208, 49.034330142391696), + Offset(49.03184435071208, 48.9100547800917), + Offset(48.91200739421208, 48.7857794176917), + Offset(48.79217043771208, 48.66150405539169), + Offset(48.67233348121208, 48.53722869309169), + Offset(48.55249652461208, 48.412953330791694), + Offset(48.43265956811208, 48.2886779684917), + Offset(47.886224725057794, 47.521307740480445), + Offset(46.37283467228049, 45.1208643415384), + Offset(44.859444619603074, 42.45185274562246), + Offset(43.346054566925766, 39.51427295295898), + Offset(41.83266451414846, 36.30812496343945), + Offset(40.31927446147115, 32.83340877695263), + Offset(38.80588440879385, 29.090124393704222), + Offset(37.57729680711652, 25.730332389347893), + Offset(36.80002629848357, 23.19078423834003), + Offset(36.799396511844705, 21.440196815204512), + Offset(36.79888886839874, 20.472132118373413), + Offset(36.798481101983185, 20.275446961001492), + Offset(36.798160047714276, 20.102690905119566), + Offset(36.79791093245497, 19.94993821381455), + Offset(36.79772245845069, 19.814194600945225), + Offset(36.79758441807573, 19.692721660984457), + Offset(36.797488148804206, 19.583178817233097), + Offset(36.797426074710145, 19.483498384926143), + Offset(36.79739159858897, 19.391861154060404), + Offset(36.79737895089332, 19.306658130182875), + Offset(36.79738307228838, 19.22646109704454), + Offset(36.79739951722624, 19.14999852829856), + Offset(36.79742436471152, 19.076132860806798), + Offset(36.79745414717516, 19.003842529223242), + Offset(36.79748577699936, 18.93220267566557), + Offset(36.79751660719509, 18.860406520593376), + Offset(36.7975441640286, 18.787685822781068), + Offset(36.797566336361996, 18.7133709186601), + Offset(36.79758120646093, 18.636840503908616), + Offset(36.79758708492745, 18.557533955743487), + Offset(36.79758712071989, 18.476404467546697), + Offset(36.79758712071989, 18.395263760542782), + Offset(36.79758712071989, 18.314123053518756), + Offset(36.79758712071989, 18.312467120719884), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(50.1342941041875, 50.02853304089169), + Offset(50.014457147687494, 49.90425767859169), + Offset(49.8946201911875, 49.779982316291694), + Offset(49.7747832346875, 49.6557069539917), + Offset(49.65494627808749, 49.5314315915917), + Offset(49.5351093215875, 49.4071562292917), + Offset(49.415272365087496, 49.28288086699169), + Offset(49.295435408587494, 49.15860550469169), + Offset(49.1755984519875, 49.034330142391696), + Offset(49.055761495487495, 48.9100547800917), + Offset(48.9359245389875, 48.7857794176917), + Offset(48.8160875824875, 48.66150405539169), + Offset(48.696250625987496, 48.53722869309169), + Offset(48.5764136693875, 48.412953330791694), + Offset(48.4565767128875, 48.2886779684917), + Offset(48.02431502037189, 47.521307740480445), + Offset(46.98074593174941, 45.1208643415384), + Offset(46.08548658116816, 42.45185274562246), + Offset(45.338536968526306, 39.51427295295898), + Offset(44.73989709372328, 36.30812496343945), + Offset(44.28956695695909, 32.83340877695263), + Offset(43.9875465581158, 29.090124393704222), + Offset(43.91970523287322, 25.730332389347893), + Offset(44.10552703056539, 23.19078423834003), + Offset(45.07126540837772, 21.440196815204512), + Offset(45.60506520753746, 20.472132118373413), + Offset(45.7130454079458, 20.275446961001492), + Offset(45.80794597703926, 20.102690905119566), + Offset(45.89191187243428, 19.94993821381455), + Offset(45.96657907636557, 19.814194600945225), + Offset(46.0334439590158, 19.692721660984457), + Offset(46.09378559792097, 19.583178817233097), + Offset(46.14873405934452, 19.483498384926143), + Offset(46.19928371471381, 19.391861154060404), + Offset(46.24631413371717, 19.306658130182875), + Offset(46.29060615824605, 19.22646109704454), + Offset(46.332855054781064, 19.14999852829856), + Offset(46.37368292614194, 19.076132860806798), + Offset(46.41364852205306, 19.003842529223242), + Offset(46.453257777743495, 18.93220267566557), + Offset(46.492952106563976, 18.860406520593376), + Offset(46.533151908941335, 18.787685822781068), + Offset(46.574223711150275, 18.7133709186601), + Offset(46.61650763441162, 18.636840503908616), + Offset(46.66031064453808, 18.557533955743487), + Offset(46.70511125417474, 18.476404467546697), + Offset(46.74991800356274, 18.395263760542782), + Offset(46.79472475293062, 18.314123053518756), + Offset(46.79563917637766, 18.312467120719884), + ], + <Offset>[ + Offset(50.15368826922652, 50.04792720593072), + Offset(50.033851312726526, 49.92365184363072), + Offset(49.91401435622653, 49.799376481330725), + Offset(49.79417739972652, 49.675101119030714), + Offset(49.674340443126525, 49.550825756630715), + Offset(49.55450348662653, 49.42655039433072), + Offset(49.43466653012652, 49.30227503203072), + Offset(49.314829573626525, 49.177999669730724), + Offset(49.19499261702653, 49.05372430743071), + Offset(49.07515566052652, 48.929448945130716), + Offset(48.955318704026524, 48.80517358273072), + Offset(48.83548174752653, 48.68089822043072), + Offset(48.71564479102652, 48.55662285813072), + Offset(48.59580783442652, 48.43234749583071), + Offset(48.47597087792653, 48.308072133530715), + Offset(48.136288078984855, 47.63328079909341), + Offset(47.473679845580676, 45.61379825536869), + Offset(47.07964031920627, 43.44600648365714), + Offset(46.95416949975036, 41.12990548418304), + Offset(47.097267387117355, 38.66549525683352), + Offset(47.508933981507255, 36.05277580150079), + Offset(48.18916928282006, 33.29174711839053), + Offset(49.06253495466028, 30.873162111115967), + Offset(50.02929327547615, 29.114550483250788), + Offset(51.77862444643605, 28.14755585326284), + Offset(52.74567493591703, 27.612742526019634), + Offset(52.94154343336185, 27.50394701174588), + Offset(53.11365619281698, 27.408404506536808), + Offset(53.26590941777883, 27.323940514375185), + Offset(53.40127480891536, 27.24889646368377), + Offset(53.522470364938954, 27.18175557438962), + Offset(53.63181934443066, 27.121221448636877), + Offset(53.731374286002364, 27.066148872338097), + Offset(53.822941209917815, 27.015530283280118), + Offset(53.9081175752586, 26.968474575656916), + Offset(53.988321482634554, 26.924190791684836), + Offset(54.064815569663324, 26.88197477619479), + Offset(54.13872955933007, 26.841196586461674), + Offset(54.211078083148614, 26.801290539485016), + Offset(54.282779824728905, 26.761744526458138), + Offset(54.3546362702083, 26.72211184147367), + Offset(54.42741071364266, 26.681967137945506), + Offset(54.50176859638355, 26.640939668537342), + Offset(54.57832738700239, 26.598685477488804), + Offset(54.65764432912613, 26.5548942211387), + Offset(54.73877252649494, 26.510093683047582), + Offset(54.81991187110869, 26.46528693369982), + Offset(54.90105121572243, 26.42048018431182), + Offset(54.90270712071989, 26.419565760864774), + ], + <Offset>[ + Offset(50.15368826922652, 50.07184435070614), + Offset(50.033851312726526, 49.94756898840614), + Offset(49.91401435622653, 49.82329362610614), + Offset(49.79417739972652, 49.69901826380614), + Offset(49.674340443126525, 49.57474290140614), + Offset(49.55450348662653, 49.45046753910614), + Offset(49.43466653012652, 49.32619217680614), + Offset(49.314829573626525, 49.20191681450614), + Offset(49.19499261702653, 49.07764145220614), + Offset(49.07515566052652, 48.95336608990614), + Offset(48.955318704026524, 48.82909072750614), + Offset(48.83548174752653, 48.70481536520614), + Offset(48.71564479102652, 48.58054000290614), + Offset(48.59580783442652, 48.456264640606136), + Offset(48.47597087792653, 48.33198927830614), + Offset(48.136288078984855, 47.7713707071586), + Offset(47.473679845580676, 46.22170752947072), + Offset(47.07964031920627, 44.672044351691675), + Offset(46.95416949975036, 43.12238117401274), + Offset(47.097267387117355, 41.572717996339655), + Offset(47.508933981507255, 40.02305481856121), + Offset(48.18916928282006, 38.47339164088308), + Offset(49.06253495466028, 37.21554892929752), + Offset(50.02929327547615, 36.42002629846431), + Offset(51.77862444643605, 36.41939651182523), + Offset(52.74567493591703, 36.41888886837908), + Offset(52.94154343336185, 36.41848110196339), + Offset(53.11365619281698, 36.41816004771427), + Offset(53.26590941777883, 36.41791093245497), + Offset(53.40127480891536, 36.4177224584507), + Offset(53.522470364938954, 36.417584418075734), + Offset(53.63181934443066, 36.4174881488042), + Offset(53.731374286002364, 36.417426074710136), + Offset(53.822941209917815, 36.41739159858898), + Offset(53.9081175752586, 36.417378950893315), + Offset(53.988321482634554, 36.41738307228838), + Offset(54.064815569663324, 36.41739951720605), + Offset(54.13872955933007, 36.41742436469134), + Offset(54.211078083148614, 36.417454147155), + Offset(54.282779824728905, 36.417485776979206), + Offset(54.3546362702083, 36.417516607174946), + Offset(54.42741071364266, 36.41754416400846), + Offset(54.50176859638355, 36.41756633636199), + Offset(54.57832738700239, 36.41758120646094), + Offset(54.65764432912613, 36.41758708492745), + Offset(54.73877252649494, 36.41758712071989), + Offset(54.81991187110869, 36.41758712071989), + Offset(54.90105121572243, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(50.15368826922652, 50.095761495481554), + Offset(50.033851312726526, 49.97148613318156), + Offset(49.91401435622653, 49.84721077088156), + Offset(49.79417739972652, 49.722935408581556), + Offset(49.674340443126525, 49.59866004618156), + Offset(49.55450348662653, 49.47438468388156), + Offset(49.43466653012652, 49.350109321581556), + Offset(49.314829573626525, 49.22583395928156), + Offset(49.19499261702653, 49.101558596981555), + Offset(49.07515566052652, 48.97728323468156), + Offset(48.955318704026524, 48.85300787228156), + Offset(48.83548174752653, 48.728732509981555), + Offset(48.71564479102652, 48.60445714768156), + Offset(48.59580783442652, 48.48018178538155), + Offset(48.47597087792653, 48.35590642308156), + Offset(48.136288078984855, 47.909461002472696), + Offset(47.473679845580676, 46.82961878893964), + Offset(47.07964031920627, 45.89808631325676), + Offset(46.95416949975036, 45.11486357561327), + Offset(47.097267387117355, 44.47995057591447), + Offset(47.508933981507255, 43.993347314049146), + Offset(48.18916928282006, 43.65505379020504), + Offset(49.06253495466028, 43.55795735505423), + Offset(50.02929327547615, 43.725527030546125), + Offset(51.77862444643605, 44.69126540835824), + Offset(52.74567493591703, 45.225065207517794), + Offset(52.94154343336185, 45.333045407926), + Offset(53.11365619281698, 45.42794597703926), + Offset(53.26590941777883, 45.511911872434275), + Offset(53.40127480891536, 45.58657907636557), + Offset(53.522470364938954, 45.653443959015796), + Offset(53.63181934443066, 45.71378559792097), + Offset(53.731374286002364, 45.76873405934451), + Offset(53.822941209917815, 45.819283714713805), + Offset(53.9081175752586, 45.86631413371717), + Offset(53.988321482634554, 45.91060615824605), + Offset(54.064815569663324, 45.95285505476088), + Offset(54.13872955933007, 45.993682926121764), + Offset(54.211078083148614, 46.03364852203289), + Offset(54.282779824728905, 46.07325777772334), + Offset(54.3546362702083, 46.11295210654383), + Offset(54.42741071364266, 46.1531519089212), + Offset(54.50176859638355, 46.19422371115027), + Offset(54.57832738700239, 46.236507634411616), + Offset(54.65764432912613, 46.280310644538076), + Offset(54.73877252649494, 46.32511125417474), + Offset(54.81991187110869, 46.36991800356274), + Offset(54.90105121572243, 46.41472475293062), + Offset(54.90270712071989, 46.41563917637766), + ], + <Offset>[ + Offset(50.1342941041875, 50.11515566052058), + Offset(50.014457147687494, 49.99088029822058), + Offset(49.8946201911875, 49.866604935920584), + Offset(49.7747832346875, 49.74232957362058), + Offset(49.65494627808749, 49.61805421122058), + Offset(49.5351093215875, 49.493778848920584), + Offset(49.415272365087496, 49.36950348662058), + Offset(49.295435408587494, 49.24522812432058), + Offset(49.1755984519875, 49.12095276202058), + Offset(49.055761495487495, 48.99667739972058), + Offset(48.9359245389875, 48.87240203732058), + Offset(48.8160875824875, 48.74812667502058), + Offset(48.696250625987496, 48.62385131272058), + Offset(48.5764136693875, 48.49957595042058), + Offset(48.4565767128875, 48.37530058812058), + Offset(48.02431502037189, 48.02143406108567), + Offset(46.980745931750384, 47.32255270277091), + Offset(46.08548658117158, 46.89224005129487), + Offset(45.3385369685263, 46.73049610683733), + Offset(44.73989709372328, 46.83732086930855), + Offset(44.28956695695909, 47.21271433859731), + Offset(43.98754655813375, 47.856676514909296), + Offset(43.919705232892206, 48.70078707684129), + Offset(44.10552703056539, 49.649293275456884), + Offset(45.07126540837772, 51.39862444641657), + Offset(45.60506520755712, 52.36567493589737), + Offset(45.7130454079458, 52.56154343334205), + Offset(45.80794597703926, 52.73365619281698), + Offset(45.89191187243428, 52.88590941777883), + Offset(45.96657907636557, 53.021274808915365), + Offset(46.0334439590158, 53.14247036493895), + Offset(46.09378559792097, 53.25181934443066), + Offset(46.14873405934452, 53.351374286002354), + Offset(46.19928371469362, 53.44294120991781), + Offset(46.24631413371717, 53.5281175752586), + Offset(46.29060615824605, 53.60832148263455), + Offset(46.332855054781064, 53.68481556964314), + Offset(46.373682926141946, 53.7587295593099), + Offset(46.413648522073224, 53.83107808312845), + Offset(46.453257777743495, 53.90277982470875), + Offset(46.49295210656398, 53.97463627018816), + Offset(46.533151908941335, 54.04741071362252), + Offset(46.574223711150275, 54.121768596383546), + Offset(46.61650763441162, 54.19832738700239), + Offset(46.660310644538086, 54.27764432912612), + Offset(46.70511125417474, 54.35877252649494), + Offset(46.74991800354262, 54.439911871108684), + Offset(46.79472475293062, 54.52105121572244), + Offset(46.79563917637766, 54.522707120719886), + ], + <Offset>[ + Offset(50.11037695941208, 50.11515566052058), + Offset(49.99054000291208, 49.99088029822058), + Offset(49.87070304641208, 49.866604935920584), + Offset(49.75086608991208, 49.74232957362058), + Offset(49.631029133312076, 49.61805421122058), + Offset(49.51119217681208, 49.493778848920584), + Offset(49.39135522031208, 49.36950348662058), + Offset(49.27151826381208, 49.24522812432058), + Offset(49.15168130721208, 49.12095276202058), + Offset(49.03184435071208, 48.99667739972058), + Offset(48.91200739421208, 48.87240203732058), + Offset(48.79217043771208, 48.74812667502058), + Offset(48.67233348121208, 48.62385131272058), + Offset(48.55249652461208, 48.49957595042058), + Offset(48.43265956811208, 48.37530058812058), + Offset(47.886224725057794, 48.02143406108567), + Offset(46.37283467228049, 47.32255270277091), + Offset(44.859444619603074, 46.89224005129487), + Offset(43.346054566925766, 46.73049610683733), + Offset(41.83266451414846, 46.83732086930855), + Offset(40.31927446147115, 47.21271433859731), + Offset(38.80588440879385, 47.856676514909296), + Offset(37.57729680711652, 48.70078707684129), + Offset(36.80002629848357, 49.649293275456884), + Offset(36.799396511844705, 51.39862444641657), + Offset(36.79888886839874, 52.36567493589737), + Offset(36.798481101983185, 52.56154343334205), + Offset(36.798160047714276, 52.73365619281698), + Offset(36.79791093245497, 52.88590941777883), + Offset(36.79772245845069, 53.021274808915365), + Offset(36.79758441807573, 53.14247036493895), + Offset(36.797488148804206, 53.25181934443066), + Offset(36.797426074710145, 53.351374286002354), + Offset(36.79739159858897, 53.44294120991781), + Offset(36.79737895089332, 53.5281175752586), + Offset(36.79738307228838, 53.60832148263455), + Offset(36.79739951722624, 53.68481556964314), + Offset(36.79742436471152, 53.7587295593099), + Offset(36.79745414717516, 53.83107808312845), + Offset(36.79748577699936, 53.90277982470875), + Offset(36.79751660719509, 53.97463627018816), + Offset(36.7975441640286, 54.04741071362252), + Offset(36.797566336361996, 54.121768596383546), + Offset(36.79758120646093, 54.19832738700239), + Offset(36.79758708492745, 54.27764432912612), + Offset(36.79758712071989, 54.35877252649494), + Offset(36.79758712071989, 54.439911871108684), + Offset(36.79758712071989, 54.52105121572244), + Offset(36.79758712071989, 54.522707120719886), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + ]), + _PathCubicTo( + <Offset>[ + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + ], + <Offset>[ + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + ], + <Offset>[ + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + ], + <Offset>[ + Offset(56.04837809389354, 52.900684331474906), + Offset(56.030300891829654, 52.8847340126718), + Offset(56.01222368976779, 52.86878369386669), + Offset(55.9941464877039, 52.85283337506358), + Offset(55.976069285642026, 52.83688305626048), + Offset(55.95799208358016, 52.820932737455365), + Offset(55.93991488151627, 52.80498241865226), + Offset(55.921837679454406, 52.78903209984916), + Offset(55.90376047739254, 52.77308178104404), + Offset(55.88568327532865, 52.757131462240935), + Offset(55.86760607326678, 52.741181143437835), + Offset(55.84952887120491, 52.72523082463272), + Offset(55.831451669141025, 52.70928050582962), + Offset(55.81337446707916, 52.69333018702651), + Offset(55.79529726501527, 52.6773798682214), + Offset(55.78900433275297, 52.672562732506236), + Offset(55.80942241203834, 52.69298081179161), + Offset(55.829840491323715, 52.713398891074974), + Offset(55.850258570607075, 52.73381697036035), + Offset(55.87067664989245, 52.75423504964572), + Offset(55.89109472917782, 52.77465312892909), + Offset(55.91151280846119, 52.795071208214466), + Offset(55.93193088774657, 52.81548928749984), + Offset(55.952348967031945, 52.8359073667832), + Offset(55.972767046315305, 52.856325446068574), + Offset(55.997486569314425, 52.87878031963378), + Offset(56.02633547827809, 52.9031905157102), + Offset(56.05518438724176, 52.9276007117846), + Offset(56.084033296203415, 52.952010907861016), + Offset(56.11288220516708, 52.97642110393542), + Offset(56.14173111413075, 53.00083130001184), + Offset(56.170580023094416, 53.025241496086245), + Offset(56.19942893205808, 53.04965169216266), + Offset(56.22827784102175, 53.07406188823707), + Offset(56.25712674998542, 53.098472084313485), + Offset(56.285975658949084, 53.122882280387884), + Offset(56.31482456791073, 53.1472924764643), + Offset(56.3436734768744, 53.17170267253871), + Offset(56.37252238583807, 53.196112868615124), + Offset(56.401371294801734, 53.22052306468953), + Offset(56.4302202037654, 53.24493326076595), + Offset(56.45906911272907, 53.26934345684035), + Offset(56.487918021692735, 53.29375365291677), + Offset(56.51676693065439, 53.318163848991176), + Offset(56.54561583961806, 53.342574045067586), + Offset(56.574464748581725, 53.36698424114199), + Offset(56.60331365754539, 53.39139443721841), + Offset(56.63216256650906, 53.415804633292815), + Offset(56.632751319752806, 53.41630280056001), + ], + <Offset>[ + Offset(56.04837809389354, 52.900684331474906), + Offset(56.030300891829654, 52.8847340126718), + Offset(56.01222368976779, 52.86878369386669), + Offset(55.9941464877039, 52.85283337506358), + Offset(55.976069285642026, 52.83688305626048), + Offset(55.95799208358016, 52.820932737455365), + Offset(55.93991488151627, 52.80498241865226), + Offset(55.921837679454406, 52.78903209984916), + Offset(55.90376047739254, 52.77308178104404), + Offset(55.88568327532865, 52.757131462240935), + Offset(55.86760607326678, 52.741181143437835), + Offset(55.84952887120491, 52.72523082463272), + Offset(55.831451669141025, 52.70928050582962), + Offset(55.81337446707916, 52.69333018702651), + Offset(55.79529726501527, 52.6773798682214), + Offset(55.78900433275297, 52.672562732506236), + Offset(55.80942241203834, 52.69298081179161), + Offset(55.829840491323715, 52.713398891074974), + Offset(55.850258570607075, 52.73381697036035), + Offset(55.87067664989245, 52.75423504964572), + Offset(55.89109472917782, 52.77465312892909), + Offset(55.91151280846119, 52.795071208214466), + Offset(55.93193088774657, 52.81548928749984), + Offset(55.952348967031945, 52.8359073667832), + Offset(55.972767046315305, 52.856325446068574), + Offset(55.997486569314425, 52.87878031963378), + Offset(56.02633547827809, 52.9031905157102), + Offset(56.05518438724176, 52.9276007117846), + Offset(56.084033296203415, 52.952010907861016), + Offset(56.11288220516708, 52.97642110393542), + Offset(56.14173111413075, 53.00083130001184), + Offset(56.170580023094416, 53.025241496086245), + Offset(56.19942893205808, 53.04965169216266), + Offset(56.22827784102175, 53.07406188823707), + Offset(56.25712674998542, 53.098472084313485), + Offset(56.285975658949084, 53.122882280387884), + Offset(56.31482456791073, 53.1472924764643), + Offset(56.3436734768744, 53.17170267253871), + Offset(56.37252238583807, 53.196112868615124), + Offset(56.401371294801734, 53.22052306468953), + Offset(56.4302202037654, 53.24493326076595), + Offset(56.45906911272907, 53.26934345684035), + Offset(56.487918021692735, 53.29375365291677), + Offset(56.51676693065439, 53.318163848991176), + Offset(56.54561583961806, 53.342574045067586), + Offset(56.574464748581725, 53.36698424114199), + Offset(56.60331365754539, 53.39139443721841), + Offset(56.63216256650906, 53.415804633292815), + Offset(56.632751319752806, 53.41630280056001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04837809389354, 52.9006440978749), + Offset(56.030300891829654, 52.8846937790718), + Offset(56.01222368976779, 52.868743460266685), + Offset(55.9941464877039, 52.85279314146358), + Offset(55.976069285642026, 52.83684282266048), + Offset(55.95799208358016, 52.82089250385536), + Offset(55.93991488151627, 52.80494218505226), + Offset(55.921837679454406, 52.788991866249155), + Offset(55.90376047739254, 52.773041547444045), + Offset(55.88568327532865, 52.75709122864094), + Offset(55.86760607326678, 52.74114090983784), + Offset(55.84952887120491, 52.72519059103272), + Offset(55.831451669141025, 52.709240272229614), + Offset(55.81337446707916, 52.693289953426515), + Offset(55.79529726501527, 52.6773396346214), + Offset(55.78900433275297, 52.67252249890623), + Offset(55.80942241203834, 52.69294057819161), + Offset(55.829840491323715, 52.71335865747497), + Offset(55.850258570607075, 52.73377673676035), + Offset(55.87067664989245, 52.754194816045725), + Offset(55.89109472917782, 52.774612895329085), + Offset(55.91151280846119, 52.79503097461446), + Offset(55.93193088774657, 52.81544905389984), + Offset(55.952348967031945, 52.8358671331832), + Offset(55.972767046315305, 52.85628521246858), + Offset(55.997486569314425, 52.87874008603378), + Offset(56.02633547827809, 52.9031502821102), + Offset(56.05518438724176, 52.9275604781846), + Offset(56.084033296203415, 52.95197067426102), + Offset(56.11288220516708, 52.976380870335426), + Offset(56.14173111413075, 53.00079106641184), + Offset(56.170580023094416, 53.02520126248624), + Offset(56.19942893205808, 53.049611458562666), + Offset(56.22827784102175, 53.074021654637065), + Offset(56.25712674998542, 53.09843185071348), + Offset(56.285975658949084, 53.12284204678789), + Offset(56.31482456791073, 53.147252242864305), + Offset(56.3436734768744, 53.17166243893871), + Offset(56.37252238583807, 53.19607263501513), + Offset(56.401371294801734, 53.22048283108953), + Offset(56.4302202037654, 53.24489302716594), + Offset(56.45906911272907, 53.26930322324035), + Offset(56.487918021692735, 53.29371341931677), + Offset(56.51676693065439, 53.31812361539117), + Offset(56.54561583961806, 53.34253381146759), + Offset(56.574464748581725, 53.366944007541996), + Offset(56.60331365754539, 53.39135420361841), + Offset(56.63216256650906, 53.41576439969282), + Offset(56.632751319752806, 53.41626256696001), + ], + <Offset>[ + Offset(53.21193262270276, 55.75726624798824), + Offset(53.193855420638876, 55.741315929185134), + Offset(53.17577821857701, 55.725365610380024), + Offset(53.15770101651312, 55.70941529157692), + Offset(53.139623814451255, 55.69346497277382), + Offset(53.12154661238938, 55.6775146539687), + Offset(53.1034694103255, 55.6615643351656), + Offset(53.08539220826363, 55.645614016362494), + Offset(53.06731500620175, 55.629663697557376), + Offset(53.049237804137874, 55.61371337875428), + Offset(53.031160602076, 55.59776305995117), + Offset(53.01308340001413, 55.58181274114605), + Offset(52.99500619795025, 55.56586242234295), + Offset(52.97692899588838, 55.549912103539846), + Offset(52.95885179382449, 55.533961784734736), + Offset(52.95255886156219, 55.52914464901957), + Offset(52.97297694084756, 55.54956272830495), + Offset(52.99339502013294, 55.56998080758831), + Offset(53.013813099416296, 55.590398886873686), + Offset(53.034231178701674, 55.610816966159064), + Offset(53.05464925798705, 55.63123504544242), + Offset(53.07506733727041, 55.6516531247278), + Offset(53.09548541655579, 55.67207120401317), + Offset(53.11590349584117, 55.69248928329654), + Offset(53.136321575124526, 55.712907362581916), + Offset(53.16104109812365, 55.73536223614712), + Offset(53.189890007087314, 55.759772432223535), + Offset(53.21873891605098, 55.78418262829794), + Offset(53.24758782501264, 55.80859282437436), + Offset(53.276436733976304, 55.83300302044876), + Offset(53.30528564293997, 55.857413216525174), + Offset(53.33413455190364, 55.88182341259958), + Offset(53.362983460867305, 55.906233608676), + Offset(53.39183236983097, 55.9306438047504), + Offset(53.42068127879464, 55.95505400082682), + Offset(53.449530187758306, 55.979464196901226), + Offset(53.47837909671996, 56.00387439297764), + Offset(53.50722800568363, 56.02828458905205), + Offset(53.53607691464729, 56.052694785128466), + Offset(53.56492582361096, 56.077104981202865), + Offset(53.59377473257462, 56.10151517727928), + Offset(53.6226236415383, 56.12592537335369), + Offset(53.65147255050196, 56.150335569430105), + Offset(53.68032145946361, 56.17474576550451), + Offset(53.70917036842728, 56.19915596158093), + Offset(53.73801927739095, 56.22356615765533), + Offset(53.766868186354614, 56.247976353731744), + Offset(53.79571709531828, 56.27238654980615), + Offset(53.79630584856203, 56.272884717073346), + ], + <Offset>[ + Offset(53.21190192690009, 55.75726624798824), + Offset(53.19382472483621, 55.741315929185134), + Offset(53.17574752277434, 55.725365610380024), + Offset(53.15767032071045, 55.70941529157692), + Offset(53.139593118648584, 55.69346497277382), + Offset(53.121515916586716, 55.6775146539687), + Offset(53.10343871452283, 55.6615643351656), + Offset(53.08536151246096, 55.645614016362494), + Offset(53.06728431039909, 55.629663697557376), + Offset(53.04920710833521, 55.61371337875428), + Offset(53.031129906273335, 55.59776305995117), + Offset(53.01305270421147, 55.58181274114605), + Offset(52.99497550214758, 55.56586242234295), + Offset(52.97689830008571, 55.549912103539846), + Offset(52.95882109802183, 55.533961784734736), + Offset(52.95252816575952, 55.52914464901957), + Offset(52.972946245044895, 55.54956272830495), + Offset(52.993364324330265, 55.56998080758831), + Offset(53.01378240361363, 55.590398886873686), + Offset(53.03420048289901, 55.610816966159064), + Offset(53.05461856218438, 55.63123504544242), + Offset(53.07503664146775, 55.6516531247278), + Offset(53.095454720753125, 55.67207120401317), + Offset(53.115872800038495, 55.69248928329654), + Offset(53.13629087932186, 55.712907362581916), + Offset(53.161010402320976, 55.73536223614712), + Offset(53.18985931128464, 55.759772432223535), + Offset(53.21870822024831, 55.78418262829794), + Offset(53.247557129209966, 55.80859282437436), + Offset(53.27640603817363, 55.83300302044876), + Offset(53.3052549471373, 55.857413216525174), + Offset(53.33410385610097, 55.88182341259958), + Offset(53.362952765064634, 55.906233608676), + Offset(53.3918016740283, 55.9306438047504), + Offset(53.42065058299197, 55.95505400082682), + Offset(53.449499491955635, 55.979464196901226), + Offset(53.47834840091729, 56.00387439297764), + Offset(53.50719730988096, 56.02828458905205), + Offset(53.536046218844625, 56.052694785128466), + Offset(53.56489512780829, 56.077104981202865), + Offset(53.59374403677196, 56.10151517727928), + Offset(53.622592945735626, 56.12592537335369), + Offset(53.65144185469929, 56.150335569430105), + Offset(53.68029076366095, 56.17474576550451), + Offset(53.709139672624616, 56.19915596158093), + Offset(53.73798858158828, 56.22356615765533), + Offset(53.76683749055195, 56.247976353731744), + Offset(53.79568639951562, 56.27238654980615), + Offset(53.796275152759364, 56.272884717073346), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(53.21190192690009, 55.75722601438824), + Offset(53.19382472483621, 55.74127569558514), + Offset(53.17574752277434, 55.72532537678002), + Offset(53.15767032071045, 55.70937505797692), + Offset(53.139593118648584, 55.693424739173814), + Offset(53.121515916586716, 55.677474420368696), + Offset(53.10343871452283, 55.6615241015656), + Offset(53.08536151246096, 55.6455737827625), + Offset(53.06728431039909, 55.62962346395738), + Offset(53.04920710833521, 55.61367314515427), + Offset(53.031129906273335, 55.59772282635117), + Offset(53.01305270421147, 55.581772507546056), + Offset(52.99497550214758, 55.565822188742956), + Offset(52.97689830008571, 55.54987186993985), + Offset(52.95882109802183, 55.53392155113473), + Offset(52.95252816575952, 55.52910441541957), + Offset(52.972946245044895, 55.549522494704945), + Offset(52.993364324330265, 55.56994057398831), + Offset(53.01378240361363, 55.59035865327368), + Offset(53.03420048289901, 55.61077673255906), + Offset(53.05461856218438, 55.63119481184242), + Offset(53.07503664146775, 55.6516128911278), + Offset(53.095454720753125, 55.672030970413175), + Offset(53.115872800038495, 55.692449049696535), + Offset(53.13629087932186, 55.71286712898191), + Offset(53.161010402320976, 55.73532200254712), + Offset(53.18985931128464, 55.75973219862353), + Offset(53.21870822024831, 55.78414239469794), + Offset(53.247557129209966, 55.808552590774354), + Offset(53.27640603817363, 55.83296278684876), + Offset(53.3052549471373, 55.85737298292518), + Offset(53.33410385610097, 55.881783178999584), + Offset(53.362952765064634, 55.906193375076), + Offset(53.3918016740283, 55.9306035711504), + Offset(53.42065058299197, 55.955013767226816), + Offset(53.449499491955635, 55.97942396330122), + Offset(53.47834840091729, 56.00383415937764), + Offset(53.50719730988096, 56.028244355452046), + Offset(53.536046218844625, 56.05265455152846), + Offset(53.56489512780829, 56.07706474760286), + Offset(53.59374403677196, 56.101474943679285), + Offset(53.622592945735626, 56.125885139753684), + Offset(53.65144185469929, 56.15029533583011), + Offset(53.68029076366095, 56.17470553190451), + Offset(53.709139672624616, 56.199115727980924), + Offset(53.73798858158828, 56.22352592405533), + Offset(53.76683749055195, 56.24793612013175), + Offset(53.79568639951562, 56.27234631620615), + Offset(53.796275152759364, 56.27284448347334), + ], + <Offset>[ + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856235894), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856235894), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + ], + <Offset>[ + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856235894), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856235894), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + ], + <Offset>[ + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243940005), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + ], + <Offset>[ + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243940005), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243940005), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + ], + <Offset>[ + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.53659283720165), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.5785472078895, 61.10082674001624), + Offset(57.412981276788415, 62.9439908225431), + Offset(59.24741534567123, 64.7871549050921), + Offset(61.081849414574165, 66.63031898762097), + Offset(62.91628348345698, 68.47348307014985), + Offset(64.75071755235992, 70.31664715269883), + Offset(66.58515162124274, 72.15981123522772), + Offset(68.41958569014567, 74.00297531775661), + Offset(70.2540197590285, 75.84613940028547), + Offset(72.08845382793142, 77.68930348283448), + Offset(73.03297916605787, 78.63832439892204), + Offset(73.12319212266141, 78.72896787526297), + Offset(73.21340507924484, 78.81961135158377), + Offset(73.30361803584839, 78.9102548279046), + Offset(73.39383099245194, 79.0008983042254), + Offset(73.48404394903537, 79.09154178054621), + Offset(73.5742569056389, 79.18218525686703), + Offset(73.66446986222233, 79.27282873318785), + Offset(73.75468281882588, 79.36347220952878), + Offset(73.84489577542942, 79.45411568584959), + Offset(73.93510873201285, 79.5447591621704), + Offset(74.0253216886164, 79.6354026384912), + Offset(74.11553464521995, 79.72604611481202), + Offset(74.20574760180338, 79.81668959113284), + Offset(74.29596055840692, 79.90733306745365), + Offset(74.38617351501047, 79.99797654379458), + Offset(74.4763864715939, 80.0886200201154), + Offset(74.56659942819743, 80.1792634964362), + Offset(74.65681238478088, 80.26990697275701), + Offset(74.74702534138441, 80.36055044907783), + Offset(74.83723829798797, 80.45119392539866), + Offset(74.9274512545714, 80.54183740171945), + Offset(75.01766421117493, 80.63248087806039), + Offset(75.0195052919199, 80.63433074491833), + ], + <Offset>[ + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.53659283720165), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.5785472078895, 61.10082674001624), + Offset(57.412981276788415, 62.9439908225431), + Offset(59.24741534567123, 64.7871549050921), + Offset(61.081849414574165, 66.63031898762097), + Offset(62.91628348345698, 68.47348307014985), + Offset(64.75071755235992, 70.31664715269883), + Offset(66.58515162124274, 72.15981123522772), + Offset(68.41958569014567, 74.00297531775661), + Offset(70.2540197590285, 75.84613940028547), + Offset(72.08845382793142, 77.68930348283448), + Offset(73.03297916605787, 78.63832439892204), + Offset(73.12319212266141, 78.72896787526297), + Offset(73.21340507924484, 78.81961135158377), + Offset(73.30361803584839, 78.9102548279046), + Offset(73.39383099245194, 79.0008983042254), + Offset(73.48404394903537, 79.09154178054621), + Offset(73.5742569056389, 79.18218525686703), + Offset(73.66446986222233, 79.27282873318785), + Offset(73.75468281882588, 79.36347220952878), + Offset(73.84489577542942, 79.45411568584959), + Offset(73.93510873201285, 79.5447591621704), + Offset(74.0253216886164, 79.6354026384912), + Offset(74.11553464521995, 79.72604611481202), + Offset(74.20574760180338, 79.81668959113284), + Offset(74.29596055840692, 79.90733306745365), + Offset(74.38617351501047, 79.99797654379458), + Offset(74.4763864715939, 80.0886200201154), + Offset(74.56659942819743, 80.1792634964362), + Offset(74.65681238478088, 80.26990697275701), + Offset(74.74702534138441, 80.36055044907783), + Offset(74.83723829798797, 80.45119392539866), + Offset(74.9274512545714, 80.54183740171945), + Offset(75.01766421117493, 80.63248087806039), + Offset(75.0195052919199, 80.63433074491833), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.53659283720165), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.5785472078895, 61.10082674001624), + Offset(57.412981276788415, 62.9439908225431), + Offset(59.24741534567123, 64.7871549050921), + Offset(61.081849414574165, 66.63031898762097), + Offset(62.91628348345698, 68.47348307014985), + Offset(64.75071755235992, 70.31664715269883), + Offset(66.58515162124274, 72.15981123522772), + Offset(68.41958569014567, 74.00297531775661), + Offset(70.2540197590285, 75.84613940028547), + Offset(72.08845382793142, 77.68930348283448), + Offset(73.03297916605787, 78.63832439892204), + Offset(73.12319212266141, 78.72896787526297), + Offset(73.21340507924484, 78.81961135158377), + Offset(73.30361803584839, 78.9102548279046), + Offset(73.39383099245194, 79.0008983042254), + Offset(73.48404394903537, 79.09154178054621), + Offset(73.5742569056389, 79.18218525686703), + Offset(73.66446986222233, 79.27282873318785), + Offset(73.75468281882588, 79.36347220952878), + Offset(73.84489577542942, 79.45411568584959), + Offset(73.93510873201285, 79.5447591621704), + Offset(74.0253216886164, 79.6354026384912), + Offset(74.11553464521995, 79.72604611481202), + Offset(74.20574760180338, 79.81668959113284), + Offset(74.29596055840692, 79.90733306745365), + Offset(74.38617351501047, 79.99797654379458), + Offset(74.4763864715939, 80.0886200201154), + Offset(74.56659942819743, 80.1792634964362), + Offset(74.65681238478088, 80.26990697275701), + Offset(74.74702534138441, 80.36055044907783), + Offset(74.83723829798797, 80.45119392539866), + Offset(74.9274512545714, 80.54183740171945), + Offset(75.01766421117493, 80.63248087806039), + Offset(75.0195052919199, 80.63433074491833), + ], + <Offset>[ + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.54176433622131), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.57337474962035, 55.105999198285396), + Offset(63.407805684985675, 56.94916641434585), + Offset(65.24223662033491, 58.79233363042842), + Offset(67.07666755572437, 60.63550084647077), + Offset(68.9110984910736, 62.47866806253323), + Offset(70.74552942644296, 64.32183527861581), + Offset(72.57996036179219, 66.16500249467828), + Offset(74.41439129716153, 68.00816971074073), + Offset(76.24882223253088, 69.85133692678309), + Offset(78.08325316790022, 71.69450414286567), + Offset(79.02777766633133, 72.64352589864858), + Offset(79.11799198534516, 72.73416801257923), + Offset(79.20820630433886, 72.82481012648977), + Offset(79.29842062333256, 72.91545224042041), + Offset(79.38863494234639, 73.00609435433094), + Offset(79.47884926134012, 73.09673646824147), + Offset(79.56906358035394, 73.18737858215201), + Offset(79.65927789932752, 73.27802069608266), + Offset(79.74949221834135, 73.36866281001332), + Offset(79.83970653735517, 73.45930492392384), + Offset(79.92992085632878, 73.5499470378545), + Offset(80.0201351753426, 73.64058915176503), + Offset(80.11034949435643, 73.73123126567556), + Offset(80.20056381333, 73.82187337960622), + Offset(80.29077813234383, 73.91251549351674), + Offset(80.38099245135766, 74.00315760744739), + Offset(80.47120677033126, 74.09379972137803), + Offset(80.56142108934507, 74.18444183528857), + Offset(80.65163540833879, 74.2750839491991), + Offset(80.74184972735262, 74.36572606310963), + Offset(80.83206404634632, 74.45636817704029), + Offset(80.92227836534002, 74.54701029095082), + Offset(81.01249268435384, 74.63765240488146), + Offset(81.01433379290023, 74.639502243938), + ], + <Offset>[ + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.54176433622131), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.57337474962035, 55.105999198285396), + Offset(63.407805684985675, 56.94916641434585), + Offset(65.24223662033491, 58.79233363042842), + Offset(67.07666755572437, 60.63550084647077), + Offset(68.9110984910736, 62.47866806253323), + Offset(70.74552942644296, 64.32183527861581), + Offset(72.57996036179219, 66.16500249467828), + Offset(74.41439129716153, 68.00816971074073), + Offset(76.24882223253088, 69.85133692678309), + Offset(78.08325316790022, 71.69450414286567), + Offset(79.02777766633133, 72.64352589864858), + Offset(79.11799198534516, 72.73416801257923), + Offset(79.20820630433886, 72.82481012648977), + Offset(79.29842062333256, 72.91545224042041), + Offset(79.38863494234639, 73.00609435433094), + Offset(79.47884926134012, 73.09673646824147), + Offset(79.56906358035394, 73.18737858215201), + Offset(79.65927789932752, 73.27802069608266), + Offset(79.74949221834135, 73.36866281001332), + Offset(79.83970653735517, 73.45930492392384), + Offset(79.92992085632878, 73.5499470378545), + Offset(80.0201351753426, 73.64058915176503), + Offset(80.11034949435643, 73.73123126567556), + Offset(80.20056381333, 73.82187337960622), + Offset(80.29077813234383, 73.91251549351674), + Offset(80.38099245135766, 74.00315760744739), + Offset(80.47120677033126, 74.09379972137803), + Offset(80.56142108934507, 74.18444183528857), + Offset(80.65163540833879, 74.2750839491991), + Offset(80.74184972735262, 74.36572606310963), + Offset(80.83206404634632, 74.45636817704029), + Offset(80.92227836534002, 74.54701029095082), + Offset(81.01249268435384, 74.63765240488146), + Offset(81.01433379290023, 74.639502243938), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.54176433622131), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.57337474962035, 55.105999198285396), + Offset(63.407805684985675, 56.94916641434585), + Offset(65.24223662033491, 58.79233363042842), + Offset(67.07666755572437, 60.63550084647077), + Offset(68.9110984910736, 62.47866806253323), + Offset(70.74552942644296, 64.32183527861581), + Offset(72.57996036179219, 66.16500249467828), + Offset(74.41439129716153, 68.00816971074073), + Offset(76.24882223253088, 69.85133692678309), + Offset(78.08325316790022, 71.69450414286567), + Offset(79.02777766633133, 72.64352589864858), + Offset(79.11799198534516, 72.73416801257923), + Offset(79.20820630433886, 72.82481012648977), + Offset(79.29842062333256, 72.91545224042041), + Offset(79.38863494234639, 73.00609435433094), + Offset(79.47884926134012, 73.09673646824147), + Offset(79.56906358035394, 73.18737858215201), + Offset(79.65927789932752, 73.27802069608266), + Offset(79.74949221834135, 73.36866281001332), + Offset(79.83970653735517, 73.45930492392384), + Offset(79.92992085632878, 73.5499470378545), + Offset(80.0201351753426, 73.64058915176503), + Offset(80.11034949435643, 73.73123126567556), + Offset(80.20056381333, 73.82187337960622), + Offset(80.29077813234383, 73.91251549351674), + Offset(80.38099245135766, 74.00315760744739), + Offset(80.47120677033126, 74.09379972137803), + Offset(80.56142108934507, 74.18444183528857), + Offset(80.65163540833879, 74.2750839491991), + Offset(80.74184972735262, 74.36572606310963), + Offset(80.83206404634632, 74.45636817704029), + Offset(80.92227836534002, 74.54701029095082), + Offset(81.01249268435384, 74.63765240488146), + Offset(81.01433379290023, 74.639502243938), + ], + <Offset>[ + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.522702243936166), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.937745291917885, 54.52270224394), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529189977, 54.52270224395811), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.937745291919896, 54.522702243937985), + Offset(60.937745291919896, 54.52270224395811), + Offset(60.93774529189977, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224391788), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529191989, 54.52270224395811), + Offset(60.93774529191989, 54.522702243937985), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194001, 54.52270224391787), + Offset(60.93774529189977, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.93774529189978, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194002, 54.52270224393799), + Offset(60.93774529189978, 54.52270224393799), + Offset(60.937745291919896, 54.522702243938), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.93774529189978, 54.52270224395811), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224391788), + Offset(60.937745291919896, 54.522702243938), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + ], + <Offset>[ + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.522702243936166), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.937745291917885, 54.52270224394), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529189977, 54.52270224395811), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.937745291919896, 54.522702243937985), + Offset(60.937745291919896, 54.522702243937985), + Offset(60.93774529189977, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224391788), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529191989, 54.52270224395811), + Offset(60.93774529191989, 54.522702243937985), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194001, 54.52270224391787), + Offset(60.93774529189977, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.93774529189978, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194002, 54.52270224393799), + Offset(60.93774529189978, 54.52270224393799), + Offset(60.937745291919896, 54.522702243938), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.93774529189978, 54.52270224395811), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224391788), + Offset(60.937745291919896, 54.522702243938), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.920754690484827), + Offset(48.0, 32.45354569710999), + Offset(48.0, 33.98010729644093), + Offset(48.0, 37.048746193280394), + Offset(48.0, 43.18831617722788), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + ]), + _PathCubicTo( + <Offset>[ + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.920754690484827), + Offset(52.445826306158004, 32.45354569710999), + Offset(52.445826306158004, 33.98010729644093), + Offset(52.445826306158004, 37.048746193280394), + Offset(52.445826306158004, 43.18831617722788), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + ], + <Offset>[ + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.319830300840163), + Offset(56.04672, 28.852621307465323), + Offset(56.04672, 30.379182906796263), + Offset(56.04672, 33.44782180363573), + Offset(56.04672, 39.587391787583215), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + ], + <Offset>[ + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.874034690484827), + Offset(56.04672, 24.406825697109987), + Offset(56.04672, 25.933387296440927), + Offset(56.04672, 29.002026193280393), + Offset(56.04672, 35.14159617722788), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.428208384326826), + Offset(56.04672, 19.960999390951986), + Offset(56.04672, 21.487560990282926), + Offset(56.04672, 24.556199887122393), + Offset(56.04672, 30.69576987106988), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + ], + <Offset>[ + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.827314690484826), + Offset(52.445826306158004, 16.360105697109987), + Offset(52.445826306158004, 17.886667296440926), + Offset(52.445826306158004, 20.955306193280393), + Offset(52.445826306158004, 27.09487617722788), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + ], + <Offset>[ + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.827314690484826), + Offset(48.0, 16.360105697109987), + Offset(48.0, 17.886667296440926), + Offset(48.0, 20.955306193280393), + Offset(48.0, 27.09487617722788), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.827314690484826), + Offset(43.554173693841996, 16.360105697109987), + Offset(43.554173693841996, 17.886667296440926), + Offset(43.554173693841996, 20.955306193280393), + Offset(43.554173693841996, 27.09487617722788), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + ], + <Offset>[ + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.428208384326826), + Offset(39.95328, 19.960999390951986), + Offset(39.95328, 21.487560990282926), + Offset(39.95328, 24.556199887122393), + Offset(39.95328, 30.69576987106988), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + ], + <Offset>[ + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.874034690484827), + Offset(39.95328, 24.406825697109987), + Offset(39.95328, 25.933387296440927), + Offset(39.95328, 29.002026193280393), + Offset(39.95328, 35.14159617722788), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.319830300840163), + Offset(39.95328, 28.852621307465323), + Offset(39.95328, 30.379182906796263), + Offset(39.95328, 33.44782180363573), + Offset(39.95328, 39.587391787583215), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + ], + <Offset>[ + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.920754690484827), + Offset(43.554173693841996, 32.45354569710999), + Offset(43.554173693841996, 33.98010729644093), + Offset(43.554173693841996, 37.048746193280394), + Offset(43.554173693841996, 43.18831617722788), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + ], + <Offset>[ + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.920754690484827), + Offset(48.0, 32.45354569710999), + Offset(48.0, 33.98010729644093), + Offset(48.0, 37.048746193280394), + Offset(48.0, 43.18831617722788), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + ]), + _PathCubicTo( + <Offset>[ + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + ], + <Offset>[ + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + ], + <Offset>[ + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + ], + <Offset>[ + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + ], + <Offset>[ + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + ], + <Offset>[ + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + ], + <Offset>[ + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + ], + <Offset>[ + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + ], + <Offset>[ + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.07926469426482), + Offset(48.0, 63.5472012869485), + Offset(48.0, 62.02272441694872), + Offset(48.0, 58.958276167797024), + Offset(48.0, 52.827090609197995), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + ]), + _PathCubicTo( + <Offset>[ + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.07926469426482), + Offset(43.554173693841996, 63.5472012869485), + Offset(43.554173693841996, 62.02272441694872), + Offset(43.554173693841996, 58.958276167797024), + Offset(43.554173693841996, 52.827090609197995), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + ], + <Offset>[ + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.68018908390948), + Offset(39.95328, 67.14812567659317), + Offset(39.95328, 65.62364880659338), + Offset(39.95328, 62.559200557441685), + Offset(39.95328, 56.428014998842656), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + ], + <Offset>[ + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.12598469426482), + Offset(39.95328, 71.5939212869485), + Offset(39.95328, 70.06944441694873), + Offset(39.95328, 67.00499616779703), + Offset(39.95328, 60.873810609197996), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.57181100042281), + Offset(39.95328, 76.0397475931065), + Offset(39.95328, 74.51527072310672), + Offset(39.95328, 71.45082247395503), + Offset(39.95328, 65.319636915356), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + ], + <Offset>[ + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.17270469426481), + Offset(43.554173693841996, 79.6406412869485), + Offset(43.554173693841996, 78.11616441694872), + Offset(43.554173693841996, 75.05171616779703), + Offset(43.554173693841996, 68.920530609198), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + ], + <Offset>[ + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.17270469426481), + Offset(48.0, 79.6406412869485), + Offset(48.0, 78.11616441694872), + Offset(48.0, 75.05171616779703), + Offset(48.0, 68.920530609198), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.17270469426481), + Offset(52.445826306158004, 79.6406412869485), + Offset(52.445826306158004, 78.11616441694872), + Offset(52.445826306158004, 75.05171616779703), + Offset(52.445826306158004, 68.920530609198), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + ], + <Offset>[ + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.57181100042281), + Offset(56.04672, 76.0397475931065), + Offset(56.04672, 74.51527072310672), + Offset(56.04672, 71.45082247395503), + Offset(56.04672, 65.319636915356), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + ], + <Offset>[ + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.12598469426482), + Offset(56.04672, 71.5939212869485), + Offset(56.04672, 70.06944441694873), + Offset(56.04672, 67.00499616779703), + Offset(56.04672, 60.873810609197996), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.68018908390948), + Offset(56.04672, 67.14812567659317), + Offset(56.04672, 65.62364880659338), + Offset(56.04672, 62.559200557441685), + Offset(56.04672, 56.428014998842656), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + ], + <Offset>[ + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.07926469426482), + Offset(52.445826306158004, 63.5472012869485), + Offset(52.445826306158004, 62.02272441694872), + Offset(52.445826306158004, 58.958276167797024), + Offset(52.445826306158004, 52.827090609197995), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + ], + <Offset>[ + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.07926469426482), + Offset(48.0, 63.5472012869485), + Offset(48.0, 62.02272441694872), + Offset(48.0, 58.958276167797024), + Offset(48.0, 52.827090609197995), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/event_add.g.dart b/packages/material_ui/lib/src/animated_icons/data/event_add.g.dart new file mode 100644 index 000000000000..b4fab0817ecf --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/event_add.g.dart @@ -0,0 +1,3285 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$event_add = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.761904761905, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(32.01015625, 23.52890625), + Offset(32.01015329719101, 23.53062658538017), + Offset(31.908309754160946, 24.86045986263573), + Offset(29.93347206506402, 28.868314694169428), + Offset(26.259363778077685, 30.74028679651635), + Offset(23.62410382175968, 30.58943972916481), + Offset(21.925014841592773, 29.92755775441251), + Offset(20.79646278990295, 29.175637008874478), + Offset(20.023435215871057, 28.454097084853878), + Offset(19.478915461565702, 27.800711501947685), + Offset(19.08488578667808, 27.224748870271775), + Offset(18.794354809526837, 26.724256833788246), + Offset(18.590826530804044, 26.293721762918267), + Offset(18.45085412812233, 25.930620946266075), + Offset(18.35542242952162, 25.63066280891873), + Offset(18.291196017351787, 25.38919913375952), + Offset(18.248665479323506, 25.201028049969374), + Offset(18.221259589085008, 25.06135592936411), + Offset(18.204520556081953, 24.965782149690927), + Offset(18.19555633054043, 24.91035753064884), + Offset(18.192643234510292, 24.891585410299637), + Offset(18.192636718750002, 24.891542968750002), + ]), + _PathCubicTo( + <Offset>[ + Offset(32.01015625, 23.52890625), + Offset(32.01015329719101, 23.53062658538017), + Offset(31.908309754160946, 24.86045986263573), + Offset(29.93347206506402, 28.868314694169428), + Offset(26.259363778077685, 30.74028679651635), + Offset(23.62410382175968, 30.58943972916481), + Offset(21.925014841592773, 29.92755775441251), + Offset(20.79646278990295, 29.175637008874478), + Offset(20.023435215871057, 28.454097084853878), + Offset(19.478915461565702, 27.800711501947685), + Offset(19.08488578667808, 27.224748870271775), + Offset(18.794354809526837, 26.724256833788246), + Offset(18.590826530804044, 26.293721762918267), + Offset(18.45085412812233, 25.930620946266075), + Offset(18.35542242952162, 25.63066280891873), + Offset(18.291196017351787, 25.38919913375952), + Offset(18.248665479323506, 25.201028049969374), + Offset(18.221259589085008, 25.06135592936411), + Offset(18.204520556081953, 24.965782149690927), + Offset(18.19555633054043, 24.91035753064884), + Offset(18.192643234510292, 24.891585410299637), + Offset(18.192636718750002, 24.891542968750002), + ], + <Offset>[ + Offset(24.01015625, 23.52890625), + Offset(24.010258478063488, 23.528888402424656), + Offset(24.091555200930664, 23.52418084688126), + Offset(24.377115816859927, 23.6152417705347), + Offset(24.633459690010874, 23.859538100350317), + Offset(24.736069637313253, 24.1352514457875), + Offset(24.72116824601048, 24.371795772576675), + Offset(24.648015807099657, 24.549452838288758), + Offset(24.55323690381452, 24.674789163937373), + Offset(24.455385525488182, 24.759322859315567), + Offset(24.363483168967523, 24.813428460885813), + Offset(24.28176718778124, 24.845878439805734), + Offset(24.21233699329442, 24.865919213504945), + Offset(24.154550207153598, 24.87807591728), + Offset(24.107302586424357, 24.885092784958154), + Offset(24.06957428023968, 24.88885460147735), + Offset(24.040355338731057, 24.890659603706283), + Offset(24.01876825740043, 24.89137581050446), + Offset(24.004045682792036, 24.891564195943896), + Offset(23.995525876462455, 24.89156214884712), + Offset(23.9926432343554, 24.891543022538777), + Offset(23.99263671875, 24.89154296875), + ], + <Offset>[ + Offset(24.01015625, 23.52890625), + Offset(24.010258478063488, 23.528888402424656), + Offset(24.091555200930664, 23.52418084688126), + Offset(24.377115816859927, 23.6152417705347), + Offset(24.633459690010874, 23.859538100350317), + Offset(24.736069637313253, 24.1352514457875), + Offset(24.72116824601048, 24.371795772576675), + Offset(24.648015807099657, 24.549452838288758), + Offset(24.55323690381452, 24.674789163937373), + Offset(24.455385525488182, 24.759322859315567), + Offset(24.363483168967523, 24.813428460885813), + Offset(24.28176718778124, 24.845878439805734), + Offset(24.21233699329442, 24.865919213504945), + Offset(24.154550207153598, 24.87807591728), + Offset(24.107302586424357, 24.885092784958154), + Offset(24.06957428023968, 24.88885460147735), + Offset(24.040355338731057, 24.890659603706283), + Offset(24.01876825740043, 24.89137581050446), + Offset(24.004045682792036, 24.891564195943896), + Offset(23.995525876462455, 24.89156214884712), + Offset(23.9926432343554, 24.891543022538777), + Offset(23.99263671875, 24.89154296875), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.01015625, 23.52890625), + Offset(24.010258478063488, 23.528888402424656), + Offset(24.091555200930664, 23.52418084688126), + Offset(24.377115816859927, 23.6152417705347), + Offset(24.633459690010874, 23.859538100350317), + Offset(24.736069637313253, 24.1352514457875), + Offset(24.72116824601048, 24.371795772576675), + Offset(24.648015807099657, 24.549452838288758), + Offset(24.55323690381452, 24.674789163937373), + Offset(24.455385525488182, 24.759322859315567), + Offset(24.363483168967523, 24.813428460885813), + Offset(24.28176718778124, 24.845878439805734), + Offset(24.21233699329442, 24.865919213504945), + Offset(24.154550207153598, 24.87807591728), + Offset(24.107302586424357, 24.885092784958154), + Offset(24.06957428023968, 24.88885460147735), + Offset(24.040355338731057, 24.890659603706283), + Offset(24.01876825740043, 24.89137581050446), + Offset(24.004045682792036, 24.891564195943896), + Offset(23.995525876462455, 24.89156214884712), + Offset(23.9926432343554, 24.891543022538777), + Offset(23.99263671875, 24.89154296875), + ], + <Offset>[ + Offset(24.01015625, 31.52890625), + Offset(24.008520295107978, 31.528783221552175), + Offset(22.755276185176193, 31.340935400111544), + Offset(19.124042893225194, 29.17159801873879), + Offset(17.752710993844843, 25.48544218841713), + Offset(18.281881353935944, 23.023285630233925), + Offset(19.165406264174642, 21.57564236815897), + Offset(20.021831636513937, 20.69789982109205), + Offset(20.773928982898013, 20.14498747599391), + Offset(21.41399688285607, 19.782852795393087), + Offset(21.95216275958156, 19.534831078596365), + Offset(22.40338879379873, 19.358466061551326), + Offset(22.7845344438811, 19.244408751014564), + Offset(23.10200517816752, 19.174379838248726), + Offset(23.36173256246378, 19.133212628055418), + Offset(23.569229747957515, 19.110476338589457), + Offset(23.729986892467963, 19.098969744298735), + Offset(23.84878813854078, 19.09386714218904), + Offset(23.929827729045, 19.092039069233813), + Offset(23.976730494660732, 19.091592602925097), + Offset(23.99260084659454, 19.09154302269367), + Offset(23.99263671875, 19.09154296875), + ], + <Offset>[ + Offset(24.01015625, 31.52890625), + Offset(24.008520295107978, 31.528783221552175), + Offset(22.755276185176193, 31.340935400111544), + Offset(19.124042893225194, 29.17159801873879), + Offset(17.752710993844843, 25.48544218841713), + Offset(18.281881353935944, 23.023285630233925), + Offset(19.165406264174642, 21.57564236815897), + Offset(20.021831636513937, 20.69789982109205), + Offset(20.773928982898013, 20.14498747599391), + Offset(21.41399688285607, 19.782852795393087), + Offset(21.95216275958156, 19.534831078596365), + Offset(22.40338879379873, 19.358466061551326), + Offset(22.7845344438811, 19.244408751014564), + Offset(23.10200517816752, 19.174379838248726), + Offset(23.36173256246378, 19.133212628055418), + Offset(23.569229747957515, 19.110476338589457), + Offset(23.729986892467963, 19.098969744298735), + Offset(23.84878813854078, 19.09386714218904), + Offset(23.929827729045, 19.092039069233813), + Offset(23.976730494660732, 19.091592602925097), + Offset(23.99260084659454, 19.09154302269367), + Offset(23.99263671875, 19.09154296875), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.01015625, 31.52890625), + Offset(24.008520295107978, 31.528783221552175), + Offset(22.755276185176193, 31.340935400111544), + Offset(19.124042893225194, 29.17159801873879), + Offset(17.752710993844843, 25.48544218841713), + Offset(18.281881353935944, 23.023285630233925), + Offset(19.165406264174642, 21.57564236815897), + Offset(20.021831636513937, 20.69789982109205), + Offset(20.773928982898013, 20.14498747599391), + Offset(21.41399688285607, 19.782852795393087), + Offset(21.95216275958156, 19.534831078596365), + Offset(22.40338879379873, 19.358466061551326), + Offset(22.7845344438811, 19.244408751014564), + Offset(23.10200517816752, 19.174379838248726), + Offset(23.36173256246378, 19.133212628055418), + Offset(23.569229747957515, 19.110476338589457), + Offset(23.729986892467963, 19.098969744298735), + Offset(23.84878813854078, 19.09386714218904), + Offset(23.929827729045, 19.092039069233813), + Offset(23.976730494660732, 19.091592602925097), + Offset(23.99260084659454, 19.09154302269367), + Offset(23.99263671875, 19.09154296875), + ], + <Offset>[ + Offset(32.01015625, 31.52890625), + Offset(32.0084151142355, 31.530521404507688), + Offset(30.572030738406475, 32.67721441586602), + Offset(24.68039914142929, 34.424670942373524), + Offset(19.378615081911654, 32.36619088458316), + Offset(17.16991553838237, 29.477473913611234), + Offset(16.369252859756937, 27.131404349994806), + Offset(16.17027861931723, 25.32408399167777), + Offset(16.244127294954552, 23.924295396910416), + Offset(16.437526818933584, 22.824241438025204), + Offset(16.673565377292114, 21.94615148798233), + Offset(16.915976415544325, 21.236844455533838), + Offset(17.16302398139072, 20.67221130042789), + Offset(17.39830909913625, 20.226924867234807), + Offset(17.609852405561046, 19.878782652015996), + Offset(17.79085148506962, 19.61082087087162), + Offset(17.938297033060415, 19.409338190561826), + Offset(18.05127947022536, 19.26384726104869), + Offset(18.13030260233492, 19.166257022980844), + Offset(18.17676094873871, 19.11038798472682), + Offset(18.192600846749432, 19.091585410454528), + Offset(18.19263671875, 19.09154296875), + ], + <Offset>[ + Offset(32.01015625, 31.52890625), + Offset(32.0084151142355, 31.530521404507688), + Offset(30.572030738406475, 32.67721441586602), + Offset(24.68039914142929, 34.424670942373524), + Offset(19.378615081911654, 32.36619088458316), + Offset(17.16991553838237, 29.477473913611234), + Offset(16.369252859756937, 27.131404349994806), + Offset(16.17027861931723, 25.32408399167777), + Offset(16.244127294954552, 23.924295396910416), + Offset(16.437526818933584, 22.824241438025204), + Offset(16.673565377292114, 21.94615148798233), + Offset(16.915976415544325, 21.236844455533838), + Offset(17.16302398139072, 20.67221130042789), + Offset(17.39830909913625, 20.226924867234807), + Offset(17.609852405561046, 19.878782652015996), + Offset(17.79085148506962, 19.61082087087162), + Offset(17.938297033060415, 19.409338190561826), + Offset(18.05127947022536, 19.26384726104869), + Offset(18.13030260233492, 19.166257022980844), + Offset(18.17676094873871, 19.11038798472682), + Offset(18.192600846749432, 19.091585410454528), + Offset(18.19263671875, 19.09154296875), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(32.01015625, 31.52890625), + Offset(32.0084151142355, 31.530521404507688), + Offset(30.572030738406475, 32.67721441586602), + Offset(24.68039914142929, 34.424670942373524), + Offset(19.378615081911654, 32.36619088458316), + Offset(17.16991553838237, 29.477473913611234), + Offset(16.369252859756937, 27.131404349994806), + Offset(16.17027861931723, 25.32408399167777), + Offset(16.244127294954552, 23.924295396910416), + Offset(16.437526818933584, 22.824241438025204), + Offset(16.673565377292114, 21.94615148798233), + Offset(16.915976415544325, 21.236844455533838), + Offset(17.16302398139072, 20.67221130042789), + Offset(17.39830909913625, 20.226924867234807), + Offset(17.609852405561046, 19.878782652015996), + Offset(17.79085148506962, 19.61082087087162), + Offset(17.938297033060415, 19.409338190561826), + Offset(18.05127947022536, 19.26384726104869), + Offset(18.13030260233492, 19.166257022980844), + Offset(18.17676094873871, 19.11038798472682), + Offset(18.192600846749432, 19.091585410454528), + Offset(18.19263671875, 19.09154296875), + ], + <Offset>[ + Offset(32.01015625, 23.52890625), + Offset(32.01015329719101, 23.53062658538017), + Offset(31.908309754160946, 24.86045986263573), + Offset(29.93347206506402, 28.868314694169428), + Offset(26.259363778077685, 30.74028679651635), + Offset(23.62410382175968, 30.58943972916481), + Offset(21.925014841592773, 29.92755775441251), + Offset(20.79646278990295, 29.175637008874478), + Offset(20.023435215871057, 28.454097084853878), + Offset(19.478915461565702, 27.800711501947685), + Offset(19.08488578667808, 27.224748870271775), + Offset(18.794354809526837, 26.724256833788246), + Offset(18.590826530804044, 26.293721762918267), + Offset(18.45085412812233, 25.930620946266075), + Offset(18.35542242952162, 25.63066280891873), + Offset(18.291196017351787, 25.38919913375952), + Offset(18.248665479323506, 25.201028049969374), + Offset(18.221259589085008, 25.06135592936411), + Offset(18.204520556081953, 24.965782149690927), + Offset(18.19555633054043, 24.91035753064884), + Offset(18.192643234510292, 24.891585410299637), + Offset(18.192636718750002, 24.891542968750002), + ], + <Offset>[ + Offset(32.01015625, 23.52890625), + Offset(32.01015329719101, 23.53062658538017), + Offset(31.908309754160946, 24.86045986263573), + Offset(29.93347206506402, 28.868314694169428), + Offset(26.259363778077685, 30.74028679651635), + Offset(23.62410382175968, 30.58943972916481), + Offset(21.925014841592773, 29.92755775441251), + Offset(20.79646278990295, 29.175637008874478), + Offset(20.023435215871057, 28.454097084853878), + Offset(19.478915461565702, 27.800711501947685), + Offset(19.08488578667808, 27.224748870271775), + Offset(18.794354809526837, 26.724256833788246), + Offset(18.590826530804044, 26.293721762918267), + Offset(18.45085412812233, 25.930620946266075), + Offset(18.35542242952162, 25.63066280891873), + Offset(18.291196017351787, 25.38919913375952), + Offset(18.248665479323506, 25.201028049969374), + Offset(18.221259589085008, 25.06135592936411), + Offset(18.204520556081953, 24.965782149690927), + Offset(18.19555633054043, 24.91035753064884), + Offset(18.192643234510292, 24.891585410299637), + Offset(18.192636718750002, 24.891542968750002), + ], + ), + _PathClose(), + _PathMoveTo(<Offset>[ + Offset(30.4, 6.0), + Offset(30.403826772654973, 6.001600955318109), + Offset(33.26297392821128, 7.464112893661335), + Offset(40.3252246978808, 15.636425198410986), + Offset(41.008618677528034, 25.792861690388108), + Offset(37.989766327618334, 31.726851345525642), + Offset(34.66109919246733, 34.936041005423874), + Offset(31.70815663793754, 36.68371605880273), + Offset(29.216038057712375, 37.618746191043186), + Offset(27.14455430692518, 38.09258228629963), + Offset(25.430782781823723, 38.305428372486986), + Offset(24.014543572941673, 38.36973863661014), + Offset(22.850742435528595, 38.323714848128795), + Offset(21.905079756466474, 38.2162199326415), + Offset(21.146729034109512, 38.08362328363005), + Offset(20.550519051592694, 37.94957638018356), + Offset(20.09440860715983, 37.82880891010404), + Offset(19.760567137156492, 37.73014235182733), + Offset(19.53440820396981, 37.65826086700748), + Offset(19.40409629300435, 37.614964895879226), + Offset(19.360099392114897, 37.60003390984549), + Offset(19.360000000000003, 37.6), + ]), + _PathCubicTo( + <Offset>[ + Offset(30.4, 6.0), + Offset(30.403826772654973, 6.001600955318109), + Offset(33.26297392821128, 7.464112893661335), + Offset(40.3252246978808, 15.636425198410986), + Offset(41.008618677528034, 25.792861690388108), + Offset(37.989766327618334, 31.726851345525642), + Offset(34.66109919246733, 34.936041005423874), + Offset(31.70815663793754, 36.68371605880273), + Offset(29.216038057712375, 37.618746191043186), + Offset(27.14455430692518, 38.09258228629963), + Offset(25.430782781823723, 38.305428372486986), + Offset(24.014543572941673, 38.36973863661014), + Offset(22.850742435528595, 38.323714848128795), + Offset(21.905079756466474, 38.2162199326415), + Offset(21.146729034109512, 38.08362328363005), + Offset(20.550519051592694, 37.94957638018356), + Offset(20.09440860715983, 37.82880891010404), + Offset(19.760567137156492, 37.73014235182733), + Offset(19.53440820396981, 37.65826086700748), + Offset(19.40409629300435, 37.614964895879226), + Offset(19.360099392114897, 37.60003390984549), + Offset(19.360000000000003, 37.6), + ], + <Offset>[ + Offset(30.4, 9.2), + Offset(30.40313149947277, 9.201558882969117), + Offset(32.7284623219095, 10.590814714953448), + Offset(38.22399552842691, 17.858967697692623), + Offset(38.25631919906162, 26.44322332561483), + Offset(35.40809101426741, 31.282065019304213), + Offset(32.438794399733, 33.81757964365679), + Offset(29.857682969703255, 35.14309485192405), + Offset(27.704314889345774, 35.8068255158658), + Offset(25.92799884987233, 36.101994260730635), + Offset(24.466254618069335, 36.19398941957121), + Offset(23.263192215348667, 36.174773685308374), + Offset(22.279621415763266, 36.07511066313264), + Offset(21.484061744872044, 35.93474150102899), + Offset(20.84850102452528, 35.782871220868955), + Offset(20.35038123867983, 35.6382250750284), + Offset(19.970261228654596, 35.512132966341014), + Offset(19.692575089612635, 35.41113888450116), + Offset(19.504721022470996, 35.338450816323444), + Offset(19.39657814028366, 35.29497707751041), + Offset(19.360082437010554, 35.28003390990745), + Offset(19.36, 35.28), + ], + <Offset>[ + Offset(30.4, 9.2), + Offset(30.40313149947277, 9.201558882969117), + Offset(32.7284623219095, 10.590814714953448), + Offset(38.22399552842691, 17.858967697692623), + Offset(38.25631919906162, 26.44322332561483), + Offset(35.40809101426741, 31.282065019304213), + Offset(32.438794399733, 33.81757964365679), + Offset(29.857682969703255, 35.14309485192405), + Offset(27.704314889345774, 35.8068255158658), + Offset(25.92799884987233, 36.101994260730635), + Offset(24.466254618069335, 36.19398941957121), + Offset(23.263192215348667, 36.174773685308374), + Offset(22.279621415763266, 36.07511066313264), + Offset(21.484061744872044, 35.93474150102899), + Offset(20.84850102452528, 35.782871220868955), + Offset(20.35038123867983, 35.6382250750284), + Offset(19.970261228654596, 35.512132966341014), + Offset(19.692575089612635, 35.41113888450116), + Offset(19.504721022470996, 35.338450816323444), + Offset(19.39657814028366, 35.29497707751041), + Offset(19.360082437010554, 35.28003390990745), + Offset(19.36, 35.28), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(30.4, 9.2), + Offset(30.40313149947277, 9.201558882969117), + Offset(32.7284623219095, 10.590814714953448), + Offset(38.22399552842691, 17.858967697692623), + Offset(38.25631919906162, 26.44322332561483), + Offset(35.40809101426741, 31.282065019304213), + Offset(32.438794399733, 33.81757964365679), + Offset(29.857682969703255, 35.14309485192405), + Offset(27.704314889345774, 35.8068255158658), + Offset(25.92799884987233, 36.101994260730635), + Offset(24.466254618069335, 36.19398941957121), + Offset(23.263192215348667, 36.174773685308374), + Offset(22.279621415763266, 36.07511066313264), + Offset(21.484061744872044, 35.93474150102899), + Offset(20.84850102452528, 35.782871220868955), + Offset(20.35038123867983, 35.6382250750284), + Offset(19.970261228654596, 35.512132966341014), + Offset(19.692575089612635, 35.41113888450116), + Offset(19.504721022470996, 35.338450816323444), + Offset(19.39657814028366, 35.29497707751041), + Offset(19.360082437010554, 35.28003390990745), + Offset(19.36, 35.28), + ], + <Offset>[ + Offset(17.6, 9.2), + Offset(17.60329978886874, 9.1987777902403), + Offset(20.22165503674104, 8.452768289746293), + Offset(29.333825531300356, 9.454051019877056), + Offset(35.65487265815472, 15.434025411749177), + Offset(37.18723631915313, 20.955363765900515), + Offset(36.912639846801326, 24.928360472719458), + Offset(36.02016779721799, 27.741200178986897), + Offset(34.95199759005531, 29.759932842399394), + Offset(33.8903509521483, 31.235772432519248), + Offset(32.91201042973245, 32.335876764553674), + Offset(32.04305202055572, 33.16936825493635), + Offset(31.274038155747874, 33.790626584071326), + Offset(30.609975471322077, 34.250669454651266), + Offset(30.051509275569657, 34.589959182532034), + Offset(29.595786459300463, 34.83767382337693), + Offset(29.236965003706672, 35.01554345232007), + Offset(28.96858895891731, 35.13917069432573), + Offset(28.78396122520713, 35.21970209032819), + Offset(28.6765294137589, 35.264904466627655), + Offset(28.640082436762725, 35.279966089490074), + Offset(28.64, 35.28), + ], + <Offset>[ + Offset(17.6, 9.2), + Offset(17.60329978886874, 9.1987777902403), + Offset(20.22165503674104, 8.452768289746293), + Offset(29.333825531300356, 9.454051019877056), + Offset(35.65487265815472, 15.434025411749177), + Offset(37.18723631915313, 20.955363765900515), + Offset(36.912639846801326, 24.928360472719458), + Offset(36.02016779721799, 27.741200178986897), + Offset(34.95199759005531, 29.759932842399394), + Offset(33.8903509521483, 31.235772432519248), + Offset(32.91201042973245, 32.335876764553674), + Offset(32.04305202055572, 33.16936825493635), + Offset(31.274038155747874, 33.790626584071326), + Offset(30.609975471322077, 34.250669454651266), + Offset(30.051509275569657, 34.589959182532034), + Offset(29.595786459300463, 34.83767382337693), + Offset(29.236965003706672, 35.01554345232007), + Offset(28.96858895891731, 35.13917069432573), + Offset(28.78396122520713, 35.21970209032819), + Offset(28.6765294137589, 35.264904466627655), + Offset(28.640082436762725, 35.279966089490074), + Offset(28.64, 35.28), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(17.6, 9.2), + Offset(17.60329978886874, 9.1987777902403), + Offset(20.22165503674104, 8.452768289746293), + Offset(29.333825531300356, 9.454051019877056), + Offset(35.65487265815472, 15.434025411749177), + Offset(37.18723631915313, 20.955363765900515), + Offset(36.912639846801326, 24.928360472719458), + Offset(36.02016779721799, 27.741200178986897), + Offset(34.95199759005531, 29.759932842399394), + Offset(33.8903509521483, 31.235772432519248), + Offset(32.91201042973245, 32.335876764553674), + Offset(32.04305202055572, 33.16936825493635), + Offset(31.274038155747874, 33.790626584071326), + Offset(30.609975471322077, 34.250669454651266), + Offset(30.051509275569657, 34.589959182532034), + Offset(29.595786459300463, 34.83767382337693), + Offset(29.236965003706672, 35.01554345232007), + Offset(28.96858895891731, 35.13917069432573), + Offset(28.78396122520713, 35.21970209032819), + Offset(28.6765294137589, 35.264904466627655), + Offset(28.640082436762725, 35.279966089490074), + Offset(28.64, 35.28), + ], + <Offset>[ + Offset(17.6, 6.0), + Offset(17.60399506205094, 5.99881986258929), + Offset(20.75616664304283, 5.326066468454183), + Offset(31.43505470075425, 7.231508520595419), + Offset(38.40717213662114, 14.783663776522452), + Offset(39.76891163250406, 21.400150092121944), + Offset(39.13494463953566, 26.04682183448654), + Offset(37.87064146545227, 29.28182138586558), + Offset(36.463720758421914, 31.571853517576777), + Offset(35.106906409201144, 33.22636045808824), + Offset(33.876538593486835, 34.44731571746945), + Offset(32.79440337814872, 35.364333206238115), + Offset(31.845159175513203, 36.03923076906747), + Offset(31.030993482916507, 36.53214788626377), + Offset(30.349737285153886, 36.890711245293126), + Offset(29.795924272213327, 37.14902512853209), + Offset(29.36111238221191, 37.33221939608309), + Offset(29.03658100646117, 37.4581741616519), + Offset(28.81364840670594, 37.539512141012224), + Offset(28.684047566479585, 37.58489228499647), + Offset(28.64009939186707, 37.59996608942812), + Offset(28.64, 37.6), + ], + <Offset>[ + Offset(17.6, 6.0), + Offset(17.60399506205094, 5.99881986258929), + Offset(20.75616664304283, 5.326066468454183), + Offset(31.43505470075425, 7.231508520595419), + Offset(38.40717213662114, 14.783663776522452), + Offset(39.76891163250406, 21.400150092121944), + Offset(39.13494463953566, 26.04682183448654), + Offset(37.87064146545227, 29.28182138586558), + Offset(36.463720758421914, 31.571853517576777), + Offset(35.106906409201144, 33.22636045808824), + Offset(33.876538593486835, 34.44731571746945), + Offset(32.79440337814872, 35.364333206238115), + Offset(31.845159175513203, 36.03923076906747), + Offset(31.030993482916507, 36.53214788626377), + Offset(30.349737285153886, 36.890711245293126), + Offset(29.795924272213327, 37.14902512853209), + Offset(29.36111238221191, 37.33221939608309), + Offset(29.03658100646117, 37.4581741616519), + Offset(28.81364840670594, 37.539512141012224), + Offset(28.684047566479585, 37.58489228499647), + Offset(28.64009939186707, 37.59996608942812), + Offset(28.64, 37.6), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(17.6, 6.0), + Offset(17.60399506205094, 5.99881986258929), + Offset(20.75616664304283, 5.326066468454183), + Offset(31.43505470075425, 7.231508520595419), + Offset(38.40717213662114, 14.783663776522452), + Offset(39.76891163250406, 21.400150092121944), + Offset(39.13494463953566, 26.04682183448654), + Offset(37.87064146545227, 29.28182138586558), + Offset(36.463720758421914, 31.571853517576777), + Offset(35.106906409201144, 33.22636045808824), + Offset(33.876538593486835, 34.44731571746945), + Offset(32.79440337814872, 35.364333206238115), + Offset(31.845159175513203, 36.03923076906747), + Offset(31.030993482916507, 36.53214788626377), + Offset(30.349737285153886, 36.890711245293126), + Offset(29.795924272213327, 37.14902512853209), + Offset(29.36111238221191, 37.33221939608309), + Offset(29.03658100646117, 37.4581741616519), + Offset(28.81364840670594, 37.539512141012224), + Offset(28.684047566479585, 37.58489228499647), + Offset(28.64009939186707, 37.59996608942812), + Offset(28.64, 37.6), + ], + <Offset>[ + Offset(14.399999999999999, 6.0), + Offset(14.404037134399934, 5.998124589407087), + Offset(17.629464821750716, 4.791554862152392), + Offset(29.212512201472613, 5.130279351141528), + Offset(37.75681050139441, 12.03136429805604), + Offset(40.21369795872548, 18.81847477877102), + Offset(40.25340600130274, 23.824517041752205), + Offset(39.411262672330956, 27.43134771763129), + Offset(38.2756414335993, 30.060130349210176), + Offset(37.09749443477014, 32.009805001035396), + Offset(35.98797754640261, 33.48278755371506), + Offset(34.989368329450485, 34.61298184864511), + Offset(34.09376336050936, 35.468109749302144), + Offset(33.31247191452901, 36.111129874669345), + Offset(32.650489347914984, 36.5924832357089), + Offset(32.10727557736848, 36.948887315619224), + Offset(31.67778832597493, 37.20807201757785), + Offset(31.35558447378734, 37.390182114108036), + Offset(31.133458457389974, 37.50982495951341), + Offset(31.004035384848397, 37.57737413227578), + Offset(30.960099391805116, 37.59994913432378), + Offset(30.96, 37.6), + ], + <Offset>[ + Offset(14.399999999999999, 6.0), + Offset(14.404037134399934, 5.998124589407087), + Offset(17.629464821750716, 4.791554862152392), + Offset(29.212512201472613, 5.130279351141528), + Offset(37.75681050139441, 12.03136429805604), + Offset(40.21369795872548, 18.81847477877102), + Offset(40.25340600130274, 23.824517041752205), + Offset(39.411262672330956, 27.43134771763129), + Offset(38.2756414335993, 30.060130349210176), + Offset(37.09749443477014, 32.009805001035396), + Offset(35.98797754640261, 33.48278755371506), + Offset(34.989368329450485, 34.61298184864511), + Offset(34.09376336050936, 35.468109749302144), + Offset(33.31247191452901, 36.111129874669345), + Offset(32.650489347914984, 36.5924832357089), + Offset(32.10727557736848, 36.948887315619224), + Offset(31.67778832597493, 37.20807201757785), + Offset(31.35558447378734, 37.390182114108036), + Offset(31.133458457389974, 37.50982495951341), + Offset(31.004035384848397, 37.57737413227578), + Offset(30.960099391805116, 37.59994913432378), + Offset(30.96, 37.6), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(14.399999999999999, 6.0), + Offset(14.404037134399934, 5.998124589407087), + Offset(17.629464821750716, 4.791554862152392), + Offset(29.212512201472613, 5.130279351141528), + Offset(37.75681050139441, 12.03136429805604), + Offset(40.21369795872548, 18.81847477877102), + Offset(40.25340600130274, 23.824517041752205), + Offset(39.411262672330956, 27.43134771763129), + Offset(38.2756414335993, 30.060130349210176), + Offset(37.09749443477014, 32.009805001035396), + Offset(35.98797754640261, 33.48278755371506), + Offset(34.989368329450485, 34.61298184864511), + Offset(34.09376336050936, 35.468109749302144), + Offset(33.31247191452901, 36.111129874669345), + Offset(32.650489347914984, 36.5924832357089), + Offset(32.10727557736848, 36.948887315619224), + Offset(31.67778832597493, 37.20807201757785), + Offset(31.35558447378734, 37.390182114108036), + Offset(31.133458457389974, 37.50982495951341), + Offset(31.004035384848397, 37.57737413227578), + Offset(30.960099391805116, 37.59994913432378), + Offset(30.96, 37.6), + ], + <Offset>[ + Offset(14.399999999999999, 9.2), + Offset(14.40334186121773, 9.198082517058094), + Offset(17.09495321544893, 7.918256683444506), + Offset(27.11128303201872, 7.3528218504231635), + Offset(35.004511022928, 12.681725933282765), + Offset(37.63202264537456, 18.37368845254959), + Offset(38.03110120856841, 22.706055679985123), + Offset(37.56078900409667, 25.890726510752607), + Offset(36.7639182652327, 28.248209674032793), + Offset(35.88093897771729, 30.019216975466403), + Offset(35.023449382648224, 31.371348600799287), + Offset(34.238016971857476, 32.41801689734335), + Offset(33.52264234074403, 33.21950556430599), + Offset(32.891453902934586, 33.82965144305683), + Offset(32.35226133833075, 34.2917311729478), + Offset(31.90713776445562, 34.63753601046407), + Offset(31.55364094746969, 34.89139607381483), + Offset(31.287592426243478, 35.07117864678187), + Offset(31.10377127589116, 35.19001490882938), + Offset(30.996517232127708, 35.25738631390697), + Offset(30.96008243670077, 35.27994913438573), + Offset(30.96, 35.28), + ], + <Offset>[ + Offset(14.399999999999999, 9.2), + Offset(14.40334186121773, 9.198082517058094), + Offset(17.09495321544893, 7.918256683444506), + Offset(27.11128303201872, 7.3528218504231635), + Offset(35.004511022928, 12.681725933282765), + Offset(37.63202264537456, 18.37368845254959), + Offset(38.03110120856841, 22.706055679985123), + Offset(37.56078900409667, 25.890726510752607), + Offset(36.7639182652327, 28.248209674032793), + Offset(35.88093897771729, 30.019216975466403), + Offset(35.023449382648224, 31.371348600799287), + Offset(34.238016971857476, 32.41801689734335), + Offset(33.52264234074403, 33.21950556430599), + Offset(32.891453902934586, 33.82965144305683), + Offset(32.35226133833075, 34.2917311729478), + Offset(31.90713776445562, 34.63753601046407), + Offset(31.55364094746969, 34.89139607381483), + Offset(31.287592426243478, 35.07117864678187), + Offset(31.10377127589116, 35.19001490882938), + Offset(30.996517232127708, 35.25738631390697), + Offset(30.96008243670077, 35.27994913438573), + Offset(30.96, 35.28), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(14.399999999999999, 9.2), + Offset(14.40334186121773, 9.198082517058094), + Offset(17.09495321544893, 7.918256683444506), + Offset(27.11128303201872, 7.3528218504231635), + Offset(35.004511022928, 12.681725933282765), + Offset(37.63202264537456, 18.37368845254959), + Offset(38.03110120856841, 22.706055679985123), + Offset(37.56078900409667, 25.890726510752607), + Offset(36.7639182652327, 28.248209674032793), + Offset(35.88093897771729, 30.019216975466403), + Offset(35.023449382648224, 31.371348600799287), + Offset(34.238016971857476, 32.41801689734335), + Offset(33.52264234074403, 33.21950556430599), + Offset(32.891453902934586, 33.82965144305683), + Offset(32.35226133833075, 34.2917311729478), + Offset(31.90713776445562, 34.63753601046407), + Offset(31.55364094746969, 34.89139607381483), + Offset(31.287592426243478, 35.07117864678187), + Offset(31.10377127589116, 35.19001490882938), + Offset(30.996517232127708, 35.25738631390697), + Offset(30.96008243670077, 35.27994913438573), + Offset(30.96, 35.28), + ], + <Offset>[ + Offset(12.799999999999999, 9.2), + Offset(12.803362897392224, 9.197734880466992), + Offset(15.531602304802872, 7.651000880293612), + Offset(26.0000117823779, 6.302207265696218), + Offset(34.67933020531464, 11.305576194049557), + Offset(37.85441580848527, 17.08285079587413), + Offset(38.590331889451946, 21.594903283617956), + Offset(38.33109960753601, 24.965489676635464), + Offset(37.669878602821385, 27.49234808984949), + Offset(36.87623299050179, 29.41093924693998), + Offset(36.07916885910612, 30.889084518922097), + Offset(35.33549944750836, 32.042341218546845), + Offset(34.646944433242105, 32.93394505442333), + Offset(34.032193118740835, 33.61914243725962), + Offset(33.502637369711294, 34.14261716815569), + Offset(33.0628134170332, 34.53746710400763), + Offset(32.7119789193512, 34.82932238456221), + Offset(32.44709415990656, 35.03718262300993), + Offset(32.263676301233176, 35.175171318079975), + Offset(32.15651114131211, 35.253627237546624), + Offset(32.12008243666979, 35.27994065683356), + Offset(32.120000000000005, 35.28), + ], + <Offset>[ + Offset(12.799999999999999, 9.2), + Offset(12.803362897392224, 9.197734880466992), + Offset(15.531602304802872, 7.651000880293612), + Offset(26.0000117823779, 6.302207265696218), + Offset(34.67933020531464, 11.305576194049557), + Offset(37.85441580848527, 17.08285079587413), + Offset(38.590331889451946, 21.594903283617956), + Offset(38.33109960753601, 24.965489676635464), + Offset(37.669878602821385, 27.49234808984949), + Offset(36.87623299050179, 29.41093924693998), + Offset(36.07916885910612, 30.889084518922097), + Offset(35.33549944750836, 32.042341218546845), + Offset(34.646944433242105, 32.93394505442333), + Offset(34.032193118740835, 33.61914243725962), + Offset(33.502637369711294, 34.14261716815569), + Offset(33.0628134170332, 34.53746710400763), + Offset(32.7119789193512, 34.82932238456221), + Offset(32.44709415990656, 35.03718262300993), + Offset(32.263676301233176, 35.175171318079975), + Offset(32.15651114131211, 35.253627237546624), + Offset(32.12008243666979, 35.27994065683356), + Offset(32.120000000000005, 35.28), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(11.031994628903998, 9.2), + Offset(11.035380771339659, 9.197350740866831), + Offset(13.80409430047159, 7.355682320651514), + Offset(24.772053321059442, 5.141274622728069), + Offset(34.320004310241, 9.784926112551638), + Offset(38.10016100028201, 15.656470851989635), + Offset(39.20828366912931, 20.36707615556587), + Offset(39.18229541021911, 23.94309986897611), + Offset(38.670967817106856, 26.657118501948734), + Offset(37.976036215765966, 28.738790314969485), + Offset(37.24574242457374, 30.35618108951862), + Offset(36.54822126727992, 31.627218332357877), + Offset(35.88930201966152, 32.61839973239491), + Offset(35.29271378159415, 33.38652927918864), + Offset(34.773806746124365, 33.97784569229438), + Offset(34.33983889265947, 34.42689062644846), + Offset(33.99194626674555, 34.76073074956073), + Offset(33.72834746797622, 34.99961690261951), + Offset(33.545375247961886, 35.15876910047291), + Offset(33.438308304985036, 35.24947344554947), + Offset(33.40188633068016, 35.27993128910995), + Offset(33.401803894044605, 35.28), + ], + <Offset>[ + Offset(9.616003417967999, 10.631994628904), + Offset(9.61909704372049, 10.629018885991774), + Offset(12.181344285634644, 8.518356219904028), + Offset(22.84828784303791, 5.2060705244071706), + Offset(32.80057167595657, 8.858076892900925), + Offset(37.14168235852622, 14.315045482191389), + Offset(38.70872208508259, 18.883202806434653), + Offset(39.03593220224475, 22.434844949065443), + Offset(38.79624415881739, 25.177353691127), + Offset(38.31245942468365, 27.309683066288713), + Offset(37.74842362764679, 28.984514638765333), + Offset(37.18325875817459, 30.31250428874126), + Offset(36.6287274978085, 31.35943365120302), + Offset(36.11385686779049, 32.17927219665962), + Offset(35.65842668096787, 32.816297930813164), + Offset(35.27304416151989, 33.30400436440065), + Offset(34.96151326541533, 33.66908727918451), + Offset(34.72407380578028, 33.931780449070615), + Offset(34.55859985994813, 34.10752150024265), + Offset(34.46153218185176, 34.2079560289239), + Offset(34.42847237120061, 34.241727680595176), + Offset(34.4283975219732, 34.241803894044594), + ], + <Offset>[ + Offset(9.616003417967999, 12.399999999999999), + Offset(9.61871290412033, 12.397001012044338), + Offset(11.886025725992546, 10.245864224235309), + Offset(21.68735520006976, 6.434028985725625), + Offset(31.279921594458653, 9.217402787974557), + Offset(35.71530241464173, 14.069300290394656), + Offset(37.4808949570305, 18.265251026757294), + Offset(38.01354239458539, 21.58364914638235), + Offset(37.96101457091663, 24.176264476841528), + Offset(37.64031049271316, 26.209879841024538), + Offset(37.21552019824331, 27.817941073297703), + Offset(36.76813587198562, 29.0997824689697), + Offset(36.313182175780085, 30.117076064783603), + Offset(35.88124370971952, 30.918751533806304), + Offset(35.49365520510656, 31.545128554400094), + Offset(35.16246768396071, 32.02697888877437), + Offset(34.892921630413845, 32.38911993179016), + Offset(34.68650808538985, 32.65052714100095), + Offset(34.542197642341065, 32.825822553513945), + Offset(34.4573783898546, 32.92615886525098), + Offset(34.428463003477, 32.9599237865848), + Offset(34.428397521973196, 32.96), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.616003417967999, 12.399999999999999), + Offset(9.61871290412033, 12.397001012044338), + Offset(11.886025725992546, 10.245864224235309), + Offset(21.68735520006976, 6.434028985725625), + Offset(31.279921594458653, 9.217402787974557), + Offset(35.71530241464173, 14.069300290394656), + Offset(37.4808949570305, 18.265251026757294), + Offset(38.01354239458539, 21.58364914638235), + Offset(37.96101457091663, 24.176264476841528), + Offset(37.64031049271316, 26.209879841024538), + Offset(37.21552019824331, 27.817941073297703), + Offset(36.76813587198562, 29.0997824689697), + Offset(36.313182175780085, 30.117076064783603), + Offset(35.88124370971952, 30.918751533806304), + Offset(35.49365520510656, 31.545128554400094), + Offset(35.16246768396071, 32.02697888877437), + Offset(34.892921630413845, 32.38911993179016), + Offset(34.68650808538985, 32.65052714100095), + Offset(34.542197642341065, 32.825822553513945), + Offset(34.4573783898546, 32.92615886525098), + Offset(34.428463003477, 32.9599237865848), + Offset(34.428397521973196, 32.96), + ], + <Offset>[ + Offset(9.59999999999928, 34.8), + Offset(9.59784278428286, 34.79670302849285), + Offset(8.12880763309575, 32.13010384432861), + Offset(6.967635927464624, 21.981318090494952), + Offset(12.01057274235646, 13.75616979739632), + Offset(17.64579962789941, 10.942884872751186), + Offset(21.93035490934461, 10.42490759674647), + Offset(25.067931468540735, 10.790046353371297), + Offset(27.388013931066364, 11.485259520063472), + Offset(29.134377359648454, 12.26967958533492), + Offset(30.47438250198729, 13.033044731841269), + Offset(31.519653538066578, 13.731270250539573), + Offset(32.32658046011612, 14.373990554688655), + Offset(32.945527457598416, 14.946296972765026), + Offset(33.41756535579906, 15.438372656482638), + Offset(33.77306221888595, 15.846518849853458), + Offset(34.035475835072845, 16.171767455952793), + Offset(34.22216124688238, 16.41716283685671), + Offset(34.34598889992722, 16.58700373110889), + Offset(34.416353737915934, 16.68620653787546), + Offset(34.43994679577361, 16.719923702224868), + Offset(34.440000000000516, 16.72), + ], + <Offset>[ + Offset(9.59999999999928, 34.8), + Offset(9.59784278428286, 34.79670302849285), + Offset(8.12880763309575, 32.13010384432861), + Offset(6.967635927464624, 21.981318090494952), + Offset(12.01057274235646, 13.75616979739632), + Offset(17.64579962789941, 10.942884872751186), + Offset(21.93035490934461, 10.42490759674647), + Offset(25.067931468540735, 10.790046353371297), + Offset(27.388013931066364, 11.485259520063472), + Offset(29.134377359648454, 12.26967958533492), + Offset(30.47438250198729, 13.033044731841269), + Offset(31.519653538066578, 13.731270250539573), + Offset(32.32658046011612, 14.373990554688655), + Offset(32.945527457598416, 14.946296972765026), + Offset(33.41756535579906, 15.438372656482638), + Offset(33.77306221888595, 15.846518849853458), + Offset(34.035475835072845, 16.171767455952793), + Offset(34.22216124688238, 16.41716283685671), + Offset(34.34598889992722, 16.58700373110889), + Offset(34.416353737915934, 16.68620653787546), + Offset(34.43994679577361, 16.719923702224868), + Offset(34.440000000000516, 16.72), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.59999999999928, 36.568005371096), + Offset(9.5974586446827, 36.564685154545415), + Offset(7.833489073453652, 33.857611848659886), + Offset(5.8067032844964785, 23.20927655181341), + Offset(10.489922660858541, 14.115495692469953), + Offset(16.219419684014916, 10.697139680954454), + Offset(20.70252778129252, 9.806955817069111), + Offset(24.045541660881376, 9.938850550688198), + Offset(26.55278434316561, 10.484170305778001), + Offset(28.46222842767796, 11.169876360070745), + Offset(29.941479072583817, 11.866471166373643), + Offset(31.10453065187761, 12.518548430768014), + Offset(32.011035138087706, 13.131632968269235), + Offset(32.71291429952744, 13.685776309911715), + Offset(33.252793879937755, 14.16720328006957), + Offset(33.662485741326776, 14.569493374227186), + Offset(33.96688420007136, 14.891800108558444), + Offset(34.184595526491954, 15.135909528787048), + Offset(34.32958668232016, 15.305304784380185), + Offset(34.41219994591878, 15.404409374202539), + Offset(34.43993742805, 15.438119808214498), + Offset(34.440000000000516, 15.438196105955399), + ], + <Offset>[ + Offset(11.031994628903279, 38.0), + Offset(11.029123312699099, 37.9969720897259), + Offset(8.99348984375479, 35.49599871228041), + Offset(5.860990795973912, 25.144157116262335), + Offset(9.549309004043138, 15.638180829591539), + Offset(14.86508318012379, 11.653393915996185), + Offset(19.207540534520554, 10.300923899661631), + Offset(22.52803239611086, 10.077509007067547), + Offset(25.06545930180785, 10.349832425351934), + Offset(27.02703710229079, 10.82349808484828), + Offset(28.564988950784752, 11.353230513276403), + Offset(29.786059048943372, 11.872533770641846), + Offset(30.74921284177406, 12.380962067429417), + Offset(31.503551677244786, 12.853223394675975), + Offset(32.089754659866806, 13.271077127444466), + Offset(32.538598576444194, 13.624728880051993), + Offset(32.87461986019893, 13.910647255693526), + Offset(33.11641904008201, 14.128585696683976), + Offset(33.27819061447309, 14.280478644316606), + Offset(33.37064493049935, 14.369583080230184), + Offset(33.40173373474159, 14.399931289667558), + Offset(33.401803894045116, 14.399999999999999), + ], + <Offset>[ + Offset(12.79999999999928, 38.0), + Offset(12.797105438751665, 37.997356229326066), + Offset(10.720997848086075, 35.79131727192251), + Offset(7.08894925729237, 26.305089759230484), + Offset(9.90863489911677, 17.15883091108946), + Offset(14.619337988327057, 13.07977385988068), + Offset(18.589588754843195, 11.528751027713723), + Offset(21.676836593427762, 11.099898814726902), + Offset(24.064370087522377, 11.18506201325269), + Offset(25.927233877026616, 11.495647016818776), + Offset(27.398415385317126, 11.886133942679876), + Offset(28.573337229171813, 12.287656656830816), + Offset(29.50685525535464, 12.696507389457834), + Offset(30.243031014391473, 13.085836552746947), + Offset(30.818585283453736, 13.435848603305775), + Offset(31.26157310081792, 13.735305357611166), + Offset(31.594652512804586, 13.97923889069501), + Offset(31.83516573201235, 14.166151417074401), + Offset(31.99649166774438, 14.29688086192367), + Offset(32.08884776682643, 14.37373687222734), + Offset(32.119929840731224, 14.399940657391166), + Offset(32.120000000000516, 14.399999999999999), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.79999999999928, 38.0), + Offset(12.797105438751665, 37.997356229326066), + Offset(10.720997848086075, 35.79131727192251), + Offset(7.08894925729237, 26.305089759230484), + Offset(9.90863489911677, 17.15883091108946), + Offset(14.619337988327057, 13.07977385988068), + Offset(18.589588754843195, 11.528751027713723), + Offset(21.676836593427762, 11.099898814726902), + Offset(24.064370087522377, 11.18506201325269), + Offset(25.927233877026616, 11.495647016818776), + Offset(27.398415385317126, 11.886133942679876), + Offset(28.573337229171813, 12.287656656830816), + Offset(29.50685525535464, 12.696507389457834), + Offset(30.243031014391473, 13.085836552746947), + Offset(30.818585283453736, 13.435848603305775), + Offset(31.26157310081792, 13.735305357611166), + Offset(31.594652512804586, 13.97923889069501), + Offset(31.83516573201235, 14.166151417074401), + Offset(31.99649166774438, 14.29688086192367), + Offset(32.08884776682643, 14.37373687222734), + Offset(32.119929840731224, 14.399940657391166), + Offset(32.120000000000516, 14.399999999999999), + ], + <Offset>[ + Offset(35.19999999999928, 38.0), + Offset(35.19681093230872, 38.0022231416015), + Offset(32.60791059713087, 39.532898516035026), + Offset(22.646746752263834, 41.013693945407724), + Offset(14.461166345703841, 36.42492726035435), + Offset(11.505833704777048, 31.15150105333715), + Offset(10.760359222473623, 27.08488457685406), + Offset(10.892488145276982, 24.053214492366923), + Offset(11.380925361280685, 21.767124191818905), + Offset(11.99311769804367, 20.0115352161887), + Offset(12.618342714906678, 18.63783108896057), + Offset(13.208582570059477, 17.547116159981847), + Offset(13.766625960381576, 16.694354527815143), + Offset(14.272681993103916, 16.032962633907964), + Offset(14.713320844126082, 15.523444670395389), + Offset(15.082113964731814, 15.13627004800123), + Offset(15.377920906463453, 14.84827054023167), + Offset(15.602141460729166, 14.642095749881417), + Offset(15.75782131295615, 14.504691132415362), + Offset(15.84893303824477, 14.426363941272161), + Offset(15.879929841164916, 14.400059343121573), + Offset(15.88000000000052, 14.400000000000002), + ], + <Offset>[ + Offset(35.19999999999928, 38.0), + Offset(35.19681093230872, 38.0022231416015), + Offset(32.60791059713087, 39.532898516035026), + Offset(22.646746752263834, 41.013693945407724), + Offset(14.461166345703841, 36.42492726035435), + Offset(11.505833704777048, 31.15150105333715), + Offset(10.760359222473623, 27.08488457685406), + Offset(10.892488145276982, 24.053214492366923), + Offset(11.380925361280685, 21.767124191818905), + Offset(11.99311769804367, 20.0115352161887), + Offset(12.618342714906678, 18.63783108896057), + Offset(13.208582570059477, 17.547116159981847), + Offset(13.766625960381576, 16.694354527815143), + Offset(14.272681993103916, 16.032962633907964), + Offset(14.713320844126082, 15.523444670395389), + Offset(15.082113964731814, 15.13627004800123), + Offset(15.377920906463453, 14.84827054023167), + Offset(15.602141460729166, 14.642095749881417), + Offset(15.75782131295615, 14.504691132415362), + Offset(15.84893303824477, 14.426363941272161), + Offset(15.879929841164916, 14.400059343121573), + Offset(15.88000000000052, 14.400000000000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(36.96800537109528, 38.0), + Offset(36.96479305836129, 38.00260728120166), + Offset(34.33541860146215, 39.828217075677124), + Offset(23.874705213582292, 42.17462658837587), + Offset(14.820492240777476, 37.94557734185227), + Offset(11.260088512980314, 32.57788099722164), + Offset(10.142407442796266, 28.31271170490615), + Offset(10.041292342593884, 25.075604300026278), + Offset(10.379836146995213, 22.60235377971966), + Offset(10.893314472779496, 20.683684148159198), + Offset(11.45176914943905, 19.170734518364043), + Offset(11.99586075028792, 17.962239046170815), + Offset(12.524268373962157, 17.009899849843556), + Offset(13.012161330250605, 16.26557579197894), + Offset(13.442151467713014, 15.688216146256698), + Offset(13.805088489105541, 15.246846525560404), + Offset(14.097953559069104, 14.916862175233152), + Offset(14.320888152659505, 14.679661470271842), + Offset(14.476122366227445, 14.521093350022426), + Offset(14.567135874571848, 14.430517733269317), + Offset(14.598125947154546, 14.400068710845181), + Offset(14.59819610595592, 14.400000000000002), + ], + <Offset>[ + Offset(38.39999999999928, 36.568005371096), + Offset(38.39707999354177, 36.57094261318526), + Offset(35.97380546508267, 38.668216305375985), + Offset(25.809585778031217, 42.120339076898446), + Offset(16.34317737789906, 38.88619099866767), + Offset(12.216342748022047, 33.93221750111277), + Offset(10.636375525388786, 29.80769895167812), + Offset(10.17995079897323, 26.593113564796795), + Offset(10.245498266569147, 24.08967882107742), + Offset(10.54693619755703, 22.118875473546364), + Offset(10.938528496341814, 20.547224640163105), + Offset(11.34984609016175, 19.280710649105057), + Offset(11.773597473122338, 18.271722146157202), + Offset(12.179608415014865, 17.474938414261594), + Offset(12.546025315087908, 16.851255366327646), + Offset(12.860323994930349, 16.370733690442982), + Offset(13.116800706204186, 16.009126515105578), + Offset(13.313564320556432, 15.747837956681783), + Offset(13.451296226163867, 15.572489417869503), + Offset(13.532309580599492, 15.472072748688737), + Offset(13.559937428607608, 15.43827240415359), + Offset(13.560000000000521, 15.438196105955402), + ], + <Offset>[ + Offset(38.39999999999928, 34.8), + Offset(38.39746413314193, 34.802960487132694), + Offset(36.26912402472477, 36.9407083010447), + Offset(26.970518420999362, 40.89238061557998), + Offset(17.86382745939698, 38.52686510359404), + Offset(13.642722691906542, 34.1779626929095), + Offset(11.864202653440877, 30.425650731355475), + Offset(11.202340606632587, 27.444309367479892), + Offset(11.080727854469902, 25.090768035362892), + Offset(11.219085129527524, 23.218678698810542), + Offset(11.471431925745287, 21.713798205630734), + Offset(11.764968976350719, 20.493432468876616), + Offset(12.089142795150753, 19.514079732576622), + Offset(12.412221573085839, 18.735459077114903), + Offset(12.710796790949217, 18.122424742740712), + Offset(12.970900472489523, 17.647759166069257), + Offset(13.18539234120567, 17.289093862499925), + Offset(13.351130040946858, 17.029091264751443), + Offset(13.46769844377093, 16.85418836459821), + Offset(13.536463372596648, 16.753869912361658), + Offset(13.559946796331216, 16.72007629816396), + Offset(13.560000000000521, 16.720000000000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.39999999999928, 34.8), + Offset(38.39746413314193, 34.802960487132694), + Offset(36.26912402472477, 36.9407083010447), + Offset(26.970518420999362, 40.89238061557998), + Offset(17.86382745939698, 38.52686510359404), + Offset(13.642722691906542, 34.1779626929095), + Offset(11.864202653440877, 30.425650731355475), + Offset(11.202340606632587, 27.444309367479892), + Offset(11.080727854469902, 25.090768035362892), + Offset(11.219085129527524, 23.218678698810542), + Offset(11.471431925745287, 21.713798205630734), + Offset(11.764968976350719, 20.493432468876616), + Offset(12.089142795150753, 19.514079732576622), + Offset(12.412221573085839, 18.735459077114903), + Offset(12.710796790949217, 18.122424742740712), + Offset(12.970900472489523, 17.647759166069257), + Offset(13.18539234120567, 17.289093862499925), + Offset(13.351130040946858, 17.029091264751443), + Offset(13.46769844377093, 16.85418836459821), + Offset(13.536463372596648, 16.753869912361658), + Offset(13.559946796331216, 16.72007629816396), + Offset(13.560000000000521, 16.720000000000002), + ], + <Offset>[ + Offset(38.39999999999928, 12.399999999999999), + Offset(38.40233104541737, 12.403254993575636), + Offset(40.010705268837285, 15.05379555199991), + Offset(41.67912260717661, 25.33458312060852), + Offset(37.12992380866187, 33.974333657006966), + Offset(31.71444988536301, 37.29146697645951), + Offset(27.420336202581215, 38.25488026372505), + Offset(24.155656284272606, 38.22865781563067), + Offset(21.662790033036117, 37.77421276160458), + Offset(19.734973328897453, 37.15279487779348), + Offset(18.223129072025984, 36.49387087604118), + Offset(17.02442847950175, 35.858187127988955), + Offset(16.086989933508065, 35.25430902754969), + Offset(15.359347654246855, 34.705808098402464), + Offset(14.798392858038833, 34.227689182068374), + Offset(14.371865162879587, 33.82721830215536), + Offset(14.05442399074233, 33.50582546884106), + Offset(13.827074373753874, 33.26211553603463), + Offset(13.675508714262623, 33.09285871938644), + Offset(13.589090441641469, 32.993784640943325), + Offset(13.56006548206162, 32.96007629773027), + Offset(13.560000000000523, 32.96), + ], + <Offset>[ + Offset(38.39999999999928, 12.399999999999999), + Offset(38.40233104541737, 12.403254993575636), + Offset(40.010705268837285, 15.05379555199991), + Offset(41.67912260717661, 25.33458312060852), + Offset(37.12992380866187, 33.974333657006966), + Offset(31.71444988536301, 37.29146697645951), + Offset(27.420336202581215, 38.25488026372505), + Offset(24.155656284272606, 38.22865781563067), + Offset(21.662790033036117, 37.77421276160458), + Offset(19.734973328897453, 37.15279487779348), + Offset(18.223129072025984, 36.49387087604118), + Offset(17.02442847950175, 35.858187127988955), + Offset(16.086989933508065, 35.25430902754969), + Offset(15.359347654246855, 34.705808098402464), + Offset(14.798392858038833, 34.227689182068374), + Offset(14.371865162879587, 33.82721830215536), + Offset(14.05442399074233, 33.50582546884106), + Offset(13.827074373753874, 33.26211553603463), + Offset(13.675508714262623, 33.09285871938644), + Offset(13.589090441641469, 32.993784640943325), + Offset(13.56006548206162, 32.96007629773027), + Offset(13.560000000000523, 32.96), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.39999999999928, 10.631994628904), + Offset(38.40271518501753, 10.635272867523073), + Offset(40.30602382847938, 13.32628754766863), + Offset(42.84005525014476, 24.106624659290063), + Offset(38.65057389015979, 33.61500776193334), + Offset(33.14082982924751, 37.53721216825625), + Offset(28.648163330633302, 38.872832043402404), + Offset(25.17804609193196, 39.07985361831377), + Offset(22.498019620936873, 38.77530197589005), + Offset(20.407122260867947, 38.252598103057665), + Offset(18.756032501429452, 37.66044444150881), + Offset(17.439551365690722, 37.07090894776051), + Offset(16.402535255536478, 36.496666613969104), + Offset(15.591960812317827, 35.966328761255774), + Offset(14.96316433390014, 35.49885855848144), + Offset(14.48244164043876, 35.10424377778163), + Offset(14.123015625743813, 34.78579281623541), + Offset(13.864640094144299, 34.543368844104286), + Offset(13.691910931869685, 34.374557666115145), + Offset(13.593244233638625, 34.275581804616245), + Offset(13.560074849785229, 34.241880191740634), + Offset(13.560000000000525, 34.2418038940446), + ], + <Offset>[ + Offset(36.96800537109528, 9.2), + Offset(36.97105051700113, 9.202985932342585), + Offset(39.146023058178244, 11.687900684048108), + Offset(42.78576773866732, 22.171744094841138), + Offset(39.5911875469752, 32.09232262481175), + Offset(34.49516633313863, 36.58095793321451), + Offset(30.143150577405272, 38.37886396080988), + Offset(26.695555356702478, 38.941195161934424), + Offset(23.985344662294633, 38.90963985631612), + Offset(21.842313586255116, 38.59897637828013), + Offset(20.132522623228514, 38.17368509460604), + Offset(18.75802296862496, 37.71692360788668), + Offset(17.664357551850124, 37.247337514808926), + Offset(16.801323434600484, 36.79888167649151), + Offset(16.126203553971088, 36.394984711106545), + Offset(15.606328805321338, 36.04900827195683), + Offset(15.215279965616238, 35.76694566910032), + Offset(14.93281658055424, 35.55069267620736), + Offset(14.743306999716763, 35.39938380617872), + Offset(14.634799249058046, 35.3104080985886), + Offset(14.598278543093638, 35.28006871028757), + Offset(14.598196105955923, 35.28), + ], + <Offset>[ + Offset(35.19999999999928, 9.2), + Offset(35.20306839094856, 9.202601792742424), + Offset(37.41851505384696, 11.39258212440601), + Offset(41.557809277348866, 21.01081145187299), + Offset(39.23186165190157, 30.57167254331383), + Offset(34.74091152493536, 35.15457798933002), + Offset(30.761102357082628, 37.151036832757796), + Offset(27.54675115938558, 37.918805354275065), + Offset(24.986433876580104, 38.07441026841536), + Offset(22.94211681151929, 37.92682744630963), + Offset(21.299096188696144, 37.640781665202574), + Offset(19.970744788396516, 37.30180072169771), + Offset(18.906715138269544, 36.931792192780506), + Offset(18.061844097453793, 36.56626851842054), + Offset(17.397372930384158, 36.23021323524523), + Offset(16.88335428094761, 35.93843179439765), + Offset(16.495247313010587, 35.69835403409884), + Offset(16.214069888623904, 35.51312695581694), + Offset(16.025005946445468, 35.38298158857166), + Offset(15.916596412730968, 35.30625430659144), + Offset(15.880082437104008, 35.280059342563966), + Offset(15.880000000000523, 35.28), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.19999999999928, 9.2), + Offset(35.20306839094856, 9.202601792742424), + Offset(37.41851505384696, 11.39258212440601), + Offset(41.557809277348866, 21.01081145187299), + Offset(39.23186165190157, 30.57167254331383), + Offset(34.74091152493536, 35.15457798933002), + Offset(30.761102357082628, 37.151036832757796), + Offset(27.54675115938558, 37.918805354275065), + Offset(24.986433876580104, 38.07441026841536), + Offset(22.94211681151929, 37.92682744630963), + Offset(21.299096188696144, 37.640781665202574), + Offset(19.970744788396516, 37.30180072169771), + Offset(18.906715138269544, 36.931792192780506), + Offset(18.061844097453793, 36.56626851842054), + Offset(17.397372930384158, 36.23021323524523), + Offset(16.88335428094761, 35.93843179439765), + Offset(16.495247313010587, 35.69835403409884), + Offset(16.214069888623904, 35.51312695581694), + Offset(16.025005946445468, 35.38298158857166), + Offset(15.916596412730968, 35.30625430659144), + Offset(15.880082437104008, 35.280059342563966), + Offset(15.880000000000523, 35.28), + ], + <Offset>[ + Offset(33.599999999999284, 9.2), + Offset(33.60308942712306, 9.202254156151323), + Offset(35.85516414320091, 11.125326321255116), + Offset(40.446538027708044, 19.960196867146045), + Offset(38.9066808342882, 29.195522804080625), + Offset(34.96330468804608, 33.86374033265456), + Offset(31.320333037966172, 36.03988443639063), + Offset(28.31706176282492, 36.99356852015792), + Offset(25.892394214168796, 37.31854868423206), + Offset(23.93741082430379, 37.31854971778321), + Offset(22.35481566515403, 37.15851758332538), + Offset(21.068227264047398, 36.926125042901205), + Offset(20.03101723076762, 36.64623168289785), + Offset(19.20258331326005, 36.35575951262332), + Offset(18.547748961764704, 36.08109923045312), + Offset(18.039029933525192, 35.83836288794122), + Offset(17.653585284892095, 35.63628034484623), + Offset(17.37357162228699, 35.47913093204501), + Offset(17.184910971787485, 35.36813799782225), + Offset(17.076590321915372, 35.3024952302311), + Offset(17.040082437073032, 35.280050865011795), + Offset(17.04000000000052, 35.28), + ], + <Offset>[ + Offset(33.599999999999284, 9.2), + Offset(33.60308942712306, 9.202254156151323), + Offset(35.85516414320091, 11.125326321255116), + Offset(40.446538027708044, 19.960196867146045), + Offset(38.9066808342882, 29.195522804080625), + Offset(34.96330468804608, 33.86374033265456), + Offset(31.320333037966172, 36.03988443639063), + Offset(28.31706176282492, 36.99356852015792), + Offset(25.892394214168796, 37.31854868423206), + Offset(23.93741082430379, 37.31854971778321), + Offset(22.35481566515403, 37.15851758332538), + Offset(21.068227264047398, 36.926125042901205), + Offset(20.03101723076762, 36.64623168289785), + Offset(19.20258331326005, 36.35575951262332), + Offset(18.547748961764704, 36.08109923045312), + Offset(18.039029933525192, 35.83836288794122), + Offset(17.653585284892095, 35.63628034484623), + Offset(17.37357162228699, 35.47913093204501), + Offset(17.184910971787485, 35.36813799782225), + Offset(17.076590321915372, 35.3024952302311), + Offset(17.040082437073032, 35.280050865011795), + Offset(17.04000000000052, 35.28), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(33.599999999999284, 9.2), + Offset(33.60308942712306, 9.202254156151323), + Offset(35.85516414320091, 11.125326321255116), + Offset(40.446538027708044, 19.960196867146045), + Offset(38.9066808342882, 29.195522804080625), + Offset(34.96330468804608, 33.86374033265456), + Offset(31.320333037966172, 36.03988443639063), + Offset(28.31706176282492, 36.99356852015792), + Offset(25.892394214168796, 37.31854868423206), + Offset(23.93741082430379, 37.31854971778321), + Offset(22.35481566515403, 37.15851758332538), + Offset(21.068227264047398, 36.926125042901205), + Offset(20.03101723076762, 36.64623168289785), + Offset(19.20258331326005, 36.35575951262332), + Offset(18.547748961764704, 36.08109923045312), + Offset(18.039029933525192, 35.83836288794122), + Offset(17.653585284892095, 35.63628034484623), + Offset(17.37357162228699, 35.47913093204501), + Offset(17.184910971787485, 35.36813799782225), + Offset(17.076590321915372, 35.3024952302311), + Offset(17.040082437073032, 35.280050865011795), + Offset(17.04000000000052, 35.28), + ], + <Offset>[ + Offset(33.599999999999284, 6.0), + Offset(33.60378470030526, 6.002296228500315), + Offset(36.38967574950269, 7.998624499963004), + Offset(42.54776719716194, 17.73765436786441), + Offset(41.658980312754615, 28.5451611688539), + Offset(37.54498000139701, 34.308526658875984), + Offset(33.54263783070051, 37.15834579815771), + Offset(30.167535431059207, 38.534189727036605), + Offset(27.404117382535397, 39.130469359409446), + Offset(25.153966281356634, 39.3091377433522), + Offset(23.319343828908416, 39.26995653624116), + Offset(21.819578621640403, 39.12108999420297), + Offset(20.60213825053295, 38.894835867893995), + Offset(19.62360132485448, 38.637237944235835), + Offset(18.845976971348936, 38.38185129321421), + Offset(18.239167746438056, 38.14971419309638), + Offset(17.777732663397334, 37.95295628860924), + Offset(17.441563669830845, 37.79813439937118), + Offset(17.214598153286296, 37.687948048506286), + Offset(17.08410847463606, 37.62248304859991), + Offset(17.040099392177375, 37.600050864949836), + Offset(17.040000000000525, 37.6), + ], + <Offset>[ + Offset(33.599999999999284, 6.0), + Offset(33.60378470030526, 6.002296228500315), + Offset(36.38967574950269, 7.998624499963004), + Offset(42.54776719716194, 17.73765436786441), + Offset(41.658980312754615, 28.5451611688539), + Offset(37.54498000139701, 34.308526658875984), + Offset(33.54263783070051, 37.15834579815771), + Offset(30.167535431059207, 38.534189727036605), + Offset(27.404117382535397, 39.130469359409446), + Offset(25.153966281356634, 39.3091377433522), + Offset(23.319343828908416, 39.26995653624116), + Offset(21.819578621640403, 39.12108999420297), + Offset(20.60213825053295, 38.894835867893995), + Offset(19.62360132485448, 38.637237944235835), + Offset(18.845976971348936, 38.38185129321421), + Offset(18.239167746438056, 38.14971419309638), + Offset(17.777732663397334, 37.95295628860924), + Offset(17.441563669830845, 37.79813439937118), + Offset(17.214598153286296, 37.687948048506286), + Offset(17.08410847463606, 37.62248304859991), + Offset(17.040099392177375, 37.600050864949836), + Offset(17.040000000000525, 37.6), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(33.599999999999284, 6.0), + Offset(33.60378470030526, 6.002296228500315), + Offset(36.38967574950269, 7.998624499963004), + Offset(42.54776719716194, 17.73765436786441), + Offset(41.658980312754615, 28.5451611688539), + Offset(37.54498000139701, 34.308526658875984), + Offset(33.54263783070051, 37.15834579815771), + Offset(30.167535431059207, 38.534189727036605), + Offset(27.404117382535397, 39.130469359409446), + Offset(25.153966281356634, 39.3091377433522), + Offset(23.319343828908416, 39.26995653624116), + Offset(21.819578621640403, 39.12108999420297), + Offset(20.60213825053295, 38.894835867893995), + Offset(19.62360132485448, 38.637237944235835), + Offset(18.845976971348936, 38.38185129321421), + Offset(18.239167746438056, 38.14971419309638), + Offset(17.777732663397334, 37.95295628860924), + Offset(17.441563669830845, 37.79813439937118), + Offset(17.214598153286296, 37.687948048506286), + Offset(17.08410847463606, 37.62248304859991), + Offset(17.040099392177375, 37.600050864949836), + Offset(17.040000000000525, 37.6), + ], + <Offset>[ + Offset(30.39999999999928, 6.0), + Offset(30.403826772654256, 6.001600955318109), + Offset(33.262973928210585, 7.464112893661214), + Offset(40.3252246978803, 15.636425198410514), + Offset(41.00861867752789, 25.79286169038749), + Offset(37.98976632761843, 31.726851345525063), + Offset(34.66109919246759, 34.93604100542338), + Offset(31.70815663793789, 36.68371605880232), + Offset(29.216038057712783, 37.618746191042845), + Offset(27.144554306925627, 38.092582286299354), + Offset(25.430782781824195, 38.30542837248677), + Offset(24.014543572942166, 38.369738636609966), + Offset(22.850742435529103, 38.32371484812867), + Offset(21.90507975646699, 38.2162199326414), + Offset(21.146729034110027, 38.08362328362998), + Offset(20.550519051593216, 37.949576380183515), + Offset(20.094408607160354, 37.82880891010401), + Offset(19.760567137157015, 37.73014235182732), + Offset(19.53440820397033, 37.65826086700747), + Offset(19.40409629300487, 37.61496489587922), + Offset(19.360099392115416, 37.60003390984549), + Offset(19.360000000000525, 37.6), + ], + <Offset>[ + Offset(30.39999999999928, 6.0), + Offset(30.403826772654256, 6.001600955318109), + Offset(33.262973928210585, 7.464112893661214), + Offset(40.3252246978803, 15.636425198410514), + Offset(41.00861867752789, 25.79286169038749), + Offset(37.98976632761843, 31.726851345525063), + Offset(34.66109919246759, 34.93604100542338), + Offset(31.70815663793789, 36.68371605880232), + Offset(29.216038057712783, 37.618746191042845), + Offset(27.144554306925627, 38.092582286299354), + Offset(25.430782781824195, 38.30542837248677), + Offset(24.014543572942166, 38.369738636609966), + Offset(22.850742435529103, 38.32371484812867), + Offset(21.90507975646699, 38.2162199326414), + Offset(21.146729034110027, 38.08362328362998), + Offset(20.550519051593216, 37.949576380183515), + Offset(20.094408607160354, 37.82880891010401), + Offset(19.760567137157015, 37.73014235182732), + Offset(19.53440820397033, 37.65826086700747), + Offset(19.40409629300487, 37.61496489587922), + Offset(19.360099392115416, 37.60003390984549), + Offset(19.360000000000525, 37.6), + ], + ), + _PathClose(), + _PathMoveTo(<Offset>[ + Offset(35.2, 34.8), + Offset(35.19750620549164, 34.80226521395049), + Offset(33.142422203433355, 36.40619669474303), + Offset(24.747975921718226, 38.79115144612656), + Offset(17.213465824170402, 35.77456562512825), + Offset(14.087509018127873, 31.596287379559158), + Offset(12.982664015207707, 28.20334593862164), + Offset(12.742961813510924, 25.59383569924602), + Offset(12.89264852964688, 23.57904486699663), + Offset(13.20967315509607, 22.002123241757968), + Offset(13.58287087866059, 20.749270041876564), + Offset(13.959933927651988, 19.74208111128378), + Offset(14.3377469801464, 18.94295871281142), + Offset(14.693700004697835, 18.31444106552057), + Offset(15.011548853709796, 17.824196733156548), + Offset(15.282251777644161, 17.447621353156435), + Offset(15.50206828496817, 17.16494648399472), + Offset(15.670133508272507, 16.961099217207604), + Offset(15.78750849445444, 16.8245011830994), + Offset(15.856451190964936, 16.746351759640973), + Offset(15.879946796268738, 16.720059343059617), + Offset(15.879999999999999, 16.720000000000002), + ]), + _PathCubicTo( + <Offset>[ + Offset(35.2, 34.8), + Offset(35.19750620549164, 34.80226521395049), + Offset(33.142422203433355, 36.40619669474303), + Offset(24.747975921718226, 38.79115144612656), + Offset(17.213465824170402, 35.77456562512825), + Offset(14.087509018127873, 31.596287379559158), + Offset(12.982664015207707, 28.20334593862164), + Offset(12.742961813510924, 25.59383569924602), + Offset(12.89264852964688, 23.57904486699663), + Offset(13.20967315509607, 22.002123241757968), + Offset(13.58287087866059, 20.749270041876564), + Offset(13.959933927651988, 19.74208111128378), + Offset(14.3377469801464, 18.94295871281142), + Offset(14.693700004697835, 18.31444106552057), + Offset(15.011548853709796, 17.824196733156548), + Offset(15.282251777644161, 17.447621353156435), + Offset(15.50206828496817, 17.16494648399472), + Offset(15.670133508272507, 16.961099217207604), + Offset(15.78750849445444, 16.8245011830994), + Offset(15.856451190964936, 16.746351759640973), + Offset(15.879946796268738, 16.720059343059617), + Offset(15.879999999999999, 16.720000000000002), + ], + <Offset>[ + Offset(12.799999999999999, 34.8), + Offset(12.797800711934586, 34.79739830167506), + Offset(11.255509454388564, 32.66461545063052), + Offset(9.190178426746762, 24.08254725994932), + Offset(12.660934377583331, 16.50846927586335), + Offset(17.201013301677882, 13.52456018610269), + Offset(20.811893547577277, 12.647212389481304), + Offset(23.527310261661704, 12.640520021606001), + Offset(25.576093255888573, 12.996982688430414), + Offset(27.143789334079017, 13.48623504238804), + Offset(28.362943549071037, 13.997572895595871), + Offset(29.324688586764324, 14.482621608132748), + Offset(30.077976275119465, 14.945111574454113), + Offset(30.664049025985392, 15.367314984359552), + Offset(31.116813293037453, 15.736600666066936), + Offset(31.46171091373027, 16.046656662766367), + Offset(31.718799891309303, 16.29591483445806), + Offset(31.903157779555688, 16.485154884400586), + Offset(32.02617884924267, 16.61669091260771), + Offset(32.0963659195466, 16.69372469059615), + Offset(32.11994679583504, 16.71994065732921), + Offset(32.12, 16.72), + ], + <Offset>[ + Offset(12.799999999999999, 34.8), + Offset(12.797800711934586, 34.79739830167506), + Offset(11.255509454388564, 32.66461545063052), + Offset(9.190178426746762, 24.08254725994932), + Offset(12.660934377583331, 16.50846927586335), + Offset(17.201013301677882, 13.52456018610269), + Offset(20.811893547577277, 12.647212389481304), + Offset(23.527310261661704, 12.640520021606001), + Offset(25.576093255888573, 12.996982688430414), + Offset(27.143789334079017, 13.48623504238804), + Offset(28.362943549071037, 13.997572895595871), + Offset(29.324688586764324, 14.482621608132748), + Offset(30.077976275119465, 14.945111574454113), + Offset(30.664049025985392, 15.367314984359552), + Offset(31.116813293037453, 15.736600666066936), + Offset(31.46171091373027, 16.046656662766367), + Offset(31.718799891309303, 16.29591483445806), + Offset(31.903157779555688, 16.485154884400586), + Offset(32.02617884924267, 16.61669091260771), + Offset(32.0963659195466, 16.69372469059615), + Offset(32.11994679583504, 16.71994065732921), + Offset(32.12, 16.72), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.799999999999999, 34.8), + Offset(12.797800711934586, 34.79739830167506), + Offset(11.255509454388564, 32.66461545063052), + Offset(9.190178426746762, 24.08254725994932), + Offset(12.660934377583331, 16.50846927586335), + Offset(17.201013301677882, 13.52456018610269), + Offset(20.811893547577277, 12.647212389481304), + Offset(23.527310261661704, 12.640520021606001), + Offset(25.576093255888573, 12.996982688430414), + Offset(27.143789334079017, 13.48623504238804), + Offset(28.362943549071037, 13.997572895595871), + Offset(29.324688586764324, 14.482621608132748), + Offset(30.077976275119465, 14.945111574454113), + Offset(30.664049025985392, 15.367314984359552), + Offset(31.116813293037453, 15.736600666066936), + Offset(31.46171091373027, 16.046656662766367), + Offset(31.718799891309303, 16.29591483445806), + Offset(31.903157779555688, 16.485154884400586), + Offset(32.02617884924267, 16.61669091260771), + Offset(32.0963659195466, 16.69372469059615), + Offset(32.11994679583504, 16.71994065732921), + Offset(32.12, 16.72), + ], + <Offset>[ + Offset(12.799999999999999, 17.2), + Offset(12.801624714436713, 17.19762969959451), + Offset(14.195323289048401, 15.467755433523894), + Offset(20.74693885874317, 11.858563513900311), + Offset(27.798581509148605, 12.931480282116368), + Offset(31.400227525107965, 15.970884980320555), + Offset(33.03456990761612, 18.79874987920025), + Offset(33.70491543695029, 21.113936659438757), + Offset(33.89057068190488, 22.962546401906028), + Offset(33.83484434786968, 24.434469183017498), + Offset(33.667848449720154, 25.61048713663265), + Offset(33.45712105352585, 26.55492884029244), + Offset(33.219141883828776, 27.31243459193295), + Offset(32.97964808975476, 27.915446358228348), + Offset(32.75706734575072, 28.390737011252952), + Offset(32.562468884751034, 28.759088841119738), + Offset(32.40161047308811, 29.037632525154663), + Offset(32.277114041046914, 29.239673954694513), + Offset(32.189458347486145, 29.375646191369892), + Offset(32.13771575951039, 29.453657691624603), + Offset(32.12004004890893, 29.47994065698845), + Offset(32.12, 29.479999999999997), + ], + <Offset>[ + Offset(12.799999999999999, 17.2), + Offset(12.801624714436713, 17.19762969959451), + Offset(14.195323289048401, 15.467755433523894), + Offset(20.74693885874317, 11.858563513900311), + Offset(27.798581509148605, 12.931480282116368), + Offset(31.400227525107965, 15.970884980320555), + Offset(33.03456990761612, 18.79874987920025), + Offset(33.70491543695029, 21.113936659438757), + Offset(33.89057068190488, 22.962546401906028), + Offset(33.83484434786968, 24.434469183017498), + Offset(33.667848449720154, 25.61048713663265), + Offset(33.45712105352585, 26.55492884029244), + Offset(33.219141883828776, 27.31243459193295), + Offset(32.97964808975476, 27.915446358228348), + Offset(32.75706734575072, 28.390737011252952), + Offset(32.562468884751034, 28.759088841119738), + Offset(32.40161047308811, 29.037632525154663), + Offset(32.277114041046914, 29.239673954694513), + Offset(32.189458347486145, 29.375646191369892), + Offset(32.13771575951039, 29.453657691624603), + Offset(32.12004004890893, 29.47994065698845), + Offset(32.12, 29.479999999999997), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.799999999999999, 17.2), + Offset(12.801624714436713, 17.19762969959451), + Offset(14.195323289048401, 15.467755433523894), + Offset(20.74693885874317, 11.858563513900311), + Offset(27.798581509148605, 12.931480282116368), + Offset(31.400227525107965, 15.970884980320555), + Offset(33.03456990761612, 18.79874987920025), + Offset(33.70491543695029, 21.113936659438757), + Offset(33.89057068190488, 22.962546401906028), + Offset(33.83484434786968, 24.434469183017498), + Offset(33.667848449720154, 25.61048713663265), + Offset(33.45712105352585, 26.55492884029244), + Offset(33.219141883828776, 27.31243459193295), + Offset(32.97964808975476, 27.915446358228348), + Offset(32.75706734575072, 28.390737011252952), + Offset(32.562468884751034, 28.759088841119738), + Offset(32.40161047308811, 29.037632525154663), + Offset(32.277114041046914, 29.239673954694513), + Offset(32.189458347486145, 29.375646191369892), + Offset(32.13771575951039, 29.453657691624603), + Offset(32.12004004890893, 29.47994065698845), + Offset(32.12, 29.479999999999997), + ], + <Offset>[ + Offset(35.2, 17.2), + Offset(35.20133020799377, 17.202496611869943), + Offset(36.08223603809319, 19.20933667763641), + Offset(36.30473635371463, 26.567167700077555), + Offset(32.35111295573567, 32.19757663138127), + Offset(28.286723241557954, 34.04261217377702), + Offset(25.205340375246543, 34.35488342834059), + Offset(22.92056698879951, 34.06725233707878), + Offset(21.20712595566319, 33.54460858047224), + Offset(19.90072816888673, 32.95035738238742), + Offset(18.887775779309706, 32.36218428291335), + Offset(18.092366394413514, 31.814388343443472), + Offset(17.478912588855714, 31.310281730290257), + Offset(17.009299068467204, 30.862572439389364), + Offset(16.651802906423065, 30.478333078342565), + Offset(16.383009748664925, 30.160053531509803), + Offset(16.184878866746974, 29.906664174691322), + Offset(16.044089769763733, 29.71561828750153), + Offset(15.950787992697913, 29.583456461861584), + Offset(15.897801030928724, 29.506284760669423), + Offset(15.880040049342629, 29.480059342718857), + Offset(15.88, 29.48), + ], + <Offset>[ + Offset(35.2, 17.2), + Offset(35.20133020799377, 17.202496611869943), + Offset(36.08223603809319, 19.20933667763641), + Offset(36.30473635371463, 26.567167700077555), + Offset(32.35111295573567, 32.19757663138127), + Offset(28.286723241557954, 34.04261217377702), + Offset(25.205340375246543, 34.35488342834059), + Offset(22.92056698879951, 34.06725233707878), + Offset(21.20712595566319, 33.54460858047224), + Offset(19.90072816888673, 32.95035738238742), + Offset(18.887775779309706, 32.36218428291335), + Offset(18.092366394413514, 31.814388343443472), + Offset(17.478912588855714, 31.310281730290257), + Offset(17.009299068467204, 30.862572439389364), + Offset(16.651802906423065, 30.478333078342565), + Offset(16.383009748664925, 30.160053531509803), + Offset(16.184878866746974, 29.906664174691322), + Offset(16.044089769763733, 29.71561828750153), + Offset(15.950787992697913, 29.583456461861584), + Offset(15.897801030928724, 29.506284760669423), + Offset(15.880040049342629, 29.480059342718857), + Offset(15.88, 29.48), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.2, 17.2), + Offset(35.20133020799377, 17.202496611869943), + Offset(36.08223603809319, 19.20933667763641), + Offset(36.30473635371463, 26.567167700077555), + Offset(32.35111295573567, 32.19757663138127), + Offset(28.286723241557954, 34.04261217377702), + Offset(25.205340375246543, 34.35488342834059), + Offset(22.92056698879951, 34.06725233707878), + Offset(21.20712595566319, 33.54460858047224), + Offset(19.90072816888673, 32.95035738238742), + Offset(18.887775779309706, 32.36218428291335), + Offset(18.092366394413514, 31.814388343443472), + Offset(17.478912588855714, 31.310281730290257), + Offset(17.009299068467204, 30.862572439389364), + Offset(16.651802906423065, 30.478333078342565), + Offset(16.383009748664925, 30.160053531509803), + Offset(16.184878866746974, 29.906664174691322), + Offset(16.044089769763733, 29.71561828750153), + Offset(15.950787992697913, 29.583456461861584), + Offset(15.897801030928724, 29.506284760669423), + Offset(15.880040049342629, 29.480059342718857), + Offset(15.88, 29.48), + ], + <Offset>[ + Offset(35.2, 34.8), + Offset(35.19750620549164, 34.80226521395049), + Offset(33.142422203433355, 36.40619669474303), + Offset(24.747975921718226, 38.79115144612656), + Offset(17.213465824170402, 35.77456562512825), + Offset(14.087509018127873, 31.596287379559158), + Offset(12.982664015207707, 28.20334593862164), + Offset(12.742961813510924, 25.59383569924602), + Offset(12.89264852964688, 23.57904486699663), + Offset(13.20967315509607, 22.002123241757968), + Offset(13.58287087866059, 20.749270041876564), + Offset(13.959933927651988, 19.74208111128378), + Offset(14.3377469801464, 18.94295871281142), + Offset(14.693700004697835, 18.31444106552057), + Offset(15.011548853709796, 17.824196733156548), + Offset(15.282251777644161, 17.447621353156435), + Offset(15.50206828496817, 17.16494648399472), + Offset(15.670133508272507, 16.961099217207604), + Offset(15.78750849445444, 16.8245011830994), + Offset(15.856451190964936, 16.746351759640973), + Offset(15.879946796268738, 16.720059343059617), + Offset(15.879999999999999, 16.720000000000002), + ], + <Offset>[ + Offset(35.2, 34.8), + Offset(35.19750620549164, 34.80226521395049), + Offset(33.142422203433355, 36.40619669474303), + Offset(24.747975921718226, 38.79115144612656), + Offset(17.213465824170402, 35.77456562512825), + Offset(14.087509018127873, 31.596287379559158), + Offset(12.982664015207707, 28.20334593862164), + Offset(12.742961813510924, 25.59383569924602), + Offset(12.89264852964688, 23.57904486699663), + Offset(13.20967315509607, 22.002123241757968), + Offset(13.58287087866059, 20.749270041876564), + Offset(13.959933927651988, 19.74208111128378), + Offset(14.3377469801464, 18.94295871281142), + Offset(14.693700004697835, 18.31444106552057), + Offset(15.011548853709796, 17.824196733156548), + Offset(15.282251777644161, 17.447621353156435), + Offset(15.50206828496817, 17.16494648399472), + Offset(15.670133508272507, 16.961099217207604), + Offset(15.78750849445444, 16.8245011830994), + Offset(15.856451190964936, 16.746351759640973), + Offset(15.879946796268738, 16.720059343059617), + Offset(15.879999999999999, 16.720000000000002), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.285714285714, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ]), + _PathCubicTo( + <Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.000434881903109, 21.996958187115208), + Offset(10.537203766746376, 19.66951300869151), + Offset(15.2007493434278, 12.928722391574958), + Offset(22.726894381864838, 9.915284806391266), + Offset(28.34794602732309, 10.54283219457061), + Offset(32.08037023016842, 12.393638944810997), + Offset(34.49466952610868, 14.52044771427649), + Offset(36.03113891519555, 16.566851514784105), + Offset(36.98867178324178, 18.40585973475647), + Offset(37.56526083167866, 20.0020381982159), + Offset(37.893194841987366, 21.35819435185408), + Offset(38.06150889201586, 22.492031936757623), + Offset(38.13048882144999, 23.426165819354733), + Offset(38.14094176630335, 24.183755168682307), + Offset(38.12034219741291, 24.78481604721129), + Offset(38.086964642108946, 25.247969219160655), + Offset(38.052600274850896, 25.58884408148203), + Offset(38.02444615197159, 25.820689465683056), + Offset(38.00640765629513, 25.954621335624125), + Offset(38.000014616095385, 25.999897684661754), + Offset(38.0, 25.999999999999993), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.000434881903109, 21.996958187115208), + Offset(10.537203766746376, 19.66951300869151), + Offset(15.2007493434278, 12.928722391574958), + Offset(22.726894381864838, 9.915284806391266), + Offset(28.34794602732309, 10.54283219457061), + Offset(32.08037023016842, 12.393638944810997), + Offset(34.49466952610868, 14.52044771427649), + Offset(36.03113891519555, 16.566851514784105), + Offset(36.98867178324178, 18.40585973475647), + Offset(37.56526083167866, 20.0020381982159), + Offset(37.893194841987366, 21.35819435185408), + Offset(38.06150889201586, 22.492031936757623), + Offset(38.13048882144999, 23.426165819354733), + Offset(38.14094176630335, 24.183755168682307), + Offset(38.12034219741291, 24.78481604721129), + Offset(38.086964642108946, 25.247969219160655), + Offset(38.052600274850896, 25.58884408148203), + Offset(38.02444615197159, 25.820689465683056), + Offset(38.00640765629513, 25.954621335624125), + Offset(38.000014616095385, 25.999897684661754), + Offset(38.0, 25.999999999999993), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.0, 22.0), + Offset(10.000434881903109, 21.996958187115208), + Offset(10.537203766746376, 19.66951300869151), + Offset(15.2007493434278, 12.928722391574958), + Offset(22.726894381864838, 9.915284806391266), + Offset(28.34794602732309, 10.54283219457061), + Offset(32.08037023016842, 12.393638944810997), + Offset(34.49466952610868, 14.52044771427649), + Offset(36.03113891519555, 16.566851514784105), + Offset(36.98867178324178, 18.40585973475647), + Offset(37.56526083167866, 20.0020381982159), + Offset(37.893194841987366, 21.35819435185408), + Offset(38.06150889201586, 22.492031936757623), + Offset(38.13048882144999, 23.426165819354733), + Offset(38.14094176630335, 24.183755168682307), + Offset(38.12034219741291, 24.78481604721129), + Offset(38.086964642108946, 25.247969219160655), + Offset(38.052600274850896, 25.58884408148203), + Offset(38.02444615197159, 25.820689465683056), + Offset(38.00640765629513, 25.954621335624125), + Offset(38.000014616095385, 25.999897684661754), + Offset(38.0, 25.999999999999993), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.00043422098076, 22.003041907302272), + Offset(38.136820741489856, 24.387684566345136), + Offset(35.547238360668324, 32.16463632024782), + Offset(29.165901647620203, 37.16485701278654), + Offset(23.59397879929007, 38.13630598086269), + Offset(19.492625674491244, 37.4046102774286), + Offset(16.57939189493825, 36.038877661605), + Offset(14.531388217272726, 34.50454144206201), + Offset(13.097234362356085, 33.007202753941485), + Offset(12.096778906088034, 31.63631636258276), + Offset(11.402238533172989, 30.426227977976552), + Offset(10.923182521372642, 29.384871830477124), + Offset(10.595404301988683, 28.507417683425455), + Offset(10.373244457117737, 27.783058732629918), + Offset(10.224722997264447, 27.20027240995278), + Offset(10.12708256221043, 26.746299649396278), + Offset(10.064627393328172, 26.40943775873551), + Offset(10.026738643716017, 26.17898303549632), + Offset(10.006554675981915, 26.045357661563475), + Offset(10.000014616843135, 26.00010231523142), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.00043422098076, 22.003041907302272), + Offset(38.136820741489856, 24.387684566345136), + Offset(35.547238360668324, 32.16463632024782), + Offset(29.165901647620203, 37.16485701278654), + Offset(23.59397879929007, 38.13630598086269), + Offset(19.492625674491244, 37.4046102774286), + Offset(16.57939189493825, 36.038877661605), + Offset(14.531388217272726, 34.50454144206201), + Offset(13.097234362356085, 33.007202753941485), + Offset(12.096778906088034, 31.63631636258276), + Offset(11.402238533172989, 30.426227977976552), + Offset(10.923182521372642, 29.384871830477124), + Offset(10.595404301988683, 28.507417683425455), + Offset(10.373244457117737, 27.783058732629918), + Offset(10.224722997264447, 27.20027240995278), + Offset(10.12708256221043, 26.746299649396278), + Offset(10.064627393328172, 26.40943775873551), + Offset(10.026738643716017, 26.17898303549632), + Offset(10.006554675981915, 26.045357661563475), + Offset(10.000014616843135, 26.00010231523142), + Offset(10.0, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.0, 22.0), + Offset(38.00043422098076, 22.003041907302272), + Offset(38.136820741489856, 24.387684566345136), + Offset(35.547238360668324, 32.16463632024782), + Offset(29.165901647620203, 37.16485701278654), + Offset(23.59397879929007, 38.13630598086269), + Offset(19.492625674491244, 37.4046102774286), + Offset(16.57939189493825, 36.038877661605), + Offset(14.531388217272726, 34.50454144206201), + Offset(13.097234362356085, 33.007202753941485), + Offset(12.096778906088034, 31.63631636258276), + Offset(11.402238533172989, 30.426227977976552), + Offset(10.923182521372642, 29.384871830477124), + Offset(10.595404301988683, 28.507417683425455), + Offset(10.373244457117737, 27.783058732629918), + Offset(10.224722997264447, 27.20027240995278), + Offset(10.12708256221043, 26.746299649396278), + Offset(10.064627393328172, 26.40943775873551), + Offset(10.026738643716017, 26.17898303549632), + Offset(10.006554675981915, 26.045357661563475), + Offset(10.000014616843135, 26.00010231523142), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.99956511809689, 26.003041812884792), + Offset(37.46279623325363, 28.33048699130849), + Offset(32.7992506565722, 35.07127760842504), + Offset(25.273105618135162, 38.08471519360873), + Offset(19.652053972676917, 37.4571678054294), + Offset(15.919629769831586, 35.606361055189005), + Offset(13.50533047389132, 33.479552285723514), + Offset(11.968861084804454, 31.433148485215895), + Offset(11.011328216758224, 29.59414026524353), + Offset(10.43473916832134, 27.9979618017841), + Offset(10.106805158012635, 26.641805648145926), + Offset(9.938491107984142, 25.507968063242377), + Offset(9.86951117855001, 24.573834180645267), + Offset(9.859058233696649, 23.816244831317686), + Offset(9.87965780258709, 23.215183952788717), + Offset(9.913035357891056, 22.752030780839345), + Offset(9.947399725149102, 22.411155918517977), + Offset(9.97555384802841, 22.17931053431695), + Offset(9.993592343704867, 22.045378664375875), + Offset(9.999985383904612, 22.00010231533824), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.99956511809689, 26.003041812884792), + Offset(37.46279623325363, 28.33048699130849), + Offset(32.7992506565722, 35.07127760842504), + Offset(25.273105618135162, 38.08471519360873), + Offset(19.652053972676917, 37.4571678054294), + Offset(15.919629769831586, 35.606361055189005), + Offset(13.50533047389132, 33.479552285723514), + Offset(11.968861084804454, 31.433148485215895), + Offset(11.011328216758224, 29.59414026524353), + Offset(10.43473916832134, 27.9979618017841), + Offset(10.106805158012635, 26.641805648145926), + Offset(9.938491107984142, 25.507968063242377), + Offset(9.86951117855001, 24.573834180645267), + Offset(9.859058233696649, 23.816244831317686), + Offset(9.87965780258709, 23.215183952788717), + Offset(9.913035357891056, 22.752030780839345), + Offset(9.947399725149102, 22.411155918517977), + Offset(9.97555384802841, 22.17931053431695), + Offset(9.993592343704867, 22.045378664375875), + Offset(9.999985383904612, 22.00010231533824), + Offset(10.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.0, 26.0), + Offset(37.99956511809689, 26.003041812884792), + Offset(37.46279623325363, 28.33048699130849), + Offset(32.7992506565722, 35.07127760842504), + Offset(25.273105618135162, 38.08471519360873), + Offset(19.652053972676917, 37.4571678054294), + Offset(15.919629769831586, 35.606361055189005), + Offset(13.50533047389132, 33.479552285723514), + Offset(11.968861084804454, 31.433148485215895), + Offset(11.011328216758224, 29.59414026524353), + Offset(10.43473916832134, 27.9979618017841), + Offset(10.106805158012635, 26.641805648145926), + Offset(9.938491107984142, 25.507968063242377), + Offset(9.86951117855001, 24.573834180645267), + Offset(9.859058233696649, 23.816244831317686), + Offset(9.87965780258709, 23.215183952788717), + Offset(9.913035357891056, 22.752030780839345), + Offset(9.947399725149102, 22.411155918517977), + Offset(9.97555384802841, 22.17931053431695), + Offset(9.993592343704867, 22.045378664375875), + Offset(9.999985383904612, 22.00010231533824), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.285714285714, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.003041812884792, 10.000434881903109), + Offset(28.33048699130849, 10.537203766746376), + Offset(35.07127760842504, 15.2007493434278), + Offset(38.08471519360873, 22.726894381864838), + Offset(37.4571678054294, 28.34794602732309), + Offset(35.606361055189005, 32.08037023016842), + Offset(33.479552285723514, 34.49466952610868), + Offset(31.433148485215895, 36.03113891519555), + Offset(29.59414026524353, 36.98867178324178), + Offset(27.9979618017841, 37.56526083167866), + Offset(26.641805648145922, 37.893194841987366), + Offset(25.507968063242377, 38.06150889201586), + Offset(24.573834180645267, 38.13048882144999), + Offset(23.81624483131769, 38.14094176630335), + Offset(23.215183952788713, 38.12034219741291), + Offset(22.752030780839345, 38.086964642108946), + Offset(22.411155918517974, 38.0526002748509), + Offset(22.179310534316947, 38.024446151971595), + Offset(22.045378664375875, 38.00640765629513), + Offset(22.000102315338243, 38.000014616095385), + Offset(22.000000000000004, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.003041812884792, 10.000434881903109), + Offset(28.33048699130849, 10.537203766746376), + Offset(35.07127760842504, 15.2007493434278), + Offset(38.08471519360873, 22.726894381864838), + Offset(37.4571678054294, 28.34794602732309), + Offset(35.606361055189005, 32.08037023016842), + Offset(33.479552285723514, 34.49466952610868), + Offset(31.433148485215895, 36.03113891519555), + Offset(29.59414026524353, 36.98867178324178), + Offset(27.9979618017841, 37.56526083167866), + Offset(26.641805648145922, 37.893194841987366), + Offset(25.507968063242377, 38.06150889201586), + Offset(24.573834180645267, 38.13048882144999), + Offset(23.81624483131769, 38.14094176630335), + Offset(23.215183952788713, 38.12034219741291), + Offset(22.752030780839345, 38.086964642108946), + Offset(22.411155918517974, 38.0526002748509), + Offset(22.179310534316947, 38.024446151971595), + Offset(22.045378664375875, 38.00640765629513), + Offset(22.000102315338243, 38.000014616095385), + Offset(22.000000000000004, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.0, 10.0), + Offset(26.003041812884792, 10.000434881903109), + Offset(28.33048699130849, 10.537203766746376), + Offset(35.07127760842504, 15.2007493434278), + Offset(38.08471519360873, 22.726894381864838), + Offset(37.4571678054294, 28.34794602732309), + Offset(35.606361055189005, 32.08037023016842), + Offset(33.479552285723514, 34.49466952610868), + Offset(31.433148485215895, 36.03113891519555), + Offset(29.59414026524353, 36.98867178324178), + Offset(27.9979618017841, 37.56526083167866), + Offset(26.641805648145922, 37.893194841987366), + Offset(25.507968063242377, 38.06150889201586), + Offset(24.573834180645267, 38.13048882144999), + Offset(23.81624483131769, 38.14094176630335), + Offset(23.215183952788713, 38.12034219741291), + Offset(22.752030780839345, 38.086964642108946), + Offset(22.411155918517974, 38.0526002748509), + Offset(22.179310534316947, 38.024446151971595), + Offset(22.045378664375875, 38.00640765629513), + Offset(22.000102315338243, 38.000014616095385), + Offset(22.000000000000004, 38.0), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.996958092697728, 38.00043422098076), + Offset(23.612315433654864, 38.136820741489856), + Offset(15.835363679752177, 35.547238360668324), + Offset(10.835142987213462, 29.165901647620203), + Offset(9.863694019137322, 23.59397879929007), + Offset(10.595389722571403, 19.49262567449124), + Offset(11.961122338395, 16.57939189493825), + Offset(13.495458557937988, 14.531388217272726), + Offset(14.992797246058515, 13.097234362356085), + Offset(16.36368363741724, 12.096778906088034), + Offset(17.573772022023455, 11.402238533172993), + Offset(18.615128169522876, 10.923182521372642), + Offset(19.492582316574545, 10.595404301988683), + Offset(20.21694126737008, 10.373244457117734), + Offset(20.79972759004722, 10.22472299726445), + Offset(21.253700350603722, 10.12708256221043), + Offset(21.590562241264493, 10.064627393328175), + Offset(21.821016964503684, 10.02673864371602), + Offset(21.954642338436525, 10.006554675981915), + Offset(21.999897684768577, 10.000014616843131), + Offset(21.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.996958092697728, 38.00043422098076), + Offset(23.612315433654864, 38.136820741489856), + Offset(15.835363679752177, 35.547238360668324), + Offset(10.835142987213462, 29.165901647620203), + Offset(9.863694019137322, 23.59397879929007), + Offset(10.595389722571403, 19.49262567449124), + Offset(11.961122338395, 16.57939189493825), + Offset(13.495458557937988, 14.531388217272726), + Offset(14.992797246058515, 13.097234362356085), + Offset(16.36368363741724, 12.096778906088034), + Offset(17.573772022023455, 11.402238533172993), + Offset(18.615128169522876, 10.923182521372642), + Offset(19.492582316574545, 10.595404301988683), + Offset(20.21694126737008, 10.373244457117734), + Offset(20.79972759004722, 10.22472299726445), + Offset(21.253700350603722, 10.12708256221043), + Offset(21.590562241264493, 10.064627393328175), + Offset(21.821016964503684, 10.02673864371602), + Offset(21.954642338436525, 10.006554675981915), + Offset(21.999897684768577, 10.000014616843131), + Offset(21.999999999999996, 9.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.0, 38.0), + Offset(25.996958092697728, 38.00043422098076), + Offset(23.612315433654864, 38.136820741489856), + Offset(15.835363679752177, 35.547238360668324), + Offset(10.835142987213462, 29.165901647620203), + Offset(9.863694019137322, 23.59397879929007), + Offset(10.595389722571403, 19.49262567449124), + Offset(11.961122338395, 16.57939189493825), + Offset(13.495458557937988, 14.531388217272726), + Offset(14.992797246058515, 13.097234362356085), + Offset(16.36368363741724, 12.096778906088034), + Offset(17.573772022023455, 11.402238533172993), + Offset(18.615128169522876, 10.923182521372642), + Offset(19.492582316574545, 10.595404301988683), + Offset(20.21694126737008, 10.373244457117734), + Offset(20.79972759004722, 10.22472299726445), + Offset(21.253700350603722, 10.12708256221043), + Offset(21.590562241264493, 10.064627393328175), + Offset(21.821016964503684, 10.02673864371602), + Offset(21.954642338436525, 10.006554675981915), + Offset(21.999897684768577, 10.000014616843131), + Offset(21.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.996958187115208, 37.99956511809689), + Offset(19.66951300869151, 37.46279623325363), + Offset(12.928722391574958, 32.7992506565722), + Offset(9.915284806391266, 25.273105618135162), + Offset(10.54283219457061, 19.652053972676917), + Offset(12.393638944811, 15.919629769831582), + Offset(14.52044771427649, 13.50533047389132), + Offset(16.566851514784105, 11.968861084804454), + Offset(18.40585973475647, 11.011328216758224), + Offset(20.0020381982159, 10.43473916832134), + Offset(21.358194351854078, 10.106805158012639), + Offset(22.492031936757623, 9.938491107984142), + Offset(23.426165819354733, 9.86951117855001), + Offset(24.18375516868231, 9.859058233696645), + Offset(24.784816047211287, 9.879657802587094), + Offset(25.247969219160655, 9.913035357891056), + Offset(25.588844081482026, 9.947399725149106), + Offset(25.820689465683053, 9.975553848028413), + Offset(25.954621335624125, 9.993592343704867), + Offset(25.999897684661757, 9.999985383904608), + Offset(25.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.996958187115208, 37.99956511809689), + Offset(19.66951300869151, 37.46279623325363), + Offset(12.928722391574958, 32.7992506565722), + Offset(9.915284806391266, 25.273105618135162), + Offset(10.54283219457061, 19.652053972676917), + Offset(12.393638944811, 15.919629769831582), + Offset(14.52044771427649, 13.50533047389132), + Offset(16.566851514784105, 11.968861084804454), + Offset(18.40585973475647, 11.011328216758224), + Offset(20.0020381982159, 10.43473916832134), + Offset(21.358194351854078, 10.106805158012639), + Offset(22.492031936757623, 9.938491107984142), + Offset(23.426165819354733, 9.86951117855001), + Offset(24.18375516868231, 9.859058233696645), + Offset(24.784816047211287, 9.879657802587094), + Offset(25.247969219160655, 9.913035357891056), + Offset(25.588844081482026, 9.947399725149106), + Offset(25.820689465683053, 9.975553848028413), + Offset(25.954621335624125, 9.993592343704867), + Offset(25.999897684661757, 9.999985383904608), + Offset(25.999999999999996, 9.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.0, 38.0), + Offset(21.996958187115208, 37.99956511809689), + Offset(19.66951300869151, 37.46279623325363), + Offset(12.928722391574958, 32.7992506565722), + Offset(9.915284806391266, 25.273105618135162), + Offset(10.54283219457061, 19.652053972676917), + Offset(12.393638944811, 15.919629769831582), + Offset(14.52044771427649, 13.50533047389132), + Offset(16.566851514784105, 11.968861084804454), + Offset(18.40585973475647, 11.011328216758224), + Offset(20.0020381982159, 10.43473916832134), + Offset(21.358194351854078, 10.106805158012639), + Offset(22.492031936757623, 9.938491107984142), + Offset(23.426165819354733, 9.86951117855001), + Offset(24.18375516868231, 9.859058233696645), + Offset(24.784816047211287, 9.879657802587094), + Offset(25.247969219160655, 9.913035357891056), + Offset(25.588844081482026, 9.947399725149106), + Offset(25.820689465683053, 9.975553848028413), + Offset(25.954621335624125, 9.993592343704867), + Offset(25.999897684661757, 9.999985383904608), + Offset(25.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ]), + _PathCubicTo( + <Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.000434881903109, 21.996958187115208), + Offset(10.537203766746376, 19.66951300869151), + Offset(15.2007493434278, 12.928722391574958), + Offset(22.726894381864838, 9.915284806391266), + Offset(28.34794602732309, 10.54283219457061), + Offset(32.08037023016842, 12.393638944810997), + Offset(34.49466952610868, 14.52044771427649), + Offset(36.03113891519555, 16.566851514784105), + Offset(36.98867178324178, 18.40585973475647), + Offset(37.56526083167866, 20.0020381982159), + Offset(37.893194841987366, 21.35819435185408), + Offset(38.06150889201586, 22.492031936757623), + Offset(38.13048882144999, 23.426165819354733), + Offset(38.14094176630335, 24.183755168682307), + Offset(38.12034219741291, 24.78481604721129), + Offset(38.086964642108946, 25.247969219160655), + Offset(38.052600274850896, 25.58884408148203), + Offset(38.02444615197159, 25.820689465683056), + Offset(38.00640765629513, 25.954621335624125), + Offset(38.000014616095385, 25.999897684661754), + Offset(38.0, 25.999999999999993), + ], + <Offset>[ + Offset(10.0, 22.0), + Offset(10.000434881903109, 21.996958187115208), + Offset(10.537203766746376, 19.66951300869151), + Offset(15.2007493434278, 12.928722391574958), + Offset(22.726894381864838, 9.915284806391266), + Offset(28.34794602732309, 10.54283219457061), + Offset(32.08037023016842, 12.393638944810997), + Offset(34.49466952610868, 14.52044771427649), + Offset(36.03113891519555, 16.566851514784105), + Offset(36.98867178324178, 18.40585973475647), + Offset(37.56526083167866, 20.0020381982159), + Offset(37.893194841987366, 21.35819435185408), + Offset(38.06150889201586, 22.492031936757623), + Offset(38.13048882144999, 23.426165819354733), + Offset(38.14094176630335, 24.183755168682307), + Offset(38.12034219741291, 24.78481604721129), + Offset(38.086964642108946, 25.247969219160655), + Offset(38.052600274850896, 25.58884408148203), + Offset(38.02444615197159, 25.820689465683056), + Offset(38.00640765629513, 25.954621335624125), + Offset(38.000014616095385, 25.999897684661754), + Offset(38.0, 25.999999999999993), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.0, 22.0), + Offset(10.000434881903109, 21.996958187115208), + Offset(10.537203766746376, 19.66951300869151), + Offset(15.2007493434278, 12.928722391574958), + Offset(22.726894381864838, 9.915284806391266), + Offset(28.34794602732309, 10.54283219457061), + Offset(32.08037023016842, 12.393638944810997), + Offset(34.49466952610868, 14.52044771427649), + Offset(36.03113891519555, 16.566851514784105), + Offset(36.98867178324178, 18.40585973475647), + Offset(37.56526083167866, 20.0020381982159), + Offset(37.893194841987366, 21.35819435185408), + Offset(38.06150889201586, 22.492031936757623), + Offset(38.13048882144999, 23.426165819354733), + Offset(38.14094176630335, 24.183755168682307), + Offset(38.12034219741291, 24.78481604721129), + Offset(38.086964642108946, 25.247969219160655), + Offset(38.052600274850896, 25.58884408148203), + Offset(38.02444615197159, 25.820689465683056), + Offset(38.00640765629513, 25.954621335624125), + Offset(38.000014616095385, 25.999897684661754), + Offset(38.0, 25.999999999999993), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.00043422098076, 22.003041907302272), + Offset(38.136820741489856, 24.387684566345136), + Offset(35.547238360668324, 32.16463632024782), + Offset(29.165901647620203, 37.16485701278654), + Offset(23.59397879929007, 38.13630598086269), + Offset(19.492625674491244, 37.4046102774286), + Offset(16.57939189493825, 36.038877661605), + Offset(14.531388217272726, 34.50454144206201), + Offset(13.097234362356085, 33.007202753941485), + Offset(12.096778906088034, 31.63631636258276), + Offset(11.402238533172989, 30.426227977976552), + Offset(10.923182521372642, 29.384871830477124), + Offset(10.595404301988683, 28.507417683425455), + Offset(10.373244457117737, 27.783058732629918), + Offset(10.224722997264447, 27.20027240995278), + Offset(10.12708256221043, 26.746299649396278), + Offset(10.064627393328172, 26.40943775873551), + Offset(10.026738643716017, 26.17898303549632), + Offset(10.006554675981915, 26.045357661563475), + Offset(10.000014616843135, 26.00010231523142), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 22.0), + Offset(38.00043422098076, 22.003041907302272), + Offset(38.136820741489856, 24.387684566345136), + Offset(35.547238360668324, 32.16463632024782), + Offset(29.165901647620203, 37.16485701278654), + Offset(23.59397879929007, 38.13630598086269), + Offset(19.492625674491244, 37.4046102774286), + Offset(16.57939189493825, 36.038877661605), + Offset(14.531388217272726, 34.50454144206201), + Offset(13.097234362356085, 33.007202753941485), + Offset(12.096778906088034, 31.63631636258276), + Offset(11.402238533172989, 30.426227977976552), + Offset(10.923182521372642, 29.384871830477124), + Offset(10.595404301988683, 28.507417683425455), + Offset(10.373244457117737, 27.783058732629918), + Offset(10.224722997264447, 27.20027240995278), + Offset(10.12708256221043, 26.746299649396278), + Offset(10.064627393328172, 26.40943775873551), + Offset(10.026738643716017, 26.17898303549632), + Offset(10.006554675981915, 26.045357661563475), + Offset(10.000014616843135, 26.00010231523142), + Offset(10.0, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.0, 22.0), + Offset(38.00043422098076, 22.003041907302272), + Offset(38.136820741489856, 24.387684566345136), + Offset(35.547238360668324, 32.16463632024782), + Offset(29.165901647620203, 37.16485701278654), + Offset(23.59397879929007, 38.13630598086269), + Offset(19.492625674491244, 37.4046102774286), + Offset(16.57939189493825, 36.038877661605), + Offset(14.531388217272726, 34.50454144206201), + Offset(13.097234362356085, 33.007202753941485), + Offset(12.096778906088034, 31.63631636258276), + Offset(11.402238533172989, 30.426227977976552), + Offset(10.923182521372642, 29.384871830477124), + Offset(10.595404301988683, 28.507417683425455), + Offset(10.373244457117737, 27.783058732629918), + Offset(10.224722997264447, 27.20027240995278), + Offset(10.12708256221043, 26.746299649396278), + Offset(10.064627393328172, 26.40943775873551), + Offset(10.026738643716017, 26.17898303549632), + Offset(10.006554675981915, 26.045357661563475), + Offset(10.000014616843135, 26.00010231523142), + Offset(10.0, 26.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.99956511809689, 26.003041812884792), + Offset(37.46279623325363, 28.33048699130849), + Offset(32.7992506565722, 35.07127760842504), + Offset(25.273105618135162, 38.08471519360873), + Offset(19.652053972676917, 37.4571678054294), + Offset(15.919629769831586, 35.606361055189005), + Offset(13.50533047389132, 33.479552285723514), + Offset(11.968861084804454, 31.433148485215895), + Offset(11.011328216758224, 29.59414026524353), + Offset(10.43473916832134, 27.9979618017841), + Offset(10.106805158012635, 26.641805648145926), + Offset(9.938491107984142, 25.507968063242377), + Offset(9.86951117855001, 24.573834180645267), + Offset(9.859058233696649, 23.816244831317686), + Offset(9.87965780258709, 23.215183952788717), + Offset(9.913035357891056, 22.752030780839345), + Offset(9.947399725149102, 22.411155918517977), + Offset(9.97555384802841, 22.17931053431695), + Offset(9.993592343704867, 22.045378664375875), + Offset(9.999985383904612, 22.00010231533824), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(38.0, 26.0), + Offset(37.99956511809689, 26.003041812884792), + Offset(37.46279623325363, 28.33048699130849), + Offset(32.7992506565722, 35.07127760842504), + Offset(25.273105618135162, 38.08471519360873), + Offset(19.652053972676917, 37.4571678054294), + Offset(15.919629769831586, 35.606361055189005), + Offset(13.50533047389132, 33.479552285723514), + Offset(11.968861084804454, 31.433148485215895), + Offset(11.011328216758224, 29.59414026524353), + Offset(10.43473916832134, 27.9979618017841), + Offset(10.106805158012635, 26.641805648145926), + Offset(9.938491107984142, 25.507968063242377), + Offset(9.86951117855001, 24.573834180645267), + Offset(9.859058233696649, 23.816244831317686), + Offset(9.87965780258709, 23.215183952788717), + Offset(9.913035357891056, 22.752030780839345), + Offset(9.947399725149102, 22.411155918517977), + Offset(9.97555384802841, 22.17931053431695), + Offset(9.993592343704867, 22.045378664375875), + Offset(9.999985383904612, 22.00010231533824), + Offset(10.0, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.0, 26.0), + Offset(37.99956511809689, 26.003041812884792), + Offset(37.46279623325363, 28.33048699130849), + Offset(32.7992506565722, 35.07127760842504), + Offset(25.273105618135162, 38.08471519360873), + Offset(19.652053972676917, 37.4571678054294), + Offset(15.919629769831586, 35.606361055189005), + Offset(13.50533047389132, 33.479552285723514), + Offset(11.968861084804454, 31.433148485215895), + Offset(11.011328216758224, 29.59414026524353), + Offset(10.43473916832134, 27.9979618017841), + Offset(10.106805158012635, 26.641805648145926), + Offset(9.938491107984142, 25.507968063242377), + Offset(9.86951117855001, 24.573834180645267), + Offset(9.859058233696649, 23.816244831317686), + Offset(9.87965780258709, 23.215183952788717), + Offset(9.913035357891056, 22.752030780839345), + Offset(9.947399725149102, 22.411155918517977), + Offset(9.97555384802841, 22.17931053431695), + Offset(9.993592343704867, 22.045378664375875), + Offset(9.999985383904612, 22.00010231533824), + Offset(10.0, 22.0), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ], + <Offset>[ + Offset(10.0, 26.0), + Offset(9.999565779019244, 25.996958092697728), + Offset(9.863179258510144, 23.612315433654864), + Offset(12.452761639331678, 15.835363679752177), + Offset(18.834098352379797, 10.835142987213462), + Offset(24.406021200709937, 9.863694019137322), + Offset(28.507374325508763, 10.595389722571399), + Offset(31.42060810506175, 11.961122338395), + Offset(33.46861178272727, 13.495458557937988), + Offset(34.90276563764392, 14.992797246058515), + Offset(35.90322109391197, 16.36368363741724), + Offset(36.59776146682701, 17.573772022023455), + Offset(37.07681747862736, 18.615128169522876), + Offset(37.40459569801132, 19.492582316574545), + Offset(37.626755542882265, 20.216941267370075), + Offset(37.77527700273555, 20.799727590047226), + Offset(37.87291743778957, 21.253700350603722), + Offset(37.93537260667183, 21.590562241264497), + Offset(37.973261356283984, 21.821016964503688), + Offset(37.99344532401808, 21.954642338436525), + Offset(37.99998538315687, 21.999897684768573), + Offset(38.0, 21.999999999999993), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.003041812884792, 10.000434881903109), + Offset(28.33048699130849, 10.537203766746376), + Offset(35.07127760842504, 15.2007493434278), + Offset(38.08471519360873, 22.726894381864838), + Offset(37.4571678054294, 28.34794602732309), + Offset(35.606361055189005, 32.08037023016842), + Offset(33.479552285723514, 34.49466952610868), + Offset(31.433148485215895, 36.03113891519555), + Offset(29.59414026524353, 36.98867178324178), + Offset(27.9979618017841, 37.56526083167866), + Offset(26.641805648145922, 37.893194841987366), + Offset(25.507968063242377, 38.06150889201586), + Offset(24.573834180645267, 38.13048882144999), + Offset(23.81624483131769, 38.14094176630335), + Offset(23.215183952788713, 38.12034219741291), + Offset(22.752030780839345, 38.086964642108946), + Offset(22.411155918517974, 38.0526002748509), + Offset(22.179310534316947, 38.024446151971595), + Offset(22.045378664375875, 38.00640765629513), + Offset(22.000102315338243, 38.000014616095385), + Offset(22.000000000000004, 38.0), + ], + <Offset>[ + Offset(26.0, 10.0), + Offset(26.003041812884792, 10.000434881903109), + Offset(28.33048699130849, 10.537203766746376), + Offset(35.07127760842504, 15.2007493434278), + Offset(38.08471519360873, 22.726894381864838), + Offset(37.4571678054294, 28.34794602732309), + Offset(35.606361055189005, 32.08037023016842), + Offset(33.479552285723514, 34.49466952610868), + Offset(31.433148485215895, 36.03113891519555), + Offset(29.59414026524353, 36.98867178324178), + Offset(27.9979618017841, 37.56526083167866), + Offset(26.641805648145922, 37.893194841987366), + Offset(25.507968063242377, 38.06150889201586), + Offset(24.573834180645267, 38.13048882144999), + Offset(23.81624483131769, 38.14094176630335), + Offset(23.215183952788713, 38.12034219741291), + Offset(22.752030780839345, 38.086964642108946), + Offset(22.411155918517974, 38.0526002748509), + Offset(22.179310534316947, 38.024446151971595), + Offset(22.045378664375875, 38.00640765629513), + Offset(22.000102315338243, 38.000014616095385), + Offset(22.000000000000004, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.0, 10.0), + Offset(26.003041812884792, 10.000434881903109), + Offset(28.33048699130849, 10.537203766746376), + Offset(35.07127760842504, 15.2007493434278), + Offset(38.08471519360873, 22.726894381864838), + Offset(37.4571678054294, 28.34794602732309), + Offset(35.606361055189005, 32.08037023016842), + Offset(33.479552285723514, 34.49466952610868), + Offset(31.433148485215895, 36.03113891519555), + Offset(29.59414026524353, 36.98867178324178), + Offset(27.9979618017841, 37.56526083167866), + Offset(26.641805648145922, 37.893194841987366), + Offset(25.507968063242377, 38.06150889201586), + Offset(24.573834180645267, 38.13048882144999), + Offset(23.81624483131769, 38.14094176630335), + Offset(23.215183952788713, 38.12034219741291), + Offset(22.752030780839345, 38.086964642108946), + Offset(22.411155918517974, 38.0526002748509), + Offset(22.179310534316947, 38.024446151971595), + Offset(22.045378664375875, 38.00640765629513), + Offset(22.000102315338243, 38.000014616095385), + Offset(22.000000000000004, 38.0), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.996958092697728, 38.00043422098076), + Offset(23.612315433654864, 38.136820741489856), + Offset(15.835363679752177, 35.547238360668324), + Offset(10.835142987213462, 29.165901647620203), + Offset(9.863694019137322, 23.59397879929007), + Offset(10.595389722571403, 19.49262567449124), + Offset(11.961122338395, 16.57939189493825), + Offset(13.495458557937988, 14.531388217272726), + Offset(14.992797246058515, 13.097234362356085), + Offset(16.36368363741724, 12.096778906088034), + Offset(17.573772022023455, 11.402238533172993), + Offset(18.615128169522876, 10.923182521372642), + Offset(19.492582316574545, 10.595404301988683), + Offset(20.21694126737008, 10.373244457117734), + Offset(20.79972759004722, 10.22472299726445), + Offset(21.253700350603722, 10.12708256221043), + Offset(21.590562241264493, 10.064627393328175), + Offset(21.821016964503684, 10.02673864371602), + Offset(21.954642338436525, 10.006554675981915), + Offset(21.999897684768577, 10.000014616843131), + Offset(21.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(26.0, 38.0), + Offset(25.996958092697728, 38.00043422098076), + Offset(23.612315433654864, 38.136820741489856), + Offset(15.835363679752177, 35.547238360668324), + Offset(10.835142987213462, 29.165901647620203), + Offset(9.863694019137322, 23.59397879929007), + Offset(10.595389722571403, 19.49262567449124), + Offset(11.961122338395, 16.57939189493825), + Offset(13.495458557937988, 14.531388217272726), + Offset(14.992797246058515, 13.097234362356085), + Offset(16.36368363741724, 12.096778906088034), + Offset(17.573772022023455, 11.402238533172993), + Offset(18.615128169522876, 10.923182521372642), + Offset(19.492582316574545, 10.595404301988683), + Offset(20.21694126737008, 10.373244457117734), + Offset(20.79972759004722, 10.22472299726445), + Offset(21.253700350603722, 10.12708256221043), + Offset(21.590562241264493, 10.064627393328175), + Offset(21.821016964503684, 10.02673864371602), + Offset(21.954642338436525, 10.006554675981915), + Offset(21.999897684768577, 10.000014616843131), + Offset(21.999999999999996, 9.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.0, 38.0), + Offset(25.996958092697728, 38.00043422098076), + Offset(23.612315433654864, 38.136820741489856), + Offset(15.835363679752177, 35.547238360668324), + Offset(10.835142987213462, 29.165901647620203), + Offset(9.863694019137322, 23.59397879929007), + Offset(10.595389722571403, 19.49262567449124), + Offset(11.961122338395, 16.57939189493825), + Offset(13.495458557937988, 14.531388217272726), + Offset(14.992797246058515, 13.097234362356085), + Offset(16.36368363741724, 12.096778906088034), + Offset(17.573772022023455, 11.402238533172993), + Offset(18.615128169522876, 10.923182521372642), + Offset(19.492582316574545, 10.595404301988683), + Offset(20.21694126737008, 10.373244457117734), + Offset(20.79972759004722, 10.22472299726445), + Offset(21.253700350603722, 10.12708256221043), + Offset(21.590562241264493, 10.064627393328175), + Offset(21.821016964503684, 10.02673864371602), + Offset(21.954642338436525, 10.006554675981915), + Offset(21.999897684768577, 10.000014616843131), + Offset(21.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.996958187115208, 37.99956511809689), + Offset(19.66951300869151, 37.46279623325363), + Offset(12.928722391574958, 32.7992506565722), + Offset(9.915284806391266, 25.273105618135162), + Offset(10.54283219457061, 19.652053972676917), + Offset(12.393638944811, 15.919629769831582), + Offset(14.52044771427649, 13.50533047389132), + Offset(16.566851514784105, 11.968861084804454), + Offset(18.40585973475647, 11.011328216758224), + Offset(20.0020381982159, 10.43473916832134), + Offset(21.358194351854078, 10.106805158012639), + Offset(22.492031936757623, 9.938491107984142), + Offset(23.426165819354733, 9.86951117855001), + Offset(24.18375516868231, 9.859058233696645), + Offset(24.784816047211287, 9.879657802587094), + Offset(25.247969219160655, 9.913035357891056), + Offset(25.588844081482026, 9.947399725149106), + Offset(25.820689465683053, 9.975553848028413), + Offset(25.954621335624125, 9.993592343704867), + Offset(25.999897684661757, 9.999985383904608), + Offset(25.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(22.0, 38.0), + Offset(21.996958187115208, 37.99956511809689), + Offset(19.66951300869151, 37.46279623325363), + Offset(12.928722391574958, 32.7992506565722), + Offset(9.915284806391266, 25.273105618135162), + Offset(10.54283219457061, 19.652053972676917), + Offset(12.393638944811, 15.919629769831582), + Offset(14.52044771427649, 13.50533047389132), + Offset(16.566851514784105, 11.968861084804454), + Offset(18.40585973475647, 11.011328216758224), + Offset(20.0020381982159, 10.43473916832134), + Offset(21.358194351854078, 10.106805158012639), + Offset(22.492031936757623, 9.938491107984142), + Offset(23.426165819354733, 9.86951117855001), + Offset(24.18375516868231, 9.859058233696645), + Offset(24.784816047211287, 9.879657802587094), + Offset(25.247969219160655, 9.913035357891056), + Offset(25.588844081482026, 9.947399725149106), + Offset(25.820689465683053, 9.975553848028413), + Offset(25.954621335624125, 9.993592343704867), + Offset(25.999897684661757, 9.999985383904608), + Offset(25.999999999999996, 9.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.0, 38.0), + Offset(21.996958187115208, 37.99956511809689), + Offset(19.66951300869151, 37.46279623325363), + Offset(12.928722391574958, 32.7992506565722), + Offset(9.915284806391266, 25.273105618135162), + Offset(10.54283219457061, 19.652053972676917), + Offset(12.393638944811, 15.919629769831582), + Offset(14.52044771427649, 13.50533047389132), + Offset(16.566851514784105, 11.968861084804454), + Offset(18.40585973475647, 11.011328216758224), + Offset(20.0020381982159, 10.43473916832134), + Offset(21.358194351854078, 10.106805158012639), + Offset(22.492031936757623, 9.938491107984142), + Offset(23.426165819354733, 9.86951117855001), + Offset(24.18375516868231, 9.859058233696645), + Offset(24.784816047211287, 9.879657802587094), + Offset(25.247969219160655, 9.913035357891056), + Offset(25.588844081482026, 9.947399725149106), + Offset(25.820689465683053, 9.975553848028413), + Offset(25.954621335624125, 9.993592343704867), + Offset(25.999897684661757, 9.999985383904608), + Offset(25.999999999999996, 9.999999999999996), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ], + <Offset>[ + Offset(22.0, 10.0), + Offset(22.003041907302272, 9.999565779019244), + Offset(24.387684566345136, 9.863179258510144), + Offset(32.16463632024782, 12.452761639331678), + Offset(37.16485701278654, 18.834098352379797), + Offset(38.13630598086269, 24.406021200709937), + Offset(37.4046102774286, 28.50737432550876), + Offset(36.038877661605, 31.42060810506175), + Offset(34.50454144206201, 33.46861178272727), + Offset(33.007202753941485, 34.90276563764392), + Offset(31.63631636258276, 35.90322109391197), + Offset(30.426227977976545, 36.597761466827016), + Offset(29.384871830477124, 37.07681747862736), + Offset(28.507417683425455, 37.40459569801132), + Offset(27.78305873262992, 37.62675554288226), + Offset(27.20027240995278, 37.77527700273556), + Offset(26.746299649396278, 37.87291743778957), + Offset(26.409437758735507, 37.935372606671834), + Offset(26.178983035496316, 37.973261356283984), + Offset(26.045357661563475, 37.99344532401808), + Offset(26.000102315231423, 37.99998538315686), + Offset(26.000000000000004, 38.0), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/home_menu.g.dart b/packages/material_ui/lib/src/animated_icons/data/home_menu.g.dart new file mode 100644 index 000000000000..ea3e0642fec2 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/home_menu.g.dart @@ -0,0 +1,1685 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$home_menu = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(41.961602748986124, 36.01074618636323), + Offset(41.61012274691143, 36.52041429443682), + Offset(40.73074694162982, 37.67330625857439), + Offset(38.95361640675702, 39.597004636313734), + Offset(35.56126879040947, 42.254157368159426), + Offset(29.45217770721976, 44.90815523939214), + Offset(20.31425476555182, 45.290666483987), + Offset(11.333081294378362, 41.505038353879655), + Offset(5.639760014140567, 35.39204919789558), + Offset(3.1052482171005202, 29.50332128779013), + Offset(2.404116971035613, 24.703586146049652), + Offset(2.598975684223376, 21.02118219738726), + Offset(3.169562607312212, 18.258042555457294), + Offset(3.8457725409130035, 16.209986341924132), + Offset(4.491857018848872, 14.709706425920167), + Offset(5.042760982134354, 13.631765527094924), + Offset(5.471212748718976, 12.884189626374933), + Offset(5.770140143051716, 12.400456609777674), + Offset(5.943251827328922, 12.132774431470317), + Offset(5.999943741061273, 12.046959719775653), + Offset(5.9999999999999964, 12.046875000000004), + ]), + _PathCubicTo( + <Offset>[ + Offset(41.961602748986124, 36.01074618636323), + Offset(41.61012274691143, 36.52041429443682), + Offset(40.73074694162982, 37.67330625857439), + Offset(38.95361640675702, 39.597004636313734), + Offset(35.56126879040947, 42.254157368159426), + Offset(29.45217770721976, 44.90815523939214), + Offset(20.31425476555182, 45.290666483987), + Offset(11.333081294378362, 41.505038353879655), + Offset(5.639760014140567, 35.39204919789558), + Offset(3.1052482171005202, 29.50332128779013), + Offset(2.404116971035613, 24.703586146049652), + Offset(2.598975684223376, 21.02118219738726), + Offset(3.169562607312212, 18.258042555457294), + Offset(3.8457725409130035, 16.209986341924132), + Offset(4.491857018848872, 14.709706425920167), + Offset(5.042760982134354, 13.631765527094924), + Offset(5.471212748718976, 12.884189626374933), + Offset(5.770140143051716, 12.400456609777674), + Offset(5.943251827328922, 12.132774431470317), + Offset(5.999943741061273, 12.046959719775653), + Offset(5.9999999999999964, 12.046875000000004), + ], + <Offset>[ + Offset(41.97427088111201, 32.057641484479724), + Offset(41.73604180244445, 32.56929525585058), + Offset(41.121377806968006, 33.73952883824795), + Offset(39.81729775857236, 35.739382079848234), + Offset(37.1732602008072, 38.64463095068733), + Offset(32.08695737071304, 41.96110020250069), + Offset(23.93217304852415, 43.69759882586336), + Offset(15.2830121061518, 41.66392148019527), + Offset(9.234232376942689, 37.03733509970011), + Offset(6.058741440847815, 32.130881951771805), + Offset(4.6970485662914285, 27.923782097401467), + Offset(4.310954600883996, 24.584372545170346), + Offset(4.402665831815483, 22.013925101989418), + Offset(4.698295162721713, 20.070090284168355), + Offset(5.0503340012927325, 18.62318323378621), + Offset(5.38119368862878, 17.57037702974403), + Offset(5.652345022660288, 16.833162703639687), + Offset(5.847293032367528, 16.35282864404965), + Offset(5.962087095621268, 16.085854559458284), + Offset(5.999962347024592, 16.000084719731866), + Offset(5.9999999999999964, 16.000000000000004), + ], + <Offset>[ + Offset(41.97427088111201, 32.057641484479724), + Offset(41.73604180244445, 32.56929525585058), + Offset(41.121377806968006, 33.73952883824795), + Offset(39.81729775857236, 35.739382079848234), + Offset(37.1732602008072, 38.64463095068733), + Offset(32.08695737071304, 41.96110020250069), + Offset(23.93217304852415, 43.69759882586336), + Offset(15.2830121061518, 41.66392148019527), + Offset(9.234232376942689, 37.03733509970011), + Offset(6.058741440847815, 32.130881951771805), + Offset(4.6970485662914285, 27.923782097401467), + Offset(4.310954600883996, 24.584372545170346), + Offset(4.402665831815483, 22.013925101989418), + Offset(4.698295162721713, 20.070090284168355), + Offset(5.0503340012927325, 18.62318323378621), + Offset(5.38119368862878, 17.57037702974403), + Offset(5.652345022660288, 16.833162703639687), + Offset(5.847293032367528, 16.35282864404965), + Offset(5.962087095621268, 16.085854559458284), + Offset(5.999962347024592, 16.000084719731866), + Offset(5.9999999999999964, 16.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(41.97427088111201, 32.057641484479724), + Offset(41.73604180244445, 32.56929525585058), + Offset(41.121377806968006, 33.73952883824795), + Offset(39.81729775857236, 35.739382079848234), + Offset(37.1732602008072, 38.64463095068733), + Offset(32.08695737071304, 41.96110020250069), + Offset(23.93217304852415, 43.69759882586336), + Offset(15.2830121061518, 41.66392148019527), + Offset(9.234232376942689, 37.03733509970011), + Offset(6.058741440847815, 32.130881951771805), + Offset(4.6970485662914285, 27.923782097401467), + Offset(4.310954600883996, 24.584372545170346), + Offset(4.402665831815483, 22.013925101989418), + Offset(4.698295162721713, 20.070090284168355), + Offset(5.0503340012927325, 18.62318323378621), + Offset(5.38119368862878, 17.57037702974403), + Offset(5.652345022660288, 16.833162703639687), + Offset(5.847293032367528, 16.35282864404965), + Offset(5.962087095621268, 16.085854559458284), + Offset(5.999962347024592, 16.000084719731866), + Offset(5.9999999999999964, 16.000000000000004), + ], + <Offset>[ + Offset(5.9744557303626635, 31.942276360297758), + Offset(5.75430953010175, 31.42258575407952), + Offset(5.297570785497186, 30.182163171294633), + Offset(4.687011710760039, 27.874078385846133), + Offset(4.3023160669901515, 23.964677553231365), + Offset(5.248954188903129, 17.96690121163705), + Offset(9.424552952409986, 10.750232327965165), + Offset(16.729916149753336, 5.693010055981826), + Offset(24.217389364126984, 4.303484017106875), + Offset(29.987199029044564, 5.234248009029647), + Offset(34.02246940389847, 7.042697530328738), + Offset(36.75992915144614, 8.993860987913147), + Offset(38.606434160708815, 10.7844000851691), + Offset(39.851178494463575, 12.30640601283526), + Offset(40.68926904209655, 13.537290081412095), + Offset(41.24902334121195, 14.488365346885672), + Offset(41.61453462747447, 15.183641916442898), + Offset(41.840435984789025, 15.650218932651905), + Offset(41.96167845880022, 15.914327056906625), + Offset(41.99996234662585, 15.999915280445354), + Offset(42.0, 15.999999999999996), + ], + <Offset>[ + Offset(5.9744557303626635, 31.942276360297758), + Offset(5.75430953010175, 31.42258575407952), + Offset(5.297570785497186, 30.182163171294633), + Offset(4.687011710760039, 27.874078385846133), + Offset(4.3023160669901515, 23.964677553231365), + Offset(5.248954188903129, 17.96690121163705), + Offset(9.424552952409986, 10.750232327965165), + Offset(16.729916149753336, 5.693010055981826), + Offset(24.217389364126984, 4.303484017106875), + Offset(29.987199029044564, 5.234248009029647), + Offset(34.02246940389847, 7.042697530328738), + Offset(36.75992915144614, 8.993860987913147), + Offset(38.606434160708815, 10.7844000851691), + Offset(39.851178494463575, 12.30640601283526), + Offset(40.68926904209655, 13.537290081412095), + Offset(41.24902334121195, 14.488365346885672), + Offset(41.61453462747447, 15.183641916442898), + Offset(41.840435984789025, 15.650218932651905), + Offset(41.96167845880022, 15.914327056906625), + Offset(41.99996234662585, 15.999915280445354), + Offset(42.0, 15.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(5.9744557303626635, 31.942276360297758), + Offset(5.75430953010175, 31.42258575407952), + Offset(5.297570785497186, 30.182163171294633), + Offset(4.687011710760039, 27.874078385846133), + Offset(4.3023160669901515, 23.964677553231365), + Offset(5.248954188903129, 17.96690121163705), + Offset(9.424552952409986, 10.750232327965165), + Offset(16.729916149753336, 5.693010055981826), + Offset(24.217389364126984, 4.303484017106875), + Offset(29.987199029044564, 5.234248009029647), + Offset(34.02246940389847, 7.042697530328738), + Offset(36.75992915144614, 8.993860987913147), + Offset(38.606434160708815, 10.7844000851691), + Offset(39.851178494463575, 12.30640601283526), + Offset(40.68926904209655, 13.537290081412095), + Offset(41.24902334121195, 14.488365346885672), + Offset(41.61453462747447, 15.183641916442898), + Offset(41.840435984789025, 15.650218932651905), + Offset(41.96167845880022, 15.914327056906625), + Offset(41.99996234662585, 15.999915280445354), + Offset(42.0, 15.999999999999996), + ], + <Offset>[ + Offset(5.96181263407102, 35.88756860229611), + Offset(5.628639326457137, 35.36589625701638), + Offset(4.907711917916583, 34.10816632794454), + Offset(3.825037239086633, 31.72407718231862), + Offset(2.6935104103679173, 27.567070519285533), + Offset(2.6193815998436385, 20.90813202908801), + Offset(5.813784705570013, 12.34015163103323), + Offset(12.787791525354942, 5.534440927939556), + Offset(20.630020701646604, 2.66144966846797), + Offset(27.039542748427206, 2.6118801526843), + Offset(31.73406929400877, 3.8288656025961956), + Offset(35.05133359232832, 5.4377125182877375), + Offset(37.37576789909982, 7.035940231416685), + Offset(39.00034069997069, 8.453930734508514), + Offset(40.13189576910416, 9.631547417435115), + Offset(40.91125947405842, 10.557537661435477), + Offset(41.43376032245399, 11.242473133797246), + Offset(41.76343557153906, 11.705657910305364), + Offset(41.94288041435826, 11.969059340238793), + Offset(41.9999437774332, 12.054602780489052), + Offset(42.0, 12.054687499999996), + ], + <Offset>[ + Offset(5.96181263407102, 35.88756860229611), + Offset(5.628639326457137, 35.36589625701638), + Offset(4.907711917916583, 34.10816632794454), + Offset(3.825037239086633, 31.72407718231862), + Offset(2.6935104103679173, 27.567070519285533), + Offset(2.6193815998436385, 20.90813202908801), + Offset(5.813784705570013, 12.34015163103323), + Offset(12.787791525354942, 5.534440927939556), + Offset(20.630020701646604, 2.66144966846797), + Offset(27.039542748427206, 2.6118801526843), + Offset(31.73406929400877, 3.8288656025961956), + Offset(35.05133359232832, 5.4377125182877375), + Offset(37.37576789909982, 7.035940231416685), + Offset(39.00034069997069, 8.453930734508514), + Offset(40.13189576910416, 9.631547417435115), + Offset(40.91125947405842, 10.557537661435477), + Offset(41.43376032245399, 11.242473133797246), + Offset(41.76343557153906, 11.705657910305364), + Offset(41.94288041435826, 11.969059340238793), + Offset(41.9999437774332, 12.054602780489052), + Offset(42.0, 12.054687499999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(5.96181263407102, 35.88756860229611), + Offset(5.628639326457137, 35.36589625701638), + Offset(4.907711917916583, 34.10816632794454), + Offset(3.825037239086633, 31.72407718231862), + Offset(2.6935104103679173, 27.567070519285533), + Offset(2.6193815998436385, 20.90813202908801), + Offset(5.813784705570013, 12.34015163103323), + Offset(12.787791525354942, 5.534440927939556), + Offset(20.630020701646604, 2.66144966846797), + Offset(27.039542748427206, 2.6118801526843), + Offset(31.73406929400877, 3.8288656025961956), + Offset(35.05133359232832, 5.4377125182877375), + Offset(37.37576789909982, 7.035940231416685), + Offset(39.00034069997069, 8.453930734508514), + Offset(40.13189576910416, 9.631547417435115), + Offset(40.91125947405842, 10.557537661435477), + Offset(41.43376032245399, 11.242473133797246), + Offset(41.76343557153906, 11.705657910305364), + Offset(41.94288041435826, 11.969059340238793), + Offset(41.9999437774332, 12.054602780489052), + Offset(42.0, 12.054687499999996), + ], + <Offset>[ + Offset(41.961602748986124, 36.01074618636323), + Offset(41.61012274691143, 36.52041429443682), + Offset(40.73074694162982, 37.67330625857439), + Offset(38.95361640675702, 39.597004636313734), + Offset(35.56126879040947, 42.254157368159426), + Offset(29.45217770721976, 44.90815523939214), + Offset(20.31425476555182, 45.290666483987), + Offset(11.333081294378362, 41.505038353879655), + Offset(5.639760014140567, 35.39204919789558), + Offset(3.1052482171005202, 29.50332128779013), + Offset(2.404116971035613, 24.703586146049652), + Offset(2.598975684223376, 21.02118219738726), + Offset(3.169562607312212, 18.258042555457294), + Offset(3.8457725409130035, 16.209986341924132), + Offset(4.491857018848872, 14.709706425920167), + Offset(5.042760982134354, 13.631765527094924), + Offset(5.471212748718976, 12.884189626374933), + Offset(5.770140143051716, 12.400456609777674), + Offset(5.943251827328922, 12.132774431470317), + Offset(5.999943741061273, 12.046959719775653), + Offset(5.9999999999999964, 12.046875000000004), + ], + <Offset>[ + Offset(41.961602748986124, 36.01074618636323), + Offset(41.61012274691143, 36.52041429443682), + Offset(40.73074694162982, 37.67330625857439), + Offset(38.95361640675702, 39.597004636313734), + Offset(35.56126879040947, 42.254157368159426), + Offset(29.45217770721976, 44.90815523939214), + Offset(20.31425476555182, 45.290666483987), + Offset(11.333081294378362, 41.505038353879655), + Offset(5.639760014140567, 35.39204919789558), + Offset(3.1052482171005202, 29.50332128779013), + Offset(2.404116971035613, 24.703586146049652), + Offset(2.598975684223376, 21.02118219738726), + Offset(3.169562607312212, 18.258042555457294), + Offset(3.8457725409130035, 16.209986341924132), + Offset(4.491857018848872, 14.709706425920167), + Offset(5.042760982134354, 13.631765527094924), + Offset(5.471212748718976, 12.884189626374933), + Offset(5.770140143051716, 12.400456609777674), + Offset(5.943251827328922, 12.132774431470317), + Offset(5.999943741061273, 12.046959719775653), + Offset(5.9999999999999964, 12.046875000000004), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(38.005915505011714, 22.044873132716152), + Offset(38.49527667048537, 22.460937675484836), + Offset(39.29605291379161, 23.509087727219047), + Offset(40.090934628235715, 25.55307837508503), + Offset(40.245484311889584, 29.064745081245363), + Offset(38.450753318138695, 34.23676198345152), + Offset(33.0728907045072, 39.641976680762035), + Offset(25.27493194621109, 42.06583927008458), + Offset(18.326968788774145, 41.199323151695744), + Offset(13.530028647165079, 38.7776757262709), + Offset(10.497349834922744, 36.06973233007009), + Offset(8.641652255677663, 33.59797658699316), + Offset(7.521978336487795, 31.514971860015343), + Offset(6.854874126980906, 29.834780098541096), + Offset(6.463082099174432, 28.52288741178727), + Offset(6.237308044978324, 27.533663044350465), + Offset(6.1105452413260615, 26.82265981608807), + Offset(6.04246239664468, 26.35092390861118), + Offset(6.009733624107838, 26.085741049230215), + Offset(6.000009413493068, 26.000084719621103), + Offset(6.0, 26.000000000000004), + ]), + _PathCubicTo( + <Offset>[ + Offset(38.005915505011714, 22.044873132716152), + Offset(38.49527667048537, 22.460937675484836), + Offset(39.29605291379161, 23.509087727219047), + Offset(40.090934628235715, 25.55307837508503), + Offset(40.245484311889584, 29.064745081245363), + Offset(38.450753318138695, 34.23676198345152), + Offset(33.0728907045072, 39.641976680762035), + Offset(25.27493194621109, 42.06583927008458), + Offset(18.326968788774145, 41.199323151695744), + Offset(13.530028647165079, 38.7776757262709), + Offset(10.497349834922744, 36.06973233007009), + Offset(8.641652255677663, 33.59797658699316), + Offset(7.521978336487795, 31.514971860015343), + Offset(6.854874126980906, 29.834780098541096), + Offset(6.463082099174432, 28.52288741178727), + Offset(6.237308044978324, 27.533663044350465), + Offset(6.1105452413260615, 26.82265981608807), + Offset(6.04246239664468, 26.35092390861118), + Offset(6.009733624107838, 26.085741049230215), + Offset(6.000009413493068, 26.000084719621103), + Offset(6.0, 26.000000000000004), + ], + <Offset>[ + Offset(10.006902842119626, 21.955147406089473), + Offset(9.632135496378087, 21.541092072032644), + Offset(9.099209938092097, 20.510489270395304), + Offset(8.782988004431196, 18.54355650849138), + Offset(9.385621621161075, 15.282927792774965), + Offset(12.215268791957268, 10.781237663014041), + Offset(18.587927795299567, 6.746065530872357), + Offset(26.721835989812625, 6.094927845871137), + Offset(33.31012577595844, 8.46547206910251), + Offset(37.45848623536183, 11.881041783528742), + Offset(39.822770672529785, 15.188647762997359), + Offset(41.090626806239804, 18.007465029735965), + Offset(41.72574666538112, 20.285446843195025), + Offset(42.007757458722764, 22.071095827208), + Offset(42.102017139978244, 23.436994259413154), + Offset(42.10513769756149, 24.451651361492107), + Offset(42.07273484614025, 25.17313902889128), + Offset(42.03560534906618, 25.64831419721343), + Offset(42.00932498728679, 25.914213546678557), + Offset(42.000009413094325, 25.99991528033459), + Offset(42.0, 25.999999999999996), + ], + <Offset>[ + Offset(10.006902842119626, 21.955147406089473), + Offset(9.632135496378087, 21.541092072032644), + Offset(9.099209938092097, 20.510489270395304), + Offset(8.782988004431196, 18.54355650849138), + Offset(9.385621621161075, 15.282927792774965), + Offset(12.215268791957268, 10.781237663014041), + Offset(18.587927795299567, 6.746065530872357), + Offset(26.721835989812625, 6.094927845871137), + Offset(33.31012577595844, 8.46547206910251), + Offset(37.45848623536183, 11.881041783528742), + Offset(39.822770672529785, 15.188647762997359), + Offset(41.090626806239804, 18.007465029735965), + Offset(41.72574666538112, 20.285446843195025), + Offset(42.007757458722764, 22.071095827208), + Offset(42.102017139978244, 23.436994259413154), + Offset(42.10513769756149, 24.451651361492107), + Offset(42.07273484614025, 25.17313902889128), + Offset(42.03560534906618, 25.64831419721343), + Offset(42.00932498728679, 25.914213546678557), + Offset(42.000009413094325, 25.99991528033459), + Offset(42.0, 25.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.006902842119626, 21.955147406089473), + Offset(9.632135496378087, 21.541092072032644), + Offset(9.099209938092097, 20.510489270395304), + Offset(8.782988004431196, 18.54355650849138), + Offset(9.385621621161075, 15.282927792774965), + Offset(12.215268791957268, 10.781237663014041), + Offset(18.587927795299567, 6.746065530872357), + Offset(26.721835989812625, 6.094927845871137), + Offset(33.31012577595844, 8.46547206910251), + Offset(37.45848623536183, 11.881041783528742), + Offset(39.822770672529785, 15.188647762997359), + Offset(41.090626806239804, 18.007465029735965), + Offset(41.72574666538112, 20.285446843195025), + Offset(42.007757458722764, 22.071095827208), + Offset(42.102017139978244, 23.436994259413154), + Offset(42.10513769756149, 24.451651361492107), + Offset(42.07273484614025, 25.17313902889128), + Offset(42.03560534906618, 25.64831419721343), + Offset(42.00932498728679, 25.914213546678557), + Offset(42.000009413094325, 25.99991528033459), + Offset(42.0, 25.999999999999996), + ], + <Offset>[ + Offset(9.991126102962665, 26.878296385119686), + Offset(9.478546725862362, 26.36043822046848), + Offset(8.639473946578361, 25.14017735498227), + Offset(7.81032131475563, 22.887960789223563), + Offset(7.650884598609569, 19.167302777578165), + Offset(9.48709191219624, 13.832759399890797), + Offset(14.921172139995065, 8.360637610125773), + Offset(22.72506805378891, 5.934160729915408), + Offset(29.673031211225855, 6.800676848304256), + Offset(34.46997135283492, 9.222324273729102), + Offset(37.502650165077256, 11.930267669929911), + Offset(39.35834774432234, 14.402023413006837), + Offset(40.47802166351221, 16.485028139984657), + Offset(41.1451258730191, 18.165219901458904), + Offset(41.53691790082557, 19.47711258821273), + Offset(41.76269195502168, 20.466336955649535), + Offset(41.889454758673935, 21.17734018391193), + Offset(41.95753760335532, 21.64907609138882), + Offset(41.99026637589216, 21.914258950769785), + Offset(41.99999058650693, 21.999915280378897), + Offset(42.0, 21.999999999999996), + ], + <Offset>[ + Offset(9.991126102962665, 26.878296385119686), + Offset(9.478546725862362, 26.36043822046848), + Offset(8.639473946578361, 25.14017735498227), + Offset(7.81032131475563, 22.887960789223563), + Offset(7.650884598609569, 19.167302777578165), + Offset(9.48709191219624, 13.832759399890797), + Offset(14.921172139995065, 8.360637610125773), + Offset(22.72506805378891, 5.934160729915408), + Offset(29.673031211225855, 6.800676848304256), + Offset(34.46997135283492, 9.222324273729102), + Offset(37.502650165077256, 11.930267669929911), + Offset(39.35834774432234, 14.402023413006837), + Offset(40.47802166351221, 16.485028139984657), + Offset(41.1451258730191, 18.165219901458904), + Offset(41.53691790082557, 19.47711258821273), + Offset(41.76269195502168, 20.466336955649535), + Offset(41.889454758673935, 21.17734018391193), + Offset(41.95753760335532, 21.64907609138882), + Offset(41.99026637589216, 21.914258950769785), + Offset(41.99999058650693, 21.999915280378897), + Offset(42.0, 21.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.991126102962665, 26.878296385119686), + Offset(9.478546725862362, 26.36043822046848), + Offset(8.639473946578361, 25.14017735498227), + Offset(7.81032131475563, 22.887960789223563), + Offset(7.650884598609569, 19.167302777578165), + Offset(9.48709191219624, 13.832759399890797), + Offset(14.921172139995065, 8.360637610125773), + Offset(22.72506805378891, 5.934160729915408), + Offset(29.673031211225855, 6.800676848304256), + Offset(34.46997135283492, 9.222324273729102), + Offset(37.502650165077256, 11.930267669929911), + Offset(39.35834774432234, 14.402023413006837), + Offset(40.47802166351221, 16.485028139984657), + Offset(41.1451258730191, 18.165219901458904), + Offset(41.53691790082557, 19.47711258821273), + Offset(41.76269195502168, 20.466336955649535), + Offset(41.889454758673935, 21.17734018391193), + Offset(41.95753760335532, 21.64907609138882), + Offset(41.99026637589216, 21.914258950769785), + Offset(41.99999058650693, 21.999915280378897), + Offset(42.0, 21.999999999999996), + ], + <Offset>[ + Offset(37.99013876585475, 26.968022111746365), + Offset(38.34168789996964, 27.280283823920673), + Offset(38.83631692227788, 28.138775811806013), + Offset(39.118267938560145, 29.897482655817214), + Offset(38.51074728933807, 32.949120066048565), + Offset(35.722576438377665, 37.28828372032828), + Offset(29.406135049202696, 41.25654876001545), + Offset(21.278164010187375, 41.90507215412886), + Offset(14.689874224041562, 39.53452793089749), + Offset(10.541513764638172, 36.118958216471256), + Offset(8.177229327470219, 32.81135223700264), + Offset(6.909373193760196, 29.992534970264035), + Offset(6.274253334618873, 27.714553156804975), + Offset(5.992242541277232, 25.928904172792), + Offset(5.897982860021752, 24.563005740586846), + Offset(5.894862302438508, 23.548348638507893), + Offset(5.927265153859754, 22.82686097110872), + Offset(5.964394650933819, 22.35168580278657), + Offset(5.990675012713211, 22.085786453321443), + Offset(5.999990586905675, 22.00008471966541), + Offset(6.0, 22.000000000000004), + ], + <Offset>[ + Offset(37.99013876585475, 26.968022111746365), + Offset(38.34168789996964, 27.280283823920673), + Offset(38.83631692227788, 28.138775811806013), + Offset(39.118267938560145, 29.897482655817214), + Offset(38.51074728933807, 32.949120066048565), + Offset(35.722576438377665, 37.28828372032828), + Offset(29.406135049202696, 41.25654876001545), + Offset(21.278164010187375, 41.90507215412886), + Offset(14.689874224041562, 39.53452793089749), + Offset(10.541513764638172, 36.118958216471256), + Offset(8.177229327470219, 32.81135223700264), + Offset(6.909373193760196, 29.992534970264035), + Offset(6.274253334618873, 27.714553156804975), + Offset(5.992242541277232, 25.928904172792), + Offset(5.897982860021752, 24.563005740586846), + Offset(5.894862302438508, 23.548348638507893), + Offset(5.927265153859754, 22.82686097110872), + Offset(5.964394650933819, 22.35168580278657), + Offset(5.990675012713211, 22.085786453321443), + Offset(5.999990586905675, 22.00008471966541), + Offset(6.0, 22.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(37.99013876585475, 26.968022111746365), + Offset(38.34168789996964, 27.280283823920673), + Offset(38.83631692227788, 28.138775811806013), + Offset(39.118267938560145, 29.897482655817214), + Offset(38.51074728933807, 32.949120066048565), + Offset(35.722576438377665, 37.28828372032828), + Offset(29.406135049202696, 41.25654876001545), + Offset(21.278164010187375, 41.90507215412886), + Offset(14.689874224041562, 39.53452793089749), + Offset(10.541513764638172, 36.118958216471256), + Offset(8.177229327470219, 32.81135223700264), + Offset(6.909373193760196, 29.992534970264035), + Offset(6.274253334618873, 27.714553156804975), + Offset(5.992242541277232, 25.928904172792), + Offset(5.897982860021752, 24.563005740586846), + Offset(5.894862302438508, 23.548348638507893), + Offset(5.927265153859754, 22.82686097110872), + Offset(5.964394650933819, 22.35168580278657), + Offset(5.990675012713211, 22.085786453321443), + Offset(5.999990586905675, 22.00008471966541), + Offset(6.0, 22.000000000000004), + ], + <Offset>[ + Offset(38.005915505011714, 22.044873132716152), + Offset(38.49527667048537, 22.460937675484836), + Offset(39.29605291379161, 23.509087727219047), + Offset(40.090934628235715, 25.55307837508503), + Offset(40.245484311889584, 29.064745081245363), + Offset(38.450753318138695, 34.23676198345152), + Offset(33.0728907045072, 39.641976680762035), + Offset(25.27493194621109, 42.06583927008458), + Offset(18.326968788774145, 41.199323151695744), + Offset(13.530028647165079, 38.7776757262709), + Offset(10.497349834922744, 36.06973233007009), + Offset(8.641652255677663, 33.59797658699316), + Offset(7.521978336487795, 31.514971860015343), + Offset(6.854874126980906, 29.834780098541096), + Offset(6.463082099174432, 28.52288741178727), + Offset(6.237308044978324, 27.533663044350465), + Offset(6.1105452413260615, 26.82265981608807), + Offset(6.04246239664468, 26.35092390861118), + Offset(6.009733624107838, 26.085741049230215), + Offset(6.000009413493068, 26.000084719621103), + Offset(6.0, 26.000000000000004), + ], + <Offset>[ + Offset(38.005915505011714, 22.044873132716152), + Offset(38.49527667048537, 22.460937675484836), + Offset(39.29605291379161, 23.509087727219047), + Offset(40.090934628235715, 25.55307837508503), + Offset(40.245484311889584, 29.064745081245363), + Offset(38.450753318138695, 34.23676198345152), + Offset(33.0728907045072, 39.641976680762035), + Offset(25.27493194621109, 42.06583927008458), + Offset(18.326968788774145, 41.199323151695744), + Offset(13.530028647165079, 38.7776757262709), + Offset(10.497349834922744, 36.06973233007009), + Offset(8.641652255677663, 33.59797658699316), + Offset(7.521978336487795, 31.514971860015343), + Offset(6.854874126980906, 29.834780098541096), + Offset(6.463082099174432, 28.52288741178727), + Offset(6.237308044978324, 27.533663044350465), + Offset(6.1105452413260615, 26.82265981608807), + Offset(6.04246239664468, 26.35092390861118), + Offset(6.009733624107838, 26.085741049230215), + Offset(6.000009413493068, 26.000084719621103), + Offset(6.0, 26.000000000000004), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(20.176179763180997, 21.987735903833876), + Offset(20.55193618727129, 21.88909752977205), + Offset(21.227655124257208, 21.71486474263169), + Offset(22.13567600845661, 21.53308372554798), + Offset(23.226514419921436, 21.464181986077985), + Offset(24.4102783754196, 21.684041595886637), + Offset(25.440842849032627, 22.309300738195095), + Offset(26.03642252837242, 23.134721894037483), + Offset(26.21244881576038, 23.97183703919535), + Offset(26.123327546035636, 24.622256415770117), + Offset(25.931014482213666, 25.0802342265462), + Offset(25.71921080582189, 25.392852152910002), + Offset(25.5230666408985, 25.604989383797584), + Offset(25.3554718074648, 25.748833638139686), + Offset(25.21948374988762, 25.846234627148135), + Offset(25.114174554400172, 25.911632280813432), + Offset(25.037072414338468, 25.95453414453137), + Offset(24.98527999526863, 25.9811477091939), + Offset(24.955944954391757, 25.995467904751596), + Offset(24.946435804885212, 25.999995545483056), + Offset(24.946426391602, 26.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(20.176179763180997, 21.987735903833876), + Offset(20.55193618727129, 21.88909752977205), + Offset(21.227655124257208, 21.71486474263169), + Offset(22.13567600845661, 21.53308372554798), + Offset(23.226514419921436, 21.464181986077985), + Offset(24.4102783754196, 21.684041595886637), + Offset(25.440842849032627, 22.309300738195095), + Offset(26.03642252837242, 23.134721894037483), + Offset(26.21244881576038, 23.97183703919535), + Offset(26.123327546035636, 24.622256415770117), + Offset(25.931014482213666, 25.0802342265462), + Offset(25.71921080582189, 25.392852152910002), + Offset(25.5230666408985, 25.604989383797584), + Offset(25.3554718074648, 25.748833638139686), + Offset(25.21948374988762, 25.846234627148135), + Offset(25.114174554400172, 25.911632280813432), + Offset(25.037072414338468, 25.95453414453137), + Offset(24.98527999526863, 25.9811477091939), + Offset(24.955944954391757, 25.995467904751596), + Offset(24.946435804885212, 25.999995545483056), + Offset(24.946426391602, 26.0), + ], + <Offset>[ + Offset(10.006902842149625, 21.95514740608957), + Offset(9.632135496348102, 21.541092072031688), + Offset(9.099209938111999, 20.510489270397283), + Offset(8.78298800447023, 18.543556508500117), + Offset(9.385621621133684, 15.282927792762731), + Offset(12.215268791942359, 10.781237663000711), + Offset(18.58792779526773, 6.746065530800053), + Offset(26.721835989812707, 6.094927845869137), + Offset(33.31012577595927, 8.465472069100691), + Offset(37.458486235363154, 11.881041783527248), + Offset(39.82277067253141, 15.1886477629962), + Offset(41.09062680624161, 18.007465029735098), + Offset(41.72574666538303, 20.2854468431944), + Offset(42.007757458724726, 22.07109582720757), + Offset(42.10201713998023, 23.43699425941287), + Offset(42.105137697563485, 24.451651361491937), + Offset(42.07273484614225, 25.173139028891192), + Offset(42.03560534906818, 25.64831419721339), + Offset(42.00932498728879, 25.91421354667855), + Offset(42.00000941309632, 25.99991528033459), + Offset(42.000000000002004, 25.999999999999996), + ], + <Offset>[ + Offset(10.006902842149625, 21.95514740608957), + Offset(9.632135496348102, 21.541092072031688), + Offset(9.099209938111999, 20.510489270397283), + Offset(8.78298800447023, 18.543556508500117), + Offset(9.385621621133684, 15.282927792762731), + Offset(12.215268791942359, 10.781237663000711), + Offset(18.58792779526773, 6.746065530800053), + Offset(26.721835989812707, 6.094927845869137), + Offset(33.31012577595927, 8.465472069100691), + Offset(37.458486235363154, 11.881041783527248), + Offset(39.82277067253141, 15.1886477629962), + Offset(41.09062680624161, 18.007465029735098), + Offset(41.72574666538303, 20.2854468431944), + Offset(42.007757458724726, 22.07109582720757), + Offset(42.10201713998023, 23.43699425941287), + Offset(42.105137697563485, 24.451651361491937), + Offset(42.07273484614225, 25.173139028891192), + Offset(42.03560534906818, 25.64831419721339), + Offset(42.00932498728879, 25.91421354667855), + Offset(42.00000941309632, 25.99991528033459), + Offset(42.000000000002004, 25.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.006902842149625, 21.95514740608957), + Offset(9.632135496348102, 21.541092072031688), + Offset(9.099209938111999, 20.510489270397283), + Offset(8.78298800447023, 18.543556508500117), + Offset(9.385621621133684, 15.282927792762731), + Offset(12.215268791942359, 10.781237663000711), + Offset(18.58792779526773, 6.746065530800053), + Offset(26.721835989812707, 6.094927845869137), + Offset(33.31012577595927, 8.465472069100691), + Offset(37.458486235363154, 11.881041783527248), + Offset(39.82277067253141, 15.1886477629962), + Offset(41.09062680624161, 18.007465029735098), + Offset(41.72574666538303, 20.2854468431944), + Offset(42.007757458724726, 22.07109582720757), + Offset(42.10201713998023, 23.43699425941287), + Offset(42.105137697563485, 24.451651361491937), + Offset(42.07273484614225, 25.173139028891192), + Offset(42.03560534906818, 25.64831419721339), + Offset(42.00932498728879, 25.91421354667855), + Offset(42.00000941309632, 25.99991528033459), + Offset(42.000000000002004, 25.999999999999996), + ], + <Offset>[ + Offset(9.950103066904006, 39.679580365751406), + Offset(9.115564488520917, 37.75018397768418), + Offset(7.7454464103988485, 34.143319828138715), + Offset(6.441070390954248, 29.003703867586243), + Offset(6.213866824197122, 22.385033086525855), + Offset(8.625212439492824, 14.796789248890494), + Offset(14.838843583186215, 8.396889109087798), + Offset(22.72506805378899, 5.934160729913408), + Offset(29.67303121122669, 6.800676848302437), + Offset(34.469971352836254, 9.222324273727608), + Offset(37.50265016507888, 11.930267669928751), + Offset(39.35834774432414, 14.40202341300597), + Offset(40.478021663514106, 16.48502813998403), + Offset(41.145125873021044, 18.165219901458475), + Offset(41.53691790082755, 19.47711258821245), + Offset(41.76269195502367, 20.46633695564936), + Offset(41.88945475867594, 21.177340183911838), + Offset(41.95753760335732, 21.649076091388782), + Offset(41.990266375894166, 21.914258950769778), + Offset(41.99999058650893, 21.999915280378897), + Offset(42.000000000002004, 21.999999999999996), + ], + <Offset>[ + Offset(9.950103066904006, 39.679580365751406), + Offset(9.115564488520917, 37.75018397768418), + Offset(7.7454464103988485, 34.143319828138715), + Offset(6.441070390954248, 29.003703867586243), + Offset(6.213866824197122, 22.385033086525855), + Offset(8.625212439492824, 14.796789248890494), + Offset(14.838843583186215, 8.396889109087798), + Offset(22.72506805378899, 5.934160729913408), + Offset(29.67303121122669, 6.800676848302437), + Offset(34.469971352836254, 9.222324273727608), + Offset(37.50265016507888, 11.930267669928751), + Offset(39.35834774432414, 14.40202341300597), + Offset(40.478021663514106, 16.48502813998403), + Offset(41.145125873021044, 18.165219901458475), + Offset(41.53691790082755, 19.47711258821245), + Offset(41.76269195502367, 20.46633695564936), + Offset(41.88945475867594, 21.177340183911838), + Offset(41.95753760335732, 21.649076091388782), + Offset(41.990266375894166, 21.914258950769778), + Offset(41.99999058650893, 21.999915280378897), + Offset(42.000000000002004, 21.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.950103066904006, 39.679580365751406), + Offset(9.115564488520917, 37.75018397768418), + Offset(7.7454464103988485, 34.143319828138715), + Offset(6.441070390954248, 29.003703867586243), + Offset(6.213866824197122, 22.385033086525855), + Offset(8.625212439492824, 14.796789248890494), + Offset(14.838843583186215, 8.396889109087798), + Offset(22.72506805378899, 5.934160729913408), + Offset(29.67303121122669, 6.800676848302437), + Offset(34.469971352836254, 9.222324273727608), + Offset(37.50265016507888, 11.930267669928751), + Offset(39.35834774432414, 14.40202341300597), + Offset(40.478021663514106, 16.48502813998403), + Offset(41.145125873021044, 18.165219901458475), + Offset(41.53691790082755, 19.47711258821245), + Offset(41.76269195502367, 20.46633695564936), + Offset(41.88945475867594, 21.177340183911838), + Offset(41.95753760335732, 21.649076091388782), + Offset(41.990266375894166, 21.914258950769778), + Offset(41.99999058650893, 21.999915280378897), + Offset(42.000000000002004, 21.999999999999996), + ], + <Offset>[ + Offset(20.119379987935375, 39.71216886349571), + Offset(20.035365179444106, 38.098189435424544), + Offset(19.873891596544055, 35.347695300373125), + Offset(19.793758394940628, 31.993231084634104), + Offset(20.054759622984875, 28.566287279841106), + Offset(20.820222022970064, 25.699593181776418), + Offset(21.69175863695111, 23.96012431648284), + Offset(22.0396545923487, 22.973954778081758), + Offset(22.575354251027797, 22.307041818397096), + Offset(23.13481266350873, 21.963538905970477), + Offset(23.61089397476114, 21.82185413347875), + Offset(23.986931743904425, 21.787410536180875), + Offset(24.275341639029573, 21.804570680587215), + Offset(24.49284022176112, 21.842957712390593), + Offset(24.65438451073494, 21.88635295594771), + Offset(24.771728811860356, 21.926317874970856), + Offset(24.853792326872156, 21.958735299552014), + Offset(24.90721224955777, 21.981909603369285), + Offset(24.93688634299713, 21.995513308842824), + Offset(24.946416978297822, 21.99999554552736), + Offset(24.946426391602, 22.0), + ], + <Offset>[ + Offset(20.119379987935375, 39.71216886349571), + Offset(20.035365179444106, 38.098189435424544), + Offset(19.873891596544055, 35.347695300373125), + Offset(19.793758394940628, 31.993231084634104), + Offset(20.054759622984875, 28.566287279841106), + Offset(20.820222022970064, 25.699593181776418), + Offset(21.69175863695111, 23.96012431648284), + Offset(22.0396545923487, 22.973954778081758), + Offset(22.575354251027797, 22.307041818397096), + Offset(23.13481266350873, 21.963538905970477), + Offset(23.61089397476114, 21.82185413347875), + Offset(23.986931743904425, 21.787410536180875), + Offset(24.275341639029573, 21.804570680587215), + Offset(24.49284022176112, 21.842957712390593), + Offset(24.65438451073494, 21.88635295594771), + Offset(24.771728811860356, 21.926317874970856), + Offset(24.853792326872156, 21.958735299552014), + Offset(24.90721224955777, 21.981909603369285), + Offset(24.93688634299713, 21.995513308842824), + Offset(24.946416978297822, 21.99999554552736), + Offset(24.946426391602, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(20.119379987935375, 39.71216886349571), + Offset(20.035365179444106, 38.098189435424544), + Offset(19.873891596544055, 35.347695300373125), + Offset(19.793758394940628, 31.993231084634104), + Offset(20.054759622984875, 28.566287279841106), + Offset(20.820222022970064, 25.699593181776418), + Offset(21.69175863695111, 23.96012431648284), + Offset(22.0396545923487, 22.973954778081758), + Offset(22.575354251027797, 22.307041818397096), + Offset(23.13481266350873, 21.963538905970477), + Offset(23.61089397476114, 21.82185413347875), + Offset(23.986931743904425, 21.787410536180875), + Offset(24.275341639029573, 21.804570680587215), + Offset(24.49284022176112, 21.842957712390593), + Offset(24.65438451073494, 21.88635295594771), + Offset(24.771728811860356, 21.926317874970856), + Offset(24.853792326872156, 21.958735299552014), + Offset(24.90721224955777, 21.981909603369285), + Offset(24.93688634299713, 21.995513308842824), + Offset(24.946416978297822, 21.99999554552736), + Offset(24.946426391602, 22.0), + ], + <Offset>[ + Offset(20.176179763180997, 21.987735903833876), + Offset(20.55193618727129, 21.88909752977205), + Offset(21.227655124257208, 21.71486474263169), + Offset(22.13567600845661, 21.53308372554798), + Offset(23.226514419921436, 21.464181986077985), + Offset(24.410278375419598, 21.684041595886633), + Offset(25.440842849032627, 22.309300738195095), + Offset(26.03642252837242, 23.134721894037487), + Offset(26.21244881576038, 23.971837039195353), + Offset(26.123327546035632, 24.622256415770117), + Offset(25.931014482213666, 25.0802342265462), + Offset(25.71921080582189, 25.392852152910002), + Offset(25.5230666408985, 25.604989383797584), + Offset(25.3554718074648, 25.748833638139686), + Offset(25.21948374988762, 25.846234627148135), + Offset(25.114174554400172, 25.911632280813432), + Offset(25.037072414338468, 25.95453414453137), + Offset(24.98527999526863, 25.9811477091939), + Offset(24.955944954391757, 25.995467904751596), + Offset(24.946435804885212, 25.999995545483056), + Offset(24.946426391602, 26.0), + ], + <Offset>[ + Offset(20.176179763180997, 21.987735903833876), + Offset(20.55193618727129, 21.88909752977205), + Offset(21.227655124257208, 21.71486474263169), + Offset(22.13567600845661, 21.53308372554798), + Offset(23.226514419921436, 21.464181986077985), + Offset(24.410278375419598, 21.684041595886633), + Offset(25.440842849032627, 22.309300738195095), + Offset(26.03642252837242, 23.134721894037487), + Offset(26.21244881576038, 23.971837039195353), + Offset(26.123327546035632, 24.622256415770117), + Offset(25.931014482213666, 25.0802342265462), + Offset(25.71921080582189, 25.392852152910002), + Offset(25.5230666408985, 25.604989383797584), + Offset(25.3554718074648, 25.748833638139686), + Offset(25.21948374988762, 25.846234627148135), + Offset(25.114174554400172, 25.911632280813432), + Offset(25.037072414338468, 25.95453414453137), + Offset(24.98527999526863, 25.9811477091939), + Offset(24.955944954391757, 25.995467904751596), + Offset(24.946435804885212, 25.999995545483056), + Offset(24.946426391602, 26.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(27.83663858395034, 22.012284634971753), + Offset(27.57547597959216, 22.112932217745428), + Offset(27.1676077276265, 22.304712254982665), + Offset(26.73824662421029, 22.56355115802843), + Offset(26.404591513129226, 22.883490887942344), + Offset(26.255743734676358, 23.33395805057893), + Offset(26.21997565073384, 24.078741473347776), + Offset(25.9603454076513, 25.02604522191824), + Offset(25.424645748972203, 25.692958181602904), + Offset(24.865187336491275, 26.036461094029523), + Offset(24.38910602523886, 26.17814586652125), + Offset(24.013068256095575, 26.212589463819125), + Offset(23.724658360970427, 26.195429319412785), + Offset(23.50715977823888, 26.157042287609407), + Offset(23.34561548926506, 26.11364704405229), + Offset(23.228271188139644, 26.073682125029144), + Offset(23.14620767312784, 26.041264700447986), + Offset(23.092787750442234, 26.018090396630715), + Offset(23.063113657002866, 26.004486691157176), + Offset(23.053583021702178, 26.00000445447264), + Offset(23.053573608398, 26.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(27.83663858395034, 22.012284634971753), + Offset(27.57547597959216, 22.112932217745428), + Offset(27.1676077276265, 22.304712254982665), + Offset(26.73824662421029, 22.56355115802843), + Offset(26.404591513129226, 22.883490887942344), + Offset(26.255743734676358, 23.33395805057893), + Offset(26.21997565073384, 24.078741473347776), + Offset(25.9603454076513, 25.02604522191824), + Offset(25.424645748972203, 25.692958181602904), + Offset(24.865187336491275, 26.036461094029523), + Offset(24.38910602523886, 26.17814586652125), + Offset(24.013068256095575, 26.212589463819125), + Offset(23.724658360970427, 26.195429319412785), + Offset(23.50715977823888, 26.157042287609407), + Offset(23.34561548926506, 26.11364704405229), + Offset(23.228271188139644, 26.073682125029144), + Offset(23.14620767312784, 26.041264700447986), + Offset(23.092787750442234, 26.018090396630715), + Offset(23.063113657002866, 26.004486691157176), + Offset(23.053583021702178, 26.00000445447264), + Offset(23.053573608398, 26.0), + ], + <Offset>[ + Offset(38.00591550498171, 22.044873132716056), + Offset(38.49527667051535, 22.460937675485788), + Offset(39.29605291377171, 23.50908772721707), + Offset(40.09093462819668, 25.55307837507629), + Offset(40.245484311916975, 29.0647450812576), + Offset(38.4507533181536, 34.23676198346485), + Offset(33.07289070449873, 39.64197668074282), + Offset(25.27493194621101, 42.065839270086585), + Offset(18.32696878877331, 41.19932315169756), + Offset(13.530028647163753, 38.77767572627239), + Offset(10.497349834921115, 36.06973233007125), + Offset(8.641652255675858, 33.59797658699403), + Offset(7.521978336485894, 31.51497186001597), + Offset(6.854874126978952, 29.834780098541525), + Offset(6.463082099172453, 28.52288741178755), + Offset(6.237308044976331, 27.53366304435064), + Offset(6.110545241324058, 26.822659816088162), + Offset(6.042462396642684, 26.350923908611218), + Offset(6.009733624105834, 26.085741049230222), + Offset(6.0000094134910675, 26.000084719621103), + Offset(5.999999999998, 26.000000000000004), + ], + <Offset>[ + Offset(38.00591550498171, 22.044873132716056), + Offset(38.49527667051535, 22.460937675485788), + Offset(39.29605291377171, 23.50908772721707), + Offset(40.09093462819668, 25.55307837507629), + Offset(40.245484311916975, 29.0647450812576), + Offset(38.4507533181536, 34.23676198346485), + Offset(33.07289070449873, 39.64197668074282), + Offset(25.27493194621101, 42.065839270086585), + Offset(18.32696878877331, 41.19932315169756), + Offset(13.530028647163753, 38.77767572627239), + Offset(10.497349834921115, 36.06973233007125), + Offset(8.641652255675858, 33.59797658699403), + Offset(7.521978336485894, 31.51497186001597), + Offset(6.854874126978952, 29.834780098541525), + Offset(6.463082099172453, 28.52288741178755), + Offset(6.237308044976331, 27.53366304435064), + Offset(6.110545241324058, 26.822659816088162), + Offset(6.042462396642684, 26.350923908611218), + Offset(6.009733624105834, 26.085741049230222), + Offset(6.0000094134910675, 26.000084719621103), + Offset(5.999999999998, 26.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.00591550498171, 22.044873132716056), + Offset(38.49527667051535, 22.460937675485788), + Offset(39.29605291377171, 23.50908772721707), + Offset(40.09093462819668, 25.55307837507629), + Offset(40.245484311916975, 29.0647450812576), + Offset(38.4507533181536, 34.23676198346485), + Offset(33.07289070449873, 39.64197668074282), + Offset(25.27493194621101, 42.065839270086585), + Offset(18.32696878877331, 41.19932315169756), + Offset(13.530028647163753, 38.77767572627239), + Offset(10.497349834921115, 36.06973233007125), + Offset(8.641652255675858, 33.59797658699403), + Offset(7.521978336485894, 31.51497186001597), + Offset(6.854874126978952, 29.834780098541525), + Offset(6.463082099172453, 28.52288741178755), + Offset(6.237308044976331, 27.53366304435064), + Offset(6.110545241324058, 26.822659816088162), + Offset(6.042462396642684, 26.350923908611218), + Offset(6.009733624105834, 26.085741049230222), + Offset(6.0000094134910675, 26.000084719621103), + Offset(5.999999999998, 26.000000000000004), + ], + <Offset>[ + Offset(37.94911572973609, 39.76930609237789), + Offset(37.978705662688164, 38.67002958113828), + Offset(37.94228938605856, 37.14191828495851), + Offset(37.74901701468069, 36.01322573416242), + Offset(37.073729514980414, 36.166850375020715), + Offset(34.860696965704065, 38.25231356935464), + Offset(29.32380649241722, 41.29280025903057), + Offset(21.278164010187293, 41.90507215413086), + Offset(14.689874224040729, 39.53452793089931), + Offset(10.541513764636846, 36.11895821647275), + Offset(8.17722932746859, 32.8113522370038), + Offset(6.909373193758391, 29.992534970264902), + Offset(6.274253334616972, 27.7145531568056), + Offset(5.992242541275278, 25.92890417279243), + Offset(5.8979828600197735, 24.56300574058713), + Offset(5.894862302436515, 23.548348638508063), + Offset(5.92726515385775, 22.826860971108808), + Offset(5.964394650931823, 22.35168580278661), + Offset(5.990675012711208, 22.08578645332145), + Offset(5.9999905869036745, 22.00008471966541), + Offset(5.999999999998, 22.000000000000004), + ], + <Offset>[ + Offset(37.94911572973609, 39.76930609237789), + Offset(37.978705662688164, 38.67002958113828), + Offset(37.94228938605856, 37.14191828495851), + Offset(37.74901701468069, 36.01322573416242), + Offset(37.073729514980414, 36.166850375020715), + Offset(34.860696965704065, 38.25231356935464), + Offset(29.32380649241722, 41.29280025903057), + Offset(21.278164010187293, 41.90507215413086), + Offset(14.689874224040729, 39.53452793089931), + Offset(10.541513764636846, 36.11895821647275), + Offset(8.17722932746859, 32.8113522370038), + Offset(6.909373193758391, 29.992534970264902), + Offset(6.274253334616972, 27.7145531568056), + Offset(5.992242541275278, 25.92890417279243), + Offset(5.8979828600197735, 24.56300574058713), + Offset(5.894862302436515, 23.548348638508063), + Offset(5.92726515385775, 22.826860971108808), + Offset(5.964394650931823, 22.35168580278661), + Offset(5.990675012711208, 22.08578645332145), + Offset(5.9999905869036745, 22.00008471966541), + Offset(5.999999999998, 22.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(37.94911572973609, 39.76930609237789), + Offset(37.978705662688164, 38.67002958113828), + Offset(37.94228938605856, 37.14191828495851), + Offset(37.74901701468069, 36.01322573416242), + Offset(37.073729514980414, 36.166850375020715), + Offset(34.860696965704065, 38.25231356935464), + Offset(29.32380649241722, 41.29280025903057), + Offset(21.278164010187293, 41.90507215413086), + Offset(14.689874224040729, 39.53452793089931), + Offset(10.541513764636846, 36.11895821647275), + Offset(8.17722932746859, 32.8113522370038), + Offset(6.909373193758391, 29.992534970264902), + Offset(6.274253334616972, 27.7145531568056), + Offset(5.992242541275278, 25.92890417279243), + Offset(5.8979828600197735, 24.56300574058713), + Offset(5.894862302436515, 23.548348638508063), + Offset(5.92726515385775, 22.826860971108808), + Offset(5.964394650931823, 22.35168580278661), + Offset(5.990675012711208, 22.08578645332145), + Offset(5.9999905869036745, 22.00008471966541), + Offset(5.999999999998, 22.000000000000004), + ], + <Offset>[ + Offset(27.779838808704717, 39.73671759463359), + Offset(27.058904971764974, 38.322024123397924), + Offset(25.813844199913348, 35.937542812724104), + Offset(24.396329010694313, 33.02369851711455), + Offset(23.23283671619266, 29.985596181705468), + Offset(22.665687382226825, 27.349509636468714), + Offset(22.470891438652323, 25.72956505163552), + Offset(21.96357747162758, 24.86527810596251), + Offset(21.78755118423962, 24.028162960804647), + Offset(21.876672453964368, 23.377743584229883), + Offset(22.068985517786334, 22.9197657734538), + Offset(22.28078919417811, 22.607147847089998), + Offset(22.4769333591015, 22.395010616202416), + Offset(22.6445281925352, 22.251166361860314), + Offset(22.78051625011238, 22.153765372851865), + Offset(22.885825445599828, 22.088367719186568), + Offset(22.96292758566153, 22.04546585546863), + Offset(23.014720004731373, 22.0188522908061), + Offset(23.04405504560824, 22.004532095248404), + Offset(23.053564195114788, 22.000004454516944), + Offset(23.053573608398, 22.0), + ], + <Offset>[ + Offset(27.779838808704717, 39.73671759463359), + Offset(27.058904971764974, 38.322024123397924), + Offset(25.813844199913348, 35.937542812724104), + Offset(24.396329010694313, 33.02369851711455), + Offset(23.23283671619266, 29.985596181705468), + Offset(22.665687382226825, 27.349509636468714), + Offset(22.470891438652323, 25.72956505163552), + Offset(21.96357747162758, 24.86527810596251), + Offset(21.78755118423962, 24.028162960804647), + Offset(21.876672453964368, 23.377743584229883), + Offset(22.068985517786334, 22.9197657734538), + Offset(22.28078919417811, 22.607147847089998), + Offset(22.4769333591015, 22.395010616202416), + Offset(22.6445281925352, 22.251166361860314), + Offset(22.78051625011238, 22.153765372851865), + Offset(22.885825445599828, 22.088367719186568), + Offset(22.96292758566153, 22.04546585546863), + Offset(23.014720004731373, 22.0188522908061), + Offset(23.04405504560824, 22.004532095248404), + Offset(23.053564195114788, 22.000004454516944), + Offset(23.053573608398, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(27.779838808704717, 39.73671759463359), + Offset(27.058904971764974, 38.322024123397924), + Offset(25.813844199913348, 35.937542812724104), + Offset(24.396329010694313, 33.02369851711455), + Offset(23.23283671619266, 29.985596181705468), + Offset(22.665687382226825, 27.349509636468714), + Offset(22.470891438652323, 25.72956505163552), + Offset(21.96357747162758, 24.86527810596251), + Offset(21.78755118423962, 24.028162960804647), + Offset(21.876672453964368, 23.377743584229883), + Offset(22.068985517786334, 22.9197657734538), + Offset(22.28078919417811, 22.607147847089998), + Offset(22.4769333591015, 22.395010616202416), + Offset(22.6445281925352, 22.251166361860314), + Offset(22.78051625011238, 22.153765372851865), + Offset(22.885825445599828, 22.088367719186568), + Offset(22.96292758566153, 22.04546585546863), + Offset(23.014720004731373, 22.0188522908061), + Offset(23.04405504560824, 22.004532095248404), + Offset(23.053564195114788, 22.000004454516944), + Offset(23.053573608398, 22.0), + ], + <Offset>[ + Offset(27.83663858395034, 22.012284634971753), + Offset(27.57547597959216, 22.112932217745428), + Offset(27.1676077276265, 22.304712254982665), + Offset(26.738246624210294, 22.56355115802843), + Offset(26.404591513129223, 22.883490887942344), + Offset(26.25574373467636, 23.333958050578932), + Offset(26.21997565073384, 24.078741473347776), + Offset(25.9603454076513, 25.02604522191824), + Offset(25.424645748972203, 25.692958181602904), + Offset(24.865187336491275, 26.036461094029523), + Offset(24.38910602523886, 26.17814586652125), + Offset(24.013068256095575, 26.212589463819125), + Offset(23.724658360970427, 26.195429319412785), + Offset(23.50715977823888, 26.157042287609407), + Offset(23.34561548926506, 26.11364704405229), + Offset(23.228271188139644, 26.073682125029144), + Offset(23.14620767312784, 26.041264700447986), + Offset(23.092787750442234, 26.018090396630715), + Offset(23.063113657002866, 26.004486691157176), + Offset(23.053583021702178, 26.00000445447264), + Offset(23.053573608398, 26.0), + ], + <Offset>[ + Offset(27.83663858395034, 22.012284634971753), + Offset(27.57547597959216, 22.112932217745428), + Offset(27.1676077276265, 22.304712254982665), + Offset(26.738246624210294, 22.56355115802843), + Offset(26.404591513129223, 22.883490887942344), + Offset(26.25574373467636, 23.333958050578932), + Offset(26.21997565073384, 24.078741473347776), + Offset(25.9603454076513, 25.02604522191824), + Offset(25.424645748972203, 25.692958181602904), + Offset(24.865187336491275, 26.036461094029523), + Offset(24.38910602523886, 26.17814586652125), + Offset(24.013068256095575, 26.212589463819125), + Offset(23.724658360970427, 26.195429319412785), + Offset(23.50715977823888, 26.157042287609407), + Offset(23.34561548926506, 26.11364704405229), + Offset(23.228271188139644, 26.073682125029144), + Offset(23.14620767312784, 26.041264700447986), + Offset(23.092787750442234, 26.018090396630715), + Offset(23.063113657002866, 26.004486691157176), + Offset(23.053583021702178, 26.00000445447264), + Offset(23.053573608398, 26.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(23.684125337085877, 6.32904503029285), + Offset(24.757855588582725, 6.522328815913423), + Offset(27.574407323116677, 7.305175651642309), + Offset(34.29718691345236, 10.565460438397327), + Offset(42.96465562696383, 18.312204872266342), + Offset(45.05615687407709, 26.45166424954756), + Offset(42.23233332042143, 35.60390079418309), + Offset(35.26685178627038, 42.467757059973906), + Offset(27.4197052006056, 45.36131120369138), + Offset(21.001315853482343, 45.424469500769995), + Offset(16.29765110355406, 44.215682562738714), + Offset(12.97234991047133, 42.61158062881598), + Offset(10.641290841160107, 41.016018618041265), + Offset(9.0114530912401, 39.59946991291384), + Offset(7.875830197056132, 38.422591589788325), + Offset(7.093422401327867, 37.49694905895691), + Offset(6.568745459991835, 36.81215692853645), + Offset(6.237631760921833, 36.34901917317271), + Offset(6.057380152594408, 36.08562753900215), + Offset(6.000056479961543, 36.00008471951035), + Offset(6.0000000000000036, 36.00000000000001), + ]), + _PathCubicTo( + <Offset>[ + Offset(23.684125337085877, 6.32904503029285), + Offset(24.757855588582725, 6.522328815913423), + Offset(27.574407323116677, 7.305175651642309), + Offset(34.29718691345236, 10.565460438397327), + Offset(42.96465562696383, 18.312204872266342), + Offset(45.05615687407709, 26.45166424954756), + Offset(42.23233332042143, 35.60390079418309), + Offset(35.26685178627038, 42.467757059973906), + Offset(27.4197052006056, 45.36131120369138), + Offset(21.001315853482343, 45.424469500769995), + Offset(16.29765110355406, 44.215682562738714), + Offset(12.97234991047133, 42.61158062881598), + Offset(10.641290841160107, 41.016018618041265), + Offset(9.0114530912401, 39.59946991291384), + Offset(7.875830197056132, 38.422591589788325), + Offset(7.093422401327867, 37.49694905895691), + Offset(6.568745459991835, 36.81215692853645), + Offset(6.237631760921833, 36.34901917317271), + Offset(6.057380152594408, 36.08562753900215), + Offset(6.000056479961543, 36.00008471951035), + Offset(6.0000000000000036, 36.00000000000001), + ], + <Offset>[ + Offset(23.689243079011977, 6.329061430625194), + Offset(23.64011618905085, 6.48670734054237), + Offset(23.133765296901146, 6.8642122589514845), + Offset(19.98658162330333, 7.361465557774245), + Offset(15.467378257263233, 6.032096680033179), + Offset(19.194942220379907, 3.3307514773626146), + Offset(27.75047360634506, 2.715037120490525), + Offset(36.713755829871914, 6.4968456357604545), + Offset(42.40286218778989, 12.627460121098146), + Offset(44.92977344167909, 18.52783555802784), + Offset(45.6230719411611, 23.334597995665984), + Offset(45.42132446103347, 27.02106907155878), + Offset(44.84505917005344, 29.786493601220947), + Offset(44.16433642298196, 31.835785641580745), + Offset(43.51476523785995, 33.33669843741421), + Offset(42.96125205391104, 34.41493737609855), + Offset(42.530935064806016, 35.16263614133967), + Offset(42.23077471334334, 35.64640946177496), + Offset(42.056971515773355, 35.91410003645049), + Offset(42.0000564795628, 35.99991528022383), + Offset(42.0, 35.99999999999999), + ], + <Offset>[ + Offset(23.689243079011977, 6.329061430625194), + Offset(23.64011618905085, 6.48670734054237), + Offset(23.133765296901146, 6.8642122589514845), + Offset(19.98658162330333, 7.361465557774245), + Offset(15.467378257263233, 6.032096680033179), + Offset(19.194942220379907, 3.3307514773626146), + Offset(27.75047360634506, 2.715037120490525), + Offset(36.713755829871914, 6.4968456357604545), + Offset(42.40286218778989, 12.627460121098146), + Offset(44.92977344167909, 18.52783555802784), + Offset(45.6230719411611, 23.334597995665984), + Offset(45.42132446103347, 27.02106907155878), + Offset(44.84505917005344, 29.786493601220947), + Offset(44.16433642298196, 31.835785641580745), + Offset(43.51476523785995, 33.33669843741421), + Offset(42.96125205391104, 34.41493737609855), + Offset(42.530935064806016, 35.16263614133967), + Offset(42.23077471334334, 35.64640946177496), + Offset(42.056971515773355, 35.91410003645049), + Offset(42.0000564795628, 35.99991528022383), + Offset(42.0, 35.99999999999999), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(23.689243079011977, 6.329061430625194), + Offset(23.64011618905085, 6.48670734054237), + Offset(23.133765296901146, 6.8642122589514845), + Offset(19.98658162330333, 7.361465557774245), + Offset(15.467378257263233, 6.032096680033179), + Offset(19.194942220379907, 3.3307514773626146), + Offset(27.75047360634506, 2.715037120490525), + Offset(36.713755829871914, 6.4968456357604545), + Offset(42.40286218778989, 12.627460121098146), + Offset(44.92977344167909, 18.52783555802784), + Offset(45.6230719411611, 23.334597995665984), + Offset(45.42132446103347, 27.02106907155878), + Offset(44.84505917005344, 29.786493601220947), + Offset(44.16433642298196, 31.835785641580745), + Offset(43.51476523785995, 33.33669843741421), + Offset(42.96125205391104, 34.41493737609855), + Offset(42.530935064806016, 35.16263614133967), + Offset(42.23077471334334, 35.64640946177496), + Offset(42.056971515773355, 35.91410003645049), + Offset(42.0000564795628, 35.99991528022383), + Offset(42.0, 35.99999999999999), + ], + <Offset>[ + Offset(4.256210085244433, 23.724986123422013), + Offset(4.334340540250139, 22.918290544448126), + Offset(4.683893395049303, 20.897955670871653), + Offset(6.165631658910659, 16.538261784200643), + Offset(10.04725752347015, 10.401464969282092), + Offset(15.676813589860602, 6.207875011618711), + Offset(24.053924042171055, 4.3051387362291464), + Offset(32.7169878938482, 6.336078519804726), + Offset(38.76576762305731, 10.962664900299892), + Offset(41.94125855915219, 15.869118048228197), + Offset(43.30295143370857, 20.076217902598536), + Offset(43.689045399116004, 23.415627454829654), + Offset(43.59733416818452, 25.98607489801058), + Offset(43.30170483727829, 27.92990971583165), + Offset(42.94966599870727, 29.37681676621379), + Offset(42.618806311371216, 30.429622970255974), + Offset(42.347654977339715, 31.166837296360313), + Offset(42.15270696763247, 31.64717135595035), + Offset(42.03791290437873, 31.914145440541716), + Offset(42.00003765297541, 31.999915280268137), + Offset(42.0, 31.999999999999996), + ], + <Offset>[ + Offset(4.256210085244433, 23.724986123422013), + Offset(4.334340540250139, 22.918290544448126), + Offset(4.683893395049303, 20.897955670871653), + Offset(6.165631658910659, 16.538261784200643), + Offset(10.04725752347015, 10.401464969282092), + Offset(15.676813589860602, 6.207875011618711), + Offset(24.053924042171055, 4.3051387362291464), + Offset(32.7169878938482, 6.336078519804726), + Offset(38.76576762305731, 10.962664900299892), + Offset(41.94125855915219, 15.869118048228197), + Offset(43.30295143370857, 20.076217902598536), + Offset(43.689045399116004, 23.415627454829654), + Offset(43.59733416818452, 25.98607489801058), + Offset(43.30170483727829, 27.92990971583165), + Offset(42.94966599870727, 29.37681676621379), + Offset(42.618806311371216, 30.429622970255974), + Offset(42.347654977339715, 31.166837296360313), + Offset(42.15270696763247, 31.64717135595035), + Offset(42.03791290437873, 31.914145440541716), + Offset(42.00003765297541, 31.999915280268137), + Offset(42.0, 31.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(4.256210085244433, 23.724986123422013), + Offset(4.334340540250139, 22.918290544448126), + Offset(4.683893395049303, 20.897955670871653), + Offset(6.165631658910659, 16.538261784200643), + Offset(10.04725752347015, 10.401464969282092), + Offset(15.676813589860602, 6.207875011618711), + Offset(24.053924042171055, 4.3051387362291464), + Offset(32.7169878938482, 6.336078519804726), + Offset(38.76576762305731, 10.962664900299892), + Offset(41.94125855915219, 15.869118048228197), + Offset(43.30295143370857, 20.076217902598536), + Offset(43.689045399116004, 23.415627454829654), + Offset(43.59733416818452, 25.98607489801058), + Offset(43.30170483727829, 27.92990971583165), + Offset(42.94966599870727, 29.37681676621379), + Offset(42.618806311371216, 30.429622970255974), + Offset(42.347654977339715, 31.166837296360313), + Offset(42.15270696763247, 31.64717135595035), + Offset(42.03791290437873, 31.914145440541716), + Offset(42.00003765297541, 31.999915280268137), + Offset(42.0, 31.999999999999996), + ], + <Offset>[ + Offset(43.99436998801117, 23.85233115929924), + Offset(43.935955265272696, 24.180362852333243), + Offset(43.76617309606209, 24.778893280638325), + Offset(43.45759773349659, 24.887543031835328), + Offset(43.476142398493316, 25.33059113299948), + Offset(42.61623543783426, 30.292746160199744), + Offset(38.56421880483555, 37.258579505338574), + Offset(31.270083850246664, 42.30698994401817), + Offset(23.782610635873013, 43.696515982893125), + Offset(18.012800970955436, 42.76575199097035), + Offset(13.977530596101534, 40.95730246967126), + Offset(11.240070848553863, 39.00613901208685), + Offset(9.393565839291185, 37.2155999148309), + Offset(8.148821505536425, 35.693593987164746), + Offset(7.310730957903452, 34.462709918587905), + Offset(6.750976658788051, 33.51163465311433), + Offset(6.385465372525527, 32.8163580835571), + Offset(6.159564015210972, 32.3497810673481), + Offset(6.038321541199782, 32.08567294309337), + Offset(6.00003765337415, 32.00008471955465), + Offset(6.0000000000000036, 32.00000000000001), + ], + <Offset>[ + Offset(43.99436998801117, 23.85233115929924), + Offset(43.935955265272696, 24.180362852333243), + Offset(43.76617309606209, 24.778893280638325), + Offset(43.45759773349659, 24.887543031835328), + Offset(43.476142398493316, 25.33059113299948), + Offset(42.61623543783426, 30.292746160199744), + Offset(38.56421880483555, 37.258579505338574), + Offset(31.270083850246664, 42.30698994401817), + Offset(23.782610635873013, 43.696515982893125), + Offset(18.012800970955436, 42.76575199097035), + Offset(13.977530596101534, 40.95730246967126), + Offset(11.240070848553863, 39.00613901208685), + Offset(9.393565839291185, 37.2155999148309), + Offset(8.148821505536425, 35.693593987164746), + Offset(7.310730957903452, 34.462709918587905), + Offset(6.750976658788051, 33.51163465311433), + Offset(6.385465372525527, 32.8163580835571), + Offset(6.159564015210972, 32.3497810673481), + Offset(6.038321541199782, 32.08567294309337), + Offset(6.00003765337415, 32.00008471955465), + Offset(6.0000000000000036, 32.00000000000001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(43.99436998801117, 23.85233115929924), + Offset(43.935955265272696, 24.180362852333243), + Offset(43.76617309606209, 24.778893280638325), + Offset(43.45759773349659, 24.887543031835328), + Offset(43.476142398493316, 25.33059113299948), + Offset(42.61623543783426, 30.292746160199744), + Offset(38.56421880483555, 37.258579505338574), + Offset(31.270083850246664, 42.30698994401817), + Offset(23.782610635873013, 43.696515982893125), + Offset(18.012800970955436, 42.76575199097035), + Offset(13.977530596101534, 40.95730246967126), + Offset(11.240070848553863, 39.00613901208685), + Offset(9.393565839291185, 37.2155999148309), + Offset(8.148821505536425, 35.693593987164746), + Offset(7.310730957903452, 34.462709918587905), + Offset(6.750976658788051, 33.51163465311433), + Offset(6.385465372525527, 32.8163580835571), + Offset(6.159564015210972, 32.3497810673481), + Offset(6.038321541199782, 32.08567294309337), + Offset(6.00003765337415, 32.00008471955465), + Offset(6.0000000000000036, 32.00000000000001), + ], + <Offset>[ + Offset(23.684125337090187, 6.329045030292864), + Offset(24.757855588602716, 6.52232881591406), + Offset(27.574407323047023, 7.305175651635392), + Offset(34.29718691345236, 10.565460438397327), + Offset(42.96465562689078, 18.312204872233718), + Offset(45.05615687408604, 26.45166424955556), + Offset(42.232333320435735, 35.60390079421558), + Offset(35.26685178627038, 42.467757059973906), + Offset(27.4197052006056, 45.36131120369138), + Offset(21.001315853482343, 45.424469500769995), + Offset(16.29765110355406, 44.215682562738714), + Offset(12.97234991047133, 42.61158062881598), + Offset(10.641290841160107, 41.016018618041265), + Offset(9.0114530912401, 39.59946991291384), + Offset(7.875830197056132, 38.422591589788325), + Offset(7.093422401327867, 37.49694905895691), + Offset(6.568745459991835, 36.81215692853645), + Offset(6.237631760921833, 36.34901917317271), + Offset(6.057380152594408, 36.08562753900215), + Offset(6.000056479961543, 36.00008471951035), + Offset(6.0000000000000036, 36.00000000000001), + ], + <Offset>[ + Offset(23.684125337090187, 6.329045030292864), + Offset(24.757855588602716, 6.52232881591406), + Offset(27.574407323047023, 7.305175651635392), + Offset(34.29718691345236, 10.565460438397327), + Offset(42.96465562689078, 18.312204872233718), + Offset(45.05615687408604, 26.45166424955556), + Offset(42.232333320435735, 35.60390079421558), + Offset(35.26685178627038, 42.467757059973906), + Offset(27.4197052006056, 45.36131120369138), + Offset(21.001315853482343, 45.424469500769995), + Offset(16.29765110355406, 44.215682562738714), + Offset(12.97234991047133, 42.61158062881598), + Offset(10.641290841160107, 41.016018618041265), + Offset(9.0114530912401, 39.59946991291384), + Offset(7.875830197056132, 38.422591589788325), + Offset(7.093422401327867, 37.49694905895691), + Offset(6.568745459991835, 36.81215692853645), + Offset(6.237631760921833, 36.34901917317271), + Offset(6.057380152594408, 36.08562753900215), + Offset(6.000056479961543, 36.00008471951035), + Offset(6.0000000000000036, 36.00000000000001), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/list_view.g.dart b/packages/material_ui/lib/src/animated_icons/data/list_view.g.dart new file mode 100644 index 000000000000..cae8ba0a1cb5 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/list_view.g.dart @@ -0,0 +1,3862 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$list_view = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.878048780488, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(3.3000000000000016, 35.5), + Offset(3.1872491552817053, 35.294662556963694), + Offset(2.803584610041825, 34.55708172822131), + Offset(2.086762034690722, 32.974965285488736), + Offset(1.0562543947134673, 29.85871467131613), + Offset(0.3277056784121384, 23.397935591386524), + Offset(5.068384450186188, 9.77558673709007), + Offset(19.142440865906075, 0.8236301535641601), + Offset(28.4986244695124, 0.7512929847205392), + Offset(34.03339705415398, 2.550735594111994), + Offset(37.52630261515188, 4.563458703685585), + Offset(39.83951862260312, 6.397453314812415), + Offset(41.420680465862844, 7.960676694252207), + Offset(42.52292959864048, 9.247336542718527), + Offset(43.29857028685616, 10.277566364406328), + Offset(43.843675623946226, 11.078369385731442), + Offset(44.22049532021145, 11.67638165937833), + Offset(44.47040673191611, 12.096116254351111), + Offset(44.62108022189184, 12.359078623996243), + Offset(44.69110902782625, 12.484010802427473), + ]), + _PathCubicTo( + <Offset>[ + Offset(3.3000000000000016, 35.5), + Offset(3.1872491552817053, 35.294662556963694), + Offset(2.803584610041825, 34.55708172822131), + Offset(2.086762034690722, 32.974965285488736), + Offset(1.0562543947134673, 29.85871467131613), + Offset(0.3277056784121384, 23.397935591386524), + Offset(5.068384450186188, 9.77558673709007), + Offset(19.142440865906075, 0.8236301535641601), + Offset(28.4986244695124, 0.7512929847205392), + Offset(34.03339705415398, 2.550735594111994), + Offset(37.52630261515188, 4.563458703685585), + Offset(39.83951862260312, 6.397453314812415), + Offset(41.420680465862844, 7.960676694252207), + Offset(42.52292959864048, 9.247336542718527), + Offset(43.29857028685616, 10.277566364406328), + Offset(43.843675623946226, 11.078369385731442), + Offset(44.22049532021145, 11.67638165937833), + Offset(44.47040673191611, 12.096116254351111), + Offset(44.62108022189184, 12.359078623996243), + Offset(44.69110902782625, 12.484010802427473), + ], + <Offset>[ + Offset(7.900000000000001, 35.5), + Offset(7.787024068249312, 35.34016805150932), + Offset(7.398927009112204, 34.7640315658779), + Offset(6.654572753845953, 33.51820117713304), + Offset(5.505071070263646, 31.02834289744042), + Offset(4.290730713340881, 25.733408257149932), + Offset(6.941261311200804, 13.977054607189284), + Offset(17.78085941259789, 5.217500423156466), + Offset(25.541432860040533, 4.274788093964937), + Offset(30.30609713874217, 5.246516620191658), + Offset(33.39574733179454, 6.587937167668043), + Offset(35.489171433369805, 7.892270806391144), + Offset(36.94930799941936, 9.040874947505337), + Offset(37.985765755731585, 10.005059620498354), + Offset(38.72688536686231, 10.787171105652785), + Offset(39.254973101552665, 11.400563676461365), + Offset(39.62422079973953, 11.861477668143195), + Offset(39.87129202785027, 12.186360028903175), + Offset(40.02118722421174, 12.390453931063687), + Offset(40.091110400688535, 12.487564720144858), + ], + <Offset>[ + Offset(7.900000000000001, 35.5), + Offset(7.787024068249312, 35.34016805150932), + Offset(7.398927009112204, 34.7640315658779), + Offset(6.654572753845953, 33.51820117713304), + Offset(5.505071070263646, 31.02834289744042), + Offset(4.290730713340881, 25.733408257149932), + Offset(6.941261311200804, 13.977054607189284), + Offset(17.78085941259789, 5.217500423156466), + Offset(25.541432860040533, 4.274788093964937), + Offset(30.30609713874217, 5.246516620191658), + Offset(33.39574733179454, 6.587937167668043), + Offset(35.489171433369805, 7.892270806391144), + Offset(36.94930799941936, 9.040874947505337), + Offset(37.985765755731585, 10.005059620498354), + Offset(38.72688536686231, 10.787171105652785), + Offset(39.254973101552665, 11.400563676461365), + Offset(39.62422079973953, 11.861477668143195), + Offset(39.87129202785027, 12.186360028903175), + Offset(40.02118722421174, 12.390453931063687), + Offset(40.091110400688535, 12.487564720144858), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(7.900000000000001, 35.5), + Offset(7.787024068249312, 35.34016805150932), + Offset(7.398927009112204, 34.7640315658779), + Offset(6.654572753845953, 33.51820117713304), + Offset(5.505071070263646, 31.02834289744042), + Offset(4.290730713340881, 25.733408257149932), + Offset(6.941261311200804, 13.977054607189284), + Offset(17.78085941259789, 5.217500423156466), + Offset(25.541432860040533, 4.274788093964937), + Offset(30.30609713874217, 5.246516620191658), + Offset(33.39574733179454, 6.587937167668043), + Offset(35.489171433369805, 7.892270806391144), + Offset(36.94930799941936, 9.040874947505337), + Offset(37.985765755731585, 10.005059620498354), + Offset(38.72688536686231, 10.787171105652785), + Offset(39.254973101552665, 11.400563676461365), + Offset(39.62422079973953, 11.861477668143195), + Offset(39.87129202785027, 12.186360028903175), + Offset(40.02118722421174, 12.390453931063687), + Offset(40.091110400688535, 12.487564720144858), + ], + <Offset>[ + Offset(7.900000000000001, 30.900000000000002), + Offset(7.832529562794939, 30.740393138541712), + Offset(7.605876846768791, 30.168689166807518), + Offset(7.19780864549025, 28.950390457977804), + Offset(6.674699296387938, 26.57952622189024), + Offset(6.626203379104288, 21.770383222221188), + Offset(11.14272918130002, 12.104177746174667), + Offset(22.174729682190197, 6.579081876464652), + Offset(29.06492796928493, 7.231979703436803), + Offset(33.00187816482183, 8.973816535603465), + Offset(35.420225795777, 10.718492451025382), + Offset(36.983988924948534, 12.242617995624466), + Offset(38.02950625267249, 13.51224741394882), + Offset(38.743488833511414, 14.542223463407256), + Offset(39.23649010810877, 15.358856025646629), + Offset(39.57716739228259, 15.989266198854928), + Offset(39.8093168085044, 16.457752188615107), + Offset(39.96153580240233, 16.785474732969014), + Offset(40.052562531279186, 16.990346928743786), + Offset(40.094664318405925, 17.087563347282572), + ], + <Offset>[ + Offset(7.900000000000001, 30.900000000000002), + Offset(7.832529562794939, 30.740393138541712), + Offset(7.605876846768791, 30.168689166807518), + Offset(7.19780864549025, 28.950390457977804), + Offset(6.674699296387938, 26.57952622189024), + Offset(6.626203379104288, 21.770383222221188), + Offset(11.14272918130002, 12.104177746174667), + Offset(22.174729682190197, 6.579081876464652), + Offset(29.06492796928493, 7.231979703436803), + Offset(33.00187816482183, 8.973816535603465), + Offset(35.420225795777, 10.718492451025382), + Offset(36.983988924948534, 12.242617995624466), + Offset(38.02950625267249, 13.51224741394882), + Offset(38.743488833511414, 14.542223463407256), + Offset(39.23649010810877, 15.358856025646629), + Offset(39.57716739228259, 15.989266198854928), + Offset(39.8093168085044, 16.457752188615107), + Offset(39.96153580240233, 16.785474732969014), + Offset(40.052562531279186, 16.990346928743786), + Offset(40.094664318405925, 17.087563347282572), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(7.900000000000001, 30.900000000000002), + Offset(7.832529562794939, 30.740393138541712), + Offset(7.605876846768791, 30.168689166807518), + Offset(7.19780864549025, 28.950390457977804), + Offset(6.674699296387938, 26.57952622189024), + Offset(6.626203379104288, 21.770383222221188), + Offset(11.14272918130002, 12.104177746174667), + Offset(22.174729682190197, 6.579081876464652), + Offset(29.06492796928493, 7.231979703436803), + Offset(33.00187816482183, 8.973816535603465), + Offset(35.420225795777, 10.718492451025382), + Offset(36.983988924948534, 12.242617995624466), + Offset(38.02950625267249, 13.51224741394882), + Offset(38.743488833511414, 14.542223463407256), + Offset(39.23649010810877, 15.358856025646629), + Offset(39.57716739228259, 15.989266198854928), + Offset(39.8093168085044, 16.457752188615107), + Offset(39.96153580240233, 16.785474732969014), + Offset(40.052562531279186, 16.990346928743786), + Offset(40.094664318405925, 17.087563347282572), + ], + <Offset>[ + Offset(3.3000000000000016, 30.900000000000002), + Offset(3.2327546498273323, 30.694887643996086), + Offset(3.0105344476984124, 29.96173932915093), + Offset(2.6299979263350193, 28.407154566333507), + Offset(2.2258826208377593, 25.40989799576595), + Offset(2.663178344175545, 19.43491055645778), + Offset(9.269852320285402, 7.902709876075453), + Offset(23.53631113549838, 2.185211606872347), + Offset(32.0221195787568, 3.708484594192405), + Offset(36.729178080233645, 6.278035509523802), + Offset(39.55078107913434, 8.694013987042924), + Offset(41.33433611418185, 10.747800504045737), + Offset(42.500878719115974, 12.43204916069569), + Offset(43.28065267642031, 13.784500385627428), + Offset(43.80817502810262, 14.849251284400172), + Offset(44.16586991467615, 15.667071908125004), + Offset(44.405591328976314, 16.272656179850244), + Offset(44.56065050646817, 16.695230958416946), + Offset(44.65245552895929, 16.95897162167634), + Offset(44.69466294554364, 17.084009429565185), + ], + <Offset>[ + Offset(3.3000000000000016, 30.900000000000002), + Offset(3.2327546498273323, 30.694887643996086), + Offset(3.0105344476984124, 29.96173932915093), + Offset(2.6299979263350193, 28.407154566333507), + Offset(2.2258826208377593, 25.40989799576595), + Offset(2.663178344175545, 19.43491055645778), + Offset(9.269852320285402, 7.902709876075453), + Offset(23.53631113549838, 2.185211606872347), + Offset(32.0221195787568, 3.708484594192405), + Offset(36.729178080233645, 6.278035509523802), + Offset(39.55078107913434, 8.694013987042924), + Offset(41.33433611418185, 10.747800504045737), + Offset(42.500878719115974, 12.43204916069569), + Offset(43.28065267642031, 13.784500385627428), + Offset(43.80817502810262, 14.849251284400172), + Offset(44.16586991467615, 15.667071908125004), + Offset(44.405591328976314, 16.272656179850244), + Offset(44.56065050646817, 16.695230958416946), + Offset(44.65245552895929, 16.95897162167634), + Offset(44.69466294554364, 17.084009429565185), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.3000000000000016, 30.900000000000002), + Offset(3.2327546498273323, 30.694887643996086), + Offset(3.0105344476984124, 29.96173932915093), + Offset(2.6299979263350193, 28.407154566333507), + Offset(2.2258826208377593, 25.40989799576595), + Offset(2.663178344175545, 19.43491055645778), + Offset(9.269852320285402, 7.902709876075453), + Offset(23.53631113549838, 2.185211606872347), + Offset(32.0221195787568, 3.708484594192405), + Offset(36.729178080233645, 6.278035509523802), + Offset(39.55078107913434, 8.694013987042924), + Offset(41.33433611418185, 10.747800504045737), + Offset(42.500878719115974, 12.43204916069569), + Offset(43.28065267642031, 13.784500385627428), + Offset(43.80817502810262, 14.849251284400172), + Offset(44.16586991467615, 15.667071908125004), + Offset(44.405591328976314, 16.272656179850244), + Offset(44.56065050646817, 16.695230958416946), + Offset(44.65245552895929, 16.95897162167634), + Offset(44.69466294554364, 17.084009429565185), + ], + <Offset>[ + Offset(3.3000000000000016, 35.5), + Offset(3.1872491552817053, 35.294662556963694), + Offset(2.803584610041825, 34.55708172822131), + Offset(2.086762034690722, 32.974965285488736), + Offset(1.0562543947134673, 29.85871467131613), + Offset(0.3277056784121384, 23.397935591386524), + Offset(5.068384450186188, 9.77558673709007), + Offset(19.142440865906075, 0.8236301535641601), + Offset(28.4986244695124, 0.7512929847205392), + Offset(34.03339705415398, 2.550735594111994), + Offset(37.52630261515188, 4.563458703685585), + Offset(39.83951862260312, 6.397453314812415), + Offset(41.420680465862844, 7.960676694252207), + Offset(42.52292959864048, 9.247336542718527), + Offset(43.29857028685616, 10.277566364406328), + Offset(43.843675623946226, 11.078369385731442), + Offset(44.22049532021145, 11.67638165937833), + Offset(44.47040673191611, 12.096116254351111), + Offset(44.62108022189184, 12.359078623996243), + Offset(44.69110902782625, 12.484010802427473), + ], + <Offset>[ + Offset(3.3000000000000016, 35.5), + Offset(3.1872491552817053, 35.294662556963694), + Offset(2.803584610041825, 34.55708172822131), + Offset(2.086762034690722, 32.974965285488736), + Offset(1.0562543947134673, 29.85871467131613), + Offset(0.3277056784121384, 23.397935591386524), + Offset(5.068384450186188, 9.77558673709007), + Offset(19.142440865906075, 0.8236301535641601), + Offset(28.4986244695124, 0.7512929847205392), + Offset(34.03339705415398, 2.550735594111994), + Offset(37.52630261515188, 4.563458703685585), + Offset(39.83951862260312, 6.397453314812415), + Offset(41.420680465862844, 7.960676694252207), + Offset(42.52292959864048, 9.247336542718527), + Offset(43.29857028685616, 10.277566364406328), + Offset(43.843675623946226, 11.078369385731442), + Offset(44.22049532021145, 11.67638165937833), + Offset(44.47040673191611, 12.096116254351111), + Offset(44.62108022189184, 12.359078623996243), + Offset(44.69110902782625, 12.484010802427473), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.878048780488, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(3.3000000000000016, 17.1), + Offset(3.3692711334642134, 16.89556290509327), + Offset(3.631383960668174, 16.17571213193979), + Offset(4.25970560126791, 14.703722408867815), + Offset(5.734767299210637, 12.063447969115414), + Offset(9.669596341465768, 7.545835451671554), + Offset(21.874255930583047, 2.2840792930316054), + Offset(36.7179219442753, 6.269955966796905), + Offset(42.592604906489996, 12.580059422608004), + Offset(44.81652115847264, 17.459935255759223), + Offset(45.624216471081716, 21.085679837114938), + Offset(45.81878858891804, 23.798842071745703), + Offset(45.74147347887537, 25.84616656002614), + Offset(45.55382190975979, 27.395991914354134), + Offset(45.33698925184199, 28.564306044381702), + Offset(45.13245278686591, 29.433179475305685), + Offset(44.960879355270905, 30.061479741265988), + Offset(44.831381830124364, 30.492575070614457), + Offset(44.74658145016162, 30.758650614716647), + Offset(44.7053246986958, 30.884005310978335), + ]), + _PathCubicTo( + <Offset>[ + Offset(3.3000000000000016, 17.1), + Offset(3.3692711334642134, 16.89556290509327), + Offset(3.631383960668174, 16.17571213193979), + Offset(4.25970560126791, 14.703722408867815), + Offset(5.734767299210637, 12.063447969115414), + Offset(9.669596341465768, 7.545835451671554), + Offset(21.874255930583047, 2.2840792930316054), + Offset(36.7179219442753, 6.269955966796905), + Offset(42.592604906489996, 12.580059422608004), + Offset(44.81652115847264, 17.459935255759223), + Offset(45.624216471081716, 21.085679837114938), + Offset(45.81878858891804, 23.798842071745703), + Offset(45.74147347887537, 25.84616656002614), + Offset(45.55382190975979, 27.395991914354134), + Offset(45.33698925184199, 28.564306044381702), + Offset(45.13245278686591, 29.433179475305685), + Offset(44.960879355270905, 30.061479741265988), + Offset(44.831381830124364, 30.492575070614457), + Offset(44.74658145016162, 30.758650614716647), + Offset(44.7053246986958, 30.884005310978335), + ], + <Offset>[ + Offset(7.900000000000001, 17.1), + Offset(7.96904604643182, 16.941068399638898), + Offset(8.226726359738553, 16.382661969596377), + Offset(8.82751632042314, 15.246958300512112), + Offset(10.183583974760815, 13.233076195239708), + Offset(13.63262137639451, 9.88130811743496), + Offset(23.747132791597664, 6.48554716313082), + Offset(35.35634049096711, 10.66382623638921), + Offset(39.63541329701813, 16.103554531852403), + Offset(41.08922124306083, 20.155716281838888), + Offset(41.49366118772438, 23.1101583010974), + Offset(41.46844139968472, 25.293659563324432), + Offset(41.27010101243189, 26.92636481327927), + Offset(41.016658066850894, 28.15371499213396), + Offset(40.76530433184814, 29.07391078562816), + Offset(40.54375026447235, 29.75537376603561), + Offset(40.36460483479899, 30.24657575003085), + Offset(40.232267126058524, 30.582818845166525), + Offset(40.14668845248152, 30.790025921784093), + Offset(40.10532607155808, 30.88755922869572), + ], + <Offset>[ + Offset(7.900000000000001, 17.1), + Offset(7.96904604643182, 16.941068399638898), + Offset(8.226726359738553, 16.382661969596377), + Offset(8.82751632042314, 15.246958300512112), + Offset(10.183583974760815, 13.233076195239708), + Offset(13.63262137639451, 9.88130811743496), + Offset(23.747132791597664, 6.48554716313082), + Offset(35.35634049096711, 10.66382623638921), + Offset(39.63541329701813, 16.103554531852403), + Offset(41.08922124306083, 20.155716281838888), + Offset(41.49366118772438, 23.1101583010974), + Offset(41.46844139968472, 25.293659563324432), + Offset(41.27010101243189, 26.92636481327927), + Offset(41.016658066850894, 28.15371499213396), + Offset(40.76530433184814, 29.07391078562816), + Offset(40.54375026447235, 29.75537376603561), + Offset(40.36460483479899, 30.24657575003085), + Offset(40.232267126058524, 30.582818845166525), + Offset(40.14668845248152, 30.790025921784093), + Offset(40.10532607155808, 30.88755922869572), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(7.900000000000001, 17.1), + Offset(7.96904604643182, 16.941068399638898), + Offset(8.226726359738553, 16.382661969596377), + Offset(8.82751632042314, 15.246958300512112), + Offset(10.183583974760815, 13.233076195239708), + Offset(13.63262137639451, 9.88130811743496), + Offset(23.747132791597664, 6.48554716313082), + Offset(35.35634049096711, 10.66382623638921), + Offset(39.63541329701813, 16.103554531852403), + Offset(41.08922124306083, 20.155716281838888), + Offset(41.49366118772438, 23.1101583010974), + Offset(41.46844139968472, 25.293659563324432), + Offset(41.27010101243189, 26.92636481327927), + Offset(41.016658066850894, 28.15371499213396), + Offset(40.76530433184814, 29.07391078562816), + Offset(40.54375026447235, 29.75537376603561), + Offset(40.36460483479899, 30.24657575003085), + Offset(40.232267126058524, 30.582818845166525), + Offset(40.14668845248152, 30.790025921784093), + Offset(40.10532607155808, 30.88755922869572), + ], + <Offset>[ + Offset(7.900000000000001, 12.5), + Offset(8.014551540977447, 12.34129348667129), + Offset(8.43367619739514, 11.787319570526), + Offset(9.370752212067437, 10.679147581356883), + Offset(11.353212200885107, 8.78425951968953), + Offset(15.968094042157917, 5.918283082506218), + Offset(27.94860066169688, 4.6126703021162045), + Offset(39.75021076055942, 12.025407689697397), + Offset(43.158908406262526, 19.060746141324266), + Offset(43.78500226914049, 23.883016197250694), + Offset(43.51813965170684, 27.24071358445474), + Offset(42.96325889126345, 29.644006752557758), + Offset(42.35029926568502, 31.397737279722755), + Offset(41.77438114463072, 32.69087883504286), + Offset(41.2749090730946, 33.64559570562201), + Offset(40.865944555202276, 34.34407628842917), + Offset(40.549700843563855, 34.842850270502765), + Offset(40.32251090061059, 35.18193354923236), + Offset(40.17806375954897, 35.38991891946419), + Offset(40.10887998927547, 35.48755785583344), + ], + <Offset>[ + Offset(7.900000000000001, 12.5), + Offset(8.014551540977447, 12.34129348667129), + Offset(8.43367619739514, 11.787319570526), + Offset(9.370752212067437, 10.679147581356883), + Offset(11.353212200885107, 8.78425951968953), + Offset(15.968094042157917, 5.918283082506218), + Offset(27.94860066169688, 4.6126703021162045), + Offset(39.75021076055942, 12.025407689697397), + Offset(43.158908406262526, 19.060746141324266), + Offset(43.78500226914049, 23.883016197250694), + Offset(43.51813965170684, 27.24071358445474), + Offset(42.96325889126345, 29.644006752557758), + Offset(42.35029926568502, 31.397737279722755), + Offset(41.77438114463072, 32.69087883504286), + Offset(41.2749090730946, 33.64559570562201), + Offset(40.865944555202276, 34.34407628842917), + Offset(40.549700843563855, 34.842850270502765), + Offset(40.32251090061059, 35.18193354923236), + Offset(40.17806375954897, 35.38991891946419), + Offset(40.10887998927547, 35.48755785583344), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(7.900000000000001, 12.5), + Offset(8.014551540977447, 12.34129348667129), + Offset(8.43367619739514, 11.787319570526), + Offset(9.370752212067437, 10.679147581356883), + Offset(11.353212200885107, 8.78425951968953), + Offset(15.968094042157917, 5.918283082506218), + Offset(27.94860066169688, 4.6126703021162045), + Offset(39.75021076055942, 12.025407689697397), + Offset(43.158908406262526, 19.060746141324266), + Offset(43.78500226914049, 23.883016197250694), + Offset(43.51813965170684, 27.24071358445474), + Offset(42.96325889126345, 29.644006752557758), + Offset(42.35029926568502, 31.397737279722755), + Offset(41.77438114463072, 32.69087883504286), + Offset(41.2749090730946, 33.64559570562201), + Offset(40.865944555202276, 34.34407628842917), + Offset(40.549700843563855, 34.842850270502765), + Offset(40.32251090061059, 35.18193354923236), + Offset(40.17806375954897, 35.38991891946419), + Offset(40.10887998927547, 35.48755785583344), + ], + <Offset>[ + Offset(3.3000000000000016, 12.5), + Offset(3.4147766280098404, 12.295787992125664), + Offset(3.8383337983247614, 11.580369732869412), + Offset(4.802941492912208, 10.135911689712586), + Offset(6.904395525334929, 7.614631293565236), + Offset(12.005069007229174, 3.5828104167428108), + Offset(26.075723800682262, 0.4112024320169896), + Offset(41.1117922138676, 7.631537420105092), + Offset(46.11610001573439, 15.537251032079869), + Offset(47.512302184552304, 21.18723517117103), + Offset(47.64869493506418, 25.216235120472277), + Offset(47.31360608049677, 28.14918926097903), + Offset(46.8216717321285, 30.317539026469625), + Offset(46.31154498753962, 31.933155757263034), + Offset(45.84659399308845, 33.13599096437555), + Offset(45.45464707759584, 34.021881997699246), + Offset(45.14597536403577, 34.6577542617379), + Offset(44.92162560467643, 35.0916897746803), + Offset(44.77795675722907, 35.358543612396744), + Offset(44.70887861641319, 35.48400393811605), + ], + <Offset>[ + Offset(3.3000000000000016, 12.5), + Offset(3.4147766280098404, 12.295787992125664), + Offset(3.8383337983247614, 11.580369732869412), + Offset(4.802941492912208, 10.135911689712586), + Offset(6.904395525334929, 7.614631293565236), + Offset(12.005069007229174, 3.5828104167428108), + Offset(26.075723800682262, 0.4112024320169896), + Offset(41.1117922138676, 7.631537420105092), + Offset(46.11610001573439, 15.537251032079869), + Offset(47.512302184552304, 21.18723517117103), + Offset(47.64869493506418, 25.216235120472277), + Offset(47.31360608049677, 28.14918926097903), + Offset(46.8216717321285, 30.317539026469625), + Offset(46.31154498753962, 31.933155757263034), + Offset(45.84659399308845, 33.13599096437555), + Offset(45.45464707759584, 34.021881997699246), + Offset(45.14597536403577, 34.6577542617379), + Offset(44.92162560467643, 35.0916897746803), + Offset(44.77795675722907, 35.358543612396744), + Offset(44.70887861641319, 35.48400393811605), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.3000000000000016, 12.5), + Offset(3.4147766280098404, 12.295787992125664), + Offset(3.8383337983247614, 11.580369732869412), + Offset(4.802941492912208, 10.135911689712586), + Offset(6.904395525334929, 7.614631293565236), + Offset(12.005069007229174, 3.5828104167428108), + Offset(26.075723800682262, 0.4112024320169896), + Offset(41.1117922138676, 7.631537420105092), + Offset(46.11610001573439, 15.537251032079869), + Offset(47.512302184552304, 21.18723517117103), + Offset(47.64869493506418, 25.216235120472277), + Offset(47.31360608049677, 28.14918926097903), + Offset(46.8216717321285, 30.317539026469625), + Offset(46.31154498753962, 31.933155757263034), + Offset(45.84659399308845, 33.13599096437555), + Offset(45.45464707759584, 34.021881997699246), + Offset(45.14597536403577, 34.6577542617379), + Offset(44.92162560467643, 35.0916897746803), + Offset(44.77795675722907, 35.358543612396744), + Offset(44.70887861641319, 35.48400393811605), + ], + <Offset>[ + Offset(3.3000000000000016, 17.1), + Offset(3.3692711334642134, 16.89556290509327), + Offset(3.631383960668174, 16.17571213193979), + Offset(4.25970560126791, 14.703722408867815), + Offset(5.734767299210637, 12.063447969115414), + Offset(9.669596341465768, 7.545835451671554), + Offset(21.874255930583047, 2.2840792930316054), + Offset(36.7179219442753, 6.269955966796905), + Offset(42.592604906489996, 12.580059422608004), + Offset(44.81652115847264, 17.459935255759223), + Offset(45.624216471081716, 21.085679837114938), + Offset(45.81878858891804, 23.798842071745703), + Offset(45.74147347887537, 25.84616656002614), + Offset(45.55382190975979, 27.395991914354134), + Offset(45.33698925184199, 28.564306044381702), + Offset(45.13245278686591, 29.433179475305685), + Offset(44.960879355270905, 30.061479741265988), + Offset(44.831381830124364, 30.492575070614457), + Offset(44.74658145016162, 30.758650614716647), + Offset(44.7053246986958, 30.884005310978335), + ], + <Offset>[ + Offset(3.3000000000000016, 17.1), + Offset(3.3692711334642134, 16.89556290509327), + Offset(3.631383960668174, 16.17571213193979), + Offset(4.25970560126791, 14.703722408867815), + Offset(5.734767299210637, 12.063447969115414), + Offset(9.669596341465768, 7.545835451671554), + Offset(21.874255930583047, 2.2840792930316054), + Offset(36.7179219442753, 6.269955966796905), + Offset(42.592604906489996, 12.580059422608004), + Offset(44.81652115847264, 17.459935255759223), + Offset(45.624216471081716, 21.085679837114938), + Offset(45.81878858891804, 23.798842071745703), + Offset(45.74147347887537, 25.84616656002614), + Offset(45.55382190975979, 27.395991914354134), + Offset(45.33698925184199, 28.564306044381702), + Offset(45.13245278686591, 29.433179475305685), + Offset(44.960879355270905, 30.061479741265988), + Offset(44.831381830124364, 30.492575070614457), + Offset(44.74658145016162, 30.758650614716647), + Offset(44.7053246986958, 30.884005310978335), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.878048780488, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(3.3000000000000016, 26.3), + Offset(3.2782601443729593, 26.095112731028483), + Offset(3.217484285354999, 25.366396930080548), + Offset(3.1732338179793156, 23.839343847178277), + Offset(3.3955108469620523, 20.961081320215772), + Offset(4.998651009938952, 15.471885521529039), + Offset(13.471320190384617, 6.029833015060841), + Offset(27.930181405090686, 3.5467930601805335), + Offset(35.545614688001194, 6.665676203664274), + Offset(39.42495910631331, 10.005335424935609), + Offset(41.57525954311679, 12.824569270400263), + Offset(42.82915360576058, 15.098147693279058), + Offset(43.58107697236911, 16.903421627139174), + Offset(44.03837575420013, 18.32166422853633), + Offset(44.31777976934908, 19.420936204394014), + Offset(44.48806420540607, 20.25577443051856), + Offset(44.59068733774117, 20.86893070032216), + Offset(44.650894281020236, 21.294345662482787), + Offset(44.68383083602673, 21.558864619356445), + Offset(44.698216863261024, 21.684008056702904), + ]), + _PathCubicTo( + <Offset>[ + Offset(3.3000000000000016, 26.3), + Offset(3.2782601443729593, 26.095112731028483), + Offset(3.217484285354999, 25.366396930080548), + Offset(3.1732338179793156, 23.839343847178277), + Offset(3.3955108469620523, 20.961081320215772), + Offset(4.998651009938952, 15.471885521529039), + Offset(13.471320190384617, 6.029833015060841), + Offset(27.930181405090686, 3.5467930601805335), + Offset(35.545614688001194, 6.665676203664274), + Offset(39.42495910631331, 10.005335424935609), + Offset(41.57525954311679, 12.824569270400263), + Offset(42.82915360576058, 15.098147693279058), + Offset(43.58107697236911, 16.903421627139174), + Offset(44.03837575420013, 18.32166422853633), + Offset(44.31777976934908, 19.420936204394014), + Offset(44.48806420540607, 20.25577443051856), + Offset(44.59068733774117, 20.86893070032216), + Offset(44.650894281020236, 21.294345662482787), + Offset(44.68383083602673, 21.558864619356445), + Offset(44.698216863261024, 21.684008056702904), + ], + <Offset>[ + Offset(7.900000000000001, 26.3), + Offset(7.878035057340566, 26.14061822557411), + Offset(7.812826684425378, 25.573346767737135), + Offset(7.741044537134545, 24.382579738822574), + Offset(7.844327522512231, 22.130709546340064), + Offset(8.961676044867694, 17.807358187292447), + Offset(15.344197051399235, 10.231300885160056), + Offset(26.568599951782502, 7.940663329772839), + Offset(32.58842307852933, 10.189171312908673), + Offset(35.6976591909015, 12.701116451015272), + Offset(37.444704259759455, 14.84904773438272), + Offset(38.47880641652726, 16.592965184857785), + Offset(39.10970450592563, 17.983619880392304), + Offset(39.501211911291236, 19.079387306316157), + Offset(39.74609484935523, 19.930540945640473), + Offset(39.89936168301251, 20.577968721248485), + Offset(39.99441281726926, 21.054026709087022), + Offset(40.051779576954395, 21.384589437034855), + Offset(40.08393783834663, 21.59023992642389), + Offset(40.09821823612331, 21.68756197442029), + ], + <Offset>[ + Offset(7.900000000000001, 26.3), + Offset(7.878035057340566, 26.14061822557411), + Offset(7.812826684425378, 25.573346767737135), + Offset(7.741044537134545, 24.382579738822574), + Offset(7.844327522512231, 22.130709546340064), + Offset(8.961676044867694, 17.807358187292447), + Offset(15.344197051399235, 10.231300885160056), + Offset(26.568599951782502, 7.940663329772839), + Offset(32.58842307852933, 10.189171312908673), + Offset(35.6976591909015, 12.701116451015272), + Offset(37.444704259759455, 14.84904773438272), + Offset(38.47880641652726, 16.592965184857785), + Offset(39.10970450592563, 17.983619880392304), + Offset(39.501211911291236, 19.079387306316157), + Offset(39.74609484935523, 19.930540945640473), + Offset(39.89936168301251, 20.577968721248485), + Offset(39.99441281726926, 21.054026709087022), + Offset(40.051779576954395, 21.384589437034855), + Offset(40.08393783834663, 21.59023992642389), + Offset(40.09821823612331, 21.68756197442029), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(7.900000000000001, 26.3), + Offset(7.878035057340566, 26.14061822557411), + Offset(7.812826684425378, 25.573346767737135), + Offset(7.741044537134545, 24.382579738822574), + Offset(7.844327522512231, 22.130709546340064), + Offset(8.961676044867694, 17.807358187292447), + Offset(15.344197051399235, 10.231300885160056), + Offset(26.568599951782502, 7.940663329772839), + Offset(32.58842307852933, 10.189171312908673), + Offset(35.6976591909015, 12.701116451015272), + Offset(37.444704259759455, 14.84904773438272), + Offset(38.47880641652726, 16.592965184857785), + Offset(39.10970450592563, 17.983619880392304), + Offset(39.501211911291236, 19.079387306316157), + Offset(39.74609484935523, 19.930540945640473), + Offset(39.89936168301251, 20.577968721248485), + Offset(39.99441281726926, 21.054026709087022), + Offset(40.051779576954395, 21.384589437034855), + Offset(40.08393783834663, 21.59023992642389), + Offset(40.09821823612331, 21.68756197442029), + ], + <Offset>[ + Offset(7.900000000000001, 21.7), + Offset(7.923540551886193, 21.5408433126065), + Offset(8.019776522081965, 20.97800436866676), + Offset(8.284280428778843, 19.814769019667345), + Offset(9.013955748636523, 17.681892870789884), + Offset(11.2971487106311, 13.844333152363703), + Offset(19.54566492149845, 8.358424024145439), + Offset(30.962470221374808, 9.302244783081026), + Offset(36.111918187773725, 13.146362922380538), + Offset(38.39344021698116, 16.42841636642708), + Offset(39.469182723741916, 18.97960301774006), + Offset(39.97362390810599, 20.94331237409111), + Offset(40.18990275917876, 22.454992346835787), + Offset(40.258934989071065, 23.616551149225057), + Offset(40.25569959060169, 24.50222586563432), + Offset(40.22155597374243, 25.166671243642046), + Offset(40.17950882603412, 25.650301229558934), + Offset(40.14202335150646, 25.98370414110069), + Offset(40.11531314541408, 26.190132924103988), + Offset(40.1017721538407, 26.287560601558003), + ], + <Offset>[ + Offset(7.900000000000001, 21.7), + Offset(7.923540551886193, 21.5408433126065), + Offset(8.019776522081965, 20.97800436866676), + Offset(8.284280428778843, 19.814769019667345), + Offset(9.013955748636523, 17.681892870789884), + Offset(11.2971487106311, 13.844333152363703), + Offset(19.54566492149845, 8.358424024145439), + Offset(30.962470221374808, 9.302244783081026), + Offset(36.111918187773725, 13.146362922380538), + Offset(38.39344021698116, 16.42841636642708), + Offset(39.469182723741916, 18.97960301774006), + Offset(39.97362390810599, 20.94331237409111), + Offset(40.18990275917876, 22.454992346835787), + Offset(40.258934989071065, 23.616551149225057), + Offset(40.25569959060169, 24.50222586563432), + Offset(40.22155597374243, 25.166671243642046), + Offset(40.17950882603412, 25.650301229558934), + Offset(40.14202335150646, 25.98370414110069), + Offset(40.11531314541408, 26.190132924103988), + Offset(40.1017721538407, 26.287560601558003), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(7.900000000000001, 21.7), + Offset(7.923540551886193, 21.5408433126065), + Offset(8.019776522081965, 20.97800436866676), + Offset(8.284280428778843, 19.814769019667345), + Offset(9.013955748636523, 17.681892870789884), + Offset(11.2971487106311, 13.844333152363703), + Offset(19.54566492149845, 8.358424024145439), + Offset(30.962470221374808, 9.302244783081026), + Offset(36.111918187773725, 13.146362922380538), + Offset(38.39344021698116, 16.42841636642708), + Offset(39.469182723741916, 18.97960301774006), + Offset(39.97362390810599, 20.94331237409111), + Offset(40.18990275917876, 22.454992346835787), + Offset(40.258934989071065, 23.616551149225057), + Offset(40.25569959060169, 24.50222586563432), + Offset(40.22155597374243, 25.166671243642046), + Offset(40.17950882603412, 25.650301229558934), + Offset(40.14202335150646, 25.98370414110069), + Offset(40.11531314541408, 26.190132924103988), + Offset(40.1017721538407, 26.287560601558003), + ], + <Offset>[ + Offset(3.3000000000000016, 21.7), + Offset(3.3237656389185863, 21.495337818060875), + Offset(3.4244341230115865, 20.771054531010172), + Offset(3.716469709623613, 19.271533128023048), + Offset(4.565139073086344, 16.512264644665592), + Offset(7.334123675702358, 11.508860486600296), + Offset(17.672788060483832, 4.156956154046225), + Offset(32.32405167468299, 4.90837451348872), + Offset(39.06910979724559, 9.62286781313614), + Offset(42.120740132392974, 13.732635340347416), + Offset(43.599738007099255, 16.9551245537576), + Offset(44.32397109733931, 19.44849488251238), + Offset(44.66127522562224, 21.374794093582658), + Offset(44.79609883197996, 22.85882807144523), + Offset(44.827384510595536, 23.99262112438786), + Offset(44.810258496135994, 24.84447695291212), + Offset(44.77578334650604, 25.465205220794072), + Offset(44.7411380555723, 25.893460366548624), + Offset(44.71520614309418, 26.158757617036542), + Offset(44.701770780978414, 26.284006683840616), + ], + <Offset>[ + Offset(3.3000000000000016, 21.7), + Offset(3.3237656389185863, 21.495337818060875), + Offset(3.4244341230115865, 20.771054531010172), + Offset(3.716469709623613, 19.271533128023048), + Offset(4.565139073086344, 16.512264644665592), + Offset(7.334123675702358, 11.508860486600296), + Offset(17.672788060483832, 4.156956154046225), + Offset(32.32405167468299, 4.90837451348872), + Offset(39.06910979724559, 9.62286781313614), + Offset(42.120740132392974, 13.732635340347416), + Offset(43.599738007099255, 16.9551245537576), + Offset(44.32397109733931, 19.44849488251238), + Offset(44.66127522562224, 21.374794093582658), + Offset(44.79609883197996, 22.85882807144523), + Offset(44.827384510595536, 23.99262112438786), + Offset(44.810258496135994, 24.84447695291212), + Offset(44.77578334650604, 25.465205220794072), + Offset(44.7411380555723, 25.893460366548624), + Offset(44.71520614309418, 26.158757617036542), + Offset(44.701770780978414, 26.284006683840616), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.3000000000000016, 21.7), + Offset(3.3237656389185863, 21.495337818060875), + Offset(3.4244341230115865, 20.771054531010172), + Offset(3.716469709623613, 19.271533128023048), + Offset(4.565139073086344, 16.512264644665592), + Offset(7.334123675702358, 11.508860486600296), + Offset(17.672788060483832, 4.156956154046225), + Offset(32.32405167468299, 4.90837451348872), + Offset(39.06910979724559, 9.62286781313614), + Offset(42.120740132392974, 13.732635340347416), + Offset(43.599738007099255, 16.9551245537576), + Offset(44.32397109733931, 19.44849488251238), + Offset(44.66127522562224, 21.374794093582658), + Offset(44.79609883197996, 22.85882807144523), + Offset(44.827384510595536, 23.99262112438786), + Offset(44.810258496135994, 24.84447695291212), + Offset(44.77578334650604, 25.465205220794072), + Offset(44.7411380555723, 25.893460366548624), + Offset(44.71520614309418, 26.158757617036542), + Offset(44.701770780978414, 26.284006683840616), + ], + <Offset>[ + Offset(3.3000000000000016, 26.3), + Offset(3.2782601443729593, 26.095112731028483), + Offset(3.217484285354999, 25.366396930080548), + Offset(3.1732338179793156, 23.839343847178277), + Offset(3.3955108469620523, 20.961081320215772), + Offset(4.998651009938952, 15.471885521529039), + Offset(13.471320190384617, 6.029833015060841), + Offset(27.930181405090686, 3.5467930601805335), + Offset(35.545614688001194, 6.665676203664274), + Offset(39.42495910631331, 10.005335424935609), + Offset(41.57525954311679, 12.824569270400263), + Offset(42.82915360576058, 15.098147693279058), + Offset(43.58107697236911, 16.903421627139174), + Offset(44.03837575420013, 18.32166422853633), + Offset(44.31777976934908, 19.420936204394014), + Offset(44.48806420540607, 20.25577443051856), + Offset(44.59068733774117, 20.86893070032216), + Offset(44.650894281020236, 21.294345662482787), + Offset(44.68383083602673, 21.558864619356445), + Offset(44.698216863261024, 21.684008056702904), + ], + <Offset>[ + Offset(3.3000000000000016, 26.3), + Offset(3.2782601443729593, 26.095112731028483), + Offset(3.217484285354999, 25.366396930080548), + Offset(3.1732338179793156, 23.839343847178277), + Offset(3.3955108469620523, 20.961081320215772), + Offset(4.998651009938952, 15.471885521529039), + Offset(13.471320190384617, 6.029833015060841), + Offset(27.930181405090686, 3.5467930601805335), + Offset(35.545614688001194, 6.665676203664274), + Offset(39.42495910631331, 10.005335424935609), + Offset(41.57525954311679, 12.824569270400263), + Offset(42.82915360576058, 15.098147693279058), + Offset(43.58107697236911, 16.903421627139174), + Offset(44.03837575420013, 18.32166422853633), + Offset(44.31777976934908, 19.420936204394014), + Offset(44.48806420540607, 20.25577443051856), + Offset(44.59068733774117, 20.86893070032216), + Offset(44.650894281020236, 21.294345662482787), + Offset(44.68383083602673, 21.558864619356445), + Offset(44.698216863261024, 21.684008056702904), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.878048780488, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(12.5, 35.5), + Offset(12.386798981216916, 35.38567354605495), + Offset(11.994269408182586, 34.97098140353448), + Offset(11.222383473001184, 34.06143706877733), + Offset(9.953887745813823, 32.19797112356471), + Offset(8.253755748269624, 28.068880922913333), + Offset(8.81413817221542, 18.1785224772885), + Offset(16.419277959289705, 9.611370692748771), + Offset(22.584241250568667, 7.798283203209337), + Offset(26.57879722333036, 7.942297646271321), + Offset(29.2651920484372, 8.612415631650505), + Offset(31.13882424413648, 9.387088297969871), + Offset(32.47793553297588, 10.121073200758469), + Offset(33.44860191282268, 10.762782698278183), + Offset(34.15520044686847, 11.296775846899239), + Offset(34.666270579159104, 11.72275796719129), + Offset(35.027946279267624, 12.046573676908062), + Offset(35.27217732378443, 12.276603803455243), + Offset(35.42129422653164, 12.421829238131133), + Offset(35.49111177355083, 12.491118637862245), + ]), + _PathCubicTo( + <Offset>[ + Offset(12.5, 35.5), + Offset(12.386798981216916, 35.38567354605495), + Offset(11.994269408182586, 34.97098140353448), + Offset(11.222383473001184, 34.06143706877733), + Offset(9.953887745813823, 32.19797112356471), + Offset(8.253755748269624, 28.068880922913333), + Offset(8.81413817221542, 18.1785224772885), + Offset(16.419277959289705, 9.611370692748771), + Offset(22.584241250568667, 7.798283203209337), + Offset(26.57879722333036, 7.942297646271321), + Offset(29.2651920484372, 8.612415631650505), + Offset(31.13882424413648, 9.387088297969871), + Offset(32.47793553297588, 10.121073200758469), + Offset(33.44860191282268, 10.762782698278183), + Offset(34.15520044686847, 11.296775846899239), + Offset(34.666270579159104, 11.72275796719129), + Offset(35.027946279267624, 12.046573676908062), + Offset(35.27217732378443, 12.276603803455243), + Offset(35.42129422653164, 12.421829238131133), + Offset(35.49111177355083, 12.491118637862245), + ], + <Offset>[ + Offset(44.699999999999996, 35.5), + Offset(44.58522337199017, 35.70421200787435), + Offset(44.161666201675246, 36.41963026713059), + Offset(43.197058507087796, 37.86408831028741), + Offset(41.09560447466507, 40.38536870643476), + Offset(35.994930992770826, 44.41718958325718), + Offset(21.92427619931773, 47.58879756798301), + Offset(6.8882077861324005, 40.36846257989491), + Offset(1.8838999842656072, 32.46274896792012), + Offset(0.4876978154477065, 26.81276482882897), + Offset(0.3513050649358309, 22.783764879527716), + Offset(0.6863939195032227, 19.85081073902098), + Offset(1.1783282678714926, 17.682460973530382), + Offset(1.6884550124603805, 16.066844242736977), + Offset(2.1534060069115526, 14.864009035624443), + Offset(2.5453529224041667, 13.978118002300755), + Offset(2.854024635964233, 13.34224573826211), + Offset(3.0783743953235785, 12.9083102253197), + Offset(3.222043242770944, 12.641456387603249), + Offset(3.2911213835868267, 12.515996061883946), + ], + <Offset>[ + Offset(44.699999999999996, 35.5), + Offset(44.58522337199017, 35.70421200787435), + Offset(44.161666201675246, 36.41963026713059), + Offset(43.197058507087796, 37.86408831028741), + Offset(41.09560447466507, 40.38536870643476), + Offset(35.994930992770826, 44.41718958325718), + Offset(21.92427619931773, 47.58879756798301), + Offset(6.8882077861324005, 40.36846257989491), + Offset(1.8838999842656072, 32.46274896792012), + Offset(0.4876978154477065, 26.81276482882897), + Offset(0.3513050649358309, 22.783764879527716), + Offset(0.6863939195032227, 19.85081073902098), + Offset(1.1783282678714926, 17.682460973530382), + Offset(1.6884550124603805, 16.066844242736977), + Offset(2.1534060069115526, 14.864009035624443), + Offset(2.5453529224041667, 13.978118002300755), + Offset(2.854024635964233, 13.34224573826211), + Offset(3.0783743953235785, 12.9083102253197), + Offset(3.222043242770944, 12.641456387603249), + Offset(3.2911213835868267, 12.515996061883946), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.699999999999996, 35.5), + Offset(44.58522337199017, 35.70421200787435), + Offset(44.161666201675246, 36.41963026713059), + Offset(43.197058507087796, 37.86408831028741), + Offset(41.09560447466507, 40.38536870643476), + Offset(35.994930992770826, 44.41718958325718), + Offset(21.92427619931773, 47.58879756798301), + Offset(6.8882077861324005, 40.36846257989491), + Offset(1.8838999842656072, 32.46274896792012), + Offset(0.4876978154477065, 26.81276482882897), + Offset(0.3513050649358309, 22.783764879527716), + Offset(0.6863939195032227, 19.85081073902098), + Offset(1.1783282678714926, 17.682460973530382), + Offset(1.6884550124603805, 16.066844242736977), + Offset(2.1534060069115526, 14.864009035624443), + Offset(2.5453529224041667, 13.978118002300755), + Offset(2.854024635964233, 13.34224573826211), + Offset(3.0783743953235785, 12.9083102253197), + Offset(3.222043242770944, 12.641456387603249), + Offset(3.2911213835868267, 12.515996061883946), + ], + <Offset>[ + Offset(44.699999999999996, 30.900000000000002), + Offset(44.63072886653579, 31.104437094906736), + Offset(44.368616039331826, 31.82428786806021), + Offset(43.74029439873209, 33.296277591132174), + Offset(42.26523270078937, 35.936552030884584), + Offset(38.33040365853423, 40.454164548328436), + Offset(26.125744069416946, 45.71592070696839), + Offset(11.282078055724707, 41.73004403320309), + Offset(5.407395093510004, 35.41994057739199), + Offset(3.1834788415273714, 30.54006474424078), + Offset(2.375783528918289, 26.914320162885055), + Offset(2.181211411081952, 24.2011579282543), + Offset(2.2585265211246224, 22.153833439973866), + Offset(2.446178090240206, 20.604008085645876), + Offset(2.66301074815801, 19.43569395561829), + Offset(2.8675472131340918, 18.566820524694318), + Offset(3.0391206447290973, 17.938520258734023), + Offset(3.168618169875643, 17.507424929385536), + Offset(3.2534185498383863, 17.24134938528335), + Offset(3.29467530130421, 17.11599468902166), + ], + <Offset>[ + Offset(44.699999999999996, 30.900000000000002), + Offset(44.63072886653579, 31.104437094906736), + Offset(44.368616039331826, 31.82428786806021), + Offset(43.74029439873209, 33.296277591132174), + Offset(42.26523270078937, 35.936552030884584), + Offset(38.33040365853423, 40.454164548328436), + Offset(26.125744069416946, 45.71592070696839), + Offset(11.282078055724707, 41.73004403320309), + Offset(5.407395093510004, 35.41994057739199), + Offset(3.1834788415273714, 30.54006474424078), + Offset(2.375783528918289, 26.914320162885055), + Offset(2.181211411081952, 24.2011579282543), + Offset(2.2585265211246224, 22.153833439973866), + Offset(2.446178090240206, 20.604008085645876), + Offset(2.66301074815801, 19.43569395561829), + Offset(2.8675472131340918, 18.566820524694318), + Offset(3.0391206447290973, 17.938520258734023), + Offset(3.168618169875643, 17.507424929385536), + Offset(3.2534185498383863, 17.24134938528335), + Offset(3.29467530130421, 17.11599468902166), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.699999999999996, 30.900000000000002), + Offset(44.63072886653579, 31.104437094906736), + Offset(44.368616039331826, 31.82428786806021), + Offset(43.74029439873209, 33.296277591132174), + Offset(42.26523270078937, 35.936552030884584), + Offset(38.33040365853423, 40.454164548328436), + Offset(26.125744069416946, 45.71592070696839), + Offset(11.282078055724707, 41.73004403320309), + Offset(5.407395093510004, 35.41994057739199), + Offset(3.1834788415273714, 30.54006474424078), + Offset(2.375783528918289, 26.914320162885055), + Offset(2.181211411081952, 24.2011579282543), + Offset(2.2585265211246224, 22.153833439973866), + Offset(2.446178090240206, 20.604008085645876), + Offset(2.66301074815801, 19.43569395561829), + Offset(2.8675472131340918, 18.566820524694318), + Offset(3.0391206447290973, 17.938520258734023), + Offset(3.168618169875643, 17.507424929385536), + Offset(3.2534185498383863, 17.24134938528335), + Offset(3.29467530130421, 17.11599468902166), + ], + <Offset>[ + Offset(12.5, 30.900000000000002), + Offset(12.432304475762546, 30.785898633087346), + Offset(12.201219245839173, 30.3756390044641), + Offset(11.765619364645481, 29.493626349622097), + Offset(11.123515971938117, 27.749154448014536), + Offset(10.589228414033032, 24.10585588798459), + Offset(13.015606042314635, 16.305645616273882), + Offset(20.81314822888201, 10.972952146056956), + Offset(26.107736359813064, 10.755474812681204), + Offset(29.274578249410023, 11.66959756168313), + Offset(31.28967051241966, 12.742970915007843), + Offset(32.63364173571521, 13.737435487203195), + Offset(33.55813378622901, 14.592445667201952), + Offset(34.20632499060251, 15.299946541187085), + Offset(34.664805188114926, 15.868460766893085), + Offset(34.98846486988903, 16.311460489584853), + Offset(35.21304228803248, 16.642848197379976), + Offset(35.3624210983365, 16.87571850752108), + Offset(35.452669533599085, 17.021722235811232), + Offset(35.49466569126821, 17.09111726499996), + ], + <Offset>[ + Offset(12.5, 30.900000000000002), + Offset(12.432304475762546, 30.785898633087346), + Offset(12.201219245839173, 30.3756390044641), + Offset(11.765619364645481, 29.493626349622097), + Offset(11.123515971938117, 27.749154448014536), + Offset(10.589228414033032, 24.10585588798459), + Offset(13.015606042314635, 16.305645616273882), + Offset(20.81314822888201, 10.972952146056956), + Offset(26.107736359813064, 10.755474812681204), + Offset(29.274578249410023, 11.66959756168313), + Offset(31.28967051241966, 12.742970915007843), + Offset(32.63364173571521, 13.737435487203195), + Offset(33.55813378622901, 14.592445667201952), + Offset(34.20632499060251, 15.299946541187085), + Offset(34.664805188114926, 15.868460766893085), + Offset(34.98846486988903, 16.311460489584853), + Offset(35.21304228803248, 16.642848197379976), + Offset(35.3624210983365, 16.87571850752108), + Offset(35.452669533599085, 17.021722235811232), + Offset(35.49466569126821, 17.09111726499996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.5, 30.900000000000002), + Offset(12.432304475762546, 30.785898633087346), + Offset(12.201219245839173, 30.3756390044641), + Offset(11.765619364645481, 29.493626349622097), + Offset(11.123515971938117, 27.749154448014536), + Offset(10.589228414033032, 24.10585588798459), + Offset(13.015606042314635, 16.305645616273882), + Offset(20.81314822888201, 10.972952146056956), + Offset(26.107736359813064, 10.755474812681204), + Offset(29.274578249410023, 11.66959756168313), + Offset(31.28967051241966, 12.742970915007843), + Offset(32.63364173571521, 13.737435487203195), + Offset(33.55813378622901, 14.592445667201952), + Offset(34.20632499060251, 15.299946541187085), + Offset(34.664805188114926, 15.868460766893085), + Offset(34.98846486988903, 16.311460489584853), + Offset(35.21304228803248, 16.642848197379976), + Offset(35.3624210983365, 16.87571850752108), + Offset(35.452669533599085, 17.021722235811232), + Offset(35.49466569126821, 17.09111726499996), + ], + <Offset>[ + Offset(12.5, 35.5), + Offset(12.386798981216916, 35.38567354605495), + Offset(11.994269408182586, 34.97098140353448), + Offset(11.222383473001184, 34.06143706877733), + Offset(9.953887745813823, 32.19797112356471), + Offset(8.253755748269624, 28.068880922913333), + Offset(8.81413817221542, 18.1785224772885), + Offset(16.419277959289705, 9.611370692748771), + Offset(22.584241250568667, 7.798283203209337), + Offset(26.57879722333036, 7.942297646271321), + Offset(29.2651920484372, 8.612415631650505), + Offset(31.13882424413648, 9.387088297969871), + Offset(32.47793553297588, 10.121073200758469), + Offset(33.44860191282268, 10.762782698278183), + Offset(34.15520044686847, 11.296775846899239), + Offset(34.666270579159104, 11.72275796719129), + Offset(35.027946279267624, 12.046573676908062), + Offset(35.27217732378443, 12.276603803455243), + Offset(35.42129422653164, 12.421829238131133), + Offset(35.49111177355083, 12.491118637862245), + ], + <Offset>[ + Offset(12.5, 35.5), + Offset(12.386798981216916, 35.38567354605495), + Offset(11.994269408182586, 34.97098140353448), + Offset(11.222383473001184, 34.06143706877733), + Offset(9.953887745813823, 32.19797112356471), + Offset(8.253755748269624, 28.068880922913333), + Offset(8.81413817221542, 18.1785224772885), + Offset(16.419277959289705, 9.611370692748771), + Offset(22.584241250568667, 7.798283203209337), + Offset(26.57879722333036, 7.942297646271321), + Offset(29.2651920484372, 8.612415631650505), + Offset(31.13882424413648, 9.387088297969871), + Offset(32.47793553297588, 10.121073200758469), + Offset(33.44860191282268, 10.762782698278183), + Offset(34.15520044686847, 11.296775846899239), + Offset(34.666270579159104, 11.72275796719129), + Offset(35.027946279267624, 12.046573676908062), + Offset(35.27217732378443, 12.276603803455243), + Offset(35.42129422653164, 12.421829238131133), + Offset(35.49111177355083, 12.491118637862245), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.878048780488, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(12.5, 17.1), + Offset(12.568820959399424, 16.986573894184524), + Offset(12.822068758808932, 16.589611807252965), + Offset(13.395327039578369, 15.790194192156408), + Offset(14.632400650310995, 14.402704421364), + Offset(17.59564641132325, 12.216780783198363), + Offset(25.620009652612282, 10.687015033230036), + Offset(33.99475903765893, 15.057696505981518), + Offset(36.67822168754626, 19.6270496410968), + Offset(37.361921327649014, 22.851497307918553), + Offset(37.36310590436704, 25.13463676507986), + Offset(37.118094210451396, 26.788477054903165), + Offset(36.7987285459884, 28.0065630665324), + Offset(36.479494223942, 28.91143806991379), + Offset(36.1936194118543, 29.58351552687462), + Offset(35.95504774207879, 30.077568056765532), + Offset(35.76833031432708, 30.43167175879571), + Offset(35.63315242199269, 30.673062619718593), + Offset(35.54679545480142, 30.821401228851528), + Offset(35.50532744442037, 30.891113146413097), + ]), + _PathCubicTo( + <Offset>[ + Offset(12.5, 17.1), + Offset(12.568820959399424, 16.986573894184524), + Offset(12.822068758808932, 16.589611807252965), + Offset(13.395327039578369, 15.790194192156408), + Offset(14.632400650310995, 14.402704421364), + Offset(17.59564641132325, 12.216780783198363), + Offset(25.620009652612282, 10.687015033230036), + Offset(33.99475903765893, 15.057696505981518), + Offset(36.67822168754626, 19.6270496410968), + Offset(37.361921327649014, 22.851497307918553), + Offset(37.36310590436704, 25.13463676507986), + Offset(37.118094210451396, 26.788477054903165), + Offset(36.7987285459884, 28.0065630665324), + Offset(36.479494223942, 28.91143806991379), + Offset(36.1936194118543, 29.58351552687462), + Offset(35.95504774207879, 30.077568056765532), + Offset(35.76833031432708, 30.43167175879571), + Offset(35.63315242199269, 30.673062619718593), + Offset(35.54679545480142, 30.821401228851528), + Offset(35.50532744442037, 30.891113146413097), + ], + <Offset>[ + Offset(44.699999999999996, 17.1), + Offset(44.76724535017267, 17.305112356003914), + Offset(44.98946555230159, 18.038260670849073), + Offset(45.37000207366498, 19.592845433666486), + Offset(45.77411737916225, 22.590102004234048), + Offset(45.33682165582445, 28.565089443542213), + Offset(38.7301476797146, 40.09729012392454), + Offset(24.463688864501623, 45.81478839312766), + Offset(15.977880421243203, 44.29151540580759), + Offset(11.270821919766366, 41.7219644904762), + Offset(8.44921892086567, 39.30598601295708), + Offset(6.665663885818139, 37.25219949595427), + Offset(5.499121280884019, 35.56795083930431), + Offset(4.71934732357969, 34.21549961437258), + Offset(4.191824971897383, 33.150748715599825), + Offset(3.83413008532386, 32.332928091875), + Offset(3.5944086710236895, 31.727343820149756), + Offset(3.439349493531836, 31.30476904158305), + Offset(3.3475444710407203, 31.041028378323645), + Offset(3.3053370544563663, 30.9159905704348), + ], + <Offset>[ + Offset(44.699999999999996, 17.1), + Offset(44.76724535017267, 17.305112356003914), + Offset(44.98946555230159, 18.038260670849073), + Offset(45.37000207366498, 19.592845433666486), + Offset(45.77411737916225, 22.590102004234048), + Offset(45.33682165582445, 28.565089443542213), + Offset(38.7301476797146, 40.09729012392454), + Offset(24.463688864501623, 45.81478839312766), + Offset(15.977880421243203, 44.29151540580759), + Offset(11.270821919766366, 41.7219644904762), + Offset(8.44921892086567, 39.30598601295708), + Offset(6.665663885818139, 37.25219949595427), + Offset(5.499121280884019, 35.56795083930431), + Offset(4.71934732357969, 34.21549961437258), + Offset(4.191824971897383, 33.150748715599825), + Offset(3.83413008532386, 32.332928091875), + Offset(3.5944086710236895, 31.727343820149756), + Offset(3.439349493531836, 31.30476904158305), + Offset(3.3475444710407203, 31.041028378323645), + Offset(3.3053370544563663, 30.9159905704348), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.699999999999996, 17.1), + Offset(44.76724535017267, 17.305112356003914), + Offset(44.98946555230159, 18.038260670849073), + Offset(45.37000207366498, 19.592845433666486), + Offset(45.77411737916225, 22.590102004234048), + Offset(45.33682165582445, 28.565089443542213), + Offset(38.7301476797146, 40.09729012392454), + Offset(24.463688864501623, 45.81478839312766), + Offset(15.977880421243203, 44.29151540580759), + Offset(11.270821919766366, 41.7219644904762), + Offset(8.44921892086567, 39.30598601295708), + Offset(6.665663885818139, 37.25219949595427), + Offset(5.499121280884019, 35.56795083930431), + Offset(4.71934732357969, 34.21549961437258), + Offset(4.191824971897383, 33.150748715599825), + Offset(3.83413008532386, 32.332928091875), + Offset(3.5944086710236895, 31.727343820149756), + Offset(3.439349493531836, 31.30476904158305), + Offset(3.3475444710407203, 31.041028378323645), + Offset(3.3053370544563663, 30.9159905704348), + ], + <Offset>[ + Offset(44.699999999999996, 12.5), + Offset(44.8127508447183, 12.705337443036306), + Offset(45.196415389958176, 13.442918271778694), + Offset(45.91323796530928, 15.025034714511255), + Offset(46.94374560528654, 18.14128532868387), + Offset(47.67229432158786, 24.60206440861347), + Offset(42.93161554981381, 38.22441326290992), + Offset(28.85755913409393, 47.17636984643584), + Offset(19.5013755304876, 47.24870701527945), + Offset(13.96660294584603, 45.449264405888016), + Offset(10.473697384848128, 43.43654129631442), + Offset(8.160481377396868, 41.60254668518759), + Offset(6.579319534137149, 40.03932330574779), + Offset(5.477070401359516, 38.75266345728148), + Offset(4.70142971314384, 37.722433635593674), + Offset(4.156324376053785, 36.92163061426856), + Offset(3.7795046797885536, 36.32361834062167), + Offset(3.5295932680839, 35.90388374564888), + Offset(3.3789197781081626, 35.640921376003746), + Offset(3.3088909721737494, 35.515989197572516), + ], + <Offset>[ + Offset(44.699999999999996, 12.5), + Offset(44.8127508447183, 12.705337443036306), + Offset(45.196415389958176, 13.442918271778694), + Offset(45.91323796530928, 15.025034714511255), + Offset(46.94374560528654, 18.14128532868387), + Offset(47.67229432158786, 24.60206440861347), + Offset(42.93161554981381, 38.22441326290992), + Offset(28.85755913409393, 47.17636984643584), + Offset(19.5013755304876, 47.24870701527945), + Offset(13.96660294584603, 45.449264405888016), + Offset(10.473697384848128, 43.43654129631442), + Offset(8.160481377396868, 41.60254668518759), + Offset(6.579319534137149, 40.03932330574779), + Offset(5.477070401359516, 38.75266345728148), + Offset(4.70142971314384, 37.722433635593674), + Offset(4.156324376053785, 36.92163061426856), + Offset(3.7795046797885536, 36.32361834062167), + Offset(3.5295932680839, 35.90388374564888), + Offset(3.3789197781081626, 35.640921376003746), + Offset(3.3088909721737494, 35.515989197572516), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.699999999999996, 12.5), + Offset(44.8127508447183, 12.705337443036306), + Offset(45.196415389958176, 13.442918271778694), + Offset(45.91323796530928, 15.025034714511255), + Offset(46.94374560528654, 18.14128532868387), + Offset(47.67229432158786, 24.60206440861347), + Offset(42.93161554981381, 38.22441326290992), + Offset(28.85755913409393, 47.17636984643584), + Offset(19.5013755304876, 47.24870701527945), + Offset(13.96660294584603, 45.449264405888016), + Offset(10.473697384848128, 43.43654129631442), + Offset(8.160481377396868, 41.60254668518759), + Offset(6.579319534137149, 40.03932330574779), + Offset(5.477070401359516, 38.75266345728148), + Offset(4.70142971314384, 37.722433635593674), + Offset(4.156324376053785, 36.92163061426856), + Offset(3.7795046797885536, 36.32361834062167), + Offset(3.5295932680839, 35.90388374564888), + Offset(3.3789197781081626, 35.640921376003746), + Offset(3.3088909721737494, 35.515989197572516), + ], + <Offset>[ + Offset(12.5, 12.5), + Offset(12.614326453945054, 12.386798981216916), + Offset(13.02901859646552, 11.994269408182586), + Offset(13.938562931222666, 11.222383473001177), + Offset(15.802028876435289, 9.953887745813823), + Offset(19.931119077086663, 8.25375574826962), + Offset(29.821477522711497, 8.814138172215419), + Offset(38.388629307251236, 16.4192779592897), + Offset(40.20171679679066, 22.584241250568667), + Offset(40.057702353728686, 26.578797223330362), + Offset(39.3875843683495, 29.2651920484372), + Offset(38.612911702030125, 31.138824244136487), + Offset(37.87892679924153, 32.477935532975884), + Offset(37.23721730172182, 33.44860191282269), + Offset(36.703224153100756, 34.15520044686847), + Offset(36.277242032808715, 34.6662705791591), + Offset(35.953426323091946, 35.027946279267624), + Offset(35.723396196544755, 35.272177323784426), + Offset(35.57817076186886, 35.42129422653163), + Offset(35.50888136213775, 35.49111177355081), + ], + <Offset>[ + Offset(12.5, 12.5), + Offset(12.614326453945054, 12.386798981216916), + Offset(13.02901859646552, 11.994269408182586), + Offset(13.938562931222666, 11.222383473001177), + Offset(15.802028876435289, 9.953887745813823), + Offset(19.931119077086663, 8.25375574826962), + Offset(29.821477522711497, 8.814138172215419), + Offset(38.388629307251236, 16.4192779592897), + Offset(40.20171679679066, 22.584241250568667), + Offset(40.057702353728686, 26.578797223330362), + Offset(39.3875843683495, 29.2651920484372), + Offset(38.612911702030125, 31.138824244136487), + Offset(37.87892679924153, 32.477935532975884), + Offset(37.23721730172182, 33.44860191282269), + Offset(36.703224153100756, 34.15520044686847), + Offset(36.277242032808715, 34.6662705791591), + Offset(35.953426323091946, 35.027946279267624), + Offset(35.723396196544755, 35.272177323784426), + Offset(35.57817076186886, 35.42129422653163), + Offset(35.50888136213775, 35.49111177355081), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.5, 12.5), + Offset(12.614326453945054, 12.386798981216916), + Offset(13.02901859646552, 11.994269408182586), + Offset(13.938562931222666, 11.222383473001177), + Offset(15.802028876435289, 9.953887745813823), + Offset(19.931119077086663, 8.25375574826962), + Offset(29.821477522711497, 8.814138172215419), + Offset(38.388629307251236, 16.4192779592897), + Offset(40.20171679679066, 22.584241250568667), + Offset(40.057702353728686, 26.578797223330362), + Offset(39.3875843683495, 29.2651920484372), + Offset(38.612911702030125, 31.138824244136487), + Offset(37.87892679924153, 32.477935532975884), + Offset(37.23721730172182, 33.44860191282269), + Offset(36.703224153100756, 34.15520044686847), + Offset(36.277242032808715, 34.6662705791591), + Offset(35.953426323091946, 35.027946279267624), + Offset(35.723396196544755, 35.272177323784426), + Offset(35.57817076186886, 35.42129422653163), + Offset(35.50888136213775, 35.49111177355081), + ], + <Offset>[ + Offset(12.5, 17.1), + Offset(12.568820959399424, 16.986573894184524), + Offset(12.822068758808932, 16.589611807252965), + Offset(13.395327039578369, 15.790194192156408), + Offset(14.632400650310995, 14.402704421364), + Offset(17.59564641132325, 12.216780783198363), + Offset(25.620009652612282, 10.687015033230036), + Offset(33.99475903765893, 15.057696505981518), + Offset(36.67822168754626, 19.6270496410968), + Offset(37.361921327649014, 22.851497307918553), + Offset(37.36310590436704, 25.13463676507986), + Offset(37.118094210451396, 26.788477054903165), + Offset(36.7987285459884, 28.0065630665324), + Offset(36.479494223942, 28.91143806991379), + Offset(36.1936194118543, 29.58351552687462), + Offset(35.95504774207879, 30.077568056765532), + Offset(35.76833031432708, 30.43167175879571), + Offset(35.63315242199269, 30.673062619718593), + Offset(35.54679545480142, 30.821401228851528), + Offset(35.50532744442037, 30.891113146413097), + ], + <Offset>[ + Offset(12.5, 17.1), + Offset(12.568820959399424, 16.986573894184524), + Offset(12.822068758808932, 16.589611807252965), + Offset(13.395327039578369, 15.790194192156408), + Offset(14.632400650310995, 14.402704421364), + Offset(17.59564641132325, 12.216780783198363), + Offset(25.620009652612282, 10.687015033230036), + Offset(33.99475903765893, 15.057696505981518), + Offset(36.67822168754626, 19.6270496410968), + Offset(37.361921327649014, 22.851497307918553), + Offset(37.36310590436704, 25.13463676507986), + Offset(37.118094210451396, 26.788477054903165), + Offset(36.7987285459884, 28.0065630665324), + Offset(36.479494223942, 28.91143806991379), + Offset(36.1936194118543, 29.58351552687462), + Offset(35.95504774207879, 30.077568056765532), + Offset(35.76833031432708, 30.43167175879571), + Offset(35.63315242199269, 30.673062619718593), + Offset(35.54679545480142, 30.821401228851528), + Offset(35.50532744442037, 30.891113146413097), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.878048780488, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(12.5, 26.3), + Offset(12.477809970308172, 26.186123720119735), + Offset(12.40816908349576, 25.780296605393723), + Offset(12.308855256289778, 24.925815630466868), + Offset(12.293144198062407, 23.30033777246436), + Offset(12.924701079796437, 20.14283085305585), + Offset(17.217073912413852, 14.432768755259271), + Offset(25.207018498474316, 12.334533599365145), + Offset(29.631231469057468, 13.71266642215307), + Offset(31.970359275489688, 15.396897477094935), + Offset(33.314148976402116, 16.873526198365184), + Offset(34.12845922729394, 18.087782676436515), + Offset(34.638332039482144, 19.063818133645434), + Offset(34.96404806838234, 19.837110384095983), + Offset(35.17440992936138, 20.44014568688693), + Offset(35.31065916061894, 20.900163011978407), + Offset(35.398138296797356, 21.239122717851885), + Offset(35.45266487288856, 21.47483321158692), + Offset(35.484044840666535, 21.62161523349133), + Offset(35.4982196089856, 21.691115892137674), + ]), + _PathCubicTo( + <Offset>[ + Offset(12.5, 26.3), + Offset(12.477809970308172, 26.186123720119735), + Offset(12.40816908349576, 25.780296605393723), + Offset(12.308855256289778, 24.925815630466868), + Offset(12.293144198062407, 23.30033777246436), + Offset(12.924701079796437, 20.14283085305585), + Offset(17.217073912413852, 14.432768755259271), + Offset(25.207018498474316, 12.334533599365145), + Offset(29.631231469057468, 13.71266642215307), + Offset(31.970359275489688, 15.396897477094935), + Offset(33.314148976402116, 16.873526198365184), + Offset(34.12845922729394, 18.087782676436515), + Offset(34.638332039482144, 19.063818133645434), + Offset(34.96404806838234, 19.837110384095983), + Offset(35.17440992936138, 20.44014568688693), + Offset(35.31065916061894, 20.900163011978407), + Offset(35.398138296797356, 21.239122717851885), + Offset(35.45266487288856, 21.47483321158692), + Offset(35.484044840666535, 21.62161523349133), + Offset(35.4982196089856, 21.691115892137674), + ], + <Offset>[ + Offset(44.699999999999996, 26.3), + Offset(44.67623436108142, 26.504662181939125), + Offset(44.57556587698842, 27.22894546898983), + Offset(44.28353029037639, 28.72846687197695), + Offset(43.43486092691366, 31.487735355334408), + Offset(40.665876324297635, 36.4911395133997), + Offset(30.32721193951616, 43.84304384595377), + Offset(15.675948325317012, 43.09162548651128), + Offset(8.930890202754409, 38.37713218686386), + Offset(5.879259867607036, 34.267364659652586), + Offset(4.400261992900747, 31.044875446242393), + Offset(3.676028902660681, 28.551505117487622), + Offset(3.3387247743777593, 26.62520590641735), + Offset(3.2039011680200318, 25.141171928554776), + Offset(3.172615489404464, 24.007378875612137), + Offset(3.18974150386401, 23.155523047087872), + Offset(3.224216653493965, 22.53479477920593), + Offset(3.258861944427707, 22.106539633451376), + Offset(3.2847938569058357, 21.841242382963447), + Offset(3.2982292190216, 21.715993316159373), + ], + <Offset>[ + Offset(44.699999999999996, 26.3), + Offset(44.67623436108142, 26.504662181939125), + Offset(44.57556587698842, 27.22894546898983), + Offset(44.28353029037639, 28.72846687197695), + Offset(43.43486092691366, 31.487735355334408), + Offset(40.665876324297635, 36.4911395133997), + Offset(30.32721193951616, 43.84304384595377), + Offset(15.675948325317012, 43.09162548651128), + Offset(8.930890202754409, 38.37713218686386), + Offset(5.879259867607036, 34.267364659652586), + Offset(4.400261992900747, 31.044875446242393), + Offset(3.676028902660681, 28.551505117487622), + Offset(3.3387247743777593, 26.62520590641735), + Offset(3.2039011680200318, 25.141171928554776), + Offset(3.172615489404464, 24.007378875612137), + Offset(3.18974150386401, 23.155523047087872), + Offset(3.224216653493965, 22.53479477920593), + Offset(3.258861944427707, 22.106539633451376), + Offset(3.2847938569058357, 21.841242382963447), + Offset(3.2982292190216, 21.715993316159373), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.699999999999996, 26.3), + Offset(44.67623436108142, 26.504662181939125), + Offset(44.57556587698842, 27.22894546898983), + Offset(44.28353029037639, 28.72846687197695), + Offset(43.43486092691366, 31.487735355334408), + Offset(40.665876324297635, 36.4911395133997), + Offset(30.32721193951616, 43.84304384595377), + Offset(15.675948325317012, 43.09162548651128), + Offset(8.930890202754409, 38.37713218686386), + Offset(5.879259867607036, 34.267364659652586), + Offset(4.400261992900747, 31.044875446242393), + Offset(3.676028902660681, 28.551505117487622), + Offset(3.3387247743777593, 26.62520590641735), + Offset(3.2039011680200318, 25.141171928554776), + Offset(3.172615489404464, 24.007378875612137), + Offset(3.18974150386401, 23.155523047087872), + Offset(3.224216653493965, 22.53479477920593), + Offset(3.258861944427707, 22.106539633451376), + Offset(3.2847938569058357, 21.841242382963447), + Offset(3.2982292190216, 21.715993316159373), + ], + <Offset>[ + Offset(44.699999999999996, 21.7), + Offset(44.72173985562705, 21.904887268971517), + Offset(44.782515714645, 22.633603069919452), + Offset(44.82676618202069, 24.16065615282172), + Offset(44.60448915303795, 27.03891867978423), + Offset(43.00134899006105, 32.528114478470954), + Offset(34.528679809615376, 41.970166984939155), + Offset(20.069818594909318, 44.45320693981947), + Offset(12.454385311998806, 41.334323796335724), + Offset(8.575040893686701, 37.99466457506439), + Offset(6.424740456883205, 35.17543072959973), + Offset(5.17084639423941, 32.90185230672094), + Offset(4.418923027630889, 31.096578372860833), + Offset(3.9616242457998574, 29.678335771463676), + Offset(3.6822202306509215, 28.579063795605983), + Offset(3.511935794593935, 27.744225569481436), + Offset(3.409312662258829, 27.131069299677847), + Offset(3.3491057189797715, 26.70565433751721), + Offset(3.316169163973278, 26.441135380643548), + Offset(3.301783136738983, 26.315991943297085), + ], + <Offset>[ + Offset(44.699999999999996, 21.7), + Offset(44.72173985562705, 21.904887268971517), + Offset(44.782515714645, 22.633603069919452), + Offset(44.82676618202069, 24.16065615282172), + Offset(44.60448915303795, 27.03891867978423), + Offset(43.00134899006105, 32.528114478470954), + Offset(34.528679809615376, 41.970166984939155), + Offset(20.069818594909318, 44.45320693981947), + Offset(12.454385311998806, 41.334323796335724), + Offset(8.575040893686701, 37.99466457506439), + Offset(6.424740456883205, 35.17543072959973), + Offset(5.17084639423941, 32.90185230672094), + Offset(4.418923027630889, 31.096578372860833), + Offset(3.9616242457998574, 29.678335771463676), + Offset(3.6822202306509215, 28.579063795605983), + Offset(3.511935794593935, 27.744225569481436), + Offset(3.409312662258829, 27.131069299677847), + Offset(3.3491057189797715, 26.70565433751721), + Offset(3.316169163973278, 26.441135380643548), + Offset(3.301783136738983, 26.315991943297085), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.699999999999996, 21.7), + Offset(44.72173985562705, 21.904887268971517), + Offset(44.782515714645, 22.633603069919452), + Offset(44.82676618202069, 24.16065615282172), + Offset(44.60448915303795, 27.03891867978423), + Offset(43.00134899006105, 32.528114478470954), + Offset(34.528679809615376, 41.970166984939155), + Offset(20.069818594909318, 44.45320693981947), + Offset(12.454385311998806, 41.334323796335724), + Offset(8.575040893686701, 37.99466457506439), + Offset(6.424740456883205, 35.17543072959973), + Offset(5.17084639423941, 32.90185230672094), + Offset(4.418923027630889, 31.096578372860833), + Offset(3.9616242457998574, 29.678335771463676), + Offset(3.6822202306509215, 28.579063795605983), + Offset(3.511935794593935, 27.744225569481436), + Offset(3.409312662258829, 27.131069299677847), + Offset(3.3491057189797715, 26.70565433751721), + Offset(3.316169163973278, 26.441135380643548), + Offset(3.301783136738983, 26.315991943297085), + ], + <Offset>[ + Offset(12.5, 21.7), + Offset(12.523315464853802, 21.586348807152127), + Offset(12.615118921152348, 21.184954206323344), + Offset(12.852091147934075, 20.35800491131164), + Offset(13.462772424186701, 18.851521096914183), + Offset(15.260173745559845, 16.179805818127107), + Offset(21.418541782513067, 12.559891894244654), + Offset(29.60088876806662, 13.69611505267333), + Offset(33.15472657830186, 16.669858031624937), + Offset(34.66614030156936, 19.124197392506744), + Offset(35.33862744038458, 21.004081481722523), + Offset(35.62327671887267, 22.438129865669836), + Offset(35.718530292735274, 23.535190600088917), + Offset(35.72177114616216, 24.374274227004882), + Offset(35.68401467060784, 25.011830606880775), + Offset(35.632853451348865, 25.48886553437197), + Offset(35.583234305562215, 25.8353972383238), + Offset(35.542908647440626, 26.073947915652752), + Offset(35.51542014773398, 26.22150823117143), + Offset(35.50177352670298, 26.291114519275386), + ], + <Offset>[ + Offset(12.5, 21.7), + Offset(12.523315464853802, 21.586348807152127), + Offset(12.615118921152348, 21.184954206323344), + Offset(12.852091147934075, 20.35800491131164), + Offset(13.462772424186701, 18.851521096914183), + Offset(15.260173745559845, 16.179805818127107), + Offset(21.418541782513067, 12.559891894244654), + Offset(29.60088876806662, 13.69611505267333), + Offset(33.15472657830186, 16.669858031624937), + Offset(34.66614030156936, 19.124197392506744), + Offset(35.33862744038458, 21.004081481722523), + Offset(35.62327671887267, 22.438129865669836), + Offset(35.718530292735274, 23.535190600088917), + Offset(35.72177114616216, 24.374274227004882), + Offset(35.68401467060784, 25.011830606880775), + Offset(35.632853451348865, 25.48886553437197), + Offset(35.583234305562215, 25.8353972383238), + Offset(35.542908647440626, 26.073947915652752), + Offset(35.51542014773398, 26.22150823117143), + Offset(35.50177352670298, 26.291114519275386), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.5, 21.7), + Offset(12.523315464853802, 21.586348807152127), + Offset(12.615118921152348, 21.184954206323344), + Offset(12.852091147934075, 20.35800491131164), + Offset(13.462772424186701, 18.851521096914183), + Offset(15.260173745559845, 16.179805818127107), + Offset(21.418541782513067, 12.559891894244654), + Offset(29.60088876806662, 13.69611505267333), + Offset(33.15472657830186, 16.669858031624937), + Offset(34.66614030156936, 19.124197392506744), + Offset(35.33862744038458, 21.004081481722523), + Offset(35.62327671887267, 22.438129865669836), + Offset(35.718530292735274, 23.535190600088917), + Offset(35.72177114616216, 24.374274227004882), + Offset(35.68401467060784, 25.011830606880775), + Offset(35.632853451348865, 25.48886553437197), + Offset(35.583234305562215, 25.8353972383238), + Offset(35.542908647440626, 26.073947915652752), + Offset(35.51542014773398, 26.22150823117143), + Offset(35.50177352670298, 26.291114519275386), + ], + <Offset>[ + Offset(12.5, 26.3), + Offset(12.477809970308172, 26.186123720119735), + Offset(12.40816908349576, 25.780296605393723), + Offset(12.308855256289778, 24.925815630466868), + Offset(12.293144198062407, 23.30033777246436), + Offset(12.924701079796437, 20.14283085305585), + Offset(17.217073912413852, 14.432768755259271), + Offset(25.207018498474316, 12.334533599365145), + Offset(29.631231469057468, 13.71266642215307), + Offset(31.970359275489688, 15.396897477094935), + Offset(33.314148976402116, 16.873526198365184), + Offset(34.12845922729394, 18.087782676436515), + Offset(34.638332039482144, 19.063818133645434), + Offset(34.96404806838234, 19.837110384095983), + Offset(35.17440992936138, 20.44014568688693), + Offset(35.31065916061894, 20.900163011978407), + Offset(35.398138296797356, 21.239122717851885), + Offset(35.45266487288856, 21.47483321158692), + Offset(35.484044840666535, 21.62161523349133), + Offset(35.4982196089856, 21.691115892137674), + ], + <Offset>[ + Offset(12.5, 26.3), + Offset(12.477809970308172, 26.186123720119735), + Offset(12.40816908349576, 25.780296605393723), + Offset(12.308855256289778, 24.925815630466868), + Offset(12.293144198062407, 23.30033777246436), + Offset(12.924701079796437, 20.14283085305585), + Offset(17.217073912413852, 14.432768755259271), + Offset(25.207018498474316, 12.334533599365145), + Offset(29.631231469057468, 13.71266642215307), + Offset(31.970359275489688, 15.396897477094935), + Offset(33.314148976402116, 16.873526198365184), + Offset(34.12845922729394, 18.087782676436515), + Offset(34.638332039482144, 19.063818133645434), + Offset(34.96404806838234, 19.837110384095983), + Offset(35.17440992936138, 20.44014568688693), + Offset(35.31065916061894, 20.900163011978407), + Offset(35.398138296797356, 21.239122717851885), + Offset(35.45266487288856, 21.47483321158692), + Offset(35.484044840666535, 21.62161523349133), + Offset(35.4982196089856, 21.691115892137674), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.146341463415, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(18.039538499999995, 12.930571500000006), + Offset(18.149334465395803, 12.872149290550801), + Offset(18.543577170010774, 12.67362376971493), + Offset(19.38848933269092, 12.304132440263823), + Offset(21.0500251098891, 11.778829975926628), + Offset(24.4849959176686, 11.43667565110878), + Offset(31.716380432411114, 14.006619703548635), + Offset(36.563833612934715, 21.53883416894973), + Offset(36.92848913752976, 26.678594000151676), + Offset(36.41588677676291, 30.008169262247456), + Offset(35.68873044963835, 32.36483086720799), + Offset(34.89395296392378, 34.0653568909929), + Offset(34.11750536482414, 35.28323166524228), + Offset(33.42442890399698, 36.15784461978932), + Offset(32.840093239206396, 36.785183822829495), + Offset(32.36951533303179, 37.23048479128569), + Offset(32.00877148413249, 37.538392723203316), + Offset(31.749801205381853, 37.7397340593929), + Offset(31.58332657425492, 37.85607615398918), + Offset(31.500705947534364, 37.904137673197035), + ]), + _PathCubicTo( + <Offset>[ + Offset(18.039538499999995, 12.930571500000006), + Offset(18.149334465395803, 12.872149290550801), + Offset(18.543577170010774, 12.67362376971493), + Offset(19.38848933269092, 12.304132440263823), + Offset(21.0500251098891, 11.778829975926628), + Offset(24.4849959176686, 11.43667565110878), + Offset(31.716380432411114, 14.006619703548635), + Offset(36.563833612934715, 21.53883416894973), + Offset(36.92848913752976, 26.678594000151676), + Offset(36.41588677676291, 30.008169262247456), + Offset(35.68873044963835, 32.36483086720799), + Offset(34.89395296392378, 34.0653568909929), + Offset(34.11750536482414, 35.28323166524228), + Offset(33.42442890399698, 36.15784461978932), + Offset(32.840093239206396, 36.785183822829495), + Offset(32.36951533303179, 37.23048479128569), + Offset(32.00877148413249, 37.538392723203316), + Offset(31.749801205381853, 37.7397340593929), + Offset(31.58332657425492, 37.85607615398918), + Offset(31.500705947534364, 37.904137673197035), + ], + <Offset>[ + Offset(9.524593499999996, 12.930571500000006), + Offset(9.63480611837429, 12.787915207234757), + Offset(10.037253738651572, 12.290544098974763), + Offset(10.933129105730652, 11.298562062138357), + Offset(12.814931782195051, 9.613760407253604), + Offset(17.148837885012377, 7.11336292762574), + Offset(28.234762045851944, 6.196224855085145), + Offset(39.13042441508561, 13.256357206311968), + Offset(42.67716555792793, 19.829043505343183), + Offset(43.985492641283884, 24.533428745633444), + Offset(44.43019441391232, 28.080441886710624), + Offset(44.394288300179475, 30.80095825365075), + Offset(44.09477826399472, 32.872912945700186), + Offset(43.700712499650194, 34.44166739898953), + Offset(43.302847476418705, 35.61890284347735), + Offset(42.94671302192797, 36.48781021374379), + Offset(42.653748813975184, 37.109710143785925), + Offset(42.43168887555528, 37.5301341853464), + Offset(42.28113740093583, 37.78310769585204), + Offset(42.20064775415372, 37.89587099404257), + ], + <Offset>[ + Offset(9.524593499999996, 12.930571500000006), + Offset(9.63480611837429, 12.787915207234757), + Offset(10.037253738651572, 12.290544098974763), + Offset(10.933129105730652, 11.298562062138357), + Offset(12.814931782195051, 9.613760407253604), + Offset(17.148837885012377, 7.11336292762574), + Offset(28.234762045851944, 6.196224855085145), + Offset(39.13042441508561, 13.256357206311968), + Offset(42.67716555792793, 19.829043505343183), + Offset(43.985492641283884, 24.533428745633444), + Offset(44.43019441391232, 28.080441886710624), + Offset(44.394288300179475, 30.80095825365075), + Offset(44.09477826399472, 32.872912945700186), + Offset(43.700712499650194, 34.44166739898953), + Offset(43.302847476418705, 35.61890284347735), + Offset(42.94671302192797, 36.48781021374379), + Offset(42.653748813975184, 37.109710143785925), + Offset(42.43168887555528, 37.5301341853464), + Offset(42.28113740093583, 37.78310769585204), + Offset(42.20064775415372, 37.89587099404257), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.524593499999996, 12.930571500000006), + Offset(9.63480611837429, 12.787915207234757), + Offset(10.037253738651572, 12.290544098974763), + Offset(10.933129105730652, 11.298562062138357), + Offset(12.814931782195051, 9.613760407253604), + Offset(17.148837885012377, 7.11336292762574), + Offset(28.234762045851944, 6.196224855085145), + Offset(39.13042441508561, 13.256357206311968), + Offset(42.67716555792793, 19.829043505343183), + Offset(43.985492641283884, 24.533428745633444), + Offset(44.43019441391232, 28.080441886710624), + Offset(44.394288300179475, 30.80095825365075), + Offset(44.09477826399472, 32.872912945700186), + Offset(43.700712499650194, 34.44166739898953), + Offset(43.302847476418705, 35.61890284347735), + Offset(42.94671302192797, 36.48781021374379), + Offset(42.653748813975184, 37.109710143785925), + Offset(42.43168887555528, 37.5301341853464), + Offset(42.28113740093583, 37.78310769585204), + Offset(42.20064775415372, 37.89587099404257), + ], + <Offset>[ + Offset(9.524593499999998, 23.148505500000006), + Offset(9.533725218395034, 23.005349223660573), + Offset(9.577558133763372, 22.498132216605804), + Offset(9.726444651980092, 21.44499433449068), + Offset(10.216848299787422, 19.495872400486462), + Offset(11.960862616832728, 15.916752566813207), + Offset(18.862288227695757, 10.374166918956151), + Offset(29.191452059920294, 10.176448243730892), + Offset(34.457704964157735, 12.93063180086538), + Offset(37.415804021347064, 15.449901708208273), + Offset(39.288927637315474, 17.590685129581864), + Offset(40.4770099353689, 19.400555850143917), + Offset(41.202395800544195, 20.900185466695486), + Offset(41.64129983469044, 22.110127084205665), + Offset(41.90331030119613, 23.06359775882259), + Offset(42.055503528877686, 23.79517298706838), + Offset(42.13932971867431, 24.335737347974696), + Offset(42.18016902669948, 24.711868981138284), + Offset(42.19357525117126, 24.945734703834944), + Offset(42.19072773916836, 25.055940826099334), + ], + <Offset>[ + Offset(9.524593499999998, 23.148505500000006), + Offset(9.533725218395034, 23.005349223660573), + Offset(9.577558133763372, 22.498132216605804), + Offset(9.726444651980092, 21.44499433449068), + Offset(10.216848299787422, 19.495872400486462), + Offset(11.960862616832728, 15.916752566813207), + Offset(18.862288227695757, 10.374166918956151), + Offset(29.191452059920294, 10.176448243730892), + Offset(34.457704964157735, 12.93063180086538), + Offset(37.415804021347064, 15.449901708208273), + Offset(39.288927637315474, 17.590685129581864), + Offset(40.4770099353689, 19.400555850143917), + Offset(41.202395800544195, 20.900185466695486), + Offset(41.64129983469044, 22.110127084205665), + Offset(41.90331030119613, 23.06359775882259), + Offset(42.055503528877686, 23.79517298706838), + Offset(42.13932971867431, 24.335737347974696), + Offset(42.18016902669948, 24.711868981138284), + Offset(42.19357525117126, 24.945734703834944), + Offset(42.19072773916836, 25.055940826099334), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.524593499999998, 23.148505500000006), + Offset(9.533725218395034, 23.005349223660573), + Offset(9.577558133763372, 22.498132216605804), + Offset(9.726444651980092, 21.44499433449068), + Offset(10.216848299787422, 19.495872400486462), + Offset(11.960862616832728, 15.916752566813207), + Offset(18.862288227695757, 10.374166918956151), + Offset(29.191452059920294, 10.176448243730892), + Offset(34.457704964157735, 12.93063180086538), + Offset(37.415804021347064, 15.449901708208273), + Offset(39.288927637315474, 17.590685129581864), + Offset(40.4770099353689, 19.400555850143917), + Offset(41.202395800544195, 20.900185466695486), + Offset(41.64129983469044, 22.110127084205665), + Offset(41.90331030119613, 23.06359775882259), + Offset(42.055503528877686, 23.79517298706838), + Offset(42.13932971867431, 24.335737347974696), + Offset(42.18016902669948, 24.711868981138284), + Offset(42.19357525117126, 24.945734703834944), + Offset(42.19072773916836, 25.055940826099334), + ], + <Offset>[ + Offset(18.0395385, 23.148505500000006), + Offset(18.048253565416548, 23.089583306976618), + Offset(18.083881565122574, 22.88121188734597), + Offset(18.181804878940362, 22.450564712616142), + Offset(18.45194162748147, 21.660941969159484), + Offset(19.29702064948895, 20.240065290296247), + Offset(22.343906614254927, 18.18456176741964), + Offset(26.6248612577694, 18.458925206368654), + Offset(28.709028543759565, 19.78018229567387), + Offset(29.84619815682609, 20.924642224822286), + Offset(30.547463673041506, 21.875074110079233), + Offset(30.976674599113203, 22.664954487486067), + Offset(31.225122901373616, 23.310504186237587), + Offset(31.365016239037228, 23.82630430500546), + Offset(31.440556063983824, 24.229878738174733), + Offset(31.47830583998151, 24.53784756461028), + Offset(31.49435238883162, 24.76441992739209), + Offset(31.498281356526046, 24.921468855184788), + Offset(31.495764424490346, 25.018703161972084), + Offset(31.490785932549, 25.064207505253805), + ], + <Offset>[ + Offset(18.0395385, 23.148505500000006), + Offset(18.048253565416548, 23.089583306976618), + Offset(18.083881565122574, 22.88121188734597), + Offset(18.181804878940362, 22.450564712616142), + Offset(18.45194162748147, 21.660941969159484), + Offset(19.29702064948895, 20.240065290296247), + Offset(22.343906614254927, 18.18456176741964), + Offset(26.6248612577694, 18.458925206368654), + Offset(28.709028543759565, 19.78018229567387), + Offset(29.84619815682609, 20.924642224822286), + Offset(30.547463673041506, 21.875074110079233), + Offset(30.976674599113203, 22.664954487486067), + Offset(31.225122901373616, 23.310504186237587), + Offset(31.365016239037228, 23.82630430500546), + Offset(31.440556063983824, 24.229878738174733), + Offset(31.47830583998151, 24.53784756461028), + Offset(31.49435238883162, 24.76441992739209), + Offset(31.498281356526046, 24.921468855184788), + Offset(31.495764424490346, 25.018703161972084), + Offset(31.490785932549, 25.064207505253805), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(18.0395385, 23.148505500000006), + Offset(18.048253565416548, 23.089583306976618), + Offset(18.083881565122574, 22.88121188734597), + Offset(18.181804878940362, 22.450564712616142), + Offset(18.45194162748147, 21.660941969159484), + Offset(19.29702064948895, 20.240065290296247), + Offset(22.343906614254927, 18.18456176741964), + Offset(26.6248612577694, 18.458925206368654), + Offset(28.709028543759565, 19.78018229567387), + Offset(29.84619815682609, 20.924642224822286), + Offset(30.547463673041506, 21.875074110079233), + Offset(30.976674599113203, 22.664954487486067), + Offset(31.225122901373616, 23.310504186237587), + Offset(31.365016239037228, 23.82630430500546), + Offset(31.440556063983824, 24.229878738174733), + Offset(31.47830583998151, 24.53784756461028), + Offset(31.49435238883162, 24.76441992739209), + Offset(31.498281356526046, 24.921468855184788), + Offset(31.495764424490346, 25.018703161972084), + Offset(31.490785932549, 25.064207505253805), + ], + <Offset>[ + Offset(18.039538499999995, 12.930571500000006), + Offset(18.149334465395803, 12.872149290550801), + Offset(18.543577170010774, 12.67362376971493), + Offset(19.38848933269092, 12.304132440263823), + Offset(21.0500251098891, 11.778829975926628), + Offset(24.4849959176686, 11.43667565110878), + Offset(31.716380432411114, 14.006619703548635), + Offset(36.563833612934715, 21.53883416894973), + Offset(36.92848913752976, 26.678594000151676), + Offset(36.41588677676291, 30.008169262247456), + Offset(35.68873044963835, 32.36483086720799), + Offset(34.89395296392378, 34.0653568909929), + Offset(34.11750536482414, 35.28323166524228), + Offset(33.42442890399698, 36.15784461978932), + Offset(32.840093239206396, 36.785183822829495), + Offset(32.36951533303179, 37.23048479128569), + Offset(32.00877148413249, 37.538392723203316), + Offset(31.749801205381853, 37.7397340593929), + Offset(31.58332657425492, 37.85607615398918), + Offset(31.500705947534364, 37.904137673197035), + ], + <Offset>[ + Offset(18.039538499999995, 12.930571500000006), + Offset(18.149334465395803, 12.872149290550801), + Offset(18.543577170010774, 12.67362376971493), + Offset(19.38848933269092, 12.304132440263823), + Offset(21.0500251098891, 11.778829975926628), + Offset(24.4849959176686, 11.43667565110878), + Offset(31.716380432411114, 14.006619703548635), + Offset(36.563833612934715, 21.53883416894973), + Offset(36.92848913752976, 26.678594000151676), + Offset(36.41588677676291, 30.008169262247456), + Offset(35.68873044963835, 32.36483086720799), + Offset(34.89395296392378, 34.0653568909929), + Offset(34.11750536482414, 35.28323166524228), + Offset(33.42442890399698, 36.15784461978932), + Offset(32.840093239206396, 36.785183822829495), + Offset(32.36951533303179, 37.23048479128569), + Offset(32.00877148413249, 37.538392723203316), + Offset(31.749801205381853, 37.7397340593929), + Offset(31.58332657425492, 37.85607615398918), + Offset(31.500705947534364, 37.904137673197035), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.146341463415, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(38.4754065, 24.8514945), + Offset(38.466274781604966, 24.99465077633943), + Offset(38.42244186623663, 25.501867783394207), + Offset(38.27355534801991, 26.555005665509313), + Offset(37.78315170021258, 28.504127599513545), + Offset(36.03913738316727, 32.08324743318679), + Offset(29.137711772304243, 37.62583308104385), + Offset(18.8085479400797, 37.8235517562691), + Offset(13.542295035842265, 35.06936819913462), + Offset(10.58419597865294, 32.55009829179173), + Offset(8.71107236268452, 30.409314870418136), + Offset(7.522990064631102, 28.599444149856083), + Offset(6.797604199455802, 27.099814533304514), + Offset(6.358700165309552, 25.889872915794328), + Offset(6.096689698803871, 24.93640224117742), + Offset(5.94449647112231, 24.204827012931613), + Offset(5.860670281325686, 23.6642626520253), + Offset(5.819830973300516, 23.288131018861716), + Offset(5.806424748828734, 23.05426529616505), + Offset(5.809272260831642, 22.944059173900662), + ]), + _PathCubicTo( + <Offset>[ + Offset(38.4754065, 24.8514945), + Offset(38.466274781604966, 24.99465077633943), + Offset(38.42244186623663, 25.501867783394207), + Offset(38.27355534801991, 26.555005665509313), + Offset(37.78315170021258, 28.504127599513545), + Offset(36.03913738316727, 32.08324743318679), + Offset(29.137711772304243, 37.62583308104385), + Offset(18.8085479400797, 37.8235517562691), + Offset(13.542295035842265, 35.06936819913462), + Offset(10.58419597865294, 32.55009829179173), + Offset(8.71107236268452, 30.409314870418136), + Offset(7.522990064631102, 28.599444149856083), + Offset(6.797604199455802, 27.099814533304514), + Offset(6.358700165309552, 25.889872915794328), + Offset(6.096689698803871, 24.93640224117742), + Offset(5.94449647112231, 24.204827012931613), + Offset(5.860670281325686, 23.6642626520253), + Offset(5.819830973300516, 23.288131018861716), + Offset(5.806424748828734, 23.05426529616505), + Offset(5.809272260831642, 22.944059173900662), + ], + <Offset>[ + Offset(29.9604615, 24.8514945), + Offset(29.951746434583455, 24.910416693023386), + Offset(29.91611843487743, 25.11878811265404), + Offset(29.818195121059638, 25.54943528738385), + Offset(29.54805837251853, 26.339058030840523), + Offset(28.702979350511054, 27.759934709703753), + Offset(25.656093385745073, 29.81543823258036), + Offset(21.375138742230593, 29.54107479363134), + Offset(19.290971456240435, 28.21981770432613), + Offset(18.153801843173916, 27.07535777517771), + Offset(17.452536326958487, 26.124925889920767), + Offset(17.023325400886797, 25.335045512513933), + Offset(16.774877098626384, 24.689495813762413), + Offset(16.63498376096277, 24.17369569499453), + Offset(16.559443936016173, 23.770121261825274), + Offset(16.521694160018484, 23.46215243538971), + Offset(16.505647611168378, 23.235580072607906), + Offset(16.501718643473946, 23.078531144815212), + Offset(16.504235575509647, 22.98129683802791), + Offset(16.509214067451, 22.93579249474619), + ], + <Offset>[ + Offset(29.9604615, 24.8514945), + Offset(29.951746434583455, 24.910416693023386), + Offset(29.91611843487743, 25.11878811265404), + Offset(29.818195121059638, 25.54943528738385), + Offset(29.54805837251853, 26.339058030840523), + Offset(28.702979350511054, 27.759934709703753), + Offset(25.656093385745073, 29.81543823258036), + Offset(21.375138742230593, 29.54107479363134), + Offset(19.290971456240435, 28.21981770432613), + Offset(18.153801843173916, 27.07535777517771), + Offset(17.452536326958487, 26.124925889920767), + Offset(17.023325400886797, 25.335045512513933), + Offset(16.774877098626384, 24.689495813762413), + Offset(16.63498376096277, 24.17369569499453), + Offset(16.559443936016173, 23.770121261825274), + Offset(16.521694160018484, 23.46215243538971), + Offset(16.505647611168378, 23.235580072607906), + Offset(16.501718643473946, 23.078531144815212), + Offset(16.504235575509647, 22.98129683802791), + Offset(16.509214067451, 22.93579249474619), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.9604615, 24.8514945), + Offset(29.951746434583455, 24.910416693023386), + Offset(29.91611843487743, 25.11878811265404), + Offset(29.818195121059638, 25.54943528738385), + Offset(29.54805837251853, 26.339058030840523), + Offset(28.702979350511054, 27.759934709703753), + Offset(25.656093385745073, 29.81543823258036), + Offset(21.375138742230593, 29.54107479363134), + Offset(19.290971456240435, 28.21981770432613), + Offset(18.153801843173916, 27.07535777517771), + Offset(17.452536326958487, 26.124925889920767), + Offset(17.023325400886797, 25.335045512513933), + Offset(16.774877098626384, 24.689495813762413), + Offset(16.63498376096277, 24.17369569499453), + Offset(16.559443936016173, 23.770121261825274), + Offset(16.521694160018484, 23.46215243538971), + Offset(16.505647611168378, 23.235580072607906), + Offset(16.501718643473946, 23.078531144815212), + Offset(16.504235575509647, 22.98129683802791), + Offset(16.509214067451, 22.93579249474619), + ], + <Offset>[ + Offset(29.9604615, 35.0694285), + Offset(29.8506655346042, 35.1278507094492), + Offset(29.45642282998923, 35.326376230285085), + Offset(28.61151066730908, 35.69586755973617), + Offset(26.9499748901109, 36.22117002407338), + Offset(23.515004082331405, 36.56332434889122), + Offset(16.283619567588886, 33.99338029645136), + Offset(11.43616638706528, 26.461165831050263), + Offset(11.071510862470241, 21.321405999848324), + Offset(11.5841132232371, 17.99183073775254), + Offset(12.311269550361642, 15.635169132792008), + Offset(13.10604703607622, 13.934643109007098), + Offset(13.882494635175863, 12.716768334757713), + Offset(14.575571096003015, 11.84215538021067), + Offset(15.159906760793596, 11.214816177170512), + Offset(15.630484666968204, 10.7695152087143), + Offset(15.991228515867505, 10.461607276796677), + Offset(16.250198794618143, 10.260265940607095), + Offset(16.416673425745074, 10.143923846010813), + Offset(16.49929405246564, 10.095862326802958), + ], + <Offset>[ + Offset(29.9604615, 35.0694285), + Offset(29.8506655346042, 35.1278507094492), + Offset(29.45642282998923, 35.326376230285085), + Offset(28.61151066730908, 35.69586755973617), + Offset(26.9499748901109, 36.22117002407338), + Offset(23.515004082331405, 36.56332434889122), + Offset(16.283619567588886, 33.99338029645136), + Offset(11.43616638706528, 26.461165831050263), + Offset(11.071510862470241, 21.321405999848324), + Offset(11.5841132232371, 17.99183073775254), + Offset(12.311269550361642, 15.635169132792008), + Offset(13.10604703607622, 13.934643109007098), + Offset(13.882494635175863, 12.716768334757713), + Offset(14.575571096003015, 11.84215538021067), + Offset(15.159906760793596, 11.214816177170512), + Offset(15.630484666968204, 10.7695152087143), + Offset(15.991228515867505, 10.461607276796677), + Offset(16.250198794618143, 10.260265940607095), + Offset(16.416673425745074, 10.143923846010813), + Offset(16.49929405246564, 10.095862326802958), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.9604615, 35.0694285), + Offset(29.8506655346042, 35.1278507094492), + Offset(29.45642282998923, 35.326376230285085), + Offset(28.61151066730908, 35.69586755973617), + Offset(26.9499748901109, 36.22117002407338), + Offset(23.515004082331405, 36.56332434889122), + Offset(16.283619567588886, 33.99338029645136), + Offset(11.43616638706528, 26.461165831050263), + Offset(11.071510862470241, 21.321405999848324), + Offset(11.5841132232371, 17.99183073775254), + Offset(12.311269550361642, 15.635169132792008), + Offset(13.10604703607622, 13.934643109007098), + Offset(13.882494635175863, 12.716768334757713), + Offset(14.575571096003015, 11.84215538021067), + Offset(15.159906760793596, 11.214816177170512), + Offset(15.630484666968204, 10.7695152087143), + Offset(15.991228515867505, 10.461607276796677), + Offset(16.250198794618143, 10.260265940607095), + Offset(16.416673425745074, 10.143923846010813), + Offset(16.49929405246564, 10.095862326802958), + ], + <Offset>[ + Offset(38.4754065, 35.0694285), + Offset(38.365193881625714, 35.212084792765246), + Offset(37.96274626134843, 35.70945590102525), + Offset(37.06687089426935, 36.701437937861634), + Offset(35.18506821780495, 38.386239592746406), + Offset(30.851162114987627, 40.886637072374256), + Offset(19.765237954148056, 41.80377514491485), + Offset(8.869575584914383, 34.743642793688025), + Offset(5.3228344420720735, 28.170956494656817), + Offset(4.0145073587161235, 23.466571254366553), + Offset(3.5698055860876767, 19.919558113289376), + Offset(3.6057116998205245, 17.19904174634925), + Offset(3.90522173600528, 15.127087054299814), + Offset(4.299287500349799, 13.558332601010466), + Offset(4.697152523581295, 12.381097156522657), + Offset(5.053286978072029, 11.5121897862562), + Offset(5.346251186024813, 10.890289856214071), + Offset(5.568311124444711, 10.469865814653598), + Offset(5.7188625990641615, 10.216892304147956), + Offset(5.799352245846278, 10.104129005957429), + ], + <Offset>[ + Offset(38.4754065, 35.0694285), + Offset(38.365193881625714, 35.212084792765246), + Offset(37.96274626134843, 35.70945590102525), + Offset(37.06687089426935, 36.701437937861634), + Offset(35.18506821780495, 38.386239592746406), + Offset(30.851162114987627, 40.886637072374256), + Offset(19.765237954148056, 41.80377514491485), + Offset(8.869575584914383, 34.743642793688025), + Offset(5.3228344420720735, 28.170956494656817), + Offset(4.0145073587161235, 23.466571254366553), + Offset(3.5698055860876767, 19.919558113289376), + Offset(3.6057116998205245, 17.19904174634925), + Offset(3.90522173600528, 15.127087054299814), + Offset(4.299287500349799, 13.558332601010466), + Offset(4.697152523581295, 12.381097156522657), + Offset(5.053286978072029, 11.5121897862562), + Offset(5.346251186024813, 10.890289856214071), + Offset(5.568311124444711, 10.469865814653598), + Offset(5.7188625990641615, 10.216892304147956), + Offset(5.799352245846278, 10.104129005957429), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.4754065, 35.0694285), + Offset(38.365193881625714, 35.212084792765246), + Offset(37.96274626134843, 35.70945590102525), + Offset(37.06687089426935, 36.701437937861634), + Offset(35.18506821780495, 38.386239592746406), + Offset(30.851162114987627, 40.886637072374256), + Offset(19.765237954148056, 41.80377514491485), + Offset(8.869575584914383, 34.743642793688025), + Offset(5.3228344420720735, 28.170956494656817), + Offset(4.0145073587161235, 23.466571254366553), + Offset(3.5698055860876767, 19.919558113289376), + Offset(3.6057116998205245, 17.19904174634925), + Offset(3.90522173600528, 15.127087054299814), + Offset(4.299287500349799, 13.558332601010466), + Offset(4.697152523581295, 12.381097156522657), + Offset(5.053286978072029, 11.5121897862562), + Offset(5.346251186024813, 10.890289856214071), + Offset(5.568311124444711, 10.469865814653598), + Offset(5.7188625990641615, 10.216892304147956), + Offset(5.799352245846278, 10.104129005957429), + ], + <Offset>[ + Offset(38.4754065, 24.8514945), + Offset(38.466274781604966, 24.99465077633943), + Offset(38.42244186623663, 25.501867783394207), + Offset(38.27355534801991, 26.555005665509313), + Offset(37.78315170021258, 28.504127599513545), + Offset(36.03913738316727, 32.08324743318679), + Offset(29.137711772304243, 37.62583308104385), + Offset(18.8085479400797, 37.8235517562691), + Offset(13.542295035842265, 35.06936819913462), + Offset(10.58419597865294, 32.55009829179173), + Offset(8.71107236268452, 30.409314870418136), + Offset(7.522990064631102, 28.599444149856083), + Offset(6.797604199455802, 27.099814533304514), + Offset(6.358700165309552, 25.889872915794328), + Offset(6.096689698803871, 24.93640224117742), + Offset(5.94449647112231, 24.204827012931613), + Offset(5.860670281325686, 23.6642626520253), + Offset(5.819830973300516, 23.288131018861716), + Offset(5.806424748828734, 23.05426529616505), + Offset(5.809272260831642, 22.944059173900662), + ], + <Offset>[ + Offset(38.4754065, 24.8514945), + Offset(38.466274781604966, 24.99465077633943), + Offset(38.42244186623663, 25.501867783394207), + Offset(38.27355534801991, 26.555005665509313), + Offset(37.78315170021258, 28.504127599513545), + Offset(36.03913738316727, 32.08324743318679), + Offset(29.137711772304243, 37.62583308104385), + Offset(18.8085479400797, 37.8235517562691), + Offset(13.542295035842265, 35.06936819913462), + Offset(10.58419597865294, 32.55009829179173), + Offset(8.71107236268452, 30.409314870418136), + Offset(7.522990064631102, 28.599444149856083), + Offset(6.797604199455802, 27.099814533304514), + Offset(6.358700165309552, 25.889872915794328), + Offset(6.096689698803871, 24.93640224117742), + Offset(5.94449647112231, 24.204827012931613), + Offset(5.860670281325686, 23.6642626520253), + Offset(5.819830973300516, 23.288131018861716), + Offset(5.806424748828734, 23.05426529616505), + Offset(5.809272260831642, 22.944059173900662), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.146341463415, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(28.2574725, 24.8514945), + Offset(28.248840765179153, 24.893569876360175), + Offset(28.214853748605588, 25.042172178506007), + Offset(28.12712307566759, 25.34832121175876), + Offset(27.90103970697972, 25.90604411710592), + Offset(27.235747743979807, 26.895272165007142), + Offset(24.959769708433235, 28.253359262887663), + Offset(21.888456902660778, 27.88457940110379), + Offset(20.440706740320067, 26.84990760536443), + Offset(19.66772301607811, 25.980409671854908), + Offset(19.20082911981328, 25.26804809382129), + Offset(18.92339246813794, 24.682165785045505), + Offset(18.770331678460497, 24.207432069853994), + Offset(18.690240480093415, 23.830460250834577), + Offset(18.651994783458633, 23.536865065954846), + Offset(18.637133697797722, 23.313617519881333), + Offset(18.634643077136914, 23.149843556724427), + Offset(18.638096177508633, 23.03661117000591), + Offset(18.643797740845827, 22.966703146400476), + Offset(18.649202428774874, 22.9341391589153), + ]), + _PathCubicTo( + <Offset>[ + Offset(28.2574725, 24.8514945), + Offset(28.248840765179153, 24.893569876360175), + Offset(28.214853748605588, 25.042172178506007), + Offset(28.12712307566759, 25.34832121175876), + Offset(27.90103970697972, 25.90604411710592), + Offset(27.235747743979807, 26.895272165007142), + Offset(24.959769708433235, 28.253359262887663), + Offset(21.888456902660778, 27.88457940110379), + Offset(20.440706740320067, 26.84990760536443), + Offset(19.66772301607811, 25.980409671854908), + Offset(19.20082911981328, 25.26804809382129), + Offset(18.92339246813794, 24.682165785045505), + Offset(18.770331678460497, 24.207432069853994), + Offset(18.690240480093415, 23.830460250834577), + Offset(18.651994783458633, 23.536865065954846), + Offset(18.637133697797722, 23.313617519881333), + Offset(18.634643077136914, 23.149843556724427), + Offset(18.638096177508633, 23.03661117000591), + Offset(18.643797740845827, 22.966703146400476), + Offset(18.649202428774874, 22.9341391589153), + ], + <Offset>[ + Offset(19.7425275, 24.8514945), + Offset(19.73431241815764, 24.80933579304413), + Offset(19.708530317246385, 24.65909250776584), + Offset(19.67176284870732, 24.34275083363329), + Offset(19.66594637928567, 23.740974548432895), + Offset(19.899589711323586, 22.5719594415241), + Offset(21.478151321874066, 20.442964414424175), + Offset(24.455047704811673, 19.602102438466027), + Offset(26.189383160718236, 20.00035711055594), + Offset(27.237328880599087, 20.505669155240895), + Offset(27.942293084087247, 20.983659113323917), + Offset(28.423727804393636, 21.417767147703355), + Offset(28.74760457763108, 21.797113350311893), + Offset(28.96652407574663, 22.11428303003478), + Offset(29.114749020670935, 22.3705840866027), + Offset(29.2143313866939, 22.570942942339432), + Offset(29.279620406979603, 22.721160977307033), + Offset(29.319983847682064, 22.827011295959405), + Offset(29.34160856752674, 22.893734688263336), + Offset(29.349144235394235, 22.92587247976083), + ], + <Offset>[ + Offset(19.7425275, 24.8514945), + Offset(19.73431241815764, 24.80933579304413), + Offset(19.708530317246385, 24.65909250776584), + Offset(19.67176284870732, 24.34275083363329), + Offset(19.66594637928567, 23.740974548432895), + Offset(19.899589711323586, 22.5719594415241), + Offset(21.478151321874066, 20.442964414424175), + Offset(24.455047704811673, 19.602102438466027), + Offset(26.189383160718236, 20.00035711055594), + Offset(27.237328880599087, 20.505669155240895), + Offset(27.942293084087247, 20.983659113323917), + Offset(28.423727804393636, 21.417767147703355), + Offset(28.74760457763108, 21.797113350311893), + Offset(28.96652407574663, 22.11428303003478), + Offset(29.114749020670935, 22.3705840866027), + Offset(29.2143313866939, 22.570942942339432), + Offset(29.279620406979603, 22.721160977307033), + Offset(29.319983847682064, 22.827011295959405), + Offset(29.34160856752674, 22.893734688263336), + Offset(29.349144235394235, 22.92587247976083), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(19.7425275, 24.8514945), + Offset(19.73431241815764, 24.80933579304413), + Offset(19.708530317246385, 24.65909250776584), + Offset(19.67176284870732, 24.34275083363329), + Offset(19.66594637928567, 23.740974548432895), + Offset(19.899589711323586, 22.5719594415241), + Offset(21.478151321874066, 20.442964414424175), + Offset(24.455047704811673, 19.602102438466027), + Offset(26.189383160718236, 20.00035711055594), + Offset(27.237328880599087, 20.505669155240895), + Offset(27.942293084087247, 20.983659113323917), + Offset(28.423727804393636, 21.417767147703355), + Offset(28.74760457763108, 21.797113350311893), + Offset(28.96652407574663, 22.11428303003478), + Offset(29.114749020670935, 22.3705840866027), + Offset(29.2143313866939, 22.570942942339432), + Offset(29.279620406979603, 22.721160977307033), + Offset(29.319983847682064, 22.827011295959405), + Offset(29.34160856752674, 22.893734688263336), + Offset(29.349144235394235, 22.92587247976083), + ], + <Offset>[ + Offset(19.7425275, 35.0694285), + Offset(19.633231518178384, 35.02676980946995), + Offset(19.248834712358185, 34.86668062539688), + Offset(18.465078394956763, 34.48918310598561), + Offset(17.067862896878044, 33.62308654166575), + Offset(14.711614443143937, 31.375349080711565), + Offset(12.105677503717876, 24.620906478295183), + Offset(14.516075349646359, 16.52219347588495), + Offset(17.969922566948043, 13.101945406078135), + Offset(20.66764026066227, 11.422142117815724), + Offset(22.8010263074904, 10.49390235619516), + Offset(24.506449439583058, 10.01736474419652), + Offset(25.85522211418056, 9.824385871307193), + Offset(26.907111410786875, 9.782742715250919), + Offset(27.71521184544836, 9.815279001947939), + Offset(28.323121893643616, 9.87830571566402), + Offset(28.76520131167873, 9.947188181495804), + Offset(29.068463998826257, 10.008746091751288), + Offset(29.25404641776217, 10.05636169624624), + Offset(29.33922422040887, 10.085942311817597), + ], + <Offset>[ + Offset(19.7425275, 35.0694285), + Offset(19.633231518178384, 35.02676980946995), + Offset(19.248834712358185, 34.86668062539688), + Offset(18.465078394956763, 34.48918310598561), + Offset(17.067862896878044, 33.62308654166575), + Offset(14.711614443143937, 31.375349080711565), + Offset(12.105677503717876, 24.620906478295183), + Offset(14.516075349646359, 16.52219347588495), + Offset(17.969922566948043, 13.101945406078135), + Offset(20.66764026066227, 11.422142117815724), + Offset(22.8010263074904, 10.49390235619516), + Offset(24.506449439583058, 10.01736474419652), + Offset(25.85522211418056, 9.824385871307193), + Offset(26.907111410786875, 9.782742715250919), + Offset(27.71521184544836, 9.815279001947939), + Offset(28.323121893643616, 9.87830571566402), + Offset(28.76520131167873, 9.947188181495804), + Offset(29.068463998826257, 10.008746091751288), + Offset(29.25404641776217, 10.05636169624624), + Offset(29.33922422040887, 10.085942311817597), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(19.7425275, 35.0694285), + Offset(19.633231518178384, 35.02676980946995), + Offset(19.248834712358185, 34.86668062539688), + Offset(18.465078394956763, 34.48918310598561), + Offset(17.067862896878044, 33.62308654166575), + Offset(14.711614443143937, 31.375349080711565), + Offset(12.105677503717876, 24.620906478295183), + Offset(14.516075349646359, 16.52219347588495), + Offset(17.969922566948043, 13.101945406078135), + Offset(20.66764026066227, 11.422142117815724), + Offset(22.8010263074904, 10.49390235619516), + Offset(24.506449439583058, 10.01736474419652), + Offset(25.85522211418056, 9.824385871307193), + Offset(26.907111410786875, 9.782742715250919), + Offset(27.71521184544836, 9.815279001947939), + Offset(28.323121893643616, 9.87830571566402), + Offset(28.76520131167873, 9.947188181495804), + Offset(29.068463998826257, 10.008746091751288), + Offset(29.25404641776217, 10.05636169624624), + Offset(29.33922422040887, 10.085942311817597), + ], + <Offset>[ + Offset(28.2574725, 35.0694285), + Offset(28.147759865199898, 35.111003892785995), + Offset(27.755158143717388, 35.24976029613705), + Offset(26.920438621917032, 35.49475348411108), + Offset(25.302956224572092, 35.788156110338775), + Offset(22.04777247580016, 35.69866180419461), + Offset(15.58729589027705, 32.43130132675867), + Offset(11.949484547495462, 24.804670438522713), + Offset(12.221246146549875, 19.951495900886627), + Offset(13.098034396141294, 16.896882634429737), + Offset(14.059562343216436, 14.778291336692531), + Offset(15.006114103327363, 13.28176338153867), + Offset(15.877949215009977, 12.234704590849294), + Offset(16.63082781513366, 11.498919936050715), + Offset(17.252457608236057, 10.981559981300084), + Offset(17.74592420474744, 10.620980293205921), + Offset(18.12022398183604, 10.375870760913198), + Offset(18.386576328652826, 10.218345965797791), + Offset(18.556235591081254, 10.129330154383384), + Offset(18.63928241378951, 10.094208990972067), + ], + <Offset>[ + Offset(28.2574725, 35.0694285), + Offset(28.147759865199898, 35.111003892785995), + Offset(27.755158143717388, 35.24976029613705), + Offset(26.920438621917032, 35.49475348411108), + Offset(25.302956224572092, 35.788156110338775), + Offset(22.04777247580016, 35.69866180419461), + Offset(15.58729589027705, 32.43130132675867), + Offset(11.949484547495462, 24.804670438522713), + Offset(12.221246146549875, 19.951495900886627), + Offset(13.098034396141294, 16.896882634429737), + Offset(14.059562343216436, 14.778291336692531), + Offset(15.006114103327363, 13.28176338153867), + Offset(15.877949215009977, 12.234704590849294), + Offset(16.63082781513366, 11.498919936050715), + Offset(17.252457608236057, 10.981559981300084), + Offset(17.74592420474744, 10.620980293205921), + Offset(18.12022398183604, 10.375870760913198), + Offset(18.386576328652826, 10.218345965797791), + Offset(18.556235591081254, 10.129330154383384), + Offset(18.63928241378951, 10.094208990972067), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(28.2574725, 35.0694285), + Offset(28.147759865199898, 35.111003892785995), + Offset(27.755158143717388, 35.24976029613705), + Offset(26.920438621917032, 35.49475348411108), + Offset(25.302956224572092, 35.788156110338775), + Offset(22.04777247580016, 35.69866180419461), + Offset(15.58729589027705, 32.43130132675867), + Offset(11.949484547495462, 24.804670438522713), + Offset(12.221246146549875, 19.951495900886627), + Offset(13.098034396141294, 16.896882634429737), + Offset(14.059562343216436, 14.778291336692531), + Offset(15.006114103327363, 13.28176338153867), + Offset(15.877949215009977, 12.234704590849294), + Offset(16.63082781513366, 11.498919936050715), + Offset(17.252457608236057, 10.981559981300084), + Offset(17.74592420474744, 10.620980293205921), + Offset(18.12022398183604, 10.375870760913198), + Offset(18.386576328652826, 10.218345965797791), + Offset(18.556235591081254, 10.129330154383384), + Offset(18.63928241378951, 10.094208990972067), + ], + <Offset>[ + Offset(28.2574725, 24.8514945), + Offset(28.248840765179153, 24.893569876360175), + Offset(28.214853748605588, 25.042172178506007), + Offset(28.12712307566759, 25.34832121175876), + Offset(27.90103970697972, 25.90604411710592), + Offset(27.235747743979807, 26.895272165007142), + Offset(24.959769708433235, 28.253359262887663), + Offset(21.888456902660778, 27.88457940110379), + Offset(20.440706740320067, 26.84990760536443), + Offset(19.66772301607811, 25.980409671854908), + Offset(19.20082911981328, 25.26804809382129), + Offset(18.92339246813794, 24.682165785045505), + Offset(18.770331678460497, 24.207432069853994), + Offset(18.690240480093415, 23.830460250834577), + Offset(18.651994783458633, 23.536865065954846), + Offset(18.637133697797722, 23.313617519881333), + Offset(18.634643077136914, 23.149843556724427), + Offset(18.638096177508633, 23.03661117000591), + Offset(18.643797740845827, 22.966703146400476), + Offset(18.649202428774874, 22.9341391589153), + ], + <Offset>[ + Offset(28.2574725, 24.8514945), + Offset(28.248840765179153, 24.893569876360175), + Offset(28.214853748605588, 25.042172178506007), + Offset(28.12712307566759, 25.34832121175876), + Offset(27.90103970697972, 25.90604411710592), + Offset(27.235747743979807, 26.895272165007142), + Offset(24.959769708433235, 28.253359262887663), + Offset(21.888456902660778, 27.88457940110379), + Offset(20.440706740320067, 26.84990760536443), + Offset(19.66772301607811, 25.980409671854908), + Offset(19.20082911981328, 25.26804809382129), + Offset(18.92339246813794, 24.682165785045505), + Offset(18.770331678460497, 24.207432069853994), + Offset(18.690240480093415, 23.830460250834577), + Offset(18.651994783458633, 23.536865065954846), + Offset(18.637133697797722, 23.313617519881333), + Offset(18.634643077136914, 23.149843556724427), + Offset(18.638096177508633, 23.03661117000591), + Offset(18.643797740845827, 22.966703146400476), + Offset(18.649202428774874, 22.9341391589153), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.146341463415, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(18.0395385, 24.851494500000005), + Offset(18.03140674875334, 24.79248897638092), + Offset(18.007265630974544, 24.58247657361781), + Offset(17.98069080331527, 24.141636758008197), + Offset(18.018927713746866, 23.30796063469829), + Offset(18.432358104792343, 21.707296896827494), + Offset(20.78182764456223, 18.880885444731472), + Offset(24.96836586524185, 17.945607045938473), + Offset(27.339118444797872, 18.63044701159424), + Offset(28.751250053503288, 19.41072105191809), + Offset(29.690585876942034, 20.12678131722444), + Offset(30.323794871644772, 20.764887420234924), + Offset(30.7430591574652, 21.315049606403473), + Offset(31.021780794877273, 21.771047585874822), + Offset(31.20729986811339, 22.13732789073227), + Offset(31.32977092447313, 22.42240802683105), + Offset(31.408615872948143, 22.635424461423554), + Offset(31.456361381716757, 22.785091321150105), + Offset(31.48117073286292, 22.87914099663591), + Offset(31.489132596718107, 22.924219143929935), + ]), + _PathCubicTo( + <Offset>[ + Offset(18.0395385, 24.851494500000005), + Offset(18.03140674875334, 24.79248897638092), + Offset(18.007265630974544, 24.58247657361781), + Offset(17.98069080331527, 24.141636758008197), + Offset(18.018927713746866, 23.30796063469829), + Offset(18.432358104792343, 21.707296896827494), + Offset(20.78182764456223, 18.880885444731472), + Offset(24.96836586524185, 17.945607045938473), + Offset(27.339118444797872, 18.63044701159424), + Offset(28.751250053503288, 19.41072105191809), + Offset(29.690585876942034, 20.12678131722444), + Offset(30.323794871644772, 20.764887420234924), + Offset(30.7430591574652, 21.315049606403473), + Offset(31.021780794877273, 21.771047585874822), + Offset(31.20729986811339, 22.13732789073227), + Offset(31.32977092447313, 22.42240802683105), + Offset(31.408615872948143, 22.635424461423554), + Offset(31.456361381716757, 22.785091321150105), + Offset(31.48117073286292, 22.87914099663591), + Offset(31.489132596718107, 22.924219143929935), + ], + <Offset>[ + Offset(9.5245935, 24.851494500000005), + Offset(9.516878401731827, 24.708254893064876), + Offset(9.500942199615341, 24.199396902877645), + Offset(9.525330576355001, 23.136066379882735), + Offset(9.783834386052819, 21.142891066025268), + Offset(11.09620007213612, 17.383984173344455), + Offset(17.300209258003058, 11.070490596267984), + Offset(27.534956667392745, 9.663130083300711), + Offset(33.08779486519604, 11.780896516785745), + Offset(36.320855918024264, 13.935980535304076), + Offset(38.432049841216, 15.842392336727073), + Offset(39.82413020790047, 17.500488782892777), + Offset(40.72033205663578, 18.904730886861373), + Offset(41.298064390530485, 20.054870365075026), + Offset(41.67005410532569, 20.971046911380125), + Offset(41.90696861336931, 21.67973344928915), + Offset(42.053593202790836, 22.20674188200616), + Offset(42.138249051890185, 22.5754914471036), + Offset(42.178981559543836, 22.806172538498764), + Offset(42.189074403337465, 22.915952464775465), + ], + <Offset>[ + Offset(9.5245935, 24.851494500000005), + Offset(9.516878401731827, 24.708254893064876), + Offset(9.500942199615341, 24.199396902877645), + Offset(9.525330576355001, 23.136066379882735), + Offset(9.783834386052819, 21.142891066025268), + Offset(11.09620007213612, 17.383984173344455), + Offset(17.300209258003058, 11.070490596267984), + Offset(27.534956667392745, 9.663130083300711), + Offset(33.08779486519604, 11.780896516785745), + Offset(36.320855918024264, 13.935980535304076), + Offset(38.432049841216, 15.842392336727073), + Offset(39.82413020790047, 17.500488782892777), + Offset(40.72033205663578, 18.904730886861373), + Offset(41.298064390530485, 20.054870365075026), + Offset(41.67005410532569, 20.971046911380125), + Offset(41.90696861336931, 21.67973344928915), + Offset(42.053593202790836, 22.20674188200616), + Offset(42.138249051890185, 22.5754914471036), + Offset(42.178981559543836, 22.806172538498764), + Offset(42.189074403337465, 22.915952464775465), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.5245935, 24.851494500000005), + Offset(9.516878401731827, 24.708254893064876), + Offset(9.500942199615341, 24.199396902877645), + Offset(9.525330576355001, 23.136066379882735), + Offset(9.783834386052819, 21.142891066025268), + Offset(11.09620007213612, 17.383984173344455), + Offset(17.300209258003058, 11.070490596267984), + Offset(27.534956667392745, 9.663130083300711), + Offset(33.08779486519604, 11.780896516785745), + Offset(36.320855918024264, 13.935980535304076), + Offset(38.432049841216, 15.842392336727073), + Offset(39.82413020790047, 17.500488782892777), + Offset(40.72033205663578, 18.904730886861373), + Offset(41.298064390530485, 20.054870365075026), + Offset(41.67005410532569, 20.971046911380125), + Offset(41.90696861336931, 21.67973344928915), + Offset(42.053593202790836, 22.20674188200616), + Offset(42.138249051890185, 22.5754914471036), + Offset(42.178981559543836, 22.806172538498764), + Offset(42.189074403337465, 22.915952464775465), + ], + <Offset>[ + Offset(9.524593500000002, 35.06942850000001), + Offset(9.415797501752571, 34.92568890949069), + Offset(9.041246594727141, 34.406985020508685), + Offset(8.318646122604441, 33.282498652235056), + Offset(7.185750903645189, 31.025003059258125), + Offset(5.908224803956472, 26.187373812531924), + Offset(7.92773543984687, 15.24843266013899), + Offset(17.59598431222743, 6.583221120719635), + Offset(24.868334271425848, 4.882484812307943), + Offset(29.75116729808745, 4.852453497878906), + Offset(33.290783064619156, 5.352635579598315), + Offset(35.90685184308989, 6.1000863793859414), + Offset(37.82794959318526, 6.932003407856674), + Offset(39.23865172557073, 7.723330050291165), + Offset(40.27051693010311, 8.415741826725363), + Offset(41.015759120319025, 8.987096222613737), + Offset(41.53917410748996, 9.43276908619493), + Offset(41.886729203034385, 9.757226242895484), + Offset(42.09141940977926, 9.968799546481671), + Offset(42.1791543883521, 10.076022296832232), + ], + <Offset>[ + Offset(9.524593500000002, 35.06942850000001), + Offset(9.415797501752571, 34.92568890949069), + Offset(9.041246594727141, 34.406985020508685), + Offset(8.318646122604441, 33.282498652235056), + Offset(7.185750903645189, 31.025003059258125), + Offset(5.908224803956472, 26.187373812531924), + Offset(7.92773543984687, 15.24843266013899), + Offset(17.59598431222743, 6.583221120719635), + Offset(24.868334271425848, 4.882484812307943), + Offset(29.75116729808745, 4.852453497878906), + Offset(33.290783064619156, 5.352635579598315), + Offset(35.90685184308989, 6.1000863793859414), + Offset(37.82794959318526, 6.932003407856674), + Offset(39.23865172557073, 7.723330050291165), + Offset(40.27051693010311, 8.415741826725363), + Offset(41.015759120319025, 8.987096222613737), + Offset(41.53917410748996, 9.43276908619493), + Offset(41.886729203034385, 9.757226242895484), + Offset(42.09141940977926, 9.968799546481671), + Offset(42.1791543883521, 10.076022296832232), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(9.524593500000002, 35.06942850000001), + Offset(9.415797501752571, 34.92568890949069), + Offset(9.041246594727141, 34.406985020508685), + Offset(8.318646122604441, 33.282498652235056), + Offset(7.185750903645189, 31.025003059258125), + Offset(5.908224803956472, 26.187373812531924), + Offset(7.92773543984687, 15.24843266013899), + Offset(17.59598431222743, 6.583221120719635), + Offset(24.868334271425848, 4.882484812307943), + Offset(29.75116729808745, 4.852453497878906), + Offset(33.290783064619156, 5.352635579598315), + Offset(35.90685184308989, 6.1000863793859414), + Offset(37.82794959318526, 6.932003407856674), + Offset(39.23865172557073, 7.723330050291165), + Offset(40.27051693010311, 8.415741826725363), + Offset(41.015759120319025, 8.987096222613737), + Offset(41.53917410748996, 9.43276908619493), + Offset(41.886729203034385, 9.757226242895484), + Offset(42.09141940977926, 9.968799546481671), + Offset(42.1791543883521, 10.076022296832232), + ], + <Offset>[ + Offset(18.0395385, 35.0694285), + Offset(17.930325848774086, 35.009922992806736), + Offset(17.547570026086344, 34.79006469124885), + Offset(16.77400634956471, 34.28806903036052), + Offset(15.420844231339236, 33.190072627931144), + Offset(13.244382836612694, 30.510686536014962), + Offset(11.409353826406043, 23.05882750860248), + Offset(15.029393510076535, 14.865698083357398), + Offset(19.119657851027682, 11.732035307116437), + Offset(22.181561433566472, 10.327194014492921), + Offset(24.549319100345187, 9.637024560095686), + Offset(26.406516506834194, 9.36448501672809), + Offset(27.85067669401468, 9.342322127398774), + Offset(28.962368129917518, 9.43950727109096), + Offset(29.807762692890815, 9.582022806077507), + Offset(30.438561431422848, 9.729770800155638), + Offset(30.89419677764727, 9.861451665612325), + Offset(31.20484153286095, 9.966826116941988), + Offset(31.39360858309835, 10.041768004618815), + Offset(31.479212581732742, 10.084288975986702), + ], + <Offset>[ + Offset(18.0395385, 35.0694285), + Offset(17.930325848774086, 35.009922992806736), + Offset(17.547570026086344, 34.79006469124885), + Offset(16.77400634956471, 34.28806903036052), + Offset(15.420844231339236, 33.190072627931144), + Offset(13.244382836612694, 30.510686536014962), + Offset(11.409353826406043, 23.05882750860248), + Offset(15.029393510076535, 14.865698083357398), + Offset(19.119657851027682, 11.732035307116437), + Offset(22.181561433566472, 10.327194014492921), + Offset(24.549319100345187, 9.637024560095686), + Offset(26.406516506834194, 9.36448501672809), + Offset(27.85067669401468, 9.342322127398774), + Offset(28.962368129917518, 9.43950727109096), + Offset(29.807762692890815, 9.582022806077507), + Offset(30.438561431422848, 9.729770800155638), + Offset(30.89419677764727, 9.861451665612325), + Offset(31.20484153286095, 9.966826116941988), + Offset(31.39360858309835, 10.041768004618815), + Offset(31.479212581732742, 10.084288975986702), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(18.0395385, 35.0694285), + Offset(17.930325848774086, 35.009922992806736), + Offset(17.547570026086344, 34.79006469124885), + Offset(16.77400634956471, 34.28806903036052), + Offset(15.420844231339236, 33.190072627931144), + Offset(13.244382836612694, 30.510686536014962), + Offset(11.409353826406043, 23.05882750860248), + Offset(15.029393510076535, 14.865698083357398), + Offset(19.119657851027682, 11.732035307116437), + Offset(22.181561433566472, 10.327194014492921), + Offset(24.549319100345187, 9.637024560095686), + Offset(26.406516506834194, 9.36448501672809), + Offset(27.85067669401468, 9.342322127398774), + Offset(28.962368129917518, 9.43950727109096), + Offset(29.807762692890815, 9.582022806077507), + Offset(30.438561431422848, 9.729770800155638), + Offset(30.89419677764727, 9.861451665612325), + Offset(31.20484153286095, 9.966826116941988), + Offset(31.39360858309835, 10.041768004618815), + Offset(31.479212581732742, 10.084288975986702), + ], + <Offset>[ + Offset(18.0395385, 24.851494500000005), + Offset(18.03140674875334, 24.79248897638092), + Offset(18.007265630974544, 24.58247657361781), + Offset(17.98069080331527, 24.141636758008197), + Offset(18.018927713746866, 23.30796063469829), + Offset(18.432358104792343, 21.707296896827494), + Offset(20.78182764456223, 18.880885444731472), + Offset(24.96836586524185, 17.945607045938473), + Offset(27.339118444797872, 18.63044701159424), + Offset(28.751250053503288, 19.41072105191809), + Offset(29.690585876942034, 20.12678131722444), + Offset(30.323794871644772, 20.764887420234924), + Offset(30.7430591574652, 21.315049606403473), + Offset(31.021780794877273, 21.771047585874822), + Offset(31.20729986811339, 22.13732789073227), + Offset(31.32977092447313, 22.42240802683105), + Offset(31.408615872948143, 22.635424461423554), + Offset(31.456361381716757, 22.785091321150105), + Offset(31.48117073286292, 22.87914099663591), + Offset(31.489132596718107, 22.924219143929935), + ], + <Offset>[ + Offset(18.0395385, 24.851494500000005), + Offset(18.03140674875334, 24.79248897638092), + Offset(18.007265630974544, 24.58247657361781), + Offset(17.98069080331527, 24.141636758008197), + Offset(18.018927713746866, 23.30796063469829), + Offset(18.432358104792343, 21.707296896827494), + Offset(20.78182764456223, 18.880885444731472), + Offset(24.96836586524185, 17.945607045938473), + Offset(27.339118444797872, 18.63044701159424), + Offset(28.751250053503288, 19.41072105191809), + Offset(29.690585876942034, 20.12678131722444), + Offset(30.323794871644772, 20.764887420234924), + Offset(30.7430591574652, 21.315049606403473), + Offset(31.021780794877273, 21.771047585874822), + Offset(31.20729986811339, 22.13732789073227), + Offset(31.32977092447313, 22.42240802683105), + Offset(31.408615872948143, 22.635424461423554), + Offset(31.456361381716757, 22.785091321150105), + Offset(31.48117073286292, 22.87914099663591), + Offset(31.489132596718107, 22.924219143929935), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.146341463415, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(38.4754065, 12.930571500000003), + Offset(38.58420249824743, 13.074311090509312), + Offset(38.958753405272866, 13.593014979491326), + Offset(39.681353877395566, 14.717501347764943), + Offset(40.81424909635481, 16.97499694074188), + Offset(42.09177519604353, 21.812626187468076), + Offset(40.07226456015313, 32.75156733986101), + Offset(30.404015687772564, 41.41677887928036), + Offset(23.131665728574156, 43.11751518769206), + Offset(18.248832701912562, 43.147546502121095), + Offset(14.709216935380839, 42.647364420401686), + Offset(12.093148156910111, 41.89991362061406), + Offset(10.17205040681474, 41.06799659214333), + Offset(8.761348274429261, 40.276669949708825), + Offset(7.729483069896876, 39.58425817327465), + Offset(6.984240879680973, 39.012903777386256), + Offset(6.460825892510038, 38.56723091380506), + Offset(6.1132707969656215, 38.242773757104516), + Offset(5.908580590220735, 38.03120045351832), + Offset(5.820845611647898, 37.923977703167765), + ]), + _PathCubicTo( + <Offset>[ + Offset(38.4754065, 12.930571500000003), + Offset(38.58420249824743, 13.074311090509312), + Offset(38.958753405272866, 13.593014979491326), + Offset(39.681353877395566, 14.717501347764943), + Offset(40.81424909635481, 16.97499694074188), + Offset(42.09177519604353, 21.812626187468076), + Offset(40.07226456015313, 32.75156733986101), + Offset(30.404015687772564, 41.41677887928036), + Offset(23.131665728574156, 43.11751518769206), + Offset(18.248832701912562, 43.147546502121095), + Offset(14.709216935380839, 42.647364420401686), + Offset(12.093148156910111, 41.89991362061406), + Offset(10.17205040681474, 41.06799659214333), + Offset(8.761348274429261, 40.276669949708825), + Offset(7.729483069896876, 39.58425817327465), + Offset(6.984240879680973, 39.012903777386256), + Offset(6.460825892510038, 38.56723091380506), + Offset(6.1132707969656215, 38.242773757104516), + Offset(5.908580590220735, 38.03120045351832), + Offset(5.820845611647898, 37.923977703167765), + ], + <Offset>[ + Offset(29.9604615, 12.930571500000003), + Offset(30.069674151225918, 12.990077007193268), + Offset(30.452429973913663, 13.20993530875116), + Offset(31.225993650435292, 13.711930969639477), + Offset(32.57915576866076, 14.80992737206886), + Offset(34.755617163387306, 17.489313463985035), + Offset(36.59064617359396, 24.94117249139752), + Offset(32.970606489923455, 33.1343019166426), + Offset(28.880342148972325, 36.26796469288357), + Offset(25.81843856643354, 37.672805985507075), + Offset(23.450680899654806, 38.36297543990432), + Offset(21.593483493165806, 38.63551498327191), + Offset(20.149323305985323, 38.657677872601234), + Offset(19.03763187008248, 38.56049272890903), + Offset(18.192237307109178, 38.417977193922496), + Offset(17.56143856857715, 38.27022919984435), + Offset(17.10580322235273, 38.13854833438767), + Offset(16.795158467139053, 38.03317388305801), + Offset(16.606391416901644, 37.95823199538118), + Offset(16.520787418267258, 37.9157110240133), + ], + <Offset>[ + Offset(29.9604615, 12.930571500000003), + Offset(30.069674151225918, 12.990077007193268), + Offset(30.452429973913663, 13.20993530875116), + Offset(31.225993650435292, 13.711930969639477), + Offset(32.57915576866076, 14.80992737206886), + Offset(34.755617163387306, 17.489313463985035), + Offset(36.59064617359396, 24.94117249139752), + Offset(32.970606489923455, 33.1343019166426), + Offset(28.880342148972325, 36.26796469288357), + Offset(25.81843856643354, 37.672805985507075), + Offset(23.450680899654806, 38.36297543990432), + Offset(21.593483493165806, 38.63551498327191), + Offset(20.149323305985323, 38.657677872601234), + Offset(19.03763187008248, 38.56049272890903), + Offset(18.192237307109178, 38.417977193922496), + Offset(17.56143856857715, 38.27022919984435), + Offset(17.10580322235273, 38.13854833438767), + Offset(16.795158467139053, 38.03317388305801), + Offset(16.606391416901644, 37.95823199538118), + Offset(16.520787418267258, 37.9157110240133), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.9604615, 12.930571500000003), + Offset(30.069674151225918, 12.990077007193268), + Offset(30.452429973913663, 13.20993530875116), + Offset(31.225993650435292, 13.711930969639477), + Offset(32.57915576866076, 14.80992737206886), + Offset(34.755617163387306, 17.489313463985035), + Offset(36.59064617359396, 24.94117249139752), + Offset(32.970606489923455, 33.1343019166426), + Offset(28.880342148972325, 36.26796469288357), + Offset(25.81843856643354, 37.672805985507075), + Offset(23.450680899654806, 38.36297543990432), + Offset(21.593483493165806, 38.63551498327191), + Offset(20.149323305985323, 38.657677872601234), + Offset(19.03763187008248, 38.56049272890903), + Offset(18.192237307109178, 38.417977193922496), + Offset(17.56143856857715, 38.27022919984435), + Offset(17.10580322235273, 38.13854833438767), + Offset(16.795158467139053, 38.03317388305801), + Offset(16.606391416901644, 37.95823199538118), + Offset(16.520787418267258, 37.9157110240133), + ], + <Offset>[ + Offset(29.9604615, 23.148505500000002), + Offset(29.968593251246663, 23.207511023619084), + Offset(29.992734369025463, 23.4175234263822), + Offset(30.019309196684734, 23.858363241991796), + Offset(29.98107228625313, 24.692039365301717), + Offset(29.567641895207657, 26.2927031031725), + Offset(27.218172355437773, 29.119114555268528), + Offset(23.031634134758143, 30.054392954061523), + Offset(20.66088155520213, 29.369552988405765), + Offset(19.248749946496723, 28.58927894808191), + Offset(18.30941412305796, 27.87321868277556), + Offset(17.676205128355228, 27.235112579765072), + Offset(17.256940842534803, 26.684950393596534), + Offset(16.978219205122727, 26.228952414125168), + Offset(16.7927001318866, 25.862672109267738), + Offset(16.67022907552687, 25.57759197316894), + Offset(16.591384127051857, 25.364575538576442), + Offset(16.543638618283246, 25.21490867884989), + Offset(16.518829267137072, 25.12085900336409), + Offset(16.510867403281896, 25.075780856070065), + ], + <Offset>[ + Offset(29.9604615, 23.148505500000002), + Offset(29.968593251246663, 23.207511023619084), + Offset(29.992734369025463, 23.4175234263822), + Offset(30.019309196684734, 23.858363241991796), + Offset(29.98107228625313, 24.692039365301717), + Offset(29.567641895207657, 26.2927031031725), + Offset(27.218172355437773, 29.119114555268528), + Offset(23.031634134758143, 30.054392954061523), + Offset(20.66088155520213, 29.369552988405765), + Offset(19.248749946496723, 28.58927894808191), + Offset(18.30941412305796, 27.87321868277556), + Offset(17.676205128355228, 27.235112579765072), + Offset(17.256940842534803, 26.684950393596534), + Offset(16.978219205122727, 26.228952414125168), + Offset(16.7927001318866, 25.862672109267738), + Offset(16.67022907552687, 25.57759197316894), + Offset(16.591384127051857, 25.364575538576442), + Offset(16.543638618283246, 25.21490867884989), + Offset(16.518829267137072, 25.12085900336409), + Offset(16.510867403281896, 25.075780856070065), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.9604615, 23.148505500000002), + Offset(29.968593251246663, 23.207511023619084), + Offset(29.992734369025463, 23.4175234263822), + Offset(30.019309196684734, 23.858363241991796), + Offset(29.98107228625313, 24.692039365301717), + Offset(29.567641895207657, 26.2927031031725), + Offset(27.218172355437773, 29.119114555268528), + Offset(23.031634134758143, 30.054392954061523), + Offset(20.66088155520213, 29.369552988405765), + Offset(19.248749946496723, 28.58927894808191), + Offset(18.30941412305796, 27.87321868277556), + Offset(17.676205128355228, 27.235112579765072), + Offset(17.256940842534803, 26.684950393596534), + Offset(16.978219205122727, 26.228952414125168), + Offset(16.7927001318866, 25.862672109267738), + Offset(16.67022907552687, 25.57759197316894), + Offset(16.591384127051857, 25.364575538576442), + Offset(16.543638618283246, 25.21490867884989), + Offset(16.518829267137072, 25.12085900336409), + Offset(16.510867403281896, 25.075780856070065), + ], + <Offset>[ + Offset(38.4754065, 23.148505500000002), + Offset(38.48312159826818, 23.291745106935128), + Offset(38.49905780038466, 23.800603097122366), + Offset(38.474669423645004, 24.863933620117265), + Offset(38.216165613947176, 26.85710893397474), + Offset(36.90379992786388, 30.61601582665554), + Offset(30.699790741996942, 36.92950940373201), + Offset(20.465043332607248, 38.336869916699285), + Offset(14.912205134803964, 36.219103483214255), + Offset(11.679144081975746, 34.064019464695924), + Offset(9.567950158783995, 32.15760766327293), + Offset(8.175869792099533, 30.499511217107223), + Offset(7.2796679433642195, 29.095269113138635), + Offset(6.701935609469508, 27.945129634924964), + Offset(6.3299458946742995, 27.028953088619883), + Offset(6.093031386630692, 26.32026655071084), + Offset(5.946406797209165, 25.793258117993837), + Offset(5.861750948109816, 25.424508552896395), + Offset(5.821018440456163, 25.19382746150123), + Offset(5.810925596662535, 25.084047535224535), + ], + <Offset>[ + Offset(38.4754065, 23.148505500000002), + Offset(38.48312159826818, 23.291745106935128), + Offset(38.49905780038466, 23.800603097122366), + Offset(38.474669423645004, 24.863933620117265), + Offset(38.216165613947176, 26.85710893397474), + Offset(36.90379992786388, 30.61601582665554), + Offset(30.699790741996942, 36.92950940373201), + Offset(20.465043332607248, 38.336869916699285), + Offset(14.912205134803964, 36.219103483214255), + Offset(11.679144081975746, 34.064019464695924), + Offset(9.567950158783995, 32.15760766327293), + Offset(8.175869792099533, 30.499511217107223), + Offset(7.2796679433642195, 29.095269113138635), + Offset(6.701935609469508, 27.945129634924964), + Offset(6.3299458946742995, 27.028953088619883), + Offset(6.093031386630692, 26.32026655071084), + Offset(5.946406797209165, 25.793258117993837), + Offset(5.861750948109816, 25.424508552896395), + Offset(5.821018440456163, 25.19382746150123), + Offset(5.810925596662535, 25.084047535224535), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(38.4754065, 23.148505500000002), + Offset(38.48312159826818, 23.291745106935128), + Offset(38.49905780038466, 23.800603097122366), + Offset(38.474669423645004, 24.863933620117265), + Offset(38.216165613947176, 26.85710893397474), + Offset(36.90379992786388, 30.61601582665554), + Offset(30.699790741996942, 36.92950940373201), + Offset(20.465043332607248, 38.336869916699285), + Offset(14.912205134803964, 36.219103483214255), + Offset(11.679144081975746, 34.064019464695924), + Offset(9.567950158783995, 32.15760766327293), + Offset(8.175869792099533, 30.499511217107223), + Offset(7.2796679433642195, 29.095269113138635), + Offset(6.701935609469508, 27.945129634924964), + Offset(6.3299458946742995, 27.028953088619883), + Offset(6.093031386630692, 26.32026655071084), + Offset(5.946406797209165, 25.793258117993837), + Offset(5.861750948109816, 25.424508552896395), + Offset(5.821018440456163, 25.19382746150123), + Offset(5.810925596662535, 25.084047535224535), + ], + <Offset>[ + Offset(38.4754065, 12.930571500000003), + Offset(38.58420249824743, 13.074311090509312), + Offset(38.958753405272866, 13.593014979491326), + Offset(39.681353877395566, 14.717501347764943), + Offset(40.81424909635481, 16.97499694074188), + Offset(42.09177519604353, 21.812626187468076), + Offset(40.07226456015313, 32.75156733986101), + Offset(30.404015687772564, 41.41677887928036), + Offset(23.131665728574156, 43.11751518769206), + Offset(18.248832701912562, 43.147546502121095), + Offset(14.709216935380839, 42.647364420401686), + Offset(12.093148156910111, 41.89991362061406), + Offset(10.17205040681474, 41.06799659214333), + Offset(8.761348274429261, 40.276669949708825), + Offset(7.729483069896876, 39.58425817327465), + Offset(6.984240879680973, 39.012903777386256), + Offset(6.460825892510038, 38.56723091380506), + Offset(6.1132707969656215, 38.242773757104516), + Offset(5.908580590220735, 38.03120045351832), + Offset(5.820845611647898, 37.923977703167765), + ], + <Offset>[ + Offset(38.4754065, 12.930571500000003), + Offset(38.58420249824743, 13.074311090509312), + Offset(38.958753405272866, 13.593014979491326), + Offset(39.681353877395566, 14.717501347764943), + Offset(40.81424909635481, 16.97499694074188), + Offset(42.09177519604353, 21.812626187468076), + Offset(40.07226456015313, 32.75156733986101), + Offset(30.404015687772564, 41.41677887928036), + Offset(23.131665728574156, 43.11751518769206), + Offset(18.248832701912562, 43.147546502121095), + Offset(14.709216935380839, 42.647364420401686), + Offset(12.093148156910111, 41.89991362061406), + Offset(10.17205040681474, 41.06799659214333), + Offset(8.761348274429261, 40.276669949708825), + Offset(7.729483069896876, 39.58425817327465), + Offset(6.984240879680973, 39.012903777386256), + Offset(6.460825892510038, 38.56723091380506), + Offset(6.1132707969656215, 38.242773757104516), + Offset(5.908580590220735, 38.03120045351832), + Offset(5.820845611647898, 37.923977703167765), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.146341463415, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(28.257472499999995, 12.930571500000006), + Offset(28.366768481821616, 12.973230190530057), + Offset(28.75116528764182, 13.133319374603126), + Offset(29.53492160504324, 13.510816894014384), + Offset(30.932137103121953, 14.376913458334252), + Offset(33.28838555685606, 16.624650919288428), + Offset(35.89432249628212, 23.37909352170482), + Offset(33.48392465035364, 31.477806524115046), + Offset(30.030077433051954, 34.89805459392186), + Offset(27.33235973933773, 36.577857882184276), + Offset(25.1989736925096, 37.50609764380484), + Offset(23.49355056041695, 37.98263525580348), + Offset(22.14477788581944, 38.17561412869281), + Offset(21.092888589213125, 38.217257284749074), + Offset(20.284788154551638, 38.18472099805207), + Offset(19.676878106356384, 38.12169428433597), + Offset(19.234798688321266, 38.052811818504196), + Offset(18.93153600117374, 37.99125390824871), + Offset(18.74595358223783, 37.94363830375375), + Offset(18.66077577959113, 37.914057688182396), + ]), + _PathCubicTo( + <Offset>[ + Offset(28.257472499999995, 12.930571500000006), + Offset(28.366768481821616, 12.973230190530057), + Offset(28.75116528764182, 13.133319374603126), + Offset(29.53492160504324, 13.510816894014384), + Offset(30.932137103121953, 14.376913458334252), + Offset(33.28838555685606, 16.624650919288428), + Offset(35.89432249628212, 23.37909352170482), + Offset(33.48392465035364, 31.477806524115046), + Offset(30.030077433051954, 34.89805459392186), + Offset(27.33235973933773, 36.577857882184276), + Offset(25.1989736925096, 37.50609764380484), + Offset(23.49355056041695, 37.98263525580348), + Offset(22.14477788581944, 38.17561412869281), + Offset(21.092888589213125, 38.217257284749074), + Offset(20.284788154551638, 38.18472099805207), + Offset(19.676878106356384, 38.12169428433597), + Offset(19.234798688321266, 38.052811818504196), + Offset(18.93153600117374, 37.99125390824871), + Offset(18.74595358223783, 37.94363830375375), + Offset(18.66077577959113, 37.914057688182396), + ], + <Offset>[ + Offset(19.742527499999994, 12.930571500000006), + Offset(19.852240134800102, 12.888996107214012), + Offset(20.244841856282616, 12.75023970386296), + Offset(21.07956137808297, 12.505246515888919), + Offset(22.697043775427904, 12.211843889661228), + Offset(25.95222752419984, 12.30133819580539), + Offset(32.41270410972295, 15.568698673241332), + Offset(36.05051545250454, 23.195329561477283), + Offset(35.77875385345012, 28.048504099113373), + Offset(34.901965603858706, 31.10311736557026), + Offset(33.940437656783566, 33.221708663307474), + Offset(32.993885896672644, 34.71823661846133), + Offset(32.12205078499002, 35.7652954091507), + Offset(31.369172184866343, 36.50108006394928), + Offset(30.74754239176394, 37.01844001869992), + Offset(30.25407579525256, 37.379019706794075), + Offset(29.879776018163955, 37.6241292390868), + Offset(29.61342367134717, 37.781654034202205), + Offset(29.44376440891874, 37.87066984561661), + Offset(29.36071758621049, 37.90579100902793), + ], + <Offset>[ + Offset(19.742527499999994, 12.930571500000006), + Offset(19.852240134800102, 12.888996107214012), + Offset(20.244841856282616, 12.75023970386296), + Offset(21.07956137808297, 12.505246515888919), + Offset(22.697043775427904, 12.211843889661228), + Offset(25.95222752419984, 12.30133819580539), + Offset(32.41270410972295, 15.568698673241332), + Offset(36.05051545250454, 23.195329561477283), + Offset(35.77875385345012, 28.048504099113373), + Offset(34.901965603858706, 31.10311736557026), + Offset(33.940437656783566, 33.221708663307474), + Offset(32.993885896672644, 34.71823661846133), + Offset(32.12205078499002, 35.7652954091507), + Offset(31.369172184866343, 36.50108006394928), + Offset(30.74754239176394, 37.01844001869992), + Offset(30.25407579525256, 37.379019706794075), + Offset(29.879776018163955, 37.6241292390868), + Offset(29.61342367134717, 37.781654034202205), + Offset(29.44376440891874, 37.87066984561661), + Offset(29.36071758621049, 37.90579100902793), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(19.742527499999994, 12.930571500000006), + Offset(19.852240134800102, 12.888996107214012), + Offset(20.244841856282616, 12.75023970386296), + Offset(21.07956137808297, 12.505246515888919), + Offset(22.697043775427904, 12.211843889661228), + Offset(25.95222752419984, 12.30133819580539), + Offset(32.41270410972295, 15.568698673241332), + Offset(36.05051545250454, 23.195329561477283), + Offset(35.77875385345012, 28.048504099113373), + Offset(34.901965603858706, 31.10311736557026), + Offset(33.940437656783566, 33.221708663307474), + Offset(32.993885896672644, 34.71823661846133), + Offset(32.12205078499002, 35.7652954091507), + Offset(31.369172184866343, 36.50108006394928), + Offset(30.74754239176394, 37.01844001869992), + Offset(30.25407579525256, 37.379019706794075), + Offset(29.879776018163955, 37.6241292390868), + Offset(29.61342367134717, 37.781654034202205), + Offset(29.44376440891874, 37.87066984561661), + Offset(29.36071758621049, 37.90579100902793), + ], + <Offset>[ + Offset(19.742527499999998, 23.148505500000006), + Offset(19.751159234820847, 23.10643012363983), + Offset(19.785146251394416, 22.957827821494), + Offset(19.872876924332413, 22.65167878824124), + Offset(20.098960293020276, 22.093955882894086), + Offset(20.764252256020193, 21.104727834992858), + Offset(23.040230291566765, 19.74664073711234), + Offset(26.111543097339222, 20.115420598896208), + Offset(27.55929325967993, 21.15009239463557), + Offset(28.33227698392189, 22.01959032814509), + Offset(28.79917088018672, 22.731951906178715), + Offset(29.076607531862066, 23.317834214954495), + Offset(29.229668321539503, 23.792567930146006), + Offset(29.309759519906592, 24.16953974916542), + Offset(29.348005216541363, 24.46313493404516), + Offset(29.362866302202278, 24.686382480118663), + Offset(29.365356922863082, 24.85015644327557), + Offset(29.361903822491364, 24.96338882999409), + Offset(29.356202259154166, 25.033296853599516), + Offset(29.350797571225126, 25.065860841084696), + ], + <Offset>[ + Offset(19.742527499999998, 23.148505500000006), + Offset(19.751159234820847, 23.10643012363983), + Offset(19.785146251394416, 22.957827821494), + Offset(19.872876924332413, 22.65167878824124), + Offset(20.098960293020276, 22.093955882894086), + Offset(20.764252256020193, 21.104727834992858), + Offset(23.040230291566765, 19.74664073711234), + Offset(26.111543097339222, 20.115420598896208), + Offset(27.55929325967993, 21.15009239463557), + Offset(28.33227698392189, 22.01959032814509), + Offset(28.79917088018672, 22.731951906178715), + Offset(29.076607531862066, 23.317834214954495), + Offset(29.229668321539503, 23.792567930146006), + Offset(29.309759519906592, 24.16953974916542), + Offset(29.348005216541363, 24.46313493404516), + Offset(29.362866302202278, 24.686382480118663), + Offset(29.365356922863082, 24.85015644327557), + Offset(29.361903822491364, 24.96338882999409), + Offset(29.356202259154166, 25.033296853599516), + Offset(29.350797571225126, 25.065860841084696), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(19.742527499999998, 23.148505500000006), + Offset(19.751159234820847, 23.10643012363983), + Offset(19.785146251394416, 22.957827821494), + Offset(19.872876924332413, 22.65167878824124), + Offset(20.098960293020276, 22.093955882894086), + Offset(20.764252256020193, 21.104727834992858), + Offset(23.040230291566765, 19.74664073711234), + Offset(26.111543097339222, 20.115420598896208), + Offset(27.55929325967993, 21.15009239463557), + Offset(28.33227698392189, 22.01959032814509), + Offset(28.79917088018672, 22.731951906178715), + Offset(29.076607531862066, 23.317834214954495), + Offset(29.229668321539503, 23.792567930146006), + Offset(29.309759519906592, 24.16953974916542), + Offset(29.348005216541363, 24.46313493404516), + Offset(29.362866302202278, 24.686382480118663), + Offset(29.365356922863082, 24.85015644327557), + Offset(29.361903822491364, 24.96338882999409), + Offset(29.356202259154166, 25.033296853599516), + Offset(29.350797571225126, 25.065860841084696), + ], + <Offset>[ + Offset(28.2574725, 23.148505500000006), + Offset(28.26568758184236, 23.190664206955873), + Offset(28.29146968275362, 23.340907492234166), + Offset(28.328237151292683, 23.657249166366704), + Offset(28.334053620714325, 24.25902545156711), + Offset(28.100410288676414, 25.428040558475896), + Offset(26.521848678125934, 27.55703558557583), + Offset(23.544952295188327, 28.39789756153397), + Offset(21.810616839281764, 27.99964288944406), + Offset(20.762671119400913, 27.4943308447591), + Offset(20.057706915912753, 27.016340886676083), + Offset(19.57627219560637, 26.582232852296645), + Offset(19.25239542236892, 26.202886649688107), + Offset(19.033475924253374, 25.885716969965216), + Offset(18.88525097932906, 25.629415913397306), + Offset(18.7856686133061, 25.429057057660565), + Offset(18.720379593020393, 25.278839022692964), + Offset(18.680016152317933, 25.172988704040595), + Offset(18.65839143247326, 25.106265311736657), + Offset(18.650855764605765, 25.074127520239166), + ], + <Offset>[ + Offset(28.2574725, 23.148505500000006), + Offset(28.26568758184236, 23.190664206955873), + Offset(28.29146968275362, 23.340907492234166), + Offset(28.328237151292683, 23.657249166366704), + Offset(28.334053620714325, 24.25902545156711), + Offset(28.100410288676414, 25.428040558475896), + Offset(26.521848678125934, 27.55703558557583), + Offset(23.544952295188327, 28.39789756153397), + Offset(21.810616839281764, 27.99964288944406), + Offset(20.762671119400913, 27.4943308447591), + Offset(20.057706915912753, 27.016340886676083), + Offset(19.57627219560637, 26.582232852296645), + Offset(19.25239542236892, 26.202886649688107), + Offset(19.033475924253374, 25.885716969965216), + Offset(18.88525097932906, 25.629415913397306), + Offset(18.7856686133061, 25.429057057660565), + Offset(18.720379593020393, 25.278839022692964), + Offset(18.680016152317933, 25.172988704040595), + Offset(18.65839143247326, 25.106265311736657), + Offset(18.650855764605765, 25.074127520239166), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(28.2574725, 23.148505500000006), + Offset(28.26568758184236, 23.190664206955873), + Offset(28.29146968275362, 23.340907492234166), + Offset(28.328237151292683, 23.657249166366704), + Offset(28.334053620714325, 24.25902545156711), + Offset(28.100410288676414, 25.428040558475896), + Offset(26.521848678125934, 27.55703558557583), + Offset(23.544952295188327, 28.39789756153397), + Offset(21.810616839281764, 27.99964288944406), + Offset(20.762671119400913, 27.4943308447591), + Offset(20.057706915912753, 27.016340886676083), + Offset(19.57627219560637, 26.582232852296645), + Offset(19.25239542236892, 26.202886649688107), + Offset(19.033475924253374, 25.885716969965216), + Offset(18.88525097932906, 25.629415913397306), + Offset(18.7856686133061, 25.429057057660565), + Offset(18.720379593020393, 25.278839022692964), + Offset(18.680016152317933, 25.172988704040595), + Offset(18.65839143247326, 25.106265311736657), + Offset(18.650855764605765, 25.074127520239166), + ], + <Offset>[ + Offset(28.257472499999995, 12.930571500000006), + Offset(28.366768481821616, 12.973230190530057), + Offset(28.75116528764182, 13.133319374603126), + Offset(29.53492160504324, 13.510816894014384), + Offset(30.932137103121953, 14.376913458334252), + Offset(33.28838555685606, 16.624650919288428), + Offset(35.89432249628212, 23.37909352170482), + Offset(33.48392465035364, 31.477806524115046), + Offset(30.030077433051954, 34.89805459392186), + Offset(27.33235973933773, 36.577857882184276), + Offset(25.1989736925096, 37.50609764380484), + Offset(23.49355056041695, 37.98263525580348), + Offset(22.14477788581944, 38.17561412869281), + Offset(21.092888589213125, 38.217257284749074), + Offset(20.284788154551638, 38.18472099805207), + Offset(19.676878106356384, 38.12169428433597), + Offset(19.234798688321266, 38.052811818504196), + Offset(18.93153600117374, 37.99125390824871), + Offset(18.74595358223783, 37.94363830375375), + Offset(18.66077577959113, 37.914057688182396), + ], + <Offset>[ + Offset(28.257472499999995, 12.930571500000006), + Offset(28.366768481821616, 12.973230190530057), + Offset(28.75116528764182, 13.133319374603126), + Offset(29.53492160504324, 13.510816894014384), + Offset(30.932137103121953, 14.376913458334252), + Offset(33.28838555685606, 16.624650919288428), + Offset(35.89432249628212, 23.37909352170482), + Offset(33.48392465035364, 31.477806524115046), + Offset(30.030077433051954, 34.89805459392186), + Offset(27.33235973933773, 36.577857882184276), + Offset(25.1989736925096, 37.50609764380484), + Offset(23.49355056041695, 37.98263525580348), + Offset(22.14477788581944, 38.17561412869281), + Offset(21.092888589213125, 38.217257284749074), + Offset(20.284788154551638, 38.18472099805207), + Offset(19.676878106356384, 38.12169428433597), + Offset(19.234798688321266, 38.052811818504196), + Offset(18.93153600117374, 37.99125390824871), + Offset(18.74595358223783, 37.94363830375375), + Offset(18.66077577959113, 37.914057688182396), + ], + ), + _PathClose(), + ], + ), +], matchTextDirection: true); diff --git a/packages/material_ui/lib/src/animated_icons/data/menu_arrow.g.dart b/packages/material_ui/lib/src/animated_icons/data/menu_arrow.g.dart new file mode 100644 index 000000000000..3273a3397e89 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/menu_arrow.g.dart @@ -0,0 +1,1015 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$menu_arrow = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + <Offset>[ + Offset(42.0, 26.0), + Offset(41.91421333157091, 26.360426629492423), + Offset(41.55655262500356, 27.60382930516768), + Offset(40.57766190556539, 29.99090297157744), + Offset(38.19401046368096, 33.57567286235671), + Offset(32.70215654116029, 37.756226919427284), + Offset(26.22621984436523, 39.26167875408963), + Offset(20.102351173097617, 38.04803275423973), + Offset(15.903199608216863, 35.25316524725598), + Offset(13.57741782841064, 32.27000071222682), + Offset(12.442030802775209, 29.665215617986277), + Offset(11.981806515947115, 27.560177578292762), + Offset(11.879421136842055, 25.918712565594948), + Offset(11.95091483982305, 24.66543021784112), + Offset(12.092167805674123, 23.72603017548901), + Offset(12.245452640806768, 23.03857447590349), + Offset(12.379956070248545, 22.554583229506296), + Offset(12.480582865035407, 22.237279988168645), + Offset(12.541514124262473, 22.059212079933666), + Offset(12.562455771803593, 22.000123717314214), + Offset(12.562499999999996, 22.000000000000004), + ], + <Offset>[ + Offset(42.0, 26.0), + Offset(41.91421333157091, 26.360426629492423), + Offset(41.55655262500356, 27.60382930516768), + Offset(40.57766190556539, 29.99090297157744), + Offset(38.19401046368096, 33.57567286235671), + Offset(32.70215654116029, 37.756226919427284), + Offset(26.22621984436523, 39.26167875408963), + Offset(20.102351173097617, 38.04803275423973), + Offset(15.903199608216863, 35.25316524725598), + Offset(13.57741782841064, 32.27000071222682), + Offset(12.442030802775209, 29.665215617986277), + Offset(11.981806515947115, 27.560177578292762), + Offset(11.879421136842055, 25.918712565594948), + Offset(11.95091483982305, 24.66543021784112), + Offset(12.092167805674123, 23.72603017548901), + Offset(12.245452640806768, 23.03857447590349), + Offset(12.379956070248545, 22.554583229506296), + Offset(12.480582865035407, 22.237279988168645), + Offset(12.541514124262473, 22.059212079933666), + Offset(12.562455771803593, 22.000123717314214), + Offset(12.562499999999996, 22.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 26.0), + Offset(41.91421333157091, 26.360426629492423), + Offset(41.55655262500356, 27.60382930516768), + Offset(40.57766190556539, 29.99090297157744), + Offset(38.19401046368096, 33.57567286235671), + Offset(32.70215654116029, 37.756226919427284), + Offset(26.22621984436523, 39.26167875408963), + Offset(20.102351173097617, 38.04803275423973), + Offset(15.903199608216863, 35.25316524725598), + Offset(13.57741782841064, 32.27000071222682), + Offset(12.442030802775209, 29.665215617986277), + Offset(11.981806515947115, 27.560177578292762), + Offset(11.879421136842055, 25.918712565594948), + Offset(11.95091483982305, 24.66543021784112), + Offset(12.092167805674123, 23.72603017548901), + Offset(12.245452640806768, 23.03857447590349), + Offset(12.379956070248545, 22.554583229506296), + Offset(12.480582865035407, 22.237279988168645), + Offset(12.541514124262473, 22.059212079933666), + Offset(12.562455771803593, 22.000123717314214), + Offset(12.562499999999996, 22.000000000000004), + ], + <Offset>[ + Offset(42.0, 22.0), + Offset(41.99458528858859, 22.361234167441474), + Offset(41.91859127809106, 23.620246996030513), + Offset(41.501535596836376, 26.09905798461081), + Offset(40.02840620381446, 30.021099432452637), + Offset(35.79419835461124, 35.2186537827727), + Offset(30.076040790179817, 38.175916954629336), + Offset(24.067012730992623, 38.57855959743385), + Offset(19.453150566288006, 37.096490556388844), + Offset(16.506465839286186, 34.99409280868502), + Offset(14.73924581501028, 32.939784778587686), + Offset(13.715334530064114, 31.165018854170466), + Offset(13.140377980959201, 29.714761542791386), + Offset(12.83036672005031, 28.56755327976071), + Offset(12.672939622830032, 27.683643609921106), + Offset(12.600162038813565, 27.02281609043513), + Offset(12.571432188039635, 26.54999771317575), + Offset(12.56310619400641, 26.23642863509033), + Offset(12.562193301685781, 26.059158626029138), + Offset(12.562499038934627, 26.000123717080207), + Offset(12.562499999999996, 26.000000000000004), + ], + <Offset>[ + Offset(42.0, 22.0), + Offset(41.99458528858859, 22.361234167441474), + Offset(41.91859127809106, 23.620246996030513), + Offset(41.501535596836376, 26.09905798461081), + Offset(40.02840620381446, 30.021099432452637), + Offset(35.79419835461124, 35.2186537827727), + Offset(30.076040790179817, 38.175916954629336), + Offset(24.067012730992623, 38.57855959743385), + Offset(19.453150566288006, 37.096490556388844), + Offset(16.506465839286186, 34.99409280868502), + Offset(14.73924581501028, 32.939784778587686), + Offset(13.715334530064114, 31.165018854170466), + Offset(13.140377980959201, 29.714761542791386), + Offset(12.83036672005031, 28.56755327976071), + Offset(12.672939622830032, 27.683643609921106), + Offset(12.600162038813565, 27.02281609043513), + Offset(12.571432188039635, 26.54999771317575), + Offset(12.56310619400641, 26.23642863509033), + Offset(12.562193301685781, 26.059158626029138), + Offset(12.562499038934627, 26.000123717080207), + Offset(12.562499999999996, 26.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 22.0), + Offset(41.99458528858859, 22.361234167441474), + Offset(41.91859127809106, 23.620246996030513), + Offset(41.501535596836376, 26.09905798461081), + Offset(40.02840620381446, 30.021099432452637), + Offset(35.79419835461124, 35.2186537827727), + Offset(30.076040790179817, 38.175916954629336), + Offset(24.067012730992623, 38.57855959743385), + Offset(19.453150566288006, 37.096490556388844), + Offset(16.506465839286186, 34.99409280868502), + Offset(14.73924581501028, 32.939784778587686), + Offset(13.715334530064114, 31.165018854170466), + Offset(13.140377980959201, 29.714761542791386), + Offset(12.83036672005031, 28.56755327976071), + Offset(12.672939622830032, 27.683643609921106), + Offset(12.600162038813565, 27.02281609043513), + Offset(12.571432188039635, 26.54999771317575), + Offset(12.56310619400641, 26.23642863509033), + Offset(12.562193301685781, 26.059158626029138), + Offset(12.562499038934627, 26.000123717080207), + Offset(12.562499999999996, 26.000000000000004), + ], + <Offset>[ + Offset(6.0, 22.0), + Offset(6.056934514707525, 21.63899352743156), + Offset(6.3138203227485405, 20.384389840375796), + Offset(7.096666807426793, 17.931786874735423), + Offset(9.197983716971518, 14.110555792928775), + Offset(14.492848562759846, 9.262883961619078), + Offset(21.26860668167255, 6.947111219644562), + Offset(28.222185090070198, 7.526686671873211), + Offset(33.453333439427794, 10.134368158658866), + Offset(36.69157710577769, 13.290289151940406), + Offset(38.53223137521963, 16.248244324219414), + Offset(39.50406341592221, 18.763506401664923), + Offset(39.965161333050226, 20.80420892269316), + Offset(40.139843919215444, 22.41260360500229), + Offset(40.164704435685586, 23.649282378914172), + Offset(40.1214749003011, 24.572646331189105), + Offset(40.057897202434084, 25.232737230122385), + Offset(40.00055137536795, 25.670250802073745), + Offset(39.96271993040885, 25.917501645087587), + Offset(39.949247443632466, 25.99982748057223), + Offset(39.94921875, 26.0), + ], + <Offset>[ + Offset(6.0, 22.0), + Offset(6.056934514707525, 21.63899352743156), + Offset(6.3138203227485405, 20.384389840375796), + Offset(7.096666807426793, 17.931786874735423), + Offset(9.197983716971518, 14.110555792928775), + Offset(14.492848562759846, 9.262883961619078), + Offset(21.26860668167255, 6.947111219644562), + Offset(28.222185090070198, 7.526686671873211), + Offset(33.453333439427794, 10.134368158658866), + Offset(36.69157710577769, 13.290289151940406), + Offset(38.53223137521963, 16.248244324219414), + Offset(39.50406341592221, 18.763506401664923), + Offset(39.965161333050226, 20.80420892269316), + Offset(40.139843919215444, 22.41260360500229), + Offset(40.164704435685586, 23.649282378914172), + Offset(40.1214749003011, 24.572646331189105), + Offset(40.057897202434084, 25.232737230122385), + Offset(40.00055137536795, 25.670250802073745), + Offset(39.96271993040885, 25.917501645087587), + Offset(39.949247443632466, 25.99982748057223), + Offset(39.94921875, 26.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 22.0), + Offset(6.056934514707525, 21.63899352743156), + Offset(6.3138203227485405, 20.384389840375796), + Offset(7.096666807426793, 17.931786874735423), + Offset(9.197983716971518, 14.110555792928775), + Offset(14.492848562759846, 9.262883961619078), + Offset(21.26860668167255, 6.947111219644562), + Offset(28.222185090070198, 7.526686671873211), + Offset(33.453333439427794, 10.134368158658866), + Offset(36.69157710577769, 13.290289151940406), + Offset(38.53223137521963, 16.248244324219414), + Offset(39.50406341592221, 18.763506401664923), + Offset(39.965161333050226, 20.80420892269316), + Offset(40.139843919215444, 22.41260360500229), + Offset(40.164704435685586, 23.649282378914172), + Offset(40.1214749003011, 24.572646331189105), + Offset(40.057897202434084, 25.232737230122385), + Offset(40.00055137536795, 25.670250802073745), + Offset(39.96271993040885, 25.917501645087587), + Offset(39.949247443632466, 25.99982748057223), + Offset(39.94921875, 26.0), + ], + <Offset>[ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + <Offset>[ + Offset(6.0, 26.0), + Offset(5.976562557689849, 25.638185989482512), + Offset(5.951781669661045, 24.367972149512962), + Offset(6.172793116155802, 21.823631861702058), + Offset(7.363587976838016, 17.665129222832853), + Offset(11.400806749308899, 11.800457098273661), + Offset(17.41878573585796, 8.03287301910486), + Offset(24.257523532175192, 6.996159828679087), + Offset(29.90338248135665, 8.291042849526), + Offset(33.76252909490214, 10.56619705548221), + Offset(36.23501636298456, 12.973675163618006), + Offset(37.77053540180521, 15.158665125787222), + Offset(38.70420448893307, 17.008159945496722), + Offset(39.260392038988186, 18.5104805430827), + Offset(39.58393261852967, 19.691668944482075), + Offset(39.766765502294305, 20.58840471665747), + Offset(39.866421084642994, 21.237322746452932), + Offset(39.91802804639694, 21.671102155152063), + Offset(39.94204075298555, 21.917555098992118), + Offset(39.94920417650143, 21.999827480806236), + Offset(39.94921875, 22.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 36.0), + Offset(5.8396336833594695, 35.66398057820908), + Offset(5.329309336374063, 34.47365089829387), + Offset(4.546341863759643, 32.03857491308836), + Offset(3.9472816617934896, 27.893335303194206), + Offset(4.788314785722232, 21.470485758169694), + Offset(7.406922551234356, 16.186721598040453), + Offset(10.987511722222681, 12.449414121983239), + Offset(14.290737577882037, 10.382465570533384), + Offset(16.84152025666389, 9.340052761292668), + Offset(18.753361861843203, 8.79207829497377), + Offset(20.19495897321279, 8.483469022255434), + Offset(21.293826339887335, 8.297708512391797), + Offset(22.135385178177998, 8.180000583359465), + Offset(22.776244370552647, 8.102975309903787), + Offset(23.25488929254563, 8.051973096906334), + Offset(23.598629725699347, 8.018606137477462), + Offset(23.827700643867974, 7.99783596371886), + Offset(23.95771797811348, 7.986559676107813), + Offset(24.001111438945117, 7.982878122631195), + Offset(24.001202429357242, 7.98287044589657), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 36.0), + Offset(5.8396336833594695, 35.66398057820908), + Offset(5.329309336374063, 34.47365089829387), + Offset(4.546341863759643, 32.03857491308836), + Offset(3.9472816617934896, 27.893335303194206), + Offset(4.788314785722232, 21.470485758169694), + Offset(7.406922551234356, 16.186721598040453), + Offset(10.987511722222681, 12.449414121983239), + Offset(14.290737577882037, 10.382465570533384), + Offset(16.84152025666389, 9.340052761292668), + Offset(18.753361861843203, 8.79207829497377), + Offset(20.19495897321279, 8.483469022255434), + Offset(21.293826339887335, 8.297708512391797), + Offset(22.135385178177998, 8.180000583359465), + Offset(22.776244370552647, 8.102975309903787), + Offset(23.25488929254563, 8.051973096906334), + Offset(23.598629725699347, 8.018606137477462), + Offset(23.827700643867974, 7.99783596371886), + Offset(23.95771797811348, 7.986559676107813), + Offset(24.001111438945117, 7.982878122631195), + Offset(24.001202429357242, 7.98287044589657), + ], + <Offset>[ + Offset(42.0, 36.0), + Offset(41.7493389152824, 36.20520796529164), + Offset(40.85819701033384, 36.89246335931071), + Offset(39.01294315759756, 38.1256246432051), + Offset(35.758514239960064, 39.76970128020763), + Offset(30.180134511403956, 41.28645636464381), + Offset(24.56603417073137, 41.32925393403815), + Offset(19.271926095830622, 39.91690773672663), + Offset(15.201959304751512, 37.5726832793895), + Offset(12.456295622648877, 35.01429311055303), + Offset(10.686459838185314, 32.608514843335385), + Offset(9.579921816288039, 30.502293804851334), + Offset(8.90802993167501, 28.734147272525124), + Offset(8.513791284564158, 27.294928344333726), + Offset(8.292240475325507, 26.156988797411067), + Offset(8.174465865426919, 25.287693028463128), + Offset(8.11616441641861, 24.655137447505503), + Offset(8.089821190085125, 24.230473791307258), + Offset(8.079382709319852, 23.988506993748523), + Offset(8.076631388780909, 23.907616552409003), + Offset(8.076626005900048, 23.907446869353766), + ], + <Offset>[ + Offset(42.0, 36.0), + Offset(41.7493389152824, 36.20520796529164), + Offset(40.85819701033384, 36.89246335931071), + Offset(39.01294315759756, 38.1256246432051), + Offset(35.758514239960064, 39.76970128020763), + Offset(30.180134511403956, 41.28645636464381), + Offset(24.56603417073137, 41.32925393403815), + Offset(19.271926095830622, 39.91690773672663), + Offset(15.201959304751512, 37.5726832793895), + Offset(12.456295622648877, 35.01429311055303), + Offset(10.686459838185314, 32.608514843335385), + Offset(9.579921816288039, 30.502293804851334), + Offset(8.90802993167501, 28.734147272525124), + Offset(8.513791284564158, 27.294928344333726), + Offset(8.292240475325507, 26.156988797411067), + Offset(8.174465865426919, 25.287693028463128), + Offset(8.11616441641861, 24.655137447505503), + Offset(8.089821190085125, 24.230473791307258), + Offset(8.079382709319852, 23.988506993748523), + Offset(8.076631388780909, 23.907616552409003), + Offset(8.076626005900048, 23.907446869353766), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 36.0), + Offset(41.7493389152824, 36.20520796529164), + Offset(40.85819701033384, 36.89246335931071), + Offset(39.01294315759756, 38.1256246432051), + Offset(35.758514239960064, 39.76970128020763), + Offset(30.180134511403956, 41.28645636464381), + Offset(24.56603417073137, 41.32925393403815), + Offset(19.271926095830622, 39.91690773672663), + Offset(15.201959304751512, 37.5726832793895), + Offset(12.456295622648877, 35.01429311055303), + Offset(10.686459838185314, 32.608514843335385), + Offset(9.579921816288039, 30.502293804851334), + Offset(8.90802993167501, 28.734147272525124), + Offset(8.513791284564158, 27.294928344333726), + Offset(8.292240475325507, 26.156988797411067), + Offset(8.174465865426919, 25.287693028463128), + Offset(8.11616441641861, 24.655137447505503), + Offset(8.089821190085125, 24.230473791307258), + Offset(8.079382709319852, 23.988506993748523), + Offset(8.076631388780909, 23.907616552409003), + Offset(8.076626005900048, 23.907446869353766), + ], + <Offset>[ + Offset(42.0, 32.0), + Offset(41.803966700752746, 32.205577011286266), + Offset(41.104447603276626, 32.89996903899956), + Offset(39.64402995767152, 34.17517788052204), + Offset(37.031973302731046, 35.97545970343111), + Offset(32.44508133022271, 37.98012671725157), + Offset(27.6644042246058, 38.77327245743646), + Offset(22.963108117227325, 38.302914175295534), + Offset(19.18039906547299, 36.862333955479784), + Offset(16.509090720567585, 35.04434211490934), + Offset(14.703380298498667, 33.21759365821649), + Offset(13.512146444284534, 31.556733263561572), + Offset(12.740174664860898, 30.12862517729895), + Offset(12.248059307884624, 28.947244716051806), + Offset(11.939734974297815, 28.002595790430043), + Offset(11.750425410476474, 27.27521551305395), + Offset(11.637314290474384, 26.742992599694542), + Offset(11.572897732210654, 26.384358993735816), + Offset(11.54031155133882, 26.17955109507089), + Offset(11.530083003283234, 26.111009046369567), + Offset(11.530061897030713, 26.110865227715482), + ], + <Offset>[ + Offset(42.0, 32.0), + Offset(41.803966700752746, 32.205577011286266), + Offset(41.104447603276626, 32.89996903899956), + Offset(39.64402995767152, 34.17517788052204), + Offset(37.031973302731046, 35.97545970343111), + Offset(32.44508133022271, 37.98012671725157), + Offset(27.6644042246058, 38.77327245743646), + Offset(22.963108117227325, 38.302914175295534), + Offset(19.18039906547299, 36.862333955479784), + Offset(16.509090720567585, 35.04434211490934), + Offset(14.703380298498667, 33.21759365821649), + Offset(13.512146444284534, 31.556733263561572), + Offset(12.740174664860898, 30.12862517729895), + Offset(12.248059307884624, 28.947244716051806), + Offset(11.939734974297815, 28.002595790430043), + Offset(11.750425410476474, 27.27521551305395), + Offset(11.637314290474384, 26.742992599694542), + Offset(11.572897732210654, 26.384358993735816), + Offset(11.54031155133882, 26.17955109507089), + Offset(11.530083003283234, 26.111009046369567), + Offset(11.530061897030713, 26.110865227715482), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 32.0), + Offset(41.803966700752746, 32.205577011286266), + Offset(41.104447603276626, 32.89996903899956), + Offset(39.64402995767152, 34.17517788052204), + Offset(37.031973302731046, 35.97545970343111), + Offset(32.44508133022271, 37.98012671725157), + Offset(27.6644042246058, 38.77327245743646), + Offset(22.963108117227325, 38.302914175295534), + Offset(19.18039906547299, 36.862333955479784), + Offset(16.509090720567585, 35.04434211490934), + Offset(14.703380298498667, 33.21759365821649), + Offset(13.512146444284534, 31.556733263561572), + Offset(12.740174664860898, 30.12862517729895), + Offset(12.248059307884624, 28.947244716051806), + Offset(11.939734974297815, 28.002595790430043), + Offset(11.750425410476474, 27.27521551305395), + Offset(11.637314290474384, 26.742992599694542), + Offset(11.572897732210654, 26.384358993735816), + Offset(11.54031155133882, 26.17955109507089), + Offset(11.530083003283234, 26.111009046369567), + Offset(11.530061897030713, 26.110865227715482), + ], + <Offset>[ + Offset(6.0, 32.0), + Offset(5.899914425897517, 31.66443482499171), + Offset(5.601001082666045, 30.482888615847468), + Offset(5.242005036683729, 28.09953280239226), + Offset(5.346316156571252, 24.145975901906155), + Offset(7.249241148069178, 18.317100047682345), + Offset(10.710823881370487, 13.931896549234073), + Offset(14.817117889097364, 11.294374466111893), + Offset(18.288493245756, 10.248489378687303), + Offset(20.784419638077317, 10.013509863155594), + Offset(22.541938014255397, 10.075312777589325), + Offset(23.798109358346892, 10.220508832423288), + Offset(24.71461203122786, 10.370924674281323), + Offset(25.392890381083, 10.501349297587215), + Offset(25.896277759611298, 10.60605174724228), + Offset(26.265268043339944, 10.685909272436422), + Offset(26.526795349038366, 10.74364670273436), + Offset(26.699555102368272, 10.782158496973931), + Offset(26.79709065296033, 10.80399872839147), + Offset(26.829561509459538, 10.811282301423006), + Offset(26.829629554119695, 10.811297570626497), + ], + <Offset>[ + Offset(6.0, 32.0), + Offset(5.899914425897517, 31.66443482499171), + Offset(5.601001082666045, 30.482888615847468), + Offset(5.242005036683729, 28.09953280239226), + Offset(5.346316156571252, 24.145975901906155), + Offset(7.249241148069178, 18.317100047682345), + Offset(10.710823881370487, 13.931896549234073), + Offset(14.817117889097364, 11.294374466111893), + Offset(18.288493245756, 10.248489378687303), + Offset(20.784419638077317, 10.013509863155594), + Offset(22.541938014255397, 10.075312777589325), + Offset(23.798109358346892, 10.220508832423288), + Offset(24.71461203122786, 10.370924674281323), + Offset(25.392890381083, 10.501349297587215), + Offset(25.896277759611298, 10.60605174724228), + Offset(26.265268043339944, 10.685909272436422), + Offset(26.526795349038366, 10.74364670273436), + Offset(26.699555102368272, 10.782158496973931), + Offset(26.79709065296033, 10.80399872839147), + Offset(26.829561509459538, 10.811282301423006), + Offset(26.829629554119695, 10.811297570626497), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 32.0), + Offset(5.899914425897517, 31.66443482499171), + Offset(5.601001082666045, 30.482888615847468), + Offset(5.242005036683729, 28.09953280239226), + Offset(5.346316156571252, 24.145975901906155), + Offset(7.249241148069178, 18.317100047682345), + Offset(10.710823881370487, 13.931896549234073), + Offset(14.817117889097364, 11.294374466111893), + Offset(18.288493245756, 10.248489378687303), + Offset(20.784419638077317, 10.013509863155594), + Offset(22.541938014255397, 10.075312777589325), + Offset(23.798109358346892, 10.220508832423288), + Offset(24.71461203122786, 10.370924674281323), + Offset(25.392890381083, 10.501349297587215), + Offset(25.896277759611298, 10.60605174724228), + Offset(26.265268043339944, 10.685909272436422), + Offset(26.526795349038366, 10.74364670273436), + Offset(26.699555102368272, 10.782158496973931), + Offset(26.79709065296033, 10.80399872839147), + Offset(26.829561509459538, 10.811282301423006), + Offset(26.829629554119695, 10.811297570626497), + ], + <Offset>[ + Offset(6.0, 36.0), + Offset(5.839633683308566, 35.66398057820831), + Offset(5.329309336323984, 34.47365089829046), + Offset(4.546341863735712, 32.03857491308413), + Offset(3.947281661825336, 27.893335303206097), + Offset(4.788314785746671, 21.47048575818877), + Offset(7.406922551270995, 16.18672159809414), + Offset(10.98751172223972, 12.449414122039723), + Offset(14.290737577881032, 10.382465570503403), + Offset(16.841520256655304, 9.340052761342939), + Offset(18.753361861827802, 8.792078295019234), + Offset(20.194958973207576, 8.483469022266245), + Offset(21.293826339889407, 8.297708512388375), + Offset(22.13538517817335, 8.180000583365981), + Offset(22.776244370563283, 8.102975309890528), + Offset(23.25488929251534, 8.051973096940955), + Offset(23.598629725644848, 8.018606137536025), + Offset(23.82770064384222, 7.997835963745423), + Offset(23.957717978081078, 7.986559676140466), + Offset(24.001111438940168, 7.982878122636148), + Offset(24.001202429373503, 7.982870445880305), + ], + <Offset>[ + Offset(6.0, 36.0), + Offset(5.839633683308566, 35.66398057820831), + Offset(5.329309336323984, 34.47365089829046), + Offset(4.546341863735712, 32.03857491308413), + Offset(3.947281661825336, 27.893335303206097), + Offset(4.788314785746671, 21.47048575818877), + Offset(7.406922551270995, 16.18672159809414), + Offset(10.98751172223972, 12.449414122039723), + Offset(14.290737577881032, 10.382465570503403), + Offset(16.841520256655304, 9.340052761342939), + Offset(18.753361861827802, 8.792078295019234), + Offset(20.194958973207576, 8.483469022266245), + Offset(21.293826339889407, 8.297708512388375), + Offset(22.13538517817335, 8.180000583365981), + Offset(22.776244370563283, 8.102975309890528), + Offset(23.25488929251534, 8.051973096940955), + Offset(23.598629725644848, 8.018606137536025), + Offset(23.82770064384222, 7.997835963745423), + Offset(23.957717978081078, 7.986559676140466), + Offset(24.001111438940168, 7.982878122636148), + Offset(24.001202429373503, 7.982870445880305), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 16.0), + Offset(6.222470088677106, 15.614531066984553), + Offset(7.071161725316092, 14.306422712262563), + Offset(9.085869786142727, 11.907139949336411), + Offset(13.311519331212619, 8.711520321213257), + Offset(21.694206315186374, 6.462423500731354), + Offset(30.07031570748504, 8.471955170698632), + Offset(36.20036889900587, 14.155750775196541), + Offset(38.533897479983715, 20.76099122996903), + Offset(38.182626701431914, 26.194302454359914), + Offset(36.59711302702814, 30.110286603895076), + Offset(34.63761335058528, 32.76106836363335), + Offset(32.7272901891386, 34.4927008221791), + Offset(31.04869117038896, 35.596105690451935), + Offset(29.664526028757855, 36.28441549314729), + Offset(28.581655311555835, 36.70452225851578), + Offset(27.782897949107628, 36.95396775456513), + Offset(27.242531133855476, 37.09522522130338), + Offset(26.933380541033216, 37.166375518103024), + Offset(26.82984682779076, 37.188656481991416), + Offset(26.829629554103434, 37.18870242935725), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 16.0), + Offset(6.222470088677106, 15.614531066984553), + Offset(7.071161725316092, 14.306422712262563), + Offset(9.085869786142727, 11.907139949336411), + Offset(13.311519331212619, 8.711520321213257), + Offset(21.694206315186374, 6.462423500731354), + Offset(30.07031570748504, 8.471955170698632), + Offset(36.20036889900587, 14.155750775196541), + Offset(38.533897479983715, 20.76099122996903), + Offset(38.182626701431914, 26.194302454359914), + Offset(36.59711302702814, 30.110286603895076), + Offset(34.63761335058528, 32.76106836363335), + Offset(32.7272901891386, 34.4927008221791), + Offset(31.04869117038896, 35.596105690451935), + Offset(29.664526028757855, 36.28441549314729), + Offset(28.581655311555835, 36.70452225851578), + Offset(27.782897949107628, 36.95396775456513), + Offset(27.242531133855476, 37.09522522130338), + Offset(26.933380541033216, 37.166375518103024), + Offset(26.82984682779076, 37.188656481991416), + Offset(26.829629554103434, 37.18870242935725), + ], + <Offset>[ + Offset(42.0, 16.0), + Offset(42.119273441095075, 16.516374018071716), + Offset(42.428662704565184, 18.32937541467259), + Offset(42.54812490043565, 21.94159775950881), + Offset(41.3111285319893, 27.683594454682137), + Offset(36.06395079582478, 35.01020271691918), + Offset(28.59459512599702, 38.51093769070532), + Offset(21.239886122259133, 38.07233071493643), + Offset(16.251628495692138, 35.34156866251391), + Offset(13.527101819238178, 32.27103394597236), + Offset(12.16858814546228, 29.604397296366464), + Offset(11.548946515009288, 27.474331231158473), + Offset(11.311114637013635, 25.826563435488687), + Offset(11.262012546535352, 24.572239162454554), + Offset(11.298221100690522, 23.63118177535833), + Offset(11.364474416879979, 22.940254245947138), + Offset(11.431638843687892, 22.451805922237554), + Offset(11.485090012547001, 22.130328573710905), + Offset(11.518417313485447, 21.949395273355513), + Offset(11.530012405933167, 21.889264075838188), + Offset(11.53003696527787, 21.889138124802937), + ], + <Offset>[ + Offset(42.0, 16.0), + Offset(42.119273441095075, 16.516374018071716), + Offset(42.428662704565184, 18.32937541467259), + Offset(42.54812490043565, 21.94159775950881), + Offset(41.3111285319893, 27.683594454682137), + Offset(36.06395079582478, 35.01020271691918), + Offset(28.59459512599702, 38.51093769070532), + Offset(21.239886122259133, 38.07233071493643), + Offset(16.251628495692138, 35.34156866251391), + Offset(13.527101819238178, 32.27103394597236), + Offset(12.16858814546228, 29.604397296366464), + Offset(11.548946515009288, 27.474331231158473), + Offset(11.311114637013635, 25.826563435488687), + Offset(11.262012546535352, 24.572239162454554), + Offset(11.298221100690522, 23.63118177535833), + Offset(11.364474416879979, 22.940254245947138), + Offset(11.431638843687892, 22.451805922237554), + Offset(11.485090012547001, 22.130328573710905), + Offset(11.518417313485447, 21.949395273355513), + Offset(11.530012405933167, 21.889264075838188), + Offset(11.53003696527787, 21.889138124802937), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 16.0), + Offset(42.119273441095075, 16.516374018071716), + Offset(42.428662704565184, 18.32937541467259), + Offset(42.54812490043565, 21.94159775950881), + Offset(41.3111285319893, 27.683594454682137), + Offset(36.06395079582478, 35.01020271691918), + Offset(28.59459512599702, 38.51093769070532), + Offset(21.239886122259133, 38.07233071493643), + Offset(16.251628495692138, 35.34156866251391), + Offset(13.527101819238178, 32.27103394597236), + Offset(12.16858814546228, 29.604397296366464), + Offset(11.548946515009288, 27.474331231158473), + Offset(11.311114637013635, 25.826563435488687), + Offset(11.262012546535352, 24.572239162454554), + Offset(11.298221100690522, 23.63118177535833), + Offset(11.364474416879979, 22.940254245947138), + Offset(11.431638843687892, 22.451805922237554), + Offset(11.485090012547001, 22.130328573710905), + Offset(11.518417313485447, 21.949395273355513), + Offset(11.530012405933167, 21.889264075838188), + Offset(11.53003696527787, 21.889138124802937), + ], + <Offset>[ + Offset(42.0, 12.0), + Offset(42.22538630246601, 12.517777761542249), + Offset(42.90619853384615, 14.357900907446863), + Offset(43.759884509852945, 18.128995147835514), + Offset(43.66585885175813, 24.44736028078141), + Offset(39.74861752085834, 33.43380529842439), + Offset(32.57188683977151, 39.07136996422343), + Offset(24.376857043988256, 40.600018479197814), + Offset(17.959269400168804, 39.004426856660785), + Offset(13.850567169499653, 36.311009998593796), + Offset(11.374155956344177, 33.58880277176081), + Offset(9.917496515696001, 31.204288894581083), + Offset(9.07498759074148, 29.236785710939074), + Offset(8.597571742452605, 27.666692096657314), + Offset(8.334783321442917, 26.44693980672826), + Offset(8.195874559699876, 25.52824222288586), + Offset(8.126295299747222, 24.866824239052814), + Offset(8.093843447379264, 24.426077640310794), + Offset(8.080338503727083, 24.17611706018137), + Offset(8.076619249177135, 24.092742069165425), + Offset(8.07661186374038, 24.09256727275783), + ], + <Offset>[ + Offset(42.0, 12.0), + Offset(42.22538630246601, 12.517777761542249), + Offset(42.90619853384615, 14.357900907446863), + Offset(43.759884509852945, 18.128995147835514), + Offset(43.66585885175813, 24.44736028078141), + Offset(39.74861752085834, 33.43380529842439), + Offset(32.57188683977151, 39.07136996422343), + Offset(24.376857043988256, 40.600018479197814), + Offset(17.959269400168804, 39.004426856660785), + Offset(13.850567169499653, 36.311009998593796), + Offset(11.374155956344177, 33.58880277176081), + Offset(9.917496515696001, 31.204288894581083), + Offset(9.07498759074148, 29.236785710939074), + Offset(8.597571742452605, 27.666692096657314), + Offset(8.334783321442917, 26.44693980672826), + Offset(8.195874559699876, 25.52824222288586), + Offset(8.126295299747222, 24.866824239052814), + Offset(8.093843447379264, 24.426077640310794), + Offset(8.080338503727083, 24.17611706018137), + Offset(8.076619249177135, 24.092742069165425), + Offset(8.07661186374038, 24.09256727275783), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 12.0), + Offset(42.22538630246601, 12.517777761542249), + Offset(42.90619853384615, 14.357900907446863), + Offset(43.759884509852945, 18.128995147835514), + Offset(43.66585885175813, 24.44736028078141), + Offset(39.74861752085834, 33.43380529842439), + Offset(32.57188683977151, 39.07136996422343), + Offset(24.376857043988256, 40.600018479197814), + Offset(17.959269400168804, 39.004426856660785), + Offset(13.850567169499653, 36.311009998593796), + Offset(11.374155956344177, 33.58880277176081), + Offset(9.917496515696001, 31.204288894581083), + Offset(9.07498759074148, 29.236785710939074), + Offset(8.597571742452605, 27.666692096657314), + Offset(8.334783321442917, 26.44693980672826), + Offset(8.195874559699876, 25.52824222288586), + Offset(8.126295299747222, 24.866824239052814), + Offset(8.093843447379264, 24.426077640310794), + Offset(8.080338503727083, 24.17611706018137), + Offset(8.076619249177135, 24.092742069165425), + Offset(8.07661186374038, 24.09256727275783), + ], + <Offset>[ + Offset(6.0, 12.0), + Offset(6.3229312318803075, 11.61579282114921), + Offset(7.523361420980265, 10.332065476778915), + Offset(10.234818160108134, 8.075701885898315), + Offset(15.555284551985588, 5.400098023461183), + Offset(25.267103519984172, 4.663978182144188), + Offset(34.065497532306516, 8.668225867992323), + Offset(39.59155761731576, 16.27703318845691), + Offset(40.72409454498984, 24.108085016590273), + Offset(39.139841854472834, 30.0780814324673), + Offset(36.514293313228855, 34.10942912386185), + Offset(33.744815583253256, 36.6601595585975), + Offset(31.226861893018718, 38.20062678263231), + Offset(29.10189988007002, 39.09038725780428), + Offset(27.3951953205187, 39.57837027981981), + Offset(26.083922435637483, 39.82883505984612), + Offset(25.128742795932077, 39.94653528477588), + Offset(24.487982707377697, 39.99564983955995), + Offset(24.123290412440365, 40.013021521592925), + Offset(24.001457946431486, 40.017121849607435), + Offset(24.001202429333205, 40.017129554079396), + ], + <Offset>[ + Offset(6.0, 12.0), + Offset(6.3229312318803075, 11.61579282114921), + Offset(7.523361420980265, 10.332065476778915), + Offset(10.234818160108134, 8.075701885898315), + Offset(15.555284551985588, 5.400098023461183), + Offset(25.267103519984172, 4.663978182144188), + Offset(34.065497532306516, 8.668225867992323), + Offset(39.59155761731576, 16.27703318845691), + Offset(40.72409454498984, 24.108085016590273), + Offset(39.139841854472834, 30.0780814324673), + Offset(36.514293313228855, 34.10942912386185), + Offset(33.744815583253256, 36.6601595585975), + Offset(31.226861893018718, 38.20062678263231), + Offset(29.10189988007002, 39.09038725780428), + Offset(27.3951953205187, 39.57837027981981), + Offset(26.083922435637483, 39.82883505984612), + Offset(25.128742795932077, 39.94653528477588), + Offset(24.487982707377697, 39.99564983955995), + Offset(24.123290412440365, 40.013021521592925), + Offset(24.001457946431486, 40.017121849607435), + Offset(24.001202429333205, 40.017129554079396), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 12.0), + Offset(6.3229312318803075, 11.61579282114921), + Offset(7.523361420980265, 10.332065476778915), + Offset(10.234818160108134, 8.075701885898315), + Offset(15.555284551985588, 5.400098023461183), + Offset(25.267103519984172, 4.663978182144188), + Offset(34.065497532306516, 8.668225867992323), + Offset(39.59155761731576, 16.27703318845691), + Offset(40.72409454498984, 24.108085016590273), + Offset(39.139841854472834, 30.0780814324673), + Offset(36.514293313228855, 34.10942912386185), + Offset(33.744815583253256, 36.6601595585975), + Offset(31.226861893018718, 38.20062678263231), + Offset(29.10189988007002, 39.09038725780428), + Offset(27.3951953205187, 39.57837027981981), + Offset(26.083922435637483, 39.82883505984612), + Offset(25.128742795932077, 39.94653528477588), + Offset(24.487982707377697, 39.99564983955995), + Offset(24.123290412440365, 40.013021521592925), + Offset(24.001457946431486, 40.017121849607435), + Offset(24.001202429333205, 40.017129554079396), + ], + <Offset>[ + Offset(6.0, 16.0), + Offset(6.22247008872931, 15.614531066985863), + Offset(7.071161725356028, 14.306422712267109), + Offset(9.085869786222908, 11.907139949360454), + Offset(13.311519331206826, 8.711520321209331), + Offset(21.69420631520211, 6.462423500762615), + Offset(30.070315707485825, 8.471955170682651), + Offset(36.20036889903345, 14.155750775152455), + Offset(38.53389748002304, 20.760991229943293), + Offset(38.18262670145813, 26.194302454353455), + Offset(36.597113027065134, 30.110286603895844), + Offset(34.63761335066132, 32.761068363650764), + Offset(32.72729018913396, 34.49270082217723), + Offset(31.048691170407302, 35.59610569046216), + Offset(29.66452602881138, 36.28441549318417), + Offset(28.58165531160348, 36.70452225855387), + Offset(27.78289794916673, 36.95396775461755), + Offset(27.24253113386635, 37.09522522131371), + Offset(26.933380541051008, 37.16637551812059), + Offset(26.829846827821875, 37.18865648202253), + Offset(26.829629554079393, 37.188702429333205), + ], + <Offset>[ + Offset(6.0, 16.0), + Offset(6.22247008872931, 15.614531066985863), + Offset(7.071161725356028, 14.306422712267109), + Offset(9.085869786222908, 11.907139949360454), + Offset(13.311519331206826, 8.711520321209331), + Offset(21.69420631520211, 6.462423500762615), + Offset(30.070315707485825, 8.471955170682651), + Offset(36.20036889903345, 14.155750775152455), + Offset(38.53389748002304, 20.760991229943293), + Offset(38.18262670145813, 26.194302454353455), + Offset(36.597113027065134, 30.110286603895844), + Offset(34.63761335066132, 32.761068363650764), + Offset(32.72729018913396, 34.49270082217723), + Offset(31.048691170407302, 35.59610569046216), + Offset(29.66452602881138, 36.28441549318417), + Offset(28.58165531160348, 36.70452225855387), + Offset(27.78289794916673, 36.95396775461755), + Offset(27.24253113386635, 37.09522522131371), + Offset(26.933380541051008, 37.16637551812059), + Offset(26.829846827821875, 37.18865648202253), + Offset(26.829629554079393, 37.188702429333205), + ], + ), + _PathClose(), + ], + ), +], matchTextDirection: true); diff --git a/packages/material_ui/lib/src/animated_icons/data/menu_close.g.dart b/packages/material_ui/lib/src/animated_icons/data/menu_close.g.dart new file mode 100644 index 000000000000..e8284f73b77e --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/menu_close.g.dart @@ -0,0 +1,1015 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$menu_close = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 26.0), + Offset(6.667958372815065, 25.652081003354123), + Offset(8.330956385969174, 24.584197933972426), + Offset(10.795082531480682, 22.920903618043887), + Offset(14.118850428921743, 21.151292868049936), + Offset(18.25264983114299, 20.14945205026408), + Offset(21.311663261847183, 21.835975547204264), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 26.0), + Offset(6.667958372815065, 25.652081003354123), + Offset(8.330956385969174, 24.584197933972426), + Offset(10.795082531480682, 22.920903618043887), + Offset(14.118850428921743, 21.151292868049936), + Offset(18.25264983114299, 20.14945205026408), + Offset(21.311663261847183, 21.835975547204264), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ], + <Offset>[ + Offset(42.0, 26.0), + Offset(41.25166967016726, 26.34711145869683), + Offset(39.30700496104292, 27.399384375173792), + Offset(36.28104377724833, 28.970941368922745), + Offset(32.04675383085589, 30.403280561808284), + Offset(26.655308355431437, 30.38812108642142), + Offset(22.838515792338228, 27.24978625225603), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ], + <Offset>[ + Offset(42.0, 26.0), + Offset(41.25166967016726, 26.34711145869683), + Offset(39.30700496104292, 27.399384375173792), + Offset(36.28104377724833, 28.970941368922745), + Offset(32.04675383085589, 30.403280561808284), + Offset(26.655308355431437, 30.38812108642142), + Offset(22.838515792338228, 27.24978625225603), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 26.0), + Offset(41.25166967016726, 26.34711145869683), + Offset(39.30700496104292, 27.399384375173792), + Offset(36.28104377724833, 28.970941368922745), + Offset(32.04675383085589, 30.403280561808284), + Offset(26.655308355431437, 30.38812108642142), + Offset(22.838515792338228, 27.24978625225603), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ], + <Offset>[ + Offset(42.0, 22.0), + Offset(41.332041627184935, 22.347918996645877), + Offset(39.669043614130416, 23.415802066036626), + Offset(37.20491746851932, 25.079096381956113), + Offset(33.88114957098939, 26.848707131904206), + Offset(29.747350168882384, 27.85054794976684), + Offset(26.688336738152817, 26.164024452795736), + Offset(25.982330778947503, 24.265263421597062), + Offset(25.77497547903557, 24.921662654566433), + Offset(25.464524005437774, 25.362046048229097), + Offset(25.148607506117536, 25.637284580300705), + Offset(24.8667640070585, 25.802420637938855), + Offset(24.630478422058573, 25.898024488598217), + Offset(24.43972594011363, 25.95106153095979), + Offset(24.290385908577957, 25.97880671721605), + Offset(24.177354699003402, 25.992120807265813), + Offset(24.09573805889554, 25.997707241834732), + Offset(24.041261664485504, 25.999574323460838), + Offset(24.010339588711656, 25.999973273047736), + Offset(24.000021633565517, 25.999999999883), + Offset(23.999999999999996, 26.000000000000004), + ], + <Offset>[ + Offset(42.0, 22.0), + Offset(41.332041627184935, 22.347918996645877), + Offset(39.669043614130416, 23.415802066036626), + Offset(37.20491746851932, 25.079096381956113), + Offset(33.88114957098939, 26.848707131904206), + Offset(29.747350168882384, 27.85054794976684), + Offset(26.688336738152817, 26.164024452795736), + Offset(25.982330778947503, 24.265263421597062), + Offset(25.77497547903557, 24.921662654566433), + Offset(25.464524005437774, 25.362046048229097), + Offset(25.148607506117536, 25.637284580300705), + Offset(24.8667640070585, 25.802420637938855), + Offset(24.630478422058573, 25.898024488598217), + Offset(24.43972594011363, 25.95106153095979), + Offset(24.290385908577957, 25.97880671721605), + Offset(24.177354699003402, 25.992120807265813), + Offset(24.09573805889554, 25.997707241834732), + Offset(24.041261664485504, 25.999574323460838), + Offset(24.010339588711656, 25.999973273047736), + Offset(24.000021633565517, 25.999999999883), + Offset(23.999999999999996, 26.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 22.0), + Offset(41.332041627184935, 22.347918996645877), + Offset(39.669043614130416, 23.415802066036626), + Offset(37.20491746851932, 25.079096381956113), + Offset(33.88114957098939, 26.848707131904206), + Offset(29.747350168882384, 27.85054794976684), + Offset(26.688336738152817, 26.164024452795736), + Offset(25.982330778947503, 24.265263421597062), + Offset(25.77497547903557, 24.921662654566433), + Offset(25.464524005437774, 25.362046048229097), + Offset(25.148607506117536, 25.637284580300705), + Offset(24.8667640070585, 25.802420637938855), + Offset(24.630478422058573, 25.898024488598217), + Offset(24.43972594011363, 25.95106153095979), + Offset(24.290385908577957, 25.97880671721605), + Offset(24.177354699003402, 25.992120807265813), + Offset(24.09573805889554, 25.997707241834732), + Offset(24.041261664485504, 25.999574323460838), + Offset(24.010339588711656, 25.999973273047736), + Offset(24.000021633565517, 25.999999999883), + Offset(23.999999999999996, 26.000000000000004), + ], + <Offset>[ + Offset(6.0, 22.0), + Offset(6.74833032983274, 21.65288854130317), + Offset(8.692995039056669, 20.60061562483526), + Offset(11.718956222751673, 19.029058631077255), + Offset(15.953246169055248, 17.596719438145858), + Offset(21.344691644593937, 17.6118789136095), + Offset(25.161484207661772, 20.75021374774397), + Offset(25.982330778947503, 24.265263421597062), + Offset(25.77497547903557, 24.921662654566433), + Offset(25.464524005437774, 25.362046048229097), + Offset(25.148607506117536, 25.637284580300705), + Offset(24.8667640070585, 25.802420637938855), + Offset(24.630478422058573, 25.898024488598217), + Offset(24.43972594011363, 25.95106153095979), + Offset(24.290385908577957, 25.97880671721605), + Offset(24.177354699003402, 25.992120807265813), + Offset(24.09573805889554, 25.997707241834732), + Offset(24.041261664485504, 25.999574323460838), + Offset(24.010339588711656, 25.999973273047736), + Offset(24.000021633565517, 25.999999999883), + Offset(23.999999999999996, 26.000000000000004), + ], + <Offset>[ + Offset(6.0, 22.0), + Offset(6.74833032983274, 21.65288854130317), + Offset(8.692995039056669, 20.60061562483526), + Offset(11.718956222751673, 19.029058631077255), + Offset(15.953246169055248, 17.596719438145858), + Offset(21.344691644593937, 17.6118789136095), + Offset(25.161484207661772, 20.75021374774397), + Offset(25.982330778947503, 24.265263421597062), + Offset(25.77497547903557, 24.921662654566433), + Offset(25.464524005437774, 25.362046048229097), + Offset(25.148607506117536, 25.637284580300705), + Offset(24.8667640070585, 25.802420637938855), + Offset(24.630478422058573, 25.898024488598217), + Offset(24.43972594011363, 25.95106153095979), + Offset(24.290385908577957, 25.97880671721605), + Offset(24.177354699003402, 25.992120807265813), + Offset(24.09573805889554, 25.997707241834732), + Offset(24.041261664485504, 25.999574323460838), + Offset(24.010339588711656, 25.999973273047736), + Offset(24.000021633565517, 25.999999999883), + Offset(23.999999999999996, 26.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 22.0), + Offset(6.74833032983274, 21.65288854130317), + Offset(8.692995039056669, 20.60061562483526), + Offset(11.718956222751673, 19.029058631077255), + Offset(15.953246169055248, 17.596719438145858), + Offset(21.344691644593937, 17.6118789136095), + Offset(25.161484207661772, 20.75021374774397), + Offset(25.982330778947503, 24.265263421597062), + Offset(25.77497547903557, 24.921662654566433), + Offset(25.464524005437774, 25.362046048229097), + Offset(25.148607506117536, 25.637284580300705), + Offset(24.8667640070585, 25.802420637938855), + Offset(24.630478422058573, 25.898024488598217), + Offset(24.43972594011363, 25.95106153095979), + Offset(24.290385908577957, 25.97880671721605), + Offset(24.177354699003402, 25.992120807265813), + Offset(24.09573805889554, 25.997707241834732), + Offset(24.041261664485504, 25.999574323460838), + Offset(24.010339588711656, 25.999973273047736), + Offset(24.000021633565517, 25.999999999883), + Offset(23.999999999999996, 26.000000000000004), + ], + <Offset>[ + Offset(6.0, 26.0), + Offset(6.667958372815065, 25.652081003354123), + Offset(8.330956385969174, 24.584197933972426), + Offset(10.795082531480682, 22.920903618043887), + Offset(14.118850428921743, 21.151292868049936), + Offset(18.25264983114299, 20.14945205026408), + Offset(21.311663261847183, 21.835975547204264), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ], + <Offset>[ + Offset(6.0, 26.0), + Offset(6.667958372815065, 25.652081003354123), + Offset(8.330956385969174, 24.584197933972426), + Offset(10.795082531480682, 22.920903618043887), + Offset(14.118850428921743, 21.151292868049936), + Offset(18.25264983114299, 20.14945205026408), + Offset(21.311663261847183, 21.835975547204264), + Offset(22.017669221052497, 23.734736578402938), + Offset(22.22502452096443, 23.078337345433567), + Offset(22.535475994562226, 22.637953951770903), + Offset(22.851392493882464, 22.362715419699295), + Offset(23.1332359929415, 22.197579362061152), + Offset(23.369521577941427, 22.101975511401783), + Offset(23.560274059886364, 22.048938469040202), + Offset(23.709614091422043, 22.02119328278395), + Offset(23.822645300996605, 22.00787919273418), + Offset(23.90426194110445, 22.002292758165275), + Offset(23.958738335514504, 22.000425676539155), + Offset(23.989660411288344, 22.000026726952264), + Offset(23.999978366434483, 22.000000000117), + Offset(23.999999999999996, 22.000000000000004), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 36.0), + Offset(5.755802622931704, 35.48132577125743), + Offset(5.010307637171476, 33.62392385238556), + Offset(4.045724406149144, 29.753229622188503), + Offset(4.0861862642629525, 23.09758660034176), + Offset(8.564534830281378, 13.380886967716135), + Offset(17.231202711318005, 7.518259082609372), + Offset(27.314036258810987, 7.072010923819601), + Offset(34.88234825995056, 10.868941008448914), + Offset(39.083273856489825, 15.969526394266794), + Offset(40.919225828875916, 20.778998598927743), + Offset(41.3739602310385, 24.78219833097991), + Offset(41.11406980357167, 27.929167557007872), + Offset(40.542525130544135, 30.325075504900447), + Offset(39.89012097509991, 32.10612260851932), + Offset(39.28321027503917, 33.39611499843784), + Offset(38.78633478113526, 34.295159061960966), + Offset(38.427830072911185, 34.87959495005215), + Offset(38.21486700558917, 35.20562675712161), + Offset(38.14228859445484, 35.31348285156429), + Offset(38.14213562373095, 35.31370849898477), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 36.0), + Offset(5.755802622931704, 35.48132577125743), + Offset(5.010307637171476, 33.62392385238556), + Offset(4.045724406149144, 29.753229622188503), + Offset(4.0861862642629525, 23.09758660034176), + Offset(8.564534830281378, 13.380886967716135), + Offset(17.231202711318005, 7.518259082609372), + Offset(27.314036258810987, 7.072010923819601), + Offset(34.88234825995056, 10.868941008448914), + Offset(39.083273856489825, 15.969526394266794), + Offset(40.919225828875916, 20.778998598927743), + Offset(41.3739602310385, 24.78219833097991), + Offset(41.11406980357167, 27.929167557007872), + Offset(40.542525130544135, 30.325075504900447), + Offset(39.89012097509991, 32.10612260851932), + Offset(39.28321027503917, 33.39611499843784), + Offset(38.78633478113526, 34.295159061960966), + Offset(38.427830072911185, 34.87959495005215), + Offset(38.21486700558917, 35.20562675712161), + Offset(38.14228859445484, 35.31348285156429), + Offset(38.14213562373095, 35.31370849898477), + ], + <Offset>[ + Offset(42.0, 36.0), + Offset(41.74444683546158, 36.38547605961641), + Offset(40.779522756565214, 37.69372111300368), + Offset(38.528666977308376, 40.09376498715554), + Offset(33.888986943996294, 43.29147358735062), + Offset(24.750542697847216, 45.53696181075469), + Offset(15.464766435530956, 43.474895505995576), + Offset(8.222494539070887, 37.592709388360404), + Offset(4.758504180127748, 30.580714593150105), + Offset(4.129263053465191, 24.58446277139909), + Offset(4.926943149181838, 20.033621174401183), + Offset(6.282139476517855, 16.74701842430737), + Offset(7.742736159475957, 14.425312891970705), + Offset(9.093991024465053, 12.803953891864928), + Offset(10.244527895379168, 11.682146233885181), + Offset(11.164395063408941, 10.916519114743828), + Offset(11.853227009710306, 10.407762682849047), + Offset(12.32400850869499, 10.088659111654252), + Offset(12.595052974338124, 9.914815599625843), + Offset(12.68610028619067, 9.857982919050801), + Offset(12.68629150101523, 9.85786437626906), + ], + <Offset>[ + Offset(42.0, 36.0), + Offset(41.74444683546158, 36.38547605961641), + Offset(40.779522756565214, 37.69372111300368), + Offset(38.528666977308376, 40.09376498715554), + Offset(33.888986943996294, 43.29147358735062), + Offset(24.750542697847216, 45.53696181075469), + Offset(15.464766435530956, 43.474895505995576), + Offset(8.222494539070887, 37.592709388360404), + Offset(4.758504180127748, 30.580714593150105), + Offset(4.129263053465191, 24.58446277139909), + Offset(4.926943149181838, 20.033621174401183), + Offset(6.282139476517855, 16.74701842430737), + Offset(7.742736159475957, 14.425312891970705), + Offset(9.093991024465053, 12.803953891864928), + Offset(10.244527895379168, 11.682146233885181), + Offset(11.164395063408941, 10.916519114743828), + Offset(11.853227009710306, 10.407762682849047), + Offset(12.32400850869499, 10.088659111654252), + Offset(12.595052974338124, 9.914815599625843), + Offset(12.68610028619067, 9.857982919050801), + Offset(12.68629150101523, 9.85786437626906), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 36.0), + Offset(41.74444683546158, 36.38547605961641), + Offset(40.779522756565214, 37.69372111300368), + Offset(38.528666977308376, 40.09376498715554), + Offset(33.888986943996294, 43.29147358735062), + Offset(24.750542697847216, 45.53696181075469), + Offset(15.464766435530956, 43.474895505995576), + Offset(8.222494539070887, 37.592709388360404), + Offset(4.758504180127748, 30.580714593150105), + Offset(4.129263053465191, 24.58446277139909), + Offset(4.926943149181838, 20.033621174401183), + Offset(6.282139476517855, 16.74701842430737), + Offset(7.742736159475957, 14.425312891970705), + Offset(9.093991024465053, 12.803953891864928), + Offset(10.244527895379168, 11.682146233885181), + Offset(11.164395063408941, 10.916519114743828), + Offset(11.853227009710306, 10.407762682849047), + Offset(12.32400850869499, 10.088659111654252), + Offset(12.595052974338124, 9.914815599625843), + Offset(12.68610028619067, 9.857982919050801), + Offset(12.68629150101523, 9.85786437626906), + ], + <Offset>[ + Offset(42.0, 32.0), + Offset(41.84490797861258, 32.38673781377975), + Offset(41.231722452189445, 33.71936387751549), + Offset(39.67761535119361, 36.262326923693394), + Offset(36.132752164775056, 39.98005128960247), + Offset(28.323439902629275, 43.73851649213626), + Offset(19.459948260351645, 43.67116620330525), + Offset(11.613683257353195, 39.71399180166486), + Offset(6.948701245094547, 33.927808379797085), + Offset(5.086478206479892, 28.468241749512934), + Offset(4.844123435345551, 24.032763694367194), + Offset(5.389341709109795, 20.646109619254112), + Offset(6.242307863360715, 18.133238852425784), + Offset(7.147199734127774, 16.29823545920705), + Offset(7.975197187086486, 14.976101020520819), + Offset(8.66666218744294, 14.040831916036076), + Offset(9.199071856475648, 13.400330213007376), + Offset(9.569460082206334, 12.989083729900493), + Offset(9.784962845727483, 12.76146160309818), + Offset(9.857711404800284, 12.68644828663571), + Offset(9.857864376269042, 12.686291501015248), + ], + <Offset>[ + Offset(42.0, 32.0), + Offset(41.84490797861258, 32.38673781377975), + Offset(41.231722452189445, 33.71936387751549), + Offset(39.67761535119361, 36.262326923693394), + Offset(36.132752164775056, 39.98005128960247), + Offset(28.323439902629275, 43.73851649213626), + Offset(19.459948260351645, 43.67116620330525), + Offset(11.613683257353195, 39.71399180166486), + Offset(6.948701245094547, 33.927808379797085), + Offset(5.086478206479892, 28.468241749512934), + Offset(4.844123435345551, 24.032763694367194), + Offset(5.389341709109795, 20.646109619254112), + Offset(6.242307863360715, 18.133238852425784), + Offset(7.147199734127774, 16.29823545920705), + Offset(7.975197187086486, 14.976101020520819), + Offset(8.66666218744294, 14.040831916036076), + Offset(9.199071856475648, 13.400330213007376), + Offset(9.569460082206334, 12.989083729900493), + Offset(9.784962845727483, 12.76146160309818), + Offset(9.857711404800284, 12.68644828663571), + Offset(9.857864376269042, 12.686291501015248), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 32.0), + Offset(41.84490797861258, 32.38673781377975), + Offset(41.231722452189445, 33.71936387751549), + Offset(39.67761535119361, 36.262326923693394), + Offset(36.132752164775056, 39.98005128960247), + Offset(28.323439902629275, 43.73851649213626), + Offset(19.459948260351645, 43.67116620330525), + Offset(11.613683257353195, 39.71399180166486), + Offset(6.948701245094547, 33.927808379797085), + Offset(5.086478206479892, 28.468241749512934), + Offset(4.844123435345551, 24.032763694367194), + Offset(5.389341709109795, 20.646109619254112), + Offset(6.242307863360715, 18.133238852425784), + Offset(7.147199734127774, 16.29823545920705), + Offset(7.975197187086486, 14.976101020520819), + Offset(8.66666218744294, 14.040831916036076), + Offset(9.199071856475648, 13.400330213007376), + Offset(9.569460082206334, 12.989083729900493), + Offset(9.784962845727483, 12.76146160309818), + Offset(9.857711404800284, 12.68644828663571), + Offset(9.857864376269042, 12.686291501015248), + ], + <Offset>[ + Offset(6.0, 32.0), + Offset(5.8562637660827015, 31.482587525420783), + Offset(5.462507332795713, 29.649566616897364), + Offset(5.19467278003437, 25.921791558726365), + Offset(6.329951485041715, 19.786164302593612), + Offset(12.137432035063437, 11.58244164909771), + Offset(21.226384536138692, 7.714529779919044), + Offset(30.7052249770933, 9.193293337124057), + Offset(37.072545324917364, 14.216034795095894), + Offset(40.040489009504526, 19.85330537238064), + Offset(40.83640611503963, 24.77814111889375), + Offset(40.48116246363044, 28.68128952592665), + Offset(39.61364150745642, 31.63709351746295), + Offset(38.59573384020686, 33.81935707224257), + Offset(37.620790266807234, 35.40007739515496), + Offset(36.78547739907316, 36.520427799730086), + Offset(36.1321796279006, 37.28772659211929), + Offset(35.67328164642253, 37.78001956829839), + Offset(35.40477687697853, 38.05227276059395), + Offset(35.31389971306446, 38.1419482191492), + Offset(35.31370849898476, 38.14213562373095), + ], + <Offset>[ + Offset(6.0, 32.0), + Offset(5.8562637660827015, 31.482587525420783), + Offset(5.462507332795713, 29.649566616897364), + Offset(5.19467278003437, 25.921791558726365), + Offset(6.329951485041715, 19.786164302593612), + Offset(12.137432035063437, 11.58244164909771), + Offset(21.226384536138692, 7.714529779919044), + Offset(30.7052249770933, 9.193293337124057), + Offset(37.072545324917364, 14.216034795095894), + Offset(40.040489009504526, 19.85330537238064), + Offset(40.83640611503963, 24.77814111889375), + Offset(40.48116246363044, 28.68128952592665), + Offset(39.61364150745642, 31.63709351746295), + Offset(38.59573384020686, 33.81935707224257), + Offset(37.620790266807234, 35.40007739515496), + Offset(36.78547739907316, 36.520427799730086), + Offset(36.1321796279006, 37.28772659211929), + Offset(35.67328164642253, 37.78001956829839), + Offset(35.40477687697853, 38.05227276059395), + Offset(35.31389971306446, 38.1419482191492), + Offset(35.31370849898476, 38.14213562373095), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 32.0), + Offset(5.8562637660827015, 31.482587525420783), + Offset(5.462507332795713, 29.649566616897364), + Offset(5.19467278003437, 25.921791558726365), + Offset(6.329951485041715, 19.786164302593612), + Offset(12.137432035063437, 11.58244164909771), + Offset(21.226384536138692, 7.714529779919044), + Offset(30.7052249770933, 9.193293337124057), + Offset(37.072545324917364, 14.216034795095894), + Offset(40.040489009504526, 19.85330537238064), + Offset(40.83640611503963, 24.77814111889375), + Offset(40.48116246363044, 28.68128952592665), + Offset(39.61364150745642, 31.63709351746295), + Offset(38.59573384020686, 33.81935707224257), + Offset(37.620790266807234, 35.40007739515496), + Offset(36.78547739907316, 36.520427799730086), + Offset(36.1321796279006, 37.28772659211929), + Offset(35.67328164642253, 37.78001956829839), + Offset(35.40477687697853, 38.05227276059395), + Offset(35.31389971306446, 38.1419482191492), + Offset(35.31370849898476, 38.14213562373095), + ], + <Offset>[ + Offset(6.0, 36.0), + Offset(5.755802622931704, 35.48132577125743), + Offset(5.010307637171476, 33.62392385238556), + Offset(4.045724406149144, 29.753229622188503), + Offset(4.0861862642629525, 23.09758660034176), + Offset(8.564534830281378, 13.380886967716135), + Offset(17.231202711318005, 7.518259082609372), + Offset(27.314036258810987, 7.072010923819601), + Offset(34.88234825995056, 10.868941008448914), + Offset(39.083273856489825, 15.969526394266794), + Offset(40.919225828875916, 20.778998598927743), + Offset(41.3739602310385, 24.78219833097991), + Offset(41.11406980357167, 27.929167557007872), + Offset(40.542525130544135, 30.325075504900447), + Offset(39.89012097509991, 32.10612260851932), + Offset(39.28321027503917, 33.39611499843784), + Offset(38.78633478113526, 34.295159061960966), + Offset(38.427830072911185, 34.87959495005215), + Offset(38.21486700558917, 35.20562675712161), + Offset(38.14228859445484, 35.31348285156429), + Offset(38.14213562373095, 35.31370849898477), + ], + <Offset>[ + Offset(6.0, 36.0), + Offset(5.755802622931704, 35.48132577125743), + Offset(5.010307637171476, 33.62392385238556), + Offset(4.045724406149144, 29.753229622188503), + Offset(4.0861862642629525, 23.09758660034176), + Offset(8.564534830281378, 13.380886967716135), + Offset(17.231202711318005, 7.518259082609372), + Offset(27.314036258810987, 7.072010923819601), + Offset(34.88234825995056, 10.868941008448914), + Offset(39.083273856489825, 15.969526394266794), + Offset(40.919225828875916, 20.778998598927743), + Offset(41.3739602310385, 24.78219833097991), + Offset(41.11406980357167, 27.929167557007872), + Offset(40.542525130544135, 30.325075504900447), + Offset(39.89012097509991, 32.10612260851932), + Offset(39.28321027503917, 33.39611499843784), + Offset(38.78633478113526, 34.295159061960966), + Offset(38.427830072911185, 34.87959495005215), + Offset(38.21486700558917, 35.20562675712161), + Offset(38.14228859445484, 35.31348285156429), + Offset(38.14213562373095, 35.31370849898477), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 16.0), + Offset(6.1715484384586965, 15.794477742439435), + Offset(6.7847088111550455, 15.101124417731686), + Offset(8.064809036741185, 13.831258504138926), + Offset(10.327896232258308, 12.039205529321242), + Offset(14.135313755104503, 9.942822494875724), + Offset(17.85576112924513, 8.665143896025008), + Offset(21.423658706813853, 7.951290714362276), + Offset(24.4827045503675, 7.678712896347676), + Offset(26.97423123596107, 7.701340160804744), + Offset(28.998592463240865, 7.903908926143316), + Offset(30.63345298306288, 8.203149386677556), + Offset(31.940891049382852, 8.538653103313674), + Offset(32.972454180204764, 8.86889674774221), + Offset(33.77116819246568, 9.167199716087978), + Offset(34.37258718307626, 9.41785407630459), + Offset(34.80589641289028, 9.612989774645834), + Offset(35.09487909270652, 9.750154330042164), + Offset(35.25887447203158, 9.830559256807962), + Offset(35.31359376965362, 9.857807024169409), + Offset(35.313708498984745, 9.85786437626905), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 16.0), + Offset(6.1715484384586965, 15.794477742439435), + Offset(6.7847088111550455, 15.101124417731686), + Offset(8.064809036741185, 13.831258504138926), + Offset(10.327896232258308, 12.039205529321242), + Offset(14.135313755104503, 9.942822494875724), + Offset(17.85576112924513, 8.665143896025008), + Offset(21.423658706813853, 7.951290714362276), + Offset(24.4827045503675, 7.678712896347676), + Offset(26.97423123596107, 7.701340160804744), + Offset(28.998592463240865, 7.903908926143316), + Offset(30.63345298306288, 8.203149386677556), + Offset(31.940891049382852, 8.538653103313674), + Offset(32.972454180204764, 8.86889674774221), + Offset(33.77116819246568, 9.167199716087978), + Offset(34.37258718307626, 9.41785407630459), + Offset(34.80589641289028, 9.612989774645834), + Offset(35.09487909270652, 9.750154330042164), + Offset(35.25887447203158, 9.830559256807962), + Offset(35.31359376965362, 9.857807024169409), + Offset(35.313708498984745, 9.85786437626905), + ], + <Offset>[ + Offset(42.0, 16.0), + Offset(42.16746021740808, 16.33700442573998), + Offset(42.70156935314198, 17.546350134810247), + Offset(43.51618803296806, 20.092227060671057), + Offset(44.054130843957765, 24.630515982034453), + Offset(42.51578514966233, 32.09115975577829), + Offset(38.14918656898573, 38.40025586692044), + Offset(31.819015610164328, 42.4177462160811), + Offset(25.688490276712407, 43.658513907222414), + Offset(20.913117319647185, 43.18743459360287), + Offset(17.449482120110062, 42.00109429799166), + Offset(15.000094691649505, 40.63150285293139), + Offset(13.281945592346325, 39.325724325359715), + Offset(12.08031575221366, 38.18644357392904), + Offset(11.243480256299911, 37.247500217520134), + Offset(10.667161603617046, 36.51126283372602), + Offset(10.280531326105294, 35.9664803851875), + Offset(10.035976293649936, 35.59684445677665), + Offset(9.901923001772541, 35.38491333072124), + Offset(9.857956160571883, 35.31385765884373), + Offset(9.857864376269035, 35.31370849898477), + ], + <Offset>[ + Offset(42.0, 16.0), + Offset(42.16746021740808, 16.33700442573998), + Offset(42.70156935314198, 17.546350134810247), + Offset(43.51618803296806, 20.092227060671057), + Offset(44.054130843957765, 24.630515982034453), + Offset(42.51578514966233, 32.09115975577829), + Offset(38.14918656898573, 38.40025586692044), + Offset(31.819015610164328, 42.4177462160811), + Offset(25.688490276712407, 43.658513907222414), + Offset(20.913117319647185, 43.18743459360287), + Offset(17.449482120110062, 42.00109429799166), + Offset(15.000094691649505, 40.63150285293139), + Offset(13.281945592346325, 39.325724325359715), + Offset(12.08031575221366, 38.18644357392904), + Offset(11.243480256299911, 37.247500217520134), + Offset(10.667161603617046, 36.51126283372602), + Offset(10.280531326105294, 35.9664803851875), + Offset(10.035976293649936, 35.59684445677665), + Offset(9.901923001772541, 35.38491333072124), + Offset(9.857956160571883, 35.31385765884373), + Offset(9.857864376269035, 35.31370849898477), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 16.0), + Offset(42.16746021740808, 16.33700442573998), + Offset(42.70156935314198, 17.546350134810247), + Offset(43.51618803296806, 20.092227060671057), + Offset(44.054130843957765, 24.630515982034453), + Offset(42.51578514966233, 32.09115975577829), + Offset(38.14918656898573, 38.40025586692044), + Offset(31.819015610164328, 42.4177462160811), + Offset(25.688490276712407, 43.658513907222414), + Offset(20.913117319647185, 43.18743459360287), + Offset(17.449482120110062, 42.00109429799166), + Offset(15.000094691649505, 40.63150285293139), + Offset(13.281945592346325, 39.325724325359715), + Offset(12.08031575221366, 38.18644357392904), + Offset(11.243480256299911, 37.247500217520134), + Offset(10.667161603617046, 36.51126283372602), + Offset(10.280531326105294, 35.9664803851875), + Offset(10.035976293649936, 35.59684445677665), + Offset(9.901923001772541, 35.38491333072124), + Offset(9.857956160571883, 35.31385765884373), + Offset(9.857864376269035, 35.31370849898477), + ], + <Offset>[ + Offset(42.0, 12.0), + Offset(42.227740959997035, 12.33745867252338), + Offset(42.973261099484034, 13.555587852367255), + Offset(44.211851205916076, 16.153184949979185), + Offset(45.453165338703684, 20.883156580734514), + Offset(44.97671151198483, 28.937774045271865), + Offset(41.453087899085226, 36.14543081806037), + Offset(35.64862177702197, 41.26270656015326), + Offset(29.68624594458738, 43.52453771540631), + Offset(24.8560167010692, 43.86089169541553), + Offset(21.238058272537653, 43.28432878056175), + Offset(18.60324507678882, 42.368542663088434), + Offset(16.702731283684777, 41.398940487252666), + Offset(15.33782095512331, 40.50779228815028), + Offset(14.363513645347927, 39.750576654871885), + Offset(13.67754035444165, 39.145199009221486), + Offset(13.208696949498814, 38.691520950385836), + Offset(12.90783075217599, 38.38116699000516), + Offset(12.741295676651793, 38.202352382972244), + Offset(12.68640623109125, 38.14226183763059), + Offset(12.686291501015226, 38.14213562373095), + ], + <Offset>[ + Offset(42.0, 12.0), + Offset(42.227740959997035, 12.33745867252338), + Offset(42.973261099484034, 13.555587852367255), + Offset(44.211851205916076, 16.153184949979185), + Offset(45.453165338703684, 20.883156580734514), + Offset(44.97671151198483, 28.937774045271865), + Offset(41.453087899085226, 36.14543081806037), + Offset(35.64862177702197, 41.26270656015326), + Offset(29.68624594458738, 43.52453771540631), + Offset(24.8560167010692, 43.86089169541553), + Offset(21.238058272537653, 43.28432878056175), + Offset(18.60324507678882, 42.368542663088434), + Offset(16.702731283684777, 41.398940487252666), + Offset(15.33782095512331, 40.50779228815028), + Offset(14.363513645347927, 39.750576654871885), + Offset(13.67754035444165, 39.145199009221486), + Offset(13.208696949498814, 38.691520950385836), + Offset(12.90783075217599, 38.38116699000516), + Offset(12.741295676651793, 38.202352382972244), + Offset(12.68640623109125, 38.14226183763059), + Offset(12.686291501015226, 38.14213562373095), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 12.0), + Offset(42.227740959997035, 12.33745867252338), + Offset(42.973261099484034, 13.555587852367255), + Offset(44.211851205916076, 16.153184949979185), + Offset(45.453165338703684, 20.883156580734514), + Offset(44.97671151198483, 28.937774045271865), + Offset(41.453087899085226, 36.14543081806037), + Offset(35.64862177702197, 41.26270656015326), + Offset(29.68624594458738, 43.52453771540631), + Offset(24.8560167010692, 43.86089169541553), + Offset(21.238058272537653, 43.28432878056175), + Offset(18.60324507678882, 42.368542663088434), + Offset(16.702731283684777, 41.398940487252666), + Offset(15.33782095512331, 40.50779228815028), + Offset(14.363513645347927, 39.750576654871885), + Offset(13.67754035444165, 39.145199009221486), + Offset(13.208696949498814, 38.691520950385836), + Offset(12.90783075217599, 38.38116699000516), + Offset(12.741295676651793, 38.202352382972244), + Offset(12.68640623109125, 38.14226183763059), + Offset(12.686291501015226, 38.14213562373095), + ], + <Offset>[ + Offset(6.0, 12.0), + Offset(6.231829181047647, 11.794931989222837), + Offset(7.056400557497106, 11.110362135288694), + Offset(8.7604722096892, 9.89221639344705), + Offset(11.726930727004222, 8.291846128021302), + Offset(16.596240117427012, 6.7894367843693), + Offset(21.159662459344624, 6.4103188471649375), + Offset(25.253264873671498, 6.796251058434446), + Offset(28.48046021824247, 7.5447367045315765), + Offset(30.917130617383087, 8.374797262617399), + Offset(32.78716861566846, 9.187143408713407), + Offset(34.236603368202196, 9.940189196834599), + Offset(35.3616767407213, 10.611869265206622), + Offset(36.22995938311441, 11.190245461963444), + Offset(36.8912015815137, 11.67027615343973), + Offset(37.38296593390086, 12.051790251800059), + Offset(37.734062036283795, 12.338030339844167), + Offset(37.96673355123257, 12.534476863270674), + Offset(38.09824714691083, 12.647998309058966), + Offset(38.14204384017299, 12.686211202956269), + Offset(38.14213562373094, 12.68629150101524), + ], + <Offset>[ + Offset(6.0, 12.0), + Offset(6.231829181047647, 11.794931989222837), + Offset(7.056400557497106, 11.110362135288694), + Offset(8.7604722096892, 9.89221639344705), + Offset(11.726930727004222, 8.291846128021302), + Offset(16.596240117427012, 6.7894367843693), + Offset(21.159662459344624, 6.4103188471649375), + Offset(25.253264873671498, 6.796251058434446), + Offset(28.48046021824247, 7.5447367045315765), + Offset(30.917130617383087, 8.374797262617399), + Offset(32.78716861566846, 9.187143408713407), + Offset(34.236603368202196, 9.940189196834599), + Offset(35.3616767407213, 10.611869265206622), + Offset(36.22995938311441, 11.190245461963444), + Offset(36.8912015815137, 11.67027615343973), + Offset(37.38296593390086, 12.051790251800059), + Offset(37.734062036283795, 12.338030339844167), + Offset(37.96673355123257, 12.534476863270674), + Offset(38.09824714691083, 12.647998309058966), + Offset(38.14204384017299, 12.686211202956269), + Offset(38.14213562373094, 12.68629150101524), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 12.0), + Offset(6.231829181047647, 11.794931989222837), + Offset(7.056400557497106, 11.110362135288694), + Offset(8.7604722096892, 9.89221639344705), + Offset(11.726930727004222, 8.291846128021302), + Offset(16.596240117427012, 6.7894367843693), + Offset(21.159662459344624, 6.4103188471649375), + Offset(25.253264873671498, 6.796251058434446), + Offset(28.48046021824247, 7.5447367045315765), + Offset(30.917130617383087, 8.374797262617399), + Offset(32.78716861566846, 9.187143408713407), + Offset(34.236603368202196, 9.940189196834599), + Offset(35.3616767407213, 10.611869265206622), + Offset(36.22995938311441, 11.190245461963444), + Offset(36.8912015815137, 11.67027615343973), + Offset(37.38296593390086, 12.051790251800059), + Offset(37.734062036283795, 12.338030339844167), + Offset(37.96673355123257, 12.534476863270674), + Offset(38.09824714691083, 12.647998309058966), + Offset(38.14204384017299, 12.686211202956269), + Offset(38.14213562373094, 12.68629150101524), + ], + <Offset>[ + Offset(6.0, 16.0), + Offset(6.1715484384586965, 15.794477742439435), + Offset(6.7847088111550455, 15.101124417731686), + Offset(8.064809036741185, 13.831258504138926), + Offset(10.327896232258308, 12.039205529321242), + Offset(14.135313755104503, 9.942822494875724), + Offset(17.85576112924513, 8.665143896025008), + Offset(21.423658706813853, 7.951290714362276), + Offset(24.4827045503675, 7.678712896347676), + Offset(26.97423123596107, 7.701340160804744), + Offset(28.998592463240865, 7.903908926143316), + Offset(30.63345298306288, 8.203149386677556), + Offset(31.940891049382852, 8.538653103313674), + Offset(32.972454180204764, 8.86889674774221), + Offset(33.77116819246568, 9.167199716087978), + Offset(34.37258718307626, 9.41785407630459), + Offset(34.80589641289028, 9.612989774645834), + Offset(35.09487909270652, 9.750154330042164), + Offset(35.25887447203158, 9.830559256807962), + Offset(35.31359376965362, 9.857807024169409), + Offset(35.313708498984745, 9.85786437626905), + ], + <Offset>[ + Offset(6.0, 16.0), + Offset(6.1715484384586965, 15.794477742439435), + Offset(6.7847088111550455, 15.101124417731686), + Offset(8.064809036741185, 13.831258504138926), + Offset(10.327896232258308, 12.039205529321242), + Offset(14.135313755104503, 9.942822494875724), + Offset(17.85576112924513, 8.665143896025008), + Offset(21.423658706813853, 7.951290714362276), + Offset(24.4827045503675, 7.678712896347676), + Offset(26.97423123596107, 7.701340160804744), + Offset(28.998592463240865, 7.903908926143316), + Offset(30.63345298306288, 8.203149386677556), + Offset(31.940891049382852, 8.538653103313674), + Offset(32.972454180204764, 8.86889674774221), + Offset(33.77116819246568, 9.167199716087978), + Offset(34.37258718307626, 9.41785407630459), + Offset(34.80589641289028, 9.612989774645834), + Offset(35.09487909270652, 9.750154330042164), + Offset(35.25887447203158, 9.830559256807962), + Offset(35.31359376965362, 9.857807024169409), + Offset(35.313708498984745, 9.85786437626905), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/menu_home.g.dart b/packages/material_ui/lib/src/animated_icons/data/menu_home.g.dart new file mode 100644 index 000000000000..889fae0ecba8 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/menu_home.g.dart @@ -0,0 +1,1685 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$menu_home = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.853658536585, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 12.046875), + Offset(6.1618805351617105, 11.806612807600084), + Offset(6.781939829118144, 10.945667339250278), + Offset(8.288913455339518, 9.166289848732603), + Offset(11.935042610511557, 6.074766376544677), + Offset(23.256788788206386, 2.4054443351966768), + Offset(36.24002084790047, 6.193846936842682), + Offset(43.02277578355333, 13.752502805353782), + Offset(45.18366429152893, 19.742117374864932), + Offset(45.60696992064962, 24.126681150009468), + Offset(45.345679612709475, 27.35248551632729), + Offset(44.82758462670622, 29.752296574674826), + Offset(44.24395862249675, 31.553763072552943), + Offset(43.684466559586255, 32.91061016598502), + Offset(43.191063930200144, 33.928759363213324), + Offset(42.78156545517243, 34.68316415294171), + Offset(42.461367454866235, 35.22742664024267), + Offset(42.229341109216534, 35.60035861469174), + Offset(42.08077400509552, 35.830587840098524), + Offset(42.00921658628339, 35.93923428088923), + Offset(42.0, 35.953125), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 12.046875), + Offset(6.1618805351617105, 11.806612807600084), + Offset(6.781939829118144, 10.945667339250278), + Offset(8.288913455339518, 9.166289848732603), + Offset(11.935042610511557, 6.074766376544677), + Offset(23.256788788206386, 2.4054443351966768), + Offset(36.24002084790047, 6.193846936842682), + Offset(43.02277578355333, 13.752502805353782), + Offset(45.18366429152893, 19.742117374864932), + Offset(45.60696992064962, 24.126681150009468), + Offset(45.345679612709475, 27.35248551632729), + Offset(44.82758462670622, 29.752296574674826), + Offset(44.24395862249675, 31.553763072552943), + Offset(43.684466559586255, 32.91061016598502), + Offset(43.191063930200144, 33.928759363213324), + Offset(42.78156545517243, 34.68316415294171), + Offset(42.461367454866235, 35.22742664024267), + Offset(42.229341109216534, 35.60035861469174), + Offset(42.08077400509552, 35.830587840098524), + Offset(42.00921658628339, 35.93923428088923), + Offset(42.0, 35.953125), + ], + <Offset>[ + Offset(6.0, 16.0), + Offset(6.108878658535886, 15.759382477932412), + Offset(6.534966570751962, 14.8910699138017), + Offset(7.618227825115078, 13.06210530265691), + Offset(10.424159957469506, 9.727769370570497), + Offset(20.040805554226083, 4.704280802712924), + Offset(32.287403394427514, 6.130502385964199), + Offset(39.5356887387035, 11.890401517221083), + Offset(42.39074647053927, 16.944474204118634), + Offset(43.43945637458355, 20.820764893773518), + Offset(43.696252373988955, 23.759911640985017), + Offset(43.59634590360605, 25.995802401597533), + Offset(43.34634769806003, 27.703893997641842), + Offset(43.050276896752194, 29.008687468496074), + Offset(42.76198609923806, 29.998989715943665), + Offset(42.5089105957515, 30.739453134971555), + Offset(42.30406998921387, 31.277432365193103), + Offset(42.152364969347445, 31.64798313410847), + Offset(42.05392318847743, 31.87755403057046), + Offset(42.00616671483272, 31.986110457391014), + Offset(42.0, 31.999999999999996), + ], + <Offset>[ + Offset(6.0, 16.0), + Offset(6.108878658535886, 15.759382477932412), + Offset(6.534966570751962, 14.8910699138017), + Offset(7.618227825115078, 13.06210530265691), + Offset(10.424159957469506, 9.727769370570497), + Offset(20.040805554226083, 4.704280802712924), + Offset(32.287403394427514, 6.130502385964199), + Offset(39.5356887387035, 11.890401517221083), + Offset(42.39074647053927, 16.944474204118634), + Offset(43.43945637458355, 20.820764893773518), + Offset(43.696252373988955, 23.759911640985017), + Offset(43.59634590360605, 25.995802401597533), + Offset(43.34634769806003, 27.703893997641842), + Offset(43.050276896752194, 29.008687468496074), + Offset(42.76198609923806, 29.998989715943665), + Offset(42.5089105957515, 30.739453134971555), + Offset(42.30406998921387, 31.277432365193103), + Offset(42.152364969347445, 31.64798313410847), + Offset(42.05392318847743, 31.87755403057046), + Offset(42.00616671483272, 31.986110457391014), + Offset(42.0, 31.999999999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 16.0), + Offset(6.108878658535886, 15.759382477932412), + Offset(6.534966570751962, 14.8910699138017), + Offset(7.618227825115078, 13.06210530265691), + Offset(10.424159957469506, 9.727769370570497), + Offset(20.040805554226083, 4.704280802712924), + Offset(32.287403394427514, 6.130502385964199), + Offset(39.5356887387035, 11.890401517221083), + Offset(42.39074647053927, 16.944474204118634), + Offset(43.43945637458355, 20.820764893773518), + Offset(43.696252373988955, 23.759911640985017), + Offset(43.59634590360605, 25.995802401597533), + Offset(43.34634769806003, 27.703893997641842), + Offset(43.050276896752194, 29.008687468496074), + Offset(42.76198609923806, 29.998989715943665), + Offset(42.5089105957515, 30.739453134971555), + Offset(42.30406998921387, 31.277432365193103), + Offset(42.152364969347445, 31.64798313410847), + Offset(42.05392318847743, 31.87755403057046), + Offset(42.00616671483272, 31.986110457391014), + Offset(42.0, 31.999999999999996), + ], + <Offset>[ + Offset(42.0, 16.0), + Offset(42.10564277096942, 16.24205569431935), + Offset(42.46464060935464, 17.140186069041555), + Offset(43.096325871919824, 19.169851120985427), + Offset(43.691033073024805, 23.4869536891827), + Offset(40.975664135876016, 33.99133760544266), + Offset(31.710542346111218, 42.12588030217638), + Offset(22.578054873653144, 43.64632464502351), + Offset(16.913395223901084, 42.37879301660944), + Offset(13.333404776292529, 40.559702483244706), + Offset(10.979690283915431, 38.78078262126988), + Offset(9.387007663408092, 37.208347927379336), + Offset(8.286670430885419, 35.8781847877691), + Offset(7.516561896694743, 34.78407475375168), + Offset(6.974676742284352, 33.906481109369096), + Offset(6.594641088228872, 33.222444423927385), + Offset(6.332580622754584, 32.709896242122134), + Offset(6.159190632336145, 32.348983237896554), + Offset(6.054753634514366, 32.12207688230202), + Offset(6.00617742890433, 32.01388478079939), + Offset(6.0, 32.0), + ], + <Offset>[ + Offset(42.0, 16.0), + Offset(42.10564277096942, 16.24205569431935), + Offset(42.46464060935464, 17.140186069041555), + Offset(43.096325871919824, 19.169851120985427), + Offset(43.691033073024805, 23.4869536891827), + Offset(40.975664135876016, 33.99133760544266), + Offset(31.710542346111218, 42.12588030217638), + Offset(22.578054873653144, 43.64632464502351), + Offset(16.913395223901084, 42.37879301660944), + Offset(13.333404776292529, 40.559702483244706), + Offset(10.979690283915431, 38.78078262126988), + Offset(9.387007663408092, 37.208347927379336), + Offset(8.286670430885419, 35.8781847877691), + Offset(7.516561896694743, 34.78407475375168), + Offset(6.974676742284352, 33.906481109369096), + Offset(6.594641088228872, 33.222444423927385), + Offset(6.332580622754584, 32.709896242122134), + Offset(6.159190632336145, 32.348983237896554), + Offset(6.054753634514366, 32.12207688230202), + Offset(6.00617742890433, 32.01388478079939), + Offset(6.0, 32.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 16.0), + Offset(42.10564277096942, 16.24205569431935), + Offset(42.46464060935464, 17.140186069041555), + Offset(43.096325871919824, 19.169851120985427), + Offset(43.691033073024805, 23.4869536891827), + Offset(40.975664135876016, 33.99133760544266), + Offset(31.710542346111218, 42.12588030217638), + Offset(22.578054873653144, 43.64632464502351), + Offset(16.913395223901084, 42.37879301660944), + Offset(13.333404776292529, 40.559702483244706), + Offset(10.979690283915431, 38.78078262126988), + Offset(9.387007663408092, 37.208347927379336), + Offset(8.286670430885419, 35.8781847877691), + Offset(7.516561896694743, 34.78407475375168), + Offset(6.974676742284352, 33.906481109369096), + Offset(6.594641088228872, 33.222444423927385), + Offset(6.332580622754584, 32.709896242122134), + Offset(6.159190632336145, 32.348983237896554), + Offset(6.054753634514366, 32.12207688230202), + Offset(6.00617742890433, 32.01388478079939), + Offset(6.0, 32.0), + ], + <Offset>[ + Offset(42.0, 12.0546875), + Offset(42.15853990080349, 12.297097821754479), + Offset(42.711125778277406, 13.202580732779758), + Offset(43.765686036471884, 15.281734907088639), + Offset(45.19892979196993, 19.841170068662862), + Offset(44.185291671765455, 31.697044293000552), + Offset(35.65534830264055, 42.18909966619542), + Offset(26.058250442129758, 45.50474589108481), + Offset(19.700793444454007, 45.170907248283115), + Offset(15.496634698749768, 43.85908530814422), + Offset(12.625857785010018, 42.366256548241914), + Offset(10.615813108399369, 40.957418198928806), + Offset(9.18250742068886, 39.72044542577326), + Offset(8.14949822027419, 38.6782861415618), + Offset(7.402906593343346, 37.82848441346233), + Offset(6.866757104054109, 37.15836154660511), + Offset(6.489567223850322, 36.65208420437516), + Offset(6.236014645446598, 36.2935476997435), + Offset(6.081551386277482, 36.0672983720484), + Offset(6.009221272941055, 35.9591961066227), + Offset(6.0, 35.9453125), + ], + <Offset>[ + Offset(42.0, 12.0546875), + Offset(42.15853990080349, 12.297097821754479), + Offset(42.711125778277406, 13.202580732779758), + Offset(43.765686036471884, 15.281734907088639), + Offset(45.19892979196993, 19.841170068662862), + Offset(44.185291671765455, 31.697044293000552), + Offset(35.65534830264055, 42.18909966619542), + Offset(26.058250442129758, 45.50474589108481), + Offset(19.700793444454007, 45.170907248283115), + Offset(15.496634698749768, 43.85908530814422), + Offset(12.625857785010018, 42.366256548241914), + Offset(10.615813108399369, 40.957418198928806), + Offset(9.18250742068886, 39.72044542577326), + Offset(8.14949822027419, 38.6782861415618), + Offset(7.402906593343346, 37.82848441346233), + Offset(6.866757104054109, 37.15836154660511), + Offset(6.489567223850322, 36.65208420437516), + Offset(6.236014645446598, 36.2935476997435), + Offset(6.081551386277482, 36.0672983720484), + Offset(6.009221272941055, 35.9591961066227), + Offset(6.0, 35.9453125), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 12.0546875), + Offset(42.15853990080349, 12.297097821754479), + Offset(42.711125778277406, 13.202580732779758), + Offset(43.765686036471884, 15.281734907088639), + Offset(45.19892979196993, 19.841170068662862), + Offset(44.185291671765455, 31.697044293000552), + Offset(35.65534830264055, 42.18909966619542), + Offset(26.058250442129758, 45.50474589108481), + Offset(19.700793444454007, 45.170907248283115), + Offset(15.496634698749768, 43.85908530814422), + Offset(12.625857785010018, 42.366256548241914), + Offset(10.615813108399369, 40.957418198928806), + Offset(9.18250742068886, 39.72044542577326), + Offset(8.14949822027419, 38.6782861415618), + Offset(7.402906593343346, 37.82848441346233), + Offset(6.866757104054109, 37.15836154660511), + Offset(6.489567223850322, 36.65208420437516), + Offset(6.236014645446598, 36.2935476997435), + Offset(6.081551386277482, 36.0672983720484), + Offset(6.009221272941055, 35.9591961066227), + Offset(6.0, 35.9453125), + ], + <Offset>[ + Offset(6.0, 12.046875), + Offset(6.1618805351617105, 11.806612807600084), + Offset(6.781939829118144, 10.945667339250278), + Offset(8.288913455339518, 9.166289848732603), + Offset(11.935042610511557, 6.074766376544677), + Offset(23.256788788206386, 2.4054443351966768), + Offset(36.24002084790047, 6.193846936842682), + Offset(43.02277578355333, 13.752502805353782), + Offset(45.18366429152893, 19.742117374864932), + Offset(45.60696992064962, 24.126681150009468), + Offset(45.345679612709475, 27.35248551632729), + Offset(44.82758462670622, 29.752296574674826), + Offset(44.24395862249675, 31.553763072552943), + Offset(43.684466559586255, 32.91061016598502), + Offset(43.191063930200144, 33.928759363213324), + Offset(42.78156545517243, 34.68316415294171), + Offset(42.461367454866235, 35.22742664024267), + Offset(42.229341109216534, 35.60035861469174), + Offset(42.08077400509552, 35.830587840098524), + Offset(42.00921658628339, 35.93923428088923), + Offset(42.0, 35.953125), + ], + <Offset>[ + Offset(6.0, 12.046875), + Offset(6.1618805351617105, 11.806612807600084), + Offset(6.781939829118144, 10.945667339250278), + Offset(8.288913455339518, 9.166289848732603), + Offset(11.935042610511557, 6.074766376544677), + Offset(23.256788788206386, 2.4054443351966768), + Offset(36.24002084790047, 6.193846936842682), + Offset(43.02277578355333, 13.752502805353782), + Offset(45.18366429152893, 19.742117374864932), + Offset(45.60696992064962, 24.126681150009468), + Offset(45.345679612709475, 27.35248551632729), + Offset(44.82758462670622, 29.752296574674826), + Offset(44.24395862249675, 31.553763072552943), + Offset(43.684466559586255, 32.91061016598502), + Offset(43.191063930200144, 33.928759363213324), + Offset(42.78156545517243, 34.68316415294171), + Offset(42.461367454866235, 35.22742664024267), + Offset(42.229341109216534, 35.60035861469174), + Offset(42.08077400509552, 35.830587840098524), + Offset(42.00921658628339, 35.93923428088923), + Offset(42.0, 35.953125), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 26.0), + Offset(5.97480276509507, 25.758483620275058), + Offset(5.9102120831853355, 24.871534924524667), + Offset(5.921631764468266, 22.91713253788045), + Offset(6.602164313410562, 18.968567458224747), + Offset(11.905511997912269, 10.519519297615686), + Offset(22.288325077287325, 5.992865842523692), + Offset(30.404134439557254, 7.761342935335598), + Offset(33.61929906828942, 11.570908914742331), + Offset(35.25300243452185, 14.230458757139374), + Offset(36.21471762947331, 16.191242561808075), + Offset(36.803975496328306, 17.698645771806415), + Offset(37.18416968837589, 18.87242179985836), + Offset(37.445620479790264, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119959, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 26.0), + Offset(5.97480276509507, 25.758483620275058), + Offset(5.9102120831853355, 24.871534924524667), + Offset(5.921631764468266, 22.91713253788045), + Offset(6.602164313410562, 18.968567458224747), + Offset(11.905511997912269, 10.519519297615686), + Offset(22.288325077287325, 5.992865842523692), + Offset(30.404134439557254, 7.761342935335598), + Offset(33.61929906828942, 11.570908914742331), + Offset(35.25300243452185, 14.230458757139374), + Offset(36.21471762947331, 16.191242561808075), + Offset(36.803975496328306, 17.698645771806415), + Offset(37.18416968837589, 18.87242179985836), + Offset(37.445620479790264, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119959, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ], + <Offset>[ + Offset(42.0, 26.0), + Offset(41.9715668775286, 26.241156836662), + Offset(41.83988612178801, 27.12065107976452), + Offset(41.39972981127301, 29.024878356208966), + Offset(39.86903742896586, 32.72775177683695), + Offset(32.840370579562205, 39.80657610034542), + Offset(21.712188487576384, 41.94303848554115), + Offset(14.06742965730648, 38.354475524191464), + Offset(11.554665508171254, 33.598274280005), + Offset(10.55378227775913, 30.424424398606067), + Offset(10.116296706050587, 28.1735838726282), + Offset(9.950186111823127, 26.50031664598051), + Offset(9.907575779387743, 25.232058503788906), + Offset(9.912669821946713, 24.26340582721259), + Offset(9.927760918072412, 23.52461229394332), + Offset(9.95774683780533, 22.966059779854906), + Offset(9.993956644475434, 22.556166293104994), + Offset(10.026198124175803, 22.27177334090344), + Offset(10.04923690661602, 22.094713837168957), + Offset(10.060961130015452, 22.01075350168465), + Offset(10.0625, 22.0), + ], + <Offset>[ + Offset(42.0, 26.0), + Offset(41.9715668775286, 26.241156836662), + Offset(41.83988612178801, 27.12065107976452), + Offset(41.39972981127301, 29.024878356208966), + Offset(39.86903742896586, 32.72775177683695), + Offset(32.840370579562205, 39.80657610034542), + Offset(21.712188487576384, 41.94303848554115), + Offset(14.06742965730648, 38.354475524191464), + Offset(11.554665508171254, 33.598274280005), + Offset(10.55378227775913, 30.424424398606067), + Offset(10.116296706050587, 28.1735838726282), + Offset(9.950186111823127, 26.50031664598051), + Offset(9.907575779387743, 25.232058503788906), + Offset(9.912669821946713, 24.26340582721259), + Offset(9.927760918072412, 23.52461229394332), + Offset(9.95774683780533, 22.966059779854906), + Offset(9.993956644475434, 22.556166293104994), + Offset(10.026198124175803, 22.27177334090344), + Offset(10.04923690661602, 22.094713837168957), + Offset(10.060961130015452, 22.01075350168465), + Offset(10.0625, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 26.0), + Offset(41.9715668775286, 26.241156836662), + Offset(41.83988612178801, 27.12065107976452), + Offset(41.39972981127301, 29.024878356208966), + Offset(39.86903742896586, 32.72775177683695), + Offset(32.840370579562205, 39.80657610034542), + Offset(21.712188487576384, 41.94303848554115), + Offset(14.06742965730648, 38.354475524191464), + Offset(11.554665508171254, 33.598274280005), + Offset(10.55378227775913, 30.424424398606067), + Offset(10.116296706050587, 28.1735838726282), + Offset(9.950186111823127, 26.50031664598051), + Offset(9.907575779387743, 25.232058503788906), + Offset(9.912669821946713, 24.26340582721259), + Offset(9.927760918072412, 23.52461229394332), + Offset(9.95774683780533, 22.966059779854906), + Offset(9.993956644475434, 22.556166293104994), + Offset(10.026198124175803, 22.27177334090344), + Offset(10.04923690661602, 22.094713837168957), + Offset(10.060961130015452, 22.01075350168465), + Offset(10.0625, 22.0), + ], + <Offset>[ + Offset(42.0, 22.0), + Offset(42.02519723490493, 22.241516379724942), + Offset(42.089787916814664, 23.128465075475333), + Offset(42.078368235531734, 25.08286746211955), + Offset(41.39783568658944, 29.031432541775253), + Offset(36.09448800208773, 37.48048070238431), + Offset(25.716890915777384, 42.00721774895393), + Offset(17.730033699513, 40.31030272815691), + Offset(14.773810944920879, 36.822866202860226), + Offset(13.156032790251274, 34.39340640919596), + Offset(12.13588194777422, 32.57238911127051), + Offset(11.474202347993174, 31.15007140388232), + Offset(11.025213475416908, 30.025626279936134), + Offset(10.704423952068467, 29.134761499184584), + Offset(10.46368421682347, 28.432941181442178), + Offset(10.298295990283647, 27.89180160467138), + Offset(10.190423044262575, 27.48975598051868), + Offset(10.12234223563285, 27.208337182264366), + Offset(10.082773894961147, 27.032099939188196), + Offset(10.064770455621808, 26.94825203221997), + Offset(10.0625, 26.9375), + ], + <Offset>[ + Offset(42.0, 22.0), + Offset(42.02519723490493, 22.241516379724942), + Offset(42.089787916814664, 23.128465075475333), + Offset(42.078368235531734, 25.08286746211955), + Offset(41.39783568658944, 29.031432541775253), + Offset(36.09448800208773, 37.48048070238431), + Offset(25.716890915777384, 42.00721774895393), + Offset(17.730033699513, 40.31030272815691), + Offset(14.773810944920879, 36.822866202860226), + Offset(13.156032790251274, 34.39340640919596), + Offset(12.13588194777422, 32.57238911127051), + Offset(11.474202347993174, 31.15007140388232), + Offset(11.025213475416908, 30.025626279936134), + Offset(10.704423952068467, 29.134761499184584), + Offset(10.46368421682347, 28.432941181442178), + Offset(10.298295990283647, 27.89180160467138), + Offset(10.190423044262575, 27.48975598051868), + Offset(10.12234223563285, 27.208337182264366), + Offset(10.082773894961147, 27.032099939188196), + Offset(10.064770455621808, 26.94825203221997), + Offset(10.0625, 26.9375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 22.0), + Offset(42.02519723490493, 22.241516379724942), + Offset(42.089787916814664, 23.128465075475333), + Offset(42.078368235531734, 25.08286746211955), + Offset(41.39783568658944, 29.031432541775253), + Offset(36.09448800208773, 37.48048070238431), + Offset(25.716890915777384, 42.00721774895393), + Offset(17.730033699513, 40.31030272815691), + Offset(14.773810944920879, 36.822866202860226), + Offset(13.156032790251274, 34.39340640919596), + Offset(12.13588194777422, 32.57238911127051), + Offset(11.474202347993174, 31.15007140388232), + Offset(11.025213475416908, 30.025626279936134), + Offset(10.704423952068467, 29.134761499184584), + Offset(10.46368421682347, 28.432941181442178), + Offset(10.298295990283647, 27.89180160467138), + Offset(10.190423044262575, 27.48975598051868), + Offset(10.12234223563285, 27.208337182264366), + Offset(10.082773894961147, 27.032099939188196), + Offset(10.064770455621808, 26.94825203221997), + Offset(10.0625, 26.9375), + ], + <Offset>[ + Offset(6.0, 22.0), + Offset(6.028433122471398, 21.758843163338), + Offset(6.1601138782119875, 20.87934892023548), + Offset(6.600270188726988, 18.975121643791034), + Offset(8.13096257103414, 15.272248223163048), + Offset(15.159629420437794, 8.193423899654581), + Offset(26.293027505488325, 6.057045105936464), + Offset(34.06673848176378, 9.717170139301047), + Offset(36.83844450503905, 14.79550083759756), + Offset(37.855252947013994, 18.199440767729264), + Offset(38.234302871196945, 20.590047800450385), + Offset(38.327991732498354, 22.348400529708222), + Offset(38.30180738440505, 23.665989576005586), + Offset(38.23737460991202, 24.659759289181423), + Offset(38.17399667029804, 25.40734888722735), + Offset(38.106914393677904, 25.96920765523683), + Offset(38.04334710231959, 26.380591242479884), + Offset(37.992057086860356, 26.66554890745623), + Offset(37.95713087598116, 26.842764536632167), + Offset(37.93976215965664, 26.92674621930307), + Offset(37.9375, 26.9375), + ], + <Offset>[ + Offset(6.0, 22.0), + Offset(6.028433122471398, 21.758843163338), + Offset(6.1601138782119875, 20.87934892023548), + Offset(6.600270188726988, 18.975121643791034), + Offset(8.13096257103414, 15.272248223163048), + Offset(15.159629420437794, 8.193423899654581), + Offset(26.293027505488325, 6.057045105936464), + Offset(34.06673848176378, 9.717170139301047), + Offset(36.83844450503905, 14.79550083759756), + Offset(37.855252947013994, 18.199440767729264), + Offset(38.234302871196945, 20.590047800450385), + Offset(38.327991732498354, 22.348400529708222), + Offset(38.30180738440505, 23.665989576005586), + Offset(38.23737460991202, 24.659759289181423), + Offset(38.17399667029804, 25.40734888722735), + Offset(38.106914393677904, 25.96920765523683), + Offset(38.04334710231959, 26.380591242479884), + Offset(37.992057086860356, 26.66554890745623), + Offset(37.95713087598116, 26.842764536632167), + Offset(37.93976215965664, 26.92674621930307), + Offset(37.9375, 26.9375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 22.0), + Offset(6.028433122471398, 21.758843163338), + Offset(6.1601138782119875, 20.87934892023548), + Offset(6.600270188726988, 18.975121643791034), + Offset(8.13096257103414, 15.272248223163048), + Offset(15.159629420437794, 8.193423899654581), + Offset(26.293027505488325, 6.057045105936464), + Offset(34.06673848176378, 9.717170139301047), + Offset(36.83844450503905, 14.79550083759756), + Offset(37.855252947013994, 18.199440767729264), + Offset(38.234302871196945, 20.590047800450385), + Offset(38.327991732498354, 22.348400529708222), + Offset(38.30180738440505, 23.665989576005586), + Offset(38.23737460991202, 24.659759289181423), + Offset(38.17399667029804, 25.40734888722735), + Offset(38.106914393677904, 25.96920765523683), + Offset(38.04334710231959, 26.380591242479884), + Offset(37.992057086860356, 26.66554890745623), + Offset(37.95713087598116, 26.842764536632167), + Offset(37.93976215965664, 26.92674621930307), + Offset(37.9375, 26.9375), + ], + <Offset>[ + Offset(6.0, 26.0), + Offset(5.97480276509507, 25.758483620275058), + Offset(5.9102120831853355, 24.871534924524667), + Offset(5.921631764468266, 22.91713253788045), + Offset(6.602164313410562, 18.968567458224747), + Offset(11.905511997912269, 10.519519297615686), + Offset(22.288325077287325, 5.992865842523692), + Offset(30.404134439557254, 7.761342935335598), + Offset(33.61929906828942, 11.570908914742331), + Offset(35.25300243452185, 14.230458757139374), + Offset(36.21471762947331, 16.191242561808075), + Offset(36.803975496328306, 17.698645771806415), + Offset(37.18416968837589, 18.87242179985836), + Offset(37.445620479790264, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119959, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ], + <Offset>[ + Offset(6.0, 26.0), + Offset(5.97480276509507, 25.758483620275058), + Offset(5.9102120831853355, 24.871534924524667), + Offset(5.921631764468266, 22.91713253788045), + Offset(6.602164313410562, 18.968567458224747), + Offset(11.905511997912269, 10.519519297615686), + Offset(22.288325077287325, 5.992865842523692), + Offset(30.404134439557254, 7.761342935335598), + Offset(33.61929906828942, 11.570908914742331), + Offset(35.25300243452185, 14.230458757139374), + Offset(36.21471762947331, 16.191242561808075), + Offset(36.803975496328306, 17.698645771806415), + Offset(37.18416968837589, 18.87242179985836), + Offset(37.445620479790264, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119959, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(24.946426391602, 26.0), + Offset(24.919526142652916, 26.01250952487153), + Offset(24.81962665114753, 26.055221415675078), + Offset(24.593386574407845, 26.131575775813122), + Offset(24.112549450730533, 26.21086569759901), + Offset(23.26102990202604, 26.40544929256212), + Offset(21.93030078929294, 28.333115001580325), + Offset(20.21574669997896, 26.840753449152132), + Offset(19.702668556230993, 25.46403358899737), + Offset(19.57851450097965, 24.507387236351565), + Offset(19.59742184955703, 23.820597237404847), + Offset(19.672638738964572, 23.313659191847616), + Offset(19.76421128572404, 22.933948220939197), + Offset(19.85318172952868, 22.647748525973807), + Offset(19.930810929304265, 22.432414178767115), + Offset(19.99628397445662, 22.272029228601628), + Offset(20.048487526419784, 22.155772744171706), + Offset(20.086790256569586, 22.07583407578212), + Offset(20.111504785414724, 22.026366303959268), + Offset(20.12345813528363, 22.002990192537517), + Offset(20.125, 22.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(24.946426391602, 26.0), + Offset(24.919526142652916, 26.01250952487153), + Offset(24.81962665114753, 26.055221415675078), + Offset(24.593386574407845, 26.131575775813122), + Offset(24.112549450730533, 26.21086569759901), + Offset(23.26102990202604, 26.40544929256212), + Offset(21.93030078929294, 28.333115001580325), + Offset(20.21574669997896, 26.840753449152132), + Offset(19.702668556230993, 25.46403358899737), + Offset(19.57851450097965, 24.507387236351565), + Offset(19.59742184955703, 23.820597237404847), + Offset(19.672638738964572, 23.313659191847616), + Offset(19.76421128572404, 22.933948220939197), + Offset(19.85318172952868, 22.647748525973807), + Offset(19.930810929304265, 22.432414178767115), + Offset(19.99628397445662, 22.272029228601628), + Offset(20.048487526419784, 22.155772744171706), + Offset(20.086790256569586, 22.07583407578212), + Offset(20.111504785414724, 22.026366303959268), + Offset(20.12345813528363, 22.002990192537517), + Offset(20.125, 22.0), + ], + <Offset>[ + Offset(42.000000000002004, 26.0), + Offset(41.971566877530606, 26.241156836662025), + Offset(41.83988612179001, 27.120651079764645), + Offset(41.39972981127498, 29.024878356209307), + Offset(39.86903742895662, 32.72775177683313), + Offset(32.84037057959128, 39.806576100386096), + Offset(21.712188487577507, 41.94303848547116), + Offset(14.067429657339456, 38.35447552412971), + Offset(11.554665508114638, 33.59827428006152), + Offset(10.553782277809306, 30.424424398573166), + Offset(10.11629670607785, 28.173583872615687), + Offset(9.950186111889643, 26.50031664595871), + Offset(9.907575779455914, 25.23205850377301), + Offset(9.912669821848008, 24.263405827228635), + Offset(9.927760918072412, 23.52461229394332), + Offset(9.95774683780533, 22.966059779854906), + Offset(9.993956644475434, 22.556166293104994), + Offset(10.026198124175803, 22.27177334090344), + Offset(10.04923690661602, 22.094713837168957), + Offset(10.060961130015452, 22.01075350168465), + Offset(10.0625, 22.0), + ], + <Offset>[ + Offset(42.000000000002004, 26.0), + Offset(41.971566877530606, 26.241156836662025), + Offset(41.83988612179001, 27.120651079764645), + Offset(41.39972981127498, 29.024878356209307), + Offset(39.86903742895662, 32.72775177683313), + Offset(32.84037057959128, 39.806576100386096), + Offset(21.712188487577507, 41.94303848547116), + Offset(14.067429657339456, 38.35447552412971), + Offset(11.554665508114638, 33.59827428006152), + Offset(10.553782277809306, 30.424424398573166), + Offset(10.11629670607785, 28.173583872615687), + Offset(9.950186111889643, 26.50031664595871), + Offset(9.907575779455914, 25.23205850377301), + Offset(9.912669821848008, 24.263405827228635), + Offset(9.927760918072412, 23.52461229394332), + Offset(9.95774683780533, 22.966059779854906), + Offset(9.993956644475434, 22.556166293104994), + Offset(10.026198124175803, 22.27177334090344), + Offset(10.04923690661602, 22.094713837168957), + Offset(10.060961130015452, 22.01075350168465), + Offset(10.0625, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.000000000002004, 26.0), + Offset(41.971566877530606, 26.241156836662025), + Offset(41.83988612179001, 27.120651079764645), + Offset(41.39972981127498, 29.024878356209307), + Offset(39.86903742895662, 32.72775177683313), + Offset(32.84037057959128, 39.806576100386096), + Offset(21.712188487577507, 41.94303848547116), + Offset(14.067429657339456, 38.35447552412971), + Offset(11.554665508114638, 33.59827428006152), + Offset(10.553782277809306, 30.424424398573166), + Offset(10.11629670607785, 28.173583872615687), + Offset(9.950186111889643, 26.50031664595871), + Offset(9.907575779455914, 25.23205850377301), + Offset(9.912669821848008, 24.263405827228635), + Offset(9.927760918072412, 23.52461229394332), + Offset(9.95774683780533, 22.966059779854906), + Offset(9.993956644475434, 22.556166293104994), + Offset(10.026198124175803, 22.27177334090344), + Offset(10.04923690661602, 22.094713837168957), + Offset(10.060961130015452, 22.01075350168465), + Offset(10.0625, 22.0), + ], + <Offset>[ + Offset(42.000000000002004, 22.0), + Offset(42.025197234906926, 22.241516379724967), + Offset(42.08978791681666, 23.128465075475457), + Offset(42.0783682355337, 25.08286746211989), + Offset(41.397835686580194, 29.03143254177143), + Offset(36.094488002116805, 37.48048070242499), + Offset(25.789219352940048, 42.00837688265331), + Offset(19.59049856191341, 41.30378926060276), + Offset(20.224936460436354, 42.28321450135585), + Offset(18.827987907130577, 43.04433671564179), + Offset(16.997529418949235, 43.161414977908365), + Offset(15.331601790632021, 42.918949572968955), + Offset(13.928660013420258, 42.478559653106245), + Offset(12.785040072364048, 41.93598444668365), + Offset(11.87472277556043, 41.35613622700879), + Offset(11.194931733517697, 40.86084337127677), + Offset(10.707701666486946, 40.479460473962305), + Offset(10.375481162000769, 40.20587235951845), + Offset(10.171073813641986, 40.03180005589708), + Offset(10.074800072408165, 39.948248163249666), + Offset(10.062500000000002, 39.9375), + ], + <Offset>[ + Offset(42.000000000002004, 22.0), + Offset(42.025197234906926, 22.241516379724967), + Offset(42.08978791681666, 23.128465075475457), + Offset(42.0783682355337, 25.08286746211989), + Offset(41.397835686580194, 29.03143254177143), + Offset(36.094488002116805, 37.48048070242499), + Offset(25.789219352940048, 42.00837688265331), + Offset(19.59049856191341, 41.30378926060276), + Offset(20.224936460436354, 42.28321450135585), + Offset(18.827987907130577, 43.04433671564179), + Offset(16.997529418949235, 43.161414977908365), + Offset(15.331601790632021, 42.918949572968955), + Offset(13.928660013420258, 42.478559653106245), + Offset(12.785040072364048, 41.93598444668365), + Offset(11.87472277556043, 41.35613622700879), + Offset(11.194931733517697, 40.86084337127677), + Offset(10.707701666486946, 40.479460473962305), + Offset(10.375481162000769, 40.20587235951845), + Offset(10.171073813641986, 40.03180005589708), + Offset(10.074800072408165, 39.948248163249666), + Offset(10.062500000000002, 39.9375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.000000000002004, 22.0), + Offset(42.025197234906926, 22.241516379724967), + Offset(42.08978791681666, 23.128465075475457), + Offset(42.0783682355337, 25.08286746211989), + Offset(41.397835686580194, 29.03143254177143), + Offset(36.094488002116805, 37.48048070242499), + Offset(25.789219352940048, 42.00837688265331), + Offset(19.59049856191341, 41.30378926060276), + Offset(20.224936460436354, 42.28321450135585), + Offset(18.827987907130577, 43.04433671564179), + Offset(16.997529418949235, 43.161414977908365), + Offset(15.331601790632021, 42.918949572968955), + Offset(13.928660013420258, 42.478559653106245), + Offset(12.785040072364048, 41.93598444668365), + Offset(11.87472277556043, 41.35613622700879), + Offset(11.194931733517697, 40.86084337127677), + Offset(10.707701666486946, 40.479460473962305), + Offset(10.375481162000769, 40.20587235951845), + Offset(10.171073813641986, 40.03180005589708), + Offset(10.074800072408165, 39.948248163249666), + Offset(10.062500000000002, 39.9375), + ], + <Offset>[ + Offset(24.946426391602, 22.0), + Offset(24.97315650002924, 22.01286906793447), + Offset(25.06952844617418, 22.06303541138589), + Offset(25.272024998666566, 22.189564881723705), + Offset(25.64134770835411, 22.51454646253731), + Offset(26.515147324551563, 24.079353894601017), + Offset(26.00733165465548, 28.39845339876247), + Offset(25.738815604552915, 29.790067185625176), + Offset(28.372939508552708, 34.1489738102917), + Offset(27.852720130300924, 37.12729955342019), + Offset(26.47865456242841, 38.80842834269753), + Offset(25.05405441770695, 39.73229211885786), + Offset(23.785295519688383, 40.180449370272434), + Offset(22.72555198004472, 40.32032714542882), + Offset(21.87777278679228, 40.26393811183259), + Offset(21.233468870168988, 40.166812820023495), + Offset(20.762232548431296, 40.079066925029025), + Offset(20.436073294394554, 40.00993309439713), + Offset(20.23334169244069, 39.96345252268739), + Offset(20.137297077676344, 39.940484854102536), + Offset(20.125, 39.9375), + ], + <Offset>[ + Offset(24.946426391602, 22.0), + Offset(24.97315650002924, 22.01286906793447), + Offset(25.06952844617418, 22.06303541138589), + Offset(25.272024998666566, 22.189564881723705), + Offset(25.64134770835411, 22.51454646253731), + Offset(26.515147324551563, 24.079353894601017), + Offset(26.00733165465548, 28.39845339876247), + Offset(25.738815604552915, 29.790067185625176), + Offset(28.372939508552708, 34.1489738102917), + Offset(27.852720130300924, 37.12729955342019), + Offset(26.47865456242841, 38.80842834269753), + Offset(25.05405441770695, 39.73229211885786), + Offset(23.785295519688383, 40.180449370272434), + Offset(22.72555198004472, 40.32032714542882), + Offset(21.87777278679228, 40.26393811183259), + Offset(21.233468870168988, 40.166812820023495), + Offset(20.762232548431296, 40.079066925029025), + Offset(20.436073294394554, 40.00993309439713), + Offset(20.23334169244069, 39.96345252268739), + Offset(20.137297077676344, 39.940484854102536), + Offset(20.125, 39.9375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(24.946426391602, 22.0), + Offset(24.97315650002924, 22.01286906793447), + Offset(25.06952844617418, 22.06303541138589), + Offset(25.272024998666566, 22.189564881723705), + Offset(25.64134770835411, 22.51454646253731), + Offset(26.515147324551563, 24.079353894601017), + Offset(26.00733165465548, 28.39845339876247), + Offset(25.738815604552915, 29.790067185625176), + Offset(28.372939508552708, 34.1489738102917), + Offset(27.852720130300924, 37.12729955342019), + Offset(26.47865456242841, 38.80842834269753), + Offset(25.05405441770695, 39.73229211885786), + Offset(23.785295519688383, 40.180449370272434), + Offset(22.72555198004472, 40.32032714542882), + Offset(21.87777278679228, 40.26393811183259), + Offset(21.233468870168988, 40.166812820023495), + Offset(20.762232548431296, 40.079066925029025), + Offset(20.436073294394554, 40.00993309439713), + Offset(20.23334169244069, 39.96345252268739), + Offset(20.137297077676344, 39.940484854102536), + Offset(20.125, 39.9375), + ], + <Offset>[ + Offset(24.946426391602, 26.0), + Offset(24.919526142652916, 26.01250952487153), + Offset(24.81962665114753, 26.055221415675078), + Offset(24.59338657440784, 26.131575775813122), + Offset(24.112549450730533, 26.21086569759901), + Offset(23.26102990202604, 26.405449292562118), + Offset(21.93030078929294, 28.333115001580325), + Offset(20.21574669997896, 26.840753449152132), + Offset(19.702668556230993, 25.46403358899737), + Offset(19.57851450097965, 24.507387236351565), + Offset(19.597421849557026, 23.820597237404847), + Offset(19.672638738964572, 23.313659191847616), + Offset(19.76421128572404, 22.933948220939197), + Offset(19.853181729528682, 22.647748525973807), + Offset(19.930810929304265, 22.432414178767115), + Offset(19.99628397445662, 22.272029228601628), + Offset(20.048487526419784, 22.155772744171706), + Offset(20.086790256569586, 22.07583407578212), + Offset(20.111504785414724, 22.026366303959268), + Offset(20.12345813528363, 22.002990192537517), + Offset(20.125, 22.0), + ], + <Offset>[ + Offset(24.946426391602, 26.0), + Offset(24.919526142652916, 26.01250952487153), + Offset(24.81962665114753, 26.055221415675078), + Offset(24.59338657440784, 26.131575775813122), + Offset(24.112549450730533, 26.21086569759901), + Offset(23.26102990202604, 26.405449292562118), + Offset(21.93030078929294, 28.333115001580325), + Offset(20.21574669997896, 26.840753449152132), + Offset(19.702668556230993, 25.46403358899737), + Offset(19.57851450097965, 24.507387236351565), + Offset(19.597421849557026, 23.820597237404847), + Offset(19.672638738964572, 23.313659191847616), + Offset(19.76421128572404, 22.933948220939197), + Offset(19.853181729528682, 22.647748525973807), + Offset(19.930810929304265, 22.432414178767115), + Offset(19.99628397445662, 22.272029228601628), + Offset(20.048487526419784, 22.155772744171706), + Offset(20.086790256569586, 22.07583407578212), + Offset(20.111504785414724, 22.026366303959268), + Offset(20.12345813528363, 22.002990192537517), + Offset(20.125, 22.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(23.053573608398, 26.0), + Offset(23.026843499970756, 25.98713093206553), + Offset(22.930471553825814, 25.93696458861411), + Offset(22.727975001333434, 25.810435118276295), + Offset(22.35865229164589, 25.48545353746269), + Offset(21.484852675448433, 23.920646105398983), + Offset(22.070212775572372, 19.602789326384535), + Offset(24.25581739693188, 19.275065010286717), + Offset(25.471296020158917, 19.70514960582061), + Offset(26.22827021130133, 20.147495919393876), + Offset(26.733592485966874, 20.544229197031424), + Offset(27.081522869281894, 20.885303225908167), + Offset(27.327534182039592, 21.170532082708068), + Offset(27.505108572109584, 21.404060918464253), + Offset(27.63502336031513, 21.591218114904695), + Offset(27.72782810454829, 21.737496381673637), + Offset(27.7923498205881, 21.847395103999485), + Offset(27.83532084300952, 21.924924331216626), + Offset(27.861326008837327, 21.973725967822613), + Offset(27.873455828782106, 21.997010997914884), + Offset(27.875, 22.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(23.053573608398, 26.0), + Offset(23.026843499970756, 25.98713093206553), + Offset(22.930471553825814, 25.93696458861411), + Offset(22.727975001333434, 25.810435118276295), + Offset(22.35865229164589, 25.48545353746269), + Offset(21.484852675448433, 23.920646105398983), + Offset(22.070212775572372, 19.602789326384535), + Offset(24.25581739693188, 19.275065010286717), + Offset(25.471296020158917, 19.70514960582061), + Offset(26.22827021130133, 20.147495919393876), + Offset(26.733592485966874, 20.544229197031424), + Offset(27.081522869281894, 20.885303225908167), + Offset(27.327534182039592, 21.170532082708068), + Offset(27.505108572109584, 21.404060918464253), + Offset(27.63502336031513, 21.591218114904695), + Offset(27.72782810454829, 21.737496381673637), + Offset(27.7923498205881, 21.847395103999485), + Offset(27.83532084300952, 21.924924331216626), + Offset(27.861326008837327, 21.973725967822613), + Offset(27.873455828782106, 21.997010997914884), + Offset(27.875, 22.0), + ], + <Offset>[ + Offset(5.999999999998, 26.0), + Offset(5.974802765093067, 25.758483620275033), + Offset(5.910212083183335, 24.871534924524543), + Offset(5.921631764466294, 22.91713253788011), + Offset(6.602164313419802, 18.96856745822857), + Offset(11.905511997883192, 10.519519297575007), + Offset(22.288325077287805, 5.992865842493696), + Offset(30.404134439571386, 7.761342935309134), + Offset(33.61929906827527, 11.570908914756462), + Offset(35.253002434471675, 14.230458757172274), + Offset(36.21471762944605, 16.191242561820587), + Offset(36.80397549635682, 17.698645771797075), + Offset(37.18416968830772, 18.872421799874253), + Offset(37.44562047979026, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119958, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ], + <Offset>[ + Offset(5.999999999998, 26.0), + Offset(5.974802765093067, 25.758483620275033), + Offset(5.910212083183335, 24.871534924524543), + Offset(5.921631764466294, 22.91713253788011), + Offset(6.602164313419802, 18.96856745822857), + Offset(11.905511997883192, 10.519519297575007), + Offset(22.288325077287805, 5.992865842493696), + Offset(30.404134439571386, 7.761342935309134), + Offset(33.61929906827527, 11.570908914756462), + Offset(35.253002434471675, 14.230458757172274), + Offset(36.21471762944605, 16.191242561820587), + Offset(36.80397549635682, 17.698645771797075), + Offset(37.18416968830772, 18.872421799874253), + Offset(37.44562047979026, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119958, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(5.999999999998, 26.0), + Offset(5.974802765093067, 25.758483620275033), + Offset(5.910212083183335, 24.871534924524543), + Offset(5.921631764466294, 22.91713253788011), + Offset(6.602164313419802, 18.96856745822857), + Offset(11.905511997883192, 10.519519297575007), + Offset(22.288325077287805, 5.992865842493696), + Offset(30.404134439571386, 7.761342935309134), + Offset(33.61929906827527, 11.570908914756462), + Offset(35.253002434471675, 14.230458757172274), + Offset(36.21471762944605, 16.191242561820587), + Offset(36.80397549635682, 17.698645771797075), + Offset(37.18416968830772, 18.872421799874253), + Offset(37.44562047979026, 19.788403617209426), + Offset(37.63807337154698, 20.499019999728493), + Offset(37.76636524119958, 21.043465830420356), + Offset(37.84688070253245, 21.447001555066198), + Offset(37.895912975403306, 21.728985066095305), + Offset(37.92359388763603, 21.905378434612928), + Offset(37.93595283405028, 21.98924768876775), + Offset(37.9375, 22.0), + ], + <Offset>[ + Offset(5.999999999998, 22.0), + Offset(6.028433122469394, 21.758843163337975), + Offset(6.160113878209987, 20.879348920235355), + Offset(6.600270188725016, 18.975121643790693), + Offset(8.13096257104338, 15.27224822316687), + Offset(15.159629420408717, 8.193423899613903), + Offset(26.365355942650346, 6.058204239675842), + Offset(35.92720334414534, 10.710656671782182), + Offset(42.28957002059698, 20.25584913605079), + Offset(43.52720806379294, 26.850371074240893), + Offset(43.09595034231744, 31.17907366711327), + Offset(42.1853911750992, 34.117278698807326), + Offset(41.205253922272064, 36.11892294920749), + Offset(40.3179907303063, 37.46098223666444), + Offset(39.585035229035, 38.330543932793965), + Offset(39.00355013691195, 38.93824942184222), + Offset(38.56062572454396, 39.37029573592351), + Offset(38.24519601322827, 39.66308408471031), + Offset(38.045430794661996, 39.842464653341054), + Offset(37.949791776443, 39.926742350332766), + Offset(37.9375, 39.9375), + ], + <Offset>[ + Offset(5.999999999998, 22.0), + Offset(6.028433122469394, 21.758843163337975), + Offset(6.160113878209987, 20.879348920235355), + Offset(6.600270188725016, 18.975121643790693), + Offset(8.13096257104338, 15.27224822316687), + Offset(15.159629420408717, 8.193423899613903), + Offset(26.365355942650346, 6.058204239675842), + Offset(35.92720334414534, 10.710656671782182), + Offset(42.28957002059698, 20.25584913605079), + Offset(43.52720806379294, 26.850371074240893), + Offset(43.09595034231744, 31.17907366711327), + Offset(42.1853911750992, 34.117278698807326), + Offset(41.205253922272064, 36.11892294920749), + Offset(40.3179907303063, 37.46098223666444), + Offset(39.585035229035, 38.330543932793965), + Offset(39.00355013691195, 38.93824942184222), + Offset(38.56062572454396, 39.37029573592351), + Offset(38.24519601322827, 39.66308408471031), + Offset(38.045430794661996, 39.842464653341054), + Offset(37.949791776443, 39.926742350332766), + Offset(37.9375, 39.9375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(5.999999999998, 22.0), + Offset(6.028433122469394, 21.758843163337975), + Offset(6.160113878209987, 20.879348920235355), + Offset(6.600270188725016, 18.975121643790693), + Offset(8.13096257104338, 15.27224822316687), + Offset(15.159629420408717, 8.193423899613903), + Offset(26.365355942650346, 6.058204239675842), + Offset(35.92720334414534, 10.710656671782182), + Offset(42.28957002059698, 20.25584913605079), + Offset(43.52720806379294, 26.850371074240893), + Offset(43.09595034231744, 31.17907366711327), + Offset(42.1853911750992, 34.117278698807326), + Offset(41.205253922272064, 36.11892294920749), + Offset(40.3179907303063, 37.46098223666444), + Offset(39.585035229035, 38.330543932793965), + Offset(39.00355013691195, 38.93824942184222), + Offset(38.56062572454396, 39.37029573592351), + Offset(38.24519601322827, 39.66308408471031), + Offset(38.045430794661996, 39.842464653341054), + Offset(37.949791776443, 39.926742350332766), + Offset(37.9375, 39.9375), + ], + <Offset>[ + Offset(23.053573608398, 22.0), + Offset(23.08047385734708, 21.98749047512847), + Offset(23.180373348852466, 21.944778584324922), + Offset(23.40661342559216, 21.868424224186878), + Offset(23.887450549269467, 21.78913430240099), + Offset(24.73897009797396, 21.594550707437882), + Offset(26.147243640934914, 19.66812772356668), + Offset(29.778886301505835, 22.224378746759765), + Offset(34.14156697248063, 28.39008982711494), + Offset(34.5024758406226, 32.767408236462494), + Offset(33.614825198838254, 35.532060302324105), + Offset(32.46293854802427, 37.30393615291841), + Offset(31.348618416003937, 38.417033232041305), + Offset(30.377478822625626, 39.07663953791927), + Offset(29.58198521780315, 39.42274204797017), + Offset(28.96501300026066, 39.6322799730955), + Offset(28.506094842599616, 39.7706892848568), + Offset(28.184603880834484, 39.85902334983163), + Offset(27.983162915863293, 39.910812186550736), + Offset(27.88729477117482, 39.934505659479896), + Offset(27.875, 39.9375), + ], + <Offset>[ + Offset(23.053573608398, 22.0), + Offset(23.08047385734708, 21.98749047512847), + Offset(23.180373348852466, 21.944778584324922), + Offset(23.40661342559216, 21.868424224186878), + Offset(23.887450549269467, 21.78913430240099), + Offset(24.73897009797396, 21.594550707437882), + Offset(26.147243640934914, 19.66812772356668), + Offset(29.778886301505835, 22.224378746759765), + Offset(34.14156697248063, 28.39008982711494), + Offset(34.5024758406226, 32.767408236462494), + Offset(33.614825198838254, 35.532060302324105), + Offset(32.46293854802427, 37.30393615291841), + Offset(31.348618416003937, 38.417033232041305), + Offset(30.377478822625626, 39.07663953791927), + Offset(29.58198521780315, 39.42274204797017), + Offset(28.96501300026066, 39.6322799730955), + Offset(28.506094842599616, 39.7706892848568), + Offset(28.184603880834484, 39.85902334983163), + Offset(27.983162915863293, 39.910812186550736), + Offset(27.88729477117482, 39.934505659479896), + Offset(27.875, 39.9375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(23.053573608398, 22.0), + Offset(23.08047385734708, 21.98749047512847), + Offset(23.180373348852466, 21.944778584324922), + Offset(23.40661342559216, 21.868424224186878), + Offset(23.887450549269467, 21.78913430240099), + Offset(24.73897009797396, 21.594550707437882), + Offset(26.147243640934914, 19.66812772356668), + Offset(29.778886301505835, 22.224378746759765), + Offset(34.14156697248063, 28.39008982711494), + Offset(34.5024758406226, 32.767408236462494), + Offset(33.614825198838254, 35.532060302324105), + Offset(32.46293854802427, 37.30393615291841), + Offset(31.348618416003937, 38.417033232041305), + Offset(30.377478822625626, 39.07663953791927), + Offset(29.58198521780315, 39.42274204797017), + Offset(28.96501300026066, 39.6322799730955), + Offset(28.506094842599616, 39.7706892848568), + Offset(28.184603880834484, 39.85902334983163), + Offset(27.983162915863293, 39.910812186550736), + Offset(27.88729477117482, 39.934505659479896), + Offset(27.875, 39.9375), + ], + <Offset>[ + Offset(23.053573608398, 26.0), + Offset(23.026843499970756, 25.98713093206553), + Offset(22.930471553825818, 25.93696458861411), + Offset(22.727975001333434, 25.810435118276295), + Offset(22.35865229164589, 25.48545353746269), + Offset(21.484852675448437, 23.920646105398983), + Offset(22.070212775572372, 19.602789326384535), + Offset(24.25581739693188, 19.275065010286717), + Offset(25.471296020158917, 19.70514960582061), + Offset(26.22827021130133, 20.147495919393876), + Offset(26.733592485966874, 20.544229197031424), + Offset(27.081522869281894, 20.885303225908167), + Offset(27.327534182039592, 21.170532082708068), + Offset(27.505108572109584, 21.404060918464253), + Offset(27.63502336031513, 21.591218114904695), + Offset(27.72782810454829, 21.737496381673637), + Offset(27.7923498205881, 21.847395103999485), + Offset(27.83532084300952, 21.924924331216626), + Offset(27.861326008837327, 21.973725967822613), + Offset(27.873455828782106, 21.997010997914884), + Offset(27.875, 22.0), + ], + <Offset>[ + Offset(23.053573608398, 26.0), + Offset(23.026843499970756, 25.98713093206553), + Offset(22.930471553825818, 25.93696458861411), + Offset(22.727975001333434, 25.810435118276295), + Offset(22.35865229164589, 25.48545353746269), + Offset(21.484852675448437, 23.920646105398983), + Offset(22.070212775572372, 19.602789326384535), + Offset(24.25581739693188, 19.275065010286717), + Offset(25.471296020158917, 19.70514960582061), + Offset(26.22827021130133, 20.147495919393876), + Offset(26.733592485966874, 20.544229197031424), + Offset(27.081522869281894, 20.885303225908167), + Offset(27.327534182039592, 21.170532082708068), + Offset(27.505108572109584, 21.404060918464253), + Offset(27.63502336031513, 21.591218114904695), + Offset(27.72782810454829, 21.737496381673637), + Offset(27.7923498205881, 21.847395103999485), + Offset(27.83532084300952, 21.924924331216626), + Offset(27.861326008837327, 21.973725967822613), + Offset(27.873455828782106, 21.997010997914884), + Offset(27.875, 22.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(6.0, 36.0), + Offset(5.840726871654255, 35.757584762617704), + Offset(5.285457595618709, 34.85199993524763), + Offset(4.227525983784449, 32.77342417727839), + Offset(3.5264717596110557, 28.827211071001663), + Offset(6.517886449942477, 24.591045888519158), + Offset(7.520599639567256, 20.429019310226224), + Offset(9.337441251165988, 14.915299719515282), + Offset(11.67955424234765, 11.532962037598704), + Offset(14.02683296676815, 9.419579743902961), + Offset(16.229456854531474, 8.105002459199046), + Offset(18.082743777434697, 7.3261172370029986), + Offset(19.565347960385694, 6.872065247520328), + Offset(20.738317895220952, 6.610528546128252), + Offset(21.653024624707548, 6.463637943249828), + Offset(22.351393858266118, 6.3842574556990765), + Offset(22.866855439990758, 6.343605379246371), + Offset(23.22597898795316, 6.324220552672472), + Offset(23.450183580341104, 6.315826581309215), + Offset(23.556666562733803, 6.312836771798083), + Offset(23.5703125, 6.312499999999998), + ]), + _PathCubicTo( + <Offset>[ + Offset(6.0, 36.0), + Offset(5.840726871654255, 35.757584762617704), + Offset(5.285457595618709, 34.85199993524763), + Offset(4.227525983784449, 32.77342417727839), + Offset(3.5264717596110557, 28.827211071001663), + Offset(6.517886449942477, 24.591045888519158), + Offset(7.520599639567256, 20.429019310226224), + Offset(9.337441251165988, 14.915299719515282), + Offset(11.67955424234765, 11.532962037598704), + Offset(14.02683296676815, 9.419579743902961), + Offset(16.229456854531474, 8.105002459199046), + Offset(18.082743777434697, 7.3261172370029986), + Offset(19.565347960385694, 6.872065247520328), + Offset(20.738317895220952, 6.610528546128252), + Offset(21.653024624707548, 6.463637943249828), + Offset(22.351393858266118, 6.3842574556990765), + Offset(22.866855439990758, 6.343605379246371), + Offset(23.22597898795316, 6.324220552672472), + Offset(23.450183580341104, 6.315826581309215), + Offset(23.556666562733803, 6.312836771798083), + Offset(23.5703125, 6.312499999999998), + ], + <Offset>[ + Offset(42.0, 36.0), + Offset(41.837490984087786, 36.240257979004646), + Offset(41.215131634221386, 37.10111609048749), + Offset(39.70046993008684, 38.880282689423), + Offset(35.11680046930425, 41.89297634122701), + Offset(17.977223307522138, 40.62221458070813), + Offset(7.4051530249760695, 27.632737664041155), + Offset(7.974125419949719, 17.46832999953845), + Offset(11.043336185217218, 12.16810549276672), + Offset(13.987438256258187, 9.445408761353912), + Offset(16.335956080085097, 8.056106394810097), + Offset(18.194102300352007, 7.289618065369594), + Offset(19.679474513989774, 6.845456228021216), + Offset(20.85398754040343, 6.5917284573090615), + Offset(21.76951977235388, 6.450918244703521), + Offset(22.468302287652584, 6.376174801763673), + Offset(22.98395013193887, 6.338942410896992), + Offset(23.343144268998117, 6.321938651292953), + Offset(23.56736837706624, 6.315030608484569), + Offset(23.673854027857267, 6.312746360589072), + Offset(23.6875, 6.312499999999998), + ], + <Offset>[ + Offset(42.0, 36.0), + Offset(41.837490984087786, 36.240257979004646), + Offset(41.215131634221386, 37.10111609048749), + Offset(39.70046993008684, 38.880282689423), + Offset(35.11680046930425, 41.89297634122701), + Offset(17.977223307522138, 40.62221458070813), + Offset(7.4051530249760695, 27.632737664041155), + Offset(7.974125419949719, 17.46832999953845), + Offset(11.043336185217218, 12.16810549276672), + Offset(13.987438256258187, 9.445408761353912), + Offset(16.335956080085097, 8.056106394810097), + Offset(18.194102300352007, 7.289618065369594), + Offset(19.679474513989774, 6.845456228021216), + Offset(20.85398754040343, 6.5917284573090615), + Offset(21.76951977235388, 6.450918244703521), + Offset(22.468302287652584, 6.376174801763673), + Offset(22.98395013193887, 6.338942410896992), + Offset(23.343144268998117, 6.321938651292953), + Offset(23.56736837706624, 6.315030608484569), + Offset(23.673854027857267, 6.312746360589072), + Offset(23.6875, 6.312499999999998), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 36.0), + Offset(41.837490984087786, 36.240257979004646), + Offset(41.215131634221386, 37.10111609048749), + Offset(39.70046993008684, 38.880282689423), + Offset(35.11680046930425, 41.89297634122701), + Offset(17.977223307522138, 40.62221458070813), + Offset(7.4051530249760695, 27.632737664041155), + Offset(7.974125419949719, 17.46832999953845), + Offset(11.043336185217218, 12.16810549276672), + Offset(13.987438256258187, 9.445408761353912), + Offset(16.335956080085097, 8.056106394810097), + Offset(18.194102300352007, 7.289618065369594), + Offset(19.679474513989774, 6.845456228021216), + Offset(20.85398754040343, 6.5917284573090615), + Offset(21.76951977235388, 6.450918244703521), + Offset(22.468302287652584, 6.376174801763673), + Offset(22.98395013193887, 6.338942410896992), + Offset(23.343144268998117, 6.321938651292953), + Offset(23.56736837706624, 6.315030608484569), + Offset(23.673854027857267, 6.312746360589072), + Offset(23.6875, 6.312499999999998), + ], + <Offset>[ + Offset(42.0, 32.0), + Offset(41.891121341464114, 32.24061752206759), + Offset(41.46503342924804, 33.1089300861983), + Offset(40.382213841518244, 34.93682280811562), + Offset(37.807067599374584, 37.94317621909476), + Offset(31.28569346733829, 41.88836406262549), + Offset(21.918156356601166, 43.36435943014172), + Offset(14.025584375723557, 40.89778062244908), + Offset(9.769920092960579, 37.63050924159487), + Offset(7.367850012815694, 34.638113152492416), + Offset(5.973096817006795, 32.07021795746492), + Offset(5.173978263055725, 29.973153422615376), + Offset(4.723297068059789, 28.301904267150505), + Offset(4.475728989802221, 26.983370203369095), + Offset(4.346332932330473, 25.957300958770094), + Offset(4.284100454437475, 25.175143122894923), + Offset(4.258180500986249, 24.598514092031586), + Offset(4.250093592349071, 24.197111996156252), + Offset(4.24918203540361, 23.946652278620284), + Offset(4.2498612199243055, 23.82773735822788), + Offset(4.25, 23.8125), + ], + <Offset>[ + Offset(42.0, 32.0), + Offset(41.891121341464114, 32.24061752206759), + Offset(41.46503342924804, 33.1089300861983), + Offset(40.382213841518244, 34.93682280811562), + Offset(37.807067599374584, 37.94317621909476), + Offset(31.28569346733829, 41.88836406262549), + Offset(21.918156356601166, 43.36435943014172), + Offset(14.025584375723557, 40.89778062244908), + Offset(9.769920092960579, 37.63050924159487), + Offset(7.367850012815694, 34.638113152492416), + Offset(5.973096817006795, 32.07021795746492), + Offset(5.173978263055725, 29.973153422615376), + Offset(4.723297068059789, 28.301904267150505), + Offset(4.475728989802221, 26.983370203369095), + Offset(4.346332932330473, 25.957300958770094), + Offset(4.284100454437475, 25.175143122894923), + Offset(4.258180500986249, 24.598514092031586), + Offset(4.250093592349071, 24.197111996156252), + Offset(4.24918203540361, 23.946652278620284), + Offset(4.2498612199243055, 23.82773735822788), + Offset(4.25, 23.8125), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.0, 32.0), + Offset(41.891121341464114, 32.24061752206759), + Offset(41.46503342924804, 33.1089300861983), + Offset(40.382213841518244, 34.93682280811562), + Offset(37.807067599374584, 37.94317621909476), + Offset(31.28569346733829, 41.88836406262549), + Offset(21.918156356601166, 43.36435943014172), + Offset(14.025584375723557, 40.89778062244908), + Offset(9.769920092960579, 37.63050924159487), + Offset(7.367850012815694, 34.638113152492416), + Offset(5.973096817006795, 32.07021795746492), + Offset(5.173978263055725, 29.973153422615376), + Offset(4.723297068059789, 28.301904267150505), + Offset(4.475728989802221, 26.983370203369095), + Offset(4.346332932330473, 25.957300958770094), + Offset(4.284100454437475, 25.175143122894923), + Offset(4.258180500986249, 24.598514092031586), + Offset(4.250093592349071, 24.197111996156252), + Offset(4.24918203540361, 23.946652278620284), + Offset(4.2498612199243055, 23.82773735822788), + Offset(4.25, 23.8125), + ], + <Offset>[ + Offset(6.0, 32.0), + Offset(5.894357229030582, 31.757944305680645), + Offset(5.535359390645361, 30.859813930958445), + Offset(4.903580651262228, 28.82898486195736), + Offset(4.366121086699318, 24.111995114141347), + Offset(9.36700394742377, 11.224965859696677), + Offset(22.54292545773696, 4.379581429626963), + Offset(32.60235573940598, 6.109759600029818), + Offset(37.826493474762316, 9.62132472038408), + Offset(40.59568298818934, 12.852389468570193), + Offset(42.097634124796315, 15.484672916733718), + Offset(42.946789236607636, 17.592634404564635), + Offset(43.43502405056509, 19.276124853051655), + Offset(43.71087263569899, 20.606380075899363), + Offset(43.86148701396686, 21.64277921186285), + Offset(43.93943970232704, 22.43350690800619), + Offset(43.97670000978504, 23.016835227922453), + Offset(43.992556922799054, 23.423091048223576), + Offset(43.99826508457116, 23.676658296500023), + Offset(43.99984938980357, 23.797069876131136), + Offset(44.0, 23.812499999999996), + ], + <Offset>[ + Offset(6.0, 32.0), + Offset(5.894357229030582, 31.757944305680645), + Offset(5.535359390645361, 30.859813930958445), + Offset(4.903580651262228, 28.82898486195736), + Offset(4.366121086699318, 24.111995114141347), + Offset(9.36700394742377, 11.224965859696677), + Offset(22.54292545773696, 4.379581429626963), + Offset(32.60235573940598, 6.109759600029818), + Offset(37.826493474762316, 9.62132472038408), + Offset(40.59568298818934, 12.852389468570193), + Offset(42.097634124796315, 15.484672916733718), + Offset(42.946789236607636, 17.592634404564635), + Offset(43.43502405056509, 19.276124853051655), + Offset(43.71087263569899, 20.606380075899363), + Offset(43.86148701396686, 21.64277921186285), + Offset(43.93943970232704, 22.43350690800619), + Offset(43.97670000978504, 23.016835227922453), + Offset(43.992556922799054, 23.423091048223576), + Offset(43.99826508457116, 23.676658296500023), + Offset(43.99984938980357, 23.797069876131136), + Offset(44.0, 23.812499999999996), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(6.0, 32.0), + Offset(5.894357229030582, 31.757944305680645), + Offset(5.535359390645361, 30.859813930958445), + Offset(4.903580651262228, 28.82898486195736), + Offset(4.366121086699318, 24.111995114141347), + Offset(9.36700394742377, 11.224965859696677), + Offset(22.54292545773696, 4.379581429626963), + Offset(32.60235573940598, 6.109759600029818), + Offset(37.826493474762316, 9.62132472038408), + Offset(40.59568298818934, 12.852389468570193), + Offset(42.097634124796315, 15.484672916733718), + Offset(42.946789236607636, 17.592634404564635), + Offset(43.43502405056509, 19.276124853051655), + Offset(43.71087263569899, 20.606380075899363), + Offset(43.86148701396686, 21.64277921186285), + Offset(43.93943970232704, 22.43350690800619), + Offset(43.97670000978504, 23.016835227922453), + Offset(43.992556922799054, 23.423091048223576), + Offset(43.99826508457116, 23.676658296500023), + Offset(43.99984938980357, 23.797069876131136), + Offset(44.0, 23.812499999999996), + ], + <Offset>[ + Offset(6.0, 36.0), + Offset(5.840726871654255, 35.757584762617704), + Offset(5.285457595618709, 34.85199993524763), + Offset(4.227525983824194, 32.773424177285236), + Offset(3.526471759568551, 28.827211070984085), + Offset(6.517886449901766, 24.591045888462205), + Offset(7.520599639566615, 20.429019310266217), + Offset(9.337441251161277, 14.915299719524107), + Offset(11.679554242336327, 11.53296203761001), + Offset(14.02683296675945, 9.419579743908663), + Offset(16.229456854531474, 8.105002459199046), + Offset(18.082743777434697, 7.3261172370029986), + Offset(19.565347960385694, 6.872065247520328), + Offset(20.738317895220952, 6.610528546128252), + Offset(21.653024624707548, 6.463637943249828), + Offset(22.351393858266118, 6.3842574556990765), + Offset(22.866855439990758, 6.343605379246371), + Offset(23.22597898795316, 6.324220552672472), + Offset(23.450183580341104, 6.315826581309215), + Offset(23.556666562733803, 6.312836771798083), + Offset(23.5703125, 6.312499999999998), + ], + <Offset>[ + Offset(6.0, 36.0), + Offset(5.840726871654255, 35.757584762617704), + Offset(5.285457595618709, 34.85199993524763), + Offset(4.227525983824194, 32.773424177285236), + Offset(3.526471759568551, 28.827211070984085), + Offset(6.517886449901766, 24.591045888462205), + Offset(7.520599639566615, 20.429019310266217), + Offset(9.337441251161277, 14.915299719524107), + Offset(11.679554242336327, 11.53296203761001), + Offset(14.02683296675945, 9.419579743908663), + Offset(16.229456854531474, 8.105002459199046), + Offset(18.082743777434697, 7.3261172370029986), + Offset(19.565347960385694, 6.872065247520328), + Offset(20.738317895220952, 6.610528546128252), + Offset(21.653024624707548, 6.463637943249828), + Offset(22.351393858266118, 6.3842574556990765), + Offset(22.866855439990758, 6.343605379246371), + Offset(23.22597898795316, 6.324220552672472), + Offset(23.450183580341104, 6.315826581309215), + Offset(23.556666562733803, 6.312836771798083), + Offset(23.5703125, 6.312499999999998), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/pause_play.g.dart b/packages/material_ui/lib/src/animated_icons/data/pause_play.g.dart new file mode 100644 index 000000000000..919a9b299fed --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/pause_play.g.dart @@ -0,0 +1,805 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$pause_play = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(12.0, 38.0), + Offset(11.702518567871357, 37.66537647364598), + Offset(10.658516625352865, 36.33928535710834), + Offset(8.824010134196683, 33.09051115695809), + Offset(7.183618981881198, 27.281162329416798), + Offset(7.4961448079806345, 21.476581828368367), + Offset(9.111297618374987, 17.041890063132637), + Offset(10.944617318451954, 14.273857051215758), + Offset(12.539001321903285, 12.570685504133255), + Offset(13.794958189315498, 11.511449771679523), + Offset(14.725226220617056, 10.84845238497297), + Offset(15.369630177419687, 10.439047154098617), + Offset(15.770227104208459, 10.200145460041224), + Offset(15.977727112935135, 10.078681424879498), + Offset(16.046646118566024, 10.039192889334426), + Offset(16.046875, 10.039062500000002), + ]), + _PathCubicTo( + <Offset>[ + Offset(12.0, 38.0), + Offset(11.702518567871357, 37.66537647364598), + Offset(10.658516625352865, 36.33928535710834), + Offset(8.824010134196683, 33.09051115695809), + Offset(7.183618981881198, 27.281162329416798), + Offset(7.4961448079806345, 21.476581828368367), + Offset(9.111297618374987, 17.041890063132637), + Offset(10.944617318451954, 14.273857051215758), + Offset(12.539001321903285, 12.570685504133255), + Offset(13.794958189315498, 11.511449771679523), + Offset(14.725226220617056, 10.84845238497297), + Offset(15.369630177419687, 10.439047154098617), + Offset(15.770227104208459, 10.200145460041224), + Offset(15.977727112935135, 10.078681424879498), + Offset(16.046646118566024, 10.039192889334426), + Offset(16.046875, 10.039062500000002), + ], + <Offset>[ + Offset(20.0, 38.0), + Offset(19.80052916638957, 37.82094858440343), + Offset(19.11164196247534, 37.113338636628065), + Offset(17.966685507002932, 35.41245490387412), + Offset(16.388368582775627, 32.64921363804367), + Offset(15.647464737031132, 29.978925368550197), + Offset(15.429304294338682, 27.901717576585412), + Offset(15.521061545777922, 26.541981731286498), + Offset(15.686957461206134, 25.650190745693187), + Offset(15.84005263487414, 25.05047803281999), + Offset(15.953666440217397, 24.63834819764938), + Offset(16.022300799535966, 24.35280207511846), + Offset(16.047681038809458, 24.15758118207667), + Offset(16.04697282889272, 24.039447195772148), + Offset(16.046875001068816, 24.00013038745822), + Offset(16.046875, 24.0), + ], + <Offset>[ + Offset(20.0, 38.0), + Offset(19.80052916638957, 37.82094858440343), + Offset(19.11164196247534, 37.113338636628065), + Offset(17.966685507002932, 35.41245490387412), + Offset(16.388368582775627, 32.64921363804367), + Offset(15.647464737031132, 29.978925368550197), + Offset(15.429304294338682, 27.901717576585412), + Offset(15.521061545777922, 26.541981731286498), + Offset(15.686957461206134, 25.650190745693187), + Offset(15.84005263487414, 25.05047803281999), + Offset(15.953666440217397, 24.63834819764938), + Offset(16.022300799535966, 24.35280207511846), + Offset(16.047681038809458, 24.15758118207667), + Offset(16.04697282889272, 24.039447195772148), + Offset(16.046875001068816, 24.00013038745822), + Offset(16.046875, 24.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(20.0, 38.0), + Offset(19.80052916638957, 37.82094858440343), + Offset(19.11164196247534, 37.113338636628065), + Offset(17.966685507002932, 35.41245490387412), + Offset(16.388368582775627, 32.64921363804367), + Offset(15.647464737031132, 29.978925368550197), + Offset(15.429304294338682, 27.901717576585412), + Offset(15.521061545777922, 26.541981731286498), + Offset(15.686957461206134, 25.650190745693187), + Offset(15.84005263487414, 25.05047803281999), + Offset(15.953666440217397, 24.63834819764938), + Offset(16.022300799535966, 24.35280207511846), + Offset(16.047681038809458, 24.15758118207667), + Offset(16.04697282889272, 24.039447195772148), + Offset(16.046875001068816, 24.00013038745822), + Offset(16.046875, 24.0), + ], + <Offset>[ + Offset(20.0, 10.0), + Offset(20.336398367184067, 9.927295626138141), + Offset(21.61961776538543, 9.72474102274872), + Offset(24.500250707242675, 9.686480391272799), + Offset(29.133385987204207, 10.794971358676317), + Offset(33.085276289027, 13.261042804636414), + Offset(35.61934940727931, 16.155597331715125), + Offset(36.90120687325454, 18.566431660274624), + Offset(37.51766973937702, 20.39600693122759), + Offset(37.80131897673813, 21.733189454430033), + Offset(37.922586409468074, 22.681298975667566), + Offset(37.96809580639086, 23.323362088481808), + Offset(37.98160482507013, 23.72156603022978), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(20.0, 10.0), + Offset(20.336398367184067, 9.927295626138141), + Offset(21.61961776538543, 9.72474102274872), + Offset(24.500250707242675, 9.686480391272799), + Offset(29.133385987204207, 10.794971358676317), + Offset(33.085276289027, 13.261042804636414), + Offset(35.61934940727931, 16.155597331715125), + Offset(36.90120687325454, 18.566431660274624), + Offset(37.51766973937702, 20.39600693122759), + Offset(37.80131897673813, 21.733189454430033), + Offset(37.922586409468074, 22.681298975667566), + Offset(37.96809580639086, 23.323362088481808), + Offset(37.98160482507013, 23.72156603022978), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(20.0, 10.0), + Offset(20.336398367184067, 9.927295626138141), + Offset(21.61961776538543, 9.72474102274872), + Offset(24.500250707242675, 9.686480391272799), + Offset(29.133385987204207, 10.794971358676317), + Offset(33.085276289027, 13.261042804636414), + Offset(35.61934940727931, 16.155597331715125), + Offset(36.90120687325454, 18.566431660274624), + Offset(37.51766973937702, 20.39600693122759), + Offset(37.80131897673813, 21.733189454430033), + Offset(37.922586409468074, 22.681298975667566), + Offset(37.96809580639086, 23.323362088481808), + Offset(37.98160482507013, 23.72156603022978), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(12.0, 10.0), + Offset(12.471392106978678, 9.776199797090369), + Offset(14.305807236043629, 9.055014880021247), + Offset(18.610309025181536, 8.190625764405059), + Offset(25.30150028462865, 8.56028166562692), + Offset(31.058306963326036, 11.146785273031004), + Offset(34.676551168058815, 14.535050411928939), + Offset(36.494518178468795, 17.476216776016784), + Offset(37.358155554762675, 19.733238289785056), + Offset(37.747534126472296, 21.37712051621949), + Offset(37.90872109014562, 22.525653380696408), + Offset(37.966090830555, 23.280619636994825), + Offset(37.98158497072831, 23.720567249296572), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(12.0, 10.0), + Offset(12.471392106978678, 9.776199797090369), + Offset(14.305807236043629, 9.055014880021247), + Offset(18.610309025181536, 8.190625764405059), + Offset(25.30150028462865, 8.56028166562692), + Offset(31.058306963326036, 11.146785273031004), + Offset(34.676551168058815, 14.535050411928939), + Offset(36.494518178468795, 17.476216776016784), + Offset(37.358155554762675, 19.733238289785056), + Offset(37.747534126472296, 21.37712051621949), + Offset(37.90872109014562, 22.525653380696408), + Offset(37.966090830555, 23.280619636994825), + Offset(37.98158497072831, 23.720567249296572), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(12.0, 10.0), + Offset(12.471392106978678, 9.776199797090369), + Offset(14.305807236043629, 9.055014880021247), + Offset(18.610309025181536, 8.190625764405059), + Offset(25.30150028462865, 8.56028166562692), + Offset(31.058306963326036, 11.146785273031004), + Offset(34.676551168058815, 14.535050411928939), + Offset(36.494518178468795, 17.476216776016784), + Offset(37.358155554762675, 19.733238289785056), + Offset(37.747534126472296, 21.37712051621949), + Offset(37.90872109014562, 22.525653380696408), + Offset(37.966090830555, 23.280619636994825), + Offset(37.98158497072831, 23.720567249296572), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(12.0, 38.0), + Offset(11.702518567871357, 37.66537647364598), + Offset(10.658516625352863, 36.33928535710834), + Offset(8.824010134196683, 33.09051115695809), + Offset(7.1836189818552825, 27.281162329401685), + Offset(7.496144808015238, 21.476581828404456), + Offset(9.111297618380014, 17.04189006314128), + Offset(10.944617318448458, 14.273857051206388), + Offset(12.53900132190984, 12.570685504160476), + Offset(13.794958189310869, 11.511449771648872), + Offset(14.725226220612885, 10.848452384926155), + Offset(15.369630177421872, 10.439047154145166), + Offset(15.770227104207432, 10.2001454599895), + Offset(15.977727112935135, 10.078681424879498), + Offset(16.046646118566024, 10.039192889334426), + Offset(16.046875, 10.039062500000002), + ], + <Offset>[ + Offset(12.0, 38.0), + Offset(11.702518567871357, 37.66537647364598), + Offset(10.658516625352863, 36.33928535710834), + Offset(8.824010134196683, 33.09051115695809), + Offset(7.1836189818552825, 27.281162329401685), + Offset(7.496144808015238, 21.476581828404456), + Offset(9.111297618380014, 17.04189006314128), + Offset(10.944617318448458, 14.273857051206388), + Offset(12.53900132190984, 12.570685504160476), + Offset(13.794958189310869, 11.511449771648872), + Offset(14.725226220612885, 10.848452384926155), + Offset(15.369630177421872, 10.439047154145166), + Offset(15.770227104207432, 10.2001454599895), + Offset(15.977727112935135, 10.078681424879498), + Offset(16.046646118566024, 10.039192889334426), + Offset(16.046875, 10.039062500000002), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(28.0, 10.0), + Offset(28.201404627403626, 10.078391455203436), + Offset(28.933428294762216, 10.394467165488965), + Offset(30.390192389339767, 11.182335018165535), + Offset(32.965271689797355, 13.029661051643949), + Offset(35.1122456147493, 15.375300336193657), + Offset(36.562147646495, 17.776144251469397), + Offset(37.30789556803902, 19.656646544522257), + Offset(37.67718392397584, 21.05877557270265), + Offset(37.8551038269895, 22.089258392661982), + Offset(37.936451728823435, 22.836944570678963), + Offset(37.970100782170086, 23.366104539917995), + Offset(37.98162467944151, 23.722564811170674), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(28.0, 10.0), + Offset(28.201404627403626, 10.078391455203436), + Offset(28.933428294762216, 10.394467165488965), + Offset(30.390192389339767, 11.182335018165535), + Offset(32.965271689797355, 13.029661051643949), + Offset(35.1122456147493, 15.375300336193657), + Offset(36.562147646495, 17.776144251469397), + Offset(37.30789556803902, 19.656646544522257), + Offset(37.67718392397584, 21.05877557270265), + Offset(37.8551038269895, 22.089258392661982), + Offset(37.936451728823435, 22.836944570678963), + Offset(37.970100782170086, 23.366104539917995), + Offset(37.98162467944151, 23.722564811170674), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(28.0, 38.0), + Offset(27.665535426609125, 37.97204441346873), + Offset(26.425452491852123, 37.78306477936831), + Offset(23.856627189100024, 36.90830953076686), + Offset(20.220254285368775, 34.883903331011304), + Offset(17.674434062753434, 32.09318290010744), + Offset(16.372102533554372, 29.522264496339687), + Offset(15.927750240562414, 27.632196615534127), + Offset(15.84647164580496, 26.31295938716825), + Offset(15.893837485125506, 25.406546971051934), + Offset(15.967531759572758, 24.79399379266078), + Offset(16.024305775315185, 24.395544526554644), + Offset(16.04770089318083, 24.15857996301756), + Offset(16.04697282889272, 24.039447195772148), + Offset(16.046875001068816, 24.00013038745822), + Offset(16.046875, 24.0), + ], + <Offset>[ + Offset(28.0, 38.0), + Offset(27.665535426609125, 37.97204441346873), + Offset(26.425452491852123, 37.78306477936831), + Offset(23.856627189100024, 36.90830953076686), + Offset(20.220254285368775, 34.883903331011304), + Offset(17.674434062753434, 32.09318290010744), + Offset(16.372102533554372, 29.522264496339687), + Offset(15.927750240562414, 27.632196615534127), + Offset(15.84647164580496, 26.31295938716825), + Offset(15.893837485125506, 25.406546971051934), + Offset(15.967531759572758, 24.79399379266078), + Offset(16.024305775315185, 24.395544526554644), + Offset(16.04770089318083, 24.15857996301756), + Offset(16.04697282889272, 24.039447195772148), + Offset(16.046875001068816, 24.00013038745822), + Offset(16.046875, 24.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(28.0, 38.0), + Offset(27.665535426609125, 37.97204441346873), + Offset(26.425452491852123, 37.78306477936831), + Offset(23.856627189100024, 36.90830953076686), + Offset(20.220254285368775, 34.883903331011304), + Offset(17.674434062753434, 32.09318290010744), + Offset(16.372102533554372, 29.522264496339687), + Offset(15.927750240562414, 27.632196615534127), + Offset(15.84647164580496, 26.31295938716825), + Offset(15.893837485125506, 25.406546971051934), + Offset(15.967531759572758, 24.79399379266078), + Offset(16.024305775315185, 24.395544526554644), + Offset(16.04770089318083, 24.15857996301756), + Offset(16.04697282889272, 24.039447195772148), + Offset(16.046875001068816, 24.00013038745822), + Offset(16.046875, 24.0), + ], + <Offset>[ + Offset(36.0, 38.0), + Offset(35.76354602512734, 38.127616524226184), + Offset(34.8785778289746, 38.55711805888804), + Offset(32.999302561906276, 39.23025327768289), + Offset(29.425003886263205, 40.25195463963817), + Offset(25.82575399180393, 40.59552644028928), + Offset(22.690109209518063, 40.382092009792466), + Offset(20.504194467888382, 39.90032129560486), + Offset(18.99442778510781, 39.39246462872818), + Offset(17.93893193068415, 38.9455752321924), + Offset(17.195971979173102, 38.583889605337184), + Offset(16.676976397431464, 38.30929944757449), + Offset(16.325154827781827, 38.116015685053), + Offset(16.11621854485031, 38.00021296666479), + Offset(16.047103883571605, 37.96106788558201), + Offset(16.046875, 37.9609375), + ], + <Offset>[ + Offset(36.0, 38.0), + Offset(35.76354602512734, 38.127616524226184), + Offset(34.8785778289746, 38.55711805888804), + Offset(32.999302561906276, 39.23025327768289), + Offset(29.425003886263205, 40.25195463963817), + Offset(25.82575399180393, 40.59552644028928), + Offset(22.690109209518063, 40.382092009792466), + Offset(20.504194467888382, 39.90032129560486), + Offset(18.99442778510781, 39.39246462872818), + Offset(17.93893193068415, 38.9455752321924), + Offset(17.195971979173102, 38.583889605337184), + Offset(16.676976397431464, 38.30929944757449), + Offset(16.325154827781827, 38.116015685053), + Offset(16.11621854485031, 38.00021296666479), + Offset(16.047103883571605, 37.96106788558201), + Offset(16.046875, 37.9609375), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(36.0, 38.0), + Offset(35.76354602512734, 38.127616524226184), + Offset(34.8785778289746, 38.55711805888804), + Offset(32.999302561906276, 39.23025327768289), + Offset(29.425003886263205, 40.25195463963817), + Offset(25.82575399180393, 40.59552644028928), + Offset(22.690109209518063, 40.382092009792466), + Offset(20.504194467888382, 39.90032129560486), + Offset(18.99442778510781, 39.39246462872818), + Offset(17.93893193068415, 38.9455752321924), + Offset(17.195971979173102, 38.583889605337184), + Offset(16.676976397431464, 38.30929944757449), + Offset(16.325154827781827, 38.116015685053), + Offset(16.11621854485031, 38.00021296666479), + Offset(16.047103883571605, 37.96106788558201), + Offset(16.046875, 37.9609375), + ], + <Offset>[ + Offset(36.0, 10.0), + Offset(36.06641088760902, 10.22948728425121), + Offset(36.247238824104016, 11.064193308216439), + Offset(36.28013407140091, 12.678189645033275), + Offset(36.797157392346996, 15.26435074467823), + Offset(37.13921494048486, 17.48955786783516), + Offset(37.50494588572052, 19.396691171264223), + Offset(37.71458426282127, 20.746861428770725), + Offset(37.836698108596735, 21.721544214172404), + Offset(37.9088886772507, 22.445327330841877), + Offset(37.95031704814173, 22.992590165603303), + Offset(37.972105758008134, 23.40884699145153), + Offset(37.981644533782294, 23.72356359205216), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(36.0, 10.0), + Offset(36.06641088760902, 10.22948728425121), + Offset(36.247238824104016, 11.064193308216439), + Offset(36.28013407140091, 12.678189645033275), + Offset(36.797157392346996, 15.26435074467823), + Offset(37.13921494048486, 17.48955786783516), + Offset(37.50494588572052, 19.396691171264223), + Offset(37.71458426282127, 20.746861428770725), + Offset(37.836698108596735, 21.721544214172404), + Offset(37.9088886772507, 22.445327330841877), + Offset(37.95031704814173, 22.992590165603303), + Offset(37.972105758008134, 23.40884699145153), + Offset(37.981644533782294, 23.72356359205216), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(36.0, 10.0), + Offset(36.06641088760902, 10.22948728425121), + Offset(36.247238824104016, 11.064193308216439), + Offset(36.28013407140091, 12.678189645033275), + Offset(36.797157392346996, 15.26435074467823), + Offset(37.13921494048486, 17.48955786783516), + Offset(37.50494588572052, 19.396691171264223), + Offset(37.71458426282127, 20.746861428770725), + Offset(37.836698108596735, 21.721544214172404), + Offset(37.9088886772507, 22.445327330841877), + Offset(37.95031704814173, 22.992590165603303), + Offset(37.972105758008134, 23.40884699145153), + Offset(37.981644533782294, 23.72356359205216), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(28.0, 10.0), + Offset(28.201404627403626, 10.078391455203436), + Offset(28.933428294762216, 10.394467165488965), + Offset(30.390192389339767, 11.182335018165535), + Offset(32.965271689771434, 13.029661051628835), + Offset(35.1122456147839, 15.37530033622975), + Offset(36.56214764650002, 17.776144251478037), + Offset(37.30789556803553, 19.656646544512885), + Offset(37.6771839239824, 21.058775572729875), + Offset(37.855103826984866, 22.08925839263133), + Offset(37.936451728819264, 22.83694457063215), + Offset(37.97010078217227, 23.366104539964546), + Offset(37.98162467944048, 23.72256481111895), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + <Offset>[ + Offset(28.0, 10.0), + Offset(28.201404627403626, 10.078391455203436), + Offset(28.933428294762216, 10.394467165488965), + Offset(30.390192389339767, 11.182335018165535), + Offset(32.965271689771434, 13.029661051628835), + Offset(35.1122456147839, 15.37530033622975), + Offset(36.56214764650002, 17.776144251478037), + Offset(37.30789556803553, 19.656646544512885), + Offset(37.6771839239824, 21.058775572729875), + Offset(37.855103826984866, 22.08925839263133), + Offset(37.936451728819264, 22.83694457063215), + Offset(37.97010078217227, 23.366104539964546), + Offset(37.98162467944048, 23.72256481111895), + Offset(37.98420298259533, 23.93063803493896), + Offset(37.98437499812064, 23.99977073325126), + Offset(37.984375, 24.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(25.9803619385, 10.0808715820312), + Offset(26.24734975266162, 10.121477441291603), + Offset(27.241376275815597, 10.319449968588765), + Offset(29.345667299377446, 10.996623113678018), + Offset(32.72280163575925, 12.973817480806794), + Offset(35.41804029052999, 15.796810056618218), + Offset(36.783888674275296, 18.8537388815253), + Offset(36.76701249453052, 21.35111747744179), + Offset(36.26390281517317, 23.085253769933463), + Offset(35.55535514851543, 24.257370049664456), + Offset(34.77089367166791, 25.028704643469656), + Offset(33.96889356105216, 25.514915651546502), + Offset(33.73686027995342, 25.787198268946305), + Offset(33.7091203242374, 25.93222884083115), + Offset(33.69944957998795, 25.98020292120046), + Offset(33.69941711426, 25.9803619385), + ]), + _PathCubicTo( + <Offset>[ + Offset(25.9803619385, 10.0808715820312), + Offset(26.24734975266162, 10.121477441291603), + Offset(27.241376275815597, 10.319449968588765), + Offset(29.345667299377446, 10.996623113678018), + Offset(32.72280163575925, 12.973817480806794), + Offset(35.41804029052999, 15.796810056618218), + Offset(36.783888674275296, 18.8537388815253), + Offset(36.76701249453052, 21.35111747744179), + Offset(36.26390281517317, 23.085253769933463), + Offset(35.55535514851543, 24.257370049664456), + Offset(34.77089367166791, 25.028704643469656), + Offset(33.96889356105216, 25.514915651546502), + Offset(33.73686027995342, 25.787198268946305), + Offset(33.7091203242374, 25.93222884083115), + Offset(33.69944957998795, 25.98020292120046), + Offset(33.69941711426, 25.9803619385), + ], + <Offset>[ + Offset(21.71524047854, 10.08666992187495), + Offset(21.982903763348478, 10.045351931852684), + Offset(22.99349608666543, 9.936293880836264), + Offset(25.21035246628397, 9.952369351341783), + Offset(29.035517231913246, 10.830169092168797), + Offset(32.462182698221135, 12.722041677870582), + Offset(34.6342053830667, 15.169969772143412), + Offset(35.27135627354946, 17.356833870551508), + Offset(35.261093097185864, 18.9396949456921), + Offset(34.91382519935541, 20.040769389846336), + Offset(34.38827629668238, 20.780777793707646), + Offset(33.765232522395, 21.254657700831064), + Offset(33.64837008011561, 21.522993253407158), + Offset(33.68424238621404, 21.667178311445102), + Offset(33.695656510950016, 21.71508152285272), + Offset(33.69569396972875, 21.71524047854), + ], + <Offset>[ + Offset(21.71524047854, 10.08666992187495), + Offset(21.982903763348478, 10.045351931852684), + Offset(22.99349608666543, 9.936293880836264), + Offset(25.21035246628397, 9.952369351341783), + Offset(29.035517231913246, 10.830169092168797), + Offset(32.462182698221135, 12.722041677870582), + Offset(34.6342053830667, 15.169969772143412), + Offset(35.27135627354946, 17.356833870551508), + Offset(35.261093097185864, 18.9396949456921), + Offset(34.91382519935541, 20.040769389846336), + Offset(34.38827629668238, 20.780777793707646), + Offset(33.765232522395, 21.254657700831064), + Offset(33.64837008011561, 21.522993253407158), + Offset(33.68424238621404, 21.667178311445102), + Offset(33.695656510950016, 21.71508152285272), + Offset(33.69569396972875, 21.71524047854), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(21.71524047854, 10.08666992187495), + Offset(21.982903763348478, 10.045351931852684), + Offset(22.99349608666543, 9.936293880836264), + Offset(25.21035246628397, 9.952369351341783), + Offset(29.035517231913246, 10.830169092168797), + Offset(32.462182698221135, 12.722041677870582), + Offset(34.6342053830667, 15.169969772143412), + Offset(35.27135627354946, 17.356833870551508), + Offset(35.261093097185864, 18.9396949456921), + Offset(34.91382519935541, 20.040769389846336), + Offset(34.38827629668238, 20.780777793707646), + Offset(33.765232522395, 21.254657700831064), + Offset(33.64837008011561, 21.522993253407158), + Offset(33.68424238621404, 21.667178311445102), + Offset(33.695656510950016, 21.71508152285272), + Offset(33.69569396972875, 21.71524047854), + ], + <Offset>[ + Offset(20.38534545901, 33.58843994137495), + Offset(20.201841309352226, 33.51724216990988), + Offset(19.52604939723053, 33.21887595990057), + Offset(18.136339024380444, 32.40365324862053), + Offset(16.04712654551591, 30.461865967119433), + Offset(14.577060238240236, 28.0264269776442), + Offset(13.994487095033097, 25.634345790525074), + Offset(14.153634069120049, 23.79893867630368), + Offset(14.553527226932179, 22.528826743801503), + Offset(15.025384555247236, 21.662436101573526), + Offset(15.497710938825563, 21.080134472962335), + Offset(15.942008679892364, 20.700182681641447), + Offset(16.112739485629255, 20.479547499155572), + Offset(16.16612590845245, 20.362297297739467), + Offset(16.184204863198634, 20.323614463660565), + Offset(16.18426513672875, 20.323486328150004), + ], + <Offset>[ + Offset(20.38534545901, 33.58843994137495), + Offset(20.201841309352226, 33.51724216990988), + Offset(19.52604939723053, 33.21887595990057), + Offset(18.136339024380444, 32.40365324862053), + Offset(16.04712654551591, 30.461865967119433), + Offset(14.577060238240236, 28.0264269776442), + Offset(13.994487095033097, 25.634345790525074), + Offset(14.153634069120049, 23.79893867630368), + Offset(14.553527226932179, 22.528826743801503), + Offset(15.025384555247236, 21.662436101573526), + Offset(15.497710938825563, 21.080134472962335), + Offset(15.942008679892364, 20.700182681641447), + Offset(16.112739485629255, 20.479547499155572), + Offset(16.16612590845245, 20.362297297739467), + Offset(16.184204863198634, 20.323614463660565), + Offset(16.18426513672875, 20.323486328150004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(20.38534545901, 33.58843994137495), + Offset(20.201841309352226, 33.51724216990988), + Offset(19.52604939723053, 33.21887595990057), + Offset(18.136339024380444, 32.40365324862053), + Offset(16.04712654551591, 30.461865967119433), + Offset(14.577060238240236, 28.0264269776442), + Offset(13.994487095033097, 25.634345790525074), + Offset(14.153634069120049, 23.79893867630368), + Offset(14.553527226932179, 22.528826743801503), + Offset(15.025384555247236, 21.662436101573526), + Offset(15.497710938825563, 21.080134472962335), + Offset(15.942008679892364, 20.700182681641447), + Offset(16.112739485629255, 20.479547499155572), + Offset(16.16612590845245, 20.362297297739467), + Offset(16.184204863198634, 20.323614463660565), + Offset(16.18426513672875, 20.323486328150004), + ], + <Offset>[ + Offset(26.45292663577, 33.61682128903115), + Offset(26.267757982058896, 33.66216200032068), + Offset(25.565762818520753, 33.800433030319155), + Offset(24.010240085864943, 33.92471641540826), + Offset(21.274220812328387, 33.54307228337337), + Offset(18.755635225924188, 32.42595626039356), + Offset(17.02173119623894, 30.892872384129042), + Offset(16.25004855832711, 29.492905492037444), + Offset(15.94990552834042, 28.433591685898556), + Offset(15.909598267598703, 27.665285725985097), + Offset(16.015710329110547, 27.125598624037742), + Offset(16.207680408412312, 26.761973499405), + Offset(16.21513039026629, 26.546291998077596), + Offset(16.178017367555142, 26.42989412892011), + Offset(16.166100602771248, 26.391195938046174), + Offset(16.16606140137715, 26.391067504910005), + ], + <Offset>[ + Offset(26.45292663577, 33.61682128903125), + Offset(26.267757982058896, 33.66216200032078), + Offset(25.565762818520742, 33.800433030319255), + Offset(24.010240085864922, 33.924716415408355), + Offset(21.274220812328338, 33.543072283373455), + Offset(18.75563522592411, 32.42595626039363), + Offset(17.02173119623894, 30.892872384129042), + Offset(16.25004855832711, 29.492905492037444), + Offset(15.94990552834032, 28.433591685898577), + Offset(15.909598267598604, 27.66528572598511), + Offset(16.015710329110444, 27.12559862403775), + Offset(16.207680408412212, 26.761973499405002), + Offset(16.21513039026629, 26.546291998077596), + Offset(16.178017367555142, 26.42989412892011), + Offset(16.166100602771248, 26.391195938046174), + Offset(16.16606140137715, 26.391067504910005), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(26.45292663577, 33.61682128903125), + Offset(26.267757982058896, 33.66216200032078), + Offset(25.565762818520742, 33.800433030319255), + Offset(24.010240085864922, 33.924716415408355), + Offset(21.274220812328338, 33.543072283373455), + Offset(18.75563522592411, 32.42595626039363), + Offset(17.02173119623894, 30.892872384129042), + Offset(16.25004855832711, 29.492905492037444), + Offset(15.94990552834032, 28.433591685898577), + Offset(15.909598267598604, 27.66528572598511), + Offset(16.015710329110444, 27.12559862403775), + Offset(16.207680408412212, 26.761973499405002), + Offset(16.21513039026629, 26.546291998077596), + Offset(16.178017367555142, 26.42989412892011), + Offset(16.166100602771248, 26.391195938046174), + Offset(16.16606140137715, 26.391067504910005), + ], + <Offset>[ + Offset(25.980361938504004, 10.08087158203125), + Offset(26.24734975266562, 10.121477441291729), + Offset(27.241376275819576, 10.319449968589181), + Offset(29.34566729938131, 10.996623113679052), + Offset(32.72280163576268, 12.973817480808854), + Offset(35.418040290532716, 15.796810056621142), + Offset(36.78388867431184, 18.85373888150635), + Offset(36.7670124944848, 21.351117477459912), + Offset(36.26390281513503, 23.08525376994367), + Offset(35.55535514848602, 24.2573700496689), + Offset(34.770893671747466, 25.028704643461566), + Offset(33.968893561041796, 25.51491565154599), + Offset(33.73686027997065, 25.787198268949965), + Offset(33.70912032425456, 25.93222884083507), + Offset(33.6994495800051, 25.98020292120446), + Offset(33.69941711427715, 25.980361938504004), + ], + <Offset>[ + Offset(25.980361938504004, 10.08087158203125), + Offset(26.24734975266562, 10.121477441291729), + Offset(27.241376275819576, 10.319449968589181), + Offset(29.34566729938131, 10.996623113679052), + Offset(32.72280163576268, 12.973817480808854), + Offset(35.418040290532716, 15.796810056621142), + Offset(36.78388867431184, 18.85373888150635), + Offset(36.7670124944848, 21.351117477459912), + Offset(36.26390281513503, 23.08525376994367), + Offset(35.55535514848602, 24.2573700496689), + Offset(34.770893671747466, 25.028704643461566), + Offset(33.968893561041796, 25.51491565154599), + Offset(33.73686027997065, 25.787198268949965), + Offset(33.70912032425456, 25.93222884083507), + Offset(33.6994495800051, 25.98020292120446), + Offset(33.69941711427715, 25.980361938504004), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/play_pause.g.dart b/packages/material_ui/lib/src/animated_icons/data/play_pause.g.dart new file mode 100644 index 000000000000..2ecdc471599a --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/play_pause.g.dart @@ -0,0 +1,805 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$play_pause = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(16.046875, 10.039062500000002), + Offset(16.316498427194905, 9.888877552610037), + Offset(17.350168694919763, 9.372654593279519), + Offset(19.411307079826894, 8.531523285503246), + Offset(22.581365240485308, 7.589125591600418), + Offset(25.499178877190392, 6.946027752843147), + Offset(28.464059662259196, 6.878006546805963), + Offset(30.817518246129985, 7.278084288616373), + Offset(32.55729037951853, 7.8522502852455425), + Offset(33.815177617779455, 8.44633949301522), + Offset(34.712260860180656, 8.99474841944718), + Offset(35.33082450786742, 9.453096000457315), + Offset(35.71938467416858, 9.764269500343072), + Offset(35.93041292728106, 9.940652668613495), + Offset(35.999770475547926, 9.999803268019111), + Offset(36.0, 10.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(16.046875, 10.039062500000002), + Offset(16.316498427194905, 9.888877552610037), + Offset(17.350168694919763, 9.372654593279519), + Offset(19.411307079826894, 8.531523285503246), + Offset(22.581365240485308, 7.589125591600418), + Offset(25.499178877190392, 6.946027752843147), + Offset(28.464059662259196, 6.878006546805963), + Offset(30.817518246129985, 7.278084288616373), + Offset(32.55729037951853, 7.8522502852455425), + Offset(33.815177617779455, 8.44633949301522), + Offset(34.712260860180656, 8.99474841944718), + Offset(35.33082450786742, 9.453096000457315), + Offset(35.71938467416858, 9.764269500343072), + Offset(35.93041292728106, 9.940652668613495), + Offset(35.999770475547926, 9.999803268019111), + Offset(36.0, 10.0), + ], + <Offset>[ + Offset(16.046875, 24.0), + Offset(16.048342217256838, 23.847239495401816), + Offset(16.077346902872737, 23.272630763824544), + Offset(16.048056811677085, 21.774352893256555), + Offset(16.312852147291277, 18.33792251536507), + Offset(17.783803270262858, 14.342870123090869), + Offset(20.317723014778526, 11.617364447163006), + Offset(22.6612333095366, 10.320666923510533), + Offset(24.489055761050455, 9.794101160418514), + Offset(25.820333134665205, 9.653975058221658), + Offset(26.739449095852216, 9.704987479092615), + Offset(27.339611564620206, 9.827950233030684), + Offset(27.720964836869285, 9.92326668993185), + Offset(27.930511332768496, 9.98033236260651), + Offset(27.999770476623045, 9.999934423927339), + Offset(27.999999999999996, 10.0), + ], + <Offset>[ + Offset(16.046875, 24.0), + Offset(16.048342217256838, 23.847239495401816), + Offset(16.077346902872737, 23.272630763824544), + Offset(16.048056811677085, 21.774352893256555), + Offset(16.312852147291277, 18.33792251536507), + Offset(17.783803270262858, 14.342870123090869), + Offset(20.317723014778526, 11.617364447163006), + Offset(22.6612333095366, 10.320666923510533), + Offset(24.489055761050455, 9.794101160418514), + Offset(25.820333134665205, 9.653975058221658), + Offset(26.739449095852216, 9.704987479092615), + Offset(27.339611564620206, 9.827950233030684), + Offset(27.720964836869285, 9.92326668993185), + Offset(27.930511332768496, 9.98033236260651), + Offset(27.999770476623045, 9.999934423927339), + Offset(27.999999999999996, 10.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.046875, 24.0), + Offset(16.048342217256838, 23.847239495401816), + Offset(16.077346902872737, 23.272630763824544), + Offset(16.048056811677085, 21.774352893256555), + Offset(16.312852147291277, 18.33792251536507), + Offset(17.783803270262858, 14.342870123090869), + Offset(20.317723014778526, 11.617364447163006), + Offset(22.6612333095366, 10.320666923510533), + Offset(24.489055761050455, 9.794101160418514), + Offset(25.820333134665205, 9.653975058221658), + Offset(26.739449095852216, 9.704987479092615), + Offset(27.339611564620206, 9.827950233030684), + Offset(27.720964836869285, 9.92326668993185), + Offset(27.930511332768496, 9.98033236260651), + Offset(27.999770476623045, 9.999934423927339), + Offset(27.999999999999996, 10.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92629019604922, 25.273340032354483), + Offset(37.60401862920776, 27.24886978355857), + Offset(36.59673961336577, 30.16713606026377), + Offset(35.26901818749416, 32.58105797429066), + Offset(33.66938906523204, 34.56713290494057), + Offset(32.196778918797094, 35.8827095523761), + Offset(30.969894470496282, 36.721466129987085), + Offset(29.989349224706995, 37.25388702486493), + Offset(29.223528593231507, 37.59010302049878), + Offset(28.651601378627003, 37.79719553439594), + Offset(28.27745500043001, 37.91773612047938), + Offset(28.069390261744058, 37.979987943400474), + Offset(28.000229522301836, 37.99993442016443), + Offset(28.0, 38.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92629019604922, 25.273340032354483), + Offset(37.60401862920776, 27.24886978355857), + Offset(36.59673961336577, 30.16713606026377), + Offset(35.26901818749416, 32.58105797429066), + Offset(33.66938906523204, 34.56713290494057), + Offset(32.196778918797094, 35.8827095523761), + Offset(30.969894470496282, 36.721466129987085), + Offset(29.989349224706995, 37.25388702486493), + Offset(29.223528593231507, 37.59010302049878), + Offset(28.651601378627003, 37.79719553439594), + Offset(28.27745500043001, 37.91773612047938), + Offset(28.069390261744058, 37.979987943400474), + Offset(28.000229522301836, 37.99993442016443), + Offset(28.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92629019604922, 25.273340032354483), + Offset(37.60401862920776, 27.24886978355857), + Offset(36.59673961336577, 30.16713606026377), + Offset(35.26901818749416, 32.58105797429066), + Offset(33.66938906523204, 34.56713290494057), + Offset(32.196778918797094, 35.8827095523761), + Offset(30.969894470496282, 36.721466129987085), + Offset(29.989349224706995, 37.25388702486493), + Offset(29.223528593231507, 37.59010302049878), + Offset(28.651601378627003, 37.79719553439594), + Offset(28.27745500043001, 37.91773612047938), + Offset(28.069390261744058, 37.979987943400474), + Offset(28.000229522301836, 37.99993442016443), + Offset(28.0, 38.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92663369548548, 25.26958881281347), + Offset(37.702366207906195, 26.86162526614268), + Offset(37.62294586290445, 28.407471142252255), + Offset(38.43944238184115, 29.541526367903558), + Offset(38.93163276984633, 31.5056762828673), + Offset(38.80537374713073, 33.4174700441868), + Offset(38.35814295213548, 34.94327332096457), + Offset(37.78610517302408, 36.076173087300646), + Offset(37.186112675124534, 36.8807750697281), + Offset(36.64281432187422, 37.42234130182257), + Offset(36.275874837729305, 37.7587389308906), + Offset(36.06929185625662, 37.94030824940746), + Offset(36.00022952122672, 37.9998032642562), + Offset(36.0, 38.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92663369548548, 25.26958881281347), + Offset(37.702366207906195, 26.86162526614268), + Offset(37.62294586290445, 28.407471142252255), + Offset(38.43944238184115, 29.541526367903558), + Offset(38.93163276984633, 31.5056762828673), + Offset(38.80537374713073, 33.4174700441868), + Offset(38.35814295213548, 34.94327332096457), + Offset(37.78610517302408, 36.076173087300646), + Offset(37.186112675124534, 36.8807750697281), + Offset(36.64281432187422, 37.42234130182257), + Offset(36.275874837729305, 37.7587389308906), + Offset(36.06929185625662, 37.94030824940746), + Offset(36.00022952122672, 37.9998032642562), + Offset(36.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92663369548548, 25.26958881281347), + Offset(37.702366207906195, 26.86162526614268), + Offset(37.62294586290445, 28.407471142252255), + Offset(38.43944238184115, 29.541526367903558), + Offset(38.93163276984633, 31.5056762828673), + Offset(38.80537374713073, 33.4174700441868), + Offset(38.35814295213548, 34.94327332096457), + Offset(37.78610517302408, 36.076173087300646), + Offset(37.186112675124534, 36.8807750697281), + Offset(36.64281432187422, 37.42234130182257), + Offset(36.275874837729305, 37.7587389308906), + Offset(36.06929185625662, 37.94030824940746), + Offset(36.00022952122672, 37.9998032642562), + Offset(36.0, 38.0), + ], + <Offset>[ + Offset(16.046875, 10.039062500000002), + Offset(16.316498427194905, 9.888877552610037), + Offset(17.35016869491465, 9.372654593335355), + Offset(19.411307079839695, 8.531523285452844), + Offset(22.58136524050546, 7.589125591565864), + Offset(25.499178877175954, 6.946027752856988), + Offset(28.464059662259196, 6.878006546805963), + Offset(30.817518246129985, 7.278084288616373), + Offset(32.55729037951755, 7.852250285245777), + Offset(33.81517761778539, 8.446339493014325), + Offset(34.71226086018563, 8.994748419446736), + Offset(35.33082450786742, 9.453096000457315), + Offset(35.71938467416858, 9.764269500343072), + Offset(35.93041292728106, 9.940652668613495), + Offset(35.999770475547926, 9.999803268019111), + Offset(36.0, 10.0), + ], + <Offset>[ + Offset(16.046875, 10.039062500000002), + Offset(16.316498427194905, 9.888877552610037), + Offset(17.35016869491465, 9.372654593335355), + Offset(19.411307079839695, 8.531523285452844), + Offset(22.58136524050546, 7.589125591565864), + Offset(25.499178877175954, 6.946027752856988), + Offset(28.464059662259196, 6.878006546805963), + Offset(30.817518246129985, 7.278084288616373), + Offset(32.55729037951755, 7.852250285245777), + Offset(33.81517761778539, 8.446339493014325), + Offset(34.71226086018563, 8.994748419446736), + Offset(35.33082450786742, 9.453096000457315), + Offset(35.71938467416858, 9.764269500343072), + Offset(35.93041292728106, 9.940652668613495), + Offset(35.999770475547926, 9.999803268019111), + Offset(36.0, 10.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.925946696573504, 25.277091251817644), + Offset(37.50567105053561, 27.636114300999704), + Offset(35.57053336387648, 31.926800978315658), + Offset(32.09859399311199, 35.6205895806324), + Offset(28.407145360613207, 37.6285895270458), + Offset(25.588184090469714, 38.34794906057932), + Offset(23.581645988882627, 38.49965893899394), + Offset(22.19259327642332, 38.43160096243417), + Offset(21.26094464377359, 38.29943245748053), + Offset(20.660388435379787, 38.17204976696931), + Offset(20.279035163130715, 38.07673331006816), + Offset(20.069488667231496, 38.01966763739349), + Offset(20.000229523376955, 38.00006557607266), + Offset(20.0, 38.0), + ]), + _PathCubicTo( + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.925946696573504, 25.277091251817644), + Offset(37.50567105053561, 27.636114300999704), + Offset(35.57053336387648, 31.926800978315658), + Offset(32.09859399311199, 35.6205895806324), + Offset(28.407145360613207, 37.6285895270458), + Offset(25.588184090469714, 38.34794906057932), + Offset(23.581645988882627, 38.49965893899394), + Offset(22.19259327642332, 38.43160096243417), + Offset(21.26094464377359, 38.29943245748053), + Offset(20.660388435379787, 38.17204976696931), + Offset(20.279035163130715, 38.07673331006816), + Offset(20.069488667231496, 38.01966763739349), + Offset(20.000229523376955, 38.00006557607266), + Offset(20.0, 38.0), + ], + <Offset>[ + Offset(16.046875, 24.0), + Offset(16.048342217256838, 23.847239495401816), + Offset(16.077003403397015, 23.276381983287706), + Offset(15.949709233004938, 22.161597410697688), + Offset(15.286645897801982, 20.097587433416958), + Offset(14.613379075880687, 17.38240172943261), + Offset(15.05547931015969, 14.678821069268237), + Offset(16.052638481209218, 12.785906431713748), + Offset(17.100807279436804, 11.57229396942536), + Offset(18.02357718638153, 10.831688995790898), + Offset(18.7768651463943, 10.414316916074366), + Offset(19.34839862137299, 10.202804465604057), + Offset(19.722544999569994, 10.082263879520628), + Offset(19.93060973825594, 10.02001205659953), + Offset(19.99977047769816, 10.000065579835564), + Offset(19.999999999999996, 10.000000000000004), + ], + <Offset>[ + Offset(16.046875, 24.0), + Offset(16.048342217256838, 23.847239495401816), + Offset(16.077003403397015, 23.276381983287706), + Offset(15.949709233004938, 22.161597410697688), + Offset(15.286645897801982, 20.097587433416958), + Offset(14.613379075880687, 17.38240172943261), + Offset(15.05547931015969, 14.678821069268237), + Offset(16.052638481209218, 12.785906431713748), + Offset(17.100807279436804, 11.57229396942536), + Offset(18.02357718638153, 10.831688995790898), + Offset(18.7768651463943, 10.414316916074366), + Offset(19.34839862137299, 10.202804465604057), + Offset(19.722544999569994, 10.082263879520628), + Offset(19.93060973825594, 10.02001205659953), + Offset(19.99977047769816, 10.000065579835564), + Offset(19.999999999999996, 10.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.046875, 24.0), + Offset(16.048342217256838, 23.847239495401816), + Offset(16.077003403397015, 23.276381983287706), + Offset(15.949709233004938, 22.161597410697688), + Offset(15.286645897801982, 20.097587433416958), + Offset(14.613379075880687, 17.38240172943261), + Offset(15.05547931015969, 14.678821069268237), + Offset(16.052638481209218, 12.785906431713748), + Offset(17.100807279436804, 11.57229396942536), + Offset(18.02357718638153, 10.831688995790898), + Offset(18.7768651463943, 10.414316916074366), + Offset(19.34839862137299, 10.202804465604057), + Offset(19.722544999569994, 10.082263879520628), + Offset(19.93060973825594, 10.02001205659953), + Offset(19.99977047769816, 10.000065579835564), + Offset(19.999999999999996, 10.000000000000004), + ], + <Offset>[ + Offset(16.046875, 37.9609375), + Offset(15.780186007318768, 37.8056014381936), + Offset(14.804181611349989, 37.17635815383272), + Offset(12.58645896485513, 35.404427018450995), + Offset(9.018132804607959, 30.846384357181606), + Offset(6.898003468953149, 24.77924409968033), + Offset(6.909142662679017, 19.41817896962528), + Offset(7.8963535446158275, 15.828489066607908), + Offset(9.032572660968736, 13.51414484459833), + Offset(10.02873270326728, 12.039324560997336), + Offset(10.80405338206586, 11.124555975719801), + Offset(11.357185678125777, 10.577658698177427), + Offset(11.724125162270699, 10.241261069109406), + Offset(11.930708143743377, 10.059691750592545), + Offset(11.999770478773279, 10.000196735743792), + Offset(11.999999999999996, 10.000000000000004), + ], + <Offset>[ + Offset(16.046875, 37.9609375), + Offset(15.780186007318768, 37.8056014381936), + Offset(14.804181611349989, 37.17635815383272), + Offset(12.58645896485513, 35.404427018450995), + Offset(9.018132804607959, 30.846384357181606), + Offset(6.898003468953149, 24.77924409968033), + Offset(6.909142662679017, 19.41817896962528), + Offset(7.8963535446158275, 15.828489066607908), + Offset(9.032572660968736, 13.51414484459833), + Offset(10.02873270326728, 12.039324560997336), + Offset(10.80405338206586, 11.124555975719801), + Offset(11.357185678125777, 10.577658698177427), + Offset(11.724125162270699, 10.241261069109406), + Offset(11.930708143743377, 10.059691750592545), + Offset(11.999770478773279, 10.000196735743792), + Offset(11.999999999999996, 10.000000000000004), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.046875, 37.9609375), + Offset(15.780186007318768, 37.8056014381936), + Offset(14.804181611349989, 37.17635815383272), + Offset(12.58645896485513, 35.404427018450995), + Offset(9.018132804607959, 30.846384357181606), + Offset(6.898003468953149, 24.77924409968033), + Offset(6.909142662679017, 19.41817896962528), + Offset(7.8963535446158275, 15.828489066607908), + Offset(9.032572660968736, 13.51414484459833), + Offset(10.02873270326728, 12.039324560997336), + Offset(10.80405338206586, 11.124555975719801), + Offset(11.357185678125777, 10.577658698177427), + Offset(11.724125162270699, 10.241261069109406), + Offset(11.930708143743377, 10.059691750592545), + Offset(11.999770478773279, 10.000196735743792), + Offset(11.999999999999996, 10.000000000000004), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92560319713213, 25.28084247141449), + Offset(37.40732347184997, 28.02335881836519), + Offset(34.544327114357955, 33.68646589629262), + Offset(28.928169798750567, 38.66012118703334), + Offset(23.144901655998915, 40.69004614911907), + Offset(18.979589262136074, 40.81318856876862), + Offset(16.193397507242462, 40.27785174801669), + Offset(14.395837328112165, 39.60931489999756), + Offset(13.298360561885538, 39.008760408250765), + Offset(12.669175492132574, 38.546903999542685), + Offset(12.280615325831423, 38.23573049965694), + Offset(12.069587072718935, 38.05934733138651), + Offset(12.000229524452074, 38.00019673198088), + Offset(12.0, 38.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92560319713213, 25.28084247141449), + Offset(37.40732347184997, 28.02335881836519), + Offset(34.544327114357955, 33.68646589629262), + Offset(28.928169798750567, 38.66012118703334), + Offset(23.144901655998915, 40.69004614911907), + Offset(18.979589262136074, 40.81318856876862), + Offset(16.193397507242462, 40.27785174801669), + Offset(14.395837328112165, 39.60931489999756), + Offset(13.298360561885538, 39.008760408250765), + Offset(12.669175492132574, 38.546903999542685), + Offset(12.280615325831423, 38.23573049965694), + Offset(12.069587072718935, 38.05934733138651), + Offset(12.000229524452074, 38.00019673198088), + Offset(12.0, 38.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92560319713213, 25.28084247141449), + Offset(37.40732347184997, 28.02335881836519), + Offset(34.544327114357955, 33.68646589629262), + Offset(28.928169798750567, 38.66012118703334), + Offset(23.144901655998915, 40.69004614911907), + Offset(18.979589262136074, 40.81318856876862), + Offset(16.193397507242462, 40.27785174801669), + Offset(14.395837328112165, 39.60931489999756), + Offset(13.298360561885538, 39.008760408250765), + Offset(12.669175492132574, 38.546903999542685), + Offset(12.280615325831423, 38.23573049965694), + Offset(12.069587072718935, 38.05934733138651), + Offset(12.000229524452074, 38.00019673198088), + Offset(12.0, 38.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92594669656839, 25.27709125187348), + Offset(37.50567105054841, 27.636114300949302), + Offset(35.57053336389663, 31.9268009782811), + Offset(32.09859399309755, 35.62058958064624), + Offset(28.407145360613207, 37.628589527045804), + Offset(25.588184090469714, 38.34794906057932), + Offset(23.58164598888166, 38.49965893899417), + Offset(22.192593276429257, 38.43160096243327), + Offset(21.260944643778565, 38.29943245748009), + Offset(20.660388435379787, 38.17204976696931), + Offset(20.279035163130715, 38.07673331006816), + Offset(20.069488667231496, 38.01966763739349), + Offset(20.000229523376955, 38.00006557607266), + Offset(20.0, 38.0), + ], + <Offset>[ + Offset(37.984375, 24.0), + Offset(37.98179511896882, 24.268606388242382), + Offset(37.92594669656839, 25.27709125187348), + Offset(37.50567105054841, 27.636114300949302), + Offset(35.57053336389663, 31.9268009782811), + Offset(32.09859399309755, 35.62058958064624), + Offset(28.407145360613207, 37.628589527045804), + Offset(25.588184090469714, 38.34794906057932), + Offset(23.58164598888166, 38.49965893899417), + Offset(22.192593276429257, 38.43160096243327), + Offset(21.260944643778565, 38.29943245748009), + Offset(20.660388435379787, 38.17204976696931), + Offset(20.279035163130715, 38.07673331006816), + Offset(20.069488667231496, 38.01966763739349), + Offset(20.000229523376955, 38.00006557607266), + Offset(20.0, 38.0), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 0.733333333333, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(36.21875, 24.387283325200002), + Offset(36.858953419818775, 24.63439009154731), + Offset(37.42714268809582, 25.618428032998864), + Offset(37.46673246436919, 27.957602694496682), + Offset(35.51445214909996, 31.937043103050268), + Offset(32.888668544302234, 34.79679735028506), + Offset(30.100083850883422, 36.58444430738925), + Offset(27.884884986535624, 37.434542424473584), + Offset(26.23678799810123, 37.80492814052796), + Offset(25.03902259291319, 37.946314694750235), + Offset(24.185908910024594, 37.98372980970255), + Offset(23.59896217337824, 37.97921421880389), + Offset(23.221743554700737, 37.96329396736102), + Offset(23.013561704380457, 37.95013265178958), + Offset(22.94461033630511, 37.9450856638228), + Offset(22.9443817139, 37.945068359375), + ]), + _PathCubicTo( + <Offset>[ + Offset(36.21875, 24.387283325200002), + Offset(36.858953419818775, 24.63439009154731), + Offset(37.42714268809582, 25.618428032998864), + Offset(37.46673246436919, 27.957602694496682), + Offset(35.51445214909996, 31.937043103050268), + Offset(32.888668544302234, 34.79679735028506), + Offset(30.100083850883422, 36.58444430738925), + Offset(27.884884986535624, 37.434542424473584), + Offset(26.23678799810123, 37.80492814052796), + Offset(25.03902259291319, 37.946314694750235), + Offset(24.185908910024594, 37.98372980970255), + Offset(23.59896217337824, 37.97921421880389), + Offset(23.221743554700737, 37.96329396736102), + Offset(23.013561704380457, 37.95013265178958), + Offset(22.94461033630511, 37.9450856638228), + Offset(22.9443817139, 37.945068359375), + ], + <Offset>[ + Offset(36.1819000244141, 23.597152709966), + Offset(36.8358384608093, 23.843669618675563), + Offset(37.45961204802207, 24.827964901265894), + Offset(37.71106940406011, 26.916549745564488), + Offset(36.67279396166709, 30.08280087402087), + Offset(34.51215067847019, 33.33246277147643), + Offset(32.022419367141104, 35.54300484126963), + Offset(29.955608739426065, 36.73306317469314), + Offset(28.376981306736234, 37.3582262261251), + Offset(27.209745307333925, 37.68567529681684), + Offset(26.368492376458054, 37.856060664218916), + Offset(25.784980483216092, 37.94324273411291), + Offset(25.407936267815487, 37.98634651128109), + Offset(25.199167384595825, 38.0057906185826), + Offset(25.129914160588893, 38.01154763962766), + Offset(25.129684448280003, 38.0115661621094), + ], + <Offset>[ + Offset(36.1819000244141, 23.597152709966), + Offset(36.8358384608093, 23.843669618675563), + Offset(37.45961204802207, 24.827964901265894), + Offset(37.71106940406011, 26.916549745564488), + Offset(36.67279396166709, 30.08280087402087), + Offset(34.51215067847019, 33.33246277147643), + Offset(32.022419367141104, 35.54300484126963), + Offset(29.955608739426065, 36.73306317469314), + Offset(28.376981306736234, 37.3582262261251), + Offset(27.209745307333925, 37.68567529681684), + Offset(26.368492376458054, 37.856060664218916), + Offset(25.784980483216092, 37.94324273411291), + Offset(25.407936267815487, 37.98634651128109), + Offset(25.199167384595825, 38.0057906185826), + Offset(25.129914160588893, 38.01154763962766), + Offset(25.129684448280003, 38.0115661621094), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(36.1819000244141, 23.597152709966), + Offset(36.8358384608093, 23.843669618675563), + Offset(37.45961204802207, 24.827964901265894), + Offset(37.71106940406011, 26.916549745564488), + Offset(36.67279396166709, 30.08280087402087), + Offset(34.51215067847019, 33.33246277147643), + Offset(32.022419367141104, 35.54300484126963), + Offset(29.955608739426065, 36.73306317469314), + Offset(28.376981306736234, 37.3582262261251), + Offset(27.209745307333925, 37.68567529681684), + Offset(26.368492376458054, 37.856060664218916), + Offset(25.784980483216092, 37.94324273411291), + Offset(25.407936267815487, 37.98634651128109), + Offset(25.199167384595825, 38.0057906185826), + Offset(25.129914160588893, 38.01154763962766), + Offset(25.129684448280003, 38.0115661621094), + ], + <Offset>[ + Offset(16.1149902344141, 22.955383300786004), + Offset(15.997629933953313, 22.801455805116497), + Offset(15.966446205406928, 22.215379763234004), + Offset(16.088459709151728, 20.876736411055298), + Offset(16.769441289779344, 18.37084947089115), + Offset(18.595653610551377, 16.59990844352802), + Offset(20.48764499639903, 15.536450078720307), + Offset(21.968961727208672, 15.064497861016925), + Offset(23.06110116092593, 14.884804779309462), + Offset(23.849967628988242, 14.837805654268031), + Offset(24.40943781230773, 14.84572910499329), + Offset(24.793207208324446, 14.870972819299066), + Offset(25.03935354219434, 14.895712045654406), + Offset(25.1750322217718, 14.912227213496571), + Offset(25.21994388130627, 14.918147112632923), + Offset(25.220092773475297, 14.9181671142094), + ], + <Offset>[ + Offset(16.170043945314102, 22.942321777349), + Offset(16.055083258838646, 22.789495616149246), + Offset(16.026762188208856, 22.207786731939372), + Offset(16.150920741832245, 20.879123319500057), + Offset(16.82882476693832, 18.390360508490243), + Offset(18.647384744725734, 16.634993592875272), + Offset(20.52967353640347, 15.58271755944683), + Offset(22.002563841255288, 15.117204368008782), + Offset(23.0881035089048, 14.941178098808251), + Offset(23.872012376061566, 14.896295884855345), + Offset(24.42787166552447, 14.90545574061985), + Offset(24.80911858591767, 14.931420366898372), + Offset(25.053627357583, 14.956567087696417), + Offset(25.188396770682292, 14.973288385939487), + Offset(25.233006406883348, 14.979273607487709), + Offset(25.233154296913, 14.9792938232094), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.225097656251602, 22.9292602539115), + Offset(16.112536583755883, 22.7775354271821), + Offset(16.087078170937534, 22.200193700637527), + Offset(16.213381774594694, 20.88151022796511), + Offset(16.888208244083728, 18.409871546081646), + Offset(18.699115878889145, 16.67007874221141), + Offset(20.571702076399895, 15.628985040159975), + Offset(22.03616595529626, 15.16991087498609), + Offset(23.115105856879826, 14.997551418291916), + Offset(23.894057123132363, 14.954786115427265), + Offset(24.446305518739628, 14.965182376230889), + Offset(24.825029963509966, 14.9918679144821), + Offset(25.067901172971148, 15.017422129722831), + Offset(25.201761319592507, 15.034349558366799), + Offset(25.24606893246022, 15.040400102326899), + Offset(25.2462158203505, 15.0404205321938), + ], + <Offset>[ + Offset(16.172653198243793, 25.050704956059), + Offset(16.017298096111325, 24.897541931224776), + Offset(15.837305455486472, 24.307642370134865), + Offset(15.617771431142284, 23.034739327639596), + Offset(15.534079923477577, 20.72510957725349), + Offset(16.76065281331448, 18.52381863579275), + Offset(18.25163791556585, 16.97482787617967), + Offset(19.521978435885586, 16.104176237124552), + Offset(20.506617505527394, 15.621874388004521), + Offset(21.24147683283453, 15.352037236477383), + Offset(21.774425023577333, 15.199799658679147), + Offset(22.14565785051594, 15.114161535583197), + Offset(22.386204205776483, 15.067342323943635), + Offset(22.519618086537456, 15.044265557010121), + Offset(22.563909453457644, 15.037056623787358), + Offset(22.564056396523, 15.0370330810219), + ], + <Offset>[ + Offset(16.172653198243804, 25.050704956059), + Offset(16.017298096111343, 24.89754193122478), + Offset(15.837305455486483, 24.307642370134865), + Offset(15.617771431142284, 23.034739327639596), + Offset(15.534079923477577, 20.72510957725349), + Offset(16.76065281331448, 18.52381863579275), + Offset(18.25163791556585, 16.97482787617967), + Offset(19.521978435885586, 16.104176237124552), + Offset(20.506617505527394, 15.621874388004521), + Offset(21.24147683283453, 15.352037236477383), + Offset(21.774425023577333, 15.199799658679147), + Offset(22.14565785051594, 15.114161535583197), + Offset(22.386204205776483, 15.067342323943635), + Offset(22.519618086537456, 15.044265557010121), + Offset(22.563909453457644, 15.037056623787358), + Offset(22.564056396523, 15.0370330810219), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.172653198243804, 25.050704956059), + Offset(16.017298096111343, 24.89754193122478), + Offset(15.837305455486483, 24.307642370134865), + Offset(15.617771431142284, 23.034739327639596), + Offset(15.534079923477577, 20.72510957725349), + Offset(16.76065281331448, 18.52381863579275), + Offset(18.25163791556585, 16.97482787617967), + Offset(19.521978435885586, 16.104176237124552), + Offset(20.506617505527394, 15.621874388004521), + Offset(21.24147683283453, 15.352037236477383), + Offset(21.774425023577333, 15.199799658679147), + Offset(22.14565785051594, 15.114161535583197), + Offset(22.386204205776483, 15.067342323943635), + Offset(22.519618086537456, 15.044265557010121), + Offset(22.563909453457644, 15.037056623787358), + Offset(22.564056396523, 15.0370330810219), + ], + <Offset>[ + Offset(36.218750000043805, 24.387283325200002), + Offset(36.858953419751415, 24.634390091546017), + Offset(37.42714268811728, 25.61842803300083), + Offset(37.46673246430412, 27.95760269448635), + Offset(35.51445214905712, 31.937043103018333), + Offset(32.88866854426982, 34.79679735024258), + Offset(30.100083850861907, 36.584444307340334), + Offset(27.884884986522685, 37.434542424421736), + Offset(26.23678799809464, 37.80492814047493), + Offset(25.039022592911195, 37.94631469469684), + Offset(24.185908910025862, 37.983729809649134), + Offset(23.59896217338175, 37.97921421875057), + Offset(23.221743554705682, 37.96329396730781), + Offset(23.0135617043862, 37.95013265173645), + Offset(22.94461033631111, 37.9450856637697), + Offset(22.944381713906004, 37.9450683593219), + ], + <Offset>[ + Offset(36.218750000043805, 24.387283325200002), + Offset(36.858953419751415, 24.634390091546017), + Offset(37.42714268811728, 25.61842803300083), + Offset(37.46673246430412, 27.95760269448635), + Offset(35.51445214905712, 31.937043103018333), + Offset(32.88866854426982, 34.79679735024258), + Offset(30.100083850861907, 36.584444307340334), + Offset(27.884884986522685, 37.434542424421736), + Offset(26.23678799809464, 37.80492814047493), + Offset(25.039022592911195, 37.94631469469684), + Offset(24.185908910025862, 37.983729809649134), + Offset(23.59896217338175, 37.97921421875057), + Offset(23.221743554705682, 37.96329396730781), + Offset(23.0135617043862, 37.95013265173645), + Offset(22.94461033631111, 37.9450856637697), + Offset(22.944381713906004, 37.9450683593219), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/search_ellipsis.g.dart b/packages/material_ui/lib/src/animated_icons/data/search_ellipsis.g.dart new file mode 100644 index 000000000000..5d3f3272d8ba --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/search_ellipsis.g.dart @@ -0,0 +1,5247 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$search_ellipsis = _AnimatedIconData(Size(96.0, 96.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(56.63275314854877, 53.416307677358), + Offset(57.069606004928445, 53.86609044856987), + Offset(59.09460654800288, 55.971023159415935), + Offset(60.89239811736094, 57.88133187802052), + Offset(62.39435194501161, 59.538230172598404), + Offset(63.48463970399173, 60.8425011369294), + Offset(63.932061433030945, 61.59610215538352), + Offset(63.07663991080549, 61.23370607747795), + Offset(59.89718017699878, 58.88057174806894), + Offset(61.21880612019878, 60.239014522068935), + Offset(61.09896916359878, 60.11473915976894), + Offset(60.97913220709878, 59.99046379736894), + Offset(60.85929525059878, 59.866188435068935), + Offset(60.73945829409878, 59.74191307276894), + Offset(60.61962133759878, 59.61763771046894), + Offset(60.49978438099878, 59.49336234816894), + Offset(60.37994742449878, 59.36908698586894), + Offset(60.26011046799878, 59.24481162346894), + Offset(60.140273511498776, 59.12053626116894), + Offset(60.02043655499878, 58.99626089886894), + Offset(59.90059959839878, 58.871985536568936), + Offset(59.78076264189878, 58.74771017426894), + Offset(59.66092568539878, 58.62343481186894), + Offset(59.54108872889878, 58.499159449568936), + Offset(59.42125177229878, 58.37488408726894), + Offset(59.30141481579878, 58.250608724968934), + Offset(59.18157785929878, 58.12633336266894), + Offset(59.06174090279878, 58.00205800026894), + Offset(58.94190394629878, 57.877782637968934), + Offset(58.822066989698776, 57.75350727566894), + Offset(58.70223003319878, 57.62923191336894), + Offset(58.58239307669878, 57.504956551068936), + Offset(58.46255612019878, 57.38068118876894), + Offset(58.34271916359878, 57.25640582636894), + Offset(58.22288220709878, 57.132130464068936), + Offset(58.10304525059878, 57.00785510176894), + Offset(57.98320829409878, 56.883579739468935), + Offset(57.86337133759878, 56.75930437716894), + Offset(57.74353438099878, 56.63502901476894), + Offset(57.62369742449878, 56.510753652468935), + ]), + _PathCubicTo( + <Offset>[ + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(60.57565945470678, 48.849785482535665), + Offset(61.01177894139154, 49.30041761427725), + Offset(63.01014446522872, 51.43619797836744), + Offset(64.73600505254888, 53.42981438943695), + Offset(66.10705023046606, 55.238326039262105), + Offset(66.98436034991653, 56.78925950135372), + Offset(67.09064385091588, 57.93795367416795), + Offset(65.63435166794956, 58.2714628487421), + Offset(61.39070529296772, 57.15082849245442), + Offset(62.71233123616772, 58.50927126645442), + Offset(62.592494279567724, 58.38499590415442), + Offset(62.47265732306772, 58.260720541754424), + Offset(62.35282036656772, 58.13644517945442), + Offset(62.232983410067725, 58.01216981715442), + Offset(62.11314645356772, 57.887894454854425), + Offset(61.993309496967726, 57.76361909255442), + Offset(61.87347254046772, 57.639343730254424), + Offset(61.75363558396772, 57.515068367854425), + Offset(61.63379862746772, 57.39079300555442), + Offset(61.513961670967724, 57.266517643254424), + Offset(61.39412471436772, 57.14224228095442), + Offset(61.274287757867725, 57.01796691865442), + Offset(61.15445080136772, 56.893691556254424), + Offset(61.03461384486772, 56.76941619395442), + Offset(60.914776888267724, 56.64514083165442), + Offset(60.79493993176772, 56.52086546935442), + Offset(60.67510297526772, 56.39659010705442), + Offset(60.555266018767725, 56.27231474465442), + Offset(60.43542906226772, 56.14803938235442), + Offset(60.31559210566772, 56.02376402005442), + Offset(60.195755149167724, 55.899488657754425), + Offset(60.07591819266772, 55.77521329545442), + Offset(59.95608123616772, 55.650937933154424), + Offset(59.83624427956772, 55.526662570754425), + Offset(59.71640732306772, 55.40238720845442), + Offset(59.596570366567725, 55.278111846154424), + Offset(59.47673341006772, 55.15383648385442), + Offset(59.35689645356772, 55.02956112155442), + Offset(59.237059496967724, 54.905285759154424), + Offset(59.11722254046772, 54.78101039685442), + ], + <Offset>[ + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(62.94942712071989, 42.915335621700216), + Offset(63.38510509316773, 43.36707154474344), + Offset(65.36743540273916, 45.5429401518535), + Offset(67.04999102004787, 47.644819547939385), + Offset(68.34222460676555, 49.6503611948963), + Offset(69.09131472932955, 51.52184630725002), + Offset(68.99222107132888, 53.18398603334939), + Offset(67.17418370976294, 54.42186283224286), + Offset(62.28985971191208, 54.90293081789554), + Offset(63.61148565511208, 56.261373591895534), + Offset(63.49164869851208, 56.13709822959554), + Offset(63.37181174201208, 56.01282286719554), + Offset(63.251974785512076, 55.88854750489554), + Offset(63.13213782901208, 55.764272142595544), + Offset(63.01230087251208, 55.63999678029555), + Offset(62.89246391591208, 55.515721417995536), + Offset(62.77262695941208, 55.39144605569554), + Offset(62.65279000291208, 55.26717069329554), + Offset(62.532953046412075, 55.14289533099554), + Offset(62.41311608991208, 55.018619968695546), + Offset(62.293279133312076, 54.894344606395535), + Offset(62.17344217681208, 54.77006924409554), + Offset(62.05360522031208, 54.64579388169554), + Offset(61.93376826381208, 54.52151851939554), + Offset(61.81393130721208, 54.397243157095545), + Offset(61.69409435071208, 54.272967794795534), + Offset(61.574257394212076, 54.14869243249554), + Offset(61.45442043771208, 54.02441707009554), + Offset(61.33458348121208, 53.90014170779554), + Offset(61.214746524612075, 53.775866345495544), + Offset(61.09490956811208, 53.65159098319555), + Offset(60.97507261161208, 53.527315620895536), + Offset(60.855235655112075, 53.40304025859554), + Offset(60.73539869851208, 53.27876489619554), + Offset(60.615561742012076, 53.15448953389554), + Offset(60.49572478551208, 53.030214171595546), + Offset(60.37588782901208, 52.905938809295534), + Offset(60.25605087251208, 52.78166344699554), + Offset(60.13621391591208, 52.65738808459554), + Offset(60.01637695941208, 52.53311272229554), + ], + <Offset>[ + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(62.94942712071989, 36.41758712071989), + Offset(63.38510509316773, 36.87053160705772), + Offset(65.36743540273916, 39.09029363766916), + Offset(67.04999102004787, 41.31071235333787), + Offset(68.34222460676555, 43.531986106645554), + Offset(69.09131472932955, 45.754449667519545), + Offset(68.99222107132888, 47.97877125586888), + Offset(67.17418370976294, 50.206858354462945), + Offset(62.28985971191208, 52.44166244631208), + Offset(63.61148565511208, 53.800105220312076), + Offset(63.49164869851208, 53.67582985801208), + Offset(63.37181174201208, 53.55155449561208), + Offset(63.251974785512076, 53.42727913331208), + Offset(63.13213782901208, 53.30300377101208), + Offset(63.01230087251208, 53.17872840871208), + Offset(62.89246391591208, 53.05445304641208), + Offset(62.77262695941208, 52.93017768411208), + Offset(62.65279000291208, 52.80590232171208), + Offset(62.532953046412075, 52.68162695941208), + Offset(62.41311608991208, 52.55735159711208), + Offset(62.293279133312076, 52.43307623481208), + Offset(62.17344217681208, 52.30880087251208), + Offset(62.05360522031208, 52.18452551011208), + Offset(61.93376826381208, 52.06025014781208), + Offset(61.81393130721208, 51.93597478551208), + Offset(61.69409435071208, 51.811699423212076), + Offset(61.574257394212076, 51.68742406091208), + Offset(61.45442043771208, 51.56314869851208), + Offset(61.33458348121208, 51.438873336212076), + Offset(61.214746524612075, 51.31459797391208), + Offset(61.09490956811208, 51.19032261161208), + Offset(60.97507261161208, 51.06604724931208), + Offset(60.855235655112075, 50.94177188701208), + Offset(60.73539869851208, 50.81749652461208), + Offset(60.615561742012076, 50.69322116231208), + Offset(60.49572478551208, 50.56894580001208), + Offset(60.37588782901208, 50.44467043771208), + Offset(60.25605087251208, 50.32039507541208), + Offset(60.13621391591208, 50.19611971301208), + Offset(60.01637695941208, 50.07184435071208), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(62.94942712071989, 21.97373945470677), + Offset(63.38510509316773, 22.429370456861527), + Offset(65.36743540273916, 24.74670319561872), + Offset(67.04999102004787, 27.230623010818878), + Offset(68.34222460676555, 29.931447747106052), + Offset(69.09131472932955, 32.934101437486525), + Offset(68.99222107132888, 36.408097305735886), + Offset(67.17418370976294, 40.83732326404956), + Offset(62.28985971191208, 46.97050802736772), + Offset(63.61148565511208, 48.32895080136772), + Offset(63.49164869851208, 48.20467543906772), + Offset(63.37181174201208, 48.08040007666772), + Offset(63.251974785512076, 47.95612471436772), + Offset(63.13213782901208, 47.83184935206772), + Offset(63.01230087251208, 47.707573989767724), + Offset(62.89246391591208, 47.58329862746772), + Offset(62.77262695941208, 47.45902326516772), + Offset(62.65279000291208, 47.334747902767724), + Offset(62.532953046412075, 47.21047254046772), + Offset(62.41311608991208, 47.08619717816772), + Offset(62.293279133312076, 46.96192181586772), + Offset(62.17344217681208, 46.83764645356772), + Offset(62.05360522031208, 46.71337109116772), + Offset(61.93376826381208, 46.58909572886772), + Offset(61.81393130721208, 46.46482036656772), + Offset(61.69409435071208, 46.34054500426772), + Offset(61.574257394212076, 46.21626964196772), + Offset(61.45442043771208, 46.09199427956772), + Offset(61.33458348121208, 45.96771891726772), + Offset(61.214746524612075, 45.84344355496772), + Offset(61.09490956811208, 45.71916819266772), + Offset(60.97507261161208, 45.59489283036772), + Offset(60.855235655112075, 45.47061746806772), + Offset(60.73539869851208, 45.34634210566772), + Offset(60.615561742012076, 45.22206674336772), + Offset(60.49572478551208, 45.09779138106772), + Offset(60.37588782901208, 44.97351601876772), + Offset(60.25605087251208, 44.84924065646772), + Offset(60.13621391591208, 44.72496529406772), + Offset(60.01637695941208, 44.60068993176772), + ], + <Offset>[ + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.241465482535666, 10.265747120719887), + Offset(51.67932110354725, 10.723555777147723), + Offset(53.74074073435744, 13.11997804449917), + Offset(55.636886306106945, 15.817488374127876), + Offset(57.3178365729021, 18.90703080962556), + Offset(58.69933686192372, 22.542096324509547), + Offset(59.61321003018795, 27.02906167480888), + Offset(59.5793821068421, 33.242501749162955), + Offset(57.855025758054424, 42.53566244631208), + Offset(59.17665170125442, 43.89410522031208), + Offset(59.056814744654424, 43.76982985801208), + Offset(58.93697778815442, 43.64555449561208), + Offset(58.81714083165442, 43.52127913331208), + Offset(58.697303875154425, 43.39700377101208), + Offset(58.57746691865442, 43.272728408712084), + Offset(58.457629962054426, 43.14845304641208), + Offset(58.337793005554424, 43.02417768411208), + Offset(58.21795604905442, 42.899902321712084), + Offset(58.09811909255442, 42.77562695941208), + Offset(57.978282136054425, 42.65135159711208), + Offset(57.85844517945442, 42.52707623481208), + Offset(57.738608222954426, 42.40280087251208), + Offset(57.618771266454424, 42.27852551011208), + Offset(57.49893430995442, 42.15425014781208), + Offset(57.379097353354425, 42.02997478551208), + Offset(57.25926039685442, 41.90569942321208), + Offset(57.13942344035442, 41.78142406091208), + Offset(57.019586483854425, 41.65714869851208), + Offset(56.89974952735442, 41.53287333621208), + Offset(56.77991257075442, 41.40859797391208), + Offset(56.660075614254424, 41.28432261161208), + Offset(56.54023865775442, 41.16004724931208), + Offset(56.42040170125442, 41.03577188701208), + Offset(56.30056474465442, 40.91149652461208), + Offset(56.18072778815442, 40.78722116231208), + Offset(56.060890831654426, 40.66294580001208), + Offset(55.941053875154424, 40.53867043771208), + Offset(55.82121691865442, 40.41439507541208), + Offset(55.701379962054425, 40.29011971301208), + Offset(55.58154300555442, 40.16584435071208), + ], + <Offset>[ + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(36.79758712071989, 10.265747120719887), + Offset(37.238129263257726, 10.723555777147723), + Offset(39.39711980956916, 13.11997804449917), + Offset(41.55676704083787, 15.817488374127876), + Offset(43.717269309745554, 18.90703080962556), + Offset(45.878961386319546, 22.542096324509547), + Offset(48.04251149026888, 27.02906167480888), + Offset(50.209827104462946, 33.242501749162955), + Offset(52.38385971191208, 42.53566244631208), + Offset(53.70548565511208, 43.89410522031208), + Offset(53.58564869851208, 43.76982985801208), + Offset(53.46581174201208, 43.64555449561208), + Offset(53.34597478551208, 43.52127913331208), + Offset(53.22613782901208, 43.39700377101208), + Offset(53.10630087251208, 43.272728408712084), + Offset(52.98646391591208, 43.14845304641208), + Offset(52.86662695941208, 43.02417768411208), + Offset(52.74679000291208, 42.899902321712084), + Offset(52.626953046412076, 42.77562695941208), + Offset(52.50711608991208, 42.65135159711208), + Offset(52.38727913331208, 42.52707623481208), + Offset(52.26744217681208, 42.40280087251208), + Offset(52.14760522031208, 42.27852551011208), + Offset(52.02776826381208, 42.15425014781208), + Offset(51.90793130721208, 42.02997478551208), + Offset(51.78809435071208, 41.90569942321208), + Offset(51.66825739421208, 41.78142406091208), + Offset(51.54842043771208, 41.65714869851208), + Offset(51.42858348121208, 41.53287333621208), + Offset(51.308746524612076, 41.40859797391208), + Offset(51.18890956811208, 41.28432261161208), + Offset(51.06907261161208, 41.16004724931208), + Offset(50.949235655112076, 41.03577188701208), + Offset(50.82939869851208, 40.91149652461208), + Offset(50.70956174201208, 40.78722116231208), + Offset(50.58972478551208, 40.66294580001208), + Offset(50.46988782901208, 40.53867043771208), + Offset(50.35005087251208, 40.41439507541208), + Offset(50.23021391591208, 40.29011971301208), + Offset(50.11037695941208, 40.16584435071208), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.353739454706773, 10.265747120719887), + Offset(22.79696811306153, 10.723555777147723), + Offset(25.05352936751872, 13.11997804449917), + Offset(27.476677698318877, 15.817488374127876), + Offset(30.11673095020605, 18.90703080962556), + Offset(33.058613156286526, 22.542096324509547), + Offset(36.47183754013589, 27.02906167480888), + Offset(40.84029201404956, 33.242501749162955), + Offset(46.91270529296772, 42.53566244631208), + Offset(48.23433123616772, 43.89410522031208), + Offset(48.11449427956772, 43.76982985801208), + Offset(47.99465732306772, 43.64555449561208), + Offset(47.87482036656772, 43.52127913331208), + Offset(47.75498341006772, 43.39700377101208), + Offset(47.63514645356772, 43.272728408712084), + Offset(47.515309496967724, 43.14845304641208), + Offset(47.39547254046772, 43.02417768411208), + Offset(47.27563558396772, 42.899902321712084), + Offset(47.15579862746772, 42.77562695941208), + Offset(47.03596167096772, 42.65135159711208), + Offset(46.91612471436772, 42.52707623481208), + Offset(46.796287757867724, 42.40280087251208), + Offset(46.67645080136772, 42.27852551011208), + Offset(46.55661384486772, 42.15425014781208), + Offset(46.43677688826772, 42.02997478551208), + Offset(46.31693993176772, 41.90569942321208), + Offset(46.19710297526772, 41.78142406091208), + Offset(46.07726601876772, 41.65714869851208), + Offset(45.95742906226772, 41.53287333621208), + Offset(45.83759210566772, 41.40859797391208), + Offset(45.71775514916772, 41.28432261161208), + Offset(45.59791819266772, 41.16004724931208), + Offset(45.47808123616772, 41.03577188701208), + Offset(45.35824427956772, 40.91149652461208), + Offset(45.23840732306772, 40.78722116231208), + Offset(45.118570366567724, 40.66294580001208), + Offset(44.99873341006772, 40.53867043771208), + Offset(44.87889645356772, 40.41439507541208), + Offset(44.75905949696772, 40.29011971301208), + Offset(44.63922254046772, 40.16584435071208), + ], + <Offset>[ + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(10.64574712071989, 21.973739454706777), + Offset(11.091153433347728, 22.429370456861534), + Offset(13.42680421639917, 24.74670319561873), + Offset(16.063543061627875, 27.230623010818885), + Offset(19.09231401272556, 29.93144774710606), + Offset(22.666608043309548, 32.93410143748653), + Offset(27.09280190920888, 36.40809730573589), + Offset(33.24547049916295, 40.83732326404956), + Offset(42.47785971191208, 46.97050802736772), + Offset(43.79948565511208, 48.32895080136772), + Offset(43.67964869851208, 48.20467543906772), + Offset(43.55981174201208, 48.08040007666772), + Offset(43.43997478551208, 47.95612471436772), + Offset(43.32013782901208, 47.83184935206772), + Offset(43.20030087251208, 47.707573989767724), + Offset(43.080463915912084, 47.58329862746772), + Offset(42.96062695941208, 47.45902326516772), + Offset(42.84079000291208, 47.334747902767724), + Offset(42.72095304641208, 47.21047254046772), + Offset(42.60111608991208, 47.08619717816772), + Offset(42.48127913331208, 46.96192181586772), + Offset(42.36144217681208, 46.83764645356772), + Offset(42.24160522031208, 46.71337109116772), + Offset(42.12176826381208, 46.58909572886772), + Offset(42.00193130721208, 46.46482036656772), + Offset(41.88209435071208, 46.34054500426772), + Offset(41.76225739421208, 46.21626964196772), + Offset(41.64242043771208, 46.09199427956772), + Offset(41.52258348121208, 45.96771891726772), + Offset(41.40274652461208, 45.84344355496772), + Offset(41.28290956811208, 45.71916819266772), + Offset(41.16307261161208, 45.59489283036772), + Offset(41.04323565511208, 45.47061746806772), + Offset(40.92339869851208, 45.34634210566772), + Offset(40.80356174201208, 45.22206674336772), + Offset(40.683724785512084, 45.09779138106772), + Offset(40.56388782901208, 44.97351601876772), + Offset(40.44405087251208, 44.84924065646772), + Offset(40.32421391591208, 44.72496529406772), + Offset(40.20437695941208, 44.60068993176772), + ], + <Offset>[ + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(10.64574712071989, 36.41758712071989), + Offset(11.091153433347728, 36.87053160705773), + Offset(13.42680421639917, 39.09029363766917), + Offset(16.063543061627875, 41.31071235333788), + Offset(19.09231401272556, 43.53198610664556), + Offset(22.666608043309548, 45.75444966751955), + Offset(27.09280190920888, 47.97877125586888), + Offset(33.24547049916295, 50.20685835446295), + Offset(42.47785971191208, 52.44166244631208), + Offset(43.79948565511208, 53.800105220312076), + Offset(43.67964869851208, 53.67582985801208), + Offset(43.55981174201208, 53.55155449561208), + Offset(43.43997478551208, 53.42727913331208), + Offset(43.32013782901208, 53.30300377101208), + Offset(43.20030087251208, 53.17872840871208), + Offset(43.080463915912084, 53.05445304641208), + Offset(42.96062695941208, 52.93017768411208), + Offset(42.84079000291208, 52.80590232171208), + Offset(42.72095304641208, 52.68162695941208), + Offset(42.60111608991208, 52.55735159711208), + Offset(42.48127913331208, 52.43307623481208), + Offset(42.36144217681208, 52.30880087251208), + Offset(42.24160522031208, 52.18452551011208), + Offset(42.12176826381208, 52.06025014781208), + Offset(42.00193130721208, 51.93597478551208), + Offset(41.88209435071208, 51.811699423212076), + Offset(41.76225739421208, 51.68742406091208), + Offset(41.64242043771208, 51.56314869851208), + Offset(41.52258348121208, 51.438873336212076), + Offset(41.40274652461208, 51.31459797391208), + Offset(41.28290956811208, 51.19032261161208), + Offset(41.16307261161208, 51.06604724931208), + Offset(41.04323565511208, 50.94177188701208), + Offset(40.92339869851208, 50.81749652461208), + Offset(40.80356174201208, 50.69322116231208), + Offset(40.683724785512084, 50.56894580001208), + Offset(40.56388782901208, 50.44467043771208), + Offset(40.44405087251208, 50.32039507541208), + Offset(40.32421391591208, 50.19611971301208), + Offset(40.20437695941208, 50.07184435071208), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(10.64574712071989, 50.861465482535664), + Offset(11.091153433347728, 51.311723447347255), + Offset(13.42680421639917, 53.433914562457446), + Offset(16.063543061627875, 55.39083161860695), + Offset(19.09231401272556, 57.13255336980211), + Offset(22.666608043309548, 58.574825143123725), + Offset(27.09280190920888, 59.54946979578796), + Offset(33.24547049916295, 59.5764133568421), + Offset(42.47785971191208, 57.91282849245442), + Offset(43.79948565511208, 59.27127126645442), + Offset(43.67964869851208, 59.14699590415442), + Offset(43.55981174201208, 59.022720541754424), + Offset(43.43997478551208, 58.89844517945442), + Offset(43.32013782901208, 58.77416981715442), + Offset(43.20030087251208, 58.649894454854426), + Offset(43.080463915912084, 58.52561909255442), + Offset(42.96062695941208, 58.401343730254425), + Offset(42.84079000291208, 58.277068367854426), + Offset(42.72095304641208, 58.15279300555442), + Offset(42.60111608991208, 58.028517643254425), + Offset(42.48127913331208, 57.90424228095442), + Offset(42.36144217681208, 57.779966918654424), + Offset(42.24160522031208, 57.655691556254425), + Offset(42.12176826381208, 57.53141619395442), + Offset(42.00193130721208, 57.407140831654424), + Offset(41.88209435071208, 57.28286546935442), + Offset(41.76225739421208, 57.15859010705442), + Offset(41.64242043771208, 57.034314744654424), + Offset(41.52258348121208, 56.91003938235442), + Offset(41.40274652461208, 56.78576402005442), + Offset(41.28290956811208, 56.661488657754425), + Offset(41.16307261161208, 56.53721329545442), + Offset(41.04323565511208, 56.412937933154424), + Offset(40.92339869851208, 56.288662570754425), + Offset(40.80356174201208, 56.16438720845442), + Offset(40.683724785512084, 56.040111846154424), + Offset(40.56388782901208, 55.91583648385442), + Offset(40.44405087251208, 55.79156112155442), + Offset(40.32421391591208, 55.667285759154424), + Offset(40.20437695941208, 55.54301039685442), + ], + <Offset>[ + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.35373945470678, 62.569427120719894), + Offset(22.79696811306154, 63.01750743696773), + Offset(25.053529367518728, 65.06060923083916), + Offset(27.476677698318884, 66.80393633254788), + Offset(30.11673095020606, 68.15694140366556), + Offset(33.05861315628653, 68.96680301052955), + Offset(36.471837540135894, 68.92848083692888), + Offset(40.84029201404956, 67.17121495976295), + Offset(46.91270529296772, 62.347662446312086), + Offset(48.23433123616772, 63.70610522031208), + Offset(48.11449427956772, 63.581829858012085), + Offset(47.99465732306772, 63.45755449561209), + Offset(47.87482036656772, 63.33327913331208), + Offset(47.75498341006772, 63.209003771012085), + Offset(47.63514645356772, 63.08472840871209), + Offset(47.515309496967724, 62.960453046412084), + Offset(47.39547254046772, 62.83617768411209), + Offset(47.27563558396772, 62.71190232171209), + Offset(47.15579862746772, 62.587626959412084), + Offset(47.03596167096772, 62.46335159711209), + Offset(46.91612471436772, 62.33907623481208), + Offset(46.796287757867724, 62.214800872512086), + Offset(46.67645080136772, 62.09052551011209), + Offset(46.55661384486772, 61.96625014781208), + Offset(46.43677688826772, 61.841974785512086), + Offset(46.31693993176772, 61.71769942321208), + Offset(46.19710297526772, 61.593424060912085), + Offset(46.07726601876772, 61.469148698512086), + Offset(45.95742906226772, 61.34487333621208), + Offset(45.83759210566772, 61.220597973912085), + Offset(45.71775514916772, 61.09632261161209), + Offset(45.59791819266772, 60.972047249312084), + Offset(45.47808123616772, 60.84777188701209), + Offset(45.35824427956772, 60.72349652461209), + Offset(45.23840732306772, 60.599221162312084), + Offset(45.118570366567724, 60.47494580001209), + Offset(44.99873341006772, 60.35067043771208), + Offset(44.87889645356772, 60.226395075412086), + Offset(44.75905949696772, 60.10211971301209), + Offset(44.63922254046772, 59.97784435071208), + ], + <Offset>[ + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(36.79758712071989, 62.569427120719894), + Offset(37.23812926325773, 63.01750743696773), + Offset(39.39711980956917, 65.06060923083916), + Offset(41.556767040837876, 66.80393633254788), + Offset(43.71726930974556, 68.15694140366556), + Offset(45.878961386319546, 68.96680301052955), + Offset(48.04251149026888, 68.92848083692888), + Offset(50.20982710446295, 67.17121495976295), + Offset(52.38385971191208, 62.347662446312086), + Offset(53.70548565511208, 63.70610522031208), + Offset(53.58564869851208, 63.581829858012085), + Offset(53.46581174201208, 63.45755449561209), + Offset(53.34597478551208, 63.33327913331208), + Offset(53.22613782901208, 63.209003771012085), + Offset(53.10630087251208, 63.08472840871209), + Offset(52.98646391591208, 62.960453046412084), + Offset(52.86662695941208, 62.83617768411209), + Offset(52.74679000291208, 62.71190232171209), + Offset(52.626953046412076, 62.587626959412084), + Offset(52.50711608991208, 62.46335159711209), + Offset(52.38727913331208, 62.33907623481208), + Offset(52.26744217681208, 62.214800872512086), + Offset(52.14760522031208, 62.09052551011209), + Offset(52.02776826381208, 61.96625014781208), + Offset(51.90793130721208, 61.841974785512086), + Offset(51.78809435071208, 61.71769942321208), + Offset(51.66825739421208, 61.593424060912085), + Offset(51.54842043771208, 61.469148698512086), + Offset(51.42858348121208, 61.34487333621208), + Offset(51.308746524612076, 61.220597973912085), + Offset(51.18890956811208, 61.09632261161209), + Offset(51.06907261161208, 60.972047249312084), + Offset(50.949235655112076, 60.84777188701209), + Offset(50.82939869851208, 60.72349652461209), + Offset(50.70956174201208, 60.599221162312084), + Offset(50.58972478551208, 60.47494580001209), + Offset(50.46988782901208, 60.35067043771208), + Offset(50.35005087251208, 60.226395075412086), + Offset(50.23021391591208, 60.10211971301209), + Offset(50.11037695941208, 59.97784435071208), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.295335621700225, 62.569427120719894), + Offset(43.73466920094344, 63.01750743696773), + Offset(45.84976632375351, 65.06060923083916), + Offset(47.89087423543939, 66.80393633254788), + Offset(49.835644397996305, 68.15694140366556), + Offset(51.646358026050024, 68.96680301052955), + Offset(53.24772626774939, 68.92848083692888), + Offset(54.42483158224287, 67.17121495976295), + Offset(54.845128083495545, 62.347662446312086), + Offset(56.16675402669554, 63.70610522031208), + Offset(56.046917070095546, 63.581829858012085), + Offset(55.927080113595544, 63.45755449561209), + Offset(55.80724315709554, 63.33327913331208), + Offset(55.68740620059555, 63.209003771012085), + Offset(55.567569244095544, 63.08472840871209), + Offset(55.44773228749555, 62.960453046412084), + Offset(55.327895330995545, 62.83617768411209), + Offset(55.20805837449554, 62.71190232171209), + Offset(55.08822141799554, 62.587626959412084), + Offset(54.968384461495546, 62.46335159711209), + Offset(54.84854750489554, 62.33907623481208), + Offset(54.72871054839555, 62.214800872512086), + Offset(54.608873591895545, 62.09052551011209), + Offset(54.48903663539554, 61.96625014781208), + Offset(54.369199678795546, 61.841974785512086), + Offset(54.249362722295544, 61.71769942321208), + Offset(54.12952576579554, 61.593424060912085), + Offset(54.00968880929555, 61.469148698512086), + Offset(53.889851852795545, 61.34487333621208), + Offset(53.77001489619554, 61.220597973912085), + Offset(53.650177939695546, 61.09632261161209), + Offset(53.53034098319554, 60.972047249312084), + Offset(53.41050402669554, 60.84777188701209), + Offset(53.290667070095544, 60.72349652461209), + Offset(53.17083011359554, 60.599221162312084), + Offset(53.05099315709555, 60.47494580001209), + Offset(52.931156200595545, 60.35067043771208), + Offset(52.81131924409554, 60.226395075412086), + Offset(52.691482287495546, 60.10211971301209), + Offset(52.571645330995544, 59.97784435071208), + ], + <Offset>[ + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.22978548253567, 60.19565945470678), + Offset(49.668015270477255, 60.644181285191536), + Offset(51.743024150267445, 62.70331829332873), + Offset(53.67586907693695, 64.48995036504888), + Offset(55.423609242362105, 65.92176702736606), + Offset(56.91377122015372, 66.85984863111653), + Offset(58.00169390856796, 67.0269036165159), + Offset(58.274431598742105, 65.63138291794957), + Offset(57.093025758054424, 61.44850802736772), + Offset(58.41465170125442, 62.80695080136772), + Offset(58.294814744654424, 62.68267543906772), + Offset(58.17497778815442, 62.55840007666772), + Offset(58.05514083165442, 62.43412471436772), + Offset(57.935303875154425, 62.30984935206772), + Offset(57.81546691865442, 62.185573989767725), + Offset(57.695629962054426, 62.06129862746772), + Offset(57.575793005554424, 61.937023265167724), + Offset(57.45595604905442, 61.812747902767725), + Offset(57.33611909255442, 61.68847254046772), + Offset(57.216282136054424, 61.564197178167724), + Offset(57.09644517945442, 61.43992181586772), + Offset(56.976608222954425, 61.31564645356772), + Offset(56.85677126645442, 61.191371091167724), + Offset(56.73693430995442, 61.06709572886772), + Offset(56.617097353354424, 60.94282036656772), + Offset(56.49726039685442, 60.81854500426772), + Offset(56.37742344035442, 60.69426964196772), + Offset(56.257586483854425, 60.56999427956772), + Offset(56.13774952735442, 60.44571891726772), + Offset(56.01791257075442, 60.32144355496772), + Offset(55.898075614254424, 60.197168192667725), + Offset(55.77823865775442, 60.07289283036772), + Offset(55.65840170125442, 59.948617468067724), + Offset(55.53856474465442, 59.824342105667725), + Offset(55.41872778815442, 59.70006674336772), + Offset(55.298890831654425, 59.575791381067724), + Offset(55.17905387515442, 59.45151601876772), + Offset(55.05921691865442, 59.32724065646772), + Offset(54.939379962054424, 59.202965294067724), + Offset(54.81954300555442, 59.07868993176772), + ], + <Offset>[ + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(54.233688104769875, 56.72214104872773), + Offset(56.27784933131594, 58.80777705090479), + Offset(58.127386565520524, 60.66597275272132), + Offset(59.7235133756984, 62.22802951354025), + Offset(60.96701285572941, 63.37800107878284), + Offset(61.65984238978352, 63.88445209731733), + Offset(61.23667482747795, 63.08673340955032), + Offset(58.822769013668946, 59.962610352808845), + Offset(60.14439495686894, 61.32105312680884), + Offset(60.02455800026895, 61.196777764508845), + Offset(59.904721043768944, 61.072502402108846), + Offset(59.78488408726894, 60.94822703980884), + Offset(59.66504713076895, 60.823951677508845), + Offset(59.545210174268945, 60.69967631520885), + Offset(59.42537321766895, 60.57540095290884), + Offset(59.305536261168946, 60.45112559060885), + Offset(59.185699304668944, 60.32685022820885), + Offset(59.06586234816894, 60.20257486590884), + Offset(58.94602539166895, 60.07829950360885), + Offset(58.82618843506894, 59.95402414130884), + Offset(58.70635147856895, 59.829748779008845), + Offset(58.586514522068946, 59.70547341660885), + Offset(58.46667756556894, 59.58119805430884), + Offset(58.34684060896895, 59.456922692008845), + Offset(58.227003652468944, 59.33264732970884), + Offset(58.10716669596894, 59.208371967408844), + Offset(57.98732973946895, 59.084096605008845), + Offset(57.867492782968945, 58.95982124270884), + Offset(57.74765582636894, 58.835545880408844), + Offset(57.627818869868946, 58.71127051810885), + Offset(57.507981913368944, 58.58699515580884), + Offset(57.38814495686894, 58.462719793508846), + Offset(57.268308000268945, 58.33844443110885), + Offset(57.14847104376894, 58.21416906880884), + Offset(57.02863408726895, 58.089893706508846), + Offset(56.908797130768946, 57.96561834420884), + Offset(56.788960174268944, 57.841342981908845), + Offset(56.66912321766895, 57.717067619508846), + Offset(56.549286261168945, 57.59279225720884), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(53.796307677358, 56.272889593871334), + Offset(54.233688104769875, 56.72214104872773), + Offset(56.27784933131594, 58.80777705090479), + Offset(58.127386565520524, 60.66597275272132), + Offset(59.7235133756984, 62.22802951354025), + Offset(60.96701285572941, 63.37800107878284), + Offset(61.65984238978352, 63.88445209731733), + Offset(61.23667482747795, 63.08673340955032), + Offset(58.822769013668946, 59.962610352808845), + Offset(60.14439495686894, 61.32105312680884), + Offset(60.02455800026895, 61.196777764508845), + Offset(59.904721043768944, 61.072502402108846), + Offset(59.78488408726894, 60.94822703980884), + Offset(59.66504713076895, 60.823951677508845), + Offset(59.545210174268945, 60.69967631520885), + Offset(59.42537321766895, 60.57540095290884), + Offset(59.305536261168946, 60.45112559060885), + Offset(59.185699304668944, 60.32685022820885), + Offset(59.06586234816894, 60.20257486590884), + Offset(58.94602539166895, 60.07829950360885), + Offset(58.82618843506894, 59.95402414130884), + Offset(58.70635147856895, 59.829748779008845), + Offset(58.586514522068946, 59.70547341660885), + Offset(58.46667756556894, 59.58119805430884), + Offset(58.34684060896895, 59.456922692008845), + Offset(58.227003652468944, 59.33264732970884), + Offset(58.10716669596894, 59.208371967408844), + Offset(57.98732973946895, 59.084096605008845), + Offset(57.867492782968945, 58.95982124270884), + Offset(57.74765582636894, 58.835545880408844), + Offset(57.627818869868946, 58.71127051810885), + Offset(57.507981913368944, 58.58699515580884), + Offset(57.38814495686894, 58.462719793508846), + Offset(57.268308000268945, 58.33844443110885), + Offset(57.14847104376894, 58.21416906880884), + Offset(57.02863408726895, 58.089893706508846), + Offset(56.908797130768946, 57.96561834420884), + Offset(56.788960174268944, 57.841342981908845), + Offset(56.66912321766895, 57.717067619508846), + Offset(56.549286261168945, 57.59279225720884), + ], + <Offset>[ + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(57.069606004928445, 53.86609044856987), + Offset(59.094606548002886, 55.97102315941594), + Offset(60.89239811736094, 57.881331878020525), + Offset(62.39435194501161, 59.538230172598404), + Offset(63.48463970399174, 60.8425011369294), + Offset(63.932061433030945, 61.59610215538352), + Offset(63.07663991080549, 61.23370607747795), + Offset(59.89718017699878, 58.880571748068945), + Offset(61.21880612019878, 60.23901452206894), + Offset(61.09896916359878, 60.114739159768945), + Offset(60.97913220709878, 59.990463797368946), + Offset(60.85929525059878, 59.86618843506894), + Offset(60.73945829409878, 59.741913072768945), + Offset(60.61962133759878, 59.61763771046895), + Offset(60.49978438099878, 59.493362348168944), + Offset(60.37994742449878, 59.36908698586895), + Offset(60.26011046799878, 59.24481162346895), + Offset(60.140273511498776, 59.120536261168944), + Offset(60.02043655499878, 58.99626089886895), + Offset(59.90059959839878, 58.87198553656894), + Offset(59.78076264189878, 58.747710174268946), + Offset(59.66092568539878, 58.62343481186895), + Offset(59.54108872889878, 58.49915944956894), + Offset(59.42125177229878, 58.374884087268946), + Offset(59.30141481579878, 58.25060872496894), + Offset(59.18157785929878, 58.126333362668944), + Offset(59.06174090279878, 58.002058000268946), + Offset(58.94190394629878, 57.87778263796894), + Offset(58.822066989698776, 57.753507275668944), + Offset(58.70223003319878, 57.62923191336895), + Offset(58.58239307669878, 57.50495655106894), + Offset(58.46255612019878, 57.380681188768946), + Offset(58.34271916359878, 57.25640582636895), + Offset(58.22288220709878, 57.13213046406894), + Offset(58.10304525059878, 57.007855101768946), + Offset(57.98320829409878, 56.88357973946894), + Offset(57.86337133759878, 56.759304377168945), + Offset(57.74353438099878, 56.635029014768946), + Offset(57.62369742449878, 56.51075365246894), + ], + <Offset>[ + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(56.63275314854878, 53.416307677358), + Offset(57.069606004928445, 53.86609044856987), + Offset(59.094606548002886, 55.97102315941594), + Offset(60.89239811736094, 57.881331878020525), + Offset(62.39435194501161, 59.538230172598404), + Offset(63.48463970399174, 60.8425011369294), + Offset(63.932061433030945, 61.59610215538352), + Offset(63.07663991080549, 61.23370607747795), + Offset(59.89718017699878, 58.880571748068945), + Offset(61.21880612019878, 60.23901452206894), + Offset(61.09896916359878, 60.114739159768945), + Offset(60.97913220709878, 59.990463797368946), + Offset(60.85929525059878, 59.86618843506894), + Offset(60.73945829409878, 59.741913072768945), + Offset(60.61962133759878, 59.61763771046895), + Offset(60.49978438099878, 59.493362348168944), + Offset(60.37994742449878, 59.36908698586895), + Offset(60.26011046799878, 59.24481162346895), + Offset(60.140273511498776, 59.120536261168944), + Offset(60.02043655499878, 58.99626089886895), + Offset(59.90059959839878, 58.87198553656894), + Offset(59.78076264189878, 58.747710174268946), + Offset(59.66092568539878, 58.62343481186895), + Offset(59.54108872889878, 58.49915944956894), + Offset(59.42125177229878, 58.374884087268946), + Offset(59.30141481579878, 58.25060872496894), + Offset(59.18157785929878, 58.126333362668944), + Offset(59.06174090279878, 58.002058000268946), + Offset(58.94190394629878, 57.87778263796894), + Offset(58.822066989698776, 57.753507275668944), + Offset(58.70223003319878, 57.62923191336895), + Offset(58.58239307669878, 57.50495655106894), + Offset(58.46255612019878, 57.380681188768946), + Offset(58.34271916359878, 57.25640582636895), + Offset(58.22288220709878, 57.13213046406894), + Offset(58.10304525059878, 57.007855101768946), + Offset(57.98320829409878, 56.88357973946894), + Offset(57.86337133759878, 56.759304377168945), + Offset(57.74353438099878, 56.635029014768946), + Offset(57.62369742449878, 56.51075365246894), + ], + ), + _PathClose(), + _PathMoveTo(<Offset>[ + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(37.238129263257726, 54.34772049679427), + Offset(39.39711980956916, 53.40971455122347), + Offset(41.55676704083787, 52.38323260637452), + Offset(43.717269309745554, 51.34517214659979), + Offset(45.878961386319546, 50.4025549847652), + Offset(48.04251149026888, 49.72174872023579), + Offset(50.209827104462946, 50.28103042236462), + Offset(52.38385971191208, 52.48497375612058), + Offset(53.70548565511208, 53.84341653012058), + Offset(53.58564869851208, 53.71914116782058), + Offset(53.46581174201208, 53.59486580542058), + Offset(53.34597478551208, 53.47059044312058), + Offset(53.22613782901208, 53.34631508082058), + Offset(53.10630087251208, 53.222039718520584), + Offset(52.98646391591208, 53.09776435622058), + Offset(52.86662695941208, 52.97348899392058), + Offset(52.74679000291208, 52.849213631520584), + Offset(52.626953046412076, 52.72493826922058), + Offset(52.50711608991208, 52.60066290692058), + Offset(52.38727913331208, 52.47638754462058), + Offset(52.26744217681208, 52.35211218232058), + Offset(52.14760522031208, 52.22783681992058), + Offset(52.02776826381208, 52.10356145762058), + Offset(51.90793130721208, 51.97928609532058), + Offset(51.78809435071208, 51.85501073302058), + Offset(51.66825739421208, 51.73073537072058), + Offset(51.54842043771208, 51.60646000832058), + Offset(51.42858348121208, 51.48218464602058), + Offset(51.308746524612076, 51.35790928372058), + Offset(51.18890956811208, 51.233633921420584), + Offset(51.06907261161208, 51.10935855912058), + Offset(50.949235655112076, 50.98508319682058), + Offset(50.82939869851208, 50.860807834420584), + Offset(50.70956174201208, 50.73653247212058), + Offset(50.58972478551208, 50.61225710982058), + Offset(50.46988782901208, 50.48798174752058), + Offset(50.35005087251208, 50.36370638522058), + Offset(50.23021391591208, 50.23943102282058), + Offset(50.11037695941208, 50.11515566052058), + ]), + _PathCubicTo( + <Offset>[ + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(26.79953506506211, 54.522707120719886), + Offset(27.586834836005405, 54.34772049679427), + Offset(31.489615606172315, 53.40971455122347), + Offset(35.442274116666454, 52.38323260637452), + Offset(39.402653738040314, 51.34517214659979), + Offset(43.31217425963652, 50.4025549847652), + Offset(47.08000098098459, 49.72174872023579), + Offset(50.16886819340391, 50.28103042236462), + Offset(52.35994256713666, 52.48497375612058), + Offset(53.68156851033666, 53.84341653012058), + Offset(53.561731553736664, 53.71914116782058), + Offset(53.44189459723666, 53.59486580542058), + Offset(53.32205764073666, 53.47059044312058), + Offset(53.202220684236664, 53.34631508082058), + Offset(53.08238372773666, 53.222039718520584), + Offset(52.962546771136665, 53.09776435622058), + Offset(52.84270981463666, 52.97348899392058), + Offset(52.72287285813666, 52.849213631520584), + Offset(52.60303590163666, 52.72493826922058), + Offset(52.483198945136664, 52.60066290692058), + Offset(52.36336198853666, 52.47638754462058), + Offset(52.243525032036665, 52.35211218232058), + Offset(52.12368807553666, 52.22783681992058), + Offset(52.00385111903666, 52.10356145762058), + Offset(51.884014162436664, 51.97928609532058), + Offset(51.76417720593666, 51.85501073302058), + Offset(51.64434024943666, 51.73073537072058), + Offset(51.524503292936664, 51.60646000832058), + Offset(51.40466633643666, 51.48218464602058), + Offset(51.28482937983666, 51.35790928372058), + Offset(51.16499242333666, 51.233633921420584), + Offset(51.04515546683666, 51.10935855912058), + Offset(50.92531851033666, 50.98508319682058), + Offset(50.80548155373666, 50.860807834420584), + Offset(50.68564459723666, 50.73653247212058), + Offset(50.565807640736665, 50.61225710982058), + Offset(50.44597068423666, 50.48798174752058), + Offset(50.32613372773666, 50.36370638522058), + Offset(50.206296771136664, 50.23943102282058), + Offset(50.08645981463666, 50.11515566052058), + ], + <Offset>[ + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(18.692467120719886, 46.41563917637766), + Offset(19.76094037350107, 46.52182603428993), + Offset(25.077698896014848, 46.99779784106601), + Offset(30.48424678780122, 47.42520527750929), + Offset(35.90408326978374, 47.84660167834321), + Offset(41.23085606905603, 48.32123679418471), + Offset(46.299534025885855, 48.94128176514349), + Offset(50.13565503655109, 50.247817265511806), + Offset(52.34054840209764, 52.46557959108156), + Offset(53.662174345297636, 53.824022365081554), + Offset(53.54233738869764, 53.69974700278156), + Offset(53.42250043219764, 53.57547164038156), + Offset(53.302663475697635, 53.451196278081554), + Offset(53.18282651919764, 53.32692091578156), + Offset(53.06298956269764, 53.20264555348156), + Offset(52.94315260609764, 53.078370191181556), + Offset(52.82331564959764, 52.95409482888156), + Offset(52.70347869309764, 52.82981946648156), + Offset(52.583641736597635, 52.705544104181556), + Offset(52.46380478009764, 52.58126874188156), + Offset(52.343967823497636, 52.456993379581554), + Offset(52.22413086699764, 52.33271801728156), + Offset(52.10429391049764, 52.20844265488156), + Offset(51.984456953997636, 52.084167292581554), + Offset(51.86461999739764, 51.95989193028156), + Offset(51.74478304089764, 51.83561656798155), + Offset(51.624946084397635, 51.711341205681556), + Offset(51.50510912789764, 51.58706584328156), + Offset(51.38527217139764, 51.46279048098155), + Offset(51.265435214797634, 51.338515118681556), + Offset(51.14559825829764, 51.21423975638156), + Offset(51.02576130179764, 51.089964394081555), + Offset(50.905924345297635, 50.96568903178156), + Offset(50.78608738869764, 50.84141366938156), + Offset(50.666250432197636, 50.717138307081555), + Offset(50.54641347569764, 50.59286294478156), + Offset(50.42657651919764, 50.468587582481554), + Offset(50.306739562697636, 50.34431222018156), + Offset(50.18690260609764, 50.22003685778156), + Offset(50.06706564959764, 50.095761495481554), + ], + <Offset>[ + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(18.692467120719886, 36.41758712071989), + Offset(19.76094037350107, 36.87053160703761), + Offset(25.077698896014848, 39.09029363766916), + Offset(30.48424678780122, 41.31071235333787), + Offset(35.90408326978374, 43.53198610663798), + Offset(41.23085606905603, 45.75444966750169), + Offset(46.299534025885855, 47.97877125585276), + Offset(50.13565503655109, 50.20685835445277), + Offset(52.34054840209764, 52.44166244630614), + Offset(53.662174345297636, 53.800105220306136), + Offset(53.54233738869764, 53.67582985800614), + Offset(53.42250043219764, 53.55155449560614), + Offset(53.302663475697635, 53.427279133306136), + Offset(53.18282651919764, 53.30300377100614), + Offset(53.06298956269764, 53.17872840870614), + Offset(52.94315260609764, 53.05445304640614), + Offset(52.82331564959764, 52.93017768410614), + Offset(52.70347869309764, 52.80590232170614), + Offset(52.583641736597635, 52.68162695940614), + Offset(52.46380478009764, 52.55735159710614), + Offset(52.343967823497636, 52.43307623480614), + Offset(52.22413086699764, 52.30880087250614), + Offset(52.10429391049764, 52.18452551010614), + Offset(51.984456953997636, 52.06025014780614), + Offset(51.86461999739764, 51.93597478550614), + Offset(51.74478304089764, 51.811699423206136), + Offset(51.624946084397635, 51.68742406090614), + Offset(51.50510912789764, 51.56314869850614), + Offset(51.38527217139764, 51.438873336206136), + Offset(51.265435214797634, 51.31459797390614), + Offset(51.14559825829764, 51.19032261160614), + Offset(51.02576130179764, 51.06604724930614), + Offset(50.905924345297635, 50.94177188700614), + Offset(50.78608738869764, 50.81749652460614), + Offset(50.666250432197636, 50.69322116230614), + Offset(50.54641347569764, 50.56894580000614), + Offset(50.42657651919764, 50.44467043770614), + Offset(50.306739562697636, 50.32039507540614), + Offset(50.18690260609764, 50.19611971300614), + Offset(50.06706564959764, 50.07184435070614), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(18.692467120719886, 26.419565760864774), + Offset(19.76094037350107, 27.219266804248566), + Offset(25.077698896014848, 31.18281367228795), + Offset(30.48424678780122, 35.1962381308779), + Offset(35.90408326978374, 39.21738368206378), + Offset(41.23085606905603, 43.187670298244306), + Offset(46.299534025885855, 47.016263564148105), + Offset(50.13565503655109, 50.165899443393734), + Offset(52.34054840209764, 52.417745301530715), + Offset(53.662174345297636, 53.77618807553071), + Offset(53.54233738869764, 53.651912713230715), + Offset(53.42250043219764, 53.527637350830716), + Offset(53.302663475697635, 53.40336198853072), + Offset(53.18282651919764, 53.27908662623072), + Offset(53.06298956269764, 53.154811263930725), + Offset(52.94315260609764, 53.030535901630714), + Offset(52.82331564959764, 52.90626053933072), + Offset(52.70347869309764, 52.78198517693072), + Offset(52.583641736597635, 52.65770981463072), + Offset(52.46380478009764, 52.533434452330724), + Offset(52.343967823497636, 52.40915909003071), + Offset(52.22413086699764, 52.284883727730715), + Offset(52.10429391049764, 52.16060836533072), + Offset(51.984456953997636, 52.03633300303072), + Offset(51.86461999739764, 51.91205764073072), + Offset(51.74478304089764, 51.78778227843071), + Offset(51.624946084397635, 51.663506916130714), + Offset(51.50510912789764, 51.539231553730716), + Offset(51.38527217139764, 51.41495619143072), + Offset(51.265435214797634, 51.29068082913072), + Offset(51.14559825829764, 51.166405466830724), + Offset(51.02576130179764, 51.04213010453071), + Offset(50.905924345297635, 50.917854742230716), + Offset(50.78608738869764, 50.79357937983072), + Offset(50.666250432197636, 50.66930401753072), + Offset(50.54641347569764, 50.54502865523072), + Offset(50.42657651919764, 50.42075329293071), + Offset(50.306739562697636, 50.296477930630715), + Offset(50.18690260609764, 50.172202568230716), + Offset(50.06706564959764, 50.04792720593072), + ], + <Offset>[ + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(26.79953506506211, 18.312467120719884), + Offset(27.58683483600541, 19.393342717280955), + Offset(31.489615606172315, 24.77087272411485), + Offset(35.442274116666454, 30.23819210030122), + Offset(39.40265373804032, 35.71880006667617), + Offset(43.31217425963652, 41.106344350238174), + Offset(47.08000098097815, 46.23579379146974), + Offset(50.16886819340391, 50.13268628654091), + Offset(52.35994256713666, 52.3983511364917), + Offset(53.68156851033666, 53.756793910491695), + Offset(53.561731553736664, 53.6325185481917), + Offset(53.44189459723666, 53.5082431857917), + Offset(53.32205764073666, 53.38396782349169), + Offset(53.202220684236664, 53.25969246119169), + Offset(53.08238372773666, 53.13541709889169), + Offset(52.962546771136665, 53.011141736591696), + Offset(52.84270981463666, 52.8868663742917), + Offset(52.72287285813666, 52.7625910118917), + Offset(52.60303590163666, 52.63831564959169), + Offset(52.483198945136664, 52.51404028729169), + Offset(52.36336198853666, 52.389764924991695), + Offset(52.243525032036665, 52.2654895626917), + Offset(52.12368807553666, 52.1412142002917), + Offset(52.00385111903666, 52.01693883799169), + Offset(51.884014162436664, 51.89266347569169), + Offset(51.76417720593666, 51.768388113391694), + Offset(51.64434024943666, 51.6441127510917), + Offset(51.524503292936664, 51.5198373886917), + Offset(51.40466633643666, 51.39556202639169), + Offset(51.28482937983666, 51.27128666409169), + Offset(51.16499242333666, 51.14701130179169), + Offset(51.04515546683666, 51.022735939491696), + Offset(50.92531851033666, 50.8984605771917), + Offset(50.80548155373666, 50.7741852147917), + Offset(50.68564459723666, 50.64990985249169), + Offset(50.565807640736665, 50.52563449019169), + Offset(50.44597068423666, 50.401359127891695), + Offset(50.32613372773666, 50.2770837655917), + Offset(50.206296771136664, 50.1528084031917), + Offset(50.08645981463666, 50.02853304089169), + ], + <Offset>[ + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(36.79758712071989, 18.312467120719884), + Offset(37.238129263257726, 19.393342717280955), + Offset(39.39711980956916, 24.77087272411485), + Offset(41.55676704083787, 30.23819210030122), + Offset(43.717269309745554, 35.71880006667617), + Offset(45.878961386319546, 41.106344350238174), + Offset(48.04251149026888, 46.23579379146974), + Offset(50.209827104462946, 50.13268628654091), + Offset(52.38385971191208, 52.3983511364917), + Offset(53.70548565511208, 53.756793910491695), + Offset(53.58564869851208, 53.6325185481917), + Offset(53.46581174201208, 53.5082431857917), + Offset(53.34597478551208, 53.38396782349169), + Offset(53.22613782901208, 53.25969246119169), + Offset(53.10630087251208, 53.13541709889169), + Offset(52.98646391591208, 53.011141736591696), + Offset(52.86662695941208, 52.8868663742917), + Offset(52.74679000291208, 52.7625910118917), + Offset(52.626953046412076, 52.63831564959169), + Offset(52.50711608991208, 52.51404028729169), + Offset(52.38727913331208, 52.389764924991695), + Offset(52.26744217681208, 52.2654895626917), + Offset(52.14760522031208, 52.1412142002917), + Offset(52.02776826381208, 52.01693883799169), + Offset(51.90793130721208, 51.89266347569169), + Offset(51.78809435071208, 51.768388113391694), + Offset(51.66825739421208, 51.6441127510917), + Offset(51.54842043771208, 51.5198373886917), + Offset(51.42858348121208, 51.39556202639169), + Offset(51.308746524612076, 51.27128666409169), + Offset(51.18890956811208, 51.14701130179169), + Offset(51.06907261161208, 51.022735939491696), + Offset(50.949235655112076, 50.8984605771917), + Offset(50.82939869851208, 50.7741852147917), + Offset(50.70956174201208, 50.64990985249169), + Offset(50.58972478551208, 50.52563449019169), + Offset(50.46988782901208, 50.401359127891695), + Offset(50.35005087251208, 50.2770837655917), + Offset(50.23021391591208, 50.1528084031917), + Offset(50.11037695941208, 50.02853304089169), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.79563917637766, 18.312467120719884), + Offset(46.88942369051005, 19.393342717280955), + Offset(47.30462401296601, 24.77087272411485), + Offset(47.67125996500929, 30.23819210030122), + Offset(48.031884881450786, 35.71880006667617), + Offset(48.44574851300257, 41.106344350238174), + Offset(49.005021999553165, 46.23579379146974), + Offset(50.25078601552198, 50.13268628654091), + Offset(52.4077768566875, 52.3983511364917), + Offset(53.729402799887495, 53.756793910491695), + Offset(53.6095658432875, 53.6325185481917), + Offset(53.489728886787496, 53.5082431857917), + Offset(53.369891930287494, 53.38396782349169), + Offset(53.2500549737875, 53.25969246119169), + Offset(53.1302180172875, 53.13541709889169), + Offset(53.0103810606875, 53.011141736591696), + Offset(52.8905441041875, 52.8868663742917), + Offset(52.770707147687496, 52.7625910118917), + Offset(52.650870191187494, 52.63831564959169), + Offset(52.5310332346875, 52.51404028729169), + Offset(52.411196278087495, 52.389764924991695), + Offset(52.2913593215875, 52.2654895626917), + Offset(52.1715223650875, 52.1412142002917), + Offset(52.051685408587495, 52.01693883799169), + Offset(51.9318484519875, 51.89266347569169), + Offset(51.812011495487496, 51.768388113391694), + Offset(51.692174538987494, 51.6441127510917), + Offset(51.5723375824875, 51.5198373886917), + Offset(51.4525006259875, 51.39556202639169), + Offset(51.33266366938749, 51.27128666409169), + Offset(51.2128267128875, 51.14701130179169), + Offset(51.092989756387496, 51.022735939491696), + Offset(50.973152799887494, 50.8984605771917), + Offset(50.8533158432875, 50.7741852147917), + Offset(50.733478886787495, 50.64990985249169), + Offset(50.6136419302875, 50.52563449019169), + Offset(50.4938049737875, 50.401359127891695), + Offset(50.373968017287496, 50.2770837655917), + Offset(50.2541310606875, 50.1528084031917), + Offset(50.1342941041875, 50.02853304089169), + ], + <Offset>[ + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.90270712071989, 26.419565760864774), + Offset(54.71531815301439, 27.21926680424857), + Offset(53.71654072312347, 31.18281367228795), + Offset(52.62928729387452, 35.1962381308779), + Offset(51.53045534970737, 39.21738368206378), + Offset(50.52706670358306, 43.187670298244306), + Offset(49.7854889546519, 47.01626356414165), + Offset(50.283999172374806, 50.165899443393734), + Offset(52.42717102172652, 52.417745301530715), + Offset(53.74879696492653, 53.77618807553071), + Offset(53.62896000832653, 53.651912713230715), + Offset(53.50912305182652, 53.527637350830716), + Offset(53.389286095326526, 53.40336198853072), + Offset(53.26944913882653, 53.27908662623072), + Offset(53.14961218232652, 53.154811263930725), + Offset(53.029775225726524, 53.030535901630714), + Offset(52.90993826922653, 52.90626053933072), + Offset(52.79010131272652, 52.78198517693072), + Offset(52.670264356226525, 52.65770981463072), + Offset(52.55042739972653, 52.533434452330724), + Offset(52.43059044312652, 52.40915909003071), + Offset(52.310753486626524, 52.284883727730715), + Offset(52.19091653012653, 52.16060836533072), + Offset(52.07107957362652, 52.03633300303072), + Offset(51.95124261702652, 51.91205764073072), + Offset(51.83140566052653, 51.78778227843071), + Offset(51.71156870402652, 51.663506916130714), + Offset(51.591731747526524, 51.539231553730716), + Offset(51.47189479102653, 51.41495619143072), + Offset(51.35205783442652, 51.29068082913072), + Offset(51.23222087792652, 51.166405466830724), + Offset(51.11238392142653, 51.04213010453071), + Offset(50.99254696492652, 50.917854742230716), + Offset(50.87271000832652, 50.79357937983072), + Offset(50.752873051826526, 50.66930401753072), + Offset(50.63303609532653, 50.54502865523072), + Offset(50.51319913882652, 50.42075329293071), + Offset(50.39336218232653, 50.296477930630715), + Offset(50.27352522572653, 50.172202568230716), + Offset(50.15368826922652, 50.04792720593072), + ], + <Offset>[ + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.90270712071989, 36.41758712071989), + Offset(54.71531815301439, 36.87053160703761), + Offset(53.71654072312347, 39.09029363766916), + Offset(52.62928729387452, 41.31071235333787), + Offset(51.53045534970737, 43.53198610663798), + Offset(50.52706670358306, 45.75444966750169), + Offset(49.7854889546519, 47.97877125585276), + Offset(50.283999172374806, 50.20685835445277), + Offset(52.42717102172652, 52.44166244630614), + Offset(53.74879696492653, 53.800105220306136), + Offset(53.62896000832653, 53.67582985800614), + Offset(53.50912305182652, 53.55155449560614), + Offset(53.389286095326526, 53.427279133306136), + Offset(53.26944913882653, 53.30300377100614), + Offset(53.14961218232652, 53.17872840870614), + Offset(53.029775225726524, 53.05445304640614), + Offset(52.90993826922653, 52.93017768410614), + Offset(52.79010131272652, 52.80590232170614), + Offset(52.670264356226525, 52.68162695940614), + Offset(52.55042739972653, 52.55735159710614), + Offset(52.43059044312652, 52.43307623480614), + Offset(52.310753486626524, 52.30880087250614), + Offset(52.19091653012653, 52.18452551010614), + Offset(52.07107957362652, 52.06025014780614), + Offset(51.95124261702652, 51.93597478550614), + Offset(51.83140566052653, 51.811699423206136), + Offset(51.71156870402652, 51.68742406090614), + Offset(51.591731747526524, 51.56314869850614), + Offset(51.47189479102653, 51.438873336206136), + Offset(51.35205783442652, 51.31459797390614), + Offset(51.23222087792652, 51.19032261160614), + Offset(51.11238392142653, 51.06604724930614), + Offset(50.99254696492652, 50.94177188700614), + Offset(50.87271000832652, 50.81749652460614), + Offset(50.752873051826526, 50.69322116230614), + Offset(50.63303609532653, 50.56894580000614), + Offset(50.51319913882652, 50.44467043770614), + Offset(50.39336218232653, 50.32039507540614), + Offset(50.27352522572653, 50.19611971300614), + Offset(50.15368826922652, 50.07184435070614), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.90270712071989, 46.41563917637766), + Offset(54.71531815301439, 46.52182603428994), + Offset(53.71654072312347, 46.99779784106601), + Offset(52.62928729387452, 47.42520527750929), + Offset(51.53045534970737, 47.84660167834321), + Offset(50.52706670358306, 48.32123679418471), + Offset(49.7854889546519, 48.94128176513705), + Offset(50.283999172374806, 50.247817265511806), + Offset(52.42717102172652, 52.46557959108156), + Offset(53.74879696492653, 53.824022365081554), + Offset(53.62896000832653, 53.69974700278156), + Offset(53.50912305182652, 53.57547164038156), + Offset(53.389286095326526, 53.451196278081554), + Offset(53.26944913882653, 53.32692091578156), + Offset(53.14961218232652, 53.20264555348156), + Offset(53.029775225726524, 53.078370191181556), + Offset(52.90993826922653, 52.95409482888156), + Offset(52.79010131272652, 52.82981946648156), + Offset(52.670264356226525, 52.705544104181556), + Offset(52.55042739972653, 52.58126874188156), + Offset(52.43059044312652, 52.456993379581554), + Offset(52.310753486626524, 52.33271801728156), + Offset(52.19091653012653, 52.20844265488156), + Offset(52.07107957362652, 52.084167292581554), + Offset(51.95124261702652, 51.95989193028156), + Offset(51.83140566052653, 51.83561656798155), + Offset(51.71156870402652, 51.711341205681556), + Offset(51.591731747526524, 51.58706584328156), + Offset(51.47189479102653, 51.46279048098155), + Offset(51.35205783442652, 51.338515118681556), + Offset(51.23222087792652, 51.21423975638156), + Offset(51.11238392142653, 51.089964394081555), + Offset(50.99254696492652, 50.96568903178156), + Offset(50.87271000832652, 50.84141366938156), + Offset(50.752873051826526, 50.717138307081555), + Offset(50.63303609532653, 50.59286294478156), + Offset(50.51319913882652, 50.468587582481554), + Offset(50.39336218232653, 50.34431222018156), + Offset(50.27352522572653, 50.22003685778156), + Offset(50.15368826922652, 50.095761495481554), + ], + <Offset>[ + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.79563917637766, 54.522707120719886), + Offset(46.88942369051005, 54.34772049679427), + Offset(47.30462401296601, 53.40971455122347), + Offset(47.67125996500929, 52.38323260637452), + Offset(48.031884881450786, 51.34517214659979), + Offset(48.44574851300257, 50.4025549847652), + Offset(49.00502199955961, 49.72174872023579), + Offset(50.25078601552198, 50.28103042236462), + Offset(52.4077768566875, 52.48497375612058), + Offset(53.729402799887495, 53.84341653012058), + Offset(53.6095658432875, 53.71914116782058), + Offset(53.489728886787496, 53.59486580542058), + Offset(53.369891930287494, 53.47059044312058), + Offset(53.2500549737875, 53.34631508082058), + Offset(53.1302180172875, 53.222039718520584), + Offset(53.0103810606875, 53.09776435622058), + Offset(52.8905441041875, 52.97348899392058), + Offset(52.770707147687496, 52.849213631520584), + Offset(52.650870191187494, 52.72493826922058), + Offset(52.5310332346875, 52.60066290692058), + Offset(52.411196278087495, 52.47638754462058), + Offset(52.2913593215875, 52.35211218232058), + Offset(52.1715223650875, 52.22783681992058), + Offset(52.051685408587495, 52.10356145762058), + Offset(51.9318484519875, 51.97928609532058), + Offset(51.812011495487496, 51.85501073302058), + Offset(51.692174538987494, 51.73073537072058), + Offset(51.5723375824875, 51.60646000832058), + Offset(51.4525006259875, 51.48218464602058), + Offset(51.33266366938749, 51.35790928372058), + Offset(51.2128267128875, 51.233633921420584), + Offset(51.092989756387496, 51.10935855912058), + Offset(50.973152799887494, 50.98508319682058), + Offset(50.8533158432875, 50.860807834420584), + Offset(50.733478886787495, 50.73653247212058), + Offset(50.6136419302875, 50.61225710982058), + Offset(50.4938049737875, 50.48798174752058), + Offset(50.373968017287496, 50.36370638522058), + Offset(50.2541310606875, 50.23943102282058), + Offset(50.1342941041875, 50.11515566052058), + ], + <Offset>[ + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(36.79758712071989, 54.522707120719886), + Offset(37.238129263257726, 54.34772049679427), + Offset(39.39711980956916, 53.40971455122347), + Offset(41.55676704083787, 52.38323260637452), + Offset(43.717269309745554, 51.34517214659979), + Offset(45.878961386319546, 50.4025549847652), + Offset(48.04251149026888, 49.72174872023579), + Offset(50.209827104462946, 50.28103042236462), + Offset(52.38385971191208, 52.48497375612058), + Offset(53.70548565511208, 53.84341653012058), + Offset(53.58564869851208, 53.71914116782058), + Offset(53.46581174201208, 53.59486580542058), + Offset(53.34597478551208, 53.47059044312058), + Offset(53.22613782901208, 53.34631508082058), + Offset(53.10630087251208, 53.222039718520584), + Offset(52.98646391591208, 53.09776435622058), + Offset(52.86662695941208, 52.97348899392058), + Offset(52.74679000291208, 52.849213631520584), + Offset(52.626953046412076, 52.72493826922058), + Offset(52.50711608991208, 52.60066290692058), + Offset(52.38727913331208, 52.47638754462058), + Offset(52.26744217681208, 52.35211218232058), + Offset(52.14760522031208, 52.22783681992058), + Offset(52.02776826381208, 52.10356145762058), + Offset(51.90793130721208, 51.97928609532058), + Offset(51.78809435071208, 51.85501073302058), + Offset(51.66825739421208, 51.73073537072058), + Offset(51.54842043771208, 51.60646000832058), + Offset(51.42858348121208, 51.48218464602058), + Offset(51.308746524612076, 51.35790928372058), + Offset(51.18890956811208, 51.233633921420584), + Offset(51.06907261161208, 51.10935855912058), + Offset(50.949235655112076, 50.98508319682058), + Offset(50.82939869851208, 50.860807834420584), + Offset(50.70956174201208, 50.73653247212058), + Offset(50.58972478551208, 50.61225710982058), + Offset(50.46988782901208, 50.48798174752058), + Offset(50.35005087251208, 50.36370638522058), + Offset(50.23021391591208, 50.23943102282058), + Offset(50.11037695941208, 50.11515566052058), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + ]), + _PathCubicTo( + <Offset>[ + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + Offset(60.937745291919896, 54.52270224391989), + ], + <Offset>[ + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + ], + <Offset>[ + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + Offset(57.739181458913336, 54.52270224391989), + ], + <Offset>[ + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.39144480852299, 53.174996289330196), + Offset(56.43397725114974, 53.217528731956946), + Offset(56.47650969377649, 53.2600611745837), + Offset(56.51904213640324, 53.30259361721045), + Offset(56.561574579029994, 53.3451260598372), + Offset(56.604107021656745, 53.38765850246395), + Offset(56.62684855989554, 53.41109453319635), + Offset(56.60877135783367, 53.39514421439123), + Offset(56.5906941557718, 53.37919389558813), + Offset(56.572616953707914, 53.363243576785024), + Offset(56.55453975164605, 53.347293257979906), + Offset(56.53646254958216, 53.33134293917681), + Offset(56.518385347520294, 53.3153926203737), + Offset(56.50030814545842, 53.29944230156859), + Offset(56.48223094339454, 53.28349198276548), + Offset(56.464153741332666, 53.267541663962376), + Offset(56.44607653927079, 53.251591345157266), + Offset(56.42799933720691, 53.23564102635416), + Offset(56.40992213514504, 53.21969070755106), + Offset(56.39184493308116, 53.20374038874594), + Offset(56.373767731019285, 53.187790069942835), + Offset(56.35569052895741, 53.171839751139736), + Offset(56.33761332689353, 53.15588943233462), + Offset(56.31953612483166, 53.13993911353152), + Offset(56.30145892276979, 53.12398879472841), + Offset(56.283381720705904, 53.108038475923294), + Offset(56.26530451864404, 53.092088157120195), + Offset(56.24722731658217, 53.07613783831709), + Offset(56.22915011451828, 53.06018751951198), + Offset(56.211072912456416, 53.04423720070887), + Offset(56.19299571039253, 53.02828688190577), + Offset(56.174918508330656, 53.012336563100654), + Offset(56.15684130626879, 52.996386244297554), + Offset(56.1387641042049, 52.98043592549445), + Offset(56.120686902143035, 52.96448560668933), + Offset(56.10260970008116, 52.94853528788623), + Offset(56.08453249801728, 52.93258496908312), + Offset(56.06645529595541, 52.916634650278006), + Offset(56.04837809389354, 52.900684331474906), + ], + <Offset>[ + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.3827647181903, 53.16631619899751), + Offset(56.39144480852299, 53.174996289330196), + Offset(56.43397725114974, 53.217528731956946), + Offset(56.47650969377649, 53.2600611745837), + Offset(56.51904213640324, 53.30259361721045), + Offset(56.561574579029994, 53.3451260598372), + Offset(56.604107021656745, 53.38765850246395), + Offset(56.62684855989554, 53.41109453319635), + Offset(56.60877135783367, 53.39514421439123), + Offset(56.5906941557718, 53.37919389558813), + Offset(56.572616953707914, 53.363243576785024), + Offset(56.55453975164605, 53.347293257979906), + Offset(56.53646254958216, 53.33134293917681), + Offset(56.518385347520294, 53.3153926203737), + Offset(56.50030814545842, 53.29944230156859), + Offset(56.48223094339454, 53.28349198276548), + Offset(56.464153741332666, 53.267541663962376), + Offset(56.44607653927079, 53.251591345157266), + Offset(56.42799933720691, 53.23564102635416), + Offset(56.40992213514504, 53.21969070755106), + Offset(56.39184493308116, 53.20374038874594), + Offset(56.373767731019285, 53.187790069942835), + Offset(56.35569052895741, 53.171839751139736), + Offset(56.33761332689353, 53.15588943233462), + Offset(56.31953612483166, 53.13993911353152), + Offset(56.30145892276979, 53.12398879472841), + Offset(56.283381720705904, 53.108038475923294), + Offset(56.26530451864404, 53.092088157120195), + Offset(56.24722731658217, 53.07613783831709), + Offset(56.22915011451828, 53.06018751951198), + Offset(56.211072912456416, 53.04423720070887), + Offset(56.19299571039253, 53.02828688190577), + Offset(56.174918508330656, 53.012336563100654), + Offset(56.15684130626879, 52.996386244297554), + Offset(56.1387641042049, 52.98043592549445), + Offset(56.120686902143035, 52.96448560668933), + Offset(56.10260970008116, 52.94853528788623), + Offset(56.08453249801728, 52.93258496908312), + Offset(56.06645529595541, 52.916634650278006), + Offset(56.04837809389354, 52.900684331474906), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.3827647181903, 53.16627596539751), + Offset(56.39144480852299, 53.1749560557302), + Offset(56.43397725114974, 53.21748849835695), + Offset(56.47650969377649, 53.2600209409837), + Offset(56.51904213640324, 53.302553383610444), + Offset(56.561574579029994, 53.345085826237195), + Offset(56.604107021656745, 53.387618268863946), + Offset(56.62684855989554, 53.411054299596344), + Offset(56.60877135783367, 53.395103980791234), + Offset(56.5906941557718, 53.37915366198813), + Offset(56.572616953707914, 53.36320334318502), + Offset(56.55453975164605, 53.34725302437991), + Offset(56.53646254958216, 53.3313027055768), + Offset(56.518385347520294, 53.315352386773704), + Offset(56.50030814545842, 53.299402067968586), + Offset(56.48223094339454, 53.28345174916548), + Offset(56.464153741332666, 53.26750143036238), + Offset(56.44607653927079, 53.25155111155726), + Offset(56.42799933720691, 53.23560079275416), + Offset(56.40992213514504, 53.219650473951056), + Offset(56.39184493308116, 53.20370015514594), + Offset(56.373767731019285, 53.18774983634284), + Offset(56.35569052895741, 53.17179951753973), + Offset(56.33761332689353, 53.15584919873462), + Offset(56.31953612483166, 53.139898879931515), + Offset(56.30145892276979, 53.123948561128415), + Offset(56.283381720705904, 53.1079982423233), + Offset(56.26530451864404, 53.0920479235202), + Offset(56.24722731658217, 53.07609760471709), + Offset(56.22915011451828, 53.060147285911974), + Offset(56.211072912456416, 53.044196967108874), + Offset(56.19299571039253, 53.02824664830577), + Offset(56.174918508330656, 53.01229632950065), + Offset(56.15684130626879, 52.99634601069755), + Offset(56.1387641042049, 52.980395691894444), + Offset(56.120686902143035, 52.96444537308933), + Offset(56.10260970008116, 52.94849505428623), + Offset(56.08453249801728, 52.93254473548313), + Offset(56.06645529595541, 52.91659441667801), + Offset(56.04837809389354, 52.9006440978749), + ], + <Offset>[ + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.54631924699953, 56.02289811551084), + Offset(53.55499933733222, 56.03157820584353), + Offset(53.59753177995896, 56.07411064847028), + Offset(53.64006422258572, 56.11664309109703), + Offset(53.682596665212465, 56.15917553372378), + Offset(53.725129107839216, 56.201707976350534), + Offset(53.76766155046597, 56.244240418977284), + Offset(53.790403088704764, 56.26767644970968), + Offset(53.77232588664289, 56.251726130904565), + Offset(53.75424868458102, 56.235775812101465), + Offset(53.736171482517136, 56.21982549329836), + Offset(53.71809428045527, 56.20387517449325), + Offset(53.70001707839138, 56.18792485569014), + Offset(53.68193987632951, 56.171974536887035), + Offset(53.66386267426764, 56.156024218081924), + Offset(53.645785472203755, 56.14007389927882), + Offset(53.62770827014189, 56.12412358047571), + Offset(53.60963106808002, 56.1081732616706), + Offset(53.591553866016135, 56.092222942867494), + Offset(53.57347666395426, 56.076272624064394), + Offset(53.55539946189038, 56.06032230525928), + Offset(53.53732225982851, 56.04437198645618), + Offset(53.51924505776664, 56.02842166765307), + Offset(53.501167855702754, 56.01247134884795), + Offset(53.483090653640886, 55.99652103004485), + Offset(53.46501345157901, 55.980570711241754), + Offset(53.44693624951513, 55.964620392436636), + Offset(53.42885904745326, 55.94867007363353), + Offset(53.41078184539139, 55.93271975483043), + Offset(53.392704643327505, 55.91676943602531), + Offset(53.37462744126564, 55.90081911722221), + Offset(53.35655023920175, 55.884868798419106), + Offset(53.33847303713988, 55.86891847961399), + Offset(53.32039583507801, 55.85296816081089), + Offset(53.302318633014124, 55.83701784200778), + Offset(53.28424143095226, 55.821067523202665), + Offset(53.26616422889038, 55.805117204399565), + Offset(53.248087026826504, 55.78916688559646), + Offset(53.23000982476463, 55.77321656679135), + Offset(53.21193262270276, 55.75726624798824), + ], + <Offset>[ + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.54628855119686, 56.02289811551084), + Offset(53.55496864152955, 56.03157820584353), + Offset(53.5975010841563, 56.07411064847028), + Offset(53.64003352678305, 56.11664309109703), + Offset(53.6825659694098, 56.15917553372378), + Offset(53.72509841203655, 56.201707976350534), + Offset(53.7676308546633, 56.244240418977284), + Offset(53.79037239290209, 56.26767644970968), + Offset(53.772295190840225, 56.251726130904565), + Offset(53.75421798877835, 56.235775812101465), + Offset(53.73614078671447, 56.21982549329836), + Offset(53.7180635846526, 56.20387517449325), + Offset(53.69998638258872, 56.18792485569014), + Offset(53.681909180526844, 56.171974536887035), + Offset(53.66383197846497, 56.156024218081924), + Offset(53.64575477640109, 56.14007389927882), + Offset(53.62767757433922, 56.12412358047571), + Offset(53.60960037227735, 56.1081732616706), + Offset(53.59152317021346, 56.092222942867494), + Offset(53.573445968151596, 56.076272624064394), + Offset(53.55536876608771, 56.06032230525928), + Offset(53.53729156402584, 56.04437198645618), + Offset(53.51921436196397, 56.02842166765307), + Offset(53.50113715990009, 56.01247134884795), + Offset(53.483059957838215, 55.99652103004485), + Offset(53.46498275577635, 55.980570711241754), + Offset(53.44690555371246, 55.964620392436636), + Offset(53.428828351650594, 55.94867007363353), + Offset(53.41075114958872, 55.93271975483043), + Offset(53.392673947524834, 55.91676943602531), + Offset(53.374596745462966, 55.90081911722221), + Offset(53.35651954339908, 55.884868798419106), + Offset(53.33844234133721, 55.86891847961399), + Offset(53.32036513927534, 55.85296816081089), + Offset(53.30228793721146, 55.83701784200778), + Offset(53.284210735149586, 55.821067523202665), + Offset(53.26613353308772, 55.805117204399565), + Offset(53.24805633102383, 55.78916688559646), + Offset(53.229979128961965, 55.77321656679135), + Offset(53.21190192690009, 55.75726624798824), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.54628855119686, 56.022857881910845), + Offset(53.55496864152955, 56.031537972243534), + Offset(53.5975010841563, 56.074070414870285), + Offset(53.64003352678305, 56.116602857497035), + Offset(53.6825659694098, 56.159135300123786), + Offset(53.72509841203655, 56.20166774275054), + Offset(53.7676308546633, 56.24420018537729), + Offset(53.79037239290209, 56.26763621610968), + Offset(53.772295190840225, 56.25168589730457), + Offset(53.75421798877835, 56.23573557850146), + Offset(53.73614078671447, 56.21978525969836), + Offset(53.7180635846526, 56.203834940893245), + Offset(53.69998638258872, 56.18788462209014), + Offset(53.681909180526844, 56.17193430328704), + Offset(53.66383197846497, 56.15598398448192), + Offset(53.64575477640109, 56.14003366567882), + Offset(53.62767757433922, 56.124083346875715), + Offset(53.60960037227735, 56.1081330280706), + Offset(53.59152317021346, 56.0921827092675), + Offset(53.573445968151596, 56.0762323904644), + Offset(53.55536876608771, 56.06028207165928), + Offset(53.53729156402584, 56.04433175285617), + Offset(53.51921436196397, 56.028381434053074), + Offset(53.50113715990009, 56.012431115247956), + Offset(53.483059957838215, 55.99648079644485), + Offset(53.46498275577635, 55.98053047764175), + Offset(53.44690555371246, 55.96458015883663), + Offset(53.428828351650594, 55.94862984003353), + Offset(53.41075114958872, 55.932679521230426), + Offset(53.392673947524834, 55.916729202425316), + Offset(53.374596745462966, 55.90077888362221), + Offset(53.35651954339908, 55.8848285648191), + Offset(53.33844234133721, 55.86887824601399), + Offset(53.32036513927534, 55.852927927210885), + Offset(53.30228793721146, 55.83697760840778), + Offset(53.284210735149586, 55.82102728960267), + Offset(53.26613353308772, 55.80507697079956), + Offset(53.24805633102383, 55.78912665199646), + Offset(53.229979128961965, 55.773176333191344), + Offset(53.21190192690009, 55.75722601438824), + ], + <Offset>[ + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + ], + <Offset>[ + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291921905, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291921905, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.379314856233876), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.37931485623388), + Offset(54.902705291919894, 57.379314856233876), + ], + <Offset>[ + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291921905, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + ], + <Offset>[ + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291921905, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291921905, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + Offset(54.902705291919894, 60.557742243937994), + ], + <Offset>[ + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(74.32497336367568, 79.93649262312883), + Offset(70.92176691523076, 76.5170858263241), + Offset(67.51856046678586, 73.09767902951937), + Offset(64.11535401834094, 69.67827223271463), + Offset(60.71214756989602, 66.25886543593002), + Offset(57.30894112147122, 62.83945863912528), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.01698575823052, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + ], + <Offset>[ + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(74.32497336367568, 79.93649262312883), + Offset(70.92176691523076, 76.5170858263241), + Offset(67.51856046678586, 73.09767902951937), + Offset(64.11535401834094, 69.67827223271463), + Offset(60.71214756989602, 66.25886543593002), + Offset(57.30894112147122, 62.83945863912528), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.01698575823052, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(75.0195052919199, 80.63433074491833), + Offset(74.32497336367568, 79.93649262312883), + Offset(70.92176691523076, 76.5170858263241), + Offset(67.51856046678586, 73.09767902951937), + Offset(64.11535401834094, 69.67827223271463), + Offset(60.71214756989602, 66.25886543593002), + Offset(57.30894112147122, 62.83945863912528), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.01698575823052, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + Offset(55.016985758228515, 60.536592837199635), + ], + <Offset>[ + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(80.31980186465601, 73.9416641221485), + Offset(76.9165954162111, 70.52225732534376), + Offset(73.51338896776619, 67.10285052853904), + Offset(70.11018251932127, 63.6834437317343), + Offset(66.70697607087635, 60.26403693494969), + Offset(63.30376962245156, 56.84463013814495), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425921086, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + ], + <Offset>[ + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(80.31980186465601, 73.9416641221485), + Offset(76.9165954162111, 70.52225732534376), + Offset(73.51338896776619, 67.10285052853904), + Offset(70.11018251932127, 63.6834437317343), + Offset(66.70697607087635, 60.26403693494969), + Offset(63.30376962245156, 56.84463013814495), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425921086, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(81.01433379290023, 74.639502243938), + Offset(80.31980186465601, 73.9416641221485), + Offset(76.9165954162111, 70.52225732534376), + Offset(73.51338896776619, 67.10285052853904), + Offset(70.11018251932127, 63.6834437317343), + Offset(66.70697607087635, 60.26403693494969), + Offset(63.30376962245156, 56.84463013814495), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425921086, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + Offset(61.01181425920885, 54.5417643362193), + ], + <Offset>[ + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224391788), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192572, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + ], + <Offset>[ + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529191989, 54.52270224393799), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.937745291919896, 54.52270224391788), + Offset(60.937745291919896, 54.52270224393799), + Offset(60.93774529194001, 54.52270224393799), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192572, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + Offset(60.93774529192372, 54.52270224393415), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 54.754867779413956), + Offset(48.0, 51.604258802513954), + Offset(48.0, 48.45364982561395), + Offset(48.0, 45.30304084871395), + Offset(48.0, 42.15243187181395), + Offset(48.0, 39.00182289491395), + Offset(48.0, 35.85121391801395), + Offset(48.0, 34.05512107195435), + Offset(48.0, 33.540905901349916), + Offset(48.0, 33.12129231086275), + Offset(48.0, 32.781503706824346), + Offset(48.0, 32.50949551311259), + Offset(48.0, 32.29485026134325), + Offset(48.0, 32.1283960707288), + Offset(48.0, 32.00233286349681), + Offset(48.0, 31.909924001704802), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + ]), + _PathCubicTo( + <Offset>[ + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 55.9765324848), + Offset(52.445826306158004, 54.754867779413956), + Offset(52.445826306158004, 51.604258802513954), + Offset(52.445826306158004, 48.45364982561395), + Offset(52.445826306158004, 45.30304084871395), + Offset(52.445826306158004, 42.15243187181395), + Offset(52.445826306158004, 39.00182289491395), + Offset(52.445826306158004, 35.85121391801395), + Offset(52.445826306158004, 34.05512107195435), + Offset(52.445826306158004, 33.540905901349916), + Offset(52.445826306158004, 33.12129231086275), + Offset(52.445826306158004, 32.781503706824346), + Offset(52.445826306158004, 32.50949551311259), + Offset(52.445826306158004, 32.29485026134325), + Offset(52.445826306158004, 32.1283960707288), + Offset(52.445826306158004, 32.00233286349681), + Offset(52.445826306158004, 31.909924001704802), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + Offset(52.445826306158004, 31.90656), + ], + <Offset>[ + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 52.37560809515534), + Offset(56.04672, 51.153943389769296), + Offset(56.04672, 48.00333441286929), + Offset(56.04672, 44.85272543596929), + Offset(56.04672, 41.70211645906929), + Offset(56.04672, 38.551507482169285), + Offset(56.04672, 35.40089850526928), + Offset(56.04672, 32.25028952836928), + Offset(56.04672, 30.45419668230969), + Offset(56.04672, 29.939981511705252), + Offset(56.04672, 29.520367921218085), + Offset(56.04672, 29.18057931717968), + Offset(56.04672, 28.908571123467926), + Offset(56.04672, 28.693925871698585), + Offset(56.04672, 28.527471681084133), + Offset(56.04672, 28.401408473852147), + Offset(56.04672, 28.308999612060138), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + Offset(56.04672, 28.305635610355335), + ], + <Offset>[ + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 47.9298124848), + Offset(56.04672, 46.708147779413956), + Offset(56.04672, 43.55753880251395), + Offset(56.04672, 40.40692982561395), + Offset(56.04672, 37.25632084871395), + Offset(56.04672, 34.10571187181395), + Offset(56.04672, 30.95510289491395), + Offset(56.04672, 27.804493918013947), + Offset(56.04672, 26.008401071954353), + Offset(56.04672, 25.494185901349915), + Offset(56.04672, 25.07457231086275), + Offset(56.04672, 24.734783706824345), + Offset(56.04672, 24.46277551311259), + Offset(56.04672, 24.24813026134325), + Offset(56.04672, 24.081676070728797), + Offset(56.04672, 23.95561286349681), + Offset(56.04672, 23.8632040017048), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + Offset(56.04672, 23.85984), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 43.483986178642), + Offset(56.04672, 42.26232147325595), + Offset(56.04672, 39.11171249635595), + Offset(56.04672, 35.961103519455946), + Offset(56.04672, 32.810494542555944), + Offset(56.04672, 29.65988556565595), + Offset(56.04672, 26.50927658875595), + Offset(56.04672, 23.358667611855946), + Offset(56.04672, 21.562574765796352), + Offset(56.04672, 21.048359595191915), + Offset(56.04672, 20.628746004704748), + Offset(56.04672, 20.288957400666344), + Offset(56.04672, 20.01694920695459), + Offset(56.04672, 19.80230395518525), + Offset(56.04672, 19.635849764570796), + Offset(56.04672, 19.50978655733881), + Offset(56.04672, 19.4173776955468), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + Offset(56.04672, 19.414013693841998), + ], + <Offset>[ + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 39.8830924848), + Offset(52.445826306158004, 38.661427779413955), + Offset(52.445826306158004, 35.51081880251395), + Offset(52.445826306158004, 32.36020982561395), + Offset(52.445826306158004, 29.209600848713947), + Offset(52.445826306158004, 26.05899187181395), + Offset(52.445826306158004, 22.90838289491395), + Offset(52.445826306158004, 19.757773918013946), + Offset(52.445826306158004, 17.961681071954352), + Offset(52.445826306158004, 17.447465901349915), + Offset(52.445826306158004, 17.027852310862748), + Offset(52.445826306158004, 16.688063706824344), + Offset(52.445826306158004, 16.41605551311259), + Offset(52.445826306158004, 16.20141026134325), + Offset(52.445826306158004, 16.034956070728796), + Offset(52.445826306158004, 15.90889286349681), + Offset(52.445826306158004, 15.8164840017048), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + Offset(52.445826306158004, 15.813119999999998), + ], + <Offset>[ + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 39.8830924848), + Offset(48.0, 38.661427779413955), + Offset(48.0, 35.51081880251395), + Offset(48.0, 32.36020982561395), + Offset(48.0, 29.209600848713947), + Offset(48.0, 26.05899187181395), + Offset(48.0, 22.90838289491395), + Offset(48.0, 19.757773918013946), + Offset(48.0, 17.961681071954352), + Offset(48.0, 17.447465901349915), + Offset(48.0, 17.027852310862748), + Offset(48.0, 16.688063706824344), + Offset(48.0, 16.41605551311259), + Offset(48.0, 16.20141026134325), + Offset(48.0, 16.034956070728796), + Offset(48.0, 15.90889286349681), + Offset(48.0, 15.8164840017048), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + Offset(48.0, 15.813119999999998), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 39.8830924848), + Offset(43.554173693841996, 38.661427779413955), + Offset(43.554173693841996, 35.51081880251395), + Offset(43.554173693841996, 32.36020982561395), + Offset(43.554173693841996, 29.209600848713947), + Offset(43.554173693841996, 26.05899187181395), + Offset(43.554173693841996, 22.90838289491395), + Offset(43.554173693841996, 19.757773918013946), + Offset(43.554173693841996, 17.961681071954352), + Offset(43.554173693841996, 17.447465901349915), + Offset(43.554173693841996, 17.027852310862748), + Offset(43.554173693841996, 16.688063706824344), + Offset(43.554173693841996, 16.41605551311259), + Offset(43.554173693841996, 16.20141026134325), + Offset(43.554173693841996, 16.034956070728796), + Offset(43.554173693841996, 15.90889286349681), + Offset(43.554173693841996, 15.8164840017048), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + Offset(43.554173693841996, 15.813119999999998), + ], + <Offset>[ + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 43.483986178642), + Offset(39.95328, 42.26232147325595), + Offset(39.95328, 39.11171249635595), + Offset(39.95328, 35.961103519455946), + Offset(39.95328, 32.810494542555944), + Offset(39.95328, 29.65988556565595), + Offset(39.95328, 26.50927658875595), + Offset(39.95328, 23.358667611855946), + Offset(39.95328, 21.562574765796352), + Offset(39.95328, 21.048359595191915), + Offset(39.95328, 20.628746004704748), + Offset(39.95328, 20.288957400666344), + Offset(39.95328, 20.01694920695459), + Offset(39.95328, 19.80230395518525), + Offset(39.95328, 19.635849764570796), + Offset(39.95328, 19.50978655733881), + Offset(39.95328, 19.4173776955468), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + Offset(39.95328, 19.414013693841998), + ], + <Offset>[ + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 47.9298124848), + Offset(39.95328, 46.708147779413956), + Offset(39.95328, 43.55753880251395), + Offset(39.95328, 40.40692982561395), + Offset(39.95328, 37.25632084871395), + Offset(39.95328, 34.10571187181395), + Offset(39.95328, 30.95510289491395), + Offset(39.95328, 27.804493918013947), + Offset(39.95328, 26.008401071954353), + Offset(39.95328, 25.494185901349915), + Offset(39.95328, 25.07457231086275), + Offset(39.95328, 24.734783706824345), + Offset(39.95328, 24.46277551311259), + Offset(39.95328, 24.24813026134325), + Offset(39.95328, 24.081676070728797), + Offset(39.95328, 23.95561286349681), + Offset(39.95328, 23.8632040017048), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + Offset(39.95328, 23.85984), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 52.37560809515534), + Offset(39.95328, 51.153943389769296), + Offset(39.95328, 48.00333441286929), + Offset(39.95328, 44.85272543596929), + Offset(39.95328, 41.70211645906929), + Offset(39.95328, 38.551507482169285), + Offset(39.95328, 35.40089850526928), + Offset(39.95328, 32.25028952836928), + Offset(39.95328, 30.45419668230969), + Offset(39.95328, 29.939981511705252), + Offset(39.95328, 29.520367921218085), + Offset(39.95328, 29.18057931717968), + Offset(39.95328, 28.908571123467926), + Offset(39.95328, 28.693925871698585), + Offset(39.95328, 28.527471681084133), + Offset(39.95328, 28.401408473852147), + Offset(39.95328, 28.308999612060138), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + Offset(39.95328, 28.305635610355335), + ], + <Offset>[ + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 55.9765324848), + Offset(43.554173693841996, 54.754867779413956), + Offset(43.554173693841996, 51.604258802513954), + Offset(43.554173693841996, 48.45364982561395), + Offset(43.554173693841996, 45.30304084871395), + Offset(43.554173693841996, 42.15243187181395), + Offset(43.554173693841996, 39.00182289491395), + Offset(43.554173693841996, 35.85121391801395), + Offset(43.554173693841996, 34.05512107195435), + Offset(43.554173693841996, 33.540905901349916), + Offset(43.554173693841996, 33.12129231086275), + Offset(43.554173693841996, 32.781503706824346), + Offset(43.554173693841996, 32.50949551311259), + Offset(43.554173693841996, 32.29485026134325), + Offset(43.554173693841996, 32.1283960707288), + Offset(43.554173693841996, 32.00233286349681), + Offset(43.554173693841996, 31.909924001704802), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + Offset(43.554173693841996, 31.90656), + ], + <Offset>[ + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 55.9765324848), + Offset(48.0, 54.754867779413956), + Offset(48.0, 51.604258802513954), + Offset(48.0, 48.45364982561395), + Offset(48.0, 45.30304084871395), + Offset(48.0, 42.15243187181395), + Offset(48.0, 39.00182289491395), + Offset(48.0, 35.85121391801395), + Offset(48.0, 34.05512107195435), + Offset(48.0, 33.540905901349916), + Offset(48.0, 33.12129231086275), + Offset(48.0, 32.781503706824346), + Offset(48.0, 32.50949551311259), + Offset(48.0, 32.29485026134325), + Offset(48.0, 32.1283960707288), + Offset(48.0, 32.00233286349681), + Offset(48.0, 31.909924001704802), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + Offset(48.0, 31.90656), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + ]), + _PathCubicTo( + <Offset>[ + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + Offset(43.554173693841996, 39.95328), + ], + <Offset>[ + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + Offset(39.95328, 43.554173693841996), + ], + <Offset>[ + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + Offset(39.95328, 48.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + Offset(39.95328, 52.445826306158004), + ], + <Offset>[ + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + Offset(43.554173693841996, 56.04672), + ], + <Offset>[ + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + Offset(48.0, 56.04672), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + Offset(52.445826306158004, 56.04672), + ], + <Offset>[ + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + Offset(56.04672, 52.445826306158004), + ], + <Offset>[ + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + Offset(56.04672, 48.0), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + Offset(56.04672, 43.554173693841996), + ], + <Offset>[ + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + Offset(52.445826306158004, 39.95328), + ], + <Offset>[ + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + Offset(48.0, 39.95328), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 41.2763342754), + Offset(48.0, 44.422639514400004), + Offset(48.0, 47.568944753400004), + Offset(48.0, 50.715249992400004), + Offset(48.0, 53.861555231400004), + Offset(48.0, 57.0078604704), + Offset(48.0, 60.154165709400004), + Offset(48.0, 61.94780621836377), + Offset(48.0, 62.46132080048674), + Offset(48.0, 62.88036269183587), + Offset(48.0, 63.219688353387404), + Offset(48.0, 63.491325951788546), + Offset(48.0, 63.70567876161293), + Offset(48.0, 63.87190616809776), + Offset(48.0, 63.997797621511204), + Offset(48.0, 64.09008058170691), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + ]), + _PathCubicTo( + <Offset>[ + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 40.0563383664), + Offset(43.554173693841996, 41.2763342754), + Offset(43.554173693841996, 44.422639514400004), + Offset(43.554173693841996, 47.568944753400004), + Offset(43.554173693841996, 50.715249992400004), + Offset(43.554173693841996, 53.861555231400004), + Offset(43.554173693841996, 57.0078604704), + Offset(43.554173693841996, 60.154165709400004), + Offset(43.554173693841996, 61.94780621836377), + Offset(43.554173693841996, 62.46132080048674), + Offset(43.554173693841996, 62.88036269183587), + Offset(43.554173693841996, 63.219688353387404), + Offset(43.554173693841996, 63.491325951788546), + Offset(43.554173693841996, 63.70567876161293), + Offset(43.554173693841996, 63.87190616809776), + Offset(43.554173693841996, 63.997797621511204), + Offset(43.554173693841996, 64.09008058170691), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + Offset(43.554173693841996, 64.09344), + ], + <Offset>[ + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 43.65726275604466), + Offset(39.95328, 44.877258665044664), + Offset(39.95328, 48.023563904044664), + Offset(39.95328, 51.169869143044664), + Offset(39.95328, 54.316174382044665), + Offset(39.95328, 57.462479621044665), + Offset(39.95328, 60.608784860044665), + Offset(39.95328, 63.755090099044665), + Offset(39.95328, 65.54873060800844), + Offset(39.95328, 66.0622451901314), + Offset(39.95328, 66.48128708148053), + Offset(39.95328, 66.82061274303207), + Offset(39.95328, 67.09225034143321), + Offset(39.95328, 67.30660315125759), + Offset(39.95328, 67.47283055774243), + Offset(39.95328, 67.59872201115587), + Offset(39.95328, 67.69100497135157), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + Offset(39.95328, 67.69436438964466), + ], + <Offset>[ + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 48.1030583664), + Offset(39.95328, 49.3230542754), + Offset(39.95328, 52.4693595144), + Offset(39.95328, 55.6156647534), + Offset(39.95328, 58.761969992400005), + Offset(39.95328, 61.908275231400005), + Offset(39.95328, 65.0545804704), + Offset(39.95328, 68.20088570940001), + Offset(39.95328, 69.99452621836377), + Offset(39.95328, 70.50804080048674), + Offset(39.95328, 70.92708269183586), + Offset(39.95328, 71.2664083533874), + Offset(39.95328, 71.53804595178855), + Offset(39.95328, 71.75239876161294), + Offset(39.95328, 71.91862616809776), + Offset(39.95328, 72.0445176215112), + Offset(39.95328, 72.13680058170692), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + Offset(39.95328, 72.14016000000001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 52.548884672558), + Offset(39.95328, 53.768880581558), + Offset(39.95328, 56.915185820558), + Offset(39.95328, 60.061491059558), + Offset(39.95328, 63.20779629855801), + Offset(39.95328, 66.35410153755801), + Offset(39.95328, 69.500406776558), + Offset(39.95328, 72.64671201555801), + Offset(39.95328, 74.44035252452177), + Offset(39.95328, 74.95386710664474), + Offset(39.95328, 75.37290899799387), + Offset(39.95328, 75.7122346595454), + Offset(39.95328, 75.98387225794654), + Offset(39.95328, 76.19822506777093), + Offset(39.95328, 76.36445247425576), + Offset(39.95328, 76.4903439276692), + Offset(39.95328, 76.58262688786492), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + Offset(39.95328, 76.585986306158), + ], + <Offset>[ + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 56.1497783664), + Offset(43.554173693841996, 57.369774275400005), + Offset(43.554173693841996, 60.516079514400005), + Offset(43.554173693841996, 63.662384753400005), + Offset(43.554173693841996, 66.8086899924), + Offset(43.554173693841996, 69.9549952314), + Offset(43.554173693841996, 73.10130047039999), + Offset(43.554173693841996, 76.2476057094), + Offset(43.554173693841996, 78.04124621836377), + Offset(43.554173693841996, 78.55476080048675), + Offset(43.554173693841996, 78.97380269183587), + Offset(43.554173693841996, 79.31312835338741), + Offset(43.554173693841996, 79.58476595178854), + Offset(43.554173693841996, 79.79911876161293), + Offset(43.554173693841996, 79.96534616809777), + Offset(43.554173693841996, 80.0912376215112), + Offset(43.554173693841996, 80.18352058170692), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + Offset(43.554173693841996, 80.18688), + ], + <Offset>[ + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 56.1497783664), + Offset(48.0, 57.369774275400005), + Offset(48.0, 60.516079514400005), + Offset(48.0, 63.662384753400005), + Offset(48.0, 66.8086899924), + Offset(48.0, 69.9549952314), + Offset(48.0, 73.10130047039999), + Offset(48.0, 76.2476057094), + Offset(48.0, 78.04124621836377), + Offset(48.0, 78.55476080048675), + Offset(48.0, 78.97380269183587), + Offset(48.0, 79.31312835338741), + Offset(48.0, 79.58476595178854), + Offset(48.0, 79.79911876161293), + Offset(48.0, 79.96534616809777), + Offset(48.0, 80.0912376215112), + Offset(48.0, 80.18352058170692), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + Offset(48.0, 80.18688), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 56.1497783664), + Offset(52.445826306158004, 57.369774275400005), + Offset(52.445826306158004, 60.516079514400005), + Offset(52.445826306158004, 63.662384753400005), + Offset(52.445826306158004, 66.8086899924), + Offset(52.445826306158004, 69.9549952314), + Offset(52.445826306158004, 73.10130047039999), + Offset(52.445826306158004, 76.2476057094), + Offset(52.445826306158004, 78.04124621836377), + Offset(52.445826306158004, 78.55476080048675), + Offset(52.445826306158004, 78.97380269183587), + Offset(52.445826306158004, 79.31312835338741), + Offset(52.445826306158004, 79.58476595178854), + Offset(52.445826306158004, 79.79911876161293), + Offset(52.445826306158004, 79.96534616809777), + Offset(52.445826306158004, 80.0912376215112), + Offset(52.445826306158004, 80.18352058170692), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + Offset(52.445826306158004, 80.18688), + ], + <Offset>[ + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 52.548884672558), + Offset(56.04672, 53.768880581558), + Offset(56.04672, 56.915185820558), + Offset(56.04672, 60.061491059558), + Offset(56.04672, 63.20779629855801), + Offset(56.04672, 66.35410153755801), + Offset(56.04672, 69.500406776558), + Offset(56.04672, 72.64671201555801), + Offset(56.04672, 74.44035252452177), + Offset(56.04672, 74.95386710664474), + Offset(56.04672, 75.37290899799387), + Offset(56.04672, 75.7122346595454), + Offset(56.04672, 75.98387225794654), + Offset(56.04672, 76.19822506777093), + Offset(56.04672, 76.36445247425576), + Offset(56.04672, 76.4903439276692), + Offset(56.04672, 76.58262688786492), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + Offset(56.04672, 76.585986306158), + ], + <Offset>[ + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 48.1030583664), + Offset(56.04672, 49.3230542754), + Offset(56.04672, 52.4693595144), + Offset(56.04672, 55.6156647534), + Offset(56.04672, 58.761969992400005), + Offset(56.04672, 61.908275231400005), + Offset(56.04672, 65.0545804704), + Offset(56.04672, 68.20088570940001), + Offset(56.04672, 69.99452621836377), + Offset(56.04672, 70.50804080048674), + Offset(56.04672, 70.92708269183586), + Offset(56.04672, 71.2664083533874), + Offset(56.04672, 71.53804595178855), + Offset(56.04672, 71.75239876161294), + Offset(56.04672, 71.91862616809776), + Offset(56.04672, 72.0445176215112), + Offset(56.04672, 72.13680058170692), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + Offset(56.04672, 72.14016000000001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 43.65726275604466), + Offset(56.04672, 44.877258665044664), + Offset(56.04672, 48.023563904044664), + Offset(56.04672, 51.169869143044664), + Offset(56.04672, 54.316174382044665), + Offset(56.04672, 57.462479621044665), + Offset(56.04672, 60.608784860044665), + Offset(56.04672, 63.755090099044665), + Offset(56.04672, 65.54873060800844), + Offset(56.04672, 66.0622451901314), + Offset(56.04672, 66.48128708148053), + Offset(56.04672, 66.82061274303207), + Offset(56.04672, 67.09225034143321), + Offset(56.04672, 67.30660315125759), + Offset(56.04672, 67.47283055774243), + Offset(56.04672, 67.59872201115587), + Offset(56.04672, 67.69100497135157), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + Offset(56.04672, 67.69436438964466), + ], + <Offset>[ + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 40.0563383664), + Offset(52.445826306158004, 41.2763342754), + Offset(52.445826306158004, 44.422639514400004), + Offset(52.445826306158004, 47.568944753400004), + Offset(52.445826306158004, 50.715249992400004), + Offset(52.445826306158004, 53.861555231400004), + Offset(52.445826306158004, 57.0078604704), + Offset(52.445826306158004, 60.154165709400004), + Offset(52.445826306158004, 61.94780621836377), + Offset(52.445826306158004, 62.46132080048674), + Offset(52.445826306158004, 62.88036269183587), + Offset(52.445826306158004, 63.219688353387404), + Offset(52.445826306158004, 63.491325951788546), + Offset(52.445826306158004, 63.70567876161293), + Offset(52.445826306158004, 63.87190616809776), + Offset(52.445826306158004, 63.997797621511204), + Offset(52.445826306158004, 64.09008058170691), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + Offset(52.445826306158004, 64.09344), + ], + <Offset>[ + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 40.0563383664), + Offset(48.0, 41.2763342754), + Offset(48.0, 44.422639514400004), + Offset(48.0, 47.568944753400004), + Offset(48.0, 50.715249992400004), + Offset(48.0, 53.861555231400004), + Offset(48.0, 57.0078604704), + Offset(48.0, 60.154165709400004), + Offset(48.0, 61.94780621836377), + Offset(48.0, 62.46132080048674), + Offset(48.0, 62.88036269183587), + Offset(48.0, 63.219688353387404), + Offset(48.0, 63.491325951788546), + Offset(48.0, 63.70567876161293), + Offset(48.0, 63.87190616809776), + Offset(48.0, 63.997797621511204), + Offset(48.0, 64.09008058170691), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + Offset(48.0, 64.09344), + ], + ), + _PathClose(), + ], + ), +]); diff --git a/packages/material_ui/lib/src/animated_icons/data/view_list.g.dart b/packages/material_ui/lib/src/animated_icons/data/view_list.g.dart new file mode 100644 index 000000000000..2f8aa09e15e5 --- /dev/null +++ b/packages/material_ui/lib/src/animated_icons/data/view_list.g.dart @@ -0,0 +1,4198 @@ +// 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. + +// AUTOGENERATED FILE DO NOT EDIT! +// This file was generated by vitool. +part of material_animated_icons; // ignore: use_string_in_part_of_directives + +const _AnimatedIconData _$view_list = _AnimatedIconData(Size(48.0, 48.0), <_PathFrames>[ + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(44.69110902782625, 12.484010802427473), + Offset(44.91169585808795, 12.889600531986844), + Offset(45.905579651564764, 15.006358905929599), + Offset(46.93099203454209, 18.091564986244922), + Offset(47.57487910345822, 21.7717551172888), + Offset(47.611090562906426, 25.804550478715143), + Offset(46.90934109732998, 29.991835318680575), + Offset(45.41657507137544, 34.10298531188382), + Offset(43.12531983681261, 37.96288441331596), + Offset(40.11875918372392, 41.34720733654602), + Offset(36.52311532698831, 44.097551654541164), + Offset(32.469796235554185, 46.11340208398952), + Offset(28.106936276731332, 47.32108647595278), + Offset(23.593982433468472, 47.67646826990182), + Offset(19.205125831976975, 47.18941960707135), + Offset(15.060647386344348, 45.9277900128747), + Offset(11.36456649310902, 44.027127105327324), + Offset(8.242362271905536, 41.67588337906012), + Offset(5.7867901900563705, 39.13337333243891), + Offset(4.073681185543615, 36.793819543226384), + Offset(3.3006450057068637, 35.50116093358544), + Offset(3.3000000000000016, 35.5), + ]), + _PathCubicTo( + <Offset>[ + Offset(44.69110902782625, 12.484010802427473), + Offset(44.91169585808795, 12.889600531986844), + Offset(45.905579651564764, 15.006358905929599), + Offset(46.93099203454209, 18.091564986244922), + Offset(47.57487910345822, 21.7717551172888), + Offset(47.611090562906426, 25.804550478715143), + Offset(46.90934109732998, 29.991835318680575), + Offset(45.41657507137544, 34.10298531188382), + Offset(43.12531983681261, 37.96288441331596), + Offset(40.11875918372392, 41.34720733654602), + Offset(36.52311532698831, 44.097551654541164), + Offset(32.469796235554185, 46.11340208398952), + Offset(28.106936276731332, 47.32108647595278), + Offset(23.593982433468472, 47.67646826990182), + Offset(19.205125831976975, 47.18941960707135), + Offset(15.060647386344348, 45.9277900128747), + Offset(11.36456649310902, 44.027127105327324), + Offset(8.242362271905536, 41.67588337906012), + Offset(5.7867901900563705, 39.13337333243891), + Offset(4.073681185543615, 36.793819543226384), + Offset(3.3006450057068637, 35.50116093358544), + Offset(3.3000000000000016, 35.5), + ], + <Offset>[ + Offset(40.091110400688535, 12.487564720144858), + Offset(40.3125022719963, 12.803470643716631), + Offset(41.33730753204713, 14.467016860435445), + Offset(42.479650461341066, 16.931583004189925), + Offset(43.36138596487936, 19.926091125347472), + Offset(43.771900701159595, 23.27065600469687), + Offset(43.58434311086645, 26.813095255382933), + Offset(42.73290999433846, 30.366952422000203), + Offset(41.194877262006315, 33.787553113242254), + Offset(39.018140391822314, 36.880817488479344), + Offset(36.292544012456446, 39.503333889903196), + Offset(33.117698958194936, 41.55925868901449), + Offset(29.60963450105552, 42.973455161476714), + Offset(25.896557894757017, 43.69423946619505), + Offset(22.20703084230837, 43.70394025568405), + Offset(18.647310095538202, 43.04753818178802), + Offset(15.399557758932708, 41.81831622960108), + Offset(12.585723714323475, 40.16088787394561), + Offset(10.307276132541034, 38.28178255994396), + Offset(8.664359809057567, 36.50112554668383), + Offset(7.900644998472249, 35.500902944325), + Offset(7.900000000000001, 35.5), + ], + <Offset>[ + Offset(40.091110400688535, 12.487564720144858), + Offset(40.3125022719963, 12.803470643716631), + Offset(41.33730753204713, 14.467016860435445), + Offset(42.479650461341066, 16.931583004189925), + Offset(43.36138596487936, 19.926091125347472), + Offset(43.771900701159595, 23.27065600469687), + Offset(43.58434311086645, 26.813095255382933), + Offset(42.73290999433846, 30.366952422000203), + Offset(41.194877262006315, 33.787553113242254), + Offset(39.018140391822314, 36.880817488479344), + Offset(36.292544012456446, 39.503333889903196), + Offset(33.117698958194936, 41.55925868901449), + Offset(29.60963450105552, 42.973455161476714), + Offset(25.896557894757017, 43.69423946619505), + Offset(22.20703084230837, 43.70394025568405), + Offset(18.647310095538202, 43.04753818178802), + Offset(15.399557758932708, 41.81831622960108), + Offset(12.585723714323475, 40.16088787394561), + Offset(10.307276132541034, 38.28178255994396), + Offset(8.664359809057567, 36.50112554668383), + Offset(7.900644998472249, 35.500902944325), + Offset(7.900000000000001, 35.5), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.091110400688535, 12.487564720144858), + Offset(40.3125022719963, 12.803470643716631), + Offset(41.33730753204713, 14.467016860435445), + Offset(42.479650461341066, 16.931583004189925), + Offset(43.36138596487936, 19.926091125347472), + Offset(43.771900701159595, 23.27065600469687), + Offset(43.58434311086645, 26.813095255382933), + Offset(42.73290999433846, 30.366952422000203), + Offset(41.194877262006315, 33.787553113242254), + Offset(39.018140391822314, 36.880817488479344), + Offset(36.292544012456446, 39.503333889903196), + Offset(33.117698958194936, 41.55925868901449), + Offset(29.60963450105552, 42.973455161476714), + Offset(25.896557894757017, 43.69423946619505), + Offset(22.20703084230837, 43.70394025568405), + Offset(18.647310095538202, 43.04753818178802), + Offset(15.399557758932708, 41.81831622960108), + Offset(12.585723714323475, 40.16088787394561), + Offset(10.307276132541034, 38.28178255994396), + Offset(8.664359809057567, 36.50112554668383), + Offset(7.900644998472249, 35.500902944325), + Offset(7.900000000000001, 35.5), + ], + <Offset>[ + Offset(40.094664318405925, 17.087563347282572), + Offset(40.22637238372609, 17.40266422980828), + Offset(40.79796548655297, 19.035288979953084), + Offset(41.31966847928607, 21.38292457739095), + Offset(41.51572197293803, 24.13958426392634), + Offset(41.23800622714133, 27.109845866443706), + Offset(40.40560304756881, 30.13809324184646), + Offset(38.996877104454846, 33.05061749903718), + Offset(37.01954596193261, 35.717995688048546), + Offset(34.55175054375564, 37.98143628038095), + Offset(31.69832624781848, 39.73390520443506), + Offset(28.563555563219907, 40.91135596637374), + Offset(25.26200318657945, 41.47075693715253), + Offset(21.914329091050245, 41.3916640049065), + Offset(18.72155149092107, 40.702035245352654), + Offset(15.767058264451522, 39.46087547259416), + Offset(13.190746883206462, 37.78332496377739), + Offset(11.070728209208971, 35.817526431527675), + Offset(9.455685360046092, 33.761296617459294), + Offset(8.37166581251501, 31.91044692316988), + Offset(7.90038700921181, 30.900902951559615), + Offset(7.900000000000001, 30.900000000000002), + ], + <Offset>[ + Offset(40.094664318405925, 17.087563347282572), + Offset(40.22637238372609, 17.40266422980828), + Offset(40.79796548655297, 19.035288979953084), + Offset(41.31966847928607, 21.38292457739095), + Offset(41.51572197293803, 24.13958426392634), + Offset(41.23800622714133, 27.109845866443706), + Offset(40.40560304756881, 30.13809324184646), + Offset(38.996877104454846, 33.05061749903718), + Offset(37.01954596193261, 35.717995688048546), + Offset(34.55175054375564, 37.98143628038095), + Offset(31.69832624781848, 39.73390520443506), + Offset(28.563555563219907, 40.91135596637374), + Offset(25.26200318657945, 41.47075693715253), + Offset(21.914329091050245, 41.3916640049065), + Offset(18.72155149092107, 40.702035245352654), + Offset(15.767058264451522, 39.46087547259416), + Offset(13.190746883206462, 37.78332496377739), + Offset(11.070728209208971, 35.817526431527675), + Offset(9.455685360046092, 33.761296617459294), + Offset(8.37166581251501, 31.91044692316988), + Offset(7.90038700921181, 30.900902951559615), + Offset(7.900000000000001, 30.900000000000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.094664318405925, 17.087563347282572), + Offset(40.22637238372609, 17.40266422980828), + Offset(40.79796548655297, 19.035288979953084), + Offset(41.31966847928607, 21.38292457739095), + Offset(41.51572197293803, 24.13958426392634), + Offset(41.23800622714133, 27.109845866443706), + Offset(40.40560304756881, 30.13809324184646), + Offset(38.996877104454846, 33.05061749903718), + Offset(37.01954596193261, 35.717995688048546), + Offset(34.55175054375564, 37.98143628038095), + Offset(31.69832624781848, 39.73390520443506), + Offset(28.563555563219907, 40.91135596637374), + Offset(25.26200318657945, 41.47075693715253), + Offset(21.914329091050245, 41.3916640049065), + Offset(18.72155149092107, 40.702035245352654), + Offset(15.767058264451522, 39.46087547259416), + Offset(13.190746883206462, 37.78332496377739), + Offset(11.070728209208971, 35.817526431527675), + Offset(9.455685360046092, 33.761296617459294), + Offset(8.37166581251501, 31.91044692316988), + Offset(7.90038700921181, 30.900902951559615), + Offset(7.900000000000001, 30.900000000000002), + ], + <Offset>[ + Offset(44.69466294554364, 17.084009429565185), + Offset(44.82556596981774, 17.488794118078495), + Offset(45.366237606070605, 19.57463102544724), + Offset(45.7710100524871, 22.542906559445946), + Offset(45.72921511151689, 25.98524825586767), + Offset(45.07719608888816, 29.643740340461978), + Offset(43.73060103403234, 33.3168333051441), + Offset(41.68054218149182, 36.7866503889208), + Offset(38.9499885367389, 39.893326988122254), + Offset(35.65236933565724, 42.44782612844762), + Offset(31.928897562350347, 44.32812296907303), + Offset(27.915652840579153, 45.46549936134877), + Offset(23.75930496225526, 45.8183882516286), + Offset(19.6117536297617, 45.37389280861327), + Offset(15.719646480589672, 44.18751459673995), + Offset(12.180395555257666, 42.34112730368084), + Offset(9.155755617382773, 39.99213583950363), + Offset(6.7273667667910315, 37.33252193664218), + Offset(4.935199417561428, 34.61288738995424), + Offset(3.7809871890010585, 32.20314091971243), + Offset(3.3003870164464253, 30.901160940820056), + Offset(3.3000000000000016, 30.900000000000002), + ], + <Offset>[ + Offset(44.69466294554364, 17.084009429565185), + Offset(44.82556596981774, 17.488794118078495), + Offset(45.366237606070605, 19.57463102544724), + Offset(45.7710100524871, 22.542906559445946), + Offset(45.72921511151689, 25.98524825586767), + Offset(45.07719608888816, 29.643740340461978), + Offset(43.73060103403234, 33.3168333051441), + Offset(41.68054218149182, 36.7866503889208), + Offset(38.9499885367389, 39.893326988122254), + Offset(35.65236933565724, 42.44782612844762), + Offset(31.928897562350347, 44.32812296907303), + Offset(27.915652840579153, 45.46549936134877), + Offset(23.75930496225526, 45.8183882516286), + Offset(19.6117536297617, 45.37389280861327), + Offset(15.719646480589672, 44.18751459673995), + Offset(12.180395555257666, 42.34112730368084), + Offset(9.155755617382773, 39.99213583950363), + Offset(6.7273667667910315, 37.33252193664218), + Offset(4.935199417561428, 34.61288738995424), + Offset(3.7809871890010585, 32.20314091971243), + Offset(3.3003870164464253, 30.901160940820056), + Offset(3.3000000000000016, 30.900000000000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.69466294554364, 17.084009429565185), + Offset(44.82556596981774, 17.488794118078495), + Offset(45.366237606070605, 19.57463102544724), + Offset(45.7710100524871, 22.542906559445946), + Offset(45.72921511151689, 25.98524825586767), + Offset(45.07719608888816, 29.643740340461978), + Offset(43.73060103403234, 33.3168333051441), + Offset(41.68054218149182, 36.7866503889208), + Offset(38.9499885367389, 39.893326988122254), + Offset(35.65236933565724, 42.44782612844762), + Offset(31.928897562350347, 44.32812296907303), + Offset(27.915652840579153, 45.46549936134877), + Offset(23.75930496225526, 45.8183882516286), + Offset(19.6117536297617, 45.37389280861327), + Offset(15.719646480589672, 44.18751459673995), + Offset(12.180395555257666, 42.34112730368084), + Offset(9.155755617382773, 39.99213583950363), + Offset(6.7273667667910315, 37.33252193664218), + Offset(4.935199417561428, 34.61288738995424), + Offset(3.7809871890010585, 32.20314091971243), + Offset(3.3003870164464253, 30.901160940820056), + Offset(3.3000000000000016, 30.900000000000002), + ], + <Offset>[ + Offset(44.69110902782625, 12.484010802427473), + Offset(44.91169585808795, 12.889600531986844), + Offset(45.905579651564764, 15.006358905929599), + Offset(46.93099203454209, 18.091564986244922), + Offset(47.57487910345822, 21.7717551172888), + Offset(47.611090562906426, 25.804550478715143), + Offset(46.90934109732998, 29.991835318680575), + Offset(45.41657507137544, 34.10298531188382), + Offset(43.12531983681261, 37.96288441331596), + Offset(40.11875918372392, 41.34720733654602), + Offset(36.52311532698831, 44.097551654541164), + Offset(32.469796235554185, 46.11340208398952), + Offset(28.106936276731332, 47.32108647595278), + Offset(23.593982433468472, 47.67646826990182), + Offset(19.205125831976975, 47.18941960707135), + Offset(15.060647386344348, 45.9277900128747), + Offset(11.36456649310902, 44.027127105327324), + Offset(8.242362271905536, 41.67588337906012), + Offset(5.7867901900563705, 39.13337333243891), + Offset(4.073681185543615, 36.793819543226384), + Offset(3.3006450057068637, 35.50116093358544), + Offset(3.3000000000000016, 35.5), + ], + <Offset>[ + Offset(44.69110902782625, 12.484010802427473), + Offset(44.91169585808795, 12.889600531986844), + Offset(45.905579651564764, 15.006358905929599), + Offset(46.93099203454209, 18.091564986244922), + Offset(47.57487910345822, 21.7717551172888), + Offset(47.611090562906426, 25.804550478715143), + Offset(46.90934109732998, 29.991835318680575), + Offset(45.41657507137544, 34.10298531188382), + Offset(43.12531983681261, 37.96288441331596), + Offset(40.11875918372392, 41.34720733654602), + Offset(36.52311532698831, 44.097551654541164), + Offset(32.469796235554185, 46.11340208398952), + Offset(28.106936276731332, 47.32108647595278), + Offset(23.593982433468472, 47.67646826990182), + Offset(19.205125831976975, 47.18941960707135), + Offset(15.060647386344348, 45.9277900128747), + Offset(11.36456649310902, 44.027127105327324), + Offset(8.242362271905536, 41.67588337906012), + Offset(5.7867901900563705, 39.13337333243891), + Offset(4.073681185543615, 36.793819543226384), + Offset(3.3006450057068637, 35.50116093358544), + Offset(3.3000000000000016, 35.5), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(44.7053246986958, 30.884005310978335), + Offset(44.567176305007095, 31.28637487635344), + Offset(43.74821146958815, 33.27944738400016), + Offset(42.29106410632212, 35.896931279049014), + Offset(40.19222313569291, 38.62572767160428), + Offset(37.47551266683334, 41.16130992570247), + Offset(34.19438084413941, 43.291827264534675), + Offset(30.472443511840986, 44.83764562003173), + Offset(26.423994636517765, 45.684654712541146), + Offset(22.25319979145722, 45.749682504152446), + Offset(18.146244268436455, 45.01983691266863), + Offset(14.253222655654064, 43.5217911934265), + Offset(10.71641101882705, 41.31029357865603), + Offset(7.665067218641396, 38.46616642474764), + Offset(5.263208426427763, 35.18179956574576), + Offset(3.5396400619976247, 31.581139176099278), + Offset(2.529322990204036, 27.887162042032575), + Offset(2.1823802514475172, 24.302437609388363), + Offset(2.3804271000766017, 21.051429562500246), + Offset(2.902905199373388, 18.43110504917058), + Offset(3.2996130486651083, 17.1011609625239), + Offset(3.3000000000000016, 17.1), + ]), + _PathCubicTo( + <Offset>[ + Offset(44.7053246986958, 30.884005310978335), + Offset(44.567176305007095, 31.28637487635344), + Offset(43.74821146958815, 33.27944738400016), + Offset(42.29106410632212, 35.896931279049014), + Offset(40.19222313569291, 38.62572767160428), + Offset(37.47551266683334, 41.16130992570247), + Offset(34.19438084413941, 43.291827264534675), + Offset(30.472443511840986, 44.83764562003173), + Offset(26.423994636517765, 45.684654712541146), + Offset(22.25319979145722, 45.749682504152446), + Offset(18.146244268436455, 45.01983691266863), + Offset(14.253222655654064, 43.5217911934265), + Offset(10.71641101882705, 41.31029357865603), + Offset(7.665067218641396, 38.46616642474764), + Offset(5.263208426427763, 35.18179956574576), + Offset(3.5396400619976247, 31.581139176099278), + Offset(2.529322990204036, 27.887162042032575), + Offset(2.1823802514475172, 24.302437609388363), + Offset(2.3804271000766017, 21.051429562500246), + Offset(2.902905199373388, 18.43110504917058), + Offset(3.2996130486651083, 17.1011609625239), + Offset(3.3000000000000016, 17.1), + ], + <Offset>[ + Offset(40.10532607155808, 30.88755922869572), + Offset(39.967982718915444, 31.200244988083227), + Offset(39.17993935007051, 32.740105338506), + Offset(37.83972253312109, 34.73694929699402), + Offset(35.978729997114044, 36.78006367966295), + Offset(33.63632280508651, 38.6274154516842), + Offset(30.869382857675884, 40.113087201237036), + Offset(27.788778434804005, 41.10161273014811), + Offset(24.49355206171147, 41.50932341246744), + Offset(21.152580999555614, 41.28329265608577), + Offset(17.915672953904586, 40.42561914803066), + Offset(14.901125378294816, 38.96764779845147), + Offset(12.219109243151237, 36.96266226417996), + Offset(9.96764267992994, 34.48393762104087), + Offset(8.26511343675916, 31.69632021435846), + Offset(7.126302771191481, 28.7008873450126), + Offset(6.564314256027725, 25.678351166306328), + Offset(6.525741693865457, 22.787442104273858), + Offset(6.900913042561266, 20.199838790005305), + Offset(7.49358382288734, 18.138411052628022), + Offset(7.899613041430493, 17.10090297326346), + Offset(7.900000000000001, 17.1), + ], + <Offset>[ + Offset(40.10532607155808, 30.88755922869572), + Offset(39.967982718915444, 31.200244988083227), + Offset(39.17993935007051, 32.740105338506), + Offset(37.83972253312109, 34.73694929699402), + Offset(35.978729997114044, 36.78006367966295), + Offset(33.63632280508651, 38.6274154516842), + Offset(30.869382857675884, 40.113087201237036), + Offset(27.788778434804005, 41.10161273014811), + Offset(24.49355206171147, 41.50932341246744), + Offset(21.152580999555614, 41.28329265608577), + Offset(17.915672953904586, 40.42561914803066), + Offset(14.901125378294816, 38.96764779845147), + Offset(12.219109243151237, 36.96266226417996), + Offset(9.96764267992994, 34.48393762104087), + Offset(8.26511343675916, 31.69632021435846), + Offset(7.126302771191481, 28.7008873450126), + Offset(6.564314256027725, 25.678351166306328), + Offset(6.525741693865457, 22.787442104273858), + Offset(6.900913042561266, 20.199838790005305), + Offset(7.49358382288734, 18.138411052628022), + Offset(7.899613041430493, 17.10090297326346), + Offset(7.900000000000001, 17.1), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.10532607155808, 30.88755922869572), + Offset(39.967982718915444, 31.200244988083227), + Offset(39.17993935007051, 32.740105338506), + Offset(37.83972253312109, 34.73694929699402), + Offset(35.978729997114044, 36.78006367966295), + Offset(33.63632280508651, 38.6274154516842), + Offset(30.869382857675884, 40.113087201237036), + Offset(27.788778434804005, 41.10161273014811), + Offset(24.49355206171147, 41.50932341246744), + Offset(21.152580999555614, 41.28329265608577), + Offset(17.915672953904586, 40.42561914803066), + Offset(14.901125378294816, 38.96764779845147), + Offset(12.219109243151237, 36.96266226417996), + Offset(9.96764267992994, 34.48393762104087), + Offset(8.26511343675916, 31.69632021435846), + Offset(7.126302771191481, 28.7008873450126), + Offset(6.564314256027725, 25.678351166306328), + Offset(6.525741693865457, 22.787442104273858), + Offset(6.900913042561266, 20.199838790005305), + Offset(7.49358382288734, 18.138411052628022), + Offset(7.899613041430493, 17.10090297326346), + Offset(7.900000000000001, 17.1), + ], + <Offset>[ + Offset(40.10887998927547, 35.48755785583344), + Offset(39.88185283064523, 35.79943857417488), + Offset(38.64059730457635, 37.308377458023635), + Offset(36.6797405510661, 39.18829087019505), + Offset(34.13306600517272, 40.993556818241814), + Offset(31.102428331068236, 42.46660531343103), + Offset(27.690642794378242, 43.438085187700565), + Offset(24.05274554492039, 43.78527780718509), + Offset(20.318220761637757, 43.43976598727373), + Offset(16.68619115148894, 42.38391144798737), + Offset(13.321455189266624, 40.65619046256253), + Offset(10.346981983319786, 38.31974507581072), + Offset(7.871477928675166, 35.45996403985578), + Offset(5.985413876223171, 32.181362159752325), + Offset(4.779634085371857, 28.694415204027067), + Offset(4.2460509401048, 25.114224635818744), + Offset(4.355503380301478, 21.64335990048264), + Offset(5.010746188750952, 18.44408066185592), + Offset(6.049322270066323, 15.67935284752064), + Offset(7.200889826344783, 13.547732429114072), + Offset(7.899355052170055, 12.500902980498076), + Offset(7.900000000000001, 12.5), + ], + <Offset>[ + Offset(40.10887998927547, 35.48755785583344), + Offset(39.88185283064523, 35.79943857417488), + Offset(38.64059730457635, 37.308377458023635), + Offset(36.6797405510661, 39.18829087019505), + Offset(34.13306600517272, 40.993556818241814), + Offset(31.102428331068236, 42.46660531343103), + Offset(27.690642794378242, 43.438085187700565), + Offset(24.05274554492039, 43.78527780718509), + Offset(20.318220761637757, 43.43976598727373), + Offset(16.68619115148894, 42.38391144798737), + Offset(13.321455189266624, 40.65619046256253), + Offset(10.346981983319786, 38.31974507581072), + Offset(7.871477928675166, 35.45996403985578), + Offset(5.985413876223171, 32.181362159752325), + Offset(4.779634085371857, 28.694415204027067), + Offset(4.2460509401048, 25.114224635818744), + Offset(4.355503380301478, 21.64335990048264), + Offset(5.010746188750952, 18.44408066185592), + Offset(6.049322270066323, 15.67935284752064), + Offset(7.200889826344783, 13.547732429114072), + Offset(7.899355052170055, 12.500902980498076), + Offset(7.900000000000001, 12.5), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.10887998927547, 35.48755785583344), + Offset(39.88185283064523, 35.79943857417488), + Offset(38.64059730457635, 37.308377458023635), + Offset(36.6797405510661, 39.18829087019505), + Offset(34.13306600517272, 40.993556818241814), + Offset(31.102428331068236, 42.46660531343103), + Offset(27.690642794378242, 43.438085187700565), + Offset(24.05274554492039, 43.78527780718509), + Offset(20.318220761637757, 43.43976598727373), + Offset(16.68619115148894, 42.38391144798737), + Offset(13.321455189266624, 40.65619046256253), + Offset(10.346981983319786, 38.31974507581072), + Offset(7.871477928675166, 35.45996403985578), + Offset(5.985413876223171, 32.181362159752325), + Offset(4.779634085371857, 28.694415204027067), + Offset(4.2460509401048, 25.114224635818744), + Offset(4.355503380301478, 21.64335990048264), + Offset(5.010746188750952, 18.44408066185592), + Offset(6.049322270066323, 15.67935284752064), + Offset(7.200889826344783, 13.547732429114072), + Offset(7.899355052170055, 12.500902980498076), + Offset(7.900000000000001, 12.5), + ], + <Offset>[ + Offset(44.70887861641319, 35.48400393811605), + Offset(44.48104641673688, 35.88556846244509), + Offset(43.20886942409399, 37.847719503517794), + Offset(41.131082124267124, 40.34827285225004), + Offset(38.34655914375159, 42.83922081018314), + Offset(34.94161819281507, 45.0004997874493), + Offset(31.015640780841768, 46.616825250998204), + Offset(26.736410621957372, 47.52131069706871), + Offset(22.248663336444054, 47.61509728734744), + Offset(17.786809943390548, 46.85030129605405), + Offset(13.55202650379849, 45.250408227200495), + Offset(9.699079260679033, 42.87388847078575), + Offset(6.3687797043509775, 39.807595354331845), + Offset(3.682838414934627, 36.16359096345909), + Offset(1.77772907504046, 32.179894555414364), + Offset(0.6593882309109436, 27.99447646690542), + Offset(0.32051211447779027, 23.852170776208887), + Offset(0.6673847463330125, 19.959076166970426), + Offset(1.5288363275816592, 16.53094362001558), + Offset(2.6102112028308313, 13.840426425656629), + Offset(3.29935505940467, 12.501160969758514), + Offset(3.3000000000000016, 12.5), + ], + <Offset>[ + Offset(44.70887861641319, 35.48400393811605), + Offset(44.48104641673688, 35.88556846244509), + Offset(43.20886942409399, 37.847719503517794), + Offset(41.131082124267124, 40.34827285225004), + Offset(38.34655914375159, 42.83922081018314), + Offset(34.94161819281507, 45.0004997874493), + Offset(31.015640780841768, 46.616825250998204), + Offset(26.736410621957372, 47.52131069706871), + Offset(22.248663336444054, 47.61509728734744), + Offset(17.786809943390548, 46.85030129605405), + Offset(13.55202650379849, 45.250408227200495), + Offset(9.699079260679033, 42.87388847078575), + Offset(6.3687797043509775, 39.807595354331845), + Offset(3.682838414934627, 36.16359096345909), + Offset(1.77772907504046, 32.179894555414364), + Offset(0.6593882309109436, 27.99447646690542), + Offset(0.32051211447779027, 23.852170776208887), + Offset(0.6673847463330125, 19.959076166970426), + Offset(1.5288363275816592, 16.53094362001558), + Offset(2.6102112028308313, 13.840426425656629), + Offset(3.29935505940467, 12.501160969758514), + Offset(3.3000000000000016, 12.5), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.70887861641319, 35.48400393811605), + Offset(44.48104641673688, 35.88556846244509), + Offset(43.20886942409399, 37.847719503517794), + Offset(41.131082124267124, 40.34827285225004), + Offset(38.34655914375159, 42.83922081018314), + Offset(34.94161819281507, 45.0004997874493), + Offset(31.015640780841768, 46.616825250998204), + Offset(26.736410621957372, 47.52131069706871), + Offset(22.248663336444054, 47.61509728734744), + Offset(17.786809943390548, 46.85030129605405), + Offset(13.55202650379849, 45.250408227200495), + Offset(9.699079260679033, 42.87388847078575), + Offset(6.3687797043509775, 39.807595354331845), + Offset(3.682838414934627, 36.16359096345909), + Offset(1.77772907504046, 32.179894555414364), + Offset(0.6593882309109436, 27.99447646690542), + Offset(0.32051211447779027, 23.852170776208887), + Offset(0.6673847463330125, 19.959076166970426), + Offset(1.5288363275816592, 16.53094362001558), + Offset(2.6102112028308313, 13.840426425656629), + Offset(3.29935505940467, 12.501160969758514), + Offset(3.3000000000000016, 12.5), + ], + <Offset>[ + Offset(44.7053246986958, 30.884005310978335), + Offset(44.567176305007095, 31.28637487635344), + Offset(43.74821146958815, 33.27944738400016), + Offset(42.29106410632212, 35.896931279049014), + Offset(40.19222313569291, 38.62572767160428), + Offset(37.47551266683334, 41.16130992570247), + Offset(34.19438084413941, 43.291827264534675), + Offset(30.472443511840986, 44.83764562003173), + Offset(26.423994636517765, 45.684654712541146), + Offset(22.25319979145722, 45.749682504152446), + Offset(18.146244268436455, 45.01983691266863), + Offset(14.253222655654064, 43.5217911934265), + Offset(10.71641101882705, 41.31029357865603), + Offset(7.665067218641396, 38.46616642474764), + Offset(5.263208426427763, 35.18179956574576), + Offset(3.5396400619976247, 31.581139176099278), + Offset(2.529322990204036, 27.887162042032575), + Offset(2.1823802514475172, 24.302437609388363), + Offset(2.3804271000766017, 21.051429562500246), + Offset(2.902905199373388, 18.43110504917058), + Offset(3.2996130486651083, 17.1011609625239), + Offset(3.3000000000000016, 17.1), + ], + <Offset>[ + Offset(44.7053246986958, 30.884005310978335), + Offset(44.567176305007095, 31.28637487635344), + Offset(43.74821146958815, 33.27944738400016), + Offset(42.29106410632212, 35.896931279049014), + Offset(40.19222313569291, 38.62572767160428), + Offset(37.47551266683334, 41.16130992570247), + Offset(34.19438084413941, 43.291827264534675), + Offset(30.472443511840986, 44.83764562003173), + Offset(26.423994636517765, 45.684654712541146), + Offset(22.25319979145722, 45.749682504152446), + Offset(18.146244268436455, 45.01983691266863), + Offset(14.253222655654064, 43.5217911934265), + Offset(10.71641101882705, 41.31029357865603), + Offset(7.665067218641396, 38.46616642474764), + Offset(5.263208426427763, 35.18179956574576), + Offset(3.5396400619976247, 31.581139176099278), + Offset(2.529322990204036, 27.887162042032575), + Offset(2.1823802514475172, 24.302437609388363), + Offset(2.3804271000766017, 21.051429562500246), + Offset(2.902905199373388, 18.43110504917058), + Offset(3.2996130486651083, 17.1011609625239), + Offset(3.3000000000000016, 17.1), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(44.698216863261024, 21.684008056702904), + Offset(44.739436081547524, 22.08798770417014), + Offset(44.82689556057646, 24.14290314496488), + Offset(44.611028070432106, 26.99424813264697), + Offset(43.88355111957557, 30.19874139444654), + Offset(42.54330161486988, 33.482930202208806), + Offset(40.55186097073469, 36.64183129160762), + Offset(37.94450929160821, 39.470315465957775), + Offset(34.77465723666519, 41.823769562928554), + Offset(31.18597948759057, 43.548444920349226), + Offset(27.334679797712376, 44.5586942836049), + Offset(23.361509445604124, 44.81759663870801), + Offset(19.41167364777919, 44.315690027304406), + Offset(15.629524826054936, 43.07131734732473), + Offset(12.234167129202369, 41.18560958640855), + Offset(9.300143724170985, 38.754464594487), + Offset(6.946944741656527, 35.95714457367995), + Offset(5.2123712616765285, 32.989160494224244), + Offset(4.0836086450664855, 30.092401447469573), + Offset(3.4882931924585017, 27.61246229619848), + Offset(3.300129027185985, 26.30116094805467), + Offset(3.3000000000000016, 26.3), + ]), + _PathCubicTo( + <Offset>[ + Offset(44.698216863261024, 21.684008056702904), + Offset(44.739436081547524, 22.08798770417014), + Offset(44.82689556057646, 24.14290314496488), + Offset(44.611028070432106, 26.99424813264697), + Offset(43.88355111957557, 30.19874139444654), + Offset(42.54330161486988, 33.482930202208806), + Offset(40.55186097073469, 36.64183129160762), + Offset(37.94450929160821, 39.470315465957775), + Offset(34.77465723666519, 41.823769562928554), + Offset(31.18597948759057, 43.548444920349226), + Offset(27.334679797712376, 44.5586942836049), + Offset(23.361509445604124, 44.81759663870801), + Offset(19.41167364777919, 44.315690027304406), + Offset(15.629524826054936, 43.07131734732473), + Offset(12.234167129202369, 41.18560958640855), + Offset(9.300143724170985, 38.754464594487), + Offset(6.946944741656527, 35.95714457367995), + Offset(5.2123712616765285, 32.989160494224244), + Offset(4.0836086450664855, 30.092401447469573), + Offset(3.4882931924585017, 27.61246229619848), + Offset(3.300129027185985, 26.30116094805467), + Offset(3.3000000000000016, 26.3), + ], + <Offset>[ + Offset(40.09821823612331, 21.68756197442029), + Offset(40.14024249545587, 22.001857815899925), + Offset(40.25862344105882, 23.603561099470724), + Offset(40.15968649723108, 25.834266150591972), + Offset(39.6700579809967, 28.35307740250521), + Offset(38.70411175312305, 30.949035728190534), + Offset(37.22686298427116, 33.463091228309985), + Offset(35.260844214571236, 35.73428257607416), + Offset(32.84421466185889, 37.64843826285484), + Offset(30.085360695688962, 39.08205507228256), + Offset(27.10410848318051, 39.96447651896693), + Offset(24.00941216824488, 40.26345324373298), + Offset(20.914371872103377, 39.96805871282834), + Offset(17.93210028734348, 39.08908854361796), + Offset(15.236072139533766, 37.700130235021255), + Offset(12.88680643336484, 35.874212763400315), + Offset(10.981936007480215, 33.748333697953704), + Offset(9.555732704094467, 31.474164989109738), + Offset(8.604094587551149, 29.240810674974632), + Offset(8.078971815972453, 27.319768299655923), + Offset(7.90012901995137, 26.300902958794232), + Offset(7.900000000000001, 26.3), + ], + <Offset>[ + Offset(40.09821823612331, 21.68756197442029), + Offset(40.14024249545587, 22.001857815899925), + Offset(40.25862344105882, 23.603561099470724), + Offset(40.15968649723108, 25.834266150591972), + Offset(39.6700579809967, 28.35307740250521), + Offset(38.70411175312305, 30.949035728190534), + Offset(37.22686298427116, 33.463091228309985), + Offset(35.260844214571236, 35.73428257607416), + Offset(32.84421466185889, 37.64843826285484), + Offset(30.085360695688962, 39.08205507228256), + Offset(27.10410848318051, 39.96447651896693), + Offset(24.00941216824488, 40.26345324373298), + Offset(20.914371872103377, 39.96805871282834), + Offset(17.93210028734348, 39.08908854361796), + Offset(15.236072139533766, 37.700130235021255), + Offset(12.88680643336484, 35.874212763400315), + Offset(10.981936007480215, 33.748333697953704), + Offset(9.555732704094467, 31.474164989109738), + Offset(8.604094587551149, 29.240810674974632), + Offset(8.078971815972453, 27.319768299655923), + Offset(7.90012901995137, 26.300902958794232), + Offset(7.900000000000001, 26.3), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.09821823612331, 21.68756197442029), + Offset(40.14024249545587, 22.001857815899925), + Offset(40.25862344105882, 23.603561099470724), + Offset(40.15968649723108, 25.834266150591972), + Offset(39.6700579809967, 28.35307740250521), + Offset(38.70411175312305, 30.949035728190534), + Offset(37.22686298427116, 33.463091228309985), + Offset(35.260844214571236, 35.73428257607416), + Offset(32.84421466185889, 37.64843826285484), + Offset(30.085360695688962, 39.08205507228256), + Offset(27.10410848318051, 39.96447651896693), + Offset(24.00941216824488, 40.26345324373298), + Offset(20.914371872103377, 39.96805871282834), + Offset(17.93210028734348, 39.08908854361796), + Offset(15.236072139533766, 37.700130235021255), + Offset(12.88680643336484, 35.874212763400315), + Offset(10.981936007480215, 33.748333697953704), + Offset(9.555732704094467, 31.474164989109738), + Offset(8.604094587551149, 29.240810674974632), + Offset(8.078971815972453, 27.319768299655923), + Offset(7.90012901995137, 26.300902958794232), + Offset(7.900000000000001, 26.3), + ], + <Offset>[ + Offset(40.1017721538407, 26.287560601558003), + Offset(40.05411260718566, 26.601051401991576), + Offset(39.719281395564664, 28.171833218988365), + Offset(38.999704515176084, 30.285607723792996), + Offset(37.82439398905538, 32.566570541084076), + Offset(36.17021727910478, 34.78822558993737), + Offset(34.04812292097352, 36.788089214773514), + Offset(31.52481132468762, 38.417947653111135), + Offset(28.66888336178518, 39.57888083766114), + Offset(25.61897084762229, 40.18267386418417), + Offset(22.50989071854255, 40.1950478334988), + Offset(19.455268773269847, 39.61555052109223), + Offset(16.566740557627305, 38.46536048850415), + Offset(13.94987148363671, 36.786513082329414), + Offset(11.750592788146463, 34.69822522468986), + Offset(10.00655460227816, 32.287550054206456), + Offset(8.773125131753968, 29.713342432130013), + Offset(8.040737198979963, 27.130803546691798), + Offset(7.7525038150562064, 24.720324732489967), + Offset(7.7862778194298965, 22.729089676141975), + Offset(7.899871030690932, 21.700902966028845), + Offset(7.900000000000001, 21.7), + ], + <Offset>[ + Offset(40.1017721538407, 26.287560601558003), + Offset(40.05411260718566, 26.601051401991576), + Offset(39.719281395564664, 28.171833218988365), + Offset(38.999704515176084, 30.285607723792996), + Offset(37.82439398905538, 32.566570541084076), + Offset(36.17021727910478, 34.78822558993737), + Offset(34.04812292097352, 36.788089214773514), + Offset(31.52481132468762, 38.417947653111135), + Offset(28.66888336178518, 39.57888083766114), + Offset(25.61897084762229, 40.18267386418417), + Offset(22.50989071854255, 40.1950478334988), + Offset(19.455268773269847, 39.61555052109223), + Offset(16.566740557627305, 38.46536048850415), + Offset(13.94987148363671, 36.786513082329414), + Offset(11.750592788146463, 34.69822522468986), + Offset(10.00655460227816, 32.287550054206456), + Offset(8.773125131753968, 29.713342432130013), + Offset(8.040737198979963, 27.130803546691798), + Offset(7.7525038150562064, 24.720324732489967), + Offset(7.7862778194298965, 22.729089676141975), + Offset(7.899871030690932, 21.700902966028845), + Offset(7.900000000000001, 21.7), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(40.1017721538407, 26.287560601558003), + Offset(40.05411260718566, 26.601051401991576), + Offset(39.719281395564664, 28.171833218988365), + Offset(38.999704515176084, 30.285607723792996), + Offset(37.82439398905538, 32.566570541084076), + Offset(36.17021727910478, 34.78822558993737), + Offset(34.04812292097352, 36.788089214773514), + Offset(31.52481132468762, 38.417947653111135), + Offset(28.66888336178518, 39.57888083766114), + Offset(25.61897084762229, 40.18267386418417), + Offset(22.50989071854255, 40.1950478334988), + Offset(19.455268773269847, 39.61555052109223), + Offset(16.566740557627305, 38.46536048850415), + Offset(13.94987148363671, 36.786513082329414), + Offset(11.750592788146463, 34.69822522468986), + Offset(10.00655460227816, 32.287550054206456), + Offset(8.773125131753968, 29.713342432130013), + Offset(8.040737198979963, 27.130803546691798), + Offset(7.7525038150562064, 24.720324732489967), + Offset(7.7862778194298965, 22.729089676141975), + Offset(7.899871030690932, 21.700902966028845), + Offset(7.900000000000001, 21.7), + ], + <Offset>[ + Offset(44.701770780978414, 26.284006683840616), + Offset(44.65330619327731, 26.68718129026179), + Offset(44.2875535150823, 28.71117526448252), + Offset(43.45104608837711, 31.445589705847993), + Offset(42.03788712763425, 34.412234533025405), + Offset(40.009407140851614, 37.32212006395564), + Offset(37.37312090743705, 39.96682927807115), + Offset(34.208476401724596, 42.15398054299475), + Offset(30.599325936591477, 43.754212137734854), + Offset(26.719589639523896, 44.649063712250836), + Offset(22.740462033074415, 44.789265598136765), + Offset(18.807366050629092, 44.16969391606726), + Offset(15.064042333303117, 42.81299180298022), + Offset(11.647296022348167, 40.76874188603618), + Offset(8.748687777815066, 38.183704576077155), + Offset(6.419891893084303, 35.16780188529314), + Offset(4.73813386593028, 31.92215330785626), + Offset(3.6973757565620238, 28.6457990518063), + Offset(3.2320178725715425, 25.571915504984908), + Offset(3.195599195915945, 23.02178367268453), + Offset(3.2998710379255467, 21.701160955289282), + Offset(3.3000000000000016, 21.7), + ], + <Offset>[ + Offset(44.701770780978414, 26.284006683840616), + Offset(44.65330619327731, 26.68718129026179), + Offset(44.2875535150823, 28.71117526448252), + Offset(43.45104608837711, 31.445589705847993), + Offset(42.03788712763425, 34.412234533025405), + Offset(40.009407140851614, 37.32212006395564), + Offset(37.37312090743705, 39.96682927807115), + Offset(34.208476401724596, 42.15398054299475), + Offset(30.599325936591477, 43.754212137734854), + Offset(26.719589639523896, 44.649063712250836), + Offset(22.740462033074415, 44.789265598136765), + Offset(18.807366050629092, 44.16969391606726), + Offset(15.064042333303117, 42.81299180298022), + Offset(11.647296022348167, 40.76874188603618), + Offset(8.748687777815066, 38.183704576077155), + Offset(6.419891893084303, 35.16780188529314), + Offset(4.73813386593028, 31.92215330785626), + Offset(3.6973757565620238, 28.6457990518063), + Offset(3.2320178725715425, 25.571915504984908), + Offset(3.195599195915945, 23.02178367268453), + Offset(3.2998710379255467, 21.701160955289282), + Offset(3.3000000000000016, 21.7), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(44.701770780978414, 26.284006683840616), + Offset(44.65330619327731, 26.68718129026179), + Offset(44.2875535150823, 28.71117526448252), + Offset(43.45104608837711, 31.445589705847993), + Offset(42.03788712763425, 34.412234533025405), + Offset(40.009407140851614, 37.32212006395564), + Offset(37.37312090743705, 39.96682927807115), + Offset(34.208476401724596, 42.15398054299475), + Offset(30.599325936591477, 43.754212137734854), + Offset(26.719589639523896, 44.649063712250836), + Offset(22.740462033074415, 44.789265598136765), + Offset(18.807366050629092, 44.16969391606726), + Offset(15.064042333303117, 42.81299180298022), + Offset(11.647296022348167, 40.76874188603618), + Offset(8.748687777815066, 38.183704576077155), + Offset(6.419891893084303, 35.16780188529314), + Offset(4.73813386593028, 31.92215330785626), + Offset(3.6973757565620238, 28.6457990518063), + Offset(3.2320178725715425, 25.571915504984908), + Offset(3.195599195915945, 23.02178367268453), + Offset(3.2998710379255467, 21.701160955289282), + Offset(3.3000000000000016, 21.7), + ], + <Offset>[ + Offset(44.698216863261024, 21.684008056702904), + Offset(44.739436081547524, 22.08798770417014), + Offset(44.82689556057646, 24.14290314496488), + Offset(44.611028070432106, 26.99424813264697), + Offset(43.88355111957557, 30.19874139444654), + Offset(42.54330161486988, 33.482930202208806), + Offset(40.55186097073469, 36.64183129160762), + Offset(37.94450929160821, 39.470315465957775), + Offset(34.77465723666519, 41.823769562928554), + Offset(31.18597948759057, 43.548444920349226), + Offset(27.334679797712376, 44.5586942836049), + Offset(23.361509445604124, 44.81759663870801), + Offset(19.41167364777919, 44.315690027304406), + Offset(15.629524826054936, 43.07131734732473), + Offset(12.234167129202369, 41.18560958640855), + Offset(9.300143724170985, 38.754464594487), + Offset(6.946944741656527, 35.95714457367995), + Offset(5.2123712616765285, 32.989160494224244), + Offset(4.0836086450664855, 30.092401447469573), + Offset(3.4882931924585017, 27.61246229619848), + Offset(3.300129027185985, 26.30116094805467), + Offset(3.3000000000000016, 26.3), + ], + <Offset>[ + Offset(44.698216863261024, 21.684008056702904), + Offset(44.739436081547524, 22.08798770417014), + Offset(44.82689556057646, 24.14290314496488), + Offset(44.611028070432106, 26.99424813264697), + Offset(43.88355111957557, 30.19874139444654), + Offset(42.54330161486988, 33.482930202208806), + Offset(40.55186097073469, 36.64183129160762), + Offset(37.94450929160821, 39.470315465957775), + Offset(34.77465723666519, 41.823769562928554), + Offset(31.18597948759057, 43.548444920349226), + Offset(27.334679797712376, 44.5586942836049), + Offset(23.361509445604124, 44.81759663870801), + Offset(19.41167364777919, 44.315690027304406), + Offset(15.629524826054936, 43.07131734732473), + Offset(12.234167129202369, 41.18560958640855), + Offset(9.300143724170985, 38.754464594487), + Offset(6.946944741656527, 35.95714457367995), + Offset(5.2123712616765285, 32.989160494224244), + Offset(4.0836086450664855, 30.092401447469573), + Offset(3.4882931924585017, 27.61246229619848), + Offset(3.300129027185985, 26.30116094805467), + Offset(3.3000000000000016, 26.3), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(35.49111177355083, 12.491118637862245), + Offset(35.71330868590466, 12.717340755446422), + Offset(36.76903541252949, 13.927674814941295), + Offset(38.028308888140046, 15.771601022134929), + Offset(39.14789282630049, 18.080427133406147), + Offset(39.93271083941276, 20.736761530678603), + Offset(40.25934512440292, 23.63435519208529), + Offset(40.04924491730148, 26.630919532116586), + Offset(39.264434687200016, 29.612221813168535), + Offset(37.917521599920704, 32.41442764041267), + Offset(36.061972697924574, 34.909116125265236), + Offset(33.765601680835694, 37.005115294039456), + Offset(31.112332725379705, 38.625823847000646), + Offset(28.199133356045557, 39.71201066248828), + Offset(25.20893585263977, 40.21846090429675), + Offset(22.23397280473206, 40.167286350701346), + Offset(19.4345490247564, 39.60950535387484), + Offset(16.929085156741415, 38.64589236883111), + Offset(14.827762075025696, 37.430191787449026), + Offset(13.255038432571515, 36.20843155014127), + Offset(12.500644991237632, 35.50064495506456), + Offset(12.5, 35.5), + ]), + _PathCubicTo( + <Offset>[ + Offset(35.49111177355083, 12.491118637862245), + Offset(35.71330868590466, 12.717340755446422), + Offset(36.76903541252949, 13.927674814941295), + Offset(38.028308888140046, 15.771601022134929), + Offset(39.14789282630049, 18.080427133406147), + Offset(39.93271083941276, 20.736761530678603), + Offset(40.25934512440292, 23.63435519208529), + Offset(40.04924491730148, 26.630919532116586), + Offset(39.264434687200016, 29.612221813168535), + Offset(37.917521599920704, 32.41442764041267), + Offset(36.061972697924574, 34.909116125265236), + Offset(33.765601680835694, 37.005115294039456), + Offset(31.112332725379705, 38.625823847000646), + Offset(28.199133356045557, 39.71201066248828), + Offset(25.20893585263977, 40.21846090429675), + Offset(22.23397280473206, 40.167286350701346), + Offset(19.4345490247564, 39.60950535387484), + Offset(16.929085156741415, 38.64589236883111), + Offset(14.827762075025696, 37.430191787449026), + Offset(13.255038432571515, 36.20843155014127), + Offset(12.500644991237632, 35.50064495506456), + Offset(12.5, 35.5), + ], + <Offset>[ + Offset(3.2911213835868267, 12.515996061883946), + Offset(3.5189535832631194, 12.114431537554925), + Offset(4.791130575906015, 10.152280496482213), + Offset(6.868917875732878, 7.651727147749964), + Offset(9.653440856248416, 5.16077918981685), + Offset(13.05838180718493, 2.9995002125507035), + Offset(16.984359219158236, 1.3831747490018031), + Offset(21.26358937804263, 0.47868930293129175), + Offset(25.751336663555943, 0.38490271265255416), + Offset(30.21319005660945, 1.1496987039459512), + Offset(34.44797349620151, 2.7495917727994907), + Offset(38.30092073932097, 5.126111529214247), + Offset(41.63122029564902, 8.192404645668148), + Offset(44.317161585065364, 11.836409036540893), + Offset(46.222270924959545, 15.820105444585629), + Offset(47.34061176908905, 20.005523533094575), + Offset(47.67948788552222, 24.147829223791117), + Offset(47.33261525366699, 28.040923833029577), + Offset(46.47116367241834, 31.469056379984426), + Offset(45.389788797169174, 34.15957357434337), + Offset(44.700644940595325, 35.49883903024149), + Offset(44.699999999999996, 35.5), + ], + <Offset>[ + Offset(3.2911213835868267, 12.515996061883946), + Offset(3.5189535832631194, 12.114431537554925), + Offset(4.791130575906015, 10.152280496482213), + Offset(6.868917875732878, 7.651727147749964), + Offset(9.653440856248416, 5.16077918981685), + Offset(13.05838180718493, 2.9995002125507035), + Offset(16.984359219158236, 1.3831747490018031), + Offset(21.26358937804263, 0.47868930293129175), + Offset(25.751336663555943, 0.38490271265255416), + Offset(30.21319005660945, 1.1496987039459512), + Offset(34.44797349620151, 2.7495917727994907), + Offset(38.30092073932097, 5.126111529214247), + Offset(41.63122029564902, 8.192404645668148), + Offset(44.317161585065364, 11.836409036540893), + Offset(46.222270924959545, 15.820105444585629), + Offset(47.34061176908905, 20.005523533094575), + Offset(47.67948788552222, 24.147829223791117), + Offset(47.33261525366699, 28.040923833029577), + Offset(46.47116367241834, 31.469056379984426), + Offset(45.389788797169174, 34.15957357434337), + Offset(44.700644940595325, 35.49883903024149), + Offset(44.699999999999996, 35.5), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.2911213835868267, 12.515996061883946), + Offset(3.5189535832631194, 12.114431537554925), + Offset(4.791130575906015, 10.152280496482213), + Offset(6.868917875732878, 7.651727147749964), + Offset(9.653440856248416, 5.16077918981685), + Offset(13.05838180718493, 2.9995002125507035), + Offset(16.984359219158236, 1.3831747490018031), + Offset(21.26358937804263, 0.47868930293129175), + Offset(25.751336663555943, 0.38490271265255416), + Offset(30.21319005660945, 1.1496987039459512), + Offset(34.44797349620151, 2.7495917727994907), + Offset(38.30092073932097, 5.126111529214247), + Offset(41.63122029564902, 8.192404645668148), + Offset(44.317161585065364, 11.836409036540893), + Offset(46.222270924959545, 15.820105444585629), + Offset(47.34061176908905, 20.005523533094575), + Offset(47.67948788552222, 24.147829223791117), + Offset(47.33261525366699, 28.040923833029577), + Offset(46.47116367241834, 31.469056379984426), + Offset(45.389788797169174, 34.15957357434337), + Offset(44.700644940595325, 35.49883903024149), + Offset(44.699999999999996, 35.5), + ], + <Offset>[ + Offset(3.29467530130421, 17.11599468902166), + Offset(3.432823694992905, 16.713625123646572), + Offset(4.2517885304118614, 14.720552615999853), + Offset(5.708935893677882, 12.103068720950988), + Offset(7.807776864307087, 9.374272328395719), + Offset(10.524487333166658, 6.838690074297536), + Offset(13.805619155860596, 4.708172735465329), + Offset(17.52755648815902, 3.162354379968269), + Offset(21.57600536348223, 2.3153452874588503), + Offset(25.746800208542776, 2.2503174958475576), + Offset(29.853755731563552, 2.9801630873313556), + Offset(33.74677734434594, 4.478208806573495), + Offset(37.28358898117295, 6.68970642134396), + Offset(40.334932781358596, 9.533833575252348), + Offset(42.73679157357225, 12.81820043425423), + Offset(44.46035993800237, 16.41886082390072), + Offset(45.47067700979597, 20.11283795796743), + Offset(45.81761974855248, 23.697562390611637), + Offset(45.619572899923405, 26.94857043749976), + Offset(45.097094800626614, 29.568894950829417), + Offset(44.70038695133489, 30.898839037476105), + Offset(44.699999999999996, 30.900000000000002), + ], + <Offset>[ + Offset(3.29467530130421, 17.11599468902166), + Offset(3.432823694992905, 16.713625123646572), + Offset(4.2517885304118614, 14.720552615999853), + Offset(5.708935893677882, 12.103068720950988), + Offset(7.807776864307087, 9.374272328395719), + Offset(10.524487333166658, 6.838690074297536), + Offset(13.805619155860596, 4.708172735465329), + Offset(17.52755648815902, 3.162354379968269), + Offset(21.57600536348223, 2.3153452874588503), + Offset(25.746800208542776, 2.2503174958475576), + Offset(29.853755731563552, 2.9801630873313556), + Offset(33.74677734434594, 4.478208806573495), + Offset(37.28358898117295, 6.68970642134396), + Offset(40.334932781358596, 9.533833575252348), + Offset(42.73679157357225, 12.81820043425423), + Offset(44.46035993800237, 16.41886082390072), + Offset(45.47067700979597, 20.11283795796743), + Offset(45.81761974855248, 23.697562390611637), + Offset(45.619572899923405, 26.94857043749976), + Offset(45.097094800626614, 29.568894950829417), + Offset(44.70038695133489, 30.898839037476105), + Offset(44.699999999999996, 30.900000000000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.29467530130421, 17.11599468902166), + Offset(3.432823694992905, 16.713625123646572), + Offset(4.2517885304118614, 14.720552615999853), + Offset(5.708935893677882, 12.103068720950988), + Offset(7.807776864307087, 9.374272328395719), + Offset(10.524487333166658, 6.838690074297536), + Offset(13.805619155860596, 4.708172735465329), + Offset(17.52755648815902, 3.162354379968269), + Offset(21.57600536348223, 2.3153452874588503), + Offset(25.746800208542776, 2.2503174958475576), + Offset(29.853755731563552, 2.9801630873313556), + Offset(33.74677734434594, 4.478208806573495), + Offset(37.28358898117295, 6.68970642134396), + Offset(40.334932781358596, 9.533833575252348), + Offset(42.73679157357225, 12.81820043425423), + Offset(44.46035993800237, 16.41886082390072), + Offset(45.47067700979597, 20.11283795796743), + Offset(45.81761974855248, 23.697562390611637), + Offset(45.619572899923405, 26.94857043749976), + Offset(45.097094800626614, 29.568894950829417), + Offset(44.70038695133489, 30.898839037476105), + Offset(44.699999999999996, 30.900000000000002), + ], + <Offset>[ + Offset(35.49466569126821, 17.09111726499996), + Offset(35.62717879763444, 17.31653434153807), + Offset(36.22969336703533, 18.495946934458935), + Offset(36.86832690608505, 20.22294259533595), + Offset(37.30222883435916, 22.293920271985016), + Offset(37.39881636539449, 24.575951392425434), + Offset(37.08060506110528, 26.959353178548817), + Offset(36.31321202741787, 29.314584609153563), + Offset(35.08910338712631, 31.54266438797483), + Offset(33.45113175185403, 33.51504643231427), + Offset(31.46775493328661, 35.1396874397971), + Offset(29.21145828586066, 36.357212571398705), + Offset(26.764701410903633, 37.123125622676454), + Offset(24.21690455233879, 37.409435201199734), + Offset(21.72345650125247, 37.21655589396535), + Offset(19.353720973645377, 36.580623641507486), + Offset(17.225738149030153, 35.574514088051146), + Offset(15.414089651626908, 34.30253092641317), + Offset(13.976171302530755, 32.90970584496436), + Offset(12.962344436028957, 31.61775292662732), + Offset(12.500387001977192, 30.900644962299175), + Offset(12.5, 30.900000000000002), + ], + <Offset>[ + Offset(35.49466569126821, 17.09111726499996), + Offset(35.62717879763444, 17.31653434153807), + Offset(36.22969336703533, 18.495946934458935), + Offset(36.86832690608505, 20.22294259533595), + Offset(37.30222883435916, 22.293920271985016), + Offset(37.39881636539449, 24.575951392425434), + Offset(37.08060506110528, 26.959353178548817), + Offset(36.31321202741787, 29.314584609153563), + Offset(35.08910338712631, 31.54266438797483), + Offset(33.45113175185403, 33.51504643231427), + Offset(31.46775493328661, 35.1396874397971), + Offset(29.21145828586066, 36.357212571398705), + Offset(26.764701410903633, 37.123125622676454), + Offset(24.21690455233879, 37.409435201199734), + Offset(21.72345650125247, 37.21655589396535), + Offset(19.353720973645377, 36.580623641507486), + Offset(17.225738149030153, 35.574514088051146), + Offset(15.414089651626908, 34.30253092641317), + Offset(13.976171302530755, 32.90970584496436), + Offset(12.962344436028957, 31.61775292662732), + Offset(12.500387001977192, 30.900644962299175), + Offset(12.5, 30.900000000000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.49466569126821, 17.09111726499996), + Offset(35.62717879763444, 17.31653434153807), + Offset(36.22969336703533, 18.495946934458935), + Offset(36.86832690608505, 20.22294259533595), + Offset(37.30222883435916, 22.293920271985016), + Offset(37.39881636539449, 24.575951392425434), + Offset(37.08060506110528, 26.959353178548817), + Offset(36.31321202741787, 29.314584609153563), + Offset(35.08910338712631, 31.54266438797483), + Offset(33.45113175185403, 33.51504643231427), + Offset(31.46775493328661, 35.1396874397971), + Offset(29.21145828586066, 36.357212571398705), + Offset(26.764701410903633, 37.123125622676454), + Offset(24.21690455233879, 37.409435201199734), + Offset(21.72345650125247, 37.21655589396535), + Offset(19.353720973645377, 36.580623641507486), + Offset(17.225738149030153, 35.574514088051146), + Offset(15.414089651626908, 34.30253092641317), + Offset(13.976171302530755, 32.90970584496436), + Offset(12.962344436028957, 31.61775292662732), + Offset(12.500387001977192, 30.900644962299175), + Offset(12.5, 30.900000000000002), + ], + <Offset>[ + Offset(35.49111177355083, 12.491118637862245), + Offset(35.71330868590466, 12.717340755446422), + Offset(36.76903541252949, 13.927674814941295), + Offset(38.028308888140046, 15.771601022134929), + Offset(39.14789282630049, 18.080427133406147), + Offset(39.93271083941276, 20.736761530678603), + Offset(40.25934512440292, 23.63435519208529), + Offset(40.04924491730148, 26.630919532116586), + Offset(39.264434687200016, 29.612221813168535), + Offset(37.917521599920704, 32.41442764041267), + Offset(36.061972697924574, 34.909116125265236), + Offset(33.765601680835694, 37.005115294039456), + Offset(31.112332725379705, 38.625823847000646), + Offset(28.199133356045557, 39.71201066248828), + Offset(25.20893585263977, 40.21846090429675), + Offset(22.23397280473206, 40.167286350701346), + Offset(19.4345490247564, 39.60950535387484), + Offset(16.929085156741415, 38.64589236883111), + Offset(14.827762075025696, 37.430191787449026), + Offset(13.255038432571515, 36.20843155014127), + Offset(12.500644991237632, 35.50064495506456), + Offset(12.5, 35.5), + ], + <Offset>[ + Offset(35.49111177355083, 12.491118637862245), + Offset(35.71330868590466, 12.717340755446422), + Offset(36.76903541252949, 13.927674814941295), + Offset(38.028308888140046, 15.771601022134929), + Offset(39.14789282630049, 18.080427133406147), + Offset(39.93271083941276, 20.736761530678603), + Offset(40.25934512440292, 23.63435519208529), + Offset(40.04924491730148, 26.630919532116586), + Offset(39.264434687200016, 29.612221813168535), + Offset(37.917521599920704, 32.41442764041267), + Offset(36.061972697924574, 34.909116125265236), + Offset(33.765601680835694, 37.005115294039456), + Offset(31.112332725379705, 38.625823847000646), + Offset(28.199133356045557, 39.71201066248828), + Offset(25.20893585263977, 40.21846090429675), + Offset(22.23397280473206, 40.167286350701346), + Offset(19.4345490247564, 39.60950535387484), + Offset(16.929085156741415, 38.64589236883111), + Offset(14.827762075025696, 37.430191787449026), + Offset(13.255038432571515, 36.20843155014127), + Offset(12.500644991237632, 35.50064495506456), + Offset(12.5, 35.5), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(35.50532744442037, 30.891113146413097), + Offset(35.3687891328238, 31.114115099813013), + Offset(34.61166723055287, 32.200763293011846), + Offset(33.38838095992007, 33.576967314939026), + Offset(31.76523685853518, 34.93439968772162), + Offset(29.797132943339673, 36.093520977665925), + Offset(27.54438487121236, 36.9343471379394), + Offset(25.105113357767024, 37.3655798402645), + Offset(22.563109486905173, 37.333992112393716), + Offset(20.051962207654007, 36.816902808019094), + Offset(17.68510163937272, 35.8314013833927), + Offset(15.549028100935569, 34.41350440347644), + Offset(13.721807467475426, 32.61503094970389), + Offset(12.270218141218486, 30.5017088173341), + Offset(11.267018447090559, 28.21084086297116), + Offset(10.71296548038534, 25.820635513925914), + Offset(10.599305521851413, 23.469540290580085), + Offset(10.869103136283398, 21.272446599159352), + Offset(11.421398985045926, 19.348248017510365), + Offset(12.084262446401288, 17.84571705608547), + Offset(12.499613034195878, 17.100644984003022), + Offset(12.5, 17.1), + ]), + _PathCubicTo( + <Offset>[ + Offset(35.50532744442037, 30.891113146413097), + Offset(35.3687891328238, 31.114115099813013), + Offset(34.61166723055287, 32.200763293011846), + Offset(33.38838095992007, 33.576967314939026), + Offset(31.76523685853518, 34.93439968772162), + Offset(29.797132943339673, 36.093520977665925), + Offset(27.54438487121236, 36.9343471379394), + Offset(25.105113357767024, 37.3655798402645), + Offset(22.563109486905173, 37.333992112393716), + Offset(20.051962207654007, 36.816902808019094), + Offset(17.68510163937272, 35.8314013833927), + Offset(15.549028100935569, 34.41350440347644), + Offset(13.721807467475426, 32.61503094970389), + Offset(12.270218141218486, 30.5017088173341), + Offset(11.267018447090559, 28.21084086297116), + Offset(10.71296548038534, 25.820635513925914), + Offset(10.599305521851413, 23.469540290580085), + Offset(10.869103136283398, 21.272446599159352), + Offset(11.421398985045926, 19.348248017510365), + Offset(12.084262446401288, 17.84571705608547), + Offset(12.499613034195878, 17.100644984003022), + Offset(12.5, 17.1), + ], + <Offset>[ + Offset(3.3053370544563663, 30.9159905704348), + Offset(3.174434030182262, 30.511205881921516), + Offset(2.6337623939293966, 28.42536897455276), + Offset(2.2289899475128987, 25.45709344055406), + Offset(2.270784888483103, 22.014751744132322), + Offset(2.922803911111842, 18.35625965953803), + Offset(4.269398965967673, 14.683166694855906), + Offset(6.3194578185081784, 11.213349611079208), + Offset(9.0500114632611, 8.106673011877739), + Offset(12.347630664342756, 5.552173871552384), + Offset(16.071102437649664, 3.6718770309269573), + Offset(20.084347159420847, 2.534500638651229), + Offset(24.240695037744743, 2.181611748371399), + Offset(28.388246370238292, 2.6261071913867156), + Offset(32.28035351941033, 3.812485403260041), + Offset(35.81960444474234, 5.658872696319149), + Offset(38.844244382617234, 8.007864160496363), + Offset(41.27263323320897, 10.667478063357821), + Offset(43.06480058243858, 13.387112610045765), + Offset(44.21901281099895, 15.796859080287568), + Offset(44.69961298355358, 17.09883905917995), + Offset(44.699999999999996, 17.1), + ], + <Offset>[ + Offset(3.3053370544563663, 30.9159905704348), + Offset(3.174434030182262, 30.511205881921516), + Offset(2.6337623939293966, 28.42536897455276), + Offset(2.2289899475128987, 25.45709344055406), + Offset(2.270784888483103, 22.014751744132322), + Offset(2.922803911111842, 18.35625965953803), + Offset(4.269398965967673, 14.683166694855906), + Offset(6.3194578185081784, 11.213349611079208), + Offset(9.0500114632611, 8.106673011877739), + Offset(12.347630664342756, 5.552173871552384), + Offset(16.071102437649664, 3.6718770309269573), + Offset(20.084347159420847, 2.534500638651229), + Offset(24.240695037744743, 2.181611748371399), + Offset(28.388246370238292, 2.6261071913867156), + Offset(32.28035351941033, 3.812485403260041), + Offset(35.81960444474234, 5.658872696319149), + Offset(38.844244382617234, 8.007864160496363), + Offset(41.27263323320897, 10.667478063357821), + Offset(43.06480058243858, 13.387112610045765), + Offset(44.21901281099895, 15.796859080287568), + Offset(44.69961298355358, 17.09883905917995), + Offset(44.699999999999996, 17.1), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.3053370544563663, 30.9159905704348), + Offset(3.174434030182262, 30.511205881921516), + Offset(2.6337623939293966, 28.42536897455276), + Offset(2.2289899475128987, 25.45709344055406), + Offset(2.270784888483103, 22.014751744132322), + Offset(2.922803911111842, 18.35625965953803), + Offset(4.269398965967673, 14.683166694855906), + Offset(6.3194578185081784, 11.213349611079208), + Offset(9.0500114632611, 8.106673011877739), + Offset(12.347630664342756, 5.552173871552384), + Offset(16.071102437649664, 3.6718770309269573), + Offset(20.084347159420847, 2.534500638651229), + Offset(24.240695037744743, 2.181611748371399), + Offset(28.388246370238292, 2.6261071913867156), + Offset(32.28035351941033, 3.812485403260041), + Offset(35.81960444474234, 5.658872696319149), + Offset(38.844244382617234, 8.007864160496363), + Offset(41.27263323320897, 10.667478063357821), + Offset(43.06480058243858, 13.387112610045765), + Offset(44.21901281099895, 15.796859080287568), + Offset(44.69961298355358, 17.09883905917995), + Offset(44.699999999999996, 17.1), + ], + <Offset>[ + Offset(3.3088909721737494, 35.515989197572516), + Offset(3.0883041419120474, 35.11039946801316), + Offset(2.094420348435243, 32.9936410940704), + Offset(1.069007965457903, 29.90843501375508), + Offset(0.4251208965417739, 26.22824488271119), + Offset(0.3889094370935702, 22.19544952128486), + Offset(1.0906589026700306, 18.008164681319432), + Offset(2.5834249286245647, 13.897014688116185), + Offset(4.874680163187389, 10.037115586684035), + Offset(7.881240816276082, 6.65279266345399), + Offset(11.4768846730117, 3.902448345458822), + Offset(15.530203764445819, 1.8865979160104764), + Offset(19.89306372326867, 0.6789135240472106), + Offset(24.406017566531524, 0.32353173009817127), + Offset(28.794874168023036, 0.8105803929286424), + Offset(32.93935261365566, 2.0722099871252926), + Offset(36.63543350689098, 3.9728728946726743), + Offset(39.757637728094466, 6.324116620939881), + Offset(42.213209809943635, 8.866626667561102), + Offset(43.92631881445639, 11.206180456773618), + Offset(44.69935499429313, 12.498839066414565), + Offset(44.699999999999996, 12.5), + ], + <Offset>[ + Offset(3.3088909721737494, 35.515989197572516), + Offset(3.0883041419120474, 35.11039946801316), + Offset(2.094420348435243, 32.9936410940704), + Offset(1.069007965457903, 29.90843501375508), + Offset(0.4251208965417739, 26.22824488271119), + Offset(0.3889094370935702, 22.19544952128486), + Offset(1.0906589026700306, 18.008164681319432), + Offset(2.5834249286245647, 13.897014688116185), + Offset(4.874680163187389, 10.037115586684035), + Offset(7.881240816276082, 6.65279266345399), + Offset(11.4768846730117, 3.902448345458822), + Offset(15.530203764445819, 1.8865979160104764), + Offset(19.89306372326867, 0.6789135240472106), + Offset(24.406017566531524, 0.32353173009817127), + Offset(28.794874168023036, 0.8105803929286424), + Offset(32.93935261365566, 2.0722099871252926), + Offset(36.63543350689098, 3.9728728946726743), + Offset(39.757637728094466, 6.324116620939881), + Offset(42.213209809943635, 8.866626667561102), + Offset(43.92631881445639, 11.206180456773618), + Offset(44.69935499429313, 12.498839066414565), + Offset(44.699999999999996, 12.5), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.3088909721737494, 35.515989197572516), + Offset(3.0883041419120474, 35.11039946801316), + Offset(2.094420348435243, 32.9936410940704), + Offset(1.069007965457903, 29.90843501375508), + Offset(0.4251208965417739, 26.22824488271119), + Offset(0.3889094370935702, 22.19544952128486), + Offset(1.0906589026700306, 18.008164681319432), + Offset(2.5834249286245647, 13.897014688116185), + Offset(4.874680163187389, 10.037115586684035), + Offset(7.881240816276082, 6.65279266345399), + Offset(11.4768846730117, 3.902448345458822), + Offset(15.530203764445819, 1.8865979160104764), + Offset(19.89306372326867, 0.6789135240472106), + Offset(24.406017566531524, 0.32353173009817127), + Offset(28.794874168023036, 0.8105803929286424), + Offset(32.93935261365566, 2.0722099871252926), + Offset(36.63543350689098, 3.9728728946726743), + Offset(39.757637728094466, 6.324116620939881), + Offset(42.213209809943635, 8.866626667561102), + Offset(43.92631881445639, 11.206180456773618), + Offset(44.69935499429313, 12.498839066414565), + Offset(44.699999999999996, 12.5), + ], + <Offset>[ + Offset(35.50888136213775, 35.49111177355081), + Offset(35.282659244553585, 35.713308685904664), + Offset(34.072325185058716, 36.76903541252948), + Offset(32.22839897786507, 38.028308888140046), + Offset(29.91957286659385, 39.14789282630049), + Offset(27.2632384693214, 39.93271083941276), + Offset(24.365644807914713, 40.25934512440292), + Offset(21.369080467883414, 40.04924491730148), + Offset(18.38777818683146, 39.264434687200016), + Offset(15.585572359587335, 37.917521599920704), + Offset(13.09088387473476, 36.06197269792457), + Offset(10.99488470596054, 33.76560168083569), + Offset(9.374176152999356, 31.112332725379705), + Offset(8.287989337511718, 28.199133356045557), + Offset(7.781539095703257, 25.208935852639762), + Offset(7.8327136492986575, 22.233972804732062), + Offset(8.390494646125166, 19.434549024756393), + Offset(9.354107631168892, 16.929085156741415), + Offset(10.569808212550985, 14.8277620750257), + Offset(11.79156844985873, 13.255038432571517), + Offset(12.499355044935438, 12.500644991237637), + Offset(12.5, 12.5), + ], + <Offset>[ + Offset(35.50888136213775, 35.49111177355081), + Offset(35.282659244553585, 35.713308685904664), + Offset(34.072325185058716, 36.76903541252948), + Offset(32.22839897786507, 38.028308888140046), + Offset(29.91957286659385, 39.14789282630049), + Offset(27.2632384693214, 39.93271083941276), + Offset(24.365644807914713, 40.25934512440292), + Offset(21.369080467883414, 40.04924491730148), + Offset(18.38777818683146, 39.264434687200016), + Offset(15.585572359587335, 37.917521599920704), + Offset(13.09088387473476, 36.06197269792457), + Offset(10.99488470596054, 33.76560168083569), + Offset(9.374176152999356, 31.112332725379705), + Offset(8.287989337511718, 28.199133356045557), + Offset(7.781539095703257, 25.208935852639762), + Offset(7.8327136492986575, 22.233972804732062), + Offset(8.390494646125166, 19.434549024756393), + Offset(9.354107631168892, 16.929085156741415), + Offset(10.569808212550985, 14.8277620750257), + Offset(11.79156844985873, 13.255038432571517), + Offset(12.499355044935438, 12.500644991237637), + Offset(12.5, 12.5), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.50888136213775, 35.49111177355081), + Offset(35.282659244553585, 35.713308685904664), + Offset(34.072325185058716, 36.76903541252948), + Offset(32.22839897786507, 38.028308888140046), + Offset(29.91957286659385, 39.14789282630049), + Offset(27.2632384693214, 39.93271083941276), + Offset(24.365644807914713, 40.25934512440292), + Offset(21.369080467883414, 40.04924491730148), + Offset(18.38777818683146, 39.264434687200016), + Offset(15.585572359587335, 37.917521599920704), + Offset(13.09088387473476, 36.06197269792457), + Offset(10.99488470596054, 33.76560168083569), + Offset(9.374176152999356, 31.112332725379705), + Offset(8.287989337511718, 28.199133356045557), + Offset(7.781539095703257, 25.208935852639762), + Offset(7.8327136492986575, 22.233972804732062), + Offset(8.390494646125166, 19.434549024756393), + Offset(9.354107631168892, 16.929085156741415), + Offset(10.569808212550985, 14.8277620750257), + Offset(11.79156844985873, 13.255038432571517), + Offset(12.499355044935438, 12.500644991237637), + Offset(12.5, 12.5), + ], + <Offset>[ + Offset(35.50532744442037, 30.891113146413097), + Offset(35.3687891328238, 31.114115099813013), + Offset(34.61166723055287, 32.200763293011846), + Offset(33.38838095992007, 33.576967314939026), + Offset(31.76523685853518, 34.93439968772162), + Offset(29.797132943339673, 36.093520977665925), + Offset(27.54438487121236, 36.9343471379394), + Offset(25.105113357767024, 37.3655798402645), + Offset(22.563109486905173, 37.333992112393716), + Offset(20.051962207654007, 36.816902808019094), + Offset(17.68510163937272, 35.8314013833927), + Offset(15.549028100935569, 34.41350440347644), + Offset(13.721807467475426, 32.61503094970389), + Offset(12.270218141218486, 30.5017088173341), + Offset(11.267018447090559, 28.21084086297116), + Offset(10.71296548038534, 25.820635513925914), + Offset(10.599305521851413, 23.469540290580085), + Offset(10.869103136283398, 21.272446599159352), + Offset(11.421398985045926, 19.348248017510365), + Offset(12.084262446401288, 17.84571705608547), + Offset(12.499613034195878, 17.100644984003022), + Offset(12.5, 17.1), + ], + <Offset>[ + Offset(35.50532744442037, 30.891113146413097), + Offset(35.3687891328238, 31.114115099813013), + Offset(34.61166723055287, 32.200763293011846), + Offset(33.38838095992007, 33.576967314939026), + Offset(31.76523685853518, 34.93439968772162), + Offset(29.797132943339673, 36.093520977665925), + Offset(27.54438487121236, 36.9343471379394), + Offset(25.105113357767024, 37.3655798402645), + Offset(22.563109486905173, 37.333992112393716), + Offset(20.051962207654007, 36.816902808019094), + Offset(17.68510163937272, 35.8314013833927), + Offset(15.549028100935569, 34.41350440347644), + Offset(13.721807467475426, 32.61503094970389), + Offset(12.270218141218486, 30.5017088173341), + Offset(11.267018447090559, 28.21084086297116), + Offset(10.71296548038534, 25.820635513925914), + Offset(10.599305521851413, 23.469540290580085), + Offset(10.869103136283398, 21.272446599159352), + Offset(11.421398985045926, 19.348248017510365), + Offset(12.084262446401288, 17.84571705608547), + Offset(12.499613034195878, 17.100644984003022), + Offset(12.5, 17.1), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.634146341463, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(35.4982196089856, 21.691115892137674), + Offset(35.54104890936423, 21.915727927629714), + Offset(35.69035132154117, 23.06421905397657), + Offset(35.70834492403006, 24.67428416853698), + Offset(35.45656484241783, 26.507413410563885), + Offset(34.86492189137621, 28.415141254172266), + Offset(33.901864997807635, 30.284351165012342), + Offset(32.57717913753425, 31.99824968619054), + Offset(30.913772087052596, 33.47310696278113), + Offset(28.984741903787356, 34.61566522421589), + Offset(26.873537168648646, 35.370258754328965), + Offset(24.65731489088563, 35.709309848757954), + Offset(22.41707009642756, 35.62042739835226), + Offset(20.23467574863202, 35.10685973991119), + Offset(18.23797714986516, 34.21465088363395), + Offset(16.473469142558695, 32.99396093231363), + Offset(15.016927273303907, 31.53952282222746), + Offset(13.899094146512402, 29.959169483995232), + Offset(13.124580530035814, 28.389219902479688), + Offset(12.669650439486402, 27.027074303113366), + Offset(12.500129012716759, 26.300644969533792), + Offset(12.5, 26.3), + ]), + _PathCubicTo( + <Offset>[ + Offset(35.4982196089856, 21.691115892137674), + Offset(35.54104890936423, 21.915727927629714), + Offset(35.69035132154117, 23.06421905397657), + Offset(35.70834492403006, 24.67428416853698), + Offset(35.45656484241783, 26.507413410563885), + Offset(34.86492189137621, 28.415141254172266), + Offset(33.901864997807635, 30.284351165012342), + Offset(32.57717913753425, 31.99824968619054), + Offset(30.913772087052596, 33.47310696278113), + Offset(28.984741903787356, 34.61566522421589), + Offset(26.873537168648646, 35.370258754328965), + Offset(24.65731489088563, 35.709309848757954), + Offset(22.41707009642756, 35.62042739835226), + Offset(20.23467574863202, 35.10685973991119), + Offset(18.23797714986516, 34.21465088363395), + Offset(16.473469142558695, 32.99396093231363), + Offset(15.016927273303907, 31.53952282222746), + Offset(13.899094146512402, 29.959169483995232), + Offset(13.124580530035814, 28.389219902479688), + Offset(12.669650439486402, 27.027074303113366), + Offset(12.500129012716759, 26.300644969533792), + Offset(12.5, 26.3), + ], + <Offset>[ + Offset(3.2982292190216, 21.715993316159373), + Offset(3.3466938067226906, 21.312818709738217), + Offset(3.712446484917704, 19.288824735517487), + Offset(4.5489539116228865, 16.554410294152014), + Offset(5.962112872365758, 13.587765466974588), + Offset(7.990592859148386, 10.677879936044366), + Offset(10.626879092562953, 8.033170721928855), + Offset(13.791523598275406, 5.846019457005246), + Offset(17.400674063408523, 4.245787862265146), + Offset(21.280410360476104, 3.350936287749171), + Offset(25.25953796692559, 3.2107344018632205), + Offset(29.192633949370908, 3.8303060839327383), + Offset(32.93595766669688, 5.187008197019772), + Offset(36.35270397765183, 7.231258113963804), + Offset(39.251312222184936, 9.816295423922835), + Offset(41.58010810691569, 12.832198114706863), + Offset(43.26186613406972, 16.07784669214374), + Offset(44.302624243437975, 19.354200948193697), + Offset(44.76798212742847, 22.428084495015092), + Offset(44.80440080408406, 24.978216327315465), + Offset(44.70012896207446, 26.29883904471072), + Offset(44.699999999999996, 26.3), + ], + <Offset>[ + Offset(3.2982292190216, 21.715993316159373), + Offset(3.3466938067226906, 21.312818709738217), + Offset(3.712446484917704, 19.288824735517487), + Offset(4.5489539116228865, 16.554410294152014), + Offset(5.962112872365758, 13.587765466974588), + Offset(7.990592859148386, 10.677879936044366), + Offset(10.626879092562953, 8.033170721928855), + Offset(13.791523598275406, 5.846019457005246), + Offset(17.400674063408523, 4.245787862265146), + Offset(21.280410360476104, 3.350936287749171), + Offset(25.25953796692559, 3.2107344018632205), + Offset(29.192633949370908, 3.8303060839327383), + Offset(32.93595766669688, 5.187008197019772), + Offset(36.35270397765183, 7.231258113963804), + Offset(39.251312222184936, 9.816295423922835), + Offset(41.58010810691569, 12.832198114706863), + Offset(43.26186613406972, 16.07784669214374), + Offset(44.302624243437975, 19.354200948193697), + Offset(44.76798212742847, 22.428084495015092), + Offset(44.80440080408406, 24.978216327315465), + Offset(44.70012896207446, 26.29883904471072), + Offset(44.699999999999996, 26.3), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.2982292190216, 21.715993316159373), + Offset(3.3466938067226906, 21.312818709738217), + Offset(3.712446484917704, 19.288824735517487), + Offset(4.5489539116228865, 16.554410294152014), + Offset(5.962112872365758, 13.587765466974588), + Offset(7.990592859148386, 10.677879936044366), + Offset(10.626879092562953, 8.033170721928855), + Offset(13.791523598275406, 5.846019457005246), + Offset(17.400674063408523, 4.245787862265146), + Offset(21.280410360476104, 3.350936287749171), + Offset(25.25953796692559, 3.2107344018632205), + Offset(29.192633949370908, 3.8303060839327383), + Offset(32.93595766669688, 5.187008197019772), + Offset(36.35270397765183, 7.231258113963804), + Offset(39.251312222184936, 9.816295423922835), + Offset(41.58010810691569, 12.832198114706863), + Offset(43.26186613406972, 16.07784669214374), + Offset(44.302624243437975, 19.354200948193697), + Offset(44.76798212742847, 22.428084495015092), + Offset(44.80440080408406, 24.978216327315465), + Offset(44.70012896207446, 26.29883904471072), + Offset(44.699999999999996, 26.3), + ], + <Offset>[ + Offset(3.301783136738983, 26.315991943297085), + Offset(3.260563918452476, 25.912012295829864), + Offset(3.1731044394235504, 23.857096855035127), + Offset(3.388971929567891, 21.005751867353034), + Offset(4.116448880424429, 17.801258605553457), + Offset(5.456698385130114, 14.517069797791198), + Offset(7.448139029265311, 11.35816870839238), + Offset(10.055490708391792, 8.529684534042223), + Offset(13.225342763334812, 6.1762304370714425), + Offset(16.81402051240943, 4.4515550796507775), + Offset(20.665320202287624, 3.4413057163950853), + Offset(24.63849055439588, 3.1824033612919855), + Offset(28.588326352220808, 3.6843099726955835), + Offset(32.37047517394506, 4.92868265267526), + Offset(35.76583287079764, 6.814390413591436), + Offset(38.699856275829006, 9.245535405513007), + Offset(41.05305525834348, 12.042855426320052), + Offset(42.78762873832347, 15.010839505775758), + Offset(43.91639135493352, 17.90759855253043), + Offset(44.5117068075415, 20.387537703801513), + Offset(44.69987097281401, 21.698839051945335), + Offset(44.699999999999996, 21.7), + ], + <Offset>[ + Offset(3.301783136738983, 26.315991943297085), + Offset(3.260563918452476, 25.912012295829864), + Offset(3.1731044394235504, 23.857096855035127), + Offset(3.388971929567891, 21.005751867353034), + Offset(4.116448880424429, 17.801258605553457), + Offset(5.456698385130114, 14.517069797791198), + Offset(7.448139029265311, 11.35816870839238), + Offset(10.055490708391792, 8.529684534042223), + Offset(13.225342763334812, 6.1762304370714425), + Offset(16.81402051240943, 4.4515550796507775), + Offset(20.665320202287624, 3.4413057163950853), + Offset(24.63849055439588, 3.1824033612919855), + Offset(28.588326352220808, 3.6843099726955835), + Offset(32.37047517394506, 4.92868265267526), + Offset(35.76583287079764, 6.814390413591436), + Offset(38.699856275829006, 9.245535405513007), + Offset(41.05305525834348, 12.042855426320052), + Offset(42.78762873832347, 15.010839505775758), + Offset(43.91639135493352, 17.90759855253043), + Offset(44.5117068075415, 20.387537703801513), + Offset(44.69987097281401, 21.698839051945335), + Offset(44.699999999999996, 21.7), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(3.301783136738983, 26.315991943297085), + Offset(3.260563918452476, 25.912012295829864), + Offset(3.1731044394235504, 23.857096855035127), + Offset(3.388971929567891, 21.005751867353034), + Offset(4.116448880424429, 17.801258605553457), + Offset(5.456698385130114, 14.517069797791198), + Offset(7.448139029265311, 11.35816870839238), + Offset(10.055490708391792, 8.529684534042223), + Offset(13.225342763334812, 6.1762304370714425), + Offset(16.81402051240943, 4.4515550796507775), + Offset(20.665320202287624, 3.4413057163950853), + Offset(24.63849055439588, 3.1824033612919855), + Offset(28.588326352220808, 3.6843099726955835), + Offset(32.37047517394506, 4.92868265267526), + Offset(35.76583287079764, 6.814390413591436), + Offset(38.699856275829006, 9.245535405513007), + Offset(41.05305525834348, 12.042855426320052), + Offset(42.78762873832347, 15.010839505775758), + Offset(43.91639135493352, 17.90759855253043), + Offset(44.5117068075415, 20.387537703801513), + Offset(44.69987097281401, 21.698839051945335), + Offset(44.699999999999996, 21.7), + ], + <Offset>[ + Offset(35.50177352670298, 26.291114519275386), + Offset(35.454919021094014, 26.51492151372136), + Offset(35.15100927604702, 27.63249117349421), + Offset(34.54836294197506, 29.125625741738), + Offset(33.610900850476504, 30.720906549142754), + Offset(32.331027417357944, 32.2543311159191), + Offset(30.723124934509997, 33.60934915147587), + Offset(28.84114624765064, 34.68191476322752), + Offset(26.738440786978884, 35.40354953758742), + Offset(24.518352055720683, 35.71628401611749), + Offset(22.279319404010682, 35.60083006886083), + Offset(20.1031714959106, 35.061407126117196), + Offset(18.06943878195149, 34.11772917402808), + Offset(16.252446944925254, 32.804284278622646), + Offset(14.75249779847786, 31.212745873302556), + Offset(13.593217311472014, 29.407298223119774), + Offset(12.80811639757766, 27.504531556403773), + Offset(12.384098641397896, 25.615808041577292), + Offset(12.272989757540874, 23.868733959995026), + Offset(12.376956442943843, 22.436395679599414), + Offset(12.499871023456318, 21.700644976768405), + Offset(12.5, 21.7), + ], + <Offset>[ + Offset(35.50177352670298, 26.291114519275386), + Offset(35.454919021094014, 26.51492151372136), + Offset(35.15100927604702, 27.63249117349421), + Offset(34.54836294197506, 29.125625741738), + Offset(33.610900850476504, 30.720906549142754), + Offset(32.331027417357944, 32.2543311159191), + Offset(30.723124934509997, 33.60934915147587), + Offset(28.84114624765064, 34.68191476322752), + Offset(26.738440786978884, 35.40354953758742), + Offset(24.518352055720683, 35.71628401611749), + Offset(22.279319404010682, 35.60083006886083), + Offset(20.1031714959106, 35.061407126117196), + Offset(18.06943878195149, 34.11772917402808), + Offset(16.252446944925254, 32.804284278622646), + Offset(14.75249779847786, 31.212745873302556), + Offset(13.593217311472014, 29.407298223119774), + Offset(12.80811639757766, 27.504531556403773), + Offset(12.384098641397896, 25.615808041577292), + Offset(12.272989757540874, 23.868733959995026), + Offset(12.376956442943843, 22.436395679599414), + Offset(12.499871023456318, 21.700644976768405), + Offset(12.5, 21.7), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(35.50177352670298, 26.291114519275386), + Offset(35.454919021094014, 26.51492151372136), + Offset(35.15100927604702, 27.63249117349421), + Offset(34.54836294197506, 29.125625741738), + Offset(33.610900850476504, 30.720906549142754), + Offset(32.331027417357944, 32.2543311159191), + Offset(30.723124934509997, 33.60934915147587), + Offset(28.84114624765064, 34.68191476322752), + Offset(26.738440786978884, 35.40354953758742), + Offset(24.518352055720683, 35.71628401611749), + Offset(22.279319404010682, 35.60083006886083), + Offset(20.1031714959106, 35.061407126117196), + Offset(18.06943878195149, 34.11772917402808), + Offset(16.252446944925254, 32.804284278622646), + Offset(14.75249779847786, 31.212745873302556), + Offset(13.593217311472014, 29.407298223119774), + Offset(12.80811639757766, 27.504531556403773), + Offset(12.384098641397896, 25.615808041577292), + Offset(12.272989757540874, 23.868733959995026), + Offset(12.376956442943843, 22.436395679599414), + Offset(12.499871023456318, 21.700644976768405), + Offset(12.5, 21.7), + ], + <Offset>[ + Offset(35.4982196089856, 21.691115892137674), + Offset(35.54104890936423, 21.915727927629714), + Offset(35.69035132154117, 23.06421905397657), + Offset(35.70834492403006, 24.67428416853698), + Offset(35.45656484241783, 26.507413410563885), + Offset(34.86492189137621, 28.415141254172266), + Offset(33.901864997807635, 30.284351165012342), + Offset(32.57717913753425, 31.99824968619054), + Offset(30.913772087052596, 33.47310696278113), + Offset(28.984741903787356, 34.61566522421589), + Offset(26.873537168648646, 35.370258754328965), + Offset(24.65731489088563, 35.709309848757954), + Offset(22.41707009642756, 35.62042739835226), + Offset(20.23467574863202, 35.10685973991119), + Offset(18.23797714986516, 34.21465088363395), + Offset(16.473469142558695, 32.99396093231363), + Offset(15.016927273303907, 31.53952282222746), + Offset(13.899094146512402, 29.959169483995232), + Offset(13.124580530035814, 28.389219902479688), + Offset(12.669650439486402, 27.027074303113366), + Offset(12.500129012716759, 26.300644969533792), + Offset(12.5, 26.3), + ], + <Offset>[ + Offset(35.4982196089856, 21.691115892137674), + Offset(35.54104890936423, 21.915727927629714), + Offset(35.69035132154117, 23.06421905397657), + Offset(35.70834492403006, 24.67428416853698), + Offset(35.45656484241783, 26.507413410563885), + Offset(34.86492189137621, 28.415141254172266), + Offset(33.901864997807635, 30.284351165012342), + Offset(32.57717913753425, 31.99824968619054), + Offset(30.913772087052596, 33.47310696278113), + Offset(28.984741903787356, 34.61566522421589), + Offset(26.873537168648646, 35.370258754328965), + Offset(24.65731489088563, 35.709309848757954), + Offset(22.41707009642756, 35.62042739835226), + Offset(20.23467574863202, 35.10685973991119), + Offset(18.23797714986516, 34.21465088363395), + Offset(16.473469142558695, 32.99396093231363), + Offset(15.016927273303907, 31.53952282222746), + Offset(13.899094146512402, 29.959169483995232), + Offset(13.124580530035814, 28.389219902479688), + Offset(12.669650439486402, 27.027074303113366), + Offset(12.500129012716759, 26.300644969533792), + Offset(12.5, 26.3), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.390243902439, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(31.500705947534364, 37.904137673197035), + Offset(31.224359596665288, 38.04026654278025), + Offset(29.77500721437707, 38.610257636892996), + Offset(27.671279110116373, 39.06613773120253), + Offset(25.221379281133324, 39.03073015693383), + Offset(22.702260300479338, 38.4714008591571), + Offset(20.28270034295162, 37.48547892474107), + Offset(18.054569997652198, 36.18517574943152), + Offset(16.048128541632522, 34.59627819756455), + Offset(14.343114833822348, 32.739119933221964), + Offset(12.99519031795477, 30.657890586626984), + Offset(12.038214592605321, 28.40198375080891), + Offset(11.498734264325233, 26.032323169068647), + Offset(11.392995785033866, 23.617840044802506), + Offset(11.712187869419232, 21.29019602644484), + Offset(12.42139313017819, 19.101094337832414), + Offset(13.45637301051034, 17.152279319649516), + Offset(14.726397156564566, 15.511205096609956), + Offset(16.093303132855745, 14.22536480165701), + Offset(17.347278595340214, 13.332261414827599), + Offset(18.038917684662923, 12.930905807638549), + Offset(18.039538499999995, 12.930571500000006), + ]), + _PathCubicTo( + <Offset>[ + Offset(31.500705947534364, 37.904137673197035), + Offset(31.224359596665288, 38.04026654278025), + Offset(29.77500721437707, 38.610257636892996), + Offset(27.671279110116373, 39.06613773120253), + Offset(25.221379281133324, 39.03073015693383), + Offset(22.702260300479338, 38.4714008591571), + Offset(20.28270034295162, 37.48547892474107), + Offset(18.054569997652198, 36.18517574943152), + Offset(16.048128541632522, 34.59627819756455), + Offset(14.343114833822348, 32.739119933221964), + Offset(12.99519031795477, 30.657890586626984), + Offset(12.038214592605321, 28.40198375080891), + Offset(11.498734264325233, 26.032323169068647), + Offset(11.392995785033866, 23.617840044802506), + Offset(11.712187869419232, 21.29019602644484), + Offset(12.42139313017819, 19.101094337832414), + Offset(13.45637301051034, 17.152279319649516), + Offset(14.726397156564566, 15.511205096609956), + Offset(16.093303132855745, 14.22536480165701), + Offset(17.347278595340214, 13.332261414827599), + Offset(18.038917684662923, 12.930905807638549), + Offset(18.039538499999995, 12.930571500000006), + ], + <Offset>[ + Offset(42.20064775415372, 37.89587099404257), + Offset(41.91674410275704, 38.2405046640102), + Offset(40.34190628686553, 39.857813126404416), + Offset(37.83452426041213, 41.714593496656875), + Offset(34.57684474021924, 43.1287659571083), + Offset(30.91528934967189, 43.89206242373822), + Offset(27.130871371497264, 44.03241690707209), + Offset(23.411876177751978, 43.64328677129125), + Offset(19.813656815016323, 42.74069454351543), + Offset(16.453681942475647, 41.30395235032578), + Offset(13.431837558864594, 39.35824654869015), + Offset(10.822310505850872, 36.948639555937035), + Offset(8.696502202314639, 34.13978724004353), + Offset(7.116981614284766, 31.013066425911557), + Offset(6.150621971699855, 27.74766987427346), + Offset(5.782126079860243, 24.43272505675491), + Offset(5.987301553125755, 21.240953911434477), + Offset(6.686509374540779, 18.31557540123979), + Offset(7.725544616870944, 15.801723190853087), + Offset(8.849588162319126, 13.874059954477616), + Offset(9.523972698054738, 12.931383365108815), + Offset(9.524593499999996, 12.930571500000006), + ], + <Offset>[ + Offset(42.20064775415372, 37.89587099404257), + Offset(41.91674410275704, 38.2405046640102), + Offset(40.34190628686553, 39.857813126404416), + Offset(37.83452426041213, 41.714593496656875), + Offset(34.57684474021924, 43.1287659571083), + Offset(30.91528934967189, 43.89206242373822), + Offset(27.130871371497264, 44.03241690707209), + Offset(23.411876177751978, 43.64328677129125), + Offset(19.813656815016323, 42.74069454351543), + Offset(16.453681942475647, 41.30395235032578), + Offset(13.431837558864594, 39.35824654869015), + Offset(10.822310505850872, 36.948639555937035), + Offset(8.696502202314639, 34.13978724004353), + Offset(7.116981614284766, 31.013066425911557), + Offset(6.150621971699855, 27.74766987427346), + Offset(5.782126079860243, 24.43272505675491), + Offset(5.987301553125755, 21.240953911434477), + Offset(6.686509374540779, 18.31557540123979), + Offset(7.725544616870944, 15.801723190853087), + Offset(8.849588162319126, 13.874059954477616), + Offset(9.523972698054738, 12.931383365108815), + Offset(9.524593499999996, 12.930571500000006), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.20064775415372, 37.89587099404257), + Offset(41.91674410275704, 38.2405046640102), + Offset(40.34190628686553, 39.857813126404416), + Offset(37.83452426041213, 41.714593496656875), + Offset(34.57684474021924, 43.1287659571083), + Offset(30.91528934967189, 43.89206242373822), + Offset(27.130871371497264, 44.03241690707209), + Offset(23.411876177751978, 43.64328677129125), + Offset(19.813656815016323, 42.74069454351543), + Offset(16.453681942475647, 41.30395235032578), + Offset(13.431837558864594, 39.35824654869015), + Offset(10.822310505850872, 36.948639555937035), + Offset(8.696502202314639, 34.13978724004353), + Offset(7.116981614284766, 31.013066425911557), + Offset(6.150621971699855, 27.74766987427346), + Offset(5.782126079860243, 24.43272505675491), + Offset(5.987301553125755, 21.240953911434477), + Offset(6.686509374540779, 18.31557540123979), + Offset(7.725544616870944, 15.801723190853087), + Offset(8.849588162319126, 13.874059954477616), + Offset(9.523972698054738, 12.931383365108815), + Offset(9.524593499999996, 12.930571500000006), + ], + <Offset>[ + Offset(42.19072773916836, 25.055940826099334), + Offset(42.15702984823299, 25.409643256700093), + Offset(41.838972874279236, 27.177534239418264), + Offset(41.01267117895735, 29.518699316301966), + Offset(39.49448770042861, 31.902207406205207), + Offset(37.42008322716923, 34.036427564707154), + Offset(34.987196950294496, 35.814611672817314), + Offset(32.36160940398365, 37.21451935517152), + Offset(29.58695643015737, 38.22206061545487), + Offset(26.731480843000227, 38.77127181994182), + Offset(23.87226471334039, 38.83426985959836), + Offset(21.07829747200463, 38.407724460042374), + Offset(18.4254590874845, 37.50246571445624), + Offset(15.991253271615628, 36.144283430810475), + Offset(13.899590589094196, 34.421548951536714), + Offset(12.180082942567237, 32.39984551713645), + Offset(10.893711063267709, 30.203839660295976), + Offset(10.05175374009658, 27.963440739668336), + Offset(9.617174683906235, 25.84303341003485), + Offset(9.499746409899146, 24.07128847410292), + Offset(9.52454576701906, 23.149317349038636), + Offset(9.524593499999998, 23.148505500000006), + ], + <Offset>[ + Offset(42.19072773916836, 25.055940826099334), + Offset(42.15702984823299, 25.409643256700093), + Offset(41.838972874279236, 27.177534239418264), + Offset(41.01267117895735, 29.518699316301966), + Offset(39.49448770042861, 31.902207406205207), + Offset(37.42008322716923, 34.036427564707154), + Offset(34.987196950294496, 35.814611672817314), + Offset(32.36160940398365, 37.21451935517152), + Offset(29.58695643015737, 38.22206061545487), + Offset(26.731480843000227, 38.77127181994182), + Offset(23.87226471334039, 38.83426985959836), + Offset(21.07829747200463, 38.407724460042374), + Offset(18.4254590874845, 37.50246571445624), + Offset(15.991253271615628, 36.144283430810475), + Offset(13.899590589094196, 34.421548951536714), + Offset(12.180082942567237, 32.39984551713645), + Offset(10.893711063267709, 30.203839660295976), + Offset(10.05175374009658, 27.963440739668336), + Offset(9.617174683906235, 25.84303341003485), + Offset(9.499746409899146, 24.07128847410292), + Offset(9.52454576701906, 23.149317349038636), + Offset(9.524593499999998, 23.148505500000006), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.19072773916836, 25.055940826099334), + Offset(42.15702984823299, 25.409643256700093), + Offset(41.838972874279236, 27.177534239418264), + Offset(41.01267117895735, 29.518699316301966), + Offset(39.49448770042861, 31.902207406205207), + Offset(37.42008322716923, 34.036427564707154), + Offset(34.987196950294496, 35.814611672817314), + Offset(32.36160940398365, 37.21451935517152), + Offset(29.58695643015737, 38.22206061545487), + Offset(26.731480843000227, 38.77127181994182), + Offset(23.87226471334039, 38.83426985959836), + Offset(21.07829747200463, 38.407724460042374), + Offset(18.4254590874845, 37.50246571445624), + Offset(15.991253271615628, 36.144283430810475), + Offset(13.899590589094196, 34.421548951536714), + Offset(12.180082942567237, 32.39984551713645), + Offset(10.893711063267709, 30.203839660295976), + Offset(10.05175374009658, 27.963440739668336), + Offset(9.617174683906235, 25.84303341003485), + Offset(9.499746409899146, 24.07128847410292), + Offset(9.52454576701906, 23.149317349038636), + Offset(9.524593499999998, 23.148505500000006), + ], + <Offset>[ + Offset(31.490785932549, 25.064207505253805), + Offset(31.46464534214123, 25.209405135470142), + Offset(31.272073801790775, 25.92997874990684), + Offset(30.84942602866159, 26.87024355084762), + Offset(30.139022241342694, 27.80417160603073), + Offset(29.207054177976676, 28.615766000126037), + Offset(28.139025921748853, 29.26767369048629), + Offset(27.004303223883873, 29.75640833331179), + Offset(25.82142815677357, 30.077644269503992), + Offset(24.620913734346928, 30.206439402838008), + Offset(23.435617472430565, 30.133913897535194), + Offset(22.294201558759077, 29.861068654914245), + Offset(21.227691149495094, 29.395001643481358), + Offset(20.267267442364727, 28.749057049701424), + Offset(19.46115648681357, 27.964075103708094), + Offset(18.819349992885186, 27.06821479821395), + Offset(18.362782520652296, 26.115165068511015), + Offset(18.091641522120366, 25.1590704350385), + Offset(17.984933199891035, 24.26667502083877), + Offset(17.997436842920234, 23.529489934452904), + Offset(18.039490753627245, 23.14883979156837), + Offset(18.0395385, 23.148505500000006), + ], + <Offset>[ + Offset(31.490785932549, 25.064207505253805), + Offset(31.46464534214123, 25.209405135470142), + Offset(31.272073801790775, 25.92997874990684), + Offset(30.84942602866159, 26.87024355084762), + Offset(30.139022241342694, 27.80417160603073), + Offset(29.207054177976676, 28.615766000126037), + Offset(28.139025921748853, 29.26767369048629), + Offset(27.004303223883873, 29.75640833331179), + Offset(25.82142815677357, 30.077644269503992), + Offset(24.620913734346928, 30.206439402838008), + Offset(23.435617472430565, 30.133913897535194), + Offset(22.294201558759077, 29.861068654914245), + Offset(21.227691149495094, 29.395001643481358), + Offset(20.267267442364727, 28.749057049701424), + Offset(19.46115648681357, 27.964075103708094), + Offset(18.819349992885186, 27.06821479821395), + Offset(18.362782520652296, 26.115165068511015), + Offset(18.091641522120366, 25.1590704350385), + Offset(17.984933199891035, 24.26667502083877), + Offset(17.997436842920234, 23.529489934452904), + Offset(18.039490753627245, 23.14883979156837), + Offset(18.0395385, 23.148505500000006), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(31.490785932549, 25.064207505253805), + Offset(31.46464534214123, 25.209405135470142), + Offset(31.272073801790775, 25.92997874990684), + Offset(30.84942602866159, 26.87024355084762), + Offset(30.139022241342694, 27.80417160603073), + Offset(29.207054177976676, 28.615766000126037), + Offset(28.139025921748853, 29.26767369048629), + Offset(27.004303223883873, 29.75640833331179), + Offset(25.82142815677357, 30.077644269503992), + Offset(24.620913734346928, 30.206439402838008), + Offset(23.435617472430565, 30.133913897535194), + Offset(22.294201558759077, 29.861068654914245), + Offset(21.227691149495094, 29.395001643481358), + Offset(20.267267442364727, 28.749057049701424), + Offset(19.46115648681357, 27.964075103708094), + Offset(18.819349992885186, 27.06821479821395), + Offset(18.362782520652296, 26.115165068511015), + Offset(18.091641522120366, 25.1590704350385), + Offset(17.984933199891035, 24.26667502083877), + Offset(17.997436842920234, 23.529489934452904), + Offset(18.039490753627245, 23.14883979156837), + Offset(18.0395385, 23.148505500000006), + ], + <Offset>[ + Offset(31.500705947534364, 37.904137673197035), + Offset(31.224359596665288, 38.04026654278025), + Offset(29.77500721437707, 38.610257636892996), + Offset(27.671279110116373, 39.06613773120253), + Offset(25.221379281133324, 39.03073015693383), + Offset(22.702260300479338, 38.4714008591571), + Offset(20.28270034295162, 37.48547892474107), + Offset(18.054569997652198, 36.18517574943152), + Offset(16.048128541632522, 34.59627819756455), + Offset(14.343114833822348, 32.739119933221964), + Offset(12.99519031795477, 30.657890586626984), + Offset(12.038214592605321, 28.40198375080891), + Offset(11.498734264325233, 26.032323169068647), + Offset(11.392995785033866, 23.617840044802506), + Offset(11.712187869419232, 21.29019602644484), + Offset(12.42139313017819, 19.101094337832414), + Offset(13.45637301051034, 17.152279319649516), + Offset(14.726397156564566, 15.511205096609956), + Offset(16.093303132855745, 14.22536480165701), + Offset(17.347278595340214, 13.332261414827599), + Offset(18.038917684662923, 12.930905807638549), + Offset(18.039538499999995, 12.930571500000006), + ], + <Offset>[ + Offset(31.500705947534364, 37.904137673197035), + Offset(31.224359596665288, 38.04026654278025), + Offset(29.77500721437707, 38.610257636892996), + Offset(27.671279110116373, 39.06613773120253), + Offset(25.221379281133324, 39.03073015693383), + Offset(22.702260300479338, 38.4714008591571), + Offset(20.28270034295162, 37.48547892474107), + Offset(18.054569997652198, 36.18517574943152), + Offset(16.048128541632522, 34.59627819756455), + Offset(14.343114833822348, 32.739119933221964), + Offset(12.99519031795477, 30.657890586626984), + Offset(12.038214592605321, 28.40198375080891), + Offset(11.498734264325233, 26.032323169068647), + Offset(11.392995785033866, 23.617840044802506), + Offset(11.712187869419232, 21.29019602644484), + Offset(12.42139313017819, 19.101094337832414), + Offset(13.45637301051034, 17.152279319649516), + Offset(14.726397156564566, 15.511205096609956), + Offset(16.093303132855745, 14.22536480165701), + Offset(17.347278595340214, 13.332261414827599), + Offset(18.038917684662923, 12.930905807638549), + Offset(18.039538499999995, 12.930571500000006), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.390243902439, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(5.809272260831642, 22.944059173900662), + Offset(5.842970151767011, 22.590356743299907), + Offset(6.161027125720756, 20.822465760581736), + Offset(6.987328821042649, 18.48130068369803), + Offset(8.505512299571395, 16.097792593794804), + Offset(10.579916772830773, 13.963572435292853), + Offset(13.012803049705509, 12.18538832718269), + Offset(15.638390596016343, 10.785480644828485), + Offset(18.413043569842625, 9.77793938454513), + Offset(21.26851915699977, 9.228728180058184), + Offset(24.12773528665962, 9.165730140401642), + Offset(26.92170252799538, 9.592275539957622), + Offset(29.5745409125155, 10.497534285543754), + Offset(32.008746728384374, 11.855716569189521), + Offset(34.100409410905804, 13.578451048463291), + Offset(35.819917057432754, 15.600154482863552), + Offset(37.10628893673229, 17.796160339704027), + Offset(37.94824625990342, 20.03655926033166), + Offset(38.38282531609377, 22.156966589965144), + Offset(38.50025359010085, 23.928711525897086), + Offset(38.47545423298094, 24.850682650961364), + Offset(38.4754065, 24.8514945), + ]), + _PathCubicTo( + <Offset>[ + Offset(5.809272260831642, 22.944059173900662), + Offset(5.842970151767011, 22.590356743299907), + Offset(6.161027125720756, 20.822465760581736), + Offset(6.987328821042649, 18.48130068369803), + Offset(8.505512299571395, 16.097792593794804), + Offset(10.579916772830773, 13.963572435292853), + Offset(13.012803049705509, 12.18538832718269), + Offset(15.638390596016343, 10.785480644828485), + Offset(18.413043569842625, 9.77793938454513), + Offset(21.26851915699977, 9.228728180058184), + Offset(24.12773528665962, 9.165730140401642), + Offset(26.92170252799538, 9.592275539957622), + Offset(29.5745409125155, 10.497534285543754), + Offset(32.008746728384374, 11.855716569189521), + Offset(34.100409410905804, 13.578451048463291), + Offset(35.819917057432754, 15.600154482863552), + Offset(37.10628893673229, 17.796160339704027), + Offset(37.94824625990342, 20.03655926033166), + Offset(38.38282531609377, 22.156966589965144), + Offset(38.50025359010085, 23.928711525897086), + Offset(38.47545423298094, 24.850682650961364), + Offset(38.4754065, 24.8514945), + ], + <Offset>[ + Offset(16.509214067451, 22.93579249474619), + Offset(16.53535465785877, 22.790594864529858), + Offset(16.727926198209218, 22.07002125009316), + Offset(17.150573971338403, 21.129756449152378), + Offset(17.86097775865731, 20.19582839396928), + Offset(18.792945822023324, 19.38423399987397), + Offset(19.860974078251154, 18.732326309513716), + Offset(20.995696776116127, 18.24359166668821), + Offset(22.178571843226425, 17.922355730496008), + Offset(23.379086265653072, 17.793560597162), + Offset(24.564382527569446, 17.866086102464806), + Offset(25.70579844124093, 18.13893134508575), + Offset(26.772308850504906, 18.60499835651864), + Offset(27.732732557635273, 19.250942950298572), + Offset(28.53884351318643, 20.03592489629191), + Offset(29.180650007114806, 20.93178520178605), + Offset(29.637217479347704, 21.88483493148899), + Offset(29.908358477879634, 22.840929564961495), + Offset(30.015066800108972, 23.733324979161225), + Offset(30.00256315707976, 24.470510065547103), + Offset(29.960509246372755, 24.85116020843163), + Offset(29.9604615, 24.8514945), + ], + <Offset>[ + Offset(16.509214067451, 22.93579249474619), + Offset(16.53535465785877, 22.790594864529858), + Offset(16.727926198209218, 22.07002125009316), + Offset(17.150573971338403, 21.129756449152378), + Offset(17.86097775865731, 20.19582839396928), + Offset(18.792945822023324, 19.38423399987397), + Offset(19.860974078251154, 18.732326309513716), + Offset(20.995696776116127, 18.24359166668821), + Offset(22.178571843226425, 17.922355730496008), + Offset(23.379086265653072, 17.793560597162), + Offset(24.564382527569446, 17.866086102464806), + Offset(25.70579844124093, 18.13893134508575), + Offset(26.772308850504906, 18.60499835651864), + Offset(27.732732557635273, 19.250942950298572), + Offset(28.53884351318643, 20.03592489629191), + Offset(29.180650007114806, 20.93178520178605), + Offset(29.637217479347704, 21.88483493148899), + Offset(29.908358477879634, 22.840929564961495), + Offset(30.015066800108972, 23.733324979161225), + Offset(30.00256315707976, 24.470510065547103), + Offset(29.960509246372755, 24.85116020843163), + Offset(29.9604615, 24.8514945), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.509214067451, 22.93579249474619), + Offset(16.53535465785877, 22.790594864529858), + Offset(16.727926198209218, 22.07002125009316), + Offset(17.150573971338403, 21.129756449152378), + Offset(17.86097775865731, 20.19582839396928), + Offset(18.792945822023324, 19.38423399987397), + Offset(19.860974078251154, 18.732326309513716), + Offset(20.995696776116127, 18.24359166668821), + Offset(22.178571843226425, 17.922355730496008), + Offset(23.379086265653072, 17.793560597162), + Offset(24.564382527569446, 17.866086102464806), + Offset(25.70579844124093, 18.13893134508575), + Offset(26.772308850504906, 18.60499835651864), + Offset(27.732732557635273, 19.250942950298572), + Offset(28.53884351318643, 20.03592489629191), + Offset(29.180650007114806, 20.93178520178605), + Offset(29.637217479347704, 21.88483493148899), + Offset(29.908358477879634, 22.840929564961495), + Offset(30.015066800108972, 23.733324979161225), + Offset(30.00256315707976, 24.470510065547103), + Offset(29.960509246372755, 24.85116020843163), + Offset(29.9604615, 24.8514945), + ], + <Offset>[ + Offset(16.49929405246564, 10.095862326802958), + Offset(16.775640403334712, 9.95973345721975), + Offset(18.224992785622923, 9.389742363107004), + Offset(20.328720889883623, 8.93386226879747), + Offset(22.778620718866684, 8.969269843066183), + Offset(25.297739699520662, 9.528599140842907), + Offset(27.717299657048386, 10.514521075258944), + Offset(29.9454300023478, 11.814824250568474), + Offset(31.951871458367474, 13.403721802435447), + Offset(33.656885166177645, 15.26088006677804), + Offset(35.00480968204524, 17.342109413373016), + Offset(35.961785407394686, 19.59801624919109), + Offset(36.501265735674764, 21.96767683093135), + Offset(36.60700421496613, 24.38215995519749), + Offset(36.28781213058077, 26.70980397355516), + Offset(35.578606869821805, 28.898905662167586), + Offset(34.54362698948966, 30.847720680350488), + Offset(33.273602843435434, 32.48879490339004), + Offset(31.906696867144262, 33.774635198342985), + Offset(30.65272140465978, 34.66773858517241), + Offset(29.961082315337077, 35.069094192361455), + Offset(29.9604615, 35.0694285), + ], + <Offset>[ + Offset(16.49929405246564, 10.095862326802958), + Offset(16.775640403334712, 9.95973345721975), + Offset(18.224992785622923, 9.389742363107004), + Offset(20.328720889883623, 8.93386226879747), + Offset(22.778620718866684, 8.969269843066183), + Offset(25.297739699520662, 9.528599140842907), + Offset(27.717299657048386, 10.514521075258944), + Offset(29.9454300023478, 11.814824250568474), + Offset(31.951871458367474, 13.403721802435447), + Offset(33.656885166177645, 15.26088006677804), + Offset(35.00480968204524, 17.342109413373016), + Offset(35.961785407394686, 19.59801624919109), + Offset(36.501265735674764, 21.96767683093135), + Offset(36.60700421496613, 24.38215995519749), + Offset(36.28781213058077, 26.70980397355516), + Offset(35.578606869821805, 28.898905662167586), + Offset(34.54362698948966, 30.847720680350488), + Offset(33.273602843435434, 32.48879490339004), + Offset(31.906696867144262, 33.774635198342985), + Offset(30.65272140465978, 34.66773858517241), + Offset(29.961082315337077, 35.069094192361455), + Offset(29.9604615, 35.0694285), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.49929405246564, 10.095862326802958), + Offset(16.775640403334712, 9.95973345721975), + Offset(18.224992785622923, 9.389742363107004), + Offset(20.328720889883623, 8.93386226879747), + Offset(22.778620718866684, 8.969269843066183), + Offset(25.297739699520662, 9.528599140842907), + Offset(27.717299657048386, 10.514521075258944), + Offset(29.9454300023478, 11.814824250568474), + Offset(31.951871458367474, 13.403721802435447), + Offset(33.656885166177645, 15.26088006677804), + Offset(35.00480968204524, 17.342109413373016), + Offset(35.961785407394686, 19.59801624919109), + Offset(36.501265735674764, 21.96767683093135), + Offset(36.60700421496613, 24.38215995519749), + Offset(36.28781213058077, 26.70980397355516), + Offset(35.578606869821805, 28.898905662167586), + Offset(34.54362698948966, 30.847720680350488), + Offset(33.273602843435434, 32.48879490339004), + Offset(31.906696867144262, 33.774635198342985), + Offset(30.65272140465978, 34.66773858517241), + Offset(29.961082315337077, 35.069094192361455), + Offset(29.9604615, 35.0694285), + ], + <Offset>[ + Offset(5.799352245846278, 10.104129005957429), + Offset(6.083255897242955, 9.759495335989797), + Offset(7.658093713134462, 8.14218687359558), + Offset(10.165475739587867, 6.285406503343122), + Offset(13.423155259780767, 4.871234042891707), + Offset(17.08471065032811, 4.107937576261792), + Offset(20.869128628502743, 3.9675830929279154), + Offset(24.588123822248015, 4.356713228708747), + Offset(28.186343184983674, 5.2593054564845705), + Offset(31.546318057524346, 6.696047649674224), + Offset(34.56816244113542, 8.641753451309853), + Offset(37.177689494149135, 11.051360444062961), + Offset(39.30349779768536, 13.860212759956465), + Offset(40.88301838571523, 16.98693357408844), + Offset(41.849378028300144, 20.252330125726544), + Offset(42.21787392013975, 23.56727494324509), + Offset(42.012698446874246, 26.759046088565526), + Offset(41.313490625459224, 29.684424598760206), + Offset(40.274455383129066, 32.198276809146904), + Offset(39.15041183768086, 34.12594004552239), + Offset(38.476027301945265, 35.068616634891185), + Offset(38.4754065, 35.0694285), + ], + <Offset>[ + Offset(5.799352245846278, 10.104129005957429), + Offset(6.083255897242955, 9.759495335989797), + Offset(7.658093713134462, 8.14218687359558), + Offset(10.165475739587867, 6.285406503343122), + Offset(13.423155259780767, 4.871234042891707), + Offset(17.08471065032811, 4.107937576261792), + Offset(20.869128628502743, 3.9675830929279154), + Offset(24.588123822248015, 4.356713228708747), + Offset(28.186343184983674, 5.2593054564845705), + Offset(31.546318057524346, 6.696047649674224), + Offset(34.56816244113542, 8.641753451309853), + Offset(37.177689494149135, 11.051360444062961), + Offset(39.30349779768536, 13.860212759956465), + Offset(40.88301838571523, 16.98693357408844), + Offset(41.849378028300144, 20.252330125726544), + Offset(42.21787392013975, 23.56727494324509), + Offset(42.012698446874246, 26.759046088565526), + Offset(41.313490625459224, 29.684424598760206), + Offset(40.274455383129066, 32.198276809146904), + Offset(39.15041183768086, 34.12594004552239), + Offset(38.476027301945265, 35.068616634891185), + Offset(38.4754065, 35.0694285), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(5.799352245846278, 10.104129005957429), + Offset(6.083255897242955, 9.759495335989797), + Offset(7.658093713134462, 8.14218687359558), + Offset(10.165475739587867, 6.285406503343122), + Offset(13.423155259780767, 4.871234042891707), + Offset(17.08471065032811, 4.107937576261792), + Offset(20.869128628502743, 3.9675830929279154), + Offset(24.588123822248015, 4.356713228708747), + Offset(28.186343184983674, 5.2593054564845705), + Offset(31.546318057524346, 6.696047649674224), + Offset(34.56816244113542, 8.641753451309853), + Offset(37.177689494149135, 11.051360444062961), + Offset(39.30349779768536, 13.860212759956465), + Offset(40.88301838571523, 16.98693357408844), + Offset(41.849378028300144, 20.252330125726544), + Offset(42.21787392013975, 23.56727494324509), + Offset(42.012698446874246, 26.759046088565526), + Offset(41.313490625459224, 29.684424598760206), + Offset(40.274455383129066, 32.198276809146904), + Offset(39.15041183768086, 34.12594004552239), + Offset(38.476027301945265, 35.068616634891185), + Offset(38.4754065, 35.0694285), + ], + <Offset>[ + Offset(5.809272260831642, 22.944059173900662), + Offset(5.842970151767011, 22.590356743299907), + Offset(6.161027125720756, 20.822465760581736), + Offset(6.987328821042649, 18.48130068369803), + Offset(8.505512299571395, 16.097792593794804), + Offset(10.579916772830773, 13.963572435292853), + Offset(13.012803049705509, 12.18538832718269), + Offset(15.638390596016343, 10.785480644828485), + Offset(18.413043569842625, 9.77793938454513), + Offset(21.26851915699977, 9.228728180058184), + Offset(24.12773528665962, 9.165730140401642), + Offset(26.92170252799538, 9.592275539957622), + Offset(29.5745409125155, 10.497534285543754), + Offset(32.008746728384374, 11.855716569189521), + Offset(34.100409410905804, 13.578451048463291), + Offset(35.819917057432754, 15.600154482863552), + Offset(37.10628893673229, 17.796160339704027), + Offset(37.94824625990342, 20.03655926033166), + Offset(38.38282531609377, 22.156966589965144), + Offset(38.50025359010085, 23.928711525897086), + Offset(38.47545423298094, 24.850682650961364), + Offset(38.4754065, 24.8514945), + ], + <Offset>[ + Offset(5.809272260831642, 22.944059173900662), + Offset(5.842970151767011, 22.590356743299907), + Offset(6.161027125720756, 20.822465760581736), + Offset(6.987328821042649, 18.48130068369803), + Offset(8.505512299571395, 16.097792593794804), + Offset(10.579916772830773, 13.963572435292853), + Offset(13.012803049705509, 12.18538832718269), + Offset(15.638390596016343, 10.785480644828485), + Offset(18.413043569842625, 9.77793938454513), + Offset(21.26851915699977, 9.228728180058184), + Offset(24.12773528665962, 9.165730140401642), + Offset(26.92170252799538, 9.592275539957622), + Offset(29.5745409125155, 10.497534285543754), + Offset(32.008746728384374, 11.855716569189521), + Offset(34.100409410905804, 13.578451048463291), + Offset(35.819917057432754, 15.600154482863552), + Offset(37.10628893673229, 17.796160339704027), + Offset(37.94824625990342, 20.03655926033166), + Offset(38.38282531609377, 22.156966589965144), + Offset(38.50025359010085, 23.928711525897086), + Offset(38.47545423298094, 24.850682650961364), + Offset(38.4754065, 24.8514945), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.390243902439, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(18.649202428774874, 22.9341391589153), + Offset(18.67383155907712, 22.83064248877585), + Offset(18.841306012706912, 22.31953234799544), + Offset(19.183223001397558, 21.65944760224325), + Offset(19.732070850474493, 21.015435554004174), + Offset(20.435551631861834, 20.468366312790188), + Offset(21.23060828396028, 20.041713905979922), + Offset(22.06715801213608, 19.735213871060157), + Offset(22.931677497903188, 19.55123899968618), + Offset(23.801199687383725, 19.506527080582764), + Offset(24.651711975751407, 19.606157294877438), + Offset(25.46261762389004, 19.84826250611138), + Offset(26.211862438102788, 20.22649117071362), + Offset(26.877529723485452, 20.729988226520383), + Offset(27.426530333642553, 21.327419665857633), + Offset(27.852796597051224, 21.998111345570546), + Offset(28.14340318787079, 22.70256984984598), + Offset(28.300380921474876, 23.40180362588746), + Offset(28.341515096912012, 24.048596657000438), + Offset(28.30302507047554, 24.578869773477106), + Offset(28.25752024905112, 24.851255719925685), + Offset(28.2574725, 24.8514945), + ]), + _PathCubicTo( + <Offset>[ + Offset(18.649202428774874, 22.9341391589153), + Offset(18.67383155907712, 22.83064248877585), + Offset(18.841306012706912, 22.31953234799544), + Offset(19.183223001397558, 21.65944760224325), + Offset(19.732070850474493, 21.015435554004174), + Offset(20.435551631861834, 20.468366312790188), + Offset(21.23060828396028, 20.041713905979922), + Offset(22.06715801213608, 19.735213871060157), + Offset(22.931677497903188, 19.55123899968618), + Offset(23.801199687383725, 19.506527080582764), + Offset(24.651711975751407, 19.606157294877438), + Offset(25.46261762389004, 19.84826250611138), + Offset(26.211862438102788, 20.22649117071362), + Offset(26.877529723485452, 20.729988226520383), + Offset(27.426530333642553, 21.327419665857633), + Offset(27.852796597051224, 21.998111345570546), + Offset(28.14340318787079, 22.70256984984598), + Offset(28.300380921474876, 23.40180362588746), + Offset(28.341515096912012, 24.048596657000438), + Offset(28.30302507047554, 24.578869773477106), + Offset(28.25752024905112, 24.851255719925685), + Offset(28.2574725, 24.8514945), + ], + <Offset>[ + Offset(29.349144235394235, 22.92587247976083), + Offset(29.366216065168874, 23.030880610005802), + Offset(29.408205085195373, 23.567087837506865), + Offset(29.346468151693315, 24.307903367697598), + Offset(29.087536309560406, 25.11347135417865), + Offset(28.648580681054387, 25.889027877371305), + Offset(28.078779312505926, 26.58865188831095), + Offset(27.42446419223586, 27.193324892919883), + Offset(26.69720577128699, 27.695655345637057), + Offset(25.91176679603703, 28.07135949768658), + Offset(25.08835921666123, 28.3065132569406), + Offset(24.24671353713559, 28.394918311239508), + Offset(23.409630376092196, 28.333955241688503), + Offset(22.601515552736352, 28.125214607629434), + Offset(21.864964435923177, 27.78489351368625), + Offset(21.213529546733277, 27.329742064493043), + Offset(20.6743317304862, 26.79124444163094), + Offset(20.26049313945109, 26.206173930517295), + Offset(19.973756580927212, 25.62495504619652), + Offset(19.805334637454454, 25.120668313127123), + Offset(19.742575262442934, 24.85173327739595), + Offset(19.7425275, 24.8514945), + ], + <Offset>[ + Offset(29.349144235394235, 22.92587247976083), + Offset(29.366216065168874, 23.030880610005802), + Offset(29.408205085195373, 23.567087837506865), + Offset(29.346468151693315, 24.307903367697598), + Offset(29.087536309560406, 25.11347135417865), + Offset(28.648580681054387, 25.889027877371305), + Offset(28.078779312505926, 26.58865188831095), + Offset(27.42446419223586, 27.193324892919883), + Offset(26.69720577128699, 27.695655345637057), + Offset(25.91176679603703, 28.07135949768658), + Offset(25.08835921666123, 28.3065132569406), + Offset(24.24671353713559, 28.394918311239508), + Offset(23.409630376092196, 28.333955241688503), + Offset(22.601515552736352, 28.125214607629434), + Offset(21.864964435923177, 27.78489351368625), + Offset(21.213529546733277, 27.329742064493043), + Offset(20.6743317304862, 26.79124444163094), + Offset(20.26049313945109, 26.206173930517295), + Offset(19.973756580927212, 25.62495504619652), + Offset(19.805334637454454, 25.120668313127123), + Offset(19.742575262442934, 24.85173327739595), + Offset(19.7425275, 24.8514945), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.349144235394235, 22.92587247976083), + Offset(29.366216065168874, 23.030880610005802), + Offset(29.408205085195373, 23.567087837506865), + Offset(29.346468151693315, 24.307903367697598), + Offset(29.087536309560406, 25.11347135417865), + Offset(28.648580681054387, 25.889027877371305), + Offset(28.078779312505926, 26.58865188831095), + Offset(27.42446419223586, 27.193324892919883), + Offset(26.69720577128699, 27.695655345637057), + Offset(25.91176679603703, 28.07135949768658), + Offset(25.08835921666123, 28.3065132569406), + Offset(24.24671353713559, 28.394918311239508), + Offset(23.409630376092196, 28.333955241688503), + Offset(22.601515552736352, 28.125214607629434), + Offset(21.864964435923177, 27.78489351368625), + Offset(21.213529546733277, 27.329742064493043), + Offset(20.6743317304862, 26.79124444163094), + Offset(20.26049313945109, 26.206173930517295), + Offset(19.973756580927212, 25.62495504619652), + Offset(19.805334637454454, 25.120668313127123), + Offset(19.742575262442934, 24.85173327739595), + Offset(19.7425275, 24.8514945), + ], + <Offset>[ + Offset(29.33922422040887, 10.085942311817597), + Offset(29.60650181064482, 10.200019202695694), + Offset(30.90527167260908, 10.88680895052071), + Offset(32.52461507023853, 12.11200918734269), + Offset(34.005179269769776, 13.886912803275553), + Offset(35.153374558551725, 16.03339301834024), + Offset(35.93510489130316, 18.370846654056177), + Offset(36.37419741846753, 20.764557476800146), + Offset(36.47050538642804, 23.177021417576494), + Offset(36.1895656965616, 25.538678967302616), + Offset(35.52878637113702, 27.782536567848812), + Offset(34.50270050328935, 29.854003215344843), + Offset(33.13858726126206, 31.696633716101214), + Offset(31.475787210067217, 33.25643161252835), + Offset(29.613933053317517, 34.4587725909495), + Offset(27.611486409440268, 35.29686252487458), + Offset(25.580741240628157, 35.75413019049244), + Offset(23.62573750500689, 35.85403926894584), + Offset(21.865386647962502, 35.66626526537828), + Offset(20.455492885034474, 35.317896832752425), + Offset(19.743148331407255, 35.069667261325776), + Offset(19.7425275, 35.0694285), + ], + <Offset>[ + Offset(29.33922422040887, 10.085942311817597), + Offset(29.60650181064482, 10.200019202695694), + Offset(30.90527167260908, 10.88680895052071), + Offset(32.52461507023853, 12.11200918734269), + Offset(34.005179269769776, 13.886912803275553), + Offset(35.153374558551725, 16.03339301834024), + Offset(35.93510489130316, 18.370846654056177), + Offset(36.37419741846753, 20.764557476800146), + Offset(36.47050538642804, 23.177021417576494), + Offset(36.1895656965616, 25.538678967302616), + Offset(35.52878637113702, 27.782536567848812), + Offset(34.50270050328935, 29.854003215344843), + Offset(33.13858726126206, 31.696633716101214), + Offset(31.475787210067217, 33.25643161252835), + Offset(29.613933053317517, 34.4587725909495), + Offset(27.611486409440268, 35.29686252487458), + Offset(25.580741240628157, 35.75413019049244), + Offset(23.62573750500689, 35.85403926894584), + Offset(21.865386647962502, 35.66626526537828), + Offset(20.455492885034474, 35.317896832752425), + Offset(19.743148331407255, 35.069667261325776), + Offset(19.7425275, 35.0694285), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.33922422040887, 10.085942311817597), + Offset(29.60650181064482, 10.200019202695694), + Offset(30.90527167260908, 10.88680895052071), + Offset(32.52461507023853, 12.11200918734269), + Offset(34.005179269769776, 13.886912803275553), + Offset(35.153374558551725, 16.03339301834024), + Offset(35.93510489130316, 18.370846654056177), + Offset(36.37419741846753, 20.764557476800146), + Offset(36.47050538642804, 23.177021417576494), + Offset(36.1895656965616, 25.538678967302616), + Offset(35.52878637113702, 27.782536567848812), + Offset(34.50270050328935, 29.854003215344843), + Offset(33.13858726126206, 31.696633716101214), + Offset(31.475787210067217, 33.25643161252835), + Offset(29.613933053317517, 34.4587725909495), + Offset(27.611486409440268, 35.29686252487458), + Offset(25.580741240628157, 35.75413019049244), + Offset(23.62573750500689, 35.85403926894584), + Offset(21.865386647962502, 35.66626526537828), + Offset(20.455492885034474, 35.317896832752425), + Offset(19.743148331407255, 35.069667261325776), + Offset(19.7425275, 35.0694285), + ], + <Offset>[ + Offset(18.63928241378951, 10.094208990972067), + Offset(18.914117304553063, 9.999781081465741), + Offset(20.338372600120618, 9.639253461009286), + Offset(22.361369919942774, 9.463553421888342), + Offset(24.649713810683863, 9.788877003101078), + Offset(26.940345509359172, 10.612731453759126), + Offset(29.086933862757515, 11.823908671725148), + Offset(31.016891238367755, 13.306446454940419), + Offset(32.70497711304424, 15.03260507162562), + Offset(34.0789985879083, 16.9738465501988), + Offset(35.0921391302272, 19.08218060578565), + Offset(35.718604590043796, 21.307347410216714), + Offset(35.94081932327265, 23.58916964512633), + Offset(35.75180138081632, 25.8612052314193), + Offset(35.17549895103689, 28.001298743120884), + Offset(34.250753459758215, 29.965231805952083), + Offset(33.049812698012744, 31.665455598707478), + Offset(31.665625287030675, 33.049668964316005), + Offset(30.233145163947302, 34.0899068761822), + Offset(28.95318331805556, 34.776098293102415), + Offset(28.25809331801544, 35.06918970385551), + Offset(28.2574725, 35.0694285), + ], + <Offset>[ + Offset(18.63928241378951, 10.094208990972067), + Offset(18.914117304553063, 9.999781081465741), + Offset(20.338372600120618, 9.639253461009286), + Offset(22.361369919942774, 9.463553421888342), + Offset(24.649713810683863, 9.788877003101078), + Offset(26.940345509359172, 10.612731453759126), + Offset(29.086933862757515, 11.823908671725148), + Offset(31.016891238367755, 13.306446454940419), + Offset(32.70497711304424, 15.03260507162562), + Offset(34.0789985879083, 16.9738465501988), + Offset(35.0921391302272, 19.08218060578565), + Offset(35.718604590043796, 21.307347410216714), + Offset(35.94081932327265, 23.58916964512633), + Offset(35.75180138081632, 25.8612052314193), + Offset(35.17549895103689, 28.001298743120884), + Offset(34.250753459758215, 29.965231805952083), + Offset(33.049812698012744, 31.665455598707478), + Offset(31.665625287030675, 33.049668964316005), + Offset(30.233145163947302, 34.0899068761822), + Offset(28.95318331805556, 34.776098293102415), + Offset(28.25809331801544, 35.06918970385551), + Offset(28.2574725, 35.0694285), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(18.63928241378951, 10.094208990972067), + Offset(18.914117304553063, 9.999781081465741), + Offset(20.338372600120618, 9.639253461009286), + Offset(22.361369919942774, 9.463553421888342), + Offset(24.649713810683863, 9.788877003101078), + Offset(26.940345509359172, 10.612731453759126), + Offset(29.086933862757515, 11.823908671725148), + Offset(31.016891238367755, 13.306446454940419), + Offset(32.70497711304424, 15.03260507162562), + Offset(34.0789985879083, 16.9738465501988), + Offset(35.0921391302272, 19.08218060578565), + Offset(35.718604590043796, 21.307347410216714), + Offset(35.94081932327265, 23.58916964512633), + Offset(35.75180138081632, 25.8612052314193), + Offset(35.17549895103689, 28.001298743120884), + Offset(34.250753459758215, 29.965231805952083), + Offset(33.049812698012744, 31.665455598707478), + Offset(31.665625287030675, 33.049668964316005), + Offset(30.233145163947302, 34.0899068761822), + Offset(28.95318331805556, 34.776098293102415), + Offset(28.25809331801544, 35.06918970385551), + Offset(28.2574725, 35.0694285), + ], + <Offset>[ + Offset(18.649202428774874, 22.9341391589153), + Offset(18.67383155907712, 22.83064248877585), + Offset(18.841306012706912, 22.31953234799544), + Offset(19.183223001397558, 21.65944760224325), + Offset(19.732070850474493, 21.015435554004174), + Offset(20.435551631861834, 20.468366312790188), + Offset(21.23060828396028, 20.041713905979922), + Offset(22.06715801213608, 19.735213871060157), + Offset(22.931677497903188, 19.55123899968618), + Offset(23.801199687383725, 19.506527080582764), + Offset(24.651711975751407, 19.606157294877438), + Offset(25.46261762389004, 19.84826250611138), + Offset(26.211862438102788, 20.22649117071362), + Offset(26.877529723485452, 20.729988226520383), + Offset(27.426530333642553, 21.327419665857633), + Offset(27.852796597051224, 21.998111345570546), + Offset(28.14340318787079, 22.70256984984598), + Offset(28.300380921474876, 23.40180362588746), + Offset(28.341515096912012, 24.048596657000438), + Offset(28.30302507047554, 24.578869773477106), + Offset(28.25752024905112, 24.851255719925685), + Offset(28.2574725, 24.8514945), + ], + <Offset>[ + Offset(18.649202428774874, 22.9341391589153), + Offset(18.67383155907712, 22.83064248877585), + Offset(18.841306012706912, 22.31953234799544), + Offset(19.183223001397558, 21.65944760224325), + Offset(19.732070850474493, 21.015435554004174), + Offset(20.435551631861834, 20.468366312790188), + Offset(21.23060828396028, 20.041713905979922), + Offset(22.06715801213608, 19.735213871060157), + Offset(22.931677497903188, 19.55123899968618), + Offset(23.801199687383725, 19.506527080582764), + Offset(24.651711975751407, 19.606157294877438), + Offset(25.46261762389004, 19.84826250611138), + Offset(26.211862438102788, 20.22649117071362), + Offset(26.877529723485452, 20.729988226520383), + Offset(27.426530333642553, 21.327419665857633), + Offset(27.852796597051224, 21.998111345570546), + Offset(28.14340318787079, 22.70256984984598), + Offset(28.300380921474876, 23.40180362588746), + Offset(28.341515096912012, 24.048596657000438), + Offset(28.30302507047554, 24.578869773477106), + Offset(28.25752024905112, 24.851255719925685), + Offset(28.2574725, 24.8514945), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.390243902439, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(31.489132596718107, 22.924219143929935), + Offset(31.504692966387218, 23.070928234251795), + Offset(31.52158489969306, 23.816598935409147), + Offset(31.379117181752463, 24.837594520788468), + Offset(30.958629401377593, 25.933078514213545), + Offset(30.291186490892898, 26.97316019028753), + Offset(29.448413518215055, 27.898039484777154), + Offset(28.495925428255816, 28.68494709729183), + Offset(27.450311425963747, 29.324538614827237), + Offset(26.33388021776769, 29.784325981107337), + Offset(25.175688664843197, 30.046584449353226), + Offset(24.0035327197847, 30.10424947226514), + Offset(22.849183963690074, 29.955448055883483), + Offset(21.746312718586534, 29.604259883851242), + Offset(20.7526512563793, 29.076388283251973), + Offset(19.885676136669687, 28.39606820827754), + Offset(19.180517439009286, 27.608979359987934), + Offset(18.65251558304633, 26.76704799144326), + Offset(18.300204877730252, 25.940226724035732), + Offset(18.105796550850236, 25.229028021057122), + Offset(18.039586265121297, 24.851828788890007), + Offset(18.0395385, 24.851494500000005), + ]), + _PathCubicTo( + <Offset>[ + Offset(31.489132596718107, 22.924219143929935), + Offset(31.504692966387218, 23.070928234251795), + Offset(31.52158489969306, 23.816598935409147), + Offset(31.379117181752463, 24.837594520788468), + Offset(30.958629401377593, 25.933078514213545), + Offset(30.291186490892898, 26.97316019028753), + Offset(29.448413518215055, 27.898039484777154), + Offset(28.495925428255816, 28.68494709729183), + Offset(27.450311425963747, 29.324538614827237), + Offset(26.33388021776769, 29.784325981107337), + Offset(25.175688664843197, 30.046584449353226), + Offset(24.0035327197847, 30.10424947226514), + Offset(22.849183963690074, 29.955448055883483), + Offset(21.746312718586534, 29.604259883851242), + Offset(20.7526512563793, 29.076388283251973), + Offset(19.885676136669687, 28.39606820827754), + Offset(19.180517439009286, 27.608979359987934), + Offset(18.65251558304633, 26.76704799144326), + Offset(18.300204877730252, 25.940226724035732), + Offset(18.105796550850236, 25.229028021057122), + Offset(18.039586265121297, 24.851828788890007), + Offset(18.0395385, 24.851494500000005), + ], + <Offset>[ + Offset(42.189074403337465, 22.915952464775465), + Offset(42.19707747247897, 23.271166355481746), + Offset(42.08848397218152, 25.06415442492057), + Offset(41.54236233204822, 27.486050286242815), + Offset(40.314094860463506, 30.03111431438802), + Offset(38.504215540085454, 32.39382175486865), + Offset(36.2965845467607, 34.44497746710818), + Offset(33.8532316083556, 36.143058119151554), + Offset(31.215839699347548, 37.46895496077811), + Offset(28.444447326420992, 38.34915839821115), + Offset(25.61233590575302, 38.74694041141639), + Offset(22.787628633030252, 38.650905277393264), + Offset(20.046951901679478, 38.062912126858365), + Offset(17.470298547837437, 36.99948626496029), + Offset(15.191085358659922, 35.53386213108059), + Offset(13.246409086351742, 33.72769892720004), + Offset(11.711445981624701, 31.697653951772896), + Offset(10.612627801022544, 29.571418296073094), + Offset(9.932446361745452, 27.516585113231805), + Offset(9.608106117829148, 25.77082656070714), + Offset(9.524641278513112, 24.852306346360272), + Offset(9.5245935, 24.851494500000005), + ], + <Offset>[ + Offset(42.189074403337465, 22.915952464775465), + Offset(42.19707747247897, 23.271166355481746), + Offset(42.08848397218152, 25.06415442492057), + Offset(41.54236233204822, 27.486050286242815), + Offset(40.314094860463506, 30.03111431438802), + Offset(38.504215540085454, 32.39382175486865), + Offset(36.2965845467607, 34.44497746710818), + Offset(33.8532316083556, 36.143058119151554), + Offset(31.215839699347548, 37.46895496077811), + Offset(28.444447326420992, 38.34915839821115), + Offset(25.61233590575302, 38.74694041141639), + Offset(22.787628633030252, 38.650905277393264), + Offset(20.046951901679478, 38.062912126858365), + Offset(17.470298547837437, 36.99948626496029), + Offset(15.191085358659922, 35.53386213108059), + Offset(13.246409086351742, 33.72769892720004), + Offset(11.711445981624701, 31.697653951772896), + Offset(10.612627801022544, 29.571418296073094), + Offset(9.932446361745452, 27.516585113231805), + Offset(9.608106117829148, 25.77082656070714), + Offset(9.524641278513112, 24.852306346360272), + Offset(9.5245935, 24.851494500000005), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.189074403337465, 22.915952464775465), + Offset(42.19707747247897, 23.271166355481746), + Offset(42.08848397218152, 25.06415442492057), + Offset(41.54236233204822, 27.486050286242815), + Offset(40.314094860463506, 30.03111431438802), + Offset(38.504215540085454, 32.39382175486865), + Offset(36.2965845467607, 34.44497746710818), + Offset(33.8532316083556, 36.143058119151554), + Offset(31.215839699347548, 37.46895496077811), + Offset(28.444447326420992, 38.34915839821115), + Offset(25.61233590575302, 38.74694041141639), + Offset(22.787628633030252, 38.650905277393264), + Offset(20.046951901679478, 38.062912126858365), + Offset(17.470298547837437, 36.99948626496029), + Offset(15.191085358659922, 35.53386213108059), + Offset(13.246409086351742, 33.72769892720004), + Offset(11.711445981624701, 31.697653951772896), + Offset(10.612627801022544, 29.571418296073094), + Offset(9.932446361745452, 27.516585113231805), + Offset(9.608106117829148, 25.77082656070714), + Offset(9.524641278513112, 24.852306346360272), + Offset(9.5245935, 24.851494500000005), + ], + <Offset>[ + Offset(42.1791543883521, 10.076022296832232), + Offset(42.43736321795492, 10.440304948171638), + Offset(43.58555055959523, 12.383875537934415), + Offset(44.72050925059344, 15.290156105887906), + Offset(45.231737820672876, 18.804555763484924), + Offset(45.00900941758279, 22.538186895837583), + Offset(44.15291012555793, 26.22717223285341), + Offset(42.80296483458727, 29.714290703031818), + Offset(40.9891393144886, 32.950321032717554), + Offset(38.72224622694557, 35.816477867827196), + Offset(36.05276306022881, 38.2229637223246), + Offset(33.04361559918401, 40.1099901814986), + Offset(29.775908786849342, 41.42559060127108), + Offset(26.3445702051683, 42.13070326985921), + Offset(22.940053976054262, 42.20774120834385), + Offset(19.644365949058738, 41.694819387581575), + Offset(16.617855491766655, 40.660539700634395), + Offset(13.977872166578344, 39.219283634501636), + Offset(11.824076428780742, 37.557895332413565), + Offset(10.258264365409168, 35.96805508033244), + Offset(9.525214347477434, 35.0702403302901), + Offset(9.524593500000002, 35.06942850000001), + ], + <Offset>[ + Offset(42.1791543883521, 10.076022296832232), + Offset(42.43736321795492, 10.440304948171638), + Offset(43.58555055959523, 12.383875537934415), + Offset(44.72050925059344, 15.290156105887906), + Offset(45.231737820672876, 18.804555763484924), + Offset(45.00900941758279, 22.538186895837583), + Offset(44.15291012555793, 26.22717223285341), + Offset(42.80296483458727, 29.714290703031818), + Offset(40.9891393144886, 32.950321032717554), + Offset(38.72224622694557, 35.816477867827196), + Offset(36.05276306022881, 38.2229637223246), + Offset(33.04361559918401, 40.1099901814986), + Offset(29.775908786849342, 41.42559060127108), + Offset(26.3445702051683, 42.13070326985921), + Offset(22.940053976054262, 42.20774120834385), + Offset(19.644365949058738, 41.694819387581575), + Offset(16.617855491766655, 40.660539700634395), + Offset(13.977872166578344, 39.219283634501636), + Offset(11.824076428780742, 37.557895332413565), + Offset(10.258264365409168, 35.96805508033244), + Offset(9.525214347477434, 35.0702403302901), + Offset(9.524593500000002, 35.06942850000001), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(42.1791543883521, 10.076022296832232), + Offset(42.43736321795492, 10.440304948171638), + Offset(43.58555055959523, 12.383875537934415), + Offset(44.72050925059344, 15.290156105887906), + Offset(45.231737820672876, 18.804555763484924), + Offset(45.00900941758279, 22.538186895837583), + Offset(44.15291012555793, 26.22717223285341), + Offset(42.80296483458727, 29.714290703031818), + Offset(40.9891393144886, 32.950321032717554), + Offset(38.72224622694557, 35.816477867827196), + Offset(36.05276306022881, 38.2229637223246), + Offset(33.04361559918401, 40.1099901814986), + Offset(29.775908786849342, 41.42559060127108), + Offset(26.3445702051683, 42.13070326985921), + Offset(22.940053976054262, 42.20774120834385), + Offset(19.644365949058738, 41.694819387581575), + Offset(16.617855491766655, 40.660539700634395), + Offset(13.977872166578344, 39.219283634501636), + Offset(11.824076428780742, 37.557895332413565), + Offset(10.258264365409168, 35.96805508033244), + Offset(9.525214347477434, 35.0702403302901), + Offset(9.524593500000002, 35.06942850000001), + ], + <Offset>[ + Offset(31.479212581732742, 10.084288975986702), + Offset(31.744978711863162, 10.240066826941685), + Offset(33.018651487106766, 11.136320048422991), + Offset(34.55726410029768, 12.641700340433559), + Offset(35.87627236158696, 14.706519963310448), + Offset(36.79598036839023, 17.117525331256466), + Offset(37.30473909701229, 19.680234250522382), + Offset(37.44565865448749, 22.256179681172092), + Offset(37.223611041104796, 24.805904686766674), + Offset(36.611679118292265, 27.25164545072338), + Offset(35.61611581931899, 29.522607760261437), + Offset(34.25951968593846, 31.563334376370474), + Offset(32.57814084885994, 33.3181265302962), + Offset(30.620584375917396, 34.73547688875016), + Offset(28.501619873773638, 35.75026736051523), + Offset(26.283632999376685, 36.36318866865908), + Offset(24.08692694915124, 36.57186510884944), + Offset(22.01775994860213, 36.41491332987181), + Offset(20.191834944765542, 35.98153694321749), + Offset(18.755954798430256, 35.42625654068243), + Offset(18.04015933408562, 35.06976277281983), + Offset(18.0395385, 35.0694285), + ], + <Offset>[ + Offset(31.479212581732742, 10.084288975986702), + Offset(31.744978711863162, 10.240066826941685), + Offset(33.018651487106766, 11.136320048422991), + Offset(34.55726410029768, 12.641700340433559), + Offset(35.87627236158696, 14.706519963310448), + Offset(36.79598036839023, 17.117525331256466), + Offset(37.30473909701229, 19.680234250522382), + Offset(37.44565865448749, 22.256179681172092), + Offset(37.223611041104796, 24.805904686766674), + Offset(36.611679118292265, 27.25164545072338), + Offset(35.61611581931899, 29.522607760261437), + Offset(34.25951968593846, 31.563334376370474), + Offset(32.57814084885994, 33.3181265302962), + Offset(30.620584375917396, 34.73547688875016), + Offset(28.501619873773638, 35.75026736051523), + Offset(26.283632999376685, 36.36318866865908), + Offset(24.08692694915124, 36.57186510884944), + Offset(22.01775994860213, 36.41491332987181), + Offset(20.191834944765542, 35.98153694321749), + Offset(18.755954798430256, 35.42625654068243), + Offset(18.04015933408562, 35.06976277281983), + Offset(18.0395385, 35.0694285), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(31.479212581732742, 10.084288975986702), + Offset(31.744978711863162, 10.240066826941685), + Offset(33.018651487106766, 11.136320048422991), + Offset(34.55726410029768, 12.641700340433559), + Offset(35.87627236158696, 14.706519963310448), + Offset(36.79598036839023, 17.117525331256466), + Offset(37.30473909701229, 19.680234250522382), + Offset(37.44565865448749, 22.256179681172092), + Offset(37.223611041104796, 24.805904686766674), + Offset(36.611679118292265, 27.25164545072338), + Offset(35.61611581931899, 29.522607760261437), + Offset(34.25951968593846, 31.563334376370474), + Offset(32.57814084885994, 33.3181265302962), + Offset(30.620584375917396, 34.73547688875016), + Offset(28.501619873773638, 35.75026736051523), + Offset(26.283632999376685, 36.36318866865908), + Offset(24.08692694915124, 36.57186510884944), + Offset(22.01775994860213, 36.41491332987181), + Offset(20.191834944765542, 35.98153694321749), + Offset(18.755954798430256, 35.42625654068243), + Offset(18.04015933408562, 35.06976277281983), + Offset(18.0395385, 35.0694285), + ], + <Offset>[ + Offset(31.489132596718107, 22.924219143929935), + Offset(31.504692966387218, 23.070928234251795), + Offset(31.52158489969306, 23.816598935409147), + Offset(31.379117181752463, 24.837594520788468), + Offset(30.958629401377593, 25.933078514213545), + Offset(30.291186490892898, 26.97316019028753), + Offset(29.448413518215055, 27.898039484777154), + Offset(28.495925428255816, 28.68494709729183), + Offset(27.450311425963747, 29.324538614827237), + Offset(26.33388021776769, 29.784325981107337), + Offset(25.175688664843197, 30.046584449353226), + Offset(24.0035327197847, 30.10424947226514), + Offset(22.849183963690074, 29.955448055883483), + Offset(21.746312718586534, 29.604259883851242), + Offset(20.7526512563793, 29.076388283251973), + Offset(19.885676136669687, 28.39606820827754), + Offset(19.180517439009286, 27.608979359987934), + Offset(18.65251558304633, 26.76704799144326), + Offset(18.300204877730252, 25.940226724035732), + Offset(18.105796550850236, 25.229028021057122), + Offset(18.039586265121297, 24.851828788890007), + Offset(18.0395385, 24.851494500000005), + ], + <Offset>[ + Offset(31.489132596718107, 22.924219143929935), + Offset(31.504692966387218, 23.070928234251795), + Offset(31.52158489969306, 23.816598935409147), + Offset(31.379117181752463, 24.837594520788468), + Offset(30.958629401377593, 25.933078514213545), + Offset(30.291186490892898, 26.97316019028753), + Offset(29.448413518215055, 27.898039484777154), + Offset(28.495925428255816, 28.68494709729183), + Offset(27.450311425963747, 29.324538614827237), + Offset(26.33388021776769, 29.784325981107337), + Offset(25.175688664843197, 30.046584449353226), + Offset(24.0035327197847, 30.10424947226514), + Offset(22.849183963690074, 29.955448055883483), + Offset(21.746312718586534, 29.604259883851242), + Offset(20.7526512563793, 29.076388283251973), + Offset(19.885676136669687, 28.39606820827754), + Offset(19.180517439009286, 27.608979359987934), + Offset(18.65251558304633, 26.76704799144326), + Offset(18.300204877730252, 25.940226724035732), + Offset(18.105796550850236, 25.229028021057122), + Offset(18.039586265121297, 24.851828788890007), + Offset(18.0395385, 24.851494500000005), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.390243902439, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(5.820845611647898, 37.923977703167765), + Offset(5.562636782045075, 37.55969505182836), + Offset(4.414449440404765, 35.616124462065585), + Offset(3.279490749406561, 32.70984389411209), + Offset(2.7682621793271274, 29.195444236515087), + Offset(2.990990582417215, 25.461813104162424), + Offset(3.8470898744420694, 21.77282776714659), + Offset(5.197035165412721, 18.285709296968175), + Offset(7.010860685511399, 15.049678967282448), + Offset(9.277753773054428, 12.183522132172802), + Offset(11.947236939771194, 9.777036277675393), + Offset(14.956384400815995, 7.8900098185013965), + Offset(18.22409121315066, 6.574409398728922), + Offset(21.6554297948317, 5.869296730140784), + Offset(25.05994602394574, 5.792258791656161), + Offset(28.35563405094127, 6.305180612418426), + Offset(31.382144508233342, 7.339460299365608), + Offset(34.022127833421656, 8.780716365498357), + Offset(36.175923571219265, 10.44210466758643), + Offset(37.741735634590825, 12.031944919667563), + Offset(38.47478565252257, 12.929759669709906), + Offset(38.4754065, 12.930571500000003), + ]), + _PathCubicTo( + <Offset>[ + Offset(5.820845611647898, 37.923977703167765), + Offset(5.562636782045075, 37.55969505182836), + Offset(4.414449440404765, 35.616124462065585), + Offset(3.279490749406561, 32.70984389411209), + Offset(2.7682621793271274, 29.195444236515087), + Offset(2.990990582417215, 25.461813104162424), + Offset(3.8470898744420694, 21.77282776714659), + Offset(5.197035165412721, 18.285709296968175), + Offset(7.010860685511399, 15.049678967282448), + Offset(9.277753773054428, 12.183522132172802), + Offset(11.947236939771194, 9.777036277675393), + Offset(14.956384400815995, 7.8900098185013965), + Offset(18.22409121315066, 6.574409398728922), + Offset(21.6554297948317, 5.869296730140784), + Offset(25.05994602394574, 5.792258791656161), + Offset(28.35563405094127, 6.305180612418426), + Offset(31.382144508233342, 7.339460299365608), + Offset(34.022127833421656, 8.780716365498357), + Offset(36.175923571219265, 10.44210466758643), + Offset(37.741735634590825, 12.031944919667563), + Offset(38.47478565252257, 12.929759669709906), + Offset(38.4754065, 12.930571500000003), + ], + <Offset>[ + Offset(16.520787418267258, 37.9157110240133), + Offset(16.25502128813683, 37.759933173058315), + Offset(14.981348512893227, 36.863679951577005), + Offset(13.442735899702317, 35.35829965956644), + Offset(12.123727638413042, 33.29348003668956), + Offset(11.204019631609766, 30.88247466874354), + Offset(10.695260902987714, 28.319765749477618), + Offset(10.554341345512503, 25.7438203188279), + Offset(10.7763889588952, 23.194095313233326), + Offset(11.38832088170773, 20.74835454927662), + Offset(12.383884180681019, 18.477392239738556), + Offset(13.740480314061546, 16.436665623629526), + Offset(15.421859151140067, 14.681873469703806), + Offset(17.379415624082604, 13.264523111249835), + Offset(19.498380126226365, 12.249732639484778), + Offset(21.716367000623322, 11.636811331340922), + Offset(23.91307305084876, 11.42813489115057), + Offset(25.98224005139787, 11.585086670128192), + Offset(27.808165055234465, 12.018463056782506), + Offset(29.244045201569737, 12.57374345931758), + Offset(29.95984066591438, 12.930237227180172), + Offset(29.9604615, 12.930571500000003), + ], + <Offset>[ + Offset(16.520787418267258, 37.9157110240133), + Offset(16.25502128813683, 37.759933173058315), + Offset(14.981348512893227, 36.863679951577005), + Offset(13.442735899702317, 35.35829965956644), + Offset(12.123727638413042, 33.29348003668956), + Offset(11.204019631609766, 30.88247466874354), + Offset(10.695260902987714, 28.319765749477618), + Offset(10.554341345512503, 25.7438203188279), + Offset(10.7763889588952, 23.194095313233326), + Offset(11.38832088170773, 20.74835454927662), + Offset(12.383884180681019, 18.477392239738556), + Offset(13.740480314061546, 16.436665623629526), + Offset(15.421859151140067, 14.681873469703806), + Offset(17.379415624082604, 13.264523111249835), + Offset(19.498380126226365, 12.249732639484778), + Offset(21.716367000623322, 11.636811331340922), + Offset(23.91307305084876, 11.42813489115057), + Offset(25.98224005139787, 11.585086670128192), + Offset(27.808165055234465, 12.018463056782506), + Offset(29.244045201569737, 12.57374345931758), + Offset(29.95984066591438, 12.930237227180172), + Offset(29.9604615, 12.930571500000003), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.520787418267258, 37.9157110240133), + Offset(16.25502128813683, 37.759933173058315), + Offset(14.981348512893227, 36.863679951577005), + Offset(13.442735899702317, 35.35829965956644), + Offset(12.123727638413042, 33.29348003668956), + Offset(11.204019631609766, 30.88247466874354), + Offset(10.695260902987714, 28.319765749477618), + Offset(10.554341345512503, 25.7438203188279), + Offset(10.7763889588952, 23.194095313233326), + Offset(11.38832088170773, 20.74835454927662), + Offset(12.383884180681019, 18.477392239738556), + Offset(13.740480314061546, 16.436665623629526), + Offset(15.421859151140067, 14.681873469703806), + Offset(17.379415624082604, 13.264523111249835), + Offset(19.498380126226365, 12.249732639484778), + Offset(21.716367000623322, 11.636811331340922), + Offset(23.91307305084876, 11.42813489115057), + Offset(25.98224005139787, 11.585086670128192), + Offset(27.808165055234465, 12.018463056782506), + Offset(29.244045201569737, 12.57374345931758), + Offset(29.95984066591438, 12.930237227180172), + Offset(29.9604615, 12.930571500000003), + ], + <Offset>[ + Offset(16.510867403281896, 25.075780856070065), + Offset(16.495307033612775, 24.929071765748205), + Offset(16.478415100306933, 24.183401064590853), + Offset(16.620882818247537, 23.16240547921153), + Offset(17.041370598622414, 22.066921485786466), + Offset(17.708813509107102, 21.026839809712477), + Offset(18.55158648178495, 20.101960515222846), + Offset(19.504074571744177, 19.315052902708164), + Offset(20.54968857403625, 18.675461385172763), + Offset(21.666119782232308, 18.215674018892656), + Offset(22.824311335156814, 17.95341555064677), + Offset(23.996467280215303, 17.89575052773486), + Offset(25.15081603630993, 18.04455194411652), + Offset(26.253687281413466, 18.39574011614875), + Offset(27.247348743620705, 18.923611716748034), + Offset(28.114323863330313, 19.60393179172246), + Offset(28.81948256099071, 20.39102064001207), + Offset(29.34748441695367, 21.232952008556737), + Offset(29.699795122269755, 22.059773275964268), + Offset(29.894203449149757, 22.770971978942885), + Offset(29.960413734878703, 23.148171211109993), + Offset(29.9604615, 23.148505500000002), + ], + <Offset>[ + Offset(16.510867403281896, 25.075780856070065), + Offset(16.495307033612775, 24.929071765748205), + Offset(16.478415100306933, 24.183401064590853), + Offset(16.620882818247537, 23.16240547921153), + Offset(17.041370598622414, 22.066921485786466), + Offset(17.708813509107102, 21.026839809712477), + Offset(18.55158648178495, 20.101960515222846), + Offset(19.504074571744177, 19.315052902708164), + Offset(20.54968857403625, 18.675461385172763), + Offset(21.666119782232308, 18.215674018892656), + Offset(22.824311335156814, 17.95341555064677), + Offset(23.996467280215303, 17.89575052773486), + Offset(25.15081603630993, 18.04455194411652), + Offset(26.253687281413466, 18.39574011614875), + Offset(27.247348743620705, 18.923611716748034), + Offset(28.114323863330313, 19.60393179172246), + Offset(28.81948256099071, 20.39102064001207), + Offset(29.34748441695367, 21.232952008556737), + Offset(29.699795122269755, 22.059773275964268), + Offset(29.894203449149757, 22.770971978942885), + Offset(29.960413734878703, 23.148171211109993), + Offset(29.9604615, 23.148505500000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(16.510867403281896, 25.075780856070065), + Offset(16.495307033612775, 24.929071765748205), + Offset(16.478415100306933, 24.183401064590853), + Offset(16.620882818247537, 23.16240547921153), + Offset(17.041370598622414, 22.066921485786466), + Offset(17.708813509107102, 21.026839809712477), + Offset(18.55158648178495, 20.101960515222846), + Offset(19.504074571744177, 19.315052902708164), + Offset(20.54968857403625, 18.675461385172763), + Offset(21.666119782232308, 18.215674018892656), + Offset(22.824311335156814, 17.95341555064677), + Offset(23.996467280215303, 17.89575052773486), + Offset(25.15081603630993, 18.04455194411652), + Offset(26.253687281413466, 18.39574011614875), + Offset(27.247348743620705, 18.923611716748034), + Offset(28.114323863330313, 19.60393179172246), + Offset(28.81948256099071, 20.39102064001207), + Offset(29.34748441695367, 21.232952008556737), + Offset(29.699795122269755, 22.059773275964268), + Offset(29.894203449149757, 22.770971978942885), + Offset(29.960413734878703, 23.148171211109993), + Offset(29.9604615, 23.148505500000002), + ], + <Offset>[ + Offset(5.810925596662535, 25.084047535224535), + Offset(5.8029225275210194, 24.728833644518254), + Offset(5.911516027818471, 22.93584557507943), + Offset(6.457637667951779, 20.51394971375718), + Offset(7.6859051395365, 17.96888568561199), + Offset(9.495784459914551, 15.606178245131362), + Offset(11.703415453239304, 13.555022532891817), + Offset(14.146768391644395, 11.856941880848437), + Offset(16.78416030065245, 10.531045039221889), + Offset(19.555552673579008, 9.650841601788843), + Offset(22.38766409424699, 9.253059588583607), + Offset(25.21237136696975, 9.349094722606733), + Offset(27.953048098320522, 9.937087873141635), + Offset(30.529701452162563, 11.000513735039702), + Offset(32.80891464134008, 12.466137868919416), + Offset(34.75359091364826, 14.272301072799962), + Offset(36.288554018375294, 16.302346048227108), + Offset(37.38737219897746, 18.428581703926902), + Offset(38.06755363825456, 20.483414886768188), + Offset(38.39189388217084, 22.229173439292868), + Offset(38.475358721486884, 23.147693653639728), + Offset(38.4754065, 23.148505500000002), + ], + <Offset>[ + Offset(5.810925596662535, 25.084047535224535), + Offset(5.8029225275210194, 24.728833644518254), + Offset(5.911516027818471, 22.93584557507943), + Offset(6.457637667951779, 20.51394971375718), + Offset(7.6859051395365, 17.96888568561199), + Offset(9.495784459914551, 15.606178245131362), + Offset(11.703415453239304, 13.555022532891817), + Offset(14.146768391644395, 11.856941880848437), + Offset(16.78416030065245, 10.531045039221889), + Offset(19.555552673579008, 9.650841601788843), + Offset(22.38766409424699, 9.253059588583607), + Offset(25.21237136696975, 9.349094722606733), + Offset(27.953048098320522, 9.937087873141635), + Offset(30.529701452162563, 11.000513735039702), + Offset(32.80891464134008, 12.466137868919416), + Offset(34.75359091364826, 14.272301072799962), + Offset(36.288554018375294, 16.302346048227108), + Offset(37.38737219897746, 18.428581703926902), + Offset(38.06755363825456, 20.483414886768188), + Offset(38.39189388217084, 22.229173439292868), + Offset(38.475358721486884, 23.147693653639728), + Offset(38.4754065, 23.148505500000002), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(5.810925596662535, 25.084047535224535), + Offset(5.8029225275210194, 24.728833644518254), + Offset(5.911516027818471, 22.93584557507943), + Offset(6.457637667951779, 20.51394971375718), + Offset(7.6859051395365, 17.96888568561199), + Offset(9.495784459914551, 15.606178245131362), + Offset(11.703415453239304, 13.555022532891817), + Offset(14.146768391644395, 11.856941880848437), + Offset(16.78416030065245, 10.531045039221889), + Offset(19.555552673579008, 9.650841601788843), + Offset(22.38766409424699, 9.253059588583607), + Offset(25.21237136696975, 9.349094722606733), + Offset(27.953048098320522, 9.937087873141635), + Offset(30.529701452162563, 11.000513735039702), + Offset(32.80891464134008, 12.466137868919416), + Offset(34.75359091364826, 14.272301072799962), + Offset(36.288554018375294, 16.302346048227108), + Offset(37.38737219897746, 18.428581703926902), + Offset(38.06755363825456, 20.483414886768188), + Offset(38.39189388217084, 22.229173439292868), + Offset(38.475358721486884, 23.147693653639728), + Offset(38.4754065, 23.148505500000002), + ], + <Offset>[ + Offset(5.820845611647898, 37.923977703167765), + Offset(5.562636782045075, 37.55969505182836), + Offset(4.414449440404765, 35.616124462065585), + Offset(3.279490749406561, 32.70984389411209), + Offset(2.7682621793271274, 29.195444236515087), + Offset(2.990990582417215, 25.461813104162424), + Offset(3.8470898744420694, 21.77282776714659), + Offset(5.197035165412721, 18.285709296968175), + Offset(7.010860685511399, 15.049678967282448), + Offset(9.277753773054428, 12.183522132172802), + Offset(11.947236939771194, 9.777036277675393), + Offset(14.956384400815995, 7.8900098185013965), + Offset(18.22409121315066, 6.574409398728922), + Offset(21.6554297948317, 5.869296730140784), + Offset(25.05994602394574, 5.792258791656161), + Offset(28.35563405094127, 6.305180612418426), + Offset(31.382144508233342, 7.339460299365608), + Offset(34.022127833421656, 8.780716365498357), + Offset(36.175923571219265, 10.44210466758643), + Offset(37.741735634590825, 12.031944919667563), + Offset(38.47478565252257, 12.929759669709906), + Offset(38.4754065, 12.930571500000003), + ], + <Offset>[ + Offset(5.820845611647898, 37.923977703167765), + Offset(5.562636782045075, 37.55969505182836), + Offset(4.414449440404765, 35.616124462065585), + Offset(3.279490749406561, 32.70984389411209), + Offset(2.7682621793271274, 29.195444236515087), + Offset(2.990990582417215, 25.461813104162424), + Offset(3.8470898744420694, 21.77282776714659), + Offset(5.197035165412721, 18.285709296968175), + Offset(7.010860685511399, 15.049678967282448), + Offset(9.277753773054428, 12.183522132172802), + Offset(11.947236939771194, 9.777036277675393), + Offset(14.956384400815995, 7.8900098185013965), + Offset(18.22409121315066, 6.574409398728922), + Offset(21.6554297948317, 5.869296730140784), + Offset(25.05994602394574, 5.792258791656161), + Offset(28.35563405094127, 6.305180612418426), + Offset(31.382144508233342, 7.339460299365608), + Offset(34.022127833421656, 8.780716365498357), + Offset(36.175923571219265, 10.44210466758643), + Offset(37.741735634590825, 12.031944919667563), + Offset(38.47478565252257, 12.929759669709906), + Offset(38.4754065, 12.930571500000003), + ], + ), + _PathClose(), + ], + ), + _PathFrames( + opacities: <double>[ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.390243902439, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + commands: <_PathCommand>[ + _PathMoveTo(<Offset>[ + Offset(18.66077577959113, 37.914057688182396), + Offset(18.393498189355185, 37.79998079730431), + Offset(17.09472832739092, 37.11319104947929), + Offset(15.475384929761471, 35.88799081265731), + Offset(13.994820730230222, 34.11308719672446), + Offset(12.846625441448277, 31.966606981659766), + Offset(12.064895108696843, 29.629153345943823), + Offset(11.625802581532461, 27.235442523199854), + Offset(11.529494613571963, 24.822978582423502), + Offset(11.810434303438388, 22.461321032697384), + Offset(12.47121362886298, 20.217463432151188), + Offset(13.49729949671066, 18.145996784655154), + Offset(14.861412738737947, 16.303366283898786), + Offset(16.52421278993278, 14.743568387471647), + Offset(18.38606694668249, 13.541227409050501), + Offset(20.388513590559725, 12.70313747512542), + Offset(22.419258759371843, 12.24586980950756), + Offset(24.37426249499311, 12.145960731054156), + Offset(26.134613352037505, 12.33373473462172), + Offset(27.54450711496552, 12.682103167247583), + Offset(28.256851668592745, 12.930332738674227), + Offset(28.257472499999995, 12.930571500000006), + ]), + _PathCubicTo( + <Offset>[ + Offset(18.66077577959113, 37.914057688182396), + Offset(18.393498189355185, 37.79998079730431), + Offset(17.09472832739092, 37.11319104947929), + Offset(15.475384929761471, 35.88799081265731), + Offset(13.994820730230222, 34.11308719672446), + Offset(12.846625441448277, 31.966606981659766), + Offset(12.064895108696843, 29.629153345943823), + Offset(11.625802581532461, 27.235442523199854), + Offset(11.529494613571963, 24.822978582423502), + Offset(11.810434303438388, 22.461321032697384), + Offset(12.47121362886298, 20.217463432151188), + Offset(13.49729949671066, 18.145996784655154), + Offset(14.861412738737947, 16.303366283898786), + Offset(16.52421278993278, 14.743568387471647), + Offset(18.38606694668249, 13.541227409050501), + Offset(20.388513590559725, 12.70313747512542), + Offset(22.419258759371843, 12.24586980950756), + Offset(24.37426249499311, 12.145960731054156), + Offset(26.134613352037505, 12.33373473462172), + Offset(27.54450711496552, 12.682103167247583), + Offset(28.256851668592745, 12.930332738674227), + Offset(28.257472499999995, 12.930571500000006), + ], + <Offset>[ + Offset(29.36071758621049, 37.90579100902793), + Offset(29.08588269544694, 38.00021891853426), + Offset(27.661627399879382, 38.36074653899071), + Offset(25.638630080057226, 38.53644657811165), + Offset(23.350286189316137, 38.211122996898936), + Offset(21.059654490640828, 37.38726854624088), + Offset(18.913066137242488, 36.17609132827485), + Offset(16.983108761632245, 34.69355354505958), + Offset(15.295022886955763, 32.967394928374375), + Offset(13.92100141209169, 31.0261534498012), + Offset(12.907860869772804, 28.91781939421435), + Offset(12.281395409956211, 26.692652589783282), + Offset(12.059180676727353, 24.41083035487367), + Offset(12.24819861918368, 22.1387947685807), + Offset(12.824501048963114, 19.99870125687912), + Offset(13.74924654024178, 18.034768194047917), + Offset(14.950187301987258, 16.334544401292522), + Offset(16.334374712969325, 14.950331035683991), + Offset(17.766854836052705, 13.910093123817797), + Offset(19.04681668194443, 13.2239017068976), + Offset(19.74190668198456, 12.930810296144493), + Offset(19.742527499999994, 12.930571500000006), + ], + <Offset>[ + Offset(29.36071758621049, 37.90579100902793), + Offset(29.08588269544694, 38.00021891853426), + Offset(27.661627399879382, 38.36074653899071), + Offset(25.638630080057226, 38.53644657811165), + Offset(23.350286189316137, 38.211122996898936), + Offset(21.059654490640828, 37.38726854624088), + Offset(18.913066137242488, 36.17609132827485), + Offset(16.983108761632245, 34.69355354505958), + Offset(15.295022886955763, 32.967394928374375), + Offset(13.92100141209169, 31.0261534498012), + Offset(12.907860869772804, 28.91781939421435), + Offset(12.281395409956211, 26.692652589783282), + Offset(12.059180676727353, 24.41083035487367), + Offset(12.24819861918368, 22.1387947685807), + Offset(12.824501048963114, 19.99870125687912), + Offset(13.74924654024178, 18.034768194047917), + Offset(14.950187301987258, 16.334544401292522), + Offset(16.334374712969325, 14.950331035683991), + Offset(17.766854836052705, 13.910093123817797), + Offset(19.04681668194443, 13.2239017068976), + Offset(19.74190668198456, 12.930810296144493), + Offset(19.742527499999994, 12.930571500000006), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.36071758621049, 37.90579100902793), + Offset(29.08588269544694, 38.00021891853426), + Offset(27.661627399879382, 38.36074653899071), + Offset(25.638630080057226, 38.53644657811165), + Offset(23.350286189316137, 38.211122996898936), + Offset(21.059654490640828, 37.38726854624088), + Offset(18.913066137242488, 36.17609132827485), + Offset(16.983108761632245, 34.69355354505958), + Offset(15.295022886955763, 32.967394928374375), + Offset(13.92100141209169, 31.0261534498012), + Offset(12.907860869772804, 28.91781939421435), + Offset(12.281395409956211, 26.692652589783282), + Offset(12.059180676727353, 24.41083035487367), + Offset(12.24819861918368, 22.1387947685807), + Offset(12.824501048963114, 19.99870125687912), + Offset(13.74924654024178, 18.034768194047917), + Offset(14.950187301987258, 16.334544401292522), + Offset(16.334374712969325, 14.950331035683991), + Offset(17.766854836052705, 13.910093123817797), + Offset(19.04681668194443, 13.2239017068976), + Offset(19.74190668198456, 12.930810296144493), + Offset(19.742527499999994, 12.930571500000006), + ], + <Offset>[ + Offset(29.350797571225126, 25.065860841084696), + Offset(29.326168440922885, 25.16935751122415), + Offset(29.158693987293088, 25.68046765200456), + Offset(28.816776998602446, 26.340552397756746), + Offset(28.267929149525507, 26.984564445995836), + Offset(27.564448368138166, 27.53163368720982), + Offset(26.76939171603972, 27.958286094020078), + Offset(25.932841987863917, 28.264786128939843), + Offset(25.068322502096812, 28.448761000313816), + Offset(24.198800312616267, 28.493472919417236), + Offset(23.3482880242486, 28.393842705122562), + Offset(22.537382376109967, 28.151737493888618), + Offset(21.788137561897216, 27.77350882928638), + Offset(21.122470276514544, 27.270011773479617), + Offset(20.573469666357454, 26.672580334142374), + Offset(20.147203402948776, 26.001888654429454), + Offset(19.85659681212921, 25.29743015015402), + Offset(19.699619078525124, 24.598196374112536), + Offset(19.658484903087995, 23.951403342999555), + Offset(19.69697492952445, 23.421130226522905), + Offset(19.74247975094888, 23.148744280074315), + Offset(19.742527499999998, 23.148505500000006), + ], + <Offset>[ + Offset(29.350797571225126, 25.065860841084696), + Offset(29.326168440922885, 25.16935751122415), + Offset(29.158693987293088, 25.68046765200456), + Offset(28.816776998602446, 26.340552397756746), + Offset(28.267929149525507, 26.984564445995836), + Offset(27.564448368138166, 27.53163368720982), + Offset(26.76939171603972, 27.958286094020078), + Offset(25.932841987863917, 28.264786128939843), + Offset(25.068322502096812, 28.448761000313816), + Offset(24.198800312616267, 28.493472919417236), + Offset(23.3482880242486, 28.393842705122562), + Offset(22.537382376109967, 28.151737493888618), + Offset(21.788137561897216, 27.77350882928638), + Offset(21.122470276514544, 27.270011773479617), + Offset(20.573469666357454, 26.672580334142374), + Offset(20.147203402948776, 26.001888654429454), + Offset(19.85659681212921, 25.29743015015402), + Offset(19.699619078525124, 24.598196374112536), + Offset(19.658484903087995, 23.951403342999555), + Offset(19.69697492952445, 23.421130226522905), + Offset(19.74247975094888, 23.148744280074315), + Offset(19.742527499999998, 23.148505500000006), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(29.350797571225126, 25.065860841084696), + Offset(29.326168440922885, 25.16935751122415), + Offset(29.158693987293088, 25.68046765200456), + Offset(28.816776998602446, 26.340552397756746), + Offset(28.267929149525507, 26.984564445995836), + Offset(27.564448368138166, 27.53163368720982), + Offset(26.76939171603972, 27.958286094020078), + Offset(25.932841987863917, 28.264786128939843), + Offset(25.068322502096812, 28.448761000313816), + Offset(24.198800312616267, 28.493472919417236), + Offset(23.3482880242486, 28.393842705122562), + Offset(22.537382376109967, 28.151737493888618), + Offset(21.788137561897216, 27.77350882928638), + Offset(21.122470276514544, 27.270011773479617), + Offset(20.573469666357454, 26.672580334142374), + Offset(20.147203402948776, 26.001888654429454), + Offset(19.85659681212921, 25.29743015015402), + Offset(19.699619078525124, 24.598196374112536), + Offset(19.658484903087995, 23.951403342999555), + Offset(19.69697492952445, 23.421130226522905), + Offset(19.74247975094888, 23.148744280074315), + Offset(19.742527499999998, 23.148505500000006), + ], + <Offset>[ + Offset(18.650855764605765, 25.074127520239166), + Offset(18.63378393483113, 24.969119389994198), + Offset(18.591794914804627, 24.432912162493135), + Offset(18.65353184830669, 23.6920966323024), + Offset(18.912463690439594, 22.88652864582136), + Offset(19.351419318945613, 22.110972122628702), + Offset(19.921220687494078, 21.41134811168905), + Offset(20.575535807764133, 20.806675107080117), + Offset(21.30279422871301, 20.304344654362943), + Offset(22.088233203962965, 19.92864050231342), + Offset(22.911640783338775, 19.6934867430594), + Offset(23.753286462864416, 19.60508168876049), + Offset(24.59036962390781, 19.666044758311497), + Offset(25.39848444726364, 19.874785392370566), + Offset(26.13503556407683, 20.215106486313754), + Offset(26.786470453266723, 20.670257935506957), + Offset(27.3256682695138, 21.20875555836906), + Offset(27.73950686054891, 21.7938260694827), + Offset(28.026243419072795, 22.37504495380348), + Offset(28.19466536254554, 22.879331686872888), + Offset(28.257424737557066, 23.14826672260405), + Offset(28.2574725, 23.148505500000006), + ], + <Offset>[ + Offset(18.650855764605765, 25.074127520239166), + Offset(18.63378393483113, 24.969119389994198), + Offset(18.591794914804627, 24.432912162493135), + Offset(18.65353184830669, 23.6920966323024), + Offset(18.912463690439594, 22.88652864582136), + Offset(19.351419318945613, 22.110972122628702), + Offset(19.921220687494078, 21.41134811168905), + Offset(20.575535807764133, 20.806675107080117), + Offset(21.30279422871301, 20.304344654362943), + Offset(22.088233203962965, 19.92864050231342), + Offset(22.911640783338775, 19.6934867430594), + Offset(23.753286462864416, 19.60508168876049), + Offset(24.59036962390781, 19.666044758311497), + Offset(25.39848444726364, 19.874785392370566), + Offset(26.13503556407683, 20.215106486313754), + Offset(26.786470453266723, 20.670257935506957), + Offset(27.3256682695138, 21.20875555836906), + Offset(27.73950686054891, 21.7938260694827), + Offset(28.026243419072795, 22.37504495380348), + Offset(28.19466536254554, 22.879331686872888), + Offset(28.257424737557066, 23.14826672260405), + Offset(28.2574725, 23.148505500000006), + ], + ), + _PathCubicTo( + <Offset>[ + Offset(18.650855764605765, 25.074127520239166), + Offset(18.63378393483113, 24.969119389994198), + Offset(18.591794914804627, 24.432912162493135), + Offset(18.65353184830669, 23.6920966323024), + Offset(18.912463690439594, 22.88652864582136), + Offset(19.351419318945613, 22.110972122628702), + Offset(19.921220687494078, 21.41134811168905), + Offset(20.575535807764133, 20.806675107080117), + Offset(21.30279422871301, 20.304344654362943), + Offset(22.088233203962965, 19.92864050231342), + Offset(22.911640783338775, 19.6934867430594), + Offset(23.753286462864416, 19.60508168876049), + Offset(24.59036962390781, 19.666044758311497), + Offset(25.39848444726364, 19.874785392370566), + Offset(26.13503556407683, 20.215106486313754), + Offset(26.786470453266723, 20.670257935506957), + Offset(27.3256682695138, 21.20875555836906), + Offset(27.73950686054891, 21.7938260694827), + Offset(28.026243419072795, 22.37504495380348), + Offset(28.19466536254554, 22.879331686872888), + Offset(28.257424737557066, 23.14826672260405), + Offset(28.2574725, 23.148505500000006), + ], + <Offset>[ + Offset(18.66077577959113, 37.914057688182396), + Offset(18.393498189355185, 37.79998079730431), + Offset(17.09472832739092, 37.11319104947929), + Offset(15.475384929761471, 35.88799081265731), + Offset(13.994820730230222, 34.11308719672446), + Offset(12.846625441448277, 31.966606981659766), + Offset(12.064895108696843, 29.629153345943823), + Offset(11.625802581532461, 27.235442523199854), + Offset(11.529494613571963, 24.822978582423502), + Offset(11.810434303438388, 22.461321032697384), + Offset(12.47121362886298, 20.217463432151188), + Offset(13.49729949671066, 18.145996784655154), + Offset(14.861412738737947, 16.303366283898786), + Offset(16.52421278993278, 14.743568387471647), + Offset(18.38606694668249, 13.541227409050501), + Offset(20.388513590559725, 12.70313747512542), + Offset(22.419258759371843, 12.24586980950756), + Offset(24.37426249499311, 12.145960731054156), + Offset(26.134613352037505, 12.33373473462172), + Offset(27.54450711496552, 12.682103167247583), + Offset(28.256851668592745, 12.930332738674227), + Offset(28.257472499999995, 12.930571500000006), + ], + <Offset>[ + Offset(18.66077577959113, 37.914057688182396), + Offset(18.393498189355185, 37.79998079730431), + Offset(17.09472832739092, 37.11319104947929), + Offset(15.475384929761471, 35.88799081265731), + Offset(13.994820730230222, 34.11308719672446), + Offset(12.846625441448277, 31.966606981659766), + Offset(12.064895108696843, 29.629153345943823), + Offset(11.625802581532461, 27.235442523199854), + Offset(11.529494613571963, 24.822978582423502), + Offset(11.810434303438388, 22.461321032697384), + Offset(12.47121362886298, 20.217463432151188), + Offset(13.49729949671066, 18.145996784655154), + Offset(14.861412738737947, 16.303366283898786), + Offset(16.52421278993278, 14.743568387471647), + Offset(18.38606694668249, 13.541227409050501), + Offset(20.388513590559725, 12.70313747512542), + Offset(22.419258759371843, 12.24586980950756), + Offset(24.37426249499311, 12.145960731054156), + Offset(26.134613352037505, 12.33373473462172), + Offset(27.54450711496552, 12.682103167247583), + Offset(28.256851668592745, 12.930332738674227), + Offset(28.257472499999995, 12.930571500000006), + ], + ), + _PathClose(), + ], + ), +], matchTextDirection: true); diff --git a/packages/material_ui/lib/src/app.dart b/packages/material_ui/lib/src/app.dart new file mode 100644 index 000000000000..160318e943c3 --- /dev/null +++ b/packages/material_ui/lib/src/app.dart @@ -0,0 +1,1271 @@ +// 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. + +/// @docImport 'package:flutter_localizations/flutter_localizations.dart'; +/// +/// @docImport 'app_bar.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'dialog.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'material.dart'; +/// @docImport 'popup_menu.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:ui' as ui; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'arc.dart'; +import 'button_style.dart'; +import 'colors.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'material_localizations.dart'; +import 'page.dart'; +import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState; +import 'scrollbar.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +// Examples can assume: +// typedef GlobalWidgetsLocalizations = DefaultWidgetsLocalizations; +// typedef GlobalMaterialLocalizations = DefaultMaterialLocalizations; + +/// [MaterialApp] uses this [TextStyle] as its [DefaultTextStyle] to encourage +/// developers to be intentional about their [DefaultTextStyle]. +/// +/// In Material Design, most [Text] widgets are contained in [Material] widgets, +/// which sets a specific [DefaultTextStyle]. If you're seeing text that uses +/// this text style, consider putting your text in a [Material] widget (or +/// another widget that sets a [DefaultTextStyle]). +const TextStyle _errorTextStyle = TextStyle( + color: Color(0xD0FF0000), + fontFamily: 'monospace', + fontSize: 48.0, + fontWeight: FontWeight.w900, + decoration: TextDecoration.underline, + decorationColor: Color(0xFFFFFF00), + decorationStyle: TextDecorationStyle.double, + debugLabel: 'fallback style; consider putting your text in a Material', +); + +/// Describes which theme will be used by [MaterialApp]. +enum ThemeMode { + /// Use either the light or dark theme based on what the user has selected in + /// the system settings. + system, + + /// Always use the light mode regardless of system preference. + light, + + /// Always use the dark mode (if available) regardless of system preference. + dark; + + /// Whether this theme mode follows the system setting. + bool get isSystem => this == ThemeMode.system; + + /// Whether this theme mode forces light mode. + bool get isLight => this == ThemeMode.light; + + /// Whether this theme mode forces dark mode. + bool get isDark => this == ThemeMode.dark; +} + +/// An application that uses Material Design. +/// +/// A convenience widget that wraps a number of widgets that are commonly +/// required for Material Design applications. It builds upon a [WidgetsApp] by +/// adding material-design specific functionality, such as [AnimatedTheme] and +/// [GridPaper]. +/// +/// [MaterialApp] configures its [WidgetsApp.textStyle] with an ugly red/yellow +/// text style that's intended to warn the developer that their app hasn't defined +/// a default text style. Typically the app's [Scaffold] builds a [Material] widget +/// whose default [Material.textStyle] defines the text style for the entire scaffold. +/// +/// The [MaterialApp] configures the top-level [Navigator] to search for routes +/// in the following order: +/// +/// 1. For the `/` route, the [home] property, if non-null, is used. +/// +/// 2. Otherwise, the [routes] table is used, if it has an entry for the route. +/// +/// 3. Otherwise, [onGenerateRoute] is called, if provided. It should return a +/// non-null value for any _valid_ route not handled by [home] and [routes]. +/// +/// 4. Finally if all else fails [onUnknownRoute] is called. +/// +/// If a [Navigator] is created, at least one of these options must handle the +/// `/` route, since it is used when an invalid [initialRoute] is specified on +/// startup (e.g. by another application launching this one with an intent on +/// Android; see [dart:ui.PlatformDispatcher.defaultRouteName]). +/// +/// This widget also configures the observer of the top-level [Navigator] (if +/// any) to perform [Hero] animations. +/// +/// {@template flutter.material.MaterialApp.defaultSelectionStyle} +/// The [MaterialApp] automatically creates a [DefaultSelectionStyle]. It uses +/// the colors in the [ThemeData.textSelectionTheme] if they are not null; +/// otherwise, the [MaterialApp] sets [DefaultSelectionStyle.selectionColor] to +/// [ColorScheme.primary] with 0.4 opacity and +/// [DefaultSelectionStyle.cursorColor] to [ColorScheme.primary]. +/// {@endtemplate} +/// +/// If [home], [routes], [onGenerateRoute], and [onUnknownRoute] are all null, +/// and [builder] is not null, then no [Navigator] is created. +/// +/// {@tool snippet} +/// This example shows how to create a [MaterialApp] that disables the "debug" +/// banner with a [home] route that will be displayed when the app is launched. +/// +/// ![The MaterialApp displays a Scaffold ](https://flutter.github.io/assets-for-api-docs/assets/material/basic_material_app.png) +/// +/// ```dart +/// MaterialApp( +/// home: Scaffold( +/// appBar: AppBar( +/// title: const Text('Home'), +/// ), +/// ), +/// debugShowCheckedModeBanner: false, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// This example shows how to create a [MaterialApp] that uses the [routes] +/// `Map` to define the "home" route and an "about" route. +/// +/// ```dart +/// MaterialApp( +/// routes: <String, WidgetBuilder>{ +/// '/': (BuildContext context) { +/// return Scaffold( +/// appBar: AppBar( +/// title: const Text('Home Route'), +/// ), +/// ); +/// }, +/// '/about': (BuildContext context) { +/// return Scaffold( +/// appBar: AppBar( +/// title: const Text('About Route'), +/// ), +/// ); +/// } +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// This example shows how to create a [MaterialApp] that defines a [theme] that +/// will be used for material widgets in the app. +/// +/// ![The MaterialApp displays a Scaffold with a dark background and a blue / grey AppBar at the top](https://flutter.github.io/assets-for-api-docs/assets/material/theme_material_app.png) +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// brightness: Brightness.dark, +/// primaryColor: Colors.blueGrey +/// ), +/// home: Scaffold( +/// appBar: AppBar( +/// title: const Text('MaterialApp Theme'), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Troubleshooting +/// +/// ### Why is my app's text red with yellow underlines? +/// +/// [Text] widgets that lack a [Material] ancestor will be rendered with an ugly +/// red/yellow text style. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/material_app_unspecified_textstyle.png) +/// +/// The typical fix is to give the widget a [Scaffold] ancestor. The [Scaffold] creates +/// a [Material] widget that defines its default text style. +/// +/// ```dart +/// const MaterialApp( +/// title: 'Material App', +/// home: Scaffold( +/// body: Center( +/// child: Text('Hello World'), +/// ), +/// ), +/// ) +/// ``` +/// +/// See also: +/// +/// * [Scaffold], which provides standard app elements like an [AppBar] and a [Drawer]. +/// * [Navigator], which is used to manage the app's stack of pages. +/// * [MaterialPageRoute], which defines an app page that transitions in a material-specific way. +/// * [WidgetsApp], which defines the basic app elements but does not depend on the material library. +/// * The Flutter Internationalization Tutorial, +/// <https://flutter.dev/to/internationalization/>. +class MaterialApp extends StatefulWidget { + /// Creates a MaterialApp. + /// + /// At least one of [home], [routes], [onGenerateRoute], or [builder] must be + /// non-null. If only [routes] is given, it must include an entry for the + /// [Navigator.defaultRouteName] (`/`), since that is the route used when the + /// application is launched with an intent that specifies an otherwise + /// unsupported route. + /// + /// This class creates an instance of [WidgetsApp]. + const MaterialApp({ + super.key, + this.navigatorKey, + this.scaffoldMessengerKey, + this.home, + Map<String, WidgetBuilder> this.routes = const <String, WidgetBuilder>{}, + this.initialRoute, + this.onGenerateRoute, + this.onGenerateInitialRoutes, + this.onUnknownRoute, + this.onNavigationNotification, + List<NavigatorObserver> this.navigatorObservers = const <NavigatorObserver>[], + this.builder, + this.title = '', + this.onGenerateTitle, + this.color, + this.theme, + this.darkTheme, + this.highContrastTheme, + this.highContrastDarkTheme, + this.themeMode = ThemeMode.system, + this.themeAnimationDuration = kThemeAnimationDuration, + this.themeAnimationCurve = Curves.linear, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const <Locale>[Locale('en', 'US')], + this.debugShowMaterialGrid = false, + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + this.restorationScopeId, + this.scrollBehavior, + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'MaterialApp never introduces its own MediaQuery; the View widget takes care of that. ' + 'This feature was deprecated after v3.7.0-29.0.pre.', + ) + this.useInheritedMediaQuery = false, + this.themeAnimationStyle, + }) : routeInformationProvider = null, + routeInformationParser = null, + routerDelegate = null, + backButtonDispatcher = null, + routerConfig = null; + + /// Creates a [MaterialApp] that uses the [Router] instead of a [Navigator]. + /// + /// {@macro flutter.widgets.WidgetsApp.router} + const MaterialApp.router({ + super.key, + this.scaffoldMessengerKey, + this.routeInformationProvider, + this.routeInformationParser, + this.routerDelegate, + this.routerConfig, + this.backButtonDispatcher, + this.builder, + this.title, + this.onGenerateTitle, + this.onNavigationNotification, + this.color, + this.theme, + this.darkTheme, + this.highContrastTheme, + this.highContrastDarkTheme, + this.themeMode = ThemeMode.system, + this.themeAnimationDuration = kThemeAnimationDuration, + this.themeAnimationCurve = Curves.linear, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const <Locale>[Locale('en', 'US')], + this.debugShowMaterialGrid = false, + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + this.restorationScopeId, + this.scrollBehavior, + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'MaterialApp never introduces its own MediaQuery; the View widget takes care of that. ' + 'This feature was deprecated after v3.7.0-29.0.pre.', + ) + this.useInheritedMediaQuery = false, + this.themeAnimationStyle, + }) : assert(routerDelegate != null || routerConfig != null), + navigatorObservers = null, + navigatorKey = null, + onGenerateRoute = null, + home = null, + onGenerateInitialRoutes = null, + onUnknownRoute = null, + routes = null, + initialRoute = null; + + /// {@macro flutter.widgets.widgetsApp.navigatorKey} + final GlobalKey<NavigatorState>? navigatorKey; + + /// A key to use when building the [ScaffoldMessenger]. + /// + /// If a [scaffoldMessengerKey] is specified, the [ScaffoldMessenger] can be + /// directly manipulated without first obtaining it from a [BuildContext] via + /// [ScaffoldMessenger.of]: from the [scaffoldMessengerKey], use the + /// [GlobalKey.currentState] getter. + final GlobalKey<ScaffoldMessengerState>? scaffoldMessengerKey; + + /// {@macro flutter.widgets.widgetsApp.home} + final Widget? home; + + /// The application's top-level routing table. + /// + /// When a named route is pushed with [Navigator.pushNamed], the route name is + /// looked up in this map. If the name is present, the associated + /// [WidgetBuilder] is used to construct a [MaterialPageRoute] that + /// performs an appropriate transition, including [Hero] animations, to the + /// new route. + /// + /// {@macro flutter.widgets.widgetsApp.routes} + final Map<String, WidgetBuilder>? routes; + + /// {@macro flutter.widgets.widgetsApp.initialRoute} + final String? initialRoute; + + /// {@macro flutter.widgets.widgetsApp.onGenerateRoute} + final RouteFactory? onGenerateRoute; + + /// {@macro flutter.widgets.widgetsApp.onGenerateInitialRoutes} + final InitialRouteListFactory? onGenerateInitialRoutes; + + /// {@macro flutter.widgets.widgetsApp.onUnknownRoute} + final RouteFactory? onUnknownRoute; + + /// {@macro flutter.widgets.widgetsApp.onNavigationNotification} + final NotificationListenerCallback<NavigationNotification>? onNavigationNotification; + + /// {@macro flutter.widgets.widgetsApp.navigatorObservers} + final List<NavigatorObserver>? navigatorObservers; + + /// {@macro flutter.widgets.widgetsApp.routeInformationProvider} + final RouteInformationProvider? routeInformationProvider; + + /// {@macro flutter.widgets.widgetsApp.routeInformationParser} + final RouteInformationParser<Object>? routeInformationParser; + + /// {@macro flutter.widgets.widgetsApp.routerDelegate} + final RouterDelegate<Object>? routerDelegate; + + /// {@macro flutter.widgets.widgetsApp.backButtonDispatcher} + final BackButtonDispatcher? backButtonDispatcher; + + /// {@macro flutter.widgets.widgetsApp.routerConfig} + final RouterConfig<Object>? routerConfig; + + /// {@macro flutter.widgets.widgetsApp.builder} + /// + /// Material specific features such as [showDialog] and [showMenu], and widgets + /// such as [Tooltip], [PopupMenuButton], also require a [Navigator] to properly + /// function. + final TransitionBuilder? builder; + + /// {@macro flutter.widgets.widgetsApp.title} + /// + /// This value is passed unmodified to [WidgetsApp.title]. + final String? title; + + /// {@macro flutter.widgets.widgetsApp.onGenerateTitle} + /// + /// This value is passed unmodified to [WidgetsApp.onGenerateTitle]. + final GenerateAppTitle? onGenerateTitle; + + /// Default visual properties, like colors fonts and shapes, for this app's + /// material widgets. + /// + /// A second [darkTheme] [ThemeData] value, which is used to provide a dark + /// version of the user interface can also be specified. [themeMode] will + /// control which theme will be used if a [darkTheme] is provided. + /// + /// The default value of this property is the value of [ThemeData.light()]. + /// + /// See also: + /// + /// * [themeMode], which controls which theme to use. + /// * [MediaQueryData.platformBrightness], which indicates the platform's + /// desired brightness and is used to automatically toggle between [theme] + /// and [darkTheme] in [MaterialApp]. + /// * [ThemeData.brightness], which indicates the [Brightness] of a theme's + /// colors. + final ThemeData? theme; + + /// The [ThemeData] to use when a 'dark mode' is requested by the system. + /// + /// Some host platforms allow the users to select a system-wide 'dark mode', + /// or the application may want to offer the user the ability to choose a + /// dark theme just for this application. This is theme that will be used for + /// such cases. [themeMode] will control which theme will be used. + /// + /// This theme should have a [ThemeData.brightness] set to [Brightness.dark]. + /// + /// Uses [theme] instead when null. Defaults to the value of + /// [ThemeData.light()] when both [darkTheme] and [theme] are null. + /// + /// See also: + /// + /// * [themeMode], which controls which theme to use. + /// * [MediaQueryData.platformBrightness], which indicates the platform's + /// desired brightness and is used to automatically toggle between [theme] + /// and [darkTheme] in [MaterialApp]. + /// * [ThemeData.brightness], which is typically set to the value of + /// [MediaQueryData.platformBrightness]. + final ThemeData? darkTheme; + + /// The [ThemeData] to use when 'high contrast' is requested by the system. + /// + /// Some host platforms (for example, iOS) allow the users to increase + /// contrast through an accessibility setting. + /// + /// Uses [theme] instead when null. + /// + /// See also: + /// + /// * [MediaQueryData.highContrast], which indicates the platform's + /// desire to increase contrast. + final ThemeData? highContrastTheme; + + /// The [ThemeData] to use when a 'dark mode' and 'high contrast' is requested + /// by the system. + /// + /// Some host platforms (for example, iOS) allow the users to increase + /// contrast through an accessibility setting. + /// + /// This theme should have a [ThemeData.brightness] set to [Brightness.dark]. + /// + /// Uses [darkTheme] instead when null. + /// + /// See also: + /// + /// * [MediaQueryData.highContrast], which indicates the platform's + /// desire to increase contrast. + final ThemeData? highContrastDarkTheme; + + /// Determines which theme will be used by the application if both [theme] + /// and [darkTheme] are provided. + /// + /// If set to [ThemeMode.system], the choice of which theme to use will + /// be based on the user's system preferences. If the [MediaQuery.platformBrightnessOf] + /// is [Brightness.light], [theme] will be used. If it is [Brightness.dark], + /// [darkTheme] will be used (unless it is null, in which case [theme] + /// will be used. + /// + /// If set to [ThemeMode.light] the [theme] will always be used, + /// regardless of the user's system preference. + /// + /// If set to [ThemeMode.dark] the [darkTheme] will be used + /// regardless of the user's system preference. If [darkTheme] is null + /// then it will fallback to using [theme]. + /// + /// The default value is [ThemeMode.system]. + /// + /// See also: + /// + /// * [theme], which is used when a light mode is selected. + /// * [darkTheme], which is used when a dark mode is selected. + /// * [ThemeData.brightness], which indicates to various parts of the + /// system what kind of theme is being used. + final ThemeMode? themeMode; + + /// The duration of animated theme changes. + /// + /// When the theme changes (either by the [theme], [darkTheme] or [themeMode] + /// parameters changing) it is animated to the new theme over time. + /// The [themeAnimationDuration] determines how long this animation takes. + /// + /// To have the theme change immediately, you can set this to [Duration.zero]. + /// + /// The default is [kThemeAnimationDuration]. + /// + /// See also: + /// [themeAnimationCurve], which defines the curve used for the animation. + final Duration themeAnimationDuration; + + /// The curve to apply when animating theme changes. + /// + /// The default is [Curves.linear]. + /// + /// This is ignored if [themeAnimationDuration] is [Duration.zero]. + /// + /// See also: + /// [themeAnimationDuration], which defines how long the animation is. + final Curve themeAnimationCurve; + + /// {@macro flutter.widgets.widgetsApp.color} + final Color? color; + + /// {@macro flutter.widgets.widgetsApp.locale} + final Locale? locale; + + /// {@macro flutter.widgets.widgetsApp.localizationsDelegates} + /// + /// Internationalized apps that require translations for one of the locales + /// listed in [GlobalMaterialLocalizations] should specify this parameter + /// and list the [supportedLocales] that the application can handle. + /// + /// ```dart + /// // The GlobalMaterialLocalizations and GlobalWidgetsLocalizations + /// // classes require the following import: + /// // import 'package:flutter_localizations/flutter_localizations.dart'; + /// + /// const MaterialApp( + /// localizationsDelegates: <LocalizationsDelegate<Object>>[ + /// // ... app-specific localization delegate(s) here + /// GlobalMaterialLocalizations.delegate, + /// GlobalWidgetsLocalizations.delegate, + /// ], + /// supportedLocales: <Locale>[ + /// Locale('en', 'US'), // English + /// Locale('he', 'IL'), // Hebrew + /// // ... other locales the app supports + /// ], + /// // ... + /// ) + /// ``` + /// + /// ## Adding localizations for a new locale + /// + /// The information that follows applies to the unusual case of an app + /// adding translations for a language not already supported by + /// [GlobalMaterialLocalizations]. + /// + /// Delegates that produce [WidgetsLocalizations] and [MaterialLocalizations] + /// are included automatically. Apps can provide their own versions of these + /// localizations by creating implementations of + /// [LocalizationsDelegate<WidgetsLocalizations>] or + /// [LocalizationsDelegate<MaterialLocalizations>] whose load methods return + /// custom versions of [WidgetsLocalizations] or [MaterialLocalizations]. + /// + /// For example: to add support to [MaterialLocalizations] for a locale it + /// doesn't already support, say `const Locale('foo', 'BR')`, one first + /// creates a subclass of [MaterialLocalizations] that provides the + /// translations: + /// + /// ```dart + /// class FooLocalizations extends MaterialLocalizations { + /// FooLocalizations(); + /// @override + /// String get okButtonLabel => 'foo'; + /// // ... + /// // lots of other getters and methods to override! + /// } + /// ``` + /// + /// One must then create a [LocalizationsDelegate] subclass that can provide + /// an instance of the [MaterialLocalizations] subclass. In this case, this is + /// essentially just a method that constructs a `FooLocalizations` object. A + /// [SynchronousFuture] is used here because no asynchronous work takes place + /// upon "loading" the localizations object. + /// + /// ```dart + /// // continuing from previous example... + /// class FooLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { + /// const FooLocalizationsDelegate(); + /// @override + /// bool isSupported(Locale locale) { + /// return locale == const Locale('foo', 'BR'); + /// } + /// @override + /// Future<FooLocalizations> load(Locale locale) { + /// assert(locale == const Locale('foo', 'BR')); + /// return SynchronousFuture<FooLocalizations>(FooLocalizations()); + /// } + /// @override + /// bool shouldReload(FooLocalizationsDelegate old) => false; + /// } + /// ``` + /// + /// Constructing a [MaterialApp] with a `FooLocalizationsDelegate` overrides + /// the automatically included delegate for [MaterialLocalizations] because + /// only the first delegate of each [LocalizationsDelegate.type] is used and + /// the automatically included delegates are added to the end of the app's + /// [localizationsDelegates] list. + /// + /// ```dart + /// // continuing from previous example... + /// const MaterialApp( + /// localizationsDelegates: <LocalizationsDelegate<Object>>[ + /// FooLocalizationsDelegate(), + /// ], + /// // ... + /// ) + /// ``` + /// See also: + /// + /// * [supportedLocales], which must be specified along with + /// [localizationsDelegates]. + /// * [GlobalMaterialLocalizations], a [localizationsDelegates] value + /// which provides material localizations for many languages. + /// * The Flutter Internationalization Tutorial, + /// <https://flutter.dev/to/internationalization/>. + final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates; + + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleListResolutionCallback? localeListResolutionCallback; + + /// {@macro flutter.widgets.LocaleResolutionCallback} + /// + /// This callback is passed along to the [WidgetsApp] built by this widget. + final LocaleResolutionCallback? localeResolutionCallback; + + /// {@macro flutter.widgets.widgetsApp.supportedLocales} + /// + /// It is passed along unmodified to the [WidgetsApp] built by this widget. + /// + /// See also: + /// + /// * [localizationsDelegates], which must be specified for localized + /// applications. + /// * [GlobalMaterialLocalizations], a [localizationsDelegates] value + /// which provides material localizations for many languages. + /// * The Flutter Internationalization Tutorial, + /// <https://flutter.dev/to/internationalization/>. + final Iterable<Locale> supportedLocales; + + /// Turns on a performance overlay. + /// + /// See also: + /// + /// * <https://flutter.dev/to/performance-overlay> + final bool showPerformanceOverlay; + + /// Turns on checkerboarding of raster cache images. + final bool checkerboardRasterCacheImages; + + /// Turns on checkerboarding of layers rendered to offscreen bitmaps. + final bool checkerboardOffscreenLayers; + + /// Turns on an overlay that shows the accessibility information + /// reported by the framework. + final bool showSemanticsDebugger; + + /// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner} + final bool debugShowCheckedModeBanner; + + /// {@macro flutter.widgets.widgetsApp.shortcuts} + /// {@tool snippet} + /// This example shows how to add a single shortcut for + /// [LogicalKeyboardKey.select] to the default shortcuts without needing to + /// add your own [Shortcuts] widget. + /// + /// Alternatively, you could insert a [Shortcuts] widget with just the mapping + /// you want to add between the [WidgetsApp] and its child and get the same + /// effect. + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return WidgetsApp( + /// shortcuts: <ShortcutActivator, Intent>{ + /// ... WidgetsApp.defaultShortcuts, + /// const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(), + /// }, + /// color: const Color(0xFFFF0000), + /// builder: (BuildContext context, Widget? child) { + /// return const Placeholder(); + /// }, + /// ); + /// } + /// ``` + /// {@end-tool} + /// {@macro flutter.widgets.widgetsApp.shortcuts.seeAlso} + final Map<ShortcutActivator, Intent>? shortcuts; + + /// {@macro flutter.widgets.widgetsApp.actions} + /// {@tool snippet} + /// This example shows how to add a single action handling an + /// [ActivateAction] to the default actions without needing to + /// add your own [Actions] widget. + /// + /// Alternatively, you could insert a [Actions] widget with just the mapping + /// you want to add between the [WidgetsApp] and its child and get the same + /// effect. + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return WidgetsApp( + /// actions: <Type, Action<Intent>>{ + /// ... WidgetsApp.defaultActions, + /// ActivateAction: CallbackAction<Intent>( + /// onInvoke: (Intent intent) { + /// // Do something here... + /// return null; + /// }, + /// ), + /// }, + /// color: const Color(0xFFFF0000), + /// builder: (BuildContext context, Widget? child) { + /// return const Placeholder(); + /// }, + /// ); + /// } + /// ``` + /// {@end-tool} + /// {@macro flutter.widgets.widgetsApp.actions.seeAlso} + final Map<Type, Action<Intent>>? actions; + + /// {@macro flutter.widgets.widgetsApp.restorationScopeId} + final String? restorationScopeId; + + /// {@template flutter.material.materialApp.scrollBehavior} + /// The default [ScrollBehavior] for the application. + /// + /// [ScrollBehavior]s describe how [Scrollable] widgets behave. Providing + /// a [ScrollBehavior] can set the default [ScrollPhysics] across + /// an application, and manage [Scrollable] decorations like [Scrollbar]s and + /// [GlowingOverscrollIndicator]s. + /// {@endtemplate} + /// + /// When null, defaults to [MaterialScrollBehavior]. + /// + /// See also: + /// + /// * [ScrollConfiguration], which controls how [Scrollable] widgets behave + /// in a subtree. + final ScrollBehavior? scrollBehavior; + + /// Turns on a [GridPaper] overlay that paints a baseline grid + /// Material apps. + /// + /// Only available in debug mode. + /// + /// See also: + /// + /// * <https://material.io/design/layout/spacing-methods.html> + final bool debugShowMaterialGrid; + + /// {@macro flutter.widgets.widgetsApp.useInheritedMediaQuery} + @Deprecated( + 'This setting is now ignored. ' + 'MaterialApp never introduces its own MediaQuery; the View widget takes care of that. ' + 'This feature was deprecated after v3.7.0-29.0.pre.', + ) + final bool useInheritedMediaQuery; + + /// Used to override the theme animation curve and duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the theme animation duration in the underlying [AnimatedTheme] widget. + /// If it is null, then [themeAnimationDuration] will be used. Otherwise, + /// defaults to 200ms. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the theme animation curve in the underlying [AnimatedTheme] widget. + /// If it is null, then [themeAnimationCurve] will be used. Otherwise, + /// defaults to [Curves.linear]. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override the theme animation curve and + /// duration in the [MaterialApp] widget using [AnimationStyle]. + /// + /// ** See code in examples/api/lib/material/app/app.0.dart ** + /// {@end-tool} + final AnimationStyle? themeAnimationStyle; + + @override + State<MaterialApp> createState() => _MaterialAppState(); + + /// The [HeroController] used for Material page transitions. + /// + /// Used by the [MaterialApp]. + static HeroController createMaterialHeroController() { + return HeroController( + createRectTween: (Rect? begin, Rect? end) { + return MaterialRectArcTween(begin: begin, end: end); + }, + ); + } +} + +/// Describes how [Scrollable] widgets behave for [MaterialApp]s. +/// +/// {@macro flutter.widgets.scrollBehavior} +/// +/// Setting a [MaterialScrollBehavior] will apply a +/// [GlowingOverscrollIndicator] to [Scrollable] descendants when executing on +/// [TargetPlatform.android] and [TargetPlatform.fuchsia]. +/// +/// When using the desktop platform, if the [Scrollable] widget scrolls in the +/// [Axis.vertical], a [Scrollbar] is applied. +/// +/// If the scroll direction is [Axis.horizontal] scroll views are less +/// discoverable, so consider adding a Scrollbar in these cases, either directly +/// or through the [buildScrollbar] method. +/// +/// [ThemeData.useMaterial3] specifies the +/// overscroll indicator that is used on [TargetPlatform.android], which +/// defaults to true, resulting in a [StretchingOverscrollIndicator]. Setting +/// [ThemeData.useMaterial3] to false will instead use a +/// [GlowingOverscrollIndicator]. +/// +/// See also: +/// +/// * [ScrollBehavior], the default scrolling behavior extended by this class. +class MaterialScrollBehavior extends ScrollBehavior { + /// Creates a MaterialScrollBehavior that decorates [Scrollable]s with + /// [StretchingOverscrollIndicator]s and [Scrollbar]s based on the current + /// platform and provided [ScrollableDetails]. + /// + /// [ThemeData.useMaterial3] specifies the + /// overscroll indicator that is used on [TargetPlatform.android], which + /// defaults to true, resulting in a [StretchingOverscrollIndicator]. Setting + /// [ThemeData.useMaterial3] to false will instead use a + /// [GlowingOverscrollIndicator]. + const MaterialScrollBehavior(); + + @override + TargetPlatform getPlatform(BuildContext context) => Theme.of(context).platform; + + @override + Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) { + // When modifying this function, consider modifying the implementation in + // the base class ScrollBehavior as well. + switch (axisDirectionToAxis(details.direction)) { + case Axis.horizontal: + return child; + case Axis.vertical: + switch (getPlatform(context)) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + assert(details.controller != null); + return Scrollbar(controller: details.controller, child: child); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + return child; + } + } + } + + @override + Widget buildOverscrollIndicator(BuildContext context, Widget child, ScrollableDetails details) { + // When modifying this function, consider modifying the implementation in + // the base class ScrollBehavior as well. + final AndroidOverscrollIndicator indicator = Theme.of(context).useMaterial3 + ? AndroidOverscrollIndicator.stretch + : AndroidOverscrollIndicator.glow; + switch (getPlatform(context)) { + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return child; + case TargetPlatform.android: + switch (indicator) { + case AndroidOverscrollIndicator.stretch: + return StretchingOverscrollIndicator( + axisDirection: details.direction, + clipBehavior: details.clipBehavior ?? Clip.hardEdge, + child: child, + ); + case AndroidOverscrollIndicator.glow: + break; + } + case TargetPlatform.fuchsia: + break; + } + return GlowingOverscrollIndicator( + axisDirection: details.direction, + color: Theme.of(context).colorScheme.secondary, + child: child, + ); + } +} + +class _MaterialAppState extends State<MaterialApp> { + late HeroController _heroController; + + bool get _usesRouter => widget.routerDelegate != null || widget.routerConfig != null; + + @override + void initState() { + super.initState(); + _heroController = MaterialApp.createMaterialHeroController(); + } + + @override + void dispose() { + _heroController.dispose(); + super.dispose(); + } + + // Combine the Localizations for Material with the ones contributed + // by the localizationsDelegates parameter, if any. Only the first delegate + // of a particular LocalizationsDelegate.type is loaded so the + // localizationsDelegate parameter can be used to override + // _MaterialLocalizationsDelegate. + Iterable<LocalizationsDelegate<dynamic>> get _localizationsDelegates { + return <LocalizationsDelegate<dynamic>>[ + ...?widget.localizationsDelegates, + DefaultMaterialLocalizations.delegate, + DefaultCupertinoLocalizations.delegate, + ]; + } + + Widget _exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticsLabel, + required GlobalKey key, + }) { + return _MaterialInspectorButton.filled( + onPressed: onPressed, + semanticsLabel: semanticsLabel, + icon: Icons.close, + isDarkTheme: _isDarkTheme(context), + buttonKey: key, + ); + } + + Widget _moveExitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticsLabel, + bool usesDefaultAlignment = true, + }) { + return _MaterialInspectorButton.iconOnly( + onPressed: onPressed, + semanticsLabel: semanticsLabel, + icon: usesDefaultAlignment ? Icons.arrow_right : Icons.arrow_left, + isDarkTheme: _isDarkTheme(context), + ); + } + + Widget _tapBehaviorButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticsLabel, + required bool selectionOnTapEnabled, + }) { + return _MaterialInspectorButton.toggle( + onPressed: onPressed, + semanticsLabel: semanticsLabel, + // This unicode icon is also used for the Cupertino-styled button and for + // DevTools. It should be updated in all 3 places if changed. + icon: const IconData(0x1F74A), + isDarkTheme: _isDarkTheme(context), + toggledOn: selectionOnTapEnabled, + ); + } + + bool _isDarkTheme(BuildContext context) { + return widget.themeMode == ThemeMode.dark || + widget.themeMode == ThemeMode.system && + MediaQuery.platformBrightnessOf(context) == Brightness.dark; + } + + ThemeData _themeBuilder(BuildContext context) { + ThemeData? theme; + // Resolve which theme to use based on brightness and high contrast. + final ThemeMode mode = widget.themeMode ?? ThemeMode.system; + final Brightness platformBrightness = MediaQuery.platformBrightnessOf(context); + final bool useDarkTheme = + mode == ThemeMode.dark || + (mode == ThemeMode.system && platformBrightness == ui.Brightness.dark); + final bool highContrast = MediaQuery.highContrastOf(context); + if (useDarkTheme && highContrast && widget.highContrastDarkTheme != null) { + theme = widget.highContrastDarkTheme; + } else if (useDarkTheme && widget.darkTheme != null) { + theme = widget.darkTheme; + } else if (highContrast && widget.highContrastTheme != null) { + theme = widget.highContrastTheme; + } + theme ??= widget.theme ?? ThemeData(); + SystemChrome.setSystemUIOverlayStyle( + theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + ); + + return theme; + } + + Widget _materialBuilder(BuildContext context, Widget? child) { + final ThemeData theme = _themeBuilder(context); + final Color effectiveSelectionColor = + theme.textSelectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); + final Color effectiveCursorColor = + theme.textSelectionTheme.cursorColor ?? theme.colorScheme.primary; + + Widget childWidget = child ?? const SizedBox.shrink(); + + if (widget.builder != null) { + childWidget = Builder( + builder: (BuildContext context) { + // Why are we surrounding a builder with a builder? + // + // The widget.builder may contain code that invokes + // Theme.of(), which should return the theme we selected + // above in AnimatedTheme. However, if we invoke + // widget.builder() directly as the child of AnimatedTheme + // then there is no BuildContext separating them, the + // widget.builder() will not find the theme. Therefore, we + // surround widget.builder with yet another builder so that + // a context separates them and Theme.of() correctly + // resolves to the theme we passed to AnimatedTheme. + return widget.builder!(context, child); + }, + ); + } + + childWidget = ScaffoldMessenger( + key: widget.scaffoldMessengerKey, + child: DefaultSelectionStyle( + selectionColor: effectiveSelectionColor, + cursorColor: effectiveCursorColor, + child: childWidget, + ), + ); + + if (widget.themeAnimationStyle != AnimationStyle.noAnimation) { + childWidget = AnimatedTheme( + data: theme, + duration: widget.themeAnimationStyle?.duration ?? widget.themeAnimationDuration, + curve: widget.themeAnimationStyle?.curve ?? widget.themeAnimationCurve, + child: childWidget, + ); + } else { + childWidget = Theme(data: theme, child: childWidget); + } + + return childWidget; + } + + Widget _buildWidgetApp(BuildContext context) { + // The color property is always pulled from the light theme, even if dark + // mode is activated. This was done to simplify the technical details + // of switching themes and it was deemed acceptable because this color + // property is only used on old Android OSes to color the app bar in + // Android's switcher UI. + // + // blue is the primary color of the default theme. + final Color materialColor = widget.color ?? widget.theme?.primaryColor ?? Colors.blue; + if (_usesRouter) { + return WidgetsApp.router( + key: GlobalObjectKey(this), + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + routerConfig: widget.routerConfig, + backButtonDispatcher: widget.backButtonDispatcher, + onNavigationNotification: widget.onNavigationNotification, + builder: _materialBuilder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: _errorTextStyle, + color: materialColor, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + ); + } + + return WidgetsApp( + key: GlobalObjectKey(this), + navigatorKey: widget.navigatorKey, + navigatorObservers: widget.navigatorObservers!, + pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) { + return MaterialPageRoute<T>(settings: settings, builder: builder); + }, + home: widget.home, + routes: widget.routes!, + initialRoute: widget.initialRoute, + onGenerateRoute: widget.onGenerateRoute, + onGenerateInitialRoutes: widget.onGenerateInitialRoutes, + onUnknownRoute: widget.onUnknownRoute, + onNavigationNotification: widget.onNavigationNotification, + builder: _materialBuilder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: _errorTextStyle, + color: materialColor, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + ); + } + + @override + Widget build(BuildContext context) { + Widget result = _buildWidgetApp(context); + assert(() { + if (widget.debugShowMaterialGrid) { + result = GridPaper( + color: const Color(0xE0F9BBE0), + interval: 8.0, + subdivisions: 1, + child: result, + ); + } + return true; + }()); + + return ScrollConfiguration( + behavior: widget.scrollBehavior ?? const MaterialScrollBehavior(), + child: HeroControllerScope(controller: _heroController, child: result), + ); + } +} + +class _MaterialInspectorButton extends InspectorButton { + const _MaterialInspectorButton.filled({ + required super.onPressed, + required super.semanticsLabel, + required super.icon, + required this.isDarkTheme, + super.buttonKey, + }) : super.filled(); + + const _MaterialInspectorButton.toggle({ + required super.onPressed, + required super.semanticsLabel, + required super.icon, + required this.isDarkTheme, + super.toggledOn, + }) : super.toggle(); + + const _MaterialInspectorButton.iconOnly({ + required super.onPressed, + required super.semanticsLabel, + required super.icon, + required this.isDarkTheme, + }) : super.iconOnly(); + + final bool isDarkTheme; + + static const EdgeInsets _buttonPadding = EdgeInsets.zero; + static const BoxConstraints _buttonConstraints = BoxConstraints.tightFor( + width: InspectorButton.buttonSize, + height: InspectorButton.buttonSize, + ); + + @override + Widget build(BuildContext context) { + return IconButton( + key: buttonKey, + onPressed: onPressed, + iconSize: iconSizeForVariant, + padding: _buttonPadding, + constraints: _buttonConstraints, + style: _selectionButtonsIconStyle(context), + icon: Icon(icon, semanticLabel: semanticsLabel), + ); + } + + ButtonStyle _selectionButtonsIconStyle(BuildContext context) { + final Color foreground = foregroundColor(context); + final Color background = backgroundColor(context); + + return IconButton.styleFrom( + foregroundColor: foreground, + backgroundColor: background, + side: _borderSide(color: foreground), + tapTargetSize: MaterialTapTargetSize.padded, + ); + } + + BorderSide? _borderSide({required Color color}) { + switch (variant) { + case InspectorButtonVariant.filled: + case InspectorButtonVariant.iconOnly: + return null; + case InspectorButtonVariant.toggle: + return toggledOn == false ? BorderSide(color: color) : null; + } + } + + @override + Color foregroundColor(BuildContext context) { + final Color primaryColor = _primaryColor(context); + final Color secondaryColor = _secondaryColor(context); + switch (variant) { + case InspectorButtonVariant.filled: + return primaryColor; + case InspectorButtonVariant.iconOnly: + return secondaryColor; + case InspectorButtonVariant.toggle: + return !toggledOn! ? secondaryColor : primaryColor; + } + } + + @override + Color backgroundColor(BuildContext context) { + final Color secondaryColor = _secondaryColor(context); + switch (variant) { + case InspectorButtonVariant.filled: + return secondaryColor; + case InspectorButtonVariant.iconOnly: + return Colors.transparent; + case InspectorButtonVariant.toggle: + return !toggledOn! ? Colors.transparent : secondaryColor; + } + } + + Color _primaryColor(BuildContext context) { + final ThemeData theme = Theme.of(context); + return isDarkTheme ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.primaryContainer; + } + + Color _secondaryColor(BuildContext context) { + final ThemeData theme = Theme.of(context); + return isDarkTheme ? theme.colorScheme.primaryContainer : theme.colorScheme.onPrimaryContainer; + } +} diff --git a/packages/material_ui/lib/src/app_bar.dart b/packages/material_ui/lib/src/app_bar.dart new file mode 100644 index 000000000000..c5b1504fa7ec --- /dev/null +++ b/packages/material_ui/lib/src/app_bar.dart @@ -0,0 +1,2620 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'popup_menu.dart'; +/// @docImport 'snack_bar.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'action_buttons.dart'; +import 'app_bar_theme.dart'; +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'flexible_space_bar.dart'; +import 'icon_button.dart'; +import 'icon_button_theme.dart'; +import 'icons.dart'; +import 'material.dart'; +import 'scaffold.dart'; +import 'tabs.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late String _logoAsset; +// double _myToolbarHeight = 250.0; + +typedef _FlexibleConfigBuilder = _ScrollUnderFlexibleConfig Function(BuildContext); + +const double _kLeadingWidth = kToolbarHeight; // So the leading button is square. +const double _kMaxTitleTextScaleFactor = + 1.34; // TODO(perc): Add link to Material spec when available, https://github.com/flutter/flutter/issues/58769. + +enum _SliverAppVariant { small, medium, large } + +// Bottom justify the toolbarHeight child which may overflow the top. +class _ToolbarContainerLayout extends SingleChildLayoutDelegate { + const _ToolbarContainerLayout(this.toolbarHeight); + + final double toolbarHeight; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.tighten(height: toolbarHeight); + } + + @override + Size getSize(BoxConstraints constraints) { + return Size(constraints.maxWidth, toolbarHeight); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + return Offset(0.0, size.height - childSize.height); + } + + @override + bool shouldRelayout(_ToolbarContainerLayout oldDelegate) => + toolbarHeight != oldDelegate.toolbarHeight; +} + +class _PreferredAppBarSize extends Size { + _PreferredAppBarSize(this.toolbarHeight, this.bottomHeight) + : super.fromHeight((toolbarHeight ?? kToolbarHeight) + (bottomHeight ?? 0)); + + final double? toolbarHeight; + final double? bottomHeight; +} + +/// A Material Design app bar. +/// +/// An app bar consists of a toolbar and potentially other widgets, such as a +/// [TabBar] and a [FlexibleSpaceBar]. App bars typically expose one or more +/// common [actions] with [IconButton]s which are optionally followed by a +/// [PopupMenuButton] for less common operations (sometimes called the "overflow +/// menu"). +/// +/// App bars are typically used in the [Scaffold.appBar] property, which places +/// the app bar as a fixed-height widget at the top of the screen. For a scrollable +/// app bar, see [SliverAppBar], which embeds an [AppBar] in a sliver for use in +/// a [CustomScrollView]. +/// +/// The AppBar displays the toolbar widgets, [leading], [title], and [actions], +/// above the [bottom] (if any). The [bottom] is usually used for a [TabBar]. If +/// a [flexibleSpace] widget is specified then it is stacked behind the toolbar +/// and the bottom widget. The following diagram shows where each of these slots +/// appears in the toolbar when the writing language is left-to-right (e.g. +/// English): +/// +/// ![The leading widget is in the top left, the actions are in the top right, +/// the title is between them. The bottom is, naturally, at the bottom, and the +/// flexibleSpace is behind all of them.](https://flutter.github.io/assets-for-api-docs/assets/material/app_bar.png) +/// +/// If the [leading] widget is omitted, but the [AppBar] is in a [Scaffold] with +/// a [Drawer], then a button will be inserted to open the drawer. Otherwise, if +/// the nearest [Navigator] has any previous routes, a [BackButton] is inserted +/// instead. This behavior can be turned off by setting the [automaticallyImplyLeading] +/// to false. In that case a null leading widget will result in the middle/title widget +/// stretching to start. +/// +/// If the [actions] widget list is omitted or empty, but the [AppBar] is in a [Scaffold] with +/// an end [Drawer], then a button will be inserted to open the end drawer. +/// This behavior can be turned off by setting the [automaticallyImplyActions] +/// to false. +/// +/// The [AppBar] insets its content based on the ambient [MediaQuery]'s padding, +/// to avoid system UI intrusions. It's taken care of by [Scaffold] when used in +/// the [Scaffold.appBar] property. When animating an [AppBar], unexpected +/// [MediaQuery] changes (as is common in [Hero] animations) may cause the content +/// to suddenly jump. Wrap the [AppBar] in a [MediaQuery] widget, and adjust its +/// padding such that the animation is smooth. +/// +/// {@tool dartpad} +/// This sample shows an [AppBar] with two simple actions. The first action +/// opens a [SnackBar], while the second action navigates to a new page. +/// +/// ** See code in examples/api/lib/material/app_bar/app_bar.0.dart ** +/// {@end-tool} +/// +/// Material Design 3 introduced new types of app bar. +/// {@tool dartpad} +/// This sample shows the creation of an [AppBar] widget with the [shadowColor] and +/// [scrolledUnderElevation] properties set, as described in: +/// https://m3.material.io/components/top-app-bar/overview +/// +/// ** See code in examples/api/lib/material/app_bar/app_bar.1.dart ** +/// {@end-tool} +/// +/// ## Troubleshooting +/// +/// ### Why don't my TextButton actions appear? +/// +/// If the app bar's [actions] contains [TextButton]s, they will not +/// be visible if their foreground (text) color is the same as the +/// app bar's background color. +/// +/// In Material v2 (i.e., when [ThemeData.useMaterial3] is false), +/// the default app bar [backgroundColor] is the overall theme's +/// [ColorScheme.primary] if the overall theme's brightness is +/// [Brightness.light]. Unfortunately this is the same as the default +/// [ButtonStyle.foregroundColor] for [TextButton] for light themes. +/// In this case a preferable text button foreground color is +/// [ColorScheme.onPrimary], a color that contrasts nicely with +/// [ColorScheme.primary]. To remedy the problem, override +/// [TextButton.style]: +/// +/// {@tool dartpad} +/// This sample shows an [AppBar] with two action buttons with their primary +/// color set to [ColorScheme.onPrimary]. +/// +/// ** See code in examples/api/lib/material/app_bar/app_bar.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to listen to a nested Scrollable's scroll notification +/// in a nested scroll view using the [notificationPredicate] property and use it +/// to make [scrolledUnderElevation] take effect. +/// +/// ** See code in examples/api/lib/material/app_bar/app_bar.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Scaffold], which displays the [AppBar] in its [Scaffold.appBar] slot. +/// * [SliverAppBar], which uses [AppBar] to provide a flexible app bar that +/// can be used in a [CustomScrollView]. +/// * [TabBar], which is typically placed in the [bottom] slot of the [AppBar] +/// if the screen has multiple pages arranged in tabs. +/// * [IconButton], which is used with [actions] to show buttons on the app bar. +/// * [PopupMenuButton], to show a popup menu on the app bar, via [actions]. +/// * [FlexibleSpaceBar], which is used with [flexibleSpace] when the app bar +/// can expand and collapse. +/// * <https://material.io/design/components/app-bars-top.html> +/// * <https://m3.material.io/components/top-app-bar> +/// * Cookbook: [Place a floating app bar above a list](https://docs.flutter.dev/cookbook/lists/floating-app-bar) +class AppBar extends StatefulWidget implements PreferredSizeWidget { + /// Creates a Material Design app bar. + /// + /// If [elevation] is specified, it must be non-negative. + /// + /// Typically used in the [Scaffold.appBar] property. + AppBar({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.actions, + this.automaticallyImplyActions = true, + this.flexibleSpace, + this.bottom, + this.elevation, + this.scrolledUnderElevation, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.shadowColor, + this.surfaceTintColor, + this.shape, + this.backgroundColor, + this.foregroundColor, + this.iconTheme, + this.actionsIconTheme, + this.primary = true, + this.centerTitle, + this.excludeHeaderSemantics = false, + this.titleSpacing, + this.toolbarOpacity = 1.0, + this.bottomOpacity = 1.0, + this.toolbarHeight, + this.leadingWidth, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.forceMaterialTransparency = false, + this.useDefaultSemanticsOrder = true, + this.clipBehavior, + this.actionsPadding, + this.animateColor = false, + }) : assert(elevation == null || elevation >= 0.0), + preferredSize = _PreferredAppBarSize(toolbarHeight, bottom?.preferredSize.height); + + /// Used by [Scaffold] to compute its [AppBar]'s overall height. The returned value is + /// the same `preferredSize.height` unless [AppBar.toolbarHeight] was null and + /// `AppBarTheme.of(context).toolbarHeight` is non-null. In that case the + /// return value is the sum of the theme's toolbar height and the height of + /// the app bar's [AppBar.bottom] widget. + static double preferredHeightFor(BuildContext context, Size preferredSize) { + if (preferredSize is _PreferredAppBarSize && preferredSize.toolbarHeight == null) { + return (AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight) + + (preferredSize.bottomHeight ?? 0); + } + return preferredSize.height; + } + + /// {@template flutter.material.appbar.leading} + /// A widget to display before the toolbar's [title]. + /// + /// Typically the [leading] widget is an [Icon] or an [IconButton]. + /// + /// Becomes the leading component of the [NavigationToolbar] built + /// by this widget. The [leading] widget's width and height are constrained to + /// be no bigger than [leadingWidth] and [toolbarHeight] respectively. + /// + /// If this is null and [automaticallyImplyLeading] is set to true, the + /// [AppBar] will imply an appropriate widget. For example, if the [AppBar] is + /// in a [Scaffold] that also has a [Drawer], the [Scaffold] will fill this + /// widget with an [IconButton] that opens the drawer (using [Icons.menu]). If + /// there's no [Drawer] and the parent [Navigator] can go back, the [AppBar] + /// will use a [BackButton] that calls [Navigator.maybePop]. + /// {@endtemplate} + /// + /// {@tool snippet} + /// + /// The following code shows how the drawer button could be manually specified + /// instead of relying on [automaticallyImplyLeading]: + /// + /// ```dart + /// AppBar( + /// leading: Builder( + /// builder: (BuildContext context) { + /// return IconButton( + /// icon: const Icon(Icons.menu), + /// onPressed: () { Scaffold.of(context).openDrawer(); }, + /// tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, + /// ); + /// }, + /// ), + /// ) + /// ``` + /// {@end-tool} + /// + /// The [Builder] is used in this example to ensure that the `context` refers + /// to that part of the subtree. That way this code snippet can be used even + /// inside the very code that is creating the [Scaffold] (in which case, + /// without the [Builder], the `context` wouldn't be able to see the + /// [Scaffold], since it would refer to an ancestor of that widget). + /// + /// See also: + /// + /// * [Scaffold.appBar], in which an [AppBar] is usually placed. + /// * [Scaffold.drawer], in which the [Drawer] is usually placed. + final Widget? leading; + + /// {@template flutter.material.appbar.automaticallyImplyLeading} + /// Controls whether we should try to imply the leading widget if null. + /// + /// If true and [AppBar.leading] is null, automatically try to deduce what the leading + /// widget should be. If false and [AppBar.leading] is null, leading space is given to [AppBar.title]. + /// If leading widget is not null, this parameter has no effect. + /// {@endtemplate} + final bool automaticallyImplyLeading; + + /// {@template flutter.material.appbar.title} + /// The primary widget displayed in the app bar. + /// + /// Becomes the middle component of the [NavigationToolbar] built by this widget. + /// + /// Typically a [Text] widget that contains a description of the current + /// contents of the app. + /// {@endtemplate} + /// + /// The [title]'s width is constrained to fit within the remaining space + /// between the toolbar's [leading] and [actions] widgets. Its height is + /// _not_ constrained. The [title] is vertically centered and clipped to fit + /// within the toolbar, whose height is [toolbarHeight]. Typically this + /// isn't noticeable because a simple [Text] [title] will fit within the + /// toolbar by default. On the other hand, it is noticeable when a + /// widget with an intrinsic height that is greater than [toolbarHeight] + /// is used as the [title]. For example, when the height of an Image used + /// as the [title] exceeds [toolbarHeight], it will be centered and + /// clipped (top and bottom), which may be undesirable. In cases like this + /// the height of the [title] widget can be constrained. For example: + /// + /// ```dart + /// MaterialApp( + /// home: Scaffold( + /// appBar: AppBar( + /// title: SizedBox( + /// height: _myToolbarHeight, + /// child: Image.asset(_logoAsset), + /// ), + /// toolbarHeight: _myToolbarHeight, + /// ), + /// ), + /// ) + /// ``` + final Widget? title; + + /// {@template flutter.material.appbar.actions} + /// A list of Widgets to display in a row after the [title] widget. + /// + /// Typically these widgets are [IconButton]s representing common operations. + /// For less common operations, consider using a [PopupMenuButton] as the + /// last action. + /// + /// The [actions] become the trailing component of the [NavigationToolbar] built + /// by this widget. The height of each action is constrained to be no bigger + /// than the [toolbarHeight]. + /// + /// To avoid having the last action covered by the debug banner, you may want + /// to set the [MaterialApp.debugShowCheckedModeBanner] to false. + /// + /// If this is null or empty and [automaticallyImplyActions] is set to true, the + /// [AppBar] will imply an appropriate widget. For example, if the [AppBar] is + /// in a [Scaffold] that also has an end [Drawer], the [Scaffold] will fill this + /// widget with an [IconButton] that opens the end drawer (using [Icons.menu]). + /// {@endtemplate} + /// + /// {@tool snippet} + /// + /// ```dart + /// Scaffold( + /// body: CustomScrollView( + /// primary: true, + /// slivers: <Widget>[ + /// SliverAppBar( + /// title: const Text('Hello World'), + /// actions: <Widget>[ + /// IconButton( + /// icon: const Icon(Icons.shopping_cart), + /// tooltip: 'Open shopping cart', + /// onPressed: () { + /// // handle the press + /// }, + /// ), + /// ], + /// ), + /// // ...rest of body... + /// ], + /// ), + /// ) + /// ``` + /// {@end-tool} + final List<Widget>? actions; + + /// {@template flutter.material.appbar.automaticallyImplyActions} + /// Controls whether we should try to imply the actions widget if null. + /// + /// If true and [AppBar.actions] is null or empty, automatically try to deduce what the actions + /// widget should be. If false and [AppBar.actions] is null or empty, the actions widget list is kept empty. + /// If [AppBar.actions] is not null, this parameter has no effect. + /// {@endtemplate} + final bool automaticallyImplyActions; + + /// {@template flutter.material.appbar.flexibleSpace} + /// This widget is stacked behind the toolbar and the tab bar. Its height will + /// be the same as the app bar's overall height. + /// + /// A flexible space isn't actually flexible unless the [AppBar]'s container + /// changes the [AppBar]'s size. A [SliverAppBar] in a [CustomScrollView] + /// changes the [AppBar]'s height when scrolled. + /// + /// Typically a [FlexibleSpaceBar]. See [FlexibleSpaceBar] for details. + /// {@endtemplate} + final Widget? flexibleSpace; + + /// {@template flutter.material.appbar.bottom} + /// This widget appears across the bottom of the app bar. + /// + /// Typically a [TabBar]. Only widgets that implement [PreferredSizeWidget] can + /// be used at the bottom of an app bar. + /// {@endtemplate} + /// + /// See also: + /// + /// * [PreferredSize], which can be used to give an arbitrary widget a preferred size. + final PreferredSizeWidget? bottom; + + /// {@template flutter.material.appbar.elevation} + /// The z-coordinate at which to place this app bar relative to its parent. + /// + /// This property controls the size of the shadow below the app bar if + /// [shadowColor] is not null. + /// + /// If [surfaceTintColor] is not null then it will apply a surface tint overlay + /// to the background color (see [Material.surfaceTintColor] for more + /// detail). + /// + /// The value must be non-negative. + /// + /// If this property is null, then the ambient [AppBarThemeData.elevation] + /// is used. If that is also null, the default value is 4. + /// {@endtemplate} + /// + /// See also: + /// + /// * [scrolledUnderElevation], which will be used when the app bar has + /// something scrolled underneath it. + /// * [shadowColor], which is the color of the shadow below the app bar. + /// * [surfaceTintColor], which determines the elevation overlay that will + /// be applied to the background of the app bar. + /// * [shape], which defines the shape of the app bar's [Material] and its + /// shadow. + final double? elevation; + + /// {@template flutter.material.appbar.scrolledUnderElevation} + /// The elevation that will be used if this app bar has something + /// scrolled underneath it. + /// + /// If this property is null, then the ambient [AppBarThemeData.scrolledUnderElevation] + /// is used. If that is also null then [elevation] is used. + /// + /// The value must be non-negative. + /// + /// {@endtemplate} + /// + /// See also: + /// * [elevation], which will be used if there is no content scrolled under + /// the app bar. + /// * [shadowColor], which is the color of the shadow below the app bar. + /// * [surfaceTintColor], which determines the elevation overlay that will + /// be applied to the background of the app bar. + /// * [shape], which defines the shape of the app bar's [Material] and its + /// shadow. + final double? scrolledUnderElevation; + + /// A check that specifies which child's [ScrollNotification]s should be + /// listened to. + /// + /// By default, checks whether `notification.depth == 0`. Set it to something + /// else for more complicated layouts. + final ScrollNotificationPredicate notificationPredicate; + + /// {@template flutter.material.appbar.shadowColor} + /// The color of the shadow below the app bar. + /// + /// If this property is null, then the ambient [AppBarThemeData.shadowColor] + /// is used. If that is also null, the default value is fully opaque black. + /// {@endtemplate} + /// + /// See also: + /// + /// * [elevation], which defines the size of the shadow below the app bar. + /// * [shape], which defines the shape of the app bar and its shadow. + final Color? shadowColor; + + /// {@template flutter.material.appbar.surfaceTintColor} + /// The color of the surface tint overlay applied to the app bar's + /// background color to indicate elevation. + /// + /// If null no overlay will be applied. + /// {@endtemplate} + /// + /// See also: + /// * [Material.surfaceTintColor], which described this feature in more detail. + final Color? surfaceTintColor; + + /// {@template flutter.material.appbar.shape} + /// The shape of the app bar's [Material] as well as its shadow. + /// + /// If this property is null, then the ambient [AppBarThemeData.shape] + /// is used. Both properties default to null. + /// If both properties are null then the shape of the app bar's [Material] + /// is just a simple rectangle. + /// + /// A shadow is only displayed if the [elevation] is greater than + /// zero. + /// {@endtemplate} + /// + /// {@tool dartpad} + /// This sample demonstrates how to implement a custom app bar shape for the + /// [shape] property. + /// + /// ** See code in examples/api/lib/material/app_bar/app_bar.4.dart ** + /// {@end-tool} + /// See also: + /// + /// * [elevation], which defines the size of the shadow below the app bar. + /// * [shadowColor], which is the color of the shadow below the app bar. + final ShapeBorder? shape; + + /// {@template flutter.material.appbar.backgroundColor} + /// The fill color to use for an app bar's [Material]. + /// + /// If null, then the [AppBarTheme.backgroundColor] is used. If that value is also + /// null: + /// In Material v2 (i.e., when [ThemeData.useMaterial3] is false), + /// then [AppBar] uses the overall theme's [ColorScheme.primary] if the + /// overall theme's brightness is [Brightness.light], and [ColorScheme.surface] + /// if the overall theme's brightness is [Brightness.dark]. + /// In Material v3 (i.e., when [ThemeData.useMaterial3] is true), + /// then [AppBar] uses the overall theme's [ColorScheme.surface] + /// + /// If this color is a [WidgetStateColor] it will be resolved against + /// [WidgetState.scrolledUnder] when the content of the app's + /// primary scrollable overlaps the app bar. + /// {@endtemplate} + /// + /// See also: + /// + /// * [foregroundColor], which specifies the color for icons and text within + /// the app bar. + /// * [Theme.of], which returns the current overall Material theme as + /// a [ThemeData]. + /// * [ThemeData.colorScheme], the thirteen colors that most Material widget + /// default colors are based on. + /// * [ColorScheme.brightness], which indicates if the overall [Theme] + /// is light or dark. + final Color? backgroundColor; + + /// {@template flutter.material.appbar.foregroundColor} + /// The default color for [Text] and [Icon]s within the app bar. + /// + /// If null, then [AppBarTheme.foregroundColor] is used. If that + /// value is also null: + /// In Material v2 (i.e., when [ThemeData.useMaterial3] is false), + /// then [AppBar] uses the overall theme's [ColorScheme.onPrimary] if the + /// overall theme's brightness is [Brightness.light], and [ColorScheme.onSurface] + /// if the overall theme's brightness is [Brightness.dark]. + /// In Material v3 (i.e., when [ThemeData.useMaterial3] is true), + /// then [AppBar] uses the overall theme's [ColorScheme.onSurface]. + /// + /// This color is used to configure [DefaultTextStyle] that contains + /// the toolbar's children, and the default [IconTheme] widgets that + /// are created if [iconTheme] and [actionsIconTheme] are null. + /// {@endtemplate} + /// + /// See also: + /// + /// * [backgroundColor], which specifies the app bar's background color. + /// * [Theme.of], which returns the current overall Material theme as + /// a [ThemeData]. + /// * [ThemeData.colorScheme], the thirteen colors that most Material widget + /// default colors are based on. + /// * [ColorScheme.brightness], which indicates if the overall [Theme] + /// is light or dark. + final Color? foregroundColor; + + /// {@template flutter.material.appbar.iconTheme} + /// The color, opacity, and size to use for toolbar icons. + /// + /// If this property is null, then a copy of [ThemeData.iconTheme] + /// is used, with the [IconThemeData.color] set to the + /// app bar's [foregroundColor]. + /// {@endtemplate} + /// + /// See also: + /// + /// * [actionsIconTheme], which defines the appearance of icons in + /// the [actions] list. + final IconThemeData? iconTheme; + + /// {@template flutter.material.appbar.actionsIconTheme} + /// The color, opacity, and size to use for the icons that appear in the app + /// bar's [actions]. + /// + /// This property should only be used when the [actions] should be + /// themed differently than the icon that appears in the app bar's [leading] + /// widget. + /// + /// If this property is null, then the ambient [AppBarThemeData.actionsIconTheme] + /// is used. If that is also null, then the value of [iconTheme] is used. + /// {@endtemplate} + /// + /// See also: + /// + /// * [iconTheme], which defines the appearance of all of the toolbar icons. + final IconThemeData? actionsIconTheme; + + /// {@template flutter.material.appbar.primary} + /// Whether this app bar is being displayed at the top of the screen. + /// + /// If true, the app bar's toolbar elements and [bottom] widget will be + /// padded on top by the height of the system status bar. The layout + /// of the [flexibleSpace] is not affected by the [primary] property. + /// {@endtemplate} + final bool primary; + + /// {@template flutter.material.appbar.centerTitle} + /// Whether the title should be centered. + /// + /// If this property is null, then [AppBarTheme.centerTitle] of + /// [ThemeData.appBarTheme] is used. If that is also null, then value is + /// adapted to the current [TargetPlatform]. + /// {@endtemplate} + final bool? centerTitle; + + /// {@template flutter.material.appbar.excludeHeaderSemantics} + /// Whether the title should be wrapped with header [Semantics]. + /// + /// If false, the title will be used as [SemanticsProperties.namesRoute] + /// for Android, Fuchsia, Linux, and Windows platform. This means the title is + /// announced by screen reader when transition to this route. + /// + /// The accessibility behavior is platform adaptive, based on the device's + /// actual platform rather than the theme's platform setting. This ensures that + /// assistive technologies like VoiceOver on iOS and macOS receive the correct + /// `namesRoute` semantic information, even when the app's theme is configured + /// to mimic a different platform's appearance. + /// + /// Defaults to false. + /// {@endtemplate} + final bool excludeHeaderSemantics; + + /// {@template flutter.material.appbar.titleSpacing} + /// The spacing around [title] content on the horizontal axis. This spacing is + /// applied even if there is no [leading] content or [actions]. If you want + /// [title] to take all the space available, set this value to 0.0. + /// + /// If this property is null, then [AppBarTheme.titleSpacing] of + /// [ThemeData.appBarTheme] is used. If that is also null, then the + /// default value is [NavigationToolbar.kMiddleSpacing]. + /// {@endtemplate} + final double? titleSpacing; + + /// {@template flutter.material.appbar.toolbarOpacity} + /// How opaque the toolbar part of the app bar is. + /// + /// A value of 1.0 is fully opaque, and a value of 0.0 is fully transparent. + /// + /// Typically, this value is not changed from its default value (1.0). It is + /// used by [SliverAppBar] to animate the opacity of the toolbar when the app + /// bar is scrolled. + /// {@endtemplate} + final double toolbarOpacity; + + /// {@template flutter.material.appbar.bottomOpacity} + /// How opaque the bottom part of the app bar is. + /// + /// A value of 1.0 is fully opaque, and a value of 0.0 is fully transparent. + /// + /// Typically, this value is not changed from its default value (1.0). It is + /// used by [SliverAppBar] to animate the opacity of the toolbar when the app + /// bar is scrolled. + /// {@endtemplate} + final double bottomOpacity; + + /// {@template flutter.material.appbar.preferredSize} + /// A size whose height is the sum of [toolbarHeight] and the [bottom] widget's + /// preferred height. + /// + /// [Scaffold] uses this size to set its app bar's height. + /// {@endtemplate} + @override + final Size preferredSize; + + /// {@template flutter.material.appbar.toolbarHeight} + /// Defines the height of the toolbar component of an [AppBar]. + /// + /// By default, the value of [toolbarHeight] is [kToolbarHeight]. + /// {@endtemplate} + final double? toolbarHeight; + + /// {@template flutter.material.appbar.leadingWidth} + /// Defines the width of [AppBar.leading] widget. + /// + /// By default, the value of [AppBar.leadingWidth] is 56.0. + /// {@endtemplate} + final double? leadingWidth; + + /// {@template flutter.material.appbar.toolbarTextStyle} + /// The default text style for the AppBar's [leading], and + /// [actions] widgets, but not its [title]. + /// + /// If this property is null, then [AppBarTheme.toolbarTextStyle] of + /// [ThemeData.appBarTheme] is used. If that is also null, the default + /// value is a copy of the overall theme's [TextTheme.bodyMedium] + /// [TextStyle], with color set to the app bar's [foregroundColor]. + /// {@endtemplate} + /// + /// See also: + /// + /// * [titleTextStyle], which overrides the default text style for the [title]. + /// * [DefaultTextStyle], which overrides the default text style for all of the + /// widgets in a subtree. + final TextStyle? toolbarTextStyle; + + /// {@template flutter.material.appbar.titleTextStyle} + /// The default text style for the AppBar's [title] widget. + /// + /// If this property is null, then [AppBarTheme.titleTextStyle] of + /// [ThemeData.appBarTheme] is used. If that is also null, the default + /// value is a copy of the overall theme's [TextTheme.titleLarge] + /// [TextStyle], with color set to the app bar's [foregroundColor]. + /// {@endtemplate} + /// + /// See also: + /// + /// * [toolbarTextStyle], which is the default text style for the AppBar's + /// [title], [leading], and [actions] widgets, also known as the + /// AppBar's "toolbar". + /// * [DefaultTextStyle], which overrides the default text style for all of the + /// widgets in a subtree. + final TextStyle? titleTextStyle; + + /// {@template flutter.material.appbar.systemOverlayStyle} + /// Specifies the style to use for the system overlays (e.g. the status bar on + /// Android or iOS, the system navigation bar on Android). + /// + /// If this property is null, then [AppBarTheme.systemOverlayStyle] of + /// [ThemeData.appBarTheme] is used. If that is also null, an appropriate + /// [SystemUiOverlayStyle] is calculated based on the [backgroundColor]. + /// + /// The AppBar's descendants are built within a + /// `AnnotatedRegion<SystemUiOverlayStyle>` widget, which causes + /// [SystemChrome.setSystemUIOverlayStyle] to be called + /// automatically. Apps should not enclose an AppBar with their + /// own [AnnotatedRegion]. + /// {@endtemplate} + // + /// See also: + /// + /// * [AnnotatedRegion], for placing [SystemUiOverlayStyle] in the layer tree. + /// * [SystemChrome.setSystemUIOverlayStyle], the imperative API for setting + /// system overlays style. + final SystemUiOverlayStyle? systemOverlayStyle; + + /// {@template flutter.material.appbar.forceMaterialTransparency} + /// Forces the AppBar's Material widget type to be [MaterialType.transparency] + /// (instead of Material's default type). + /// + /// This will remove the visual display of [backgroundColor] and [elevation], + /// and affect other characteristics of the AppBar's Material widget. + /// + /// Provided for cases where the app bar is to be transparent, and gestures + /// must pass through the app bar to widgets beneath the app bar (i.e. with + /// [Scaffold.extendBodyBehindAppBar] set to true). + /// + /// Defaults to false. + /// {@endtemplate} + final bool forceMaterialTransparency; + + /// {@template flutter.material.appbar.useDefaultSemanticsOrder} + /// Whether to use the default semantic ordering for the app bar's children for + /// accessibility traversal order. + /// + /// If this is set to true, the app bar will use the default semantic ordering, + /// which places the flexible space after the main content in the semantics tree. + /// This affects how screen readers and other assistive technologies navigate the app bar's content. + /// + /// Set this to false if you want to customize semantics traversal order in the app bar. + /// You can then assign [SemanticsSortKey]s to app bar's children to control the order. + /// + /// Defaults to true. + /// + /// See also: + /// * [SemanticsSortKey], which are keys used to define the accessibility traversal order. + /// {@endtemplate} + final bool useDefaultSemanticsOrder; + + /// {@macro flutter.material.Material.clipBehavior} + final Clip? clipBehavior; + + /// {@template flutter.material.appbar.actionsPadding} + /// The padding between the [actions] and the end of the AppBar. + /// + /// Defaults to zero. + /// {@endtemplate} + final EdgeInsetsGeometry? actionsPadding; + + /// Whether the color should be animated. + final bool animateColor; + + bool _getEffectiveCenterTitle(ThemeData theme, AppBarThemeData appbarTheme) { + bool platformCenter() { + return switch (theme.platform) { + TargetPlatform.iOS || TargetPlatform.macOS => actions == null || actions!.length < 2, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => false, + }; + } + + return centerTitle ?? appbarTheme.centerTitle ?? platformCenter(); + } + + @override + State<AppBar> createState() => _AppBarState(); +} + +class _AppBarState extends State<AppBar> { + ScrollNotificationObserverState? _scrollNotificationObserver; + bool _scrolledUnder = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollNotificationObserver?.removeListener(_handleScrollNotification); + final ScaffoldState? scaffoldState = Scaffold.maybeOf(context); + + if (scaffoldState != null && (scaffoldState.isDrawerOpen || scaffoldState.isEndDrawerOpen)) { + return; + } + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_handleScrollNotification); + } + + @override + void dispose() { + if (_scrollNotificationObserver != null) { + _scrollNotificationObserver!.removeListener(_handleScrollNotification); + _scrollNotificationObserver = null; + } + super.dispose(); + } + + void _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification && widget.notificationPredicate(notification)) { + final bool oldScrolledUnder = _scrolledUnder; + final ScrollMetrics metrics = notification.metrics; + switch (metrics.axisDirection) { + case AxisDirection.up: + // Scroll view is reversed + _scrolledUnder = metrics.extentAfter > 0; + case AxisDirection.down: + _scrolledUnder = metrics.extentBefore > 0; + case AxisDirection.right: + case AxisDirection.left: + // Scrolled under is only supported in the vertical axis, and should + // not be altered based on horizontal notifications of the same + // predicate since it could be a 2D scroller. + break; + } + + if (_scrolledUnder != oldScrolledUnder) { + setState(() { + // React to a change in WidgetState.scrolledUnder + }); + } + } + } + + Color _resolveColor( + Set<WidgetState> states, + Color? widgetColor, + Color? themeColor, + Color defaultColor, + ) { + return WidgetStateProperty.resolveAs<Color?>(widgetColor, states) ?? + WidgetStateProperty.resolveAs<Color?>(themeColor, states) ?? + WidgetStateProperty.resolveAs<Color>(defaultColor, states); + } + + SystemUiOverlayStyle _systemOverlayStyleForBrightness( + Brightness brightness, [ + Color? backgroundColor, + ]) { + final SystemUiOverlayStyle style = brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark; + // For backward compatibility, create an overlay style without system navigation bar settings. + return SystemUiOverlayStyle( + statusBarColor: backgroundColor, + statusBarBrightness: style.statusBarBrightness, + statusBarIconBrightness: style.statusBarIconBrightness, + systemStatusBarContrastEnforced: style.systemStatusBarContrastEnforced, + ); + } + + @override + Widget build(BuildContext context) { + assert(!widget.primary || debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData theme = Theme.of(context); + final IconButtonThemeData iconButtonTheme = IconButtonTheme.of(context); + final AppBarThemeData appBarTheme = AppBarTheme.of(context); + final AppBarThemeData defaults = theme.useMaterial3 + ? _AppBarDefaultsM3(context) + : _AppBarDefaultsM2(context); + final ScaffoldState? scaffold = Scaffold.maybeOf(context); + final ModalRoute<dynamic>? parentRoute = ModalRoute.of(context); + + final FlexibleSpaceBarSettings? settings = context + .dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>(); + final states = <WidgetState>{ + if (settings?.isScrolledUnder ?? _scrolledUnder) WidgetState.scrolledUnder, + }; + + final bool hasDrawer = scaffold?.hasDrawer ?? false; + final bool hasEndDrawer = scaffold?.hasEndDrawer ?? false; + final bool useCloseButton = parentRoute?.fullscreenDialog ?? false; + + final double toolbarHeight = + widget.toolbarHeight ?? appBarTheme.toolbarHeight ?? kToolbarHeight; + + final Color backgroundColor = _resolveColor( + states, + widget.backgroundColor, + appBarTheme.backgroundColor, + defaults.backgroundColor!, + ); + + final Color scrolledUnderBackground = _resolveColor( + states, + widget.backgroundColor, + appBarTheme.backgroundColor, + Theme.of(context).colorScheme.surfaceContainer, + ); + + final effectiveBackgroundColor = states.contains(WidgetState.scrolledUnder) + ? scrolledUnderBackground + : backgroundColor; + + final Color foregroundColor = + widget.foregroundColor ?? appBarTheme.foregroundColor ?? defaults.foregroundColor!; + + final double elevation = widget.elevation ?? appBarTheme.elevation ?? defaults.elevation!; + + final double effectiveElevation = states.contains(WidgetState.scrolledUnder) + ? widget.scrolledUnderElevation ?? + appBarTheme.scrolledUnderElevation ?? + defaults.scrolledUnderElevation ?? + elevation + : elevation; + + IconThemeData overallIconTheme = + widget.iconTheme ?? + appBarTheme.iconTheme ?? + defaults.iconTheme!.copyWith(color: foregroundColor); + + final Color? actionForegroundColor = widget.foregroundColor ?? appBarTheme.foregroundColor; + IconThemeData actionsIconTheme = + widget.actionsIconTheme ?? + appBarTheme.actionsIconTheme ?? + widget.iconTheme ?? + appBarTheme.iconTheme ?? + defaults.actionsIconTheme?.copyWith(color: actionForegroundColor) ?? + overallIconTheme; + + final EdgeInsetsGeometry actionsPadding = + widget.actionsPadding ?? appBarTheme.actionsPadding ?? defaults.actionsPadding!; + + TextStyle? toolbarTextStyle = + widget.toolbarTextStyle ?? + appBarTheme.toolbarTextStyle ?? + defaults.toolbarTextStyle?.copyWith(color: foregroundColor); + + TextStyle? titleTextStyle = + widget.titleTextStyle ?? + appBarTheme.titleTextStyle ?? + defaults.titleTextStyle?.copyWith(color: foregroundColor); + + if (widget.toolbarOpacity != 1.0) { + final double opacity = const Interval( + 0.25, + 1.0, + curve: Curves.fastOutSlowIn, + ).transform(widget.toolbarOpacity); + if (titleTextStyle?.color != null) { + titleTextStyle = titleTextStyle!.copyWith( + color: titleTextStyle.color!.withOpacity(opacity), + ); + } + if (toolbarTextStyle?.color != null) { + toolbarTextStyle = toolbarTextStyle!.copyWith( + color: toolbarTextStyle.color!.withOpacity(opacity), + ); + } + overallIconTheme = overallIconTheme.copyWith( + opacity: opacity * (overallIconTheme.opacity ?? 1.0), + ); + actionsIconTheme = actionsIconTheme.copyWith( + opacity: opacity * (actionsIconTheme.opacity ?? 1.0), + ); + } + + Widget? leading = widget.leading; + if (leading == null && widget.automaticallyImplyLeading) { + if (hasDrawer) { + leading = DrawerButton(style: IconButton.styleFrom(iconSize: overallIconTheme.size ?? 24)); + } else if (parentRoute?.impliesAppBarDismissal ?? false) { + leading = useCloseButton ? const CloseButton() : const BackButton(); + } + } + if (leading != null) { + if (theme.useMaterial3) { + final IconButtonThemeData effectiveIconButtonTheme; + + // This comparison is to check if there is a custom [overallIconTheme]. If true, it means that no + // custom [overallIconTheme] is provided, so [iconButtonTheme] is applied. Otherwise, we generate + // a new [IconButtonThemeData] based on the values from [overallIconTheme]. If [iconButtonTheme] only + // has null values, the default [overallIconTheme] will be applied below by [IconTheme.merge] + if (overallIconTheme == defaults.iconTheme) { + effectiveIconButtonTheme = iconButtonTheme; + } else { + // The [IconButton.styleFrom] method is used to generate a correct [overlayColor] based on the [foregroundColor]. + final ButtonStyle leadingIconButtonStyle = IconButton.styleFrom( + foregroundColor: overallIconTheme.color, + iconSize: overallIconTheme.size, + ); + + effectiveIconButtonTheme = IconButtonThemeData( + style: iconButtonTheme.style?.copyWith( + foregroundColor: leadingIconButtonStyle.foregroundColor, + overlayColor: leadingIconButtonStyle.overlayColor, + iconSize: leadingIconButtonStyle.iconSize, + ), + ); + } + + leading = IconButtonTheme( + data: effectiveIconButtonTheme, + child: leading is IconButton ? Center(child: leading) : leading, + ); + + // Based on the Material Design 3 specs, the leading IconButton should have + // a size of 48x48, and a highlight size of 40x40. Users can also put other + // type of widgets on leading with the original config. + leading = ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: widget.leadingWidth ?? appBarTheme.leadingWidth ?? _kLeadingWidth, + ), + child: leading, + ); + } else { + leading = ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: widget.leadingWidth ?? appBarTheme.leadingWidth ?? _kLeadingWidth, + ), + child: leading, + ); + } + } + + Widget? title = widget.title; + if (title != null) { + title = _AppBarTitleBox(child: title); + if (!widget.excludeHeaderSemantics) { + title = Semantics( + namesRoute: switch (defaultTargetPlatform) { + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => true, + TargetPlatform.iOS || TargetPlatform.macOS => null, + }, + header: true, + child: title, + ); + } + + title = DefaultTextStyle( + style: titleTextStyle!, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: title, + ); + + // Set maximum text scale factor to [_kMaxTitleTextScaleFactor] for the + // title to keep the visual hierarchy the same even with larger font + // sizes. To opt out, wrap the [title] widget in a [MediaQuery] widget + // with a different `TextScaler`. + title = MediaQuery.withClampedTextScaling( + maxScaleFactor: _kMaxTitleTextScaleFactor, + child: title, + ); + } + + Widget? actions; + if (widget.actions != null && widget.actions!.isNotEmpty) { + actions = Padding( + padding: actionsPadding, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: theme.useMaterial3 + ? CrossAxisAlignment.center + : CrossAxisAlignment.stretch, + children: widget.actions!, + ), + ); + } else if (hasEndDrawer && widget.automaticallyImplyActions) { + actions = EndDrawerButton(style: IconButton.styleFrom(iconSize: overallIconTheme.size ?? 24)); + } + + // Allow the trailing actions to have their own theme if necessary. + if (actions != null) { + final IconButtonThemeData effectiveActionsIconButtonTheme; + if (actionsIconTheme == defaults.actionsIconTheme) { + effectiveActionsIconButtonTheme = iconButtonTheme; + } else { + final ButtonStyle actionsIconButtonStyle = IconButton.styleFrom( + foregroundColor: actionsIconTheme.color, + iconSize: actionsIconTheme.size, + ); + + effectiveActionsIconButtonTheme = IconButtonThemeData( + style: iconButtonTheme.style?.copyWith( + foregroundColor: actionsIconButtonStyle.foregroundColor, + overlayColor: actionsIconButtonStyle.overlayColor, + iconSize: actionsIconButtonStyle.iconSize, + ), + ); + } + + actions = IconButtonTheme( + data: effectiveActionsIconButtonTheme, + child: IconTheme.merge(data: actionsIconTheme, child: actions), + ); + } + + final Widget toolbar = NavigationToolbar( + leading: leading, + middle: title, + trailing: actions, + centerMiddle: widget._getEffectiveCenterTitle(theme, appBarTheme), + middleSpacing: + widget.titleSpacing ?? appBarTheme.titleSpacing ?? NavigationToolbar.kMiddleSpacing, + ); + + // If the toolbar is allocated less than toolbarHeight make it + // appear to scroll upwards within its shrinking container. + Widget appBar = ClipRect( + clipBehavior: widget.clipBehavior ?? Clip.hardEdge, + child: CustomSingleChildLayout( + delegate: _ToolbarContainerLayout(toolbarHeight), + child: IconTheme.merge( + data: overallIconTheme, + child: DefaultTextStyle(style: toolbarTextStyle!, child: toolbar), + ), + ), + ); + if (widget.bottom != null) { + appBar = Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: toolbarHeight), + child: appBar, + ), + ), + if (widget.bottomOpacity == 1.0) + widget.bottom! + else + Opacity( + opacity: const Interval( + 0.25, + 1.0, + curve: Curves.fastOutSlowIn, + ).transform(widget.bottomOpacity), + child: widget.bottom, + ), + ], + ); + } + + // The padding applies to the toolbar and tabbar, not the flexible space. + if (widget.primary) { + appBar = SafeArea(bottom: false, child: appBar); + } + + appBar = Align(alignment: Alignment.topCenter, child: appBar); + + if (widget.flexibleSpace != null) { + appBar = Stack( + fit: StackFit.passthrough, + children: <Widget>[ + Semantics( + sortKey: widget.useDefaultSemanticsOrder ? const OrdinalSortKey(1.0) : null, + explicitChildNodes: true, + child: widget.flexibleSpace, + ), + Semantics( + sortKey: widget.useDefaultSemanticsOrder ? const OrdinalSortKey(0.0) : null, + explicitChildNodes: true, + // Creates a material widget to prevent the flexibleSpace from + // obscuring the ink splashes produced by appBar children. + child: Material(type: MaterialType.transparency, child: appBar), + ), + ], + ); + } + + final SystemUiOverlayStyle overlayStyle = + widget.systemOverlayStyle ?? + appBarTheme.systemOverlayStyle ?? + defaults.systemOverlayStyle ?? + _systemOverlayStyleForBrightness( + ThemeData.estimateBrightnessForColor(effectiveBackgroundColor), + // Make the status bar transparent for M3 so the elevation overlay + // color is picked up by the statusbar. + theme.useMaterial3 ? const Color(0x00000000) : null, + ); + + return Semantics( + container: true, + child: AnnotatedRegion<SystemUiOverlayStyle>( + value: overlayStyle, + child: Material( + color: theme.useMaterial3 ? effectiveBackgroundColor : backgroundColor, + elevation: effectiveElevation, + type: widget.forceMaterialTransparency ? MaterialType.transparency : MaterialType.canvas, + shadowColor: widget.shadowColor ?? appBarTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: + widget.surfaceTintColor ?? + appBarTheme.surfaceTintColor + // M3 `defaults.surfaceTint` is Colors.transparent now. It is not used + // here because otherwise, it will cause breaking change for + // `scrolledUnderElevation`. + ?? + (theme.useMaterial3 ? theme.colorScheme.surfaceTint : null), + shape: widget.shape ?? appBarTheme.shape ?? defaults.shape, + animateColor: widget.animateColor, + child: Semantics(explicitChildNodes: true, child: appBar), + ), + ), + ); + } +} + +class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { + _SliverAppBarDelegate({ + required this.leading, + required this.automaticallyImplyLeading, + required this.title, + required this.actions, + required this.automaticallyImplyActions, + required this.flexibleSpace, + required this.bottom, + required this.elevation, + required this.scrolledUnderElevation, + required this.shadowColor, + required this.surfaceTintColor, + required this.forceElevated, + required this.backgroundColor, + required this.foregroundColor, + required this.iconTheme, + required this.actionsIconTheme, + required this.primary, + required this.centerTitle, + required this.excludeHeaderSemantics, + required this.titleSpacing, + required this.expandedHeight, + required this.collapsedHeight, + required this.topPadding, + required this.floating, + required this.pinned, + required this.vsync, + required this.snapConfiguration, + required this.stretchConfiguration, + required this.showOnScreenConfiguration, + required this.shape, + required this.toolbarHeight, + required this.leadingWidth, + required this.toolbarTextStyle, + required this.titleTextStyle, + required this.systemOverlayStyle, + required this.forceMaterialTransparency, + required this.useDefaultSemanticsOrder, + required this.clipBehavior, + required this.variant, + required this.accessibleNavigation, + required this.actionsPadding, + }) : assert(primary || topPadding == 0.0), + _bottomHeight = bottom?.preferredSize.height ?? 0.0; + + final Widget? leading; + final bool automaticallyImplyLeading; + final Widget? title; + final List<Widget>? actions; + final bool automaticallyImplyActions; + final Widget? flexibleSpace; + final PreferredSizeWidget? bottom; + final double? elevation; + final double? scrolledUnderElevation; + final Color? shadowColor; + final Color? surfaceTintColor; + final bool forceElevated; + final Color? backgroundColor; + final Color? foregroundColor; + final IconThemeData? iconTheme; + final IconThemeData? actionsIconTheme; + final bool primary; + final bool? centerTitle; + final bool excludeHeaderSemantics; + final double? titleSpacing; + final double? expandedHeight; + final double collapsedHeight; + final double topPadding; + final bool floating; + final bool pinned; + final ShapeBorder? shape; + final double? toolbarHeight; + final double? leadingWidth; + final TextStyle? toolbarTextStyle; + final TextStyle? titleTextStyle; + final SystemUiOverlayStyle? systemOverlayStyle; + final double _bottomHeight; + final bool forceMaterialTransparency; + final bool useDefaultSemanticsOrder; + final Clip? clipBehavior; + final _SliverAppVariant variant; + final bool accessibleNavigation; + final EdgeInsetsGeometry? actionsPadding; + + @override + double get minExtent => collapsedHeight; + + @override + double get maxExtent => math.max( + topPadding + (expandedHeight ?? (toolbarHeight ?? kToolbarHeight) + _bottomHeight), + minExtent, + ); + + @override + final TickerProvider vsync; + + @override + final FloatingHeaderSnapConfiguration? snapConfiguration; + + @override + final OverScrollHeaderStretchConfiguration? stretchConfiguration; + + @override + final PersistentHeaderShowOnScreenConfiguration? showOnScreenConfiguration; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + final double visibleMainHeight = maxExtent - shrinkOffset - topPadding; + final double extraToolbarHeight = math.max( + minExtent - _bottomHeight - topPadding - (toolbarHeight ?? kToolbarHeight), + 0.0, + ); + final double visibleToolbarHeight = visibleMainHeight - _bottomHeight - extraToolbarHeight; + + final bool isScrolledUnder = + overlapsContent || forceElevated || (pinned && shrinkOffset > maxExtent - minExtent); + final bool isPinnedWithOpacityFade = + pinned && floating && bottom != null && extraToolbarHeight == 0.0; + final double toolbarOpacity = !accessibleNavigation && (!pinned || isPinnedWithOpacityFade) + ? clampDouble(visibleToolbarHeight / (toolbarHeight ?? kToolbarHeight), 0.0, 1.0) + : 1.0; + final Widget? effectiveTitle = switch (variant) { + _SliverAppVariant.small => title, + _SliverAppVariant.medium || _SliverAppVariant.large => AnimatedOpacity( + opacity: isScrolledUnder ? 1 : 0, + duration: const Duration(milliseconds: 500), + curve: const Cubic(0.2, 0.0, 0.0, 1.0), + child: title, + ), + }; + + final Widget appBar = FlexibleSpaceBar.createSettings( + minExtent: minExtent, + maxExtent: maxExtent, + currentExtent: math.max(minExtent, maxExtent - shrinkOffset), + toolbarOpacity: toolbarOpacity, + isScrolledUnder: isScrolledUnder, + hasLeading: leading != null || automaticallyImplyLeading, + child: AppBar( + clipBehavior: clipBehavior, + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + title: effectiveTitle, + actions: actions, + automaticallyImplyActions: automaticallyImplyActions, + flexibleSpace: (title == null && flexibleSpace != null && !excludeHeaderSemantics) + ? Semantics(header: true, child: flexibleSpace) + : flexibleSpace, + bottom: bottom, + elevation: isScrolledUnder ? elevation : 0.0, + scrolledUnderElevation: scrolledUnderElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + primary: primary, + centerTitle: centerTitle, + excludeHeaderSemantics: excludeHeaderSemantics, + titleSpacing: titleSpacing, + shape: shape, + toolbarOpacity: toolbarOpacity, + bottomOpacity: pinned ? 1.0 : clampDouble(visibleMainHeight / _bottomHeight, 0.0, 1.0), + toolbarHeight: toolbarHeight, + leadingWidth: leadingWidth, + toolbarTextStyle: toolbarTextStyle, + titleTextStyle: titleTextStyle, + systemOverlayStyle: systemOverlayStyle, + forceMaterialTransparency: forceMaterialTransparency, + useDefaultSemanticsOrder: useDefaultSemanticsOrder, + actionsPadding: actionsPadding, + ), + ); + return appBar; + } + + @override + bool shouldRebuild(covariant _SliverAppBarDelegate oldDelegate) { + return leading != oldDelegate.leading || + automaticallyImplyLeading != oldDelegate.automaticallyImplyLeading || + title != oldDelegate.title || + actions != oldDelegate.actions || + automaticallyImplyActions != oldDelegate.automaticallyImplyActions || + flexibleSpace != oldDelegate.flexibleSpace || + bottom != oldDelegate.bottom || + _bottomHeight != oldDelegate._bottomHeight || + elevation != oldDelegate.elevation || + shadowColor != oldDelegate.shadowColor || + backgroundColor != oldDelegate.backgroundColor || + foregroundColor != oldDelegate.foregroundColor || + iconTheme != oldDelegate.iconTheme || + actionsIconTheme != oldDelegate.actionsIconTheme || + primary != oldDelegate.primary || + centerTitle != oldDelegate.centerTitle || + titleSpacing != oldDelegate.titleSpacing || + expandedHeight != oldDelegate.expandedHeight || + topPadding != oldDelegate.topPadding || + pinned != oldDelegate.pinned || + floating != oldDelegate.floating || + vsync != oldDelegate.vsync || + snapConfiguration != oldDelegate.snapConfiguration || + stretchConfiguration != oldDelegate.stretchConfiguration || + showOnScreenConfiguration != oldDelegate.showOnScreenConfiguration || + forceElevated != oldDelegate.forceElevated || + toolbarHeight != oldDelegate.toolbarHeight || + leadingWidth != oldDelegate.leadingWidth || + toolbarTextStyle != oldDelegate.toolbarTextStyle || + titleTextStyle != oldDelegate.titleTextStyle || + systemOverlayStyle != oldDelegate.systemOverlayStyle || + forceMaterialTransparency != oldDelegate.forceMaterialTransparency || + useDefaultSemanticsOrder != oldDelegate.useDefaultSemanticsOrder || + accessibleNavigation != oldDelegate.accessibleNavigation || + actionsPadding != oldDelegate.actionsPadding; + } + + @override + String toString() { + return '${describeIdentity(this)}(topPadding: ${topPadding.toStringAsFixed(1)}, bottomHeight: ${_bottomHeight.toStringAsFixed(1)}, ...)'; + } +} + +/// A Material Design app bar that integrates with a [CustomScrollView]. +/// +/// An app bar consists of a toolbar and potentially other widgets, such as a +/// [TabBar] and a [FlexibleSpaceBar]. App bars typically expose one or more +/// common actions with [IconButton]s which are optionally followed by a +/// [PopupMenuButton] for less common operations. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=R9C5KMJKluE} +/// +/// Sliver app bars are typically used as the first child of a +/// [CustomScrollView], which lets the app bar integrate with the scroll view so +/// that it can vary in height according to the scroll offset or float above the +/// other content in the scroll view. For a fixed-height app bar at the top of +/// the screen see [AppBar], which is used in the [Scaffold.appBar] slot. +/// +/// The AppBar displays the toolbar widgets, [leading], [title], and +/// [actions], above the [bottom] (if any). If a [flexibleSpace] widget is +/// specified then it is stacked behind the toolbar and the bottom widget. +/// +/// {@tool snippet} +/// +/// This is an example that could be included in a [CustomScrollView]'s +/// [CustomScrollView.slivers] list: +/// +/// ```dart +/// SliverAppBar( +/// expandedHeight: 150.0, +/// flexibleSpace: const FlexibleSpaceBar( +/// title: Text('Available seats'), +/// ), +/// actions: <Widget>[ +/// IconButton( +/// icon: const Icon(Icons.add_circle), +/// tooltip: 'Add new entry', +/// onPressed: () { /* ... */ }, +/// ), +/// ] +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// Here is an example of [SliverAppBar] when using [stretch] and [onStretchTrigger]. +/// +/// ** See code in examples/api/lib/material/app_bar/sliver_app_bar.4.dart ** +/// {@end-tool} +/// +/// +/// {@tool dartpad} +/// This sample shows a [SliverAppBar] and its behavior when using the +/// [pinned], [snap] and [floating] parameters. +/// +/// ** See code in examples/api/lib/material/app_bar/sliver_app_bar.1.dart ** +/// {@end-tool} +/// +/// ## Animated Examples +/// +/// The following animations show how app bars with different configurations +/// behave when a user scrolls up and then down again. +/// +/// * App bar with [floating]: false, [pinned]: false, [snap]: false: +/// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar.mp4} +/// +/// * App bar with [floating]: true, [pinned]: false, [snap]: false: +/// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_floating.mp4} +/// +/// * App bar with [floating]: true, [pinned]: false, [snap]: true: +/// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_floating_snap.mp4} +/// +/// * App bar with [floating]: true, [pinned]: true, [snap]: false: +/// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_pinned_floating.mp4} +/// +/// * App bar with [floating]: true, [pinned]: true, [snap]: true: +/// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_pinned_floating_snap.mp4} +/// +/// * App bar with [floating]: false, [pinned]: true, [snap]: false: +/// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_pinned.mp4} +/// +/// The property [snap] can only be set to true if [floating] is also true. +/// +/// See also: +/// +/// * [CustomScrollView], which integrates the [SliverAppBar] into its +/// scrolling. +/// * [AppBar], which is a fixed-height app bar for use in [Scaffold.appBar]. +/// * [TabBar], which is typically placed in the [bottom] slot of the [AppBar] +/// if the screen has multiple pages arranged in tabs. +/// * [IconButton], which is used with [actions] to show buttons on the app bar. +/// * [PopupMenuButton], to show a popup menu on the app bar, via [actions]. +/// * [FlexibleSpaceBar], which is used with [flexibleSpace] when the app bar +/// can expand and collapse. +/// * <https://material.io/design/components/app-bars-top.html> +class SliverAppBar extends StatefulWidget { + /// Creates a Material Design app bar that can be placed in a [CustomScrollView]. + const SliverAppBar({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.actions, + this.automaticallyImplyActions = true, + this.flexibleSpace, + this.bottom, + this.elevation, + this.scrolledUnderElevation, + this.shadowColor, + this.surfaceTintColor, + this.forceElevated = false, + this.backgroundColor, + this.foregroundColor, + this.iconTheme, + this.actionsIconTheme, + this.primary = true, + this.centerTitle, + this.excludeHeaderSemantics = false, + this.titleSpacing, + this.collapsedHeight, + this.expandedHeight, + this.floating = false, + this.pinned = false, + this.snap = false, + this.stretch = false, + this.stretchTriggerOffset = 100.0, + this.onStretchTrigger, + this.shape, + this.toolbarHeight = kToolbarHeight, + this.leadingWidth, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.forceMaterialTransparency = false, + this.useDefaultSemanticsOrder = true, + this.clipBehavior, + this.actionsPadding, + }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), + assert(stretchTriggerOffset > 0.0), + assert( + collapsedHeight == null || collapsedHeight >= toolbarHeight, + 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].', + ), + _variant = _SliverAppVariant.small; + + /// Creates a Material Design medium top app bar that can be placed + /// in a [CustomScrollView]. + /// + /// Returns a [SliverAppBar] configured with appropriate defaults + /// for a medium top app bar as defined in Material 3. It starts fully + /// expanded with the title in an area underneath the main row of icons. + /// When the [CustomScrollView] is scrolled, the title will be scrolled + /// under the main row. When it is fully collapsed, a smaller version of the + /// title will fade in on the main row. The reverse will happen if it is + /// expanded again. + /// + /// {@tool dartpad} + /// This sample shows how to use [SliverAppBar.medium] in a [CustomScrollView]. + /// + /// ** See code in examples/api/lib/material/app_bar/sliver_app_bar.2.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [AppBar], for a small or center-aligned top app bar. + /// * [SliverAppBar.large], for a large top app bar. + /// * https://m3.material.io/components/top-app-bar/overview, the Material 3 + /// app bar specification. + const SliverAppBar.medium({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.actions, + this.automaticallyImplyActions = true, + this.flexibleSpace, + this.bottom, + this.elevation, + this.scrolledUnderElevation, + this.shadowColor, + this.surfaceTintColor, + this.forceElevated = false, + this.backgroundColor, + this.foregroundColor, + this.iconTheme, + this.actionsIconTheme, + this.primary = true, + this.centerTitle, + this.excludeHeaderSemantics = false, + this.titleSpacing, + this.collapsedHeight, + this.expandedHeight, + this.floating = false, + this.pinned = true, + this.snap = false, + this.stretch = false, + this.stretchTriggerOffset = 100.0, + this.onStretchTrigger, + this.shape, + this.toolbarHeight = _MediumScrollUnderFlexibleConfig.collapsedHeight, + this.leadingWidth, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.forceMaterialTransparency = false, + this.useDefaultSemanticsOrder = true, + this.clipBehavior, + this.actionsPadding, + }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), + assert(stretchTriggerOffset > 0.0), + assert( + collapsedHeight == null || collapsedHeight >= toolbarHeight, + 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].', + ), + _variant = _SliverAppVariant.medium; + + /// Creates a Material Design large top app bar that can be placed + /// in a [CustomScrollView]. + /// + /// Returns a [SliverAppBar] configured with appropriate defaults + /// for a large top app bar as defined in Material 3. It starts fully + /// expanded with the title in an area underneath the main row of icons. + /// When the [CustomScrollView] is scrolled, the title will be scrolled + /// under the main row. When it is fully collapsed, a smaller version of the + /// title will fade in on the main row. The reverse will happen if it is + /// expanded again. + /// + /// {@tool dartpad} + /// This sample shows how to use [SliverAppBar.large] in a [CustomScrollView]. + /// + /// ** See code in examples/api/lib/material/app_bar/sliver_app_bar.3.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [AppBar], for a small or center-aligned top app bar. + /// * [SliverAppBar.medium], for a medium top app bar. + /// * https://m3.material.io/components/top-app-bar/overview, the Material 3 + /// app bar specification. + const SliverAppBar.large({ + super.key, + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.actions, + this.automaticallyImplyActions = true, + this.flexibleSpace, + this.bottom, + this.elevation, + this.scrolledUnderElevation, + this.shadowColor, + this.surfaceTintColor, + this.forceElevated = false, + this.backgroundColor, + this.foregroundColor, + this.iconTheme, + this.actionsIconTheme, + this.primary = true, + this.centerTitle, + this.excludeHeaderSemantics = false, + this.titleSpacing, + this.collapsedHeight, + this.expandedHeight, + this.floating = false, + this.pinned = true, + this.snap = false, + this.stretch = false, + this.stretchTriggerOffset = 100.0, + this.onStretchTrigger, + this.shape, + this.toolbarHeight = _LargeScrollUnderFlexibleConfig.collapsedHeight, + this.leadingWidth, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.forceMaterialTransparency = false, + this.useDefaultSemanticsOrder = true, + this.clipBehavior, + this.actionsPadding, + }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), + assert(stretchTriggerOffset > 0.0), + assert( + collapsedHeight == null || collapsedHeight >= toolbarHeight, + 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].', + ), + _variant = _SliverAppVariant.large; + + /// {@macro flutter.material.appbar.leading} + /// + /// This property is used to configure an [AppBar]. + final Widget? leading; + + /// {@macro flutter.material.appbar.automaticallyImplyLeading} + /// + /// This property is used to configure an [AppBar]. + final bool automaticallyImplyLeading; + + /// {@macro flutter.material.appbar.title} + /// + /// This property is used to configure an [AppBar]. + final Widget? title; + + /// {@macro flutter.material.appbar.actions} + /// + /// This property is used to configure an [AppBar]. + final List<Widget>? actions; + + /// {@macro flutter.material.appbar.automaticallyImplyActions} + /// + /// This property is used to configure an [AppBar]. + final bool automaticallyImplyActions; + + /// {@macro flutter.material.appbar.flexibleSpace} + /// + /// This property is used to configure an [AppBar]. + final Widget? flexibleSpace; + + /// {@macro flutter.material.appbar.bottom} + /// + /// This property is used to configure an [AppBar]. + final PreferredSizeWidget? bottom; + + /// {@macro flutter.material.appbar.elevation} + /// + /// This property is used to configure an [AppBar]. + final double? elevation; + + /// {@macro flutter.material.appbar.scrolledUnderElevation} + /// + /// This property is used to configure an [AppBar]. + final double? scrolledUnderElevation; + + /// {@macro flutter.material.appbar.shadowColor} + /// + /// This property is used to configure an [AppBar]. + final Color? shadowColor; + + /// {@macro flutter.material.appbar.surfaceTintColor} + /// + /// This property is used to configure an [AppBar]. + final Color? surfaceTintColor; + + /// Whether to show the shadow appropriate for the [elevation] even if the + /// content is not scrolled under the [AppBar]. + /// + /// Defaults to false, meaning that the [elevation] is only applied when the + /// [AppBar] is being displayed over content that is scrolled under it. + /// + /// When set to true, the [elevation] is applied regardless. + /// + /// Ignored when [elevation] is zero. + final bool forceElevated; + + /// {@macro flutter.material.appbar.backgroundColor} + /// + /// This property is used to configure an [AppBar]. + final Color? backgroundColor; + + /// {@macro flutter.material.appbar.foregroundColor} + /// + /// This property is used to configure an [AppBar]. + final Color? foregroundColor; + + /// {@macro flutter.material.appbar.iconTheme} + /// + /// This property is used to configure an [AppBar]. + final IconThemeData? iconTheme; + + /// {@macro flutter.material.appbar.actionsIconTheme} + /// + /// This property is used to configure an [AppBar]. + final IconThemeData? actionsIconTheme; + + /// {@macro flutter.material.appbar.primary} + /// + /// This property is used to configure an [AppBar]. + final bool primary; + + /// {@macro flutter.material.appbar.centerTitle} + /// + /// This property is used to configure an [AppBar]. + final bool? centerTitle; + + /// {@macro flutter.material.appbar.excludeHeaderSemantics} + /// + /// This property is used to configure an [AppBar]. + final bool excludeHeaderSemantics; + + /// {@macro flutter.material.appbar.titleSpacing} + /// + /// This property is used to configure an [AppBar]. + final double? titleSpacing; + + /// Defines the height of the app bar when it is collapsed. + /// + /// By default, the collapsed height is [toolbarHeight]. If [bottom] widget is + /// specified, then its height from [PreferredSizeWidget.preferredSize] is + /// added to the height. If [primary] is true, then the [MediaQuery] top + /// padding, [EdgeInsets.top] of [MediaQueryData.padding], is added as well. + /// + /// If [pinned] and [floating] are true, with [bottom] set, the default + /// collapsed height is only the height of [PreferredSizeWidget.preferredSize] + /// with the [MediaQuery] top padding. + final double? collapsedHeight; + + /// The size of the app bar when it is fully expanded. + /// + /// By default, the total height of the toolbar and the bottom widget (if + /// any). If a [flexibleSpace] widget is specified this height should be big + /// enough to accommodate whatever that widget contains. + /// + /// This does not include the status bar height (which will be automatically + /// included if [primary] is true). + final double? expandedHeight; + + /// Whether the app bar should become visible as soon as the user scrolls + /// towards the app bar. + /// + /// Otherwise, the user will need to scroll near the top of the scroll view to + /// reveal the app bar. + /// + /// If [snap] is true then a scroll that exposes the app bar will trigger an + /// animation that slides the entire app bar into view. Similarly if a scroll + /// dismisses the app bar, the animation will slide it completely out of view. + /// + /// ## Animated Examples + /// + /// The following animations show how the app bar changes its scrolling + /// behavior based on the value of this property. + /// + /// * App bar with [floating] set to false: + /// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar.mp4} + /// * App bar with [floating] set to true: + /// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_floating.mp4} + /// + /// See also: + /// + /// * [SliverAppBar] for more animated examples of how this property changes the + /// behavior of the app bar in combination with [pinned] and [snap]. + final bool floating; + + /// Whether the app bar should remain visible at the start of the scroll view. + /// + /// The app bar can still expand and contract as the user scrolls, but it will + /// remain visible rather than being scrolled out of view. + /// + /// ## Animated Examples + /// + /// The following animations show how the app bar changes its scrolling + /// behavior based on the value of this property. + /// + /// * App bar with [pinned] set to false: + /// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar.mp4} + /// * App bar with [pinned] set to true: + /// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_pinned.mp4} + /// + /// See also: + /// + /// * [SliverAppBar] for more animated examples of how this property changes the + /// behavior of the app bar in combination with [floating]. + final bool pinned; + + /// {@macro flutter.material.appbar.shape} + /// + /// This property is used to configure an [AppBar]. + final ShapeBorder? shape; + + /// If [snap] and [floating] are true then the floating app bar will "snap" + /// into view. + /// + /// If [snap] is true then a scroll that exposes the floating app bar will + /// trigger an animation that slides the entire app bar into view. Similarly + /// if a scroll dismisses the app bar, the animation will slide the app bar + /// completely out of view. Additionally, setting [snap] to true will fully + /// expand the floating app bar when the framework tries to reveal the + /// contents of the app bar by calling [RenderObject.showOnScreen]. For + /// example, when a [TextField] in the floating app bar gains focus, if [snap] + /// is true, the framework will always fully expand the floating app bar, in + /// order to reveal the focused [TextField]. + /// + /// Snapping only applies when the app bar is floating, not when the app bar + /// appears at the top of its scroll view. + /// + /// ## Animated Examples + /// + /// The following animations show how the app bar changes its scrolling + /// behavior based on the value of this property. + /// + /// * App bar with [snap] set to false: + /// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_floating.mp4} + /// * App bar with [snap] set to true: + /// {@animation 476 400 https://flutter.github.io/assets-for-api-docs/assets/material/app_bar_floating_snap.mp4} + /// + /// See also: + /// + /// * [SliverAppBar] for more animated examples of how this property changes the + /// behavior of the app bar in combination with [pinned] and [floating]. + final bool snap; + + /// Whether the app bar should stretch to fill the over-scroll area. + /// + /// The app bar can still expand and contract as the user scrolls, but it will + /// also stretch when the user over-scrolls. + final bool stretch; + + /// The offset of overscroll required to activate [onStretchTrigger]. + /// + /// This defaults to 100.0. + final double stretchTriggerOffset; + + /// The callback function to be executed when a user over-scrolls to the + /// offset specified by [stretchTriggerOffset]. + final AsyncCallback? onStretchTrigger; + + /// {@macro flutter.material.appbar.toolbarHeight} + /// + /// This property is used to configure an [AppBar]. + final double toolbarHeight; + + /// {@macro flutter.material.appbar.leadingWidth} + /// + /// This property is used to configure an [AppBar]. + final double? leadingWidth; + + /// {@macro flutter.material.appbar.toolbarTextStyle} + /// + /// This property is used to configure an [AppBar]. + final TextStyle? toolbarTextStyle; + + /// {@macro flutter.material.appbar.titleTextStyle} + /// + /// This property is used to configure an [AppBar]. + final TextStyle? titleTextStyle; + + /// {@macro flutter.material.appbar.systemOverlayStyle} + /// + /// This property is used to configure an [AppBar]. + final SystemUiOverlayStyle? systemOverlayStyle; + + /// {@macro flutter.material.appbar.forceMaterialTransparency} + /// + /// This property is used to configure an [AppBar]. + final bool forceMaterialTransparency; + + /// {@macro flutter.material.appbar.useDefaultSemanticsOrder} + /// + /// This property is used to configure an [AppBar]. + final bool useDefaultSemanticsOrder; + + /// {@macro flutter.material.Material.clipBehavior} + final Clip? clipBehavior; + + /// {@macro flutter.material.appbar.actionsPadding} + /// + /// This property is used to configure an [AppBar]. + final EdgeInsetsGeometry? actionsPadding; + + final _SliverAppVariant _variant; + + @override + State<SliverAppBar> createState() => _SliverAppBarState(); +} + +// This class is only Stateful because it owns the TickerProvider used +// by the floating appbar snap animation (via FloatingHeaderSnapConfiguration). +class _SliverAppBarState extends State<SliverAppBar> with TickerProviderStateMixin { + FloatingHeaderSnapConfiguration? _snapConfiguration; + OverScrollHeaderStretchConfiguration? _stretchConfiguration; + PersistentHeaderShowOnScreenConfiguration? _showOnScreenConfiguration; + + void _updateSnapConfiguration() { + if (widget.snap && widget.floating) { + _snapConfiguration = FloatingHeaderSnapConfiguration( + curve: Curves.easeOut, + duration: const Duration(milliseconds: 200), + ); + } else { + _snapConfiguration = null; + } + + _showOnScreenConfiguration = widget.floating & widget.snap + ? const PersistentHeaderShowOnScreenConfiguration(minShowOnScreenExtent: double.infinity) + : null; + } + + void _updateStretchConfiguration() { + if (widget.stretch) { + _stretchConfiguration = OverScrollHeaderStretchConfiguration( + stretchTriggerOffset: widget.stretchTriggerOffset, + onStretchTrigger: widget.onStretchTrigger, + ); + } else { + _stretchConfiguration = null; + } + } + + @override + void initState() { + super.initState(); + _updateSnapConfiguration(); + _updateStretchConfiguration(); + } + + @override + void didUpdateWidget(SliverAppBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.snap != oldWidget.snap || widget.floating != oldWidget.floating) { + _updateSnapConfiguration(); + } + if (widget.stretch != oldWidget.stretch) { + _updateStretchConfiguration(); + } + } + + @override + Widget build(BuildContext context) { + assert(!widget.primary || debugCheckHasMediaQuery(context)); + final double bottomHeight = widget.bottom?.preferredSize.height ?? 0.0; + final double topPadding = widget.primary ? MediaQuery.paddingOf(context).top : 0.0; + final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null) + ? (widget.collapsedHeight ?? 0.0) + bottomHeight + topPadding + : (widget.collapsedHeight ?? widget.toolbarHeight) + bottomHeight + topPadding; + final double? effectiveExpandedHeight; + final double effectiveCollapsedHeight; + final Widget? effectiveFlexibleSpace; + switch (widget._variant) { + case _SliverAppVariant.small: + effectiveExpandedHeight = widget.expandedHeight; + effectiveCollapsedHeight = collapsedHeight; + effectiveFlexibleSpace = widget.flexibleSpace; + case _SliverAppVariant.medium: + effectiveExpandedHeight = + widget.expandedHeight ?? _MediumScrollUnderFlexibleConfig.expandedHeight + bottomHeight; + effectiveCollapsedHeight = + widget.collapsedHeight ?? + topPadding + _MediumScrollUnderFlexibleConfig.collapsedHeight + bottomHeight; + effectiveFlexibleSpace = + widget.flexibleSpace ?? + _ScrollUnderFlexibleSpace( + title: widget.title, + foregroundColor: widget.foregroundColor, + configBuilder: _MediumScrollUnderFlexibleConfig.new, + titleTextStyle: widget.titleTextStyle, + bottomHeight: bottomHeight, + ); + case _SliverAppVariant.large: + effectiveExpandedHeight = + widget.expandedHeight ?? _LargeScrollUnderFlexibleConfig.expandedHeight + bottomHeight; + effectiveCollapsedHeight = + widget.collapsedHeight ?? + topPadding + _LargeScrollUnderFlexibleConfig.collapsedHeight + bottomHeight; + effectiveFlexibleSpace = + widget.flexibleSpace ?? + _ScrollUnderFlexibleSpace( + title: widget.title, + foregroundColor: widget.foregroundColor, + configBuilder: _LargeScrollUnderFlexibleConfig.new, + titleTextStyle: widget.titleTextStyle, + bottomHeight: bottomHeight, + ); + } + + return MediaQuery.removePadding( + context: context, + removeBottom: true, + child: SliverPersistentHeader( + floating: widget.floating, + pinned: widget.pinned, + delegate: _SliverAppBarDelegate( + vsync: this, + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + title: widget.title, + actions: widget.actions, + automaticallyImplyActions: widget.automaticallyImplyActions, + flexibleSpace: effectiveFlexibleSpace, + bottom: widget.bottom, + elevation: widget.elevation, + scrolledUnderElevation: widget.scrolledUnderElevation, + shadowColor: widget.shadowColor, + surfaceTintColor: widget.surfaceTintColor, + forceElevated: widget.forceElevated, + backgroundColor: widget.backgroundColor, + foregroundColor: widget.foregroundColor, + iconTheme: widget.iconTheme, + actionsIconTheme: widget.actionsIconTheme, + primary: widget.primary, + centerTitle: widget.centerTitle, + excludeHeaderSemantics: widget.excludeHeaderSemantics, + titleSpacing: widget.titleSpacing, + expandedHeight: effectiveExpandedHeight, + collapsedHeight: effectiveCollapsedHeight, + topPadding: topPadding, + floating: widget.floating, + pinned: widget.pinned, + shape: widget.shape, + snapConfiguration: _snapConfiguration, + stretchConfiguration: _stretchConfiguration, + showOnScreenConfiguration: _showOnScreenConfiguration, + toolbarHeight: widget.toolbarHeight, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + systemOverlayStyle: widget.systemOverlayStyle, + forceMaterialTransparency: widget.forceMaterialTransparency, + useDefaultSemanticsOrder: widget.useDefaultSemanticsOrder, + clipBehavior: widget.clipBehavior, + variant: widget._variant, + accessibleNavigation: MediaQuery.of(context).accessibleNavigation, + actionsPadding: widget.actionsPadding, + ), + ), + ); + } +} + +// Layout the AppBar's title with unconstrained height, vertically +// center it within its (NavigationToolbar) parent, and allow the +// parent to constrain the title's actual height. +class _AppBarTitleBox extends SingleChildRenderObjectWidget { + const _AppBarTitleBox({required Widget super.child}); + + @override + _RenderAppBarTitleBox createRenderObject(BuildContext context) { + return _RenderAppBarTitleBox(textDirection: Directionality.of(context)); + } + + @override + void updateRenderObject(BuildContext context, _RenderAppBarTitleBox renderObject) { + renderObject.textDirection = Directionality.of(context); + } +} + +class _RenderAppBarTitleBox extends RenderAligningShiftedBox { + _RenderAppBarTitleBox({super.textDirection}) : super(alignment: Alignment.center); + + @override + Size computeDryLayout(BoxConstraints constraints) { + final BoxConstraints innerConstraints = constraints.copyWith(maxHeight: double.infinity); + final Size childSize = child!.getDryLayout(innerConstraints); + return constraints.constrain(childSize); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final BoxConstraints innerConstraints = constraints.copyWith(maxHeight: double.infinity); + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final double? result = child.getDryBaseline(innerConstraints, baseline); + if (result == null) { + return null; + } + final Size childSize = child.getDryLayout(innerConstraints); + return result + + resolvedAlignment.alongOffset(getDryLayout(constraints) - childSize as Offset).dy; + } + + @override + void performLayout() { + final BoxConstraints innerConstraints = constraints.copyWith(maxHeight: double.infinity); + child!.layout(innerConstraints, parentUsesSize: true); + size = constraints.constrain(child!.size); + alignChild(); + } +} + +class _ScrollUnderFlexibleSpace extends StatelessWidget { + const _ScrollUnderFlexibleSpace({ + this.title, + this.foregroundColor, + required this.configBuilder, + this.titleTextStyle, + required this.bottomHeight, + }); + + final Widget? title; + final Color? foregroundColor; + final _FlexibleConfigBuilder configBuilder; + final TextStyle? titleTextStyle; + final double bottomHeight; + + @override + Widget build(BuildContext context) { + late final AppBarThemeData appBarTheme = AppBarTheme.of(context); + late final AppBarThemeData defaults = Theme.of(context).useMaterial3 + ? _AppBarDefaultsM3(context) + : _AppBarDefaultsM2(context); + final FlexibleSpaceBarSettings settings = context + .dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!; + final _ScrollUnderFlexibleConfig config = configBuilder(context); + assert( + config.expandedTitlePadding.isNonNegative, + 'The _ExpandedTitleWithPadding widget assumes that the expanded title padding is non-negative. ' + 'Update its implementation to handle negative padding.', + ); + + final TextStyle? expandedTextStyle = + titleTextStyle ?? + appBarTheme.titleTextStyle ?? + config.expandedTextStyle?.copyWith( + color: foregroundColor ?? appBarTheme.foregroundColor ?? defaults.foregroundColor, + ); + + final Widget? expandedTitle = switch ((title, expandedTextStyle)) { + (null, _) => null, + (final Widget title, null) => title, + (final Widget title, final TextStyle textStyle) => DefaultTextStyle( + style: textStyle, + child: title, + ), + }; + + final EdgeInsets resolvedTitlePadding = config.expandedTitlePadding.resolve( + Directionality.of(context), + ); + final EdgeInsetsGeometry expandedTitlePadding = bottomHeight > 0 + ? resolvedTitlePadding.copyWith(bottom: 0) + : resolvedTitlePadding; + + // Set maximum text scale factor to [_kMaxTitleTextScaleFactor] for the + // title to keep the visual hierarchy the same even with larger font + // sizes. To opt out, wrap the [title] widget in a [MediaQuery] widget + // with a different TextScaler. + // TODO(tahatesser): Add link to Material spec when available, https://github.com/flutter/flutter/issues/58769. + return MediaQuery.withClampedTextScaling( + maxScaleFactor: _kMaxTitleTextScaleFactor, + // This column will assume the full height of the parent Stack. + child: Column( + children: <Widget>[ + Padding(padding: EdgeInsets.only(top: settings.minExtent - bottomHeight)), + Flexible( + child: ClipRect( + child: _ExpandedTitleWithPadding( + padding: expandedTitlePadding, + maxExtent: settings.maxExtent - settings.minExtent, + child: expandedTitle, + ), + ), + ), + // Reserve space for AppBar.bottom, which is a sibling of this widget, + // on the parent Stack. + if (bottomHeight > 0) Padding(padding: EdgeInsets.only(bottom: bottomHeight)), + ], + ), + ); + } +} + +// A widget that bottom-start aligns its child (the expanded title widget), and +// insets the child according to the specified padding. +// +// This widget gives the child an infinite max height constraint, and will also +// attempt to vertically limit the child's bounding box (not including the +// padding) to within the y range [0, maxExtent], to make sure the child is +// visible when the AppBar is fully expanded. +class _ExpandedTitleWithPadding extends SingleChildRenderObjectWidget { + const _ExpandedTitleWithPadding({required this.padding, required this.maxExtent, super.child}); + + final EdgeInsetsGeometry padding; + final double maxExtent; + + @override + _RenderExpandedTitleBox createRenderObject(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + return _RenderExpandedTitleBox( + padding.resolve(textDirection), + AlignmentDirectional.bottomStart.resolve(textDirection), + maxExtent, + null, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderExpandedTitleBox renderObject) { + final TextDirection textDirection = Directionality.of(context); + renderObject + ..padding = padding.resolve(textDirection) + ..titleAlignment = AlignmentDirectional.bottomStart.resolve(textDirection) + ..maxExtent = maxExtent; + } +} + +class _RenderExpandedTitleBox extends RenderShiftedBox { + _RenderExpandedTitleBox(this._padding, this._titleAlignment, this._maxExtent, super.child); + + EdgeInsets get padding => _padding; + EdgeInsets _padding; + set padding(EdgeInsets value) { + if (_padding == value) { + return; + } + assert(value.isNonNegative); + _padding = value; + markNeedsLayout(); + } + + Alignment get titleAlignment => _titleAlignment; + Alignment _titleAlignment; + set titleAlignment(Alignment value) { + if (_titleAlignment == value) { + return; + } + _titleAlignment = value; + markNeedsLayout(); + } + + double get maxExtent => _maxExtent; + double _maxExtent; + set maxExtent(double value) { + if (_maxExtent == value) { + return; + } + _maxExtent = value; + markNeedsLayout(); + } + + @override + double computeMaxIntrinsicHeight(double width) { + final RenderBox? child = this.child; + return child == null + ? 0.0 + : child.getMaxIntrinsicHeight(math.max(0, width - padding.horizontal)) + padding.vertical; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final RenderBox? child = this.child; + return child == null ? 0.0 : child.getMaxIntrinsicWidth(double.infinity) + padding.horizontal; + } + + @override + double computeMinIntrinsicHeight(double width) { + final RenderBox? child = this.child; + return child == null + ? 0.0 + : child.getMinIntrinsicHeight(math.max(0, width - padding.horizontal)) + padding.vertical; + } + + @override + double computeMinIntrinsicWidth(double height) { + final RenderBox? child = this.child; + return child == null ? 0.0 : child.getMinIntrinsicWidth(double.infinity) + padding.horizontal; + } + + @override + Size computeDryLayout(BoxConstraints constraints) => + child == null ? Size.zero : constraints.biggest; + + Offset _childOffsetFromSize(Size childSize, Size size) { + assert(child != null); + assert(padding.isNonNegative); + assert(titleAlignment.y == 1.0); + // yAdjustment is the minimum additional y offset to shift the child in + // the visible vertical space when AppBar is fully expanded. The goal is to + // prevent the expanded title from being clipped when the expanded title + // widget + the bottom padding is too tall to fit in the flexible space (the + // top padding is basically ignored since the expanded title is + // bottom-aligned). + final double yAdjustment = clampDouble( + childSize.height + padding.bottom - maxExtent, + 0, + padding.bottom, + ); + final double offsetX = + (titleAlignment.x + 1) / 2 * (size.width - padding.horizontal - childSize.width) + + padding.left; + final double offsetY = size.height - childSize.height - padding.bottom + yAdjustment; + return Offset(offsetX, offsetY); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final BoxConstraints childConstraints = constraints.widthConstraints().deflate(padding); + final BaselineOffset result = + BaselineOffset(child.getDryBaseline(childConstraints, baseline)) + + _childOffsetFromSize(child.getDryLayout(childConstraints), getDryLayout(constraints)).dy; + return result.offset; + } + + @override + void performLayout() { + final RenderBox? child = this.child; + if (child == null) { + size = constraints.smallest; + return; + } + size = constraints.biggest; + child.layout(constraints.widthConstraints().deflate(padding), parentUsesSize: true); + final childParentData = child.parentData! as BoxParentData; + childParentData.offset = _childOffsetFromSize(child.size, size); + } +} + +mixin _ScrollUnderFlexibleConfig { + TextStyle? get collapsedTextStyle; + TextStyle? get expandedTextStyle; + EdgeInsetsGeometry get expandedTitlePadding; +} + +// Hand coded defaults based on Material Design 2. +class _AppBarDefaultsM2 extends AppBarThemeData { + _AppBarDefaultsM2(this.context) + : super( + elevation: 4.0, + shadowColor: const Color(0xFF000000), + titleSpacing: NavigationToolbar.kMiddleSpacing, + toolbarHeight: kToolbarHeight, + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color? get backgroundColor => + _colors.brightness == Brightness.dark ? _colors.surface : _colors.primary; + + @override + Color? get foregroundColor => + _colors.brightness == Brightness.dark ? _colors.onSurface : _colors.onPrimary; + + @override + IconThemeData? get iconTheme => _theme.iconTheme; + + @override + TextStyle? get toolbarTextStyle => _theme.textTheme.bodyMedium; + + @override + TextStyle? get titleTextStyle => _theme.textTheme.titleLarge; + + @override + EdgeInsets? get actionsPadding => EdgeInsets.zero; +} + +// BEGIN GENERATED TOKEN PROPERTIES - AppBar + +// 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. + +// dart format off +class _AppBarDefaultsM3 extends AppBarThemeData { + _AppBarDefaultsM3(this.context) + : super( + elevation: 0.0, + scrolledUnderElevation: 3.0, + titleSpacing: NavigationToolbar.kMiddleSpacing, + toolbarHeight: 64.0, + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get backgroundColor => _colors.surface; + + @override + Color? get foregroundColor => _colors.onSurface; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + IconThemeData? get iconTheme => IconThemeData( + color: _colors.onSurface, + size: 24.0, + ); + + @override + IconThemeData? get actionsIconTheme => IconThemeData( + color: _colors.onSurfaceVariant, + size: 24.0, + ); + + @override + TextStyle? get toolbarTextStyle => _textTheme.bodyMedium; + + @override + TextStyle? get titleTextStyle => _textTheme.titleLarge; + + // TODO(Craftplacer): Consider using EdgeInsets.only(right: 8.0) instead of + // EdgeInsets.zero for Material 3 in the future, + // https://github.com/flutter/flutter/issues/155747 + @override + EdgeInsets? get actionsPadding => EdgeInsets.zero; +} + +// Variant configuration +class _MediumScrollUnderFlexibleConfig with _ScrollUnderFlexibleConfig { + _MediumScrollUnderFlexibleConfig(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + static const double collapsedHeight = 64.0; + static const double expandedHeight = 112.0; + + @override + TextStyle? get collapsedTextStyle => + _textTheme.titleLarge?.apply(color: _colors.onSurface); + + @override + TextStyle? get expandedTextStyle => + _textTheme.headlineSmall?.apply(color: _colors.onSurface); + + @override + EdgeInsetsGeometry get expandedTitlePadding => const EdgeInsets.fromLTRB(16, 0, 16, 20); +} + +class _LargeScrollUnderFlexibleConfig with _ScrollUnderFlexibleConfig { + _LargeScrollUnderFlexibleConfig(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + static const double collapsedHeight = 64.0; + static const double expandedHeight = 152.0; + + @override + TextStyle? get collapsedTextStyle => + _textTheme.titleLarge?.apply(color: _colors.onSurface); + + @override + TextStyle? get expandedTextStyle => + _textTheme.headlineMedium?.apply(color: _colors.onSurface); + + @override + EdgeInsetsGeometry get expandedTitlePadding => const EdgeInsets.fromLTRB(16, 0, 16, 28); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - AppBar diff --git a/packages/material_ui/lib/src/app_bar_theme.dart b/packages/material_ui/lib/src/app_bar_theme.dart new file mode 100644 index 000000000000..6551608fd8d6 --- /dev/null +++ b/packages/material_ui/lib/src/app_bar_theme.dart @@ -0,0 +1,637 @@ +// 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. + +/// @docImport 'app_bar.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [AppBar] widgets. +/// +/// Descendant widgets obtain the current [AppBarThemeData] object with +/// [AppBarTheme.of]. Instances of [AppBarThemeData] can be customized +/// with [AppBarThemeData.copyWith]. +/// +/// Typically an [AppBarThemeData] is specified as part of the overall [Theme] with +/// [ThemeData.appBarTheme]. +/// +/// All [AppBarTheme] properties are `null` by default. When null, the +// [AppBar] constructor provides defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class AppBarTheme extends InheritedTheme with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.appBarTheme]. + const AppBarTheme({ + super.key, + @Deprecated( + 'Use backgroundColor instead. ' + 'This feature was deprecated after v3.33.0-0.2.pre.', + ) + Color? color, + Color? backgroundColor, + Color? foregroundColor, + double? elevation, + double? scrolledUnderElevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + IconThemeData? iconTheme, + IconThemeData? actionsIconTheme, + bool? centerTitle, + double? titleSpacing, + double? leadingWidth, + double? toolbarHeight, + TextStyle? toolbarTextStyle, + TextStyle? titleTextStyle, + SystemUiOverlayStyle? systemOverlayStyle, + EdgeInsetsGeometry? actionsPadding, + AppBarThemeData? data, + Widget? child, + }) : assert( + color == null || backgroundColor == null, + 'The color and backgroundColor parameters mean the same thing. Only specify one.', + ), + assert( + data == null || + (color ?? + backgroundColor ?? + foregroundColor ?? + elevation ?? + scrolledUnderElevation ?? + shadowColor ?? + surfaceTintColor ?? + shape ?? + iconTheme ?? + actionsIconTheme ?? + centerTitle ?? + titleSpacing ?? + leadingWidth ?? + toolbarHeight ?? + toolbarTextStyle ?? + titleTextStyle ?? + systemOverlayStyle ?? + actionsPadding) == + null, + ), + _backgroundColor = backgroundColor ?? color, + _foregroundColor = foregroundColor, + _elevation = elevation, + _scrolledUnderElevation = scrolledUnderElevation, + _shadowColor = shadowColor, + _surfaceTintColor = surfaceTintColor, + _shape = shape, + _iconTheme = iconTheme, + _actionsIconTheme = actionsIconTheme, + _centerTitle = centerTitle, + _titleSpacing = titleSpacing, + _leadingWidth = leadingWidth, + _toolbarHeight = toolbarHeight, + _toolbarTextStyle = toolbarTextStyle, + _titleTextStyle = titleTextStyle, + _systemOverlayStyle = systemOverlayStyle, + _actionsPadding = actionsPadding, + _data = data, + super(child: child ?? const SizedBox()); + + final AppBarThemeData? _data; + final Color? _backgroundColor; + final Color? _foregroundColor; + final double? _elevation; + final double? _scrolledUnderElevation; + final Color? _shadowColor; + final Color? _surfaceTintColor; + final ShapeBorder? _shape; + final IconThemeData? _iconTheme; + final IconThemeData? _actionsIconTheme; + final bool? _centerTitle; + final double? _titleSpacing; + final double? _leadingWidth; + final double? _toolbarHeight; + final TextStyle? _toolbarTextStyle; + final TextStyle? _titleTextStyle; + final SystemUiOverlayStyle? _systemOverlayStyle; + final EdgeInsetsGeometry? _actionsPadding; + + /// Overrides the default value of [AppBar.backgroundColor] in all + /// descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [foregroundColor], which overrides the default value of + /// [AppBar.foregroundColor] in all descendant [AppBar] widgets. + Color? get backgroundColor => _data != null ? _data.backgroundColor : _backgroundColor; + + /// Overrides the default value of [AppBar.foregroundColor] in all + /// descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [backgroundColor], which overrides the default value of + /// [AppBar.backgroundColor] in all descendant [AppBar] widgets. + Color? get foregroundColor => _data != null ? _data.foregroundColor : _foregroundColor; + + /// Overrides the default value of [AppBar.elevation] in all + /// descendant [AppBar] widgets. + double? get elevation => _data != null ? _data.elevation : _elevation; + + /// Overrides the default value of [AppBar.scrolledUnderElevation] in all + /// descendant [AppBar] widgets. + double? get scrolledUnderElevation => + _data != null ? _data.scrolledUnderElevation : _scrolledUnderElevation; + + /// Overrides the default value of [AppBar.shadowColor] in all + /// descendant [AppBar] widgets. + Color? get shadowColor => _data != null ? _data.shadowColor : _shadowColor; + + /// Overrides the default value of [AppBar.surfaceTintColor] in all + /// descendant [AppBar] widgets. + Color? get surfaceTintColor => _data != null ? _data.surfaceTintColor : _surfaceTintColor; + + /// Overrides the default value of [AppBar.shape] in all + /// descendant [AppBar] widgets. + ShapeBorder? get shape => _data != null ? _data.shape : _shape; + + /// Overrides the default value of [AppBar.iconTheme] in all + /// descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [actionsIconTheme], which overrides the default value of + /// [AppBar.actionsIconTheme] in all descendant [AppBar] widgets. + /// * [foregroundColor], which overrides the default value + /// [AppBar.foregroundColor] in all descendant [AppBar] widgets. + IconThemeData? get iconTheme => _data != null ? _data.iconTheme : _iconTheme; + + /// Overrides the default value of [AppBar.actionsIconTheme] in all + /// descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [iconTheme], which overrides the default value of + /// [AppBar.iconTheme] in all descendant [AppBar] widgets. + /// * [foregroundColor], which overrides the default value + /// [AppBar.foregroundColor] in all descendant [AppBar] widgets. + IconThemeData? get actionsIconTheme => _data != null ? _data.actionsIconTheme : _actionsIconTheme; + + /// Overrides the default value of [AppBar.centerTitle] + /// property in all descendant [AppBar] widgets. + bool? get centerTitle => _data != null ? _data.centerTitle : _centerTitle; + + /// Overrides the default value of the obsolete [AppBar.titleSpacing] + /// property in all descendant [AppBar] widgets. + /// + /// If null, [AppBar] uses default value of [NavigationToolbar.kMiddleSpacing]. + double? get titleSpacing => _data != null ? _data.titleSpacing : _titleSpacing; + + /// Overrides the default value of the [AppBar.leadingWidth] + /// property in all descendant [AppBar] widgets. + double? get leadingWidth => _data != null ? _data.leadingWidth : _leadingWidth; + + /// Overrides the default value of the [AppBar.toolbarHeight] + /// property in all descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [AppBar.preferredHeightFor], which computes the overall + /// height of an AppBar widget, taking this value into account. + double? get toolbarHeight => _data != null ? _data.toolbarHeight : _toolbarHeight; + + /// Overrides the default value of the obsolete [AppBar.toolbarTextStyle] + /// property in all descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [titleTextStyle], which overrides the default of [AppBar.titleTextStyle] + /// in all descendant [AppBar] widgets. + TextStyle? get toolbarTextStyle => _data != null ? _data.toolbarTextStyle : _toolbarTextStyle; + + /// Overrides the default value of [AppBar.titleTextStyle] + /// property in all descendant [AppBar] widgets. + /// + /// See also: + /// + /// * [toolbarTextStyle], which overrides the default of [AppBar.toolbarTextStyle] + /// in all descendant [AppBar] widgets. + TextStyle? get titleTextStyle => _data != null ? _data.titleTextStyle : _titleTextStyle; + + /// Overrides the default value of [AppBar.systemOverlayStyle] + /// property in all descendant [AppBar] widgets. + SystemUiOverlayStyle? get systemOverlayStyle => + _data != null ? _data.systemOverlayStyle : _systemOverlayStyle; + + /// Overrides the default value of [AppBar.actionsPadding] + /// property in all descendant [AppBar] widgets. + EdgeInsetsGeometry? get actionsPadding => _data != null ? _data.actionsPadding : _actionsPadding; + + /// The properties used for all descendant [AppBar] widgets. + AppBarThemeData get data => + _data ?? + AppBarThemeData( + backgroundColor: _backgroundColor, + foregroundColor: _foregroundColor, + elevation: _elevation, + scrolledUnderElevation: _scrolledUnderElevation, + shadowColor: _shadowColor, + surfaceTintColor: _surfaceTintColor, + shape: _shape, + iconTheme: _iconTheme, + actionsIconTheme: _actionsIconTheme, + centerTitle: _centerTitle, + titleSpacing: _titleSpacing, + leadingWidth: _leadingWidth, + toolbarHeight: _toolbarHeight, + toolbarTextStyle: _toolbarTextStyle, + titleTextStyle: _titleTextStyle, + systemOverlayStyle: _systemOverlayStyle, + actionsPadding: _actionsPadding, + ); + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [AppBarThemeData.copyWith] method instead. + AppBarTheme copyWith({ + IconThemeData? actionsIconTheme, + @Deprecated( + 'Use backgroundColor instead. ' + 'This feature was deprecated after v3.33.0-0.2.pre.', + ) + Color? color, + Color? backgroundColor, + Color? foregroundColor, + double? elevation, + double? scrolledUnderElevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + IconThemeData? iconTheme, + bool? centerTitle, + double? titleSpacing, + double? leadingWidth, + double? toolbarHeight, + TextStyle? toolbarTextStyle, + TextStyle? titleTextStyle, + SystemUiOverlayStyle? systemOverlayStyle, + EdgeInsetsGeometry? actionsPadding, + }) { + assert( + color == null || backgroundColor == null, + 'The color and backgroundColor parameters mean the same thing. Only specify one.', + ); + return AppBarTheme( + backgroundColor: backgroundColor ?? color ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + elevation: elevation ?? this.elevation, + scrolledUnderElevation: scrolledUnderElevation ?? this.scrolledUnderElevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shape: shape ?? this.shape, + iconTheme: iconTheme ?? this.iconTheme, + actionsIconTheme: actionsIconTheme ?? this.actionsIconTheme, + centerTitle: centerTitle ?? this.centerTitle, + titleSpacing: titleSpacing ?? this.titleSpacing, + leadingWidth: leadingWidth ?? this.leadingWidth, + toolbarHeight: toolbarHeight ?? this.toolbarHeight, + toolbarTextStyle: toolbarTextStyle ?? this.toolbarTextStyle, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + systemOverlayStyle: systemOverlayStyle ?? this.systemOverlayStyle, + actionsPadding: actionsPadding ?? this.actionsPadding, + ); + } + + /// Retrieves the [AppBarThemeData] from the closest ancestor [AppBarTheme]. + /// + /// If there is no enclosing [AppBarTheme] widget, then + /// [ThemeData.appBarTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// AppBarThemeData theme = AppBarTheme.of(context); + /// ``` + static AppBarThemeData of(BuildContext context) { + final AppBarTheme? appBarTheme = context.dependOnInheritedWidgetOfExactType<AppBarTheme>(); + return appBarTheme?.data ?? Theme.of(context).appBarTheme; + } + + /// Linearly interpolate between two AppBar themes. + /// + /// {@macro dart.ui.shadow.lerp} + static AppBarTheme lerp(AppBarTheme? a, AppBarTheme? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return AppBarTheme( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + foregroundColor: Color.lerp(a?.foregroundColor, b?.foregroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + scrolledUnderElevation: lerpDouble(a?.scrolledUnderElevation, b?.scrolledUnderElevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + iconTheme: IconThemeData.lerp(a?.iconTheme, b?.iconTheme, t), + actionsIconTheme: IconThemeData.lerp(a?.actionsIconTheme, b?.actionsIconTheme, t), + centerTitle: t < 0.5 ? a?.centerTitle : b?.centerTitle, + titleSpacing: lerpDouble(a?.titleSpacing, b?.titleSpacing, t), + leadingWidth: lerpDouble(a?.leadingWidth, b?.leadingWidth, t), + toolbarHeight: lerpDouble(a?.toolbarHeight, b?.toolbarHeight, t), + toolbarTextStyle: TextStyle.lerp(a?.toolbarTextStyle, b?.toolbarTextStyle, t), + titleTextStyle: TextStyle.lerp(a?.titleTextStyle, b?.titleTextStyle, t), + systemOverlayStyle: t < 0.5 ? a?.systemOverlayStyle : b?.systemOverlayStyle, + actionsPadding: EdgeInsetsGeometry.lerp(a?.actionsPadding, b?.actionsPadding, t), + ); + } + + @override + bool updateShouldNotify(covariant AppBarTheme oldWidget) => data != oldWidget.data; + + @override + Widget wrap(BuildContext context, Widget child) { + return AppBarTheme(data: data, child: child); + } +} + +/// Defines default property values for descendant [AppBar] widgets. +/// +/// Descendant widgets obtain the current [AppBarThemeData] object using +/// [AppBarTheme.of]. Instances of [AppBarThemeData] can be +/// customized with [AppBarThemeData.copyWith]. +/// +/// Typically an [AppBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.appBarTheme]. +/// +/// All [AppBarThemeData] properties are `null` by default. When null, the [AppBar] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. See the individual [AppBar] properties for details. +/// +/// See also: +/// +/// * [AppBar], which is the widget that this theme configures. +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class AppBarThemeData with Diagnosticable { + /// Creates an app bar theme that can be used with [ThemeData.appBarTheme]. + const AppBarThemeData({ + this.backgroundColor, + this.foregroundColor, + @Deprecated( + 'Use backgroundColor instead. ' + 'This feature was deprecated after v3.33.0-0.2.pre.', + ) + Color? color, + this.elevation, + this.scrolledUnderElevation, + this.shadowColor, + this.surfaceTintColor, + this.shape, + this.iconTheme, + this.actionsIconTheme, + this.centerTitle, + this.titleSpacing, + this.leadingWidth, + this.toolbarHeight, + this.toolbarTextStyle, + this.titleTextStyle, + this.systemOverlayStyle, + this.actionsPadding, + }) : assert( + color == null || backgroundColor == null, + 'The color and backgroundColor parameters mean the same thing. Only specify one.', + ); + + /// Overrides the default value of [AppBar.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [AppBar.foregroundColor]. + final Color? foregroundColor; + + /// Overrides the default value of [AppBar.elevation]. + final double? elevation; + + /// Overrides the default value of [AppBar.scrolledUnderElevation]. + final double? scrolledUnderElevation; + + /// Overrides the default value of [AppBar.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value of [AppBar.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of [AppBar.shape]. + final ShapeBorder? shape; + + /// Overrides the default value of [AppBar.iconTheme]. + final IconThemeData? iconTheme; + + /// Overrides the default value of [AppBar.actionsIconTheme]. + final IconThemeData? actionsIconTheme; + + /// Overrides the default value of [AppBar.centerTitle]. + final bool? centerTitle; + + /// Overrides the default value of [AppBar.titleSpacing]. + final double? titleSpacing; + + /// Overrides the default value of [AppBar.leadingWidth]. + final double? leadingWidth; + + /// Overrides the default value of [AppBar.toolbarHeight]. + final double? toolbarHeight; + + /// Overrides the default value of [AppBar.toolbarTextStyle]. + final TextStyle? toolbarTextStyle; + + /// Overrides the default value of [AppBar.titleTextStyle]. + final TextStyle? titleTextStyle; + + /// Overrides the default value of [AppBar.systemOverlayStyle]. + final SystemUiOverlayStyle? systemOverlayStyle; + + /// Overrides the default value of [AppBar.actionsPadding]. + final EdgeInsetsGeometry? actionsPadding; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + AppBarThemeData copyWith({ + Color? backgroundColor, + Color? foregroundColor, + @Deprecated( + 'Use backgroundColor instead. ' + 'This feature was deprecated after v3.33.0-0.2.pre.', + ) + Color? color, + double? elevation, + double? scrolledUnderElevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + IconThemeData? iconTheme, + IconThemeData? actionsIconTheme, + bool? centerTitle, + double? titleSpacing, + double? leadingWidth, + double? toolbarHeight, + TextStyle? toolbarTextStyle, + TextStyle? titleTextStyle, + SystemUiOverlayStyle? systemOverlayStyle, + EdgeInsetsGeometry? actionsPadding, + }) { + return AppBarThemeData( + backgroundColor: backgroundColor ?? color ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + elevation: elevation ?? this.elevation, + scrolledUnderElevation: scrolledUnderElevation ?? this.scrolledUnderElevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shape: shape ?? this.shape, + iconTheme: iconTheme ?? this.iconTheme, + actionsIconTheme: actionsIconTheme ?? this.actionsIconTheme, + centerTitle: centerTitle ?? this.centerTitle, + titleSpacing: titleSpacing ?? this.titleSpacing, + leadingWidth: leadingWidth ?? this.leadingWidth, + toolbarHeight: toolbarHeight ?? this.toolbarHeight, + toolbarTextStyle: toolbarTextStyle ?? this.toolbarTextStyle, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + systemOverlayStyle: systemOverlayStyle ?? this.systemOverlayStyle, + actionsPadding: actionsPadding ?? this.actionsPadding, + ); + } + + /// Linearly interpolate between two app bar themes. + /// + /// {@macro dart.ui.shadow.lerp} + static AppBarThemeData lerp(AppBarThemeData a, AppBarThemeData b, double t) { + if (identical(a, b)) { + return a; + } + return AppBarThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t), + elevation: lerpDouble(a.elevation, b.elevation, t), + scrolledUnderElevation: lerpDouble(a.scrolledUnderElevation, b.scrolledUnderElevation, t), + shadowColor: Color.lerp(a.shadowColor, b.shadowColor, t), + surfaceTintColor: Color.lerp(a.surfaceTintColor, b.surfaceTintColor, t), + shape: ShapeBorder.lerp(a.shape, b.shape, t), + iconTheme: IconThemeData.lerp(a.iconTheme, b.iconTheme, t), + actionsIconTheme: IconThemeData.lerp(a.actionsIconTheme, b.actionsIconTheme, t), + centerTitle: t < 0.5 ? a.centerTitle : b.centerTitle, + titleSpacing: lerpDouble(a.titleSpacing, b.titleSpacing, t), + leadingWidth: lerpDouble(a.leadingWidth, b.leadingWidth, t), + toolbarHeight: lerpDouble(a.toolbarHeight, b.toolbarHeight, t), + toolbarTextStyle: TextStyle.lerp(a.toolbarTextStyle, b.toolbarTextStyle, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + systemOverlayStyle: t < 0.5 ? a.systemOverlayStyle : b.systemOverlayStyle, + actionsPadding: EdgeInsetsGeometry.lerp(a.actionsPadding, b.actionsPadding, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + foregroundColor, + elevation, + scrolledUnderElevation, + shadowColor, + surfaceTintColor, + shape, + iconTheme, + actionsIconTheme, + centerTitle, + titleSpacing, + leadingWidth, + toolbarHeight, + toolbarTextStyle, + titleTextStyle, + systemOverlayStyle, + actionsPadding, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is AppBarThemeData && + other.backgroundColor == backgroundColor && + other.foregroundColor == foregroundColor && + other.elevation == elevation && + other.scrolledUnderElevation == scrolledUnderElevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.shape == shape && + other.iconTheme == iconTheme && + other.actionsIconTheme == actionsIconTheme && + other.centerTitle == centerTitle && + other.titleSpacing == titleSpacing && + other.leadingWidth == leadingWidth && + other.toolbarHeight == toolbarHeight && + other.toolbarTextStyle == toolbarTextStyle && + other.titleTextStyle == titleTextStyle && + other.systemOverlayStyle == systemOverlayStyle && + other.actionsPadding == actionsPadding; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('foregroundColor', foregroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add( + DoubleProperty('scrolledUnderElevation', scrolledUnderElevation, defaultValue: null), + ); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<IconThemeData>('iconTheme', iconTheme, defaultValue: null)); + properties.add( + DiagnosticsProperty<IconThemeData>('actionsIconTheme', actionsIconTheme, defaultValue: null), + ); + properties.add(DiagnosticsProperty<bool>('centerTitle', centerTitle, defaultValue: null)); + properties.add(DoubleProperty('titleSpacing', titleSpacing, defaultValue: null)); + properties.add(DoubleProperty('leadingWidth', leadingWidth, defaultValue: null)); + properties.add(DoubleProperty('toolbarHeight', toolbarHeight, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('toolbarTextStyle', toolbarTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('titleTextStyle', titleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<SystemUiOverlayStyle?>( + 'systemOverlayStyle', + systemOverlayStyle, + defaultValue: null, + description: systemOverlayStyle == null + ? null + : 'SystemUiOverlayStyle(${<String>[if (systemOverlayStyle?.systemNavigationBarColor != null) 'systemNavigationBarColor: ${systemOverlayStyle?.systemNavigationBarColor}', if (systemOverlayStyle?.systemNavigationBarDividerColor != null) 'systemNavigationBarDividerColor: ${systemOverlayStyle?.systemNavigationBarDividerColor}', if (systemOverlayStyle?.systemNavigationBarIconBrightness != null) 'systemNavigationBarIconBrightness: ${systemOverlayStyle?.systemNavigationBarIconBrightness}', if (systemOverlayStyle?.statusBarColor != null) 'statusBarColor: ${systemOverlayStyle?.statusBarColor}', if (systemOverlayStyle?.statusBarBrightness != null) 'statusBarBrightness: ${systemOverlayStyle?.statusBarBrightness}', if (systemOverlayStyle?.statusBarIconBrightness != null) 'statusBarIconBrightness: ${systemOverlayStyle?.statusBarIconBrightness}', if (systemOverlayStyle?.systemStatusBarContrastEnforced != null) 'systemStatusBarContrastEnforced: ${systemOverlayStyle?.systemStatusBarContrastEnforced}', if (systemOverlayStyle?.systemNavigationBarContrastEnforced != null) 'systemNavigationBarContrastEnforced: ${systemOverlayStyle?.systemNavigationBarContrastEnforced}'].where((String s) => s.isNotEmpty).join(', ')})', + ), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry?>( + 'actionsPadding', + actionsPadding, + defaultValue: null, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/arc.dart b/packages/material_ui/lib/src/arc.dart new file mode 100644 index 000000000000..c7b1ca7d9cf2 --- /dev/null +++ b/packages/material_ui/lib/src/arc.dart @@ -0,0 +1,437 @@ +// 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. + +/// @docImport 'package:flutter/widgets.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +// How close the begin and end points must be to an axis to be considered +// vertical or horizontal. +const double _kOnAxisDelta = 2.0; + +/// A [Tween] that interpolates an [Offset] along a circular arc. +/// +/// This class specializes the interpolation of [Tween<Offset>] so that instead +/// of a straight line, the intermediate points follow the arc of a circle in a +/// manner consistent with Material Design principles. +/// +/// The arc's radius is related to the bounding box that contains the [begin] +/// and [end] points. If the bounding box is taller than it is wide, then the +/// center of the circle will be horizontally aligned with the end point. +/// Otherwise the center of the circle will be aligned with the begin point. +/// The arc's sweep is always less than or equal to 90 degrees. +/// +/// See also: +/// +/// * [Tween], for a discussion on how to use interpolation objects. +/// * [MaterialRectArcTween], which extends this concept to interpolating [Rect]s. +class MaterialPointArcTween extends Tween<Offset> { + /// Creates a [Tween] for animating [Offset]s along a circular arc. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + MaterialPointArcTween({super.begin, super.end}); + + bool _dirty = true; + + void _initialize() { + assert(this.begin != null); + assert(this.end != null); + + final Offset begin = this.begin!; + final Offset end = this.end!; + + // An explanation with a diagram can be found at https://docs.google.com/document/d/1kF7vhX_RpQCIjQYT6lZdLFs4Dl6hHxoXNkTQfzyHNlw/ + final Offset delta = end - begin; + final double deltaX = delta.dx.abs(); + final double deltaY = delta.dy.abs(); + final double distanceFromAtoB = delta.distance; + final c = Offset(end.dx, begin.dy); + + double sweepAngle() => 2.0 * math.asin(distanceFromAtoB / (2.0 * _radius!)); + + if (deltaX > _kOnAxisDelta && deltaY > _kOnAxisDelta) { + if (deltaX < deltaY) { + _radius = distanceFromAtoB * distanceFromAtoB / (c - begin).distance / 2.0; + _center = Offset(end.dx + _radius! * (begin.dx - end.dx).sign, end.dy); + if (begin.dx < end.dx) { + _beginAngle = sweepAngle() * (begin.dy - end.dy).sign; + _endAngle = 0.0; + } else { + _beginAngle = math.pi + sweepAngle() * (end.dy - begin.dy).sign; + _endAngle = math.pi; + } + } else { + _radius = distanceFromAtoB * distanceFromAtoB / (c - end).distance / 2.0; + _center = Offset(begin.dx, begin.dy + (end.dy - begin.dy).sign * _radius!); + if (begin.dy < end.dy) { + _beginAngle = -math.pi / 2.0; + _endAngle = _beginAngle! + sweepAngle() * (end.dx - begin.dx).sign; + } else { + _beginAngle = math.pi / 2.0; + _endAngle = _beginAngle! + sweepAngle() * (begin.dx - end.dx).sign; + } + } + assert(_beginAngle != null); + assert(_endAngle != null); + } else { + _beginAngle = null; + _endAngle = null; + } + _dirty = false; + } + + /// The center of the circular arc, null if [begin] and [end] are horizontally or + /// vertically aligned, or if either is null. + Offset? get center { + if (begin == null || end == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _center; + } + + Offset? _center; + + /// The radius of the circular arc, null if [begin] and [end] are horizontally or + /// vertically aligned, or if either is null. + double? get radius { + if (begin == null || end == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _radius; + } + + double? _radius; + + /// The beginning of the arc's sweep in radians, measured from the positive x + /// axis. Positive angles turn clockwise. + /// + /// This will be null if [begin] and [end] are horizontally or vertically + /// aligned, or if either is null. + double? get beginAngle { + if (begin == null || end == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _beginAngle; + } + + double? _beginAngle; + + /// The end of the arc's sweep in radians, measured from the positive x axis. + /// Positive angles turn clockwise. + /// + /// This will be null if [begin] and [end] are horizontally or vertically + /// aligned, or if either is null. + double? get endAngle { + if (begin == null || end == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _beginAngle; + } + + double? _endAngle; + + @override + set begin(Offset? value) { + if (value != begin) { + super.begin = value; + _dirty = true; + } + } + + @override + set end(Offset? value) { + if (value != end) { + super.end = value; + _dirty = true; + } + } + + @override + Offset lerp(double t) { + if (_dirty) { + _initialize(); + } + if (t == 0.0) { + return begin!; + } + if (t == 1.0) { + return end!; + } + if (_beginAngle == null || _endAngle == null) { + return Offset.lerp(begin, end, t)!; + } + final double angle = lerpDouble(_beginAngle, _endAngle, t)!; + final double x = math.cos(angle) * _radius!; + final double y = math.sin(angle) * _radius!; + return _center! + Offset(x, y); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'MaterialPointArcTween')}($begin \u2192 $end; center=$center, radius=$radius, beginAngle=$beginAngle, endAngle=$endAngle)'; + } +} + +enum _CornerId { topLeft, topRight, bottomLeft, bottomRight } + +class _Diagonal { + const _Diagonal(this.beginId, this.endId); + final _CornerId beginId; + final _CornerId endId; +} + +const List<_Diagonal> _allDiagonals = <_Diagonal>[ + _Diagonal(_CornerId.topLeft, _CornerId.bottomRight), + _Diagonal(_CornerId.bottomRight, _CornerId.topLeft), + _Diagonal(_CornerId.topRight, _CornerId.bottomLeft), + _Diagonal(_CornerId.bottomLeft, _CornerId.topRight), +]; + +typedef _KeyFunc<T> = double Function(T input); + +// Select the element for which the key function returns the maximum value. +T _maxBy<T>(Iterable<T> input, _KeyFunc<T> keyFunc) { + late T maxValue; + double? maxKey; + for (final value in input) { + final double key = keyFunc(value); + if (maxKey == null || key > maxKey) { + maxValue = value; + maxKey = key; + } + } + return maxValue; +} + +/// A [Tween] that interpolates a [Rect] by having its opposite corners follow +/// circular arcs. +/// +/// This class specializes the interpolation of [Tween<Rect>] so that instead of +/// growing or shrinking linearly, opposite corners of the rectangle follow arcs +/// in a manner consistent with Material Design principles. +/// +/// Specifically, the rectangle corners whose diagonals are closest to the overall +/// direction of the animation follow arcs defined with [MaterialPointArcTween]. +/// +/// See also: +/// +/// * [MaterialRectCenterArcTween], which interpolates a rect along a circular +/// arc between the begin and end [Rect]'s centers. +/// * [Tween], for a discussion on how to use interpolation objects. +/// * [MaterialPointArcTween], the analog for [Offset] interpolation. +/// * [RectTween], which does a linear rectangle interpolation. +/// * [Hero.createRectTween], which can be used to specify the tween that defines +/// a hero's path. +class MaterialRectArcTween extends RectTween { + /// Creates a [Tween] for animating [Rect]s along a circular arc. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + MaterialRectArcTween({super.begin, super.end}); + + bool _dirty = true; + + void _initialize() { + assert(begin != null); + assert(end != null); + final Offset centersVector = end!.center - begin!.center; + final _Diagonal diagonal = _maxBy<_Diagonal>( + _allDiagonals, + (_Diagonal d) => _diagonalSupport(centersVector, d), + ); + _beginArc = MaterialPointArcTween( + begin: _cornerFor(begin!, diagonal.beginId), + end: _cornerFor(end!, diagonal.beginId), + ); + _endArc = MaterialPointArcTween( + begin: _cornerFor(begin!, diagonal.endId), + end: _cornerFor(end!, diagonal.endId), + ); + _dirty = false; + } + + double _diagonalSupport(Offset centersVector, _Diagonal diagonal) { + final Offset delta = _cornerFor(begin!, diagonal.endId) - _cornerFor(begin!, diagonal.beginId); + final double length = delta.distance; + return centersVector.dx * delta.dx / length + centersVector.dy * delta.dy / length; + } + + Offset _cornerFor(Rect rect, _CornerId id) { + return switch (id) { + _CornerId.topLeft => rect.topLeft, + _CornerId.topRight => rect.topRight, + _CornerId.bottomLeft => rect.bottomLeft, + _CornerId.bottomRight => rect.bottomRight, + }; + } + + /// The path of the corresponding [begin], [end] rectangle corners that lead + /// the animation. + MaterialPointArcTween? get beginArc { + if (begin == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _beginArc; + } + + late MaterialPointArcTween _beginArc; + + /// The path of the corresponding [begin], [end] rectangle corners that trail + /// the animation. + MaterialPointArcTween? get endArc { + if (end == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _endArc; + } + + late MaterialPointArcTween _endArc; + + @override + set begin(Rect? value) { + if (value != begin) { + super.begin = value; + _dirty = true; + } + } + + @override + set end(Rect? value) { + if (value != end) { + super.end = value; + _dirty = true; + } + } + + @override + Rect lerp(double t) { + if (_dirty) { + _initialize(); + } + if (t == 0.0) { + return begin!; + } + if (t == 1.0) { + return end!; + } + return Rect.fromPoints(_beginArc.lerp(t), _endArc.lerp(t)); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'MaterialRectArcTween')}($begin \u2192 $end; beginArc=$beginArc, endArc=$endArc)'; + } +} + +/// A [Tween] that interpolates a [Rect] by moving it along a circular arc from +/// [begin]'s [Rect.center] to [end]'s [Rect.center] while interpolating the +/// rectangle's width and height. +/// +/// The arc that defines that center of the interpolated rectangle as it morphs +/// from [begin] to [end] is a [MaterialPointArcTween]. +/// +/// See also: +/// +/// * [MaterialRectArcTween], A [Tween] that interpolates a [Rect] by having +/// its opposite corners follow circular arcs. +/// * [Tween], for a discussion on how to use interpolation objects. +/// * [MaterialPointArcTween], the analog for [Offset] interpolation. +/// * [RectTween], which does a linear rectangle interpolation. +/// * [Hero.createRectTween], which can be used to specify the tween that defines +/// a hero's path. +class MaterialRectCenterArcTween extends RectTween { + /// Creates a [Tween] for animating [Rect]s along a circular arc. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + MaterialRectCenterArcTween({super.begin, super.end}); + + bool _dirty = true; + + void _initialize() { + assert(begin != null); + assert(end != null); + _centerArc = MaterialPointArcTween(begin: begin!.center, end: end!.center); + _dirty = false; + } + + /// If [begin] and [end] are non-null, returns a tween that interpolates along + /// a circular arc between [begin]'s [Rect.center] and [end]'s [Rect.center]. + MaterialPointArcTween? get centerArc { + if (begin == null || end == null) { + return null; + } + if (_dirty) { + _initialize(); + } + return _centerArc; + } + + late MaterialPointArcTween _centerArc; + + @override + set begin(Rect? value) { + if (value != begin) { + super.begin = value; + _dirty = true; + } + } + + @override + set end(Rect? value) { + if (value != end) { + super.end = value; + _dirty = true; + } + } + + @override + Rect lerp(double t) { + if (_dirty) { + _initialize(); + } + if (t == 0.0) { + return begin!; + } + if (t == 1.0) { + return end!; + } + final Offset center = _centerArc.lerp(t); + final double width = lerpDouble(begin!.width, end!.width, t)!; + final double height = lerpDouble(begin!.height, end!.height, t)!; + return Rect.fromLTWH(center.dx - width / 2.0, center.dy - height / 2.0, width, height); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'MaterialRectCenterArcTween')}($begin \u2192 $end; centerArc=$centerArc)'; + } +} diff --git a/packages/material_ui/lib/src/autocomplete.dart b/packages/material_ui/lib/src/autocomplete.dart new file mode 100644 index 000000000000..686e3028db98 --- /dev/null +++ b/packages/material_ui/lib/src/autocomplete.dart @@ -0,0 +1,308 @@ +// 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/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'ink_well.dart'; +import 'material.dart'; +import 'text_form_field.dart'; +import 'theme.dart'; + +/// {@macro flutter.widgets.RawAutocomplete.RawAutocomplete} +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=-Nny8kzW380} +/// +/// {@tool dartpad} +/// This example shows how to create a very basic Autocomplete widget using the +/// default UI. +/// +/// ** See code in examples/api/lib/material/autocomplete/autocomplete.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to create an Autocomplete widget with a custom type. +/// Try searching with text from the name or email field. +/// +/// ** See code in examples/api/lib/material/autocomplete/autocomplete.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to create an Autocomplete widget whose options are +/// fetched over the network. +/// +/// ** See code in examples/api/lib/material/autocomplete/autocomplete.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to create an Autocomplete widget whose options are +/// fetched over the network. It uses debouncing to wait to perform the network +/// request until after the user finishes typing. +/// +/// ** See code in examples/api/lib/material/autocomplete/autocomplete.3.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to create an Autocomplete widget whose options are +/// fetched over the network. It includes both debouncing and error handling, so +/// that failed network requests show an error to the user and can be recovered +/// from. Try toggling the network Switch widget to simulate going offline. +/// +/// ** See code in examples/api/lib/material/autocomplete/autocomplete.4.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [RawAutocomplete], which is what Autocomplete is built upon, and which +/// contains more detailed examples. +class Autocomplete<T extends Object> extends StatelessWidget { + /// Creates an instance of [Autocomplete]. + const Autocomplete({ + super.key, + required this.optionsBuilder, + this.displayStringForOption = RawAutocomplete.defaultStringForOption, + this.fieldViewBuilder = _defaultFieldViewBuilder, + this.focusNode, + this.onSelected, + this.optionsMaxHeight = 200.0, + this.optionsViewBuilder, + this.optionsViewOpenDirection = OptionsViewOpenDirection.down, + this.textEditingController, + this.initialValue, + }); + + /// {@macro flutter.widgets.RawAutocomplete.displayStringForOption} + final AutocompleteOptionToString<T> displayStringForOption; + + /// {@macro flutter.widgets.RawAutocomplete.fieldViewBuilder} + /// + /// If not provided, will build a standard Material-style text field by + /// default. + final AutocompleteFieldViewBuilder fieldViewBuilder; + + /// The [FocusNode] that is used for the text field. + /// + /// {@macro flutter.widgets.RawAutocomplete.split} + /// + /// If this parameter is not null, then [textEditingController] must also be + /// non-null. + final FocusNode? focusNode; + + /// {@macro flutter.widgets.RawAutocomplete.onSelected} + final AutocompleteOnSelected<T>? onSelected; + + /// {@macro flutter.widgets.RawAutocomplete.optionsBuilder} + final AutocompleteOptionsBuilder<T> optionsBuilder; + + /// {@macro flutter.widgets.RawAutocomplete.optionsViewBuilder} + /// + /// If not provided, will build a standard Material-style list of results by + /// default. + final AutocompleteOptionsViewBuilder<T>? optionsViewBuilder; + + /// {@macro flutter.widgets.RawAutocomplete.optionsViewOpenDirection} + final OptionsViewOpenDirection optionsViewOpenDirection; + + /// The maximum height used for the default Material options list widget. + /// + /// When [optionsViewBuilder] is `null`, this property sets the maximum height + /// that the options widget can occupy. + /// + /// The default value is set to 200. + final double optionsMaxHeight; + + /// The [TextEditingController] that is used for the text field. + /// + /// {@macro flutter.widgets.RawAutocomplete.split} + /// + /// If this parameter is not null, then [focusNode] must also be non-null. + final TextEditingController? textEditingController; + + /// {@macro flutter.widgets.RawAutocomplete.initialValue} + final TextEditingValue? initialValue; + + static Widget _defaultFieldViewBuilder( + BuildContext context, + TextEditingController textEditingController, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + return _AutocompleteField( + focusNode: focusNode, + textEditingController: textEditingController, + onFieldSubmitted: onFieldSubmitted, + ); + } + + @override + Widget build(BuildContext context) { + return RawAutocomplete<T>( + displayStringForOption: displayStringForOption, + fieldViewBuilder: fieldViewBuilder, + focusNode: focusNode, + textEditingController: textEditingController, + initialValue: initialValue, + optionsBuilder: optionsBuilder, + optionsViewOpenDirection: optionsViewOpenDirection, + optionsViewBuilder: + optionsViewBuilder ?? + (BuildContext context, AutocompleteOnSelected<T> onSelected, Iterable<T> options) { + return _AutocompleteOptions<T>( + displayStringForOption: displayStringForOption, + onSelected: onSelected, + options: options, + openDirection: optionsViewOpenDirection, + optionsMaxHeight: optionsMaxHeight, + ); + }, + onSelected: onSelected, + ); + } +} + +// The default Material-style Autocomplete text field. +class _AutocompleteField extends StatelessWidget { + const _AutocompleteField({ + required this.focusNode, + required this.textEditingController, + required this.onFieldSubmitted, + }); + + final FocusNode focusNode; + + final VoidCallback onFieldSubmitted; + + final TextEditingController textEditingController; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + onFieldSubmitted: (String value) { + onFieldSubmitted(); + }, + ); + } +} + +// The default Material-style Autocomplete options. +class _AutocompleteOptions<T extends Object> extends StatelessWidget { + const _AutocompleteOptions({ + super.key, + required this.displayStringForOption, + required this.onSelected, + required this.openDirection, + required this.options, + required this.optionsMaxHeight, + }); + + final AutocompleteOptionToString<T> displayStringForOption; + final AutocompleteOnSelected<T> onSelected; + final OptionsViewOpenDirection openDirection; + final Iterable<T> options; + final double optionsMaxHeight; + + @override + Widget build(BuildContext context) { + final int highlightedIndex = AutocompleteHighlightedOption.of(context); + + return Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: optionsMaxHeight), + child: _AutocompleteOptionsList<T>( + displayStringForOption: displayStringForOption, + highlightedIndex: highlightedIndex, + onSelected: onSelected, + options: options, + ), + ), + ); + } +} + +class _AutocompleteOptionsList<T extends Object> extends StatefulWidget { + const _AutocompleteOptionsList({ + required this.displayStringForOption, + required this.highlightedIndex, + required this.onSelected, + required this.options, + }); + + final AutocompleteOptionToString<T> displayStringForOption; + final int highlightedIndex; + final AutocompleteOnSelected<T> onSelected; + final Iterable<T> options; + + @override + State<_AutocompleteOptionsList<T>> createState() => _AutocompleteOptionsListState<T>(); +} + +class _AutocompleteOptionsListState<T extends Object> extends State<_AutocompleteOptionsList<T>> { + final ScrollController _scrollController = ScrollController(); + + @override + void didUpdateWidget(_AutocompleteOptionsList<T> oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.highlightedIndex != oldWidget.highlightedIndex) { + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!mounted) { + return; + } + final BuildContext? highlightedContext = GlobalObjectKey( + widget.options.elementAt(widget.highlightedIndex), + ).currentContext; + if (highlightedContext == null) { + _scrollController.jumpTo( + widget.highlightedIndex == 0 ? 0.0 : _scrollController.position.maxScrollExtent, + ); + } else { + Scrollable.ensureVisible(highlightedContext, alignment: 0.5); + } + }, debugLabel: 'AutocompleteOptions.ensureVisible'); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final int highlightedIndex = AutocompleteHighlightedOption.of(context); + + return ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + controller: _scrollController, + itemCount: widget.options.length, + itemBuilder: (BuildContext context, int index) { + final T option = widget.options.elementAt(index); + return Semantics( + button: true, + child: InkWell( + key: GlobalObjectKey(option), + onTap: () { + widget.onSelected(option); + }, + child: Builder( + builder: (BuildContext context) { + final highlight = highlightedIndex == index; + return Container( + color: highlight ? Theme.of(context).focusColor : null, + padding: const EdgeInsets.all(16.0), + child: Text(widget.displayStringForOption(option)), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/packages/material_ui/lib/src/back_button.dart b/packages/material_ui/lib/src/back_button.dart new file mode 100644 index 000000000000..95f4b97216e0 --- /dev/null +++ b/packages/material_ui/lib/src/back_button.dart @@ -0,0 +1,5 @@ +// 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. + +export 'action_buttons.dart' show BackButton, BackButtonIcon, CloseButton, CloseButtonIcon; diff --git a/packages/material_ui/lib/src/badge.dart b/packages/material_ui/lib/src/badge.dart new file mode 100644 index 000000000000..517014347795 --- /dev/null +++ b/packages/material_ui/lib/src/badge.dart @@ -0,0 +1,515 @@ +// 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. + +/// @docImport 'icon_button.dart'; +/// @docImport 'navigation_rail.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'badge_theme.dart'; +import 'color_scheme.dart'; +import 'theme.dart'; + +/// A Material Design "badge". +/// +/// A badge's [label] conveys a small amount of information about its +/// [child], like a count or status. If the label is null then this is +/// a "small" badge that's displayed as a [smallSize] diameter filled +/// circle. Otherwise this is a [StadiumBorder] shaped "large" badge +/// with height [largeSize]. +/// +/// Badges are typically used to decorate the icon within a +/// [BottomNavigationBarItem] or a [NavigationRailDestination] +/// or a button's icon, as in [TextButton.icon]. The badge's default +/// configuration is intended to work well with a default sized (24) +/// [Icon]. +/// +/// {@tool dartpad} +/// This example shows how to create a [Badge] with label and count +/// wrapped on an icon in an [IconButton]. +/// +/// ** See code in examples/api/lib/material/badge/badge.0.dart ** +/// {@end-tool} +class Badge extends StatelessWidget { + /// Create a Badge that stacks [label] on top of [child]. + /// + /// If [label] is null then just a filled circle is displayed. Otherwise + /// the [label] is displayed within a [StadiumBorder] shaped area. + const Badge({ + super.key, + this.backgroundColor, + this.textColor, + this.smallSize, + this.largeSize, + this.textStyle, + this.padding, + this.alignment, + this.offset, + this.label, + this.isLabelVisible = true, + this.child, + }); + + /// Convenience constructor for creating a badge with a numeric label based on [count]. + /// + /// Initializes [label] with a [Text] widget that shows: + /// - the [count] value if it is less than or equal to [maxCount], + /// - otherwise, shows '[maxCount]+'. + /// + /// For example, if [count] is 1000 and [maxCount] is 99, the label will display '99+'. + Badge.count({ + super.key, + this.backgroundColor, + this.textColor, + this.smallSize, + this.largeSize, + this.textStyle, + this.padding, + this.alignment, + this.offset, + required int count, + int maxCount = 999, + this.isLabelVisible = true, + this.child, + }) : assert(count >= 0, 'count must be non-negative'), + assert(maxCount > 0, 'maxCount must be positive'), + label = Text(count > maxCount ? '$maxCount+' : '$count'); + + /// The badge's fill color. + /// + /// Defaults to the [BadgeTheme]'s background color, or + /// [ColorScheme.error] if the theme value is null. + final Color? backgroundColor; + + /// The color of the badge's [label] text. + /// + /// This color overrides the color of the label's [textStyle]. + /// + /// Defaults to the [BadgeTheme]'s foreground color, or + /// [ColorScheme.onError] if the theme value is null. + final Color? textColor; + + /// The diameter of the badge if [label] is null. + /// + /// Defaults to the [BadgeTheme]'s small size, or 6 if the theme value + /// is null. + final double? smallSize; + + /// The badge's height if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s large size, or 16 if the theme value + /// is null. If the default value is overridden then it may be useful to + /// also override [padding] and [alignment]. + final double? largeSize; + + /// The [DefaultTextStyle] for the badge's label. + /// + /// The text style's color is overwritten by the [textColor]. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s text style, or the overall theme's + /// [TextTheme.labelSmall] if the badge theme's value is null. If + /// the default text style is overridden then it may be useful to + /// also override [largeSize], [padding], and [alignment]. + final TextStyle? textStyle; + + /// The padding added to the badge's label. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s padding, or 4 pixels on the + /// left and right if the theme's value is null. + final EdgeInsetsGeometry? padding; + + /// Combined with [offset] to determine the location of the [label] + /// relative to the [child]. + /// + /// The alignment positions the label in the same way a child of an + /// [Align] widget is positioned, except that, the alignment is + /// resolved as if the label was a [largeSize] square and [offset] + /// is added to the result. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s alignment, or + /// [AlignmentDirectional.topEnd] if the theme's value is null. + final AlignmentGeometry? alignment; + + /// Combined with [alignment] to determine the location of the [label] + /// relative to the [child]. + /// + /// This value is only used if [label] is non-null. + /// + /// Defaults to the [BadgeTheme]'s offset, or + /// if the theme's value is null then `Offset(4, -4)` for + /// [TextDirection.ltr] or `Offset(-4, -4)` for [TextDirection.rtl]. + final Offset? offset; + + /// The badge's content, typically a [Text] widget that contains 1 to 4 + /// characters. + /// + /// If the label is null then this is a "small" badge that's + /// displayed as a [smallSize] diameter filled circle. Otherwise + /// this is a [StadiumBorder] shaped "large" badge with height [largeSize]. + final Widget? label; + + /// If false, the badge's [label] is not included. + /// + /// This flag is true by default. It's intended to make it convenient + /// to create a badge that's only shown under certain conditions. + final bool isLabelVisible; + + /// The widget that the badge is stacked on top of. + /// + /// Typically this is an default sized [Icon] that's part of a + /// [BottomNavigationBarItem] or a [NavigationRailDestination]. + final Widget? child; + + @override + Widget build(BuildContext context) { + if (!isLabelVisible) { + return child ?? const SizedBox(); + } + + final BadgeThemeData badgeTheme = BadgeTheme.of(context); + final BadgeThemeData defaults = _BadgeDefaultsM3(context); + final Decoration effectiveDecoration = ShapeDecoration( + color: backgroundColor ?? badgeTheme.backgroundColor ?? defaults.backgroundColor!, + shape: const StadiumBorder(), + ); + final double effectiveWidthOffset; + final Widget badge; + final hasLabel = label != null; + if (hasLabel) { + final double minSize = effectiveWidthOffset = + largeSize ?? badgeTheme.largeSize ?? defaults.largeSize!; + badge = DefaultTextStyle( + style: (textStyle ?? badgeTheme.textStyle ?? defaults.textStyle!).copyWith( + color: textColor ?? badgeTheme.textColor ?? defaults.textColor!, + ), + child: _IntrinsicHorizontalStadium( + minSize: minSize, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: effectiveDecoration, + padding: padding ?? badgeTheme.padding ?? defaults.padding!, + alignment: Alignment.center, + child: label, + ), + ), + ); + } else { + final double effectiveSmallSize = effectiveWidthOffset = + smallSize ?? badgeTheme.smallSize ?? defaults.smallSize!; + badge = Container( + width: effectiveSmallSize, + height: effectiveSmallSize, + clipBehavior: Clip.antiAlias, + decoration: effectiveDecoration, + ); + } + + if (child == null) { + return badge; + } + + final AlignmentGeometry effectiveAlignment = + alignment ?? badgeTheme.alignment ?? defaults.alignment!; + final TextDirection textDirection = Directionality.of(context); + final defaultOffset = textDirection == TextDirection.ltr + ? const Offset(4, -4) + : const Offset(-4, -4); + // Adds a offset const Offset(0, 8) to avoiding breaking customers after + // the offset calculation changes. + // See https://github.com/flutter/flutter/pull/146853. + final Offset effectiveOffset = + (offset ?? badgeTheme.offset ?? defaultOffset) + const Offset(0, 8); + + return Stack( + clipBehavior: Clip.none, + children: <Widget>[ + child!, + Positioned.fill( + child: _Badge( + alignment: effectiveAlignment, + offset: hasLabel ? effectiveOffset : Offset.zero, + hasLabel: hasLabel, + widthOffset: effectiveWidthOffset, + textDirection: textDirection, + child: badge, + ), + ), + ], + ); + } +} + +class _Badge extends SingleChildRenderObjectWidget { + const _Badge({ + required this.alignment, + required this.offset, + required this.widthOffset, + required this.textDirection, + required this.hasLabel, + super.child, // the badge + }); + + final AlignmentGeometry alignment; + final Offset offset; + final double widthOffset; + final TextDirection textDirection; + final bool hasLabel; + + @override + _RenderBadge createRenderObject(BuildContext context) { + return _RenderBadge( + alignment: alignment, + widthOffset: widthOffset, + hasLabel: hasLabel, + offset: offset, + textDirection: Directionality.maybeOf(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderBadge renderObject) { + renderObject + ..alignment = alignment + ..offset = offset + ..widthOffset = widthOffset + ..hasLabel = hasLabel + ..textDirection = Directionality.maybeOf(context); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment)); + properties.add(DiagnosticsProperty<Offset>('offset', offset)); + } +} + +class _RenderBadge extends RenderAligningShiftedBox { + _RenderBadge({ + super.textDirection, + super.alignment, + required Offset offset, + required bool hasLabel, + required double widthOffset, + }) : _offset = offset, + _hasLabel = hasLabel, + _widthOffset = widthOffset; + + Offset get offset => _offset; + Offset _offset; + set offset(Offset value) { + if (_offset == value) { + return; + } + _offset = value; + markNeedsLayout(); + } + + bool get hasLabel => _hasLabel; + bool _hasLabel; + set hasLabel(bool value) { + if (_hasLabel == value) { + return; + } + _hasLabel = value; + markNeedsLayout(); + } + + double get widthOffset => _widthOffset; + double _widthOffset; + set widthOffset(double value) { + if (_widthOffset == value) { + return; + } + _widthOffset = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + assert(constraints.hasBoundedWidth); + assert(constraints.hasBoundedHeight); + size = constraints.biggest; + + child!.layout(const BoxConstraints(), parentUsesSize: true); + final double badgeSize = child!.size.height; + final Alignment resolvedAlignment = alignment.resolve(textDirection); + final childParentData = child!.parentData! as BoxParentData; + Offset badgeLocation = + offset + resolvedAlignment.alongOffset(Offset(size.width - widthOffset, size.height)); + if (hasLabel) { + // Adjust for label height. + badgeLocation = badgeLocation - Offset(0, badgeSize / 2); + } + childParentData.offset = badgeLocation; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + // Mirrors performLayout: size is the tightest allowed (biggest) under bounded constraints. + // Callers (e.g., Stack) pass in tight constraints for Positioned.fill; otherwise, this + // is still consistent with performLayout which asserts bounded constraints. + return constraints.biggest; + } + + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + + // Child is laid out with unconstrained BoxConstraints in performLayout. + const childConstraints = BoxConstraints(); + final double? childBaseline = child.getDryBaseline(childConstraints, baseline); + if (childBaseline == null) { + return null; + } + + // Mirror the paint offset logic from performLayout using dry sizes only. + final Size mySize = getDryLayout(constraints); + final Alignment resolvedAlignment = alignment.resolve(textDirection); + final Size childSize = child.getDryLayout(childConstraints); + + Offset badgeLocation = + offset + resolvedAlignment.alongOffset(Offset(mySize.width - widthOffset, mySize.height)); + if (hasLabel) { + // Subtract half of the badge height when we have a label (as in performLayout). + badgeLocation -= Offset(0, childSize.height / 2); + } + + return childBaseline + badgeLocation.dy; + } +} + +/// A widget size itself to the smallest horizontal stadium rect that can still +/// fit the child's intrinsic size. +/// +/// A horizontal stadium means a rect that has width >= height. +/// +/// Uses [minSize] to set the min size of width and height. +class _IntrinsicHorizontalStadium extends SingleChildRenderObjectWidget { + const _IntrinsicHorizontalStadium({super.child, required this.minSize}); + final double minSize; + + @override + _RenderIntrinsicHorizontalStadium createRenderObject(BuildContext context) { + return _RenderIntrinsicHorizontalStadium(minSize: minSize); + } +} + +class _RenderIntrinsicHorizontalStadium extends RenderProxyBox { + _RenderIntrinsicHorizontalStadium({RenderBox? child, required double minSize}) + : _minSize = minSize, + super(child); + + double get minSize => _minSize; + double _minSize; + set minSize(double value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + return getMaxIntrinsicWidth(height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + return math.max(getMaxIntrinsicHeight(double.infinity), super.computeMaxIntrinsicWidth(height)); + } + + @override + double computeMinIntrinsicHeight(double width) { + return getMaxIntrinsicHeight(width); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return math.max(minSize, super.computeMaxIntrinsicHeight(width)); + } + + BoxConstraints _childConstraints(RenderBox child, BoxConstraints constraints) { + final double childHeight = math.max(minSize, child.getMaxIntrinsicHeight(constraints.maxWidth)); + final double childWidth = child.getMaxIntrinsicWidth(constraints.maxHeight); + return constraints.tighten(width: math.max(childWidth, childHeight), height: childHeight); + } + + Size _computeSize({required ChildLayouter layoutChild, required BoxConstraints constraints}) { + final RenderBox child = this.child!; + final Size childSize = layoutChild(child, _childConstraints(child, constraints)); + if (childSize.height > childSize.width) { + return Size(childSize.height, childSize.height); + } + return childSize; + } + + @override + @protected + Size computeDryLayout(covariant BoxConstraints constraints) { + return _computeSize(layoutChild: ChildLayoutHelper.dryLayoutChild, constraints: constraints); + } + + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + final RenderBox child = this.child!; + return child.getDryBaseline(_childConstraints(child, constraints), baseline); + } + + @override + void performLayout() { + size = _computeSize(layoutChild: ChildLayoutHelper.layoutChild, constraints: constraints); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - Badge + +// 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. + +// dart format off +class _BadgeDefaultsM3 extends BadgeThemeData { + _BadgeDefaultsM3(this.context) : super( + smallSize: 6.0, + largeSize: 16.0, + padding: const EdgeInsets.symmetric(horizontal: 4), + alignment: AlignmentDirectional.topEnd, + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color? get backgroundColor => _colors.error; + + @override + Color? get textColor => _colors.onError; + + @override + TextStyle? get textStyle => Theme.of(context).textTheme.labelSmall; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Badge diff --git a/packages/material_ui/lib/src/badge_theme.dart b/packages/material_ui/lib/src/badge_theme.dart new file mode 100644 index 000000000000..fc117a2856e0 --- /dev/null +++ b/packages/material_ui/lib/src/badge_theme.dart @@ -0,0 +1,196 @@ +// 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. + +/// @docImport 'badge.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default properties values for descendant [Badge] widgets. +/// +/// Descendant widgets obtain the current [BadgeThemeData] object +/// using [BadgeTheme.of]. Instances of [BadgeThemeData] can +/// be customized with [BadgeThemeData.copyWith]. +/// +/// Typically a [BadgeThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.badgeTheme]. +/// +/// All [BadgeThemeData] properties are `null` by default. +/// When null, the [Badge] will use the values from [ThemeData] +/// if they exist, otherwise it will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class BadgeThemeData with Diagnosticable { + /// Creates the set of color, style, and size properties used to configure [Badge]. + const BadgeThemeData({ + this.backgroundColor, + this.textColor, + this.smallSize, + this.largeSize, + this.textStyle, + this.padding, + this.alignment, + this.offset, + }); + + /// Overrides the default value for [Badge.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value for [Badge.textColor]. + final Color? textColor; + + /// Overrides the default value for [Badge.smallSize]. + final double? smallSize; + + /// Overrides the default value for [Badge.largeSize]. + final double? largeSize; + + /// Overrides the default value for [Badge.textStyle]. + final TextStyle? textStyle; + + /// Overrides the default value for [Badge.padding]. + final EdgeInsetsGeometry? padding; + + /// Overrides the default value for [Badge.alignment]. + final AlignmentGeometry? alignment; + + /// Overrides the default value for [Badge.offset]. + final Offset? offset; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + BadgeThemeData copyWith({ + Color? backgroundColor, + Color? textColor, + double? smallSize, + double? largeSize, + TextStyle? textStyle, + EdgeInsetsGeometry? padding, + AlignmentGeometry? alignment, + Offset? offset, + }) { + return BadgeThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + textColor: textColor ?? this.textColor, + smallSize: smallSize ?? this.smallSize, + largeSize: largeSize ?? this.largeSize, + textStyle: textStyle ?? this.textStyle, + padding: padding ?? this.padding, + alignment: alignment ?? this.alignment, + offset: offset ?? this.offset, + ); + } + + /// Linearly interpolate between two [Badge] themes. + static BadgeThemeData lerp(BadgeThemeData? a, BadgeThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return BadgeThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + textColor: Color.lerp(a?.textColor, b?.textColor, t), + smallSize: lerpDouble(a?.smallSize, b?.smallSize, t), + largeSize: lerpDouble(a?.largeSize, b?.largeSize, t), + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), + offset: Offset.lerp(a?.offset, b?.offset, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + textColor, + smallSize, + largeSize, + textStyle, + padding, + alignment, + offset, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is BadgeThemeData && + other.backgroundColor == backgroundColor && + other.textColor == textColor && + other.smallSize == smallSize && + other.largeSize == largeSize && + other.textStyle == textStyle && + other.padding == padding && + other.alignment == alignment && + other.offset == offset; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add(DoubleProperty('smallSize', smallSize, defaultValue: null)); + properties.add(DoubleProperty('largeSize', largeSize, defaultValue: null)); + properties.add(DiagnosticsProperty<TextStyle>('textStyle', textStyle, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add( + DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Offset>('offset', offset, defaultValue: null)); + } +} + +/// An inherited widget that overrides the default color style, and size +/// parameters for [Badge]s in this widget's subtree. +/// +/// Values specified here override the defaults for [Badge] properties which +/// are not given an explicit non-null value. +class BadgeTheme extends InheritedTheme { + /// Creates a theme that overrides the default color parameters for [Badge]s + /// in this widget's subtree. + const BadgeTheme({super.key, required this.data, required super.child}); + + /// Specifies the default color and size overrides for descendant [Badge] widgets. + final BadgeThemeData data; + + /// Retrieves the [BadgeThemeData] from the closest ancestor [BadgeTheme]. + /// + /// If there is no enclosing [BadgeTheme] widget, then + /// [ThemeData.badgeTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// BadgeThemeData theme = BadgeTheme.of(context); + /// ``` + static BadgeThemeData of(BuildContext context) { + final BadgeTheme? badgeTheme = context.dependOnInheritedWidgetOfExactType<BadgeTheme>(); + return badgeTheme?.data ?? Theme.of(context).badgeTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return BadgeTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(BadgeTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/banner.dart b/packages/material_ui/lib/src/banner.dart new file mode 100644 index 000000000000..4dc177ecd568 --- /dev/null +++ b/packages/material_ui/lib/src/banner.dart @@ -0,0 +1,523 @@ +// 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. + +/// @docImport 'dart:ui'; +/// +/// @docImport 'text_button.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'banner_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'divider.dart'; +import 'material.dart'; +import 'scaffold.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +const Duration _materialBannerTransitionDuration = Duration(milliseconds: 250); +const Curve _materialBannerHeightCurve = Curves.fastOutSlowIn; +const double _kMaxContentTextScaleFactor = 1.5; + +/// Specify how a [MaterialBanner] was closed. +/// +/// The [ScaffoldMessengerState.showMaterialBanner] function returns a +/// [ScaffoldFeatureController]. The value of the controller's closed property +/// is a Future that resolves to a MaterialBannerClosedReason. Applications that need +/// to know how a [MaterialBanner] was closed can use this value. +/// +/// Example: +/// +/// ```dart +/// ScaffoldMessenger.of(context).showMaterialBanner( +/// const MaterialBanner( +/// content: Text('Message...'), +/// actions: <Widget>[ +/// // ... +/// ], +/// ) +/// ).closed.then((MaterialBannerClosedReason reason) { +/// // ... +/// }); +/// ``` +enum MaterialBannerClosedReason { + /// The material banner was closed through a [SemanticsAction.dismiss]. + dismiss, + + /// The material banner was closed by a user's swipe. + swipe, + + /// The material banner was closed by the [ScaffoldFeatureController] close callback + /// or by calling [ScaffoldMessengerState.hideCurrentMaterialBanner] directly. + hide, + + /// The material banner was closed by a call to [ScaffoldMessengerState.removeCurrentMaterialBanner]. + remove, +} + +/// A Material Design banner. +/// +/// A banner displays an important, succinct message, and provides actions for +/// users to address (or dismiss the banner). A user action is required for it +/// to be dismissed. +/// +/// Banners should be displayed at the top of the screen, below a top app bar. +/// They are persistent and non-modal, allowing the user to either ignore them or +/// interact with them at any time. +/// +/// {@tool dartpad} +/// Banners placed directly into the widget tree are static. +/// +/// ** See code in examples/api/lib/material/banner/material_banner.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// MaterialBanner's can also be presented through a [ScaffoldMessenger]. +/// Here is an example where ScaffoldMessengerState.showMaterialBanner() is used to show the MaterialBanner. +/// +/// ** See code in examples/api/lib/material/banner/material_banner.1.dart ** +/// {@end-tool} +/// +/// The [actions] will be placed beside the [content] if there is only one. +/// Otherwise, the [actions] will be placed below the [content]. Use +/// [forceActionsBelow] to override this behavior. +/// +/// If the [actions] placed below the [content], they will be laid out in a row. +/// If there isn't sufficient room to display everything, they are laid out +/// in a column instead. +/// +/// The [actions] and [content] must be provided. An optional leading widget +/// (typically an [Image]) can also be provided. The [contentTextStyle] and +/// [backgroundColor] can be provided to customize the banner. +/// +/// This widget is unrelated to the widgets library [Banner] widget. +class MaterialBanner extends StatefulWidget { + /// Creates a [MaterialBanner]. + /// + /// The length of the [actions] list must not be empty. The [elevation] must + /// be null or non-negative. + const MaterialBanner({ + super.key, + required this.content, + this.contentTextStyle, + required this.actions, + this.elevation, + this.leading, + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.dividerColor, + this.padding, + this.margin, + this.leadingPadding, + this.forceActionsBelow = false, + this.overflowAlignment = OverflowBarAlignment.end, + this.animation, + this.onVisible, + this.minActionBarHeight = 52.0, + }) : assert(elevation == null || elevation >= 0.0); + + /// The content of the [MaterialBanner]. + /// + /// Typically a [Text] widget. + final Widget content; + + /// Style for the text in the [content] of the [MaterialBanner]. + /// + /// If `null`, [MaterialBannerThemeData.contentTextStyle] is used. If that is + /// also `null`, [TextTheme.bodyMedium] of [ThemeData.textTheme] is used. + final TextStyle? contentTextStyle; + + /// The set of actions that are displayed at the bottom or trailing side of + /// the [MaterialBanner]. + /// + /// Typically this is a list of [TextButton] widgets. + final List<Widget> actions; + + /// The z-coordinate at which to place the material banner. + /// + /// This controls the size of the shadow below the material banner. + /// + /// Defines the banner's [Material.elevation]. + /// + /// If this property is null, then the ambient [MaterialBannerThemeData.elevation] + /// is used, if that is also null, the default value is 0. + /// If the elevation is 0, the [Scaffold]'s body will be pushed down by the + /// MaterialBanner when used with [ScaffoldMessenger]. + final double? elevation; + + /// The (optional) leading widget of the [MaterialBanner]. + /// + /// Typically an [Icon] widget. + final Widget? leading; + + /// The optional minimum action bar height. + /// + /// Default to 52.0. + final double minActionBarHeight; + + /// The color of the surface of this [MaterialBanner]. + /// + /// If `null`, [MaterialBannerThemeData.backgroundColor] is used. If that is + /// also `null`, [ColorScheme.surfaceContainerLow] of [ThemeData.colorScheme] is used. + final Color? backgroundColor; + + /// The color used as an overlay on [backgroundColor] to indicate elevation. + /// + /// If null, [MaterialBannerThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [Colors.transparent]. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + final Color? surfaceTintColor; + + /// The color of the shadow below the [MaterialBanner]. + /// + /// If this property is null, then the ambient [MaterialBannerThemeData.shadowColor] + /// is used. If that is also null, the default value is null. + final Color? shadowColor; + + /// The color of the divider. + /// + /// If this property is null, then the ambient [MaterialBannerThemeData.dividerColor] + /// is used. If that is also null, the default value is [ColorScheme.surfaceVariant]. + final Color? dividerColor; + + /// The amount of space by which to inset the [content]. + /// + /// If the [actions] are below the [content], this defaults to + /// `EdgeInsetsDirectional.only(start: 16.0, top: 24.0, end: 16.0, bottom: 4.0)`. + /// + /// If the [actions] are trailing the [content], this defaults to + /// `EdgeInsetsDirectional.only(start: 16.0, top: 2.0)`. + final EdgeInsetsGeometry? padding; + + /// Empty space to surround the [MaterialBanner]. + /// + /// If the [margin] is null then this defaults to + /// 0 if the banner's [elevation] is 0, 10 otherwise. + final EdgeInsetsGeometry? margin; + + /// The amount of space by which to inset the [leading] widget. + /// + /// This defaults to `EdgeInsetsDirectional.only(end: 16.0)`. + final EdgeInsetsGeometry? leadingPadding; + + /// An override to force the [actions] to be below the [content] regardless of + /// how many there are. + /// + /// If this is true, the [actions] will be placed below the [content]. If + /// this is false, the [actions] will be placed on the trailing side of the + /// [content] if [actions]'s length is 1 and below the [content] if greater + /// than 1. + /// + /// Defaults to false. + final bool forceActionsBelow; + + /// The horizontal alignment of the [actions] when the [actions] laid out in a column. + /// + /// Defaults to [OverflowBarAlignment.end]. + final OverflowBarAlignment overflowAlignment; + + /// The animation driving the entrance and exit of the material banner when presented by the [ScaffoldMessenger]. + final Animation<double>? animation; + + /// Called the first time that the material banner is visible within a [Scaffold] when presented by the [ScaffoldMessenger]. + final VoidCallback? onVisible; + + // API for ScaffoldMessengerState.showMaterialBanner(): + + /// Creates an animation controller useful for driving a [MaterialBanner]'s entrance and exit animation. + static AnimationController createAnimationController({required TickerProvider vsync}) { + return AnimationController( + duration: _materialBannerTransitionDuration, + debugLabel: 'MaterialBanner', + vsync: vsync, + ); + } + + /// Creates a copy of this material banner but with the animation replaced with the given animation. + /// + /// If the original material banner lacks a key, the newly created material banner will + /// use the given fallback key. + MaterialBanner withAnimation(Animation<double> newAnimation, {Key? fallbackKey}) { + return MaterialBanner( + key: key ?? fallbackKey, + content: content, + contentTextStyle: contentTextStyle, + actions: actions, + elevation: elevation, + leading: leading, + minActionBarHeight: minActionBarHeight, + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + dividerColor: dividerColor, + padding: padding, + margin: margin, + leadingPadding: leadingPadding, + forceActionsBelow: forceActionsBelow, + overflowAlignment: overflowAlignment, + animation: newAnimation, + onVisible: onVisible, + ); + } + + @override + State<MaterialBanner> createState() => _MaterialBannerState(); +} + +class _MaterialBannerState extends State<MaterialBanner> { + bool _wasVisible = false; + CurvedAnimation? _heightAnimation; + CurvedAnimation? _slideOutCurvedAnimation; + + @override + void initState() { + super.initState(); + widget.animation?.addStatusListener(_onAnimationStatusChanged); + _setCurvedAnimations(); + } + + @override + void didUpdateWidget(MaterialBanner oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.animation != oldWidget.animation) { + oldWidget.animation?.removeStatusListener(_onAnimationStatusChanged); + widget.animation?.addStatusListener(_onAnimationStatusChanged); + _setCurvedAnimations(); + } + } + + void _setCurvedAnimations() { + _heightAnimation?.dispose(); + _slideOutCurvedAnimation?.dispose(); + if (widget.animation != null) { + _heightAnimation = CurvedAnimation( + parent: widget.animation!, + curve: _materialBannerHeightCurve, + ); + _slideOutCurvedAnimation = CurvedAnimation( + parent: widget.animation!, + curve: const Threshold(0.0), + ); + } else { + _heightAnimation = null; + _slideOutCurvedAnimation = null; + } + } + + @override + void dispose() { + widget.animation?.removeStatusListener(_onAnimationStatusChanged); + _heightAnimation?.dispose(); + _slideOutCurvedAnimation?.dispose(); + super.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus status) { + if (status.isCompleted) { + if (widget.onVisible != null && !_wasVisible) { + widget.onVisible!(); + } + _wasVisible = true; + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final bool accessibleNavigation = MediaQuery.accessibleNavigationOf(context); + + assert(widget.actions.isNotEmpty); + + final ThemeData theme = Theme.of(context); + final MaterialBannerThemeData bannerTheme = MaterialBannerTheme.of(context); + final MaterialBannerThemeData defaults = theme.useMaterial3 + ? _BannerDefaultsM3(context) + : _BannerDefaultsM2(context); + + final bool isSingleRow = widget.actions.length == 1 && !widget.forceActionsBelow; + final EdgeInsetsGeometry padding = + widget.padding ?? + bannerTheme.padding ?? + (isSingleRow + ? const EdgeInsetsDirectional.only(start: 16.0, top: 2.0) + : const EdgeInsetsDirectional.only(start: 16.0, top: 24.0, end: 16.0, bottom: 4.0)); + final EdgeInsetsGeometry leadingPadding = + widget.leadingPadding ?? + bannerTheme.leadingPadding ?? + const EdgeInsetsDirectional.only(end: 16.0); + + final Widget actionsBar = ConstrainedBox( + constraints: BoxConstraints(minHeight: widget.minActionBarHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: OverflowBar( + overflowAlignment: widget.overflowAlignment, + spacing: 8, + children: widget.actions, + ), + ), + ), + ); + + final double elevation = widget.elevation ?? bannerTheme.elevation ?? 0.0; + final EdgeInsetsGeometry margin = + widget.margin ?? EdgeInsets.only(bottom: elevation > 0 ? 10.0 : 0.0); + final Color backgroundColor = + widget.backgroundColor ?? bannerTheme.backgroundColor ?? defaults.backgroundColor!; + final Color? surfaceTintColor = + widget.surfaceTintColor ?? bannerTheme.surfaceTintColor ?? defaults.surfaceTintColor; + final Color? shadowColor = widget.shadowColor ?? bannerTheme.shadowColor; + final Color? dividerColor = + widget.dividerColor ?? bannerTheme.dividerColor ?? defaults.dividerColor; + final TextStyle? textStyle = + widget.contentTextStyle ?? bannerTheme.contentTextStyle ?? defaults.contentTextStyle; + + Widget materialBanner = Padding( + padding: margin, + child: Material( + elevation: elevation, + color: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Padding( + padding: padding, + child: Row( + children: <Widget>[ + if (widget.leading != null) + Padding(padding: leadingPadding, child: widget.leading), + MediaQuery.withClampedTextScaling( + // Set maximum text scale factor to _kMaxContentTextScaleFactor for the + // content to keep the visual hierarchy the same even with larger font + // sizes. + maxScaleFactor: _kMaxContentTextScaleFactor, + child: Expanded( + child: DefaultTextStyle(style: textStyle!, child: widget.content), + ), + ), + if (isSingleRow) + MediaQuery.withClampedTextScaling( + // Set maximum text scale factor to _kMaxContentTextScaleFactor for the + // actionsBar to keep the visual hierarchy the same even with larger font + // sizes. + maxScaleFactor: _kMaxContentTextScaleFactor, + child: actionsBar, + ), + ], + ), + ), + if (!isSingleRow) actionsBar, + if (elevation == 0) Divider(height: 0, color: dividerColor), + ], + ), + ), + ); + + // This provides a static banner for backwards compatibility. + if (widget.animation == null) { + return materialBanner; + } + + materialBanner = SafeArea(child: materialBanner); + + final Animation<Offset> slideOutAnimation = Tween<Offset>( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(_slideOutCurvedAnimation!); + + materialBanner = Semantics( + container: true, + liveRegion: true, + onDismiss: () { + ScaffoldMessenger.of( + context, + ).removeCurrentMaterialBanner(reason: MaterialBannerClosedReason.dismiss); + }, + child: accessibleNavigation + ? materialBanner + : SlideTransition(position: slideOutAnimation, child: materialBanner), + ); + + final Widget materialBannerTransition; + if (accessibleNavigation) { + materialBannerTransition = materialBanner; + } else { + materialBannerTransition = AnimatedBuilder( + animation: _heightAnimation!, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.bottomStart, + heightFactor: _heightAnimation!.value, + child: child, + ); + }, + child: materialBanner, + ); + } + + return Hero( + tag: '<MaterialBanner Hero tag - ${widget.content}>', + child: ClipRect(child: materialBannerTransition), + ); + } +} + +class _BannerDefaultsM2 extends MaterialBannerThemeData { + _BannerDefaultsM2(this.context) : _theme = Theme.of(context), super(elevation: 0.0); + + final BuildContext context; + final ThemeData _theme; + + @override + Color? get backgroundColor => _theme.colorScheme.surface; + + @override + TextStyle? get contentTextStyle => _theme.textTheme.bodyMedium; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Banner + +// 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. + +// dart format off +class _BannerDefaultsM3 extends MaterialBannerThemeData { + _BannerDefaultsM3(this.context) + : super(elevation: 1.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get backgroundColor => _colors.surfaceContainerLow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get dividerColor => _colors.outlineVariant; + + @override + TextStyle? get contentTextStyle => _textTheme.bodyMedium; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Banner diff --git a/packages/material_ui/lib/src/banner_theme.dart b/packages/material_ui/lib/src/banner_theme.dart new file mode 100644 index 000000000000..c5f2bf051c55 --- /dev/null +++ b/packages/material_ui/lib/src/banner_theme.dart @@ -0,0 +1,206 @@ +// 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. + +/// @docImport 'banner.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the visual properties of [MaterialBanner] widgets. +/// +/// Descendant widgets obtain the current [MaterialBannerThemeData] object using +/// [MaterialBannerTheme.of]. Instances of [MaterialBannerThemeData] +/// can be customized with [MaterialBannerThemeData.copyWith]. +/// +/// Typically a [MaterialBannerThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.bannerTheme]. +/// +/// All [MaterialBannerThemeData] properties are `null` by default. When null, +/// the [MaterialBanner] will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class MaterialBannerThemeData with Diagnosticable { + /// Creates a theme that can be used for [MaterialBannerTheme] or + /// [ThemeData.bannerTheme]. + const MaterialBannerThemeData({ + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.dividerColor, + this.contentTextStyle, + this.elevation, + this.padding, + this.leadingPadding, + }); + + /// The background color of a [MaterialBanner]. + final Color? backgroundColor; + + /// Overrides the default value of [MaterialBanner.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of [MaterialBanner.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value of [MaterialBanner.dividerColor]. + final Color? dividerColor; + + /// Used to configure the [DefaultTextStyle] for the [MaterialBanner.content] + /// widget. + final TextStyle? contentTextStyle; + + /// Default value for [MaterialBanner.elevation]. + // + // If null, MaterialBanner uses a default of 0.0. + final double? elevation; + + /// The amount of space by which to inset [MaterialBanner.content]. + final EdgeInsetsGeometry? padding; + + /// The amount of space by which to inset [MaterialBanner.leading]. + final EdgeInsetsGeometry? leadingPadding; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + MaterialBannerThemeData copyWith({ + Color? backgroundColor, + Color? surfaceTintColor, + Color? shadowColor, + Color? dividerColor, + TextStyle? contentTextStyle, + double? elevation, + EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? leadingPadding, + }) { + return MaterialBannerThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shadowColor: shadowColor ?? this.shadowColor, + dividerColor: dividerColor ?? this.dividerColor, + contentTextStyle: contentTextStyle ?? this.contentTextStyle, + elevation: elevation ?? this.elevation, + padding: padding ?? this.padding, + leadingPadding: leadingPadding ?? this.leadingPadding, + ); + } + + /// Linearly interpolate between two Banner themes. + /// + /// {@macro dart.ui.shadow.lerp} + static MaterialBannerThemeData lerp( + MaterialBannerThemeData? a, + MaterialBannerThemeData? b, + double t, + ) { + return MaterialBannerThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + dividerColor: Color.lerp(a?.dividerColor, b?.dividerColor, t), + contentTextStyle: TextStyle.lerp(a?.contentTextStyle, b?.contentTextStyle, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + leadingPadding: EdgeInsetsGeometry.lerp(a?.leadingPadding, b?.leadingPadding, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + surfaceTintColor, + shadowColor, + dividerColor, + contentTextStyle, + elevation, + padding, + leadingPadding, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is MaterialBannerThemeData && + other.backgroundColor == backgroundColor && + other.surfaceTintColor == surfaceTintColor && + other.shadowColor == shadowColor && + other.dividerColor == dividerColor && + other.contentTextStyle == contentTextStyle && + other.elevation == elevation && + other.padding == padding && + other.leadingPadding == leadingPadding; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('dividerColor', dividerColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('contentTextStyle', contentTextStyle, defaultValue: null), + ); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('leadingPadding', leadingPadding, defaultValue: null), + ); + } +} + +/// An inherited widget that defines the configuration for +/// [MaterialBanner]s in this widget's subtree. +/// +/// Values specified here are used for [MaterialBanner] properties that are not +/// given an explicit non-null value. +class MaterialBannerTheme extends InheritedTheme { + /// Creates a banner theme that controls the configurations for + /// [MaterialBanner]s in its widget subtree. + const MaterialBannerTheme({super.key, this.data, required super.child}); + + /// The properties for descendant [MaterialBanner] widgets. + final MaterialBannerThemeData? data; + + /// The closest instance of this class's [data] value that encloses the given + /// context. + /// + /// If there is no ancestor, it returns [ThemeData.bannerTheme]. Applications + /// can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// MaterialBannerThemeData theme = MaterialBannerTheme.of(context); + /// ``` + static MaterialBannerThemeData of(BuildContext context) { + final MaterialBannerTheme? bannerTheme = context + .dependOnInheritedWidgetOfExactType<MaterialBannerTheme>(); + return bannerTheme?.data ?? Theme.of(context).bannerTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return MaterialBannerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(MaterialBannerTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/bottom_app_bar.dart b/packages/material_ui/lib/src/bottom_app_bar.dart new file mode 100644 index 000000000000..2a12ec014fef --- /dev/null +++ b/packages/material_ui/lib/src/bottom_app_bar.dart @@ -0,0 +1,337 @@ +// 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. + +/// @docImport 'app_bar.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'floating_action_button_location.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'icons.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'bottom_app_bar_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'elevation_overlay.dart'; +import 'material.dart'; +import 'scaffold.dart'; +import 'theme.dart'; + +// Examples can assume: +// late Widget bottomAppBarContents; + +/// A container that is typically used with [Scaffold.bottomNavigationBar]. +/// +/// Typically used with a [Scaffold] and a [FloatingActionButton]. +/// +/// {@tool snippet} +/// ```dart +/// Scaffold( +/// bottomNavigationBar: BottomAppBar( +/// color: Colors.white, +/// child: bottomAppBarContents, +/// ), +/// floatingActionButton: const FloatingActionButton(onPressed: null), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows the [BottomAppBar], which can be configured to have a notch using the +/// [BottomAppBar.shape] property. This also includes an optional [FloatingActionButton], which illustrates +/// the [FloatingActionButtonLocation]s in relation to the [BottomAppBar]. +/// +/// ** See code in examples/api/lib/material/bottom_app_bar/bottom_app_bar.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows Material 3 [BottomAppBar] with its expected look and behaviors. +/// +/// This also includes an optional [FloatingActionButton], which illustrates +/// the [FloatingActionButtonLocation.endContained]. +/// +/// ** See code in examples/api/lib/material/bottom_app_bar/bottom_app_bar.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [NotchedShape] which calculates the notch for a notched [BottomAppBar]. +/// * [FloatingActionButton] which the [BottomAppBar] makes a notch for. +/// * [AppBar] for a toolbar that is shown at the top of the screen. +class BottomAppBar extends StatefulWidget { + /// Creates a bottom application bar. + /// + /// The [clipBehavior] argument defaults to [Clip.none]. + /// Additionally, [elevation] must be non-negative. + /// + /// If [color], [elevation], or [shape] are null, their [BottomAppBarThemeData] values will be used. + /// If the corresponding [BottomAppBarThemeData] property is null, then the default + /// specified in the property's documentation will be used. + const BottomAppBar({ + super.key, + this.color, + this.elevation, + this.shape, + this.clipBehavior = Clip.none, + this.notchMargin = 4.0, + this.child, + this.padding, + this.surfaceTintColor, + this.shadowColor, + this.height, + }) : assert(elevation == null || elevation >= 0.0); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + /// + /// Typically the child will be a [Row] whose first child + /// is an [IconButton] with the [Icons.menu] icon. + final Widget? child; + + /// The amount of space to surround the child inside the bounds of the [BottomAppBar]. + /// + /// In Material 3 the padding will default to `EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0)` + /// Otherwise the value will default to EdgeInsets.zero. + final EdgeInsetsGeometry? padding; + + /// The bottom app bar's background color. + /// + /// If this property is null then the ambient [BottomAppBarThemeData.color] + /// is used. If that's null and [ThemeData.useMaterial3] is true, + /// the default value is [ColorScheme.surface]; if [ThemeData.useMaterial3] + /// is false, then the default value is `Color(0xFF424242)` in dark theme and + /// [Colors.white] in light theme. + final Color? color; + + /// The z-coordinate at which to place this bottom app bar relative to its + /// parent. + /// + /// This controls the size of the shadow below the bottom app bar. The + /// value is non-negative. + /// + /// If this property is null then the ambient [BottomAppBarThemeData.elevation] + /// is used. If that's null and [ThemeData.useMaterial3] is true, + /// then the default value is 3 else is 8. + final double? elevation; + + /// The notch that is made for the floating action button. + /// + /// If this property is null then the ambient [BottomAppBarThemeData.shape] + /// is used. If that's null then the shape will be rectangular with no notch. + final NotchedShape? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// The margin between the [FloatingActionButton] and the [BottomAppBar]'s + /// notch. + /// + /// Not used if [shape] is null. + final double notchMargin; + + /// A custom color for the Material 3 surface-tint elevation effect. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If this property is null, then the ambient [BottomAppBarThemeData.surfaceTintColor] + /// is used. If that is also null, the default value is [Colors.transparent]. + /// + /// Ignored if [ThemeData.useMaterial3] is false. + /// + /// See [Material.surfaceTintColor] for more details on how this overlay is applied. + final Color? surfaceTintColor; + + /// The color of the shadow below the app bar. + /// + /// If this property is null, then the ambient [BottomAppBarThemeData.shadowColor] + /// is used. If that is also null, the default value is fully opaque black for + /// Material 2, and transparent for Material 3. + /// + /// See also: + /// + /// * [elevation], which defines the size of the shadow below the app bar. + /// * [shape], which defines the shape of the app bar and its shadow. + final Color? shadowColor; + + /// The double value used to indicate the height of the [BottomAppBar]. + /// + /// If this is null, the default value is the minimum in relation to the content, + /// unless [ThemeData.useMaterial3] is true, in which case it defaults to 80.0. + final double? height; + + @override + State createState() => _BottomAppBarState(); +} + +class _BottomAppBarState extends State<BottomAppBar> { + late ValueListenable<ScaffoldGeometry> geometryListenable; + final GlobalKey materialKey = GlobalKey(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + geometryListenable = Scaffold.geometryOf(context); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final bool isMaterial3 = theme.useMaterial3; + final BottomAppBarThemeData babTheme = BottomAppBarTheme.of(context); + final BottomAppBarThemeData defaults = isMaterial3 + ? _BottomAppBarDefaultsM3(context) + : _BottomAppBarDefaultsM2(context); + + final bool hasFab = Scaffold.of(context).hasFloatingActionButton; + final NotchedShape? notchedShape = widget.shape ?? babTheme.shape ?? defaults.shape; + final CustomClipper<Path> clipper = notchedShape != null && hasFab + ? _BottomAppBarClipper( + geometry: geometryListenable, + shape: notchedShape, + materialKey: materialKey, + notchMargin: widget.notchMargin, + ) + : const ShapeBorderClipper(shape: RoundedRectangleBorder()); + final double elevation = widget.elevation ?? babTheme.elevation ?? defaults.elevation!; + final double? height = widget.height ?? babTheme.height ?? defaults.height; + final Color color = widget.color ?? babTheme.color ?? defaults.color!; + final Color surfaceTintColor = + widget.surfaceTintColor ?? babTheme.surfaceTintColor ?? defaults.surfaceTintColor!; + final Color effectiveColor = isMaterial3 + ? ElevationOverlay.applySurfaceTint(color, surfaceTintColor, elevation) + : ElevationOverlay.applyOverlay(context, color, elevation); + final Color shadowColor = widget.shadowColor ?? babTheme.shadowColor ?? defaults.shadowColor!; + + final Widget child = SizedBox( + height: height, + child: Padding( + padding: + widget.padding ?? + babTheme.padding ?? + (isMaterial3 + ? const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0) + : EdgeInsets.zero), + child: widget.child, + ), + ); + + final material = Material( + key: materialKey, + type: MaterialType.transparency, + child: SafeArea(child: child), + ); + + return PhysicalShape( + clipper: clipper, + elevation: elevation, + shadowColor: shadowColor, + color: effectiveColor, + clipBehavior: widget.clipBehavior, + child: material, + ); + } +} + +class _BottomAppBarClipper extends CustomClipper<Path> { + const _BottomAppBarClipper({ + required this.geometry, + required this.shape, + required this.materialKey, + required this.notchMargin, + }) : super(reclip: geometry); + + final ValueListenable<ScaffoldGeometry> geometry; + final NotchedShape shape; + final GlobalKey materialKey; + final double notchMargin; + + // Returns the top of the BottomAppBar in global coordinates. + // + // If the Scaffold's bottomNavigationBar was specified, then we can use its + // geometry value, otherwise we compute the location based on the AppBar's + // Material widget. + double get bottomNavigationBarTop { + final double? bottomNavigationBarTop = geometry.value.bottomNavigationBarTop; + if (bottomNavigationBarTop != null) { + return bottomNavigationBarTop; + } + final box = materialKey.currentContext?.findRenderObject() as RenderBox?; + return box?.localToGlobal(Offset.zero).dy ?? 0; + } + + @override + Path getClip(Size size) { + // button is the floating action button's bounding rectangle in the + // coordinate system whose origin is at the appBar's top left corner, + // or null if there is no floating action button. + final Rect? button = geometry.value.floatingActionButtonArea?.translate( + 0.0, + bottomNavigationBarTop * -1.0, + ); + return shape.getOuterPath(Offset.zero & size, button?.inflate(notchMargin)); + } + + @override + bool shouldReclip(_BottomAppBarClipper oldClipper) { + return oldClipper.geometry != geometry || + oldClipper.shape != shape || + oldClipper.notchMargin != notchMargin; + } +} + +class _BottomAppBarDefaultsM2 extends BottomAppBarThemeData { + const _BottomAppBarDefaultsM2(this.context) : super(elevation: 8.0); + + final BuildContext context; + + @override + Color? get color => + Theme.brightnessOf(context) == Brightness.dark ? Colors.grey[800]! : Colors.white; + + @override + Color? get surfaceTintColor => Theme.of(context).colorScheme.surfaceTint; + + @override + Color get shadowColor => const Color(0xFF000000); +} + +// BEGIN GENERATED TOKEN PROPERTIES - BottomAppBar + +// 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. + +// dart format off +class _BottomAppBarDefaultsM3 extends BottomAppBarThemeData { + _BottomAppBarDefaultsM3(this.context) + : super( + elevation: 3.0, + height: 80.0, + shape: const AutomaticNotchedShape(RoundedRectangleBorder()), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get color => _colors.surfaceContainer; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get shadowColor => Colors.transparent; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - BottomAppBar diff --git a/packages/material_ui/lib/src/bottom_app_bar_theme.dart b/packages/material_ui/lib/src/bottom_app_bar_theme.dart new file mode 100644 index 000000000000..38f989535706 --- /dev/null +++ b/packages/material_ui/lib/src/bottom_app_bar_theme.dart @@ -0,0 +1,328 @@ +// 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. + +/// @docImport 'bottom_app_bar.dart'; +/// @docImport 'material.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +/// Defines default property values for descendant [BottomAppBar] widgets. +/// +/// Descendant widgets obtain the current [BottomAppBarThemeData] object using +/// [BottomAppBarTheme.of]. Instances of [BottomAppBarThemeData] can be +/// customized with [BottomAppBarThemeData.copyWith]. +/// +/// Typically a [BottomAppBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.bottomAppBarTheme]. +/// +/// All [BottomAppBarTheme] properties are `null` by default. When null, the +/// [BottomAppBar] constructor provides defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class BottomAppBarTheme extends InheritedTheme with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.bottomAppBarTheme]. + const BottomAppBarTheme({ + super.key, + Color? color, + double? elevation, + NotchedShape? shape, + double? height, + Color? surfaceTintColor, + Color? shadowColor, + EdgeInsetsGeometry? padding, + BottomAppBarThemeData? data, + Widget? child, + }) : assert( + data == null || + (color ?? + elevation ?? + shape ?? + height ?? + surfaceTintColor ?? + shadowColor ?? + padding) == + null, + ), + _color = color, + _elevation = elevation, + _shape = shape, + _height = height, + _surfaceTintColor = surfaceTintColor, + _shadowColor = shadowColor, + _padding = padding, + _data = data, + super(child: child ?? const SizedBox.shrink()); + + final BottomAppBarThemeData? _data; + final Color? _color; + final double? _elevation; + final NotchedShape? _shape; + final double? _height; + final Color? _surfaceTintColor; + final Color? _shadowColor; + final EdgeInsetsGeometry? _padding; + + /// Overrides the default value for [BottomAppBar.color]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.color] property in [data] instead. + Color? get color => _data != null ? _data.color : _color; + + /// Overrides the default value for [BottomAppBar.elevation]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.elevation] property in [data] instead. + double? get elevation => _data != null ? _data.elevation : _elevation; + + /// Overrides the default value for [BottomAppBar.shape]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.shape] property in [data] instead. + NotchedShape? get shape => _data != null ? _data.shape : _shape; + + /// Overrides the default value for [BottomAppBar.height]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.height] property in [data] instead. + double? get height => _data != null ? _data.height : _height; + + /// Overrides the default value for [BottomAppBar.surfaceTintColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.surfaceTintColor] property in [data] instead. + /// + /// If null, [BottomAppBar] will not display an overlay color. + /// + /// See [Material.surfaceTintColor] for more details. + Color? get surfaceTintColor => _data != null ? _data.surfaceTintColor : _surfaceTintColor; + + /// Overrides the default value for [BottomAppBar.shadowColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.shadowColor] property in [data] instead. + Color? get shadowColor => _data != null ? _data.shadowColor : _shadowColor; + + /// Overrides the default value for [BottomAppBar.padding]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.padding] property in [data] instead. + EdgeInsetsGeometry? get padding => _data != null ? _data.padding : _padding; + + /// The properties used for all descendant [BottomAppBar] widgets. + BottomAppBarThemeData get data => + _data ?? + BottomAppBarThemeData( + color: _color, + elevation: _elevation, + shape: _shape, + height: _height, + surfaceTintColor: _surfaceTintColor, + shadowColor: _shadowColor, + padding: _padding, + ); + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.copyWith] method instead. + BottomAppBarTheme copyWith({ + Color? color, + double? elevation, + NotchedShape? shape, + double? height, + Color? surfaceTintColor, + Color? shadowColor, + EdgeInsetsGeometry? padding, + }) { + return BottomAppBarTheme( + color: color ?? this.color, + elevation: elevation ?? this.elevation, + shape: shape ?? this.shape, + height: height ?? this.height, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shadowColor: shadowColor ?? this.shadowColor, + padding: padding ?? this.padding, + ); + } + + /// Returns the closest [BottomAppBarThemeData] instance given the build context. + static BottomAppBarThemeData of(BuildContext context) { + final BottomAppBarTheme? bottomAppBarTheme = context + .dependOnInheritedWidgetOfExactType<BottomAppBarTheme>(); + return bottomAppBarTheme?.data ?? Theme.of(context).bottomAppBarTheme; + } + + /// Linearly interpolate between two bottom app bar themes. + /// + /// {@macro dart.ui.shadow.lerp} + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [BottomAppBarThemeData.lerp] instead. + static BottomAppBarTheme lerp(BottomAppBarTheme? a, BottomAppBarTheme? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return BottomAppBarTheme( + color: Color.lerp(a?.color, b?.color, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shape: t < 0.5 ? a?.shape : b?.shape, + height: lerpDouble(a?.height, b?.height, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + ); + } + + @override + bool updateShouldNotify(BottomAppBarTheme oldWidget) => data != oldWidget.data; + + @override + Widget wrap(BuildContext context, Widget child) { + return BottomAppBarTheme(data: data, child: child); + } +} + +/// Defines default property values for descendant [BottomAppBar] widgets. +/// +/// Descendant widgets obtain the current [BottomAppBarThemeData] object using +/// [BottomAppBarTheme.of]. Instances of [BottomAppBarThemeData] can be +/// customized with [BottomAppBarThemeData.copyWith]. +/// +/// Typically a [BottomAppBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.bottomAppBarTheme]. +/// +/// All [BottomAppBarThemeData] properties are `null` by default. When null, the [BottomAppBar] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. See the individual [BottomAppBar] properties for details. +/// +/// See also: +/// +/// * [BottomAppBar], which is the widget that this theme configures. +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class BottomAppBarThemeData with Diagnosticable { + /// Creates a bottom app bar theme that can be used with [ThemeData.bottomAppBarTheme]. + const BottomAppBarThemeData({ + this.color, + this.elevation, + this.shape, + this.height, + this.surfaceTintColor, + this.shadowColor, + this.padding, + }); + + /// Overrides the default value for [BottomAppBar.color]. + final Color? color; + + /// Overrides the default value for [BottomAppBar.elevation]. + final double? elevation; + + /// Overrides the default value for [BottomAppBar.shape]. + final NotchedShape? shape; + + /// Overrides the default value for [BottomAppBar.height]. + final double? height; + + /// Overrides the default value for [BottomAppBar.surfaceTintColor]. + /// + /// If null, [BottomAppBar] will not display an overlay color. + /// + /// See [Material.surfaceTintColor] for more details. + final Color? surfaceTintColor; + + /// Overrides the default value for [BottomAppBar.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value for [BottomAppBar.padding]. + final EdgeInsetsGeometry? padding; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + BottomAppBarThemeData copyWith({ + Color? color, + double? elevation, + NotchedShape? shape, + double? height, + Color? surfaceTintColor, + Color? shadowColor, + EdgeInsetsGeometry? padding, + }) { + return BottomAppBarThemeData( + color: color ?? this.color, + elevation: elevation ?? this.elevation, + shape: shape ?? this.shape, + height: height ?? this.height, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shadowColor: shadowColor ?? this.shadowColor, + padding: padding ?? this.padding, + ); + } + + /// Linearly interpolate between two bottom app bar themes. + /// + /// {@macro dart.ui.shadow.lerp} + static BottomAppBarThemeData lerp(BottomAppBarThemeData? a, BottomAppBarThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return BottomAppBarThemeData( + color: Color.lerp(a?.color, b?.color, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shape: t < 0.5 ? a?.shape : b?.shape, + height: lerpDouble(a?.height, b?.height, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + ); + } + + @override + int get hashCode => + Object.hash(color, elevation, shape, height, surfaceTintColor, shadowColor, padding); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is BottomAppBarThemeData && + other.color == color && + other.elevation == elevation && + other.shape == shape && + other.height == height && + other.surfaceTintColor == surfaceTintColor && + other.shadowColor == shadowColor && + other.padding == padding; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty<NotchedShape?>('shape', shape, defaultValue: null)); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry?>('padding', padding, defaultValue: null), + ); + } +} diff --git a/packages/material_ui/lib/src/bottom_navigation_bar.dart b/packages/material_ui/lib/src/bottom_navigation_bar.dart new file mode 100644 index 000000000000..74334ee3a06d --- /dev/null +++ b/packages/material_ui/lib/src/bottom_navigation_bar.dart @@ -0,0 +1,1277 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'navigation_bar.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:collection' show Queue; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:vector_math/vector_math_64.dart' show Vector3; + +import 'bottom_navigation_bar_theme.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +/// Defines the layout and behavior of a [BottomNavigationBar]. +/// +/// For a sample on how to use these, please see [BottomNavigationBar]. +/// See also: +/// +/// * [BottomNavigationBar] +/// * [BottomNavigationBarItem] +/// * <https://material.io/design/components/bottom-navigation.html#specs> +enum BottomNavigationBarType { + /// The [BottomNavigationBar]'s [BottomNavigationBarItem]s have fixed width. + fixed, + + /// The location and size of the [BottomNavigationBar] [BottomNavigationBarItem]s + /// animate and labels fade in when they are tapped. + shifting, +} + +/// Refines the layout of a [BottomNavigationBar] when the enclosing +/// [MediaQueryData.orientation] is [Orientation.landscape]. +enum BottomNavigationBarLandscapeLayout { + /// If the enclosing [MediaQueryData.orientation] is + /// [Orientation.landscape] then the navigation bar's items are + /// evenly spaced and spread out across the available width. Each + /// item's label and icon are arranged in a column. + spread, + + /// If the enclosing [MediaQueryData.orientation] is + /// [Orientation.landscape] then the navigation bar's items are + /// evenly spaced in a row but only consume as much width as they + /// would in portrait orientation. The row of items is centered within + /// the available width. Each item's label and icon are arranged + /// in a column. + centered, + + /// If the enclosing [MediaQueryData.orientation] is + /// [Orientation.landscape] then the navigation bar's items are + /// evenly spaced and each item's icon and label are lined up in a + /// row instead of a column. + linear, +} + +/// A material widget that's displayed at the bottom of an app for selecting +/// among a small number of views, typically between three and five. +/// +/// There is an updated version of this component, [NavigationBar], that's +/// preferred for new applications and applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// +/// The bottom navigation bar consists of multiple items in the form of +/// text labels, icons, or both, laid out on top of a piece of material. It +/// provides quick navigation between the top-level views of an app. For larger +/// screens, side navigation may be a better fit. +/// +/// A bottom navigation bar is usually used in conjunction with a [Scaffold], +/// where it is provided as the [Scaffold.bottomNavigationBar] argument. +/// +/// The bottom navigation bar's [type] changes how its [items] are displayed. +/// If not specified, then it's automatically set to +/// [BottomNavigationBarType.fixed] when there are less than four items, and +/// [BottomNavigationBarType.shifting] otherwise. +/// +/// The length of [items] must be at least two and each item's icon and +/// label must not be null. +/// +/// * [BottomNavigationBarType.fixed], the default when there are less than +/// four [items]. The selected item is rendered with the +/// [selectedItemColor] if it's non-null, otherwise the theme's +/// [ColorScheme.primary] color is used for [Brightness.light] themes +/// and [ColorScheme.secondary] for [Brightness.dark] themes. +/// If [backgroundColor] is null, The +/// navigation bar's background color defaults to the [Material] background +/// color, [ThemeData.canvasColor] (essentially opaque white). +/// * [BottomNavigationBarType.shifting], the default when there are four +/// or more [items]. If [selectedItemColor] is null, all items are rendered +/// in white. The navigation bar's background color is the same as the +/// [BottomNavigationBarItem.backgroundColor] of the selected item. In this +/// case it's assumed that each item will have a different background color +/// and that background color will contrast well with white. +/// +/// ## Updating to [NavigationBar] +/// +/// The [NavigationBar] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// <https://m3.material.io/components/navigation-bar/overview> for +/// more details. +/// +/// The [NavigationBar] widget's API is also slightly different. +/// To update from [BottomNavigationBar] to [NavigationBar], you will +/// need to make the following changes. +/// +/// 1. Instead of using [BottomNavigationBar.items], which +/// takes a list of [BottomNavigationBarItem]s, use +/// [NavigationBar.destinations], which takes a list of widgets. +/// Usually, you use a list of [NavigationDestination] widgets. +/// Just like [BottomNavigationBarItem]s, [NavigationDestination]s +/// have a label and icon field. +/// +/// 2. Instead of using [BottomNavigationBar.onTap], +/// use [NavigationBar.onDestinationSelected], which is also +/// a callback that is called when the user taps on a +/// navigation bar item. +/// +/// 3. Instead of using [BottomNavigationBar.currentIndex], +/// use [NavigationBar.selectedIndex], which is also an integer +/// that represents the index of the selected destination. +/// +/// 4. You may also need to make changes to the styling of the +/// [NavigationBar], see the properties in the [NavigationBar] +/// constructor for more details. +/// +/// ## Using [BottomNavigationBar] +/// +/// {@tool dartpad} +/// This example shows a [BottomNavigationBar] as it is used within a [Scaffold] +/// widget. The [BottomNavigationBar] has three [BottomNavigationBarItem] +/// widgets, which means it defaults to [BottomNavigationBarType.fixed], and +/// the [currentIndex] is set to index 0. The selected item is +/// amber. The `_onItemTapped` function changes the selected item's index +/// and displays a corresponding message in the center of the [Scaffold]. +/// +/// ** See code in examples/api/lib/material/bottom_navigation_bar/bottom_navigation_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how you would migrate the above [BottomNavigationBar] +/// to the new [NavigationBar]. +/// +/// ** See code in examples/api/lib/material/navigation_bar/navigation_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [BottomNavigationBar] as it is used within a [Scaffold] +/// widget. The [BottomNavigationBar] has four [BottomNavigationBarItem] +/// widgets, which means it defaults to [BottomNavigationBarType.shifting], and +/// the [currentIndex] is set to index 0. The selected item is amber in color. +/// With each [BottomNavigationBarItem] widget, backgroundColor property is +/// also defined, which changes the background color of [BottomNavigationBar], +/// when that item is selected. The `_onItemTapped` function changes the +/// selected item's index and displays a corresponding message in the center of +/// the [Scaffold]. +/// +/// ** See code in examples/api/lib/material/bottom_navigation_bar/bottom_navigation_bar.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows [BottomNavigationBar] used in a [Scaffold] Widget with +/// different interaction patterns. Tapping twice on the first [BottomNavigationBarItem] +/// uses the [ScrollController] to animate the [ListView] to the top. The second +/// [BottomNavigationBarItem] shows a Modal Dialog. +/// +/// ** See code in examples/api/lib/material/bottom_navigation_bar/bottom_navigation_bar.2.dart ** +/// {@end-tool} +/// See also: +/// +/// * [BottomNavigationBarItem] +/// * [Scaffold] +/// * <https://material.io/design/components/bottom-navigation.html> +/// * [NavigationBar], this widget's replacement in Material Design 3. +class BottomNavigationBar extends StatefulWidget { + /// Creates a bottom navigation bar which is typically used as a + /// [Scaffold]'s [Scaffold.bottomNavigationBar] argument. + /// + /// The length of [items] must be at least two and each item's icon and label + /// must not be null. + /// + /// If [type] is null then [BottomNavigationBarType.fixed] is used when there + /// are two or three [items], [BottomNavigationBarType.shifting] otherwise. + /// + /// The [iconSize], [selectedFontSize], [unselectedFontSize], and [elevation] + /// arguments must be non-negative. + /// + /// If [selectedLabelStyle].color and [unselectedLabelStyle].color values + /// are non-null, they will be used instead of [selectedItemColor] and + /// [unselectedItemColor]. + /// + /// If custom [IconThemeData]s are used, you must provide both + /// [selectedIconTheme] and [unselectedIconTheme], and both + /// [IconThemeData.color] and [IconThemeData.size] must be set. + /// + /// If [useLegacyColorScheme] is set to `false` + /// [selectedIconTheme] values will be used instead of [iconSize] and [selectedItemColor] for selected icons. + /// [unselectedIconTheme] values will be used instead of [iconSize] and [unselectedItemColor] for unselected icons. + /// + /// + /// If both [selectedLabelStyle].fontSize and [selectedFontSize] are set, + /// [selectedLabelStyle].fontSize will be used. + /// + /// Only one of [selectedItemColor] and [fixedColor] can be specified. The + /// former is preferred, [fixedColor] only exists for the sake of + /// backwards compatibility. + /// + /// If [showSelectedLabels] is `null`, [BottomNavigationBarThemeData.showSelectedLabels] + /// is used. If [BottomNavigationBarThemeData.showSelectedLabels] is null, + /// then [showSelectedLabels] defaults to `true`. + /// + /// If [showUnselectedLabels] is `null`, [BottomNavigationBarThemeData.showUnselectedLabels] + /// is used. If [BottomNavigationBarThemeData.showSelectedLabels] is null, + /// then [showUnselectedLabels] defaults to `true` when [type] is + /// [BottomNavigationBarType.fixed] and `false` when [type] is + /// [BottomNavigationBarType.shifting]. + BottomNavigationBar({ + super.key, + required this.items, + this.onTap, + this.currentIndex = 0, + this.elevation, + this.type, + Color? fixedColor, + this.backgroundColor, + this.iconSize = 24.0, + Color? selectedItemColor, + this.unselectedItemColor, + this.selectedIconTheme, + this.unselectedIconTheme, + this.selectedFontSize = 14.0, + this.unselectedFontSize = 12.0, + this.selectedLabelStyle, + this.unselectedLabelStyle, + this.showSelectedLabels, + this.showUnselectedLabels, + this.mouseCursor, + this.enableFeedback, + this.landscapeLayout, + this.useLegacyColorScheme = true, + }) : assert(items.length >= 2), + assert( + items.every((BottomNavigationBarItem item) => item.label != null), + 'Every item must have a non-null label', + ), + assert(0 <= currentIndex && currentIndex < items.length), + assert(elevation == null || elevation >= 0.0), + assert(iconSize >= 0.0), + assert( + selectedItemColor == null || fixedColor == null, + 'Either selectedItemColor or fixedColor can be specified, but not both', + ), + assert(selectedFontSize >= 0.0), + assert(unselectedFontSize >= 0.0), + selectedItemColor = selectedItemColor ?? fixedColor; + + /// Defines the appearance of the button items that are arrayed within the + /// bottom navigation bar. + final List<BottomNavigationBarItem> items; + + /// Called when one of the [items] is tapped. + /// + /// The stateful widget that creates the bottom navigation bar needs to keep + /// track of the index of the selected [BottomNavigationBarItem] and call + /// `setState` to rebuild the bottom navigation bar with the new [currentIndex]. + final ValueChanged<int>? onTap; + + /// The index into [items] for the current active [BottomNavigationBarItem]. + final int currentIndex; + + /// The z-coordinate of this [BottomNavigationBar]. + /// + /// If null, defaults to `8.0`. + /// + /// {@macro flutter.material.material.elevation} + final double? elevation; + + /// Defines the layout and behavior of a [BottomNavigationBar]. + /// + /// See documentation for [BottomNavigationBarType] for information on the + /// meaning of different types. + final BottomNavigationBarType? type; + + /// The value of [selectedItemColor]. + /// + /// This getter only exists for backwards compatibility, the + /// [selectedItemColor] property is preferred. + Color? get fixedColor => selectedItemColor; + + /// The color of the [BottomNavigationBar] itself. + /// + /// If [type] is [BottomNavigationBarType.shifting] and the + /// [items] have [BottomNavigationBarItem.backgroundColor] set, the [items]' + /// backgroundColor will splash and overwrite this color. + final Color? backgroundColor; + + /// The size of all of the [BottomNavigationBarItem] icons. + /// + /// See [BottomNavigationBarItem.icon] for more information. + final double iconSize; + + /// The color of the selected [BottomNavigationBarItem.icon] and + /// [BottomNavigationBarItem.label]. + /// + /// If null then the ambient [BottomNavigationBarThemeData.selectedItemColor] + /// is used. If that is also null, [ColorScheme.primary] is used when + /// [ThemeData.brightness] is [Brightness.light], and [ColorScheme.secondary] + /// is used when [ThemeData.brightness] is [Brightness.dark]. + final Color? selectedItemColor; + + /// The color of the unselected [BottomNavigationBarItem.icon] and + /// [BottomNavigationBarItem.label]s. + /// + /// If null then the ambient [BottomNavigationBarThemeData.unselectedItemColor] + /// is used. If that is also null, [ThemeData.unselectedWidgetColor] is used. + final Color? unselectedItemColor; + + /// The size, opacity, and color of the icon in the currently selected + /// [BottomNavigationBarItem.icon]. + /// + /// If this is not provided, the size will default to [iconSize], the color + /// will default to [selectedItemColor]. + /// + /// It this field is provided, it must contain non-null [IconThemeData.size] + /// and [IconThemeData.color] properties. Also, if this field is supplied, + /// [unselectedIconTheme] must be provided. + final IconThemeData? selectedIconTheme; + + /// The size, opacity, and color of the icon in the currently unselected + /// [BottomNavigationBarItem.icon]s. + /// + /// If this is not provided, the size will default to [iconSize], the color + /// will default to [unselectedItemColor]. + /// + /// It this field is provided, it must contain non-null [IconThemeData.size] + /// and [IconThemeData.color] properties. Also, if this field is supplied, + /// [selectedIconTheme] must be provided. + final IconThemeData? unselectedIconTheme; + + /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are + /// selected. + final TextStyle? selectedLabelStyle; + + /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are not + /// selected. + final TextStyle? unselectedLabelStyle; + + /// The font size of the [BottomNavigationBarItem] labels when they are selected. + /// + /// If [TextStyle.fontSize] of [selectedLabelStyle] is non-null, it will be + /// used instead of this. + /// + /// Defaults to `14.0`. + final double selectedFontSize; + + /// The font size of the [BottomNavigationBarItem] labels when they are not + /// selected. + /// + /// If [TextStyle.fontSize] of [unselectedLabelStyle] is non-null, it will be + /// used instead of this. + /// + /// Defaults to `12.0`. + final double unselectedFontSize; + + /// Whether the labels are shown for the unselected [BottomNavigationBarItem]s. + final bool? showUnselectedLabels; + + /// Whether the labels are shown for the selected [BottomNavigationBarItem]. + final bool? showSelectedLabels; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// items. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], its `resolve` method + /// can define the appearance of the cursor depending on whether + /// [WidgetState.selected] is active. + /// + /// If null, then the value of [BottomNavigationBarThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// The arrangement of the bar's [items] when the enclosing + /// [MediaQueryData.orientation] is [Orientation.landscape]. + /// + /// The following alternatives are supported: + /// + /// * [BottomNavigationBarLandscapeLayout.spread] - the items are + /// evenly spaced and spread out across the available width. Each + /// item's label and icon are arranged in a column. + /// * [BottomNavigationBarLandscapeLayout.centered] - the items are + /// evenly spaced in a row but only consume as much width as they + /// would in portrait orientation. The row of items is centered within + /// the available width. Each item's label and icon are arranged + /// in a column. + /// * [BottomNavigationBarLandscapeLayout.linear] - the items are + /// evenly spaced and each item's icon and label are lined up in a + /// row instead of a column. + /// + /// If this property is null, then the value of the enclosing + /// [BottomNavigationBarThemeData.landscapeLayout is used. If that + /// property is also null, then + /// [BottomNavigationBarLandscapeLayout.spread] is used. + /// + /// This property is null by default. + /// + /// See also: + /// + /// * [ThemeData.bottomNavigationBarTheme] - which can be used to specify + /// bottom navigation bar defaults for an entire application. + /// * [BottomNavigationBarTheme] - which can be used to specify + /// bottom navigation bar defaults for a widget subtree. + /// * [MediaQuery.orientationOf] - which can be used to determine the current + /// orientation. + final BottomNavigationBarLandscapeLayout? landscapeLayout; + + /// This flag is controlling how [BottomNavigationBar] is going to use + /// the colors provided by the [selectedIconTheme], [unselectedIconTheme], + /// [selectedItemColor], [unselectedItemColor]. + /// The default value is `true` as the new theming logic is a breaking change. + /// To opt-in the new theming logic set the flag to `false` + final bool useLegacyColorScheme; + + @override + State<BottomNavigationBar> createState() => _BottomNavigationBarState(); +} + +// This represents a single tile in the bottom navigation bar. It is intended +// to go into a flex container. +class _BottomNavigationTile extends StatelessWidget { + const _BottomNavigationTile( + this.type, + this.item, + this.animation, + this.iconSize, { + super.key, + this.onTap, + this.labelColorTween, + this.iconColorTween, + this.flex, + this.selected = false, + required this.selectedLabelStyle, + required this.unselectedLabelStyle, + required this.selectedIconTheme, + required this.unselectedIconTheme, + required this.showSelectedLabels, + required this.showUnselectedLabels, + this.indexLabel, + required this.mouseCursor, + required this.enableFeedback, + required this.layout, + }); + + final BottomNavigationBarType type; + final BottomNavigationBarItem item; + final Animation<double> animation; + final double iconSize; + final VoidCallback? onTap; + final ColorTween? labelColorTween; + final ColorTween? iconColorTween; + final double? flex; + final bool selected; + final IconThemeData? selectedIconTheme; + final IconThemeData? unselectedIconTheme; + final TextStyle selectedLabelStyle; + final TextStyle unselectedLabelStyle; + final String? indexLabel; + final bool showSelectedLabels; + final bool showUnselectedLabels; + final MouseCursor mouseCursor; + final bool enableFeedback; + final BottomNavigationBarLandscapeLayout layout; + + @override + Widget build(BuildContext context) { + // In order to use the flex container to grow the tile during animation, we + // need to divide the changes in flex allotment into smaller pieces to + // produce smooth animation. We do this by multiplying the flex value + // (which is an integer) by a large number. + final int size; + + final double selectedFontSize = selectedLabelStyle.fontSize!; + + final double selectedIconSize = selectedIconTheme?.size ?? iconSize; + final double unselectedIconSize = unselectedIconTheme?.size ?? iconSize; + + // The amount that the selected icon is bigger than the unselected icons, + // (or zero if the selected icon is not bigger than the unselected icons). + final double selectedIconDiff = math.max(selectedIconSize - unselectedIconSize, 0); + // The amount that the unselected icons are bigger than the selected icon, + // (or zero if the unselected icons are not any bigger than the selected icon). + final double unselectedIconDiff = math.max(unselectedIconSize - selectedIconSize, 0); + + // The effective tool tip message to be shown on the BottomNavigationBarItem. + final String? effectiveTooltip = item.tooltip == '' ? null : item.tooltip; + + // Defines the padding for the animating icons + labels. + // + // The animations go from "Unselected": + // ======= + // | <-- Padding equal to the text height + 1/2 selectedIconDiff. + // | ☆ + // | text <-- Invisible text + padding equal to 1/2 selectedIconDiff. + // ======= + // + // To "Selected": + // + // ======= + // | <-- Padding equal to 1/2 text height + 1/2 unselectedIconDiff. + // | ☆ + // | text + // | <-- Padding equal to 1/2 text height + 1/2 unselectedIconDiff. + // ======= + double bottomPadding; + double topPadding; + if (showSelectedLabels && !showUnselectedLabels) { + bottomPadding = Tween<double>( + begin: selectedIconDiff / 2.0, + end: selectedFontSize / 2.0 - unselectedIconDiff / 2.0, + ).evaluate(animation); + topPadding = Tween<double>( + begin: selectedFontSize + selectedIconDiff / 2.0, + end: selectedFontSize / 2.0 - unselectedIconDiff / 2.0, + ).evaluate(animation); + } else if (!showSelectedLabels && !showUnselectedLabels) { + bottomPadding = Tween<double>( + begin: selectedIconDiff / 2.0, + end: unselectedIconDiff / 2.0, + ).evaluate(animation); + topPadding = Tween<double>( + begin: selectedFontSize + selectedIconDiff / 2.0, + end: selectedFontSize + unselectedIconDiff / 2.0, + ).evaluate(animation); + } else { + bottomPadding = Tween<double>( + begin: selectedFontSize / 2.0 + selectedIconDiff / 2.0, + end: selectedFontSize / 2.0 + unselectedIconDiff / 2.0, + ).evaluate(animation); + topPadding = Tween<double>( + begin: selectedFontSize / 2.0 + selectedIconDiff / 2.0, + end: selectedFontSize / 2.0 + unselectedIconDiff / 2.0, + ).evaluate(animation); + } + + size = switch (type) { + BottomNavigationBarType.fixed => 1, + BottomNavigationBarType.shifting => (flex! * 1000.0).round(), + }; + + Widget result = InkResponse( + onTap: onTap, + mouseCursor: mouseCursor, + enableFeedback: enableFeedback, + child: Padding( + padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), + child: _Tile( + layout: layout, + icon: _TileIcon( + colorTween: iconColorTween!, + animation: animation, + iconSize: iconSize, + selected: selected, + item: item, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + ), + label: _Label( + colorTween: labelColorTween!, + animation: animation, + item: item, + selectedLabelStyle: selectedLabelStyle, + unselectedLabelStyle: unselectedLabelStyle, + showSelectedLabels: showSelectedLabels, + showUnselectedLabels: showUnselectedLabels, + ), + ), + ), + ); + + if (effectiveTooltip != null) { + result = Tooltip( + message: effectiveTooltip, + preferBelow: false, + verticalOffset: selectedIconSize + selectedFontSize, + excludeFromSemantics: true, + child: result, + ); + } + + result = Semantics( + selected: selected, + button: true, + container: true, + child: Stack( + children: <Widget>[ + result, + Semantics(label: indexLabel), + ], + ), + ); + + return Expanded(flex: size, child: result); + } +} + +// If the orientation is landscape and layout is +// BottomNavigationBarLandscapeLayout.linear then return a +// icon-space-label row, where space is 8 pixels. Otherwise return a +// icon-label column. +class _Tile extends StatelessWidget { + const _Tile({required this.layout, required this.icon, required this.label}); + + final BottomNavigationBarLandscapeLayout layout; + final Widget icon; + final Widget label; + + @override + Widget build(BuildContext context) { + if (MediaQuery.orientationOf(context) == Orientation.landscape && + layout == BottomNavigationBarLandscapeLayout.linear) { + return Align( + heightFactor: 1, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: <Widget>[ + icon, + // Flexible lets the overflow property of + // label to work and IntrinsicWidth gives label a + // reasonable width preventing extra space before it. + Flexible(child: IntrinsicWidth(child: label)), + ], + ), + ); + } + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: <Widget>[icon, label], + ); + } +} + +class _TileIcon extends StatelessWidget { + const _TileIcon({ + required this.colorTween, + required this.animation, + required this.iconSize, + required this.selected, + required this.item, + required this.selectedIconTheme, + required this.unselectedIconTheme, + }); + + final ColorTween colorTween; + final Animation<double> animation; + final double iconSize; + final bool selected; + final BottomNavigationBarItem item; + final IconThemeData? selectedIconTheme; + final IconThemeData? unselectedIconTheme; + + @override + Widget build(BuildContext context) { + final Color? iconColor = colorTween.evaluate(animation); + final defaultIconTheme = IconThemeData(color: iconColor, size: iconSize); + final IconThemeData iconThemeData = IconThemeData.lerp( + defaultIconTheme.merge(unselectedIconTheme), + defaultIconTheme.merge(selectedIconTheme), + animation.value, + ); + + return Align( + alignment: Alignment.topCenter, + heightFactor: 1.0, + child: IconTheme(data: iconThemeData, child: selected ? item.activeIcon : item.icon), + ); + } +} + +class _Label extends StatelessWidget { + const _Label({ + required this.colorTween, + required this.animation, + required this.item, + required this.selectedLabelStyle, + required this.unselectedLabelStyle, + required this.showSelectedLabels, + required this.showUnselectedLabels, + }); + + final ColorTween colorTween; + final Animation<double> animation; + final BottomNavigationBarItem item; + final TextStyle selectedLabelStyle; + final TextStyle unselectedLabelStyle; + final bool showSelectedLabels; + final bool showUnselectedLabels; + + @override + Widget build(BuildContext context) { + final double? selectedFontSize = selectedLabelStyle.fontSize; + final double? unselectedFontSize = unselectedLabelStyle.fontSize; + + final TextStyle customStyle = TextStyle.lerp( + unselectedLabelStyle, + selectedLabelStyle, + animation.value, + )!; + Widget text = DefaultTextStyle.merge( + style: customStyle.copyWith( + fontSize: selectedFontSize, + color: colorTween.evaluate(animation), + ), + // The font size should grow here when active, but because of the way + // font rendering works, it doesn't grow smoothly if we just animate + // the font size, so we use a transform instead. + child: Transform( + transform: Matrix4.diagonal3( + Vector3.all( + Tween<double>( + begin: unselectedFontSize! / selectedFontSize!, + end: 1.0, + ).evaluate(animation), + ), + ), + alignment: Alignment.bottomCenter, + child: Text(item.label!, semanticsLabel: item.semanticsLabel), + ), + ); + + if (!showUnselectedLabels && !showSelectedLabels) { + // Never show any labels. + text = Visibility.maintain(visible: false, child: text); + } else if (!showUnselectedLabels) { + // Fade selected labels in. + text = FadeTransition(alwaysIncludeSemantics: true, opacity: animation, child: text); + } else if (!showSelectedLabels) { + // Fade selected labels out. + text = FadeTransition( + alwaysIncludeSemantics: true, + opacity: Tween<double>(begin: 1.0, end: 0.0).animate(animation), + child: text, + ); + } + + text = Align(alignment: Alignment.bottomCenter, heightFactor: 1.0, child: text); + + if (item.label != null) { + // Do not grow text in bottom navigation bar when we can show a tooltip + // instead. + text = MediaQuery.withClampedTextScaling(maxScaleFactor: 1.0, child: text); + } + + return text; + } +} + +class _BottomNavigationBarState extends State<BottomNavigationBar> with TickerProviderStateMixin { + List<AnimationController> _controllers = <AnimationController>[]; + List<CurvedAnimation> _animations = <CurvedAnimation>[]; + + // A queue of color splashes currently being animated. + final Queue<_Circle> _circles = Queue<_Circle>(); + + // Last splash circle's color, and the final color of the control after + // animation is complete. + Color? _backgroundColor; + + static final Animatable<double> _flexTween = Tween<double>(begin: 1.0, end: 1.5); + + void _resetState() { + for (final AnimationController controller in _controllers) { + controller.dispose(); + } + for (final _Circle circle in _circles) { + circle.dispose(); + } + for (final CurvedAnimation animation in _animations) { + animation.dispose(); + } + _circles.clear(); + + _controllers = List<AnimationController>.generate(widget.items.length, (int index) { + return AnimationController(duration: kThemeAnimationDuration, vsync: this) + ..addListener(_rebuild); + }); + _animations = List<CurvedAnimation>.generate(widget.items.length, (int index) { + return CurvedAnimation( + parent: _controllers[index], + curve: Curves.fastOutSlowIn, + reverseCurve: Curves.fastOutSlowIn.flipped, + ); + }); + _controllers[widget.currentIndex].value = 1.0; + _backgroundColor = widget.items[widget.currentIndex].backgroundColor; + } + + // Computes the default value for the [type] parameter. + // + // If type is provided, it is returned. Next, if the bottom navigation bar + // theme provides a type, it is used. Finally, the default behavior will be + // [BottomNavigationBarType.fixed] for 3 or fewer items, and + // [BottomNavigationBarType.shifting] is used for 4+ items. + BottomNavigationBarType get _effectiveType { + return widget.type ?? + BottomNavigationBarTheme.of(context).type ?? + (widget.items.length <= 3 + ? BottomNavigationBarType.fixed + : BottomNavigationBarType.shifting); + } + + // Computes the default value for the [showUnselected] parameter. + // + // Unselected labels are shown by default for [BottomNavigationBarType.fixed], + // and hidden by default for [BottomNavigationBarType.shifting]. + bool get _defaultShowUnselected => switch (_effectiveType) { + BottomNavigationBarType.shifting => false, + BottomNavigationBarType.fixed => true, + }; + + @override + void initState() { + super.initState(); + _resetState(); + } + + void _rebuild() { + setState(() { + // Rebuilding when any of the controllers tick, i.e. when the items are + // animated. + }); + } + + @override + void dispose() { + for (final AnimationController controller in _controllers) { + controller.dispose(); + } + for (final _Circle circle in _circles) { + circle.dispose(); + } + for (final CurvedAnimation animation in _animations) { + animation.dispose(); + } + super.dispose(); + } + + double _evaluateFlex(Animation<double> animation) => _flexTween.evaluate(animation); + + void _pushCircle(int index) { + if (widget.items[index].backgroundColor != null) { + _circles.add( + _Circle(state: this, index: index, color: widget.items[index].backgroundColor!, vsync: this) + ..controller.addStatusListener((AnimationStatus status) { + if (status.isCompleted) { + setState(() { + final _Circle circle = _circles.removeFirst(); + _backgroundColor = circle.color; + circle.dispose(); + }); + } + }), + ); + } + } + + @override + void didUpdateWidget(BottomNavigationBar oldWidget) { + super.didUpdateWidget(oldWidget); + + // No animated segue if the length of the items list changes. + if (widget.items.length != oldWidget.items.length) { + _resetState(); + return; + } + + if (widget.currentIndex != oldWidget.currentIndex) { + switch (_effectiveType) { + case BottomNavigationBarType.fixed: + break; + case BottomNavigationBarType.shifting: + _pushCircle(widget.currentIndex); + } + _controllers[oldWidget.currentIndex].reverse(); + _controllers[widget.currentIndex].forward(); + } else { + if (_backgroundColor != widget.items[widget.currentIndex].backgroundColor) { + _backgroundColor = widget.items[widget.currentIndex].backgroundColor; + } + } + } + + // If the given [TextStyle] has a non-null `fontSize`, it should be used. + // Otherwise, the [selectedFontSize] parameter should be used. + static TextStyle _effectiveTextStyle(TextStyle? textStyle, double fontSize) { + textStyle ??= const TextStyle(); + // Prefer the font size on textStyle if present. + return textStyle.fontSize == null ? textStyle.copyWith(fontSize: fontSize) : textStyle; + } + + // If [IconThemeData] is provided, it should be used. + // Otherwise, the [IconThemeData]'s color should be selectedItemColor + // or unselectedItemColor. + static IconThemeData _effectiveIconTheme(IconThemeData? iconTheme, Color? itemColor) { + // Prefer the iconTheme over itemColor if present. + return iconTheme ?? IconThemeData(color: itemColor); + } + + List<Widget> _createTiles(BottomNavigationBarLandscapeLayout layout) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + final ThemeData themeData = Theme.of(context); + final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context); + + final Color themeColor = switch (themeData.brightness) { + Brightness.light => themeData.colorScheme.primary, + Brightness.dark => themeData.colorScheme.secondary, + }; + + final TextStyle effectiveSelectedLabelStyle = _effectiveTextStyle( + widget.selectedLabelStyle ?? bottomTheme.selectedLabelStyle, + widget.selectedFontSize, + ); + + final TextStyle effectiveUnselectedLabelStyle = _effectiveTextStyle( + widget.unselectedLabelStyle ?? bottomTheme.unselectedLabelStyle, + widget.unselectedFontSize, + ); + + final IconThemeData effectiveSelectedIconTheme = _effectiveIconTheme( + widget.selectedIconTheme ?? bottomTheme.selectedIconTheme, + widget.selectedItemColor ?? bottomTheme.selectedItemColor ?? themeColor, + ); + + final IconThemeData effectiveUnselectedIconTheme = _effectiveIconTheme( + widget.unselectedIconTheme ?? bottomTheme.unselectedIconTheme, + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.unselectedWidgetColor, + ); + + final ColorTween colorTween; + switch (_effectiveType) { + case BottomNavigationBarType.fixed: + colorTween = ColorTween( + begin: + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.unselectedWidgetColor, + end: + widget.selectedItemColor ?? + bottomTheme.selectedItemColor ?? + widget.fixedColor ?? + themeColor, + ); + case BottomNavigationBarType.shifting: + colorTween = ColorTween( + begin: + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.colorScheme.surface, + end: + widget.selectedItemColor ?? + bottomTheme.selectedItemColor ?? + themeData.colorScheme.surface, + ); + } + + final ColorTween labelColorTween; + switch (_effectiveType) { + case BottomNavigationBarType.fixed: + labelColorTween = ColorTween( + begin: + effectiveUnselectedLabelStyle.color ?? + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.unselectedWidgetColor, + end: + effectiveSelectedLabelStyle.color ?? + widget.selectedItemColor ?? + bottomTheme.selectedItemColor ?? + widget.fixedColor ?? + themeColor, + ); + case BottomNavigationBarType.shifting: + labelColorTween = ColorTween( + begin: + effectiveUnselectedLabelStyle.color ?? + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.colorScheme.surface, + end: + effectiveSelectedLabelStyle.color ?? + widget.selectedItemColor ?? + bottomTheme.selectedItemColor ?? + themeColor, + ); + } + + final ColorTween iconColorTween; + switch (_effectiveType) { + case BottomNavigationBarType.fixed: + iconColorTween = ColorTween( + begin: + effectiveSelectedIconTheme.color ?? + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.unselectedWidgetColor, + end: + effectiveUnselectedIconTheme.color ?? + widget.selectedItemColor ?? + bottomTheme.selectedItemColor ?? + widget.fixedColor ?? + themeColor, + ); + case BottomNavigationBarType.shifting: + iconColorTween = ColorTween( + begin: + effectiveUnselectedIconTheme.color ?? + widget.unselectedItemColor ?? + bottomTheme.unselectedItemColor ?? + themeData.colorScheme.surface, + end: + effectiveSelectedIconTheme.color ?? + widget.selectedItemColor ?? + bottomTheme.selectedItemColor ?? + themeColor, + ); + } + + final tiles = <Widget>[]; + for (var i = 0; i < widget.items.length; i++) { + final states = <WidgetState>{if (i == widget.currentIndex) WidgetState.selected}; + + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? + bottomTheme.mouseCursor?.resolve(states) ?? + WidgetStateMouseCursor.clickable.resolve(states); + + tiles.add( + _BottomNavigationTile( + _effectiveType, + widget.items[i], + _animations[i], + widget.iconSize, + key: widget.items[i].key, + selectedIconTheme: widget.useLegacyColorScheme + ? widget.selectedIconTheme ?? bottomTheme.selectedIconTheme + : effectiveSelectedIconTheme, + unselectedIconTheme: widget.useLegacyColorScheme + ? widget.unselectedIconTheme ?? bottomTheme.unselectedIconTheme + : effectiveUnselectedIconTheme, + selectedLabelStyle: effectiveSelectedLabelStyle, + unselectedLabelStyle: effectiveUnselectedLabelStyle, + enableFeedback: widget.enableFeedback ?? bottomTheme.enableFeedback ?? true, + onTap: () { + widget.onTap?.call(i); + }, + labelColorTween: widget.useLegacyColorScheme ? colorTween : labelColorTween, + iconColorTween: widget.useLegacyColorScheme ? colorTween : iconColorTween, + flex: _evaluateFlex(_animations[i]), + selected: i == widget.currentIndex, + showSelectedLabels: widget.showSelectedLabels ?? bottomTheme.showSelectedLabels ?? true, + showUnselectedLabels: + widget.showUnselectedLabels ?? + bottomTheme.showUnselectedLabels ?? + _defaultShowUnselected, + indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: widget.items.length), + mouseCursor: effectiveMouseCursor, + layout: layout, + ), + ); + } + return tiles; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasOverlay(context)); + + final BottomNavigationBarThemeData bottomTheme = BottomNavigationBarTheme.of(context); + final BottomNavigationBarLandscapeLayout layout = + widget.landscapeLayout ?? + bottomTheme.landscapeLayout ?? + BottomNavigationBarLandscapeLayout.spread; + final double additionalBottomPadding = MediaQuery.viewPaddingOf(context).bottom; + + final Color? backgroundColor = switch (_effectiveType) { + BottomNavigationBarType.fixed => widget.backgroundColor ?? bottomTheme.backgroundColor, + BottomNavigationBarType.shifting => _backgroundColor, + }; + + return Semantics( + explicitChildNodes: true, + child: _Bar( + layout: layout, + elevation: widget.elevation ?? bottomTheme.elevation ?? 8.0, + color: backgroundColor, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: kBottomNavigationBarHeight + additionalBottomPadding, + ), + child: CustomPaint( + painter: _RadialPainter( + circles: _circles.toList(), + textDirection: Directionality.of(context), + ), + child: Material( + // Splashes. + type: MaterialType.transparency, + child: Padding( + padding: EdgeInsets.only(bottom: additionalBottomPadding), + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: DefaultTextStyle.merge( + overflow: TextOverflow.ellipsis, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: _createTiles(layout), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// Optionally center a Material child for landscape layouts when layout is +// BottomNavigationBarLandscapeLayout.centered +class _Bar extends StatelessWidget { + const _Bar({ + required this.child, + required this.layout, + required this.elevation, + required this.color, + }); + + final Widget child; + final BottomNavigationBarLandscapeLayout layout; + final double elevation; + final Color? color; + + @override + Widget build(BuildContext context) { + Widget alignedChild = child; + if (MediaQuery.orientationOf(context) == Orientation.landscape && + layout == BottomNavigationBarLandscapeLayout.centered) { + alignedChild = Align( + alignment: Alignment.bottomCenter, + heightFactor: 1, + child: SizedBox(width: MediaQuery.heightOf(context), child: child), + ); + } + return Material(elevation: elevation, color: color, child: alignedChild); + } +} + +// Describes an animating color splash circle. +class _Circle { + _Circle({ + required this.state, + required this.index, + required this.color, + required TickerProvider vsync, + }) { + controller = AnimationController(duration: kThemeAnimationDuration, vsync: vsync); + animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + controller.forward(); + } + + final _BottomNavigationBarState state; + final int index; + final Color color; + late AnimationController controller; + late CurvedAnimation animation; + + double get horizontalLeadingOffset { + double weightSum(Iterable<Animation<double>> animations) { + // We're adding flex values instead of animation values to produce correct + // ratios. + return animations + .map<double>(state._evaluateFlex) + .fold<double>(0.0, (double sum, double value) => sum + value); + } + + final double allWeights = weightSum(state._animations); + // These weights sum to the start edge of the indexed item. + final double leadingWeights = weightSum(state._animations.sublist(0, index)); + + // Add half of its flex value in order to get to the center. + return (leadingWeights + state._evaluateFlex(state._animations[index]) / 2.0) / allWeights; + } + + void dispose() { + controller.dispose(); + animation.dispose(); + } +} + +// Paints the animating color splash circles. +class _RadialPainter extends CustomPainter { + _RadialPainter({required this.circles, required this.textDirection}); + + final List<_Circle> circles; + final TextDirection textDirection; + + // Computes the maximum radius attainable such that at least one of the + // bounding rectangle's corners touches the edge of the circle. Drawing a + // circle larger than this radius is not needed, since there is no perceivable + // difference within the cropped rectangle. + static double _maxRadius(Offset center, Size size) { + final double maxX = math.max(center.dx, size.width - center.dx); + final double maxY = math.max(center.dy, size.height - center.dy); + return math.sqrt(maxX * maxX + maxY * maxY); + } + + @override + bool shouldRepaint(_RadialPainter oldPainter) { + if (textDirection != oldPainter.textDirection) { + return true; + } + if (circles == oldPainter.circles) { + return false; + } + if (circles.length != oldPainter.circles.length) { + return true; + } + for (var i = 0; i < circles.length; i += 1) { + if (circles[i] != oldPainter.circles[i]) { + return true; + } + } + return false; + } + + @override + void paint(Canvas canvas, Size size) { + for (final _Circle circle in circles) { + final paint = Paint()..color = circle.color; + final rect = Rect.fromLTWH(0.0, 0.0, size.width, size.height); + canvas.clipRect(rect); + final double leftFraction = switch (textDirection) { + TextDirection.rtl => 1.0 - circle.horizontalLeadingOffset, + TextDirection.ltr => circle.horizontalLeadingOffset, + }; + final center = Offset(leftFraction * size.width, size.height / 2.0); + final radiusTween = Tween<double>(begin: 0.0, end: _maxRadius(center, size)); + canvas.drawCircle(center, radiusTween.transform(circle.animation.value), paint); + } + } +} diff --git a/packages/material_ui/lib/src/bottom_navigation_bar_theme.dart b/packages/material_ui/lib/src/bottom_navigation_bar_theme.dart new file mode 100644 index 000000000000..96bc66e72685 --- /dev/null +++ b/packages/material_ui/lib/src/bottom_navigation_bar_theme.dart @@ -0,0 +1,339 @@ +// 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:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'bottom_navigation_bar.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [BottomNavigationBar] +/// widgets. +/// +/// Descendant widgets obtain the current [BottomNavigationBarThemeData] object +/// using [BottomNavigationBarTheme.of]. Instances of +/// [BottomNavigationBarThemeData] can be customized with +/// [BottomNavigationBarThemeData.copyWith]. +/// +/// Typically a [BottomNavigationBarThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.bottomNavigationBarTheme]. +/// +/// All [BottomNavigationBarThemeData] properties are `null` by default. When +/// null, the [BottomNavigationBar]'s build method provides defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class BottomNavigationBarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.bottomNavigationBarTheme]. + const BottomNavigationBarThemeData({ + this.backgroundColor, + this.elevation, + this.selectedIconTheme, + this.unselectedIconTheme, + this.selectedItemColor, + this.unselectedItemColor, + this.selectedLabelStyle, + this.unselectedLabelStyle, + this.showSelectedLabels, + this.showUnselectedLabels, + this.type, + this.enableFeedback, + this.landscapeLayout, + this.mouseCursor, + }); + + /// The color of the [BottomNavigationBar] itself. + /// + /// See [BottomNavigationBar.backgroundColor]. + final Color? backgroundColor; + + /// The z-coordinate of the [BottomNavigationBar]. + /// + /// See [BottomNavigationBar.elevation]. + final double? elevation; + + /// The size, opacity, and color of the icon in the currently selected + /// [BottomNavigationBarItem.icon]. + /// + /// If [BottomNavigationBar.selectedIconTheme] is non-null on the widget, + /// the whole [IconThemeData] from the widget will be used over this + /// [selectedIconTheme]. + /// + /// See [BottomNavigationBar.selectedIconTheme]. + final IconThemeData? selectedIconTheme; + + /// The size, opacity, and color of the icon in the currently unselected + /// [BottomNavigationBarItem.icon]s. + /// + /// If [BottomNavigationBar.unselectedIconTheme] is non-null on the widget, + /// the whole [IconThemeData] from the widget will be used over this + /// [unselectedIconTheme]. + /// + /// See [BottomNavigationBar.unselectedIconTheme]. + final IconThemeData? unselectedIconTheme; + + /// The color of the selected [BottomNavigationBarItem.icon] and + /// [BottomNavigationBarItem.label]. + /// + /// See [BottomNavigationBar.selectedItemColor]. + final Color? selectedItemColor; + + /// The color of the unselected [BottomNavigationBarItem.icon] and + /// [BottomNavigationBarItem.label]s. + /// + /// See [BottomNavigationBar.unselectedItemColor]. + final Color? unselectedItemColor; + + /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are + /// selected. + /// + /// See [BottomNavigationBar.selectedLabelStyle]. + final TextStyle? selectedLabelStyle; + + /// The [TextStyle] of the [BottomNavigationBarItem] labels when they are not + /// selected. + /// + /// See [BottomNavigationBar.unselectedLabelStyle]. + final TextStyle? unselectedLabelStyle; + + /// Whether the labels are shown for the selected [BottomNavigationBarItem]. + /// + /// See [BottomNavigationBar.showSelectedLabels]. + final bool? showSelectedLabels; + + /// Whether the labels are shown for the unselected [BottomNavigationBarItem]s. + /// + /// See [BottomNavigationBar.showUnselectedLabels]. + final bool? showUnselectedLabels; + + /// Defines the layout and behavior of a [BottomNavigationBar]. + /// + /// See [BottomNavigationBar.type]. + final BottomNavigationBarType? type; + + /// If specified, defines the feedback property for [BottomNavigationBar]. + /// + /// If [BottomNavigationBar.enableFeedback] is provided, [enableFeedback] is ignored. + final bool? enableFeedback; + + /// If non-null, overrides the [BottomNavigationBar.landscapeLayout] property. + final BottomNavigationBarLandscapeLayout? landscapeLayout; + + /// If specified, overrides the default value of [BottomNavigationBar.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + BottomNavigationBarThemeData copyWith({ + Color? backgroundColor, + double? elevation, + IconThemeData? selectedIconTheme, + IconThemeData? unselectedIconTheme, + Color? selectedItemColor, + Color? unselectedItemColor, + TextStyle? selectedLabelStyle, + TextStyle? unselectedLabelStyle, + bool? showSelectedLabels, + bool? showUnselectedLabels, + BottomNavigationBarType? type, + bool? enableFeedback, + BottomNavigationBarLandscapeLayout? landscapeLayout, + WidgetStateProperty<MouseCursor?>? mouseCursor, + }) { + return BottomNavigationBarThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + selectedIconTheme: selectedIconTheme ?? this.selectedIconTheme, + unselectedIconTheme: unselectedIconTheme ?? this.unselectedIconTheme, + selectedItemColor: selectedItemColor ?? this.selectedItemColor, + unselectedItemColor: unselectedItemColor ?? this.unselectedItemColor, + selectedLabelStyle: selectedLabelStyle ?? this.selectedLabelStyle, + unselectedLabelStyle: unselectedLabelStyle ?? this.unselectedLabelStyle, + showSelectedLabels: showSelectedLabels ?? this.showSelectedLabels, + showUnselectedLabels: showUnselectedLabels ?? this.showUnselectedLabels, + type: type ?? this.type, + enableFeedback: enableFeedback ?? this.enableFeedback, + landscapeLayout: landscapeLayout ?? this.landscapeLayout, + mouseCursor: mouseCursor ?? this.mouseCursor, + ); + } + + /// Linearly interpolate between two [BottomNavigationBarThemeData]. + /// + /// {@macro dart.ui.shadow.lerp} + static BottomNavigationBarThemeData lerp( + BottomNavigationBarThemeData? a, + BottomNavigationBarThemeData? b, + double t, + ) { + if (identical(a, b) && a != null) { + return a; + } + return BottomNavigationBarThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + selectedIconTheme: IconThemeData.lerp(a?.selectedIconTheme, b?.selectedIconTheme, t), + unselectedIconTheme: IconThemeData.lerp(a?.unselectedIconTheme, b?.unselectedIconTheme, t), + selectedItemColor: Color.lerp(a?.selectedItemColor, b?.selectedItemColor, t), + unselectedItemColor: Color.lerp(a?.unselectedItemColor, b?.unselectedItemColor, t), + selectedLabelStyle: TextStyle.lerp(a?.selectedLabelStyle, b?.selectedLabelStyle, t), + unselectedLabelStyle: TextStyle.lerp(a?.unselectedLabelStyle, b?.unselectedLabelStyle, t), + showSelectedLabels: t < 0.5 ? a?.showSelectedLabels : b?.showSelectedLabels, + showUnselectedLabels: t < 0.5 ? a?.showUnselectedLabels : b?.showUnselectedLabels, + type: t < 0.5 ? a?.type : b?.type, + enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback, + landscapeLayout: t < 0.5 ? a?.landscapeLayout : b?.landscapeLayout, + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + elevation, + selectedIconTheme, + unselectedIconTheme, + selectedItemColor, + unselectedItemColor, + selectedLabelStyle, + unselectedLabelStyle, + showSelectedLabels, + showUnselectedLabels, + type, + enableFeedback, + landscapeLayout, + mouseCursor, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is BottomNavigationBarThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.selectedIconTheme == selectedIconTheme && + other.unselectedIconTheme == unselectedIconTheme && + other.selectedItemColor == selectedItemColor && + other.unselectedItemColor == unselectedItemColor && + other.selectedLabelStyle == selectedLabelStyle && + other.unselectedLabelStyle == unselectedLabelStyle && + other.showSelectedLabels == showSelectedLabels && + other.showUnselectedLabels == showUnselectedLabels && + other.type == type && + other.enableFeedback == enableFeedback && + other.landscapeLayout == landscapeLayout && + other.mouseCursor == mouseCursor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add( + DiagnosticsProperty<IconThemeData>( + 'selectedIconTheme', + selectedIconTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<IconThemeData>( + 'unselectedIconTheme', + unselectedIconTheme, + defaultValue: null, + ), + ); + properties.add(ColorProperty('selectedItemColor', selectedItemColor, defaultValue: null)); + properties.add(ColorProperty('unselectedItemColor', unselectedItemColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('selectedLabelStyle', selectedLabelStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'unselectedLabelStyle', + unselectedLabelStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<bool>('showSelectedLabels', showSelectedLabels, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<bool>('showUnselectedLabels', showUnselectedLabels, defaultValue: null), + ); + properties.add(DiagnosticsProperty<BottomNavigationBarType>('type', type, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null)); + properties.add( + DiagnosticsProperty<BottomNavigationBarLandscapeLayout>( + 'landscapeLayout', + landscapeLayout, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + } +} + +/// Applies a bottom navigation bar theme to descendant [BottomNavigationBar] +/// widgets. +/// +/// Descendant widgets obtain the current theme's [BottomNavigationBarTheme] +/// object using [BottomNavigationBarTheme.of]. When a widget uses +/// [BottomNavigationBarTheme.of], it is automatically rebuilt if the theme +/// later changes. +/// +/// A bottom navigation theme can be specified as part of the overall Material +/// theme using [ThemeData.bottomNavigationBarTheme]. +/// +/// See also: +/// +/// * [BottomNavigationBarThemeData], which describes the actual configuration +/// of a bottom navigation bar theme. +class BottomNavigationBarTheme extends InheritedWidget { + /// Constructs a bottom navigation bar theme that configures all descendant + /// [BottomNavigationBar] widgets. + const BottomNavigationBarTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [BottomNavigationBar] widgets. + final BottomNavigationBarThemeData data; + + /// Returns the configuration [data] from the closest + /// [BottomNavigationBarTheme] ancestor. If there is no ancestor, it returns + /// [ThemeData.bottomNavigationBarTheme]. Applications can assume that the + /// returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// BottomNavigationBarThemeData theme = BottomNavigationBarTheme.of(context); + /// ``` + static BottomNavigationBarThemeData of(BuildContext context) { + final BottomNavigationBarTheme? bottomNavTheme = context + .dependOnInheritedWidgetOfExactType<BottomNavigationBarTheme>(); + return bottomNavTheme?.data ?? Theme.of(context).bottomNavigationBarTheme; + } + + @override + bool updateShouldNotify(BottomNavigationBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/bottom_sheet.dart b/packages/material_ui/lib/src/bottom_sheet.dart new file mode 100644 index 000000000000..0fba5686f288 --- /dev/null +++ b/packages/material_ui/lib/src/bottom_sheet.dart @@ -0,0 +1,1515 @@ +// 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. + +/// @docImport 'dart:ui'; +library; + +import 'dart:math' as math; +import 'dart:ui' show SemanticsHitTestBehavior; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'bottom_sheet_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'motion.dart'; +import 'scaffold.dart'; +import 'theme.dart'; + +const Duration _kBottomSheetEnterDuration = Duration(milliseconds: 250); +const Duration _kBottomSheetExitDuration = Duration(milliseconds: 200); +const Curve _kModalBottomSheetCurve = Easing.legacyDecelerate; +const double _kMinFlingVelocity = 700.0; +const double _kCloseProgressThreshold = 0.5; +const double _kDefaultScrollControlDisabledMaxHeightRatio = 9.0 / 16.0; + +/// A callback for when the user begins dragging the bottom sheet. +/// +/// Used by [BottomSheet.onDragStart]. +typedef BottomSheetDragStartHandler = void Function(DragStartDetails details); + +/// A callback for when the user stops dragging the bottom sheet. +/// +/// Used by [BottomSheet.onDragEnd]. +typedef BottomSheetDragEndHandler = + void Function(DragEndDetails details, {required bool isClosing}); + +/// A Material Design bottom sheet. +/// +/// There are two kinds of bottom sheets in Material Design: +/// +/// * _Persistent_. A persistent bottom sheet shows information that +/// supplements the primary content of the app. A persistent bottom sheet +/// remains visible even when the user interacts with other parts of the app. +/// Persistent bottom sheets can be created and displayed with the +/// [ScaffoldState.showBottomSheet] function or by specifying the +/// [Scaffold.bottomSheet] constructor parameter. +/// +/// * _Modal_. A modal bottom sheet is an alternative to a menu or a dialog and +/// prevents the user from interacting with the rest of the app. Modal bottom +/// sheets can be created and displayed with the [showModalBottomSheet] +/// function. +/// +/// The [BottomSheet] widget itself is rarely used directly. Instead, prefer to +/// create a persistent bottom sheet with [ScaffoldState.showBottomSheet] or +/// [Scaffold.bottomSheet], and a modal bottom sheet with [showModalBottomSheet]. +/// +/// See also: +/// +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal "persistent" bottom sheets. +/// * [showModalBottomSheet], which can be used to display a modal bottom +/// sheet. +/// * [BottomSheetThemeData], which can be used to customize the default +/// bottom sheet property values. +/// * The Material 2 spec at <https://m2.material.io/components/sheets-bottom>. +/// * The Material 3 spec at <https://m3.material.io/components/bottom-sheets/overview>. +class BottomSheet extends StatefulWidget { + /// Creates a bottom sheet. + /// + /// Typically, bottom sheets are created implicitly by + /// [ScaffoldState.showBottomSheet], for persistent bottom sheets, or by + /// [showModalBottomSheet], for modal bottom sheets. + const BottomSheet({ + super.key, + this.animationController, + this.enableDrag = true, + this.showDragHandle, + this.dragHandleColor, + this.dragHandleSize, + this.onDragStart, + this.onDragEnd, + this.backgroundColor, + this.shadowColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + required this.onClosing, + required this.builder, + }) : assert(elevation == null || elevation >= 0.0); + + /// The animation controller that controls the bottom sheet's entrance and + /// exit animations. + /// + /// The BottomSheet widget will manipulate the position of this animation, it + /// is not just a passive observer. + final AnimationController? animationController; + + /// Called when the bottom sheet begins to close. + /// + /// A bottom sheet might be prevented from closing (e.g., by user + /// interaction) even after this callback is called. For this reason, this + /// callback might be call multiple times for a given bottom sheet. + final VoidCallback onClosing; + + /// A builder for the contents of the sheet. + /// + /// The bottom sheet will wrap the widget produced by this builder in a + /// [Material] widget. + final WidgetBuilder builder; + + /// If true, the bottom sheet can be dragged up and down and dismissed by + /// swiping downwards. + /// + /// If [showDragHandle] is true, this only applies to the content below the drag handle, + /// because the drag handle is always draggable. + /// + /// Default is true. + /// + /// If this is true, the [animationController] must not be null. + /// Use [BottomSheet.createAnimationController] to create one, or provide + /// another AnimationController. + final bool enableDrag; + + /// Specifies whether a drag handle is shown. + /// + /// The drag handle appears at the top of the bottom sheet. The default color is + /// [ColorScheme.onSurfaceVariant] with an opacity of 0.4 and can be customized + /// using [dragHandleColor]. The default size is `Size(32,4)` and can be customized + /// with [dragHandleSize]. + /// + /// If null, then the value of [BottomSheetThemeData.showDragHandle] is used. If + /// that is also null, defaults to false. + /// + /// If this is true, the [animationController] must not be null. + /// Use [BottomSheet.createAnimationController] to create one, or provide + /// another AnimationController. + final bool? showDragHandle; + + /// The bottom sheet drag handle's color. + /// + /// Defaults to [BottomSheetThemeData.dragHandleColor]. + /// If that is also null, defaults to [ColorScheme.onSurfaceVariant]. + final Color? dragHandleColor; + + /// Defaults to [BottomSheetThemeData.dragHandleSize]. + /// If that is also null, defaults to Size(32, 4). + final Size? dragHandleSize; + + /// Called when the user begins dragging the bottom sheet vertically, if + /// [enableDrag] is true. + /// + /// Would typically be used to change the bottom sheet animation curve so + /// that it tracks the user's finger accurately. + final BottomSheetDragStartHandler? onDragStart; + + /// Called when the user stops dragging the bottom sheet, if [enableDrag] + /// is true. + /// + /// Would typically be used to reset the bottom sheet animation curve, so + /// that it animates non-linearly. Called before [onClosing] if the bottom + /// sheet is closing. + final BottomSheetDragEndHandler? onDragEnd; + + /// The bottom sheet's background color. + /// + /// Defines the bottom sheet's [Material.color]. + /// + /// Defaults to null and falls back to [Material]'s default. + final Color? backgroundColor; + + /// The color of the shadow below the sheet. + /// + /// If this property is null, then [BottomSheetThemeData.shadowColor] of + /// [ThemeData.bottomSheetTheme] is used. If that is also null, the default value + /// is transparent. + /// + /// See also: + /// + /// * [elevation], which defines the size of the shadow below the sheet. + /// * [shape], which defines the shape of the sheet and its shadow. + final Color? shadowColor; + + /// The z-coordinate at which to place this material relative to its parent. + /// + /// This controls the size of the shadow below the material. + /// + /// Defaults to 0. The value is non-negative. + final double? elevation; + + /// The shape of the bottom sheet. + /// + /// Defines the bottom sheet's [Material.shape]. + /// + /// Defaults to null and falls back to [Material]'s default. + final ShapeBorder? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defines the bottom sheet's [Material.clipBehavior]. + /// + /// Use this property to enable clipping of content when the bottom sheet has + /// a custom [shape] and the content can extend past this shape. For example, + /// a bottom sheet with rounded corners and an edge-to-edge [Image] at the + /// top. + /// + /// If this property is null then [BottomSheetThemeData.clipBehavior] of + /// [ThemeData.bottomSheetTheme] is used. If that's null then the behavior + /// will be [Clip.none]. + final Clip? clipBehavior; + + /// Defines minimum and maximum sizes for a [BottomSheet]. + /// + /// If null, then the ambient [ThemeData.bottomSheetTheme]'s + /// [BottomSheetThemeData.constraints] will be used. If that + /// is null and [ThemeData.useMaterial3] is true, then the bottom sheet + /// will have a max width of 640dp. If [ThemeData.useMaterial3] is false, then + /// the bottom sheet's size will be constrained by its parent + /// (usually a [Scaffold]). In this case, consider limiting the width by + /// setting smaller constraints for large screens. + /// + /// If constraints are specified (either in this property or in the + /// theme), the bottom sheet will be aligned to the bottom-center of + /// the available space. Otherwise, no alignment is applied. + final BoxConstraints? constraints; + + @override + State<BottomSheet> createState() => _BottomSheetState(); + + /// Creates an [AnimationController] suitable for a + /// [BottomSheet.animationController]. + /// + /// This API is available as a convenience for a Material compliant bottom sheet + /// animation. If alternative animation durations are required, a different + /// animation controller could be provided. + static AnimationController createAnimationController( + TickerProvider vsync, { + AnimationStyle? sheetAnimationStyle, + }) { + return AnimationController( + duration: sheetAnimationStyle?.duration ?? _kBottomSheetEnterDuration, + reverseDuration: sheetAnimationStyle?.reverseDuration ?? _kBottomSheetExitDuration, + debugLabel: 'BottomSheet', + vsync: vsync, + ); + } +} + +class _BottomSheetState extends State<BottomSheet> { + final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child'); + + double get _childHeight { + final renderBox = _childKey.currentContext!.findRenderObject()! as RenderBox; + return renderBox.size.height; + } + + bool get _dismissUnderway => widget.animationController!.status == AnimationStatus.reverse; + + Set<WidgetState> dragHandleStates = <WidgetState>{}; + + void _handleDragStart(DragStartDetails details) { + setState(() { + dragHandleStates.add(WidgetState.dragged); + }); + widget.onDragStart?.call(details); + } + + void _handleDragUpdate(DragUpdateDetails details) { + assert( + (widget.enableDrag || (widget.showDragHandle ?? false)) && widget.animationController != null, + "'BottomSheet.animationController' cannot be null when 'BottomSheet.enableDrag' or 'BottomSheet.showDragHandle' is true. " + "Use 'BottomSheet.createAnimationController' to create one, or provide another AnimationController.", + ); + if (_dismissUnderway) { + return; + } + widget.animationController!.value -= details.primaryDelta! / _childHeight; + } + + void _handleDragEnd(DragEndDetails details) { + assert( + (widget.enableDrag || (widget.showDragHandle ?? false)) && widget.animationController != null, + "'BottomSheet.animationController' cannot be null when 'BottomSheet.enableDrag' or 'BottomSheet.showDragHandle' is true. " + "Use 'BottomSheet.createAnimationController' to create one, or provide another AnimationController.", + ); + if (_dismissUnderway) { + return; + } + setState(() { + dragHandleStates.remove(WidgetState.dragged); + }); + var isClosing = false; + if (details.velocity.pixelsPerSecond.dy > _kMinFlingVelocity) { + final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight; + if (widget.animationController!.value > 0.0) { + widget.animationController!.fling(velocity: flingVelocity); + } + if (flingVelocity < 0.0) { + isClosing = true; + } + } else if (widget.animationController!.value < _kCloseProgressThreshold) { + if (widget.animationController!.value > 0.0) { + widget.animationController!.fling(velocity: -1.0); + } + isClosing = true; + } else { + widget.animationController!.forward(); + } + + widget.onDragEnd?.call(details, isClosing: isClosing); + + if (isClosing) { + widget.onClosing(); + } + } + + bool extentChanged(DraggableScrollableNotification notification) { + if (notification.extent == notification.minExtent && notification.shouldCloseOnMinExtent) { + widget.onClosing(); + } + return false; + } + + void _handleDragHandleHover(bool hovering) { + if (hovering != dragHandleStates.contains(WidgetState.hovered)) { + setState(() { + if (hovering) { + dragHandleStates.add(WidgetState.hovered); + } else { + dragHandleStates.remove(WidgetState.hovered); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final BottomSheetThemeData bottomSheetTheme = Theme.of(context).bottomSheetTheme; + final bool useMaterial3 = Theme.of(context).useMaterial3; + final BottomSheetThemeData defaults = useMaterial3 + ? _BottomSheetDefaultsM3(context) + : const BottomSheetThemeData(); + final BoxConstraints? constraints = + widget.constraints ?? bottomSheetTheme.constraints ?? defaults.constraints; + final Color? color = + widget.backgroundColor ?? bottomSheetTheme.backgroundColor ?? defaults.backgroundColor; + final Color? surfaceTintColor = bottomSheetTheme.surfaceTintColor ?? defaults.surfaceTintColor; + final Color? shadowColor = + widget.shadowColor ?? bottomSheetTheme.shadowColor ?? defaults.shadowColor; + final double elevation = + widget.elevation ?? bottomSheetTheme.elevation ?? defaults.elevation ?? 0; + final ShapeBorder? shape = widget.shape ?? bottomSheetTheme.shape ?? defaults.shape; + final Clip clipBehavior = widget.clipBehavior ?? bottomSheetTheme.clipBehavior ?? Clip.none; + final bool showDragHandle = + widget.showDragHandle ?? (widget.enableDrag && (bottomSheetTheme.showDragHandle ?? false)); + + Widget? dragHandle; + if (showDragHandle) { + dragHandle = _DragHandle( + onSemanticsTap: widget.onClosing, + handleHover: _handleDragHandleHover, + states: dragHandleStates, + dragHandleColor: widget.dragHandleColor, + dragHandleSize: widget.dragHandleSize, + ); + // Only add [_BottomSheetGestureDetector] to the drag handle when the rest of the + // bottom sheet is not draggable. If the whole bottom sheet is draggable, + // no need to add it. + if (!widget.enableDrag) { + dragHandle = _BottomSheetGestureDetector( + onVerticalDragStart: _handleDragStart, + onVerticalDragUpdate: _handleDragUpdate, + onVerticalDragEnd: _handleDragEnd, + child: dragHandle, + ); + } + } + + Widget bottomSheet = Material( + key: _childKey, + color: color, + elevation: elevation, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + shape: shape, + clipBehavior: clipBehavior, + child: NotificationListener<DraggableScrollableNotification>( + onNotification: extentChanged, + child: !showDragHandle + ? widget.builder(context) + : Stack( + alignment: Alignment.topCenter, + children: <Widget>[ + dragHandle!, + Padding( + padding: const EdgeInsets.only(top: kMinInteractiveDimension), + child: widget.builder(context), + ), + ], + ), + ), + ); + + if (constraints != null) { + bottomSheet = Align( + alignment: Alignment.bottomCenter, + heightFactor: 1.0, + child: ConstrainedBox(constraints: constraints, child: bottomSheet), + ); + } + + return !widget.enableDrag + ? bottomSheet + : _BottomSheetGestureDetector( + onVerticalDragStart: _handleDragStart, + onVerticalDragUpdate: _handleDragUpdate, + onVerticalDragEnd: _handleDragEnd, + child: bottomSheet, + ); + } +} + +// PERSISTENT BOTTOM SHEETS + +// See scaffold.dart + +class _DragHandle extends StatelessWidget { + const _DragHandle({ + required this.onSemanticsTap, + required this.handleHover, + required this.states, + this.dragHandleColor, + this.dragHandleSize, + }); + + final VoidCallback? onSemanticsTap; + final ValueChanged<bool> handleHover; + final Set<WidgetState> states; + final Color? dragHandleColor; + final Size? dragHandleSize; + + @override + Widget build(BuildContext context) { + final BottomSheetThemeData bottomSheetTheme = Theme.of(context).bottomSheetTheme; + final BottomSheetThemeData m3Defaults = _BottomSheetDefaultsM3(context); + final Size handleSize = + dragHandleSize ?? bottomSheetTheme.dragHandleSize ?? m3Defaults.dragHandleSize!; + + return MouseRegion( + onEnter: (PointerEnterEvent event) => handleHover(true), + onExit: (PointerExitEvent event) => handleHover(false), + child: Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + onTap: onSemanticsTap, + child: SizedBox( + width: math.max(handleSize.width, kMinInteractiveDimension), + height: math.max(handleSize.height, kMinInteractiveDimension), + child: Center( + child: Container( + height: handleSize.height, + width: handleSize.width, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(handleSize.height / 2), + color: + WidgetStateProperty.resolveAs<Color?>(dragHandleColor, states) ?? + WidgetStateProperty.resolveAs<Color?>( + bottomSheetTheme.dragHandleColor, + states, + ) ?? + m3Defaults.dragHandleColor, + ), + ), + ), + ), + ), + ); + } +} + +class _BottomSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget { + const _BottomSheetLayoutWithSizeListener({ + required this.onChildSizeChanged, + required this.animationValue, + required this.isScrollControlled, + required this.scrollControlDisabledMaxHeightRatio, + super.child, + }); + + final ValueChanged<Size> onChildSizeChanged; + final double animationValue; + final bool isScrollControlled; + final double scrollControlDisabledMaxHeightRatio; + + @override + _RenderBottomSheetLayoutWithSizeListener createRenderObject(BuildContext context) { + return _RenderBottomSheetLayoutWithSizeListener( + onChildSizeChanged: onChildSizeChanged, + animationValue: animationValue, + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderBottomSheetLayoutWithSizeListener renderObject, + ) { + renderObject.onChildSizeChanged = onChildSizeChanged; + renderObject.animationValue = animationValue; + renderObject.isScrollControlled = isScrollControlled; + renderObject.scrollControlDisabledMaxHeightRatio = scrollControlDisabledMaxHeightRatio; + } +} + +class _RenderBottomSheetLayoutWithSizeListener extends RenderShiftedBox { + _RenderBottomSheetLayoutWithSizeListener({ + RenderBox? child, + required ValueChanged<Size> onChildSizeChanged, + required double animationValue, + required bool isScrollControlled, + required double scrollControlDisabledMaxHeightRatio, + }) : _onChildSizeChanged = onChildSizeChanged, + _animationValue = animationValue, + _isScrollControlled = isScrollControlled, + _scrollControlDisabledMaxHeightRatio = scrollControlDisabledMaxHeightRatio, + super(child); + + Size _lastSize = Size.zero; + + ValueChanged<Size> get onChildSizeChanged => _onChildSizeChanged; + ValueChanged<Size> _onChildSizeChanged; + set onChildSizeChanged(ValueChanged<Size> newCallback) { + if (_onChildSizeChanged == newCallback) { + return; + } + + _onChildSizeChanged = newCallback; + markNeedsLayout(); + } + + double get animationValue => _animationValue; + double _animationValue; + set animationValue(double newValue) { + if (_animationValue == newValue) { + return; + } + + _animationValue = newValue; + markNeedsLayout(); + } + + bool get isScrollControlled => _isScrollControlled; + bool _isScrollControlled; + set isScrollControlled(bool newValue) { + if (_isScrollControlled == newValue) { + return; + } + + _isScrollControlled = newValue; + markNeedsLayout(); + } + + double get scrollControlDisabledMaxHeightRatio => _scrollControlDisabledMaxHeightRatio; + double _scrollControlDisabledMaxHeightRatio; + set scrollControlDisabledMaxHeightRatio(double newValue) { + if (_scrollControlDisabledMaxHeightRatio == newValue) { + return; + } + + _scrollControlDisabledMaxHeightRatio = newValue; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) => 0.0; + + @override + double computeMaxIntrinsicWidth(double height) => 0.0; + + @override + double computeMinIntrinsicHeight(double width) => 0.0; + + @override + double computeMaxIntrinsicHeight(double width) => 0.0; + + @override + Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final BoxConstraints childConstraints = _getConstraintsForChild(constraints); + final double? result = child.getDryBaseline(childConstraints, baseline); + if (result == null) { + return null; + } + final Size childSize = childConstraints.isTight + ? childConstraints.smallest + : child.getDryLayout(childConstraints); + return result + _getPositionForChild(constraints.biggest, childSize).dy; + } + + BoxConstraints _getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: constraints.maxWidth, + maxWidth: constraints.maxWidth, + maxHeight: isScrollControlled + ? constraints.maxHeight + : constraints.maxHeight * scrollControlDisabledMaxHeightRatio, + ); + } + + Offset _getPositionForChild(Size size, Size childSize) { + return Offset(0.0, size.height - childSize.height * animationValue); + } + + @override + void performLayout() { + size = constraints.biggest; + final RenderBox? child = this.child; + if (child == null) { + return; + } + + final BoxConstraints childConstraints = _getConstraintsForChild(constraints); + assert(childConstraints.debugAssertIsValid(isAppliedConstraint: true)); + child.layout(childConstraints, parentUsesSize: !childConstraints.isTight); + final childParentData = child.parentData! as BoxParentData; + final Size childSize = childConstraints.isTight ? childConstraints.smallest : child.size; + childParentData.offset = _getPositionForChild(size, childSize); + + if (_lastSize != childSize) { + _lastSize = childSize; + _onChildSizeChanged.call(_lastSize); + } + } +} + +class _ModalBottomSheet<T> extends StatefulWidget { + const _ModalBottomSheet({ + super.key, + required this.route, + this.backgroundColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + this.isScrollControlled = false, + this.scrollControlDisabledMaxHeightRatio = _kDefaultScrollControlDisabledMaxHeightRatio, + this.enableDrag = true, + this.showDragHandle = false, + this.animationStyle, + }); + + final ModalBottomSheetRoute<T> route; + final bool isScrollControlled; + final double scrollControlDisabledMaxHeightRatio; + final Color? backgroundColor; + final double? elevation; + final ShapeBorder? shape; + final Clip? clipBehavior; + final BoxConstraints? constraints; + final bool enableDrag; + final bool showDragHandle; + final AnimationStyle? animationStyle; + + @override + _ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>(); +} + +class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> { + late final ProxyAnimation _sheetAnimation; + late final CurvedAnimation _curvedSheetAnimation; + + @override + void initState() { + super.initState(); + _curvedSheetAnimation = CurvedAnimation( + parent: widget.route.animation!, + curve: widget.animationStyle?.curve ?? _kModalBottomSheetCurve, + reverseCurve: widget.animationStyle?.reverseCurve ?? _kModalBottomSheetCurve, + ); + _sheetAnimation = ProxyAnimation(_curvedSheetAnimation); + } + + @override + void didUpdateWidget(_ModalBottomSheet<T> oldWidget) { + super.didUpdateWidget(oldWidget); + assert(oldWidget.route == widget.route); + + assert( + _curvedSheetAnimation.curve == (widget.animationStyle?.curve ?? _kModalBottomSheetCurve), + ); + assert( + _curvedSheetAnimation.reverseCurve == + (widget.animationStyle?.reverseCurve ?? _kModalBottomSheetCurve), + ); + } + + @override + void dispose() { + // Detach to avoid leaking listeners on the route animation. + _sheetAnimation.parent = kAlwaysDismissedAnimation; + _curvedSheetAnimation.dispose(); + super.dispose(); + } + + String _getRouteLabel(MaterialLocalizations localizations) => switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => '', + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => localizations.dialogLabel, + }; + + EdgeInsets _getNewClipDetails(Size topLayerSize) { + return EdgeInsets.fromLTRB(0, 0, 0, topLayerSize.height); + } + + void handleDragStart(DragStartDetails details) { + // Allow the bottom sheet to track the user's finger accurately. + _sheetAnimation.parent = widget.route.animation; + } + + void handleDragEnd(DragEndDetails details, {bool? isClosing}) { + final double currentProgress = widget.route.animation!.value; + + // Rebind the animation using CurvedAnimation and Split so the + // remaining transition continues smoothly from the exact point + // where the drag gesture ended. + _sheetAnimation.parent = CurvedAnimation( + parent: widget.route.animation!, + curve: Split( + currentProgress, + endCurve: widget.animationStyle?.curve ?? _kModalBottomSheetCurve, + ), + reverseCurve: Split( + currentProgress, + endCurve: widget.animationStyle?.reverseCurve ?? _kModalBottomSheetCurve, + ), + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String routeLabel = _getRouteLabel(localizations); + + return AnimatedBuilder( + animation: _sheetAnimation, + child: BottomSheet( + animationController: widget.route._animationController, + onClosing: () { + if (widget.route.isCurrent) { + Navigator.pop(context); + } + }, + builder: widget.route.builder, + backgroundColor: widget.backgroundColor, + elevation: widget.elevation, + shape: widget.shape, + clipBehavior: widget.clipBehavior, + constraints: widget.constraints, + enableDrag: widget.enableDrag, + showDragHandle: widget.showDragHandle, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + ), + builder: (BuildContext context, Widget? child) { + final double animationValue = _sheetAnimation.value; + return Semantics( + scopesRoute: true, + namesRoute: true, + label: routeLabel, + explicitChildNodes: true, + child: ClipRect( + child: _BottomSheetLayoutWithSizeListener( + onChildSizeChanged: (Size size) { + widget.route._didChangeBarrierSemanticsClip(_getNewClipDetails(size)); + }, + animationValue: animationValue, + isScrollControlled: widget.isScrollControlled, + scrollControlDisabledMaxHeightRatio: widget.scrollControlDisabledMaxHeightRatio, + child: child, + ), + ), + ); + }, + ); + } +} + +/// A route that represents a Material Design modal bottom sheet. +/// +/// {@template flutter.material.ModalBottomSheetRoute} +/// A modal bottom sheet is an alternative to a menu or a dialog and prevents +/// the user from interacting with the rest of the app. +/// +/// A closely related widget is a persistent bottom sheet, which shows +/// information that supplements the primary content of the app without +/// preventing the user from interacting with the app. Persistent bottom sheets +/// can be created and displayed with the [showBottomSheet] function or the +/// [ScaffoldState.showBottomSheet] method. +/// +/// The [isScrollControlled] parameter specifies whether this is a route for +/// a bottom sheet that will utilize [DraggableScrollableSheet]. Consider +/// setting this parameter to true if this bottom sheet has +/// a scrollable child, such as a [ListView] or a [GridView], +/// to have the bottom sheet be draggable. +/// +/// The [isDismissible] parameter specifies whether the bottom sheet will be +/// dismissed when user taps on the scrim. +/// +/// The [enableDrag] parameter specifies whether the bottom sheet can be +/// dragged up and down and dismissed by swiping downwards. +/// +/// The [useSafeArea] parameter specifies whether the sheet will avoid system +/// intrusions on the top, left, and right. If false, no [SafeArea] is added; +/// and [MediaQuery.removePadding] is applied to the top, +/// so that system intrusions at the top will not be avoided by a [SafeArea] +/// inside the bottom sheet either. +/// Defaults to false. +/// +/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], +/// [constraints] and [transitionAnimationController] +/// parameters can be passed in to customize the appearance and behavior of +/// modal bottom sheets (see the documentation for these on [BottomSheet] +/// for more details). +/// +/// The [transitionAnimationController] controls the bottom sheet's entrance and +/// exit animations. It's up to the owner of the controller to call +/// [AnimationController.dispose] when the controller is no longer needed. +/// +/// The optional `settings` parameter sets the [RouteSettings] of the modal bottom sheet +/// sheet. This is particularly useful in the case that a user wants to observe +/// [PopupRoute]s within a [NavigatorObserver]. +/// {@endtemplate} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// See also: +/// +/// * [showModalBottomSheet], which is a way to display a ModalBottomSheetRoute. +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// function passed as the `builder` argument to [showModalBottomSheet]. +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal bottom sheets. +/// * [DraggableScrollableSheet], creates a bottom sheet that grows +/// and then becomes scrollable once it reaches its maximum size. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * The Material 2 spec at <https://m2.material.io/components/sheets-bottom>. +/// * The Material 3 spec at <https://m3.material.io/components/bottom-sheets/overview>. +class ModalBottomSheetRoute<T> extends PopupRoute<T> { + /// A modal bottom sheet route. + ModalBottomSheetRoute({ + required this.builder, + this.capturedThemes, + this.barrierLabel, + this.barrierOnTapHint, + this.backgroundColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + this.modalBarrierColor, + this.isDismissible = true, + this.enableDrag = true, + this.showDragHandle, + required this.isScrollControlled, + this.scrollControlDisabledMaxHeightRatio = _kDefaultScrollControlDisabledMaxHeightRatio, + super.settings, + super.requestFocus, + this.transitionAnimationController, + this.anchorPoint, + this.useSafeArea = false, + this.sheetAnimationStyle, + }); + + /// A builder for the contents of the sheet. + /// + /// The bottom sheet will wrap the widget produced by this builder in a + /// [Material] widget. + final WidgetBuilder builder; + + /// Stores a list of captured [InheritedTheme]s that are wrapped around the + /// bottom sheet. + /// + /// Consider setting this attribute when the [ModalBottomSheetRoute] + /// is created through [Navigator.push] and its friends. + final CapturedThemes? capturedThemes; + + /// Specifies whether this is a route for a bottom sheet that will utilize + /// [DraggableScrollableSheet]. + /// + /// Consider setting this parameter to true if this bottom sheet has + /// a scrollable child, such as a [ListView] or a [GridView], + /// to have the bottom sheet be draggable. + final bool isScrollControlled; + + /// The max height constraint ratio for the bottom sheet + /// when [isScrollControlled] is set to false, + /// no ratio will be applied when [isScrollControlled] is set to true. + /// + /// Defaults to 9 / 16. + final double scrollControlDisabledMaxHeightRatio; + + /// The bottom sheet's background color. + /// + /// Defines the bottom sheet's [Material.color]. + /// + /// If this property is not provided, it falls back to [Material]'s default. + final Color? backgroundColor; + + /// The z-coordinate at which to place this material relative to its parent. + /// + /// This controls the size of the shadow below the material. + /// + /// Defaults to 0, must not be negative. + final double? elevation; + + /// The shape of the bottom sheet. + /// + /// Defines the bottom sheet's [Material.shape]. + /// + /// If this property is not provided, it falls back to [Material]'s default. + final ShapeBorder? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defines the bottom sheet's [Material.clipBehavior]. + /// + /// Use this property to enable clipping of content when the bottom sheet has + /// a custom [shape] and the content can extend past this shape. For example, + /// a bottom sheet with rounded corners and an edge-to-edge [Image] at the + /// top. + /// + /// If this property is null, the [BottomSheetThemeData.clipBehavior] of + /// [ThemeData.bottomSheetTheme] is used. If that's null, the behavior defaults to [Clip.none] + /// will be [Clip.none]. + final Clip? clipBehavior; + + /// Defines minimum and maximum sizes for a [BottomSheet]. + /// + /// If null, the ambient [ThemeData.bottomSheetTheme]'s + /// [BottomSheetThemeData.constraints] will be used. If that + /// is null and [ThemeData.useMaterial3] is true, then the bottom sheet + /// will have a max width of 640dp. If [ThemeData.useMaterial3] is false, then + /// the bottom sheet's size will be constrained by its parent + /// (usually a [Scaffold]). In this case, consider limiting the width by + /// setting smaller constraints for large screens. + /// + /// If constraints are specified (either in this property or in the + /// theme), the bottom sheet will be aligned to the bottom-center of + /// the available space. Otherwise, no alignment is applied. + final BoxConstraints? constraints; + + /// Specifies the color of the modal barrier that darkens everything below the + /// bottom sheet. + /// + /// Defaults to `Colors.black54` if not provided. + final Color? modalBarrierColor; + + /// Specifies whether the bottom sheet will be dismissed + /// when user taps on the scrim. + /// + /// If true, the bottom sheet will be dismissed when user taps on the scrim. + /// + /// Defaults to true. + final bool isDismissible; + + /// Specifies whether the bottom sheet can be dragged up and down + /// and dismissed by swiping downwards. + /// + /// If true, the bottom sheet can be dragged up and down and dismissed by + /// swiping downwards. + /// + /// This applies to the content below the drag handle, if showDragHandle is true. + /// + /// Defaults is true. + final bool enableDrag; + + /// Specifies whether a drag handle is shown. + /// + /// The drag handle appears at the top of the bottom sheet. The default color is + /// [ColorScheme.onSurfaceVariant] with an opacity of 0.4 and can be customized + /// using dragHandleColor. The default size is `Size(32,4)` and can be customized + /// with dragHandleSize. + /// + /// If null, then the value of [BottomSheetThemeData.showDragHandle] is used. If + /// that is also null, defaults to false. + final bool? showDragHandle; + + /// The animation controller that controls the bottom sheet's entrance and + /// exit animations. + /// + /// The BottomSheet widget will manipulate the position of this animation, it + /// is not just a passive observer. + final AnimationController? transitionAnimationController; + + /// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint} + final Offset? anchorPoint; + + /// Whether to avoid system intrusions on the top, left, and right. + /// + /// If true, a [SafeArea] is inserted to keep the bottom sheet away from + /// system intrusions at the top, left, and right sides of the screen. + /// + /// If false, the bottom sheet will extend through any system intrusions + /// at the top, left, and right. + /// + /// If false, then moreover [MediaQuery.removePadding] will be used + /// to remove top padding, so that a [SafeArea] widget inside the bottom + /// sheet will have no effect at the top edge. If this is undesired, consider + /// setting [useSafeArea] to true. Alternatively, wrap the [SafeArea] in a + /// [MediaQuery] that restates an ambient [MediaQueryData] from outside [builder]. + /// + /// In either case, the bottom sheet extends all the way to the bottom of + /// the screen, including any system intrusions. + /// + /// The default is false. + final bool useSafeArea; + + /// Used to override the modal bottom sheet animation duration and reverse + /// animation duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the modal bottom sheet animation duration in the underlying + /// [BottomSheet.createAnimationController]. + /// + /// If [AnimationStyle.reverseDuration] is provided, it will be used to + /// override the modal bottom sheet reverse animation duration in the + /// underlying [BottomSheet.createAnimationController]. + /// + /// To disable the modal bottom sheet animation, use [AnimationStyle.noAnimation]. + final AnimationStyle? sheetAnimationStyle; + + /// {@template flutter.material.ModalBottomSheetRoute.barrierOnTapHint} + /// The semantic hint text that informs users what will happen if they + /// tap on the widget. Announced in the format of 'Double tap to ...'. + /// + /// If the field is null, the default hint will be used, which results in + /// announcement of 'Double tap to activate'. + /// {@endtemplate} + /// + /// See also: + /// + /// * [barrierDismissible], which controls the behavior of the barrier when + /// tapped. + /// * [ModalBarrier], which uses this field as onTapHint when it has an onTap action. + final String? barrierOnTapHint; + + final ValueNotifier<EdgeInsets> _clipDetailsNotifier = ValueNotifier<EdgeInsets>(EdgeInsets.zero); + + @override + void dispose() { + _clipDetailsNotifier.dispose(); + super.dispose(); + } + + /// Updates the details regarding how the [SemanticsNode.rect] (focus) of + /// the barrier for this [ModalBottomSheetRoute] should be clipped. + /// + /// Returns true if the clipDetails did change and false otherwise. + bool _didChangeBarrierSemanticsClip(EdgeInsets newClipDetails) { + if (_clipDetailsNotifier.value == newClipDetails) { + return false; + } + _clipDetailsNotifier.value = newClipDetails; + return true; + } + + @override + Duration get transitionDuration => + transitionAnimationController?.duration ?? + sheetAnimationStyle?.duration ?? + _kBottomSheetEnterDuration; + + @override + Duration get reverseTransitionDuration => + transitionAnimationController?.reverseDuration ?? + transitionAnimationController?.duration ?? + sheetAnimationStyle?.reverseDuration ?? + _kBottomSheetExitDuration; + + @override + bool get barrierDismissible => isDismissible; + + @override + final String? barrierLabel; + + @override + Color get barrierColor => modalBarrierColor ?? Colors.black54; + + AnimationController? _animationController; + + @override + AnimationController createAnimationController() { + assert(_animationController == null); + if (transitionAnimationController != null) { + _animationController = transitionAnimationController; + willDisposeAnimationController = false; + } else { + _animationController = BottomSheet.createAnimationController( + navigator!, + sheetAnimationStyle: sheetAnimationStyle, + ); + } + return _animationController!; + } + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + final Widget content = DisplayFeatureSubScreen( + anchorPoint: anchorPoint, + child: Builder( + builder: (BuildContext context) { + final BottomSheetThemeData sheetTheme = Theme.of(context).bottomSheetTheme; + final BottomSheetThemeData defaults = Theme.of(context).useMaterial3 + ? _BottomSheetDefaultsM3(context) + : const BottomSheetThemeData(); + return _ModalBottomSheet<T>( + route: this, + animationStyle: sheetAnimationStyle, + backgroundColor: + backgroundColor ?? + sheetTheme.modalBackgroundColor ?? + sheetTheme.backgroundColor ?? + defaults.backgroundColor, + elevation: + elevation ?? + sheetTheme.modalElevation ?? + sheetTheme.elevation ?? + defaults.modalElevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + enableDrag: enableDrag, + showDragHandle: showDragHandle ?? (enableDrag && (sheetTheme.showDragHandle ?? false)), + ); + }, + ), + ); + + Widget bottomSheet = useSafeArea + ? SafeArea(bottom: false, child: content) + : MediaQuery.removePadding(context: context, removeTop: true, child: content); + + // Prevent clicks inside the bottom sheet from passing through to the barrier + bottomSheet = Semantics(hitTestBehavior: SemanticsHitTestBehavior.opaque, child: bottomSheet); + + return capturedThemes?.wrap(bottomSheet) ?? bottomSheet; + } + + @override + Widget buildModalBarrier() { + if (barrierColor.a != 0 && !offstage) { + // changedInternalState is called if barrierColor or offstage updates + assert(barrierColor != barrierColor.withValues(alpha: 0.0)); + final Animation<Color?> color = animation!.drive( + ColorTween( + begin: barrierColor.withValues(alpha: 0.0), + end: barrierColor, // changedInternalState is called if barrierColor updates + ).chain( + CurveTween(curve: barrierCurve), + ), // changedInternalState is called if barrierCurve updates + ); + return AnimatedModalBarrier( + color: color, + dismissible: + barrierDismissible, // changedInternalState is called if barrierDismissible updates + semanticsLabel: barrierLabel, // changedInternalState is called if barrierLabel updates + barrierSemanticsDismissible: semanticsDismissible, + clipDetailsNotifier: _clipDetailsNotifier, + semanticsOnTapHint: barrierOnTapHint, + ); + } else { + return ModalBarrier( + dismissible: + barrierDismissible, // changedInternalState is called if barrierDismissible updates + semanticsLabel: barrierLabel, // changedInternalState is called if barrierLabel updates + barrierSemanticsDismissible: semanticsDismissible, + clipDetailsNotifier: _clipDetailsNotifier, + semanticsOnTapHint: barrierOnTapHint, + ); + } + } +} + +/// Shows a modal Material Design bottom sheet. +/// +/// {@macro flutter.material.ModalBottomSheetRoute} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the bottom sheet. It is only used when the method is called. Its +/// corresponding widget can be safely removed from the tree before the bottom +/// sheet is closed. +/// +/// The `useRootNavigator` parameter ensures that the root navigator is used to +/// display the [BottomSheet] when set to `true`. This is useful in the case +/// that a modal [BottomSheet] needs to be displayed above all other content +/// but the caller is inside another [Navigator]. +/// +/// Returns a `Future` that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the modal bottom sheet was closed. +/// +/// The 'barrierLabel' parameter can be used to set a custom barrier label. +/// Will default to [MaterialLocalizations.modalBarrierDismissLabel] of context +/// if not set. +/// +/// {@tool dartpad} +/// This example demonstrates how to use [showModalBottomSheet] to display a +/// bottom sheet that obscures the content behind it when a user taps a button. +/// It also demonstrates how to close the bottom sheet using the [Navigator] +/// when a user taps on a button inside the bottom sheet. +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_modal_bottom_sheet.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [showModalBottomSheet], as described in: +/// https://m3.material.io/components/bottom-sheets/overview +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_modal_bottom_sheet.1.dart ** +/// {@end-tool} +/// +/// The [sheetAnimationStyle] parameter is used to override the modal bottom sheet +/// animation duration and reverse animation duration. +/// +/// The [requestFocus] parameter is used to specify whether the bottom sheet should +/// request focus when shown. +/// {@macro flutter.widgets.navigator.Route.requestFocus} +/// +/// If [AnimationStyle.duration] is provided, it will be used to override +/// the modal bottom sheet animation duration in the underlying +/// [BottomSheet.createAnimationController]. +/// +/// If [AnimationStyle.reverseDuration] is provided, it will be used to +/// override the modal bottom sheet reverse animation duration in the +/// underlying [BottomSheet.createAnimationController]. +/// +/// To disable the bottom sheet animation, use [AnimationStyle.noAnimation]. +/// +/// {@tool dartpad} +/// This sample showcases how to override the [showModalBottomSheet] animation +/// duration and reverse animation duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_modal_bottom_sheet.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// function passed as the `builder` argument to [showModalBottomSheet]. +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal bottom sheets. +/// * [DraggableScrollableSheet], creates a bottom sheet that grows +/// and then becomes scrollable once it reaches its maximum size. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * The Material 2 spec at <https://m2.material.io/components/sheets-bottom>. +/// * The Material 3 spec at <https://m3.material.io/components/bottom-sheets/overview>. +/// * [AnimationStyle], which is used to override the modal bottom sheet +/// animation duration and reverse animation duration. +Future<T?> showModalBottomSheet<T>({ + required BuildContext context, + required WidgetBuilder builder, + Color? backgroundColor, + String? barrierLabel, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + Color? barrierColor, + bool isScrollControlled = false, + double scrollControlDisabledMaxHeightRatio = _kDefaultScrollControlDisabledMaxHeightRatio, + bool useRootNavigator = false, + bool isDismissible = true, + bool enableDrag = true, + bool? showDragHandle, + bool useSafeArea = false, + RouteSettings? routeSettings, + AnimationController? transitionAnimationController, + Offset? anchorPoint, + AnimationStyle? sheetAnimationStyle, + bool? requestFocus, +}) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + + final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return navigator.push( + ModalBottomSheetRoute<T>( + builder: builder, + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + barrierLabel: barrierLabel ?? localizations.scrimLabel, + barrierOnTapHint: localizations.scrimOnTapHint(localizations.bottomSheetLabel), + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + isDismissible: isDismissible, + modalBarrierColor: barrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + settings: routeSettings, + transitionAnimationController: transitionAnimationController, + anchorPoint: anchorPoint, + useSafeArea: useSafeArea, + sheetAnimationStyle: sheetAnimationStyle, + requestFocus: requestFocus, + ), + ); +} + +/// Shows a Material Design bottom sheet in the nearest [Scaffold] ancestor. To +/// show a persistent bottom sheet, use the [Scaffold.bottomSheet]. +/// +/// Returns a controller that can be used to close and otherwise manipulate the +/// bottom sheet. +/// +/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], +/// [constraints] and [transitionAnimationController] +/// parameters can be passed in to customize the appearance and behavior of +/// persistent bottom sheets (see the documentation for these on [BottomSheet] +/// for more details). +/// +/// The [enableDrag] parameter specifies whether the bottom sheet can be +/// dragged up and down and dismissed by swiping downwards. +/// +/// The [sheetAnimationStyle] parameter is used to override the bottom sheet +/// animation duration and reverse animation duration. +/// +/// If [AnimationStyle.duration] is provided, it will be used to override +/// the bottom sheet animation duration in the underlying +/// [BottomSheet.createAnimationController]. +/// +/// If [AnimationStyle.reverseDuration] is provided, it will be used to +/// override the bottom sheet reverse animation duration in the underlying +/// [BottomSheet.createAnimationController]. +/// +/// To disable the bottom sheet animation, use [AnimationStyle.noAnimation]. +/// +/// {@tool dartpad} +/// This sample showcases how to override the [showBottomSheet] animation +/// duration and reverse animation duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_bottom_sheet.0.dart ** +/// {@end-tool} +/// +/// To rebuild the bottom sheet (e.g. if it is stateful), call +/// [PersistentBottomSheetController.setState] on the controller returned by +/// this method. +/// +/// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing +/// [ModalRoute] and a back button is added to the app bar of the [Scaffold] +/// that closes the bottom sheet. +/// +/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and +/// does not add a back button to the enclosing Scaffold's app bar, use the +/// [Scaffold.bottomSheet] constructor parameter. +/// +/// A closely related widget is a modal bottom sheet, which is an alternative +/// to a menu or a dialog and prevents the user from interacting with the rest +/// of the app. Modal bottom sheets can be created and displayed with the +/// [showModalBottomSheet] function. +/// +/// The `context` argument is used to look up the [Scaffold] for the bottom +/// sheet. It is only used when the method is called. Its corresponding widget +/// can be safely removed from the tree before the bottom sheet is closed. +/// +/// See also: +/// +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// `builder`. +/// * [showModalBottomSheet], which can be used to display a modal bottom +/// sheet. +/// * [Scaffold.of], for information about how to obtain the [BuildContext]. +/// * The Material 2 spec at <https://m2.material.io/components/sheets-bottom>. +/// * The Material 3 spec at <https://m3.material.io/components/bottom-sheets/overview>. +/// * [AnimationStyle], which is used to override the bottom sheet animation +/// duration and reverse animation duration. +PersistentBottomSheetController showBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + Color? backgroundColor, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + bool? enableDrag, + bool? showDragHandle, + AnimationController? transitionAnimationController, + AnimationStyle? sheetAnimationStyle, +}) { + assert(debugCheckHasScaffold(context)); + + return Scaffold.of(context).showBottomSheet( + builder, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + transitionAnimationController: transitionAnimationController, + sheetAnimationStyle: sheetAnimationStyle, + ); +} + +class _BottomSheetGestureDetector extends StatelessWidget { + const _BottomSheetGestureDetector({ + required this.child, + required this.onVerticalDragStart, + required this.onVerticalDragUpdate, + required this.onVerticalDragEnd, + }); + + final Widget child; + final GestureDragStartCallback onVerticalDragStart; + final GestureDragUpdateCallback onVerticalDragUpdate; + final GestureDragEndCallback onVerticalDragEnd; + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + excludeFromSemantics: true, + gestures: <Type, GestureRecognizerFactory<GestureRecognizer>>{ + VerticalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>( + () => VerticalDragGestureRecognizer(debugOwner: this), + (VerticalDragGestureRecognizer instance) { + instance + ..onStart = onVerticalDragStart + ..onUpdate = onVerticalDragUpdate + ..onEnd = onVerticalDragEnd + ..onlyAcceptDragOnThreshold = true; + }, + ), + }, + child: child, + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - BottomSheet + +// 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. + +// dart format off +class _BottomSheetDefaultsM3 extends BottomSheetThemeData { + _BottomSheetDefaultsM3(this.context) + : super( + elevation: 1.0, + modalElevation: 1.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))), + constraints: const BoxConstraints(maxWidth: 640), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get backgroundColor => _colors.surfaceContainerLow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get dragHandleColor => _colors.onSurfaceVariant; + + @override + Size? get dragHandleSize => const Size(32, 4); + + @override + BoxConstraints? get constraints => const BoxConstraints(maxWidth: 640.0); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - BottomSheet diff --git a/packages/material_ui/lib/src/bottom_sheet_theme.dart b/packages/material_ui/lib/src/bottom_sheet_theme.dart new file mode 100644 index 000000000000..9e7c97cf95ac --- /dev/null +++ b/packages/material_ui/lib/src/bottom_sheet_theme.dart @@ -0,0 +1,231 @@ +// 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. + +/// @docImport 'bottom_sheet.dart'; +/// @docImport 'material.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +/// Defines default property values for [BottomSheet]'s [Material]. +/// +/// Descendant widgets obtain the current [BottomSheetThemeData] object +/// using `Theme.of(context).bottomSheetTheme`. Instances of +/// [BottomSheetThemeData] can be customized with +/// [BottomSheetThemeData.copyWith]. +/// +/// Typically a [BottomSheetThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.bottomSheetTheme]. +/// +/// All [BottomSheetThemeData] properties are `null` by default. +/// When null, the [BottomSheet] will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class BottomSheetThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.bottomSheetTheme]. + const BottomSheetThemeData({ + this.backgroundColor, + this.surfaceTintColor, + this.elevation, + this.modalBackgroundColor, + this.modalBarrierColor, + this.shadowColor, + this.modalElevation, + this.shape, + this.showDragHandle, + this.dragHandleColor, + this.dragHandleSize, + this.clipBehavior, + this.constraints, + }); + + /// Overrides the default value for [BottomSheet.backgroundColor]. + /// + /// If null, [BottomSheet] defaults to [Material]'s default. + final Color? backgroundColor; + + /// Overrides the default value for surfaceTintColor. + /// + /// If null, [BottomSheet] will not display an overlay color. + /// + /// See [Material.surfaceTintColor] for more details. + final Color? surfaceTintColor; + + /// Overrides the default value for [BottomSheet.elevation]. + /// + /// {@macro flutter.material.material.elevation} + /// + /// If null, [BottomSheet] defaults to 0.0. + final double? elevation; + + /// Value for [BottomSheet.backgroundColor] when the Bottom sheet is presented + /// as a modal bottom sheet. + final Color? modalBackgroundColor; + + /// Overrides the default value for barrier color when the Bottom sheet is presented as + /// a modal bottom sheet. + final Color? modalBarrierColor; + + /// Overrides the default value for [BottomSheet.shadowColor]. + final Color? shadowColor; + + /// Value for [BottomSheet.elevation] when the Bottom sheet is presented as a + /// modal bottom sheet. + final double? modalElevation; + + /// Overrides the default value for [BottomSheet.shape]. + /// + /// If null, no overriding shape is specified for [BottomSheet], so the + /// [BottomSheet] is rectangular. + final ShapeBorder? shape; + + /// Overrides the default value for [BottomSheet.showDragHandle]. + final bool? showDragHandle; + + /// Overrides the default value for [BottomSheet.dragHandleColor]. + final Color? dragHandleColor; + + /// Overrides the default value for [BottomSheet.dragHandleSize]. + final Size? dragHandleSize; + + /// Overrides the default value for [BottomSheet.clipBehavior]. + /// + /// If null, [BottomSheet] uses [Clip.none]. + final Clip? clipBehavior; + + /// Constrains the size of the [BottomSheet]. + /// + /// If null, the bottom sheet's size will be unconstrained. + final BoxConstraints? constraints; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + BottomSheetThemeData copyWith({ + Color? backgroundColor, + Color? surfaceTintColor, + double? elevation, + Color? modalBackgroundColor, + Color? modalBarrierColor, + Color? shadowColor, + double? modalElevation, + ShapeBorder? shape, + bool? showDragHandle, + Color? dragHandleColor, + Size? dragHandleSize, + Clip? clipBehavior, + BoxConstraints? constraints, + }) { + return BottomSheetThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + elevation: elevation ?? this.elevation, + modalBackgroundColor: modalBackgroundColor ?? this.modalBackgroundColor, + modalBarrierColor: modalBarrierColor ?? this.modalBarrierColor, + shadowColor: shadowColor ?? this.shadowColor, + modalElevation: modalElevation ?? this.modalElevation, + shape: shape ?? this.shape, + showDragHandle: showDragHandle ?? this.showDragHandle, + dragHandleColor: dragHandleColor ?? this.dragHandleColor, + dragHandleSize: dragHandleSize ?? this.dragHandleSize, + clipBehavior: clipBehavior ?? this.clipBehavior, + constraints: constraints ?? this.constraints, + ); + } + + /// Linearly interpolate between two bottom sheet themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static BottomSheetThemeData? lerp(BottomSheetThemeData? a, BottomSheetThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return BottomSheetThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + modalBackgroundColor: Color.lerp(a?.modalBackgroundColor, b?.modalBackgroundColor, t), + modalBarrierColor: Color.lerp(a?.modalBarrierColor, b?.modalBarrierColor, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + modalElevation: lerpDouble(a?.modalElevation, b?.modalElevation, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + showDragHandle: t < 0.5 ? a?.showDragHandle : b?.showDragHandle, + dragHandleColor: Color.lerp(a?.dragHandleColor, b?.dragHandleColor, t), + dragHandleSize: Size.lerp(a?.dragHandleSize, b?.dragHandleSize, t), + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + surfaceTintColor, + elevation, + modalBackgroundColor, + modalBarrierColor, + shadowColor, + modalElevation, + shape, + showDragHandle, + dragHandleColor, + dragHandleSize, + clipBehavior, + constraints, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is BottomSheetThemeData && + other.backgroundColor == backgroundColor && + other.surfaceTintColor == surfaceTintColor && + other.elevation == elevation && + other.modalBackgroundColor == modalBackgroundColor && + other.shadowColor == shadowColor && + other.modalBarrierColor == modalBarrierColor && + other.modalElevation == modalElevation && + other.shape == shape && + other.showDragHandle == showDragHandle && + other.dragHandleColor == dragHandleColor && + other.dragHandleSize == dragHandleSize && + other.clipBehavior == clipBehavior && + other.constraints == constraints; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('modalBackgroundColor', modalBackgroundColor, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('modalBarrierColor', modalBarrierColor, defaultValue: null)); + properties.add(DoubleProperty('modalElevation', modalElevation, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('showDragHandle', showDragHandle, defaultValue: null)); + properties.add(ColorProperty('dragHandleColor', dragHandleColor, defaultValue: null)); + properties.add(DiagnosticsProperty<Size>('dragHandleSize', dragHandleSize, defaultValue: null)); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + } +} diff --git a/packages/material_ui/lib/src/button.dart b/packages/material_ui/lib/src/button.dart new file mode 100644 index 000000000000..21c3a472f13a --- /dev/null +++ b/packages/material_ui/lib/src/button.dart @@ -0,0 +1,562 @@ +// 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. + +/// @docImport 'button_style_button.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'filled_button.dart'; +/// @docImport 'material_button.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_theme.dart'; +import 'constants.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_state_mixin.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// Creates a button based on [Semantics], [Material], and [InkWell] +/// widgets. +/// +/// This class does not use the current [Theme] or [ButtonTheme] to +/// compute default values for unspecified parameters. It's intended to +/// be used for custom Material buttons that optionally incorporate defaults +/// 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 [ElevatedButton], [FilledButton], +/// [OutlinedButton] and [TextButton]. +/// +/// See also: +/// +/// * [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. +@Category(<String>['Material', 'Button']) +class RawMaterialButton extends StatefulWidget { + /// Create a button based on [Semantics], [Material], and [InkWell] widgets. + /// + /// The [elevation], [focusElevation], [hoverElevation], [highlightElevation], + /// and [disabledElevation] parameters must be non-negative. + const RawMaterialButton({ + super.key, + required this.onPressed, + this.onLongPress, + this.onHighlightChanged, + this.mouseCursor, + this.textStyle, + this.fillColor, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.elevation = 2.0, + this.focusElevation = 4.0, + this.hoverElevation = 4.0, + this.highlightElevation = 8.0, + this.disabledElevation = 0.0, + this.padding = EdgeInsets.zero, + this.visualDensity = VisualDensity.standard, + this.constraints = const BoxConstraints(minWidth: 88.0, minHeight: 36.0), + this.shape = const RoundedRectangleBorder(), + this.animationDuration = kThemeChangeDuration, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + MaterialTapTargetSize? materialTapTargetSize, + this.child, + this.enableFeedback = true, + }) : materialTapTargetSize = materialTapTargetSize ?? MaterialTapTargetSize.padded, + assert(elevation >= 0.0), + assert(focusElevation >= 0.0), + assert(hoverElevation >= 0.0), + assert(highlightElevation >= 0.0), + assert(disabledElevation >= 0.0); + + /// Called when the button is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onPressed; + + /// Called when the button is long-pressed. + /// + /// If this callback and [onPressed] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onLongPress; + + /// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged] + /// callback. + /// + /// If [onPressed] changes from null to non-null while a gesture is ongoing, + /// this can fire during the build phase (in which case calling + /// [State.setState] is not allowed). + final ValueChanged<bool>? onHighlightChanged; + + /// {@template flutter.material.RawMaterialButton.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// button. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.pressed]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? mouseCursor; + + /// Defines the default text style, with [Material.textStyle], for the + /// button's [child]. + /// + /// If [TextStyle.color] is a [WidgetStateProperty<Color>], [WidgetStateProperty.resolve] + /// is used for the following [WidgetState]s: + /// + /// * [WidgetState.pressed]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + final TextStyle? textStyle; + + /// The color of the button's [Material]. + final Color? fillColor; + + /// The color for the button's [Material] when it has the input focus. + final Color? focusColor; + + /// The color for the button's [Material] when a pointer is hovering over it. + final Color? hoverColor; + + /// The highlight color for the button's [InkWell]. + final Color? highlightColor; + + /// The splash color for the button's [InkWell]. + final Color? splashColor; + + /// The elevation for the button's [Material] when the button + /// is [enabled] but not pressed. + /// + /// Defaults to 2.0. The value is always non-negative. + /// + /// See also: + /// + /// * [highlightElevation], the default elevation. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [focusElevation], the elevation when the button is focused. + /// * [disabledElevation], the elevation when the button is disabled. + final double elevation; + + /// The elevation for the button's [Material] when the button + /// is [enabled] and a pointer is hovering over it. + /// + /// Defaults to 4.0. The value is always non-negative. + /// + /// If the button is [enabled], and being pressed (in the highlighted state), + /// then the [highlightElevation] take precedence over the [hoverElevation]. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [focusElevation], the elevation when the button is focused. + /// * [disabledElevation], the elevation when the button is disabled. + /// * [highlightElevation], the elevation when the button is pressed. + final double hoverElevation; + + /// The elevation for the button's [Material] when the button + /// is [enabled] and has the input focus. + /// + /// Defaults to 4.0. The value is always non-negative. + /// + /// If the button is [enabled], and being pressed (in the highlighted state), + /// or a mouse cursor is hovering over the button, then the [hoverElevation] + /// and [highlightElevation] take precedence over the [focusElevation]. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [disabledElevation], the elevation when the button is disabled. + /// * [highlightElevation], the elevation when the button is pressed. + final double focusElevation; + + /// The elevation for the button's [Material] when the button + /// is [enabled] and pressed. + /// + /// Defaults to 8.0. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [focusElevation], the elevation when the button is focused. + /// * [disabledElevation], the elevation when the button is disabled. + final double highlightElevation; + + /// The elevation for the button's [Material] when the button + /// is not [enabled]. + /// + /// Defaults to 0.0. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [focusElevation], the elevation when the button is focused. + /// * [highlightElevation], the elevation when the button is pressed. + final double disabledElevation; + + /// The internal padding for the button's [child]. + final EdgeInsetsGeometry padding; + + /// Defines how compact the button's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets + /// within a [Theme]. + final VisualDensity visualDensity; + + /// Defines the button's size. + /// + /// Typically used to constrain the button's minimum size. + final BoxConstraints constraints; + + /// The shape of the button's [Material]. + /// + /// The button's highlight and splash are clipped to this shape. If the + /// button has an elevation, then its drop shadow is defined by this shape. + /// + /// If [shape] is a [WidgetStateProperty<ShapeBorder>], [WidgetStateProperty.resolve] + /// is used for the following [WidgetState]s: + /// + /// * [WidgetState.pressed]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + final ShapeBorder shape; + + /// Defines the duration of animated changes for [shape] and [elevation]. + /// + /// The default value is [kThemeChangeDuration]. + final Duration animationDuration; + + /// Typically the button's label. + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// Buttons are disabled by default. To enable a button, set its [onPressed] + /// or [onLongPress] properties to a non-null value. + bool get enabled => onPressed != null || onLongPress != null; + + /// Configures the minimum size of the tap target. + /// + /// Defaults to [MaterialTapTargetSize.padded]. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + final MaterialTapTargetSize materialTapTargetSize; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + @override + State<RawMaterialButton> createState() => _RawMaterialButtonState(); +} + +class _RawMaterialButtonState extends State<RawMaterialButton> with MaterialStateMixin { + @override + void initState() { + super.initState(); + setMaterialState(WidgetState.disabled, !widget.enabled); + } + + @override + void didUpdateWidget(RawMaterialButton oldWidget) { + super.didUpdateWidget(oldWidget); + setMaterialState(WidgetState.disabled, !widget.enabled); + // If the button is disabled while a press gesture is currently ongoing, + // InkWell makes a call to handleHighlightChanged. This causes an exception + // because it calls setState in the middle of a build. To preempt this, we + // manually update pressed to false when this situation occurs. + if (isDisabled && isPressed) { + removeMaterialState(WidgetState.pressed); + } + } + + double get _effectiveElevation { + // These conditionals are in order of precedence, so be careful about + // reorganizing them. + if (isDisabled) { + return widget.disabledElevation; + } + if (isPressed) { + return widget.highlightElevation; + } + if (isHovered) { + return widget.hoverElevation; + } + if (isFocused) { + return widget.focusElevation; + } + return widget.elevation; + } + + @override + Widget build(BuildContext context) { + final Color? effectiveTextColor = WidgetStateProperty.resolveAs<Color?>( + widget.textStyle?.color, + materialStates, + ); + final ShapeBorder? effectiveShape = WidgetStateProperty.resolveAs<ShapeBorder?>( + widget.shape, + materialStates, + ); + final Offset densityAdjustment = widget.visualDensity.baseSizeAdjustment; + final BoxConstraints effectiveConstraints = widget.visualDensity.effectiveConstraints( + widget.constraints, + ); + final MouseCursor? effectiveMouseCursor = WidgetStateProperty.resolveAs<MouseCursor?>( + widget.mouseCursor ?? WidgetStateMouseCursor.adaptiveClickable, + materialStates, + ); + final EdgeInsetsGeometry padding = widget.padding + .add( + EdgeInsets.only( + left: densityAdjustment.dx, + top: densityAdjustment.dy, + right: densityAdjustment.dx, + bottom: densityAdjustment.dy, + ), + ) + .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + + final Widget result = ConstrainedBox( + constraints: effectiveConstraints, + child: Material( + elevation: _effectiveElevation, + textStyle: widget.textStyle?.copyWith(color: effectiveTextColor), + shape: effectiveShape, + color: widget.fillColor, + // For compatibility during the M3 migration the default shadow needs to be passed. + shadowColor: Theme.of(context).useMaterial3 ? Theme.of(context).shadowColor : null, + type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button, + animationDuration: widget.animationDuration, + clipBehavior: widget.clipBehavior, + child: InkWell( + focusNode: widget.focusNode, + canRequestFocus: widget.enabled, + onFocusChange: updateMaterialState(WidgetState.focused), + autofocus: widget.autofocus, + onHighlightChanged: updateMaterialState( + WidgetState.pressed, + onChanged: widget.onHighlightChanged, + ), + splashColor: widget.splashColor, + highlightColor: widget.highlightColor, + focusColor: widget.focusColor, + hoverColor: widget.hoverColor, + onHover: updateMaterialState(WidgetState.hovered), + onTap: widget.onPressed, + onLongPress: widget.onLongPress, + enableFeedback: widget.enableFeedback, + customBorder: effectiveShape, + mouseCursor: effectiveMouseCursor, + child: IconTheme.merge( + data: IconThemeData(color: effectiveTextColor), + child: Padding( + padding: padding, + child: Center(widthFactor: 1.0, heightFactor: 1.0, child: widget.child), + ), + ), + ), + ), + ); + final Size minSize; + switch (widget.materialTapTargetSize) { + case MaterialTapTargetSize.padded: + minSize = Size( + kMinInteractiveDimension + densityAdjustment.dx, + kMinInteractiveDimension + densityAdjustment.dy, + ); + assert(minSize.width >= 0.0); + assert(minSize.height >= 0.0); + case MaterialTapTargetSize.shrinkWrap: + minSize = Size.zero; + } + + return Semantics( + container: true, + button: true, + enabled: widget.enabled, + child: _InputPadding(minSize: minSize, child: result), + ); + } +} + +/// A widget to pad the area around a [MaterialButton]'s inner [Material]. +/// +/// Redirect taps that occur in the padded area around the child to the center +/// of the child. This increases the size of the button and the button's +/// "tap target", but not its material or its ink splashes. +class _InputPadding extends SingleChildRenderObjectWidget { + const _InputPadding({super.child, required this.minSize}); + + final Size minSize; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double width = math.max(childSize.width, minSize.width); + final double height = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(width, height)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.dryLayoutChild); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final double? result = child.getDryBaseline(constraints, baseline); + if (result == null) { + return null; + } + final Size childSize = child.getDryLayout(constraints); + return result + + Alignment.center.alongOffset(getDryLayout(constraints) - childSize as Offset).dy; + } + + @override + void performLayout() { + size = _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.layoutChild); + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (super.hitTest(result, position: position)) { + return true; + } + final Offset center = child!.size.center(Offset.zero); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return child!.hitTest(result, position: center); + }, + ); + } +} diff --git a/packages/material_ui/lib/src/button_bar.dart b/packages/material_ui/lib/src/button_bar.dart new file mode 100644 index 000000000000..e24ecc856de0 --- /dev/null +++ b/packages/material_ui/lib/src/button_bar.dart @@ -0,0 +1,464 @@ +// 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. + +/// @docImport 'card.dart'; +/// @docImport 'dropdown.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_bar_theme.dart'; +import 'button_theme.dart'; +import 'dialog.dart'; + +/// An end-aligned row of buttons, laying out into a column if there is not +/// enough horizontal space. +/// +/// ## Updating to [OverflowBar] +/// +/// [ButtonBar] has been replaced by a more efficient widget, [OverflowBar]. +/// +/// ```dart +/// // Before +/// // ignore: deprecated_member_use +/// ButtonBar( +/// alignment: MainAxisAlignment.spaceEvenly, +/// children: <Widget>[ +/// TextButton( child: const Text('Button 1'), onPressed: () {}), +/// TextButton( child: const Text('Button 2'), onPressed: () {}), +/// TextButton( child: const Text('Button 3'), onPressed: () {}), +/// ], +/// ); +/// ``` +/// ```dart +/// // After +/// OverflowBar( +/// alignment: MainAxisAlignment.spaceEvenly, +/// children: <Widget>[ +/// TextButton( child: const Text('Button 1'), onPressed: () {}), +/// TextButton( child: const Text('Button 2'), onPressed: () {}), +/// TextButton( child: const Text('Button 3'), onPressed: () {}), +/// ], +/// ); +/// ``` +/// +/// See the [OverflowBar] documentation for more details. +/// +/// ## Using [ButtonBar] +/// +/// Places the buttons horizontally according to the [buttonPadding]. The +/// children are laid out in a [Row] with [MainAxisAlignment.end]. When the +/// [Directionality] is [TextDirection.ltr], the button bar's children are +/// right justified and the last child becomes the rightmost child. When the +/// [Directionality] [TextDirection.rtl] the children are left justified and +/// the last child becomes the leftmost child. +/// +/// If the button bar's width exceeds the maximum width constraint on the +/// widget, it aligns its buttons in a column. The key difference here +/// is that the [MainAxisAlignment] will then be treated as a +/// cross-axis/horizontal alignment. For example, if the buttons overflow and +/// [ButtonBar.alignment] was set to [MainAxisAlignment.start], the buttons would +/// align to the horizontal start of the button bar. +/// +/// The [ButtonBar] can be configured with a [ButtonBarTheme]. For any null +/// property on the ButtonBar, the surrounding ButtonBarTheme's property +/// will be used instead. If the ButtonBarTheme's property is null +/// as well, the property will default to a value described in the field +/// documentation below. +/// +/// The [children] are wrapped in a [ButtonTheme] that is a copy of the +/// surrounding ButtonTheme with the button properties overridden by the +/// properties of the ButtonBar as described above. These properties include +/// [buttonTextTheme], [buttonMinWidth], [buttonHeight], [buttonPadding], +/// and [buttonAlignedDropdown]. +/// +/// Used by [Dialog] to arrange the actions at the bottom of the dialog. +/// +/// 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. +/// * [Card], at the bottom of which it is common to place a [ButtonBar]. +/// * [Dialog], which uses a [ButtonBar] for its actions. +/// * [ButtonBarTheme], which configures the [ButtonBar]. +@Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', +) +class ButtonBar extends StatelessWidget { + /// Creates a button bar. + /// + /// Both [buttonMinWidth] and [buttonHeight] must be non-negative if they + /// are not null. + @Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', + ) + const ButtonBar({ + super.key, + this.alignment, + this.mainAxisSize, + this.buttonTextTheme, + this.buttonMinWidth, + this.buttonHeight, + this.buttonPadding, + this.buttonAlignedDropdown, + this.layoutBehavior, + this.overflowDirection, + this.overflowButtonSpacing, + this.children = const <Widget>[], + }) : assert(buttonMinWidth == null || buttonMinWidth >= 0.0), + assert(buttonHeight == null || buttonHeight >= 0.0), + assert(overflowButtonSpacing == null || overflowButtonSpacing >= 0.0); + + /// How the children should be placed along the horizontal axis. + /// + /// If null then it will use [ButtonBarThemeData.alignment]. If that is null, + /// it will default to [MainAxisAlignment.end]. + final MainAxisAlignment? alignment; + + /// How much horizontal space is available. See [Row.mainAxisSize]. + /// + /// If null then it will use the surrounding [ButtonBarThemeData.mainAxisSize]. + /// If that is null, it will default to [MainAxisSize.max]. + final MainAxisSize? mainAxisSize; + + /// Overrides the surrounding [ButtonBarThemeData.buttonTextTheme] to define a + /// button's base colors, size, internal padding and shape. + /// + /// If null then it will use the surrounding + /// [ButtonBarThemeData.buttonTextTheme]. If that is null, it will default to + /// [ButtonTextTheme.primary]. + final ButtonTextTheme? buttonTextTheme; + + /// Overrides the surrounding [ButtonThemeData.minWidth] to define a button's + /// minimum width. + /// + /// If null then it will use the surrounding [ButtonBarThemeData.buttonMinWidth]. + /// If that is null, it will default to 64.0 logical pixels. + final double? buttonMinWidth; + + /// Overrides the surrounding [ButtonThemeData.height] to define a button's + /// minimum height. + /// + /// If null then it will use the surrounding [ButtonBarThemeData.buttonHeight]. + /// If that is null, it will default to 36.0 logical pixels. + final double? buttonHeight; + + /// Overrides the surrounding [ButtonThemeData.padding] to define the padding + /// for a button's child (typically the button's label). + /// + /// If null then it will use the surrounding [ButtonBarThemeData.buttonPadding]. + /// If that is null, it will default to 8.0 logical pixels on the left + /// and right. + final EdgeInsetsGeometry? buttonPadding; + + /// Overrides the surrounding [ButtonThemeData.alignedDropdown] to define whether + /// a [DropdownButton] menu's width will match the button's width. + /// + /// If null then it will use the surrounding [ButtonBarThemeData.buttonAlignedDropdown]. + /// If that is null, it will default to false. + final bool? buttonAlignedDropdown; + + /// Defines whether a [ButtonBar] should size itself with a minimum size + /// constraint or with padding. + /// + /// Overrides the surrounding [ButtonThemeData.layoutBehavior]. + /// + /// If null then it will use the surrounding [ButtonBarThemeData.layoutBehavior]. + /// If that is null, it will default [ButtonBarLayoutBehavior.padded]. + final ButtonBarLayoutBehavior? layoutBehavior; + + /// Defines the vertical direction of a [ButtonBar]'s children if it + /// overflows. + /// + /// If [children] do not fit into a single row, then they + /// are arranged in a column. The first action is at the top of the + /// column if this property is set to [VerticalDirection.down], since it + /// "starts" at the top and "ends" at the bottom. On the other hand, + /// the first action will be at the bottom of the column if this + /// property is set to [VerticalDirection.up], since it "starts" at the + /// bottom and "ends" at the top. + /// + /// If null then it will use the surrounding + /// [ButtonBarThemeData.overflowDirection]. If that is null, it will + /// default to [VerticalDirection.down]. + final VerticalDirection? overflowDirection; + + /// The spacing between buttons when the button bar overflows. + /// + /// If the [children] do not fit into a single row, they are arranged into a + /// column. This parameter provides additional vertical space in between + /// buttons when it does overflow. + /// + /// The button spacing may appear to be more than the value provided. This is + /// because most buttons adhere to the [MaterialTapTargetSize] of 48px. So, + /// even though a button might visually be 36px in height, it might still take + /// up to 48px vertically. + /// + /// If null then no spacing will be added in between buttons in + /// an overflow state. + final double? overflowButtonSpacing; + + /// The buttons to arrange horizontally. + /// + /// Typically [ElevatedButton] or [TextButton] widgets. + final List<Widget> children; + + @override + Widget build(BuildContext context) { + final ButtonThemeData parentButtonTheme = ButtonTheme.of(context); + final ButtonBarThemeData barTheme = ButtonBarTheme.of(context); + + final ButtonThemeData buttonTheme = parentButtonTheme.copyWith( + textTheme: buttonTextTheme ?? barTheme.buttonTextTheme ?? ButtonTextTheme.primary, + minWidth: buttonMinWidth ?? barTheme.buttonMinWidth ?? 64.0, + height: buttonHeight ?? barTheme.buttonHeight ?? 36.0, + padding: + buttonPadding ?? barTheme.buttonPadding ?? const EdgeInsets.symmetric(horizontal: 8.0), + alignedDropdown: buttonAlignedDropdown ?? barTheme.buttonAlignedDropdown ?? false, + layoutBehavior: layoutBehavior ?? barTheme.layoutBehavior ?? ButtonBarLayoutBehavior.padded, + ); + + // We divide by 4.0 because we want half of the average of the left and right padding. + final double paddingUnit = buttonTheme.padding.horizontal / 4.0; + final Widget child = ButtonTheme.fromButtonThemeData( + data: buttonTheme, + child: _ButtonBarRow( + mainAxisAlignment: alignment ?? barTheme.alignment ?? MainAxisAlignment.end, + mainAxisSize: mainAxisSize ?? barTheme.mainAxisSize ?? MainAxisSize.max, + overflowDirection: + overflowDirection ?? barTheme.overflowDirection ?? VerticalDirection.down, + overflowButtonSpacing: overflowButtonSpacing, + children: children.map<Widget>((Widget child) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: paddingUnit), + child: child, + ); + }).toList(), + ), + ); + switch (buttonTheme.layoutBehavior) { + case ButtonBarLayoutBehavior.padded: + return Padding( + padding: EdgeInsets.symmetric(vertical: 2.0 * paddingUnit, horizontal: paddingUnit), + child: child, + ); + case ButtonBarLayoutBehavior.constrained: + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52.0), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: paddingUnit), + child: Center(child: child), + ), + ); + } + } +} + +/// Attempts to display buttons in a row, but displays them in a column if +/// there is not enough horizontal space. +/// +/// It first attempts to lay out its buttons as though there were no +/// maximum width constraints on the widget. If the button bar's width is +/// less than the maximum width constraints of the widget, it then lays +/// out the widget as though it were placed in a [Row]. +/// +/// However, if the button bar's width exceeds the maximum width constraint on +/// the widget, it then aligns its buttons in a column. The key difference here +/// is that the [MainAxisAlignment] will then be treated as a +/// cross-axis/horizontal alignment. For example, if the buttons overflow and +/// [ButtonBar.alignment] was set to [MainAxisAlignment.start], the column of +/// buttons would align to the horizontal start of the button bar. +class _ButtonBarRow extends Flex { + /// Creates a button bar that attempts to display in a row, but displays in + /// a column if there is insufficient horizontal space. + const _ButtonBarRow({ + required super.children, + super.mainAxisSize, + super.mainAxisAlignment, + VerticalDirection overflowDirection = VerticalDirection.down, + this.overflowButtonSpacing, + }) : super(direction: Axis.horizontal, verticalDirection: overflowDirection); + + final double? overflowButtonSpacing; + + @override + _RenderButtonBarRow createRenderObject(BuildContext context) { + return _RenderButtonBarRow( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: getEffectiveTextDirection(context)!, + verticalDirection: verticalDirection, + textBaseline: textBaseline, + overflowButtonSpacing: overflowButtonSpacing, + ); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderButtonBarRow renderObject) { + renderObject + ..direction = direction + ..mainAxisAlignment = mainAxisAlignment + ..mainAxisSize = mainAxisSize + ..crossAxisAlignment = crossAxisAlignment + ..textDirection = getEffectiveTextDirection(context) + ..verticalDirection = verticalDirection + ..textBaseline = textBaseline + ..overflowButtonSpacing = overflowButtonSpacing; + } +} + +/// Attempts to display buttons in a row, but displays them in a column if +/// there is not enough horizontal space. +/// +/// It first attempts to lay out its buttons as though there were no +/// maximum width constraints on the widget. If the button bar's width is +/// less than the maximum width constraints of the widget, it then lays +/// out the widget as though it were placed in a [Row]. +/// +/// However, if the button bar's width exceeds the maximum width constraint on +/// the widget, it then aligns its buttons in a column. The key difference here +/// is that the [MainAxisAlignment] will then be treated as a +/// cross-axis/horizontal alignment. For example, if the buttons overflow and +/// [ButtonBar.alignment] was set to [MainAxisAlignment.start], the buttons would +/// align to the horizontal start of the button bar. +class _RenderButtonBarRow extends RenderFlex { + /// Creates a button bar that attempts to display in a row, but displays in + /// a column if there is insufficient horizontal space. + _RenderButtonBarRow({ + super.direction, + super.mainAxisSize, + super.mainAxisAlignment, + super.crossAxisAlignment, + required TextDirection super.textDirection, + super.verticalDirection, + super.textBaseline, + this.overflowButtonSpacing, + }) : assert(overflowButtonSpacing == null || overflowButtonSpacing >= 0); + + bool _hasCheckedLayoutWidth = false; + double? overflowButtonSpacing; + + @override + BoxConstraints get constraints { + if (_hasCheckedLayoutWidth) { + return super.constraints; + } + return super.constraints.copyWith(maxWidth: double.infinity); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final Size size = super.computeDryLayout(constraints.copyWith(maxWidth: double.infinity)); + if (size.width <= constraints.maxWidth) { + return super.computeDryLayout(constraints); + } + var currentHeight = 0.0; + RenderBox? child = firstChild; + while (child != null) { + final BoxConstraints childConstraints = constraints.copyWith(minWidth: 0.0); + final Size childSize = child.getDryLayout(childConstraints); + currentHeight += childSize.height; + child = childAfter(child); + if (overflowButtonSpacing != null && child != null) { + currentHeight += overflowButtonSpacing!; + } + } + return constraints.constrain(Size(constraints.maxWidth, currentHeight)); + } + + @override + void performLayout() { + // Set check layout width to false in reload or update cases. + _hasCheckedLayoutWidth = false; + + // Perform layout to ensure that button bar knows how wide it would + // ideally want to be. + super.performLayout(); + _hasCheckedLayoutWidth = true; + + // If the button bar is constrained by width and it overflows, set the + // buttons to align vertically. Otherwise, lay out the button bar + // horizontally. + if (size.width <= constraints.maxWidth) { + // A second performLayout is required to ensure that the original maximum + // width constraints are used. The original perform layout call assumes + // a maximum width constraint of infinity. + super.performLayout(); + } else { + final BoxConstraints childConstraints = constraints.copyWith(minWidth: 0.0); + var currentHeight = 0.0; + RenderBox? child = switch (verticalDirection) { + VerticalDirection.down => firstChild, + VerticalDirection.up => lastChild, + }; + + while (child != null) { + final childParentData = child.parentData! as FlexParentData; + + // Lay out the child with the button bar's original constraints, but + // with minimum width set to zero. + child.layout(childConstraints, parentUsesSize: true); + + // Set the cross axis alignment for the column to match the main axis + // alignment for a row. For [MainAxisAlignment.spaceAround], + // [MainAxisAlignment.spaceBetween] and [MainAxisAlignment.spaceEvenly] + // cases, use [MainAxisAlignment.start]. + switch (textDirection!) { + case TextDirection.ltr: + switch (mainAxisAlignment) { + case MainAxisAlignment.center: + final double midpoint = (constraints.maxWidth - child.size.width) / 2.0; + childParentData.offset = Offset(midpoint, currentHeight); + case MainAxisAlignment.end: + childParentData.offset = Offset( + constraints.maxWidth - child.size.width, + currentHeight, + ); + case MainAxisAlignment.spaceAround: + case MainAxisAlignment.spaceBetween: + case MainAxisAlignment.spaceEvenly: + case MainAxisAlignment.start: + childParentData.offset = Offset(0, currentHeight); + } + case TextDirection.rtl: + switch (mainAxisAlignment) { + case MainAxisAlignment.center: + final double midpoint = constraints.maxWidth / 2.0 - child.size.width / 2.0; + childParentData.offset = Offset(midpoint, currentHeight); + case MainAxisAlignment.end: + childParentData.offset = Offset(0, currentHeight); + case MainAxisAlignment.spaceAround: + case MainAxisAlignment.spaceBetween: + case MainAxisAlignment.spaceEvenly: + case MainAxisAlignment.start: + childParentData.offset = Offset( + constraints.maxWidth - child.size.width, + currentHeight, + ); + } + } + currentHeight += child.size.height; + child = switch (verticalDirection) { + VerticalDirection.down => childParentData.nextSibling, + VerticalDirection.up => childParentData.previousSibling, + }; + + if (overflowButtonSpacing != null && child != null) { + currentHeight += overflowButtonSpacing!; + } + } + size = constraints.constrain(Size(constraints.maxWidth, currentHeight)); + } + } +} diff --git a/packages/material_ui/lib/src/button_bar_theme.dart b/packages/material_ui/lib/src/button_bar_theme.dart new file mode 100644 index 000000000000..cbafcc241416 --- /dev/null +++ b/packages/material_ui/lib/src/button_bar_theme.dart @@ -0,0 +1,294 @@ +// 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. + +/// @docImport 'button_bar.dart'; +/// @docImport 'dropdown.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the visual properties of [ButtonBar] widgets. +/// +/// Used by [ButtonBarTheme] to control the visual properties of [ButtonBar] +/// instances in a widget subtree. +/// +/// To obtain this configuration, use [ButtonBarTheme.of] to access the closest +/// ancestor [ButtonBarTheme] of the current [BuildContext]. +/// +/// See also: +/// +/// * [ButtonBarTheme], an [InheritedWidget] that propagates the theme down +/// its subtree. +/// * [ButtonBar], which uses this to configure itself and its children +/// button widgets. +@Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', +) +@immutable +class ButtonBarThemeData with Diagnosticable { + /// Constructs the set of properties used to configure [ButtonBar] widgets. + /// + /// Both [buttonMinWidth] and [buttonHeight] must be non-negative if they + /// are not null. + @Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', + ) + const ButtonBarThemeData({ + this.alignment, + this.mainAxisSize, + this.buttonTextTheme, + this.buttonMinWidth, + this.buttonHeight, + this.buttonPadding, + this.buttonAlignedDropdown, + this.layoutBehavior, + this.overflowDirection, + }) : assert(buttonMinWidth == null || buttonMinWidth >= 0.0), + assert(buttonHeight == null || buttonHeight >= 0.0); + + /// How the children should be placed along the horizontal axis. + final MainAxisAlignment? alignment; + + /// How much horizontal space is available. See [Row.mainAxisSize]. + final MainAxisSize? mainAxisSize; + + /// Defines a [ButtonBar] button's base colors, and the defaults for + /// the button's minimum size, internal padding, and shape. + /// + /// This will override the surrounding [ButtonThemeData.textTheme] setting + /// for buttons contained in the [ButtonBar]. + /// + /// Despite the name, this property is not a [TextTheme], its value is not a + /// collection of [TextStyle]s. + final ButtonTextTheme? buttonTextTheme; + + /// The minimum width for [ButtonBar] buttons. + /// + /// This will override the surrounding [ButtonThemeData.minWidth] setting + /// for buttons contained in the [ButtonBar]. + /// + /// The actual horizontal space allocated for a button's child is + /// at least this value less the theme's horizontal [ButtonThemeData.padding]. + final double? buttonMinWidth; + + /// The minimum height for [ButtonBar] buttons. + /// + /// This will override the surrounding [ButtonThemeData.height] setting + /// for buttons contained in the [ButtonBar]. + final double? buttonHeight; + + /// Padding for a [ButtonBar] button's child (typically the button's label). + /// + /// This will override the surrounding [ButtonThemeData.padding] setting + /// for buttons contained in the [ButtonBar]. + final EdgeInsetsGeometry? buttonPadding; + + /// If true, then a [DropdownButton] menu's width will match the [ButtonBar] + /// button's width. + /// + /// If false, then the dropdown's menu will be wider than + /// its button. In either case the dropdown button will line up the leading + /// edge of the menu's value with the leading edge of the values + /// displayed by the menu items. + /// + /// This will override the surrounding [ButtonThemeData.alignedDropdown] setting + /// for buttons contained in the [ButtonBar]. + /// + /// This property only affects [DropdownButton] contained in a [ButtonBar] + /// and its menu. + final bool? buttonAlignedDropdown; + + /// Defines whether a [ButtonBar] should size itself with a minimum size + /// constraint or with padding. + final ButtonBarLayoutBehavior? layoutBehavior; + + /// Defines the vertical direction of a [ButtonBar]'s children if it + /// overflows. + /// + /// If the [ButtonBar]'s children do not fit into a single row, then they + /// are arranged in a column. The first action is at the top of the + /// column if this property is set to [VerticalDirection.down], since it + /// "starts" at the top and "ends" at the bottom. On the other hand, + /// the first action will be at the bottom of the column if this + /// property is set to [VerticalDirection.up], since it "starts" at the + /// bottom and "ends" at the top. + final VerticalDirection? overflowDirection; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + ButtonBarThemeData copyWith({ + MainAxisAlignment? alignment, + MainAxisSize? mainAxisSize, + ButtonTextTheme? buttonTextTheme, + double? buttonMinWidth, + double? buttonHeight, + EdgeInsetsGeometry? buttonPadding, + bool? buttonAlignedDropdown, + ButtonBarLayoutBehavior? layoutBehavior, + VerticalDirection? overflowDirection, + }) { + return ButtonBarThemeData( + alignment: alignment ?? this.alignment, + mainAxisSize: mainAxisSize ?? this.mainAxisSize, + buttonTextTheme: buttonTextTheme ?? this.buttonTextTheme, + buttonMinWidth: buttonMinWidth ?? this.buttonMinWidth, + buttonHeight: buttonHeight ?? this.buttonHeight, + buttonPadding: buttonPadding ?? this.buttonPadding, + buttonAlignedDropdown: buttonAlignedDropdown ?? this.buttonAlignedDropdown, + layoutBehavior: layoutBehavior ?? this.layoutBehavior, + overflowDirection: overflowDirection ?? this.overflowDirection, + ); + } + + /// Linearly interpolate between two button bar themes. + /// + /// If both arguments are null, then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static ButtonBarThemeData? lerp(ButtonBarThemeData? a, ButtonBarThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return ButtonBarThemeData( + alignment: t < 0.5 ? a?.alignment : b?.alignment, + mainAxisSize: t < 0.5 ? a?.mainAxisSize : b?.mainAxisSize, + buttonTextTheme: t < 0.5 ? a?.buttonTextTheme : b?.buttonTextTheme, + buttonMinWidth: lerpDouble(a?.buttonMinWidth, b?.buttonMinWidth, t), + buttonHeight: lerpDouble(a?.buttonHeight, b?.buttonHeight, t), + buttonPadding: EdgeInsetsGeometry.lerp(a?.buttonPadding, b?.buttonPadding, t), + buttonAlignedDropdown: t < 0.5 ? a?.buttonAlignedDropdown : b?.buttonAlignedDropdown, + layoutBehavior: t < 0.5 ? a?.layoutBehavior : b?.layoutBehavior, + overflowDirection: t < 0.5 ? a?.overflowDirection : b?.overflowDirection, + ); + } + + @override + int get hashCode => Object.hash( + alignment, + mainAxisSize, + buttonTextTheme, + buttonMinWidth, + buttonHeight, + buttonPadding, + buttonAlignedDropdown, + layoutBehavior, + overflowDirection, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ButtonBarThemeData && + other.alignment == alignment && + other.mainAxisSize == mainAxisSize && + other.buttonTextTheme == buttonTextTheme && + other.buttonMinWidth == buttonMinWidth && + other.buttonHeight == buttonHeight && + other.buttonPadding == buttonPadding && + other.buttonAlignedDropdown == buttonAlignedDropdown && + other.layoutBehavior == layoutBehavior && + other.overflowDirection == overflowDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<MainAxisAlignment>('alignment', alignment, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<MainAxisSize>('mainAxisSize', mainAxisSize, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ButtonTextTheme>('textTheme', buttonTextTheme, defaultValue: null), + ); + properties.add(DoubleProperty('minWidth', buttonMinWidth, defaultValue: null)); + properties.add(DoubleProperty('height', buttonHeight, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('padding', buttonPadding, defaultValue: null), + ); + properties.add( + FlagProperty( + 'buttonAlignedDropdown', + value: buttonAlignedDropdown, + ifTrue: 'dropdown width matches button', + ), + ); + properties.add( + DiagnosticsProperty<ButtonBarLayoutBehavior>( + 'layoutBehavior', + layoutBehavior, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<VerticalDirection>( + 'overflowDirection', + overflowDirection, + defaultValue: null, + ), + ); + } +} + +/// Applies a button bar theme to descendant [ButtonBar] widgets. +/// +/// A button bar theme describes the layout and properties for the buttons +/// contained in a [ButtonBar]. +/// +/// Descendant widgets obtain the current theme's [ButtonBarTheme] object using +/// [ButtonBarTheme.of]. When a widget uses [ButtonBarTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// A button bar theme can be specified as part of the overall Material theme +/// using [ThemeData.buttonBarTheme]. +/// +/// See also: +/// +/// * [ButtonBarThemeData], which describes the actual configuration of a button +/// bar theme. +class ButtonBarTheme extends InheritedWidget { + /// Constructs a button bar theme that configures all descendant [ButtonBar] + /// widgets. + const ButtonBarTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [ButtonBar] widgets. + final ButtonBarThemeData data; + + /// Returns the configuration [data] from the closest [ButtonBarTheme] + /// ancestor. If there is no ancestor, it returns [ThemeData.buttonBarTheme]. + /// Applications can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// // ignore: deprecated_member_use + /// ButtonBarThemeData theme = ButtonBarTheme.of(context); + /// ``` + static ButtonBarThemeData of(BuildContext context) { + final ButtonBarTheme? buttonBarTheme = context + .dependOnInheritedWidgetOfExactType<ButtonBarTheme>(); + return buttonBarTheme?.data ?? Theme.of(context).buttonBarTheme; + } + + @override + bool updateShouldNotify(ButtonBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/button_style.dart b/packages/material_ui/lib/src/button_style.dart new file mode 100644 index 000000000000..74ebec4e6eb0 --- /dev/null +++ b/packages/material_ui/lib/src/button_style.dart @@ -0,0 +1,774 @@ +// 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. + +/// @docImport 'button_style_button.dart'; +/// @docImport 'constants.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'elevated_button_theme.dart'; +/// @docImport 'filled_button.dart'; +/// @docImport 'filled_button_theme.dart'; +/// @docImport 'material.dart'; +/// @docImport 'no_splash.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'outlined_button_theme.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_button_theme.dart'; +/// @docImport 'theme.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style_button.dart'; +import 'ink_well.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late BuildContext context; +// typedef MyAppHome = Placeholder; + +/// The type for [ButtonStyle.backgroundBuilder] and [ButtonStyle.foregroundBuilder]. +/// +/// The [states] parameter is the button's current pressed/hovered/etc state. The [child] is +/// typically a descendant of the returned widget. +typedef ButtonLayerBuilder = + Widget Function(BuildContext context, Set<WidgetState> states, Widget? child); + +/// The visual properties that most buttons have in common. +/// +/// Buttons and their themes have a ButtonStyle property which defines the visual +/// properties whose default values are to be overridden. The default values are +/// defined by the individual button widgets and are typically based on overall +/// theme's [ThemeData.colorScheme] and [ThemeData.textTheme]. +/// +/// All of the ButtonStyle properties are null by default. +/// +/// Many of the ButtonStyle properties are [WidgetStateProperty] objects which +/// resolve to different values depending on the button's state. For example +/// the [Color] properties are defined with `WidgetStateProperty<Color>` and +/// can resolve to different colors depending on if the button is pressed, +/// hovered, focused, disabled, etc. +/// +/// These properties can override the default value for just one state or all of +/// them. For example to create a [ElevatedButton] whose background color is the +/// color scheme’s primary color with 50% opacity, but only when the button is +/// pressed, one could write: +/// +/// ```dart +/// ElevatedButton( +/// style: ButtonStyle( +/// backgroundColor: WidgetStateProperty.resolveWith<Color?>( +/// (Set<WidgetState> states) { +/// if (states.contains(WidgetState.pressed)) { +/// return Theme.of(context).colorScheme.primary.withValues(alpha: 0.5); +/// } +/// return null; // Use the component's default. +/// }, +/// ), +/// ), +/// child: const Text('Fly me to the moon'), +/// onPressed: () { +/// // ... +/// }, +/// ), +/// ``` +/// +/// In this case the background color for all other button states would fallback +/// to the ElevatedButton’s default values. To unconditionally set the button's +/// [backgroundColor] for all states one could write: +/// +/// ```dart +/// ElevatedButton( +/// style: const ButtonStyle( +/// backgroundColor: WidgetStatePropertyAll<Color>(Colors.green), +/// ), +/// child: const Text('Let me play among the stars'), +/// onPressed: () { +/// // ... +/// }, +/// ), +/// ``` +/// +/// Configuring a ButtonStyle directly makes it possible to very +/// precisely control the button’s visual attributes for all states. +/// This level of control is typically required when a custom +/// “branded” look and feel is desirable. However, in many cases it’s +/// 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: +/// [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 +/// standard opacity adjustments for the pressed, focused, and +/// hovered states, one could write: +/// +/// ```dart +/// TextButton( +/// style: TextButton.styleFrom(foregroundColor: Colors.green), +/// child: const Text('Let me see what spring is like'), +/// onPressed: () { +/// // ... +/// }, +/// ), +/// ``` +/// +/// To configure all of the application's text buttons in the same +/// way, specify the overall theme's `textButtonTheme`: +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// textButtonTheme: TextButtonThemeData( +/// style: TextButton.styleFrom(foregroundColor: Colors.green), +/// ), +/// ), +/// home: const MyAppHome(), +/// ), +/// ``` +/// +/// ## Material 3 button types +/// +/// Material Design 3 specifies five types of common buttons. Flutter provides +/// support for these using the following button classes: +/// <style>table,td,th { border-collapse: collapse; padding: 0.45em; } td { border: 1px solid }</style> +/// +/// | Type | Flutter implementation | +/// | :----------- | :---------------------- | +/// | Elevated | [ElevatedButton] | +/// | Filled | [FilledButton] | +/// | Filled Tonal | [FilledButton.tonal] | +/// | Outlined | [OutlinedButton] | +/// | Text | [TextButton] | +/// +/// {@tool dartpad} +/// This sample shows how to create each of the Material 3 button types with Flutter. +/// +/// ** See code in examples/api/lib/material/button_style/button_style.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [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]. + const ButtonStyle({ + this.textStyle, + this.backgroundColor, + this.foregroundColor, + this.overlayColor, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.padding, + this.minimumSize, + this.fixedSize, + this.maximumSize, + this.iconColor, + this.iconSize, + this.iconAlignment, + this.side, + this.shape, + this.mouseCursor, + this.visualDensity, + this.tapTargetSize, + this.animationDuration, + this.enableFeedback, + this.alignment, + this.splashFactory, + this.backgroundBuilder, + this.foregroundBuilder, + }); + + /// The style for a button's [Text] widget descendants. + /// + /// The color of the [textStyle] is typically not used directly, the + /// [foregroundColor] is used instead. + final WidgetStateProperty<TextStyle?>? textStyle; + + /// The button's background fill color. + final WidgetStateProperty<Color?>? backgroundColor; + + /// The color for the button's [Text] widget descendants. + /// + /// This color is typically used instead of the color of the [textStyle]. All + /// of the components that compute defaults from [ButtonStyle] values + /// compute a default [foregroundColor] and use that instead of the + /// [textStyle]'s color. + final WidgetStateProperty<Color?>? foregroundColor; + + /// The highlight color that's typically used to indicate that + /// the button is focused, hovered, or pressed. + final WidgetStateProperty<Color?>? overlayColor; + + /// The shadow color of the button's [Material]. + /// + /// The material's elevation shadow can be difficult to see for + /// dark themes, so by default the button classes add a + /// semi-transparent overlay to indicate elevation. See + /// [ThemeData.applyElevationOverlayColor]. + final WidgetStateProperty<Color?>? shadowColor; + + /// The surface tint color of the button's [Material]. + /// + /// See [Material.surfaceTintColor] for more details. + final WidgetStateProperty<Color?>? surfaceTintColor; + + /// The elevation of the button's [Material]. + final WidgetStateProperty<double?>? elevation; + + /// The padding between the button's boundary and its child. + /// + /// The vertical aspect of the default or user-specified padding is adjusted + /// automatically based on [visualDensity]. + /// + /// When the visual density is [VisualDensity.compact], the top and bottom insets + /// are reduced by 8 pixels or set to 0 pixels if the result of the reduced padding + /// is negative. For example: the visual density defaults to [VisualDensity.compact] + /// on desktop and web, so if the provided padding is 16 pixels on the top and bottom, + /// it will be reduced to 8 pixels on the top and bottom. If the provided padding + /// is 4 pixels, the result will be no padding on the top and bottom. + /// + /// When the visual density is [VisualDensity.comfortable], the top and bottom insets + /// are reduced by 4 pixels or set to 0 pixels if the result of the reduced padding + /// is negative. + /// + /// When the visual density is [VisualDensity.standard] the top and bottom insets + /// are not changed. The visual density defaults to [VisualDensity.standard] on mobile. + /// + /// See [ThemeData.visualDensity] for more details. + final WidgetStateProperty<EdgeInsetsGeometry?>? padding; + + /// The minimum size of the button itself before applying [visualDensity]. + /// + /// The size of the rectangle the button lies within may be larger + /// per [tapTargetSize]. + /// + /// This value must be less than or equal to [maximumSize]. + /// + /// The minimum size is adjusted automatically based on [visualDensity]. + /// + /// When visual density is [VisualDensity.compact], the minimum size is + /// reduced by 8 pixels on both dimensions. + /// + /// When visual density is [VisualDensity.comfortable], the minimum size is + /// [minimumSize] reduced by 4 pixels on both dimensions. + /// + /// When visual density is [VisualDensity.standard], the minimum size is + /// [minimumSize]. + final WidgetStateProperty<Size?>? minimumSize; + + /// The button's size. + /// + /// This size is still constrained by the style's [minimumSize] + /// and [maximumSize]. Fixed size dimensions whose value is + /// [double.infinity] are ignored. + /// + /// The size of the rectangle the button lies within may be larger + /// per [tapTargetSize]. + /// + /// To specify buttons with a fixed width and the default height use + /// `fixedSize: Size.fromWidth(320)`. Similarly, to specify a fixed + /// height and the default width use `fixedSize: Size.fromHeight(100)`. + final WidgetStateProperty<Size?>? fixedSize; + + /// The maximum size of the button itself. + /// + /// A [Size.infinite] or null value for this property means that + /// the button's maximum size is not constrained. + /// + /// This value must be greater than or equal to [minimumSize]. + final WidgetStateProperty<Size?>? maximumSize; + + /// The icon's color inside of the button. + final WidgetStateProperty<Color?>? iconColor; + + /// The icon's size inside of the button. + final WidgetStateProperty<double?>? iconSize; + + /// The alignment of the button's icon. + /// + /// This property is supported for the following button types: + /// + /// * [ElevatedButton.icon]. + /// * [FilledButton.icon]. + /// * [FilledButton.tonalIcon]. + /// * [OutlinedButton.icon]. + /// * [TextButton.icon]. + /// + /// See also: + /// + /// * [IconAlignment], for more information about the different icon + /// alignments. + final IconAlignment? iconAlignment; + + /// The color and weight of the button's outline. + /// + /// This value is combined with [shape] to create a shape decorated + /// with an outline. + final WidgetStateProperty<BorderSide?>? side; + + /// The shape of the button's underlying [Material]. + /// + /// This shape is combined with [side] to create a shape decorated + /// with an outline. + final WidgetStateProperty<OutlinedBorder?>? shape; + + /// The cursor for a mouse pointer when it enters or is hovering over + /// this button's [InkWell]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Defines how compact the button's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets + /// within a [Theme]. + final VisualDensity? visualDensity; + + /// Configures the minimum size of the area within which the button may be pressed. + /// + /// If the [tapTargetSize] is larger than [minimumSize], the button will include + /// a transparent margin that responds to taps. + /// + /// Always defaults to [ThemeData.materialTapTargetSize]. + final MaterialTapTargetSize? tapTargetSize; + + /// Defines the duration of animated changes for [shape] and [elevation]. + /// + /// Typically the component default value is [kThemeChangeDuration]. + final Duration? animationDuration; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// Typically the component default value is true. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// The alignment of the button's child. + /// + /// Typically buttons are sized to be just big enough to contain the child and its + /// padding. If the button's size is constrained to a fixed size, for example by + /// enclosing it with a [SizedBox], this property defines how the child is aligned + /// within the available space. + /// + /// Always defaults to [Alignment.center]. + final AlignmentGeometry? alignment; + + /// Creates the [InkWell] splash factory, which defines the appearance of + /// "ink" splashes that occur in response to taps. + /// + /// Use [NoSplash.splashFactory] to defeat ink splash rendering. For example: + /// ```dart + /// ElevatedButton( + /// style: ElevatedButton.styleFrom( + /// splashFactory: NoSplash.splashFactory, + /// ), + /// onPressed: () { }, + /// child: const Text('No Splash'), + /// ) + /// ``` + final InteractiveInkFeatureFactory? splashFactory; + + /// Creates a widget that becomes the child of the button's [Material] + /// and whose child is the rest of the button, including the button's + /// `child` parameter. + /// + /// The widget created by [backgroundBuilder] is constrained to be + /// the same size as the overall button and will appear behind the + /// button's child. The widget created by [foregroundBuilder] is + /// constrained to be the same size as the button's child, i.e. it's + /// inset by [ButtonStyle.padding] and aligned by the button's + /// [ButtonStyle.alignment]. + /// + /// By default the returned widget is clipped to the Material's [ButtonStyle.shape]. + /// + /// See also: + /// + /// * [foregroundBuilder], to create a widget that's as big as the button's + /// child and is layered behind the child. + /// * [ButtonStyleButton.clipBehavior], for more information about + /// configuring clipping. + final ButtonLayerBuilder? backgroundBuilder; + + /// Creates a Widget that contains the button's child parameter which is used + /// instead of the button's child. + /// + /// The returned widget is clipped by the button's + /// [ButtonStyle.shape], inset by the button's [ButtonStyle.padding] + /// and aligned by the button's [ButtonStyle.alignment]. + /// + /// See also: + /// + /// * [backgroundBuilder], to create a widget that's as big as the button and + /// is layered behind the button's child. + /// * [ButtonStyleButton.clipBehavior], for more information about + /// configuring clipping. + final ButtonLayerBuilder? foregroundBuilder; + + /// Returns a copy of this ButtonStyle with the given fields replaced with + /// the new values. + ButtonStyle copyWith({ + WidgetStateProperty<TextStyle?>? textStyle, + WidgetStateProperty<Color?>? backgroundColor, + WidgetStateProperty<Color?>? foregroundColor, + WidgetStateProperty<Color?>? overlayColor, + WidgetStateProperty<Color?>? shadowColor, + WidgetStateProperty<Color?>? surfaceTintColor, + WidgetStateProperty<double?>? elevation, + WidgetStateProperty<EdgeInsetsGeometry?>? padding, + WidgetStateProperty<Size?>? minimumSize, + WidgetStateProperty<Size?>? fixedSize, + WidgetStateProperty<Size?>? maximumSize, + WidgetStateProperty<Color?>? iconColor, + WidgetStateProperty<double?>? iconSize, + IconAlignment? iconAlignment, + WidgetStateProperty<BorderSide?>? side, + WidgetStateProperty<OutlinedBorder?>? shape, + WidgetStateProperty<MouseCursor?>? mouseCursor, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, + }) { + return ButtonStyle( + textStyle: textStyle ?? this.textStyle, + backgroundColor: backgroundColor ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + overlayColor: overlayColor ?? this.overlayColor, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + elevation: elevation ?? this.elevation, + padding: padding ?? this.padding, + minimumSize: minimumSize ?? this.minimumSize, + fixedSize: fixedSize ?? this.fixedSize, + maximumSize: maximumSize ?? this.maximumSize, + iconColor: iconColor ?? this.iconColor, + iconSize: iconSize ?? this.iconSize, + iconAlignment: iconAlignment ?? this.iconAlignment, + side: side ?? this.side, + shape: shape ?? this.shape, + mouseCursor: mouseCursor ?? this.mouseCursor, + visualDensity: visualDensity ?? this.visualDensity, + tapTargetSize: tapTargetSize ?? this.tapTargetSize, + animationDuration: animationDuration ?? this.animationDuration, + enableFeedback: enableFeedback ?? this.enableFeedback, + alignment: alignment ?? this.alignment, + splashFactory: splashFactory ?? this.splashFactory, + backgroundBuilder: backgroundBuilder ?? this.backgroundBuilder, + foregroundBuilder: foregroundBuilder ?? this.foregroundBuilder, + ); + } + + /// Returns a copy of this ButtonStyle where the non-null fields in [style] + /// have replaced the corresponding null fields in this ButtonStyle. + /// + /// In other words, [style] is used to fill in unspecified (null) fields + /// this ButtonStyle. + ButtonStyle merge(ButtonStyle? style) { + if (style == null) { + return this; + } + return copyWith( + textStyle: textStyle ?? style.textStyle, + backgroundColor: backgroundColor ?? style.backgroundColor, + foregroundColor: foregroundColor ?? style.foregroundColor, + overlayColor: overlayColor ?? style.overlayColor, + shadowColor: shadowColor ?? style.shadowColor, + surfaceTintColor: surfaceTintColor ?? style.surfaceTintColor, + elevation: elevation ?? style.elevation, + padding: padding ?? style.padding, + minimumSize: minimumSize ?? style.minimumSize, + fixedSize: fixedSize ?? style.fixedSize, + maximumSize: maximumSize ?? style.maximumSize, + iconColor: iconColor ?? style.iconColor, + iconSize: iconSize ?? style.iconSize, + iconAlignment: iconAlignment ?? style.iconAlignment, + side: side ?? style.side, + shape: shape ?? style.shape, + mouseCursor: mouseCursor ?? style.mouseCursor, + visualDensity: visualDensity ?? style.visualDensity, + tapTargetSize: tapTargetSize ?? style.tapTargetSize, + animationDuration: animationDuration ?? style.animationDuration, + enableFeedback: enableFeedback ?? style.enableFeedback, + alignment: alignment ?? style.alignment, + splashFactory: splashFactory ?? style.splashFactory, + backgroundBuilder: backgroundBuilder ?? style.backgroundBuilder, + foregroundBuilder: foregroundBuilder ?? style.foregroundBuilder, + ); + } + + @override + int get hashCode { + final values = <Object?>[ + textStyle, + backgroundColor, + foregroundColor, + overlayColor, + shadowColor, + surfaceTintColor, + elevation, + padding, + minimumSize, + fixedSize, + maximumSize, + iconColor, + iconSize, + iconAlignment, + side, + shape, + mouseCursor, + visualDensity, + tapTargetSize, + animationDuration, + enableFeedback, + alignment, + splashFactory, + backgroundBuilder, + foregroundBuilder, + ]; + return Object.hashAll(values); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ButtonStyle && + other.textStyle == textStyle && + other.backgroundColor == backgroundColor && + other.foregroundColor == foregroundColor && + other.overlayColor == overlayColor && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.elevation == elevation && + other.padding == padding && + other.minimumSize == minimumSize && + other.fixedSize == fixedSize && + other.maximumSize == maximumSize && + other.iconColor == iconColor && + other.iconSize == iconSize && + other.iconAlignment == iconAlignment && + other.side == side && + other.shape == shape && + other.mouseCursor == mouseCursor && + other.visualDensity == visualDensity && + other.tapTargetSize == tapTargetSize && + other.animationDuration == animationDuration && + other.enableFeedback == enableFeedback && + other.alignment == alignment && + other.splashFactory == splashFactory && + other.backgroundBuilder == backgroundBuilder && + other.foregroundBuilder == foregroundBuilder; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'textStyle', + textStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'backgroundColor', + backgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'foregroundColor', + foregroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'shadowColor', + shadowColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'surfaceTintColor', + surfaceTintColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>('elevation', elevation, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<EdgeInsetsGeometry?>>( + 'padding', + padding, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>( + 'minimumSize', + minimumSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>('fixedSize', fixedSize, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>( + 'maximumSize', + maximumSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>('iconColor', iconColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>('iconSize', iconSize, defaultValue: null), + ); + properties.add(EnumProperty<IconAlignment>('iconAlignment', iconAlignment, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<BorderSide?>>('side', side, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<OutlinedBorder?>>('shape', shape, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add( + EnumProperty<MaterialTapTargetSize>('tapTargetSize', tapTargetSize, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<Duration>('animationDuration', animationDuration, defaultValue: null), + ); + properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null)); + properties.add( + DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ButtonLayerBuilder>( + 'backgroundBuilder', + backgroundBuilder, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<ButtonLayerBuilder>( + 'foregroundBuilder', + foregroundBuilder, + defaultValue: null, + ), + ); + } + + /// Linearly interpolate between two [ButtonStyle]s. + static ButtonStyle? lerp(ButtonStyle? a, ButtonStyle? b, double t) { + if (identical(a, b)) { + return a; + } + return ButtonStyle( + textStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.textStyle, + b?.textStyle, + t, + TextStyle.lerp, + ), + backgroundColor: WidgetStateProperty.lerp<Color?>( + a?.backgroundColor, + b?.backgroundColor, + t, + Color.lerp, + ), + foregroundColor: WidgetStateProperty.lerp<Color?>( + a?.foregroundColor, + b?.foregroundColor, + t, + Color.lerp, + ), + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + shadowColor: WidgetStateProperty.lerp<Color?>(a?.shadowColor, b?.shadowColor, t, Color.lerp), + surfaceTintColor: WidgetStateProperty.lerp<Color?>( + a?.surfaceTintColor, + b?.surfaceTintColor, + t, + Color.lerp, + ), + elevation: WidgetStateProperty.lerp<double?>(a?.elevation, b?.elevation, t, lerpDouble), + padding: WidgetStateProperty.lerp<EdgeInsetsGeometry?>( + a?.padding, + b?.padding, + t, + EdgeInsetsGeometry.lerp, + ), + minimumSize: WidgetStateProperty.lerp<Size?>(a?.minimumSize, b?.minimumSize, t, Size.lerp), + fixedSize: WidgetStateProperty.lerp<Size?>(a?.fixedSize, b?.fixedSize, t, Size.lerp), + maximumSize: WidgetStateProperty.lerp<Size?>(a?.maximumSize, b?.maximumSize, t, Size.lerp), + iconColor: WidgetStateProperty.lerp<Color?>(a?.iconColor, b?.iconColor, t, Color.lerp), + iconSize: WidgetStateProperty.lerp<double?>(a?.iconSize, b?.iconSize, t, lerpDouble), + iconAlignment: t < 0.5 ? a?.iconAlignment : b?.iconAlignment, + side: WidgetStateBorderSide.lerp(a?.side, b?.side, t), + shape: WidgetStateProperty.lerp<OutlinedBorder?>(a?.shape, b?.shape, t, OutlinedBorder.lerp), + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity, + tapTargetSize: t < 0.5 ? a?.tapTargetSize : b?.tapTargetSize, + animationDuration: t < 0.5 ? a?.animationDuration : b?.animationDuration, + enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback, + alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), + splashFactory: t < 0.5 ? a?.splashFactory : b?.splashFactory, + backgroundBuilder: t < 0.5 ? a?.backgroundBuilder : b?.backgroundBuilder, + foregroundBuilder: t < 0.5 ? a?.foregroundBuilder : b?.foregroundBuilder, + ); + } +} diff --git a/packages/material_ui/lib/src/button_style_button.dart b/packages/material_ui/lib/src/button_style_button.dart new file mode 100644 index 000000000000..71be80bcc85c --- /dev/null +++ b/packages/material_ui/lib/src/button_style_button.dart @@ -0,0 +1,750 @@ +// 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. + +/// @docImport 'elevated_button_theme.dart'; +/// @docImport 'menu_anchor.dart'; +/// @docImport 'text_button_theme.dart'; +/// @docImport 'text_theme.dart'; +/// @docImport 'theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'elevated_button.dart'; +import 'filled_button.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'outlined_button.dart'; +import 'text_button.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'tooltip.dart'; + +/// {@template flutter.material.ButtonStyle.iconAlignment} +/// Determines the alignment of the icon within the widgets such as: +/// - [ElevatedButton.icon], +/// - [FilledButton.icon], +/// - [FilledButton.tonalIcon]. +/// - [OutlinedButton.icon], +/// - [TextButton.icon], +/// +/// The effect of `iconAlignment` depends on [TextDirection]. If textDirection is +/// [TextDirection.ltr] then [IconAlignment.start] and [IconAlignment.end] align the +/// icon on the left or right respectively. If textDirection is [TextDirection.rtl] the +/// the alignments are reversed. +/// +/// Defaults to [IconAlignment.start]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use `iconAlignment` to align the button icon to the start +/// or the end of the button. +/// +/// ** See code in examples/api/lib/material/icon_alignment/icon_alignment.0.dart ** +/// {@end-tool} +/// +/// {@endtemplate} +enum IconAlignment { + /// The icon is placed at the start of the button. + start, + + /// The icon is placed at the end of the button. + end, +} + +/// The base [StatefulWidget] class for buttons whose style is defined by a [ButtonStyle] object. +/// +/// Concrete subclasses must override [defaultStyleOf] and [themeStyleOf]. +/// +/// See also: +/// * [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. +/// * <https://m3.material.io/components/buttons/overview>, 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. + const ButtonStyleButton({ + super.key, + required this.onPressed, + required this.onLongPress, + required this.onHover, + required this.onFocusChange, + required this.style, + required this.focusNode, + required this.autofocus, + required this.clipBehavior, + this.statesController, + this.isSemanticButton = true, + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'Use ButtonStyle.iconAlignment instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + this.iconAlignment, + this.tooltip, + required this.child, + }); + + /// Called when the button is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onPressed; + + /// Called when the button is long-pressed. + /// + /// If this callback and [onPressed] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onLongPress; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the material and false if a pointer has exited this part of the + /// material. + final ValueChanged<bool>? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged<bool>? onFocusChange; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding + /// properties in [themeStyleOf] and [defaultStyleOf]. [WidgetStateProperty]s + /// that resolve to non-null values will similarly override the corresponding + /// [WidgetStateProperty]s in [themeStyleOf] and [defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none] unless [ButtonStyle.backgroundBuilder] or + /// [ButtonStyle.foregroundBuilder] is specified. In those + /// cases the default is [Clip.antiAlias]. + final Clip? clipBehavior; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// Determine whether this subtree represents a button. + /// + /// If this is null, the screen reader will not announce "button" when this + /// is focused. This is useful for [MenuItemButton] and [SubmenuButton] when we + /// traverse the menu system. + /// + /// Defaults to true. + final bool? isSemanticButton; + + /// {@macro flutter.material.ButtonStyle.iconAlignment} + @Deprecated( + 'Remove this parameter as it is now ignored. ' + 'Use ButtonStyle.iconAlignment instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + final IconAlignment? iconAlignment; + + /// Text that describes the action that will occur when the button is pressed or + /// hovered over. + /// + /// This text is displayed when the user long-presses or hovers over the button + /// in a tooltip. This string is also used for accessibility. + /// + /// If null, the button will not display a tooltip. + final String? tooltip; + + /// Typically the button's label. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Returns a [ButtonStyle] that's based primarily on the [Theme]'s + /// [ThemeData.textTheme] and [ThemeData.colorScheme], but has most values + /// filled out (non-null). + /// + /// The returned style can be overridden by the [style] parameter and by the + /// style returned by [themeStyleOf] that some button-specific themes like + /// [TextButtonTheme] or [ElevatedButtonTheme] override. For example the + /// default style of the [TextButton] subclass can be overridden with its + /// [TextButton.style] constructor parameter, or with a [TextButtonTheme]. + /// + /// Concrete button subclasses should return a [ButtonStyle] with as many + /// non-null properties as possible, where all of the non-null + /// [WidgetStateProperty] properties resolve to non-null values. + /// + /// ## Properties that can be null + /// + /// Some properties, like [ButtonStyle.fixedSize] would override other values + /// in the same [ButtonStyle] if set, so they are allowed to be null. Here is + /// a summary of properties that are allowed to be null when returned in the + /// [ButtonStyle] returned by this function, an why: + /// + /// - [ButtonStyle.fixedSize] because it would override other values in the + /// same [ButtonStyle], like [ButtonStyle.maximumSize]. + /// - [ButtonStyle.side] because null is a valid value for a button that has + /// no side. [OutlinedButton] returns a non-null default for this, however. + /// - [ButtonStyle.backgroundBuilder] and [ButtonStyle.foregroundBuilder] + /// because they would override the [ButtonStyle.foregroundColor] and + /// [ButtonStyle.backgroundColor] of the same [ButtonStyle]. + /// + /// See also: + /// + /// * [themeStyleOf], returns the ButtonStyle of this button's component + /// theme. + @protected + ButtonStyle defaultStyleOf(BuildContext context); + + /// Returns the ButtonStyle that belongs to the button's component theme. + /// + /// The returned style can be overridden by the [style] parameter. + /// + /// Concrete button subclasses should return the ButtonStyle for the + /// nearest subclass-specific inherited theme, and if no such theme + /// exists, then the same value from the overall [Theme]. + /// + /// See also: + /// + /// * [defaultStyleOf], Returns the default [ButtonStyle] for this button. + @protected + ButtonStyle? themeStyleOf(BuildContext context); + + /// Whether the button is enabled or disabled. + /// + /// Buttons are disabled by default. To enable a button, set its [onPressed] + /// or [onLongPress] properties to a non-null value. + bool get enabled => onPressed != null || onLongPress != null; + + @override + State<ButtonStyleButton> createState() => _ButtonStyleState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'disabled')); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + } + + /// Returns null if [value] is null, otherwise `WidgetStatePropertyAll<T>(value)`. + /// + /// A convenience method for subclasses. + static WidgetStateProperty<T>? allOrNull<T>(T? value) => + value == null ? null : MaterialStatePropertyAll<T>(value); + + /// Returns null if [enabled] and [disabled] are null. + /// Otherwise, returns a [WidgetStateProperty] that resolves to [disabled] + /// when [WidgetState.disabled] is active, and [enabled] otherwise. + /// + /// A convenience method for subclasses. + static WidgetStateProperty<Color?>? defaultColor(Color? enabled, Color? disabled) { + if ((enabled ?? disabled) == null) { + return null; + } + return WidgetStateProperty<Color?>.fromMap(<WidgetStatesConstraint, Color?>{ + WidgetState.disabled: disabled, + WidgetState.any: enabled, + }); + } + + /// A convenience method used by subclasses in the framework, that returns an + /// interpolated value based on the [fontSizeMultiplier] parameter: + /// + /// * 0 - 1 [geometry1x] + /// * 1 - 2 lerp([geometry1x], [geometry2x], [fontSizeMultiplier] - 1) + /// * 2 - 3 lerp([geometry2x], [geometry3x], [fontSizeMultiplier] - 2) + /// * otherwise [geometry3x] + /// + /// This method is used by the framework for estimating the default paddings to + /// use on a button with a text label, when the system text scaling setting + /// changes. It's usually supplied with empirical [geometry1x], [geometry2x], + /// [geometry3x] values adjusted for different system text scaling values, when + /// the unscaled font size is set to 14.0 (the default [TextTheme.labelLarge] + /// value). + /// + /// The `fontSizeMultiplier` argument, for historical reasons, is the default + /// font size specified in the [ButtonStyle], scaled by the ambient font + /// scaler, then divided by 14.0 (the default font size used in buttons). + static EdgeInsetsGeometry scaledPadding( + EdgeInsetsGeometry geometry1x, + EdgeInsetsGeometry geometry2x, + EdgeInsetsGeometry geometry3x, + double fontSizeMultiplier, + ) { + return switch (fontSizeMultiplier) { + <= 1 => geometry1x, + < 2 => EdgeInsetsGeometry.lerp(geometry1x, geometry2x, fontSizeMultiplier - 1)!, + < 3 => EdgeInsetsGeometry.lerp(geometry2x, geometry3x, fontSizeMultiplier - 2)!, + _ => geometry3x, + }; + } +} + +/// The base [State] class for buttons whose style is defined by a [ButtonStyle] object. +/// +/// See also: +/// +/// * [ButtonStyleButton], the [StatefulWidget] subclass for which this class is the [State]. +/// * [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<ButtonStyleButton> with TickerProviderStateMixin { + AnimationController? controller; + double? elevation; + Color? backgroundColor; + MaterialStatesController? internalStatesController; + + void handleStatesControllerChange() { + // Force a rebuild to resolve WidgetStateProperty properties + setState(() {}); + } + + MaterialStatesController get statesController => + widget.statesController ?? internalStatesController!; + + void initStatesController() { + if (widget.statesController == null) { + internalStatesController = MaterialStatesController(); + } + statesController.update(WidgetState.disabled, !widget.enabled); + statesController.addListener(handleStatesControllerChange); + } + + @override + void initState() { + super.initState(); + initStatesController(); + } + + @override + void didUpdateWidget(ButtonStyleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.statesController != oldWidget.statesController) { + oldWidget.statesController?.removeListener(handleStatesControllerChange); + if (widget.statesController != null) { + internalStatesController?.dispose(); + internalStatesController = null; + } + initStatesController(); + } + if (widget.enabled != oldWidget.enabled) { + statesController.update(WidgetState.disabled, !widget.enabled); + if (!widget.enabled) { + // The button may have been disabled while a press gesture is currently underway. + statesController.update(WidgetState.pressed, false); + } + } + } + + @override + void dispose() { + statesController.removeListener(handleStatesControllerChange); + internalStatesController?.dispose(); + controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final IconThemeData iconTheme = IconTheme.of(context); + final ButtonStyle? widgetStyle = widget.style; + final ButtonStyle? themeStyle = widget.themeStyleOf(context); + final ButtonStyle defaultStyle = widget.defaultStyleOf(context); + + T? effectiveValue<T>(T? Function(ButtonStyle? style) getProperty) { + final T? widgetValue = getProperty(widgetStyle); + final T? themeValue = getProperty(themeStyle); + final T? defaultValue = getProperty(defaultStyle); + return widgetValue ?? themeValue ?? defaultValue; + } + + T? resolve<T>(WidgetStateProperty<T>? Function(ButtonStyle? style) getProperty) { + return effectiveValue((ButtonStyle? style) { + return getProperty(style)?.resolve(statesController.value); + }); + } + + Color? effectiveIconColor() { + return widgetStyle?.iconColor?.resolve(statesController.value) ?? + themeStyle?.iconColor?.resolve(statesController.value) ?? + widgetStyle?.foregroundColor?.resolve(statesController.value) ?? + themeStyle?.foregroundColor?.resolve(statesController.value) ?? + defaultStyle.iconColor?.resolve(statesController.value) ?? + // Fallback to foregroundColor if iconColor is null. + defaultStyle.foregroundColor?.resolve(statesController.value); + } + + final double? resolvedElevation = resolve<double?>((ButtonStyle? style) => style?.elevation); + final TextStyle? resolvedTextStyle = resolve<TextStyle?>( + (ButtonStyle? style) => style?.textStyle, + ); + Color? resolvedBackgroundColor = resolve<Color?>( + (ButtonStyle? style) => style?.backgroundColor, + ); + final Color? resolvedForegroundColor = resolve<Color?>( + (ButtonStyle? style) => style?.foregroundColor, + ); + final Color? resolvedShadowColor = resolve<Color?>((ButtonStyle? style) => style?.shadowColor); + final Color? resolvedSurfaceTintColor = resolve<Color?>( + (ButtonStyle? style) => style?.surfaceTintColor, + ); + final EdgeInsetsGeometry? resolvedPadding = resolve<EdgeInsetsGeometry?>( + (ButtonStyle? style) => style?.padding, + ); + final Size? resolvedMinimumSize = resolve<Size?>((ButtonStyle? style) => style?.minimumSize); + final Size? resolvedFixedSize = resolve<Size?>((ButtonStyle? style) => style?.fixedSize); + final Size? resolvedMaximumSize = resolve<Size?>((ButtonStyle? style) => style?.maximumSize); + final Color? resolvedIconColor = effectiveIconColor(); + final double? resolvedIconSize = resolve<double?>((ButtonStyle? style) => style?.iconSize); + final BorderSide? resolvedSide = resolve<BorderSide?>((ButtonStyle? style) => style?.side); + final OutlinedBorder? resolvedShape = resolve<OutlinedBorder?>( + (ButtonStyle? style) => style?.shape, + ); + + final WidgetStateMouseCursor mouseCursor = _MouseCursor( + (Set<WidgetState> states) => + effectiveValue((ButtonStyle? style) => style?.mouseCursor?.resolve(states)), + ); + + final WidgetStateProperty<Color?> overlayColor = WidgetStateProperty.resolveWith<Color?>( + (Set<WidgetState> states) => + effectiveValue((ButtonStyle? style) => style?.overlayColor?.resolve(states)), + ); + + final VisualDensity? resolvedVisualDensity = effectiveValue( + (ButtonStyle? style) => style?.visualDensity, + ); + final MaterialTapTargetSize? resolvedTapTargetSize = effectiveValue( + (ButtonStyle? style) => style?.tapTargetSize, + ); + final Duration? resolvedAnimationDuration = effectiveValue( + (ButtonStyle? style) => style?.animationDuration, + ); + final bool resolvedEnableFeedback = + effectiveValue((ButtonStyle? style) => style?.enableFeedback) ?? true; + final AlignmentGeometry? resolvedAlignment = effectiveValue( + (ButtonStyle? style) => style?.alignment, + ); + final Offset densityAdjustment = resolvedVisualDensity!.baseSizeAdjustment; + final InteractiveInkFeatureFactory? resolvedSplashFactory = effectiveValue( + (ButtonStyle? style) => style?.splashFactory, + ); + final ButtonLayerBuilder? resolvedBackgroundBuilder = effectiveValue( + (ButtonStyle? style) => style?.backgroundBuilder, + ); + final ButtonLayerBuilder? resolvedForegroundBuilder = effectiveValue( + (ButtonStyle? style) => style?.foregroundBuilder, + ); + + final Clip effectiveClipBehavior = + widget.clipBehavior ?? + ((resolvedBackgroundBuilder ?? resolvedForegroundBuilder) != null + ? Clip.antiAlias + : Clip.none); + + BoxConstraints effectiveConstraints = resolvedVisualDensity.effectiveConstraints( + BoxConstraints( + minWidth: resolvedMinimumSize!.width, + minHeight: resolvedMinimumSize.height, + maxWidth: resolvedMaximumSize!.width, + maxHeight: resolvedMaximumSize.height, + ), + ); + if (resolvedFixedSize != null) { + final Size size = effectiveConstraints.constrain(resolvedFixedSize); + if (size.width.isFinite) { + effectiveConstraints = effectiveConstraints.copyWith( + minWidth: size.width, + maxWidth: size.width, + ); + } + if (size.height.isFinite) { + effectiveConstraints = effectiveConstraints.copyWith( + minHeight: size.height, + maxHeight: size.height, + ); + } + } + + // Per the Material Design team: don't allow the VisualDensity + // adjustment to reduce the width of the left/right padding. If we + // did, VisualDensity.compact, the default for desktop/web, would + // reduce the horizontal padding to zero. + final double dy = densityAdjustment.dy; + final double dx = math.max(0, densityAdjustment.dx); + final EdgeInsetsGeometry padding = resolvedPadding! + .add(EdgeInsets.fromLTRB(dx, dy, dx, dy)) + .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + + // If an opaque button's background is becoming translucent while its + // elevation is changing, change the elevation first. Material implicitly + // animates its elevation but not its color. SKIA renders non-zero + // elevations as a shadow colored fill behind the Material's background. + if (resolvedAnimationDuration! > Duration.zero && + elevation != null && + backgroundColor != null && + elevation != resolvedElevation && + backgroundColor!.value != resolvedBackgroundColor!.value && + backgroundColor!.opacity == 1 && + resolvedBackgroundColor.opacity < 1 && + resolvedElevation == 0) { + if (controller?.duration != resolvedAnimationDuration) { + controller?.dispose(); + controller = AnimationController(duration: resolvedAnimationDuration, vsync: this) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + setState(() {}); // Rebuild with the final background color. + } + }); + } + resolvedBackgroundColor = backgroundColor; // Defer changing the background color. + controller!.value = 0; + controller!.forward(); + } + elevation = resolvedElevation; + backgroundColor = resolvedBackgroundColor; + + Widget result = Padding( + padding: padding, + child: Align( + alignment: resolvedAlignment!, + widthFactor: 1.0, + heightFactor: 1.0, + child: resolvedForegroundBuilder != null + ? resolvedForegroundBuilder(context, statesController.value, widget.child) + : widget.child, + ), + ); + if (resolvedBackgroundBuilder != null) { + result = resolvedBackgroundBuilder(context, statesController.value, result); + } + + result = AnimatedTheme( + duration: resolvedAnimationDuration, + data: theme.copyWith( + iconTheme: iconTheme.merge(IconThemeData(color: resolvedIconColor, size: resolvedIconSize)), + ), + child: InkWell( + onTap: widget.onPressed, + onLongPress: widget.onLongPress, + onHover: widget.onHover, + mouseCursor: mouseCursor, + enableFeedback: resolvedEnableFeedback, + focusNode: widget.focusNode, + canRequestFocus: widget.enabled, + onFocusChange: widget.onFocusChange, + autofocus: widget.autofocus, + splashFactory: resolvedSplashFactory, + overlayColor: overlayColor, + highlightColor: Colors.transparent, + customBorder: resolvedShape!.copyWith(side: resolvedSide), + statesController: statesController, + child: result, + ), + ); + + if (widget.tooltip != null) { + result = Tooltip(message: widget.tooltip, child: result); + } + + final Size minSize; + switch (resolvedTapTargetSize!) { + case MaterialTapTargetSize.padded: + minSize = Size( + kMinInteractiveDimension + densityAdjustment.dx, + kMinInteractiveDimension + densityAdjustment.dy, + ); + assert(minSize.width >= 0.0); + assert(minSize.height >= 0.0); + case MaterialTapTargetSize.shrinkWrap: + minSize = Size.zero; + } + + return Semantics( + container: true, + button: widget.isSemanticButton, + enabled: widget.enabled, + child: _InputPadding( + minSize: minSize, + child: ConstrainedBox( + constraints: effectiveConstraints, + child: Material( + elevation: resolvedElevation!, + textStyle: resolvedTextStyle?.copyWith(color: resolvedForegroundColor), + shape: resolvedShape.copyWith(side: resolvedSide), + color: resolvedBackgroundColor, + shadowColor: resolvedShadowColor, + surfaceTintColor: resolvedSurfaceTintColor, + type: resolvedBackgroundColor == null ? MaterialType.transparency : MaterialType.button, + animationDuration: resolvedAnimationDuration, + clipBehavior: effectiveClipBehavior, + borderOnForeground: false, + child: result, + ), + ), + ), + ); + } +} + +class _MouseCursor extends WidgetStateMouseCursor { + const _MouseCursor(this.resolveCallback); + + final WidgetPropertyResolver<MouseCursor?> resolveCallback; + + @override + MouseCursor resolve(Set<WidgetState> states) => resolveCallback(states)!; + + @override + String get debugDescription => 'ButtonStyleButton_MouseCursor'; +} + +/// A widget to pad the area around a [ButtonStyleButton]'s inner [Material]. +/// +/// Redirect taps that occur in the padded area around the child to the center +/// of the child. This increases the size of the button and the button's +/// "tap target", but not its material or its ink splashes. +class _InputPadding extends SingleChildRenderObjectWidget { + const _InputPadding({super.child, required this.minSize}); + + final Size minSize; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, [RenderBox? child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double width = math.max(childSize.width, minSize.width); + final double height = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(width, height)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.dryLayoutChild); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final double? result = child.getDryBaseline(constraints, baseline); + if (result == null) { + return null; + } + final Size childSize = child.getDryLayout(constraints); + return result + + Alignment.center.alongOffset(getDryLayout(constraints) - childSize as Offset).dy; + } + + @override + void performLayout() { + size = _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.layoutChild); + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (super.hitTest(result, position: position)) { + return true; + } + final Offset center = child!.size.center(Offset.zero); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return child!.hitTest(result, position: center); + }, + ); + } +} diff --git a/packages/material_ui/lib/src/button_theme.dart b/packages/material_ui/lib/src/button_theme.dart new file mode 100644 index 000000000000..3ceac62cc349 --- /dev/null +++ b/packages/material_ui/lib/src/button_theme.dart @@ -0,0 +1,799 @@ +// 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. + +/// @docImport 'button.dart'; +/// @docImport 'button_bar.dart'; +/// @docImport 'dropdown.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'elevated_button_theme.dart'; +/// @docImport 'filled_button.dart'; +/// @docImport 'filled_button_theme.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'outlined_button_theme.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_button_theme.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'material_button.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Used with [ButtonTheme] and [ButtonThemeData] to define a button's base +/// colors, and the defaults for the button's minimum size, internal padding, +/// and shape. +enum ButtonTextTheme { + /// Button text is black or white depending on [ThemeData.brightness]. + normal, + + /// Button text is [ColorScheme.secondary]. + accent, + + /// Button text is based on [ThemeData.primaryColor]. + primary, +} + +/// Used with [ButtonTheme] and [ButtonThemeData] to define how the button bar +/// should size itself with either constraints or internal padding. +enum ButtonBarLayoutBehavior { + /// Button bars will be constrained to a minimum height of 52. + /// + /// This setting is require to create button bars which conform to the + /// Material Design specification. + constrained, + + /// Button bars will calculate their padding from the button theme padding. + padded, +} + +/// Used with [ButtonThemeData] to configure the color and geometry of buttons. +/// +/// This class is planned to be deprecated in a future release. +/// Please use one or more of these buttons and associated themes instead: +/// +/// * [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 +/// can be overridden with [ButtonTheme]. +/// +/// The actual appearance of buttons depends on the button theme, the +/// button's enabled state, its elevation (if any), and the overall [Theme]. +/// +/// See also: +/// +/// * [RawMaterialButton], which can be used to configure a button that doesn't +/// depend on any inherited themes. +class ButtonTheme extends InheritedTheme { + /// Creates a button theme. + ButtonTheme({ + super.key, + ButtonTextTheme textTheme = ButtonTextTheme.normal, + ButtonBarLayoutBehavior layoutBehavior = ButtonBarLayoutBehavior.padded, + double minWidth = 88.0, + double height = 36.0, + EdgeInsetsGeometry? padding, + ShapeBorder? shape, + bool alignedDropdown = false, + Color? buttonColor, + Color? disabledColor, + Color? focusColor, + Color? hoverColor, + Color? highlightColor, + Color? splashColor, + ColorScheme? colorScheme, + MaterialTapTargetSize? materialTapTargetSize, + required super.child, + }) : assert(minWidth >= 0.0), + assert(height >= 0.0), + data = ButtonThemeData( + textTheme: textTheme, + minWidth: minWidth, + height: height, + padding: padding, + shape: shape, + alignedDropdown: alignedDropdown, + layoutBehavior: layoutBehavior, + buttonColor: buttonColor, + disabledColor: disabledColor, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + splashColor: splashColor, + colorScheme: colorScheme, + materialTapTargetSize: materialTapTargetSize, + ); + + /// Creates a button theme from [data]. + const ButtonTheme.fromButtonThemeData({super.key, required this.data, required super.child}); + + /// Specifies the color and geometry of buttons. + final ButtonThemeData data; + + /// Retrieves the [ButtonThemeData] from the closest ancestor [ButtonTheme] + /// widget. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ButtonThemeData theme = ButtonTheme.of(context); + /// ``` + static ButtonThemeData of(BuildContext context) { + final ButtonTheme? inheritedButtonTheme = context + .dependOnInheritedWidgetOfExactType<ButtonTheme>(); + ButtonThemeData? buttonTheme = inheritedButtonTheme?.data; + if (buttonTheme?.colorScheme == null) { + // if buttonTheme or buttonTheme.colorScheme is null + final ThemeData theme = Theme.of(context); + buttonTheme ??= theme.buttonTheme; + if (buttonTheme.colorScheme == null) { + buttonTheme = buttonTheme.copyWith( + colorScheme: theme.buttonTheme.colorScheme ?? theme.colorScheme, + ); + assert(buttonTheme.colorScheme != null); + } + } + return buttonTheme!; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ButtonTheme.fromButtonThemeData(data: data, child: child); + } + + @override + bool updateShouldNotify(ButtonTheme oldWidget) => data != oldWidget.data; +} + +/// Used with [ButtonTheme] to configure the color and geometry of buttons. +/// +/// 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], +/// * [OutlinedButton], [OutlinedButtonTheme], [OutlinedButtonThemeData] +/// +/// A button theme can be specified as part of the overall Material theme +/// using [ThemeData.buttonTheme]. The Material theme's button theme data +/// can be overridden with [ButtonTheme]. +@immutable +class ButtonThemeData with Diagnosticable { + /// Create a button theme object that can be used with [ButtonTheme] + /// or [ThemeData]. + /// + /// The [minWidth] and [height] parameters must greater than or equal to zero. + /// + /// The ButtonTheme's methods that have a [MaterialButton] parameter and + /// have a name with a `get` prefix are used to configure a + /// [RawMaterialButton]. + const ButtonThemeData({ + this.textTheme = ButtonTextTheme.normal, + this.minWidth = 88.0, + this.height = 36.0, + EdgeInsetsGeometry? padding, + ShapeBorder? shape, + this.layoutBehavior = ButtonBarLayoutBehavior.padded, + this.alignedDropdown = false, + Color? buttonColor, + Color? disabledColor, + Color? focusColor, + Color? hoverColor, + Color? highlightColor, + Color? splashColor, + this.colorScheme, + MaterialTapTargetSize? materialTapTargetSize, + }) : assert(minWidth >= 0.0), + assert(height >= 0.0), + _buttonColor = buttonColor, + _disabledColor = disabledColor, + _focusColor = focusColor, + _hoverColor = hoverColor, + _highlightColor = highlightColor, + _splashColor = splashColor, + _padding = padding, + _shape = shape, + _materialTapTargetSize = materialTapTargetSize; + + /// The minimum width for buttons. + /// + /// The actual horizontal space allocated for a button's child is + /// at least this value less the theme's horizontal [padding]. + /// + /// Defaults to 88.0 logical pixels. + final double minWidth; + + /// The minimum height for buttons. + /// + /// Defaults to 36.0 logical pixels. + final double height; + + /// Defines a button's base colors, and the defaults for the button's minimum + /// size, internal padding, and shape. + /// + /// Despite the name, this property is not a [TextTheme], its value is not a + /// collection of [TextStyle]s. + final ButtonTextTheme textTheme; + + /// Defines whether a [ButtonBar] should size itself with a minimum size + /// constraint or with padding. + /// + /// Defaults to [ButtonBarLayoutBehavior.padded]. + final ButtonBarLayoutBehavior layoutBehavior; + + /// Convenience that returns [minWidth] and [height] as a + /// [BoxConstraints] object. + BoxConstraints get constraints { + return BoxConstraints(minWidth: minWidth, minHeight: height); + } + + /// Padding for a button's child (typically the button's label). + /// + /// Defaults to 24.0 on the left and right if [textTheme] is + /// [ButtonTextTheme.primary], 16.0 on the left and right otherwise. + /// + /// See also: + /// + /// * [getPadding], which is used to calculate padding for the button's + /// child (typically the button's label). + EdgeInsetsGeometry get padding => + _padding ?? + switch (textTheme) { + ButtonTextTheme.normal => const EdgeInsets.symmetric(horizontal: 16.0), + ButtonTextTheme.accent => const EdgeInsets.symmetric(horizontal: 16.0), + ButtonTextTheme.primary => const EdgeInsets.symmetric(horizontal: 24.0), + }; + final EdgeInsetsGeometry? _padding; + + /// The shape of a button's material. + /// + /// The button's highlight and splash are clipped to this shape. If the + /// button has an elevation, then its drop shadow is defined by this + /// shape as well. + /// + /// Defaults to a rounded rectangle with circular corner radii of 4.0 if + /// [textTheme] is [ButtonTextTheme.primary], a rounded rectangle with + /// circular corner radii of 2.0 otherwise. + /// + /// See also: + /// + /// * [getShape], which is used to calculate the shape of the button's + /// [Material]. + ShapeBorder get shape => + _shape ?? + switch (textTheme) { + ButtonTextTheme.normal || ButtonTextTheme.accent => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(2.0)), + ), + ButtonTextTheme.primary => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ), + }; + final ShapeBorder? _shape; + + /// If true, then a [DropdownButton] menu's width will match the button's + /// width. + /// + /// If false (the default), then the dropdown's menu will be wider than + /// its button. In either case the dropdown button will line up the leading + /// edge of the menu's value with the leading edge of the values + /// displayed by the menu items. + /// + /// This property only affects [DropdownButton] and its menu. + final bool alignedDropdown; + + /// The background fill color. + /// + /// This property is null by default. + /// + /// If the button is in the focused, hovering, or highlighted state, then the + /// `focusColor`, `hoverColor`, or `highlightColor` will take precedence over + /// the `buttonColor`. + /// + /// See also: + /// + /// * [getFillColor], which is used to compute the background fill color. + final Color? _buttonColor; + + /// The background fill color when disabled. + /// + /// This property is null by default. + /// + /// See also: + /// + /// * [getDisabledFillColor], which is to compute background fill color for + /// disabled state. + final Color? _disabledColor; + + /// The fill color of the button when it has the input focus. + /// + /// This property is null by default. + /// + /// If the button is in the hovering or highlighted state, then the `hoverColor` + /// or `highlightColor` will take precedence over the `focusColor`. + /// + /// See also: + /// + /// * [getFocusColor], which is used to compute the fill color of the button + /// when it has input focus. + final Color? _focusColor; + + /// The fill color of the button when a pointer is hovering over it. + /// + /// This property is null by default. + /// + /// If the button is in the highlighted state, then the `highlightColor` will + /// take precedence over the `hoverColor`. + /// + /// See also: + /// + /// * [getHoverColor], which is used to compute the fill color of the button + /// when it has input focus. + final Color? _hoverColor; + + /// The color of the overlay that appears when a button is pressed. + /// + /// This property is null by default. + /// + /// See also: + /// + /// * [getHighlightColor], which is used to compute the color of the overlay + /// that appears when the `button` is pressed. + final Color? _highlightColor; + + /// The color of the ink "splash" overlay that appears when a button is tapped. + /// + /// This property is null by default. + /// + /// See also: + /// + /// * [getSplashColor], which is used to compute the color of the ink + /// "splash" overlay that appears when the (enabled) `button` is tapped. + final Color? _splashColor; + + /// A set of thirteen colors that can be used to derive the button theme's + /// colors. + /// + /// This property was added much later than the theme's set of highly specific + /// colors, like [ThemeData.highlightColor] and [ThemeData.splashColor] etc. + /// + /// The colors for new button classes can be defined exclusively in terms of + /// [colorScheme]. When it's possible, the existing buttons will (continue to) + /// gradually migrate to it. + final ColorScheme? colorScheme; + + // The minimum size of a button's tap target. + // + // This property is null by default. + final MaterialTapTargetSize? _materialTapTargetSize; + + /// The [button]'s overall brightness. + /// + /// Returns the button's [MaterialButton.colorBrightness] if it is non-null, + /// otherwise the color scheme's [ColorScheme.brightness] is returned. + Brightness getBrightness(MaterialButton button) { + return button.colorBrightness ?? colorScheme!.brightness; + } + + /// Defines the [button]'s base colors, and the defaults for the button's + /// minimum size, internal padding, and shape. + /// + /// Despite the name, this property is not the [TextTheme] whose + /// [TextTheme.labelLarge] is used as the button text's [TextStyle]. + ButtonTextTheme getTextTheme(MaterialButton button) => button.textTheme ?? textTheme; + + /// The foreground color of the [button]'s text and icon when + /// [MaterialButton.onPressed] is null (when MaterialButton.enabled is false). + /// + /// Returns the button's [MaterialButton.disabledColor] if it is non-null. + /// Otherwise the color scheme's [ColorScheme.onSurface] color is returned + /// with its opacity set to 0.38. + /// + /// If [MaterialButton.textColor] is a [WidgetStateProperty<Color>], it will be + /// used as the `disabledTextColor`. It will be resolved in the [WidgetState.disabled] state. + Color getDisabledTextColor(MaterialButton button) { + return button.textColor ?? button.disabledTextColor ?? colorScheme!.onSurface.withOpacity(0.38); + } + + /// The [button]'s background color when [MaterialButton.onPressed] is null + /// (when [MaterialButton.enabled] is false). + /// + /// Returns the button's [MaterialButton.disabledColor] if it is non-null. + /// + /// Otherwise the value of the `disabledColor` constructor parameter + /// is returned, if it is non-null. + /// + /// Otherwise the color scheme's [ColorScheme.onSurface] color is returned + /// with its opacity set to 0.38. + Color getDisabledFillColor(MaterialButton button) { + return button.disabledColor ?? _disabledColor ?? colorScheme!.onSurface.withOpacity(0.38); + } + + /// The button's background fill color or null for buttons that don't have + /// a background color. + /// + /// Returns [MaterialButton.color] if it is non-null and the button + /// is enabled. + /// + /// Otherwise, returns [MaterialButton.disabledColor] if it is non-null and + /// the button is disabled. + /// + /// Otherwise the fill color depends on the value of [getTextTheme]. + /// + /// * [ButtonTextTheme.normal] or [ButtonTextTheme.accent], the + /// color scheme's [ColorScheme.primary] color if the [button] is enabled + /// the value of [getDisabledFillColor] otherwise. + /// * [ButtonTextTheme.primary], if the [button] is enabled then the value + /// of the `buttonColor` constructor parameter if it is non-null, + /// otherwise the color scheme's ColorScheme.primary color. If the button + /// is not enabled then the colorScheme's [ColorScheme.onSurface] color + /// with opacity 0.12. + Color? getFillColor(MaterialButton button) { + final Color? fillColor = button.enabled ? button.color : button.disabledColor; + if (fillColor != null) { + return fillColor; + } + + if (button.runtimeType == MaterialButton) { + return null; + } + + if (button.enabled && _buttonColor != null) { + return _buttonColor; + } + + switch (getTextTheme(button)) { + case ButtonTextTheme.normal: + case ButtonTextTheme.accent: + return button.enabled ? colorScheme!.primary : getDisabledFillColor(button); + case ButtonTextTheme.primary: + return button.enabled + ? _buttonColor ?? colorScheme!.primary + : colorScheme!.onSurface.withOpacity(0.12); + } + } + + /// The foreground color of the [button]'s text and icon. + /// + /// If [button] is not [MaterialButton.enabled], the value of + /// [getDisabledTextColor] is returned. If the button is enabled and + /// [MaterialButton.textColor] is non-null, then [MaterialButton.textColor] + /// is returned. + /// + /// Otherwise the text color depends on the value of [getTextTheme] + /// and [getBrightness]. + /// + /// * [ButtonTextTheme.normal]: [Colors.white] is used if [getBrightness] + /// resolves to [Brightness.dark]. [Colors.black87] is used if + /// [getBrightness] resolves to [Brightness.light]. + /// * [ButtonTextTheme.accent]: [ColorScheme.secondary] of [colorScheme]. + /// * [ButtonTextTheme.primary]: If [getFillColor] is dark then [Colors.white], + /// otherwise [Colors.black]. + Color getTextColor(MaterialButton button) { + if (!button.enabled) { + return getDisabledTextColor(button); + } + + if (button.textColor != null) { + return button.textColor!; + } + + switch (getTextTheme(button)) { + case ButtonTextTheme.normal: + return getBrightness(button) == Brightness.dark ? Colors.white : Colors.black87; + + case ButtonTextTheme.accent: + return colorScheme!.secondary; + + case ButtonTextTheme.primary: + final Color? fillColor = getFillColor(button); + final fillIsDark = fillColor != null + ? ThemeData.estimateBrightnessForColor(fillColor) == Brightness.dark + : getBrightness(button) == Brightness.dark; + return fillIsDark ? Colors.white : Colors.black; + } + } + + /// The color of the ink "splash" overlay that appears when the (enabled) + /// [button] is tapped. + /// + /// Returns the button's [MaterialButton.splashColor] if it is non-null. + /// + /// Otherwise, returns the value of the `splashColor` constructor parameter + /// it is non-null. + /// + /// Otherwise, returns the value of the `splashColor` constructor parameter + /// if it is non-null and [getTextTheme] is not [ButtonTextTheme.primary]. + /// + /// Otherwise, returns [getTextColor] with an opacity of 0.12. + Color getSplashColor(MaterialButton button) { + if (button.splashColor != null) { + return button.splashColor!; + } + + if (_splashColor != null) { + switch (getTextTheme(button)) { + case ButtonTextTheme.normal: + case ButtonTextTheme.accent: + return _splashColor; + case ButtonTextTheme.primary: + break; + } + } + + return getTextColor(button).withOpacity(0.12); + } + + /// The fill color of the button when it has input focus. + /// + /// Returns the button's [MaterialButton.focusColor] if it is non-null. + /// Otherwise the focus color depends on [getTextTheme]: + /// + /// * [ButtonTextTheme.normal], [ButtonTextTheme.accent]: returns the + /// value of the `focusColor` constructor parameter if it is non-null, + /// otherwise the value of [getTextColor] with opacity 0.12. + /// * [ButtonTextTheme.primary], returns [Colors.transparent]. + Color getFocusColor(MaterialButton button) { + return button.focusColor ?? _focusColor ?? getTextColor(button).withOpacity(0.12); + } + + /// The fill color of the button when it has input focus. + /// + /// Returns the button's [MaterialButton.focusColor] if it is non-null. + /// Otherwise the focus color depends on [getTextTheme]: + /// + /// * [ButtonTextTheme.normal], [ButtonTextTheme.accent], + /// [ButtonTextTheme.primary]: returns the value of the `focusColor` + /// constructor parameter if it is non-null, otherwise the value of + /// [getTextColor] with opacity 0.04. + Color getHoverColor(MaterialButton button) { + return button.hoverColor ?? _hoverColor ?? getTextColor(button).withOpacity(0.04); + } + + /// The color of the overlay that appears when the [button] is pressed. + /// + /// Returns the button's [MaterialButton.highlightColor] if it is non-null. + /// Otherwise the highlight color depends on [getTextTheme]: + /// + /// * [ButtonTextTheme.normal], [ButtonTextTheme.accent]: returns the + /// value of the `highlightColor` constructor parameter if it is non-null, + /// otherwise the value of [getTextColor] with opacity 0.16. + /// * [ButtonTextTheme.primary], returns [Colors.transparent]. + Color getHighlightColor(MaterialButton button) { + if (button.highlightColor != null) { + return button.highlightColor!; + } + + switch (getTextTheme(button)) { + case ButtonTextTheme.normal: + case ButtonTextTheme.accent: + return _highlightColor ?? getTextColor(button).withOpacity(0.16); + case ButtonTextTheme.primary: + return Colors.transparent; + } + } + + /// The [button]'s elevation when it is enabled and has not been pressed. + /// + /// Returns the button's [MaterialButton.elevation] if it is non-null, + /// otherwise it is 2.0. + double getElevation(MaterialButton button) => button.elevation ?? 2.0; + + /// The [button]'s elevation when it is enabled and has focus. + /// + /// Returns the button's [MaterialButton.focusElevation] if it is non-null, + /// otherwise the highlight elevation is 4.0. + double getFocusElevation(MaterialButton button) => button.focusElevation ?? 4.0; + + /// The [button]'s elevation when it is enabled and has focus. + /// + /// Returns the button's [MaterialButton.hoverElevation] if it is non-null, + /// otherwise the highlight elevation is 4.0. + double getHoverElevation(MaterialButton button) => button.hoverElevation ?? 4.0; + + /// The [button]'s elevation when it is enabled and has been pressed. + /// + /// Returns the button's [MaterialButton.highlightElevation] if it is non-null, + /// otherwise the highlight elevation is 8.0. + double getHighlightElevation(MaterialButton button) => button.highlightElevation ?? 8.0; + + /// The [button]'s elevation when [MaterialButton.onPressed] is null (when + /// MaterialButton.enabled is false). + /// + /// Returns the button's [MaterialButton.elevation] if it is non-null. + /// + /// Otherwise the disabled elevation is 0.0. + double getDisabledElevation(MaterialButton button) => button.disabledElevation ?? 0.0; + + /// Padding for the [button]'s child (typically the button's label). + /// + /// Returns the button's [MaterialButton.padding] if it is non-null, + /// otherwise, returns the `padding` of the constructor parameter if it is + /// non-null. + /// + /// Otherwise, returns horizontal padding of 24.0 on the left and right if + /// [getTextTheme] is [ButtonTextTheme.primary], 16.0 on the left and right + /// otherwise. + EdgeInsetsGeometry getPadding(MaterialButton button) { + return button.padding ?? + _padding ?? + switch (getTextTheme(button)) { + ButtonTextTheme.normal => const EdgeInsets.symmetric(horizontal: 16.0), + ButtonTextTheme.accent => const EdgeInsets.symmetric(horizontal: 16.0), + ButtonTextTheme.primary => const EdgeInsets.symmetric(horizontal: 24.0), + }; + } + + /// The shape of the [button]'s [Material]. + /// + /// Returns the button's [MaterialButton.shape] if it is non-null, otherwise + /// [shape] is returned. + ShapeBorder getShape(MaterialButton button) => button.shape ?? shape; + + /// The duration of the [button]'s highlight animation. + /// + /// Returns the button's [MaterialButton.animationDuration] it if is non-null, + /// otherwise 200ms. + Duration getAnimationDuration(MaterialButton button) { + return button.animationDuration ?? kThemeChangeDuration; + } + + /// The [BoxConstraints] that the define the [button]'s size. + /// + /// By default this method just returns [constraints]. Subclasses + /// could override this method to return a value that was, + /// for example, based on the button's type. + BoxConstraints getConstraints(MaterialButton button) => constraints; + + /// The minimum size of the [button]'s tap target. + /// + /// Returns the button's [MaterialButton.materialTapTargetSize] if it is non-null. + /// + /// Otherwise the value of the `materialTapTargetSize` constructor + /// parameter is returned if that's non-null. + /// + /// Otherwise [MaterialTapTargetSize.padded] is returned. + MaterialTapTargetSize getMaterialTapTargetSize(MaterialButton button) { + return button.materialTapTargetSize ?? _materialTapTargetSize ?? MaterialTapTargetSize.padded; + } + + /// Creates a copy of this button theme data object with the matching fields + /// replaced with the non-null parameter values. + ButtonThemeData copyWith({ + ButtonTextTheme? textTheme, + ButtonBarLayoutBehavior? layoutBehavior, + double? minWidth, + double? height, + EdgeInsetsGeometry? padding, + ShapeBorder? shape, + bool? alignedDropdown, + Color? buttonColor, + Color? disabledColor, + Color? focusColor, + Color? hoverColor, + Color? highlightColor, + Color? splashColor, + ColorScheme? colorScheme, + MaterialTapTargetSize? materialTapTargetSize, + }) { + return ButtonThemeData( + textTheme: textTheme ?? this.textTheme, + layoutBehavior: layoutBehavior ?? this.layoutBehavior, + minWidth: minWidth ?? this.minWidth, + height: height ?? this.height, + padding: padding ?? this.padding, + shape: shape ?? this.shape, + alignedDropdown: alignedDropdown ?? this.alignedDropdown, + buttonColor: buttonColor ?? _buttonColor, + disabledColor: disabledColor ?? _disabledColor, + focusColor: focusColor ?? _focusColor, + hoverColor: hoverColor ?? _hoverColor, + highlightColor: highlightColor ?? _highlightColor, + splashColor: splashColor ?? _splashColor, + colorScheme: colorScheme ?? this.colorScheme, + materialTapTargetSize: materialTapTargetSize ?? _materialTapTargetSize, + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is ButtonThemeData && + other.textTheme == textTheme && + other.minWidth == minWidth && + other.height == height && + other.padding == padding && + other.shape == shape && + other.alignedDropdown == alignedDropdown && + other._buttonColor == _buttonColor && + other._disabledColor == _disabledColor && + other._focusColor == _focusColor && + other._hoverColor == _hoverColor && + other._highlightColor == _highlightColor && + other._splashColor == _splashColor && + other.colorScheme == colorScheme && + other._materialTapTargetSize == _materialTapTargetSize; + } + + @override + int get hashCode => Object.hash( + textTheme, + minWidth, + height, + padding, + shape, + alignedDropdown, + _buttonColor, + _disabledColor, + _focusColor, + _hoverColor, + _highlightColor, + _splashColor, + colorScheme, + _materialTapTargetSize, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultTheme = ButtonThemeData(); + properties.add( + EnumProperty<ButtonTextTheme>('textTheme', textTheme, defaultValue: defaultTheme.textTheme), + ); + properties.add(DoubleProperty('minWidth', minWidth, defaultValue: defaultTheme.minWidth)); + properties.add(DoubleProperty('height', height, defaultValue: defaultTheme.height)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'padding', + padding, + defaultValue: defaultTheme.padding, + ), + ); + properties.add( + DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: defaultTheme.shape), + ); + properties.add( + FlagProperty( + 'alignedDropdown', + value: alignedDropdown, + defaultValue: defaultTheme.alignedDropdown, + ifTrue: 'dropdown width matches button', + ), + ); + properties.add(ColorProperty('buttonColor', _buttonColor, defaultValue: null)); + properties.add(ColorProperty('disabledColor', _disabledColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', _focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', _hoverColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', _highlightColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', _splashColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<ColorScheme>( + 'colorScheme', + colorScheme, + defaultValue: defaultTheme.colorScheme, + ), + ); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + _materialTapTargetSize, + defaultValue: null, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/calendar_date_picker.dart b/packages/material_ui/lib/src/calendar_date_picker.dart new file mode 100644 index 000000000000..bce7bedec61a --- /dev/null +++ b/packages/material_ui/lib/src/calendar_date_picker.dart @@ -0,0 +1,1656 @@ +// 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. + +/// @docImport 'date_picker.dart'; +/// @docImport 'time_picker.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'date.dart'; +import 'date_picker_theme.dart'; +import 'debug.dart'; +import 'divider.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'ink_decoration.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'theme.dart'; + +const Duration _monthScrollDuration = Duration(milliseconds: 200); + +// Current M2 implementation is not compliant with the M2 specification. +// Instead of a 42 pixels row height it should be 40 with a 2 pixels inner padding. +// See: https://m2.material.io/components/date-pickers#specs. +const double _dayPickerRowHeightM2 = 42.0; +// For M3, row height is 48 pxiels with 4 pixels inner padding. +// See: https://m3.material.io/components/date-pickers/specs#2d53890e-a08f-4c63-a0d9-abd9e95b4245. +const double _dayPickerRowHeightM3 = 48.0; + +const int _maxDayPickerRowCount = 6; // A 31 day month that starts on Saturday. +// One extra row for the day-of-week header. +const double _maxDayPickerHeightM2 = _dayPickerRowHeightM2 * (_maxDayPickerRowCount + 1); +const double _maxDayPickerHeightM3 = _dayPickerRowHeightM3 * (_maxDayPickerRowCount + 1); + +const double _monthPickerHorizontalPaddingPortraitM3 = 12.0; +const double _monthPickerHorizontalPaddingOther = 8.0; + +const int _yearPickerColumnCount = 3; +const double _yearPickerPadding = 16.0; +const double _yearPickerRowHeight = 52.0; +const double _yearPickerRowSpacing = 8.0; + +const double _subHeaderHeight = 52.0; +const double _monthNavButtonsWidth = 108.0; + +// 3.0 is the maximum scale factor on mobile phones. As of 07/30/24, iOS goes up +// to a max of 3.0 text scale factor, and Android goes up to 2.0. This is the +// default used for non-range date pickers. This default is changed to a lower +// value at different parts of the date pickers depending on content, and device +// orientation. +const double _kMaxTextScaleFactor = 3.0; + +const double _kModeToggleButtonMaxScaleFactor = 2.0; + +// The max scale factor of the day picker grid. This affects the size of the +// individual days in calendar view. Due to them filling a majority of the modal, +// which covers most of the screen, there's a limit in how large they can grow. +// There is also less room vertically in landscape orientation. +const double _kDayPickerGridPortraitMaxScaleFactor = 2.0; +const double _kDayPickerGridLandscapeMaxScaleFactor = 1.5; + +// 14 is a common font size used to compute the effective text scale. +const double _fontSizeToScale = 14.0; + +/// Displays a grid of days for a given month and allows the user to select a +/// date. +/// +/// Days are arranged in a rectangular grid with one column for each day of the +/// week. Controls are provided to change the year and month that the grid is +/// showing. +/// +/// The calendar picker widget is rarely used directly. Instead, consider using +/// [showDatePicker], which will create a dialog that uses this as well as +/// provides a text entry option. +/// +/// See also: +/// +/// * [showDatePicker], which creates a Dialog that contains a +/// [CalendarDatePicker] and provides an optional compact view where the +/// user can enter a date as a line of text. +/// * [showTimePicker], which shows a dialog that contains a Material Design +/// time picker. +/// +class CalendarDatePicker extends StatefulWidget { + /// Creates a calendar date picker. + /// + /// It will display a grid of days for the [initialDate]'s month, or, if that + /// is null, the [currentDate]'s month. The day indicated by [initialDate] will + /// be selected if it is not null. + /// + /// The optional [onDisplayedMonthChanged] callback can be used to track + /// the currently displayed month. + /// + /// The user interface provides a way to change the year of the month being + /// displayed. By default it will show the day grid, but this can be changed + /// to start in the year selection interface with [initialCalendarMode] set + /// to [DatePickerMode.year]. + /// + /// The [lastDate] must be after or equal to [firstDate]. + /// + /// The [initialDate], if provided, must be between [firstDate] and [lastDate] + /// or equal to one of them. + /// + /// The [currentDate] represents the current day (i.e. today). This + /// date will be highlighted in the day grid. If null, the date of + /// `DateTime.now()` will be used. + /// + /// If [selectableDayPredicate] and [initialDate] are both non-null, + /// [selectableDayPredicate] must return `true` for the [initialDate]. + /// + /// {@template flutter.material.calendar_date_picker.calendarDelegate} + /// The [calendarDelegate] controls date interpretation, formatting, and + /// navigation within the picker. By providing a custom implementation, + /// you can support alternative calendar systems such as Nepali, Hijri, + /// Buddhist, and more. Defaults to [GregorianCalendarDelegate]. + /// {@endtemplate} + CalendarDatePicker({ + super.key, + required DateTime? initialDate, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + required this.onDateChanged, + this.onDisplayedMonthChanged, + this.initialCalendarMode = DatePickerMode.day, + this.selectableDayPredicate, + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { + assert( + !this.lastDate.isBefore(this.firstDate), + 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + this.initialDate == null || !this.initialDate!.isBefore(this.firstDate), + 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + this.initialDate == null || !this.initialDate!.isAfter(this.lastDate), + 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.', + ); + assert( + selectableDayPredicate == null || + this.initialDate == null || + selectableDayPredicate!(this.initialDate!), + 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate.', + ); + } + + /// The initially selected [DateTime] that the picker should display. + /// + /// Subsequently changing this has no effect. To change the selected date, + /// change the [key] to create a new instance of the [CalendarDatePicker], and + /// provide that widget the new [initialDate]. This will reset the widget's + /// interactive state. + final DateTime? initialDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// The [DateTime] representing today. It will be highlighted in the day grid. + final DateTime currentDate; + + /// Called when the user selects a date in the picker. + final ValueChanged<DateTime> onDateChanged; + + /// Called when the user navigates to a new month/year in the picker. + final ValueChanged<DateTime>? onDisplayedMonthChanged; + + /// The initial display of the calendar picker. + /// + /// Subsequently changing this has no effect. To change the calendar mode, + /// change the [key] to create a new instance of the [CalendarDatePicker], and + /// provide that widget a new [initialCalendarMode]. This will reset the + /// widget's interactive state. + final DatePickerMode initialCalendarMode; + + /// Function to provide full control over which dates in the calendar can be selected. + final SelectableDayPredicate? selectableDayPredicate; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<CalendarDatePicker> createState() => _CalendarDatePickerState(); +} + +class _CalendarDatePickerState extends State<CalendarDatePicker> { + bool _announcedInitialDate = false; + String _announcementText = ''; + late DatePickerMode _mode; + late DateTime _currentDisplayedMonthDate; + DateTime? _selectedDate; + final GlobalKey _monthPickerKey = GlobalKey(); + final GlobalKey _yearPickerKey = GlobalKey(); + late MaterialLocalizations _localizations; + late TextDirection _textDirection; + + @override + void initState() { + super.initState(); + _mode = widget.initialCalendarMode; + final DateTime currentDisplayedDate = widget.initialDate ?? widget.currentDate; + _currentDisplayedMonthDate = widget.calendarDelegate.getMonth( + currentDisplayedDate.year, + currentDisplayedDate.month, + ); + if (widget.initialDate != null) { + _selectedDate = widget.initialDate; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + _localizations = MaterialLocalizations.of(context); + _textDirection = Directionality.of(context); + if (!_announcedInitialDate && widget.initialDate != null) { + assert(_selectedDate != null); + _announcedInitialDate = true; + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, _selectedDate); + final semanticLabelSuffix = isToday ? ', ${_localizations.currentDateLabel}' : ''; + _announce('${_localizations.formatFullDate(_selectedDate!)}$semanticLabelSuffix'); + } + } + + // Auxiliary method for handling the difference between platforms + void _announce(String message) { + if (MediaQuery.maybeSupportsAnnounceOf(context) ?? false) { + SemanticsService.sendAnnouncement( + View.of(context), + message, + Directionality.of(context), + ).catchError(_reportAnnouncementError); + } else { + // If SemanticsService.sendAnnouncement is not supported, + // we use live region to achieve the announcement effect instead. + _announcementText = message; + } + } + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + HapticFeedback.vibrate(); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + } + + void _handleModeChanged(DatePickerMode mode) { + _vibrate(); + setState(() { + _mode = mode; + if (_selectedDate case final DateTime selected) { + final String message = switch (mode) { + DatePickerMode.day => widget.calendarDelegate.formatMonthYear(selected, _localizations), + DatePickerMode.year => widget.calendarDelegate.formatYear(selected.year, _localizations), + }; + _announce(message); + } + }); + } + + void _handleMonthChanged(DateTime date) { + setState(() { + if (_currentDisplayedMonthDate.year != date.year || + _currentDisplayedMonthDate.month != date.month) { + _currentDisplayedMonthDate = widget.calendarDelegate.getMonth(date.year, date.month); + widget.onDisplayedMonthChanged?.call(_currentDisplayedMonthDate); + } + }); + } + + void _handleYearChanged(DateTime value) { + _vibrate(); + + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(value.year, value.month); + final int preferredDay = math.min(_selectedDate?.day ?? 1, daysInMonth); + value = widget.calendarDelegate.getDay(value.year, value.month, preferredDay); + + if (value.isBefore(widget.firstDate)) { + value = widget.firstDate; + } else if (value.isAfter(widget.lastDate)) { + value = widget.lastDate; + } + + setState(() { + _mode = DatePickerMode.day; + _handleMonthChanged(value); + + if (_isSelectable(value)) { + _selectedDate = value; + widget.onDateChanged(_selectedDate!); + } + }); + } + + void _handleDayChanged(DateTime value) { + _vibrate(); + setState(() { + _selectedDate = value; + widget.onDateChanged(_selectedDate!); + switch (Theme.of(context).platform) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, _selectedDate); + final semanticLabelSuffix = isToday ? ', ${_localizations.currentDateLabel}' : ''; + SemanticsService.sendAnnouncement( + View.of(context), + '${_localizations.selectedDateLabel} ${widget.calendarDelegate.formatFullDate(_selectedDate!, _localizations)}$semanticLabelSuffix', + _textDirection, + ).catchError(_reportAnnouncementError); + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + break; + } + }); + } + + bool _isSelectable(DateTime date) { + return widget.selectableDayPredicate?.call(date) ?? true; + } + + Widget _buildPicker() { + switch (_mode) { + case DatePickerMode.day: + return _MonthPicker( + key: _monthPickerKey, + calendarDelegate: widget.calendarDelegate, + initialMonth: _currentDisplayedMonthDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selectedDate: _selectedDate, + onChanged: _handleDayChanged, + onDisplayedMonthChanged: _handleMonthChanged, + selectableDayPredicate: widget.selectableDayPredicate, + ); + case DatePickerMode.year: + return Padding( + padding: const EdgeInsets.only(top: _subHeaderHeight), + child: YearPicker( + key: _yearPickerKey, + calendarDelegate: widget.calendarDelegate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selectedDate: _currentDisplayedMonthDate, + onChanged: _handleYearChanged, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + final double textScaleFactor = + MediaQuery.textScalerOf( + context, + ).clamp(maxScaleFactor: _kMaxTextScaleFactor).scale(_fontSizeToScale) / + _fontSizeToScale; + + // Conform to M3 spec in portrait mode (landscape mode is not specified). + final Orientation orientation = MediaQuery.orientationOf(context); + final double maxDayPickerHeight = + Theme.of(context).useMaterial3 && orientation == Orientation.portrait + ? _maxDayPickerHeightM3 + : _maxDayPickerHeightM2; + + // Scale the height of the picker area up with larger text. The size of the + // picker has room for larger text, up until a scale factor of 1.3. After + // after which, we increase the height to add room for content to continue + // to scale the text size. + final double scaledMaxDayPickerHeight = textScaleFactor > 1.3 + ? maxDayPickerHeight + ((_maxDayPickerRowCount + 1) * ((textScaleFactor - 1) * 8)) + : maxDayPickerHeight; + final picker = SizedBox( + height: _subHeaderHeight + scaledMaxDayPickerHeight, + child: _buildPicker(), + ); + return Stack( + children: <Widget>[ + if (MediaQuery.maybeSupportsAnnounceOf(context) ?? false) + picker + else + Semantics( + container: true, + liveRegion: true, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, + label: _announcementText, + child: picker, + ), + + // Put the mode toggle button on top so that it won't be covered up by the _MonthPicker + MediaQuery.withClampedTextScaling( + maxScaleFactor: _kModeToggleButtonMaxScaleFactor, + child: _DatePickerModeToggleButton( + mode: _mode, + title: widget.calendarDelegate.formatMonthYear( + _currentDisplayedMonthDate, + _localizations, + ), + onTitlePressed: () => _handleModeChanged(switch (_mode) { + DatePickerMode.day => DatePickerMode.year, + DatePickerMode.year => DatePickerMode.day, + }), + ), + ), + ], + ); + } +} + +/// A button that used to toggle the [DatePickerMode] for a date picker. +/// +/// This appears above the calendar grid and allows the user to toggle the +/// [DatePickerMode] to display either the calendar view or the year list. +class _DatePickerModeToggleButton extends StatefulWidget { + const _DatePickerModeToggleButton({ + required this.mode, + required this.title, + required this.onTitlePressed, + }); + + /// The current display of the calendar picker. + final DatePickerMode mode; + + /// The text that displays the current month/year being viewed. + final String title; + + /// The callback when the title is pressed. + final VoidCallback onTitlePressed; + + @override + _DatePickerModeToggleButtonState createState() => _DatePickerModeToggleButtonState(); +} + +class _DatePickerModeToggleButtonState extends State<_DatePickerModeToggleButton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + value: widget.mode == DatePickerMode.year ? 0.5 : 0, + upperBound: 0.5, + duration: const Duration(milliseconds: 200), + vsync: this, + ); + } + + @override + void didUpdateWidget(_DatePickerModeToggleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mode == widget.mode) { + return; + } + + if (widget.mode == DatePickerMode.year) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final TextStyle? buttonTextStyle = + datePickerTheme.toggleButtonTextStyle ?? defaults.toggleButtonTextStyle; + final Color? subHeaderForegroundColor = + datePickerTheme.subHeaderForegroundColor ?? defaults.subHeaderForegroundColor; + final Color? buttonTextColor = + datePickerTheme.toggleButtonTextStyle?.color ?? + datePickerTheme.subHeaderForegroundColor ?? + defaults.toggleButtonTextStyle?.color; + + return SizedBox( + height: _subHeaderHeight, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + child: Row( + children: <Widget>[ + Flexible( + child: Semantics( + label: MaterialLocalizations.of(context).selectYearSemanticsLabel, + button: true, + container: true, + child: SizedBox( + height: _subHeaderHeight, + child: InkWell( + onTap: widget.onTitlePressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: <Widget>[ + Flexible( + child: Text( + widget.title, + overflow: TextOverflow.ellipsis, + style: buttonTextStyle?.apply(color: buttonTextColor), + ), + ), + RotationTransition( + turns: _controller, + child: Icon(Icons.arrow_drop_down, color: subHeaderForegroundColor), + ), + ], + ), + ), + ), + ), + ), + ), + if (widget.mode == DatePickerMode.day) + // Give space for the prev/next month buttons that are underneath this row + const SizedBox(width: _monthNavButtonsWidth), + ], + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class _MonthPicker extends StatefulWidget { + /// Creates a month picker. + _MonthPicker({ + super.key, + required this.initialMonth, + required this.currentDate, + required this.firstDate, + required this.lastDate, + required this.selectedDate, + required this.onChanged, + required this.onDisplayedMonthChanged, + required this.calendarDelegate, + this.selectableDayPredicate, + }) : assert(!firstDate.isAfter(lastDate)), + assert(selectedDate == null || !selectedDate.isBefore(firstDate)), + assert(selectedDate == null || !selectedDate.isAfter(lastDate)); + + /// The initial month to display. + /// + /// Subsequently changing this has no effect. To change the selected month, + /// change the [key] to create a new instance of the [_MonthPicker], and + /// provide that widget the new [initialMonth]. This will reset the widget's + /// interactive state. + final DateTime initialMonth; + + /// The current date. + /// + /// This date is subtly highlighted in the picker. + final DateTime currentDate; + + /// The earliest date the user is permitted to pick. + /// + /// This date must be on or before the [lastDate]. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + /// + /// This date must be on or after the [firstDate]. + final DateTime lastDate; + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDate; + + /// Called when the user picks a day. + final ValueChanged<DateTime> onChanged; + + /// Called when the user navigates to a new month. + final ValueChanged<DateTime> onDisplayedMonthChanged; + + /// Optional user supplied predicate function to customize selectable days. + final SelectableDayPredicate? selectableDayPredicate; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + _MonthPickerState createState() => _MonthPickerState(); +} + +class _MonthPickerState extends State<_MonthPicker> { + final GlobalKey _pageViewKey = GlobalKey(); + String _announcementText = ''; + late DateTime _currentMonth; + late PageController _pageController; + late MaterialLocalizations _localizations; + Map<ShortcutActivator, Intent>? _shortcutMap; + Map<Type, Action<Intent>>? _actionMap; + late FocusNode _dayGridFocus; + DateTime? _focusedDay; + + @override + void initState() { + super.initState(); + _currentMonth = widget.initialMonth; + _pageController = PageController( + initialPage: widget.calendarDelegate.monthDelta(widget.firstDate, _currentMonth), + ); + _shortcutMap = const <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent( + TraversalDirection.left, + ), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent( + TraversalDirection.right, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent( + TraversalDirection.down, + ), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + }; + _actionMap = <Type, Action<Intent>>{ + NextFocusIntent: CallbackAction<NextFocusIntent>(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>( + onInvoke: _handleDirectionFocus, + ), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _localizations = MaterialLocalizations.of(context); + } + + @override + void dispose() { + _pageController.dispose(); + _dayGridFocus.dispose(); + super.dispose(); + } + + void _handleDateSelected(DateTime selectedDate) { + _focusedDay = selectedDate; + widget.onChanged(selectedDate); + } + + // Auxiliary method for handling the difference between platforms + void _announce(String message) { + if (MediaQuery.maybeSupportsAnnounceOf(context) ?? false) { + SemanticsService.sendAnnouncement( + View.of(context), + message, + Directionality.of(context), + ).catchError(_reportAnnouncementError); + } else { + // If SemanticsService.sendAnnouncement is not supported, + // we use live region to achieve the announcement effect instead. + _announcementText = message; + } + } + + void _handleMonthPageChanged(int monthPage) { + setState(() { + final DateTime monthDate = widget.calendarDelegate.addMonthsToMonthDate( + widget.firstDate, + monthPage, + ); + if (!widget.calendarDelegate.isSameMonth(_currentMonth, monthDate)) { + _currentMonth = widget.calendarDelegate.getMonth(monthDate.year, monthDate.month); + widget.onDisplayedMonthChanged(_currentMonth); + if (_focusedDay != null && + !widget.calendarDelegate.isSameMonth(_focusedDay, _currentMonth)) { + // We have navigated to a new month with the grid focused, but the + // focused day is not in this month. Choose a new one trying to keep + // the same day of the month. + _focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay!.day); + } + _announce(widget.calendarDelegate.formatMonthYear(_currentMonth, _localizations)); + } + }); + } + + /// Returns a focusable date for the given month. + /// + /// If the preferredDay is available in the month it will be returned, + /// otherwise the first selectable day in the month will be returned. If + /// no dates are selectable in the month, then it will return null. + DateTime? _focusableDayForMonth(DateTime month, int preferredDay) { + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(month.year, month.month); + + // Can we use the preferred day in this month? + if (preferredDay <= daysInMonth) { + final DateTime newFocus = widget.calendarDelegate.getDay( + month.year, + month.month, + preferredDay, + ); + if (_isSelectable(newFocus)) { + return newFocus; + } + } + + // Start at the 1st and take the first selectable date. + for (var day = 1; day <= daysInMonth; day++) { + final DateTime newFocus = widget.calendarDelegate.getDay(month.year, month.month, day); + if (_isSelectable(newFocus)) { + return newFocus; + } + } + return null; + } + + /// Navigate to the next month. + void _handleNextMonth() { + if (!_isDisplayingLastMonth) { + _pageController.nextPage(duration: _monthScrollDuration, curve: Curves.ease); + } + } + + /// Navigate to the previous month. + void _handlePreviousMonth() { + if (!_isDisplayingFirstMonth) { + _pageController.previousPage(duration: _monthScrollDuration, curve: Curves.ease); + } + } + + /// Navigate to the given month. + void _showMonth(DateTime month, {bool jump = false}) { + final int monthPage = widget.calendarDelegate.monthDelta(widget.firstDate, month); + if (jump) { + _pageController.jumpToPage(monthPage); + } else { + _pageController.animateToPage(monthPage, duration: _monthScrollDuration, curve: Curves.ease); + } + } + + /// True if the earliest allowable month is displayed. + bool get _isDisplayingFirstMonth { + return !_currentMonth.isAfter( + widget.calendarDelegate.getMonth(widget.firstDate.year, widget.firstDate.month), + ); + } + + /// True if the latest allowable month is displayed. + bool get _isDisplayingLastMonth { + return !_currentMonth.isBefore( + widget.calendarDelegate.getMonth(widget.lastDate.year, widget.lastDate.month), + ); + } + + /// Handler for when the overall day grid obtains or loses focus. + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused && _focusedDay == null) { + if (widget.calendarDelegate.isSameMonth(widget.selectedDate, _currentMonth)) { + _focusedDay = widget.selectedDate; + } else if (widget.calendarDelegate.isSameMonth(widget.currentDate, _currentMonth)) { + _focusedDay = _focusableDayForMonth(_currentMonth, widget.currentDate.day); + } else { + _focusedDay = _focusableDayForMonth(_currentMonth, 1); + } + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime? nextDate = _nextDateInDirection(_focusedDay!, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + if (!widget.calendarDelegate.isSameMonth(_focusedDay, _currentMonth)) { + _showMonth(_focusedDay!); + } + } + }); + } + + static const Map<TraversalDirection, int> _directionOffset = <TraversalDirection, int>{ + TraversalDirection.up: -DateTime.daysPerWeek, + TraversalDirection.right: 1, + TraversalDirection.down: DateTime.daysPerWeek, + TraversalDirection.left: -1, + }; + + int _dayDirectionOffset(TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) { + traversalDirection = TraversalDirection.right; + } else if (traversalDirection == TraversalDirection.right) { + traversalDirection = TraversalDirection.left; + } + } + return _directionOffset[traversalDirection]!; + } + + DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + DateTime nextDate = widget.calendarDelegate.addDaysToDate( + date, + _dayDirectionOffset(direction, textDirection), + ); + while (!nextDate.isBefore(widget.firstDate) && !nextDate.isAfter(widget.lastDate)) { + if (_isSelectable(nextDate)) { + return nextDate; + } + nextDate = widget.calendarDelegate.addDaysToDate( + nextDate, + _dayDirectionOffset(direction, textDirection), + ); + } + return null; + } + + bool _isSelectable(DateTime date) { + return widget.selectableDayPredicate?.call(date) ?? true; + } + + Widget _buildItems(BuildContext context, int index) { + final DateTime month = widget.calendarDelegate.addMonthsToMonthDate(widget.firstDate, index); + return _DayPicker( + key: ValueKey<DateTime>(month), + calendarDelegate: widget.calendarDelegate, + selectedDate: widget.selectedDate, + currentDate: widget.currentDate, + onChanged: _handleDateSelected, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + selectableDayPredicate: widget.selectableDayPredicate, + ); + } + + @override + Widget build(BuildContext context) { + final Color? subHeaderForegroundColor = + DatePickerTheme.of(context).subHeaderForegroundColor ?? + DatePickerTheme.defaults(context).subHeaderForegroundColor; + + final bool supportsAnnounce = MediaQuery.maybeSupportsAnnounceOf(context) ?? false; + return Semantics( + container: true, + explicitChildNodes: true, + liveRegion: !supportsAnnounce, + accessibilityFocusBlockType: !supportsAnnounce + ? AccessibilityFocusBlockType.blockNode + : AccessibilityFocusBlockType.none, + label: !supportsAnnounce ? _announcementText : null, + child: Column( + children: <Widget>[ + SizedBox( + height: _subHeaderHeight, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + child: Row( + children: <Widget>[ + const Spacer(), + IconButton( + icon: Icon( + Icons.chevron_left, + semanticLabel: _isDisplayingFirstMonth + ? _localizations.previousMonthTooltip + : null, + ), + color: subHeaderForegroundColor, + tooltip: _isDisplayingFirstMonth ? null : _localizations.previousMonthTooltip, + onPressed: _isDisplayingFirstMonth ? null : _handlePreviousMonth, + ), + IconButton( + icon: Icon( + Icons.chevron_right, + semanticLabel: _isDisplayingLastMonth + ? _localizations.nextMonthTooltip + : null, + ), + color: subHeaderForegroundColor, + tooltip: _isDisplayingLastMonth ? null : _localizations.nextMonthTooltip, + onPressed: _isDisplayingLastMonth ? null : _handleNextMonth, + ), + ], + ), + ), + ), + Expanded( + child: FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + calendarDelegate: widget.calendarDelegate, + date: _dayGridFocus.hasFocus ? _focusedDay : null, + // Wrap the PageView with `Material`, so when its child paints on materials + // the content won't go out of boundary during page transition. + child: Material( + type: MaterialType.transparency, + child: PageView.builder( + key: _pageViewKey, + controller: _pageController, + itemBuilder: _buildItems, + itemCount: + widget.calendarDelegate.monthDelta(widget.firstDate, widget.lastDate) + 1, + onPageChanged: _handleMonthPageChanged, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// InheritedWidget indicating what the current focused date is for its children. +/// +/// This is used by the [_MonthPicker] to let its children [_DayPicker]s know +/// what the currently focused date (if any) should be. +class _FocusedDate extends InheritedWidget { + const _FocusedDate({required super.child, required this.calendarDelegate, this.date}); + + final CalendarDelegate<DateTime> calendarDelegate; + final DateTime? date; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !calendarDelegate.isSameDay(date, oldWidget.date); + } + + static DateTime? maybeOf(BuildContext context) { + final _FocusedDate? focusedDate = context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + return focusedDate?.date; + } +} + +/// Displays the days of a given month and allows choosing a day. +/// +/// The days are arranged in a rectangular grid with one column for each day of +/// the week. +class _DayPicker extends StatefulWidget { + /// Creates a day picker. + _DayPicker({ + super.key, + required this.currentDate, + required this.displayedMonth, + required this.firstDate, + required this.lastDate, + required this.selectedDate, + required this.onChanged, + required this.calendarDelegate, + this.selectableDayPredicate, + }) : assert(!firstDate.isAfter(lastDate)), + assert(selectedDate == null || !selectedDate.isBefore(firstDate)), + assert(selectedDate == null || !selectedDate.isAfter(lastDate)); + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDate; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged<DateTime> onChanged; + + /// The earliest date the user is permitted to pick. + /// + /// This date must be on or before the [lastDate]. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + /// + /// This date must be on or after the [firstDate]. + final DateTime lastDate; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + /// Optional user supplied predicate function to customize selectable days. + final SelectableDayPredicate? selectableDayPredicate; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + _DayPickerState createState() => _DayPickerState(); +} + +class _DayPickerState extends State<_DayPicker> { + /// List of [FocusNode]s, one for each day of the month. + late List<FocusNode> _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth( + widget.displayedMonth.year, + widget.displayedMonth.month, + ); + _dayFocusNodes = List<FocusNode>.generate( + daysInMonth, + (int index) => FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime? focusedDate = _FocusedDate.maybeOf(context); + if (focusedDate != null && + widget.calendarDelegate.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S ← the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S ← the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// + List<Widget> _dayHeaders(TextStyle? headerStyle, MaterialLocalizations localizations) { + final result = <Widget>[]; + for ( + int i = localizations.firstDayOfWeekIndex; + result.length < DateTime.daysPerWeek; + i = (i + 1) % DateTime.daysPerWeek + ) { + final String weekday = localizations.narrowWeekdays[i]; + result.add( + ExcludeSemantics( + child: Center(child: Text(weekday, style: headerStyle)), + ), + ); + } + return result; + } + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final TextStyle? weekdayStyle = datePickerTheme.weekdayStyle ?? defaults.weekdayStyle; + + final Orientation orientation = MediaQuery.orientationOf(context); + final isLandscapeOrientation = orientation == Orientation.landscape; + + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; + + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(year, month); + final int dayOffset = widget.calendarDelegate.firstDayOffset(year, month, localizations); + + final List<Widget> dayItems = _dayHeaders(weekdayStyle, localizations); + // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on + // a leap year. + int day = -dayOffset; + while (day < daysInMonth) { + day++; + if (day < 1) { + dayItems.add(const SizedBox.shrink()); + } else { + final DateTime dayToBuild = widget.calendarDelegate.getDay(year, month, day); + final bool isDisabled = + dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate) || + (widget.selectableDayPredicate != null && !widget.selectableDayPredicate!(dayToBuild)); + final bool isSelectedDay = widget.calendarDelegate.isSameDay( + widget.selectedDate, + dayToBuild, + ); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, dayToBuild); + + dayItems.add( + _Day( + dayToBuild, + key: ValueKey<DateTime>(dayToBuild), + isDisabled: isDisabled, + isSelectedDay: isSelectedDay, + isToday: isToday, + onChanged: widget.onChanged, + focusNode: _dayFocusNodes[day - 1], + calendarDelegate: widget.calendarDelegate, + ), + ); + } + } + + final double monthPickerHorizontalPadding = + Theme.of(context).useMaterial3 && !isLandscapeOrientation + ? _monthPickerHorizontalPaddingPortraitM3 + : _monthPickerHorizontalPaddingOther; + return Padding( + padding: EdgeInsets.symmetric(horizontal: monthPickerHorizontalPadding), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: isLandscapeOrientation + ? _kDayPickerGridLandscapeMaxScaleFactor + : _kDayPickerGridPortraitMaxScaleFactor, + child: GridView.custom( + physics: const ClampingScrollPhysics(), + gridDelegate: _DayPickerGridDelegate(context), + childrenDelegate: SliverChildListDelegate(dayItems, addRepaintBoundaries: false), + ), + ), + ); + } +} + +class _Day extends StatefulWidget { + const _Day( + this.day, { + super.key, + required this.isDisabled, + required this.isSelectedDay, + required this.isToday, + required this.onChanged, + required this.focusNode, + required this.calendarDelegate, + }); + + final DateTime day; + final bool isDisabled; + final bool isSelectedDay; + final bool isToday; + final ValueChanged<DateTime> onChanged; + final FocusNode focusNode; + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<_Day> createState() => _DayState(); +} + +class _DayState extends State<_Day> { + final MaterialStatesController _statesController = MaterialStatesController(); + + @override + Widget build(BuildContext context) { + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final TextStyle? dayStyle = datePickerTheme.dayStyle ?? defaults.dayStyle; + T? effectiveValue<T>(T? Function(DatePickerThemeData? theme) getProperty) { + return getProperty(datePickerTheme) ?? getProperty(defaults); + } + + T? resolve<T>( + WidgetStateProperty<T>? Function(DatePickerThemeData? theme) getProperty, + Set<WidgetState> states, + ) { + return effectiveValue((DatePickerThemeData? theme) { + return getProperty(theme)?.resolve(states); + }); + } + + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final semanticLabelSuffix = widget.isToday ? ', ${localizations.currentDateLabel}' : ''; + + final states = <WidgetState>{ + if (widget.isDisabled) WidgetState.disabled, + if (widget.isSelectedDay) WidgetState.selected, + }; + + _statesController.value = states; + + final Color? dayForegroundColor = resolve<Color?>( + (DatePickerThemeData? theme) => + widget.isToday ? theme?.todayForegroundColor : theme?.dayForegroundColor, + states, + ); + final Color? dayBackgroundColor = resolve<Color?>( + (DatePickerThemeData? theme) => + widget.isToday ? theme?.todayBackgroundColor : theme?.dayBackgroundColor, + states, + ); + final WidgetStateProperty<Color?> dayOverlayColor = WidgetStateProperty.resolveWith<Color?>( + (Set<WidgetState> states) => + effectiveValue((DatePickerThemeData? theme) => theme?.dayOverlayColor?.resolve(states)), + ); + final OutlinedBorder dayShape = resolve<OutlinedBorder?>( + (DatePickerThemeData? theme) => theme?.dayShape, + states, + )!; + final bool hasCustomBorderColor = + datePickerTheme.todayBorder != null && datePickerTheme.todayBorder!.color.opacity != 0.0; + final BorderSide todayBorderSide = hasCustomBorderColor + ? datePickerTheme.todayBorder! + : (datePickerTheme.todayBorder ?? defaults.todayBorder!).copyWith( + color: dayForegroundColor, + ); + final decoration = widget.isToday + ? ShapeDecoration( + color: dayBackgroundColor, + shape: dayShape.copyWith(side: todayBorderSide), + ) + : ShapeDecoration(color: dayBackgroundColor, shape: dayShape); + + Widget dayWidget = Ink( + decoration: decoration, + child: Center( + child: Text( + localizations.formatDecimal(widget.day.day), + style: dayStyle?.apply(color: dayForegroundColor), + ), + ), + ); + + // Adds padding as per M3 guidelines for portrait mode. Not applied in landscape + // mode currently due to unclear specifications. + final Orientation orientation = MediaQuery.orientationOf(context); + if (Theme.of(context).useMaterial3 && orientation == Orientation.portrait) { + dayWidget = Padding(padding: const EdgeInsets.all(4.0), child: dayWidget); + } + dayWidget = Semantics( + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + label: + '${localizations.formatDecimal(widget.day.day)}, ${widget.calendarDelegate.formatFullDate(widget.day, localizations)}$semanticLabelSuffix', + // Set button to true to make the date selectable. + button: true, + selected: widget.isSelectedDay, + enabled: !widget.isDisabled, + excludeSemantics: true, + child: dayWidget, + ); + + if (!widget.isDisabled) { + dayWidget = InkResponse( + focusNode: widget.focusNode, + onTap: () => widget.onChanged(widget.day), + statesController: _statesController, + overlayColor: dayOverlayColor, + customBorder: dayShape, + containedInkWell: true, + child: dayWidget, + ); + } + + return dayWidget; + } + + @override + void dispose() { + _statesController.dispose(); + super.dispose(); + } +} + +class _DayPickerGridDelegate extends SliverGridDelegate { + const _DayPickerGridDelegate(this.context); + + final BuildContext context; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double textScaleFactor = + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 3.0).scale(_fontSizeToScale) / + _fontSizeToScale; + // Conform to M3 spec in portrait mode (landscape mode is not specified). + final Orientation orientation = MediaQuery.orientationOf(context); + final double dayPickerRowHeight = + Theme.of(context).useMaterial3 && orientation == Orientation.portrait + ? _dayPickerRowHeightM3 + : _dayPickerRowHeightM2; + final double scaledRowHeight = textScaleFactor > 1.3 + ? ((textScaleFactor - 1) * 30) + dayPickerRowHeight + : dayPickerRowHeight; + const int columnCount = DateTime.daysPerWeek; + final double tileWidth = constraints.crossAxisExtent / columnCount; + final double tileHeight = math.min( + scaledRowHeight, + constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1), + ); + return SliverGridRegularTileLayout( + childCrossAxisExtent: tileWidth, + childMainAxisExtent: tileHeight, + crossAxisCount: columnCount, + crossAxisStride: tileWidth, + mainAxisStride: tileHeight, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_DayPickerGridDelegate oldDelegate) => false; +} + +/// A scrollable grid of years to allow picking a year. +/// +/// The year picker widget is rarely used directly. Instead, consider using +/// [CalendarDatePicker], or [showDatePicker] which create full date pickers. +/// +/// See also: +/// +/// * [CalendarDatePicker], which provides a Material Design date picker +/// interface. +/// +/// * [showDatePicker], which shows a dialog containing a Material Design +/// date picker. +/// +class YearPicker extends StatefulWidget { + /// Creates a year picker. + /// + /// The [lastDate] must be after the [firstDate]. + YearPicker({ + super.key, + DateTime? currentDate, + required this.firstDate, + required this.lastDate, + @Deprecated( + 'This parameter has no effect and can be removed. Previously it controlled ' + 'the month that was used in "onChanged" when a new year was selected, but ' + 'now that role is filled by "selectedDate" instead. ' + 'This feature was deprecated after v3.13.0-0.3.pre.', + ) + DateTime? initialDate, + required this.selectedDate, + required this.onChanged, + this.dragStartBehavior = DragStartBehavior.start, + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : assert(!firstDate.isAfter(lastDate)), + currentDate = calendarDelegate.dateOnly(currentDate ?? DateTime.now()); + + /// The current date. + /// + /// This date is subtly highlighted in the picker. + final DateTime currentDate; + + /// The earliest date the user is permitted to pick. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + final DateTime lastDate; + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDate; + + /// Called when the user picks a year. + final ValueChanged<DateTime> onChanged; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<YearPicker> createState() => _YearPickerState(); +} + +class _YearPickerState extends State<YearPicker> { + ScrollController? _scrollController; + final MaterialStatesController _statesController = MaterialStatesController(); + + // The approximate number of years necessary to fill the available space. + static const int minYears = 18; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController( + initialScrollOffset: _scrollOffsetForYear(widget.selectedDate ?? widget.firstDate), + ); + } + + @override + void dispose() { + _scrollController?.dispose(); + _statesController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(YearPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selectedDate != oldWidget.selectedDate && widget.selectedDate != null) { + _scrollController!.jumpTo(_scrollOffsetForYear(widget.selectedDate!)); + } + } + + double _scrollOffsetForYear(DateTime date) { + final int initialYearIndex = date.year - widget.firstDate.year; + final int initialYearRow = initialYearIndex ~/ _yearPickerColumnCount; + // Move the offset down by 2 rows to approximately center it. + final int centeredYearRow = initialYearRow - 2; + return _itemCount < minYears ? 0 : centeredYearRow * _yearPickerRowHeight; + } + + Widget _buildYearItem(BuildContext context, int index) { + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + + T? effectiveValue<T>(T? Function(DatePickerThemeData? theme) getProperty) { + return getProperty(datePickerTheme) ?? getProperty(defaults); + } + + T? resolve<T>( + WidgetStateProperty<T>? Function(DatePickerThemeData? theme) getProperty, + Set<WidgetState> states, + ) { + return effectiveValue((DatePickerThemeData? theme) { + return getProperty(theme)?.resolve(states); + }); + } + + final double textScaleFactor = + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 3.0).scale(_fontSizeToScale) / + _fontSizeToScale; + + // Backfill the _YearPicker with disabled years if necessary. + final int offset = _itemCount < minYears ? (minYears - _itemCount) ~/ 2 : 0; + final int year = widget.firstDate.year + index - offset; + final isSelected = year == widget.selectedDate?.year; + final isCurrentYear = year == widget.currentDate.year; + final bool isDisabled = year < widget.firstDate.year || year > widget.lastDate.year; + final double decorationHeight = 36.0 * textScaleFactor; + final double decorationWidth = 72.0 * textScaleFactor; + + final states = <WidgetState>{ + if (isDisabled) WidgetState.disabled, + if (isSelected) WidgetState.selected, + }; + + final Color? textColor = resolve<Color?>( + (DatePickerThemeData? theme) => + isCurrentYear ? theme?.todayForegroundColor : theme?.yearForegroundColor, + states, + ); + final Color? background = resolve<Color?>( + (DatePickerThemeData? theme) => + isCurrentYear ? theme?.todayBackgroundColor : theme?.yearBackgroundColor, + states, + ); + final WidgetStateProperty<Color?> overlayColor = WidgetStateProperty.resolveWith<Color?>( + (Set<WidgetState> states) => + effectiveValue((DatePickerThemeData? theme) => theme?.yearOverlayColor?.resolve(states)), + ); + + final OutlinedBorder yearShape = resolve<OutlinedBorder?>( + (DatePickerThemeData? theme) => theme?.yearShape, + states, + )!; + + BorderSide? borderSide; + if (isCurrentYear) { + borderSide = datePickerTheme.todayBorder ?? defaults.todayBorder; + if (borderSide != null) { + borderSide = borderSide.copyWith(color: textColor); + } + } + final decoration = ShapeDecoration( + color: background, + shape: yearShape.copyWith(side: borderSide), + ); + + final TextStyle? itemStyle = (datePickerTheme.yearStyle ?? defaults.yearStyle)?.apply( + color: textColor, + ); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + Widget yearItem = Center( + child: Container( + decoration: decoration, + height: decorationHeight, + width: decorationWidth, + alignment: Alignment.center, + child: Semantics( + selected: isSelected, + enabled: !isDisabled, + button: true, + child: Text(widget.calendarDelegate.formatYear(year, localizations), style: itemStyle), + ), + ), + ); + + if (!isDisabled) { + DateTime date = widget.calendarDelegate.getMonth( + year, + widget.selectedDate?.month ?? DateTime.january, + ); + if (date.isBefore( + widget.calendarDelegate.getMonth(widget.firstDate.year, widget.firstDate.month), + )) { + // Ignore firstDate.day because we're just working in years and months here. + assert(date.year == widget.firstDate.year); + date = widget.calendarDelegate.getMonth(year, widget.firstDate.month); + } else if (date.isAfter(widget.lastDate)) { + // No need to ignore the day here because it can only be bigger than what we care about. + assert(date.year == widget.lastDate.year); + date = widget.calendarDelegate.getMonth(year, widget.lastDate.month); + } + _statesController.value = states; + yearItem = InkWell( + key: ValueKey<int>(year), + onTap: () => widget.onChanged(date), + statesController: _statesController, + overlayColor: overlayColor, + child: yearItem, + ); + } + + return yearItem; + } + + int get _itemCount { + return widget.lastDate.year - widget.firstDate.year + 1; + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + const Divider(), + Expanded( + child: Material( + type: MaterialType.transparency, + child: GridView.builder( + controller: _scrollController, + dragStartBehavior: widget.dragStartBehavior, + gridDelegate: _YearPickerGridDelegate(context), + itemBuilder: _buildYearItem, + itemCount: math.max(_itemCount, minYears), + padding: const EdgeInsets.symmetric(horizontal: _yearPickerPadding), + ), + ), + ), + const Divider(), + ], + ); + } +} + +class _YearPickerGridDelegate extends SliverGridDelegate { + const _YearPickerGridDelegate(this.context); + + final BuildContext context; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double textScaleFactor = + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 3.0).scale(_fontSizeToScale) / + _fontSizeToScale; + final int scaledYearPickerColumnCount = textScaleFactor > 1.65 + ? _yearPickerColumnCount - 1 + : _yearPickerColumnCount; + final double tileWidth = math.max( + (constraints.crossAxisExtent - (scaledYearPickerColumnCount - 1) * _yearPickerRowSpacing) / + scaledYearPickerColumnCount, + 0.0, + ); + final double scaledYearPickerRowHeight = textScaleFactor > 1 + ? _yearPickerRowHeight + ((textScaleFactor - 1) * 9) + : _yearPickerRowHeight; + return SliverGridRegularTileLayout( + childCrossAxisExtent: tileWidth, + childMainAxisExtent: scaledYearPickerRowHeight, + crossAxisCount: scaledYearPickerColumnCount, + crossAxisStride: tileWidth + _yearPickerRowSpacing, + mainAxisStride: scaledYearPickerRowHeight, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_YearPickerGridDelegate oldDelegate) => false; +} + +void _reportAnnouncementError(Object exception, StackTrace stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'material library', + context: ErrorDescription('while sending semantics announcement'), + ), + ); +} diff --git a/packages/material_ui/lib/src/card.dart b/packages/material_ui/lib/src/card.dart new file mode 100644 index 000000000000..9f9fac3783f0 --- /dev/null +++ b/packages/material_ui/lib/src/card.dart @@ -0,0 +1,399 @@ +// 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. + +/// @docImport 'dialog.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'list_tile.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'card_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'material.dart'; +import 'theme.dart'; + +enum _CardVariant { elevated, filled, outlined } + +/// A Material Design card: a panel with slightly rounded corners and an +/// elevation shadow. +/// +/// A card is a sheet of [Material] used to represent some related information, +/// for example an album, a geographical location, a meal, contact details, etc. +/// +/// This is what it looks like when run: +/// +/// ![A card with a slight shadow, consisting of two rows, one with an icon and +/// some text describing a musical, and the other with buttons for buying +/// tickets or listening to the show.](https://flutter.github.io/assets-for-api-docs/assets/material/card.png) +/// +/// {@tool dartpad} +/// This sample shows creation of a [Card] widget that shows album information +/// and two actions. +/// +/// ** See code in examples/api/lib/material/card/card.0.dart ** +/// {@end-tool} +/// +/// Sometimes the primary action area of a card is the card itself. Cards can be +/// one large touch target that shows a detail screen when tapped. +/// +/// {@tool dartpad} +/// This sample shows creation of a [Card] widget that can be tapped. When +/// tapped this [Card]'s [InkWell] displays an "ink splash" that fills the +/// entire card. +/// +/// ** See code in examples/api/lib/material/card/card.1.dart ** +/// {@end-tool} +/// +/// For Material Design 2 (when [ThemeData.useMaterial3] is false), there is a +/// single card type: the elevated card. In that mode the named constructors +/// ([Card.filled], [Card.outlined]) behave the same as the default [Card]. +/// +/// For Material Design 3 (when [ThemeData.useMaterial3] is true), three visual +/// variants are available: the default [Card] (elevated), [Card.filled], and +/// [Card.outlined]. All variants share the same theme class, [CardThemeData], +/// so theme properties (for example [CardThemeData.shape]) apply to every card +/// variant within the theme's scope. +/// +/// {@tool dartpad} +/// This sample shows creation of [Card] widgets for elevated, filled and +/// outlined types, as described in: https://m3.material.io/components/cards/overview +/// +/// ** See code in examples/api/lib/material/card/card.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListTile], to display icons and text in a card. +/// * [showDialog], to display a modal card. +/// * <https://material.io/design/components/cards.html> +/// * <https://m3.material.io/components/cards> +class Card extends StatelessWidget { + /// Creates an elevated variant of Card. + /// + /// Elevated cards have a drop shadow, providing more separation from the + /// background than filled cards, but less than outlined cards. + /// + /// The [elevation] must be null or non-negative. + const Card({ + super.key, + this.color, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.shape, + this.borderOnForeground = true, + this.margin, + this.clipBehavior, + this.child, + this.semanticContainer = true, + }) : assert(elevation == null || elevation >= 0.0), + _variant = _CardVariant.elevated; + + /// Create a filled variant of Card. + /// + /// Filled cards provide subtle separation from the background. This has less + /// emphasis than elevated cards (the default) or outlined cards. + /// + /// If [ThemeData.useMaterial3] is false, this constructor is equivalent to + /// the default constructor of [Card]. + const Card.filled({ + super.key, + this.color, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.shape, + this.borderOnForeground = true, + this.margin, + this.clipBehavior, + this.child, + this.semanticContainer = true, + }) : assert(elevation == null || elevation >= 0.0), + _variant = _CardVariant.filled; + + /// Create an outlined variant of Card. + /// + /// Outlined cards have a visual boundary around the container. This can + /// provide greater emphasis than the other types. + /// + /// The card's outline is defined by the [shape] property. By default, the + /// card uses a [RoundedRectangleBorder] with a 12.0 corner radius, a 1.0 + /// border width, and the color from [ColorScheme.outlineVariant]. If you + /// provide a custom [shape], it is recommended to use an [OutlinedBorder] + /// with a non-null [OutlinedBorder.side] to keep a visible outline. + /// + /// If [ThemeData.useMaterial3] is false, this constructor is equivalent to + /// the default constructor of [Card]. + const Card.outlined({ + super.key, + this.color, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.shape, + this.borderOnForeground = true, + this.margin, + this.clipBehavior, + this.child, + this.semanticContainer = true, + }) : assert(elevation == null || elevation >= 0.0), + _variant = _CardVariant.outlined; + + /// The card's background color. + /// + /// Defines the card's [Material.color]. + /// + /// If this property is null then the ambient [CardTheme.color] is used. If that is null, + /// and [ThemeData.useMaterial3] is true, then [ColorScheme.surfaceContainerLow] of + /// [ThemeData.colorScheme] is used. Otherwise, [ThemeData.cardColor] is used. + final Color? color; + + /// The color to paint the shadow below the card. + /// + /// If null then the ambient [CardThemeData.shadowColor] is used. + /// If that's null too, then the overall theme's [ThemeData.shadowColor] + /// (default black) is used. + final Color? shadowColor; + + /// The color used as an overlay on [color] to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If this is null, no overlay will be applied. Otherwise this color + /// will be composited on top of [color] with an opacity related + /// to [elevation] and used to paint the background of the card. + /// + /// The default is [Colors.transparent]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// The z-coordinate at which to place this card. This controls the size of + /// the shadow below the card. + /// + /// Defines the card's [Material.elevation]. + /// + /// If this property is null then the ambient [CardThemeData.elevation] is + /// used. If that's null, the default value is 1.0. + final double? elevation; + + /// The shape of the card's [Material]. + /// + /// Defines the card's [Material.shape]. + /// + /// If null, the ambient [CardTheme.shape] from [ThemeData.cardTheme] is used. + /// If that is also null, the shape defaults to a [RoundedRectangleBorder]. + /// The default corner radius is 12.0 when [ThemeData.useMaterial3] is true, + /// and 4.0 otherwise. For Material 3 outlined cards, the default [shape] also + /// includes a border side (see [OutlinedBorder.side]). + final ShapeBorder? shape; + + /// Whether to paint the [shape] border in front of the [child]. + /// + /// The default value is true. + /// If false, the border will be painted behind the [child]. + final bool borderOnForeground; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// If this property is null then the ambient [CardThemeData.clipBehavior] is + /// used. If that's null then the behavior will be [Clip.none]. + final Clip? clipBehavior; + + /// The empty space that surrounds the card. + /// + /// Defines the card's outer [Container.margin]. + /// + /// If this property is null then the ambient [CardThemeData.margin] is used. + /// If that's null, the default margin is 4.0 logical pixels on + /// all sides: `EdgeInsets.all(4.0)`. + final EdgeInsetsGeometry? margin; + + /// Whether this widget represents a single semantic container, or if false + /// a collection of individual semantic nodes. + /// + /// Defaults to true. + /// + /// Setting this flag to true will attempt to merge all child semantics into + /// this node. Setting this flag to false will force all child semantic nodes + /// to be explicit. + /// + /// This flag should be false if the card contains multiple different types + /// of content. + final bool semanticContainer; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + final _CardVariant _variant; + + @override + Widget build(BuildContext context) { + final CardThemeData cardTheme = CardTheme.of(context); + final CardThemeData defaults; + if (Theme.of(context).useMaterial3) { + defaults = switch (_variant) { + _CardVariant.elevated => _CardDefaultsM3(context), + _CardVariant.filled => _FilledCardDefaultsM3(context), + _CardVariant.outlined => _OutlinedCardDefaultsM3(context), + }; + } else { + defaults = _CardDefaultsM2(context); + } + + return Semantics( + container: semanticContainer, + child: Padding( + padding: margin ?? cardTheme.margin ?? defaults.margin!, + child: Material( + type: MaterialType.card, + color: color ?? cardTheme.color ?? defaults.color, + shadowColor: shadowColor ?? cardTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: + surfaceTintColor ?? cardTheme.surfaceTintColor ?? defaults.surfaceTintColor, + elevation: elevation ?? cardTheme.elevation ?? defaults.elevation!, + shape: shape ?? cardTheme.shape ?? defaults.shape, + borderOnForeground: borderOnForeground, + clipBehavior: clipBehavior ?? cardTheme.clipBehavior ?? defaults.clipBehavior!, + child: Semantics(explicitChildNodes: !semanticContainer, child: child), + ), + ), + ); + } +} + +// Hand coded defaults based on Material Design 2. +class _CardDefaultsM2 extends CardThemeData { + const _CardDefaultsM2(this.context) + : super( + clipBehavior: Clip.none, + elevation: 1.0, + margin: const EdgeInsets.all(4.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + final BuildContext context; + + @override + Color? get color => Theme.of(context).cardColor; + + @override + Color? get shadowColor => Theme.of(context).shadowColor; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Card + +// 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. + +// dart format off +class _CardDefaultsM3 extends CardThemeData { + _CardDefaultsM3(this.context) + : super( + clipBehavior: Clip.none, + elevation: 1.0, + margin: const EdgeInsets.all(4.0), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get color => _colors.surfaceContainerLow; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape =>const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Card + +// BEGIN GENERATED TOKEN PROPERTIES - FilledCard + +// 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. + +// dart format off +class _FilledCardDefaultsM3 extends CardThemeData { + _FilledCardDefaultsM3(this.context) + : super( + clipBehavior: Clip.none, + elevation: 0.0, + margin: const EdgeInsets.all(4.0), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get color => _colors.surfaceContainerHighest; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape =>const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - FilledCard + +// BEGIN GENERATED TOKEN PROPERTIES - OutlinedCard + +// 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. + +// dart format off +class _OutlinedCardDefaultsM3 extends CardThemeData { + _OutlinedCardDefaultsM3(this.context) + : super( + clipBehavior: Clip.none, + elevation: 0.0, + margin: const EdgeInsets.all(4.0), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get color => _colors.surface; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape => + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))).copyWith( + side: BorderSide(color: _colors.outlineVariant) + ); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - OutlinedCard diff --git a/packages/material_ui/lib/src/card_theme.dart b/packages/material_ui/lib/src/card_theme.dart new file mode 100644 index 000000000000..02833700f79e --- /dev/null +++ b/packages/material_ui/lib/src/card_theme.dart @@ -0,0 +1,340 @@ +// 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. + +/// @docImport 'card.dart'; +/// @docImport 'material.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Card] widgets. +/// +/// Descendant widgets obtain the current [CardThemeData] object using +/// [CardTheme.of]. Instances of [CardThemeData] can be +/// customized with [CardThemeData.copyWith]. +/// +/// Typically a [CardThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.cardTheme]. +/// +/// All [CardThemeData] properties are `null` by default. When null, the [Card] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +class CardTheme extends InheritedWidget with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.cardTheme]. + /// + /// The [elevation] must be null or non-negative. + const CardTheme({ + super.key, + Clip? clipBehavior, + Color? color, + Color? surfaceTintColor, + Color? shadowColor, + double? elevation, + EdgeInsetsGeometry? margin, + ShapeBorder? shape, + CardThemeData? data, + Widget? child, + }) : assert( + data == null || + (clipBehavior ?? + color ?? + surfaceTintColor ?? + shadowColor ?? + elevation ?? + margin ?? + shape) == + null, + ), + assert(elevation == null || elevation >= 0.0), + _data = data, + _clipBehavior = clipBehavior, + _color = color, + _surfaceTintColor = surfaceTintColor, + _shadowColor = shadowColor, + _elevation = elevation, + _margin = margin, + _shape = shape, + super(child: child ?? const SizedBox()); + + final CardThemeData? _data; + final Clip? _clipBehavior; + final Color? _color; + final Color? _surfaceTintColor; + final Color? _shadowColor; + final double? _elevation; + final EdgeInsetsGeometry? _margin; + final ShapeBorder? _shape; + + /// Overrides the default value for [Card.clipBehavior]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.clipBehavior] property in [data] instead. + Clip? get clipBehavior => _data != null ? _data.clipBehavior : _clipBehavior; + + /// Overrides the default value for [Card.color]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.color] property in [data] instead. + Color? get color => _data != null ? _data.color : _color; + + /// Overrides the default value for [Card.surfaceTintColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.surfaceTintColor] property in [data] instead. + Color? get surfaceTintColor => _data != null ? _data.surfaceTintColor : _surfaceTintColor; + + /// Overrides the default value for [Card.shadowColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.shadowColor] property in [data] instead. + Color? get shadowColor => _data != null ? _data.shadowColor : _shadowColor; + + /// Overrides the default value for [Card.elevation]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.elevation] property in [data] instead. + double? get elevation => _data != null ? _data.elevation : _elevation; + + /// Overrides the default value for [Card.margin]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.margin] property in [data] instead. + EdgeInsetsGeometry? get margin => _data != null ? _data.margin : _margin; + + /// Overrides the default value for [Card.shape]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.shape] property in [data] instead. + ShapeBorder? get shape => _data != null ? _data.shape : _shape; + + /// The properties used for all descendant [Card] widgets. + CardThemeData get data { + return _data ?? + CardThemeData( + clipBehavior: _clipBehavior, + color: _color, + surfaceTintColor: _surfaceTintColor, + shadowColor: _shadowColor, + elevation: _elevation, + margin: _margin, + shape: _shape, + ); + } + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.copyWith] instead. + CardTheme copyWith({ + Clip? clipBehavior, + Color? color, + Color? shadowColor, + Color? surfaceTintColor, + double? elevation, + EdgeInsetsGeometry? margin, + ShapeBorder? shape, + }) { + return CardTheme( + clipBehavior: clipBehavior ?? this.clipBehavior, + color: color ?? this.color, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + elevation: elevation ?? this.elevation, + margin: margin ?? this.margin, + shape: shape ?? this.shape, + ); + } + + /// Returns the configuration [data] from the closest [CardTheme] ancestor. + /// + /// If there is no ancestor, it returns [ThemeData.cardTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// CardThemeData theme = CardTheme.of(context); + /// ``` + static CardThemeData of(BuildContext context) { + final CardTheme? cardTheme = context.dependOnInheritedWidgetOfExactType<CardTheme>(); + return cardTheme?.data ?? Theme.of(context).cardTheme; + } + + @override + bool updateShouldNotify(CardTheme oldWidget) => data != oldWidget.data; + + /// Linearly interpolate between two Card themes. + /// + /// {@macro dart.ui.shadow.lerp} + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [CardThemeData.lerp] instead. + static CardTheme lerp(CardTheme? a, CardTheme? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return CardTheme( + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + color: Color.lerp(a?.color, b?.color, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + margin: EdgeInsetsGeometry.lerp(a?.margin, b?.margin, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<double>('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + } +} + +/// Defines default property values for descendant [Card] widgets. +/// +/// Descendant widgets obtain the current [CardThemeData] object using +/// `CardTheme.of(context)`. Instances of [CardThemeData] can be +/// customized with [CardThemeData.copyWith]. +/// +/// Typically a [CardThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.cardTheme]. +/// +/// All [CardThemeData] properties are `null` by default. When null, the [Card] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. See the individual [Card] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class CardThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.cardTheme]. + /// + /// The [elevation] must be null or non-negative. + const CardThemeData({ + this.clipBehavior, + this.color, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.margin, + this.shape, + }) : assert(elevation == null || elevation >= 0.0); + + /// Overrides the default value for [Card.clipBehavior]. + final Clip? clipBehavior; + + /// Overrides the default value for [Card.color]. + final Color? color; + + /// Overrides the default value for [Card.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value for [Card.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value for [Card.elevation]. + final double? elevation; + + /// Overrides the default value for [Card.margin]. + final EdgeInsetsGeometry? margin; + + /// Overrides the default value for [Card.shape]. + final ShapeBorder? shape; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + CardThemeData copyWith({ + Clip? clipBehavior, + Color? color, + Color? shadowColor, + Color? surfaceTintColor, + double? elevation, + EdgeInsetsGeometry? margin, + ShapeBorder? shape, + }) { + return CardThemeData( + clipBehavior: clipBehavior ?? this.clipBehavior, + color: color ?? this.color, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + elevation: elevation ?? this.elevation, + margin: margin ?? this.margin, + shape: shape ?? this.shape, + ); + } + + /// Linearly interpolate between two Card themes. + /// + /// {@macro dart.ui.shadow.lerp} + static CardThemeData lerp(CardThemeData? a, CardThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return CardThemeData( + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + color: Color.lerp(a?.color, b?.color, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + margin: EdgeInsetsGeometry.lerp(a?.margin, b?.margin, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + ); + } + + @override + int get hashCode => + Object.hash(clipBehavior, color, shadowColor, surfaceTintColor, elevation, margin, shape); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is CardThemeData && + other.clipBehavior == clipBehavior && + other.color == color && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.elevation == elevation && + other.margin == margin && + other.shape == shape; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<double>('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + } +} diff --git a/packages/material_ui/lib/src/carousel.dart b/packages/material_ui/lib/src/carousel.dart new file mode 100644 index 000000000000..f4038228acc0 --- /dev/null +++ b/packages/material_ui/lib/src/carousel.dart @@ -0,0 +1,2088 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +library; + +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'carousel_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// A Material Design carousel widget. +/// +/// The [CarouselView] presents a scrollable list of items, each of which can dynamically +/// change size based on the chosen layout. +/// +/// When [infinite] is true, the carousel will create an infinite loop of items, +/// allowing continuous scrolling in both directions. +/// +/// Material Design 3 introduced 4 carousel layouts: +/// * Multi-browse: This layout shows at least one large, medium, and small +/// carousel item at a time. This layout is supported by [CarouselView.weighted]. +/// * Uncontained (default): This layout show items that scroll to the edge of the +/// container. This layout is supported by [CarouselView]. +/// * Hero: This layout shows at least one large and one small item at a time. +/// This layout is supported by [CarouselView.weighted]. +/// * Full-screen: This layout shows one edge-to-edge large item at a time and +/// scrolls vertically. The full-screen layout can be supported by both +/// constructors. +/// +/// The default constructor implements the uncontained layout model. It shows +/// items that scroll to the edge of the container, behaving similarly to a +/// [ListView] where all children are a uniform size. [CarouselView.weighted] +/// enables dynamic item sizing. Each item is assigned a weight that determines +/// the portion of the viewport it occupies. This constructor helps to create +/// layouts like multi-browse, and hero. In order to have a full-screen layout, +/// if [CarouselView] is used, then set the [itemExtent] to screen size; if +/// [CarouselView.weighted] is used, then set the [flexWeights] to only have +/// one integer in the array. +/// +/// {@tool snippet} +/// +/// This code snippet shows how to get a vertical full-screen carousel by using +/// [itemExtent] in [CarouselView]. +/// +/// ```dart +/// Scaffold( +/// body: CarouselView( +/// scrollDirection: Axis.vertical, +/// itemExtent: double.infinity, +/// children: List<Widget>.generate(10, (int index) { +/// return Center(child: Text('Item $index')); +/// }), +/// ), +/// ), +/// ``` +/// +/// This code snippet below shows how to achieve the same vertical full-screen +/// carousel by using [flexWeights] in [CarouselView.weighted]. +/// +/// ```dart +/// Scaffold( +/// body: CarouselView.weighted( +/// scrollDirection: Axis.vertical, +/// flexWeights: const <int>[1], // Or any positive integers as long as the length of the array is 1. +/// children: List<Widget>.generate(10, (int index) { +/// return Center(child: Text('Item $index')); +/// }), +/// ), +/// ), +/// ``` +/// {@end-tool} +/// +/// In [CarouselView.weighted], weights are relative proportions. For example, +/// if the layout weights is `[3, 2, 1]`, it means the first visible item occupies +/// 3/6 of the viewport; the second visible item occupies 2/6 of the viewport; +/// the last visible item occupies 1/6 of the viewport. As the carousel scrolls, +/// the size of the latter one gradually changes to the size of the former one. +/// As a result, when the first visible item is completely off-screen, the +/// following items will follow the same layout as before. Using [CarouselView.weighted] +/// helps build the multi-browse, hero, center-aligned hero and full-screen layouts, +/// as indicated in [Carousel specs](https://m3.material.io/components/carousel/specs). +/// +/// The [CarouselController] is used to control the +/// [CarouselController.initialItem], which determines the first fully expanded +/// item when the [CarouselView] or [CarouselView.weighted] is initially displayed. +/// This is straightforward for [CarouselView] because each item in the view +/// has fixed size. In [CarouselView.weighted], for instance, if the layout +/// weights are `[1, 2, 3, 2, 1]` and the initial item is 4 (the fourth item), the +/// view will display items 2, 3, 4, 5, and 6 with weights 1, 2, 3, 2 and 1 +/// respectively. +/// +/// The [CarouselView.itemExtent] property must be non-null and defines the base +/// size of items. While items typically maintain this size, the first and last +/// visible items may be slightly compressed during scrolling. The [shrinkExtent] +/// property controls the minimum allowable size for these compressed items. +/// +/// {@tool dartpad} +/// Here is an example to show different carousel layouts that [CarouselView] +/// and [CarouselView.weighted] can build. +/// +/// On desktop and web running on desktop platforms, dragging to scroll with a mouse +/// is disabled by default to align with natural behavior. +/// +/// To further align expected behavior like this, mouse input can scroll horizontally +/// by pressing the shift key while scrolling with the mouse wheel. +/// +/// This key-driven behavior is dictated by the [ScrollBehavior.pointerAxisModifiers], +/// while [ScrollBehavior.dragDevices] manages what devices can drag a scrollable. +/// +/// ** See code in examples/api/lib/material/carousel/carousel.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CarouselController], which controls the first fully visible item in the +/// view. +/// * [PageView], which is a scrollable list that works page by page. +class CarouselView extends StatefulWidget { + /// Creates a Material Design carousel. + const CarouselView({ + super.key, + this.padding, + this.backgroundColor, + this.elevation, + this.shape, + this.itemClipBehavior, + this.overlayColor, + this.itemSnapping = false, + this.shrinkExtent = 0.0, + this.controller, + this.scrollDirection = Axis.horizontal, + this.reverse = false, + this.onTap, + this.enableSplash = true, + this.infinite = false, + required double this.itemExtent, + required this.children, + this.onIndexChanged, + }) : consumeMaxWeight = true, + flexWeights = null, + itemBuilder = null, + itemCount = null; + + /// Creates a scrollable list where the size of each child widget is dynamically + /// determined by the provided [flexWeights]. + /// + /// The [flexWeights] parameter is required and defines the relative size + /// proportions of each child widget. + /// + /// While scrolling, the main-axis extent (size) of each visible item changes + /// dynamically based on the scrolling progress. The cross-axis extent is determined + /// by the parent constraints. As the first visible item scrolls completely + /// off-screen, the next item becomes the first visible item, and has the same + /// size as the previously first item. The rest of the visible items maintain + /// their relative layout. + /// + /// For example, if the layout weights are `[1, 6, 1]`, the length of [flexWeights] + /// indicates three items will be visible at a time. The layout of these items + /// would be: + /// * First item: Extent is (1 / (1 + 6 + 1)) * viewport extent. + /// * Second item: Extent is (6 / (1 + 6 + 1)) * viewport extent. + /// * Third item: Extent is (1 / (1 + 6 + 1)) * viewport extent. + /// + /// Assuming a viewport extent of 800 in the main axis and the first item is + /// item 0, there would be three visible items with extents of 100, 600, and 100. + /// As item 0 scrolls off-screen, the extent of item 1 smoothly decreases from 600 + /// to 100. For instance, if item 0 is 30% off-screen, item 1 should have decreased + /// its size to 30% of the difference from 600 to 100; its extent would be + /// 600 - 0.3 * (600 - 100). Similarly, item 2's extent would increase from 100 + /// to 600, becoming 100 + 0.3 * (600 - 100). + /// + /// As the initially visible items change size during scrolling, item 3 enters + /// the view to fill the remaining space. Its extent starts at a minimum of + /// [shrinkExtent] (or 0 if [shrinkExtent] is not provided) and gradually + /// increases to match the extent of the last visible item (100 in this example). + /// + /// When [consumeMaxWeight] is set to `true`, each child can be expanded to occupy + /// the maximum weight while scrolling. For example, with [flexWeights] of `[1, 7, 1]`, + /// the initial weight of the first item is 1. However, by enabling + /// [consumeMaxWeight] and scrolling forward, the first item can expand to occupy + /// a weight of 7, leaving a weight of 1 as some empty space before it. This feature + /// is particularly useful for achieving [Hero](https://m3.material.io/components/carousel/specs#b33a5579-d648-42a9-b934-98718d65454f) + /// and [Center-aligned hero](https://m3.material.io/components/carousel/specs#92c779ce-de8b-4dee-8201-95d3e429204f) + /// layouts indicated in the Material Design 3. + const CarouselView.weighted({ + super.key, + this.padding, + this.backgroundColor, + this.elevation, + this.shape, + this.itemClipBehavior, + this.overlayColor, + this.itemSnapping = false, + this.shrinkExtent = 0.0, + this.controller, + this.scrollDirection = Axis.horizontal, + this.reverse = false, + this.consumeMaxWeight = true, + this.onTap, + this.enableSplash = true, + this.infinite = false, + required List<int> this.flexWeights, + required this.children, + this.onIndexChanged, + }) : itemExtent = null, + itemBuilder = null, + itemCount = null; + + /// Creates a scrollable carousel with fixed-sized items created on demand. + /// + /// This constructor allows lazy loading of carousel items. Only items that + /// are visible (or about to be visible) are built, improving performance + /// when dealing with large numbers of items. + /// + /// The [itemBuilder] callback will be called only with indices greater than + /// or equal to zero and less than [itemCount]. + /// + /// {@tool dartpad} + /// This example shows how to create a carousel with 1000 items using lazy loading: + /// + /// ** See code in examples/api/lib/material/carousel/carousel.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [CarouselView.new], which creates a carousel with explicit children. + /// * [CarouselView.weighted], which creates a carousel with weighted items. + /// * [CarouselView.weightedBuilder], which creates a carousel with weighted + /// items using lazy loading. + const CarouselView.builder({ + super.key, + this.padding, + this.backgroundColor, + this.elevation, + this.shape, + this.itemClipBehavior, + this.overlayColor, + this.itemSnapping = false, + this.shrinkExtent = 0.0, + this.controller, + this.scrollDirection = Axis.horizontal, + this.reverse = false, + this.onTap, + this.enableSplash = true, + required double this.itemExtent, + required this.itemBuilder, + this.itemCount, + this.onIndexChanged, + this.infinite = false, + }) : consumeMaxWeight = true, + flexWeights = null, + children = const <Widget>[]; + + /// Creates a scrollable carousel with weighted items created on demand. + /// + /// This constructor combines the benefits of [CarouselView.weighted] with + /// lazy loading. Items are built on demand while maintaining the weighted + /// layout system. + /// + /// The [flexWeights] parameter determines the layout, and [itemBuilder] + /// creates items as they become visible. + /// + /// {@tool snippet} + /// This example shows how to create a weighted carousel with lazy loading: + /// + /// ```dart + /// CarouselView.weightedBuilder( + /// flexWeights: const <int>[1, 7, 1], + /// itemCount: 100, + /// itemBuilder: (BuildContext context, int index) { + /// return ColoredBox( + /// color: Colors.primaries[index % Colors.primaries.length], + /// child: Center( + /// child: Text('Item $index'), + /// ), + /// ); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [CarouselView.new], which creates a carousel with explicit children. + /// * [CarouselView.weighted], which creates a carousel with weighted items. + /// * [CarouselView.builder], which creates a carousel with fixed-sized items + /// using lazy loading. + const CarouselView.weightedBuilder({ + super.key, + this.padding, + this.backgroundColor, + this.elevation, + this.shape, + this.itemClipBehavior, + this.overlayColor, + this.itemSnapping = false, + this.shrinkExtent = 0.0, + this.controller, + this.scrollDirection = Axis.horizontal, + this.reverse = false, + this.consumeMaxWeight = true, + this.onTap, + this.enableSplash = true, + required List<int> this.flexWeights, + required this.itemBuilder, + this.itemCount, + this.onIndexChanged, + this.infinite = false, + }) : itemExtent = null, + children = const <Widget>[]; + + /// The amount of space to surround each carousel item with. + /// + /// Defaults to [EdgeInsets.all] of 4 pixels. + final EdgeInsets? padding; + + /// The background color for each carousel item. + /// + /// Defaults to [ColorScheme.surface]. + final Color? backgroundColor; + + /// The z-coordinate of each carousel item. + /// + /// Defaults to 0.0. + final double? elevation; + + /// The shape of each carousel item's [Material]. + /// + /// Defines each item's [Material.shape]. + /// + /// Defaults to a [RoundedRectangleBorder] with a circular corner radius + /// of 28.0. + final ShapeBorder? shape; + + /// The clip behavior for each carousel item. + /// + /// The item content will be clipped (or not) according to this option. + /// Refer to the [Clip] enum for more details on the different clip options. + /// + /// Defaults to [Clip.antiAlias]. + final Clip? itemClipBehavior; + + /// The highlight color to indicate the carousel items are in pressed, hovered + /// or focused states. + /// + /// The default values are: + /// * [WidgetState.pressed] - [ColorScheme.onSurface] with an opacity of 0.1 + /// * [WidgetState.hovered] - [ColorScheme.onSurface] with an opacity of 0.08 + /// * [WidgetState.focused] - [ColorScheme.onSurface] with an opacity of 0.1 + final WidgetStateProperty<Color?>? overlayColor; + + /// The minimum allowable extent (size) in the main axis for carousel items + /// during scrolling transitions. + /// + /// As the carousel scrolls, the first visible item is pinned and gradually + /// shrinks until it reaches this minimum extent before scrolling off-screen. + /// Similarly, the last visible item enters the viewport at this minimum size + /// and expands to its full [itemExtent]. + /// + /// In cases where the remaining viewport space for the last visible item is + /// larger than the defined [shrinkExtent], the [shrinkExtent] is dynamically + /// adjusted to match this remaining space, ensuring a smooth size transition. + /// + /// Defaults to 0.0. Setting to 0.0 allows items to shrink/expand completely, + /// transitioning between 0.0 and the full item size. In cases where the + /// remaining viewport space for the last visible item is larger than the + /// defined [shrinkExtent], the [shrinkExtent] is dynamically adjusted to match + /// this remaining space, ensuring a smooth size transition. + final double shrinkExtent; + + /// Whether the carousel should keep scrolling to the next/previous items to + /// maintain the original layout. + /// + /// Defaults to false. + final bool itemSnapping; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + final CarouselController? controller; + + /// The [Axis] along which the scroll view's offset increases with each item. + /// + /// Defaults to [Axis.horizontal]. + final Axis scrollDirection; + + /// Whether the carousel list scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the carousel scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the carousel view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + final bool reverse; + + /// Whether the collapsed items are allowed to expand to the max size. + /// + /// If this is false, the layout of the carousel doesn't change. This is especially + /// useful when a weight list in [CarouselView.weighted] has a max item in the + /// middle and at least one small item on either side, such as `[1, 7, 1, 1]`. + /// In this case, if this is false, the first and the last two items cannot + /// expand to the max size. If this is true, there will be some space before + /// the first item or after the last item coming so every item has a chance to + /// be fully expanded. + /// + /// Defaults to true. + final bool consumeMaxWeight; + + /// Called when one of the [children] is tapped. + final ValueChanged<int>? onTap; + + /// Determines whether an [InkWell] will cover each Carousel item. + /// + /// If true, tapping an item will create an ink splash + /// as defined by the [ThemeData.splashFactory]. + /// + /// Setting this to false allows the [children] to respond to user gestures. + /// + /// Defaults to true. + final bool enableSplash; + + /// The extent the children are forced to have in the main axis. + /// + /// The item extent should not exceed the available space that the carousel view + /// occupies to ensure at least one item is fully visible. + /// + /// This is required for [CarouselView]. In [CarouselView.weighted], this is null. + final double? itemExtent; + + /// The weights that each visible child should occupy in the viewport. + /// + /// The length of [flexWeights] represents how many items should be visible + /// at a time in the viewport. For example, setting [flexWeights] to + /// `<int>[3, 2, 1]` means there are 3 carousel items and their extents are + /// 3/6, 2/6 and 1/6 of the viewport extent. + /// + /// This is a required property in [CarouselView.weighted]. This is null + /// for default [CarouselView]. The integers must be greater than 0. + final List<int>? flexWeights; + + /// The child widgets for the carousel. + final List<Widget> children; + + /// {@template flutter.material.CarouselView.onIndexChanged} + /// A callback invoked when the leading item changes. + /// + /// The leading item is the first visible item in the carousel view. + /// + /// The callback fires only when the leading item is completely out of view, + /// whether due to user interaction or programmatic scrolling. If the leading item + /// remains partially visible, the leading index will not change and the callback will + /// not be invoked. + /// {@endtemplate} + /// + /// Example: + /// + /// ```dart + /// CarouselView( + /// itemExtent: 200.0, + /// onIndexChanged: (int index) { + /// print('Leading item changed to: $index'); + /// }, + /// children: <Widget>[ + /// Container(color: Colors.red), + /// Container(color: Colors.green), + /// Container(color: Colors.blue), + /// ], + /// ) + /// ``` + final ValueChanged<int>? onIndexChanged; + + /// Called to build carousel item on demand. + /// + /// Will be called only for indices greater than or equal to zero and less + /// than [itemCount] (if [itemCount] is non-null). + /// + /// Should return null if asked to build a widget with a greater index than + /// exists. + final NullableIndexedWidgetBuilder? itemBuilder; + + /// The number of items in the carousel. + /// + /// If null, the carousel will continue to build items until [itemBuilder] returns null. + /// + /// When [infinite] is true, the carousel will loop infinitely. + final int? itemCount; + + /// Whether the carousel should loop infinitely. + /// + /// If true, the carousel will create an infinite loop of items, + /// allowing continuous scrolling in both directions. + /// + /// Defaults to false. + final bool infinite; + + @override + State<CarouselView> createState() => _CarouselViewState(); +} + +class _CarouselViewState extends State<CarouselView> { + double? _itemExtent; + List<int>? get _flexWeights => widget.flexWeights; + bool get _consumeMaxWeight => widget.consumeMaxWeight; + CarouselController? _internalController; + CarouselController get _controller => widget.controller ?? _internalController!; + late int _lastReportedLeadingItem; + + @override + void initState() { + super.initState(); + _itemExtent = widget.itemExtent; + if (widget.controller == null) { + _internalController = CarouselController(); + } + _lastReportedLeadingItem = _getInitialLeadingItem(); + _controller._attach(this); + _controller.addListener(_handleScroll); + } + + @override + void didUpdateWidget(covariant CarouselView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller?._detach(this); + if (widget.controller != null) { + _internalController?._detach(this); + _internalController = null; + widget.controller?._attach(this); + } else { + // widget.controller == null && oldWidget.controller != null + assert(_internalController == null); + _internalController = CarouselController(); + _controller._attach(this); + } + } + if (widget.flexWeights != oldWidget.flexWeights) { + (_controller.position as _CarouselPosition).flexWeights = _flexWeights; + } + if (widget.itemExtent != oldWidget.itemExtent) { + _itemExtent = widget.itemExtent; + (_controller.position as _CarouselPosition).itemExtent = _itemExtent; + } + if (widget.consumeMaxWeight != oldWidget.consumeMaxWeight) { + (_controller.position as _CarouselPosition).consumeMaxWeight = _consumeMaxWeight; + } + } + + @override + void dispose() { + _controller.removeListener(_handleScroll); + _controller._detach(this); + _internalController?.dispose(); + super.dispose(); + } + + void _handleScroll() { + if (widget.onIndexChanged == null) { + return; + } + + final ScrollPosition position = _controller.position; + final int currentLeadingIndex = (position as _CarouselPosition).leadingItem; + + if (currentLeadingIndex != _lastReportedLeadingItem) { + _lastReportedLeadingItem = currentLeadingIndex; + widget.onIndexChanged!(currentLeadingIndex); + } + } + + // For weighted carousel, the initialItem means the index of the item to occupy the first maximum weight + // in flexWeights. To get the initial leading item, it should be initialItem - index of the first max weight in flexWeights. + // So it might be negative when initialItem value is small but the first max weight index is large. In that case, + // the initial leading item should be 0. + int _getInitialLeadingItem() { + if (widget.flexWeights != null) { + final int maxWeight = widget.flexWeights!.max; + final int firstMaxWeightIndex = widget.flexWeights!.indexOf(maxWeight); + return math.max(_controller.initialItem - firstMaxWeightIndex, 0); + } + return _controller.initialItem; + } + + Widget _buildCarouselItem(int index) { + // For infinite scrolling, wrap the index to the actual children range. + if (widget.infinite && widget.children.isNotEmpty) { + index = index % widget.children.length; + } + final CarouselViewThemeData carouselTheme = CarouselViewTheme.of(context); + final ColorScheme colorScheme = ColorScheme.of(context); + final EdgeInsets effectivePadding = + widget.padding ?? carouselTheme.padding ?? const EdgeInsets.all(4.0); + final Color effectiveBackgroundColor = + widget.backgroundColor ?? carouselTheme.backgroundColor ?? colorScheme.surface; + final double effectiveElevation = widget.elevation ?? carouselTheme.elevation ?? 0.0; + final ShapeBorder effectiveShape = + widget.shape ?? + carouselTheme.shape ?? + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))); + final Clip effectiveItemClipBehavior = + widget.itemClipBehavior ?? carouselTheme.itemClipBehavior ?? Clip.antiAlias; + final WidgetStateProperty<Color?> effectiveOverlayColor = + widget.overlayColor ?? + carouselTheme.overlayColor ?? + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return colorScheme.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return colorScheme.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return colorScheme.onSurface.withOpacity(0.1); + } + return null; + }); + + Widget contents = widget.children[index]; + + if (widget.enableSplash) { + contents = Stack( + fit: StackFit.expand, + children: <Widget>[ + contents, + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => widget.onTap?.call(index), + overlayColor: effectiveOverlayColor, + ), + ), + ], + ); + } else if (widget.onTap != null) { + contents = GestureDetector(onTap: () => widget.onTap!(index), child: contents); + } + + return Padding( + padding: effectivePadding, + child: Material( + clipBehavior: effectiveItemClipBehavior, + color: effectiveBackgroundColor, + elevation: effectiveElevation, + shape: effectiveShape, + child: contents, + ), + ); + } + + Widget _buildSliverCarousel(ThemeData theme) { + // Determine the child count and builder based on whether we're using lazy loading + final int? childCount = widget.infinite + ? null + : widget.itemBuilder != null + ? widget.itemCount + : widget.children.length; + + NullableIndexedWidgetBuilder effectiveBuilder; + if (widget.itemBuilder != null) { + if (widget.infinite && widget.itemCount != null && widget.itemCount! > 0) { + final int itemCount = widget.itemCount!; + effectiveBuilder = (BuildContext context, int index) { + return widget.itemBuilder!(context, index % itemCount); + }; + } else { + effectiveBuilder = widget.itemBuilder!; + } + } else { + effectiveBuilder = (BuildContext context, int index) => _buildCarouselItem(index); + } + + if (_itemExtent != null) { + return _SliverFixedExtentCarousel( + itemExtent: _itemExtent!, + minExtent: widget.shrinkExtent, + infinite: widget.infinite, + delegate: SliverChildBuilderDelegate(effectiveBuilder, childCount: childCount), + ); + } + + assert( + _flexWeights != null && _flexWeights!.every((int weight) => weight > 0), + 'flexWeights is null or it contains non-positive integers', + ); + return _SliverWeightedCarousel( + consumeMaxWeight: _consumeMaxWeight, + shrinkExtent: widget.shrinkExtent, + weights: _flexWeights!, + infinite: widget.infinite, + delegate: SliverChildBuilderDelegate(effectiveBuilder, childCount: childCount), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ScrollPhysics physics = widget.itemSnapping + ? const CarouselScrollPhysics() + : ScrollConfiguration.of(context).getScrollPhysics(context); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double mainAxisExtent = switch (widget.scrollDirection) { + Axis.horizontal => constraints.maxWidth, + Axis.vertical => constraints.maxHeight, + }; + + _itemExtent = widget.itemExtent == null + ? null + : clampDouble(widget.itemExtent!, 0, mainAxisExtent); + return CustomScrollView( + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + controller: _controller, + physics: physics, + clipBehavior: Clip.antiAlias, + scrollCacheExtent: const ScrollCacheExtent.viewport(0.0), + slivers: <Widget>[_buildSliverCarousel(theme)], + ); + }, + ); + } +} + +/// A sliver that displays its box children in a linear array with a fixed extent +/// per item. +/// +/// _To learn more about slivers, see [CustomScrollView.slivers]._ +/// +/// This sliver list arranges its children in a line along the main axis starting +/// at offset zero and without gaps. Each child is constrained to a fixed extent +/// along the main axis and the [SliverConstraints.crossAxisExtent] +/// along the cross axis. The difference between this and a list view with a fixed +/// extent is the first item and last item can be collapsed a little during scrolling +/// transition. This compression is controlled by the `minExtent` property and +/// aligns with the [Material Design Carousel specifications] +/// (https://m3.material.io/components/carousel/guidelines#96c5c157-fe5b-4ee3-a9b4-72bf8efab7e9). +class _SliverFixedExtentCarousel extends SliverMultiBoxAdaptorWidget { + const _SliverFixedExtentCarousel({ + required super.delegate, + required this.minExtent, + required this.itemExtent, + required this.infinite, + }); + + final double itemExtent; + final double minExtent; + final bool infinite; + + @override + RenderSliverFixedExtentBoxAdaptor createRenderObject(BuildContext context) { + final element = context as SliverMultiBoxAdaptorElement; + return _RenderSliverFixedExtentCarousel( + childManager: element, + minExtent: minExtent, + maxExtent: itemExtent, + infinite: infinite, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSliverFixedExtentCarousel renderObject) { + renderObject.maxExtent = itemExtent; + renderObject.minExtent = minExtent; + renderObject.infinite = infinite; + } +} + +class _RenderSliverFixedExtentCarousel extends RenderSliverFixedExtentBoxAdaptor { + _RenderSliverFixedExtentCarousel({ + required super.childManager, + required double maxExtent, + required double minExtent, + required bool infinite, + }) : _maxExtent = maxExtent, + _minExtent = minExtent, + _infinite = infinite; + + double get maxExtent => _maxExtent; + double _maxExtent; + set maxExtent(double value) { + if (_maxExtent == value) { + return; + } + _maxExtent = value; + markNeedsLayout(); + } + + double get minExtent => _minExtent; + double _minExtent; + set minExtent(double value) { + if (_minExtent == value) { + return; + } + _minExtent = value; + markNeedsLayout(); + } + + bool get infinite => _infinite; + bool _infinite; + set infinite(bool value) { + if (_infinite == value) { + return; + } + _infinite = value; + markNeedsLayout(); + } + + // This implements the [itemExtentBuilder] callback. + double _buildItemExtent(int index, SliverLayoutDimensions currentLayoutDimensions) { + if (maxExtent == 0.0) { + return maxExtent; + } + + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + + // Calculate how many items have been completely scroll off screen. + final int offscreenItems = (constraints.scrollOffset / maxExtent).floor(); + + // If an item is partially off screen and partially on screen, + // `constraints.scrollOffset` must be greater than + // `offscreenItems * maxExtent`, so the difference between these two is how + // much the current first visible item is off screen. + final double offscreenExtent = constraints.scrollOffset - offscreenItems * maxExtent; + + // If there is not enough space to place the last visible item but the remaining + // space is larger than `minExtent`, the extent for last item should be at + // least the remaining extent to ensure a smooth size transition. + final double effectiveMinExtent = math.max( + constraints.remainingPaintExtent % maxExtent, + minExtent, + ); + + // Two special cases are the first and last visible items. Other items' extent + // should all return `maxExtent`. + if (index == firstVisibleIndex) { + final double effectiveExtent = maxExtent - offscreenExtent; + return math.max(effectiveExtent, effectiveMinExtent); + } + + final double scrollOffsetForLastIndex = + constraints.scrollOffset + constraints.remainingPaintExtent; + if (index == getMaxChildIndexForScrollOffset(scrollOffsetForLastIndex, maxExtent)) { + return clampDouble( + scrollOffsetForLastIndex - maxExtent * index, + effectiveMinExtent, + maxExtent, + ); + } + + return maxExtent; + } + + /// The layout offset for the child with the given index. + @override + double indexToLayoutOffset( + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + int index, + ) { + if (maxExtent == 0.0) { + return maxExtent; + } + + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + + // If there is not enough space to place the last visible item but the remaining + // space is larger than `minExtent`, the extent for last item should be at + // least the remaining extent to make sure a smooth size transition. + final double effectiveMinExtent = math.max( + constraints.remainingPaintExtent % maxExtent, + minExtent, + ); + if (index == firstVisibleIndex) { + final double firstVisibleItemExtent = _buildItemExtent(index, layoutDimensions); + + // If the first item is collapsed to be less than `effectiveMinExtent`, + // then it should stop changing its size and should start to scroll off screen. + if (firstVisibleItemExtent <= effectiveMinExtent) { + return maxExtent * index - effectiveMinExtent + maxExtent; + } + return constraints.scrollOffset; + } + return maxExtent * index; + } + + /// The minimum child index that is visible at the given scroll offset. + @override + int getMinChildIndexForScrollOffset( + double scrollOffset, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + ) { + if (maxExtent == 0.0) { + return 0; + } + + final int firstVisibleIndex = (scrollOffset / maxExtent).floor(); + return math.max(firstVisibleIndex, 0); + } + + /// The maximum child index that is visible at the given scroll offset. + @override + int getMaxChildIndexForScrollOffset( + double scrollOffset, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + ) { + if (maxExtent > 0.0) { + final double actual = scrollOffset / maxExtent - 1; + final int round = actual.round(); + if ((actual * maxExtent - round * maxExtent).abs() < precisionErrorTolerance) { + return math.max(0, round); + } + return math.max(0, actual.ceil()); + } + return 0; + } + + @override + double? get itemExtent => null; + + @override + ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent; +} + +/// A sliver that arranges its box children in a linear array, constraining them +/// to specific weights determined by the [weights] property. +/// +/// _To learn more about slivers, see [CustomScrollView.slivers]._ +/// +/// This sliver arranges its children in a line along the main axis, starting +/// at offset zero without gaps. Each child is constrained to its corresponding +/// weight along the main axis and to the [SliverConstraints.crossAxisExtent] +/// along the cross axis. +/// +/// See [CarouselView.weighted] to get more calculation explanations. +class _SliverWeightedCarousel extends SliverMultiBoxAdaptorWidget { + const _SliverWeightedCarousel({ + required super.delegate, + required this.consumeMaxWeight, + required this.shrinkExtent, + required this.weights, + required this.infinite, + }); + + // Determine whether extra scroll offset should be calculate so that every + // item have a chance to scroll to the maximum extent. + // + // This is useful when the leading/trailing items have smaller weights, such + // as [1, 7], and [3, 2, 1]. + final bool consumeMaxWeight; + + // The starting extent for items when they gradually show on/off screen. + // + // This is useful to avoid a hairline shape. This value should also smaller + // than the last item extent to make sure a smooth transition. So in calculation, + // this is limited to [0, weight for the last visible item]. + final double shrinkExtent; + + // The layout arrangement. + // + // When items are laying out, each item will be arranged based on the order of + // the weights and the extent is based on the corresponding weight out of the + // sum of weights. The length of weights means how many items we can put in the + // view at a time. + final List<int> weights; + + // Whether the carousel should loop infinitely. + final bool infinite; + + @override + RenderSliverFixedExtentBoxAdaptor createRenderObject(BuildContext context) { + final element = context as SliverMultiBoxAdaptorElement; + return _RenderSliverWeightedCarousel( + childManager: element, + consumeMaxWeight: consumeMaxWeight, + shrinkExtent: shrinkExtent, + weights: weights, + infinite: infinite, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSliverWeightedCarousel renderObject) { + renderObject + ..consumeMaxWeight = consumeMaxWeight + ..shrinkExtent = shrinkExtent + ..weights = weights + ..infinite = infinite; + } +} + +// A sliver that places its box children in a linear array and constrains them +// to have the corresponding weight which is determined by [weights]. +class _RenderSliverWeightedCarousel extends RenderSliverFixedExtentBoxAdaptor { + _RenderSliverWeightedCarousel({ + required super.childManager, + required bool consumeMaxWeight, + required double shrinkExtent, + required List<int> weights, + required bool infinite, + }) : _consumeMaxWeight = consumeMaxWeight, + _shrinkExtent = shrinkExtent, + _weights = weights, + _infinite = infinite; + + bool get consumeMaxWeight => _consumeMaxWeight; + bool _consumeMaxWeight; + set consumeMaxWeight(bool value) { + if (_consumeMaxWeight == value) { + return; + } + _consumeMaxWeight = value; + markNeedsLayout(); + } + + double get shrinkExtent => _shrinkExtent; + double _shrinkExtent; + set shrinkExtent(double value) { + if (_shrinkExtent == value) { + return; + } + _shrinkExtent = value; + markNeedsLayout(); + } + + List<int> get weights => _weights; + List<int> _weights; + set weights(List<int> value) { + if (_weights == value) { + return; + } + _weights = value; + markNeedsLayout(); + } + + bool get infinite => _infinite; + bool _infinite; + set infinite(bool value) { + if (_infinite == value) { + return; + } + _infinite = value; + markNeedsLayout(); + } + + // This is to implement the itemExtentBuilder callback to return each item extent + // while scrolling. + // + // The given `index` is compared with `_firstVisibleItemIndex` to know how + // many items are placed before the current one in the view. + double _buildItemExtent(int index, SliverLayoutDimensions currentLayoutDimensions) { + // If constraints.viewportMainAxisExtent is 0, firstChildExtent will be 0 and cause division error. + if (constraints.viewportMainAxisExtent == 0) { + return 0; + } + + double extent; + if (index == _firstVisibleItemIndex) { + extent = math.max(_distanceToLeadingEdge, effectiveShrinkExtent); + } + // Calculate the extents of items located within the range defined by the + // weights array relative to the first visible item. This allows us to + // precisely determine each item's extent based on its initial extent + // (calculated from the weights) and the scrolling progress (the off-screen + // portion of the first item). + else if (index > _firstVisibleItemIndex && + index - _firstVisibleItemIndex + 1 <= weights.length) { + assert(index - _firstVisibleItemIndex < weights.length); + final int currIndexOnWeightList = index - _firstVisibleItemIndex; + final int currWeight = weights[currIndexOnWeightList]; + extent = extentUnit * currWeight; // initial extent + final double progress = _firstVisibleItemOffscreenExtent / firstChildExtent; + + final int prevWeight = weights[currIndexOnWeightList - 1]; + final double finalIncrease = (prevWeight - currWeight) / weights.max; + extent = extent + finalIncrease * progress * maxChildExtent; + } + // Calculate the extents of items located beyond the range defined by the + // weights array relative to the first visible item. During scrolling transition, + // it is possible that the number of visible items is larger than the length + // of `weights`. The extra item extent should be calculated here to fill + // the remaining space. + else if (index > _firstVisibleItemIndex && + index - _firstVisibleItemIndex + 1 > weights.length) { + double visibleItemsTotalExtent = _distanceToLeadingEdge; + for (int i = _firstVisibleItemIndex + 1; i < index; i++) { + visibleItemsTotalExtent += _buildItemExtent(i, currentLayoutDimensions); + } + extent = math.max( + constraints.remainingPaintExtent - visibleItemsTotalExtent, + effectiveShrinkExtent, + ); + } else { + extent = math.max(minChildExtent, effectiveShrinkExtent); + } + return extent; + } + + // To ge the extent unit based on the viewport extent and the sum of weights. + double get extentUnit => + constraints.viewportMainAxisExtent / + (weights.reduce((int total, int extent) => total + extent)); + + double get firstChildExtent => weights.first * extentUnit; + double get maxChildExtent => weights.max * extentUnit; + double get minChildExtent => weights.min * extentUnit; + + // The shrink extent for first and last visible items should be no larger + // than [minChildExtent] to ensure a smooth transition. + double get effectiveShrinkExtent => clampDouble(shrinkExtent, 0, minChildExtent); + + // The index of the first visible item. The returned value can be negative when + // the leading items with smaller weights need to be fully expanded. For example, + // assuming a weights [1, 7, 1], when item 0 is expanding to the maximum size + // (with weight 7), we leave some space before item 0 assuming there is another + // item -1 as the first visible item. + int get _firstVisibleItemIndex { + // If constraints.viewportMainAxisExtent is 0, firstChildExtent will be 0 and cause division error. + if (constraints.viewportMainAxisExtent == 0.0) { + return 0; + } + var smallerWeightCount = 0; + for (final int weight in weights) { + if (weight == weights.max) { + break; + } + smallerWeightCount += 1; + } + int index; + + final double actual = constraints.scrollOffset / firstChildExtent; + final int round = (constraints.scrollOffset / firstChildExtent).round(); + if ((actual - round).abs() < precisionErrorTolerance) { + index = round; + } else { + index = actual.floor(); + } + return consumeMaxWeight ? index - smallerWeightCount : index; + } + + // This value indicates the scrolling progress of items following the first + // item. It informs them how much the first item has moved off-screen, + // enabling them to adjust their sizes (grow or shrink) accordingly. + double get _firstVisibleItemOffscreenExtent { + // If constraints.viewportMainAxisExtent is 0, firstChildExtent will be 0 and cause division error. + if (constraints.viewportMainAxisExtent == 0.0) { + return 0; + } + int index; + final double actual = constraints.scrollOffset / firstChildExtent; + final int round = (constraints.scrollOffset / firstChildExtent).round(); + if ((actual - round).abs() < precisionErrorTolerance) { + index = round; + } else { + index = actual.floor(); + } + return constraints.scrollOffset - index * firstChildExtent; + } + + // Given the off-screen extent for the first visible item, we can know the + // on-screen extent for the first visible item. + double get _distanceToLeadingEdge => firstChildExtent - _firstVisibleItemOffscreenExtent; + + // Given an index, this method returns the layout offset for the item. The `index` + // is firstly compared to `_firstVisibleItemIndex` and compute the distance + // between them, then compute all the current extents for items that are located + // in front. + @override + double indexToLayoutOffset( + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + int index, + ) { + if (index == _firstVisibleItemIndex) { + if (_distanceToLeadingEdge <= effectiveShrinkExtent) { + return constraints.scrollOffset - effectiveShrinkExtent + _distanceToLeadingEdge; + } + return constraints.scrollOffset; + } + double visibleItemsTotalExtent = _distanceToLeadingEdge; + for (int i = _firstVisibleItemIndex + 1; i < index; i++) { + visibleItemsTotalExtent += _buildItemExtent(i, layoutDimensions); + } + return constraints.scrollOffset + visibleItemsTotalExtent; + } + + @override + int getMinChildIndexForScrollOffset( + double scrollOffset, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + ) { + return math.max(_firstVisibleItemIndex, 0); + } + + @override + int getMaxChildIndexForScrollOffset( + double scrollOffset, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + ) { + final int? childCount = childManager.estimatedChildCount; + + // For infinite scrolling, calculate how many items fit in the viewport + if (infinite && childCount == null) { + double visibleItemsTotalExtent = _distanceToLeadingEdge; + int index = _firstVisibleItemIndex + 1; + // Calculate upper bound based on viewport extent and minimum possible item extent. + // In worst case, all items would be at minimum extent i.e. minChildExtent. + final double safeMinExtent = math.max(minChildExtent, 1.0); + final int estimatedUpperBound = + _firstVisibleItemIndex + (constraints.viewportMainAxisExtent / safeMinExtent).ceil(); + while (visibleItemsTotalExtent < constraints.viewportMainAxisExtent && + index < estimatedUpperBound) { + visibleItemsTotalExtent += _buildItemExtent(index, layoutDimensions); + if (visibleItemsTotalExtent >= constraints.viewportMainAxisExtent) { + return index; + } + index++; + } + return index; + } + + if (childCount != null) { + double visibleItemsTotalExtent = _distanceToLeadingEdge; + for (int i = _firstVisibleItemIndex + 1; i < childCount; i++) { + visibleItemsTotalExtent += _buildItemExtent(i, layoutDimensions); + if (visibleItemsTotalExtent >= constraints.viewportMainAxisExtent) { + return i; + } + } + } + return childCount ?? 0; + } + + @override + double computeMaxScrollOffset( + SliverConstraints constraints, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.', + ) + double itemExtent, + ) { + if (infinite) { + return double.infinity; + } + return childManager.childCount * maxChildExtent; + } + + BoxConstraints _getChildConstraints(int index) { + final double extent = itemExtentBuilder!(index, layoutDimensions)!; + return constraints.asBoxConstraints(minExtent: extent, maxExtent: extent); + } + + // This method is mostly the same as its parent class [RenderSliverFixedExtentList]. + // The difference is when we allow some space before the leading items or after + // the trailing items with smaller weights, we leave extra scroll offset. + // TODO(quncCccccc): add the calculation for the extra scroll offset on the super class to simplify the implementation here. + @override + void performLayout() { + assert( + (itemExtent != null && itemExtentBuilder == null) || + (itemExtent == null && itemExtentBuilder != null), + ); + assert(itemExtentBuilder != null || (itemExtent!.isFinite && itemExtent! >= 0)); + + final SliverConstraints constraints = this.constraints; + childManager.didStartLayout(); + childManager.setDidUnderflow(false); + + final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin; + assert(scrollOffset >= 0.0); + final double remainingExtent = constraints.remainingCacheExtent; + assert(remainingExtent >= 0.0); + final double targetEndScrollOffset = scrollOffset + remainingExtent; + // TODO(Piinks): Clean up when deprecation expires. + const double deprecatedExtraItemExtent = -1; + + final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, deprecatedExtraItemExtent); + final int? targetLastIndex = targetEndScrollOffset.isFinite + ? getMaxChildIndexForScrollOffset(targetEndScrollOffset, deprecatedExtraItemExtent) + : null; + + if (firstChild != null) { + final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex); + final int trailingGarbage = targetLastIndex != null + ? calculateTrailingGarbage(lastIndex: targetLastIndex) + : 0; + collectGarbage(leadingGarbage, trailingGarbage); + } else { + collectGarbage(0, 0); + } + + if (firstChild == null) { + final double layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, firstIndex); + if (!addInitialChild(index: firstIndex, layoutOffset: layoutOffset)) { + // There are either no children, or we are past the end of all our children. + final double max; + if (firstIndex <= 0) { + max = 0.0; + } else { + max = computeMaxScrollOffset(constraints, deprecatedExtraItemExtent); + } + geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max); + childManager.didFinishLayout(); + return; + } + } + + RenderBox? trailingChildWithLayout; + + for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) { + final RenderBox? child = insertAndLayoutLeadingChild(_getChildConstraints(index)); + if (child == null) { + // Items before the previously first child are no longer present. + // Reset the scroll offset to offset all items prior and up to the + // missing item. Let parent re-layout everything. + geometry = SliverGeometry( + scrollOffsetCorrection: indexToLayoutOffset(deprecatedExtraItemExtent, index), + ); + return; + } + final childParentData = child.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, index); + assert(childParentData.index == index); + trailingChildWithLayout ??= child; + } + + if (trailingChildWithLayout == null) { + firstChild!.layout(_getChildConstraints(indexOf(firstChild!))); + final childParentData = firstChild!.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, firstIndex); + trailingChildWithLayout = firstChild; + } + + // From the last item to the firstly encountered max item + double extraLayoutOffset = 0; + if (consumeMaxWeight) { + for (int i = weights.length - 1; i >= 0; i--) { + if (weights[i] == weights.max) { + break; + } + extraLayoutOffset += weights[i] * extentUnit; + } + } + + double estimatedMaxScrollOffset = double.infinity; + // Layout visible items after the first visible item. + for ( + int index = indexOf(trailingChildWithLayout!) + 1; + targetLastIndex == null || index <= targetLastIndex; + ++index + ) { + RenderBox? child = childAfter(trailingChildWithLayout!); + if (child == null || indexOf(child) != index) { + child = insertAndLayoutChild(_getChildConstraints(index), after: trailingChildWithLayout); + if (child == null) { + // We have run out of children. + estimatedMaxScrollOffset = + indexToLayoutOffset(deprecatedExtraItemExtent, index) + extraLayoutOffset; + break; + } + } else { + child.layout(_getChildConstraints(index)); + } + trailingChildWithLayout = child; + final childParentData = child.parentData! as SliverMultiBoxAdaptorParentData; + assert(childParentData.index == index); + childParentData.layoutOffset = indexToLayoutOffset( + deprecatedExtraItemExtent, + childParentData.index!, + ); + } + + final int lastIndex = indexOf(lastChild!); + final double leadingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, firstIndex); + double trailingScrollOffset; + + if (!infinite && lastIndex + 1 == childManager.childCount) { + trailingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, lastIndex); + + trailingScrollOffset += math.max( + weights.last * extentUnit, + _buildItemExtent(lastIndex, layoutDimensions), + ); + trailingScrollOffset += extraLayoutOffset; + } else { + trailingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, lastIndex + 1); + } + + assert(debugAssertChildListIsNonEmptyAndContiguous()); + assert(indexOf(firstChild!) == firstIndex); + assert(targetLastIndex == null || lastIndex <= targetLastIndex); + + estimatedMaxScrollOffset = math.min( + estimatedMaxScrollOffset, + estimateMaxScrollOffset( + constraints, + firstIndex: firstIndex, + lastIndex: lastIndex, + leadingScrollOffset: leadingScrollOffset, + trailingScrollOffset: trailingScrollOffset, + ), + ); + + final double paintExtent = calculatePaintOffset( + constraints, + from: consumeMaxWeight ? 0 : leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double cacheExtent = calculateCacheOffset( + constraints, + from: consumeMaxWeight ? 0 : leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double targetEndScrollOffsetForPaint = + constraints.scrollOffset + constraints.remainingPaintExtent; + final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite + ? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint, deprecatedExtraItemExtent) + : null; + + geometry = SliverGeometry( + scrollExtent: estimatedMaxScrollOffset, + paintExtent: paintExtent, + cacheExtent: cacheExtent, + maxPaintExtent: estimatedMaxScrollOffset, + // Conservative to avoid flickering away the clip during scroll. + hasVisualOverflow: + (targetLastIndexForPaint != null && lastIndex >= targetLastIndexForPaint) || + constraints.scrollOffset > 0.0, + ); + + // We may have started the layout while scrolled to the end, which would not + // expose a new child. + if (estimatedMaxScrollOffset == trailingScrollOffset) { + childManager.setDidUnderflow(true); + } + childManager.didFinishLayout(); + } + + @override + double? get itemExtent => null; + + /// The main-axis extent builder of each item. + /// + /// If this is non-null, the [itemExtent] must be null. + /// If this is null, the [itemExtent] must be non-null. + @override + ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent; +} + +/// Scroll physics used by a [CarouselView]. +/// +/// These physics cause the carousel item to snap to item boundaries. +/// +/// See also: +/// +/// * [ScrollPhysics], the base class which defines the API for scrolling +/// physics. +/// * [PageScrollPhysics], scroll physics used by a [PageView]. +class CarouselScrollPhysics extends ScrollPhysics { + /// Creates physics for a [CarouselView]. + const CarouselScrollPhysics({super.parent}); + + @override + CarouselScrollPhysics applyTo(ScrollPhysics? ancestor) { + return CarouselScrollPhysics(parent: buildParent(ancestor)); + } + + double _getTargetPixels(_CarouselPosition position, Tolerance tolerance, double velocity) { + double fraction; + + if (position.itemExtent != null) { + fraction = position.itemExtent! / position.viewportDimension; + } else { + assert(position.flexWeights != null); + fraction = position.flexWeights!.first / position.flexWeights!.sum; + } + + final double itemWidth = position.viewportDimension * fraction; + + final double actual = math.max(0.0, position.pixels) / itemWidth; + final double round = actual.roundToDouble(); + double item; + if ((actual - round).abs() < precisionErrorTolerance) { + item = round; + } else { + item = actual; + } + if (velocity < -tolerance.velocity) { + item -= 0.5; + } else if (velocity > tolerance.velocity) { + item += 0.5; + } + return item.roundToDouble() * itemWidth; + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + assert( + position is _CarouselPosition, + 'CarouselScrollPhysics can only be used with Scrollables that uses ' + 'the CarouselController', + ); + + final metrics = position as _CarouselPosition; + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || + (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + final Tolerance tolerance = toleranceFor(metrics); + final double target = _getTargetPixels(metrics, tolerance, velocity); + if (target != metrics.pixels) { + return ScrollSpringSimulation(spring, metrics.pixels, target, velocity, tolerance: tolerance); + } + return null; + } + + @override + bool get allowImplicitScrolling => true; +} + +/// Metrics for a [CarouselView]. +class _CarouselMetrics extends FixedScrollMetrics { + /// Creates an immutable snapshot of values associated with a [CarouselView]. + _CarouselMetrics({ + required super.minScrollExtent, + required super.maxScrollExtent, + required super.pixels, + required super.viewportDimension, + required super.axisDirection, + this.itemExtent, + this.flexWeights, + this.consumeMaxWeight, + required super.devicePixelRatio, + }); + + /// Extent for the carousel item. + /// + /// Used to compute the first item from the current [pixels]. + final double? itemExtent; + + /// The fraction of the viewport that the first item occupies. + /// + /// Used to compute the extent of each carousel item from the current [pixels], + /// if [itemExtent] is null. + final List<int>? flexWeights; + + /// Determine whether each child can be expanded to occupy the maximum weight while scrolling. + final bool? consumeMaxWeight; + + @override + _CarouselMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? itemExtent, + List<int>? flexWeights, + bool? consumeMaxWeight, + double? devicePixelRatio, + }) { + return _CarouselMetrics( + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: + viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + itemExtent: itemExtent ?? this.itemExtent, + flexWeights: flexWeights ?? this.flexWeights, + consumeMaxWeight: consumeMaxWeight ?? this.consumeMaxWeight, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +class _CarouselPosition extends ScrollPositionWithSingleContext implements _CarouselMetrics { + _CarouselPosition({ + required super.physics, + required super.context, + this.initialItem = 0, + double? itemExtent, + List<int>? flexWeights, + bool consumeMaxWeight = true, + bool infinite = false, + int? itemCount, + super.oldPosition, + }) : assert( + flexWeights != null && itemExtent == null || flexWeights == null && itemExtent != null, + ), + _itemToShowOnStartup = initialItem.toDouble(), + _consumeMaxWeight = consumeMaxWeight, + _infinite = infinite, + _itemCount = itemCount, + super(initialPixels: null); + + int initialItem; + final double _itemToShowOnStartup; + + /// The number of items in the carousel for infinite scrolling wrapping. + int? get itemCount => _itemCount; + int? _itemCount; + set itemCount(int? value) { + if (_itemCount == value) { + return; + } + _itemCount = value; + } + + /// Whether the carousel scrolls infinitely in both directions. + bool get infinite => _infinite; + bool _infinite; + set infinite(bool value) { + if (_infinite == value) { + return; + } + _infinite = value; + } + + // When the viewport has a zero-size, the item can not + // be retrieved by `getItemFromPixels`, so we need to cache the item + // for use when resizing the viewport to non-zero next time. + double? _cachedItem; + + @override + bool get consumeMaxWeight => _consumeMaxWeight; + bool _consumeMaxWeight; + set consumeMaxWeight(bool value) { + if (_consumeMaxWeight == value) { + return; + } + if (hasPixels && flexWeights != null) { + final double leadingItem = updateLeadingItem(flexWeights, value); + final double newPixel = getPixelsFromItem(leadingItem, flexWeights, itemExtent); + forcePixels(newPixel); + } + _consumeMaxWeight = value; + } + + @override + double? get itemExtent => _itemExtent; + double? _itemExtent; + set itemExtent(double? value) { + if (_itemExtent == value) { + return; + } + if (hasPixels && _itemExtent != null && viewportDimension != 0.0) { + final double leadingItem = getItemFromPixels(pixels, viewportDimension); + final double newPixel = getPixelsFromItem(leadingItem, flexWeights, value); + forcePixels(newPixel); + } + _itemExtent = value; + } + + @override + List<int>? get flexWeights => _flexWeights; + List<int>? _flexWeights; + set flexWeights(List<int>? value) { + if (flexWeights == value) { + return; + } + final List<int>? oldWeights = _flexWeights; + if (hasPixels && oldWeights != null) { + final double leadingItem = updateLeadingItem(value, consumeMaxWeight); + final double newPixel = getPixelsFromItem(leadingItem, value, itemExtent); + forcePixels(newPixel); + } + _flexWeights = value; + } + + // The index of the leading item in the carousel. + // `getItemFromPixels` may return a fractional value (e.g., 0.6 when mid-scroll). + // Use `toInt()` to truncate the fractional part, ensuring the leading item + // only advances after fully crossing the next item's boundary. + int get leadingItem { + int leadingItem = getItemFromPixels(pixels, viewportDimension).toInt(); + // When `consumeMaxWeight` is true, there is some reserved space before + // item 0 so that item 0 can be expanded to occupy the maximum + // weight while scrolling. The way how consumeMaxWeight works is that we assume + // there are some "invisible" items before the first visible item. Therefore, + // to calculate the correct visible leading item, we need to offset the leading + // item by the index of the maximum weight. + // + // The subtraction may cause negative number for leading item. In this case, + // constrain the leading item to 0. + if (consumeMaxWeight && flexWeights != null) { + leadingItem = math.max(leadingItem - flexWeights!.indexOf(flexWeights!.max), 0); + } + // For infinite scrolling, wrap the index to the range [0, itemCount - 1]. + if (infinite && itemCount != null && itemCount! > 0) { + leadingItem = leadingItem % itemCount!; + } + return leadingItem; + } + + double updateLeadingItem(List<int>? newFlexWeights, bool newConsumeMaxWeight) { + final double maxItem; + if (hasPixels && flexWeights != null) { + final double leadingItem = getItemFromPixels(pixels, viewportDimension); + maxItem = consumeMaxWeight + ? leadingItem + : leadingItem + flexWeights!.indexOf(flexWeights!.max); + } else { + if (!newConsumeMaxWeight) { + return _itemToShowOnStartup; + } + maxItem = _itemToShowOnStartup; + } + if (newFlexWeights != null && !newConsumeMaxWeight) { + var smallerWeights = 0; + for (final int weight in newFlexWeights) { + if (weight == newFlexWeights.max) { + break; + } + smallerWeights += 1; + } + return maxItem - smallerWeights; + } + return maxItem; + } + + double getItemFromPixels(double pixels, double viewportDimension) { + assert(viewportDimension > 0.0); + double fraction; + if (itemExtent != null) { + fraction = itemExtent! / viewportDimension; + } else { + // If itemExtent is null, flexWeights cannot be null. + assert(flexWeights != null); + fraction = flexWeights!.first / flexWeights!.sum; + } + + final double actual = math.max(0.0, pixels) / (viewportDimension * fraction); + final double round = actual.roundToDouble(); + if ((actual - round).abs() < precisionErrorTolerance) { + return round; + } + return actual; + } + + double getPixelsFromItem(double item, List<int>? flexWeights, double? itemExtent) { + double fraction; + if (viewportDimension == 0.0) { + return 0.0; + } + if (itemExtent != null) { + fraction = itemExtent / viewportDimension; + } else { + // If itemExtent is null, flexWeights cannot be null. + assert(flexWeights != null); + fraction = flexWeights!.first / flexWeights.sum; + } + + return item * viewportDimension * fraction; + } + + @override + bool applyViewportDimension(double viewportDimension) { + final double? oldViewportDimensions = hasViewportDimension ? this.viewportDimension : null; + if (viewportDimension == oldViewportDimensions) { + return true; + } + final bool result = super.applyViewportDimension(viewportDimension); + final double? oldPixels = hasPixels ? pixels : null; + double item; + if (oldPixels == null) { + item = updateLeadingItem(flexWeights, consumeMaxWeight); + } else if (oldViewportDimensions == 0.0) { + // If resize from zero, we should use the _cachedItem to recover the state. + item = _cachedItem!; + } else { + item = getItemFromPixels(oldPixels, oldViewportDimensions ?? viewportDimension); + } + final double newPixels = getPixelsFromItem(item, flexWeights, itemExtent); + // If the viewportDimension is zero, cache the item + // in case the viewport is resized to be non-zero. + _cachedItem = (viewportDimension == 0.0) ? item : null; + + if (newPixels != oldPixels) { + correctPixels(newPixels); + return false; + } + return result; + } + + @override + void absorb(ScrollPosition other) { + super.absorb(other); + + if (other is! _CarouselPosition) { + return; + } + + _cachedItem = other._cachedItem; + _itemExtent = other._itemExtent; + } + + /// Returns the length of one complete cycle in pixels. + /// + /// A cycle is the scroll distance needed to return to the same visual state. + double _getCycleLengthInPixels() { + if (itemCount == null || itemCount! <= 0 || !hasViewportDimension || viewportDimension == 0) { + return 0.0; + } + double fraction; + if (itemExtent != null) { + fraction = itemExtent! / viewportDimension; + } else if (flexWeights != null) { + fraction = flexWeights!.first / flexWeights!.sum; + } else { + return 0.0; + } + return itemCount! * viewportDimension * fraction; + } + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + // For infinite scrolling, dynamically add cycles when approaching the boundary. + // This eliminates the need for a large hardcoded starting offset. + if (infinite && hasPixels) { + final double cycleLength = _getCycleLengthInPixels(); + if (cycleLength > 0 && pixels < cycleLength) { + // When scroll position drops below one cycle, add cycles to maintain buffer. + // This allows seamless backward scrolling without hitting the boundary. + final int cyclesToAdd = ((cycleLength - pixels) / cycleLength).ceil(); + correctPixels(pixels + cyclesToAdd * cycleLength); + // Indicate position was corrected and layout should rerun. + return false; + } + } + return super.applyContentDimensions(infinite ? 0.0 : minScrollExtent, maxScrollExtent); + } + + @override + _CarouselMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? itemExtent, + List<int>? flexWeights, + bool? consumeMaxWeight, + double? devicePixelRatio, + }) { + return _CarouselMetrics( + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: + viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + itemExtent: itemExtent ?? this.itemExtent, + flexWeights: flexWeights ?? this.flexWeights, + consumeMaxWeight: consumeMaxWeight ?? this.consumeMaxWeight, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +/// A controller for [CarouselView]. +/// +/// Using a carousel controller helps to show the first visible item on the +/// carousel list. +class CarouselController extends ScrollController { + /// Creates a carousel controller. + CarouselController({this.initialItem = 0}); + + /// The item that expands to the maximum size when first creating the [CarouselView]. + final int initialItem; + + /// The current leading item index in the [CarouselView]. + /// + /// {@macro flutter.material.CarouselView.onIndexChanged} + int get leadingItem { + assert( + positions.isNotEmpty, + 'CarouselController.leadingItem cannot be accessed before a CarouselView is built with it.', + ); + assert( + positions.length == 1, + 'CarouselController.leadingItem cannot be read when multiple CarouselViews ' + 'are attached to the same controller.', + ); + return (position as _CarouselPosition).leadingItem; + } + + _CarouselViewState? _carouselState; + + // ignore: use_setters_to_change_properties + void _attach(_CarouselViewState anchor) { + _carouselState = anchor; + } + + void _detach(_CarouselViewState anchor) { + if (_carouselState == anchor) { + _carouselState = null; + } + } + + /// Animates the controlled carousel to the given item index. + /// + /// For [CarouselView], this will scroll the carousel so the item at [index] becomes + /// the leading item. + /// + /// If the [index] is less than 0, the carousel will scroll to the first item. + /// If the [index] is greater than the number of items, the carousel will scroll + /// to the last item. + /// + /// For [CarouselView.weighted], animates to make the item at [index] occupy the primary, + /// most prominent position determined by the largest weight in `flexWeights`. + /// + /// The animation uses the provided [Duration] and [Curve]. The returned [Future] + /// completes when the animation finishes. + /// + /// The [Duration] defaults to 300 milliseconds and [Curve] defaults to [Curves.ease]. + /// + /// When [CarouselView.infinite] is true, the animation always scrolls forward + /// to reach the target item, even if the item is closer in the backward + /// direction. + /// + /// Does nothing if the carousel is not attached to this controller. + Future<void> animateToItem( + int index, { + Duration duration = const Duration(milliseconds: 300), + Curve curve = Curves.ease, + }) async { + if (!hasClients || _carouselState == null) { + return; + } + + final bool hasFlexWeights = _carouselState!._flexWeights?.isNotEmpty ?? false; + if (_carouselState!.widget.itemBuilder != null) { + final int? itemCount = _carouselState!.widget.itemCount; + index = itemCount != null ? index.clamp(0, itemCount - 1) : 0; + } else { + index = index.clamp(0, _carouselState!.widget.children.length - 1); + } + + await Future.wait<void>(<Future<void>>[ + for (final _CarouselPosition position in positions.cast<_CarouselPosition>()) + position.animateTo( + _getTargetOffset(position, index, hasFlexWeights), + duration: duration, + curve: curve, + ), + ]); + } + + double _getTargetOffset(_CarouselPosition position, int index, bool hasFlexWeights) { + if (!hasFlexWeights) { + final double targetInFirstCycle = index * _carouselState!._itemExtent!; + if (!_carouselState!.widget.infinite) { + return targetInFirstCycle; + } + return _adjustForInfiniteCycle(position, targetInFirstCycle); + } + + final _CarouselViewState carouselState = _carouselState!; + final List<int> weights = carouselState._flexWeights!; + final int totalWeight = weights.reduce((int a, int b) => a + b); + final double dimension = position.viewportDimension; + + final int maxWeightIndex = weights.indexOf(weights.max); + int leadingIndex = carouselState._consumeMaxWeight ? index : index - maxWeightIndex; + if (carouselState.widget.itemBuilder != null) { + final int? itemCount = carouselState.widget.itemCount; + leadingIndex = itemCount != null ? leadingIndex.clamp(0, itemCount - 1) : 0; + } else { + final int itemCount = carouselState.widget.children.length; + leadingIndex = leadingIndex.clamp(0, itemCount - 1); + } + + final double targetInFirstCycle = dimension * (weights.first / totalWeight) * leadingIndex; + if (!carouselState.widget.infinite) { + return targetInFirstCycle; + } + return _adjustForInfiniteCycle(position, targetInFirstCycle); + } + + /// Adjusts a target offset (computed for the first cycle) to always scroll + /// forward from the current position. + /// + /// In infinite mode, the scroll position can be many cycles ahead of 0. + /// This method finds the next forward occurrence of the target offset, + /// ensuring the animation always moves in the forward direction. + double _adjustForInfiniteCycle(_CarouselPosition position, double targetInFirstCycle) { + final double cycleLength = position._getCycleLengthInPixels(); + if (cycleLength <= 0) { + return targetInFirstCycle; + } + final double currentPixels = position.pixels; + // Determine which cycle the current position is in. + final double currentCycleStart = (currentPixels / cycleLength).floorToDouble() * cycleLength; + // Candidate target in the same cycle as the current position. + final double sameCycleTarget = currentCycleStart + targetInFirstCycle; + + // Always scroll forward: pick the first target at or ahead of current position. + if (sameCycleTarget >= currentPixels) { + return sameCycleTarget; + } + return sameCycleTarget + cycleLength; + } + + int? _getItemCount() { + if (_carouselState == null) { + return null; + } + if (_carouselState!.widget.itemBuilder != null) { + return _carouselState!.widget.itemCount; + } + return _carouselState!.widget.children.length; + } + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + assert(_carouselState != null); + return _CarouselPosition( + physics: physics, + context: context, + initialItem: initialItem, + itemExtent: _carouselState!._itemExtent, + consumeMaxWeight: _carouselState!._consumeMaxWeight, + flexWeights: _carouselState!._flexWeights, + infinite: _carouselState!.widget.infinite, + itemCount: _getItemCount(), + oldPosition: oldPosition, + ); + } + + @override + void attach(ScrollPosition position) { + super.attach(position); + final carouselPosition = position as _CarouselPosition; + carouselPosition.flexWeights = _carouselState!._flexWeights; + carouselPosition.itemExtent = _carouselState!._itemExtent; + carouselPosition.consumeMaxWeight = _carouselState!._consumeMaxWeight; + carouselPosition.infinite = _carouselState!.widget.infinite; + carouselPosition.itemCount = _getItemCount(); + } +} diff --git a/packages/material_ui/lib/src/carousel_theme.dart b/packages/material_ui/lib/src/carousel_theme.dart new file mode 100644 index 000000000000..ddcaa11e9804 --- /dev/null +++ b/packages/material_ui/lib/src/carousel_theme.dart @@ -0,0 +1,207 @@ +// 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:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'carousel.dart'; +import 'material.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [CarouselView] widgets. +/// +/// Descendant widgets obtain the current [CarouselViewThemeData] object using +/// [CarouselViewTheme.of]. Instances of [CarouselViewThemeData] can be +/// customized with [CarouselViewThemeData.copyWith]. +/// +/// Typically a [CarouselViewThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.carouselViewTheme]. +/// +/// All [CarouselViewThemeData] properties are `null` by default. When null, the [CarouselView] +/// will provide its own defaults. +/// +/// See also: +/// +/// * [CarouselViewTheme], an [InheritedWidget] that propagates the theme to its descendants. +/// * [ThemeData], which describes the overall theme information for the application. +@immutable +class CarouselViewThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.carouselViewTheme]. + const CarouselViewThemeData({ + this.elevation, + this.backgroundColor, + this.overlayColor, + this.shape, + this.padding, + this.itemClipBehavior, + }); + + /// The amount of space to surround each carousel item with. + /// + /// Overrides the default value for [CarouselView.padding]. + final EdgeInsets? padding; + + /// The background color for each carousel item. + /// + /// Overrides the default value for [CarouselView.backgroundColor]. + final Color? backgroundColor; + + /// The z-coordinate of each carousel item. + /// + /// This controls the size of the shadow below the carousel. + /// + /// Overrides the default value for [CarouselView.elevation]. + final double? elevation; + + /// The shape of the carousel item's [Material]. + /// + /// Overrides the default value for [CarouselView.shape]. + final OutlinedBorder? shape; + + /// The clip behavior for each carousel item. + /// + /// The item content will be clipped (or not) according to this option. + /// Refer to the [Clip] enum for more details on the different clip options. + /// + /// Overrides the default value for [CarouselView.itemClipBehavior]. + final Clip? itemClipBehavior; + + /// The highlight color to indicate the carousel items are in pressed, hovered + /// or focused states. + /// + /// Overrides the default value for [CarouselView.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + CarouselViewThemeData copyWith({ + Color? backgroundColor, + double? elevation, + OutlinedBorder? shape, + WidgetStateProperty<Color?>? overlayColor, + EdgeInsets? padding, + Clip? itemClipBehavior, + }) { + return CarouselViewThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + shape: shape ?? this.shape, + overlayColor: overlayColor ?? this.overlayColor, + padding: padding ?? this.padding, + itemClipBehavior: itemClipBehavior ?? this.itemClipBehavior, + ); + } + + /// Linearly interpolate between two carousel themes. + /// + /// {@macro dart.ui.shadow.lerp} + static CarouselViewThemeData lerp(CarouselViewThemeData? a, CarouselViewThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return CarouselViewThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t) as OutlinedBorder?, + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + padding: EdgeInsets.lerp(a?.padding, b?.padding, t), + itemClipBehavior: t < 0.5 ? a?.itemClipBehavior : b?.itemClipBehavior, + ); + } + + @override + int get hashCode => + Object.hash(backgroundColor, elevation, shape, overlayColor, padding, itemClipBehavior); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is CarouselViewThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.shape == shape && + other.overlayColor == overlayColor && + other.padding == padding && + other.itemClipBehavior == itemClipBehavior; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty<OutlinedBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<EdgeInsets>('padding', padding, defaultValue: null)); + properties.add(EnumProperty<Clip>('itemClipBehavior', itemClipBehavior, defaultValue: null)); + } +} + +/// Applies a carousel theme to descendant [CarouselView] widgets. +/// +/// Descendant widgets obtain the current theme's [CarouselViewThemeData] using +/// [CarouselViewTheme.of]. When a widget uses [CarouselViewTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// A carousel theme can be specified as part of the overall Material theme using +/// [ThemeData.carouselViewTheme]. +/// +/// See also: +/// +/// * [CarouselViewThemeData], which describes the actual configuration of a carousel +/// theme. +/// * [Theme], which controls the overall theme inheritance. +class CarouselViewTheme extends InheritedTheme { + /// Creates a carousel theme that configures all descendant [CarouselView] widgets. + const CarouselViewTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant carousel widgets. + final CarouselViewThemeData data; + + /// Returns the configuration [data] from the closest [CarouselViewTheme] ancestor. + /// + /// If there is no ancestor, it returns [ThemeData.carouselViewTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// CarouselViewThemeData theme = CarouselViewTheme.of(context); + /// ``` + static CarouselViewThemeData of(BuildContext context) { + final CarouselViewTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType<CarouselViewTheme>(); + return inheritedTheme?.data ?? Theme.of(context).carouselViewTheme; + } + + /// Wraps the given [child] with a [CarouselViewTheme] containing the [data]. + @override + Widget wrap(BuildContext context, Widget child) { + return CarouselViewTheme(data: data, child: child); + } + + /// Returns true if the [data] fields of the two themes are different. + @override + bool updateShouldNotify(CarouselViewTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/checkbox.dart b/packages/material_ui/lib/src/checkbox.dart new file mode 100644 index 000000000000..61b6b42a93fc --- /dev/null +++ b/packages/material_ui/lib/src/checkbox.dart @@ -0,0 +1,1052 @@ +// 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. + +/// @docImport 'checkbox_list_tile.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'slider.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:cupertino_ui/cupertino_ui.dart'; + +import 'checkbox_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// bool _throwShotAway = false; +// late StateSetter setState; + +enum _CheckboxType { material, adaptive } + +/// A Material Design checkbox. +/// +/// The checkbox itself does not maintain any state. Instead, when the state of +/// the checkbox changes, the widget calls the [onChanged] callback. Most +/// widgets that use a checkbox will listen for the [onChanged] callback and +/// rebuild the checkbox with a new [value] to update the visual appearance of +/// the checkbox. +/// +/// The checkbox can optionally display three values - true, false, and null - +/// if [tristate] is true. When [value] is null a dash is displayed. By default +/// [tristate] is false and the checkbox's [value] must be true or false. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// {@tool dartpad} +/// This example shows how you can override the default theme of +/// a [Checkbox] with a [WidgetStateProperty]. +/// In this example, the checkbox's color will be `Colors.blue` when the [Checkbox] +/// is being pressed, hovered, or focused. Otherwise, the checkbox's color will +/// be `Colors.red`. +/// +/// ** See code in examples/api/lib/material/checkbox/checkbox.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows what the checkbox error state looks like. +/// +/// ** See code in examples/api/lib/material/checkbox/checkbox.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CheckboxListTile], which combines this widget with a [ListTile] so that +/// you can give the checkbox a label. +/// * [Switch], a widget with semantics similar to [Checkbox]. +/// * [Radio], for selecting among a set of explicit values. +/// * [Slider], for selecting a value in a range. +/// * <https://material.io/design/components/selection-controls.html#checkboxes> +/// * <https://material.io/design/components/lists.html#types> +class Checkbox extends StatefulWidget { + /// Creates a Material Design checkbox. + /// + /// The checkbox itself does not maintain any state. Instead, when the state of + /// the checkbox changes, the widget calls the [onChanged] callback. Most + /// widgets that use a checkbox will listen for the [onChanged] callback and + /// rebuild the checkbox with a new [value] to update the visual appearance of + /// the checkbox. + /// + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked. The [value] + /// can only be null if [tristate] is true. + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. + const Checkbox({ + super.key, + required this.value, + this.tristate = false, + required this.onChanged, + this.mouseCursor, + this.activeColor, + this.fillColor, + this.checkColor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.autofocus = false, + this.shape, + this.side, + this.isError = false, + this.semanticLabel, + }) : _checkboxType = _CheckboxType.material, + assert(tristate || value != null); + + /// Creates an adaptive [Checkbox] based on whether the target platform is iOS + /// or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// On iOS and macOS, this constructor creates a [CupertinoCheckbox], which has + /// matching functionality and presentation as Material checkboxes, and are the + /// graphics expected on iOS. On other platforms, this creates a Material + /// design [Checkbox]. + /// + /// If a [CupertinoCheckbox] is created, the following parameters are ignored: + /// [fillColor], [hoverColor], [overlayColor], [splashRadius], + /// [materialTapTargetSize], [visualDensity], [isError]. However, [shape] and + /// [side] will still affect the [CupertinoCheckbox] and should be handled if + /// native fidelity is important. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + const Checkbox.adaptive({ + super.key, + required this.value, + this.tristate = false, + required this.onChanged, + this.mouseCursor, + this.activeColor, + this.fillColor, + this.checkColor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.autofocus = false, + this.shape, + this.side, + this.isError = false, + this.semanticLabel, + }) : _checkboxType = _CheckboxType.adaptive, + assert(tristate || value != null); + + /// Whether this checkbox is checked. + /// + /// When [tristate] is true, a value of null corresponds to the mixed state. + /// When [tristate] is false, this value must not be null. + final bool? value; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox with the new + /// value. + /// + /// If this callback is null, the checkbox will be displayed as disabled + /// and will not respond to input gestures. + /// + /// When the checkbox is tapped, if [tristate] is false (the default) then + /// the [onChanged] callback will be applied to `!value`. If [tristate] is + /// true this callback cycle from false to true to null. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// Checkbox( + /// value: _throwShotAway, + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue!; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged<bool?>? onChanged; + + /// {@template flutter.material.checkbox.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// When [value] is null and [tristate] is true, [WidgetState.selected] is + /// included as a state. + /// + /// If this property is null, the value of [CheckboxThemeData.mouseCursor] is used. + /// If that is also null, [WidgetStateMouseCursor.adaptiveClickable] is used. + final MouseCursor? mouseCursor; + + /// The color to use when this checkbox is checked. + /// + /// Defaults to [ColorScheme.secondary]. + /// + /// If [fillColor] returns a non-null color in the [WidgetState.selected] + /// state, it will be used instead of this color. + final Color? activeColor; + + /// {@template flutter.material.checkbox.fillColor} + /// The color that fills the checkbox, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [fillColor] based on the current [WidgetState] + /// of the [Checkbox], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Checkbox( + /// value: true, + /// onChanged: (_){}, + /// fillColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return Colors.orange.withValues(alpha: .32); + /// } + /// return Colors.orange; + /// }) + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If null, then the value of [activeColor] is used in the selected + /// state. If that is also null, the value of [CheckboxThemeData.fillColor] + /// is used. If that is also null, then [ThemeData.disabledColor] is used in + /// the disabled state, [ColorScheme.secondary] is used in the + /// selected state, and [ThemeData.unselectedWidgetColor] is used in the + /// default state. + final WidgetStateProperty<Color?>? fillColor; + + /// {@template flutter.material.checkbox.checkColor} + /// The color to use for the check icon when this checkbox is checked. + /// {@endtemplate} + /// + /// If null, then the value of [CheckboxThemeData.checkColor] is used. If + /// that is also null, then Color(0xFFFFFFFF) is used. + final Color? checkColor; + + /// If true the checkbox's [value] can be true, false, or null. + /// + /// [Checkbox] displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null. + final bool tristate; + + /// {@template flutter.material.checkbox.materialTapTargetSize} + /// Configures the minimum size of the tap target. + /// {@endtemplate} + /// + /// If null, then the value of [CheckboxThemeData.materialTapTargetSize] is + /// used. If that is also null, then the value of + /// [ThemeData.materialTapTargetSize] is used. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@template flutter.material.checkbox.visualDensity} + /// Defines how compact the checkbox's layout will be. + /// {@endtemplate} + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// If null, then the value of [CheckboxThemeData.visualDensity] is used. If + /// that is also null and if [ThemeData.useMaterial3] is false, then the + /// value of [ThemeData.visualDensity] is used. Otherwise, the default value + /// is [VisualDensity.standard]. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// The color for the checkbox's [Material] when it has the input focus. + /// + /// If [overlayColor] returns a non-null color in the [WidgetState.focused] + /// state, it will be used instead. + /// + /// If null, then the value of [CheckboxThemeData.overlayColor] is used in the + /// focused state. If that is also null, then the value of + /// [ThemeData.focusColor] is used. + final Color? focusColor; + + /// {@template flutter.material.checkbox.hoverColor} + /// The color for the checkbox's [Material] when a pointer is hovering over it. + /// + /// If [overlayColor] returns a non-null color in the [WidgetState.hovered] + /// state, it will be used instead. + /// {@endtemplate} + /// + /// If null, then the value of [CheckboxThemeData.overlayColor] is used in the + /// hovered state. If that is also null, then the value of + /// [ThemeData.hoverColor] is used. + final Color? hoverColor; + + /// {@template flutter.material.checkbox.overlayColor} + /// The color for the checkbox's [Material]. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// {@endtemplate} + /// + /// If null, then the value of [activeColor] with alpha + /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the + /// pressed, focused and hovered state. If that is also null, + /// the value of [CheckboxThemeData.overlayColor] is used. If that is + /// also null, then the value of [ColorScheme.secondary] with alpha + /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] + /// is used in the pressed, focused and hovered state. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@template flutter.material.checkbox.splashRadius} + /// The splash radius of the circular [Material] ink response. + /// {@endtemplate} + /// + /// If null, then the value of [CheckboxThemeData.splashRadius] is used. If + /// that is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@template flutter.material.checkbox.shape} + /// The shape of the checkbox's [Material]. + /// {@endtemplate} + /// + /// If this property is null then the ambient [CheckboxThemeData.shape] + /// is used. If that's null then the shape will be a [RoundedRectangleBorder] + /// with a circular corner radius of 1.0 in Material 2, and 2.0 in Material 3. + final OutlinedBorder? shape; + + /// {@template flutter.material.checkbox.side} + /// The color and width of the checkbox's border. + /// + /// This property can be a [WidgetStateBorderSide] that can + /// specify different border color and widths depending on the + /// checkbox's state. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// * [WidgetState.error]. + /// + /// If this property is not a [WidgetStateBorderSide] and it is + /// non-null, then it is only rendered when the checkbox's value is + /// false. The difference in interpretation is for backwards + /// compatibility. + /// {@endtemplate} + /// + /// If this property is null, then the ambient [CheckboxThemeData.side] is + /// used. If that is also null, then the side will be width 2. + final BorderSide? side; + + /// {@template flutter.material.checkbox.isError} + /// True if this checkbox wants to show an error state. + /// + /// The checkbox will have different default container color and check color when + /// this is true. This is only used when [ThemeData.useMaterial3] is set to true. + /// {@endtemplate} + /// + /// Defaults to false. + final bool isError; + + /// {@template flutter.material.checkbox.semanticLabel} + /// The semantic label for the checkbox that will be announced by screen readers. + /// + /// This is announced by assistive technologies (e.g TalkBack/VoiceOver). + /// + /// This label does not show in the UI. + /// {@endtemplate} + final String? semanticLabel; + + /// The width of a checkbox widget. + static const double width = 18.0; + + final _CheckboxType _checkboxType; + + @override + State<Checkbox> createState() => _CheckboxState(); +} + +class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin, ToggleableStateMixin { + final _CheckboxPainter _painter = _CheckboxPainter(); + bool? _previousValue; + + @override + void initState() { + super.initState(); + _previousValue = widget.value; + } + + @override + void didUpdateWidget(Checkbox oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _previousValue = oldWidget.value; + animateToValue(); + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + @override + ValueChanged<bool?>? get onChanged => widget.onChanged; + + @override + bool get tristate => widget.tristate; + + @override + bool? get value => widget.value; + + @override + Duration? get reactionAnimationDuration => kRadialReactionDuration; + + WidgetStateProperty<Color?> get _widgetFillColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return widget.activeColor; + } + return null; + }); + } + + BorderSide? _resolveSide(BorderSide? side, Set<WidgetState> states) { + if (side is WidgetStateBorderSide) { + return WidgetStateProperty.resolveAs<BorderSide?>(side, states); + } + if (!states.contains(WidgetState.selected)) { + return side; + } + return null; + } + + @override + Widget build(BuildContext context) { + switch (widget._checkboxType) { + case _CheckboxType.material: + break; + + case _CheckboxType.adaptive: + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoCheckbox( + value: value, + tristate: tristate, + onChanged: onChanged, + mouseCursor: widget.mouseCursor, + activeColor: widget.activeColor, + checkColor: widget.checkColor, + focusColor: widget.focusColor, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + side: widget.side, + shape: widget.shape, + semanticLabel: widget.semanticLabel, + ); + } + } + + assert(debugCheckHasMaterial(context)); + final CheckboxThemeData checkboxTheme = CheckboxTheme.of(context); + final CheckboxThemeData defaults = Theme.of(context).useMaterial3 + ? _CheckboxDefaultsM3(context) + : _CheckboxDefaultsM2(context); + final MaterialTapTargetSize effectiveMaterialTapTargetSize = + widget.materialTapTargetSize ?? + checkboxTheme.materialTapTargetSize ?? + defaults.materialTapTargetSize!; + final VisualDensity effectiveVisualDensity = + widget.visualDensity ?? checkboxTheme.visualDensity ?? defaults.visualDensity!; + Size size = switch (effectiveMaterialTapTargetSize) { + MaterialTapTargetSize.padded => const Size( + kMinInteractiveDimension, + kMinInteractiveDimension, + ), + MaterialTapTargetSize.shrinkWrap => const Size( + kMinInteractiveDimension - 8.0, + kMinInteractiveDimension - 8.0, + ), + }; + size += effectiveVisualDensity.baseSizeAdjustment; + + final WidgetStateProperty<MouseCursor> effectiveMouseCursor = + WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) { + return WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? + checkboxTheme.mouseCursor?.resolve(states) ?? + WidgetStateMouseCursor.adaptiveClickable.resolve(states); + }); + + // Colors need to be resolved in selected and non selected states separately + final Set<WidgetState> activeStates = states..add(WidgetState.selected); + final Set<WidgetState> inactiveStates = states..remove(WidgetState.selected); + if (widget.isError) { + activeStates.add(WidgetState.error); + inactiveStates.add(WidgetState.error); + } + final Color? activeColor = + widget.fillColor?.resolve(activeStates) ?? + _widgetFillColor.resolve(activeStates) ?? + checkboxTheme.fillColor?.resolve(activeStates); + final Color effectiveActiveColor = activeColor ?? defaults.fillColor!.resolve(activeStates)!; + final Color? inactiveColor = + widget.fillColor?.resolve(inactiveStates) ?? + _widgetFillColor.resolve(inactiveStates) ?? + checkboxTheme.fillColor?.resolve(inactiveStates); + final Color effectiveInactiveColor = + inactiveColor ?? defaults.fillColor!.resolve(inactiveStates)!; + + final BorderSide activeSide = + _resolveSide(widget.side, activeStates) ?? + _resolveSide(checkboxTheme.side, activeStates) ?? + _resolveSide(defaults.side, activeStates)!; + final BorderSide inactiveSide = + _resolveSide(widget.side, inactiveStates) ?? + _resolveSide(checkboxTheme.side, inactiveStates) ?? + _resolveSide(defaults.side, inactiveStates)!; + + final Set<WidgetState> focusedStates = states..add(WidgetState.focused); + if (widget.isError) { + focusedStates.add(WidgetState.error); + } + Color effectiveFocusOverlayColor = + widget.overlayColor?.resolve(focusedStates) ?? + widget.focusColor ?? + checkboxTheme.overlayColor?.resolve(focusedStates) ?? + defaults.overlayColor!.resolve(focusedStates)!; + + final Set<WidgetState> hoveredStates = states..add(WidgetState.hovered); + if (widget.isError) { + hoveredStates.add(WidgetState.error); + } + Color effectiveHoverOverlayColor = + widget.overlayColor?.resolve(hoveredStates) ?? + widget.hoverColor ?? + checkboxTheme.overlayColor?.resolve(hoveredStates) ?? + defaults.overlayColor!.resolve(hoveredStates)!; + + final activePressedStates = activeStates..add(WidgetState.pressed); + final Color effectiveActivePressedOverlayColor = + widget.overlayColor?.resolve(activePressedStates) ?? + checkboxTheme.overlayColor?.resolve(activePressedStates) ?? + activeColor?.withAlpha(kRadialReactionAlpha) ?? + defaults.overlayColor!.resolve(activePressedStates)!; + + final inactivePressedStates = inactiveStates..add(WidgetState.pressed); + final Color effectiveInactivePressedOverlayColor = + widget.overlayColor?.resolve(inactivePressedStates) ?? + checkboxTheme.overlayColor?.resolve(inactivePressedStates) ?? + inactiveColor?.withAlpha(kRadialReactionAlpha) ?? + defaults.overlayColor!.resolve(inactivePressedStates)!; + + if (downPosition != null) { + effectiveHoverOverlayColor = states.contains(WidgetState.selected) + ? effectiveActivePressedOverlayColor + : effectiveInactivePressedOverlayColor; + effectiveFocusOverlayColor = states.contains(WidgetState.selected) + ? effectiveActivePressedOverlayColor + : effectiveInactivePressedOverlayColor; + } + + final Set<WidgetState> checkStates = widget.isError ? (states..add(WidgetState.error)) : states; + final Color effectiveCheckColor = + widget.checkColor ?? + checkboxTheme.checkColor?.resolve(checkStates) ?? + defaults.checkColor!.resolve(checkStates)!; + + final double effectiveSplashRadius = + widget.splashRadius ?? checkboxTheme.splashRadius ?? defaults.splashRadius!; + + return Semantics( + label: widget.semanticLabel, + checked: widget.value ?? false, + mixed: widget.tristate ? widget.value == null : null, + child: buildToggleable( + mouseCursor: effectiveMouseCursor, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + size: size, + painter: _painter + ..position = position + ..reaction = reaction + ..reactionFocusFade = reactionFocusFade + ..reactionHoverFade = reactionHoverFade + ..inactiveReactionColor = effectiveInactivePressedOverlayColor + ..reactionColor = effectiveActivePressedOverlayColor + ..hoverColor = effectiveHoverOverlayColor + ..focusColor = effectiveFocusOverlayColor + ..splashRadius = effectiveSplashRadius + ..downPosition = downPosition + ..isFocused = states.contains(WidgetState.focused) + ..isHovered = states.contains(WidgetState.hovered) + ..activeColor = effectiveActiveColor + ..inactiveColor = effectiveInactiveColor + ..checkColor = effectiveCheckColor + ..value = value + ..previousValue = _previousValue + ..shape = widget.shape ?? checkboxTheme.shape ?? defaults.shape! + ..activeSide = activeSide + ..inactiveSide = inactiveSide, + ), + ); + } +} + +const double _kEdgeSize = Checkbox.width; +const double _kStrokeWidth = 2.0; + +class _CheckboxPainter extends ToggleablePainter { + Color get checkColor => _checkColor!; + Color? _checkColor; + set checkColor(Color value) { + if (_checkColor == value) { + return; + } + _checkColor = value; + notifyListeners(); + } + + bool? get value => _value; + bool? _value; + set value(bool? value) { + if (_value == value) { + return; + } + _value = value; + notifyListeners(); + } + + bool? get previousValue => _previousValue; + bool? _previousValue; + set previousValue(bool? value) { + if (_previousValue == value) { + return; + } + _previousValue = value; + notifyListeners(); + } + + OutlinedBorder get shape => _shape!; + OutlinedBorder? _shape; + set shape(OutlinedBorder value) { + if (_shape == value) { + return; + } + _shape = value; + notifyListeners(); + } + + BorderSide get activeSide => _activeSide!; + BorderSide? _activeSide; + set activeSide(BorderSide value) { + if (_activeSide == value) { + return; + } + _activeSide = value; + notifyListeners(); + } + + BorderSide get inactiveSide => _inactiveSide!; + BorderSide? _inactiveSide; + set inactiveSide(BorderSide value) { + if (_inactiveSide == value) { + return; + } + _inactiveSide = value; + notifyListeners(); + } + + // The square outer bounds of the checkbox at t, with the specified origin. + // At t == 0.0, the outer rect's size is _kEdgeSize (Checkbox.width) + // At t == 0.5, .. is _kEdgeSize - _kStrokeWidth + // At t == 1.0, .. is _kEdgeSize + Rect _outerRectAt(Offset origin, double t) { + final double inset = 1.0 - (t - 0.5).abs() * 2.0; + final double size = _kEdgeSize - inset * _kStrokeWidth; + final rect = Rect.fromLTWH(origin.dx + inset, origin.dy + inset, size, size); + return rect; + } + + // The checkbox's fill color + Color _colorAt(double t) { + // As t goes from 0.0 to 0.25, animate from the inactiveColor to activeColor. + return t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0)!; + } + + // White stroke used to paint the check and dash. + Paint _createStrokePaint() { + return Paint() + ..color = checkColor + ..style = PaintingStyle.stroke + ..strokeWidth = _kStrokeWidth; + } + + void _drawBox(Canvas canvas, Rect outer, Paint paint, BorderSide? side) { + if (shape.preferPaintInterior) { + shape.paintInterior(canvas, outer, paint); + } else { + canvas.drawPath(shape.getOuterPath(outer), paint); + } + if (side != null) { + shape.copyWith(side: side).paint(canvas, outer); + } + } + + void _drawCheck(Canvas canvas, Offset origin, double t, Paint paint) { + assert(t >= 0.0 && t <= 1.0); + // As t goes from 0.0 to 1.0, animate the two check mark strokes from the + // short side to the long side. + final path = Path(); + const start = Offset(_kEdgeSize * 0.15, _kEdgeSize * 0.45); + const mid = Offset(_kEdgeSize * 0.4, _kEdgeSize * 0.7); + const end = Offset(_kEdgeSize * 0.85, _kEdgeSize * 0.25); + if (t < 0.5) { + final double strokeT = t * 2.0; + final Offset drawMid = Offset.lerp(start, mid, strokeT)!; + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + drawMid.dx, origin.dy + drawMid.dy); + } else { + final double strokeT = (t - 0.5) * 2.0; + final Offset drawEnd = Offset.lerp(mid, end, strokeT)!; + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.lineTo(origin.dx + drawEnd.dx, origin.dy + drawEnd.dy); + } + canvas.drawPath(path, paint); + } + + void _drawDash(Canvas canvas, Offset origin, double t, Paint paint) { + assert(t >= 0.0 && t <= 1.0); + // As t goes from 0.0 to 1.0, animate the horizontal line from the + // mid point outwards. + const start = Offset(_kEdgeSize * 0.2, _kEdgeSize * 0.5); + const mid = Offset(_kEdgeSize * 0.5, _kEdgeSize * 0.5); + const end = Offset(_kEdgeSize * 0.8, _kEdgeSize * 0.5); + final Offset drawStart = Offset.lerp(start, mid, 1.0 - t)!; + final Offset drawEnd = Offset.lerp(mid, end, t)!; + canvas.drawLine(origin + drawStart, origin + drawEnd, paint); + } + + @override + void paint(Canvas canvas, Size size) { + paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero)); + + final Paint strokePaint = _createStrokePaint(); + final origin = size / 2.0 - const Size.square(_kEdgeSize) / 2.0 as Offset; + final double tNormalized = switch (position.status) { + AnimationStatus.forward || AnimationStatus.completed => position.value, + AnimationStatus.reverse || AnimationStatus.dismissed => 1.0 - position.value, + }; + + // Four cases: false to null, false to true, null to false, true to false + if (previousValue == false || value == false) { + final double t = value == false ? 1.0 - tNormalized : tNormalized; + final Rect outer = _outerRectAt(origin, t); + final paint = Paint()..color = _colorAt(t); + + if (t <= 0.5) { + final BorderSide border = BorderSide.lerp(inactiveSide, activeSide, t); + _drawBox(canvas, outer, paint, border); + } else { + _drawBox(canvas, outer, paint, activeSide); + final double tShrink = (t - 0.5) * 2.0; + if (previousValue == null || value == null) { + _drawDash(canvas, origin, tShrink, strokePaint); + } else { + _drawCheck(canvas, origin, tShrink, strokePaint); + } + } + } else { + // Two cases: null to true, true to null + final Rect outer = _outerRectAt(origin, 1.0); + final paint = Paint()..color = _colorAt(1.0); + + _drawBox(canvas, outer, paint, activeSide); + if (tNormalized <= 0.5) { + final double tShrink = 1.0 - tNormalized * 2.0; + if (previousValue ?? false) { + _drawCheck(canvas, origin, tShrink, strokePaint); + } else { + _drawDash(canvas, origin, tShrink, strokePaint); + } + } else { + final double tExpand = (tNormalized - 0.5) * 2.0; + if (value ?? false) { + _drawCheck(canvas, origin, tExpand, strokePaint); + } else { + _drawDash(canvas, origin, tExpand, strokePaint); + } + } + } + } +} + +// Hand coded defaults based on Material Design 2. +class _CheckboxDefaultsM2 extends CheckboxThemeData { + _CheckboxDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme; + + final ThemeData _theme; + final ColorScheme _colors; + + @override + WidgetStateBorderSide? get side { + return WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return const BorderSide(width: 2.0, color: Colors.transparent); + } + return BorderSide(width: 2.0, color: _theme.disabledColor); + } + if (states.contains(WidgetState.selected)) { + return const BorderSide(width: 2.0, color: Colors.transparent); + } + return BorderSide(width: 2.0, color: _theme.unselectedWidgetColor); + }); + } + + @override + WidgetStateProperty<Color> get fillColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _theme.disabledColor; + } + return Colors.transparent; + } + if (states.contains(WidgetState.selected)) { + return _colors.secondary; + } + return Colors.transparent; + }); + } + + @override + WidgetStateProperty<Color> get checkColor { + return WidgetStateProperty.all<Color>(const Color(0xFFFFFFFF)); + } + + @override + WidgetStateProperty<Color?> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return fillColor.resolve(states).withAlpha(kRadialReactionAlpha); + } + if (states.contains(WidgetState.hovered)) { + return _theme.hoverColor; + } + if (states.contains(WidgetState.focused)) { + return _theme.focusColor; + } + return Colors.transparent; + }); + } + + @override + double get splashRadius => kRadialReactionRadius; + + @override + MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; + + @override + VisualDensity get visualDensity => _theme.visualDensity; + + @override + OutlinedBorder get shape => + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(1.0))); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Checkbox + +// 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. + +// dart format off +class _CheckboxDefaultsM3 extends CheckboxThemeData { + _CheckboxDefaultsM3(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme; + + final ThemeData _theme; + final ColorScheme _colors; + + @override + WidgetStateBorderSide? get side { + return WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return const BorderSide(width: 2.0, color: Colors.transparent); + } + return BorderSide(width: 2.0, color: _colors.onSurface.withOpacity(0.38)); + } + if (states.contains(WidgetState.selected)) { + return const BorderSide(width: 0.0, color: Colors.transparent); + } + if (states.contains(WidgetState.error)) { + return BorderSide(width: 2.0, color: _colors.error); + } + if (states.contains(WidgetState.pressed)) { + return BorderSide(width: 2.0, color: _colors.onSurface); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(width: 2.0, color: _colors.onSurface); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(width: 2.0, color: _colors.onSurface); + } + return BorderSide(width: 2.0, color: _colors.onSurfaceVariant); + }); + } + + @override + WidgetStateProperty<Color> get fillColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _colors.onSurface.withOpacity(0.38); + } + return Colors.transparent; + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.error)) { + return _colors.error; + } + return _colors.primary; + } + return Colors.transparent; + }); + } + + @override + WidgetStateProperty<Color> get checkColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _colors.surface; + } + return Colors.transparent; // No icons available when the checkbox is unselected. + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.error)) { + return _colors.onError; + } + return _colors.onPrimary; + } + return Colors.transparent; // No icons available when the checkbox is unselected. + }); + } + + @override + WidgetStateProperty<Color> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.pressed)) { + return _colors.error.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.error.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.error.withOpacity(0.1); + } + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return Colors.transparent; + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return Colors.transparent; + }); + } + + @override + double get splashRadius => 40.0 / 2; + + @override + MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; + + @override + VisualDensity get visualDensity => VisualDensity.standard; + + @override + OutlinedBorder get shape => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(2.0)), + ); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Checkbox diff --git a/packages/material_ui/lib/src/checkbox_list_tile.dart b/packages/material_ui/lib/src/checkbox_list_tile.dart new file mode 100644 index 000000000000..e583ab10aed2 --- /dev/null +++ b/packages/material_ui/lib/src/checkbox_list_tile.dart @@ -0,0 +1,635 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// @docImport 'package:flutter/scheduler.dart'; +/// +/// @docImport 'color_scheme.dart'; +/// @docImport 'constants.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio_list_tile.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'switch_list_tile.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'checkbox.dart'; +import 'checkbox_theme.dart'; +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late bool? _throwShotAway; +// void setState(VoidCallback fn) { } + +enum _CheckboxType { material, adaptive } + +/// A [ListTile] with a [Checkbox]. In other words, a checkbox with a label. +/// +/// The entire list tile is interactive: tapping anywhere in the tile toggles +/// the checkbox. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=RkSqPAn9szs} +/// +/// The [value], [onChanged], [activeColor] and [checkColor] properties of this widget are +/// identical to the similarly-named properties on the [Checkbox] widget. +/// +/// The [title], [subtitle], [isThreeLine], [dense], and [contentPadding] properties are like +/// those of the same name on [ListTile]. +/// +/// The [selected] property on this widget is similar to the [ListTile.selected] +/// property. This tile's [activeColor] is used for the selected item's text color, or +/// the theme's [CheckboxThemeData.overlayColor] if [activeColor] is null. +/// +/// This widget does not coordinate the [selected] state and the [value] state; to have the list tile +/// appear selected when the checkbox is checked, pass the same value to both. +/// +/// The checkbox is shown on the right by default in left-to-right languages +/// (i.e. the trailing edge). This can be changed using [controlAffinity]. The +/// [secondary] widget is placed on the opposite side. This maps to the +/// [ListTile.leading] and [ListTile.trailing] properties of [ListTile]. +/// +/// This widget requires a [Material] widget ancestor in the tree to paint +/// itself on, which is typically provided by the app's [Scaffold]. +/// The [tileColor], and [selectedTileColor] are not painted by the +/// [CheckboxListTile] itself but by the [Material] widget ancestor. +/// In this case, one can wrap a [Material] widget around the [CheckboxListTile], +/// e.g.: +/// +/// {@tool snippet} +/// ```dart +/// ColoredBox( +/// color: Colors.green, +/// child: Material( +/// child: CheckboxListTile( +/// tileColor: Colors.red, +/// title: const Text('CheckboxListTile with red background'), +/// value: true, +/// onChanged:(bool? value) { }, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Performance considerations when wrapping [CheckboxListTile] with [Material] +/// +/// Wrapping a large number of [CheckboxListTile]s individually with [Material]s +/// is expensive. Consider only wrapping the [CheckboxListTile]s that require it +/// or include a common [Material] ancestor where possible. +/// +/// To show the [CheckboxListTile] as disabled, pass null as the [onChanged] +/// callback. +/// +/// {@tool dartpad} +/// ![CheckboxListTile sample](https://flutter.github.io/assets-for-api-docs/assets/material/checkbox_list_tile.png) +/// +/// This widget shows a checkbox that, when checked, slows down all animations +/// (including the animation of the checkbox itself getting checked!). +/// +/// This sample requires that you also import 'package:flutter/scheduler.dart', +/// so that you can reference [timeDilation]. +/// +/// ** See code in examples/api/lib/material/checkbox_list_tile/checkbox_list_tile.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample demonstrates how [CheckboxListTile] positions the checkbox widget +/// relative to the text in different configurations. +/// +/// ** See code in examples/api/lib/material/checkbox_list_tile/checkbox_list_tile.1.dart ** +/// {@end-tool} +/// +/// ## Semantics in CheckboxListTile +/// +/// Since the entirety of the CheckboxListTile is interactive, it should represent +/// itself as a single interactive entity. +/// +/// To do so, a CheckboxListTile widget wraps its children with a [MergeSemantics] +/// widget. [MergeSemantics] will attempt to merge its descendant [Semantics] +/// nodes into one node in the semantics tree. Therefore, CheckboxListTile will +/// throw an error if any of its children requires its own [Semantics] node. +/// +/// For example, you cannot nest a [RichText] widget as a descendant of +/// CheckboxListTile. [RichText] has an embedded gesture recognizer that +/// requires its own [Semantics] node, which directly conflicts with +/// CheckboxListTile's desire to merge all its descendants' semantic nodes +/// into one. Therefore, it may be necessary to create a custom radio tile +/// widget to accommodate similar use cases. +/// +/// {@tool dartpad} +/// ![Checkbox list tile semantics sample](https://flutter.github.io/assets-for-api-docs/assets/material/checkbox_list_tile_semantics.png) +/// +/// Here is an example of a custom labeled checkbox widget, called +/// LinkedLabelCheckbox, that includes an interactive [RichText] widget that +/// handles tap gestures. +/// +/// ** See code in examples/api/lib/material/checkbox_list_tile/custom_labeled_checkbox.0.dart ** +/// {@end-tool} +/// +/// ## CheckboxListTile isn't exactly what I want +/// +/// If the way CheckboxListTile pads and positions its elements isn't quite +/// what you're looking for, you can create custom labeled checkbox widgets by +/// combining [Checkbox] with other widgets, such as [Text], [Padding] and +/// [InkWell]. +/// +/// {@tool dartpad} +/// ![Custom checkbox list tile sample](https://flutter.github.io/assets-for-api-docs/assets/material/checkbox_list_tile_custom.png) +/// +/// Here is an example of a custom LabeledCheckbox widget, but you can easily +/// make your own configurable widget. +/// +/// ** See code in examples/api/lib/material/checkbox_list_tile/custom_labeled_checkbox.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListTileTheme], which can be used to affect the style of list tiles, +/// including checkbox list tiles. +/// * [RadioListTile], a similar widget for radio buttons. +/// * [SwitchListTile], a similar widget for switches. +/// * [ListTile] and [Checkbox], the widgets from which this widget is made. +class CheckboxListTile extends StatelessWidget { + /// Creates a combination of a list tile and a checkbox. + /// + /// The checkbox tile itself does not maintain any state. Instead, when the + /// state of the checkbox changes, the widget calls the [onChanged] callback. + /// Most widgets that use a checkbox will listen for the [onChanged] callback + /// and rebuild the checkbox tile with a new [value] to update the visual + /// appearance of the checkbox. + /// + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked. The [value] + /// can only be null if [tristate] is true. + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. + const CheckboxListTile({ + super.key, + required this.value, + required this.onChanged, + this.mouseCursor, + this.activeColor, + this.fillColor, + this.checkColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.statesController, + this.autofocus = false, + this.shape, + this.side, + this.isError = false, + this.enabled, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.secondary, + this.selected = false, + this.controlAffinity, + this.contentPadding, + this.tristate = false, + this.checkboxShape, + this.selectedTileColor, + this.onFocusChange, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.checkboxSemanticLabel, + this.checkboxScaleFactor = 1.0, + this.titleAlignment, + this.internalAddSemanticForOnTap = false, + }) : _checkboxType = _CheckboxType.material, + assert(tristate || value != null), + assert(isThreeLine != true || subtitle != null); + + /// Creates a combination of a list tile and a platform adaptive checkbox. + /// + /// The checkbox uses [Checkbox.adaptive] to show a [CupertinoCheckbox] for + /// iOS platforms, or [Checkbox] for all others. + /// + /// All other properties are the same as [CheckboxListTile]. + const CheckboxListTile.adaptive({ + super.key, + required this.value, + required this.onChanged, + this.mouseCursor, + this.activeColor, + this.fillColor, + this.checkColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.statesController, + this.autofocus = false, + this.shape, + this.side, + this.isError = false, + this.enabled, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.secondary, + this.selected = false, + this.controlAffinity, + this.contentPadding, + this.tristate = false, + this.checkboxShape, + this.selectedTileColor, + this.onFocusChange, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.checkboxSemanticLabel, + this.checkboxScaleFactor = 1.0, + this.titleAlignment, + this.internalAddSemanticForOnTap = false, + }) : _checkboxType = _CheckboxType.adaptive, + assert(tristate || value != null), + assert(isThreeLine != true || subtitle != null); + + /// Whether this checkbox is checked. + final bool? value; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox tile with the + /// new value. + /// + /// If null, the checkbox will be displayed as disabled. + /// + /// {@tool snippet} + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CheckboxListTile( + /// value: _throwShotAway, + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue; + /// }); + /// }, + /// title: const Text('Throw away your shot'), + /// ) + /// ``` + /// {@end-tool} + final ValueChanged<bool?>? onChanged; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [CheckboxThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The color to use when this checkbox is checked. + /// + /// Defaults to [ColorScheme.secondary] of the current [Theme]. + final Color? activeColor; + + /// The color that fills the checkbox. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [activeColor] is used in the selected + /// state. If that is also null, the value of [CheckboxThemeData.fillColor] + /// is used. If that is also null, then the default value is used. + final WidgetStateProperty<Color?>? fillColor; + + /// The color to use for the check icon when this checkbox is checked. + /// + /// Defaults to Color(0xFFFFFFFF). + final Color? checkColor; + + /// {@macro flutter.material.checkbox.hoverColor} + final Color? hoverColor; + + /// The color for the checkbox's [Material]. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// + /// If null, then the value of [activeColor] with alpha [kRadialReactionAlpha] + /// and [hoverColor] is used in the pressed and hovered state. If that is also null, + /// the value of [CheckboxThemeData.overlayColor] is used. If that is also null, + /// then the default value is used in the pressed and hovered state. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.material.checkbox.splashRadius} + /// + /// If null, then the value of [CheckboxThemeData.splashRadius] is used. If + /// that is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// {@macro flutter.material.checkbox.materialTapTargetSize} + /// + /// Defaults to [MaterialTapTargetSize.shrinkWrap]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// Defines how compact the list tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + final VisualDensity? visualDensity; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Controls the interactive states of the backing [ListTile]. + final WidgetStatesController? statesController; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.ListTile.shape} + final ShapeBorder? shape; + + /// {@macro flutter.material.checkbox.side} + /// + /// The given value is passed directly to [Checkbox.side]. + /// + /// If this property is null, then [CheckboxThemeData.side] of + /// [ThemeData.checkboxTheme] is used. If that is also null, then the side + /// will be width 2. + final BorderSide? side; + + /// {@macro flutter.material.checkbox.isError} + /// + /// Defaults to false. + final bool isError; + + /// {@macro flutter.material.ListTile.tileColor} + final Color? tileColor; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// A widget to display on the opposite side of the tile from the checkbox. + /// + /// Typically an [Icon] widget. + final Widget? secondary; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If null then the ambient [ListTileThemeData.isThreeLine] is used. + /// If that is also null, the default value is `false`. + final bool? isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileThemeData.dense]. + final bool? dense; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the checkbox is + /// checked, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// Where to place the control relative to the text. + final ListTileControlAffinity? controlAffinity; + + /// Defines insets surrounding the tile's contents. + /// + /// This value will surround the [Checkbox], [title], [subtitle], and [secondary] + /// widgets in [CheckboxListTile]. + /// + /// When the value is null, the [contentPadding] is `EdgeInsets.symmetric(horizontal: 16.0)`. + final EdgeInsetsGeometry? contentPadding; + + /// If true the checkbox's [value] can be true, false, or null. + /// + /// Checkbox displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null. + final bool tristate; + + /// {@macro flutter.material.checkbox.shape} + /// + /// If this property is null then [CheckboxThemeData.shape] of [ThemeData.checkboxTheme] + /// is used. If that's null then the shape will be a [RoundedRectangleBorder] + /// with a circular corner radius of 1.0. + final OutlinedBorder? checkboxShape; + + /// If non-null, defines the background color when [CheckboxListTile.selected] is true. + final Color? selectedTileColor; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.material.ListTile.enableFeedback} + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// {@macro flutter.material.ListTile.horizontalTitleGap} + final double? horizontalTitleGap; + + /// {@macro flutter.material.ListTile.minVerticalPadding} + final double? minVerticalPadding; + + /// {@macro flutter.material.ListTile.minLeadingWidth} + final double? minLeadingWidth; + + /// {@macro flutter.material.ListTile.minTileHeight} + final double? minTileHeight; + + /// Whether the CheckboxListTile is interactive. + /// + /// If false, this list tile is styled with the disabled color from the + /// current [Theme] and the [ListTile.onTap] callback is + /// inoperative. + final bool? enabled; + + /// Defines how [ListTile.leading] and [ListTile.trailing] are + /// vertically aligned relative to the [ListTile]'s titles + /// ([ListTile.title] and [ListTile.subtitle]). + /// + /// If this property is null then [ListTileThemeData.titleAlignment] + /// is used. If that is also null then [ListTileTitleAlignment.threeLine] + /// is used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final ListTileTitleAlignment? titleAlignment; + + /// Whether to add button:true to the semantics if onTap is provided. + /// This is a temporary flag to help changing the behavior of ListTile onTap semantics. + /// + // TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping + // the default value to true. + final bool internalAddSemanticForOnTap; + + /// Controls the scaling factor applied to the [Checkbox] within the [CheckboxListTile]. + /// + /// Defaults to 1.0. + final double checkboxScaleFactor; + + /// {@macro flutter.material.checkbox.semanticLabel} + final String? checkboxSemanticLabel; + + final _CheckboxType _checkboxType; + + void _handleValueChange() { + assert(onChanged != null); + switch (value) { + case false: + onChanged!(true); + case true: + onChanged!(tristate ? null : false); + case null: + onChanged!(false); + } + } + + @override + Widget build(BuildContext context) { + Widget control; + + switch (_checkboxType) { + case _CheckboxType.material: + control = ExcludeFocus( + child: Checkbox( + value: value, + onChanged: enabled ?? true ? onChanged : null, + mouseCursor: mouseCursor, + activeColor: activeColor, + fillColor: fillColor, + checkColor: checkColor, + hoverColor: hoverColor, + overlayColor: overlayColor, + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + autofocus: autofocus, + tristate: tristate, + shape: checkboxShape, + side: side, + isError: isError, + semanticLabel: checkboxSemanticLabel, + ), + ); + case _CheckboxType.adaptive: + control = ExcludeFocus( + child: Checkbox.adaptive( + value: value, + onChanged: enabled ?? true ? onChanged : null, + mouseCursor: mouseCursor, + activeColor: activeColor, + fillColor: fillColor, + checkColor: checkColor, + hoverColor: hoverColor, + overlayColor: overlayColor, + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + autofocus: autofocus, + tristate: tristate, + shape: checkboxShape, + side: side, + isError: isError, + semanticLabel: checkboxSemanticLabel, + ), + ); + } + if (checkboxScaleFactor != 1.0) { + control = Transform.scale(scale: checkboxScaleFactor, child: control); + } + + final ListTileThemeData listTileTheme = ListTileTheme.of(context); + final ListTileControlAffinity effectiveControlAffinity = + controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform; + final (Widget? leading, Widget? trailing) = switch (effectiveControlAffinity) { + ListTileControlAffinity.leading => (control, secondary), + ListTileControlAffinity.trailing || ListTileControlAffinity.platform => (secondary, control), + }; + + final ThemeData theme = Theme.of(context); + final CheckboxThemeData checkboxTheme = CheckboxTheme.of(context); + final states = <WidgetState>{if (selected) WidgetState.selected}; + final Color effectiveActiveColor = + activeColor ?? checkboxTheme.fillColor?.resolve(states) ?? theme.colorScheme.secondary; + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + enabled: enabled ?? onChanged != null, + onTap: onChanged != null ? _handleValueChange : null, + selected: selected, + autofocus: autofocus, + contentPadding: contentPadding, + shape: shape, + selectedTileColor: selectedTileColor, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + statesController: statesController, + onFocusChange: onFocusChange, + enableFeedback: enableFeedback, + horizontalTitleGap: horizontalTitleGap, + minVerticalPadding: minVerticalPadding, + minLeadingWidth: minLeadingWidth, + minTileHeight: minTileHeight, + titleAlignment: titleAlignment, + internalAddSemanticForOnTap: internalAddSemanticForOnTap, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/checkbox_theme.dart b/packages/material_ui/lib/src/checkbox_theme.dart new file mode 100644 index 000000000000..4a32c81db5cb --- /dev/null +++ b/packages/material_ui/lib/src/checkbox_theme.dart @@ -0,0 +1,286 @@ +// 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. + +/// @docImport 'checkbox.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Checkbox] widgets. +/// +/// Descendant widgets obtain the current [CheckboxThemeData] object using +/// [CheckboxTheme.of]. Instances of [CheckboxThemeData] can be +/// customized with [CheckboxThemeData.copyWith]. +/// +/// Typically a [CheckboxThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.checkboxTheme]. +/// +/// All [CheckboxThemeData] properties are `null` by default. When null, the +/// [Checkbox] will use the values from [ThemeData] if they exist, otherwise it +/// will provide its own defaults based on the overall [Theme]'s colorScheme. +/// See the individual [Checkbox] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class CheckboxThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.checkboxTheme]. + const CheckboxThemeData({ + this.mouseCursor, + this.fillColor, + this.checkColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.shape, + this.side, + }); + + /// {@macro flutter.material.checkbox.mouseCursor} + /// + /// If specified, overrides the default value of [Checkbox.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// {@macro flutter.material.checkbox.fillColor} + /// + /// If specified, overrides the default value of [Checkbox.fillColor]. + final WidgetStateProperty<Color?>? fillColor; + + /// {@macro flutter.material.checkbox.checkColor} + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If specified, overrides the default value of [Checkbox.checkColor]. + final WidgetStateProperty<Color?>? checkColor; + + /// {@macro flutter.material.checkbox.overlayColor} + /// + /// If specified, overrides the default value of [Checkbox.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.material.checkbox.splashRadius} + /// + /// If specified, overrides the default value of [Checkbox.splashRadius]. + final double? splashRadius; + + /// {@macro flutter.material.checkbox.materialTapTargetSize} + /// + /// If specified, overrides the default value of + /// [Checkbox.materialTapTargetSize]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@macro flutter.material.checkbox.visualDensity} + /// + /// If specified, overrides the default value of [Checkbox.visualDensity]. + final VisualDensity? visualDensity; + + /// {@macro flutter.material.checkbox.shape} + /// + /// If specified, overrides the default value of [Checkbox.shape]. + final OutlinedBorder? shape; + + /// {@macro flutter.material.checkbox.side} + /// + /// If specified, overrides the default value of [Checkbox.side]. + final BorderSide? side; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + CheckboxThemeData copyWith({ + WidgetStateProperty<MouseCursor?>? mouseCursor, + WidgetStateProperty<Color?>? fillColor, + WidgetStateProperty<Color?>? checkColor, + WidgetStateProperty<Color?>? overlayColor, + double? splashRadius, + MaterialTapTargetSize? materialTapTargetSize, + VisualDensity? visualDensity, + OutlinedBorder? shape, + BorderSide? side, + }) { + return CheckboxThemeData( + mouseCursor: mouseCursor ?? this.mouseCursor, + fillColor: fillColor ?? this.fillColor, + checkColor: checkColor ?? this.checkColor, + overlayColor: overlayColor ?? this.overlayColor, + splashRadius: splashRadius ?? this.splashRadius, + materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize, + visualDensity: visualDensity ?? this.visualDensity, + shape: shape ?? this.shape, + side: side ?? this.side, + ); + } + + /// Linearly interpolate between two [CheckboxThemeData]s. + /// + /// {@macro dart.ui.shadow.lerp} + static CheckboxThemeData lerp(CheckboxThemeData? a, CheckboxThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return CheckboxThemeData( + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + fillColor: WidgetStateProperty.lerp<Color?>(a?.fillColor, b?.fillColor, t, Color.lerp), + checkColor: WidgetStateProperty.lerp<Color?>(a?.checkColor, b?.checkColor, t, Color.lerp), + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + splashRadius: lerpDouble(a?.splashRadius, b?.splashRadius, t), + materialTapTargetSize: t < 0.5 ? a?.materialTapTargetSize : b?.materialTapTargetSize, + visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity, + shape: ShapeBorder.lerp(a?.shape, b?.shape, t) as OutlinedBorder?, + side: _lerpSides(a?.side, b?.side, t), + ); + } + + @override + int get hashCode => Object.hash( + mouseCursor, + fillColor, + checkColor, + overlayColor, + splashRadius, + materialTapTargetSize, + visualDensity, + shape, + side, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is CheckboxThemeData && + other.mouseCursor == mouseCursor && + other.fillColor == fillColor && + other.checkColor == checkColor && + other.overlayColor == overlayColor && + other.splashRadius == splashRadius && + other.materialTapTargetSize == materialTapTargetSize && + other.visualDensity == visualDensity && + other.shape == shape && + other.side == side; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>('fillColor', fillColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'checkColor', + checkColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('splashRadius', splashRadius, defaultValue: null)); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + materialTapTargetSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add(DiagnosticsProperty<OutlinedBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<BorderSide>('side', side, defaultValue: null)); + } + + // Special case because BorderSide.lerp() doesn't support null arguments + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null && b == null) { + return null; + } + if (a is WidgetStateBorderSide) { + a = a.resolve(const <WidgetState>{}); + } + if (b is WidgetStateBorderSide) { + b = b.resolve(const <WidgetState>{}); + } + a ??= BorderSide(width: 0, color: b!.color.withAlpha(0)); + b ??= BorderSide(width: 0, color: a.color.withAlpha(0)); + + return BorderSide.lerp(a, b, t); + } +} + +/// Applies a checkbox theme to descendant [Checkbox] widgets. +/// +/// Descendant widgets obtain the current theme's [CheckboxTheme] object using +/// [CheckboxTheme.of]. When a widget uses [CheckboxTheme.of], it is +/// automatically rebuilt if the theme later changes. +/// +/// A checkbox theme can be specified as part of the overall Material theme +/// using [ThemeData.checkboxTheme]. +/// +/// See also: +/// +/// * [CheckboxThemeData], which describes the actual configuration of a +/// checkbox theme. +class CheckboxTheme extends InheritedWidget { + /// Constructs a checkbox theme that configures all descendant [Checkbox] + /// widgets. + const CheckboxTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [Checkbox] widgets. + final CheckboxThemeData data; + + /// Returns the configuration [data] from the closest [CheckboxTheme] + /// ancestor. If there is no ancestor, it returns [ThemeData.checkboxTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// CheckboxThemeData theme = CheckboxTheme.of(context); + /// ``` + static CheckboxThemeData of(BuildContext context) { + final CheckboxTheme? checkboxTheme = context + .dependOnInheritedWidgetOfExactType<CheckboxTheme>(); + return checkboxTheme?.data ?? Theme.of(context).checkboxTheme; + } + + @override + bool updateShouldNotify(CheckboxTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/chip.dart b/packages/material_ui/lib/src/chip.dart new file mode 100644 index 000000000000..783a221fe5a1 --- /dev/null +++ b/packages/material_ui/lib/src/chip.dart @@ -0,0 +1,2569 @@ +// 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. + +/// @docImport 'action_chip.dart'; +/// @docImport 'app.dart'; +/// @docImport 'choice_chip.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'filter_chip.dart'; +/// @docImport 'input_chip.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show clampDouble, kIsWeb; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'chip_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'icons.dart'; +import 'ink_decoration.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'tooltip.dart'; + +// Some design constants +const double _kChipHeight = 32.0; + +const int _kCheckmarkAlpha = 0xde; // 87% +const int _kDisabledAlpha = 0x61; // 38% +const double _kCheckmarkStrokeWidth = 2.0; + +const Duration _kSelectDuration = Duration(milliseconds: 195); +const Duration _kCheckmarkDuration = Duration(milliseconds: 150); +const Duration _kCheckmarkReverseDuration = Duration(milliseconds: 50); +const Duration _kDrawerDuration = Duration(milliseconds: 150); +const Duration _kReverseDrawerDuration = Duration(milliseconds: 100); +const Duration _kDisableDuration = Duration(milliseconds: 75); + +const Color _kSelectScrimColor = Color(0x60191919); +const Icon _kDefaultDeleteIcon = Icon(Icons.cancel); + +/// An interface defining the base attributes for a Material Design chip. +/// +/// Chips are compact elements that represent an attribute, text, entity, or +/// action. +/// +/// The defaults mentioned in the documentation for each attribute are what +/// the implementing classes typically use for defaults (but this class doesn't +/// provide or enforce them). +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * <https://material.io/design/components/chips.html> +abstract interface class ChipAttributes { + /// The primary content of the chip. + /// + /// Typically a [Text] widget. + Widget get label; + + /// A widget to display prior to the chip's label. + /// + /// Typically a [CircleAvatar] widget. + Widget? get avatar; + + /// The style to be applied to the chip's label. + /// + /// If this is null and [ThemeData.useMaterial3] is true, then + /// [TextTheme.labelLarge] is used. Otherwise, [TextTheme.bodyLarge] + /// is used. + // + /// This only has an effect on widgets that respect the [DefaultTextStyle], + /// such as [Text]. + /// + /// If [TextStyle.color] is a [WidgetStateProperty<Color>], [WidgetStateProperty.resolve] + /// is used for the following [WidgetState]s: + /// + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.pressed]. + TextStyle? get labelStyle; + + /// The color and weight of the chip's outline. + /// + /// Defaults to the border side in the ambient [ChipThemeData]. If the theme + /// border side resolves to null and [ThemeData.useMaterial3] is true, then + /// [BorderSide] with a [ColorScheme.outline] color is used when the chip is + /// enabled, and [BorderSide] with a [ColorScheme.onSurface] color with an + /// opacity of 0.12 is used when the chip is disabled. Otherwise, it defaults + /// to null. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. To omit the outline entirely, pass [BorderSide.none] to [side]. + /// + /// If it is a [WidgetStateBorderSide], [WidgetStateProperty.resolve] is + /// used for the following [WidgetState]s: + /// + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.pressed]. + BorderSide? get side; + + /// The [OutlinedBorder] to draw around the chip. + /// + /// Defaults to the shape in the ambient [ChipThemeData]. If the theme + /// shape resolves to null and [ThemeData.useMaterial3] is true, then + /// [RoundedRectangleBorder] with a circular border radius of 8.0 is used. + /// Otherwise, [StadiumBorder] is used. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. If [side] is not null or side of [shape] is [BorderSide.none], + /// side of [shape] is ignored. To omit the outline entirely, + /// pass [BorderSide.none] to [side]. + /// + /// If it is a [WidgetStateOutlinedBorder], [WidgetStateProperty.resolve] + /// is used for the following [WidgetState]s: + /// + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.pressed]. + OutlinedBorder? get shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + Clip get clipBehavior; + + /// {@macro flutter.widgets.Focus.focusNode} + FocusNode? get focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + bool get autofocus; + + /// The color that fills the chip, in all [WidgetState]s. + /// + /// Defaults to null. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.disabled]. + WidgetStateProperty<Color?>? get color; + + /// Color to be used for the unselected, enabled chip's background. + /// + /// The default is light grey. + Color? get backgroundColor; + + /// The padding between the contents of the chip and the outside [shape]. + /// + /// If this is null and [ThemeData.useMaterial3] is true, then + /// a padding of 8.0 logical pixels on all sides is used. Otherwise, + /// it defaults to a padding of 4.0 logical pixels on all sides. + EdgeInsetsGeometry? get padding; + + /// Defines how compact the chip's layout will be. + /// + /// Chips are unaffected by horizontal density changes. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + VisualDensity? get visualDensity; + + /// The padding around the [label] widget. + /// + /// By default, this is 4 logical pixels at the beginning and the end of the + /// label, and zero on top and bottom. + EdgeInsetsGeometry? get labelPadding; + + /// Configures the minimum size of the tap target. + /// + /// Defaults to [ThemeData.materialTapTargetSize]. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + MaterialTapTargetSize? get materialTapTargetSize; + + /// Elevation to be applied on the chip relative to its parent. + /// + /// This controls the size of the shadow below the chip. + /// + /// Defaults to 0. The value is always non-negative. + double? get elevation; + + /// Color of the chip's shadow when the elevation is greater than 0. + /// + /// If this is null and [ThemeData.useMaterial3] is true, then + /// [Colors.transparent] color is used. Otherwise, it defaults to null. + Color? get shadowColor; + + /// Color of the chip's surface tint overlay when its elevation is + /// greater than 0. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If this is null, defaults to [Colors.transparent]. + Color? get surfaceTintColor; + + /// Theme used for all icons in the chip. + /// + /// If this is null and [ThemeData.useMaterial3] is true, then [IconThemeData] + /// with a [ColorScheme.primary] color and a size of 18.0 is used when + /// the chip is enabled, and [IconThemeData] with a [ColorScheme.onSurface] + /// color and a size of 18.0 is used when the chip is disabled. Otherwise, + /// it defaults to null. + IconThemeData? get iconTheme; + + /// Optional size constraints for the avatar. + /// + /// When unspecified, defaults to a minimum size of chip height or label height + /// (whichever is greater) and a padding of 8.0 pixels on all sides. + /// + /// The default constraints ensure that the avatar is accessible. + /// Specifying this parameter enables creation of avatar smaller than + /// the minimum size, but it is not recommended. + /// + /// {@tool dartpad} + /// This sample shows how to use [avatarBoxConstraints] to adjust avatar size constraints + /// + /// ** See code in examples/api/lib/material/chip/chip_attributes.avatar_box_constraints.0.dart ** + /// {@end-tool} + BoxConstraints? get avatarBoxConstraints; + + /// Used to override the default chip animations durations. + /// + /// If [ChipAnimationStyle.enableAnimation] with duration or reverse duration is + /// provided, it will be used to override the chip enable and disable animation durations. + /// If it is null, then default duration will be 75ms. + /// + /// If [ChipAnimationStyle.selectAnimation] with duration or reverse duration is provided, + /// it will be used to override the chip select and unselect animation durations. + /// If it is null, then default duration will be 195ms. + /// + /// If [ChipAnimationStyle.avatarDrawerAnimation] with duration or reverse duration + /// is provided, it will be used to override the chip checkmark animation duration. + /// If it is null, then default duration will be 150ms. + /// + /// If [ChipAnimationStyle.deleteDrawerAnimation] with duration or reverse duration + /// is provided, it will be used to override the chip delete icon animation duration. + /// If it is null, then default duration will be 150ms. + /// + /// {@tool dartpad} + /// This sample showcases how to override the chip animations durations using + /// [ChipAnimationStyle]. + /// + /// ** See code in examples/api/lib/material/chip/chip_attributes.chip_animation_style.0.dart ** + /// {@end-tool} + ChipAnimationStyle? get chipAnimationStyle; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + MouseCursor? get mouseCursor; +} + +/// An interface for Material Design chips that can be deleted. +/// +/// The defaults mentioned in the documentation for each attribute are what +/// the implementing classes typically use for defaults (but this class doesn't +/// provide or enforce them). +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * <https://material.io/design/components/chips.html> +abstract interface class DeletableChipAttributes { + /// The icon displayed when [onDeleted] is set. + /// + /// If [deleteIconColor] is provided, it will be used as the color of the + /// delete icon. If [deleteIconColor] is null, then the icon will use the + /// color specified in the chip [IconTheme]. If the [IconTheme] is null, then + /// the icon will use the color specified in the [ThemeData.iconTheme]. + /// + /// If a size is specified in the chip [IconTheme], then the delete icon will + /// use that size. Otherwise, defaults to 18 pixels. + /// + /// Defaults to an [Icon] widget set to use [Icons.clear]. + /// If [ThemeData.useMaterial3] is false, then defaults to an [Icon] widget + /// set to use [Icons.cancel]. + Widget? get deleteIcon; + + /// Called when the user taps the [deleteIcon] to delete the chip. + /// + /// If null, the delete button will not appear on the chip. + /// + /// The chip will not automatically remove itself: this just tells the app + /// that the user tapped the delete button. In order to delete the chip, you + /// have to do something similar to the following sample: + /// + /// {@tool dartpad} + /// This sample shows how to use [onDeleted] to remove an entry when the + /// delete button is tapped. + /// + /// ** See code in examples/api/lib/material/chip/deletable_chip_attributes.on_deleted.0.dart ** + /// {@end-tool} + VoidCallback? get onDeleted; + + /// Used to define the delete icon's color with an [IconTheme] that + /// contains the icon. + /// + /// The default is `Color(0xde000000)` + /// (slightly transparent black) for light themes, and `Color(0xdeffffff)` + /// (slightly transparent white) for dark themes. + /// + /// The delete icon appears if [DeletableChipAttributes.onDeleted] is + /// non-null. + Color? get deleteIconColor; + + /// The message to be used for the chip's delete button tooltip. + /// + /// If provided with an empty string, the tooltip of the delete button will be + /// disabled. + /// + /// If null, the default [MaterialLocalizations.deleteButtonTooltip] will be + /// used. + /// + /// If the chip is disabled, the delete button tooltip will not be shown. + String? get deleteButtonTooltipMessage; + + /// Optional size constraints for the delete icon. + /// + /// When unspecified, defaults to a minimum size of chip height or label height + /// (whichever is greater) and a padding of 8.0 pixels on all sides. + /// + /// The default constraints ensure that the delete icon is accessible. + /// Specifying this parameter enables creation of delete icon smaller than + /// the minimum size, but it is not recommended. + /// + /// {@tool dartpad} + /// This sample shows how to use [deleteIconBoxConstraints] to adjust delete icon + /// size constraints. + /// + /// ** See code in examples/api/lib/material/chip/deletable_chip_attributes.delete_icon_box_constraints.0.dart ** + /// {@end-tool} + BoxConstraints? get deleteIconBoxConstraints; +} + +/// An interface for Material Design chips that can have check marks. +/// +/// The defaults mentioned in the documentation for each attribute are what +/// the implementing classes typically use for defaults (but this class doesn't +/// provide or enforce them). +/// +/// See also: +/// +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * <https://material.io/design/components/chips.html> +abstract interface class CheckmarkableChipAttributes { + /// Whether or not to show a check mark when + /// [SelectableChipAttributes.selected] is true. + /// + /// Defaults to true. + bool? get showCheckmark; + + /// [Color] of the chip's check mark when a check mark is visible. + /// + /// This will override the color set by the platform's brightness setting. + /// + /// If null, it will defer to a color selected by the platform's brightness + /// setting. + Color? get checkmarkColor; +} + +/// An interface for Material Design chips that can be selected. +/// +/// The defaults mentioned in the documentation for each attribute are what +/// the implementing classes typically use for defaults (but this class doesn't +/// provide or enforce them). +/// +/// See also: +/// +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * <https://material.io/design/components/chips.html> +abstract interface class SelectableChipAttributes { + /// Whether or not this chip is selected. + /// + /// If [onSelected] is not null, this value will be used to determine if the + /// select check mark will be shown or not. + /// + /// Defaults to false. + bool get selected; + + /// Called when the chip should change between selected and de-selected + /// states. + /// + /// When the chip is tapped, then the [onSelected] callback, if set, will be + /// applied to `!selected` (see [selected]). + /// + /// The chip passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the chip with the new + /// value. + /// + /// The callback provided to [onSelected] should update the state of the + /// parent [StatefulWidget] using the [State.setState] method, so that the + /// parent gets rebuilt. + /// + /// The [onSelected] and [TappableChipAttributes.onPressed] callbacks must not + /// both be specified at the same time. + /// + /// {@tool snippet} + /// + /// A [StatefulWidget] that illustrates use of onSelected in an [InputChip]. + /// + /// ```dart + /// class Wood extends StatefulWidget { + /// const Wood({super.key}); + /// + /// @override + /// State<StatefulWidget> createState() => WoodState(); + /// } + /// + /// class WoodState extends State<Wood> { + /// bool _useChisel = false; + /// + /// @override + /// Widget build(BuildContext context) { + /// return InputChip( + /// label: const Text('Use Chisel'), + /// selected: _useChisel, + /// onSelected: (bool newValue) { + /// setState(() { + /// _useChisel = newValue; + /// }); + /// }, + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + ValueChanged<bool>? get onSelected; + + /// Elevation to be applied on the chip relative to its parent during the + /// press motion. + /// + /// This controls the size of the shadow below the chip. + /// + /// Defaults to 8. The value is always non-negative. + double? get pressElevation; + + /// Color to be used for the chip's background, indicating that it is + /// selected. + /// + /// The chip is selected when [selected] is true. + Color? get selectedColor; + + /// Color of the chip's shadow when the elevation is greater than 0 and the + /// chip is selected. + /// + /// The default is [Colors.black]. + Color? get selectedShadowColor; + + /// Tooltip string to be used for the body area (where the label and avatar + /// are) of the chip. + String? get tooltip; + + /// The shape of the translucent highlight painted over the avatar when the + /// [selected] property is true. + /// + /// Only the outer path of the shape is used. + /// + /// Defaults to [CircleBorder]. + ShapeBorder get avatarBorder; +} + +/// An interface for Material Design chips that can be enabled and disabled. +/// +/// The defaults mentioned in the documentation for each attribute are what +/// the implementing classes typically use for defaults (but this class doesn't +/// provide or enforce them). +/// +/// See also: +/// +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * <https://material.io/design/components/chips.html> +abstract interface class DisabledChipAttributes { + /// Whether or not this chip is enabled for input. + /// + /// If this is true, but all of the user action callbacks are null (i.e. + /// [SelectableChipAttributes.onSelected], [TappableChipAttributes.onPressed], + /// and [DeletableChipAttributes.onDeleted]), then the + /// control will still be shown as disabled. + /// + /// This is typically used if you want the chip to be disabled, but also show + /// a delete button. + /// + /// For classes which don't have this as a constructor argument, [isEnabled] + /// returns true if their user action callback is set. + /// + /// Defaults to true. + bool get isEnabled; + + /// The color used for the chip's background to indicate that it is not + /// enabled. + /// + /// The chip is disabled when [isEnabled] is false, or all three of + /// [SelectableChipAttributes.onSelected], [TappableChipAttributes.onPressed], + /// and [DeletableChipAttributes.onDeleted] are null. + /// + /// It defaults to [Colors.black38]. + Color? get disabledColor; +} + +/// An interface for Material Design chips that can be tapped. +/// +/// The defaults mentioned in the documentation for each attribute are what +/// the implementing classes typically use for defaults (but this class doesn't +/// provide or enforce them). +/// +/// See also: +/// +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * <https://material.io/design/components/chips.html> +abstract interface class TappableChipAttributes { + /// Called when the user taps the chip. + /// + /// If [onPressed] is set, then this callback will be called when the user + /// taps on the label or avatar parts of the chip. If [onPressed] is null, + /// then the chip will be disabled. + /// + /// {@tool snippet} + /// + /// ```dart + /// class Blacksmith extends StatelessWidget { + /// const Blacksmith({super.key}); + /// + /// void startHammering() { + /// print('bang bang bang'); + /// } + /// + /// @override + /// Widget build(BuildContext context) { + /// return InputChip( + /// label: const Text('Apply Hammer'), + /// onPressed: startHammering, + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + VoidCallback? get onPressed; + + /// Elevation to be applied on the chip relative to its parent during the + /// press motion. + /// + /// This controls the size of the shadow below the chip. + /// + /// Defaults to 8. The value is always non-negative. + double? get pressElevation; + + /// Tooltip string to be used for the body area (where the label and avatar + /// are) of the chip. + String? get tooltip; +} + +/// A helper class that overrides the default chip animation parameters. +class ChipAnimationStyle { + /// Creates an instance of Chip Animation Style class. + ChipAnimationStyle({ + this.enableAnimation, + this.selectAnimation, + this.avatarDrawerAnimation, + this.deleteDrawerAnimation, + }); + + /// If [enableAnimation] with duration or reverse duration is provided, + /// it will be used to override the chip enable and disable animation durations. + /// If it is null, then default duration will be 75ms. + final AnimationStyle? enableAnimation; + + /// If [selectAnimation] with duration or reverse duration is provided, + /// it will be used to override the chip select and unselect animation durations. + /// If it is null, then default duration will be 195ms. + final AnimationStyle? selectAnimation; + + /// If [avatarDrawerAnimation] with duration or reverse duration is provided, + /// it will be used to override the chip checkmark animation duration. If it + /// is null, then default duration will be 150ms. + final AnimationStyle? avatarDrawerAnimation; + + /// If [deleteDrawerAnimation] with duration or reverse duration is provided, + /// it will be used to override the chip delete icon animation duration. If it + /// is null, then default duration will be 150ms. + final AnimationStyle? deleteDrawerAnimation; +} + +/// A Material Design chip. +/// +/// Chips are compact elements that represent an attribute, text, entity, or +/// action. +/// +/// Supplying a non-null [onDeleted] callback will cause the chip to include a +/// button for deleting the chip. +/// +/// Its ancestors must include [Material], [MediaQuery], [Directionality], and +/// [MaterialLocalizations]. Typically all of these widgets are provided by +/// [MaterialApp] and [Scaffold]. The [label] and [clipBehavior] arguments must +/// not be null. +/// +/// {@tool snippet} +/// +/// ```dart +/// Chip( +/// avatar: CircleAvatar( +/// backgroundColor: Colors.grey.shade800, +/// child: const Text('AB'), +/// ), +/// label: const Text('Aaron Burr'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * [CircleAvatar], which shows images or initials of entities. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * <https://material.io/design/components/chips.html> +class Chip extends StatelessWidget implements ChipAttributes, DeletableChipAttributes { + /// Creates a Material Design chip. + /// + /// The [elevation] must be null or non-negative. + const Chip({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.deleteIcon, + this.onDeleted, + this.deleteIconColor, + this.deleteButtonTooltipMessage, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(elevation == null || elevation >= 0.0); + + @override + final Widget? avatar; + @override + final Widget label; + @override + final TextStyle? labelStyle; + @override + final EdgeInsetsGeometry? labelPadding; + @override + final BorderSide? side; + @override + final OutlinedBorder? shape; + @override + final Clip clipBehavior; + @override + final FocusNode? focusNode; + @override + final bool autofocus; + @override + final WidgetStateProperty<Color?>? color; + @override + final Color? backgroundColor; + @override + final EdgeInsetsGeometry? padding; + @override + final VisualDensity? visualDensity; + @override + final Widget? deleteIcon; + @override + final VoidCallback? onDeleted; + @override + final Color? deleteIconColor; + @override + final String? deleteButtonTooltipMessage; + @override + final MaterialTapTargetSize? materialTapTargetSize; + @override + final double? elevation; + @override + final Color? shadowColor; + @override + final Color? surfaceTintColor; + @override + final IconThemeData? iconTheme; + @override + final BoxConstraints? avatarBoxConstraints; + @override + final BoxConstraints? deleteIconBoxConstraints; + @override + final ChipAnimationStyle? chipAnimationStyle; + @override + final MouseCursor? mouseCursor; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + return RawChip( + avatar: avatar, + label: label, + labelStyle: labelStyle, + labelPadding: labelPadding, + deleteIcon: deleteIcon, + onDeleted: onDeleted, + deleteIconColor: deleteIconColor, + deleteButtonTooltipMessage: deleteButtonTooltipMessage, + tapEnabled: false, + side: side, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + color: color, + backgroundColor: backgroundColor, + padding: padding, + visualDensity: visualDensity, + materialTapTargetSize: materialTapTargetSize, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + iconTheme: iconTheme, + avatarBoxConstraints: avatarBoxConstraints, + deleteIconBoxConstraints: deleteIconBoxConstraints, + chipAnimationStyle: chipAnimationStyle, + mouseCursor: mouseCursor, + ); + } +} + +/// A raw Material Design chip. +/// +/// This serves as the basis for all of the chip widget types to aggregate. +/// It is typically not created directly, one of the other chip types +/// that are appropriate for the use case are used instead: +/// +/// * [Chip] a simple chip that can only display information and be deleted. +/// * [InputChip] represents a complex piece of information, such as an entity +/// (person, place, or thing) or conversational text, in a compact form. +/// * [ChoiceChip] allows a single selection from a set of options. +/// * [FilterChip] a chip that uses tags or descriptive words as a way to +/// filter content. +/// * [ActionChip]s display a set of actions related to primary content. +/// +/// Raw chips are typically only used if you want to create your own custom chip +/// type. +/// +/// Raw chips can be selected by setting [onSelected], deleted by setting +/// [onDeleted], and pushed like a button with [onPressed]. They have a [label], +/// and they can have a leading icon (see [avatar]) and a trailing icon +/// ([deleteIcon]). Colors and padding can be customized. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// See also: +/// +/// * [CircleAvatar], which shows images or initials of people. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * <https://material.io/design/components/chips.html> +class RawChip extends StatefulWidget + implements + ChipAttributes, + DeletableChipAttributes, + SelectableChipAttributes, + CheckmarkableChipAttributes, + DisabledChipAttributes, + TappableChipAttributes { + /// Creates a RawChip. + /// + /// The [onPressed] and [onSelected] callbacks must not both be specified at + /// the same time. + /// + /// The [pressElevation] and [elevation] must be null or non-negative. + /// Typically, [pressElevation] is greater than [elevation]. + const RawChip({ + super.key, + this.defaultProperties, + this.avatar, + required this.label, + this.labelStyle, + this.padding, + this.visualDensity, + this.labelPadding, + Widget? deleteIcon, + this.onDeleted, + this.deleteIconColor, + this.deleteButtonTooltipMessage, + this.onPressed, + this.onSelected, + this.pressElevation, + this.tapEnabled = true, + this.selected = false, + this.isEnabled = true, + this.disabledColor, + this.selectedColor, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.avatarBorder = const CircleBorder(), + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + deleteIcon = deleteIcon ?? _kDefaultDeleteIcon; + + /// Defines the defaults for the chip properties if + /// they are not specified elsewhere. + /// + /// If null then [ChipThemeData.fromDefaults] will be used + /// for the default properties. + final ChipThemeData? defaultProperties; + + @override + final Widget? avatar; + @override + final Widget label; + @override + final TextStyle? labelStyle; + @override + final EdgeInsetsGeometry? labelPadding; + @override + final Widget deleteIcon; + @override + final VoidCallback? onDeleted; + @override + final Color? deleteIconColor; + @override + final String? deleteButtonTooltipMessage; + @override + final ValueChanged<bool>? onSelected; + @override + final VoidCallback? onPressed; + @override + final double? pressElevation; + @override + final bool selected; + @override + final bool isEnabled; + @override + final Color? disabledColor; + @override + final Color? selectedColor; + @override + final String? tooltip; + @override + final BorderSide? side; + @override + final OutlinedBorder? shape; + @override + final Clip clipBehavior; + @override + final FocusNode? focusNode; + @override + final bool autofocus; + @override + final WidgetStateProperty<Color?>? color; + @override + final Color? backgroundColor; + @override + final EdgeInsetsGeometry? padding; + @override + final VisualDensity? visualDensity; + @override + final MaterialTapTargetSize? materialTapTargetSize; + @override + final double? elevation; + @override + final Color? shadowColor; + @override + final Color? surfaceTintColor; + @override + final IconThemeData? iconTheme; + @override + final Color? selectedShadowColor; + @override + final bool? showCheckmark; + @override + final Color? checkmarkColor; + @override + final ShapeBorder avatarBorder; + @override + final BoxConstraints? avatarBoxConstraints; + @override + final BoxConstraints? deleteIconBoxConstraints; + @override + final ChipAnimationStyle? chipAnimationStyle; + @override + final MouseCursor? mouseCursor; + + /// If set, this indicates that the chip should be disabled if all of the + /// tap callbacks ([onSelected], [onPressed]) are null. + /// + /// For example, the [Chip] class sets this to false because it can't be + /// disabled, even if no callbacks are set on it, since it is used for + /// displaying information only. + /// + /// Defaults to true. + final bool tapEnabled; + + @override + State<RawChip> createState() => _RawChipState(); +} + +class _RawChipState extends State<RawChip> with TickerProviderStateMixin<RawChip> { + static const Duration pressedAnimationDuration = Duration(milliseconds: 75); + + late AnimationController selectController; + late AnimationController avatarDrawerController; + late AnimationController deleteDrawerController; + late AnimationController enableController; + late CurvedAnimation checkmarkAnimation; + late CurvedAnimation avatarDrawerAnimation; + late CurvedAnimation deleteDrawerAnimation; + late CurvedAnimation enableAnimation; + late CurvedAnimation selectionFade; + + final WidgetStatesController statesController = WidgetStatesController(); + + bool get hasDeleteButton => widget.onDeleted != null; + bool get hasAvatar => widget.avatar != null; + + bool get canTap { + return widget.isEnabled && + widget.tapEnabled && + (widget.onPressed != null || widget.onSelected != null); + } + + bool _isTapping = false; + bool get isTapping => canTap && _isTapping; + + @override + void initState() { + assert(widget.onSelected == null || widget.onPressed == null); + super.initState(); + statesController + ..update(WidgetState.disabled, !widget.isEnabled) + ..update(WidgetState.selected, widget.selected) + ..addListener(() => setState(() {})); + selectController = AnimationController( + duration: widget.chipAnimationStyle?.selectAnimation?.duration ?? _kSelectDuration, + reverseDuration: widget.chipAnimationStyle?.selectAnimation?.reverseDuration, + value: widget.selected ? 1.0 : 0.0, + vsync: this, + ); + selectionFade = CurvedAnimation(parent: selectController, curve: Curves.fastOutSlowIn); + avatarDrawerController = AnimationController( + duration: widget.chipAnimationStyle?.avatarDrawerAnimation?.duration ?? _kDrawerDuration, + reverseDuration: widget.chipAnimationStyle?.avatarDrawerAnimation?.reverseDuration, + value: hasAvatar || widget.selected ? 1.0 : 0.0, + vsync: this, + ); + deleteDrawerController = AnimationController( + duration: widget.chipAnimationStyle?.deleteDrawerAnimation?.duration ?? _kDrawerDuration, + reverseDuration: widget.chipAnimationStyle?.deleteDrawerAnimation?.reverseDuration, + value: hasDeleteButton ? 1.0 : 0.0, + vsync: this, + ); + enableController = AnimationController( + duration: widget.chipAnimationStyle?.enableAnimation?.duration ?? _kDisableDuration, + reverseDuration: widget.chipAnimationStyle?.enableAnimation?.reverseDuration, + value: widget.isEnabled ? 1.0 : 0.0, + vsync: this, + ); + + // These will delay the start of some animations, and/or reduce their + // length compared to the overall select animation, using Intervals. + final double checkmarkPercentage = + _kCheckmarkDuration.inMilliseconds / _kSelectDuration.inMilliseconds; + final double checkmarkReversePercentage = + _kCheckmarkReverseDuration.inMilliseconds / _kSelectDuration.inMilliseconds; + final double avatarDrawerReversePercentage = + _kReverseDrawerDuration.inMilliseconds / _kSelectDuration.inMilliseconds; + checkmarkAnimation = CurvedAnimation( + parent: selectController, + curve: Interval(1.0 - checkmarkPercentage, 1.0, curve: Curves.fastOutSlowIn), + reverseCurve: Interval(1.0 - checkmarkReversePercentage, 1.0, curve: Curves.fastOutSlowIn), + ); + deleteDrawerAnimation = CurvedAnimation( + parent: deleteDrawerController, + curve: Curves.fastOutSlowIn, + ); + avatarDrawerAnimation = CurvedAnimation( + parent: avatarDrawerController, + curve: Curves.fastOutSlowIn, + reverseCurve: Interval(1.0 - avatarDrawerReversePercentage, 1.0, curve: Curves.fastOutSlowIn), + ); + enableAnimation = CurvedAnimation(parent: enableController, curve: Curves.fastOutSlowIn); + } + + @override + void dispose() { + selectController.dispose(); + avatarDrawerController.dispose(); + deleteDrawerController.dispose(); + enableController.dispose(); + checkmarkAnimation.dispose(); + avatarDrawerAnimation.dispose(); + deleteDrawerAnimation.dispose(); + enableAnimation.dispose(); + selectionFade.dispose(); + statesController.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + if (!canTap) { + return; + } + statesController.update(WidgetState.pressed, true); + setState(() { + _isTapping = true; + }); + } + + void _handleTapCancel() { + if (!canTap) { + return; + } + statesController.update(WidgetState.pressed, false); + setState(() { + _isTapping = false; + }); + } + + void _handleTap() { + if (!canTap) { + return; + } + statesController.update(WidgetState.pressed, false); + setState(() { + _isTapping = false; + }); + // Only one of these can be set, so only one will be called. + widget.onSelected?.call(!widget.selected); + widget.onPressed?.call(); + } + + OutlinedBorder _getShape(ThemeData theme, ChipThemeData chipTheme, ChipThemeData chipDefaults) { + final BorderSide? resolvedSide = + WidgetStateProperty.resolveAs<BorderSide?>(widget.side, statesController.value) ?? + WidgetStateProperty.resolveAs<BorderSide?>(chipTheme.side, statesController.value); + final OutlinedBorder resolvedShape = + WidgetStateProperty.resolveAs<OutlinedBorder?>(widget.shape, statesController.value) ?? + WidgetStateProperty.resolveAs<OutlinedBorder?>(chipTheme.shape, statesController.value) ?? + WidgetStateProperty.resolveAs<OutlinedBorder?>(chipDefaults.shape, statesController.value) + // TODO(tahatesser): Remove this fallback when Material 2 is deprecated. + ?? + const StadiumBorder(); + // If the side is provided, shape uses the provided side. + if (resolvedSide != null) { + return resolvedShape.copyWith(side: resolvedSide); + } + // If the side is not provided and the shape's side is not [BorderSide.none], + // then the shape's side is used. Otherwise, the default side is used. + return resolvedShape.side != BorderSide.none + ? resolvedShape + : resolvedShape.copyWith(side: chipDefaults.side); + } + + Color? resolveColor({ + WidgetStateProperty<Color?>? color, + Color? selectedColor, + Color? backgroundColor, + Color? disabledColor, + WidgetStateProperty<Color?>? defaultColor, + }) { + return _IndividualOverrides( + color: color, + selectedColor: selectedColor, + backgroundColor: backgroundColor, + disabledColor: disabledColor, + ).resolve(statesController.value) ?? + defaultColor?.resolve(statesController.value); + } + + /// Picks between three different colors, depending upon the state of two + /// different animations. + Color? _getBackgroundColor(ThemeData theme, ChipThemeData chipTheme, ChipThemeData chipDefaults) { + if (theme.useMaterial3) { + final Color? disabledColor = resolveColor( + color: widget.color ?? chipTheme.color, + disabledColor: widget.disabledColor ?? chipTheme.disabledColor, + defaultColor: chipDefaults.color, + ); + final Color? backgroundColor = resolveColor( + color: widget.color ?? chipTheme.color, + backgroundColor: widget.backgroundColor ?? chipTheme.backgroundColor, + defaultColor: chipDefaults.color, + ); + final Color? selectedColor = resolveColor( + color: widget.color ?? chipTheme.color, + selectedColor: widget.selectedColor ?? chipTheme.selectedColor, + defaultColor: chipDefaults.color, + ); + final backgroundTween = ColorTween(begin: disabledColor, end: backgroundColor); + final selectTween = ColorTween( + begin: backgroundTween.evaluate(enableController), + end: selectedColor, + ); + return selectTween.evaluate(selectionFade); + } else { + final backgroundTween = ColorTween( + begin: widget.disabledColor ?? chipTheme.disabledColor ?? theme.disabledColor, + end: + widget.backgroundColor ?? + chipTheme.backgroundColor ?? + theme.chipTheme.backgroundColor ?? + chipDefaults.backgroundColor, + ); + final selectTween = ColorTween( + begin: backgroundTween.evaluate(enableController), + end: + widget.selectedColor ?? + chipTheme.selectedColor ?? + theme.chipTheme.selectedColor ?? + chipDefaults.selectedColor, + ); + return selectTween.evaluate(selectionFade); + } + } + + @override + void didUpdateWidget(RawChip oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isEnabled != widget.isEnabled) { + setState(() { + statesController.update(WidgetState.disabled, !widget.isEnabled); + if (widget.isEnabled) { + enableController.forward(); + } else { + enableController.reverse(); + } + }); + } + if (oldWidget.avatar != widget.avatar || oldWidget.selected != widget.selected) { + setState(() { + if (hasAvatar || widget.selected) { + avatarDrawerController.forward(); + } else { + avatarDrawerController.reverse(); + } + }); + } + if (oldWidget.selected != widget.selected) { + setState(() { + statesController.update(WidgetState.selected, widget.selected); + if (widget.selected) { + selectController.forward(); + } else { + selectController.reverse(); + } + }); + } + if (oldWidget.onDeleted != widget.onDeleted) { + setState(() { + if (hasDeleteButton) { + deleteDrawerController.forward(); + } else { + deleteDrawerController.reverse(); + } + }); + } + } + + Widget? _wrapWithTooltip({String? tooltip, bool enabled = true, Widget? child}) { + if (child == null || !enabled || tooltip == null) { + return child; + } + return Tooltip(message: tooltip, child: child); + } + + Widget? _buildDeleteIcon( + BuildContext context, + ThemeData theme, + ChipThemeData chipTheme, + ChipThemeData chipDefaults, + ) { + if (!hasDeleteButton) { + return null; + } + final IconThemeData iconTheme = + widget.iconTheme ?? + chipTheme.iconTheme ?? + theme.chipTheme.iconTheme ?? + _ChipDefaultsM3(context, widget.isEnabled).iconTheme!; + final Color? effectiveDeleteIconColor = WidgetStateProperty.resolveAs( + widget.deleteIconColor ?? + chipTheme.deleteIconColor ?? + theme.chipTheme.deleteIconColor ?? + widget.iconTheme?.color ?? + chipTheme.iconTheme?.color ?? + chipDefaults.deleteIconColor, + statesController.value, + ); + final double effectiveIconSize = + widget.iconTheme?.size ?? + chipTheme.iconTheme?.size ?? + theme.chipTheme.iconTheme?.size ?? + _ChipDefaultsM3(context, widget.isEnabled).iconTheme!.size!; + + final MaterialTapTargetSize effectiveMaterialTapTargetSize = + widget.materialTapTargetSize ?? theme.materialTapTargetSize; + final Size semanticSize = switch (effectiveMaterialTapTargetSize) { + MaterialTapTargetSize.padded => const Size.square(kMinInteractiveDimension), + MaterialTapTargetSize.shrinkWrap => const Size.square(kMinInteractiveDimension - 8.0), + }; + final VisualDensity effectiveVisualDensity = widget.visualDensity ?? theme.visualDensity; + + return _EnsureMinSemanticsSize( + semanticSize: semanticSize + effectiveVisualDensity.baseSizeAdjustment, + child: _wrapWithTooltip( + tooltip: + widget.deleteButtonTooltipMessage ?? + MaterialLocalizations.of(context).deleteButtonTooltip, + enabled: widget.isEnabled && widget.onDeleted != null, + child: InkWell( + // Radius should be slightly less than the full size of the chip. + radius: (_kChipHeight + (widget.padding?.vertical ?? 0.0)) * .45, + // Keeps the splash from being constrained to the icon alone. + splashFactory: _UnconstrainedInkSplashFactory(Theme.of(context).splashFactory), + customBorder: const CircleBorder(), + onTap: widget.isEnabled ? widget.onDeleted : null, + child: IconTheme( + data: iconTheme.copyWith(color: effectiveDeleteIconColor, size: effectiveIconSize), + child: widget.deleteIcon, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasDirectionality(context)); + assert(debugCheckHasMaterialLocalizations(context)); + + final ThemeData theme = Theme.of(context); + final ChipThemeData chipTheme = ChipTheme.of(context); + final Brightness brightness = chipTheme.brightness ?? theme.brightness; + final ChipThemeData chipDefaults = + widget.defaultProperties ?? + (theme.useMaterial3 + ? _ChipDefaultsM3(context, widget.isEnabled) + : ChipThemeData.fromDefaults( + brightness: brightness, + secondaryColor: brightness == Brightness.dark + ? Colors.tealAccent[200]! + : theme.primaryColor, + labelStyle: theme.textTheme.bodyLarge!, + )); + final TextDirection? textDirection = Directionality.maybeOf(context); + final OutlinedBorder resolvedShape = _getShape(theme, chipTheme, chipDefaults); + + final double elevation = widget.elevation ?? chipTheme.elevation ?? chipDefaults.elevation ?? 0; + final double pressElevation = + widget.pressElevation ?? chipTheme.pressElevation ?? chipDefaults.pressElevation ?? 0; + final Color? shadowColor = + widget.shadowColor ?? chipTheme.shadowColor ?? chipDefaults.shadowColor; + final Color? surfaceTintColor = + widget.surfaceTintColor ?? chipTheme.surfaceTintColor ?? chipDefaults.surfaceTintColor; + final Color? selectedShadowColor = + widget.selectedShadowColor ?? + chipTheme.selectedShadowColor ?? + chipDefaults.selectedShadowColor; + final Color? checkmarkColor = + widget.checkmarkColor ?? chipTheme.checkmarkColor ?? chipDefaults.checkmarkColor; + final bool showCheckmark = + widget.showCheckmark ?? chipTheme.showCheckmark ?? chipDefaults.showCheckmark!; + final EdgeInsetsGeometry padding = widget.padding ?? chipTheme.padding ?? chipDefaults.padding!; + // Widget's label style is merged with this below. + final TextStyle labelStyle = chipTheme.labelStyle ?? chipDefaults.labelStyle!; + final IconThemeData? iconTheme = + widget.iconTheme ?? chipTheme.iconTheme ?? chipDefaults.iconTheme; + final BoxConstraints? avatarBoxConstraints = + widget.avatarBoxConstraints ?? chipTheme.avatarBoxConstraints; + final BoxConstraints? deleteIconBoxConstraints = + widget.deleteIconBoxConstraints ?? chipTheme.deleteIconBoxConstraints; + + final TextStyle effectiveLabelStyle = labelStyle.merge(widget.labelStyle); + final Color? resolvedLabelColor = WidgetStateProperty.resolveAs<Color?>( + effectiveLabelStyle.color, + statesController.value, + ); + final TextStyle resolvedLabelStyle = effectiveLabelStyle.copyWith(color: resolvedLabelColor); + final Widget? avatar = iconTheme != null && hasAvatar + ? IconTheme.merge(data: chipDefaults.iconTheme!.merge(iconTheme), child: widget.avatar!) + : widget.avatar; + + /// The chip at text scale 1 starts with 8px on each side and as text scaling + /// gets closer to 2 the label padding is linearly interpolated from 8px to 4px. + /// Once the widget has a text scaling of 2 or higher than the label padding + /// remains 4px. + final double defaultFontSize = effectiveLabelStyle.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + final EdgeInsetsGeometry defaultLabelPadding = EdgeInsets.lerp( + const EdgeInsets.symmetric(horizontal: 8.0), + const EdgeInsets.symmetric(horizontal: 4.0), + clampDouble(effectiveTextScale - 1.0, 0.0, 1.0), + )!; + + final EdgeInsetsGeometry labelPadding = + widget.labelPadding ?? + chipTheme.labelPadding ?? + chipDefaults.labelPadding ?? + defaultLabelPadding; + + Widget result = Material( + elevation: isTapping ? pressElevation : elevation, + shadowColor: widget.selected ? selectedShadowColor : shadowColor, + surfaceTintColor: surfaceTintColor, + animationDuration: pressedAnimationDuration, + shape: resolvedShape, + clipBehavior: widget.clipBehavior, + child: InkWell( + onFocusChange: (bool value) { + statesController.update(WidgetState.focused, value); + }, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + canRequestFocus: widget.isEnabled, + onTap: canTap ? _handleTap : null, + onTapDown: canTap ? _handleTapDown : null, + onTapCancel: canTap ? _handleTapCancel : null, + onHover: canTap + ? (bool value) { + statesController.update(WidgetState.hovered, value); + } + : null, + mouseCursor: widget.mouseCursor, + hoverColor: (widget.color ?? chipTheme.color) == null ? null : Colors.transparent, + customBorder: resolvedShape, + child: AnimatedBuilder( + animation: Listenable.merge(<Listenable>[selectController, enableController]), + builder: (BuildContext context, Widget? child) { + return Ink( + decoration: ShapeDecoration( + shape: resolvedShape, + color: _getBackgroundColor(theme, chipTheme, chipDefaults), + ), + child: child, + ); + }, + child: _wrapWithTooltip( + tooltip: widget.tooltip, + enabled: widget.onPressed != null || widget.onSelected != null, + child: _ChipRenderWidget( + theme: _ChipRenderTheme( + label: DefaultTextStyle( + overflow: TextOverflow.fade, + textAlign: TextAlign.start, + maxLines: 1, + softWrap: false, + style: resolvedLabelStyle, + child: widget.label, + ), + avatar: AnimatedSwitcher( + duration: _kDrawerDuration, + switchInCurve: Curves.fastOutSlowIn, + child: avatar, + ), + deleteIcon: AnimatedSwitcher( + duration: _kDrawerDuration, + switchInCurve: Curves.fastOutSlowIn, + child: _buildDeleteIcon(context, theme, chipTheme, chipDefaults), + ), + brightness: brightness, + padding: padding.resolve(textDirection), + visualDensity: widget.visualDensity ?? theme.visualDensity, + labelPadding: labelPadding.resolve(textDirection), + showAvatar: hasAvatar, + showCheckmark: showCheckmark, + checkmarkColor: checkmarkColor, + canTapBody: canTap, + ), + value: widget.selected, + checkmarkAnimation: checkmarkAnimation, + enableAnimation: enableAnimation, + avatarDrawerAnimation: avatarDrawerAnimation, + deleteDrawerAnimation: deleteDrawerAnimation, + isEnabled: widget.isEnabled, + avatarBorder: widget.avatarBorder, + avatarBoxConstraints: avatarBoxConstraints, + deleteIconBoxConstraints: deleteIconBoxConstraints, + ), + ), + ), + ), + ); + + final BoxConstraints constraints; + final Offset densityAdjustment = + (widget.visualDensity ?? theme.visualDensity).baseSizeAdjustment; + switch (widget.materialTapTargetSize ?? theme.materialTapTargetSize) { + case MaterialTapTargetSize.padded: + constraints = BoxConstraints( + minWidth: kMinInteractiveDimension + densityAdjustment.dx, + minHeight: kMinInteractiveDimension + densityAdjustment.dy, + ); + case MaterialTapTargetSize.shrinkWrap: + constraints = const BoxConstraints(); + } + result = _ChipRedirectingHitDetectionWidget( + constraints: constraints, + child: Center(widthFactor: 1.0, heightFactor: 1.0, child: result), + ); + return Semantics( + button: widget.tapEnabled, + container: true, + // On web, aria-selected only works for certain roles: gridcell, option, row and tab. + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-selected + // If the role doesn't support aria-selected, aria-current will be set instead in flutter engine. + // But in this case, aria-checked makes more sense than aria-current for a selected chip. + // So use checked on web instead. + selected: kIsWeb ? null : widget.selected, + checked: kIsWeb ? widget.selected : null, + enabled: widget.tapEnabled ? canTap : null, + child: result, + ); + } +} + +class _IndividualOverrides extends WidgetStateProperty<Color?> { + _IndividualOverrides({this.color, this.backgroundColor, this.selectedColor, this.disabledColor}); + + final WidgetStateProperty<Color?>? color; + final Color? backgroundColor; + final Color? selectedColor; + final Color? disabledColor; + + @override + Color? resolve(Set<WidgetState> states) { + if (color != null) { + return color!.resolve(states); + } + if (states.contains(WidgetState.selected) && states.contains(WidgetState.disabled)) { + return selectedColor; + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return backgroundColor; + } +} + +/// Redirects the `buttonRect.dy` passed to [RenderBox.hitTest] to the vertical +/// center of the widget. +/// +/// The primary purpose of this widget is to allow padding around the [RawChip] +/// to trigger the child ink feature without increasing the size of the material. +class _ChipRedirectingHitDetectionWidget extends SingleChildRenderObjectWidget { + const _ChipRedirectingHitDetectionWidget({super.child, required this.constraints}); + + final BoxConstraints constraints; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderChipRedirectingHitDetection(constraints); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderChipRedirectingHitDetection renderObject, + ) { + renderObject.additionalConstraints = constraints; + } +} + +class _RenderChipRedirectingHitDetection extends RenderConstrainedBox { + _RenderChipRedirectingHitDetection(BoxConstraints additionalConstraints) + : super(additionalConstraints: additionalConstraints); + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!size.contains(position)) { + return false; + } + // Only redirects hit detection which occurs above and below the render object. + // In order to make this assumption true, I have removed the minimum width + // constraints, since any reasonable chip would be at least that wide. + final offset = Offset(position.dx, size.height / 2); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(offset), + position: position, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == offset); + return child!.hitTest(result, position: offset); + }, + ); + } +} + +class _ChipRenderWidget extends SlottedMultiChildRenderObjectWidget<_ChipSlot, RenderBox> { + const _ChipRenderWidget({ + required this.theme, + this.value, + this.isEnabled, + required this.checkmarkAnimation, + required this.avatarDrawerAnimation, + required this.deleteDrawerAnimation, + required this.enableAnimation, + this.avatarBorder, + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + }); + + final _ChipRenderTheme theme; + final bool? value; + final bool? isEnabled; + final Animation<double> checkmarkAnimation; + final Animation<double> avatarDrawerAnimation; + final Animation<double> deleteDrawerAnimation; + final Animation<double> enableAnimation; + final ShapeBorder? avatarBorder; + final BoxConstraints? avatarBoxConstraints; + final BoxConstraints? deleteIconBoxConstraints; + + @override + Iterable<_ChipSlot> get slots => _ChipSlot.values; + + @override + Widget? childForSlot(_ChipSlot slot) { + return switch (slot) { + _ChipSlot.label => theme.label, + _ChipSlot.avatar => theme.avatar, + _ChipSlot.deleteIcon => theme.deleteIcon, + }; + } + + @override + void updateRenderObject(BuildContext context, _RenderChip renderObject) { + renderObject + ..theme = theme + ..textDirection = Directionality.of(context) + ..value = value + ..isEnabled = isEnabled + ..checkmarkAnimation = checkmarkAnimation + ..avatarDrawerAnimation = avatarDrawerAnimation + ..deleteDrawerAnimation = deleteDrawerAnimation + ..enableAnimation = enableAnimation + ..avatarBorder = avatarBorder + ..avatarBoxConstraints = avatarBoxConstraints + ..deleteIconBoxConstraints = deleteIconBoxConstraints; + } + + @override + SlottedContainerRenderObjectMixin<_ChipSlot, RenderBox> createRenderObject(BuildContext context) { + return _RenderChip( + theme: theme, + textDirection: Directionality.of(context), + value: value, + isEnabled: isEnabled, + checkmarkAnimation: checkmarkAnimation, + avatarDrawerAnimation: avatarDrawerAnimation, + deleteDrawerAnimation: deleteDrawerAnimation, + enableAnimation: enableAnimation, + avatarBorder: avatarBorder, + avatarBoxConstraints: avatarBoxConstraints, + deleteIconBoxConstraints: deleteIconBoxConstraints, + ); + } +} + +enum _ChipSlot { label, avatar, deleteIcon } + +@immutable +class _ChipRenderTheme { + const _ChipRenderTheme({ + required this.avatar, + required this.label, + required this.deleteIcon, + required this.brightness, + required this.padding, + required this.visualDensity, + required this.labelPadding, + required this.showAvatar, + required this.showCheckmark, + required this.checkmarkColor, + required this.canTapBody, + }); + + final Widget avatar; + final Widget label; + final Widget deleteIcon; + final Brightness brightness; + final EdgeInsets padding; + final VisualDensity visualDensity; + final EdgeInsets labelPadding; + final bool showAvatar; + final bool showCheckmark; + final Color? checkmarkColor; + final bool canTapBody; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is _ChipRenderTheme && + other.avatar == avatar && + other.label == label && + other.deleteIcon == deleteIcon && + other.brightness == brightness && + other.padding == padding && + other.labelPadding == labelPadding && + other.showAvatar == showAvatar && + other.showCheckmark == showCheckmark && + other.checkmarkColor == checkmarkColor && + other.canTapBody == canTapBody; + } + + @override + int get hashCode => Object.hash( + avatar, + label, + deleteIcon, + brightness, + padding, + labelPadding, + showAvatar, + showCheckmark, + checkmarkColor, + canTapBody, + ); +} + +class _RenderChip extends RenderBox with SlottedContainerRenderObjectMixin<_ChipSlot, RenderBox> { + _RenderChip({ + required _ChipRenderTheme theme, + required TextDirection textDirection, + this.value, + this.isEnabled, + required this.checkmarkAnimation, + required this.avatarDrawerAnimation, + required this.deleteDrawerAnimation, + required this.enableAnimation, + this.avatarBorder, + BoxConstraints? avatarBoxConstraints, + BoxConstraints? deleteIconBoxConstraints, + }) : _theme = theme, + _textDirection = textDirection, + _avatarBoxConstraints = avatarBoxConstraints, + _deleteIconBoxConstraints = deleteIconBoxConstraints; + + bool? value; + bool? isEnabled; + late Rect _deleteButtonRect; + late Rect _pressRect; + Animation<double> checkmarkAnimation; + Animation<double> avatarDrawerAnimation; + Animation<double> deleteDrawerAnimation; + Animation<double> enableAnimation; + ShapeBorder? avatarBorder; + + RenderBox get avatar => childForSlot(_ChipSlot.avatar)!; + RenderBox get deleteIcon => childForSlot(_ChipSlot.deleteIcon)!; + RenderBox get label => childForSlot(_ChipSlot.label)!; + + _ChipRenderTheme get theme => _theme; + _ChipRenderTheme _theme; + set theme(_ChipRenderTheme value) { + if (_theme == value) { + return; + } + _theme = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + BoxConstraints? get avatarBoxConstraints => _avatarBoxConstraints; + BoxConstraints? _avatarBoxConstraints; + set avatarBoxConstraints(BoxConstraints? value) { + if (_avatarBoxConstraints == value) { + return; + } + _avatarBoxConstraints = value; + markNeedsLayout(); + } + + BoxConstraints? get deleteIconBoxConstraints => _deleteIconBoxConstraints; + BoxConstraints? _deleteIconBoxConstraints; + set deleteIconBoxConstraints(BoxConstraints? value) { + if (_deleteIconBoxConstraints == value) { + return; + } + _deleteIconBoxConstraints = value; + markNeedsLayout(); + } + + // The returned list is ordered for hit testing. + @override + Iterable<RenderBox> get children { + final RenderBox? avatar = childForSlot(_ChipSlot.avatar); + final RenderBox? label = childForSlot(_ChipSlot.label); + final RenderBox? deleteIcon = childForSlot(_ChipSlot.deleteIcon); + return <RenderBox>[?avatar, ?label, ?deleteIcon]; + } + + bool get isDrawingCheckmark => theme.showCheckmark && !checkmarkAnimation.isDismissed; + bool get deleteIconShowing => !deleteDrawerAnimation.isDismissed; + + static Rect _boxRect(RenderBox box) => _boxParentData(box).offset & box.size; + + static BoxParentData _boxParentData(RenderBox box) => box.parentData! as BoxParentData; + + @override + double computeMinIntrinsicWidth(double height) { + // The overall padding isn't affected by missing avatar or delete icon + // because we add the padding regardless to give extra padding for the label + // when they're missing. + final double overallPadding = theme.padding.horizontal + theme.labelPadding.horizontal; + return overallPadding + + avatar.getMinIntrinsicWidth(height) + + label.getMinIntrinsicWidth(height) + + deleteIcon.getMinIntrinsicWidth(height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final double overallPadding = theme.padding.horizontal + theme.labelPadding.horizontal; + return overallPadding + + avatar.getMaxIntrinsicWidth(height) + + label.getMaxIntrinsicWidth(height) + + deleteIcon.getMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicHeight(double width) { + return math.max( + _kChipHeight, + theme.padding.vertical + theme.labelPadding.vertical + label.getMinIntrinsicHeight(width), + ); + } + + @override + double computeMaxIntrinsicHeight(double width) => getMinIntrinsicHeight(width); + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + // The baseline of this widget is the baseline of the label. + return (BaselineOffset(label.getDistanceToActualBaseline(baseline)) + + _boxParentData(label).offset.dy) + .offset; + } + + BoxConstraints _labelConstraintsFrom( + BoxConstraints contentConstraints, + double iconWidth, + double contentSize, + Size rawLabelSize, + ) { + // Now that we know the label height and the width of the icons, we can + // determine how much to shrink the width constraints for the "real" layout. + final double freeSpace = + contentConstraints.maxWidth - + iconWidth - + theme.labelPadding.horizontal - + theme.padding.horizontal; + final double maxLabelWidth = math.max(0.0, freeSpace); + return BoxConstraints( + minHeight: rawLabelSize.height, + maxHeight: contentSize, + maxWidth: maxLabelWidth.isFinite ? maxLabelWidth : rawLabelSize.width, + ); + } + + Size _layoutAvatar( + double contentSize, [ + ChildLayouter layoutChild = ChildLayoutHelper.layoutChild, + ]) { + final BoxConstraints avatarConstraints = + avatarBoxConstraints ?? BoxConstraints.tightFor(width: contentSize, height: contentSize); + final Size avatarBoxSize = layoutChild(avatar, avatarConstraints); + if (!theme.showCheckmark && !theme.showAvatar) { + return Size(0.0, contentSize); + } + final double avatarFullWidth = theme.showAvatar ? avatarBoxSize.width : contentSize; + return Size(avatarFullWidth * avatarDrawerAnimation.value, avatarBoxSize.height); + } + + Size _layoutDeleteIcon( + double contentSize, [ + ChildLayouter layoutChild = ChildLayoutHelper.layoutChild, + ]) { + final BoxConstraints deleteIconConstraints = + deleteIconBoxConstraints ?? + BoxConstraints.tightFor(width: contentSize, height: contentSize); + final Size boxSize = layoutChild(deleteIcon, deleteIconConstraints); + if (!deleteIconShowing) { + return Size(0.0, contentSize); + } + return Size(deleteDrawerAnimation.value * boxSize.width, boxSize.height); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!size.contains(position)) { + return false; + } + final bool hitIsOnDeleteIcon = _hitIsOnDeleteIcon( + padding: theme.padding, + labelPadding: theme.labelPadding, + tapPosition: position, + chipSize: size, + deleteButtonSize: deleteIcon.size, + textDirection: textDirection, + ); + final RenderBox hitTestChild = hitIsOnDeleteIcon ? deleteIcon : label; + + final Offset center = hitTestChild.size.center(Offset.zero); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: position, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return hitTestChild.hitTest(result, position: center); + }, + ); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSizes(constraints, ChildLayoutHelper.dryLayoutChild).size; + } + + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + final _ChipSizes sizes = _computeSizes(constraints, ChildLayoutHelper.dryLayoutChild); + final BaselineOffset labelBaseline = + BaselineOffset(label.getDryBaseline(sizes.labelConstraints, baseline)) + + (sizes.content - sizes.label.height + sizes.densityAdjustment.dy) / 2 + + theme.padding.top + + theme.labelPadding.top; + return labelBaseline.offset; + } + + _ChipSizes _computeSizes(BoxConstraints constraints, ChildLayouter layoutChild) { + final BoxConstraints contentConstraints = constraints.loosen(); + // Find out the height of the label within the constraints. + final Size rawLabelSize = label.getDryLayout(contentConstraints); + final double contentSize = math.max( + _kChipHeight - theme.padding.vertical + theme.labelPadding.vertical, + rawLabelSize.height + theme.labelPadding.vertical, + ); + assert(contentSize >= rawLabelSize.height); + final Size avatarSize = _layoutAvatar(contentSize, layoutChild); + final Size deleteIconSize = _layoutDeleteIcon(contentSize, layoutChild); + + final BoxConstraints labelConstraints = _labelConstraintsFrom( + contentConstraints, + avatarSize.width + deleteIconSize.width, + contentSize, + rawLabelSize, + ); + + final Size labelSize = theme.labelPadding.inflateSize(layoutChild(label, labelConstraints)); + final densityAdjustment = Offset(0.0, theme.visualDensity.baseSizeAdjustment.dy / 2.0); + // This is the overall size of the content: it doesn't include + // theme.padding, that is added in at the end. + final Size overallSize = + Size(avatarSize.width + labelSize.width + deleteIconSize.width, contentSize) + + densityAdjustment; + final paddedSize = Size( + overallSize.width + theme.padding.horizontal, + overallSize.height + theme.padding.vertical, + ); + + return _ChipSizes( + size: constraints.constrain(paddedSize), + overall: overallSize, + content: contentSize, + densityAdjustment: densityAdjustment, + avatar: avatarSize, + labelConstraints: labelConstraints, + label: labelSize, + deleteIcon: deleteIconSize, + ); + } + + @override + void performLayout() { + final _ChipSizes sizes = _computeSizes(constraints, ChildLayoutHelper.layoutChild); + + // Now we have all of the dimensions. Place the children where they belong. + + const left = 0.0; + final double right = sizes.overall.width; + + Offset centerLayout(Size boxSize, double x) { + assert(sizes.content >= boxSize.height); + switch (textDirection) { + case TextDirection.rtl: + x -= boxSize.width; + case TextDirection.ltr: + break; + } + return Offset(x, (sizes.content - boxSize.height + sizes.densityAdjustment.dy) / 2.0); + } + + // These are the offsets to the upper left corners of the boxes (including + // the child's padding) containing the children, for each child, but not + // including the overall padding. + Offset avatarOffset = Offset.zero; + Offset labelOffset = Offset.zero; + Offset deleteIconOffset = Offset.zero; + switch (textDirection) { + case TextDirection.rtl: + var start = right; + if (theme.showCheckmark || theme.showAvatar) { + avatarOffset = centerLayout(sizes.avatar, start); + start -= sizes.avatar.width; + } + labelOffset = centerLayout(sizes.label, start); + start -= sizes.label.width; + if (deleteIconShowing) { + _deleteButtonRect = Rect.fromLTWH( + 0.0, + 0.0, + sizes.deleteIcon.width + theme.padding.right, + sizes.overall.height + theme.padding.vertical, + ); + deleteIconOffset = centerLayout(sizes.deleteIcon, start); + } else { + _deleteButtonRect = Rect.zero; + } + start -= sizes.deleteIcon.width; + if (theme.canTapBody) { + _pressRect = Rect.fromLTWH( + _deleteButtonRect.width, + 0.0, + sizes.overall.width - _deleteButtonRect.width + theme.padding.horizontal, + sizes.overall.height + theme.padding.vertical, + ); + } else { + _pressRect = Rect.zero; + } + case TextDirection.ltr: + var start = left; + if (theme.showCheckmark || theme.showAvatar) { + avatarOffset = centerLayout(sizes.avatar, start - avatar.size.width + sizes.avatar.width); + start += sizes.avatar.width; + } + labelOffset = centerLayout(sizes.label, start); + start += sizes.label.width; + if (theme.canTapBody) { + _pressRect = Rect.fromLTWH( + 0.0, + 0.0, + deleteIconShowing + ? start + theme.padding.left + : sizes.overall.width + theme.padding.horizontal, + sizes.overall.height + theme.padding.vertical, + ); + } else { + _pressRect = Rect.zero; + } + start -= deleteIcon.size.width - sizes.deleteIcon.width; + if (deleteIconShowing) { + deleteIconOffset = centerLayout(sizes.deleteIcon, start); + _deleteButtonRect = Rect.fromLTWH( + start + theme.padding.left, + 0.0, + sizes.deleteIcon.width + theme.padding.right, + sizes.overall.height + theme.padding.vertical, + ); + } else { + _deleteButtonRect = Rect.zero; + } + } + // Center the label vertically. + labelOffset = + labelOffset + + Offset(0.0, ((sizes.label.height - theme.labelPadding.vertical) - label.size.height) / 2.0); + _boxParentData(avatar).offset = theme.padding.topLeft + avatarOffset; + _boxParentData(label).offset = theme.padding.topLeft + labelOffset + theme.labelPadding.topLeft; + _boxParentData(deleteIcon).offset = theme.padding.topLeft + deleteIconOffset; + final paddedSize = Size( + sizes.overall.width + theme.padding.horizontal, + sizes.overall.height + theme.padding.vertical, + ); + size = constraints.constrain(paddedSize); + assert( + size.height == constraints.constrainHeight(paddedSize.height), + "Constrained height ${size.height} doesn't match expected height " + '${constraints.constrainWidth(paddedSize.height)}', + ); + assert( + size.width == constraints.constrainWidth(paddedSize.width), + "Constrained width ${size.width} doesn't match expected width " + '${constraints.constrainWidth(paddedSize.width)}', + ); + } + + static final ColorTween selectionScrimTween = ColorTween( + begin: Colors.transparent, + end: _kSelectScrimColor, + ); + + Color get _disabledColor { + if (enableAnimation.isCompleted) { + return Colors.white; + } + final Color color = switch (theme.brightness) { + Brightness.light => Colors.white, + Brightness.dark => Colors.black, + }; + return ColorTween( + begin: color.withAlpha(_kDisabledAlpha), + end: color, + ).evaluate(enableAnimation)!; + } + + void _paintCheck(Canvas canvas, Offset origin, double size) { + Color? paintColor = + theme.checkmarkColor ?? + switch ((theme.brightness, theme.showAvatar)) { + (Brightness.light, true) => Colors.white, + (Brightness.light, false) => Colors.black.withAlpha(_kCheckmarkAlpha), + (Brightness.dark, true) => Colors.black, + (Brightness.dark, false) => Colors.white.withAlpha(_kCheckmarkAlpha), + }; + + final fadeTween = ColorTween(begin: Colors.transparent, end: paintColor); + + paintColor = checkmarkAnimation.status == AnimationStatus.reverse + ? fadeTween.evaluate(checkmarkAnimation) + : paintColor; + + final paint = Paint() + ..color = paintColor! + ..style = PaintingStyle.stroke + ..strokeWidth = _kCheckmarkStrokeWidth * avatar.size.height / 24.0; + final double t = checkmarkAnimation.status == AnimationStatus.reverse + ? 1.0 + : checkmarkAnimation.value; + if (t == 0.0) { + // Nothing to draw. + return; + } + assert(t > 0.0 && t <= 1.0); + // As t goes from 0.0 to 1.0, animate the two check mark strokes from the + // short side to the long side. + final path = Path(); + final start = Offset(size * 0.15, size * 0.45); + final mid = Offset(size * 0.4, size * 0.7); + final end = Offset(size * 0.85, size * 0.25); + if (t < 0.5) { + final double strokeT = t * 2.0; + final Offset drawMid = Offset.lerp(start, mid, strokeT)!; + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + drawMid.dx, origin.dy + drawMid.dy); + } else { + final double strokeT = (t - 0.5) * 2.0; + final Offset drawEnd = Offset.lerp(mid, end, strokeT)!; + path.moveTo(origin.dx + start.dx, origin.dy + start.dy); + path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.lineTo(origin.dx + drawEnd.dx, origin.dy + drawEnd.dy); + } + canvas.drawPath(path, paint); + } + + void _paintSelectionOverlay(PaintingContext context, Offset offset) { + if (isDrawingCheckmark) { + if (theme.showAvatar) { + final Rect avatarRect = _boxRect(avatar).shift(offset); + final darkenPaint = Paint() + ..color = selectionScrimTween.evaluate(checkmarkAnimation)! + ..blendMode = BlendMode.srcATop; + if (avatarBorder!.preferPaintInterior) { + avatarBorder!.paintInterior(context.canvas, avatarRect, darkenPaint); + } else { + final Path path = avatarBorder!.getOuterPath(avatarRect); + context.canvas.drawPath(path, darkenPaint); + } + } + // Need to make the check mark be a little smaller than the avatar. + final double checkSize = avatar.size.height * 0.75; + final Offset checkOffset = + _boxParentData(avatar).offset + + Offset(avatar.size.height * 0.125, avatar.size.height * 0.125); + _paintCheck(context.canvas, offset + checkOffset, checkSize); + } + } + + final LayerHandle<OpacityLayer> _avatarOpacityLayerHandler = LayerHandle<OpacityLayer>(); + + void _paintAvatar(PaintingContext context, Offset offset) { + void paintWithOverlay(PaintingContext context, Offset offset) { + context.paintChild(avatar, _boxParentData(avatar).offset + offset); + _paintSelectionOverlay(context, offset); + } + + if (!theme.showAvatar && avatarDrawerAnimation.isDismissed) { + _avatarOpacityLayerHandler.layer = null; + return; + } + final Color disabledColor = _disabledColor; + final int disabledColorAlpha = disabledColor.alpha; + if (needsCompositing) { + _avatarOpacityLayerHandler.layer = context.pushOpacity( + offset, + disabledColorAlpha, + paintWithOverlay, + oldLayer: _avatarOpacityLayerHandler.layer, + ); + } else { + _avatarOpacityLayerHandler.layer = null; + if (disabledColorAlpha != 0xff) { + context.canvas.saveLayer( + _boxRect(avatar).shift(offset).inflate(20.0), + Paint()..color = disabledColor, + ); + } + paintWithOverlay(context, offset); + if (disabledColorAlpha != 0xff) { + context.canvas.restore(); + } + } + } + + final LayerHandle<OpacityLayer> _labelOpacityLayerHandler = LayerHandle<OpacityLayer>(); + final LayerHandle<OpacityLayer> _deleteIconOpacityLayerHandler = LayerHandle<OpacityLayer>(); + + void _paintChild( + PaintingContext context, + Offset offset, + RenderBox? child, { + required bool isDeleteIcon, + }) { + if (child == null) { + _labelOpacityLayerHandler.layer = null; + _deleteIconOpacityLayerHandler.layer = null; + return; + } + final int disabledColorAlpha = _disabledColor.alpha; + if (!enableAnimation.isCompleted) { + if (needsCompositing) { + _labelOpacityLayerHandler.layer = context.pushOpacity(offset, disabledColorAlpha, ( + PaintingContext context, + Offset offset, + ) { + context.paintChild(child, _boxParentData(child).offset + offset); + }, oldLayer: _labelOpacityLayerHandler.layer); + if (isDeleteIcon) { + _deleteIconOpacityLayerHandler.layer = context.pushOpacity(offset, disabledColorAlpha, ( + PaintingContext context, + Offset offset, + ) { + context.paintChild(child, _boxParentData(child).offset + offset); + }, oldLayer: _deleteIconOpacityLayerHandler.layer); + } + } else { + _labelOpacityLayerHandler.layer = null; + _deleteIconOpacityLayerHandler.layer = null; + final Rect childRect = _boxRect(child).shift(offset); + context.canvas.saveLayer(childRect.inflate(20.0), Paint()..color = _disabledColor); + context.paintChild(child, _boxParentData(child).offset + offset); + context.canvas.restore(); + } + } else { + context.paintChild(child, _boxParentData(child).offset + offset); + } + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + checkmarkAnimation.addListener(markNeedsPaint); + avatarDrawerAnimation.addListener(markNeedsLayout); + deleteDrawerAnimation.addListener(markNeedsLayout); + enableAnimation.addListener(markNeedsPaint); + } + + @override + void detach() { + checkmarkAnimation.removeListener(markNeedsPaint); + avatarDrawerAnimation.removeListener(markNeedsLayout); + deleteDrawerAnimation.removeListener(markNeedsLayout); + enableAnimation.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void dispose() { + _labelOpacityLayerHandler.layer = null; + _deleteIconOpacityLayerHandler.layer = null; + _avatarOpacityLayerHandler.layer = null; + super.dispose(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _paintAvatar(context, offset); + if (deleteIconShowing) { + _paintChild(context, offset, deleteIcon, isDeleteIcon: true); + } + _paintChild(context, offset, label, isDeleteIcon: false); + } + + // Set this to true to have outlines of the tap targets drawn over + // the chip. This should never be checked in while set to 'true'. + static const bool _debugShowTapTargetOutlines = false; + + @override + void debugPaint(PaintingContext context, Offset offset) { + assert( + !_debugShowTapTargetOutlines || + () { + // Draws a rect around the tap targets to help with visualizing where + // they really are. + final outlinePaint = Paint() + ..color = const Color(0xff800000) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + if (deleteIconShowing) { + context.canvas.drawRect(_deleteButtonRect.shift(offset), outlinePaint); + } + context.canvas.drawRect( + _pressRect.shift(offset), + outlinePaint..color = const Color(0xff008000), + ); + return true; + }(), + ); + } + + @override + bool hitTestSelf(Offset position) => + _deleteButtonRect.contains(position) || _pressRect.contains(position); +} + +class _ChipSizes { + _ChipSizes({ + required this.size, + required this.overall, + required this.content, + required this.avatar, + required this.labelConstraints, + required this.label, + required this.deleteIcon, + required this.densityAdjustment, + }); + final Size size; + final Size overall; + final double content; + final Size avatar; + final BoxConstraints labelConstraints; + final Size label; + final Size deleteIcon; + final Offset densityAdjustment; +} + +class _UnconstrainedInkSplashFactory extends InteractiveInkFeatureFactory { + const _UnconstrainedInkSplashFactory(this.parentFactory); + + final InteractiveInkFeatureFactory parentFactory; + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + return parentFactory.create( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + rectCallback: rectCallback, + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + textDirection: textDirection, + ); + } +} + +bool _hitIsOnDeleteIcon({ + required EdgeInsetsGeometry padding, + required EdgeInsetsGeometry labelPadding, + required Offset tapPosition, + required Size chipSize, + required Size deleteButtonSize, + required TextDirection textDirection, +}) { + // The chipSize includes the padding, so we need to deflate the size and adjust the + // tap position to account for the padding. + final EdgeInsets resolvedPadding = padding.resolve(textDirection); + final Size deflatedSize = resolvedPadding.deflateSize(chipSize); + final Offset adjustedPosition = tapPosition - Offset(resolvedPadding.left, resolvedPadding.top); + // The delete button hit area should be at least the width of the delete + // button and right label padding, but, if there's room, up to 24 pixels + // from the center of the delete icon (corresponding to part of a 48x48 square + // that Material would prefer for touch targets), but no more than approximately + // half of the overall size of the chip when the chip is small. + // + // This isn't affected by materialTapTargetSize because it only applies to the + // width of the tappable region within the chip, not outside of the chip, + // which is handled elsewhere. Also because delete buttons aren't specified to + // be used on touch devices, only desktop devices. + + // Max out at not quite half, so that tests that tap on the center of a small + // chip will still hit the chip, not the delete button. + final double accessibleDeleteButtonWidth = math.min( + deflatedSize.width * 0.499, + math.min( + labelPadding.resolve(textDirection).right + deleteButtonSize.width, + 24.0 + deleteButtonSize.width / 2.0, + ), + ); + return switch (textDirection) { + TextDirection.ltr => adjustedPosition.dx >= deflatedSize.width - accessibleDeleteButtonWidth, + TextDirection.rtl => adjustedPosition.dx <= accessibleDeleteButtonWidth, + }; +} + +class _EnsureMinSemanticsSize extends SingleChildRenderObjectWidget { + const _EnsureMinSemanticsSize({super.child, required this.semanticSize}); + + final Size semanticSize; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderEnsureMinSemanticsSize(semanticSize); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderEnsureMinSemanticsSize renderObject, + ) { + renderObject.semanticSize = semanticSize; + } +} + +class _RenderEnsureMinSemanticsSize extends RenderProxyBox { + _RenderEnsureMinSemanticsSize(this._semanticSize, [RenderBox? child]) : super(child); + + Size get semanticSize => _semanticSize; + Size _semanticSize; + set semanticSize(Size value) { + if (_semanticSize == value) { + return; + } + _semanticSize = value; + markNeedsSemanticsUpdate(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + config.isSemanticBoundary = true; + config.isButton = true; + } + + @override + Rect get semanticBounds { + return Rect.fromCenter( + center: paintBounds.center, + width: math.max(_semanticSize.width, size.width), + height: math.max(_semanticSize.height, size.height), + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - Chip + +// 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. + +// dart format off +class _ChipDefaultsM3 extends ChipThemeData { + _ChipDefaultsM3(this.context, this.isEnabled) + : super( + elevation: 0.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + showCheckmark: true, + ); + + final BuildContext context; + final bool isEnabled; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + TextStyle? get labelStyle => _textTheme.labelLarge?.copyWith( + color: isEnabled + ? _colors.onSurfaceVariant + : _colors.onSurface, + ); + + @override + WidgetStateProperty<Color?>? get color => null; // Subclasses override this getter + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get checkmarkColor => null; + + @override + Color? get deleteIconColor => isEnabled + ? _colors.onSurfaceVariant + : _colors.onSurface; + + @override + BorderSide? get side => isEnabled + ? BorderSide(color: _colors.outlineVariant) + : BorderSide(color: _colors.onSurface.withOpacity(0.12)); + + @override + IconThemeData? get iconTheme => IconThemeData( + color: isEnabled + ? _colors.primary + : _colors.onSurface, + size: 18.0, + ); + + @override + EdgeInsetsGeometry? get padding => const EdgeInsets.all(8.0); + + /// The label padding of the chip scales with the font size specified in the + /// [labelStyle], and the system font size settings that scale font sizes + /// globally. + /// + /// The chip at effective font size 14.0 starts with 8px on each side and as + /// the font size scales up to closer to 28.0, the label padding is linearly + /// interpolated from 8px to 4px. Once the label has a font size of 2 or + /// higher, label padding remains 4px. + @override + EdgeInsetsGeometry? get labelPadding { + final double fontSize = labelStyle?.fontSize ?? 14.0; + final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(fontSize) / 14.0; + return EdgeInsets.lerp( + const EdgeInsets.symmetric(horizontal: 8.0), + const EdgeInsets.symmetric(horizontal: 4.0), + clampDouble(fontSizeRatio - 1.0, 0.0, 1.0), + )!; + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Chip diff --git a/packages/material_ui/lib/src/chip_theme.dart b/packages/material_ui/lib/src/chip_theme.dart new file mode 100644 index 000000000000..3376e82d72df --- /dev/null +++ b/packages/material_ui/lib/src/chip_theme.dart @@ -0,0 +1,692 @@ +// 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. + +/// @docImport 'action_chip.dart'; +/// @docImport 'chip.dart'; +/// @docImport 'choice_chip.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'filter_chip.dart'; +/// @docImport 'input_chip.dart'; +/// @docImport 'material.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +/// Applies a chip theme to descendant [RawChip]-based widgets, like [Chip], +/// [InputChip], [ChoiceChip], [FilterChip], and [ActionChip]. +/// +/// A chip theme describes the color, shape and text styles for the chips it is +/// applied to. +/// +/// Descendant widgets obtain the current theme's [ChipThemeData] object using +/// [ChipTheme.of]. When a widget uses [ChipTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// The [ThemeData] object given by the [Theme.of] call also contains a default +/// [ThemeData.chipTheme] that can be customized by copying it (using +/// [ChipThemeData.copyWith]). +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * [ChipThemeData], which describes the actual configuration of a chip +/// theme. +/// * [ThemeData], which describes the overall theme information for the +/// application. +class ChipTheme extends InheritedTheme { + /// Applies the given theme [data] to [child]. + const ChipTheme({super.key, required this.data, required super.child}); + + /// Specifies the color, shape, and text style values for descendant chip + /// widgets. + final ChipThemeData data; + + /// Returns the data from the closest [ChipTheme] instance that encloses + /// the given context. + /// + /// Defaults to the ambient [ThemeData.chipTheme] if there is no + /// [ChipTheme] in the given build context. + /// + /// {@tool snippet} + /// + /// ```dart + /// class Spaceship extends StatelessWidget { + /// const Spaceship({super.key}); + /// + /// @override + /// Widget build(BuildContext context) { + /// return ChipTheme( + /// data: ChipTheme.of(context).copyWith(backgroundColor: Colors.red), + /// child: ActionChip( + /// label: const Text('Launch'), + /// onPressed: () { print('We have liftoff!'); }, + /// ), + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [ChipThemeData], which describes the actual configuration of a chip + /// theme. + static ChipThemeData of(BuildContext context) { + final ChipTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<ChipTheme>(); + return inheritedTheme?.data ?? Theme.of(context).chipTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ChipTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ChipTheme oldWidget) => data != oldWidget.data; +} + +/// Holds the color, shape, and text styles for a Material Design chip theme. +/// +/// Use this class to configure a [ChipTheme] widget, or to set the +/// [ThemeData.chipTheme] for a [Theme] widget. +/// +/// To obtain the current ambient chip theme, use [ChipTheme.of]. +/// +/// The parts of a chip are: +/// +/// * The "avatar", which is a widget that appears at the beginning of the +/// chip. This is typically a [CircleAvatar] widget. +/// * The "label", which is the widget displayed in the center of the chip. +/// Typically this is a [Text] widget. +/// * The "delete icon", which is a widget that appears at the end of the chip. +/// * The chip is disabled when it is not accepting user input. Only some chips +/// have a disabled state: [ActionChip], [ChoiceChip], [FilterChip], and +/// [InputChip]. +/// +/// The simplest way to create a ChipThemeData is to use [copyWith] on the one +/// you get from [ChipTheme.of], or create an entirely new one with +/// [ChipThemeData.fromDefaults]. +/// +/// {@tool snippet} +/// +/// ```dart +/// class CarColor extends StatefulWidget { +/// const CarColor({super.key}); +/// +/// @override +/// State createState() => _CarColorState(); +/// } +/// +/// class _CarColorState extends State<CarColor> { +/// Color _color = Colors.red; +/// +/// @override +/// Widget build(BuildContext context) { +/// return ChipTheme( +/// data: ChipTheme.of(context).copyWith(backgroundColor: Colors.lightBlue), +/// child: ChoiceChip( +/// label: const Text('Light Blue'), +/// onSelected: (bool value) { +/// setState(() { +/// _color = value ? Colors.lightBlue : Colors.red; +/// }); +/// }, +/// selected: _color == Colors.lightBlue, +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * [CircleAvatar], which shows images or initials of entities. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * [ChipTheme] widget, which can override the chip theme of its +/// children. +/// * [Theme] widget, which performs a similar function to [ChipTheme], +/// but for overall themes. +/// * [ThemeData], which has a default [ChipThemeData]. +@immutable +class ChipThemeData with Diagnosticable { + /// Create a [ChipThemeData] given a set of exact values. All the values + /// must be specified except for [shadowColor], [selectedShadowColor], + /// [elevation], and [pressElevation], which may be null. + /// + /// This will rarely be used directly. It is used by [lerp] to + /// create intermediate themes based on two themes. + const ChipThemeData({ + this.color, + this.backgroundColor, + this.deleteIconColor, + this.disabledColor, + this.selectedColor, + this.secondarySelectedColor, + this.shadowColor, + this.surfaceTintColor, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.labelPadding, + this.padding, + this.side, + this.shape, + this.labelStyle, + this.secondaryLabelStyle, + this.brightness, + this.elevation, + this.pressElevation, + this.iconTheme, + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + }); + + /// Generates a ChipThemeData from a brightness, a primary color, and a text + /// style. + /// + /// The [brightness] is used to select a primary color from the default + /// values. + /// + /// The optional [primaryColor] is used as the base color for the other + /// colors. The opacity of the [primaryColor] is ignored. If a [primaryColor] + /// is specified, then the [brightness] is ignored, and the theme brightness + /// is determined from the [primaryColor]. + /// + /// Only one of [primaryColor] or [brightness] may be specified. + /// + /// The [secondaryColor] is used for the selection colors needed by + /// [ChoiceChip]. + /// + /// This is used to generate the default chip theme for a [ThemeData]. + factory ChipThemeData.fromDefaults({ + Brightness? brightness, + Color? primaryColor, + required Color secondaryColor, + required TextStyle labelStyle, + }) { + assert( + primaryColor != null || brightness != null, + 'One of primaryColor or brightness must be specified', + ); + assert( + primaryColor == null || brightness == null, + 'Only one of primaryColor or brightness may be specified', + ); + + if (primaryColor != null) { + brightness = ThemeData.estimateBrightnessForColor(primaryColor); + } + + // These are Material Design defaults, and are used to derive + // component Colors (with opacity) from base colors. + const backgroundAlpha = 0x1f; // 12% + const deleteIconAlpha = 0xde; // 87% + const disabledAlpha = 0x0c; // 38% * 12% = 5% + const selectAlpha = 0x3d; // 12% + 12% = 24% + const textLabelAlpha = 0xde; // 87% + const EdgeInsetsGeometry padding = EdgeInsets.all(4.0); + + primaryColor = primaryColor ?? (brightness == Brightness.light ? Colors.black : Colors.white); + final Color backgroundColor = primaryColor.withAlpha(backgroundAlpha); + final Color deleteIconColor = primaryColor.withAlpha(deleteIconAlpha); + final Color disabledColor = primaryColor.withAlpha(disabledAlpha); + final Color selectedColor = primaryColor.withAlpha(selectAlpha); + final Color secondarySelectedColor = secondaryColor.withAlpha(selectAlpha); + final TextStyle secondaryLabelStyle = labelStyle.copyWith( + color: secondaryColor.withAlpha(textLabelAlpha), + ); + labelStyle = labelStyle.copyWith(color: primaryColor.withAlpha(textLabelAlpha)); + + return ChipThemeData( + backgroundColor: backgroundColor, + deleteIconColor: deleteIconColor, + disabledColor: disabledColor, + selectedColor: selectedColor, + secondarySelectedColor: secondarySelectedColor, + shadowColor: Colors.black, + selectedShadowColor: Colors.black, + showCheckmark: true, + padding: padding, + labelStyle: labelStyle, + secondaryLabelStyle: secondaryLabelStyle, + brightness: brightness, + elevation: 0.0, + pressElevation: 8.0, + iconTheme: const IconThemeData(size: 18.0), + ); + } + + /// Overrides the default for [ChipAttributes.color]. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final WidgetStateProperty<Color?>? color; + + /// Overrides the default for [ChipAttributes.backgroundColor] + /// which is used for unselected, enabled chip backgrounds. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final Color? backgroundColor; + + /// Overrides the default for [DeletableChipAttributes.deleteIconColor]. + /// + /// This property applies to [Chip], [InputChip], [RawChip]. + final Color? deleteIconColor; + + /// Overrides the default for + /// [DisabledChipAttributes.disabledColor], the background color + /// which indicates that the chip is not enabled. + /// + /// This property applies to [ActionChip], [ChoiceChip], + /// [FilterChip], [InputChip], and [RawChip]. + final Color? disabledColor; + + /// Overrides the default for + /// [SelectableChipAttributes.selectedColor], the background color + /// that indicates that the chip is selected. + /// + /// This property applies to [ChoiceChip], [FilterChip], + /// [InputChip], [RawChip]. + final Color? selectedColor; + + /// Overrides the default for [ChoiceChip.selectedColor], the + /// background color that indicates that the chip is selected. + final Color? secondarySelectedColor; + + /// Overrides the default for [ChipAttributes.shadowColor], the + /// Color of the chip's shadow when its elevation is greater than 0. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final Color? shadowColor; + + /// Overrides the default for [ChipAttributes.surfaceTintColor], the + /// Color of the chip's surface tint overlay when its elevation is + /// greater than 0. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final Color? surfaceTintColor; + + /// Overrides the default for + /// [SelectableChipAttributes.selectedShadowColor], the Color of the + /// chip's shadow when its elevation is greater than 0 and the chip + /// is selected. + /// + /// This property applies to [ChoiceChip], [FilterChip], + /// [InputChip], [RawChip]. + final Color? selectedShadowColor; + + /// Overrides the default for + /// [CheckmarkableChipAttributes.showCheckmark], which indicates if + /// a check mark should be shown. + /// + /// This property applies to [FilterChip], [InputChip], [RawChip]. + final bool? showCheckmark; + + /// Overrides the default for + /// [CheckmarkableChipAttributes.checkmarkColor]. + /// + /// This property applies to [FilterChip], [InputChip], [RawChip]. + final Color? checkmarkColor; + + /// Overrides the default for [ChipAttributes.labelPadding], + /// the padding around the chip's label widget. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final EdgeInsetsGeometry? labelPadding; + + /// Overrides the default for [ChipAttributes.padding], + /// the padding between the contents of the chip and the outside [shape]. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final EdgeInsetsGeometry? padding; + + /// Overrides the default for [ChipAttributes.side], + /// the color and weight of the chip's outline. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. If it is a [WidgetStateBorderSide], + /// [WidgetStateProperty.resolve] is used for the following + /// [WidgetState]s: + /// + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.pressed]. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final BorderSide? side; + + /// Overrides the default for [ChipAttributes.shape], + /// the shape of border to draw around the chip. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. If it is a [WidgetStateOutlinedBorder], + /// [WidgetStateProperty.resolve] is used for the following + /// [WidgetState]s: + /// + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.pressed]. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final OutlinedBorder? shape; + + /// Overrides the default for [ChipAttributes.labelStyle], + /// the style of the [DefaultTextStyle] that contains the + /// chip's label. + /// + /// This only has an effect on label widgets that respect the + /// [DefaultTextStyle], such as [Text]. + /// + /// This property applies to [ActionChip], [Chip], + /// [FilterChip], [InputChip], [RawChip]. + final TextStyle? labelStyle; + + /// Overrides the default for [ChoiceChip.labelStyle], + /// the style of the [DefaultTextStyle] that contains the + /// chip's label. + /// + /// This only has an effect on label widgets that respect the + /// [DefaultTextStyle], such as [Text]. + final TextStyle? secondaryLabelStyle; + + /// Overrides the default value for all chips which affects various base + /// material color choices in the chip rendering. + final Brightness? brightness; + + /// Overrides the default for [ChipAttributes.elevation], + /// the elevation of the chip's [Material]. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final double? elevation; + + /// Overrides the default for [TappableChipAttributes.pressElevation], + /// the elevation of the chip's [Material] during a "press" or tap down. + /// + /// This property applies to [ActionChip], [InputChip], [RawChip]. + final double? pressElevation; + + /// Overrides the default for [ChipAttributes.iconTheme], + /// the theme used for all icons in the chip. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final IconThemeData? iconTheme; + + /// Overrides the default for [ChipAttributes.avatarBoxConstraints], + /// the size constraints for the avatar widget. + /// + /// This property applies to [ActionChip], [Chip], [ChoiceChip], + /// [FilterChip], [InputChip], [RawChip]. + final BoxConstraints? avatarBoxConstraints; + + /// Overrides the default for [DeletableChipAttributes.deleteIconBoxConstraints]. + /// the size constraints for the delete icon widget. + /// + /// This property applies to [Chip], [FilterChip], [InputChip], [RawChip]. + final BoxConstraints? deleteIconBoxConstraints; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + ChipThemeData copyWith({ + WidgetStateProperty<Color?>? color, + Color? backgroundColor, + Color? deleteIconColor, + Color? disabledColor, + Color? selectedColor, + Color? secondarySelectedColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? selectedShadowColor, + bool? showCheckmark, + Color? checkmarkColor, + EdgeInsetsGeometry? labelPadding, + EdgeInsetsGeometry? padding, + BorderSide? side, + OutlinedBorder? shape, + TextStyle? labelStyle, + TextStyle? secondaryLabelStyle, + Brightness? brightness, + double? elevation, + double? pressElevation, + IconThemeData? iconTheme, + BoxConstraints? avatarBoxConstraints, + BoxConstraints? deleteIconBoxConstraints, + }) { + return ChipThemeData( + color: color ?? this.color, + backgroundColor: backgroundColor ?? this.backgroundColor, + deleteIconColor: deleteIconColor ?? this.deleteIconColor, + disabledColor: disabledColor ?? this.disabledColor, + selectedColor: selectedColor ?? this.selectedColor, + secondarySelectedColor: secondarySelectedColor ?? this.secondarySelectedColor, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + selectedShadowColor: selectedShadowColor ?? this.selectedShadowColor, + showCheckmark: showCheckmark ?? this.showCheckmark, + checkmarkColor: checkmarkColor ?? this.checkmarkColor, + labelPadding: labelPadding ?? this.labelPadding, + padding: padding ?? this.padding, + side: side ?? this.side, + shape: shape ?? this.shape, + labelStyle: labelStyle ?? this.labelStyle, + secondaryLabelStyle: secondaryLabelStyle ?? this.secondaryLabelStyle, + brightness: brightness ?? this.brightness, + elevation: elevation ?? this.elevation, + pressElevation: pressElevation ?? this.pressElevation, + iconTheme: iconTheme ?? this.iconTheme, + avatarBoxConstraints: avatarBoxConstraints ?? this.avatarBoxConstraints, + deleteIconBoxConstraints: deleteIconBoxConstraints ?? this.deleteIconBoxConstraints, + ); + } + + /// Linearly interpolate between two chip themes. + /// + /// {@macro dart.ui.shadow.lerp} + static ChipThemeData? lerp(ChipThemeData? a, ChipThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return ChipThemeData( + color: WidgetStateProperty.lerp<Color?>(a?.color, b?.color, t, Color.lerp), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + deleteIconColor: Color.lerp(a?.deleteIconColor, b?.deleteIconColor, t), + disabledColor: Color.lerp(a?.disabledColor, b?.disabledColor, t), + selectedColor: Color.lerp(a?.selectedColor, b?.selectedColor, t), + secondarySelectedColor: Color.lerp(a?.secondarySelectedColor, b?.secondarySelectedColor, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + selectedShadowColor: Color.lerp(a?.selectedShadowColor, b?.selectedShadowColor, t), + showCheckmark: t < 0.5 ? a?.showCheckmark ?? true : b?.showCheckmark ?? true, + checkmarkColor: Color.lerp(a?.checkmarkColor, b?.checkmarkColor, t), + labelPadding: EdgeInsetsGeometry.lerp(a?.labelPadding, b?.labelPadding, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + side: _lerpSides(a?.side, b?.side, t), + shape: OutlinedBorder.lerp(a?.shape, b?.shape, t), + labelStyle: TextStyle.lerp(a?.labelStyle, b?.labelStyle, t), + secondaryLabelStyle: TextStyle.lerp(a?.secondaryLabelStyle, b?.secondaryLabelStyle, t), + brightness: t < 0.5 ? a?.brightness ?? Brightness.light : b?.brightness ?? Brightness.light, + elevation: lerpDouble(a?.elevation, b?.elevation, t), + pressElevation: lerpDouble(a?.pressElevation, b?.pressElevation, t), + iconTheme: a?.iconTheme != null || b?.iconTheme != null + ? IconThemeData.lerp(a?.iconTheme, b?.iconTheme, t) + : null, + avatarBoxConstraints: BoxConstraints.lerp( + a?.avatarBoxConstraints, + b?.avatarBoxConstraints, + t, + ), + deleteIconBoxConstraints: BoxConstraints.lerp( + a?.deleteIconBoxConstraints, + b?.deleteIconBoxConstraints, + t, + ), + ); + } + + // Special case because BorderSide.lerp() doesn't support null arguments. + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null && b == null) { + return null; + } + if (a is WidgetStateBorderSide) { + a = a.resolve(const <WidgetState>{}); + } + if (b is WidgetStateBorderSide) { + b = b.resolve(const <WidgetState>{}); + } + a ??= BorderSide(width: 0, color: b!.color.withAlpha(0)); + b ??= BorderSide(width: 0, color: a.color.withAlpha(0)); + + return BorderSide.lerp(a, b, t); + } + + @override + int get hashCode => Object.hashAll(<Object?>[ + color, + backgroundColor, + deleteIconColor, + disabledColor, + selectedColor, + secondarySelectedColor, + shadowColor, + surfaceTintColor, + selectedShadowColor, + showCheckmark, + checkmarkColor, + labelPadding, + padding, + side, + shape, + labelStyle, + secondaryLabelStyle, + brightness, + elevation, + pressElevation, + iconTheme, + avatarBoxConstraints, + deleteIconBoxConstraints, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ChipThemeData && + other.color == color && + other.backgroundColor == backgroundColor && + other.deleteIconColor == deleteIconColor && + other.disabledColor == disabledColor && + other.selectedColor == selectedColor && + other.secondarySelectedColor == secondarySelectedColor && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.selectedShadowColor == selectedShadowColor && + other.showCheckmark == showCheckmark && + other.checkmarkColor == checkmarkColor && + other.labelPadding == labelPadding && + other.padding == padding && + other.side == side && + other.shape == shape && + other.labelStyle == labelStyle && + other.secondaryLabelStyle == secondaryLabelStyle && + other.brightness == brightness && + other.elevation == elevation && + other.pressElevation == pressElevation && + other.iconTheme == iconTheme && + other.avatarBoxConstraints == avatarBoxConstraints && + other.deleteIconBoxConstraints == deleteIconBoxConstraints; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>('color', color, defaultValue: null), + ); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('deleteIconColor', deleteIconColor, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add( + ColorProperty('secondarySelectedColor', secondarySelectedColor, defaultValue: null), + ); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(ColorProperty('selectedShadowColor', selectedShadowColor, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('showCheckmark', showCheckmark, defaultValue: null)); + properties.add(ColorProperty('checkMarkColor', checkmarkColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('labelPadding', labelPadding, defaultValue: null), + ); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty<BorderSide>('side', side, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<TextStyle>('labelStyle', labelStyle, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>( + 'secondaryLabelStyle', + secondaryLabelStyle, + defaultValue: null, + ), + ); + properties.add(EnumProperty<Brightness>('brightness', brightness, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DoubleProperty('pressElevation', pressElevation, defaultValue: null)); + properties.add(DiagnosticsProperty<IconThemeData>('iconTheme', iconTheme, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'avatarBoxConstraints', + avatarBoxConstraints, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'deleteIconBoxConstraints', + deleteIconBoxConstraints, + defaultValue: null, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/choice_chip.dart b/packages/material_ui/lib/src/choice_chip.dart new file mode 100644 index 000000000000..ba0461f3beea --- /dev/null +++ b/packages/material_ui/lib/src/choice_chip.dart @@ -0,0 +1,393 @@ +// 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. + +/// @docImport 'action_chip.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'filter_chip.dart'; +/// @docImport 'input_chip.dart'; +/// @docImport 'material.dart'; +library; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/widgets.dart'; + +import 'chip.dart'; +import 'chip_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +enum _ChipVariant { flat, elevated } + +/// A Material Design choice chip. +/// +/// [ChoiceChip]s represent a single choice from a set. Choice chips contain +/// related descriptive text or categories. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// {@tool dartpad} +/// This example shows how to create [ChoiceChip]s with [onSelected]. When the +/// user taps, the chip will be selected. +/// +/// ** See code in examples/api/lib/material/choice_chip/choice_chip.0.dart ** +/// {@end-tool} +/// +/// ## Material Design 3 +/// +/// [ChoiceChip] can be used for single select Filter chips from +/// Material Design 3. If [ThemeData.useMaterial3] is true, then [ChoiceChip] +/// will be styled to match the Material Design 3 specification for Filter +/// chips. Use [FilterChip] for multiple select Filter chips. +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * [CircleAvatar], which shows images or initials of people. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * <https://material.io/design/components/chips.html> +class ChoiceChip extends StatelessWidget + implements + ChipAttributes, + SelectableChipAttributes, + CheckmarkableChipAttributes, + DisabledChipAttributes { + /// Create a chip that acts like a radio button. + /// + /// The [label], [selected], [autofocus], and [clipBehavior] arguments must + /// not be null. When [onSelected] is null, the [ChoiceChip] will be disabled. + /// The [pressElevation] and [elevation] must be null or non-negative. Typically, + /// [pressElevation] is greater than [elevation]. + const ChoiceChip({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.onSelected, + this.pressElevation, + required this.selected, + this.selectedColor, + this.disabledColor, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.avatarBorder = const CircleBorder(), + this.avatarBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + _chipVariant = _ChipVariant.flat; + + /// Create an elevated chip that acts like a radio button. + /// + /// The [label], [selected], [autofocus], and [clipBehavior] arguments must + /// not be null. When [onSelected] is null, the [ChoiceChip] will be disabled. + /// The [pressElevation] and [elevation] must be null or non-negative. Typically, + /// [pressElevation] is greater than [elevation]. + const ChoiceChip.elevated({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.onSelected, + this.pressElevation, + required this.selected, + this.selectedColor, + this.disabledColor, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.avatarBorder = const CircleBorder(), + this.avatarBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + _chipVariant = _ChipVariant.elevated; + + @override + final Widget? avatar; + @override + final Widget label; + @override + final TextStyle? labelStyle; + @override + final EdgeInsetsGeometry? labelPadding; + @override + final ValueChanged<bool>? onSelected; + @override + final double? pressElevation; + @override + final bool selected; + @override + final Color? disabledColor; + @override + final Color? selectedColor; + @override + final String? tooltip; + @override + final BorderSide? side; + @override + final OutlinedBorder? shape; + @override + final Clip clipBehavior; + @override + final FocusNode? focusNode; + @override + final bool autofocus; + @override + final WidgetStateProperty<Color?>? color; + @override + final Color? backgroundColor; + @override + final EdgeInsetsGeometry? padding; + @override + final VisualDensity? visualDensity; + @override + final MaterialTapTargetSize? materialTapTargetSize; + @override + final double? elevation; + @override + final Color? shadowColor; + @override + final Color? surfaceTintColor; + @override + final Color? selectedShadowColor; + @override + final bool? showCheckmark; + @override + final Color? checkmarkColor; + @override + final ShapeBorder avatarBorder; + @override + final IconThemeData? iconTheme; + @override + final BoxConstraints? avatarBoxConstraints; + @override + final ChipAnimationStyle? chipAnimationStyle; + @override + final MouseCursor? mouseCursor; + + @override + bool get isEnabled => onSelected != null; + + final _ChipVariant _chipVariant; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ChipThemeData chipTheme = ChipTheme.of(context); + final ChipThemeData? defaults = Theme.of(context).useMaterial3 + ? _ChoiceChipDefaultsM3(context, isEnabled, selected, _chipVariant) + : null; + return RawChip( + defaultProperties: defaults, + avatar: avatar, + label: label, + labelStyle: labelStyle ?? (selected ? chipTheme.secondaryLabelStyle : null), + labelPadding: labelPadding, + onSelected: onSelected, + pressElevation: pressElevation, + selected: selected, + showCheckmark: showCheckmark ?? chipTheme.showCheckmark ?? Theme.of(context).useMaterial3, + checkmarkColor: checkmarkColor, + tooltip: tooltip, + side: side, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + disabledColor: disabledColor, + selectedColor: selectedColor ?? chipTheme.secondarySelectedColor, + color: color, + backgroundColor: backgroundColor, + padding: padding, + visualDensity: visualDensity, + isEnabled: isEnabled, + materialTapTargetSize: materialTapTargetSize, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + selectedShadowColor: selectedShadowColor, + avatarBorder: avatarBorder, + iconTheme: iconTheme, + avatarBoxConstraints: avatarBoxConstraints, + chipAnimationStyle: chipAnimationStyle, + mouseCursor: mouseCursor, + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - ChoiceChip + +// 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. + +// dart format off +class _ChoiceChipDefaultsM3 extends ChipThemeData { + _ChoiceChipDefaultsM3( + this.context, + this.isEnabled, + this.isSelected, + this._chipVariant, + ) : super( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + showCheckmark: true, + ); + + final BuildContext context; + final bool isEnabled; + final bool isSelected; + final _ChipVariant _chipVariant; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + double? get elevation => _chipVariant == _ChipVariant.flat + ? 0.0 + : isEnabled ? 1.0 : 0.0; + + @override + double? get pressElevation => 1.0; + + @override + TextStyle? get labelStyle => _textTheme.labelLarge?.copyWith( + color: isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant + : _colors.onSurface, + ); + + @override + WidgetStateProperty<Color?>? get color => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected) && states.contains(WidgetState.disabled)) { + return _chipVariant == _ChipVariant.flat + ? _colors.onSurface.withOpacity(0.12) + : _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.disabled)) { + return _chipVariant == _ChipVariant.flat + ? null + : _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.selected)) { + return _chipVariant == _ChipVariant.flat + ? _colors.secondaryContainer + : _colors.secondaryContainer; + } + return _chipVariant == _ChipVariant.flat + ? null + : _colors.surfaceContainerLow; + }); + + @override + Color? get shadowColor => _chipVariant == _ChipVariant.flat + ? Colors.transparent + : _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get checkmarkColor => isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.primary + : _colors.onSurface; + + @override + Color? get deleteIconColor => isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant + : _colors.onSurface; + + @override + BorderSide? get side => _chipVariant == _ChipVariant.flat && !isSelected + ? isEnabled + ? BorderSide(color: _colors.outlineVariant) + : BorderSide(color: _colors.onSurface.withOpacity(0.12)) + : const BorderSide(color: Colors.transparent); + + @override + IconThemeData? get iconTheme => IconThemeData( + color: isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.primary + : _colors.onSurface, + size: 18.0, + ); + + @override + EdgeInsetsGeometry? get padding => const EdgeInsets.all(8.0); + + /// The label padding of the chip scales with the font size specified in the + /// [labelStyle], and the system font size settings that scale font sizes + /// globally. + /// + /// The chip at effective font size 14.0 starts with 8px on each side and as + /// the font size scales up to closer to 28.0, the label padding is linearly + /// interpolated from 8px to 4px. Once the label has a font size of 2 or + /// higher, label padding remains 4px. + @override + EdgeInsetsGeometry? get labelPadding { + final double fontSize = labelStyle?.fontSize ?? 14.0; + final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(fontSize) / 14.0; + return EdgeInsets.lerp( + const EdgeInsets.symmetric(horizontal: 8.0), + const EdgeInsets.symmetric(horizontal: 4.0), + clampDouble(fontSizeRatio - 1.0, 0.0, 1.0), + )!; + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - ChoiceChip diff --git a/packages/material_ui/lib/src/circle_avatar.dart b/packages/material_ui/lib/src/circle_avatar.dart new file mode 100644 index 000000000000..ba6810bbbad3 --- /dev/null +++ b/packages/material_ui/lib/src/circle_avatar.dart @@ -0,0 +1,267 @@ +// 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. + +/// @docImport 'chip.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'list_tile.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'constants.dart'; +import 'theme.dart'; + +// Examples can assume: +// late String userAvatarUrl; + +/// A circle that represents a user. +/// +/// Typically used with a user's profile image, or, in the absence of +/// such an image, the user's initials. A given user's initials should +/// always be paired with the same background color, for consistency. +/// +/// If [foregroundImage] fails then [backgroundImage] is used. If +/// [backgroundImage] fails too, [backgroundColor] is used. +/// +/// The [onBackgroundImageError] parameter must be null if the [backgroundImage] +/// is null. +/// The [onForegroundImageError] parameter must be null if the [foregroundImage] +/// is null. +/// +/// {@tool snippet} +/// +/// If the avatar is to have an image, the image should be specified in the +/// [backgroundImage] property: +/// +/// ```dart +/// CircleAvatar( +/// backgroundImage: NetworkImage(userAvatarUrl), +/// ) +/// ``` +/// {@end-tool} +/// +/// The image will be cropped to have a circle shape. +/// +/// {@tool snippet} +/// +/// If the avatar is to just have the user's initials, they are typically +/// provided using a [Text] widget as the [child] and a [backgroundColor]: +/// +/// ```dart +/// CircleAvatar( +/// backgroundColor: Colors.brown.shade800, +/// child: const Text('AH'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Chip], for representing users or concepts in long form. +/// * [ListTile], which can combine an icon (such as a [CircleAvatar]) with +/// some text for a fixed height list entry. +/// * <https://material.io/design/components/chips.html#input-chips> +class CircleAvatar extends StatelessWidget { + /// Creates a circle that represents a user. + const CircleAvatar({ + super.key, + this.child, + this.backgroundColor, + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.foregroundColor, + this.radius, + this.minRadius, + this.maxRadius, + }) : assert(radius == null || (minRadius == null && maxRadius == null)), + assert(backgroundImage != null || onBackgroundImageError == null), + assert(foregroundImage != null || onForegroundImageError == null); + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. If the [CircleAvatar] is to have an image, use + /// [backgroundImage] instead. + final Widget? child; + + /// The color with which to fill the circle. Changing the background + /// color will cause the avatar to animate to the new color. + /// + /// If a [backgroundColor] is not specified and [ThemeData.useMaterial3] is true, + /// [ColorScheme.primaryContainer] will be used, otherwise the theme's + /// [ThemeData.primaryColorLight] is used with dark foreground colors, and + /// [ThemeData.primaryColorDark] with light foreground colors. + final Color? backgroundColor; + + /// The default text color for text in the circle. + /// + /// Defaults to the primary text theme color if no [backgroundColor] is + /// specified. + /// + /// If a [foregroundColor] is not specified and [ThemeData.useMaterial3] is true, + /// [ColorScheme.onPrimaryContainer] will be used, otherwise the theme's + /// [ThemeData.primaryColorLight] for dark background colors, and + /// [ThemeData.primaryColorDark] for light background colors. + final Color? foregroundColor; + + /// The background image of the circle. Changing the background + /// image will cause the avatar to animate to the new image. + /// + /// Typically used as a fallback image for [foregroundImage]. + /// + /// If the [CircleAvatar] is to have the user's initials, use [child] instead. + final ImageProvider? backgroundImage; + + /// The foreground image of the circle. + /// + /// Typically used as profile image. For fallback use [backgroundImage]. + final ImageProvider? foregroundImage; + + /// An optional error callback for errors emitted when loading + /// [backgroundImage]. + final ImageErrorListener? onBackgroundImageError; + + /// An optional error callback for errors emitted when loading + /// [foregroundImage]. + final ImageErrorListener? onForegroundImageError; + + /// The size of the avatar, expressed as the radius (half the diameter). + /// + /// If [radius] is specified, then neither [minRadius] nor [maxRadius] may be + /// specified. Specifying [radius] is equivalent to specifying a [minRadius] + /// and [maxRadius], both with the value of [radius]. + /// + /// If neither [minRadius] nor [maxRadius] are specified, defaults to 20 + /// logical pixels. This is the appropriate size for use with + /// [ListTile.leading]. + /// + /// Changes to the [radius] are animated (including changing from an explicit + /// [radius] to a [minRadius]/[maxRadius] pair or vice versa). + final double? radius; + + /// The minimum size of the avatar, expressed as the radius (half the + /// diameter). + /// + /// If [minRadius] is specified, then [radius] must not also be specified. + /// + /// Defaults to zero. + /// + /// Constraint changes are animated, but size changes due to the environment + /// itself changing are not. For example, changing the [minRadius] from 10 to + /// 20 when the [CircleAvatar] is in an unconstrained environment will cause + /// the avatar to animate from a 20 pixel diameter to a 40 pixel diameter. + /// However, if the [minRadius] is 40 and the [CircleAvatar] has a parent + /// [SizedBox] whose size changes instantaneously from 20 pixels to 40 pixels, + /// the size will snap to 40 pixels instantly. + final double? minRadius; + + /// The maximum size of the avatar, expressed as the radius (half the + /// diameter). + /// + /// If [maxRadius] is specified, then [radius] must not also be specified. + /// + /// Defaults to [double.infinity]. + /// + /// Constraint changes are animated, but size changes due to the environment + /// itself changing are not. For example, changing the [maxRadius] from 10 to + /// 20 when the [CircleAvatar] is in an unconstrained environment will cause + /// the avatar to animate from a 20 pixel diameter to a 40 pixel diameter. + /// However, if the [maxRadius] is 40 and the [CircleAvatar] has a parent + /// [SizedBox] whose size changes instantaneously from 20 pixels to 40 pixels, + /// the size will snap to 40 pixels instantly. + final double? maxRadius; + + // The default radius if nothing is specified. + static const double _defaultRadius = 20.0; + + // The default min if only the max is specified. + static const double _defaultMinRadius = 0.0; + + // The default max if only the min is specified. + static const double _defaultMaxRadius = double.infinity; + + double get _minDiameter { + if (radius == null && minRadius == null && maxRadius == null) { + return _defaultRadius * 2.0; + } + return 2.0 * (radius ?? minRadius ?? _defaultMinRadius); + } + + double get _maxDiameter { + if (radius == null && minRadius == null && maxRadius == null) { + return _defaultRadius * 2.0; + } + return 2.0 * (radius ?? maxRadius ?? _defaultMaxRadius); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + final Color? effectiveForegroundColor = + foregroundColor ?? (theme.useMaterial3 ? theme.colorScheme.onPrimaryContainer : null); + final TextStyle effectiveTextStyle = theme.useMaterial3 + ? theme.textTheme.titleMedium! + : theme.primaryTextTheme.titleMedium!; + TextStyle textStyle = effectiveTextStyle.copyWith(color: effectiveForegroundColor); + Color? effectiveBackgroundColor = + backgroundColor ?? (theme.useMaterial3 ? theme.colorScheme.primaryContainer : null); + if (effectiveBackgroundColor == null) { + effectiveBackgroundColor = switch (ThemeData.estimateBrightnessForColor(textStyle.color!)) { + Brightness.dark => theme.primaryColorLight, + Brightness.light => theme.primaryColorDark, + }; + } else if (effectiveForegroundColor == null) { + textStyle = switch (ThemeData.estimateBrightnessForColor(backgroundColor!)) { + Brightness.dark => textStyle.copyWith(color: theme.primaryColorLight), + Brightness.light => textStyle.copyWith(color: theme.primaryColorDark), + }; + } + final double minDiameter = _minDiameter; + final double maxDiameter = _maxDiameter; + return AnimatedContainer( + constraints: BoxConstraints( + minHeight: minDiameter, + minWidth: minDiameter, + maxWidth: maxDiameter, + maxHeight: maxDiameter, + ), + duration: kThemeChangeDuration, + decoration: BoxDecoration( + color: effectiveBackgroundColor, + image: backgroundImage != null + ? DecorationImage( + image: backgroundImage!, + onError: onBackgroundImageError, + fit: BoxFit.cover, + ) + : null, + shape: BoxShape.circle, + ), + foregroundDecoration: foregroundImage != null + ? BoxDecoration( + image: DecorationImage( + image: foregroundImage!, + onError: onForegroundImageError, + fit: BoxFit.cover, + ), + shape: BoxShape.circle, + ) + : null, + child: child == null + ? null + : Center( + // Need to disable text scaling here so that the text doesn't + // escape the avatar when the textScaleFactor is large. + child: MediaQuery.withNoTextScaling( + child: IconTheme( + data: theme.iconTheme.copyWith(color: textStyle.color), + child: DefaultTextStyle(style: textStyle, child: child!), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/color_scheme.dart b/packages/material_ui/lib/src/color_scheme.dart new file mode 100644 index 000000000000..3b8f21a522a8 --- /dev/null +++ b/packages/material_ui/lib/src/color_scheme.dart @@ -0,0 +1,2239 @@ +// 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. + +/// @docImport 'input_decorator.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +/// The algorithm used to construct a [ColorScheme] in [ColorScheme.fromSeed]. +/// +/// The `tonalSpot` variant builds default Material scheme colors. These colors are +/// mapped to light or dark tones to achieve visually accessible color +/// pairings with sufficient contrast between foreground and background elements. +/// +/// In some cases, the tones can prevent colors from appearing as intended, +/// such as when a color is too light to offer enough contrast for accessibility. +/// Color fidelity (`DynamicSchemeVariant.fidelity`) is a feature that adjusts +/// tones in these cases to produce the intended visual results without harming +/// visual contrast. +enum DynamicSchemeVariant { + /// Default for Material theme colors. Builds pastel palettes with a low chroma. + tonalSpot, + + /// The resulting color palettes match seed color, even if the seed color + /// is very bright (high chroma). + fidelity, + + /// All colors are grayscale, no chroma. + monochrome, + + /// Close to grayscale, a hint of chroma. + neutral, + + /// Pastel colors, high chroma palettes. The primary palette's chroma is at + /// maximum. Use `fidelity` instead if tokens should alter their tone to match + /// the palette vibrancy. + vibrant, + + /// Pastel colors, medium chroma palettes. The primary palette's hue is + /// different from the seed color, for variety. + expressive, + + /// Almost identical to `fidelity`. Tokens and palettes match the seed color. + /// [ColorScheme.primaryContainer] is the seed color, adjusted to ensure + /// contrast with surfaces. The tertiary palette is analogue of the seed color. + content, + + /// A playful theme - the seed color's hue does not appear in the theme. + rainbow, + + /// A playful theme - the seed color's hue does not appear in the theme. + fruitSalad, +} + +/// {@template flutter.material.color_scheme.ColorScheme} +/// A set of 45 colors based on the +/// [Material spec](https://m3.material.io/styles/color/the-color-system/color-roles) +/// that can be used to configure the color properties of most components. +/// {@endtemplate} +/// +/// ### Colors in Material 3 +/// +/// {@macro flutter.material.colors.colorRoles} +/// +/// The main accent color groups in the scheme are [primary], [secondary], +/// and [tertiary]. +/// +/// * Primary colors are used for key components across the UI, such as the FAB, +/// prominent buttons, and active states. +/// +/// * Secondary colors are used for less prominent components in the UI, such as +/// filter chips, while expanding the opportunity for color expression. +/// +/// * Tertiary colors are used for contrasting accents that can be used to +/// balance primary and secondary colors or bring heightened attention to +/// an element, such as an input field. The tertiary colors are left +/// for makers to use at their discretion and are intended to support +/// broader color expression in products. +/// +/// Each accent color group (primary, secondary and tertiary) includes '-Fixed' +/// '-Dim' color roles, such as [primaryFixed] and [primaryFixedDim]. Fixed roles +/// are appropriate to use in places where Container roles are normally used, +/// but they stay the same color between light and dark themes. The '-Dim' roles +/// provide a stronger, more emphasized color with the same fixed behavior. +/// +/// The remaining colors of the scheme are composed of neutral colors used for +/// backgrounds and surfaces, as well as specific colors for errors, dividers +/// and shadows. Surface colors are used for backgrounds and large, low-emphasis +/// areas of the screen. +/// +/// Material 3 also introduces tone-based surfaces and surface containers. +/// They replace the old opacity-based model which applied a tinted overlay on +/// top of surfaces based on their elevation. These colors include: [surfaceBright], +/// [surfaceDim], [surfaceContainerLowest], [surfaceContainerLow], [surfaceContainer], +/// [surfaceContainerHigh], and [surfaceContainerHighest]. +/// +/// Many of the colors have matching 'on' colors, which are used for drawing +/// content on top of the matching color. For example, if something is using +/// [primary] for a background color, [onPrimary] would be used to paint text +/// and icons on top of it. For this reason, the 'on' colors should have a +/// contrast ratio with their matching colors of at least 4.5:1 in order to +/// be readable. On '-FixedVariant' roles, such as [onPrimaryFixedVariant], +/// also have the same color between light and dark themes, but compared +/// with on '-Fixed' roles, such as [onPrimaryFixed], they provide a +/// lower-emphasis option for text and icons. +/// +/// {@tool dartpad} +/// This example shows all Material [ColorScheme] roles in light and dark +/// brightnesses. +/// +/// ** See code in examples/api/lib/material/color_scheme/color_scheme.0.dart ** +/// {@end-tool} +/// +/// ### Setting Colors in Flutter +/// +///{@macro flutter.material.colors.settingColors} +@immutable +class ColorScheme with Diagnosticable { + /// Create a ColorScheme instance from the given colors. + /// + /// [ColorScheme.fromSeed] can be used as a simpler way to create a full + /// color scheme derived from a single seed color. + /// + /// For the color parameters that are nullable, it is still recommended + /// that applications provide values for them. They are only nullable due + /// to backwards compatibility concerns. + /// + /// If a color is not provided, the closest fallback color from the given + /// colors will be used for it (e.g. [primaryContainer] will default + /// to [primary]). Material Design 3 makes use of these colors for many + /// component defaults, so for the best results the application should + /// supply colors for all the parameters. An easy way to ensure this is to + /// use [ColorScheme.fromSeed] to generate a full set of colors. + /// + /// During the migration to Material Design 3, if an app's + /// [ThemeData.useMaterial3] is false, then components will only + /// use the following colors for defaults: + /// + /// * [primary] + /// * [onPrimary] + /// * [secondary] + /// * [onSecondary] + /// * [error] + /// * [onError] + /// * [surface] + /// * [onSurface] + /// DEPRECATED: + /// * [background] + /// * [onBackground] + const ColorScheme({ + required this.brightness, + required this.primary, + required this.onPrimary, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + required this.secondary, + required this.onSecondary, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + required this.error, + required this.onError, + Color? errorContainer, + Color? onErrorContainer, + required this.surface, + required this.onSurface, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? outline, + Color? outlineVariant, + Color? shadow, + Color? scrim, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) : _primaryContainer = primaryContainer, + _onPrimaryContainer = onPrimaryContainer, + _primaryFixed = primaryFixed, + _primaryFixedDim = primaryFixedDim, + _onPrimaryFixed = onPrimaryFixed, + _onPrimaryFixedVariant = onPrimaryFixedVariant, + _secondaryContainer = secondaryContainer, + _onSecondaryContainer = onSecondaryContainer, + _secondaryFixed = secondaryFixed, + _secondaryFixedDim = secondaryFixedDim, + _onSecondaryFixed = onSecondaryFixed, + _onSecondaryFixedVariant = onSecondaryFixedVariant, + _tertiary = tertiary, + _onTertiary = onTertiary, + _tertiaryContainer = tertiaryContainer, + _onTertiaryContainer = onTertiaryContainer, + _tertiaryFixed = tertiaryFixed, + _tertiaryFixedDim = tertiaryFixedDim, + _onTertiaryFixed = onTertiaryFixed, + _onTertiaryFixedVariant = onTertiaryFixedVariant, + _errorContainer = errorContainer, + _onErrorContainer = onErrorContainer, + _surfaceDim = surfaceDim, + _surfaceBright = surfaceBright, + _surfaceContainerLowest = surfaceContainerLowest, + _surfaceContainerLow = surfaceContainerLow, + _surfaceContainer = surfaceContainer, + _surfaceContainerHigh = surfaceContainerHigh, + _surfaceContainerHighest = surfaceContainerHighest, + _onSurfaceVariant = onSurfaceVariant, + _outline = outline, + _outlineVariant = outlineVariant, + _shadow = shadow, + _scrim = scrim, + _inverseSurface = inverseSurface, + _onInverseSurface = onInverseSurface, + _inversePrimary = inversePrimary, + _surfaceTint = surfaceTint, + // DEPRECATED (newest deprecations at the bottom) + _background = background, + _onBackground = onBackground, + _surfaceVariant = surfaceVariant; + + /// Generate a [ColorScheme] derived from the given `seedColor`. + /// + /// Using the `seedColor` as a starting point, a set of tonal palettes are + /// constructed. By default, the tonal palettes are based on the Material 3 + /// Color system and provide all of the [ColorScheme] colors. These colors are + /// designed to work well together and meet contrast requirements for + /// accessibility. + /// + /// If any of the optional color parameters are non-null they will be + /// used in place of the generated colors for that field in the resulting + /// color scheme. This allows apps to override specific colors for their + /// needs. + /// + /// Given the nature of the algorithm, the `seedColor` may not wind up as + /// one of the ColorScheme colors. + /// + /// The `dynamicSchemeVariant` parameter creates different types of + /// [DynamicScheme]s, which are used to generate different styles of [ColorScheme]s. + /// By default, `dynamicSchemeVariant` is set to `tonalSpot`. A [ColorScheme] + /// constructed by `dynamicSchemeVariant.tonalSpot` has pastel palettes and + /// won't be too "colorful" even if the `seedColor` has a high chroma value. + /// If the resulting color scheme is too dark, consider setting `dynamicSchemeVariant` + /// to [DynamicSchemeVariant.fidelity], whose palettes match the seed color. + /// + /// The `contrastLevel` parameter indicates the contrast level between color + /// pairs, such as [primary] and [onPrimary]. 0.0 is the default (normal); + /// -1.0 is the lowest; 1.0 is the highest. From Material Design guideline, the + /// medium and high contrast correspond to 0.5 and 1.0 respectively. + /// + /// {@tool dartpad} + /// This sample shows how to use [ColorScheme.fromSeed] to create dynamic + /// color schemes with different [DynamicSchemeVariant]s and different + /// contrast level. + /// + /// ** See code in examples/api/lib/material/color_scheme/color_scheme.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * <https://m3.material.io/styles/color/the-color-system/color-roles>, the + /// Material 3 Color system specification. + /// * <https://pub.dev/packages/material_color_utilities>, the package + /// used to generate the tonal palettes needed for the scheme. + factory ColorScheme.fromSeed({ + required Color seedColor, + Brightness brightness = Brightness.light, + DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot, + double contrastLevel = 0.0, + Color? primary, + Color? onPrimary, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + Color? secondary, + Color? onSecondary, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + Color? error, + Color? onError, + Color? errorContainer, + Color? onErrorContainer, + Color? outline, + Color? outlineVariant, + Color? surface, + Color? onSurface, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? shadow, + Color? scrim, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) { + final DynamicScheme scheme = _buildDynamicScheme( + brightness, + seedColor, + dynamicSchemeVariant, + contrastLevel, + ); + + return ColorScheme( + primary: primary ?? Color(MaterialDynamicColors.primary.getArgb(scheme)), + onPrimary: onPrimary ?? Color(MaterialDynamicColors.onPrimary.getArgb(scheme)), + primaryContainer: + primaryContainer ?? Color(MaterialDynamicColors.primaryContainer.getArgb(scheme)), + onPrimaryContainer: + onPrimaryContainer ?? Color(MaterialDynamicColors.onPrimaryContainer.getArgb(scheme)), + primaryFixed: primaryFixed ?? Color(MaterialDynamicColors.primaryFixed.getArgb(scheme)), + primaryFixedDim: + primaryFixedDim ?? Color(MaterialDynamicColors.primaryFixedDim.getArgb(scheme)), + onPrimaryFixed: onPrimaryFixed ?? Color(MaterialDynamicColors.onPrimaryFixed.getArgb(scheme)), + onPrimaryFixedVariant: + onPrimaryFixedVariant ?? + Color(MaterialDynamicColors.onPrimaryFixedVariant.getArgb(scheme)), + secondary: secondary ?? Color(MaterialDynamicColors.secondary.getArgb(scheme)), + onSecondary: onSecondary ?? Color(MaterialDynamicColors.onSecondary.getArgb(scheme)), + secondaryContainer: + secondaryContainer ?? Color(MaterialDynamicColors.secondaryContainer.getArgb(scheme)), + onSecondaryContainer: + onSecondaryContainer ?? Color(MaterialDynamicColors.onSecondaryContainer.getArgb(scheme)), + secondaryFixed: secondaryFixed ?? Color(MaterialDynamicColors.secondaryFixed.getArgb(scheme)), + secondaryFixedDim: + secondaryFixedDim ?? Color(MaterialDynamicColors.secondaryFixedDim.getArgb(scheme)), + onSecondaryFixed: + onSecondaryFixed ?? Color(MaterialDynamicColors.onSecondaryFixed.getArgb(scheme)), + onSecondaryFixedVariant: + onSecondaryFixedVariant ?? + Color(MaterialDynamicColors.onSecondaryFixedVariant.getArgb(scheme)), + tertiary: tertiary ?? Color(MaterialDynamicColors.tertiary.getArgb(scheme)), + onTertiary: onTertiary ?? Color(MaterialDynamicColors.onTertiary.getArgb(scheme)), + tertiaryContainer: + tertiaryContainer ?? Color(MaterialDynamicColors.tertiaryContainer.getArgb(scheme)), + onTertiaryContainer: + onTertiaryContainer ?? Color(MaterialDynamicColors.onTertiaryContainer.getArgb(scheme)), + tertiaryFixed: tertiaryFixed ?? Color(MaterialDynamicColors.tertiaryFixed.getArgb(scheme)), + tertiaryFixedDim: + tertiaryFixedDim ?? Color(MaterialDynamicColors.tertiaryFixedDim.getArgb(scheme)), + onTertiaryFixed: + onTertiaryFixed ?? Color(MaterialDynamicColors.onTertiaryFixed.getArgb(scheme)), + onTertiaryFixedVariant: + onTertiaryFixedVariant ?? + Color(MaterialDynamicColors.onTertiaryFixedVariant.getArgb(scheme)), + error: error ?? Color(MaterialDynamicColors.error.getArgb(scheme)), + onError: onError ?? Color(MaterialDynamicColors.onError.getArgb(scheme)), + errorContainer: errorContainer ?? Color(MaterialDynamicColors.errorContainer.getArgb(scheme)), + onErrorContainer: + onErrorContainer ?? Color(MaterialDynamicColors.onErrorContainer.getArgb(scheme)), + outline: outline ?? Color(MaterialDynamicColors.outline.getArgb(scheme)), + outlineVariant: outlineVariant ?? Color(MaterialDynamicColors.outlineVariant.getArgb(scheme)), + surface: surface ?? Color(MaterialDynamicColors.surface.getArgb(scheme)), + surfaceDim: surfaceDim ?? Color(MaterialDynamicColors.surfaceDim.getArgb(scheme)), + surfaceBright: surfaceBright ?? Color(MaterialDynamicColors.surfaceBright.getArgb(scheme)), + surfaceContainerLowest: + surfaceContainerLowest ?? + Color(MaterialDynamicColors.surfaceContainerLowest.getArgb(scheme)), + surfaceContainerLow: + surfaceContainerLow ?? Color(MaterialDynamicColors.surfaceContainerLow.getArgb(scheme)), + surfaceContainer: + surfaceContainer ?? Color(MaterialDynamicColors.surfaceContainer.getArgb(scheme)), + surfaceContainerHigh: + surfaceContainerHigh ?? Color(MaterialDynamicColors.surfaceContainerHigh.getArgb(scheme)), + surfaceContainerHighest: + surfaceContainerHighest ?? + Color(MaterialDynamicColors.surfaceContainerHighest.getArgb(scheme)), + onSurface: onSurface ?? Color(MaterialDynamicColors.onSurface.getArgb(scheme)), + onSurfaceVariant: + onSurfaceVariant ?? Color(MaterialDynamicColors.onSurfaceVariant.getArgb(scheme)), + inverseSurface: inverseSurface ?? Color(MaterialDynamicColors.inverseSurface.getArgb(scheme)), + onInverseSurface: + onInverseSurface ?? Color(MaterialDynamicColors.inverseOnSurface.getArgb(scheme)), + inversePrimary: inversePrimary ?? Color(MaterialDynamicColors.inversePrimary.getArgb(scheme)), + shadow: shadow ?? Color(MaterialDynamicColors.shadow.getArgb(scheme)), + scrim: scrim ?? Color(MaterialDynamicColors.scrim.getArgb(scheme)), + surfaceTint: surfaceTint ?? Color(MaterialDynamicColors.primary.getArgb(scheme)), + brightness: brightness, + // DEPRECATED (newest deprecations at the bottom) + background: background ?? Color(MaterialDynamicColors.background.getArgb(scheme)), + onBackground: onBackground ?? Color(MaterialDynamicColors.onBackground.getArgb(scheme)), + surfaceVariant: surfaceVariant ?? Color(MaterialDynamicColors.surfaceVariant.getArgb(scheme)), + ); + } + + /// Create a light ColorScheme based on a purple primary color that matches the + /// [baseline Material 2 color scheme](https://material.io/design/color/the-color-system.html#color-theme-creation). + /// + /// This constructor shouldn't be used to update the Material 3 color scheme. + /// + /// For Material 3, use [ColorScheme.fromSeed] to create a color scheme + /// from a single seed color based on the Material 3 color system. + /// + /// {@tool snippet} + /// This example demonstrates how to create a color scheme similar to [ColorScheme.light] + /// using the [ColorScheme.fromSeed] constructor: + /// + /// ```dart + /// colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff6200ee)).copyWith( + /// primaryContainer: const Color(0xff6200ee), + /// onPrimaryContainer: Colors.white, + /// secondaryContainer: const Color(0xff03dac6), + /// onSecondaryContainer: Colors.black, + /// error: const Color(0xffb00020), + /// onError: Colors.white, + /// ), + /// ``` + /// {@end-tool} + const ColorScheme.light({ + this.brightness = Brightness.light, + this.primary = const Color(0xff6200ee), + this.onPrimary = Colors.white, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + this.secondary = const Color(0xff03dac6), + this.onSecondary = Colors.black, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + this.error = const Color(0xffb00020), + this.onError = Colors.white, + Color? errorContainer, + Color? onErrorContainer, + this.surface = Colors.white, + this.onSurface = Colors.black, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? outline, + Color? outlineVariant, + Color? shadow, + Color? scrim, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background = Colors.white, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground = Colors.black, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) : _primaryContainer = primaryContainer, + _onPrimaryContainer = onPrimaryContainer, + _primaryFixed = primaryFixed, + _primaryFixedDim = primaryFixedDim, + _onPrimaryFixed = onPrimaryFixed, + _onPrimaryFixedVariant = onPrimaryFixedVariant, + _secondaryContainer = secondaryContainer, + _onSecondaryContainer = onSecondaryContainer, + _secondaryFixed = secondaryFixed, + _secondaryFixedDim = secondaryFixedDim, + _onSecondaryFixed = onSecondaryFixed, + _onSecondaryFixedVariant = onSecondaryFixedVariant, + _tertiary = tertiary, + _onTertiary = onTertiary, + _tertiaryContainer = tertiaryContainer, + _onTertiaryContainer = onTertiaryContainer, + _tertiaryFixed = tertiaryFixed, + _tertiaryFixedDim = tertiaryFixedDim, + _onTertiaryFixed = onTertiaryFixed, + _onTertiaryFixedVariant = onTertiaryFixedVariant, + _errorContainer = errorContainer, + _onErrorContainer = onErrorContainer, + _surfaceDim = surfaceDim, + _surfaceBright = surfaceBright, + _surfaceContainerLowest = surfaceContainerLowest, + _surfaceContainerLow = surfaceContainerLow, + _surfaceContainer = surfaceContainer, + _surfaceContainerHigh = surfaceContainerHigh, + _surfaceContainerHighest = surfaceContainerHighest, + _onSurfaceVariant = onSurfaceVariant, + _outline = outline, + _outlineVariant = outlineVariant, + _shadow = shadow, + _scrim = scrim, + _inverseSurface = inverseSurface, + _onInverseSurface = onInverseSurface, + _inversePrimary = inversePrimary, + _surfaceTint = surfaceTint, + // DEPRECATED (newest deprecations at the bottom) + _background = background, + _onBackground = onBackground, + _surfaceVariant = surfaceVariant; + + /// Create the dark color scheme that matches the + /// [baseline Material 2 color scheme](https://material.io/design/color/dark-theme.html#ui-application). + /// + /// This constructor shouldn't be used to update the Material 3 color scheme. + /// + /// For Material 3, use [ColorScheme.fromSeed] to create a color scheme + /// from a single seed color based on the Material 3 color system. + /// Override the `brightness` property of [ColorScheme.fromSeed] to create a + /// dark color scheme. + /// + /// {@tool snippet} + /// This example demonstrates how to create a color scheme similar to [ColorScheme.dark] + /// using the [ColorScheme.fromSeed] constructor: + /// + /// ```dart + /// colorScheme: ColorScheme.fromSeed( + /// seedColor: const Color(0xffbb86fc), + /// brightness: Brightness.dark, + /// ).copyWith( + /// primaryContainer: const Color(0xffbb86fc), + /// onPrimaryContainer: Colors.black, + /// secondaryContainer: const Color(0xff03dac6), + /// onSecondaryContainer: Colors.black, + /// error: const Color(0xffcf6679), + /// onError: Colors.black, + /// ), + /// ``` + /// {@end-tool} + const ColorScheme.dark({ + this.brightness = Brightness.dark, + this.primary = const Color(0xffbb86fc), + this.onPrimary = Colors.black, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + this.secondary = const Color(0xff03dac6), + this.onSecondary = Colors.black, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + this.error = const Color(0xffcf6679), + this.onError = Colors.black, + Color? errorContainer, + Color? onErrorContainer, + this.surface = const Color(0xff121212), + this.onSurface = Colors.white, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? outline, + Color? outlineVariant, + Color? shadow, + Color? scrim, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background = const Color(0xff121212), + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground = Colors.white, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) : _primaryContainer = primaryContainer, + _onPrimaryContainer = onPrimaryContainer, + _primaryFixed = primaryFixed, + _primaryFixedDim = primaryFixedDim, + _onPrimaryFixed = onPrimaryFixed, + _onPrimaryFixedVariant = onPrimaryFixedVariant, + _secondaryContainer = secondaryContainer, + _onSecondaryContainer = onSecondaryContainer, + _secondaryFixed = secondaryFixed, + _secondaryFixedDim = secondaryFixedDim, + _onSecondaryFixed = onSecondaryFixed, + _onSecondaryFixedVariant = onSecondaryFixedVariant, + _tertiary = tertiary, + _onTertiary = onTertiary, + _tertiaryContainer = tertiaryContainer, + _onTertiaryContainer = onTertiaryContainer, + _tertiaryFixed = tertiaryFixed, + _tertiaryFixedDim = tertiaryFixedDim, + _onTertiaryFixed = onTertiaryFixed, + _onTertiaryFixedVariant = onTertiaryFixedVariant, + _errorContainer = errorContainer, + _onErrorContainer = onErrorContainer, + _surfaceDim = surfaceDim, + _surfaceBright = surfaceBright, + _surfaceContainerLowest = surfaceContainerLowest, + _surfaceContainerLow = surfaceContainerLow, + _surfaceContainer = surfaceContainer, + _surfaceContainerHigh = surfaceContainerHigh, + _surfaceContainerHighest = surfaceContainerHighest, + _onSurfaceVariant = onSurfaceVariant, + _outline = outline, + _outlineVariant = outlineVariant, + _shadow = shadow, + _scrim = scrim, + _inverseSurface = inverseSurface, + _onInverseSurface = onInverseSurface, + _inversePrimary = inversePrimary, + _surfaceTint = surfaceTint, + // DEPRECATED (newest deprecations at the bottom) + _background = background, + _onBackground = onBackground, + _surfaceVariant = surfaceVariant; + + /// Create a high contrast ColorScheme based on a purple primary color that + /// matches the [baseline Material 2 color scheme](https://material.io/design/color/the-color-system.html#color-theme-creation). + /// + /// This constructor shouldn't be used to update the Material 3 color scheme. + /// + /// For Material 3, use [ColorScheme.fromSeed] to create a color scheme + /// from a single seed color based on the Material 3 color system. To create a + /// high-contrast color scheme, set `contrastLevel` to 1.0. + /// + /// {@tool snippet} + /// This example demonstrates how to create a color scheme similar to [ColorScheme.highContrastLight] + /// using the [ColorScheme.fromSeed] constructor: + /// + /// ```dart + /// colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff0000ba)).copyWith( + /// primaryContainer: const Color(0xff0000ba), + /// onPrimaryContainer: Colors.white, + /// secondaryContainer: const Color(0xff66fff9), + /// onSecondaryContainer: Colors.black, + /// error: const Color(0xff790000), + /// onError: Colors.white, + /// ), + /// ``` + /// {@end-tool} + const ColorScheme.highContrastLight({ + this.brightness = Brightness.light, + this.primary = const Color(0xff0000ba), + this.onPrimary = Colors.white, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + this.secondary = const Color(0xff66fff9), + this.onSecondary = Colors.black, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + this.error = const Color(0xff790000), + this.onError = Colors.white, + Color? errorContainer, + Color? onErrorContainer, + this.surface = Colors.white, + this.onSurface = Colors.black, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? outline, + Color? outlineVariant, + Color? shadow, + Color? scrim, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background = Colors.white, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground = Colors.black, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) : _primaryContainer = primaryContainer, + _onPrimaryContainer = onPrimaryContainer, + _primaryFixed = primaryFixed, + _primaryFixedDim = primaryFixedDim, + _onPrimaryFixed = onPrimaryFixed, + _onPrimaryFixedVariant = onPrimaryFixedVariant, + _secondaryContainer = secondaryContainer, + _onSecondaryContainer = onSecondaryContainer, + _secondaryFixed = secondaryFixed, + _secondaryFixedDim = secondaryFixedDim, + _onSecondaryFixed = onSecondaryFixed, + _onSecondaryFixedVariant = onSecondaryFixedVariant, + _tertiary = tertiary, + _onTertiary = onTertiary, + _tertiaryContainer = tertiaryContainer, + _onTertiaryContainer = onTertiaryContainer, + _tertiaryFixed = tertiaryFixed, + _tertiaryFixedDim = tertiaryFixedDim, + _onTertiaryFixed = onTertiaryFixed, + _onTertiaryFixedVariant = onTertiaryFixedVariant, + _errorContainer = errorContainer, + _onErrorContainer = onErrorContainer, + _surfaceDim = surfaceDim, + _surfaceBright = surfaceBright, + _surfaceContainerLowest = surfaceContainerLowest, + _surfaceContainerLow = surfaceContainerLow, + _surfaceContainer = surfaceContainer, + _surfaceContainerHigh = surfaceContainerHigh, + _surfaceContainerHighest = surfaceContainerHighest, + _onSurfaceVariant = onSurfaceVariant, + _outline = outline, + _outlineVariant = outlineVariant, + _shadow = shadow, + _scrim = scrim, + _inverseSurface = inverseSurface, + _onInverseSurface = onInverseSurface, + _inversePrimary = inversePrimary, + _surfaceTint = surfaceTint, + // DEPRECATED (newest deprecations at the bottom) + _background = background, + _onBackground = onBackground, + _surfaceVariant = surfaceVariant; + + /// Create a high contrast ColorScheme based on the dark + /// [baseline Material 2 color scheme](https://material.io/design/color/dark-theme.html#ui-application). + /// + /// This constructor shouldn't be used to update the Material 3 color scheme. + /// + /// For Material 3, use [ColorScheme.fromSeed] to create a color scheme + /// from a single seed color based on the Material 3 color system. + /// Override the `brightness` property of [ColorScheme.fromSeed] to create a + /// dark color scheme. To create a high-contrast color scheme, set + /// `contrastLevel` to 1.0. + /// + /// {@tool snippet} + /// This example demonstrates how to create a color scheme similar to [ColorScheme.highContrastDark] + /// using the [ColorScheme.fromSeed] constructor: + /// + /// ```dart + /// colorScheme: ColorScheme.fromSeed( + /// seedColor: const Color(0xffefb7ff), + /// brightness: Brightness.dark, + /// ).copyWith( + /// primaryContainer: const Color(0xffefb7ff), + /// onPrimaryContainer: Colors.black, + /// secondaryContainer: const Color(0xff66fff9), + /// onSecondaryContainer: Colors.black, + /// error: const Color(0xff9b374d), + /// onError: Colors.white, + /// ), + /// ``` + /// {@end-tool} + const ColorScheme.highContrastDark({ + this.brightness = Brightness.dark, + this.primary = const Color(0xffefb7ff), + this.onPrimary = Colors.black, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + this.secondary = const Color(0xff66fff9), + this.onSecondary = Colors.black, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + this.error = const Color(0xff9b374d), + this.onError = Colors.black, + Color? errorContainer, + Color? onErrorContainer, + this.surface = const Color(0xff121212), + this.onSurface = Colors.white, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? outline, + Color? outlineVariant, + Color? shadow, + Color? scrim, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background = const Color(0xff121212), + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground = Colors.white, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) : _primaryContainer = primaryContainer, + _onPrimaryContainer = onPrimaryContainer, + _primaryFixed = primaryFixed, + _primaryFixedDim = primaryFixedDim, + _onPrimaryFixed = onPrimaryFixed, + _onPrimaryFixedVariant = onPrimaryFixedVariant, + _secondaryContainer = secondaryContainer, + _onSecondaryContainer = onSecondaryContainer, + _secondaryFixed = secondaryFixed, + _secondaryFixedDim = secondaryFixedDim, + _onSecondaryFixed = onSecondaryFixed, + _onSecondaryFixedVariant = onSecondaryFixedVariant, + _tertiary = tertiary, + _onTertiary = onTertiary, + _tertiaryContainer = tertiaryContainer, + _onTertiaryContainer = onTertiaryContainer, + _tertiaryFixed = tertiaryFixed, + _tertiaryFixedDim = tertiaryFixedDim, + _onTertiaryFixed = onTertiaryFixed, + _onTertiaryFixedVariant = onTertiaryFixedVariant, + _errorContainer = errorContainer, + _onErrorContainer = onErrorContainer, + _surfaceDim = surfaceDim, + _surfaceBright = surfaceBright, + _surfaceContainerLowest = surfaceContainerLowest, + _surfaceContainerLow = surfaceContainerLow, + _surfaceContainer = surfaceContainer, + _surfaceContainerHigh = surfaceContainerHigh, + _surfaceContainerHighest = surfaceContainerHighest, + _onSurfaceVariant = onSurfaceVariant, + _outline = outline, + _outlineVariant = outlineVariant, + _shadow = shadow, + _scrim = scrim, + _inverseSurface = inverseSurface, + _onInverseSurface = onInverseSurface, + _inversePrimary = inversePrimary, + _surfaceTint = surfaceTint, + // DEPRECATED (newest deprecations at the bottom) + _background = background, + _onBackground = onBackground, + _surfaceVariant = surfaceVariant; + + /// Creates a color scheme from a [MaterialColor] swatch. + /// + /// In Material 3, this constructor is ignored by [ThemeData] when creating + /// its default color scheme. Instead, [ThemeData] uses [ColorScheme.fromSeed] + /// to create its default color scheme. This constructor shouldn't be used + /// to update the Material 3 color scheme. It will be phased out gradually; + /// see https://github.com/flutter/flutter/issues/120064 for more details. + /// + /// If [ThemeData.useMaterial3] is false, then this constructor is used by + /// [ThemeData] to create its default color scheme. + factory ColorScheme.fromSwatch({ + MaterialColor primarySwatch = Colors.blue, + Color? accentColor, + Color? cardColor, + Color? backgroundColor, + Color? errorColor, + Brightness brightness = Brightness.light, + }) { + final isDark = brightness == Brightness.dark; + final primaryIsDark = _brightnessFor(primarySwatch) == Brightness.dark; + final Color secondary = accentColor ?? (isDark ? Colors.tealAccent[200]! : primarySwatch); + final secondaryIsDark = _brightnessFor(secondary) == Brightness.dark; + + return ColorScheme( + primary: primarySwatch, + secondary: secondary, + surface: cardColor ?? (isDark ? Colors.grey[800]! : Colors.white), + error: errorColor ?? Colors.red[700]!, + onPrimary: primaryIsDark ? Colors.white : Colors.black, + onSecondary: secondaryIsDark ? Colors.white : Colors.black, + onSurface: isDark ? Colors.white : Colors.black, + onError: isDark ? Colors.black : Colors.white, + brightness: brightness, + // DEPRECATED (newest deprecations at the bottom) + background: backgroundColor ?? (isDark ? Colors.grey[700]! : primarySwatch[200]!), + onBackground: primaryIsDark ? Colors.white : Colors.black, + ); + } + + static Brightness _brightnessFor(Color color) => ThemeData.estimateBrightnessForColor(color); + + /// The overall brightness of this color scheme. + final Brightness brightness; + + /// The color displayed most frequently across your app’s screens and components. + final Color primary; + + /// A color that's clearly legible when drawn on [primary]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [primary] and [onPrimary] of at least 4.5:1 is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + final Color onPrimary; + + final Color? _primaryContainer; + + /// A color used for elements needing less emphasis than [primary]. + Color get primaryContainer => _primaryContainer ?? primary; + + final Color? _onPrimaryContainer; + + /// A color that's clearly legible when drawn on [primaryContainer]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [primaryContainer] and [onPrimaryContainer] of at least 4.5:1 + /// is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onPrimaryContainer => _onPrimaryContainer ?? onPrimary; + + final Color? _primaryFixed; + + /// A substitute for [primaryContainer] that's the same color for the dark + /// and light themes. + Color get primaryFixed => _primaryFixed ?? primary; + + final Color? _primaryFixedDim; + + /// A color used for elements needing more emphasis than [primaryFixed]. + Color get primaryFixedDim => _primaryFixedDim ?? primary; + + final Color? _onPrimaryFixed; + + /// A color that is used for text and icons that exist on top of elements having + /// [primaryFixed] color. + Color get onPrimaryFixed => _onPrimaryFixed ?? onPrimary; + + final Color? _onPrimaryFixedVariant; + + /// A color that provides a lower-emphasis option for text and icons than + /// [onPrimaryFixed]. + Color get onPrimaryFixedVariant => _onPrimaryFixedVariant ?? onPrimary; + + /// An accent color used for less prominent components in the UI, such as + /// filter chips, while expanding the opportunity for color expression. + final Color secondary; + + /// A color that's clearly legible when drawn on [secondary]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [secondary] and [onSecondary] of at least 4.5:1 is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + final Color onSecondary; + + final Color? _secondaryContainer; + + /// A color used for elements needing less emphasis than [secondary]. + Color get secondaryContainer => _secondaryContainer ?? secondary; + + final Color? _onSecondaryContainer; + + /// A color that's clearly legible when drawn on [secondaryContainer]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [secondaryContainer] and [onSecondaryContainer] of at least 4.5:1 is + /// recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onSecondaryContainer => _onSecondaryContainer ?? onSecondary; + + final Color? _secondaryFixed; + + /// A substitute for [secondaryContainer] that's the same color for the dark + /// and light themes. + Color get secondaryFixed => _secondaryFixed ?? secondary; + + final Color? _secondaryFixedDim; + + /// A color used for elements needing more emphasis than [secondaryFixed]. + Color get secondaryFixedDim => _secondaryFixedDim ?? secondary; + + final Color? _onSecondaryFixed; + + /// A color that is used for text and icons that exist on top of elements having + /// [secondaryFixed] color. + Color get onSecondaryFixed => _onSecondaryFixed ?? onSecondary; + + final Color? _onSecondaryFixedVariant; + + /// A color that provides a lower-emphasis option for text and icons than + /// [onSecondaryFixed]. + Color get onSecondaryFixedVariant => _onSecondaryFixedVariant ?? onSecondary; + + final Color? _tertiary; + + /// A color used as a contrasting accent that can balance [primary] + /// and [secondary] colors or bring heightened attention to an element, + /// such as an input field. + Color get tertiary => _tertiary ?? secondary; + + final Color? _onTertiary; + + /// A color that's clearly legible when drawn on [tertiary]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [tertiary] and [onTertiary] of at least 4.5:1 is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onTertiary => _onTertiary ?? onSecondary; + + final Color? _tertiaryContainer; + + /// A color used for elements needing less emphasis than [tertiary]. + Color get tertiaryContainer => _tertiaryContainer ?? tertiary; + + final Color? _onTertiaryContainer; + + /// A color that's clearly legible when drawn on [tertiaryContainer]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [tertiaryContainer] and [onTertiaryContainer] of at least 4.5:1 is + /// recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onTertiaryContainer => _onTertiaryContainer ?? onTertiary; + + final Color? _tertiaryFixed; + + /// A substitute for [tertiaryContainer] that's the same color for dark + /// and light themes. + Color get tertiaryFixed => _tertiaryFixed ?? tertiary; + + final Color? _tertiaryFixedDim; + + /// A color used for elements needing more emphasis than [tertiaryFixed]. + Color get tertiaryFixedDim => _tertiaryFixedDim ?? tertiary; + + final Color? _onTertiaryFixed; + + /// A color that is used for text and icons that exist on top of elements having + /// [tertiaryFixed] color. + Color get onTertiaryFixed => _onTertiaryFixed ?? onTertiary; + + final Color? _onTertiaryFixedVariant; + + /// A color that provides a lower-emphasis option for text and icons than + /// [onTertiaryFixed]. + Color get onTertiaryFixedVariant => _onTertiaryFixedVariant ?? onTertiary; + + /// The color to use for input validation errors, e.g. for + /// [InputDecoration.errorText]. + final Color error; + + /// A color that's clearly legible when drawn on [error]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [error] and [onError] of at least 4.5:1 is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + final Color onError; + + final Color? _errorContainer; + + /// A color used for error elements needing less emphasis than [error]. + Color get errorContainer => _errorContainer ?? error; + + final Color? _onErrorContainer; + + /// A color that's clearly legible when drawn on [errorContainer]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [errorContainer] and [onErrorContainer] of at least 4.5:1 is + /// recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onErrorContainer => _onErrorContainer ?? onError; + + /// The background color for widgets like [Scaffold]. + final Color surface; + + /// A color that's clearly legible when drawn on [surface]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [surface] and [onSurface] of at least 4.5:1 is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + final Color onSurface; + + final Color? _surfaceVariant; + + /// A color variant of [surface] that can be used for differentiation against + /// a component using [surface]. + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color get surfaceVariant => _surfaceVariant ?? surface; + + final Color? _surfaceDim; + + /// A color that's always darkest in the dark or light theme. + Color get surfaceDim => _surfaceDim ?? surface; + + final Color? _surfaceBright; + + /// A color that's always the lightest in the dark or light theme. + Color get surfaceBright => _surfaceBright ?? surface; + + final Color? _surfaceContainerLowest; + + /// A surface container color with the lightest tone and the least emphasis + /// relative to the surface. + Color get surfaceContainerLowest => _surfaceContainerLowest ?? surface; + + final Color? _surfaceContainerLow; + + /// A surface container color with a lighter tone that creates less emphasis + /// than [surfaceContainer] but more emphasis than [surfaceContainerLowest]. + Color get surfaceContainerLow => _surfaceContainerLow ?? surface; + + final Color? _surfaceContainer; + + /// A recommended color role for a distinct area within the surface. + /// + /// Surface container color roles are independent of elevation. They replace the old + /// opacity-based model which applied a tinted overlay on top of + /// surfaces based on their elevation. + /// + /// Surface container colors include [surfaceContainerLowest], [surfaceContainerLow], + /// [surfaceContainer], [surfaceContainerHigh] and [surfaceContainerHighest]. + Color get surfaceContainer => _surfaceContainer ?? surface; + + final Color? _surfaceContainerHigh; + + /// A surface container color with a darker tone. It is used to create more + /// emphasis than [surfaceContainer] but less emphasis than [surfaceContainerHighest]. + Color get surfaceContainerHigh => _surfaceContainerHigh ?? surface; + + final Color? _surfaceContainerHighest; + + /// A surface container color with the darkest tone. It is used to create the + /// most emphasis against the surface. + Color get surfaceContainerHighest => _surfaceContainerHighest ?? surface; + + final Color? _onSurfaceVariant; + + /// A color that's clearly legible when drawn on [surfaceVariant]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [surfaceVariant] and [onSurfaceVariant] of at least 4.5:1 is + /// recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onSurfaceVariant => _onSurfaceVariant ?? onSurface; + + final Color? _outline; + + /// A utility color that creates boundaries and emphasis to improve usability. + Color get outline => _outline ?? onBackground; + + final Color? _outlineVariant; + + /// A utility color that creates boundaries for decorative elements when a + /// 3:1 contrast isn’t required, such as for dividers or decorative elements. + Color get outlineVariant => _outlineVariant ?? onBackground; + + final Color? _shadow; + + /// A color use to paint the drop shadows of elevated components. + Color get shadow => _shadow ?? const Color(0xff000000); + + final Color? _scrim; + + /// A color use to paint the scrim around of modal components. + Color get scrim => _scrim ?? const Color(0xff000000); + + final Color? _inverseSurface; + + /// A surface color used for displaying the reverse of what’s seen in the + /// surrounding UI, for example in a SnackBar to bring attention to + /// an alert. + Color get inverseSurface => _inverseSurface ?? onSurface; + + final Color? _onInverseSurface; + + /// A color that's clearly legible when drawn on [inverseSurface]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [inverseSurface] and [onInverseSurface] of at least 4.5:1 is + /// recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + Color get onInverseSurface => _onInverseSurface ?? surface; + + final Color? _inversePrimary; + + /// An accent color used for displaying a highlight color on [inverseSurface] + /// backgrounds, like button text in a SnackBar. + Color get inversePrimary => _inversePrimary ?? onPrimary; + + final Color? _surfaceTint; + + /// A color used as an overlay on a surface color to indicate a component's + /// elevation. + Color get surfaceTint => _surfaceTint ?? primary; + + final Color? _background; + + /// A color that typically appears behind scrollable content. + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color get background => _background ?? surface; + + final Color? _onBackground; + + /// A color that's clearly legible when drawn on [background]. + /// + /// To ensure that an app is accessible, a contrast ratio between + /// [background] and [onBackground] of at least 4.5:1 is recommended. See + /// <https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html>. + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color get onBackground => _onBackground ?? onSurface; + + /// Creates a copy of this color scheme with the given fields + /// replaced by the non-null parameter values. + ColorScheme copyWith({ + Brightness? brightness, + Color? primary, + Color? onPrimary, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + Color? secondary, + Color? onSecondary, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + Color? error, + Color? onError, + Color? errorContainer, + Color? onErrorContainer, + Color? surface, + Color? onSurface, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? outline, + Color? outlineVariant, + Color? shadow, + Color? scrim, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) { + return ColorScheme( + brightness: brightness ?? this.brightness, + primary: primary ?? this.primary, + onPrimary: onPrimary ?? this.onPrimary, + primaryContainer: primaryContainer ?? this.primaryContainer, + onPrimaryContainer: onPrimaryContainer ?? this.onPrimaryContainer, + primaryFixed: primaryFixed ?? this.primaryFixed, + primaryFixedDim: primaryFixedDim ?? this.primaryFixedDim, + onPrimaryFixed: onPrimaryFixed ?? this.onPrimaryFixed, + onPrimaryFixedVariant: onPrimaryFixedVariant ?? this.onPrimaryFixedVariant, + secondary: secondary ?? this.secondary, + onSecondary: onSecondary ?? this.onSecondary, + secondaryContainer: secondaryContainer ?? this.secondaryContainer, + onSecondaryContainer: onSecondaryContainer ?? this.onSecondaryContainer, + secondaryFixed: secondaryFixed ?? this.secondaryFixed, + secondaryFixedDim: secondaryFixedDim ?? this.secondaryFixedDim, + onSecondaryFixed: onSecondaryFixed ?? this.onSecondaryFixed, + onSecondaryFixedVariant: onSecondaryFixedVariant ?? this.onSecondaryFixedVariant, + tertiary: tertiary ?? this.tertiary, + onTertiary: onTertiary ?? this.onTertiary, + tertiaryContainer: tertiaryContainer ?? this.tertiaryContainer, + onTertiaryContainer: onTertiaryContainer ?? this.onTertiaryContainer, + tertiaryFixed: tertiaryFixed ?? this.tertiaryFixed, + tertiaryFixedDim: tertiaryFixedDim ?? this.tertiaryFixedDim, + onTertiaryFixed: onTertiaryFixed ?? this.onTertiaryFixed, + onTertiaryFixedVariant: onTertiaryFixedVariant ?? this.onTertiaryFixedVariant, + error: error ?? this.error, + onError: onError ?? this.onError, + errorContainer: errorContainer ?? this.errorContainer, + onErrorContainer: onErrorContainer ?? this.onErrorContainer, + surface: surface ?? this.surface, + onSurface: onSurface ?? this.onSurface, + surfaceDim: surfaceDim ?? this.surfaceDim, + surfaceBright: surfaceBright ?? this.surfaceBright, + surfaceContainerLowest: surfaceContainerLowest ?? this.surfaceContainerLowest, + surfaceContainerLow: surfaceContainerLow ?? this.surfaceContainerLow, + surfaceContainer: surfaceContainer ?? this.surfaceContainer, + surfaceContainerHigh: surfaceContainerHigh ?? this.surfaceContainerHigh, + surfaceContainerHighest: surfaceContainerHighest ?? this.surfaceContainerHighest, + onSurfaceVariant: onSurfaceVariant ?? this.onSurfaceVariant, + outline: outline ?? this.outline, + outlineVariant: outlineVariant ?? this.outlineVariant, + shadow: shadow ?? this.shadow, + scrim: scrim ?? this.scrim, + inverseSurface: inverseSurface ?? this.inverseSurface, + onInverseSurface: onInverseSurface ?? this.onInverseSurface, + inversePrimary: inversePrimary ?? this.inversePrimary, + surfaceTint: surfaceTint ?? this.surfaceTint, + // DEPRECATED (newest deprecations at the bottom) + background: background ?? this.background, + onBackground: onBackground ?? this.onBackground, + surfaceVariant: surfaceVariant ?? this.surfaceVariant, + ); + } + + /// Linearly interpolate between two [ColorScheme] objects. + /// + /// {@macro dart.ui.shadow.lerp} + static ColorScheme lerp(ColorScheme a, ColorScheme b, double t) { + if (identical(a, b)) { + return a; + } + return ColorScheme( + brightness: t < 0.5 ? a.brightness : b.brightness, + primary: Color.lerp(a.primary, b.primary, t)!, + onPrimary: Color.lerp(a.onPrimary, b.onPrimary, t)!, + primaryContainer: Color.lerp(a.primaryContainer, b.primaryContainer, t), + onPrimaryContainer: Color.lerp(a.onPrimaryContainer, b.onPrimaryContainer, t), + primaryFixed: Color.lerp(a.primaryFixed, b.primaryFixed, t), + primaryFixedDim: Color.lerp(a.primaryFixedDim, b.primaryFixedDim, t), + onPrimaryFixed: Color.lerp(a.onPrimaryFixed, b.onPrimaryFixed, t), + onPrimaryFixedVariant: Color.lerp(a.onPrimaryFixedVariant, b.onPrimaryFixedVariant, t), + secondary: Color.lerp(a.secondary, b.secondary, t)!, + onSecondary: Color.lerp(a.onSecondary, b.onSecondary, t)!, + secondaryContainer: Color.lerp(a.secondaryContainer, b.secondaryContainer, t), + onSecondaryContainer: Color.lerp(a.onSecondaryContainer, b.onSecondaryContainer, t), + secondaryFixed: Color.lerp(a.secondaryFixed, b.secondaryFixed, t), + secondaryFixedDim: Color.lerp(a.secondaryFixedDim, b.secondaryFixedDim, t), + onSecondaryFixed: Color.lerp(a.onSecondaryFixed, b.onSecondaryFixed, t), + onSecondaryFixedVariant: Color.lerp(a.onSecondaryFixedVariant, b.onSecondaryFixedVariant, t), + tertiary: Color.lerp(a.tertiary, b.tertiary, t), + onTertiary: Color.lerp(a.onTertiary, b.onTertiary, t), + tertiaryContainer: Color.lerp(a.tertiaryContainer, b.tertiaryContainer, t), + onTertiaryContainer: Color.lerp(a.onTertiaryContainer, b.onTertiaryContainer, t), + tertiaryFixed: Color.lerp(a.tertiaryFixed, b.tertiaryFixed, t), + tertiaryFixedDim: Color.lerp(a.tertiaryFixedDim, b.tertiaryFixedDim, t), + onTertiaryFixed: Color.lerp(a.onTertiaryFixed, b.onTertiaryFixed, t), + onTertiaryFixedVariant: Color.lerp(a.onTertiaryFixedVariant, b.onTertiaryFixedVariant, t), + error: Color.lerp(a.error, b.error, t)!, + onError: Color.lerp(a.onError, b.onError, t)!, + errorContainer: Color.lerp(a.errorContainer, b.errorContainer, t), + onErrorContainer: Color.lerp(a.onErrorContainer, b.onErrorContainer, t), + surface: Color.lerp(a.surface, b.surface, t)!, + onSurface: Color.lerp(a.onSurface, b.onSurface, t)!, + surfaceDim: Color.lerp(a.surfaceDim, b.surfaceDim, t), + surfaceBright: Color.lerp(a.surfaceBright, b.surfaceBright, t), + surfaceContainerLowest: Color.lerp(a.surfaceContainerLowest, b.surfaceContainerLowest, t), + surfaceContainerLow: Color.lerp(a.surfaceContainerLow, b.surfaceContainerLow, t), + surfaceContainer: Color.lerp(a.surfaceContainer, b.surfaceContainer, t), + surfaceContainerHigh: Color.lerp(a.surfaceContainerHigh, b.surfaceContainerHigh, t), + surfaceContainerHighest: Color.lerp(a.surfaceContainerHighest, b.surfaceContainerHighest, t), + onSurfaceVariant: Color.lerp(a.onSurfaceVariant, b.onSurfaceVariant, t), + outline: Color.lerp(a.outline, b.outline, t), + outlineVariant: Color.lerp(a.outlineVariant, b.outlineVariant, t), + shadow: Color.lerp(a.shadow, b.shadow, t), + scrim: Color.lerp(a.scrim, b.scrim, t), + inverseSurface: Color.lerp(a.inverseSurface, b.inverseSurface, t), + onInverseSurface: Color.lerp(a.onInverseSurface, b.onInverseSurface, t), + inversePrimary: Color.lerp(a.inversePrimary, b.inversePrimary, t), + surfaceTint: Color.lerp(a.surfaceTint, b.surfaceTint, t), + // DEPRECATED (newest deprecations at the bottom) + background: Color.lerp(a.background, b.background, t), + onBackground: Color.lerp(a.onBackground, b.onBackground, t), + surfaceVariant: Color.lerp(a.surfaceVariant, b.surfaceVariant, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ColorScheme && + other.brightness == brightness && + other.primary == primary && + other.onPrimary == onPrimary && + other.primaryContainer == primaryContainer && + other.onPrimaryContainer == onPrimaryContainer && + other.primaryFixed == primaryFixed && + other.primaryFixedDim == primaryFixedDim && + other.onPrimaryFixed == onPrimaryFixed && + other.onPrimaryFixedVariant == onPrimaryFixedVariant && + other.secondary == secondary && + other.onSecondary == onSecondary && + other.secondaryContainer == secondaryContainer && + other.onSecondaryContainer == onSecondaryContainer && + other.secondaryFixed == secondaryFixed && + other.secondaryFixedDim == secondaryFixedDim && + other.onSecondaryFixed == onSecondaryFixed && + other.onSecondaryFixedVariant == onSecondaryFixedVariant && + other.tertiary == tertiary && + other.onTertiary == onTertiary && + other.tertiaryContainer == tertiaryContainer && + other.onTertiaryContainer == onTertiaryContainer && + other.tertiaryFixed == tertiaryFixed && + other.tertiaryFixedDim == tertiaryFixedDim && + other.onTertiaryFixed == onTertiaryFixed && + other.onTertiaryFixedVariant == onTertiaryFixedVariant && + other.error == error && + other.onError == onError && + other.errorContainer == errorContainer && + other.onErrorContainer == onErrorContainer && + other.surface == surface && + other.onSurface == onSurface && + other.surfaceDim == surfaceDim && + other.surfaceBright == surfaceBright && + other.surfaceContainerLowest == surfaceContainerLowest && + other.surfaceContainerLow == surfaceContainerLow && + other.surfaceContainer == surfaceContainer && + other.surfaceContainerHigh == surfaceContainerHigh && + other.surfaceContainerHighest == surfaceContainerHighest && + other.onSurfaceVariant == onSurfaceVariant && + other.outline == outline && + other.outlineVariant == outlineVariant && + other.shadow == shadow && + other.scrim == scrim && + other.inverseSurface == inverseSurface && + other.onInverseSurface == onInverseSurface && + other.inversePrimary == inversePrimary && + other.surfaceTint == surfaceTint + // DEPRECATED (newest deprecations at the bottom) + && + other.background == background && + other.onBackground == onBackground && + other.surfaceVariant == surfaceVariant; + } + + @override + int get hashCode => Object.hash( + brightness, + primary, + onPrimary, + primaryContainer, + onPrimaryContainer, + secondary, + onSecondary, + secondaryContainer, + onSecondaryContainer, + tertiary, + onTertiary, + tertiaryContainer, + onTertiaryContainer, + error, + onError, + errorContainer, + onErrorContainer, + Object.hash( + surface, + onSurface, + surfaceDim, + surfaceBright, + surfaceContainerLowest, + surfaceContainerLow, + surfaceContainer, + surfaceContainerHigh, + surfaceContainerHighest, + onSurfaceVariant, + outline, + outlineVariant, + shadow, + scrim, + inverseSurface, + onInverseSurface, + inversePrimary, + surfaceTint, + Object.hash( + primaryFixed, + primaryFixedDim, + onPrimaryFixed, + onPrimaryFixedVariant, + secondaryFixed, + secondaryFixedDim, + onSecondaryFixed, + onSecondaryFixedVariant, + tertiaryFixed, + tertiaryFixedDim, + onTertiaryFixed, + onTertiaryFixedVariant, + // DEPRECATED (newest deprecations at the bottom) + background, + onBackground, + surfaceVariant, + ), + ), + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultScheme = ColorScheme.light(); + properties.add( + DiagnosticsProperty<Brightness>( + 'brightness', + brightness, + defaultValue: defaultScheme.brightness, + ), + ); + properties.add(ColorProperty('primary', primary, defaultValue: defaultScheme.primary)); + properties.add(ColorProperty('onPrimary', onPrimary, defaultValue: defaultScheme.onPrimary)); + properties.add( + ColorProperty( + 'primaryContainer', + primaryContainer, + defaultValue: defaultScheme.primaryContainer, + ), + ); + properties.add( + ColorProperty( + 'onPrimaryContainer', + onPrimaryContainer, + defaultValue: defaultScheme.onPrimaryContainer, + ), + ); + properties.add( + ColorProperty('primaryFixed', primaryFixed, defaultValue: defaultScheme.primaryFixed), + ); + properties.add( + ColorProperty( + 'primaryFixedDim', + primaryFixedDim, + defaultValue: defaultScheme.primaryFixedDim, + ), + ); + properties.add( + ColorProperty('onPrimaryFixed', onPrimaryFixed, defaultValue: defaultScheme.onPrimaryFixed), + ); + properties.add( + ColorProperty( + 'onPrimaryFixedVariant', + onPrimaryFixedVariant, + defaultValue: defaultScheme.onPrimaryFixedVariant, + ), + ); + properties.add(ColorProperty('secondary', secondary, defaultValue: defaultScheme.secondary)); + properties.add( + ColorProperty('onSecondary', onSecondary, defaultValue: defaultScheme.onSecondary), + ); + properties.add( + ColorProperty( + 'secondaryContainer', + secondaryContainer, + defaultValue: defaultScheme.secondaryContainer, + ), + ); + properties.add( + ColorProperty( + 'onSecondaryContainer', + onSecondaryContainer, + defaultValue: defaultScheme.onSecondaryContainer, + ), + ); + properties.add( + ColorProperty('secondaryFixed', secondaryFixed, defaultValue: defaultScheme.secondaryFixed), + ); + properties.add( + ColorProperty( + 'secondaryFixedDim', + secondaryFixedDim, + defaultValue: defaultScheme.secondaryFixedDim, + ), + ); + properties.add( + ColorProperty( + 'onSecondaryFixed', + onSecondaryFixed, + defaultValue: defaultScheme.onSecondaryFixed, + ), + ); + properties.add( + ColorProperty( + 'onSecondaryFixedVariant', + onSecondaryFixedVariant, + defaultValue: defaultScheme.onSecondaryFixedVariant, + ), + ); + properties.add(ColorProperty('tertiary', tertiary, defaultValue: defaultScheme.tertiary)); + properties.add(ColorProperty('onTertiary', onTertiary, defaultValue: defaultScheme.onTertiary)); + properties.add( + ColorProperty( + 'tertiaryContainer', + tertiaryContainer, + defaultValue: defaultScheme.tertiaryContainer, + ), + ); + properties.add( + ColorProperty( + 'onTertiaryContainer', + onTertiaryContainer, + defaultValue: defaultScheme.onTertiaryContainer, + ), + ); + properties.add( + ColorProperty('tertiaryFixed', tertiaryFixed, defaultValue: defaultScheme.tertiaryFixed), + ); + properties.add( + ColorProperty( + 'tertiaryFixedDim', + tertiaryFixedDim, + defaultValue: defaultScheme.tertiaryFixedDim, + ), + ); + properties.add( + ColorProperty( + 'onTertiaryFixed', + onTertiaryFixed, + defaultValue: defaultScheme.onTertiaryFixed, + ), + ); + properties.add( + ColorProperty( + 'onTertiaryFixedVariant', + onTertiaryFixedVariant, + defaultValue: defaultScheme.onTertiaryFixedVariant, + ), + ); + properties.add(ColorProperty('error', error, defaultValue: defaultScheme.error)); + properties.add(ColorProperty('onError', onError, defaultValue: defaultScheme.onError)); + properties.add( + ColorProperty('errorContainer', errorContainer, defaultValue: defaultScheme.errorContainer), + ); + properties.add( + ColorProperty( + 'onErrorContainer', + onErrorContainer, + defaultValue: defaultScheme.onErrorContainer, + ), + ); + properties.add(ColorProperty('surface', surface, defaultValue: defaultScheme.surface)); + properties.add(ColorProperty('onSurface', onSurface, defaultValue: defaultScheme.onSurface)); + properties.add(ColorProperty('surfaceDim', surfaceDim, defaultValue: defaultScheme.surfaceDim)); + properties.add( + ColorProperty('surfaceBright', surfaceBright, defaultValue: defaultScheme.surfaceBright), + ); + properties.add( + ColorProperty( + 'surfaceContainerLowest', + surfaceContainerLowest, + defaultValue: defaultScheme.surfaceContainerLowest, + ), + ); + properties.add( + ColorProperty( + 'surfaceContainerLow', + surfaceContainerLow, + defaultValue: defaultScheme.surfaceContainerLow, + ), + ); + properties.add( + ColorProperty( + 'surfaceContainer', + surfaceContainer, + defaultValue: defaultScheme.surfaceContainer, + ), + ); + properties.add( + ColorProperty( + 'surfaceContainerHigh', + surfaceContainerHigh, + defaultValue: defaultScheme.surfaceContainerHigh, + ), + ); + properties.add( + ColorProperty( + 'surfaceContainerHighest', + surfaceContainerHighest, + defaultValue: defaultScheme.surfaceContainerHighest, + ), + ); + properties.add( + ColorProperty( + 'onSurfaceVariant', + onSurfaceVariant, + defaultValue: defaultScheme.onSurfaceVariant, + ), + ); + properties.add(ColorProperty('outline', outline, defaultValue: defaultScheme.outline)); + properties.add( + ColorProperty('outlineVariant', outlineVariant, defaultValue: defaultScheme.outlineVariant), + ); + properties.add(ColorProperty('shadow', shadow, defaultValue: defaultScheme.shadow)); + properties.add(ColorProperty('scrim', scrim, defaultValue: defaultScheme.scrim)); + properties.add( + ColorProperty('inverseSurface', inverseSurface, defaultValue: defaultScheme.inverseSurface), + ); + properties.add( + ColorProperty( + 'onInverseSurface', + onInverseSurface, + defaultValue: defaultScheme.onInverseSurface, + ), + ); + properties.add( + ColorProperty('inversePrimary', inversePrimary, defaultValue: defaultScheme.inversePrimary), + ); + properties.add( + ColorProperty('surfaceTint', surfaceTint, defaultValue: defaultScheme.surfaceTint), + ); + // DEPRECATED (newest deprecations at the bottom) + properties.add(ColorProperty('background', background, defaultValue: defaultScheme.background)); + properties.add( + ColorProperty('onBackground', onBackground, defaultValue: defaultScheme.onBackground), + ); + properties.add( + ColorProperty('surfaceVariant', surfaceVariant, defaultValue: defaultScheme.surfaceVariant), + ); + } + + /// Generate a [ColorScheme] derived from the given `imageProvider`. + /// + /// Material Color Utilities extracts the dominant color from the + /// supplied [ImageProvider]. Using this color, a [ColorScheme] is generated + /// with harmonious colors that meet contrast requirements for accessibility. + /// + /// If any of the optional color parameters are non-null, they will be + /// used in place of the generated colors for that field in the resulting + /// [ColorScheme]. This allows apps to override specific colors for their + /// needs. + /// + /// Given the nature of the algorithm, the most dominant color of the + /// `imageProvider` may not wind up as one of the [ColorScheme] colors. + /// + /// The provided image will be scaled down to a maximum size of 112x112 pixels + /// during color extraction. + /// + /// {@tool dartpad} + /// This sample shows how to use [ColorScheme.fromImageProvider] to create + /// content-based dynamic color schemes. + /// + /// ** See code in examples/api/lib/material/color_scheme/dynamic_content_color.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [M3 Guidelines: Dynamic color from content](https://m3.material.io/styles/color/dynamic-color/user-generated-color#8af550b9-a19e-4e9f-bb0a-7f611fed5d0f) + /// * <https://pub.dev/packages/dynamic_color>, a package to create + /// [ColorScheme]s based on a platform's implementation of dynamic color. + /// * <https://m3.material.io/styles/color/the-color-system/color-roles>, the + /// Material 3 Color system specification. + /// * <https://pub.dev/packages/material_color_utilities>, the package + /// used to algorithmically determine the dominant color and to generate + /// the [ColorScheme]. + static Future<ColorScheme> fromImageProvider({ + required ImageProvider provider, + Brightness brightness = Brightness.light, + DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot, + double contrastLevel = 0.0, + Color? primary, + Color? onPrimary, + Color? primaryContainer, + Color? onPrimaryContainer, + Color? primaryFixed, + Color? primaryFixedDim, + Color? onPrimaryFixed, + Color? onPrimaryFixedVariant, + Color? secondary, + Color? onSecondary, + Color? secondaryContainer, + Color? onSecondaryContainer, + Color? secondaryFixed, + Color? secondaryFixedDim, + Color? onSecondaryFixed, + Color? onSecondaryFixedVariant, + Color? tertiary, + Color? onTertiary, + Color? tertiaryContainer, + Color? onTertiaryContainer, + Color? tertiaryFixed, + Color? tertiaryFixedDim, + Color? onTertiaryFixed, + Color? onTertiaryFixedVariant, + Color? error, + Color? onError, + Color? errorContainer, + Color? onErrorContainer, + Color? outline, + Color? outlineVariant, + Color? surface, + Color? onSurface, + Color? surfaceDim, + Color? surfaceBright, + Color? surfaceContainerLowest, + Color? surfaceContainerLow, + Color? surfaceContainer, + Color? surfaceContainerHigh, + Color? surfaceContainerHighest, + Color? onSurfaceVariant, + Color? inverseSurface, + Color? onInverseSurface, + Color? inversePrimary, + Color? shadow, + Color? scrim, + Color? surfaceTint, + @Deprecated( + 'Use surface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? background, + @Deprecated( + 'Use onSurface instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? onBackground, + @Deprecated( + 'Use surfaceContainerHighest instead. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', + ) + Color? surfaceVariant, + }) async { + // Extract dominant colors from image. + final QuantizerResult quantizerResult = await _extractColorsFromImageProvider(provider); + final Map<int, int> colorToCount = quantizerResult.colorToCount.map( + (int key, int value) => MapEntry<int, int>(_getArgbFromAbgr(key), value), + ); + + // Score colors for color scheme suitability. + final List<int> scoredResults = Score.score(colorToCount, desired: 1); + final baseColor = Color(scoredResults.first); + + final DynamicScheme scheme = _buildDynamicScheme( + brightness, + baseColor, + dynamicSchemeVariant, + contrastLevel, + ); + + return ColorScheme( + primary: primary ?? Color(MaterialDynamicColors.primary.getArgb(scheme)), + onPrimary: onPrimary ?? Color(MaterialDynamicColors.onPrimary.getArgb(scheme)), + primaryContainer: + primaryContainer ?? Color(MaterialDynamicColors.primaryContainer.getArgb(scheme)), + onPrimaryContainer: + onPrimaryContainer ?? Color(MaterialDynamicColors.onPrimaryContainer.getArgb(scheme)), + primaryFixed: primaryFixed ?? Color(MaterialDynamicColors.primaryFixed.getArgb(scheme)), + primaryFixedDim: + primaryFixedDim ?? Color(MaterialDynamicColors.primaryFixedDim.getArgb(scheme)), + onPrimaryFixed: onPrimaryFixed ?? Color(MaterialDynamicColors.onPrimaryFixed.getArgb(scheme)), + onPrimaryFixedVariant: + onPrimaryFixedVariant ?? + Color(MaterialDynamicColors.onPrimaryFixedVariant.getArgb(scheme)), + secondary: secondary ?? Color(MaterialDynamicColors.secondary.getArgb(scheme)), + onSecondary: onSecondary ?? Color(MaterialDynamicColors.onSecondary.getArgb(scheme)), + secondaryContainer: + secondaryContainer ?? Color(MaterialDynamicColors.secondaryContainer.getArgb(scheme)), + onSecondaryContainer: + onSecondaryContainer ?? Color(MaterialDynamicColors.onSecondaryContainer.getArgb(scheme)), + secondaryFixed: secondaryFixed ?? Color(MaterialDynamicColors.secondaryFixed.getArgb(scheme)), + secondaryFixedDim: + secondaryFixedDim ?? Color(MaterialDynamicColors.secondaryFixedDim.getArgb(scheme)), + onSecondaryFixed: + onSecondaryFixed ?? Color(MaterialDynamicColors.onSecondaryFixed.getArgb(scheme)), + onSecondaryFixedVariant: + onSecondaryFixedVariant ?? + Color(MaterialDynamicColors.onSecondaryFixedVariant.getArgb(scheme)), + tertiary: tertiary ?? Color(MaterialDynamicColors.tertiary.getArgb(scheme)), + onTertiary: onTertiary ?? Color(MaterialDynamicColors.onTertiary.getArgb(scheme)), + tertiaryContainer: + tertiaryContainer ?? Color(MaterialDynamicColors.tertiaryContainer.getArgb(scheme)), + onTertiaryContainer: + onTertiaryContainer ?? Color(MaterialDynamicColors.onTertiaryContainer.getArgb(scheme)), + tertiaryFixed: tertiaryFixed ?? Color(MaterialDynamicColors.tertiaryFixed.getArgb(scheme)), + tertiaryFixedDim: + tertiaryFixedDim ?? Color(MaterialDynamicColors.tertiaryFixedDim.getArgb(scheme)), + onTertiaryFixed: + onTertiaryFixed ?? Color(MaterialDynamicColors.onTertiaryFixed.getArgb(scheme)), + onTertiaryFixedVariant: + onTertiaryFixedVariant ?? + Color(MaterialDynamicColors.onTertiaryFixedVariant.getArgb(scheme)), + error: error ?? Color(MaterialDynamicColors.error.getArgb(scheme)), + onError: onError ?? Color(MaterialDynamicColors.onError.getArgb(scheme)), + errorContainer: errorContainer ?? Color(MaterialDynamicColors.errorContainer.getArgb(scheme)), + onErrorContainer: + onErrorContainer ?? Color(MaterialDynamicColors.onErrorContainer.getArgb(scheme)), + outline: outline ?? Color(MaterialDynamicColors.outline.getArgb(scheme)), + outlineVariant: outlineVariant ?? Color(MaterialDynamicColors.outlineVariant.getArgb(scheme)), + surface: surface ?? Color(MaterialDynamicColors.surface.getArgb(scheme)), + surfaceDim: surfaceDim ?? Color(MaterialDynamicColors.surfaceDim.getArgb(scheme)), + surfaceBright: surfaceBright ?? Color(MaterialDynamicColors.surfaceBright.getArgb(scheme)), + surfaceContainerLowest: + surfaceContainerLowest ?? + Color(MaterialDynamicColors.surfaceContainerLowest.getArgb(scheme)), + surfaceContainerLow: + surfaceContainerLow ?? Color(MaterialDynamicColors.surfaceContainerLow.getArgb(scheme)), + surfaceContainer: + surfaceContainer ?? Color(MaterialDynamicColors.surfaceContainer.getArgb(scheme)), + surfaceContainerHigh: + surfaceContainerHigh ?? Color(MaterialDynamicColors.surfaceContainerHigh.getArgb(scheme)), + surfaceContainerHighest: + surfaceContainerHighest ?? + Color(MaterialDynamicColors.surfaceContainerHighest.getArgb(scheme)), + onSurface: onSurface ?? Color(MaterialDynamicColors.onSurface.getArgb(scheme)), + onSurfaceVariant: + onSurfaceVariant ?? Color(MaterialDynamicColors.onSurfaceVariant.getArgb(scheme)), + inverseSurface: inverseSurface ?? Color(MaterialDynamicColors.inverseSurface.getArgb(scheme)), + onInverseSurface: + onInverseSurface ?? Color(MaterialDynamicColors.inverseOnSurface.getArgb(scheme)), + inversePrimary: inversePrimary ?? Color(MaterialDynamicColors.inversePrimary.getArgb(scheme)), + shadow: shadow ?? Color(MaterialDynamicColors.shadow.getArgb(scheme)), + scrim: scrim ?? Color(MaterialDynamicColors.scrim.getArgb(scheme)), + surfaceTint: surfaceTint ?? Color(MaterialDynamicColors.primary.getArgb(scheme)), + brightness: brightness, + // DEPRECATED (newest deprecations at the bottom) + background: background ?? Color(MaterialDynamicColors.background.getArgb(scheme)), + onBackground: onBackground ?? Color(MaterialDynamicColors.onBackground.getArgb(scheme)), + surfaceVariant: surfaceVariant ?? Color(MaterialDynamicColors.surfaceVariant.getArgb(scheme)), + ); + } + + // ColorScheme.fromImageProvider() utilities. + + // Extracts bytes from an [ImageProvider] and returns a [QuantizerResult] + // containing the most dominant colors. + static Future<QuantizerResult> _extractColorsFromImageProvider( + ImageProvider imageProvider, + ) async { + final ui.Image scaledImage = await _imageProviderToScaled(imageProvider); + final ByteData? imageBytes = await scaledImage.toByteData(); + + final QuantizerResult quantizerResult = await QuantizerCelebi().quantize( + imageBytes!.buffer.asUint32List(), + 128, + returnInputPixelToClusterPixel: true, + ); + return quantizerResult; + } + + // Scale image size down to reduce computation time of color extraction. + static Future<ui.Image> _imageProviderToScaled(ImageProvider imageProvider) async { + const maxDimension = 112.0; + final ImageStream stream = imageProvider.resolve( + const ImageConfiguration(size: Size(maxDimension, maxDimension)), + ); + final imageCompleter = Completer<ui.Image>(); + late ImageStreamListener listener; + late ui.Image scaledImage; + Timer? loadFailureTimeout; + + listener = ImageStreamListener( + (ImageInfo info, bool sync) async { + loadFailureTimeout?.cancel(); + stream.removeListener(listener); + final ui.Image image = info.image; + final int width = image.width; + final int height = image.height; + double paintWidth = width.toDouble(); + double paintHeight = height.toDouble(); + assert(width > 0 && height > 0); + + final bool rescale = width > maxDimension || height > maxDimension; + if (rescale) { + paintWidth = (width > height) ? maxDimension : (maxDimension / height) * width; + paintHeight = (height > width) ? maxDimension : (maxDimension / width) * height; + } + final pictureRecorder = ui.PictureRecorder(); + final canvas = Canvas(pictureRecorder); + paintImage( + canvas: canvas, + rect: Rect.fromLTRB(0, 0, paintWidth, paintHeight), + image: image, + filterQuality: FilterQuality.none, + ); + + final ui.Picture picture = pictureRecorder.endRecording(); + scaledImage = await picture.toImage(paintWidth.toInt(), paintHeight.toInt()); + imageCompleter.complete(info.image); + }, + onError: (Object exception, StackTrace? stackTrace) { + loadFailureTimeout?.cancel(); + stream.removeListener(listener); + imageCompleter.completeError(Exception('Failed to render image: $exception'), stackTrace); + }, + ); + + loadFailureTimeout = Timer(const Duration(seconds: 5), () { + stream.removeListener(listener); + imageCompleter.completeError(TimeoutException('Timeout occurred trying to load image')); + }); + + stream.addListener(listener); + await imageCompleter.future; + return scaledImage; + } + + // Converts AABBGGRR color int to AARRGGBB format. + static int _getArgbFromAbgr(int abgr) { + const exceptRMask = 0xFF00FFFF; + const int onlyRMask = ~exceptRMask; + const exceptBMask = 0xFFFFFF00; + const int onlyBMask = ~exceptBMask; + final int r = (abgr & onlyRMask) >> 16; + final int b = abgr & onlyBMask; + return (abgr & exceptRMask & exceptBMask) | (b << 16) | r; + } + + static DynamicScheme _buildDynamicScheme( + Brightness brightness, + Color seedColor, + DynamicSchemeVariant schemeVariant, + double contrastLevel, + ) { + assert( + contrastLevel >= -1.0 && contrastLevel <= 1.0, + 'contrastLevel must be between -1.0 and 1.0 inclusive.', + ); + final isDark = brightness == Brightness.dark; + final Hct sourceColor = Hct.fromInt(seedColor.value); + return switch (schemeVariant) { + DynamicSchemeVariant.tonalSpot => SchemeTonalSpot( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.fidelity => SchemeFidelity( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.content => SchemeContent( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.monochrome => SchemeMonochrome( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.neutral => SchemeNeutral( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.vibrant => SchemeVibrant( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.expressive => SchemeExpressive( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.rainbow => SchemeRainbow( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.fruitSalad => SchemeFruitSalad( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + }; + } + + /// The [ThemeData.colorScheme] of the ambient [Theme]. + /// + /// Equivalent to `Theme.of(context).colorScheme`. + static ColorScheme of(BuildContext context) => Theme.of(context).colorScheme; +} diff --git a/packages/material_ui/lib/src/colors.dart b/packages/material_ui/lib/src/colors.dart new file mode 100644 index 000000000000..29b135a953ea --- /dev/null +++ b/packages/material_ui/lib/src/colors.dart @@ -0,0 +1,1926 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'app_bar.dart'; +/// @docImport 'app_bar_theme.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'data_table.dart'; +/// @docImport 'expand_icon.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +/// @docImport 'typography.dart'; +library; + +import 'package:flutter/painting.dart'; + +/// Defines a single color as well a color swatch with ten shades of the color. +/// +/// The color's shades are referred to by index. The greater the index, the +/// darker the color. There are 10 valid indices: 50, 100, 200, ..., 900. +/// The value of this color should the same the value of index 500 and [shade500]. +/// +/// ## Updating to [ColorScheme] +/// +/// The [ColorScheme] is preferred for +/// representing colors in applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// For more information on colors in Material 3 see +/// the spec at <https://m3.material.io/styles/color/the-color-system>. +/// +///{@template flutter.material.colors.colorRoles} +/// In Material 3, colors are represented using color roles and +/// corresponding tokens. Each property in the [ColorScheme] class +/// represents one color role as defined in the spec above. +/// {@endtemplate} +/// +/// ### Material 3 Colors in Flutter +/// +///{@template flutter.material.colors.settingColors} +/// Flutter's Material widgets can be assigned colors at the widget level +/// using widget properties, +/// or at the app level using theme classes. +/// +/// For example, you can set the background of the [AppBar] by +/// setting the [AppBar.backgroundColor] to a specific [Color] value. +/// +/// To globally set the AppBar background color for your app, you +/// can set the [ThemeData.appBarTheme] property for your [MaterialApp] +/// using the [ThemeData] class. You can also override +/// the default appearance of all the [AppBar]s in a widget subtree by +/// placing the [AppBarTheme] at the root of the subtree. +/// +/// Alternatively, you can set the [ThemeData.colorScheme] property +/// to a custom [ColorScheme]. This creates a unified [ColorScheme] to be +/// used across the app. The [AppBar.backgroundColor] uses the +/// [ColorScheme.surface] by default. +///{@endtemplate} +/// +/// ### Migrating from [MaterialColor] to [ColorScheme] +/// +/// In most cases, there are new properties in Flutter widgets that +/// accept a [ColorScheme] instead of a [MaterialColor]. +/// +/// For example, you may have previously constructed a [ThemeData] +/// using a primarySwatch: +/// +/// ```dart +/// ThemeData( +/// primarySwatch: Colors.amber, +/// ) +/// ``` +/// +/// In Material 3, you can use the [ColorScheme] class to +/// construct a [ThemeData] with the same color palette +/// by using the [ColorScheme.fromSeed] constructor: +/// +/// ```dart +/// ThemeData( +/// colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber), +/// ) +/// ``` +/// +/// The [ColorScheme.fromSeed] constructor +/// will generate a set of tonal palettes, +/// which are used to create the color scheme. +/// +/// Alternatively you can use the [ColorScheme.fromSwatch] constructor: +/// +/// ```dart +/// ThemeData( +/// colorScheme: ColorScheme.fromSwatch(primarySwatch: Colors.amber), +/// ) +/// ``` +/// +/// The [ColorScheme.fromSwatch] constructor will +/// create the color scheme directly from the specific +/// color values used in the [MaterialColor]. +/// +/// +/// See also: +/// +/// * [Colors], which defines all of the standard material colors. +class MaterialColor extends ColorSwatch<int> { + /// Creates a color swatch with a variety of shades. + /// + /// The `primary` argument should be the 32 bit ARGB value of one of the + /// values in the swatch, as would be passed to the [Color.new] constructor + /// for that same color, and as is exposed by [value]. (This is distinct from + /// the specific index of the color in the swatch.) + const MaterialColor(super.primary, super.swatch); + + /// The lightest shade. + Color get shade50 => this[50]!; + + /// The second lightest shade. + Color get shade100 => this[100]!; + + /// The third lightest shade. + Color get shade200 => this[200]!; + + /// The fourth lightest shade. + Color get shade300 => this[300]!; + + /// The fifth lightest shade. + Color get shade400 => this[400]!; + + /// The default shade. + Color get shade500 => this[500]!; + + /// The fourth darkest shade. + Color get shade600 => this[600]!; + + /// The third darkest shade. + Color get shade700 => this[700]!; + + /// The second darkest shade. + Color get shade800 => this[800]!; + + /// The darkest shade. + Color get shade900 => this[900]!; +} + +/// Defines a single accent color as well a swatch of four shades of the +/// accent color. +/// +/// The color's shades are referred to by index, the colors with smaller +/// indices are lighter, larger indices are darker. There are four valid +/// indices: 100, 200, 400, and 700. The value of this color should be the +/// same as the value of index 200 and [shade200]. +/// +/// See also: +/// +/// * [Colors], which defines all of the standard material colors. +/// * <https://material.io/go/design-theming#color-color-schemes> +class MaterialAccentColor extends ColorSwatch<int> { + /// Creates a color swatch with a variety of shades appropriate for accent + /// colors. + const MaterialAccentColor(super.primary, super.swatch); + + /// The lightest shade. + Color get shade100 => this[100]!; + + /// The default shade. + Color get shade200 => this[200]!; + + /// The second darkest shade. + Color get shade400 => this[400]!; + + /// The darkest shade. + Color get shade700 => this[700]!; +} + +/// [Color] and [ColorSwatch] constants which represent Material design's +/// [color palette](https://material.io/design/color/). +/// +/// Instead of using an absolute color from these palettes, consider using +/// [Theme.of] to obtain the local [ThemeData.colorScheme], which defines +/// the colors that most of the Material components use by default. +/// +/// +/// Most swatches have colors from 100 to 900 in increments of one hundred, plus +/// the color 50. The smaller the number, the more pale the color. The greater +/// the number, the darker the color. The accent swatches (e.g. [redAccent]) only +/// have the values 100, 200, 400, and 700. +/// +/// In addition, a series of blacks and whites with common opacities are +/// available. For example, [black54] is a pure black with 54% opacity. +/// +/// {@tool snippet} +/// +/// To select a specific color from one of the swatches, index into the swatch +/// using an integer for the specific color desired, as follows: +/// +/// ```dart +/// Color selection = Colors.green[400]!; // Selects a mid-range green. +/// ``` +/// {@end-tool} +/// {@tool snippet} +/// +/// Each [ColorSwatch] constant is a color and can used directly. For example: +/// +/// ```dart +/// Container( +/// color: Colors.blue, // same as Colors.blue[500] or Colors.blue.shade500 +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Color palettes +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueGrey.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.brown.png) +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.grey.png) +/// +/// ## Blacks and whites +/// +/// These colors are identified by their transparency. The low transparency +/// levels (e.g. [Colors.white12] and [Colors.white10]) are very hard to see and +/// should be avoided in general. They are intended for very subtle effects. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) +/// +/// The [Colors.transparent] color isn't shown here because it is entirely +/// invisible! +/// +/// See also: +/// +/// * Cookbook: [Use themes to share colors and font styles](https://docs.flutter.dev/cookbook/design/themes) +abstract final class Colors { + /// Completely invisible. + static const Color transparent = Color(0x00000000); + + /// Completely opaque black. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// See also: + /// + /// * [black87], [black54], [black45], [black38], [black26], [black12], which + /// are variants on this color but with different opacities. + /// * [white], a solid white color. + /// * [transparent], a fully-transparent color. + static const Color black = Color(0xFF000000); + + /// Black with 87% opacity. + /// + /// This is a good contrasting color for text in light themes. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// See also: + /// + /// * [Typography.black], which uses this color for its text styles. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [black], [black54], [black45], [black38], [black26], [black12], which + /// are variants on this color but with different opacities. + static const Color black87 = Color(0xDD000000); + + /// Black with 54% opacity. + /// + /// This is a color commonly used for headings in light themes. It's also used + /// as the mask color behind dialogs. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// See also: + /// + /// * [Typography.black], which uses this color for its text styles. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [black], [black87], [black45], [black38], [black26], [black12], which + /// are variants on this color but with different opacities. + static const Color black54 = Color(0x8A000000); + + /// Black with 45% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// See also: + /// + /// * [black], [black87], [black54], [black38], [black26], [black12], which + /// are variants on this color but with different opacities. + static const Color black45 = Color(0x73000000); + + /// Black with 38% opacity. + /// + /// For light themes, i.e. when the Theme's [ThemeData.brightness] is + /// [Brightness.light], this color is used for disabled icons and for + /// placeholder text in [DataTable]. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// See also: + /// + /// * [black], [black87], [black54], [black45], [black26], [black12], which + /// are variants on this color but with different opacities. + static const Color black38 = Color(0x61000000); + + /// Black with 26% opacity. + /// + /// Used for disabled radio buttons and the text of disabled flat buttons in light themes. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// See also: + /// + /// * [ThemeData.disabledColor], which uses this color by default in light themes. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [black], [black87], [black54], [black45], [black38], [black12], which + /// are variants on this color but with different opacities. + static const Color black26 = Color(0x42000000); + + /// Black with 12% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blacks.png) + /// + /// Used for the background of disabled raised buttons in light themes. + /// + /// See also: + /// + /// * [black], [black87], [black54], [black45], [black38], [black26], which + /// are variants on this color but with different opacities. + static const Color black12 = Color(0x1F000000); + + /// Completely opaque white. + /// + /// This is a good contrasting color for the [ThemeData.primaryColor] in the + /// dark theme. See [ThemeData.brightness]. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [Typography.white], which uses this color for its text styles. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [white70], [white60], [white54], [white38], [white30], [white12], + /// [white10], which are variants on this color but with different + /// opacities. + /// * [black], a solid black color. + /// * [transparent], a fully-transparent color. + static const Color white = Color(0xFFFFFFFF); + + /// White with 70% opacity. + /// + /// This is a color commonly used for headings in dark themes. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [Typography.white], which uses this color for its text styles. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [white], [white60], [white54], [white38], [white30], [white12], + /// [white10], which are variants on this color but with different + /// opacities. + static const Color white70 = Color(0xB3FFFFFF); + + /// White with 60% opacity. + /// + /// Used for medium-emphasis text and hint text when [ThemeData.brightness] is + /// set to [Brightness.dark]. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [ExpandIcon], which uses this color for dark themes. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [white], [white54], [white30], [white38], [white12], [white10], which + /// are variants on this color but with different opacities. + static const Color white60 = Color(0x99FFFFFF); + + /// White with 54% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [white], [white60], [white38], [white30], [white12], [white10], which + /// are variants on this color but with different opacities. + static const Color white54 = Color(0x8AFFFFFF); + + /// White with 38% opacity. + /// + /// Used for disabled radio buttons and the text of disabled flat buttons in dark themes. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [ThemeData.disabledColor], which uses this color by default in dark themes. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [white], [white60], [white54], [white70], [white30], [white12], + /// [white10], which are variants on this color but with different + /// opacities. + static const Color white38 = Color(0x62FFFFFF); + + /// White with 30% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + /// * [white], [white60], [white54], [white70], [white38], [white12], + /// [white10], which are variants on this color but with different + /// opacities. + static const Color white30 = Color(0x4DFFFFFF); + + /// White with 24% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// Used for the splash color for filled buttons. + /// + /// See also: + /// + /// * [white], [white60], [white54], [white70], [white38], [white30], + /// [white10], which are variants on this color + /// but with different opacities. + static const Color white24 = Color(0x3DFFFFFF); + + /// White with 12% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// Used for the background of disabled raised buttons in dark themes. + /// + /// See also: + /// + /// * [white], [white60], [white54], [white70], [white38], [white30], + /// [white10], which are variants on this color but with different + /// opacities. + static const Color white12 = Color(0x1FFFFFFF); + + /// White with 10% opacity. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.whites.png) + /// + /// See also: + /// + /// * [white], [white60], [white54], [white70], [white38], [white30], + /// [white12], which are variants on this color + /// but with different opacities. + /// * [transparent], a fully-transparent color, not far from this one. + static const Color white10 = Color(0x1AFFFFFF); + + /// The red primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.red[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [redAccent], the corresponding accent colors. + /// * [deepOrange] and [pink], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor red = MaterialColor(_redPrimaryValue, <int, Color>{ + 50: Color(0xFFFFEBEE), + 100: Color(0xFFFFCDD2), + 200: Color(0xFFEF9A9A), + 300: Color(0xFFE57373), + 400: Color(0xFFEF5350), + 500: Color(_redPrimaryValue), + 600: Color(0xFFE53935), + 700: Color(0xFFD32F2F), + 800: Color(0xFFC62828), + 900: Color(0xFFB71C1C), + }); + static const int _redPrimaryValue = 0xFFF44336; + + /// The red accent swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.redAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [red], the corresponding primary colors. + /// * [deepOrangeAccent] and [pinkAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor redAccent = MaterialAccentColor(_redAccentValue, <int, Color>{ + 100: Color(0xFFFF8A80), + 200: Color(_redAccentValue), + 400: Color(0xFFFF1744), + 700: Color(0xFFD50000), + }); + static const int _redAccentValue = 0xFFFF5252; + + /// The pink primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.pink[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [pinkAccent], the corresponding accent colors. + /// * [red] and [purple], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor pink = MaterialColor(_pinkPrimaryValue, <int, Color>{ + 50: Color(0xFFFCE4EC), + 100: Color(0xFFF8BBD0), + 200: Color(0xFFF48FB1), + 300: Color(0xFFF06292), + 400: Color(0xFFEC407A), + 500: Color(_pinkPrimaryValue), + 600: Color(0xFFD81B60), + 700: Color(0xFFC2185B), + 800: Color(0xFFAD1457), + 900: Color(0xFF880E4F), + }); + static const int _pinkPrimaryValue = 0xFFE91E63; + + /// The pink accent color swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.pinkAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [pink], the corresponding primary colors. + /// * [redAccent] and [purpleAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor pinkAccent = + MaterialAccentColor(_pinkAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFFF80AB), + 200: Color(_pinkAccentPrimaryValue), + 400: Color(0xFFF50057), + 700: Color(0xFFC51162), + }); + static const int _pinkAccentPrimaryValue = 0xFFFF4081; + + /// The purple primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.purple[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [purpleAccent], the corresponding accent colors. + /// * [deepPurple] and [pink], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor purple = MaterialColor(_purplePrimaryValue, <int, Color>{ + 50: Color(0xFFF3E5F5), + 100: Color(0xFFE1BEE7), + 200: Color(0xFFCE93D8), + 300: Color(0xFFBA68C8), + 400: Color(0xFFAB47BC), + 500: Color(_purplePrimaryValue), + 600: Color(0xFF8E24AA), + 700: Color(0xFF7B1FA2), + 800: Color(0xFF6A1B9A), + 900: Color(0xFF4A148C), + }); + static const int _purplePrimaryValue = 0xFF9C27B0; + + /// The purple accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pink.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.pinkAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.purpleAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [purple], the corresponding primary colors. + /// * [deepPurpleAccent] and [pinkAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor purpleAccent = + MaterialAccentColor(_purpleAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFEA80FC), + 200: Color(_purpleAccentPrimaryValue), + 400: Color(0xFFD500F9), + 700: Color(0xFFAA00FF), + }); + static const int _purpleAccentPrimaryValue = 0xFFE040FB; + + /// The deep purple primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.deepPurple[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [deepPurpleAccent], the corresponding accent colors. + /// * [purple] and [indigo], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor deepPurple = MaterialColor(_deepPurplePrimaryValue, <int, Color>{ + 50: Color(0xFFEDE7F6), + 100: Color(0xFFD1C4E9), + 200: Color(0xFFB39DDB), + 300: Color(0xFF9575CD), + 400: Color(0xFF7E57C2), + 500: Color(_deepPurplePrimaryValue), + 600: Color(0xFF5E35B1), + 700: Color(0xFF512DA8), + 800: Color(0xFF4527A0), + 900: Color(0xFF311B92), + }); + static const int _deepPurplePrimaryValue = 0xFF673AB7; + + /// The deep purple accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.purpleAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.deepPurpleAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [deepPurple], the corresponding primary colors. + /// * [purpleAccent] and [indigoAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor deepPurpleAccent = + MaterialAccentColor(_deepPurpleAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFB388FF), + 200: Color(_deepPurpleAccentPrimaryValue), + 400: Color(0xFF651FFF), + 700: Color(0xFF6200EA), + }); + static const int _deepPurpleAccentPrimaryValue = 0xFF7C4DFF; + + /// The indigo primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.indigo[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [indigoAccent], the corresponding accent colors. + /// * [blue] and [deepPurple], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor indigo = MaterialColor(_indigoPrimaryValue, <int, Color>{ + 50: Color(0xFFE8EAF6), + 100: Color(0xFFC5CAE9), + 200: Color(0xFF9FA8DA), + 300: Color(0xFF7986CB), + 400: Color(0xFF5C6BC0), + 500: Color(_indigoPrimaryValue), + 600: Color(0xFF3949AB), + 700: Color(0xFF303F9F), + 800: Color(0xFF283593), + 900: Color(0xFF1A237E), + }); + static const int _indigoPrimaryValue = 0xFF3F51B5; + + /// The indigo accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurple.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepPurpleAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.indigoAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [indigo], the corresponding primary colors. + /// * [blueAccent] and [deepPurpleAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor indigoAccent = + MaterialAccentColor(_indigoAccentPrimaryValue, <int, Color>{ + 100: Color(0xFF8C9EFF), + 200: Color(_indigoAccentPrimaryValue), + 400: Color(0xFF3D5AFE), + 700: Color(0xFF304FFE), + }); + static const int _indigoAccentPrimaryValue = 0xFF536DFE; + + /// The blue primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueGrey.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.blue[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [blueAccent], the corresponding accent colors. + /// * [indigo], [lightBlue], and [blueGrey], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor blue = MaterialColor(_bluePrimaryValue, <int, Color>{ + 50: Color(0xFFE3F2FD), + 100: Color(0xFFBBDEFB), + 200: Color(0xFF90CAF9), + 300: Color(0xFF64B5F6), + 400: Color(0xFF42A5F5), + 500: Color(_bluePrimaryValue), + 600: Color(0xFF1E88E5), + 700: Color(0xFF1976D2), + 800: Color(0xFF1565C0), + 900: Color(0xFF0D47A1), + }); + static const int _bluePrimaryValue = 0xFF2196F3; + + /// The blue accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigo.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.indigoAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.blueAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [blue], the corresponding primary colors. + /// * [indigoAccent] and [lightBlueAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor blueAccent = + MaterialAccentColor(_blueAccentPrimaryValue, <int, Color>{ + 100: Color(0xFF82B1FF), + 200: Color(_blueAccentPrimaryValue), + 400: Color(0xFF2979FF), + 700: Color(0xFF2962FF), + }); + static const int _blueAccentPrimaryValue = 0xFF448AFF; + + /// The light blue primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.lightBlue[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [lightBlueAccent], the corresponding accent colors. + /// * [blue] and [cyan], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor lightBlue = MaterialColor(_lightBluePrimaryValue, <int, Color>{ + 50: Color(0xFFE1F5FE), + 100: Color(0xFFB3E5FC), + 200: Color(0xFF81D4FA), + 300: Color(0xFF4FC3F7), + 400: Color(0xFF29B6F6), + 500: Color(_lightBluePrimaryValue), + 600: Color(0xFF039BE5), + 700: Color(0xFF0288D1), + 800: Color(0xFF0277BD), + 900: Color(0xFF01579B), + }); + static const int _lightBluePrimaryValue = 0xFF03A9F4; + + /// The light blue accent swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.lightBlueAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [lightBlue], the corresponding primary colors. + /// * [blueAccent] and [cyanAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor lightBlueAccent = + MaterialAccentColor(_lightBlueAccentPrimaryValue, <int, Color>{ + 100: Color(0xFF80D8FF), + 200: Color(_lightBlueAccentPrimaryValue), + 400: Color(0xFF00B0FF), + 700: Color(0xFF0091EA), + }); + static const int _lightBlueAccentPrimaryValue = 0xFF40C4FF; + + /// The cyan primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueGrey.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.cyan[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [cyanAccent], the corresponding accent colors. + /// * [lightBlue], [teal], and [blueGrey], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor cyan = MaterialColor(_cyanPrimaryValue, <int, Color>{ + 50: Color(0xFFE0F7FA), + 100: Color(0xFFB2EBF2), + 200: Color(0xFF80DEEA), + 300: Color(0xFF4DD0E1), + 400: Color(0xFF26C6DA), + 500: Color(_cyanPrimaryValue), + 600: Color(0xFF00ACC1), + 700: Color(0xFF0097A7), + 800: Color(0xFF00838F), + 900: Color(0xFF006064), + }); + static const int _cyanPrimaryValue = 0xFF00BCD4; + + /// The cyan accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlue.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightBlueAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.cyanAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [cyan], the corresponding primary colors. + /// * [lightBlueAccent] and [tealAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor cyanAccent = + MaterialAccentColor(_cyanAccentPrimaryValue, <int, Color>{ + 100: Color(0xFF84FFFF), + 200: Color(_cyanAccentPrimaryValue), + 400: Color(0xFF00E5FF), + 700: Color(0xFF00B8D4), + }); + static const int _cyanAccentPrimaryValue = 0xFF18FFFF; + + /// The teal primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.teal[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [tealAccent], the corresponding accent colors. + /// * [green] and [cyan], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor teal = MaterialColor(_tealPrimaryValue, <int, Color>{ + 50: Color(0xFFE0F2F1), + 100: Color(0xFFB2DFDB), + 200: Color(0xFF80CBC4), + 300: Color(0xFF4DB6AC), + 400: Color(0xFF26A69A), + 500: Color(_tealPrimaryValue), + 600: Color(0xFF00897B), + 700: Color(0xFF00796B), + 800: Color(0xFF00695C), + 900: Color(0xFF004D40), + }); + static const int _tealPrimaryValue = 0xFF009688; + + /// The teal accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyanAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.tealAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [teal], the corresponding primary colors. + /// * [greenAccent] and [cyanAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor tealAccent = + MaterialAccentColor(_tealAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFA7FFEB), + 200: Color(_tealAccentPrimaryValue), + 400: Color(0xFF1DE9B6), + 700: Color(0xFF00BFA5), + }); + static const int _tealAccentPrimaryValue = 0xFF64FFDA; + + /// The green primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.green[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [greenAccent], the corresponding accent colors. + /// * [teal], [lightGreen], and [lime], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor green = MaterialColor(_greenPrimaryValue, <int, Color>{ + 50: Color(0xFFE8F5E9), + 100: Color(0xFFC8E6C9), + 200: Color(0xFFA5D6A7), + 300: Color(0xFF81C784), + 400: Color(0xFF66BB6A), + 500: Color(_greenPrimaryValue), + 600: Color(0xFF43A047), + 700: Color(0xFF388E3C), + 800: Color(0xFF2E7D32), + 900: Color(0xFF1B5E20), + }); + static const int _greenPrimaryValue = 0xFF4CAF50; + + /// The green accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.teal.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.tealAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.greenAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [green], the corresponding primary colors. + /// * [tealAccent], [lightGreenAccent], and [limeAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor greenAccent = + MaterialAccentColor(_greenAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFB9F6CA), + 200: Color(_greenAccentPrimaryValue), + 400: Color(0xFF00E676), + 700: Color(0xFF00C853), + }); + static const int _greenAccentPrimaryValue = 0xFF69F0AE; + + /// The light green primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.lightGreen[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [lightGreenAccent], the corresponding accent colors. + /// * [green] and [lime], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor lightGreen = MaterialColor(_lightGreenPrimaryValue, <int, Color>{ + 50: Color(0xFFF1F8E9), + 100: Color(0xFFDCEDC8), + 200: Color(0xFFC5E1A5), + 300: Color(0xFFAED581), + 400: Color(0xFF9CCC65), + 500: Color(_lightGreenPrimaryValue), + 600: Color(0xFF7CB342), + 700: Color(0xFF689F38), + 800: Color(0xFF558B2F), + 900: Color(0xFF33691E), + }); + static const int _lightGreenPrimaryValue = 0xFF8BC34A; + + /// The light green accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.green.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.greenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.lightGreenAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [lightGreen], the corresponding primary colors. + /// * [greenAccent] and [limeAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor lightGreenAccent = + MaterialAccentColor(_lightGreenAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFCCFF90), + 200: Color(_lightGreenAccentPrimaryValue), + 400: Color(0xFF76FF03), + 700: Color(0xFF64DD17), + }); + static const int _lightGreenAccentPrimaryValue = 0xFFB2FF59; + + /// The lime primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.lime[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [limeAccent], the corresponding accent colors. + /// * [lightGreen] and [yellow], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor lime = MaterialColor(_limePrimaryValue, <int, Color>{ + 50: Color(0xFFF9FBE7), + 100: Color(0xFFF0F4C3), + 200: Color(0xFFE6EE9C), + 300: Color(0xFFDCE775), + 400: Color(0xFFD4E157), + 500: Color(_limePrimaryValue), + 600: Color(0xFFC0CA33), + 700: Color(0xFFAFB42B), + 800: Color(0xFF9E9D24), + 900: Color(0xFF827717), + }); + static const int _limePrimaryValue = 0xFFCDDC39; + + /// The lime accent primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreen.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lightGreenAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.limeAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [lime], the corresponding primary colors. + /// * [lightGreenAccent] and [yellowAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor limeAccent = + MaterialAccentColor(_limeAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFF4FF81), + 200: Color(_limeAccentPrimaryValue), + 400: Color(0xFFC6FF00), + 700: Color(0xFFAEEA00), + }); + static const int _limeAccentPrimaryValue = 0xFFEEFF41; + + /// The yellow primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.yellow[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [yellowAccent], the corresponding accent colors. + /// * [lime] and [amber], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor yellow = MaterialColor(_yellowPrimaryValue, <int, Color>{ + 50: Color(0xFFFFFDE7), + 100: Color(0xFFFFF9C4), + 200: Color(0xFFFFF59D), + 300: Color(0xFFFFF176), + 400: Color(0xFFFFEE58), + 500: Color(_yellowPrimaryValue), + 600: Color(0xFFFDD835), + 700: Color(0xFFFBC02D), + 800: Color(0xFFF9A825), + 900: Color(0xFFF57F17), + }); + static const int _yellowPrimaryValue = 0xFFFFEB3B; + + /// The yellow accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.lime.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.limeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.yellowAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [yellow], the corresponding primary colors. + /// * [limeAccent] and [amberAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor yellowAccent = + MaterialAccentColor(_yellowAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFFFFF8D), + 200: Color(_yellowAccentPrimaryValue), + 400: Color(0xFFFFEA00), + 700: Color(0xFFFFD600), + }); + static const int _yellowAccentPrimaryValue = 0xFFFFFF00; + + /// The amber primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.amber[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [amberAccent], the corresponding accent colors. + /// * [yellow] and [orange], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor amber = MaterialColor(_amberPrimaryValue, <int, Color>{ + 50: Color(0xFFFFF8E1), + 100: Color(0xFFFFECB3), + 200: Color(0xFFFFE082), + 300: Color(0xFFFFD54F), + 400: Color(0xFFFFCA28), + 500: Color(_amberPrimaryValue), + 600: Color(0xFFFFB300), + 700: Color(0xFFFFA000), + 800: Color(0xFFFF8F00), + 900: Color(0xFFFF6F00), + }); + static const int _amberPrimaryValue = 0xFFFFC107; + + /// The amber accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellow.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.yellowAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.amberAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [amber], the corresponding primary colors. + /// * [yellowAccent] and [orangeAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor amberAccent = + MaterialAccentColor(_amberAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFFFE57F), + 200: Color(_amberAccentPrimaryValue), + 400: Color(0xFFFFC400), + 700: Color(0xFFFFAB00), + }); + static const int _amberAccentPrimaryValue = 0xFFFFD740; + + /// The orange primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.brown.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.orange[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [orangeAccent], the corresponding accent colors. + /// * [amber], [deepOrange], and [brown], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor orange = MaterialColor(_orangePrimaryValue, <int, Color>{ + 50: Color(0xFFFFF3E0), + 100: Color(0xFFFFE0B2), + 200: Color(0xFFFFCC80), + 300: Color(0xFFFFB74D), + 400: Color(0xFFFFA726), + 500: Color(_orangePrimaryValue), + 600: Color(0xFFFB8C00), + 700: Color(0xFFF57C00), + 800: Color(0xFFEF6C00), + 900: Color(0xFFE65100), + }); + static const int _orangePrimaryValue = 0xFFFF9800; + + /// The orange accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amber.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.amberAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.orangeAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [orange], the corresponding primary colors. + /// * [amberAccent] and [deepOrangeAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor orangeAccent = + MaterialAccentColor(_orangeAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFFFD180), + 200: Color(_orangeAccentPrimaryValue), + 400: Color(0xFFFF9100), + 700: Color(0xFFFF6D00), + }); + static const int _orangeAccentPrimaryValue = 0xFFFFAB40; + + /// The deep orange primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.brown.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.deepOrange[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [deepOrangeAccent], the corresponding accent colors. + /// * [orange], [red], and [brown], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor deepOrange = MaterialColor(_deepOrangePrimaryValue, <int, Color>{ + 50: Color(0xFFFBE9E7), + 100: Color(0xFFFFCCBC), + 200: Color(0xFFFFAB91), + 300: Color(0xFFFF8A65), + 400: Color(0xFFFF7043), + 500: Color(_deepOrangePrimaryValue), + 600: Color(0xFFF4511E), + 700: Color(0xFFE64A19), + 800: Color(0xFFD84315), + 900: Color(0xFFBF360C), + }); + static const int _deepOrangePrimaryValue = 0xFFFF5722; + + /// The deep orange accent color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.deepOrangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orangeAccent.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.red.png) + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.redAccent.png) + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.deepOrangeAccent[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [deepOrange], the corresponding primary colors. + /// * [orangeAccent] [redAccent], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialAccentColor deepOrangeAccent = + MaterialAccentColor(_deepOrangeAccentPrimaryValue, <int, Color>{ + 100: Color(0xFFFF9E80), + 200: Color(_deepOrangeAccentPrimaryValue), + 400: Color(0xFFFF3D00), + 700: Color(0xFFDD2C00), + }); + static const int _deepOrangeAccentPrimaryValue = 0xFFFF6E40; + + /// The brown primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.brown.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.orange.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueGrey.png) + /// + /// This swatch has no corresponding accent color and swatch. + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.brown[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [orange] and [blueGrey], vaguely similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor brown = MaterialColor(_brownPrimaryValue, <int, Color>{ + 50: Color(0xFFEFEBE9), + 100: Color(0xFFD7CCC8), + 200: Color(0xFFBCAAA4), + 300: Color(0xFFA1887F), + 400: Color(0xFF8D6E63), + 500: Color(_brownPrimaryValue), + 600: Color(0xFF6D4C41), + 700: Color(0xFF5D4037), + 800: Color(0xFF4E342E), + 900: Color(0xFF3E2723), + }); + static const int _brownPrimaryValue = 0xFF795548; + + /// The grey primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.grey.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueGrey.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.brown.png) + /// + /// This swatch has no corresponding accent swatch. + /// + /// This swatch, in addition to the values 50 and 100 to 900 in 100 + /// increments, also features the special values 350 and 850. The 350 value is + /// used for raised button while pressed in light themes, and 850 is used for + /// the background color of the dark theme. See [ThemeData.brightness]. + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.grey[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [blueGrey] and [brown], somewhat similar colors. + /// * [black], [black87], [black54], [black45], [black38], [black26], [black12], which + /// provide a different approach to showing shades of grey. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor grey = MaterialColor(_greyPrimaryValue, <int, Color>{ + 50: Color(0xFFFAFAFA), + 100: Color(0xFFF5F5F5), + 200: Color(0xFFEEEEEE), + 300: Color(0xFFE0E0E0), + 350: Color(0xFFD6D6D6), // only for raised button while pressed in light theme + 400: Color(0xFFBDBDBD), + 500: Color(_greyPrimaryValue), + 600: Color(0xFF757575), + 700: Color(0xFF616161), + 800: Color(0xFF424242), + 850: Color(0xFF303030), // only for background color in dark theme + 900: Color(0xFF212121), + }); + static const int _greyPrimaryValue = 0xFF9E9E9E; + + /// The blue-grey primary color and swatch. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blueGrey.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.grey.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.cyan.png) + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/Colors.blue.png) + /// + /// This swatch has no corresponding accent swatch. + /// + /// {@tool snippet} + /// + /// ```dart + /// Icon( + /// Icons.widgets, + /// color: Colors.blueGrey[400], + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [grey], [cyan], and [blue], similar colors. + /// * [Theme.of], which allows you to select colors from the current theme + /// rather than hard-coding colors in your build methods. + static const MaterialColor blueGrey = MaterialColor(_blueGreyPrimaryValue, <int, Color>{ + 50: Color(0xFFECEFF1), + 100: Color(0xFFCFD8DC), + 200: Color(0xFFB0BEC5), + 300: Color(0xFF90A4AE), + 400: Color(0xFF78909C), + 500: Color(_blueGreyPrimaryValue), + 600: Color(0xFF546E7A), + 700: Color(0xFF455A64), + 800: Color(0xFF37474F), + 900: Color(0xFF263238), + }); + static const int _blueGreyPrimaryValue = 0xFF607D8B; + + /// The Material Design primary color swatches, excluding grey. + static const List<MaterialColor> primaries = <MaterialColor>[ + red, + pink, + purple, + deepPurple, + indigo, + blue, + lightBlue, + cyan, + teal, + green, + lightGreen, + lime, + yellow, + amber, + orange, + deepOrange, + brown, + // The grey swatch is intentionally omitted because when picking a color + // randomly from this list to colorize an application, picking grey suddenly + // makes the app look disabled. + blueGrey, + ]; + + /// The Material Design accent color swatches. + static const List<MaterialAccentColor> accents = <MaterialAccentColor>[ + redAccent, + pinkAccent, + purpleAccent, + deepPurpleAccent, + indigoAccent, + blueAccent, + lightBlueAccent, + cyanAccent, + tealAccent, + greenAccent, + lightGreenAccent, + limeAccent, + yellowAccent, + amberAccent, + orangeAccent, + deepOrangeAccent, + ]; +} diff --git a/packages/material_ui/lib/src/constants.dart b/packages/material_ui/lib/src/constants.dart new file mode 100644 index 000000000000..0c974b914633 --- /dev/null +++ b/packages/material_ui/lib/src/constants.dart @@ -0,0 +1,69 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'app_bar.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'tabs.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'package:flutter/painting.dart'; + +/// The minimum dimension of any interactive region according to Material +/// guidelines. +/// +/// This is used to avoid small regions that are hard for the user to interact +/// with. It applies to both dimensions of a region, so a square of size +/// kMinInteractiveDimension x kMinInteractiveDimension is the smallest +/// acceptable region that should respond to gestures. +/// +/// See also: +/// +/// * [kMinInteractiveDimensionCupertino] +/// * The Material spec on touch targets at <https://material.io/design/usability/accessibility.html#layout-typography>. +const double kMinInteractiveDimension = 48.0; + +/// The height of the toolbar component of the [AppBar]. +const double kToolbarHeight = 56.0; + +/// The height of the bottom navigation bar. +const double kBottomNavigationBarHeight = 56.0; + +/// The height of a tab bar containing text. +const double kTextTabBarHeight = kMinInteractiveDimension; + +/// The amount of time theme change animations should last. +const Duration kThemeChangeDuration = Duration(milliseconds: 200); + +/// The default radius of a circular material ink response in logical pixels. +const double kRadialReactionRadius = 20.0; + +/// The amount of time a circular material ink response should take to expand to its full size. +const Duration kRadialReactionDuration = Duration(milliseconds: 100); + +/// The value of the alpha channel to use when drawing a circular material ink response. +const int kRadialReactionAlpha = 0x1F; + +/// The duration of the horizontal scroll animation that occurs when a tab is tapped. +const Duration kTabScrollDuration = Duration(milliseconds: 300); + +/// The horizontal padding included by [Tab]s. +const EdgeInsets kTabLabelPadding = EdgeInsets.symmetric(horizontal: 16.0); + +/// The padding added around material list items. +const EdgeInsets kMaterialListPadding = EdgeInsets.symmetric(vertical: 8.0); + +/// The default color for [ThemeData.iconTheme] when [ThemeData.brightness] is +/// [Brightness.dark]. This color is used in [IconButton] to detect whether +/// [IconTheme.of(context).color] is the same as the default color of [ThemeData.iconTheme]. +// ignore: prefer_const_constructors +final Color kDefaultIconLightColor = Color(0xFFFFFFFF); + +/// The default color for [ThemeData.iconTheme] when [ThemeData.brightness] is +/// [Brightness.light]. This color is used in [IconButton] to detect whether +/// [IconTheme.of(context).color] is the same as the default color of [ThemeData.iconTheme]. +// ignore: prefer_const_constructors +final Color kDefaultIconDarkColor = Color(0xDD000000); diff --git a/packages/material_ui/lib/src/curves.dart b/packages/material_ui/lib/src/curves.dart new file mode 100644 index 000000000000..76d92c5aaf73 --- /dev/null +++ b/packages/material_ui/lib/src/curves.dart @@ -0,0 +1,51 @@ +// 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/animation.dart'; + +// The easing curves of the Material Library + +/// The standard easing curve in the Material 2 specification. +/// +/// Elements that begin and end at rest use standard easing. +/// They speed up quickly and slow down gradually, in order +/// to emphasize the end of the transition. +/// +/// See also: +/// * <https://material.io/design/motion/speed.html#easing> +@Deprecated( + 'Use Easing.legacy (M2) or Easing.standard (M3) instead. ' + 'This curve is updated in M3. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', +) +const Curve standardEasing = Curves.fastOutSlowIn; + +/// The accelerate easing curve in the Material 2 specification. +/// +/// Elements exiting a screen use acceleration easing, +/// where they start at rest and end at peak velocity. +/// +/// See also: +/// * <https://material.io/design/motion/speed.html#easing> +@Deprecated( + 'Use Easing.legacyAccelerate (M2) or Easing.standardAccelerate (M3) instead. ' + 'This curve is updated in M3. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', +) +const Curve accelerateEasing = Cubic(0.4, 0.0, 1.0, 1.0); + +/// The decelerate easing curve in the Material 2 specification. +/// +/// Incoming elements are animated using deceleration easing, +/// which starts a transition at peak velocity (the fastest +/// point of an element’s movement) and ends at rest. +/// +/// See also: +/// * <https://material.io/design/motion/speed.html#easing> +@Deprecated( + 'Use Easing.legacyDecelerate (M2) or Easing.standardDecelerate (M3) instead. ' + 'This curve is updated in M3. ' + 'This feature was deprecated after v3.18.0-0.1.pre.', +) +const Curve decelerateEasing = Cubic(0.0, 0.0, 0.2, 1.0); diff --git a/packages/material_ui/lib/src/data_table.dart b/packages/material_ui/lib/src/data_table.dart new file mode 100644 index 000000000000..69236bf6c132 --- /dev/null +++ b/packages/material_ui/lib/src/data_table.dart @@ -0,0 +1,1459 @@ +// 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. + +/// @docImport 'paginated_data_table.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'checkbox.dart'; +import 'constants.dart'; +import 'data_table_theme.dart'; +import 'debug.dart'; +import 'divider.dart'; +import 'dropdown.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +// Examples can assume: +// late BuildContext context; +// late List<DataColumn> _columns; +// late List<DataRow> _rows; + +/// Signature for [DataColumn.onSort] callback. +typedef DataColumnSortCallback = void Function(int columnIndex, bool ascending); + +/// Column configuration for a [DataTable]. +/// +/// One column configuration must be provided for each column to +/// display in the table. The list of [DataColumn] objects is passed +/// as the `columns` argument to the [DataTable.new] constructor. +@immutable +class DataColumn { + /// Creates the configuration for a column of a [DataTable]. + const DataColumn({ + required this.label, + this.columnWidth, + this.tooltip, + this.numeric = false, + this.onSort, + this.mouseCursor, + this.headingRowAlignment, + }); + + /// The column heading. + /// + /// Typically, this will be a [Text] widget. It could also be an + /// [Icon] (typically using size 18), or a [Row] with an icon and + /// some text. + /// + /// The [label] is placed within a [Row] along with the + /// sort indicator (if applicable). By default, [label] only occupy minimal + /// space. It is recommended to place the label content in an [Expanded] or + /// [Flexible] as [label] to control how the content flexes. Otherwise, + /// an exception will occur when the available space is insufficient. + /// + /// By default, [DefaultTextStyle.softWrap] of this subtree will be set to false. + /// Use [DefaultTextStyle.merge] to override it if needed. + /// + /// The label should not include the sort indicator. + final Widget label; + + /// How the horizontal extents of this column of the table should be determined. + /// + /// The [FixedColumnWidth] class can be used to specify a specific width in + /// pixels. This is the cheapest way to size a table's columns. + /// + /// The layout performance of the table depends critically on which column + /// sizing algorithms are used here. In particular, [IntrinsicColumnWidth] is + /// quite expensive because it needs to measure each cell in the column to + /// determine the intrinsic size of the column. + /// + /// If this property is `null`, the table applies a default behavior: + /// - If the table has exactly one column identified as the only text column + /// (i.e., all the rest are numeric), that column uses `IntrinsicColumnWidth(flex: 1.0)`. + /// - All other columns use `IntrinsicColumnWidth()`. + final TableColumnWidth? columnWidth; + + /// The column heading's tooltip. + /// + /// This is a longer description of the column heading, for cases + /// where the heading might have been abbreviated to keep the column + /// width to a reasonable size. + final String? tooltip; + + /// Whether this column represents numeric data or not. + /// + /// The contents of cells of columns containing numeric data are + /// right-aligned. + final bool numeric; + + /// Called when the user asks to sort the table using this column. + /// + /// If non-null, space is reserved in the column header for the sort + /// indicator (the arrow icon), even when this column is not currently + /// the active sort column and no arrow is painted. This can affect + /// the layout and width of the column. + /// + /// If null, the column will not be considered sortable. + /// + /// See [DataTable.sortColumnIndex] and [DataTable.sortAscending]. + final DataColumnSortCallback? onSort; + + bool get _debugInteractive => onSort != null; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// heading row. + /// + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.disabled]. + /// + /// If this is null, then the value of [DataTableThemeData.headingCellCursor] + /// is used. If that's null, then [WidgetStateMouseCursor.clickable] is used. + /// + /// See also: + /// * [WidgetStateMouseCursor], which can be used to create a [MouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Defines the horizontal layout of the [label] and sort indicator in the + /// heading row. + /// + /// If [headingRowAlignment] value is [MainAxisAlignment.center] and [onSort] is + /// not null, then a [SizedBox] with a width of sort arrow icon size and sort + /// arrow padding will be placed before the [label] to ensure the label is + /// centered in the column. + /// + /// If null, then defaults to [MainAxisAlignment.start]. + final MainAxisAlignment? headingRowAlignment; +} + +/// Row configuration and cell data for a [DataTable]. +/// +/// One row configuration must be provided for each row to +/// display in the table. The list of [DataRow] objects is passed +/// as the `rows` argument to the [DataTable.new] constructor. +/// +/// The data for this row of the table is provided in the [cells] +/// property of the [DataRow] object. +@immutable +class DataRow { + /// Creates the configuration for a row of a [DataTable]. + const DataRow({ + this.key, + this.selected = false, + this.onSelectChanged, + this.onLongPress, + this.onHover, + this.color, + this.mouseCursor, + required this.cells, + }); + + /// Creates the configuration for a row of a [DataTable], deriving + /// the key from a row index. + DataRow.byIndex({ + int? index, + this.selected = false, + this.onSelectChanged, + this.onLongPress, + this.onHover, + this.color, + this.mouseCursor, + required this.cells, + }) : key = ValueKey<int?>(index); + + /// A [Key] that uniquely identifies this row. This is used to + /// ensure that if a row is added or removed, any stateful widgets + /// related to this row (e.g. an in-progress checkbox animation) + /// remain on the right row visually. + /// + /// If the table never changes once created, no key is necessary. + final LocalKey? key; + + /// Called when the user selects or unselects a selectable row. + /// + /// If this is not null, then the row is selectable. The current + /// selection state of the row is given by [selected]. + /// + /// If any row is selectable, then the table's heading row will have + /// a checkbox that can be checked to select all selectable rows + /// (and which is checked if all the rows are selected), and each + /// subsequent row will have a checkbox to toggle just that row. + /// + /// A row whose [onSelectChanged] callback is null is ignored for + /// the purposes of determining the state of the "all" checkbox, + /// and its checkbox is disabled. + /// + /// If a [DataCell] in the row has its [DataCell.onTap] callback defined, + /// that callback behavior overrides the gesture behavior of the row for + /// that particular cell. + final ValueChanged<bool?>? onSelectChanged; + + /// Called if the row is long-pressed. + /// + /// If a [DataCell] in the row has its [DataCell.onTap], [DataCell.onDoubleTap], + /// [DataCell.onLongPress], [DataCell.onTapCancel] or [DataCell.onTapDown] callback defined, + /// that callback behavior overrides the gesture behavior of the row for + /// that particular cell. + final GestureLongPressCallback? onLongPress; + + /// Called when a pointer enters or exits the row. + /// + /// The boolean value passed to the callback is true if a pointer has entered the row and false + /// when a pointer has exited the row. + final ValueChanged<bool>? onHover; + + /// Whether the row is selected. + /// + /// If [onSelectChanged] is non-null for any row in the table, then + /// a checkbox is shown at the start of each row. If the row is + /// selected (true), the checkbox will be checked and the row will + /// be highlighted. + /// + /// Otherwise, the checkbox, if present, will not be checked. + final bool selected; + + /// The data for this row. + /// + /// There must be exactly as many cells as there are columns in the + /// table. + final List<DataCell> cells; + + /// The color for the row. + /// + /// By default, the color is transparent unless selected. Selected rows has + /// a grey translucent color. + /// + /// The effective color can depend on the [WidgetState] state, if the + /// row is selected, pressed, hovered, focused, disabled or enabled. The + /// color is painted as an overlay to the row. To make sure that the row's + /// [InkWell] is visible (when pressed, hovered and focused), it is + /// recommended to use a translucent color. + /// + /// If [onSelectChanged] or [onLongPress] is null, the row's [InkWell] will be disabled. + /// + /// ```dart + /// DataRow( + /// color: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.selected)) { + /// return Theme.of(context).colorScheme.primary.withValues(alpha: 0.08); + /// } + /// return null; // Use the default value. + /// }), + /// cells: const <DataCell>[ + /// // ... + /// ], + /// ) + /// ``` + /// + /// See also: + /// + /// * The Material Design specification for overlay colors and how they + /// match a component's state: + /// <https://material.io/design/interaction/states.html#anatomy>. + final WidgetStateProperty<Color?>? color; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// data row. + /// + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// + /// If this is null, then the value of [DataTableThemeData.dataRowCursor] + /// is used. If that's null, then [WidgetStateMouseCursor.clickable] is used. + /// + /// See also: + /// * [WidgetStateMouseCursor], which can be used to create a [MouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + bool get _debugInteractive => + onSelectChanged != null || cells.any((DataCell cell) => cell._debugInteractive); +} + +/// The data for a cell of a [DataTable]. +/// +/// One list of [DataCell] objects must be provided for each [DataRow] +/// in the [DataTable], in the new [DataRow] constructor's `cells` +/// argument. +@immutable +class DataCell { + /// Creates an object to hold the data for a cell in a [DataTable]. + /// + /// The first argument is the widget to show for the cell, typically + /// a [Text] or [DropdownButton] widget. + /// + /// If the cell has no data, then a [Text] widget with placeholder + /// text should be provided instead, and then the [placeholder] + /// argument should be set to true. + const DataCell( + this.child, { + this.placeholder = false, + this.showEditIcon = false, + this.onTap, + this.onLongPress, + this.onTapDown, + this.onDoubleTap, + this.onTapCancel, + }); + + /// A cell that has no content and has zero width and height. + static const DataCell empty = DataCell(SizedBox.shrink()); + + /// The data for the row. + /// + /// Typically a [Text] widget or a [DropdownButton] widget. + /// + /// If the cell has no data, then a [Text] widget with placeholder + /// text should be provided instead, and [placeholder] should be set + /// to true. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// Whether the [child] is actually a placeholder. + /// + /// If this is true, the default text style for the cell is changed + /// to be appropriate for placeholder text. + final bool placeholder; + + /// Whether to show an edit icon at the end of the cell. + /// + /// This does not make the cell actually editable; the caller must + /// implement editing behavior if desired (initiated from the + /// [onTap] callback). + /// + /// If this is set, [onTap] should also be set, otherwise tapping + /// the icon will have no effect. + final bool showEditIcon; + + /// Called if the cell is tapped. + /// + /// If non-null, tapping the cell will call this callback. If + /// null (including [onDoubleTap], [onLongPress], [onTapCancel] and [onTapDown]), + /// tapping the cell will attempt to select the row (if + /// [DataRow.onSelectChanged] is provided). + final GestureTapCallback? onTap; + + /// Called when the cell is double tapped. + /// + /// If non-null, tapping the cell will call this callback. If + /// null (including [onTap], [onLongPress], [onTapCancel] and [onTapDown]), + /// tapping the cell will attempt to select the row (if + /// [DataRow.onSelectChanged] is provided). + final GestureTapCallback? onDoubleTap; + + /// Called if the cell is long-pressed. + /// + /// If non-null, tapping the cell will invoke this callback. If + /// null (including [onDoubleTap], [onTap], [onTapCancel] and [onTapDown]), + /// tapping the cell will attempt to select the row (if + /// [DataRow.onSelectChanged] is provided). + final GestureLongPressCallback? onLongPress; + + /// Called if the cell is tapped down. + /// + /// If non-null, tapping the cell will call this callback. If + /// null (including [onTap] [onDoubleTap], [onLongPress] and [onTapCancel]), + /// tapping the cell will attempt to select the row (if + /// [DataRow.onSelectChanged] is provided). + final GestureTapDownCallback? onTapDown; + + /// Called if the user cancels a tap was started on cell. + /// + /// If non-null, canceling the tap gesture will invoke this callback. + /// If null (including [onTap], [onDoubleTap] and [onLongPress]), + /// tapping the cell will attempt to select the + /// row (if [DataRow.onSelectChanged] is provided). + final GestureTapCancelCallback? onTapCancel; + + bool get _debugInteractive => + onTap != null || + onDoubleTap != null || + onLongPress != null || + onTapDown != null || + onTapCancel != null; +} + +/// A data table that follows the +/// [Material 2](https://material.io/go/design-data-tables) +/// design specification. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ktTajqbhIcY} +/// +/// ## Performance considerations +/// +/// Columns are sized automatically based on the table's contents. +/// It's expensive to display large amounts of data with this widget, +/// since it must be measured twice: once to negotiate each column's +/// dimensions, and again when the table is laid out. +/// +/// A [SingleChildScrollView] mounts and paints the entire child, even +/// when only some of it is visible. For a table that effectively handles +/// large amounts of data, here are some other options to consider: +/// +/// * `TableView`, a widget from the +/// [two_dimensional_scrollables](https://pub.dev/packages/two_dimensional_scrollables) +/// package. +/// * [PaginatedDataTable], which automatically splits the data into +/// multiple pages. +/// * [CustomScrollView], for greater control over scrolling effects. +/// +/// {@tool dartpad} +/// This sample shows how to display a [DataTable] with three columns: name, age, and +/// role. The columns are defined by three [DataColumn] objects. The table +/// contains three rows of data for three example users, the data for which +/// is defined by three [DataRow] objects. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/data_table.png) +/// +/// ** See code in examples/api/lib/material/data_table/data_table.0.dart ** +/// {@end-tool} +/// +/// +/// {@tool dartpad} +/// This sample shows how to display a [DataTable] with alternate colors per +/// row, and a custom color for when the row is selected. +/// +/// ** See code in examples/api/lib/material/data_table/data_table.1.dart ** +/// {@end-tool} +/// +/// [DataTable] can be sorted on the basis of any column in [columns] in +/// ascending or descending order. If [sortColumnIndex] is non-null, then the +/// table will be sorted by the values in the specified column. The boolean +/// [sortAscending] flag controls the sort order. +/// +/// See also: +/// +/// * [DataColumn], which describes a column in the data table. +/// * [DataRow], which contains the data for a row in the data table. +/// * [DataCell], which contains the data for a single cell in the data table. +/// * [PaginatedDataTable], which shows part of the data in a data table and +/// provides controls for paging through the remainder of the data. +/// * `TableView` from the +/// [two_dimensional_scrollables](https://pub.dev/packages/two_dimensional_scrollables) +/// package, for displaying large amounts of data without pagination. +/// * <https://material.io/go/design-data-tables> +class DataTable extends StatelessWidget { + /// Creates a widget describing a data table. + /// + /// The [columns] argument must be a list of as many [DataColumn] + /// objects as the table is to have columns, ignoring the leading + /// checkbox column if any. The [columns] argument must have a + /// length greater than zero. + /// + /// The [rows] argument must be a list of as many [DataRow] objects + /// as the table is to have rows, ignoring the leading heading row + /// that contains the column headings (derived from the [columns] + /// argument). There may be zero rows, but the rows argument must + /// not be null. + /// + /// Each [DataRow] object in [rows] must have as many [DataCell] + /// objects in the [DataRow.cells] list as the table has columns. + /// + /// If the table is sorted, the column that provides the current + /// primary key should be specified by index in [sortColumnIndex], 0 + /// meaning the first column in [columns], 1 being the next one, and + /// so forth. + /// + /// The actual sort order can be specified using [sortAscending]; if + /// the sort order is ascending, this should be true (the default), + /// otherwise it should be false. + DataTable({ + super.key, + required this.columns, + this.sortColumnIndex, + this.sortAscending = true, + this.onSelectAll, + this.decoration, + this.dataRowColor, + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + this.dataTextStyle, + this.headingRowColor, + this.headingRowHeight, + this.headingTextStyle, + this.horizontalMargin, + this.columnSpacing, + this.showCheckboxColumn = true, + this.showBottomBorder = false, + this.dividerThickness, + required this.rows, + this.checkboxHorizontalMargin, + this.border, + this.clipBehavior = Clip.none, + }) : assert(columns.isNotEmpty), + assert( + sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length), + ), + assert( + !rows.any((DataRow row) => row.cells.length != columns.length), + 'All rows must have the same number of cells as there are header cells (${columns.length})', + ), + assert(dividerThickness == null || dividerThickness >= 0), + assert( + dataRowMinHeight == null || + dataRowMaxHeight == null || + dataRowMaxHeight >= dataRowMinHeight, + ), + assert( + dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null), + 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.', + ), + dataRowMinHeight = dataRowHeight ?? dataRowMinHeight, + dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight, + _onlyTextColumn = _initOnlyTextColumn(columns); + + /// The configuration and labels for the columns in the table. + final List<DataColumn> columns; + + /// The current primary sort key's column. + /// + /// If non-null, indicates that the indicated column is the column + /// by which the data is sorted. The number must correspond to the + /// index of the relevant column in [columns]. + /// + /// Setting this will cause the relevant column to have a sort + /// indicator displayed. + /// + /// When this is null, it implies that the table's sort order does + /// not correspond to any of the columns. + /// + /// The direction of the sort is specified using [sortAscending]. + final int? sortColumnIndex; + + /// Whether the column mentioned in [sortColumnIndex], if any, is sorted + /// in ascending order. + /// + /// If true, the order is ascending (meaning the rows with the + /// smallest values for the current sort column are first in the + /// table). + /// + /// If false, the order is descending (meaning the rows with the + /// smallest values for the current sort column are last in the + /// table). + /// + /// Ascending order is represented by an upwards-facing arrow. + final bool sortAscending; + + /// Invoked when the user selects or unselects every row, using the + /// checkbox in the heading row. + /// + /// If this is null, then the [DataRow.onSelectChanged] callback of + /// every row in the table is invoked appropriately instead. + /// + /// To control whether a particular row is selectable or not, see + /// [DataRow.onSelectChanged]. This callback is only relevant if any + /// row is selectable. + final ValueSetter<bool?>? onSelectAll; + + /// {@template flutter.material.dataTable.decoration} + /// The background and border decoration for the table. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.decoration] is used. By default there is no + /// decoration. + final Decoration? decoration; + + /// {@template flutter.material.dataTable.dataRowColor} + /// The background color for the data rows. + /// + /// The effective background color can be made to depend on the + /// [WidgetState] state, i.e. if the row is selected, pressed, hovered, + /// focused, disabled or enabled. The color is painted as an overlay to the + /// row. To make sure that the row's [InkWell] is visible (when pressed, + /// hovered and focused), it is recommended to use a translucent background + /// color. + /// + /// If [DataRow.onSelectChanged] or [DataRow.onLongPress] is null, the row's + /// [InkWell] will be disabled. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.dataRowColor] is used. By default, the + /// background color is transparent unless selected. Selected rows have a grey + /// translucent color. To set a different color for individual rows, see + /// [DataRow.color]. + /// + /// {@template flutter.material.DataTable.dataRowColor} + /// ```dart + /// DataTable( + /// dataRowColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.selected)) { + /// return Theme.of(context).colorScheme.primary.withValues(alpha: 0.08); + /// } + /// return null; // Use the default value. + /// }), + /// columns: _columns, + /// rows: _rows, + /// ) + /// ``` + /// + /// See also: + /// + /// * The Material Design specification for overlay colors and how they + /// match a component's state: + /// <https://material.io/design/interaction/states.html#anatomy>. + /// {@endtemplate} + final WidgetStateProperty<Color?>? dataRowColor; + + /// {@template flutter.material.dataTable.dataRowHeight} + /// The height of each row (excluding the row that contains column headings). + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.dataRowHeight] is used. This value defaults + /// to [kMinInteractiveDimension] to adhere to the Material Design + /// specifications. + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null; + + /// {@template flutter.material.dataTable.dataRowMinHeight} + /// The minimum height of each row (excluding the row that contains column headings). + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.dataRowMinHeight] is used. This value defaults + /// to [kMinInteractiveDimension] to adhere to the Material Design + /// specifications. + final double? dataRowMinHeight; + + /// {@template flutter.material.dataTable.dataRowMaxHeight} + /// The maximum height of each row (excluding the row that contains column headings). + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.dataRowMaxHeight] is used. This value defaults + /// to [kMinInteractiveDimension] to adhere to the Material Design + /// specifications. + final double? dataRowMaxHeight; + + /// {@template flutter.material.dataTable.dataTextStyle} + /// The text style for data rows. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.dataTextStyle] is used. By default, the text + /// style is [TextTheme.bodyMedium]. + final TextStyle? dataTextStyle; + + /// {@template flutter.material.dataTable.headingRowColor} + /// The background color for the heading row. + /// + /// The effective background color can be made to depend on the + /// [WidgetState] state, i.e. if the row is pressed, hovered, focused when + /// sorted. The color is painted as an overlay to the row. To make sure that + /// the row's [InkWell] is visible (when pressed, hovered and focused), it is + /// recommended to use a translucent color. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.headingRowColor] is used. + /// + /// {@template flutter.material.DataTable.headingRowColor} + /// ```dart + /// DataTable( + /// columns: _columns, + /// rows: _rows, + /// headingRowColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.hovered)) { + /// return Theme.of(context).colorScheme.primary.withValues(alpha: 0.08); + /// } + /// return null; // Use the default value. + /// }), + /// ) + /// ``` + /// + /// See also: + /// + /// * The Material Design specification for overlay colors and how they + /// match a component's state: + /// <https://material.io/design/interaction/states.html#anatomy>. + /// {@endtemplate} + final WidgetStateProperty<Color?>? headingRowColor; + + /// {@template flutter.material.dataTable.headingRowHeight} + /// The height of the heading row. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.headingRowHeight] is used. This value + /// defaults to 56.0 to adhere to the Material Design specifications. + final double? headingRowHeight; + + /// {@template flutter.material.dataTable.headingTextStyle} + /// The text style for the heading row. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.headingTextStyle] is used. By default, the + /// text style is [TextTheme.titleSmall]. + final TextStyle? headingTextStyle; + + /// {@template flutter.material.dataTable.horizontalMargin} + /// The horizontal margin between the edges of the table and the content + /// in the first and last cells of each row. + /// + /// When a checkbox is displayed, it is also the margin between the checkbox + /// the content in the first data column. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.horizontalMargin] is used. This value + /// defaults to 24.0 to adhere to the Material Design specifications. + /// + /// If [checkboxHorizontalMargin] is null, then [horizontalMargin] is also the + /// margin between the edge of the table and the checkbox, as well as the + /// margin between the checkbox and the content in the first data column. + final double? horizontalMargin; + + /// {@template flutter.material.dataTable.columnSpacing} + /// The horizontal margin between the contents of each data column. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.columnSpacing] is used. This value defaults + /// to 56.0 to adhere to the Material Design specifications. + final double? columnSpacing; + + /// {@template flutter.material.dataTable.showCheckboxColumn} + /// Whether the widget should display checkboxes for selectable rows. + /// + /// If true, a [Checkbox] will be placed at the beginning of each row that is + /// selectable. However, if [DataRow.onSelectChanged] is not set for any row, + /// checkboxes will not be placed, even if this value is true. + /// + /// If false, all rows will not display a [Checkbox]. + /// {@endtemplate} + final bool showCheckboxColumn; + + /// The data to show in each row (excluding the row that contains + /// the column headings). + /// + /// The list may be empty. + final List<DataRow> rows; + + /// {@template flutter.material.dataTable.dividerThickness} + /// The width of the divider that appears between [TableRow]s. + /// + /// Must be greater than or equal to zero. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.dividerThickness] is used. This value + /// defaults to 1.0. + final double? dividerThickness; + + /// Whether a border at the bottom of the table is displayed. + /// + /// By default, a border is not shown at the bottom to allow for a border + /// around the table defined by [decoration]. + final bool showBottomBorder; + + /// {@template flutter.material.dataTable.checkboxHorizontalMargin} + /// Horizontal margin around the checkbox, if it is displayed. + /// {@endtemplate} + /// + /// If null, [DataTableThemeData.checkboxHorizontalMargin] is used. If that is + /// also null, then [horizontalMargin] is used as the margin between the edge + /// of the table and the checkbox, as well as the margin between the checkbox + /// and the content in the first data column. This value defaults to 24.0. + final double? checkboxHorizontalMargin; + + /// The style to use when painting the boundary and interior divisions of the table. + final TableBorder? border; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// This can be used to clip the content within the border of the [DataTable]. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + // Set by the constructor to the index of the only Column that is + // non-numeric, if there is exactly one, otherwise null. + final int? _onlyTextColumn; + static int? _initOnlyTextColumn(List<DataColumn> columns) { + int? result; + for (var index = 0; index < columns.length; index += 1) { + final DataColumn column = columns[index]; + if (!column.numeric) { + if (result != null) { + return null; + } + result = index; + } + } + return result; + } + + bool get _debugInteractive { + return columns.any((DataColumn column) => column._debugInteractive) || + rows.any((DataRow row) => row._debugInteractive); + } + + static final LocalKey _headingRowKey = UniqueKey(); + + void _handleSelectAll(bool? checked, bool someChecked) { + // If some checkboxes are checked, all checkboxes are selected. Otherwise, + // use the new checked value but default to false if it's null. + final bool effectiveChecked = someChecked || (checked ?? false); + if (onSelectAll != null) { + onSelectAll!(effectiveChecked); + } else { + for (final DataRow row in rows) { + if (row.onSelectChanged != null && row.selected != effectiveChecked) { + row.onSelectChanged!(effectiveChecked); + } + } + } + } + + /// The default height of the heading row. + static const double _headingRowHeight = 56.0; + + /// The default horizontal margin between the edges of the table and the content + /// in the first and last cells of each row. + static const double _horizontalMargin = 24.0; + + /// The default horizontal margin between the contents of each data column. + static const double _columnSpacing = 56.0; + + /// The default padding between the heading content and sort arrow. + static const double _sortArrowPadding = 2.0; + + /// The default divider thickness. + static const double _dividerThickness = 1.0; + + static const Duration _sortArrowAnimationDuration = Duration(milliseconds: 150); + + Widget _buildCheckbox({ + required BuildContext context, + required bool? checked, + required VoidCallback? onRowTap, + required ValueChanged<bool?>? onCheckboxChanged, + required WidgetStateProperty<Color?>? overlayColor, + required bool tristate, + MouseCursor? rowMouseCursor, + }) { + final ThemeData themeData = Theme.of(context); + final double effectiveHorizontalMargin = + horizontalMargin ?? themeData.dataTableTheme.horizontalMargin ?? _horizontalMargin; + final double effectiveCheckboxHorizontalMarginStart = + checkboxHorizontalMargin ?? + themeData.dataTableTheme.checkboxHorizontalMargin ?? + effectiveHorizontalMargin; + final double effectiveCheckboxHorizontalMarginEnd = + checkboxHorizontalMargin ?? + themeData.dataTableTheme.checkboxHorizontalMargin ?? + effectiveHorizontalMargin / 2.0; + Widget contents = Semantics( + container: true, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: effectiveCheckboxHorizontalMarginStart, + end: effectiveCheckboxHorizontalMarginEnd, + ), + child: Center( + child: Checkbox(value: checked, onChanged: onCheckboxChanged, tristate: tristate), + ), + ), + ); + if (onRowTap != null) { + contents = TableRowInkWell( + onTap: onRowTap, + overlayColor: overlayColor, + mouseCursor: rowMouseCursor, + child: contents, + ); + } + return TableCell(verticalAlignment: TableCellVerticalAlignment.fill, child: contents); + } + + Widget _buildHeadingCell({ + required BuildContext context, + required EdgeInsetsGeometry padding, + required Widget label, + required String? tooltip, + required bool numeric, + required VoidCallback? onSort, + required bool sorted, + required bool ascending, + required WidgetStateProperty<Color?>? overlayColor, + required MouseCursor? mouseCursor, + required MainAxisAlignment headingRowAlignment, + }) { + final ThemeData themeData = Theme.of(context); + final DataTableThemeData dataTableTheme = DataTableTheme.of(context); + label = Semantics( + role: SemanticsRole.columnHeader, + child: Row( + textDirection: numeric ? TextDirection.rtl : null, + mainAxisAlignment: headingRowAlignment, + children: <Widget>[ + if (headingRowAlignment == MainAxisAlignment.center && onSort != null) + const SizedBox(width: _SortArrowState._arrowIconSize + _sortArrowPadding), + label, + if (onSort != null) ...<Widget>[ + _SortArrow( + visible: sorted, + up: sorted ? ascending : null, + duration: _sortArrowAnimationDuration, + ), + const SizedBox(width: _sortArrowPadding), + ], + ], + ), + ); + + final TextStyle effectiveHeadingTextStyle = + headingTextStyle ?? + dataTableTheme.headingTextStyle ?? + themeData.dataTableTheme.headingTextStyle ?? + themeData.textTheme.titleSmall!; + final double effectiveHeadingRowHeight = + headingRowHeight ?? + dataTableTheme.headingRowHeight ?? + themeData.dataTableTheme.headingRowHeight ?? + _headingRowHeight; + label = Container( + padding: padding, + height: effectiveHeadingRowHeight, + alignment: numeric ? Alignment.centerRight : AlignmentDirectional.centerStart, + child: AnimatedDefaultTextStyle( + style: DefaultTextStyle.of(context).style.merge(effectiveHeadingTextStyle), + softWrap: false, + duration: _sortArrowAnimationDuration, + child: label, + ), + ); + if (tooltip != null) { + label = Tooltip(message: tooltip, child: label); + } + + label = InkWell( + onTap: onSort, + overlayColor: overlayColor, + mouseCursor: mouseCursor, + child: label, + ); + return label; + } + + Widget _buildDataCell({ + required BuildContext context, + required EdgeInsetsGeometry padding, + required Widget label, + required bool numeric, + required bool placeholder, + required bool showEditIcon, + required GestureTapCallback? onTap, + required VoidCallback? onSelectChanged, + required GestureTapCallback? onDoubleTap, + required GestureLongPressCallback? onLongPress, + required GestureTapDownCallback? onTapDown, + required GestureTapCancelCallback? onTapCancel, + required WidgetStateProperty<Color?>? overlayColor, + required GestureLongPressCallback? onRowLongPress, + required ValueChanged<bool>? onRowHover, + required MouseCursor? mouseCursor, + }) { + final ThemeData themeData = Theme.of(context); + final DataTableThemeData dataTableTheme = DataTableTheme.of(context); + if (showEditIcon) { + const Widget icon = Icon(Icons.edit, size: 18.0); + label = Expanded(child: label); + label = Row( + textDirection: numeric ? TextDirection.rtl : null, + children: <Widget>[label, icon], + ); + } + + final TextStyle effectiveDataTextStyle = + dataTextStyle ?? + dataTableTheme.dataTextStyle ?? + themeData.dataTableTheme.dataTextStyle ?? + themeData.textTheme.bodyMedium!; + final double effectiveDataRowMinHeight = + dataRowMinHeight ?? + dataTableTheme.dataRowMinHeight ?? + themeData.dataTableTheme.dataRowMinHeight ?? + kMinInteractiveDimension; + final double effectiveDataRowMaxHeight = + dataRowMaxHeight ?? + dataTableTheme.dataRowMaxHeight ?? + themeData.dataTableTheme.dataRowMaxHeight ?? + kMinInteractiveDimension; + label = Container( + padding: padding, + constraints: BoxConstraints( + minHeight: effectiveDataRowMinHeight, + maxHeight: effectiveDataRowMaxHeight, + ), + alignment: numeric ? Alignment.centerRight : AlignmentDirectional.centerStart, + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style + .merge(effectiveDataTextStyle) + .copyWith(color: placeholder ? effectiveDataTextStyle.color!.withOpacity(0.6) : null), + child: DropdownButtonHideUnderline(child: label), + ), + ); + if (onTap != null || + onDoubleTap != null || + onLongPress != null || + onTapDown != null || + onTapCancel != null) { + label = InkWell( + onTap: onTap, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + onTapCancel: onTapCancel, + onTapDown: onTapDown, + overlayColor: overlayColor, + child: label, + ); + } else if (onSelectChanged != null || onRowLongPress != null || onRowHover != null) { + label = TableRowInkWell( + onTap: onSelectChanged, + onLongPress: onRowLongPress, + onHover: onRowHover, + overlayColor: overlayColor, + mouseCursor: mouseCursor, + child: label, + ); + } + return TableCell(child: label); + } + + @override + Widget build(BuildContext context) { + assert(!_debugInteractive || debugCheckHasMaterial(context)); + + final ThemeData theme = Theme.of(context); + final DataTableThemeData dataTableTheme = DataTableTheme.of(context); + final WidgetStateProperty<Color?>? effectiveHeadingRowColor = + headingRowColor ?? dataTableTheme.headingRowColor ?? theme.dataTableTheme.headingRowColor; + final WidgetStateProperty<Color?>? effectiveDataRowColor = + dataRowColor ?? dataTableTheme.dataRowColor ?? theme.dataTableTheme.dataRowColor; + final WidgetStateProperty<Color?> defaultRowColor = WidgetStateProperty.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.selected)) { + return theme.colorScheme.primary.withOpacity(0.08); + } + return null; + }); + final bool anyRowSelectable = rows.any((DataRow row) => row.onSelectChanged != null); + final bool displayCheckboxColumn = showCheckboxColumn && anyRowSelectable; + final Iterable<DataRow> rowsWithCheckbox = displayCheckboxColumn + ? rows.where((DataRow row) => row.onSelectChanged != null) + : <DataRow>[]; + final Iterable<DataRow> rowsChecked = rowsWithCheckbox.where((DataRow row) => row.selected); + final bool allChecked = displayCheckboxColumn && rowsChecked.length == rowsWithCheckbox.length; + final bool anyChecked = displayCheckboxColumn && rowsChecked.isNotEmpty; + final bool someChecked = anyChecked && !allChecked; + final double effectiveHorizontalMargin = + horizontalMargin ?? + dataTableTheme.horizontalMargin ?? + theme.dataTableTheme.horizontalMargin ?? + _horizontalMargin; + final double effectiveCheckboxHorizontalMarginStart = + checkboxHorizontalMargin ?? + dataTableTheme.checkboxHorizontalMargin ?? + theme.dataTableTheme.checkboxHorizontalMargin ?? + effectiveHorizontalMargin; + final double effectiveCheckboxHorizontalMarginEnd = + checkboxHorizontalMargin ?? + dataTableTheme.checkboxHorizontalMargin ?? + theme.dataTableTheme.checkboxHorizontalMargin ?? + effectiveHorizontalMargin / 2.0; + final double effectiveColumnSpacing = + columnSpacing ?? + dataTableTheme.columnSpacing ?? + theme.dataTableTheme.columnSpacing ?? + _columnSpacing; + + final tableColumns = List<TableColumnWidth>.filled( + columns.length + (displayCheckboxColumn ? 1 : 0), + const _NullTableColumnWidth(), + ); + final tableRows = List<TableRow>.generate( + rows.length + 1, // the +1 is for the header row + (int index) { + final bool isSelected = index > 0 && rows[index - 1].selected; + final bool isDisabled = + index > 0 && anyRowSelectable && rows[index - 1].onSelectChanged == null; + final states = <WidgetState>{ + if (isSelected) WidgetState.selected, + if (isDisabled) WidgetState.disabled, + }; + final Color? resolvedDataRowColor = index > 0 + ? (rows[index - 1].color ?? effectiveDataRowColor)?.resolve(states) + : null; + final Color? resolvedHeadingRowColor = effectiveHeadingRowColor?.resolve(<WidgetState>{}); + final rowColor = index > 0 ? resolvedDataRowColor : resolvedHeadingRowColor; + final BorderSide borderSide = Divider.createBorderSide( + context, + width: + dividerThickness ?? + dataTableTheme.dividerThickness ?? + theme.dataTableTheme.dividerThickness ?? + _dividerThickness, + ); + final Border? border = showBottomBorder + ? Border(bottom: borderSide) + : index == 0 + ? null + : Border(top: borderSide); + return TableRow( + key: index == 0 ? _headingRowKey : rows[index - 1].key, + decoration: BoxDecoration( + border: border, + color: rowColor ?? defaultRowColor.resolve(states), + ), + children: List<Widget>.filled(tableColumns.length, const _NullWidget()), + ); + }, + ); + + int rowIndex; + + var displayColumnIndex = 0; + if (displayCheckboxColumn) { + tableColumns[0] = FixedColumnWidth( + effectiveCheckboxHorizontalMarginStart + + Checkbox.width + + effectiveCheckboxHorizontalMarginEnd, + ); + tableRows[0].children[0] = _buildCheckbox( + context: context, + checked: someChecked ? null : allChecked, + onRowTap: null, + onCheckboxChanged: (bool? checked) => _handleSelectAll(checked, someChecked), + overlayColor: null, + tristate: true, + ); + rowIndex = 1; + for (final DataRow row in rows) { + final states = <WidgetState>{if (row.selected) WidgetState.selected}; + tableRows[rowIndex].children[0] = _buildCheckbox( + context: context, + checked: row.selected, + onRowTap: row.onSelectChanged == null + ? null + : () => row.onSelectChanged?.call(!row.selected), + onCheckboxChanged: row.onSelectChanged, + overlayColor: row.color ?? effectiveDataRowColor, + rowMouseCursor: + row.mouseCursor?.resolve(states) ?? dataTableTheme.dataRowCursor?.resolve(states), + tristate: false, + ); + rowIndex += 1; + } + displayColumnIndex += 1; + } + + for (var dataColumnIndex = 0; dataColumnIndex < columns.length; dataColumnIndex += 1) { + final DataColumn column = columns[dataColumnIndex]; + + final double paddingStart = switch (dataColumnIndex) { + 0 when displayCheckboxColumn && checkboxHorizontalMargin == null => + effectiveHorizontalMargin / 2.0, + 0 => effectiveHorizontalMargin, + _ => effectiveColumnSpacing / 2.0, + }; + + final double paddingEnd; + if (dataColumnIndex == columns.length - 1) { + paddingEnd = effectiveHorizontalMargin; + } else { + paddingEnd = effectiveColumnSpacing / 2.0; + } + + final padding = EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd); + if (column.columnWidth != null) { + tableColumns[displayColumnIndex] = column.columnWidth!; + } else if (dataColumnIndex == _onlyTextColumn) { + tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(flex: 1.0); + } else { + tableColumns[displayColumnIndex] = const IntrinsicColumnWidth(); + } + + final headerStates = <WidgetState>{if (column.onSort == null) WidgetState.disabled}; + tableRows[0].children[displayColumnIndex] = _buildHeadingCell( + context: context, + padding: padding, + label: column.label, + tooltip: column.tooltip, + numeric: column.numeric, + onSort: column.onSort != null + ? () => column.onSort!( + dataColumnIndex, + sortColumnIndex != dataColumnIndex || !sortAscending, + ) + : null, + sorted: dataColumnIndex == sortColumnIndex, + ascending: sortAscending, + overlayColor: effectiveHeadingRowColor, + mouseCursor: + column.mouseCursor?.resolve(headerStates) ?? + dataTableTheme.headingCellCursor?.resolve(headerStates), + headingRowAlignment: + column.headingRowAlignment ?? + dataTableTheme.headingRowAlignment ?? + MainAxisAlignment.start, + ); + rowIndex = 1; + for (final DataRow row in rows) { + final states = <WidgetState>{if (row.selected) WidgetState.selected}; + final DataCell cell = row.cells[dataColumnIndex]; + tableRows[rowIndex].children[displayColumnIndex] = _buildDataCell( + context: context, + padding: padding, + label: cell.child, + numeric: column.numeric, + placeholder: cell.placeholder, + showEditIcon: cell.showEditIcon, + onTap: cell.onTap, + onDoubleTap: cell.onDoubleTap, + onLongPress: cell.onLongPress, + onTapCancel: cell.onTapCancel, + onTapDown: cell.onTapDown, + onSelectChanged: row.onSelectChanged == null + ? null + : () => row.onSelectChanged?.call(!row.selected), + overlayColor: row.color ?? effectiveDataRowColor, + onRowLongPress: row.onLongPress, + onRowHover: row.onHover, + mouseCursor: + row.mouseCursor?.resolve(states) ?? dataTableTheme.dataRowCursor?.resolve(states), + ); + rowIndex += 1; + } + displayColumnIndex += 1; + } + + return Container( + decoration: decoration ?? dataTableTheme.decoration ?? theme.dataTableTheme.decoration, + child: Material( + type: MaterialType.transparency, + borderRadius: border?.borderRadius, + clipBehavior: clipBehavior, + child: Table( + columnWidths: tableColumns.asMap(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: tableRows, + border: border, + ), + ), + ); + } +} + +/// A rectangular area of a Material that responds to touch but clips +/// its ink splashes to the current table row of the nearest table. +/// +/// Must have an ancestor [Material] widget in which to cause ink +/// reactions and an ancestor [Table] widget to establish a row. +/// +/// The [TableRowInkWell] must be in the same coordinate space (modulo +/// translations) as the [Table]. If it's rotated or scaled or +/// otherwise transformed, it will not be able to describe the +/// rectangle of the row in its own coordinate system as a [Rect], and +/// thus the splash will not occur. (In general, this is easy to +/// achieve: just put the [TableRowInkWell] as the direct child of the +/// [Table], and put the other contents of the cell inside it.) +/// +/// See also: +/// +/// * [DataTable], which makes use of [TableRowInkWell] when +/// [DataRow.onSelectChanged] is defined and [DataCell.onTap] +/// is not. +class TableRowInkWell extends InkResponse { + /// Creates an ink well for a table row. + const TableRowInkWell({ + super.key, + super.child, + super.onTap, + super.onDoubleTap, + super.onLongPress, + super.onHighlightChanged, + super.onHover, + super.onSecondaryTap, + super.onSecondaryTapDown, + super.overlayColor, + super.mouseCursor, + }) : super(containedInkWell: true, highlightShape: BoxShape.rectangle); + + @override + RectCallback getRectCallback(RenderBox referenceBox) { + return () { + RenderObject cell = referenceBox; + RenderObject? table = cell.parent; + final transform = Matrix4.identity(); + while (table is RenderObject && table is! RenderTable) { + table.applyPaintTransform(cell, transform); + assert(table == cell.parent); + cell = table; + table = table.parent; + } + if (table is RenderTable) { + final cellParentData = cell.parentData! as TableCellParentData; + assert(cellParentData.y != null); + final Rect rect = table.getRowBox(cellParentData.y!); + // The rect is in the table's coordinate space. We need to change it to the + // TableRowInkWell's coordinate space. + table.applyPaintTransform(cell, transform); + final Offset? offset = MatrixUtils.getAsTranslation(transform); + if (offset != null) { + return rect.shift(-offset); + } + } + return Rect.zero; + }; + } + + @override + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasTable(context)); + return super.debugCheckContext(context); + } +} + +class _SortArrow extends StatefulWidget { + const _SortArrow({required this.visible, required this.up, required this.duration}); + + final bool visible; + + final bool? up; + + final Duration duration; + + @override + _SortArrowState createState() => _SortArrowState(); +} + +class _SortArrowState extends State<_SortArrow> with TickerProviderStateMixin { + late final AnimationController _opacityController; + late final CurvedAnimation _opacityAnimation; + + late final AnimationController _orientationController; + late final Animation<double> _orientationAnimation; + double _orientationOffset = 0.0; + + bool? _up; + + static final Animatable<double> _turnTween = Tween<double>( + begin: 0.0, + end: math.pi, + ).chain(CurveTween(curve: Curves.easeIn)); + + @override + void initState() { + super.initState(); + _up = widget.up; + _opacityAnimation = CurvedAnimation( + parent: _opacityController = AnimationController(duration: widget.duration, vsync: this), + curve: Curves.fastOutSlowIn, + )..addListener(_rebuild); + _opacityController.value = widget.visible ? 1.0 : 0.0; + _orientationController = AnimationController(duration: widget.duration, vsync: this); + _orientationAnimation = _orientationController.drive(_turnTween) + ..addListener(_rebuild) + ..addStatusListener(_resetOrientationAnimation); + if (widget.visible) { + _orientationOffset = widget.up! ? 0.0 : math.pi; + } + } + + void _rebuild() { + setState(() { + // The animations changed, so we need to rebuild. + }); + } + + void _resetOrientationAnimation(AnimationStatus status) { + if (status.isCompleted) { + assert(_orientationAnimation.value == math.pi); + _orientationOffset += math.pi; + _orientationController.value = 0.0; // TODO(ianh): This triggers a pointless rebuild. + } + } + + @override + void didUpdateWidget(_SortArrow oldWidget) { + super.didUpdateWidget(oldWidget); + var skipArrow = false; + final bool? newUp = widget.up ?? _up; + if (oldWidget.visible != widget.visible) { + if (widget.visible && _opacityController.isDismissed) { + _orientationController.stop(); + _orientationController.value = 0.0; + _orientationOffset = newUp! ? 0.0 : math.pi; + skipArrow = true; + } + if (widget.visible) { + _opacityController.forward(); + } else { + _opacityController.reverse(); + } + } + if ((_up != newUp) && !skipArrow) { + if (_orientationController.isDismissed) { + _orientationController.forward(); + } else { + _orientationController.reverse(); + } + } + _up = newUp; + } + + @override + void dispose() { + _opacityController.dispose(); + _orientationController.dispose(); + _opacityAnimation.dispose(); + super.dispose(); + } + + static const double _arrowIconBaselineOffset = -1.5; + static const double _arrowIconSize = 16.0; + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacityAnimation, + child: Transform( + transform: Matrix4.rotationZ(_orientationOffset + _orientationAnimation.value) + ..setTranslationRaw(0.0, _arrowIconBaselineOffset, 0.0), + alignment: Alignment.center, + child: const Icon(Icons.arrow_upward, size: _arrowIconSize), + ), + ); + } +} + +class _NullTableColumnWidth extends TableColumnWidth { + const _NullTableColumnWidth(); + + @override + double maxIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth) => + throw UnimplementedError(); + + @override + double minIntrinsicWidth(Iterable<RenderBox> cells, double containerWidth) => + throw UnimplementedError(); +} + +class _NullWidget extends Widget { + const _NullWidget(); + + @override + Element createElement() => throw UnimplementedError(); +} diff --git a/packages/material_ui/lib/src/data_table_source.dart b/packages/material_ui/lib/src/data_table_source.dart new file mode 100644 index 000000000000..994f4fe36576 --- /dev/null +++ b/packages/material_ui/lib/src/data_table_source.dart @@ -0,0 +1,78 @@ +// 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. + +/// @docImport 'paginated_data_table.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'data_table.dart'; + +/// A data source for obtaining row data for [PaginatedDataTable] objects. +/// +/// A data table source provides two main pieces of information: +/// +/// * The number of rows in the data table ([rowCount]). +/// * The data for each row (indexed from `0` to `rowCount - 1`). +/// +/// It also provides a listener API ([addListener]/[removeListener]) so that +/// consumers of the data can be notified when it changes. When the data +/// changes, call [notifyListeners] to send the notifications. +/// +/// DataTableSource objects are expected to be long-lived, not recreated with +/// each build. +/// +/// If a [DataTableSource] is used with a [PaginatedDataTable] that supports +/// sortable columns (see [DataColumn.onSort] and +/// [PaginatedDataTable.sortColumnIndex]), the rows reported by the data source +/// must be reported in the sorted order. +abstract class DataTableSource extends ChangeNotifier { + /// Called to obtain the data about a particular row. + /// + /// Rows should be keyed so that state can be maintained when the data source + /// is sorted (e.g. in response to [DataColumn.onSort]). Keys should be + /// consistent for a given [DataRow] regardless of the sort order (i.e. the + /// key represents the data's identity, not the row position). + /// + /// The [DataRow.byIndex] constructor provides a convenient way to construct + /// [DataRow] objects for this method's purposes without having to worry about + /// independently keying each row. The index passed to that constructor is the + /// index of the underlying data, which is different than the `index` + /// parameter for [getRow], which represents the _sorted_ position. + /// + /// If the given index does not correspond to a row, or if no data is yet + /// available for a row, then return null. The row will be left blank and a + /// loading indicator will be displayed over the table. Once data is available + /// or once it is firmly established that the row index in question is beyond + /// the end of the table, call [notifyListeners]. (See [rowCount].) + /// + /// If the underlying data changes, call [notifyListeners]. + DataRow? getRow(int index); + + /// Called to obtain the number of rows to tell the user are available. + /// + /// If [isRowCountApproximate] is false, then this must be an accurate number, + /// and [getRow] must return a non-null value for all indices in the range 0 + /// to one less than the row count. + /// + /// If [isRowCountApproximate] is true, then the user will be allowed to + /// attempt to display rows up to this [rowCount], and the display will + /// indicate that the count is approximate. The row count should therefore be + /// greater than the actual number of rows if at all possible. + /// + /// If the row count changes, call [notifyListeners]. + int get rowCount; + + /// Called to establish if [rowCount] is a precise number or might be an + /// over-estimate. If this returns true (i.e. the count is approximate), and + /// then later the exact number becomes available, then call + /// [notifyListeners]. + bool get isRowCountApproximate; + + /// Called to obtain the number of rows that are currently selected. + /// + /// If the selected row count changes, call [notifyListeners]. + /// + /// Selected rows are those whose [DataRow.selected] property is set to true. + int get selectedRowCount; +} diff --git a/packages/material_ui/lib/src/data_table_theme.dart b/packages/material_ui/lib/src/data_table_theme.dart new file mode 100644 index 000000000000..bd12cfd4a2f5 --- /dev/null +++ b/packages/material_ui/lib/src/data_table_theme.dart @@ -0,0 +1,354 @@ +// 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. + +/// @docImport 'data_table.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [DataTable] +/// widgets. +/// +/// Descendant widgets obtain the current [DataTableThemeData] object +/// using [DataTableTheme.of]. Instances of [DataTableThemeData] can +/// be customized with [DataTableThemeData.copyWith]. +/// +/// Typically a [DataTableThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.dataTableTheme]. +/// +/// All [DataTableThemeData] properties are `null` by default. When +/// null, the [DataTable] will use the values from [ThemeData] if they exist, +/// otherwise it will provide its own defaults based on the overall [Theme]'s +/// textTheme and colorScheme. See the individual [DataTable] properties for +/// details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class DataTableThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.dataTableTheme]. + const DataTableThemeData({ + this.decoration, + this.dataRowColor, + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + this.dataTextStyle, + this.headingRowColor, + this.headingRowHeight, + this.headingTextStyle, + this.horizontalMargin, + this.columnSpacing, + this.dividerThickness, + this.checkboxHorizontalMargin, + this.headingCellCursor, + this.dataRowCursor, + this.headingRowAlignment, + }) : assert( + dataRowMinHeight == null || + dataRowMaxHeight == null || + dataRowMaxHeight >= dataRowMinHeight, + ), + assert( + dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null), + 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.', + ), + dataRowMinHeight = dataRowHeight ?? dataRowMinHeight, + dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight; + + /// {@macro flutter.material.dataTable.decoration} + final Decoration? decoration; + + /// {@macro flutter.material.dataTable.dataRowColor} + /// {@macro flutter.material.DataTable.dataRowColor} + final WidgetStateProperty<Color?>? dataRowColor; + + /// {@macro flutter.material.dataTable.dataRowHeight} + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null; + + /// {@macro flutter.material.dataTable.dataRowMinHeight} + final double? dataRowMinHeight; + + /// {@macro flutter.material.dataTable.dataRowMaxHeight} + final double? dataRowMaxHeight; + + /// {@macro flutter.material.dataTable.dataTextStyle} + final TextStyle? dataTextStyle; + + /// {@macro flutter.material.dataTable.headingRowColor} + /// {@macro flutter.material.DataTable.headingRowColor} + final WidgetStateProperty<Color?>? headingRowColor; + + /// {@macro flutter.material.dataTable.headingRowHeight} + final double? headingRowHeight; + + /// {@macro flutter.material.dataTable.headingTextStyle} + final TextStyle? headingTextStyle; + + /// {@macro flutter.material.dataTable.horizontalMargin} + final double? horizontalMargin; + + /// {@macro flutter.material.dataTable.columnSpacing} + final double? columnSpacing; + + /// {@macro flutter.material.dataTable.dividerThickness} + final double? dividerThickness; + + /// {@macro flutter.material.dataTable.checkboxHorizontalMargin} + final double? checkboxHorizontalMargin; + + /// If specified, overrides the default value of [DataColumn.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? headingCellCursor; + + /// If specified, overrides the default value of [DataRow.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? dataRowCursor; + + /// If specified, overrides the default value of [DataColumn.headingRowAlignment]. + final MainAxisAlignment? headingRowAlignment; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + DataTableThemeData copyWith({ + Decoration? decoration, + WidgetStateProperty<Color?>? dataRowColor, + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + TextStyle? dataTextStyle, + WidgetStateProperty<Color?>? headingRowColor, + double? headingRowHeight, + TextStyle? headingTextStyle, + double? horizontalMargin, + double? columnSpacing, + double? dividerThickness, + double? checkboxHorizontalMargin, + WidgetStateProperty<MouseCursor?>? headingCellCursor, + WidgetStateProperty<MouseCursor?>? dataRowCursor, + MainAxisAlignment? headingRowAlignment, + }) { + assert( + dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null), + 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.', + ); + dataRowMinHeight = dataRowHeight ?? dataRowMinHeight; + dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight; + + return DataTableThemeData( + decoration: decoration ?? this.decoration, + dataRowColor: dataRowColor ?? this.dataRowColor, + dataRowMinHeight: dataRowMinHeight ?? this.dataRowMinHeight, + dataRowMaxHeight: dataRowMaxHeight ?? this.dataRowMaxHeight, + dataTextStyle: dataTextStyle ?? this.dataTextStyle, + headingRowColor: headingRowColor ?? this.headingRowColor, + headingRowHeight: headingRowHeight ?? this.headingRowHeight, + headingTextStyle: headingTextStyle ?? this.headingTextStyle, + horizontalMargin: horizontalMargin ?? this.horizontalMargin, + columnSpacing: columnSpacing ?? this.columnSpacing, + dividerThickness: dividerThickness ?? this.dividerThickness, + checkboxHorizontalMargin: checkboxHorizontalMargin ?? this.checkboxHorizontalMargin, + headingCellCursor: headingCellCursor ?? this.headingCellCursor, + dataRowCursor: dataRowCursor ?? this.dataRowCursor, + headingRowAlignment: headingRowAlignment ?? this.headingRowAlignment, + ); + } + + /// Linearly interpolate between two [DataTableThemeData]s. + /// + /// {@macro dart.ui.shadow.lerp} + static DataTableThemeData lerp(DataTableThemeData a, DataTableThemeData b, double t) { + if (identical(a, b)) { + return a; + } + return DataTableThemeData( + decoration: Decoration.lerp(a.decoration, b.decoration, t), + dataRowColor: WidgetStateProperty.lerp<Color?>(a.dataRowColor, b.dataRowColor, t, Color.lerp), + dataRowMinHeight: lerpDouble(a.dataRowMinHeight, b.dataRowMinHeight, t), + dataRowMaxHeight: lerpDouble(a.dataRowMaxHeight, b.dataRowMaxHeight, t), + dataTextStyle: TextStyle.lerp(a.dataTextStyle, b.dataTextStyle, t), + headingRowColor: WidgetStateProperty.lerp<Color?>( + a.headingRowColor, + b.headingRowColor, + t, + Color.lerp, + ), + headingRowHeight: lerpDouble(a.headingRowHeight, b.headingRowHeight, t), + headingTextStyle: TextStyle.lerp(a.headingTextStyle, b.headingTextStyle, t), + horizontalMargin: lerpDouble(a.horizontalMargin, b.horizontalMargin, t), + columnSpacing: lerpDouble(a.columnSpacing, b.columnSpacing, t), + dividerThickness: lerpDouble(a.dividerThickness, b.dividerThickness, t), + checkboxHorizontalMargin: lerpDouble( + a.checkboxHorizontalMargin, + b.checkboxHorizontalMargin, + t, + ), + headingCellCursor: t < 0.5 ? a.headingCellCursor : b.headingCellCursor, + dataRowCursor: t < 0.5 ? a.dataRowCursor : b.dataRowCursor, + headingRowAlignment: t < 0.5 ? a.headingRowAlignment : b.headingRowAlignment, + ); + } + + @override + int get hashCode => Object.hash( + decoration, + dataRowColor, + dataRowMinHeight, + dataRowMaxHeight, + dataTextStyle, + headingRowColor, + headingRowHeight, + headingTextStyle, + horizontalMargin, + columnSpacing, + dividerThickness, + checkboxHorizontalMargin, + headingCellCursor, + dataRowCursor, + headingRowAlignment, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is DataTableThemeData && + other.decoration == decoration && + other.dataRowColor == dataRowColor && + other.dataRowMinHeight == dataRowMinHeight && + other.dataRowMaxHeight == dataRowMaxHeight && + other.dataTextStyle == dataTextStyle && + other.headingRowColor == headingRowColor && + other.headingRowHeight == headingRowHeight && + other.headingTextStyle == headingTextStyle && + other.horizontalMargin == horizontalMargin && + other.columnSpacing == columnSpacing && + other.dividerThickness == dividerThickness && + other.checkboxHorizontalMargin == checkboxHorizontalMargin && + other.headingCellCursor == headingCellCursor && + other.dataRowCursor == dataRowCursor && + other.headingRowAlignment == headingRowAlignment; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<Decoration>('decoration', decoration, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'dataRowColor', + dataRowColor, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('dataRowMinHeight', dataRowMinHeight, defaultValue: null)); + properties.add(DoubleProperty('dataRowMaxHeight', dataRowMaxHeight, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('dataTextStyle', dataTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'headingRowColor', + headingRowColor, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('headingRowHeight', headingRowHeight, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('headingTextStyle', headingTextStyle, defaultValue: null), + ); + properties.add(DoubleProperty('horizontalMargin', horizontalMargin, defaultValue: null)); + properties.add(DoubleProperty('columnSpacing', columnSpacing, defaultValue: null)); + properties.add(DoubleProperty('dividerThickness', dividerThickness, defaultValue: null)); + properties.add( + DoubleProperty('checkboxHorizontalMargin', checkboxHorizontalMargin, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>?>( + 'headingCellCursor', + headingCellCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>?>( + 'dataRowCursor', + dataRowCursor, + defaultValue: null, + ), + ); + properties.add( + EnumProperty<MainAxisAlignment>( + 'headingRowAlignment', + headingRowAlignment, + defaultValue: null, + ), + ); + } +} + +/// Applies a data table theme to descendant [DataTable] widgets. +/// +/// Descendant widgets obtain the current theme's [DataTableTheme] object using +/// [DataTableTheme.of]. When a widget uses [DataTableTheme.of], it is +/// automatically rebuilt if the theme later changes. +/// +/// A data table theme can be specified as part of the overall Material +/// theme using [ThemeData.dataTableTheme]. +/// +/// See also: +/// +/// * [DataTableThemeData], which describes the actual configuration +/// of a data table theme. +class DataTableTheme extends InheritedWidget { + /// Constructs a data table theme that configures all descendant + /// [DataTable] widgets. + const DataTableTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [DataTable] widgets. + final DataTableThemeData data; + + /// Returns the configuration [data] from the closest + /// [DataTableTheme] ancestor. If there is no ancestor, it returns + /// [ThemeData.dataTableTheme]. Applications can assume that the + /// returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DataTableThemeData theme = DataTableTheme.of(context); + /// ``` + static DataTableThemeData of(BuildContext context) { + final DataTableTheme? dataTableTheme = context + .dependOnInheritedWidgetOfExactType<DataTableTheme>(); + return dataTableTheme?.data ?? Theme.of(context).dataTableTheme; + } + + @override + bool updateShouldNotify(DataTableTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/date.dart b/packages/material_ui/lib/src/date.dart new file mode 100644 index 000000000000..6ee56e331db9 --- /dev/null +++ b/packages/material_ui/lib/src/date.dart @@ -0,0 +1,479 @@ +// 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. + +/// @docImport 'calendar_date_picker.dart'; +/// @docImport 'date_picker.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'material_localizations.dart'; + +/// Controls the calendar system used in the date picker. +/// +/// A [CalendarDelegate] defines how dates are interpreted, formatted, and +/// navigated within the picker. Different calendar systems (e.g., Gregorian, +/// Nepali, Hijri, Buddhist) can be supported by providing custom implementations. +/// +/// {@tool dartpad} +/// This example demonstrates how a [CalendarDelegate] is used to implement a +/// custom calendar system in the date picker. +/// +/// ** See code in examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [GregorianCalendarDelegate], the default implementation for the Gregorian calendar. +/// * [CalendarDatePicker], which uses this delegate to manage calendar-specific behavior. +abstract class CalendarDelegate<T extends DateTime> { + /// Creates a calendar delegate. + const CalendarDelegate(); + + /// Returns a [DateTime] representing the current date and time. + T now(); + + /// {@macro flutter.material.date.dateOnly} + T dateOnly(T date); + + /// {@macro flutter.material.date.datesOnly} + DateTimeRange<T> datesOnly(DateTimeRange<T> range) { + return DateTimeRange<T>(start: dateOnly(range.start), end: dateOnly(range.end)); + } + + /// {@macro flutter.material.date.isSameDay} + bool isSameDay(T? dateA, T? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month && dateA?.day == dateB?.day; + } + + /// {@macro flutter.material.date.isSameMonth} + bool isSameMonth(T? dateA, T? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month; + } + + /// {@macro flutter.material.date.monthDelta} + int monthDelta(T startDate, T endDate); + + /// {@macro flutter.material.date.addMonthsToMonthDate} + T addMonthsToMonthDate(T monthDate, int monthsToAdd); + + /// {@macro flutter.material.date.addDaysToDate} + T addDaysToDate(T date, int days); + + /// {@macro flutter.material.date.firstDayOffset} + int firstDayOffset(int year, int month, MaterialLocalizations localizations); + + /// Returns the number of days in a month, according to the calendar system. + int getDaysInMonth(int year, int month); + + /// Returns a [DateTime] with the given [year] and [month]. + T getMonth(int year, int month); + + /// Returns a [DateTime] with the given [year], [month], and [day]. + T getDay(int year, int month, int day); + + /// Formats the month and the year of the given [date]. + /// + /// The returned string does not contain the day of the month. This appears + /// in the date picker invoked using [showDatePicker]. + String formatMonthYear(T date, MaterialLocalizations localizations); + + /// Full unabbreviated year format, e.g. 2017 rather than 17. + String formatYear(int year, MaterialLocalizations localizations) { + return localizations.formatYear(DateTime(year)); + } + + /// Formats the date using a medium-width format. + /// + /// Abbreviates month and days of week. This appears in the header of the date + /// picker invoked using [showDatePicker]. + /// + /// Examples: + /// + /// - US English: Wed, Sep 27 + /// - Russian: ср, сент. 27 + String formatMediumDate(T date, MaterialLocalizations localizations); + + /// Formats the month and day of the given [date]. + /// + /// Examples: + /// + /// - US English: Feb 21 + /// - Russian: 21 февр. + String formatShortMonthDay(T date, MaterialLocalizations localizations); + + /// Formats the date using a short-width format. + /// + /// Includes the abbreviation of the month, the day and year. + /// + /// Examples: + /// + /// - US English: Feb 21, 2019 + /// - Russian: 21 февр. 2019 г. + String formatShortDate(T date, MaterialLocalizations localizations); + + /// Formats day of week, month, day of month and year in a long-width format. + /// + /// Does not abbreviate names. Appears in spoken announcements of the date + /// picker invoked using [showDatePicker], when accessibility mode is on. + /// + /// Examples: + /// + /// - US English: Wednesday, September 27, 2017 + /// - Russian: Среда, Сентябрь 27, 2017 + String formatFullDate(T date, MaterialLocalizations localizations); + + /// Formats the date in a compact format. + /// + /// Usually just the numeric values for the for day, month and year are used. + /// + /// Examples: + /// + /// - US English: 02/21/2019 + /// - Russian: 21.02.2019 + /// + /// See also: + /// * [parseCompactDate], which will convert a compact date string to a [DateTime]. + String formatCompactDate(T date, MaterialLocalizations localizations); + + /// Converts the given compact date formatted string into a [DateTime]. + /// + /// The format of the string must be a valid compact date format for the + /// given locale. If the text doesn't represent a valid date, `null` will be + /// returned. + /// + /// See also: + /// * [formatCompactDate], which will convert a [DateTime] into a string in the compact format. + T? parseCompactDate(String? inputString, MaterialLocalizations localizations); + + /// The help text used on an empty [InputDatePickerFormField] to indicate + /// to the user the date format being asked for. + String dateHelpText(MaterialLocalizations localizations); +} + +/// A [CalendarDelegate] implementation for the Gregorian calendar system. +/// +/// The Gregorian calendar is the most widely used civil calendar worldwide. +/// This delegate provides standard date interpretation, formatting, and +/// navigation based on the Gregorian system. +/// +/// This delegate is the default calendar system for [CalendarDatePicker]. +/// +/// See also: +/// * [CalendarDelegate], the base class for defining custom calendars. +/// * [CalendarDatePicker], which uses this delegate for date selection. +class GregorianCalendarDelegate extends CalendarDelegate<DateTime> { + /// Creates a calendar delegate that uses the Gregorian calendar and the + /// conventions of the current [MaterialLocalizations]. + const GregorianCalendarDelegate(); + + @override + DateTime now() => DateTime.now(); + + @override + DateTime dateOnly(DateTime date) => DateUtils.dateOnly(date); + + @override + int monthDelta(DateTime startDate, DateTime endDate) => DateUtils.monthDelta(startDate, endDate); + + @override + DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateUtils.addMonthsToMonthDate(monthDate, monthsToAdd); + } + + @override + DateTime addDaysToDate(DateTime date, int days) => DateUtils.addDaysToDate(date, days); + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return DateUtils.firstDayOffset(year, month, localizations); + } + + /// {@macro flutter.material.date.getDaysInMonth} + @override + int getDaysInMonth(int year, int month) => DateUtils.getDaysInMonth(year, month); + + @override + DateTime getMonth(int year, int month) => DateTime(year, month); + + @override + DateTime getDay(int year, int month, int day) => DateTime(year, month, day); + + @override + String formatMonthYear(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMonthYear(date); + } + + @override + String formatMediumDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMediumDate(date); + } + + @override + String formatShortMonthDay(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortMonthDay(date); + } + + @override + String formatShortDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortDate(date); + } + + @override + String formatFullDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatFullDate(date); + } + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatCompactDate(date); + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + return localizations.parseCompactDate(inputString); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return localizations.dateHelpText; + } +} + +/// Utility functions for working with dates. +abstract final class DateUtils { + /// {@template flutter.material.date.dateOnly} + /// Returns a [DateTime] with the date of the original, but time set to + /// midnight. + /// {@endtemplate} + static DateTime dateOnly(DateTime date) { + return DateTime(date.year, date.month, date.day); + } + + /// {@template flutter.material.date.datesOnly} + /// Returns a [DateTimeRange] with the dates of the original, but with times + /// set to midnight. + /// + /// See also: + /// * [dateOnly], which does the same thing for a single date. + /// {@endtemplate} + static DateTimeRange datesOnly(DateTimeRange range) { + return DateTimeRange(start: dateOnly(range.start), end: dateOnly(range.end)); + } + + /// {@template flutter.material.date.isSameDay} + /// Returns true if the two [DateTime] objects have the same day, month, and + /// year, or are both null. + /// {@endtemplate} + static bool isSameDay(DateTime? dateA, DateTime? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month && dateA?.day == dateB?.day; + } + + /// {@template flutter.material.date.isSameMonth} + /// Returns true if the two [DateTime] objects have the same month and + /// year, or are both null. + /// {@endtemplate} + static bool isSameMonth(DateTime? dateA, DateTime? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month; + } + + /// {@template flutter.material.date.monthDelta} + /// Determines the number of months between two [DateTime] objects. + /// + /// For example: + /// + /// ```dart + /// DateTime date1 = DateTime(2019, 6, 15); + /// DateTime date2 = DateTime(2020, 1, 15); + /// int delta = DateUtils.monthDelta(date1, date2); + /// ``` + /// + /// The value for `delta` would be `7`. + /// {@endtemplate} + static int monthDelta(DateTime startDate, DateTime endDate) { + return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month; + } + + /// {@template flutter.material.date.addMonthsToMonthDate} + /// Returns a [DateTime] that is [monthDate] with the added number + /// of months and the day set to 1 and time set to midnight. + /// + /// For example: + /// + /// ```dart + /// DateTime date = DateTime(2019, 1, 15); + /// DateTime futureDate = DateUtils.addMonthsToMonthDate(date, 3); + /// ``` + /// + /// `date` would be January 15, 2019. + /// `futureDate` would be April 1, 2019 since it adds 3 months. + /// {@endtemplate} + static DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateTime(monthDate.year, monthDate.month + monthsToAdd); + } + + /// {@template flutter.material.date.addDaysToDate} + /// Returns a [DateTime] with the added number of days and time set to + /// midnight. + /// {@endtemplate} + static DateTime addDaysToDate(DateTime date, int days) { + return DateTime(date.year, date.month, date.day + days); + } + + /// {@template flutter.material.date.firstDayOffset} + /// Computes the offset from the first day of the week that the first day of + /// the [month] falls on. + /// + /// For example, September 1, 2017 falls on a Friday, which in the calendar + /// localized for United States English appears as: + /// + /// S M T W T F S + /// _ _ _ _ _ 1 2 + /// + /// The offset for the first day of the months is the number of leading blanks + /// in the calendar, i.e. 5. + /// + /// The same date localized for the Russian calendar has a different offset, + /// because the first day of week is Monday rather than Sunday: + /// + /// M T W T F S S + /// _ _ _ _ 1 2 3 + /// + /// So the offset is 4, rather than 5. + /// + /// This code consolidates the following: + /// + /// - [DateTime.weekday] provides a 1-based index into days of week, with 1 + /// falling on Monday. + /// - [MaterialLocalizations.firstDayOfWeekIndex] provides a 0-based index + /// into the [MaterialLocalizations.narrowWeekdays] list. + /// - [MaterialLocalizations.narrowWeekdays] list provides localized names of + /// days of week, always starting with Sunday and ending with Saturday. + /// {@endtemplate} + static int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + // 0-based day of week for the month and year, with 0 representing Monday. + final int weekdayFromMonday = DateTime(year, month).weekday - 1; + + // 0-based start of week depending on the locale, with 0 representing Sunday. + int firstDayOfWeekIndex = localizations.firstDayOfWeekIndex; + + // firstDayOfWeekIndex recomputed to be Monday-based, in order to compare with + // weekdayFromMonday. + firstDayOfWeekIndex = (firstDayOfWeekIndex - 1) % 7; + + // Number of days between the first day of week appearing on the calendar, + // and the day corresponding to the first of the month. + return (weekdayFromMonday - firstDayOfWeekIndex) % 7; + } + + /// {@template flutter.material.date.getDaysInMonth} + /// Returns the number of days in a month, according to the proleptic + /// Gregorian calendar. + /// + /// This applies the leap year logic introduced by the Gregorian reforms of + /// 1582. It will not give valid results for dates prior to that time. + /// {@endtemplate} + static int getDaysInMonth(int year, int month) { + if (month == DateTime.february) { + final bool isLeapYear = (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0); + return isLeapYear ? 29 : 28; + } + const daysInMonth = <int>[31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return daysInMonth[month - 1]; + } +} + +/// Mode of date entry method for the date picker dialog. +/// +/// In [calendar] mode, a calendar grid is displayed and the user taps the +/// day they wish to select. In [input] mode, a [TextField] is displayed and +/// the user types in the date they wish to select. +/// +/// [calendarOnly] and [inputOnly] are variants of the above that don't +/// allow the user to change to the mode. +/// +/// See also: +/// +/// * [showDatePicker] and [showDateRangePicker], which use this to control +/// the initial entry mode of their dialogs. +enum DatePickerEntryMode { + /// User picks a date from calendar grid. Can switch to [input] by activating + /// a mode button in the dialog. + calendar, + + /// User can input the date by typing it into a text field. + /// + /// Can switch to [calendar] by activating a mode button in the dialog. + input, + + /// User can only pick a date from calendar grid. + /// + /// There is no user interface to switch to another mode. + calendarOnly, + + /// User can only input the date by typing it into a text field. + /// + /// There is no user interface to switch to another mode. + inputOnly, +} + +/// Initial display of a calendar date picker. +/// +/// Either a grid of available years or a monthly calendar. +/// +/// See also: +/// +/// * [showDatePicker], which shows a dialog that contains a Material Design +/// date picker. +/// * [CalendarDatePicker], widget which implements the Material Design date picker. +enum DatePickerMode { + /// Choosing a month and day. + day, + + /// Choosing a year. + year, +} + +/// Encapsulates a start and end [DateTime] that represent the range of dates. +/// +/// The range includes the [start] and [end] dates. The [start] and [end] dates +/// may be equal to indicate a date range of a single day. The [start] date must +/// not be after the [end] date. +/// +/// See also: +/// * [showDateRangePicker], which displays a dialog that allows the user to +/// select a date range. +@immutable +@optionalTypeArgs +class DateTimeRange<T extends DateTime> { + /// Creates a date range for the given start and end [DateTime]. + DateTimeRange({required this.start, required this.end}) : assert(!start.isAfter(end)); + + /// The start of the range of dates. + final T start; + + /// The end of the range of dates. + final T end; + + /// Returns a [Duration] of the time between [start] and [end]. + /// + /// See [DateTime.difference] for more details. + Duration get duration => end.difference(start); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is DateTimeRange && other.start == start && other.end == end; + } + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => '$start - $end'; +} diff --git a/packages/material_ui/lib/src/date_picker.dart b/packages/material_ui/lib/src/date_picker.dart new file mode 100644 index 000000000000..dcb8ae5405aa --- /dev/null +++ b/packages/material_ui/lib/src/date_picker.dart @@ -0,0 +1,3491 @@ +// 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. + +/// @docImport 'dart:ui'; +/// +/// @docImport 'app.dart'; +/// @docImport 'time_picker.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'app_bar.dart'; +import 'back_button.dart'; +import 'button_style.dart'; +import 'calendar_date_picker.dart'; +import 'color_scheme.dart'; +import 'date.dart'; +import 'date_picker_theme.dart'; +import 'debug.dart'; +import 'dialog.dart'; +import 'dialog_theme.dart'; +import 'divider.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'input_border.dart'; +import 'input_date_picker_form_field.dart'; +import 'input_decorator.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'scaffold.dart'; +import 'text_button.dart'; +import 'text_field.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// The M3 sizes are coming from the tokens, but are hand coded, +// as the current token DB does not contain landscape versions. +const Size _calendarPortraitDialogSizeM2 = Size(330.0, 518.0); +const Size _calendarPortraitDialogSizeM3 = Size(360.0, 568.0); +const Size _calendarLandscapeDialogSize = Size(496.0, 346.0); +const Size _inputPortraitDialogSizeM2 = Size(330.0, 270.0); +const Size _inputPortraitDialogSizeM3 = Size(328.0, 270.0); +const Size _inputLandscapeDialogSize = Size(496, 160.0); +const Size _inputRangeLandscapeDialogSize = Size(496, 164.0); +const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200); +const double _inputFormPortraitHeight = 98.0; +const double _inputFormLandscapeHeight = 108.0; + +// 3.0 is the maximum scale factor on mobile phones. As of 07/30/24, iOS goes up +// to a max of 3.0 text scale factor, and Android goes up to 2.0. This is the +// default used for non-range date pickers. This default is changed to a lower +// value at different parts of the date pickers depending on content, and device +// orientation. +const double _kMaxTextScaleFactor = 3.0; + +// The max scale factor for the date range pickers. +const double _kMaxRangeTextScaleFactor = 1.3; + +// The max text scale factor for the header. This is lower than the default as +// the title text already starts at a large size. +const double _kMaxHeaderTextScaleFactor = 1.6; + +// The entry button shares a line with the header text, so there is less room to +// scale up. +const double _kMaxHeaderWithEntryTextScaleFactor = 1.4; + +const double _kMaxHelpPortraitTextScaleFactor = 1.6; +const double _kMaxHelpLandscapeTextScaleFactor = 1.4; + +// 14 is a common font size used to compute the effective text scale. +const double _fontSizeToScale = 14.0; + +/// Shows a dialog containing a Material Design date picker. +/// +/// The returned [Future] resolves to the date selected by the user when the +/// user confirms the dialog. If the user cancels the dialog, null is returned. +/// +/// When the date picker is first displayed, if [initialDate] is not null, it +/// will show the month of [initialDate], with [initialDate] selected. Otherwise +/// it will show the [currentDate]'s month. +/// +/// The [firstDate] is the earliest allowable date. The [lastDate] is the latest +/// allowable date. If [initialDate] is not null, it must either fall between +/// these dates, or be equal to one of them. For each of these [DateTime] +/// parameters, only their dates are considered. Their time fields are ignored. +/// They must all be non-null. +/// +/// The [currentDate] represents the current day (i.e. today). This +/// date will be highlighted in the day grid. If null, the date of +/// [DateTime.now] will be used. +/// +/// An optional [initialEntryMode] argument can be used to display the date +/// picker in the [DatePickerEntryMode.calendar] (a calendar month grid) +/// or [DatePickerEntryMode.input] (a text input field) mode. +/// It defaults to [DatePickerEntryMode.calendar]. +/// +/// {@template flutter.material.date_picker.switchToInputEntryModeIcon} +/// An optional [switchToInputEntryModeIcon] argument can be used to +/// display a custom Icon in the corner of the dialog +/// when [DatePickerEntryMode] is [DatePickerEntryMode.calendar]. Clicking on +/// icon changes the [DatePickerEntryMode] to [DatePickerEntryMode.input]. +/// If null, `Icon(useMaterial3 ? Icons.edit_outlined : Icons.edit)` is used. +/// {@endtemplate} +/// +/// {@template flutter.material.date_picker.switchToCalendarEntryModeIcon} +/// An optional [switchToCalendarEntryModeIcon] argument can be used to +/// display a custom Icon in the corner of the dialog +/// when [DatePickerEntryMode] is [DatePickerEntryMode.input]. Clicking on +/// icon changes the [DatePickerEntryMode] to [DatePickerEntryMode.calendar]. +/// If null, `Icon(Icons.calendar_today)` is used. +/// {@endtemplate} +/// +/// An optional [selectableDayPredicate] function can be passed in to only allow +/// certain days for selection. If provided, only the days that +/// [selectableDayPredicate] returns true for will be selectable. For example, +/// this can be used to only allow weekdays for selection. If provided, it must +/// return true for [initialDate]. +/// +/// {@macro flutter.material.calendar_date_picker.calendarDelegate} +/// +/// The following optional string parameters allow you to override the default +/// text used for various parts of the dialog: +/// +/// * [helpText], label displayed at the top of the dialog. +/// * [cancelText], label on the cancel button. +/// * [confirmText], label on the ok button. +/// * [errorFormatText], message used when the input text isn't in a proper date format. +/// * [errorInvalidText], message used when the input text isn't a selectable date. +/// * [fieldHintText], text used to prompt the user when no text has been entered in the field. +/// * [fieldLabelText], label for the date text input field. +/// +/// An optional [locale] argument can be used to set the locale for the date +/// picker. It defaults to the ambient locale provided by [Localizations]. +/// +/// An optional [textDirection] argument can be used to set the text direction +/// ([TextDirection.ltr] or [TextDirection.rtl]) for the date picker. It +/// defaults to the ambient text direction provided by [Directionality]. If both +/// [locale] and [textDirection] are non-null, [textDirection] overrides the +/// direction chosen for the [locale]. +/// +/// The [context], [barrierDismissible], [barrierColor], [barrierLabel], +/// [useRootNavigator] and [routeSettings] arguments are passed to [showDialog], +/// the documentation for which discusses how it is used. +/// +/// The [builder] parameter can be used to wrap the dialog widget +/// to add inherited widgets like [Theme]. +/// +/// An optional [initialDatePickerMode] argument can be used to have the +/// calendar date picker initially appear in the [DatePickerMode.year] or +/// [DatePickerMode.day] mode. It defaults to [DatePickerMode.day]. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a basic date picker. +/// Tapping the button displays a date picker which returns the selected date. +/// +/// ** See code in examples/api/lib/material/date_picker/show_date_picker.1.dart ** +/// {@end-tool} +/// +/// ### State Restoration +/// +/// Using this method will not enable state restoration for the date picker. +/// In order to enable state restoration for a date picker, use +/// [Navigator.restorablePush] or [Navigator.restorablePushNamed] with +/// [DatePickerDialog]. +/// +/// For more information about state restoration, see [RestorationManager]. +/// +/// {@macro flutter.widgets.RestorationManager} +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a restorable Material date picker. +/// This is accomplished by enabling state restoration by specifying +/// [MaterialApp.restorationScopeId] and using [Navigator.restorablePush] to +/// push [DatePickerDialog] when the button is tapped. +/// +/// ** See code in examples/api/lib/material/date_picker/show_date_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [showDateRangePicker], which shows a Material Design date range picker +/// used to select a range of dates. +/// * [CalendarDatePicker], which provides the calendar grid used by the date picker dialog. +/// * [InputDatePickerFormField], which provides a text input field for entering dates. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * [showTimePicker], which shows a dialog that contains a Material Design time picker. +Future<DateTime?> showDatePicker({ + required BuildContext context, + DateTime? initialDate, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar, + SelectableDayPredicate? selectableDayPredicate, + String? helpText, + String? cancelText, + String? confirmText, + Locale? locale, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, + TextDirection? textDirection, + TransitionBuilder? builder, + DatePickerMode initialDatePickerMode = DatePickerMode.day, + String? errorFormatText, + String? errorInvalidText, + String? fieldHintText, + String? fieldLabelText, + TextInputType? keyboardType, + Offset? anchorPoint, + final ValueChanged<DatePickerEntryMode>? onDatePickerModeChange, + final Icon? switchToInputEntryModeIcon, + final Icon? switchToCalendarEntryModeIcon, + final CalendarDelegate<DateTime> calendarDelegate = const GregorianCalendarDelegate(), +}) async { + initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate); + firstDate = calendarDelegate.dateOnly(firstDate); + lastDate = calendarDelegate.dateOnly(lastDate); + assert( + !lastDate.isBefore(firstDate), + 'lastDate $lastDate must be on or after firstDate $firstDate.', + ); + assert( + initialDate == null || !initialDate.isBefore(firstDate), + 'initialDate $initialDate must be on or after firstDate $firstDate.', + ); + assert( + initialDate == null || !initialDate.isAfter(lastDate), + 'initialDate $initialDate must be on or before lastDate $lastDate.', + ); + assert( + selectableDayPredicate == null || initialDate == null || selectableDayPredicate(initialDate), + 'Provided initialDate $initialDate must satisfy provided selectableDayPredicate.', + ); + assert(debugCheckHasMaterialLocalizations(context)); + + Widget dialog = DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + initialEntryMode: initialEntryMode, + selectableDayPredicate: selectableDayPredicate, + helpText: helpText, + cancelText: cancelText, + confirmText: confirmText, + initialCalendarMode: initialDatePickerMode, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + fieldHintText: fieldHintText, + fieldLabelText: fieldLabelText, + keyboardType: keyboardType, + onDatePickerModeChange: onDatePickerModeChange, + switchToInputEntryModeIcon: switchToInputEntryModeIcon, + switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + calendarDelegate: calendarDelegate, + ); + + if (textDirection != null) { + dialog = Directionality(textDirection: textDirection, child: dialog); + } + + if (locale != null) { + dialog = Localizations.override(context: context, locale: locale, child: dialog); + } else { + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + if (datePickerTheme.locale != null) { + dialog = Localizations.override( + context: context, + locale: datePickerTheme.locale, + child: dialog, + ); + } + } + + return showDialog<DateTime>( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + builder: (BuildContext context) { + return builder == null ? dialog : builder(context, dialog); + }, + anchorPoint: anchorPoint, + ); +} + +/// A Material-style date picker dialog. +/// +/// It is used internally by [showDatePicker] or can be directly pushed +/// onto the [Navigator] stack to enable state restoration. See +/// [showDatePicker] for a state restoration app example. +/// +/// See also: +/// +/// * [showDatePicker], which is a way to display the date picker. +class DatePickerDialog extends StatefulWidget { + /// A Material-style date picker dialog. + DatePickerDialog({ + super.key, + DateTime? initialDate, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + this.initialEntryMode = DatePickerEntryMode.calendar, + this.selectableDayPredicate, + this.cancelText, + this.confirmText, + this.helpText, + this.initialCalendarMode = DatePickerMode.day, + this.errorFormatText, + this.errorInvalidText, + this.fieldHintText, + this.fieldLabelText, + this.keyboardType, + this.restorationId, + this.onDatePickerModeChange, + this.switchToInputEntryModeIcon, + this.switchToCalendarEntryModeIcon, + this.insetPadding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0), + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { + assert( + !this.lastDate.isBefore(this.firstDate), + 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + initialDate == null || !this.initialDate!.isBefore(this.firstDate), + 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + initialDate == null || !this.initialDate!.isAfter(this.lastDate), + 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.', + ); + assert( + selectableDayPredicate == null || + initialDate == null || + selectableDayPredicate!(this.initialDate!), + 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate', + ); + } + + /// The initially selected [DateTime] that the picker should display. + /// + /// If this is null, there is no selected date. A date must be selected to + /// submit the dialog. + final DateTime? initialDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// The [DateTime] representing today. It will be highlighted in the day grid. + final DateTime currentDate; + + /// The initial mode of date entry method for the date picker dialog. + /// + /// See [DatePickerEntryMode] for more details on the different data entry + /// modes available. + final DatePickerEntryMode initialEntryMode; + + /// Function to provide full control over which [DateTime] can be selected. + final SelectableDayPredicate? selectableDayPredicate; + + /// The text that is displayed on the cancel button. + final String? cancelText; + + /// The text that is displayed on the confirm button. + final String? confirmText; + + /// The text that is displayed at the top of the header. + /// + /// This is used to indicate to the user what they are selecting a date for. + final String? helpText; + + /// The initial display of the calendar picker. + final DatePickerMode initialCalendarMode; + + /// The error text displayed if the entered date is not in the correct format. + final String? errorFormatText; + + /// The error text displayed if the date is not valid. + /// + /// A date is not valid if it is earlier than [firstDate], later than + /// [lastDate], or doesn't pass the [selectableDayPredicate]. + final String? errorInvalidText; + + /// The hint text displayed in the [TextField]. + /// + /// If this is null, it will default to the date format string. For example, + /// 'mm/dd/yyyy' for en_US. + final String? fieldHintText; + + /// The label text displayed in the [TextField]. + /// + /// If this is null, it will default to the words representing the date format + /// string. For example, 'Month, Day, Year' for en_US. + final String? fieldLabelText; + + /// {@template flutter.material.datePickerDialog} + /// The keyboard type of the [TextField]. + /// + /// If this is null, it will default to [TextInputType.datetime] + /// {@endtemplate} + final TextInputType? keyboardType; + + /// Restoration ID to save and restore the state of the [DatePickerDialog]. + /// + /// If it is non-null, the date picker will persist and restore the + /// date selected on the dialog. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// Called when the [DatePickerDialog] is toggled between + /// [DatePickerEntryMode.calendar],[DatePickerEntryMode.input]. + /// + /// An example of how this callback might be used is an app that saves the + /// user's preferred entry mode and uses it to initialize the + /// `initialEntryMode` parameter the next time the date picker is shown. + final ValueChanged<DatePickerEntryMode>? onDatePickerModeChange; + + /// {@macro flutter.material.date_picker.switchToInputEntryModeIcon} + final Icon? switchToInputEntryModeIcon; + + /// {@macro flutter.material.date_picker.switchToCalendarEntryModeIcon} + final Icon? switchToCalendarEntryModeIcon; + + /// The amount of padding added to [MediaQueryData.viewInsets] on the outside + /// of the dialog. This defines the minimum space between the screen's edges + /// and the dialog. + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)`. + final EdgeInsets insetPadding; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<DatePickerDialog> createState() => _DatePickerDialogState(); +} + +class _DatePickerDialogState extends State<DatePickerDialog> with RestorationMixin { + late final RestorableDateTimeN _selectedDate = RestorableDateTimeN(widget.initialDate); + late final _RestorableDatePickerEntryMode _entryMode = _RestorableDatePickerEntryMode( + widget.initialEntryMode, + ); + final _RestorableAutovalidateMode _autovalidateMode = _RestorableAutovalidateMode( + AutovalidateMode.disabled, + ); + + @override + void dispose() { + _selectedDate.dispose(); + _entryMode.dispose(); + _autovalidateMode.dispose(); + super.dispose(); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedDate, 'selected_date'); + registerForRestoration(_autovalidateMode, 'autovalidateMode'); + registerForRestoration(_entryMode, 'calendar_entry_mode'); + } + + final GlobalKey _calendarPickerKey = GlobalKey(); + final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); + + void _handleOk() { + if (_entryMode.value == DatePickerEntryMode.input || + _entryMode.value == DatePickerEntryMode.inputOnly) { + final FormState form = _formKey.currentState!; + if (!form.validate()) { + setState(() => _autovalidateMode.value = AutovalidateMode.always); + return; + } + form.save(); + } + Navigator.pop(context, _selectedDate.value); + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleOnDatePickerModeChange() { + widget.onDatePickerModeChange?.call(_entryMode.value); + } + + void _handleEntryModeToggle() { + setState(() { + switch (_entryMode.value) { + case DatePickerEntryMode.calendar: + _autovalidateMode.value = AutovalidateMode.disabled; + _entryMode.value = DatePickerEntryMode.input; + _handleOnDatePickerModeChange(); + case DatePickerEntryMode.input: + _formKey.currentState!.save(); + _entryMode.value = DatePickerEntryMode.calendar; + _handleOnDatePickerModeChange(); + case DatePickerEntryMode.calendarOnly: + case DatePickerEntryMode.inputOnly: + assert(false, 'Can not change entry mode from ${_entryMode.value}'); + } + }); + } + + void _handleDateChanged(DateTime date) { + setState(() => _selectedDate.value = date); + } + + Size _dialogSize(BuildContext context) { + final bool useMaterial3 = Theme.of(context).useMaterial3; + final bool isCalendar = switch (_entryMode.value) { + DatePickerEntryMode.calendar || DatePickerEntryMode.calendarOnly => true, + DatePickerEntryMode.input || DatePickerEntryMode.inputOnly => false, + }; + final Orientation orientation = MediaQuery.orientationOf(context); + + return switch ((isCalendar, orientation)) { + (true, Orientation.portrait) when useMaterial3 => _calendarPortraitDialogSizeM3, + (false, Orientation.portrait) when useMaterial3 => _inputPortraitDialogSizeM3, + (true, Orientation.portrait) => _calendarPortraitDialogSizeM2, + (false, Orientation.portrait) => _inputPortraitDialogSizeM2, + (true, Orientation.landscape) => _calendarLandscapeDialogSize, + (false, Orientation.landscape) => _inputLandscapeDialogSize, + }; + } + + static const Map<ShortcutActivator, Intent> _formShortcutMap = <ShortcutActivator, Intent>{ + // Pressing enter on the field will move focus to the next field or control. + SingleActivator(LogicalKeyboardKey.enter): NextFocusIntent(), + }; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final bool useMaterial3 = theme.useMaterial3; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final Orientation orientation = MediaQuery.orientationOf(context); + final isLandscapeOrientation = orientation == Orientation.landscape; + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final TextTheme textTheme = theme.textTheme; + + // There's no M3 spec for a landscape layout input (not calendar) + // date picker. To ensure that the date displayed in the input + // date picker's header fits in landscape mode, we override the M3 + // default here. + TextStyle? headlineStyle; + if (useMaterial3) { + headlineStyle = datePickerTheme.headerHeadlineStyle ?? defaults.headerHeadlineStyle; + switch (_entryMode.value) { + case DatePickerEntryMode.input: + case DatePickerEntryMode.inputOnly: + if (orientation == Orientation.landscape) { + headlineStyle = textTheme.headlineSmall; + } + case DatePickerEntryMode.calendar: + case DatePickerEntryMode.calendarOnly: + // M3 default is OK. + } + } else { + headlineStyle = isLandscapeOrientation ? textTheme.headlineSmall : textTheme.headlineMedium; + } + final Color? headerForegroundColor = + datePickerTheme.headerForegroundColor ?? defaults.headerForegroundColor; + headlineStyle = headlineStyle?.copyWith(color: headerForegroundColor); + + final Widget actions = ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52.0), + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: isLandscapeOrientation ? 1.6 : _kMaxTextScaleFactor, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: OverflowBar( + spacing: 8, + children: <Widget>[ + TextButton( + style: datePickerTheme.cancelButtonStyle ?? defaults.cancelButtonStyle, + onPressed: _handleCancel, + child: Text( + widget.cancelText ?? + (useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase()), + ), + ), + TextButton( + style: datePickerTheme.confirmButtonStyle ?? defaults.confirmButtonStyle, + onPressed: _handleOk, + child: Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), + ), + ), + ), + ); + + CalendarDatePicker calendarDatePicker() { + return CalendarDatePicker( + calendarDelegate: widget.calendarDelegate, + key: _calendarPickerKey, + initialDate: _selectedDate.value, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + currentDate: widget.currentDate, + onDateChanged: _handleDateChanged, + selectableDayPredicate: widget.selectableDayPredicate, + initialCalendarMode: widget.initialCalendarMode, + ); + } + + Form inputDatePicker() { + return Form( + key: _formKey, + autovalidateMode: _autovalidateMode.value, + child: SizedBox( + height: orientation == Orientation.portrait + ? _inputFormPortraitHeight + : _inputFormLandscapeHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Shortcuts( + shortcuts: _formShortcutMap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + Flexible( + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 2.0, + child: InputDatePickerFormField( + calendarDelegate: widget.calendarDelegate, + initialDate: _selectedDate.value, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onDateSubmitted: _handleDateChanged, + onDateSaved: _handleDateChanged, + selectableDayPredicate: widget.selectableDayPredicate, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldHintText: widget.fieldHintText, + fieldLabelText: widget.fieldLabelText, + keyboardType: widget.keyboardType, + autofocus: true, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + final Widget picker; + final Widget? entryModeButton; + switch (_entryMode.value) { + case DatePickerEntryMode.calendar: + picker = calendarDatePicker(); + entryModeButton = IconButton( + icon: + widget.switchToInputEntryModeIcon ?? + Icon(useMaterial3 ? Icons.edit_outlined : Icons.edit), + color: headerForegroundColor, + tooltip: localizations.inputDateModeButtonLabel, + onPressed: _handleEntryModeToggle, + ); + + case DatePickerEntryMode.calendarOnly: + picker = calendarDatePicker(); + entryModeButton = null; + + case DatePickerEntryMode.input: + picker = inputDatePicker(); + entryModeButton = IconButton( + icon: widget.switchToCalendarEntryModeIcon ?? const Icon(Icons.calendar_today), + color: headerForegroundColor, + tooltip: localizations.calendarModeButtonLabel, + onPressed: _handleEntryModeToggle, + ); + + case DatePickerEntryMode.inputOnly: + picker = inputDatePicker(); + entryModeButton = null; + } + + final Widget header = _DatePickerHeader( + helpText: + widget.helpText ?? + (useMaterial3 + ? localizations.datePickerHelpText + : localizations.datePickerHelpText.toUpperCase()), + titleText: _selectedDate.value == null + ? '' + : widget.calendarDelegate.formatMediumDate(_selectedDate.value!, localizations), + titleStyle: headlineStyle, + orientation: orientation, + isShort: orientation == Orientation.landscape, + entryModeButton: entryModeButton, + ); + + // Constrain the textScaleFactor to the largest supported value to prevent + // layout issues. + final double textScaleFactor = + MediaQuery.textScalerOf( + context, + ).clamp(maxScaleFactor: _kMaxTextScaleFactor).scale(_fontSizeToScale) / + _fontSizeToScale; + final Size dialogSize = _dialogSize(context) * textScaleFactor; + final DialogThemeData dialogTheme = theme.dialogTheme; + return Dialog( + backgroundColor: datePickerTheme.backgroundColor ?? defaults.backgroundColor, + elevation: useMaterial3 + ? datePickerTheme.elevation ?? defaults.elevation! + : datePickerTheme.elevation ?? dialogTheme.elevation ?? 24, + shadowColor: datePickerTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: datePickerTheme.surfaceTintColor ?? defaults.surfaceTintColor, + shape: useMaterial3 + ? datePickerTheme.shape ?? defaults.shape + : datePickerTheme.shape ?? dialogTheme.shape ?? defaults.shape, + insetPadding: widget.insetPadding, + clipBehavior: Clip.antiAlias, + child: AnimatedContainer( + width: dialogSize.width, + height: dialogSize.height, + duration: _dialogSizeAnimationDuration, + curve: Curves.easeIn, + child: MediaQuery.withClampedTextScaling( + // Constrain the textScaleFactor to the largest supported value to prevent + // layout issues. + maxScaleFactor: _kMaxTextScaleFactor, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Size portraitDialogSize = useMaterial3 + ? _inputPortraitDialogSizeM3 + : _inputPortraitDialogSizeM2; + // Make sure the portrait dialog can fit the contents comfortably when + // resized from the landscape dialog. + final bool isFullyPortrait = + constraints.maxHeight >= math.min(dialogSize.height, portraitDialogSize.height); + + switch (orientation) { + case Orientation.portrait: + final bool isInputMode = + _entryMode.value == DatePickerEntryMode.inputOnly || + _entryMode.value == DatePickerEntryMode.input; + // When the portrait dialog does not fit vertically, hide the header when the entry mode + // is input, or hide the picker when the entry mode is not input. + final bool showHeader = isFullyPortrait || !isInputMode; + final bool showPicker = isFullyPortrait || isInputMode; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + if (showHeader) header, + if (useMaterial3) Divider(height: 0, color: datePickerTheme.dividerColor), + if (showPicker) ...<Widget>[Expanded(child: picker), actions], + ], + ); + case Orientation.landscape: + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + header, + if (useMaterial3) + VerticalDivider(width: 0, color: datePickerTheme.dividerColor), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Expanded(child: picker), + actions, + ], + ), + ), + ], + ); + } + }, + ), + ), + ), + ); + } +} + +// A restorable [DatePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableDatePickerEntryMode extends RestorableValue<DatePickerEntryMode> { + _RestorableDatePickerEntryMode(DatePickerEntryMode defaultValue) : _defaultValue = defaultValue; + + final DatePickerEntryMode _defaultValue; + + @override + DatePickerEntryMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(DatePickerEntryMode? oldValue) { + assert(debugIsSerializableForRestoration(value.index)); + notifyListeners(); + } + + @override + DatePickerEntryMode fromPrimitives(Object? data) => DatePickerEntryMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [AutovalidateMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableAutovalidateMode extends RestorableValue<AutovalidateMode> { + _RestorableAutovalidateMode(AutovalidateMode defaultValue) : _defaultValue = defaultValue; + + final AutovalidateMode _defaultValue; + + @override + AutovalidateMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(AutovalidateMode? oldValue) { + assert(debugIsSerializableForRestoration(value.index)); + notifyListeners(); + } + + @override + AutovalidateMode fromPrimitives(Object? data) => AutovalidateMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +/// Re-usable widget that displays the selected date (in large font) and the +/// help text above it. +/// +/// These types include: +/// +/// * Single Date picker with calendar mode. +/// * Single Date picker with text input mode. +/// * Date Range picker with text input mode. +class _DatePickerHeader extends StatelessWidget { + /// Creates a header for use in a date picker dialog. + const _DatePickerHeader({ + required this.helpText, + required this.titleText, + this.titleSemanticsLabel, + required this.titleStyle, + required this.orientation, + this.isShort = false, + this.entryModeButton, + }); + + static const double _datePickerHeaderLandscapeWidth = 152.0; + static const double _datePickerHeaderPortraitHeight = 120.0; + static const double _headerPaddingLandscape = 16.0; + + /// The text that is displayed at the top of the header. + /// + /// This is used to indicate to the user what they are selecting a date for. + final String helpText; + + /// The text that is displayed at the center of the header. + final String titleText; + + /// The semantic label associated with the [titleText]. + final String? titleSemanticsLabel; + + /// The [TextStyle] that the title text is displayed with. + final TextStyle? titleStyle; + + /// The orientation is used to decide how to layout its children. + final Orientation orientation; + + /// Indicates the header is being displayed in a shorter/narrower context. + /// + /// This will be used to tighten up the space between the help text and date + /// text if `true`. Additionally, it will use a smaller typography style if + /// `true`. + /// + /// This is necessary for displaying the manual input mode in + /// landscape orientation, in order to account for the keyboard height. + final bool isShort; + + final Widget? entryModeButton; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final Color? backgroundColor = + datePickerTheme.headerBackgroundColor ?? defaults.headerBackgroundColor; + final Color? foregroundColor = + datePickerTheme.headerForegroundColor ?? defaults.headerForegroundColor; + final TextStyle? helpStyle = (datePickerTheme.headerHelpStyle ?? defaults.headerHelpStyle) + ?.copyWith(color: foregroundColor); + final double currentScale = + MediaQuery.textScalerOf(context).scale(_fontSizeToScale) / _fontSizeToScale; + final double maxHeaderTextScaleFactor = math.min( + currentScale, + entryModeButton != null ? _kMaxHeaderWithEntryTextScaleFactor : _kMaxHeaderTextScaleFactor, + ); + final double textScaleFactor = + MediaQuery.textScalerOf( + context, + ).clamp(maxScaleFactor: maxHeaderTextScaleFactor).scale(_fontSizeToScale) / + _fontSizeToScale; + final double scaledFontSize = MediaQuery.textScalerOf( + context, + ).scale(titleStyle?.fontSize ?? 32); + final headerScaleFactor = textScaleFactor > 1 ? textScaleFactor : 1.0; + + final help = Text( + helpText, + style: helpStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textScaler: MediaQuery.textScalerOf(context).clamp( + maxScaleFactor: math.min( + textScaleFactor, + orientation == Orientation.portrait + ? _kMaxHelpPortraitTextScaleFactor + : _kMaxHelpLandscapeTextScaleFactor, + ), + ), + ); + final title = Text( + titleText, + semanticsLabel: titleSemanticsLabel ?? titleText, + style: titleStyle, + maxLines: orientation == Orientation.portrait + ? (scaledFontSize > 70 ? 2 : 1) + : scaledFontSize > 40 + ? 3 + : 2, + overflow: TextOverflow.ellipsis, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: textScaleFactor), + ); + + final double fontScaleAdjustedHeaderHeight = headerScaleFactor > 1.3 + ? headerScaleFactor - 0.2 + : 1.0; + + switch (orientation) { + case Orientation.portrait: + return Semantics( + container: true, + child: SizedBox( + height: _datePickerHeaderPortraitHeight * fontScaleAdjustedHeaderHeight, + child: Material( + color: backgroundColor, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 24, end: 12, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + const SizedBox(height: 16), + help, + const Flexible(child: SizedBox(height: 38)), + Row( + children: <Widget>[ + Expanded(child: title), + if (entryModeButton != null) + Semantics(container: true, child: entryModeButton), + ], + ), + ], + ), + ), + ), + ), + ); + case Orientation.landscape: + return Semantics( + container: true, + child: SizedBox( + width: _datePickerHeaderLandscapeWidth, + child: Material( + color: backgroundColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: _headerPaddingLandscape), + child: help, + ), + SizedBox(height: isShort ? 16 : 56), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _headerPaddingLandscape), + child: title, + ), + ), + if (entryModeButton != null) + Padding( + padding: theme.useMaterial3 + // TODO(TahaTesser): This is an eye-balled M3 entry mode button padding + // from https://m3.material.io/components/date-pickers/specs#c16c142b-4706-47f3-9400-3cde654b9aa8. + // Update this value to use tokens when available. + ? const EdgeInsetsDirectional.only(start: 8.0, end: 4.0, bottom: 6.0) + : const EdgeInsets.symmetric(horizontal: 4), + child: Semantics(container: true, child: entryModeButton), + ), + ], + ), + ), + ), + ); + } + } +} + +/// Signature for predicating enabled dates in date range pickers. +/// +/// The [selectedStartDay] and [selectedEndDay] are the currently selected start +/// and end dates of a date range, which conditionally enables or disables each +/// date in the picker based on the user selection. (Example: in a hostel's room +/// selection, you are not able to select the end date after the next +/// non-selectable day). +/// +/// See [showDateRangePicker], which has a [SelectableDayForRangePredicate] +/// parameter used to specify allowable days in the date range picker. +typedef SelectableDayForRangePredicate = + bool Function(DateTime day, DateTime? selectedStartDay, DateTime? selectedEndDay); + +/// Shows a full screen modal dialog containing a Material Design date range +/// picker. +/// +/// The returned [Future] resolves to the [DateTimeRange] selected by the user +/// when the user saves their selection. If the user cancels the dialog, null is +/// returned. +/// +/// If [initialDateRange] is non-null, then it will be used as the initially +/// selected date range. If it is provided, `initialDateRange.start` must be +/// before or on `initialDateRange.end`. +/// +/// The [firstDate] is the earliest allowable date. The [lastDate] is the latest +/// allowable date. +/// +/// If an initial date range is provided, `initialDateRange.start` +/// and `initialDateRange.end` must both fall between or on [firstDate] and +/// [lastDate]. For all of these [DateTime] values, only their dates are +/// considered. Their time fields are ignored. +/// +/// The [currentDate] represents the current day (i.e. today). This +/// date will be highlighted in the day grid. If null, the date of +/// `DateTime.now()` will be used. +/// +/// An optional [initialEntryMode] argument can be used to display the date +/// picker in the [DatePickerEntryMode.calendar] (a scrollable calendar month +/// grid) or [DatePickerEntryMode.input] (two text input fields) mode. +/// It defaults to [DatePickerEntryMode.calendar]. +/// +/// {@macro flutter.material.date_picker.switchToInputEntryModeIcon} +/// +/// {@macro flutter.material.date_picker.switchToCalendarEntryModeIcon} +/// +/// {@macro flutter.material.calendar_date_picker.calendarDelegate} +/// +/// The following optional string parameters allow you to override the default +/// text used for various parts of the dialog: +/// +/// * [helpText], the label displayed at the top of the dialog. +/// * [cancelText], the label on the cancel button for the text input mode. +/// * [confirmText],the label on the ok button for the text input mode. +/// * [saveText], the label on the save button for the fullscreen calendar +/// mode. +/// * [errorFormatText], the message used when an input text isn't in a proper +/// date format. +/// * [errorInvalidText], the message used when an input text isn't a +/// selectable date. +/// * [errorInvalidRangeText], the message used when the date range is +/// invalid (e.g. start date is after end date). +/// * [fieldStartHintText], the text used to prompt the user when no text has +/// been entered in the start field. +/// * [fieldEndHintText], the text used to prompt the user when no text has +/// been entered in the end field. +/// * [fieldStartLabelText], the label for the start date text input field. +/// * [fieldEndLabelText], the label for the end date text input field. +/// +/// An optional [locale] argument can be used to set the locale for the date +/// picker. It defaults to the ambient locale provided by [Localizations]. +/// +/// An optional [textDirection] argument can be used to set the text direction +/// ([TextDirection.ltr] or [TextDirection.rtl]) for the date picker. It +/// defaults to the ambient text direction provided by [Directionality]. If both +/// [locale] and [textDirection] are non-null, [textDirection] overrides the +/// direction chosen for the [locale]. +/// +/// The [context], [barrierDismissible], [barrierColor], [barrierLabel], +/// [useRootNavigator] and [routeSettings] arguments are passed to [showDialog], +/// the documentation for which discusses how it is used. +/// +/// The [builder] parameter can be used to wrap the dialog widget +/// to add inherited widgets like [Theme]. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// ### State Restoration +/// +/// Using this method will not enable state restoration for the date range picker. +/// In order to enable state restoration for a date range picker, use +/// [Navigator.restorablePush] or [Navigator.restorablePushNamed] with +/// [DateRangePickerDialog]. +/// +/// For more information about state restoration, see [RestorationManager]. +/// +/// {@macro flutter.widgets.RestorationManager} +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a restorable Material date range picker. +/// This is accomplished by enabling state restoration by specifying +/// [MaterialApp.restorationScopeId] and using [Navigator.restorablePush] to +/// push [DateRangePickerDialog] when the button is tapped. +/// +/// ** See code in examples/api/lib/material/date_picker/show_date_range_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [showDatePicker], which shows a Material Design date picker used to +/// select a single date. +/// * [DateTimeRange], which is used to describe a date range. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +Future<DateTimeRange?> showDateRangePicker({ + required BuildContext context, + DateTimeRange? initialDateRange, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar, + String? helpText, + String? cancelText, + String? confirmText, + String? saveText, + String? errorFormatText, + String? errorInvalidText, + String? errorInvalidRangeText, + String? fieldStartHintText, + String? fieldEndHintText, + String? fieldStartLabelText, + String? fieldEndLabelText, + Locale? locale, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, + TextDirection? textDirection, + TransitionBuilder? builder, + Offset? anchorPoint, + TextInputType keyboardType = TextInputType.datetime, + final Icon? switchToInputEntryModeIcon, + final Icon? switchToCalendarEntryModeIcon, + SelectableDayForRangePredicate? selectableDayPredicate, + CalendarDelegate<DateTime> calendarDelegate = const GregorianCalendarDelegate(), +}) async { + initialDateRange = initialDateRange == null ? null : calendarDelegate.datesOnly(initialDateRange); + firstDate = calendarDelegate.dateOnly(firstDate); + lastDate = calendarDelegate.dateOnly(lastDate); + assert( + !lastDate.isBefore(firstDate), + 'lastDate $lastDate must be on or after firstDate $firstDate.', + ); + assert( + initialDateRange == null || !initialDateRange.start.isBefore(firstDate), + "initialDateRange's start date must be on or after firstDate $firstDate.", + ); + assert( + initialDateRange == null || !initialDateRange.end.isBefore(firstDate), + "initialDateRange's end date must be on or after firstDate $firstDate.", + ); + assert( + initialDateRange == null || !initialDateRange.start.isAfter(lastDate), + "initialDateRange's start date must be on or before lastDate $lastDate.", + ); + assert( + initialDateRange == null || !initialDateRange.end.isAfter(lastDate), + "initialDateRange's end date must be on or before lastDate $lastDate.", + ); + assert( + initialDateRange == null || + selectableDayPredicate == null || + selectableDayPredicate( + initialDateRange.start, + initialDateRange.start, + initialDateRange.end, + ), + "initialDateRange's start date must be selectable.", + ); + assert( + initialDateRange == null || + selectableDayPredicate == null || + selectableDayPredicate(initialDateRange.end, initialDateRange.start, initialDateRange.end), + "initialDateRange's end date must be selectable.", + ); + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()); + assert(debugCheckHasMaterialLocalizations(context)); + + Widget dialog = DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + selectableDayPredicate: selectableDayPredicate, + initialEntryMode: initialEntryMode, + helpText: helpText, + cancelText: cancelText, + confirmText: confirmText, + saveText: saveText, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + errorInvalidRangeText: errorInvalidRangeText, + fieldStartHintText: fieldStartHintText, + fieldEndHintText: fieldEndHintText, + fieldStartLabelText: fieldStartLabelText, + fieldEndLabelText: fieldEndLabelText, + keyboardType: keyboardType, + switchToInputEntryModeIcon: switchToInputEntryModeIcon, + switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + calendarDelegate: calendarDelegate, + ); + + if (textDirection != null) { + dialog = Directionality(textDirection: textDirection, child: dialog); + } + + if (locale != null) { + dialog = Localizations.override(context: context, locale: locale, child: dialog); + } + + return showDialog<DateTimeRange>( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + useSafeArea: false, + builder: (BuildContext context) { + return builder == null ? dialog : builder(context, dialog); + }, + anchorPoint: anchorPoint, + ); +} + +/// Returns a locale-appropriate string to describe the start of a date range. +/// +/// If `startDate` is null, then it defaults to 'Start Date', otherwise if it +/// is in the same year as the `endDate` then it will use the short month +/// day format (i.e. 'Jan 21'). Otherwise it will return the short date format +/// (i.e. 'Jan 21, 2020'). +String _formatRangeStartDate( + MaterialLocalizations localizations, + CalendarDelegate<DateTime> calendarDelegate, + DateTime? startDate, + DateTime? endDate, +) { + return startDate == null + ? localizations.dateRangeStartLabel + : (endDate == null || startDate.year == endDate.year) + ? calendarDelegate.formatShortMonthDay(startDate, localizations) + : calendarDelegate.formatShortDate(startDate, localizations); +} + +/// Returns an locale-appropriate string to describe the end of a date range. +/// +/// If `endDate` is null, then it defaults to 'End Date', otherwise if it +/// is in the same year as the `startDate` and the `currentDate` then it will +/// just use the short month day format (i.e. 'Jan 21'), otherwise it will +/// include the year (i.e. 'Jan 21, 2020'). +String _formatRangeEndDate( + MaterialLocalizations localizations, + CalendarDelegate<DateTime> calendarDelegate, + DateTime? startDate, + DateTime? endDate, + DateTime currentDate, +) { + return endDate == null + ? localizations.dateRangeEndLabel + : (startDate != null && startDate.year == endDate.year && startDate.year == currentDate.year) + ? calendarDelegate.formatShortMonthDay(endDate, localizations) + : calendarDelegate.formatShortDate(endDate, localizations); +} + +/// A Material-style date range picker dialog. +/// +/// It is used internally by [showDateRangePicker] or can be directly pushed +/// onto the [Navigator] stack to enable state restoration. See +/// [showDateRangePicker] for a state restoration app example. +/// +/// See also: +/// +/// * [showDateRangePicker], which is a way to display the date picker. +class DateRangePickerDialog extends StatefulWidget { + /// A Material-style date range picker dialog. + const DateRangePickerDialog({ + super.key, + this.initialDateRange, + required this.firstDate, + required this.lastDate, + DateTime? currentDate, + this.initialEntryMode = DatePickerEntryMode.calendar, + this.helpText, + this.cancelText, + this.confirmText, + this.saveText, + this.errorInvalidRangeText, + this.errorFormatText, + this.errorInvalidText, + this.fieldStartHintText, + this.fieldEndHintText, + this.fieldStartLabelText, + this.fieldEndLabelText, + this.keyboardType = TextInputType.datetime, + this.restorationId, + this.switchToInputEntryModeIcon, + this.switchToCalendarEntryModeIcon, + this.selectableDayPredicate, + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : _currentDate = currentDate; + + /// The date range that the date range picker starts with when it opens. + /// + /// If an initial date range is provided, `initialDateRange.start` + /// and `initialDateRange.end` must both fall between or on [firstDate] and + /// [lastDate]. For all of these [DateTime] values, only their dates are + /// considered. Their time fields are ignored. + /// + /// If [initialDateRange] is non-null, then it will be used as the initially + /// selected date range. If it is provided, `initialDateRange.start` must be + /// before or on `initialDateRange.end`. + final DateTimeRange? initialDateRange; + + /// The earliest allowable date on the date range. + final DateTime firstDate; + + /// The latest allowable date on the date range. + final DateTime lastDate; + + /// The [currentDate] represents the current day (i.e. today). + /// + /// This date will be highlighted in the day grid. + /// + /// If `null`, the date of `calendarDelegate.now()` will be used. + DateTime get currentDate { + return calendarDelegate.dateOnly(_currentDate ?? calendarDelegate.now()); + } + + final DateTime? _currentDate; + + /// The initial date range picker entry mode. + /// + /// The date range has two main modes: [DatePickerEntryMode.calendar] (a + /// scrollable calendar month grid) or [DatePickerEntryMode.input] (two text + /// input fields) mode. + /// + /// It defaults to [DatePickerEntryMode.calendar]. + final DatePickerEntryMode initialEntryMode; + + /// The label on the cancel button for the text input mode. + /// + /// If null, the localized value of + /// [MaterialLocalizations.cancelButtonLabel] is used. + final String? cancelText; + + /// The label on the "OK" button for the text input mode. + /// + /// If null, the localized value of + /// [MaterialLocalizations.okButtonLabel] is used. + final String? confirmText; + + /// The label on the save button for the fullscreen calendar mode. + /// + /// If null, the localized value of + /// [MaterialLocalizations.saveButtonLabel] is used. + final String? saveText; + + /// The label displayed at the top of the dialog. + /// + /// If null, the localized value of + /// [MaterialLocalizations.dateRangePickerHelpText] is used. + final String? helpText; + + /// The message used when the date range is invalid (e.g. start date is after + /// end date). + /// + /// If null, the localized value of + /// [MaterialLocalizations.invalidDateRangeLabel] is used. + final String? errorInvalidRangeText; + + /// The message used when an input text isn't in a proper date format. + /// + /// If null, the localized value of + /// [MaterialLocalizations.invalidDateFormatLabel] is used. + final String? errorFormatText; + + /// The message used when an input text isn't a selectable date. + /// + /// If null, the localized value of + /// [MaterialLocalizations.dateOutOfRangeLabel] is used. + final String? errorInvalidText; + + /// The text used to prompt the user when no text has been entered in the + /// start field. + /// + /// If null, the localized value of + /// [MaterialLocalizations.dateHelpText] is used. + final String? fieldStartHintText; + + /// The text used to prompt the user when no text has been entered in the + /// end field. + /// + /// If null, the localized value of [MaterialLocalizations.dateHelpText] is + /// used. + final String? fieldEndHintText; + + /// The label for the start date text input field. + /// + /// If null, the localized value of [MaterialLocalizations.dateRangeStartLabel] + /// is used. + final String? fieldStartLabelText; + + /// The label for the end date text input field. + /// + /// If null, the localized value of [MaterialLocalizations.dateRangeEndLabel] + /// is used. + final String? fieldEndLabelText; + + /// {@macro flutter.material.datePickerDialog} + final TextInputType keyboardType; + + /// Restoration ID to save and restore the state of the [DateRangePickerDialog]. + /// + /// If it is non-null, the date range picker will persist and restore the + /// date range selected on the dialog. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// {@macro flutter.material.date_picker.switchToInputEntryModeIcon} + final Icon? switchToInputEntryModeIcon; + + /// {@macro flutter.material.date_picker.switchToCalendarEntryModeIcon} + final Icon? switchToCalendarEntryModeIcon; + + /// Function to provide full control over which [DateTime] can be selected. + final SelectableDayForRangePredicate? selectableDayPredicate; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<DateRangePickerDialog> createState() => _DateRangePickerDialogState(); +} + +class _DateRangePickerDialogState extends State<DateRangePickerDialog> with RestorationMixin { + late final _RestorableDatePickerEntryMode _entryMode = _RestorableDatePickerEntryMode( + widget.initialEntryMode, + ); + late final RestorableDateTimeN _selectedStart = RestorableDateTimeN( + widget.initialDateRange?.start, + ); + late final RestorableDateTimeN _selectedEnd = RestorableDateTimeN(widget.initialDateRange?.end); + final RestorableBool _autoValidate = RestorableBool(false); + final GlobalKey _calendarPickerKey = GlobalKey(); + final GlobalKey<_InputDateRangePickerState> _inputPickerKey = + GlobalKey<_InputDateRangePickerState>(); + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_entryMode, 'entry_mode'); + registerForRestoration(_selectedStart, 'selected_start'); + registerForRestoration(_selectedEnd, 'selected_end'); + registerForRestoration(_autoValidate, 'autovalidate'); + } + + @override + void dispose() { + _entryMode.dispose(); + _selectedStart.dispose(); + _selectedEnd.dispose(); + _autoValidate.dispose(); + super.dispose(); + } + + void _handleOk() { + if (_entryMode.value == DatePickerEntryMode.input || + _entryMode.value == DatePickerEntryMode.inputOnly) { + final _InputDateRangePickerState picker = _inputPickerKey.currentState!; + if (!picker.validate()) { + setState(() { + _autoValidate.value = true; + }); + return; + } + } + final DateTimeRange? selectedRange = _hasSelectedDateRange + ? DateTimeRange(start: _selectedStart.value!, end: _selectedEnd.value!) + : null; + + Navigator.pop(context, selectedRange); + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleEntryModeToggle() { + setState(() { + switch (_entryMode.value) { + case DatePickerEntryMode.calendar: + _autoValidate.value = false; + _entryMode.value = DatePickerEntryMode.input; + + case DatePickerEntryMode.input: + // Validate the range dates + if (_selectedStart.value != null && + _selectedEnd.value != null && + _selectedStart.value!.isAfter(_selectedEnd.value!)) { + _selectedEnd.value = null; + } + if (_selectedStart.value != null && !_isDaySelectable(_selectedStart.value!)) { + _selectedStart.value = null; + // With no valid start date, having an end date makes no sense for the UI. + _selectedEnd.value = null; + } else if (_selectedEnd.value != null && !_isDaySelectable(_selectedEnd.value!)) { + _selectedEnd.value = null; + } + _entryMode.value = DatePickerEntryMode.calendar; + + case DatePickerEntryMode.calendarOnly: + case DatePickerEntryMode.inputOnly: + assert(false, 'Can not change entry mode from $_entryMode'); + } + }); + } + + bool _isDaySelectable(DateTime day) { + if (day.isBefore(widget.firstDate) || day.isAfter(widget.lastDate)) { + return false; + } + if (widget.selectableDayPredicate == null) { + return true; + } + return widget.selectableDayPredicate!(day, _selectedStart.value, _selectedEnd.value); + } + + void _handleStartDateChanged(DateTime? date) { + setState(() => _selectedStart.value = date); + } + + void _handleEndDateChanged(DateTime? date) { + setState(() => _selectedEnd.value = date); + } + + bool get _hasSelectedDateRange => _selectedStart.value != null && _selectedEnd.value != null; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final bool useMaterial3 = theme.useMaterial3; + final Orientation orientation = MediaQuery.orientationOf(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + + final Widget contents; + final Size size; + final double? elevation; + final Color? shadowColor; + final Color? surfaceTintColor; + final ShapeBorder? shape; + final EdgeInsets insetPadding; + final bool showEntryModeButton = + _entryMode.value == DatePickerEntryMode.calendar || + _entryMode.value == DatePickerEntryMode.input; + switch (_entryMode.value) { + case DatePickerEntryMode.calendar: + case DatePickerEntryMode.calendarOnly: + contents = _CalendarRangePickerDialog( + key: _calendarPickerKey, + calendarDelegate: widget.calendarDelegate, + selectedStartDate: _selectedStart.value, + selectedEndDate: _selectedEnd.value, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selectableDayPredicate: widget.selectableDayPredicate, + currentDate: widget.currentDate, + onStartDateChanged: _handleStartDateChanged, + onEndDateChanged: _handleEndDateChanged, + onConfirm: _hasSelectedDateRange ? _handleOk : null, + onCancel: _handleCancel, + entryModeButton: showEntryModeButton + ? IconButton( + icon: + widget.switchToInputEntryModeIcon ?? + Icon(useMaterial3 ? Icons.edit_outlined : Icons.edit), + padding: EdgeInsets.zero, + tooltip: localizations.inputDateModeButtonLabel, + onPressed: _handleEntryModeToggle, + ) + : null, + confirmText: + widget.saveText ?? + (useMaterial3 + ? localizations.saveButtonLabel + : localizations.saveButtonLabel.toUpperCase()), + helpText: + widget.helpText ?? + (useMaterial3 + ? localizations.dateRangePickerHelpText + : localizations.dateRangePickerHelpText.toUpperCase()), + ); + size = MediaQuery.sizeOf(context); + insetPadding = EdgeInsets.zero; + elevation = datePickerTheme.rangePickerElevation ?? defaults.rangePickerElevation!; + shadowColor = datePickerTheme.rangePickerShadowColor ?? defaults.rangePickerShadowColor!; + surfaceTintColor = + datePickerTheme.rangePickerSurfaceTintColor ?? defaults.rangePickerSurfaceTintColor!; + shape = datePickerTheme.rangePickerShape ?? defaults.rangePickerShape; + + case DatePickerEntryMode.input: + case DatePickerEntryMode.inputOnly: + contents = _InputDateRangePickerDialog( + calendarDelegate: widget.calendarDelegate, + selectedStartDate: _selectedStart.value, + selectedEndDate: _selectedEnd.value, + currentDate: widget.currentDate, + picker: SizedBox( + height: orientation == Orientation.portrait + ? _inputFormPortraitHeight + : _inputFormLandscapeHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: <Widget>[ + const Spacer(), + _InputDateRangePicker( + key: _inputPickerKey, + calendarDelegate: widget.calendarDelegate, + initialStartDate: _selectedStart.value, + initialEndDate: _selectedEnd.value, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selectableDayPredicate: widget.selectableDayPredicate, + onStartDateChanged: _handleStartDateChanged, + onEndDateChanged: _handleEndDateChanged, + autofocus: true, + autovalidate: _autoValidate.value, + helpText: widget.helpText, + errorInvalidRangeText: widget.errorInvalidRangeText, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldStartHintText: widget.fieldStartHintText, + fieldEndHintText: widget.fieldEndHintText, + fieldStartLabelText: widget.fieldStartLabelText, + fieldEndLabelText: widget.fieldEndLabelText, + keyboardType: widget.keyboardType, + ), + const Spacer(), + ], + ), + ), + ), + onConfirm: _handleOk, + onCancel: _handleCancel, + entryModeButton: showEntryModeButton + ? IconButton( + icon: widget.switchToCalendarEntryModeIcon ?? const Icon(Icons.calendar_today), + padding: EdgeInsets.zero, + tooltip: localizations.calendarModeButtonLabel, + onPressed: _handleEntryModeToggle, + ) + : null, + confirmText: widget.confirmText ?? localizations.okButtonLabel, + cancelText: + widget.cancelText ?? + (useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase()), + helpText: + widget.helpText ?? + (useMaterial3 + ? localizations.dateRangePickerHelpText + : localizations.dateRangePickerHelpText.toUpperCase()), + ); + final DialogThemeData dialogTheme = theme.dialogTheme; + size = orientation == Orientation.portrait + ? (useMaterial3 ? _inputPortraitDialogSizeM3 : _inputPortraitDialogSizeM2) + : _inputRangeLandscapeDialogSize; + elevation = useMaterial3 + ? datePickerTheme.elevation ?? defaults.elevation! + : datePickerTheme.elevation ?? dialogTheme.elevation ?? 24; + shadowColor = datePickerTheme.shadowColor ?? defaults.shadowColor; + surfaceTintColor = datePickerTheme.surfaceTintColor ?? defaults.surfaceTintColor; + shape = useMaterial3 + ? datePickerTheme.shape ?? defaults.shape + : datePickerTheme.shape ?? dialogTheme.shape ?? defaults.shape; + + insetPadding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0); + } + + return Dialog( + insetPadding: insetPadding, + backgroundColor: datePickerTheme.backgroundColor ?? defaults.backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + clipBehavior: Clip.antiAlias, + child: AnimatedContainer( + width: size.width, + height: size.height, + duration: _dialogSizeAnimationDuration, + curve: Curves.easeIn, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: _kMaxRangeTextScaleFactor, + child: Builder( + builder: (BuildContext context) { + return contents; + }, + ), + ), + ), + ); + } +} + +class _CalendarRangePickerDialog extends StatelessWidget { + const _CalendarRangePickerDialog({ + super.key, + required this.selectedStartDate, + required this.selectedEndDate, + required this.firstDate, + required this.lastDate, + required this.currentDate, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.onConfirm, + required this.onCancel, + required this.confirmText, + required this.helpText, + required this.selectableDayPredicate, + required this.calendarDelegate, + this.entryModeButton, + }); + + final DateTime? selectedStartDate; + final DateTime? selectedEndDate; + final DateTime firstDate; + final DateTime lastDate; + final SelectableDayForRangePredicate? selectableDayPredicate; + final DateTime? currentDate; + final ValueChanged<DateTime> onStartDateChanged; + final ValueChanged<DateTime?> onEndDateChanged; + final VoidCallback? onConfirm; + final VoidCallback? onCancel; + final String confirmText; + final String helpText; + final CalendarDelegate<DateTime> calendarDelegate; + final Widget? entryModeButton; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final bool useMaterial3 = theme.useMaterial3; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final Orientation orientation = MediaQuery.orientationOf(context); + final DatePickerThemeData themeData = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final Color? dialogBackground = + themeData.rangePickerBackgroundColor ?? defaults.rangePickerBackgroundColor; + final Color? headerBackground = + themeData.rangePickerHeaderBackgroundColor ?? defaults.rangePickerHeaderBackgroundColor; + final Color? headerForeground = + themeData.rangePickerHeaderForegroundColor ?? defaults.rangePickerHeaderForegroundColor; + final Color? headerDisabledForeground = headerForeground?.withOpacity(0.38); + final TextStyle? headlineStyle = + themeData.rangePickerHeaderHeadlineStyle ?? defaults.rangePickerHeaderHeadlineStyle; + final TextStyle? headlineHelpStyle = + (themeData.rangePickerHeaderHelpStyle ?? defaults.rangePickerHeaderHelpStyle)?.apply( + color: headerForeground, + ); + final String startDateText = _formatRangeStartDate( + localizations, + calendarDelegate, + selectedStartDate, + selectedEndDate, + ); + final String endDateText = _formatRangeEndDate( + localizations, + calendarDelegate, + selectedStartDate, + selectedEndDate, + calendarDelegate.now(), + ); + final TextStyle? startDateStyle = headlineStyle?.apply( + color: selectedStartDate != null ? headerForeground : headerDisabledForeground, + ); + final TextStyle? endDateStyle = headlineStyle?.apply( + color: selectedEndDate != null ? headerForeground : headerDisabledForeground, + ); + final ButtonStyle buttonStyle = TextButton.styleFrom( + foregroundColor: headerForeground, + disabledForegroundColor: headerDisabledForeground, + ); + final iconTheme = IconThemeData(color: headerForeground); + + return SafeArea( + top: false, + left: false, + right: false, + child: Scaffold( + appBar: AppBar( + iconTheme: iconTheme, + actionsIconTheme: iconTheme, + elevation: useMaterial3 ? 0 : null, + scrolledUnderElevation: useMaterial3 ? 0 : null, + backgroundColor: headerBackground, + leading: CloseButton(onPressed: onCancel), + actions: <Widget>[ + if (orientation == Orientation.landscape && entryModeButton != null) entryModeButton!, + TextButton(style: buttonStyle, onPressed: onConfirm, child: Text(confirmText)), + const SizedBox(width: 8), + ], + bottom: PreferredSize( + preferredSize: const Size(double.infinity, 64), + child: Row( + children: <Widget>[ + SizedBox(width: MediaQuery.widthOf(context) < 360 ? 42 : 72), + Expanded( + child: Semantics( + label: '$helpText $startDateText to $endDateText', + excludeSemantics: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Text( + helpText, + style: headlineHelpStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Row( + children: <Widget>[ + Text( + startDateText, + style: startDateStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text(' – ', style: startDateStyle), + Flexible( + child: Text( + endDateText, + style: endDateStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + if (orientation == Orientation.portrait && entryModeButton != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: IconTheme(data: iconTheme, child: entryModeButton!), + ), + ], + ), + ), + ), + backgroundColor: dialogBackground, + body: _CalendarDateRangePicker( + initialStartDate: selectedStartDate, + initialEndDate: selectedEndDate, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + onStartDateChanged: onStartDateChanged, + onEndDateChanged: onEndDateChanged, + selectableDayPredicate: selectableDayPredicate, + calendarDelegate: calendarDelegate, + ), + ), + ); + } +} + +const Duration _monthScrollDuration = Duration(milliseconds: 200); + +const double _monthItemHeaderHeight = 58.0; +const double _monthItemFooterHeight = 12.0; +const double _monthItemRowHeight = 42.0; +const double _monthItemSpaceBetweenRows = 8.0; +const double _horizontalPadding = 8.0; +const double _maxCalendarWidthLandscape = 384.0; +const double _maxCalendarWidthPortrait = 480.0; + +/// Displays a scrollable calendar grid that allows a user to select a range +/// of dates. +class _CalendarDateRangePicker extends StatefulWidget { + /// Creates a scrollable calendar grid for picking date ranges. + _CalendarDateRangePicker({ + DateTime? initialStartDate, + DateTime? initialEndDate, + required DateTime firstDate, + required DateTime lastDate, + required this.selectableDayPredicate, + DateTime? currentDate, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.calendarDelegate, + }) : initialStartDate = initialStartDate != null + ? calendarDelegate.dateOnly(initialStartDate) + : null, + initialEndDate = initialEndDate != null ? calendarDelegate.dateOnly(initialEndDate) : null, + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { + assert( + this.initialStartDate == null || + this.initialEndDate == null || + !this.initialStartDate!.isAfter(initialEndDate!), + 'initialStartDate must be on or before initialEndDate.', + ); + assert(!this.lastDate.isBefore(this.firstDate), 'firstDate must be on or before lastDate.'); + } + + /// The [DateTime] that represents the start of the initial date range selection. + final DateTime? initialStartDate; + + /// The [DateTime] that represents the end of the initial date range selection. + final DateTime? initialEndDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// Function to provide full control over which [DateTime] can be selected. + final SelectableDayForRangePredicate? selectableDayPredicate; + + /// The [DateTime] representing today. It will be highlighted in the day grid. + final DateTime currentDate; + + /// Called when the user changes the start date of the selected range. + final ValueChanged<DateTime>? onStartDateChanged; + + /// Called when the user changes the end date of the selected range. + final ValueChanged<DateTime?>? onEndDateChanged; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<_CalendarDateRangePicker> createState() => _CalendarDateRangePickerState(); +} + +class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { + final GlobalKey _scrollViewKey = GlobalKey(); + final Key _sliverAfterKey = UniqueKey(); + DateTime? _startDate; + DateTime? _endDate; + int _initialMonthIndex = 0; + late ScrollController _controller; + late bool _showWeekBottomDivider; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _controller.addListener(_scrollListener); + + _startDate = widget.initialStartDate; + _endDate = widget.initialEndDate; + + // Calculate the index for the initially displayed month. This is needed to + // divide the list of months into two `SliverList`s. + final DateTime initialDate = widget.initialStartDate ?? widget.currentDate; + if (!initialDate.isBefore(widget.firstDate) && !initialDate.isAfter(widget.lastDate)) { + _initialMonthIndex = widget.calendarDelegate.monthDelta(widget.firstDate, initialDate); + } + + _showWeekBottomDivider = _initialMonthIndex != 0; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _scrollListener() { + if (_controller.offset <= _controller.position.minScrollExtent) { + setState(() { + _showWeekBottomDivider = false; + }); + } else if (!_showWeekBottomDivider) { + setState(() { + _showWeekBottomDivider = true; + }); + } + } + + int get _numberOfMonths => + widget.calendarDelegate.monthDelta(widget.firstDate, widget.lastDate) + 1; + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + HapticFeedback.vibrate(); + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } + + // This updates the selected date range using this logic: + // + // * From the unselected state, selecting one date creates the start date. + // * If the next selection is before the start date, reset date range and + // set the start date to that selection. + // * If the next selection is on or after the start date, set the end date + // to that selection. + // * After both start and end dates are selected, any subsequent selection + // resets the date range and sets start date to that selection. + void _updateSelection(DateTime date) { + _vibrate(); + setState(() { + if (_startDate != null && _endDate == null && !date.isBefore(_startDate!)) { + _endDate = date; + widget.onEndDateChanged?.call(_endDate); + } else { + _startDate = date; + widget.onStartDateChanged?.call(_startDate!); + if (_endDate != null) { + _endDate = null; + widget.onEndDateChanged?.call(_endDate); + } + } + }); + } + + Widget _buildMonthItem(BuildContext context, int index, bool beforeInitialMonth) { + final int monthIndex = beforeInitialMonth + ? _initialMonthIndex - index - 1 + : _initialMonthIndex + index; + final DateTime month = widget.calendarDelegate.addMonthsToMonthDate( + widget.firstDate, + monthIndex, + ); + return _MonthItem( + calendarDelegate: widget.calendarDelegate, + selectedDateStart: _startDate, + selectedDateEnd: _endDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + onChanged: _updateSelection, + selectableDayPredicate: widget.selectableDayPredicate, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + const _DayHeaders(), + if (_showWeekBottomDivider) const Divider(height: 0), + Expanded( + child: _CalendarKeyboardNavigator( + calendarDelegate: widget.calendarDelegate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialFocusedDay: _startDate ?? widget.initialStartDate ?? widget.currentDate, + // In order to prevent performance issues when displaying the + // correct initial month, 2 `SliverList`s are used to split the + // months. The first item in the second SliverList is the initial + // month to be displayed. + child: CustomScrollView( + key: _scrollViewKey, + controller: _controller, + center: _sliverAfterKey, + slivers: <Widget>[ + SliverList.builder( + itemCount: _initialMonthIndex, + itemBuilder: (BuildContext context, int index) => + _buildMonthItem(context, index, true), + ), + SliverList.builder( + key: _sliverAfterKey, + itemCount: _numberOfMonths - _initialMonthIndex, + itemBuilder: (BuildContext context, int index) => + _buildMonthItem(context, index, false), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _CalendarKeyboardNavigator extends StatefulWidget { + const _CalendarKeyboardNavigator({ + required this.child, + required this.firstDate, + required this.lastDate, + required this.initialFocusedDay, + required this.calendarDelegate, + }); + + final Widget child; + final DateTime firstDate; + final DateTime lastDate; + final DateTime initialFocusedDay; + final CalendarDelegate<DateTime> calendarDelegate; + + @override + _CalendarKeyboardNavigatorState createState() => _CalendarKeyboardNavigatorState(); +} + +class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> { + final Map<ShortcutActivator, Intent> _shortcutMap = const <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent( + TraversalDirection.right, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + }; + late Map<Type, Action<Intent>> _actionMap; + late FocusNode _dayGridFocus; + TraversalDirection? _dayTraversalDirection; + DateTime? _focusedDay; + + @override + void initState() { + super.initState(); + + _actionMap = <Type, Action<Intent>>{ + NextFocusIntent: CallbackAction<NextFocusIntent>(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction<PreviousFocusIntent>(onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction<DirectionalFocusIntent>( + onInvoke: _handleDirectionFocus, + ), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); + } + + @override + void dispose() { + _dayGridFocus.dispose(); + super.dispose(); + } + + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused) { + _focusedDay ??= widget.initialFocusedDay; + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime? nextDate = _nextDateInDirection(_focusedDay!, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + _dayTraversalDirection = intent.direction; + } + }); + } + + static const Map<TraversalDirection, int> _directionOffset = <TraversalDirection, int>{ + TraversalDirection.up: -DateTime.daysPerWeek, + TraversalDirection.right: 1, + TraversalDirection.down: DateTime.daysPerWeek, + TraversalDirection.left: -1, + }; + + int _dayDirectionOffset(TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) { + traversalDirection = TraversalDirection.right; + } else if (traversalDirection == TraversalDirection.right) { + traversalDirection = TraversalDirection.left; + } + } + return _directionOffset[traversalDirection]!; + } + + DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + final DateTime nextDate = widget.calendarDelegate.addDaysToDate( + date, + _dayDirectionOffset(direction, textDirection), + ); + if (!nextDate.isBefore(widget.firstDate) && !nextDate.isAfter(widget.lastDate)) { + return nextDate; + } + return null; + } + + @override + Widget build(BuildContext context) { + return FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + calendarDelegate: widget.calendarDelegate, + date: _dayGridFocus.hasFocus ? _focusedDay : null, + scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null, + child: widget.child, + ), + ); + } +} + +/// InheritedWidget indicating what the current focused date is for its children. +// See also: _FocusedDate in calendar_date_picker.dart +class _FocusedDate extends InheritedWidget { + const _FocusedDate({ + required super.child, + required this.calendarDelegate, + this.date, + this.scrollDirection, + }); + + final CalendarDelegate<DateTime> calendarDelegate; + final DateTime? date; + final TraversalDirection? scrollDirection; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !calendarDelegate.isSameDay(date, oldWidget.date) || + scrollDirection != oldWidget.scrollDirection; + } + + static _FocusedDate? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + } +} + +class _DayHeaders extends StatelessWidget { + const _DayHeaders(); + + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S ← the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S ← the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// + List<Widget> _getDayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) { + final result = <Widget>[]; + for ( + int i = localizations.firstDayOfWeekIndex; + result.length < DateTime.daysPerWeek; + i = (i + 1) % DateTime.daysPerWeek + ) { + final String weekday = localizations.narrowWeekdays[i]; + result.add( + ExcludeSemantics( + child: Center(child: Text(weekday, style: headerStyle)), + ), + ); + } + return result; + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final ColorScheme colorScheme = themeData.colorScheme; + final TextStyle textStyle = themeData.textTheme.titleSmall!.apply(color: colorScheme.onSurface); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final List<Widget> labels = _getDayHeaders(textStyle, localizations); + + // Add leading and trailing boxes for edges of the custom grid layout. + labels.insert(0, const SizedBox.shrink()); + labels.add(const SizedBox.shrink()); + + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.orientationOf(context) == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait, + maxHeight: _monthItemRowHeight, + ), + child: GridView.custom( + shrinkWrap: true, + gridDelegate: _monthItemGridDelegate, + childrenDelegate: SliverChildListDelegate(labels, addRepaintBoundaries: false), + ), + ); + } +} + +class _MonthItemGridDelegate extends SliverGridDelegate { + const _MonthItemGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double tileWidth = math.max( + (constraints.crossAxisExtent - 2 * _horizontalPadding) / DateTime.daysPerWeek, + 0.0, + ); + return _MonthSliverGridLayout( + crossAxisCount: DateTime.daysPerWeek + 2, + dayChildWidth: tileWidth, + edgeChildWidth: _horizontalPadding, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_MonthItemGridDelegate oldDelegate) => false; +} + +const _MonthItemGridDelegate _monthItemGridDelegate = _MonthItemGridDelegate(); + +class _MonthSliverGridLayout extends SliverGridLayout { + /// Creates a layout that uses equally sized and spaced tiles for each day of + /// the week and an additional edge tile for padding at the start and end of + /// each row. + /// + /// This is necessary to facilitate the painting of the range highlight + /// correctly. + const _MonthSliverGridLayout({ + required this.crossAxisCount, + required this.dayChildWidth, + required this.edgeChildWidth, + required this.reverseCrossAxis, + }) : assert(crossAxisCount > 0), + assert(dayChildWidth >= 0), + assert(edgeChildWidth >= 0); + + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The width in logical pixels of the day child widgets. + final double dayChildWidth; + + /// The width in logical pixels of the edge child widgets. + final double edgeChildWidth; + + /// Whether the children should be placed in the opposite order of increasing + /// coordinates in the cross axis. + /// + /// For example, if the cross axis is horizontal, the children are placed from + /// left to right when [reverseCrossAxis] is false and from right to left when + /// [reverseCrossAxis] is true. + /// + /// Typically set to the return value of [axisDirectionIsReversed] applied to + /// the [SliverConstraints.crossAxisDirection]. + final bool reverseCrossAxis; + + /// The number of logical pixels from the leading edge of one row to the + /// leading edge of the next row. + double get _rowHeight { + return _monthItemRowHeight + _monthItemSpaceBetweenRows; + } + + /// The height in logical pixels of the children widgets. + double get _childHeight { + return _monthItemRowHeight; + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + return crossAxisCount * (scrollOffset ~/ _rowHeight); + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + final int mainAxisCount = (scrollOffset / _rowHeight).ceil(); + return math.max(0, crossAxisCount * mainAxisCount - 1); + } + + double _getCrossAxisOffset(double crossAxisStart, bool isPadding) { + if (reverseCrossAxis) { + return ((crossAxisCount - 2) * dayChildWidth + 2 * edgeChildWidth) - + crossAxisStart - + (isPadding ? edgeChildWidth : dayChildWidth); + } + return crossAxisStart; + } + + @override + SliverGridGeometry getGeometryForChildIndex(int index) { + final int adjustedIndex = index % crossAxisCount; + final bool isEdge = adjustedIndex == 0 || adjustedIndex == crossAxisCount - 1; + final double crossAxisStart = math.max(0, (adjustedIndex - 1) * dayChildWidth + edgeChildWidth); + + return SliverGridGeometry( + scrollOffset: (index ~/ crossAxisCount) * _rowHeight, + crossAxisOffset: _getCrossAxisOffset(crossAxisStart, isEdge), + mainAxisExtent: _childHeight, + crossAxisExtent: isEdge ? edgeChildWidth : dayChildWidth, + ); + } + + @override + double computeMaxScrollOffset(int childCount) { + assert(childCount >= 0); + final int mainAxisCount = ((childCount - 1) ~/ crossAxisCount) + 1; + final double mainAxisSpacing = _rowHeight - _childHeight; + return _rowHeight * mainAxisCount - mainAxisSpacing; + } +} + +/// Displays the days of a given month and allows choosing a date range. +/// +/// The days are arranged in a rectangular grid with one column for each day of +/// the week. +class _MonthItem extends StatefulWidget { + /// Creates a month item. + _MonthItem({ + required this.selectedDateStart, + required this.selectedDateEnd, + required this.currentDate, + required this.onChanged, + required this.firstDate, + required this.lastDate, + required this.displayedMonth, + required this.selectableDayPredicate, + required this.calendarDelegate, + }) : assert(!firstDate.isAfter(lastDate)), + assert(selectedDateStart == null || !selectedDateStart.isBefore(firstDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)), + assert(selectedDateStart == null || !selectedDateStart.isAfter(lastDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isAfter(lastDate)), + assert( + selectedDateStart == null || + selectedDateEnd == null || + !selectedDateStart.isAfter(selectedDateEnd), + ); + + /// The currently selected start date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDateStart; + + /// The currently selected end date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDateEnd; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged<DateTime> onChanged; + + /// The earliest date the user is permitted to pick. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + final DateTime lastDate; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + final SelectableDayForRangePredicate? selectableDayPredicate; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + _MonthItemState createState() => _MonthItemState(); +} + +class _MonthItemState extends State<_MonthItem> { + /// List of [FocusNode]s, one for each day of the month. + late List<FocusNode> _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth( + widget.displayedMonth.year, + widget.displayedMonth.month, + ); + _dayFocusNodes = List<FocusNode>.generate( + daysInMonth, + (int index) => FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime? focusedDate = _FocusedDate.maybeOf(context)?.date; + if (focusedDate != null && + widget.calendarDelegate.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + Color _highlightColor(BuildContext context) { + return DatePickerTheme.of(context).rangeSelectionBackgroundColor ?? + DatePickerTheme.defaults(context).rangeSelectionBackgroundColor!; + } + + void _dayFocusChanged(bool focused) { + if (focused) { + final TraversalDirection? focusDirection = _FocusedDate.maybeOf(context)?.scrollDirection; + if (focusDirection != null) { + ScrollPositionAlignmentPolicy policy = ScrollPositionAlignmentPolicy.explicit; + switch (focusDirection) { + case TraversalDirection.up: + case TraversalDirection.left: + policy = ScrollPositionAlignmentPolicy.keepVisibleAtStart; + case TraversalDirection.right: + case TraversalDirection.down: + policy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + } + Scrollable.ensureVisible( + primaryFocus!.context!, + duration: _monthScrollDuration, + alignmentPolicy: policy, + ); + } + } + } + + Widget _buildDayItem( + BuildContext context, + DateTime dayToBuild, + int firstDayOffset, + int daysInMonth, + ) { + final int day = dayToBuild.day; + + final bool isDisabled = + dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate) || + widget.selectableDayPredicate != null && + !widget.selectableDayPredicate!( + dayToBuild, + widget.selectedDateStart, + widget.selectedDateEnd, + ); + final bool isRangeSelected = widget.selectedDateStart != null && widget.selectedDateEnd != null; + final bool isSelectedDayStart = + widget.selectedDateStart != null && dayToBuild.isAtSameMomentAs(widget.selectedDateStart!); + final bool isSelectedDayEnd = + widget.selectedDateEnd != null && dayToBuild.isAtSameMomentAs(widget.selectedDateEnd!); + final bool isInRange = + isRangeSelected && + dayToBuild.isAfter(widget.selectedDateStart!) && + dayToBuild.isBefore(widget.selectedDateEnd!); + final bool isOneDayRange = + isRangeSelected && widget.selectedDateStart == widget.selectedDateEnd; + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, dayToBuild); + + return _DayItem( + calendarDelegate: widget.calendarDelegate, + day: dayToBuild, + focusNode: _dayFocusNodes[day - 1], + onChanged: widget.onChanged, + onFocusChange: _dayFocusChanged, + highlightColor: _highlightColor(context), + isDisabled: isDisabled, + isRangeSelected: isRangeSelected, + isSelectedDayStart: isSelectedDayStart, + isSelectedDayEnd: isSelectedDayEnd, + isInRange: isInRange, + isOneDayRange: isOneDayRange, + isToday: isToday, + ); + } + + Widget _buildEdgeBox(BuildContext context, bool isHighlighted) { + const Widget empty = LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand()); + return isHighlighted ? ColoredBox(color: _highlightColor(context), child: empty) : empty; + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(year, month); + final int dayOffset = widget.calendarDelegate.firstDayOffset(year, month, localizations); + final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); + final double gridHeight = + weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows; + final dayItems = <Widget>[]; + + // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on + // a leap year. + for (int day = 0 - dayOffset + 1; day <= daysInMonth; day += 1) { + if (day < 1) { + dayItems.add(const LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand())); + } else { + final DateTime dayToBuild = widget.calendarDelegate.getDay(year, month, day); + final Widget dayItem = _buildDayItem(context, dayToBuild, dayOffset, daysInMonth); + dayItems.add(dayItem); + } + } + + // Add the leading/trailing edge containers to each week in order to + // correctly extend the range highlight. + final paddedDayItems = <Widget>[]; + for (var i = 0; i < weeks; i++) { + final int start = i * DateTime.daysPerWeek; + final int end = math.min(start + DateTime.daysPerWeek, dayItems.length); + final List<Widget> weekList = dayItems.sublist(start, end); + + final DateTime dateAfterLeadingPadding = widget.calendarDelegate.getDay( + year, + month, + start - dayOffset + 1, + ); + // Only color the edge container if it is after the start date and + // on/before the end date. + final bool isLeadingInRange = + !(dayOffset > 0 && i == 0) && + widget.selectedDateStart != null && + widget.selectedDateEnd != null && + dateAfterLeadingPadding.isAfter(widget.selectedDateStart!) && + !dateAfterLeadingPadding.isAfter(widget.selectedDateEnd!); + weekList.insert(0, _buildEdgeBox(context, isLeadingInRange)); + + // Only add a trailing edge container if it is for a full week and not a + // partial week. + if (end < dayItems.length || + (end == dayItems.length && dayItems.length % DateTime.daysPerWeek == 0)) { + final DateTime dateBeforeTrailingPadding = widget.calendarDelegate.getDay( + year, + month, + end - dayOffset, + ); + // Only color the edge container if it is on/after the start date and + // before the end date. + final bool isTrailingInRange = + widget.selectedDateStart != null && + widget.selectedDateEnd != null && + !dateBeforeTrailingPadding.isBefore(widget.selectedDateStart!) && + dateBeforeTrailingPadding.isBefore(widget.selectedDateEnd!); + weekList.add(_buildEdgeBox(context, isTrailingInRange)); + } + + paddedDayItems.addAll(weekList); + } + + final double maxWidth = MediaQuery.orientationOf(context) == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait; + return Column( + children: <Widget>[ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth).tighten(height: _monthItemHeaderHeight), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: ExcludeSemantics( + child: Text( + widget.calendarDelegate.formatMonthYear(widget.displayedMonth, localizations), + style: textTheme.bodyMedium!.apply(color: themeData.colorScheme.onSurface), + ), + ), + ), + ), + ), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: gridHeight), + child: GridView.custom( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: _monthItemGridDelegate, + childrenDelegate: SliverChildListDelegate(paddedDayItems, addRepaintBoundaries: false), + ), + ), + const SizedBox(height: _monthItemFooterHeight), + ], + ); + } +} + +class _DayItem extends StatefulWidget { + const _DayItem({ + required this.day, + required this.focusNode, + required this.onChanged, + required this.onFocusChange, + required this.highlightColor, + required this.isDisabled, + required this.isRangeSelected, + required this.isSelectedDayStart, + required this.isSelectedDayEnd, + required this.isInRange, + required this.isOneDayRange, + required this.isToday, + required this.calendarDelegate, + }); + + final DateTime day; + + final FocusNode focusNode; + + final ValueChanged<DateTime> onChanged; + + final ValueChanged<bool> onFocusChange; + + final Color highlightColor; + + final bool isDisabled; + + final bool isRangeSelected; + + final bool isSelectedDayStart; + + final bool isSelectedDayEnd; + + final bool isInRange; + + final bool isOneDayRange; + + final bool isToday; + + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<_DayItem> createState() => _DayItemState(); +} + +class _DayItemState extends State<_DayItem> { + final WidgetStatesController _statesController = WidgetStatesController(); + + @override + void dispose() { + _statesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final TextTheme textTheme = theme.textTheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final TextDirection textDirection = Directionality.of(context); + final Color highlightColor = widget.highlightColor; + + ShapeDecoration? decoration; + TextStyle? itemStyle = textTheme.bodyMedium; + + T? effectiveValue<T>(T? Function(DatePickerThemeData? theme) getProperty) { + return getProperty(datePickerTheme) ?? getProperty(defaults); + } + + T? resolve<T>( + WidgetStateProperty<T>? Function(DatePickerThemeData? theme) getProperty, + Set<WidgetState> states, + ) { + return effectiveValue((DatePickerThemeData? theme) { + return getProperty(theme)?.resolve(states); + }); + } + + final states = <WidgetState>{ + if (widget.isDisabled) WidgetState.disabled, + if (widget.isSelectedDayStart || widget.isSelectedDayEnd) WidgetState.selected, + }; + + _statesController.value = states; + + final Color? dayForegroundColor = resolve<Color?>( + (DatePickerThemeData? theme) => theme?.dayForegroundColor, + states, + ); + final Color? dayBackgroundColor = resolve<Color?>( + (DatePickerThemeData? theme) => theme?.dayBackgroundColor, + states, + ); + final WidgetStateProperty<Color?> dayOverlayColor = WidgetStateProperty.resolveWith<Color?>( + (Set<WidgetState> states) => effectiveValue( + (DatePickerThemeData? theme) => widget.isInRange + ? theme?.rangeSelectionOverlayColor?.resolve(states) + : theme?.dayOverlayColor?.resolve(states), + ), + ); + + final OutlinedBorder dayShape = + resolve<OutlinedBorder?>((DatePickerThemeData? theme) => theme?.dayShape, states) ?? + const CircleBorder(); + + _HighlightPainter? highlightPainter; + + if (widget.isSelectedDayStart || widget.isSelectedDayEnd) { + // The selected start and end dates get a custom shaped background + // highlight, and a contrasting text color. + itemStyle = itemStyle?.apply(color: dayForegroundColor); + decoration = ShapeDecoration(color: dayBackgroundColor, shape: dayShape); + + if (widget.isRangeSelected && !widget.isOneDayRange) { + final _HighlightPainterStyle style = widget.isSelectedDayStart + ? _HighlightPainterStyle.highlightTrailing + : _HighlightPainterStyle.highlightLeading; + highlightPainter = _HighlightPainter( + color: highlightColor, + style: style, + textDirection: textDirection, + ); + } + } else if (widget.isInRange) { + // The days within the range get a light background highlight. + highlightPainter = _HighlightPainter( + color: highlightColor, + style: _HighlightPainterStyle.highlightAll, + textDirection: textDirection, + ); + if (widget.isDisabled) { + itemStyle = itemStyle?.apply(color: colorScheme.onSurface.withOpacity(0.38)); + } + } else if (widget.isDisabled) { + itemStyle = itemStyle?.apply(color: colorScheme.onSurface.withOpacity(0.38)); + } else if (widget.isToday) { + // The current day gets a different text color and a custom shape border. + itemStyle = itemStyle?.apply(color: colorScheme.primary); + final BorderSide todaySide = (datePickerTheme.todayBorder ?? defaults.todayBorder!).copyWith( + color: colorScheme.primary, + ); + + decoration = ShapeDecoration(shape: dayShape.copyWith(side: todaySide)); + } + + final String dayText = localizations.formatDecimal(widget.day.day); + + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + final semanticLabelSuffix = widget.isToday ? ', ${localizations.currentDateLabel}' : ''; + var semanticLabel = + '$dayText, ${widget.calendarDelegate.formatFullDate(widget.day, localizations)}$semanticLabelSuffix'; + if (widget.isSelectedDayStart) { + semanticLabel = localizations.dateRangeStartDateSemanticLabel(semanticLabel); + } else if (widget.isSelectedDayEnd) { + semanticLabel = localizations.dateRangeEndDateSemanticLabel(semanticLabel); + } + + Widget dayWidget = Container( + decoration: decoration, + alignment: Alignment.center, + child: Semantics( + label: semanticLabel, + selected: widget.isSelectedDayStart || widget.isSelectedDayEnd, + child: ExcludeSemantics(child: Text(dayText, style: itemStyle)), + ), + ); + + if (highlightPainter != null) { + dayWidget = CustomPaint(painter: highlightPainter, child: dayWidget); + } + + if (!widget.isDisabled) { + dayWidget = InkResponse( + focusNode: widget.focusNode, + onTap: () => widget.onChanged(widget.day), + customBorder: dayShape, + containedInkWell: true, + statesController: _statesController, + overlayColor: dayOverlayColor, + onFocusChange: widget.onFocusChange, + child: dayWidget, + ); + } + + return dayWidget; + } +} + +/// Determines which style to use to paint the highlight. +enum _HighlightPainterStyle { + /// Paints nothing. + none, + + /// Paints a rectangle that occupies the leading half of the space. + highlightLeading, + + /// Paints a rectangle that occupies the trailing half of the space. + highlightTrailing, + + /// Paints a rectangle that occupies all available space. + highlightAll, +} + +/// This custom painter will add a background highlight to its child. +/// +/// This highlight will be drawn depending on the [style], [color], and +/// [textDirection] supplied. It will either paint a rectangle on the +/// left/right, a full rectangle, or nothing at all. This logic is determined by +/// a combination of the [style] and [textDirection]. +class _HighlightPainter extends CustomPainter { + _HighlightPainter({ + required this.color, + this.style = _HighlightPainterStyle.none, + this.textDirection, + }); + + final Color color; + final _HighlightPainterStyle style; + final TextDirection? textDirection; + + @override + void paint(Canvas canvas, Size size) { + if (style == _HighlightPainterStyle.none) { + return; + } + + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final bool rtl = switch (textDirection) { + TextDirection.rtl || null => true, + TextDirection.ltr => false, + }; + + switch (style) { + case _HighlightPainterStyle.highlightLeading when rtl: + case _HighlightPainterStyle.highlightTrailing when !rtl: + canvas.drawRect(Rect.fromLTWH(size.width / 2, 0, size.width / 2, size.height), paint); + case _HighlightPainterStyle.highlightLeading: + case _HighlightPainterStyle.highlightTrailing: + canvas.drawRect(Rect.fromLTWH(0, 0, size.width / 2, size.height), paint); + case _HighlightPainterStyle.highlightAll: + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint); + case _HighlightPainterStyle.none: + break; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +class _InputDateRangePickerDialog extends StatelessWidget { + const _InputDateRangePickerDialog({ + required this.selectedStartDate, + required this.selectedEndDate, + required this.currentDate, + required this.picker, + required this.onConfirm, + required this.onCancel, + required this.confirmText, + required this.cancelText, + required this.helpText, + required this.entryModeButton, + required this.calendarDelegate, + }); + + final DateTime? selectedStartDate; + final DateTime? selectedEndDate; + final DateTime? currentDate; + final Widget picker; + final VoidCallback onConfirm; + final VoidCallback onCancel; + final String? confirmText; + final String? cancelText; + final String? helpText; + final Widget? entryModeButton; + final CalendarDelegate<DateTime> calendarDelegate; + + String _formatDateRange(BuildContext context, DateTime? start, DateTime? end, DateTime now) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String startText = _formatRangeStartDate(localizations, calendarDelegate, start, end); + final String endText = _formatRangeEndDate(localizations, calendarDelegate, start, end, now); + if (start == null || end == null) { + return localizations.unspecifiedDateRange; + } + return switch (Directionality.of(context)) { + TextDirection.rtl => '$endText – $startText', + TextDirection.ltr => '$startText – $endText', + }; + } + + @override + Widget build(BuildContext context) { + final bool useMaterial3 = Theme.of(context).useMaterial3; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final Orientation orientation = MediaQuery.orientationOf(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + + // There's no M3 spec for a landscape layout input (not calendar) + // date range picker. To ensure that the date range displayed in the + // input date range picker's header fits in landscape mode, we override + // the M3 default here. + TextStyle? headlineStyle = (orientation == Orientation.portrait) + ? datePickerTheme.headerHeadlineStyle ?? defaults.headerHeadlineStyle + : Theme.of(context).textTheme.headlineSmall; + + final Color? headerForegroundColor = + datePickerTheme.headerForegroundColor ?? defaults.headerForegroundColor; + headlineStyle = headlineStyle?.copyWith(color: headerForegroundColor); + + final String dateText = _formatDateRange( + context, + selectedStartDate, + selectedEndDate, + currentDate!, + ); + final semanticDateText = selectedStartDate != null && selectedEndDate != null + ? '${calendarDelegate.formatMediumDate(selectedStartDate!, localizations)} – ${calendarDelegate.formatMediumDate(selectedEndDate!, localizations)}' + : ''; + + final Widget header = _DatePickerHeader( + helpText: + helpText ?? + (useMaterial3 + ? localizations.dateRangePickerHelpText + : localizations.dateRangePickerHelpText.toUpperCase()), + titleText: dateText, + titleSemanticsLabel: semanticDateText, + titleStyle: headlineStyle, + orientation: orientation, + isShort: orientation == Orientation.landscape, + entryModeButton: entryModeButton, + ); + + final Widget actions = ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52.0), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: OverflowBar( + spacing: 8, + children: <Widget>[ + TextButton( + onPressed: onCancel, + child: Text( + cancelText ?? + (useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase()), + ), + ), + TextButton( + onPressed: onConfirm, + child: Text(confirmText ?? localizations.okButtonLabel), + ), + ], + ), + ), + ), + ); + + final double textScaleFactor = + MediaQuery.textScalerOf( + context, + ).clamp(maxScaleFactor: _kMaxRangeTextScaleFactor).scale(_fontSizeToScale) / + _fontSizeToScale; + final Size dialogSize = + (useMaterial3 ? _inputPortraitDialogSizeM3 : _inputPortraitDialogSizeM2) * textScaleFactor; + switch (orientation) { + case Orientation.portrait: + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Size portraitDialogSize = useMaterial3 + ? _inputPortraitDialogSizeM3 + : _inputPortraitDialogSizeM2; + // Make sure the portrait dialog can fit the contents comfortably when + // resized from the landscape dialog. + final bool isFullyPortrait = + constraints.maxHeight >= math.min(dialogSize.height, portraitDialogSize.height); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + // When the portrait dialog does not fit vertically, hide the header. + children: <Widget>[ + if (isFullyPortrait) header, + Expanded(child: picker), + actions, + ], + ); + }, + ); + + case Orientation.landscape: + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + header, + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Expanded(child: picker), + actions, + ], + ), + ), + ], + ); + } + } +} + +/// Provides a pair of text fields that allow the user to enter the start and +/// end dates that represent a range of dates. +class _InputDateRangePicker extends StatefulWidget { + /// Creates a row with two text fields configured to accept the start and end dates + /// of a date range. + _InputDateRangePicker({ + super.key, + DateTime? initialStartDate, + DateTime? initialEndDate, + required DateTime firstDate, + required DateTime lastDate, + required this.onStartDateChanged, + required this.onEndDateChanged, + required this.selectableDayPredicate, + required this.calendarDelegate, + this.helpText, + this.errorFormatText, + this.errorInvalidText, + this.errorInvalidRangeText, + this.fieldStartHintText, + this.fieldEndHintText, + this.fieldStartLabelText, + this.fieldEndLabelText, + this.autofocus = false, + this.autovalidate = false, + this.keyboardType = TextInputType.datetime, + }) : initialStartDate = initialStartDate == null + ? null + : calendarDelegate.dateOnly(initialStartDate), + initialEndDate = initialEndDate == null ? null : calendarDelegate.dateOnly(initialEndDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate); + + /// The [DateTime] that represents the start of the initial date range selection. + final DateTime? initialStartDate; + + /// The [DateTime] that represents the end of the initial date range selection. + final DateTime? initialEndDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// Called when the user changes the start date of the selected range. + final ValueChanged<DateTime?>? onStartDateChanged; + + /// Called when the user changes the end date of the selected range. + final ValueChanged<DateTime?>? onEndDateChanged; + + /// The text that is displayed at the top of the header. + /// + /// This is used to indicate to the user what they are selecting a date for. + final String? helpText; + + /// Error text used to indicate the text in a field is not a valid date. + final String? errorFormatText; + + /// Error text used to indicate the date in a field is not in the valid range + /// of [firstDate] - [lastDate]. + final String? errorInvalidText; + + /// Error text used to indicate the dates given don't form a valid date + /// range (i.e. the start date is after the end date). + final String? errorInvalidRangeText; + + /// Hint text shown when the start date field is empty. + final String? fieldStartHintText; + + /// Hint text shown when the end date field is empty. + final String? fieldEndHintText; + + /// Label used for the start date field. + final String? fieldStartLabelText; + + /// Label used for the end date field. + final String? fieldEndLabelText; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// If true, the date fields will validate and update their error text + /// immediately after every change. Otherwise, you must call + /// [_InputDateRangePickerState.validate] to validate. + final bool autovalidate; + + /// {@macro flutter.material.datePickerDialog} + final TextInputType keyboardType; + + final SelectableDayForRangePredicate? selectableDayPredicate; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + _InputDateRangePickerState createState() => _InputDateRangePickerState(); +} + +/// The current state of an [_InputDateRangePicker]. Can be used to +/// [validate] the date field entries. +class _InputDateRangePickerState extends State<_InputDateRangePicker> { + late String _startInputText; + late String _endInputText; + DateTime? _startDate; + DateTime? _endDate; + late TextEditingController _startController; + late TextEditingController _endController; + String? _startErrorText; + String? _endErrorText; + bool _autoSelected = false; + + @override + void initState() { + super.initState(); + _startDate = widget.initialStartDate; + _startController = TextEditingController(); + _endDate = widget.initialEndDate; + _endController = TextEditingController(); + } + + @override + void dispose() { + _startController.dispose(); + _endController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + if (_startDate != null) { + _startInputText = widget.calendarDelegate.formatCompactDate(_startDate!, localizations); + final bool selectText = widget.autofocus && !_autoSelected; + _updateController(_startController, _startInputText, selectText); + _autoSelected = selectText; + } + + if (_endDate != null) { + _endInputText = widget.calendarDelegate.formatCompactDate(_endDate!, localizations); + _updateController(_endController, _endInputText, false); + } + } + + /// Validates that the text in the start and end fields represent a valid + /// date range. + /// + /// Will return true if the range is valid. If not, it will + /// return false and display an appropriate error message under one of the + /// text fields. + bool validate() { + String? startError = _validateDate(_startDate); + final String? endError = _validateDate(_endDate); + if (startError == null && endError == null) { + if (_startDate!.isAfter(_endDate!)) { + startError = + widget.errorInvalidRangeText ?? MaterialLocalizations.of(context).invalidDateRangeLabel; + } + } + setState(() { + _startErrorText = startError; + _endErrorText = endError; + }); + return startError == null && endError == null; + } + + DateTime? _parseDate(String? text) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return widget.calendarDelegate.parseCompactDate(text, localizations); + } + + String? _validateDate(DateTime? date) { + if (date == null) { + return widget.errorFormatText ?? MaterialLocalizations.of(context).invalidDateFormatLabel; + } else if (!_isDaySelectable(date)) { + return widget.errorInvalidText ?? MaterialLocalizations.of(context).dateOutOfRangeLabel; + } + return null; + } + + bool _isDaySelectable(DateTime day) { + if (day.isBefore(widget.firstDate) || day.isAfter(widget.lastDate)) { + return false; + } + if (widget.selectableDayPredicate == null) { + return true; + } + return widget.selectableDayPredicate!(day, _startDate, _endDate); + } + + void _updateController(TextEditingController controller, String text, bool selectText) { + TextEditingValue textEditingValue = controller.value.copyWith(text: text); + if (selectText) { + textEditingValue = textEditingValue.copyWith( + selection: TextSelection(baseOffset: 0, extentOffset: text.length), + ); + } + controller.value = textEditingValue; + } + + void _handleStartChanged(String text) { + setState(() { + _startInputText = text; + _startDate = _parseDate(text); + widget.onStartDateChanged?.call(_startDate); + }); + if (widget.autovalidate) { + validate(); + } + } + + void _handleEndChanged(String text) { + setState(() { + _endInputText = text; + _endDate = _parseDate(text); + widget.onEndDateChanged?.call(_endDate); + }); + if (widget.autovalidate) { + validate(); + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final bool useMaterial3 = theme.useMaterial3; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final InputDecorationThemeData inputTheme = InputDecorationTheme.of(context); + final InputBorder inputBorder = + inputTheme.border ?? + (useMaterial3 ? const OutlineInputBorder() : const UnderlineInputBorder()); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Expanded( + child: TextField( + controller: _startController, + decoration: InputDecoration( + border: inputBorder, + filled: inputTheme.filled, + hintText: + widget.fieldStartHintText ?? widget.calendarDelegate.dateHelpText(localizations), + labelText: widget.fieldStartLabelText ?? localizations.dateRangeStartLabel, + errorText: _startErrorText, + ), + keyboardType: widget.keyboardType, + onChanged: _handleStartChanged, + autofocus: widget.autofocus, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _endController, + decoration: InputDecoration( + border: inputBorder, + filled: inputTheme.filled, + hintText: + widget.fieldEndHintText ?? widget.calendarDelegate.dateHelpText(localizations), + labelText: widget.fieldEndLabelText ?? localizations.dateRangeEndLabel, + errorText: _endErrorText, + ), + keyboardType: widget.keyboardType, + onChanged: _handleEndChanged, + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/lib/src/date_picker_theme.dart b/packages/material_ui/lib/src/date_picker_theme.dart new file mode 100644 index 000000000000..816688dbed1a --- /dev/null +++ b/packages/material_ui/lib/src/date_picker_theme.dart @@ -0,0 +1,1471 @@ +// 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. + +/// @docImport 'calendar_date_picker.dart'; +/// @docImport 'date_picker.dart'; +/// @docImport 'dialog.dart'; +/// @docImport 'input_date_picker_form_field.dart'; +/// @docImport 'material.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'input_decorator.dart'; +import 'text_button.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default values of visual properties for descendant +/// [DatePickerDialog] widgets. +/// +/// Descendant widgets obtain the current [DatePickerThemeData] object with +/// [DatePickerTheme.of]. Instances of [DatePickerThemeData] can +/// be customized with [DatePickerThemeData.copyWith]. +/// +/// Typically a [DatePickerTheme] is specified as part of the overall +/// [Theme] with [ThemeData.datePickerTheme]. +/// +/// All [DatePickerThemeData] properties are null by default. When null, +/// the [DatePickerDialog] computes its own default values, typically based on +/// the overall theme's [ThemeData.colorScheme], [ThemeData.textTheme], and +/// [ThemeData.iconTheme]. +@immutable +class DatePickerThemeData with Diagnosticable { + /// Creates a [DatePickerThemeData] that can be used to override default properties + /// in a [DatePickerTheme] widget. + const DatePickerThemeData({ + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.shape, + this.headerBackgroundColor, + this.headerForegroundColor, + this.headerHeadlineStyle, + this.headerHelpStyle, + this.weekdayStyle, + this.dayStyle, + this.dayForegroundColor, + this.dayBackgroundColor, + this.dayOverlayColor, + this.dayShape, + this.todayForegroundColor, + this.todayBackgroundColor, + this.todayBorder, + this.yearStyle, + this.yearForegroundColor, + this.yearBackgroundColor, + this.yearOverlayColor, + this.yearShape, + this.rangePickerBackgroundColor, + this.rangePickerElevation, + this.rangePickerShadowColor, + this.rangePickerSurfaceTintColor, + this.rangePickerShape, + this.rangePickerHeaderBackgroundColor, + this.rangePickerHeaderForegroundColor, + this.rangePickerHeaderHeadlineStyle, + this.rangePickerHeaderHelpStyle, + this.rangeSelectionBackgroundColor, + this.rangeSelectionOverlayColor, + this.dividerColor, + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + Object? inputDecorationTheme, + this.cancelButtonStyle, + this.confirmButtonStyle, + this.locale, + this.toggleButtonTextStyle, + this.subHeaderForegroundColor, + }) : assert( + inputDecorationTheme == null || + (inputDecorationTheme is InputDecorationTheme || + inputDecorationTheme is InputDecorationThemeData), + ), + _inputDecorationTheme = inputDecorationTheme; + + /// Overrides the default value of [Dialog.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [Dialog.elevation]. + /// + /// See also: + /// [Material.elevation], which explains how elevation is related to a component's shadow. + final double? elevation; + + /// Overrides the default value of [Dialog.shadowColor]. + /// + /// See also: + /// [Material.shadowColor], which explains how the shadow is rendered. + final Color? shadowColor; + + /// Overrides the default value of [Dialog.surfaceTintColor]. + /// + /// See also: + /// [Material.surfaceTintColor], which explains how this color is related to + /// [elevation] and [backgroundColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of [Dialog.shape]. + /// + /// If [elevation] is greater than zero then a shadow is shown and the shadow's + /// shape mirrors the shape of the dialog. + final ShapeBorder? shape; + + /// Overrides the header's default background fill color. + /// + /// The dialog's header displays the currently selected date. + final Color? headerBackgroundColor; + + /// Overrides the header's default color used for text labels and icons. + /// + /// The dialog's header displays the currently selected date. + /// + /// This is used instead of the [TextStyle.color] property of [headerHeadlineStyle] + /// and [headerHelpStyle]. + final Color? headerForegroundColor; + + /// Overrides the header's default headline text style. + /// + /// The dialog's header displays the currently selected date. + /// + /// The [TextStyle.color] of the [headerHeadlineStyle] is not used, + /// [headerForegroundColor] is used instead. + final TextStyle? headerHeadlineStyle; + + /// Overrides the header's default help text style. + /// + /// The help text (also referred to as "supporting text" in the Material + /// spec) is usually a prompt to the user at the top of the header + /// (i.e. 'Select date'). + /// + /// The [TextStyle.color] of the [headerHelpStyle] is not used, + /// [headerForegroundColor] is used instead. + /// + /// See also: + /// [DatePickerDialog.helpText], which specifies the help text. + final TextStyle? headerHelpStyle; + + /// Overrides the default text style used for the row of weekday + /// labels at the top of the date picker grid. + final TextStyle? weekdayStyle; + + /// Overrides the default text style used for each individual day + /// label in the grid of the date picker. + /// + /// The [TextStyle.color] of the [dayStyle] is not used, + /// [dayForegroundColor] is used instead. + final TextStyle? dayStyle; + + /// Overrides the default color used to paint the day labels in the + /// grid of the date picker. + /// + /// This will be used instead of the color provided in [dayStyle]. + /// + /// This supports different colors based on the [WidgetState]s of + /// the day button, such as `WidgetState.selected`, `WidgetState.hovered`, + /// `WidgetState.focused`, and `WidgetState.disabled`. + /// + /// ```dart + /// dayBackgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + /// if (states.contains(WidgetState.selected)) { + /// return Theme.of(context).colorScheme.primary; + /// } + /// return null; // Use the default color. + /// }) + /// ``` + /// + /// See also: + /// * [dayOverlayColor] which applies an overlay over the day labels depending on the [WidgetState]. + final WidgetStateProperty<Color?>? dayForegroundColor; + + /// Overrides the default color used to paint the background of the + /// day labels in the grid of the date picker. + /// + /// This supports different colors based on the [WidgetState]s of + /// the day button, such as `WidgetState.selected`, `WidgetState.hovered`, + /// `WidgetState.focused`, and `WidgetState.disabled`. + /// + /// ```dart + /// dayBackgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + /// if (states.contains(WidgetState.selected)) { + /// return Theme.of(context).colorScheme.primary; + /// } + /// return null; // Use the default color. + /// }) + /// ``` + /// See also: + /// * [dayOverlayColor] which applies an overlay over the day labels depending on the [WidgetState]. + final WidgetStateProperty<Color?>? dayBackgroundColor; + + /// Overrides the default highlight color that's typically used to + /// indicate that a day in the grid is focused, hovered, or pressed. + /// + /// This supports different colors based on the [WidgetState]s of + /// the day button. The overlay color is usually used with an opacity to + /// create hover, focus, and press effects. + /// + /// ```dart + /// dayOverlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + /// if (states.contains(WidgetState.pressed)) { + /// return Colors.blue.withValues(alpha: 0.12); + /// } + /// if (states.contains(WidgetState.hovered)) { + /// return Colors.blue.withValues(alpha: 0.08); + /// } + /// if (states.contains(WidgetState.focused)) { + /// return Colors.blue.withValues(alpha: 0.12); + /// } + /// return null; // Use the default color. + /// }) + /// ``` + final WidgetStateProperty<Color?>? dayOverlayColor; + + /// Overrides the default shape used to paint the shape decoration of the + /// day labels in the grid of the date picker. + /// + /// If the selected day is the current day, the provided shape with the + /// value of [todayBackgroundColor] is used to paint the shape decoration of + /// the day label and the value of [todayBorder] and [todayForegroundColor] is + /// used to paint the border. + /// + /// If the selected day is not the current day, the provided shape with the + /// value of [dayBackgroundColor] is used to paint the shape decoration of + /// the day label. + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the day selector shape decoration + /// using the [dayShape], [todayForegroundColor], [todayBackgroundColor], and + /// [todayBorder] properties. + /// + /// ** See code in examples/api/lib/material/date_picker/date_picker_theme_day_shape.0.dart ** + /// {@end-tool} + final WidgetStateProperty<OutlinedBorder?>? dayShape; + + /// Overrides the default color used to paint the + /// [DatePickerDialog.currentDate] label in the grid of the dialog's + /// [CalendarDatePicker] and the corresponding year in the dialog's + /// [YearPicker]. + /// + /// This will be used instead of the [TextStyle.color] provided in [dayStyle]. + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the day selector shape decoration + /// using the [dayShape], [todayForegroundColor], [todayBackgroundColor], and + /// [todayBorder] properties. + /// + /// ** See code in examples/api/lib/material/date_picker/date_picker_theme_day_shape.0.dart ** + /// {@end-tool} + final WidgetStateProperty<Color?>? todayForegroundColor; + + /// Overrides the default color used to paint the background of the + /// [DatePickerDialog.currentDate] label in the grid of the date picker. + final WidgetStateProperty<Color?>? todayBackgroundColor; + + /// Overrides the border used to paint the + /// [DatePickerDialog.currentDate] label in the grid of the date + /// picker. + /// + /// If the border side's [BorderSide.color] is transparent (has 0 opacity), + /// [todayForegroundColor] is used instead. Otherwise, the border's color + /// is used as specified. To omit the border entirely, + /// set [todayBorder] to [BorderSide.none]. + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the day selector shape decoration + /// using the [dayShape], [todayForegroundColor], [todayBackgroundColor], and + /// [todayBorder] properties. + /// + /// ** See code in examples/api/lib/material/date_picker/date_picker_theme_day_shape.0.dart ** + /// {@end-tool} + final BorderSide? todayBorder; + + /// Overrides the default text style used to paint each of the year + /// entries in the year selector of the date picker. + /// + /// The [TextStyle.color] of the [yearStyle] is not used, + /// [yearForegroundColor] is used instead. + final TextStyle? yearStyle; + + /// Overrides the default color used to paint the year labels in the year + /// selector of the date picker. + /// + /// This will be used instead of the color provided in [yearStyle]. + final WidgetStateProperty<Color?>? yearForegroundColor; + + /// Overrides the default color used to paint the background of the + /// year labels in the year selector of the of the date picker. + final WidgetStateProperty<Color?>? yearBackgroundColor; + + /// Overrides the default highlight color that's typically used to + /// indicate that a year in the year selector is focused, hovered, + /// or pressed. + final WidgetStateProperty<Color?>? yearOverlayColor; + + /// Overrides the default shape used to paint the shape decoration of the + /// year labels in the list of the year picker. + /// + /// If the selected year is the current year, the provided shape with the + /// value of [todayBackgroundColor] is used to paint the shape decoration of + /// the year label and the value of [todayBorder] and [todayForegroundColor] is + /// used to paint the border. + /// + /// If the selected year is not the current year, the provided shape with the + /// value of [yearBackgroundColor] is used to paint the shape decoration of + /// the year label. + final WidgetStateProperty<OutlinedBorder?>? yearShape; + + /// Overrides the default [Scaffold.backgroundColor] for + /// [DateRangePickerDialog]. + final Color? rangePickerBackgroundColor; + + /// Overrides the default elevation of the full screen + /// [DateRangePickerDialog]. + /// + /// See also: + /// [Material.elevation], which explains how elevation is related to a component's shadow. + final double? rangePickerElevation; + + /// Overrides the color of the shadow painted below a full screen + /// [DateRangePickerDialog]. + /// + /// See also: + /// [Material.shadowColor], which explains how the shadow is rendered. + final Color? rangePickerShadowColor; + + /// Overrides the default color of the surface tint overlay applied + /// to the [backgroundColor] of a full screen + /// [DateRangePickerDialog]'s to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// See also: + /// [Material.surfaceTintColor], which explains how this color is related to + /// [elevation]. + final Color? rangePickerSurfaceTintColor; + + /// Overrides the default overall shape of a full screen + /// [DateRangePickerDialog]. + /// + /// If [elevation] is greater than zero then a shadow is shown and the shadow's + /// shape mirrors the shape of the dialog. + /// + /// [Material.surfaceTintColor], which explains how this color is related to + /// [elevation]. + final ShapeBorder? rangePickerShape; + + /// Overrides the default background fill color for [DateRangePickerDialog]. + /// + /// The dialog's header displays the currently selected date range. + final Color? rangePickerHeaderBackgroundColor; + + /// Overrides the default color used for text labels and icons in + /// the header of a full screen [DateRangePickerDialog] + /// + /// The dialog's header displays the currently selected date range. + /// + /// This is used instead of any colors provided by + /// [rangePickerHeaderHeadlineStyle] or [rangePickerHeaderHelpStyle]. + final Color? rangePickerHeaderForegroundColor; + + /// Overrides the default text style used for the headline text in + /// the header of a full screen [DateRangePickerDialog]. + /// + /// The dialog's header displays the currently selected date range. + /// + /// The [TextStyle.color] of [rangePickerHeaderHeadlineStyle] is not used, + /// [rangePickerHeaderForegroundColor] is used instead. + final TextStyle? rangePickerHeaderHeadlineStyle; + + /// Overrides the default text style used for the help text of the + /// header of a full screen [DateRangePickerDialog]. + /// + /// The help text (also referred to as "supporting text" in the Material + /// spec) is usually a prompt to the user at the top of the header + /// (i.e. 'Select date'). + /// + /// The [TextStyle.color] of the [rangePickerHeaderHelpStyle] is not used, + /// [rangePickerHeaderForegroundColor] is used instead. + /// + /// See also: + /// [DateRangePickerDialog.helpText], which specifies the help text. + final TextStyle? rangePickerHeaderHelpStyle; + + /// Overrides the default background color used to paint days + /// selected between the start and end dates in a + /// [DateRangePickerDialog]. + final Color? rangeSelectionBackgroundColor; + + /// Overrides the default highlight color that's typically used to + /// indicate that a date in the selected range of a + /// [DateRangePickerDialog] is focused, hovered, or pressed. + final WidgetStateProperty<Color?>? rangeSelectionOverlayColor; + + /// Overrides the default color used to paint the horizontal divider + /// below the header text when dialog is in portrait orientation + /// and vertical divider when the dialog is in landscape orientation. + final Color? dividerColor; + + /// Overrides the [InputDatePickerFormField]'s input decoration theme. + /// If this is null, [ThemeData.inputDecorationTheme] is used instead. + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + InputDecorationThemeData? get inputDecorationTheme { + if (_inputDecorationTheme == null) { + return null; + } + return _inputDecorationTheme is InputDecorationTheme + ? _inputDecorationTheme.data + : _inputDecorationTheme as InputDecorationThemeData; + } + + final Object? _inputDecorationTheme; + + /// Overrides the default style of the cancel button of a [DatePickerDialog]. + final ButtonStyle? cancelButtonStyle; + + /// Overrides the default style of the confirm (OK) button of a [DatePickerDialog]. + final ButtonStyle? confirmButtonStyle; + + /// An optional [locale] argument can be used to set the locale for the date + /// picker. It defaults to the ambient locale provided by [Localizations]. + final Locale? locale; + + /// Overrides the default text style used for the text of toggle mode button. + /// + /// If no [TextStyle.color] is given, [subHeaderForegroundColor] will be used. + final TextStyle? toggleButtonTextStyle; + + /// Overrides the default color used for text labels and icons of sub header foreground. + /// + /// This is used in [TextStyle.color] property of [toggleButtonTextStyle] if no color is given. + final Color? subHeaderForegroundColor; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + DatePickerThemeData copyWith({ + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + Color? headerBackgroundColor, + Color? headerForegroundColor, + TextStyle? headerHeadlineStyle, + TextStyle? headerHelpStyle, + TextStyle? weekdayStyle, + TextStyle? dayStyle, + WidgetStateProperty<Color?>? dayForegroundColor, + WidgetStateProperty<Color?>? dayBackgroundColor, + WidgetStateProperty<Color?>? dayOverlayColor, + WidgetStateProperty<OutlinedBorder?>? dayShape, + WidgetStateProperty<Color?>? todayForegroundColor, + WidgetStateProperty<Color?>? todayBackgroundColor, + BorderSide? todayBorder, + TextStyle? yearStyle, + WidgetStateProperty<Color?>? yearForegroundColor, + WidgetStateProperty<Color?>? yearBackgroundColor, + WidgetStateProperty<Color?>? yearOverlayColor, + WidgetStateProperty<OutlinedBorder?>? yearShape, + Color? rangePickerBackgroundColor, + double? rangePickerElevation, + Color? rangePickerShadowColor, + Color? rangePickerSurfaceTintColor, + ShapeBorder? rangePickerShape, + Color? rangePickerHeaderBackgroundColor, + Color? rangePickerHeaderForegroundColor, + TextStyle? rangePickerHeaderHeadlineStyle, + TextStyle? rangePickerHeaderHelpStyle, + Color? rangeSelectionBackgroundColor, + WidgetStateProperty<Color?>? rangeSelectionOverlayColor, + Color? dividerColor, + InputDecorationTheme? inputDecorationTheme, + ButtonStyle? cancelButtonStyle, + ButtonStyle? confirmButtonStyle, + Locale? locale, + TextStyle? toggleButtonTextStyle, + Color? subHeaderForegroundColor, + }) { + return DatePickerThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shape: shape ?? this.shape, + headerBackgroundColor: headerBackgroundColor ?? this.headerBackgroundColor, + headerForegroundColor: headerForegroundColor ?? this.headerForegroundColor, + headerHeadlineStyle: headerHeadlineStyle ?? this.headerHeadlineStyle, + headerHelpStyle: headerHelpStyle ?? this.headerHelpStyle, + weekdayStyle: weekdayStyle ?? this.weekdayStyle, + dayStyle: dayStyle ?? this.dayStyle, + dayForegroundColor: dayForegroundColor ?? this.dayForegroundColor, + dayBackgroundColor: dayBackgroundColor ?? this.dayBackgroundColor, + dayOverlayColor: dayOverlayColor ?? this.dayOverlayColor, + dayShape: dayShape ?? this.dayShape, + todayForegroundColor: todayForegroundColor ?? this.todayForegroundColor, + todayBackgroundColor: todayBackgroundColor ?? this.todayBackgroundColor, + todayBorder: todayBorder ?? this.todayBorder, + yearStyle: yearStyle ?? this.yearStyle, + yearForegroundColor: yearForegroundColor ?? this.yearForegroundColor, + yearBackgroundColor: yearBackgroundColor ?? this.yearBackgroundColor, + yearOverlayColor: yearOverlayColor ?? this.yearOverlayColor, + yearShape: yearShape ?? this.yearShape, + rangePickerBackgroundColor: rangePickerBackgroundColor ?? this.rangePickerBackgroundColor, + rangePickerElevation: rangePickerElevation ?? this.rangePickerElevation, + rangePickerShadowColor: rangePickerShadowColor ?? this.rangePickerShadowColor, + rangePickerSurfaceTintColor: rangePickerSurfaceTintColor ?? this.rangePickerSurfaceTintColor, + rangePickerShape: rangePickerShape ?? this.rangePickerShape, + rangePickerHeaderBackgroundColor: + rangePickerHeaderBackgroundColor ?? this.rangePickerHeaderBackgroundColor, + rangePickerHeaderForegroundColor: + rangePickerHeaderForegroundColor ?? this.rangePickerHeaderForegroundColor, + rangePickerHeaderHeadlineStyle: + rangePickerHeaderHeadlineStyle ?? this.rangePickerHeaderHeadlineStyle, + rangePickerHeaderHelpStyle: rangePickerHeaderHelpStyle ?? this.rangePickerHeaderHelpStyle, + rangeSelectionBackgroundColor: + rangeSelectionBackgroundColor ?? this.rangeSelectionBackgroundColor, + rangeSelectionOverlayColor: rangeSelectionOverlayColor ?? this.rangeSelectionOverlayColor, + dividerColor: dividerColor ?? this.dividerColor, + inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme, + cancelButtonStyle: cancelButtonStyle ?? this.cancelButtonStyle, + confirmButtonStyle: confirmButtonStyle ?? this.confirmButtonStyle, + locale: locale ?? this.locale, + toggleButtonTextStyle: toggleButtonTextStyle ?? this.toggleButtonTextStyle, + subHeaderForegroundColor: subHeaderForegroundColor ?? this.subHeaderForegroundColor, + ); + } + + /// Linearly interpolates between two [DatePickerThemeData]. + static DatePickerThemeData lerp(DatePickerThemeData? a, DatePickerThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return DatePickerThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + headerBackgroundColor: Color.lerp(a?.headerBackgroundColor, b?.headerBackgroundColor, t), + headerForegroundColor: Color.lerp(a?.headerForegroundColor, b?.headerForegroundColor, t), + headerHeadlineStyle: TextStyle.lerp(a?.headerHeadlineStyle, b?.headerHeadlineStyle, t), + headerHelpStyle: TextStyle.lerp(a?.headerHelpStyle, b?.headerHelpStyle, t), + weekdayStyle: TextStyle.lerp(a?.weekdayStyle, b?.weekdayStyle, t), + dayStyle: TextStyle.lerp(a?.dayStyle, b?.dayStyle, t), + dayForegroundColor: WidgetStateProperty.lerp<Color?>( + a?.dayForegroundColor, + b?.dayForegroundColor, + t, + Color.lerp, + ), + dayBackgroundColor: WidgetStateProperty.lerp<Color?>( + a?.dayBackgroundColor, + b?.dayBackgroundColor, + t, + Color.lerp, + ), + dayOverlayColor: WidgetStateProperty.lerp<Color?>( + a?.dayOverlayColor, + b?.dayOverlayColor, + t, + Color.lerp, + ), + dayShape: WidgetStateProperty.lerp<OutlinedBorder?>( + a?.dayShape, + b?.dayShape, + t, + OutlinedBorder.lerp, + ), + todayForegroundColor: WidgetStateProperty.lerp<Color?>( + a?.todayForegroundColor, + b?.todayForegroundColor, + t, + Color.lerp, + ), + todayBackgroundColor: WidgetStateProperty.lerp<Color?>( + a?.todayBackgroundColor, + b?.todayBackgroundColor, + t, + Color.lerp, + ), + todayBorder: _lerpBorderSide(a?.todayBorder, b?.todayBorder, t), + yearStyle: TextStyle.lerp(a?.yearStyle, b?.yearStyle, t), + yearForegroundColor: WidgetStateProperty.lerp<Color?>( + a?.yearForegroundColor, + b?.yearForegroundColor, + t, + Color.lerp, + ), + yearBackgroundColor: WidgetStateProperty.lerp<Color?>( + a?.yearBackgroundColor, + b?.yearBackgroundColor, + t, + Color.lerp, + ), + yearOverlayColor: WidgetStateProperty.lerp<Color?>( + a?.yearOverlayColor, + b?.yearOverlayColor, + t, + Color.lerp, + ), + yearShape: WidgetStateProperty.lerp<OutlinedBorder?>( + a?.yearShape, + b?.yearShape, + t, + OutlinedBorder.lerp, + ), + rangePickerBackgroundColor: Color.lerp( + a?.rangePickerBackgroundColor, + b?.rangePickerBackgroundColor, + t, + ), + rangePickerElevation: lerpDouble(a?.rangePickerElevation, b?.rangePickerElevation, t), + rangePickerShadowColor: Color.lerp(a?.rangePickerShadowColor, b?.rangePickerShadowColor, t), + rangePickerSurfaceTintColor: Color.lerp( + a?.rangePickerSurfaceTintColor, + b?.rangePickerSurfaceTintColor, + t, + ), + rangePickerShape: ShapeBorder.lerp(a?.rangePickerShape, b?.rangePickerShape, t), + rangePickerHeaderBackgroundColor: Color.lerp( + a?.rangePickerHeaderBackgroundColor, + b?.rangePickerHeaderBackgroundColor, + t, + ), + rangePickerHeaderForegroundColor: Color.lerp( + a?.rangePickerHeaderForegroundColor, + b?.rangePickerHeaderForegroundColor, + t, + ), + rangePickerHeaderHeadlineStyle: TextStyle.lerp( + a?.rangePickerHeaderHeadlineStyle, + b?.rangePickerHeaderHeadlineStyle, + t, + ), + rangePickerHeaderHelpStyle: TextStyle.lerp( + a?.rangePickerHeaderHelpStyle, + b?.rangePickerHeaderHelpStyle, + t, + ), + rangeSelectionBackgroundColor: Color.lerp( + a?.rangeSelectionBackgroundColor, + b?.rangeSelectionBackgroundColor, + t, + ), + rangeSelectionOverlayColor: WidgetStateProperty.lerp<Color?>( + a?.rangeSelectionOverlayColor, + b?.rangeSelectionOverlayColor, + t, + Color.lerp, + ), + dividerColor: Color.lerp(a?.dividerColor, b?.dividerColor, t), + inputDecorationTheme: t < 0.5 ? a?.inputDecorationTheme : b?.inputDecorationTheme, + cancelButtonStyle: ButtonStyle.lerp(a?.cancelButtonStyle, b?.cancelButtonStyle, t), + confirmButtonStyle: ButtonStyle.lerp(a?.confirmButtonStyle, b?.confirmButtonStyle, t), + locale: t < 0.5 ? a?.locale : b?.locale, + toggleButtonTextStyle: TextStyle.lerp(a?.toggleButtonTextStyle, b?.toggleButtonTextStyle, t), + subHeaderForegroundColor: Color.lerp( + a?.subHeaderForegroundColor, + b?.subHeaderForegroundColor, + t, + ), + ); + } + + static BorderSide? _lerpBorderSide(BorderSide? a, BorderSide? b, double t) { + if (identical(a, b)) { + return a; + } + if (a == null) { + return BorderSide.lerp(BorderSide(width: 0, color: b!.color.withAlpha(0)), b, t); + } + return BorderSide.lerp(a, BorderSide(width: 0, color: a.color.withAlpha(0)), t); + } + + @override + int get hashCode => Object.hashAll(<Object?>[ + backgroundColor, + elevation, + shadowColor, + surfaceTintColor, + shape, + headerBackgroundColor, + headerForegroundColor, + headerHeadlineStyle, + headerHelpStyle, + weekdayStyle, + dayStyle, + dayForegroundColor, + dayBackgroundColor, + dayOverlayColor, + dayShape, + todayForegroundColor, + todayBackgroundColor, + todayBorder, + yearStyle, + yearForegroundColor, + yearBackgroundColor, + yearOverlayColor, + yearShape, + rangePickerBackgroundColor, + rangePickerElevation, + rangePickerShadowColor, + rangePickerSurfaceTintColor, + rangePickerShape, + rangePickerHeaderBackgroundColor, + rangePickerHeaderForegroundColor, + rangePickerHeaderHeadlineStyle, + rangePickerHeaderHelpStyle, + rangeSelectionBackgroundColor, + rangeSelectionOverlayColor, + dividerColor, + inputDecorationTheme, + cancelButtonStyle, + confirmButtonStyle, + locale, + toggleButtonTextStyle, + subHeaderForegroundColor, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is DatePickerThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.shape == shape && + other.headerBackgroundColor == headerBackgroundColor && + other.headerForegroundColor == headerForegroundColor && + other.headerHeadlineStyle == headerHeadlineStyle && + other.headerHelpStyle == headerHelpStyle && + other.weekdayStyle == weekdayStyle && + other.dayStyle == dayStyle && + other.dayForegroundColor == dayForegroundColor && + other.dayBackgroundColor == dayBackgroundColor && + other.dayOverlayColor == dayOverlayColor && + other.dayShape == dayShape && + other.todayForegroundColor == todayForegroundColor && + other.todayBackgroundColor == todayBackgroundColor && + other.todayBorder == todayBorder && + other.yearStyle == yearStyle && + other.yearForegroundColor == yearForegroundColor && + other.yearBackgroundColor == yearBackgroundColor && + other.yearOverlayColor == yearOverlayColor && + other.yearShape == yearShape && + other.rangePickerBackgroundColor == rangePickerBackgroundColor && + other.rangePickerElevation == rangePickerElevation && + other.rangePickerShadowColor == rangePickerShadowColor && + other.rangePickerSurfaceTintColor == rangePickerSurfaceTintColor && + other.rangePickerShape == rangePickerShape && + other.rangePickerHeaderBackgroundColor == rangePickerHeaderBackgroundColor && + other.rangePickerHeaderForegroundColor == rangePickerHeaderForegroundColor && + other.rangePickerHeaderHeadlineStyle == rangePickerHeaderHeadlineStyle && + other.rangePickerHeaderHelpStyle == rangePickerHeaderHelpStyle && + other.rangeSelectionBackgroundColor == rangeSelectionBackgroundColor && + other.rangeSelectionOverlayColor == rangeSelectionOverlayColor && + other.dividerColor == dividerColor && + other.inputDecorationTheme == inputDecorationTheme && + other.cancelButtonStyle == cancelButtonStyle && + other.confirmButtonStyle == confirmButtonStyle && + other.locale == locale && + other.toggleButtonTextStyle == toggleButtonTextStyle && + other.subHeaderForegroundColor == subHeaderForegroundColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + ColorProperty('headerBackgroundColor', headerBackgroundColor, defaultValue: null), + ); + properties.add( + ColorProperty('headerForegroundColor', headerForegroundColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'headerHeadlineStyle', + headerHeadlineStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>('headerHelpStyle', headerHelpStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('weekDayStyle', weekdayStyle, defaultValue: null), + ); + properties.add(DiagnosticsProperty<TextStyle>('dayStyle', dayStyle, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'dayForegroundColor', + dayForegroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'dayBackgroundColor', + dayBackgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'dayOverlayColor', + dayOverlayColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<OutlinedBorder?>>( + 'dayShape', + dayShape, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'todayForegroundColor', + todayForegroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'todayBackgroundColor', + todayBackgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BorderSide?>('todayBorder', todayBorder, defaultValue: null), + ); + properties.add(DiagnosticsProperty<TextStyle>('yearStyle', yearStyle, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'yearForegroundColor', + yearForegroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'yearBackgroundColor', + yearBackgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'yearOverlayColor', + yearOverlayColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<OutlinedBorder?>>( + 'yearShape', + yearShape, + defaultValue: null, + ), + ); + properties.add( + ColorProperty('rangePickerBackgroundColor', rangePickerBackgroundColor, defaultValue: null), + ); + properties.add( + DoubleProperty('rangePickerElevation', rangePickerElevation, defaultValue: null), + ); + properties.add( + ColorProperty('rangePickerShadowColor', rangePickerShadowColor, defaultValue: null), + ); + properties.add( + ColorProperty('rangePickerSurfaceTintColor', rangePickerSurfaceTintColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ShapeBorder>('rangePickerShape', rangePickerShape, defaultValue: null), + ); + properties.add( + ColorProperty( + 'rangePickerHeaderBackgroundColor', + rangePickerHeaderBackgroundColor, + defaultValue: null, + ), + ); + properties.add( + ColorProperty( + 'rangePickerHeaderForegroundColor', + rangePickerHeaderForegroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'rangePickerHeaderHeadlineStyle', + rangePickerHeaderHeadlineStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'rangePickerHeaderHelpStyle', + rangePickerHeaderHelpStyle, + defaultValue: null, + ), + ); + properties.add( + ColorProperty( + 'rangeSelectionBackgroundColor', + rangeSelectionBackgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'rangeSelectionOverlayColor', + rangeSelectionOverlayColor, + defaultValue: null, + ), + ); + properties.add(ColorProperty('dividerColor', dividerColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<InputDecorationThemeData>( + 'inputDecorationTheme', + inputDecorationTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<ButtonStyle>('cancelButtonStyle', cancelButtonStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ButtonStyle>( + 'confirmButtonStyle', + confirmButtonStyle, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>( + 'toggleButtonTextStyle', + toggleButtonTextStyle, + defaultValue: null, + ), + ); + properties.add( + ColorProperty('subHeaderForegroundColor', subHeaderForegroundColor, defaultValue: null), + ); + } +} + +/// An inherited widget that defines the visual properties for +/// [DatePickerDialog]s in this widget's subtree. +/// +/// Values specified here are used for [DatePickerDialog] properties that are not +/// given an explicit non-null value. +class DatePickerTheme extends InheritedTheme { + /// Creates a [DatePickerTheme] that controls visual parameters for + /// descendent [DatePickerDialog]s. + const DatePickerTheme({super.key, required this.data, required super.child}); + + /// Specifies the visual properties used by descendant [DatePickerDialog] + /// widgets. + final DatePickerThemeData data; + + /// The [data] from the closest instance of this class that encloses the given + /// context. + /// + /// If there is no [DatePickerTheme] in scope, this will return + /// [ThemeData.datePickerTheme] from the ambient [Theme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DatePickerThemeData theme = DatePickerTheme.of(context); + /// ``` + /// + /// See also: + /// + /// * [maybeOf], which returns null if it doesn't find a + /// [DatePickerTheme] ancestor. + /// * [defaults], which will return the default properties used when no + /// other [DatePickerTheme] has been provided. + static DatePickerThemeData of(BuildContext context) { + return maybeOf(context) ?? Theme.of(context).datePickerTheme; + } + + /// The data from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Use this function if you want to allow situations where no + /// [DatePickerTheme] is in scope. Prefer using [DatePickerTheme.of] + /// in situations where a [DatePickerThemeData] is expected to be + /// non-null. + /// + /// If there is no [DatePickerTheme] in scope, then this function will + /// return null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DatePickerThemeData? theme = DatePickerTheme.maybeOf(context); + /// if (theme == null) { + /// // Do something else instead. + /// } + /// ``` + /// + /// See also: + /// + /// * [of], which will return the data from [ThemeData.datePickerTheme] if + /// it doesn't find a [DatePickerTheme] ancestor, instead of returning null. + /// * [defaults], which will return the default properties used when no + /// other [DatePickerTheme] has been provided. + static DatePickerThemeData? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<DatePickerTheme>()?.data; + } + + /// A DatePickerThemeData used as the default properties for date pickers. + /// + /// This is only used for properties not already specified in the ambient + /// [DatePickerTheme.of]. + /// + /// See also: + /// + /// * [of], which will return the data from [ThemeData.datePickerTheme] if + /// it doesn't find a [DatePickerTheme] ancestor, instead of returning null. + /// * [maybeOf], which returns null if it doesn't find a + /// [DatePickerTheme] ancestor. + static DatePickerThemeData defaults(BuildContext context) { + return Theme.of(context).useMaterial3 + ? _DatePickerDefaultsM3(context) + : _DatePickerDefaultsM2(context); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return DatePickerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(DatePickerTheme oldWidget) => data != oldWidget.data; +} + +// Hand coded defaults based on Material Design 2. +class _DatePickerDefaultsM2 extends DatePickerThemeData { + _DatePickerDefaultsM2(this.context) + : super( + elevation: 24.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + dayShape: const WidgetStatePropertyAll<OutlinedBorder>(CircleBorder()), + yearShape: const WidgetStatePropertyAll<OutlinedBorder>(StadiumBorder()), + rangePickerElevation: 0.0, + rangePickerShape: const RoundedRectangleBorder(), + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + late final bool _isDark = _colors.brightness == Brightness.dark; + + @override + Color? get headerBackgroundColor => _isDark ? _colors.surface : _colors.primary; + + @override + Color? get subHeaderForegroundColor => _colors.onSurface.withOpacity(0.60); + + @override + TextStyle? get toggleButtonTextStyle => + _textTheme.titleSmall?.apply(color: subHeaderForegroundColor); + + @override + ButtonStyle get cancelButtonStyle { + return TextButton.styleFrom(); + } + + @override + ButtonStyle get confirmButtonStyle { + return TextButton.styleFrom(); + } + + @override + Color? get headerForegroundColor => _isDark ? _colors.onSurface : _colors.onPrimary; + + @override + TextStyle? get headerHeadlineStyle => _textTheme.headlineSmall; + + @override + TextStyle? get headerHelpStyle => _textTheme.labelSmall; + + @override + TextStyle? get weekdayStyle => + _textTheme.bodySmall?.apply(color: _colors.onSurface.withOpacity(0.60)); + + @override + TextStyle? get dayStyle => _textTheme.bodySmall; + + @override + WidgetStateProperty<Color?>? get dayForegroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } else if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onSurface; + }); + + @override + WidgetStateProperty<Color?>? get dayBackgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.primary; + } + return null; + }); + + @override + WidgetStateProperty<Color?>? get dayOverlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.38); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.12); + } + } else { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant.withOpacity(0.12); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.12); + } + } + return null; + }); + + @override + WidgetStateProperty<Color?>? get todayForegroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } else if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get todayBackgroundColor => dayBackgroundColor; + + @override + BorderSide? get todayBorder => BorderSide(color: _colors.primary); + + @override + TextStyle? get yearStyle => _textTheme.bodyLarge; + + @override + Color? get rangePickerBackgroundColor => _colors.surface; + + @override + Color? get rangePickerShadowColor => Colors.transparent; + + @override + Color? get rangePickerSurfaceTintColor => Colors.transparent; + + @override + Color? get rangePickerHeaderBackgroundColor => _isDark ? _colors.surface : _colors.primary; + + @override + Color? get rangePickerHeaderForegroundColor => _isDark ? _colors.onSurface : _colors.onPrimary; + + @override + TextStyle? get rangePickerHeaderHeadlineStyle => _textTheme.headlineSmall; + + @override + TextStyle? get rangePickerHeaderHelpStyle => _textTheme.labelSmall; + + @override + Color? get rangeSelectionBackgroundColor => _colors.primary.withOpacity(0.12); + + @override + WidgetStateProperty<Color?>? get rangeSelectionOverlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.38); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.12); + } + } else { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant.withOpacity(0.12); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.12); + } + } + return null; + }); +} +// BEGIN GENERATED TOKEN PROPERTIES - DatePicker + +// 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. + +// dart format off +class _DatePickerDefaultsM3 extends DatePickerThemeData { + _DatePickerDefaultsM3(this.context) + : super( + elevation: 6.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + // TODO(tahatesser): Update this to use token when gen_defaults + // supports `CircleBorder` for fully rounded corners. + dayShape: const WidgetStatePropertyAll<OutlinedBorder>(CircleBorder()), + yearShape: const WidgetStatePropertyAll<OutlinedBorder>(StadiumBorder()), + rangePickerElevation: 0.0, + rangePickerShape: const RoundedRectangleBorder(), + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get backgroundColor => _colors.surfaceContainerHigh; + + @override + Color? get subHeaderForegroundColor => _colors.onSurface.withOpacity(0.60); + + @override + TextStyle? get toggleButtonTextStyle => _textTheme.titleSmall?.apply( + color: subHeaderForegroundColor, + ); + + @override + ButtonStyle get cancelButtonStyle { + return TextButton.styleFrom(); + } + + @override + ButtonStyle get confirmButtonStyle { + return TextButton.styleFrom(); + } + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get headerBackgroundColor => Colors.transparent; + + @override + Color? get headerForegroundColor => _colors.onSurfaceVariant; + + @override + TextStyle? get headerHeadlineStyle => _textTheme.headlineLarge; + + @override + TextStyle? get headerHelpStyle => _textTheme.labelLarge; + + @override + TextStyle? get weekdayStyle => _textTheme.bodyLarge?.apply( + color: _colors.onSurface, + ); + + @override + TextStyle? get dayStyle => _textTheme.bodyLarge; + + @override + WidgetStateProperty<Color?>? get dayForegroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } else if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onSurface; + }); + + @override + WidgetStateProperty<Color?>? get dayBackgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.primary; + } + return null; + }); + + @override + WidgetStateProperty<Color?>? get dayOverlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.1); + } + } else { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + } + return null; + }); + + @override + WidgetStateProperty<Color?>? get todayForegroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } else if (states.contains(WidgetState.disabled)) { + return _colors.primary.withOpacity(0.38); + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get todayBackgroundColor => dayBackgroundColor; + + @override + BorderSide? get todayBorder => BorderSide(color: _colors.primary); + + @override + TextStyle? get yearStyle => _textTheme.bodyLarge; + + @override + WidgetStateProperty<Color?>? get yearForegroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } else if (states.contains(WidgetState.disabled)) { + return _colors.onSurfaceVariant.withOpacity(0.38); + } + return _colors.onSurfaceVariant; + }); + + @override + WidgetStateProperty<Color?>? get yearBackgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.primary; + } + return null; + }); + + @override + WidgetStateProperty<Color?>? get yearOverlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.1); + } + } else { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + } + return null; + }); + + @override + Color? get rangePickerShadowColor => Colors.transparent; + + @override + Color? get rangePickerSurfaceTintColor => Colors.transparent; + + @override + Color? get rangeSelectionBackgroundColor => _colors.secondaryContainer; + + @override + WidgetStateProperty<Color?>? get rangeSelectionOverlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimaryContainer.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimaryContainer.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimaryContainer.withOpacity(0.1); + } + return null; + }); + + @override + Color? get rangePickerHeaderBackgroundColor => Colors.transparent; + + @override + Color? get rangePickerHeaderForegroundColor => _colors.onSurfaceVariant; + + @override + TextStyle? get rangePickerHeaderHeadlineStyle => _textTheme.titleLarge; + + @override + TextStyle? get rangePickerHeaderHelpStyle => _textTheme.titleSmall; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - DatePicker diff --git a/packages/material_ui/lib/src/debug.dart b/packages/material_ui/lib/src/debug.dart new file mode 100644 index 000000000000..579ac2c7aa3a --- /dev/null +++ b/packages/material_ui/lib/src/debug.dart @@ -0,0 +1,197 @@ +// 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/widgets.dart'; + +import 'material.dart'; +import 'material_localizations.dart'; +import 'scaffold.dart' show Scaffold, ScaffoldMessenger; + +// Examples can assume: +// late BuildContext context; + +/// Asserts that the given context has a [Material] ancestor within the closest +/// [LookupBoundary]. +/// +/// Used by many Material Design widgets to make sure that they are +/// only used in contexts where they can print ink onto some material. +/// +/// To call this function, use the following pattern, typically in the +/// relevant Widget's build method: +/// +/// ```dart +/// assert(debugCheckHasMaterial(context)); +/// ``` +/// +/// Always place this before any early returns, so that the invariant is checked +/// in all cases. This prevents bugs from hiding until a particular codepath is +/// hit. +/// +/// This method can be expensive (it walks the element tree). +/// +/// Does nothing if asserts are disabled. Always returns true. +bool debugCheckHasMaterial(BuildContext context) { + assert(() { + if (LookupBoundary.findAncestorWidgetOfExactType<Material>(context) == null) { + final bool hiddenByBoundary = LookupBoundary.debugIsHidingAncestorWidgetOfExactType<Material>( + context, + ); + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary( + 'No Material widget found${hiddenByBoundary ? ' within the closest LookupBoundary' : ''}.', + ), + if (hiddenByBoundary) + ErrorDescription( + 'There is an ancestor Material widget, but it is hidden by a LookupBoundary.', + ), + ErrorDescription( + '${context.widget.runtimeType} widgets require a Material ' + 'widget ancestor within the closest LookupBoundary.\n' + 'In Material Design, most widgets are conceptually "printed" on ' + "a sheet of material. In Flutter's material library, that " + 'material is represented by the Material widget. It is the ' + 'Material widget that renders ink splashes, for instance. ' + 'Because of this, many material library widgets require that ' + 'there be a Material widget in the tree above them.', + ), + ErrorHint( + 'To introduce a Material widget, you can either directly ' + 'include one, or use a widget that contains Material itself, ' + 'such as a Card, Dialog, Drawer, or Scaffold.', + ), + ...context.describeMissingAncestor(expectedAncestorType: Material), + ]); + } + return true; + }()); + return true; +} + +/// Asserts that the given context has a [Localizations] ancestor that contains +/// a [MaterialLocalizations] delegate. +/// +/// Used by many Material Design widgets to make sure that they are +/// only used in contexts where they have access to localizations. +/// +/// To call this function, use the following pattern, typically in the +/// relevant Widget's build method: +/// +/// ```dart +/// assert(debugCheckHasMaterialLocalizations(context)); +/// ``` +/// +/// Always place this before any early returns, so that the invariant is checked +/// in all cases. This prevents bugs from hiding until a particular codepath is +/// hit. +/// +/// This function has the side-effect of establishing an inheritance +/// relationship with the nearest [Localizations] widget (see +/// [BuildContext.dependOnInheritedWidgetOfExactType]). This is ok if the caller +/// always also calls [Localizations.of] or [Localizations.localeOf]. +/// +/// Does nothing if asserts are disabled. Always returns true. +bool debugCheckHasMaterialLocalizations(BuildContext context) { + assert(() { + if (Localizations.of<MaterialLocalizations>(context, MaterialLocalizations) == null) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('No MaterialLocalizations found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require MaterialLocalizations ' + 'to be provided by a Localizations widget ancestor.', + ), + ErrorDescription( + 'The material library uses Localizations to generate messages, ' + 'labels, and abbreviations.', + ), + ErrorHint( + 'To introduce a MaterialLocalizations, either use a ' + 'MaterialApp at the root of your application to include them ' + 'automatically, or add a Localization widget with a ' + 'MaterialLocalizations delegate.', + ), + ...context.describeMissingAncestor(expectedAncestorType: MaterialLocalizations), + ]); + } + return true; + }()); + return true; +} + +/// Asserts that the given context has a [Scaffold] ancestor. +/// +/// Used by various widgets to make sure that they are only used in an +/// appropriate context. +/// +/// To invoke this function, use the following pattern, typically in the +/// relevant Widget's build method: +/// +/// ```dart +/// assert(debugCheckHasScaffold(context)); +/// ``` +/// +/// Always place this before any early returns, so that the invariant is checked +/// in all cases. This prevents bugs from hiding until a particular codepath is +/// hit. +/// +/// This method can be expensive (it walks the element tree). +/// +/// Does nothing if asserts are disabled. Always returns true. +bool debugCheckHasScaffold(BuildContext context) { + assert(() { + if (context.widget is! Scaffold && context.findAncestorWidgetOfExactType<Scaffold>() == null) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('No Scaffold widget found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require a Scaffold widget ancestor.', + ), + ...context.describeMissingAncestor(expectedAncestorType: Scaffold), + ErrorHint( + 'Typically, the Scaffold widget is introduced by the MaterialApp or ' + 'WidgetsApp widget at the top of your application widget tree.', + ), + ]); + } + return true; + }()); + return true; +} + +/// Asserts that the given context has a [ScaffoldMessenger] ancestor. +/// +/// Used by various widgets to make sure that they are only used in an +/// appropriate context. +/// +/// To invoke this function, use the following pattern, typically in the +/// relevant Widget's build method: +/// +/// ```dart +/// assert(debugCheckHasScaffoldMessenger(context)); +/// ``` +/// +/// Always place this before any early returns, so that the invariant is checked +/// in all cases. This prevents bugs from hiding until a particular codepath is +/// hit. +/// +/// This method can be expensive (it walks the element tree). +/// +/// Does nothing if asserts are disabled. Always returns true. +bool debugCheckHasScaffoldMessenger(BuildContext context) { + assert(() { + if (context.findAncestorWidgetOfExactType<ScaffoldMessenger>() == null) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('No ScaffoldMessenger widget found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require a ScaffoldMessenger widget ancestor.', + ), + ...context.describeMissingAncestor(expectedAncestorType: ScaffoldMessenger), + ErrorHint( + 'Typically, the ScaffoldMessenger widget is introduced by the MaterialApp ' + 'at the top of your application widget tree.', + ), + ]); + } + return true; + }()); + return true; +} diff --git a/packages/material_ui/lib/src/desktop_text_selection.dart b/packages/material_ui/lib/src/desktop_text_selection.dart new file mode 100644 index 000000000000..c3a28e9df7bb --- /dev/null +++ b/packages/material_ui/lib/src/desktop_text_selection.dart @@ -0,0 +1,227 @@ +// 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' show ValueListenable, clampDouble; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'desktop_text_selection_toolbar.dart'; +import 'desktop_text_selection_toolbar_button.dart'; +import 'material_localizations.dart'; + +/// Desktop Material styled text selection handle controls. +/// +/// Specifically does not manage the toolbar, which is left to +/// [EditableText.contextMenuBuilder]. +class _DesktopTextSelectionHandleControls extends DesktopTextSelectionControls + with TextSelectionHandleControls {} + +/// Desktop Material styled text selection controls. +/// +/// The [desktopTextSelectionControls] global variable has a +/// suitable instance of this class. +class DesktopTextSelectionControls extends TextSelectionControls { + /// Desktop has no text selection handles. + @override + Size getHandleSize(double textLineHeight) { + return Size.zero; + } + + /// Builder for the Material-style desktop copy/paste text selection toolbar. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List<TextSelectionPoint> endpoints, + TextSelectionDelegate delegate, + ValueListenable<ClipboardStatus>? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return _DesktopTextSelectionControlsToolbar( + clipboardStatus: clipboardStatus, + endpoints: endpoints, + globalEditableRegion: globalEditableRegion, + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, + selectionMidpoint: selectionMidpoint, + lastSecondaryTapDownPosition: lastSecondaryTapDownPosition, + textLineHeight: textLineHeight, + ); + } + + /// Builds the text selection handles, but desktop has none. + @override + Widget buildHandle( + BuildContext context, + TextSelectionHandleType type, + double textLineHeight, [ + VoidCallback? onTap, + ]) { + return const SizedBox.shrink(); + } + + /// Gets the position for the text selection handles, but desktop has none. + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + return Offset.zero; + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + bool canSelectAll(TextSelectionDelegate delegate) { + // Allow SelectAll when selection is not collapsed, unless everything has + // already been selected. Same behavior as Android. + final TextEditingValue value = delegate.textEditingValue; + return delegate.selectAllEnabled && + value.text.isNotEmpty && + !(value.selection.start == 0 && value.selection.end == value.text.length); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void handleSelectAll(TextSelectionDelegate delegate) { + super.handleSelectAll(delegate); + delegate.hideToolbar(); + } +} + +// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is +// deleted, when users should migrate back to desktopTextSelectionControls. +// See https://github.com/flutter/flutter/pull/124262 +/// Desktop text selection handle controls that loosely follow Material design +/// conventions. +final TextSelectionControls desktopTextSelectionHandleControls = + _DesktopTextSelectionHandleControls(); + +/// Desktop text selection controls that loosely follow Material design +/// conventions. +final TextSelectionControls desktopTextSelectionControls = DesktopTextSelectionControls(); + +// Generates the child that's passed into DesktopTextSelectionToolbar. +class _DesktopTextSelectionControlsToolbar extends StatefulWidget { + const _DesktopTextSelectionControlsToolbar({ + required this.clipboardStatus, + required this.endpoints, + required this.globalEditableRegion, + required this.handleCopy, + required this.handleCut, + required this.handlePaste, + required this.handleSelectAll, + required this.selectionMidpoint, + required this.textLineHeight, + required this.lastSecondaryTapDownPosition, + }); + + final ValueListenable<ClipboardStatus>? clipboardStatus; + final List<TextSelectionPoint> endpoints; + final Rect globalEditableRegion; + final VoidCallback? handleCopy; + final VoidCallback? handleCut; + final VoidCallback? handlePaste; + final VoidCallback? handleSelectAll; + final Offset? lastSecondaryTapDownPosition; + final Offset selectionMidpoint; + final double textLineHeight; + + @override + _DesktopTextSelectionControlsToolbarState createState() => + _DesktopTextSelectionControlsToolbarState(); +} + +class _DesktopTextSelectionControlsToolbarState + extends State<_DesktopTextSelectionControlsToolbar> { + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + + @override + void didUpdateWidget(_DesktopTextSelectionControlsToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clipboardStatus != widget.clipboardStatus) { + oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + } + + @override + void dispose() { + widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasMediaQuery(context)); + + // Don't render the menu until the state of the clipboard is known. + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) { + return const SizedBox.shrink(); + } + + final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); + final midpointAnchor = Offset( + clampDouble( + widget.selectionMidpoint.dx - widget.globalEditableRegion.left, + mediaQueryPadding.left, + MediaQuery.widthOf(context) - mediaQueryPadding.right, + ), + widget.selectionMidpoint.dy - widget.globalEditableRegion.top, + ); + + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final items = <Widget>[]; + + void addToolbarButton(String text, VoidCallback onPressed) { + items.add( + DesktopTextSelectionToolbarButton.text(context: context, onPressed: onPressed, text: text), + ); + } + + if (widget.handleCut != null) { + addToolbarButton(localizations.cutButtonLabel, widget.handleCut!); + } + if (widget.handleCopy != null) { + addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!); + } + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.pasteable) { + addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!); + } + if (widget.handleSelectAll != null) { + addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!); + } + + // If there is no option available, build an empty widget. + if (items.isEmpty) { + return const SizedBox.shrink(); + } + + return DesktopTextSelectionToolbar( + anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor, + children: items, + ); + } +} diff --git a/packages/material_ui/lib/src/desktop_text_selection_toolbar.dart b/packages/material_ui/lib/src/desktop_text_selection_toolbar.dart new file mode 100644 index 000000000000..b51f086f6e9c --- /dev/null +++ b/packages/material_ui/lib/src/desktop_text_selection_toolbar.dart @@ -0,0 +1,88 @@ +// 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. + +/// @docImport 'adaptive_text_selection_toolbar.dart'; +/// @docImport 'desktop_text_selection_toolbar_button.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'material.dart'; +import 'text_selection_toolbar.dart'; + +// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on +// a Macbook Pro. +const double _kToolbarScreenPadding = 8.0; +const double _kToolbarWidth = 222.0; + +/// A Material-style desktop text selection toolbar. +/// +/// Typically displays buttons for text manipulation, e.g. copying and pasting +/// text. +/// +/// Tries to position its top left corner as closely as possible to [anchor] +/// while remaining fully inside the viewport. +/// +/// See also: +/// +/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current +/// platform. +/// * [TextSelectionToolbar], which is similar, but builds an Android-style +/// toolbar. +class DesktopTextSelectionToolbar extends StatelessWidget { + /// Creates a const instance of DesktopTextSelectionToolbar. + const DesktopTextSelectionToolbar({super.key, required this.anchor, required this.children}) + : assert(children.length > 0); + + /// {@template flutter.material.DesktopTextSelectionToolbar.anchor} + /// The point where the toolbar will attempt to position itself as closely as + /// possible. + /// {@endtemplate} + final Offset anchor; + + /// {@macro flutter.material.TextSelectionToolbar.children} + /// + /// See also: + /// * [DesktopTextSelectionToolbarButton], which builds a default + /// Material-style desktop text selection toolbar text button. + final List<Widget> children; + + // Builds a desktop toolbar in the Material style. + static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { + return SizedBox( + width: _kToolbarWidth, + child: Material( + borderRadius: const BorderRadius.all(Radius.circular(7.0)), + clipBehavior: Clip.antiAlias, + elevation: 1.0, + type: MaterialType.card, + child: child, + ), + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + final double paddingAbove = MediaQuery.paddingOf(context).top + _kToolbarScreenPadding; + final localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove); + + return Padding( + padding: EdgeInsets.fromLTRB( + _kToolbarScreenPadding, + paddingAbove, + _kToolbarScreenPadding, + _kToolbarScreenPadding, + ), + child: CustomSingleChildLayout( + delegate: DesktopTextSelectionToolbarLayoutDelegate(anchor: anchor - localAdjustment), + child: _defaultToolbarBuilder( + context, + Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/desktop_text_selection_toolbar_button.dart b/packages/material_ui/lib/src/desktop_text_selection_toolbar_button.dart new file mode 100644 index 000000000000..345fea77ecf7 --- /dev/null +++ b/packages/material_ui/lib/src/desktop_text_selection_toolbar_button.dart @@ -0,0 +1,77 @@ +// 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/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'text_button.dart'; +import 'theme.dart'; + +const TextStyle _kToolbarButtonFontStyle = TextStyle( + inherit: false, + fontSize: 14.0, + letterSpacing: -0.15, + fontWeight: FontWeight.w400, +); + +const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(20.0, 0.0, 20.0, 3.0); + +/// A [TextButton] for the Material desktop text selection toolbar. +class DesktopTextSelectionToolbarButton extends StatelessWidget { + /// Creates an instance of DesktopTextSelectionToolbarButton. + const DesktopTextSelectionToolbarButton({ + super.key, + required this.onPressed, + required this.child, + }); + + /// Create an instance of [DesktopTextSelectionToolbarButton] whose child is + /// a [Text] widget in the style of the Material text selection toolbar. + DesktopTextSelectionToolbarButton.text({ + super.key, + required BuildContext context, + required this.onPressed, + required String text, + }) : child = Text( + text, + overflow: TextOverflow.ellipsis, + style: _kToolbarButtonFontStyle.copyWith( + color: Theme.of(context).colorScheme.brightness == Brightness.dark + ? Colors.white + : Colors.black87, + ), + ); + + /// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed} + final VoidCallback? onPressed; + + /// {@macro flutter.material.TextSelectionToolbarTextButton.child} + final Widget child; + + @override + Widget build(BuildContext context) { + // TODO(hansmuller): Should be colorScheme.onSurface + final ThemeData theme = Theme.of(context); + final isDark = theme.colorScheme.brightness == Brightness.dark; + final Color foregroundColor = isDark ? Colors.white : Colors.black87; + + return SizedBox( + width: double.infinity, + child: TextButton( + style: TextButton.styleFrom( + alignment: Alignment.centerLeft, + enabledMouseCursor: SystemMouseCursors.basic, + disabledMouseCursor: SystemMouseCursors.basic, + foregroundColor: foregroundColor, + shape: const RoundedRectangleBorder(), + minimumSize: const Size(kMinInteractiveDimension, 36.0), + padding: _kToolbarButtonPadding, + ), + onPressed: onPressed, + child: child, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/dialog.dart b/packages/material_ui/lib/src/dialog.dart new file mode 100644 index 000000000000..d916b0d23ca7 --- /dev/null +++ b/packages/material_ui/lib/src/dialog.dart @@ -0,0 +1,1998 @@ +// 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. + +/// @docImport 'dart:ui'; +/// @docImport 'package:flutter/semantics.dart'; +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:ui' show SemanticsHitTestBehavior, SemanticsRole, clampDouble, lerpDouble; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'dialog_theme.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// enum Department { treasury, state } +// late BuildContext context; + +const EdgeInsets _defaultInsetPadding = EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0); + +/// A Material Design dialog. +/// +/// This dialog widget does not have any opinion about the contents of the +/// dialog. Rather than using this widget directly, consider using [AlertDialog] +/// or [SimpleDialog], which implement specific kinds of Material Design +/// dialogs. +/// +/// {@tool dartpad} +/// This sample shows the creation of [Dialog] and [Dialog.fullscreen] widgets. +/// +/// ** See code in examples/api/lib/material/dialog/dialog.0.dart ** +/// {@end-tool} +/// +/// ## Contraints +/// The Material 3 guideline recommends that a dialog should have a maximal width of 560dp. +/// For historical reasons, Flutter's [Dialog] widget does not come with this constraint by default. +/// For applications targeting large screens such as desktop or Web, it is recommended to +/// set the [constraints] property. +/// +/// {@tool snippet} +/// This sample shows a [Dialog] using [BoxConstraints] defined by the Material 3 specification. +/// +/// ```dart +/// const Dialog(constraints: BoxConstraints(maxWidth: 560, minHeight: 280)); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [AlertDialog], for dialogs that have a message and some buttons. +/// * [SimpleDialog], for dialogs that offer a variety of options. +/// * [showDialog], which actually displays the dialog and returns its result. +/// * <https://material.io/design/components/dialogs.html> +class Dialog extends StatelessWidget { + /// Creates a dialog. + /// + /// Typically used in conjunction with [showDialog]. + const Dialog({ + super.key, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.insetAnimationDuration = const Duration(milliseconds: 100), + this.insetAnimationCurve = Curves.decelerate, + this.insetPadding, + this.clipBehavior, + this.shape, + this.alignment, + this.child, + this.semanticsRole = SemanticsRole.dialog, + this.constraints, + }) : assert(elevation == null || elevation >= 0.0), + _fullscreen = false; + + /// Creates a fullscreen dialog. + /// + /// Typically used in conjunction with [showDialog]. + const Dialog.fullscreen({ + super.key, + this.backgroundColor, + this.insetAnimationDuration = Duration.zero, + this.insetAnimationCurve = Curves.decelerate, + this.child, + this.semanticsRole = SemanticsRole.dialog, + }) : elevation = 0, + shadowColor = null, + surfaceTintColor = null, + insetPadding = EdgeInsets.zero, + clipBehavior = Clip.none, + shape = null, + alignment = null, + constraints = null, + _fullscreen = true; + + /// {@template flutter.material.dialog.backgroundColor} + /// The background color of the surface of this [Dialog]. + /// + /// This sets the [Material.color] on this [Dialog]'s [Material]. + /// + /// If null, then the [DialogThemeData.backgroundColor] is used. If that is + /// also null, defaults to [ColorScheme.surfaceContainerHigh]. If + /// [ThemeData.useMaterial3] is false, defaults to [Colors.grey] with a shade + /// of 800 in dark theme and [Colors.white] in light theme. + /// + /// If [Dialog.fullscreen] is used, defaults to [ColorScheme.surface]. + /// {@endtemplate} + final Color? backgroundColor; + + /// {@template flutter.material.dialog.elevation} + /// The z-coordinate of this [Dialog]. + /// + /// Controls how far above the parent the dialog will appear. Elevation is + /// represented by a drop shadow if [shadowColor] is non null, + /// and a surface tint overlay on the background color if [surfaceTintColor] is + /// non null. + /// + /// If null then [DialogThemeData.elevation] is used, and if that is null then + /// the elevation will match the Material Design specification for Dialogs. + /// + /// See also: + /// * [Material.elevation], which describes how [elevation] effects the + /// drop shadow or surface tint overlay. + /// * [shadowColor], color of the drop shadow used to indicate the elevation. + /// * [surfaceTintColor], color of an overlay on top of the background + /// color used to indicate the elevation. + /// * <https://m3.material.io/components/dialogs/overview>, the Material + /// Design specification for dialogs. + /// {@endtemplate} + final double? elevation; + + /// {@template flutter.material.dialog.shadowColor} + /// The color used to paint a drop shadow under the dialog's [Material], + /// which reflects the dialog's [elevation]. + /// + /// If null and [ThemeData.useMaterial3] is true then no drop shadow will + /// be rendered. + /// + /// If null and [ThemeData.useMaterial3] is false then it will default to + /// [ThemeData.shadowColor]. + /// + /// See also: + /// * [Material.shadowColor], which describes how the drop shadow is painted. + /// * [elevation], which affects how the drop shadow is painted. + /// * [surfaceTintColor], which can be used to indicate elevation through + /// tinting the background color. + /// {@endtemplate} + final Color? shadowColor; + + /// {@template flutter.material.dialog.surfaceTintColor} + /// The color used as a surface tint overlay on the dialog's background color, + /// which reflects the dialog's [elevation]. + /// + /// If [ThemeData.useMaterial3] is false property has no effect. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// defaults to [Colors.transparent]. + /// + /// To disable this feature, set [surfaceTintColor] to [Colors.transparent]. + /// + /// See also: + /// * [Material.surfaceTintColor], which describes how the surface tint will + /// be applied to the background color of the dialog. + /// * [elevation], which affects the opacity of the surface tint. + /// * [shadowColor], which can be used to indicate elevation through + /// a drop shadow. + /// {@endtemplate} + final Color? surfaceTintColor; + + /// {@template flutter.material.dialog.insetAnimationDuration} + /// The duration of the animation to show when the system keyboard intrudes + /// into the space that the dialog is placed in. + /// + /// Defaults to 100 milliseconds when [Dialog] is used, and [Duration.zero] + /// when [Dialog.fullscreen] is used. + /// {@endtemplate} + final Duration insetAnimationDuration; + + /// {@template flutter.material.dialog.insetAnimationCurve} + /// The curve to use for the animation shown when the system keyboard intrudes + /// into the space that the dialog is placed in. + /// + /// Defaults to [Curves.decelerate]. + /// {@endtemplate} + final Curve insetAnimationCurve; + + /// {@template flutter.material.dialog.insetPadding} + /// The amount of padding added to [MediaQueryData.viewInsets] on the outside + /// of the dialog. This defines the minimum space between the screen's edges + /// and the dialog. + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0)`. + /// {@endtemplate} + final EdgeInsets? insetPadding; + + /// {@template flutter.material.dialog.clipBehavior} + /// Controls how the contents of the dialog are clipped (or not) to the given + /// [shape]. + /// + /// See the enum [Clip] for details of all possible options and their common + /// use cases. + /// + /// If null, then [DialogThemeData.clipBehavior] is used. If that is also null, + /// defaults to [Clip.none]. + /// {@endtemplate} + final Clip? clipBehavior; + + /// {@template flutter.material.dialog.shape} + /// The shape of this dialog's border. + /// + /// Defines the dialog's [Material.shape]. + /// + /// The default shape is a [RoundedRectangleBorder] with a radius of 4.0 + /// {@endtemplate} + final ShapeBorder? shape; + + /// {@template flutter.material.dialog.alignment} + /// How to align the [Dialog]. + /// + /// If null, then [DialogThemeData.alignment] is used. If that is also null, the + /// default is [Alignment.center]. + /// {@endtemplate} + final AlignmentGeometry? alignment; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// This value is used to determine if this is a fullscreen dialog. + final bool _fullscreen; + + /// The role this dialog represent in assist technologies. + /// + /// Defaults to [SemanticsRole.dialog]. + final SemanticsRole semanticsRole; + + /// {@template flutter.material.dialog.constraints} + /// Constrains the size of the dialog. + /// + /// If null, then [DialogThemeData.constraints] is used. If that is also null, the + /// default is `const BoxConstraints(minWidth: 280.0)`. + /// {@endtemplate} + final BoxConstraints? constraints; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final DialogThemeData dialogTheme = DialogTheme.of(context); + final EdgeInsets effectivePadding = + MediaQuery.viewInsetsOf(context) + + (insetPadding ?? dialogTheme.insetPadding ?? _defaultInsetPadding); + final DialogThemeData defaults = theme.useMaterial3 + ? (_fullscreen ? _DialogFullscreenDefaultsM3(context) : _DialogDefaultsM3(context)) + : _DialogDefaultsM2(context); + + final BoxConstraints boxConstraints = + constraints ?? dialogTheme.constraints ?? const BoxConstraints(minWidth: 280.0); + + Widget dialogChild; + + if (_fullscreen) { + dialogChild = Material( + color: backgroundColor ?? dialogTheme.backgroundColor ?? defaults.backgroundColor, + child: child, + ); + } else { + dialogChild = Align( + alignment: alignment ?? dialogTheme.alignment ?? defaults.alignment!, + child: ConstrainedBox( + constraints: boxConstraints, + child: Material( + color: backgroundColor ?? dialogTheme.backgroundColor ?? defaults.backgroundColor, + elevation: elevation ?? dialogTheme.elevation ?? defaults.elevation!, + shadowColor: shadowColor ?? dialogTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: + surfaceTintColor ?? dialogTheme.surfaceTintColor ?? defaults.surfaceTintColor, + shape: shape ?? dialogTheme.shape ?? defaults.shape!, + type: MaterialType.card, + clipBehavior: clipBehavior ?? dialogTheme.clipBehavior ?? defaults.clipBehavior!, + child: child, + ), + ), + ); + } + + return Semantics( + role: semanticsRole, + child: AnimatedPadding( + padding: effectivePadding, + duration: insetAnimationDuration, + curve: insetAnimationCurve, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: dialogChild, + ), + ), + ); + } +} + +/// A Material Design alert dialog. +/// +/// An alert dialog (also known as a basic dialog) informs the user about +/// situations that require acknowledgment. An alert dialog has an optional +/// title and an optional list of actions. The title is displayed above the +/// content and the actions are displayed below the content. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=75CsnyRXf5I} +/// +/// For dialogs that offer the user a choice between several options, consider +/// using a [SimpleDialog]. +/// +/// Typically passed as the child widget to [showDialog], which displays the +/// dialog. +/// +/// {@animation 350 622 https://flutter.github.io/assets-for-api-docs/assets/material/alert_dialog.mp4} +/// +/// {@tool snippet} +/// +/// This snippet shows a method in a [State] which, when called, displays a dialog box +/// and returns a [Future] that completes when the dialog is dismissed. +/// +/// ```dart +/// Future<void> _showMyDialog() async { +/// return showDialog<void>( +/// context: context, +/// barrierDismissible: false, // user must tap button! +/// builder: (BuildContext context) { +/// return AlertDialog( +/// title: const Text('AlertDialog Title'), +/// content: const SingleChildScrollView( +/// child: ListBody( +/// children: <Widget>[ +/// Text('This is a demo alert dialog.'), +/// Text('Would you like to approve of this message?'), +/// ], +/// ), +/// ), +/// actions: <Widget>[ +/// TextButton( +/// child: const Text('Approve'), +/// onPressed: () { +/// Navigator.of(context).pop(); +/// }, +/// ), +/// ], +/// ); +/// }, +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// This demo shows a [TextButton] which when pressed, calls [showDialog]. When called, this method +/// displays a Material dialog above the current contents of the app and returns +/// a [Future] that completes when the dialog is dismissed. +/// +/// ** See code in examples/api/lib/material/dialog/alert_dialog.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [AlertDialog], as described in: +/// https://m3.material.io/components/dialogs/overview +/// +/// ** See code in examples/api/lib/material/dialog/alert_dialog.1.dart ** +/// {@end-tool} +/// +/// ## Alert dialogs and scrolling +/// +/// By default, alert dialogs size themselves to contain their children. +/// +/// If the content is too large to fit on the screen vertically, the dialog will +/// display the title and actions, and let the _[content]_ overflow. This is +/// rarely desired. Consider using a scrolling widget for [content], such as +/// [SingleChildScrollView], to avoid overflow. +/// +/// Because the dialog attempts to size itself to the contents, the [content] +/// must support reporting its intrinsic dimensions. In particular, this means +/// that lazily-rendered widgets such as [ListView], [GridView], and +/// [CustomScrollView], will not work in an [AlertDialog] unless they are +/// wrapped in a widget that forces a particular size (e.g. a [SizedBox]). +/// +/// For finer-grained control over the sizing of a dialog, consider using +/// [Dialog] directly. +/// +/// See also: +/// +/// * [SimpleDialog], which handles the scrolling of the contents but has no [actions]. +/// * [Dialog], on which [AlertDialog] and [SimpleDialog] are based. +/// * [CupertinoAlertDialog], an iOS-styled alert dialog. +/// * [showDialog], which actually displays the dialog and returns its result. +/// * <https://material.io/design/components/dialogs.html#alert-dialog> +/// * <https://m3.material.io/components/dialogs> +class AlertDialog extends StatelessWidget { + /// Creates an alert dialog. + /// + /// Typically used in conjunction with [showDialog]. + /// + /// The [titlePadding] and [contentPadding] default to null, which implies a + /// default that depends on the values of the other properties. See the + /// documentation of [titlePadding] and [contentPadding] for details. + const AlertDialog({ + super.key, + this.icon, + this.iconPadding, + this.iconColor, + this.title, + this.titlePadding, + this.titleTextStyle, + this.content, + this.contentPadding, + this.contentTextStyle, + this.actions, + this.actionsPadding, + this.actionsAlignment, + this.actionsOverflowAlignment, + this.actionsOverflowDirection, + this.actionsOverflowButtonSpacing, + this.buttonPadding, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.semanticLabel, + this.insetPadding, + this.clipBehavior, + this.shape, + this.alignment, + this.constraints, + this.scrollable = false, + }); + + /// Creates an adaptive [AlertDialog] based on whether the target platform is + /// iOS or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// On iOS and macOS, this constructor creates a [CupertinoAlertDialog]. On + /// other platforms, this creates a Material design [AlertDialog]. + /// + /// Typically passed as a child of [showAdaptiveDialog], which will display + /// the alert differently based on platform. + /// + /// If a [CupertinoAlertDialog] is created only these parameters are used: + /// [title], [content], [actions], [scrollController], + /// [actionScrollController], [insetAnimationDuration], and + /// [insetAnimationCurve]. If a material [AlertDialog] is created, + /// [scrollController], [actionScrollController], [insetAnimationDuration], + /// and [insetAnimationCurve] are ignored. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + /// + /// {@tool dartpad} + /// This demo shows a [TextButton] which when pressed, calls [showAdaptiveDialog]. + /// When called, this method displays an adaptive dialog above the current + /// contents of the app, with different behaviors depending on target platform. + /// + /// [CupertinoDialogAction] is conditionally used as the child to show more + /// platform specific design. + /// + /// ** See code in examples/api/lib/material/dialog/adaptive_alert_dialog.0.dart ** + /// {@end-tool} + const factory AlertDialog.adaptive({ + Key? key, + Widget? icon, + EdgeInsetsGeometry? iconPadding, + Color? iconColor, + Widget? title, + EdgeInsetsGeometry? titlePadding, + TextStyle? titleTextStyle, + Widget? content, + EdgeInsetsGeometry? contentPadding, + TextStyle? contentTextStyle, + List<Widget>? actions, + EdgeInsetsGeometry? actionsPadding, + MainAxisAlignment? actionsAlignment, + OverflowBarAlignment? actionsOverflowAlignment, + VerticalDirection? actionsOverflowDirection, + double? actionsOverflowButtonSpacing, + EdgeInsetsGeometry? buttonPadding, + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + String? semanticLabel, + EdgeInsets insetPadding, + Clip? clipBehavior, + ShapeBorder? shape, + AlignmentGeometry? alignment, + BoxConstraints? constraints, + bool scrollable, + ScrollController? scrollController, + ScrollController? actionScrollController, + Duration insetAnimationDuration, + Curve insetAnimationCurve, + }) = _AdaptiveAlertDialog; + + /// An optional icon to display at the top of the dialog. + /// + /// Typically, an [Icon] widget. Providing an icon centers the [title]'s text. + final Widget? icon; + + /// Color for the [Icon] in the [icon] of this [AlertDialog]. + /// + /// If null, [DialogThemeData.iconColor] is used. If that is null, defaults to + /// color scheme's [ColorScheme.secondary] if [ThemeData.useMaterial3] is + /// true, black otherwise. + final Color? iconColor; + + /// Padding around the [icon]. + /// + /// If there is no [icon], no padding will be provided. Otherwise, this + /// padding is used. + /// + /// This property defaults to providing 24 pixels on the top, left, and right + /// of the [icon]. If [title] is _not_ null, 16 pixels of bottom padding is + /// added to separate the [icon] from the [title]. If the [title] is null and + /// [content] is _not_ null, then no bottom padding is provided (but see + /// [contentPadding]). In any other case 24 pixels of bottom padding is + /// added. + final EdgeInsetsGeometry? iconPadding; + + /// The (optional) title of the dialog is displayed in a large font at the top + /// of the dialog, below the (optional) [icon]. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Padding around the title. + /// + /// If there is no title, no padding will be provided. Otherwise, this padding + /// is used. + /// + /// This property defaults to providing 24 pixels on the top, left, and right + /// of the title. If the [content] is not null, then no bottom padding is + /// provided (but see [contentPadding]). If it _is_ null, then an extra 20 + /// pixels of bottom padding is added to separate the [title] from the + /// [actions]. + final EdgeInsetsGeometry? titlePadding; + + /// Style for the text in the [title] of this [AlertDialog]. + /// + /// If null, [DialogThemeData.titleTextStyle] is used. If that's null, defaults to + /// [TextTheme.headlineSmall] of [ThemeData.textTheme] if + /// [ThemeData.useMaterial3] is true, [TextTheme.titleLarge] otherwise. + final TextStyle? titleTextStyle; + + /// The (optional) content of the dialog is displayed in the center of the + /// dialog in a lighter font. + /// + /// Typically this is a [SingleChildScrollView] that contains the dialog's + /// message. As noted in the [AlertDialog] documentation, it's important + /// to use a [SingleChildScrollView] if there's any risk that the content + /// will not fit, as the contents will otherwise overflow the dialog. + /// + /// The [content] must support reporting its intrinsic dimensions. In + /// particular, [ListView], [GridView], and [CustomScrollView] cannot be used + /// here unless they are first wrapped in a widget that itself can report + /// intrinsic dimensions, such as a [SizedBox]. + final Widget? content; + + /// Padding around the content. + /// + /// If there is no [content], no padding will be provided. Otherwise, this + /// padding is used. + /// + /// This property defaults to providing a padding of 20 pixels above the + /// [content] to separate the [content] from the [title], and 24 pixels on the + /// left, right, and bottom to separate the [content] from the other edges of + /// the dialog. + /// + /// If [ThemeData.useMaterial3] is true, the top padding separating the + /// content from the title defaults to 16 pixels instead of 20 pixels. + final EdgeInsetsGeometry? contentPadding; + + /// Style for the text in the [content] of this [AlertDialog]. + /// + /// If null, [DialogThemeData.contentTextStyle] is used. If that's null, defaults + /// to [TextTheme.bodyMedium] of [ThemeData.textTheme] if + /// [ThemeData.useMaterial3] is true, [TextTheme.titleMedium] otherwise. + final TextStyle? contentTextStyle; + + /// The (optional) set of actions that are displayed at the bottom of the + /// dialog with an [OverflowBar]. + /// + /// Typically this is a list of [TextButton] widgets. It is recommended to + /// set the [Text.textAlign] to [TextAlign.end] for the [Text] within the + /// [TextButton], so that buttons whose labels wrap to an extra line align + /// with the overall [OverflowBar]'s alignment within the dialog. + /// + /// If the [title] is not null but the [content] _is_ null, then an extra 20 + /// pixels of padding is added above the [OverflowBar] to separate the [title] + /// from the [actions]. + final List<Widget>? actions; + + /// Padding around the set of [actions] at the bottom of the dialog. + /// + /// Typically used to provide padding to the button bar between the button bar + /// and the edges of the dialog. + /// + /// The [buttonPadding] may contribute to the padding on the edges of + /// [actions] as well. + /// + /// If there are no [actions], then no padding will be included. + /// + /// {@tool snippet} + /// This is an example of a set of actions aligned with the content widget. + /// ```dart + /// AlertDialog( + /// title: const Text('Title'), + /// content: Container(width: 200, height: 200, color: Colors.green), + /// actions: <Widget>[ + /// ElevatedButton(onPressed: () {}, child: const Text('Button 1')), + /// ElevatedButton(onPressed: () {}, child: const Text('Button 2')), + /// ], + /// actionsPadding: const EdgeInsets.symmetric(horizontal: 8.0), + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [OverflowBar], which [actions] configures to lay itself out. + final EdgeInsetsGeometry? actionsPadding; + + /// Defines the horizontal layout of the [actions] according to the same + /// rules as for [Row.mainAxisAlignment]. + /// + /// This parameter is passed along to the dialog's [OverflowBar]. + /// + /// If this parameter is null (the default) then [MainAxisAlignment.end] + /// is used. + final MainAxisAlignment? actionsAlignment; + + /// The horizontal alignment of [actions] within the vertical + /// "overflow" layout. + /// + /// If the dialog's [actions] do not fit into a single row, then they + /// are arranged in a column. This parameter controls the horizontal + /// alignment of widgets in the case of an overflow. + /// + /// If this parameter is null (the default) then [OverflowBarAlignment.end] + /// is used. + /// + /// See also: + /// + /// * [OverflowBar], which [actions] configures to lay itself out. + final OverflowBarAlignment? actionsOverflowAlignment; + + /// The vertical direction of [actions] if the children overflow + /// horizontally. + /// + /// If the dialog's [actions] do not fit into a single row, then they + /// are arranged in a column. The first action is at the top of the + /// column if this property is set to [VerticalDirection.down], since it + /// "starts" at the top and "ends" at the bottom. On the other hand, + /// the first action will be at the bottom of the column if this + /// property is set to [VerticalDirection.up], since it "starts" at the + /// bottom and "ends" at the top. + /// + /// See also: + /// + /// * [OverflowBar], which [actions] configures to lay itself out. + final VerticalDirection? actionsOverflowDirection; + + /// The spacing between [actions] when the [OverflowBar] switches to a column + /// layout because the actions don't fit horizontally. + /// + /// If the widgets in [actions] do not fit into a single row, they are + /// arranged into a column. This parameter provides additional vertical space + /// between buttons when it does overflow. + /// + /// The button spacing may appear to be more than the value provided. This is + /// because most buttons adhere to the [MaterialTapTargetSize] of 48px. So, + /// even though a button might visually be 36px in height, it might still take + /// up to 48px vertically. + /// + /// If null then no spacing will be added in between buttons in an overflow + /// state. + final double? actionsOverflowButtonSpacing; + + /// The padding that surrounds each button in [actions]. + /// + /// This is different from [actionsPadding], which defines the padding + /// between the entire button bar and the edges of the dialog. + /// + /// If this property is null, then it will default to + /// 8.0 logical pixels on the left and right. + final EdgeInsetsGeometry? buttonPadding; + + /// {@macro flutter.material.dialog.backgroundColor} + final Color? backgroundColor; + + /// {@macro flutter.material.dialog.elevation} + final double? elevation; + + /// {@macro flutter.material.dialog.shadowColor} + final Color? shadowColor; + + /// {@macro flutter.material.dialog.surfaceTintColor} + final Color? surfaceTintColor; + + /// The semantic label of the dialog used by accessibility frameworks to + /// announce screen transitions when the dialog is opened and closed. + /// + /// In iOS, if this label is not provided, a semantic label will be inferred + /// from the [title] if it is not null. + /// + /// In Android, if this label is not provided, the dialog will use the + /// [MaterialLocalizations.alertDialogLabel] as its label. + /// + /// See also: + /// + /// * [SemanticsConfiguration.namesRoute], for a description of how this + /// value is used. + final String? semanticLabel; + + /// {@macro flutter.material.dialog.insetPadding} + final EdgeInsets? insetPadding; + + /// {@macro flutter.material.dialog.clipBehavior} + final Clip? clipBehavior; + + /// {@macro flutter.material.dialog.shape} + final ShapeBorder? shape; + + /// {@macro flutter.material.dialog.alignment} + final AlignmentGeometry? alignment; + + /// {@macro flutter.material.dialog.constraints} + final BoxConstraints? constraints; + + /// Determines whether the [title] and [content] widgets are wrapped in a + /// scrollable. + /// + /// This configuration is used when the [title] and [content] are expected + /// to overflow. Both [title] and [content] are wrapped in a scroll view, + /// allowing all overflowed content to be visible while still showing the + /// button bar. + final bool scrollable; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData theme = Theme.of(context); + + final DialogThemeData dialogTheme = DialogTheme.of(context); + final DialogThemeData defaults = theme.useMaterial3 + ? _DialogDefaultsM3(context) + : _DialogDefaultsM2(context); + + final String? label = switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => semanticLabel, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => semanticLabel ?? MaterialLocalizations.of(context).alertDialogLabel, + }; + + // The paddingScaleFactor is used to adjust the padding of Dialog's + // children. + const fontSizeToScale = 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(fontSizeToScale) / fontSizeToScale; + final double paddingScaleFactor = _scalePadding(effectiveTextScale); + final TextDirection? textDirection = Directionality.maybeOf(context); + + Widget? iconWidget; + Widget? titleWidget; + Widget? contentWidget; + Widget? actionsWidget; + + if (icon != null) { + final belowIsTitle = title != null; + final bool belowIsContent = !belowIsTitle && content != null; + final defaultIconPadding = EdgeInsets.only( + left: 24.0, + top: 24.0, + right: 24.0, + bottom: belowIsTitle + ? 16.0 + : belowIsContent + ? 0.0 + : 24.0, + ); + final EdgeInsets effectiveIconPadding = + iconPadding?.resolve(textDirection) ?? defaultIconPadding; + iconWidget = Padding( + padding: EdgeInsets.only( + left: effectiveIconPadding.left * paddingScaleFactor, + right: effectiveIconPadding.right * paddingScaleFactor, + top: effectiveIconPadding.top * paddingScaleFactor, + bottom: effectiveIconPadding.bottom, + ), + child: IconTheme( + data: IconThemeData(color: iconColor ?? dialogTheme.iconColor ?? defaults.iconColor), + child: icon!, + ), + ); + } + + if (title != null) { + final defaultTitlePadding = EdgeInsets.only( + left: 24.0, + top: icon == null ? 24.0 : 0.0, + right: 24.0, + bottom: content == null ? 20.0 : 0.0, + ); + final EdgeInsets effectiveTitlePadding = + titlePadding?.resolve(textDirection) ?? defaultTitlePadding; + titleWidget = Padding( + padding: EdgeInsets.only( + left: effectiveTitlePadding.left * paddingScaleFactor, + right: effectiveTitlePadding.right * paddingScaleFactor, + top: icon == null + ? effectiveTitlePadding.top * paddingScaleFactor + : effectiveTitlePadding.top, + bottom: effectiveTitlePadding.bottom, + ), + child: DefaultTextStyle( + style: titleTextStyle ?? dialogTheme.titleTextStyle ?? defaults.titleTextStyle!, + textAlign: icon == null ? TextAlign.start : TextAlign.center, + child: Semantics( + // For iOS platform, the focus always lands on the title. + // Set nameRoute to false to avoid title being announce twice. + namesRoute: label == null && defaultTargetPlatform != TargetPlatform.iOS, + container: true, + child: title, + ), + ), + ); + } + + if (content != null) { + final defaultContentPadding = EdgeInsets.only( + left: 24.0, + top: theme.useMaterial3 ? 16.0 : 20.0, + right: 24.0, + bottom: 24.0, + ); + final EdgeInsets effectiveContentPadding = + contentPadding?.resolve(textDirection) ?? defaultContentPadding; + contentWidget = Padding( + padding: EdgeInsets.only( + left: effectiveContentPadding.left * paddingScaleFactor, + right: effectiveContentPadding.right * paddingScaleFactor, + top: title == null && icon == null + ? effectiveContentPadding.top * paddingScaleFactor + : effectiveContentPadding.top, + bottom: effectiveContentPadding.bottom, + ), + child: DefaultTextStyle( + style: contentTextStyle ?? dialogTheme.contentTextStyle ?? defaults.contentTextStyle!, + child: Semantics(container: true, explicitChildNodes: true, child: content), + ), + ); + } + + if (actions != null) { + final double spacing = (buttonPadding?.horizontal ?? 16) / 2; + actionsWidget = Padding( + padding: + actionsPadding ?? + dialogTheme.actionsPadding ?? + (theme.useMaterial3 + ? defaults.actionsPadding! + : defaults.actionsPadding!.add(EdgeInsets.all(spacing))), + child: OverflowBar( + alignment: actionsAlignment ?? MainAxisAlignment.end, + spacing: spacing, + overflowAlignment: actionsOverflowAlignment ?? OverflowBarAlignment.end, + overflowDirection: actionsOverflowDirection ?? VerticalDirection.down, + overflowSpacing: actionsOverflowButtonSpacing ?? 0, + children: actions!, + ), + ); + } + + List<Widget> columnChildren; + if (scrollable) { + columnChildren = <Widget>[ + if (title != null || content != null) + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[?iconWidget, ?titleWidget, ?contentWidget], + ), + ), + ), + ?actionsWidget, + ]; + } else { + columnChildren = <Widget>[ + ?iconWidget, + ?titleWidget, + if (contentWidget != null) Flexible(child: contentWidget), + ?actionsWidget, + ]; + } + + Widget dialogChild = IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: columnChildren, + ), + ); + + if (label != null) { + dialogChild = Semantics( + scopesRoute: true, + explicitChildNodes: true, + namesRoute: true, + label: label, + child: dialogChild, + ); + } + + return Dialog( + backgroundColor: backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + insetPadding: insetPadding, + clipBehavior: clipBehavior, + shape: shape, + alignment: alignment, + constraints: constraints, + semanticsRole: SemanticsRole.alertDialog, + child: dialogChild, + ); + } +} + +class _AdaptiveAlertDialog extends AlertDialog { + const _AdaptiveAlertDialog({ + super.key, + super.icon, + super.iconPadding, + super.iconColor, + super.title, + super.titlePadding, + super.titleTextStyle, + super.content, + super.contentPadding, + super.contentTextStyle, + super.actions, + super.actionsPadding, + super.actionsAlignment, + super.actionsOverflowAlignment, + super.actionsOverflowDirection, + super.actionsOverflowButtonSpacing, + super.buttonPadding, + super.backgroundColor, + super.elevation, + super.shadowColor, + super.surfaceTintColor, + super.semanticLabel, + super.insetPadding, + super.clipBehavior, + super.shape, + super.alignment, + super.constraints, + super.scrollable = false, + this.scrollController, + this.actionScrollController, + this.insetAnimationDuration = const Duration(milliseconds: 100), + this.insetAnimationCurve = Curves.decelerate, + }); + + final ScrollController? scrollController; + final ScrollController? actionScrollController; + final Duration insetAnimationDuration; + final Curve insetAnimationCurve; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoAlertDialog( + title: title, + content: content, + actions: actions ?? <Widget>[], + scrollController: scrollController, + actionScrollController: actionScrollController, + insetAnimationDuration: insetAnimationDuration, + insetAnimationCurve: insetAnimationCurve, + ); + } + return super.build(context); + } +} + +/// An option used in a [SimpleDialog]. +/// +/// A simple dialog offers the user a choice between several options. This +/// widget is commonly used to represent each of the options. If the user +/// selects this option, the widget will call the [onPressed] callback, which +/// typically uses [Navigator.pop] to close the dialog. +/// +/// The padding on a [SimpleDialogOption] is configured to combine with the +/// default [SimpleDialog.contentPadding] so that each option ends up 8 pixels +/// from the other vertically, with 20 pixels of spacing between the dialog's +/// title and the first option, and 24 pixels of spacing between the last option +/// and the bottom of the dialog. +/// +/// {@tool snippet} +/// +/// ```dart +/// SimpleDialogOption( +/// onPressed: () { Navigator.pop(context, Department.treasury); }, +/// child: const Text('Treasury department'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [SimpleDialog], for a dialog in which to use this widget. +/// * [showDialog], which actually displays the dialog and returns its result. +/// * [TextButton], which are commonly used as actions in other kinds of +/// dialogs, such as [AlertDialog]s. +/// * <https://material.io/design/components/dialogs.html#simple-dialog> +class SimpleDialogOption extends StatelessWidget { + /// Creates an option for a [SimpleDialog]. + const SimpleDialogOption({super.key, this.onPressed, this.padding, this.child}); + + /// The callback that is called when this option is selected. + /// + /// If this is set to null, the option cannot be selected. + /// + /// When used in a [SimpleDialog], this will typically call [Navigator.pop] + /// with a value for [showDialog] to complete its future with. + final VoidCallback? onPressed; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget? child; + + /// The amount of space to surround the [child] with. + /// + /// Defaults to EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0). + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0), + child: child, + ), + ); + } +} + +/// A simple Material Design dialog. +/// +/// A simple dialog offers the user a choice between several options. A simple +/// dialog has an optional title that is displayed above the choices. +/// +/// Choices are normally represented using [SimpleDialogOption] widgets. If +/// other widgets are used, see [contentPadding] for notes regarding the +/// conventions for obtaining the spacing expected by Material Design. +/// +/// For dialogs that inform the user about a situation, consider using an +/// [AlertDialog]. +/// +/// Typically passed as the child widget to [showDialog], which displays the +/// dialog. +/// +/// {@animation 350 622 https://flutter.github.io/assets-for-api-docs/assets/material/simple_dialog.mp4} +/// +/// {@tool snippet} +/// +/// In this example, the user is asked to select between two options. These +/// options are represented as an enum. The [showDialog] method here returns +/// a [Future] that completes to a value of that enum. If the user cancels +/// the dialog (e.g. by hitting the back button on Android, or tapping on the +/// mask behind the dialog) then the future completes with the null value. +/// +/// The return value in this example is used as the index for a switch statement. +/// One advantage of using an enum as the return value and then using that to +/// drive a switch statement is that the analyzer will flag any switch statement +/// that doesn't mention every value in the enum. +/// +/// ```dart +/// Future<void> _askedToLead() async { +/// switch (await showDialog<Department>( +/// context: context, +/// builder: (BuildContext context) { +/// return SimpleDialog( +/// title: const Text('Select assignment'), +/// children: <Widget>[ +/// SimpleDialogOption( +/// onPressed: () { Navigator.pop(context, Department.treasury); }, +/// child: const Text('Treasury department'), +/// ), +/// SimpleDialogOption( +/// onPressed: () { Navigator.pop(context, Department.state); }, +/// child: const Text('State department'), +/// ), +/// ], +/// ); +/// } +/// )) { +/// case Department.treasury: +/// // Let's go. +/// // ... +/// break; +/// case Department.state: +/// // ... +/// break; +/// case null: +/// // dialog dismissed +/// break; +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [SimpleDialogOption], which are options used in this type of dialog. +/// * [AlertDialog], for dialogs that have a row of buttons below the body. +/// * [Dialog], on which [SimpleDialog] and [AlertDialog] are based. +/// * [showDialog], which actually displays the dialog and returns its result. +/// * <https://material.io/design/components/dialogs.html#simple-dialog> +class SimpleDialog extends StatelessWidget { + /// Creates a simple dialog. + /// + /// Typically used in conjunction with [showDialog]. + const SimpleDialog({ + super.key, + this.title, + this.titlePadding = const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 0.0), + this.titleTextStyle, + this.children, + this.contentPadding = const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0), + this.contentTextStyle, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.semanticLabel, + this.insetPadding, + this.clipBehavior, + this.shape, + this.alignment, + this.constraints, + }); + + /// The (optional) title of the dialog is displayed in a large font at the top + /// of the dialog. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Padding around the title. + /// + /// If there is no title, no padding will be provided. + /// + /// By default, this provides the recommend Material Design padding of 24 + /// pixels around the left, top, and right edges of the title. + /// + /// See [contentPadding] for the conventions regarding padding between the + /// [title] and the [children]. + final EdgeInsetsGeometry titlePadding; + + /// Style for the text in the [title] of this [SimpleDialog]. + /// + /// If null, [DialogThemeData.titleTextStyle] is used. If that's null, defaults to + /// [TextTheme.titleLarge] of [ThemeData.textTheme]. + final TextStyle? titleTextStyle; + + /// The (optional) content of the dialog is displayed in a + /// [SingleChildScrollView] underneath the title. + /// + /// Typically a list of [SimpleDialogOption]s. + final List<Widget>? children; + + /// Padding around the content. + /// + /// By default, this is 12 pixels on the top and 16 pixels on the bottom. This + /// is intended to be combined with children that have 24 pixels of padding on + /// the left and right, and 8 pixels of padding on the top and bottom, so that + /// the content ends up being indented 20 pixels from the title, 24 pixels + /// from the bottom, and 24 pixels from the sides. + /// + /// The [SimpleDialogOption] widget uses such padding. + /// + /// If there is no [title], the [contentPadding] should be adjusted so that + /// the top padding ends up being 24 pixels. + final EdgeInsetsGeometry contentPadding; + + /// {@macro flutter.material.dialog.backgroundColor} + final Color? backgroundColor; + + /// {@macro flutter.material.dialog.elevation} + final double? elevation; + + /// {@macro flutter.material.dialog.shadowColor} + final Color? shadowColor; + + /// {@macro flutter.material.dialog.surfaceTintColor} + final Color? surfaceTintColor; + + /// Style for the text in the [children] of this [SimpleDialog]. + /// + /// If null, [DialogThemeData.contentTextStyle] is used. If that is also null, + /// defaults to [TextTheme.titleMedium] for Material 2, or [TextTheme.bodyMedium] + /// for Material 3. + final TextStyle? contentTextStyle; + + /// The semantic label of the dialog used by accessibility frameworks to + /// announce screen transitions when the dialog is opened and closed. + /// + /// If this label is not provided, a semantic label will be inferred from the + /// [title] if it is not null. If there is no title, the label will be taken + /// from [MaterialLocalizations.dialogLabel]. + /// + /// See also: + /// + /// * [SemanticsConfiguration.namesRoute], for a description of how this + /// value is used. + final String? semanticLabel; + + /// {@macro flutter.material.dialog.insetPadding} + final EdgeInsets? insetPadding; + + /// {@macro flutter.material.dialog.clipBehavior} + final Clip? clipBehavior; + + /// {@macro flutter.material.dialog.shape} + final ShapeBorder? shape; + + /// {@macro flutter.material.dialog.shape} + final AlignmentGeometry? alignment; + + /// {@macro flutter.material.dialog.constraints} + final BoxConstraints? constraints; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData theme = Theme.of(context); + + final DialogThemeData dialogTheme = DialogTheme.of(context); + final DialogThemeData defaults = theme.useMaterial3 + ? _DialogDefaultsM3(context) + : _DialogDefaultsM2(context); + + String? label = semanticLabel; + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + case TargetPlatform.iOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + label ??= MaterialLocalizations.of(context).dialogLabel; + } + + // The paddingScaleFactor is used to adjust the padding of Dialog + // children. + final TextStyle effectiveTitleTextStyle = + titleTextStyle ?? dialogTheme.titleTextStyle ?? theme.textTheme.titleLarge!; + final double fontSize = effectiveTitleTextStyle.fontSize ?? kDefaultFontSize; + final double fontSizeToScale = fontSize == 0.0 ? kDefaultFontSize : fontSize; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(fontSizeToScale) / fontSizeToScale; + final double paddingScaleFactor = _scalePadding(effectiveTextScale); + final TextDirection? textDirection = Directionality.maybeOf(context); + + Widget? titleWidget; + if (title != null) { + final EdgeInsets effectiveTitlePadding = titlePadding.resolve(textDirection); + titleWidget = Padding( + padding: EdgeInsets.only( + left: effectiveTitlePadding.left * paddingScaleFactor, + right: effectiveTitlePadding.right * paddingScaleFactor, + top: effectiveTitlePadding.top * paddingScaleFactor, + bottom: children == null + ? effectiveTitlePadding.bottom * paddingScaleFactor + : effectiveTitlePadding.bottom, + ), + child: DefaultTextStyle( + style: effectiveTitleTextStyle, + child: Semantics( + // For iOS platform, the focus always lands on the title. + // Set nameRoute to false to avoid title being announce twice. + namesRoute: label == null && defaultTargetPlatform != TargetPlatform.iOS, + container: true, + child: title, + ), + ), + ); + } + + Widget? contentWidget; + if (children != null) { + final EdgeInsets effectiveContentPadding = contentPadding.resolve(textDirection); + contentWidget = Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: effectiveContentPadding.left * paddingScaleFactor, + right: effectiveContentPadding.right * paddingScaleFactor, + top: title == null + ? effectiveContentPadding.top * paddingScaleFactor + : effectiveContentPadding.top, + bottom: effectiveContentPadding.bottom * paddingScaleFactor, + ), + child: DefaultTextStyle( + style: contentTextStyle ?? dialogTheme.contentTextStyle ?? defaults.contentTextStyle!, + child: ListBody(children: children!), + ), + ), + ); + } + + Widget dialogChild = IntrinsicWidth( + stepWidth: 56.0, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[?titleWidget, ?contentWidget], + ), + ); + + if (label != null) { + dialogChild = Semantics( + scopesRoute: true, + explicitChildNodes: true, + namesRoute: true, + label: label, + child: dialogChild, + ); + } + return Dialog( + backgroundColor: backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + insetPadding: insetPadding, + clipBehavior: clipBehavior, + shape: shape, + alignment: alignment, + constraints: constraints, + child: dialogChild, + ); + } +} + +Widget _buildMaterialDialogTransitions( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, +) { + return child; +} + +// Wrapper that makes dialogs fill the entire window without insets or rounded corners. +class _FullWindowDialogWrapper extends StatelessWidget { + const _FullWindowDialogWrapper({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final DialogThemeData windowDialogTheme = DialogTheme.of(context).copyWith( + insetPadding: EdgeInsets.zero, + shape: const RoundedRectangleBorder(), // No rounded corners. + alignment: Alignment.topLeft, // Align to top-left so it fills from corner. + constraints: + const BoxConstraints.expand(), // Remove default constraints so dialog can expand to fill available space. + ); + + return DialogTheme( + data: windowDialogTheme, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: MediaQuery.removeViewPadding( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: child, + ), + ), + ); + } +} + +// Provides a pop callback that dialog content can use. +// Wraps content to provide a Navigator-like interface for popping. +class _DialogPopScope extends StatelessWidget { + const _DialogPopScope({required this.child, this.onPop}); + + final Widget child; + final void Function(Object?)? onPop; + + @override + Widget build(BuildContext context) { + // Wrap with PopupScope to handle back button and provide popNavigator function. + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (!didPop) { + onPop?.call(result); + } + }, + child: Builder( + builder: (BuildContext context) { + // Provide a way for child widgets to pop using Navigator.maybePop(context) + // by wrapping in a minimal Navigator. + return _NavigatorShim(onPop: onPop, child: child); + }, + ), + ); + } +} + +// Creates a minimal Navigator that intercepts pop calls. +class _NavigatorShim extends StatelessWidget { + const _NavigatorShim({required this.child, this.onPop}); + + final void Function(Object?)? onPop; + final Widget child; + + @override + Widget build(BuildContext context) { + // Create a Navigator with a single page that contains the child + // This allows Navigator.pop(context) calls from within the dialog to work. + // Wrap in HeroControllerScope.none() to prevent this Navigator from + // inheriting the outer HeroController, which would cause a "HeroController + // can not be shared by multiple Navigators" assertion. + return HeroControllerScope.none( + child: Navigator( + pages: <Page<void>>[_DialogContentPage(child: child)], + onPopPage: (Route<dynamic> route, dynamic result) { + // When the page is popped, call our onPop callback + onPop?.call(result); + // Return false to prevent the route from being removed from the Navigator + // (since we're handling the pop externally by closing the dialog window). + return false; + }, + ), + ); + } +} + +// A simple page for the dialog content. +class _DialogContentPage extends Page<void> { + const _DialogContentPage({required this.child}); + + final Widget child; + + @override + Route<void> createRoute(BuildContext context) { + return PageRouteBuilder<void>( + settings: this, + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return child; + }, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ); + } +} + +/// Displays a Material dialog above the current contents of the app, with +/// Material entrance and exit animations, modal barrier color, and modal +/// barrier behavior (dialog is dismissible with a tap on the barrier). +/// +/// {@macro flutter.widgets.showRawDialog.windowing} +/// +/// This function takes a `builder` which typically builds a [Dialog] widget. +/// Content below the dialog is dimmed with a [ModalBarrier]. The widget +/// returned by the `builder` does not share a context with the location that +/// [showDialog] is originally called from. Use a [StatefulBuilder] or a +/// custom [StatefulWidget] if the dialog needs to update dynamically. +/// +/// {@macro flutter.widgets.showRawDialog.context} +/// +/// The `barrierDismissible` argument is used to indicate whether tapping on the +/// barrier will dismiss the dialog. It is `true` by default and can not be `null`. +/// If windowing is enabled via `flutter config --enable-windowing`,then this +/// argument is ignored as dialogs are displayed in their own windows which do +/// not have a modal barrier. +/// +/// The `barrierColor` argument is used to specify the color of the modal +/// barrier that darkens everything below the dialog. If `null` the `barrierColor` +/// field from `DialogThemeData` is used. If that is `null` the default color +/// `Colors.black54` is used. If windowing is enabled via `flutter config +/// --enable-windowing`, then this argument is ignored as dialogs are displayed +/// in their own windows which do not have a modal barrier. +/// +/// The `useSafeArea` argument is used to indicate if the dialog should only +/// display in 'safe' areas of the screen not used by the operating system +/// (see [SafeArea] for more details). It is `true` by default, which means +/// the dialog will not overlap operating system areas. If it is set to `false` +/// the dialog will only be constrained by the screen size. It can not be `null`. +/// +/// {@macro flutter.widgets.showRawDialog.navigator} +/// +/// {@macro flutter.widgets.showRawDialog.routeSettings} +/// +/// If not null, the `traversalEdgeBehavior` argument specifies the transfer of +/// focus beyond the first and the last items of the dialog route. By default, +/// [TraversalEdgeBehavior.closedLoop] is used, because it's typical for dialogs +/// to allow users to cycle through dialog widgets without leaving the dialog. +/// If windowing is enabled via `flutter config --enable-windowing`, then this +/// argument is ignored as dialogs are displayed in their own windows which +/// manage focus traversal independently. +/// +/// {@template flutter.material.dialog.requestFocus} +/// The `requestFocus` argument is used to specify whether the dialog should +/// request focus when shown. +/// {@endtemplate} +/// {@macro flutter.widgets.navigator.Route.requestFocus} +/// If windowing is enabled via `flutter config --enable-windowing`, then this +/// argument is ignored as dialogs are displayed in their own windows which are +/// focused by the windowing system. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// If the application has multiple [Navigator] objects, it may be necessary to +/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the +/// dialog rather than just `Navigator.pop(context, result)`. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the dialog was closed. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use [showDialog] to display a dialog box. +/// +/// ** See code in examples/api/lib/material/dialog/show_dialog.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [showDialog], as described in: +/// https://m3.material.io/components/dialogs/overview +/// +/// ** See code in examples/api/lib/material/dialog/show_dialog.1.dart ** +/// {@end-tool} +/// +/// ### State Restoration in Dialogs +/// +/// Using this method will not enable state restoration for the dialog. In order +/// to enable state restoration for a dialog, use [Navigator.restorablePush] +/// or [Navigator.restorablePushNamed] with [DialogRoute]. +/// +/// For more information about state restoration, see [RestorationManager]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a restorable Material dialog. This is +/// accomplished by enabling state restoration by specifying +/// [MaterialApp.restorationScopeId] and using [Navigator.restorablePush] to +/// push [DialogRoute] when the button is tapped. +/// +/// {@macro flutter.widgets.RestorationManager} +/// +/// ** See code in examples/api/lib/material/dialog/show_dialog.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [AlertDialog], for dialogs that have a row of buttons below a body. +/// * [SimpleDialog], which handles the scrolling of the contents and does +/// not show buttons below its body. +/// * [Dialog], on which [SimpleDialog] and [AlertDialog] are based. +/// * [showCupertinoDialog], which displays an iOS-style dialog. +/// * [showGeneralDialog], which allows for customization of the dialog popup. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * <https://material.io/design/components/dialogs.html> +/// * <https://m3.material.io/components/dialogs> +Future<T?> showDialog<T>({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, + TraversalEdgeBehavior? traversalEdgeBehavior, + bool fullscreenDialog = false, + bool? requestFocus, + AnimationStyle? animationStyle, +}) { + assert(_debugIsActive(context)); + assert(debugCheckHasMaterialLocalizations(context)); + + final CapturedThemes themes = InheritedTheme.capture( + from: context, + to: Navigator.of(context, rootNavigator: useRootNavigator).context, + ); + final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); + + return showRawDialog( + context: context, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + fullscreenDialog: fullscreenDialog, + routeBuilder: (BuildContext routeContext, WidgetBuilder _) { + return DialogRoute<T>( + context: routeContext, + builder: builder, + barrierColor: + barrierColor ?? + DialogTheme.of(context).barrierColor ?? + Theme.of(context).dialogTheme.barrierColor ?? + Colors.black54, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + settings: routeSettings, + themes: themes, + anchorPoint: anchorPoint, + traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop, + requestFocus: requestFocus, + animationStyle: animationStyle, + fullscreenDialog: fullscreenDialog, + ); + }, + builder: (BuildContext routeContext) { + // Wrap the build dialog with the theme, text direction, and media + // query data from the caller's context (not the navigator's context, + // which would resolve to the root app theme and override captured themes). + final TextDirection textDirection = Directionality.of(context); + final ThemeData themeData = Theme.of(context); + final MediaQueryData mediaQuery = MediaQuery.of(context); + final Widget dialogContent = _DialogPopScope( + onPop: Navigator.of(navigator.context).pop, + child: Builder( + builder: (BuildContext innerContext) { + return _FullWindowDialogWrapper(child: builder(innerContext)); + }, + ), + ); + + return Directionality( + textDirection: textDirection, + child: Theme( + data: themeData, + child: MediaQuery(data: mediaQuery, child: dialogContent), + ), + ); + }, + ); +} + +/// Displays either a Material or Cupertino dialog depending on platform. +/// +/// On most platforms this function will act the same as [showDialog], except +/// for iOS and macOS, in which case it will act the same as +/// [showCupertinoDialog]. +/// +/// On Cupertino platforms, [barrierColor], [useSafeArea], and +/// [traversalEdgeBehavior] are ignored. +Future<T?> showAdaptiveDialog<T>({ + required BuildContext context, + required WidgetBuilder builder, + bool? barrierDismissible, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, + TraversalEdgeBehavior? traversalEdgeBehavior, + bool? requestFocus, + AnimationStyle? animationStyle, +}) { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return showDialog<T>( + context: context, + builder: builder, + barrierDismissible: barrierDismissible ?? true, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + traversalEdgeBehavior: traversalEdgeBehavior, + requestFocus: requestFocus, + animationStyle: animationStyle, + ); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return showCupertinoDialog<T>( + context: context, + builder: builder, + barrierDismissible: barrierDismissible ?? false, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + anchorPoint: anchorPoint, + routeSettings: routeSettings, + requestFocus: requestFocus, + ); + } +} + +bool _debugIsActive(BuildContext context) { + if (context is Element && !context.debugIsActive) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('This BuildContext is no longer valid.'), + ErrorDescription( + 'The showDialog function context parameter is a BuildContext that is no longer valid.', + ), + ErrorHint( + 'This can commonly occur when the showDialog function is called after awaiting a Future. ' + 'In this situation the BuildContext might refer to a widget that has already been disposed during the await. ' + 'Consider using a parent context instead.', + ), + ]); + } + return true; +} + +/// A dialog route with Material entrance and exit animations, +/// modal barrier color, and modal barrier behavior (dialog is dismissible +/// with a tap on the barrier). +/// +/// It is used internally by [showDialog] or can be directly pushed +/// onto the [Navigator] stack to enable state restoration. See +/// [showDialog] for a state restoration app example. +/// +/// This function takes a `builder` which typically builds a [Dialog] widget. +/// Content below the dialog is dimmed with a [ModalBarrier]. The widget +/// returned by the `builder` does not share a context with the location that +/// `showDialog` is originally called from. Use a [StatefulBuilder] or a +/// custom [StatefulWidget] if the dialog needs to update dynamically. +/// +/// The `context` argument is used to look up +/// [MaterialLocalizations.modalBarrierDismissLabel], which provides the +/// modal with a localized accessibility label that will be used for the +/// modal's barrier. However, a custom `barrierLabel` can be passed in as well. +/// +/// The `barrierDismissible` argument is used to indicate whether tapping on the +/// barrier will dismiss the dialog. It is `true` by default and cannot be `null`. +/// +/// The `barrierColor` argument is used to specify the color of the modal +/// barrier that darkens everything below the dialog. If `null`, the default +/// color `Colors.black54` is used. +/// +/// The `useSafeArea` argument is used to indicate if the dialog should only +/// display in 'safe' areas of the screen not used by the operating system +/// (see [SafeArea] for more details). It is `true` by default, which means +/// the dialog will not overlap operating system areas. If it is set to `false` +/// the dialog will only be constrained by the screen size. It can not be `null`. +/// +/// The `settings` argument define the settings for this route. See +/// [RouteSettings] for details. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// See also: +/// +/// * [showDialog], which is a way to display a DialogRoute. +/// * [showGeneralDialog], which allows for customization of the dialog popup. +/// * [showCupertinoDialog], which displays an iOS-style dialog. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +class DialogRoute<T> extends RawDialogRoute<T> { + /// A dialog route with Material entrance and exit animations, + /// modal barrier color, and modal barrier behavior (dialog is dismissible + /// with a tap on the barrier). + DialogRoute({ + required BuildContext context, + required WidgetBuilder builder, + CapturedThemes? themes, + super.barrierColor = Colors.black54, + super.barrierDismissible, + String? barrierLabel, + bool useSafeArea = true, + super.settings, + super.requestFocus, + super.anchorPoint, + super.traversalEdgeBehavior, + super.fullscreenDialog, + AnimationStyle? animationStyle, + }) : _animationStyle = animationStyle, + super( + pageBuilder: + ( + BuildContext buildContext, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + final Widget pageChild = Builder(builder: builder); + Widget dialog = themes?.wrap(pageChild) ?? pageChild; + if (useSafeArea) { + dialog = SafeArea(child: dialog); + } + // Prevent clicks inside the dialog from passing through to the barrier + dialog = Semantics(hitTestBehavior: SemanticsHitTestBehavior.opaque, child: dialog); + return dialog; + }, + barrierLabel: barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel, + transitionDuration: animationStyle?.duration ?? const Duration(milliseconds: 150), + transitionBuilder: _buildMaterialDialogTransitions, + ); + + CurvedAnimation? _curvedAnimation; + final AnimationStyle? _animationStyle; + + void _setAnimation(Animation<double> animation) { + if (_curvedAnimation?.parent != animation) { + _curvedAnimation?.dispose(); + _curvedAnimation = CurvedAnimation( + parent: animation, + curve: _animationStyle?.curve ?? Curves.easeOut, + reverseCurve: _animationStyle?.reverseCurve ?? Curves.easeOut, + ); + } + } + + @override + Widget buildTransitions( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + _setAnimation(animation); + return FadeTransition( + opacity: _curvedAnimation!, + child: super.buildTransitions(context, animation, secondaryAnimation, child), + ); + } + + @override + void dispose() { + _curvedAnimation?.dispose(); + super.dispose(); + } +} + +double _scalePadding(double textScaleFactor) { + final double clampedTextScaleFactor = clampDouble(textScaleFactor, 1.0, 2.0); + // The final padding scale factor is clamped between 1/3 and 1. For example, + // a non-scaled padding of 24 will produce a padding between 24 and 8. + return lerpDouble(1.0, 1.0 / 3.0, clampedTextScaleFactor - 1.0)!; +} + +// Hand coded defaults based on Material Design 2. +class _DialogDefaultsM2 extends DialogThemeData { + _DialogDefaultsM2(this.context) + : super( + alignment: Alignment.center, + elevation: 24.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + clipBehavior: Clip.none, + ); + + final BuildContext context; + late final ThemeData theme = Theme.of(context); + late final TextTheme textTheme = theme.textTheme; + late final IconThemeData iconTheme = theme.iconTheme; + + @override + Color? get iconColor => iconTheme.color; + + @override + Color? get backgroundColor => + theme.brightness == Brightness.dark ? Colors.grey[800]! : Colors.white; + + @override + Color? get shadowColor => theme.shadowColor; + + @override + TextStyle? get titleTextStyle => textTheme.titleLarge; + + @override + TextStyle? get contentTextStyle => textTheme.titleMedium; + + @override + EdgeInsetsGeometry? get actionsPadding => EdgeInsets.zero; +} + +// BEGIN GENERATED TOKEN PROPERTIES - DialogFullscreen + +// 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. + +// dart format off +class _DialogFullscreenDefaultsM3 extends DialogThemeData { + const _DialogFullscreenDefaultsM3(this.context): super(clipBehavior: Clip.none); + + final BuildContext context; + + @override + Color? get backgroundColor => Theme.of(context).colorScheme.surface; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - DialogFullscreen + +// BEGIN GENERATED TOKEN PROPERTIES - Dialog + +// 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. + +// dart format off +class _DialogDefaultsM3 extends DialogThemeData { + _DialogDefaultsM3(this.context) + : super( + alignment: Alignment.center, + elevation: 6.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + clipBehavior: Clip.none, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get iconColor => _colors.secondary; + + @override + Color? get backgroundColor => _colors.surfaceContainerHigh; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + TextStyle? get titleTextStyle => _textTheme.headlineSmall; + + @override + TextStyle? get contentTextStyle => _textTheme.bodyMedium; + + @override + EdgeInsetsGeometry? get actionsPadding => const EdgeInsets.only(left: 24.0, right: 24.0, bottom: 24.0); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Dialog diff --git a/packages/material_ui/lib/src/dialog_theme.dart b/packages/material_ui/lib/src/dialog_theme.dart new file mode 100644 index 000000000000..ba9cf1e8f50f --- /dev/null +++ b/packages/material_ui/lib/src/dialog_theme.dart @@ -0,0 +1,534 @@ +// 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. + +/// @docImport 'dialog.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines a theme for [Dialog] widgets. +/// +/// Descendant widgets obtain the current [DialogThemeData] object using +/// [DialogTheme.of]. Instances of [DialogThemeData] can be customized with +/// [DialogThemeData.copyWith]. +/// +/// [titleTextStyle] and [contentTextStyle] are used in [AlertDialog]s and [SimpleDialog]s. +/// +/// See also: +/// +/// * [Dialog], a dialog that can be customized using this [DialogTheme]. +/// * [AlertDialog], a dialog that can be customized using this [DialogTheme]. +/// * [SimpleDialog], a dialog that can be customized using this [DialogTheme]. +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class DialogTheme extends InheritedTheme with Diagnosticable { + /// Creates a dialog theme that can be used for [ThemeData.dialogTheme]. + const DialogTheme({ + super.key, + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + AlignmentGeometry? alignment, + Color? iconColor, + TextStyle? titleTextStyle, + TextStyle? contentTextStyle, + EdgeInsetsGeometry? actionsPadding, + Color? barrierColor, + EdgeInsets? insetPadding, + Clip? clipBehavior, + DialogThemeData? data, + Widget? child, + }) : assert( + data == null || + (backgroundColor ?? + elevation ?? + shadowColor ?? + surfaceTintColor ?? + shape ?? + alignment ?? + iconColor ?? + titleTextStyle ?? + contentTextStyle ?? + actionsPadding ?? + barrierColor ?? + insetPadding ?? + clipBehavior) == + null, + ), + _data = data, + _backgroundColor = backgroundColor, + _elevation = elevation, + _shadowColor = shadowColor, + _surfaceTintColor = surfaceTintColor, + _shape = shape, + _alignment = alignment, + _iconColor = iconColor, + _titleTextStyle = titleTextStyle, + _contentTextStyle = contentTextStyle, + _actionsPadding = actionsPadding, + _barrierColor = barrierColor, + _insetPadding = insetPadding, + _clipBehavior = clipBehavior, + super(child: child ?? const SizedBox()); + + final DialogThemeData? _data; + final Color? _backgroundColor; + final double? _elevation; + final Color? _shadowColor; + final Color? _surfaceTintColor; + final ShapeBorder? _shape; + final AlignmentGeometry? _alignment; + final TextStyle? _titleTextStyle; + final TextStyle? _contentTextStyle; + final EdgeInsetsGeometry? _actionsPadding; + final Color? _iconColor; + final Color? _barrierColor; + final EdgeInsets? _insetPadding; + final Clip? _clipBehavior; + + /// Overrides the default value for [Dialog.backgroundColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.backgroundColor] property in [data] instead. + Color? get backgroundColor => _data != null ? _data.backgroundColor : _backgroundColor; + + /// Overrides the default value for [Dialog.elevation]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.elevation] property in [data] instead. + double? get elevation => _data != null ? _data.elevation : _elevation; + + /// Overrides the default value for [Dialog.shadowColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.shadowColor] property in [data] instead. + Color? get shadowColor => _data != null ? _data.shadowColor : _shadowColor; + + /// Overrides the default value for [Dialog.surfaceTintColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.surfaceTintColor] property in [data] instead. + Color? get surfaceTintColor => _data != null ? _data.surfaceTintColor : _surfaceTintColor; + + /// Overrides the default value for [Dialog.shape]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.shape] property in [data] instead. + ShapeBorder? get shape => _data != null ? _data.shape : _shape; + + /// Overrides the default value for [Dialog.alignment]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.alignment] property in [data] instead. + AlignmentGeometry? get alignment => _data != null ? _data.alignment : _alignment; + + /// Overrides the default value for [DefaultTextStyle] for [SimpleDialog.title] and + /// [AlertDialog.title]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.titleTextStyle] property in [data] instead. + TextStyle? get titleTextStyle => _data != null ? _data.titleTextStyle : _titleTextStyle; + + /// Overrides the default value for [DefaultTextStyle] for [SimpleDialog.children] and + /// [AlertDialog.content]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.contentTextStyle] property in [data] instead. + TextStyle? get contentTextStyle => _data != null ? _data.contentTextStyle : _contentTextStyle; + + /// Overrides the default value for [AlertDialog.actionsPadding]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.actionsPadding] property in [data] instead. + EdgeInsetsGeometry? get actionsPadding => _data != null ? _data.actionsPadding : _actionsPadding; + + /// Used to configure the [IconTheme] for the [AlertDialog.icon] widget. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.iconColor] property in [data] instead. + Color? get iconColor => _data != null ? _data.iconColor : _iconColor; + + /// Overrides the default value for [barrierColor] in [showDialog]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.barrierColor] property in [data] instead. + Color? get barrierColor => _data != null ? _data.barrierColor : _barrierColor; + + /// Overrides the default value for [Dialog.insetPadding]. + EdgeInsets? get insetPadding => _data != null ? _data.insetPadding : _insetPadding; + + /// Overrides the default value of [Dialog.clipBehavior]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.clipBehavior] property in [data] instead. + Clip? get clipBehavior => _data != null ? _data.clipBehavior : _clipBehavior; + + /// The properties used for all descendant [Dialog] widgets. + DialogThemeData get data { + return _data ?? + DialogThemeData( + backgroundColor: _backgroundColor, + elevation: _elevation, + shadowColor: _shadowColor, + surfaceTintColor: _surfaceTintColor, + shape: _shape, + alignment: _alignment, + iconColor: _iconColor, + titleTextStyle: _titleTextStyle, + contentTextStyle: _contentTextStyle, + actionsPadding: _actionsPadding, + barrierColor: _barrierColor, + insetPadding: _insetPadding, + clipBehavior: _clipBehavior, + ); + } + + /// Retrieves the [DialogThemeData] from the closest ancestor [DialogTheme]. + /// + /// If there is no enclosing [DialogTheme] widget, then + /// [ThemeData.dialogTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DialogThemeData theme = DialogTheme.of(context); + /// ``` + static DialogThemeData of(BuildContext context) { + final DialogTheme? dialogTheme = context.dependOnInheritedWidgetOfExactType<DialogTheme>(); + return dialogTheme?.data ?? Theme.of(context).dialogTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return DialogTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(DialogTheme oldWidget) => data != oldWidget.data; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.copyWith] instead. + DialogTheme copyWith({ + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + AlignmentGeometry? alignment, + Color? iconColor, + TextStyle? titleTextStyle, + TextStyle? contentTextStyle, + EdgeInsetsGeometry? actionsPadding, + Color? barrierColor, + EdgeInsets? insetPadding, + Clip? clipBehavior, + }) { + return DialogTheme( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shape: shape ?? this.shape, + alignment: alignment ?? this.alignment, + iconColor: iconColor ?? this.iconColor, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + contentTextStyle: contentTextStyle ?? this.contentTextStyle, + actionsPadding: actionsPadding ?? this.actionsPadding, + barrierColor: barrierColor ?? this.barrierColor, + insetPadding: insetPadding ?? this.insetPadding, + clipBehavior: clipBehavior ?? this.clipBehavior, + ); + } + + /// Linearly interpolate between two dialog themes. + /// + /// {@macro dart.ui.shadow.lerp} + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [DialogThemeData.lerp] instead. + static DialogTheme lerp(DialogTheme? a, DialogTheme? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return DialogTheme( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), + iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), + titleTextStyle: TextStyle.lerp(a?.titleTextStyle, b?.titleTextStyle, t), + contentTextStyle: TextStyle.lerp(a?.contentTextStyle, b?.contentTextStyle, t), + actionsPadding: EdgeInsetsGeometry.lerp(a?.actionsPadding, b?.actionsPadding, t), + barrierColor: Color.lerp(a?.barrierColor, b?.barrierColor, t), + insetPadding: EdgeInsets.lerp(a?.insetPadding, b?.insetPadding, t), + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null), + ); + properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('titleTextStyle', titleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('contentTextStyle', contentTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('actionsPadding', actionsPadding, defaultValue: null), + ); + properties.add(ColorProperty('barrierColor', barrierColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsets>('insetPadding', insetPadding, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + } +} + +/// Defines default property values for descendant [Dialog] widgets. +/// +/// Descendant widgets obtain the current [DialogThemeData] object using +/// [DialogTheme.of]. Instances of [DialogThemeData] can be +/// customized with [DialogThemeData.copyWith]. +/// +/// Typically a [DialogThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.dialogTheme]. +/// +/// All [DialogThemeData] properties are `null` by default. When null, the [Dialog] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. See the individual [Dialog] properties for details. +/// +/// See also: +/// +/// * [Dialog], a dialog that can be customized using this [DialogTheme]. +/// * [AlertDialog], a dialog that can be customized using this [DialogTheme]. +/// * [SimpleDialog], a dialog that can be customized using this [DialogTheme]. +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class DialogThemeData with Diagnosticable { + /// Creates a dialog theme that can be used for [ThemeData.dialogTheme]. + const DialogThemeData({ + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.shape, + this.alignment, + this.iconColor, + this.titleTextStyle, + this.contentTextStyle, + this.actionsPadding, + this.barrierColor, + this.insetPadding, + this.clipBehavior, + this.constraints, + }); + + /// Overrides the default value for [Dialog.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value for [Dialog.elevation]. + final double? elevation; + + /// Overrides the default value for [Dialog.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value for [Dialog.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value for [Dialog.shape]. + final ShapeBorder? shape; + + /// Overrides the default value for [Dialog.alignment]. + final AlignmentGeometry? alignment; + + /// Overrides the default value for [DefaultTextStyle] for [SimpleDialog.title] and + /// [AlertDialog.title]. + final TextStyle? titleTextStyle; + + /// Overrides the default value for [DefaultTextStyle] for [SimpleDialog.children] and + /// [AlertDialog.content]. + final TextStyle? contentTextStyle; + + /// Overrides the default value for [AlertDialog.actionsPadding]. + final EdgeInsetsGeometry? actionsPadding; + + /// Used to configure the [IconTheme] for the [AlertDialog.icon] widget. + final Color? iconColor; + + /// Overrides the default value for [barrierColor] in [showDialog]. + final Color? barrierColor; + + /// Overrides the default value for [Dialog.insetPadding]. + final EdgeInsets? insetPadding; + + /// Overrides the default value of [Dialog.clipBehavior]. + final Clip? clipBehavior; + + /// Constrains the size of the [Dialog]. + /// + /// If null, the bottom sheet's size will be unconstrained. + final BoxConstraints? constraints; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + DialogThemeData copyWith({ + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + AlignmentGeometry? alignment, + Color? iconColor, + TextStyle? titleTextStyle, + TextStyle? contentTextStyle, + EdgeInsetsGeometry? actionsPadding, + Color? barrierColor, + EdgeInsets? insetPadding, + Clip? clipBehavior, + BoxConstraints? constraints, + }) { + return DialogThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shape: shape ?? this.shape, + alignment: alignment ?? this.alignment, + iconColor: iconColor ?? this.iconColor, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + contentTextStyle: contentTextStyle ?? this.contentTextStyle, + actionsPadding: actionsPadding ?? this.actionsPadding, + barrierColor: barrierColor ?? this.barrierColor, + insetPadding: insetPadding ?? this.insetPadding, + clipBehavior: clipBehavior ?? this.clipBehavior, + constraints: constraints ?? this.constraints, + ); + } + + /// Linearly interpolate between two [DialogThemeData]. + /// + /// {@macro dart.ui.shadow.lerp} + static DialogThemeData lerp(DialogThemeData? a, DialogThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return DialogThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), + iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), + titleTextStyle: TextStyle.lerp(a?.titleTextStyle, b?.titleTextStyle, t), + contentTextStyle: TextStyle.lerp(a?.contentTextStyle, b?.contentTextStyle, t), + actionsPadding: EdgeInsetsGeometry.lerp(a?.actionsPadding, b?.actionsPadding, t), + barrierColor: Color.lerp(a?.barrierColor, b?.barrierColor, t), + insetPadding: EdgeInsets.lerp(a?.insetPadding, b?.insetPadding, t), + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + ); + } + + @override + int get hashCode => Object.hashAll(<Object?>[ + backgroundColor, + elevation, + shadowColor, + surfaceTintColor, + shape, + alignment, + iconColor, + titleTextStyle, + contentTextStyle, + actionsPadding, + barrierColor, + insetPadding, + clipBehavior, + constraints, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is DialogThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.shape == shape && + other.alignment == alignment && + other.iconColor == iconColor && + other.titleTextStyle == titleTextStyle && + other.contentTextStyle == contentTextStyle && + other.actionsPadding == actionsPadding && + other.barrierColor == barrierColor && + other.insetPadding == insetPadding && + other.clipBehavior == clipBehavior && + other.constraints == constraints; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null), + ); + properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('titleTextStyle', titleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('contentTextStyle', contentTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('actionsPadding', actionsPadding, defaultValue: null), + ); + properties.add(ColorProperty('barrierColor', barrierColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsets>('insetPadding', insetPadding, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + } +} diff --git a/packages/material_ui/lib/src/divider.dart b/packages/material_ui/lib/src/divider.dart new file mode 100644 index 000000000000..24e78b6b37ad --- /dev/null +++ b/packages/material_ui/lib/src/divider.dart @@ -0,0 +1,373 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'popup_menu.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'divider_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// A thin horizontal line, with padding on either side. +/// +/// In the Material Design language, this represents a divider. Dividers can be +/// used in lists, [Drawer]s, and elsewhere to separate content. +/// +/// To create a divider between [ListTile] items, consider using +/// [ListTile.divideTiles], which is optimized for this case. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=_liUC641Nmk} +/// +/// The box's total height is controlled by [height]. The appropriate +/// padding is automatically computed from the height. +/// +/// {@tool dartpad} +/// This sample shows how to display a Divider between an orange and blue box +/// inside a column. The Divider is 20 logical pixels in height and contains a +/// vertically centered black line that is 5 logical pixels thick. The black +/// line is indented by 20 logical pixels. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/divider.png) +/// +/// ** See code in examples/api/lib/material/divider/divider.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [Divider] widget, as described in: +/// https://m3.material.io/components/divider/overview +/// +/// ** See code in examples/api/lib/material/divider/divider.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PopupMenuDivider], which is the equivalent but for popup menus. +/// * [ListTile.divideTiles], another approach to dividing widgets in a list. +/// * [VerticalDivider], which is the vertical analog of this widget. +/// * <https://material.io/design/components/dividers.html> +class Divider extends StatelessWidget { + /// Creates a Material Design divider. + /// + /// The [height], [thickness], [indent], and [endIndent] must be null or + /// non-negative. + const Divider({ + super.key, + this.height, + this.thickness, + this.indent, + this.endIndent, + this.color, + this.radius, + }) : assert(height == null || height >= 0.0), + assert(thickness == null || thickness >= 0.0), + assert(indent == null || indent >= 0.0), + assert(endIndent == null || endIndent >= 0.0); + + /// The divider's height extent. + /// + /// The divider itself is always drawn as a horizontal line that is centered + /// within the height specified by this value. + /// + /// If this is null, then the [DividerThemeData.space] is used. If that is + /// also null, then this defaults to 16.0. + final double? height; + + /// The thickness of the line drawn within the divider. + /// + /// {@template flutter.material.Divider.thickness} + /// A divider with a [thickness] of 0.0 is always drawn as a line with a + /// height of exactly one device pixel. + /// + /// If this is null, then the [DividerThemeData.thickness] is used. If + /// that is also null, then this defaults to 0.0. + /// {@endtemplate} + final double? thickness; + + /// The amount of empty space to the leading edge of the divider. + /// + /// {@template flutter.material.Divider.indent} + /// If this is null, then the [DividerThemeData.indent] is used. If that is + /// also null, then this defaults to 0.0. + /// {@endtemplate} + final double? indent; + + /// The amount of empty space to the trailing edge of the divider. + /// + /// {@template flutter.material.Divider.endIndent} + /// If this is null, then the [DividerThemeData.endIndent] is used. If that is + /// also null, then this defaults to 0.0. + /// {@endtemplate} + final double? endIndent; + + /// {@template flutter.material.Divider.radius} + /// The amount of radius for the border of the divider. + /// + /// If this is null, then [DividerThemeData.radius] is used. If that is + /// also null, then the default radius of [BoxDecoration] is used. + /// {@endtemplate} + final BorderRadiusGeometry? radius; + + /// {@template flutter.material.Divider.color} + /// The color to use when painting the line. + /// + /// If this is null, then the [DividerThemeData.color] is used. If that is + /// also null, then [ThemeData.dividerColor] is used. + /// {@endtemplate} + /// + /// {@tool snippet} + /// + /// ```dart + /// const Divider( + /// color: Colors.deepOrange, + /// ) + /// ``` + /// {@end-tool} + final Color? color; + + /// Computes the [BorderSide] that represents a divider. + /// + /// If [color] is null, then [DividerThemeData.color] is used. If that is also + /// null, then if [ThemeData.useMaterial3] is true then it defaults to + /// [ThemeData.colorScheme]'s [ColorScheme.outlineVariant]. Otherwise + /// [ThemeData.dividerColor] is used. + /// + /// If [width] is null, then [DividerThemeData.thickness] is used. If that is + /// also null, then this defaults to 0.0 (a hairline border). + /// + /// If [context] is null, the default color of [BorderSide] is used and the + /// default width of 0.0 is used. + /// + /// {@tool snippet} + /// + /// This example uses this method to create a box that has a divider above and + /// below it. This is sometimes useful with lists, for instance, to separate a + /// scrollable section from the rest of the interface. + /// + /// ```dart + /// DecoratedBox( + /// decoration: BoxDecoration( + /// border: Border( + /// top: Divider.createBorderSide(context), + /// bottom: Divider.createBorderSide(context), + /// ), + /// ), + /// // child: ... + /// ) + /// ``` + /// {@end-tool} + static BorderSide createBorderSide(BuildContext? context, {Color? color, double? width}) { + final DividerThemeData? dividerTheme = context != null ? DividerTheme.of(context) : null; + final DividerThemeData? defaults = context != null + ? Theme.of(context).useMaterial3 + ? _DividerDefaultsM3(context) + : _DividerDefaultsM2(context) + : null; + final Color? effectiveColor = color ?? dividerTheme?.color ?? defaults?.color; + final double effectiveWidth = width ?? dividerTheme?.thickness ?? defaults?.thickness ?? 0.0; + + // Prevent assertion since it is possible that context is null and no color + // is specified. + if (effectiveColor == null) { + return BorderSide(width: effectiveWidth); + } + return BorderSide(color: effectiveColor, width: effectiveWidth); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final DividerThemeData dividerTheme = DividerTheme.of(context); + final DividerThemeData defaults = theme.useMaterial3 + ? _DividerDefaultsM3(context) + : _DividerDefaultsM2(context); + final double height = this.height ?? dividerTheme.space ?? defaults.space!; + final double thickness = this.thickness ?? dividerTheme.thickness ?? defaults.thickness!; + final double indent = this.indent ?? dividerTheme.indent ?? defaults.indent!; + final double endIndent = this.endIndent ?? dividerTheme.endIndent ?? defaults.endIndent!; + + return SizedBox( + height: height, + child: Center( + child: Container( + height: thickness, + margin: EdgeInsetsDirectional.only(start: indent, end: endIndent), + decoration: BoxDecoration( + borderRadius: radius ?? dividerTheme.radius ?? defaults.radius, + border: Border( + bottom: createBorderSide(context, color: color, width: thickness), + ), + ), + ), + ), + ); + } +} + +/// A thin vertical line, with padding on either side. +/// +/// In the Material Design language, this represents a divider. Vertical +/// dividers can be used in horizontally scrolling lists, such as a +/// [ListView] with [ListView.scrollDirection] set to [Axis.horizontal]. +/// +/// The box's total width is controlled by [width]. The appropriate +/// padding is automatically computed from the width. +/// +/// {@tool dartpad} +/// This sample shows how to display a [VerticalDivider] between a purple and orange box +/// inside a [Row]. The [VerticalDivider] is 20 logical pixels in width and contains a +/// horizontally centered black line that is 1 logical pixels thick. The grey +/// line is indented by 20 logical pixels. +/// +/// ** See code in examples/api/lib/material/divider/vertical_divider.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [VerticalDivider] widget, as described in: +/// https://m3.material.io/components/divider/overview +/// +/// ** See code in examples/api/lib/material/divider/vertical_divider.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListView.separated], which can be used to generate vertical dividers. +/// * [Divider], which is the horizontal analog of this widget. +/// * <https://material.io/design/components/dividers.html> +class VerticalDivider extends StatelessWidget { + /// Creates a Material Design vertical divider. + /// + /// The [width], [thickness], [indent], and [endIndent] must be null or + /// non-negative. + const VerticalDivider({ + super.key, + this.width, + this.thickness, + this.indent, + this.endIndent, + this.color, + this.radius, + }) : assert(width == null || width >= 0.0), + assert(thickness == null || thickness >= 0.0), + assert(indent == null || indent >= 0.0), + assert(endIndent == null || endIndent >= 0.0); + + /// The divider's width. + /// + /// The divider itself is always drawn as a vertical line that is centered + /// within the width specified by this value. + /// + /// If this is null, then the [DividerThemeData.space] is used. If that is + /// also null, then this defaults to 16.0. + final double? width; + + /// The thickness of the line drawn within the divider. + /// + /// A divider with a [thickness] of 0.0 is always drawn as a line with a + /// width of exactly one device pixel. + /// + /// If this is null, then the [DividerThemeData.thickness] is used which + /// defaults to 0.0. + final double? thickness; + + /// The amount of empty space on top of the divider. + /// + /// If this is null, then the [DividerThemeData.indent] is used. If that is + /// also null, then this defaults to 0.0. + final double? indent; + + /// The amount of empty space under the divider. + /// + /// If this is null, then the [DividerThemeData.endIndent] is used. If that is + /// also null, then this defaults to 0.0. + final double? endIndent; + + /// The color to use when painting the line. + /// + /// If this is null, then the [DividerThemeData.color] is used. If that is + /// also null, then [ThemeData.dividerColor] is used. + /// + /// {@tool snippet} + /// + /// ```dart + /// const Divider( + /// color: Colors.deepOrange, + /// ) + /// ``` + /// {@end-tool} + final Color? color; + + /// The amount of radius for the border of the divider. + /// + /// If this is null, then the default radius of [BoxDecoration] will be used. + final BorderRadiusGeometry? radius; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final DividerThemeData dividerTheme = DividerTheme.of(context); + final DividerThemeData defaults = theme.useMaterial3 + ? _DividerDefaultsM3(context) + : _DividerDefaultsM2(context); + final double width = this.width ?? dividerTheme.space ?? defaults.space!; + final double thickness = this.thickness ?? dividerTheme.thickness ?? defaults.thickness!; + final double indent = this.indent ?? dividerTheme.indent ?? defaults.indent!; + final double endIndent = this.endIndent ?? dividerTheme.endIndent ?? defaults.endIndent!; + + return SizedBox( + width: width, + child: Center( + child: Container( + width: thickness, + margin: EdgeInsetsDirectional.only(top: indent, bottom: endIndent), + decoration: BoxDecoration( + borderRadius: radius ?? dividerTheme.radius ?? defaults.radius, + border: Border( + left: Divider.createBorderSide(context, color: color, width: thickness), + ), + ), + ), + ), + ); + } +} + +class _DividerDefaultsM2 extends DividerThemeData { + const _DividerDefaultsM2(this.context) : super(space: 16, thickness: 0, indent: 0, endIndent: 0); + + final BuildContext context; + + @override + Color? get color => Theme.of(context).dividerColor; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Divider + +// 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. + +// dart format off +class _DividerDefaultsM3 extends DividerThemeData { + const _DividerDefaultsM3(this.context) : super( + space: 16, + thickness: 1.0, + indent: 0, + endIndent: 0, + ); + + final BuildContext context; + + @override Color? get color => Theme.of(context).colorScheme.outlineVariant; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Divider diff --git a/packages/material_ui/lib/src/divider_theme.dart b/packages/material_ui/lib/src/divider_theme.dart new file mode 100644 index 000000000000..8539edb91400 --- /dev/null +++ b/packages/material_ui/lib/src/divider_theme.dart @@ -0,0 +1,181 @@ +// 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. + +/// @docImport 'data_table.dart'; +/// @docImport 'divider.dart'; +/// @docImport 'list_tile.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the visual properties of [Divider], [VerticalDivider], dividers +/// between [ListTile]s, and dividers between rows in [DataTable]s. +/// +/// Descendant widgets obtain the current [DividerThemeData] object using +/// [DividerTheme.of]. Instances of [DividerThemeData] can be customized with +/// [DividerThemeData.copyWith]. +/// +/// Typically a [DividerThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.dividerTheme]. +/// +/// All [DividerThemeData] properties are `null` by default. When null, +/// the widgets will provide their own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class DividerThemeData with Diagnosticable { + /// Creates a theme that can be used for [DividerTheme] or + /// [ThemeData.dividerTheme]. + const DividerThemeData({ + this.color, + this.space, + this.thickness, + this.indent, + this.endIndent, + this.radius, + }); + + /// The color of [Divider]s and [VerticalDivider]s, also + /// used between [ListTile]s, between rows in [DataTable]s, and so forth. + final Color? color; + + /// The [Divider]'s height or the [VerticalDivider]'s width. + /// + /// This represents the amount of horizontal or vertical space the divider + /// takes up. + final double? space; + + /// The thickness of the line drawn within the divider. + final double? thickness; + + /// The amount of empty space at the leading edge of [Divider] or top edge of + /// [VerticalDivider]. + final double? indent; + + /// The amount of empty space at the trailing edge of [Divider] or bottom edge + /// of [VerticalDivider]. + final double? endIndent; + + /// The border radius applied to the [Divider] or [VerticalDivider]. + /// + /// If non-null, this radius will be used to round the corners of the divider. + final BorderRadiusGeometry? radius; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + DividerThemeData copyWith({ + Color? color, + double? space, + double? thickness, + double? indent, + double? endIndent, + BorderRadiusGeometry? radius, + }) { + return DividerThemeData( + color: color ?? this.color, + space: space ?? this.space, + thickness: thickness ?? this.thickness, + indent: indent ?? this.indent, + endIndent: endIndent ?? this.endIndent, + radius: radius ?? this.radius, + ); + } + + /// Linearly interpolate between two Divider themes. + /// + /// {@macro dart.ui.shadow.lerp} + static DividerThemeData lerp(DividerThemeData? a, DividerThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return DividerThemeData( + color: Color.lerp(a?.color, b?.color, t), + space: lerpDouble(a?.space, b?.space, t), + thickness: lerpDouble(a?.thickness, b?.thickness, t), + indent: lerpDouble(a?.indent, b?.indent, t), + endIndent: lerpDouble(a?.endIndent, b?.endIndent, t), + radius: BorderRadiusGeometry.lerp(a?.radius, b?.radius, t), + ); + } + + @override + int get hashCode => Object.hash(color, space, thickness, indent, endIndent, radius); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is DividerThemeData && + other.color == color && + other.space == space && + other.thickness == thickness && + other.indent == indent && + other.endIndent == endIndent && + other.radius == radius; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(DoubleProperty('space', space, defaultValue: null)); + properties.add(DoubleProperty('thickness', thickness, defaultValue: null)); + properties.add(DoubleProperty('indent', indent, defaultValue: null)); + properties.add(DoubleProperty('endIndent', endIndent, defaultValue: null)); + properties.add(DiagnosticsProperty<BorderRadiusGeometry>('radius', radius, defaultValue: null)); + } +} + +/// An inherited widget that defines the configuration for +/// [Divider]s, [VerticalDivider]s, dividers between [ListTile]s, and dividers +/// between rows in [DataTable]s in this widget's subtree. +class DividerTheme extends InheritedTheme { + /// Creates a divider theme that controls the configurations for + /// [Divider]s, [VerticalDivider]s, dividers between [ListTile]s, and dividers + /// between rows in [DataTable]s in its widget subtree. + const DividerTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant [Divider]s, [VerticalDivider]s, dividers + /// between [ListTile]s, and dividers between rows in [DataTable]s. + final DividerThemeData data; + + /// The closest instance of this class's [data] value that encloses the given + /// context. + /// + /// If there is no ancestor, it returns [ThemeData.dividerTheme]. Applications + /// can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DividerThemeData theme = DividerTheme.of(context); + /// ``` + static DividerThemeData of(BuildContext context) { + final DividerTheme? dividerTheme = context.dependOnInheritedWidgetOfExactType<DividerTheme>(); + return dividerTheme?.data ?? Theme.of(context).dividerTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return DividerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(DividerTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/drawer.dart b/packages/material_ui/lib/src/drawer.dart new file mode 100644 index 000000000000..634226cfe8a1 --- /dev/null +++ b/packages/material_ui/lib/src/drawer.dart @@ -0,0 +1,817 @@ +// 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. + +/// @docImport 'package:flutter/gestures.dart'; +/// @docImport 'package:flutter/semantics.dart'; +/// +/// @docImport 'about.dart'; +/// @docImport 'app_bar.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'drawer_header.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'navigation_drawer.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'debug.dart'; +import 'drawer_theme.dart'; +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// The possible alignments of a [Drawer]. +enum DrawerAlignment { + /// Denotes that the [Drawer] is at the start side of the [Scaffold]. + /// + /// This corresponds to the left side when the text direction is left-to-right + /// and the right side when the text direction is right-to-left. + start, + + /// Denotes that the [Drawer] is at the end side of the [Scaffold]. + /// + /// This corresponds to the right side when the text direction is left-to-right + /// and the left side when the text direction is right-to-left. + end, +} + +// TODO(eseidel): Draw width should vary based on device size: +// https://material.io/design/components/navigation-drawer.html#specs + +// Mobile: +// Width = Screen width − 56 dp +// Maximum width: 320dp +// Maximum width applies only when using a left nav. When using a right nav, +// the panel can cover the full width of the screen. + +// Desktop/Tablet: +// Maximum width for a left nav is 400dp. +// The right nav can vary depending on content. + +const double _kWidth = 304.0; +const double _kEdgeDragWidth = 20.0; +const double _kMinFlingVelocity = 365.0; +const Duration _kBaseSettleDuration = Duration(milliseconds: 246); + +/// A Material Design panel that slides in horizontally from the edge of a +/// [Scaffold] to show navigation links in an application. +/// +/// There is a Material 3 version of this component, [NavigationDrawer], +/// that's preferred for applications that are configured for Material 3 +/// (see [ThemeData.useMaterial3]). +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=WRj86iHihgY} +/// +/// Drawers are typically used with the [Scaffold.drawer] property. The child of +/// the drawer is usually a [ListView] whose first child is a [DrawerHeader] +/// that displays status information about the current user. The remaining +/// drawer children are often constructed with [ListTile]s, often concluding +/// with an [AboutListTile]. +/// +/// The [AppBar] automatically displays an appropriate [IconButton] to show the +/// [Drawer] when a [Drawer] is available in the [Scaffold]. The [Scaffold] +/// automatically handles the edge-swipe gesture to show the drawer. +/// +/// {@animation 350 622 https://flutter.github.io/assets-for-api-docs/assets/material/drawer.mp4} +/// +/// ## Updating to [NavigationDrawer] +/// +/// There is a Material 3 version of this component, [NavigationDrawer], +/// that's preferred for applications that are configured for Material 3 +/// (see [ThemeData.useMaterial3]). The [NavigationDrawer] widget's visual +/// are a little bit different, see the Material 3 spec at +/// <https://m3.material.io/components/navigation-drawer/overview> for +/// more details. While the [Drawer] widget can have only one child, the +/// [NavigationDrawer] widget can have a list of widgets, which typically contains +/// [NavigationDrawerDestination] widgets and/or customized widgets like headlines +/// and dividers. +/// +/// {@tool dartpad} +/// This example shows how to create a [Scaffold] that contains an [AppBar] and +/// a [Drawer]. A user taps the "menu" icon in the [AppBar] to open the +/// [Drawer]. The [Drawer] displays four items: A header and three menu items. +/// The [Drawer] displays the four items using a [ListView], which allows the +/// user to scroll through the items if need be. +/// +/// ** See code in examples/api/lib/material/drawer/drawer.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to migrate the above [Drawer] to a [NavigationDrawer]. +/// +/// ** See code in examples/api/lib/material/navigation_drawer/navigation_drawer.0.dart ** +/// {@end-tool} +/// +/// An open drawer may be closed with a swipe to close gesture, pressing the +/// escape key, by tapping the scrim, or by calling pop route function such as +/// [Navigator.pop]. For example a drawer item might close the drawer when tapped: +/// +/// ```dart +/// ListTile( +/// leading: const Icon(Icons.change_history), +/// title: const Text('Change history'), +/// onTap: () { +/// // change app state... +/// Navigator.pop(context); // close the drawer +/// }, +/// ); +/// ``` +/// +/// See also: +/// +/// * [Scaffold.drawer], where one specifies a [Drawer] so that it can be +/// shown. +/// * [Scaffold.of], to obtain the current [ScaffoldState], which manages the +/// display and animation of the drawer. +/// * [ScaffoldState.openDrawer], which displays its [Drawer], if any. +/// * <https://material.io/design/components/navigation-drawer.html> +class Drawer extends StatelessWidget { + /// Creates a Material Design drawer. + /// + /// Typically used in the [Scaffold.drawer] property. + /// + /// The [elevation] must be non-negative. + const Drawer({ + super.key, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.shape, + this.width, + this.child, + this.semanticLabel, + this.clipBehavior, + }) : assert(elevation == null || elevation >= 0.0); + + /// Sets the color of the [Material] that holds all of the [Drawer]'s + /// contents. + /// + /// If this is null, then [DrawerThemeData.backgroundColor] is used. If that + /// is also null, then it falls back to [Material]'s default. + final Color? backgroundColor; + + /// The z-coordinate at which to place this drawer relative to its parent. + /// + /// This controls the size of the shadow below the drawer. + /// + /// If this is null, then [DrawerThemeData.elevation] is used. If that + /// is also null, then it defaults to 16.0. + final double? elevation; + + /// The color used to paint a drop shadow under the drawer's [Material], + /// which reflects the drawer's [elevation]. + /// + /// If null and [ThemeData.useMaterial3] is true then no drop shadow will + /// be rendered. + /// + /// If null and [ThemeData.useMaterial3] is false then it will default to + /// [ThemeData.shadowColor]. + /// + /// See also: + /// * [Material.shadowColor], which describes how the drop shadow is painted. + /// * [elevation], which affects how the drop shadow is painted. + /// * [surfaceTintColor], which can be used to indicate elevation through + /// tinting the background color. + final Color? shadowColor; + + /// The color used as a surface tint overlay on the drawer's background color, + /// which reflects the drawer's [elevation]. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// To disable this feature, set [surfaceTintColor] to [Colors.transparent]. + /// + /// Defaults to [Colors.transparent]. + /// + /// See also: + /// * [Material.surfaceTintColor], which describes how the surface tint will + /// be applied to the background color of the drawer. + /// * [elevation], which affects the opacity of the surface tint. + /// * [shadowColor], which can be used to indicate elevation through + /// a drop shadow. + final Color? surfaceTintColor; + + /// The shape of the drawer. + /// + /// Defines the drawer's [Material.shape]. + /// + /// If this is null, then [DrawerThemeData.shape] is used. If that + /// is also null, then it falls back to [Material]'s default. + final ShapeBorder? shape; + + /// The width of the drawer. + /// + /// If this is null, then [DrawerThemeData.width] is used. If that is also + /// null, then it falls back to the Material spec's default (304.0). + final double? width; + + /// The widget below this widget in the tree. + /// + /// Typically a [ListView]. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// The semantic label of the drawer used by accessibility frameworks to + /// announce screen transitions when the drawer is opened and closed. + /// + /// If this label is not provided, it will default to + /// [MaterialLocalizations.drawerLabel]. + /// + /// See also: + /// + /// * [SemanticsConfiguration.namesRoute], for a description of how this + /// value is used. + final String? semanticLabel; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// The [clipBehavior] argument specifies how to clip the drawer's [shape]. + /// + /// If the drawer has a [shape], it defaults to [Clip.hardEdge]. Otherwise, + /// defaults to [Clip.none]. + final Clip? clipBehavior; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final DrawerThemeData drawerTheme = DrawerTheme.of(context); + final String? label = switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => semanticLabel, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => semanticLabel ?? MaterialLocalizations.of(context).drawerLabel, + }; + final bool useMaterial3 = Theme.of(context).useMaterial3; + final isDrawerStart = DrawerController.maybeOf(context)?.alignment != DrawerAlignment.end; + final DrawerThemeData defaults = useMaterial3 + ? _DrawerDefaultsM3(context) + : _DrawerDefaultsM2(context); + final ShapeBorder? effectiveShape = + shape ?? + (isDrawerStart + ? (drawerTheme.shape ?? defaults.shape) + : (drawerTheme.endShape ?? defaults.endShape)); + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: label, + child: ConstrainedBox( + constraints: BoxConstraints.expand(width: width ?? drawerTheme.width ?? _kWidth), + child: Material( + color: backgroundColor ?? drawerTheme.backgroundColor ?? defaults.backgroundColor, + elevation: elevation ?? drawerTheme.elevation ?? defaults.elevation!, + shadowColor: shadowColor ?? drawerTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: + surfaceTintColor ?? drawerTheme.surfaceTintColor ?? defaults.surfaceTintColor, + shape: effectiveShape, + clipBehavior: effectiveShape != null + ? (clipBehavior ?? drawerTheme.clipBehavior ?? defaults.clipBehavior!) + : Clip.none, + child: child, + ), + ), + ); + } +} + +/// Signature for the callback that's called when a [DrawerController] is +/// opened or closed. +typedef DrawerCallback = void Function(bool isOpened); + +class _DrawerControllerScope extends InheritedWidget { + const _DrawerControllerScope({required this.controller, required super.child}); + + final DrawerController controller; + + @override + bool updateShouldNotify(_DrawerControllerScope old) { + return controller != old.controller; + } +} + +/// Provides interactive behavior for [Drawer] widgets. +/// +/// Rarely used directly. Drawer controllers are typically created automatically +/// by [Scaffold] widgets. +/// +/// The drawer controller provides the ability to open and close a drawer, either +/// via an animation or via user interaction. When closed, the drawer collapses +/// to a translucent gesture detector that can be used to listen for edge +/// swipes. +/// +/// See also: +/// +/// * [Drawer], a container with the default width of a drawer. +/// * [Scaffold.drawer], the [Scaffold] slot for showing a drawer. +class DrawerController extends StatefulWidget { + /// Creates a controller for a [Drawer]. + /// + /// Rarely used directly. + /// + /// The [child] argument is typically a [Drawer]. + const DrawerController({ + GlobalKey? key, + required this.child, + required this.alignment, + this.isDrawerOpen = false, + this.drawerCallback, + this.dragStartBehavior = DragStartBehavior.start, + this.scrimColor, + this.edgeDragWidth, + this.enableOpenDragGesture = true, + this.drawerBarrierDismissible = true, + }) : super(key: key); + + /// The widget below this widget in the tree. + /// + /// Typically a [Drawer]. + final Widget child; + + /// The alignment of the [Drawer]. + /// + /// This controls the direction in which the user should swipe to open and + /// close the drawer. + final DrawerAlignment alignment; + + /// Optional callback that is called when a [Drawer] is opened or closed. + final DrawerCallback? drawerCallback; + + /// Whether tapping the barrier behind the [Drawer] dismisses it. + /// + /// Defaults to true. + /// + /// If false, tapping the barrier will not dismiss the drawer. + final bool drawerBarrierDismissible; + + /// {@template flutter.material.DrawerController.dragStartBehavior} + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag behavior used for opening + /// and closing a drawer will begin at the position where the drag gesture won + /// the arena. If set to [DragStartBehavior.down] it will begin at the position + /// where a down event is first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for + /// the different behaviors. + /// + /// {@endtemplate} + final DragStartBehavior dragStartBehavior; + + /// The color to use for the scrim that obscures the underlying content while + /// a drawer is open. + /// + /// If this is null, then [DrawerThemeData.scrimColor] is used. If that + /// is also null, then it defaults to [Colors.black54]. + final Color? scrimColor; + + /// Determines if the [Drawer] can be opened with a drag gesture. + /// + /// By default, the drag gesture is enabled. + final bool enableOpenDragGesture; + + /// The width of the area within which a horizontal swipe will open the + /// drawer. + /// + /// By default, the value used is 20.0 added to the padding edge of + /// `MediaQuery.paddingOf(context)` that corresponds to [alignment]. + /// This ensures that the drag area for notched devices is not obscured. For + /// example, if [alignment] is set to [DrawerAlignment.start] and + /// `TextDirection.of(context)` is set to [TextDirection.ltr], + /// 20.0 will be added to `MediaQuery.paddingOf(context).left`. + final double? edgeDragWidth; + + /// Whether or not the drawer is opened or closed. + /// + /// This parameter is primarily used by the state restoration framework + /// to restore the drawer's animation controller to the open or closed state + /// depending on what was last saved to the target platform before the + /// application was killed. + final bool isDrawerOpen; + + /// The closest instance of [DrawerController] that encloses the given + /// context, or null if none is found. + /// + /// {@tool snippet} Typical usage is as follows: + /// + /// ```dart + /// DrawerController? controller = DrawerController.maybeOf(context); + /// ``` + /// {@end-tool} + /// + /// Calling this method will create a dependency on the closest + /// [DrawerController] in the [context], if there is one. + /// + /// See also: + /// + /// * [DrawerController.of], which is similar to this method, but asserts + /// if no [DrawerController] ancestor is found. + static DrawerController? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_DrawerControllerScope>()?.controller; + } + + /// The closest instance of [DrawerController] that encloses the given + /// context. + /// + /// If no instance is found, this method will assert in debug mode and throw + /// an exception in release mode. + /// + /// Calling this method will create a dependency on the closest + /// [DrawerController] in the [context]. + /// + /// {@tool snippet} Typical usage is as follows: + /// + /// ```dart + /// DrawerController controller = DrawerController.of(context); + /// ``` + /// {@end-tool} + static DrawerController of(BuildContext context) { + final DrawerController? controller = maybeOf(context); + assert(() { + if (controller == null) { + throw FlutterError( + 'DrawerController.of() was called with a context that does not ' + 'contain a DrawerController widget.\n' + 'No DrawerController widget ancestor could be found starting from ' + 'the context that was passed to DrawerController.of(). This can ' + 'happen because you are using a widget that looks for a DrawerController ' + 'ancestor, but no such ancestor exists.\n' + 'The context used was:\n' + ' $context', + ); + } + return true; + }()); + return controller!; + } + + @override + DrawerControllerState createState() => DrawerControllerState(); +} + +/// State for a [DrawerController]. +/// +/// Typically used by a [Scaffold] to [open] and [close] the drawer. +class DrawerControllerState extends State<DrawerController> with SingleTickerProviderStateMixin { + @protected + @override + void initState() { + super.initState(); + _controller = AnimationController( + value: widget.isDrawerOpen ? 1.0 : 0.0, + duration: _kBaseSettleDuration, + vsync: this, + ); + _controller + ..addListener(_animationChanged) + ..addStatusListener(_animationStatusChanged); + } + + @protected + @override + void dispose() { + _historyEntry?.remove(); + _controller.dispose(); + _focusScopeNode.dispose(); + super.dispose(); + } + + @protected + @override + void didUpdateWidget(DrawerController oldWidget) { + super.didUpdateWidget(oldWidget); + + if (_controller.status.isAnimating) { + return; // Don't snap the drawer open or shut while the user is dragging. + } + if (widget.isDrawerOpen != oldWidget.isDrawerOpen) { + _controller.value = widget.isDrawerOpen ? 1.0 : 0.0; + } + } + + void _animationChanged() { + setState(() { + // The animation controller's state is our build state, and it changed already. + }); + } + + LocalHistoryEntry? _historyEntry; + final FocusScopeNode _focusScopeNode = FocusScopeNode(); + + void _ensureHistoryEntry() { + if (_historyEntry == null) { + final ModalRoute<dynamic>? route = ModalRoute.of(context); + if (route != null) { + _historyEntry = LocalHistoryEntry( + onRemove: _handleHistoryEntryRemoved, + impliesAppBarDismissal: false, + ); + route.addLocalHistoryEntry(_historyEntry!); + FocusScope.of(context).setFirstFocus(_focusScopeNode); + } + } + } + + void _animationStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.forward: + _ensureHistoryEntry(); + case AnimationStatus.reverse: + _historyEntry?.remove(); + _historyEntry = null; + case AnimationStatus.dismissed: + case AnimationStatus.completed: + break; + } + } + + void _handleHistoryEntryRemoved() { + _historyEntry = null; + close(); + } + + late AnimationController _controller; + + void _handleDragDown(DragDownDetails details) { + _controller.stop(); + _ensureHistoryEntry(); + } + + void _handleDragCancel() { + if (_controller.isDismissed || _controller.isAnimating) { + return; + } + if (_controller.value < 0.5) { + close(); + } else { + open(); + } + } + + final GlobalKey _drawerKey = GlobalKey(); + + double get _width { + final box = _drawerKey.currentContext?.findRenderObject() as RenderBox?; + // return _kWidth if drawer not being shown currently + return box?.size.width ?? _kWidth; + } + + bool _previouslyOpened = false; + + int get _directionFactor { + return switch ((Directionality.of(context), widget.alignment)) { + (TextDirection.rtl, DrawerAlignment.start) => -1, + (TextDirection.rtl, DrawerAlignment.end) => 1, + (TextDirection.ltr, DrawerAlignment.start) => 1, + (TextDirection.ltr, DrawerAlignment.end) => -1, + }; + } + + void _move(DragUpdateDetails details) { + _controller.value += details.primaryDelta! / _width * _directionFactor; + + final bool opened = _controller.value > 0.5; + if (opened != _previouslyOpened && widget.drawerCallback != null) { + widget.drawerCallback!(opened); + } + _previouslyOpened = opened; + } + + void _settle(DragEndDetails details) { + if (_controller.isDismissed) { + return; + } + final double xVelocity = details.velocity.pixelsPerSecond.dx; + if (xVelocity.abs() >= _kMinFlingVelocity) { + final double visualVelocity = xVelocity / _width * _directionFactor; + + _controller.fling(velocity: visualVelocity); + widget.drawerCallback?.call(visualVelocity > 0.0); + } else if (_controller.value < 0.5) { + close(); + } else { + open(); + } + } + + /// Starts an animation to open the drawer. + /// + /// Typically called by [ScaffoldState.openDrawer]. + void open() { + _controller.fling(); + widget.drawerCallback?.call(true); + } + + /// Starts an animation to close the drawer. + void close() { + _controller.fling(velocity: -1.0); + widget.drawerCallback?.call(false); + } + + final GlobalKey _gestureDetectorKey = GlobalKey(); + + AlignmentDirectional get _drawerOuterAlignment => switch (widget.alignment) { + DrawerAlignment.start => AlignmentDirectional.centerStart, + DrawerAlignment.end => AlignmentDirectional.centerEnd, + }; + + AlignmentDirectional get _drawerInnerAlignment => switch (widget.alignment) { + DrawerAlignment.start => AlignmentDirectional.centerEnd, + DrawerAlignment.end => AlignmentDirectional.centerStart, + }; + + Widget _buildDrawer(BuildContext context) { + final bool isDesktop = switch (Theme.of(context).platform) { + TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => false, + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => true, + }; + + final double dragAreaWidth = + widget.edgeDragWidth ?? + _kEdgeDragWidth + + switch ((widget.alignment, Directionality.of(context))) { + (DrawerAlignment.start, TextDirection.ltr) => MediaQuery.paddingOf(context).left, + (DrawerAlignment.start, TextDirection.rtl) => MediaQuery.paddingOf(context).right, + (DrawerAlignment.end, TextDirection.rtl) => MediaQuery.paddingOf(context).left, + (DrawerAlignment.end, TextDirection.ltr) => MediaQuery.paddingOf(context).right, + }; + + if (_controller.isDismissed) { + if (widget.enableOpenDragGesture && !isDesktop) { + return Align( + alignment: _drawerOuterAlignment, + child: GestureDetector( + key: _gestureDetectorKey, + onHorizontalDragUpdate: _move, + onHorizontalDragEnd: _settle, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + dragStartBehavior: widget.dragStartBehavior, + child: LimitedBox( + maxHeight: 0.0, + child: SizedBox(width: dragAreaWidth, height: double.infinity), + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + } else { + final bool platformHasBackButton = switch (defaultTargetPlatform) { + TargetPlatform.android => true, + TargetPlatform.iOS || + TargetPlatform.macOS || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => false, + }; + + final Color scrimColor = + widget.scrimColor ?? DrawerTheme.of(context).scrimColor ?? Colors.black54; + final Color effectiveScrimColor = scrimColor.withValues( + alpha: scrimColor.a * _controller.value, + ); + final Widget drawerScrim = ColoredBox( + color: effectiveScrimColor, + child: const LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand()), + ); + + final Widget child = _DrawerControllerScope( + controller: widget, + child: RepaintBoundary( + child: Stack( + children: <Widget>[ + BlockSemantics( + child: ExcludeSemantics( + // On Android, the back button is used to dismiss a modal. + excluding: platformHasBackButton, + child: GestureDetector( + onTap: widget.drawerBarrierDismissible ? close : null, + child: Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + child: drawerScrim, + ), + ), + ), + ), + Align( + alignment: _drawerOuterAlignment, + child: Align( + alignment: _drawerInnerAlignment, + widthFactor: _controller.value, + child: RepaintBoundary( + child: FocusScope(key: _drawerKey, node: _focusScopeNode, child: widget.child), + ), + ), + ), + ], + ), + ), + ); + + if (isDesktop) { + return child; + } + + return GestureDetector( + key: _gestureDetectorKey, + onHorizontalDragDown: _handleDragDown, + onHorizontalDragUpdate: _move, + onHorizontalDragEnd: _settle, + onHorizontalDragCancel: _handleDragCancel, + excludeFromSemantics: true, + dragStartBehavior: widget.dragStartBehavior, + child: child, + ); + } + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + return ListTileTheme.merge(style: ListTileStyle.drawer, child: _buildDrawer(context)); + } +} + +class _DrawerDefaultsM2 extends DrawerThemeData { + const _DrawerDefaultsM2(this.context) : super(elevation: 16.0, clipBehavior: Clip.hardEdge); + + final BuildContext context; + + @override + Color? get shadowColor => Theme.of(context).shadowColor; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Drawer + +// 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. + +// dart format off +class _DrawerDefaultsM3 extends DrawerThemeData { + _DrawerDefaultsM3(this.context) + : super( + elevation: 1.0, + clipBehavior: Clip.hardEdge, + ); + + final BuildContext context; + late final TextDirection direction = Directionality.of(context); + + @override + Color? get backgroundColor => Theme.of(context).colorScheme.surfaceContainerLow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get shadowColor => Colors.transparent; + + // There isn't currently a token for this value, but it is shown in the spec, + // so hard coding here for now. + @override + ShapeBorder? get shape => RoundedRectangleBorder( + borderRadius: const BorderRadiusDirectional.horizontal( + end: Radius.circular(16.0), + ).resolve(direction), + ); + + // There isn't currently a token for this value, but it is shown in the spec, + // so hard coding here for now. + @override + ShapeBorder? get endShape => RoundedRectangleBorder( + borderRadius: const BorderRadiusDirectional.horizontal( + start: Radius.circular(16.0), + ).resolve(direction), + ); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Drawer diff --git a/packages/material_ui/lib/src/drawer_header.dart b/packages/material_ui/lib/src/drawer_header.dart new file mode 100644 index 000000000000..d7259826096a --- /dev/null +++ b/packages/material_ui/lib/src/drawer_header.dart @@ -0,0 +1,103 @@ +// 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. + +/// @docImport 'drawer.dart'; +/// @docImport 'material.dart'; +/// @docImport 'user_accounts_drawer_header.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'divider.dart'; +import 'theme.dart'; + +const double _kDrawerHeaderHeight = 160.0 + 1.0; // bottom edge + +/// The top-most region of a Material Design drawer. The header's [child] +/// widget, if any, is placed inside a [Container] whose [decoration] can be +/// passed as an argument, inset by the given [padding]. +/// +/// Part of the Material Design [Drawer]. +/// +/// Requires one of its ancestors to be a [Material] widget. This condition is +/// satisfied by putting the [DrawerHeader] in a [Drawer]. +/// +/// See also: +/// +/// * [UserAccountsDrawerHeader], a variant of [DrawerHeader] that is +/// specialized for showing user accounts. +/// * <https://material.io/design/components/navigation-drawer.html> +class DrawerHeader extends StatelessWidget { + /// Creates a Material Design drawer header. + /// + /// Requires one of its ancestors to be a [Material] widget. + const DrawerHeader({ + super.key, + this.decoration, + this.margin = const EdgeInsets.only(bottom: 8.0), + this.padding = const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), + this.duration = const Duration(milliseconds: 250), + this.curve = Curves.fastOutSlowIn, + required this.child, + }); + + /// Decoration for the main drawer header [Container]; useful for applying + /// backgrounds. + /// + /// This decoration will extend under the system status bar. + /// + /// If this is changed, it will be animated according to [duration] and [curve]. + final Decoration? decoration; + + /// The padding by which to inset [child]. + /// + /// The [DrawerHeader] additionally offsets the child by the height of the + /// system status bar. + /// + /// If the child is null, the padding has no effect. + final EdgeInsetsGeometry padding; + + /// The margin around the drawer header. + final EdgeInsetsGeometry? margin; + + /// The duration for animations of the [decoration]. + final Duration duration; + + /// The curve for animations of the [decoration]. + final Curve curve; + + /// A widget to be placed inside the drawer header, inset by the [padding]. + /// + /// This widget will be sized to the size of the header. To position the child + /// precisely, consider using an [Align] or [Center] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + final double statusBarHeight = MediaQuery.paddingOf(context).top; + return Container( + height: statusBarHeight + _kDrawerHeaderHeight, + margin: margin, + decoration: BoxDecoration(border: Border(bottom: Divider.createBorderSide(context))), + child: AnimatedContainer( + padding: padding.add(EdgeInsets.only(top: statusBarHeight)), + decoration: decoration, + duration: duration, + curve: curve, + child: child == null + ? null + : DefaultTextStyle( + style: theme.textTheme.bodyLarge!, + child: MediaQuery.removePadding(context: context, removeTop: true, child: child!), + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/drawer_theme.dart b/packages/material_ui/lib/src/drawer_theme.dart new file mode 100644 index 000000000000..368477df9cd1 --- /dev/null +++ b/packages/material_ui/lib/src/drawer_theme.dart @@ -0,0 +1,213 @@ +// 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. + +/// @docImport 'drawer.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Drawer] widgets. +/// +/// Descendant widgets obtain the current [DrawerThemeData] object +/// using [DrawerTheme.of]. Instances of [DrawerThemeData] can be +/// customized with [DrawerThemeData.copyWith]. +/// +/// Typically a [DrawerThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.drawerTheme]. +/// +/// All [DrawerThemeData] properties are `null` by default. +/// +/// See also: +/// +/// * [DrawerTheme], an [InheritedWidget] that propagates the theme down its +/// subtree. +/// * [ThemeData], which describes the overall theme information for the +/// application and can customize a drawer using [ThemeData.drawerTheme]. +@immutable +class DrawerThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.drawerTheme] and + /// [DrawerTheme]. + const DrawerThemeData({ + this.backgroundColor, + this.scrimColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.shape, + this.endShape, + this.width, + this.clipBehavior, + }); + + /// Overrides the default value of [Drawer.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [DrawerController.scrimColor]. + final Color? scrimColor; + + /// Overrides the default value of [Drawer.elevation]. + final double? elevation; + + /// Overrides the default value for [Drawer.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value for [Drawer.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of [Drawer.shape]. + final ShapeBorder? shape; + + /// Overrides the default value of [Drawer.shape] for an end drawer. + final ShapeBorder? endShape; + + /// Overrides the default value of [Drawer.width]. + final double? width; + + /// Overrides the default value of [Drawer.clipBehavior]. + final Clip? clipBehavior; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + DrawerThemeData copyWith({ + Color? backgroundColor, + Color? scrimColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + ShapeBorder? shape, + ShapeBorder? endShape, + double? width, + Clip? clipBehavior, + }) { + return DrawerThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + scrimColor: scrimColor ?? this.scrimColor, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + shape: shape ?? this.shape, + endShape: endShape ?? this.endShape, + width: width ?? this.width, + clipBehavior: clipBehavior ?? this.clipBehavior, + ); + } + + /// Linearly interpolate between two drawer themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static DrawerThemeData? lerp(DrawerThemeData? a, DrawerThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return DrawerThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + scrimColor: Color.lerp(a?.scrimColor, b?.scrimColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + endShape: ShapeBorder.lerp(a?.endShape, b?.endShape, t), + width: lerpDouble(a?.width, b?.width, t), + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + scrimColor, + elevation, + shadowColor, + surfaceTintColor, + shape, + endShape, + width, + clipBehavior, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is DrawerThemeData && + other.backgroundColor == backgroundColor && + other.scrimColor == scrimColor && + other.elevation == elevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.shape == shape && + other.endShape == endShape && + other.width == width && + other.clipBehavior == clipBehavior; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('scrimColor', scrimColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('endShape', endShape, defaultValue: null)); + properties.add(DoubleProperty('width', width, defaultValue: null)); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + } +} + +/// An inherited widget that defines visual properties for [Drawer]s in this +/// widget's subtree. +/// +/// Values specified here are used for [Drawer] properties that are not +/// given an explicit non-null value. +/// +/// Using this would allow you to override the [ThemeData.drawerTheme]. +class DrawerTheme extends InheritedTheme { + /// Creates a theme that defines the [DrawerThemeData] properties for a + /// [Drawer]. + const DrawerTheme({super.key, required this.data, required super.child}); + + /// Specifies the background color, scrim color, elevation, and shape for + /// descendant [Drawer] widgets. + final DrawerThemeData data; + + /// Retrieves the [DrawerThemeData] from the closest ancestor [DrawerTheme]. + /// + /// If there is no enclosing [DrawerTheme] widget, then + /// [ThemeData.drawerTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DrawerThemeData theme = DrawerTheme.of(context); + /// ``` + static DrawerThemeData of(BuildContext context) { + final DrawerTheme? drawerTheme = context.dependOnInheritedWidgetOfExactType<DrawerTheme>(); + return drawerTheme?.data ?? Theme.of(context).drawerTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return DrawerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(DrawerTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/dropdown.dart b/packages/material_ui/lib/src/dropdown.dart new file mode 100644 index 000000000000..46acc04934db --- /dev/null +++ b/packages/material_ui/lib/src/dropdown.dart @@ -0,0 +1,2014 @@ +// 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. + +/// @docImport 'data_table.dart'; +/// @docImport 'dropdown_menu.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_theme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'icons.dart'; +import 'ink_decoration.dart'; +import 'ink_well.dart'; +import 'input_decorator.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'scrollbar.dart'; +import 'shadows.dart'; +import 'theme.dart'; + +const Duration _kDropdownMenuDuration = Duration(milliseconds: 300); +const double _kMenuItemHeight = kMinInteractiveDimension; +const double _kDenseButtonHeight = 24.0; +const EdgeInsets _kMenuItemPadding = EdgeInsets.symmetric(horizontal: 16.0); +const EdgeInsetsGeometry _kAlignedButtonPadding = EdgeInsetsDirectional.only(start: 16.0, end: 4.0); +const EdgeInsets _kUnalignedButtonPadding = EdgeInsets.zero; +const EdgeInsets _kAlignedMenuMargin = EdgeInsets.zero; +const EdgeInsetsGeometry _kUnalignedMenuMargin = EdgeInsetsDirectional.only(start: 16.0, end: 24.0); + +/// A builder to customize dropdown buttons. +/// +/// Used by [DropdownButton.selectedItemBuilder]. +/// +/// The list of widgets returned by this builder must be exactly the same length +/// as the [DropdownButton.items] list. +typedef DropdownButtonBuilder = List<Widget> Function(BuildContext context); + +class _DropdownMenuPainter extends CustomPainter { + _DropdownMenuPainter({ + this.color, + this.elevation, + this.selectedIndex, + this.borderRadius, + required this.resize, + required this.getSelectedItemOffset, + }) : _painter = BoxDecoration( + // If you add an image here, you must provide a real + // configuration in the paint() function and you must provide some sort + // of onChanged callback here. + color: color, + borderRadius: borderRadius ?? const BorderRadius.all(Radius.circular(2.0)), + boxShadow: kElevationToShadow[elevation], + ).createBoxPainter(), + super(repaint: resize); + + final Color? color; + final int? elevation; + final int? selectedIndex; + final BorderRadius? borderRadius; + final Animation<double> resize; + final ValueGetter<double> getSelectedItemOffset; + final BoxPainter _painter; + + @override + void paint(Canvas canvas, Size size) { + final double selectedItemOffset = getSelectedItemOffset(); + final top = Tween<double>( + begin: clampDouble(selectedItemOffset, 0.0, math.max(size.height - _kMenuItemHeight, 0.0)), + end: 0.0, + ); + + final bottom = Tween<double>( + begin: clampDouble( + top.begin! + _kMenuItemHeight, + math.min(_kMenuItemHeight, size.height), + size.height, + ), + end: size.height, + ); + + final rect = Rect.fromLTRB(0.0, top.evaluate(resize), size.width, bottom.evaluate(resize)); + + _painter.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size)); + } + + @override + bool shouldRepaint(_DropdownMenuPainter oldPainter) { + return oldPainter.color != color || + oldPainter.elevation != elevation || + oldPainter.selectedIndex != selectedIndex || + oldPainter.borderRadius != borderRadius || + oldPainter.resize != resize; + } +} + +// The widget that is the button wrapping the menu items. +class _DropdownMenuItemButton<T> extends StatefulWidget { + const _DropdownMenuItemButton({ + super.key, + this.padding, + required this.route, + required this.buttonRect, + required this.constraints, + required this.itemIndex, + required this.enableFeedback, + required this.scrollController, + this.mouseCursor, + }); + + final _DropdownRoute<T> route; + final ScrollController scrollController; + final EdgeInsets? padding; + final Rect buttonRect; + final BoxConstraints constraints; + final int itemIndex; + final bool enableFeedback; + final MouseCursor? mouseCursor; + + @override + _DropdownMenuItemButtonState<T> createState() => _DropdownMenuItemButtonState<T>(); +} + +class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>> { + late CurvedAnimation _opacityAnimation; + + @override + void initState() { + super.initState(); + _setOpacityAnimation(); + } + + @override + void didUpdateWidget(_DropdownMenuItemButton<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.itemIndex != widget.itemIndex || + oldWidget.route.animation != widget.route.animation || + oldWidget.route.selectedIndex != widget.route.selectedIndex || + widget.route.items.length != oldWidget.route.items.length) { + _opacityAnimation.dispose(); + _setOpacityAnimation(); + } + } + + void _setOpacityAnimation() { + final double unit = 0.5 / (widget.route.items.length + 1.5); + if (widget.itemIndex == widget.route.selectedIndex) { + _opacityAnimation = CurvedAnimation( + parent: widget.route.animation!, + curve: const Threshold(0.0), + ); + } else { + final double start = clampDouble(0.5 + (widget.itemIndex + 1) * unit, 0.0, 1.0); + final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); + _opacityAnimation = CurvedAnimation( + parent: widget.route.animation!, + curve: Interval(start, end), + ); + } + } + + void _handleFocusChange(bool focused) { + final bool inTraditionalMode = switch (FocusManager.instance.highlightMode) { + FocusHighlightMode.touch => false, + FocusHighlightMode.traditional => true, + }; + + if (focused && inTraditionalMode) { + final _MenuLimits menuLimits = widget.route.getMenuLimits( + widget.buttonRect, + widget.constraints.maxHeight, + widget.itemIndex, + ); + widget.scrollController.animateTo( + menuLimits.scrollOffset, + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 100), + ); + } + } + + void _handleOnTap() { + final DropdownMenuItem<T> dropdownMenuItem = widget.route.items[widget.itemIndex].item!; + + dropdownMenuItem.onTap?.call(); + + Navigator.pop(context, _DropdownRouteResult<T>(dropdownMenuItem.value)); + } + + static const Map<ShortcutActivator, Intent> _webShortcuts = <ShortcutActivator, Intent>{ + // On the web, up/down don't change focus, *except* in a <select> + // element, which is what a dropdown emulates. + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + }; + + @override + void dispose() { + _opacityAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final DropdownMenuItem<T> dropdownMenuItem = widget.route.items[widget.itemIndex].item!; + Widget child = widget.route.items[widget.itemIndex]; + if (widget.padding case final EdgeInsetsGeometry padding) { + child = Padding(padding: padding, child: child); + } + child = SizedBox(height: widget.route.itemHeight, child: child); + + final isSelected = widget.itemIndex == widget.route.selectedIndex; + final FocusHighlightMode highlightMode = FocusManager.instance.highlightMode; + // An [InkWell] is added to the item only if it is enabled + if (dropdownMenuItem.enabled) { + child = InkWell( + autofocus: isSelected, + enableFeedback: widget.enableFeedback, + onTap: _handleOnTap, + onFocusChange: _handleFocusChange, + mouseCursor: widget.mouseCursor, + // When highlightMode is traditional, the InkWell draws the selected item background color. + // When highlightMode is touch, insert an Ink to force the selected item background color. + child: highlightMode == FocusHighlightMode.touch + ? Ink(color: isSelected ? Theme.of(context).focusColor : null, child: child) + : child, + ); + } + child = FadeTransition(opacity: _opacityAnimation, child: child); + if (kIsWeb && dropdownMenuItem.enabled) { + child = Shortcuts(shortcuts: _webShortcuts, child: child); + } + return Semantics(role: SemanticsRole.menuItem, child: child); + } +} + +class _DropdownMenu<T> extends StatefulWidget { + const _DropdownMenu({ + super.key, + this.padding, + required this.route, + required this.buttonRect, + required this.constraints, + this.dropdownColor, + required this.enableFeedback, + this.borderRadius, + required this.scrollController, + this.menuWidth, + this.mouseCursor, + }); + + final _DropdownRoute<T> route; + final EdgeInsets? padding; + final Rect buttonRect; + final BoxConstraints constraints; + final Color? dropdownColor; + final bool enableFeedback; + final BorderRadius? borderRadius; + final ScrollController scrollController; + final double? menuWidth; + final MouseCursor? mouseCursor; + + @override + _DropdownMenuState<T> createState() => _DropdownMenuState<T>(); +} + +class _DropdownMenuState<T> extends State<_DropdownMenu<T>> { + late final CurvedAnimation _fadeOpacity; + late final CurvedAnimation _resize; + + @override + void initState() { + super.initState(); + // We need to hold these animations as state because of their curve + // direction. When the route's animation reverses, if we were to recreate + // the CurvedAnimation objects in build, we'd lose + // CurvedAnimation._curveDirection. + _fadeOpacity = CurvedAnimation( + parent: widget.route.animation!, + curve: const Interval(0.0, 0.25), + reverseCurve: const Interval(0.75, 1.0), + ); + _resize = CurvedAnimation( + parent: widget.route.animation!, + curve: const Interval(0.25, 0.5), + reverseCurve: const Threshold(0.0), + ); + } + + @override + void dispose() { + _fadeOpacity.dispose(); + _resize.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // The menu is shown in three stages (unit timing in brackets): + // [0s - 0.25s] - Fade in a rect-sized menu container with the selected item. + // [0.25s - 0.5s] - Grow the otherwise empty menu container from the center + // until it's big enough for as many items as we're going to show. + // [0.5s - 1.0s] Fade in the remaining visible items from top to bottom. + // + // When the menu is dismissed we just fade the entire thing out + // in the first 0.25s. + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final _DropdownRoute<T> route = widget.route; + final children = <Widget>[ + for (int itemIndex = 0; itemIndex < route.items.length; ++itemIndex) + _DropdownMenuItemButton<T>( + route: widget.route, + padding: widget.padding, + buttonRect: widget.buttonRect, + constraints: widget.constraints, + itemIndex: itemIndex, + enableFeedback: widget.enableFeedback, + scrollController: widget.scrollController, + mouseCursor: widget.mouseCursor, + ), + ]; + + return FadeTransition( + opacity: _fadeOpacity, + child: CustomPaint( + painter: _DropdownMenuPainter( + color: widget.dropdownColor ?? Theme.of(context).canvasColor, + elevation: route.elevation, + selectedIndex: route.selectedIndex, + resize: _resize, + borderRadius: widget.borderRadius, + // This offset is passed as a callback, not a value, because it must + // be retrieved at paint time (after layout), not at build time. + getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex), + ), + child: Semantics( + role: SemanticsRole.menu, + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: localizations.popupMenuLabel, + child: ClipRRect( + borderRadius: widget.borderRadius ?? BorderRadius.zero, + clipBehavior: widget.borderRadius != null ? Clip.antiAlias : Clip.none, + child: Material( + type: MaterialType.transparency, + textStyle: route.style, + child: ScrollConfiguration( + // Dropdown menus should never overscroll or display an overscroll indicator. + // Scrollbars are built-in below. + // Platform must use Theme and ScrollPhysics must be Clamping. + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + physics: const ClampingScrollPhysics(), + platform: Theme.of(context).platform, + ), + child: PrimaryScrollController( + controller: widget.scrollController, + child: Scrollbar( + thumbVisibility: true, + child: ListView( + // Ensure this always inherits the PrimaryScrollController + primary: true, + padding: kMaterialListPadding, + shrinkWrap: true, + children: children, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _DropdownMenuRouteLayout<T> extends SingleChildLayoutDelegate { + _DropdownMenuRouteLayout({ + required this.buttonRect, + required this.route, + required this.textDirection, + this.menuWidth, + }); + + final Rect buttonRect; + final _DropdownRoute<T> route; + final TextDirection? textDirection; + final double? menuWidth; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The maximum height of a simple menu should be one or more rows less than + // the view height. This ensures a tappable area outside of the simple menu + // with which to dismiss the menu. + // -- https://material.io/design/components/menus.html#usage + double maxHeight = math.max(0.0, constraints.maxHeight - 2 * _kMenuItemHeight); + if (route.menuMaxHeight != null && route.menuMaxHeight! <= maxHeight) { + maxHeight = route.menuMaxHeight!; + } + // The width of a menu should be at most the view width. This ensures that + // the menu does not extend past the left and right edges of the screen. + final double width = math.min(constraints.maxWidth, menuWidth ?? buttonRect.width); + return BoxConstraints(minWidth: width, maxWidth: width, maxHeight: maxHeight); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final _MenuLimits menuLimits = route.getMenuLimits( + buttonRect, + size.height, + route.selectedIndex, + ); + + assert(() { + final Rect container = Offset.zero & size; + if (container.intersect(buttonRect) == buttonRect) { + // If the button was entirely on-screen, then verify + // that the menu is also on-screen. + // If the button was a bit off-screen, then, oh well. + assert(menuLimits.top >= 0.0); + assert(menuLimits.top + menuLimits.height <= size.height); + } + return true; + }()); + assert(textDirection != null); + final double left = switch (textDirection!) { + TextDirection.rtl => clampDouble(buttonRect.right, 0.0, size.width) - childSize.width, + TextDirection.ltr => clampDouble(buttonRect.left, 0.0, size.width - childSize.width), + }; + + return Offset(left, menuLimits.top); + } + + @override + bool shouldRelayout(_DropdownMenuRouteLayout<T> oldDelegate) { + return buttonRect != oldDelegate.buttonRect || textDirection != oldDelegate.textDirection; + } +} + +// We box the return value so that the return value can be null. Otherwise, +// canceling the route (which returns null) would get confused with actually +// returning a real null value. +@immutable +class _DropdownRouteResult<T> { + const _DropdownRouteResult(this.result); + + final T? result; + + @override + bool operator ==(Object other) { + return other is _DropdownRouteResult<T> && other.result == result; + } + + @override + int get hashCode => result.hashCode; +} + +class _MenuLimits { + const _MenuLimits(this.top, this.bottom, this.height, this.scrollOffset); + final double top; + final double bottom; + final double height; + final double scrollOffset; +} + +class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> { + _DropdownRoute({ + required this.items, + required this.padding, + required this.buttonRect, + required this.selectedIndex, + this.elevation = 8, + required this.capturedThemes, + required this.style, + this.barrierLabel, + this.itemHeight, + this.menuWidth, + this.dropdownColor, + this.menuMaxHeight, + required this.enableFeedback, + this.borderRadius, + this.barrierDismissible = true, + this.dropdownMenuItemMouseCursor, + }) : itemHeights = List<double>.filled(items.length, itemHeight ?? kMinInteractiveDimension); + + final List<_MenuItem<T>> items; + final EdgeInsetsGeometry padding; + final Rect buttonRect; + final int selectedIndex; + final int elevation; + final CapturedThemes capturedThemes; + final TextStyle style; + final double? itemHeight; + final double? menuWidth; + final Color? dropdownColor; + final double? menuMaxHeight; + final bool enableFeedback; + final BorderRadius? borderRadius; + final MouseCursor? dropdownMenuItemMouseCursor; + + final List<double> itemHeights; + + @override + Duration get transitionDuration => _kDropdownMenuDuration; + + @override + final bool barrierDismissible; + + @override + Color? get barrierColor => null; + + @override + final String? barrierLabel; + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return _DropdownRoutePage<T>( + route: this, + constraints: constraints, + items: items, + padding: padding, + buttonRect: buttonRect, + selectedIndex: selectedIndex, + elevation: elevation, + capturedThemes: capturedThemes, + style: style, + dropdownColor: dropdownColor, + enableFeedback: enableFeedback, + borderRadius: borderRadius, + menuWidth: menuWidth, + mouseCursor: dropdownMenuItemMouseCursor, + ); + }, + ); + } + + void _dismiss() { + if (isActive) { + navigator?.removeRoute(this); + } + } + + double getItemOffset(int index) { + double offset = kMaterialListPadding.top; + if (items.isNotEmpty && index > 0) { + assert(items.length == itemHeights.length); + offset += itemHeights + .sublist(0, index) + .reduce((double total, double height) => total + height); + } + return offset; + } + + // Returns the vertical extent of the menu and the initial scrollOffset + // for the ListView that contains the menu items. The vertical center of the + // selected item is aligned with the button's vertical center, as far as + // that's possible given availableHeight. + _MenuLimits getMenuLimits(Rect buttonRect, double availableHeight, int index) { + double computedMaxHeight = availableHeight - 2.0 * _kMenuItemHeight; + if (menuMaxHeight != null) { + computedMaxHeight = math.min(computedMaxHeight, menuMaxHeight!); + } + final double buttonTop = buttonRect.top; + final double buttonBottom = math.min(buttonRect.bottom, availableHeight); + final double selectedItemOffset = getItemOffset(index); + + // If the button is placed on the bottom or top of the screen, its top or + // bottom may be less than [_kMenuItemHeight] from the edge of the screen. + // In this case, we want to change the menu limits to align with the top + // or bottom edge of the button. + final double topLimit = math.min(_kMenuItemHeight, buttonTop); + final double bottomLimit = math.max(availableHeight - _kMenuItemHeight, buttonBottom); + + double menuTop = + (buttonTop - selectedItemOffset) - (itemHeights[selectedIndex] - buttonRect.height) / 2.0; + double preferredMenuHeight = kMaterialListPadding.vertical; + if (items.isNotEmpty) { + preferredMenuHeight += itemHeights.reduce((double total, double height) => total + height); + } + + // If there are too many elements in the menu, we need to shrink it down + // so it is at most the computedMaxHeight. + final double menuHeight = math.min(computedMaxHeight, preferredMenuHeight); + double menuBottom = menuTop + menuHeight; + + // If the computed top or bottom of the menu are outside of the range + // specified, we need to bring them into range. If the item height is larger + // than the button height and the button is at the very bottom or top of the + // screen, the menu will be aligned with the bottom or top of the button + // respectively. + if (menuTop < topLimit) { + menuTop = math.min(buttonTop, topLimit); + menuBottom = menuTop + menuHeight; + } + + if (menuBottom > bottomLimit) { + menuBottom = math.max(buttonBottom, bottomLimit); + menuTop = menuBottom - menuHeight; + } + + if (menuBottom - itemHeights[selectedIndex] / 2.0 < buttonBottom - buttonRect.height / 2.0) { + menuBottom = buttonBottom - buttonRect.height / 2.0 + itemHeights[selectedIndex] / 2.0; + menuTop = menuBottom - menuHeight; + } + + double scrollOffset = 0; + // If all of the menu items will not fit within availableHeight then + // compute the scroll offset that will line the selected menu item up + // with the select item. This is only done when the menu is first + // shown - subsequently we leave the scroll offset where the user left + // it. This scroll offset is only accurate for fixed height menu items + // (the default). + if (preferredMenuHeight > computedMaxHeight) { + // The offset should be zero if the selected item is in view at the beginning + // of the menu. Otherwise, the scroll offset should center the item if possible. + scrollOffset = math.max(0.0, selectedItemOffset - (buttonTop - menuTop)); + // If the selected item's scroll offset is greater than the maximum scroll offset, + // set it instead to the maximum allowed scroll offset. + scrollOffset = math.min(scrollOffset, preferredMenuHeight - menuHeight); + } + + assert((menuBottom - menuTop - menuHeight).abs() < precisionErrorTolerance); + return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset); + } +} + +class _DropdownRoutePage<T> extends StatefulWidget { + const _DropdownRoutePage({ + super.key, + required this.route, + required this.constraints, + this.items, + required this.padding, + required this.buttonRect, + required this.selectedIndex, + this.elevation = 8, + required this.capturedThemes, + this.style, + required this.dropdownColor, + required this.enableFeedback, + this.borderRadius, + this.menuWidth, + this.mouseCursor, + }); + + final _DropdownRoute<T> route; + final BoxConstraints constraints; + final List<_MenuItem<T>>? items; + final EdgeInsetsGeometry padding; + final Rect buttonRect; + final int selectedIndex; + final int elevation; + final CapturedThemes capturedThemes; + final TextStyle? style; + final Color? dropdownColor; + final bool enableFeedback; + final BorderRadius? borderRadius; + final double? menuWidth; + final MouseCursor? mouseCursor; + + @override + State<_DropdownRoutePage<T>> createState() => _DropdownRoutePageState<T>(); +} + +class _DropdownRoutePageState<T> extends State<_DropdownRoutePage<T>> { + late ScrollController _scrollController; + + @override + void initState() { + super.initState(); + + // Computing the initialScrollOffset now, before the items have been laid + // out. This only works if the item heights are effectively fixed, i.e. either + // DropdownButton.itemHeight is specified or DropdownButton.itemHeight is null + // and all of the items' intrinsic heights are less than kMinInteractiveDimension. + // Otherwise the initialScrollOffset is just a rough approximation based on + // treating the items as if their heights were all equal to kMinInteractiveDimension. + final _MenuLimits menuLimits = widget.route.getMenuLimits( + widget.buttonRect, + widget.constraints.maxHeight, + widget.selectedIndex, + ); + _scrollController = ScrollController(initialScrollOffset: menuLimits.scrollOffset); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + + final TextDirection? textDirection = Directionality.maybeOf(context); + final Widget menu = _DropdownMenu<T>( + route: widget.route, + padding: widget.padding.resolve(textDirection), + buttonRect: widget.buttonRect, + constraints: widget.constraints, + dropdownColor: widget.dropdownColor, + enableFeedback: widget.enableFeedback, + borderRadius: widget.borderRadius, + scrollController: _scrollController, + mouseCursor: widget.mouseCursor, + ); + + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Builder( + builder: (BuildContext context) { + return CustomSingleChildLayout( + delegate: _DropdownMenuRouteLayout<T>( + buttonRect: widget.buttonRect, + route: widget.route, + textDirection: textDirection, + menuWidth: widget.menuWidth, + ), + child: widget.capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } +} + +// This widget enables _DropdownRoute to look up the sizes of +// each menu item. These sizes are used to compute the offset of the selected +// item so that _DropdownRoutePage can align the vertical center of the +// selected item lines up with the vertical center of the dropdown button, +// as closely as possible. +class _MenuItem<T> extends SingleChildRenderObjectWidget { + const _MenuItem({super.key, required this.onLayout, required this.item}) : super(child: item); + + final ValueChanged<Size> onLayout; + final DropdownMenuItem<T>? item; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderMenuItem(onLayout); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderMenuItem renderObject) { + renderObject.onLayout = onLayout; + } +} + +class _RenderMenuItem extends RenderProxyBox { + _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); + + ValueChanged<Size> onLayout; + + @override + void performLayout() { + super.performLayout(); + onLayout(size); + } +} + +// The container widget for a menu item created by a [DropdownButton]. It +// provides the default configuration for [DropdownMenuItem]s, as well as a +// [DropdownButton]'s hint and disabledHint widgets. +class _DropdownMenuItemContainer extends StatelessWidget { + /// Creates an item for a dropdown menu. + /// + /// The [child] argument is required. + const _DropdownMenuItemContainer({ + super.key, + this.alignment = AlignmentDirectional.centerStart, + required this.child, + }); + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + /// Defines how the item is positioned within the container. + /// + /// Defaults to [AlignmentDirectional.centerStart]. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kMenuItemHeight), + child: Align(alignment: alignment, child: child), + ), + ); + } +} + +/// An item in a menu created by a [DropdownButton]. +/// +/// The type `T` is the type of the value the entry represents. All the entries +/// in a given menu must represent values with consistent types. +class DropdownMenuItem<T> extends _DropdownMenuItemContainer { + /// Creates an item for a dropdown menu. + /// + /// The [child] argument is required. + const DropdownMenuItem({ + super.key, + this.onTap, + this.value, + this.enabled = true, + super.alignment, + required super.child, + }); + + /// Called when the dropdown menu item is tapped. + final VoidCallback? onTap; + + /// The value to return if the user selects this menu item. + /// + /// Eventually returned in a call to [DropdownButton.onChanged]. + final T? value; + + /// Whether or not a user can select this menu item. + /// + /// Defaults to `true`. + final bool enabled; +} + +/// An inherited widget that causes any descendant [DropdownButton] +/// widgets to not include their regular underline. +/// +/// This is used by [DataTable] to remove the underline from any +/// [DropdownButton] widgets placed within material data tables, as +/// required by the Material Design specification. +class DropdownButtonHideUnderline extends InheritedWidget { + /// Creates a [DropdownButtonHideUnderline]. A non-null [child] must + /// be given. + const DropdownButtonHideUnderline({super.key, required super.child}); + + /// Returns whether the underline of [DropdownButton] widgets should + /// be hidden. + static bool at(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<DropdownButtonHideUnderline>() != null; + } + + @override + bool updateShouldNotify(DropdownButtonHideUnderline oldWidget) => false; +} + +/// A Material Design button for selecting from a list of items. +/// +/// A dropdown button lets the user select from a number of items. The button +/// shows the currently selected item as well as an arrow that opens a menu for +/// selecting another item. +/// +/// ## Updating to [DropdownMenu] +/// +/// There is a Material 3 version of this component, +/// [DropdownMenu] that is preferred for applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// The [DropdownMenu] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// <https://m3.material.io/components/menus/guidelines> for +/// more details. +/// +/// The [DropdownMenu] widget's API is also slightly different. +/// To update from [DropdownButton] to [DropdownMenu], you will +/// need to make the following changes: +/// +/// 1. Instead of using [DropdownButton.items], which +/// takes a list of [DropdownMenuItem]s, use +/// [DropdownMenu.dropdownMenuEntries], which +/// takes a list of [DropdownMenuEntry]'s. +/// +/// 2. Instead of using [DropdownButton.onChanged], +/// use [DropdownMenu.onSelected], which is also +/// a callback that is called when the user selects an entry. +/// +/// 3. In [DropdownMenu] it is not required to track +/// the current selection in your app's state. +/// So, instead of tracking the current selection in +/// the [DropdownButton.value] property, you can set the +/// [DropdownMenu.initialSelection] property to the +/// item that should be selected before there is any user action. +/// +/// 4. You may also need to make changes to the styling of the +/// [DropdownMenu], see the properties in the [DropdownMenu] +/// constructor for more details. +/// +/// See the sample below for an example of migrating +/// from [DropdownButton] to [DropdownMenu]. +/// +/// ## Using [DropdownButton] +/// {@youtube 560 315 https://www.youtube.com/watch?v=ZzQ_PWrFihg} +/// +/// One ancestor must be a [Material] widget and typically this is +/// provided by the app's [Scaffold]. +/// +/// The type `T` is the type of the [value] that each dropdown item represents. +/// All the entries in a given menu must represent values with consistent types. +/// Typically, an enum is used. Each [DropdownMenuItem] in [items] must be +/// specialized with that same type argument. +/// +/// The [onChanged] callback should update a state variable that defines the +/// dropdown's value. It should also call [State.setState] to rebuild the +/// dropdown with the new value. +/// +/// +/// {@tool dartpad} +/// This sample shows a [DropdownButton] with a large arrow icon, +/// purple text style, and bold purple underline, whose value is one of "One", +/// "Two", "Three", or "Four". +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/dropdown_button.png) +/// +/// ** See code in examples/api/lib/material/dropdown/dropdown_button.0.dart ** +/// {@end-tool} +/// +/// If the [onChanged] callback is null or the list of [items] is null +/// then the dropdown button will be disabled, i.e. its arrow will be +/// displayed in grey and it will not respond to input. A disabled button +/// will display the [disabledHint] widget if it is non-null. However, if +/// [disabledHint] is null and [hint] is non-null, the [hint] widget will +/// instead be displayed. +/// +/// {@tool dartpad} +/// This sample shows how you would rewrite the above [DropdownButton] +/// to use the [DropdownMenu]. +/// +/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.1.dart ** +/// {@end-tool} +/// +/// +/// See also: +/// +/// * [DropdownButtonFormField], which integrates with the [Form] widget. +/// * [DropdownMenuItem], the class used to represent the [items]. +/// * [DropdownButtonHideUnderline], which prevents its descendant dropdown buttons +/// from displaying their underlines. +/// * [ElevatedButton], [TextButton], ordinary buttons that trigger a single action. +/// * <https://material.io/design/components/menus.html#dropdown-menu> +class DropdownButton<T> extends StatefulWidget { + /// Creates a dropdown button. + /// + /// The [items] must have distinct values. If [value] isn't null then it + /// must be equal to one of the [DropdownMenuItem] values. If [items] or + /// [onChanged] is null, the button will be disabled, the down arrow + /// will be greyed out. + /// + /// If [value] is null and the button is enabled, [hint] will be displayed + /// if it is non-null. + /// + /// If [value] is null and the button is disabled, [disabledHint] will be displayed + /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed + /// if it is non-null. + /// + /// The [dropdownColor] argument specifies the background color of the + /// dropdown when it is open. If it is null, the current theme's + /// [ThemeData.canvasColor] will be used instead. + DropdownButton({ + super.key, + required this.items, + this.selectedItemBuilder, + this.value, + this.hint, + this.disabledHint, + required this.onChanged, + this.onTap, + this.elevation = 8, + this.style, + this.underline, + this.icon, + this.iconDisabledColor, + this.iconEnabledColor, + this.iconSize = 24.0, + this.isDense = false, + this.isExpanded = false, + this.itemHeight = kMinInteractiveDimension, + this.menuWidth, + this.focusColor, + this.focusNode, + this.autofocus = false, + this.dropdownColor, + this.menuMaxHeight, + this.enableFeedback, + this.alignment = AlignmentDirectional.centerStart, + this.borderRadius, + this.padding, + this.barrierDismissible = true, + this.mouseCursor, + this.dropdownMenuItemMouseCursor, + // When adding new arguments, consider adding similar arguments to + // DropdownButtonFormField. + }) : assert( + items == null || + items.isEmpty || + value == null || + items.where((DropdownMenuItem<T> item) { + return item.value == value; + }).length == + 1, + "There should be exactly one item with [DropdownButton]'s value: " + '$value. \n' + 'Either zero or 2 or more [DropdownMenuItem]s were detected ' + 'with the same value', + ), + assert(itemHeight == null || itemHeight >= kMinInteractiveDimension), + _inputDecoration = null, + _isEmpty = false; + + DropdownButton._formField({ + super.key, + required this.items, + this.selectedItemBuilder, + this.value, + this.hint, + this.disabledHint, + required this.onChanged, + this.onTap, + this.elevation = 8, + this.style, + this.underline, + this.icon, + this.iconDisabledColor, + this.iconEnabledColor, + this.iconSize = 24.0, + this.isDense = false, + this.isExpanded = false, + this.itemHeight = kMinInteractiveDimension, + this.menuWidth, + this.focusColor, + this.focusNode, + this.autofocus = false, + this.dropdownColor, + this.menuMaxHeight, + this.enableFeedback, + this.alignment = AlignmentDirectional.centerStart, + this.borderRadius, + this.padding, + this.barrierDismissible = true, + this.mouseCursor, + this.dropdownMenuItemMouseCursor, + required InputDecoration inputDecoration, + required bool isEmpty, + }) : assert( + items == null || + items.isEmpty || + value == null || + items.where((DropdownMenuItem<T> item) { + return item.value == value; + }).length == + 1, + "There should be exactly one item with [DropdownButtonFormField]'s value: " + '$value. \n' + 'Either zero or 2 or more [DropdownMenuItem]s were detected ' + 'with the same value', + ), + assert(itemHeight == null || itemHeight >= kMinInteractiveDimension), + _inputDecoration = inputDecoration, + _isEmpty = isEmpty; + + /// The list of items the user can select. + /// + /// If the [onChanged] callback is null or the list of items is null + /// then the dropdown button will be disabled, i.e. its arrow will be + /// displayed in grey and it will not respond to input. + final List<DropdownMenuItem<T>>? items; + + /// The value of the currently selected [DropdownMenuItem]. + /// + /// If [value] is null and the button is enabled, [hint] will be displayed + /// if it is non-null. + /// + /// If [value] is null and the button is disabled, [disabledHint] will be displayed + /// if it is non-null. If [disabledHint] is null, then [hint] will be displayed + /// if it is non-null. + final T? value; + + /// A placeholder widget that is displayed by the dropdown button. + /// + /// If [value] is null and the dropdown is enabled ([items] and [onChanged] are non-null), + /// this widget is displayed as a placeholder for the dropdown button's value. + /// + /// If [value] is null and the dropdown is disabled and [disabledHint] is null, + /// this widget is used as the placeholder. + final Widget? hint; + + /// A preferred placeholder widget that is displayed when the dropdown is disabled. + /// + /// If [value] is null, the dropdown is disabled ([items] or [onChanged] is null), + /// this widget is displayed as a placeholder for the dropdown button's value. + final Widget? disabledHint; + + /// {@template flutter.material.dropdownButton.onChanged} + /// Called when the user selects an item. + /// + /// If the [onChanged] callback is null or the list of [DropdownButton.items] + /// is null then the dropdown button will be disabled, i.e. its arrow will be + /// displayed in grey and it will not respond to input. A disabled button + /// will display the [DropdownButton.disabledHint] widget if it is non-null. + /// If [DropdownButton.disabledHint] is also null but [DropdownButton.hint] is + /// non-null, [DropdownButton.hint] will instead be displayed. + /// {@endtemplate} + final ValueChanged<T?>? onChanged; + + /// Called when the dropdown button is tapped. + /// + /// This is distinct from [onChanged], which is called when the user + /// selects an item from the dropdown. + /// + /// The callback will not be invoked if the dropdown button is disabled. + final VoidCallback? onTap; + + /// A builder to customize the dropdown buttons corresponding to the + /// [DropdownMenuItem]s in [items]. + /// + /// When a [DropdownMenuItem] is selected, the widget that will be displayed + /// from the list corresponds to the [DropdownMenuItem] of the same index + /// in [items]. + /// + /// {@tool dartpad} + /// This sample shows a `DropdownButton` with a button with [Text] that + /// corresponds to but is unique from [DropdownMenuItem]. + /// + /// ** See code in examples/api/lib/material/dropdown/dropdown_button.selected_item_builder.0.dart ** + /// {@end-tool} + /// + /// If this callback is null, the [DropdownMenuItem] from [items] + /// that matches [value] will be displayed. + /// + /// The list of widgets returned by this builder must be exactly the same length + /// as the [items] list. + final DropdownButtonBuilder? selectedItemBuilder; + + /// The z-coordinate at which to place the menu when open. + /// + /// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, + /// 16, and 24. See [kElevationToShadow]. + /// + /// Defaults to 8, the appropriate elevation for dropdown buttons. + final int elevation; + + /// The text style to use for text in the dropdown button and the dropdown + /// menu that appears when you tap the button. + /// + /// To use a separate text style for selected item when it's displayed within + /// the dropdown button, consider using [selectedItemBuilder]. + /// + /// {@tool dartpad} + /// This sample shows a `DropdownButton` with a dropdown button text style + /// that is different than its menu items. + /// + /// ** See code in examples/api/lib/material/dropdown/dropdown_button.style.0.dart ** + /// {@end-tool} + /// + /// Defaults to the [TextTheme.titleMedium] value of the current + /// [ThemeData.textTheme] of the current [Theme]. + final TextStyle? style; + + /// The widget to use for drawing the drop-down button's underline. + /// + /// Defaults to a 0.0 width bottom border with color 0xFFBDBDBD. + final Widget? underline; + + /// The widget to use for the drop-down button's icon. + /// + /// Defaults to an [Icon] with the [Icons.arrow_drop_down] glyph. + final Widget? icon; + + /// The color of any [Icon] descendant of [icon] if this button is disabled, + /// i.e. if [onChanged] is null. + /// + /// Defaults to [MaterialColor.shade400] of [Colors.grey] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white10] when it is [Brightness.dark] + final Color? iconDisabledColor; + + /// The color of any [Icon] descendant of [icon] if this button is enabled, + /// i.e. if [onChanged] is defined. + /// + /// Defaults to [MaterialColor.shade700] of [Colors.grey] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white70] when it is [Brightness.dark] + final Color? iconEnabledColor; + + /// The size to use for the drop-down button's down arrow icon button. + /// + /// Defaults to 24.0. + final double iconSize; + + /// Reduce the button's height. + /// + /// By default this button's height is the same as its menu items' heights. + /// If isDense is true, the button's height is reduced by about half. This + /// can be useful when the button is embedded in a container that adds + /// its own decorations, like [InputDecorator]. + final bool isDense; + + /// Set the dropdown's inner contents to horizontally fill its parent. + /// + /// By default this button's inner width is the minimum size of its contents. + /// If [isExpanded] is true, the inner width is expanded to fill its + /// surrounding container. + final bool isExpanded; + + /// If null, then the menu item heights will vary according to each menu item's + /// intrinsic height. + /// + /// The default value is [kMinInteractiveDimension], which is also the minimum + /// height for menu items. + /// + /// If this value is null and there isn't enough vertical room for the menu, + /// then the menu's initial scroll offset may not align the selected item with + /// the dropdown button. That's because, in this case, the initial scroll + /// offset is computed as if all of the menu item heights were + /// [kMinInteractiveDimension]. + final double? itemHeight; + + /// The width of the menu. + /// + /// If it is not provided, the width of the menu is the width of the + /// dropdown button. + final double? menuWidth; + + /// The color for the button's [Material] when it has the input focus. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The background color of the dropdown. + /// + /// If it is not provided, the theme's [ThemeData.canvasColor] will be used + /// instead. + final Color? dropdownColor; + + /// Padding around the visible portion of the dropdown widget. + /// + /// As the padding increases, the size of the [DropdownButton] will also + /// increase. The padding is included in the clickable area of the dropdown + /// widget, so this can make the widget easier to click. + /// + /// Padding can be useful when used with a custom border. The clickable + /// area will stay flush with the border, as opposed to an external [Padding] + /// widget which will leave a non-clickable gap. + final EdgeInsetsGeometry? padding; + + /// The maximum height of the menu. + /// + /// The maximum height of the menu must be at least one row shorter than + /// the height of the app's view. This ensures that a tappable area + /// outside of the simple menu is present so the user can dismiss the menu. + /// + /// If this property is set above the maximum allowable height threshold + /// mentioned above, then the menu defaults to being padded at the top + /// and bottom of the menu by at one menu item's height. + final double? menuMaxHeight; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// By default, platform-specific feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// Defines how the hint or the selected item is positioned within the button. + /// + /// Defaults to [AlignmentDirectional.centerStart]. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry alignment; + + /// Defines the corner radii of the menu's rounded rectangle shape. + final BorderRadius? borderRadius; + + /// Determines whether tapping outside the dropdown will close it. + /// + /// Defaults to `true`. + final bool barrierDismissible; + + /// The cursor for a mouse pointer when it enters or is hovering over this + /// button. + /// + /// {@macro flutter.material.InkWell.mouseCursor} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? mouseCursor; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// this button's [items]. + /// + /// {@macro flutter.material.InkWell.mouseCursor} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? dropdownMenuItemMouseCursor; + + final InputDecoration? _inputDecoration; + + final bool _isEmpty; + + @override + State<DropdownButton<T>> createState() => _DropdownButtonState<T>(); +} + +class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindingObserver { + int? _selectedIndex; + _DropdownRoute<T>? _dropdownRoute; + Orientation? _lastOrientation; + FocusNode? _internalNode; + FocusNode get focusNode => widget.focusNode ?? _internalNode!; + late Map<Type, Action<Intent>> _actionMap; + bool _isHovering = false; + bool _hasPrimaryFocus = false; + bool _isMenuExpanded = false; + + // Only used if needed to create _internalNode. + FocusNode _createFocusNode() { + return FocusNode(debugLabel: '${widget.runtimeType}'); + } + + @override + void initState() { + super.initState(); + _updateSelectedIndex(); + if (widget.focusNode == null) { + _internalNode ??= _createFocusNode(); + } + _actionMap = <Type, Action<Intent>>{ + ActivateIntent: CallbackAction<ActivateIntent>( + onInvoke: (ActivateIntent intent) => _handleTap(), + ), + ButtonActivateIntent: CallbackAction<ButtonActivateIntent>( + onInvoke: (ButtonActivateIntent intent) => _handleTap(), + ), + }; + focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _removeDropdownRoute(); + focusNode.removeListener(_handleFocusChanged); + _internalNode?.dispose(); + super.dispose(); + } + + void _handleFocusChanged() { + if (_hasPrimaryFocus != focusNode.hasPrimaryFocus) { + setState(() { + _hasPrimaryFocus = focusNode.hasPrimaryFocus; + }); + } + } + + void _removeDropdownRoute() { + _dropdownRoute?._dismiss(); + _dropdownRoute = null; + _lastOrientation = null; + } + + @override + void didUpdateWidget(DropdownButton<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode?.removeListener(_handleFocusChanged); + if (_internalNode != null && widget.focusNode != null) { + _internalNode!.dispose(); + _internalNode = null; + } + + if (widget.focusNode == null) { + _internalNode ??= _createFocusNode(); + } + _hasPrimaryFocus = focusNode.hasPrimaryFocus; + focusNode.addListener(_handleFocusChanged); + } + _updateSelectedIndex(); + } + + void _updateSelectedIndex() { + if (widget.items == null || + widget.items!.isEmpty || + (widget.value == null && + widget.items! + .where((DropdownMenuItem<T> item) => item.enabled && item.value == widget.value) + .isEmpty)) { + _selectedIndex = null; + return; + } + + assert( + widget.items!.where((DropdownMenuItem<T> item) => item.value == widget.value).length == 1, + ); + for (var itemIndex = 0; itemIndex < widget.items!.length; itemIndex++) { + if (widget.items![itemIndex].value == widget.value) { + _selectedIndex = itemIndex; + return; + } + } + } + + TextStyle? get _textStyle => widget.style ?? Theme.of(context).textTheme.titleMedium; + + void _handleTap() { + final TextDirection? textDirection = Directionality.maybeOf(context); + final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown + ? _kAlignedMenuMargin + : _kUnalignedMenuMargin; + + final menuItems = <_MenuItem<T>>[ + for (int index = 0; index < widget.items!.length; index += 1) + _MenuItem<T>( + item: widget.items![index], + onLayout: (Size size) { + // If [_dropdownRoute] is null and onLayout is called, this means + // that performLayout was called on a _DropdownRoute that has not + // left the widget tree but is already on its way out. + // + // Since onLayout is used primarily to collect the desired heights + // of each menu item before laying them out, not having the _DropdownRoute + // collect each item's height to lay out is fine since the route is + // already on its way out. + if (_dropdownRoute == null) { + return; + } + + _dropdownRoute!.itemHeights[index] = size.height; + }, + ), + ]; + + final NavigatorState navigator = Navigator.of(context); + assert(_dropdownRoute == null); + final itemBox = context.findRenderObject()! as RenderBox; + final Rect itemRect = + itemBox.localToGlobal(Offset.zero, ancestor: navigator.context.findRenderObject()) & + itemBox.size; + _dropdownRoute = _DropdownRoute<T>( + items: menuItems, + buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect), + padding: _kMenuItemPadding.resolve(textDirection), + selectedIndex: _selectedIndex ?? 0, + elevation: widget.elevation, + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + style: _textStyle!, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + itemHeight: widget.itemHeight, + menuWidth: widget.menuWidth, + dropdownColor: widget.dropdownColor, + menuMaxHeight: widget.menuMaxHeight, + enableFeedback: widget.enableFeedback ?? true, + borderRadius: widget.borderRadius, + barrierDismissible: widget.barrierDismissible, + dropdownMenuItemMouseCursor: widget.dropdownMenuItemMouseCursor, + ); + + focusNode.requestFocus(); + navigator.push(_dropdownRoute!).then<void>((_DropdownRouteResult<T>? newValue) { + _removeDropdownRoute(); + if (mounted) { + setState(() { + _isMenuExpanded = false; + }); + } + if (!mounted || newValue == null) { + return; + } + widget.onChanged?.call(newValue.result); + }); + + widget.onTap?.call(); + setState(() { + _isMenuExpanded = true; + }); + } + + // When isDense is true, reduce the height of this button from _kMenuItemHeight to + // _kDenseButtonHeight, but don't make it smaller than the text that it contains. + // Similarly, we don't reduce the height of the button so much that its icon + // would be clipped. + double get _denseButtonHeight { + final double fontSize = + _textStyle!.fontSize ?? Theme.of(context).textTheme.titleMedium!.fontSize!; + final double lineHeight = + _textStyle!.height ?? Theme.of(context).textTheme.titleMedium!.height ?? 1.0; + final double scaledFontSize = MediaQuery.textScalerOf(context).scale(fontSize * lineHeight); + return math.max(scaledFontSize, math.max(widget.iconSize, _kDenseButtonHeight)); + } + + Color get _iconColor { + // These colors are not defined in the Material Design spec. + final Brightness brightness = Theme.brightnessOf(context); + if (_enabled) { + return widget.iconEnabledColor ?? + switch (brightness) { + Brightness.light => Colors.grey.shade700, + Brightness.dark => Colors.white70, + }; + } else { + return widget.iconDisabledColor ?? + switch (brightness) { + Brightness.light => Colors.grey.shade400, + Brightness.dark => Colors.white10, + }; + } + } + + bool get _enabled => widget.items != null && widget.items!.isNotEmpty && widget.onChanged != null; + + Orientation _getOrientation(BuildContext context) { + Orientation? result = MediaQuery.maybeOrientationOf(context); + if (result == null) { + // If there's no MediaQuery, then use the view aspect to determine + // orientation. + final Size size = View.of(context).physicalSize; + result = size.width > size.height ? Orientation.landscape : Orientation.portrait; + } + return result; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + final Orientation newOrientation = _getOrientation(context); + _lastOrientation ??= newOrientation; + if (newOrientation != _lastOrientation) { + _removeDropdownRoute(); + _lastOrientation = newOrientation; + } + + // The width of the button and the menu are defined by the widest + // item and the width of the hint. + // We should explicitly type the items list to be a list of <Widget>, + // otherwise, no explicit type adding items maybe trigger a crash/failure + // when hint and selectedItemBuilder are provided. + final List<Widget> items; + if (widget.selectedItemBuilder != null) { + final List<Widget> selectedItems = widget.selectedItemBuilder!(context); + assert( + widget.items == null || selectedItems.length == widget.items!.length, + 'The selectedItemBuilder must return a list of widgets with the same length as the items list.\n' + 'Currently, selectedItemBuilder returns a list of length ${selectedItems.length}, ' + 'but items has length ${widget.items!.length}.', + ); + items = List<Widget>.of(selectedItems); + } else { + items = widget.items != null ? List<Widget>.of(widget.items!) : <Widget>[]; + } + + int? hintIndex; + if (widget.hint != null || (!_enabled && widget.disabledHint != null)) { + final Widget displayedHint = _enabled ? widget.hint! : widget.disabledHint ?? widget.hint!; + + hintIndex = items.length; + items.add( + DefaultTextStyle( + style: _textStyle!.copyWith(color: Theme.of(context).hintColor), + child: IgnorePointer( + child: _DropdownMenuItemContainer(alignment: widget.alignment, child: displayedHint), + ), + ), + ); + } + + final EdgeInsetsGeometry padding = + ButtonTheme.of(context).alignedDropdown && widget._inputDecoration == null + ? _kAlignedButtonPadding + : _kUnalignedButtonPadding; + + // If value is null (then _selectedIndex is null) then we + // display the hint or nothing at all. + final Widget innerItemsWidget; + if (items.isEmpty) { + innerItemsWidget = const SizedBox.shrink(); + } else { + innerItemsWidget = IndexedStack( + index: _selectedIndex ?? hintIndex, + alignment: widget.alignment, + children: widget.isDense + ? items + : items.map((Widget item) { + return widget.itemHeight != null + ? SizedBox(height: widget.itemHeight, child: item) + : Column(mainAxisSize: MainAxisSize.min, children: <Widget>[item]); + }).toList(), + ); + } + + const defaultIcon = Icon(Icons.arrow_drop_down); + final Widget effectiveSuffixIcon = IconTheme( + data: IconThemeData(color: _iconColor, size: widget.iconSize), + child: widget.icon ?? widget._inputDecoration?.suffixIcon ?? defaultIcon, + ); + + Widget result = DefaultTextStyle( + style: _enabled ? _textStyle! : _textStyle!.copyWith(color: Theme.of(context).disabledColor), + child: SizedBox( + height: widget.isDense ? _denseButtonHeight : null, + child: Padding( + padding: padding.resolve(Directionality.of(context)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + if (widget.isExpanded) Expanded(child: innerItemsWidget) else innerItemsWidget, + if (widget._inputDecoration == null) effectiveSuffixIcon, + ], + ), + ), + ), + ); + + if (!DropdownButtonHideUnderline.at(context)) { + final bottom = (widget.isDense || widget.itemHeight == null) ? 0.0 : 8.0; + result = Stack( + children: <Widget>[ + result, + Positioned( + left: 0.0, + right: 0.0, + bottom: bottom, + child: + widget.underline ?? + Container( + height: 1.0, + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFFBDBDBD), width: 0.0)), + ), + ), + ), + ], + ); + } + + final MouseCursor effectiveMouseCursor = WidgetStateProperty.resolveAs<MouseCursor>( + widget.mouseCursor ?? WidgetStateMouseCursor.adaptiveClickable, + <WidgetState>{if (!_enabled) WidgetState.disabled}, + ); + + // When an InputDecoration is provided, use it instead of using an InkWell + // that overflows in some cases (such as showing an errorText) and requires + // additional logic to manage clipping properly. + // A filled InputDecoration is able to fill the InputDecorator container + // without overflowing. It also supports blending the hovered color. + // According to the Material specification, the overlay colors should be + // visible only for filled dropdown button, see: + // https://m2.material.io/components/menus#dropdown-menu + if (widget._inputDecoration != null) { + final bool filled = + widget._inputDecoration?.filled ?? InputDecorationTheme.of(context).filled; + final bool oulined = + widget._inputDecoration?.border?.isOutline ?? + InputDecorationTheme.of(context).border?.isOutline ?? + false; + + final suffixIconEndMargin = (filled || oulined) ? 12.0 : 0.0; + InputDecoration effectiveDecoration = widget._inputDecoration!.copyWith( + // Override the suffix icon constraints to allow the + // icon alignment to match the regular dropdown button. + suffixIconConstraints: BoxConstraints( + minWidth: widget.iconSize + suffixIconEndMargin, + minHeight: widget.iconSize, + ), + // suffixIconGap: 0.0, + suffixIcon: Padding( + padding: EdgeInsetsGeometry.directional(end: suffixIconEndMargin), + child: effectiveSuffixIcon, + ), + ); + if (_hasPrimaryFocus) { + final Color? focusColor = widget.focusColor ?? effectiveDecoration.focusColor; + // For compatibility, override the fill color when focusColor is set. + if (focusColor != null) { + effectiveDecoration = effectiveDecoration.copyWith(fillColor: focusColor); + } + } + result = Focus( + canRequestFocus: _enabled, + focusNode: focusNode, + autofocus: widget.autofocus, + child: MouseRegion( + onEnter: (PointerEnterEvent event) { + if (!_isHovering) { + setState(() { + _isHovering = true; + }); + } + }, + onExit: (PointerExitEvent event) { + if (_isHovering) { + setState(() { + _isHovering = false; + }); + } + }, + cursor: effectiveMouseCursor, + child: GestureDetector( + onTap: _enabled ? _handleTap : null, + behavior: HitTestBehavior.opaque, + child: InputDecorator( + decoration: effectiveDecoration, + isEmpty: widget._isEmpty, + isFocused: _hasPrimaryFocus, + isHovering: _isHovering, + child: widget.padding == null + ? result + : Padding(padding: widget.padding!, child: result), + ), + ), + ), + ); + } else { + result = InkWell( + mouseCursor: effectiveMouseCursor, + onTap: _enabled ? _handleTap : null, + canRequestFocus: _enabled, + borderRadius: widget.borderRadius, + focusNode: focusNode, + autofocus: widget.autofocus, + focusColor: widget.focusColor ?? Theme.of(context).focusColor, + enableFeedback: false, + child: widget.padding == null ? result : Padding(padding: widget.padding!, child: result), + ); + } + + final bool childHasButtonSemantic = + hintIndex != null || (_selectedIndex != null && widget.selectedItemBuilder == null); + return Semantics( + button: !childHasButtonSemantic, + expanded: _isMenuExpanded, + child: Actions(actions: _actionMap, child: result), + ); + } +} + +/// A [FormField] that contains a [DropdownButton]. +/// +/// This is a convenience widget that wraps a [DropdownButton] widget in a +/// [FormField]. +/// +/// A [Form] ancestor is not required. The [Form] allows one to +/// save, reset, or validate multiple fields at once. To use without a [Form], +/// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to +/// save or reset the form field. +/// +/// The `value` parameter maps to [FormField.initialValue]. +/// +/// See also: +/// +/// * [DropdownButton], which is the underlying text field without the [Form] +/// integration. +class DropdownButtonFormField<T> extends FormField<T> { + /// Creates a [DropdownButton] widget that is a [FormField], wrapped in an + /// [InputDecorator]. + /// + /// For a description of the `onSaved`, `validator`, or `autovalidateMode` + /// parameters, see [FormField]. For the rest (other than [decoration]), see + /// [DropdownButton]. + DropdownButtonFormField({ + super.key, + required List<DropdownMenuItem<T>>? items, + DropdownButtonBuilder? selectedItemBuilder, + @Deprecated( + 'Use initialValue instead. ' + 'This will set the initial value for the form field. ' + 'This feature was deprecated after v3.33.0-1.0.pre.', + ) + T? value, + T? initialValue, + Widget? hint, + Widget? disabledHint, + required this.onChanged, + VoidCallback? onTap, + int elevation = 8, + TextStyle? style, + Widget? icon, + Color? iconDisabledColor, + Color? iconEnabledColor, + double iconSize = 24.0, + bool isDense = true, + bool isExpanded = false, + double? itemHeight, + Color? focusColor, + FocusNode? focusNode, + bool autofocus = false, + Color? dropdownColor, + InputDecoration? decoration, + super.onSaved, + super.validator, + super.errorBuilder, + super.forceErrorText, + AutovalidateMode? autovalidateMode, + double? menuMaxHeight, + bool? enableFeedback, + AlignmentGeometry alignment = AlignmentDirectional.centerStart, + BorderRadius? borderRadius, + EdgeInsetsGeometry? padding, + this.barrierDismissible = true, + this.mouseCursor, + this.dropdownMenuItemMouseCursor, + // When adding new arguments, consider adding similar arguments to + // DropdownButton. + }) : assert( + items == null || + items.isEmpty || + (initialValue == null && value == null) || + items + .where((DropdownMenuItem<T> item) => item.value == (initialValue ?? value)) + .length == + 1, + "There should be exactly one item with [DropdownButton]'s value: " + '${initialValue ?? value}. \n' + 'Either zero or 2 or more [DropdownMenuItem]s were detected ' + 'with the same value', + ), + assert(itemHeight == null || itemHeight >= kMinInteractiveDimension), + assert( + errorBuilder == null || decoration?.errorText == null, + 'Declaring both errorBuilder and decoration.errorText is not supported.', + ), + decoration = decoration ?? const InputDecoration(), + super( + initialValue: initialValue ?? value, + autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled, + builder: (FormFieldState<T> field) { + final state = field as _DropdownButtonFormFieldState<T>; + InputDecoration effectiveDecoration = (decoration ?? const InputDecoration()) + .applyDefaults(InputDecorationTheme.of(field.context)); + + final bool showSelectedItem = + items != null && + items.where((DropdownMenuItem<T> item) => item.value == state.value).isNotEmpty; + final bool isDropdownEnabled = onChanged != null && items != null && items.isNotEmpty; + // If decoration hintText is provided, use it as the default value for both hint and disabledHint. + final Widget? decorationHint = effectiveDecoration.hintText != null + ? Text(effectiveDecoration.hintText!) + : null; + final Widget? effectiveHint = hint ?? decorationHint; + final Widget? effectiveDisabledHint = disabledHint ?? effectiveHint; + final bool isHintOrDisabledHintAvailable = isDropdownEnabled + ? effectiveHint != null + : effectiveHint != null || effectiveDisabledHint != null; + final bool isEmpty = !showSelectedItem && !isHintOrDisabledHintAvailable; + + if (field.errorText != null || effectiveDecoration.hintText != null) { + final Widget? error = field.errorText != null && errorBuilder != null + ? errorBuilder(state.context, field.errorText!) + : null; + final String? errorText = error == null ? field.errorText : null; + // Clear the decoration hintText because DropdownButton has its own hint logic. + final String? hintText = effectiveDecoration.hintText != null ? '' : null; + + effectiveDecoration = effectiveDecoration.copyWith( + error: error, + errorText: errorText, + hintText: hintText, + ); + } + + // An unfocusable Focus widget so that this widget can detect if its + // descendants have focus or not. + return Focus( + canRequestFocus: false, + skipTraversal: true, + child: DropdownButtonHideUnderline( + child: DropdownButton<T>._formField( + items: items, + selectedItemBuilder: selectedItemBuilder, + value: state.value, + hint: effectiveHint, + disabledHint: effectiveDisabledHint, + onChanged: onChanged == null ? null : state.didChange, + onTap: onTap, + elevation: elevation, + style: style, + icon: icon, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + iconSize: iconSize, + isDense: isDense, + isExpanded: isExpanded, + itemHeight: itemHeight, + focusColor: focusColor, + focusNode: focusNode, + autofocus: autofocus, + dropdownColor: dropdownColor, + menuMaxHeight: menuMaxHeight, + enableFeedback: enableFeedback, + alignment: alignment, + borderRadius: borderRadius, + inputDecoration: effectiveDecoration, + isEmpty: isEmpty, + padding: padding, + barrierDismissible: barrierDismissible, + mouseCursor: mouseCursor, + dropdownMenuItemMouseCursor: dropdownMenuItemMouseCursor, + ), + ), + ); + }, + ); + + /// {@macro flutter.material.dropdownButton.onChanged} + /// + /// This callback is invoked after the parent [Form]'s [Form.onChanged] callback. + /// The field's updated value is available in the [Form.onChanged] callback + /// via [FormFieldState.value]. + final ValueChanged<T?>? onChanged; + + /// The decoration to show around the dropdown button form field. + /// + /// By default, draws a horizontal line under the dropdown button field but + /// can be configured to show an icon, label, hint text, and error text. + /// + /// If not specified, an [InputDecorator] with the `focusColor` set to the + /// supplied `focusColor` (if any) will be used. + final InputDecoration decoration; + + /// Determines whether tapping outside the dropdown will close it. + /// + /// Defaults to `true`. + final bool barrierDismissible; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// dropdown button and its [DropdownMenuItem]s. + /// + /// {@macro flutter.material.InkWell.mouseCursor} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? mouseCursor; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// this button's [DropdownMenuItem]s. + /// + /// {@macro flutter.material.InkWell.mouseCursor} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? dropdownMenuItemMouseCursor; + + @override + FormFieldState<T> createState() => _DropdownButtonFormFieldState<T>(); +} + +class _DropdownButtonFormFieldState<T> extends FormFieldState<T> { + DropdownButtonFormField<T> get _dropdownButtonFormField => widget as DropdownButtonFormField<T>; + + @override + void didChange(T? value) { + super.didChange(value); + _dropdownButtonFormField.onChanged?.call(value); + } + + @override + void didUpdateWidget(DropdownButtonFormField<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue) { + setValue(widget.initialValue); + } + } + + @override + void reset() { + super.reset(); + _dropdownButtonFormField.onChanged?.call(value); + } +} diff --git a/packages/material_ui/lib/src/dropdown_menu.dart b/packages/material_ui/lib/src/dropdown_menu.dart new file mode 100644 index 000000000000..5804d226a093 --- /dev/null +++ b/packages/material_ui/lib/src/dropdown_menu.dart @@ -0,0 +1,1731 @@ +// 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. + +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'dropdown_menu_theme.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'input_border.dart'; +import 'input_decorator.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'menu_anchor.dart'; +import 'menu_button_theme.dart'; +import 'menu_style.dart'; +import 'text_field.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late BuildContext context; +// late FocusNode myFocusNode; + +/// A callback function that returns the list of the items that matches the +/// current applied filter. +/// +/// Used by [DropdownMenu.filterCallback]. +typedef FilterCallback<T> = + List<DropdownMenuEntry<T>> Function(List<DropdownMenuEntry<T>> entries, String filter); + +/// A callback function that returns the index of the item that matches the +/// current contents of a text field. +/// +/// If a match doesn't exist then null must be returned. +/// +/// Used by [DropdownMenu.searchCallback]. +typedef SearchCallback<T> = int? Function(List<DropdownMenuEntry<T>> entries, String query); + +/// The type of builder function used by [DropdownMenu.decorationBuilder] to +/// build the [InputDecoration] passed to the inner text field. +/// +/// The `context` is the context that the decoration is being built in. +/// +/// The `controller` is the [MenuController] that can be used to open and close +/// the menu with and query the current state. +typedef DropdownMenuDecorationBuilder = + InputDecoration Function(BuildContext context, MenuController controller); + +const double _kMinimumWidth = 112.0; + +const double _kDefaultHorizontalPadding = 12.0; + +const double _kInputStartGap = 4.0; + +/// Defines a [DropdownMenu] menu button that represents one item view in the menu. +/// +/// See also: +/// +/// * [DropdownMenu] +class DropdownMenuEntry<T> { + /// Creates an entry that is used with [DropdownMenu.dropdownMenuEntries]. + const DropdownMenuEntry({ + required this.value, + required this.label, + this.labelWidget, + this.leadingIcon, + this.trailingIcon, + this.enabled = true, + this.style, + }); + + /// the value used to identify the entry. + /// + /// This value must be unique across all entries in a [DropdownMenu]. + final T value; + + /// The label displayed in the center of the menu item. + final String label; + + /// Overrides the default label widget which is `Text(label)`. + /// + /// This widget is only displayed in the open dropdown menu. When an item is + /// selected, the menu closes and the text field displays the plain text of + /// the [label]. + /// + /// The dropdown menu's closed state is a text field or a read-only text field + /// on mobile, which can only display text. + /// While custom widgets like icons or images can be shown in [labelWidget] + /// when the menu is open, the text field will only show the [label] string upon selection. + /// + /// To control the text that appears in the text field for a selected item, + /// set the [label] property to a descriptive string. + /// + /// {@tool dartpad} + /// This sample shows how to override the default label [Text] + /// widget with one that forces the menu entry to appear on one line + /// by specifying [Text.maxLines] and [Text.overflow]. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart ** + /// {@end-tool} + final Widget? labelWidget; + + /// An optional icon to display before the label. + final Widget? leadingIcon; + + /// An optional icon to display after the label. + final Widget? trailingIcon; + + /// Whether the menu item is enabled or disabled. + /// + /// The default value is true. If true, the [DropdownMenuEntry.label] will be filled + /// out in the text field of the [DropdownMenu] when this entry is clicked; otherwise, + /// this entry is disabled. + final bool enabled; + + /// Customizes this menu item's appearance. + /// + /// Null by default. + final ButtonStyle? style; +} + +/// Defines the behavior for closing the dropdown menu when an item is selected. +enum DropdownMenuCloseBehavior { + /// Closes all open menus in the widget tree. + all, + + /// Closes only the current dropdown menu. + self, + + /// Does not close any menus. + none, +} + +/// A dropdown menu that can be opened from a [TextField]. The selected +/// menu item is displayed in that field. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=giV9AbM2gd8} +/// +/// This widget is used to help people make a choice from a menu and put the +/// selected item into the text input field. People can also filter the list based +/// on the text input or search one item in the menu list. +/// +/// The menu is composed of a list of [DropdownMenuEntry]s. People can provide information, +/// such as: label, leading icon or trailing icon for each entry. The [TextField] +/// will be updated based on the selection from the menu entries. The text field +/// will stay empty if the selected entry is disabled. +/// +/// When the dropdown menu has focus, it can be traversed by pressing the up or down key. +/// During the process, the corresponding item will be highlighted and +/// the text field will be updated. Disabled items will be skipped during traversal. +/// +/// The menu can be scrollable if not all items in the list are displayed at once. +/// +/// {@tool dartpad} +/// This sample shows how to display outlined [DropdownMenu] and filled [DropdownMenu]. +/// +/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [MenuAnchor], which is a widget used to mark the "anchor" for a set of submenus. +/// The [DropdownMenu] uses a [TextField] as the "anchor". +/// * [TextField], which is a text input widget that uses an [InputDecoration]. +/// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [DropdownMenu] list. +class DropdownMenu<T> extends StatefulWidget { + /// Creates a const [DropdownMenu]. + /// + /// The leading and trailing icons in the text field can be customized by using + /// [leadingIcon], [trailingIcon] and [selectedTrailingIcon] properties. They are + /// passed down to the [InputDecoration] properties, and will override values + /// in the [InputDecoration.prefixIcon] and [InputDecoration.suffixIcon]. + /// + /// Except leading and trailing icons, the text field can be configured by the + /// [inputDecorationTheme] property. The menu can be configured by the [menuStyle]. + const DropdownMenu({ + super.key, + this.enabled = true, + this.width, + this.menuHeight, + this.leadingIcon, + this.trailingIcon, + this.showTrailingIcon = true, + this.trailingIconFocusNode, + this.label, + this.hintText, + this.helperText, + this.errorText, + this.selectedTrailingIcon, + this.enableFilter = false, + this.enableSearch = true, + this.keyboardType, + this.textStyle, + this.textAlign = TextAlign.start, + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + Object? inputDecorationTheme, + this.decorationBuilder, + this.menuStyle, + this.controller, + this.initialSelection, + this.onSelected, + this.focusNode, + this.requestFocusOnTap, + this.selectOnly = false, + this.expandedInsets, + this.filterCallback, + this.searchCallback, + this.alignmentOffset, + required this.dropdownMenuEntries, + this.inputFormatters, + this.closeBehavior = DropdownMenuCloseBehavior.all, + this.maxLines = 1, + this.textInputAction, + this.cursorHeight, + this.restorationId, + this.menuController, + this.scrollPadding = const EdgeInsets.all(20.0), + }) : assert(filterCallback == null || enableFilter), + assert( + inputDecorationTheme == null || + (inputDecorationTheme is InputDecorationTheme || + inputDecorationTheme is InputDecorationThemeData), + ), + assert(trailingIconFocusNode == null || showTrailingIcon), + assert( + decorationBuilder == null || + (label == null && hintText == null && helperText == null && errorText == null), + ), + _inputDecorationTheme = inputDecorationTheme; + + /// Determine if the [DropdownMenu] is enabled. + /// + /// Defaults to true. + /// + /// {@tool dartpad} + /// This sample demonstrates how the [enabled] and [requestFocusOnTap] properties + /// affect the textfield's hover cursor. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.2.dart ** + /// {@end-tool} + final bool enabled; + + /// Determine the width of the [DropdownMenu]. + /// + /// If this is null, the width of the [DropdownMenu] will be the same as the width of the widest + /// menu item plus the width of the leading/trailing icon. + final double? width; + + /// Determine the height of the menu. + /// + /// If this is null, the menu will display as many items as possible on the screen. + final double? menuHeight; + + /// An optional Icon at the front of the text input field. + /// + /// Defaults to null. If this is not null, the menu items will have extra paddings to be aligned + /// with the text in the text field. + final Widget? leadingIcon; + + /// An optional icon at the end of the text field. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_down]. + /// + /// If [showTrailingIcon] is false, the trailing icon will not be shown. + final Widget? trailingIcon; + + /// Specifies if the [DropdownMenu] should show the [trailingIcon]. + /// + /// If [trailingIcon] is set, [DropdownMenu] will use that trailing icon, + /// otherwise a default trailing icon will be created. + /// + /// If [showTrailingIcon] is false, [trailingIconFocusNode] must be null. + /// + /// If a value is provided for [decorationBuilder] and the resulting [InputDecoration.suffixIcon] + /// is not null, [showTrailingIcon] has no effect. + /// + /// Defaults to true. + final bool showTrailingIcon; + + /// Defines the FocusNode for the trailing icon. + /// + /// If [showTrailingIcon] is false, [trailingIconFocusNode] must be null. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// myFocusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + final FocusNode? trailingIconFocusNode; + + /// Optional widget that describes the input field. + /// + /// When the input field is empty and unfocused, the label is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text may be entered in the input field). When the input field receives + /// focus (or if the field is non-empty), the label moves above, either + /// vertically adjacent to, or to the center of the input field. + /// + /// Defaults to null. + final Widget? label; + + /// Text that suggests what sort of input the field accepts. + /// + /// Defaults to null; + final String? hintText; + + /// Text that provides context about the [DropdownMenu]'s value, such + /// as how the value will be used. + /// + /// If non-null, the text is displayed below the input field, in + /// the same location as [errorText]. If a non-null [errorText] value is + /// specified then the helper text is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.helperText], which is the text that provides context about the [InputDecorator.child]'s value. + final String? helperText; + + /// Text that appears below the input field and the border to show the error message. + /// + /// If non-null, the border's color animates to red and the [helperText] is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.errorText], which is the text that appears below the [InputDecorator.child] and the border. + final String? errorText; + + /// An optional icon at the end of the text field to indicate that the text + /// field is pressed. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_up]. + final Widget? selectedTrailingIcon; + + /// Determine if the menu list can be filtered by the text input. + /// + /// Defaults to false. + final bool enableFilter; + + /// Determine if the first item that matches the text input can be highlighted. + /// + /// Defaults to true as the search function could be commonly used. + final bool enableSearch; + + /// The type of keyboard to use for editing the text. + /// + /// Defaults to [TextInputType.text]. + final TextInputType? keyboardType; + + /// The text style for the [TextField] of the [DropdownMenu]; + /// + /// Defaults to the overall theme's [TextTheme.bodyLarge] + /// if the dropdown menu theme's value is null. + final TextStyle? textStyle; + + /// The text align for the [TextField] of the [DropdownMenu]. + /// + /// Defaults to [TextAlign.start]. + final TextAlign textAlign; + + /// Defines the default appearance of [InputDecoration] to show around the text field. + /// + /// By default, shows a outlined text field. + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + InputDecorationThemeData? get inputDecorationTheme { + if (_inputDecorationTheme == null) { + return null; + } + return _inputDecorationTheme is InputDecorationTheme + ? _inputDecorationTheme.data + : _inputDecorationTheme as InputDecorationThemeData; + } + + final Object? _inputDecorationTheme; + + /// The builder function used to create the [InputDecoration] passed to the text field. + /// + /// If a value is provided for this property and the resulting [InputDecoration.suffixIcon] + /// is null, a default [IconButton] is assigned as the suffix icon. This button's icon will + /// use [trailingIcon] and [selectedTrailingIcon] if those are explicitly defined; otherwise, + /// it defaults to [Icons.arrow_drop_down] for the collapsed state and [Icons.arrow_drop_up] + /// for the expanded state. + /// + /// If null, the default builder creates a decoration where: + /// - [InputDecoration.label] is set to [label]. + /// - [InputDecoration.hintText] is set to [hintText]. + /// - [InputDecoration.helperText] is set to [helperText]. + /// - [InputDecoration.errorText] is set to [errorText]. + /// - [InputDecoration.prefixIcon] is set to [leadingIcon]. + /// - [InputDecoration.suffixIcon] is set to an [IconButton] which uses [trailingIcon] and [selectedTrailingIcon] if defined, or [Icons.arrow_drop_down] and [Icons.arrow_drop_up] otherwise. + final DropdownMenuDecorationBuilder? decorationBuilder; + + /// The [MenuStyle] that defines the visual attributes of the menu. + /// + /// The default width of the menu is set to the width of the text field. + final MenuStyle? menuStyle; + + /// Controls the text being edited or selected in the menu. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// The value used for an initial selection. + /// + /// This property sets the initial value of the dropdown menu when the widget + /// is first created. If the value matches one of the [dropdownMenuEntries], + /// the corresponding label will be displayed in the text field. + /// + /// Setting this to null does not clear the text field. + /// + /// To programmatically clear the text field, use a [TextEditingController] + /// and call [TextEditingController.clear] on it. + /// + /// Defaults to null. + /// + /// See also: + /// + /// * [controller], which is required to programmatically clear or modify + /// the text field content. + final T? initialSelection; + + /// The callback is called when a selection is made. + /// + /// The callback receives the selected entry's value of type `T` when the user + /// chooses an item. It may also be invoked with `null` to indicate that the + /// selection was cleared / that no item was chosen. + /// + /// Defaults to null. If this callback itself is null, the widget still updates + /// the text field with the selected label. + final ValueChanged<T?>? onSelected; + + /// Defines the keyboard focus for this widget. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// myFocusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + /// + /// ## Keyboard + /// + /// Requesting the focus will typically cause the keyboard to be shown + /// if it's not showing already. + /// + /// On Android, the user can hide the keyboard - without changing the focus - + /// with the system back button. They can restore the keyboard's visibility + /// by tapping on a text field. The user might hide the keyboard and + /// switch to a physical keyboard, or they might just need to get it + /// out of the way for a moment, to expose something it's + /// obscuring. In this case requesting the focus again will not + /// cause the focus to change, and will not make the keyboard visible. + /// + /// If this is non-null, the behaviour of [requestFocusOnTap] is overridden + /// by the [FocusNode.canRequestFocus] property. + final FocusNode? focusNode; + + /// Determine if the dropdown menu requests focus and the on-screen virtual + /// keyboard is shown in response to a touch event. + /// + /// Ignored if a [focusNode] is explicitly provided (in which case, + /// [FocusNode.canRequestFocus] controls the behavior). + /// + /// Defaults to null, which enables platform-specific behavior: + /// + /// * On mobile platforms, acts as if set to false; tapping on the text + /// field and opening the menu will not cause a focus request and the + /// virtual keyboard will not appear. + /// + /// * On desktop platforms, acts as if set to true; the dropdown takes the + /// focus when activated. + /// + /// Set this to true or false explicitly to override the default behavior. + /// + /// {@tool dartpad} + /// This sample demonstrates how the [enabled] and [requestFocusOnTap] properties + /// affect the textfield's hover cursor. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.2.dart ** + /// {@end-tool} + final bool? requestFocusOnTap; + + /// Determines if the dropdown menu behaves as a 'select' component. + /// + /// This is useful for mobile platforms where a dropdown menu is commonly used as + /// a 'select' widget (i.e., the user can only select from the list, not edit + /// the text field to search or filter). + /// + /// When true, the inner text field is read-only. + /// + /// If the text field is also focusable (see [requestFocusOnTap]), the following + /// behaviors are also activated: + /// + /// * Pressing Enter when the menu is closed opens it. + /// * The decoration reflects the focus state. + /// + /// Defaults to false. + final bool selectOnly; + + /// Descriptions of the menu items in the [DropdownMenu]. + /// + /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] + /// is provided. If this is an empty list, the menu will be empty and only + /// contain space for padding. + final List<DropdownMenuEntry<T>> dropdownMenuEntries; + + /// Defines the menu text field's width to be equal to its parent's width + /// plus the horizontal width of the specified insets. + /// + /// If this property is null, the width of the text field will be determined + /// by the width of menu items or [DropdownMenu.width]. If this property is not null, + /// the text field's width will match the parent's width plus the specified insets. + /// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same + /// as its parent's width. + /// + /// The [expandedInsets]' top and bottom are ignored, only its left and right + /// properties are used. + /// + /// Defaults to null. + final EdgeInsetsGeometry? expandedInsets; + + /// When [DropdownMenu.enableFilter] is true, this callback is used to + /// compute the list of filtered items. + /// + /// {@tool snippet} + /// + /// In this example the `filterCallback` returns the items that contains the + /// trimmed query. + /// + /// ```dart + /// DropdownMenu<Text>( + /// enableFilter: true, + /// filterCallback: (List<DropdownMenuEntry<Text>> entries, String filter) { + /// final String trimmedFilter = filter.trim().toLowerCase(); + /// if (trimmedFilter.isEmpty) { + /// return entries; + /// } + /// + /// return entries + /// .where((DropdownMenuEntry<Text> entry) => + /// entry.label.toLowerCase().contains(trimmedFilter), + /// ) + /// .toList(); + /// }, + /// dropdownMenuEntries: const <DropdownMenuEntry<Text>>[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this parameter is null and the + /// [DropdownMenu.enableFilter] property is set to true, the default behavior + /// will return a filtered list. The filtered list will contain items + /// that match the text provided by the input field, with a case-insensitive + /// comparison. When this is not null, `enableFilter` must be set to true. + final FilterCallback<T>? filterCallback; + + /// When [DropdownMenu.enableSearch] is true, this callback is used to compute + /// the index of the search result to be highlighted. + /// + /// {@tool snippet} + /// + /// In this example the `searchCallback` returns the index of the search result + /// that exactly matches the query. + /// + /// ```dart + /// DropdownMenu<Text>( + /// searchCallback: (List<DropdownMenuEntry<Text>> entries, String query) { + /// if (query.isEmpty) { + /// return null; + /// } + /// final int index = entries.indexWhere((DropdownMenuEntry<Text> entry) => entry.label == query); + /// + /// return index != -1 ? index : null; + /// }, + /// dropdownMenuEntries: const <DropdownMenuEntry<Text>>[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this is null and [DropdownMenu.enableSearch] is true, + /// the default function will return the index of the first matching result + /// which contains the contents of the text input field. + final SearchCallback<T>? searchCallback; + + /// Optional input validation and formatting overrides. + /// + /// Formatters are run in the provided order when the user changes the text + /// this widget contains. When this parameter changes, the new formatters will + /// not be applied until the next time the user inserts or deletes text. + /// Formatters don't run when the text is changed + /// programmatically via [controller]. + /// + /// See also: + /// + /// * [TextEditingController], which implements the [Listenable] interface + /// and notifies its listeners on [TextEditingValue] changes. + final List<TextInputFormatter>? inputFormatters; + + /// {@macro flutter.material.MenuAnchor.alignmentOffset} + final Offset? alignmentOffset; + + /// Defines the behavior for closing the dropdown menu when an item is selected. + /// + /// The close behavior can be set to: + /// * [DropdownMenuCloseBehavior.all]: Closes all open menus in the widget tree. + /// * [DropdownMenuCloseBehavior.self]: Closes only the current dropdown menu. + /// * [DropdownMenuCloseBehavior.none]: Does not close any menus. + /// + /// This property allows fine-grained control over the menu's closing behavior, + /// which can be useful for creating nested or complex menu structures. + /// + /// Defaults to [DropdownMenuCloseBehavior.all]. + final DropdownMenuCloseBehavior closeBehavior; + + /// Specifies the maximum number of lines the selected value can display + /// in the [DropdownMenu]. + /// + /// If the provided value is 1, then the text will not wrap, but will scroll + /// horizontally instead. Defaults to 1. + /// + /// If this is null, there is no limit to the number of lines, and the text + /// container will start with enough vertical space for one line and + /// automatically grow to accommodate additional lines as they are entered, up + /// to the height of its constraints. + /// + /// If this is not null, the provided value must be greater than zero. The text + /// field will restrict the input to the given number of lines and take up enough + /// horizontal space to accommodate that number of lines. + /// + /// See also: + /// * [TextField.maxLines], which specifies the maximum number of lines + /// the [TextField] can display. + final int? maxLines; + + /// {@macro flutter.widgets.TextField.textInputAction} + final TextInputAction? textInputAction; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.material.textfield.restorationId} + final String? restorationId; + + /// An optional controller that allows opening and closing of the menu from + /// other widgets. + final MenuController? menuController; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + @override + State<DropdownMenu<T>> createState() => _DropdownMenuState<T>(); +} + +class _DropdownMenuState<T> extends State<DropdownMenu<T>> { + static const Map<ShortcutActivator, Intent> _editableShortcuts = <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowLeft): ExtendSelectionByCharacterIntent( + forward: false, + collapseSelection: true, + ), + SingleActivator(LogicalKeyboardKey.arrowRight): ExtendSelectionByCharacterIntent( + forward: true, + collapseSelection: true, + ), + SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(), + }; + + static const Map<ShortcutActivator, Intent> _selectOnlyShortcuts = <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(), + // When selectOnly is true, a shortcut for the enter key is needed because + // the text field won't provide one. + SingleActivator(LogicalKeyboardKey.enter): _EnterIntent(), + }; + + final GlobalKey _anchorKey = GlobalKey(); + final GlobalKey _leadingKey = GlobalKey(); + late List<GlobalKey> buttonItemKeys; + late MenuController _controller; + bool _enableFilter = false; + late bool _enableSearch; + late List<DropdownMenuEntry<T>> filteredEntries; + List<Widget>? _initialMenu; + int? currentHighlight; + double? leadingPadding; + bool _menuHasEnabledItem = false; + TextEditingController? _localTextEditingController; + TextEditingController get _effectiveTextEditingController => + widget.controller ?? (_localTextEditingController ??= TextEditingController()); + final FocusNode _internalFocusNode = FocusNode(); + WidgetStatesController? _highlightedItemStatesController; + + FocusNode? _localTrailingIconButtonFocusNode; + FocusNode get _trailingIconButtonFocusNode => + widget.trailingIconFocusNode ?? (_localTrailingIconButtonFocusNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _enableSearch = widget.enableSearch; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey()); + _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled); + final int index = filteredEntries.indexWhere( + (DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection, + ); + if (index != -1) { + _effectiveTextEditingController.value = TextEditingValue( + text: filteredEntries[index].label, + selection: TextSelection.collapsed(offset: filteredEntries[index].label.length), + ); + } + refreshLeadingPadding(); + _controller = widget.menuController ?? MenuController(); + } + + @override + void dispose() { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + _internalFocusNode.dispose(); + _localTrailingIconButtonFocusNode?.dispose(); + _localTrailingIconButtonFocusNode = null; + _highlightedItemStatesController?.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(DropdownMenu<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + } + if (oldWidget.enableFilter != widget.enableFilter) { + if (!widget.enableFilter) { + _enableFilter = false; + } + } + if (oldWidget.enableSearch != widget.enableSearch) { + if (!widget.enableSearch) { + _enableSearch = widget.enableSearch; + currentHighlight = null; + } + } + if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) { + currentHighlight = null; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List<GlobalKey>.generate(filteredEntries.length, (int index) => GlobalKey()); + _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled); + } + if (oldWidget.leadingIcon != widget.leadingIcon) { + refreshLeadingPadding(); + } + if (oldWidget.initialSelection != widget.initialSelection) { + final int index = filteredEntries.indexWhere( + (DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection, + ); + if (index != -1) { + _effectiveTextEditingController.value = TextEditingValue( + text: filteredEntries[index].label, + selection: TextSelection.collapsed(offset: filteredEntries[index].label.length), + ); + } + } + if (oldWidget.menuController != widget.menuController) { + _controller = widget.menuController ?? MenuController(); + } + } + + bool canRequestFocus() { + return widget.focusNode?.canRequestFocus ?? + widget.requestFocusOnTap ?? + switch (Theme.of(context).platform) { + TargetPlatform.iOS || TargetPlatform.android || TargetPlatform.fuchsia => false, + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => true, + }; + } + + bool get selectOnly => widget.selectOnly; + bool get isButton => !canRequestFocus() || selectOnly; + + void refreshLeadingPadding() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() { + leadingPadding = getWidth(_leadingKey); + }); + }, debugLabel: 'DropdownMenu.refreshLeadingPadding'); + } + + void scrollToHighlight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final BuildContext? highlightContext = buttonItemKeys[currentHighlight!].currentContext; + if (highlightContext != null) { + Scrollable.of( + highlightContext, + ).position.ensureVisible(highlightContext.findRenderObject()!); + } + }, debugLabel: 'DropdownMenu.scrollToHighlight'); + } + + double? getWidth(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final box = context.findRenderObject()! as RenderBox; + return box.hasSize ? box.size.width : null; + } + return null; + } + + List<DropdownMenuEntry<T>> filter( + List<DropdownMenuEntry<T>> entries, + TextEditingController textEditingController, + ) { + final String filterText = textEditingController.text.toLowerCase(); + return entries + .where((DropdownMenuEntry<T> entry) => entry.label.toLowerCase().contains(filterText)) + .toList(); + } + + bool _shouldUpdateCurrentHighlight(List<DropdownMenuEntry<T>> entries) { + final String searchText = _effectiveTextEditingController.value.text.toLowerCase(); + if (searchText.isEmpty) { + return true; + } + + // When `entries` are filtered by filter algorithm, currentHighlight may exceed the valid range of `entries` and should be updated. + if (currentHighlight == null || currentHighlight! >= entries.length) { + return true; + } + + if (entries[currentHighlight!].label.toLowerCase().contains(searchText)) { + return false; + } + + return true; + } + + int? search(List<DropdownMenuEntry<T>> entries, TextEditingController textEditingController) { + final String searchText = textEditingController.value.text.toLowerCase(); + if (searchText.isEmpty) { + return null; + } + + final int index = entries.indexWhere( + (DropdownMenuEntry<T> entry) => entry.label.toLowerCase().contains(searchText), + ); + + return index != -1 ? index : null; + } + + List<Widget> _buildButtons( + List<DropdownMenuEntry<T>> filteredEntries, + TextDirection textDirection, { + int? focusedIndex, + bool enableScrollToHighlight = true, + bool excludeSemantics = false, + bool? useMaterial3, + }) { + final double effectiveInputStartGap = useMaterial3 ?? false ? _kInputStartGap : 0.0; + final result = <Widget>[]; + for (var i = 0; i < filteredEntries.length; i++) { + final DropdownMenuEntry<T> entry = filteredEntries[i]; + + // By default, when the text field has a leading icon but a menu entry doesn't + // have one, the label of the entry should have extra padding to be aligned + // with the text in the text input field. When both the text field and the + // menu entry have leading icons, the menu entry should remove the extra + // paddings so its leading icon will be aligned with the leading icon of + // the text field. + final double padding = entry.leadingIcon == null + ? (leadingPadding ?? _kDefaultHorizontalPadding) + : _kDefaultHorizontalPadding; + ButtonStyle effectiveStyle = + entry.style ?? + MenuItemButton.styleFrom( + padding: EdgeInsetsDirectional.only(start: padding, end: _kDefaultHorizontalPadding), + ); + + final ButtonStyle? themeStyle = MenuButtonTheme.of(context).style; + + final WidgetStateProperty<Color?>? effectiveForegroundColor = + entry.style?.foregroundColor ?? themeStyle?.foregroundColor; + final WidgetStateProperty<Color?>? effectiveIconColor = + entry.style?.iconColor ?? themeStyle?.iconColor; + final WidgetStateProperty<Color?>? effectiveOverlayColor = + entry.style?.overlayColor ?? themeStyle?.overlayColor; + final WidgetStateProperty<Color?>? effectiveBackgroundColor = + entry.style?.backgroundColor ?? themeStyle?.backgroundColor; + + // Simulate the focused state because the text field should always be focused + // during traversal. Include potential MenuItemButton theme in the focus + // simulation for all colors in the theme. + final bool entryIsSelected = entry.enabled && i == focusedIndex; + if (entryIsSelected) { + _highlightedItemStatesController?.dispose(); + _highlightedItemStatesController = WidgetStatesController(<WidgetState>{ + WidgetState.focused, + }); + + // Query the Material 3 default style. + // TODO(bleroux): replace once a standard way for accessing defaults will be defined. + // See: https://github.com/flutter/flutter/issues/130135. + final ButtonStyle defaultStyle = const MenuItemButton().defaultStyleOf(context); + + Color? resolveFocusedColor(WidgetStateProperty<Color?>? colorStateProperty) { + return colorStateProperty?.resolve(<WidgetState>{WidgetState.focused}); + } + + final Color focusedForegroundColor = resolveFocusedColor( + effectiveForegroundColor ?? defaultStyle.foregroundColor!, + )!; + final Color focusedIconColor = resolveFocusedColor( + effectiveIconColor ?? defaultStyle.iconColor!, + )!; + final Color focusedOverlayColor = resolveFocusedColor( + effectiveOverlayColor ?? defaultStyle.overlayColor!, + )!; + // For the background color we can't rely on the default style which is transparent. + // Defaults to onSurface.withOpacity(0.12). + final Color focusedBackgroundColor = + resolveFocusedColor(effectiveBackgroundColor) ?? + Theme.of(context).colorScheme.onSurface.withOpacity(0.12); + + effectiveStyle = effectiveStyle.copyWith( + backgroundColor: MaterialStatePropertyAll<Color>(focusedBackgroundColor), + foregroundColor: MaterialStatePropertyAll<Color>(focusedForegroundColor), + iconColor: MaterialStatePropertyAll<Color>(focusedIconColor), + overlayColor: MaterialStatePropertyAll<Color>(focusedOverlayColor), + ); + } else { + effectiveStyle = effectiveStyle.copyWith( + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + iconColor: effectiveIconColor, + overlayColor: effectiveOverlayColor, + ); + } + + Widget label = entry.labelWidget ?? Text(entry.label); + if (widget.width != null) { + final double horizontalPadding = + padding + _kDefaultHorizontalPadding + effectiveInputStartGap; + label = ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.width! - horizontalPadding), + child: label, + ); + } + + final Widget menuItemButton = ExcludeFocus( + child: ExcludeSemantics( + excluding: excludeSemantics, + child: MenuItemButton( + key: enableScrollToHighlight ? buttonItemKeys[i] : null, + statesController: entryIsSelected ? _highlightedItemStatesController : null, + style: effectiveStyle, + leadingIcon: entry.leadingIcon, + trailingIcon: entry.trailingIcon, + closeOnActivate: widget.closeBehavior == DropdownMenuCloseBehavior.all, + onPressed: entry.enabled && widget.enabled + ? () { + if (!mounted) { + // In some cases (e.g., nested menus), calling onSelected from MenuAnchor inside a postFrameCallback + // can result in the MenuItemButton's onPressed callback being triggered after the state has been disposed. + // TODO(ahmedrasar): MenuAnchor should avoid calling onSelected inside a postFrameCallback. + widget.controller?.value = TextEditingValue( + text: entry.label, + selection: TextSelection.collapsed(offset: entry.label.length), + ); + widget.onSelected?.call(entry.value); + return; + } + _effectiveTextEditingController.value = TextEditingValue( + text: entry.label, + selection: TextSelection.collapsed(offset: entry.label.length), + ); + currentHighlight = widget.enableSearch ? i : null; + widget.onSelected?.call(entry.value); + _enableFilter = false; + if (widget.closeBehavior == DropdownMenuCloseBehavior.self) { + _controller.close(); + } + } + : null, + requestFocusOnHover: false, + // MenuItemButton implementation is based on M3 spec for menu which specifies a + // horizontal padding of 12 pixels. + // In the context of DropdownMenu the M3 spec specifies that the menu item and the text + // field content should be aligned. The text field has a horizontal padding of 16 pixels. + // To conform with the 16 pixels padding, a 4 pixels padding is added in front of the item label. + child: Padding( + padding: EdgeInsetsDirectional.only(start: effectiveInputStartGap), + child: label, + ), + ), + ), + ); + result.add(menuItemButton); + } + + return result; + } + + void handleUpKey(_ArrowUpIntent _) { + setState(() { + if (!widget.enabled || !_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + _enableSearch = false; + currentHighlight ??= 0; + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _effectiveTextEditingController.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handleDownKey(_ArrowDownIntent _) { + setState(() { + if (!widget.enabled || !_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + _enableSearch = false; + currentHighlight ??= -1; + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _effectiveTextEditingController.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handleEnterKey(_EnterIntent _) { + if (selectOnly && !_controller.isOpen) { + _controller.open(); + return; + } + _handleSubmitted(); + } + + void handlePressed(MenuController controller, {bool focusForKeyboard = true}) { + if (controller.isOpen) { + currentHighlight = null; + controller.close(); + } else { + filteredEntries = widget.dropdownMenuEntries; + // close to open + if (_effectiveTextEditingController.text.isNotEmpty) { + _enableFilter = false; + } + controller.open(); + if (focusForKeyboard) { + _internalFocusNode.requestFocus(); + } + } + setState(() {}); + } + + void _handleSubmitted() { + if (currentHighlight != null) { + final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!]; + if (entry.enabled) { + _effectiveTextEditingController.value = TextEditingValue( + text: entry.label, + selection: TextSelection.collapsed(offset: entry.label.length), + ); + widget.onSelected?.call(entry.value); + } + } else { + if (_controller.isOpen) { + widget.onSelected?.call(null); + } + } + if (!widget.enableSearch) { + currentHighlight = null; + } + _controller.close(); + } + + @override + Widget build(BuildContext context) { + final bool useMaterial3 = Theme.of(context).useMaterial3; + final TextDirection textDirection = Directionality.of(context); + _initialMenu ??= _buildButtons( + widget.dropdownMenuEntries, + textDirection, + enableScrollToHighlight: false, + // The _initialMenu is invisible, we should not add semantics nodes to it + excludeSemantics: true, + useMaterial3: useMaterial3, + ); + final DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); + + if (_enableFilter) { + filteredEntries = + widget.filterCallback?.call(filteredEntries, _effectiveTextEditingController.text) ?? + filter(widget.dropdownMenuEntries, _effectiveTextEditingController); + } + _menuHasEnabledItem = filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled); + + if (_enableSearch) { + if (widget.searchCallback != null) { + currentHighlight = widget.searchCallback!( + filteredEntries, + _effectiveTextEditingController.text, + ); + } else { + final bool shouldUpdateCurrentHighlight = _shouldUpdateCurrentHighlight(filteredEntries); + if (shouldUpdateCurrentHighlight) { + currentHighlight = search(filteredEntries, _effectiveTextEditingController); + } + } + if (currentHighlight != null) { + scrollToHighlight(); + } + } + + final List<Widget> menu = _buildButtons( + filteredEntries, + textDirection, + focusedIndex: currentHighlight, + useMaterial3: useMaterial3, + ); + + final TextStyle? baseTextStyle = widget.textStyle ?? theme.textStyle ?? defaults.textStyle; + final Color? disabledColor = theme.disabledColor ?? defaults.disabledColor; + final TextStyle? effectiveTextStyle = widget.enabled + ? baseTextStyle + : baseTextStyle?.copyWith(color: disabledColor) ?? TextStyle(color: disabledColor); + + MenuStyle? effectiveMenuStyle = widget.menuStyle ?? theme.menuStyle ?? defaults.menuStyle!; + + final double? anchorWidth = getWidth(_anchorKey); + if (widget.width != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: WidgetStateProperty.resolveWith<Size?>((Set<WidgetState> states) { + final double? effectiveMaximumWidth = effectiveMenuStyle!.maximumSize + ?.resolve(states) + ?.width; + return Size(math.min(widget.width!, effectiveMaximumWidth ?? widget.width!), 0.0); + }), + ); + } else if (anchorWidth != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: WidgetStateProperty.resolveWith<Size?>((Set<WidgetState> states) { + final double? effectiveMaximumWidth = effectiveMenuStyle!.maximumSize + ?.resolve(states) + ?.width; + return Size(math.min(anchorWidth, effectiveMaximumWidth ?? anchorWidth), 0.0); + }), + ); + } + + if (widget.menuHeight != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + maximumSize: MaterialStatePropertyAll<Size>(Size(double.infinity, widget.menuHeight!)), + ); + } + final InputDecorationThemeData effectiveInputDecorationTheme = + widget.inputDecorationTheme ?? theme.inputDecorationTheme ?? defaults.inputDecorationTheme!; + + final MouseCursor? effectiveMouseCursor = switch (widget.enabled) { + true => isButton ? SystemMouseCursors.click : SystemMouseCursors.text, + false => null, + }; + + Widget menuAnchor = MenuAnchor( + style: effectiveMenuStyle, + alignmentOffset: widget.alignmentOffset, + reservedPadding: EdgeInsets.zero, + controller: _controller, + menuChildren: menu, + crossAxisUnconstrained: false, + builder: (BuildContext context, MenuController controller, Widget? child) { + assert(_initialMenu != null); + final DropdownMenuDecorationBuilder decorationBuilder = + widget.decorationBuilder ?? _buildDefaultDecoration; + InputDecoration decoration = decorationBuilder(context, controller); + // If no suffixIcon is provided, the default IconButton is used for convenience. + if (decoration.suffixIcon == null) { + decoration = decoration.copyWith( + suffixIcon: _buildDefaultSuffixIcon(context, controller), + ); + } + final InputDecoration effectiveDecoration = decoration.applyDefaults( + effectiveInputDecorationTheme, + ); + final InputDecoration textFieldDecoration = effectiveDecoration.prefixIcon == null + ? effectiveDecoration + : effectiveDecoration.copyWith( + prefixIcon: SizedBox( + key: _leadingKey, // Used to query the width in refreshLeadingPadding. + child: effectiveDecoration.prefixIcon, + ), + ); + + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final Widget textField = Semantics( + button: isButton, + // This is set specificly for iOS because iOS does not have any native + // APIs to show whether the menu is expanded or collapsed. + hint: Theme.of(context).platform == TargetPlatform.iOS + ? _controller.isOpen + ? localizations.collapsedHint + : localizations.expandedHint + : null, + expanded: _controller.isOpen, + onExpand: _controller.isOpen + ? null + : () { + _controller.open(); + }, + onCollapse: !_controller.isOpen + ? null + : () { + _controller.close(); + }, + child: ExcludeSemantics( + // When both `isTextField` and `isButton` are true, this widget will + // still be treated as a text field on web. So excluding the semantics + // of the `TextField` on web is needed. + excluding: isButton && kIsWeb, + child: TextField( + key: _anchorKey, + enabled: widget.enabled, + mouseCursor: effectiveMouseCursor, + focusNode: widget.focusNode, + canRequestFocus: canRequestFocus(), + enableInteractiveSelection: !isButton, + readOnly: isButton, + keyboardType: widget.keyboardType, + textAlign: widget.textAlign, + textAlignVertical: TextAlignVertical.center, + maxLines: widget.maxLines, + textInputAction: widget.textInputAction, + cursorHeight: widget.cursorHeight, + style: effectiveTextStyle, + controller: _effectiveTextEditingController, + onSubmitted: (_) => _handleSubmitted(), + onTap: !widget.enabled + ? null + : () { + handlePressed(controller, focusForKeyboard: !canRequestFocus()); + }, + onChanged: (String text) { + controller.open(); + setState(() { + filteredEntries = widget.dropdownMenuEntries; + _enableFilter = widget.enableFilter; + _enableSearch = widget.enableSearch; + }); + }, + inputFormatters: widget.inputFormatters, + decoration: textFieldDecoration, + restorationId: widget.restorationId, + scrollPadding: widget.scrollPadding, + ), + ), + ); + + // The label used in _DropdownMenuBody to compute the preferred width. + final Widget? effectiveLabel = + effectiveDecoration.label ?? + (effectiveDecoration.labelText != null ? Text(effectiveDecoration.labelText!) : null); + + // If [expandedInsets] is not null, the width of the text field should depend + // on its parent width. So we don't need to use `_DropdownMenuBody` to + // calculate the children's width. + final Widget body = widget.expandedInsets != null + ? textField + : _DropdownMenuBody( + width: widget.width, + // The children, except the text field, are used to compute the preferred width, + // which is the width of the longest children, plus the width of trailingButton + // and leadingButton. + // + // See _RenderDropdownMenuBody layout logic. + // + // TODO(bleroux): find a more accurate way to measure the text field minimum width. + // The text field width computation is not accurate as it is based only on label, + // prefixIcon and suffixIcon. Other InputDecoration parameters can have an + // impact on the total width. + children: <Widget>[ + textField, + ..._initialMenu!, + if (effectiveLabel != null) + ExcludeSemantics( + child: Padding( + // See RenderEditable.floatingCursorAddedMargin for the default horizontal padding. + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: DefaultTextStyle(style: effectiveTextStyle!, child: effectiveLabel), + ), + ), + effectiveDecoration.suffixIcon ?? const SizedBox.shrink(), + Padding( + // TODO(bleroux): find a more accurate way to get the correct width. + // This padding is used to mimic default input decorator padding. + // It won't be correct if non default values are used. + padding: const EdgeInsets.all(8.0), + child: effectiveDecoration.prefixIcon ?? const SizedBox.shrink(), + ), + ], + ); + + return Shortcuts( + shortcuts: selectOnly ? _selectOnlyShortcuts : _editableShortcuts, + child: body, + ); + }, + ); + + if (widget.expandedInsets case final EdgeInsetsGeometry padding) { + menuAnchor = Padding( + // Clamp the top and bottom padding to 0. + padding: padding.clamp( + EdgeInsets.zero, + const EdgeInsets.only( + left: double.infinity, + right: double.infinity, + ).add(const EdgeInsetsDirectional.only(end: double.infinity, start: double.infinity)), + ), + child: menuAnchor, + ); + } + + // Wrap the menu anchor with an Align to narrow down the constraints. + // Without this Align, when tight constraints are applied to DropdownMenu, + // the menu will appear below these constraints instead of below the + // text field. + menuAnchor = Align( + alignment: AlignmentDirectional.topStart, + widthFactor: 1.0, + heightFactor: 1.0, + child: menuAnchor, + ); + + return Actions( + actions: <Type, Action<Intent>>{ + _ArrowUpIntent: CallbackAction<_ArrowUpIntent>(onInvoke: handleUpKey), + _ArrowDownIntent: CallbackAction<_ArrowDownIntent>(onInvoke: handleDownKey), + _EnterIntent: CallbackAction<_EnterIntent>(onInvoke: handleEnterKey), + DismissIntent: DismissMenuAction(controller: _controller), + }, + child: Stack( + children: <Widget>[ + // Handling keyboard navigation when the Textfield has no focus. + Shortcuts( + shortcuts: const <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(), + SingleActivator(LogicalKeyboardKey.enter): _EnterIntent(), + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Focus( + focusNode: _internalFocusNode, + skipTraversal: true, + child: const SizedBox.shrink(), + ), + ), + menuAnchor, + ], + ), + ); + } + + InputDecoration _buildDefaultDecoration(BuildContext context, MenuController controller) { + return InputDecoration( + label: widget.label, + hintText: widget.hintText, + helperText: widget.helperText, + errorText: widget.errorText, + prefixIcon: widget.leadingIcon, + suffixIcon: _buildDefaultSuffixIcon(context, controller), + ); + } + + Widget? _buildDefaultSuffixIcon(BuildContext context, MenuController controller) { + final bool isCollapsed = widget.inputDecorationTheme?.isCollapsed ?? false; + return widget.showTrailingIcon + ? Padding( + padding: isCollapsed ? EdgeInsets.zero : const EdgeInsets.all(4.0), + child: ExcludeSemantics( + // When the text field is treated as a button (i.e., it can + // not be focused), the trailing button should become part of + // the text field button by excluding semantics. Otherwise, + // it will inappropriately announce whether this icon button + // is selected or not. + excluding: isButton, + child: IconButton( + focusNode: _trailingIconButtonFocusNode, + isSelected: controller.isOpen, + constraints: widget.inputDecorationTheme?.suffixIconConstraints, + padding: isCollapsed ? EdgeInsets.zero : null, + icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down), + selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up), + onPressed: !widget.enabled + ? null + : () { + handlePressed(controller); + }, + ), + ), + ) + : null; + } +} + +// `DropdownMenu` dispatches these private intents on arrow up/down keys. +// They are needed instead of the typical `DirectionalFocusIntent`s because +// `DropdownMenu` does not really navigate the focus tree upon arrow up/down +// keys: the focus stays on the text field and the menu items are given fake +// highlights as if they are focused. Using `DirectionalFocusIntent`s will cause +// the action to be processed by `EditableText`. +class _ArrowUpIntent extends Intent { + const _ArrowUpIntent(); +} + +class _ArrowDownIntent extends Intent { + const _ArrowDownIntent(); +} + +class _EnterIntent extends Intent { + const _EnterIntent(); +} + +class _DropdownMenuBody extends MultiChildRenderObjectWidget { + const _DropdownMenuBody({super.children, this.width}); + + final double? width; + + @override + _RenderDropdownMenuBody createRenderObject(BuildContext context) { + return _RenderDropdownMenuBody(width: width); + } + + @override + void updateRenderObject(BuildContext context, _RenderDropdownMenuBody renderObject) { + renderObject.width = width; + } +} + +class _DropdownMenuBodyParentData extends ContainerBoxParentData<RenderBox> {} + +class _RenderDropdownMenuBody extends RenderBox + with + ContainerRenderObjectMixin<RenderBox, _DropdownMenuBodyParentData>, + RenderBoxContainerDefaultsMixin<RenderBox, _DropdownMenuBodyParentData> { + _RenderDropdownMenuBody({double? width}) : _width = width; + + double? get width => _width; + double? _width; + set width(double? value) { + if (_width == value) { + return; + } + _width = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _DropdownMenuBodyParentData) { + child.parentData = _DropdownMenuBodyParentData(); + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + var maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + + final double intrinsicWidth = width ?? getMaxIntrinsicWidth(constraints.maxHeight); + final double widthConstraint = math.min(intrinsicWidth, constraints.maxWidth); + final innerConstraints = BoxConstraints( + maxWidth: widthConstraint, + maxHeight: getMaxIntrinsicHeight(widthConstraint), + ); + while (child != null) { + if (child == firstChild) { + child.layout(innerConstraints, parentUsesSize: true); + maxHeight ??= child.size.height; + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + child.layout(innerConstraints, parentUsesSize: true); + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, child.size.width); + maxHeight ??= child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + size = constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = firstChild; + if (child != null) { + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + context.paintChild(child, offset + childParentData.offset); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + var maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + final double intrinsicWidth = width ?? getMaxIntrinsicWidth(constraints.maxHeight); + final double widthConstraint = math.min(intrinsicWidth, constraints.maxWidth); + final innerConstraints = BoxConstraints( + maxWidth: widthConstraint, + maxHeight: getMaxIntrinsicHeight(widthConstraint), + ); + + while (child != null) { + final Size childSize = child.getDryLayout(innerConstraints); + + // The first child is the TextField, which doesn't contribute to the + // menu's width calculation. + if (child != firstChild) { + maxWidth = math.max(maxWidth, childSize.width); + } + + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + maxHeight ??= childSize.height; + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + return constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double minIntrinsicWidth = child.getMinIntrinsicWidth(height); + // Add the width of leading icon. + if (child == lastChild) { + width += minIntrinsicWidth; + } + // Add the width of trailing icon. + if (child == childBefore(lastChild!)) { + width += minIntrinsicWidth; + } + width = math.max(width, minIntrinsicWidth); + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMaxIntrinsicWidth(height); + // Add the width of leading icon. + if (child == lastChild) { + width += maxIntrinsicWidth; + } + // Add the width of trailing icon. + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMinIntrinsicHeight(double width) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMinIntrinsicHeight(width)); + } + return width; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMaxIntrinsicHeight(width)); + } + return width; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = firstChild; + if (child != null) { + final childParentData = child.parentData! as _DropdownMenuBodyParentData; + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } + + // Children except the text field (first child) are laid out for measurement purpose but not painted. + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + if (child == firstChild) { + visitor(renderObjectChild); + } + }); + } +} + +// Hand coded defaults. These will be updated once we have tokens/spec. +class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData { + _DropdownMenuDefaultsM3(this.context) + : super(disabledColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.38)); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + @override + TextStyle? get textStyle => _theme.textTheme.bodyLarge; + + @override + MenuStyle get menuStyle { + return const MenuStyle( + minimumSize: MaterialStatePropertyAll<Size>(Size(_kMinimumWidth, 0.0)), + maximumSize: MaterialStatePropertyAll<Size>(Size.infinite), + visualDensity: VisualDensity.standard, + ); + } + + @override + InputDecorationThemeData get inputDecorationTheme { + return const InputDecorationThemeData(border: OutlineInputBorder()); + } +} diff --git a/packages/material_ui/lib/src/dropdown_menu_form_field.dart b/packages/material_ui/lib/src/dropdown_menu_form_field.dart new file mode 100644 index 000000000000..de18fc6a4ca5 --- /dev/null +++ b/packages/material_ui/lib/src/dropdown_menu_form_field.dart @@ -0,0 +1,282 @@ +// 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. + +/// @docImport 'text_theme.dart'; +library; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'dropdown_menu.dart'; +import 'input_decorator.dart'; +import 'menu_style.dart'; + +/// A [FormField] that contains a [DropdownMenu]. +/// +/// This is a convenience widget that wraps a [DropdownMenu] widget in a +/// [FormField]. +/// +/// A [Form] ancestor is not required. The [Form] allows one to +/// save, reset, or validate multiple fields at once. To use without a [Form], +/// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to +/// save or reset the form field. +/// +/// The `value` parameter maps to [FormField.initialValue]. +/// +/// See also: +/// +/// * [DropdownMenu], which is the underlying text field without the [Form] +/// integration. +class DropdownMenuFormField<T> extends FormField<T> { + /// Creates a [DropdownMenu] widget that is a [FormField]. + /// + /// For a description of the `onSaved`, `validator`, or `autovalidateMode` + /// parameters, see [FormField]. For the rest, see [DropdownMenu]. + DropdownMenuFormField({ + super.key, + bool enabled = true, + double? width, + double? menuHeight, + Widget? leadingIcon, + Widget? trailingIcon, + bool showTrailingIcon = true, + FocusNode? trailingIconFocusNode, + Widget? label, + String? hintText, + String? helperText, + Widget? selectedTrailingIcon, + bool enableFilter = false, + bool enableSearch = true, + TextInputType? keyboardType, + TextStyle? textStyle, + TextAlign textAlign = TextAlign.start, + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + Object? inputDecorationTheme, + DropdownMenuDecorationBuilder? decorationBuilder, + MenuStyle? menuStyle, + this.controller, + T? initialSelection, + this.onSelected, + FocusNode? focusNode, + bool? requestFocusOnTap, + bool selectOnly = false, + EdgeInsetsGeometry? expandedInsets, + Offset? alignmentOffset, + FilterCallback<T>? filterCallback, + SearchCallback<T>? searchCallback, + required this.dropdownMenuEntries, + List<TextInputFormatter>? inputFormatters, + DropdownMenuCloseBehavior closeBehavior = DropdownMenuCloseBehavior.all, + int maxLines = 1, + TextInputAction? textInputAction, + double? cursorHeight, + MenuController? menuController, + super.restorationId, + super.onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + super.validator, + super.forceErrorText, + super.errorBuilder, + }) : super( + initialValue: initialSelection, + autovalidateMode: autovalidateMode, + builder: (FormFieldState<T> field) { + final state = field as _DropdownMenuFormFieldState<T>; + + InputDecoration effectiveDecorationBuilder( + BuildContext context, + MenuController menuController, + ) { + final InputDecoration decoration = + decorationBuilder?.call(context, menuController) ?? const InputDecoration(); + final InputDecoration decorationWithLabels = decoration.copyWith( + label: label, + hintText: hintText, + helperText: helperText, + ); + + final String? errorText = state.errorText; + if (errorText == null) { + return decorationWithLabels; + } + + return errorBuilder != null + ? decorationWithLabels.copyWith(error: errorBuilder(state.context, errorText)) + : decorationWithLabels.copyWith(errorText: errorText); + } + + return UnmanagedRestorationScope( + bucket: field.bucket, + child: DropdownMenu<T>( + restorationId: restorationId, + enabled: enabled, + width: width, + menuHeight: menuHeight, + leadingIcon: leadingIcon, + trailingIcon: trailingIcon, + showTrailingIcon: showTrailingIcon, + trailingIconFocusNode: trailingIconFocusNode, + selectedTrailingIcon: selectedTrailingIcon, + enableFilter: enableFilter, + enableSearch: enableSearch, + keyboardType: keyboardType, + textStyle: textStyle, + textAlign: textAlign, + inputDecorationTheme: inputDecorationTheme, + decorationBuilder: effectiveDecorationBuilder, + menuStyle: menuStyle, + controller: state.textFieldController, + initialSelection: state.value, + onSelected: field.didChange, + focusNode: focusNode, + requestFocusOnTap: requestFocusOnTap, + selectOnly: selectOnly, + expandedInsets: expandedInsets, + alignmentOffset: alignmentOffset, + filterCallback: filterCallback, + searchCallback: searchCallback, + inputFormatters: inputFormatters, + closeBehavior: closeBehavior, + dropdownMenuEntries: dropdownMenuEntries, + maxLines: maxLines, + textInputAction: textInputAction, + cursorHeight: cursorHeight, + menuController: menuController, + ), + ); + }, + ); + + /// The callback is called when a selection is made. + /// + /// The callback receives the selected entry's value of type `T` when the user + /// chooses an item. It may also be invoked with `null` to indicate that the + /// selection was cleared / that no item was chosen. + /// + /// Defaults to null. If this callback itself is null, the widget still updates + /// the text field with the selected label. + final ValueChanged<T?>? onSelected; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// Descriptions of the menu items in the [DropdownMenuFormField]. + /// + /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] + /// is provided. If this is an empty list, the menu will be empty and only + /// contain space for padding. + final List<DropdownMenuEntry<T>> dropdownMenuEntries; + + @override + FormFieldState<T> createState() => _DropdownMenuFormFieldState<T>(); +} + +class _DropdownMenuFormFieldState<T> extends FormFieldState<T> { + DropdownMenuFormField<T> get _dropdownMenuFormField => widget as DropdownMenuFormField<T>; + + // The controller used to restore the selected item. + RestorableTextEditingController? _restorableController; + + // The controller used to reset the content of the DropdownMenu inner TextField. + TextEditingController? _localTextFieldController; + TextEditingController get textFieldController => + _dropdownMenuFormField.controller ?? (_localTextFieldController ??= TextEditingController()); + + @override + void initState() { + super.initState(); + _createRestorableController(widget.initialValue); + } + + void _createRestorableController(T? initialValue) { + assert(_restorableController == null); + _restorableController = RestorableTextEditingController.fromValue( + TextEditingValue(text: _findLabelByValue(initialValue)), + ); + if (!restorePending) { + _registerRestorableController(); + } + } + + @override + void didUpdateWidget(DropdownMenuFormField<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue && !hasInteractedByUser) { + setValue(widget.initialValue); + } + if (oldWidget.controller != _dropdownMenuFormField.controller) { + _localTextFieldController?.dispose(); + _localTextFieldController = null; + } + } + + @override + void dispose() { + _restorableController?.dispose(); + _localTextFieldController?.dispose(); + super.dispose(); + } + + @override + void didChange(T? value) { + super.didChange(value); + _dropdownMenuFormField.onSelected?.call(value); + _updateRestorableController(value); + } + + @override + void reset() { + super.reset(); + _dropdownMenuFormField.onSelected?.call(value); + _updateRestorableController(widget.initialValue); + if (widget.initialValue == null) { + textFieldController.clear(); + } + } + + void _updateRestorableController(T? value) { + if (_restorableController != null) { + _restorableController!.value.value = TextEditingValue(text: _findLabelByValue(value)); + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + super.restoreState(oldBucket, initialRestore); + if (_restorableController != null) { + _registerRestorableController(); + // Make sure to update the internal [DropdownMenuFieldState] value to sync up with + // text editing controller value if it matches one of the item label. + final T? matchingValue = _findValueByLabel(_restorableController!.value.text); + if (matchingValue != null) { + setValue(matchingValue); + } + } + } + + void _registerRestorableController() { + assert(_restorableController != null); + registerForRestoration(_restorableController!, 'controller'); + } + + T? _findValueByLabel(String label) { + for (final DropdownMenuEntry<T> entry in _dropdownMenuFormField.dropdownMenuEntries) { + if (entry.label == label) { + return entry.value; + } + } + return null; + } + + String _findLabelByValue(T? value) { + for (final DropdownMenuEntry<T> entry in _dropdownMenuFormField.dropdownMenuEntries) { + if (entry.value == value) { + return entry.label; + } + } + return ''; + } +} diff --git a/packages/material_ui/lib/src/dropdown_menu_theme.dart b/packages/material_ui/lib/src/dropdown_menu_theme.dart new file mode 100644 index 000000000000..8ef9a6acd891 --- /dev/null +++ b/packages/material_ui/lib/src/dropdown_menu_theme.dart @@ -0,0 +1,207 @@ +// 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. + +/// @docImport 'dropdown_menu.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; + +import 'input_decorator.dart'; +import 'menu_style.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default values of visual properties for descendant [DropdownMenu] widgets. +/// +/// Descendant widgets obtain the current [DropdownMenuThemeData] object with +/// [DropdownMenuTheme.of]. Instances of [DropdownMenuThemeData] can +/// be customized with [DropdownMenuThemeData.copyWith]. +/// +/// Typically a [DropdownMenuTheme] is specified as part of the overall [Theme] with +/// [ThemeData.dropdownMenuTheme]. +/// +/// All [DropdownMenuThemeData] properties are null by default. When null, the [DropdownMenu] +/// computes its own default values, typically based on the overall +/// theme's [ThemeData.colorScheme], [ThemeData.textTheme], and [ThemeData.iconTheme]. +@immutable +class DropdownMenuThemeData with Diagnosticable { + /// Creates a [DropdownMenuThemeData] that can be used to override default properties + /// in a [DropdownMenuTheme] widget. + const DropdownMenuThemeData({ + this.textStyle, + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + Object? inputDecorationTheme, + this.menuStyle, + this.disabledColor, + }) : assert( + inputDecorationTheme == null || + (inputDecorationTheme is InputDecorationTheme || + inputDecorationTheme is InputDecorationThemeData), + ), + _inputDecorationTheme = inputDecorationTheme; + + /// Overrides the default value for [DropdownMenu.textStyle]. + final TextStyle? textStyle; + + /// The input decoration theme for the [TextField]s in a [DropdownMenu]. + /// + /// If this is null, the [DropdownMenu] provides its own defaults. + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + InputDecorationThemeData? get inputDecorationTheme { + if (_inputDecorationTheme == null) { + return null; + } + return _inputDecorationTheme is InputDecorationTheme + ? _inputDecorationTheme.data + : _inputDecorationTheme as InputDecorationThemeData; + } + + final Object? _inputDecorationTheme; + + /// Overrides the menu's default style in a [DropdownMenu]. + /// + /// Any values not set in the [MenuStyle] will use the menu default for that + /// property. + final MenuStyle? menuStyle; + + /// The color used for disabled DropdownMenu. + /// This color is applied to the text of the selected item on TextField. + final Color? disabledColor; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + DropdownMenuThemeData copyWith({ + TextStyle? textStyle, + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + Object? inputDecorationTheme, + MenuStyle? menuStyle, + Color? disabledColor, + }) { + return DropdownMenuThemeData( + textStyle: textStyle ?? this.textStyle, + inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme, + menuStyle: menuStyle ?? this.menuStyle, + disabledColor: disabledColor ?? this.disabledColor, + ); + } + + /// Linearly interpolates between two dropdown menu themes. + static DropdownMenuThemeData lerp(DropdownMenuThemeData? a, DropdownMenuThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return DropdownMenuThemeData( + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + inputDecorationTheme: t < 0.5 ? a?.inputDecorationTheme : b?.inputDecorationTheme, + menuStyle: MenuStyle.lerp(a?.menuStyle, b?.menuStyle, t), + disabledColor: Color.lerp(a?.disabledColor, b?.disabledColor, t), + ); + } + + @override + int get hashCode => Object.hash(textStyle, inputDecorationTheme, menuStyle, disabledColor); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is DropdownMenuThemeData && + other.textStyle == textStyle && + other.inputDecorationTheme == inputDecorationTheme && + other.menuStyle == menuStyle && + other.disabledColor == disabledColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<TextStyle>('textStyle', textStyle, defaultValue: null)); + properties.add( + DiagnosticsProperty<InputDecorationThemeData>( + 'inputDecorationThemeData', + inputDecorationTheme, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<MenuStyle>('menuStyle', menuStyle, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + } +} + +/// An inherited widget that defines the visual properties for [DropdownMenu]s in this widget's subtree. +/// +/// Values specified here are used for [DropdownMenu] properties that are not +/// given an explicit non-null value. +class DropdownMenuTheme extends InheritedTheme { + /// Creates a [DropdownMenuTheme] that controls visual parameters for + /// descendant [DropdownMenu]s. + const DropdownMenuTheme({super.key, required this.data, required super.child}); + + /// Specifies the visual properties used by descendant [DropdownMenu] + /// widgets. + final DropdownMenuThemeData data; + + /// Retrieves the [DropdownMenuThemeData] from the closest ancestor [DropdownMenuTheme]. + /// + /// If there is no enclosing [DropdownMenuTheme] widget, then + /// [ThemeData.dropdownMenuTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + /// ``` + /// + /// See also: + /// + /// * [maybeOf], which returns null if it doesn't find a + /// [DropdownMenuTheme] ancestor. + static DropdownMenuThemeData of(BuildContext context) { + return maybeOf(context) ?? Theme.of(context).dropdownMenuTheme; + } + + /// The data from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Use this function if you want to allow situations where no + /// [DropdownMenuTheme] is in scope. Prefer using [DropdownMenuTheme.of] + /// in situations where a [DropdownMenuThemeData] is expected to be + /// non-null. + /// + /// If there is no [DropdownMenuTheme] in scope, then this function will + /// return null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// DropdownMenuThemeData? theme = DropdownMenuTheme.maybeOf(context); + /// if (theme == null) { + /// // Do something else instead. + /// } + /// ``` + /// + /// See also: + /// + /// * [of], which will return [ThemeData.dropdownMenuTheme] if it doesn't + /// find a [DropdownMenuTheme] ancestor, instead of returning null. + static DropdownMenuThemeData? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<DropdownMenuTheme>()?.data; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return DropdownMenuTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(DropdownMenuTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/elevated_button.dart b/packages/material_ui/lib/src/elevated_button.dart new file mode 100644 index 000000000000..2c1cde331ebc --- /dev/null +++ b/packages/material_ui/lib/src/elevated_button.dart @@ -0,0 +1,644 @@ +// 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. + +/// @docImport 'filled_button.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +library; + +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 'colors.dart'; +import 'constants.dart'; +import 'elevated_button_theme.dart'; +import 'ink_ripple.dart'; +import 'ink_well.dart'; +import 'material_state.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design "elevated button". +/// +/// Use elevated buttons to add dimension to otherwise mostly flat +/// layouts, e.g. in long busy lists of content, or in wide +/// spaces. Avoid using elevated buttons on already-elevated content +/// such as dialogs or cards. +/// +/// An elevated button is a label [child] displayed on a [Material] +/// widget whose [Material.elevation] increases when the button is +/// pressed. 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 elevated button's default style is defined by +/// [defaultStyleOf]. The style of this elevated button can be +/// overridden with its [style] parameter. The style of all elevated +/// buttons in a subtree can be overridden with the +/// [ElevatedButtonTheme], and the style of all of the elevated +/// buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.elevatedButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// elevated button [ButtonStyle] from simple values. +/// +/// If [onPressed] and [onLongPress] callbacks are null, then the +/// button will be disabled. +/// +/// {@tool dartpad} +/// This sample produces an enabled and a disabled ElevatedButton. +/// +/// ** See code in examples/api/lib/material/elevated_button/elevated_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [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. +/// * <https://material.io/design/components/buttons.html> +/// * <https://m3.material.io/components/buttons> +class ElevatedButton extends ButtonStyleButton { + /// Create an ElevatedButton. + const ElevatedButton({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior, + super.statesController, + required super.child, + }) : _addPadding = false; + + /// Create an elevated button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 12 logical pixels + /// at the start, and 16 at the end, with an 8 pixel gap in between. + /// + /// If [icon] is null, this constructor will create an [ElevatedButton] + /// that doesn't display an icon. + /// + /// {@macro flutter.material.ButtonStyle.iconAlignment} + /// + ElevatedButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior = Clip.none, + super.statesController, + Widget? icon, + required Widget label, + IconAlignment? iconAlignment, + }) : _addPadding = icon != null, + super( + child: icon != null + ? _ElevatedButtonWithIconChild( + label: label, + icon: icon, + buttonStyle: style, + iconAlignment: iconAlignment, + ) + : label, + ); + + final bool _addPadding; + + /// A static convenience method that constructs an elevated button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] and [disabledForegroundColor] colors are used + /// to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], and + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// The [backgroundColor] and [disabledBackgroundColor] colors are + /// used to create a [WidgetStateProperty] [ButtonStyle.backgroundColor]. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// If [iconColor] is null, the button icon will use [foregroundColor]. If [foregroundColor] is also + /// null, the button icon will use the default icon color. + /// + /// 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. + /// + /// All of the other parameters are either used directly or used to + /// create a [WidgetStateProperty] 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 an + /// [ElevatedButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// ElevatedButton( + /// style: ElevatedButton.styleFrom(foregroundColor: Colors.green), + /// onPressed: () { + /// // ... + /// }, + /// child: const Text('Jump'), + /// ), + /// ``` + /// + /// And to change the fill color: + /// + /// ```dart + /// ElevatedButton( + /// style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + /// onPressed: () { + /// // ... + /// }, + /// child: const Text('Meow'), + /// ), + /// ``` + /// + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + IconAlignment? iconAlignment, + Color? disabledIconColor, + Color? overlayColor, + 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, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, + }) { + final WidgetStateProperty<Color?>? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, Color(a: 0.0)) => WidgetStatePropertyAll<Color?>(overlayColor), + (_, final Color color) || + (final Color color, _) => WidgetStateProperty<Color?>.fromMap(<WidgetState, Color?>{ + WidgetState.pressed: color.withOpacity(0.1), + WidgetState.hovered: color.withOpacity(0.08), + WidgetState.focused: color.withOpacity(0.1), + }), + }; + + WidgetStateProperty<double>? elevationValue; + if (elevation != null) { + elevationValue = WidgetStateProperty<double>.fromMap(<WidgetStatesConstraint, double>{ + WidgetState.disabled: 0, + WidgetState.pressed: elevation + 6, + WidgetState.hovered: elevation + 2, + WidgetState.focused: elevation + 2, + WidgetState.any: elevation, + }); + } + + return ButtonStyle( + textStyle: MaterialStatePropertyAll<TextStyle?>(textStyle), + backgroundColor: ButtonStyleButton.defaultColor(backgroundColor, disabledBackgroundColor), + foregroundColor: ButtonStyleButton.defaultColor(foregroundColor, disabledForegroundColor), + overlayColor: overlayColorProp, + shadowColor: ButtonStyleButton.allOrNull<Color>(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull<Color>(surfaceTintColor), + iconColor: ButtonStyleButton.defaultColor(iconColor, disabledIconColor), + iconSize: ButtonStyleButton.allOrNull<double>(iconSize), + iconAlignment: iconAlignment, + elevation: elevationValue, + padding: ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(padding), + minimumSize: ButtonStyleButton.allOrNull<Size>(minimumSize), + fixedSize: ButtonStyleButton.allOrNull<Size>(fixedSize), + maximumSize: ButtonStyleButton.allOrNull<Size>(maximumSize), + side: ButtonStyleButton.allOrNull<BorderSide>(side), + shape: ButtonStyleButton.allOrNull<OutlinedBorder>(shape), + mouseCursor: WidgetStateProperty<MouseCursor?>.fromMap(<WidgetStatesConstraint, MouseCursor?>{ + WidgetState.disabled: disabledMouseCursor, + WidgetState.any: enabledMouseCursor, + }), + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, + ); + } + + /// 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)`. [WidgetStateProperty] 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. + /// + /// {@template flutter.material.elevated_button.default_font_size} + /// The "default font size" below refers to the font size specified in the + /// [defaultStyleOf] method (or 14.0 if unspecified), scaled by the + /// `MediaQuery.textScalerOf(context).scale` method. The names of the + /// EdgeInsets constructors and `EdgeInsetsGeometry.lerp` have been abbreviated + /// for readability. + /// {@endtemplate} + /// + /// The color of the [ButtonStyle.textStyle] is not used, the + /// [ButtonStyle.foregroundColor] color is used instead. + /// + /// ## Material 2 defaults + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.12) + /// * others - Theme.colorScheme.primary + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.onPrimary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.onPrimary(0.08) + /// * focused or pressed - Theme.colorScheme.onPrimary(0.12) + /// * `shadowColor` - Theme.shadowColor + /// * `elevation` + /// * disabled - 0 + /// * default - 2 + /// * hovered or focused - 4 + /// * pressed - 8 + /// * `padding` + /// * `default font size <= 14` - horizontal(16) + /// * `14 < default font size <= 28` - lerp(horizontal(16), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - InkRipple.splashFactory + /// + /// The default padding values for the [ElevatedButton.icon] factory are slightly different: + /// + /// * `padding` + /// * `default font size <= 14` - start(12) end(16) + /// * `14 < default font size <= 28` - lerp(start(12) end(16), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - 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. + /// + /// ## Material 3 defaults + /// + /// If [ThemeData.useMaterial3] is set to true the following defaults will + /// be used: + /// + /// * `textStyle` - Theme.textTheme.labelLarge + /// * `backgroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.12) + /// * others - Theme.colorScheme.surfaceContainerLow + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.1) + /// * `shadowColor` - Theme.colorScheme.shadow + /// * `surfaceTintColor` - Colors.transparent + /// * `elevation` + /// * disabled - 0 + /// * default - 1 + /// * hovered - 3 + /// * focused or pressed - 1 + /// * `padding` + /// * `default font size <= 14` - horizontal(24) + /// * `14 < default font size <= 28` - lerp(horizontal(24), horizontal(12)) + /// * `28 < default font size <= 36` - lerp(horizontal(12), horizontal(6)) + /// * `36 < default font size` - horizontal(6) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - Theme.visualDensity + /// * `tapTargetSize` - Theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + /// + /// For the [ElevatedButton.icon] factory, the start (generally the left) value of + /// [ButtonStyle.padding] is reduced from 24 to 16. + + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final ButtonStyle buttonStyle = theme.useMaterial3 + ? _ElevatedButtonDefaultsM3(context) + : styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: colorScheme.onSurface.withOpacity(0.12), + disabledForegroundColor: colorScheme.onSurface.withOpacity(0.38), + shadowColor: theme.shadowColor, + elevation: 2, + textStyle: theme.textTheme.labelLarge, + padding: _scaledPadding(context), + minimumSize: const Size(64, 36), + maximumSize: Size.infinite, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + enabledMouseCursor: kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + disabledMouseCursor: SystemMouseCursors.basic, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + splashFactory: InkRipple.splashFactory, + ); + + // Only apply padding when the ElevatedButton has an Icon. + if (_addPadding) { + final double defaultFontSize = + buttonStyle.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + + final EdgeInsetsGeometry scaledPadding = theme.useMaterial3 + ? ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(16, 0, 24, 0), + const EdgeInsetsDirectional.fromSTEB(8, 0, 12, 0), + const EdgeInsetsDirectional.fromSTEB(4, 0, 6, 0), + effectiveTextScale, + ) + : ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsetsDirectional.fromSTEB(8, 0, 4, 0), + effectiveTextScale, + ); + return buttonStyle.copyWith( + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(scaledPadding), + ); + } + + return buttonStyle; + } + + /// Returns the [ElevatedButtonThemeData.style] of the closest + /// [ElevatedButtonTheme] ancestor. + @override + ButtonStyle? themeStyleOf(BuildContext context) { + return ElevatedButtonTheme.of(context).style; + } +} + +EdgeInsetsGeometry _scaledPadding(BuildContext context) { + final ThemeData theme = Theme.of(context); + final padding1x = theme.useMaterial3 ? 24.0 : 16.0; + final double defaultFontSize = theme.textTheme.labelLarge?.fontSize ?? 14.0; + final double effectiveTextScale = MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + + return ButtonStyleButton.scaledPadding( + EdgeInsets.symmetric(horizontal: padding1x), + EdgeInsets.symmetric(horizontal: padding1x / 2), + EdgeInsets.symmetric(horizontal: padding1x / 2 / 2), + effectiveTextScale, + ); +} + +class _ElevatedButtonWithIconChild extends StatelessWidget { + const _ElevatedButtonWithIconChild({ + required this.label, + required this.icon, + required this.buttonStyle, + required this.iconAlignment, + }); + + final Widget label; + final Widget icon; + final ButtonStyle? buttonStyle; + final IconAlignment? iconAlignment; + + @override + Widget build(BuildContext context) { + final double defaultFontSize = + buttonStyle?.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double scale = + clampDouble(MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0, 1.0, 2.0) - 1.0; + final ElevatedButtonThemeData elevatedButtonTheme = ElevatedButtonTheme.of(context); + final IconAlignment effectiveIconAlignment = + iconAlignment ?? + elevatedButtonTheme.style?.iconAlignment ?? + buttonStyle?.iconAlignment ?? + IconAlignment.start; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: lerpDouble(8, 4, scale)!, + children: effectiveIconAlignment == IconAlignment.start + ? <Widget>[icon, Flexible(child: label)] + : <Widget>[Flexible(child: label), icon], + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - ElevatedButton + +// 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. + +// dart format off +class _ElevatedButtonDefaultsM3 extends ButtonStyle { + _ElevatedButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty<TextStyle?> get textStyle => + MaterialStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge); + + @override + WidgetStateProperty<Color?>? get backgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.surfaceContainerLow; + }); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return null; + }); + + @override + WidgetStateProperty<Color>? get shadowColor => + MaterialStatePropertyAll<Color>(_colors.shadow); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<double>? get elevation => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return 0.0; + } + if (states.contains(WidgetState.pressed)) { + return 1.0; + } + if (states.contains(WidgetState.hovered)) { + return 3.0; + } + if (states.contains(WidgetState.focused)) { + return 1.0; + } + return 1.0; + }); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + MaterialStatePropertyAll<EdgeInsetsGeometry>(_scaledPadding(context)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(64.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(18.0); + + @override + WidgetStateProperty<Color>? get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary; + } + if (states.contains(WidgetState.focused)) { + return _colors.primary; + } + return _colors.primary; + }); + } + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + // No default side + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - ElevatedButton diff --git a/packages/material_ui/lib/src/elevated_button_theme.dart b/packages/material_ui/lib/src/elevated_button_theme.dart new file mode 100644 index 000000000000..321ad47c39d7 --- /dev/null +++ b/packages/material_ui/lib/src/elevated_button_theme.dart @@ -0,0 +1,127 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +library; + +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 +/// [ElevatedButton]s when it's used with [ElevatedButtonTheme] or with the +/// overall [Theme]'s [ThemeData.elevatedButtonTheme]. +/// +/// The [style]'s properties override [ElevatedButton]'s default style, +/// i.e. the [ButtonStyle] returned by [ElevatedButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [WidgetStateProperty] values are used. +/// +/// See also: +/// +/// * [ElevatedButtonTheme], the theme which is configured with this class. +/// * [ElevatedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [ElevatedButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [ElevatedButton]'s defaults. +/// * [WidgetStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [WidgetState]s. +/// * [ThemeData.elevatedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [ElevatedButton]s below the overall [Theme]. +@immutable +class ElevatedButtonThemeData with Diagnosticable { + /// Creates an [ElevatedButtonThemeData]. + /// + /// The [style] may be null. + const ElevatedButtonThemeData({this.style}); + + /// Overrides for [ElevatedButton]'s default style. + /// + /// Non-null properties or non-null resolved [WidgetStateProperty] + /// values override the [ButtonStyle] returned by + /// [ElevatedButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Linearly interpolate between two elevated button themes. + static ElevatedButtonThemeData? lerp( + ElevatedButtonThemeData? a, + ElevatedButtonThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return ElevatedButtonThemeData(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 ElevatedButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [ElevatedButton] descendants. +/// +/// See also: +/// +/// * [ElevatedButtonThemeData], which is used to configure this theme. +/// * [ElevatedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for elevated buttons. +/// * [ElevatedButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [ElevatedButton]'s defaults. +/// * [ThemeData.elevatedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [ElevatedButton]s below the overall [Theme]. +class ElevatedButtonTheme extends InheritedTheme { + /// Create a [ElevatedButtonTheme]. + const ElevatedButtonTheme({super.key, required this.data, required super.child}); + + /// The configuration of this theme. + final ElevatedButtonThemeData data; + + /// Retrieves the [ElevatedButtonThemeData] from the closest ancestor [ElevatedButtonTheme]. + /// + /// If there is no enclosing [ElevatedButtonTheme] widget, then + /// [ThemeData.elevatedButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ElevatedButtonThemeData theme = ElevatedButtonTheme.of(context); + /// ``` + static ElevatedButtonThemeData of(BuildContext context) { + final ElevatedButtonTheme? buttonTheme = context + .dependOnInheritedWidgetOfExactType<ElevatedButtonTheme>(); + return buttonTheme?.data ?? Theme.of(context).elevatedButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ElevatedButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ElevatedButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/elevation_overlay.dart b/packages/material_ui/lib/src/elevation_overlay.dart new file mode 100644 index 000000000000..ef3fa4e398b0 --- /dev/null +++ b/packages/material_ui/lib/src/elevation_overlay.dart @@ -0,0 +1,181 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'material.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +/// A utility class for dealing with the overlay color needed +/// to indicate elevation of surfaces. +abstract final class ElevationOverlay { + /// Applies a surface tint color to a given container color to indicate + /// the level of its elevation. + /// + /// With Material Design 3, some components will use a "surface tint" color + /// overlay with an opacity applied to their base color to indicate they are + /// elevated. The amount of opacity will vary with the elevation as described + /// in: https://m3.material.io/styles/color/the-color-system/color-roles. + /// + /// If [surfaceTint] is not null and not completely transparent ([Color.alpha] + /// is 0), then the returned color will be the given [color] with the + /// [surfaceTint] of the appropriate opacity applied to it. Otherwise it will + /// just return [color] unmodified. + static Color applySurfaceTint(Color color, Color? surfaceTint, double elevation) { + if (surfaceTint != null && surfaceTint != Colors.transparent) { + return Color.alphaBlend( + surfaceTint.withOpacity(_surfaceTintOpacityForElevation(elevation)), + color, + ); + } + return color; + } + + // Calculates the opacity of the surface tint color from the elevation by + // looking it up in the token generated table of opacities, interpolating + // between values as needed. If the elevation is outside the range of values + // in the table it will clamp to the smallest or largest opacity. + static double _surfaceTintOpacityForElevation(double elevation) { + if (elevation < _surfaceTintElevationOpacities[0].elevation) { + // Elevation less than the first entry, so just clamp it to the first one. + return _surfaceTintElevationOpacities[0].opacity; + } + + // Walk the opacity list and find the closest match(es) for the elevation. + var index = 0; + while (elevation >= _surfaceTintElevationOpacities[index].elevation) { + // If we found it exactly or walked off the end of the list just return it. + if (elevation == _surfaceTintElevationOpacities[index].elevation || + index + 1 == _surfaceTintElevationOpacities.length) { + return _surfaceTintElevationOpacities[index].opacity; + } + index += 1; + } + + // Interpolate between the two opacity values + final _ElevationOpacity lower = _surfaceTintElevationOpacities[index - 1]; + final _ElevationOpacity upper = _surfaceTintElevationOpacities[index]; + final double t = (elevation - lower.elevation) / (upper.elevation - lower.elevation); + return lower.opacity + t * (upper.opacity - lower.opacity); + } + + /// Applies an overlay color to a surface color to indicate + /// the level of its elevation in a dark theme. + /// + /// If using Material Design 3, this type of color overlay is no longer used. + /// Instead a "surface tint" overlay is used instead. See [applySurfaceTint], + /// [ThemeData.useMaterial3] for more information. + /// + /// Material drop shadows can be difficult to see in a dark theme, so the + /// elevation of a surface should be portrayed with an "overlay" in addition + /// to the shadow. As the elevation of the component increases, the + /// overlay increases in opacity. This function computes and applies this + /// overlay to a given color as needed. + /// + /// If the ambient theme is dark ([ThemeData.brightness] is [Brightness.dark]), + /// and [ThemeData.applyElevationOverlayColor] is true, and the given + /// [color] is [ColorScheme.surface] then this will return a version of + /// the [color] with a semi-transparent [ColorScheme.onSurface] overlaid + /// on top of it. The opacity of the overlay is computed based on the + /// [elevation]. + /// + /// Otherwise it will just return the [color] unmodified. + /// + /// See also: + /// + /// * [ThemeData.applyElevationOverlayColor] which controls the whether + /// an overlay color will be applied to indicate elevation. + /// * [overlayColor] which computes the needed overlay color. + /// * [Material] which uses this to apply an elevation overlay to its surface. + /// * <https://material.io/design/color/dark-theme.html>, which specifies how + /// the overlay should be applied. + static Color applyOverlay(BuildContext context, Color color, double elevation) { + final ThemeData theme = Theme.of(context); + if (elevation > 0.0 && + theme.applyElevationOverlayColor && + theme.brightness == Brightness.dark && + color.withOpacity(1.0) == theme.colorScheme.surface.withOpacity(1.0)) { + return colorWithOverlay(color, theme.colorScheme.onSurface, elevation); + } + return color; + } + + /// Computes the appropriate overlay color used to indicate elevation in + /// dark themes. + /// + /// If using Material Design 3, this type of color overlay is no longer used. + /// Instead a "surface tint" overlay is used instead. See [applySurfaceTint], + /// [ThemeData.useMaterial3] for more information. + /// + /// See also: + /// + /// * https://material.io/design/color/dark-theme.html#properties which + /// specifies the exact overlay values for a given elevation. + static Color overlayColor(BuildContext context, double elevation) { + final ThemeData theme = Theme.of(context); + return _overlayColor(theme.colorScheme.onSurface, elevation); + } + + /// Returns a color blended by laying a semi-transparent overlay (using the + /// [overlay] color) on top of a surface (using the [surface] color). + /// + /// If using Material Design 3, this type of color overlay is no longer used. + /// Instead a "surface tint" overlay is used instead. See [applySurfaceTint], + /// [ThemeData.useMaterial3] for more information. + /// + /// The opacity of the overlay depends on [elevation]. As [elevation] + /// increases, the opacity will also increase. + /// + /// See https://material.io/design/color/dark-theme.html#properties. + static Color colorWithOverlay(Color surface, Color overlay, double elevation) { + return Color.alphaBlend(_overlayColor(overlay, elevation), surface); + } + + /// Applies an opacity to [color] based on [elevation]. + static Color _overlayColor(Color color, double elevation) { + // Compute the opacity for the given elevation + // This formula matches the values in the spec: + // https://material.io/design/color/dark-theme.html#properties + final double opacity = (4.5 * math.log(elevation + 1) + 2) / 100.0; + return color.withOpacity(opacity); + } +} + +// A data class to hold the opacity at a given elevation. +class _ElevationOpacity { + const _ElevationOpacity(this.elevation, this.opacity); + + final double elevation; + final double opacity; +} + +// BEGIN GENERATED TOKEN PROPERTIES - SurfaceTint + +// 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. + +// dart format off +// Surface tint opacities based on elevations according to the +// Material Design 3 specification: +// https://m3.material.io/styles/color/the-color-system/color-roles +// Ordered by increasing elevation. +const List<_ElevationOpacity> _surfaceTintElevationOpacities = <_ElevationOpacity>[ + _ElevationOpacity(0.0, 0.0), // Elevation level 0 + _ElevationOpacity(1.0, 0.05), // Elevation level 1 + _ElevationOpacity(3.0, 0.08), // Elevation level 2 + _ElevationOpacity(6.0, 0.11), // Elevation level 3 + _ElevationOpacity(8.0, 0.12), // Elevation level 4 + _ElevationOpacity(12.0, 0.14), // Elevation level 5 +]; +// dart format on + +// END GENERATED TOKEN PROPERTIES - SurfaceTint diff --git a/packages/material_ui/lib/src/expand_icon.dart b/packages/material_ui/lib/src/expand_icon.dart new file mode 100644 index 000000000000..6431d6bfedab --- /dev/null +++ b/packages/material_ui/lib/src/expand_icon.dart @@ -0,0 +1,205 @@ +// 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. + +/// @docImport 'expansion_panel.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'debug.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'material_localizations.dart'; +import 'theme.dart'; + +/// A widget representing a rotating expand/collapse button. The icon rotates +/// 180 degrees when pressed, then reverts the animation on a second press. +/// The underlying icon is [Icons.expand_more]. +/// +/// The expand icon does not include a semantic label for accessibility. In +/// order to be accessible it should be combined with a label using +/// [MergeSemantics]. This is done automatically by the [ExpansionPanel] widget. +/// +/// See [IconButton] for a more general implementation of a pressable button +/// with an icon. +/// +/// See also: +/// +/// * https://material.io/design/iconography/system-icons.html +class ExpandIcon extends StatefulWidget { + /// Creates an [ExpandIcon] with the given padding, and a callback that is + /// triggered when the icon is pressed. + const ExpandIcon({ + super.key, + this.isExpanded = false, + this.size = 24.0, + required this.onPressed, + this.padding = const EdgeInsets.all(8.0), + this.color, + this.disabledColor, + this.expandedColor, + this.splashColor, + this.highlightColor, + }); + + /// Whether the icon is in an expanded state. + /// + /// Rebuilding the widget with a different [isExpanded] value will trigger + /// the animation, but will not trigger the [onPressed] callback. + final bool isExpanded; + + /// The size of the icon. + /// + /// Defaults to 24. + final double size; + + /// The callback triggered when the icon is pressed and the state changes + /// between expanded and collapsed. The value passed to the current state. + /// + /// If this is set to null, the button will be disabled. + final ValueChanged<bool>? onPressed; + + /// The padding around the icon. The entire padded icon will react to input + /// gestures. + /// + /// Defaults to a padding of 8 on all sides. + final EdgeInsetsGeometry padding; + + /// {@template flutter.material.ExpandIcon.color} + /// The color of the icon. + /// + /// Defaults to [Colors.black54] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white60] when it is [Brightness.dark]. This adheres to the + /// Material Design specifications for [icons](https://material.io/design/iconography/system-icons.html#color) + /// and for [dark theme](https://material.io/design/color/dark-theme.html#ui-application) + /// {@endtemplate} + final Color? color; + + /// The color of the icon when it is disabled, + /// i.e. if [onPressed] is null. + /// + /// Defaults to [Colors.black38] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white38] when it is [Brightness.dark]. This adheres to the + /// Material Design specifications for [icons](https://material.io/design/iconography/system-icons.html#color) + /// and for [dark theme](https://material.io/design/color/dark-theme.html#ui-application) + final Color? disabledColor; + + /// The color of the icon when the icon is expanded. + /// + /// Defaults to [Colors.black54] when the theme's + /// [ThemeData.brightness] is [Brightness.light] and to + /// [Colors.white] when it is [Brightness.dark]. This adheres to the + /// Material Design specifications for [icons](https://material.io/design/iconography/system-icons.html#color) + /// and for [dark theme](https://material.io/design/color/dark-theme.html#ui-application) + final Color? expandedColor; + + /// Defines the splash color of the IconButton. + /// + /// If [ThemeData.useMaterial3] is true, this field will be ignored, + /// as [IconButton.splashColor] will be ignored, and you should use + /// [highlightColor] instead. + /// + /// Defaults to [ThemeData.splashColor]. + final Color? splashColor; + + /// Defines the highlight color of the IconButton. + /// + /// Defaults to [ThemeData.highlightColor]. + final Color? highlightColor; + + @override + State<ExpandIcon> createState() => _ExpandIconState(); +} + +class _ExpandIconState extends State<ExpandIcon> with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _iconTurns; + + static final Animatable<double> _iconTurnTween = Tween<double>( + begin: 0.0, + end: 0.5, + ).chain(CurveTween(curve: Curves.fastOutSlowIn)); + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: kThemeAnimationDuration, vsync: this); + _iconTurns = _controller.drive(_iconTurnTween); + // If the widget is initially expanded, rotate the icon without animating it. + if (widget.isExpanded) { + _controller.value = math.pi; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(ExpandIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded != oldWidget.isExpanded) { + if (widget.isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + void _handlePressed() { + widget.onPressed?.call(widget.isExpanded); + } + + /// Default icon colors and opacities for when [ThemeData.brightness] is set to + /// [Brightness.light] are based on the + /// [Material Design system icon specifications](https://material.io/design/iconography/system-icons.html#color). + /// Icon colors and opacities for [Brightness.dark] are based on the + /// [Material Design dark theme specifications](https://material.io/design/color/dark-theme.html#ui-application) + Color get _iconColor { + if (widget.isExpanded && widget.expandedColor != null) { + return widget.expandedColor!; + } + + if (widget.color != null) { + return widget.color!; + } + + return switch (Theme.brightnessOf(context)) { + Brightness.light => Colors.black54, + Brightness.dark => Colors.white60, + }; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String onTapHint = widget.isExpanded + ? localizations.expandedIconTapHint + : localizations.collapsedIconTapHint; + + return Semantics( + onTapHint: widget.onPressed == null ? null : onTapHint, + child: IconButton( + padding: widget.padding, + iconSize: widget.size, + highlightColor: widget.highlightColor, + splashColor: widget.splashColor, + color: _iconColor, + disabledColor: widget.disabledColor, + onPressed: widget.onPressed == null ? null : _handlePressed, + icon: RotationTransition(turns: _iconTurns, child: const Icon(Icons.expand_more)), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/expansion_panel.dart b/packages/material_ui/lib/src/expansion_panel.dart new file mode 100644 index 000000000000..aa8847c46920 --- /dev/null +++ b/packages/material_ui/lib/src/expansion_panel.dart @@ -0,0 +1,491 @@ +// 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. + +/// @docImport 'divider_theme.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'constants.dart'; +import 'expand_icon.dart'; +import 'icon_button.dart'; +import 'ink_well.dart'; +import 'material_localizations.dart'; +import 'mergeable_material.dart'; +import 'shadows.dart'; +import 'theme.dart'; + +const double _kPanelHeaderCollapsedHeight = kMinInteractiveDimension; +const EdgeInsets _kPanelHeaderExpandedDefaultPadding = EdgeInsets.symmetric( + vertical: 64.0 - _kPanelHeaderCollapsedHeight, +); +const EdgeInsets _kExpandIconPadding = EdgeInsets.all(12.0); + +class _SaltedKey<S, V> extends LocalKey { + const _SaltedKey(this.salt, this.value); + + final S salt; + final V value; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is _SaltedKey<S, V> && other.salt == salt && other.value == value; + } + + @override + int get hashCode => Object.hash(runtimeType, salt, value); + + @override + String toString() { + final saltString = S == String ? "<'$salt'>" : '<$salt>'; + final valueString = V == String ? "<'$value'>" : '<$value>'; + return '[$saltString $valueString]'; + } +} + +/// Signature for the callback that's called when an [ExpansionPanel] is +/// expanded or collapsed. +/// +/// The position of the panel within an [ExpansionPanelList] is given by +/// [panelIndex]. +typedef ExpansionPanelCallback = void Function(int panelIndex, bool isExpanded); + +/// Signature for the callback that's called when the header of the +/// [ExpansionPanel] needs to rebuild. +typedef ExpansionPanelHeaderBuilder = Widget Function(BuildContext context, bool isExpanded); + +/// A material expansion panel. It has a header and a body and can be either +/// expanded or collapsed. The body of the panel is only visible when it is +/// expanded. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=2aJZzRMziJc} +/// +/// Expansion panels are only intended to be used as children for +/// [ExpansionPanelList]. +/// +/// See [ExpansionPanelList] for a sample implementation. +/// +/// See also: +/// +/// * [ExpansionPanelList] +/// * <https://material.io/design/components/lists.html#types> +class ExpansionPanel { + /// Creates an expansion panel to be used as a child for [ExpansionPanelList]. + /// See [ExpansionPanelList] for an example on how to use this widget. + ExpansionPanel({ + required this.headerBuilder, + required this.body, + this.isExpanded = false, + this.canTapOnHeader = false, + this.backgroundColor, + this.splashColor, + this.highlightColor, + }); + + /// The widget builder that builds the expansion panels' header. + final ExpansionPanelHeaderBuilder headerBuilder; + + /// The body of the expansion panel that's displayed below the header. + /// + /// This widget is visible only when the panel is expanded. + final Widget body; + + /// Whether the panel is expanded. + /// + /// Defaults to false. + final bool isExpanded; + + /// Defines the splash color of the panel if [canTapOnHeader] is true, + /// or the splash color of the expand/collapse IconButton if [canTapOnHeader] + /// is false. + /// + /// If [canTapOnHeader] is false, and [ThemeData.useMaterial3] is + /// true, this field will be ignored, as [IconButton.splashColor] + /// will be ignored, and you should use [highlightColor] instead. + /// + /// If this is null, then the icon button will use its default splash color + /// [ThemeData.splashColor], and the panel will use its default splash color + /// [ThemeData.splashColor] (if [canTapOnHeader] is true). + final Color? splashColor; + + /// Defines the highlight color of the panel if [canTapOnHeader] is true, or + /// the highlight color of the expand/collapse IconButton if [canTapOnHeader] + /// is false. + /// + /// If this is null, then the icon button will use its default highlight color + /// [ThemeData.highlightColor], and the panel will use its default highlight + /// color [ThemeData.highlightColor] (if [canTapOnHeader] is true). + final Color? highlightColor; + + /// Whether tapping on the panel's header will expand/collapse it. + /// + /// Defaults to false. + final bool canTapOnHeader; + + /// Defines the background color of the panel. + /// + /// Defaults to [ThemeData.cardColor]. + final Color? backgroundColor; +} + +/// An expansion panel that allows for radio-like functionality. +/// This means that at any given time, at most, one [ExpansionPanelRadio] +/// can remain expanded. +/// +/// A unique identifier [value] must be assigned to each panel. +/// This identifier allows the [ExpansionPanelList] to determine +/// which [ExpansionPanelRadio] instance should be expanded. +/// +/// See [ExpansionPanelList.radio] for a sample implementation. +class ExpansionPanelRadio extends ExpansionPanel { + /// An expansion panel that allows for radio functionality. + /// + /// A unique [value] must be passed into the constructor. + ExpansionPanelRadio({ + required this.value, + required super.headerBuilder, + required super.body, + super.canTapOnHeader, + super.backgroundColor, + super.splashColor, + super.highlightColor, + }); + + /// The value that uniquely identifies a radio panel so that the currently + /// selected radio panel can be identified. + final Object value; +} + +/// A material expansion panel list that lays out its children and animates +/// expansions. +/// +/// The [expansionCallback] is called when the expansion state changes. For +/// normal [ExpansionPanelList] widgets, it is the responsibility of the parent +/// widget to rebuild the [ExpansionPanelList] with updated values for +/// [ExpansionPanel.isExpanded]. For [ExpansionPanelList.radio] widgets, the +/// open state is tracked internally and the callback is invoked both for the +/// previously open panel, which is closing, and the previously closed panel, +/// which is opening. +/// +/// {@tool dartpad} +/// Here is a simple example of how to use [ExpansionPanelList]. +/// +/// ** See code in examples/api/lib/material/expansion_panel/expansion_panel_list.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ExpansionPanel], which is used in the [children] property. +/// * [ExpansionPanelList.radio], a variant of this widget where only one panel is open at a time. +/// * <https://material.io/design/components/lists.html#types> +class ExpansionPanelList extends StatefulWidget { + /// Creates an expansion panel list widget. The [expansionCallback] is + /// triggered when an expansion panel expand/collapse button is pushed. + const ExpansionPanelList({ + super.key, + this.children = const <ExpansionPanel>[], + this.expansionCallback, + this.animationDuration = kThemeAnimationDuration, + this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding, + this.dividerColor, + this.elevation = 2, + this.expandIconColor, + this.materialGapSize = 16.0, + }) : _allowOnlyOnePanelOpen = false, + initialOpenPanelValue = null; + + /// Creates a radio expansion panel list widget. + /// + /// This widget allows for at most one panel in the list to be open. The + /// expansion panel callback is triggered when an expansion panel + /// expand/collapse button is pushed. The [children] objects must be instances + /// of [ExpansionPanelRadio]. + /// + /// {@tool dartpad} + /// Here is a simple example of how to implement ExpansionPanelList.radio. + /// + /// ** See code in examples/api/lib/material/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0.dart ** + /// {@end-tool} + const ExpansionPanelList.radio({ + super.key, + this.children = const <ExpansionPanelRadio>[], + this.expansionCallback, + this.animationDuration = kThemeAnimationDuration, + this.initialOpenPanelValue, + this.expandedHeaderPadding = _kPanelHeaderExpandedDefaultPadding, + this.dividerColor, + this.elevation = 2, + this.expandIconColor, + this.materialGapSize = 16.0, + }) : _allowOnlyOnePanelOpen = true; + + /// The children of the expansion panel list. They are laid out in a similar + /// fashion to [ListBody]. + final List<ExpansionPanel> children; + + /// The callback that gets called whenever one of the expand/collapse buttons + /// is pressed. The arguments passed to the callback are the index of the + /// pressed panel and whether the panel is currently expanded or not. + /// + /// If [ExpansionPanelList.radio] is used, the callback may be called a + /// second time if a different panel was previously open. The arguments + /// passed to the second callback are the index of the panel that will close + /// and false, marking that it will be closed. + /// + /// For [ExpansionPanelList], the callback should call [State.setState] when + /// it is notified about the closing/opening panel. On the other hand, the + /// callback for [ExpansionPanelList.radio] is intended to inform the parent + /// widget of changes, as the radio panels' open/close states are managed + /// internally. + /// + /// This callback is useful in order to keep track of the expanded/collapsed + /// panels in a parent widget that may need to react to these changes. + final ExpansionPanelCallback? expansionCallback; + + /// The duration of the expansion animation. + final Duration animationDuration; + + // Whether multiple panels can be open simultaneously + final bool _allowOnlyOnePanelOpen; + + /// The value of the panel that initially begins open. (This value is + /// only used when initializing with the [ExpansionPanelList.radio] + /// constructor.) + final Object? initialOpenPanelValue; + + /// The padding that surrounds the panel header when expanded. + /// + /// By default, 16px of space is added to the header vertically (above and below) + /// during expansion. + final EdgeInsets expandedHeaderPadding; + + /// Defines color for the divider when [ExpansionPanel.isExpanded] is false. + /// + /// If [dividerColor] is null, then [DividerThemeData.color] is used. If that + /// is null, then [ThemeData.dividerColor] is used. + final Color? dividerColor; + + /// Defines elevation for the [ExpansionPanel] while it's expanded. + /// + /// By default, the value of elevation is 2. + final double elevation; + + /// {@macro flutter.material.ExpandIcon.color} + final Color? expandIconColor; + + /// Defines the [MaterialGap.size] of the [MaterialGap] which is placed + /// between the [ExpansionPanelList.children] when they're expanded. + /// + /// Defaults to `16.0`. + final double materialGapSize; + + @override + State<StatefulWidget> createState() => _ExpansionPanelListState(); +} + +class _ExpansionPanelListState extends State<ExpansionPanelList> { + ExpansionPanelRadio? _currentOpenPanel; + + @override + void initState() { + super.initState(); + if (widget._allowOnlyOnePanelOpen) { + assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.'); + if (widget.initialOpenPanelValue != null) { + _currentOpenPanel = searchPanelByValue( + widget.children.cast<ExpansionPanelRadio>(), + widget.initialOpenPanelValue, + ); + } + } + } + + @override + void didUpdateWidget(ExpansionPanelList oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget._allowOnlyOnePanelOpen) { + assert(_allIdentifiersUnique(), 'All ExpansionPanelRadio identifier values must be unique.'); + // If the previous widget was non-radio ExpansionPanelList, initialize the + // open panel to widget.initialOpenPanelValue + if (!oldWidget._allowOnlyOnePanelOpen) { + _currentOpenPanel = searchPanelByValue( + widget.children.cast<ExpansionPanelRadio>(), + widget.initialOpenPanelValue, + ); + } + } else { + _currentOpenPanel = null; + } + } + + bool _allIdentifiersUnique() { + final identifierMap = <Object, bool>{}; + for (final ExpansionPanelRadio child in widget.children.cast<ExpansionPanelRadio>()) { + identifierMap[child.value] = true; + } + return identifierMap.length == widget.children.length; + } + + bool _isChildExpanded(int index) { + if (widget._allowOnlyOnePanelOpen) { + final radioWidget = widget.children[index] as ExpansionPanelRadio; + return _currentOpenPanel?.value == radioWidget.value; + } + return widget.children[index].isExpanded; + } + + void _handlePressed(bool isExpanded, int index) { + if (widget._allowOnlyOnePanelOpen) { + final pressedChild = widget.children[index] as ExpansionPanelRadio; + + // If another ExpansionPanelRadio was already open, apply its + // expansionCallback (if any) to false, because it's closing. + for (var childIndex = 0; childIndex < widget.children.length; childIndex += 1) { + final child = widget.children[childIndex] as ExpansionPanelRadio; + if (widget.expansionCallback != null && + childIndex != index && + child.value == _currentOpenPanel?.value) { + widget.expansionCallback!(childIndex, false); + } + } + + setState(() { + _currentOpenPanel = isExpanded ? null : pressedChild; + }); + } + // !isExpanded is passed because, when _handlePressed, the state of the panel to expand is not yet expanded. + widget.expansionCallback?.call(index, !isExpanded); + } + + ExpansionPanelRadio? searchPanelByValue(List<ExpansionPanelRadio> panels, Object? value) { + for (final panel in panels) { + if (panel.value == value) { + return panel; + } + } + return null; + } + + @override + Widget build(BuildContext context) { + assert( + kElevationToShadow.containsKey(widget.elevation), + 'Invalid value for elevation. See the kElevationToShadow constant for' + ' possible elevation values.', + ); + + final items = <MergeableMaterialItem>[]; + + for (var index = 0; index < widget.children.length; index += 1) { + if (_isChildExpanded(index) && index != 0 && !_isChildExpanded(index - 1)) { + items.add( + MaterialGap( + key: _SaltedKey<BuildContext, int>(context, index * 2 - 1), + size: widget.materialGapSize, + ), + ); + } + + final ExpansionPanel child = widget.children[index]; + final Widget headerWidget = child.headerBuilder(context, _isChildExpanded(index)); + + Widget expandIconPadded = Padding( + padding: const EdgeInsetsDirectional.only(end: 8.0), + child: IgnorePointer( + ignoring: child.canTapOnHeader, + child: ExpandIcon( + color: widget.expandIconColor, + isExpanded: _isChildExpanded(index), + padding: _kExpandIconPadding, + splashColor: child.splashColor, + highlightColor: child.highlightColor, + onPressed: (bool isExpanded) => _handlePressed(isExpanded, index), + ), + ), + ); + + if (!child.canTapOnHeader) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + expandIconPadded = Semantics( + label: _isChildExpanded(index) + ? localizations.expandedIconTapHint + : localizations.collapsedIconTapHint, + container: true, + child: expandIconPadded, + ); + } + Widget header = Row( + children: <Widget>[ + Expanded( + child: AnimatedContainer( + duration: widget.animationDuration, + curve: Curves.fastOutSlowIn, + margin: _isChildExpanded(index) ? widget.expandedHeaderPadding : EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kPanelHeaderCollapsedHeight), + child: headerWidget, + ), + ), + ), + expandIconPadded, + ], + ); + if (child.canTapOnHeader) { + header = MergeSemantics( + child: InkWell( + splashColor: child.splashColor, + highlightColor: child.highlightColor, + onTap: () => _handlePressed(_isChildExpanded(index), index), + child: header, + ), + ); + } + items.add( + MaterialSlice( + key: _SaltedKey<BuildContext, int>(context, index * 2), + color: child.backgroundColor, + child: Column( + children: <Widget>[ + header, + AnimatedCrossFade( + firstChild: const LimitedBox( + maxWidth: 0.0, + child: SizedBox(width: double.infinity, height: 0), + ), + secondChild: child.body, + firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn), + secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn), + sizeCurve: Curves.fastOutSlowIn, + crossFadeState: _isChildExpanded(index) + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: widget.animationDuration, + ), + ], + ), + ), + ); + + if (_isChildExpanded(index) && index != widget.children.length - 1) { + items.add( + MaterialGap( + key: _SaltedKey<BuildContext, int>(context, index * 2 + 1), + size: widget.materialGapSize, + ), + ); + } + } + + return MergeableMaterial( + hasDividers: true, + dividerColor: widget.dividerColor, + elevation: widget.elevation, + children: items, + ); + } +} diff --git a/packages/material_ui/lib/src/expansion_tile.dart b/packages/material_ui/lib/src/expansion_tile.dart new file mode 100644 index 000000000000..2fc32449ed20 --- /dev/null +++ b/packages/material_ui/lib/src/expansion_tile.dart @@ -0,0 +1,928 @@ +// 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. + +/// @docImport 'circle_avatar.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'expansion_tile_theme.dart'; +import 'icons.dart'; +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +const Duration _kExpand = Duration(milliseconds: 200); + +/// Enables control over a single [ExpansionTile]'s expanded/collapsed state. +/// +/// It can be useful to expand or collapse an [ExpansionTile] +/// programmatically, for example to reconfigure an existing expansion +/// tile based on a system event. To do so, create an [ExpansionTile] +/// with an [ExpansionTileController] that's owned by a stateful widget +/// or look up the tile's automatically created [ExpansionTileController] +/// with [ExpansibleController.of]. +/// +/// {@tool dartpad} +/// Typical usage of the [ExpansibleController.of] function is to call it from within the +/// `build` method of a descendant of an [ExpansionTile]. +/// +/// When the [ExpansionTile] is actually created in the same `build` +/// function as the callback that refers to the controller, then the +/// `context` argument to the `build` function can't be used to find +/// the [ExpansionTileController] (since it's "above" the widget +/// being returned in the widget tree). In cases like that you can +/// add a [Builder] widget, which provides a new scope with a +/// [BuildContext] that is "under" the [ExpansionTile]: +/// +/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.1.dart ** +/// {@end-tool} +/// +/// A more efficient solution is to split your build function into +/// several widgets. This introduces a new context from which you +/// can obtain the [ExpansionTileController]. With this approach you +/// would have an outer widget that creates the [ExpansionTile] +/// populated by instances of your new inner widgets, and then in +/// these inner widgets you would use `ExpansionTileController.of`. +/// +/// The [ExpansibleController.expand] and [ExpansibleController.collapse] +/// methods cause the [ExpansionTile] to rebuild, so they may not be called from +/// a build method. +/// +/// Remember to dispose of the [ExpansionTileController] when it is no longer +/// needed. This will ensure we discard any resources used by the object. +@Deprecated( + 'Use ExpansibleController instead. ' + 'This feature was deprecated after v3.31.0-0.1.pre.', +) +typedef ExpansionTileController = ExpansibleController; + +/// A single-line [ListTile] with an expansion arrow icon that expands or collapses +/// the tile to reveal or hide the [children]. +/// +/// This widget is typically used with [ListView] to create an "expand / +/// collapse" list entry. When used with scrolling widgets like [ListView], a +/// unique [PageStorageKey] must be specified as the [key], to enable the +/// [ExpansionTile] to save and restore its expanded state when it is scrolled +/// in and out of view. +/// +/// This class overrides the [ListTileThemeData.iconColor] and [ListTileThemeData.textColor] +/// theme properties for its [ListTile]. These colors animate between values when +/// the tile is expanded and collapsed: between [iconColor], [collapsedIconColor] and +/// between [textColor] and [collapsedTextColor]. +/// +/// The expansion arrow icon is shown on the right by default in left-to-right languages +/// (i.e. the trailing edge). This can be changed using [controlAffinity]. This maps +/// to the [leading] and [trailing] properties of [ExpansionTile]. +/// +/// {@tool dartpad} +/// This example demonstrates how the [ExpansionTile] icon's location and appearance +/// can be customized. +/// +/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example demonstrates how an [ExpansibleController] can be used to +/// programmatically expand or collapse an [ExpansionTile]. +/// +/// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.1.dart ** +/// {@end-tool} +/// +/// ## Accessibility +/// +/// The accessibility behavior of [ExpansionTile] is platform adaptive, based on +/// the device's actual platform rather than the theme's platform setting. This +/// ensures that assistive technologies like VoiceOver on iOS and macOS receive +/// the correct platform-specific semantics hints, even when the app's theme is +/// configured to mimic a different platform's appearance. +/// +/// See also: +/// +/// * [ListTile], useful for creating expansion tile [children] when the +/// expansion tile represents a sublist. +/// * The "Expand and collapse" section of +/// <https://material.io/components/lists#types> +class ExpansionTile extends StatefulWidget { + /// Creates a single-line [ListTile] with an expansion arrow icon that expands or collapses + /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must + /// be non-null. + const ExpansionTile({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.onExpansionChanged, + this.children = const <Widget>[], + this.trailing, + this.showTrailingIcon = true, + this.initiallyExpanded = false, + this.maintainState = false, + this.tilePadding, + this.expandedCrossAxisAlignment, + this.expandedAlignment, + this.childrenPadding, + this.backgroundColor, + this.collapsedBackgroundColor, + this.textColor, + this.collapsedTextColor, + this.iconColor, + this.collapsedIconColor, + this.shape, + this.collapsedShape, + this.clipBehavior, + this.controlAffinity, + this.controller, + this.dense, + this.splashColor, + this.visualDensity, + this.minTileHeight, + this.enableFeedback = true, + this.enabled = true, + this.expansionAnimationStyle, + this.internalAddSemanticForOnTap = false, + this.statesController, + }) : assert( + expandedCrossAxisAlignment != CrossAxisAlignment.baseline, + 'CrossAxisAlignment.baseline is not supported since the expanded children ' + 'are aligned in a column, not a row. Try to use another constant.', + ); + + /// A widget to display before the title. + /// + /// Typically a [CircleAvatar] widget. + /// + /// Depending on the value of [controlAffinity], the [leading] widget + /// may replace the rotating expansion arrow icon. + final Widget? leading; + + /// The primary content of the list item. + /// + /// Typically a [Text] widget. + final Widget title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// Called when the tile expands or collapses. + /// + /// When the tile starts expanding, this function is called with the value + /// true. When the tile starts collapsing, this function is called with + /// the value false. + /// + /// Instead of providing this property, consider adding this callback as a + /// listener to a provided [controller]. + final ValueChanged<bool>? onExpansionChanged; + + /// The widgets that are displayed when the tile expands. + /// + /// Typically [ListTile] widgets. + final List<Widget> children; + + /// The color to display behind the sublist when expanded. + /// + /// If this property is null then [ExpansionTileThemeData.backgroundColor] is used. If that + /// is also null then Colors.transparent is used. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Color? backgroundColor; + + /// When not null, defines the background color of tile when the sublist is collapsed. + /// + /// If this property is null then [ExpansionTileThemeData.collapsedBackgroundColor] is used. + /// If that is also null then Colors.transparent is used. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Color? collapsedBackgroundColor; + + /// A widget to display after the title. + /// + /// Depending on the value of [controlAffinity], the [trailing] widget + /// may replace the rotating expansion arrow icon. + final Widget? trailing; + + /// Specifies if the [ExpansionTile] should build a default trailing icon if [trailing] is null. + final bool showTrailingIcon; + + /// Specifies if the list tile is initially expanded (true) or collapsed (false). + /// + /// Alternatively, a provided [controller] can be used to initially expand the + /// tile if [ExpansibleController.expand] is called before this widget is built. + /// + /// Defaults to false. + final bool initiallyExpanded; + + /// Specifies whether the state of the children is maintained when the tile expands and collapses. + /// + /// When true, the children are kept in the tree while the tile is collapsed. + /// When false (default), the children are removed from the tree when the tile is + /// collapsed and recreated upon expansion. + final bool maintainState; + + /// Specifies padding for the [ListTile]. + /// + /// Analogous to [ListTile.contentPadding], this property defines the insets for + /// the [leading], [title], [subtitle] and [trailing] widgets. It does not inset + /// the expanded [children] widgets. + /// + /// If this property is null then [ExpansionTileThemeData.tilePadding] is used. If that + /// is also null then the tile's padding is `EdgeInsets.symmetric(horizontal: 16.0)`. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final EdgeInsetsGeometry? tilePadding; + + /// Specifies the alignment of [children], which are arranged in a column when + /// the tile is expanded. + /// + /// The internals of the expanded tile make use of a [Column] widget for + /// [children], and [Align] widget to align the column. The [expandedAlignment] + /// parameter is passed directly into the [Align]. + /// + /// Modifying this property controls the alignment of the column within the + /// expanded tile, not the alignment of [children] widgets within the column. + /// To align each child within [children], see [expandedCrossAxisAlignment]. + /// + /// The width of the column is the width of the widest child widget in [children]. + /// + /// If this property is null then [ExpansionTileThemeData.expandedAlignment]is used. If that + /// is also null then the value of [expandedAlignment] is [Alignment.center]. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final AlignmentGeometry? expandedAlignment; + + /// Specifies the alignment of each child within [children] when the tile is expanded. + /// + /// The internals of the expanded tile make use of a [Column] widget for + /// [children], and the `crossAxisAlignment` parameter is passed directly into + /// the [Column]. + /// + /// Modifying this property controls the cross axis alignment of each child + /// within its [Column]. The width of the [Column] that houses [children] will + /// be the same as the widest child widget in [children]. The width of the + /// [Column] might not be equal to the width of the expanded tile. + /// + /// To align the [Column] along the expanded tile, use the [expandedAlignment] + /// property instead. + /// + /// When the value is null, the value of [expandedCrossAxisAlignment] is + /// [CrossAxisAlignment.center]. + final CrossAxisAlignment? expandedCrossAxisAlignment; + + /// Specifies padding for [children]. + /// + /// If this property is null then [ExpansionTileThemeData.childrenPadding] is used. If that + /// is also null then the value of [childrenPadding] is [EdgeInsets.zero]. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final EdgeInsetsGeometry? childrenPadding; + + /// The icon color of tile's expansion arrow icon when the sublist is expanded. + /// + /// Used to override to the [ListTileThemeData.iconColor]. + /// + /// If this property is null then [ExpansionTileThemeData.iconColor] is used. If that + /// is also null then the value of [ColorScheme.primary] is used. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Color? iconColor; + + /// The icon color of tile's expansion arrow icon when the sublist is collapsed. + /// + /// Used to override to the [ListTileThemeData.iconColor]. + /// + /// If this property is null then [ExpansionTileThemeData.collapsedIconColor] is used. If that + /// is also null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurface] is used. Otherwise, + /// defaults to [ThemeData.unselectedWidgetColor] color. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Color? collapsedIconColor; + + /// The color of the tile's titles when the sublist is expanded. + /// + /// Used to override to the [ListTileThemeData.textColor]. + /// + /// If this property is null then [ExpansionTileThemeData.textColor] is used. If that + /// is also null then and [ThemeData.useMaterial3] is true, color of the [TextTheme.bodyLarge] + /// will be used for the [title] and [subtitle]. Otherwise, defaults to [ColorScheme.primary] color. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Color? textColor; + + /// The color of the tile's titles when the sublist is collapsed. + /// + /// Used to override to the [ListTileThemeData.textColor]. + /// + /// If this property is null then [ExpansionTileThemeData.collapsedTextColor] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, color of the + /// [TextTheme.bodyLarge] will be used for the [title] and [subtitle]. Otherwise, + /// defaults to color of the [TextTheme.titleMedium]. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Color? collapsedTextColor; + + /// The tile's border shape when the sublist is expanded. + /// + /// If this property is null, the [ExpansionTileThemeData.shape] is used. If that + /// is also null, a [Border] with vertical sides default to [ThemeData.dividerColor] is used + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final ShapeBorder? shape; + + /// The tile's border shape when the sublist is collapsed. + /// + /// If this property is null, the [ExpansionTileThemeData.collapsedShape] is used. If that + /// is also null, a [Border] with vertical sides default to Color [Colors.transparent] is used + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final ShapeBorder? collapsedShape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// If this is not null and a custom collapsed or expanded shape is provided, + /// the value of [clipBehavior] will be used to clip the expansion tile. + /// + /// If this property is null, the [ExpansionTileThemeData.clipBehavior] is used. If that + /// is also null, defaults to [Clip.antiAlias]. + /// + /// See also: + /// + /// * [ExpansionTileTheme.of], which returns the nearest [ExpansionTileTheme]'s + /// [ExpansionTileThemeData]. + final Clip? clipBehavior; + + /// Typically used to force the expansion arrow icon to the tile's leading or trailing edge. + /// + /// By default, the value of [controlAffinity] is [ListTileControlAffinity.platform], + /// which means that the expansion arrow icon will appear on the tile's trailing edge. + final ListTileControlAffinity? controlAffinity; + + /// If provided, the controller can be used to expand and collapse tiles. + /// + /// In cases where control over the tile's state is needed from a callback + /// triggered by a widget within the tile, [ExpansibleController.of] may be + /// more convenient than supplying a controller. + final ExpansibleController? controller; + + /// {@macro flutter.material.ListTile.dense} + final bool? dense; + + /// The splash color of the ink response when the tile is tapped. + /// + /// This color is passed directly to the underlying [ListTile]'s + /// `splashColor` property, which controls the ink ripple (splash) + /// animation when the tile is tapped. Internally, [ListTile] uses + /// an [InkWell] (which handles the actual splash effect), and so the + /// provided color will apply to that ripple. + /// + /// If null, the splash color will default to the current theme’s + /// `ThemeData.splashColor`. + /// + /// See also: + /// + /// * [ListTile.splashColor], which sets the ink splash for the tile. + /// * [InkWell.splashColor], which determines the color of the ripple + /// effect in Material widgets. + /// * [ThemeData.splashColor], which provides a fallback color. + final Color? splashColor; + + /// Defines how compact the expansion tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + final VisualDensity? visualDensity; + + /// {@macro flutter.material.ListTile.minTileHeight} + final double? minTileHeight; + + /// {@macro flutter.material.ListTile.enableFeedback} + final bool? enableFeedback; + + /// Whether this expansion tile is interactive. + /// + /// If false, the internal [ListTile] will be disabled, changing its + /// appearance according to the theme and disabling user interaction. + /// + /// Even if disabled, the expansion can still be toggled programmatically + /// through an [ExpansionTileController]. + final bool enabled; + + /// Used to override the expansion animation curve and duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the expansion animation duration. If it is null, then [AnimationStyle.duration] + /// from the [ExpansionTileThemeData.expansionAnimationStyle] will be used. + /// Otherwise, defaults to 200ms. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the expansion animation curve. If it is null, then [AnimationStyle.curve] + /// from the [ExpansionTileThemeData.expansionAnimationStyle] will be used. + /// Otherwise, defaults to [Curves.easeIn]. + /// + /// If [AnimationStyle.reverseCurve] is provided, it will be used to override + /// the collapse animation curve. If it is null, then [AnimationStyle.reverseCurve] + /// from the [ExpansionTileThemeData.expansionAnimationStyle] will be used. + /// Otherwise, the same curve will be used as for expansion. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override the [ExpansionTile] expansion + /// animation curve and duration using [AnimationStyle]. + /// + /// ** See code in examples/api/lib/material/expansion_tile/expansion_tile.2.dart ** + /// {@end-tool} + final AnimationStyle? expansionAnimationStyle; + + /// Whether to add button:true to the semantics if onTap is provided. + /// This is a temporary flag to help changing the behavior of ListTile onTap semantics. + /// + // TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping + // the default value to true. + final bool internalAddSemanticForOnTap; + + /// The controller that notifies when the widget's [WidgetState]s change. + /// + /// This allows listening to and controlling states such as + /// [WidgetState.hovered], [WidgetState.focused], [WidgetState.pressed], + /// and [WidgetState.disabled] for the tile's header. + /// + /// If null, the backing [ListTile] will create and manage its own + /// [WidgetStatesController]. + final WidgetStatesController? statesController; + + @override + State<ExpansionTile> createState() => _ExpansionTileState(); +} + +class _ExpansionTileState extends State<ExpansionTile> { + static final Animatable<double> _easeInTween = CurveTween(curve: Curves.easeIn); + static final Animatable<double> _easeOutTween = CurveTween(curve: Curves.easeOut); + static final Animatable<double> _halfTween = Tween<double>(begin: 0.0, end: 0.5); + + final ShapeBorderTween _borderTween = ShapeBorderTween(); + final ColorTween _headerColorTween = ColorTween(); + final ColorTween _iconColorTween = ColorTween(); + final ColorTween _backgroundColorTween = ColorTween(); + + late Animation<double> _iconTurns; + late Animation<ShapeBorder?> _border; + late Animation<Color?> _headerColor; + late Animation<Color?> _iconColor; + late Animation<Color?> _backgroundColor; + + late ExpansionTileThemeData _expansionTileTheme; + late ExpansibleController _tileController; + Timer? _timer; + late Curve _curve; + late Curve? _reverseCurve; + late Duration _duration; + + @override + void initState() { + super.initState(); + _curve = Curves.easeIn; + _duration = _kExpand; + _tileController = widget.controller ?? ExpansibleController(); + if (widget.initiallyExpanded) { + _tileController.expand(); + } + _tileController.addListener(_onExpansionChanged); + } + + @override + void dispose() { + _tileController.removeListener(_onExpansionChanged); + if (widget.controller == null) { + _tileController.dispose(); + } + _timer?.cancel(); + _timer = null; + super.dispose(); + } + + void _onExpansionChanged() { + final TextDirection textDirection = WidgetsLocalizations.of(context).textDirection; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String stateHint = _tileController.isExpanded + ? localizations.collapsedHint + : localizations.expandedHint; + + if (defaultTargetPlatform == TargetPlatform.iOS) { + // TODO(tahatesser): This is a workaround for VoiceOver interrupting + // semantic announcements on iOS. https://github.com/flutter/flutter/issues/122101. + _timer?.cancel(); + _timer = Timer(const Duration(seconds: 1), () { + SemanticsService.sendAnnouncement(View.of(context), stateHint, textDirection).catchError(( + Object exception, + StackTrace stack, + ) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'material library', + context: ErrorDescription('while sending semantics announcement'), + ), + ); + }); + _timer?.cancel(); + _timer = null; + }); + } + // SemanticsService.sendAnnouncement is deprecated on android. + // We use live region to achieve the announcement effect instead. + else if (defaultTargetPlatform != TargetPlatform.android) { + SemanticsService.sendAnnouncement(View.of(context), stateHint, textDirection).catchError(( + Object exception, + StackTrace stack, + ) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'material library', + context: ErrorDescription('while sending semantics announcement'), + ), + ); + }); + } + widget.onExpansionChanged?.call(_tileController.isExpanded); + } + + // Platform or null affinity defaults to trailing. + ListTileControlAffinity _effectiveAffinity() { + final ListTileThemeData listTileTheme = ListTileTheme.of(context); + final ListTileControlAffinity affinity = + widget.controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.trailing; + switch (affinity) { + case ListTileControlAffinity.leading: + return ListTileControlAffinity.leading; + case ListTileControlAffinity.trailing: + case ListTileControlAffinity.platform: + return ListTileControlAffinity.trailing; + } + } + + Widget? _buildIcon(BuildContext context, Animation<double> animation) { + _iconTurns = animation.drive(_halfTween.chain(_easeInTween)); + return RotationTransition(turns: _iconTurns, child: const Icon(Icons.expand_more)); + } + + Widget? _buildLeadingIcon(BuildContext context, Animation<double> animation) { + if (_effectiveAffinity() != ListTileControlAffinity.leading) { + return null; + } + return _buildIcon(context, animation); + } + + Widget? _buildTrailingIcon(BuildContext context, Animation<double> animation) { + if (_effectiveAffinity() != ListTileControlAffinity.trailing) { + return null; + } + return _buildIcon(context, animation); + } + + Widget _buildHeader(BuildContext context, Animation<double> animation) { + _iconColor = animation.drive(_iconColorTween.chain(_easeInTween)); + _headerColor = animation.drive(_headerColorTween.chain(_easeInTween)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String onTapHint = _tileController.isExpanded + ? localizations.expansionTileExpandedTapHint + : localizations.expansionTileCollapsedTapHint; + final String semanticsHint = switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => + _tileController.isExpanded + ? '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}' + : '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}', + _ => _tileController.isExpanded ? localizations.collapsedHint : localizations.expandedHint, + }; + + final Widget child = ListTileTheme.merge( + iconColor: _iconColor.value ?? _expansionTileTheme.iconColor, + textColor: _headerColor.value, + child: ListTile( + enabled: widget.enabled, + onTap: _tileController.isExpanded ? _tileController.collapse : _tileController.expand, + dense: widget.dense, + splashColor: widget.splashColor, + visualDensity: widget.visualDensity, + enableFeedback: widget.enableFeedback, + contentPadding: widget.tilePadding ?? _expansionTileTheme.tilePadding, + leading: widget.leading ?? _buildLeadingIcon(context, animation), + title: widget.title, + subtitle: widget.subtitle, + trailing: widget.showTrailingIcon + ? widget.trailing ?? _buildTrailingIcon(context, animation) + : null, + minTileHeight: widget.minTileHeight, + internalAddSemanticForOnTap: widget.internalAddSemanticForOnTap, + statesController: widget.statesController, + ), + ); + + if (defaultTargetPlatform == TargetPlatform.android) { + return Semantics( + // Live region used to announce state changes (e.g., "expanded" or "collapsed") + // without taking focus. + // blockNode prevents this node from being part of the focus traversal. + label: semanticsHint, + liveRegion: true, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, + child: Semantics(hint: semanticsHint, onTapHint: onTapHint, child: child), + ); + } + return Semantics(hint: semanticsHint, onTapHint: onTapHint, child: child); + } + + Widget _buildBody(BuildContext context, Animation<double> animation) { + return Align( + alignment: + widget.expandedAlignment ?? _expansionTileTheme.expandedAlignment ?? Alignment.center, + child: Padding( + padding: widget.childrenPadding ?? _expansionTileTheme.childrenPadding ?? EdgeInsets.zero, + child: Column( + crossAxisAlignment: widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center, + children: widget.children, + ), + ), + ); + } + + Widget _buildExpansible( + BuildContext context, + Widget header, + Widget body, + Animation<double> animation, + ) { + _backgroundColor = animation.drive(_backgroundColorTween.chain(_easeOutTween)); + _border = animation.drive(_borderTween.chain(_easeOutTween)); + final Color backgroundColor = + _backgroundColor.value ?? _expansionTileTheme.backgroundColor ?? Colors.transparent; + final ShapeBorder expansionTileBorder = + _border.value ?? + const Border( + top: BorderSide(color: Colors.transparent), + bottom: BorderSide(color: Colors.transparent), + ); + final Clip clipBehavior = + widget.clipBehavior ?? _expansionTileTheme.clipBehavior ?? Clip.antiAlias; + + final Decoration decoration = ShapeDecoration( + color: backgroundColor, + shape: expansionTileBorder, + ); + + Widget tile = Padding( + padding: decoration.padding, + child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[header, body]), + ); + + final bool isShapeProvided = + widget.shape != null || + _expansionTileTheme.shape != null || + widget.collapsedShape != null || + _expansionTileTheme.collapsedShape != null; + + if (isShapeProvided) { + return Material( + clipBehavior: clipBehavior, + color: backgroundColor, + shape: expansionTileBorder, + child: tile, + ); + } + + // If the background color is not transparent, wrap the tile in a Material widget. + // This is needed to ensure that the ListTile background color or ink splashes + // are visible. A DecoratedBox with a non-transparent color will hide the + // background color or ink splashes of the ListTile. + if (backgroundColor.a > 0) { + tile = Material(type: MaterialType.transparency, child: tile); + } + + return DecoratedBox(decoration: decoration, child: tile); + } + + @override + void didUpdateWidget(covariant ExpansionTile oldWidget) { + super.didUpdateWidget(oldWidget); + final ThemeData theme = Theme.of(context); + _expansionTileTheme = ExpansionTileTheme.of(context); + final ExpansionTileThemeData defaults = theme.useMaterial3 + ? _ExpansionTileDefaultsM3(context) + : _ExpansionTileDefaultsM2(context); + if (widget.collapsedShape != oldWidget.collapsedShape || widget.shape != oldWidget.shape) { + _updateShapeBorder(theme); + } + if (widget.collapsedTextColor != oldWidget.collapsedTextColor || + widget.textColor != oldWidget.textColor) { + _updateHeaderColor(defaults); + } + if (widget.collapsedIconColor != oldWidget.collapsedIconColor || + widget.iconColor != oldWidget.iconColor) { + _updateIconColor(defaults); + } + if (widget.backgroundColor != oldWidget.backgroundColor || + widget.collapsedBackgroundColor != oldWidget.collapsedBackgroundColor) { + _updateBackgroundColor(); + } + if (widget.expansionAnimationStyle != oldWidget.expansionAnimationStyle) { + _updateAnimationDuration(); + _updateHeightFactorCurve(); + } + if (widget.controller != oldWidget.controller) { + _tileController.removeListener(_onExpansionChanged); + if (oldWidget.controller == null) { + _tileController.dispose(); + } + + _tileController = widget.controller ?? ExpansibleController(); + _tileController.addListener(_onExpansionChanged); + } + } + + @override + void didChangeDependencies() { + final ThemeData theme = Theme.of(context); + _expansionTileTheme = ExpansionTileTheme.of(context); + final ExpansionTileThemeData defaults = theme.useMaterial3 + ? _ExpansionTileDefaultsM3(context) + : _ExpansionTileDefaultsM2(context); + _updateAnimationDuration(); + _updateShapeBorder(theme); + _updateHeaderColor(defaults); + _updateIconColor(defaults); + _updateBackgroundColor(); + _updateHeightFactorCurve(); + super.didChangeDependencies(); + } + + void _updateAnimationDuration() { + _duration = + widget.expansionAnimationStyle?.duration ?? + _expansionTileTheme.expansionAnimationStyle?.duration ?? + const Duration(milliseconds: 200); + } + + void _updateShapeBorder(ThemeData theme) { + _borderTween + ..begin = + widget.collapsedShape ?? + _expansionTileTheme.collapsedShape ?? + const Border( + top: BorderSide(color: Colors.transparent), + bottom: BorderSide(color: Colors.transparent), + ) + ..end = + widget.shape ?? + _expansionTileTheme.shape ?? + Border( + top: BorderSide(color: theme.dividerColor), + bottom: BorderSide(color: theme.dividerColor), + ); + } + + void _updateHeaderColor(ExpansionTileThemeData defaults) { + _headerColorTween + ..begin = + widget.collapsedTextColor ?? + _expansionTileTheme.collapsedTextColor ?? + defaults.collapsedTextColor + ..end = widget.textColor ?? _expansionTileTheme.textColor ?? defaults.textColor; + } + + void _updateIconColor(ExpansionTileThemeData defaults) { + _iconColorTween + ..begin = + widget.collapsedIconColor ?? + _expansionTileTheme.collapsedIconColor ?? + defaults.collapsedIconColor + ..end = widget.iconColor ?? _expansionTileTheme.iconColor ?? defaults.iconColor; + } + + void _updateBackgroundColor() { + _backgroundColorTween + ..begin = widget.collapsedBackgroundColor ?? _expansionTileTheme.collapsedBackgroundColor + ..end = widget.backgroundColor ?? _expansionTileTheme.backgroundColor; + } + + void _updateHeightFactorCurve() { + _curve = + widget.expansionAnimationStyle?.curve ?? + _expansionTileTheme.expansionAnimationStyle?.curve ?? + Curves.easeIn; + _reverseCurve = + widget.expansionAnimationStyle?.reverseCurve ?? + _expansionTileTheme.expansionAnimationStyle?.reverseCurve; + } + + @override + Widget build(BuildContext context) { + return Expansible( + controller: _tileController, + curve: _curve, + duration: _duration, + reverseCurve: _reverseCurve, + maintainState: widget.maintainState, + headerBuilder: _buildHeader, + bodyBuilder: _buildBody, + expansibleBuilder: _buildExpansible, + ); + } +} + +class _ExpansionTileDefaultsM2 extends ExpansionTileThemeData { + _ExpansionTileDefaultsM2(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colorScheme = _theme.colorScheme; + + @override + Color? get textColor => _colorScheme.primary; + + @override + Color? get iconColor => _colorScheme.primary; + + @override + Color? get collapsedTextColor => _theme.textTheme.titleMedium!.color; + + @override + Color? get collapsedIconColor => _theme.unselectedWidgetColor; +} + +// BEGIN GENERATED TOKEN PROPERTIES - ExpansionTile + +// 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. + +// dart format off +class _ExpansionTileDefaultsM3 extends ExpansionTileThemeData { + _ExpansionTileDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color? get textColor => _colors.onSurface; + + @override + Color? get iconColor => _colors.primary; + + @override + Color? get collapsedTextColor => _colors.onSurface; + + @override + Color? get collapsedIconColor => _colors.onSurfaceVariant; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - ExpansionTile diff --git a/packages/material_ui/lib/src/expansion_tile_theme.dart b/packages/material_ui/lib/src/expansion_tile_theme.dart new file mode 100644 index 000000000000..54789db23484 --- /dev/null +++ b/packages/material_ui/lib/src/expansion_tile_theme.dart @@ -0,0 +1,287 @@ +// 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. + +/// @docImport 'expansion_tile.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Used with [ExpansionTileTheme] to define default property values for +/// descendant [ExpansionTile] widgets. +/// +/// Descendant widgets obtain the current [ExpansionTileThemeData] object +/// using [ExpansionTileTheme.of]. Instances of [ExpansionTileThemeData] can +/// be customized with [ExpansionTileThemeData.copyWith]. +/// +/// A [ExpansionTileThemeData] is often specified as part of the +/// overall [Theme] with [ThemeData.expansionTileTheme]. +/// +/// All [ExpansionTileThemeData] properties are `null` by default. +/// When a theme property is null, the [ExpansionTile] will provide its own +/// default based on the overall [Theme]'s textTheme and +/// colorScheme. See the individual [ExpansionTile] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +/// * [ExpansionTileTheme] which overrides the default [ExpansionTileTheme] +/// of its [ExpansionTile] descendants. +/// * [ThemeData.textTheme], text with a color that contrasts with the card +/// and canvas colors. +/// * [ThemeData.colorScheme], the thirteen colors that most Material widget +/// default colors are based on. +@immutable +class ExpansionTileThemeData with Diagnosticable { + /// Creates a [ExpansionTileThemeData]. + const ExpansionTileThemeData({ + this.backgroundColor, + this.collapsedBackgroundColor, + this.tilePadding, + this.expandedAlignment, + this.childrenPadding, + this.iconColor, + this.collapsedIconColor, + this.textColor, + this.collapsedTextColor, + this.shape, + this.collapsedShape, + this.clipBehavior, + this.expansionAnimationStyle, + }); + + /// Overrides the default value of [ExpansionTile.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [ExpansionTile.collapsedBackgroundColor]. + final Color? collapsedBackgroundColor; + + /// Overrides the default value of [ExpansionTile.tilePadding]. + final EdgeInsetsGeometry? tilePadding; + + /// Overrides the default value of [ExpansionTile.expandedAlignment]. + final AlignmentGeometry? expandedAlignment; + + /// Overrides the default value of [ExpansionTile.childrenPadding]. + final EdgeInsetsGeometry? childrenPadding; + + /// Overrides the default value of [ExpansionTile.iconColor]. + final Color? iconColor; + + /// Overrides the default value of [ExpansionTile.collapsedIconColor]. + final Color? collapsedIconColor; + + /// Overrides the default value of [ExpansionTile.textColor]. + final Color? textColor; + + /// Overrides the default value of [ExpansionTile.collapsedTextColor]. + final Color? collapsedTextColor; + + /// Overrides the default value of [ExpansionTile.shape]. + final ShapeBorder? shape; + + /// Overrides the default value of [ExpansionTile.collapsedShape]. + final ShapeBorder? collapsedShape; + + /// Overrides the default value of [ExpansionTile.clipBehavior]. + final Clip? clipBehavior; + + /// Overrides the default value of [ExpansionTile.expansionAnimationStyle]. + final AnimationStyle? expansionAnimationStyle; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + ExpansionTileThemeData copyWith({ + Color? backgroundColor, + Color? collapsedBackgroundColor, + EdgeInsetsGeometry? tilePadding, + AlignmentGeometry? expandedAlignment, + EdgeInsetsGeometry? childrenPadding, + Color? iconColor, + Color? collapsedIconColor, + Color? textColor, + Color? collapsedTextColor, + ShapeBorder? shape, + ShapeBorder? collapsedShape, + Clip? clipBehavior, + AnimationStyle? expansionAnimationStyle, + }) { + return ExpansionTileThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + collapsedBackgroundColor: collapsedBackgroundColor ?? this.collapsedBackgroundColor, + tilePadding: tilePadding ?? this.tilePadding, + expandedAlignment: expandedAlignment ?? this.expandedAlignment, + childrenPadding: childrenPadding ?? this.childrenPadding, + iconColor: iconColor ?? this.iconColor, + collapsedIconColor: collapsedIconColor ?? this.collapsedIconColor, + textColor: textColor ?? this.textColor, + collapsedTextColor: collapsedTextColor ?? this.collapsedTextColor, + shape: shape ?? this.shape, + collapsedShape: collapsedShape ?? this.collapsedShape, + clipBehavior: clipBehavior ?? this.clipBehavior, + expansionAnimationStyle: expansionAnimationStyle ?? this.expansionAnimationStyle, + ); + } + + /// Linearly interpolate between ExpansionTileThemeData objects. + static ExpansionTileThemeData? lerp( + ExpansionTileThemeData? a, + ExpansionTileThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return ExpansionTileThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + collapsedBackgroundColor: Color.lerp( + a?.collapsedBackgroundColor, + b?.collapsedBackgroundColor, + t, + ), + tilePadding: EdgeInsetsGeometry.lerp(a?.tilePadding, b?.tilePadding, t), + expandedAlignment: AlignmentGeometry.lerp(a?.expandedAlignment, b?.expandedAlignment, t), + childrenPadding: EdgeInsetsGeometry.lerp(a?.childrenPadding, b?.childrenPadding, t), + iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), + collapsedIconColor: Color.lerp(a?.collapsedIconColor, b?.collapsedIconColor, t), + textColor: Color.lerp(a?.textColor, b?.textColor, t), + collapsedTextColor: Color.lerp(a?.collapsedTextColor, b?.collapsedTextColor, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + collapsedShape: ShapeBorder.lerp(a?.collapsedShape, b?.collapsedShape, t), + clipBehavior: t < 0.5 ? a?.clipBehavior : b?.clipBehavior, + expansionAnimationStyle: t < 0.5 ? a?.expansionAnimationStyle : b?.expansionAnimationStyle, + ); + } + + @override + int get hashCode { + return Object.hash( + backgroundColor, + collapsedBackgroundColor, + tilePadding, + expandedAlignment, + childrenPadding, + iconColor, + collapsedIconColor, + textColor, + collapsedTextColor, + shape, + collapsedShape, + clipBehavior, + expansionAnimationStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ExpansionTileThemeData && + other.backgroundColor == backgroundColor && + other.collapsedBackgroundColor == collapsedBackgroundColor && + other.tilePadding == tilePadding && + other.expandedAlignment == expandedAlignment && + other.childrenPadding == childrenPadding && + other.iconColor == iconColor && + other.collapsedIconColor == collapsedIconColor && + other.textColor == textColor && + other.collapsedTextColor == collapsedTextColor && + other.shape == shape && + other.collapsedShape == collapsedShape && + other.clipBehavior == clipBehavior && + other.expansionAnimationStyle == expansionAnimationStyle; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add( + ColorProperty('collapsedBackgroundColor', collapsedBackgroundColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('tilePadding', tilePadding, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<AlignmentGeometry>( + 'expandedAlignment', + expandedAlignment, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'childrenPadding', + childrenPadding, + defaultValue: null, + ), + ); + properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); + properties.add(ColorProperty('collapsedIconColor', collapsedIconColor, defaultValue: null)); + properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add(ColorProperty('collapsedTextColor', collapsedTextColor, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<ShapeBorder>('collapsedShape', collapsedShape, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + properties.add( + DiagnosticsProperty<AnimationStyle>( + 'expansionAnimationStyle', + expansionAnimationStyle, + defaultValue: null, + ), + ); + } +} + +/// Overrides the default [ExpansionTileTheme] of its [ExpansionTile] descendants. +/// +/// See also: +/// +/// * [ExpansionTileThemeData], which is used to configure this theme. +/// * [ThemeData.expansionTileTheme], which can be used to override the default +/// [ExpansionTileTheme] for [ExpansionTile]s below the overall [Theme]. +class ExpansionTileTheme extends InheritedTheme { + /// Applies the given theme [data] to [child]. + const ExpansionTileTheme({super.key, required this.data, required super.child}); + + /// Specifies color, alignment, and text style values for + /// descendant [ExpansionTile] widgets. + final ExpansionTileThemeData data; + + /// Retrieves the [ExpansionTileThemeData] from the closest ancestor [ExpansionTileTheme]. + /// + /// If there is no enclosing [ExpansionTileTheme] widget, then + /// [ThemeData.expansionTileTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ExpansionTileThemeData theme = ExpansionTileTheme.of(context); + /// ``` + static ExpansionTileThemeData of(BuildContext context) { + final ExpansionTileTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType<ExpansionTileTheme>(); + return inheritedTheme?.data ?? Theme.of(context).expansionTileTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ExpansionTileTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ExpansionTileTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/filled_button.dart b/packages/material_ui/lib/src/filled_button.dart new file mode 100644 index 000000000000..5d44226b4b1e --- /dev/null +++ b/packages/material_ui/lib/src/filled_button.dart @@ -0,0 +1,803 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +library; + +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 'colors.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} +/// +/// ## Visual density effects +/// +/// The button's appearance is affected by the [VisualDensity] from the enclosing +/// [Theme] or from its [style]. Visual density adjusts the [ButtonStyle.padding] +/// and [ButtonStyle.minimumSize] to accommodate different UI densities across platforms. +/// See [VisualDensity] for more details on how it affects component layout and +/// the platform-specific defaults. +/// +/// 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. +/// * <https://material.io/design/components/buttons.html> +/// * <https://m3.material.io/components/buttons> +class FilledButton extends ButtonStyleButton { + /// Create a FilledButton. + 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, + _addPadding = false; + + /// 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. + /// + /// If [icon] is null, this constructor will create a [FilledButton] + /// that doesn't display an icon. + /// + /// {@macro flutter.material.ButtonStyle.iconAlignment} + /// + FilledButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior = Clip.none, + super.statesController, + Widget? icon, + required Widget label, + IconAlignment? iconAlignment, + }) : _variant = _FilledButtonVariant.filled, + _addPadding = icon != null, + super( + child: icon != null + ? _FilledButtonWithIconChild( + label: label, + icon: icon, + buttonStyle: style, + iconAlignment: iconAlignment, + ) + : label, + ); + + /// 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. + 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, + _addPadding = false; + + /// 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. + /// + /// If [icon] is null, this constructor will create a [FilledButton] + /// that doesn't display an icon. + FilledButton.tonalIcon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior = Clip.none, + super.statesController, + Widget? icon, + required Widget label, + IconAlignment? iconAlignment, + }) : _variant = _FilledButtonVariant.tonal, + _addPadding = icon != null, + super( + child: icon != null + ? _FilledButtonWithIconChild( + label: label, + icon: icon, + buttonStyle: style, + iconAlignment: iconAlignment, + ) + : label, + ); + + /// A static convenience method that constructs a filled button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] and [disabledForegroundColor] colors are used + /// to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], and + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// If [iconColor] is null, the button icon will use [foregroundColor]. If [foregroundColor] is also + /// null, the button icon will use the default icon color. + /// + /// 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. + /// + /// All of the other parameters are either used directly or used to + /// create a [WidgetStateProperty] 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, + Color? iconColor, + double? iconSize, + IconAlignment? iconAlignment, + Color? disabledIconColor, + Color? overlayColor, + 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, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, + }) { + final WidgetStateProperty<Color?>? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, Color(a: 0.0)) => WidgetStatePropertyAll<Color?>(overlayColor), + (_, final Color color) || + (final Color color, _) => WidgetStateProperty<Color?>.fromMap(<WidgetState, Color?>{ + WidgetState.pressed: color.withOpacity(0.1), + WidgetState.hovered: color.withOpacity(0.08), + WidgetState.focused: color.withOpacity(0.1), + }), + }; + + return ButtonStyle( + textStyle: MaterialStatePropertyAll<TextStyle?>(textStyle), + backgroundColor: ButtonStyleButton.defaultColor(backgroundColor, disabledBackgroundColor), + foregroundColor: ButtonStyleButton.defaultColor(foregroundColor, disabledForegroundColor), + overlayColor: overlayColorProp, + shadowColor: ButtonStyleButton.allOrNull<Color>(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull<Color>(surfaceTintColor), + iconColor: ButtonStyleButton.defaultColor(iconColor, disabledIconColor), + iconSize: ButtonStyleButton.allOrNull<double>(iconSize), + iconAlignment: iconAlignment, + elevation: ButtonStyleButton.allOrNull(elevation), + padding: ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(padding), + minimumSize: ButtonStyleButton.allOrNull<Size>(minimumSize), + fixedSize: ButtonStyleButton.allOrNull<Size>(fixedSize), + maximumSize: ButtonStyleButton.allOrNull<Size>(maximumSize), + side: ButtonStyleButton.allOrNull<BorderSide>(side), + shape: ButtonStyleButton.allOrNull<OutlinedBorder>(shape), + mouseCursor: WidgetStateProperty<MouseCursor?>.fromMap(<WidgetStatesConstraint, MouseCursor?>{ + WidgetState.disabled: disabledMouseCursor, + WidgetState.any: enabledMouseCursor, + }), + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, + ); + } + + final _FilledButtonVariant _variant; + final bool _addPadding; + + /// 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)`. [WidgetStateProperty] 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. + /// + /// {@macro flutter.material.elevated_button.default_font_size} + /// + /// 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` + /// * `default font size <= 14` - horizontal(16) + /// * `14 < default font size <= 28` - lerp(horizontal(16), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `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` + /// * `default font size <= 14` - start(12) end(16) + /// * `14 < default font size <= 28` - lerp(start(12) end(16), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - 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. + /// + /// ## Material 3 defaults + /// + /// If [ThemeData.useMaterial3] is set to true the following defaults will + /// be used: + /// + /// * `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.1) + /// * `shadowColor` - Theme.colorScheme.shadow + /// * `surfaceTintColor` - Colors.transparent + /// * `elevation` + /// * disabled - 0 + /// * default - 1 + /// * hovered - 3 + /// * focused or pressed - 1 + /// * `padding` + /// * `default font size <= 14` - horizontal(24) + /// * `14 < default font size <= 28` - lerp(horizontal(24), horizontal(12)) + /// * `28 < default font size <= 36` - lerp(horizontal(12), horizontal(6)) + /// * `36 < default font size` - horizontal(6) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - Theme.visualDensity + /// * `tapTargetSize` - Theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + /// + /// For the [FilledButton.icon] factory, the start (generally the left) value of + /// [ButtonStyle.padding] is reduced from 24 to 16. + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ButtonStyle buttonStyle = switch (_variant) { + _FilledButtonVariant.filled => _FilledButtonDefaultsM3(context), + _FilledButtonVariant.tonal => _FilledTonalButtonDefaultsM3(context), + }; + + if (_addPadding) { + final bool useMaterial3 = Theme.of(context).useMaterial3; + final double defaultFontSize = + buttonStyle.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + + final EdgeInsetsGeometry scaledPadding = useMaterial3 + ? ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(16, 0, 24, 0), + const EdgeInsetsDirectional.fromSTEB(8, 0, 12, 0), + const EdgeInsetsDirectional.fromSTEB(4, 0, 6, 0), + effectiveTextScale, + ) + : ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsetsDirectional.fromSTEB(8, 0, 4, 0), + effectiveTextScale, + ); + return buttonStyle.copyWith( + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(scaledPadding), + ); + } + + return buttonStyle; + } + + /// Returns the [FilledButtonThemeData.style] of the closest + /// [FilledButtonTheme] ancestor. + @override + ButtonStyle? themeStyleOf(BuildContext context) { + return FilledButtonTheme.of(context).style; + } +} + +EdgeInsetsGeometry _scaledPadding(BuildContext context) { + final ThemeData theme = Theme.of(context); + final double defaultFontSize = theme.textTheme.labelLarge?.fontSize ?? 14.0; + final double effectiveTextScale = MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + final padding1x = theme.useMaterial3 ? 24.0 : 16.0; + return ButtonStyleButton.scaledPadding( + EdgeInsets.symmetric(horizontal: padding1x), + EdgeInsets.symmetric(horizontal: padding1x / 2), + EdgeInsets.symmetric(horizontal: padding1x / 2 / 2), + effectiveTextScale, + ); +} + +class _FilledButtonWithIconChild extends StatelessWidget { + const _FilledButtonWithIconChild({ + required this.label, + required this.icon, + required this.buttonStyle, + required this.iconAlignment, + }); + + final Widget label; + final Widget icon; + final ButtonStyle? buttonStyle; + final IconAlignment? iconAlignment; + + @override + Widget build(BuildContext context) { + final double defaultFontSize = + buttonStyle?.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double scale = + clampDouble(MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0, 1.0, 2.0) - 1.0; + + final FilledButtonThemeData filledButtonTheme = FilledButtonTheme.of(context); + final IconAlignment effectiveIconAlignment = + iconAlignment ?? + filledButtonTheme.style?.iconAlignment ?? + buttonStyle?.iconAlignment ?? + IconAlignment.start; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: lerpDouble(8, 4, scale)!, + children: effectiveIconAlignment == IconAlignment.start + ? <Widget>[icon, Flexible(child: label)] + : <Widget>[Flexible(child: label), icon], + ); + } +} + +// 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. + +// dart format off +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 + WidgetStateProperty<TextStyle?> get textStyle => + MaterialStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge); + + @override + WidgetStateProperty<Color?>? get backgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onPrimary; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.1); + } + return null; + }); + + @override + WidgetStateProperty<Color>? get shadowColor => + MaterialStatePropertyAll<Color>(_colors.shadow); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<double>? get elevation => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return 0.0; + } + if (states.contains(WidgetState.pressed)) { + return 0.0; + } + if (states.contains(WidgetState.hovered)) { + return 1.0; + } + if (states.contains(WidgetState.focused)) { + return 0.0; + } + return 0.0; + }); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + MaterialStatePropertyAll<EdgeInsetsGeometry>(_scaledPadding(context)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(64.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(18.0); + + @override + WidgetStateProperty<Color>? get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary; + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary; + } + return _colors.onPrimary; + }); + } + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + // No default side + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// 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. + +// dart format off +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 + WidgetStateProperty<TextStyle?> get textStyle => + MaterialStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge); + + @override + WidgetStateProperty<Color?>? get backgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.secondaryContainer; + }); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onSecondaryContainer; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSecondaryContainer.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + return null; + }); + + @override + WidgetStateProperty<Color>? get shadowColor => + MaterialStatePropertyAll<Color>(_colors.shadow); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<double>? get elevation => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return 0.0; + } + if (states.contains(WidgetState.pressed)) { + return 0.0; + } + if (states.contains(WidgetState.hovered)) { + return 1.0; + } + if (states.contains(WidgetState.focused)) { + return 0.0; + } + return 0.0; + }); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + MaterialStatePropertyAll<EdgeInsetsGeometry>(_scaledPadding(context)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(64.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(18.0); + + @override + WidgetStateProperty<Color>? get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSecondaryContainer; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSecondaryContainer; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSecondaryContainer; + } + return _colors.onSecondaryContainer; + }); + } + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + // No default side + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - FilledTonalButton diff --git a/packages/material_ui/lib/src/filled_button_theme.dart b/packages/material_ui/lib/src/filled_button_theme.dart new file mode 100644 index 000000000000..c50c1d38da31 --- /dev/null +++ b/packages/material_ui/lib/src/filled_button_theme.dart @@ -0,0 +1,123 @@ +// 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. + +/// @docImport 'filled_button.dart'; +library; + +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 +/// [WidgetStateProperty] 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. +/// * [WidgetStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [WidgetState]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 [WidgetStateProperty] + /// 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) { + if (identical(a, b)) { + return a; + } + 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<ButtonStyle>('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]. + const FilledButtonTheme({super.key, required this.data, required super.child}); + + /// The configuration of this theme. + final FilledButtonThemeData data; + + /// Retrieves the [FilledButtonThemeData] from the closest ancestor [FilledButtonTheme]. + /// + /// 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<FilledButtonTheme>(); + 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/material_ui/lib/src/filter_chip.dart b/packages/material_ui/lib/src/filter_chip.dart new file mode 100644 index 000000000000..df35c0fcc2bd --- /dev/null +++ b/packages/material_ui/lib/src/filter_chip.dart @@ -0,0 +1,426 @@ +// 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. + +/// @docImport 'action_chip.dart'; +/// @docImport 'checkbox.dart'; +/// @docImport 'choice_chip.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'input_chip.dart'; +/// @docImport 'material.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/widgets.dart'; + +import 'chip.dart'; +import 'chip_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'icons.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +enum _ChipVariant { flat, elevated } + +/// A Material Design filter chip. +/// +/// Filter chips use tags or descriptive words as a way to filter content. +/// +/// Filter chips are a good alternative to [Checkbox] or [Switch] widgets. +/// Unlike these alternatives, filter chips allow for clearly delineated and +/// exposed options in a compact area. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// {@tool dartpad} +/// This example shows how to use [FilterChip]s to filter through exercises. +/// +/// ** See code in examples/api/lib/material/filter_chip/filter_chip.0.dart ** +/// {@end-tool} +/// +/// ## Material Design 3 +/// +/// [FilterChip] can be used for multiple select Filter chip from +/// Material Design 3. If [ThemeData.useMaterial3] is true, then [FilterChip] +/// will be styled to match the Material Design 3 specification for Filter +/// chips. Use [ChoiceChip] for single select Filter chips. +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [InputChip], a chip that represents a complex piece of information, such +/// as an entity (person, place, or thing) or conversational text, in a +/// compact form. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [ActionChip], represents an action related to primary content. +/// * [CircleAvatar], which shows images or initials of people. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * <https://material.io/design/components/chips.html> +class FilterChip extends StatelessWidget + implements + ChipAttributes, + DeletableChipAttributes, + SelectableChipAttributes, + CheckmarkableChipAttributes, + DisabledChipAttributes { + /// Create a chip that acts like a checkbox. + /// + /// The [selected], [label], [autofocus], and [clipBehavior] arguments must + /// not be null. When [onSelected] is null, the [FilterChip] will be disabled. + /// The [pressElevation] and [elevation] must be null or non-negative. Typically, + /// [pressElevation] is greater than [elevation]. + const FilterChip({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.selected = false, + required this.onSelected, + this.deleteIcon, + this.onDeleted, + this.deleteIconColor, + this.deleteButtonTooltipMessage, + this.pressElevation, + this.disabledColor, + this.selectedColor, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.avatarBorder = const CircleBorder(), + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + _chipVariant = _ChipVariant.flat; + + /// Create an elevated chip that acts like a checkbox. + /// + /// The [selected], [label], [autofocus], and [clipBehavior] arguments must + /// not be null. When [onSelected] is null, the [FilterChip] will be disabled. + /// The [pressElevation] and [elevation] must be null or non-negative. Typically, + /// [pressElevation] is greater than [elevation]. + const FilterChip.elevated({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.selected = false, + required this.onSelected, + this.deleteIcon, + this.onDeleted, + this.deleteIconColor, + this.deleteButtonTooltipMessage, + this.pressElevation, + this.disabledColor, + this.selectedColor, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.avatarBorder = const CircleBorder(), + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0), + _chipVariant = _ChipVariant.elevated; + + @override + final Widget? avatar; + @override + final Widget label; + @override + final TextStyle? labelStyle; + @override + final EdgeInsetsGeometry? labelPadding; + @override + final bool selected; + @override + final ValueChanged<bool>? onSelected; + @override + final Widget? deleteIcon; + @override + final VoidCallback? onDeleted; + @override + final Color? deleteIconColor; + @override + final String? deleteButtonTooltipMessage; + @override + final double? pressElevation; + @override + final Color? disabledColor; + @override + final Color? selectedColor; + @override + final String? tooltip; + @override + final BorderSide? side; + @override + final OutlinedBorder? shape; + @override + final Clip clipBehavior; + @override + final FocusNode? focusNode; + @override + final bool autofocus; + @override + final WidgetStateProperty<Color?>? color; + @override + final Color? backgroundColor; + @override + final EdgeInsetsGeometry? padding; + @override + final VisualDensity? visualDensity; + @override + final MaterialTapTargetSize? materialTapTargetSize; + @override + final double? elevation; + @override + final Color? shadowColor; + @override + final Color? surfaceTintColor; + @override + final Color? selectedShadowColor; + @override + final bool? showCheckmark; + @override + final Color? checkmarkColor; + @override + final ShapeBorder avatarBorder; + @override + final IconThemeData? iconTheme; + @override + final BoxConstraints? avatarBoxConstraints; + @override + final BoxConstraints? deleteIconBoxConstraints; + @override + final ChipAnimationStyle? chipAnimationStyle; + @override + final MouseCursor? mouseCursor; + + @override + bool get isEnabled => onSelected != null; + + final _ChipVariant _chipVariant; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ChipThemeData? defaults = Theme.of(context).useMaterial3 + ? _FilterChipDefaultsM3(context, isEnabled, selected, _chipVariant) + : null; + final Widget? resolvedDeleteIcon = + deleteIcon ?? (Theme.of(context).useMaterial3 ? const Icon(Icons.clear, size: 18) : null); + return RawChip( + defaultProperties: defaults, + avatar: avatar, + label: label, + labelStyle: labelStyle, + labelPadding: labelPadding, + onSelected: onSelected, + deleteIcon: resolvedDeleteIcon, + onDeleted: onDeleted, + deleteIconColor: deleteIconColor, + deleteButtonTooltipMessage: deleteButtonTooltipMessage, + pressElevation: pressElevation, + selected: selected, + tooltip: tooltip, + side: side, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + color: color, + backgroundColor: backgroundColor, + disabledColor: disabledColor, + selectedColor: selectedColor, + padding: padding, + visualDensity: visualDensity, + isEnabled: isEnabled, + materialTapTargetSize: materialTapTargetSize, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + selectedShadowColor: selectedShadowColor, + showCheckmark: showCheckmark, + checkmarkColor: checkmarkColor, + avatarBorder: avatarBorder, + iconTheme: iconTheme, + avatarBoxConstraints: avatarBoxConstraints, + deleteIconBoxConstraints: deleteIconBoxConstraints, + chipAnimationStyle: chipAnimationStyle, + mouseCursor: mouseCursor, + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - FilterChip + +// 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. + +// dart format off +class _FilterChipDefaultsM3 extends ChipThemeData { + _FilterChipDefaultsM3( + this.context, + this.isEnabled, + this.isSelected, + this._chipVariant, + ) : super( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + showCheckmark: true, + ); + + final BuildContext context; + final bool isEnabled; + final bool isSelected; + final _ChipVariant _chipVariant; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + double? get elevation => _chipVariant == _ChipVariant.flat + ? 0.0 + : isEnabled ? 1.0 : 0.0; + + @override + double? get pressElevation => 1.0; + + @override + TextStyle? get labelStyle => _textTheme.labelLarge?.copyWith( + color: isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant + : _colors.onSurface, + ); + + @override + WidgetStateProperty<Color?>? get color => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected) && states.contains(WidgetState.disabled)) { + return _chipVariant == _ChipVariant.flat + ? _colors.onSurface.withOpacity(0.12) + : _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.disabled)) { + return _chipVariant == _ChipVariant.flat + ? null + : _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.selected)) { + return _chipVariant == _ChipVariant.flat + ? _colors.secondaryContainer + : _colors.secondaryContainer; + } + return _chipVariant == _ChipVariant.flat + ? null + : _colors.surfaceContainerLow; + }); + + @override + Color? get shadowColor => _chipVariant == _ChipVariant.flat + ? Colors.transparent + : _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get checkmarkColor => isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.primary + : _colors.onSurface; + + @override + Color? get deleteIconColor => isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant + : _colors.onSurface; + + @override + BorderSide? get side => _chipVariant == _ChipVariant.flat && !isSelected + ? isEnabled + ? BorderSide(color: _colors.outlineVariant) + : BorderSide(color: _colors.onSurface.withOpacity(0.12)) + : const BorderSide(color: Colors.transparent); + + @override + IconThemeData? get iconTheme => IconThemeData( + color: isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.primary + : _colors.onSurface, + size: 18.0, + ); + + @override + EdgeInsetsGeometry? get padding => const EdgeInsets.all(8.0); + + /// The label padding of the chip scales with the font size specified in the + /// [labelStyle], and the system font size settings that scale font sizes + /// globally. + /// + /// The chip at effective font size 14.0 starts with 8px on each side and as + /// the font size scales up to closer to 28.0, the label padding is linearly + /// interpolated from 8px to 4px. Once the label has a font size of 2 or + /// higher, label padding remains 4px. + @override + EdgeInsetsGeometry? get labelPadding { + final double fontSize = labelStyle?.fontSize ?? 14.0; + final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(fontSize) / 14.0; + return EdgeInsets.lerp( + const EdgeInsets.symmetric(horizontal: 8.0), + const EdgeInsets.symmetric(horizontal: 4.0), + clampDouble(fontSizeRatio - 1.0, 0.0, 1.0), + )!; + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - FilterChip diff --git a/packages/material_ui/lib/src/flexible_space_bar.dart b/packages/material_ui/lib/src/flexible_space_bar.dart new file mode 100644 index 000000000000..9f497e2ddf0a --- /dev/null +++ b/packages/material_ui/lib/src/flexible_space_bar.dart @@ -0,0 +1,502 @@ +// 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. + +/// @docImport 'app_bar.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'constants.dart'; +import 'theme.dart'; + +/// The collapsing effect while the space bar collapses from its full size. +enum CollapseMode { + /// The background widget will scroll in a parallax fashion. + parallax, + + /// The background widget pin in place until it reaches the min extent. + pin, + + /// The background widget will act as normal with no collapsing effect. + none, +} + +/// The stretching effect while the space bar stretches beyond its full size. +enum StretchMode { + /// The background widget will expand to fill the extra space. + zoomBackground, + + /// The background will blur using a [ui.ImageFilter.blur] effect. + blurBackground, + + /// The title will fade away as the user over-scrolls. + fadeTitle, +} + +/// The part of a Material Design [AppBar] that expands, collapses, and +/// stretches. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=mSc7qFzxHDw} +/// +/// Most commonly used in the [SliverAppBar.flexibleSpace] field, a flexible +/// space bar expands and contracts as the app scrolls so that the [AppBar] +/// reaches from the top of the app to the top of the scrolling contents of the +/// app. When using [SliverAppBar.flexibleSpace], the [SliverAppBar.expandedHeight] +/// must be large enough to accommodate the [SliverAppBar.flexibleSpace] widget. +/// +/// Furthermore is included functionality for stretch behavior. When +/// [SliverAppBar.stretch] is true, and your [ScrollPhysics] allow for +/// overscroll, this space will stretch with the overscroll. +/// +/// The widget that sizes the [AppBar] must wrap it in the widget returned by +/// [FlexibleSpaceBar.createSettings], to convey sizing information down to the +/// [FlexibleSpaceBar]. +/// +/// {@tool dartpad} +/// This sample application demonstrates the different features of the +/// [FlexibleSpaceBar] when used in a [SliverAppBar]. This app bar is configured +/// to stretch into the overscroll space, and uses the +/// [FlexibleSpaceBar.stretchModes] to apply `fadeTitle`, `blurBackground` and +/// `zoomBackground`. The app bar also makes use of [CollapseMode.parallax] by +/// default. +/// +/// ** See code in examples/api/lib/material/flexible_space_bar/flexible_space_bar.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SliverAppBar], which implements the expanding and contracting. +/// * [AppBar], which is used by [SliverAppBar]. +/// * <https://material.io/design/components/app-bars-top.html#behavior> +class FlexibleSpaceBar extends StatefulWidget { + /// Creates a flexible space bar. + /// + /// Most commonly used in the [AppBar.flexibleSpace] field. + const FlexibleSpaceBar({ + super.key, + this.title, + this.background, + this.centerTitle, + this.titlePadding, + this.collapseMode = CollapseMode.parallax, + this.stretchModes = const <StretchMode>[StretchMode.zoomBackground], + this.expandedTitleScale = 1.5, + }) : assert(expandedTitleScale >= 1); + + /// The primary contents of the flexible space bar when expanded. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Shown behind the [title] when expanded. + /// + /// Typically an [Image] widget with [Image.fit] set to [BoxFit.cover]. + final Widget? background; + + /// Whether the title should be centered. + /// + /// If the length of the title is greater than the available space, set + /// this property to false. This aligns the title to the start of the + /// flexible space bar and applies [titlePadding] to the title. + /// + /// By default this property is true if the current target platform + /// is [TargetPlatform.iOS] or [TargetPlatform.macOS], false otherwise. + final bool? centerTitle; + + /// Collapse effect while scrolling. + /// + /// Defaults to [CollapseMode.parallax]. + final CollapseMode collapseMode; + + /// Stretch effect while over-scrolling. + /// + /// Defaults to include [StretchMode.zoomBackground]. + final List<StretchMode> stretchModes; + + /// Defines how far the [title] is inset from either the widget's + /// bottom-left or its center. + /// + /// Typically this property is used to adjust how far the title is + /// inset from the bottom-left and it is specified along with + /// [centerTitle] false. + /// + /// If [centerTitle] is true, then the title is centered within the + /// flexible space bar with a bottom padding of 16.0 pixels. + /// + /// If [centerTitle] is false and [FlexibleSpaceBarSettings.hasLeading] is true, + /// then the title is aligned to the start of the flexible space bar with the + /// [titlePadding] applied. If [titlePadding] is null, then defaults to start + /// padding of 72.0 pixels and bottom padding of 16.0 pixels. + final EdgeInsetsGeometry? titlePadding; + + /// Defines how much the title is scaled when the FlexibleSpaceBar is expanded + /// due to the user scrolling downwards. The title is scaled uniformly on the + /// x and y axes while maintaining its bottom-left position (bottom-center if + /// [centerTitle] is true). + /// + /// Defaults to 1.5 and must be greater than 1. + final double expandedTitleScale; + + /// Wraps a widget that contains an [AppBar] to convey sizing information down + /// to the [FlexibleSpaceBar]. + /// + /// Used by [Scaffold] and [SliverAppBar]. + /// + /// `toolbarOpacity` affects how transparent the text within the toolbar + /// appears. `minExtent` sets the minimum height of the resulting + /// [FlexibleSpaceBar] when fully collapsed. `maxExtent` sets the maximum + /// height of the resulting [FlexibleSpaceBar] when fully expanded. + /// `currentExtent` sets the scale of the [FlexibleSpaceBar.background] and + /// [FlexibleSpaceBar.title] widgets of [FlexibleSpaceBar] upon + /// initialization. `scrolledUnder` is true if the [FlexibleSpaceBar] + /// overlaps the app's primary scrollable, false if it does not, and null + /// if the caller has not determined as much. + /// See also: + /// + /// * [FlexibleSpaceBarSettings] which creates a settings object that can be + /// used to specify these settings to a [FlexibleSpaceBar]. + static Widget createSettings({ + double? toolbarOpacity, + double? minExtent, + double? maxExtent, + bool? isScrolledUnder, + bool? hasLeading, + required double currentExtent, + required Widget child, + }) { + return FlexibleSpaceBarSettings( + toolbarOpacity: toolbarOpacity ?? 1.0, + minExtent: minExtent ?? currentExtent, + maxExtent: maxExtent ?? currentExtent, + isScrolledUnder: isScrolledUnder, + hasLeading: hasLeading, + currentExtent: currentExtent, + child: child, + ); + } + + @override + State<FlexibleSpaceBar> createState() => _FlexibleSpaceBarState(); +} + +class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> { + bool _getEffectiveCenterTitle(ThemeData theme) { + return widget.centerTitle ?? + switch (theme.platform) { + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => false, + TargetPlatform.iOS || TargetPlatform.macOS => true, + }; + } + + Alignment _getTitleAlignment(bool effectiveCenterTitle) { + if (effectiveCenterTitle) { + return Alignment.bottomCenter; + } + return switch (Directionality.of(context)) { + TextDirection.rtl => Alignment.bottomRight, + TextDirection.ltr => Alignment.bottomLeft, + }; + } + + double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) { + switch (widget.collapseMode) { + case CollapseMode.pin: + return -(settings.maxExtent - settings.currentExtent); + case CollapseMode.none: + return 0.0; + case CollapseMode.parallax: + final double deltaExtent = settings.maxExtent - settings.minExtent; + return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final FlexibleSpaceBarSettings settings = context + .dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!; + + final children = <Widget>[]; + + final double deltaExtent = settings.maxExtent - settings.minExtent; + + // 0.0 -> Expanded + // 1.0 -> Collapsed to toolbar + final double t = clampDouble( + 1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent, + 0.0, + 1.0, + ); + + // background + if (widget.background != null) { + final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaExtent); + const fadeEnd = 1.0; + assert(fadeStart <= fadeEnd); + // If the min and max extent are the same, the app bar cannot collapse + // and the content should be visible, so opacity = 1. + final double opacity = settings.maxExtent == settings.minExtent + ? 1.0 + : 1.0 - Interval(fadeStart, fadeEnd).transform(t); + double height = settings.maxExtent; + + // StretchMode.zoomBackground + if (widget.stretchModes.contains(StretchMode.zoomBackground) && + constraints.maxHeight > height) { + height = constraints.maxHeight; + } + final double topPadding = _getCollapsePadding(t, settings); + children.add( + Positioned( + top: topPadding, + left: 0.0, + right: 0.0, + height: height, + child: _FlexibleSpaceHeaderOpacity( + // IOS is relying on this semantics node to correctly traverse + // through the app bar when it is collapsed. + alwaysIncludeSemantics: true, + opacity: opacity, + child: widget.background, + ), + ), + ); + + // StretchMode.blurBackground + if (widget.stretchModes.contains(StretchMode.blurBackground) && + constraints.maxHeight > settings.maxExtent) { + final double blurAmount = (constraints.maxHeight - settings.maxExtent) / 10; + children.add( + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: blurAmount, sigmaY: blurAmount), + child: const ColoredBox(color: Colors.transparent), + ), + ), + ); + } + } + + // title + if (widget.title != null) { + final ThemeData theme = Theme.of(context); + + Widget? title; + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + title = widget.title; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + title = Semantics(namesRoute: true, child: widget.title); + } + + // StretchMode.fadeTitle + if (widget.stretchModes.contains(StretchMode.fadeTitle) && + constraints.maxHeight > settings.maxExtent) { + final double stretchOpacity = + 1 - clampDouble((constraints.maxHeight - settings.maxExtent) / 100, 0.0, 1.0); + title = Opacity(opacity: stretchOpacity, child: title); + } + + final double opacity = settings.toolbarOpacity; + if (opacity > 0.0) { + TextStyle titleStyle = theme.useMaterial3 + ? theme.textTheme.titleLarge! + : theme.primaryTextTheme.titleLarge!; + titleStyle = titleStyle.copyWith(color: titleStyle.color!.withOpacity(opacity)); + final bool effectiveCenterTitle = _getEffectiveCenterTitle(theme); + final leadingPadding = (settings.hasLeading ?? true) ? 72.0 : 0.0; + final EdgeInsetsGeometry padding = + widget.titlePadding ?? + EdgeInsetsDirectional.only( + start: effectiveCenterTitle ? 0.0 : leadingPadding, + bottom: 16.0, + ); + final double scaleValue = Tween<double>( + begin: widget.expandedTitleScale, + end: 1.0, + ).transform(t); + final scaleTransform = Matrix4.identity() + ..scaleByDouble(scaleValue, scaleValue, 1.0, 1); + final Alignment titleAlignment = _getTitleAlignment(effectiveCenterTitle); + children.add( + Padding( + padding: padding, + child: Transform( + alignment: titleAlignment, + transform: scaleTransform, + child: Align( + alignment: titleAlignment, + child: DefaultTextStyle( + style: titleStyle, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SizedBox( + width: constraints.maxWidth / scaleValue, + child: Align(alignment: titleAlignment, child: title), + ); + }, + ), + ), + ), + ), + ), + ); + } + } + + return ClipRect(child: Stack(children: children)); + }, + ); + } +} + +/// Provides sizing and opacity information to a [FlexibleSpaceBar]. +/// +/// See also: +/// +/// * [FlexibleSpaceBar] which creates a flexible space bar. +class FlexibleSpaceBarSettings extends InheritedWidget { + /// Creates a Flexible Space Bar Settings widget. + /// + /// Used by [Scaffold] and [SliverAppBar]. [child] must have a + /// [FlexibleSpaceBar] widget in its tree for the settings to take affect. + const FlexibleSpaceBarSettings({ + super.key, + required this.toolbarOpacity, + required this.minExtent, + required this.maxExtent, + required this.currentExtent, + required super.child, + this.isScrolledUnder, + this.hasLeading, + }) : assert(minExtent >= 0), + assert(maxExtent >= 0), + assert(currentExtent >= 0), + assert(toolbarOpacity >= 0.0), + assert(minExtent <= maxExtent), + assert(minExtent <= currentExtent), + assert(currentExtent <= maxExtent); + + /// Affects how transparent the text within the toolbar appears. + final double toolbarOpacity; + + /// Minimum height of the resulting [FlexibleSpaceBar] when fully collapsed. + final double minExtent; + + /// Maximum height of the resulting [FlexibleSpaceBar] when fully expanded. + final double maxExtent; + + /// If the [FlexibleSpaceBar.title] or the [FlexibleSpaceBar.background] is + /// not null, then this value is used to calculate the relative scale of + /// these elements upon initialization. + final double currentExtent; + + /// True if the FlexibleSpaceBar overlaps the primary scrollable's contents. + /// + /// This value is used by the [AppBar] to resolve + /// [AppBar.backgroundColor] against [WidgetState.scrolledUnder], + /// i.e. to enable apps to specify different colors when content + /// has been scrolled up and behind the app bar. + /// + /// Null if the caller hasn't determined if the FlexibleSpaceBar + /// overlaps the primary scrollable's contents. + final bool? isScrolledUnder; + + /// True if the FlexibleSpaceBar has a leading widget. + /// + /// This value is used by the [FlexibleSpaceBar] to determine + /// if there should be a gap between the leading widget and + /// the title. + /// + /// Null if the caller hasn't determined if the FlexibleSpaceBar + /// has a leading widget. + final bool? hasLeading; + + @override + bool updateShouldNotify(FlexibleSpaceBarSettings oldWidget) { + return toolbarOpacity != oldWidget.toolbarOpacity || + minExtent != oldWidget.minExtent || + maxExtent != oldWidget.maxExtent || + currentExtent != oldWidget.currentExtent || + isScrolledUnder != oldWidget.isScrolledUnder || + hasLeading != oldWidget.hasLeading; + } +} + +// We need the child widget to repaint, however both the opacity +// and potentially `widget.background` can be constant which won't +// lead to repainting. +// see: https://github.com/flutter/flutter/issues/127836 +class _FlexibleSpaceHeaderOpacity extends SingleChildRenderObjectWidget { + const _FlexibleSpaceHeaderOpacity({ + required this.opacity, + required super.child, + required this.alwaysIncludeSemantics, + }); + + final double opacity; + final bool alwaysIncludeSemantics; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderFlexibleSpaceHeaderOpacity( + opacity: opacity, + alwaysIncludeSemantics: alwaysIncludeSemantics, + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderFlexibleSpaceHeaderOpacity renderObject, + ) { + renderObject + ..alwaysIncludeSemantics = alwaysIncludeSemantics + ..opacity = opacity; + } +} + +class _RenderFlexibleSpaceHeaderOpacity extends RenderOpacity { + _RenderFlexibleSpaceHeaderOpacity({super.opacity, super.alwaysIncludeSemantics}); + + @override + bool get isRepaintBoundary => false; + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) { + return; + } + if ((opacity * 255).roundToDouble() <= 0) { + layer = null; + return; + } + assert(needsCompositing); + layer = context.pushOpacity( + offset, + (opacity * 255).round(), + super.paint, + oldLayer: layer as OpacityLayer?, + ); + assert(() { + layer!.debugCreator = debugCreator; + return true; + }()); + } +} diff --git a/packages/material_ui/lib/src/floating_action_button.dart b/packages/material_ui/lib/src/floating_action_button.dart new file mode 100644 index 000000000000..4b78e44dab99 --- /dev/null +++ b/packages/material_ui/lib/src/floating_action_button.dart @@ -0,0 +1,836 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'material.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'color_scheme.dart'; +import 'floating_action_button_theme.dart'; +import 'scaffold.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +class _DefaultHeroTag { + const _DefaultHeroTag(); + @override + String toString() => '<default FloatingActionButton tag>'; +} + +enum _FloatingActionButtonType { regular, small, large, extended } + +/// A Material Design floating action button. +/// +/// A floating action button is a circular icon button that hovers over content +/// to promote a primary action in the application. Floating action buttons are +/// most commonly used in the [Scaffold.floatingActionButton] field. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=2uaoEDOgk_I} +/// +/// Use at most a single floating action button per screen. Floating action +/// buttons should be used for positive actions such as "create", "share", or +/// "navigate". (If more than one floating action button is used within a +/// [Route], then make sure that each button has a unique [heroTag], otherwise +/// an exception will be thrown.) +/// +/// If the [onPressed] callback is null, then the button will be disabled and +/// will not react to touch. It is highly discouraged to disable a floating +/// action button as there is no indication to the user that the button is +/// disabled. Consider changing the [backgroundColor] if disabling the floating +/// action button. +/// +/// {@tool dartpad} +/// This example shows a [FloatingActionButton] in its usual position within a +/// [Scaffold]. Pressing the button cycles it through a few variations in its +/// [foregroundColor], [backgroundColor], and [shape]. The button automatically +/// animates its segue from one set of visual parameters to another. +/// +/// ** See code in examples/api/lib/material/floating_action_button/floating_action_button.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows all the variants of [FloatingActionButton] widget as +/// described in: https://m3.material.io/components/floating-action-button/overview. +/// +/// ** See code in examples/api/lib/material/floating_action_button/floating_action_button.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows [FloatingActionButton] with additional color mappings as +/// described in: https://m3.material.io/components/floating-action-button/overview. +/// +/// ** See code in examples/api/lib/material/floating_action_button/floating_action_button.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Scaffold], in which floating action buttons typically live. +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * <https://material.io/design/components/buttons-floating-action-button.html> +/// * <https://m3.material.io/components/floating-action-button> +class FloatingActionButton extends StatelessWidget { + /// Creates a circular floating action button. + /// + /// The [elevation], [highlightElevation], and [disabledElevation] parameters, + /// if specified, must be non-negative. + const FloatingActionButton({ + super.key, + this.child, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.heroTag = const _DefaultHeroTag(), + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + required this.onPressed, + this.mouseCursor, + this.mini = false, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.isExtended = false, + this.enableFeedback, + }) : assert(elevation == null || elevation >= 0.0), + assert(focusElevation == null || focusElevation >= 0.0), + assert(hoverElevation == null || hoverElevation >= 0.0), + assert(highlightElevation == null || highlightElevation >= 0.0), + assert(disabledElevation == null || disabledElevation >= 0.0), + _floatingActionButtonType = mini + ? _FloatingActionButtonType.small + : _FloatingActionButtonType.regular, + _extendedLabel = null, + extendedIconLabelSpacing = null, + extendedPadding = null, + extendedTextStyle = null; + + /// Creates a small circular floating action button. + /// + /// This constructor overrides the default size constraints of the floating + /// action button. + /// + /// The [elevation], [focusElevation], [hoverElevation], [highlightElevation], + /// and [disabledElevation] parameters, if specified, must be non-negative. + const FloatingActionButton.small({ + super.key, + this.child, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.heroTag = const _DefaultHeroTag(), + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + required this.onPressed, + this.mouseCursor, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.enableFeedback, + }) : assert(elevation == null || elevation >= 0.0), + assert(focusElevation == null || focusElevation >= 0.0), + assert(hoverElevation == null || hoverElevation >= 0.0), + assert(highlightElevation == null || highlightElevation >= 0.0), + assert(disabledElevation == null || disabledElevation >= 0.0), + _floatingActionButtonType = _FloatingActionButtonType.small, + mini = true, + isExtended = false, + _extendedLabel = null, + extendedIconLabelSpacing = null, + extendedPadding = null, + extendedTextStyle = null; + + /// Creates a large circular floating action button. + /// + /// This constructor overrides the default size constraints of the floating + /// action button. + /// + /// The [elevation], [focusElevation], [hoverElevation], [highlightElevation], + /// and [disabledElevation] parameters, if specified, must be non-negative. + const FloatingActionButton.large({ + super.key, + this.child, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.heroTag = const _DefaultHeroTag(), + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + required this.onPressed, + this.mouseCursor, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.enableFeedback, + }) : assert(elevation == null || elevation >= 0.0), + assert(focusElevation == null || focusElevation >= 0.0), + assert(hoverElevation == null || hoverElevation >= 0.0), + assert(highlightElevation == null || highlightElevation >= 0.0), + assert(disabledElevation == null || disabledElevation >= 0.0), + _floatingActionButtonType = _FloatingActionButtonType.large, + mini = false, + isExtended = false, + _extendedLabel = null, + extendedIconLabelSpacing = null, + extendedPadding = null, + extendedTextStyle = null; + + /// Creates a wider [StadiumBorder]-shaped floating action button with + /// an optional [icon] and a [label]. + /// + /// The [elevation], [highlightElevation], and [disabledElevation] parameters, + /// if specified, must be non-negative. + /// + /// See also: + /// * <https://m3.material.io/components/extended-fab> + const FloatingActionButton.extended({ + super.key, + this.tooltip, + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.heroTag = const _DefaultHeroTag(), + this.elevation, + this.focusElevation, + this.hoverElevation, + this.splashColor, + this.highlightElevation, + this.disabledElevation, + required this.onPressed, + this.mouseCursor, + this.shape, + this.isExtended = true, + this.materialTapTargetSize, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.extendedIconLabelSpacing, + this.extendedPadding, + this.extendedTextStyle, + Widget? icon, + required Widget label, + this.enableFeedback, + }) : assert(elevation == null || elevation >= 0.0), + assert(focusElevation == null || focusElevation >= 0.0), + assert(hoverElevation == null || hoverElevation >= 0.0), + assert(highlightElevation == null || highlightElevation >= 0.0), + assert(disabledElevation == null || disabledElevation >= 0.0), + mini = false, + _floatingActionButtonType = _FloatingActionButtonType.extended, + child = icon, + _extendedLabel = label; + + /// The widget below this widget in the tree. + /// + /// Typically an [Icon]. + final Widget? child; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// The default foreground color for icons and text within the button. + /// + /// If this property is null, then the ambient + /// [FloatingActionButtonThemeData.foregroundColor] is used. If that property is also + /// null, then the [ColorScheme.onPrimaryContainer] color of [ThemeData.colorScheme] + /// is used. If [ThemeData.useMaterial3] is set to false, then the + /// [ColorScheme.onSecondary] color of [ThemeData.colorScheme] is used. + final Color? foregroundColor; + + /// The button's background color. + /// + /// If this property is null, then the ambient + /// [FloatingActionButtonThemeData.backgroundColor] is used. If that property is also + /// null, then the [ColorScheme.primaryContainer] color of [ThemeData.colorScheme] + /// is used. If [ThemeData.useMaterial3] is set to false, then the + /// [ColorScheme.secondary] color of [ThemeData.colorScheme] is used. + final Color? backgroundColor; + + /// The color to use for filling the button when the button has input focus. + /// + /// In Material3, defaults to [ColorScheme.onPrimaryContainer] with opacity 0.1. + /// In Material 2, it defaults to [ThemeData.focusColor] for the current theme. + final Color? focusColor; + + /// The color to use for filling the button when the button has a pointer + /// hovering over it. + /// + /// Defaults to [ThemeData.hoverColor] for the current theme in Material 2. In + /// Material 3, defaults to [ColorScheme.onPrimaryContainer] with opacity 0.08. + final Color? hoverColor; + + /// The splash color for this [FloatingActionButton]'s [InkWell]. + /// + /// If null, [FloatingActionButtonThemeData.splashColor] is used, if that is + /// null, [ThemeData.splashColor] is used in Material 2; [ColorScheme.onPrimaryContainer] + /// with opacity 0.1 is used in Material 3. + final Color? splashColor; + + /// The tag to apply to the button's [Hero] widget. + /// + /// Defaults to a tag that matches other floating action buttons. + /// + /// Set this to null explicitly if you don't want the floating action button to + /// have a hero tag. + /// + /// If this is not explicitly set, then there can only be one + /// [FloatingActionButton] per route (that is, per screen), since otherwise + /// there would be a tag conflict (multiple heroes on one route can't have the + /// same tag). The Material Design specification recommends only using one + /// floating action button per screen. + final Object? heroTag; + + /// The callback that is called when the button is tapped or otherwise activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + /// {@macro flutter.material.RawMaterialButton.mouseCursor} + /// + /// If this property is null, [FloatingActionButtonThemeData.mouseCursor] is used. + /// If that is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? mouseCursor; + + /// The z-coordinate at which to place this button relative to its parent. + /// + /// This controls the size of the shadow below the floating action button. + /// + /// Defaults to 6, the appropriate elevation for floating action buttons. The + /// value is always non-negative. + /// + /// See also: + /// + /// * [highlightElevation], the elevation when the button is pressed. + /// * [disabledElevation], the elevation when the button is disabled. + final double? elevation; + + /// The z-coordinate at which to place this button relative to its parent when + /// the button has the input focus. + /// + /// This controls the size of the shadow below the floating action button. + /// + /// Defaults to 8, the appropriate elevation for floating action buttons + /// while they have focus. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [highlightElevation], the elevation when the button is pressed. + /// * [disabledElevation], the elevation when the button is disabled. + final double? focusElevation; + + /// The z-coordinate at which to place this button relative to its parent when + /// the button is enabled and has a pointer hovering over it. + /// + /// This controls the size of the shadow below the floating action button. + /// + /// Defaults to 8, the appropriate elevation for floating action buttons while + /// they have a pointer hovering over them. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [highlightElevation], the elevation when the button is pressed. + /// * [disabledElevation], the elevation when the button is disabled. + final double? hoverElevation; + + /// The z-coordinate at which to place this button relative to its parent when + /// the user is touching the button. + /// + /// This controls the size of the shadow below the floating action button. + /// + /// Defaults to 12, the appropriate elevation for floating action buttons + /// while they are being touched. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + final double? highlightElevation; + + /// The z-coordinate at which to place this button when the button is disabled + /// ([onPressed] is null). + /// + /// This controls the size of the shadow below the floating action button. + /// + /// Defaults to the same value as [elevation]. Setting this to zero makes the + /// floating action button work similar to an [ElevatedButton] but the titular + /// "floating" effect is lost. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [highlightElevation], the elevation when the button is pressed. + final double? disabledElevation; + + /// Controls the size of this button. + /// + /// By default, floating action buttons are non-mini and have a height and + /// width of 56.0 logical pixels. Mini floating action buttons have a height + /// and width of 40.0 logical pixels with a layout width and height of 48.0 + /// logical pixels. (The extra 4 pixels of padding on each side are added as a + /// result of the floating action button having [MaterialTapTargetSize.padded] + /// set on the underlying [RawMaterialButton.materialTapTargetSize].) + final bool mini; + + /// The shape of the button's [Material]. + /// + /// The button's highlight and splash are clipped to this shape. If the + /// button has an elevation, then its drop shadow is defined by this + /// shape as well. + final ShapeBorder? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// True if this is an "extended" floating action button. + /// + /// Typically "extended" buttons have a [StadiumBorder] [shape] + /// and have been created with the [FloatingActionButton.extended] + /// constructor. + /// + /// The [Scaffold] animates the appearance of ordinary floating + /// action buttons with scale and rotation transitions. Extended + /// floating action buttons are scaled and faded in. + final bool isExtended; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Configures the minimum size of the tap target. + /// + /// Defaults to [ThemeData.materialTapTargetSize]. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + final MaterialTapTargetSize? materialTapTargetSize; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// If null, [FloatingActionButtonThemeData.enableFeedback] is used. + /// If both are null, then default value is true. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// The spacing between the icon and the label for an extended + /// [FloatingActionButton]. + /// + /// If null, [FloatingActionButtonThemeData.extendedIconLabelSpacing] is used. + /// If that is also null, the default is 8.0. + final double? extendedIconLabelSpacing; + + /// The padding for an extended [FloatingActionButton]'s content. + /// + /// If null, [FloatingActionButtonThemeData.extendedPadding] is used. If that + /// is also null, the default is + /// `EdgeInsetsDirectional.only(start: 16.0, end: 20.0)` if an icon is + /// provided, and `EdgeInsetsDirectional.only(start: 20.0, end: 20.0)` if not. + final EdgeInsetsGeometry? extendedPadding; + + /// The text style for an extended [FloatingActionButton]'s label. + /// + /// If null, [FloatingActionButtonThemeData.extendedTextStyle] is used. If + /// that is also null, then [TextTheme.labelLarge] with a letter spacing of 1.2 + /// is used. + final TextStyle? extendedTextStyle; + + final _FloatingActionButtonType _floatingActionButtonType; + + final Widget? _extendedLabel; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final FloatingActionButtonThemeData floatingActionButtonTheme = FloatingActionButtonTheme.of( + context, + ); + final FloatingActionButtonThemeData defaults = theme.useMaterial3 + ? _FABDefaultsM3(context, _floatingActionButtonType, child != null) + : _FABDefaultsM2(context, _floatingActionButtonType, child != null); + + final Color foregroundColor = + this.foregroundColor ?? + floatingActionButtonTheme.foregroundColor ?? + defaults.foregroundColor!; + final Color backgroundColor = + this.backgroundColor ?? + floatingActionButtonTheme.backgroundColor ?? + defaults.backgroundColor!; + final Color focusColor = + this.focusColor ?? floatingActionButtonTheme.focusColor ?? defaults.focusColor!; + final Color hoverColor = + this.hoverColor ?? floatingActionButtonTheme.hoverColor ?? defaults.hoverColor!; + final Color splashColor = + this.splashColor ?? floatingActionButtonTheme.splashColor ?? defaults.splashColor!; + final double elevation = + this.elevation ?? floatingActionButtonTheme.elevation ?? defaults.elevation!; + final double focusElevation = + this.focusElevation ?? floatingActionButtonTheme.focusElevation ?? defaults.focusElevation!; + final double hoverElevation = + this.hoverElevation ?? floatingActionButtonTheme.hoverElevation ?? defaults.hoverElevation!; + final double disabledElevation = + this.disabledElevation ?? + floatingActionButtonTheme.disabledElevation ?? + defaults.disabledElevation ?? + elevation; + final double highlightElevation = + this.highlightElevation ?? + floatingActionButtonTheme.highlightElevation ?? + defaults.highlightElevation!; + final MaterialTapTargetSize materialTapTargetSize = + this.materialTapTargetSize ?? theme.materialTapTargetSize; + final bool enableFeedback = + this.enableFeedback ?? floatingActionButtonTheme.enableFeedback ?? defaults.enableFeedback!; + final double iconSize = floatingActionButtonTheme.iconSize ?? defaults.iconSize!; + final TextStyle extendedTextStyle = + (this.extendedTextStyle ?? + floatingActionButtonTheme.extendedTextStyle ?? + defaults.extendedTextStyle!) + .copyWith(color: foregroundColor); + final ShapeBorder shape = this.shape ?? floatingActionButtonTheme.shape ?? defaults.shape!; + + BoxConstraints sizeConstraints; + Widget? resolvedChild = child != null + ? IconTheme.merge( + data: IconThemeData(size: iconSize), + child: child!, + ) + : child; + switch (_floatingActionButtonType) { + case _FloatingActionButtonType.regular: + sizeConstraints = floatingActionButtonTheme.sizeConstraints ?? defaults.sizeConstraints!; + case _FloatingActionButtonType.small: + sizeConstraints = + floatingActionButtonTheme.smallSizeConstraints ?? defaults.smallSizeConstraints!; + case _FloatingActionButtonType.large: + sizeConstraints = + floatingActionButtonTheme.largeSizeConstraints ?? defaults.largeSizeConstraints!; + case _FloatingActionButtonType.extended: + sizeConstraints = + floatingActionButtonTheme.extendedSizeConstraints ?? defaults.extendedSizeConstraints!; + final double iconLabelSpacing = + extendedIconLabelSpacing ?? floatingActionButtonTheme.extendedIconLabelSpacing ?? 8.0; + final EdgeInsetsGeometry padding = + extendedPadding ?? + floatingActionButtonTheme.extendedPadding ?? + defaults.extendedPadding!; + resolvedChild = _ChildOverflowBox( + child: Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ?child, + if (child != null && isExtended) SizedBox(width: iconLabelSpacing), + if (isExtended) _extendedLabel!, + ], + ), + ), + ); + } + + Widget result = RawMaterialButton( + onPressed: onPressed, + mouseCursor: _EffectiveMouseCursor(mouseCursor, floatingActionButtonTheme.mouseCursor), + elevation: elevation, + focusElevation: focusElevation, + hoverElevation: hoverElevation, + highlightElevation: highlightElevation, + disabledElevation: disabledElevation, + constraints: sizeConstraints, + materialTapTargetSize: materialTapTargetSize, + fillColor: backgroundColor, + focusColor: focusColor, + hoverColor: hoverColor, + splashColor: splashColor, + textStyle: extendedTextStyle, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + enableFeedback: enableFeedback, + child: resolvedChild, + ); + + if (tooltip != null) { + result = Tooltip(message: tooltip, child: result); + } + + if (heroTag != null) { + result = Hero(tag: heroTag!, child: result); + } + + return MergeSemantics(child: result); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty<VoidCallback>('onPressed', onPressed, ifNull: 'disabled')); + properties.add(StringProperty('tooltip', tooltip, defaultValue: null)); + properties.add(ColorProperty('foregroundColor', foregroundColor, defaultValue: null)); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(ObjectFlagProperty<Object>('heroTag', heroTag, ifPresent: 'hero')); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DoubleProperty('focusElevation', focusElevation, defaultValue: null)); + properties.add(DoubleProperty('hoverElevation', hoverElevation, defaultValue: null)); + properties.add(DoubleProperty('highlightElevation', highlightElevation, defaultValue: null)); + properties.add(DoubleProperty('disabledElevation', disabledElevation, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + properties.add(FlagProperty('isExtended', value: isExtended, ifTrue: 'extended')); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + materialTapTargetSize, + defaultValue: null, + ), + ); + } +} + +// This WidgetStateProperty is passed along to RawMaterialButton which +// resolves the property against WidgetState.pressed, WidgetState.hovered, +// WidgetState.focused, WidgetState.disabled. +class _EffectiveMouseCursor extends WidgetStateMouseCursor { + const _EffectiveMouseCursor(this.widgetCursor, this.themeCursor); + + final MouseCursor? widgetCursor; + final WidgetStateProperty<MouseCursor?>? themeCursor; + + @override + MouseCursor resolve(Set<WidgetState> states) { + return WidgetStateProperty.resolveAs<MouseCursor?>(widgetCursor, states) ?? + themeCursor?.resolve(states) ?? + WidgetStateMouseCursor.adaptiveClickable.resolve(states); + } + + @override + String get debugDescription => 'WidgetStateMouseCursor(FloatActionButton)'; +} + +// This widget's size matches its child's size unless its constraints +// force it to be larger or smaller. The child is centered. +// +// Used to encapsulate extended FABs whose size is fixed, using Row +// and MainAxisSize.min, to be as wide as their label and icon. +class _ChildOverflowBox extends SingleChildRenderObjectWidget { + const _ChildOverflowBox({super.child}); + + @override + _RenderChildOverflowBox createRenderObject(BuildContext context) { + return _RenderChildOverflowBox(textDirection: Directionality.of(context)); + } + + @override + void updateRenderObject(BuildContext context, _RenderChildOverflowBox renderObject) { + renderObject.textDirection = Directionality.of(context); + } +} + +class _RenderChildOverflowBox extends RenderAligningShiftedBox { + _RenderChildOverflowBox({super.textDirection}) : super(alignment: Alignment.center); + + @override + double computeMinIntrinsicWidth(double height) => 0.0; + + @override + double computeMinIntrinsicHeight(double width) => 0.0; + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child != null) { + final Size childSize = child!.getDryLayout(const BoxConstraints()); + return Size( + math.max(constraints.minWidth, math.min(constraints.maxWidth, childSize.width)), + math.max(constraints.minHeight, math.min(constraints.maxHeight, childSize.height)), + ); + } else { + return constraints.biggest; + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + if (child != null) { + child!.layout(const BoxConstraints(), parentUsesSize: true); + size = Size( + math.max(constraints.minWidth, math.min(constraints.maxWidth, child!.size.width)), + math.max(constraints.minHeight, math.min(constraints.maxHeight, child!.size.height)), + ); + alignChild(); + } else { + size = constraints.biggest; + } + } +} + +// Hand coded defaults based on Material Design 2. +class _FABDefaultsM2 extends FloatingActionButtonThemeData { + _FABDefaultsM2(BuildContext context, this.type, this.hasChild) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme, + super( + elevation: 6, + focusElevation: 6, + hoverElevation: 8, + highlightElevation: 12, + enableFeedback: true, + sizeConstraints: const BoxConstraints.tightFor(width: 56.0, height: 56.0), + smallSizeConstraints: const BoxConstraints.tightFor(width: 40.0, height: 40.0), + largeSizeConstraints: const BoxConstraints.tightFor(width: 96.0, height: 96.0), + extendedSizeConstraints: const BoxConstraints.tightFor(height: 48.0), + extendedIconLabelSpacing: 8.0, + ); + + final _FloatingActionButtonType type; + final bool hasChild; + final ThemeData _theme; + final ColorScheme _colors; + + bool get _isExtended => type == _FloatingActionButtonType.extended; + bool get _isLarge => type == _FloatingActionButtonType.large; + + @override + Color? get foregroundColor => _colors.onSecondary; + @override + Color? get backgroundColor => _colors.secondary; + @override + Color? get focusColor => _theme.focusColor; + @override + Color? get hoverColor => _theme.hoverColor; + @override + Color? get splashColor => _theme.splashColor; + @override + ShapeBorder? get shape => _isExtended ? const StadiumBorder() : const CircleBorder(); + @override + double? get iconSize => _isLarge ? 36.0 : 24.0; + + @override + EdgeInsetsGeometry? get extendedPadding => + EdgeInsetsDirectional.only(start: hasChild && _isExtended ? 16.0 : 20.0, end: 20.0); + @override + TextStyle? get extendedTextStyle => _theme.textTheme.labelLarge!.copyWith(letterSpacing: 1.2); +} + +// BEGIN GENERATED TOKEN PROPERTIES - FAB + +// 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. + +// dart format off +class _FABDefaultsM3 extends FloatingActionButtonThemeData { + _FABDefaultsM3(this.context, this.type, this.hasChild) + : super( + elevation: 6.0, + focusElevation: 6.0, + hoverElevation: 8.0, + highlightElevation: 6.0, + enableFeedback: true, + sizeConstraints: const BoxConstraints.tightFor( + width: 56.0, + height: 56.0, + ), + smallSizeConstraints: const BoxConstraints.tightFor( + width: 40.0, + height: 40.0, + ), + largeSizeConstraints: const BoxConstraints.tightFor( + width: 96.0, + height: 96.0, + ), + extendedSizeConstraints: const BoxConstraints.tightFor( + height: 56.0, + ), + extendedIconLabelSpacing: 8.0, + ); + + final BuildContext context; + final _FloatingActionButtonType type; + final bool hasChild; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + bool get _isExtended => type == _FloatingActionButtonType.extended; + + @override Color? get foregroundColor => _colors.onPrimaryContainer; + @override Color? get backgroundColor => _colors.primaryContainer; + @override Color? get splashColor => _colors.onPrimaryContainer.withOpacity(0.1); + @override Color? get focusColor => _colors.onPrimaryContainer.withOpacity(0.1); + @override Color? get hoverColor => _colors.onPrimaryContainer.withOpacity(0.08); + + @override + ShapeBorder? get shape => switch (type) { + _FloatingActionButtonType.regular => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + _FloatingActionButtonType.small => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + _FloatingActionButtonType.large => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + _FloatingActionButtonType.extended => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + }; + + @override + double? get iconSize => switch (type) { + _FloatingActionButtonType.regular => 24.0, + _FloatingActionButtonType.small => 24.0, + _FloatingActionButtonType.large => 36.0, + _FloatingActionButtonType.extended => 24.0, + }; + + @override EdgeInsetsGeometry? get extendedPadding => EdgeInsetsDirectional.only(start: hasChild && _isExtended ? 16.0 : 20.0, end: 20.0); + @override TextStyle? get extendedTextStyle => _textTheme.labelLarge; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - FAB diff --git a/packages/material_ui/lib/src/floating_action_button_location.dart b/packages/material_ui/lib/src/floating_action_button_location.dart new file mode 100644 index 000000000000..7fdb7492d69e --- /dev/null +++ b/packages/material_ui/lib/src/floating_action_button_location.dart @@ -0,0 +1,1045 @@ +// 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. + +/// @docImport 'app_bar.dart'; +/// @docImport 'bottom_app_bar.dart'; +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'list_tile.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'scaffold.dart'; + +/// The margin that a [FloatingActionButton] should leave between it and the +/// edge of the screen. +/// +/// [FloatingActionButtonLocation.endFloat] uses this to set the appropriate margin +/// between the [FloatingActionButton] and the end of the screen. +const double kFloatingActionButtonMargin = 16.0; + +/// The amount of time the [FloatingActionButton] takes to transition in or out. +/// +/// The [Scaffold] uses this to set the duration of [FloatingActionButton] +/// motion, entrance, and exit animations. +const Duration kFloatingActionButtonSegue = Duration(milliseconds: 200); + +/// The fraction of a circle the [FloatingActionButton] should turn when it enters. +/// +/// Its value corresponds to 0.125 of a full circle, equivalent to 45 degrees or pi/4 radians. +const double kFloatingActionButtonTurnInterval = 0.125; + +/// If a [FloatingActionButton] is used on a [Scaffold] in certain positions, +/// it is moved [kMiniButtonOffsetAdjustment] pixels closer to the edge of the screen. +/// +/// This is intended to be used with [FloatingActionButton.mini] set to true, +/// so that the floating action button appears to align with [CircleAvatar]s +/// in the [ListTile.leading] slot of a [ListTile] in a [ListView] in the +/// [Scaffold.body]. +/// +/// More specifically: +/// * In the following positions, the [FloatingActionButton] is moved *horizontally* +/// closer to the edge of the screen: +/// * [FloatingActionButtonLocation.miniStartTop] +/// * [FloatingActionButtonLocation.miniStartFloat] +/// * [FloatingActionButtonLocation.miniStartDocked] +/// * [FloatingActionButtonLocation.miniEndTop] +/// * [FloatingActionButtonLocation.miniEndFloat] +/// * [FloatingActionButtonLocation.miniEndDocked] +/// * In the following positions, the [FloatingActionButton] is moved *vertically* +/// closer to the bottom of the screen: +/// * [FloatingActionButtonLocation.miniStartFloat] +/// * [FloatingActionButtonLocation.miniCenterFloat] +/// * [FloatingActionButtonLocation.miniEndFloat] +const double kMiniButtonOffsetAdjustment = 4.0; + +/// An object that defines a position for the [FloatingActionButton] +/// based on the [Scaffold]'s [ScaffoldPrelayoutGeometry]. +/// +/// Flutter provides [FloatingActionButtonLocation]s for the common +/// [FloatingActionButton] placements in Material Design applications. These +/// locations are available as static members of this class. +/// +/// ## Floating Action Button placements +/// +/// The following diagrams show the available placement locations for the FloatingActionButton. +/// +/// * [FloatingActionButtonLocation.centerDocked]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_center_docked.png) +/// +/// +/// * [FloatingActionButtonLocation.centerFloat]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_center_float.png) +/// +/// +/// * [FloatingActionButtonLocation.centerTop]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_center_top.png) +/// +/// +/// * [FloatingActionButtonLocation.endDocked]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_docked.png) +/// +/// +/// * [FloatingActionButtonLocation.endFloat]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_float.png) +/// +/// +/// * [FloatingActionButtonLocation.endTop]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_top.png) +/// +/// +/// * [FloatingActionButtonLocation.startDocked]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_start_docked.png) +/// +/// +/// * [FloatingActionButtonLocation.startFloat]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_start_float.png) +/// +/// +/// * [FloatingActionButtonLocation.startTop]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_start_top.png) +/// +/// +/// * [FloatingActionButtonLocation.miniCenterDocked]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_center_docked.png) +/// +/// +/// * [FloatingActionButtonLocation.miniCenterFloat]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_center_float.png) +/// +/// +/// * [FloatingActionButtonLocation.miniCenterTop]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_center_top.png) +/// +/// +/// * [FloatingActionButtonLocation.miniEndDocked]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_end_docked.png) +/// +/// +/// * [FloatingActionButtonLocation.miniEndFloat]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_end_float.png) +/// +/// +/// * [FloatingActionButtonLocation.miniEndTop]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_end_top.png) +/// +/// +/// * [FloatingActionButtonLocation.miniStartDocked]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_start_docked.png) +/// +/// +/// * [FloatingActionButtonLocation.miniStartFloat]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_start_float.png) +/// +/// +/// * [FloatingActionButtonLocation.miniStartTop]: +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_start_top.png) +/// +/// +/// See also: +/// +/// * [FloatingActionButton], which is a circular button typically shown in the +/// bottom right corner of the app. +/// * [FloatingActionButtonAnimator], which is used to animate the +/// [Scaffold.floatingActionButton] from one [FloatingActionButtonLocation] to +/// another. +/// * [ScaffoldPrelayoutGeometry], the geometry that +/// [FloatingActionButtonLocation]s use to position the [FloatingActionButton]. +abstract class FloatingActionButtonLocation { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const FloatingActionButtonLocation(); + + /// Start-aligned [FloatingActionButton], floating over the transition between + /// the [Scaffold.appBar] and the [Scaffold.body]. + /// + /// To align a floating action button with [CircleAvatar]s in the + /// [ListTile.leading] slots of [ListTile]s in a [ListView] in the [Scaffold.body], + /// use [miniStartTop] and set [FloatingActionButton.mini] to true. + /// + /// This is unlikely to be a useful location for apps that lack a top [AppBar] + /// or that use a [SliverAppBar] in the scaffold body itself. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_start_top.png) + static const FloatingActionButtonLocation startTop = _StartTopFabLocation(); + + /// Start-aligned [FloatingActionButton], floating over the transition between + /// the [Scaffold.appBar] and the [Scaffold.body], optimized for mini floating + /// action buttons. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align with [CircleAvatar]s + /// in the [ListTile.leading] slot of a [ListTile] in a [ListView] in the + /// [Scaffold.body]. + /// + /// This is unlikely to be a useful location for apps that lack a top [AppBar] + /// or that use a [SliverAppBar] in the scaffold body itself. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_start_top.png) + static const FloatingActionButtonLocation miniStartTop = _MiniStartTopFabLocation(); + + /// Centered [FloatingActionButton], floating over the transition between + /// the [Scaffold.appBar] and the [Scaffold.body]. + /// + /// This is unlikely to be a useful location for apps that lack a top [AppBar] + /// or that use a [SliverAppBar] in the scaffold body itself. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_center_top.png) + static const FloatingActionButtonLocation centerTop = _CenterTopFabLocation(); + + /// Centered [FloatingActionButton], floating over the transition between + /// the [Scaffold.appBar] and the [Scaffold.body], intended to be used with + /// [FloatingActionButton.mini] set to true. + /// + /// This is unlikely to be a useful location for apps that lack a top [AppBar] + /// or that use a [SliverAppBar] in the scaffold body itself. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_center_top.png) + static const FloatingActionButtonLocation miniCenterTop = _MiniCenterTopFabLocation(); + + /// End-aligned [FloatingActionButton], floating over the transition between + /// the [Scaffold.appBar] and the [Scaffold.body]. + /// + /// To align a floating action button with [CircleAvatar]s in the + /// [ListTile.trailing] slots of [ListTile]s in a [ListView] in the [Scaffold.body], + /// use [miniEndTop] and set [FloatingActionButton.mini] to true. + /// + /// This is unlikely to be a useful location for apps that lack a top [AppBar] + /// or that use a [SliverAppBar] in the scaffold body itself. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_top.png) + static const FloatingActionButtonLocation endTop = _EndTopFabLocation(); + + /// End-aligned [FloatingActionButton], floating over the transition between + /// the [Scaffold.appBar] and the [Scaffold.body], optimized for mini floating + /// action buttons. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align with [CircleAvatar]s + /// in the [ListTile.trailing] slot of a [ListTile] in a [ListView] in the + /// [Scaffold.body]. + /// + /// This is unlikely to be a useful location for apps that lack a top [AppBar] + /// or that use a [SliverAppBar] in the scaffold body itself. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_end_top.png) + static const FloatingActionButtonLocation miniEndTop = _MiniEndTopFabLocation(); + + /// Start-aligned [FloatingActionButton], floating at the bottom of the screen. + /// + /// To align a floating action button with [CircleAvatar]s in the + /// [ListTile.leading] slots of [ListTile]s in a [ListView] in the [Scaffold.body], + /// use [miniStartFloat] and set [FloatingActionButton.mini] to true. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_start_float.png) + static const FloatingActionButtonLocation startFloat = _StartFloatFabLocation(); + + /// Start-aligned [FloatingActionButton], floating at the bottom of the screen, + /// optimized for mini floating action buttons. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align with [CircleAvatar]s + /// in the [ListTile.leading] slot of a [ListTile] in a [ListView] in the + /// [Scaffold.body]. + /// + /// Compared to [FloatingActionButtonLocation.startFloat], floating action + /// buttons using this location will move horizontally _and_ vertically + /// closer to the edges, by [kMiniButtonOffsetAdjustment] each. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_start_float.png) + static const FloatingActionButtonLocation miniStartFloat = _MiniStartFloatFabLocation(); + + /// Centered [FloatingActionButton], floating at the bottom of the screen. + /// + /// To position a mini floating action button, use [miniCenterFloat] and + /// set [FloatingActionButton.mini] to true. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_center_float.png) + static const FloatingActionButtonLocation centerFloat = _CenterFloatFabLocation(); + + /// Centered [FloatingActionButton], floating at the bottom of the screen, + /// optimized for mini floating action buttons. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align horizontally with + /// the locations [FloatingActionButtonLocation.miniStartFloat] + /// and [FloatingActionButtonLocation.miniEndFloat]. + /// + /// Compared to [FloatingActionButtonLocation.centerFloat], floating action + /// buttons using this location will move vertically down + /// by [kMiniButtonOffsetAdjustment]. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_center_float.png) + static const FloatingActionButtonLocation miniCenterFloat = _MiniCenterFloatFabLocation(); + + /// End-aligned [FloatingActionButton], floating at the bottom of the screen. + /// + /// This is the default alignment of [FloatingActionButton]s in Material applications. + /// + /// To align a floating action button with [CircleAvatar]s in the + /// [ListTile.trailing] slots of [ListTile]s in a [ListView] in the [Scaffold.body], + /// use [miniEndFloat] and set [FloatingActionButton.mini] to true. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_float.png) + static const FloatingActionButtonLocation endFloat = _EndFloatFabLocation(); + + /// End-aligned [FloatingActionButton], floating at the bottom of the screen, + /// optimized for mini floating action buttons. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align with [CircleAvatar]s + /// in the [ListTile.trailing] slot of a [ListTile] in a [ListView] in the + /// [Scaffold.body]. + /// + /// Compared to [FloatingActionButtonLocation.endFloat], floating action + /// buttons using this location will move horizontally _and_ vertically + /// closer to the edges, by [kMiniButtonOffsetAdjustment] each. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_end_float.png) + static const FloatingActionButtonLocation miniEndFloat = _MiniEndFloatFabLocation(); + + /// Start-aligned [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar. + /// + /// To align a floating action button with [CircleAvatar]s in the + /// [ListTile.leading] slots of [ListTile]s in a [ListView] in the [Scaffold.body], + /// use [miniStartDocked] and set [FloatingActionButton.mini] to true. + /// + /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar], + /// the bottom app bar can include a "notch" in its shape that accommodates + /// the overlapping floating action button. + /// + /// This is unlikely to be a useful location for apps that lack a bottom + /// navigation bar. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_start_docked.png) + static const FloatingActionButtonLocation startDocked = _StartDockedFabLocation(); + + /// Start-aligned [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar, + /// optimized for mini floating action buttons. + /// + /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar], + /// the bottom app bar can include a "notch" in its shape that accommodates + /// the overlapping floating action button. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align with [CircleAvatar]s + /// in the [ListTile.leading] slot of a [ListTile] in a [ListView] in the + /// [Scaffold.body]. + /// + /// This is unlikely to be a useful location for apps that lack a bottom + /// navigation bar. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_start_docked.png) + static const FloatingActionButtonLocation miniStartDocked = _MiniStartDockedFabLocation(); + + /// Centered [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar. + /// + /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar], + /// the bottom app bar can include a "notch" in its shape that accommodates + /// the overlapping floating action button. + /// + /// This is unlikely to be a useful location for apps that lack a bottom + /// navigation bar. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_center_docked.png) + static const FloatingActionButtonLocation centerDocked = _CenterDockedFabLocation(); + + /// Centered [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar; + /// intended to be used with [FloatingActionButton.mini] set to true. + /// + /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar], + /// the bottom app bar can include a "notch" in its shape that accommodates + /// the overlapping floating action button. + /// + /// This is unlikely to be a useful location for apps that lack a bottom + /// navigation bar. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_center_docked.png) + static const FloatingActionButtonLocation miniCenterDocked = _MiniCenterDockedFabLocation(); + + /// End-aligned [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar. + /// + /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar], + /// the bottom app bar can include a "notch" in its shape that accommodates + /// the overlapping floating action button. + /// + /// This is unlikely to be a useful location for apps that lack a bottom + /// navigation bar. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_docked.png) + static const FloatingActionButtonLocation endDocked = _EndDockedFabLocation(); + + /// End-aligned [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar, + /// optimized for mini floating action buttons. + /// + /// To align a floating action button with [CircleAvatar]s in the + /// [ListTile.trailing] slots of [ListTile]s in a [ListView] in the [Scaffold.body], + /// use [miniEndDocked] and set [FloatingActionButton.mini] to true. + /// + /// If the value of [Scaffold.bottomNavigationBar] is a [BottomAppBar], + /// the bottom app bar can include a "notch" in its shape that accommodates + /// the overlapping floating action button. + /// + /// This is intended to be used with [FloatingActionButton.mini] set to true, + /// so that the floating action button appears to align with [CircleAvatar]s + /// in the [ListTile.trailing] slot of a [ListTile] in a [ListView] in the + /// [Scaffold.body]. + /// + /// This is unlikely to be a useful location for apps that lack a bottom + /// navigation bar. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_mini_end_docked.png) + static const FloatingActionButtonLocation miniEndDocked = _MiniEndDockedFabLocation(); + + /// End-aligned [FloatingActionButton], floating over the + /// [Scaffold.bottomNavigationBar] so that the floating + /// action button lines up with the center of the bottom navigation bar. + /// + /// This is unlikely to be a useful location for apps which has a [BottomNavigationBar] + /// or a non material 3 [BottomAppBar]. + /// + /// ![](https://flutter.github.io/assets-for-api-docs/assets/material/floating_action_button_location_end_contained.png) + static const FloatingActionButtonLocation endContained = _EndContainedFabLocation(); + + /// Places the [FloatingActionButton] based on the [Scaffold]'s layout. + /// + /// This uses a [ScaffoldPrelayoutGeometry], which the [Scaffold] constructs + /// during its layout phase after it has laid out every widget it can lay out + /// except the [FloatingActionButton]. The [Scaffold] uses the [Offset] + /// returned from this method to position the [FloatingActionButton] and + /// complete its layout. + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry); + + @override + String toString() => objectRuntimeType(this, 'FloatingActionButtonLocation'); +} + +/// A base class that simplifies building [FloatingActionButtonLocation]s when +/// used with mixins [FabTopOffsetY], [FabFloatOffsetY], [FabDockedOffsetY], +/// [FabStartOffsetX], [FabCenterOffsetX], [FabEndOffsetX], and [FabMiniOffsetAdjustment]. +/// +/// A subclass of [FloatingActionButtonLocation] which implements its [getOffset] method +/// using three other methods: [getOffsetX], [getOffsetY], and [isMini]. +/// +/// Different mixins on this class override different methods, so that combining +/// a set of mixins creates a floating action button location. +/// +/// For example: the location [FloatingActionButtonLocation.miniEndTop] +/// is based on a class that extends [StandardFabLocation] +/// with mixins [FabMiniOffsetAdjustment], [FabEndOffsetX], and [FabTopOffsetY]. +/// +/// You can create your own subclass of [StandardFabLocation] +/// to implement a custom [FloatingActionButtonLocation]. +/// +/// {@tool dartpad} +/// This is an example of a user-defined [FloatingActionButtonLocation]. +/// +/// The example shows a [Scaffold] with an [AppBar], a [BottomAppBar], and a +/// [FloatingActionButton] using a custom [FloatingActionButtonLocation]. +/// +/// The new [FloatingActionButtonLocation] is defined +/// by extending [StandardFabLocation] with two mixins, +/// [FabEndOffsetX] and [FabFloatOffsetY], and overriding the +/// [getOffsetX] method to adjust the FAB's x-coordinate, creating a +/// [FloatingActionButtonLocation] slightly different from +/// [FloatingActionButtonLocation.endFloat]. +/// +/// ** See code in examples/api/lib/material/floating_action_button_location/standard_fab_location.0.dart ** +/// {@end-tool} +/// +abstract class StandardFabLocation extends FloatingActionButtonLocation { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const StandardFabLocation(); + + /// Obtains the x-offset to place the [FloatingActionButton] based on the + /// [Scaffold]'s layout. + /// + /// Used by [getOffset] to compute its x-coordinate. + double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment); + + /// Obtains the y-offset to place the [FloatingActionButton] based on the + /// [Scaffold]'s layout. + /// + /// Used by [getOffset] to compute its y-coordinate. + double getOffsetY(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment); + + /// A function returning whether this [StandardFabLocation] is optimized for + /// mini [FloatingActionButton]s. + bool isMini() => false; + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + final double adjustment = isMini() ? kMiniButtonOffsetAdjustment : 0.0; + return Offset( + getOffsetX(scaffoldGeometry, adjustment), + getOffsetY(scaffoldGeometry, adjustment), + ); + } + + /// Calculates x-offset for left-aligned [FloatingActionButtonLocation]s. + static double _leftOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + return kFloatingActionButtonMargin + scaffoldGeometry.minInsets.left - adjustment; + } + + /// Calculates x-offset for right-aligned [FloatingActionButtonLocation]s. + static double _rightOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + return scaffoldGeometry.scaffoldSize.width - + kFloatingActionButtonMargin - + scaffoldGeometry.minInsets.right - + scaffoldGeometry.floatingActionButtonSize.width + + adjustment; + } +} + +/// Mixin for a "top" floating action button location, such as +/// [FloatingActionButtonLocation.startTop]. +/// +/// The `adjustment`, typically [kMiniButtonOffsetAdjustment], is ignored in the +/// Y axis of "top" positions. For "top" positions, the X offset is adjusted to +/// move closer to the edge of the screen. This is so that a minified floating +/// action button appears to align with [CircleAvatar]s in the +/// [ListTile.leading] slot of a [ListTile] in a [ListView] in the +/// [Scaffold.body]. +mixin FabTopOffsetY on StandardFabLocation { + /// Calculates y-offset for [FloatingActionButtonLocation]s floating over + /// the transition between the [Scaffold.appBar] and the [Scaffold.body]. + @override + double getOffsetY(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + if (scaffoldGeometry.contentTop > scaffoldGeometry.minViewPadding.top) { + final double fabHalfHeight = scaffoldGeometry.floatingActionButtonSize.height / 2.0; + return scaffoldGeometry.contentTop - fabHalfHeight; + } + // Otherwise, ensure we are placed within the bounds of a safe area. + return scaffoldGeometry.minViewPadding.top; + } +} + +/// Mixin for a "float" floating action button location, such as [FloatingActionButtonLocation.centerFloat]. +mixin FabFloatOffsetY on StandardFabLocation { + /// Calculates y-offset for [FloatingActionButtonLocation]s floating at + /// the bottom of the screen. + @override + double getOffsetY(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + final double contentBottom = scaffoldGeometry.contentBottom; + final double bottomContentHeight = scaffoldGeometry.scaffoldSize.height - contentBottom; + final double bottomSheetHeight = scaffoldGeometry.bottomSheetSize.height; + final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height; + final double snackBarHeight = scaffoldGeometry.snackBarSize.height; + final double safeMargin = math.max( + kFloatingActionButtonMargin, + scaffoldGeometry.minViewPadding.bottom - bottomContentHeight + kFloatingActionButtonMargin, + ); + + double fabY = contentBottom - fabHeight - safeMargin; + if (snackBarHeight > 0.0) { + fabY = math.min( + fabY, + contentBottom - snackBarHeight - fabHeight - kFloatingActionButtonMargin, + ); + } + if (bottomSheetHeight > 0.0) { + fabY = math.min(fabY, contentBottom - bottomSheetHeight - fabHeight / 2.0); + } + return fabY + adjustment; + } +} + +/// Mixin for a "docked" floating action button location, such as [FloatingActionButtonLocation.endDocked]. +mixin FabDockedOffsetY on StandardFabLocation { + /// Calculates y-offset for [FloatingActionButtonLocation]s floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the top of the bottom navigation bar. + @override + double getOffsetY(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + final double contentBottom = scaffoldGeometry.contentBottom; + final double contentMargin = scaffoldGeometry.scaffoldSize.height - contentBottom; + final double bottomViewPadding = scaffoldGeometry.minViewPadding.bottom; + final double bottomSheetHeight = scaffoldGeometry.bottomSheetSize.height; + final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height; + final double snackBarHeight = scaffoldGeometry.snackBarSize.height; + final double bottomMinInset = scaffoldGeometry.minInsets.bottom; + + double safeMargin; + + if (contentMargin > bottomMinInset + fabHeight / 2.0) { + // If contentMargin is higher than bottomMinInset enough to display the + // FAB without clipping, don't provide a margin + safeMargin = 0.0; + } else if (bottomMinInset == 0.0) { + // If bottomMinInset is zero(the software keyboard is not on the screen) + // provide bottomViewPadding as margin + safeMargin = bottomViewPadding; + } else { + // Provide a margin that would shift the FAB enough so that it stays away + // from the keyboard + safeMargin = fabHeight / 2.0 + kFloatingActionButtonMargin; + } + + double fabY = contentBottom - fabHeight / 2.0 - safeMargin; + // The FAB should sit with a margin between it and the snack bar. + if (snackBarHeight > 0.0) { + fabY = math.min( + fabY, + contentBottom - snackBarHeight - fabHeight - kFloatingActionButtonMargin, + ); + } + // The FAB should sit with its center in front of the top of the bottom sheet. + if (bottomSheetHeight > 0.0) { + fabY = math.min(fabY, contentBottom - bottomSheetHeight - fabHeight / 2.0); + } + final double maxFabY = scaffoldGeometry.scaffoldSize.height - fabHeight - safeMargin; + return math.min(maxFabY, fabY); + } +} + +/// Mixin for a "contained" floating action button location, such as [FloatingActionButtonLocation.endContained]. +mixin FabContainedOffsetY on StandardFabLocation { + /// Calculates y-offset for [FloatingActionButtonLocation]s floating over the + /// [Scaffold.bottomNavigationBar] so that the center of the floating + /// action button lines up with the center of the bottom navigation bar. + @override + double getOffsetY(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + final double contentBottom = scaffoldGeometry.contentBottom; + final double contentMargin = scaffoldGeometry.scaffoldSize.height - contentBottom; + final double bottomViewPadding = scaffoldGeometry.minViewPadding.bottom; + final double fabHeight = scaffoldGeometry.floatingActionButtonSize.height; + + double safeMargin; + if (contentMargin > bottomViewPadding + fabHeight) { + // If contentMargin is higher than bottomViewPadding enough to display the + // FAB without clipping, don't provide a margin + safeMargin = 0.0; + } else { + safeMargin = bottomViewPadding; + } + + // This is to compute the distance between the content bottom to the top edge + // of the floating action button. This can be negative if content margin is + // too small. + final double contentBottomToFabTop = (contentMargin - bottomViewPadding - fabHeight) / 2.0; + final double fabY = contentBottom + contentBottomToFabTop; + final double maxFabY = scaffoldGeometry.scaffoldSize.height - fabHeight - safeMargin; + + return math.min(maxFabY, fabY); + } +} + +/// Mixin for a "start" floating action button location, such as [FloatingActionButtonLocation.startTop]. +mixin FabStartOffsetX on StandardFabLocation { + /// Calculates x-offset for start-aligned [FloatingActionButtonLocation]s. + @override + double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + return switch (scaffoldGeometry.textDirection) { + TextDirection.rtl => StandardFabLocation._rightOffsetX(scaffoldGeometry, adjustment), + TextDirection.ltr => StandardFabLocation._leftOffsetX(scaffoldGeometry, adjustment), + }; + } +} + +/// Mixin for a "center" floating action button location, such as [FloatingActionButtonLocation.centerFloat]. +mixin FabCenterOffsetX on StandardFabLocation { + /// Calculates x-offset for center-aligned [FloatingActionButtonLocation]s. + @override + double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + return (scaffoldGeometry.scaffoldSize.width - scaffoldGeometry.floatingActionButtonSize.width) / + 2.0; + } +} + +/// Mixin for an "end" floating action button location, such as [FloatingActionButtonLocation.endDocked]. +mixin FabEndOffsetX on StandardFabLocation { + /// Calculates x-offset for end-aligned [FloatingActionButtonLocation]s. + @override + double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + return switch (scaffoldGeometry.textDirection) { + TextDirection.rtl => StandardFabLocation._leftOffsetX(scaffoldGeometry, adjustment), + TextDirection.ltr => StandardFabLocation._rightOffsetX(scaffoldGeometry, adjustment), + }; + } +} + +/// Mixin for a "mini" floating action button location, such as [FloatingActionButtonLocation.miniStartTop]. +mixin FabMiniOffsetAdjustment on StandardFabLocation { + @override + bool isMini() => true; +} + +class _StartTopFabLocation extends StandardFabLocation with FabStartOffsetX, FabTopOffsetY { + const _StartTopFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.startTop'; +} + +class _MiniStartTopFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabStartOffsetX, FabTopOffsetY { + const _MiniStartTopFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniStartTop'; +} + +class _CenterTopFabLocation extends StandardFabLocation with FabCenterOffsetX, FabTopOffsetY { + const _CenterTopFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.centerTop'; +} + +class _MiniCenterTopFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabCenterOffsetX, FabTopOffsetY { + const _MiniCenterTopFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniCenterTop'; +} + +class _EndTopFabLocation extends StandardFabLocation with FabEndOffsetX, FabTopOffsetY { + const _EndTopFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.endTop'; +} + +class _MiniEndTopFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabEndOffsetX, FabTopOffsetY { + const _MiniEndTopFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniEndTop'; +} + +class _StartFloatFabLocation extends StandardFabLocation with FabStartOffsetX, FabFloatOffsetY { + const _StartFloatFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.startFloat'; +} + +class _MiniStartFloatFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabStartOffsetX, FabFloatOffsetY { + const _MiniStartFloatFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniStartFloat'; +} + +class _CenterFloatFabLocation extends StandardFabLocation with FabCenterOffsetX, FabFloatOffsetY { + const _CenterFloatFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.centerFloat'; +} + +class _MiniCenterFloatFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabCenterOffsetX, FabFloatOffsetY { + const _MiniCenterFloatFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniCenterFloat'; +} + +class _EndFloatFabLocation extends StandardFabLocation with FabEndOffsetX, FabFloatOffsetY { + const _EndFloatFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.endFloat'; +} + +class _MiniEndFloatFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabEndOffsetX, FabFloatOffsetY { + const _MiniEndFloatFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniEndFloat'; +} + +class _StartDockedFabLocation extends StandardFabLocation with FabStartOffsetX, FabDockedOffsetY { + const _StartDockedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.startDocked'; +} + +class _MiniStartDockedFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabStartOffsetX, FabDockedOffsetY { + const _MiniStartDockedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniStartDocked'; +} + +class _CenterDockedFabLocation extends StandardFabLocation with FabCenterOffsetX, FabDockedOffsetY { + const _CenterDockedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.centerDocked'; +} + +class _MiniCenterDockedFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabCenterOffsetX, FabDockedOffsetY { + const _MiniCenterDockedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniCenterDocked'; +} + +class _EndDockedFabLocation extends StandardFabLocation with FabEndOffsetX, FabDockedOffsetY { + const _EndDockedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.endDocked'; +} + +class _MiniEndDockedFabLocation extends StandardFabLocation + with FabMiniOffsetAdjustment, FabEndOffsetX, FabDockedOffsetY { + const _MiniEndDockedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.miniEndDocked'; +} + +class _EndContainedFabLocation extends StandardFabLocation with FabEndOffsetX, FabContainedOffsetY { + const _EndContainedFabLocation(); + + @override + String toString() => 'FloatingActionButtonLocation.endContained'; +} + +/// Provider of animations to move the [FloatingActionButton] between [FloatingActionButtonLocation]s. +/// +/// The [Scaffold] uses [Scaffold.floatingActionButtonAnimator] to define: +/// +/// * The [Offset] of the [FloatingActionButton] between the old and new +/// [FloatingActionButtonLocation]s as part of the transition animation. +/// * An [Animation] to scale the [FloatingActionButton] during the transition. +/// * An [Animation] to rotate the [FloatingActionButton] during the transition. +/// * Where to start a new animation from if an animation is interrupted. +/// +/// See also: +/// +/// * [FloatingActionButton], which is a circular button typically shown in the +/// bottom right corner of the app. +/// * [FloatingActionButtonLocation], which the [Scaffold] uses to place the +/// [Scaffold.floatingActionButton] within the [Scaffold]'s layout. +abstract class FloatingActionButtonAnimator { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const FloatingActionButtonAnimator(); + + /// Moves the [FloatingActionButton] by scaling out and then in at a new + /// [FloatingActionButtonLocation]. + /// + /// This animator shrinks the [FloatingActionButton] down until it disappears, then + /// grows it back to full size at its new [FloatingActionButtonLocation]. + /// + /// This is the default [FloatingActionButton] motion animation. + static const FloatingActionButtonAnimator scaling = _ScalingFabMotionAnimator(); + + /// Gets the [FloatingActionButton]'s position relative to the origin of the + /// [Scaffold] based on [progress]. + /// + /// [begin] is the [Offset] provided by the previous + /// [FloatingActionButtonLocation]. + /// + /// [end] is the [Offset] provided by the new + /// [FloatingActionButtonLocation]. + /// + /// [progress] is the current progress of the transition animation. + /// When [progress] is 0.0, the returned [Offset] should be equal to [begin]. + /// when [progress] is 1.0, the returned [Offset] should be equal to [end]. + Offset getOffset({required Offset begin, required Offset end, required double progress}); + + /// Animates the scale of the [FloatingActionButton]. + /// + /// The animation should both start and end with a value of 1.0. + /// + /// For example, to create an animation that linearly scales out and then back in, + /// you could join animations that pass each other: + /// + /// ```dart + /// @override + /// Animation<double> getScaleAnimation({required Animation<double> parent}) { + /// // The animations will cross at value 0, and the train will return to 1.0. + /// return TrainHoppingAnimation( + /// Tween<double>(begin: 1.0, end: -1.0).animate(parent), + /// Tween<double>(begin: -1.0, end: 1.0).animate(parent), + /// ); + /// } + /// ``` + Animation<double> getScaleAnimation({required Animation<double> parent}); + + /// Animates the rotation of [Scaffold.floatingActionButton]. + /// + /// The animation should both start and end with a value of 0.0 or 1.0. + /// + /// The animation values are a fraction of a full circle, with 0.0 and 1.0 + /// corresponding to 0 and 360 degrees, while 0.5 corresponds to 180 degrees. + /// + /// For example, to create a rotation animation that rotates the + /// [FloatingActionButton] through a full circle: + /// + /// ```dart + /// @override + /// Animation<double> getRotationAnimation({required Animation<double> parent}) { + /// return Tween<double>(begin: 0.0, end: 1.0).animate(parent); + /// } + /// ``` + Animation<double> getRotationAnimation({required Animation<double> parent}); + + /// Gets the progress value to restart a motion animation from when the animation is interrupted. + /// + /// [previousValue] is the value of the animation before it was interrupted. + /// + /// The restart of the animation will affect all three parts of the motion animation: + /// offset animation, scale animation, and rotation animation. + /// + /// An interruption triggers if the [Scaffold] is given a new [FloatingActionButtonLocation] + /// while it is still animating a transition between two previous [FloatingActionButtonLocation]s. + /// + /// A sensible default is usually 0.0, which is the same as restarting + /// the animation from the beginning, regardless of the original state of the animation. + double getAnimationRestart(double previousValue) => 0.0; + + /// Creates an instance of [FloatingActionButtonAnimator] where the [FloatingActionButton] + /// does not animate on entrance and exit when [FloatingActionButtonLocation] is shown + /// or hidden and when transitioning between [FloatingActionButtonLocation]s. + /// + /// {@tool dartpad} + /// This sample showcases how to override [FloatingActionButton] entrance and exit animations + /// using [FloatingActionButtonAnimator.noAnimation] in [Scaffold.floatingActionButtonAnimator]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.floating_action_button_animator.0.dart ** + /// {@end-tool} + static const FloatingActionButtonAnimator noAnimation = _NoAnimationFabMotionAnimator(); + + @override + String toString() => objectRuntimeType(this, 'FloatingActionButtonAnimator'); +} + +class _ScalingFabMotionAnimator extends FloatingActionButtonAnimator { + const _ScalingFabMotionAnimator(); + + @override + Offset getOffset({required Offset begin, required Offset end, required double progress}) { + if (progress < 0.5) { + return begin; + } else { + return end; + } + } + + @override + Animation<double> getScaleAnimation({required Animation<double> parent}) { + // Animate the scale down from 1 to 0 in the first half of the animation + // then from 0 back to 1 in the second half. + const Curve curve = Interval(0.5, 1.0, curve: Curves.ease); + return _AnimationSwap<double>( + ReverseAnimation(parent.drive(CurveTween(curve: curve.flipped))), + parent.drive(CurveTween(curve: curve)), + parent, + 0.5, + ); + } + + // Because we only see the last half of the rotation tween, + // it needs to go twice as far. + static final Animatable<double> _rotationTween = Tween<double>( + begin: 1.0 - kFloatingActionButtonTurnInterval * 2.0, + end: 1.0, + ); + + static final Animatable<double> _thresholdCenterTween = CurveTween(curve: const Threshold(0.5)); + + @override + Animation<double> getRotationAnimation({required Animation<double> parent}) { + // This rotation will turn on the way in, but not on the way out. + return _AnimationSwap<double>( + parent.drive(_rotationTween), + ReverseAnimation(parent.drive(_thresholdCenterTween)), + parent, + 0.5, + ); + } + + // If the animation was just starting, we'll continue from where we left off. + // If the animation was finishing, we'll treat it as if we were starting at that point in reverse. + // This avoids a size jump during the animation. + @override + double getAnimationRestart(double previousValue) => math.min(1.0 - previousValue, previousValue); +} + +class _NoAnimationFabMotionAnimator extends FloatingActionButtonAnimator { + const _NoAnimationFabMotionAnimator(); + + @override + Offset getOffset({required Offset begin, required Offset end, required double progress}) { + return end; + } + + @override + Animation<double> getRotationAnimation({required Animation<double> parent}) { + return const AlwaysStoppedAnimation<double>(1.0); + } + + @override + Animation<double> getScaleAnimation({required Animation<double> parent}) { + return const AlwaysStoppedAnimation<double>(1.0); + } +} + +/// An animation that swaps from one animation to the next when the [parent] passes [swapThreshold]. +/// +/// The [value] of this animation is the value of [first] when `parent.value` < [swapThreshold] +/// and the value of [next] otherwise. +class _AnimationSwap<T> extends CompoundAnimation<T> { + /// Creates an [_AnimationSwap]. + /// + /// Either argument can be an [_AnimationSwap] itself to combine multiple + /// animations. + _AnimationSwap(Animation<T> first, Animation<T> next, this.parent, this.swapThreshold) + : super(first: first, next: next); + + final Animation<double> parent; + final double swapThreshold; + + @override + T get value => parent.value < swapThreshold ? first.value : next.value; +} diff --git a/packages/material_ui/lib/src/floating_action_button_theme.dart b/packages/material_ui/lib/src/floating_action_button_theme.dart new file mode 100644 index 000000000000..94b7daf82af7 --- /dev/null +++ b/packages/material_ui/lib/src/floating_action_button_theme.dart @@ -0,0 +1,411 @@ +// 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. + +/// @docImport 'floating_action_button.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'material.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [FloatingActionButton] +/// widgets. +/// +/// Descendant widgets obtain the current [FloatingActionButtonThemeData] object +/// using [FloatingActionButtonTheme.of]. Instances of +/// [FloatingActionButtonThemeData] can be customized with +/// [FloatingActionButtonThemeData.copyWith]. +/// +/// Typically a [FloatingActionButtonThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.floatingActionButtonTheme]. +/// +/// All [FloatingActionButtonThemeData] properties are `null` by default. +/// When null, the [FloatingActionButton] provides its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class FloatingActionButtonThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.floatingActionButtonTheme] and + /// [FloatingActionButtonTheme]. + const FloatingActionButtonThemeData({ + this.foregroundColor, + this.backgroundColor, + this.focusColor, + this.hoverColor, + this.splashColor, + this.elevation, + this.focusElevation, + this.hoverElevation, + this.disabledElevation, + this.highlightElevation, + this.shape, + this.enableFeedback, + this.iconSize, + this.sizeConstraints, + this.smallSizeConstraints, + this.largeSizeConstraints, + this.extendedSizeConstraints, + this.extendedIconLabelSpacing, + this.extendedPadding, + this.extendedTextStyle, + this.mouseCursor, + }); + + /// Color to be used for the unselected, enabled [FloatingActionButton]'s + /// foreground. + final Color? foregroundColor; + + /// Color to be used for the unselected, enabled [FloatingActionButton]'s + /// background. + final Color? backgroundColor; + + /// The color to use for filling the button when the button has input focus. + final Color? focusColor; + + /// The color to use for filling the button when the button has a pointer + /// hovering over it. + final Color? hoverColor; + + /// The splash color for this [FloatingActionButton]'s [InkWell]. + final Color? splashColor; + + /// The z-coordinate to be used for the unselected, enabled + /// [FloatingActionButton]'s elevation foreground. + final double? elevation; + + /// The z-coordinate at which to place this button relative to its parent when + /// the button has the input focus. + /// + /// This controls the size of the shadow below the floating action button. + final double? focusElevation; + + /// The z-coordinate at which to place this button relative to its parent when + /// the button is enabled and has a pointer hovering over it. + /// + /// This controls the size of the shadow below the floating action button. + final double? hoverElevation; + + /// The z-coordinate to be used for the disabled [FloatingActionButton]'s + /// elevation foreground. + final double? disabledElevation; + + /// The z-coordinate to be used for the selected, enabled + /// [FloatingActionButton]'s elevation foreground. + final double? highlightElevation; + + /// The shape to be used for the floating action button's [Material]. + final ShapeBorder? shape; + + /// If specified, defines the feedback property for [FloatingActionButton]. + /// + /// If [FloatingActionButton.enableFeedback] is provided, [enableFeedback] is + /// ignored. + final bool? enableFeedback; + + /// Overrides the default icon size for the [FloatingActionButton]; + final double? iconSize; + + /// Overrides the default size constraints for the [FloatingActionButton]. + final BoxConstraints? sizeConstraints; + + /// Overrides the default size constraints for [FloatingActionButton.small]. + final BoxConstraints? smallSizeConstraints; + + /// Overrides the default size constraints for [FloatingActionButton.large]. + final BoxConstraints? largeSizeConstraints; + + /// Overrides the default size constraints for [FloatingActionButton.extended]. + final BoxConstraints? extendedSizeConstraints; + + /// The spacing between the icon and the label for an extended + /// [FloatingActionButton]. + final double? extendedIconLabelSpacing; + + /// The padding for an extended [FloatingActionButton]'s content. + final EdgeInsetsGeometry? extendedPadding; + + /// The text style for an extended [FloatingActionButton]'s label. + final TextStyle? extendedTextStyle; + + /// {@macro flutter.material.RawMaterialButton.mouseCursor} + /// + /// If specified, overrides the default value of [FloatingActionButton.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + FloatingActionButtonThemeData copyWith({ + Color? foregroundColor, + Color? backgroundColor, + Color? focusColor, + Color? hoverColor, + Color? splashColor, + double? elevation, + double? focusElevation, + double? hoverElevation, + double? disabledElevation, + double? highlightElevation, + ShapeBorder? shape, + bool? enableFeedback, + double? iconSize, + BoxConstraints? sizeConstraints, + BoxConstraints? smallSizeConstraints, + BoxConstraints? largeSizeConstraints, + BoxConstraints? extendedSizeConstraints, + double? extendedIconLabelSpacing, + EdgeInsetsGeometry? extendedPadding, + TextStyle? extendedTextStyle, + WidgetStateProperty<MouseCursor?>? mouseCursor, + }) { + return FloatingActionButtonThemeData( + foregroundColor: foregroundColor ?? this.foregroundColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + focusColor: focusColor ?? this.focusColor, + hoverColor: hoverColor ?? this.hoverColor, + splashColor: splashColor ?? this.splashColor, + elevation: elevation ?? this.elevation, + focusElevation: focusElevation ?? this.focusElevation, + hoverElevation: hoverElevation ?? this.hoverElevation, + disabledElevation: disabledElevation ?? this.disabledElevation, + highlightElevation: highlightElevation ?? this.highlightElevation, + shape: shape ?? this.shape, + enableFeedback: enableFeedback ?? this.enableFeedback, + iconSize: iconSize ?? this.iconSize, + sizeConstraints: sizeConstraints ?? this.sizeConstraints, + smallSizeConstraints: smallSizeConstraints ?? this.smallSizeConstraints, + largeSizeConstraints: largeSizeConstraints ?? this.largeSizeConstraints, + extendedSizeConstraints: extendedSizeConstraints ?? this.extendedSizeConstraints, + extendedIconLabelSpacing: extendedIconLabelSpacing ?? this.extendedIconLabelSpacing, + extendedPadding: extendedPadding ?? this.extendedPadding, + extendedTextStyle: extendedTextStyle ?? this.extendedTextStyle, + mouseCursor: mouseCursor ?? this.mouseCursor, + ); + } + + /// Linearly interpolate between two floating action button themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static FloatingActionButtonThemeData? lerp( + FloatingActionButtonThemeData? a, + FloatingActionButtonThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return FloatingActionButtonThemeData( + foregroundColor: Color.lerp(a?.foregroundColor, b?.foregroundColor, t), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + focusColor: Color.lerp(a?.focusColor, b?.focusColor, t), + hoverColor: Color.lerp(a?.hoverColor, b?.hoverColor, t), + splashColor: Color.lerp(a?.splashColor, b?.splashColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + focusElevation: lerpDouble(a?.focusElevation, b?.focusElevation, t), + hoverElevation: lerpDouble(a?.hoverElevation, b?.hoverElevation, t), + disabledElevation: lerpDouble(a?.disabledElevation, b?.disabledElevation, t), + highlightElevation: lerpDouble(a?.highlightElevation, b?.highlightElevation, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback, + iconSize: lerpDouble(a?.iconSize, b?.iconSize, t), + sizeConstraints: BoxConstraints.lerp(a?.sizeConstraints, b?.sizeConstraints, t), + smallSizeConstraints: BoxConstraints.lerp( + a?.smallSizeConstraints, + b?.smallSizeConstraints, + t, + ), + largeSizeConstraints: BoxConstraints.lerp( + a?.largeSizeConstraints, + b?.largeSizeConstraints, + t, + ), + extendedSizeConstraints: BoxConstraints.lerp( + a?.extendedSizeConstraints, + b?.extendedSizeConstraints, + t, + ), + extendedIconLabelSpacing: lerpDouble( + a?.extendedIconLabelSpacing, + b?.extendedIconLabelSpacing, + t, + ), + extendedPadding: EdgeInsetsGeometry.lerp(a?.extendedPadding, b?.extendedPadding, t), + extendedTextStyle: TextStyle.lerp(a?.extendedTextStyle, b?.extendedTextStyle, t), + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + ); + } + + @override + int get hashCode => Object.hash( + foregroundColor, + backgroundColor, + focusColor, + hoverColor, + splashColor, + elevation, + focusElevation, + hoverElevation, + disabledElevation, + highlightElevation, + shape, + enableFeedback, + iconSize, + sizeConstraints, + smallSizeConstraints, + largeSizeConstraints, + extendedSizeConstraints, + extendedIconLabelSpacing, + extendedPadding, + Object.hash(extendedTextStyle, mouseCursor), + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is FloatingActionButtonThemeData && + other.foregroundColor == foregroundColor && + other.backgroundColor == backgroundColor && + other.focusColor == focusColor && + other.hoverColor == hoverColor && + other.splashColor == splashColor && + other.elevation == elevation && + other.focusElevation == focusElevation && + other.hoverElevation == hoverElevation && + other.disabledElevation == disabledElevation && + other.highlightElevation == highlightElevation && + other.shape == shape && + other.enableFeedback == enableFeedback && + other.iconSize == iconSize && + other.sizeConstraints == sizeConstraints && + other.smallSizeConstraints == smallSizeConstraints && + other.largeSizeConstraints == largeSizeConstraints && + other.extendedSizeConstraints == extendedSizeConstraints && + other.extendedIconLabelSpacing == extendedIconLabelSpacing && + other.extendedPadding == extendedPadding && + other.extendedTextStyle == extendedTextStyle && + other.mouseCursor == mouseCursor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + + properties.add(ColorProperty('foregroundColor', foregroundColor, defaultValue: null)); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DoubleProperty('focusElevation', focusElevation, defaultValue: null)); + properties.add(DoubleProperty('hoverElevation', hoverElevation, defaultValue: null)); + properties.add(DoubleProperty('disabledElevation', disabledElevation, defaultValue: null)); + properties.add(DoubleProperty('highlightElevation', highlightElevation, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null)); + properties.add(DoubleProperty('iconSize', iconSize, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>('sizeConstraints', sizeConstraints, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'smallSizeConstraints', + smallSizeConstraints, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'largeSizeConstraints', + largeSizeConstraints, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'extendedSizeConstraints', + extendedSizeConstraints, + defaultValue: null, + ), + ); + properties.add( + DoubleProperty('extendedIconLabelSpacing', extendedIconLabelSpacing, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'extendedPadding', + extendedPadding, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>('extendedTextStyle', extendedTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + } +} + +/// An inherited widget that defines the configuration for +/// [FloatingActionButton]s in this widget's subtree. +/// +/// Values specified here are used for [FloatingActionButton] properties that are not +/// given an explicit non-null value. +class FloatingActionButtonTheme extends InheritedTheme { + /// Creates a floating action button theme that controls the configurations for + /// [FloatingActionButton]s in its widget subtree. + const FloatingActionButtonTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant [FloatingActionButton] widgets. + final FloatingActionButtonThemeData data; + + /// The closest instance of this class's [data] value that encloses the given + /// context. + /// + /// If there is no ancestor, it returns [ThemeData.floatingActionButtonTheme]. Applications + /// can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// FloatingActionButtonThemeData theme = FloatingActionButtonTheme.of(context); + /// ``` + static FloatingActionButtonThemeData of(BuildContext context) { + final FloatingActionButtonTheme? fabTheme = context + .dependOnInheritedWidgetOfExactType<FloatingActionButtonTheme>(); + return fabTheme?.data ?? Theme.of(context).floatingActionButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return FloatingActionButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(FloatingActionButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/grid_tile.dart b/packages/material_ui/lib/src/grid_tile.dart new file mode 100644 index 000000000000..51ee3bf57ac6 --- /dev/null +++ b/packages/material_ui/lib/src/grid_tile.dart @@ -0,0 +1,57 @@ +// 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. + +/// @docImport 'grid_tile_bar.dart'; +library; + +import 'package:flutter/widgets.dart'; + +/// A tile in a Material Design grid list. +/// +/// A grid list is a [GridView] of tiles in a vertical and horizontal +/// array. Each tile typically contains some visually rich content (e.g., an +/// image) together with a [GridTileBar] in either a [header] or a [footer]. +/// +/// See also: +/// +/// * [GridView], which is a scrollable grid of tiles. +/// * [GridTileBar], which is typically used in either the [header] or +/// [footer]. +/// * <https://material.io/design/components/image-lists.html> +class GridTile extends StatelessWidget { + /// Creates a grid tile. + /// + /// Must have a child. Does not typically have both a header and a footer. + const GridTile({super.key, this.header, this.footer, required this.child}); + + /// The widget to show over the top of this grid tile. + /// + /// Typically a [GridTileBar]. + final Widget? header; + + /// The widget to show over the bottom of this grid tile. + /// + /// Typically a [GridTileBar]. + final Widget? footer; + + /// The widget that fills the tile. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + Widget build(BuildContext context) { + if (header == null && footer == null) { + return child; + } + + return Stack( + children: <Widget>[ + Positioned.fill(child: child), + if (header != null) Positioned(top: 0.0, left: 0.0, right: 0.0, child: header!), + if (footer != null) Positioned(left: 0.0, bottom: 0.0, right: 0.0, child: footer!), + ], + ); + } +} diff --git a/packages/material_ui/lib/src/grid_tile_bar.dart b/packages/material_ui/lib/src/grid_tile_bar.dart new file mode 100644 index 000000000000..171e3db1f2be --- /dev/null +++ b/packages/material_ui/lib/src/grid_tile_bar.dart @@ -0,0 +1,126 @@ +// 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. + +/// @docImport 'grid_tile.dart'; +/// @docImport 'icon_button.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +/// A header used in a Material Design [GridTile]. +/// +/// Typically used to add a one or two line header or footer on a [GridTile]. +/// +/// For a one-line header, include a [title] widget. To add a second line, also +/// include a [subtitle] widget. Use [leading] or [trailing] to add an icon. +/// +/// See also: +/// +/// * [GridTile] +/// * <https://material.io/design/components/image-lists.html#anatomy> +class GridTileBar extends StatelessWidget { + /// Creates a grid tile bar. + /// + /// Typically used to with [GridTile]. + const GridTileBar({ + super.key, + this.backgroundColor, + this.leading, + this.title, + this.subtitle, + this.trailing, + }); + + /// The color to paint behind the child widgets. + /// + /// Defaults to transparent. + final Color? backgroundColor; + + /// A widget to display before the title. + /// + /// Typically an [Icon] or an [IconButton] widget. + final Widget? leading; + + /// The primary content of the list item. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// A widget to display after the title. + /// + /// Typically an [Icon] or an [IconButton] widget. + final Widget? trailing; + + @override + Widget build(BuildContext context) { + BoxDecoration? decoration; + if (backgroundColor != null) { + decoration = BoxDecoration(color: backgroundColor); + } + + final padding = EdgeInsetsDirectional.only( + start: leading != null ? 8.0 : 16.0, + end: trailing != null ? 8.0 : 16.0, + ); + + final darkTheme = ThemeData.dark(); + return Container( + padding: padding, + decoration: decoration, + height: (title != null && subtitle != null) ? 68.0 : 48.0, + child: Theme( + data: darkTheme, + child: IconTheme.merge( + data: const IconThemeData(color: Colors.white), + child: Row( + children: <Widget>[ + if (leading != null) + Padding(padding: const EdgeInsetsDirectional.only(end: 8.0), child: leading), + if (title != null && subtitle != null) + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + DefaultTextStyle( + style: darkTheme.textTheme.titleMedium!, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: title!, + ), + DefaultTextStyle( + style: darkTheme.textTheme.bodySmall!, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: subtitle!, + ), + ], + ), + ) + else if (title != null || subtitle != null) + Expanded( + child: DefaultTextStyle( + style: darkTheme.textTheme.titleMedium!, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: title ?? subtitle!, + ), + ), + if (trailing != null) + Padding(padding: const EdgeInsetsDirectional.only(start: 8.0), child: trailing), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/icon_button.dart b/packages/material_ui/lib/src/icon_button.dart new file mode 100644 index 000000000000..1df0c76a1ea4 --- /dev/null +++ b/packages/material_ui/lib/src/icon_button.dart @@ -0,0 +1,1583 @@ +// 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. + +/// @docImport 'action_buttons.dart'; +/// @docImport 'app_bar.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'ink_decoration.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'icon_button_theme.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'tooltip.dart'; + +// Examples can assume: +// late BuildContext context; + +// Minimum logical pixel size of the IconButton. +// See: <https://material.io/design/usability/accessibility.html#layout-typography>. +const double _kMinButtonSize = kMinInteractiveDimension; + +enum _IconButtonVariant { standard, filled, filledTonal, outlined } + +/// A Material Design icon button. +/// +/// An icon button is a picture printed on a [Material] widget that reacts to +/// touches by filling with color (ink). +/// +/// Icon buttons are commonly used in the [AppBar.actions] field, but they can +/// be used in many other places as well. +/// +/// If the [onPressed] callback is null, then the button will be disabled and +/// will not react to touch. +/// +/// Requires one of its ancestors to be a [Material] widget. In Material Design 3, +/// this requirement no longer exists because this widget builds a subclass of +/// [ButtonStyleButton]. +/// +/// The hit region of an icon button will, if possible, be at least +/// kMinInteractiveDimension pixels in size, regardless of the actual +/// [iconSize], to satisfy the [touch target size](https://material.io/design/layout/spacing-methods.html#touch-targets) +/// requirements in the Material Design specification. The [alignment] controls +/// how the icon itself is positioned within the hit region. +/// +/// {@tool dartpad} +/// This sample shows an [IconButton] that uses the Material icon "volume_up" to +/// increase the volume. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/icon_button.png) +/// +/// ** See code in examples/api/lib/material/icon_button/icon_button.0.dart ** +/// {@end-tool} +/// +/// ### Icon sizes +/// +/// When creating an icon button with an [Icon], do not override the +/// icon's size with its [Icon.size] parameter, use the icon button's +/// [iconSize] parameter instead. For example do this: +/// +/// ```dart +/// IconButton( +/// iconSize: 72, +/// icon: const Icon(Icons.favorite), +/// onPressed: () { +/// // ... +/// }, +/// ), +/// ``` +/// +/// Avoid doing this: +/// +/// ```dart +/// IconButton( +/// icon: const Icon(Icons.favorite, size: 72), +/// onPressed: () { +/// // ... +/// }, +/// ), +/// ``` +/// +/// If you do, the button's size will be based on the default icon +/// size, not 72, which may produce unexpected layouts and clipping +/// issues. +/// +/// ### Adding a filled background +/// +/// Icon buttons don't support specifying a background color or other +/// background decoration because typically the icon is just displayed +/// on top of the parent widget's background. Icon buttons that appear +/// in [AppBar.actions] are an example of this. +/// +/// It's easy enough to create an icon button with a filled background +/// using the [Ink] widget. The [Ink] widget renders a decoration on +/// the underlying [Material] along with the splash and highlight +/// [InkResponse] contributed by descendant widgets. +/// +/// {@tool dartpad} +/// In this sample the icon button's background color is defined with an [Ink] +/// widget whose child is an [IconButton]. The icon button's filled background +/// is a light shade of blue, it's a filled circle, and it's as big as the +/// button is. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/icon_button_background.png) +/// +/// ** See code in examples/api/lib/material/icon_button/icon_button.1.dart ** +/// {@end-tool} +/// +/// Material Design 3 introduced new types (standard and contained) of [IconButton]s. +/// The default [IconButton] is the standard type. To create a filled icon button, +/// use [IconButton.filled]; to create a filled tonal icon button, use [IconButton.filledTonal]; +/// to create a outlined icon button, use [IconButton.outlined]. +/// +/// Material Design 3 also treats [IconButton]s as toggle buttons. In order +/// to not break existing apps, the toggle feature can be optionally controlled +/// by the [isSelected] property. +/// +/// If [isSelected] is null it will behave as a normal button. If [isSelected] is not +/// null then it will behave as a toggle button. If [isSelected] is true then it will +/// show [selectedIcon], if it false it will show the normal [icon]. +/// +/// In Material Design 3, both [IconTheme] and [IconButtonTheme] are used to override the default style +/// of [IconButton]. If both themes exist, the [IconButtonTheme] will override [IconTheme] no matter +/// which is closer to the [IconButton]. Each [IconButton]'s property is resolved by the order of +/// precedence: widget property, [IconButtonTheme] property, [IconTheme] property and +/// internal default property value. +/// +/// In Material Design 3, the [IconButton.visualDensity] defaults to [VisualDensity.standard] +/// for all platforms; otherwise the button will have a rounded rectangle shape if +/// the [IconButton.visualDensity] is set to [VisualDensity.compact]. Users can +/// customize it by using [IconButtonTheme], [IconButton.style] or [IconButton.visualDensity]. +/// +/// {@tool dartpad} +/// This sample shows creation of [IconButton] widgets for standard, filled, +/// filled tonal and outlined types, as described in: https://m3.material.io/components/icon-buttons/overview +/// +/// ** See code in examples/api/lib/material/icon_button/icon_button.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows creation of [IconButton] widgets with toggle feature for +/// standard, filled, filled tonal and outlined types, as described +/// in: https://m3.material.io/components/icon-buttons/overview +/// +/// ** See code in examples/api/lib/material/icon_button/icon_button.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Icons], the library of Material Icons. +/// * [BackButton], an icon button for a "back" affordance which adapts to the +/// current platform's conventions. +/// * [CloseButton], an icon button for closing pages. +/// * [AppBar], to show a toolbar at the top of an application. +/// * [TextButton], [ElevatedButton], [OutlinedButton], for buttons with text labels and an optional icon. +/// * [InkResponse] and [InkWell], for the ink splash effect itself. +class IconButton extends StatelessWidget { + /// Creates an icon button. + /// + /// Icon buttons are commonly used in the [AppBar.actions] field, but they can + /// be used in many other places as well. + /// + /// Requires one of its ancestors to be a [Material] widget. This requirement + /// no longer exists if [ThemeData.useMaterial3] is set to true. + /// + /// The [icon] argument must be specified, and is typically either an [Icon] + /// or an [ImageIcon]. + const IconButton({ + super.key, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + required this.onPressed, + this.onHover, + this.onLongPress, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + this.statesController, + required this.icon, + }) : assert(splashRadius == null || splashRadius > 0), + _variant = _IconButtonVariant.standard; + + /// Create a filled variant of IconButton. + /// + /// Filled icon buttons have higher visual impact and should be used for + /// high emphasis actions, such as turning off a microphone or camera. + const IconButton.filled({ + super.key, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + required this.onPressed, + this.onHover, + this.onLongPress, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + this.statesController, + required this.icon, + }) : assert(splashRadius == null || splashRadius > 0), + _variant = _IconButtonVariant.filled; + + /// Create a filled tonal variant of IconButton. + /// + /// Filled tonal icon buttons are a middle ground between filled and outlined + /// icon buttons. They’re useful in contexts where the button requires slightly + /// more emphasis than an outline would give, such as a secondary action paired + /// with a high emphasis action. + const IconButton.filledTonal({ + super.key, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + required this.onPressed, + this.onHover, + this.onLongPress, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + this.statesController, + required this.icon, + }) : assert(splashRadius == null || splashRadius > 0), + _variant = _IconButtonVariant.filledTonal; + + /// Create an outlined variant of IconButton. + /// + /// Outlined icon buttons are medium-emphasis buttons. They’re useful when an + /// icon button needs more emphasis than a standard icon button but less than + /// a filled or filled tonal icon button. + const IconButton.outlined({ + super.key, + this.iconSize, + this.visualDensity, + this.padding, + this.alignment, + this.splashRadius, + this.color, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.disabledColor, + required this.onPressed, + this.onHover, + this.onLongPress, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.tooltip, + this.enableFeedback, + this.constraints, + this.style, + this.isSelected, + this.selectedIcon, + this.statesController, + required this.icon, + }) : assert(splashRadius == null || splashRadius > 0), + _variant = _IconButtonVariant.outlined; + + /// The size of the icon inside the button. + /// + /// If null, uses [IconThemeData.size]. If it is also null, the default size + /// is 24.0. + /// + /// The size given here is passed down to the widget in the [icon] property + /// via an [IconTheme]. Setting the size here instead of in, for example, the + /// [Icon.size] property allows the [IconButton] to size the splash area to + /// fit the [Icon]. If you were to set the size of the [Icon] using + /// [Icon.size] instead, then the [IconButton] would default to 24.0 and then + /// the [Icon] itself would likely get clipped. + /// + /// This property is only used when [icon] is or contains an [Icon] widget. It will be + /// ignored if other widgets are used, such as an [Image]. + /// + /// If [ThemeData.useMaterial3] is set to true and this is null, the size of the + /// [IconButton] would default to 24.0. The size given here is passed down to the + /// [ButtonStyle.iconSize] property. + final double? iconSize; + + /// Defines how compact the icon button's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// This property can be null. If null, it defaults to [VisualDensity.standard] + /// in Material Design 3 to make sure the button will be circular on all platforms. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// The padding around the button's icon. The entire padded icon will react + /// to input gestures. + /// + /// If [ThemeData.useMaterial3] is set to false, apply padding in the square + /// proportion to the button's splash/highlight shape. For example, if the + /// button is a circle, use padding on all sides to center the icon inside the + /// splash/highlight circle. Otherwise, wrap the [IconButton] with a [Padding] + /// widget to apply padding in the desired direction. + /// + /// This property can be null. If null, it defaults to 8.0 padding on all sides. + final EdgeInsetsGeometry? padding; + + /// Defines how the icon is positioned within the IconButton. + /// + /// This property can be null. If null, it defaults to [Alignment.center]. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry? alignment; + + /// The splash radius. + /// + /// If [ThemeData.useMaterial3] is set to true, this will not be used. + /// + /// If null, default splash radius of [Material.defaultSplashRadius] is used. + final double? splashRadius; + + /// The icon to display inside the button. + /// + /// The [Icon.size] and [Icon.color] of the icon is configured automatically + /// based on the [iconSize] and [color] properties of _this_ widget using an + /// [IconTheme] and therefore should not be explicitly given in the icon + /// widget. + /// + /// See [Icon], [ImageIcon]. + final Widget icon; + + /// The color for the button when it has the input focus. + /// + /// If [ThemeData.useMaterial3] is set to true, this [focusColor] will be mapped + /// to be the [ButtonStyle.overlayColor] in focused state, which paints on top of + /// the button, as an overlay. Therefore, using a color with some transparency + /// is recommended. For example, one could customize the [focusColor] below: + /// + /// ```dart + /// IconButton( + /// focusColor: Colors.orange.withValues(alpha: 0.3), + /// icon: const Icon(Icons.sunny), + /// onPressed: () { + /// // ... + /// }, + /// ) + /// ``` + /// + /// Defaults to [ThemeData.focusColor] of the ambient theme. + final Color? focusColor; + + /// The color for the button when a pointer is hovering over it. + /// + /// If [ThemeData.useMaterial3] is set to true, this [hoverColor] will be mapped + /// to be the [ButtonStyle.overlayColor] in hovered state, which paints on top of + /// the button, as an overlay. Therefore, using a color with some transparency + /// is recommended. For example, one could customize the [hoverColor] below: + /// + /// ```dart + /// IconButton( + /// hoverColor: Colors.orange.withValues(alpha: 0.3), + /// icon: const Icon(Icons.ac_unit), + /// onPressed: () { + /// // ... + /// }, + /// ) + /// ``` + /// + /// Defaults to [ThemeData.hoverColor] of the ambient theme. + final Color? hoverColor; + + /// The color to use for the icon inside the button, if the icon is enabled. + /// Defaults to leaving this up to the [icon] widget. + /// + /// The icon is enabled if [onPressed] is not null. + /// + /// ```dart + /// IconButton( + /// color: Colors.blue, + /// icon: const Icon(Icons.sunny_snowing), + /// onPressed: () { + /// // ... + /// }, + /// ) + /// ``` + final Color? color; + + /// The primary color of the button when the button is in the down (pressed) state. + /// The splash is represented as a circular overlay that appears above the + /// [highlightColor] overlay. The splash overlay has a center point that matches + /// the hit point of the user touch event. The splash overlay will expand to + /// fill the button area if the touch is held for long enough time. If the splash + /// color has transparency then the highlight and button color will show through. + /// + /// If [ThemeData.useMaterial3] is set to true, this will not be used. Use + /// [highlightColor] instead to show the overlay color of the button when the button + /// is in the pressed state. + /// + /// Defaults to the Theme's splash color, [ThemeData.splashColor]. + final Color? splashColor; + + /// The secondary color of the button when the button is in the down (pressed) + /// state. The highlight color is represented as a solid color that is overlaid over the + /// button color (if any). If the highlight color has transparency, the button color + /// will show through. The highlight fades in quickly as the button is held down. + /// + /// If [ThemeData.useMaterial3] is set to true, this [highlightColor] will be mapped + /// to be the [ButtonStyle.overlayColor] in pressed state, which paints on top + /// of the button, as an overlay. Therefore, using a color with some transparency + /// is recommended. For example, one could customize the [highlightColor] below: + /// + /// ```dart + /// IconButton( + /// highlightColor: Colors.orange.withValues(alpha: 0.3), + /// icon: const Icon(Icons.question_mark), + /// onPressed: () { + /// // ... + /// }, + /// ) + /// ``` + /// + /// Defaults to the Theme's highlight color, [ThemeData.highlightColor]. + final Color? highlightColor; + + /// The color to use for the icon inside the button, if the icon is disabled. + /// Defaults to the [ThemeData.disabledColor] of the current [Theme]. + /// + /// The icon is disabled if [onPressed] is null. + final Color? disabledColor; + + /// The callback that is called when the button is tapped or otherwise activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + /// The callback that is called when the button is hovered. + final ValueChanged<bool>? onHover; + + /// The callback that is called when the button is long-pressed. + /// + /// If onPressed is set to null, the onLongPress callback is not called. + final VoidCallback? onLongPress; + + /// {@macro flutter.material.RawMaterialButton.mouseCursor} + /// + /// If set to null, will default to [SystemMouseCursors.basic] if [onPressed] + /// is null, otherwise [WidgetStateMouseCursor.adaptiveClickable]. + final MouseCursor? mouseCursor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// Optional size constraints for the button. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: kMinInteractiveDimension, + /// minHeight: kMinInteractiveDimension, + /// ) + /// ``` + /// where [kMinInteractiveDimension] is 48.0, and then with visual density + /// applied. + /// + /// The default constraints ensure that the button is accessible. + /// Specifying this parameter enables creation of buttons smaller than + /// the minimum size, but it is not recommended. + /// + /// The visual density uses the [visualDensity] parameter if specified, + /// and `Theme.of(context).visualDensity` otherwise. + final BoxConstraints? constraints; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding + /// properties in [_IconButtonM3.themeStyleOf] and [_IconButtonM3.defaultStyleOf]. + /// [WidgetStateProperty]s that resolve to non-null values will similarly + /// override the corresponding [WidgetStateProperty]s in [_IconButtonM3.themeStyleOf] + /// and [_IconButtonM3.defaultStyleOf]. + /// + /// The [style] is only used for Material 3 [IconButton]. If [ThemeData.useMaterial3] + /// is set to true, [style] is preferred for icon button customization, and any + /// parameters defined in [style] will override the same parameters in [IconButton]. + /// + /// For example, if [IconButton]'s [visualDensity] is set to [VisualDensity.standard] + /// and [style]'s [visualDensity] is set to [VisualDensity.compact], + /// the icon button will have [VisualDensity.compact] to define the button's layout. + /// + /// Null by default. + final ButtonStyle? style; + + /// The optional selection state of the icon button. + /// + /// If this property is null, the button will behave as a normal push button, + /// otherwise, the button will toggle between showing [icon] and [selectedIcon] + /// based on the value of [isSelected]. If true, it will show [selectedIcon], + /// if false it will show [icon]. + /// + /// This property is only used if [ThemeData.useMaterial3] is true. + final bool? isSelected; + + /// The icon to display inside the button when [isSelected] is true. This property + /// can be null. The original [icon] will be used for both selected and unselected + /// status if it is null. + /// + /// The [Icon.size] and [Icon.color] of the icon is configured automatically + /// based on the [iconSize] and [color] properties using an [IconTheme] and + /// therefore should not be explicitly configured in the icon widget. + /// + /// This property is only used if [ThemeData.useMaterial3] is true. + /// + /// See also: + /// + /// * [Icon], for icons based on glyphs from fonts instead of images. + /// * [ImageIcon], for showing icons from [AssetImage]s or other [ImageProvider]s. + final Widget? selectedIcon; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + final _IconButtonVariant _variant; + + /// A static convenience method that constructs an icon button + /// [ButtonStyle] given simple values. This method is only used for Material 3. + /// + /// The [foregroundColor] color is used to create a [WidgetStateProperty] + /// [ButtonStyle.foregroundColor] value. Specify a value for [foregroundColor] + /// to specify the color of the button's icons. The [hoverColor], [focusColor] + /// and [highlightColor] colors are used to indicate the hover, focus, + /// and pressed states if [overlayColor] isn't specified. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// Use [backgroundColor] for the button's background fill color. Use [disabledForegroundColor] + /// and [disabledBackgroundColor] to specify the button's disabled icon and fill color. + /// + /// 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 [WidgetStateProperty] 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 icon color for a + /// [IconButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// IconButton( + /// icon: const Icon(Icons.pets), + /// style: IconButton.styleFrom(foregroundColor: Colors.green), + /// onPressed: () { + /// // ... + /// }, + /// ), + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? focusColor, + Color? hoverColor, + Color? highlightColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? overlayColor, + double? elevation, + Size? minimumSize, + Size? fixedSize, + Size? maximumSize, + double? iconSize, + BorderSide? side, + OutlinedBorder? shape, + EdgeInsetsGeometry? padding, + MouseCursor? enabledMouseCursor, + MouseCursor? disabledMouseCursor, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + }) { + final Color? overlayFallback = overlayColor ?? foregroundColor; + WidgetStateProperty<Color?>? overlayColorProp; + if ((hoverColor ?? focusColor ?? highlightColor ?? overlayFallback) != null) { + overlayColorProp = switch (overlayColor) { + Color(a: 0.0) => WidgetStatePropertyAll<Color>(overlayColor), + _ => WidgetStateProperty<Color?>.fromMap(<WidgetState, Color?>{ + WidgetState.pressed: highlightColor ?? overlayFallback?.withOpacity(0.1), + WidgetState.hovered: hoverColor ?? overlayFallback?.withOpacity(0.08), + WidgetState.focused: focusColor ?? overlayFallback?.withOpacity(0.1), + }), + }; + } + + return ButtonStyle( + backgroundColor: ButtonStyleButton.defaultColor(backgroundColor, disabledBackgroundColor), + foregroundColor: ButtonStyleButton.defaultColor(foregroundColor, disabledForegroundColor), + overlayColor: overlayColorProp, + shadowColor: ButtonStyleButton.allOrNull<Color>(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull<Color>(surfaceTintColor), + elevation: ButtonStyleButton.allOrNull<double>(elevation), + padding: ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(padding), + minimumSize: ButtonStyleButton.allOrNull<Size>(minimumSize), + fixedSize: ButtonStyleButton.allOrNull<Size>(fixedSize), + maximumSize: ButtonStyleButton.allOrNull<Size>(maximumSize), + iconSize: ButtonStyleButton.allOrNull<double>(iconSize), + side: ButtonStyleButton.allOrNull<BorderSide>(side), + shape: ButtonStyleButton.allOrNull<OutlinedBorder>(shape), + mouseCursor: disabledMouseCursor == null && enabledMouseCursor == null + ? null + : WidgetStateProperty<MouseCursor?>.fromMap(<WidgetStatesConstraint, MouseCursor?>{ + WidgetState.disabled: disabledMouseCursor, + WidgetState.any: enabledMouseCursor, + }), + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + if (theme.useMaterial3) { + final Size? minSize = constraints == null + ? null + : Size(constraints!.minWidth, constraints!.minHeight); + final Size? maxSize = constraints == null + ? null + : Size(constraints!.maxWidth, constraints!.maxHeight); + + ButtonStyle adjustedStyle = styleFrom( + visualDensity: visualDensity, + foregroundColor: color, + disabledForegroundColor: disabledColor, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + padding: padding, + minimumSize: minSize, + maximumSize: maxSize, + iconSize: iconSize, + alignment: alignment, + enabledMouseCursor: mouseCursor, + disabledMouseCursor: mouseCursor, + enableFeedback: enableFeedback, + ); + if (style != null) { + adjustedStyle = style!.merge(adjustedStyle); + } + if (adjustedStyle.iconColor == null) { + adjustedStyle = adjustedStyle.copyWith(iconColor: adjustedStyle.foregroundColor); + } + Widget effectiveIcon = icon; + if ((isSelected ?? false) && selectedIcon != null) { + effectiveIcon = selectedIcon!; + } + + return _SelectableIconButton( + style: adjustedStyle, + onPressed: onPressed, + onHover: onHover, + onLongPress: onPressed != null ? onLongPress : null, + autofocus: autofocus, + focusNode: focusNode, + isSelected: isSelected, + variant: _variant, + tooltip: tooltip, + statesController: statesController, + child: effectiveIcon, + ); + } + + assert(debugCheckHasMaterial(context)); + + Color? currentColor; + if (onPressed != null) { + currentColor = color; + } else { + currentColor = disabledColor ?? theme.disabledColor; + } + + final VisualDensity effectiveVisualDensity = visualDensity ?? theme.visualDensity; + + final BoxConstraints unadjustedConstraints = + constraints ?? const BoxConstraints(minWidth: _kMinButtonSize, minHeight: _kMinButtonSize); + final BoxConstraints adjustedConstraints = effectiveVisualDensity.effectiveConstraints( + unadjustedConstraints, + ); + final double effectiveIconSize = iconSize ?? IconTheme.of(context).size ?? 24.0; + final EdgeInsetsGeometry effectivePadding = padding ?? const EdgeInsets.all(8.0); + final AlignmentGeometry effectiveAlignment = alignment ?? Alignment.center; + final bool effectiveEnableFeedback = enableFeedback ?? true; + + Widget result = ConstrainedBox( + constraints: adjustedConstraints, + child: Padding( + padding: effectivePadding, + child: SizedBox.square( + dimension: effectiveIconSize, + child: Align( + alignment: effectiveAlignment, + child: IconTheme.merge( + data: IconThemeData(size: effectiveIconSize, color: currentColor), + child: icon, + ), + ), + ), + ), + ); + + result = InkResponse( + focusNode: focusNode, + autofocus: autofocus, + canRequestFocus: onPressed != null, + onTap: onPressed, + onHover: onHover, + onLongPress: onPressed != null ? onLongPress : null, + mouseCursor: + mouseCursor ?? + (onPressed != null ? WidgetStateMouseCursor.adaptiveClickable : SystemMouseCursors.basic), + enableFeedback: effectiveEnableFeedback, + focusColor: focusColor ?? theme.focusColor, + hoverColor: hoverColor ?? theme.hoverColor, + highlightColor: highlightColor ?? theme.highlightColor, + splashColor: splashColor ?? theme.splashColor, + radius: + splashRadius ?? + math.max( + Material.defaultSplashRadius, + (effectiveIconSize + math.min(effectivePadding.horizontal, effectivePadding.vertical)) * + 0.7, + // x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps. + ), + child: result, + ); + + if (tooltip != null) { + result = Tooltip(message: tooltip, child: result); + } + + return Semantics(button: true, enabled: onPressed != null, child: result); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('tooltip', tooltip, defaultValue: null, quoted: false)); + properties.add(ObjectFlagProperty<VoidCallback>('onPressed', onPressed, ifNull: 'disabled')); + properties.add(ObjectFlagProperty<ValueChanged<bool>>('onHover', onHover, ifNull: 'disabled')); + properties.add( + ObjectFlagProperty<VoidCallback>('onLongPress', onLongPress, ifNull: 'disabled'), + ); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + } +} + +class _SelectableIconButton extends StatefulWidget { + const _SelectableIconButton({ + this.isSelected, + this.style, + this.focusNode, + this.onLongPress, + this.onHover, + this.statesController, + required this.variant, + required this.autofocus, + required this.onPressed, + this.tooltip, + required this.child, + }); + + final bool? isSelected; + final ButtonStyle? style; + final FocusNode? focusNode; + final _IconButtonVariant variant; + final bool autofocus; + final VoidCallback? onPressed; + final String? tooltip; + final Widget child; + final VoidCallback? onLongPress; + final ValueChanged<bool>? onHover; + final MaterialStatesController? statesController; + + @override + State<_SelectableIconButton> createState() => _SelectableIconButtonState(); +} + +class _SelectableIconButtonState extends State<_SelectableIconButton> { + MaterialStatesController? _internalStatesController; + + MaterialStatesController get statesController => + widget.statesController ?? _internalStatesController!; + + bool get _isSelected => widget.isSelected ?? false; + + @override + void initState() { + super.initState(); + + if (widget.statesController == null) { + _internalStatesController = MaterialStatesController(); + } + + statesController.update(WidgetState.selected, _isSelected); + } + + @override + void didUpdateWidget(_SelectableIconButton oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.statesController != oldWidget.statesController) { + if (widget.statesController != null) { + _internalStatesController?.dispose(); + _internalStatesController = null; + } + _initStatesController(); + } + + if (widget.isSelected != oldWidget.isSelected) { + statesController.update(WidgetState.selected, _isSelected); + } + } + + void _initStatesController() { + if (widget.statesController == null) { + _internalStatesController = MaterialStatesController(); + } + + statesController.update(WidgetState.selected, _isSelected); + } + + @override + Widget build(BuildContext context) { + final toggleable = widget.isSelected != null; + + return _IconButtonM3( + statesController: statesController, + style: widget.style, + autofocus: widget.autofocus, + focusNode: widget.focusNode, + onPressed: widget.onPressed, + onHover: widget.onHover, + onLongPress: widget.onPressed != null ? widget.onLongPress : null, + variant: widget.variant, + toggleable: toggleable, + tooltip: widget.tooltip, + child: Semantics(selected: widget.isSelected, child: widget.child), + ); + } + + @override + void dispose() { + _internalStatesController?.dispose(); + super.dispose(); + } +} + +class _IconButtonM3 extends ButtonStyleButton { + const _IconButtonM3({ + required super.onPressed, + super.style, + super.focusNode, + super.onHover, + super.onLongPress, + super.autofocus = false, + super.statesController, + required this.variant, + required this.toggleable, + super.tooltip, + required Widget super.child, + }) : super(onFocusChange: null, clipBehavior: Clip.none); + + final _IconButtonVariant variant; + final bool toggleable; + + /// ## Material 3 defaults + /// + /// If [ThemeData.useMaterial3] is set to true the following defaults will + /// be used: + /// + /// * `textStyle` - null + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * selected - Theme.colorScheme.primary + /// * others - Theme.colorScheme.onSurfaceVariant + /// * `overlayColor` + /// * selected + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.1) + /// * hovered - Theme.colorScheme.onSurfaceVariant(0.08) + /// * pressed or focused - Theme.colorScheme.onSurfaceVariant(0.1) + /// * others - null + /// * `shadowColor` - null + /// * `surfaceTintColor` - null + /// * `elevation` - 0 + /// * `padding` - all(8) + /// * `minimumSize` - Size(40, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `iconSize` - 24 + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - VisualDensity.standard + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + @override + ButtonStyle defaultStyleOf(BuildContext context) { + return switch (variant) { + _IconButtonVariant.filled => _FilledIconButtonDefaultsM3(context, toggleable), + _IconButtonVariant.filledTonal => _FilledTonalIconButtonDefaultsM3(context, toggleable), + _IconButtonVariant.outlined => _OutlinedIconButtonDefaultsM3(context, toggleable), + _IconButtonVariant.standard => _IconButtonDefaultsM3(context, toggleable), + }; + } + + /// Returns the [IconButtonThemeData.style] of the closest [IconButtonTheme] ancestor. + /// The color and icon size can also be configured by the [IconTheme] if the same property + /// has a null value in [IconButtonTheme]. However, if any of the properties exist + /// in both [IconButtonTheme] and [IconTheme], [IconTheme] will be overridden. + @override + ButtonStyle? themeStyleOf(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final isDefaultSize = iconTheme.size == const IconThemeData.fallback().size; + final bool isDefaultColor = identical(iconTheme.color, switch (Theme.brightnessOf(context)) { + Brightness.light => kDefaultIconDarkColor, + Brightness.dark => kDefaultIconLightColor, + }); + + final ButtonStyle iconThemeStyle = IconButton.styleFrom( + foregroundColor: isDefaultColor ? null : iconTheme.color, + iconSize: isDefaultSize ? null : iconTheme.size, + ); + + return IconButtonTheme.of(context).style?.merge(iconThemeStyle) ?? iconThemeStyle; + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - IconButton + +// 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. + +// dart format off +class _IconButtonDefaultsM3 extends ButtonStyle { + _IconButtonDefaultsM3(this.context, this.toggleable) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + final bool toggleable; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + // No default text style + + @override + WidgetStateProperty<Color?>? get backgroundColor => + const MaterialStatePropertyAll<Color?>(Colors.transparent); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + return _colors.primary; + } + return _colors.onSurfaceVariant; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + return Colors.transparent; + }); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(0.0); + + @override + WidgetStateProperty<Color>? get shadowColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + const MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.all(8.0)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(40.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(24.0); + + @override + WidgetStateProperty<BorderSide?>? get side => null; + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => VisualDensity.standard; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - IconButton + +// BEGIN GENERATED TOKEN PROPERTIES - FilledIconButton + +// 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. + +// dart format off +class _FilledIconButtonDefaultsM3 extends ButtonStyle { + _FilledIconButtonDefaultsM3(this.context, this.toggleable) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + final bool toggleable; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + // No default text style + + @override + WidgetStateProperty<Color?>? get backgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.selected)) { + return _colors.primary; + } + if (toggleable) { // toggleable but unselected case + return _colors.surfaceContainerHighest; + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } + if (toggleable) { // toggleable but unselected case + return _colors.primary; + } + return _colors.onPrimary; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.1); + } + } + if (toggleable) { // toggleable but unselected case + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + } + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimary.withOpacity(0.1); + } + return Colors.transparent; + }); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(0.0); + + @override + WidgetStateProperty<Color>? get shadowColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + const MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.all(8.0)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(40.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(24.0); + + @override + WidgetStateProperty<BorderSide?>? get side => null; + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => VisualDensity.standard; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - FilledIconButton + +// BEGIN GENERATED TOKEN PROPERTIES - FilledTonalIconButton + +// 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. + +// dart format off +class _FilledTonalIconButtonDefaultsM3 extends ButtonStyle { + _FilledTonalIconButtonDefaultsM3(this.context, this.toggleable) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + final bool toggleable; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + // No default text style + + @override + WidgetStateProperty<Color?>? get backgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.selected)) { + return _colors.secondaryContainer; + } + if (toggleable) { // toggleable but unselected case + return _colors.surfaceContainerHighest; + } + return _colors.secondaryContainer; + }); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + return _colors.onSecondaryContainer; + } + if (toggleable) { // toggleable but unselected case + return _colors.onSurfaceVariant; + } + return _colors.onSecondaryContainer; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSecondaryContainer.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + } + if (toggleable) { // toggleable but unselected case + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.1); + } + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSecondaryContainer.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + return Colors.transparent; + }); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(0.0); + + @override + WidgetStateProperty<Color>? get shadowColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + const MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.all(8.0)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(40.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(24.0); + + @override + WidgetStateProperty<BorderSide?>? get side => null; + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => VisualDensity.standard; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - FilledTonalIconButton + +// BEGIN GENERATED TOKEN PROPERTIES - OutlinedIconButton + +// 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. + +// dart format off +class _OutlinedIconButtonDefaultsM3 extends ButtonStyle { + _OutlinedIconButtonDefaultsM3(this.context, this.toggleable) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + final bool toggleable; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + // No default text style + + @override + WidgetStateProperty<Color?>? get backgroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _colors.onSurface.withOpacity(0.12); + } + return Colors.transparent; + } + if (states.contains(WidgetState.selected)) { + return _colors.inverseSurface; + } + return Colors.transparent; + }); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + return _colors.onInverseSurface; + } + return _colors.onSurfaceVariant; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onInverseSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onInverseSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onInverseSurface.withOpacity(0.08); + } + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant.withOpacity(0.08); + } + return Colors.transparent; + }); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(0.0); + + @override + WidgetStateProperty<Color>? get shadowColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + const MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.all(8.0)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(40.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(24.0); + + @override + WidgetStateProperty<BorderSide?>? get side => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return null; + } else { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: _colors.onSurface.withOpacity(0.12)); + } + return BorderSide(color: _colors.outline); + } + }); + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => VisualDensity.standard; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - OutlinedIconButton diff --git a/packages/material_ui/lib/src/icon_button_theme.dart b/packages/material_ui/lib/src/icon_button_theme.dart new file mode 100644 index 000000000000..f33e2841b0f3 --- /dev/null +++ b/packages/material_ui/lib/src/icon_button_theme.dart @@ -0,0 +1,121 @@ +// 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. + +/// @docImport 'icon_button.dart'; +library; + +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 +/// [IconButton]s when it's used with the [IconButton], the [IconButtonTheme] or the +/// overall [Theme]'s [ThemeData.iconButtonTheme]. +/// +/// The [IconButton] will be affected by [IconButtonTheme] and [IconButtonThemeData] +/// only if [ThemeData.useMaterial3] is set to true; otherwise, [IconTheme] will be used. +/// +/// The [style]'s properties override [IconButton]'s default style. Only +/// the style's non-null property values or resolved non-null +/// [WidgetStateProperty] values are used. +/// +/// See also: +/// +/// * [IconButtonTheme], the theme which is configured with this class. +/// * [IconButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [IconButton]'s defaults. +/// * [WidgetStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [WidgetState]s. +/// * [ThemeData.iconButtonTheme], which can be used to override the default +/// [ButtonStyle] for [IconButton]s below the overall [Theme]. +@immutable +class IconButtonThemeData with Diagnosticable { + /// Creates a [IconButtonThemeData]. + /// + /// The [style] may be null. + const IconButtonThemeData({this.style}); + + /// Overrides for [IconButton]'s default style if [ThemeData.useMaterial3] + /// is set to true. + /// + /// Non-null properties or non-null resolved [WidgetStateProperty] + /// values override the default [ButtonStyle] in [IconButton]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Linearly interpolate between two icon button themes. + static IconButtonThemeData? lerp(IconButtonThemeData? a, IconButtonThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return IconButtonThemeData(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 IconButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [IconButton] descendants. +/// +/// See also: +/// +/// * [IconButtonThemeData], which is used to configure this theme. +/// * [IconButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [IconButton]'s defaults. +/// * [ThemeData.iconButtonTheme], which can be used to override the default +/// [ButtonStyle] for [IconButton]s below the overall [Theme]. +class IconButtonTheme extends InheritedTheme { + /// Create a [IconButtonTheme]. + const IconButtonTheme({super.key, required this.data, required super.child}); + + /// The configuration of this theme. + final IconButtonThemeData data; + + /// Retrieves the [IconButtonThemeData] from the closest ancestor [IconButtonTheme]. + /// + /// If there is no enclosing [IconButtonTheme] widget, then + /// [ThemeData.iconButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// IconButtonThemeData theme = IconButtonTheme.of(context); + /// ``` + static IconButtonThemeData of(BuildContext context) { + final IconButtonTheme? buttonTheme = context + .dependOnInheritedWidgetOfExactType<IconButtonTheme>(); + return buttonTheme?.data ?? Theme.of(context).iconButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return IconButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(IconButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/icons.dart b/packages/material_ui/lib/src/icons.dart new file mode 100644 index 000000000000..eb3f3728a047 --- /dev/null +++ b/packages/material_ui/lib/src/icons.dart @@ -0,0 +1,29454 @@ +// 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. + +/// @docImport 'animated_icons.dart'; +/// @docImport 'icon_button.dart'; +library; + +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/widgets.dart'; + +// ignore_for_file: non_constant_identifier_names + +/// A set of platform-adaptive Material Design icons. +/// +/// Use [Icons.adaptive] to access a static instance of this class. +final class PlatformAdaptiveIcons implements Icons { + const PlatformAdaptiveIcons._(); + + static bool _isCupertino() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return true; + } + } + + // Generated code: do not hand-edit. + // https://github.com/flutter/flutter/blob/main/docs/libraries/material/Updating-Material-Design-Fonts-%26-Icons.md + + // BEGIN GENERATED PLATFORM ADAPTIVE ICONS + + /// Platform-adaptive icon for <i class="material-icons md-36">arrow_back</i> — material icon named "arrow back" and <i class="material-icons md-36">arrow_back_ios</i> — material icon named "arrow back ios".; + IconData get arrow_back => !_isCupertino() ? Icons.arrow_back : Icons.arrow_back_ios; + + /// Platform-adaptive icon for <i class="material-icons-outlined md-36">arrow_back</i> — material icon named "arrow back" (outlined) and <i class="material-icons-outlined md-36">arrow_back_ios</i> — material icon named "arrow back ios" (outlined).; + IconData get arrow_back_outlined => + !_isCupertino() ? Icons.arrow_back_outlined : Icons.arrow_back_ios_outlined; + + /// Platform-adaptive icon for <i class="material-icons-round md-36">arrow_back</i> — material icon named "arrow back" (round) and <i class="material-icons-round md-36">arrow_back_ios</i> — material icon named "arrow back ios" (round).; + IconData get arrow_back_rounded => + !_isCupertino() ? Icons.arrow_back_rounded : Icons.arrow_back_ios_rounded; + + /// Platform-adaptive icon for <i class="material-icons-sharp md-36">arrow_back</i> — material icon named "arrow back" (sharp) and <i class="material-icons-sharp md-36">arrow_back_ios</i> — material icon named "arrow back ios" (sharp).; + IconData get arrow_back_sharp => + !_isCupertino() ? Icons.arrow_back_sharp : Icons.arrow_back_ios_sharp; + + /// Platform-adaptive icon for <i class="material-icons md-36">arrow_forward</i> — material icon named "arrow forward" and <i class="material-icons md-36">arrow_forward_ios</i> — material icon named "arrow forward ios".; + IconData get arrow_forward => !_isCupertino() ? Icons.arrow_forward : Icons.arrow_forward_ios; + + /// Platform-adaptive icon for <i class="material-icons-outlined md-36">arrow_forward</i> — material icon named "arrow forward" (outlined) and <i class="material-icons-outlined md-36">arrow_forward_ios</i> — material icon named "arrow forward ios" (outlined).; + IconData get arrow_forward_outlined => + !_isCupertino() ? Icons.arrow_forward_outlined : Icons.arrow_forward_ios_outlined; + + /// Platform-adaptive icon for <i class="material-icons-round md-36">arrow_forward</i> — material icon named "arrow forward" (round) and <i class="material-icons-round md-36">arrow_forward_ios</i> — material icon named "arrow forward ios" (round).; + IconData get arrow_forward_rounded => + !_isCupertino() ? Icons.arrow_forward_rounded : Icons.arrow_forward_ios_rounded; + + /// Platform-adaptive icon for <i class="material-icons-sharp md-36">arrow_forward</i> — material icon named "arrow forward" (sharp) and <i class="material-icons-sharp md-36">arrow_forward_ios</i> — material icon named "arrow forward ios" (sharp).; + IconData get arrow_forward_sharp => + !_isCupertino() ? Icons.arrow_forward_sharp : Icons.arrow_forward_ios_sharp; + + /// Platform-adaptive icon for <i class="material-icons md-36">flip_camera_android</i> — material icon named "flip camera android" and <i class="material-icons md-36">flip_camera_ios</i> — material icon named "flip camera ios".; + IconData get flip_camera => !_isCupertino() ? Icons.flip_camera_android : Icons.flip_camera_ios; + + /// Platform-adaptive icon for <i class="material-icons-outlined md-36">flip_camera_android</i> — material icon named "flip camera android" (outlined) and <i class="material-icons-outlined md-36">flip_camera_ios</i> — material icon named "flip camera ios" (outlined).; + IconData get flip_camera_outlined => + !_isCupertino() ? Icons.flip_camera_android_outlined : Icons.flip_camera_ios_outlined; + + /// Platform-adaptive icon for <i class="material-icons-round md-36">flip_camera_android</i> — material icon named "flip camera android" (round) and <i class="material-icons-round md-36">flip_camera_ios</i> — material icon named "flip camera ios" (round).; + IconData get flip_camera_rounded => + !_isCupertino() ? Icons.flip_camera_android_rounded : Icons.flip_camera_ios_rounded; + + /// Platform-adaptive icon for <i class="material-icons-sharp md-36">flip_camera_android</i> — material icon named "flip camera android" (sharp) and <i class="material-icons-sharp md-36">flip_camera_ios</i> — material icon named "flip camera ios" (sharp).; + IconData get flip_camera_sharp => + !_isCupertino() ? Icons.flip_camera_android_sharp : Icons.flip_camera_ios_sharp; + + /// Platform-adaptive icon for <i class="material-icons md-36">more_vert</i> — material icon named "more vert" and <i class="material-icons md-36">more_horiz</i> — material icon named "more horiz".; + IconData get more => !_isCupertino() ? Icons.more_vert : Icons.more_horiz; + + /// Platform-adaptive icon for <i class="material-icons-outlined md-36">more_vert</i> — material icon named "more vert" (outlined) and <i class="material-icons-outlined md-36">more_horiz</i> — material icon named "more horiz" (outlined).; + IconData get more_outlined => + !_isCupertino() ? Icons.more_vert_outlined : Icons.more_horiz_outlined; + + /// Platform-adaptive icon for <i class="material-icons-round md-36">more_vert</i> — material icon named "more vert" (round) and <i class="material-icons-round md-36">more_horiz</i> — material icon named "more horiz" (round).; + IconData get more_rounded => !_isCupertino() ? Icons.more_vert_rounded : Icons.more_horiz_rounded; + + /// Platform-adaptive icon for <i class="material-icons-sharp md-36">more_vert</i> — material icon named "more vert" (sharp) and <i class="material-icons-sharp md-36">more_horiz</i> — material icon named "more horiz" (sharp).; + IconData get more_sharp => !_isCupertino() ? Icons.more_vert_sharp : Icons.more_horiz_sharp; + + /// Platform-adaptive icon for <i class="material-icons md-36">share</i> — material icon named "share" and <i class="material-icons md-36">ios_share</i> — material icon named "ios share".; + IconData get share => !_isCupertino() ? Icons.share : Icons.ios_share; + + /// Platform-adaptive icon for <i class="material-icons-outlined md-36">share</i> — material icon named "share" (outlined) and <i class="material-icons-outlined md-36">ios_share</i> — material icon named "ios share" (outlined).; + IconData get share_outlined => !_isCupertino() ? Icons.share_outlined : Icons.ios_share_outlined; + + /// Platform-adaptive icon for <i class="material-icons-round md-36">share</i> — material icon named "share" (round) and <i class="material-icons-round md-36">ios_share</i> — material icon named "ios share" (round).; + IconData get share_rounded => !_isCupertino() ? Icons.share_rounded : Icons.ios_share_rounded; + + /// Platform-adaptive icon for <i class="material-icons-sharp md-36">share</i> — material icon named "share" (sharp) and <i class="material-icons-sharp md-36">ios_share</i> — material icon named "ios share" (sharp).; + IconData get share_sharp => !_isCupertino() ? Icons.share_sharp : Icons.ios_share_sharp; + // END GENERATED PLATFORM ADAPTIVE ICONS +} + +/// Identifiers for the supported [Material Icons](https://material.io/resources/icons). +/// +/// Use with the [Icon] class to show specific icons. Icons are identified by +/// their name as listed below, e.g. [Icons.airplanemode_on]. +/// +/// Search and find the perfect icon on the [Google Fonts](https://material.io/resources/icons) website. +/// +/// To use this class, make sure you set `uses-material-design: true` in your +/// project's `pubspec.yaml` file in the `flutter` section. This ensures that +/// the Material Icons font is included in your application. This font is used to +/// display the icons. For example: +/// +/// ```yaml +/// name: my_awesome_application +/// flutter: +/// uses-material-design: true +/// ``` +/// +/// {@tool snippet} +/// This example shows how to create a [Row] of [Icon]s in different colors and +/// sizes. The first [Icon] uses a [Icon.semanticLabel] to announce in accessibility +/// modes like TalkBack and VoiceOver. +/// +/// ![The following code snippet would generate a row of icons consisting of a pink heart, a green musical note, and a blue umbrella, each progressively bigger than the last.](https://flutter.github.io/assets-for-api-docs/assets/widgets/icon.png) +/// +/// ```dart +/// const Row( +/// mainAxisAlignment: MainAxisAlignment.spaceAround, +/// children: <Widget>[ +/// Icon( +/// Icons.favorite, +/// color: Colors.pink, +/// size: 24.0, +/// semanticLabel: 'Text to announce in accessibility modes', +/// ), +/// Icon( +/// Icons.audiotrack, +/// color: Colors.green, +/// size: 30.0, +/// ), +/// Icon( +/// Icons.beach_access, +/// color: Colors.blue, +/// size: 36.0, +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Icon] +/// * [IconButton] +/// * <https://material.io/resources/icons> +/// * [AnimatedIcons], for the list of available animated Material Icons. +@staticIconProvider +abstract final class Icons { + /// A set of platform-adaptive Material Design icons. + /// + /// Provides a convenient way to show a certain set of platform-appropriate + /// icons on Apple platforms. + /// + /// Use with the [Icon] class to show specific icons. + /// + /// {@tool snippet} + /// This example shows how to create a share icon that uses the material icon + /// named "share" on non-Apple platforms, and the icon named "ios share" on + /// Apple platforms. + /// + /// ```dart + /// Icon( + /// Icons.adaptive.share, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [Icon] + /// * [IconButton] + /// * <https://design.google.com/icons/> + static PlatformAdaptiveIcons get adaptive => const PlatformAdaptiveIcons._(); + + // Generated code: do not hand-edit. + // https://github.com/flutter/flutter/blob/main/docs/libraries/material/Updating-Material-Design-Fonts-%26-Icons.md + // BEGIN GENERATED ICONS + + /// <i class="material-icons md-36">10k</i> — material icon named "10k". + static const IconData ten_k = IconData(0xe000, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">10k</i> — material icon named "10k" (sharp). + static const IconData ten_k_sharp = IconData(0xe700, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">10k</i> — material icon named "10k" (round). + static const IconData ten_k_rounded = IconData(0xf4df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">10k</i> — material icon named "10k" (outlined). + static const IconData ten_k_outlined = IconData(0xedf2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">10mp</i> — material icon named "10mp". + static const IconData ten_mp = IconData(0xe001, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">10mp</i> — material icon named "10mp" (sharp). + static const IconData ten_mp_sharp = IconData(0xe701, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">10mp</i> — material icon named "10mp" (round). + static const IconData ten_mp_rounded = IconData(0xf4e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">10mp</i> — material icon named "10mp" (outlined). + static const IconData ten_mp_outlined = IconData(0xedf3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">11mp</i> — material icon named "11mp". + static const IconData eleven_mp = IconData(0xe002, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">11mp</i> — material icon named "11mp" (sharp). + static const IconData eleven_mp_sharp = IconData(0xe702, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">11mp</i> — material icon named "11mp" (round). + static const IconData eleven_mp_rounded = IconData(0xf4e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">11mp</i> — material icon named "11mp" (outlined). + static const IconData eleven_mp_outlined = IconData(0xedf4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">123</i> — material icon named "123". + static const IconData onetwothree = IconData(0xf04b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">123</i> — material icon named "123" (sharp). + static const IconData onetwothree_sharp = IconData(0xf03c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">123</i> — material icon named "123" (round). + static const IconData onetwothree_rounded = IconData(0xe340, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">123</i> — material icon named "123" (outlined). + static const IconData onetwothree_outlined = IconData(0xf05b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">12mp</i> — material icon named "12mp". + static const IconData twelve_mp = IconData(0xe003, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">12mp</i> — material icon named "12mp" (sharp). + static const IconData twelve_mp_sharp = IconData(0xe703, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">12mp</i> — material icon named "12mp" (round). + static const IconData twelve_mp_rounded = IconData(0xf4e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">12mp</i> — material icon named "12mp" (outlined). + static const IconData twelve_mp_outlined = IconData(0xedf5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">13mp</i> — material icon named "13mp". + static const IconData thirteen_mp = IconData(0xe004, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">13mp</i> — material icon named "13mp" (sharp). + static const IconData thirteen_mp_sharp = IconData(0xe704, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">13mp</i> — material icon named "13mp" (round). + static const IconData thirteen_mp_rounded = IconData(0xf4e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">13mp</i> — material icon named "13mp" (outlined). + static const IconData thirteen_mp_outlined = IconData(0xedf6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">14mp</i> — material icon named "14mp". + static const IconData fourteen_mp = IconData(0xe005, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">14mp</i> — material icon named "14mp" (sharp). + static const IconData fourteen_mp_sharp = IconData(0xe705, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">14mp</i> — material icon named "14mp" (round). + static const IconData fourteen_mp_rounded = IconData(0xf4e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">14mp</i> — material icon named "14mp" (outlined). + static const IconData fourteen_mp_outlined = IconData(0xedf7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">15mp</i> — material icon named "15mp". + static const IconData fifteen_mp = IconData(0xe006, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">15mp</i> — material icon named "15mp" (sharp). + static const IconData fifteen_mp_sharp = IconData(0xe706, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">15mp</i> — material icon named "15mp" (round). + static const IconData fifteen_mp_rounded = IconData(0xf4e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">15mp</i> — material icon named "15mp" (outlined). + static const IconData fifteen_mp_outlined = IconData(0xedf8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">16mp</i> — material icon named "16mp". + static const IconData sixteen_mp = IconData(0xe007, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">16mp</i> — material icon named "16mp" (sharp). + static const IconData sixteen_mp_sharp = IconData(0xe707, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">16mp</i> — material icon named "16mp" (round). + static const IconData sixteen_mp_rounded = IconData(0xf4e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">16mp</i> — material icon named "16mp" (outlined). + static const IconData sixteen_mp_outlined = IconData(0xedf9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">17mp</i> — material icon named "17mp". + static const IconData seventeen_mp = IconData(0xe008, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">17mp</i> — material icon named "17mp" (sharp). + static const IconData seventeen_mp_sharp = IconData(0xe708, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">17mp</i> — material icon named "17mp" (round). + static const IconData seventeen_mp_rounded = IconData(0xf4e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">17mp</i> — material icon named "17mp" (outlined). + static const IconData seventeen_mp_outlined = IconData(0xedfa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">18_up_rating</i> — material icon named "18 up rating". + static const IconData eighteen_up_rating = IconData(0xf0784, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">18_up_rating</i> — material icon named "18 up rating" (sharp). + static const IconData eighteen_up_rating_sharp = IconData(0xf072c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">18_up_rating</i> — material icon named "18 up rating" (round). + static const IconData eighteen_up_rating_rounded = IconData(0xf07dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">18_up_rating</i> — material icon named "18 up rating" (outlined). + static const IconData eighteen_up_rating_outlined = IconData( + 0xf06d4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">18mp</i> — material icon named "18mp". + static const IconData eighteen_mp = IconData(0xe009, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">18mp</i> — material icon named "18mp" (sharp). + static const IconData eighteen_mp_sharp = IconData(0xe709, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">18mp</i> — material icon named "18mp" (round). + static const IconData eighteen_mp_rounded = IconData(0xf4e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">18mp</i> — material icon named "18mp" (outlined). + static const IconData eighteen_mp_outlined = IconData(0xedfb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">19mp</i> — material icon named "19mp". + static const IconData nineteen_mp = IconData(0xe00a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">19mp</i> — material icon named "19mp" (sharp). + static const IconData nineteen_mp_sharp = IconData(0xe70a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">19mp</i> — material icon named "19mp" (round). + static const IconData nineteen_mp_rounded = IconData(0xf4e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">19mp</i> — material icon named "19mp" (outlined). + static const IconData nineteen_mp_outlined = IconData(0xedfc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">1k</i> — material icon named "1k". + static const IconData one_k = IconData(0xe00b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">1k</i> — material icon named "1k" (sharp). + static const IconData one_k_sharp = IconData(0xe70c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">1k</i> — material icon named "1k" (round). + static const IconData one_k_rounded = IconData(0xf4eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">1k</i> — material icon named "1k" (outlined). + static const IconData one_k_outlined = IconData(0xedfd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">1k_plus</i> — material icon named "1k plus". + static const IconData one_k_plus = IconData(0xe00c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">1k_plus</i> — material icon named "1k plus" (sharp). + static const IconData one_k_plus_sharp = IconData(0xe70b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">1k_plus</i> — material icon named "1k plus" (round). + static const IconData one_k_plus_rounded = IconData(0xf4ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">1k_plus</i> — material icon named "1k plus" (outlined). + static const IconData one_k_plus_outlined = IconData(0xedfe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">1x_mobiledata</i> — material icon named "1x mobiledata". + static const IconData one_x_mobiledata = IconData(0xe00d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">1x_mobiledata</i> — material icon named "1x mobiledata" (sharp). + static const IconData one_x_mobiledata_sharp = IconData(0xe70d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">1x_mobiledata</i> — material icon named "1x mobiledata" (round). + static const IconData one_x_mobiledata_rounded = IconData(0xf4ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">1x_mobiledata</i> — material icon named "1x mobiledata" (outlined). + static const IconData one_x_mobiledata_outlined = IconData(0xedff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">20mp</i> — material icon named "20mp". + static const IconData twenty_mp = IconData(0xe00e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">20mp</i> — material icon named "20mp" (sharp). + static const IconData twenty_mp_sharp = IconData(0xe70e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">20mp</i> — material icon named "20mp" (round). + static const IconData twenty_mp_rounded = IconData(0xf4ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">20mp</i> — material icon named "20mp" (outlined). + static const IconData twenty_mp_outlined = IconData(0xee00, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">21mp</i> — material icon named "21mp". + static const IconData twenty_one_mp = IconData(0xe00f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">21mp</i> — material icon named "21mp" (sharp). + static const IconData twenty_one_mp_sharp = IconData(0xe70f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">21mp</i> — material icon named "21mp" (round). + static const IconData twenty_one_mp_rounded = IconData(0xf4ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">21mp</i> — material icon named "21mp" (outlined). + static const IconData twenty_one_mp_outlined = IconData(0xee01, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">22mp</i> — material icon named "22mp". + static const IconData twenty_two_mp = IconData(0xe010, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">22mp</i> — material icon named "22mp" (sharp). + static const IconData twenty_two_mp_sharp = IconData(0xe710, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">22mp</i> — material icon named "22mp" (round). + static const IconData twenty_two_mp_rounded = IconData(0xf4ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">22mp</i> — material icon named "22mp" (outlined). + static const IconData twenty_two_mp_outlined = IconData(0xee02, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">23mp</i> — material icon named "23mp". + static const IconData twenty_three_mp = IconData(0xe011, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">23mp</i> — material icon named "23mp" (sharp). + static const IconData twenty_three_mp_sharp = IconData(0xe711, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">23mp</i> — material icon named "23mp" (round). + static const IconData twenty_three_mp_rounded = IconData(0xf4f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">23mp</i> — material icon named "23mp" (outlined). + static const IconData twenty_three_mp_outlined = IconData(0xee03, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">24mp</i> — material icon named "24mp". + static const IconData twenty_four_mp = IconData(0xe012, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">24mp</i> — material icon named "24mp" (sharp). + static const IconData twenty_four_mp_sharp = IconData(0xe712, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">24mp</i> — material icon named "24mp" (round). + static const IconData twenty_four_mp_rounded = IconData(0xf4f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">24mp</i> — material icon named "24mp" (outlined). + static const IconData twenty_four_mp_outlined = IconData(0xee04, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">2k</i> — material icon named "2k". + static const IconData two_k = IconData(0xe013, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">2k</i> — material icon named "2k" (sharp). + static const IconData two_k_sharp = IconData(0xe714, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">2k</i> — material icon named "2k" (round). + static const IconData two_k_rounded = IconData(0xf4f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">2k</i> — material icon named "2k" (outlined). + static const IconData two_k_outlined = IconData(0xee05, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">2k_plus</i> — material icon named "2k plus". + static const IconData two_k_plus = IconData(0xe014, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">2k_plus</i> — material icon named "2k plus" (sharp). + static const IconData two_k_plus_sharp = IconData(0xe713, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">2k_plus</i> — material icon named "2k plus" (round). + static const IconData two_k_plus_rounded = IconData(0xf4f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">2k_plus</i> — material icon named "2k plus" (outlined). + static const IconData two_k_plus_outlined = IconData(0xee06, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">2mp</i> — material icon named "2mp". + static const IconData two_mp = IconData(0xe015, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">2mp</i> — material icon named "2mp" (sharp). + static const IconData two_mp_sharp = IconData(0xe715, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">2mp</i> — material icon named "2mp" (round). + static const IconData two_mp_rounded = IconData(0xf4f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">2mp</i> — material icon named "2mp" (outlined). + static const IconData two_mp_outlined = IconData(0xee07, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">30fps</i> — material icon named "30fps". + static const IconData thirty_fps = IconData(0xe016, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">30fps</i> — material icon named "30fps" (sharp). + static const IconData thirty_fps_sharp = IconData(0xe717, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">30fps</i> — material icon named "30fps" (round). + static const IconData thirty_fps_rounded = IconData(0xf4f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">30fps</i> — material icon named "30fps" (outlined). + static const IconData thirty_fps_outlined = IconData(0xee08, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">30fps_select</i> — material icon named "30fps select". + static const IconData thirty_fps_select = IconData(0xe017, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">30fps_select</i> — material icon named "30fps select" (sharp). + static const IconData thirty_fps_select_sharp = IconData(0xe716, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">30fps_select</i> — material icon named "30fps select" (round). + static const IconData thirty_fps_select_rounded = IconData(0xf4f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">30fps_select</i> — material icon named "30fps select" (outlined). + static const IconData thirty_fps_select_outlined = IconData(0xee09, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">360</i> — material icon named "360". + static const IconData threesixty = IconData(0xe018, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">360</i> — material icon named "360" (sharp). + static const IconData threesixty_sharp = IconData(0xe718, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">360</i> — material icon named "360" (round). + static const IconData threesixty_rounded = IconData(0xf4f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">360</i> — material icon named "360" (outlined). + static const IconData threesixty_outlined = IconData(0xee0a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">3d_rotation</i> — material icon named "3d rotation". + static const IconData threed_rotation = IconData(0xe019, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">3d_rotation</i> — material icon named "3d rotation" (sharp). + static const IconData threed_rotation_sharp = IconData(0xe719, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">3d_rotation</i> — material icon named "3d rotation" (round). + static const IconData threed_rotation_rounded = IconData(0xf4f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">3d_rotation</i> — material icon named "3d rotation" (outlined). + static const IconData threed_rotation_outlined = IconData(0xee0b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">3g_mobiledata</i> — material icon named "3g mobiledata". + static const IconData three_g_mobiledata = IconData(0xe01a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">3g_mobiledata</i> — material icon named "3g mobiledata" (sharp). + static const IconData three_g_mobiledata_sharp = IconData(0xe71a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">3g_mobiledata</i> — material icon named "3g mobiledata" (round). + static const IconData three_g_mobiledata_rounded = IconData(0xf4f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">3g_mobiledata</i> — material icon named "3g mobiledata" (outlined). + static const IconData three_g_mobiledata_outlined = IconData(0xee0c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">3k</i> — material icon named "3k". + static const IconData three_k = IconData(0xe01b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">3k</i> — material icon named "3k" (sharp). + static const IconData three_k_sharp = IconData(0xe71c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">3k</i> — material icon named "3k" (round). + static const IconData three_k_rounded = IconData(0xf4fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">3k</i> — material icon named "3k" (outlined). + static const IconData three_k_outlined = IconData(0xee0d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">3k_plus</i> — material icon named "3k plus". + static const IconData three_k_plus = IconData(0xe01c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">3k_plus</i> — material icon named "3k plus" (sharp). + static const IconData three_k_plus_sharp = IconData(0xe71b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">3k_plus</i> — material icon named "3k plus" (round). + static const IconData three_k_plus_rounded = IconData(0xf4fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">3k_plus</i> — material icon named "3k plus" (outlined). + static const IconData three_k_plus_outlined = IconData(0xee0e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">3mp</i> — material icon named "3mp". + static const IconData three_mp = IconData(0xe01d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">3mp</i> — material icon named "3mp" (sharp). + static const IconData three_mp_sharp = IconData(0xe71d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">3mp</i> — material icon named "3mp" (round). + static const IconData three_mp_rounded = IconData(0xf4fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">3mp</i> — material icon named "3mp" (outlined). + static const IconData three_mp_outlined = IconData(0xee0f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">3p</i> — material icon named "3p". + static const IconData three_p = IconData(0xe01e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">3p</i> — material icon named "3p" (sharp). + static const IconData three_p_sharp = IconData(0xe71e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">3p</i> — material icon named "3p" (round). + static const IconData three_p_rounded = IconData(0xf4fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">3p</i> — material icon named "3p" (outlined). + static const IconData three_p_outlined = IconData(0xee10, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">4g_mobiledata</i> — material icon named "4g mobiledata". + static const IconData four_g_mobiledata = IconData(0xe01f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">4g_mobiledata</i> — material icon named "4g mobiledata" (sharp). + static const IconData four_g_mobiledata_sharp = IconData(0xe71f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">4g_mobiledata</i> — material icon named "4g mobiledata" (round). + static const IconData four_g_mobiledata_rounded = IconData(0xf4fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">4g_mobiledata</i> — material icon named "4g mobiledata" (outlined). + static const IconData four_g_mobiledata_outlined = IconData(0xee11, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">4g_plus_mobiledata</i> — material icon named "4g plus mobiledata". + static const IconData four_g_plus_mobiledata = IconData(0xe020, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">4g_plus_mobiledata</i> — material icon named "4g plus mobiledata" (sharp). + static const IconData four_g_plus_mobiledata_sharp = IconData( + 0xe720, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">4g_plus_mobiledata</i> — material icon named "4g plus mobiledata" (round). + static const IconData four_g_plus_mobiledata_rounded = IconData( + 0xf4ff, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">4g_plus_mobiledata</i> — material icon named "4g plus mobiledata" (outlined). + static const IconData four_g_plus_mobiledata_outlined = IconData( + 0xee12, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">4k</i> — material icon named "4k". + static const IconData four_k = IconData(0xe021, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">4k</i> — material icon named "4k" (sharp). + static const IconData four_k_sharp = IconData(0xe722, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">4k</i> — material icon named "4k" (round). + static const IconData four_k_rounded = IconData(0xf501, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">4k</i> — material icon named "4k" (outlined). + static const IconData four_k_outlined = IconData(0xee13, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">4k_plus</i> — material icon named "4k plus". + static const IconData four_k_plus = IconData(0xe022, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">4k_plus</i> — material icon named "4k plus" (sharp). + static const IconData four_k_plus_sharp = IconData(0xe721, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">4k_plus</i> — material icon named "4k plus" (round). + static const IconData four_k_plus_rounded = IconData(0xf500, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">4k_plus</i> — material icon named "4k plus" (outlined). + static const IconData four_k_plus_outlined = IconData(0xee14, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">4mp</i> — material icon named "4mp". + static const IconData four_mp = IconData(0xe023, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">4mp</i> — material icon named "4mp" (sharp). + static const IconData four_mp_sharp = IconData(0xe723, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">4mp</i> — material icon named "4mp" (round). + static const IconData four_mp_rounded = IconData(0xf502, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">4mp</i> — material icon named "4mp" (outlined). + static const IconData four_mp_outlined = IconData(0xee15, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">5g</i> — material icon named "5g". + static const IconData five_g = IconData(0xe024, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">5g</i> — material icon named "5g" (sharp). + static const IconData five_g_sharp = IconData(0xe724, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">5g</i> — material icon named "5g" (round). + static const IconData five_g_rounded = IconData(0xf503, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">5g</i> — material icon named "5g" (outlined). + static const IconData five_g_outlined = IconData(0xee16, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">5k</i> — material icon named "5k". + static const IconData five_k = IconData(0xe025, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">5k</i> — material icon named "5k" (sharp). + static const IconData five_k_sharp = IconData(0xe726, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">5k</i> — material icon named "5k" (round). + static const IconData five_k_rounded = IconData(0xf505, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">5k</i> — material icon named "5k" (outlined). + static const IconData five_k_outlined = IconData(0xee17, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">5k_plus</i> — material icon named "5k plus". + static const IconData five_k_plus = IconData(0xe026, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">5k_plus</i> — material icon named "5k plus" (sharp). + static const IconData five_k_plus_sharp = IconData(0xe725, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">5k_plus</i> — material icon named "5k plus" (round). + static const IconData five_k_plus_rounded = IconData(0xf504, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">5k_plus</i> — material icon named "5k plus" (outlined). + static const IconData five_k_plus_outlined = IconData(0xee18, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">5mp</i> — material icon named "5mp". + static const IconData five_mp = IconData(0xe027, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">5mp</i> — material icon named "5mp" (sharp). + static const IconData five_mp_sharp = IconData(0xe727, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">5mp</i> — material icon named "5mp" (round). + static const IconData five_mp_rounded = IconData(0xf506, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">5mp</i> — material icon named "5mp" (outlined). + static const IconData five_mp_outlined = IconData(0xee19, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">60fps</i> — material icon named "60fps". + static const IconData sixty_fps = IconData(0xe028, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">60fps</i> — material icon named "60fps" (sharp). + static const IconData sixty_fps_sharp = IconData(0xe729, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">60fps</i> — material icon named "60fps" (round). + static const IconData sixty_fps_rounded = IconData(0xf507, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">60fps</i> — material icon named "60fps" (outlined). + static const IconData sixty_fps_outlined = IconData(0xee1a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">60fps_select</i> — material icon named "60fps select". + static const IconData sixty_fps_select = IconData(0xe029, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">60fps_select</i> — material icon named "60fps select" (sharp). + static const IconData sixty_fps_select_sharp = IconData(0xe728, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">60fps_select</i> — material icon named "60fps select" (round). + static const IconData sixty_fps_select_rounded = IconData(0xf508, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">60fps_select</i> — material icon named "60fps select" (outlined). + static const IconData sixty_fps_select_outlined = IconData(0xee1b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">6_ft_apart</i> — material icon named "6 ft apart". + static const IconData six_ft_apart = IconData(0xe02a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">6_ft_apart</i> — material icon named "6 ft apart" (sharp). + static const IconData six_ft_apart_sharp = IconData(0xe72a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">6_ft_apart</i> — material icon named "6 ft apart" (round). + static const IconData six_ft_apart_rounded = IconData(0xf509, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">6_ft_apart</i> — material icon named "6 ft apart" (outlined). + static const IconData six_ft_apart_outlined = IconData(0xee1c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">6k</i> — material icon named "6k". + static const IconData six_k = IconData(0xe02b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">6k</i> — material icon named "6k" (sharp). + static const IconData six_k_sharp = IconData(0xe72c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">6k</i> — material icon named "6k" (round). + static const IconData six_k_rounded = IconData(0xf50b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">6k</i> — material icon named "6k" (outlined). + static const IconData six_k_outlined = IconData(0xee1d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">6k_plus</i> — material icon named "6k plus". + static const IconData six_k_plus = IconData(0xe02c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">6k_plus</i> — material icon named "6k plus" (sharp). + static const IconData six_k_plus_sharp = IconData(0xe72b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">6k_plus</i> — material icon named "6k plus" (round). + static const IconData six_k_plus_rounded = IconData(0xf50a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">6k_plus</i> — material icon named "6k plus" (outlined). + static const IconData six_k_plus_outlined = IconData(0xee1e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">6mp</i> — material icon named "6mp". + static const IconData six_mp = IconData(0xe02d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">6mp</i> — material icon named "6mp" (sharp). + static const IconData six_mp_sharp = IconData(0xe72d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">6mp</i> — material icon named "6mp" (round). + static const IconData six_mp_rounded = IconData(0xf50c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">6mp</i> — material icon named "6mp" (outlined). + static const IconData six_mp_outlined = IconData(0xee1f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">7k</i> — material icon named "7k". + static const IconData seven_k = IconData(0xe02e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">7k</i> — material icon named "7k" (sharp). + static const IconData seven_k_sharp = IconData(0xe72f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">7k</i> — material icon named "7k" (round). + static const IconData seven_k_rounded = IconData(0xf50e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">7k</i> — material icon named "7k" (outlined). + static const IconData seven_k_outlined = IconData(0xee20, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">7k_plus</i> — material icon named "7k plus". + static const IconData seven_k_plus = IconData(0xe02f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">7k_plus</i> — material icon named "7k plus" (sharp). + static const IconData seven_k_plus_sharp = IconData(0xe72e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">7k_plus</i> — material icon named "7k plus" (round). + static const IconData seven_k_plus_rounded = IconData(0xf50d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">7k_plus</i> — material icon named "7k plus" (outlined). + static const IconData seven_k_plus_outlined = IconData(0xee21, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">7mp</i> — material icon named "7mp". + static const IconData seven_mp = IconData(0xe030, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">7mp</i> — material icon named "7mp" (sharp). + static const IconData seven_mp_sharp = IconData(0xe730, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">7mp</i> — material icon named "7mp" (round). + static const IconData seven_mp_rounded = IconData(0xf50f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">7mp</i> — material icon named "7mp" (outlined). + static const IconData seven_mp_outlined = IconData(0xee22, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">8k</i> — material icon named "8k". + static const IconData eight_k = IconData(0xe031, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">8k</i> — material icon named "8k" (sharp). + static const IconData eight_k_sharp = IconData(0xe732, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">8k</i> — material icon named "8k" (round). + static const IconData eight_k_rounded = IconData(0xf511, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">8k</i> — material icon named "8k" (outlined). + static const IconData eight_k_outlined = IconData(0xee23, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">8k_plus</i> — material icon named "8k plus". + static const IconData eight_k_plus = IconData(0xe032, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">8k_plus</i> — material icon named "8k plus" (sharp). + static const IconData eight_k_plus_sharp = IconData(0xe731, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">8k_plus</i> — material icon named "8k plus" (round). + static const IconData eight_k_plus_rounded = IconData(0xf510, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">8k_plus</i> — material icon named "8k plus" (outlined). + static const IconData eight_k_plus_outlined = IconData(0xee24, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">8mp</i> — material icon named "8mp". + static const IconData eight_mp = IconData(0xe033, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">8mp</i> — material icon named "8mp" (sharp). + static const IconData eight_mp_sharp = IconData(0xe733, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">8mp</i> — material icon named "8mp" (round). + static const IconData eight_mp_rounded = IconData(0xf512, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">8mp</i> — material icon named "8mp" (outlined). + static const IconData eight_mp_outlined = IconData(0xee25, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">9k</i> — material icon named "9k". + static const IconData nine_k = IconData(0xe034, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">9k</i> — material icon named "9k" (sharp). + static const IconData nine_k_sharp = IconData(0xe735, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">9k</i> — material icon named "9k" (round). + static const IconData nine_k_rounded = IconData(0xf514, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">9k</i> — material icon named "9k" (outlined). + static const IconData nine_k_outlined = IconData(0xee26, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">9k_plus</i> — material icon named "9k plus". + static const IconData nine_k_plus = IconData(0xe035, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">9k_plus</i> — material icon named "9k plus" (sharp). + static const IconData nine_k_plus_sharp = IconData(0xe734, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">9k_plus</i> — material icon named "9k plus" (round). + static const IconData nine_k_plus_rounded = IconData(0xf513, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">9k_plus</i> — material icon named "9k plus" (outlined). + static const IconData nine_k_plus_outlined = IconData(0xee27, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">9mp</i> — material icon named "9mp". + static const IconData nine_mp = IconData(0xe036, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">9mp</i> — material icon named "9mp" (sharp). + static const IconData nine_mp_sharp = IconData(0xe736, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">9mp</i> — material icon named "9mp" (round). + static const IconData nine_mp_rounded = IconData(0xf515, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">9mp</i> — material icon named "9mp" (outlined). + static const IconData nine_mp_outlined = IconData(0xee28, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">abc</i> — material icon named "abc". + static const IconData abc = IconData(0xf04b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">abc</i> — material icon named "abc" (sharp). + static const IconData abc_sharp = IconData(0xf03c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">abc</i> — material icon named "abc" (round). + static const IconData abc_rounded = IconData(0xe4c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">abc</i> — material icon named "abc" (outlined). + static const IconData abc_outlined = IconData(0xf05b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ac_unit</i> — material icon named "ac unit". + static const IconData ac_unit = IconData(0xe037, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ac_unit</i> — material icon named "ac unit" (sharp). + static const IconData ac_unit_sharp = IconData(0xe737, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ac_unit</i> — material icon named "ac unit" (round). + static const IconData ac_unit_rounded = IconData(0xf516, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ac_unit</i> — material icon named "ac unit" (outlined). + static const IconData ac_unit_outlined = IconData(0xee29, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">access_alarm</i> — material icon named "access alarm". + static const IconData access_alarm = IconData(0xe038, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">access_alarm</i> — material icon named "access alarm" (sharp). + static const IconData access_alarm_sharp = IconData(0xe738, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">access_alarm</i> — material icon named "access alarm" (round). + static const IconData access_alarm_rounded = IconData(0xf517, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">access_alarm</i> — material icon named "access alarm" (outlined). + static const IconData access_alarm_outlined = IconData(0xee2a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">access_alarms</i> — material icon named "access alarms". + static const IconData access_alarms = IconData(0xe039, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">access_alarms</i> — material icon named "access alarms" (sharp). + static const IconData access_alarms_sharp = IconData(0xe739, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">access_alarms</i> — material icon named "access alarms" (round). + static const IconData access_alarms_rounded = IconData(0xf518, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">access_alarms</i> — material icon named "access alarms" (outlined). + static const IconData access_alarms_outlined = IconData(0xee2b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">access_time</i> — material icon named "access time". + static const IconData access_time = IconData(0xe03a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">access_time</i> — material icon named "access time" (sharp). + static const IconData access_time_sharp = IconData(0xe73b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">access_time</i> — material icon named "access time" (round). + static const IconData access_time_rounded = IconData(0xf51a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">access_time</i> — material icon named "access time" (outlined). + static const IconData access_time_outlined = IconData(0xee2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">access_time_filled</i> — material icon named "access time filled". + static const IconData access_time_filled = IconData(0xe03b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">access_time_filled</i> — material icon named "access time filled" (sharp). + static const IconData access_time_filled_sharp = IconData(0xe73a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">access_time_filled</i> — material icon named "access time filled" (round). + static const IconData access_time_filled_rounded = IconData(0xf519, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">access_time_filled</i> — material icon named "access time filled" (outlined). + static const IconData access_time_filled_outlined = IconData(0xee2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">accessibility</i> — material icon named "accessibility". + static const IconData accessibility = IconData(0xe03c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">accessibility</i> — material icon named "accessibility" (sharp). + static const IconData accessibility_sharp = IconData(0xe73d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">accessibility</i> — material icon named "accessibility" (round). + static const IconData accessibility_rounded = IconData(0xf51c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">accessibility</i> — material icon named "accessibility" (outlined). + static const IconData accessibility_outlined = IconData(0xee2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">accessibility_new</i> — material icon named "accessibility new". + static const IconData accessibility_new = IconData(0xe03d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">accessibility_new</i> — material icon named "accessibility new" (sharp). + static const IconData accessibility_new_sharp = IconData(0xe73c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">accessibility_new</i> — material icon named "accessibility new" (round). + static const IconData accessibility_new_rounded = IconData(0xf51b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">accessibility_new</i> — material icon named "accessibility new" (outlined). + static const IconData accessibility_new_outlined = IconData(0xee2e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">accessible</i> — material icon named "accessible". + static const IconData accessible = IconData(0xe03e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">accessible</i> — material icon named "accessible" (sharp). + static const IconData accessible_sharp = IconData(0xe73f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">accessible</i> — material icon named "accessible" (round). + static const IconData accessible_rounded = IconData(0xf51e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">accessible</i> — material icon named "accessible" (outlined). + static const IconData accessible_outlined = IconData(0xee31, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">accessible_forward</i> — material icon named "accessible forward". + static const IconData accessible_forward = IconData(0xe03f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">accessible_forward</i> — material icon named "accessible forward" (sharp). + static const IconData accessible_forward_sharp = IconData(0xe73e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">accessible_forward</i> — material icon named "accessible forward" (round). + static const IconData accessible_forward_rounded = IconData(0xf51d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">accessible_forward</i> — material icon named "accessible forward" (outlined). + static const IconData accessible_forward_outlined = IconData(0xee30, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">account_balance</i> — material icon named "account balance". + static const IconData account_balance = IconData(0xe040, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">account_balance</i> — material icon named "account balance" (sharp). + static const IconData account_balance_sharp = IconData(0xe740, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">account_balance</i> — material icon named "account balance" (round). + static const IconData account_balance_rounded = IconData(0xf51f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">account_balance</i> — material icon named "account balance" (outlined). + static const IconData account_balance_outlined = IconData(0xee32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">account_balance_wallet</i> — material icon named "account balance wallet". + static const IconData account_balance_wallet = IconData(0xe041, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">account_balance_wallet</i> — material icon named "account balance wallet" (sharp). + static const IconData account_balance_wallet_sharp = IconData( + 0xe741, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">account_balance_wallet</i> — material icon named "account balance wallet" (round). + static const IconData account_balance_wallet_rounded = IconData( + 0xf520, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">account_balance_wallet</i> — material icon named "account balance wallet" (outlined). + static const IconData account_balance_wallet_outlined = IconData( + 0xee33, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">account_box</i> — material icon named "account box". + static const IconData account_box = IconData(0xe042, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">account_box</i> — material icon named "account box" (sharp). + static const IconData account_box_sharp = IconData(0xe742, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">account_box</i> — material icon named "account box" (round). + static const IconData account_box_rounded = IconData(0xf521, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">account_box</i> — material icon named "account box" (outlined). + static const IconData account_box_outlined = IconData(0xee34, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">account_circle</i> — material icon named "account circle". + static const IconData account_circle = IconData(0xe043, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">account_circle</i> — material icon named "account circle" (sharp). + static const IconData account_circle_sharp = IconData(0xe743, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">account_circle</i> — material icon named "account circle" (round). + static const IconData account_circle_rounded = IconData(0xf522, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">account_circle</i> — material icon named "account circle" (outlined). + static const IconData account_circle_outlined = IconData(0xee35, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">account_tree</i> — material icon named "account tree". + static const IconData account_tree = IconData(0xe044, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">account_tree</i> — material icon named "account tree" (sharp). + static const IconData account_tree_sharp = IconData(0xe744, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">account_tree</i> — material icon named "account tree" (round). + static const IconData account_tree_rounded = IconData(0xf523, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">account_tree</i> — material icon named "account tree" (outlined). + static const IconData account_tree_outlined = IconData(0xee36, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ad_units</i> — material icon named "ad units". + static const IconData ad_units = IconData(0xe045, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ad_units</i> — material icon named "ad units" (sharp). + static const IconData ad_units_sharp = IconData(0xe745, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ad_units</i> — material icon named "ad units" (round). + static const IconData ad_units_rounded = IconData(0xf524, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ad_units</i> — material icon named "ad units" (outlined). + static const IconData ad_units_outlined = IconData(0xee37, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">adb</i> — material icon named "adb". + static const IconData adb = IconData(0xe046, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">adb</i> — material icon named "adb" (sharp). + static const IconData adb_sharp = IconData(0xe746, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">adb</i> — material icon named "adb" (round). + static const IconData adb_rounded = IconData(0xf525, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">adb</i> — material icon named "adb" (outlined). + static const IconData adb_outlined = IconData(0xee38, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add</i> — material icon named "add". + static const IconData add = IconData(0xe047, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add</i> — material icon named "add" (sharp). + static const IconData add_sharp = IconData(0xe758, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add</i> — material icon named "add" (round). + static const IconData add_rounded = IconData(0xf537, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add</i> — material icon named "add" (outlined). + static const IconData add_outlined = IconData(0xee47, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_a_photo</i> — material icon named "add a photo". + static const IconData add_a_photo = IconData(0xe048, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_a_photo</i> — material icon named "add a photo" (sharp). + static const IconData add_a_photo_sharp = IconData(0xe747, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_a_photo</i> — material icon named "add a photo" (round). + static const IconData add_a_photo_rounded = IconData(0xf526, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_a_photo</i> — material icon named "add a photo" (outlined). + static const IconData add_a_photo_outlined = IconData(0xee39, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_alarm</i> — material icon named "add alarm". + static const IconData add_alarm = IconData(0xe049, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_alarm</i> — material icon named "add alarm" (sharp). + static const IconData add_alarm_sharp = IconData(0xe748, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_alarm</i> — material icon named "add alarm" (round). + static const IconData add_alarm_rounded = IconData(0xf527, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_alarm</i> — material icon named "add alarm" (outlined). + static const IconData add_alarm_outlined = IconData(0xee3a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_alert</i> — material icon named "add alert". + static const IconData add_alert = IconData(0xe04a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_alert</i> — material icon named "add alert" (sharp). + static const IconData add_alert_sharp = IconData(0xe749, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_alert</i> — material icon named "add alert" (round). + static const IconData add_alert_rounded = IconData(0xf528, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_alert</i> — material icon named "add alert" (outlined). + static const IconData add_alert_outlined = IconData(0xee3b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_box</i> — material icon named "add box". + static const IconData add_box = IconData(0xe04b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_box</i> — material icon named "add box" (sharp). + static const IconData add_box_sharp = IconData(0xe74a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_box</i> — material icon named "add box" (round). + static const IconData add_box_rounded = IconData(0xf529, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_box</i> — material icon named "add box" (outlined). + static const IconData add_box_outlined = IconData(0xee3c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_business</i> — material icon named "add business". + static const IconData add_business = IconData(0xe04c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_business</i> — material icon named "add business" (sharp). + static const IconData add_business_sharp = IconData(0xe74b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_business</i> — material icon named "add business" (round). + static const IconData add_business_rounded = IconData(0xf52a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_business</i> — material icon named "add business" (outlined). + static const IconData add_business_outlined = IconData(0xee3d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_call</i> — material icon named "add call". + static const IconData add_call = IconData(0xe04d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_card</i> — material icon named "add card". + static const IconData add_card = IconData(0xf04b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_card</i> — material icon named "add card" (sharp). + static const IconData add_card_sharp = IconData(0xf03c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_card</i> — material icon named "add card" (round). + static const IconData add_card_rounded = IconData(0xf02d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_card</i> — material icon named "add card" (outlined). + static const IconData add_card_outlined = IconData(0xf05b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_chart</i> — material icon named "add chart". + static const IconData add_chart = IconData(0xe04e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_chart</i> — material icon named "add chart" (sharp). + static const IconData add_chart_sharp = IconData(0xe74c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_chart</i> — material icon named "add chart" (round). + static const IconData add_chart_rounded = IconData(0xf52b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_chart</i> — material icon named "add chart" (outlined). + static const IconData add_chart_outlined = IconData(0xee3e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_circle</i> — material icon named "add circle". + static const IconData add_circle = IconData(0xe04f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_circle</i> — material icon named "add circle" (sharp). + static const IconData add_circle_sharp = IconData(0xe74e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_circle</i> — material icon named "add circle" (round). + static const IconData add_circle_rounded = IconData(0xf52d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_circle</i> — material icon named "add circle" (outlined). + static const IconData add_circle_outlined = IconData(0xee40, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_circle_outline</i> — material icon named "add circle outline". + static const IconData add_circle_outline = IconData(0xe050, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_circle_outline</i> — material icon named "add circle outline" (sharp). + static const IconData add_circle_outline_sharp = IconData(0xe74d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_circle_outline</i> — material icon named "add circle outline" (round). + static const IconData add_circle_outline_rounded = IconData(0xf52c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_circle_outline</i> — material icon named "add circle outline" (outlined). + static const IconData add_circle_outline_outlined = IconData(0xee3f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_comment</i> — material icon named "add comment". + static const IconData add_comment = IconData(0xe051, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_comment</i> — material icon named "add comment" (sharp). + static const IconData add_comment_sharp = IconData(0xe74f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_comment</i> — material icon named "add comment" (round). + static const IconData add_comment_rounded = IconData(0xf52e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_comment</i> — material icon named "add comment" (outlined). + static const IconData add_comment_outlined = IconData(0xee41, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_home</i> — material icon named "add home". + static const IconData add_home = IconData(0xf0785, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_home</i> — material icon named "add home" (sharp). + static const IconData add_home_sharp = IconData(0xf072d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_home</i> — material icon named "add home" (round). + static const IconData add_home_rounded = IconData(0xf07dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_home</i> — material icon named "add home" (outlined). + static const IconData add_home_outlined = IconData(0xf06d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_home_work</i> — material icon named "add home work". + static const IconData add_home_work = IconData(0xf0786, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_home_work</i> — material icon named "add home work" (sharp). + static const IconData add_home_work_sharp = IconData(0xf072e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_home_work</i> — material icon named "add home work" (round). + static const IconData add_home_work_rounded = IconData(0xf07de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_home_work</i> — material icon named "add home work" (outlined). + static const IconData add_home_work_outlined = IconData(0xf06d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_ic_call</i> — material icon named "add ic call". + static const IconData add_ic_call = IconData(0xe052, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_ic_call</i> — material icon named "add ic call" (sharp). + static const IconData add_ic_call_sharp = IconData(0xe750, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_ic_call</i> — material icon named "add ic call" (round). + static const IconData add_ic_call_rounded = IconData(0xf52f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_ic_call</i> — material icon named "add ic call" (outlined). + static const IconData add_ic_call_outlined = IconData(0xee42, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_link</i> — material icon named "add link". + static const IconData add_link = IconData(0xe053, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_link</i> — material icon named "add link" (sharp). + static const IconData add_link_sharp = IconData(0xe751, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_link</i> — material icon named "add link" (round). + static const IconData add_link_rounded = IconData(0xf530, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_link</i> — material icon named "add link" (outlined). + static const IconData add_link_outlined = IconData(0xee43, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_location</i> — material icon named "add location". + static const IconData add_location = IconData(0xe054, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_location</i> — material icon named "add location" (sharp). + static const IconData add_location_sharp = IconData(0xe753, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_location</i> — material icon named "add location" (round). + static const IconData add_location_rounded = IconData(0xf532, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_location</i> — material icon named "add location" (outlined). + static const IconData add_location_outlined = IconData(0xee45, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_location_alt</i> — material icon named "add location alt". + static const IconData add_location_alt = IconData(0xe055, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_location_alt</i> — material icon named "add location alt" (sharp). + static const IconData add_location_alt_sharp = IconData(0xe752, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_location_alt</i> — material icon named "add location alt" (round). + static const IconData add_location_alt_rounded = IconData(0xf531, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_location_alt</i> — material icon named "add location alt" (outlined). + static const IconData add_location_alt_outlined = IconData(0xee44, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_moderator</i> — material icon named "add moderator". + static const IconData add_moderator = IconData(0xe056, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_moderator</i> — material icon named "add moderator" (sharp). + static const IconData add_moderator_sharp = IconData(0xe754, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_moderator</i> — material icon named "add moderator" (round). + static const IconData add_moderator_rounded = IconData(0xf533, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_moderator</i> — material icon named "add moderator" (outlined). + static const IconData add_moderator_outlined = IconData(0xee46, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_photo_alternate</i> — material icon named "add photo alternate". + static const IconData add_photo_alternate = IconData(0xe057, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_photo_alternate</i> — material icon named "add photo alternate" (sharp). + static const IconData add_photo_alternate_sharp = IconData(0xe755, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_photo_alternate</i> — material icon named "add photo alternate" (round). + static const IconData add_photo_alternate_rounded = IconData(0xf534, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_photo_alternate</i> — material icon named "add photo alternate" (outlined). + static const IconData add_photo_alternate_outlined = IconData( + 0xee48, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">add_reaction</i> — material icon named "add reaction". + static const IconData add_reaction = IconData(0xe058, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_reaction</i> — material icon named "add reaction" (sharp). + static const IconData add_reaction_sharp = IconData(0xe756, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_reaction</i> — material icon named "add reaction" (round). + static const IconData add_reaction_rounded = IconData(0xf535, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_reaction</i> — material icon named "add reaction" (outlined). + static const IconData add_reaction_outlined = IconData(0xee49, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_road</i> — material icon named "add road". + static const IconData add_road = IconData(0xe059, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_road</i> — material icon named "add road" (sharp). + static const IconData add_road_sharp = IconData(0xe757, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_road</i> — material icon named "add road" (round). + static const IconData add_road_rounded = IconData(0xf536, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_road</i> — material icon named "add road" (outlined). + static const IconData add_road_outlined = IconData(0xee4a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_shopping_cart</i> — material icon named "add shopping cart". + static const IconData add_shopping_cart = IconData(0xe05a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_shopping_cart</i> — material icon named "add shopping cart" (sharp). + static const IconData add_shopping_cart_sharp = IconData(0xe759, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_shopping_cart</i> — material icon named "add shopping cart" (round). + static const IconData add_shopping_cart_rounded = IconData(0xf538, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_shopping_cart</i> — material icon named "add shopping cart" (outlined). + static const IconData add_shopping_cart_outlined = IconData(0xee4b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_task</i> — material icon named "add task". + static const IconData add_task = IconData(0xe05b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_task</i> — material icon named "add task" (sharp). + static const IconData add_task_sharp = IconData(0xe75a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_task</i> — material icon named "add task" (round). + static const IconData add_task_rounded = IconData(0xf539, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_task</i> — material icon named "add task" (outlined). + static const IconData add_task_outlined = IconData(0xee4c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_to_drive</i> — material icon named "add to drive". + static const IconData add_to_drive = IconData(0xe05c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_to_drive</i> — material icon named "add to drive" (sharp). + static const IconData add_to_drive_sharp = IconData(0xe75b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_to_drive</i> — material icon named "add to drive" (round). + static const IconData add_to_drive_rounded = IconData(0xf53a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_to_drive</i> — material icon named "add to drive" (outlined). + static const IconData add_to_drive_outlined = IconData(0xee4d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_to_home_screen</i> — material icon named "add to home screen". + static const IconData add_to_home_screen = IconData(0xe05d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_to_home_screen</i> — material icon named "add to home screen" (sharp). + static const IconData add_to_home_screen_sharp = IconData(0xe75c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_to_home_screen</i> — material icon named "add to home screen" (round). + static const IconData add_to_home_screen_rounded = IconData(0xf53b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_to_home_screen</i> — material icon named "add to home screen" (outlined). + static const IconData add_to_home_screen_outlined = IconData(0xee4e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_to_photos</i> — material icon named "add to photos". + static const IconData add_to_photos = IconData(0xe05e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_to_photos</i> — material icon named "add to photos" (sharp). + static const IconData add_to_photos_sharp = IconData(0xe75d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_to_photos</i> — material icon named "add to photos" (round). + static const IconData add_to_photos_rounded = IconData(0xf53c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_to_photos</i> — material icon named "add to photos" (outlined). + static const IconData add_to_photos_outlined = IconData(0xee4f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">add_to_queue</i> — material icon named "add to queue". + static const IconData add_to_queue = IconData(0xe05f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">add_to_queue</i> — material icon named "add to queue" (sharp). + static const IconData add_to_queue_sharp = IconData(0xe75e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">add_to_queue</i> — material icon named "add to queue" (round). + static const IconData add_to_queue_rounded = IconData(0xf53d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">add_to_queue</i> — material icon named "add to queue" (outlined). + static const IconData add_to_queue_outlined = IconData(0xee50, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">addchart</i> — material icon named "addchart". + static const IconData addchart = IconData(0xe060, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">addchart</i> — material icon named "addchart" (sharp). + static const IconData addchart_sharp = IconData(0xe75f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">addchart</i> — material icon named "addchart" (round). + static const IconData addchart_rounded = IconData(0xf53e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">addchart</i> — material icon named "addchart" (outlined). + static const IconData addchart_outlined = IconData(0xee51, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">adf_scanner</i> — material icon named "adf scanner". + static const IconData adf_scanner = IconData(0xf04b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">adf_scanner</i> — material icon named "adf scanner" (sharp). + static const IconData adf_scanner_sharp = IconData(0xf03c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">adf_scanner</i> — material icon named "adf scanner" (round). + static const IconData adf_scanner_rounded = IconData(0xf02d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">adf_scanner</i> — material icon named "adf scanner" (outlined). + static const IconData adf_scanner_outlined = IconData(0xf05b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">adjust</i> — material icon named "adjust". + static const IconData adjust = IconData(0xe061, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">adjust</i> — material icon named "adjust" (sharp). + static const IconData adjust_sharp = IconData(0xe760, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">adjust</i> — material icon named "adjust" (round). + static const IconData adjust_rounded = IconData(0xf53f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">adjust</i> — material icon named "adjust" (outlined). + static const IconData adjust_outlined = IconData(0xee52, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">admin_panel_settings</i> — material icon named "admin panel settings". + static const IconData admin_panel_settings = IconData(0xe062, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">admin_panel_settings</i> — material icon named "admin panel settings" (sharp). + static const IconData admin_panel_settings_sharp = IconData(0xe761, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">admin_panel_settings</i> — material icon named "admin panel settings" (round). + static const IconData admin_panel_settings_rounded = IconData( + 0xf540, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">admin_panel_settings</i> — material icon named "admin panel settings" (outlined). + static const IconData admin_panel_settings_outlined = IconData( + 0xee53, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">adobe</i> — material icon named "adobe". + static const IconData adobe = IconData(0xf04b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">adobe</i> — material icon named "adobe" (sharp). + static const IconData adobe_sharp = IconData(0xf03c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">adobe</i> — material icon named "adobe" (round). + static const IconData adobe_rounded = IconData(0xf02d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">adobe</i> — material icon named "adobe" (outlined). + static const IconData adobe_outlined = IconData(0xf05b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ads_click</i> — material icon named "ads click". + static const IconData ads_click = IconData(0xf04ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ads_click</i> — material icon named "ads click" (sharp). + static const IconData ads_click_sharp = IconData(0xf03c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ads_click</i> — material icon named "ads click" (round). + static const IconData ads_click_rounded = IconData(0xf02d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ads_click</i> — material icon named "ads click" (outlined). + static const IconData ads_click_outlined = IconData(0xf05b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">agriculture</i> — material icon named "agriculture". + static const IconData agriculture = IconData(0xe063, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">agriculture</i> — material icon named "agriculture" (sharp). + static const IconData agriculture_sharp = IconData(0xe762, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">agriculture</i> — material icon named "agriculture" (round). + static const IconData agriculture_rounded = IconData(0xf541, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">agriculture</i> — material icon named "agriculture" (outlined). + static const IconData agriculture_outlined = IconData(0xee54, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">air</i> — material icon named "air". + static const IconData air = IconData(0xe064, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">air</i> — material icon named "air" (sharp). + static const IconData air_sharp = IconData(0xe763, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">air</i> — material icon named "air" (round). + static const IconData air_rounded = IconData(0xf542, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">air</i> — material icon named "air" (outlined). + static const IconData air_outlined = IconData(0xee55, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airline_seat_flat</i> — material icon named "airline seat flat". + static const IconData airline_seat_flat = IconData(0xe065, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_seat_flat</i> — material icon named "airline seat flat" (sharp). + static const IconData airline_seat_flat_sharp = IconData(0xe765, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airline_seat_flat</i> — material icon named "airline seat flat" (round). + static const IconData airline_seat_flat_rounded = IconData(0xf544, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airline_seat_flat</i> — material icon named "airline seat flat" (outlined). + static const IconData airline_seat_flat_outlined = IconData(0xee57, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airline_seat_flat_angled</i> — material icon named "airline seat flat angled". + static const IconData airline_seat_flat_angled = IconData(0xe066, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_seat_flat_angled</i> — material icon named "airline seat flat angled" (sharp). + static const IconData airline_seat_flat_angled_sharp = IconData( + 0xe764, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_flat_angled</i> — material icon named "airline seat flat angled" (round). + static const IconData airline_seat_flat_angled_rounded = IconData( + 0xf543, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_flat_angled</i> — material icon named "airline seat flat angled" (outlined). + static const IconData airline_seat_flat_angled_outlined = IconData( + 0xee56, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_seat_individual_suite</i> — material icon named "airline seat individual suite". + static const IconData airline_seat_individual_suite = IconData( + 0xe067, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">airline_seat_individual_suite</i> — material icon named "airline seat individual suite" (sharp). + static const IconData airline_seat_individual_suite_sharp = IconData( + 0xe766, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_individual_suite</i> — material icon named "airline seat individual suite" (round). + static const IconData airline_seat_individual_suite_rounded = IconData( + 0xf545, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_individual_suite</i> — material icon named "airline seat individual suite" (outlined). + static const IconData airline_seat_individual_suite_outlined = IconData( + 0xee58, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_seat_legroom_extra</i> — material icon named "airline seat legroom extra". + static const IconData airline_seat_legroom_extra = IconData(0xe068, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_seat_legroom_extra</i> — material icon named "airline seat legroom extra" (sharp). + static const IconData airline_seat_legroom_extra_sharp = IconData( + 0xe767, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_legroom_extra</i> — material icon named "airline seat legroom extra" (round). + static const IconData airline_seat_legroom_extra_rounded = IconData( + 0xf546, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_legroom_extra</i> — material icon named "airline seat legroom extra" (outlined). + static const IconData airline_seat_legroom_extra_outlined = IconData( + 0xee59, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_seat_legroom_normal</i> — material icon named "airline seat legroom normal". + static const IconData airline_seat_legroom_normal = IconData(0xe069, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_seat_legroom_normal</i> — material icon named "airline seat legroom normal" (sharp). + static const IconData airline_seat_legroom_normal_sharp = IconData( + 0xe768, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_legroom_normal</i> — material icon named "airline seat legroom normal" (round). + static const IconData airline_seat_legroom_normal_rounded = IconData( + 0xf547, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_legroom_normal</i> — material icon named "airline seat legroom normal" (outlined). + static const IconData airline_seat_legroom_normal_outlined = IconData( + 0xee5a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_seat_legroom_reduced</i> — material icon named "airline seat legroom reduced". + static const IconData airline_seat_legroom_reduced = IconData( + 0xe06a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">airline_seat_legroom_reduced</i> — material icon named "airline seat legroom reduced" (sharp). + static const IconData airline_seat_legroom_reduced_sharp = IconData( + 0xe769, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_legroom_reduced</i> — material icon named "airline seat legroom reduced" (round). + static const IconData airline_seat_legroom_reduced_rounded = IconData( + 0xf548, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_legroom_reduced</i> — material icon named "airline seat legroom reduced" (outlined). + static const IconData airline_seat_legroom_reduced_outlined = IconData( + 0xee5b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_seat_recline_extra</i> — material icon named "airline seat recline extra". + static const IconData airline_seat_recline_extra = IconData(0xe06b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_seat_recline_extra</i> — material icon named "airline seat recline extra" (sharp). + static const IconData airline_seat_recline_extra_sharp = IconData( + 0xe76a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_recline_extra</i> — material icon named "airline seat recline extra" (round). + static const IconData airline_seat_recline_extra_rounded = IconData( + 0xf549, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_recline_extra</i> — material icon named "airline seat recline extra" (outlined). + static const IconData airline_seat_recline_extra_outlined = IconData( + 0xee5c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_seat_recline_normal</i> — material icon named "airline seat recline normal". + static const IconData airline_seat_recline_normal = IconData(0xe06c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_seat_recline_normal</i> — material icon named "airline seat recline normal" (sharp). + static const IconData airline_seat_recline_normal_sharp = IconData( + 0xe76b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">airline_seat_recline_normal</i> — material icon named "airline seat recline normal" (round). + static const IconData airline_seat_recline_normal_rounded = IconData( + 0xf54a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airline_seat_recline_normal</i> — material icon named "airline seat recline normal" (outlined). + static const IconData airline_seat_recline_normal_outlined = IconData( + 0xee5d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airline_stops</i> — material icon named "airline stops". + static const IconData airline_stops = IconData(0xf04bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airline_stops</i> — material icon named "airline stops" (sharp). + static const IconData airline_stops_sharp = IconData(0xf03c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airline_stops</i> — material icon named "airline stops" (round). + static const IconData airline_stops_rounded = IconData(0xf02d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airline_stops</i> — material icon named "airline stops" (outlined). + static const IconData airline_stops_outlined = IconData(0xf05b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airlines</i> — material icon named "airlines". + static const IconData airlines = IconData(0xf04bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airlines</i> — material icon named "airlines" (sharp). + static const IconData airlines_sharp = IconData(0xf03c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airlines</i> — material icon named "airlines" (round). + static const IconData airlines_rounded = IconData(0xf02d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airlines</i> — material icon named "airlines" (outlined). + static const IconData airlines_outlined = IconData(0xf05b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airplane_ticket</i> — material icon named "airplane ticket". + static const IconData airplane_ticket = IconData(0xe06d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airplane_ticket</i> — material icon named "airplane ticket" (sharp). + static const IconData airplane_ticket_sharp = IconData(0xe76c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airplane_ticket</i> — material icon named "airplane ticket" (round). + static const IconData airplane_ticket_rounded = IconData(0xf54b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airplane_ticket</i> — material icon named "airplane ticket" (outlined). + static const IconData airplane_ticket_outlined = IconData(0xee5e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airplanemode_active</i> — material icon named "airplanemode active". + static const IconData airplanemode_active = IconData(0xe06e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airplanemode_active</i> — material icon named "airplanemode active" (sharp). + static const IconData airplanemode_active_sharp = IconData(0xe76d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airplanemode_active</i> — material icon named "airplanemode active" (round). + static const IconData airplanemode_active_rounded = IconData(0xf54c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airplanemode_active</i> — material icon named "airplanemode active" (outlined). + static const IconData airplanemode_active_outlined = IconData( + 0xee5f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airplanemode_inactive</i> — material icon named "airplanemode inactive". + static const IconData airplanemode_inactive = IconData(0xe06f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airplanemode_inactive</i> — material icon named "airplanemode inactive" (sharp). + static const IconData airplanemode_inactive_sharp = IconData(0xe76e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airplanemode_inactive</i> — material icon named "airplanemode inactive" (round). + static const IconData airplanemode_inactive_rounded = IconData( + 0xf54d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">airplanemode_inactive</i> — material icon named "airplanemode inactive" (outlined). + static const IconData airplanemode_inactive_outlined = IconData( + 0xee60, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">airplanemode_off</i> — material icon named "airplanemode off". + static const IconData airplanemode_off = IconData(0xe06f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airplanemode_off</i> — material icon named "airplanemode off" (sharp). + static const IconData airplanemode_off_sharp = IconData(0xe76e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airplanemode_off</i> — material icon named "airplanemode off" (round). + static const IconData airplanemode_off_rounded = IconData(0xf54d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airplanemode_off</i> — material icon named "airplanemode off" (outlined). + static const IconData airplanemode_off_outlined = IconData(0xee60, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airplanemode_on</i> — material icon named "airplanemode on". + static const IconData airplanemode_on = IconData(0xe06e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airplanemode_on</i> — material icon named "airplanemode on" (sharp). + static const IconData airplanemode_on_sharp = IconData(0xe76d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airplanemode_on</i> — material icon named "airplanemode on" (round). + static const IconData airplanemode_on_rounded = IconData(0xf54c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airplanemode_on</i> — material icon named "airplanemode on" (outlined). + static const IconData airplanemode_on_outlined = IconData(0xee5f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airplay</i> — material icon named "airplay". + static const IconData airplay = IconData(0xe070, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airplay</i> — material icon named "airplay" (sharp). + static const IconData airplay_sharp = IconData(0xe76f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airplay</i> — material icon named "airplay" (round). + static const IconData airplay_rounded = IconData(0xf54e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airplay</i> — material icon named "airplay" (outlined). + static const IconData airplay_outlined = IconData(0xee61, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">airport_shuttle</i> — material icon named "airport shuttle". + static const IconData airport_shuttle = IconData(0xe071, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">airport_shuttle</i> — material icon named "airport shuttle" (sharp). + static const IconData airport_shuttle_sharp = IconData(0xe770, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">airport_shuttle</i> — material icon named "airport shuttle" (round). + static const IconData airport_shuttle_rounded = IconData(0xf54f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">airport_shuttle</i> — material icon named "airport shuttle" (outlined). + static const IconData airport_shuttle_outlined = IconData(0xee62, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">alarm</i> — material icon named "alarm". + static const IconData alarm = IconData(0xe072, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">alarm</i> — material icon named "alarm" (sharp). + static const IconData alarm_sharp = IconData(0xe774, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">alarm</i> — material icon named "alarm" (round). + static const IconData alarm_rounded = IconData(0xf553, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">alarm</i> — material icon named "alarm" (outlined). + static const IconData alarm_outlined = IconData(0xee66, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">alarm_add</i> — material icon named "alarm add". + static const IconData alarm_add = IconData(0xe073, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">alarm_add</i> — material icon named "alarm add" (sharp). + static const IconData alarm_add_sharp = IconData(0xe771, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">alarm_add</i> — material icon named "alarm add" (round). + static const IconData alarm_add_rounded = IconData(0xf550, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">alarm_add</i> — material icon named "alarm add" (outlined). + static const IconData alarm_add_outlined = IconData(0xee63, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">alarm_off</i> — material icon named "alarm off". + static const IconData alarm_off = IconData(0xe074, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">alarm_off</i> — material icon named "alarm off" (sharp). + static const IconData alarm_off_sharp = IconData(0xe772, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">alarm_off</i> — material icon named "alarm off" (round). + static const IconData alarm_off_rounded = IconData(0xf551, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">alarm_off</i> — material icon named "alarm off" (outlined). + static const IconData alarm_off_outlined = IconData(0xee64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">alarm_on</i> — material icon named "alarm on". + static const IconData alarm_on = IconData(0xe075, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">alarm_on</i> — material icon named "alarm on" (sharp). + static const IconData alarm_on_sharp = IconData(0xe773, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">alarm_on</i> — material icon named "alarm on" (round). + static const IconData alarm_on_rounded = IconData(0xf552, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">alarm_on</i> — material icon named "alarm on" (outlined). + static const IconData alarm_on_outlined = IconData(0xee65, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">album</i> — material icon named "album". + static const IconData album = IconData(0xe076, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">album</i> — material icon named "album" (sharp). + static const IconData album_sharp = IconData(0xe775, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">album</i> — material icon named "album" (round). + static const IconData album_rounded = IconData(0xf554, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">album</i> — material icon named "album" (outlined). + static const IconData album_outlined = IconData(0xee67, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">align_horizontal_center</i> — material icon named "align horizontal center". + static const IconData align_horizontal_center = IconData(0xe077, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">align_horizontal_center</i> — material icon named "align horizontal center" (sharp). + static const IconData align_horizontal_center_sharp = IconData( + 0xe776, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">align_horizontal_center</i> — material icon named "align horizontal center" (round). + static const IconData align_horizontal_center_rounded = IconData( + 0xf555, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">align_horizontal_center</i> — material icon named "align horizontal center" (outlined). + static const IconData align_horizontal_center_outlined = IconData( + 0xee68, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">align_horizontal_left</i> — material icon named "align horizontal left". + static const IconData align_horizontal_left = IconData(0xe078, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">align_horizontal_left</i> — material icon named "align horizontal left" (sharp). + static const IconData align_horizontal_left_sharp = IconData(0xe777, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">align_horizontal_left</i> — material icon named "align horizontal left" (round). + static const IconData align_horizontal_left_rounded = IconData( + 0xf556, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">align_horizontal_left</i> — material icon named "align horizontal left" (outlined). + static const IconData align_horizontal_left_outlined = IconData( + 0xee69, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">align_horizontal_right</i> — material icon named "align horizontal right". + static const IconData align_horizontal_right = IconData(0xe079, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">align_horizontal_right</i> — material icon named "align horizontal right" (sharp). + static const IconData align_horizontal_right_sharp = IconData( + 0xe778, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">align_horizontal_right</i> — material icon named "align horizontal right" (round). + static const IconData align_horizontal_right_rounded = IconData( + 0xf557, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">align_horizontal_right</i> — material icon named "align horizontal right" (outlined). + static const IconData align_horizontal_right_outlined = IconData( + 0xee6a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">align_vertical_bottom</i> — material icon named "align vertical bottom". + static const IconData align_vertical_bottom = IconData(0xe07a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">align_vertical_bottom</i> — material icon named "align vertical bottom" (sharp). + static const IconData align_vertical_bottom_sharp = IconData(0xe779, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">align_vertical_bottom</i> — material icon named "align vertical bottom" (round). + static const IconData align_vertical_bottom_rounded = IconData( + 0xf558, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">align_vertical_bottom</i> — material icon named "align vertical bottom" (outlined). + static const IconData align_vertical_bottom_outlined = IconData( + 0xee6b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">align_vertical_center</i> — material icon named "align vertical center". + static const IconData align_vertical_center = IconData(0xe07b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">align_vertical_center</i> — material icon named "align vertical center" (sharp). + static const IconData align_vertical_center_sharp = IconData(0xe77a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">align_vertical_center</i> — material icon named "align vertical center" (round). + static const IconData align_vertical_center_rounded = IconData( + 0xf559, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">align_vertical_center</i> — material icon named "align vertical center" (outlined). + static const IconData align_vertical_center_outlined = IconData( + 0xee6c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">align_vertical_top</i> — material icon named "align vertical top". + static const IconData align_vertical_top = IconData(0xe07c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">align_vertical_top</i> — material icon named "align vertical top" (sharp). + static const IconData align_vertical_top_sharp = IconData(0xe77b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">align_vertical_top</i> — material icon named "align vertical top" (round). + static const IconData align_vertical_top_rounded = IconData(0xf55a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">align_vertical_top</i> — material icon named "align vertical top" (outlined). + static const IconData align_vertical_top_outlined = IconData(0xee6d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">all_inbox</i> — material icon named "all inbox". + static const IconData all_inbox = IconData(0xe07d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">all_inbox</i> — material icon named "all inbox" (sharp). + static const IconData all_inbox_sharp = IconData(0xe77c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">all_inbox</i> — material icon named "all inbox" (round). + static const IconData all_inbox_rounded = IconData(0xf55b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">all_inbox</i> — material icon named "all inbox" (outlined). + static const IconData all_inbox_outlined = IconData(0xee6e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">all_inclusive</i> — material icon named "all inclusive". + static const IconData all_inclusive = IconData(0xe07e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">all_inclusive</i> — material icon named "all inclusive" (sharp). + static const IconData all_inclusive_sharp = IconData(0xe77d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">all_inclusive</i> — material icon named "all inclusive" (round). + static const IconData all_inclusive_rounded = IconData(0xf55c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">all_inclusive</i> — material icon named "all inclusive" (outlined). + static const IconData all_inclusive_outlined = IconData(0xee6f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">all_out</i> — material icon named "all out". + static const IconData all_out = IconData(0xe07f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">all_out</i> — material icon named "all out" (sharp). + static const IconData all_out_sharp = IconData(0xe77e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">all_out</i> — material icon named "all out" (round). + static const IconData all_out_rounded = IconData(0xf55d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">all_out</i> — material icon named "all out" (outlined). + static const IconData all_out_outlined = IconData(0xee70, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">alt_route</i> — material icon named "alt route". + static const IconData alt_route = IconData(0xe080, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">alt_route</i> — material icon named "alt route" (sharp). + static const IconData alt_route_sharp = IconData(0xe77f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">alt_route</i> — material icon named "alt route" (round). + static const IconData alt_route_rounded = IconData(0xf55e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">alt_route</i> — material icon named "alt route" (outlined). + static const IconData alt_route_outlined = IconData(0xee71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">alternate_email</i> — material icon named "alternate email". + static const IconData alternate_email = IconData(0xe081, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">alternate_email</i> — material icon named "alternate email" (sharp). + static const IconData alternate_email_sharp = IconData(0xe780, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">alternate_email</i> — material icon named "alternate email" (round). + static const IconData alternate_email_rounded = IconData(0xf55f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">alternate_email</i> — material icon named "alternate email" (outlined). + static const IconData alternate_email_outlined = IconData(0xee72, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">amp_stories</i> — material icon named "amp stories". + static const IconData amp_stories = IconData(0xe082, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">amp_stories</i> — material icon named "amp stories" (sharp). + static const IconData amp_stories_sharp = IconData(0xe781, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">amp_stories</i> — material icon named "amp stories" (round). + static const IconData amp_stories_rounded = IconData(0xf560, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">amp_stories</i> — material icon named "amp stories" (outlined). + static const IconData amp_stories_outlined = IconData(0xee73, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">analytics</i> — material icon named "analytics". + static const IconData analytics = IconData(0xe083, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">analytics</i> — material icon named "analytics" (sharp). + static const IconData analytics_sharp = IconData(0xe782, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">analytics</i> — material icon named "analytics" (round). + static const IconData analytics_rounded = IconData(0xf561, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">analytics</i> — material icon named "analytics" (outlined). + static const IconData analytics_outlined = IconData(0xee74, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">anchor</i> — material icon named "anchor". + static const IconData anchor = IconData(0xe084, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">anchor</i> — material icon named "anchor" (sharp). + static const IconData anchor_sharp = IconData(0xe783, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">anchor</i> — material icon named "anchor" (round). + static const IconData anchor_rounded = IconData(0xf562, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">anchor</i> — material icon named "anchor" (outlined). + static const IconData anchor_outlined = IconData(0xee75, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">android</i> — material icon named "android". + static const IconData android = IconData(0xe085, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">android</i> — material icon named "android" (sharp). + static const IconData android_sharp = IconData(0xe784, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">android</i> — material icon named "android" (round). + static const IconData android_rounded = IconData(0xf563, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">android</i> — material icon named "android" (outlined). + static const IconData android_outlined = IconData(0xee76, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">animation</i> — material icon named "animation". + static const IconData animation = IconData(0xe086, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">animation</i> — material icon named "animation" (sharp). + static const IconData animation_sharp = IconData(0xe785, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">animation</i> — material icon named "animation" (round). + static const IconData animation_rounded = IconData(0xf564, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">animation</i> — material icon named "animation" (outlined). + static const IconData animation_outlined = IconData(0xee77, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">announcement</i> — material icon named "announcement". + static const IconData announcement = IconData(0xe087, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">announcement</i> — material icon named "announcement" (sharp). + static const IconData announcement_sharp = IconData(0xe786, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">announcement</i> — material icon named "announcement" (round). + static const IconData announcement_rounded = IconData(0xf565, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">announcement</i> — material icon named "announcement" (outlined). + static const IconData announcement_outlined = IconData(0xee78, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">aod</i> — material icon named "aod". + static const IconData aod = IconData(0xe088, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">aod</i> — material icon named "aod" (sharp). + static const IconData aod_sharp = IconData(0xe787, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">aod</i> — material icon named "aod" (round). + static const IconData aod_rounded = IconData(0xf566, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">aod</i> — material icon named "aod" (outlined). + static const IconData aod_outlined = IconData(0xee79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">apartment</i> — material icon named "apartment". + static const IconData apartment = IconData(0xe089, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">apartment</i> — material icon named "apartment" (sharp). + static const IconData apartment_sharp = IconData(0xe788, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">apartment</i> — material icon named "apartment" (round). + static const IconData apartment_rounded = IconData(0xf567, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">apartment</i> — material icon named "apartment" (outlined). + static const IconData apartment_outlined = IconData(0xee7a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">api</i> — material icon named "api". + static const IconData api = IconData(0xe08a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">api</i> — material icon named "api" (sharp). + static const IconData api_sharp = IconData(0xe789, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">api</i> — material icon named "api" (round). + static const IconData api_rounded = IconData(0xf568, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">api</i> — material icon named "api" (outlined). + static const IconData api_outlined = IconData(0xee7b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">app_blocking</i> — material icon named "app blocking". + static const IconData app_blocking = IconData(0xe08b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">app_blocking</i> — material icon named "app blocking" (sharp). + static const IconData app_blocking_sharp = IconData(0xe78a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">app_blocking</i> — material icon named "app blocking" (round). + static const IconData app_blocking_rounded = IconData(0xf569, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">app_blocking</i> — material icon named "app blocking" (outlined). + static const IconData app_blocking_outlined = IconData(0xee7c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">app_registration</i> — material icon named "app registration". + static const IconData app_registration = IconData(0xe08c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">app_registration</i> — material icon named "app registration" (sharp). + static const IconData app_registration_sharp = IconData(0xe78b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">app_registration</i> — material icon named "app registration" (round). + static const IconData app_registration_rounded = IconData(0xf56a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">app_registration</i> — material icon named "app registration" (outlined). + static const IconData app_registration_outlined = IconData(0xee7d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">app_settings_alt</i> — material icon named "app settings alt". + static const IconData app_settings_alt = IconData(0xe08d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">app_settings_alt</i> — material icon named "app settings alt" (sharp). + static const IconData app_settings_alt_sharp = IconData(0xe78c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">app_settings_alt</i> — material icon named "app settings alt" (round). + static const IconData app_settings_alt_rounded = IconData(0xf56b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">app_settings_alt</i> — material icon named "app settings alt" (outlined). + static const IconData app_settings_alt_outlined = IconData(0xee7e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">app_shortcut</i> — material icon named "app shortcut". + static const IconData app_shortcut = IconData(0xf04bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">app_shortcut</i> — material icon named "app shortcut" (sharp). + static const IconData app_shortcut_sharp = IconData(0xf03ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">app_shortcut</i> — material icon named "app shortcut" (round). + static const IconData app_shortcut_rounded = IconData(0xf02d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">app_shortcut</i> — material icon named "app shortcut" (outlined). + static const IconData app_shortcut_outlined = IconData(0xf05b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">apple</i> — material icon named "apple". + static const IconData apple = IconData(0xf04be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">apple</i> — material icon named "apple" (sharp). + static const IconData apple_sharp = IconData(0xf03cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">apple</i> — material icon named "apple" (round). + static const IconData apple_rounded = IconData(0xf02d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">apple</i> — material icon named "apple" (outlined). + static const IconData apple_outlined = IconData(0xf05b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">approval</i> — material icon named "approval". + static const IconData approval = IconData(0xe08e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">approval</i> — material icon named "approval" (sharp). + static const IconData approval_sharp = IconData(0xe78d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">approval</i> — material icon named "approval" (round). + static const IconData approval_rounded = IconData(0xf56c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">approval</i> — material icon named "approval" (outlined). + static const IconData approval_outlined = IconData(0xee7f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">apps</i> — material icon named "apps". + static const IconData apps = IconData(0xe08f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">apps</i> — material icon named "apps" (sharp). + static const IconData apps_sharp = IconData(0xe78e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">apps</i> — material icon named "apps" (round). + static const IconData apps_rounded = IconData(0xf56d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">apps</i> — material icon named "apps" (outlined). + static const IconData apps_outlined = IconData(0xee80, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">apps_outage</i> — material icon named "apps outage". + static const IconData apps_outage = IconData(0xf04bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">apps_outage</i> — material icon named "apps outage" (sharp). + static const IconData apps_outage_sharp = IconData(0xf03cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">apps_outage</i> — material icon named "apps outage" (round). + static const IconData apps_outage_rounded = IconData(0xf02d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">apps_outage</i> — material icon named "apps outage" (outlined). + static const IconData apps_outage_outlined = IconData(0xf05ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">architecture</i> — material icon named "architecture". + static const IconData architecture = IconData(0xe090, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">architecture</i> — material icon named "architecture" (sharp). + static const IconData architecture_sharp = IconData(0xe78f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">architecture</i> — material icon named "architecture" (round). + static const IconData architecture_rounded = IconData(0xf56e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">architecture</i> — material icon named "architecture" (outlined). + static const IconData architecture_outlined = IconData(0xee81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">archive</i> — material icon named "archive". + static const IconData archive = IconData(0xe091, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">archive</i> — material icon named "archive" (sharp). + static const IconData archive_sharp = IconData(0xe790, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">archive</i> — material icon named "archive" (round). + static const IconData archive_rounded = IconData(0xf56f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">archive</i> — material icon named "archive" (outlined). + static const IconData archive_outlined = IconData(0xee82, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">area_chart</i> — material icon named "area chart". + static const IconData area_chart = IconData(0xf04c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">area_chart</i> — material icon named "area chart" (sharp). + static const IconData area_chart_sharp = IconData(0xf03cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">area_chart</i> — material icon named "area chart" (round). + static const IconData area_chart_rounded = IconData(0xf02da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">area_chart</i> — material icon named "area chart" (outlined). + static const IconData area_chart_outlined = IconData(0xf05bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_back</i> — material icon named "arrow back". + static const IconData arrow_back = IconData( + 0xe092, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_back</i> — material icon named "arrow back" (sharp). + static const IconData arrow_back_sharp = IconData( + 0xe793, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_back</i> — material icon named "arrow back" (round). + static const IconData arrow_back_rounded = IconData( + 0xf572, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_back</i> — material icon named "arrow back" (outlined). + static const IconData arrow_back_outlined = IconData( + 0xee85, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_back_ios</i> — material icon named "arrow back ios". + static const IconData arrow_back_ios = IconData( + 0xe093, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_back_ios</i> — material icon named "arrow back ios" (sharp). + static const IconData arrow_back_ios_sharp = IconData( + 0xe792, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_back_ios</i> — material icon named "arrow back ios" (round). + static const IconData arrow_back_ios_rounded = IconData( + 0xf571, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_back_ios</i> — material icon named "arrow back ios" (outlined). + static const IconData arrow_back_ios_outlined = IconData( + 0xee84, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_back_ios_new</i> — material icon named "arrow back ios new". + static const IconData arrow_back_ios_new = IconData( + 0xe094, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_back_ios_new</i> — material icon named "arrow back ios new" (sharp). + static const IconData arrow_back_ios_new_sharp = IconData( + 0xe791, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_back_ios_new</i> — material icon named "arrow back ios new" (round). + static const IconData arrow_back_ios_new_rounded = IconData( + 0xf570, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_back_ios_new</i> — material icon named "arrow back ios new" (outlined). + static const IconData arrow_back_ios_new_outlined = IconData( + 0xee83, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_circle_down</i> — material icon named "arrow circle down". + static const IconData arrow_circle_down = IconData(0xe095, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_circle_down</i> — material icon named "arrow circle down" (sharp). + static const IconData arrow_circle_down_sharp = IconData(0xe794, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_circle_down</i> — material icon named "arrow circle down" (round). + static const IconData arrow_circle_down_rounded = IconData(0xf573, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_circle_down</i> — material icon named "arrow circle down" (outlined). + static const IconData arrow_circle_down_outlined = IconData(0xee86, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_circle_left</i> — material icon named "arrow circle left". + static const IconData arrow_circle_left = IconData(0xf04c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_circle_left</i> — material icon named "arrow circle left" (sharp). + static const IconData arrow_circle_left_sharp = IconData(0xf03ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_circle_left</i> — material icon named "arrow circle left" (round). + static const IconData arrow_circle_left_rounded = IconData(0xf02db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_circle_left</i> — material icon named "arrow circle left" (outlined). + static const IconData arrow_circle_left_outlined = IconData(0xf05bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_circle_right</i> — material icon named "arrow circle right". + static const IconData arrow_circle_right = IconData(0xf04c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_circle_right</i> — material icon named "arrow circle right" (sharp). + static const IconData arrow_circle_right_sharp = IconData(0xf03cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_circle_right</i> — material icon named "arrow circle right" (round). + static const IconData arrow_circle_right_rounded = IconData(0xf02dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_circle_right</i> — material icon named "arrow circle right" (outlined). + static const IconData arrow_circle_right_outlined = IconData( + 0xf05bd, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">arrow_circle_up</i> — material icon named "arrow circle up". + static const IconData arrow_circle_up = IconData(0xe096, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_circle_up</i> — material icon named "arrow circle up" (sharp). + static const IconData arrow_circle_up_sharp = IconData(0xe795, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_circle_up</i> — material icon named "arrow circle up" (round). + static const IconData arrow_circle_up_rounded = IconData(0xf574, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_circle_up</i> — material icon named "arrow circle up" (outlined). + static const IconData arrow_circle_up_outlined = IconData(0xee87, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_downward</i> — material icon named "arrow downward". + static const IconData arrow_downward = IconData(0xe097, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_downward</i> — material icon named "arrow downward" (sharp). + static const IconData arrow_downward_sharp = IconData(0xe796, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_downward</i> — material icon named "arrow downward" (round). + static const IconData arrow_downward_rounded = IconData(0xf575, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_downward</i> — material icon named "arrow downward" (outlined). + static const IconData arrow_downward_outlined = IconData(0xee88, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_drop_down</i> — material icon named "arrow drop down". + static const IconData arrow_drop_down = IconData(0xe098, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_drop_down</i> — material icon named "arrow drop down" (sharp). + static const IconData arrow_drop_down_sharp = IconData(0xe798, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_drop_down</i> — material icon named "arrow drop down" (round). + static const IconData arrow_drop_down_rounded = IconData(0xf577, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_drop_down</i> — material icon named "arrow drop down" (outlined). + static const IconData arrow_drop_down_outlined = IconData(0xee8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_drop_down_circle</i> — material icon named "arrow drop down circle". + static const IconData arrow_drop_down_circle = IconData(0xe099, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_drop_down_circle</i> — material icon named "arrow drop down circle" (sharp). + static const IconData arrow_drop_down_circle_sharp = IconData( + 0xe797, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">arrow_drop_down_circle</i> — material icon named "arrow drop down circle" (round). + static const IconData arrow_drop_down_circle_rounded = IconData( + 0xf576, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">arrow_drop_down_circle</i> — material icon named "arrow drop down circle" (outlined). + static const IconData arrow_drop_down_circle_outlined = IconData( + 0xee89, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">arrow_drop_up</i> — material icon named "arrow drop up". + static const IconData arrow_drop_up = IconData(0xe09a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_drop_up</i> — material icon named "arrow drop up" (sharp). + static const IconData arrow_drop_up_sharp = IconData(0xe799, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_drop_up</i> — material icon named "arrow drop up" (round). + static const IconData arrow_drop_up_rounded = IconData(0xf578, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_drop_up</i> — material icon named "arrow drop up" (outlined). + static const IconData arrow_drop_up_outlined = IconData(0xee8b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_forward</i> — material icon named "arrow forward". + static const IconData arrow_forward = IconData( + 0xe09b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_forward</i> — material icon named "arrow forward" (sharp). + static const IconData arrow_forward_sharp = IconData( + 0xe79b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_forward</i> — material icon named "arrow forward" (round). + static const IconData arrow_forward_rounded = IconData( + 0xf57a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_forward</i> — material icon named "arrow forward" (outlined). + static const IconData arrow_forward_outlined = IconData( + 0xee8d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_forward_ios</i> — material icon named "arrow forward ios". + static const IconData arrow_forward_ios = IconData( + 0xe09c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_forward_ios</i> — material icon named "arrow forward ios" (sharp). + static const IconData arrow_forward_ios_sharp = IconData( + 0xe79a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_forward_ios</i> — material icon named "arrow forward ios" (round). + static const IconData arrow_forward_ios_rounded = IconData( + 0xf579, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_forward_ios</i> — material icon named "arrow forward ios" (outlined). + static const IconData arrow_forward_ios_outlined = IconData( + 0xee8c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_left</i> — material icon named "arrow left". + static const IconData arrow_left = IconData( + 0xe09d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_left</i> — material icon named "arrow left" (sharp). + static const IconData arrow_left_sharp = IconData( + 0xe79c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_left</i> — material icon named "arrow left" (round). + static const IconData arrow_left_rounded = IconData( + 0xf57b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_left</i> — material icon named "arrow left" (outlined). + static const IconData arrow_left_outlined = IconData( + 0xee8e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_outward</i> — material icon named "arrow outward". + static const IconData arrow_outward = IconData(0xf0852, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_outward</i> — material icon named "arrow outward" (sharp). + static const IconData arrow_outward_sharp = IconData(0xf0834, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_outward</i> — material icon named "arrow outward" (round). + static const IconData arrow_outward_rounded = IconData(0xf087d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_outward</i> — material icon named "arrow outward" (outlined). + static const IconData arrow_outward_outlined = IconData(0xf089b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">arrow_right</i> — material icon named "arrow right". + static const IconData arrow_right = IconData( + 0xe09e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_right</i> — material icon named "arrow right" (sharp). + static const IconData arrow_right_sharp = IconData( + 0xe79e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_right</i> — material icon named "arrow right" (round). + static const IconData arrow_right_rounded = IconData( + 0xf57d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_right</i> — material icon named "arrow right" (outlined). + static const IconData arrow_right_outlined = IconData( + 0xee90, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_right_alt</i> — material icon named "arrow right alt". + static const IconData arrow_right_alt = IconData( + 0xe09f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">arrow_right_alt</i> — material icon named "arrow right alt" (sharp). + static const IconData arrow_right_alt_sharp = IconData( + 0xe79d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">arrow_right_alt</i> — material icon named "arrow right alt" (round). + static const IconData arrow_right_alt_rounded = IconData( + 0xf57c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">arrow_right_alt</i> — material icon named "arrow right alt" (outlined). + static const IconData arrow_right_alt_outlined = IconData( + 0xee8f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">arrow_upward</i> — material icon named "arrow upward". + static const IconData arrow_upward = IconData(0xe0a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">arrow_upward</i> — material icon named "arrow upward" (sharp). + static const IconData arrow_upward_sharp = IconData(0xe79f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">arrow_upward</i> — material icon named "arrow upward" (round). + static const IconData arrow_upward_rounded = IconData(0xf57e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">arrow_upward</i> — material icon named "arrow upward" (outlined). + static const IconData arrow_upward_outlined = IconData(0xee91, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">art_track</i> — material icon named "art track". + static const IconData art_track = IconData(0xe0a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">art_track</i> — material icon named "art track" (sharp). + static const IconData art_track_sharp = IconData(0xe7a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">art_track</i> — material icon named "art track" (round). + static const IconData art_track_rounded = IconData(0xf57f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">art_track</i> — material icon named "art track" (outlined). + static const IconData art_track_outlined = IconData(0xee92, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">article</i> — material icon named "article". + static const IconData article = IconData(0xe0a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">article</i> — material icon named "article" (sharp). + static const IconData article_sharp = IconData(0xe7a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">article</i> — material icon named "article" (round). + static const IconData article_rounded = IconData(0xf580, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">article</i> — material icon named "article" (outlined). + static const IconData article_outlined = IconData(0xee93, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">aspect_ratio</i> — material icon named "aspect ratio". + static const IconData aspect_ratio = IconData(0xe0a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">aspect_ratio</i> — material icon named "aspect ratio" (sharp). + static const IconData aspect_ratio_sharp = IconData(0xe7a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">aspect_ratio</i> — material icon named "aspect ratio" (round). + static const IconData aspect_ratio_rounded = IconData(0xf581, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">aspect_ratio</i> — material icon named "aspect ratio" (outlined). + static const IconData aspect_ratio_outlined = IconData(0xee94, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assessment</i> — material icon named "assessment". + static const IconData assessment = IconData(0xe0a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assessment</i> — material icon named "assessment" (sharp). + static const IconData assessment_sharp = IconData(0xe7a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assessment</i> — material icon named "assessment" (round). + static const IconData assessment_rounded = IconData(0xf582, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assessment</i> — material icon named "assessment" (outlined). + static const IconData assessment_outlined = IconData(0xee95, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assignment</i> — material icon named "assignment". + static const IconData assignment = IconData( + 0xe0a5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">assignment</i> — material icon named "assignment" (sharp). + static const IconData assignment_sharp = IconData( + 0xe7a8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">assignment</i> — material icon named "assignment" (round). + static const IconData assignment_rounded = IconData( + 0xf587, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">assignment</i> — material icon named "assignment" (outlined). + static const IconData assignment_outlined = IconData( + 0xee98, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">assignment_add</i> — material icon named "assignment add". + static const IconData assignment_add = IconData(0xf0853, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assignment_ind</i> — material icon named "assignment ind". + static const IconData assignment_ind = IconData(0xe0a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assignment_ind</i> — material icon named "assignment ind" (sharp). + static const IconData assignment_ind_sharp = IconData(0xe7a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assignment_ind</i> — material icon named "assignment ind" (round). + static const IconData assignment_ind_rounded = IconData(0xf583, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assignment_ind</i> — material icon named "assignment ind" (outlined). + static const IconData assignment_ind_outlined = IconData(0xee96, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assignment_late</i> — material icon named "assignment late". + static const IconData assignment_late = IconData(0xe0a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assignment_late</i> — material icon named "assignment late" (sharp). + static const IconData assignment_late_sharp = IconData(0xe7a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assignment_late</i> — material icon named "assignment late" (round). + static const IconData assignment_late_rounded = IconData(0xf584, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assignment_late</i> — material icon named "assignment late" (outlined). + static const IconData assignment_late_outlined = IconData(0xee97, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assignment_return</i> — material icon named "assignment return". + static const IconData assignment_return = IconData( + 0xe0a8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">assignment_return</i> — material icon named "assignment return" (sharp). + static const IconData assignment_return_sharp = IconData( + 0xe7a6, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">assignment_return</i> — material icon named "assignment return" (round). + static const IconData assignment_return_rounded = IconData( + 0xf585, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">assignment_return</i> — material icon named "assignment return" (outlined). + static const IconData assignment_return_outlined = IconData( + 0xee99, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">assignment_returned</i> — material icon named "assignment returned". + static const IconData assignment_returned = IconData(0xe0a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assignment_returned</i> — material icon named "assignment returned" (sharp). + static const IconData assignment_returned_sharp = IconData(0xe7a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assignment_returned</i> — material icon named "assignment returned" (round). + static const IconData assignment_returned_rounded = IconData(0xf586, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assignment_returned</i> — material icon named "assignment returned" (outlined). + static const IconData assignment_returned_outlined = IconData( + 0xee9a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">assignment_turned_in</i> — material icon named "assignment turned in". + static const IconData assignment_turned_in = IconData(0xe0aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assignment_turned_in</i> — material icon named "assignment turned in" (sharp). + static const IconData assignment_turned_in_sharp = IconData(0xe7a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assignment_turned_in</i> — material icon named "assignment turned in" (round). + static const IconData assignment_turned_in_rounded = IconData( + 0xf588, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">assignment_turned_in</i> — material icon named "assignment turned in" (outlined). + static const IconData assignment_turned_in_outlined = IconData( + 0xee9b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">assist_walker</i> — material icon named "assist walker". + static const IconData assist_walker = IconData(0xf0854, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assist_walker</i> — material icon named "assist walker" (sharp). + static const IconData assist_walker_sharp = IconData(0xf0835, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assist_walker</i> — material icon named "assist walker" (round). + static const IconData assist_walker_rounded = IconData(0xf087e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assist_walker</i> — material icon named "assist walker" (outlined). + static const IconData assist_walker_outlined = IconData(0xf089c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assistant</i> — material icon named "assistant". + static const IconData assistant = IconData(0xe0ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assistant</i> — material icon named "assistant" (sharp). + static const IconData assistant_sharp = IconData(0xe7ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assistant</i> — material icon named "assistant" (round). + static const IconData assistant_rounded = IconData(0xf58b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assistant</i> — material icon named "assistant" (outlined). + static const IconData assistant_outlined = IconData(0xee9d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assistant_direction</i> — material icon named "assistant direction". + static const IconData assistant_direction = IconData(0xe0ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assistant_direction</i> — material icon named "assistant direction" (sharp). + static const IconData assistant_direction_sharp = IconData(0xe7aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assistant_direction</i> — material icon named "assistant direction" (round). + static const IconData assistant_direction_rounded = IconData(0xf589, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assistant_direction</i> — material icon named "assistant direction" (outlined). + static const IconData assistant_direction_outlined = IconData( + 0xee9c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">assistant_navigation</i> — material icon named "assistant navigation". + static const IconData assistant_navigation = IconData(0xe0ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assistant_photo</i> — material icon named "assistant photo". + static const IconData assistant_photo = IconData(0xe0ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assistant_photo</i> — material icon named "assistant photo" (sharp). + static const IconData assistant_photo_sharp = IconData(0xe7ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assistant_photo</i> — material icon named "assistant photo" (round). + static const IconData assistant_photo_rounded = IconData(0xf58a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assistant_photo</i> — material icon named "assistant photo" (outlined). + static const IconData assistant_photo_outlined = IconData(0xee9e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">assured_workload</i> — material icon named "assured workload". + static const IconData assured_workload = IconData(0xf04c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">assured_workload</i> — material icon named "assured workload" (sharp). + static const IconData assured_workload_sharp = IconData(0xf03d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">assured_workload</i> — material icon named "assured workload" (round). + static const IconData assured_workload_rounded = IconData(0xf02dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">assured_workload</i> — material icon named "assured workload" (outlined). + static const IconData assured_workload_outlined = IconData(0xf05be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">atm</i> — material icon named "atm". + static const IconData atm = IconData(0xe0af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">atm</i> — material icon named "atm" (sharp). + static const IconData atm_sharp = IconData(0xe7ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">atm</i> — material icon named "atm" (round). + static const IconData atm_rounded = IconData(0xf58c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">atm</i> — material icon named "atm" (outlined). + static const IconData atm_outlined = IconData(0xee9f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">attach_email</i> — material icon named "attach email". + static const IconData attach_email = IconData(0xe0b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">attach_email</i> — material icon named "attach email" (sharp). + static const IconData attach_email_sharp = IconData(0xe7ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">attach_email</i> — material icon named "attach email" (round). + static const IconData attach_email_rounded = IconData(0xf58d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">attach_email</i> — material icon named "attach email" (outlined). + static const IconData attach_email_outlined = IconData(0xeea0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">attach_file</i> — material icon named "attach file". + static const IconData attach_file = IconData(0xe0b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">attach_file</i> — material icon named "attach file" (sharp). + static const IconData attach_file_sharp = IconData(0xe7af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">attach_file</i> — material icon named "attach file" (round). + static const IconData attach_file_rounded = IconData(0xf58e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">attach_file</i> — material icon named "attach file" (outlined). + static const IconData attach_file_outlined = IconData(0xeea1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">attach_money</i> — material icon named "attach money". + static const IconData attach_money = IconData(0xe0b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">attach_money</i> — material icon named "attach money" (sharp). + static const IconData attach_money_sharp = IconData(0xe7b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">attach_money</i> — material icon named "attach money" (round). + static const IconData attach_money_rounded = IconData(0xf58f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">attach_money</i> — material icon named "attach money" (outlined). + static const IconData attach_money_outlined = IconData(0xeea2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">attachment</i> — material icon named "attachment". + static const IconData attachment = IconData(0xe0b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">attachment</i> — material icon named "attachment" (sharp). + static const IconData attachment_sharp = IconData(0xe7b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">attachment</i> — material icon named "attachment" (round). + static const IconData attachment_rounded = IconData(0xf590, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">attachment</i> — material icon named "attachment" (outlined). + static const IconData attachment_outlined = IconData(0xeea3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">attractions</i> — material icon named "attractions". + static const IconData attractions = IconData(0xe0b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">attractions</i> — material icon named "attractions" (sharp). + static const IconData attractions_sharp = IconData(0xe7b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">attractions</i> — material icon named "attractions" (round). + static const IconData attractions_rounded = IconData(0xf591, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">attractions</i> — material icon named "attractions" (outlined). + static const IconData attractions_outlined = IconData(0xeea4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">attribution</i> — material icon named "attribution". + static const IconData attribution = IconData(0xe0b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">attribution</i> — material icon named "attribution" (sharp). + static const IconData attribution_sharp = IconData(0xe7b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">attribution</i> — material icon named "attribution" (round). + static const IconData attribution_rounded = IconData(0xf592, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">attribution</i> — material icon named "attribution" (outlined). + static const IconData attribution_outlined = IconData(0xeea5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">audio_file</i> — material icon named "audio file". + static const IconData audio_file = IconData(0xf04c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">audio_file</i> — material icon named "audio file" (sharp). + static const IconData audio_file_sharp = IconData(0xf03d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">audio_file</i> — material icon named "audio file" (round). + static const IconData audio_file_rounded = IconData(0xf02de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">audio_file</i> — material icon named "audio file" (outlined). + static const IconData audio_file_outlined = IconData(0xf05bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">audiotrack</i> — material icon named "audiotrack". + static const IconData audiotrack = IconData(0xe0b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">audiotrack</i> — material icon named "audiotrack" (sharp). + static const IconData audiotrack_sharp = IconData(0xe7b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">audiotrack</i> — material icon named "audiotrack" (round). + static const IconData audiotrack_rounded = IconData(0xf593, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">audiotrack</i> — material icon named "audiotrack" (outlined). + static const IconData audiotrack_outlined = IconData(0xeea6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_awesome</i> — material icon named "auto awesome". + static const IconData auto_awesome = IconData(0xe0b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_awesome</i> — material icon named "auto awesome" (sharp). + static const IconData auto_awesome_sharp = IconData(0xe7b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_awesome</i> — material icon named "auto awesome" (round). + static const IconData auto_awesome_rounded = IconData(0xf596, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_awesome</i> — material icon named "auto awesome" (outlined). + static const IconData auto_awesome_outlined = IconData(0xeea9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_awesome_mosaic</i> — material icon named "auto awesome mosaic". + static const IconData auto_awesome_mosaic = IconData(0xe0b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_awesome_mosaic</i> — material icon named "auto awesome mosaic" (sharp). + static const IconData auto_awesome_mosaic_sharp = IconData(0xe7b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_awesome_mosaic</i> — material icon named "auto awesome mosaic" (round). + static const IconData auto_awesome_mosaic_rounded = IconData(0xf594, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_awesome_mosaic</i> — material icon named "auto awesome mosaic" (outlined). + static const IconData auto_awesome_mosaic_outlined = IconData( + 0xeea7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">auto_awesome_motion</i> — material icon named "auto awesome motion". + static const IconData auto_awesome_motion = IconData(0xe0b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_awesome_motion</i> — material icon named "auto awesome motion" (sharp). + static const IconData auto_awesome_motion_sharp = IconData(0xe7b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_awesome_motion</i> — material icon named "auto awesome motion" (round). + static const IconData auto_awesome_motion_rounded = IconData(0xf595, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_awesome_motion</i> — material icon named "auto awesome motion" (outlined). + static const IconData auto_awesome_motion_outlined = IconData( + 0xeea8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">auto_delete</i> — material icon named "auto delete". + static const IconData auto_delete = IconData(0xe0ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_delete</i> — material icon named "auto delete" (sharp). + static const IconData auto_delete_sharp = IconData(0xe7b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_delete</i> — material icon named "auto delete" (round). + static const IconData auto_delete_rounded = IconData(0xf597, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_delete</i> — material icon named "auto delete" (outlined). + static const IconData auto_delete_outlined = IconData(0xeeaa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_fix_high</i> — material icon named "auto fix high". + static const IconData auto_fix_high = IconData(0xe0bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_fix_high</i> — material icon named "auto fix high" (sharp). + static const IconData auto_fix_high_sharp = IconData(0xe7b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_fix_high</i> — material icon named "auto fix high" (round). + static const IconData auto_fix_high_rounded = IconData(0xf598, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_fix_high</i> — material icon named "auto fix high" (outlined). + static const IconData auto_fix_high_outlined = IconData(0xeeab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_fix_normal</i> — material icon named "auto fix normal". + static const IconData auto_fix_normal = IconData(0xe0bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_fix_normal</i> — material icon named "auto fix normal" (sharp). + static const IconData auto_fix_normal_sharp = IconData(0xe7ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_fix_normal</i> — material icon named "auto fix normal" (round). + static const IconData auto_fix_normal_rounded = IconData(0xf599, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_fix_normal</i> — material icon named "auto fix normal" (outlined). + static const IconData auto_fix_normal_outlined = IconData(0xeeac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_fix_off</i> — material icon named "auto fix off". + static const IconData auto_fix_off = IconData(0xe0bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_fix_off</i> — material icon named "auto fix off" (sharp). + static const IconData auto_fix_off_sharp = IconData(0xe7bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_fix_off</i> — material icon named "auto fix off" (round). + static const IconData auto_fix_off_rounded = IconData(0xf59a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_fix_off</i> — material icon named "auto fix off" (outlined). + static const IconData auto_fix_off_outlined = IconData(0xeead, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_graph</i> — material icon named "auto graph". + static const IconData auto_graph = IconData(0xe0be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_graph</i> — material icon named "auto graph" (sharp). + static const IconData auto_graph_sharp = IconData(0xe7bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_graph</i> — material icon named "auto graph" (round). + static const IconData auto_graph_rounded = IconData(0xf59b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_graph</i> — material icon named "auto graph" (outlined). + static const IconData auto_graph_outlined = IconData(0xeeae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_mode</i> — material icon named "auto mode". + static const IconData auto_mode = IconData(0xf0787, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_mode</i> — material icon named "auto mode" (sharp). + static const IconData auto_mode_sharp = IconData(0xf072f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_mode</i> — material icon named "auto mode" (round). + static const IconData auto_mode_rounded = IconData(0xf07df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_mode</i> — material icon named "auto mode" (outlined). + static const IconData auto_mode_outlined = IconData(0xf06d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">auto_stories</i> — material icon named "auto stories". + static const IconData auto_stories = IconData(0xe0bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">auto_stories</i> — material icon named "auto stories" (sharp). + static const IconData auto_stories_sharp = IconData(0xe7bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">auto_stories</i> — material icon named "auto stories" (round). + static const IconData auto_stories_rounded = IconData(0xf59c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">auto_stories</i> — material icon named "auto stories" (outlined). + static const IconData auto_stories_outlined = IconData(0xeeaf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">autofps_select</i> — material icon named "autofps select". + static const IconData autofps_select = IconData(0xe0c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">autofps_select</i> — material icon named "autofps select" (sharp). + static const IconData autofps_select_sharp = IconData(0xe7be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">autofps_select</i> — material icon named "autofps select" (round). + static const IconData autofps_select_rounded = IconData(0xf59d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">autofps_select</i> — material icon named "autofps select" (outlined). + static const IconData autofps_select_outlined = IconData(0xeeb0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">autorenew</i> — material icon named "autorenew". + static const IconData autorenew = IconData(0xe0c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">autorenew</i> — material icon named "autorenew" (sharp). + static const IconData autorenew_sharp = IconData(0xe7bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">autorenew</i> — material icon named "autorenew" (round). + static const IconData autorenew_rounded = IconData(0xf59e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">autorenew</i> — material icon named "autorenew" (outlined). + static const IconData autorenew_outlined = IconData(0xeeb1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">av_timer</i> — material icon named "av timer". + static const IconData av_timer = IconData(0xe0c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">av_timer</i> — material icon named "av timer" (sharp). + static const IconData av_timer_sharp = IconData(0xe7c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">av_timer</i> — material icon named "av timer" (round). + static const IconData av_timer_rounded = IconData(0xf59f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">av_timer</i> — material icon named "av timer" (outlined). + static const IconData av_timer_outlined = IconData(0xeeb2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">baby_changing_station</i> — material icon named "baby changing station". + static const IconData baby_changing_station = IconData(0xe0c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">baby_changing_station</i> — material icon named "baby changing station" (sharp). + static const IconData baby_changing_station_sharp = IconData(0xe7c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">baby_changing_station</i> — material icon named "baby changing station" (round). + static const IconData baby_changing_station_rounded = IconData( + 0xf5a0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">baby_changing_station</i> — material icon named "baby changing station" (outlined). + static const IconData baby_changing_station_outlined = IconData( + 0xeeb3, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">back_hand</i> — material icon named "back hand". + static const IconData back_hand = IconData(0xf04c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">back_hand</i> — material icon named "back hand" (sharp). + static const IconData back_hand_sharp = IconData(0xf03d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">back_hand</i> — material icon named "back hand" (round). + static const IconData back_hand_rounded = IconData(0xf02df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">back_hand</i> — material icon named "back hand" (outlined). + static const IconData back_hand_outlined = IconData(0xf05c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">backpack</i> — material icon named "backpack". + static const IconData backpack = IconData(0xe0c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">backpack</i> — material icon named "backpack" (sharp). + static const IconData backpack_sharp = IconData(0xe7c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">backpack</i> — material icon named "backpack" (round). + static const IconData backpack_rounded = IconData(0xf5a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">backpack</i> — material icon named "backpack" (outlined). + static const IconData backpack_outlined = IconData(0xeeb4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">backspace</i> — material icon named "backspace". + static const IconData backspace = IconData( + 0xe0c5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">backspace</i> — material icon named "backspace" (sharp). + static const IconData backspace_sharp = IconData( + 0xe7c3, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">backspace</i> — material icon named "backspace" (round). + static const IconData backspace_rounded = IconData( + 0xf5a2, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">backspace</i> — material icon named "backspace" (outlined). + static const IconData backspace_outlined = IconData( + 0xeeb5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">backup</i> — material icon named "backup". + static const IconData backup = IconData(0xe0c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">backup</i> — material icon named "backup" (sharp). + static const IconData backup_sharp = IconData(0xe7c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">backup</i> — material icon named "backup" (round). + static const IconData backup_rounded = IconData(0xf5a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">backup</i> — material icon named "backup" (outlined). + static const IconData backup_outlined = IconData(0xeeb6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">backup_table</i> — material icon named "backup table". + static const IconData backup_table = IconData(0xe0c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">backup_table</i> — material icon named "backup table" (sharp). + static const IconData backup_table_sharp = IconData(0xe7c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">backup_table</i> — material icon named "backup table" (round). + static const IconData backup_table_rounded = IconData(0xf5a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">backup_table</i> — material icon named "backup table" (outlined). + static const IconData backup_table_outlined = IconData(0xeeb7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">badge</i> — material icon named "badge". + static const IconData badge = IconData(0xe0c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">badge</i> — material icon named "badge" (sharp). + static const IconData badge_sharp = IconData(0xe7c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">badge</i> — material icon named "badge" (round). + static const IconData badge_rounded = IconData(0xf5a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">badge</i> — material icon named "badge" (outlined). + static const IconData badge_outlined = IconData(0xeeb8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bakery_dining</i> — material icon named "bakery dining". + static const IconData bakery_dining = IconData(0xe0c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bakery_dining</i> — material icon named "bakery dining" (sharp). + static const IconData bakery_dining_sharp = IconData(0xe7c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bakery_dining</i> — material icon named "bakery dining" (round). + static const IconData bakery_dining_rounded = IconData(0xf5a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bakery_dining</i> — material icon named "bakery dining" (outlined). + static const IconData bakery_dining_outlined = IconData(0xeeb9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">balance</i> — material icon named "balance". + static const IconData balance = IconData(0xf04c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">balance</i> — material icon named "balance" (sharp). + static const IconData balance_sharp = IconData(0xf03d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">balance</i> — material icon named "balance" (round). + static const IconData balance_rounded = IconData(0xf02e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">balance</i> — material icon named "balance" (outlined). + static const IconData balance_outlined = IconData(0xf05c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">balcony</i> — material icon named "balcony". + static const IconData balcony = IconData(0xe0ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">balcony</i> — material icon named "balcony" (sharp). + static const IconData balcony_sharp = IconData(0xe7c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">balcony</i> — material icon named "balcony" (round). + static const IconData balcony_rounded = IconData(0xf5a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">balcony</i> — material icon named "balcony" (outlined). + static const IconData balcony_outlined = IconData(0xeeba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ballot</i> — material icon named "ballot". + static const IconData ballot = IconData(0xe0cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ballot</i> — material icon named "ballot" (sharp). + static const IconData ballot_sharp = IconData(0xe7c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ballot</i> — material icon named "ballot" (round). + static const IconData ballot_rounded = IconData(0xf5a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ballot</i> — material icon named "ballot" (outlined). + static const IconData ballot_outlined = IconData(0xeebb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bar_chart</i> — material icon named "bar chart". + static const IconData bar_chart = IconData(0xe0cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bar_chart</i> — material icon named "bar chart" (sharp). + static const IconData bar_chart_sharp = IconData(0xe7ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bar_chart</i> — material icon named "bar chart" (round). + static const IconData bar_chart_rounded = IconData(0xf5a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bar_chart</i> — material icon named "bar chart" (outlined). + static const IconData bar_chart_outlined = IconData(0xeebc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">barcode_reader</i> — material icon named "barcode reader". + static const IconData barcode_reader = IconData(0xf0855, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">batch_prediction</i> — material icon named "batch prediction". + static const IconData batch_prediction = IconData(0xe0cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">batch_prediction</i> — material icon named "batch prediction" (sharp). + static const IconData batch_prediction_sharp = IconData(0xe7cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">batch_prediction</i> — material icon named "batch prediction" (round). + static const IconData batch_prediction_rounded = IconData(0xf5aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">batch_prediction</i> — material icon named "batch prediction" (outlined). + static const IconData batch_prediction_outlined = IconData(0xeebd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bathroom</i> — material icon named "bathroom". + static const IconData bathroom = IconData(0xe0ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bathroom</i> — material icon named "bathroom" (sharp). + static const IconData bathroom_sharp = IconData(0xe7cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bathroom</i> — material icon named "bathroom" (round). + static const IconData bathroom_rounded = IconData(0xf5ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bathroom</i> — material icon named "bathroom" (outlined). + static const IconData bathroom_outlined = IconData(0xeebe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bathtub</i> — material icon named "bathtub". + static const IconData bathtub = IconData(0xe0cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bathtub</i> — material icon named "bathtub" (sharp). + static const IconData bathtub_sharp = IconData(0xe7cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bathtub</i> — material icon named "bathtub" (round). + static const IconData bathtub_rounded = IconData(0xf5ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bathtub</i> — material icon named "bathtub" (outlined). + static const IconData bathtub_outlined = IconData(0xeebf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_0_bar</i> — material icon named "battery 0 bar". + static const IconData battery_0_bar = IconData(0xf0788, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_0_bar</i> — material icon named "battery 0 bar" (sharp). + static const IconData battery_0_bar_sharp = IconData(0xf0730, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_0_bar</i> — material icon named "battery 0 bar" (round). + static const IconData battery_0_bar_rounded = IconData(0xf07e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_0_bar</i> — material icon named "battery 0 bar" (outlined). + static const IconData battery_0_bar_outlined = IconData(0xf06d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_1_bar</i> — material icon named "battery 1 bar". + static const IconData battery_1_bar = IconData(0xf0789, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_1_bar</i> — material icon named "battery 1 bar" (sharp). + static const IconData battery_1_bar_sharp = IconData(0xf0731, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_1_bar</i> — material icon named "battery 1 bar" (round). + static const IconData battery_1_bar_rounded = IconData(0xf07e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_1_bar</i> — material icon named "battery 1 bar" (outlined). + static const IconData battery_1_bar_outlined = IconData(0xf06d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_2_bar</i> — material icon named "battery 2 bar". + static const IconData battery_2_bar = IconData(0xf078a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_2_bar</i> — material icon named "battery 2 bar" (sharp). + static const IconData battery_2_bar_sharp = IconData(0xf0732, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_2_bar</i> — material icon named "battery 2 bar" (round). + static const IconData battery_2_bar_rounded = IconData(0xf07e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_2_bar</i> — material icon named "battery 2 bar" (outlined). + static const IconData battery_2_bar_outlined = IconData(0xf06da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_3_bar</i> — material icon named "battery 3 bar". + static const IconData battery_3_bar = IconData(0xf078b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_3_bar</i> — material icon named "battery 3 bar" (sharp). + static const IconData battery_3_bar_sharp = IconData(0xf0733, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_3_bar</i> — material icon named "battery 3 bar" (round). + static const IconData battery_3_bar_rounded = IconData(0xf07e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_3_bar</i> — material icon named "battery 3 bar" (outlined). + static const IconData battery_3_bar_outlined = IconData(0xf06db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_4_bar</i> — material icon named "battery 4 bar". + static const IconData battery_4_bar = IconData(0xf078c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_4_bar</i> — material icon named "battery 4 bar" (sharp). + static const IconData battery_4_bar_sharp = IconData(0xf0734, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_4_bar</i> — material icon named "battery 4 bar" (round). + static const IconData battery_4_bar_rounded = IconData(0xf07e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_4_bar</i> — material icon named "battery 4 bar" (outlined). + static const IconData battery_4_bar_outlined = IconData(0xf06dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_5_bar</i> — material icon named "battery 5 bar". + static const IconData battery_5_bar = IconData(0xf078d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_5_bar</i> — material icon named "battery 5 bar" (sharp). + static const IconData battery_5_bar_sharp = IconData(0xf0735, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_5_bar</i> — material icon named "battery 5 bar" (round). + static const IconData battery_5_bar_rounded = IconData(0xf07e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_5_bar</i> — material icon named "battery 5 bar" (outlined). + static const IconData battery_5_bar_outlined = IconData(0xf06dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_6_bar</i> — material icon named "battery 6 bar". + static const IconData battery_6_bar = IconData(0xf078e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_6_bar</i> — material icon named "battery 6 bar" (sharp). + static const IconData battery_6_bar_sharp = IconData(0xf0736, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_6_bar</i> — material icon named "battery 6 bar" (round). + static const IconData battery_6_bar_rounded = IconData(0xf07e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_6_bar</i> — material icon named "battery 6 bar" (outlined). + static const IconData battery_6_bar_outlined = IconData(0xf06de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_alert</i> — material icon named "battery alert". + static const IconData battery_alert = IconData(0xe0d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_alert</i> — material icon named "battery alert" (sharp). + static const IconData battery_alert_sharp = IconData(0xe7ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_alert</i> — material icon named "battery alert" (round). + static const IconData battery_alert_rounded = IconData(0xf5ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_alert</i> — material icon named "battery alert" (outlined). + static const IconData battery_alert_outlined = IconData(0xeec0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_charging_full</i> — material icon named "battery charging full". + static const IconData battery_charging_full = IconData(0xe0d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_charging_full</i> — material icon named "battery charging full" (sharp). + static const IconData battery_charging_full_sharp = IconData(0xe7cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_charging_full</i> — material icon named "battery charging full" (round). + static const IconData battery_charging_full_rounded = IconData( + 0xf5ae, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">battery_charging_full</i> — material icon named "battery charging full" (outlined). + static const IconData battery_charging_full_outlined = IconData( + 0xeec1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">battery_full</i> — material icon named "battery full". + static const IconData battery_full = IconData(0xe0d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_full</i> — material icon named "battery full" (sharp). + static const IconData battery_full_sharp = IconData(0xe7d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_full</i> — material icon named "battery full" (round). + static const IconData battery_full_rounded = IconData(0xf5af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_full</i> — material icon named "battery full" (outlined). + static const IconData battery_full_outlined = IconData(0xeec2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_saver</i> — material icon named "battery saver". + static const IconData battery_saver = IconData(0xe0d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_saver</i> — material icon named "battery saver" (sharp). + static const IconData battery_saver_sharp = IconData(0xe7d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_saver</i> — material icon named "battery saver" (round). + static const IconData battery_saver_rounded = IconData(0xf5b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_saver</i> — material icon named "battery saver" (outlined). + static const IconData battery_saver_outlined = IconData(0xeec3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_std</i> — material icon named "battery std". + static const IconData battery_std = IconData(0xe0d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">battery_std</i> — material icon named "battery std" (sharp). + static const IconData battery_std_sharp = IconData(0xe7d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">battery_std</i> — material icon named "battery std" (round). + static const IconData battery_std_rounded = IconData(0xf5b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">battery_std</i> — material icon named "battery std" (outlined). + static const IconData battery_std_outlined = IconData(0xeec4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">battery_unknown</i> — material icon named "battery unknown". + static const IconData battery_unknown = IconData( + 0xe0d5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">battery_unknown</i> — material icon named "battery unknown" (sharp). + static const IconData battery_unknown_sharp = IconData( + 0xe7d3, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">battery_unknown</i> — material icon named "battery unknown" (round). + static const IconData battery_unknown_rounded = IconData( + 0xf5b2, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">battery_unknown</i> — material icon named "battery unknown" (outlined). + static const IconData battery_unknown_outlined = IconData( + 0xeec5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">beach_access</i> — material icon named "beach access". + static const IconData beach_access = IconData(0xe0d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">beach_access</i> — material icon named "beach access" (sharp). + static const IconData beach_access_sharp = IconData(0xe7d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">beach_access</i> — material icon named "beach access" (round). + static const IconData beach_access_rounded = IconData(0xf5b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">beach_access</i> — material icon named "beach access" (outlined). + static const IconData beach_access_outlined = IconData(0xeec6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bed</i> — material icon named "bed". + static const IconData bed = IconData(0xe0d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bed</i> — material icon named "bed" (sharp). + static const IconData bed_sharp = IconData(0xe7d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bed</i> — material icon named "bed" (round). + static const IconData bed_rounded = IconData(0xf5b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bed</i> — material icon named "bed" (outlined). + static const IconData bed_outlined = IconData(0xeec7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bedroom_baby</i> — material icon named "bedroom baby". + static const IconData bedroom_baby = IconData(0xe0d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bedroom_baby</i> — material icon named "bedroom baby" (sharp). + static const IconData bedroom_baby_sharp = IconData(0xe7d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bedroom_baby</i> — material icon named "bedroom baby" (round). + static const IconData bedroom_baby_rounded = IconData(0xf5b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bedroom_baby</i> — material icon named "bedroom baby" (outlined). + static const IconData bedroom_baby_outlined = IconData(0xeec8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bedroom_child</i> — material icon named "bedroom child". + static const IconData bedroom_child = IconData(0xe0d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bedroom_child</i> — material icon named "bedroom child" (sharp). + static const IconData bedroom_child_sharp = IconData(0xe7d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bedroom_child</i> — material icon named "bedroom child" (round). + static const IconData bedroom_child_rounded = IconData(0xf5b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bedroom_child</i> — material icon named "bedroom child" (outlined). + static const IconData bedroom_child_outlined = IconData(0xeec9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bedroom_parent</i> — material icon named "bedroom parent". + static const IconData bedroom_parent = IconData(0xe0da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bedroom_parent</i> — material icon named "bedroom parent" (sharp). + static const IconData bedroom_parent_sharp = IconData(0xe7d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bedroom_parent</i> — material icon named "bedroom parent" (round). + static const IconData bedroom_parent_rounded = IconData(0xf5b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bedroom_parent</i> — material icon named "bedroom parent" (outlined). + static const IconData bedroom_parent_outlined = IconData(0xeeca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bedtime</i> — material icon named "bedtime". + static const IconData bedtime = IconData(0xe0db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bedtime</i> — material icon named "bedtime" (sharp). + static const IconData bedtime_sharp = IconData(0xe7d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bedtime</i> — material icon named "bedtime" (round). + static const IconData bedtime_rounded = IconData(0xf5b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bedtime</i> — material icon named "bedtime" (outlined). + static const IconData bedtime_outlined = IconData(0xeecb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bedtime_off</i> — material icon named "bedtime off". + static const IconData bedtime_off = IconData(0xf04c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bedtime_off</i> — material icon named "bedtime off" (sharp). + static const IconData bedtime_off_sharp = IconData(0xf03d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bedtime_off</i> — material icon named "bedtime off" (round). + static const IconData bedtime_off_rounded = IconData(0xf02e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bedtime_off</i> — material icon named "bedtime off" (outlined). + static const IconData bedtime_off_outlined = IconData(0xf05c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">beenhere</i> — material icon named "beenhere". + static const IconData beenhere = IconData(0xe0dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">beenhere</i> — material icon named "beenhere" (sharp). + static const IconData beenhere_sharp = IconData(0xe7da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">beenhere</i> — material icon named "beenhere" (round). + static const IconData beenhere_rounded = IconData(0xf5b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">beenhere</i> — material icon named "beenhere" (outlined). + static const IconData beenhere_outlined = IconData(0xeecc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bento</i> — material icon named "bento". + static const IconData bento = IconData(0xe0dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bento</i> — material icon named "bento" (sharp). + static const IconData bento_sharp = IconData(0xe7db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bento</i> — material icon named "bento" (round). + static const IconData bento_rounded = IconData(0xf5ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bento</i> — material icon named "bento" (outlined). + static const IconData bento_outlined = IconData(0xeecd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bike_scooter</i> — material icon named "bike scooter". + static const IconData bike_scooter = IconData(0xe0de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bike_scooter</i> — material icon named "bike scooter" (sharp). + static const IconData bike_scooter_sharp = IconData(0xe7dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bike_scooter</i> — material icon named "bike scooter" (round). + static const IconData bike_scooter_rounded = IconData(0xf5bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bike_scooter</i> — material icon named "bike scooter" (outlined). + static const IconData bike_scooter_outlined = IconData(0xeece, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">biotech</i> — material icon named "biotech". + static const IconData biotech = IconData(0xe0df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">biotech</i> — material icon named "biotech" (sharp). + static const IconData biotech_sharp = IconData(0xe7dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">biotech</i> — material icon named "biotech" (round). + static const IconData biotech_rounded = IconData(0xf5bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">biotech</i> — material icon named "biotech" (outlined). + static const IconData biotech_outlined = IconData(0xeecf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blender</i> — material icon named "blender". + static const IconData blender = IconData(0xe0e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blender</i> — material icon named "blender" (sharp). + static const IconData blender_sharp = IconData(0xe7de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blender</i> — material icon named "blender" (round). + static const IconData blender_rounded = IconData(0xf5bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blender</i> — material icon named "blender" (outlined). + static const IconData blender_outlined = IconData(0xeed0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blind</i> — material icon named "blind". + static const IconData blind = IconData(0xf0856, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blind</i> — material icon named "blind" (sharp). + static const IconData blind_sharp = IconData(0xf0836, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blind</i> — material icon named "blind" (round). + static const IconData blind_rounded = IconData(0xf087f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blind</i> — material icon named "blind" (outlined). + static const IconData blind_outlined = IconData(0xf089d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blinds</i> — material icon named "blinds". + static const IconData blinds = IconData(0xf078f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blinds</i> — material icon named "blinds" (sharp). + static const IconData blinds_sharp = IconData(0xf0738, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blinds</i> — material icon named "blinds" (round). + static const IconData blinds_rounded = IconData(0xf07e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blinds</i> — material icon named "blinds" (outlined). + static const IconData blinds_outlined = IconData(0xf06e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blinds_closed</i> — material icon named "blinds closed". + static const IconData blinds_closed = IconData(0xf0790, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blinds_closed</i> — material icon named "blinds closed" (sharp). + static const IconData blinds_closed_sharp = IconData(0xf0737, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blinds_closed</i> — material icon named "blinds closed" (round). + static const IconData blinds_closed_rounded = IconData(0xf07e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blinds_closed</i> — material icon named "blinds closed" (outlined). + static const IconData blinds_closed_outlined = IconData(0xf06df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">block</i> — material icon named "block". + static const IconData block = IconData(0xe0e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">block</i> — material icon named "block" (sharp). + static const IconData block_sharp = IconData(0xe7df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">block</i> — material icon named "block" (round). + static const IconData block_rounded = IconData(0xf5be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">block</i> — material icon named "block" (outlined). + static const IconData block_outlined = IconData(0xeed1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">block_flipped</i> — material icon named "block flipped". + static const IconData block_flipped = IconData(0xe0e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bloodtype</i> — material icon named "bloodtype". + static const IconData bloodtype = IconData(0xe0e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bloodtype</i> — material icon named "bloodtype" (sharp). + static const IconData bloodtype_sharp = IconData(0xe7e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bloodtype</i> — material icon named "bloodtype" (round). + static const IconData bloodtype_rounded = IconData(0xf5bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bloodtype</i> — material icon named "bloodtype" (outlined). + static const IconData bloodtype_outlined = IconData(0xeed2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bluetooth</i> — material icon named "bluetooth". + static const IconData bluetooth = IconData(0xe0e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bluetooth</i> — material icon named "bluetooth" (sharp). + static const IconData bluetooth_sharp = IconData(0xe7e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bluetooth</i> — material icon named "bluetooth" (round). + static const IconData bluetooth_rounded = IconData(0xf5c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bluetooth</i> — material icon named "bluetooth" (outlined). + static const IconData bluetooth_outlined = IconData(0xeed7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bluetooth_audio</i> — material icon named "bluetooth audio". + static const IconData bluetooth_audio = IconData(0xe0e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bluetooth_audio</i> — material icon named "bluetooth audio" (sharp). + static const IconData bluetooth_audio_sharp = IconData(0xe7e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bluetooth_audio</i> — material icon named "bluetooth audio" (round). + static const IconData bluetooth_audio_rounded = IconData(0xf5c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bluetooth_audio</i> — material icon named "bluetooth audio" (outlined). + static const IconData bluetooth_audio_outlined = IconData(0xeed3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bluetooth_connected</i> — material icon named "bluetooth connected". + static const IconData bluetooth_connected = IconData(0xe0e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bluetooth_connected</i> — material icon named "bluetooth connected" (sharp). + static const IconData bluetooth_connected_sharp = IconData(0xe7e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bluetooth_connected</i> — material icon named "bluetooth connected" (round). + static const IconData bluetooth_connected_rounded = IconData(0xf5c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bluetooth_connected</i> — material icon named "bluetooth connected" (outlined). + static const IconData bluetooth_connected_outlined = IconData( + 0xeed4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">bluetooth_disabled</i> — material icon named "bluetooth disabled". + static const IconData bluetooth_disabled = IconData(0xe0e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bluetooth_disabled</i> — material icon named "bluetooth disabled" (sharp). + static const IconData bluetooth_disabled_sharp = IconData(0xe7e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bluetooth_disabled</i> — material icon named "bluetooth disabled" (round). + static const IconData bluetooth_disabled_rounded = IconData(0xf5c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bluetooth_disabled</i> — material icon named "bluetooth disabled" (outlined). + static const IconData bluetooth_disabled_outlined = IconData(0xeed5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bluetooth_drive</i> — material icon named "bluetooth drive". + static const IconData bluetooth_drive = IconData(0xe0e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bluetooth_drive</i> — material icon named "bluetooth drive" (sharp). + static const IconData bluetooth_drive_sharp = IconData(0xe7e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bluetooth_drive</i> — material icon named "bluetooth drive" (round). + static const IconData bluetooth_drive_rounded = IconData(0xf5c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bluetooth_drive</i> — material icon named "bluetooth drive" (outlined). + static const IconData bluetooth_drive_outlined = IconData(0xeed6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bluetooth_searching</i> — material icon named "bluetooth searching". + static const IconData bluetooth_searching = IconData(0xe0e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bluetooth_searching</i> — material icon named "bluetooth searching" (sharp). + static const IconData bluetooth_searching_sharp = IconData(0xe7e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bluetooth_searching</i> — material icon named "bluetooth searching" (round). + static const IconData bluetooth_searching_rounded = IconData(0xf5c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bluetooth_searching</i> — material icon named "bluetooth searching" (outlined). + static const IconData bluetooth_searching_outlined = IconData( + 0xeed8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">blur_circular</i> — material icon named "blur circular". + static const IconData blur_circular = IconData(0xe0ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blur_circular</i> — material icon named "blur circular" (sharp). + static const IconData blur_circular_sharp = IconData(0xe7e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blur_circular</i> — material icon named "blur circular" (round). + static const IconData blur_circular_rounded = IconData(0xf5c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blur_circular</i> — material icon named "blur circular" (outlined). + static const IconData blur_circular_outlined = IconData(0xeed9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blur_linear</i> — material icon named "blur linear". + static const IconData blur_linear = IconData(0xe0eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blur_linear</i> — material icon named "blur linear" (sharp). + static const IconData blur_linear_sharp = IconData(0xe7e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blur_linear</i> — material icon named "blur linear" (round). + static const IconData blur_linear_rounded = IconData(0xf5c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blur_linear</i> — material icon named "blur linear" (outlined). + static const IconData blur_linear_outlined = IconData(0xeeda, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blur_off</i> — material icon named "blur off". + static const IconData blur_off = IconData(0xe0ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blur_off</i> — material icon named "blur off" (sharp). + static const IconData blur_off_sharp = IconData(0xe7e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blur_off</i> — material icon named "blur off" (round). + static const IconData blur_off_rounded = IconData(0xf5c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blur_off</i> — material icon named "blur off" (outlined). + static const IconData blur_off_outlined = IconData(0xeedb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">blur_on</i> — material icon named "blur on". + static const IconData blur_on = IconData(0xe0ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">blur_on</i> — material icon named "blur on" (sharp). + static const IconData blur_on_sharp = IconData(0xe7ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">blur_on</i> — material icon named "blur on" (round). + static const IconData blur_on_rounded = IconData(0xf5c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">blur_on</i> — material icon named "blur on" (outlined). + static const IconData blur_on_outlined = IconData(0xeedc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bolt</i> — material icon named "bolt". + static const IconData bolt = IconData(0xe0ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bolt</i> — material icon named "bolt" (sharp). + static const IconData bolt_sharp = IconData(0xe7eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bolt</i> — material icon named "bolt" (round). + static const IconData bolt_rounded = IconData(0xf5ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bolt</i> — material icon named "bolt" (outlined). + static const IconData bolt_outlined = IconData(0xeedd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">book</i> — material icon named "book". + static const IconData book = IconData(0xe0ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">book</i> — material icon named "book" (sharp). + static const IconData book_sharp = IconData(0xe7ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">book</i> — material icon named "book" (round). + static const IconData book_rounded = IconData(0xf5cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">book</i> — material icon named "book" (outlined). + static const IconData book_outlined = IconData(0xeedf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">book_online</i> — material icon named "book online". + static const IconData book_online = IconData(0xe0f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">book_online</i> — material icon named "book online" (sharp). + static const IconData book_online_sharp = IconData(0xe7ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">book_online</i> — material icon named "book online" (round). + static const IconData book_online_rounded = IconData(0xf5cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">book_online</i> — material icon named "book online" (outlined). + static const IconData book_online_outlined = IconData(0xeede, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmark</i> — material icon named "bookmark". + static const IconData bookmark = IconData(0xe0f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmark</i> — material icon named "bookmark" (sharp). + static const IconData bookmark_sharp = IconData(0xe7f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmark</i> — material icon named "bookmark" (round). + static const IconData bookmark_rounded = IconData(0xf5d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmark</i> — material icon named "bookmark" (outlined). + static const IconData bookmark_outlined = IconData(0xeee3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmark_add</i> — material icon named "bookmark add". + static const IconData bookmark_add = IconData(0xe0f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmark_add</i> — material icon named "bookmark add" (sharp). + static const IconData bookmark_add_sharp = IconData(0xe7ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmark_add</i> — material icon named "bookmark add" (round). + static const IconData bookmark_add_rounded = IconData(0xf5cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmark_add</i> — material icon named "bookmark add" (outlined). + static const IconData bookmark_add_outlined = IconData(0xeee0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmark_added</i> — material icon named "bookmark added". + static const IconData bookmark_added = IconData(0xe0f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmark_added</i> — material icon named "bookmark added" (sharp). + static const IconData bookmark_added_sharp = IconData(0xe7ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmark_added</i> — material icon named "bookmark added" (round). + static const IconData bookmark_added_rounded = IconData(0xf5ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmark_added</i> — material icon named "bookmark added" (outlined). + static const IconData bookmark_added_outlined = IconData(0xeee1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmark_border</i> — material icon named "bookmark border". + static const IconData bookmark_border = IconData(0xe0f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmark_border</i> — material icon named "bookmark border" (sharp). + static const IconData bookmark_border_sharp = IconData(0xe7f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmark_border</i> — material icon named "bookmark border" (round). + static const IconData bookmark_border_rounded = IconData(0xf5cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmark_border</i> — material icon named "bookmark border" (outlined). + static const IconData bookmark_border_outlined = IconData(0xeee2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmark_outline</i> — material icon named "bookmark outline". + static const IconData bookmark_outline = IconData(0xe0f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmark_outline</i> — material icon named "bookmark outline" (sharp). + static const IconData bookmark_outline_sharp = IconData(0xe7f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmark_outline</i> — material icon named "bookmark outline" (round). + static const IconData bookmark_outline_rounded = IconData(0xf5cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmark_outline</i> — material icon named "bookmark outline" (outlined). + static const IconData bookmark_outline_outlined = IconData(0xeee2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmark_remove</i> — material icon named "bookmark remove". + static const IconData bookmark_remove = IconData(0xe0f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmark_remove</i> — material icon named "bookmark remove" (sharp). + static const IconData bookmark_remove_sharp = IconData(0xe7f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmark_remove</i> — material icon named "bookmark remove" (round). + static const IconData bookmark_remove_rounded = IconData(0xf5d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmark_remove</i> — material icon named "bookmark remove" (outlined). + static const IconData bookmark_remove_outlined = IconData(0xeee4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bookmarks</i> — material icon named "bookmarks". + static const IconData bookmarks = IconData(0xe0f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bookmarks</i> — material icon named "bookmarks" (sharp). + static const IconData bookmarks_sharp = IconData(0xe7f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bookmarks</i> — material icon named "bookmarks" (round). + static const IconData bookmarks_rounded = IconData(0xf5d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bookmarks</i> — material icon named "bookmarks" (outlined). + static const IconData bookmarks_outlined = IconData(0xeee5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_all</i> — material icon named "border all". + static const IconData border_all = IconData(0xe0f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_all</i> — material icon named "border all" (sharp). + static const IconData border_all_sharp = IconData(0xe7f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_all</i> — material icon named "border all" (round). + static const IconData border_all_rounded = IconData(0xf5d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_all</i> — material icon named "border all" (outlined). + static const IconData border_all_outlined = IconData(0xeee6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_bottom</i> — material icon named "border bottom". + static const IconData border_bottom = IconData(0xe0f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_bottom</i> — material icon named "border bottom" (sharp). + static const IconData border_bottom_sharp = IconData(0xe7f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_bottom</i> — material icon named "border bottom" (round). + static const IconData border_bottom_rounded = IconData(0xf5d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_bottom</i> — material icon named "border bottom" (outlined). + static const IconData border_bottom_outlined = IconData(0xeee7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_clear</i> — material icon named "border clear". + static const IconData border_clear = IconData(0xe0f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_clear</i> — material icon named "border clear" (sharp). + static const IconData border_clear_sharp = IconData(0xe7f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_clear</i> — material icon named "border clear" (round). + static const IconData border_clear_rounded = IconData(0xf5d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_clear</i> — material icon named "border clear" (outlined). + static const IconData border_clear_outlined = IconData(0xeee8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_color</i> — material icon named "border color". + static const IconData border_color = IconData(0xe0fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_color</i> — material icon named "border color" (sharp). + static const IconData border_color_sharp = IconData(0xe7f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_color</i> — material icon named "border color" (round). + static const IconData border_color_rounded = IconData(0xf5d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_color</i> — material icon named "border color" (outlined). + static const IconData border_color_outlined = IconData(0xeee9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_horizontal</i> — material icon named "border horizontal". + static const IconData border_horizontal = IconData(0xe0fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_horizontal</i> — material icon named "border horizontal" (sharp). + static const IconData border_horizontal_sharp = IconData(0xe7f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_horizontal</i> — material icon named "border horizontal" (round). + static const IconData border_horizontal_rounded = IconData(0xf5d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_horizontal</i> — material icon named "border horizontal" (outlined). + static const IconData border_horizontal_outlined = IconData(0xeeea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_inner</i> — material icon named "border inner". + static const IconData border_inner = IconData(0xe0fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_inner</i> — material icon named "border inner" (sharp). + static const IconData border_inner_sharp = IconData(0xe7f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_inner</i> — material icon named "border inner" (round). + static const IconData border_inner_rounded = IconData(0xf5d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_inner</i> — material icon named "border inner" (outlined). + static const IconData border_inner_outlined = IconData(0xeeeb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_left</i> — material icon named "border left". + static const IconData border_left = IconData(0xe0fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_left</i> — material icon named "border left" (sharp). + static const IconData border_left_sharp = IconData(0xe7fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_left</i> — material icon named "border left" (round). + static const IconData border_left_rounded = IconData(0xf5d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_left</i> — material icon named "border left" (outlined). + static const IconData border_left_outlined = IconData(0xeeec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_outer</i> — material icon named "border outer". + static const IconData border_outer = IconData(0xe0fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_outer</i> — material icon named "border outer" (sharp). + static const IconData border_outer_sharp = IconData(0xe7fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_outer</i> — material icon named "border outer" (round). + static const IconData border_outer_rounded = IconData(0xf5da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_outer</i> — material icon named "border outer" (outlined). + static const IconData border_outer_outlined = IconData(0xeeed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_right</i> — material icon named "border right". + static const IconData border_right = IconData(0xe0ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_right</i> — material icon named "border right" (sharp). + static const IconData border_right_sharp = IconData(0xe7fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_right</i> — material icon named "border right" (round). + static const IconData border_right_rounded = IconData(0xf5db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_right</i> — material icon named "border right" (outlined). + static const IconData border_right_outlined = IconData(0xeeee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_style</i> — material icon named "border style". + static const IconData border_style = IconData(0xe100, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_style</i> — material icon named "border style" (sharp). + static const IconData border_style_sharp = IconData(0xe7fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_style</i> — material icon named "border style" (round). + static const IconData border_style_rounded = IconData(0xf5dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_style</i> — material icon named "border style" (outlined). + static const IconData border_style_outlined = IconData(0xeeef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_top</i> — material icon named "border top". + static const IconData border_top = IconData(0xe101, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_top</i> — material icon named "border top" (sharp). + static const IconData border_top_sharp = IconData(0xe7fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_top</i> — material icon named "border top" (round). + static const IconData border_top_rounded = IconData(0xf5dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_top</i> — material icon named "border top" (outlined). + static const IconData border_top_outlined = IconData(0xeef0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">border_vertical</i> — material icon named "border vertical". + static const IconData border_vertical = IconData(0xe102, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">border_vertical</i> — material icon named "border vertical" (sharp). + static const IconData border_vertical_sharp = IconData(0xe7ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">border_vertical</i> — material icon named "border vertical" (round). + static const IconData border_vertical_rounded = IconData(0xf5de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">border_vertical</i> — material icon named "border vertical" (outlined). + static const IconData border_vertical_outlined = IconData(0xeef1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">boy</i> — material icon named "boy". + static const IconData boy = IconData(0xf04c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">boy</i> — material icon named "boy" (sharp). + static const IconData boy_sharp = IconData(0xf03d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">boy</i> — material icon named "boy" (round). + static const IconData boy_rounded = IconData(0xf02e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">boy</i> — material icon named "boy" (outlined). + static const IconData boy_outlined = IconData(0xf05c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">branding_watermark</i> — material icon named "branding watermark". + static const IconData branding_watermark = IconData(0xe103, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">branding_watermark</i> — material icon named "branding watermark" (sharp). + static const IconData branding_watermark_sharp = IconData(0xe800, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">branding_watermark</i> — material icon named "branding watermark" (round). + static const IconData branding_watermark_rounded = IconData(0xf5df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">branding_watermark</i> — material icon named "branding watermark" (outlined). + static const IconData branding_watermark_outlined = IconData(0xeef2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">breakfast_dining</i> — material icon named "breakfast dining". + static const IconData breakfast_dining = IconData(0xe104, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">breakfast_dining</i> — material icon named "breakfast dining" (sharp). + static const IconData breakfast_dining_sharp = IconData(0xe801, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">breakfast_dining</i> — material icon named "breakfast dining" (round). + static const IconData breakfast_dining_rounded = IconData(0xf5e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">breakfast_dining</i> — material icon named "breakfast dining" (outlined). + static const IconData breakfast_dining_outlined = IconData(0xeef3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_1</i> — material icon named "brightness 1". + static const IconData brightness_1 = IconData(0xe105, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_1</i> — material icon named "brightness 1" (sharp). + static const IconData brightness_1_sharp = IconData(0xe802, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_1</i> — material icon named "brightness 1" (round). + static const IconData brightness_1_rounded = IconData(0xf5e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_1</i> — material icon named "brightness 1" (outlined). + static const IconData brightness_1_outlined = IconData(0xeef4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_2</i> — material icon named "brightness 2". + static const IconData brightness_2 = IconData(0xe106, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_2</i> — material icon named "brightness 2" (sharp). + static const IconData brightness_2_sharp = IconData(0xe803, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_2</i> — material icon named "brightness 2" (round). + static const IconData brightness_2_rounded = IconData(0xf5e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_2</i> — material icon named "brightness 2" (outlined). + static const IconData brightness_2_outlined = IconData(0xeef5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_3</i> — material icon named "brightness 3". + static const IconData brightness_3 = IconData(0xe107, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_3</i> — material icon named "brightness 3" (sharp). + static const IconData brightness_3_sharp = IconData(0xe804, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_3</i> — material icon named "brightness 3" (round). + static const IconData brightness_3_rounded = IconData(0xf5e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_3</i> — material icon named "brightness 3" (outlined). + static const IconData brightness_3_outlined = IconData(0xeef6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_4</i> — material icon named "brightness 4". + static const IconData brightness_4 = IconData(0xe108, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_4</i> — material icon named "brightness 4" (sharp). + static const IconData brightness_4_sharp = IconData(0xe805, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_4</i> — material icon named "brightness 4" (round). + static const IconData brightness_4_rounded = IconData(0xf5e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_4</i> — material icon named "brightness 4" (outlined). + static const IconData brightness_4_outlined = IconData(0xeef7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_5</i> — material icon named "brightness 5". + static const IconData brightness_5 = IconData(0xe109, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_5</i> — material icon named "brightness 5" (sharp). + static const IconData brightness_5_sharp = IconData(0xe806, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_5</i> — material icon named "brightness 5" (round). + static const IconData brightness_5_rounded = IconData(0xf5e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_5</i> — material icon named "brightness 5" (outlined). + static const IconData brightness_5_outlined = IconData(0xeef8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_6</i> — material icon named "brightness 6". + static const IconData brightness_6 = IconData(0xe10a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_6</i> — material icon named "brightness 6" (sharp). + static const IconData brightness_6_sharp = IconData(0xe807, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_6</i> — material icon named "brightness 6" (round). + static const IconData brightness_6_rounded = IconData(0xf5e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_6</i> — material icon named "brightness 6" (outlined). + static const IconData brightness_6_outlined = IconData(0xeef9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_7</i> — material icon named "brightness 7". + static const IconData brightness_7 = IconData(0xe10b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_7</i> — material icon named "brightness 7" (sharp). + static const IconData brightness_7_sharp = IconData(0xe808, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_7</i> — material icon named "brightness 7" (round). + static const IconData brightness_7_rounded = IconData(0xf5e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_7</i> — material icon named "brightness 7" (outlined). + static const IconData brightness_7_outlined = IconData(0xeefa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_auto</i> — material icon named "brightness auto". + static const IconData brightness_auto = IconData(0xe10c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_auto</i> — material icon named "brightness auto" (sharp). + static const IconData brightness_auto_sharp = IconData(0xe809, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_auto</i> — material icon named "brightness auto" (round). + static const IconData brightness_auto_rounded = IconData(0xf5e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_auto</i> — material icon named "brightness auto" (outlined). + static const IconData brightness_auto_outlined = IconData(0xeefb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_high</i> — material icon named "brightness high". + static const IconData brightness_high = IconData(0xe10d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_high</i> — material icon named "brightness high" (sharp). + static const IconData brightness_high_sharp = IconData(0xe80a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_high</i> — material icon named "brightness high" (round). + static const IconData brightness_high_rounded = IconData(0xf5e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_high</i> — material icon named "brightness high" (outlined). + static const IconData brightness_high_outlined = IconData(0xeefc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_low</i> — material icon named "brightness low". + static const IconData brightness_low = IconData(0xe10e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_low</i> — material icon named "brightness low" (sharp). + static const IconData brightness_low_sharp = IconData(0xe80b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_low</i> — material icon named "brightness low" (round). + static const IconData brightness_low_rounded = IconData(0xf5ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_low</i> — material icon named "brightness low" (outlined). + static const IconData brightness_low_outlined = IconData(0xeefd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brightness_medium</i> — material icon named "brightness medium". + static const IconData brightness_medium = IconData(0xe10f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brightness_medium</i> — material icon named "brightness medium" (sharp). + static const IconData brightness_medium_sharp = IconData(0xe80c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brightness_medium</i> — material icon named "brightness medium" (round). + static const IconData brightness_medium_rounded = IconData(0xf5eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brightness_medium</i> — material icon named "brightness medium" (outlined). + static const IconData brightness_medium_outlined = IconData(0xeefe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">broadcast_on_home</i> — material icon named "broadcast on home". + static const IconData broadcast_on_home = IconData(0xf0791, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">broadcast_on_home</i> — material icon named "broadcast on home" (sharp). + static const IconData broadcast_on_home_sharp = IconData(0xf0739, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">broadcast_on_home</i> — material icon named "broadcast on home" (round). + static const IconData broadcast_on_home_rounded = IconData(0xf07e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">broadcast_on_home</i> — material icon named "broadcast on home" (outlined). + static const IconData broadcast_on_home_outlined = IconData(0xf06e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">broadcast_on_personal</i> — material icon named "broadcast on personal". + static const IconData broadcast_on_personal = IconData(0xf0792, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">broadcast_on_personal</i> — material icon named "broadcast on personal" (sharp). + static const IconData broadcast_on_personal_sharp = IconData( + 0xf073a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">broadcast_on_personal</i> — material icon named "broadcast on personal" (round). + static const IconData broadcast_on_personal_rounded = IconData( + 0xf07ea, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">broadcast_on_personal</i> — material icon named "broadcast on personal" (outlined). + static const IconData broadcast_on_personal_outlined = IconData( + 0xf06e2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">broken_image</i> — material icon named "broken image". + static const IconData broken_image = IconData(0xe110, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">broken_image</i> — material icon named "broken image" (sharp). + static const IconData broken_image_sharp = IconData(0xe80d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">broken_image</i> — material icon named "broken image" (round). + static const IconData broken_image_rounded = IconData(0xf5ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">broken_image</i> — material icon named "broken image" (outlined). + static const IconData broken_image_outlined = IconData(0xeeff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">browse_gallery</i> — material icon named "browse gallery". + static const IconData browse_gallery = IconData(0xf06ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">browse_gallery</i> — material icon named "browse gallery" (sharp). + static const IconData browse_gallery_sharp = IconData(0xf06ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">browse_gallery</i> — material icon named "browse gallery" (round). + static const IconData browse_gallery_rounded = IconData(0xf06c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">browse_gallery</i> — material icon named "browse gallery" (outlined). + static const IconData browse_gallery_outlined = IconData(0xf03bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">browser_not_supported</i> — material icon named "browser not supported". + static const IconData browser_not_supported = IconData(0xe111, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">browser_not_supported</i> — material icon named "browser not supported" (sharp). + static const IconData browser_not_supported_sharp = IconData(0xe80e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">browser_not_supported</i> — material icon named "browser not supported" (round). + static const IconData browser_not_supported_rounded = IconData( + 0xf5ed, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">browser_not_supported</i> — material icon named "browser not supported" (outlined). + static const IconData browser_not_supported_outlined = IconData( + 0xef00, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">browser_updated</i> — material icon named "browser updated". + static const IconData browser_updated = IconData(0xf04c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">browser_updated</i> — material icon named "browser updated" (sharp). + static const IconData browser_updated_sharp = IconData(0xf03d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">browser_updated</i> — material icon named "browser updated" (round). + static const IconData browser_updated_rounded = IconData(0xf02e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">browser_updated</i> — material icon named "browser updated" (outlined). + static const IconData browser_updated_outlined = IconData(0xf05c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brunch_dining</i> — material icon named "brunch dining". + static const IconData brunch_dining = IconData(0xe112, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brunch_dining</i> — material icon named "brunch dining" (sharp). + static const IconData brunch_dining_sharp = IconData(0xe80f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brunch_dining</i> — material icon named "brunch dining" (round). + static const IconData brunch_dining_rounded = IconData(0xf5ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brunch_dining</i> — material icon named "brunch dining" (outlined). + static const IconData brunch_dining_outlined = IconData(0xef01, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">brush</i> — material icon named "brush". + static const IconData brush = IconData(0xe113, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">brush</i> — material icon named "brush" (sharp). + static const IconData brush_sharp = IconData(0xe810, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">brush</i> — material icon named "brush" (round). + static const IconData brush_rounded = IconData(0xf5ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">brush</i> — material icon named "brush" (outlined). + static const IconData brush_outlined = IconData(0xef02, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bubble_chart</i> — material icon named "bubble chart". + static const IconData bubble_chart = IconData(0xe114, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bubble_chart</i> — material icon named "bubble chart" (sharp). + static const IconData bubble_chart_sharp = IconData(0xe811, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bubble_chart</i> — material icon named "bubble chart" (round). + static const IconData bubble_chart_rounded = IconData(0xf5f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bubble_chart</i> — material icon named "bubble chart" (outlined). + static const IconData bubble_chart_outlined = IconData(0xef03, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bug_report</i> — material icon named "bug report". + static const IconData bug_report = IconData(0xe115, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bug_report</i> — material icon named "bug report" (sharp). + static const IconData bug_report_sharp = IconData(0xe812, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bug_report</i> — material icon named "bug report" (round). + static const IconData bug_report_rounded = IconData(0xf5f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bug_report</i> — material icon named "bug report" (outlined). + static const IconData bug_report_outlined = IconData(0xef04, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">build</i> — material icon named "build". + static const IconData build = IconData(0xe116, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">build</i> — material icon named "build" (sharp). + static const IconData build_sharp = IconData(0xe814, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">build</i> — material icon named "build" (round). + static const IconData build_rounded = IconData(0xf5f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">build</i> — material icon named "build" (outlined). + static const IconData build_outlined = IconData(0xef06, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">build_circle</i> — material icon named "build circle". + static const IconData build_circle = IconData(0xe117, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">build_circle</i> — material icon named "build circle" (sharp). + static const IconData build_circle_sharp = IconData(0xe813, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">build_circle</i> — material icon named "build circle" (round). + static const IconData build_circle_rounded = IconData(0xf5f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">build_circle</i> — material icon named "build circle" (outlined). + static const IconData build_circle_outlined = IconData(0xef05, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bungalow</i> — material icon named "bungalow". + static const IconData bungalow = IconData(0xe118, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bungalow</i> — material icon named "bungalow" (sharp). + static const IconData bungalow_sharp = IconData(0xe815, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bungalow</i> — material icon named "bungalow" (round). + static const IconData bungalow_rounded = IconData(0xf5f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bungalow</i> — material icon named "bungalow" (outlined). + static const IconData bungalow_outlined = IconData(0xef07, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">burst_mode</i> — material icon named "burst mode". + static const IconData burst_mode = IconData(0xe119, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">burst_mode</i> — material icon named "burst mode" (sharp). + static const IconData burst_mode_sharp = IconData(0xe816, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">burst_mode</i> — material icon named "burst mode" (round). + static const IconData burst_mode_rounded = IconData(0xf5f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">burst_mode</i> — material icon named "burst mode" (outlined). + static const IconData burst_mode_outlined = IconData(0xef08, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">bus_alert</i> — material icon named "bus alert". + static const IconData bus_alert = IconData(0xe11a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">bus_alert</i> — material icon named "bus alert" (sharp). + static const IconData bus_alert_sharp = IconData(0xe817, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">bus_alert</i> — material icon named "bus alert" (round). + static const IconData bus_alert_rounded = IconData(0xf5f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">bus_alert</i> — material icon named "bus alert" (outlined). + static const IconData bus_alert_outlined = IconData(0xef09, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">business</i> — material icon named "business". + static const IconData business = IconData(0xe11b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">business</i> — material icon named "business" (sharp). + static const IconData business_sharp = IconData(0xe819, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">business</i> — material icon named "business" (round). + static const IconData business_rounded = IconData(0xf5f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">business</i> — material icon named "business" (outlined). + static const IconData business_outlined = IconData(0xef0b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">business_center</i> — material icon named "business center". + static const IconData business_center = IconData(0xe11c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">business_center</i> — material icon named "business center" (sharp). + static const IconData business_center_sharp = IconData(0xe818, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">business_center</i> — material icon named "business center" (round). + static const IconData business_center_rounded = IconData(0xf5f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">business_center</i> — material icon named "business center" (outlined). + static const IconData business_center_outlined = IconData(0xef0a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cabin</i> — material icon named "cabin". + static const IconData cabin = IconData(0xe11d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cabin</i> — material icon named "cabin" (sharp). + static const IconData cabin_sharp = IconData(0xe81a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cabin</i> — material icon named "cabin" (round). + static const IconData cabin_rounded = IconData(0xf5f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cabin</i> — material icon named "cabin" (outlined). + static const IconData cabin_outlined = IconData(0xef0c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cable</i> — material icon named "cable". + static const IconData cable = IconData(0xe11e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cable</i> — material icon named "cable" (sharp). + static const IconData cable_sharp = IconData(0xe81b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cable</i> — material icon named "cable" (round). + static const IconData cable_rounded = IconData(0xf5fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cable</i> — material icon named "cable" (outlined). + static const IconData cable_outlined = IconData(0xef0d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cached</i> — material icon named "cached". + static const IconData cached = IconData(0xe11f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cached</i> — material icon named "cached" (sharp). + static const IconData cached_sharp = IconData(0xe81c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cached</i> — material icon named "cached" (round). + static const IconData cached_rounded = IconData(0xf5fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cached</i> — material icon named "cached" (outlined). + static const IconData cached_outlined = IconData(0xef0e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cake</i> — material icon named "cake". + static const IconData cake = IconData(0xe120, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cake</i> — material icon named "cake" (sharp). + static const IconData cake_sharp = IconData(0xe81d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cake</i> — material icon named "cake" (round). + static const IconData cake_rounded = IconData(0xf5fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cake</i> — material icon named "cake" (outlined). + static const IconData cake_outlined = IconData(0xef0f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">calculate</i> — material icon named "calculate". + static const IconData calculate = IconData(0xe121, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">calculate</i> — material icon named "calculate" (sharp). + static const IconData calculate_sharp = IconData(0xe81e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">calculate</i> — material icon named "calculate" (round). + static const IconData calculate_rounded = IconData(0xf5fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">calculate</i> — material icon named "calculate" (outlined). + static const IconData calculate_outlined = IconData(0xef10, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">calendar_month</i> — material icon named "calendar month". + static const IconData calendar_month = IconData(0xf06bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">calendar_month</i> — material icon named "calendar month" (sharp). + static const IconData calendar_month_sharp = IconData(0xf06ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">calendar_month</i> — material icon named "calendar month" (round). + static const IconData calendar_month_rounded = IconData(0xf06c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">calendar_month</i> — material icon named "calendar month" (outlined). + static const IconData calendar_month_outlined = IconData(0xf051f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">calendar_today</i> — material icon named "calendar today". + static const IconData calendar_today = IconData(0xe122, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">calendar_today</i> — material icon named "calendar today" (sharp). + static const IconData calendar_today_sharp = IconData(0xe81f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">calendar_today</i> — material icon named "calendar today" (round). + static const IconData calendar_today_rounded = IconData(0xf5fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">calendar_today</i> — material icon named "calendar today" (outlined). + static const IconData calendar_today_outlined = IconData(0xef11, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">calendar_view_day</i> — material icon named "calendar view day". + static const IconData calendar_view_day = IconData(0xe123, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">calendar_view_day</i> — material icon named "calendar view day" (sharp). + static const IconData calendar_view_day_sharp = IconData(0xe820, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">calendar_view_day</i> — material icon named "calendar view day" (round). + static const IconData calendar_view_day_rounded = IconData(0xf5ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">calendar_view_day</i> — material icon named "calendar view day" (outlined). + static const IconData calendar_view_day_outlined = IconData(0xef12, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">calendar_view_month</i> — material icon named "calendar view month". + static const IconData calendar_view_month = IconData(0xe124, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">calendar_view_month</i> — material icon named "calendar view month" (sharp). + static const IconData calendar_view_month_sharp = IconData(0xe821, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">calendar_view_month</i> — material icon named "calendar view month" (round). + static const IconData calendar_view_month_rounded = IconData(0xf600, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">calendar_view_month</i> — material icon named "calendar view month" (outlined). + static const IconData calendar_view_month_outlined = IconData( + 0xef13, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">calendar_view_week</i> — material icon named "calendar view week". + static const IconData calendar_view_week = IconData(0xe125, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">calendar_view_week</i> — material icon named "calendar view week" (sharp). + static const IconData calendar_view_week_sharp = IconData(0xe822, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">calendar_view_week</i> — material icon named "calendar view week" (round). + static const IconData calendar_view_week_rounded = IconData(0xf601, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">calendar_view_week</i> — material icon named "calendar view week" (outlined). + static const IconData calendar_view_week_outlined = IconData(0xef14, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">call</i> — material icon named "call". + static const IconData call = IconData(0xe126, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">call</i> — material icon named "call" (sharp). + static const IconData call_sharp = IconData(0xe829, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">call</i> — material icon named "call" (round). + static const IconData call_rounded = IconData(0xf608, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">call</i> — material icon named "call" (outlined). + static const IconData call_outlined = IconData(0xef1a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">call_end</i> — material icon named "call end". + static const IconData call_end = IconData(0xe127, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">call_end</i> — material icon named "call end" (sharp). + static const IconData call_end_sharp = IconData(0xe823, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">call_end</i> — material icon named "call end" (round). + static const IconData call_end_rounded = IconData(0xf602, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">call_end</i> — material icon named "call end" (outlined). + static const IconData call_end_outlined = IconData(0xef15, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">call_made</i> — material icon named "call made". + static const IconData call_made = IconData( + 0xe128, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">call_made</i> — material icon named "call made" (sharp). + static const IconData call_made_sharp = IconData( + 0xe824, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">call_made</i> — material icon named "call made" (round). + static const IconData call_made_rounded = IconData( + 0xf603, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">call_made</i> — material icon named "call made" (outlined). + static const IconData call_made_outlined = IconData( + 0xef16, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">call_merge</i> — material icon named "call merge". + static const IconData call_merge = IconData( + 0xe129, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">call_merge</i> — material icon named "call merge" (sharp). + static const IconData call_merge_sharp = IconData( + 0xe825, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">call_merge</i> — material icon named "call merge" (round). + static const IconData call_merge_rounded = IconData( + 0xf604, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">call_merge</i> — material icon named "call merge" (outlined). + static const IconData call_merge_outlined = IconData( + 0xef17, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">call_missed</i> — material icon named "call missed". + static const IconData call_missed = IconData( + 0xe12a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">call_missed</i> — material icon named "call missed" (sharp). + static const IconData call_missed_sharp = IconData( + 0xe827, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">call_missed</i> — material icon named "call missed" (round). + static const IconData call_missed_rounded = IconData( + 0xf606, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">call_missed</i> — material icon named "call missed" (outlined). + static const IconData call_missed_outlined = IconData( + 0xef19, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">call_missed_outgoing</i> — material icon named "call missed outgoing". + static const IconData call_missed_outgoing = IconData( + 0xe12b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">call_missed_outgoing</i> — material icon named "call missed outgoing" (sharp). + static const IconData call_missed_outgoing_sharp = IconData( + 0xe826, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">call_missed_outgoing</i> — material icon named "call missed outgoing" (round). + static const IconData call_missed_outgoing_rounded = IconData( + 0xf605, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">call_missed_outgoing</i> — material icon named "call missed outgoing" (outlined). + static const IconData call_missed_outgoing_outlined = IconData( + 0xef18, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">call_received</i> — material icon named "call received". + static const IconData call_received = IconData( + 0xe12c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">call_received</i> — material icon named "call received" (sharp). + static const IconData call_received_sharp = IconData( + 0xe828, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">call_received</i> — material icon named "call received" (round). + static const IconData call_received_rounded = IconData( + 0xf607, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">call_received</i> — material icon named "call received" (outlined). + static const IconData call_received_outlined = IconData( + 0xef1b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">call_split</i> — material icon named "call split". + static const IconData call_split = IconData( + 0xe12d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">call_split</i> — material icon named "call split" (sharp). + static const IconData call_split_sharp = IconData( + 0xe82a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">call_split</i> — material icon named "call split" (round). + static const IconData call_split_rounded = IconData( + 0xf609, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">call_split</i> — material icon named "call split" (outlined). + static const IconData call_split_outlined = IconData( + 0xef1c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">call_to_action</i> — material icon named "call to action". + static const IconData call_to_action = IconData(0xe12e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">call_to_action</i> — material icon named "call to action" (sharp). + static const IconData call_to_action_sharp = IconData(0xe82b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">call_to_action</i> — material icon named "call to action" (round). + static const IconData call_to_action_rounded = IconData(0xf60a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">call_to_action</i> — material icon named "call to action" (outlined). + static const IconData call_to_action_outlined = IconData(0xef1d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera</i> — material icon named "camera". + static const IconData camera = IconData(0xe12f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera</i> — material icon named "camera" (sharp). + static const IconData camera_sharp = IconData(0xe833, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera</i> — material icon named "camera" (round). + static const IconData camera_rounded = IconData(0xf612, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera</i> — material icon named "camera" (outlined). + static const IconData camera_outlined = IconData(0xef23, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_alt</i> — material icon named "camera alt". + static const IconData camera_alt = IconData(0xe130, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_alt</i> — material icon named "camera alt" (sharp). + static const IconData camera_alt_sharp = IconData(0xe82c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_alt</i> — material icon named "camera alt" (round). + static const IconData camera_alt_rounded = IconData(0xf60b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_alt</i> — material icon named "camera alt" (outlined). + static const IconData camera_alt_outlined = IconData(0xef1e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_enhance</i> — material icon named "camera enhance". + static const IconData camera_enhance = IconData(0xe131, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_enhance</i> — material icon named "camera enhance" (sharp). + static const IconData camera_enhance_sharp = IconData(0xe82d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_enhance</i> — material icon named "camera enhance" (round). + static const IconData camera_enhance_rounded = IconData(0xf60c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_enhance</i> — material icon named "camera enhance" (outlined). + static const IconData camera_enhance_outlined = IconData(0xef1f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_front</i> — material icon named "camera front". + static const IconData camera_front = IconData(0xe132, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_front</i> — material icon named "camera front" (sharp). + static const IconData camera_front_sharp = IconData(0xe82e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_front</i> — material icon named "camera front" (round). + static const IconData camera_front_rounded = IconData(0xf60d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_front</i> — material icon named "camera front" (outlined). + static const IconData camera_front_outlined = IconData(0xef20, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_indoor</i> — material icon named "camera indoor". + static const IconData camera_indoor = IconData(0xe133, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_indoor</i> — material icon named "camera indoor" (sharp). + static const IconData camera_indoor_sharp = IconData(0xe82f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_indoor</i> — material icon named "camera indoor" (round). + static const IconData camera_indoor_rounded = IconData(0xf60e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_indoor</i> — material icon named "camera indoor" (outlined). + static const IconData camera_indoor_outlined = IconData(0xef21, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_outdoor</i> — material icon named "camera outdoor". + static const IconData camera_outdoor = IconData(0xe134, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_outdoor</i> — material icon named "camera outdoor" (sharp). + static const IconData camera_outdoor_sharp = IconData(0xe830, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_outdoor</i> — material icon named "camera outdoor" (round). + static const IconData camera_outdoor_rounded = IconData(0xf60f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_outdoor</i> — material icon named "camera outdoor" (outlined). + static const IconData camera_outdoor_outlined = IconData(0xef22, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_rear</i> — material icon named "camera rear". + static const IconData camera_rear = IconData(0xe135, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_rear</i> — material icon named "camera rear" (sharp). + static const IconData camera_rear_sharp = IconData(0xe831, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_rear</i> — material icon named "camera rear" (round). + static const IconData camera_rear_rounded = IconData(0xf610, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_rear</i> — material icon named "camera rear" (outlined). + static const IconData camera_rear_outlined = IconData(0xef24, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">camera_roll</i> — material icon named "camera roll". + static const IconData camera_roll = IconData(0xe136, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">camera_roll</i> — material icon named "camera roll" (sharp). + static const IconData camera_roll_sharp = IconData(0xe832, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">camera_roll</i> — material icon named "camera roll" (round). + static const IconData camera_roll_rounded = IconData(0xf611, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">camera_roll</i> — material icon named "camera roll" (outlined). + static const IconData camera_roll_outlined = IconData(0xef25, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cameraswitch</i> — material icon named "cameraswitch". + static const IconData cameraswitch = IconData(0xe137, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cameraswitch</i> — material icon named "cameraswitch" (sharp). + static const IconData cameraswitch_sharp = IconData(0xe834, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cameraswitch</i> — material icon named "cameraswitch" (round). + static const IconData cameraswitch_rounded = IconData(0xf613, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cameraswitch</i> — material icon named "cameraswitch" (outlined). + static const IconData cameraswitch_outlined = IconData(0xef26, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">campaign</i> — material icon named "campaign". + static const IconData campaign = IconData(0xe138, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">campaign</i> — material icon named "campaign" (sharp). + static const IconData campaign_sharp = IconData(0xe835, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">campaign</i> — material icon named "campaign" (round). + static const IconData campaign_rounded = IconData(0xf614, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">campaign</i> — material icon named "campaign" (outlined). + static const IconData campaign_outlined = IconData(0xef27, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cancel</i> — material icon named "cancel". + static const IconData cancel = IconData(0xe139, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cancel</i> — material icon named "cancel" (sharp). + static const IconData cancel_sharp = IconData(0xe838, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cancel</i> — material icon named "cancel" (round). + static const IconData cancel_rounded = IconData(0xf616, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cancel</i> — material icon named "cancel" (outlined). + static const IconData cancel_outlined = IconData(0xef28, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cancel_presentation</i> — material icon named "cancel presentation". + static const IconData cancel_presentation = IconData(0xe13a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cancel_presentation</i> — material icon named "cancel presentation" (sharp). + static const IconData cancel_presentation_sharp = IconData(0xe836, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cancel_presentation</i> — material icon named "cancel presentation" (round). + static const IconData cancel_presentation_rounded = IconData(0xf615, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cancel_presentation</i> — material icon named "cancel presentation" (outlined). + static const IconData cancel_presentation_outlined = IconData( + 0xef29, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">cancel_schedule_send</i> — material icon named "cancel schedule send". + static const IconData cancel_schedule_send = IconData(0xe13b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cancel_schedule_send</i> — material icon named "cancel schedule send" (sharp). + static const IconData cancel_schedule_send_sharp = IconData(0xe837, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cancel_schedule_send</i> — material icon named "cancel schedule send" (round). + static const IconData cancel_schedule_send_rounded = IconData( + 0xf617, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">cancel_schedule_send</i> — material icon named "cancel schedule send" (outlined). + static const IconData cancel_schedule_send_outlined = IconData( + 0xef2a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">candlestick_chart</i> — material icon named "candlestick chart". + static const IconData candlestick_chart = IconData(0xf04ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">candlestick_chart</i> — material icon named "candlestick chart" (sharp). + static const IconData candlestick_chart_sharp = IconData(0xf03d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">candlestick_chart</i> — material icon named "candlestick chart" (round). + static const IconData candlestick_chart_rounded = IconData(0xf02e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">candlestick_chart</i> — material icon named "candlestick chart" (outlined). + static const IconData candlestick_chart_outlined = IconData(0xf05c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">car_crash</i> — material icon named "car crash". + static const IconData car_crash = IconData(0xf0793, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">car_crash</i> — material icon named "car crash" (sharp). + static const IconData car_crash_sharp = IconData(0xf073b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">car_crash</i> — material icon named "car crash" (round). + static const IconData car_crash_rounded = IconData(0xf07eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">car_crash</i> — material icon named "car crash" (outlined). + static const IconData car_crash_outlined = IconData(0xf06e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">car_rental</i> — material icon named "car rental". + static const IconData car_rental = IconData(0xe13c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">car_rental</i> — material icon named "car rental" (sharp). + static const IconData car_rental_sharp = IconData(0xe839, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">car_rental</i> — material icon named "car rental" (round). + static const IconData car_rental_rounded = IconData(0xf618, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">car_rental</i> — material icon named "car rental" (outlined). + static const IconData car_rental_outlined = IconData(0xef2b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">car_repair</i> — material icon named "car repair". + static const IconData car_repair = IconData(0xe13d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">car_repair</i> — material icon named "car repair" (sharp). + static const IconData car_repair_sharp = IconData(0xe83a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">car_repair</i> — material icon named "car repair" (round). + static const IconData car_repair_rounded = IconData(0xf619, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">car_repair</i> — material icon named "car repair" (outlined). + static const IconData car_repair_outlined = IconData(0xef2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">card_giftcard</i> — material icon named "card giftcard". + static const IconData card_giftcard = IconData(0xe13e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">card_giftcard</i> — material icon named "card giftcard" (sharp). + static const IconData card_giftcard_sharp = IconData(0xe83b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">card_giftcard</i> — material icon named "card giftcard" (round). + static const IconData card_giftcard_rounded = IconData(0xf61a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">card_giftcard</i> — material icon named "card giftcard" (outlined). + static const IconData card_giftcard_outlined = IconData(0xef2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">card_membership</i> — material icon named "card membership". + static const IconData card_membership = IconData(0xe13f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">card_membership</i> — material icon named "card membership" (sharp). + static const IconData card_membership_sharp = IconData(0xe83c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">card_membership</i> — material icon named "card membership" (round). + static const IconData card_membership_rounded = IconData(0xf61b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">card_membership</i> — material icon named "card membership" (outlined). + static const IconData card_membership_outlined = IconData(0xef2e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">card_travel</i> — material icon named "card travel". + static const IconData card_travel = IconData(0xe140, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">card_travel</i> — material icon named "card travel" (sharp). + static const IconData card_travel_sharp = IconData(0xe83d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">card_travel</i> — material icon named "card travel" (round). + static const IconData card_travel_rounded = IconData(0xf61c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">card_travel</i> — material icon named "card travel" (outlined). + static const IconData card_travel_outlined = IconData(0xef2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">carpenter</i> — material icon named "carpenter". + static const IconData carpenter = IconData(0xe141, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">carpenter</i> — material icon named "carpenter" (sharp). + static const IconData carpenter_sharp = IconData(0xe83e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">carpenter</i> — material icon named "carpenter" (round). + static const IconData carpenter_rounded = IconData(0xf61d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">carpenter</i> — material icon named "carpenter" (outlined). + static const IconData carpenter_outlined = IconData(0xef30, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cases</i> — material icon named "cases". + static const IconData cases = IconData(0xe142, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cases</i> — material icon named "cases" (sharp). + static const IconData cases_sharp = IconData(0xe83f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cases</i> — material icon named "cases" (round). + static const IconData cases_rounded = IconData(0xf61e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cases</i> — material icon named "cases" (outlined). + static const IconData cases_outlined = IconData(0xef31, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">casino</i> — material icon named "casino". + static const IconData casino = IconData(0xe143, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">casino</i> — material icon named "casino" (sharp). + static const IconData casino_sharp = IconData(0xe840, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">casino</i> — material icon named "casino" (round). + static const IconData casino_rounded = IconData(0xf61f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">casino</i> — material icon named "casino" (outlined). + static const IconData casino_outlined = IconData(0xef32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cast</i> — material icon named "cast". + static const IconData cast = IconData(0xe144, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cast</i> — material icon named "cast" (sharp). + static const IconData cast_sharp = IconData(0xe843, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cast</i> — material icon named "cast" (round). + static const IconData cast_rounded = IconData(0xf622, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cast</i> — material icon named "cast" (outlined). + static const IconData cast_outlined = IconData(0xef35, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cast_connected</i> — material icon named "cast connected". + static const IconData cast_connected = IconData(0xe145, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cast_connected</i> — material icon named "cast connected" (sharp). + static const IconData cast_connected_sharp = IconData(0xe841, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cast_connected</i> — material icon named "cast connected" (round). + static const IconData cast_connected_rounded = IconData(0xf620, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cast_connected</i> — material icon named "cast connected" (outlined). + static const IconData cast_connected_outlined = IconData(0xef33, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cast_for_education</i> — material icon named "cast for education". + static const IconData cast_for_education = IconData(0xe146, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cast_for_education</i> — material icon named "cast for education" (sharp). + static const IconData cast_for_education_sharp = IconData(0xe842, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cast_for_education</i> — material icon named "cast for education" (round). + static const IconData cast_for_education_rounded = IconData(0xf621, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cast_for_education</i> — material icon named "cast for education" (outlined). + static const IconData cast_for_education_outlined = IconData(0xef34, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">castle</i> — material icon named "castle". + static const IconData castle = IconData(0xf04cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">castle</i> — material icon named "castle" (sharp). + static const IconData castle_sharp = IconData(0xf03d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">castle</i> — material icon named "castle" (round). + static const IconData castle_rounded = IconData(0xf02e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">castle</i> — material icon named "castle" (outlined). + static const IconData castle_outlined = IconData(0xf05c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">catching_pokemon</i> — material icon named "catching pokemon". + static const IconData catching_pokemon = IconData(0xe147, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">catching_pokemon</i> — material icon named "catching pokemon" (sharp). + static const IconData catching_pokemon_sharp = IconData(0xe844, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">catching_pokemon</i> — material icon named "catching pokemon" (round). + static const IconData catching_pokemon_rounded = IconData(0xf623, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">catching_pokemon</i> — material icon named "catching pokemon" (outlined). + static const IconData catching_pokemon_outlined = IconData(0xef36, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">category</i> — material icon named "category". + static const IconData category = IconData(0xe148, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">category</i> — material icon named "category" (sharp). + static const IconData category_sharp = IconData(0xe845, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">category</i> — material icon named "category" (round). + static const IconData category_rounded = IconData(0xf624, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">category</i> — material icon named "category" (outlined). + static const IconData category_outlined = IconData(0xef37, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">celebration</i> — material icon named "celebration". + static const IconData celebration = IconData(0xe149, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">celebration</i> — material icon named "celebration" (sharp). + static const IconData celebration_sharp = IconData(0xe846, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">celebration</i> — material icon named "celebration" (round). + static const IconData celebration_rounded = IconData(0xf625, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">celebration</i> — material icon named "celebration" (outlined). + static const IconData celebration_outlined = IconData(0xef38, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cell_tower</i> — material icon named "cell tower". + static const IconData cell_tower = IconData(0xf04cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cell_tower</i> — material icon named "cell tower" (sharp). + static const IconData cell_tower_sharp = IconData(0xf03d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cell_tower</i> — material icon named "cell tower" (round). + static const IconData cell_tower_rounded = IconData(0xf02e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cell_tower</i> — material icon named "cell tower" (outlined). + static const IconData cell_tower_outlined = IconData(0xf05c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cell_wifi</i> — material icon named "cell wifi". + static const IconData cell_wifi = IconData(0xe14a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cell_wifi</i> — material icon named "cell wifi" (sharp). + static const IconData cell_wifi_sharp = IconData(0xe847, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cell_wifi</i> — material icon named "cell wifi" (round). + static const IconData cell_wifi_rounded = IconData(0xf626, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cell_wifi</i> — material icon named "cell wifi" (outlined). + static const IconData cell_wifi_outlined = IconData(0xef39, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">center_focus_strong</i> — material icon named "center focus strong". + static const IconData center_focus_strong = IconData(0xe14b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">center_focus_strong</i> — material icon named "center focus strong" (sharp). + static const IconData center_focus_strong_sharp = IconData(0xe848, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">center_focus_strong</i> — material icon named "center focus strong" (round). + static const IconData center_focus_strong_rounded = IconData(0xf627, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">center_focus_strong</i> — material icon named "center focus strong" (outlined). + static const IconData center_focus_strong_outlined = IconData( + 0xef3a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">center_focus_weak</i> — material icon named "center focus weak". + static const IconData center_focus_weak = IconData(0xe14c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">center_focus_weak</i> — material icon named "center focus weak" (sharp). + static const IconData center_focus_weak_sharp = IconData(0xe849, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">center_focus_weak</i> — material icon named "center focus weak" (round). + static const IconData center_focus_weak_rounded = IconData(0xf628, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">center_focus_weak</i> — material icon named "center focus weak" (outlined). + static const IconData center_focus_weak_outlined = IconData(0xef3b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chair</i> — material icon named "chair". + static const IconData chair = IconData(0xe14d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">chair</i> — material icon named "chair" (sharp). + static const IconData chair_sharp = IconData(0xe84b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">chair</i> — material icon named "chair" (round). + static const IconData chair_rounded = IconData(0xf62a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">chair</i> — material icon named "chair" (outlined). + static const IconData chair_outlined = IconData(0xef3d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chair_alt</i> — material icon named "chair alt". + static const IconData chair_alt = IconData(0xe14e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">chair_alt</i> — material icon named "chair alt" (sharp). + static const IconData chair_alt_sharp = IconData(0xe84a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">chair_alt</i> — material icon named "chair alt" (round). + static const IconData chair_alt_rounded = IconData(0xf629, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">chair_alt</i> — material icon named "chair alt" (outlined). + static const IconData chair_alt_outlined = IconData(0xef3c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chalet</i> — material icon named "chalet". + static const IconData chalet = IconData(0xe14f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">chalet</i> — material icon named "chalet" (sharp). + static const IconData chalet_sharp = IconData(0xe84c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">chalet</i> — material icon named "chalet" (round). + static const IconData chalet_rounded = IconData(0xf62b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">chalet</i> — material icon named "chalet" (outlined). + static const IconData chalet_outlined = IconData(0xef3e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">change_circle</i> — material icon named "change circle". + static const IconData change_circle = IconData(0xe150, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">change_circle</i> — material icon named "change circle" (sharp). + static const IconData change_circle_sharp = IconData(0xe84d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">change_circle</i> — material icon named "change circle" (round). + static const IconData change_circle_rounded = IconData(0xf62c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">change_circle</i> — material icon named "change circle" (outlined). + static const IconData change_circle_outlined = IconData(0xef3f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">change_history</i> — material icon named "change history". + static const IconData change_history = IconData(0xe151, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">change_history</i> — material icon named "change history" (sharp). + static const IconData change_history_sharp = IconData(0xe84e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">change_history</i> — material icon named "change history" (round). + static const IconData change_history_rounded = IconData(0xf62d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">change_history</i> — material icon named "change history" (outlined). + static const IconData change_history_outlined = IconData(0xef40, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">charging_station</i> — material icon named "charging station". + static const IconData charging_station = IconData(0xe152, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">charging_station</i> — material icon named "charging station" (sharp). + static const IconData charging_station_sharp = IconData(0xe84f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">charging_station</i> — material icon named "charging station" (round). + static const IconData charging_station_rounded = IconData(0xf62e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">charging_station</i> — material icon named "charging station" (outlined). + static const IconData charging_station_outlined = IconData(0xef41, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chat</i> — material icon named "chat". + static const IconData chat = IconData(0xe153, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">chat</i> — material icon named "chat" (sharp). + static const IconData chat_sharp = IconData(0xe852, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">chat</i> — material icon named "chat" (round). + static const IconData chat_rounded = IconData(0xf631, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">chat</i> — material icon named "chat" (outlined). + static const IconData chat_outlined = IconData(0xef44, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chat_bubble</i> — material icon named "chat bubble". + static const IconData chat_bubble = IconData(0xe154, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">chat_bubble</i> — material icon named "chat bubble" (sharp). + static const IconData chat_bubble_sharp = IconData(0xe851, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">chat_bubble</i> — material icon named "chat bubble" (round). + static const IconData chat_bubble_rounded = IconData(0xf630, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">chat_bubble</i> — material icon named "chat bubble" (outlined). + static const IconData chat_bubble_outlined = IconData(0xef43, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chat_bubble_outline</i> — material icon named "chat bubble outline". + static const IconData chat_bubble_outline = IconData(0xe155, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">chat_bubble_outline</i> — material icon named "chat bubble outline" (sharp). + static const IconData chat_bubble_outline_sharp = IconData(0xe850, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">chat_bubble_outline</i> — material icon named "chat bubble outline" (round). + static const IconData chat_bubble_outline_rounded = IconData(0xf62f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">chat_bubble_outline</i> — material icon named "chat bubble outline" (outlined). + static const IconData chat_bubble_outline_outlined = IconData( + 0xef42, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">check</i> — material icon named "check". + static const IconData check = IconData(0xe156, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">check</i> — material icon named "check" (sharp). + static const IconData check_sharp = IconData(0xe857, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">check</i> — material icon named "check" (round). + static const IconData check_rounded = IconData(0xf636, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">check</i> — material icon named "check" (outlined). + static const IconData check_outlined = IconData(0xef49, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">check_box</i> — material icon named "check box". + static const IconData check_box = IconData(0xe157, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">check_box</i> — material icon named "check box" (sharp). + static const IconData check_box_sharp = IconData(0xe854, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">check_box</i> — material icon named "check box" (round). + static const IconData check_box_rounded = IconData(0xf633, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">check_box</i> — material icon named "check box" (outlined). + static const IconData check_box_outlined = IconData(0xef46, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">check_box_outline_blank</i> — material icon named "check box outline blank". + static const IconData check_box_outline_blank = IconData(0xe158, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">check_box_outline_blank</i> — material icon named "check box outline blank" (sharp). + static const IconData check_box_outline_blank_sharp = IconData( + 0xe853, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">check_box_outline_blank</i> — material icon named "check box outline blank" (round). + static const IconData check_box_outline_blank_rounded = IconData( + 0xf632, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">check_box_outline_blank</i> — material icon named "check box outline blank" (outlined). + static const IconData check_box_outline_blank_outlined = IconData( + 0xef45, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">check_circle</i> — material icon named "check circle". + static const IconData check_circle = IconData(0xe159, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">check_circle</i> — material icon named "check circle" (sharp). + static const IconData check_circle_sharp = IconData(0xe856, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">check_circle</i> — material icon named "check circle" (round). + static const IconData check_circle_rounded = IconData(0xf635, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">check_circle</i> — material icon named "check circle" (outlined). + static const IconData check_circle_outlined = IconData(0xef48, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">check_circle_outline</i> — material icon named "check circle outline". + static const IconData check_circle_outline = IconData(0xe15a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">check_circle_outline</i> — material icon named "check circle outline" (sharp). + static const IconData check_circle_outline_sharp = IconData(0xe855, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">check_circle_outline</i> — material icon named "check circle outline" (round). + static const IconData check_circle_outline_rounded = IconData( + 0xf634, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">check_circle_outline</i> — material icon named "check circle outline" (outlined). + static const IconData check_circle_outline_outlined = IconData( + 0xef47, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">checklist</i> — material icon named "checklist". + static const IconData checklist = IconData(0xe15b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">checklist</i> — material icon named "checklist" (sharp). + static const IconData checklist_sharp = IconData(0xe859, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">checklist</i> — material icon named "checklist" (round). + static const IconData checklist_rounded = IconData(0xf637, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">checklist</i> — material icon named "checklist" (outlined). + static const IconData checklist_outlined = IconData(0xef4a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">checklist_rtl</i> — material icon named "checklist rtl". + static const IconData checklist_rtl = IconData(0xe15c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">checklist_rtl</i> — material icon named "checklist rtl" (sharp). + static const IconData checklist_rtl_sharp = IconData(0xe858, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">checklist_rtl</i> — material icon named "checklist rtl" (round). + static const IconData checklist_rtl_rounded = IconData(0xf638, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">checklist_rtl</i> — material icon named "checklist rtl" (outlined). + static const IconData checklist_rtl_outlined = IconData(0xef4b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">checkroom</i> — material icon named "checkroom". + static const IconData checkroom = IconData(0xe15d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">checkroom</i> — material icon named "checkroom" (sharp). + static const IconData checkroom_sharp = IconData(0xe85a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">checkroom</i> — material icon named "checkroom" (round). + static const IconData checkroom_rounded = IconData(0xf639, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">checkroom</i> — material icon named "checkroom" (outlined). + static const IconData checkroom_outlined = IconData(0xef4c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chevron_left</i> — material icon named "chevron left". + static const IconData chevron_left = IconData( + 0xe15e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">chevron_left</i> — material icon named "chevron left" (sharp). + static const IconData chevron_left_sharp = IconData( + 0xe85b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">chevron_left</i> — material icon named "chevron left" (round). + static const IconData chevron_left_rounded = IconData( + 0xf63a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">chevron_left</i> — material icon named "chevron left" (outlined). + static const IconData chevron_left_outlined = IconData( + 0xef4d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">chevron_right</i> — material icon named "chevron right". + static const IconData chevron_right = IconData( + 0xe15f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">chevron_right</i> — material icon named "chevron right" (sharp). + static const IconData chevron_right_sharp = IconData( + 0xe85c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">chevron_right</i> — material icon named "chevron right" (round). + static const IconData chevron_right_rounded = IconData( + 0xf63b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">chevron_right</i> — material icon named "chevron right" (outlined). + static const IconData chevron_right_outlined = IconData( + 0xef4e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">child_care</i> — material icon named "child care". + static const IconData child_care = IconData(0xe160, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">child_care</i> — material icon named "child care" (sharp). + static const IconData child_care_sharp = IconData(0xe85d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">child_care</i> — material icon named "child care" (round). + static const IconData child_care_rounded = IconData(0xf63c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">child_care</i> — material icon named "child care" (outlined). + static const IconData child_care_outlined = IconData(0xef4f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">child_friendly</i> — material icon named "child friendly". + static const IconData child_friendly = IconData(0xe161, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">child_friendly</i> — material icon named "child friendly" (sharp). + static const IconData child_friendly_sharp = IconData(0xe85e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">child_friendly</i> — material icon named "child friendly" (round). + static const IconData child_friendly_rounded = IconData(0xf63d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">child_friendly</i> — material icon named "child friendly" (outlined). + static const IconData child_friendly_outlined = IconData(0xef50, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">chrome_reader_mode</i> — material icon named "chrome reader mode". + static const IconData chrome_reader_mode = IconData( + 0xe162, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">chrome_reader_mode</i> — material icon named "chrome reader mode" (sharp). + static const IconData chrome_reader_mode_sharp = IconData( + 0xe85f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">chrome_reader_mode</i> — material icon named "chrome reader mode" (round). + static const IconData chrome_reader_mode_rounded = IconData( + 0xf63e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">chrome_reader_mode</i> — material icon named "chrome reader mode" (outlined). + static const IconData chrome_reader_mode_outlined = IconData( + 0xef51, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">church</i> — material icon named "church". + static const IconData church = IconData(0xf04cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">church</i> — material icon named "church" (sharp). + static const IconData church_sharp = IconData(0xf03da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">church</i> — material icon named "church" (round). + static const IconData church_rounded = IconData(0xf02e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">church</i> — material icon named "church" (outlined). + static const IconData church_outlined = IconData(0xf05c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">circle</i> — material icon named "circle". + static const IconData circle = IconData(0xe163, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">circle</i> — material icon named "circle" (sharp). + static const IconData circle_sharp = IconData(0xe861, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">circle</i> — material icon named "circle" (round). + static const IconData circle_rounded = IconData(0xf640, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">circle</i> — material icon named "circle" (outlined). + static const IconData circle_outlined = IconData(0xef53, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">circle_notifications</i> — material icon named "circle notifications". + static const IconData circle_notifications = IconData(0xe164, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">circle_notifications</i> — material icon named "circle notifications" (sharp). + static const IconData circle_notifications_sharp = IconData(0xe860, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">circle_notifications</i> — material icon named "circle notifications" (round). + static const IconData circle_notifications_rounded = IconData( + 0xf63f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">circle_notifications</i> — material icon named "circle notifications" (outlined). + static const IconData circle_notifications_outlined = IconData( + 0xef52, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">class</i> — material icon named "class". + static const IconData class_ = IconData(0xe165, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">class</i> — material icon named "class" (sharp). + static const IconData class_sharp = IconData(0xe862, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">class</i> — material icon named "class" (round). + static const IconData class_rounded = IconData(0xf641, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">class</i> — material icon named "class" (outlined). + static const IconData class_outlined = IconData(0xef54, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">clean_hands</i> — material icon named "clean hands". + static const IconData clean_hands = IconData(0xe166, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">clean_hands</i> — material icon named "clean hands" (sharp). + static const IconData clean_hands_sharp = IconData(0xe863, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">clean_hands</i> — material icon named "clean hands" (round). + static const IconData clean_hands_rounded = IconData(0xf642, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">clean_hands</i> — material icon named "clean hands" (outlined). + static const IconData clean_hands_outlined = IconData(0xef55, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cleaning_services</i> — material icon named "cleaning services". + static const IconData cleaning_services = IconData(0xe167, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cleaning_services</i> — material icon named "cleaning services" (sharp). + static const IconData cleaning_services_sharp = IconData(0xe864, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cleaning_services</i> — material icon named "cleaning services" (round). + static const IconData cleaning_services_rounded = IconData(0xf643, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cleaning_services</i> — material icon named "cleaning services" (outlined). + static const IconData cleaning_services_outlined = IconData(0xef56, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">clear</i> — material icon named "clear". + static const IconData clear = IconData(0xe168, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">clear</i> — material icon named "clear" (sharp). + static const IconData clear_sharp = IconData(0xe866, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">clear</i> — material icon named "clear" (round). + static const IconData clear_rounded = IconData(0xf645, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">clear</i> — material icon named "clear" (outlined). + static const IconData clear_outlined = IconData(0xef58, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">clear_all</i> — material icon named "clear all". + static const IconData clear_all = IconData(0xe169, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">clear_all</i> — material icon named "clear all" (sharp). + static const IconData clear_all_sharp = IconData(0xe865, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">clear_all</i> — material icon named "clear all" (round). + static const IconData clear_all_rounded = IconData(0xf644, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">clear_all</i> — material icon named "clear all" (outlined). + static const IconData clear_all_outlined = IconData(0xef57, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">close</i> — material icon named "close". + static const IconData close = IconData(0xe16a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">close</i> — material icon named "close" (sharp). + static const IconData close_sharp = IconData(0xe868, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">close</i> — material icon named "close" (round). + static const IconData close_rounded = IconData(0xf647, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">close</i> — material icon named "close" (outlined). + static const IconData close_outlined = IconData(0xef5a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">close_fullscreen</i> — material icon named "close fullscreen". + static const IconData close_fullscreen = IconData(0xe16b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">close_fullscreen</i> — material icon named "close fullscreen" (sharp). + static const IconData close_fullscreen_sharp = IconData(0xe867, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">close_fullscreen</i> — material icon named "close fullscreen" (round). + static const IconData close_fullscreen_rounded = IconData(0xf646, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">close_fullscreen</i> — material icon named "close fullscreen" (outlined). + static const IconData close_fullscreen_outlined = IconData(0xef59, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">closed_caption</i> — material icon named "closed caption". + static const IconData closed_caption = IconData(0xe16c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">closed_caption</i> — material icon named "closed caption" (sharp). + static const IconData closed_caption_sharp = IconData(0xe86b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">closed_caption</i> — material icon named "closed caption" (round). + static const IconData closed_caption_rounded = IconData(0xf64a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">closed_caption</i> — material icon named "closed caption" (outlined). + static const IconData closed_caption_outlined = IconData(0xef5d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">closed_caption_disabled</i> — material icon named "closed caption disabled". + static const IconData closed_caption_disabled = IconData(0xe16d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">closed_caption_disabled</i> — material icon named "closed caption disabled" (sharp). + static const IconData closed_caption_disabled_sharp = IconData( + 0xe869, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">closed_caption_disabled</i> — material icon named "closed caption disabled" (round). + static const IconData closed_caption_disabled_rounded = IconData( + 0xf648, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">closed_caption_disabled</i> — material icon named "closed caption disabled" (outlined). + static const IconData closed_caption_disabled_outlined = IconData( + 0xef5b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">closed_caption_off</i> — material icon named "closed caption off". + static const IconData closed_caption_off = IconData(0xe16e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">closed_caption_off</i> — material icon named "closed caption off" (sharp). + static const IconData closed_caption_off_sharp = IconData(0xe86a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">closed_caption_off</i> — material icon named "closed caption off" (round). + static const IconData closed_caption_off_rounded = IconData(0xf649, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">closed_caption_off</i> — material icon named "closed caption off" (outlined). + static const IconData closed_caption_off_outlined = IconData(0xef5c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud</i> — material icon named "cloud". + static const IconData cloud = IconData(0xe16f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud</i> — material icon named "cloud" (sharp). + static const IconData cloud_sharp = IconData(0xe871, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud</i> — material icon named "cloud" (round). + static const IconData cloud_rounded = IconData(0xf650, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud</i> — material icon named "cloud" (outlined). + static const IconData cloud_outlined = IconData(0xef62, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_circle</i> — material icon named "cloud circle". + static const IconData cloud_circle = IconData(0xe170, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_circle</i> — material icon named "cloud circle" (sharp). + static const IconData cloud_circle_sharp = IconData(0xe86c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_circle</i> — material icon named "cloud circle" (round). + static const IconData cloud_circle_rounded = IconData(0xf64b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_circle</i> — material icon named "cloud circle" (outlined). + static const IconData cloud_circle_outlined = IconData(0xef5e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_done</i> — material icon named "cloud done". + static const IconData cloud_done = IconData(0xe171, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_done</i> — material icon named "cloud done" (sharp). + static const IconData cloud_done_sharp = IconData(0xe86d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_done</i> — material icon named "cloud done" (round). + static const IconData cloud_done_rounded = IconData(0xf64c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_done</i> — material icon named "cloud done" (outlined). + static const IconData cloud_done_outlined = IconData(0xef5f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_download</i> — material icon named "cloud download". + static const IconData cloud_download = IconData(0xe172, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_download</i> — material icon named "cloud download" (sharp). + static const IconData cloud_download_sharp = IconData(0xe86e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_download</i> — material icon named "cloud download" (round). + static const IconData cloud_download_rounded = IconData(0xf64d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_download</i> — material icon named "cloud download" (outlined). + static const IconData cloud_download_outlined = IconData(0xef60, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_off</i> — material icon named "cloud off". + static const IconData cloud_off = IconData(0xe173, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_off</i> — material icon named "cloud off" (sharp). + static const IconData cloud_off_sharp = IconData(0xe86f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_off</i> — material icon named "cloud off" (round). + static const IconData cloud_off_rounded = IconData(0xf64e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_off</i> — material icon named "cloud off" (outlined). + static const IconData cloud_off_outlined = IconData(0xef61, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_queue</i> — material icon named "cloud queue". + static const IconData cloud_queue = IconData(0xe174, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_queue</i> — material icon named "cloud queue" (sharp). + static const IconData cloud_queue_sharp = IconData(0xe870, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_queue</i> — material icon named "cloud queue" (round). + static const IconData cloud_queue_rounded = IconData(0xf64f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_queue</i> — material icon named "cloud queue" (outlined). + static const IconData cloud_queue_outlined = IconData(0xef63, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_sync</i> — material icon named "cloud sync". + static const IconData cloud_sync = IconData(0xf04ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_sync</i> — material icon named "cloud sync" (sharp). + static const IconData cloud_sync_sharp = IconData(0xf03db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_sync</i> — material icon named "cloud sync" (round). + static const IconData cloud_sync_rounded = IconData(0xf02e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_sync</i> — material icon named "cloud sync" (outlined). + static const IconData cloud_sync_outlined = IconData(0xf05c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloud_upload</i> — material icon named "cloud upload". + static const IconData cloud_upload = IconData(0xe175, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cloud_upload</i> — material icon named "cloud upload" (sharp). + static const IconData cloud_upload_sharp = IconData(0xe872, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cloud_upload</i> — material icon named "cloud upload" (round). + static const IconData cloud_upload_rounded = IconData(0xf651, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cloud_upload</i> — material icon named "cloud upload" (outlined). + static const IconData cloud_upload_outlined = IconData(0xef64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cloudy_snowing</i> — material icon named "cloudy snowing". + static const IconData cloudy_snowing = IconData(0xf04cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">co2</i> — material icon named "co2". + static const IconData co2 = IconData(0xf04d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">co2</i> — material icon named "co2" (sharp). + static const IconData co2_sharp = IconData(0xf03dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">co2</i> — material icon named "co2" (round). + static const IconData co2_rounded = IconData(0xf02e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">co2</i> — material icon named "co2" (outlined). + static const IconData co2_outlined = IconData(0xf05ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">co_present</i> — material icon named "co present". + static const IconData co_present = IconData(0xf04d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">co_present</i> — material icon named "co present" (sharp). + static const IconData co_present_sharp = IconData(0xf03dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">co_present</i> — material icon named "co present" (round). + static const IconData co_present_rounded = IconData(0xf02ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">co_present</i> — material icon named "co present" (outlined). + static const IconData co_present_outlined = IconData(0xf05cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">code</i> — material icon named "code". + static const IconData code = IconData(0xe176, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">code</i> — material icon named "code" (sharp). + static const IconData code_sharp = IconData(0xe874, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">code</i> — material icon named "code" (round). + static const IconData code_rounded = IconData(0xf653, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">code</i> — material icon named "code" (outlined). + static const IconData code_outlined = IconData(0xef66, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">code_off</i> — material icon named "code off". + static const IconData code_off = IconData(0xe177, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">code_off</i> — material icon named "code off" (sharp). + static const IconData code_off_sharp = IconData(0xe873, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">code_off</i> — material icon named "code off" (round). + static const IconData code_off_rounded = IconData(0xf652, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">code_off</i> — material icon named "code off" (outlined). + static const IconData code_off_outlined = IconData(0xef65, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">coffee</i> — material icon named "coffee". + static const IconData coffee = IconData(0xe178, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">coffee</i> — material icon named "coffee" (sharp). + static const IconData coffee_sharp = IconData(0xe876, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">coffee</i> — material icon named "coffee" (round). + static const IconData coffee_rounded = IconData(0xf655, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">coffee</i> — material icon named "coffee" (outlined). + static const IconData coffee_outlined = IconData(0xef68, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">coffee_maker</i> — material icon named "coffee maker". + static const IconData coffee_maker = IconData(0xe179, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">coffee_maker</i> — material icon named "coffee maker" (sharp). + static const IconData coffee_maker_sharp = IconData(0xe875, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">coffee_maker</i> — material icon named "coffee maker" (round). + static const IconData coffee_maker_rounded = IconData(0xf654, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">coffee_maker</i> — material icon named "coffee maker" (outlined). + static const IconData coffee_maker_outlined = IconData(0xef67, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">collections</i> — material icon named "collections". + static const IconData collections = IconData(0xe17a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">collections</i> — material icon named "collections" (sharp). + static const IconData collections_sharp = IconData(0xe878, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">collections</i> — material icon named "collections" (round). + static const IconData collections_rounded = IconData(0xf657, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">collections</i> — material icon named "collections" (outlined). + static const IconData collections_outlined = IconData(0xef6a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">collections_bookmark</i> — material icon named "collections bookmark". + static const IconData collections_bookmark = IconData(0xe17b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">collections_bookmark</i> — material icon named "collections bookmark" (sharp). + static const IconData collections_bookmark_sharp = IconData(0xe877, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">collections_bookmark</i> — material icon named "collections bookmark" (round). + static const IconData collections_bookmark_rounded = IconData( + 0xf656, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">collections_bookmark</i> — material icon named "collections bookmark" (outlined). + static const IconData collections_bookmark_outlined = IconData( + 0xef69, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">color_lens</i> — material icon named "color lens". + static const IconData color_lens = IconData(0xe17c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">color_lens</i> — material icon named "color lens" (sharp). + static const IconData color_lens_sharp = IconData(0xe879, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">color_lens</i> — material icon named "color lens" (round). + static const IconData color_lens_rounded = IconData(0xf658, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">color_lens</i> — material icon named "color lens" (outlined). + static const IconData color_lens_outlined = IconData(0xef6b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">colorize</i> — material icon named "colorize". + static const IconData colorize = IconData(0xe17d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">colorize</i> — material icon named "colorize" (sharp). + static const IconData colorize_sharp = IconData(0xe87a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">colorize</i> — material icon named "colorize" (round). + static const IconData colorize_rounded = IconData(0xf659, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">colorize</i> — material icon named "colorize" (outlined). + static const IconData colorize_outlined = IconData(0xef6c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">comment</i> — material icon named "comment". + static const IconData comment = IconData(0xe17e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">comment</i> — material icon named "comment" (sharp). + static const IconData comment_sharp = IconData(0xe87c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">comment</i> — material icon named "comment" (round). + static const IconData comment_rounded = IconData(0xf65b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">comment</i> — material icon named "comment" (outlined). + static const IconData comment_outlined = IconData(0xef6e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">comment_bank</i> — material icon named "comment bank". + static const IconData comment_bank = IconData(0xe17f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">comment_bank</i> — material icon named "comment bank" (sharp). + static const IconData comment_bank_sharp = IconData(0xe87b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">comment_bank</i> — material icon named "comment bank" (round). + static const IconData comment_bank_rounded = IconData(0xf65a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">comment_bank</i> — material icon named "comment bank" (outlined). + static const IconData comment_bank_outlined = IconData(0xef6d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">comments_disabled</i> — material icon named "comments disabled". + static const IconData comments_disabled = IconData(0xf04d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">comments_disabled</i> — material icon named "comments disabled" (sharp). + static const IconData comments_disabled_sharp = IconData(0xf03de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">comments_disabled</i> — material icon named "comments disabled" (round). + static const IconData comments_disabled_rounded = IconData(0xf02eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">comments_disabled</i> — material icon named "comments disabled" (outlined). + static const IconData comments_disabled_outlined = IconData(0xf05cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">commit</i> — material icon named "commit". + static const IconData commit = IconData(0xf04d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">commit</i> — material icon named "commit" (sharp). + static const IconData commit_sharp = IconData(0xf03df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">commit</i> — material icon named "commit" (round). + static const IconData commit_rounded = IconData(0xf02ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">commit</i> — material icon named "commit" (outlined). + static const IconData commit_outlined = IconData(0xf05cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">commute</i> — material icon named "commute". + static const IconData commute = IconData(0xe180, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">commute</i> — material icon named "commute" (sharp). + static const IconData commute_sharp = IconData(0xe87d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">commute</i> — material icon named "commute" (round). + static const IconData commute_rounded = IconData(0xf65c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">commute</i> — material icon named "commute" (outlined). + static const IconData commute_outlined = IconData(0xef6f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">compare</i> — material icon named "compare". + static const IconData compare = IconData(0xe181, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">compare</i> — material icon named "compare" (sharp). + static const IconData compare_sharp = IconData(0xe87f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">compare</i> — material icon named "compare" (round). + static const IconData compare_rounded = IconData(0xf65e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">compare</i> — material icon named "compare" (outlined). + static const IconData compare_outlined = IconData(0xef71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">compare_arrows</i> — material icon named "compare arrows". + static const IconData compare_arrows = IconData(0xe182, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">compare_arrows</i> — material icon named "compare arrows" (sharp). + static const IconData compare_arrows_sharp = IconData(0xe87e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">compare_arrows</i> — material icon named "compare arrows" (round). + static const IconData compare_arrows_rounded = IconData(0xf65d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">compare_arrows</i> — material icon named "compare arrows" (outlined). + static const IconData compare_arrows_outlined = IconData(0xef70, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">compass_calibration</i> — material icon named "compass calibration". + static const IconData compass_calibration = IconData(0xe183, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">compass_calibration</i> — material icon named "compass calibration" (sharp). + static const IconData compass_calibration_sharp = IconData(0xe880, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">compass_calibration</i> — material icon named "compass calibration" (round). + static const IconData compass_calibration_rounded = IconData(0xf65f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">compass_calibration</i> — material icon named "compass calibration" (outlined). + static const IconData compass_calibration_outlined = IconData( + 0xef72, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">compost</i> — material icon named "compost". + static const IconData compost = IconData(0xf04d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">compost</i> — material icon named "compost" (sharp). + static const IconData compost_sharp = IconData(0xf03e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">compost</i> — material icon named "compost" (round). + static const IconData compost_rounded = IconData(0xf02ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">compost</i> — material icon named "compost" (outlined). + static const IconData compost_outlined = IconData(0xf05ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">compress</i> — material icon named "compress". + static const IconData compress = IconData(0xe184, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">compress</i> — material icon named "compress" (sharp). + static const IconData compress_sharp = IconData(0xe881, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">compress</i> — material icon named "compress" (round). + static const IconData compress_rounded = IconData(0xf660, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">compress</i> — material icon named "compress" (outlined). + static const IconData compress_outlined = IconData(0xef73, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">computer</i> — material icon named "computer". + static const IconData computer = IconData(0xe185, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">computer</i> — material icon named "computer" (sharp). + static const IconData computer_sharp = IconData(0xe882, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">computer</i> — material icon named "computer" (round). + static const IconData computer_rounded = IconData(0xf661, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">computer</i> — material icon named "computer" (outlined). + static const IconData computer_outlined = IconData(0xef74, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">confirmation_num</i> — material icon named "confirmation num". + static const IconData confirmation_num = IconData(0xe186, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">confirmation_num</i> — material icon named "confirmation num" (sharp). + static const IconData confirmation_num_sharp = IconData(0xe883, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">confirmation_num</i> — material icon named "confirmation num" (round). + static const IconData confirmation_num_rounded = IconData(0xf662, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">confirmation_num</i> — material icon named "confirmation num" (outlined). + static const IconData confirmation_num_outlined = IconData(0xef75, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">confirmation_number</i> — material icon named "confirmation number". + static const IconData confirmation_number = IconData(0xe186, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">confirmation_number</i> — material icon named "confirmation number" (sharp). + static const IconData confirmation_number_sharp = IconData(0xe883, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">confirmation_number</i> — material icon named "confirmation number" (round). + static const IconData confirmation_number_rounded = IconData(0xf662, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">confirmation_number</i> — material icon named "confirmation number" (outlined). + static const IconData confirmation_number_outlined = IconData( + 0xef75, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">connect_without_contact</i> — material icon named "connect without contact". + static const IconData connect_without_contact = IconData(0xe187, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">connect_without_contact</i> — material icon named "connect without contact" (sharp). + static const IconData connect_without_contact_sharp = IconData( + 0xe884, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">connect_without_contact</i> — material icon named "connect without contact" (round). + static const IconData connect_without_contact_rounded = IconData( + 0xf663, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">connect_without_contact</i> — material icon named "connect without contact" (outlined). + static const IconData connect_without_contact_outlined = IconData( + 0xef76, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">connected_tv</i> — material icon named "connected tv". + static const IconData connected_tv = IconData(0xe188, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">connected_tv</i> — material icon named "connected tv" (sharp). + static const IconData connected_tv_sharp = IconData(0xe885, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">connected_tv</i> — material icon named "connected tv" (round). + static const IconData connected_tv_rounded = IconData(0xf664, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">connected_tv</i> — material icon named "connected tv" (outlined). + static const IconData connected_tv_outlined = IconData(0xef77, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">connecting_airports</i> — material icon named "connecting airports". + static const IconData connecting_airports = IconData(0xf04d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">connecting_airports</i> — material icon named "connecting airports" (sharp). + static const IconData connecting_airports_sharp = IconData(0xf03e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">connecting_airports</i> — material icon named "connecting airports" (round). + static const IconData connecting_airports_rounded = IconData( + 0xf02ee, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">connecting_airports</i> — material icon named "connecting airports" (outlined). + static const IconData connecting_airports_outlined = IconData( + 0xf05cf, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">construction</i> — material icon named "construction". + static const IconData construction = IconData(0xe189, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">construction</i> — material icon named "construction" (sharp). + static const IconData construction_sharp = IconData(0xe886, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">construction</i> — material icon named "construction" (round). + static const IconData construction_rounded = IconData(0xf665, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">construction</i> — material icon named "construction" (outlined). + static const IconData construction_outlined = IconData(0xef78, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contact_emergency</i> — material icon named "contact emergency". + static const IconData contact_emergency = IconData(0xf0857, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contact_emergency</i> — material icon named "contact emergency" (sharp). + static const IconData contact_emergency_sharp = IconData(0xf0837, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contact_emergency</i> — material icon named "contact emergency" (round). + static const IconData contact_emergency_rounded = IconData(0xf0880, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contact_emergency</i> — material icon named "contact emergency" (outlined). + static const IconData contact_emergency_outlined = IconData(0xf089e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contact_mail</i> — material icon named "contact mail". + static const IconData contact_mail = IconData(0xe18a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contact_mail</i> — material icon named "contact mail" (sharp). + static const IconData contact_mail_sharp = IconData(0xe887, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contact_mail</i> — material icon named "contact mail" (round). + static const IconData contact_mail_rounded = IconData(0xf666, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contact_mail</i> — material icon named "contact mail" (outlined). + static const IconData contact_mail_outlined = IconData(0xef79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contact_page</i> — material icon named "contact page". + static const IconData contact_page = IconData(0xe18b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contact_page</i> — material icon named "contact page" (sharp). + static const IconData contact_page_sharp = IconData(0xe888, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contact_page</i> — material icon named "contact page" (round). + static const IconData contact_page_rounded = IconData(0xf667, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contact_page</i> — material icon named "contact page" (outlined). + static const IconData contact_page_outlined = IconData(0xef7a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contact_phone</i> — material icon named "contact phone". + static const IconData contact_phone = IconData(0xe18c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contact_phone</i> — material icon named "contact phone" (sharp). + static const IconData contact_phone_sharp = IconData(0xe889, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contact_phone</i> — material icon named "contact phone" (round). + static const IconData contact_phone_rounded = IconData(0xf668, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contact_phone</i> — material icon named "contact phone" (outlined). + static const IconData contact_phone_outlined = IconData(0xef7b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contact_support</i> — material icon named "contact support". + static const IconData contact_support = IconData(0xe18d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contact_support</i> — material icon named "contact support" (sharp). + static const IconData contact_support_sharp = IconData(0xe88a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contact_support</i> — material icon named "contact support" (round). + static const IconData contact_support_rounded = IconData(0xf669, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contact_support</i> — material icon named "contact support" (outlined). + static const IconData contact_support_outlined = IconData(0xef7c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contactless</i> — material icon named "contactless". + static const IconData contactless = IconData(0xe18e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contactless</i> — material icon named "contactless" (sharp). + static const IconData contactless_sharp = IconData(0xe88b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contactless</i> — material icon named "contactless" (round). + static const IconData contactless_rounded = IconData(0xf66a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contactless</i> — material icon named "contactless" (outlined). + static const IconData contactless_outlined = IconData(0xef7d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">contacts</i> — material icon named "contacts". + static const IconData contacts = IconData(0xe18f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contacts</i> — material icon named "contacts" (sharp). + static const IconData contacts_sharp = IconData(0xe88c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contacts</i> — material icon named "contacts" (round). + static const IconData contacts_rounded = IconData(0xf66b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contacts</i> — material icon named "contacts" (outlined). + static const IconData contacts_outlined = IconData(0xef7e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">content_copy</i> — material icon named "content copy". + static const IconData content_copy = IconData(0xe190, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">content_copy</i> — material icon named "content copy" (sharp). + static const IconData content_copy_sharp = IconData(0xe88d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">content_copy</i> — material icon named "content copy" (round). + static const IconData content_copy_rounded = IconData(0xf66c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">content_copy</i> — material icon named "content copy" (outlined). + static const IconData content_copy_outlined = IconData(0xef7f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">content_cut</i> — material icon named "content cut". + static const IconData content_cut = IconData(0xe191, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">content_cut</i> — material icon named "content cut" (sharp). + static const IconData content_cut_sharp = IconData(0xe88e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">content_cut</i> — material icon named "content cut" (round). + static const IconData content_cut_rounded = IconData(0xf66d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">content_cut</i> — material icon named "content cut" (outlined). + static const IconData content_cut_outlined = IconData(0xef80, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">content_paste</i> — material icon named "content paste". + static const IconData content_paste = IconData(0xe192, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">content_paste</i> — material icon named "content paste" (sharp). + static const IconData content_paste_sharp = IconData(0xe890, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">content_paste</i> — material icon named "content paste" (round). + static const IconData content_paste_rounded = IconData(0xf66f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">content_paste</i> — material icon named "content paste" (outlined). + static const IconData content_paste_outlined = IconData(0xef82, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">content_paste_go</i> — material icon named "content paste go". + static const IconData content_paste_go = IconData(0xf04d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">content_paste_go</i> — material icon named "content paste go" (sharp). + static const IconData content_paste_go_sharp = IconData(0xf03e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">content_paste_go</i> — material icon named "content paste go" (round). + static const IconData content_paste_go_rounded = IconData(0xf02ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">content_paste_go</i> — material icon named "content paste go" (outlined). + static const IconData content_paste_go_outlined = IconData(0xf05d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">content_paste_off</i> — material icon named "content paste off". + static const IconData content_paste_off = IconData(0xe193, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">content_paste_off</i> — material icon named "content paste off" (sharp). + static const IconData content_paste_off_sharp = IconData(0xe88f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">content_paste_off</i> — material icon named "content paste off" (round). + static const IconData content_paste_off_rounded = IconData(0xf66e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">content_paste_off</i> — material icon named "content paste off" (outlined). + static const IconData content_paste_off_outlined = IconData(0xef81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">content_paste_search</i> — material icon named "content paste search". + static const IconData content_paste_search = IconData(0xf04d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">content_paste_search</i> — material icon named "content paste search" (sharp). + static const IconData content_paste_search_sharp = IconData(0xf03e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">content_paste_search</i> — material icon named "content paste search" (round). + static const IconData content_paste_search_rounded = IconData( + 0xf02f0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">content_paste_search</i> — material icon named "content paste search" (outlined). + static const IconData content_paste_search_outlined = IconData( + 0xf05d1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">contrast</i> — material icon named "contrast". + static const IconData contrast = IconData(0xf04d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">contrast</i> — material icon named "contrast" (sharp). + static const IconData contrast_sharp = IconData(0xf03e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">contrast</i> — material icon named "contrast" (round). + static const IconData contrast_rounded = IconData(0xf02f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">contrast</i> — material icon named "contrast" (outlined). + static const IconData contrast_outlined = IconData(0xf05d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">control_camera</i> — material icon named "control camera". + static const IconData control_camera = IconData(0xe194, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">control_camera</i> — material icon named "control camera" (sharp). + static const IconData control_camera_sharp = IconData(0xe891, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">control_camera</i> — material icon named "control camera" (round). + static const IconData control_camera_rounded = IconData(0xf670, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">control_camera</i> — material icon named "control camera" (outlined). + static const IconData control_camera_outlined = IconData(0xef83, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">control_point</i> — material icon named "control point". + static const IconData control_point = IconData(0xe195, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">control_point</i> — material icon named "control point" (sharp). + static const IconData control_point_sharp = IconData(0xe893, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">control_point</i> — material icon named "control point" (round). + static const IconData control_point_rounded = IconData(0xf672, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">control_point</i> — material icon named "control point" (outlined). + static const IconData control_point_outlined = IconData(0xef85, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">control_point_duplicate</i> — material icon named "control point duplicate". + static const IconData control_point_duplicate = IconData(0xe196, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">control_point_duplicate</i> — material icon named "control point duplicate" (sharp). + static const IconData control_point_duplicate_sharp = IconData( + 0xe892, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">control_point_duplicate</i> — material icon named "control point duplicate" (round). + static const IconData control_point_duplicate_rounded = IconData( + 0xf671, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">control_point_duplicate</i> — material icon named "control point duplicate" (outlined). + static const IconData control_point_duplicate_outlined = IconData( + 0xef84, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">conveyor_belt</i> — material icon named "conveyor belt". + static const IconData conveyor_belt = IconData(0xf0858, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cookie</i> — material icon named "cookie". + static const IconData cookie = IconData(0xf04d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cookie</i> — material icon named "cookie" (sharp). + static const IconData cookie_sharp = IconData(0xf03e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cookie</i> — material icon named "cookie" (round). + static const IconData cookie_rounded = IconData(0xf02f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cookie</i> — material icon named "cookie" (outlined). + static const IconData cookie_outlined = IconData(0xf05d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">copy</i> — material icon named "copy". + static const IconData copy = IconData(0xe190, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">copy</i> — material icon named "copy" (sharp). + static const IconData copy_sharp = IconData(0xe88d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">copy</i> — material icon named "copy" (round). + static const IconData copy_rounded = IconData(0xf66c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">copy</i> — material icon named "copy" (outlined). + static const IconData copy_outlined = IconData(0xef7f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">copy_all</i> — material icon named "copy all". + static const IconData copy_all = IconData(0xe197, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">copy_all</i> — material icon named "copy all" (sharp). + static const IconData copy_all_sharp = IconData(0xe894, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">copy_all</i> — material icon named "copy all" (round). + static const IconData copy_all_rounded = IconData(0xf673, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">copy_all</i> — material icon named "copy all" (outlined). + static const IconData copy_all_outlined = IconData(0xef86, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">copyright</i> — material icon named "copyright". + static const IconData copyright = IconData(0xe198, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">copyright</i> — material icon named "copyright" (sharp). + static const IconData copyright_sharp = IconData(0xe895, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">copyright</i> — material icon named "copyright" (round). + static const IconData copyright_rounded = IconData(0xf674, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">copyright</i> — material icon named "copyright" (outlined). + static const IconData copyright_outlined = IconData(0xef87, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">coronavirus</i> — material icon named "coronavirus". + static const IconData coronavirus = IconData(0xe199, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">coronavirus</i> — material icon named "coronavirus" (sharp). + static const IconData coronavirus_sharp = IconData(0xe896, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">coronavirus</i> — material icon named "coronavirus" (round). + static const IconData coronavirus_rounded = IconData(0xf675, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">coronavirus</i> — material icon named "coronavirus" (outlined). + static const IconData coronavirus_outlined = IconData(0xef88, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">corporate_fare</i> — material icon named "corporate fare". + static const IconData corporate_fare = IconData(0xe19a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">corporate_fare</i> — material icon named "corporate fare" (sharp). + static const IconData corporate_fare_sharp = IconData(0xe897, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">corporate_fare</i> — material icon named "corporate fare" (round). + static const IconData corporate_fare_rounded = IconData(0xf676, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">corporate_fare</i> — material icon named "corporate fare" (outlined). + static const IconData corporate_fare_outlined = IconData(0xef89, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cottage</i> — material icon named "cottage". + static const IconData cottage = IconData(0xe19b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cottage</i> — material icon named "cottage" (sharp). + static const IconData cottage_sharp = IconData(0xe898, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cottage</i> — material icon named "cottage" (round). + static const IconData cottage_rounded = IconData(0xf677, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cottage</i> — material icon named "cottage" (outlined). + static const IconData cottage_outlined = IconData(0xef8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">countertops</i> — material icon named "countertops". + static const IconData countertops = IconData(0xe19c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">countertops</i> — material icon named "countertops" (sharp). + static const IconData countertops_sharp = IconData(0xe899, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">countertops</i> — material icon named "countertops" (round). + static const IconData countertops_rounded = IconData(0xf678, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">countertops</i> — material icon named "countertops" (outlined). + static const IconData countertops_outlined = IconData(0xef8b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">create</i> — material icon named "create". + static const IconData create = IconData(0xe19d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">create</i> — material icon named "create" (sharp). + static const IconData create_sharp = IconData(0xe89b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">create</i> — material icon named "create" (round). + static const IconData create_rounded = IconData(0xf67a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">create</i> — material icon named "create" (outlined). + static const IconData create_outlined = IconData(0xef8d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">create_new_folder</i> — material icon named "create new folder". + static const IconData create_new_folder = IconData(0xe19e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">create_new_folder</i> — material icon named "create new folder" (sharp). + static const IconData create_new_folder_sharp = IconData(0xe89a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">create_new_folder</i> — material icon named "create new folder" (round). + static const IconData create_new_folder_rounded = IconData(0xf679, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">create_new_folder</i> — material icon named "create new folder" (outlined). + static const IconData create_new_folder_outlined = IconData(0xef8c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">credit_card</i> — material icon named "credit card". + static const IconData credit_card = IconData(0xe19f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">credit_card</i> — material icon named "credit card" (sharp). + static const IconData credit_card_sharp = IconData(0xe89d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">credit_card</i> — material icon named "credit card" (round). + static const IconData credit_card_rounded = IconData(0xf67c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">credit_card</i> — material icon named "credit card" (outlined). + static const IconData credit_card_outlined = IconData(0xef8f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">credit_card_off</i> — material icon named "credit card off". + static const IconData credit_card_off = IconData(0xe1a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">credit_card_off</i> — material icon named "credit card off" (sharp). + static const IconData credit_card_off_sharp = IconData(0xe89c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">credit_card_off</i> — material icon named "credit card off" (round). + static const IconData credit_card_off_rounded = IconData(0xf67b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">credit_card_off</i> — material icon named "credit card off" (outlined). + static const IconData credit_card_off_outlined = IconData(0xef8e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">credit_score</i> — material icon named "credit score". + static const IconData credit_score = IconData(0xe1a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">credit_score</i> — material icon named "credit score" (sharp). + static const IconData credit_score_sharp = IconData(0xe89e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">credit_score</i> — material icon named "credit score" (round). + static const IconData credit_score_rounded = IconData(0xf67d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">credit_score</i> — material icon named "credit score" (outlined). + static const IconData credit_score_outlined = IconData(0xef90, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crib</i> — material icon named "crib". + static const IconData crib = IconData(0xe1a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crib</i> — material icon named "crib" (sharp). + static const IconData crib_sharp = IconData(0xe89f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crib</i> — material icon named "crib" (round). + static const IconData crib_rounded = IconData(0xf67e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crib</i> — material icon named "crib" (outlined). + static const IconData crib_outlined = IconData(0xef91, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crisis_alert</i> — material icon named "crisis alert". + static const IconData crisis_alert = IconData(0xf0794, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crisis_alert</i> — material icon named "crisis alert" (sharp). + static const IconData crisis_alert_sharp = IconData(0xf073c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crisis_alert</i> — material icon named "crisis alert" (round). + static const IconData crisis_alert_rounded = IconData(0xf07ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crisis_alert</i> — material icon named "crisis alert" (outlined). + static const IconData crisis_alert_outlined = IconData(0xf06e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop</i> — material icon named "crop". + static const IconData crop = IconData(0xe1a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop</i> — material icon named "crop" (sharp). + static const IconData crop_sharp = IconData(0xe8aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop</i> — material icon named "crop" (round). + static const IconData crop_rounded = IconData(0xf689, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop</i> — material icon named "crop" (outlined). + static const IconData crop_outlined = IconData(0xef9a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_16_9</i> — material icon named "crop 16 9". + static const IconData crop_16_9 = IconData(0xe1a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_16_9</i> — material icon named "crop 16 9" (sharp). + static const IconData crop_16_9_sharp = IconData(0xe8a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_16_9</i> — material icon named "crop 16 9" (round). + static const IconData crop_16_9_rounded = IconData(0xf67f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_16_9</i> — material icon named "crop 16 9" (outlined). + static const IconData crop_16_9_outlined = IconData(0xef92, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_3_2</i> — material icon named "crop 3 2". + static const IconData crop_3_2 = IconData(0xe1a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_3_2</i> — material icon named "crop 3 2" (sharp). + static const IconData crop_3_2_sharp = IconData(0xe8a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_3_2</i> — material icon named "crop 3 2" (round). + static const IconData crop_3_2_rounded = IconData(0xf680, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_3_2</i> — material icon named "crop 3 2" (outlined). + static const IconData crop_3_2_outlined = IconData(0xef93, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_5_4</i> — material icon named "crop 5 4". + static const IconData crop_5_4 = IconData(0xe1a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_5_4</i> — material icon named "crop 5 4" (sharp). + static const IconData crop_5_4_sharp = IconData(0xe8a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_5_4</i> — material icon named "crop 5 4" (round). + static const IconData crop_5_4_rounded = IconData(0xf681, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_5_4</i> — material icon named "crop 5 4" (outlined). + static const IconData crop_5_4_outlined = IconData(0xef94, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_7_5</i> — material icon named "crop 7 5". + static const IconData crop_7_5 = IconData(0xe1a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_7_5</i> — material icon named "crop 7 5" (sharp). + static const IconData crop_7_5_sharp = IconData(0xe8a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_7_5</i> — material icon named "crop 7 5" (round). + static const IconData crop_7_5_rounded = IconData(0xf682, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_7_5</i> — material icon named "crop 7 5" (outlined). + static const IconData crop_7_5_outlined = IconData(0xef95, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_din</i> — material icon named "crop din". + static const IconData crop_din = IconData(0xe1a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_din</i> — material icon named "crop din" (sharp). + static const IconData crop_din_sharp = IconData(0xe8a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_din</i> — material icon named "crop din" (round). + static const IconData crop_din_rounded = IconData(0xf683, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_din</i> — material icon named "crop din" (outlined). + static const IconData crop_din_outlined = IconData(0xef96, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_free</i> — material icon named "crop free". + static const IconData crop_free = IconData(0xe1a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_free</i> — material icon named "crop free" (sharp). + static const IconData crop_free_sharp = IconData(0xe8a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_free</i> — material icon named "crop free" (round). + static const IconData crop_free_rounded = IconData(0xf684, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_free</i> — material icon named "crop free" (outlined). + static const IconData crop_free_outlined = IconData(0xef97, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_landscape</i> — material icon named "crop landscape". + static const IconData crop_landscape = IconData(0xe1aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_landscape</i> — material icon named "crop landscape" (sharp). + static const IconData crop_landscape_sharp = IconData(0xe8a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_landscape</i> — material icon named "crop landscape" (round). + static const IconData crop_landscape_rounded = IconData(0xf685, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_landscape</i> — material icon named "crop landscape" (outlined). + static const IconData crop_landscape_outlined = IconData(0xef98, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_original</i> — material icon named "crop original". + static const IconData crop_original = IconData(0xe1ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_original</i> — material icon named "crop original" (sharp). + static const IconData crop_original_sharp = IconData(0xe8a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_original</i> — material icon named "crop original" (round). + static const IconData crop_original_rounded = IconData(0xf686, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_original</i> — material icon named "crop original" (outlined). + static const IconData crop_original_outlined = IconData(0xef99, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_portrait</i> — material icon named "crop portrait". + static const IconData crop_portrait = IconData(0xe1ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_portrait</i> — material icon named "crop portrait" (sharp). + static const IconData crop_portrait_sharp = IconData(0xe8a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_portrait</i> — material icon named "crop portrait" (round). + static const IconData crop_portrait_rounded = IconData(0xf687, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_portrait</i> — material icon named "crop portrait" (outlined). + static const IconData crop_portrait_outlined = IconData(0xef9b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_rotate</i> — material icon named "crop rotate". + static const IconData crop_rotate = IconData(0xe1ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_rotate</i> — material icon named "crop rotate" (sharp). + static const IconData crop_rotate_sharp = IconData(0xe8a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_rotate</i> — material icon named "crop rotate" (round). + static const IconData crop_rotate_rounded = IconData(0xf688, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_rotate</i> — material icon named "crop rotate" (outlined). + static const IconData crop_rotate_outlined = IconData(0xef9c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">crop_square</i> — material icon named "crop square". + static const IconData crop_square = IconData(0xe1ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">crop_square</i> — material icon named "crop square" (sharp). + static const IconData crop_square_sharp = IconData(0xe8ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">crop_square</i> — material icon named "crop square" (round). + static const IconData crop_square_rounded = IconData(0xf68a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">crop_square</i> — material icon named "crop square" (outlined). + static const IconData crop_square_outlined = IconData(0xef9d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cruelty_free</i> — material icon named "cruelty free". + static const IconData cruelty_free = IconData(0xf04da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cruelty_free</i> — material icon named "cruelty free" (sharp). + static const IconData cruelty_free_sharp = IconData(0xf03e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cruelty_free</i> — material icon named "cruelty free" (round). + static const IconData cruelty_free_rounded = IconData(0xf02f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cruelty_free</i> — material icon named "cruelty free" (outlined). + static const IconData cruelty_free_outlined = IconData(0xf05d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">css</i> — material icon named "css". + static const IconData css = IconData(0xf04db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">css</i> — material icon named "css" (sharp). + static const IconData css_sharp = IconData(0xf03e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">css</i> — material icon named "css" (round). + static const IconData css_rounded = IconData(0xf02f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">css</i> — material icon named "css" (outlined). + static const IconData css_outlined = IconData(0xf05d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_bitcoin</i> — material icon named "currency bitcoin". + static const IconData currency_bitcoin = IconData(0xf06bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_bitcoin</i> — material icon named "currency bitcoin" (sharp). + static const IconData currency_bitcoin_sharp = IconData(0xf06af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_bitcoin</i> — material icon named "currency bitcoin" (round). + static const IconData currency_bitcoin_rounded = IconData(0xf06c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_bitcoin</i> — material icon named "currency bitcoin" (outlined). + static const IconData currency_bitcoin_outlined = IconData(0xf054a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_exchange</i> — material icon named "currency exchange". + static const IconData currency_exchange = IconData(0xf04dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_exchange</i> — material icon named "currency exchange" (sharp). + static const IconData currency_exchange_sharp = IconData(0xf03e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_exchange</i> — material icon named "currency exchange" (round). + static const IconData currency_exchange_rounded = IconData(0xf02f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_exchange</i> — material icon named "currency exchange" (outlined). + static const IconData currency_exchange_outlined = IconData(0xf05d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_franc</i> — material icon named "currency franc". + static const IconData currency_franc = IconData(0xf04dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_franc</i> — material icon named "currency franc" (sharp). + static const IconData currency_franc_sharp = IconData(0xf03e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_franc</i> — material icon named "currency franc" (round). + static const IconData currency_franc_rounded = IconData(0xf02f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_franc</i> — material icon named "currency franc" (outlined). + static const IconData currency_franc_outlined = IconData(0xf05d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_lira</i> — material icon named "currency lira". + static const IconData currency_lira = IconData(0xf04de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_lira</i> — material icon named "currency lira" (sharp). + static const IconData currency_lira_sharp = IconData(0xf03ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_lira</i> — material icon named "currency lira" (round). + static const IconData currency_lira_rounded = IconData(0xf02f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_lira</i> — material icon named "currency lira" (outlined). + static const IconData currency_lira_outlined = IconData(0xf05d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_pound</i> — material icon named "currency pound". + static const IconData currency_pound = IconData(0xf04df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_pound</i> — material icon named "currency pound" (sharp). + static const IconData currency_pound_sharp = IconData(0xf03eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_pound</i> — material icon named "currency pound" (round). + static const IconData currency_pound_rounded = IconData(0xf02f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_pound</i> — material icon named "currency pound" (outlined). + static const IconData currency_pound_outlined = IconData(0xf05d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_ruble</i> — material icon named "currency ruble". + static const IconData currency_ruble = IconData(0xf04e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_ruble</i> — material icon named "currency ruble" (sharp). + static const IconData currency_ruble_sharp = IconData(0xf03ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_ruble</i> — material icon named "currency ruble" (round). + static const IconData currency_ruble_rounded = IconData(0xf02f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_ruble</i> — material icon named "currency ruble" (outlined). + static const IconData currency_ruble_outlined = IconData(0xf05da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_rupee</i> — material icon named "currency rupee". + static const IconData currency_rupee = IconData(0xf04e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_rupee</i> — material icon named "currency rupee" (sharp). + static const IconData currency_rupee_sharp = IconData(0xf03ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_rupee</i> — material icon named "currency rupee" (round). + static const IconData currency_rupee_rounded = IconData(0xf02fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_rupee</i> — material icon named "currency rupee" (outlined). + static const IconData currency_rupee_outlined = IconData(0xf05db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_yen</i> — material icon named "currency yen". + static const IconData currency_yen = IconData(0xf04e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_yen</i> — material icon named "currency yen" (sharp). + static const IconData currency_yen_sharp = IconData(0xf03ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_yen</i> — material icon named "currency yen" (round). + static const IconData currency_yen_rounded = IconData(0xf02fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_yen</i> — material icon named "currency yen" (outlined). + static const IconData currency_yen_outlined = IconData(0xf05dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">currency_yuan</i> — material icon named "currency yuan". + static const IconData currency_yuan = IconData(0xf04e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">currency_yuan</i> — material icon named "currency yuan" (sharp). + static const IconData currency_yuan_sharp = IconData(0xf03ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">currency_yuan</i> — material icon named "currency yuan" (round). + static const IconData currency_yuan_rounded = IconData(0xf02fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">currency_yuan</i> — material icon named "currency yuan" (outlined). + static const IconData currency_yuan_outlined = IconData(0xf05dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">curtains</i> — material icon named "curtains". + static const IconData curtains = IconData(0xf0795, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">curtains</i> — material icon named "curtains" (sharp). + static const IconData curtains_sharp = IconData(0xf073e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">curtains</i> — material icon named "curtains" (round). + static const IconData curtains_rounded = IconData(0xf07ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">curtains</i> — material icon named "curtains" (outlined). + static const IconData curtains_outlined = IconData(0xf06e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">curtains_closed</i> — material icon named "curtains closed". + static const IconData curtains_closed = IconData(0xf0796, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">curtains_closed</i> — material icon named "curtains closed" (sharp). + static const IconData curtains_closed_sharp = IconData(0xf073d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">curtains_closed</i> — material icon named "curtains closed" (round). + static const IconData curtains_closed_rounded = IconData(0xf07ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">curtains_closed</i> — material icon named "curtains closed" (outlined). + static const IconData curtains_closed_outlined = IconData(0xf06e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cut</i> — material icon named "cut". + static const IconData cut = IconData(0xe191, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cut</i> — material icon named "cut" (sharp). + static const IconData cut_sharp = IconData(0xe88e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cut</i> — material icon named "cut" (round). + static const IconData cut_rounded = IconData(0xf66d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cut</i> — material icon named "cut" (outlined). + static const IconData cut_outlined = IconData(0xef80, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">cyclone</i> — material icon named "cyclone". + static const IconData cyclone = IconData(0xf0797, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">cyclone</i> — material icon named "cyclone" (sharp). + static const IconData cyclone_sharp = IconData(0xf073f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">cyclone</i> — material icon named "cyclone" (round). + static const IconData cyclone_rounded = IconData(0xf07ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">cyclone</i> — material icon named "cyclone" (outlined). + static const IconData cyclone_outlined = IconData(0xf06e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dangerous</i> — material icon named "dangerous". + static const IconData dangerous = IconData(0xe1af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dangerous</i> — material icon named "dangerous" (sharp). + static const IconData dangerous_sharp = IconData(0xe8ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dangerous</i> — material icon named "dangerous" (round). + static const IconData dangerous_rounded = IconData(0xf68b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dangerous</i> — material icon named "dangerous" (outlined). + static const IconData dangerous_outlined = IconData(0xef9e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dark_mode</i> — material icon named "dark mode". + static const IconData dark_mode = IconData(0xe1b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dark_mode</i> — material icon named "dark mode" (sharp). + static const IconData dark_mode_sharp = IconData(0xe8ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dark_mode</i> — material icon named "dark mode" (round). + static const IconData dark_mode_rounded = IconData(0xf68c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dark_mode</i> — material icon named "dark mode" (outlined). + static const IconData dark_mode_outlined = IconData(0xef9f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dashboard</i> — material icon named "dashboard". + static const IconData dashboard = IconData(0xe1b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dashboard</i> — material icon named "dashboard" (sharp). + static const IconData dashboard_sharp = IconData(0xe8af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dashboard</i> — material icon named "dashboard" (round). + static const IconData dashboard_rounded = IconData(0xf68e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dashboard</i> — material icon named "dashboard" (outlined). + static const IconData dashboard_outlined = IconData(0xefa1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dashboard_customize</i> — material icon named "dashboard customize". + static const IconData dashboard_customize = IconData(0xe1b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dashboard_customize</i> — material icon named "dashboard customize" (sharp). + static const IconData dashboard_customize_sharp = IconData(0xe8ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dashboard_customize</i> — material icon named "dashboard customize" (round). + static const IconData dashboard_customize_rounded = IconData(0xf68d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dashboard_customize</i> — material icon named "dashboard customize" (outlined). + static const IconData dashboard_customize_outlined = IconData( + 0xefa0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">data_array</i> — material icon named "data array". + static const IconData data_array = IconData(0xf04e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_array</i> — material icon named "data array" (sharp). + static const IconData data_array_sharp = IconData(0xf03f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_array</i> — material icon named "data array" (round). + static const IconData data_array_rounded = IconData(0xf02fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_array</i> — material icon named "data array" (outlined). + static const IconData data_array_outlined = IconData(0xf05de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">data_exploration</i> — material icon named "data exploration". + static const IconData data_exploration = IconData(0xf04e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_exploration</i> — material icon named "data exploration" (sharp). + static const IconData data_exploration_sharp = IconData(0xf03f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_exploration</i> — material icon named "data exploration" (round). + static const IconData data_exploration_rounded = IconData(0xf02fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_exploration</i> — material icon named "data exploration" (outlined). + static const IconData data_exploration_outlined = IconData(0xf05df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">data_object</i> — material icon named "data object". + static const IconData data_object = IconData(0xf04e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_object</i> — material icon named "data object" (sharp). + static const IconData data_object_sharp = IconData(0xf03f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_object</i> — material icon named "data object" (round). + static const IconData data_object_rounded = IconData(0xf02ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_object</i> — material icon named "data object" (outlined). + static const IconData data_object_outlined = IconData(0xf05e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">data_saver_off</i> — material icon named "data saver off". + static const IconData data_saver_off = IconData(0xe1b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_saver_off</i> — material icon named "data saver off" (sharp). + static const IconData data_saver_off_sharp = IconData(0xe8b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_saver_off</i> — material icon named "data saver off" (round). + static const IconData data_saver_off_rounded = IconData(0xf68f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_saver_off</i> — material icon named "data saver off" (outlined). + static const IconData data_saver_off_outlined = IconData(0xefa2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">data_saver_on</i> — material icon named "data saver on". + static const IconData data_saver_on = IconData(0xe1b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_saver_on</i> — material icon named "data saver on" (sharp). + static const IconData data_saver_on_sharp = IconData(0xe8b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_saver_on</i> — material icon named "data saver on" (round). + static const IconData data_saver_on_rounded = IconData(0xf690, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_saver_on</i> — material icon named "data saver on" (outlined). + static const IconData data_saver_on_outlined = IconData(0xefa3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">data_thresholding</i> — material icon named "data thresholding". + static const IconData data_thresholding = IconData(0xf04e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_thresholding</i> — material icon named "data thresholding" (sharp). + static const IconData data_thresholding_sharp = IconData(0xf03f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_thresholding</i> — material icon named "data thresholding" (round). + static const IconData data_thresholding_rounded = IconData(0xf0300, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_thresholding</i> — material icon named "data thresholding" (outlined). + static const IconData data_thresholding_outlined = IconData(0xf05e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">data_usage</i> — material icon named "data usage". + static const IconData data_usage = IconData(0xe1b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">data_usage</i> — material icon named "data usage" (sharp). + static const IconData data_usage_sharp = IconData(0xe8b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">data_usage</i> — material icon named "data usage" (round). + static const IconData data_usage_rounded = IconData(0xf691, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">data_usage</i> — material icon named "data usage" (outlined). + static const IconData data_usage_outlined = IconData(0xefa4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dataset</i> — material icon named "dataset". + static const IconData dataset = IconData(0xf0798, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dataset</i> — material icon named "dataset" (sharp). + static const IconData dataset_sharp = IconData(0xf0741, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dataset</i> — material icon named "dataset" (round). + static const IconData dataset_rounded = IconData(0xf07f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dataset</i> — material icon named "dataset" (outlined). + static const IconData dataset_outlined = IconData(0xf06e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dataset_linked</i> — material icon named "dataset linked". + static const IconData dataset_linked = IconData(0xf0799, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dataset_linked</i> — material icon named "dataset linked" (sharp). + static const IconData dataset_linked_sharp = IconData(0xf0740, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dataset_linked</i> — material icon named "dataset linked" (round). + static const IconData dataset_linked_rounded = IconData(0xf07f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dataset_linked</i> — material icon named "dataset linked" (outlined). + static const IconData dataset_linked_outlined = IconData(0xf06e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">date_range</i> — material icon named "date range". + static const IconData date_range = IconData(0xe1b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">date_range</i> — material icon named "date range" (sharp). + static const IconData date_range_sharp = IconData(0xe8b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">date_range</i> — material icon named "date range" (round). + static const IconData date_range_rounded = IconData(0xf692, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">date_range</i> — material icon named "date range" (outlined). + static const IconData date_range_outlined = IconData(0xefa5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">deblur</i> — material icon named "deblur". + static const IconData deblur = IconData(0xf04e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">deblur</i> — material icon named "deblur" (sharp). + static const IconData deblur_sharp = IconData(0xf03f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">deblur</i> — material icon named "deblur" (round). + static const IconData deblur_rounded = IconData(0xf0301, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">deblur</i> — material icon named "deblur" (outlined). + static const IconData deblur_outlined = IconData(0xf05e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">deck</i> — material icon named "deck". + static const IconData deck = IconData(0xe1b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">deck</i> — material icon named "deck" (sharp). + static const IconData deck_sharp = IconData(0xe8b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">deck</i> — material icon named "deck" (round). + static const IconData deck_rounded = IconData(0xf693, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">deck</i> — material icon named "deck" (outlined). + static const IconData deck_outlined = IconData(0xefa6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dehaze</i> — material icon named "dehaze". + static const IconData dehaze = IconData(0xe1b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dehaze</i> — material icon named "dehaze" (sharp). + static const IconData dehaze_sharp = IconData(0xe8b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dehaze</i> — material icon named "dehaze" (round). + static const IconData dehaze_rounded = IconData(0xf694, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dehaze</i> — material icon named "dehaze" (outlined). + static const IconData dehaze_outlined = IconData(0xefa7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">delete</i> — material icon named "delete". + static const IconData delete = IconData(0xe1b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">delete</i> — material icon named "delete" (sharp). + static const IconData delete_sharp = IconData(0xe8b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">delete</i> — material icon named "delete" (round). + static const IconData delete_rounded = IconData(0xf697, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">delete</i> — material icon named "delete" (outlined). + static const IconData delete_outlined = IconData(0xefaa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">delete_forever</i> — material icon named "delete forever". + static const IconData delete_forever = IconData(0xe1ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">delete_forever</i> — material icon named "delete forever" (sharp). + static const IconData delete_forever_sharp = IconData(0xe8b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">delete_forever</i> — material icon named "delete forever" (round). + static const IconData delete_forever_rounded = IconData(0xf695, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">delete_forever</i> — material icon named "delete forever" (outlined). + static const IconData delete_forever_outlined = IconData(0xefa8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">delete_outline</i> — material icon named "delete outline". + static const IconData delete_outline = IconData(0xe1bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">delete_outline</i> — material icon named "delete outline" (sharp). + static const IconData delete_outline_sharp = IconData(0xe8b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">delete_outline</i> — material icon named "delete outline" (round). + static const IconData delete_outline_rounded = IconData(0xf696, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">delete_outline</i> — material icon named "delete outline" (outlined). + static const IconData delete_outline_outlined = IconData(0xefa9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">delete_sweep</i> — material icon named "delete sweep". + static const IconData delete_sweep = IconData(0xe1bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">delete_sweep</i> — material icon named "delete sweep" (sharp). + static const IconData delete_sweep_sharp = IconData(0xe8b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">delete_sweep</i> — material icon named "delete sweep" (round). + static const IconData delete_sweep_rounded = IconData(0xf698, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">delete_sweep</i> — material icon named "delete sweep" (outlined). + static const IconData delete_sweep_outlined = IconData(0xefab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">delivery_dining</i> — material icon named "delivery dining". + static const IconData delivery_dining = IconData(0xe1bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">delivery_dining</i> — material icon named "delivery dining" (sharp). + static const IconData delivery_dining_sharp = IconData(0xe8ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">delivery_dining</i> — material icon named "delivery dining" (round). + static const IconData delivery_dining_rounded = IconData(0xf699, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">delivery_dining</i> — material icon named "delivery dining" (outlined). + static const IconData delivery_dining_outlined = IconData(0xefac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">density_large</i> — material icon named "density large". + static const IconData density_large = IconData(0xf04e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">density_large</i> — material icon named "density large" (sharp). + static const IconData density_large_sharp = IconData(0xf03f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">density_large</i> — material icon named "density large" (round). + static const IconData density_large_rounded = IconData(0xf0302, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">density_large</i> — material icon named "density large" (outlined). + static const IconData density_large_outlined = IconData(0xf05e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">density_medium</i> — material icon named "density medium". + static const IconData density_medium = IconData(0xf04ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">density_medium</i> — material icon named "density medium" (sharp). + static const IconData density_medium_sharp = IconData(0xf03f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">density_medium</i> — material icon named "density medium" (round). + static const IconData density_medium_rounded = IconData(0xf0303, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">density_medium</i> — material icon named "density medium" (outlined). + static const IconData density_medium_outlined = IconData(0xf05e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">density_small</i> — material icon named "density small". + static const IconData density_small = IconData(0xf04eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">density_small</i> — material icon named "density small" (sharp). + static const IconData density_small_sharp = IconData(0xf03f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">density_small</i> — material icon named "density small" (round). + static const IconData density_small_rounded = IconData(0xf0304, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">density_small</i> — material icon named "density small" (outlined). + static const IconData density_small_outlined = IconData(0xf05e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">departure_board</i> — material icon named "departure board". + static const IconData departure_board = IconData(0xe1be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">departure_board</i> — material icon named "departure board" (sharp). + static const IconData departure_board_sharp = IconData(0xe8bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">departure_board</i> — material icon named "departure board" (round). + static const IconData departure_board_rounded = IconData(0xf69a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">departure_board</i> — material icon named "departure board" (outlined). + static const IconData departure_board_outlined = IconData(0xefad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">description</i> — material icon named "description". + static const IconData description = IconData(0xe1bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">description</i> — material icon named "description" (sharp). + static const IconData description_sharp = IconData(0xe8bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">description</i> — material icon named "description" (round). + static const IconData description_rounded = IconData(0xf69b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">description</i> — material icon named "description" (outlined). + static const IconData description_outlined = IconData(0xefae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">deselect</i> — material icon named "deselect". + static const IconData deselect = IconData(0xf04ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">deselect</i> — material icon named "deselect" (sharp). + static const IconData deselect_sharp = IconData(0xf03f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">deselect</i> — material icon named "deselect" (round). + static const IconData deselect_rounded = IconData(0xf0305, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">deselect</i> — material icon named "deselect" (outlined). + static const IconData deselect_outlined = IconData(0xf05e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">design_services</i> — material icon named "design services". + static const IconData design_services = IconData(0xe1c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">design_services</i> — material icon named "design services" (sharp). + static const IconData design_services_sharp = IconData(0xe8bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">design_services</i> — material icon named "design services" (round). + static const IconData design_services_rounded = IconData(0xf69c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">design_services</i> — material icon named "design services" (outlined). + static const IconData design_services_outlined = IconData(0xefaf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">desk</i> — material icon named "desk". + static const IconData desk = IconData(0xf079a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">desk</i> — material icon named "desk" (sharp). + static const IconData desk_sharp = IconData(0xf0742, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">desk</i> — material icon named "desk" (round). + static const IconData desk_rounded = IconData(0xf07f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">desk</i> — material icon named "desk" (outlined). + static const IconData desk_outlined = IconData(0xf06ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">desktop_access_disabled</i> — material icon named "desktop access disabled". + static const IconData desktop_access_disabled = IconData(0xe1c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">desktop_access_disabled</i> — material icon named "desktop access disabled" (sharp). + static const IconData desktop_access_disabled_sharp = IconData( + 0xe8be, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">desktop_access_disabled</i> — material icon named "desktop access disabled" (round). + static const IconData desktop_access_disabled_rounded = IconData( + 0xf69d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">desktop_access_disabled</i> — material icon named "desktop access disabled" (outlined). + static const IconData desktop_access_disabled_outlined = IconData( + 0xefb0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">desktop_mac</i> — material icon named "desktop mac". + static const IconData desktop_mac = IconData(0xe1c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">desktop_mac</i> — material icon named "desktop mac" (sharp). + static const IconData desktop_mac_sharp = IconData(0xe8bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">desktop_mac</i> — material icon named "desktop mac" (round). + static const IconData desktop_mac_rounded = IconData(0xf69e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">desktop_mac</i> — material icon named "desktop mac" (outlined). + static const IconData desktop_mac_outlined = IconData(0xefb1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">desktop_windows</i> — material icon named "desktop windows". + static const IconData desktop_windows = IconData(0xe1c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">desktop_windows</i> — material icon named "desktop windows" (sharp). + static const IconData desktop_windows_sharp = IconData(0xe8c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">desktop_windows</i> — material icon named "desktop windows" (round). + static const IconData desktop_windows_rounded = IconData(0xf69f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">desktop_windows</i> — material icon named "desktop windows" (outlined). + static const IconData desktop_windows_outlined = IconData(0xefb2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">details</i> — material icon named "details". + static const IconData details = IconData(0xe1c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">details</i> — material icon named "details" (sharp). + static const IconData details_sharp = IconData(0xe8c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">details</i> — material icon named "details" (round). + static const IconData details_rounded = IconData(0xf6a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">details</i> — material icon named "details" (outlined). + static const IconData details_outlined = IconData(0xefb3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">developer_board</i> — material icon named "developer board". + static const IconData developer_board = IconData(0xe1c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">developer_board</i> — material icon named "developer board" (sharp). + static const IconData developer_board_sharp = IconData(0xe8c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">developer_board</i> — material icon named "developer board" (round). + static const IconData developer_board_rounded = IconData(0xf6a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">developer_board</i> — material icon named "developer board" (outlined). + static const IconData developer_board_outlined = IconData(0xefb5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">developer_board_off</i> — material icon named "developer board off". + static const IconData developer_board_off = IconData(0xe1c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">developer_board_off</i> — material icon named "developer board off" (sharp). + static const IconData developer_board_off_sharp = IconData(0xe8c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">developer_board_off</i> — material icon named "developer board off" (round). + static const IconData developer_board_off_rounded = IconData(0xf6a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">developer_board_off</i> — material icon named "developer board off" (outlined). + static const IconData developer_board_off_outlined = IconData( + 0xefb4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">developer_mode</i> — material icon named "developer mode". + static const IconData developer_mode = IconData(0xe1c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">developer_mode</i> — material icon named "developer mode" (sharp). + static const IconData developer_mode_sharp = IconData(0xe8c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">developer_mode</i> — material icon named "developer mode" (round). + static const IconData developer_mode_rounded = IconData(0xf6a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">developer_mode</i> — material icon named "developer mode" (outlined). + static const IconData developer_mode_outlined = IconData(0xefb6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">device_hub</i> — material icon named "device hub". + static const IconData device_hub = IconData(0xe1c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">device_hub</i> — material icon named "device hub" (sharp). + static const IconData device_hub_sharp = IconData(0xe8c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">device_hub</i> — material icon named "device hub" (round). + static const IconData device_hub_rounded = IconData(0xf6a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">device_hub</i> — material icon named "device hub" (outlined). + static const IconData device_hub_outlined = IconData(0xefb7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">device_thermostat</i> — material icon named "device thermostat". + static const IconData device_thermostat = IconData(0xe1c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">device_thermostat</i> — material icon named "device thermostat" (sharp). + static const IconData device_thermostat_sharp = IconData(0xe8c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">device_thermostat</i> — material icon named "device thermostat" (round). + static const IconData device_thermostat_rounded = IconData(0xf6a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">device_thermostat</i> — material icon named "device thermostat" (outlined). + static const IconData device_thermostat_outlined = IconData(0xefb8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">device_unknown</i> — material icon named "device unknown". + static const IconData device_unknown = IconData( + 0xe1ca, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">device_unknown</i> — material icon named "device unknown" (sharp). + static const IconData device_unknown_sharp = IconData( + 0xe8c7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">device_unknown</i> — material icon named "device unknown" (round). + static const IconData device_unknown_rounded = IconData( + 0xf6a6, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">device_unknown</i> — material icon named "device unknown" (outlined). + static const IconData device_unknown_outlined = IconData( + 0xefb9, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">devices</i> — material icon named "devices". + static const IconData devices = IconData(0xe1cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">devices</i> — material icon named "devices" (sharp). + static const IconData devices_sharp = IconData(0xe8c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">devices</i> — material icon named "devices" (round). + static const IconData devices_rounded = IconData(0xf6a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">devices</i> — material icon named "devices" (outlined). + static const IconData devices_outlined = IconData(0xefbb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">devices_fold</i> — material icon named "devices fold". + static const IconData devices_fold = IconData(0xf079b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">devices_fold</i> — material icon named "devices fold" (sharp). + static const IconData devices_fold_sharp = IconData(0xf0743, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">devices_fold</i> — material icon named "devices fold" (round). + static const IconData devices_fold_rounded = IconData(0xf07f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">devices_fold</i> — material icon named "devices fold" (outlined). + static const IconData devices_fold_outlined = IconData(0xf06eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">devices_other</i> — material icon named "devices other". + static const IconData devices_other = IconData(0xe1cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">devices_other</i> — material icon named "devices other" (sharp). + static const IconData devices_other_sharp = IconData(0xe8c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">devices_other</i> — material icon named "devices other" (round). + static const IconData devices_other_rounded = IconData(0xf6a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">devices_other</i> — material icon named "devices other" (outlined). + static const IconData devices_other_outlined = IconData(0xefba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dew_point</i> — material icon named "dew point". + static const IconData dew_point = IconData(0xf0859, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dialer_sip</i> — material icon named "dialer sip". + static const IconData dialer_sip = IconData(0xe1cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dialer_sip</i> — material icon named "dialer sip" (sharp). + static const IconData dialer_sip_sharp = IconData(0xe8ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dialer_sip</i> — material icon named "dialer sip" (round). + static const IconData dialer_sip_rounded = IconData(0xf6a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dialer_sip</i> — material icon named "dialer sip" (outlined). + static const IconData dialer_sip_outlined = IconData(0xefbc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dialpad</i> — material icon named "dialpad". + static const IconData dialpad = IconData(0xe1ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dialpad</i> — material icon named "dialpad" (sharp). + static const IconData dialpad_sharp = IconData(0xe8cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dialpad</i> — material icon named "dialpad" (round). + static const IconData dialpad_rounded = IconData(0xf6aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dialpad</i> — material icon named "dialpad" (outlined). + static const IconData dialpad_outlined = IconData(0xefbd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">diamond</i> — material icon named "diamond". + static const IconData diamond = IconData(0xf04ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">diamond</i> — material icon named "diamond" (sharp). + static const IconData diamond_sharp = IconData(0xf03f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">diamond</i> — material icon named "diamond" (round). + static const IconData diamond_rounded = IconData(0xf0306, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">diamond</i> — material icon named "diamond" (outlined). + static const IconData diamond_outlined = IconData(0xf05e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">difference</i> — material icon named "difference". + static const IconData difference = IconData(0xf04ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">difference</i> — material icon named "difference" (sharp). + static const IconData difference_sharp = IconData(0xf03fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">difference</i> — material icon named "difference" (round). + static const IconData difference_rounded = IconData(0xf0307, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">difference</i> — material icon named "difference" (outlined). + static const IconData difference_outlined = IconData(0xf05e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dining</i> — material icon named "dining". + static const IconData dining = IconData(0xe1cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dining</i> — material icon named "dining" (sharp). + static const IconData dining_sharp = IconData(0xe8cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dining</i> — material icon named "dining" (round). + static const IconData dining_rounded = IconData(0xf6ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dining</i> — material icon named "dining" (outlined). + static const IconData dining_outlined = IconData(0xefbe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dinner_dining</i> — material icon named "dinner dining". + static const IconData dinner_dining = IconData(0xe1d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dinner_dining</i> — material icon named "dinner dining" (sharp). + static const IconData dinner_dining_sharp = IconData(0xe8cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dinner_dining</i> — material icon named "dinner dining" (round). + static const IconData dinner_dining_rounded = IconData(0xf6ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dinner_dining</i> — material icon named "dinner dining" (outlined). + static const IconData dinner_dining_outlined = IconData(0xefbf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions</i> — material icon named "directions". + static const IconData directions = IconData(0xe1d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions</i> — material icon named "directions" (sharp). + static const IconData directions_sharp = IconData(0xe8d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions</i> — material icon named "directions" (round). + static const IconData directions_rounded = IconData(0xf6b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions</i> — material icon named "directions" (outlined). + static const IconData directions_outlined = IconData(0xefc8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_bike</i> — material icon named "directions bike". + static const IconData directions_bike = IconData(0xe1d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_bike</i> — material icon named "directions bike" (sharp). + static const IconData directions_bike_sharp = IconData(0xe8ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_bike</i> — material icon named "directions bike" (round). + static const IconData directions_bike_rounded = IconData(0xf6ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_bike</i> — material icon named "directions bike" (outlined). + static const IconData directions_bike_outlined = IconData(0xefc0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_boat</i> — material icon named "directions boat". + static const IconData directions_boat = IconData(0xe1d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_boat</i> — material icon named "directions boat" (sharp). + static const IconData directions_boat_sharp = IconData(0xe8d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_boat</i> — material icon named "directions boat" (round). + static const IconData directions_boat_rounded = IconData(0xf6af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_boat</i> — material icon named "directions boat" (outlined). + static const IconData directions_boat_outlined = IconData(0xefc2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_boat_filled</i> — material icon named "directions boat filled". + static const IconData directions_boat_filled = IconData(0xe1d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_boat_filled</i> — material icon named "directions boat filled" (sharp). + static const IconData directions_boat_filled_sharp = IconData( + 0xe8cf, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">directions_boat_filled</i> — material icon named "directions boat filled" (round). + static const IconData directions_boat_filled_rounded = IconData( + 0xf6ae, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">directions_boat_filled</i> — material icon named "directions boat filled" (outlined). + static const IconData directions_boat_filled_outlined = IconData( + 0xefc1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">directions_bus</i> — material icon named "directions bus". + static const IconData directions_bus = IconData(0xe1d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_bus</i> — material icon named "directions bus" (sharp). + static const IconData directions_bus_sharp = IconData(0xe8d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_bus</i> — material icon named "directions bus" (round). + static const IconData directions_bus_rounded = IconData(0xf6b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_bus</i> — material icon named "directions bus" (outlined). + static const IconData directions_bus_outlined = IconData(0xefc4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_bus_filled</i> — material icon named "directions bus filled". + static const IconData directions_bus_filled = IconData(0xe1d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_bus_filled</i> — material icon named "directions bus filled" (sharp). + static const IconData directions_bus_filled_sharp = IconData(0xe8d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_bus_filled</i> — material icon named "directions bus filled" (round). + static const IconData directions_bus_filled_rounded = IconData( + 0xf6b0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">directions_bus_filled</i> — material icon named "directions bus filled" (outlined). + static const IconData directions_bus_filled_outlined = IconData( + 0xefc3, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">directions_car</i> — material icon named "directions car". + static const IconData directions_car = IconData(0xe1d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_car</i> — material icon named "directions car" (sharp). + static const IconData directions_car_sharp = IconData(0xe8d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_car</i> — material icon named "directions car" (round). + static const IconData directions_car_rounded = IconData(0xf6b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_car</i> — material icon named "directions car" (outlined). + static const IconData directions_car_outlined = IconData(0xefc6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_car_filled</i> — material icon named "directions car filled". + static const IconData directions_car_filled = IconData(0xe1d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_car_filled</i> — material icon named "directions car filled" (sharp). + static const IconData directions_car_filled_sharp = IconData(0xe8d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_car_filled</i> — material icon named "directions car filled" (round). + static const IconData directions_car_filled_rounded = IconData( + 0xf6b2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">directions_car_filled</i> — material icon named "directions car filled" (outlined). + static const IconData directions_car_filled_outlined = IconData( + 0xefc5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">directions_ferry</i> — material icon named "directions ferry". + static const IconData directions_ferry = IconData(0xe1d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_ferry</i> — material icon named "directions ferry" (sharp). + static const IconData directions_ferry_sharp = IconData(0xe8d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_ferry</i> — material icon named "directions ferry" (round). + static const IconData directions_ferry_rounded = IconData(0xf6af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_ferry</i> — material icon named "directions ferry" (outlined). + static const IconData directions_ferry_outlined = IconData(0xefc2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_off</i> — material icon named "directions off". + static const IconData directions_off = IconData(0xe1d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_off</i> — material icon named "directions off" (sharp). + static const IconData directions_off_sharp = IconData(0xe8d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_off</i> — material icon named "directions off" (round). + static const IconData directions_off_rounded = IconData(0xf6b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_off</i> — material icon named "directions off" (outlined). + static const IconData directions_off_outlined = IconData(0xefc7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_railway</i> — material icon named "directions railway". + static const IconData directions_railway = IconData(0xe1da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_railway</i> — material icon named "directions railway" (sharp). + static const IconData directions_railway_sharp = IconData(0xe8d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_railway</i> — material icon named "directions railway" (round). + static const IconData directions_railway_rounded = IconData(0xf6b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_railway</i> — material icon named "directions railway" (outlined). + static const IconData directions_railway_outlined = IconData(0xefca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_railway_filled</i> — material icon named "directions railway filled". + static const IconData directions_railway_filled = IconData(0xe1db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_railway_filled</i> — material icon named "directions railway filled" (sharp). + static const IconData directions_railway_filled_sharp = IconData( + 0xe8d6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">directions_railway_filled</i> — material icon named "directions railway filled" (round). + static const IconData directions_railway_filled_rounded = IconData( + 0xf6b5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">directions_railway_filled</i> — material icon named "directions railway filled" (outlined). + static const IconData directions_railway_filled_outlined = IconData( + 0xefc9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">directions_run</i> — material icon named "directions run". + static const IconData directions_run = IconData(0xe1dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_run</i> — material icon named "directions run" (sharp). + static const IconData directions_run_sharp = IconData(0xe8d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_run</i> — material icon named "directions run" (round). + static const IconData directions_run_rounded = IconData(0xf6b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_run</i> — material icon named "directions run" (outlined). + static const IconData directions_run_outlined = IconData(0xefcb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_subway</i> — material icon named "directions subway". + static const IconData directions_subway = IconData(0xe1dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_subway</i> — material icon named "directions subway" (sharp). + static const IconData directions_subway_sharp = IconData(0xe8db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_subway</i> — material icon named "directions subway" (round). + static const IconData directions_subway_rounded = IconData(0xf6ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_subway</i> — material icon named "directions subway" (outlined). + static const IconData directions_subway_outlined = IconData(0xefcd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_subway_filled</i> — material icon named "directions subway filled". + static const IconData directions_subway_filled = IconData(0xe1de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_subway_filled</i> — material icon named "directions subway filled" (sharp). + static const IconData directions_subway_filled_sharp = IconData( + 0xe8da, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">directions_subway_filled</i> — material icon named "directions subway filled" (round). + static const IconData directions_subway_filled_rounded = IconData( + 0xf6b9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">directions_subway_filled</i> — material icon named "directions subway filled" (outlined). + static const IconData directions_subway_filled_outlined = IconData( + 0xefcc, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">directions_train</i> — material icon named "directions train". + static const IconData directions_train = IconData(0xe1da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_train</i> — material icon named "directions train" (sharp). + static const IconData directions_train_sharp = IconData(0xe8d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_train</i> — material icon named "directions train" (round). + static const IconData directions_train_rounded = IconData(0xf6b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_train</i> — material icon named "directions train" (outlined). + static const IconData directions_train_outlined = IconData(0xefca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_transit</i> — material icon named "directions transit". + static const IconData directions_transit = IconData(0xe1df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_transit</i> — material icon named "directions transit" (sharp). + static const IconData directions_transit_sharp = IconData(0xe8dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_transit</i> — material icon named "directions transit" (round). + static const IconData directions_transit_rounded = IconData(0xf6bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_transit</i> — material icon named "directions transit" (outlined). + static const IconData directions_transit_outlined = IconData(0xefcf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">directions_transit_filled</i> — material icon named "directions transit filled". + static const IconData directions_transit_filled = IconData(0xe1e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_transit_filled</i> — material icon named "directions transit filled" (sharp). + static const IconData directions_transit_filled_sharp = IconData( + 0xe8dc, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">directions_transit_filled</i> — material icon named "directions transit filled" (round). + static const IconData directions_transit_filled_rounded = IconData( + 0xf6bb, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">directions_transit_filled</i> — material icon named "directions transit filled" (outlined). + static const IconData directions_transit_filled_outlined = IconData( + 0xefce, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">directions_walk</i> — material icon named "directions walk". + static const IconData directions_walk = IconData(0xe1e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">directions_walk</i> — material icon named "directions walk" (sharp). + static const IconData directions_walk_sharp = IconData(0xe8de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">directions_walk</i> — material icon named "directions walk" (round). + static const IconData directions_walk_rounded = IconData(0xf6bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">directions_walk</i> — material icon named "directions walk" (outlined). + static const IconData directions_walk_outlined = IconData(0xefd0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dirty_lens</i> — material icon named "dirty lens". + static const IconData dirty_lens = IconData(0xe1e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dirty_lens</i> — material icon named "dirty lens" (sharp). + static const IconData dirty_lens_sharp = IconData(0xe8df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dirty_lens</i> — material icon named "dirty lens" (round). + static const IconData dirty_lens_rounded = IconData(0xf6be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dirty_lens</i> — material icon named "dirty lens" (outlined). + static const IconData dirty_lens_outlined = IconData(0xefd1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">disabled_by_default</i> — material icon named "disabled by default". + static const IconData disabled_by_default = IconData(0xe1e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">disabled_by_default</i> — material icon named "disabled by default" (sharp). + static const IconData disabled_by_default_sharp = IconData(0xe8e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">disabled_by_default</i> — material icon named "disabled by default" (round). + static const IconData disabled_by_default_rounded = IconData(0xf6bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">disabled_by_default</i> — material icon named "disabled by default" (outlined). + static const IconData disabled_by_default_outlined = IconData( + 0xefd2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">disabled_visible</i> — material icon named "disabled visible". + static const IconData disabled_visible = IconData(0xf04ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">disabled_visible</i> — material icon named "disabled visible" (sharp). + static const IconData disabled_visible_sharp = IconData(0xf03fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">disabled_visible</i> — material icon named "disabled visible" (round). + static const IconData disabled_visible_rounded = IconData(0xf0308, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">disabled_visible</i> — material icon named "disabled visible" (outlined). + static const IconData disabled_visible_outlined = IconData(0xf05e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">disc_full</i> — material icon named "disc full". + static const IconData disc_full = IconData(0xe1e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">disc_full</i> — material icon named "disc full" (sharp). + static const IconData disc_full_sharp = IconData(0xe8e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">disc_full</i> — material icon named "disc full" (round). + static const IconData disc_full_rounded = IconData(0xf6c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">disc_full</i> — material icon named "disc full" (outlined). + static const IconData disc_full_outlined = IconData(0xefd3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">discord</i> — material icon named "discord". + static const IconData discord = IconData(0xf04f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">discord</i> — material icon named "discord" (sharp). + static const IconData discord_sharp = IconData(0xf03fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">discord</i> — material icon named "discord" (round). + static const IconData discord_rounded = IconData(0xf0309, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">discord</i> — material icon named "discord" (outlined). + static const IconData discord_outlined = IconData(0xf05ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">discount</i> — material icon named "discount". + static const IconData discount = IconData(0xf06bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">discount</i> — material icon named "discount" (sharp). + static const IconData discount_sharp = IconData(0xf06b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">discount</i> — material icon named "discount" (round). + static const IconData discount_rounded = IconData(0xf06ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">discount</i> — material icon named "discount" (outlined). + static const IconData discount_outlined = IconData(0xf06a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">display_settings</i> — material icon named "display settings". + static const IconData display_settings = IconData(0xf04f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">display_settings</i> — material icon named "display settings" (sharp). + static const IconData display_settings_sharp = IconData(0xf03fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">display_settings</i> — material icon named "display settings" (round). + static const IconData display_settings_rounded = IconData(0xf030a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">display_settings</i> — material icon named "display settings" (outlined). + static const IconData display_settings_outlined = IconData(0xf05eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">diversity_1</i> — material icon named "diversity 1". + static const IconData diversity_1 = IconData(0xf085a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">diversity_1</i> — material icon named "diversity 1" (sharp). + static const IconData diversity_1_sharp = IconData(0xf0838, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">diversity_1</i> — material icon named "diversity 1" (round). + static const IconData diversity_1_rounded = IconData(0xf0881, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">diversity_1</i> — material icon named "diversity 1" (outlined). + static const IconData diversity_1_outlined = IconData(0xf089f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">diversity_2</i> — material icon named "diversity 2". + static const IconData diversity_2 = IconData(0xf085b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">diversity_2</i> — material icon named "diversity 2" (sharp). + static const IconData diversity_2_sharp = IconData(0xf0839, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">diversity_2</i> — material icon named "diversity 2" (round). + static const IconData diversity_2_rounded = IconData(0xf0882, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">diversity_2</i> — material icon named "diversity 2" (outlined). + static const IconData diversity_2_outlined = IconData(0xf08a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">diversity_3</i> — material icon named "diversity 3". + static const IconData diversity_3 = IconData(0xf085c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">diversity_3</i> — material icon named "diversity 3" (sharp). + static const IconData diversity_3_sharp = IconData(0xf083a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">diversity_3</i> — material icon named "diversity 3" (round). + static const IconData diversity_3_rounded = IconData(0xf0883, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">diversity_3</i> — material icon named "diversity 3" (outlined). + static const IconData diversity_3_outlined = IconData(0xf08a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dnd_forwardslash</i> — material icon named "dnd forwardslash". + static const IconData dnd_forwardslash = IconData(0xe1eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dnd_forwardslash</i> — material icon named "dnd forwardslash" (sharp). + static const IconData dnd_forwardslash_sharp = IconData(0xe8e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dnd_forwardslash</i> — material icon named "dnd forwardslash" (round). + static const IconData dnd_forwardslash_rounded = IconData(0xf6c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dnd_forwardslash</i> — material icon named "dnd forwardslash" (outlined). + static const IconData dnd_forwardslash_outlined = IconData(0xefd9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dns</i> — material icon named "dns". + static const IconData dns = IconData(0xe1e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dns</i> — material icon named "dns" (sharp). + static const IconData dns_sharp = IconData(0xe8e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dns</i> — material icon named "dns" (round). + static const IconData dns_rounded = IconData(0xf6c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dns</i> — material icon named "dns" (outlined). + static const IconData dns_outlined = IconData(0xefd4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_disturb</i> — material icon named "do disturb". + static const IconData do_disturb = IconData(0xe1e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_disturb</i> — material icon named "do disturb" (sharp). + static const IconData do_disturb_sharp = IconData(0xe8e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_disturb</i> — material icon named "do disturb" (round). + static const IconData do_disturb_rounded = IconData(0xf6c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_disturb</i> — material icon named "do disturb" (outlined). + static const IconData do_disturb_outlined = IconData(0xefd8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_disturb_alt</i> — material icon named "do disturb alt". + static const IconData do_disturb_alt = IconData(0xe1e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_disturb_alt</i> — material icon named "do disturb alt" (sharp). + static const IconData do_disturb_alt_sharp = IconData(0xe8e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_disturb_alt</i> — material icon named "do disturb alt" (round). + static const IconData do_disturb_alt_rounded = IconData(0xf6c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_disturb_alt</i> — material icon named "do disturb alt" (outlined). + static const IconData do_disturb_alt_outlined = IconData(0xefd5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_disturb_off</i> — material icon named "do disturb off". + static const IconData do_disturb_off = IconData(0xe1e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_disturb_off</i> — material icon named "do disturb off" (sharp). + static const IconData do_disturb_off_sharp = IconData(0xe8e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_disturb_off</i> — material icon named "do disturb off" (round). + static const IconData do_disturb_off_rounded = IconData(0xf6c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_disturb_off</i> — material icon named "do disturb off" (outlined). + static const IconData do_disturb_off_outlined = IconData(0xefd6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_disturb_on</i> — material icon named "do disturb on". + static const IconData do_disturb_on = IconData(0xe1e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_disturb_on</i> — material icon named "do disturb on" (sharp). + static const IconData do_disturb_on_sharp = IconData(0xe8e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_disturb_on</i> — material icon named "do disturb on" (round). + static const IconData do_disturb_on_rounded = IconData(0xf6c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_disturb_on</i> — material icon named "do disturb on" (outlined). + static const IconData do_disturb_on_outlined = IconData(0xefd7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_not_disturb</i> — material icon named "do not disturb". + static const IconData do_not_disturb = IconData(0xe1ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_not_disturb</i> — material icon named "do not disturb" (sharp). + static const IconData do_not_disturb_sharp = IconData(0xe8eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_not_disturb</i> — material icon named "do not disturb" (round). + static const IconData do_not_disturb_rounded = IconData(0xf6ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_not_disturb</i> — material icon named "do not disturb" (outlined). + static const IconData do_not_disturb_outlined = IconData(0xefdd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_not_disturb_alt</i> — material icon named "do not disturb alt". + static const IconData do_not_disturb_alt = IconData(0xe1eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_not_disturb_alt</i> — material icon named "do not disturb alt" (sharp). + static const IconData do_not_disturb_alt_sharp = IconData(0xe8e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_not_disturb_alt</i> — material icon named "do not disturb alt" (round). + static const IconData do_not_disturb_alt_rounded = IconData(0xf6c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_not_disturb_alt</i> — material icon named "do not disturb alt" (outlined). + static const IconData do_not_disturb_alt_outlined = IconData(0xefd9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_not_disturb_off</i> — material icon named "do not disturb off". + static const IconData do_not_disturb_off = IconData(0xe1ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_not_disturb_off</i> — material icon named "do not disturb off" (sharp). + static const IconData do_not_disturb_off_sharp = IconData(0xe8e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_not_disturb_off</i> — material icon named "do not disturb off" (round). + static const IconData do_not_disturb_off_rounded = IconData(0xf6c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_not_disturb_off</i> — material icon named "do not disturb off" (outlined). + static const IconData do_not_disturb_off_outlined = IconData(0xefda, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_not_disturb_on</i> — material icon named "do not disturb on". + static const IconData do_not_disturb_on = IconData(0xe1ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_not_disturb_on</i> — material icon named "do not disturb on" (sharp). + static const IconData do_not_disturb_on_sharp = IconData(0xe8e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_not_disturb_on</i> — material icon named "do not disturb on" (round). + static const IconData do_not_disturb_on_rounded = IconData(0xf6c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_not_disturb_on</i> — material icon named "do not disturb on" (outlined). + static const IconData do_not_disturb_on_outlined = IconData(0xefdb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_not_disturb_on_total_silence</i> — material icon named "do not disturb on total silence". + static const IconData do_not_disturb_on_total_silence = IconData( + 0xe1ee, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">do_not_disturb_on_total_silence</i> — material icon named "do not disturb on total silence" (sharp). + static const IconData do_not_disturb_on_total_silence_sharp = IconData( + 0xe8ea, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">do_not_disturb_on_total_silence</i> — material icon named "do not disturb on total silence" (round). + static const IconData do_not_disturb_on_total_silence_rounded = IconData( + 0xf6c9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">do_not_disturb_on_total_silence</i> — material icon named "do not disturb on total silence" (outlined). + static const IconData do_not_disturb_on_total_silence_outlined = IconData( + 0xefdc, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">do_not_step</i> — material icon named "do not step". + static const IconData do_not_step = IconData(0xe1ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_not_step</i> — material icon named "do not step" (sharp). + static const IconData do_not_step_sharp = IconData(0xe8ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_not_step</i> — material icon named "do not step" (round). + static const IconData do_not_step_rounded = IconData(0xf6cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_not_step</i> — material icon named "do not step" (outlined). + static const IconData do_not_step_outlined = IconData(0xefde, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">do_not_touch</i> — material icon named "do not touch". + static const IconData do_not_touch = IconData(0xe1f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">do_not_touch</i> — material icon named "do not touch" (sharp). + static const IconData do_not_touch_sharp = IconData(0xe8ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">do_not_touch</i> — material icon named "do not touch" (round). + static const IconData do_not_touch_rounded = IconData(0xf6cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">do_not_touch</i> — material icon named "do not touch" (outlined). + static const IconData do_not_touch_outlined = IconData(0xefdf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dock</i> — material icon named "dock". + static const IconData dock = IconData(0xe1f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dock</i> — material icon named "dock" (sharp). + static const IconData dock_sharp = IconData(0xe8ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dock</i> — material icon named "dock" (round). + static const IconData dock_rounded = IconData(0xf6cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dock</i> — material icon named "dock" (outlined). + static const IconData dock_outlined = IconData(0xefe0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">document_scanner</i> — material icon named "document scanner". + static const IconData document_scanner = IconData(0xe1f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">document_scanner</i> — material icon named "document scanner" (sharp). + static const IconData document_scanner_sharp = IconData(0xe8ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">document_scanner</i> — material icon named "document scanner" (round). + static const IconData document_scanner_rounded = IconData(0xf6ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">document_scanner</i> — material icon named "document scanner" (outlined). + static const IconData document_scanner_outlined = IconData(0xefe1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">domain</i> — material icon named "domain". + static const IconData domain = IconData(0xe1f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">domain</i> — material icon named "domain" (sharp). + static const IconData domain_sharp = IconData(0xe8f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">domain</i> — material icon named "domain" (round). + static const IconData domain_rounded = IconData(0xf6d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">domain</i> — material icon named "domain" (outlined). + static const IconData domain_outlined = IconData(0xefe3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">domain_add</i> — material icon named "domain add". + static const IconData domain_add = IconData(0xf04f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">domain_add</i> — material icon named "domain add" (sharp). + static const IconData domain_add_sharp = IconData(0xf03fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">domain_add</i> — material icon named "domain add" (round). + static const IconData domain_add_rounded = IconData(0xf030b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">domain_add</i> — material icon named "domain add" (outlined). + static const IconData domain_add_outlined = IconData(0xf05ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">domain_disabled</i> — material icon named "domain disabled". + static const IconData domain_disabled = IconData(0xe1f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">domain_disabled</i> — material icon named "domain disabled" (sharp). + static const IconData domain_disabled_sharp = IconData(0xe8f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">domain_disabled</i> — material icon named "domain disabled" (round). + static const IconData domain_disabled_rounded = IconData(0xf6cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">domain_disabled</i> — material icon named "domain disabled" (outlined). + static const IconData domain_disabled_outlined = IconData(0xefe2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">domain_verification</i> — material icon named "domain verification". + static const IconData domain_verification = IconData(0xe1f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">domain_verification</i> — material icon named "domain verification" (sharp). + static const IconData domain_verification_sharp = IconData(0xe8f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">domain_verification</i> — material icon named "domain verification" (round). + static const IconData domain_verification_rounded = IconData(0xf6d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">domain_verification</i> — material icon named "domain verification" (outlined). + static const IconData domain_verification_outlined = IconData( + 0xefe4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">done</i> — material icon named "done". + static const IconData done = IconData(0xe1f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">done</i> — material icon named "done" (sharp). + static const IconData done_sharp = IconData(0xe8f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">done</i> — material icon named "done" (round). + static const IconData done_rounded = IconData(0xf6d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">done</i> — material icon named "done" (outlined). + static const IconData done_outlined = IconData(0xefe7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">done_all</i> — material icon named "done all". + static const IconData done_all = IconData(0xe1f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">done_all</i> — material icon named "done all" (sharp). + static const IconData done_all_sharp = IconData(0xe8f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">done_all</i> — material icon named "done all" (round). + static const IconData done_all_rounded = IconData(0xf6d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">done_all</i> — material icon named "done all" (outlined). + static const IconData done_all_outlined = IconData(0xefe5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">done_outline</i> — material icon named "done outline". + static const IconData done_outline = IconData(0xe1f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">done_outline</i> — material icon named "done outline" (sharp). + static const IconData done_outline_sharp = IconData(0xe8f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">done_outline</i> — material icon named "done outline" (round). + static const IconData done_outline_rounded = IconData(0xf6d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">done_outline</i> — material icon named "done outline" (outlined). + static const IconData done_outline_outlined = IconData(0xefe6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">donut_large</i> — material icon named "donut large". + static const IconData donut_large = IconData(0xe1f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">donut_large</i> — material icon named "donut large" (sharp). + static const IconData donut_large_sharp = IconData(0xe8f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">donut_large</i> — material icon named "donut large" (round). + static const IconData donut_large_rounded = IconData(0xf6d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">donut_large</i> — material icon named "donut large" (outlined). + static const IconData donut_large_outlined = IconData(0xefe8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">donut_small</i> — material icon named "donut small". + static const IconData donut_small = IconData(0xe1fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">donut_small</i> — material icon named "donut small" (sharp). + static const IconData donut_small_sharp = IconData(0xe8f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">donut_small</i> — material icon named "donut small" (round). + static const IconData donut_small_rounded = IconData(0xf6d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">donut_small</i> — material icon named "donut small" (outlined). + static const IconData donut_small_outlined = IconData(0xefe9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">door_back</i> — material icon named "door back". + static const IconData door_back_door = IconData(0xe1fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">door_back</i> — material icon named "door back" (sharp). + static const IconData door_back_door_sharp = IconData(0xe8f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">door_back</i> — material icon named "door back" (round). + static const IconData door_back_door_rounded = IconData(0xf6d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">door_back</i> — material icon named "door back" (outlined). + static const IconData door_back_door_outlined = IconData(0xefea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">door_front</i> — material icon named "door front". + static const IconData door_front_door = IconData(0xe1fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">door_front</i> — material icon named "door front" (sharp). + static const IconData door_front_door_sharp = IconData(0xe8f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">door_front</i> — material icon named "door front" (round). + static const IconData door_front_door_rounded = IconData(0xf6d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">door_front</i> — material icon named "door front" (outlined). + static const IconData door_front_door_outlined = IconData(0xefeb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">door_sliding</i> — material icon named "door sliding". + static const IconData door_sliding = IconData(0xe1fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">door_sliding</i> — material icon named "door sliding" (sharp). + static const IconData door_sliding_sharp = IconData(0xe8fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">door_sliding</i> — material icon named "door sliding" (round). + static const IconData door_sliding_rounded = IconData(0xf6d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">door_sliding</i> — material icon named "door sliding" (outlined). + static const IconData door_sliding_outlined = IconData(0xefec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">doorbell</i> — material icon named "doorbell". + static const IconData doorbell = IconData(0xe1fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">doorbell</i> — material icon named "doorbell" (sharp). + static const IconData doorbell_sharp = IconData(0xe8fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">doorbell</i> — material icon named "doorbell" (round). + static const IconData doorbell_rounded = IconData(0xf6da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">doorbell</i> — material icon named "doorbell" (outlined). + static const IconData doorbell_outlined = IconData(0xefed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">double_arrow</i> — material icon named "double arrow". + static const IconData double_arrow = IconData(0xe1ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">double_arrow</i> — material icon named "double arrow" (sharp). + static const IconData double_arrow_sharp = IconData(0xe8fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">double_arrow</i> — material icon named "double arrow" (round). + static const IconData double_arrow_rounded = IconData(0xf6db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">double_arrow</i> — material icon named "double arrow" (outlined). + static const IconData double_arrow_outlined = IconData(0xefee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">downhill_skiing</i> — material icon named "downhill skiing". + static const IconData downhill_skiing = IconData(0xe200, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">downhill_skiing</i> — material icon named "downhill skiing" (sharp). + static const IconData downhill_skiing_sharp = IconData(0xe8fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">downhill_skiing</i> — material icon named "downhill skiing" (round). + static const IconData downhill_skiing_rounded = IconData(0xf6dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">downhill_skiing</i> — material icon named "downhill skiing" (outlined). + static const IconData downhill_skiing_outlined = IconData(0xefef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">download</i> — material icon named "download". + static const IconData download = IconData(0xe201, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">download</i> — material icon named "download" (sharp). + static const IconData download_sharp = IconData(0xe900, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">download</i> — material icon named "download" (round). + static const IconData download_rounded = IconData(0xf6df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">download</i> — material icon named "download" (outlined). + static const IconData download_outlined = IconData(0xeff2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">download_done</i> — material icon named "download done". + static const IconData download_done = IconData(0xe202, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">download_done</i> — material icon named "download done" (sharp). + static const IconData download_done_sharp = IconData(0xe8fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">download_done</i> — material icon named "download done" (round). + static const IconData download_done_rounded = IconData(0xf6dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">download_done</i> — material icon named "download done" (outlined). + static const IconData download_done_outlined = IconData(0xeff0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">download_for_offline</i> — material icon named "download for offline". + static const IconData download_for_offline = IconData(0xe203, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">download_for_offline</i> — material icon named "download for offline" (sharp). + static const IconData download_for_offline_sharp = IconData(0xe8ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">download_for_offline</i> — material icon named "download for offline" (round). + static const IconData download_for_offline_rounded = IconData( + 0xf6de, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">download_for_offline</i> — material icon named "download for offline" (outlined). + static const IconData download_for_offline_outlined = IconData( + 0xeff1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">downloading</i> — material icon named "downloading". + static const IconData downloading = IconData(0xe204, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">downloading</i> — material icon named "downloading" (sharp). + static const IconData downloading_sharp = IconData(0xe901, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">downloading</i> — material icon named "downloading" (round). + static const IconData downloading_rounded = IconData(0xf6e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">downloading</i> — material icon named "downloading" (outlined). + static const IconData downloading_outlined = IconData(0xeff3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drafts</i> — material icon named "drafts". + static const IconData drafts = IconData(0xe205, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drafts</i> — material icon named "drafts" (sharp). + static const IconData drafts_sharp = IconData(0xe902, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drafts</i> — material icon named "drafts" (round). + static const IconData drafts_rounded = IconData(0xf6e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">drafts</i> — material icon named "drafts" (outlined). + static const IconData drafts_outlined = IconData(0xeff4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drag_handle</i> — material icon named "drag handle". + static const IconData drag_handle = IconData(0xe206, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drag_handle</i> — material icon named "drag handle" (sharp). + static const IconData drag_handle_sharp = IconData(0xe903, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drag_handle</i> — material icon named "drag handle" (round). + static const IconData drag_handle_rounded = IconData(0xf6e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">drag_handle</i> — material icon named "drag handle" (outlined). + static const IconData drag_handle_outlined = IconData(0xeff5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drag_indicator</i> — material icon named "drag indicator". + static const IconData drag_indicator = IconData(0xe207, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drag_indicator</i> — material icon named "drag indicator" (sharp). + static const IconData drag_indicator_sharp = IconData(0xe904, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drag_indicator</i> — material icon named "drag indicator" (round). + static const IconData drag_indicator_rounded = IconData(0xf6e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">drag_indicator</i> — material icon named "drag indicator" (outlined). + static const IconData drag_indicator_outlined = IconData(0xeff6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">draw</i> — material icon named "draw". + static const IconData draw = IconData(0xf04f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">draw</i> — material icon named "draw" (sharp). + static const IconData draw_sharp = IconData(0xf03ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">draw</i> — material icon named "draw" (round). + static const IconData draw_rounded = IconData(0xf030c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">draw</i> — material icon named "draw" (outlined). + static const IconData draw_outlined = IconData(0xf05ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drive_eta</i> — material icon named "drive eta". + static const IconData drive_eta = IconData(0xe208, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drive_eta</i> — material icon named "drive eta" (sharp). + static const IconData drive_eta_sharp = IconData(0xe905, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drive_eta</i> — material icon named "drive eta" (round). + static const IconData drive_eta_rounded = IconData(0xf6e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">drive_eta</i> — material icon named "drive eta" (outlined). + static const IconData drive_eta_outlined = IconData(0xeff7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drive_file_move</i> — material icon named "drive file move". + static const IconData drive_file_move = IconData(0xe209, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drive_file_move</i> — material icon named "drive file move" (sharp). + static const IconData drive_file_move_sharp = IconData(0xe906, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drive_file_move</i> — material icon named "drive file move" (round). + static const IconData drive_file_move_rounded = IconData(0xf6e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">drive_file_move</i> — material icon named "drive file move" (outlined). + static const IconData drive_file_move_outlined = IconData(0xeff8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drive_file_move_outline</i> — material icon named "drive file move outline". + static const IconData drive_file_move_outline = IconData(0xe20a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">drive_file_move_rtl</i> — material icon named "drive file move rtl". + static const IconData drive_file_move_rtl = IconData(0xf04f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drive_file_move_rtl</i> — material icon named "drive file move rtl" (sharp). + static const IconData drive_file_move_rtl_sharp = IconData(0xf0400, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drive_file_move_rtl</i> — material icon named "drive file move rtl" (round). + static const IconData drive_file_move_rtl_rounded = IconData( + 0xf030d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">drive_file_move_rtl</i> — material icon named "drive file move rtl" (outlined). + static const IconData drive_file_move_rtl_outlined = IconData( + 0xf05ee, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">drive_file_rename_outline</i> — material icon named "drive file rename outline". + static const IconData drive_file_rename_outline = IconData(0xe20b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drive_file_rename_outline</i> — material icon named "drive file rename outline" (sharp). + static const IconData drive_file_rename_outline_sharp = IconData( + 0xe907, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">drive_file_rename_outline</i> — material icon named "drive file rename outline" (round). + static const IconData drive_file_rename_outline_rounded = IconData( + 0xf6e6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">drive_file_rename_outline</i> — material icon named "drive file rename outline" (outlined). + static const IconData drive_file_rename_outline_outlined = IconData( + 0xeff9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">drive_folder_upload</i> — material icon named "drive folder upload". + static const IconData drive_folder_upload = IconData(0xe20c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">drive_folder_upload</i> — material icon named "drive folder upload" (sharp). + static const IconData drive_folder_upload_sharp = IconData(0xe908, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">drive_folder_upload</i> — material icon named "drive folder upload" (round). + static const IconData drive_folder_upload_rounded = IconData(0xf6e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">drive_folder_upload</i> — material icon named "drive folder upload" (outlined). + static const IconData drive_folder_upload_outlined = IconData( + 0xeffa, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">dry</i> — material icon named "dry". + static const IconData dry = IconData(0xe20d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dry</i> — material icon named "dry" (sharp). + static const IconData dry_sharp = IconData(0xe90a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dry</i> — material icon named "dry" (round). + static const IconData dry_rounded = IconData(0xf6e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dry</i> — material icon named "dry" (outlined). + static const IconData dry_outlined = IconData(0xeffc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dry_cleaning</i> — material icon named "dry cleaning". + static const IconData dry_cleaning = IconData(0xe20e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dry_cleaning</i> — material icon named "dry cleaning" (sharp). + static const IconData dry_cleaning_sharp = IconData(0xe909, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dry_cleaning</i> — material icon named "dry cleaning" (round). + static const IconData dry_cleaning_rounded = IconData(0xf6e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dry_cleaning</i> — material icon named "dry cleaning" (outlined). + static const IconData dry_cleaning_outlined = IconData(0xeffb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">duo</i> — material icon named "duo". + static const IconData duo = IconData(0xe20f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">duo</i> — material icon named "duo" (sharp). + static const IconData duo_sharp = IconData(0xe90b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">duo</i> — material icon named "duo" (round). + static const IconData duo_rounded = IconData(0xf6ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">duo</i> — material icon named "duo" (outlined). + static const IconData duo_outlined = IconData(0xeffd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dvr</i> — material icon named "dvr". + static const IconData dvr = IconData( + 0xe210, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">dvr</i> — material icon named "dvr" (sharp). + static const IconData dvr_sharp = IconData( + 0xe90c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">dvr</i> — material icon named "dvr" (round). + static const IconData dvr_rounded = IconData( + 0xf6eb, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">dvr</i> — material icon named "dvr" (outlined). + static const IconData dvr_outlined = IconData( + 0xeffe, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">dynamic_feed</i> — material icon named "dynamic feed". + static const IconData dynamic_feed = IconData(0xe211, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dynamic_feed</i> — material icon named "dynamic feed" (sharp). + static const IconData dynamic_feed_sharp = IconData(0xe90d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dynamic_feed</i> — material icon named "dynamic feed" (round). + static const IconData dynamic_feed_rounded = IconData(0xf6ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dynamic_feed</i> — material icon named "dynamic feed" (outlined). + static const IconData dynamic_feed_outlined = IconData(0xefff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">dynamic_form</i> — material icon named "dynamic form". + static const IconData dynamic_form = IconData(0xe212, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">dynamic_form</i> — material icon named "dynamic form" (sharp). + static const IconData dynamic_form_sharp = IconData(0xe90e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">dynamic_form</i> — material icon named "dynamic form" (round). + static const IconData dynamic_form_rounded = IconData(0xf6ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">dynamic_form</i> — material icon named "dynamic form" (outlined). + static const IconData dynamic_form_outlined = IconData(0xf000, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">e_mobiledata</i> — material icon named "e mobiledata". + static const IconData e_mobiledata = IconData(0xe213, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">e_mobiledata</i> — material icon named "e mobiledata" (sharp). + static const IconData e_mobiledata_sharp = IconData(0xe90f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">e_mobiledata</i> — material icon named "e mobiledata" (round). + static const IconData e_mobiledata_rounded = IconData(0xf6ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">e_mobiledata</i> — material icon named "e mobiledata" (outlined). + static const IconData e_mobiledata_outlined = IconData(0xf001, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">earbuds</i> — material icon named "earbuds". + static const IconData earbuds = IconData(0xe214, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">earbuds</i> — material icon named "earbuds" (sharp). + static const IconData earbuds_sharp = IconData(0xe911, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">earbuds</i> — material icon named "earbuds" (round). + static const IconData earbuds_rounded = IconData(0xf6f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">earbuds</i> — material icon named "earbuds" (outlined). + static const IconData earbuds_outlined = IconData(0xf003, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">earbuds_battery</i> — material icon named "earbuds battery". + static const IconData earbuds_battery = IconData(0xe215, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">earbuds_battery</i> — material icon named "earbuds battery" (sharp). + static const IconData earbuds_battery_sharp = IconData(0xe910, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">earbuds_battery</i> — material icon named "earbuds battery" (round). + static const IconData earbuds_battery_rounded = IconData(0xf6ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">earbuds_battery</i> — material icon named "earbuds battery" (outlined). + static const IconData earbuds_battery_outlined = IconData(0xf002, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">east</i> — material icon named "east". + static const IconData east = IconData(0xe216, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">east</i> — material icon named "east" (sharp). + static const IconData east_sharp = IconData(0xe912, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">east</i> — material icon named "east" (round). + static const IconData east_rounded = IconData(0xf6f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">east</i> — material icon named "east" (outlined). + static const IconData east_outlined = IconData(0xf004, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">eco</i> — material icon named "eco". + static const IconData eco = IconData(0xe217, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">eco</i> — material icon named "eco" (sharp). + static const IconData eco_sharp = IconData(0xe913, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">eco</i> — material icon named "eco" (round). + static const IconData eco_rounded = IconData(0xf6f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">eco</i> — material icon named "eco" (outlined). + static const IconData eco_outlined = IconData(0xf005, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edgesensor_high</i> — material icon named "edgesensor high". + static const IconData edgesensor_high = IconData(0xe218, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edgesensor_high</i> — material icon named "edgesensor high" (sharp). + static const IconData edgesensor_high_sharp = IconData(0xe914, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edgesensor_high</i> — material icon named "edgesensor high" (round). + static const IconData edgesensor_high_rounded = IconData(0xf6f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edgesensor_high</i> — material icon named "edgesensor high" (outlined). + static const IconData edgesensor_high_outlined = IconData(0xf006, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edgesensor_low</i> — material icon named "edgesensor low". + static const IconData edgesensor_low = IconData(0xe219, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edgesensor_low</i> — material icon named "edgesensor low" (sharp). + static const IconData edgesensor_low_sharp = IconData(0xe915, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edgesensor_low</i> — material icon named "edgesensor low" (round). + static const IconData edgesensor_low_rounded = IconData(0xf6f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edgesensor_low</i> — material icon named "edgesensor low" (outlined). + static const IconData edgesensor_low_outlined = IconData(0xf007, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit</i> — material icon named "edit". + static const IconData edit = IconData(0xe21a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit</i> — material icon named "edit" (sharp). + static const IconData edit_sharp = IconData(0xe91c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit</i> — material icon named "edit" (round). + static const IconData edit_rounded = IconData(0xf6fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit</i> — material icon named "edit" (outlined). + static const IconData edit_outlined = IconData(0xf00d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_attributes</i> — material icon named "edit attributes". + static const IconData edit_attributes = IconData(0xe21b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_attributes</i> — material icon named "edit attributes" (sharp). + static const IconData edit_attributes_sharp = IconData(0xe916, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_attributes</i> — material icon named "edit attributes" (round). + static const IconData edit_attributes_rounded = IconData(0xf6f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_attributes</i> — material icon named "edit attributes" (outlined). + static const IconData edit_attributes_outlined = IconData(0xf008, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_calendar</i> — material icon named "edit calendar". + static const IconData edit_calendar = IconData(0xf04f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_calendar</i> — material icon named "edit calendar" (sharp). + static const IconData edit_calendar_sharp = IconData(0xf0401, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_calendar</i> — material icon named "edit calendar" (round). + static const IconData edit_calendar_rounded = IconData(0xf030e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_calendar</i> — material icon named "edit calendar" (outlined). + static const IconData edit_calendar_outlined = IconData(0xf05ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_document</i> — material icon named "edit document". + static const IconData edit_document = IconData(0xf085d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_location</i> — material icon named "edit location". + static const IconData edit_location = IconData(0xe21c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_location</i> — material icon named "edit location" (sharp). + static const IconData edit_location_sharp = IconData(0xe918, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_location</i> — material icon named "edit location" (round). + static const IconData edit_location_rounded = IconData(0xf6f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_location</i> — material icon named "edit location" (outlined). + static const IconData edit_location_outlined = IconData(0xf00a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_location_alt</i> — material icon named "edit location alt". + static const IconData edit_location_alt = IconData(0xe21d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_location_alt</i> — material icon named "edit location alt" (sharp). + static const IconData edit_location_alt_sharp = IconData(0xe917, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_location_alt</i> — material icon named "edit location alt" (round). + static const IconData edit_location_alt_rounded = IconData(0xf6f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_location_alt</i> — material icon named "edit location alt" (outlined). + static const IconData edit_location_alt_outlined = IconData(0xf009, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_note</i> — material icon named "edit note". + static const IconData edit_note = IconData(0xf04f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_note</i> — material icon named "edit note" (sharp). + static const IconData edit_note_sharp = IconData(0xf0402, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_note</i> — material icon named "edit note" (round). + static const IconData edit_note_rounded = IconData(0xf030f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_note</i> — material icon named "edit note" (outlined). + static const IconData edit_note_outlined = IconData(0xf05f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_notifications</i> — material icon named "edit notifications". + static const IconData edit_notifications = IconData(0xe21e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_notifications</i> — material icon named "edit notifications" (sharp). + static const IconData edit_notifications_sharp = IconData(0xe919, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_notifications</i> — material icon named "edit notifications" (round). + static const IconData edit_notifications_rounded = IconData(0xf6f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_notifications</i> — material icon named "edit notifications" (outlined). + static const IconData edit_notifications_outlined = IconData(0xf00b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_off</i> — material icon named "edit off". + static const IconData edit_off = IconData(0xe21f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_off</i> — material icon named "edit off" (sharp). + static const IconData edit_off_sharp = IconData(0xe91a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_off</i> — material icon named "edit off" (round). + static const IconData edit_off_rounded = IconData(0xf6f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_off</i> — material icon named "edit off" (outlined). + static const IconData edit_off_outlined = IconData(0xf00c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_road</i> — material icon named "edit road". + static const IconData edit_road = IconData(0xe220, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">edit_road</i> — material icon named "edit road" (sharp). + static const IconData edit_road_sharp = IconData(0xe91b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">edit_road</i> — material icon named "edit road" (round). + static const IconData edit_road_rounded = IconData(0xf6fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">edit_road</i> — material icon named "edit road" (outlined). + static const IconData edit_road_outlined = IconData(0xf00e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">edit_square</i> — material icon named "edit square". + static const IconData edit_square = IconData(0xf085e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">egg</i> — material icon named "egg". + static const IconData egg = IconData(0xf04f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">egg</i> — material icon named "egg" (sharp). + static const IconData egg_sharp = IconData(0xf0404, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">egg</i> — material icon named "egg" (round). + static const IconData egg_rounded = IconData(0xf0311, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">egg</i> — material icon named "egg" (outlined). + static const IconData egg_outlined = IconData(0xf05f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">egg_alt</i> — material icon named "egg alt". + static const IconData egg_alt = IconData(0xf04f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">egg_alt</i> — material icon named "egg alt" (sharp). + static const IconData egg_alt_sharp = IconData(0xf0403, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">egg_alt</i> — material icon named "egg alt" (round). + static const IconData egg_alt_rounded = IconData(0xf0310, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">egg_alt</i> — material icon named "egg alt" (outlined). + static const IconData egg_alt_outlined = IconData(0xf05f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">eject</i> — material icon named "eject". + static const IconData eject = IconData(0xe221, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">eject</i> — material icon named "eject" (sharp). + static const IconData eject_sharp = IconData(0xe91d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">eject</i> — material icon named "eject" (round). + static const IconData eject_rounded = IconData(0xf6fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">eject</i> — material icon named "eject" (outlined). + static const IconData eject_outlined = IconData(0xf00f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">elderly</i> — material icon named "elderly". + static const IconData elderly = IconData(0xe222, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">elderly</i> — material icon named "elderly" (sharp). + static const IconData elderly_sharp = IconData(0xe91e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">elderly</i> — material icon named "elderly" (round). + static const IconData elderly_rounded = IconData(0xf6fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">elderly</i> — material icon named "elderly" (outlined). + static const IconData elderly_outlined = IconData(0xf010, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">elderly_woman</i> — material icon named "elderly woman". + static const IconData elderly_woman = IconData(0xf04f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">elderly_woman</i> — material icon named "elderly woman" (sharp). + static const IconData elderly_woman_sharp = IconData(0xf0405, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">elderly_woman</i> — material icon named "elderly woman" (round). + static const IconData elderly_woman_rounded = IconData(0xf0312, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">elderly_woman</i> — material icon named "elderly woman" (outlined). + static const IconData elderly_woman_outlined = IconData(0xf05f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_bike</i> — material icon named "electric bike". + static const IconData electric_bike = IconData(0xe223, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_bike</i> — material icon named "electric bike" (sharp). + static const IconData electric_bike_sharp = IconData(0xe91f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_bike</i> — material icon named "electric bike" (round). + static const IconData electric_bike_rounded = IconData(0xf6fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_bike</i> — material icon named "electric bike" (outlined). + static const IconData electric_bike_outlined = IconData(0xf011, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_bolt</i> — material icon named "electric bolt". + static const IconData electric_bolt = IconData(0xf079c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_bolt</i> — material icon named "electric bolt" (sharp). + static const IconData electric_bolt_sharp = IconData(0xf0744, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_bolt</i> — material icon named "electric bolt" (round). + static const IconData electric_bolt_rounded = IconData(0xf07f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_bolt</i> — material icon named "electric bolt" (outlined). + static const IconData electric_bolt_outlined = IconData(0xf06ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_car</i> — material icon named "electric car". + static const IconData electric_car = IconData(0xe224, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_car</i> — material icon named "electric car" (sharp). + static const IconData electric_car_sharp = IconData(0xe920, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_car</i> — material icon named "electric car" (round). + static const IconData electric_car_rounded = IconData(0xf6ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_car</i> — material icon named "electric car" (outlined). + static const IconData electric_car_outlined = IconData(0xf012, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_meter</i> — material icon named "electric meter". + static const IconData electric_meter = IconData(0xf079d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_meter</i> — material icon named "electric meter" (sharp). + static const IconData electric_meter_sharp = IconData(0xf0745, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_meter</i> — material icon named "electric meter" (round). + static const IconData electric_meter_rounded = IconData(0xf07f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_meter</i> — material icon named "electric meter" (outlined). + static const IconData electric_meter_outlined = IconData(0xf06ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_moped</i> — material icon named "electric moped". + static const IconData electric_moped = IconData(0xe225, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_moped</i> — material icon named "electric moped" (sharp). + static const IconData electric_moped_sharp = IconData(0xe921, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_moped</i> — material icon named "electric moped" (round). + static const IconData electric_moped_rounded = IconData(0xf700, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_moped</i> — material icon named "electric moped" (outlined). + static const IconData electric_moped_outlined = IconData(0xf013, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_rickshaw</i> — material icon named "electric rickshaw". + static const IconData electric_rickshaw = IconData(0xe226, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_rickshaw</i> — material icon named "electric rickshaw" (sharp). + static const IconData electric_rickshaw_sharp = IconData(0xe922, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_rickshaw</i> — material icon named "electric rickshaw" (round). + static const IconData electric_rickshaw_rounded = IconData(0xf701, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_rickshaw</i> — material icon named "electric rickshaw" (outlined). + static const IconData electric_rickshaw_outlined = IconData(0xf014, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electric_scooter</i> — material icon named "electric scooter". + static const IconData electric_scooter = IconData(0xe227, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electric_scooter</i> — material icon named "electric scooter" (sharp). + static const IconData electric_scooter_sharp = IconData(0xe923, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electric_scooter</i> — material icon named "electric scooter" (round). + static const IconData electric_scooter_rounded = IconData(0xf702, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electric_scooter</i> — material icon named "electric scooter" (outlined). + static const IconData electric_scooter_outlined = IconData(0xf015, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">electrical_services</i> — material icon named "electrical services". + static const IconData electrical_services = IconData(0xe228, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">electrical_services</i> — material icon named "electrical services" (sharp). + static const IconData electrical_services_sharp = IconData(0xe924, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">electrical_services</i> — material icon named "electrical services" (round). + static const IconData electrical_services_rounded = IconData(0xf703, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">electrical_services</i> — material icon named "electrical services" (outlined). + static const IconData electrical_services_outlined = IconData( + 0xf016, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">elevator</i> — material icon named "elevator". + static const IconData elevator = IconData(0xe229, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">elevator</i> — material icon named "elevator" (sharp). + static const IconData elevator_sharp = IconData(0xe925, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">elevator</i> — material icon named "elevator" (round). + static const IconData elevator_rounded = IconData(0xf704, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">elevator</i> — material icon named "elevator" (outlined). + static const IconData elevator_outlined = IconData(0xf017, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">email</i> — material icon named "email". + static const IconData email = IconData(0xe22a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">email</i> — material icon named "email" (sharp). + static const IconData email_sharp = IconData(0xe926, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">email</i> — material icon named "email" (round). + static const IconData email_rounded = IconData(0xf705, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">email</i> — material icon named "email" (outlined). + static const IconData email_outlined = IconData(0xf018, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emergency</i> — material icon named "emergency". + static const IconData emergency = IconData(0xf04fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emergency</i> — material icon named "emergency" (sharp). + static const IconData emergency_sharp = IconData(0xf0406, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emergency</i> — material icon named "emergency" (round). + static const IconData emergency_rounded = IconData(0xf0313, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emergency</i> — material icon named "emergency" (outlined). + static const IconData emergency_outlined = IconData(0xf05f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emergency_recording</i> — material icon named "emergency recording". + static const IconData emergency_recording = IconData(0xf079e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emergency_recording</i> — material icon named "emergency recording" (sharp). + static const IconData emergency_recording_sharp = IconData(0xf0746, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emergency_recording</i> — material icon named "emergency recording" (round). + static const IconData emergency_recording_rounded = IconData( + 0xf07f6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">emergency_recording</i> — material icon named "emergency recording" (outlined). + static const IconData emergency_recording_outlined = IconData( + 0xf06ee, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">emergency_share</i> — material icon named "emergency share". + static const IconData emergency_share = IconData(0xf079f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emergency_share</i> — material icon named "emergency share" (sharp). + static const IconData emergency_share_sharp = IconData(0xf0747, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emergency_share</i> — material icon named "emergency share" (round). + static const IconData emergency_share_rounded = IconData(0xf07f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emergency_share</i> — material icon named "emergency share" (outlined). + static const IconData emergency_share_outlined = IconData(0xf06ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_emotions</i> — material icon named "emoji emotions". + static const IconData emoji_emotions = IconData(0xe22b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_emotions</i> — material icon named "emoji emotions" (sharp). + static const IconData emoji_emotions_sharp = IconData(0xe927, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_emotions</i> — material icon named "emoji emotions" (round). + static const IconData emoji_emotions_rounded = IconData(0xf706, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_emotions</i> — material icon named "emoji emotions" (outlined). + static const IconData emoji_emotions_outlined = IconData(0xf019, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_events</i> — material icon named "emoji events". + static const IconData emoji_events = IconData(0xe22c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_events</i> — material icon named "emoji events" (sharp). + static const IconData emoji_events_sharp = IconData(0xe928, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_events</i> — material icon named "emoji events" (round). + static const IconData emoji_events_rounded = IconData(0xf707, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_events</i> — material icon named "emoji events" (outlined). + static const IconData emoji_events_outlined = IconData(0xf01a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_flags</i> — material icon named "emoji flags". + static const IconData emoji_flags = IconData(0xe22d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_flags</i> — material icon named "emoji flags" (sharp). + static const IconData emoji_flags_sharp = IconData(0xe929, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_flags</i> — material icon named "emoji flags" (round). + static const IconData emoji_flags_rounded = IconData(0xf708, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_flags</i> — material icon named "emoji flags" (outlined). + static const IconData emoji_flags_outlined = IconData(0xf01b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_food_beverage</i> — material icon named "emoji food beverage". + static const IconData emoji_food_beverage = IconData(0xe22e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_food_beverage</i> — material icon named "emoji food beverage" (sharp). + static const IconData emoji_food_beverage_sharp = IconData(0xe92a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_food_beverage</i> — material icon named "emoji food beverage" (round). + static const IconData emoji_food_beverage_rounded = IconData(0xf709, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_food_beverage</i> — material icon named "emoji food beverage" (outlined). + static const IconData emoji_food_beverage_outlined = IconData( + 0xf01c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">emoji_nature</i> — material icon named "emoji nature". + static const IconData emoji_nature = IconData(0xe22f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_nature</i> — material icon named "emoji nature" (sharp). + static const IconData emoji_nature_sharp = IconData(0xe92b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_nature</i> — material icon named "emoji nature" (round). + static const IconData emoji_nature_rounded = IconData(0xf70a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_nature</i> — material icon named "emoji nature" (outlined). + static const IconData emoji_nature_outlined = IconData(0xf01d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_objects</i> — material icon named "emoji objects". + static const IconData emoji_objects = IconData(0xe230, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_objects</i> — material icon named "emoji objects" (sharp). + static const IconData emoji_objects_sharp = IconData(0xe92c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_objects</i> — material icon named "emoji objects" (round). + static const IconData emoji_objects_rounded = IconData(0xf70b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_objects</i> — material icon named "emoji objects" (outlined). + static const IconData emoji_objects_outlined = IconData(0xf01e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_people</i> — material icon named "emoji people". + static const IconData emoji_people = IconData(0xe231, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_people</i> — material icon named "emoji people" (sharp). + static const IconData emoji_people_sharp = IconData(0xe92d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_people</i> — material icon named "emoji people" (round). + static const IconData emoji_people_rounded = IconData(0xf70c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_people</i> — material icon named "emoji people" (outlined). + static const IconData emoji_people_outlined = IconData(0xf01f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_symbols</i> — material icon named "emoji symbols". + static const IconData emoji_symbols = IconData(0xe232, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_symbols</i> — material icon named "emoji symbols" (sharp). + static const IconData emoji_symbols_sharp = IconData(0xe92e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_symbols</i> — material icon named "emoji symbols" (round). + static const IconData emoji_symbols_rounded = IconData(0xf70d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">emoji_symbols</i> — material icon named "emoji symbols" (outlined). + static const IconData emoji_symbols_outlined = IconData(0xf020, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">emoji_transportation</i> — material icon named "emoji transportation". + static const IconData emoji_transportation = IconData(0xe233, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">emoji_transportation</i> — material icon named "emoji transportation" (sharp). + static const IconData emoji_transportation_sharp = IconData(0xe92f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">emoji_transportation</i> — material icon named "emoji transportation" (round). + static const IconData emoji_transportation_rounded = IconData( + 0xf70e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">emoji_transportation</i> — material icon named "emoji transportation" (outlined). + static const IconData emoji_transportation_outlined = IconData( + 0xf021, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">energy_savings_leaf</i> — material icon named "energy savings leaf". + static const IconData energy_savings_leaf = IconData(0xf07a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">energy_savings_leaf</i> — material icon named "energy savings leaf" (sharp). + static const IconData energy_savings_leaf_sharp = IconData(0xf0748, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">energy_savings_leaf</i> — material icon named "energy savings leaf" (round). + static const IconData energy_savings_leaf_rounded = IconData( + 0xf07f8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">energy_savings_leaf</i> — material icon named "energy savings leaf" (outlined). + static const IconData energy_savings_leaf_outlined = IconData( + 0xf06f0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">engineering</i> — material icon named "engineering". + static const IconData engineering = IconData(0xe234, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">engineering</i> — material icon named "engineering" (sharp). + static const IconData engineering_sharp = IconData(0xe930, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">engineering</i> — material icon named "engineering" (round). + static const IconData engineering_rounded = IconData(0xf70f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">engineering</i> — material icon named "engineering" (outlined). + static const IconData engineering_outlined = IconData(0xf022, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">enhance_photo_translate</i> — material icon named "enhance photo translate". + static const IconData enhance_photo_translate = IconData(0xe131, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">enhance_photo_translate</i> — material icon named "enhance photo translate" (sharp). + static const IconData enhance_photo_translate_sharp = IconData( + 0xe82d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">enhance_photo_translate</i> — material icon named "enhance photo translate" (round). + static const IconData enhance_photo_translate_rounded = IconData( + 0xf60c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">enhance_photo_translate</i> — material icon named "enhance photo translate" (outlined). + static const IconData enhance_photo_translate_outlined = IconData( + 0xef1f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">enhanced_encryption</i> — material icon named "enhanced encryption". + static const IconData enhanced_encryption = IconData(0xe235, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">enhanced_encryption</i> — material icon named "enhanced encryption" (sharp). + static const IconData enhanced_encryption_sharp = IconData(0xe931, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">enhanced_encryption</i> — material icon named "enhanced encryption" (round). + static const IconData enhanced_encryption_rounded = IconData(0xf710, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">enhanced_encryption</i> — material icon named "enhanced encryption" (outlined). + static const IconData enhanced_encryption_outlined = IconData( + 0xf023, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">equalizer</i> — material icon named "equalizer". + static const IconData equalizer = IconData(0xe236, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">equalizer</i> — material icon named "equalizer" (sharp). + static const IconData equalizer_sharp = IconData(0xe932, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">equalizer</i> — material icon named "equalizer" (round). + static const IconData equalizer_rounded = IconData(0xf711, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">equalizer</i> — material icon named "equalizer" (outlined). + static const IconData equalizer_outlined = IconData(0xf024, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">error</i> — material icon named "error". + static const IconData error = IconData(0xe237, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">error</i> — material icon named "error" (sharp). + static const IconData error_sharp = IconData(0xe934, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">error</i> — material icon named "error" (round). + static const IconData error_rounded = IconData(0xf713, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">error</i> — material icon named "error" (outlined). + static const IconData error_outlined = IconData(0xf026, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">error_outline</i> — material icon named "error outline". + static const IconData error_outline = IconData(0xe238, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">error_outline</i> — material icon named "error outline" (sharp). + static const IconData error_outline_sharp = IconData(0xe933, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">error_outline</i> — material icon named "error outline" (round). + static const IconData error_outline_rounded = IconData(0xf712, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">error_outline</i> — material icon named "error outline" (outlined). + static const IconData error_outline_outlined = IconData(0xf025, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">escalator</i> — material icon named "escalator". + static const IconData escalator = IconData(0xe239, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">escalator</i> — material icon named "escalator" (sharp). + static const IconData escalator_sharp = IconData(0xe935, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">escalator</i> — material icon named "escalator" (round). + static const IconData escalator_rounded = IconData(0xf714, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">escalator</i> — material icon named "escalator" (outlined). + static const IconData escalator_outlined = IconData(0xf027, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">escalator_warning</i> — material icon named "escalator warning". + static const IconData escalator_warning = IconData(0xe23a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">escalator_warning</i> — material icon named "escalator warning" (sharp). + static const IconData escalator_warning_sharp = IconData(0xe936, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">escalator_warning</i> — material icon named "escalator warning" (round). + static const IconData escalator_warning_rounded = IconData(0xf715, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">escalator_warning</i> — material icon named "escalator warning" (outlined). + static const IconData escalator_warning_outlined = IconData(0xf028, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">euro</i> — material icon named "euro". + static const IconData euro = IconData(0xe23b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">euro</i> — material icon named "euro" (sharp). + static const IconData euro_sharp = IconData(0xe937, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">euro</i> — material icon named "euro" (round). + static const IconData euro_rounded = IconData(0xf716, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">euro</i> — material icon named "euro" (outlined). + static const IconData euro_outlined = IconData(0xf029, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">euro_symbol</i> — material icon named "euro symbol". + static const IconData euro_symbol = IconData(0xe23c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">euro_symbol</i> — material icon named "euro symbol" (sharp). + static const IconData euro_symbol_sharp = IconData(0xe938, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">euro_symbol</i> — material icon named "euro symbol" (round). + static const IconData euro_symbol_rounded = IconData(0xf717, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">euro_symbol</i> — material icon named "euro symbol" (outlined). + static const IconData euro_symbol_outlined = IconData(0xf02a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ev_station</i> — material icon named "ev station". + static const IconData ev_station = IconData(0xe23d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ev_station</i> — material icon named "ev station" (sharp). + static const IconData ev_station_sharp = IconData(0xe939, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ev_station</i> — material icon named "ev station" (round). + static const IconData ev_station_rounded = IconData(0xf718, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ev_station</i> — material icon named "ev station" (outlined). + static const IconData ev_station_outlined = IconData(0xf02b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">event</i> — material icon named "event". + static const IconData event = IconData(0xe23e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">event</i> — material icon named "event" (sharp). + static const IconData event_sharp = IconData(0xe93e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">event</i> — material icon named "event" (round). + static const IconData event_rounded = IconData(0xf71c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">event</i> — material icon named "event" (outlined). + static const IconData event_outlined = IconData(0xf02f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">event_available</i> — material icon named "event available". + static const IconData event_available = IconData(0xe23f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">event_available</i> — material icon named "event available" (sharp). + static const IconData event_available_sharp = IconData(0xe93a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">event_available</i> — material icon named "event available" (round). + static const IconData event_available_rounded = IconData(0xf719, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">event_available</i> — material icon named "event available" (outlined). + static const IconData event_available_outlined = IconData(0xf02c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">event_busy</i> — material icon named "event busy". + static const IconData event_busy = IconData(0xe240, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">event_busy</i> — material icon named "event busy" (sharp). + static const IconData event_busy_sharp = IconData(0xe93b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">event_busy</i> — material icon named "event busy" (round). + static const IconData event_busy_rounded = IconData(0xf71a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">event_busy</i> — material icon named "event busy" (outlined). + static const IconData event_busy_outlined = IconData(0xf02d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">event_note</i> — material icon named "event note". + static const IconData event_note = IconData( + 0xe241, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">event_note</i> — material icon named "event note" (sharp). + static const IconData event_note_sharp = IconData( + 0xe93c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">event_note</i> — material icon named "event note" (round). + static const IconData event_note_rounded = IconData( + 0xf71b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">event_note</i> — material icon named "event note" (outlined). + static const IconData event_note_outlined = IconData( + 0xf02e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">event_repeat</i> — material icon named "event repeat". + static const IconData event_repeat = IconData(0xf04fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">event_repeat</i> — material icon named "event repeat" (sharp). + static const IconData event_repeat_sharp = IconData(0xf0407, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">event_repeat</i> — material icon named "event repeat" (round). + static const IconData event_repeat_rounded = IconData(0xf0314, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">event_repeat</i> — material icon named "event repeat" (outlined). + static const IconData event_repeat_outlined = IconData(0xf05f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">event_seat</i> — material icon named "event seat". + static const IconData event_seat = IconData(0xe242, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">event_seat</i> — material icon named "event seat" (sharp). + static const IconData event_seat_sharp = IconData(0xe93d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">event_seat</i> — material icon named "event seat" (round). + static const IconData event_seat_rounded = IconData(0xf71d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">event_seat</i> — material icon named "event seat" (outlined). + static const IconData event_seat_outlined = IconData(0xf030, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exit_to_app</i> — material icon named "exit to app". + static const IconData exit_to_app = IconData(0xe243, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exit_to_app</i> — material icon named "exit to app" (sharp). + static const IconData exit_to_app_sharp = IconData(0xe93f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exit_to_app</i> — material icon named "exit to app" (round). + static const IconData exit_to_app_rounded = IconData(0xf71e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exit_to_app</i> — material icon named "exit to app" (outlined). + static const IconData exit_to_app_outlined = IconData(0xf031, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">expand</i> — material icon named "expand". + static const IconData expand = IconData(0xe244, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">expand</i> — material icon named "expand" (sharp). + static const IconData expand_sharp = IconData(0xe942, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">expand</i> — material icon named "expand" (round). + static const IconData expand_rounded = IconData(0xf721, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">expand</i> — material icon named "expand" (outlined). + static const IconData expand_outlined = IconData(0xf034, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">expand_circle_down</i> — material icon named "expand circle down". + static const IconData expand_circle_down = IconData(0xf04fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">expand_circle_down</i> — material icon named "expand circle down" (sharp). + static const IconData expand_circle_down_sharp = IconData(0xf0408, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">expand_circle_down</i> — material icon named "expand circle down" (round). + static const IconData expand_circle_down_rounded = IconData(0xf0315, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">expand_circle_down</i> — material icon named "expand circle down" (outlined). + static const IconData expand_circle_down_outlined = IconData( + 0xf05f6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">expand_less</i> — material icon named "expand less". + static const IconData expand_less = IconData(0xe245, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">expand_less</i> — material icon named "expand less" (sharp). + static const IconData expand_less_sharp = IconData(0xe940, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">expand_less</i> — material icon named "expand less" (round). + static const IconData expand_less_rounded = IconData(0xf71f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">expand_less</i> — material icon named "expand less" (outlined). + static const IconData expand_less_outlined = IconData(0xf032, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">expand_more</i> — material icon named "expand more". + static const IconData expand_more = IconData(0xe246, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">expand_more</i> — material icon named "expand more" (sharp). + static const IconData expand_more_sharp = IconData(0xe941, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">expand_more</i> — material icon named "expand more" (round). + static const IconData expand_more_rounded = IconData(0xf720, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">expand_more</i> — material icon named "expand more" (outlined). + static const IconData expand_more_outlined = IconData(0xf033, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">explicit</i> — material icon named "explicit". + static const IconData explicit = IconData(0xe247, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">explicit</i> — material icon named "explicit" (sharp). + static const IconData explicit_sharp = IconData(0xe943, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">explicit</i> — material icon named "explicit" (round). + static const IconData explicit_rounded = IconData(0xf722, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">explicit</i> — material icon named "explicit" (outlined). + static const IconData explicit_outlined = IconData(0xf035, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">explore</i> — material icon named "explore". + static const IconData explore = IconData(0xe248, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">explore</i> — material icon named "explore" (sharp). + static const IconData explore_sharp = IconData(0xe945, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">explore</i> — material icon named "explore" (round). + static const IconData explore_rounded = IconData(0xf724, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">explore</i> — material icon named "explore" (outlined). + static const IconData explore_outlined = IconData(0xf037, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">explore_off</i> — material icon named "explore off". + static const IconData explore_off = IconData(0xe249, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">explore_off</i> — material icon named "explore off" (sharp). + static const IconData explore_off_sharp = IconData(0xe944, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">explore_off</i> — material icon named "explore off" (round). + static const IconData explore_off_rounded = IconData(0xf723, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">explore_off</i> — material icon named "explore off" (outlined). + static const IconData explore_off_outlined = IconData(0xf036, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure</i> — material icon named "exposure". + static const IconData exposure = IconData(0xe24a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure</i> — material icon named "exposure" (sharp). + static const IconData exposure_sharp = IconData(0xe94a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure</i> — material icon named "exposure" (round). + static const IconData exposure_rounded = IconData(0xf729, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure</i> — material icon named "exposure" (outlined). + static const IconData exposure_outlined = IconData(0xf03a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_minus_1</i> — material icon named "exposure minus 1". + static const IconData exposure_minus_1 = IconData(0xe24b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_minus_1</i> — material icon named "exposure minus 1" (sharp). + static const IconData exposure_minus_1_sharp = IconData(0xe946, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_minus_1</i> — material icon named "exposure minus 1" (round). + static const IconData exposure_minus_1_rounded = IconData(0xf725, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_minus_1</i> — material icon named "exposure minus 1" (outlined). + static const IconData exposure_minus_1_outlined = IconData(0xf038, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_minus_2</i> — material icon named "exposure minus 2". + static const IconData exposure_minus_2 = IconData(0xe24c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_minus_2</i> — material icon named "exposure minus 2" (sharp). + static const IconData exposure_minus_2_sharp = IconData(0xe947, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_minus_2</i> — material icon named "exposure minus 2" (round). + static const IconData exposure_minus_2_rounded = IconData(0xf726, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_minus_2</i> — material icon named "exposure minus 2" (outlined). + static const IconData exposure_minus_2_outlined = IconData(0xf039, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_neg_1</i> — material icon named "exposure neg 1". + static const IconData exposure_neg_1 = IconData(0xe24b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_neg_1</i> — material icon named "exposure neg 1" (sharp). + static const IconData exposure_neg_1_sharp = IconData(0xe946, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_neg_1</i> — material icon named "exposure neg 1" (round). + static const IconData exposure_neg_1_rounded = IconData(0xf725, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_neg_1</i> — material icon named "exposure neg 1" (outlined). + static const IconData exposure_neg_1_outlined = IconData(0xf038, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_neg_2</i> — material icon named "exposure neg 2". + static const IconData exposure_neg_2 = IconData(0xe24c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_neg_2</i> — material icon named "exposure neg 2" (sharp). + static const IconData exposure_neg_2_sharp = IconData(0xe947, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_neg_2</i> — material icon named "exposure neg 2" (round). + static const IconData exposure_neg_2_rounded = IconData(0xf726, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_neg_2</i> — material icon named "exposure neg 2" (outlined). + static const IconData exposure_neg_2_outlined = IconData(0xf039, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_plus_1</i> — material icon named "exposure plus 1". + static const IconData exposure_plus_1 = IconData(0xe24d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_plus_1</i> — material icon named "exposure plus 1" (sharp). + static const IconData exposure_plus_1_sharp = IconData(0xe948, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_plus_1</i> — material icon named "exposure plus 1" (round). + static const IconData exposure_plus_1_rounded = IconData(0xf727, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_plus_1</i> — material icon named "exposure plus 1" (outlined). + static const IconData exposure_plus_1_outlined = IconData(0xf03b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_plus_2</i> — material icon named "exposure plus 2". + static const IconData exposure_plus_2 = IconData(0xe24e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_plus_2</i> — material icon named "exposure plus 2" (sharp). + static const IconData exposure_plus_2_sharp = IconData(0xe949, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_plus_2</i> — material icon named "exposure plus 2" (round). + static const IconData exposure_plus_2_rounded = IconData(0xf728, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_plus_2</i> — material icon named "exposure plus 2" (outlined). + static const IconData exposure_plus_2_outlined = IconData(0xf03c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">exposure_zero</i> — material icon named "exposure zero". + static const IconData exposure_zero = IconData(0xe24f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">exposure_zero</i> — material icon named "exposure zero" (sharp). + static const IconData exposure_zero_sharp = IconData(0xe94b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">exposure_zero</i> — material icon named "exposure zero" (round). + static const IconData exposure_zero_rounded = IconData(0xf72a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">exposure_zero</i> — material icon named "exposure zero" (outlined). + static const IconData exposure_zero_outlined = IconData(0xf03d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">extension</i> — material icon named "extension". + static const IconData extension = IconData(0xe250, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">extension</i> — material icon named "extension" (sharp). + static const IconData extension_sharp = IconData(0xe94d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">extension</i> — material icon named "extension" (round). + static const IconData extension_rounded = IconData(0xf72c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">extension</i> — material icon named "extension" (outlined). + static const IconData extension_outlined = IconData(0xf03f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">extension_off</i> — material icon named "extension off". + static const IconData extension_off = IconData(0xe251, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">extension_off</i> — material icon named "extension off" (sharp). + static const IconData extension_off_sharp = IconData(0xe94c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">extension_off</i> — material icon named "extension off" (round). + static const IconData extension_off_rounded = IconData(0xf72b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">extension_off</i> — material icon named "extension off" (outlined). + static const IconData extension_off_outlined = IconData(0xf03e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face</i> — material icon named "face". + static const IconData face = IconData(0xe252, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face</i> — material icon named "face" (sharp). + static const IconData face_sharp = IconData(0xe950, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face</i> — material icon named "face" (round). + static const IconData face_rounded = IconData(0xf72f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face</i> — material icon named "face" (outlined). + static const IconData face_outlined = IconData(0xf040, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face_2</i> — material icon named "face 2". + static const IconData face_2 = IconData(0xf085f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_2</i> — material icon named "face 2" (sharp). + static const IconData face_2_sharp = IconData(0xf083b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_2</i> — material icon named "face 2" (round). + static const IconData face_2_rounded = IconData(0xf0884, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_2</i> — material icon named "face 2" (outlined). + static const IconData face_2_outlined = IconData(0xf08a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face_3</i> — material icon named "face 3". + static const IconData face_3 = IconData(0xf0860, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_3</i> — material icon named "face 3" (sharp). + static const IconData face_3_sharp = IconData(0xf083c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_3</i> — material icon named "face 3" (round). + static const IconData face_3_rounded = IconData(0xf0885, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_3</i> — material icon named "face 3" (outlined). + static const IconData face_3_outlined = IconData(0xf08a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face_4</i> — material icon named "face 4". + static const IconData face_4 = IconData(0xf0861, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_4</i> — material icon named "face 4" (sharp). + static const IconData face_4_sharp = IconData(0xf083d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_4</i> — material icon named "face 4" (round). + static const IconData face_4_rounded = IconData(0xf0886, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_4</i> — material icon named "face 4" (outlined). + static const IconData face_4_outlined = IconData(0xf08a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face_5</i> — material icon named "face 5". + static const IconData face_5 = IconData(0xf0862, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_5</i> — material icon named "face 5" (sharp). + static const IconData face_5_sharp = IconData(0xf083e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_5</i> — material icon named "face 5" (round). + static const IconData face_5_rounded = IconData(0xf0887, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_5</i> — material icon named "face 5" (outlined). + static const IconData face_5_outlined = IconData(0xf08a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face_6</i> — material icon named "face 6". + static const IconData face_6 = IconData(0xf0863, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_6</i> — material icon named "face 6" (sharp). + static const IconData face_6_sharp = IconData(0xf083f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_6</i> — material icon named "face 6" (round). + static const IconData face_6_rounded = IconData(0xf0888, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_6</i> — material icon named "face 6" (outlined). + static const IconData face_6_outlined = IconData(0xf08a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">face_retouching_natural</i> — material icon named "face retouching natural". + static const IconData face_retouching_natural = IconData(0xe253, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_retouching_natural</i> — material icon named "face retouching natural" (sharp). + static const IconData face_retouching_natural_sharp = IconData( + 0xe94e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">face_retouching_natural</i> — material icon named "face retouching natural" (round). + static const IconData face_retouching_natural_rounded = IconData( + 0xf72d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">face_retouching_natural</i> — material icon named "face retouching natural" (outlined). + static const IconData face_retouching_natural_outlined = IconData( + 0xf041, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">face_retouching_off</i> — material icon named "face retouching off". + static const IconData face_retouching_off = IconData(0xe254, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">face_retouching_off</i> — material icon named "face retouching off" (sharp). + static const IconData face_retouching_off_sharp = IconData(0xe94f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_retouching_off</i> — material icon named "face retouching off" (round). + static const IconData face_retouching_off_rounded = IconData(0xf72e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_retouching_off</i> — material icon named "face retouching off" (outlined). + static const IconData face_retouching_off_outlined = IconData( + 0xf042, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">face_unlock</i> — material icon named "face unlock" (sharp). + static const IconData face_unlock_sharp = IconData(0xe951, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">face_unlock</i> — material icon named "face unlock" (round). + static const IconData face_unlock_rounded = IconData(0xf730, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">face_unlock</i> — material icon named "face unlock" (outlined). + static const IconData face_unlock_outlined = IconData(0xf043, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">facebook</i> — material icon named "facebook". + static const IconData facebook = IconData(0xe255, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">facebook</i> — material icon named "facebook" (sharp). + static const IconData facebook_sharp = IconData(0xe952, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">facebook</i> — material icon named "facebook" (round). + static const IconData facebook_rounded = IconData(0xf731, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">facebook</i> — material icon named "facebook" (outlined). + static const IconData facebook_outlined = IconData(0xf044, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fact_check</i> — material icon named "fact check". + static const IconData fact_check = IconData(0xe256, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fact_check</i> — material icon named "fact check" (sharp). + static const IconData fact_check_sharp = IconData(0xe953, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fact_check</i> — material icon named "fact check" (round). + static const IconData fact_check_rounded = IconData(0xf732, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fact_check</i> — material icon named "fact check" (outlined). + static const IconData fact_check_outlined = IconData(0xf045, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">factory</i> — material icon named "factory". + static const IconData factory = IconData(0xf04fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">factory</i> — material icon named "factory" (sharp). + static const IconData factory_sharp = IconData(0xf0409, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">factory</i> — material icon named "factory" (round). + static const IconData factory_rounded = IconData(0xf0316, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">factory</i> — material icon named "factory" (outlined). + static const IconData factory_outlined = IconData(0xf05f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">family_restroom</i> — material icon named "family restroom". + static const IconData family_restroom = IconData(0xe257, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">family_restroom</i> — material icon named "family restroom" (sharp). + static const IconData family_restroom_sharp = IconData(0xe954, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">family_restroom</i> — material icon named "family restroom" (round). + static const IconData family_restroom_rounded = IconData(0xf733, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">family_restroom</i> — material icon named "family restroom" (outlined). + static const IconData family_restroom_outlined = IconData(0xf046, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fast_forward</i> — material icon named "fast forward". + static const IconData fast_forward = IconData(0xe258, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fast_forward</i> — material icon named "fast forward" (sharp). + static const IconData fast_forward_sharp = IconData(0xe955, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fast_forward</i> — material icon named "fast forward" (round). + static const IconData fast_forward_rounded = IconData(0xf734, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fast_forward</i> — material icon named "fast forward" (outlined). + static const IconData fast_forward_outlined = IconData(0xf047, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fast_rewind</i> — material icon named "fast rewind". + static const IconData fast_rewind = IconData(0xe259, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fast_rewind</i> — material icon named "fast rewind" (sharp). + static const IconData fast_rewind_sharp = IconData(0xe956, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fast_rewind</i> — material icon named "fast rewind" (round). + static const IconData fast_rewind_rounded = IconData(0xf735, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fast_rewind</i> — material icon named "fast rewind" (outlined). + static const IconData fast_rewind_outlined = IconData(0xf048, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fastfood</i> — material icon named "fastfood". + static const IconData fastfood = IconData(0xe25a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fastfood</i> — material icon named "fastfood" (sharp). + static const IconData fastfood_sharp = IconData(0xe957, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fastfood</i> — material icon named "fastfood" (round). + static const IconData fastfood_rounded = IconData(0xf736, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fastfood</i> — material icon named "fastfood" (outlined). + static const IconData fastfood_outlined = IconData(0xf049, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">favorite</i> — material icon named "favorite". + static const IconData favorite = IconData(0xe25b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">favorite</i> — material icon named "favorite" (sharp). + static const IconData favorite_sharp = IconData(0xe959, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">favorite</i> — material icon named "favorite" (round). + static const IconData favorite_rounded = IconData(0xf738, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">favorite</i> — material icon named "favorite" (outlined). + static const IconData favorite_outlined = IconData(0xf04b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">favorite_border</i> — material icon named "favorite border". + static const IconData favorite_border = IconData(0xe25c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">favorite_border</i> — material icon named "favorite border" (sharp). + static const IconData favorite_border_sharp = IconData(0xe958, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">favorite_border</i> — material icon named "favorite border" (round). + static const IconData favorite_border_rounded = IconData(0xf737, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">favorite_border</i> — material icon named "favorite border" (outlined). + static const IconData favorite_border_outlined = IconData(0xf04a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">favorite_outline</i> — material icon named "favorite outline". + static const IconData favorite_outline = IconData(0xe25c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">favorite_outline</i> — material icon named "favorite outline" (sharp). + static const IconData favorite_outline_sharp = IconData(0xe958, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">favorite_outline</i> — material icon named "favorite outline" (round). + static const IconData favorite_outline_rounded = IconData(0xf737, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">favorite_outline</i> — material icon named "favorite outline" (outlined). + static const IconData favorite_outline_outlined = IconData(0xf04a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fax</i> — material icon named "fax". + static const IconData fax = IconData(0xf04fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fax</i> — material icon named "fax" (sharp). + static const IconData fax_sharp = IconData(0xf040a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fax</i> — material icon named "fax" (round). + static const IconData fax_rounded = IconData(0xf0317, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fax</i> — material icon named "fax" (outlined). + static const IconData fax_outlined = IconData(0xf05f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">featured_play_list</i> — material icon named "featured play list". + static const IconData featured_play_list = IconData( + 0xe25d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">featured_play_list</i> — material icon named "featured play list" (sharp). + static const IconData featured_play_list_sharp = IconData( + 0xe95a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">featured_play_list</i> — material icon named "featured play list" (round). + static const IconData featured_play_list_rounded = IconData( + 0xf739, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">featured_play_list</i> — material icon named "featured play list" (outlined). + static const IconData featured_play_list_outlined = IconData( + 0xf04c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">featured_video</i> — material icon named "featured video". + static const IconData featured_video = IconData( + 0xe25e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">featured_video</i> — material icon named "featured video" (sharp). + static const IconData featured_video_sharp = IconData( + 0xe95b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">featured_video</i> — material icon named "featured video" (round). + static const IconData featured_video_rounded = IconData( + 0xf73a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">featured_video</i> — material icon named "featured video" (outlined). + static const IconData featured_video_outlined = IconData( + 0xf04d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">feed</i> — material icon named "feed". + static const IconData feed = IconData(0xe25f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">feed</i> — material icon named "feed" (sharp). + static const IconData feed_sharp = IconData(0xe95c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">feed</i> — material icon named "feed" (round). + static const IconData feed_rounded = IconData(0xf73b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">feed</i> — material icon named "feed" (outlined). + static const IconData feed_outlined = IconData(0xf04e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">feedback</i> — material icon named "feedback". + static const IconData feedback = IconData(0xe260, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">feedback</i> — material icon named "feedback" (sharp). + static const IconData feedback_sharp = IconData(0xe95d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">feedback</i> — material icon named "feedback" (round). + static const IconData feedback_rounded = IconData(0xf73c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">feedback</i> — material icon named "feedback" (outlined). + static const IconData feedback_outlined = IconData(0xf04f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">female</i> — material icon named "female". + static const IconData female = IconData(0xe261, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">female</i> — material icon named "female" (sharp). + static const IconData female_sharp = IconData(0xe95e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">female</i> — material icon named "female" (round). + static const IconData female_rounded = IconData(0xf73d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">female</i> — material icon named "female" (outlined). + static const IconData female_outlined = IconData(0xf050, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fence</i> — material icon named "fence". + static const IconData fence = IconData(0xe262, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fence</i> — material icon named "fence" (sharp). + static const IconData fence_sharp = IconData(0xe95f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fence</i> — material icon named "fence" (round). + static const IconData fence_rounded = IconData(0xf73e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fence</i> — material icon named "fence" (outlined). + static const IconData fence_outlined = IconData(0xf051, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">festival</i> — material icon named "festival". + static const IconData festival = IconData(0xe263, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">festival</i> — material icon named "festival" (sharp). + static const IconData festival_sharp = IconData(0xe960, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">festival</i> — material icon named "festival" (round). + static const IconData festival_rounded = IconData(0xf73f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">festival</i> — material icon named "festival" (outlined). + static const IconData festival_outlined = IconData(0xf052, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fiber_dvr</i> — material icon named "fiber dvr". + static const IconData fiber_dvr = IconData(0xe264, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fiber_dvr</i> — material icon named "fiber dvr" (sharp). + static const IconData fiber_dvr_sharp = IconData(0xe961, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fiber_dvr</i> — material icon named "fiber dvr" (round). + static const IconData fiber_dvr_rounded = IconData(0xf740, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fiber_dvr</i> — material icon named "fiber dvr" (outlined). + static const IconData fiber_dvr_outlined = IconData(0xf053, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fiber_manual_record</i> — material icon named "fiber manual record". + static const IconData fiber_manual_record = IconData(0xe265, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fiber_manual_record</i> — material icon named "fiber manual record" (sharp). + static const IconData fiber_manual_record_sharp = IconData(0xe962, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fiber_manual_record</i> — material icon named "fiber manual record" (round). + static const IconData fiber_manual_record_rounded = IconData(0xf741, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fiber_manual_record</i> — material icon named "fiber manual record" (outlined). + static const IconData fiber_manual_record_outlined = IconData( + 0xf054, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">fiber_new</i> — material icon named "fiber new". + static const IconData fiber_new = IconData(0xe266, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fiber_new</i> — material icon named "fiber new" (sharp). + static const IconData fiber_new_sharp = IconData(0xe963, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fiber_new</i> — material icon named "fiber new" (round). + static const IconData fiber_new_rounded = IconData(0xf742, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fiber_new</i> — material icon named "fiber new" (outlined). + static const IconData fiber_new_outlined = IconData(0xf055, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fiber_pin</i> — material icon named "fiber pin". + static const IconData fiber_pin = IconData(0xe267, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fiber_pin</i> — material icon named "fiber pin" (sharp). + static const IconData fiber_pin_sharp = IconData(0xe964, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fiber_pin</i> — material icon named "fiber pin" (round). + static const IconData fiber_pin_rounded = IconData(0xf743, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fiber_pin</i> — material icon named "fiber pin" (outlined). + static const IconData fiber_pin_outlined = IconData(0xf056, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fiber_smart_record</i> — material icon named "fiber smart record". + static const IconData fiber_smart_record = IconData(0xe268, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fiber_smart_record</i> — material icon named "fiber smart record" (sharp). + static const IconData fiber_smart_record_sharp = IconData(0xe965, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fiber_smart_record</i> — material icon named "fiber smart record" (round). + static const IconData fiber_smart_record_rounded = IconData(0xf744, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fiber_smart_record</i> — material icon named "fiber smart record" (outlined). + static const IconData fiber_smart_record_outlined = IconData(0xf057, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_copy</i> — material icon named "file copy". + static const IconData file_copy = IconData(0xe269, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_copy</i> — material icon named "file copy" (sharp). + static const IconData file_copy_sharp = IconData(0xe966, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_copy</i> — material icon named "file copy" (round). + static const IconData file_copy_rounded = IconData(0xf745, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_copy</i> — material icon named "file copy" (outlined). + static const IconData file_copy_outlined = IconData(0xf058, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_download</i> — material icon named "file download". + static const IconData file_download = IconData(0xe26a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_download</i> — material icon named "file download" (sharp). + static const IconData file_download_sharp = IconData(0xe969, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_download</i> — material icon named "file download" (round). + static const IconData file_download_rounded = IconData(0xf748, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_download</i> — material icon named "file download" (outlined). + static const IconData file_download_outlined = IconData(0xf05b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_download_done</i> — material icon named "file download done". + static const IconData file_download_done = IconData(0xe26b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_download_done</i> — material icon named "file download done" (sharp). + static const IconData file_download_done_sharp = IconData(0xe967, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_download_done</i> — material icon named "file download done" (round). + static const IconData file_download_done_rounded = IconData(0xf746, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_download_done</i> — material icon named "file download done" (outlined). + static const IconData file_download_done_outlined = IconData(0xf059, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_download_off</i> — material icon named "file download off". + static const IconData file_download_off = IconData(0xe26c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_download_off</i> — material icon named "file download off" (sharp). + static const IconData file_download_off_sharp = IconData(0xe968, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_download_off</i> — material icon named "file download off" (round). + static const IconData file_download_off_rounded = IconData(0xf747, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_download_off</i> — material icon named "file download off" (outlined). + static const IconData file_download_off_outlined = IconData(0xf05a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_open</i> — material icon named "file open". + static const IconData file_open = IconData(0xf04ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_open</i> — material icon named "file open" (sharp). + static const IconData file_open_sharp = IconData(0xf040b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_open</i> — material icon named "file open" (round). + static const IconData file_open_rounded = IconData(0xf0318, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_open</i> — material icon named "file open" (outlined). + static const IconData file_open_outlined = IconData(0xf05f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_present</i> — material icon named "file present". + static const IconData file_present = IconData(0xe26d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_present</i> — material icon named "file present" (sharp). + static const IconData file_present_sharp = IconData(0xe96a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_present</i> — material icon named "file present" (round). + static const IconData file_present_rounded = IconData(0xf749, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_present</i> — material icon named "file present" (outlined). + static const IconData file_present_outlined = IconData(0xf05c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_upload</i> — material icon named "file upload". + static const IconData file_upload = IconData(0xe26e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">file_upload</i> — material icon named "file upload" (sharp). + static const IconData file_upload_sharp = IconData(0xe96b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">file_upload</i> — material icon named "file upload" (round). + static const IconData file_upload_rounded = IconData(0xf74a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">file_upload</i> — material icon named "file upload" (outlined). + static const IconData file_upload_outlined = IconData(0xf05d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">file_upload_off</i> — material icon named "file upload off". + static const IconData file_upload_off = IconData(0xf0864, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter</i> — material icon named "filter". + static const IconData filter = IconData(0xe26f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter</i> — material icon named "filter" (sharp). + static const IconData filter_sharp = IconData(0xe97e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter</i> — material icon named "filter" (round). + static const IconData filter_rounded = IconData(0xf75d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter</i> — material icon named "filter" (outlined). + static const IconData filter_outlined = IconData(0xf070, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_1</i> — material icon named "filter 1". + static const IconData filter_1 = IconData(0xe270, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_1</i> — material icon named "filter 1" (sharp). + static const IconData filter_1_sharp = IconData(0xe96c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_1</i> — material icon named "filter 1" (round). + static const IconData filter_1_rounded = IconData(0xf74b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_1</i> — material icon named "filter 1" (outlined). + static const IconData filter_1_outlined = IconData(0xf05e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_2</i> — material icon named "filter 2". + static const IconData filter_2 = IconData(0xe271, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_2</i> — material icon named "filter 2" (sharp). + static const IconData filter_2_sharp = IconData(0xe96d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_2</i> — material icon named "filter 2" (round). + static const IconData filter_2_rounded = IconData(0xf74c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_2</i> — material icon named "filter 2" (outlined). + static const IconData filter_2_outlined = IconData(0xf05f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_3</i> — material icon named "filter 3". + static const IconData filter_3 = IconData(0xe272, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_3</i> — material icon named "filter 3" (sharp). + static const IconData filter_3_sharp = IconData(0xe96e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_3</i> — material icon named "filter 3" (round). + static const IconData filter_3_rounded = IconData(0xf74d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_3</i> — material icon named "filter 3" (outlined). + static const IconData filter_3_outlined = IconData(0xf060, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_4</i> — material icon named "filter 4". + static const IconData filter_4 = IconData(0xe273, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_4</i> — material icon named "filter 4" (sharp). + static const IconData filter_4_sharp = IconData(0xe96f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_4</i> — material icon named "filter 4" (round). + static const IconData filter_4_rounded = IconData(0xf74e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_4</i> — material icon named "filter 4" (outlined). + static const IconData filter_4_outlined = IconData(0xf061, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_5</i> — material icon named "filter 5". + static const IconData filter_5 = IconData(0xe274, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_5</i> — material icon named "filter 5" (sharp). + static const IconData filter_5_sharp = IconData(0xe970, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_5</i> — material icon named "filter 5" (round). + static const IconData filter_5_rounded = IconData(0xf74f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_5</i> — material icon named "filter 5" (outlined). + static const IconData filter_5_outlined = IconData(0xf062, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_6</i> — material icon named "filter 6". + static const IconData filter_6 = IconData(0xe275, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_6</i> — material icon named "filter 6" (sharp). + static const IconData filter_6_sharp = IconData(0xe971, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_6</i> — material icon named "filter 6" (round). + static const IconData filter_6_rounded = IconData(0xf750, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_6</i> — material icon named "filter 6" (outlined). + static const IconData filter_6_outlined = IconData(0xf063, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_7</i> — material icon named "filter 7". + static const IconData filter_7 = IconData(0xe276, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_7</i> — material icon named "filter 7" (sharp). + static const IconData filter_7_sharp = IconData(0xe972, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_7</i> — material icon named "filter 7" (round). + static const IconData filter_7_rounded = IconData(0xf751, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_7</i> — material icon named "filter 7" (outlined). + static const IconData filter_7_outlined = IconData(0xf064, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_8</i> — material icon named "filter 8". + static const IconData filter_8 = IconData(0xe277, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_8</i> — material icon named "filter 8" (sharp). + static const IconData filter_8_sharp = IconData(0xe973, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_8</i> — material icon named "filter 8" (round). + static const IconData filter_8_rounded = IconData(0xf752, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_8</i> — material icon named "filter 8" (outlined). + static const IconData filter_8_outlined = IconData(0xf065, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_9</i> — material icon named "filter 9". + static const IconData filter_9 = IconData(0xe278, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_9</i> — material icon named "filter 9" (sharp). + static const IconData filter_9_sharp = IconData(0xe975, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_9</i> — material icon named "filter 9" (round). + static const IconData filter_9_rounded = IconData(0xf754, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_9</i> — material icon named "filter 9" (outlined). + static const IconData filter_9_outlined = IconData(0xf066, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_9_plus</i> — material icon named "filter 9 plus". + static const IconData filter_9_plus = IconData(0xe279, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_9_plus</i> — material icon named "filter 9 plus" (sharp). + static const IconData filter_9_plus_sharp = IconData(0xe974, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_9_plus</i> — material icon named "filter 9 plus" (round). + static const IconData filter_9_plus_rounded = IconData(0xf753, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_9_plus</i> — material icon named "filter 9 plus" (outlined). + static const IconData filter_9_plus_outlined = IconData(0xf067, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_alt</i> — material icon named "filter alt". + static const IconData filter_alt = IconData(0xe27a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_alt</i> — material icon named "filter alt" (sharp). + static const IconData filter_alt_sharp = IconData(0xe976, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_alt</i> — material icon named "filter alt" (round). + static const IconData filter_alt_rounded = IconData(0xf755, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_alt</i> — material icon named "filter alt" (outlined). + static const IconData filter_alt_outlined = IconData(0xf068, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_alt_off</i> — material icon named "filter alt off". + static const IconData filter_alt_off = IconData(0xf0500, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_alt_off</i> — material icon named "filter alt off" (sharp). + static const IconData filter_alt_off_sharp = IconData(0xf040c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_alt_off</i> — material icon named "filter alt off" (round). + static const IconData filter_alt_off_rounded = IconData(0xf0319, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_alt_off</i> — material icon named "filter alt off" (outlined). + static const IconData filter_alt_off_outlined = IconData(0xf05fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_b_and_w</i> — material icon named "filter b and w". + static const IconData filter_b_and_w = IconData(0xe27b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_b_and_w</i> — material icon named "filter b and w" (sharp). + static const IconData filter_b_and_w_sharp = IconData(0xe977, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_b_and_w</i> — material icon named "filter b and w" (round). + static const IconData filter_b_and_w_rounded = IconData(0xf756, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_b_and_w</i> — material icon named "filter b and w" (outlined). + static const IconData filter_b_and_w_outlined = IconData(0xf069, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_center_focus</i> — material icon named "filter center focus". + static const IconData filter_center_focus = IconData(0xe27c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_center_focus</i> — material icon named "filter center focus" (sharp). + static const IconData filter_center_focus_sharp = IconData(0xe978, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_center_focus</i> — material icon named "filter center focus" (round). + static const IconData filter_center_focus_rounded = IconData(0xf757, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_center_focus</i> — material icon named "filter center focus" (outlined). + static const IconData filter_center_focus_outlined = IconData( + 0xf06a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">filter_drama</i> — material icon named "filter drama". + static const IconData filter_drama = IconData(0xe27d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_drama</i> — material icon named "filter drama" (sharp). + static const IconData filter_drama_sharp = IconData(0xe979, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_drama</i> — material icon named "filter drama" (round). + static const IconData filter_drama_rounded = IconData(0xf758, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_drama</i> — material icon named "filter drama" (outlined). + static const IconData filter_drama_outlined = IconData(0xf06b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_frames</i> — material icon named "filter frames". + static const IconData filter_frames = IconData(0xe27e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_frames</i> — material icon named "filter frames" (sharp). + static const IconData filter_frames_sharp = IconData(0xe97a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_frames</i> — material icon named "filter frames" (round). + static const IconData filter_frames_rounded = IconData(0xf759, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_frames</i> — material icon named "filter frames" (outlined). + static const IconData filter_frames_outlined = IconData(0xf06c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_hdr</i> — material icon named "filter hdr". + static const IconData filter_hdr = IconData(0xe27f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_hdr</i> — material icon named "filter hdr" (sharp). + static const IconData filter_hdr_sharp = IconData(0xe97b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_hdr</i> — material icon named "filter hdr" (round). + static const IconData filter_hdr_rounded = IconData(0xf75a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_hdr</i> — material icon named "filter hdr" (outlined). + static const IconData filter_hdr_outlined = IconData(0xf06d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_list</i> — material icon named "filter list". + static const IconData filter_list = IconData(0xe280, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_list</i> — material icon named "filter list" (sharp). + static const IconData filter_list_sharp = IconData(0xe97c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_list</i> — material icon named "filter list" (round). + static const IconData filter_list_rounded = IconData(0xf75b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_list</i> — material icon named "filter list" (outlined). + static const IconData filter_list_outlined = IconData(0xf06e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_list_alt</i> — material icon named "filter list alt". + static const IconData filter_list_alt = IconData(0xe281, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_list_off</i> — material icon named "filter list off". + static const IconData filter_list_off = IconData(0xf0501, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_list_off</i> — material icon named "filter list off" (sharp). + static const IconData filter_list_off_sharp = IconData(0xf040d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_list_off</i> — material icon named "filter list off" (round). + static const IconData filter_list_off_rounded = IconData(0xf031a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_list_off</i> — material icon named "filter list off" (outlined). + static const IconData filter_list_off_outlined = IconData(0xf05fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_none</i> — material icon named "filter none". + static const IconData filter_none = IconData(0xe282, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_none</i> — material icon named "filter none" (sharp). + static const IconData filter_none_sharp = IconData(0xe97d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_none</i> — material icon named "filter none" (round). + static const IconData filter_none_rounded = IconData(0xf75c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_none</i> — material icon named "filter none" (outlined). + static const IconData filter_none_outlined = IconData(0xf06f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_tilt_shift</i> — material icon named "filter tilt shift". + static const IconData filter_tilt_shift = IconData(0xe283, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_tilt_shift</i> — material icon named "filter tilt shift" (sharp). + static const IconData filter_tilt_shift_sharp = IconData(0xe97f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_tilt_shift</i> — material icon named "filter tilt shift" (round). + static const IconData filter_tilt_shift_rounded = IconData(0xf75e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_tilt_shift</i> — material icon named "filter tilt shift" (outlined). + static const IconData filter_tilt_shift_outlined = IconData(0xf071, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">filter_vintage</i> — material icon named "filter vintage". + static const IconData filter_vintage = IconData(0xe284, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">filter_vintage</i> — material icon named "filter vintage" (sharp). + static const IconData filter_vintage_sharp = IconData(0xe980, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">filter_vintage</i> — material icon named "filter vintage" (round). + static const IconData filter_vintage_rounded = IconData(0xf75f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">filter_vintage</i> — material icon named "filter vintage" (outlined). + static const IconData filter_vintage_outlined = IconData(0xf072, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">find_in_page</i> — material icon named "find in page". + static const IconData find_in_page = IconData(0xe285, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">find_in_page</i> — material icon named "find in page" (sharp). + static const IconData find_in_page_sharp = IconData(0xe981, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">find_in_page</i> — material icon named "find in page" (round). + static const IconData find_in_page_rounded = IconData(0xf760, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">find_in_page</i> — material icon named "find in page" (outlined). + static const IconData find_in_page_outlined = IconData(0xf073, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">find_replace</i> — material icon named "find replace". + static const IconData find_replace = IconData(0xe286, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">find_replace</i> — material icon named "find replace" (sharp). + static const IconData find_replace_sharp = IconData(0xe982, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">find_replace</i> — material icon named "find replace" (round). + static const IconData find_replace_rounded = IconData(0xf761, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">find_replace</i> — material icon named "find replace" (outlined). + static const IconData find_replace_outlined = IconData(0xf074, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fingerprint</i> — material icon named "fingerprint". + static const IconData fingerprint = IconData(0xe287, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fingerprint</i> — material icon named "fingerprint" (sharp). + static const IconData fingerprint_sharp = IconData(0xe983, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fingerprint</i> — material icon named "fingerprint" (round). + static const IconData fingerprint_rounded = IconData(0xf762, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fingerprint</i> — material icon named "fingerprint" (outlined). + static const IconData fingerprint_outlined = IconData(0xf075, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fire_extinguisher</i> — material icon named "fire extinguisher". + static const IconData fire_extinguisher = IconData(0xe288, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fire_extinguisher</i> — material icon named "fire extinguisher" (sharp). + static const IconData fire_extinguisher_sharp = IconData(0xe984, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fire_extinguisher</i> — material icon named "fire extinguisher" (round). + static const IconData fire_extinguisher_rounded = IconData(0xf763, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fire_extinguisher</i> — material icon named "fire extinguisher" (outlined). + static const IconData fire_extinguisher_outlined = IconData(0xf076, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fire_hydrant</i> — material icon named "fire hydrant". + static const IconData fire_hydrant = IconData(0xe289, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fire_hydrant_alt</i> — material icon named "fire hydrant alt". + static const IconData fire_hydrant_alt = IconData(0xf07a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fire_hydrant_alt</i> — material icon named "fire hydrant alt" (sharp). + static const IconData fire_hydrant_alt_sharp = IconData(0xf0749, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fire_hydrant_alt</i> — material icon named "fire hydrant alt" (round). + static const IconData fire_hydrant_alt_rounded = IconData(0xf07f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fire_hydrant_alt</i> — material icon named "fire hydrant alt" (outlined). + static const IconData fire_hydrant_alt_outlined = IconData(0xf06f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fire_truck</i> — material icon named "fire truck". + static const IconData fire_truck = IconData(0xf07a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fire_truck</i> — material icon named "fire truck" (sharp). + static const IconData fire_truck_sharp = IconData(0xf074a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fire_truck</i> — material icon named "fire truck" (round). + static const IconData fire_truck_rounded = IconData(0xf07fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fire_truck</i> — material icon named "fire truck" (outlined). + static const IconData fire_truck_outlined = IconData(0xf06f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fireplace</i> — material icon named "fireplace". + static const IconData fireplace = IconData(0xe28a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fireplace</i> — material icon named "fireplace" (sharp). + static const IconData fireplace_sharp = IconData(0xe985, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fireplace</i> — material icon named "fireplace" (round). + static const IconData fireplace_rounded = IconData(0xf764, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fireplace</i> — material icon named "fireplace" (outlined). + static const IconData fireplace_outlined = IconData(0xf077, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">first_page</i> — material icon named "first page". + static const IconData first_page = IconData( + 0xe28b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">first_page</i> — material icon named "first page" (sharp). + static const IconData first_page_sharp = IconData( + 0xe986, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">first_page</i> — material icon named "first page" (round). + static const IconData first_page_rounded = IconData( + 0xf765, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">first_page</i> — material icon named "first page" (outlined). + static const IconData first_page_outlined = IconData( + 0xf078, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">fit_screen</i> — material icon named "fit screen". + static const IconData fit_screen = IconData(0xe28c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fit_screen</i> — material icon named "fit screen" (sharp). + static const IconData fit_screen_sharp = IconData(0xe987, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fit_screen</i> — material icon named "fit screen" (round). + static const IconData fit_screen_rounded = IconData(0xf766, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fit_screen</i> — material icon named "fit screen" (outlined). + static const IconData fit_screen_outlined = IconData(0xf079, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fitbit</i> — material icon named "fitbit". + static const IconData fitbit = IconData(0xf0502, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fitbit</i> — material icon named "fitbit" (sharp). + static const IconData fitbit_sharp = IconData(0xf040e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fitbit</i> — material icon named "fitbit" (round). + static const IconData fitbit_rounded = IconData(0xf031b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fitbit</i> — material icon named "fitbit" (outlined). + static const IconData fitbit_outlined = IconData(0xf05fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fitness_center</i> — material icon named "fitness center". + static const IconData fitness_center = IconData(0xe28d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fitness_center</i> — material icon named "fitness center" (sharp). + static const IconData fitness_center_sharp = IconData(0xe988, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fitness_center</i> — material icon named "fitness center" (round). + static const IconData fitness_center_rounded = IconData(0xf767, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fitness_center</i> — material icon named "fitness center" (outlined). + static const IconData fitness_center_outlined = IconData(0xf07a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flag</i> — material icon named "flag". + static const IconData flag = IconData(0xe28e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flag</i> — material icon named "flag" (sharp). + static const IconData flag_sharp = IconData(0xe989, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flag</i> — material icon named "flag" (round). + static const IconData flag_rounded = IconData(0xf768, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flag</i> — material icon named "flag" (outlined). + static const IconData flag_outlined = IconData(0xf07b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flag_circle</i> — material icon named "flag circle". + static const IconData flag_circle = IconData(0xf0503, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flag_circle</i> — material icon named "flag circle" (sharp). + static const IconData flag_circle_sharp = IconData(0xf040f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flag_circle</i> — material icon named "flag circle" (round). + static const IconData flag_circle_rounded = IconData(0xf031c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flag_circle</i> — material icon named "flag circle" (outlined). + static const IconData flag_circle_outlined = IconData(0xf05fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flaky</i> — material icon named "flaky". + static const IconData flaky = IconData(0xe28f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flaky</i> — material icon named "flaky" (sharp). + static const IconData flaky_sharp = IconData(0xe98a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flaky</i> — material icon named "flaky" (round). + static const IconData flaky_rounded = IconData(0xf769, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flaky</i> — material icon named "flaky" (outlined). + static const IconData flaky_outlined = IconData(0xf07c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flare</i> — material icon named "flare". + static const IconData flare = IconData(0xe290, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flare</i> — material icon named "flare" (sharp). + static const IconData flare_sharp = IconData(0xe98b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flare</i> — material icon named "flare" (round). + static const IconData flare_rounded = IconData(0xf76a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flare</i> — material icon named "flare" (outlined). + static const IconData flare_outlined = IconData(0xf07d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flash_auto</i> — material icon named "flash auto". + static const IconData flash_auto = IconData(0xe291, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flash_auto</i> — material icon named "flash auto" (sharp). + static const IconData flash_auto_sharp = IconData(0xe98c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flash_auto</i> — material icon named "flash auto" (round). + static const IconData flash_auto_rounded = IconData(0xf76b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flash_auto</i> — material icon named "flash auto" (outlined). + static const IconData flash_auto_outlined = IconData(0xf07e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flash_off</i> — material icon named "flash off". + static const IconData flash_off = IconData(0xe292, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flash_off</i> — material icon named "flash off" (sharp). + static const IconData flash_off_sharp = IconData(0xe98d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flash_off</i> — material icon named "flash off" (round). + static const IconData flash_off_rounded = IconData(0xf76c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flash_off</i> — material icon named "flash off" (outlined). + static const IconData flash_off_outlined = IconData(0xf07f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flash_on</i> — material icon named "flash on". + static const IconData flash_on = IconData(0xe293, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flash_on</i> — material icon named "flash on" (sharp). + static const IconData flash_on_sharp = IconData(0xe98e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flash_on</i> — material icon named "flash on" (round). + static const IconData flash_on_rounded = IconData(0xf76d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flash_on</i> — material icon named "flash on" (outlined). + static const IconData flash_on_outlined = IconData(0xf080, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flashlight_off</i> — material icon named "flashlight off". + static const IconData flashlight_off = IconData(0xe294, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flashlight_off</i> — material icon named "flashlight off" (sharp). + static const IconData flashlight_off_sharp = IconData(0xe98f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flashlight_off</i> — material icon named "flashlight off" (round). + static const IconData flashlight_off_rounded = IconData(0xf76e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flashlight_off</i> — material icon named "flashlight off" (outlined). + static const IconData flashlight_off_outlined = IconData(0xf081, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flashlight_on</i> — material icon named "flashlight on". + static const IconData flashlight_on = IconData(0xe295, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flashlight_on</i> — material icon named "flashlight on" (sharp). + static const IconData flashlight_on_sharp = IconData(0xe990, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flashlight_on</i> — material icon named "flashlight on" (round). + static const IconData flashlight_on_rounded = IconData(0xf76f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flashlight_on</i> — material icon named "flashlight on" (outlined). + static const IconData flashlight_on_outlined = IconData(0xf082, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flatware</i> — material icon named "flatware". + static const IconData flatware = IconData(0xe296, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flatware</i> — material icon named "flatware" (sharp). + static const IconData flatware_sharp = IconData(0xe991, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flatware</i> — material icon named "flatware" (round). + static const IconData flatware_rounded = IconData(0xf770, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flatware</i> — material icon named "flatware" (outlined). + static const IconData flatware_outlined = IconData(0xf083, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flight</i> — material icon named "flight". + static const IconData flight = IconData(0xe297, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flight</i> — material icon named "flight" (sharp). + static const IconData flight_sharp = IconData(0xe993, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flight</i> — material icon named "flight" (round). + static const IconData flight_rounded = IconData(0xf772, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flight</i> — material icon named "flight" (outlined). + static const IconData flight_outlined = IconData(0xf085, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flight_class</i> — material icon named "flight class". + static const IconData flight_class = IconData(0xf0504, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flight_class</i> — material icon named "flight class" (sharp). + static const IconData flight_class_sharp = IconData(0xf0410, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flight_class</i> — material icon named "flight class" (round). + static const IconData flight_class_rounded = IconData(0xf031d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flight_class</i> — material icon named "flight class" (outlined). + static const IconData flight_class_outlined = IconData(0xf05fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flight_land</i> — material icon named "flight land". + static const IconData flight_land = IconData( + 0xe298, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">flight_land</i> — material icon named "flight land" (sharp). + static const IconData flight_land_sharp = IconData( + 0xe992, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">flight_land</i> — material icon named "flight land" (round). + static const IconData flight_land_rounded = IconData( + 0xf771, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">flight_land</i> — material icon named "flight land" (outlined). + static const IconData flight_land_outlined = IconData( + 0xf084, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">flight_takeoff</i> — material icon named "flight takeoff". + static const IconData flight_takeoff = IconData( + 0xe299, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">flight_takeoff</i> — material icon named "flight takeoff" (sharp). + static const IconData flight_takeoff_sharp = IconData( + 0xe994, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">flight_takeoff</i> — material icon named "flight takeoff" (round). + static const IconData flight_takeoff_rounded = IconData( + 0xf773, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">flight_takeoff</i> — material icon named "flight takeoff" (outlined). + static const IconData flight_takeoff_outlined = IconData( + 0xf086, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">flip</i> — material icon named "flip". + static const IconData flip = IconData(0xe29a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flip</i> — material icon named "flip" (sharp). + static const IconData flip_sharp = IconData(0xe997, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flip</i> — material icon named "flip" (round). + static const IconData flip_rounded = IconData(0xf776, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flip</i> — material icon named "flip" (outlined). + static const IconData flip_outlined = IconData(0xf089, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flip_camera_android</i> — material icon named "flip camera android". + static const IconData flip_camera_android = IconData(0xe29b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flip_camera_android</i> — material icon named "flip camera android" (sharp). + static const IconData flip_camera_android_sharp = IconData(0xe995, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flip_camera_android</i> — material icon named "flip camera android" (round). + static const IconData flip_camera_android_rounded = IconData(0xf774, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flip_camera_android</i> — material icon named "flip camera android" (outlined). + static const IconData flip_camera_android_outlined = IconData( + 0xf087, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">flip_camera_ios</i> — material icon named "flip camera ios". + static const IconData flip_camera_ios = IconData(0xe29c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flip_camera_ios</i> — material icon named "flip camera ios" (sharp). + static const IconData flip_camera_ios_sharp = IconData(0xe996, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flip_camera_ios</i> — material icon named "flip camera ios" (round). + static const IconData flip_camera_ios_rounded = IconData(0xf775, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flip_camera_ios</i> — material icon named "flip camera ios" (outlined). + static const IconData flip_camera_ios_outlined = IconData(0xf088, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flip_to_back</i> — material icon named "flip to back". + static const IconData flip_to_back = IconData(0xe29d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flip_to_back</i> — material icon named "flip to back" (sharp). + static const IconData flip_to_back_sharp = IconData(0xe998, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flip_to_back</i> — material icon named "flip to back" (round). + static const IconData flip_to_back_rounded = IconData(0xf777, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flip_to_back</i> — material icon named "flip to back" (outlined). + static const IconData flip_to_back_outlined = IconData(0xf08a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flip_to_front</i> — material icon named "flip to front". + static const IconData flip_to_front = IconData(0xe29e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flip_to_front</i> — material icon named "flip to front" (sharp). + static const IconData flip_to_front_sharp = IconData(0xe999, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flip_to_front</i> — material icon named "flip to front" (round). + static const IconData flip_to_front_rounded = IconData(0xf778, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flip_to_front</i> — material icon named "flip to front" (outlined). + static const IconData flip_to_front_outlined = IconData(0xf08b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flood</i> — material icon named "flood". + static const IconData flood = IconData(0xf07a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flood</i> — material icon named "flood" (sharp). + static const IconData flood_sharp = IconData(0xf074b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flood</i> — material icon named "flood" (round). + static const IconData flood_rounded = IconData(0xf07fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flood</i> — material icon named "flood" (outlined). + static const IconData flood_outlined = IconData(0xf06f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flourescent</i> — material icon named "flourescent". + static const IconData flourescent = IconData(0xf0865, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flourescent</i> — material icon named "flourescent" (sharp). + static const IconData flourescent_sharp = IconData(0xf0840, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flourescent</i> — material icon named "flourescent" (round). + static const IconData flourescent_rounded = IconData(0xf0889, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flourescent</i> — material icon named "flourescent" (outlined). + static const IconData flourescent_outlined = IconData(0xf08a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fluorescent</i> — material icon named "fluorescent". + static const IconData fluorescent = IconData(0xf0865, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fluorescent</i> — material icon named "fluorescent" (sharp). + static const IconData fluorescent_sharp = IconData(0xf0840, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fluorescent</i> — material icon named "fluorescent" (round). + static const IconData fluorescent_rounded = IconData(0xf0889, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fluorescent</i> — material icon named "fluorescent" (outlined). + static const IconData fluorescent_outlined = IconData(0xf08a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">flutter_dash</i> — material icon named "flutter dash". + static const IconData flutter_dash = IconData(0xe2a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">flutter_dash</i> — material icon named "flutter dash" (sharp). + static const IconData flutter_dash_sharp = IconData(0xe99b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">flutter_dash</i> — material icon named "flutter dash" (round). + static const IconData flutter_dash_rounded = IconData(0xf77a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">flutter_dash</i> — material icon named "flutter dash" (outlined). + static const IconData flutter_dash_outlined = IconData(0xf08d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fmd_bad</i> — material icon named "fmd bad". + static const IconData fmd_bad = IconData(0xe2a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fmd_bad</i> — material icon named "fmd bad" (sharp). + static const IconData fmd_bad_sharp = IconData(0xe99c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fmd_bad</i> — material icon named "fmd bad" (round). + static const IconData fmd_bad_rounded = IconData(0xf77b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fmd_bad</i> — material icon named "fmd bad" (outlined). + static const IconData fmd_bad_outlined = IconData(0xf08e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fmd_good</i> — material icon named "fmd good". + static const IconData fmd_good = IconData(0xe2a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fmd_good</i> — material icon named "fmd good" (sharp). + static const IconData fmd_good_sharp = IconData(0xe99d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fmd_good</i> — material icon named "fmd good" (round). + static const IconData fmd_good_rounded = IconData(0xf77c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fmd_good</i> — material icon named "fmd good" (outlined). + static const IconData fmd_good_outlined = IconData(0xf08f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">foggy</i> — material icon named "foggy". + static const IconData foggy = IconData(0xf0505, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder</i> — material icon named "folder". + static const IconData folder = IconData(0xe2a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder</i> — material icon named "folder" (sharp). + static const IconData folder_sharp = IconData(0xe9a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder</i> — material icon named "folder" (round). + static const IconData folder_rounded = IconData(0xf77e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder</i> — material icon named "folder" (outlined). + static const IconData folder_outlined = IconData(0xf091, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_copy</i> — material icon named "folder copy". + static const IconData folder_copy = IconData(0xf0506, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_copy</i> — material icon named "folder copy" (sharp). + static const IconData folder_copy_sharp = IconData(0xf0411, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_copy</i> — material icon named "folder copy" (round). + static const IconData folder_copy_rounded = IconData(0xf031e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_copy</i> — material icon named "folder copy" (outlined). + static const IconData folder_copy_outlined = IconData(0xf05ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_delete</i> — material icon named "folder delete". + static const IconData folder_delete = IconData(0xf0507, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_delete</i> — material icon named "folder delete" (sharp). + static const IconData folder_delete_sharp = IconData(0xf0412, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_delete</i> — material icon named "folder delete" (round). + static const IconData folder_delete_rounded = IconData(0xf031f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_delete</i> — material icon named "folder delete" (outlined). + static const IconData folder_delete_outlined = IconData(0xf0600, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_off</i> — material icon named "folder off". + static const IconData folder_off = IconData(0xf0508, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_off</i> — material icon named "folder off" (sharp). + static const IconData folder_off_sharp = IconData(0xf0413, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_off</i> — material icon named "folder off" (round). + static const IconData folder_off_rounded = IconData(0xf0320, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_off</i> — material icon named "folder off" (outlined). + static const IconData folder_off_outlined = IconData(0xf0601, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_open</i> — material icon named "folder open". + static const IconData folder_open = IconData(0xe2a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_open</i> — material icon named "folder open" (sharp). + static const IconData folder_open_sharp = IconData(0xe99e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_open</i> — material icon named "folder open" (round). + static const IconData folder_open_rounded = IconData(0xf77d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_open</i> — material icon named "folder open" (outlined). + static const IconData folder_open_outlined = IconData(0xf090, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_shared</i> — material icon named "folder shared". + static const IconData folder_shared = IconData(0xe2a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_shared</i> — material icon named "folder shared" (sharp). + static const IconData folder_shared_sharp = IconData(0xe99f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_shared</i> — material icon named "folder shared" (round). + static const IconData folder_shared_rounded = IconData(0xf77f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_shared</i> — material icon named "folder shared" (outlined). + static const IconData folder_shared_outlined = IconData(0xf092, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_special</i> — material icon named "folder special". + static const IconData folder_special = IconData(0xe2a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_special</i> — material icon named "folder special" (sharp). + static const IconData folder_special_sharp = IconData(0xe9a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_special</i> — material icon named "folder special" (round). + static const IconData folder_special_rounded = IconData(0xf780, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_special</i> — material icon named "folder special" (outlined). + static const IconData folder_special_outlined = IconData(0xf093, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">folder_zip</i> — material icon named "folder zip". + static const IconData folder_zip = IconData(0xf0509, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">folder_zip</i> — material icon named "folder zip" (sharp). + static const IconData folder_zip_sharp = IconData(0xf0414, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">folder_zip</i> — material icon named "folder zip" (round). + static const IconData folder_zip_rounded = IconData(0xf0321, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">folder_zip</i> — material icon named "folder zip" (outlined). + static const IconData folder_zip_outlined = IconData(0xf0602, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">follow_the_signs</i> — material icon named "follow the signs". + static const IconData follow_the_signs = IconData(0xe2a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">follow_the_signs</i> — material icon named "follow the signs" (sharp). + static const IconData follow_the_signs_sharp = IconData(0xe9a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">follow_the_signs</i> — material icon named "follow the signs" (round). + static const IconData follow_the_signs_rounded = IconData(0xf781, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">follow_the_signs</i> — material icon named "follow the signs" (outlined). + static const IconData follow_the_signs_outlined = IconData(0xf094, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">font_download</i> — material icon named "font download". + static const IconData font_download = IconData(0xe2a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">font_download</i> — material icon named "font download" (sharp). + static const IconData font_download_sharp = IconData(0xe9a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">font_download</i> — material icon named "font download" (round). + static const IconData font_download_rounded = IconData(0xf783, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">font_download</i> — material icon named "font download" (outlined). + static const IconData font_download_outlined = IconData(0xf096, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">font_download_off</i> — material icon named "font download off". + static const IconData font_download_off = IconData(0xe2a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">font_download_off</i> — material icon named "font download off" (sharp). + static const IconData font_download_off_sharp = IconData(0xe9a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">font_download_off</i> — material icon named "font download off" (round). + static const IconData font_download_off_rounded = IconData(0xf782, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">font_download_off</i> — material icon named "font download off" (outlined). + static const IconData font_download_off_outlined = IconData(0xf095, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">food_bank</i> — material icon named "food bank". + static const IconData food_bank = IconData(0xe2aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">food_bank</i> — material icon named "food bank" (sharp). + static const IconData food_bank_sharp = IconData(0xe9a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">food_bank</i> — material icon named "food bank" (round). + static const IconData food_bank_rounded = IconData(0xf784, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">food_bank</i> — material icon named "food bank" (outlined). + static const IconData food_bank_outlined = IconData(0xf097, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forest</i> — material icon named "forest". + static const IconData forest = IconData(0xf050a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">forest</i> — material icon named "forest" (sharp). + static const IconData forest_sharp = IconData(0xf0415, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">forest</i> — material icon named "forest" (round). + static const IconData forest_rounded = IconData(0xf0322, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">forest</i> — material icon named "forest" (outlined). + static const IconData forest_outlined = IconData(0xf0603, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fork_left</i> — material icon named "fork left". + static const IconData fork_left = IconData(0xf050b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fork_left</i> — material icon named "fork left" (sharp). + static const IconData fork_left_sharp = IconData(0xf0416, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fork_left</i> — material icon named "fork left" (round). + static const IconData fork_left_rounded = IconData(0xf0323, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fork_left</i> — material icon named "fork left" (outlined). + static const IconData fork_left_outlined = IconData(0xf0604, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fork_right</i> — material icon named "fork right". + static const IconData fork_right = IconData(0xf050c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fork_right</i> — material icon named "fork right" (sharp). + static const IconData fork_right_sharp = IconData(0xf0417, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fork_right</i> — material icon named "fork right" (round). + static const IconData fork_right_rounded = IconData(0xf0324, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fork_right</i> — material icon named "fork right" (outlined). + static const IconData fork_right_outlined = IconData(0xf0605, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forklift</i> — material icon named "forklift". + static const IconData forklift = IconData(0xf0866, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_align_center</i> — material icon named "format align center". + static const IconData format_align_center = IconData(0xe2ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_align_center</i> — material icon named "format align center" (sharp). + static const IconData format_align_center_sharp = IconData(0xe9a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_align_center</i> — material icon named "format align center" (round). + static const IconData format_align_center_rounded = IconData(0xf785, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_align_center</i> — material icon named "format align center" (outlined). + static const IconData format_align_center_outlined = IconData( + 0xf098, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_align_justify</i> — material icon named "format align justify". + static const IconData format_align_justify = IconData(0xe2ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_align_justify</i> — material icon named "format align justify" (sharp). + static const IconData format_align_justify_sharp = IconData(0xe9a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_align_justify</i> — material icon named "format align justify" (round). + static const IconData format_align_justify_rounded = IconData( + 0xf786, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">format_align_justify</i> — material icon named "format align justify" (outlined). + static const IconData format_align_justify_outlined = IconData( + 0xf099, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_align_left</i> — material icon named "format align left". + static const IconData format_align_left = IconData(0xe2ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_align_left</i> — material icon named "format align left" (sharp). + static const IconData format_align_left_sharp = IconData(0xe9a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_align_left</i> — material icon named "format align left" (round). + static const IconData format_align_left_rounded = IconData(0xf787, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_align_left</i> — material icon named "format align left" (outlined). + static const IconData format_align_left_outlined = IconData(0xf09a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_align_right</i> — material icon named "format align right". + static const IconData format_align_right = IconData(0xe2ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_align_right</i> — material icon named "format align right" (sharp). + static const IconData format_align_right_sharp = IconData(0xe9a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_align_right</i> — material icon named "format align right" (round). + static const IconData format_align_right_rounded = IconData(0xf788, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_align_right</i> — material icon named "format align right" (outlined). + static const IconData format_align_right_outlined = IconData(0xf09b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_bold</i> — material icon named "format bold". + static const IconData format_bold = IconData(0xe2af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_bold</i> — material icon named "format bold" (sharp). + static const IconData format_bold_sharp = IconData(0xe9aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_bold</i> — material icon named "format bold" (round). + static const IconData format_bold_rounded = IconData(0xf789, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_bold</i> — material icon named "format bold" (outlined). + static const IconData format_bold_outlined = IconData(0xf09c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_clear</i> — material icon named "format clear". + static const IconData format_clear = IconData(0xe2b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_clear</i> — material icon named "format clear" (sharp). + static const IconData format_clear_sharp = IconData(0xe9ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_clear</i> — material icon named "format clear" (round). + static const IconData format_clear_rounded = IconData(0xf78a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_clear</i> — material icon named "format clear" (outlined). + static const IconData format_clear_outlined = IconData(0xf09d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_color_fill</i> — material icon named "format color fill". + static const IconData format_color_fill = IconData(0xe2b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_color_fill</i> — material icon named "format color fill" (sharp). + static const IconData format_color_fill_sharp = IconData(0xe9ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_color_fill</i> — material icon named "format color fill" (round). + static const IconData format_color_fill_rounded = IconData(0xf78b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_color_fill</i> — material icon named "format color fill" (outlined). + static const IconData format_color_fill_outlined = IconData(0xf09e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_color_reset</i> — material icon named "format color reset". + static const IconData format_color_reset = IconData(0xe2b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_color_reset</i> — material icon named "format color reset" (sharp). + static const IconData format_color_reset_sharp = IconData(0xe9ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_color_reset</i> — material icon named "format color reset" (round). + static const IconData format_color_reset_rounded = IconData(0xf78c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_color_reset</i> — material icon named "format color reset" (outlined). + static const IconData format_color_reset_outlined = IconData(0xf09f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_color_text</i> — material icon named "format color text". + static const IconData format_color_text = IconData(0xe2b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_color_text</i> — material icon named "format color text" (sharp). + static const IconData format_color_text_sharp = IconData(0xe9ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_color_text</i> — material icon named "format color text" (round). + static const IconData format_color_text_rounded = IconData(0xf78d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_color_text</i> — material icon named "format color text" (outlined). + static const IconData format_color_text_outlined = IconData(0xf0a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_indent_decrease</i> — material icon named "format indent decrease". + static const IconData format_indent_decrease = IconData( + 0xe2b4, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">format_indent_decrease</i> — material icon named "format indent decrease" (sharp). + static const IconData format_indent_decrease_sharp = IconData( + 0xe9af, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">format_indent_decrease</i> — material icon named "format indent decrease" (round). + static const IconData format_indent_decrease_rounded = IconData( + 0xf78e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">format_indent_decrease</i> — material icon named "format indent decrease" (outlined). + static const IconData format_indent_decrease_outlined = IconData( + 0xf0a1, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">format_indent_increase</i> — material icon named "format indent increase". + static const IconData format_indent_increase = IconData( + 0xe2b5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">format_indent_increase</i> — material icon named "format indent increase" (sharp). + static const IconData format_indent_increase_sharp = IconData( + 0xe9b0, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">format_indent_increase</i> — material icon named "format indent increase" (round). + static const IconData format_indent_increase_rounded = IconData( + 0xf78f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">format_indent_increase</i> — material icon named "format indent increase" (outlined). + static const IconData format_indent_increase_outlined = IconData( + 0xf0a2, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">format_italic</i> — material icon named "format italic". + static const IconData format_italic = IconData(0xe2b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_italic</i> — material icon named "format italic" (sharp). + static const IconData format_italic_sharp = IconData(0xe9b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_italic</i> — material icon named "format italic" (round). + static const IconData format_italic_rounded = IconData(0xf790, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_italic</i> — material icon named "format italic" (outlined). + static const IconData format_italic_outlined = IconData(0xf0a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_line_spacing</i> — material icon named "format line spacing". + static const IconData format_line_spacing = IconData(0xe2b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_line_spacing</i> — material icon named "format line spacing" (sharp). + static const IconData format_line_spacing_sharp = IconData(0xe9b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_line_spacing</i> — material icon named "format line spacing" (round). + static const IconData format_line_spacing_rounded = IconData(0xf791, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_line_spacing</i> — material icon named "format line spacing" (outlined). + static const IconData format_line_spacing_outlined = IconData( + 0xf0a4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_list_bulleted</i> — material icon named "format list bulleted". + static const IconData format_list_bulleted = IconData( + 0xe2b8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">format_list_bulleted</i> — material icon named "format list bulleted" (sharp). + static const IconData format_list_bulleted_sharp = IconData( + 0xe9b3, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">format_list_bulleted</i> — material icon named "format list bulleted" (round). + static const IconData format_list_bulleted_rounded = IconData( + 0xf792, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">format_list_bulleted</i> — material icon named "format list bulleted" (outlined). + static const IconData format_list_bulleted_outlined = IconData( + 0xf0a5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">format_list_bulleted_add</i> — material icon named "format list bulleted add". + static const IconData format_list_bulleted_add = IconData(0xf0867, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_list_numbered</i> — material icon named "format list numbered". + static const IconData format_list_numbered = IconData(0xe2b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_list_numbered</i> — material icon named "format list numbered" (sharp). + static const IconData format_list_numbered_sharp = IconData(0xe9b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_list_numbered</i> — material icon named "format list numbered" (round). + static const IconData format_list_numbered_rounded = IconData( + 0xf793, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">format_list_numbered</i> — material icon named "format list numbered" (outlined). + static const IconData format_list_numbered_outlined = IconData( + 0xf0a6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_list_numbered_rtl</i> — material icon named "format list numbered rtl". + static const IconData format_list_numbered_rtl = IconData(0xe2ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_list_numbered_rtl</i> — material icon named "format list numbered rtl" (sharp). + static const IconData format_list_numbered_rtl_sharp = IconData( + 0xe9b4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">format_list_numbered_rtl</i> — material icon named "format list numbered rtl" (round). + static const IconData format_list_numbered_rtl_rounded = IconData( + 0xf794, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">format_list_numbered_rtl</i> — material icon named "format list numbered rtl" (outlined). + static const IconData format_list_numbered_rtl_outlined = IconData( + 0xf0a7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_overline</i> — material icon named "format overline". + static const IconData format_overline = IconData(0xf050d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_overline</i> — material icon named "format overline" (sharp). + static const IconData format_overline_sharp = IconData(0xf0418, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_overline</i> — material icon named "format overline" (round). + static const IconData format_overline_rounded = IconData(0xf0325, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_overline</i> — material icon named "format overline" (outlined). + static const IconData format_overline_outlined = IconData(0xf0606, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_paint</i> — material icon named "format paint". + static const IconData format_paint = IconData(0xe2bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_paint</i> — material icon named "format paint" (sharp). + static const IconData format_paint_sharp = IconData(0xe9b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_paint</i> — material icon named "format paint" (round). + static const IconData format_paint_rounded = IconData(0xf795, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_paint</i> — material icon named "format paint" (outlined). + static const IconData format_paint_outlined = IconData(0xf0a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_quote</i> — material icon named "format quote". + static const IconData format_quote = IconData(0xe2bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_quote</i> — material icon named "format quote" (sharp). + static const IconData format_quote_sharp = IconData(0xe9b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_quote</i> — material icon named "format quote" (round). + static const IconData format_quote_rounded = IconData(0xf796, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_quote</i> — material icon named "format quote" (outlined). + static const IconData format_quote_outlined = IconData(0xf0a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_shapes</i> — material icon named "format shapes". + static const IconData format_shapes = IconData(0xe2bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_shapes</i> — material icon named "format shapes" (sharp). + static const IconData format_shapes_sharp = IconData(0xe9b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_shapes</i> — material icon named "format shapes" (round). + static const IconData format_shapes_rounded = IconData(0xf797, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_shapes</i> — material icon named "format shapes" (outlined). + static const IconData format_shapes_outlined = IconData(0xf0aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_size</i> — material icon named "format size". + static const IconData format_size = IconData(0xe2be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_size</i> — material icon named "format size" (sharp). + static const IconData format_size_sharp = IconData(0xe9b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_size</i> — material icon named "format size" (round). + static const IconData format_size_rounded = IconData(0xf798, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_size</i> — material icon named "format size" (outlined). + static const IconData format_size_outlined = IconData(0xf0ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_strikethrough</i> — material icon named "format strikethrough". + static const IconData format_strikethrough = IconData(0xe2bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_strikethrough</i> — material icon named "format strikethrough" (sharp). + static const IconData format_strikethrough_sharp = IconData(0xe9ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_strikethrough</i> — material icon named "format strikethrough" (round). + static const IconData format_strikethrough_rounded = IconData( + 0xf799, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">format_strikethrough</i> — material icon named "format strikethrough" (outlined). + static const IconData format_strikethrough_outlined = IconData( + 0xf0ac, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_textdirection_l_to_r</i> — material icon named "format textdirection l to r". + static const IconData format_textdirection_l_to_r = IconData(0xe2c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_textdirection_l_to_r</i> — material icon named "format textdirection l to r" (sharp). + static const IconData format_textdirection_l_to_r_sharp = IconData( + 0xe9bb, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">format_textdirection_l_to_r</i> — material icon named "format textdirection l to r" (round). + static const IconData format_textdirection_l_to_r_rounded = IconData( + 0xf79a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">format_textdirection_l_to_r</i> — material icon named "format textdirection l to r" (outlined). + static const IconData format_textdirection_l_to_r_outlined = IconData( + 0xf0ad, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_textdirection_r_to_l</i> — material icon named "format textdirection r to l". + static const IconData format_textdirection_r_to_l = IconData(0xe2c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_textdirection_r_to_l</i> — material icon named "format textdirection r to l" (sharp). + static const IconData format_textdirection_r_to_l_sharp = IconData( + 0xe9bc, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">format_textdirection_r_to_l</i> — material icon named "format textdirection r to l" (round). + static const IconData format_textdirection_r_to_l_rounded = IconData( + 0xf79b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">format_textdirection_r_to_l</i> — material icon named "format textdirection r to l" (outlined). + static const IconData format_textdirection_r_to_l_outlined = IconData( + 0xf0ae, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">format_underline</i> — material icon named "format underline". + static const IconData format_underline = IconData(0xe2c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_underline</i> — material icon named "format underline" (sharp). + static const IconData format_underline_sharp = IconData(0xe9bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_underline</i> — material icon named "format underline" (round). + static const IconData format_underline_rounded = IconData(0xf79c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_underline</i> — material icon named "format underline" (outlined). + static const IconData format_underline_outlined = IconData(0xf0af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">format_underlined</i> — material icon named "format underlined". + static const IconData format_underlined = IconData(0xe2c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">format_underlined</i> — material icon named "format underlined" (sharp). + static const IconData format_underlined_sharp = IconData(0xe9bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">format_underlined</i> — material icon named "format underlined" (round). + static const IconData format_underlined_rounded = IconData(0xf79c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">format_underlined</i> — material icon named "format underlined" (outlined). + static const IconData format_underlined_outlined = IconData(0xf0af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fort</i> — material icon named "fort". + static const IconData fort = IconData(0xf050e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fort</i> — material icon named "fort" (sharp). + static const IconData fort_sharp = IconData(0xf0419, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fort</i> — material icon named "fort" (round). + static const IconData fort_rounded = IconData(0xf0326, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fort</i> — material icon named "fort" (outlined). + static const IconData fort_outlined = IconData(0xf0607, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forum</i> — material icon named "forum". + static const IconData forum = IconData(0xe2c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">forum</i> — material icon named "forum" (sharp). + static const IconData forum_sharp = IconData(0xe9be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">forum</i> — material icon named "forum" (round). + static const IconData forum_rounded = IconData(0xf79d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">forum</i> — material icon named "forum" (outlined). + static const IconData forum_outlined = IconData(0xf0b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forward</i> — material icon named "forward". + static const IconData forward = IconData( + 0xe2c4, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">forward</i> — material icon named "forward" (sharp). + static const IconData forward_sharp = IconData( + 0xe9c2, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">forward</i> — material icon named "forward" (round). + static const IconData forward_rounded = IconData( + 0xf7a1, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">forward</i> — material icon named "forward" (outlined). + static const IconData forward_outlined = IconData( + 0xf0b4, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">forward_10</i> — material icon named "forward 10". + static const IconData forward_10 = IconData(0xe2c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">forward_10</i> — material icon named "forward 10" (sharp). + static const IconData forward_10_sharp = IconData(0xe9bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">forward_10</i> — material icon named "forward 10" (round). + static const IconData forward_10_rounded = IconData(0xf79e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">forward_10</i> — material icon named "forward 10" (outlined). + static const IconData forward_10_outlined = IconData(0xf0b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forward_30</i> — material icon named "forward 30". + static const IconData forward_30 = IconData(0xe2c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">forward_30</i> — material icon named "forward 30" (sharp). + static const IconData forward_30_sharp = IconData(0xe9c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">forward_30</i> — material icon named "forward 30" (round). + static const IconData forward_30_rounded = IconData(0xf79f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">forward_30</i> — material icon named "forward 30" (outlined). + static const IconData forward_30_outlined = IconData(0xf0b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forward_5</i> — material icon named "forward 5". + static const IconData forward_5 = IconData(0xe2c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">forward_5</i> — material icon named "forward 5" (sharp). + static const IconData forward_5_sharp = IconData(0xe9c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">forward_5</i> — material icon named "forward 5" (round). + static const IconData forward_5_rounded = IconData(0xf7a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">forward_5</i> — material icon named "forward 5" (outlined). + static const IconData forward_5_outlined = IconData(0xf0b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">forward_to_inbox</i> — material icon named "forward to inbox". + static const IconData forward_to_inbox = IconData(0xe2c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">forward_to_inbox</i> — material icon named "forward to inbox" (sharp). + static const IconData forward_to_inbox_sharp = IconData(0xe9c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">forward_to_inbox</i> — material icon named "forward to inbox" (round). + static const IconData forward_to_inbox_rounded = IconData(0xf7a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">forward_to_inbox</i> — material icon named "forward to inbox" (outlined). + static const IconData forward_to_inbox_outlined = IconData(0xf0b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">foundation</i> — material icon named "foundation". + static const IconData foundation = IconData(0xe2c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">foundation</i> — material icon named "foundation" (sharp). + static const IconData foundation_sharp = IconData(0xe9c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">foundation</i> — material icon named "foundation" (round). + static const IconData foundation_rounded = IconData(0xf7a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">foundation</i> — material icon named "foundation" (outlined). + static const IconData foundation_outlined = IconData(0xf0b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">free_breakfast</i> — material icon named "free breakfast". + static const IconData free_breakfast = IconData(0xe2ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">free_breakfast</i> — material icon named "free breakfast" (sharp). + static const IconData free_breakfast_sharp = IconData(0xe9c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">free_breakfast</i> — material icon named "free breakfast" (round). + static const IconData free_breakfast_rounded = IconData(0xf7a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">free_breakfast</i> — material icon named "free breakfast" (outlined). + static const IconData free_breakfast_outlined = IconData(0xf0b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">free_cancellation</i> — material icon named "free cancellation". + static const IconData free_cancellation = IconData(0xf050f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">free_cancellation</i> — material icon named "free cancellation" (sharp). + static const IconData free_cancellation_sharp = IconData(0xf041a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">free_cancellation</i> — material icon named "free cancellation" (round). + static const IconData free_cancellation_rounded = IconData(0xf0327, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">free_cancellation</i> — material icon named "free cancellation" (outlined). + static const IconData free_cancellation_outlined = IconData(0xf0608, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">front_hand</i> — material icon named "front hand". + static const IconData front_hand = IconData(0xf0510, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">front_hand</i> — material icon named "front hand" (sharp). + static const IconData front_hand_sharp = IconData(0xf041b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">front_hand</i> — material icon named "front hand" (round). + static const IconData front_hand_rounded = IconData(0xf0328, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">front_hand</i> — material icon named "front hand" (outlined). + static const IconData front_hand_outlined = IconData(0xf0609, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">front_loader</i> — material icon named "front loader". + static const IconData front_loader = IconData(0xf0868, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fullscreen</i> — material icon named "fullscreen". + static const IconData fullscreen = IconData(0xe2cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fullscreen</i> — material icon named "fullscreen" (sharp). + static const IconData fullscreen_sharp = IconData(0xe9c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fullscreen</i> — material icon named "fullscreen" (round). + static const IconData fullscreen_rounded = IconData(0xf7a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fullscreen</i> — material icon named "fullscreen" (outlined). + static const IconData fullscreen_outlined = IconData(0xf0b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">fullscreen_exit</i> — material icon named "fullscreen exit". + static const IconData fullscreen_exit = IconData(0xe2cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">fullscreen_exit</i> — material icon named "fullscreen exit" (sharp). + static const IconData fullscreen_exit_sharp = IconData(0xe9c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">fullscreen_exit</i> — material icon named "fullscreen exit" (round). + static const IconData fullscreen_exit_rounded = IconData(0xf7a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">fullscreen_exit</i> — material icon named "fullscreen exit" (outlined). + static const IconData fullscreen_exit_outlined = IconData(0xf0b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">functions</i> — material icon named "functions". + static const IconData functions = IconData( + 0xe2cd, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">functions</i> — material icon named "functions" (sharp). + static const IconData functions_sharp = IconData( + 0xe9c8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">functions</i> — material icon named "functions" (round). + static const IconData functions_rounded = IconData( + 0xf7a7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">functions</i> — material icon named "functions" (outlined). + static const IconData functions_outlined = IconData( + 0xf0ba, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">g_mobiledata</i> — material icon named "g mobiledata". + static const IconData g_mobiledata = IconData(0xe2ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">g_mobiledata</i> — material icon named "g mobiledata" (sharp). + static const IconData g_mobiledata_sharp = IconData(0xe9c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">g_mobiledata</i> — material icon named "g mobiledata" (round). + static const IconData g_mobiledata_rounded = IconData(0xf7a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">g_mobiledata</i> — material icon named "g mobiledata" (outlined). + static const IconData g_mobiledata_outlined = IconData(0xf0bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">g_translate</i> — material icon named "g translate". + static const IconData g_translate = IconData(0xe2cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">g_translate</i> — material icon named "g translate" (sharp). + static const IconData g_translate_sharp = IconData(0xe9ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">g_translate</i> — material icon named "g translate" (round). + static const IconData g_translate_rounded = IconData(0xf7a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">g_translate</i> — material icon named "g translate" (outlined). + static const IconData g_translate_outlined = IconData(0xf0bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gamepad</i> — material icon named "gamepad". + static const IconData gamepad = IconData(0xe2d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gamepad</i> — material icon named "gamepad" (sharp). + static const IconData gamepad_sharp = IconData(0xe9cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gamepad</i> — material icon named "gamepad" (round). + static const IconData gamepad_rounded = IconData(0xf7aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gamepad</i> — material icon named "gamepad" (outlined). + static const IconData gamepad_outlined = IconData(0xf0bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">games</i> — material icon named "games". + static const IconData games = IconData(0xe2d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">games</i> — material icon named "games" (sharp). + static const IconData games_sharp = IconData(0xe9cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">games</i> — material icon named "games" (round). + static const IconData games_rounded = IconData(0xf7ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">games</i> — material icon named "games" (outlined). + static const IconData games_outlined = IconData(0xf0be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">garage</i> — material icon named "garage". + static const IconData garage = IconData(0xe2d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">garage</i> — material icon named "garage" (sharp). + static const IconData garage_sharp = IconData(0xe9cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">garage</i> — material icon named "garage" (round). + static const IconData garage_rounded = IconData(0xf7ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">garage</i> — material icon named "garage" (outlined). + static const IconData garage_outlined = IconData(0xf0bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gas_meter</i> — material icon named "gas meter". + static const IconData gas_meter = IconData(0xf07a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gas_meter</i> — material icon named "gas meter" (sharp). + static const IconData gas_meter_sharp = IconData(0xf074c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gas_meter</i> — material icon named "gas meter" (round). + static const IconData gas_meter_rounded = IconData(0xf07fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gas_meter</i> — material icon named "gas meter" (outlined). + static const IconData gas_meter_outlined = IconData(0xf06f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gavel</i> — material icon named "gavel". + static const IconData gavel = IconData(0xe2d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gavel</i> — material icon named "gavel" (sharp). + static const IconData gavel_sharp = IconData(0xe9ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gavel</i> — material icon named "gavel" (round). + static const IconData gavel_rounded = IconData(0xf7ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gavel</i> — material icon named "gavel" (outlined). + static const IconData gavel_outlined = IconData(0xf0c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">generating_tokens</i> — material icon named "generating tokens". + static const IconData generating_tokens = IconData(0xf0511, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">generating_tokens</i> — material icon named "generating tokens" (sharp). + static const IconData generating_tokens_sharp = IconData(0xf041c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">generating_tokens</i> — material icon named "generating tokens" (round). + static const IconData generating_tokens_rounded = IconData(0xf0329, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">generating_tokens</i> — material icon named "generating tokens" (outlined). + static const IconData generating_tokens_outlined = IconData(0xf060a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gesture</i> — material icon named "gesture". + static const IconData gesture = IconData(0xe2d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gesture</i> — material icon named "gesture" (sharp). + static const IconData gesture_sharp = IconData(0xe9cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gesture</i> — material icon named "gesture" (round). + static const IconData gesture_rounded = IconData(0xf7ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gesture</i> — material icon named "gesture" (outlined). + static const IconData gesture_outlined = IconData(0xf0c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">get_app</i> — material icon named "get app". + static const IconData get_app = IconData(0xe2d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">get_app</i> — material icon named "get app" (sharp). + static const IconData get_app_sharp = IconData(0xe9d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">get_app</i> — material icon named "get app" (round). + static const IconData get_app_rounded = IconData(0xf7af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">get_app</i> — material icon named "get app" (outlined). + static const IconData get_app_outlined = IconData(0xf0c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gif</i> — material icon named "gif". + static const IconData gif = IconData(0xe2d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gif</i> — material icon named "gif" (sharp). + static const IconData gif_sharp = IconData(0xe9d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gif</i> — material icon named "gif" (round). + static const IconData gif_rounded = IconData(0xf7b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gif</i> — material icon named "gif" (outlined). + static const IconData gif_outlined = IconData(0xf0c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gif_box</i> — material icon named "gif box". + static const IconData gif_box = IconData(0xf0512, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gif_box</i> — material icon named "gif box" (sharp). + static const IconData gif_box_sharp = IconData(0xf041d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gif_box</i> — material icon named "gif box" (round). + static const IconData gif_box_rounded = IconData(0xf032a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gif_box</i> — material icon named "gif box" (outlined). + static const IconData gif_box_outlined = IconData(0xf060b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">girl</i> — material icon named "girl". + static const IconData girl = IconData(0xf0513, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">girl</i> — material icon named "girl" (sharp). + static const IconData girl_sharp = IconData(0xf041e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">girl</i> — material icon named "girl" (round). + static const IconData girl_rounded = IconData(0xf032b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">girl</i> — material icon named "girl" (outlined). + static const IconData girl_outlined = IconData(0xf060c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gite</i> — material icon named "gite". + static const IconData gite = IconData(0xe2d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gite</i> — material icon named "gite" (sharp). + static const IconData gite_sharp = IconData(0xe9d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gite</i> — material icon named "gite" (round). + static const IconData gite_rounded = IconData(0xf7b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gite</i> — material icon named "gite" (outlined). + static const IconData gite_outlined = IconData(0xf0c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">golf_course</i> — material icon named "golf course". + static const IconData golf_course = IconData(0xe2d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">golf_course</i> — material icon named "golf course" (sharp). + static const IconData golf_course_sharp = IconData(0xe9d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">golf_course</i> — material icon named "golf course" (round). + static const IconData golf_course_rounded = IconData(0xf7b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">golf_course</i> — material icon named "golf course" (outlined). + static const IconData golf_course_outlined = IconData(0xf0c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gpp_bad</i> — material icon named "gpp bad". + static const IconData gpp_bad = IconData(0xe2d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gpp_bad</i> — material icon named "gpp bad" (sharp). + static const IconData gpp_bad_sharp = IconData(0xe9d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gpp_bad</i> — material icon named "gpp bad" (round). + static const IconData gpp_bad_rounded = IconData(0xf7b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gpp_bad</i> — material icon named "gpp bad" (outlined). + static const IconData gpp_bad_outlined = IconData(0xf0c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gpp_good</i> — material icon named "gpp good". + static const IconData gpp_good = IconData(0xe2da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gpp_good</i> — material icon named "gpp good" (sharp). + static const IconData gpp_good_sharp = IconData(0xe9d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gpp_good</i> — material icon named "gpp good" (round). + static const IconData gpp_good_rounded = IconData(0xf7b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gpp_good</i> — material icon named "gpp good" (outlined). + static const IconData gpp_good_outlined = IconData(0xf0c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gpp_maybe</i> — material icon named "gpp maybe". + static const IconData gpp_maybe = IconData(0xe2db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gpp_maybe</i> — material icon named "gpp maybe" (sharp). + static const IconData gpp_maybe_sharp = IconData(0xe9d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gpp_maybe</i> — material icon named "gpp maybe" (round). + static const IconData gpp_maybe_rounded = IconData(0xf7b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gpp_maybe</i> — material icon named "gpp maybe" (outlined). + static const IconData gpp_maybe_outlined = IconData(0xf0c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gps_fixed</i> — material icon named "gps fixed". + static const IconData gps_fixed = IconData(0xe2dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gps_fixed</i> — material icon named "gps fixed" (sharp). + static const IconData gps_fixed_sharp = IconData(0xe9d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gps_fixed</i> — material icon named "gps fixed" (round). + static const IconData gps_fixed_rounded = IconData(0xf7b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gps_fixed</i> — material icon named "gps fixed" (outlined). + static const IconData gps_fixed_outlined = IconData(0xf0c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gps_not_fixed</i> — material icon named "gps not fixed". + static const IconData gps_not_fixed = IconData(0xe2dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gps_not_fixed</i> — material icon named "gps not fixed" (sharp). + static const IconData gps_not_fixed_sharp = IconData(0xe9d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gps_not_fixed</i> — material icon named "gps not fixed" (round). + static const IconData gps_not_fixed_rounded = IconData(0xf7b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gps_not_fixed</i> — material icon named "gps not fixed" (outlined). + static const IconData gps_not_fixed_outlined = IconData(0xf0ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gps_off</i> — material icon named "gps off". + static const IconData gps_off = IconData(0xe2de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gps_off</i> — material icon named "gps off" (sharp). + static const IconData gps_off_sharp = IconData(0xe9d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gps_off</i> — material icon named "gps off" (round). + static const IconData gps_off_rounded = IconData(0xf7b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gps_off</i> — material icon named "gps off" (outlined). + static const IconData gps_off_outlined = IconData(0xf0cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grade</i> — material icon named "grade". + static const IconData grade = IconData(0xe2df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grade</i> — material icon named "grade" (sharp). + static const IconData grade_sharp = IconData(0xe9da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grade</i> — material icon named "grade" (round). + static const IconData grade_rounded = IconData(0xf7b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grade</i> — material icon named "grade" (outlined). + static const IconData grade_outlined = IconData(0xf0cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">gradient</i> — material icon named "gradient". + static const IconData gradient = IconData(0xe2e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">gradient</i> — material icon named "gradient" (sharp). + static const IconData gradient_sharp = IconData(0xe9db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">gradient</i> — material icon named "gradient" (round). + static const IconData gradient_rounded = IconData(0xf7ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">gradient</i> — material icon named "gradient" (outlined). + static const IconData gradient_outlined = IconData(0xf0cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grading</i> — material icon named "grading". + static const IconData grading = IconData(0xe2e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grading</i> — material icon named "grading" (sharp). + static const IconData grading_sharp = IconData(0xe9dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grading</i> — material icon named "grading" (round). + static const IconData grading_rounded = IconData(0xf7bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grading</i> — material icon named "grading" (outlined). + static const IconData grading_outlined = IconData(0xf0ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grain</i> — material icon named "grain". + static const IconData grain = IconData(0xe2e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grain</i> — material icon named "grain" (sharp). + static const IconData grain_sharp = IconData(0xe9dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grain</i> — material icon named "grain" (round). + static const IconData grain_rounded = IconData(0xf7bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grain</i> — material icon named "grain" (outlined). + static const IconData grain_outlined = IconData(0xf0cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">graphic_eq</i> — material icon named "graphic eq". + static const IconData graphic_eq = IconData(0xe2e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">graphic_eq</i> — material icon named "graphic eq" (sharp). + static const IconData graphic_eq_sharp = IconData(0xe9de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">graphic_eq</i> — material icon named "graphic eq" (round). + static const IconData graphic_eq_rounded = IconData(0xf7bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">graphic_eq</i> — material icon named "graphic eq" (outlined). + static const IconData graphic_eq_outlined = IconData(0xf0d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grass</i> — material icon named "grass". + static const IconData grass = IconData(0xe2e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grass</i> — material icon named "grass" (sharp). + static const IconData grass_sharp = IconData(0xe9df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grass</i> — material icon named "grass" (round). + static const IconData grass_rounded = IconData(0xf7be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grass</i> — material icon named "grass" (outlined). + static const IconData grass_outlined = IconData(0xf0d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grid_3x3</i> — material icon named "grid 3x3". + static const IconData grid_3x3 = IconData(0xe2e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grid_3x3</i> — material icon named "grid 3x3" (sharp). + static const IconData grid_3x3_sharp = IconData(0xe9e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grid_3x3</i> — material icon named "grid 3x3" (round). + static const IconData grid_3x3_rounded = IconData(0xf7bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grid_3x3</i> — material icon named "grid 3x3" (outlined). + static const IconData grid_3x3_outlined = IconData(0xf0d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grid_4x4</i> — material icon named "grid 4x4". + static const IconData grid_4x4 = IconData(0xe2e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grid_4x4</i> — material icon named "grid 4x4" (sharp). + static const IconData grid_4x4_sharp = IconData(0xe9e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grid_4x4</i> — material icon named "grid 4x4" (round). + static const IconData grid_4x4_rounded = IconData(0xf7c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grid_4x4</i> — material icon named "grid 4x4" (outlined). + static const IconData grid_4x4_outlined = IconData(0xf0d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grid_goldenratio</i> — material icon named "grid goldenratio". + static const IconData grid_goldenratio = IconData(0xe2e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grid_goldenratio</i> — material icon named "grid goldenratio" (sharp). + static const IconData grid_goldenratio_sharp = IconData(0xe9e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grid_goldenratio</i> — material icon named "grid goldenratio" (round). + static const IconData grid_goldenratio_rounded = IconData(0xf7c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grid_goldenratio</i> — material icon named "grid goldenratio" (outlined). + static const IconData grid_goldenratio_outlined = IconData(0xf0d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grid_off</i> — material icon named "grid off". + static const IconData grid_off = IconData(0xe2e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grid_off</i> — material icon named "grid off" (sharp). + static const IconData grid_off_sharp = IconData(0xe9e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grid_off</i> — material icon named "grid off" (round). + static const IconData grid_off_rounded = IconData(0xf7c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grid_off</i> — material icon named "grid off" (outlined). + static const IconData grid_off_outlined = IconData(0xf0d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grid_on</i> — material icon named "grid on". + static const IconData grid_on = IconData(0xe2e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grid_on</i> — material icon named "grid on" (sharp). + static const IconData grid_on_sharp = IconData(0xe9e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grid_on</i> — material icon named "grid on" (round). + static const IconData grid_on_rounded = IconData(0xf7c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grid_on</i> — material icon named "grid on" (outlined). + static const IconData grid_on_outlined = IconData(0xf0d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">grid_view</i> — material icon named "grid view". + static const IconData grid_view = IconData(0xe2ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">grid_view</i> — material icon named "grid view" (sharp). + static const IconData grid_view_sharp = IconData(0xe9e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">grid_view</i> — material icon named "grid view" (round). + static const IconData grid_view_rounded = IconData(0xf7c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">grid_view</i> — material icon named "grid view" (outlined). + static const IconData grid_view_outlined = IconData(0xf0d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">group</i> — material icon named "group". + static const IconData group = IconData(0xe2eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">group</i> — material icon named "group" (sharp). + static const IconData group_sharp = IconData(0xe9e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">group</i> — material icon named "group" (round). + static const IconData group_rounded = IconData(0xf7c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">group</i> — material icon named "group" (outlined). + static const IconData group_outlined = IconData(0xf0d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">group_add</i> — material icon named "group add". + static const IconData group_add = IconData(0xe2ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">group_add</i> — material icon named "group add" (sharp). + static const IconData group_add_sharp = IconData(0xe9e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">group_add</i> — material icon named "group add" (round). + static const IconData group_add_rounded = IconData(0xf7c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">group_add</i> — material icon named "group add" (outlined). + static const IconData group_add_outlined = IconData(0xf0d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">group_off</i> — material icon named "group off". + static const IconData group_off = IconData(0xf0514, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">group_off</i> — material icon named "group off" (sharp). + static const IconData group_off_sharp = IconData(0xf041f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">group_off</i> — material icon named "group off" (round). + static const IconData group_off_rounded = IconData(0xf032c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">group_off</i> — material icon named "group off" (outlined). + static const IconData group_off_outlined = IconData(0xf060d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">group_remove</i> — material icon named "group remove". + static const IconData group_remove = IconData(0xf0515, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">group_remove</i> — material icon named "group remove" (sharp). + static const IconData group_remove_sharp = IconData(0xf0420, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">group_remove</i> — material icon named "group remove" (round). + static const IconData group_remove_rounded = IconData(0xf032d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">group_remove</i> — material icon named "group remove" (outlined). + static const IconData group_remove_outlined = IconData(0xf060e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">group_work</i> — material icon named "group work". + static const IconData group_work = IconData(0xe2ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">group_work</i> — material icon named "group work" (sharp). + static const IconData group_work_sharp = IconData(0xe9e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">group_work</i> — material icon named "group work" (round). + static const IconData group_work_rounded = IconData(0xf7c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">group_work</i> — material icon named "group work" (outlined). + static const IconData group_work_outlined = IconData(0xf0da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">groups</i> — material icon named "groups". + static const IconData groups = IconData(0xe2ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">groups</i> — material icon named "groups" (sharp). + static const IconData groups_sharp = IconData(0xe9e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">groups</i> — material icon named "groups" (round). + static const IconData groups_rounded = IconData(0xf7c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">groups</i> — material icon named "groups" (outlined). + static const IconData groups_outlined = IconData(0xf0db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">groups_2</i> — material icon named "groups 2". + static const IconData groups_2 = IconData(0xf0869, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">groups_2</i> — material icon named "groups 2" (sharp). + static const IconData groups_2_sharp = IconData(0xf0841, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">groups_2</i> — material icon named "groups 2" (round). + static const IconData groups_2_rounded = IconData(0xf088a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">groups_2</i> — material icon named "groups 2" (outlined). + static const IconData groups_2_outlined = IconData(0xf08a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">groups_3</i> — material icon named "groups 3". + static const IconData groups_3 = IconData(0xf086a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">groups_3</i> — material icon named "groups 3" (sharp). + static const IconData groups_3_sharp = IconData(0xf0842, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">groups_3</i> — material icon named "groups 3" (round). + static const IconData groups_3_rounded = IconData(0xf088b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">groups_3</i> — material icon named "groups 3" (outlined). + static const IconData groups_3_outlined = IconData(0xf08a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">h_mobiledata</i> — material icon named "h mobiledata". + static const IconData h_mobiledata = IconData(0xe2ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">h_mobiledata</i> — material icon named "h mobiledata" (sharp). + static const IconData h_mobiledata_sharp = IconData(0xe9ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">h_mobiledata</i> — material icon named "h mobiledata" (round). + static const IconData h_mobiledata_rounded = IconData(0xf7c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">h_mobiledata</i> — material icon named "h mobiledata" (outlined). + static const IconData h_mobiledata_outlined = IconData(0xf0dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">h_plus_mobiledata</i> — material icon named "h plus mobiledata". + static const IconData h_plus_mobiledata = IconData(0xe2f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">h_plus_mobiledata</i> — material icon named "h plus mobiledata" (sharp). + static const IconData h_plus_mobiledata_sharp = IconData(0xe9eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">h_plus_mobiledata</i> — material icon named "h plus mobiledata" (round). + static const IconData h_plus_mobiledata_rounded = IconData(0xf7ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">h_plus_mobiledata</i> — material icon named "h plus mobiledata" (outlined). + static const IconData h_plus_mobiledata_outlined = IconData(0xf0dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hail</i> — material icon named "hail". + static const IconData hail = IconData(0xe2f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hail</i> — material icon named "hail" (sharp). + static const IconData hail_sharp = IconData(0xe9ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hail</i> — material icon named "hail" (round). + static const IconData hail_rounded = IconData(0xf7cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hail</i> — material icon named "hail" (outlined). + static const IconData hail_outlined = IconData(0xf0de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">handshake</i> — material icon named "handshake". + static const IconData handshake = IconData(0xf06be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">handshake</i> — material icon named "handshake" (sharp). + static const IconData handshake_sharp = IconData(0xf06b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">handshake</i> — material icon named "handshake" (round). + static const IconData handshake_rounded = IconData(0xf06cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">handshake</i> — material icon named "handshake" (outlined). + static const IconData handshake_outlined = IconData(0xf06a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">handyman</i> — material icon named "handyman". + static const IconData handyman = IconData(0xe2f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">handyman</i> — material icon named "handyman" (sharp). + static const IconData handyman_sharp = IconData(0xe9ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">handyman</i> — material icon named "handyman" (round). + static const IconData handyman_rounded = IconData(0xf7cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">handyman</i> — material icon named "handyman" (outlined). + static const IconData handyman_outlined = IconData(0xf0df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hardware</i> — material icon named "hardware". + static const IconData hardware = IconData(0xe2f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hardware</i> — material icon named "hardware" (sharp). + static const IconData hardware_sharp = IconData(0xe9ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hardware</i> — material icon named "hardware" (round). + static const IconData hardware_rounded = IconData(0xf7cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hardware</i> — material icon named "hardware" (outlined). + static const IconData hardware_outlined = IconData(0xf0e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hd</i> — material icon named "hd". + static const IconData hd = IconData(0xe2f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hd</i> — material icon named "hd" (sharp). + static const IconData hd_sharp = IconData(0xe9ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hd</i> — material icon named "hd" (round). + static const IconData hd_rounded = IconData(0xf7ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hd</i> — material icon named "hd" (outlined). + static const IconData hd_outlined = IconData(0xf0e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_auto</i> — material icon named "hdr auto". + static const IconData hdr_auto = IconData(0xe2f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_auto</i> — material icon named "hdr auto" (sharp). + static const IconData hdr_auto_sharp = IconData(0xe9f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_auto</i> — material icon named "hdr auto" (round). + static const IconData hdr_auto_rounded = IconData(0xf7cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_auto</i> — material icon named "hdr auto" (outlined). + static const IconData hdr_auto_outlined = IconData(0xf0e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_auto_select</i> — material icon named "hdr auto select". + static const IconData hdr_auto_select = IconData(0xe2f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_auto_select</i> — material icon named "hdr auto select" (sharp). + static const IconData hdr_auto_select_sharp = IconData(0xe9f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_auto_select</i> — material icon named "hdr auto select" (round). + static const IconData hdr_auto_select_rounded = IconData(0xf7d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_auto_select</i> — material icon named "hdr auto select" (outlined). + static const IconData hdr_auto_select_outlined = IconData(0xf0e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_enhanced_select</i> — material icon named "hdr enhanced select". + static const IconData hdr_enhanced_select = IconData(0xe2f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_enhanced_select</i> — material icon named "hdr enhanced select" (sharp). + static const IconData hdr_enhanced_select_sharp = IconData(0xe9f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_enhanced_select</i> — material icon named "hdr enhanced select" (round). + static const IconData hdr_enhanced_select_rounded = IconData(0xf7d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_enhanced_select</i> — material icon named "hdr enhanced select" (outlined). + static const IconData hdr_enhanced_select_outlined = IconData( + 0xf0e4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">hdr_off</i> — material icon named "hdr off". + static const IconData hdr_off = IconData(0xe2f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_off</i> — material icon named "hdr off" (sharp). + static const IconData hdr_off_sharp = IconData(0xe9f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_off</i> — material icon named "hdr off" (round). + static const IconData hdr_off_rounded = IconData(0xf7d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_off</i> — material icon named "hdr off" (outlined). + static const IconData hdr_off_outlined = IconData(0xf0e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_off_select</i> — material icon named "hdr off select". + static const IconData hdr_off_select = IconData(0xe2f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_off_select</i> — material icon named "hdr off select" (sharp). + static const IconData hdr_off_select_sharp = IconData(0xe9f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_off_select</i> — material icon named "hdr off select" (round). + static const IconData hdr_off_select_rounded = IconData(0xf7d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_off_select</i> — material icon named "hdr off select" (outlined). + static const IconData hdr_off_select_outlined = IconData(0xf0e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_on</i> — material icon named "hdr on". + static const IconData hdr_on = IconData(0xe2fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_on</i> — material icon named "hdr on" (sharp). + static const IconData hdr_on_sharp = IconData(0xe9f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_on</i> — material icon named "hdr on" (round). + static const IconData hdr_on_rounded = IconData(0xf7d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_on</i> — material icon named "hdr on" (outlined). + static const IconData hdr_on_outlined = IconData(0xf0e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_on_select</i> — material icon named "hdr on select". + static const IconData hdr_on_select = IconData(0xe2fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_on_select</i> — material icon named "hdr on select" (sharp). + static const IconData hdr_on_select_sharp = IconData(0xe9f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_on_select</i> — material icon named "hdr on select" (round). + static const IconData hdr_on_select_rounded = IconData(0xf7d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_on_select</i> — material icon named "hdr on select" (outlined). + static const IconData hdr_on_select_outlined = IconData(0xf0e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_plus</i> — material icon named "hdr plus". + static const IconData hdr_plus = IconData(0xe2fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_plus</i> — material icon named "hdr plus" (sharp). + static const IconData hdr_plus_sharp = IconData(0xe9f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_plus</i> — material icon named "hdr plus" (round). + static const IconData hdr_plus_rounded = IconData(0xf7d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_plus</i> — material icon named "hdr plus" (outlined). + static const IconData hdr_plus_outlined = IconData(0xf0e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_strong</i> — material icon named "hdr strong". + static const IconData hdr_strong = IconData(0xe2fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_strong</i> — material icon named "hdr strong" (sharp). + static const IconData hdr_strong_sharp = IconData(0xe9f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_strong</i> — material icon named "hdr strong" (round). + static const IconData hdr_strong_rounded = IconData(0xf7d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_strong</i> — material icon named "hdr strong" (outlined). + static const IconData hdr_strong_outlined = IconData(0xf0ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hdr_weak</i> — material icon named "hdr weak". + static const IconData hdr_weak = IconData(0xe2fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hdr_weak</i> — material icon named "hdr weak" (sharp). + static const IconData hdr_weak_sharp = IconData(0xe9f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hdr_weak</i> — material icon named "hdr weak" (round). + static const IconData hdr_weak_rounded = IconData(0xf7d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hdr_weak</i> — material icon named "hdr weak" (outlined). + static const IconData hdr_weak_outlined = IconData(0xf0eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">headphones</i> — material icon named "headphones". + static const IconData headphones = IconData(0xe2ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">headphones</i> — material icon named "headphones" (sharp). + static const IconData headphones_sharp = IconData(0xe9fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">headphones</i> — material icon named "headphones" (round). + static const IconData headphones_rounded = IconData(0xf7da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">headphones</i> — material icon named "headphones" (outlined). + static const IconData headphones_outlined = IconData(0xf0ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">headphones_battery</i> — material icon named "headphones battery". + static const IconData headphones_battery = IconData(0xe300, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">headphones_battery</i> — material icon named "headphones battery" (sharp). + static const IconData headphones_battery_sharp = IconData(0xe9fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">headphones_battery</i> — material icon named "headphones battery" (round). + static const IconData headphones_battery_rounded = IconData(0xf7d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">headphones_battery</i> — material icon named "headphones battery" (outlined). + static const IconData headphones_battery_outlined = IconData(0xf0ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">headset</i> — material icon named "headset". + static const IconData headset = IconData(0xe301, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">headset</i> — material icon named "headset" (sharp). + static const IconData headset_sharp = IconData(0xe9fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">headset</i> — material icon named "headset" (round). + static const IconData headset_rounded = IconData(0xf7dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">headset</i> — material icon named "headset" (outlined). + static const IconData headset_outlined = IconData(0xf0f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">headset_mic</i> — material icon named "headset mic". + static const IconData headset_mic = IconData(0xe302, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">headset_mic</i> — material icon named "headset mic" (sharp). + static const IconData headset_mic_sharp = IconData(0xe9fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">headset_mic</i> — material icon named "headset mic" (round). + static const IconData headset_mic_rounded = IconData(0xf7db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">headset_mic</i> — material icon named "headset mic" (outlined). + static const IconData headset_mic_outlined = IconData(0xf0ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">headset_off</i> — material icon named "headset off". + static const IconData headset_off = IconData(0xe303, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">headset_off</i> — material icon named "headset off" (sharp). + static const IconData headset_off_sharp = IconData(0xe9fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">headset_off</i> — material icon named "headset off" (round). + static const IconData headset_off_rounded = IconData(0xf7dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">headset_off</i> — material icon named "headset off" (outlined). + static const IconData headset_off_outlined = IconData(0xf0ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">healing</i> — material icon named "healing". + static const IconData healing = IconData(0xe304, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">healing</i> — material icon named "healing" (sharp). + static const IconData healing_sharp = IconData(0xe9ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">healing</i> — material icon named "healing" (round). + static const IconData healing_rounded = IconData(0xf7de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">healing</i> — material icon named "healing" (outlined). + static const IconData healing_outlined = IconData(0xf0f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">health_and_safety</i> — material icon named "health and safety". + static const IconData health_and_safety = IconData(0xe305, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">health_and_safety</i> — material icon named "health and safety" (sharp). + static const IconData health_and_safety_sharp = IconData(0xea00, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">health_and_safety</i> — material icon named "health and safety" (round). + static const IconData health_and_safety_rounded = IconData(0xf7df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">health_and_safety</i> — material icon named "health and safety" (outlined). + static const IconData health_and_safety_outlined = IconData(0xf0f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hearing</i> — material icon named "hearing". + static const IconData hearing = IconData(0xe306, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hearing</i> — material icon named "hearing" (sharp). + static const IconData hearing_sharp = IconData(0xea02, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hearing</i> — material icon named "hearing" (round). + static const IconData hearing_rounded = IconData(0xf7e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hearing</i> — material icon named "hearing" (outlined). + static const IconData hearing_outlined = IconData(0xf0f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hearing_disabled</i> — material icon named "hearing disabled". + static const IconData hearing_disabled = IconData(0xe307, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hearing_disabled</i> — material icon named "hearing disabled" (sharp). + static const IconData hearing_disabled_sharp = IconData(0xea01, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hearing_disabled</i> — material icon named "hearing disabled" (round). + static const IconData hearing_disabled_rounded = IconData(0xf7e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hearing_disabled</i> — material icon named "hearing disabled" (outlined). + static const IconData hearing_disabled_outlined = IconData(0xf0f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">heart_broken</i> — material icon named "heart broken". + static const IconData heart_broken = IconData(0xf0516, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">heart_broken</i> — material icon named "heart broken" (sharp). + static const IconData heart_broken_sharp = IconData(0xf0421, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">heart_broken</i> — material icon named "heart broken" (round). + static const IconData heart_broken_rounded = IconData(0xf032e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">heart_broken</i> — material icon named "heart broken" (outlined). + static const IconData heart_broken_outlined = IconData(0xf060f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">heat_pump</i> — material icon named "heat pump". + static const IconData heat_pump = IconData(0xf07a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">heat_pump</i> — material icon named "heat pump" (sharp). + static const IconData heat_pump_sharp = IconData(0xf074d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">heat_pump</i> — material icon named "heat pump" (round). + static const IconData heat_pump_rounded = IconData(0xf07fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">heat_pump</i> — material icon named "heat pump" (outlined). + static const IconData heat_pump_outlined = IconData(0xf06f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">height</i> — material icon named "height". + static const IconData height = IconData(0xe308, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">height</i> — material icon named "height" (sharp). + static const IconData height_sharp = IconData(0xea03, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">height</i> — material icon named "height" (round). + static const IconData height_rounded = IconData(0xf7e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">height</i> — material icon named "height" (outlined). + static const IconData height_outlined = IconData(0xf0f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">help</i> — material icon named "help". + static const IconData help = IconData( + 0xe309, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">help</i> — material icon named "help" (sharp). + static const IconData help_sharp = IconData( + 0xea06, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">help</i> — material icon named "help" (round). + static const IconData help_rounded = IconData( + 0xf7e5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">help</i> — material icon named "help" (outlined). + static const IconData help_outlined = IconData( + 0xf0f8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">help_center</i> — material icon named "help center". + static const IconData help_center = IconData(0xe30a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">help_center</i> — material icon named "help center" (sharp). + static const IconData help_center_sharp = IconData(0xea04, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">help_center</i> — material icon named "help center" (round). + static const IconData help_center_rounded = IconData(0xf7e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">help_center</i> — material icon named "help center" (outlined). + static const IconData help_center_outlined = IconData(0xf0f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">help_outline</i> — material icon named "help outline". + static const IconData help_outline = IconData( + 0xe30b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">help_outline</i> — material icon named "help outline" (sharp). + static const IconData help_outline_sharp = IconData( + 0xea05, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">help_outline</i> — material icon named "help outline" (round). + static const IconData help_outline_rounded = IconData( + 0xf7e4, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">help_outline</i> — material icon named "help outline" (outlined). + static const IconData help_outline_outlined = IconData( + 0xf0f7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">hevc</i> — material icon named "hevc". + static const IconData hevc = IconData(0xe30c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hevc</i> — material icon named "hevc" (sharp). + static const IconData hevc_sharp = IconData(0xea07, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hevc</i> — material icon named "hevc" (round). + static const IconData hevc_rounded = IconData(0xf7e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hevc</i> — material icon named "hevc" (outlined). + static const IconData hevc_outlined = IconData(0xf0f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hexagon</i> — material icon named "hexagon". + static const IconData hexagon = IconData(0xf0517, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hexagon</i> — material icon named "hexagon" (sharp). + static const IconData hexagon_sharp = IconData(0xf0422, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hexagon</i> — material icon named "hexagon" (round). + static const IconData hexagon_rounded = IconData(0xf032f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hexagon</i> — material icon named "hexagon" (outlined). + static const IconData hexagon_outlined = IconData(0xf0610, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hide_image</i> — material icon named "hide image". + static const IconData hide_image = IconData(0xe30d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hide_image</i> — material icon named "hide image" (sharp). + static const IconData hide_image_sharp = IconData(0xea08, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hide_image</i> — material icon named "hide image" (round). + static const IconData hide_image_rounded = IconData(0xf7e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hide_image</i> — material icon named "hide image" (outlined). + static const IconData hide_image_outlined = IconData(0xf0fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hide_source</i> — material icon named "hide source". + static const IconData hide_source = IconData(0xe30e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hide_source</i> — material icon named "hide source" (sharp). + static const IconData hide_source_sharp = IconData(0xea09, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hide_source</i> — material icon named "hide source" (round). + static const IconData hide_source_rounded = IconData(0xf7e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hide_source</i> — material icon named "hide source" (outlined). + static const IconData hide_source_outlined = IconData(0xf0fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">high_quality</i> — material icon named "high quality". + static const IconData high_quality = IconData(0xe30f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">high_quality</i> — material icon named "high quality" (sharp). + static const IconData high_quality_sharp = IconData(0xea0a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">high_quality</i> — material icon named "high quality" (round). + static const IconData high_quality_rounded = IconData(0xf7e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">high_quality</i> — material icon named "high quality" (outlined). + static const IconData high_quality_outlined = IconData(0xf0fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">highlight</i> — material icon named "highlight". + static const IconData highlight = IconData(0xe310, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">highlight</i> — material icon named "highlight" (sharp). + static const IconData highlight_sharp = IconData(0xea0d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">highlight</i> — material icon named "highlight" (round). + static const IconData highlight_rounded = IconData(0xf7ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">highlight</i> — material icon named "highlight" (outlined). + static const IconData highlight_outlined = IconData(0xf0ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">highlight_alt</i> — material icon named "highlight alt". + static const IconData highlight_alt = IconData(0xe311, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">highlight_alt</i> — material icon named "highlight alt" (sharp). + static const IconData highlight_alt_sharp = IconData(0xea0b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">highlight_alt</i> — material icon named "highlight alt" (round). + static const IconData highlight_alt_rounded = IconData(0xf7ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">highlight_alt</i> — material icon named "highlight alt" (outlined). + static const IconData highlight_alt_outlined = IconData(0xf0fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">highlight_off</i> — material icon named "highlight off". + static const IconData highlight_off = IconData(0xe312, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">highlight_off</i> — material icon named "highlight off" (sharp). + static const IconData highlight_off_sharp = IconData(0xea0c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">highlight_off</i> — material icon named "highlight off" (round). + static const IconData highlight_off_rounded = IconData(0xf7eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">highlight_off</i> — material icon named "highlight off" (outlined). + static const IconData highlight_off_outlined = IconData(0xf0fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">highlight_remove</i> — material icon named "highlight remove". + static const IconData highlight_remove = IconData(0xe312, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">highlight_remove</i> — material icon named "highlight remove" (sharp). + static const IconData highlight_remove_sharp = IconData(0xea0c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">highlight_remove</i> — material icon named "highlight remove" (round). + static const IconData highlight_remove_rounded = IconData(0xf7eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">highlight_remove</i> — material icon named "highlight remove" (outlined). + static const IconData highlight_remove_outlined = IconData(0xf0fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hiking</i> — material icon named "hiking". + static const IconData hiking = IconData(0xe313, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hiking</i> — material icon named "hiking" (sharp). + static const IconData hiking_sharp = IconData(0xea0e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hiking</i> — material icon named "hiking" (round). + static const IconData hiking_rounded = IconData(0xf7ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hiking</i> — material icon named "hiking" (outlined). + static const IconData hiking_outlined = IconData(0xf100, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">history</i> — material icon named "history". + static const IconData history = IconData(0xe314, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">history</i> — material icon named "history" (sharp). + static const IconData history_sharp = IconData(0xea10, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">history</i> — material icon named "history" (round). + static const IconData history_rounded = IconData(0xf7ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">history</i> — material icon named "history" (outlined). + static const IconData history_outlined = IconData(0xf102, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">history_edu</i> — material icon named "history edu". + static const IconData history_edu = IconData(0xe315, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">history_edu</i> — material icon named "history edu" (sharp). + static const IconData history_edu_sharp = IconData(0xea0f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">history_edu</i> — material icon named "history edu" (round). + static const IconData history_edu_rounded = IconData(0xf7ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">history_edu</i> — material icon named "history edu" (outlined). + static const IconData history_edu_outlined = IconData(0xf101, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">history_toggle_off</i> — material icon named "history toggle off". + static const IconData history_toggle_off = IconData(0xe316, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">history_toggle_off</i> — material icon named "history toggle off" (sharp). + static const IconData history_toggle_off_sharp = IconData(0xea11, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">history_toggle_off</i> — material icon named "history toggle off" (round). + static const IconData history_toggle_off_rounded = IconData(0xf7f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">history_toggle_off</i> — material icon named "history toggle off" (outlined). + static const IconData history_toggle_off_outlined = IconData(0xf103, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hive</i> — material icon named "hive". + static const IconData hive = IconData(0xf0518, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hive</i> — material icon named "hive" (sharp). + static const IconData hive_sharp = IconData(0xf0423, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hive</i> — material icon named "hive" (round). + static const IconData hive_rounded = IconData(0xf0330, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hive</i> — material icon named "hive" (outlined). + static const IconData hive_outlined = IconData(0xf0611, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hls</i> — material icon named "hls". + static const IconData hls = IconData(0xf0519, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hls</i> — material icon named "hls" (sharp). + static const IconData hls_sharp = IconData(0xf0425, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hls</i> — material icon named "hls" (round). + static const IconData hls_rounded = IconData(0xf0332, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hls</i> — material icon named "hls" (outlined). + static const IconData hls_outlined = IconData(0xf0613, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hls_off</i> — material icon named "hls off". + static const IconData hls_off = IconData(0xf051a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hls_off</i> — material icon named "hls off" (sharp). + static const IconData hls_off_sharp = IconData(0xf0424, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hls_off</i> — material icon named "hls off" (round). + static const IconData hls_off_rounded = IconData(0xf0331, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hls_off</i> — material icon named "hls off" (outlined). + static const IconData hls_off_outlined = IconData(0xf0612, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">holiday_village</i> — material icon named "holiday village". + static const IconData holiday_village = IconData(0xe317, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">holiday_village</i> — material icon named "holiday village" (sharp). + static const IconData holiday_village_sharp = IconData(0xea12, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">holiday_village</i> — material icon named "holiday village" (round). + static const IconData holiday_village_rounded = IconData(0xf7f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">holiday_village</i> — material icon named "holiday village" (outlined). + static const IconData holiday_village_outlined = IconData(0xf104, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">home</i> — material icon named "home". + static const IconData home = IconData(0xe318, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">home</i> — material icon named "home" (sharp). + static const IconData home_sharp = IconData(0xea16, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">home</i> — material icon named "home" (round). + static const IconData home_rounded = IconData(0xf7f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">home</i> — material icon named "home" (outlined). + static const IconData home_outlined = IconData(0xf107, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">home_filled</i> — material icon named "home filled". + static const IconData home_filled = IconData(0xe319, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">home_max</i> — material icon named "home max". + static const IconData home_max = IconData(0xe31a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">home_max</i> — material icon named "home max" (sharp). + static const IconData home_max_sharp = IconData(0xea13, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">home_max</i> — material icon named "home max" (round). + static const IconData home_max_rounded = IconData(0xf7f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">home_max</i> — material icon named "home max" (outlined). + static const IconData home_max_outlined = IconData(0xf105, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">home_mini</i> — material icon named "home mini". + static const IconData home_mini = IconData(0xe31b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">home_mini</i> — material icon named "home mini" (sharp). + static const IconData home_mini_sharp = IconData(0xea14, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">home_mini</i> — material icon named "home mini" (round). + static const IconData home_mini_rounded = IconData(0xf7f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">home_mini</i> — material icon named "home mini" (outlined). + static const IconData home_mini_outlined = IconData(0xf106, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">home_repair_service</i> — material icon named "home repair service". + static const IconData home_repair_service = IconData(0xe31c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">home_repair_service</i> — material icon named "home repair service" (sharp). + static const IconData home_repair_service_sharp = IconData(0xea15, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">home_repair_service</i> — material icon named "home repair service" (round). + static const IconData home_repair_service_rounded = IconData(0xf7f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">home_repair_service</i> — material icon named "home repair service" (outlined). + static const IconData home_repair_service_outlined = IconData( + 0xf108, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">home_work</i> — material icon named "home work". + static const IconData home_work = IconData(0xe31d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">home_work</i> — material icon named "home work" (sharp). + static const IconData home_work_sharp = IconData(0xea17, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">home_work</i> — material icon named "home work" (round). + static const IconData home_work_rounded = IconData(0xf7f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">home_work</i> — material icon named "home work" (outlined). + static const IconData home_work_outlined = IconData(0xf109, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">horizontal_distribute</i> — material icon named "horizontal distribute". + static const IconData horizontal_distribute = IconData(0xe31e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">horizontal_distribute</i> — material icon named "horizontal distribute" (sharp). + static const IconData horizontal_distribute_sharp = IconData(0xea18, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">horizontal_distribute</i> — material icon named "horizontal distribute" (round). + static const IconData horizontal_distribute_rounded = IconData( + 0xf7f7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">horizontal_distribute</i> — material icon named "horizontal distribute" (outlined). + static const IconData horizontal_distribute_outlined = IconData( + 0xf10a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">horizontal_rule</i> — material icon named "horizontal rule". + static const IconData horizontal_rule = IconData(0xe31f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">horizontal_rule</i> — material icon named "horizontal rule" (sharp). + static const IconData horizontal_rule_sharp = IconData(0xea19, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">horizontal_rule</i> — material icon named "horizontal rule" (round). + static const IconData horizontal_rule_rounded = IconData(0xf7f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">horizontal_rule</i> — material icon named "horizontal rule" (outlined). + static const IconData horizontal_rule_outlined = IconData(0xf10b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">horizontal_split</i> — material icon named "horizontal split". + static const IconData horizontal_split = IconData(0xe320, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">horizontal_split</i> — material icon named "horizontal split" (sharp). + static const IconData horizontal_split_sharp = IconData(0xea1a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">horizontal_split</i> — material icon named "horizontal split" (round). + static const IconData horizontal_split_rounded = IconData(0xf7f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">horizontal_split</i> — material icon named "horizontal split" (outlined). + static const IconData horizontal_split_outlined = IconData(0xf10c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hot_tub</i> — material icon named "hot tub". + static const IconData hot_tub = IconData(0xe321, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hot_tub</i> — material icon named "hot tub" (sharp). + static const IconData hot_tub_sharp = IconData(0xea1b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hot_tub</i> — material icon named "hot tub" (round). + static const IconData hot_tub_rounded = IconData(0xf7fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hot_tub</i> — material icon named "hot tub" (outlined). + static const IconData hot_tub_outlined = IconData(0xf10d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hotel</i> — material icon named "hotel". + static const IconData hotel = IconData(0xe322, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hotel</i> — material icon named "hotel" (sharp). + static const IconData hotel_sharp = IconData(0xea1c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hotel</i> — material icon named "hotel" (round). + static const IconData hotel_rounded = IconData(0xf7fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hotel</i> — material icon named "hotel" (outlined). + static const IconData hotel_outlined = IconData(0xf10e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hotel_class</i> — material icon named "hotel class". + static const IconData hotel_class = IconData(0xf051b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hotel_class</i> — material icon named "hotel class" (sharp). + static const IconData hotel_class_sharp = IconData(0xf0426, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hotel_class</i> — material icon named "hotel class" (round). + static const IconData hotel_class_rounded = IconData(0xf0333, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hotel_class</i> — material icon named "hotel class" (outlined). + static const IconData hotel_class_outlined = IconData(0xf0614, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hourglass_bottom</i> — material icon named "hourglass bottom". + static const IconData hourglass_bottom = IconData(0xe323, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hourglass_bottom</i> — material icon named "hourglass bottom" (sharp). + static const IconData hourglass_bottom_sharp = IconData(0xea1d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hourglass_bottom</i> — material icon named "hourglass bottom" (round). + static const IconData hourglass_bottom_rounded = IconData(0xf7fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hourglass_bottom</i> — material icon named "hourglass bottom" (outlined). + static const IconData hourglass_bottom_outlined = IconData(0xf10f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hourglass_disabled</i> — material icon named "hourglass disabled". + static const IconData hourglass_disabled = IconData(0xe324, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hourglass_disabled</i> — material icon named "hourglass disabled" (sharp). + static const IconData hourglass_disabled_sharp = IconData(0xea1e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hourglass_disabled</i> — material icon named "hourglass disabled" (round). + static const IconData hourglass_disabled_rounded = IconData(0xf7fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hourglass_disabled</i> — material icon named "hourglass disabled" (outlined). + static const IconData hourglass_disabled_outlined = IconData(0xf110, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hourglass_empty</i> — material icon named "hourglass empty". + static const IconData hourglass_empty = IconData(0xe325, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hourglass_empty</i> — material icon named "hourglass empty" (sharp). + static const IconData hourglass_empty_sharp = IconData(0xea1f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hourglass_empty</i> — material icon named "hourglass empty" (round). + static const IconData hourglass_empty_rounded = IconData(0xf7fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hourglass_empty</i> — material icon named "hourglass empty" (outlined). + static const IconData hourglass_empty_outlined = IconData(0xf111, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hourglass_full</i> — material icon named "hourglass full". + static const IconData hourglass_full = IconData(0xe326, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hourglass_full</i> — material icon named "hourglass full" (sharp). + static const IconData hourglass_full_sharp = IconData(0xea20, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hourglass_full</i> — material icon named "hourglass full" (round). + static const IconData hourglass_full_rounded = IconData(0xf7ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hourglass_full</i> — material icon named "hourglass full" (outlined). + static const IconData hourglass_full_outlined = IconData(0xf112, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hourglass_top</i> — material icon named "hourglass top". + static const IconData hourglass_top = IconData(0xe327, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hourglass_top</i> — material icon named "hourglass top" (sharp). + static const IconData hourglass_top_sharp = IconData(0xea21, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hourglass_top</i> — material icon named "hourglass top" (round). + static const IconData hourglass_top_rounded = IconData(0xf800, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hourglass_top</i> — material icon named "hourglass top" (outlined). + static const IconData hourglass_top_outlined = IconData(0xf113, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">house</i> — material icon named "house". + static const IconData house = IconData(0xe328, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">house</i> — material icon named "house" (sharp). + static const IconData house_sharp = IconData(0xea22, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">house</i> — material icon named "house" (round). + static const IconData house_rounded = IconData(0xf801, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">house</i> — material icon named "house" (outlined). + static const IconData house_outlined = IconData(0xf114, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">house_siding</i> — material icon named "house siding". + static const IconData house_siding = IconData(0xe329, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">house_siding</i> — material icon named "house siding" (sharp). + static const IconData house_siding_sharp = IconData(0xea23, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">house_siding</i> — material icon named "house siding" (round). + static const IconData house_siding_rounded = IconData(0xf802, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">house_siding</i> — material icon named "house siding" (outlined). + static const IconData house_siding_outlined = IconData(0xf115, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">houseboat</i> — material icon named "houseboat". + static const IconData houseboat = IconData(0xe32a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">houseboat</i> — material icon named "houseboat" (sharp). + static const IconData houseboat_sharp = IconData(0xea24, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">houseboat</i> — material icon named "houseboat" (round). + static const IconData houseboat_rounded = IconData(0xf803, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">houseboat</i> — material icon named "houseboat" (outlined). + static const IconData houseboat_outlined = IconData(0xf116, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">how_to_reg</i> — material icon named "how to reg". + static const IconData how_to_reg = IconData(0xe32b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">how_to_reg</i> — material icon named "how to reg" (sharp). + static const IconData how_to_reg_sharp = IconData(0xea25, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">how_to_reg</i> — material icon named "how to reg" (round). + static const IconData how_to_reg_rounded = IconData(0xf804, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">how_to_reg</i> — material icon named "how to reg" (outlined). + static const IconData how_to_reg_outlined = IconData(0xf117, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">how_to_vote</i> — material icon named "how to vote". + static const IconData how_to_vote = IconData(0xe32c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">how_to_vote</i> — material icon named "how to vote" (sharp). + static const IconData how_to_vote_sharp = IconData(0xea26, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">how_to_vote</i> — material icon named "how to vote" (round). + static const IconData how_to_vote_rounded = IconData(0xf805, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">how_to_vote</i> — material icon named "how to vote" (outlined). + static const IconData how_to_vote_outlined = IconData(0xf118, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">html</i> — material icon named "html". + static const IconData html = IconData(0xf051c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">html</i> — material icon named "html" (sharp). + static const IconData html_sharp = IconData(0xf0427, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">html</i> — material icon named "html" (round). + static const IconData html_rounded = IconData(0xf0334, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">html</i> — material icon named "html" (outlined). + static const IconData html_outlined = IconData(0xf0615, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">http</i> — material icon named "http". + static const IconData http = IconData(0xe32d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">http</i> — material icon named "http" (sharp). + static const IconData http_sharp = IconData(0xea27, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">http</i> — material icon named "http" (round). + static const IconData http_rounded = IconData(0xf806, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">http</i> — material icon named "http" (outlined). + static const IconData http_outlined = IconData(0xf119, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">https</i> — material icon named "https". + static const IconData https = IconData(0xe32e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">https</i> — material icon named "https" (sharp). + static const IconData https_sharp = IconData(0xea28, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">https</i> — material icon named "https" (round). + static const IconData https_rounded = IconData(0xf807, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">https</i> — material icon named "https" (outlined). + static const IconData https_outlined = IconData(0xf11a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hub</i> — material icon named "hub". + static const IconData hub = IconData(0xf051d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hub</i> — material icon named "hub" (sharp). + static const IconData hub_sharp = IconData(0xf0428, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hub</i> — material icon named "hub" (round). + static const IconData hub_rounded = IconData(0xf0335, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hub</i> — material icon named "hub" (outlined). + static const IconData hub_outlined = IconData(0xf0616, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">hvac</i> — material icon named "hvac". + static const IconData hvac = IconData(0xe32f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">hvac</i> — material icon named "hvac" (sharp). + static const IconData hvac_sharp = IconData(0xea29, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">hvac</i> — material icon named "hvac" (round). + static const IconData hvac_rounded = IconData(0xf808, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">hvac</i> — material icon named "hvac" (outlined). + static const IconData hvac_outlined = IconData(0xf11b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ice_skating</i> — material icon named "ice skating". + static const IconData ice_skating = IconData(0xe330, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ice_skating</i> — material icon named "ice skating" (sharp). + static const IconData ice_skating_sharp = IconData(0xea2a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ice_skating</i> — material icon named "ice skating" (round). + static const IconData ice_skating_rounded = IconData(0xf809, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ice_skating</i> — material icon named "ice skating" (outlined). + static const IconData ice_skating_outlined = IconData(0xf11c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">icecream</i> — material icon named "icecream". + static const IconData icecream = IconData(0xe331, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">icecream</i> — material icon named "icecream" (sharp). + static const IconData icecream_sharp = IconData(0xea2b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">icecream</i> — material icon named "icecream" (round). + static const IconData icecream_rounded = IconData(0xf80a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">icecream</i> — material icon named "icecream" (outlined). + static const IconData icecream_outlined = IconData(0xf11d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">image</i> — material icon named "image". + static const IconData image = IconData(0xe332, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">image</i> — material icon named "image" (sharp). + static const IconData image_sharp = IconData(0xea2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">image</i> — material icon named "image" (round). + static const IconData image_rounded = IconData(0xf80d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">image</i> — material icon named "image" (outlined). + static const IconData image_outlined = IconData(0xf120, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">image_aspect_ratio</i> — material icon named "image aspect ratio". + static const IconData image_aspect_ratio = IconData(0xe333, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">image_aspect_ratio</i> — material icon named "image aspect ratio" (sharp). + static const IconData image_aspect_ratio_sharp = IconData(0xea2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">image_aspect_ratio</i> — material icon named "image aspect ratio" (round). + static const IconData image_aspect_ratio_rounded = IconData(0xf80b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">image_aspect_ratio</i> — material icon named "image aspect ratio" (outlined). + static const IconData image_aspect_ratio_outlined = IconData(0xf11e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">image_not_supported</i> — material icon named "image not supported". + static const IconData image_not_supported = IconData(0xe334, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">image_not_supported</i> — material icon named "image not supported" (sharp). + static const IconData image_not_supported_sharp = IconData(0xea2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">image_not_supported</i> — material icon named "image not supported" (round). + static const IconData image_not_supported_rounded = IconData(0xf80c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">image_not_supported</i> — material icon named "image not supported" (outlined). + static const IconData image_not_supported_outlined = IconData( + 0xf11f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">image_search</i> — material icon named "image search". + static const IconData image_search = IconData(0xe335, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">image_search</i> — material icon named "image search" (sharp). + static const IconData image_search_sharp = IconData(0xea2e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">image_search</i> — material icon named "image search" (round). + static const IconData image_search_rounded = IconData(0xf80e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">image_search</i> — material icon named "image search" (outlined). + static const IconData image_search_outlined = IconData(0xf121, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">imagesearch_roller</i> — material icon named "imagesearch roller". + static const IconData imagesearch_roller = IconData(0xe336, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">imagesearch_roller</i> — material icon named "imagesearch roller" (sharp). + static const IconData imagesearch_roller_sharp = IconData(0xea30, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">imagesearch_roller</i> — material icon named "imagesearch roller" (round). + static const IconData imagesearch_roller_rounded = IconData(0xf80f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">imagesearch_roller</i> — material icon named "imagesearch roller" (outlined). + static const IconData imagesearch_roller_outlined = IconData(0xf122, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">import_contacts</i> — material icon named "import contacts". + static const IconData import_contacts = IconData(0xe337, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">import_contacts</i> — material icon named "import contacts" (sharp). + static const IconData import_contacts_sharp = IconData(0xea31, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">import_contacts</i> — material icon named "import contacts" (round). + static const IconData import_contacts_rounded = IconData(0xf810, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">import_contacts</i> — material icon named "import contacts" (outlined). + static const IconData import_contacts_outlined = IconData(0xf123, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">import_export</i> — material icon named "import export". + static const IconData import_export = IconData(0xe338, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">import_export</i> — material icon named "import export" (sharp). + static const IconData import_export_sharp = IconData(0xea32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">import_export</i> — material icon named "import export" (round). + static const IconData import_export_rounded = IconData(0xf811, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">import_export</i> — material icon named "import export" (outlined). + static const IconData import_export_outlined = IconData(0xf124, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">important_devices</i> — material icon named "important devices". + static const IconData important_devices = IconData(0xe339, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">important_devices</i> — material icon named "important devices" (sharp). + static const IconData important_devices_sharp = IconData(0xea33, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">important_devices</i> — material icon named "important devices" (round). + static const IconData important_devices_rounded = IconData(0xf812, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">important_devices</i> — material icon named "important devices" (outlined). + static const IconData important_devices_outlined = IconData(0xf125, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">inbox</i> — material icon named "inbox". + static const IconData inbox = IconData(0xe33a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">inbox</i> — material icon named "inbox" (sharp). + static const IconData inbox_sharp = IconData(0xea34, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">inbox</i> — material icon named "inbox" (round). + static const IconData inbox_rounded = IconData(0xf813, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">inbox</i> — material icon named "inbox" (outlined). + static const IconData inbox_outlined = IconData(0xf126, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">incomplete_circle</i> — material icon named "incomplete circle". + static const IconData incomplete_circle = IconData(0xf051e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">incomplete_circle</i> — material icon named "incomplete circle" (sharp). + static const IconData incomplete_circle_sharp = IconData(0xf0429, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">incomplete_circle</i> — material icon named "incomplete circle" (round). + static const IconData incomplete_circle_rounded = IconData(0xf0336, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">incomplete_circle</i> — material icon named "incomplete circle" (outlined). + static const IconData incomplete_circle_outlined = IconData(0xf0617, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">indeterminate_check_box</i> — material icon named "indeterminate check box". + static const IconData indeterminate_check_box = IconData(0xe33b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">indeterminate_check_box</i> — material icon named "indeterminate check box" (sharp). + static const IconData indeterminate_check_box_sharp = IconData( + 0xea35, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">indeterminate_check_box</i> — material icon named "indeterminate check box" (round). + static const IconData indeterminate_check_box_rounded = IconData( + 0xf814, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">indeterminate_check_box</i> — material icon named "indeterminate check box" (outlined). + static const IconData indeterminate_check_box_outlined = IconData( + 0xf127, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">info</i> — material icon named "info". + static const IconData info = IconData(0xe33c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">info</i> — material icon named "info" (sharp). + static const IconData info_sharp = IconData(0xea37, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">info</i> — material icon named "info" (round). + static const IconData info_rounded = IconData(0xf816, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">info</i> — material icon named "info" (outlined). + static const IconData info_outlined = IconData(0xf128, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">info_outline</i> — material icon named "info outline". + static const IconData info_outline = IconData(0xe33d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">info_outline</i> — material icon named "info outline" (sharp). + static const IconData info_outline_sharp = IconData(0xea36, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">info_outline</i> — material icon named "info outline" (round). + static const IconData info_outline_rounded = IconData(0xf815, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">input</i> — material icon named "input". + static const IconData input = IconData( + 0xe33e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">input</i> — material icon named "input" (sharp). + static const IconData input_sharp = IconData( + 0xea38, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">input</i> — material icon named "input" (round). + static const IconData input_rounded = IconData( + 0xf817, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">input</i> — material icon named "input" (outlined). + static const IconData input_outlined = IconData( + 0xf129, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">insert_chart</i> — material icon named "insert chart". + static const IconData insert_chart = IconData(0xe33f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_chart</i> — material icon named "insert chart" (sharp). + static const IconData insert_chart_sharp = IconData(0xea3a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_chart</i> — material icon named "insert chart" (round). + static const IconData insert_chart_rounded = IconData(0xf819, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_chart</i> — material icon named "insert chart". + static const IconData insert_chart_outlined = IconData(0xf12a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_chart_outlined</i> — material icon named "insert chart outlined" (sharp). + static const IconData insert_chart_outlined_sharp = IconData(0xea39, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_chart_outlined</i> — material icon named "insert chart outlined" (round). + static const IconData insert_chart_outlined_rounded = IconData( + 0xf818, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">insert_chart_outlined</i> — material icon named "insert chart outlined" (outlined). + static const IconData insert_chart_outlined_outlined = IconData( + 0xf12b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">insert_comment</i> — material icon named "insert comment". + static const IconData insert_comment = IconData(0xe341, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_comment</i> — material icon named "insert comment" (sharp). + static const IconData insert_comment_sharp = IconData(0xea3b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_comment</i> — material icon named "insert comment" (round). + static const IconData insert_comment_rounded = IconData(0xf81a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_comment</i> — material icon named "insert comment" (outlined). + static const IconData insert_comment_outlined = IconData(0xf12c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_drive_file</i> — material icon named "insert drive file". + static const IconData insert_drive_file = IconData(0xe342, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_drive_file</i> — material icon named "insert drive file" (sharp). + static const IconData insert_drive_file_sharp = IconData(0xea3c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_drive_file</i> — material icon named "insert drive file" (round). + static const IconData insert_drive_file_rounded = IconData(0xf81b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_drive_file</i> — material icon named "insert drive file" (outlined). + static const IconData insert_drive_file_outlined = IconData(0xf12d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_emoticon</i> — material icon named "insert emoticon". + static const IconData insert_emoticon = IconData(0xe343, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_emoticon</i> — material icon named "insert emoticon" (sharp). + static const IconData insert_emoticon_sharp = IconData(0xea3d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_emoticon</i> — material icon named "insert emoticon" (round). + static const IconData insert_emoticon_rounded = IconData(0xf81c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_emoticon</i> — material icon named "insert emoticon" (outlined). + static const IconData insert_emoticon_outlined = IconData(0xf12e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_invitation</i> — material icon named "insert invitation". + static const IconData insert_invitation = IconData(0xe344, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_invitation</i> — material icon named "insert invitation" (sharp). + static const IconData insert_invitation_sharp = IconData(0xea3e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_invitation</i> — material icon named "insert invitation" (round). + static const IconData insert_invitation_rounded = IconData(0xf81d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_invitation</i> — material icon named "insert invitation" (outlined). + static const IconData insert_invitation_outlined = IconData(0xf12f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_link</i> — material icon named "insert link". + static const IconData insert_link = IconData(0xe345, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_link</i> — material icon named "insert link" (sharp). + static const IconData insert_link_sharp = IconData(0xea3f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_link</i> — material icon named "insert link" (round). + static const IconData insert_link_rounded = IconData(0xf81e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_link</i> — material icon named "insert link" (outlined). + static const IconData insert_link_outlined = IconData(0xf130, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_page_break</i> — material icon named "insert page break". + static const IconData insert_page_break = IconData(0xf0520, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_page_break</i> — material icon named "insert page break" (sharp). + static const IconData insert_page_break_sharp = IconData(0xf042a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_page_break</i> — material icon named "insert page break" (round). + static const IconData insert_page_break_rounded = IconData(0xf0337, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_page_break</i> — material icon named "insert page break" (outlined). + static const IconData insert_page_break_outlined = IconData(0xf0618, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insert_photo</i> — material icon named "insert photo". + static const IconData insert_photo = IconData(0xe346, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insert_photo</i> — material icon named "insert photo" (sharp). + static const IconData insert_photo_sharp = IconData(0xea40, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insert_photo</i> — material icon named "insert photo" (round). + static const IconData insert_photo_rounded = IconData(0xf81f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insert_photo</i> — material icon named "insert photo" (outlined). + static const IconData insert_photo_outlined = IconData(0xf131, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">insights</i> — material icon named "insights". + static const IconData insights = IconData(0xe347, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">insights</i> — material icon named "insights" (sharp). + static const IconData insights_sharp = IconData(0xea41, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">insights</i> — material icon named "insights" (round). + static const IconData insights_rounded = IconData(0xf820, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">insights</i> — material icon named "insights" (outlined). + static const IconData insights_outlined = IconData(0xf132, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">install_desktop</i> — material icon named "install desktop". + static const IconData install_desktop = IconData(0xf0521, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">install_desktop</i> — material icon named "install desktop" (sharp). + static const IconData install_desktop_sharp = IconData(0xf042b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">install_desktop</i> — material icon named "install desktop" (round). + static const IconData install_desktop_rounded = IconData(0xf0338, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">install_desktop</i> — material icon named "install desktop" (outlined). + static const IconData install_desktop_outlined = IconData(0xf0619, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">install_mobile</i> — material icon named "install mobile". + static const IconData install_mobile = IconData(0xf0522, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">install_mobile</i> — material icon named "install mobile" (sharp). + static const IconData install_mobile_sharp = IconData(0xf042c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">install_mobile</i> — material icon named "install mobile" (round). + static const IconData install_mobile_rounded = IconData(0xf0339, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">install_mobile</i> — material icon named "install mobile" (outlined). + static const IconData install_mobile_outlined = IconData(0xf061a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">integration_instructions</i> — material icon named "integration instructions". + static const IconData integration_instructions = IconData(0xe348, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">integration_instructions</i> — material icon named "integration instructions" (sharp). + static const IconData integration_instructions_sharp = IconData( + 0xea42, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">integration_instructions</i> — material icon named "integration instructions" (round). + static const IconData integration_instructions_rounded = IconData( + 0xf821, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">integration_instructions</i> — material icon named "integration instructions" (outlined). + static const IconData integration_instructions_outlined = IconData( + 0xf133, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">interests</i> — material icon named "interests". + static const IconData interests = IconData(0xf0523, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">interests</i> — material icon named "interests" (sharp). + static const IconData interests_sharp = IconData(0xf042d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">interests</i> — material icon named "interests" (round). + static const IconData interests_rounded = IconData(0xf033a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">interests</i> — material icon named "interests" (outlined). + static const IconData interests_outlined = IconData(0xf061b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">interpreter_mode</i> — material icon named "interpreter mode". + static const IconData interpreter_mode = IconData(0xf0524, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">interpreter_mode</i> — material icon named "interpreter mode" (sharp). + static const IconData interpreter_mode_sharp = IconData(0xf042e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">interpreter_mode</i> — material icon named "interpreter mode" (round). + static const IconData interpreter_mode_rounded = IconData(0xf033b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">interpreter_mode</i> — material icon named "interpreter mode" (outlined). + static const IconData interpreter_mode_outlined = IconData(0xf061c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">inventory</i> — material icon named "inventory". + static const IconData inventory = IconData(0xe349, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">inventory</i> — material icon named "inventory" (sharp). + static const IconData inventory_sharp = IconData(0xea44, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">inventory</i> — material icon named "inventory" (round). + static const IconData inventory_rounded = IconData(0xf823, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">inventory</i> — material icon named "inventory" (outlined). + static const IconData inventory_outlined = IconData(0xf135, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">inventory_2</i> — material icon named "inventory 2". + static const IconData inventory_2 = IconData(0xe34a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">inventory_2</i> — material icon named "inventory 2" (sharp). + static const IconData inventory_2_sharp = IconData(0xea43, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">inventory_2</i> — material icon named "inventory 2" (round). + static const IconData inventory_2_rounded = IconData(0xf822, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">inventory_2</i> — material icon named "inventory 2" (outlined). + static const IconData inventory_2_outlined = IconData(0xf134, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">invert_colors</i> — material icon named "invert colors". + static const IconData invert_colors = IconData(0xe34b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">invert_colors</i> — material icon named "invert colors" (sharp). + static const IconData invert_colors_sharp = IconData(0xea46, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">invert_colors</i> — material icon named "invert colors" (round). + static const IconData invert_colors_rounded = IconData(0xf825, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">invert_colors</i> — material icon named "invert colors" (outlined). + static const IconData invert_colors_outlined = IconData(0xf137, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">invert_colors_off</i> — material icon named "invert colors off". + static const IconData invert_colors_off = IconData(0xe34c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">invert_colors_off</i> — material icon named "invert colors off" (sharp). + static const IconData invert_colors_off_sharp = IconData(0xea45, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">invert_colors_off</i> — material icon named "invert colors off" (round). + static const IconData invert_colors_off_rounded = IconData(0xf824, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">invert_colors_off</i> — material icon named "invert colors off" (outlined). + static const IconData invert_colors_off_outlined = IconData(0xf136, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">invert_colors_on</i> — material icon named "invert colors on". + static const IconData invert_colors_on = IconData(0xe34b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">invert_colors_on</i> — material icon named "invert colors on" (sharp). + static const IconData invert_colors_on_sharp = IconData(0xea46, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">invert_colors_on</i> — material icon named "invert colors on" (round). + static const IconData invert_colors_on_rounded = IconData(0xf825, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">invert_colors_on</i> — material icon named "invert colors on" (outlined). + static const IconData invert_colors_on_outlined = IconData(0xf137, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ios_share</i> — material icon named "ios share". + static const IconData ios_share = IconData(0xe34d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ios_share</i> — material icon named "ios share" (sharp). + static const IconData ios_share_sharp = IconData(0xea47, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ios_share</i> — material icon named "ios share" (round). + static const IconData ios_share_rounded = IconData(0xf826, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ios_share</i> — material icon named "ios share" (outlined). + static const IconData ios_share_outlined = IconData(0xf138, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">iron</i> — material icon named "iron". + static const IconData iron = IconData(0xe34e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">iron</i> — material icon named "iron" (sharp). + static const IconData iron_sharp = IconData(0xea48, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">iron</i> — material icon named "iron" (round). + static const IconData iron_rounded = IconData(0xf827, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">iron</i> — material icon named "iron" (outlined). + static const IconData iron_outlined = IconData(0xf139, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">iso</i> — material icon named "iso". + static const IconData iso = IconData(0xe34f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">iso</i> — material icon named "iso" (sharp). + static const IconData iso_sharp = IconData(0xea49, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">iso</i> — material icon named "iso" (round). + static const IconData iso_rounded = IconData(0xf828, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">iso</i> — material icon named "iso" (outlined). + static const IconData iso_outlined = IconData(0xf13a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">javascript</i> — material icon named "javascript". + static const IconData javascript = IconData(0xf0525, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">javascript</i> — material icon named "javascript" (sharp). + static const IconData javascript_sharp = IconData(0xf042f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">javascript</i> — material icon named "javascript" (round). + static const IconData javascript_rounded = IconData(0xf033c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">javascript</i> — material icon named "javascript" (outlined). + static const IconData javascript_outlined = IconData(0xf061d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">join_full</i> — material icon named "join full". + static const IconData join_full = IconData(0xf0526, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">join_full</i> — material icon named "join full" (sharp). + static const IconData join_full_sharp = IconData(0xf0430, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">join_full</i> — material icon named "join full" (round). + static const IconData join_full_rounded = IconData(0xf033d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">join_full</i> — material icon named "join full" (outlined). + static const IconData join_full_outlined = IconData(0xf061e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">join_inner</i> — material icon named "join inner". + static const IconData join_inner = IconData(0xf0527, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">join_inner</i> — material icon named "join inner" (sharp). + static const IconData join_inner_sharp = IconData(0xf0431, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">join_inner</i> — material icon named "join inner" (round). + static const IconData join_inner_rounded = IconData(0xf033e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">join_inner</i> — material icon named "join inner" (outlined). + static const IconData join_inner_outlined = IconData(0xf061f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">join_left</i> — material icon named "join left". + static const IconData join_left = IconData(0xf0528, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">join_left</i> — material icon named "join left" (sharp). + static const IconData join_left_sharp = IconData(0xf0432, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">join_left</i> — material icon named "join left" (round). + static const IconData join_left_rounded = IconData(0xf033f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">join_left</i> — material icon named "join left" (outlined). + static const IconData join_left_outlined = IconData(0xf0620, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">join_right</i> — material icon named "join right". + static const IconData join_right = IconData(0xf0529, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">join_right</i> — material icon named "join right" (sharp). + static const IconData join_right_sharp = IconData(0xf0433, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">join_right</i> — material icon named "join right" (round). + static const IconData join_right_rounded = IconData(0xf0340, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">join_right</i> — material icon named "join right" (outlined). + static const IconData join_right_outlined = IconData(0xf0621, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">kayaking</i> — material icon named "kayaking". + static const IconData kayaking = IconData(0xe350, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">kayaking</i> — material icon named "kayaking" (sharp). + static const IconData kayaking_sharp = IconData(0xea4a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">kayaking</i> — material icon named "kayaking" (round). + static const IconData kayaking_rounded = IconData(0xf829, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">kayaking</i> — material icon named "kayaking" (outlined). + static const IconData kayaking_outlined = IconData(0xf13b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">kebab_dining</i> — material icon named "kebab dining". + static const IconData kebab_dining = IconData(0xf052a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">kebab_dining</i> — material icon named "kebab dining" (sharp). + static const IconData kebab_dining_sharp = IconData(0xf0434, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">kebab_dining</i> — material icon named "kebab dining" (round). + static const IconData kebab_dining_rounded = IconData(0xf0341, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">kebab_dining</i> — material icon named "kebab dining" (outlined). + static const IconData kebab_dining_outlined = IconData(0xf0622, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">key</i> — material icon named "key". + static const IconData key = IconData(0xf052b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">key</i> — material icon named "key" (sharp). + static const IconData key_sharp = IconData(0xf0436, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">key</i> — material icon named "key" (round). + static const IconData key_rounded = IconData(0xf0343, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">key</i> — material icon named "key" (outlined). + static const IconData key_outlined = IconData(0xf0624, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">key_off</i> — material icon named "key off". + static const IconData key_off = IconData(0xf052c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">key_off</i> — material icon named "key off" (sharp). + static const IconData key_off_sharp = IconData(0xf0435, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">key_off</i> — material icon named "key off" (round). + static const IconData key_off_rounded = IconData(0xf0342, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">key_off</i> — material icon named "key off" (outlined). + static const IconData key_off_outlined = IconData(0xf0623, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard</i> — material icon named "keyboard". + static const IconData keyboard = IconData(0xe351, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard</i> — material icon named "keyboard" (sharp). + static const IconData keyboard_sharp = IconData(0xea54, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard</i> — material icon named "keyboard" (round). + static const IconData keyboard_rounded = IconData(0xf833, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard</i> — material icon named "keyboard" (outlined). + static const IconData keyboard_outlined = IconData(0xf144, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_alt</i> — material icon named "keyboard alt". + static const IconData keyboard_alt = IconData(0xe352, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_alt</i> — material icon named "keyboard alt" (sharp). + static const IconData keyboard_alt_sharp = IconData(0xea4b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_alt</i> — material icon named "keyboard alt" (round). + static const IconData keyboard_alt_rounded = IconData(0xf82a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_alt</i> — material icon named "keyboard alt" (outlined). + static const IconData keyboard_alt_outlined = IconData(0xf13c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_arrow_down</i> — material icon named "keyboard arrow down". + static const IconData keyboard_arrow_down = IconData(0xe353, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_arrow_down</i> — material icon named "keyboard arrow down" (sharp). + static const IconData keyboard_arrow_down_sharp = IconData(0xea4c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_arrow_down</i> — material icon named "keyboard arrow down" (round). + static const IconData keyboard_arrow_down_rounded = IconData(0xf82b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_arrow_down</i> — material icon named "keyboard arrow down" (outlined). + static const IconData keyboard_arrow_down_outlined = IconData( + 0xf13d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_arrow_left</i> — material icon named "keyboard arrow left". + static const IconData keyboard_arrow_left = IconData(0xe354, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_arrow_left</i> — material icon named "keyboard arrow left" (sharp). + static const IconData keyboard_arrow_left_sharp = IconData(0xea4d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_arrow_left</i> — material icon named "keyboard arrow left" (round). + static const IconData keyboard_arrow_left_rounded = IconData(0xf82c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_arrow_left</i> — material icon named "keyboard arrow left" (outlined). + static const IconData keyboard_arrow_left_outlined = IconData( + 0xf13e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_arrow_right</i> — material icon named "keyboard arrow right". + static const IconData keyboard_arrow_right = IconData(0xe355, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_arrow_right</i> — material icon named "keyboard arrow right" (sharp). + static const IconData keyboard_arrow_right_sharp = IconData(0xea4e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_arrow_right</i> — material icon named "keyboard arrow right" (round). + static const IconData keyboard_arrow_right_rounded = IconData( + 0xf82d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_arrow_right</i> — material icon named "keyboard arrow right" (outlined). + static const IconData keyboard_arrow_right_outlined = IconData( + 0xf13f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_arrow_up</i> — material icon named "keyboard arrow up". + static const IconData keyboard_arrow_up = IconData(0xe356, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_arrow_up</i> — material icon named "keyboard arrow up" (sharp). + static const IconData keyboard_arrow_up_sharp = IconData(0xea4f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_arrow_up</i> — material icon named "keyboard arrow up" (round). + static const IconData keyboard_arrow_up_rounded = IconData(0xf82e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_arrow_up</i> — material icon named "keyboard arrow up" (outlined). + static const IconData keyboard_arrow_up_outlined = IconData(0xf140, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_backspace</i> — material icon named "keyboard backspace". + static const IconData keyboard_backspace = IconData( + 0xe357, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">keyboard_backspace</i> — material icon named "keyboard backspace" (sharp). + static const IconData keyboard_backspace_sharp = IconData( + 0xea50, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">keyboard_backspace</i> — material icon named "keyboard backspace" (round). + static const IconData keyboard_backspace_rounded = IconData( + 0xf82f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">keyboard_backspace</i> — material icon named "keyboard backspace" (outlined). + static const IconData keyboard_backspace_outlined = IconData( + 0xf141, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">keyboard_capslock</i> — material icon named "keyboard capslock". + static const IconData keyboard_capslock = IconData(0xe358, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_capslock</i> — material icon named "keyboard capslock" (sharp). + static const IconData keyboard_capslock_sharp = IconData(0xea51, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_capslock</i> — material icon named "keyboard capslock" (round). + static const IconData keyboard_capslock_rounded = IconData(0xf830, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_capslock</i> — material icon named "keyboard capslock" (outlined). + static const IconData keyboard_capslock_outlined = IconData(0xf142, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_command_key</i> — material icon named "keyboard command key". + static const IconData keyboard_command_key = IconData(0xf052d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_command_key</i> — material icon named "keyboard command key" (sharp). + static const IconData keyboard_command_key_sharp = IconData(0xf0437, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_command_key</i> — material icon named "keyboard command key" (round). + static const IconData keyboard_command_key_rounded = IconData( + 0xf0344, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_command_key</i> — material icon named "keyboard command key" (outlined). + static const IconData keyboard_command_key_outlined = IconData( + 0xf0625, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_control</i> — material icon named "keyboard control". + static const IconData keyboard_control = IconData(0xe402, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_control</i> — material icon named "keyboard control" (sharp). + static const IconData keyboard_control_sharp = IconData(0xeafa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_control</i> — material icon named "keyboard control" (round). + static const IconData keyboard_control_rounded = IconData(0xf8d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_control</i> — material icon named "keyboard control" (outlined). + static const IconData keyboard_control_outlined = IconData(0xf1e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_control_key</i> — material icon named "keyboard control key". + static const IconData keyboard_control_key = IconData(0xf052e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_control_key</i> — material icon named "keyboard control key" (sharp). + static const IconData keyboard_control_key_sharp = IconData(0xf0438, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_control_key</i> — material icon named "keyboard control key" (round). + static const IconData keyboard_control_key_rounded = IconData( + 0xf0345, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_control_key</i> — material icon named "keyboard control key" (outlined). + static const IconData keyboard_control_key_outlined = IconData( + 0xf0626, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_double_arrow_down</i> — material icon named "keyboard double arrow down". + static const IconData keyboard_double_arrow_down = IconData(0xf052f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_double_arrow_down</i> — material icon named "keyboard double arrow down" (sharp). + static const IconData keyboard_double_arrow_down_sharp = IconData( + 0xf0439, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">keyboard_double_arrow_down</i> — material icon named "keyboard double arrow down" (round). + static const IconData keyboard_double_arrow_down_rounded = IconData( + 0xf0346, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_double_arrow_down</i> — material icon named "keyboard double arrow down" (outlined). + static const IconData keyboard_double_arrow_down_outlined = IconData( + 0xf0627, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_double_arrow_left</i> — material icon named "keyboard double arrow left". + static const IconData keyboard_double_arrow_left = IconData(0xf0530, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_double_arrow_left</i> — material icon named "keyboard double arrow left" (sharp). + static const IconData keyboard_double_arrow_left_sharp = IconData( + 0xf043a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">keyboard_double_arrow_left</i> — material icon named "keyboard double arrow left" (round). + static const IconData keyboard_double_arrow_left_rounded = IconData( + 0xf0347, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_double_arrow_left</i> — material icon named "keyboard double arrow left" (outlined). + static const IconData keyboard_double_arrow_left_outlined = IconData( + 0xf0628, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_double_arrow_right</i> — material icon named "keyboard double arrow right". + static const IconData keyboard_double_arrow_right = IconData( + 0xf0531, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">keyboard_double_arrow_right</i> — material icon named "keyboard double arrow right" (sharp). + static const IconData keyboard_double_arrow_right_sharp = IconData( + 0xf043b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">keyboard_double_arrow_right</i> — material icon named "keyboard double arrow right" (round). + static const IconData keyboard_double_arrow_right_rounded = IconData( + 0xf0348, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_double_arrow_right</i> — material icon named "keyboard double arrow right" (outlined). + static const IconData keyboard_double_arrow_right_outlined = IconData( + 0xf0629, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_double_arrow_up</i> — material icon named "keyboard double arrow up". + static const IconData keyboard_double_arrow_up = IconData(0xf0532, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_double_arrow_up</i> — material icon named "keyboard double arrow up" (sharp). + static const IconData keyboard_double_arrow_up_sharp = IconData( + 0xf043c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">keyboard_double_arrow_up</i> — material icon named "keyboard double arrow up" (round). + static const IconData keyboard_double_arrow_up_rounded = IconData( + 0xf0349, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_double_arrow_up</i> — material icon named "keyboard double arrow up" (outlined). + static const IconData keyboard_double_arrow_up_outlined = IconData( + 0xf062a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_hide</i> — material icon named "keyboard hide". + static const IconData keyboard_hide = IconData(0xe359, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_hide</i> — material icon named "keyboard hide" (sharp). + static const IconData keyboard_hide_sharp = IconData(0xea52, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_hide</i> — material icon named "keyboard hide" (round). + static const IconData keyboard_hide_rounded = IconData(0xf831, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_hide</i> — material icon named "keyboard hide" (outlined). + static const IconData keyboard_hide_outlined = IconData(0xf143, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_option_key</i> — material icon named "keyboard option key". + static const IconData keyboard_option_key = IconData(0xf0533, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_option_key</i> — material icon named "keyboard option key" (sharp). + static const IconData keyboard_option_key_sharp = IconData(0xf043d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_option_key</i> — material icon named "keyboard option key" (round). + static const IconData keyboard_option_key_rounded = IconData( + 0xf034a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">keyboard_option_key</i> — material icon named "keyboard option key" (outlined). + static const IconData keyboard_option_key_outlined = IconData( + 0xf062b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">keyboard_return</i> — material icon named "keyboard return". + static const IconData keyboard_return = IconData(0xe35a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_return</i> — material icon named "keyboard return" (sharp). + static const IconData keyboard_return_sharp = IconData(0xea53, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_return</i> — material icon named "keyboard return" (round). + static const IconData keyboard_return_rounded = IconData(0xf832, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_return</i> — material icon named "keyboard return" (outlined). + static const IconData keyboard_return_outlined = IconData(0xf145, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">keyboard_tab</i> — material icon named "keyboard tab". + static const IconData keyboard_tab = IconData( + 0xe35b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">keyboard_tab</i> — material icon named "keyboard tab" (sharp). + static const IconData keyboard_tab_sharp = IconData( + 0xea55, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">keyboard_tab</i> — material icon named "keyboard tab" (round). + static const IconData keyboard_tab_rounded = IconData( + 0xf834, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">keyboard_tab</i> — material icon named "keyboard tab" (outlined). + static const IconData keyboard_tab_outlined = IconData( + 0xf146, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">keyboard_voice</i> — material icon named "keyboard voice". + static const IconData keyboard_voice = IconData(0xe35c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">keyboard_voice</i> — material icon named "keyboard voice" (sharp). + static const IconData keyboard_voice_sharp = IconData(0xea56, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">keyboard_voice</i> — material icon named "keyboard voice" (round). + static const IconData keyboard_voice_rounded = IconData(0xf835, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">keyboard_voice</i> — material icon named "keyboard voice" (outlined). + static const IconData keyboard_voice_outlined = IconData(0xf147, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">king_bed</i> — material icon named "king bed". + static const IconData king_bed = IconData(0xe35d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">king_bed</i> — material icon named "king bed" (sharp). + static const IconData king_bed_sharp = IconData(0xea57, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">king_bed</i> — material icon named "king bed" (round). + static const IconData king_bed_rounded = IconData(0xf836, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">king_bed</i> — material icon named "king bed" (outlined). + static const IconData king_bed_outlined = IconData(0xf148, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">kitchen</i> — material icon named "kitchen". + static const IconData kitchen = IconData(0xe35e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">kitchen</i> — material icon named "kitchen" (sharp). + static const IconData kitchen_sharp = IconData(0xea58, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">kitchen</i> — material icon named "kitchen" (round). + static const IconData kitchen_rounded = IconData(0xf837, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">kitchen</i> — material icon named "kitchen" (outlined). + static const IconData kitchen_outlined = IconData(0xf149, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">kitesurfing</i> — material icon named "kitesurfing". + static const IconData kitesurfing = IconData(0xe35f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">kitesurfing</i> — material icon named "kitesurfing" (sharp). + static const IconData kitesurfing_sharp = IconData(0xea59, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">kitesurfing</i> — material icon named "kitesurfing" (round). + static const IconData kitesurfing_rounded = IconData(0xf838, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">kitesurfing</i> — material icon named "kitesurfing" (outlined). + static const IconData kitesurfing_outlined = IconData(0xf14a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">label</i> — material icon named "label". + static const IconData label = IconData( + 0xe360, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">label</i> — material icon named "label" (sharp). + static const IconData label_sharp = IconData( + 0xea5e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">label</i> — material icon named "label" (round). + static const IconData label_rounded = IconData( + 0xf83d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">label</i> — material icon named "label" (outlined). + static const IconData label_outlined = IconData( + 0xf14d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">label_important</i> — material icon named "label important". + static const IconData label_important = IconData( + 0xe361, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">label_important</i> — material icon named "label important" (sharp). + static const IconData label_important_sharp = IconData( + 0xea5b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">label_important</i> — material icon named "label important" (round). + static const IconData label_important_rounded = IconData( + 0xf83a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">label_important</i> — material icon named "label important" (outlined). + static const IconData label_important_outlined = IconData( + 0xf14b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">label_important_outline</i> — material icon named "label important outline". + static const IconData label_important_outline = IconData(0xe362, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">label_important_outline</i> — material icon named "label important outline" (sharp). + static const IconData label_important_outline_sharp = IconData( + 0xea5a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">label_important_outline</i> — material icon named "label important outline" (round). + static const IconData label_important_outline_rounded = IconData( + 0xf839, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">label_off</i> — material icon named "label off". + static const IconData label_off = IconData( + 0xe363, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">label_off</i> — material icon named "label off" (sharp). + static const IconData label_off_sharp = IconData( + 0xea5c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">label_off</i> — material icon named "label off" (round). + static const IconData label_off_rounded = IconData( + 0xf83b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">label_off</i> — material icon named "label off" (outlined). + static const IconData label_off_outlined = IconData( + 0xf14c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">label_outline</i> — material icon named "label outline". + static const IconData label_outline = IconData( + 0xe364, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">label_outline</i> — material icon named "label outline" (sharp). + static const IconData label_outline_sharp = IconData( + 0xea5d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">label_outline</i> — material icon named "label outline" (round). + static const IconData label_outline_rounded = IconData( + 0xf83c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">lan</i> — material icon named "lan". + static const IconData lan = IconData(0xf0534, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lan</i> — material icon named "lan" (sharp). + static const IconData lan_sharp = IconData(0xf043e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lan</i> — material icon named "lan" (round). + static const IconData lan_rounded = IconData(0xf034b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lan</i> — material icon named "lan" (outlined). + static const IconData lan_outlined = IconData(0xf062c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">landscape</i> — material icon named "landscape". + static const IconData landscape = IconData(0xe365, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">landscape</i> — material icon named "landscape" (sharp). + static const IconData landscape_sharp = IconData(0xea5f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">landscape</i> — material icon named "landscape" (round). + static const IconData landscape_rounded = IconData(0xf83e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">landscape</i> — material icon named "landscape" (outlined). + static const IconData landscape_outlined = IconData(0xf14e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">landslide</i> — material icon named "landslide". + static const IconData landslide = IconData(0xf07a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">landslide</i> — material icon named "landslide" (sharp). + static const IconData landslide_sharp = IconData(0xf074e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">landslide</i> — material icon named "landslide" (round). + static const IconData landslide_rounded = IconData(0xf07fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">landslide</i> — material icon named "landslide" (outlined). + static const IconData landslide_outlined = IconData(0xf06f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">language</i> — material icon named "language". + static const IconData language = IconData(0xe366, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">language</i> — material icon named "language" (sharp). + static const IconData language_sharp = IconData(0xea60, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">language</i> — material icon named "language" (round). + static const IconData language_rounded = IconData(0xf83f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">language</i> — material icon named "language" (outlined). + static const IconData language_outlined = IconData(0xf14f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">laptop</i> — material icon named "laptop". + static const IconData laptop = IconData(0xe367, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">laptop</i> — material icon named "laptop" (sharp). + static const IconData laptop_sharp = IconData(0xea63, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">laptop</i> — material icon named "laptop" (round). + static const IconData laptop_rounded = IconData(0xf842, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">laptop</i> — material icon named "laptop" (outlined). + static const IconData laptop_outlined = IconData(0xf152, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">laptop_chromebook</i> — material icon named "laptop chromebook". + static const IconData laptop_chromebook = IconData(0xe368, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">laptop_chromebook</i> — material icon named "laptop chromebook" (sharp). + static const IconData laptop_chromebook_sharp = IconData(0xea61, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">laptop_chromebook</i> — material icon named "laptop chromebook" (round). + static const IconData laptop_chromebook_rounded = IconData(0xf840, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">laptop_chromebook</i> — material icon named "laptop chromebook" (outlined). + static const IconData laptop_chromebook_outlined = IconData(0xf150, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">laptop_mac</i> — material icon named "laptop mac". + static const IconData laptop_mac = IconData(0xe369, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">laptop_mac</i> — material icon named "laptop mac" (sharp). + static const IconData laptop_mac_sharp = IconData(0xea62, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">laptop_mac</i> — material icon named "laptop mac" (round). + static const IconData laptop_mac_rounded = IconData(0xf841, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">laptop_mac</i> — material icon named "laptop mac" (outlined). + static const IconData laptop_mac_outlined = IconData(0xf151, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">laptop_windows</i> — material icon named "laptop windows". + static const IconData laptop_windows = IconData(0xe36a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">laptop_windows</i> — material icon named "laptop windows" (sharp). + static const IconData laptop_windows_sharp = IconData(0xea64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">laptop_windows</i> — material icon named "laptop windows" (round). + static const IconData laptop_windows_rounded = IconData(0xf843, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">laptop_windows</i> — material icon named "laptop windows" (outlined). + static const IconData laptop_windows_outlined = IconData(0xf153, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">last_page</i> — material icon named "last page". + static const IconData last_page = IconData( + 0xe36b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">last_page</i> — material icon named "last page" (sharp). + static const IconData last_page_sharp = IconData( + 0xea65, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">last_page</i> — material icon named "last page" (round). + static const IconData last_page_rounded = IconData( + 0xf844, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">last_page</i> — material icon named "last page" (outlined). + static const IconData last_page_outlined = IconData( + 0xf154, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">launch</i> — material icon named "launch". + static const IconData launch = IconData( + 0xe36c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">launch</i> — material icon named "launch" (sharp). + static const IconData launch_sharp = IconData( + 0xea66, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">launch</i> — material icon named "launch" (round). + static const IconData launch_rounded = IconData( + 0xf845, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">launch</i> — material icon named "launch" (outlined). + static const IconData launch_outlined = IconData( + 0xf155, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">layers</i> — material icon named "layers". + static const IconData layers = IconData(0xe36d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">layers</i> — material icon named "layers" (sharp). + static const IconData layers_sharp = IconData(0xea68, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">layers</i> — material icon named "layers" (round). + static const IconData layers_rounded = IconData(0xf847, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">layers</i> — material icon named "layers" (outlined). + static const IconData layers_outlined = IconData(0xf157, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">layers_clear</i> — material icon named "layers clear". + static const IconData layers_clear = IconData(0xe36e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">layers_clear</i> — material icon named "layers clear" (sharp). + static const IconData layers_clear_sharp = IconData(0xea67, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">layers_clear</i> — material icon named "layers clear" (round). + static const IconData layers_clear_rounded = IconData(0xf846, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">layers_clear</i> — material icon named "layers clear" (outlined). + static const IconData layers_clear_outlined = IconData(0xf156, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">leaderboard</i> — material icon named "leaderboard". + static const IconData leaderboard = IconData(0xe36f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">leaderboard</i> — material icon named "leaderboard" (sharp). + static const IconData leaderboard_sharp = IconData(0xea69, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">leaderboard</i> — material icon named "leaderboard" (round). + static const IconData leaderboard_rounded = IconData(0xf848, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">leaderboard</i> — material icon named "leaderboard" (outlined). + static const IconData leaderboard_outlined = IconData(0xf158, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">leak_add</i> — material icon named "leak add". + static const IconData leak_add = IconData(0xe370, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">leak_add</i> — material icon named "leak add" (sharp). + static const IconData leak_add_sharp = IconData(0xea6a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">leak_add</i> — material icon named "leak add" (round). + static const IconData leak_add_rounded = IconData(0xf849, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">leak_add</i> — material icon named "leak add" (outlined). + static const IconData leak_add_outlined = IconData(0xf159, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">leak_remove</i> — material icon named "leak remove". + static const IconData leak_remove = IconData(0xe371, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">leak_remove</i> — material icon named "leak remove" (sharp). + static const IconData leak_remove_sharp = IconData(0xea6b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">leak_remove</i> — material icon named "leak remove" (round). + static const IconData leak_remove_rounded = IconData(0xf84a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">leak_remove</i> — material icon named "leak remove" (outlined). + static const IconData leak_remove_outlined = IconData(0xf15a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">leave_bags_at_home</i> — material icon named "leave bags at home". + static const IconData leave_bags_at_home = IconData(0xe439, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">leave_bags_at_home</i> — material icon named "leave bags at home" (sharp). + static const IconData leave_bags_at_home_sharp = IconData(0xeb32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">leave_bags_at_home</i> — material icon named "leave bags at home" (round). + static const IconData leave_bags_at_home_rounded = IconData(0xf0011, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">leave_bags_at_home</i> — material icon named "leave bags at home" (outlined). + static const IconData leave_bags_at_home_outlined = IconData(0xf21f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">legend_toggle</i> — material icon named "legend toggle". + static const IconData legend_toggle = IconData(0xe372, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">legend_toggle</i> — material icon named "legend toggle" (sharp). + static const IconData legend_toggle_sharp = IconData(0xea6c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">legend_toggle</i> — material icon named "legend toggle" (round). + static const IconData legend_toggle_rounded = IconData(0xf84b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">legend_toggle</i> — material icon named "legend toggle" (outlined). + static const IconData legend_toggle_outlined = IconData(0xf15b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lens</i> — material icon named "lens". + static const IconData lens = IconData(0xe373, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lens</i> — material icon named "lens" (sharp). + static const IconData lens_sharp = IconData(0xea6e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lens</i> — material icon named "lens" (round). + static const IconData lens_rounded = IconData(0xf84d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lens</i> — material icon named "lens" (outlined). + static const IconData lens_outlined = IconData(0xf15d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lens_blur</i> — material icon named "lens blur". + static const IconData lens_blur = IconData(0xe374, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lens_blur</i> — material icon named "lens blur" (sharp). + static const IconData lens_blur_sharp = IconData(0xea6d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lens_blur</i> — material icon named "lens blur" (round). + static const IconData lens_blur_rounded = IconData(0xf84c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lens_blur</i> — material icon named "lens blur" (outlined). + static const IconData lens_blur_outlined = IconData(0xf15c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">library_add</i> — material icon named "library add". + static const IconData library_add = IconData(0xe375, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">library_add</i> — material icon named "library add" (sharp). + static const IconData library_add_sharp = IconData(0xea70, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">library_add</i> — material icon named "library add" (round). + static const IconData library_add_rounded = IconData(0xf84f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">library_add</i> — material icon named "library add" (outlined). + static const IconData library_add_outlined = IconData(0xf15f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">library_add_check</i> — material icon named "library add check". + static const IconData library_add_check = IconData(0xe376, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">library_add_check</i> — material icon named "library add check" (sharp). + static const IconData library_add_check_sharp = IconData(0xea6f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">library_add_check</i> — material icon named "library add check" (round). + static const IconData library_add_check_rounded = IconData(0xf84e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">library_add_check</i> — material icon named "library add check" (outlined). + static const IconData library_add_check_outlined = IconData(0xf15e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">library_books</i> — material icon named "library books". + static const IconData library_books = IconData(0xe377, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">library_books</i> — material icon named "library books" (sharp). + static const IconData library_books_sharp = IconData(0xea71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">library_books</i> — material icon named "library books" (round). + static const IconData library_books_rounded = IconData(0xf850, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">library_books</i> — material icon named "library books" (outlined). + static const IconData library_books_outlined = IconData(0xf160, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">library_music</i> — material icon named "library music". + static const IconData library_music = IconData(0xe378, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">library_music</i> — material icon named "library music" (sharp). + static const IconData library_music_sharp = IconData(0xea72, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">library_music</i> — material icon named "library music" (round). + static const IconData library_music_rounded = IconData(0xf851, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">library_music</i> — material icon named "library music" (outlined). + static const IconData library_music_outlined = IconData(0xf161, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">light</i> — material icon named "light". + static const IconData light = IconData(0xe379, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">light</i> — material icon named "light" (sharp). + static const IconData light_sharp = IconData(0xea74, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">light</i> — material icon named "light" (round). + static const IconData light_rounded = IconData(0xf853, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">light</i> — material icon named "light" (outlined). + static const IconData light_outlined = IconData(0xf163, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">light_mode</i> — material icon named "light mode". + static const IconData light_mode = IconData(0xe37a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">light_mode</i> — material icon named "light mode" (sharp). + static const IconData light_mode_sharp = IconData(0xea73, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">light_mode</i> — material icon named "light mode" (round). + static const IconData light_mode_rounded = IconData(0xf852, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">light_mode</i> — material icon named "light mode" (outlined). + static const IconData light_mode_outlined = IconData(0xf162, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lightbulb</i> — material icon named "lightbulb". + static const IconData lightbulb = IconData(0xe37b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lightbulb</i> — material icon named "lightbulb" (sharp). + static const IconData lightbulb_sharp = IconData(0xea76, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lightbulb</i> — material icon named "lightbulb" (round). + static const IconData lightbulb_rounded = IconData(0xf855, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lightbulb</i> — material icon named "lightbulb" (outlined). + static const IconData lightbulb_outlined = IconData(0xf164, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lightbulb_circle</i> — material icon named "lightbulb circle". + static const IconData lightbulb_circle = IconData(0xf07a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lightbulb_circle</i> — material icon named "lightbulb circle" (sharp). + static const IconData lightbulb_circle_sharp = IconData(0xf074f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lightbulb_circle</i> — material icon named "lightbulb circle" (round). + static const IconData lightbulb_circle_rounded = IconData(0xf07ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lightbulb_circle</i> — material icon named "lightbulb circle" (outlined). + static const IconData lightbulb_circle_outlined = IconData(0xf06f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lightbulb_outline</i> — material icon named "lightbulb outline". + static const IconData lightbulb_outline = IconData(0xe37c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lightbulb_outline</i> — material icon named "lightbulb outline" (sharp). + static const IconData lightbulb_outline_sharp = IconData(0xea75, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lightbulb_outline</i> — material icon named "lightbulb outline" (round). + static const IconData lightbulb_outline_rounded = IconData(0xf854, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">line_axis</i> — material icon named "line axis". + static const IconData line_axis = IconData(0xf0535, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">line_axis</i> — material icon named "line axis" (sharp). + static const IconData line_axis_sharp = IconData(0xf043f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">line_axis</i> — material icon named "line axis" (round). + static const IconData line_axis_rounded = IconData(0xf034c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">line_axis</i> — material icon named "line axis" (outlined). + static const IconData line_axis_outlined = IconData(0xf062d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">line_style</i> — material icon named "line style". + static const IconData line_style = IconData(0xe37d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">line_style</i> — material icon named "line style" (sharp). + static const IconData line_style_sharp = IconData(0xea77, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">line_style</i> — material icon named "line style" (round). + static const IconData line_style_rounded = IconData(0xf856, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">line_style</i> — material icon named "line style" (outlined). + static const IconData line_style_outlined = IconData(0xf165, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">line_weight</i> — material icon named "line weight". + static const IconData line_weight = IconData(0xe37e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">line_weight</i> — material icon named "line weight" (sharp). + static const IconData line_weight_sharp = IconData(0xea78, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">line_weight</i> — material icon named "line weight" (round). + static const IconData line_weight_rounded = IconData(0xf857, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">line_weight</i> — material icon named "line weight" (outlined). + static const IconData line_weight_outlined = IconData(0xf166, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">linear_scale</i> — material icon named "linear scale". + static const IconData linear_scale = IconData(0xe37f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">linear_scale</i> — material icon named "linear scale" (sharp). + static const IconData linear_scale_sharp = IconData(0xea79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">linear_scale</i> — material icon named "linear scale" (round). + static const IconData linear_scale_rounded = IconData(0xf858, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">linear_scale</i> — material icon named "linear scale" (outlined). + static const IconData linear_scale_outlined = IconData(0xf167, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">link</i> — material icon named "link". + static const IconData link = IconData(0xe380, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">link</i> — material icon named "link" (sharp). + static const IconData link_sharp = IconData(0xea7b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">link</i> — material icon named "link" (round). + static const IconData link_rounded = IconData(0xf85a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">link</i> — material icon named "link" (outlined). + static const IconData link_outlined = IconData(0xf169, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">link_off</i> — material icon named "link off". + static const IconData link_off = IconData(0xe381, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">link_off</i> — material icon named "link off" (sharp). + static const IconData link_off_sharp = IconData(0xea7a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">link_off</i> — material icon named "link off" (round). + static const IconData link_off_rounded = IconData(0xf859, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">link_off</i> — material icon named "link off" (outlined). + static const IconData link_off_outlined = IconData(0xf168, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">linked_camera</i> — material icon named "linked camera". + static const IconData linked_camera = IconData(0xe382, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">linked_camera</i> — material icon named "linked camera" (sharp). + static const IconData linked_camera_sharp = IconData(0xea7c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">linked_camera</i> — material icon named "linked camera" (round). + static const IconData linked_camera_rounded = IconData(0xf85b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">linked_camera</i> — material icon named "linked camera" (outlined). + static const IconData linked_camera_outlined = IconData(0xf16a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">liquor</i> — material icon named "liquor". + static const IconData liquor = IconData(0xe383, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">liquor</i> — material icon named "liquor" (sharp). + static const IconData liquor_sharp = IconData(0xea7d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">liquor</i> — material icon named "liquor" (round). + static const IconData liquor_rounded = IconData(0xf85c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">liquor</i> — material icon named "liquor" (outlined). + static const IconData liquor_outlined = IconData(0xf16b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">list</i> — material icon named "list". + static const IconData list = IconData( + 0xe384, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">list</i> — material icon named "list" (sharp). + static const IconData list_sharp = IconData( + 0xea7f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">list</i> — material icon named "list" (round). + static const IconData list_rounded = IconData( + 0xf85e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">list</i> — material icon named "list" (outlined). + static const IconData list_outlined = IconData( + 0xf16d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">list_alt</i> — material icon named "list alt". + static const IconData list_alt = IconData( + 0xe385, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">list_alt</i> — material icon named "list alt" (sharp). + static const IconData list_alt_sharp = IconData( + 0xea7e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">list_alt</i> — material icon named "list alt" (round). + static const IconData list_alt_rounded = IconData( + 0xf85d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">list_alt</i> — material icon named "list alt" (outlined). + static const IconData list_alt_outlined = IconData( + 0xf16c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">live_help</i> — material icon named "live help". + static const IconData live_help = IconData( + 0xe386, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">live_help</i> — material icon named "live help" (sharp). + static const IconData live_help_sharp = IconData( + 0xea80, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">live_help</i> — material icon named "live help" (round). + static const IconData live_help_rounded = IconData( + 0xf85f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">live_help</i> — material icon named "live help" (outlined). + static const IconData live_help_outlined = IconData( + 0xf16e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">live_tv</i> — material icon named "live tv". + static const IconData live_tv = IconData(0xe387, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">live_tv</i> — material icon named "live tv" (sharp). + static const IconData live_tv_sharp = IconData(0xea81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">live_tv</i> — material icon named "live tv" (round). + static const IconData live_tv_rounded = IconData(0xf860, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">live_tv</i> — material icon named "live tv" (outlined). + static const IconData live_tv_outlined = IconData(0xf16f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">living</i> — material icon named "living". + static const IconData living = IconData(0xe388, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">living</i> — material icon named "living" (sharp). + static const IconData living_sharp = IconData(0xea82, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">living</i> — material icon named "living" (round). + static const IconData living_rounded = IconData(0xf861, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">living</i> — material icon named "living" (outlined). + static const IconData living_outlined = IconData(0xf170, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_activity</i> — material icon named "local activity". + static const IconData local_activity = IconData(0xe389, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_activity</i> — material icon named "local activity" (sharp). + static const IconData local_activity_sharp = IconData(0xea83, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_activity</i> — material icon named "local activity" (round). + static const IconData local_activity_rounded = IconData(0xf862, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_activity</i> — material icon named "local activity" (outlined). + static const IconData local_activity_outlined = IconData(0xf171, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_airport</i> — material icon named "local airport". + static const IconData local_airport = IconData(0xe38a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_airport</i> — material icon named "local airport" (sharp). + static const IconData local_airport_sharp = IconData(0xea84, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_airport</i> — material icon named "local airport" (round). + static const IconData local_airport_rounded = IconData(0xf863, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_airport</i> — material icon named "local airport" (outlined). + static const IconData local_airport_outlined = IconData(0xf172, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_atm</i> — material icon named "local atm". + static const IconData local_atm = IconData(0xe38b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_atm</i> — material icon named "local atm" (sharp). + static const IconData local_atm_sharp = IconData(0xea85, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_atm</i> — material icon named "local atm" (round). + static const IconData local_atm_rounded = IconData(0xf864, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_atm</i> — material icon named "local atm" (outlined). + static const IconData local_atm_outlined = IconData(0xf173, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_attraction</i> — material icon named "local attraction". + static const IconData local_attraction = IconData(0xe389, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_attraction</i> — material icon named "local attraction" (sharp). + static const IconData local_attraction_sharp = IconData(0xea83, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_attraction</i> — material icon named "local attraction" (round). + static const IconData local_attraction_rounded = IconData(0xf862, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_attraction</i> — material icon named "local attraction" (outlined). + static const IconData local_attraction_outlined = IconData(0xf171, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_bar</i> — material icon named "local bar". + static const IconData local_bar = IconData(0xe38c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_bar</i> — material icon named "local bar" (sharp). + static const IconData local_bar_sharp = IconData(0xea86, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_bar</i> — material icon named "local bar" (round). + static const IconData local_bar_rounded = IconData(0xf865, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_bar</i> — material icon named "local bar" (outlined). + static const IconData local_bar_outlined = IconData(0xf174, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_cafe</i> — material icon named "local cafe". + static const IconData local_cafe = IconData(0xe38d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_cafe</i> — material icon named "local cafe" (sharp). + static const IconData local_cafe_sharp = IconData(0xea87, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_cafe</i> — material icon named "local cafe" (round). + static const IconData local_cafe_rounded = IconData(0xf866, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_cafe</i> — material icon named "local cafe" (outlined). + static const IconData local_cafe_outlined = IconData(0xf175, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_car_wash</i> — material icon named "local car wash". + static const IconData local_car_wash = IconData(0xe38e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_car_wash</i> — material icon named "local car wash" (sharp). + static const IconData local_car_wash_sharp = IconData(0xea88, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_car_wash</i> — material icon named "local car wash" (round). + static const IconData local_car_wash_rounded = IconData(0xf867, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_car_wash</i> — material icon named "local car wash" (outlined). + static const IconData local_car_wash_outlined = IconData(0xf176, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_convenience_store</i> — material icon named "local convenience store". + static const IconData local_convenience_store = IconData(0xe38f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_convenience_store</i> — material icon named "local convenience store" (sharp). + static const IconData local_convenience_store_sharp = IconData( + 0xea89, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">local_convenience_store</i> — material icon named "local convenience store" (round). + static const IconData local_convenience_store_rounded = IconData( + 0xf868, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">local_convenience_store</i> — material icon named "local convenience store" (outlined). + static const IconData local_convenience_store_outlined = IconData( + 0xf177, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">local_dining</i> — material icon named "local dining". + static const IconData local_dining = IconData(0xe390, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_dining</i> — material icon named "local dining" (sharp). + static const IconData local_dining_sharp = IconData(0xea8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_dining</i> — material icon named "local dining" (round). + static const IconData local_dining_rounded = IconData(0xf869, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_dining</i> — material icon named "local dining" (outlined). + static const IconData local_dining_outlined = IconData(0xf178, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_drink</i> — material icon named "local drink". + static const IconData local_drink = IconData(0xe391, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_drink</i> — material icon named "local drink" (sharp). + static const IconData local_drink_sharp = IconData(0xea8b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_drink</i> — material icon named "local drink" (round). + static const IconData local_drink_rounded = IconData(0xf86a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_drink</i> — material icon named "local drink" (outlined). + static const IconData local_drink_outlined = IconData(0xf179, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_fire_department</i> — material icon named "local fire department". + static const IconData local_fire_department = IconData(0xe392, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_fire_department</i> — material icon named "local fire department" (sharp). + static const IconData local_fire_department_sharp = IconData(0xea8c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_fire_department</i> — material icon named "local fire department" (round). + static const IconData local_fire_department_rounded = IconData( + 0xf86b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">local_fire_department</i> — material icon named "local fire department" (outlined). + static const IconData local_fire_department_outlined = IconData( + 0xf17a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">local_florist</i> — material icon named "local florist". + static const IconData local_florist = IconData(0xe393, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_florist</i> — material icon named "local florist" (sharp). + static const IconData local_florist_sharp = IconData(0xea8d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_florist</i> — material icon named "local florist" (round). + static const IconData local_florist_rounded = IconData(0xf86c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_florist</i> — material icon named "local florist" (outlined). + static const IconData local_florist_outlined = IconData(0xf17b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_gas_station</i> — material icon named "local gas station". + static const IconData local_gas_station = IconData(0xe394, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_gas_station</i> — material icon named "local gas station" (sharp). + static const IconData local_gas_station_sharp = IconData(0xea8e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_gas_station</i> — material icon named "local gas station" (round). + static const IconData local_gas_station_rounded = IconData(0xf86d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_gas_station</i> — material icon named "local gas station" (outlined). + static const IconData local_gas_station_outlined = IconData(0xf17c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_grocery_store</i> — material icon named "local grocery store". + static const IconData local_grocery_store = IconData(0xe395, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_grocery_store</i> — material icon named "local grocery store" (sharp). + static const IconData local_grocery_store_sharp = IconData(0xea8f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_grocery_store</i> — material icon named "local grocery store" (round). + static const IconData local_grocery_store_rounded = IconData(0xf86e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_grocery_store</i> — material icon named "local grocery store" (outlined). + static const IconData local_grocery_store_outlined = IconData( + 0xf17d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">local_hospital</i> — material icon named "local hospital". + static const IconData local_hospital = IconData(0xe396, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_hospital</i> — material icon named "local hospital" (sharp). + static const IconData local_hospital_sharp = IconData(0xea90, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_hospital</i> — material icon named "local hospital" (round). + static const IconData local_hospital_rounded = IconData(0xf86f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_hospital</i> — material icon named "local hospital" (outlined). + static const IconData local_hospital_outlined = IconData(0xf17e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_hotel</i> — material icon named "local hotel". + static const IconData local_hotel = IconData(0xe397, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_hotel</i> — material icon named "local hotel" (sharp). + static const IconData local_hotel_sharp = IconData(0xea91, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_hotel</i> — material icon named "local hotel" (round). + static const IconData local_hotel_rounded = IconData(0xf870, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_hotel</i> — material icon named "local hotel" (outlined). + static const IconData local_hotel_outlined = IconData(0xf17f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_laundry_service</i> — material icon named "local laundry service". + static const IconData local_laundry_service = IconData(0xe398, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_laundry_service</i> — material icon named "local laundry service" (sharp). + static const IconData local_laundry_service_sharp = IconData(0xea92, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_laundry_service</i> — material icon named "local laundry service" (round). + static const IconData local_laundry_service_rounded = IconData( + 0xf871, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">local_laundry_service</i> — material icon named "local laundry service" (outlined). + static const IconData local_laundry_service_outlined = IconData( + 0xf180, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">local_library</i> — material icon named "local library". + static const IconData local_library = IconData(0xe399, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_library</i> — material icon named "local library" (sharp). + static const IconData local_library_sharp = IconData(0xea93, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_library</i> — material icon named "local library" (round). + static const IconData local_library_rounded = IconData(0xf872, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_library</i> — material icon named "local library" (outlined). + static const IconData local_library_outlined = IconData(0xf181, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_mall</i> — material icon named "local mall". + static const IconData local_mall = IconData(0xe39a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_mall</i> — material icon named "local mall" (sharp). + static const IconData local_mall_sharp = IconData(0xea94, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_mall</i> — material icon named "local mall" (round). + static const IconData local_mall_rounded = IconData(0xf873, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_mall</i> — material icon named "local mall" (outlined). + static const IconData local_mall_outlined = IconData(0xf182, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_movies</i> — material icon named "local movies". + static const IconData local_movies = IconData(0xe39b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_movies</i> — material icon named "local movies" (sharp). + static const IconData local_movies_sharp = IconData(0xea95, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_movies</i> — material icon named "local movies" (round). + static const IconData local_movies_rounded = IconData(0xf874, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_movies</i> — material icon named "local movies" (outlined). + static const IconData local_movies_outlined = IconData(0xf183, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_offer</i> — material icon named "local offer". + static const IconData local_offer = IconData(0xe39c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_offer</i> — material icon named "local offer" (sharp). + static const IconData local_offer_sharp = IconData(0xea96, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_offer</i> — material icon named "local offer" (round). + static const IconData local_offer_rounded = IconData(0xf875, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_offer</i> — material icon named "local offer" (outlined). + static const IconData local_offer_outlined = IconData(0xf184, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_parking</i> — material icon named "local parking". + static const IconData local_parking = IconData(0xe39d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_parking</i> — material icon named "local parking" (sharp). + static const IconData local_parking_sharp = IconData(0xea97, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_parking</i> — material icon named "local parking" (round). + static const IconData local_parking_rounded = IconData(0xf876, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_parking</i> — material icon named "local parking" (outlined). + static const IconData local_parking_outlined = IconData(0xf185, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_pharmacy</i> — material icon named "local pharmacy". + static const IconData local_pharmacy = IconData(0xe39e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_pharmacy</i> — material icon named "local pharmacy" (sharp). + static const IconData local_pharmacy_sharp = IconData(0xea98, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_pharmacy</i> — material icon named "local pharmacy" (round). + static const IconData local_pharmacy_rounded = IconData(0xf877, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_pharmacy</i> — material icon named "local pharmacy" (outlined). + static const IconData local_pharmacy_outlined = IconData(0xf186, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_phone</i> — material icon named "local phone". + static const IconData local_phone = IconData(0xe39f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_phone</i> — material icon named "local phone" (sharp). + static const IconData local_phone_sharp = IconData(0xea99, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_phone</i> — material icon named "local phone" (round). + static const IconData local_phone_rounded = IconData(0xf878, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_phone</i> — material icon named "local phone" (outlined). + static const IconData local_phone_outlined = IconData(0xf187, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_pizza</i> — material icon named "local pizza". + static const IconData local_pizza = IconData(0xe3a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_pizza</i> — material icon named "local pizza" (sharp). + static const IconData local_pizza_sharp = IconData(0xea9a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_pizza</i> — material icon named "local pizza" (round). + static const IconData local_pizza_rounded = IconData(0xf879, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_pizza</i> — material icon named "local pizza" (outlined). + static const IconData local_pizza_outlined = IconData(0xf188, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_play</i> — material icon named "local play". + static const IconData local_play = IconData(0xe3a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_play</i> — material icon named "local play" (sharp). + static const IconData local_play_sharp = IconData(0xea9b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_play</i> — material icon named "local play" (round). + static const IconData local_play_rounded = IconData(0xf87a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_play</i> — material icon named "local play" (outlined). + static const IconData local_play_outlined = IconData(0xf189, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_police</i> — material icon named "local police". + static const IconData local_police = IconData(0xe3a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_police</i> — material icon named "local police" (sharp). + static const IconData local_police_sharp = IconData(0xea9c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_police</i> — material icon named "local police" (round). + static const IconData local_police_rounded = IconData(0xf87b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_police</i> — material icon named "local police" (outlined). + static const IconData local_police_outlined = IconData(0xf18a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_post_office</i> — material icon named "local post office". + static const IconData local_post_office = IconData(0xe3a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_post_office</i> — material icon named "local post office" (sharp). + static const IconData local_post_office_sharp = IconData(0xea9d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_post_office</i> — material icon named "local post office" (round). + static const IconData local_post_office_rounded = IconData(0xf87c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_post_office</i> — material icon named "local post office" (outlined). + static const IconData local_post_office_outlined = IconData(0xf18b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_print_shop</i> — material icon named "local print shop". + static const IconData local_print_shop = IconData(0xe3a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_print_shop</i> — material icon named "local print shop" (sharp). + static const IconData local_print_shop_sharp = IconData(0xea9e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_print_shop</i> — material icon named "local print shop" (round). + static const IconData local_print_shop_rounded = IconData(0xf87d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_print_shop</i> — material icon named "local print shop" (outlined). + static const IconData local_print_shop_outlined = IconData(0xf18c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_printshop</i> — material icon named "local printshop". + static const IconData local_printshop = IconData(0xe3a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_printshop</i> — material icon named "local printshop" (sharp). + static const IconData local_printshop_sharp = IconData(0xea9e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_printshop</i> — material icon named "local printshop" (round). + static const IconData local_printshop_rounded = IconData(0xf87d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_printshop</i> — material icon named "local printshop" (outlined). + static const IconData local_printshop_outlined = IconData(0xf18c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_restaurant</i> — material icon named "local restaurant". + static const IconData local_restaurant = IconData(0xe390, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_restaurant</i> — material icon named "local restaurant" (sharp). + static const IconData local_restaurant_sharp = IconData(0xea8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_restaurant</i> — material icon named "local restaurant" (round). + static const IconData local_restaurant_rounded = IconData(0xf869, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_restaurant</i> — material icon named "local restaurant" (outlined). + static const IconData local_restaurant_outlined = IconData(0xf178, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_see</i> — material icon named "local see". + static const IconData local_see = IconData(0xe3a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_see</i> — material icon named "local see" (sharp). + static const IconData local_see_sharp = IconData(0xea9f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_see</i> — material icon named "local see" (round). + static const IconData local_see_rounded = IconData(0xf87e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_see</i> — material icon named "local see" (outlined). + static const IconData local_see_outlined = IconData(0xf18d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_shipping</i> — material icon named "local shipping". + static const IconData local_shipping = IconData(0xe3a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_shipping</i> — material icon named "local shipping" (sharp). + static const IconData local_shipping_sharp = IconData(0xeaa0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_shipping</i> — material icon named "local shipping" (round). + static const IconData local_shipping_rounded = IconData(0xf87f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_shipping</i> — material icon named "local shipping" (outlined). + static const IconData local_shipping_outlined = IconData(0xf18e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">local_taxi</i> — material icon named "local taxi". + static const IconData local_taxi = IconData(0xe3a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">local_taxi</i> — material icon named "local taxi" (sharp). + static const IconData local_taxi_sharp = IconData(0xeaa1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">local_taxi</i> — material icon named "local taxi" (round). + static const IconData local_taxi_rounded = IconData(0xf880, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">local_taxi</i> — material icon named "local taxi" (outlined). + static const IconData local_taxi_outlined = IconData(0xf18f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_city</i> — material icon named "location city". + static const IconData location_city = IconData(0xe3a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">location_city</i> — material icon named "location city" (sharp). + static const IconData location_city_sharp = IconData(0xeaa2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">location_city</i> — material icon named "location city" (round). + static const IconData location_city_rounded = IconData(0xf881, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">location_city</i> — material icon named "location city" (outlined). + static const IconData location_city_outlined = IconData(0xf190, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_disabled</i> — material icon named "location disabled". + static const IconData location_disabled = IconData(0xe3a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">location_disabled</i> — material icon named "location disabled" (sharp). + static const IconData location_disabled_sharp = IconData(0xeaa3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">location_disabled</i> — material icon named "location disabled" (round). + static const IconData location_disabled_rounded = IconData(0xf882, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">location_disabled</i> — material icon named "location disabled" (outlined). + static const IconData location_disabled_outlined = IconData(0xf191, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_history</i> — material icon named "location history". + static const IconData location_history = IconData(0xe498, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">location_history</i> — material icon named "location history" (sharp). + static const IconData location_history_sharp = IconData(0xeb8f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">location_history</i> — material icon named "location history" (round). + static const IconData location_history_rounded = IconData(0xf006e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">location_history</i> — material icon named "location history" (outlined). + static const IconData location_history_outlined = IconData(0xf27d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_off</i> — material icon named "location off". + static const IconData location_off = IconData(0xe3aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">location_off</i> — material icon named "location off" (sharp). + static const IconData location_off_sharp = IconData(0xeaa4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">location_off</i> — material icon named "location off" (round). + static const IconData location_off_rounded = IconData(0xf883, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">location_off</i> — material icon named "location off" (outlined). + static const IconData location_off_outlined = IconData(0xf192, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_on</i> — material icon named "location on". + static const IconData location_on = IconData(0xe3ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">location_on</i> — material icon named "location on" (sharp). + static const IconData location_on_sharp = IconData(0xeaa5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">location_on</i> — material icon named "location on" (round). + static const IconData location_on_rounded = IconData(0xf884, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">location_on</i> — material icon named "location on" (outlined). + static const IconData location_on_outlined = IconData(0xf193, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_pin</i> — material icon named "location pin". + static const IconData location_pin = IconData(0xe3ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">location_searching</i> — material icon named "location searching". + static const IconData location_searching = IconData(0xe3ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">location_searching</i> — material icon named "location searching" (sharp). + static const IconData location_searching_sharp = IconData(0xeaa6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">location_searching</i> — material icon named "location searching" (round). + static const IconData location_searching_rounded = IconData(0xf885, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">location_searching</i> — material icon named "location searching" (outlined). + static const IconData location_searching_outlined = IconData(0xf194, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lock</i> — material icon named "lock". + static const IconData lock = IconData(0xe3ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lock</i> — material icon named "lock" (sharp). + static const IconData lock_sharp = IconData(0xeaaa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lock</i> — material icon named "lock" (round). + static const IconData lock_rounded = IconData(0xf889, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lock</i> — material icon named "lock" (outlined). + static const IconData lock_outlined = IconData(0xf197, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lock_clock</i> — material icon named "lock clock". + static const IconData lock_clock = IconData(0xe3af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lock_clock</i> — material icon named "lock clock" (sharp). + static const IconData lock_clock_sharp = IconData(0xeaa7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lock_clock</i> — material icon named "lock clock" (round). + static const IconData lock_clock_rounded = IconData(0xf886, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lock_clock</i> — material icon named "lock clock" (outlined). + static const IconData lock_clock_outlined = IconData(0xf195, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lock_open</i> — material icon named "lock open". + static const IconData lock_open = IconData(0xe3b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lock_open</i> — material icon named "lock open" (sharp). + static const IconData lock_open_sharp = IconData(0xeaa8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lock_open</i> — material icon named "lock open" (round). + static const IconData lock_open_rounded = IconData(0xf887, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lock_open</i> — material icon named "lock open" (outlined). + static const IconData lock_open_outlined = IconData(0xf196, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lock_outline</i> — material icon named "lock outline". + static const IconData lock_outline = IconData(0xe3b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lock_outline</i> — material icon named "lock outline" (sharp). + static const IconData lock_outline_sharp = IconData(0xeaa9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lock_outline</i> — material icon named "lock outline" (round). + static const IconData lock_outline_rounded = IconData(0xf888, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lock_person</i> — material icon named "lock person". + static const IconData lock_person = IconData(0xf07a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lock_person</i> — material icon named "lock person" (sharp). + static const IconData lock_person_sharp = IconData(0xf0750, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lock_person</i> — material icon named "lock person" (round). + static const IconData lock_person_rounded = IconData(0xf0800, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lock_person</i> — material icon named "lock person" (outlined). + static const IconData lock_person_outlined = IconData(0xf06f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lock_reset</i> — material icon named "lock reset". + static const IconData lock_reset = IconData(0xf0536, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lock_reset</i> — material icon named "lock reset" (sharp). + static const IconData lock_reset_sharp = IconData(0xf0440, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lock_reset</i> — material icon named "lock reset" (round). + static const IconData lock_reset_rounded = IconData(0xf034d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lock_reset</i> — material icon named "lock reset" (outlined). + static const IconData lock_reset_outlined = IconData(0xf062e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">login</i> — material icon named "login". + static const IconData login = IconData(0xe3b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">login</i> — material icon named "login" (sharp). + static const IconData login_sharp = IconData(0xeaab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">login</i> — material icon named "login" (round). + static const IconData login_rounded = IconData(0xf88a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">login</i> — material icon named "login" (outlined). + static const IconData login_outlined = IconData(0xf198, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">logo_dev</i> — material icon named "logo dev". + static const IconData logo_dev = IconData(0xf0537, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">logo_dev</i> — material icon named "logo dev" (sharp). + static const IconData logo_dev_sharp = IconData(0xf0441, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">logo_dev</i> — material icon named "logo dev" (round). + static const IconData logo_dev_rounded = IconData(0xf034e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">logo_dev</i> — material icon named "logo dev" (outlined). + static const IconData logo_dev_outlined = IconData(0xf062f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">logout</i> — material icon named "logout". + static const IconData logout = IconData(0xe3b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">logout</i> — material icon named "logout" (sharp). + static const IconData logout_sharp = IconData(0xeaac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">logout</i> — material icon named "logout" (round). + static const IconData logout_rounded = IconData(0xf88b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">logout</i> — material icon named "logout" (outlined). + static const IconData logout_outlined = IconData(0xf199, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks</i> — material icon named "looks". + static const IconData looks = IconData(0xe3b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks</i> — material icon named "looks" (sharp). + static const IconData looks_sharp = IconData(0xeab2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks</i> — material icon named "looks" (round). + static const IconData looks_rounded = IconData(0xf891, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks</i> — material icon named "looks" (outlined). + static const IconData looks_outlined = IconData(0xf19f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks_3</i> — material icon named "looks 3". + static const IconData looks_3 = IconData(0xe3b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks_3</i> — material icon named "looks 3" (sharp). + static const IconData looks_3_sharp = IconData(0xeaad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks_3</i> — material icon named "looks 3" (round). + static const IconData looks_3_rounded = IconData(0xf88c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks_3</i> — material icon named "looks 3" (outlined). + static const IconData looks_3_outlined = IconData(0xf19a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks_4</i> — material icon named "looks 4". + static const IconData looks_4 = IconData(0xe3b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks_4</i> — material icon named "looks 4" (sharp). + static const IconData looks_4_sharp = IconData(0xeaae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks_4</i> — material icon named "looks 4" (round). + static const IconData looks_4_rounded = IconData(0xf88d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks_4</i> — material icon named "looks 4" (outlined). + static const IconData looks_4_outlined = IconData(0xf19b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks_5</i> — material icon named "looks 5". + static const IconData looks_5 = IconData(0xe3b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks_5</i> — material icon named "looks 5" (sharp). + static const IconData looks_5_sharp = IconData(0xeaaf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks_5</i> — material icon named "looks 5" (round). + static const IconData looks_5_rounded = IconData(0xf88e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks_5</i> — material icon named "looks 5" (outlined). + static const IconData looks_5_outlined = IconData(0xf19c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks_6</i> — material icon named "looks 6". + static const IconData looks_6 = IconData(0xe3b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks_6</i> — material icon named "looks 6" (sharp). + static const IconData looks_6_sharp = IconData(0xeab0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks_6</i> — material icon named "looks 6" (round). + static const IconData looks_6_rounded = IconData(0xf88f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks_6</i> — material icon named "looks 6" (outlined). + static const IconData looks_6_outlined = IconData(0xf19d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks_one</i> — material icon named "looks one". + static const IconData looks_one = IconData(0xe3b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks_one</i> — material icon named "looks one" (sharp). + static const IconData looks_one_sharp = IconData(0xeab1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks_one</i> — material icon named "looks one" (round). + static const IconData looks_one_rounded = IconData(0xf890, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks_one</i> — material icon named "looks one" (outlined). + static const IconData looks_one_outlined = IconData(0xf19e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">looks_two</i> — material icon named "looks two". + static const IconData looks_two = IconData(0xe3ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">looks_two</i> — material icon named "looks two" (sharp). + static const IconData looks_two_sharp = IconData(0xeab3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">looks_two</i> — material icon named "looks two" (round). + static const IconData looks_two_rounded = IconData(0xf892, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">looks_two</i> — material icon named "looks two" (outlined). + static const IconData looks_two_outlined = IconData(0xf1a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">loop</i> — material icon named "loop". + static const IconData loop = IconData(0xe3bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">loop</i> — material icon named "loop" (sharp). + static const IconData loop_sharp = IconData(0xeab4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">loop</i> — material icon named "loop" (round). + static const IconData loop_rounded = IconData(0xf893, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">loop</i> — material icon named "loop" (outlined). + static const IconData loop_outlined = IconData(0xf1a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">loupe</i> — material icon named "loupe". + static const IconData loupe = IconData(0xe3bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">loupe</i> — material icon named "loupe" (sharp). + static const IconData loupe_sharp = IconData(0xeab5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">loupe</i> — material icon named "loupe" (round). + static const IconData loupe_rounded = IconData(0xf894, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">loupe</i> — material icon named "loupe" (outlined). + static const IconData loupe_outlined = IconData(0xf1a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">low_priority</i> — material icon named "low priority". + static const IconData low_priority = IconData(0xe3bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">low_priority</i> — material icon named "low priority" (sharp). + static const IconData low_priority_sharp = IconData(0xeab6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">low_priority</i> — material icon named "low priority" (round). + static const IconData low_priority_rounded = IconData(0xf895, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">low_priority</i> — material icon named "low priority" (outlined). + static const IconData low_priority_outlined = IconData(0xf1a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">loyalty</i> — material icon named "loyalty". + static const IconData loyalty = IconData(0xe3be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">loyalty</i> — material icon named "loyalty" (sharp). + static const IconData loyalty_sharp = IconData(0xeab7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">loyalty</i> — material icon named "loyalty" (round). + static const IconData loyalty_rounded = IconData(0xf896, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">loyalty</i> — material icon named "loyalty" (outlined). + static const IconData loyalty_outlined = IconData(0xf1a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lte_mobiledata</i> — material icon named "lte mobiledata". + static const IconData lte_mobiledata = IconData(0xe3bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lte_mobiledata</i> — material icon named "lte mobiledata" (sharp). + static const IconData lte_mobiledata_sharp = IconData(0xeab8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lte_mobiledata</i> — material icon named "lte mobiledata" (round). + static const IconData lte_mobiledata_rounded = IconData(0xf897, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lte_mobiledata</i> — material icon named "lte mobiledata" (outlined). + static const IconData lte_mobiledata_outlined = IconData(0xf1a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lte_plus_mobiledata</i> — material icon named "lte plus mobiledata". + static const IconData lte_plus_mobiledata = IconData(0xe3c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lte_plus_mobiledata</i> — material icon named "lte plus mobiledata" (sharp). + static const IconData lte_plus_mobiledata_sharp = IconData(0xeab9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lte_plus_mobiledata</i> — material icon named "lte plus mobiledata" (round). + static const IconData lte_plus_mobiledata_rounded = IconData(0xf898, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lte_plus_mobiledata</i> — material icon named "lte plus mobiledata" (outlined). + static const IconData lte_plus_mobiledata_outlined = IconData( + 0xf1a6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">luggage</i> — material icon named "luggage". + static const IconData luggage = IconData(0xe3c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">luggage</i> — material icon named "luggage" (sharp). + static const IconData luggage_sharp = IconData(0xeaba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">luggage</i> — material icon named "luggage" (round). + static const IconData luggage_rounded = IconData(0xf899, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">luggage</i> — material icon named "luggage" (outlined). + static const IconData luggage_outlined = IconData(0xf1a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lunch_dining</i> — material icon named "lunch dining". + static const IconData lunch_dining = IconData(0xe3c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lunch_dining</i> — material icon named "lunch dining" (sharp). + static const IconData lunch_dining_sharp = IconData(0xeabb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lunch_dining</i> — material icon named "lunch dining" (round). + static const IconData lunch_dining_rounded = IconData(0xf89a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lunch_dining</i> — material icon named "lunch dining" (outlined). + static const IconData lunch_dining_outlined = IconData(0xf1a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">lyrics</i> — material icon named "lyrics". + static const IconData lyrics = IconData(0xf07a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">lyrics</i> — material icon named "lyrics" (sharp). + static const IconData lyrics_sharp = IconData(0xf0751, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">lyrics</i> — material icon named "lyrics" (round). + static const IconData lyrics_rounded = IconData(0xf0801, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">lyrics</i> — material icon named "lyrics" (outlined). + static const IconData lyrics_outlined = IconData(0xf06f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">macro_off</i> — material icon named "macro off". + static const IconData macro_off = IconData(0xf086b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">macro_off</i> — material icon named "macro off" (sharp). + static const IconData macro_off_sharp = IconData(0xf0843, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">macro_off</i> — material icon named "macro off" (round). + static const IconData macro_off_rounded = IconData(0xf088c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">macro_off</i> — material icon named "macro off" (outlined). + static const IconData macro_off_outlined = IconData(0xf08aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mail</i> — material icon named "mail". + static const IconData mail = IconData(0xe3c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mail</i> — material icon named "mail" (sharp). + static const IconData mail_sharp = IconData(0xeabd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mail</i> — material icon named "mail" (round). + static const IconData mail_rounded = IconData(0xf89c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mail</i> — material icon named "mail" (outlined). + static const IconData mail_outlined = IconData(0xf1aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mail_lock</i> — material icon named "mail lock". + static const IconData mail_lock = IconData(0xf07aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mail_lock</i> — material icon named "mail lock" (sharp). + static const IconData mail_lock_sharp = IconData(0xf0752, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mail_lock</i> — material icon named "mail lock" (round). + static const IconData mail_lock_rounded = IconData(0xf0802, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mail_lock</i> — material icon named "mail lock" (outlined). + static const IconData mail_lock_outlined = IconData(0xf06fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mail_outline</i> — material icon named "mail outline". + static const IconData mail_outline = IconData(0xe3c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mail_outline</i> — material icon named "mail outline" (sharp). + static const IconData mail_outline_sharp = IconData(0xeabc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mail_outline</i> — material icon named "mail outline" (round). + static const IconData mail_outline_rounded = IconData(0xf89b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mail_outline</i> — material icon named "mail outline" (outlined). + static const IconData mail_outline_outlined = IconData(0xf1a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">male</i> — material icon named "male". + static const IconData male = IconData(0xe3c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">male</i> — material icon named "male" (sharp). + static const IconData male_sharp = IconData(0xeabe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">male</i> — material icon named "male" (round). + static const IconData male_rounded = IconData(0xf89d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">male</i> — material icon named "male" (outlined). + static const IconData male_outlined = IconData(0xf1ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">man</i> — material icon named "man". + static const IconData man = IconData(0xf0538, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">man</i> — material icon named "man" (sharp). + static const IconData man_sharp = IconData(0xf0442, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">man</i> — material icon named "man" (round). + static const IconData man_rounded = IconData(0xf034f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">man</i> — material icon named "man" (outlined). + static const IconData man_outlined = IconData(0xf0630, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">man_2</i> — material icon named "man 2". + static const IconData man_2 = IconData(0xf086c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">man_2</i> — material icon named "man 2" (sharp). + static const IconData man_2_sharp = IconData(0xf0844, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">man_2</i> — material icon named "man 2" (round). + static const IconData man_2_rounded = IconData(0xf088d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">man_2</i> — material icon named "man 2" (outlined). + static const IconData man_2_outlined = IconData(0xf08ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">man_3</i> — material icon named "man 3". + static const IconData man_3 = IconData(0xf086d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">man_3</i> — material icon named "man 3" (sharp). + static const IconData man_3_sharp = IconData(0xf0845, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">man_3</i> — material icon named "man 3" (round). + static const IconData man_3_rounded = IconData(0xf088e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">man_3</i> — material icon named "man 3" (outlined). + static const IconData man_3_outlined = IconData(0xf08ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">man_4</i> — material icon named "man 4". + static const IconData man_4 = IconData(0xf086e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">man_4</i> — material icon named "man 4" (sharp). + static const IconData man_4_sharp = IconData(0xf0846, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">man_4</i> — material icon named "man 4" (round). + static const IconData man_4_rounded = IconData(0xf088f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">man_4</i> — material icon named "man 4" (outlined). + static const IconData man_4_outlined = IconData(0xf08ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">manage_accounts</i> — material icon named "manage accounts". + static const IconData manage_accounts = IconData(0xe3c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">manage_accounts</i> — material icon named "manage accounts" (sharp). + static const IconData manage_accounts_sharp = IconData(0xeabf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">manage_accounts</i> — material icon named "manage accounts" (round). + static const IconData manage_accounts_rounded = IconData(0xf89e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">manage_accounts</i> — material icon named "manage accounts" (outlined). + static const IconData manage_accounts_outlined = IconData(0xf1ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">manage_history</i> — material icon named "manage history". + static const IconData manage_history = IconData(0xf07ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">manage_history</i> — material icon named "manage history" (sharp). + static const IconData manage_history_sharp = IconData(0xf0753, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">manage_history</i> — material icon named "manage history" (round). + static const IconData manage_history_rounded = IconData(0xf0803, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">manage_history</i> — material icon named "manage history" (outlined). + static const IconData manage_history_outlined = IconData(0xf06fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">manage_search</i> — material icon named "manage search". + static const IconData manage_search = IconData(0xe3c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">manage_search</i> — material icon named "manage search" (sharp). + static const IconData manage_search_sharp = IconData(0xeac0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">manage_search</i> — material icon named "manage search" (round). + static const IconData manage_search_rounded = IconData(0xf89f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">manage_search</i> — material icon named "manage search" (outlined). + static const IconData manage_search_outlined = IconData(0xf1ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">map</i> — material icon named "map". + static const IconData map = IconData(0xe3c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">map</i> — material icon named "map" (sharp). + static const IconData map_sharp = IconData(0xeac1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">map</i> — material icon named "map" (round). + static const IconData map_rounded = IconData(0xf8a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">map</i> — material icon named "map" (outlined). + static const IconData map_outlined = IconData(0xf1ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">maps_home_work</i> — material icon named "maps home work". + static const IconData maps_home_work = IconData(0xe3c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">maps_home_work</i> — material icon named "maps home work" (sharp). + static const IconData maps_home_work_sharp = IconData(0xeac2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">maps_home_work</i> — material icon named "maps home work" (round). + static const IconData maps_home_work_rounded = IconData(0xf8a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">maps_home_work</i> — material icon named "maps home work" (outlined). + static const IconData maps_home_work_outlined = IconData(0xf1af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">maps_ugc</i> — material icon named "maps ugc". + static const IconData maps_ugc = IconData(0xe3ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">maps_ugc</i> — material icon named "maps ugc" (sharp). + static const IconData maps_ugc_sharp = IconData(0xeac3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">maps_ugc</i> — material icon named "maps ugc" (round). + static const IconData maps_ugc_rounded = IconData(0xf8a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">maps_ugc</i> — material icon named "maps ugc" (outlined). + static const IconData maps_ugc_outlined = IconData(0xf1b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">margin</i> — material icon named "margin". + static const IconData margin = IconData(0xe3cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">margin</i> — material icon named "margin" (sharp). + static const IconData margin_sharp = IconData(0xeac4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">margin</i> — material icon named "margin" (round). + static const IconData margin_rounded = IconData(0xf8a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">margin</i> — material icon named "margin" (outlined). + static const IconData margin_outlined = IconData(0xf1b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mark_as_unread</i> — material icon named "mark as unread". + static const IconData mark_as_unread = IconData(0xe3cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mark_as_unread</i> — material icon named "mark as unread" (sharp). + static const IconData mark_as_unread_sharp = IconData(0xeac5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mark_as_unread</i> — material icon named "mark as unread" (round). + static const IconData mark_as_unread_rounded = IconData(0xf8a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mark_as_unread</i> — material icon named "mark as unread" (outlined). + static const IconData mark_as_unread_outlined = IconData(0xf1b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mark_chat_read</i> — material icon named "mark chat read". + static const IconData mark_chat_read = IconData(0xe3cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mark_chat_read</i> — material icon named "mark chat read" (sharp). + static const IconData mark_chat_read_sharp = IconData(0xeac6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mark_chat_read</i> — material icon named "mark chat read" (round). + static const IconData mark_chat_read_rounded = IconData(0xf8a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mark_chat_read</i> — material icon named "mark chat read" (outlined). + static const IconData mark_chat_read_outlined = IconData(0xf1b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mark_chat_unread</i> — material icon named "mark chat unread". + static const IconData mark_chat_unread = IconData(0xe3ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mark_chat_unread</i> — material icon named "mark chat unread" (sharp). + static const IconData mark_chat_unread_sharp = IconData(0xeac7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mark_chat_unread</i> — material icon named "mark chat unread" (round). + static const IconData mark_chat_unread_rounded = IconData(0xf8a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mark_chat_unread</i> — material icon named "mark chat unread" (outlined). + static const IconData mark_chat_unread_outlined = IconData(0xf1b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mark_email_read</i> — material icon named "mark email read". + static const IconData mark_email_read = IconData(0xe3cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mark_email_read</i> — material icon named "mark email read" (sharp). + static const IconData mark_email_read_sharp = IconData(0xeac8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mark_email_read</i> — material icon named "mark email read" (round). + static const IconData mark_email_read_rounded = IconData(0xf8a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mark_email_read</i> — material icon named "mark email read" (outlined). + static const IconData mark_email_read_outlined = IconData(0xf1b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mark_email_unread</i> — material icon named "mark email unread". + static const IconData mark_email_unread = IconData(0xe3d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mark_email_unread</i> — material icon named "mark email unread" (sharp). + static const IconData mark_email_unread_sharp = IconData(0xeac9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mark_email_unread</i> — material icon named "mark email unread" (round). + static const IconData mark_email_unread_rounded = IconData(0xf8a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mark_email_unread</i> — material icon named "mark email unread" (outlined). + static const IconData mark_email_unread_outlined = IconData(0xf1b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mark_unread_chat_alt</i> — material icon named "mark unread chat alt". + static const IconData mark_unread_chat_alt = IconData(0xf0539, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mark_unread_chat_alt</i> — material icon named "mark unread chat alt" (sharp). + static const IconData mark_unread_chat_alt_sharp = IconData(0xf0443, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mark_unread_chat_alt</i> — material icon named "mark unread chat alt" (round). + static const IconData mark_unread_chat_alt_rounded = IconData( + 0xf0350, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">mark_unread_chat_alt</i> — material icon named "mark unread chat alt" (outlined). + static const IconData mark_unread_chat_alt_outlined = IconData( + 0xf0631, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">markunread</i> — material icon named "markunread". + static const IconData markunread = IconData(0xe3d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">markunread</i> — material icon named "markunread" (sharp). + static const IconData markunread_sharp = IconData(0xeacb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">markunread</i> — material icon named "markunread" (round). + static const IconData markunread_rounded = IconData(0xf8aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">markunread</i> — material icon named "markunread" (outlined). + static const IconData markunread_outlined = IconData(0xf1b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">markunread_mailbox</i> — material icon named "markunread mailbox". + static const IconData markunread_mailbox = IconData(0xe3d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">markunread_mailbox</i> — material icon named "markunread mailbox" (sharp). + static const IconData markunread_mailbox_sharp = IconData(0xeaca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">markunread_mailbox</i> — material icon named "markunread mailbox" (round). + static const IconData markunread_mailbox_rounded = IconData(0xf8a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">markunread_mailbox</i> — material icon named "markunread mailbox" (outlined). + static const IconData markunread_mailbox_outlined = IconData(0xf1b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">masks</i> — material icon named "masks". + static const IconData masks = IconData(0xe3d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">masks</i> — material icon named "masks" (sharp). + static const IconData masks_sharp = IconData(0xeacc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">masks</i> — material icon named "masks" (round). + static const IconData masks_rounded = IconData(0xf8ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">masks</i> — material icon named "masks" (outlined). + static const IconData masks_outlined = IconData(0xf1b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">maximize</i> — material icon named "maximize". + static const IconData maximize = IconData(0xe3d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">maximize</i> — material icon named "maximize" (sharp). + static const IconData maximize_sharp = IconData(0xeacd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">maximize</i> — material icon named "maximize" (round). + static const IconData maximize_rounded = IconData(0xf8ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">maximize</i> — material icon named "maximize" (outlined). + static const IconData maximize_outlined = IconData(0xf1ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">media_bluetooth_off</i> — material icon named "media bluetooth off". + static const IconData media_bluetooth_off = IconData(0xe3d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">media_bluetooth_off</i> — material icon named "media bluetooth off" (sharp). + static const IconData media_bluetooth_off_sharp = IconData(0xeace, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">media_bluetooth_off</i> — material icon named "media bluetooth off" (round). + static const IconData media_bluetooth_off_rounded = IconData(0xf8ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">media_bluetooth_off</i> — material icon named "media bluetooth off" (outlined). + static const IconData media_bluetooth_off_outlined = IconData( + 0xf1bb, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">media_bluetooth_on</i> — material icon named "media bluetooth on". + static const IconData media_bluetooth_on = IconData(0xe3d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">media_bluetooth_on</i> — material icon named "media bluetooth on" (sharp). + static const IconData media_bluetooth_on_sharp = IconData(0xeacf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">media_bluetooth_on</i> — material icon named "media bluetooth on" (round). + static const IconData media_bluetooth_on_rounded = IconData(0xf8ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">media_bluetooth_on</i> — material icon named "media bluetooth on" (outlined). + static const IconData media_bluetooth_on_outlined = IconData(0xf1bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mediation</i> — material icon named "mediation". + static const IconData mediation = IconData(0xe3d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mediation</i> — material icon named "mediation" (sharp). + static const IconData mediation_sharp = IconData(0xead0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mediation</i> — material icon named "mediation" (round). + static const IconData mediation_rounded = IconData(0xf8af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mediation</i> — material icon named "mediation" (outlined). + static const IconData mediation_outlined = IconData(0xf1bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">medical_information</i> — material icon named "medical information". + static const IconData medical_information = IconData(0xf07ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">medical_information</i> — material icon named "medical information" (sharp). + static const IconData medical_information_sharp = IconData(0xf0754, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">medical_information</i> — material icon named "medical information" (round). + static const IconData medical_information_rounded = IconData( + 0xf0804, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">medical_information</i> — material icon named "medical information" (outlined). + static const IconData medical_information_outlined = IconData( + 0xf06fc, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">medical_services</i> — material icon named "medical services". + static const IconData medical_services = IconData(0xe3d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">medical_services</i> — material icon named "medical services" (sharp). + static const IconData medical_services_sharp = IconData(0xead1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">medical_services</i> — material icon named "medical services" (round). + static const IconData medical_services_rounded = IconData(0xf8b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">medical_services</i> — material icon named "medical services" (outlined). + static const IconData medical_services_outlined = IconData(0xf1be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">medication</i> — material icon named "medication". + static const IconData medication = IconData(0xe3d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">medication</i> — material icon named "medication" (sharp). + static const IconData medication_sharp = IconData(0xead2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">medication</i> — material icon named "medication" (round). + static const IconData medication_rounded = IconData(0xf8b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">medication</i> — material icon named "medication" (outlined). + static const IconData medication_outlined = IconData(0xf1bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">medication_liquid</i> — material icon named "medication liquid". + static const IconData medication_liquid = IconData(0xf053a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">medication_liquid</i> — material icon named "medication liquid" (sharp). + static const IconData medication_liquid_sharp = IconData(0xf0444, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">medication_liquid</i> — material icon named "medication liquid" (round). + static const IconData medication_liquid_rounded = IconData(0xf0351, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">medication_liquid</i> — material icon named "medication liquid" (outlined). + static const IconData medication_liquid_outlined = IconData(0xf0632, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">meeting_room</i> — material icon named "meeting room". + static const IconData meeting_room = IconData(0xe3da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">meeting_room</i> — material icon named "meeting room" (sharp). + static const IconData meeting_room_sharp = IconData(0xead3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">meeting_room</i> — material icon named "meeting room" (round). + static const IconData meeting_room_rounded = IconData(0xf8b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">meeting_room</i> — material icon named "meeting room" (outlined). + static const IconData meeting_room_outlined = IconData(0xf1c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">memory</i> — material icon named "memory". + static const IconData memory = IconData(0xe3db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">memory</i> — material icon named "memory" (sharp). + static const IconData memory_sharp = IconData(0xead4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">memory</i> — material icon named "memory" (round). + static const IconData memory_rounded = IconData(0xf8b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">memory</i> — material icon named "memory" (outlined). + static const IconData memory_outlined = IconData(0xf1c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">menu</i> — material icon named "menu". + static const IconData menu = IconData(0xe3dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">menu</i> — material icon named "menu" (sharp). + static const IconData menu_sharp = IconData(0xead7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">menu</i> — material icon named "menu" (round). + static const IconData menu_rounded = IconData(0xf8b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">menu</i> — material icon named "menu" (outlined). + static const IconData menu_outlined = IconData(0xf1c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">menu_book</i> — material icon named "menu book". + static const IconData menu_book = IconData(0xe3dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">menu_book</i> — material icon named "menu book" (sharp). + static const IconData menu_book_sharp = IconData(0xead5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">menu_book</i> — material icon named "menu book" (round). + static const IconData menu_book_rounded = IconData(0xf8b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">menu_book</i> — material icon named "menu book" (outlined). + static const IconData menu_book_outlined = IconData(0xf1c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">menu_open</i> — material icon named "menu open". + static const IconData menu_open = IconData(0xe3de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">menu_open</i> — material icon named "menu open" (sharp). + static const IconData menu_open_sharp = IconData(0xead6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">menu_open</i> — material icon named "menu open" (round). + static const IconData menu_open_rounded = IconData(0xf8b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">menu_open</i> — material icon named "menu open" (outlined). + static const IconData menu_open_outlined = IconData(0xf1c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">merge</i> — material icon named "merge". + static const IconData merge = IconData(0xf053b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">merge</i> — material icon named "merge" (sharp). + static const IconData merge_sharp = IconData(0xf0445, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">merge</i> — material icon named "merge" (round). + static const IconData merge_rounded = IconData(0xf0352, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">merge</i> — material icon named "merge" (outlined). + static const IconData merge_outlined = IconData(0xf0633, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">merge_type</i> — material icon named "merge type". + static const IconData merge_type = IconData(0xe3df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">merge_type</i> — material icon named "merge type" (sharp). + static const IconData merge_type_sharp = IconData(0xead8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">merge_type</i> — material icon named "merge type" (round). + static const IconData merge_type_rounded = IconData(0xf8b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">merge_type</i> — material icon named "merge type" (outlined). + static const IconData merge_type_outlined = IconData(0xf1c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">message</i> — material icon named "message". + static const IconData message = IconData(0xe3e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">message</i> — material icon named "message" (sharp). + static const IconData message_sharp = IconData(0xead9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">message</i> — material icon named "message" (round). + static const IconData message_rounded = IconData(0xf8b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">message</i> — material icon named "message" (outlined). + static const IconData message_outlined = IconData(0xf1c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">messenger</i> — material icon named "messenger". + static const IconData messenger = IconData(0xe154, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">messenger</i> — material icon named "messenger" (sharp). + static const IconData messenger_sharp = IconData(0xe851, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">messenger</i> — material icon named "messenger" (round). + static const IconData messenger_rounded = IconData(0xf630, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">messenger</i> — material icon named "messenger" (outlined). + static const IconData messenger_outlined = IconData(0xef43, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">messenger_outline</i> — material icon named "messenger outline". + static const IconData messenger_outline = IconData(0xe155, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">messenger_outline</i> — material icon named "messenger outline" (sharp). + static const IconData messenger_outline_sharp = IconData(0xe850, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">messenger_outline</i> — material icon named "messenger outline" (round). + static const IconData messenger_outline_rounded = IconData(0xf62f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">messenger_outline</i> — material icon named "messenger outline" (outlined). + static const IconData messenger_outline_outlined = IconData(0xef42, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mic</i> — material icon named "mic". + static const IconData mic = IconData(0xe3e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mic</i> — material icon named "mic" (sharp). + static const IconData mic_sharp = IconData(0xeade, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mic</i> — material icon named "mic" (round). + static const IconData mic_rounded = IconData(0xf8bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mic</i> — material icon named "mic" (outlined). + static const IconData mic_outlined = IconData(0xf1cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mic_external_off</i> — material icon named "mic external off". + static const IconData mic_external_off = IconData(0xe3e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mic_external_off</i> — material icon named "mic external off" (sharp). + static const IconData mic_external_off_sharp = IconData(0xeada, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mic_external_off</i> — material icon named "mic external off" (round). + static const IconData mic_external_off_rounded = IconData(0xf8b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mic_external_off</i> — material icon named "mic external off" (outlined). + static const IconData mic_external_off_outlined = IconData(0xf1c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mic_external_on</i> — material icon named "mic external on". + static const IconData mic_external_on = IconData(0xe3e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mic_external_on</i> — material icon named "mic external on" (sharp). + static const IconData mic_external_on_sharp = IconData(0xeadb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mic_external_on</i> — material icon named "mic external on" (round). + static const IconData mic_external_on_rounded = IconData(0xf8ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mic_external_on</i> — material icon named "mic external on" (outlined). + static const IconData mic_external_on_outlined = IconData(0xf1c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mic_none</i> — material icon named "mic none". + static const IconData mic_none = IconData(0xe3e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mic_none</i> — material icon named "mic none" (sharp). + static const IconData mic_none_sharp = IconData(0xeadc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mic_none</i> — material icon named "mic none" (round). + static const IconData mic_none_rounded = IconData(0xf8bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mic_none</i> — material icon named "mic none" (outlined). + static const IconData mic_none_outlined = IconData(0xf1c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mic_off</i> — material icon named "mic off". + static const IconData mic_off = IconData(0xe3e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mic_off</i> — material icon named "mic off" (sharp). + static const IconData mic_off_sharp = IconData(0xeadd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mic_off</i> — material icon named "mic off" (round). + static const IconData mic_off_rounded = IconData(0xf8bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mic_off</i> — material icon named "mic off" (outlined). + static const IconData mic_off_outlined = IconData(0xf1ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">microwave</i> — material icon named "microwave". + static const IconData microwave = IconData(0xe3e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">microwave</i> — material icon named "microwave" (sharp). + static const IconData microwave_sharp = IconData(0xeadf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">microwave</i> — material icon named "microwave" (round). + static const IconData microwave_rounded = IconData(0xf8be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">microwave</i> — material icon named "microwave" (outlined). + static const IconData microwave_outlined = IconData(0xf1cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">military_tech</i> — material icon named "military tech". + static const IconData military_tech = IconData(0xe3e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">military_tech</i> — material icon named "military tech" (sharp). + static const IconData military_tech_sharp = IconData(0xeae0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">military_tech</i> — material icon named "military tech" (round). + static const IconData military_tech_rounded = IconData(0xf8bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">military_tech</i> — material icon named "military tech" (outlined). + static const IconData military_tech_outlined = IconData(0xf1cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">minimize</i> — material icon named "minimize". + static const IconData minimize = IconData(0xe3e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">minimize</i> — material icon named "minimize" (sharp). + static const IconData minimize_sharp = IconData(0xeae1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">minimize</i> — material icon named "minimize" (round). + static const IconData minimize_rounded = IconData(0xf8c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">minimize</i> — material icon named "minimize" (outlined). + static const IconData minimize_outlined = IconData(0xf1ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">minor_crash</i> — material icon named "minor crash". + static const IconData minor_crash = IconData(0xf07ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">minor_crash</i> — material icon named "minor crash" (sharp). + static const IconData minor_crash_sharp = IconData(0xf0755, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">minor_crash</i> — material icon named "minor crash" (round). + static const IconData minor_crash_rounded = IconData(0xf0805, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">minor_crash</i> — material icon named "minor crash" (outlined). + static const IconData minor_crash_outlined = IconData(0xf06fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">miscellaneous_services</i> — material icon named "miscellaneous services". + static const IconData miscellaneous_services = IconData(0xe3e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">miscellaneous_services</i> — material icon named "miscellaneous services" (sharp). + static const IconData miscellaneous_services_sharp = IconData( + 0xeae2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">miscellaneous_services</i> — material icon named "miscellaneous services" (round). + static const IconData miscellaneous_services_rounded = IconData( + 0xf8c1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">miscellaneous_services</i> — material icon named "miscellaneous services" (outlined). + static const IconData miscellaneous_services_outlined = IconData( + 0xf1cf, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">missed_video_call</i> — material icon named "missed video call". + static const IconData missed_video_call = IconData(0xe3ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">missed_video_call</i> — material icon named "missed video call" (sharp). + static const IconData missed_video_call_sharp = IconData(0xeae3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">missed_video_call</i> — material icon named "missed video call" (round). + static const IconData missed_video_call_rounded = IconData(0xf8c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">missed_video_call</i> — material icon named "missed video call" (outlined). + static const IconData missed_video_call_outlined = IconData(0xf1d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mms</i> — material icon named "mms". + static const IconData mms = IconData(0xe3eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mms</i> — material icon named "mms" (sharp). + static const IconData mms_sharp = IconData(0xeae4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mms</i> — material icon named "mms" (round). + static const IconData mms_rounded = IconData(0xf8c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mms</i> — material icon named "mms" (outlined). + static const IconData mms_outlined = IconData(0xf1d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mobile_friendly</i> — material icon named "mobile friendly". + static const IconData mobile_friendly = IconData(0xe3ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mobile_friendly</i> — material icon named "mobile friendly" (sharp). + static const IconData mobile_friendly_sharp = IconData(0xeae5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mobile_friendly</i> — material icon named "mobile friendly" (round). + static const IconData mobile_friendly_rounded = IconData(0xf8c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mobile_friendly</i> — material icon named "mobile friendly" (outlined). + static const IconData mobile_friendly_outlined = IconData(0xf1d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mobile_off</i> — material icon named "mobile off". + static const IconData mobile_off = IconData(0xe3ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mobile_off</i> — material icon named "mobile off" (sharp). + static const IconData mobile_off_sharp = IconData(0xeae6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mobile_off</i> — material icon named "mobile off" (round). + static const IconData mobile_off_rounded = IconData(0xf8c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mobile_off</i> — material icon named "mobile off" (outlined). + static const IconData mobile_off_outlined = IconData(0xf1d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mobile_screen_share</i> — material icon named "mobile screen share". + static const IconData mobile_screen_share = IconData( + 0xe3ee, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">mobile_screen_share</i> — material icon named "mobile screen share" (sharp). + static const IconData mobile_screen_share_sharp = IconData( + 0xeae7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">mobile_screen_share</i> — material icon named "mobile screen share" (round). + static const IconData mobile_screen_share_rounded = IconData( + 0xf8c6, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">mobile_screen_share</i> — material icon named "mobile screen share" (outlined). + static const IconData mobile_screen_share_outlined = IconData( + 0xf1d4, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">mobiledata_off</i> — material icon named "mobiledata off". + static const IconData mobiledata_off = IconData(0xe3ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mobiledata_off</i> — material icon named "mobiledata off" (sharp). + static const IconData mobiledata_off_sharp = IconData(0xeae8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mobiledata_off</i> — material icon named "mobiledata off" (round). + static const IconData mobiledata_off_rounded = IconData(0xf8c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mobiledata_off</i> — material icon named "mobiledata off" (outlined). + static const IconData mobiledata_off_outlined = IconData(0xf1d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode</i> — material icon named "mode". + static const IconData mode = IconData(0xe3f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode</i> — material icon named "mode" (sharp). + static const IconData mode_sharp = IconData(0xeaed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode</i> — material icon named "mode" (round). + static const IconData mode_rounded = IconData(0xf8cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode</i> — material icon named "mode" (outlined). + static const IconData mode_outlined = IconData(0xf1da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_comment</i> — material icon named "mode comment". + static const IconData mode_comment = IconData(0xe3f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_comment</i> — material icon named "mode comment" (sharp). + static const IconData mode_comment_sharp = IconData(0xeae9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_comment</i> — material icon named "mode comment" (round). + static const IconData mode_comment_rounded = IconData(0xf8c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_comment</i> — material icon named "mode comment" (outlined). + static const IconData mode_comment_outlined = IconData(0xf1d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_edit</i> — material icon named "mode edit". + static const IconData mode_edit = IconData(0xe3f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_edit</i> — material icon named "mode edit" (sharp). + static const IconData mode_edit_sharp = IconData(0xeaeb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_edit</i> — material icon named "mode edit" (round). + static const IconData mode_edit_rounded = IconData(0xf8ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_edit</i> — material icon named "mode edit" (outlined). + static const IconData mode_edit_outlined = IconData(0xf1d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_edit_outline</i> — material icon named "mode edit outline". + static const IconData mode_edit_outline = IconData(0xe3f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_edit_outline</i> — material icon named "mode edit outline" (sharp). + static const IconData mode_edit_outline_sharp = IconData(0xeaea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_edit_outline</i> — material icon named "mode edit outline" (round). + static const IconData mode_edit_outline_rounded = IconData(0xf8c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_edit_outline</i> — material icon named "mode edit outline" (outlined). + static const IconData mode_edit_outline_outlined = IconData(0xf1d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_fan_off</i> — material icon named "mode fan off". + static const IconData mode_fan_off = IconData(0xf07ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_fan_off</i> — material icon named "mode fan off" (sharp). + static const IconData mode_fan_off_sharp = IconData(0xf0756, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_fan_off</i> — material icon named "mode fan off" (round). + static const IconData mode_fan_off_rounded = IconData(0xf0806, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_fan_off</i> — material icon named "mode fan off" (outlined). + static const IconData mode_fan_off_outlined = IconData(0xf06fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_night</i> — material icon named "mode night". + static const IconData mode_night = IconData(0xe3f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_night</i> — material icon named "mode night" (sharp). + static const IconData mode_night_sharp = IconData(0xeaec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_night</i> — material icon named "mode night" (round). + static const IconData mode_night_rounded = IconData(0xf8cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_night</i> — material icon named "mode night" (outlined). + static const IconData mode_night_outlined = IconData(0xf1d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_of_travel</i> — material icon named "mode of travel". + static const IconData mode_of_travel = IconData(0xf053c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_of_travel</i> — material icon named "mode of travel" (sharp). + static const IconData mode_of_travel_sharp = IconData(0xf0446, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_of_travel</i> — material icon named "mode of travel" (round). + static const IconData mode_of_travel_rounded = IconData(0xf0353, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_of_travel</i> — material icon named "mode of travel" (outlined). + static const IconData mode_of_travel_outlined = IconData(0xf0634, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mode_standby</i> — material icon named "mode standby". + static const IconData mode_standby = IconData(0xe3f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mode_standby</i> — material icon named "mode standby" (sharp). + static const IconData mode_standby_sharp = IconData(0xeaee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mode_standby</i> — material icon named "mode standby" (round). + static const IconData mode_standby_rounded = IconData(0xf8cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mode_standby</i> — material icon named "mode standby" (outlined). + static const IconData mode_standby_outlined = IconData(0xf1db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">model_training</i> — material icon named "model training". + static const IconData model_training = IconData(0xe3f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">model_training</i> — material icon named "model training" (sharp). + static const IconData model_training_sharp = IconData(0xeaef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">model_training</i> — material icon named "model training" (round). + static const IconData model_training_rounded = IconData(0xf8ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">model_training</i> — material icon named "model training" (outlined). + static const IconData model_training_outlined = IconData(0xf1dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">monetization_on</i> — material icon named "monetization on". + static const IconData monetization_on = IconData(0xe3f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">monetization_on</i> — material icon named "monetization on" (sharp). + static const IconData monetization_on_sharp = IconData(0xeaf0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">monetization_on</i> — material icon named "monetization on" (round). + static const IconData monetization_on_rounded = IconData(0xf8cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">monetization_on</i> — material icon named "monetization on" (outlined). + static const IconData monetization_on_outlined = IconData(0xf1dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">money</i> — material icon named "money". + static const IconData money = IconData(0xe3f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">money</i> — material icon named "money" (sharp). + static const IconData money_sharp = IconData(0xeaf3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">money</i> — material icon named "money" (round). + static const IconData money_rounded = IconData(0xf8d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">money</i> — material icon named "money" (outlined). + static const IconData money_outlined = IconData(0xf1e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">money_off</i> — material icon named "money off". + static const IconData money_off = IconData(0xe3f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">money_off</i> — material icon named "money off" (sharp). + static const IconData money_off_sharp = IconData(0xeaf2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">money_off</i> — material icon named "money off" (round). + static const IconData money_off_rounded = IconData(0xf8d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">money_off</i> — material icon named "money off" (outlined). + static const IconData money_off_outlined = IconData(0xf1df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">money_off_csred</i> — material icon named "money off csred". + static const IconData money_off_csred = IconData(0xe3fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">money_off_csred</i> — material icon named "money off csred" (sharp). + static const IconData money_off_csred_sharp = IconData(0xeaf1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">money_off_csred</i> — material icon named "money off csred" (round). + static const IconData money_off_csred_rounded = IconData(0xf8d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">money_off_csred</i> — material icon named "money off csred" (outlined). + static const IconData money_off_csred_outlined = IconData(0xf1de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">monitor</i> — material icon named "monitor". + static const IconData monitor = IconData(0xe3fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">monitor</i> — material icon named "monitor" (sharp). + static const IconData monitor_sharp = IconData(0xeaf4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">monitor</i> — material icon named "monitor" (round). + static const IconData monitor_rounded = IconData(0xf8d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">monitor</i> — material icon named "monitor" (outlined). + static const IconData monitor_outlined = IconData(0xf1e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">monitor_heart</i> — material icon named "monitor heart". + static const IconData monitor_heart = IconData(0xf053d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">monitor_heart</i> — material icon named "monitor heart" (sharp). + static const IconData monitor_heart_sharp = IconData(0xf0447, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">monitor_heart</i> — material icon named "monitor heart" (round). + static const IconData monitor_heart_rounded = IconData(0xf0354, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">monitor_heart</i> — material icon named "monitor heart" (outlined). + static const IconData monitor_heart_outlined = IconData(0xf0635, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">monitor_weight</i> — material icon named "monitor weight". + static const IconData monitor_weight = IconData(0xe3fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">monitor_weight</i> — material icon named "monitor weight" (sharp). + static const IconData monitor_weight_sharp = IconData(0xeaf5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">monitor_weight</i> — material icon named "monitor weight" (round). + static const IconData monitor_weight_rounded = IconData(0xf8d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">monitor_weight</i> — material icon named "monitor weight" (outlined). + static const IconData monitor_weight_outlined = IconData(0xf1e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">monochrome_photos</i> — material icon named "monochrome photos". + static const IconData monochrome_photos = IconData(0xe3fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">monochrome_photos</i> — material icon named "monochrome photos" (sharp). + static const IconData monochrome_photos_sharp = IconData(0xeaf6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">monochrome_photos</i> — material icon named "monochrome photos" (round). + static const IconData monochrome_photos_rounded = IconData(0xf8d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">monochrome_photos</i> — material icon named "monochrome photos" (outlined). + static const IconData monochrome_photos_outlined = IconData(0xf1e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mood</i> — material icon named "mood". + static const IconData mood = IconData(0xe3fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mood</i> — material icon named "mood" (sharp). + static const IconData mood_sharp = IconData(0xeaf8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mood</i> — material icon named "mood" (round). + static const IconData mood_rounded = IconData(0xf8d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mood</i> — material icon named "mood" (outlined). + static const IconData mood_outlined = IconData(0xf1e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mood_bad</i> — material icon named "mood bad". + static const IconData mood_bad = IconData(0xe3ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mood_bad</i> — material icon named "mood bad" (sharp). + static const IconData mood_bad_sharp = IconData(0xeaf7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mood_bad</i> — material icon named "mood bad" (round). + static const IconData mood_bad_rounded = IconData(0xf8d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mood_bad</i> — material icon named "mood bad" (outlined). + static const IconData mood_bad_outlined = IconData(0xf1e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">moped</i> — material icon named "moped". + static const IconData moped = IconData(0xe400, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">moped</i> — material icon named "moped" (sharp). + static const IconData moped_sharp = IconData(0xeaf9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">moped</i> — material icon named "moped" (round). + static const IconData moped_rounded = IconData(0xf8d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">moped</i> — material icon named "moped" (outlined). + static const IconData moped_outlined = IconData(0xf1e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">more</i> — material icon named "more". + static const IconData more = IconData(0xe401, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">more</i> — material icon named "more" (sharp). + static const IconData more_sharp = IconData(0xeafb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">more</i> — material icon named "more" (round). + static const IconData more_rounded = IconData(0xf8da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">more</i> — material icon named "more" (outlined). + static const IconData more_outlined = IconData(0xf1e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">more_horiz</i> — material icon named "more horiz". + static const IconData more_horiz = IconData(0xe402, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">more_horiz</i> — material icon named "more horiz" (sharp). + static const IconData more_horiz_sharp = IconData(0xeafa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">more_horiz</i> — material icon named "more horiz" (round). + static const IconData more_horiz_rounded = IconData(0xf8d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">more_horiz</i> — material icon named "more horiz" (outlined). + static const IconData more_horiz_outlined = IconData(0xf1e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">more_time</i> — material icon named "more time". + static const IconData more_time = IconData(0xe403, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">more_time</i> — material icon named "more time" (sharp). + static const IconData more_time_sharp = IconData(0xeafc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">more_time</i> — material icon named "more time" (round). + static const IconData more_time_rounded = IconData(0xf8db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">more_time</i> — material icon named "more time" (outlined). + static const IconData more_time_outlined = IconData(0xf1e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">more_vert</i> — material icon named "more vert". + static const IconData more_vert = IconData(0xe404, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">more_vert</i> — material icon named "more vert" (sharp). + static const IconData more_vert_sharp = IconData(0xeafd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">more_vert</i> — material icon named "more vert" (round). + static const IconData more_vert_rounded = IconData(0xf8dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">more_vert</i> — material icon named "more vert" (outlined). + static const IconData more_vert_outlined = IconData(0xf1ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mosque</i> — material icon named "mosque". + static const IconData mosque = IconData(0xf053e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mosque</i> — material icon named "mosque" (sharp). + static const IconData mosque_sharp = IconData(0xf0448, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mosque</i> — material icon named "mosque" (round). + static const IconData mosque_rounded = IconData(0xf0355, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mosque</i> — material icon named "mosque" (outlined). + static const IconData mosque_outlined = IconData(0xf0636, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">motion_photos_auto</i> — material icon named "motion photos auto". + static const IconData motion_photos_auto = IconData(0xe405, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">motion_photos_auto</i> — material icon named "motion photos auto" (sharp). + static const IconData motion_photos_auto_sharp = IconData(0xeafe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">motion_photos_auto</i> — material icon named "motion photos auto" (round). + static const IconData motion_photos_auto_rounded = IconData(0xf8dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">motion_photos_auto</i> — material icon named "motion photos auto" (outlined). + static const IconData motion_photos_auto_outlined = IconData(0xf1eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">motion_photos_off</i> — material icon named "motion photos off". + static const IconData motion_photos_off = IconData(0xe406, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">motion_photos_off</i> — material icon named "motion photos off" (sharp). + static const IconData motion_photos_off_sharp = IconData(0xeaff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">motion_photos_off</i> — material icon named "motion photos off" (round). + static const IconData motion_photos_off_rounded = IconData(0xf8de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">motion_photos_off</i> — material icon named "motion photos off" (outlined). + static const IconData motion_photos_off_outlined = IconData(0xf1ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">motion_photos_on</i> — material icon named "motion photos on". + static const IconData motion_photos_on = IconData(0xe407, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">motion_photos_on</i> — material icon named "motion photos on" (sharp). + static const IconData motion_photos_on_sharp = IconData(0xeb00, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">motion_photos_on</i> — material icon named "motion photos on" (round). + static const IconData motion_photos_on_rounded = IconData(0xf8df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">motion_photos_on</i> — material icon named "motion photos on" (outlined). + static const IconData motion_photos_on_outlined = IconData(0xf1ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">motion_photos_pause</i> — material icon named "motion photos pause". + static const IconData motion_photos_pause = IconData(0xe408, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">motion_photos_pause</i> — material icon named "motion photos pause" (sharp). + static const IconData motion_photos_pause_sharp = IconData(0xeb01, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">motion_photos_pause</i> — material icon named "motion photos pause" (round). + static const IconData motion_photos_pause_rounded = IconData(0xf8e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">motion_photos_pause</i> — material icon named "motion photos pause" (outlined). + static const IconData motion_photos_pause_outlined = IconData( + 0xf1ee, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">motion_photos_paused</i> — material icon named "motion photos paused". + static const IconData motion_photos_paused = IconData(0xe409, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">motion_photos_paused</i> — material icon named "motion photos paused" (sharp). + static const IconData motion_photos_paused_sharp = IconData(0xeb02, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">motion_photos_paused</i> — material icon named "motion photos paused" (round). + static const IconData motion_photos_paused_rounded = IconData( + 0xf8e1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">motion_photos_paused</i> — material icon named "motion photos paused" (outlined). + static const IconData motion_photos_paused_outlined = IconData( + 0xf1ef, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">motorcycle</i> — material icon named "motorcycle". + static const IconData motorcycle = IconData(0xe40a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">motorcycle</i> — material icon named "motorcycle" (sharp). + static const IconData motorcycle_sharp = IconData(0xeb03, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">motorcycle</i> — material icon named "motorcycle" (round). + static const IconData motorcycle_rounded = IconData(0xf8e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">motorcycle</i> — material icon named "motorcycle" (outlined). + static const IconData motorcycle_outlined = IconData(0xf1f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mouse</i> — material icon named "mouse". + static const IconData mouse = IconData(0xe40b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mouse</i> — material icon named "mouse" (sharp). + static const IconData mouse_sharp = IconData(0xeb04, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mouse</i> — material icon named "mouse" (round). + static const IconData mouse_rounded = IconData(0xf8e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mouse</i> — material icon named "mouse" (outlined). + static const IconData mouse_outlined = IconData(0xf1f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">move_down</i> — material icon named "move down". + static const IconData move_down = IconData(0xf053f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">move_down</i> — material icon named "move down" (sharp). + static const IconData move_down_sharp = IconData(0xf0449, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">move_down</i> — material icon named "move down" (round). + static const IconData move_down_rounded = IconData(0xf0356, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">move_down</i> — material icon named "move down" (outlined). + static const IconData move_down_outlined = IconData(0xf0637, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">move_to_inbox</i> — material icon named "move to inbox". + static const IconData move_to_inbox = IconData(0xe40c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">move_to_inbox</i> — material icon named "move to inbox" (sharp). + static const IconData move_to_inbox_sharp = IconData(0xeb05, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">move_to_inbox</i> — material icon named "move to inbox" (round). + static const IconData move_to_inbox_rounded = IconData(0xf8e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">move_to_inbox</i> — material icon named "move to inbox" (outlined). + static const IconData move_to_inbox_outlined = IconData(0xf1f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">move_up</i> — material icon named "move up". + static const IconData move_up = IconData(0xf0540, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">move_up</i> — material icon named "move up" (sharp). + static const IconData move_up_sharp = IconData(0xf044a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">move_up</i> — material icon named "move up" (round). + static const IconData move_up_rounded = IconData(0xf0357, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">move_up</i> — material icon named "move up" (outlined). + static const IconData move_up_outlined = IconData(0xf0638, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">movie</i> — material icon named "movie". + static const IconData movie = IconData(0xe40d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">movie</i> — material icon named "movie" (sharp). + static const IconData movie_sharp = IconData(0xeb08, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">movie</i> — material icon named "movie" (round). + static const IconData movie_rounded = IconData(0xf8e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">movie</i> — material icon named "movie" (outlined). + static const IconData movie_outlined = IconData(0xf1f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">movie_creation</i> — material icon named "movie creation". + static const IconData movie_creation = IconData(0xe40e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">movie_creation</i> — material icon named "movie creation" (sharp). + static const IconData movie_creation_sharp = IconData(0xeb06, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">movie_creation</i> — material icon named "movie creation" (round). + static const IconData movie_creation_rounded = IconData(0xf8e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">movie_creation</i> — material icon named "movie creation" (outlined). + static const IconData movie_creation_outlined = IconData(0xf1f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">movie_edit</i> — material icon named "movie edit". + static const IconData movie_edit = IconData(0xf08b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">movie_filter</i> — material icon named "movie filter". + static const IconData movie_filter = IconData(0xe40f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">movie_filter</i> — material icon named "movie filter" (sharp). + static const IconData movie_filter_sharp = IconData(0xeb07, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">movie_filter</i> — material icon named "movie filter" (round). + static const IconData movie_filter_rounded = IconData(0xf8e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">movie_filter</i> — material icon named "movie filter" (outlined). + static const IconData movie_filter_outlined = IconData(0xf1f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">moving</i> — material icon named "moving". + static const IconData moving = IconData(0xe410, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">moving</i> — material icon named "moving" (sharp). + static const IconData moving_sharp = IconData(0xeb09, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">moving</i> — material icon named "moving" (round). + static const IconData moving_rounded = IconData(0xf8e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">moving</i> — material icon named "moving" (outlined). + static const IconData moving_outlined = IconData(0xf1f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">mp</i> — material icon named "mp". + static const IconData mp = IconData(0xe411, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">mp</i> — material icon named "mp" (sharp). + static const IconData mp_sharp = IconData(0xeb0a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">mp</i> — material icon named "mp" (round). + static const IconData mp_rounded = IconData(0xf8e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">mp</i> — material icon named "mp" (outlined). + static const IconData mp_outlined = IconData(0xf1f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">multiline_chart</i> — material icon named "multiline chart". + static const IconData multiline_chart = IconData( + 0xe412, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">multiline_chart</i> — material icon named "multiline chart" (sharp). + static const IconData multiline_chart_sharp = IconData( + 0xeb0b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">multiline_chart</i> — material icon named "multiline chart" (round). + static const IconData multiline_chart_rounded = IconData( + 0xf8ea, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">multiline_chart</i> — material icon named "multiline chart" (outlined). + static const IconData multiline_chart_outlined = IconData( + 0xf1f8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">multiple_stop</i> — material icon named "multiple stop". + static const IconData multiple_stop = IconData(0xe413, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">multiple_stop</i> — material icon named "multiple stop" (sharp). + static const IconData multiple_stop_sharp = IconData(0xeb0c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">multiple_stop</i> — material icon named "multiple stop" (round). + static const IconData multiple_stop_rounded = IconData(0xf8eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">multiple_stop</i> — material icon named "multiple stop" (outlined). + static const IconData multiple_stop_outlined = IconData(0xf1f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">multitrack_audio</i> — material icon named "multitrack audio". + static const IconData multitrack_audio = IconData(0xe2e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">multitrack_audio</i> — material icon named "multitrack audio" (sharp). + static const IconData multitrack_audio_sharp = IconData(0xe9de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">multitrack_audio</i> — material icon named "multitrack audio" (round). + static const IconData multitrack_audio_rounded = IconData(0xf7bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">multitrack_audio</i> — material icon named "multitrack audio" (outlined). + static const IconData multitrack_audio_outlined = IconData(0xf0d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">museum</i> — material icon named "museum". + static const IconData museum = IconData(0xe414, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">museum</i> — material icon named "museum" (sharp). + static const IconData museum_sharp = IconData(0xeb0d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">museum</i> — material icon named "museum" (round). + static const IconData museum_rounded = IconData(0xf8ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">museum</i> — material icon named "museum" (outlined). + static const IconData museum_outlined = IconData(0xf1fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">music_note</i> — material icon named "music note". + static const IconData music_note = IconData(0xe415, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">music_note</i> — material icon named "music note" (sharp). + static const IconData music_note_sharp = IconData(0xeb0e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">music_note</i> — material icon named "music note" (round). + static const IconData music_note_rounded = IconData(0xf8ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">music_note</i> — material icon named "music note" (outlined). + static const IconData music_note_outlined = IconData(0xf1fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">music_off</i> — material icon named "music off". + static const IconData music_off = IconData(0xe416, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">music_off</i> — material icon named "music off" (sharp). + static const IconData music_off_sharp = IconData(0xeb0f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">music_off</i> — material icon named "music off" (round). + static const IconData music_off_rounded = IconData(0xf8ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">music_off</i> — material icon named "music off" (outlined). + static const IconData music_off_outlined = IconData(0xf1fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">music_video</i> — material icon named "music video". + static const IconData music_video = IconData(0xe417, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">music_video</i> — material icon named "music video" (sharp). + static const IconData music_video_sharp = IconData(0xeb10, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">music_video</i> — material icon named "music video" (round). + static const IconData music_video_rounded = IconData(0xf8ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">music_video</i> — material icon named "music video" (outlined). + static const IconData music_video_outlined = IconData(0xf1fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">my_library_add</i> — material icon named "my library add". + static const IconData my_library_add = IconData(0xe375, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">my_library_add</i> — material icon named "my library add" (sharp). + static const IconData my_library_add_sharp = IconData(0xea70, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">my_library_add</i> — material icon named "my library add" (round). + static const IconData my_library_add_rounded = IconData(0xf84f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">my_library_add</i> — material icon named "my library add" (outlined). + static const IconData my_library_add_outlined = IconData(0xf15f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">my_library_books</i> — material icon named "my library books". + static const IconData my_library_books = IconData(0xe377, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">my_library_books</i> — material icon named "my library books" (sharp). + static const IconData my_library_books_sharp = IconData(0xea71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">my_library_books</i> — material icon named "my library books" (round). + static const IconData my_library_books_rounded = IconData(0xf850, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">my_library_books</i> — material icon named "my library books" (outlined). + static const IconData my_library_books_outlined = IconData(0xf160, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">my_library_music</i> — material icon named "my library music". + static const IconData my_library_music = IconData(0xe378, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">my_library_music</i> — material icon named "my library music" (sharp). + static const IconData my_library_music_sharp = IconData(0xea72, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">my_library_music</i> — material icon named "my library music" (round). + static const IconData my_library_music_rounded = IconData(0xf851, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">my_library_music</i> — material icon named "my library music" (outlined). + static const IconData my_library_music_outlined = IconData(0xf161, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">my_location</i> — material icon named "my location". + static const IconData my_location = IconData(0xe418, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">my_location</i> — material icon named "my location" (sharp). + static const IconData my_location_sharp = IconData(0xeb11, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">my_location</i> — material icon named "my location" (round). + static const IconData my_location_rounded = IconData(0xf8f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">my_location</i> — material icon named "my location" (outlined). + static const IconData my_location_outlined = IconData(0xf1fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nat</i> — material icon named "nat". + static const IconData nat = IconData(0xe419, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nat</i> — material icon named "nat" (sharp). + static const IconData nat_sharp = IconData(0xeb12, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nat</i> — material icon named "nat" (round). + static const IconData nat_rounded = IconData(0xf8f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nat</i> — material icon named "nat" (outlined). + static const IconData nat_outlined = IconData(0xf1ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nature</i> — material icon named "nature". + static const IconData nature = IconData(0xe41a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nature</i> — material icon named "nature" (sharp). + static const IconData nature_sharp = IconData(0xeb14, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nature</i> — material icon named "nature" (round). + static const IconData nature_rounded = IconData(0xf8f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nature</i> — material icon named "nature" (outlined). + static const IconData nature_outlined = IconData(0xf200, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nature_people</i> — material icon named "nature people". + static const IconData nature_people = IconData(0xe41b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nature_people</i> — material icon named "nature people" (sharp). + static const IconData nature_people_sharp = IconData(0xeb13, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nature_people</i> — material icon named "nature people" (round). + static const IconData nature_people_rounded = IconData(0xf8f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nature_people</i> — material icon named "nature people" (outlined). + static const IconData nature_people_outlined = IconData(0xf201, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">navigate_before</i> — material icon named "navigate before". + static const IconData navigate_before = IconData( + 0xe41c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">navigate_before</i> — material icon named "navigate before" (sharp). + static const IconData navigate_before_sharp = IconData( + 0xeb15, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">navigate_before</i> — material icon named "navigate before" (round). + static const IconData navigate_before_rounded = IconData( + 0xf8f4, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">navigate_before</i> — material icon named "navigate before" (outlined). + static const IconData navigate_before_outlined = IconData( + 0xf202, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">navigate_next</i> — material icon named "navigate next". + static const IconData navigate_next = IconData( + 0xe41d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">navigate_next</i> — material icon named "navigate next" (sharp). + static const IconData navigate_next_sharp = IconData( + 0xeb16, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">navigate_next</i> — material icon named "navigate next" (round). + static const IconData navigate_next_rounded = IconData( + 0xf8f5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">navigate_next</i> — material icon named "navigate next" (outlined). + static const IconData navigate_next_outlined = IconData( + 0xf203, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">navigation</i> — material icon named "navigation". + static const IconData navigation = IconData(0xe41e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">navigation</i> — material icon named "navigation" (sharp). + static const IconData navigation_sharp = IconData(0xeb17, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">navigation</i> — material icon named "navigation" (round). + static const IconData navigation_rounded = IconData(0xf8f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">navigation</i> — material icon named "navigation" (outlined). + static const IconData navigation_outlined = IconData(0xf204, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">near_me</i> — material icon named "near me". + static const IconData near_me = IconData(0xe41f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">near_me</i> — material icon named "near me" (sharp). + static const IconData near_me_sharp = IconData(0xeb19, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">near_me</i> — material icon named "near me" (round). + static const IconData near_me_rounded = IconData(0xf8f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">near_me</i> — material icon named "near me" (outlined). + static const IconData near_me_outlined = IconData(0xf206, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">near_me_disabled</i> — material icon named "near me disabled". + static const IconData near_me_disabled = IconData(0xe420, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">near_me_disabled</i> — material icon named "near me disabled" (sharp). + static const IconData near_me_disabled_sharp = IconData(0xeb18, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">near_me_disabled</i> — material icon named "near me disabled" (round). + static const IconData near_me_disabled_rounded = IconData(0xf8f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">near_me_disabled</i> — material icon named "near me disabled" (outlined). + static const IconData near_me_disabled_outlined = IconData(0xf205, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nearby_error</i> — material icon named "nearby error". + static const IconData nearby_error = IconData(0xe421, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nearby_error</i> — material icon named "nearby error" (sharp). + static const IconData nearby_error_sharp = IconData(0xeb1a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nearby_error</i> — material icon named "nearby error" (round). + static const IconData nearby_error_rounded = IconData(0xf8f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nearby_error</i> — material icon named "nearby error" (outlined). + static const IconData nearby_error_outlined = IconData(0xf207, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nearby_off</i> — material icon named "nearby off". + static const IconData nearby_off = IconData(0xe422, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nearby_off</i> — material icon named "nearby off" (sharp). + static const IconData nearby_off_sharp = IconData(0xeb1b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nearby_off</i> — material icon named "nearby off" (round). + static const IconData nearby_off_rounded = IconData(0xf8fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nearby_off</i> — material icon named "nearby off" (outlined). + static const IconData nearby_off_outlined = IconData(0xf208, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nest_cam_wired_stand</i> — material icon named "nest cam wired stand". + static const IconData nest_cam_wired_stand = IconData(0xf07af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nest_cam_wired_stand</i> — material icon named "nest cam wired stand" (sharp). + static const IconData nest_cam_wired_stand_sharp = IconData(0xf0757, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nest_cam_wired_stand</i> — material icon named "nest cam wired stand" (round). + static const IconData nest_cam_wired_stand_rounded = IconData( + 0xf0807, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">nest_cam_wired_stand</i> — material icon named "nest cam wired stand" (outlined). + static const IconData nest_cam_wired_stand_outlined = IconData( + 0xf06ff, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">network_cell</i> — material icon named "network cell". + static const IconData network_cell = IconData(0xe423, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_cell</i> — material icon named "network cell" (sharp). + static const IconData network_cell_sharp = IconData(0xeb1c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_cell</i> — material icon named "network cell" (round). + static const IconData network_cell_rounded = IconData(0xf8fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_cell</i> — material icon named "network cell" (outlined). + static const IconData network_cell_outlined = IconData(0xf209, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">network_check</i> — material icon named "network check". + static const IconData network_check = IconData(0xe424, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_check</i> — material icon named "network check" (sharp). + static const IconData network_check_sharp = IconData(0xeb1d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_check</i> — material icon named "network check" (round). + static const IconData network_check_rounded = IconData(0xf8fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_check</i> — material icon named "network check" (outlined). + static const IconData network_check_outlined = IconData(0xf20a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">network_locked</i> — material icon named "network locked". + static const IconData network_locked = IconData(0xe425, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_locked</i> — material icon named "network locked" (sharp). + static const IconData network_locked_sharp = IconData(0xeb1e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_locked</i> — material icon named "network locked" (round). + static const IconData network_locked_rounded = IconData(0xf8fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_locked</i> — material icon named "network locked" (outlined). + static const IconData network_locked_outlined = IconData(0xf20b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">network_ping</i> — material icon named "network ping". + static const IconData network_ping = IconData(0xf06bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_ping</i> — material icon named "network ping" (sharp). + static const IconData network_ping_sharp = IconData(0xf06b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_ping</i> — material icon named "network ping" (round). + static const IconData network_ping_rounded = IconData(0xf06cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_ping</i> — material icon named "network ping" (outlined). + static const IconData network_ping_outlined = IconData(0xf06a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">network_wifi</i> — material icon named "network wifi". + static const IconData network_wifi = IconData(0xe426, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_wifi</i> — material icon named "network wifi" (sharp). + static const IconData network_wifi_sharp = IconData(0xeb1f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_wifi</i> — material icon named "network wifi" (round). + static const IconData network_wifi_rounded = IconData(0xf8fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_wifi</i> — material icon named "network wifi" (outlined). + static const IconData network_wifi_outlined = IconData(0xf20c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">network_wifi_1_bar</i> — material icon named "network wifi 1 bar". + static const IconData network_wifi_1_bar = IconData(0xf07b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_wifi_1_bar</i> — material icon named "network wifi 1 bar" (sharp). + static const IconData network_wifi_1_bar_sharp = IconData(0xf0758, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_wifi_1_bar</i> — material icon named "network wifi 1 bar" (round). + static const IconData network_wifi_1_bar_rounded = IconData(0xf0808, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_wifi_1_bar</i> — material icon named "network wifi 1 bar" (outlined). + static const IconData network_wifi_1_bar_outlined = IconData( + 0xf0700, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">network_wifi_2_bar</i> — material icon named "network wifi 2 bar". + static const IconData network_wifi_2_bar = IconData(0xf07b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_wifi_2_bar</i> — material icon named "network wifi 2 bar" (sharp). + static const IconData network_wifi_2_bar_sharp = IconData(0xf0759, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_wifi_2_bar</i> — material icon named "network wifi 2 bar" (round). + static const IconData network_wifi_2_bar_rounded = IconData(0xf0809, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_wifi_2_bar</i> — material icon named "network wifi 2 bar" (outlined). + static const IconData network_wifi_2_bar_outlined = IconData( + 0xf0701, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">network_wifi_3_bar</i> — material icon named "network wifi 3 bar". + static const IconData network_wifi_3_bar = IconData(0xf07b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">network_wifi_3_bar</i> — material icon named "network wifi 3 bar" (sharp). + static const IconData network_wifi_3_bar_sharp = IconData(0xf075a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">network_wifi_3_bar</i> — material icon named "network wifi 3 bar" (round). + static const IconData network_wifi_3_bar_rounded = IconData(0xf080a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">network_wifi_3_bar</i> — material icon named "network wifi 3 bar" (outlined). + static const IconData network_wifi_3_bar_outlined = IconData( + 0xf0702, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">new_label</i> — material icon named "new label". + static const IconData new_label = IconData(0xe427, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">new_label</i> — material icon named "new label" (sharp). + static const IconData new_label_sharp = IconData(0xeb20, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">new_label</i> — material icon named "new label" (round). + static const IconData new_label_rounded = IconData(0xf8ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">new_label</i> — material icon named "new label" (outlined). + static const IconData new_label_outlined = IconData(0xf20d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">new_releases</i> — material icon named "new releases". + static const IconData new_releases = IconData(0xe428, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">new_releases</i> — material icon named "new releases" (sharp). + static const IconData new_releases_sharp = IconData(0xeb21, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">new_releases</i> — material icon named "new releases" (round). + static const IconData new_releases_rounded = IconData(0xf0000, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">new_releases</i> — material icon named "new releases" (outlined). + static const IconData new_releases_outlined = IconData(0xf20e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">newspaper</i> — material icon named "newspaper". + static const IconData newspaper = IconData(0xf0541, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">newspaper</i> — material icon named "newspaper" (sharp). + static const IconData newspaper_sharp = IconData(0xf044b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">newspaper</i> — material icon named "newspaper" (round). + static const IconData newspaper_rounded = IconData(0xf0358, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">newspaper</i> — material icon named "newspaper" (outlined). + static const IconData newspaper_outlined = IconData(0xf0639, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">next_plan</i> — material icon named "next plan". + static const IconData next_plan = IconData(0xe429, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">next_plan</i> — material icon named "next plan" (sharp). + static const IconData next_plan_sharp = IconData(0xeb22, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">next_plan</i> — material icon named "next plan" (round). + static const IconData next_plan_rounded = IconData(0xf0001, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">next_plan</i> — material icon named "next plan" (outlined). + static const IconData next_plan_outlined = IconData(0xf20f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">next_week</i> — material icon named "next week". + static const IconData next_week = IconData( + 0xe42a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">next_week</i> — material icon named "next week" (sharp). + static const IconData next_week_sharp = IconData( + 0xeb23, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">next_week</i> — material icon named "next week" (round). + static const IconData next_week_rounded = IconData( + 0xf0002, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">next_week</i> — material icon named "next week" (outlined). + static const IconData next_week_outlined = IconData( + 0xf210, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">nfc</i> — material icon named "nfc". + static const IconData nfc = IconData(0xe42b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nfc</i> — material icon named "nfc" (sharp). + static const IconData nfc_sharp = IconData(0xeb24, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nfc</i> — material icon named "nfc" (round). + static const IconData nfc_rounded = IconData(0xf0003, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nfc</i> — material icon named "nfc" (outlined). + static const IconData nfc_outlined = IconData(0xf211, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">night_shelter</i> — material icon named "night shelter". + static const IconData night_shelter = IconData(0xe42c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">night_shelter</i> — material icon named "night shelter" (sharp). + static const IconData night_shelter_sharp = IconData(0xeb25, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">night_shelter</i> — material icon named "night shelter" (round). + static const IconData night_shelter_rounded = IconData(0xf0004, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">night_shelter</i> — material icon named "night shelter" (outlined). + static const IconData night_shelter_outlined = IconData(0xf212, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nightlife</i> — material icon named "nightlife". + static const IconData nightlife = IconData(0xe42d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nightlife</i> — material icon named "nightlife" (sharp). + static const IconData nightlife_sharp = IconData(0xeb26, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nightlife</i> — material icon named "nightlife" (round). + static const IconData nightlife_rounded = IconData(0xf0005, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nightlife</i> — material icon named "nightlife" (outlined). + static const IconData nightlife_outlined = IconData(0xf213, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nightlight</i> — material icon named "nightlight". + static const IconData nightlight = IconData(0xe42e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nightlight</i> — material icon named "nightlight" (sharp). + static const IconData nightlight_sharp = IconData(0xeb28, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nightlight</i> — material icon named "nightlight" (round). + static const IconData nightlight_rounded = IconData(0xf0007, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nightlight</i> — material icon named "nightlight" (outlined). + static const IconData nightlight_outlined = IconData(0xf214, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nightlight_round</i> — material icon named "nightlight round". + static const IconData nightlight_round = IconData(0xe42f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nightlight_round</i> — material icon named "nightlight round" (sharp). + static const IconData nightlight_round_sharp = IconData(0xeb27, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nightlight_round</i> — material icon named "nightlight round" (round). + static const IconData nightlight_round_rounded = IconData(0xf0006, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nightlight_round</i> — material icon named "nightlight round" (outlined). + static const IconData nightlight_round_outlined = IconData(0xf215, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nights_stay</i> — material icon named "nights stay". + static const IconData nights_stay = IconData(0xe430, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nights_stay</i> — material icon named "nights stay" (sharp). + static const IconData nights_stay_sharp = IconData(0xeb29, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nights_stay</i> — material icon named "nights stay" (round). + static const IconData nights_stay_rounded = IconData(0xf0008, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nights_stay</i> — material icon named "nights stay" (outlined). + static const IconData nights_stay_outlined = IconData(0xf216, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_accounts</i> — material icon named "no accounts". + static const IconData no_accounts = IconData(0xe431, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_accounts</i> — material icon named "no accounts" (sharp). + static const IconData no_accounts_sharp = IconData(0xeb2a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_accounts</i> — material icon named "no accounts" (round). + static const IconData no_accounts_rounded = IconData(0xf0009, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_accounts</i> — material icon named "no accounts" (outlined). + static const IconData no_accounts_outlined = IconData(0xf217, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_adult_content</i> — material icon named "no adult content". + static const IconData no_adult_content = IconData(0xf07b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_adult_content</i> — material icon named "no adult content" (sharp). + static const IconData no_adult_content_sharp = IconData(0xf075b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_adult_content</i> — material icon named "no adult content" (round). + static const IconData no_adult_content_rounded = IconData(0xf080b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_adult_content</i> — material icon named "no adult content" (outlined). + static const IconData no_adult_content_outlined = IconData(0xf0703, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_backpack</i> — material icon named "no backpack". + static const IconData no_backpack = IconData(0xe432, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_backpack</i> — material icon named "no backpack" (sharp). + static const IconData no_backpack_sharp = IconData(0xeb2b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_backpack</i> — material icon named "no backpack" (round). + static const IconData no_backpack_rounded = IconData(0xf000a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_backpack</i> — material icon named "no backpack" (outlined). + static const IconData no_backpack_outlined = IconData(0xf218, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_cell</i> — material icon named "no cell". + static const IconData no_cell = IconData(0xe433, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_cell</i> — material icon named "no cell" (sharp). + static const IconData no_cell_sharp = IconData(0xeb2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_cell</i> — material icon named "no cell" (round). + static const IconData no_cell_rounded = IconData(0xf000b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_cell</i> — material icon named "no cell" (outlined). + static const IconData no_cell_outlined = IconData(0xf219, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_crash</i> — material icon named "no crash". + static const IconData no_crash = IconData(0xf07b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_crash</i> — material icon named "no crash" (sharp). + static const IconData no_crash_sharp = IconData(0xf075c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_crash</i> — material icon named "no crash" (round). + static const IconData no_crash_rounded = IconData(0xf080c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_crash</i> — material icon named "no crash" (outlined). + static const IconData no_crash_outlined = IconData(0xf0704, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_drinks</i> — material icon named "no drinks". + static const IconData no_drinks = IconData(0xe434, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_drinks</i> — material icon named "no drinks" (sharp). + static const IconData no_drinks_sharp = IconData(0xeb2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_drinks</i> — material icon named "no drinks" (round). + static const IconData no_drinks_rounded = IconData(0xf000c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_drinks</i> — material icon named "no drinks" (outlined). + static const IconData no_drinks_outlined = IconData(0xf21a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_encryption</i> — material icon named "no encryption". + static const IconData no_encryption = IconData(0xe435, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_encryption</i> — material icon named "no encryption" (sharp). + static const IconData no_encryption_sharp = IconData(0xeb2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_encryption</i> — material icon named "no encryption" (round). + static const IconData no_encryption_rounded = IconData(0xf000e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_encryption</i> — material icon named "no encryption" (outlined). + static const IconData no_encryption_outlined = IconData(0xf21c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_encryption_gmailerrorred</i> — material icon named "no encryption gmailerrorred". + static const IconData no_encryption_gmailerrorred = IconData(0xe436, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_encryption_gmailerrorred</i> — material icon named "no encryption gmailerrorred" (sharp). + static const IconData no_encryption_gmailerrorred_sharp = IconData( + 0xeb2e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">no_encryption_gmailerrorred</i> — material icon named "no encryption gmailerrorred" (round). + static const IconData no_encryption_gmailerrorred_rounded = IconData( + 0xf000d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">no_encryption_gmailerrorred</i> — material icon named "no encryption gmailerrorred" (outlined). + static const IconData no_encryption_gmailerrorred_outlined = IconData( + 0xf21b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">no_flash</i> — material icon named "no flash". + static const IconData no_flash = IconData(0xe437, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_flash</i> — material icon named "no flash" (sharp). + static const IconData no_flash_sharp = IconData(0xeb30, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_flash</i> — material icon named "no flash" (round). + static const IconData no_flash_rounded = IconData(0xf000f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_flash</i> — material icon named "no flash" (outlined). + static const IconData no_flash_outlined = IconData(0xf21d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_food</i> — material icon named "no food". + static const IconData no_food = IconData(0xe438, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_food</i> — material icon named "no food" (sharp). + static const IconData no_food_sharp = IconData(0xeb31, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_food</i> — material icon named "no food" (round). + static const IconData no_food_rounded = IconData(0xf0010, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_food</i> — material icon named "no food" (outlined). + static const IconData no_food_outlined = IconData(0xf21e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_luggage</i> — material icon named "no luggage". + static const IconData no_luggage = IconData(0xe439, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_luggage</i> — material icon named "no luggage" (sharp). + static const IconData no_luggage_sharp = IconData(0xeb32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_luggage</i> — material icon named "no luggage" (round). + static const IconData no_luggage_rounded = IconData(0xf0011, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_luggage</i> — material icon named "no luggage" (outlined). + static const IconData no_luggage_outlined = IconData(0xf21f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_meals</i> — material icon named "no meals". + static const IconData no_meals = IconData(0xe43a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_meals</i> — material icon named "no meals" (sharp). + static const IconData no_meals_sharp = IconData(0xeb33, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_meals</i> — material icon named "no meals" (round). + static const IconData no_meals_rounded = IconData(0xf0012, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_meals</i> — material icon named "no meals" (outlined). + static const IconData no_meals_outlined = IconData(0xf220, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_meals_ouline</i> — material icon named "no meals ouline". + static const IconData no_meals_ouline = IconData(0xe43b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_meeting_room</i> — material icon named "no meeting room". + static const IconData no_meeting_room = IconData(0xe43c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_meeting_room</i> — material icon named "no meeting room" (sharp). + static const IconData no_meeting_room_sharp = IconData(0xeb34, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_meeting_room</i> — material icon named "no meeting room" (round). + static const IconData no_meeting_room_rounded = IconData(0xf0013, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_meeting_room</i> — material icon named "no meeting room" (outlined). + static const IconData no_meeting_room_outlined = IconData(0xf221, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_photography</i> — material icon named "no photography". + static const IconData no_photography = IconData(0xe43d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_photography</i> — material icon named "no photography" (sharp). + static const IconData no_photography_sharp = IconData(0xeb35, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_photography</i> — material icon named "no photography" (round). + static const IconData no_photography_rounded = IconData(0xf0014, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_photography</i> — material icon named "no photography" (outlined). + static const IconData no_photography_outlined = IconData(0xf222, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_sim</i> — material icon named "no sim". + static const IconData no_sim = IconData(0xe43e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_sim</i> — material icon named "no sim" (sharp). + static const IconData no_sim_sharp = IconData(0xeb36, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_sim</i> — material icon named "no sim" (round). + static const IconData no_sim_rounded = IconData(0xf0015, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_sim</i> — material icon named "no sim" (outlined). + static const IconData no_sim_outlined = IconData(0xf223, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_stroller</i> — material icon named "no stroller". + static const IconData no_stroller = IconData(0xe43f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_stroller</i> — material icon named "no stroller" (sharp). + static const IconData no_stroller_sharp = IconData(0xeb37, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_stroller</i> — material icon named "no stroller" (round). + static const IconData no_stroller_rounded = IconData(0xf0016, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_stroller</i> — material icon named "no stroller" (outlined). + static const IconData no_stroller_outlined = IconData(0xf224, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">no_transfer</i> — material icon named "no transfer". + static const IconData no_transfer = IconData(0xe440, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">no_transfer</i> — material icon named "no transfer" (sharp). + static const IconData no_transfer_sharp = IconData(0xeb38, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">no_transfer</i> — material icon named "no transfer" (round). + static const IconData no_transfer_rounded = IconData(0xf0017, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">no_transfer</i> — material icon named "no transfer" (outlined). + static const IconData no_transfer_outlined = IconData(0xf225, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">noise_aware</i> — material icon named "noise aware". + static const IconData noise_aware = IconData(0xf07b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">noise_aware</i> — material icon named "noise aware" (sharp). + static const IconData noise_aware_sharp = IconData(0xf075d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">noise_aware</i> — material icon named "noise aware" (round). + static const IconData noise_aware_rounded = IconData(0xf080d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">noise_aware</i> — material icon named "noise aware" (outlined). + static const IconData noise_aware_outlined = IconData(0xf0705, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">noise_control_off</i> — material icon named "noise control off". + static const IconData noise_control_off = IconData(0xf07b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">noise_control_off</i> — material icon named "noise control off" (sharp). + static const IconData noise_control_off_sharp = IconData(0xf075e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">noise_control_off</i> — material icon named "noise control off" (round). + static const IconData noise_control_off_rounded = IconData(0xf080e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">noise_control_off</i> — material icon named "noise control off" (outlined). + static const IconData noise_control_off_outlined = IconData(0xf0706, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">nordic_walking</i> — material icon named "nordic walking". + static const IconData nordic_walking = IconData(0xe441, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">nordic_walking</i> — material icon named "nordic walking" (sharp). + static const IconData nordic_walking_sharp = IconData(0xeb39, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">nordic_walking</i> — material icon named "nordic walking" (round). + static const IconData nordic_walking_rounded = IconData(0xf0018, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">nordic_walking</i> — material icon named "nordic walking" (outlined). + static const IconData nordic_walking_outlined = IconData(0xf226, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">north</i> — material icon named "north". + static const IconData north = IconData(0xe442, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">north</i> — material icon named "north" (sharp). + static const IconData north_sharp = IconData(0xeb3b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">north</i> — material icon named "north" (round). + static const IconData north_rounded = IconData(0xf001a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">north</i> — material icon named "north" (outlined). + static const IconData north_outlined = IconData(0xf228, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">north_east</i> — material icon named "north east". + static const IconData north_east = IconData(0xe443, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">north_east</i> — material icon named "north east" (sharp). + static const IconData north_east_sharp = IconData(0xeb3a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">north_east</i> — material icon named "north east" (round). + static const IconData north_east_rounded = IconData(0xf0019, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">north_east</i> — material icon named "north east" (outlined). + static const IconData north_east_outlined = IconData(0xf227, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">north_west</i> — material icon named "north west". + static const IconData north_west = IconData(0xe444, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">north_west</i> — material icon named "north west" (sharp). + static const IconData north_west_sharp = IconData(0xeb3c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">north_west</i> — material icon named "north west" (round). + static const IconData north_west_rounded = IconData(0xf001b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">north_west</i> — material icon named "north west" (outlined). + static const IconData north_west_outlined = IconData(0xf229, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">not_accessible</i> — material icon named "not accessible". + static const IconData not_accessible = IconData(0xe445, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">not_accessible</i> — material icon named "not accessible" (sharp). + static const IconData not_accessible_sharp = IconData(0xeb3d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">not_accessible</i> — material icon named "not accessible" (round). + static const IconData not_accessible_rounded = IconData(0xf001c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">not_accessible</i> — material icon named "not accessible" (outlined). + static const IconData not_accessible_outlined = IconData(0xf22a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">not_interested</i> — material icon named "not interested". + static const IconData not_interested = IconData(0xe446, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">not_interested</i> — material icon named "not interested" (sharp). + static const IconData not_interested_sharp = IconData(0xeb3e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">not_interested</i> — material icon named "not interested" (round). + static const IconData not_interested_rounded = IconData(0xf001d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">not_interested</i> — material icon named "not interested" (outlined). + static const IconData not_interested_outlined = IconData(0xf22b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">not_listed_location</i> — material icon named "not listed location". + static const IconData not_listed_location = IconData(0xe447, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">not_listed_location</i> — material icon named "not listed location" (sharp). + static const IconData not_listed_location_sharp = IconData(0xeb3f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">not_listed_location</i> — material icon named "not listed location" (round). + static const IconData not_listed_location_rounded = IconData( + 0xf001e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">not_listed_location</i> — material icon named "not listed location" (outlined). + static const IconData not_listed_location_outlined = IconData( + 0xf22c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">not_started</i> — material icon named "not started". + static const IconData not_started = IconData(0xe448, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">not_started</i> — material icon named "not started" (sharp). + static const IconData not_started_sharp = IconData(0xeb40, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">not_started</i> — material icon named "not started" (round). + static const IconData not_started_rounded = IconData(0xf001f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">not_started</i> — material icon named "not started" (outlined). + static const IconData not_started_outlined = IconData(0xf22d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">note</i> — material icon named "note". + static const IconData note = IconData( + 0xe449, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">note</i> — material icon named "note" (sharp). + static const IconData note_sharp = IconData( + 0xeb43, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">note</i> — material icon named "note" (round). + static const IconData note_rounded = IconData( + 0xf0022, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">note</i> — material icon named "note" (outlined). + static const IconData note_outlined = IconData( + 0xf230, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">note_add</i> — material icon named "note add". + static const IconData note_add = IconData(0xe44a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">note_add</i> — material icon named "note add" (sharp). + static const IconData note_add_sharp = IconData(0xeb41, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">note_add</i> — material icon named "note add" (round). + static const IconData note_add_rounded = IconData(0xf0020, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">note_add</i> — material icon named "note add" (outlined). + static const IconData note_add_outlined = IconData(0xf22e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">note_alt</i> — material icon named "note alt". + static const IconData note_alt = IconData( + 0xe44b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">note_alt</i> — material icon named "note alt" (sharp). + static const IconData note_alt_sharp = IconData( + 0xeb42, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">note_alt</i> — material icon named "note alt" (round). + static const IconData note_alt_rounded = IconData( + 0xf0021, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">note_alt</i> — material icon named "note alt" (outlined). + static const IconData note_alt_outlined = IconData( + 0xf22f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">notes</i> — material icon named "notes". + static const IconData notes = IconData(0xe44c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notes</i> — material icon named "notes" (sharp). + static const IconData notes_sharp = IconData(0xeb44, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notes</i> — material icon named "notes" (round). + static const IconData notes_rounded = IconData(0xf0023, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">notes</i> — material icon named "notes" (outlined). + static const IconData notes_outlined = IconData(0xf231, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">notification_add</i> — material icon named "notification add". + static const IconData notification_add = IconData(0xe44d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notification_add</i> — material icon named "notification add" (sharp). + static const IconData notification_add_sharp = IconData(0xeb45, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notification_add</i> — material icon named "notification add" (round). + static const IconData notification_add_rounded = IconData(0xf0024, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">notification_add</i> — material icon named "notification add" (outlined). + static const IconData notification_add_outlined = IconData(0xf232, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">notification_important</i> — material icon named "notification important". + static const IconData notification_important = IconData(0xe44e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notification_important</i> — material icon named "notification important" (sharp). + static const IconData notification_important_sharp = IconData( + 0xeb46, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">notification_important</i> — material icon named "notification important" (round). + static const IconData notification_important_rounded = IconData( + 0xf0025, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">notification_important</i> — material icon named "notification important" (outlined). + static const IconData notification_important_outlined = IconData( + 0xf233, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">notifications</i> — material icon named "notifications". + static const IconData notifications = IconData(0xe44f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notifications</i> — material icon named "notifications" (sharp). + static const IconData notifications_sharp = IconData(0xeb4b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notifications</i> — material icon named "notifications" (round). + static const IconData notifications_rounded = IconData(0xf002a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">notifications</i> — material icon named "notifications" (outlined). + static const IconData notifications_outlined = IconData(0xf237, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">notifications_active</i> — material icon named "notifications active". + static const IconData notifications_active = IconData(0xe450, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notifications_active</i> — material icon named "notifications active" (sharp). + static const IconData notifications_active_sharp = IconData(0xeb47, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notifications_active</i> — material icon named "notifications active" (round). + static const IconData notifications_active_rounded = IconData( + 0xf0026, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">notifications_active</i> — material icon named "notifications active" (outlined). + static const IconData notifications_active_outlined = IconData( + 0xf234, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">notifications_none</i> — material icon named "notifications none". + static const IconData notifications_none = IconData(0xe451, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notifications_none</i> — material icon named "notifications none" (sharp). + static const IconData notifications_none_sharp = IconData(0xeb48, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notifications_none</i> — material icon named "notifications none" (round). + static const IconData notifications_none_rounded = IconData(0xf0027, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">notifications_none</i> — material icon named "notifications none" (outlined). + static const IconData notifications_none_outlined = IconData(0xf235, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">notifications_off</i> — material icon named "notifications off". + static const IconData notifications_off = IconData(0xe452, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notifications_off</i> — material icon named "notifications off" (sharp). + static const IconData notifications_off_sharp = IconData(0xeb49, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notifications_off</i> — material icon named "notifications off" (round). + static const IconData notifications_off_rounded = IconData(0xf0028, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">notifications_off</i> — material icon named "notifications off" (outlined). + static const IconData notifications_off_outlined = IconData(0xf236, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">notifications_on</i> — material icon named "notifications on". + static const IconData notifications_on = IconData(0xe450, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notifications_on</i> — material icon named "notifications on" (sharp). + static const IconData notifications_on_sharp = IconData(0xeb47, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notifications_on</i> — material icon named "notifications on" (round). + static const IconData notifications_on_rounded = IconData(0xf0026, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">notifications_on</i> — material icon named "notifications on" (outlined). + static const IconData notifications_on_outlined = IconData(0xf234, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">notifications_paused</i> — material icon named "notifications paused". + static const IconData notifications_paused = IconData(0xe453, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">notifications_paused</i> — material icon named "notifications paused" (sharp). + static const IconData notifications_paused_sharp = IconData(0xeb4a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">notifications_paused</i> — material icon named "notifications paused" (round). + static const IconData notifications_paused_rounded = IconData( + 0xf0029, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">notifications_paused</i> — material icon named "notifications paused" (outlined). + static const IconData notifications_paused_outlined = IconData( + 0xf238, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">now_wallpaper</i> — material icon named "now wallpaper". + static const IconData now_wallpaper = IconData(0xe6ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">now_wallpaper</i> — material icon named "now wallpaper" (sharp). + static const IconData now_wallpaper_sharp = IconData(0xedc0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">now_wallpaper</i> — material icon named "now wallpaper" (round). + static const IconData now_wallpaper_rounded = IconData(0xf029f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">now_wallpaper</i> — material icon named "now wallpaper" (outlined). + static const IconData now_wallpaper_outlined = IconData(0xf4ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">now_widgets</i> — material icon named "now widgets". + static const IconData now_widgets = IconData(0xe6e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">now_widgets</i> — material icon named "now widgets" (sharp). + static const IconData now_widgets_sharp = IconData(0xedda, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">now_widgets</i> — material icon named "now widgets" (round). + static const IconData now_widgets_rounded = IconData(0xf02b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">now_widgets</i> — material icon named "now widgets" (outlined). + static const IconData now_widgets_outlined = IconData(0xf4c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">numbers</i> — material icon named "numbers". + static const IconData numbers = IconData(0xf0542, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">numbers</i> — material icon named "numbers" (sharp). + static const IconData numbers_sharp = IconData(0xf044c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">numbers</i> — material icon named "numbers" (round). + static const IconData numbers_rounded = IconData(0xf0359, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">numbers</i> — material icon named "numbers" (outlined). + static const IconData numbers_outlined = IconData(0xf063a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">offline_bolt</i> — material icon named "offline bolt". + static const IconData offline_bolt = IconData(0xe454, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">offline_bolt</i> — material icon named "offline bolt" (sharp). + static const IconData offline_bolt_sharp = IconData(0xeb4c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">offline_bolt</i> — material icon named "offline bolt" (round). + static const IconData offline_bolt_rounded = IconData(0xf002b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">offline_bolt</i> — material icon named "offline bolt" (outlined). + static const IconData offline_bolt_outlined = IconData(0xf239, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">offline_pin</i> — material icon named "offline pin". + static const IconData offline_pin = IconData(0xe455, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">offline_pin</i> — material icon named "offline pin" (sharp). + static const IconData offline_pin_sharp = IconData(0xeb4d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">offline_pin</i> — material icon named "offline pin" (round). + static const IconData offline_pin_rounded = IconData(0xf002c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">offline_pin</i> — material icon named "offline pin" (outlined). + static const IconData offline_pin_outlined = IconData(0xf23a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">offline_share</i> — material icon named "offline share". + static const IconData offline_share = IconData(0xe456, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">offline_share</i> — material icon named "offline share" (sharp). + static const IconData offline_share_sharp = IconData(0xeb4e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">offline_share</i> — material icon named "offline share" (round). + static const IconData offline_share_rounded = IconData(0xf002d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">offline_share</i> — material icon named "offline share" (outlined). + static const IconData offline_share_outlined = IconData(0xf23b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">oil_barrel</i> — material icon named "oil barrel". + static const IconData oil_barrel = IconData(0xf07b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">oil_barrel</i> — material icon named "oil barrel" (sharp). + static const IconData oil_barrel_sharp = IconData(0xf075f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">oil_barrel</i> — material icon named "oil barrel" (round). + static const IconData oil_barrel_rounded = IconData(0xf080f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">oil_barrel</i> — material icon named "oil barrel" (outlined). + static const IconData oil_barrel_outlined = IconData(0xf0707, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">on_device_training</i> — material icon named "on device training". + static const IconData on_device_training = IconData(0xf07b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">on_device_training</i> — material icon named "on device training" (sharp). + static const IconData on_device_training_sharp = IconData(0xf0760, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">on_device_training</i> — material icon named "on device training" (round). + static const IconData on_device_training_rounded = IconData(0xf0810, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">on_device_training</i> — material icon named "on device training" (outlined). + static const IconData on_device_training_outlined = IconData( + 0xf0708, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">ondemand_video</i> — material icon named "ondemand video". + static const IconData ondemand_video = IconData(0xe457, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ondemand_video</i> — material icon named "ondemand video" (sharp). + static const IconData ondemand_video_sharp = IconData(0xeb4f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ondemand_video</i> — material icon named "ondemand video" (round). + static const IconData ondemand_video_rounded = IconData(0xf002e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ondemand_video</i> — material icon named "ondemand video" (outlined). + static const IconData ondemand_video_outlined = IconData(0xf23c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">online_prediction</i> — material icon named "online prediction". + static const IconData online_prediction = IconData(0xe458, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">online_prediction</i> — material icon named "online prediction" (sharp). + static const IconData online_prediction_sharp = IconData(0xeb50, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">online_prediction</i> — material icon named "online prediction" (round). + static const IconData online_prediction_rounded = IconData(0xf002f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">online_prediction</i> — material icon named "online prediction" (outlined). + static const IconData online_prediction_outlined = IconData(0xf23d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">opacity</i> — material icon named "opacity". + static const IconData opacity = IconData(0xe459, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">opacity</i> — material icon named "opacity" (sharp). + static const IconData opacity_sharp = IconData(0xeb51, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">opacity</i> — material icon named "opacity" (round). + static const IconData opacity_rounded = IconData(0xf0030, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">opacity</i> — material icon named "opacity" (outlined). + static const IconData opacity_outlined = IconData(0xf23e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">open_in_browser</i> — material icon named "open in browser". + static const IconData open_in_browser = IconData(0xe45a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">open_in_browser</i> — material icon named "open in browser" (sharp). + static const IconData open_in_browser_sharp = IconData(0xeb52, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">open_in_browser</i> — material icon named "open in browser" (round). + static const IconData open_in_browser_rounded = IconData(0xf0031, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">open_in_browser</i> — material icon named "open in browser" (outlined). + static const IconData open_in_browser_outlined = IconData(0xf23f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">open_in_full</i> — material icon named "open in full". + static const IconData open_in_full = IconData(0xe45b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">open_in_full</i> — material icon named "open in full" (sharp). + static const IconData open_in_full_sharp = IconData(0xeb53, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">open_in_full</i> — material icon named "open in full" (round). + static const IconData open_in_full_rounded = IconData(0xf0032, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">open_in_full</i> — material icon named "open in full" (outlined). + static const IconData open_in_full_outlined = IconData(0xf240, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">open_in_new</i> — material icon named "open in new". + static const IconData open_in_new = IconData( + 0xe45c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">open_in_new</i> — material icon named "open in new" (sharp). + static const IconData open_in_new_sharp = IconData( + 0xeb55, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">open_in_new</i> — material icon named "open in new" (round). + static const IconData open_in_new_rounded = IconData( + 0xf0034, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">open_in_new</i> — material icon named "open in new" (outlined). + static const IconData open_in_new_outlined = IconData( + 0xf242, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">open_in_new_off</i> — material icon named "open in new off". + static const IconData open_in_new_off = IconData(0xe45d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">open_in_new_off</i> — material icon named "open in new off" (sharp). + static const IconData open_in_new_off_sharp = IconData(0xeb54, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">open_in_new_off</i> — material icon named "open in new off" (round). + static const IconData open_in_new_off_rounded = IconData(0xf0033, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">open_in_new_off</i> — material icon named "open in new off" (outlined). + static const IconData open_in_new_off_outlined = IconData(0xf241, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">open_with</i> — material icon named "open with". + static const IconData open_with = IconData(0xe45e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">open_with</i> — material icon named "open with" (sharp). + static const IconData open_with_sharp = IconData(0xeb56, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">open_with</i> — material icon named "open with" (round). + static const IconData open_with_rounded = IconData(0xf0035, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">open_with</i> — material icon named "open with" (outlined). + static const IconData open_with_outlined = IconData(0xf243, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">other_houses</i> — material icon named "other houses". + static const IconData other_houses = IconData(0xe45f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">other_houses</i> — material icon named "other houses" (sharp). + static const IconData other_houses_sharp = IconData(0xeb57, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">other_houses</i> — material icon named "other houses" (round). + static const IconData other_houses_rounded = IconData(0xf0036, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">other_houses</i> — material icon named "other houses" (outlined). + static const IconData other_houses_outlined = IconData(0xf244, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outbond</i> — material icon named "outbond". + static const IconData outbond = IconData(0xe460, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">outbond</i> — material icon named "outbond" (sharp). + static const IconData outbond_sharp = IconData(0xeb58, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">outbond</i> — material icon named "outbond" (round). + static const IconData outbond_rounded = IconData(0xf0037, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">outbond</i> — material icon named "outbond" (outlined). + static const IconData outbond_outlined = IconData(0xf245, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outbound</i> — material icon named "outbound". + static const IconData outbound = IconData(0xe461, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">outbound</i> — material icon named "outbound" (sharp). + static const IconData outbound_sharp = IconData(0xeb59, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">outbound</i> — material icon named "outbound" (round). + static const IconData outbound_rounded = IconData(0xf0038, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">outbound</i> — material icon named "outbound" (outlined). + static const IconData outbound_outlined = IconData(0xf246, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outbox</i> — material icon named "outbox". + static const IconData outbox = IconData(0xe462, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">outbox</i> — material icon named "outbox" (sharp). + static const IconData outbox_sharp = IconData(0xeb5a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">outbox</i> — material icon named "outbox" (round). + static const IconData outbox_rounded = IconData(0xf0039, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">outbox</i> — material icon named "outbox" (outlined). + static const IconData outbox_outlined = IconData(0xf247, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outdoor_grill</i> — material icon named "outdoor grill". + static const IconData outdoor_grill = IconData(0xe463, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">outdoor_grill</i> — material icon named "outdoor grill" (sharp). + static const IconData outdoor_grill_sharp = IconData(0xeb5b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">outdoor_grill</i> — material icon named "outdoor grill" (round). + static const IconData outdoor_grill_rounded = IconData(0xf003a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">outdoor_grill</i> — material icon named "outdoor grill" (outlined). + static const IconData outdoor_grill_outlined = IconData(0xf248, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outgoing_mail</i> — material icon named "outgoing mail". + static const IconData outgoing_mail = IconData(0xe464, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outlet</i> — material icon named "outlet". + static const IconData outlet = IconData(0xe465, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">outlet</i> — material icon named "outlet" (sharp). + static const IconData outlet_sharp = IconData(0xeb5c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">outlet</i> — material icon named "outlet" (round). + static const IconData outlet_rounded = IconData(0xf003b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">outlet</i> — material icon named "outlet" (outlined). + static const IconData outlet_outlined = IconData(0xf249, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">outlined_flag</i> — material icon named "outlined flag". + static const IconData outlined_flag = IconData(0xe466, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">outlined_flag</i> — material icon named "outlined flag" (sharp). + static const IconData outlined_flag_sharp = IconData(0xeb5d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">outlined_flag</i> — material icon named "outlined flag" (round). + static const IconData outlined_flag_rounded = IconData(0xf003c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">outlined_flag</i> — material icon named "outlined flag" (outlined). + static const IconData outlined_flag_outlined = IconData(0xf24a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">output</i> — material icon named "output". + static const IconData output = IconData(0xf0543, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">output</i> — material icon named "output" (sharp). + static const IconData output_sharp = IconData(0xf044d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">output</i> — material icon named "output" (round). + static const IconData output_rounded = IconData(0xf035a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">output</i> — material icon named "output" (outlined). + static const IconData output_outlined = IconData(0xf063b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">padding</i> — material icon named "padding". + static const IconData padding = IconData(0xe467, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">padding</i> — material icon named "padding" (sharp). + static const IconData padding_sharp = IconData(0xeb5e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">padding</i> — material icon named "padding" (round). + static const IconData padding_rounded = IconData(0xf003d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">padding</i> — material icon named "padding" (outlined). + static const IconData padding_outlined = IconData(0xf24b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pages</i> — material icon named "pages". + static const IconData pages = IconData(0xe468, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pages</i> — material icon named "pages" (sharp). + static const IconData pages_sharp = IconData(0xeb5f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pages</i> — material icon named "pages" (round). + static const IconData pages_rounded = IconData(0xf003e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pages</i> — material icon named "pages" (outlined). + static const IconData pages_outlined = IconData(0xf24c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pageview</i> — material icon named "pageview". + static const IconData pageview = IconData(0xe469, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pageview</i> — material icon named "pageview" (sharp). + static const IconData pageview_sharp = IconData(0xeb60, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pageview</i> — material icon named "pageview" (round). + static const IconData pageview_rounded = IconData(0xf003f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pageview</i> — material icon named "pageview" (outlined). + static const IconData pageview_outlined = IconData(0xf24d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">paid</i> — material icon named "paid". + static const IconData paid = IconData(0xe46a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">paid</i> — material icon named "paid" (sharp). + static const IconData paid_sharp = IconData(0xeb61, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">paid</i> — material icon named "paid" (round). + static const IconData paid_rounded = IconData(0xf0040, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">paid</i> — material icon named "paid" (outlined). + static const IconData paid_outlined = IconData(0xf24e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">palette</i> — material icon named "palette". + static const IconData palette = IconData(0xe46b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">palette</i> — material icon named "palette" (sharp). + static const IconData palette_sharp = IconData(0xeb62, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">palette</i> — material icon named "palette" (round). + static const IconData palette_rounded = IconData(0xf0041, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">palette</i> — material icon named "palette" (outlined). + static const IconData palette_outlined = IconData(0xf24f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pallet</i> — material icon named "pallet". + static const IconData pallet = IconData(0xf086f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pan_tool</i> — material icon named "pan tool". + static const IconData pan_tool = IconData(0xe46c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pan_tool</i> — material icon named "pan tool" (sharp). + static const IconData pan_tool_sharp = IconData(0xeb63, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pan_tool</i> — material icon named "pan tool" (round). + static const IconData pan_tool_rounded = IconData(0xf0042, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pan_tool</i> — material icon named "pan tool" (outlined). + static const IconData pan_tool_outlined = IconData(0xf250, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pan_tool_alt</i> — material icon named "pan tool alt". + static const IconData pan_tool_alt = IconData(0xf0544, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pan_tool_alt</i> — material icon named "pan tool alt" (sharp). + static const IconData pan_tool_alt_sharp = IconData(0xf044e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pan_tool_alt</i> — material icon named "pan tool alt" (round). + static const IconData pan_tool_alt_rounded = IconData(0xf035b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pan_tool_alt</i> — material icon named "pan tool alt" (outlined). + static const IconData pan_tool_alt_outlined = IconData(0xf063c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">panorama</i> — material icon named "panorama". + static const IconData panorama = IconData(0xe46d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama</i> — material icon named "panorama" (sharp). + static const IconData panorama_sharp = IconData(0xeb69, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama</i> — material icon named "panorama" (round). + static const IconData panorama_rounded = IconData(0xf0048, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">panorama</i> — material icon named "panorama" (outlined). + static const IconData panorama_outlined = IconData(0xf254, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">panorama_fish_eye</i> — material icon named "panorama fish eye". + static const IconData panorama_fish_eye = IconData(0xe46e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_fish_eye</i> — material icon named "panorama fish eye" (sharp). + static const IconData panorama_fish_eye_sharp = IconData(0xeb64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama_fish_eye</i> — material icon named "panorama fish eye" (round). + static const IconData panorama_fish_eye_rounded = IconData(0xf0043, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">panorama_fish_eye</i> — material icon named "panorama fish eye" (outlined). + static const IconData panorama_fish_eye_outlined = IconData(0xf251, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">panorama_fisheye</i> — material icon named "panorama fisheye". + static const IconData panorama_fisheye = IconData(0xe46e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_fisheye</i> — material icon named "panorama fisheye" (sharp). + static const IconData panorama_fisheye_sharp = IconData(0xeb64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama_fisheye</i> — material icon named "panorama fisheye" (round). + static const IconData panorama_fisheye_rounded = IconData(0xf0043, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">panorama_fisheye</i> — material icon named "panorama fisheye" (outlined). + static const IconData panorama_fisheye_outlined = IconData(0xf251, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">panorama_horizontal</i> — material icon named "panorama horizontal". + static const IconData panorama_horizontal = IconData(0xe46f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_horizontal</i> — material icon named "panorama horizontal" (sharp). + static const IconData panorama_horizontal_sharp = IconData(0xeb66, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama_horizontal</i> — material icon named "panorama horizontal" (round). + static const IconData panorama_horizontal_rounded = IconData( + 0xf0044, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_horizontal</i> — material icon named "panorama horizontal" (outlined). + static const IconData panorama_horizontal_outlined = IconData( + 0xf252, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">panorama_horizontal_select</i> — material icon named "panorama horizontal select". + static const IconData panorama_horizontal_select = IconData(0xe470, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_horizontal_select</i> — material icon named "panorama horizontal select" (sharp). + static const IconData panorama_horizontal_select_sharp = IconData( + 0xeb65, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">panorama_horizontal_select</i> — material icon named "panorama horizontal select" (round). + static const IconData panorama_horizontal_select_rounded = IconData( + 0xf0045, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_horizontal_select</i> — material icon named "panorama horizontal select" (outlined). + static const IconData panorama_horizontal_select_outlined = IconData( + 0xf253, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">panorama_photosphere</i> — material icon named "panorama photosphere". + static const IconData panorama_photosphere = IconData(0xe471, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_photosphere</i> — material icon named "panorama photosphere" (sharp). + static const IconData panorama_photosphere_sharp = IconData(0xeb68, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama_photosphere</i> — material icon named "panorama photosphere" (round). + static const IconData panorama_photosphere_rounded = IconData( + 0xf0046, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_photosphere</i> — material icon named "panorama photosphere" (outlined). + static const IconData panorama_photosphere_outlined = IconData( + 0xf255, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">panorama_photosphere_select</i> — material icon named "panorama photosphere select". + static const IconData panorama_photosphere_select = IconData(0xe472, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_photosphere_select</i> — material icon named "panorama photosphere select" (sharp). + static const IconData panorama_photosphere_select_sharp = IconData( + 0xeb67, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">panorama_photosphere_select</i> — material icon named "panorama photosphere select" (round). + static const IconData panorama_photosphere_select_rounded = IconData( + 0xf0047, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_photosphere_select</i> — material icon named "panorama photosphere select" (outlined). + static const IconData panorama_photosphere_select_outlined = IconData( + 0xf256, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">panorama_vertical</i> — material icon named "panorama vertical". + static const IconData panorama_vertical = IconData(0xe473, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_vertical</i> — material icon named "panorama vertical" (sharp). + static const IconData panorama_vertical_sharp = IconData(0xeb6b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama_vertical</i> — material icon named "panorama vertical" (round). + static const IconData panorama_vertical_rounded = IconData(0xf0049, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">panorama_vertical</i> — material icon named "panorama vertical" (outlined). + static const IconData panorama_vertical_outlined = IconData(0xf257, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">panorama_vertical_select</i> — material icon named "panorama vertical select". + static const IconData panorama_vertical_select = IconData(0xe474, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_vertical_select</i> — material icon named "panorama vertical select" (sharp). + static const IconData panorama_vertical_select_sharp = IconData( + 0xeb6a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">panorama_vertical_select</i> — material icon named "panorama vertical select" (round). + static const IconData panorama_vertical_select_rounded = IconData( + 0xf004a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_vertical_select</i> — material icon named "panorama vertical select" (outlined). + static const IconData panorama_vertical_select_outlined = IconData( + 0xf258, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">panorama_wide_angle</i> — material icon named "panorama wide angle". + static const IconData panorama_wide_angle = IconData(0xe475, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_wide_angle</i> — material icon named "panorama wide angle" (sharp). + static const IconData panorama_wide_angle_sharp = IconData(0xeb6d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">panorama_wide_angle</i> — material icon named "panorama wide angle" (round). + static const IconData panorama_wide_angle_rounded = IconData( + 0xf004b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_wide_angle</i> — material icon named "panorama wide angle" (outlined). + static const IconData panorama_wide_angle_outlined = IconData( + 0xf259, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">panorama_wide_angle_select</i> — material icon named "panorama wide angle select". + static const IconData panorama_wide_angle_select = IconData(0xe476, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">panorama_wide_angle_select</i> — material icon named "panorama wide angle select" (sharp). + static const IconData panorama_wide_angle_select_sharp = IconData( + 0xeb6c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">panorama_wide_angle_select</i> — material icon named "panorama wide angle select" (round). + static const IconData panorama_wide_angle_select_rounded = IconData( + 0xf004c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">panorama_wide_angle_select</i> — material icon named "panorama wide angle select" (outlined). + static const IconData panorama_wide_angle_select_outlined = IconData( + 0xf25a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">paragliding</i> — material icon named "paragliding". + static const IconData paragliding = IconData(0xe477, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">paragliding</i> — material icon named "paragliding" (sharp). + static const IconData paragliding_sharp = IconData(0xeb6e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">paragliding</i> — material icon named "paragliding" (round). + static const IconData paragliding_rounded = IconData(0xf004d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">paragliding</i> — material icon named "paragliding" (outlined). + static const IconData paragliding_outlined = IconData(0xf25b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">park</i> — material icon named "park". + static const IconData park = IconData(0xe478, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">park</i> — material icon named "park" (sharp). + static const IconData park_sharp = IconData(0xeb6f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">park</i> — material icon named "park" (round). + static const IconData park_rounded = IconData(0xf004e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">park</i> — material icon named "park" (outlined). + static const IconData park_outlined = IconData(0xf25c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">party_mode</i> — material icon named "party mode". + static const IconData party_mode = IconData(0xe479, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">party_mode</i> — material icon named "party mode" (sharp). + static const IconData party_mode_sharp = IconData(0xeb70, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">party_mode</i> — material icon named "party mode" (round). + static const IconData party_mode_rounded = IconData(0xf004f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">party_mode</i> — material icon named "party mode" (outlined). + static const IconData party_mode_outlined = IconData(0xf25d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">password</i> — material icon named "password". + static const IconData password = IconData(0xe47a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">password</i> — material icon named "password" (sharp). + static const IconData password_sharp = IconData(0xeb71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">password</i> — material icon named "password" (round). + static const IconData password_rounded = IconData(0xf0050, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">password</i> — material icon named "password" (outlined). + static const IconData password_outlined = IconData(0xf25e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">paste</i> — material icon named "paste". + static const IconData paste = IconData(0xe192, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">paste</i> — material icon named "paste" (sharp). + static const IconData paste_sharp = IconData(0xe890, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">paste</i> — material icon named "paste" (round). + static const IconData paste_rounded = IconData(0xf66f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">paste</i> — material icon named "paste" (outlined). + static const IconData paste_outlined = IconData(0xef82, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pattern</i> — material icon named "pattern". + static const IconData pattern = IconData(0xe47b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pattern</i> — material icon named "pattern" (sharp). + static const IconData pattern_sharp = IconData(0xeb72, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pattern</i> — material icon named "pattern" (round). + static const IconData pattern_rounded = IconData(0xf0051, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pattern</i> — material icon named "pattern" (outlined). + static const IconData pattern_outlined = IconData(0xf25f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pause</i> — material icon named "pause". + static const IconData pause = IconData(0xe47c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pause</i> — material icon named "pause" (sharp). + static const IconData pause_sharp = IconData(0xeb77, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pause</i> — material icon named "pause" (round). + static const IconData pause_rounded = IconData(0xf0056, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pause</i> — material icon named "pause" (outlined). + static const IconData pause_outlined = IconData(0xf263, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pause_circle</i> — material icon named "pause circle". + static const IconData pause_circle = IconData(0xe47d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pause_circle</i> — material icon named "pause circle" (sharp). + static const IconData pause_circle_sharp = IconData(0xeb75, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pause_circle</i> — material icon named "pause circle" (round). + static const IconData pause_circle_rounded = IconData(0xf0054, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pause_circle</i> — material icon named "pause circle" (outlined). + static const IconData pause_circle_outlined = IconData(0xf262, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pause_circle_filled</i> — material icon named "pause circle filled". + static const IconData pause_circle_filled = IconData(0xe47e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pause_circle_filled</i> — material icon named "pause circle filled" (sharp). + static const IconData pause_circle_filled_sharp = IconData(0xeb73, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pause_circle_filled</i> — material icon named "pause circle filled" (round). + static const IconData pause_circle_filled_rounded = IconData( + 0xf0052, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">pause_circle_filled</i> — material icon named "pause circle filled" (outlined). + static const IconData pause_circle_filled_outlined = IconData( + 0xf260, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">pause_circle_outline</i> — material icon named "pause circle outline". + static const IconData pause_circle_outline = IconData(0xe47f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pause_circle_outline</i> — material icon named "pause circle outline" (sharp). + static const IconData pause_circle_outline_sharp = IconData(0xeb74, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pause_circle_outline</i> — material icon named "pause circle outline" (round). + static const IconData pause_circle_outline_rounded = IconData( + 0xf0053, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">pause_circle_outline</i> — material icon named "pause circle outline" (outlined). + static const IconData pause_circle_outline_outlined = IconData( + 0xf261, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">pause_presentation</i> — material icon named "pause presentation". + static const IconData pause_presentation = IconData(0xe480, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pause_presentation</i> — material icon named "pause presentation" (sharp). + static const IconData pause_presentation_sharp = IconData(0xeb76, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pause_presentation</i> — material icon named "pause presentation" (round). + static const IconData pause_presentation_rounded = IconData(0xf0055, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pause_presentation</i> — material icon named "pause presentation" (outlined). + static const IconData pause_presentation_outlined = IconData(0xf264, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">payment</i> — material icon named "payment". + static const IconData payment = IconData(0xe481, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">payment</i> — material icon named "payment" (sharp). + static const IconData payment_sharp = IconData(0xeb78, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">payment</i> — material icon named "payment" (round). + static const IconData payment_rounded = IconData(0xf0057, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">payment</i> — material icon named "payment" (outlined). + static const IconData payment_outlined = IconData(0xf265, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">payments</i> — material icon named "payments". + static const IconData payments = IconData(0xe482, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">payments</i> — material icon named "payments" (sharp). + static const IconData payments_sharp = IconData(0xeb79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">payments</i> — material icon named "payments" (round). + static const IconData payments_rounded = IconData(0xf0058, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">payments</i> — material icon named "payments" (outlined). + static const IconData payments_outlined = IconData(0xf266, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">paypal</i> — material icon named "paypal". + static const IconData paypal = IconData(0xf0545, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">paypal</i> — material icon named "paypal" (sharp). + static const IconData paypal_sharp = IconData(0xf044f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">paypal</i> — material icon named "paypal" (round). + static const IconData paypal_rounded = IconData(0xf035c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">paypal</i> — material icon named "paypal" (outlined). + static const IconData paypal_outlined = IconData(0xf063d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pedal_bike</i> — material icon named "pedal bike". + static const IconData pedal_bike = IconData(0xe483, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pedal_bike</i> — material icon named "pedal bike" (sharp). + static const IconData pedal_bike_sharp = IconData(0xeb7a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pedal_bike</i> — material icon named "pedal bike" (round). + static const IconData pedal_bike_rounded = IconData(0xf0059, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pedal_bike</i> — material icon named "pedal bike" (outlined). + static const IconData pedal_bike_outlined = IconData(0xf267, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pending</i> — material icon named "pending". + static const IconData pending = IconData(0xe484, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pending</i> — material icon named "pending" (sharp). + static const IconData pending_sharp = IconData(0xeb7c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pending</i> — material icon named "pending" (round). + static const IconData pending_rounded = IconData(0xf005b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pending</i> — material icon named "pending" (outlined). + static const IconData pending_outlined = IconData(0xf269, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pending_actions</i> — material icon named "pending actions". + static const IconData pending_actions = IconData(0xe485, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pending_actions</i> — material icon named "pending actions" (sharp). + static const IconData pending_actions_sharp = IconData(0xeb7b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pending_actions</i> — material icon named "pending actions" (round). + static const IconData pending_actions_rounded = IconData(0xf005a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pending_actions</i> — material icon named "pending actions" (outlined). + static const IconData pending_actions_outlined = IconData(0xf268, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pentagon</i> — material icon named "pentagon". + static const IconData pentagon = IconData(0xf0546, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pentagon</i> — material icon named "pentagon" (sharp). + static const IconData pentagon_sharp = IconData(0xf0450, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pentagon</i> — material icon named "pentagon" (round). + static const IconData pentagon_rounded = IconData(0xf035d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pentagon</i> — material icon named "pentagon" (outlined). + static const IconData pentagon_outlined = IconData(0xf063e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">people</i> — material icon named "people". + static const IconData people = IconData(0xe486, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">people</i> — material icon named "people" (sharp). + static const IconData people_sharp = IconData(0xeb7f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">people</i> — material icon named "people" (round). + static const IconData people_rounded = IconData(0xf005e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">people</i> — material icon named "people" (outlined). + static const IconData people_outlined = IconData(0xf26c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">people_alt</i> — material icon named "people alt". + static const IconData people_alt = IconData(0xe487, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">people_alt</i> — material icon named "people alt" (sharp). + static const IconData people_alt_sharp = IconData(0xeb7d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">people_alt</i> — material icon named "people alt" (round). + static const IconData people_alt_rounded = IconData(0xf005c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">people_alt</i> — material icon named "people alt" (outlined). + static const IconData people_alt_outlined = IconData(0xf26a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">people_outline</i> — material icon named "people outline". + static const IconData people_outline = IconData(0xe488, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">people_outline</i> — material icon named "people outline" (sharp). + static const IconData people_outline_sharp = IconData(0xeb7e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">people_outline</i> — material icon named "people outline" (round). + static const IconData people_outline_rounded = IconData(0xf005d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">people_outline</i> — material icon named "people outline" (outlined). + static const IconData people_outline_outlined = IconData(0xf26b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">percent</i> — material icon named "percent". + static const IconData percent = IconData(0xf0547, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">percent</i> — material icon named "percent" (sharp). + static const IconData percent_sharp = IconData(0xf0451, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">percent</i> — material icon named "percent" (round). + static const IconData percent_rounded = IconData(0xf035e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">percent</i> — material icon named "percent" (outlined). + static const IconData percent_outlined = IconData(0xf063f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_camera_mic</i> — material icon named "perm camera mic". + static const IconData perm_camera_mic = IconData(0xe489, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_camera_mic</i> — material icon named "perm camera mic" (sharp). + static const IconData perm_camera_mic_sharp = IconData(0xeb80, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_camera_mic</i> — material icon named "perm camera mic" (round). + static const IconData perm_camera_mic_rounded = IconData(0xf005f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_camera_mic</i> — material icon named "perm camera mic" (outlined). + static const IconData perm_camera_mic_outlined = IconData(0xf26d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_contact_cal</i> — material icon named "perm contact cal". + static const IconData perm_contact_cal = IconData(0xe48a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_contact_cal</i> — material icon named "perm contact cal" (sharp). + static const IconData perm_contact_cal_sharp = IconData(0xeb81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_contact_cal</i> — material icon named "perm contact cal" (round). + static const IconData perm_contact_cal_rounded = IconData(0xf0060, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_contact_cal</i> — material icon named "perm contact cal" (outlined). + static const IconData perm_contact_cal_outlined = IconData(0xf26e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_contact_calendar</i> — material icon named "perm contact calendar". + static const IconData perm_contact_calendar = IconData(0xe48a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_contact_calendar</i> — material icon named "perm contact calendar" (sharp). + static const IconData perm_contact_calendar_sharp = IconData(0xeb81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_contact_calendar</i> — material icon named "perm contact calendar" (round). + static const IconData perm_contact_calendar_rounded = IconData( + 0xf0060, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">perm_contact_calendar</i> — material icon named "perm contact calendar" (outlined). + static const IconData perm_contact_calendar_outlined = IconData( + 0xf26e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">perm_data_setting</i> — material icon named "perm data setting". + static const IconData perm_data_setting = IconData(0xe48b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_data_setting</i> — material icon named "perm data setting" (sharp). + static const IconData perm_data_setting_sharp = IconData(0xeb82, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_data_setting</i> — material icon named "perm data setting" (round). + static const IconData perm_data_setting_rounded = IconData(0xf0061, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_data_setting</i> — material icon named "perm data setting" (outlined). + static const IconData perm_data_setting_outlined = IconData(0xf26f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_device_info</i> — material icon named "perm device info". + static const IconData perm_device_info = IconData(0xe48c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_device_info</i> — material icon named "perm device info" (sharp). + static const IconData perm_device_info_sharp = IconData(0xeb83, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_device_info</i> — material icon named "perm device info" (round). + static const IconData perm_device_info_rounded = IconData(0xf0062, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_device_info</i> — material icon named "perm device info" (outlined). + static const IconData perm_device_info_outlined = IconData(0xf270, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_device_information</i> — material icon named "perm device information". + static const IconData perm_device_information = IconData(0xe48c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_device_information</i> — material icon named "perm device information" (sharp). + static const IconData perm_device_information_sharp = IconData( + 0xeb83, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">perm_device_information</i> — material icon named "perm device information" (round). + static const IconData perm_device_information_rounded = IconData( + 0xf0062, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">perm_device_information</i> — material icon named "perm device information" (outlined). + static const IconData perm_device_information_outlined = IconData( + 0xf270, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">perm_identity</i> — material icon named "perm identity". + static const IconData perm_identity = IconData(0xe48d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_identity</i> — material icon named "perm identity" (sharp). + static const IconData perm_identity_sharp = IconData(0xeb84, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_identity</i> — material icon named "perm identity" (round). + static const IconData perm_identity_rounded = IconData(0xf0063, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_identity</i> — material icon named "perm identity" (outlined). + static const IconData perm_identity_outlined = IconData(0xf271, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_media</i> — material icon named "perm media". + static const IconData perm_media = IconData(0xe48e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_media</i> — material icon named "perm media" (sharp). + static const IconData perm_media_sharp = IconData(0xeb85, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_media</i> — material icon named "perm media" (round). + static const IconData perm_media_rounded = IconData(0xf0064, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_media</i> — material icon named "perm media" (outlined). + static const IconData perm_media_outlined = IconData(0xf272, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_phone_msg</i> — material icon named "perm phone msg". + static const IconData perm_phone_msg = IconData(0xe48f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_phone_msg</i> — material icon named "perm phone msg" (sharp). + static const IconData perm_phone_msg_sharp = IconData(0xeb86, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_phone_msg</i> — material icon named "perm phone msg" (round). + static const IconData perm_phone_msg_rounded = IconData(0xf0065, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_phone_msg</i> — material icon named "perm phone msg" (outlined). + static const IconData perm_phone_msg_outlined = IconData(0xf273, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">perm_scan_wifi</i> — material icon named "perm scan wifi". + static const IconData perm_scan_wifi = IconData(0xe490, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">perm_scan_wifi</i> — material icon named "perm scan wifi" (sharp). + static const IconData perm_scan_wifi_sharp = IconData(0xeb87, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">perm_scan_wifi</i> — material icon named "perm scan wifi" (round). + static const IconData perm_scan_wifi_rounded = IconData(0xf0066, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">perm_scan_wifi</i> — material icon named "perm scan wifi" (outlined). + static const IconData perm_scan_wifi_outlined = IconData(0xf274, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person</i> — material icon named "person". + static const IconData person = IconData(0xe491, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person</i> — material icon named "person" (sharp). + static const IconData person_sharp = IconData(0xeb93, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person</i> — material icon named "person" (round). + static const IconData person_rounded = IconData(0xf0071, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person</i> — material icon named "person" (outlined). + static const IconData person_outlined = IconData(0xf27b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_2</i> — material icon named "person 2". + static const IconData person_2 = IconData(0xf0870, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_2</i> — material icon named "person 2" (sharp). + static const IconData person_2_sharp = IconData(0xf0847, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_2</i> — material icon named "person 2" (round). + static const IconData person_2_rounded = IconData(0xf0890, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_2</i> — material icon named "person 2" (outlined). + static const IconData person_2_outlined = IconData(0xf08ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_3</i> — material icon named "person 3". + static const IconData person_3 = IconData(0xf0871, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_3</i> — material icon named "person 3" (sharp). + static const IconData person_3_sharp = IconData(0xf0848, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_3</i> — material icon named "person 3" (round). + static const IconData person_3_rounded = IconData(0xf0891, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_3</i> — material icon named "person 3" (outlined). + static const IconData person_3_outlined = IconData(0xf08af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_4</i> — material icon named "person 4". + static const IconData person_4 = IconData(0xf0872, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_4</i> — material icon named "person 4" (sharp). + static const IconData person_4_sharp = IconData(0xf0849, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_4</i> — material icon named "person 4" (round). + static const IconData person_4_rounded = IconData(0xf0892, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_4</i> — material icon named "person 4" (outlined). + static const IconData person_4_outlined = IconData(0xf08b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_add</i> — material icon named "person add". + static const IconData person_add = IconData(0xe492, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_add</i> — material icon named "person add" (sharp). + static const IconData person_add_sharp = IconData(0xeb8b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_add</i> — material icon named "person add" (round). + static const IconData person_add_rounded = IconData(0xf006a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_add</i> — material icon named "person add" (outlined). + static const IconData person_add_outlined = IconData(0xf278, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_add_alt</i> — material icon named "person add alt". + static const IconData person_add_alt = IconData(0xe493, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_add_alt</i> — material icon named "person add alt" (sharp). + static const IconData person_add_alt_sharp = IconData(0xeb89, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_add_alt</i> — material icon named "person add alt" (round). + static const IconData person_add_alt_rounded = IconData(0xf0068, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_add_alt</i> — material icon named "person add alt" (outlined). + static const IconData person_add_alt_outlined = IconData(0xf276, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_add_alt_1</i> — material icon named "person add alt 1". + static const IconData person_add_alt_1 = IconData(0xe494, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_add_alt_1</i> — material icon named "person add alt 1" (sharp). + static const IconData person_add_alt_1_sharp = IconData(0xeb88, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_add_alt_1</i> — material icon named "person add alt 1" (round). + static const IconData person_add_alt_1_rounded = IconData(0xf0067, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_add_alt_1</i> — material icon named "person add alt 1" (outlined). + static const IconData person_add_alt_1_outlined = IconData(0xf275, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_add_disabled</i> — material icon named "person add disabled". + static const IconData person_add_disabled = IconData(0xe495, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_add_disabled</i> — material icon named "person add disabled" (sharp). + static const IconData person_add_disabled_sharp = IconData(0xeb8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_add_disabled</i> — material icon named "person add disabled" (round). + static const IconData person_add_disabled_rounded = IconData( + 0xf0069, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">person_add_disabled</i> — material icon named "person add disabled" (outlined). + static const IconData person_add_disabled_outlined = IconData( + 0xf277, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">person_off</i> — material icon named "person off". + static const IconData person_off = IconData(0xe496, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_off</i> — material icon named "person off" (sharp). + static const IconData person_off_sharp = IconData(0xeb8c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_off</i> — material icon named "person off" (round). + static const IconData person_off_rounded = IconData(0xf006b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_off</i> — material icon named "person off" (outlined). + static const IconData person_off_outlined = IconData(0xf279, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_outline</i> — material icon named "person outline". + static const IconData person_outline = IconData(0xe497, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_outline</i> — material icon named "person outline" (sharp). + static const IconData person_outline_sharp = IconData(0xeb8d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_outline</i> — material icon named "person outline" (round). + static const IconData person_outline_rounded = IconData(0xf006c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_outline</i> — material icon named "person outline" (outlined). + static const IconData person_outline_outlined = IconData(0xf27a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_pin</i> — material icon named "person pin". + static const IconData person_pin = IconData(0xe498, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_pin</i> — material icon named "person pin" (sharp). + static const IconData person_pin_sharp = IconData(0xeb8f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_pin</i> — material icon named "person pin" (round). + static const IconData person_pin_rounded = IconData(0xf006e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_pin</i> — material icon named "person pin" (outlined). + static const IconData person_pin_outlined = IconData(0xf27d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_pin_circle</i> — material icon named "person pin circle". + static const IconData person_pin_circle = IconData(0xe499, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_pin_circle</i> — material icon named "person pin circle" (sharp). + static const IconData person_pin_circle_sharp = IconData(0xeb8e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_pin_circle</i> — material icon named "person pin circle" (round). + static const IconData person_pin_circle_rounded = IconData(0xf006d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_pin_circle</i> — material icon named "person pin circle" (outlined). + static const IconData person_pin_circle_outlined = IconData(0xf27c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_remove</i> — material icon named "person remove". + static const IconData person_remove = IconData(0xe49a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_remove</i> — material icon named "person remove" (sharp). + static const IconData person_remove_sharp = IconData(0xeb91, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_remove</i> — material icon named "person remove" (round). + static const IconData person_remove_rounded = IconData(0xf0070, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_remove</i> — material icon named "person remove" (outlined). + static const IconData person_remove_outlined = IconData(0xf27f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">person_remove_alt_1</i> — material icon named "person remove alt 1". + static const IconData person_remove_alt_1 = IconData(0xe49b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_remove_alt_1</i> — material icon named "person remove alt 1" (sharp). + static const IconData person_remove_alt_1_sharp = IconData(0xeb90, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_remove_alt_1</i> — material icon named "person remove alt 1" (round). + static const IconData person_remove_alt_1_rounded = IconData( + 0xf006f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">person_remove_alt_1</i> — material icon named "person remove alt 1" (outlined). + static const IconData person_remove_alt_1_outlined = IconData( + 0xf27e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">person_search</i> — material icon named "person search". + static const IconData person_search = IconData(0xe49c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">person_search</i> — material icon named "person search" (sharp). + static const IconData person_search_sharp = IconData(0xeb92, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">person_search</i> — material icon named "person search" (round). + static const IconData person_search_rounded = IconData(0xf0072, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">person_search</i> — material icon named "person search" (outlined). + static const IconData person_search_outlined = IconData(0xf280, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">personal_injury</i> — material icon named "personal injury". + static const IconData personal_injury = IconData(0xe49d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">personal_injury</i> — material icon named "personal injury" (sharp). + static const IconData personal_injury_sharp = IconData(0xeb94, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">personal_injury</i> — material icon named "personal injury" (round). + static const IconData personal_injury_rounded = IconData(0xf0073, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">personal_injury</i> — material icon named "personal injury" (outlined). + static const IconData personal_injury_outlined = IconData(0xf281, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">personal_video</i> — material icon named "personal video". + static const IconData personal_video = IconData(0xe49e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">personal_video</i> — material icon named "personal video" (sharp). + static const IconData personal_video_sharp = IconData(0xeb95, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">personal_video</i> — material icon named "personal video" (round). + static const IconData personal_video_rounded = IconData(0xf0074, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">personal_video</i> — material icon named "personal video" (outlined). + static const IconData personal_video_outlined = IconData(0xf282, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pest_control</i> — material icon named "pest control". + static const IconData pest_control = IconData(0xe49f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pest_control</i> — material icon named "pest control" (sharp). + static const IconData pest_control_sharp = IconData(0xeb97, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pest_control</i> — material icon named "pest control" (round). + static const IconData pest_control_rounded = IconData(0xf0076, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pest_control</i> — material icon named "pest control" (outlined). + static const IconData pest_control_outlined = IconData(0xf283, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pest_control_rodent</i> — material icon named "pest control rodent". + static const IconData pest_control_rodent = IconData(0xe4a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pest_control_rodent</i> — material icon named "pest control rodent" (sharp). + static const IconData pest_control_rodent_sharp = IconData(0xeb96, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pest_control_rodent</i> — material icon named "pest control rodent" (round). + static const IconData pest_control_rodent_rounded = IconData( + 0xf0075, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">pest_control_rodent</i> — material icon named "pest control rodent" (outlined). + static const IconData pest_control_rodent_outlined = IconData( + 0xf284, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">pets</i> — material icon named "pets". + static const IconData pets = IconData(0xe4a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pets</i> — material icon named "pets" (sharp). + static const IconData pets_sharp = IconData(0xeb98, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pets</i> — material icon named "pets" (round). + static const IconData pets_rounded = IconData(0xf0077, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pets</i> — material icon named "pets" (outlined). + static const IconData pets_outlined = IconData(0xf285, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phishing</i> — material icon named "phishing". + static const IconData phishing = IconData(0xf0548, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phishing</i> — material icon named "phishing" (sharp). + static const IconData phishing_sharp = IconData(0xf0452, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phishing</i> — material icon named "phishing" (round). + static const IconData phishing_rounded = IconData(0xf035f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phishing</i> — material icon named "phishing" (outlined). + static const IconData phishing_outlined = IconData(0xf0640, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone</i> — material icon named "phone". + static const IconData phone = IconData(0xe4a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone</i> — material icon named "phone" (sharp). + static const IconData phone_sharp = IconData(0xeba4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone</i> — material icon named "phone" (round). + static const IconData phone_rounded = IconData(0xf0083, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone</i> — material icon named "phone" (outlined). + static const IconData phone_outlined = IconData(0xf290, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_android</i> — material icon named "phone android". + static const IconData phone_android = IconData(0xe4a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_android</i> — material icon named "phone android" (sharp). + static const IconData phone_android_sharp = IconData(0xeb99, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_android</i> — material icon named "phone android" (round). + static const IconData phone_android_rounded = IconData(0xf0078, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_android</i> — material icon named "phone android" (outlined). + static const IconData phone_android_outlined = IconData(0xf286, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_bluetooth_speaker</i> — material icon named "phone bluetooth speaker". + static const IconData phone_bluetooth_speaker = IconData(0xe4a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_bluetooth_speaker</i> — material icon named "phone bluetooth speaker" (sharp). + static const IconData phone_bluetooth_speaker_sharp = IconData( + 0xeb9a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">phone_bluetooth_speaker</i> — material icon named "phone bluetooth speaker" (round). + static const IconData phone_bluetooth_speaker_rounded = IconData( + 0xf0079, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">phone_bluetooth_speaker</i> — material icon named "phone bluetooth speaker" (outlined). + static const IconData phone_bluetooth_speaker_outlined = IconData( + 0xf287, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">phone_callback</i> — material icon named "phone callback". + static const IconData phone_callback = IconData(0xe4a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_callback</i> — material icon named "phone callback" (sharp). + static const IconData phone_callback_sharp = IconData(0xeb9b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_callback</i> — material icon named "phone callback" (round). + static const IconData phone_callback_rounded = IconData(0xf007a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_callback</i> — material icon named "phone callback" (outlined). + static const IconData phone_callback_outlined = IconData(0xf288, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_disabled</i> — material icon named "phone disabled". + static const IconData phone_disabled = IconData(0xe4a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_disabled</i> — material icon named "phone disabled" (sharp). + static const IconData phone_disabled_sharp = IconData(0xeb9c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_disabled</i> — material icon named "phone disabled" (round). + static const IconData phone_disabled_rounded = IconData(0xf007b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_disabled</i> — material icon named "phone disabled" (outlined). + static const IconData phone_disabled_outlined = IconData(0xf289, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_enabled</i> — material icon named "phone enabled". + static const IconData phone_enabled = IconData(0xe4a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_enabled</i> — material icon named "phone enabled" (sharp). + static const IconData phone_enabled_sharp = IconData(0xeb9d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_enabled</i> — material icon named "phone enabled" (round). + static const IconData phone_enabled_rounded = IconData(0xf007c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_enabled</i> — material icon named "phone enabled" (outlined). + static const IconData phone_enabled_outlined = IconData(0xf28a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_forwarded</i> — material icon named "phone forwarded". + static const IconData phone_forwarded = IconData(0xe4a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_forwarded</i> — material icon named "phone forwarded" (sharp). + static const IconData phone_forwarded_sharp = IconData(0xeb9e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_forwarded</i> — material icon named "phone forwarded" (round). + static const IconData phone_forwarded_rounded = IconData(0xf007d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_forwarded</i> — material icon named "phone forwarded" (outlined). + static const IconData phone_forwarded_outlined = IconData(0xf28b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_in_talk</i> — material icon named "phone in talk". + static const IconData phone_in_talk = IconData(0xe4a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_in_talk</i> — material icon named "phone in talk" (sharp). + static const IconData phone_in_talk_sharp = IconData(0xeb9f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_in_talk</i> — material icon named "phone in talk" (round). + static const IconData phone_in_talk_rounded = IconData(0xf007e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_in_talk</i> — material icon named "phone in talk" (outlined). + static const IconData phone_in_talk_outlined = IconData(0xf28c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_iphone</i> — material icon named "phone iphone". + static const IconData phone_iphone = IconData(0xe4aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_iphone</i> — material icon named "phone iphone" (sharp). + static const IconData phone_iphone_sharp = IconData(0xeba0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_iphone</i> — material icon named "phone iphone" (round). + static const IconData phone_iphone_rounded = IconData(0xf007f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_iphone</i> — material icon named "phone iphone" (outlined). + static const IconData phone_iphone_outlined = IconData(0xf28d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_locked</i> — material icon named "phone locked". + static const IconData phone_locked = IconData(0xe4ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_locked</i> — material icon named "phone locked" (sharp). + static const IconData phone_locked_sharp = IconData(0xeba1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_locked</i> — material icon named "phone locked" (round). + static const IconData phone_locked_rounded = IconData(0xf0080, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_locked</i> — material icon named "phone locked" (outlined). + static const IconData phone_locked_outlined = IconData(0xf28e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_missed</i> — material icon named "phone missed". + static const IconData phone_missed = IconData(0xe4ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_missed</i> — material icon named "phone missed" (sharp). + static const IconData phone_missed_sharp = IconData(0xeba2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_missed</i> — material icon named "phone missed" (round). + static const IconData phone_missed_rounded = IconData(0xf0081, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_missed</i> — material icon named "phone missed" (outlined). + static const IconData phone_missed_outlined = IconData(0xf28f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phone_paused</i> — material icon named "phone paused". + static const IconData phone_paused = IconData(0xe4ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phone_paused</i> — material icon named "phone paused" (sharp). + static const IconData phone_paused_sharp = IconData(0xeba3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phone_paused</i> — material icon named "phone paused" (round). + static const IconData phone_paused_rounded = IconData(0xf0082, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phone_paused</i> — material icon named "phone paused" (outlined). + static const IconData phone_paused_outlined = IconData(0xf291, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phonelink</i> — material icon named "phonelink". + static const IconData phonelink = IconData(0xe4ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phonelink</i> — material icon named "phonelink" (sharp). + static const IconData phonelink_sharp = IconData(0xebaa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phonelink</i> — material icon named "phonelink" (round). + static const IconData phonelink_rounded = IconData(0xf0088, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phonelink</i> — material icon named "phonelink" (outlined). + static const IconData phonelink_outlined = IconData(0xf295, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phonelink_erase</i> — material icon named "phonelink erase". + static const IconData phonelink_erase = IconData(0xe4af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phonelink_erase</i> — material icon named "phonelink erase" (sharp). + static const IconData phonelink_erase_sharp = IconData(0xeba5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phonelink_erase</i> — material icon named "phonelink erase" (round). + static const IconData phonelink_erase_rounded = IconData(0xf0084, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phonelink_erase</i> — material icon named "phonelink erase" (outlined). + static const IconData phonelink_erase_outlined = IconData(0xf292, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phonelink_lock</i> — material icon named "phonelink lock". + static const IconData phonelink_lock = IconData(0xe4b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phonelink_lock</i> — material icon named "phonelink lock" (sharp). + static const IconData phonelink_lock_sharp = IconData(0xeba6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phonelink_lock</i> — material icon named "phonelink lock" (round). + static const IconData phonelink_lock_rounded = IconData(0xf0085, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phonelink_lock</i> — material icon named "phonelink lock" (outlined). + static const IconData phonelink_lock_outlined = IconData(0xf293, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phonelink_off</i> — material icon named "phonelink off". + static const IconData phonelink_off = IconData(0xe4b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phonelink_off</i> — material icon named "phonelink off" (sharp). + static const IconData phonelink_off_sharp = IconData(0xeba7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phonelink_off</i> — material icon named "phonelink off" (round). + static const IconData phonelink_off_rounded = IconData(0xf0086, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phonelink_off</i> — material icon named "phonelink off" (outlined). + static const IconData phonelink_off_outlined = IconData(0xf294, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phonelink_ring</i> — material icon named "phonelink ring". + static const IconData phonelink_ring = IconData(0xe4b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phonelink_ring</i> — material icon named "phonelink ring" (sharp). + static const IconData phonelink_ring_sharp = IconData(0xeba8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phonelink_ring</i> — material icon named "phonelink ring" (round). + static const IconData phonelink_ring_rounded = IconData(0xf0087, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phonelink_ring</i> — material icon named "phonelink ring" (outlined). + static const IconData phonelink_ring_outlined = IconData(0xf296, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">phonelink_setup</i> — material icon named "phonelink setup". + static const IconData phonelink_setup = IconData(0xe4b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">phonelink_setup</i> — material icon named "phonelink setup" (sharp). + static const IconData phonelink_setup_sharp = IconData(0xeba9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">phonelink_setup</i> — material icon named "phonelink setup" (round). + static const IconData phonelink_setup_rounded = IconData(0xf0089, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">phonelink_setup</i> — material icon named "phonelink setup" (outlined). + static const IconData phonelink_setup_outlined = IconData(0xf297, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo</i> — material icon named "photo". + static const IconData photo = IconData(0xe4b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo</i> — material icon named "photo" (sharp). + static const IconData photo_sharp = IconData(0xebb1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo</i> — material icon named "photo" (round). + static const IconData photo_rounded = IconData(0xf0090, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo</i> — material icon named "photo" (outlined). + static const IconData photo_outlined = IconData(0xf29e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_album</i> — material icon named "photo album". + static const IconData photo_album = IconData(0xe4b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_album</i> — material icon named "photo album" (sharp). + static const IconData photo_album_sharp = IconData(0xebab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo_album</i> — material icon named "photo album" (round). + static const IconData photo_album_rounded = IconData(0xf008a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo_album</i> — material icon named "photo album" (outlined). + static const IconData photo_album_outlined = IconData(0xf298, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_camera</i> — material icon named "photo camera". + static const IconData photo_camera = IconData(0xe4b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_camera</i> — material icon named "photo camera" (sharp). + static const IconData photo_camera_sharp = IconData(0xebae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo_camera</i> — material icon named "photo camera" (round). + static const IconData photo_camera_rounded = IconData(0xf008d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo_camera</i> — material icon named "photo camera" (outlined). + static const IconData photo_camera_outlined = IconData(0xf29b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_camera_back</i> — material icon named "photo camera back". + static const IconData photo_camera_back = IconData(0xe4b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_camera_back</i> — material icon named "photo camera back" (sharp). + static const IconData photo_camera_back_sharp = IconData(0xebac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo_camera_back</i> — material icon named "photo camera back" (round). + static const IconData photo_camera_back_rounded = IconData(0xf008b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo_camera_back</i> — material icon named "photo camera back" (outlined). + static const IconData photo_camera_back_outlined = IconData(0xf299, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_camera_front</i> — material icon named "photo camera front". + static const IconData photo_camera_front = IconData(0xe4b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_camera_front</i> — material icon named "photo camera front" (sharp). + static const IconData photo_camera_front_sharp = IconData(0xebad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo_camera_front</i> — material icon named "photo camera front" (round). + static const IconData photo_camera_front_rounded = IconData(0xf008c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo_camera_front</i> — material icon named "photo camera front" (outlined). + static const IconData photo_camera_front_outlined = IconData(0xf29a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_filter</i> — material icon named "photo filter". + static const IconData photo_filter = IconData(0xe4b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_filter</i> — material icon named "photo filter" (sharp). + static const IconData photo_filter_sharp = IconData(0xebaf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo_filter</i> — material icon named "photo filter" (round). + static const IconData photo_filter_rounded = IconData(0xf008e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo_filter</i> — material icon named "photo filter" (outlined). + static const IconData photo_filter_outlined = IconData(0xf29c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_library</i> — material icon named "photo library". + static const IconData photo_library = IconData(0xe4ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_library</i> — material icon named "photo library" (sharp). + static const IconData photo_library_sharp = IconData(0xebb0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">photo_library</i> — material icon named "photo library" (round). + static const IconData photo_library_rounded = IconData(0xf008f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">photo_library</i> — material icon named "photo library" (outlined). + static const IconData photo_library_outlined = IconData(0xf29d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">photo_size_select_actual</i> — material icon named "photo size select actual". + static const IconData photo_size_select_actual = IconData(0xe4bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_size_select_actual</i> — material icon named "photo size select actual" (sharp). + static const IconData photo_size_select_actual_sharp = IconData( + 0xebb2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">photo_size_select_actual</i> — material icon named "photo size select actual" (round). + static const IconData photo_size_select_actual_rounded = IconData( + 0xf0091, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">photo_size_select_actual</i> — material icon named "photo size select actual" (outlined). + static const IconData photo_size_select_actual_outlined = IconData( + 0xf29f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">photo_size_select_large</i> — material icon named "photo size select large". + static const IconData photo_size_select_large = IconData(0xe4bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_size_select_large</i> — material icon named "photo size select large" (sharp). + static const IconData photo_size_select_large_sharp = IconData( + 0xebb3, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">photo_size_select_large</i> — material icon named "photo size select large" (round). + static const IconData photo_size_select_large_rounded = IconData( + 0xf0092, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">photo_size_select_large</i> — material icon named "photo size select large" (outlined). + static const IconData photo_size_select_large_outlined = IconData( + 0xf2a0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">photo_size_select_small</i> — material icon named "photo size select small". + static const IconData photo_size_select_small = IconData(0xe4bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">photo_size_select_small</i> — material icon named "photo size select small" (sharp). + static const IconData photo_size_select_small_sharp = IconData( + 0xebb4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">photo_size_select_small</i> — material icon named "photo size select small" (round). + static const IconData photo_size_select_small_rounded = IconData( + 0xf0093, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">photo_size_select_small</i> — material icon named "photo size select small" (outlined). + static const IconData photo_size_select_small_outlined = IconData( + 0xf2a1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">php</i> — material icon named "php". + static const IconData php = IconData(0xf0549, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">php</i> — material icon named "php" (sharp). + static const IconData php_sharp = IconData(0xf0453, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">php</i> — material icon named "php" (round). + static const IconData php_rounded = IconData(0xf0360, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">php</i> — material icon named "php" (outlined). + static const IconData php_outlined = IconData(0xf0641, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">piano</i> — material icon named "piano". + static const IconData piano = IconData(0xe4be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">piano</i> — material icon named "piano" (sharp). + static const IconData piano_sharp = IconData(0xebb6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">piano</i> — material icon named "piano" (round). + static const IconData piano_rounded = IconData(0xf0095, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">piano</i> — material icon named "piano" (outlined). + static const IconData piano_outlined = IconData(0xf2a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">piano_off</i> — material icon named "piano off". + static const IconData piano_off = IconData(0xe4bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">piano_off</i> — material icon named "piano off" (sharp). + static const IconData piano_off_sharp = IconData(0xebb5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">piano_off</i> — material icon named "piano off" (round). + static const IconData piano_off_rounded = IconData(0xf0094, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">piano_off</i> — material icon named "piano off" (outlined). + static const IconData piano_off_outlined = IconData(0xf2a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">picture_as_pdf</i> — material icon named "picture as pdf". + static const IconData picture_as_pdf = IconData(0xe4c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">picture_as_pdf</i> — material icon named "picture as pdf" (sharp). + static const IconData picture_as_pdf_sharp = IconData(0xebb7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">picture_as_pdf</i> — material icon named "picture as pdf" (round). + static const IconData picture_as_pdf_rounded = IconData(0xf0096, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">picture_as_pdf</i> — material icon named "picture as pdf" (outlined). + static const IconData picture_as_pdf_outlined = IconData(0xf2a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">picture_in_picture</i> — material icon named "picture in picture". + static const IconData picture_in_picture = IconData(0xe4c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">picture_in_picture</i> — material icon named "picture in picture" (sharp). + static const IconData picture_in_picture_sharp = IconData(0xebb9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">picture_in_picture</i> — material icon named "picture in picture" (round). + static const IconData picture_in_picture_rounded = IconData(0xf0098, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">picture_in_picture</i> — material icon named "picture in picture" (outlined). + static const IconData picture_in_picture_outlined = IconData(0xf2a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">picture_in_picture_alt</i> — material icon named "picture in picture alt". + static const IconData picture_in_picture_alt = IconData(0xe4c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">picture_in_picture_alt</i> — material icon named "picture in picture alt" (sharp). + static const IconData picture_in_picture_alt_sharp = IconData( + 0xebb8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">picture_in_picture_alt</i> — material icon named "picture in picture alt" (round). + static const IconData picture_in_picture_alt_rounded = IconData( + 0xf0097, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">picture_in_picture_alt</i> — material icon named "picture in picture alt" (outlined). + static const IconData picture_in_picture_alt_outlined = IconData( + 0xf2a5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">pie_chart</i> — material icon named "pie chart". + static const IconData pie_chart = IconData(0xe4c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pie_chart</i> — material icon named "pie chart" (sharp). + static const IconData pie_chart_sharp = IconData(0xebbb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pie_chart</i> — material icon named "pie chart" (round). + static const IconData pie_chart_rounded = IconData(0xf009a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pie_chart_outline</i> — material icon named "pie chart outline". + static const IconData pie_chart_outline = IconData(0xe4c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pie_chart_outline</i> — material icon named "pie chart outline" (sharp). + static const IconData pie_chart_outline_sharp = IconData(0xebba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pie_chart_outline</i> — material icon named "pie chart outline" (round). + static const IconData pie_chart_outline_rounded = IconData(0xf0099, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pie_chart_outline</i> — material icon named "pie chart outline" (outlined). + static const IconData pie_chart_outline_outlined = IconData(0xf2a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pin</i> — material icon named "pin". + static const IconData pin = IconData(0xe4c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pin</i> — material icon named "pin" (sharp). + static const IconData pin_sharp = IconData(0xebbd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pin</i> — material icon named "pin" (round). + static const IconData pin_rounded = IconData(0xf009c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pin</i> — material icon named "pin" (outlined). + static const IconData pin_outlined = IconData(0xf2aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pin_drop</i> — material icon named "pin drop". + static const IconData pin_drop = IconData(0xe4c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pin_drop</i> — material icon named "pin drop" (sharp). + static const IconData pin_drop_sharp = IconData(0xebbc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pin_drop</i> — material icon named "pin drop" (round). + static const IconData pin_drop_rounded = IconData(0xf009b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pin_drop</i> — material icon named "pin drop" (outlined). + static const IconData pin_drop_outlined = IconData(0xf2a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pin_end</i> — material icon named "pin end". + static const IconData pin_end = IconData(0xf054b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pin_end</i> — material icon named "pin end" (sharp). + static const IconData pin_end_sharp = IconData(0xf0454, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pin_end</i> — material icon named "pin end" (round). + static const IconData pin_end_rounded = IconData(0xf0361, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pin_end</i> — material icon named "pin end" (outlined). + static const IconData pin_end_outlined = IconData(0xf0642, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pin_invoke</i> — material icon named "pin invoke". + static const IconData pin_invoke = IconData(0xf054c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pin_invoke</i> — material icon named "pin invoke" (sharp). + static const IconData pin_invoke_sharp = IconData(0xf0455, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pin_invoke</i> — material icon named "pin invoke" (round). + static const IconData pin_invoke_rounded = IconData(0xf0362, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pin_invoke</i> — material icon named "pin invoke" (outlined). + static const IconData pin_invoke_outlined = IconData(0xf0643, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pinch</i> — material icon named "pinch". + static const IconData pinch = IconData(0xf054d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pinch</i> — material icon named "pinch" (sharp). + static const IconData pinch_sharp = IconData(0xf0456, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pinch</i> — material icon named "pinch" (round). + static const IconData pinch_rounded = IconData(0xf0363, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pinch</i> — material icon named "pinch" (outlined). + static const IconData pinch_outlined = IconData(0xf0644, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pivot_table_chart</i> — material icon named "pivot table chart". + static const IconData pivot_table_chart = IconData(0xe4c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pivot_table_chart</i> — material icon named "pivot table chart" (sharp). + static const IconData pivot_table_chart_sharp = IconData(0xebbe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pivot_table_chart</i> — material icon named "pivot table chart" (round). + static const IconData pivot_table_chart_rounded = IconData(0xf009d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pivot_table_chart</i> — material icon named "pivot table chart" (outlined). + static const IconData pivot_table_chart_outlined = IconData(0xf2ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pix</i> — material icon named "pix". + static const IconData pix = IconData(0xf054e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pix</i> — material icon named "pix" (sharp). + static const IconData pix_sharp = IconData(0xf0457, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pix</i> — material icon named "pix" (round). + static const IconData pix_rounded = IconData(0xf0364, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pix</i> — material icon named "pix" (outlined). + static const IconData pix_outlined = IconData(0xf0645, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">place</i> — material icon named "place". + static const IconData place = IconData(0xe4c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">place</i> — material icon named "place" (sharp). + static const IconData place_sharp = IconData(0xebbf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">place</i> — material icon named "place" (round). + static const IconData place_rounded = IconData(0xf009e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">place</i> — material icon named "place" (outlined). + static const IconData place_outlined = IconData(0xf2ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">plagiarism</i> — material icon named "plagiarism". + static const IconData plagiarism = IconData(0xe4ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">plagiarism</i> — material icon named "plagiarism" (sharp). + static const IconData plagiarism_sharp = IconData(0xebc0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">plagiarism</i> — material icon named "plagiarism" (round). + static const IconData plagiarism_rounded = IconData(0xf009f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">plagiarism</i> — material icon named "plagiarism" (outlined). + static const IconData plagiarism_outlined = IconData(0xf2ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_arrow</i> — material icon named "play arrow". + static const IconData play_arrow = IconData(0xe4cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_arrow</i> — material icon named "play arrow" (sharp). + static const IconData play_arrow_sharp = IconData(0xebc1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_arrow</i> — material icon named "play arrow" (round). + static const IconData play_arrow_rounded = IconData(0xf00a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_arrow</i> — material icon named "play arrow" (outlined). + static const IconData play_arrow_outlined = IconData(0xf2ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_circle</i> — material icon named "play circle". + static const IconData play_circle = IconData(0xe4cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_circle</i> — material icon named "play circle" (sharp). + static const IconData play_circle_sharp = IconData(0xebc4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_circle</i> — material icon named "play circle" (round). + static const IconData play_circle_rounded = IconData(0xf00a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_circle</i> — material icon named "play circle" (outlined). + static const IconData play_circle_outlined = IconData(0xf2b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_circle_fill</i> — material icon named "play circle fill". + static const IconData play_circle_fill = IconData(0xe4cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_circle_fill</i> — material icon named "play circle fill" (sharp). + static const IconData play_circle_fill_sharp = IconData(0xebc2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_circle_fill</i> — material icon named "play circle fill" (round). + static const IconData play_circle_fill_rounded = IconData(0xf00a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_circle_fill</i> — material icon named "play circle fill" (outlined). + static const IconData play_circle_fill_outlined = IconData(0xf2af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_circle_filled</i> — material icon named "play circle filled". + static const IconData play_circle_filled = IconData(0xe4cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_circle_filled</i> — material icon named "play circle filled" (sharp). + static const IconData play_circle_filled_sharp = IconData(0xebc2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_circle_filled</i> — material icon named "play circle filled" (round). + static const IconData play_circle_filled_rounded = IconData(0xf00a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_circle_filled</i> — material icon named "play circle filled" (outlined). + static const IconData play_circle_filled_outlined = IconData(0xf2af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_circle_outline</i> — material icon named "play circle outline". + static const IconData play_circle_outline = IconData(0xe4ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_circle_outline</i> — material icon named "play circle outline" (sharp). + static const IconData play_circle_outline_sharp = IconData(0xebc3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_circle_outline</i> — material icon named "play circle outline" (round). + static const IconData play_circle_outline_rounded = IconData( + 0xf00a2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">play_circle_outline</i> — material icon named "play circle outline" (outlined). + static const IconData play_circle_outline_outlined = IconData( + 0xf2b0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">play_disabled</i> — material icon named "play disabled". + static const IconData play_disabled = IconData(0xe4cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_disabled</i> — material icon named "play disabled" (sharp). + static const IconData play_disabled_sharp = IconData(0xebc5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_disabled</i> — material icon named "play disabled" (round). + static const IconData play_disabled_rounded = IconData(0xf00a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_disabled</i> — material icon named "play disabled" (outlined). + static const IconData play_disabled_outlined = IconData(0xf2b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_for_work</i> — material icon named "play for work". + static const IconData play_for_work = IconData(0xe4d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_for_work</i> — material icon named "play for work" (sharp). + static const IconData play_for_work_sharp = IconData(0xebc6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_for_work</i> — material icon named "play for work" (round). + static const IconData play_for_work_rounded = IconData(0xf00a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_for_work</i> — material icon named "play for work" (outlined). + static const IconData play_for_work_outlined = IconData(0xf2b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">play_lesson</i> — material icon named "play lesson". + static const IconData play_lesson = IconData(0xe4d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">play_lesson</i> — material icon named "play lesson" (sharp). + static const IconData play_lesson_sharp = IconData(0xebc7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">play_lesson</i> — material icon named "play lesson" (round). + static const IconData play_lesson_rounded = IconData(0xf00a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">play_lesson</i> — material icon named "play lesson" (outlined). + static const IconData play_lesson_outlined = IconData(0xf2b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">playlist_add</i> — material icon named "playlist add". + static const IconData playlist_add = IconData( + 0xe4d2, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">playlist_add</i> — material icon named "playlist add" (sharp). + static const IconData playlist_add_sharp = IconData( + 0xebc9, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">playlist_add</i> — material icon named "playlist add" (round). + static const IconData playlist_add_rounded = IconData( + 0xf00a8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">playlist_add</i> — material icon named "playlist add" (outlined). + static const IconData playlist_add_outlined = IconData( + 0xf2b6, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">playlist_add_check</i> — material icon named "playlist add check". + static const IconData playlist_add_check = IconData(0xe4d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">playlist_add_check</i> — material icon named "playlist add check" (sharp). + static const IconData playlist_add_check_sharp = IconData(0xebc8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">playlist_add_check</i> — material icon named "playlist add check" (round). + static const IconData playlist_add_check_rounded = IconData(0xf00a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">playlist_add_check</i> — material icon named "playlist add check" (outlined). + static const IconData playlist_add_check_outlined = IconData(0xf2b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">playlist_add_check_circle</i> — material icon named "playlist add check circle". + static const IconData playlist_add_check_circle = IconData(0xf054f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">playlist_add_check_circle</i> — material icon named "playlist add check circle" (sharp). + static const IconData playlist_add_check_circle_sharp = IconData( + 0xf0458, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">playlist_add_check_circle</i> — material icon named "playlist add check circle" (round). + static const IconData playlist_add_check_circle_rounded = IconData( + 0xf0365, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">playlist_add_check_circle</i> — material icon named "playlist add check circle" (outlined). + static const IconData playlist_add_check_circle_outlined = IconData( + 0xf0646, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">playlist_add_circle</i> — material icon named "playlist add circle". + static const IconData playlist_add_circle = IconData(0xf0550, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">playlist_add_circle</i> — material icon named "playlist add circle" (sharp). + static const IconData playlist_add_circle_sharp = IconData(0xf0459, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">playlist_add_circle</i> — material icon named "playlist add circle" (round). + static const IconData playlist_add_circle_rounded = IconData( + 0xf0366, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">playlist_add_circle</i> — material icon named "playlist add circle" (outlined). + static const IconData playlist_add_circle_outlined = IconData( + 0xf0647, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">playlist_play</i> — material icon named "playlist play". + static const IconData playlist_play = IconData(0xe4d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">playlist_play</i> — material icon named "playlist play" (sharp). + static const IconData playlist_play_sharp = IconData(0xebca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">playlist_play</i> — material icon named "playlist play" (round). + static const IconData playlist_play_rounded = IconData(0xf00a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">playlist_play</i> — material icon named "playlist play" (outlined). + static const IconData playlist_play_outlined = IconData(0xf2b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">playlist_remove</i> — material icon named "playlist remove". + static const IconData playlist_remove = IconData(0xf0551, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">playlist_remove</i> — material icon named "playlist remove" (sharp). + static const IconData playlist_remove_sharp = IconData(0xf045a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">playlist_remove</i> — material icon named "playlist remove" (round). + static const IconData playlist_remove_rounded = IconData(0xf0367, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">playlist_remove</i> — material icon named "playlist remove" (outlined). + static const IconData playlist_remove_outlined = IconData(0xf0648, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">plumbing</i> — material icon named "plumbing". + static const IconData plumbing = IconData(0xe4d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">plumbing</i> — material icon named "plumbing" (sharp). + static const IconData plumbing_sharp = IconData(0xebcb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">plumbing</i> — material icon named "plumbing" (round). + static const IconData plumbing_rounded = IconData(0xf00aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">plumbing</i> — material icon named "plumbing" (outlined). + static const IconData plumbing_outlined = IconData(0xf2b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">plus_one</i> — material icon named "plus one". + static const IconData plus_one = IconData(0xe4d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">plus_one</i> — material icon named "plus one" (sharp). + static const IconData plus_one_sharp = IconData(0xebcc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">plus_one</i> — material icon named "plus one" (round). + static const IconData plus_one_rounded = IconData(0xf00ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">plus_one</i> — material icon named "plus one" (outlined). + static const IconData plus_one_outlined = IconData(0xf2b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">podcasts</i> — material icon named "podcasts". + static const IconData podcasts = IconData(0xe4d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">podcasts</i> — material icon named "podcasts" (sharp). + static const IconData podcasts_sharp = IconData(0xebcd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">podcasts</i> — material icon named "podcasts" (round). + static const IconData podcasts_rounded = IconData(0xf00ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">podcasts</i> — material icon named "podcasts" (outlined). + static const IconData podcasts_outlined = IconData(0xf2ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">point_of_sale</i> — material icon named "point of sale". + static const IconData point_of_sale = IconData(0xe4d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">point_of_sale</i> — material icon named "point of sale" (sharp). + static const IconData point_of_sale_sharp = IconData(0xebce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">point_of_sale</i> — material icon named "point of sale" (round). + static const IconData point_of_sale_rounded = IconData(0xf00ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">point_of_sale</i> — material icon named "point of sale" (outlined). + static const IconData point_of_sale_outlined = IconData(0xf2bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">policy</i> — material icon named "policy". + static const IconData policy = IconData(0xe4d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">policy</i> — material icon named "policy" (sharp). + static const IconData policy_sharp = IconData(0xebcf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">policy</i> — material icon named "policy" (round). + static const IconData policy_rounded = IconData(0xf00ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">policy</i> — material icon named "policy" (outlined). + static const IconData policy_outlined = IconData(0xf2bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">poll</i> — material icon named "poll". + static const IconData poll = IconData(0xe4da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">poll</i> — material icon named "poll" (sharp). + static const IconData poll_sharp = IconData(0xebd0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">poll</i> — material icon named "poll" (round). + static const IconData poll_rounded = IconData(0xf00af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">poll</i> — material icon named "poll" (outlined). + static const IconData poll_outlined = IconData(0xf2bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">polyline</i> — material icon named "polyline". + static const IconData polyline = IconData(0xf0552, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">polyline</i> — material icon named "polyline" (sharp). + static const IconData polyline_sharp = IconData(0xf045b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">polyline</i> — material icon named "polyline" (round). + static const IconData polyline_rounded = IconData(0xf0368, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">polyline</i> — material icon named "polyline" (outlined). + static const IconData polyline_outlined = IconData(0xf0649, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">polymer</i> — material icon named "polymer". + static const IconData polymer = IconData(0xe4db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">polymer</i> — material icon named "polymer" (sharp). + static const IconData polymer_sharp = IconData(0xebd1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">polymer</i> — material icon named "polymer" (round). + static const IconData polymer_rounded = IconData(0xf00b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">polymer</i> — material icon named "polymer" (outlined). + static const IconData polymer_outlined = IconData(0xf2be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">pool</i> — material icon named "pool". + static const IconData pool = IconData(0xe4dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pool</i> — material icon named "pool" (sharp). + static const IconData pool_sharp = IconData(0xebd2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pool</i> — material icon named "pool" (round). + static const IconData pool_rounded = IconData(0xf00b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pool</i> — material icon named "pool" (outlined). + static const IconData pool_outlined = IconData(0xf2bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">portable_wifi_off</i> — material icon named "portable wifi off". + static const IconData portable_wifi_off = IconData(0xe4dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">portable_wifi_off</i> — material icon named "portable wifi off" (sharp). + static const IconData portable_wifi_off_sharp = IconData(0xebd3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">portable_wifi_off</i> — material icon named "portable wifi off" (round). + static const IconData portable_wifi_off_rounded = IconData(0xf00b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">portable_wifi_off</i> — material icon named "portable wifi off" (outlined). + static const IconData portable_wifi_off_outlined = IconData(0xf2c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">portrait</i> — material icon named "portrait". + static const IconData portrait = IconData(0xe4de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">portrait</i> — material icon named "portrait" (sharp). + static const IconData portrait_sharp = IconData(0xebd4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">portrait</i> — material icon named "portrait" (round). + static const IconData portrait_rounded = IconData(0xf00b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">portrait</i> — material icon named "portrait" (outlined). + static const IconData portrait_outlined = IconData(0xf2c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">post_add</i> — material icon named "post add". + static const IconData post_add = IconData(0xe4df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">post_add</i> — material icon named "post add" (sharp). + static const IconData post_add_sharp = IconData(0xebd5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">post_add</i> — material icon named "post add" (round). + static const IconData post_add_rounded = IconData(0xf00b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">post_add</i> — material icon named "post add" (outlined). + static const IconData post_add_outlined = IconData(0xf2c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">power</i> — material icon named "power". + static const IconData power = IconData(0xe4e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">power</i> — material icon named "power" (sharp). + static const IconData power_sharp = IconData(0xebd9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">power</i> — material icon named "power" (round). + static const IconData power_rounded = IconData(0xf00b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">power</i> — material icon named "power" (outlined). + static const IconData power_outlined = IconData(0xf2c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">power_input</i> — material icon named "power input". + static const IconData power_input = IconData(0xe4e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">power_input</i> — material icon named "power input" (sharp). + static const IconData power_input_sharp = IconData(0xebd6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">power_input</i> — material icon named "power input" (round). + static const IconData power_input_rounded = IconData(0xf00b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">power_input</i> — material icon named "power input" (outlined). + static const IconData power_input_outlined = IconData(0xf2c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">power_off</i> — material icon named "power off". + static const IconData power_off = IconData(0xe4e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">power_off</i> — material icon named "power off" (sharp). + static const IconData power_off_sharp = IconData(0xebd7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">power_off</i> — material icon named "power off" (round). + static const IconData power_off_rounded = IconData(0xf00b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">power_off</i> — material icon named "power off" (outlined). + static const IconData power_off_outlined = IconData(0xf2c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">power_settings_new</i> — material icon named "power settings new". + static const IconData power_settings_new = IconData(0xe4e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">power_settings_new</i> — material icon named "power settings new" (sharp). + static const IconData power_settings_new_sharp = IconData(0xebd8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">power_settings_new</i> — material icon named "power settings new" (round). + static const IconData power_settings_new_rounded = IconData(0xf00b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">power_settings_new</i> — material icon named "power settings new" (outlined). + static const IconData power_settings_new_outlined = IconData(0xf2c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">precision_manufacturing</i> — material icon named "precision manufacturing". + static const IconData precision_manufacturing = IconData(0xe4e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">precision_manufacturing</i> — material icon named "precision manufacturing" (sharp). + static const IconData precision_manufacturing_sharp = IconData( + 0xebda, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">precision_manufacturing</i> — material icon named "precision manufacturing" (round). + static const IconData precision_manufacturing_rounded = IconData( + 0xf00b9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">precision_manufacturing</i> — material icon named "precision manufacturing" (outlined). + static const IconData precision_manufacturing_outlined = IconData( + 0xf2c7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">pregnant_woman</i> — material icon named "pregnant woman". + static const IconData pregnant_woman = IconData(0xe4e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">pregnant_woman</i> — material icon named "pregnant woman" (sharp). + static const IconData pregnant_woman_sharp = IconData(0xebdb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">pregnant_woman</i> — material icon named "pregnant woman" (round). + static const IconData pregnant_woman_rounded = IconData(0xf00ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">pregnant_woman</i> — material icon named "pregnant woman" (outlined). + static const IconData pregnant_woman_outlined = IconData(0xf2c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">present_to_all</i> — material icon named "present to all". + static const IconData present_to_all = IconData(0xe4e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">present_to_all</i> — material icon named "present to all" (sharp). + static const IconData present_to_all_sharp = IconData(0xebdc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">present_to_all</i> — material icon named "present to all" (round). + static const IconData present_to_all_rounded = IconData(0xf00bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">present_to_all</i> — material icon named "present to all" (outlined). + static const IconData present_to_all_outlined = IconData(0xf2c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">preview</i> — material icon named "preview". + static const IconData preview = IconData(0xe4e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">preview</i> — material icon named "preview" (sharp). + static const IconData preview_sharp = IconData(0xebdd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">preview</i> — material icon named "preview" (round). + static const IconData preview_rounded = IconData(0xf00bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">preview</i> — material icon named "preview" (outlined). + static const IconData preview_outlined = IconData(0xf2ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">price_change</i> — material icon named "price change". + static const IconData price_change = IconData(0xe4e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">price_change</i> — material icon named "price change" (sharp). + static const IconData price_change_sharp = IconData(0xebde, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">price_change</i> — material icon named "price change" (round). + static const IconData price_change_rounded = IconData(0xf00bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">price_change</i> — material icon named "price change" (outlined). + static const IconData price_change_outlined = IconData(0xf2cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">price_check</i> — material icon named "price check". + static const IconData price_check = IconData(0xe4e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">price_check</i> — material icon named "price check" (sharp). + static const IconData price_check_sharp = IconData(0xebdf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">price_check</i> — material icon named "price check" (round). + static const IconData price_check_rounded = IconData(0xf00be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">price_check</i> — material icon named "price check" (outlined). + static const IconData price_check_outlined = IconData(0xf2cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">print</i> — material icon named "print". + static const IconData print = IconData(0xe4ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">print</i> — material icon named "print" (sharp). + static const IconData print_sharp = IconData(0xebe1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">print</i> — material icon named "print" (round). + static const IconData print_rounded = IconData(0xf00c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">print</i> — material icon named "print" (outlined). + static const IconData print_outlined = IconData(0xf2ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">print_disabled</i> — material icon named "print disabled". + static const IconData print_disabled = IconData(0xe4eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">print_disabled</i> — material icon named "print disabled" (sharp). + static const IconData print_disabled_sharp = IconData(0xebe0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">print_disabled</i> — material icon named "print disabled" (round). + static const IconData print_disabled_rounded = IconData(0xf00bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">print_disabled</i> — material icon named "print disabled" (outlined). + static const IconData print_disabled_outlined = IconData(0xf2cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">priority_high</i> — material icon named "priority high". + static const IconData priority_high = IconData(0xe4ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">priority_high</i> — material icon named "priority high" (sharp). + static const IconData priority_high_sharp = IconData(0xebe2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">priority_high</i> — material icon named "priority high" (round). + static const IconData priority_high_rounded = IconData(0xf00c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">priority_high</i> — material icon named "priority high" (outlined). + static const IconData priority_high_outlined = IconData(0xf2cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">privacy_tip</i> — material icon named "privacy tip". + static const IconData privacy_tip = IconData(0xe4ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">privacy_tip</i> — material icon named "privacy tip" (sharp). + static const IconData privacy_tip_sharp = IconData(0xebe3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">privacy_tip</i> — material icon named "privacy tip" (round). + static const IconData privacy_tip_rounded = IconData(0xf00c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">privacy_tip</i> — material icon named "privacy tip" (outlined). + static const IconData privacy_tip_outlined = IconData(0xf2d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">private_connectivity</i> — material icon named "private connectivity". + static const IconData private_connectivity = IconData(0xf0553, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">private_connectivity</i> — material icon named "private connectivity" (sharp). + static const IconData private_connectivity_sharp = IconData(0xf045c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">private_connectivity</i> — material icon named "private connectivity" (round). + static const IconData private_connectivity_rounded = IconData( + 0xf0369, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">private_connectivity</i> — material icon named "private connectivity" (outlined). + static const IconData private_connectivity_outlined = IconData( + 0xf064a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">production_quantity_limits</i> — material icon named "production quantity limits". + static const IconData production_quantity_limits = IconData(0xe4ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">production_quantity_limits</i> — material icon named "production quantity limits" (sharp). + static const IconData production_quantity_limits_sharp = IconData( + 0xebe4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">production_quantity_limits</i> — material icon named "production quantity limits" (round). + static const IconData production_quantity_limits_rounded = IconData( + 0xf00c3, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">production_quantity_limits</i> — material icon named "production quantity limits" (outlined). + static const IconData production_quantity_limits_outlined = IconData( + 0xf2d1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">propane</i> — material icon named "propane". + static const IconData propane = IconData(0xf07b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">propane</i> — material icon named "propane" (sharp). + static const IconData propane_sharp = IconData(0xf0761, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">propane</i> — material icon named "propane" (round). + static const IconData propane_rounded = IconData(0xf0811, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">propane</i> — material icon named "propane" (outlined). + static const IconData propane_outlined = IconData(0xf0709, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">propane_tank</i> — material icon named "propane tank". + static const IconData propane_tank = IconData(0xf07ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">propane_tank</i> — material icon named "propane tank" (sharp). + static const IconData propane_tank_sharp = IconData(0xf0762, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">propane_tank</i> — material icon named "propane tank" (round). + static const IconData propane_tank_rounded = IconData(0xf0812, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">propane_tank</i> — material icon named "propane tank" (outlined). + static const IconData propane_tank_outlined = IconData(0xf070a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">psychology</i> — material icon named "psychology". + static const IconData psychology = IconData(0xe4ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">psychology</i> — material icon named "psychology" (sharp). + static const IconData psychology_sharp = IconData(0xebe5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">psychology</i> — material icon named "psychology" (round). + static const IconData psychology_rounded = IconData(0xf00c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">psychology</i> — material icon named "psychology" (outlined). + static const IconData psychology_outlined = IconData(0xf2d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">psychology_alt</i> — material icon named "psychology alt". + static const IconData psychology_alt = IconData(0xf0873, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">psychology_alt</i> — material icon named "psychology alt" (sharp). + static const IconData psychology_alt_sharp = IconData(0xf084a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">psychology_alt</i> — material icon named "psychology alt" (round). + static const IconData psychology_alt_rounded = IconData(0xf0893, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">psychology_alt</i> — material icon named "psychology alt" (outlined). + static const IconData psychology_alt_outlined = IconData(0xf08b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">public</i> — material icon named "public". + static const IconData public = IconData(0xe4f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">public</i> — material icon named "public" (sharp). + static const IconData public_sharp = IconData(0xebe7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">public</i> — material icon named "public" (round). + static const IconData public_rounded = IconData(0xf00c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">public</i> — material icon named "public" (outlined). + static const IconData public_outlined = IconData(0xf2d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">public_off</i> — material icon named "public off". + static const IconData public_off = IconData(0xe4f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">public_off</i> — material icon named "public off" (sharp). + static const IconData public_off_sharp = IconData(0xebe6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">public_off</i> — material icon named "public off" (round). + static const IconData public_off_rounded = IconData(0xf00c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">public_off</i> — material icon named "public off" (outlined). + static const IconData public_off_outlined = IconData(0xf2d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">publish</i> — material icon named "publish". + static const IconData publish = IconData(0xe4f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">publish</i> — material icon named "publish" (sharp). + static const IconData publish_sharp = IconData(0xebe8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">publish</i> — material icon named "publish" (round). + static const IconData publish_rounded = IconData(0xf00c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">publish</i> — material icon named "publish" (outlined). + static const IconData publish_outlined = IconData(0xf2d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">published_with_changes</i> — material icon named "published with changes". + static const IconData published_with_changes = IconData(0xe4f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">published_with_changes</i> — material icon named "published with changes" (sharp). + static const IconData published_with_changes_sharp = IconData( + 0xebe9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">published_with_changes</i> — material icon named "published with changes" (round). + static const IconData published_with_changes_rounded = IconData( + 0xf00c8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">published_with_changes</i> — material icon named "published with changes" (outlined). + static const IconData published_with_changes_outlined = IconData( + 0xf2d6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">punch_clock</i> — material icon named "punch clock". + static const IconData punch_clock = IconData(0xf0554, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">punch_clock</i> — material icon named "punch clock" (sharp). + static const IconData punch_clock_sharp = IconData(0xf045d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">punch_clock</i> — material icon named "punch clock" (round). + static const IconData punch_clock_rounded = IconData(0xf036a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">punch_clock</i> — material icon named "punch clock" (outlined). + static const IconData punch_clock_outlined = IconData(0xf064b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">push_pin</i> — material icon named "push pin". + static const IconData push_pin = IconData(0xe4f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">push_pin</i> — material icon named "push pin" (sharp). + static const IconData push_pin_sharp = IconData(0xebea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">push_pin</i> — material icon named "push pin" (round). + static const IconData push_pin_rounded = IconData(0xf00c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">push_pin</i> — material icon named "push pin" (outlined). + static const IconData push_pin_outlined = IconData(0xf2d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">qr_code</i> — material icon named "qr code". + static const IconData qr_code = IconData(0xe4f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">qr_code</i> — material icon named "qr code" (sharp). + static const IconData qr_code_sharp = IconData(0xebed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">qr_code</i> — material icon named "qr code" (round). + static const IconData qr_code_rounded = IconData(0xf00cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">qr_code</i> — material icon named "qr code" (outlined). + static const IconData qr_code_outlined = IconData(0xf2d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">qr_code_2</i> — material icon named "qr code 2". + static const IconData qr_code_2 = IconData(0xe4f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">qr_code_2</i> — material icon named "qr code 2" (sharp). + static const IconData qr_code_2_sharp = IconData(0xebeb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">qr_code_2</i> — material icon named "qr code 2" (round). + static const IconData qr_code_2_rounded = IconData(0xf00ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">qr_code_2</i> — material icon named "qr code 2" (outlined). + static const IconData qr_code_2_outlined = IconData(0xf2d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">qr_code_scanner</i> — material icon named "qr code scanner". + static const IconData qr_code_scanner = IconData(0xe4f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">qr_code_scanner</i> — material icon named "qr code scanner" (sharp). + static const IconData qr_code_scanner_sharp = IconData(0xebec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">qr_code_scanner</i> — material icon named "qr code scanner" (round). + static const IconData qr_code_scanner_rounded = IconData(0xf00cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">qr_code_scanner</i> — material icon named "qr code scanner" (outlined). + static const IconData qr_code_scanner_outlined = IconData(0xf2da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">query_builder</i> — material icon named "query builder". + static const IconData query_builder = IconData(0xe4f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">query_builder</i> — material icon named "query builder" (sharp). + static const IconData query_builder_sharp = IconData(0xebee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">query_builder</i> — material icon named "query builder" (round). + static const IconData query_builder_rounded = IconData(0xf00cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">query_builder</i> — material icon named "query builder" (outlined). + static const IconData query_builder_outlined = IconData(0xf2db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">query_stats</i> — material icon named "query stats". + static const IconData query_stats = IconData(0xe4f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">query_stats</i> — material icon named "query stats" (sharp). + static const IconData query_stats_sharp = IconData(0xebef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">query_stats</i> — material icon named "query stats" (round). + static const IconData query_stats_rounded = IconData(0xf00ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">query_stats</i> — material icon named "query stats" (outlined). + static const IconData query_stats_outlined = IconData(0xf2dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">question_answer</i> — material icon named "question answer". + static const IconData question_answer = IconData(0xe4fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">question_answer</i> — material icon named "question answer" (sharp). + static const IconData question_answer_sharp = IconData(0xebf0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">question_answer</i> — material icon named "question answer" (round). + static const IconData question_answer_rounded = IconData(0xf00cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">question_answer</i> — material icon named "question answer" (outlined). + static const IconData question_answer_outlined = IconData(0xf2dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">question_mark</i> — material icon named "question mark". + static const IconData question_mark = IconData(0xf0555, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">question_mark</i> — material icon named "question mark" (sharp). + static const IconData question_mark_sharp = IconData(0xf045e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">question_mark</i> — material icon named "question mark" (round). + static const IconData question_mark_rounded = IconData(0xf036b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">question_mark</i> — material icon named "question mark" (outlined). + static const IconData question_mark_outlined = IconData(0xf064c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">queue</i> — material icon named "queue". + static const IconData queue = IconData(0xe4fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">queue</i> — material icon named "queue" (sharp). + static const IconData queue_sharp = IconData(0xebf3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">queue</i> — material icon named "queue" (round). + static const IconData queue_rounded = IconData(0xf00d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">queue</i> — material icon named "queue" (outlined). + static const IconData queue_outlined = IconData(0xf2df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">queue_music</i> — material icon named "queue music". + static const IconData queue_music = IconData( + 0xe4fc, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">queue_music</i> — material icon named "queue music" (sharp). + static const IconData queue_music_sharp = IconData( + 0xebf1, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">queue_music</i> — material icon named "queue music" (round). + static const IconData queue_music_rounded = IconData( + 0xf00d0, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">queue_music</i> — material icon named "queue music" (outlined). + static const IconData queue_music_outlined = IconData( + 0xf2de, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">queue_play_next</i> — material icon named "queue play next". + static const IconData queue_play_next = IconData(0xe4fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">queue_play_next</i> — material icon named "queue play next" (sharp). + static const IconData queue_play_next_sharp = IconData(0xebf2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">queue_play_next</i> — material icon named "queue play next" (round). + static const IconData queue_play_next_rounded = IconData(0xf00d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">queue_play_next</i> — material icon named "queue play next" (outlined). + static const IconData queue_play_next_outlined = IconData(0xf2e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">quick_contacts_dialer</i> — material icon named "quick contacts dialer". + static const IconData quick_contacts_dialer = IconData(0xe18c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">quick_contacts_dialer</i> — material icon named "quick contacts dialer" (sharp). + static const IconData quick_contacts_dialer_sharp = IconData(0xe889, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">quick_contacts_dialer</i> — material icon named "quick contacts dialer" (round). + static const IconData quick_contacts_dialer_rounded = IconData( + 0xf668, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">quick_contacts_dialer</i> — material icon named "quick contacts dialer" (outlined). + static const IconData quick_contacts_dialer_outlined = IconData( + 0xef7b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">quick_contacts_mail</i> — material icon named "quick contacts mail". + static const IconData quick_contacts_mail = IconData(0xe18a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">quick_contacts_mail</i> — material icon named "quick contacts mail" (sharp). + static const IconData quick_contacts_mail_sharp = IconData(0xe887, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">quick_contacts_mail</i> — material icon named "quick contacts mail" (round). + static const IconData quick_contacts_mail_rounded = IconData(0xf666, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">quick_contacts_mail</i> — material icon named "quick contacts mail" (outlined). + static const IconData quick_contacts_mail_outlined = IconData( + 0xef79, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">quickreply</i> — material icon named "quickreply". + static const IconData quickreply = IconData(0xe4fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">quickreply</i> — material icon named "quickreply" (sharp). + static const IconData quickreply_sharp = IconData(0xebf4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">quickreply</i> — material icon named "quickreply" (round). + static const IconData quickreply_rounded = IconData(0xf00d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">quickreply</i> — material icon named "quickreply" (outlined). + static const IconData quickreply_outlined = IconData(0xf2e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">quiz</i> — material icon named "quiz". + static const IconData quiz = IconData(0xe4ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">quiz</i> — material icon named "quiz" (sharp). + static const IconData quiz_sharp = IconData(0xebf5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">quiz</i> — material icon named "quiz" (round). + static const IconData quiz_rounded = IconData(0xf00d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">quiz</i> — material icon named "quiz" (outlined). + static const IconData quiz_outlined = IconData(0xf2e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">quora</i> — material icon named "quora". + static const IconData quora = IconData(0xf0556, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">quora</i> — material icon named "quora" (sharp). + static const IconData quora_sharp = IconData(0xf045f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">quora</i> — material icon named "quora" (round). + static const IconData quora_rounded = IconData(0xf036c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">quora</i> — material icon named "quora" (outlined). + static const IconData quora_outlined = IconData(0xf064d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">r_mobiledata</i> — material icon named "r mobiledata". + static const IconData r_mobiledata = IconData(0xe500, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">r_mobiledata</i> — material icon named "r mobiledata" (sharp). + static const IconData r_mobiledata_sharp = IconData(0xebf6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">r_mobiledata</i> — material icon named "r mobiledata" (round). + static const IconData r_mobiledata_rounded = IconData(0xf00d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">r_mobiledata</i> — material icon named "r mobiledata" (outlined). + static const IconData r_mobiledata_outlined = IconData(0xf2e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">radar</i> — material icon named "radar". + static const IconData radar = IconData(0xe501, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">radar</i> — material icon named "radar" (sharp). + static const IconData radar_sharp = IconData(0xebf7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">radar</i> — material icon named "radar" (round). + static const IconData radar_rounded = IconData(0xf00d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">radar</i> — material icon named "radar" (outlined). + static const IconData radar_outlined = IconData(0xf2e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">radio</i> — material icon named "radio". + static const IconData radio = IconData(0xe502, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">radio</i> — material icon named "radio" (sharp). + static const IconData radio_sharp = IconData(0xebfa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">radio</i> — material icon named "radio" (round). + static const IconData radio_rounded = IconData(0xf00d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">radio</i> — material icon named "radio" (outlined). + static const IconData radio_outlined = IconData(0xf2e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">radio_button_checked</i> — material icon named "radio button checked". + static const IconData radio_button_checked = IconData(0xe503, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">radio_button_checked</i> — material icon named "radio button checked" (sharp). + static const IconData radio_button_checked_sharp = IconData(0xebf8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">radio_button_checked</i> — material icon named "radio button checked" (round). + static const IconData radio_button_checked_rounded = IconData( + 0xf00d7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">radio_button_checked</i> — material icon named "radio button checked" (outlined). + static const IconData radio_button_checked_outlined = IconData( + 0xf2e5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">radio_button_off</i> — material icon named "radio button off". + static const IconData radio_button_off = IconData(0xe504, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">radio_button_off</i> — material icon named "radio button off" (sharp). + static const IconData radio_button_off_sharp = IconData(0xebf9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">radio_button_off</i> — material icon named "radio button off" (round). + static const IconData radio_button_off_rounded = IconData(0xf00d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">radio_button_off</i> — material icon named "radio button off" (outlined). + static const IconData radio_button_off_outlined = IconData(0xf2e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">radio_button_on</i> — material icon named "radio button on". + static const IconData radio_button_on = IconData(0xe503, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">radio_button_on</i> — material icon named "radio button on" (sharp). + static const IconData radio_button_on_sharp = IconData(0xebf8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">radio_button_on</i> — material icon named "radio button on" (round). + static const IconData radio_button_on_rounded = IconData(0xf00d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">radio_button_on</i> — material icon named "radio button on" (outlined). + static const IconData radio_button_on_outlined = IconData(0xf2e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">radio_button_unchecked</i> — material icon named "radio button unchecked". + static const IconData radio_button_unchecked = IconData(0xe504, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">radio_button_unchecked</i> — material icon named "radio button unchecked" (sharp). + static const IconData radio_button_unchecked_sharp = IconData( + 0xebf9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">radio_button_unchecked</i> — material icon named "radio button unchecked" (round). + static const IconData radio_button_unchecked_rounded = IconData( + 0xf00d8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">radio_button_unchecked</i> — material icon named "radio button unchecked" (outlined). + static const IconData radio_button_unchecked_outlined = IconData( + 0xf2e6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">railway_alert</i> — material icon named "railway alert". + static const IconData railway_alert = IconData(0xe505, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">railway_alert</i> — material icon named "railway alert" (sharp). + static const IconData railway_alert_sharp = IconData(0xebfb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">railway_alert</i> — material icon named "railway alert" (round). + static const IconData railway_alert_rounded = IconData(0xf00da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">railway_alert</i> — material icon named "railway alert" (outlined). + static const IconData railway_alert_outlined = IconData(0xf2e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ramen_dining</i> — material icon named "ramen dining". + static const IconData ramen_dining = IconData(0xe506, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ramen_dining</i> — material icon named "ramen dining" (sharp). + static const IconData ramen_dining_sharp = IconData(0xebfc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ramen_dining</i> — material icon named "ramen dining" (round). + static const IconData ramen_dining_rounded = IconData(0xf00db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ramen_dining</i> — material icon named "ramen dining" (outlined). + static const IconData ramen_dining_outlined = IconData(0xf2e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ramp_left</i> — material icon named "ramp left". + static const IconData ramp_left = IconData(0xf0557, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ramp_left</i> — material icon named "ramp left" (sharp). + static const IconData ramp_left_sharp = IconData(0xf0460, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ramp_left</i> — material icon named "ramp left" (round). + static const IconData ramp_left_rounded = IconData(0xf036d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ramp_left</i> — material icon named "ramp left" (outlined). + static const IconData ramp_left_outlined = IconData(0xf064e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ramp_right</i> — material icon named "ramp right". + static const IconData ramp_right = IconData(0xf0558, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ramp_right</i> — material icon named "ramp right" (sharp). + static const IconData ramp_right_sharp = IconData(0xf0461, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ramp_right</i> — material icon named "ramp right" (round). + static const IconData ramp_right_rounded = IconData(0xf036e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ramp_right</i> — material icon named "ramp right" (outlined). + static const IconData ramp_right_outlined = IconData(0xf064f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rate_review</i> — material icon named "rate review". + static const IconData rate_review = IconData(0xe507, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rate_review</i> — material icon named "rate review" (sharp). + static const IconData rate_review_sharp = IconData(0xebfd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rate_review</i> — material icon named "rate review" (round). + static const IconData rate_review_rounded = IconData(0xf00dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rate_review</i> — material icon named "rate review" (outlined). + static const IconData rate_review_outlined = IconData(0xf2ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">raw_off</i> — material icon named "raw off". + static const IconData raw_off = IconData(0xe508, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">raw_off</i> — material icon named "raw off" (sharp). + static const IconData raw_off_sharp = IconData(0xebfe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">raw_off</i> — material icon named "raw off" (round). + static const IconData raw_off_rounded = IconData(0xf00dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">raw_off</i> — material icon named "raw off" (outlined). + static const IconData raw_off_outlined = IconData(0xf2eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">raw_on</i> — material icon named "raw on". + static const IconData raw_on = IconData(0xe509, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">raw_on</i> — material icon named "raw on" (sharp). + static const IconData raw_on_sharp = IconData(0xebff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">raw_on</i> — material icon named "raw on" (round). + static const IconData raw_on_rounded = IconData(0xf00de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">raw_on</i> — material icon named "raw on" (outlined). + static const IconData raw_on_outlined = IconData(0xf2ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">read_more</i> — material icon named "read more". + static const IconData read_more = IconData(0xe50a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">read_more</i> — material icon named "read more" (sharp). + static const IconData read_more_sharp = IconData(0xec00, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">read_more</i> — material icon named "read more" (round). + static const IconData read_more_rounded = IconData(0xf00df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">read_more</i> — material icon named "read more" (outlined). + static const IconData read_more_outlined = IconData(0xf2ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">real_estate_agent</i> — material icon named "real estate agent". + static const IconData real_estate_agent = IconData(0xe50b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">real_estate_agent</i> — material icon named "real estate agent" (sharp). + static const IconData real_estate_agent_sharp = IconData(0xec01, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">real_estate_agent</i> — material icon named "real estate agent" (round). + static const IconData real_estate_agent_rounded = IconData(0xf00e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">real_estate_agent</i> — material icon named "real estate agent" (outlined). + static const IconData real_estate_agent_outlined = IconData(0xf2ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rebase_edit</i> — material icon named "rebase edit". + static const IconData rebase_edit = IconData(0xf0874, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">receipt</i> — material icon named "receipt". + static const IconData receipt = IconData(0xe50c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">receipt</i> — material icon named "receipt" (sharp). + static const IconData receipt_sharp = IconData(0xec03, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">receipt</i> — material icon named "receipt" (round). + static const IconData receipt_rounded = IconData(0xf00e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">receipt</i> — material icon named "receipt" (outlined). + static const IconData receipt_outlined = IconData(0xf2f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">receipt_long</i> — material icon named "receipt long". + static const IconData receipt_long = IconData(0xe50d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">receipt_long</i> — material icon named "receipt long" (sharp). + static const IconData receipt_long_sharp = IconData(0xec02, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">receipt_long</i> — material icon named "receipt long" (round). + static const IconData receipt_long_rounded = IconData(0xf00e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">receipt_long</i> — material icon named "receipt long" (outlined). + static const IconData receipt_long_outlined = IconData(0xf2ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">recent_actors</i> — material icon named "recent actors". + static const IconData recent_actors = IconData(0xe50e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">recent_actors</i> — material icon named "recent actors" (sharp). + static const IconData recent_actors_sharp = IconData(0xec04, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">recent_actors</i> — material icon named "recent actors" (round). + static const IconData recent_actors_rounded = IconData(0xf00e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">recent_actors</i> — material icon named "recent actors" (outlined). + static const IconData recent_actors_outlined = IconData(0xf2f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">recommend</i> — material icon named "recommend". + static const IconData recommend = IconData(0xe50f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">recommend</i> — material icon named "recommend" (sharp). + static const IconData recommend_sharp = IconData(0xec05, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">recommend</i> — material icon named "recommend" (round). + static const IconData recommend_rounded = IconData(0xf00e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">recommend</i> — material icon named "recommend" (outlined). + static const IconData recommend_outlined = IconData(0xf2f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">record_voice_over</i> — material icon named "record voice over". + static const IconData record_voice_over = IconData(0xe510, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">record_voice_over</i> — material icon named "record voice over" (sharp). + static const IconData record_voice_over_sharp = IconData(0xec06, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">record_voice_over</i> — material icon named "record voice over" (round). + static const IconData record_voice_over_rounded = IconData(0xf00e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">record_voice_over</i> — material icon named "record voice over" (outlined). + static const IconData record_voice_over_outlined = IconData(0xf2f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rectangle</i> — material icon named "rectangle". + static const IconData rectangle = IconData(0xf0559, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rectangle</i> — material icon named "rectangle" (sharp). + static const IconData rectangle_sharp = IconData(0xf0462, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rectangle</i> — material icon named "rectangle" (round). + static const IconData rectangle_rounded = IconData(0xf036f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rectangle</i> — material icon named "rectangle" (outlined). + static const IconData rectangle_outlined = IconData(0xf0650, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">recycling</i> — material icon named "recycling". + static const IconData recycling = IconData(0xf055a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">recycling</i> — material icon named "recycling" (sharp). + static const IconData recycling_sharp = IconData(0xf0463, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">recycling</i> — material icon named "recycling" (round). + static const IconData recycling_rounded = IconData(0xf0370, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">recycling</i> — material icon named "recycling" (outlined). + static const IconData recycling_outlined = IconData(0xf0651, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">reddit</i> — material icon named "reddit". + static const IconData reddit = IconData(0xf055b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">reddit</i> — material icon named "reddit" (sharp). + static const IconData reddit_sharp = IconData(0xf0464, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">reddit</i> — material icon named "reddit" (round). + static const IconData reddit_rounded = IconData(0xf0371, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">reddit</i> — material icon named "reddit" (outlined). + static const IconData reddit_outlined = IconData(0xf0652, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">redeem</i> — material icon named "redeem". + static const IconData redeem = IconData(0xe511, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">redeem</i> — material icon named "redeem" (sharp). + static const IconData redeem_sharp = IconData(0xec07, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">redeem</i> — material icon named "redeem" (round). + static const IconData redeem_rounded = IconData(0xf00e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">redeem</i> — material icon named "redeem" (outlined). + static const IconData redeem_outlined = IconData(0xf2f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">redo</i> — material icon named "redo". + static const IconData redo = IconData( + 0xe512, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">redo</i> — material icon named "redo" (sharp). + static const IconData redo_sharp = IconData( + 0xec08, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">redo</i> — material icon named "redo" (round). + static const IconData redo_rounded = IconData( + 0xf00e7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">redo</i> — material icon named "redo" (outlined). + static const IconData redo_outlined = IconData( + 0xf2f5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">reduce_capacity</i> — material icon named "reduce capacity". + static const IconData reduce_capacity = IconData(0xe513, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">reduce_capacity</i> — material icon named "reduce capacity" (sharp). + static const IconData reduce_capacity_sharp = IconData(0xec09, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">reduce_capacity</i> — material icon named "reduce capacity" (round). + static const IconData reduce_capacity_rounded = IconData(0xf00e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">reduce_capacity</i> — material icon named "reduce capacity" (outlined). + static const IconData reduce_capacity_outlined = IconData(0xf2f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">refresh</i> — material icon named "refresh". + static const IconData refresh = IconData(0xe514, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">refresh</i> — material icon named "refresh" (sharp). + static const IconData refresh_sharp = IconData(0xec0a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">refresh</i> — material icon named "refresh" (round). + static const IconData refresh_rounded = IconData(0xf00e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">refresh</i> — material icon named "refresh" (outlined). + static const IconData refresh_outlined = IconData(0xf2f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remember_me</i> — material icon named "remember me". + static const IconData remember_me = IconData(0xe515, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remember_me</i> — material icon named "remember me" (sharp). + static const IconData remember_me_sharp = IconData(0xec0b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remember_me</i> — material icon named "remember me" (round). + static const IconData remember_me_rounded = IconData(0xf00ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remember_me</i> — material icon named "remember me" (outlined). + static const IconData remember_me_outlined = IconData(0xf2f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove</i> — material icon named "remove". + static const IconData remove = IconData(0xe516, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove</i> — material icon named "remove" (sharp). + static const IconData remove_sharp = IconData(0xec12, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove</i> — material icon named "remove" (round). + static const IconData remove_rounded = IconData(0xf00f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove</i> — material icon named "remove" (outlined). + static const IconData remove_outlined = IconData(0xf2fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_circle</i> — material icon named "remove circle". + static const IconData remove_circle = IconData(0xe517, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_circle</i> — material icon named "remove circle" (sharp). + static const IconData remove_circle_sharp = IconData(0xec0d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_circle</i> — material icon named "remove circle" (round). + static const IconData remove_circle_rounded = IconData(0xf00ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove_circle</i> — material icon named "remove circle" (outlined). + static const IconData remove_circle_outlined = IconData(0xf2fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_circle_outline</i> — material icon named "remove circle outline". + static const IconData remove_circle_outline = IconData(0xe518, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_circle_outline</i> — material icon named "remove circle outline" (sharp). + static const IconData remove_circle_outline_sharp = IconData(0xec0c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_circle_outline</i> — material icon named "remove circle outline" (round). + static const IconData remove_circle_outline_rounded = IconData( + 0xf00eb, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">remove_circle_outline</i> — material icon named "remove circle outline" (outlined). + static const IconData remove_circle_outline_outlined = IconData( + 0xf2f9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">remove_done</i> — material icon named "remove done". + static const IconData remove_done = IconData(0xe519, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_done</i> — material icon named "remove done" (sharp). + static const IconData remove_done_sharp = IconData(0xec0e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_done</i> — material icon named "remove done" (round). + static const IconData remove_done_rounded = IconData(0xf00ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove_done</i> — material icon named "remove done" (outlined). + static const IconData remove_done_outlined = IconData(0xf2fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_from_queue</i> — material icon named "remove from queue". + static const IconData remove_from_queue = IconData(0xe51a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_from_queue</i> — material icon named "remove from queue" (sharp). + static const IconData remove_from_queue_sharp = IconData(0xec0f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_from_queue</i> — material icon named "remove from queue" (round). + static const IconData remove_from_queue_rounded = IconData(0xf00ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove_from_queue</i> — material icon named "remove from queue" (outlined). + static const IconData remove_from_queue_outlined = IconData(0xf2fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_moderator</i> — material icon named "remove moderator". + static const IconData remove_moderator = IconData(0xe51b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_moderator</i> — material icon named "remove moderator" (sharp). + static const IconData remove_moderator_sharp = IconData(0xec10, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_moderator</i> — material icon named "remove moderator" (round). + static const IconData remove_moderator_rounded = IconData(0xf00ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove_moderator</i> — material icon named "remove moderator" (outlined). + static const IconData remove_moderator_outlined = IconData(0xf2fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_red_eye</i> — material icon named "remove red eye". + static const IconData remove_red_eye = IconData(0xe51c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_red_eye</i> — material icon named "remove red eye" (sharp). + static const IconData remove_red_eye_sharp = IconData(0xec11, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_red_eye</i> — material icon named "remove red eye" (round). + static const IconData remove_red_eye_rounded = IconData(0xf00f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove_red_eye</i> — material icon named "remove red eye" (outlined). + static const IconData remove_red_eye_outlined = IconData(0xf2ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_road</i> — material icon named "remove road". + static const IconData remove_road = IconData(0xf07bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_road</i> — material icon named "remove road" (sharp). + static const IconData remove_road_sharp = IconData(0xf0763, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_road</i> — material icon named "remove road" (round). + static const IconData remove_road_rounded = IconData(0xf0813, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">remove_road</i> — material icon named "remove road" (outlined). + static const IconData remove_road_outlined = IconData(0xf070b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">remove_shopping_cart</i> — material icon named "remove shopping cart". + static const IconData remove_shopping_cart = IconData(0xe51d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">remove_shopping_cart</i> — material icon named "remove shopping cart" (sharp). + static const IconData remove_shopping_cart_sharp = IconData(0xec13, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">remove_shopping_cart</i> — material icon named "remove shopping cart" (round). + static const IconData remove_shopping_cart_rounded = IconData( + 0xf00f2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">remove_shopping_cart</i> — material icon named "remove shopping cart" (outlined). + static const IconData remove_shopping_cart_outlined = IconData( + 0xf300, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">reorder</i> — material icon named "reorder". + static const IconData reorder = IconData(0xe51e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">reorder</i> — material icon named "reorder" (sharp). + static const IconData reorder_sharp = IconData(0xec14, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">reorder</i> — material icon named "reorder" (round). + static const IconData reorder_rounded = IconData(0xf00f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">reorder</i> — material icon named "reorder" (outlined). + static const IconData reorder_outlined = IconData(0xf301, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">repartition</i> — material icon named "repartition". + static const IconData repartition = IconData(0xf0875, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">repartition</i> — material icon named "repartition" (sharp). + static const IconData repartition_sharp = IconData(0xf084b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">repartition</i> — material icon named "repartition" (round). + static const IconData repartition_rounded = IconData(0xf0894, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">repartition</i> — material icon named "repartition" (outlined). + static const IconData repartition_outlined = IconData(0xf08b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">repeat</i> — material icon named "repeat". + static const IconData repeat = IconData(0xe51f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">repeat</i> — material icon named "repeat" (sharp). + static const IconData repeat_sharp = IconData(0xec18, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">repeat</i> — material icon named "repeat" (round). + static const IconData repeat_rounded = IconData(0xf00f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">repeat</i> — material icon named "repeat" (outlined). + static const IconData repeat_outlined = IconData(0xf305, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">repeat_on</i> — material icon named "repeat on". + static const IconData repeat_on = IconData(0xe520, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">repeat_on</i> — material icon named "repeat on" (sharp). + static const IconData repeat_on_sharp = IconData(0xec15, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">repeat_on</i> — material icon named "repeat on" (round). + static const IconData repeat_on_rounded = IconData(0xf00f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">repeat_on</i> — material icon named "repeat on" (outlined). + static const IconData repeat_on_outlined = IconData(0xf302, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">repeat_one</i> — material icon named "repeat one". + static const IconData repeat_one = IconData(0xe521, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">repeat_one</i> — material icon named "repeat one" (sharp). + static const IconData repeat_one_sharp = IconData(0xec17, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">repeat_one</i> — material icon named "repeat one" (round). + static const IconData repeat_one_rounded = IconData(0xf00f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">repeat_one</i> — material icon named "repeat one" (outlined). + static const IconData repeat_one_outlined = IconData(0xf304, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">repeat_one_on</i> — material icon named "repeat one on". + static const IconData repeat_one_on = IconData(0xe522, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">repeat_one_on</i> — material icon named "repeat one on" (sharp). + static const IconData repeat_one_on_sharp = IconData(0xec16, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">repeat_one_on</i> — material icon named "repeat one on" (round). + static const IconData repeat_one_on_rounded = IconData(0xf00f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">repeat_one_on</i> — material icon named "repeat one on" (outlined). + static const IconData repeat_one_on_outlined = IconData(0xf303, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">replay</i> — material icon named "replay". + static const IconData replay = IconData(0xe523, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">replay</i> — material icon named "replay" (sharp). + static const IconData replay_sharp = IconData(0xec1d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">replay</i> — material icon named "replay" (round). + static const IconData replay_rounded = IconData(0xf00fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">replay</i> — material icon named "replay" (outlined). + static const IconData replay_outlined = IconData(0xf30a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">replay_10</i> — material icon named "replay 10". + static const IconData replay_10 = IconData(0xe524, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">replay_10</i> — material icon named "replay 10" (sharp). + static const IconData replay_10_sharp = IconData(0xec19, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">replay_10</i> — material icon named "replay 10" (round). + static const IconData replay_10_rounded = IconData(0xf00f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">replay_10</i> — material icon named "replay 10" (outlined). + static const IconData replay_10_outlined = IconData(0xf306, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">replay_30</i> — material icon named "replay 30". + static const IconData replay_30 = IconData(0xe525, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">replay_30</i> — material icon named "replay 30" (sharp). + static const IconData replay_30_sharp = IconData(0xec1a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">replay_30</i> — material icon named "replay 30" (round). + static const IconData replay_30_rounded = IconData(0xf00f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">replay_30</i> — material icon named "replay 30" (outlined). + static const IconData replay_30_outlined = IconData(0xf307, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">replay_5</i> — material icon named "replay 5". + static const IconData replay_5 = IconData(0xe526, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">replay_5</i> — material icon named "replay 5" (sharp). + static const IconData replay_5_sharp = IconData(0xec1b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">replay_5</i> — material icon named "replay 5" (round). + static const IconData replay_5_rounded = IconData(0xf00fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">replay_5</i> — material icon named "replay 5" (outlined). + static const IconData replay_5_outlined = IconData(0xf308, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">replay_circle_filled</i> — material icon named "replay circle filled". + static const IconData replay_circle_filled = IconData(0xe527, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">replay_circle_filled</i> — material icon named "replay circle filled" (sharp). + static const IconData replay_circle_filled_sharp = IconData(0xec1c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">replay_circle_filled</i> — material icon named "replay circle filled" (round). + static const IconData replay_circle_filled_rounded = IconData( + 0xf00fb, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">replay_circle_filled</i> — material icon named "replay circle filled" (outlined). + static const IconData replay_circle_filled_outlined = IconData( + 0xf309, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">reply</i> — material icon named "reply". + static const IconData reply = IconData( + 0xe528, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">reply</i> — material icon named "reply" (sharp). + static const IconData reply_sharp = IconData( + 0xec1f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">reply</i> — material icon named "reply" (round). + static const IconData reply_rounded = IconData( + 0xf00fe, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">reply</i> — material icon named "reply" (outlined). + static const IconData reply_outlined = IconData( + 0xf30c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">reply_all</i> — material icon named "reply all". + static const IconData reply_all = IconData( + 0xe529, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">reply_all</i> — material icon named "reply all" (sharp). + static const IconData reply_all_sharp = IconData( + 0xec1e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">reply_all</i> — material icon named "reply all" (round). + static const IconData reply_all_rounded = IconData( + 0xf00fd, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">reply_all</i> — material icon named "reply all" (outlined). + static const IconData reply_all_outlined = IconData( + 0xf30b, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">report</i> — material icon named "report". + static const IconData report = IconData(0xe52a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">report</i> — material icon named "report" (sharp). + static const IconData report_sharp = IconData(0xec23, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">report</i> — material icon named "report" (round). + static const IconData report_rounded = IconData(0xf0102, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">report</i> — material icon named "report" (outlined). + static const IconData report_outlined = IconData(0xf30f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">report_gmailerrorred</i> — material icon named "report gmailerrorred". + static const IconData report_gmailerrorred = IconData(0xe52b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">report_gmailerrorred</i> — material icon named "report gmailerrorred" (sharp). + static const IconData report_gmailerrorred_sharp = IconData(0xec20, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">report_gmailerrorred</i> — material icon named "report gmailerrorred" (round). + static const IconData report_gmailerrorred_rounded = IconData( + 0xf00ff, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">report_gmailerrorred</i> — material icon named "report gmailerrorred" (outlined). + static const IconData report_gmailerrorred_outlined = IconData( + 0xf30d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">report_off</i> — material icon named "report off". + static const IconData report_off = IconData(0xe52c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">report_off</i> — material icon named "report off" (sharp). + static const IconData report_off_sharp = IconData(0xec21, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">report_off</i> — material icon named "report off" (round). + static const IconData report_off_rounded = IconData(0xf0100, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">report_off</i> — material icon named "report off" (outlined). + static const IconData report_off_outlined = IconData(0xf30e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">report_problem</i> — material icon named "report problem". + static const IconData report_problem = IconData(0xe52d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">report_problem</i> — material icon named "report problem" (sharp). + static const IconData report_problem_sharp = IconData(0xec22, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">report_problem</i> — material icon named "report problem" (round). + static const IconData report_problem_rounded = IconData(0xf0101, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">report_problem</i> — material icon named "report problem" (outlined). + static const IconData report_problem_outlined = IconData(0xf310, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">request_page</i> — material icon named "request page". + static const IconData request_page = IconData(0xe52e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">request_page</i> — material icon named "request page" (sharp). + static const IconData request_page_sharp = IconData(0xec24, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">request_page</i> — material icon named "request page" (round). + static const IconData request_page_rounded = IconData(0xf0103, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">request_page</i> — material icon named "request page" (outlined). + static const IconData request_page_outlined = IconData(0xf311, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">request_quote</i> — material icon named "request quote". + static const IconData request_quote = IconData(0xe52f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">request_quote</i> — material icon named "request quote" (sharp). + static const IconData request_quote_sharp = IconData(0xec25, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">request_quote</i> — material icon named "request quote" (round). + static const IconData request_quote_rounded = IconData(0xf0104, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">request_quote</i> — material icon named "request quote" (outlined). + static const IconData request_quote_outlined = IconData(0xf312, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">reset_tv</i> — material icon named "reset tv". + static const IconData reset_tv = IconData(0xe530, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">reset_tv</i> — material icon named "reset tv" (sharp). + static const IconData reset_tv_sharp = IconData(0xec26, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">reset_tv</i> — material icon named "reset tv" (round). + static const IconData reset_tv_rounded = IconData(0xf0105, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">reset_tv</i> — material icon named "reset tv" (outlined). + static const IconData reset_tv_outlined = IconData(0xf313, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">restart_alt</i> — material icon named "restart alt". + static const IconData restart_alt = IconData(0xe531, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">restart_alt</i> — material icon named "restart alt" (sharp). + static const IconData restart_alt_sharp = IconData(0xec27, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">restart_alt</i> — material icon named "restart alt" (round). + static const IconData restart_alt_rounded = IconData(0xf0106, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">restart_alt</i> — material icon named "restart alt" (outlined). + static const IconData restart_alt_outlined = IconData(0xf314, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">restaurant</i> — material icon named "restaurant". + static const IconData restaurant = IconData(0xe532, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">restaurant</i> — material icon named "restaurant" (sharp). + static const IconData restaurant_sharp = IconData(0xec29, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">restaurant</i> — material icon named "restaurant" (round). + static const IconData restaurant_rounded = IconData(0xf0108, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">restaurant</i> — material icon named "restaurant" (outlined). + static const IconData restaurant_outlined = IconData(0xf316, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">restaurant_menu</i> — material icon named "restaurant menu". + static const IconData restaurant_menu = IconData(0xe533, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">restaurant_menu</i> — material icon named "restaurant menu" (sharp). + static const IconData restaurant_menu_sharp = IconData(0xec28, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">restaurant_menu</i> — material icon named "restaurant menu" (round). + static const IconData restaurant_menu_rounded = IconData(0xf0107, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">restaurant_menu</i> — material icon named "restaurant menu" (outlined). + static const IconData restaurant_menu_outlined = IconData(0xf315, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">restore</i> — material icon named "restore". + static const IconData restore = IconData(0xe534, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">restore</i> — material icon named "restore" (sharp). + static const IconData restore_sharp = IconData(0xec2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">restore</i> — material icon named "restore" (round). + static const IconData restore_rounded = IconData(0xf010b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">restore</i> — material icon named "restore" (outlined). + static const IconData restore_outlined = IconData(0xf318, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">restore_from_trash</i> — material icon named "restore from trash". + static const IconData restore_from_trash = IconData(0xe535, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">restore_from_trash</i> — material icon named "restore from trash" (sharp). + static const IconData restore_from_trash_sharp = IconData(0xec2a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">restore_from_trash</i> — material icon named "restore from trash" (round). + static const IconData restore_from_trash_rounded = IconData(0xf0109, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">restore_from_trash</i> — material icon named "restore from trash" (outlined). + static const IconData restore_from_trash_outlined = IconData(0xf317, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">restore_page</i> — material icon named "restore page". + static const IconData restore_page = IconData(0xe536, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">restore_page</i> — material icon named "restore page" (sharp). + static const IconData restore_page_sharp = IconData(0xec2b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">restore_page</i> — material icon named "restore page" (round). + static const IconData restore_page_rounded = IconData(0xf010a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">restore_page</i> — material icon named "restore page" (outlined). + static const IconData restore_page_outlined = IconData(0xf319, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">reviews</i> — material icon named "reviews". + static const IconData reviews = IconData(0xe537, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">reviews</i> — material icon named "reviews" (sharp). + static const IconData reviews_sharp = IconData(0xec2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">reviews</i> — material icon named "reviews" (round). + static const IconData reviews_rounded = IconData(0xf010c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">reviews</i> — material icon named "reviews" (outlined). + static const IconData reviews_outlined = IconData(0xf31a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rice_bowl</i> — material icon named "rice bowl". + static const IconData rice_bowl = IconData(0xe538, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rice_bowl</i> — material icon named "rice bowl" (sharp). + static const IconData rice_bowl_sharp = IconData(0xec2e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rice_bowl</i> — material icon named "rice bowl" (round). + static const IconData rice_bowl_rounded = IconData(0xf010d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rice_bowl</i> — material icon named "rice bowl" (outlined). + static const IconData rice_bowl_outlined = IconData(0xf31b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ring_volume</i> — material icon named "ring volume". + static const IconData ring_volume = IconData(0xe539, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ring_volume</i> — material icon named "ring volume" (sharp). + static const IconData ring_volume_sharp = IconData(0xec2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ring_volume</i> — material icon named "ring volume" (round). + static const IconData ring_volume_rounded = IconData(0xf010e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ring_volume</i> — material icon named "ring volume" (outlined). + static const IconData ring_volume_outlined = IconData(0xf31c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rocket</i> — material icon named "rocket". + static const IconData rocket = IconData(0xf055c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rocket</i> — material icon named "rocket" (sharp). + static const IconData rocket_sharp = IconData(0xf0466, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rocket</i> — material icon named "rocket" (round). + static const IconData rocket_rounded = IconData(0xf0373, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rocket</i> — material icon named "rocket" (outlined). + static const IconData rocket_outlined = IconData(0xf0654, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rocket_launch</i> — material icon named "rocket launch". + static const IconData rocket_launch = IconData(0xf055d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rocket_launch</i> — material icon named "rocket launch" (sharp). + static const IconData rocket_launch_sharp = IconData(0xf0465, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rocket_launch</i> — material icon named "rocket launch" (round). + static const IconData rocket_launch_rounded = IconData(0xf0372, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rocket_launch</i> — material icon named "rocket launch" (outlined). + static const IconData rocket_launch_outlined = IconData(0xf0653, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">roller_shades</i> — material icon named "roller shades". + static const IconData roller_shades = IconData(0xf07bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">roller_shades</i> — material icon named "roller shades" (sharp). + static const IconData roller_shades_sharp = IconData(0xf0765, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">roller_shades</i> — material icon named "roller shades" (round). + static const IconData roller_shades_rounded = IconData(0xf0815, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">roller_shades</i> — material icon named "roller shades" (outlined). + static const IconData roller_shades_outlined = IconData(0xf070d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">roller_shades_closed</i> — material icon named "roller shades closed". + static const IconData roller_shades_closed = IconData(0xf07bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">roller_shades_closed</i> — material icon named "roller shades closed" (sharp). + static const IconData roller_shades_closed_sharp = IconData(0xf0764, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">roller_shades_closed</i> — material icon named "roller shades closed" (round). + static const IconData roller_shades_closed_rounded = IconData( + 0xf0814, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">roller_shades_closed</i> — material icon named "roller shades closed" (outlined). + static const IconData roller_shades_closed_outlined = IconData( + 0xf070c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">roller_skating</i> — material icon named "roller skating". + static const IconData roller_skating = IconData(0xf06c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">roller_skating</i> — material icon named "roller skating" (sharp). + static const IconData roller_skating_sharp = IconData(0xf06b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">roller_skating</i> — material icon named "roller skating" (round). + static const IconData roller_skating_rounded = IconData(0xf06cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">roller_skating</i> — material icon named "roller skating" (outlined). + static const IconData roller_skating_outlined = IconData(0xf06a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">roofing</i> — material icon named "roofing". + static const IconData roofing = IconData(0xe53a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">roofing</i> — material icon named "roofing" (sharp). + static const IconData roofing_sharp = IconData(0xec30, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">roofing</i> — material icon named "roofing" (round). + static const IconData roofing_rounded = IconData(0xf010f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">roofing</i> — material icon named "roofing" (outlined). + static const IconData roofing_outlined = IconData(0xf31d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">room</i> — material icon named "room". + static const IconData room = IconData(0xe53b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">room</i> — material icon named "room" (sharp). + static const IconData room_sharp = IconData(0xec33, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">room</i> — material icon named "room" (round). + static const IconData room_rounded = IconData(0xf0111, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">room</i> — material icon named "room" (outlined). + static const IconData room_outlined = IconData(0xf31e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">room_preferences</i> — material icon named "room preferences". + static const IconData room_preferences = IconData(0xe53c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">room_preferences</i> — material icon named "room preferences" (sharp). + static const IconData room_preferences_sharp = IconData(0xec31, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">room_preferences</i> — material icon named "room preferences" (round). + static const IconData room_preferences_rounded = IconData(0xf0110, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">room_preferences</i> — material icon named "room preferences" (outlined). + static const IconData room_preferences_outlined = IconData(0xf31f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">room_service</i> — material icon named "room service". + static const IconData room_service = IconData(0xe53d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">room_service</i> — material icon named "room service" (sharp). + static const IconData room_service_sharp = IconData(0xec32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">room_service</i> — material icon named "room service" (round). + static const IconData room_service_rounded = IconData(0xf0112, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">room_service</i> — material icon named "room service" (outlined). + static const IconData room_service_outlined = IconData(0xf320, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rotate_90_degrees_ccw</i> — material icon named "rotate 90 degrees ccw". + static const IconData rotate_90_degrees_ccw = IconData(0xe53e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rotate_90_degrees_ccw</i> — material icon named "rotate 90 degrees ccw" (sharp). + static const IconData rotate_90_degrees_ccw_sharp = IconData(0xec34, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rotate_90_degrees_ccw</i> — material icon named "rotate 90 degrees ccw" (round). + static const IconData rotate_90_degrees_ccw_rounded = IconData( + 0xf0113, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">rotate_90_degrees_ccw</i> — material icon named "rotate 90 degrees ccw" (outlined). + static const IconData rotate_90_degrees_ccw_outlined = IconData( + 0xf321, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">rotate_90_degrees_cw</i> — material icon named "rotate 90 degrees cw". + static const IconData rotate_90_degrees_cw = IconData(0xf055e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rotate_90_degrees_cw</i> — material icon named "rotate 90 degrees cw" (sharp). + static const IconData rotate_90_degrees_cw_sharp = IconData(0xf0467, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rotate_90_degrees_cw</i> — material icon named "rotate 90 degrees cw" (round). + static const IconData rotate_90_degrees_cw_rounded = IconData( + 0xf0374, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">rotate_90_degrees_cw</i> — material icon named "rotate 90 degrees cw" (outlined). + static const IconData rotate_90_degrees_cw_outlined = IconData( + 0xf0655, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">rotate_left</i> — material icon named "rotate left". + static const IconData rotate_left = IconData(0xe53f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rotate_left</i> — material icon named "rotate left" (sharp). + static const IconData rotate_left_sharp = IconData(0xec35, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rotate_left</i> — material icon named "rotate left" (round). + static const IconData rotate_left_rounded = IconData(0xf0114, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rotate_left</i> — material icon named "rotate left" (outlined). + static const IconData rotate_left_outlined = IconData(0xf322, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rotate_right</i> — material icon named "rotate right". + static const IconData rotate_right = IconData(0xe540, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rotate_right</i> — material icon named "rotate right" (sharp). + static const IconData rotate_right_sharp = IconData(0xec36, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rotate_right</i> — material icon named "rotate right" (round). + static const IconData rotate_right_rounded = IconData(0xf0115, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rotate_right</i> — material icon named "rotate right" (outlined). + static const IconData rotate_right_outlined = IconData(0xf323, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">roundabout_left</i> — material icon named "roundabout left". + static const IconData roundabout_left = IconData(0xf055f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">roundabout_left</i> — material icon named "roundabout left" (sharp). + static const IconData roundabout_left_sharp = IconData(0xf0468, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">roundabout_left</i> — material icon named "roundabout left" (round). + static const IconData roundabout_left_rounded = IconData(0xf0375, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">roundabout_left</i> — material icon named "roundabout left" (outlined). + static const IconData roundabout_left_outlined = IconData(0xf0656, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">roundabout_right</i> — material icon named "roundabout right". + static const IconData roundabout_right = IconData(0xf0560, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">roundabout_right</i> — material icon named "roundabout right" (sharp). + static const IconData roundabout_right_sharp = IconData(0xf0469, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">roundabout_right</i> — material icon named "roundabout right" (round). + static const IconData roundabout_right_rounded = IconData(0xf0376, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">roundabout_right</i> — material icon named "roundabout right" (outlined). + static const IconData roundabout_right_outlined = IconData(0xf0657, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rounded_corner</i> — material icon named "rounded corner". + static const IconData rounded_corner = IconData(0xe541, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rounded_corner</i> — material icon named "rounded corner" (sharp). + static const IconData rounded_corner_sharp = IconData(0xec37, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rounded_corner</i> — material icon named "rounded corner" (round). + static const IconData rounded_corner_rounded = IconData(0xf0116, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rounded_corner</i> — material icon named "rounded corner" (outlined). + static const IconData rounded_corner_outlined = IconData(0xf324, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">route</i> — material icon named "route". + static const IconData route = IconData(0xf0561, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">route</i> — material icon named "route" (sharp). + static const IconData route_sharp = IconData(0xf046a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">route</i> — material icon named "route" (round). + static const IconData route_rounded = IconData(0xf0377, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">route</i> — material icon named "route" (outlined). + static const IconData route_outlined = IconData(0xf0658, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">router</i> — material icon named "router". + static const IconData router = IconData(0xe542, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">router</i> — material icon named "router" (sharp). + static const IconData router_sharp = IconData(0xec38, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">router</i> — material icon named "router" (round). + static const IconData router_rounded = IconData(0xf0117, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">router</i> — material icon named "router" (outlined). + static const IconData router_outlined = IconData(0xf325, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rowing</i> — material icon named "rowing". + static const IconData rowing = IconData(0xe543, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rowing</i> — material icon named "rowing" (sharp). + static const IconData rowing_sharp = IconData(0xec39, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rowing</i> — material icon named "rowing" (round). + static const IconData rowing_rounded = IconData(0xf0118, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rowing</i> — material icon named "rowing" (outlined). + static const IconData rowing_outlined = IconData(0xf326, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rss_feed</i> — material icon named "rss feed". + static const IconData rss_feed = IconData(0xe544, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rss_feed</i> — material icon named "rss feed" (sharp). + static const IconData rss_feed_sharp = IconData(0xec3a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rss_feed</i> — material icon named "rss feed" (round). + static const IconData rss_feed_rounded = IconData(0xf0119, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rss_feed</i> — material icon named "rss feed" (outlined). + static const IconData rss_feed_outlined = IconData(0xf327, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rsvp</i> — material icon named "rsvp". + static const IconData rsvp = IconData(0xe545, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rsvp</i> — material icon named "rsvp" (sharp). + static const IconData rsvp_sharp = IconData(0xec3b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rsvp</i> — material icon named "rsvp" (round). + static const IconData rsvp_rounded = IconData(0xf011a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rsvp</i> — material icon named "rsvp" (outlined). + static const IconData rsvp_outlined = IconData(0xf328, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rtt</i> — material icon named "rtt". + static const IconData rtt = IconData(0xe546, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rtt</i> — material icon named "rtt" (sharp). + static const IconData rtt_sharp = IconData(0xec3c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rtt</i> — material icon named "rtt" (round). + static const IconData rtt_rounded = IconData(0xf011b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rtt</i> — material icon named "rtt" (outlined). + static const IconData rtt_outlined = IconData(0xf329, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rule</i> — material icon named "rule". + static const IconData rule = IconData(0xe547, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rule</i> — material icon named "rule" (sharp). + static const IconData rule_sharp = IconData(0xec3e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rule</i> — material icon named "rule" (round). + static const IconData rule_rounded = IconData(0xf011d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rule</i> — material icon named "rule" (outlined). + static const IconData rule_outlined = IconData(0xf32b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">rule_folder</i> — material icon named "rule folder". + static const IconData rule_folder = IconData(0xe548, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rule_folder</i> — material icon named "rule folder" (sharp). + static const IconData rule_folder_sharp = IconData(0xec3d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rule_folder</i> — material icon named "rule folder" (round). + static const IconData rule_folder_rounded = IconData(0xf011c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rule_folder</i> — material icon named "rule folder" (outlined). + static const IconData rule_folder_outlined = IconData(0xf32a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">run_circle</i> — material icon named "run circle". + static const IconData run_circle = IconData(0xe549, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">run_circle</i> — material icon named "run circle" (sharp). + static const IconData run_circle_sharp = IconData(0xec3f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">run_circle</i> — material icon named "run circle" (round). + static const IconData run_circle_rounded = IconData(0xf011e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">run_circle</i> — material icon named "run circle" (outlined). + static const IconData run_circle_outlined = IconData(0xf32c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">running_with_errors</i> — material icon named "running with errors". + static const IconData running_with_errors = IconData(0xe54a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">running_with_errors</i> — material icon named "running with errors" (sharp). + static const IconData running_with_errors_sharp = IconData(0xec40, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">running_with_errors</i> — material icon named "running with errors" (round). + static const IconData running_with_errors_rounded = IconData( + 0xf011f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">running_with_errors</i> — material icon named "running with errors" (outlined). + static const IconData running_with_errors_outlined = IconData( + 0xf32d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">rv_hookup</i> — material icon named "rv hookup". + static const IconData rv_hookup = IconData(0xe54b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">rv_hookup</i> — material icon named "rv hookup" (sharp). + static const IconData rv_hookup_sharp = IconData(0xec41, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">rv_hookup</i> — material icon named "rv hookup" (round). + static const IconData rv_hookup_rounded = IconData(0xf0120, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">rv_hookup</i> — material icon named "rv hookup" (outlined). + static const IconData rv_hookup_outlined = IconData(0xf32e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">safety_check</i> — material icon named "safety check". + static const IconData safety_check = IconData(0xf07be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">safety_check</i> — material icon named "safety check" (sharp). + static const IconData safety_check_sharp = IconData(0xf0766, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">safety_check</i> — material icon named "safety check" (round). + static const IconData safety_check_rounded = IconData(0xf0816, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">safety_check</i> — material icon named "safety check" (outlined). + static const IconData safety_check_outlined = IconData(0xf070e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">safety_divider</i> — material icon named "safety divider". + static const IconData safety_divider = IconData(0xe54c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">safety_divider</i> — material icon named "safety divider" (sharp). + static const IconData safety_divider_sharp = IconData(0xec42, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">safety_divider</i> — material icon named "safety divider" (round). + static const IconData safety_divider_rounded = IconData(0xf0121, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">safety_divider</i> — material icon named "safety divider" (outlined). + static const IconData safety_divider_outlined = IconData(0xf32f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sailing</i> — material icon named "sailing". + static const IconData sailing = IconData(0xe54d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sailing</i> — material icon named "sailing" (sharp). + static const IconData sailing_sharp = IconData(0xec43, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sailing</i> — material icon named "sailing" (round). + static const IconData sailing_rounded = IconData(0xf0122, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sailing</i> — material icon named "sailing" (outlined). + static const IconData sailing_outlined = IconData(0xf330, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sanitizer</i> — material icon named "sanitizer". + static const IconData sanitizer = IconData(0xe54e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sanitizer</i> — material icon named "sanitizer" (sharp). + static const IconData sanitizer_sharp = IconData(0xec44, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sanitizer</i> — material icon named "sanitizer" (round). + static const IconData sanitizer_rounded = IconData(0xf0123, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sanitizer</i> — material icon named "sanitizer" (outlined). + static const IconData sanitizer_outlined = IconData(0xf331, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">satellite</i> — material icon named "satellite". + static const IconData satellite = IconData(0xe54f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">satellite</i> — material icon named "satellite" (sharp). + static const IconData satellite_sharp = IconData(0xec45, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">satellite</i> — material icon named "satellite" (round). + static const IconData satellite_rounded = IconData(0xf0124, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">satellite</i> — material icon named "satellite" (outlined). + static const IconData satellite_outlined = IconData(0xf332, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">satellite_alt</i> — material icon named "satellite alt". + static const IconData satellite_alt = IconData(0xf0562, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">satellite_alt</i> — material icon named "satellite alt" (sharp). + static const IconData satellite_alt_sharp = IconData(0xf046b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">satellite_alt</i> — material icon named "satellite alt" (round). + static const IconData satellite_alt_rounded = IconData(0xf0378, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">satellite_alt</i> — material icon named "satellite alt" (outlined). + static const IconData satellite_alt_outlined = IconData(0xf0659, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">save</i> — material icon named "save". + static const IconData save = IconData(0xe550, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">save</i> — material icon named "save" (sharp). + static const IconData save_sharp = IconData(0xec47, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">save</i> — material icon named "save" (round). + static const IconData save_rounded = IconData(0xf0126, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">save</i> — material icon named "save" (outlined). + static const IconData save_outlined = IconData(0xf334, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">save_alt</i> — material icon named "save alt". + static const IconData save_alt = IconData(0xe551, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">save_alt</i> — material icon named "save alt" (sharp). + static const IconData save_alt_sharp = IconData(0xec46, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">save_alt</i> — material icon named "save alt" (round). + static const IconData save_alt_rounded = IconData(0xf0125, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">save_alt</i> — material icon named "save alt" (outlined). + static const IconData save_alt_outlined = IconData(0xf333, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">save_as</i> — material icon named "save as". + static const IconData save_as = IconData(0xf0563, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">save_as</i> — material icon named "save as" (sharp). + static const IconData save_as_sharp = IconData(0xf046c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">save_as</i> — material icon named "save as" (round). + static const IconData save_as_rounded = IconData(0xf0379, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">save_as</i> — material icon named "save as" (outlined). + static const IconData save_as_outlined = IconData(0xf065a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">saved_search</i> — material icon named "saved search". + static const IconData saved_search = IconData(0xe552, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">saved_search</i> — material icon named "saved search" (sharp). + static const IconData saved_search_sharp = IconData(0xec48, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">saved_search</i> — material icon named "saved search" (round). + static const IconData saved_search_rounded = IconData(0xf0127, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">saved_search</i> — material icon named "saved search" (outlined). + static const IconData saved_search_outlined = IconData(0xf335, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">savings</i> — material icon named "savings". + static const IconData savings = IconData(0xe553, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">savings</i> — material icon named "savings" (sharp). + static const IconData savings_sharp = IconData(0xec49, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">savings</i> — material icon named "savings" (round). + static const IconData savings_rounded = IconData(0xf0128, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">savings</i> — material icon named "savings" (outlined). + static const IconData savings_outlined = IconData(0xf336, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">scale</i> — material icon named "scale". + static const IconData scale = IconData(0xf0564, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">scale</i> — material icon named "scale" (sharp). + static const IconData scale_sharp = IconData(0xf046d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">scale</i> — material icon named "scale" (round). + static const IconData scale_rounded = IconData(0xf037a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">scale</i> — material icon named "scale" (outlined). + static const IconData scale_outlined = IconData(0xf065b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">scanner</i> — material icon named "scanner". + static const IconData scanner = IconData(0xe554, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">scanner</i> — material icon named "scanner" (sharp). + static const IconData scanner_sharp = IconData(0xec4a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">scanner</i> — material icon named "scanner" (round). + static const IconData scanner_rounded = IconData(0xf0129, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">scanner</i> — material icon named "scanner" (outlined). + static const IconData scanner_outlined = IconData(0xf337, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">scatter_plot</i> — material icon named "scatter plot". + static const IconData scatter_plot = IconData(0xe555, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">scatter_plot</i> — material icon named "scatter plot" (sharp). + static const IconData scatter_plot_sharp = IconData(0xec4b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">scatter_plot</i> — material icon named "scatter plot" (round). + static const IconData scatter_plot_rounded = IconData(0xf012a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">scatter_plot</i> — material icon named "scatter plot" (outlined). + static const IconData scatter_plot_outlined = IconData(0xf338, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">schedule</i> — material icon named "schedule". + static const IconData schedule = IconData(0xe556, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">schedule</i> — material icon named "schedule" (sharp). + static const IconData schedule_sharp = IconData(0xec4d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">schedule</i> — material icon named "schedule" (round). + static const IconData schedule_rounded = IconData(0xf012b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">schedule</i> — material icon named "schedule" (outlined). + static const IconData schedule_outlined = IconData(0xf339, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">schedule_send</i> — material icon named "schedule send". + static const IconData schedule_send = IconData(0xe557, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">schedule_send</i> — material icon named "schedule send" (sharp). + static const IconData schedule_send_sharp = IconData(0xec4c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">schedule_send</i> — material icon named "schedule send" (round). + static const IconData schedule_send_rounded = IconData(0xf012c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">schedule_send</i> — material icon named "schedule send" (outlined). + static const IconData schedule_send_outlined = IconData(0xf33a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">schema</i> — material icon named "schema". + static const IconData schema = IconData(0xe558, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">schema</i> — material icon named "schema" (sharp). + static const IconData schema_sharp = IconData(0xec4e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">schema</i> — material icon named "schema" (round). + static const IconData schema_rounded = IconData(0xf012d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">schema</i> — material icon named "schema" (outlined). + static const IconData schema_outlined = IconData(0xf33b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">school</i> — material icon named "school". + static const IconData school = IconData(0xe559, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">school</i> — material icon named "school" (sharp). + static const IconData school_sharp = IconData(0xec4f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">school</i> — material icon named "school" (round). + static const IconData school_rounded = IconData(0xf012e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">school</i> — material icon named "school" (outlined). + static const IconData school_outlined = IconData(0xf33c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">science</i> — material icon named "science". + static const IconData science = IconData(0xe55a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">science</i> — material icon named "science" (sharp). + static const IconData science_sharp = IconData(0xec50, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">science</i> — material icon named "science" (round). + static const IconData science_rounded = IconData(0xf012f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">science</i> — material icon named "science" (outlined). + static const IconData science_outlined = IconData(0xf33d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">score</i> — material icon named "score". + static const IconData score = IconData(0xe55b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">score</i> — material icon named "score" (sharp). + static const IconData score_sharp = IconData(0xec51, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">score</i> — material icon named "score" (round). + static const IconData score_rounded = IconData(0xf0130, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">score</i> — material icon named "score" (outlined). + static const IconData score_outlined = IconData(0xf33e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">scoreboard</i> — material icon named "scoreboard". + static const IconData scoreboard = IconData(0xf06c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">scoreboard</i> — material icon named "scoreboard" (sharp). + static const IconData scoreboard_sharp = IconData(0xf06b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">scoreboard</i> — material icon named "scoreboard" (round). + static const IconData scoreboard_rounded = IconData(0xf06ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">scoreboard</i> — material icon named "scoreboard" (outlined). + static const IconData scoreboard_outlined = IconData(0xf06a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">screen_lock_landscape</i> — material icon named "screen lock landscape". + static const IconData screen_lock_landscape = IconData(0xe55c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screen_lock_landscape</i> — material icon named "screen lock landscape" (sharp). + static const IconData screen_lock_landscape_sharp = IconData(0xec52, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screen_lock_landscape</i> — material icon named "screen lock landscape" (round). + static const IconData screen_lock_landscape_rounded = IconData( + 0xf0131, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">screen_lock_landscape</i> — material icon named "screen lock landscape" (outlined). + static const IconData screen_lock_landscape_outlined = IconData( + 0xf33f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">screen_lock_portrait</i> — material icon named "screen lock portrait". + static const IconData screen_lock_portrait = IconData(0xe55d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screen_lock_portrait</i> — material icon named "screen lock portrait" (sharp). + static const IconData screen_lock_portrait_sharp = IconData(0xec53, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screen_lock_portrait</i> — material icon named "screen lock portrait" (round). + static const IconData screen_lock_portrait_rounded = IconData( + 0xf0132, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">screen_lock_portrait</i> — material icon named "screen lock portrait" (outlined). + static const IconData screen_lock_portrait_outlined = IconData( + 0xf340, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">screen_lock_rotation</i> — material icon named "screen lock rotation". + static const IconData screen_lock_rotation = IconData(0xe55e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screen_lock_rotation</i> — material icon named "screen lock rotation" (sharp). + static const IconData screen_lock_rotation_sharp = IconData(0xec54, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screen_lock_rotation</i> — material icon named "screen lock rotation" (round). + static const IconData screen_lock_rotation_rounded = IconData( + 0xf0133, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">screen_lock_rotation</i> — material icon named "screen lock rotation" (outlined). + static const IconData screen_lock_rotation_outlined = IconData( + 0xf341, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">screen_rotation</i> — material icon named "screen rotation". + static const IconData screen_rotation = IconData(0xe55f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screen_rotation</i> — material icon named "screen rotation" (sharp). + static const IconData screen_rotation_sharp = IconData(0xec55, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screen_rotation</i> — material icon named "screen rotation" (round). + static const IconData screen_rotation_rounded = IconData(0xf0134, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">screen_rotation</i> — material icon named "screen rotation" (outlined). + static const IconData screen_rotation_outlined = IconData(0xf342, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">screen_rotation_alt</i> — material icon named "screen rotation alt". + static const IconData screen_rotation_alt = IconData(0xf07bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screen_rotation_alt</i> — material icon named "screen rotation alt" (sharp). + static const IconData screen_rotation_alt_sharp = IconData(0xf0767, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screen_rotation_alt</i> — material icon named "screen rotation alt" (round). + static const IconData screen_rotation_alt_rounded = IconData( + 0xf0817, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">screen_rotation_alt</i> — material icon named "screen rotation alt" (outlined). + static const IconData screen_rotation_alt_outlined = IconData( + 0xf070f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">screen_search_desktop</i> — material icon named "screen search desktop". + static const IconData screen_search_desktop = IconData(0xe560, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screen_search_desktop</i> — material icon named "screen search desktop" (sharp). + static const IconData screen_search_desktop_sharp = IconData(0xec56, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screen_search_desktop</i> — material icon named "screen search desktop" (round). + static const IconData screen_search_desktop_rounded = IconData( + 0xf0135, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">screen_search_desktop</i> — material icon named "screen search desktop" (outlined). + static const IconData screen_search_desktop_outlined = IconData( + 0xf343, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">screen_share</i> — material icon named "screen share". + static const IconData screen_share = IconData( + 0xe561, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">screen_share</i> — material icon named "screen share" (sharp). + static const IconData screen_share_sharp = IconData( + 0xec57, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">screen_share</i> — material icon named "screen share" (round). + static const IconData screen_share_rounded = IconData( + 0xf0136, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">screen_share</i> — material icon named "screen share" (outlined). + static const IconData screen_share_outlined = IconData( + 0xf344, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">screenshot</i> — material icon named "screenshot". + static const IconData screenshot = IconData(0xe562, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screenshot</i> — material icon named "screenshot" (sharp). + static const IconData screenshot_sharp = IconData(0xec58, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screenshot</i> — material icon named "screenshot" (round). + static const IconData screenshot_rounded = IconData(0xf0137, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">screenshot</i> — material icon named "screenshot" (outlined). + static const IconData screenshot_outlined = IconData(0xf345, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">screenshot_monitor</i> — material icon named "screenshot monitor". + static const IconData screenshot_monitor = IconData(0xf07c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">screenshot_monitor</i> — material icon named "screenshot monitor" (sharp). + static const IconData screenshot_monitor_sharp = IconData(0xf0768, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">screenshot_monitor</i> — material icon named "screenshot monitor" (round). + static const IconData screenshot_monitor_rounded = IconData(0xf0818, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">screenshot_monitor</i> — material icon named "screenshot monitor" (outlined). + static const IconData screenshot_monitor_outlined = IconData( + 0xf0710, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">scuba_diving</i> — material icon named "scuba diving". + static const IconData scuba_diving = IconData(0xf06c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">scuba_diving</i> — material icon named "scuba diving" (sharp). + static const IconData scuba_diving_sharp = IconData(0xf06b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">scuba_diving</i> — material icon named "scuba diving" (round). + static const IconData scuba_diving_rounded = IconData(0xf06cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">scuba_diving</i> — material icon named "scuba diving" (outlined). + static const IconData scuba_diving_outlined = IconData(0xf06a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sd</i> — material icon named "sd". + static const IconData sd = IconData(0xe563, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sd</i> — material icon named "sd" (sharp). + static const IconData sd_sharp = IconData(0xec5b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sd</i> — material icon named "sd" (round). + static const IconData sd_rounded = IconData(0xf013a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sd</i> — material icon named "sd" (outlined). + static const IconData sd_outlined = IconData(0xf348, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sd_card</i> — material icon named "sd card". + static const IconData sd_card = IconData(0xe564, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sd_card</i> — material icon named "sd card" (sharp). + static const IconData sd_card_sharp = IconData(0xec5a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sd_card</i> — material icon named "sd card" (round). + static const IconData sd_card_rounded = IconData(0xf0139, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sd_card</i> — material icon named "sd card" (outlined). + static const IconData sd_card_outlined = IconData(0xf347, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sd_card_alert</i> — material icon named "sd card alert". + static const IconData sd_card_alert = IconData(0xe565, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sd_card_alert</i> — material icon named "sd card alert" (sharp). + static const IconData sd_card_alert_sharp = IconData(0xec59, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sd_card_alert</i> — material icon named "sd card alert" (round). + static const IconData sd_card_alert_rounded = IconData(0xf0138, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sd_card_alert</i> — material icon named "sd card alert" (outlined). + static const IconData sd_card_alert_outlined = IconData(0xf346, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sd_storage</i> — material icon named "sd storage". + static const IconData sd_storage = IconData(0xe566, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sd_storage</i> — material icon named "sd storage" (sharp). + static const IconData sd_storage_sharp = IconData(0xec5c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sd_storage</i> — material icon named "sd storage" (round). + static const IconData sd_storage_rounded = IconData(0xf013b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sd_storage</i> — material icon named "sd storage" (outlined). + static const IconData sd_storage_outlined = IconData(0xf349, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">search</i> — material icon named "search". + static const IconData search = IconData(0xe567, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">search</i> — material icon named "search" (sharp). + static const IconData search_sharp = IconData(0xec5e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">search</i> — material icon named "search" (round). + static const IconData search_rounded = IconData(0xf013d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">search</i> — material icon named "search" (outlined). + static const IconData search_outlined = IconData(0xf34b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">search_off</i> — material icon named "search off". + static const IconData search_off = IconData(0xe568, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">search_off</i> — material icon named "search off" (sharp). + static const IconData search_off_sharp = IconData(0xec5d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">search_off</i> — material icon named "search off" (round). + static const IconData search_off_rounded = IconData(0xf013c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">search_off</i> — material icon named "search off" (outlined). + static const IconData search_off_outlined = IconData(0xf34a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">security</i> — material icon named "security". + static const IconData security = IconData(0xe569, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">security</i> — material icon named "security" (sharp). + static const IconData security_sharp = IconData(0xec5f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">security</i> — material icon named "security" (round). + static const IconData security_rounded = IconData(0xf013e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">security</i> — material icon named "security" (outlined). + static const IconData security_outlined = IconData(0xf34c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">security_update</i> — material icon named "security update". + static const IconData security_update = IconData(0xe56a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">security_update</i> — material icon named "security update" (sharp). + static const IconData security_update_sharp = IconData(0xec61, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">security_update</i> — material icon named "security update" (round). + static const IconData security_update_rounded = IconData(0xf0140, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">security_update</i> — material icon named "security update" (outlined). + static const IconData security_update_outlined = IconData(0xf34e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">security_update_good</i> — material icon named "security update good". + static const IconData security_update_good = IconData(0xe56b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">security_update_good</i> — material icon named "security update good" (sharp). + static const IconData security_update_good_sharp = IconData(0xec60, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">security_update_good</i> — material icon named "security update good" (round). + static const IconData security_update_good_rounded = IconData( + 0xf013f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">security_update_good</i> — material icon named "security update good" (outlined). + static const IconData security_update_good_outlined = IconData( + 0xf34d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">security_update_warning</i> — material icon named "security update warning". + static const IconData security_update_warning = IconData(0xe56c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">security_update_warning</i> — material icon named "security update warning" (sharp). + static const IconData security_update_warning_sharp = IconData( + 0xec62, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">security_update_warning</i> — material icon named "security update warning" (round). + static const IconData security_update_warning_rounded = IconData( + 0xf0141, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">security_update_warning</i> — material icon named "security update warning" (outlined). + static const IconData security_update_warning_outlined = IconData( + 0xf34f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">segment</i> — material icon named "segment". + static const IconData segment = IconData(0xe56d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">segment</i> — material icon named "segment" (sharp). + static const IconData segment_sharp = IconData(0xec63, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">segment</i> — material icon named "segment" (round). + static const IconData segment_rounded = IconData(0xf0142, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">segment</i> — material icon named "segment" (outlined). + static const IconData segment_outlined = IconData(0xf350, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">select_all</i> — material icon named "select all". + static const IconData select_all = IconData(0xe56e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">select_all</i> — material icon named "select all" (sharp). + static const IconData select_all_sharp = IconData(0xec64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">select_all</i> — material icon named "select all" (round). + static const IconData select_all_rounded = IconData(0xf0143, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">select_all</i> — material icon named "select all" (outlined). + static const IconData select_all_outlined = IconData(0xf351, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">self_improvement</i> — material icon named "self improvement". + static const IconData self_improvement = IconData(0xe56f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">self_improvement</i> — material icon named "self improvement" (sharp). + static const IconData self_improvement_sharp = IconData(0xec65, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">self_improvement</i> — material icon named "self improvement" (round). + static const IconData self_improvement_rounded = IconData(0xf0144, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">self_improvement</i> — material icon named "self improvement" (outlined). + static const IconData self_improvement_outlined = IconData(0xf352, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sell</i> — material icon named "sell". + static const IconData sell = IconData(0xe570, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sell</i> — material icon named "sell" (sharp). + static const IconData sell_sharp = IconData(0xec66, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sell</i> — material icon named "sell" (round). + static const IconData sell_rounded = IconData(0xf0145, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sell</i> — material icon named "sell" (outlined). + static const IconData sell_outlined = IconData(0xf353, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">send</i> — material icon named "send". + static const IconData send = IconData( + 0xe571, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">send</i> — material icon named "send" (sharp). + static const IconData send_sharp = IconData( + 0xec68, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">send</i> — material icon named "send" (round). + static const IconData send_rounded = IconData( + 0xf0147, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">send</i> — material icon named "send" (outlined). + static const IconData send_outlined = IconData( + 0xf355, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">send_and_archive</i> — material icon named "send and archive". + static const IconData send_and_archive = IconData(0xe572, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">send_and_archive</i> — material icon named "send and archive" (sharp). + static const IconData send_and_archive_sharp = IconData(0xec67, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">send_and_archive</i> — material icon named "send and archive" (round). + static const IconData send_and_archive_rounded = IconData(0xf0146, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">send_and_archive</i> — material icon named "send and archive" (outlined). + static const IconData send_and_archive_outlined = IconData(0xf354, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">send_time_extension</i> — material icon named "send time extension". + static const IconData send_time_extension = IconData(0xf0565, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">send_time_extension</i> — material icon named "send time extension" (sharp). + static const IconData send_time_extension_sharp = IconData(0xf046e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">send_time_extension</i> — material icon named "send time extension" (round). + static const IconData send_time_extension_rounded = IconData( + 0xf037b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">send_time_extension</i> — material icon named "send time extension" (outlined). + static const IconData send_time_extension_outlined = IconData( + 0xf065c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">send_to_mobile</i> — material icon named "send to mobile". + static const IconData send_to_mobile = IconData(0xe573, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">send_to_mobile</i> — material icon named "send to mobile" (sharp). + static const IconData send_to_mobile_sharp = IconData(0xec69, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">send_to_mobile</i> — material icon named "send to mobile" (round). + static const IconData send_to_mobile_rounded = IconData(0xf0148, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">send_to_mobile</i> — material icon named "send to mobile" (outlined). + static const IconData send_to_mobile_outlined = IconData(0xf356, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sensor_door</i> — material icon named "sensor door". + static const IconData sensor_door = IconData(0xe574, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sensor_door</i> — material icon named "sensor door" (sharp). + static const IconData sensor_door_sharp = IconData(0xec6a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sensor_door</i> — material icon named "sensor door" (round). + static const IconData sensor_door_rounded = IconData(0xf0149, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sensor_door</i> — material icon named "sensor door" (outlined). + static const IconData sensor_door_outlined = IconData(0xf357, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sensor_occupied</i> — material icon named "sensor occupied". + static const IconData sensor_occupied = IconData(0xf07c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sensor_occupied</i> — material icon named "sensor occupied" (sharp). + static const IconData sensor_occupied_sharp = IconData(0xf0769, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sensor_occupied</i> — material icon named "sensor occupied" (round). + static const IconData sensor_occupied_rounded = IconData(0xf0819, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sensor_occupied</i> — material icon named "sensor occupied" (outlined). + static const IconData sensor_occupied_outlined = IconData(0xf0711, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sensor_window</i> — material icon named "sensor window". + static const IconData sensor_window = IconData(0xe575, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sensor_window</i> — material icon named "sensor window" (sharp). + static const IconData sensor_window_sharp = IconData(0xec6b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sensor_window</i> — material icon named "sensor window" (round). + static const IconData sensor_window_rounded = IconData(0xf014a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sensor_window</i> — material icon named "sensor window" (outlined). + static const IconData sensor_window_outlined = IconData(0xf358, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sensors</i> — material icon named "sensors". + static const IconData sensors = IconData(0xe576, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sensors</i> — material icon named "sensors" (sharp). + static const IconData sensors_sharp = IconData(0xec6d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sensors</i> — material icon named "sensors" (round). + static const IconData sensors_rounded = IconData(0xf014c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sensors</i> — material icon named "sensors" (outlined). + static const IconData sensors_outlined = IconData(0xf35a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sensors_off</i> — material icon named "sensors off". + static const IconData sensors_off = IconData(0xe577, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sensors_off</i> — material icon named "sensors off" (sharp). + static const IconData sensors_off_sharp = IconData(0xec6c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sensors_off</i> — material icon named "sensors off" (round). + static const IconData sensors_off_rounded = IconData(0xf014b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sensors_off</i> — material icon named "sensors off" (outlined). + static const IconData sensors_off_outlined = IconData(0xf359, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sentiment_dissatisfied</i> — material icon named "sentiment dissatisfied". + static const IconData sentiment_dissatisfied = IconData(0xe578, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sentiment_dissatisfied</i> — material icon named "sentiment dissatisfied" (sharp). + static const IconData sentiment_dissatisfied_sharp = IconData( + 0xec6e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">sentiment_dissatisfied</i> — material icon named "sentiment dissatisfied" (round). + static const IconData sentiment_dissatisfied_rounded = IconData( + 0xf014d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">sentiment_dissatisfied</i> — material icon named "sentiment dissatisfied" (outlined). + static const IconData sentiment_dissatisfied_outlined = IconData( + 0xf35b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">sentiment_neutral</i> — material icon named "sentiment neutral". + static const IconData sentiment_neutral = IconData(0xe579, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sentiment_neutral</i> — material icon named "sentiment neutral" (sharp). + static const IconData sentiment_neutral_sharp = IconData(0xec6f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sentiment_neutral</i> — material icon named "sentiment neutral" (round). + static const IconData sentiment_neutral_rounded = IconData(0xf014e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sentiment_neutral</i> — material icon named "sentiment neutral" (outlined). + static const IconData sentiment_neutral_outlined = IconData(0xf35c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sentiment_satisfied</i> — material icon named "sentiment satisfied". + static const IconData sentiment_satisfied = IconData(0xe57a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sentiment_satisfied</i> — material icon named "sentiment satisfied" (sharp). + static const IconData sentiment_satisfied_sharp = IconData(0xec71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sentiment_satisfied</i> — material icon named "sentiment satisfied" (round). + static const IconData sentiment_satisfied_rounded = IconData( + 0xf0150, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">sentiment_satisfied</i> — material icon named "sentiment satisfied" (outlined). + static const IconData sentiment_satisfied_outlined = IconData( + 0xf35e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">sentiment_satisfied_alt</i> — material icon named "sentiment satisfied alt". + static const IconData sentiment_satisfied_alt = IconData(0xe57b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sentiment_satisfied_alt</i> — material icon named "sentiment satisfied alt" (sharp). + static const IconData sentiment_satisfied_alt_sharp = IconData( + 0xec70, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">sentiment_satisfied_alt</i> — material icon named "sentiment satisfied alt" (round). + static const IconData sentiment_satisfied_alt_rounded = IconData( + 0xf014f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">sentiment_satisfied_alt</i> — material icon named "sentiment satisfied alt" (outlined). + static const IconData sentiment_satisfied_alt_outlined = IconData( + 0xf35d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">sentiment_very_dissatisfied</i> — material icon named "sentiment very dissatisfied". + static const IconData sentiment_very_dissatisfied = IconData(0xe57c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sentiment_very_dissatisfied</i> — material icon named "sentiment very dissatisfied" (sharp). + static const IconData sentiment_very_dissatisfied_sharp = IconData( + 0xec72, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">sentiment_very_dissatisfied</i> — material icon named "sentiment very dissatisfied" (round). + static const IconData sentiment_very_dissatisfied_rounded = IconData( + 0xf0151, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">sentiment_very_dissatisfied</i> — material icon named "sentiment very dissatisfied" (outlined). + static const IconData sentiment_very_dissatisfied_outlined = IconData( + 0xf35f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">sentiment_very_satisfied</i> — material icon named "sentiment very satisfied". + static const IconData sentiment_very_satisfied = IconData(0xe57d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sentiment_very_satisfied</i> — material icon named "sentiment very satisfied" (sharp). + static const IconData sentiment_very_satisfied_sharp = IconData( + 0xec73, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">sentiment_very_satisfied</i> — material icon named "sentiment very satisfied" (round). + static const IconData sentiment_very_satisfied_rounded = IconData( + 0xf0152, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">sentiment_very_satisfied</i> — material icon named "sentiment very satisfied" (outlined). + static const IconData sentiment_very_satisfied_outlined = IconData( + 0xf360, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">set_meal</i> — material icon named "set meal". + static const IconData set_meal = IconData(0xe57e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">set_meal</i> — material icon named "set meal" (sharp). + static const IconData set_meal_sharp = IconData(0xec74, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">set_meal</i> — material icon named "set meal" (round). + static const IconData set_meal_rounded = IconData(0xf0153, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">set_meal</i> — material icon named "set meal" (outlined). + static const IconData set_meal_outlined = IconData(0xf361, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings</i> — material icon named "settings". + static const IconData settings = IconData(0xe57f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings</i> — material icon named "settings" (sharp). + static const IconData settings_sharp = IconData(0xec85, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings</i> — material icon named "settings" (round). + static const IconData settings_rounded = IconData(0xf0164, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings</i> — material icon named "settings" (outlined). + static const IconData settings_outlined = IconData(0xf36e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_accessibility</i> — material icon named "settings accessibility". + static const IconData settings_accessibility = IconData(0xe580, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_accessibility</i> — material icon named "settings accessibility" (sharp). + static const IconData settings_accessibility_sharp = IconData( + 0xec75, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">settings_accessibility</i> — material icon named "settings accessibility" (round). + static const IconData settings_accessibility_rounded = IconData( + 0xf0154, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_accessibility</i> — material icon named "settings accessibility" (outlined). + static const IconData settings_accessibility_outlined = IconData( + 0xf362, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_applications</i> — material icon named "settings applications". + static const IconData settings_applications = IconData(0xe581, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_applications</i> — material icon named "settings applications" (sharp). + static const IconData settings_applications_sharp = IconData(0xec76, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_applications</i> — material icon named "settings applications" (round). + static const IconData settings_applications_rounded = IconData( + 0xf0155, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_applications</i> — material icon named "settings applications" (outlined). + static const IconData settings_applications_outlined = IconData( + 0xf363, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_backup_restore</i> — material icon named "settings backup restore". + static const IconData settings_backup_restore = IconData(0xe582, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_backup_restore</i> — material icon named "settings backup restore" (sharp). + static const IconData settings_backup_restore_sharp = IconData( + 0xec77, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">settings_backup_restore</i> — material icon named "settings backup restore" (round). + static const IconData settings_backup_restore_rounded = IconData( + 0xf0156, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_backup_restore</i> — material icon named "settings backup restore" (outlined). + static const IconData settings_backup_restore_outlined = IconData( + 0xf364, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_bluetooth</i> — material icon named "settings bluetooth". + static const IconData settings_bluetooth = IconData(0xe583, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_bluetooth</i> — material icon named "settings bluetooth" (sharp). + static const IconData settings_bluetooth_sharp = IconData(0xec78, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_bluetooth</i> — material icon named "settings bluetooth" (round). + static const IconData settings_bluetooth_rounded = IconData(0xf0157, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_bluetooth</i> — material icon named "settings bluetooth" (outlined). + static const IconData settings_bluetooth_outlined = IconData(0xf365, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_brightness</i> — material icon named "settings brightness". + static const IconData settings_brightness = IconData(0xe584, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_brightness</i> — material icon named "settings brightness" (sharp). + static const IconData settings_brightness_sharp = IconData(0xec79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_brightness</i> — material icon named "settings brightness" (round). + static const IconData settings_brightness_rounded = IconData( + 0xf0158, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_brightness</i> — material icon named "settings brightness" (outlined). + static const IconData settings_brightness_outlined = IconData( + 0xf366, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_cell</i> — material icon named "settings cell". + static const IconData settings_cell = IconData(0xe585, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_cell</i> — material icon named "settings cell" (sharp). + static const IconData settings_cell_sharp = IconData(0xec7a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_cell</i> — material icon named "settings cell" (round). + static const IconData settings_cell_rounded = IconData(0xf0159, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_cell</i> — material icon named "settings cell" (outlined). + static const IconData settings_cell_outlined = IconData(0xf367, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_display</i> — material icon named "settings display". + static const IconData settings_display = IconData(0xe584, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_display</i> — material icon named "settings display" (sharp). + static const IconData settings_display_sharp = IconData(0xec79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_display</i> — material icon named "settings display" (round). + static const IconData settings_display_rounded = IconData(0xf0158, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_display</i> — material icon named "settings display" (outlined). + static const IconData settings_display_outlined = IconData(0xf366, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_ethernet</i> — material icon named "settings ethernet". + static const IconData settings_ethernet = IconData(0xe586, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_ethernet</i> — material icon named "settings ethernet" (sharp). + static const IconData settings_ethernet_sharp = IconData(0xec7b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_ethernet</i> — material icon named "settings ethernet" (round). + static const IconData settings_ethernet_rounded = IconData(0xf015a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_ethernet</i> — material icon named "settings ethernet" (outlined). + static const IconData settings_ethernet_outlined = IconData(0xf368, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_input_antenna</i> — material icon named "settings input antenna". + static const IconData settings_input_antenna = IconData(0xe587, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_input_antenna</i> — material icon named "settings input antenna" (sharp). + static const IconData settings_input_antenna_sharp = IconData( + 0xec7c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">settings_input_antenna</i> — material icon named "settings input antenna" (round). + static const IconData settings_input_antenna_rounded = IconData( + 0xf015b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_input_antenna</i> — material icon named "settings input antenna" (outlined). + static const IconData settings_input_antenna_outlined = IconData( + 0xf369, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_input_component</i> — material icon named "settings input component". + static const IconData settings_input_component = IconData(0xe588, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_input_component</i> — material icon named "settings input component" (sharp). + static const IconData settings_input_component_sharp = IconData( + 0xec7d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">settings_input_component</i> — material icon named "settings input component" (round). + static const IconData settings_input_component_rounded = IconData( + 0xf015c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_input_component</i> — material icon named "settings input component" (outlined). + static const IconData settings_input_component_outlined = IconData( + 0xf36a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_input_composite</i> — material icon named "settings input composite". + static const IconData settings_input_composite = IconData(0xe589, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_input_composite</i> — material icon named "settings input composite" (sharp). + static const IconData settings_input_composite_sharp = IconData( + 0xec7e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">settings_input_composite</i> — material icon named "settings input composite" (round). + static const IconData settings_input_composite_rounded = IconData( + 0xf015d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_input_composite</i> — material icon named "settings input composite" (outlined). + static const IconData settings_input_composite_outlined = IconData( + 0xf36b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_input_hdmi</i> — material icon named "settings input hdmi". + static const IconData settings_input_hdmi = IconData(0xe58a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_input_hdmi</i> — material icon named "settings input hdmi" (sharp). + static const IconData settings_input_hdmi_sharp = IconData(0xec7f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_input_hdmi</i> — material icon named "settings input hdmi" (round). + static const IconData settings_input_hdmi_rounded = IconData( + 0xf015e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_input_hdmi</i> — material icon named "settings input hdmi" (outlined). + static const IconData settings_input_hdmi_outlined = IconData( + 0xf36c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_input_svideo</i> — material icon named "settings input svideo". + static const IconData settings_input_svideo = IconData(0xe58b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_input_svideo</i> — material icon named "settings input svideo" (sharp). + static const IconData settings_input_svideo_sharp = IconData(0xec80, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_input_svideo</i> — material icon named "settings input svideo" (round). + static const IconData settings_input_svideo_rounded = IconData( + 0xf015f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_input_svideo</i> — material icon named "settings input svideo" (outlined). + static const IconData settings_input_svideo_outlined = IconData( + 0xf36d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_overscan</i> — material icon named "settings overscan". + static const IconData settings_overscan = IconData(0xe58c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_overscan</i> — material icon named "settings overscan" (sharp). + static const IconData settings_overscan_sharp = IconData(0xec81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_overscan</i> — material icon named "settings overscan" (round). + static const IconData settings_overscan_rounded = IconData(0xf0160, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_overscan</i> — material icon named "settings overscan" (outlined). + static const IconData settings_overscan_outlined = IconData(0xf36f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_phone</i> — material icon named "settings phone". + static const IconData settings_phone = IconData(0xe58d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_phone</i> — material icon named "settings phone" (sharp). + static const IconData settings_phone_sharp = IconData(0xec82, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_phone</i> — material icon named "settings phone" (round). + static const IconData settings_phone_rounded = IconData(0xf0161, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_phone</i> — material icon named "settings phone" (outlined). + static const IconData settings_phone_outlined = IconData(0xf370, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_power</i> — material icon named "settings power". + static const IconData settings_power = IconData(0xe58e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_power</i> — material icon named "settings power" (sharp). + static const IconData settings_power_sharp = IconData(0xec83, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_power</i> — material icon named "settings power" (round). + static const IconData settings_power_rounded = IconData(0xf0162, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_power</i> — material icon named "settings power" (outlined). + static const IconData settings_power_outlined = IconData(0xf371, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_remote</i> — material icon named "settings remote". + static const IconData settings_remote = IconData(0xe58f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_remote</i> — material icon named "settings remote" (sharp). + static const IconData settings_remote_sharp = IconData(0xec84, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_remote</i> — material icon named "settings remote" (round). + static const IconData settings_remote_rounded = IconData(0xf0163, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_remote</i> — material icon named "settings remote" (outlined). + static const IconData settings_remote_outlined = IconData(0xf372, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_suggest</i> — material icon named "settings suggest". + static const IconData settings_suggest = IconData(0xe590, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_suggest</i> — material icon named "settings suggest" (sharp). + static const IconData settings_suggest_sharp = IconData(0xec86, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_suggest</i> — material icon named "settings suggest" (round). + static const IconData settings_suggest_rounded = IconData(0xf0165, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_suggest</i> — material icon named "settings suggest" (outlined). + static const IconData settings_suggest_outlined = IconData(0xf373, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">settings_system_daydream</i> — material icon named "settings system daydream". + static const IconData settings_system_daydream = IconData(0xe591, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_system_daydream</i> — material icon named "settings system daydream" (sharp). + static const IconData settings_system_daydream_sharp = IconData( + 0xec87, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">settings_system_daydream</i> — material icon named "settings system daydream" (round). + static const IconData settings_system_daydream_rounded = IconData( + 0xf0166, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">settings_system_daydream</i> — material icon named "settings system daydream" (outlined). + static const IconData settings_system_daydream_outlined = IconData( + 0xf374, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">settings_voice</i> — material icon named "settings voice". + static const IconData settings_voice = IconData(0xe592, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">settings_voice</i> — material icon named "settings voice" (sharp). + static const IconData settings_voice_sharp = IconData(0xec88, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">settings_voice</i> — material icon named "settings voice" (round). + static const IconData settings_voice_rounded = IconData(0xf0167, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">settings_voice</i> — material icon named "settings voice" (outlined). + static const IconData settings_voice_outlined = IconData(0xf375, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">severe_cold</i> — material icon named "severe cold". + static const IconData severe_cold = IconData(0xf07c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">severe_cold</i> — material icon named "severe cold" (sharp). + static const IconData severe_cold_sharp = IconData(0xf076a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">severe_cold</i> — material icon named "severe cold" (round). + static const IconData severe_cold_rounded = IconData(0xf081a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">severe_cold</i> — material icon named "severe cold" (outlined). + static const IconData severe_cold_outlined = IconData(0xf0712, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shape_line</i> — material icon named "shape line". + static const IconData shape_line = IconData(0xf0876, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shape_line</i> — material icon named "shape line" (sharp). + static const IconData shape_line_sharp = IconData(0xf084c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shape_line</i> — material icon named "shape line" (round). + static const IconData shape_line_rounded = IconData(0xf0895, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shape_line</i> — material icon named "shape line" (outlined). + static const IconData shape_line_outlined = IconData(0xf08b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">share</i> — material icon named "share". + static const IconData share = IconData(0xe593, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">share</i> — material icon named "share" (sharp). + static const IconData share_sharp = IconData(0xec8b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">share</i> — material icon named "share" (round). + static const IconData share_rounded = IconData(0xf016a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">share</i> — material icon named "share" (outlined). + static const IconData share_outlined = IconData(0xf378, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">share_arrival_time</i> — material icon named "share arrival time". + static const IconData share_arrival_time = IconData(0xe594, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">share_arrival_time</i> — material icon named "share arrival time" (sharp). + static const IconData share_arrival_time_sharp = IconData(0xec89, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">share_arrival_time</i> — material icon named "share arrival time" (round). + static const IconData share_arrival_time_rounded = IconData(0xf0168, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">share_arrival_time</i> — material icon named "share arrival time" (outlined). + static const IconData share_arrival_time_outlined = IconData(0xf376, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">share_location</i> — material icon named "share location". + static const IconData share_location = IconData(0xe595, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">share_location</i> — material icon named "share location" (sharp). + static const IconData share_location_sharp = IconData(0xec8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">share_location</i> — material icon named "share location" (round). + static const IconData share_location_rounded = IconData(0xf0169, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">share_location</i> — material icon named "share location" (outlined). + static const IconData share_location_outlined = IconData(0xf377, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shelves</i> — material icon named "shelves". + static const IconData shelves = IconData(0xf0877, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shield</i> — material icon named "shield". + static const IconData shield = IconData(0xe596, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shield</i> — material icon named "shield" (sharp). + static const IconData shield_sharp = IconData(0xec8c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shield</i> — material icon named "shield" (round). + static const IconData shield_rounded = IconData(0xf016b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shield</i> — material icon named "shield" (outlined). + static const IconData shield_outlined = IconData(0xf379, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shield_moon</i> — material icon named "shield moon". + static const IconData shield_moon = IconData(0xf0566, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shield_moon</i> — material icon named "shield moon" (sharp). + static const IconData shield_moon_sharp = IconData(0xf046f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shield_moon</i> — material icon named "shield moon" (round). + static const IconData shield_moon_rounded = IconData(0xf037c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shield_moon</i> — material icon named "shield moon" (outlined). + static const IconData shield_moon_outlined = IconData(0xf065d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shop</i> — material icon named "shop". + static const IconData shop = IconData(0xe597, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shop</i> — material icon named "shop" (sharp). + static const IconData shop_sharp = IconData(0xec8e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shop</i> — material icon named "shop" (round). + static const IconData shop_rounded = IconData(0xf016d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shop</i> — material icon named "shop" (outlined). + static const IconData shop_outlined = IconData(0xf37b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shop_2</i> — material icon named "shop 2". + static const IconData shop_2 = IconData(0xe598, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shop_2</i> — material icon named "shop 2" (sharp). + static const IconData shop_2_sharp = IconData(0xec8d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shop_2</i> — material icon named "shop 2" (round). + static const IconData shop_2_rounded = IconData(0xf016c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shop_2</i> — material icon named "shop 2" (outlined). + static const IconData shop_2_outlined = IconData(0xf37a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shop_two</i> — material icon named "shop two". + static const IconData shop_two = IconData(0xe599, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shop_two</i> — material icon named "shop two" (sharp). + static const IconData shop_two_sharp = IconData(0xec8f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shop_two</i> — material icon named "shop two" (round). + static const IconData shop_two_rounded = IconData(0xf016e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shop_two</i> — material icon named "shop two" (outlined). + static const IconData shop_two_outlined = IconData(0xf37c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shopify</i> — material icon named "shopify". + static const IconData shopify = IconData(0xf0567, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shopify</i> — material icon named "shopify" (sharp). + static const IconData shopify_sharp = IconData(0xf0470, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shopify</i> — material icon named "shopify" (round). + static const IconData shopify_rounded = IconData(0xf037d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shopify</i> — material icon named "shopify" (outlined). + static const IconData shopify_outlined = IconData(0xf065e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shopping_bag</i> — material icon named "shopping bag". + static const IconData shopping_bag = IconData(0xe59a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shopping_bag</i> — material icon named "shopping bag" (sharp). + static const IconData shopping_bag_sharp = IconData(0xec90, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shopping_bag</i> — material icon named "shopping bag" (round). + static const IconData shopping_bag_rounded = IconData(0xf016f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shopping_bag</i> — material icon named "shopping bag" (outlined). + static const IconData shopping_bag_outlined = IconData(0xf37d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shopping_basket</i> — material icon named "shopping basket". + static const IconData shopping_basket = IconData(0xe59b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shopping_basket</i> — material icon named "shopping basket" (sharp). + static const IconData shopping_basket_sharp = IconData(0xec91, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shopping_basket</i> — material icon named "shopping basket" (round). + static const IconData shopping_basket_rounded = IconData(0xf0170, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shopping_basket</i> — material icon named "shopping basket" (outlined). + static const IconData shopping_basket_outlined = IconData(0xf37e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shopping_cart</i> — material icon named "shopping cart". + static const IconData shopping_cart = IconData(0xe59c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shopping_cart</i> — material icon named "shopping cart" (sharp). + static const IconData shopping_cart_sharp = IconData(0xec92, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shopping_cart</i> — material icon named "shopping cart" (round). + static const IconData shopping_cart_rounded = IconData(0xf0171, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shopping_cart</i> — material icon named "shopping cart" (outlined). + static const IconData shopping_cart_outlined = IconData(0xf37f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shopping_cart_checkout</i> — material icon named "shopping cart checkout". + static const IconData shopping_cart_checkout = IconData(0xf0568, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shopping_cart_checkout</i> — material icon named "shopping cart checkout" (sharp). + static const IconData shopping_cart_checkout_sharp = IconData( + 0xf0471, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">shopping_cart_checkout</i> — material icon named "shopping cart checkout" (round). + static const IconData shopping_cart_checkout_rounded = IconData( + 0xf037e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">shopping_cart_checkout</i> — material icon named "shopping cart checkout" (outlined). + static const IconData shopping_cart_checkout_outlined = IconData( + 0xf065f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">short_text</i> — material icon named "short text". + static const IconData short_text = IconData( + 0xe59d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">short_text</i> — material icon named "short text" (sharp). + static const IconData short_text_sharp = IconData( + 0xec93, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">short_text</i> — material icon named "short text" (round). + static const IconData short_text_rounded = IconData( + 0xf0172, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">short_text</i> — material icon named "short text" (outlined). + static const IconData short_text_outlined = IconData( + 0xf380, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">shortcut</i> — material icon named "shortcut". + static const IconData shortcut = IconData(0xe59e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shortcut</i> — material icon named "shortcut" (sharp). + static const IconData shortcut_sharp = IconData(0xec94, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shortcut</i> — material icon named "shortcut" (round). + static const IconData shortcut_rounded = IconData(0xf0173, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shortcut</i> — material icon named "shortcut" (outlined). + static const IconData shortcut_outlined = IconData(0xf381, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">show_chart</i> — material icon named "show chart". + static const IconData show_chart = IconData( + 0xe59f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">show_chart</i> — material icon named "show chart" (sharp). + static const IconData show_chart_sharp = IconData( + 0xec95, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">show_chart</i> — material icon named "show chart" (round). + static const IconData show_chart_rounded = IconData( + 0xf0174, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">show_chart</i> — material icon named "show chart" (outlined). + static const IconData show_chart_outlined = IconData( + 0xf382, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">shower</i> — material icon named "shower". + static const IconData shower = IconData(0xe5a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shower</i> — material icon named "shower" (sharp). + static const IconData shower_sharp = IconData(0xec96, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shower</i> — material icon named "shower" (round). + static const IconData shower_rounded = IconData(0xf0175, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shower</i> — material icon named "shower" (outlined). + static const IconData shower_outlined = IconData(0xf383, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shuffle</i> — material icon named "shuffle". + static const IconData shuffle = IconData(0xe5a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shuffle</i> — material icon named "shuffle" (sharp). + static const IconData shuffle_sharp = IconData(0xec98, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shuffle</i> — material icon named "shuffle" (round). + static const IconData shuffle_rounded = IconData(0xf0177, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shuffle</i> — material icon named "shuffle" (outlined). + static const IconData shuffle_outlined = IconData(0xf385, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shuffle_on</i> — material icon named "shuffle on". + static const IconData shuffle_on = IconData(0xe5a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shuffle_on</i> — material icon named "shuffle on" (sharp). + static const IconData shuffle_on_sharp = IconData(0xec97, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shuffle_on</i> — material icon named "shuffle on" (round). + static const IconData shuffle_on_rounded = IconData(0xf0176, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shuffle_on</i> — material icon named "shuffle on" (outlined). + static const IconData shuffle_on_outlined = IconData(0xf384, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">shutter_speed</i> — material icon named "shutter speed". + static const IconData shutter_speed = IconData(0xe5a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">shutter_speed</i> — material icon named "shutter speed" (sharp). + static const IconData shutter_speed_sharp = IconData(0xec99, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">shutter_speed</i> — material icon named "shutter speed" (round). + static const IconData shutter_speed_rounded = IconData(0xf0178, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">shutter_speed</i> — material icon named "shutter speed" (outlined). + static const IconData shutter_speed_outlined = IconData(0xf386, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sick</i> — material icon named "sick". + static const IconData sick = IconData(0xe5a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sick</i> — material icon named "sick" (sharp). + static const IconData sick_sharp = IconData(0xec9a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sick</i> — material icon named "sick" (round). + static const IconData sick_rounded = IconData(0xf0179, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sick</i> — material icon named "sick" (outlined). + static const IconData sick_outlined = IconData(0xf387, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sign_language</i> — material icon named "sign language". + static const IconData sign_language = IconData(0xf07c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sign_language</i> — material icon named "sign language" (sharp). + static const IconData sign_language_sharp = IconData(0xf076b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sign_language</i> — material icon named "sign language" (round). + static const IconData sign_language_rounded = IconData(0xf081b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sign_language</i> — material icon named "sign language" (outlined). + static const IconData sign_language_outlined = IconData(0xf0713, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">signal_cellular_0_bar</i> — material icon named "signal cellular 0 bar". + static const IconData signal_cellular_0_bar = IconData(0xe5a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_0_bar</i> — material icon named "signal cellular 0 bar" (sharp). + static const IconData signal_cellular_0_bar_sharp = IconData(0xec9b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_cellular_0_bar</i> — material icon named "signal cellular 0 bar" (round). + static const IconData signal_cellular_0_bar_rounded = IconData( + 0xf017a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_0_bar</i> — material icon named "signal cellular 0 bar" (outlined). + static const IconData signal_cellular_0_bar_outlined = IconData( + 0xf388, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_4_bar</i> — material icon named "signal cellular 4 bar". + static const IconData signal_cellular_4_bar = IconData(0xe5a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_4_bar</i> — material icon named "signal cellular 4 bar" (sharp). + static const IconData signal_cellular_4_bar_sharp = IconData(0xec9c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_cellular_4_bar</i> — material icon named "signal cellular 4 bar" (round). + static const IconData signal_cellular_4_bar_rounded = IconData( + 0xf017b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_4_bar</i> — material icon named "signal cellular 4 bar" (outlined). + static const IconData signal_cellular_4_bar_outlined = IconData( + 0xf389, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_alt</i> — material icon named "signal cellular alt". + static const IconData signal_cellular_alt = IconData(0xe5a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_alt</i> — material icon named "signal cellular alt" (sharp). + static const IconData signal_cellular_alt_sharp = IconData(0xec9d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_cellular_alt</i> — material icon named "signal cellular alt" (round). + static const IconData signal_cellular_alt_rounded = IconData( + 0xf017c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_alt</i> — material icon named "signal cellular alt" (outlined). + static const IconData signal_cellular_alt_outlined = IconData( + 0xf38a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_alt_1_bar</i> — material icon named "signal cellular alt 1 bar". + static const IconData signal_cellular_alt_1_bar = IconData(0xf07c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_alt_1_bar</i> — material icon named "signal cellular alt 1 bar" (sharp). + static const IconData signal_cellular_alt_1_bar_sharp = IconData( + 0xf076c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_cellular_alt_1_bar</i> — material icon named "signal cellular alt 1 bar" (round). + static const IconData signal_cellular_alt_1_bar_rounded = IconData( + 0xf081c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_alt_1_bar</i> — material icon named "signal cellular alt 1 bar" (outlined). + static const IconData signal_cellular_alt_1_bar_outlined = IconData( + 0xf0714, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_alt_2_bar</i> — material icon named "signal cellular alt 2 bar". + static const IconData signal_cellular_alt_2_bar = IconData(0xf07c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_alt_2_bar</i> — material icon named "signal cellular alt 2 bar" (sharp). + static const IconData signal_cellular_alt_2_bar_sharp = IconData( + 0xf076d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_cellular_alt_2_bar</i> — material icon named "signal cellular alt 2 bar" (round). + static const IconData signal_cellular_alt_2_bar_rounded = IconData( + 0xf081d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_alt_2_bar</i> — material icon named "signal cellular alt 2 bar" (outlined). + static const IconData signal_cellular_alt_2_bar_outlined = IconData( + 0xf0715, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_connected_no_internet_0_bar</i> — material icon named "signal cellular connected no internet 0 bar". + static const IconData signal_cellular_connected_no_internet_0_bar = IconData( + 0xe5a8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">signal_cellular_connected_no_internet_0_bar</i> — material icon named "signal cellular connected no internet 0 bar" (sharp). + static const IconData signal_cellular_connected_no_internet_0_bar_sharp = IconData( + 0xec9e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_cellular_connected_no_internet_0_bar</i> — material icon named "signal cellular connected no internet 0 bar" (round). + static const IconData signal_cellular_connected_no_internet_0_bar_rounded = IconData( + 0xf017d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_connected_no_internet_0_bar</i> — material icon named "signal cellular connected no internet 0 bar" (outlined). + static const IconData signal_cellular_connected_no_internet_0_bar_outlined = IconData( + 0xf38b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_connected_no_internet_4_bar</i> — material icon named "signal cellular connected no internet 4 bar". + static const IconData signal_cellular_connected_no_internet_4_bar = IconData( + 0xe5a9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">signal_cellular_connected_no_internet_4_bar</i> — material icon named "signal cellular connected no internet 4 bar" (sharp). + static const IconData signal_cellular_connected_no_internet_4_bar_sharp = IconData( + 0xec9f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_cellular_connected_no_internet_4_bar</i> — material icon named "signal cellular connected no internet 4 bar" (round). + static const IconData signal_cellular_connected_no_internet_4_bar_rounded = IconData( + 0xf017e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_connected_no_internet_4_bar</i> — material icon named "signal cellular connected no internet 4 bar" (outlined). + static const IconData signal_cellular_connected_no_internet_4_bar_outlined = IconData( + 0xf38c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_no_sim</i> — material icon named "signal cellular no sim". + static const IconData signal_cellular_no_sim = IconData(0xe5aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_no_sim</i> — material icon named "signal cellular no sim" (sharp). + static const IconData signal_cellular_no_sim_sharp = IconData( + 0xeca0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_cellular_no_sim</i> — material icon named "signal cellular no sim" (round). + static const IconData signal_cellular_no_sim_rounded = IconData( + 0xf017f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_no_sim</i> — material icon named "signal cellular no sim" (outlined). + static const IconData signal_cellular_no_sim_outlined = IconData( + 0xf38d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_nodata</i> — material icon named "signal cellular nodata". + static const IconData signal_cellular_nodata = IconData(0xe5ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_nodata</i> — material icon named "signal cellular nodata" (sharp). + static const IconData signal_cellular_nodata_sharp = IconData( + 0xeca1, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_cellular_nodata</i> — material icon named "signal cellular nodata" (round). + static const IconData signal_cellular_nodata_rounded = IconData( + 0xf0180, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_nodata</i> — material icon named "signal cellular nodata" (outlined). + static const IconData signal_cellular_nodata_outlined = IconData( + 0xf38e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_null</i> — material icon named "signal cellular null". + static const IconData signal_cellular_null = IconData(0xe5ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_null</i> — material icon named "signal cellular null" (sharp). + static const IconData signal_cellular_null_sharp = IconData(0xeca2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_cellular_null</i> — material icon named "signal cellular null" (round). + static const IconData signal_cellular_null_rounded = IconData( + 0xf0181, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_null</i> — material icon named "signal cellular null" (outlined). + static const IconData signal_cellular_null_outlined = IconData( + 0xf38f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_cellular_off</i> — material icon named "signal cellular off". + static const IconData signal_cellular_off = IconData(0xe5ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_cellular_off</i> — material icon named "signal cellular off" (sharp). + static const IconData signal_cellular_off_sharp = IconData(0xeca3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_cellular_off</i> — material icon named "signal cellular off" (round). + static const IconData signal_cellular_off_rounded = IconData( + 0xf0182, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_cellular_off</i> — material icon named "signal cellular off" (outlined). + static const IconData signal_cellular_off_outlined = IconData( + 0xf390, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_wifi_0_bar</i> — material icon named "signal wifi 0 bar". + static const IconData signal_wifi_0_bar = IconData(0xe5ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_0_bar</i> — material icon named "signal wifi 0 bar" (sharp). + static const IconData signal_wifi_0_bar_sharp = IconData(0xeca4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_wifi_0_bar</i> — material icon named "signal wifi 0 bar" (round). + static const IconData signal_wifi_0_bar_rounded = IconData(0xf0183, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">signal_wifi_0_bar</i> — material icon named "signal wifi 0 bar" (outlined). + static const IconData signal_wifi_0_bar_outlined = IconData(0xf391, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">signal_wifi_4_bar</i> — material icon named "signal wifi 4 bar". + static const IconData signal_wifi_4_bar = IconData(0xe5af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_4_bar</i> — material icon named "signal wifi 4 bar" (sharp). + static const IconData signal_wifi_4_bar_sharp = IconData(0xeca6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_wifi_4_bar</i> — material icon named "signal wifi 4 bar" (round). + static const IconData signal_wifi_4_bar_rounded = IconData(0xf0185, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">signal_wifi_4_bar</i> — material icon named "signal wifi 4 bar" (outlined). + static const IconData signal_wifi_4_bar_outlined = IconData(0xf393, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">signal_wifi_4_bar_lock</i> — material icon named "signal wifi 4 bar lock". + static const IconData signal_wifi_4_bar_lock = IconData(0xe5b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_4_bar_lock</i> — material icon named "signal wifi 4 bar lock" (sharp). + static const IconData signal_wifi_4_bar_lock_sharp = IconData( + 0xeca5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_wifi_4_bar_lock</i> — material icon named "signal wifi 4 bar lock" (round). + static const IconData signal_wifi_4_bar_lock_rounded = IconData( + 0xf0184, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_wifi_4_bar_lock</i> — material icon named "signal wifi 4 bar lock" (outlined). + static const IconData signal_wifi_4_bar_lock_outlined = IconData( + 0xf392, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_wifi_bad</i> — material icon named "signal wifi bad". + static const IconData signal_wifi_bad = IconData(0xe5b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_bad</i> — material icon named "signal wifi bad" (sharp). + static const IconData signal_wifi_bad_sharp = IconData(0xeca7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_wifi_bad</i> — material icon named "signal wifi bad" (round). + static const IconData signal_wifi_bad_rounded = IconData(0xf0186, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">signal_wifi_bad</i> — material icon named "signal wifi bad" (outlined). + static const IconData signal_wifi_bad_outlined = IconData(0xf394, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">signal_wifi_connected_no_internet_4</i> — material icon named "signal wifi connected no internet 4". + static const IconData signal_wifi_connected_no_internet_4 = IconData( + 0xe5b2, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">signal_wifi_connected_no_internet_4</i> — material icon named "signal wifi connected no internet 4" (sharp). + static const IconData signal_wifi_connected_no_internet_4_sharp = IconData( + 0xeca8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_wifi_connected_no_internet_4</i> — material icon named "signal wifi connected no internet 4" (round). + static const IconData signal_wifi_connected_no_internet_4_rounded = IconData( + 0xf0187, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_wifi_connected_no_internet_4</i> — material icon named "signal wifi connected no internet 4" (outlined). + static const IconData signal_wifi_connected_no_internet_4_outlined = IconData( + 0xf395, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_wifi_off</i> — material icon named "signal wifi off". + static const IconData signal_wifi_off = IconData(0xe5b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_off</i> — material icon named "signal wifi off" (sharp). + static const IconData signal_wifi_off_sharp = IconData(0xeca9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signal_wifi_off</i> — material icon named "signal wifi off" (round). + static const IconData signal_wifi_off_rounded = IconData(0xf0188, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">signal_wifi_off</i> — material icon named "signal wifi off" (outlined). + static const IconData signal_wifi_off_outlined = IconData(0xf396, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">signal_wifi_statusbar_4_bar</i> — material icon named "signal wifi statusbar 4 bar". + static const IconData signal_wifi_statusbar_4_bar = IconData(0xe5b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_statusbar_4_bar</i> — material icon named "signal wifi statusbar 4 bar" (sharp). + static const IconData signal_wifi_statusbar_4_bar_sharp = IconData( + 0xecaa, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_wifi_statusbar_4_bar</i> — material icon named "signal wifi statusbar 4 bar" (round). + static const IconData signal_wifi_statusbar_4_bar_rounded = IconData( + 0xf0189, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_wifi_statusbar_4_bar</i> — material icon named "signal wifi statusbar 4 bar" (outlined). + static const IconData signal_wifi_statusbar_4_bar_outlined = IconData( + 0xf397, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_wifi_statusbar_connected_no_internet_4</i> — material icon named "signal wifi statusbar connected no internet 4". + static const IconData signal_wifi_statusbar_connected_no_internet_4 = IconData( + 0xe5b5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">signal_wifi_statusbar_connected_no_internet_4</i> — material icon named "signal wifi statusbar connected no internet 4" (sharp). + static const IconData signal_wifi_statusbar_connected_no_internet_4_sharp = IconData( + 0xecab, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_wifi_statusbar_connected_no_internet_4</i> — material icon named "signal wifi statusbar connected no internet 4" (round). + static const IconData signal_wifi_statusbar_connected_no_internet_4_rounded = IconData( + 0xf018a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_wifi_statusbar_connected_no_internet_4</i> — material icon named "signal wifi statusbar connected no internet 4" (outlined). + static const IconData signal_wifi_statusbar_connected_no_internet_4_outlined = IconData( + 0xf398, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signal_wifi_statusbar_null</i> — material icon named "signal wifi statusbar null". + static const IconData signal_wifi_statusbar_null = IconData(0xe5b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signal_wifi_statusbar_null</i> — material icon named "signal wifi statusbar null" (sharp). + static const IconData signal_wifi_statusbar_null_sharp = IconData( + 0xecac, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">signal_wifi_statusbar_null</i> — material icon named "signal wifi statusbar null" (round). + static const IconData signal_wifi_statusbar_null_rounded = IconData( + 0xf018b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">signal_wifi_statusbar_null</i> — material icon named "signal wifi statusbar null" (outlined). + static const IconData signal_wifi_statusbar_null_outlined = IconData( + 0xf399, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">signpost</i> — material icon named "signpost". + static const IconData signpost = IconData(0xf0569, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">signpost</i> — material icon named "signpost" (sharp). + static const IconData signpost_sharp = IconData(0xf0472, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">signpost</i> — material icon named "signpost" (round). + static const IconData signpost_rounded = IconData(0xf037f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">signpost</i> — material icon named "signpost" (outlined). + static const IconData signpost_outlined = IconData(0xf0660, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sim_card</i> — material icon named "sim card". + static const IconData sim_card = IconData(0xe5b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sim_card</i> — material icon named "sim card" (sharp). + static const IconData sim_card_sharp = IconData(0xecaf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sim_card</i> — material icon named "sim card" (round). + static const IconData sim_card_rounded = IconData(0xf018e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sim_card</i> — material icon named "sim card" (outlined). + static const IconData sim_card_outlined = IconData(0xf39c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sim_card_alert</i> — material icon named "sim card alert". + static const IconData sim_card_alert = IconData(0xe5b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sim_card_alert</i> — material icon named "sim card alert" (sharp). + static const IconData sim_card_alert_sharp = IconData(0xecad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sim_card_alert</i> — material icon named "sim card alert" (round). + static const IconData sim_card_alert_rounded = IconData(0xf018c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sim_card_alert</i> — material icon named "sim card alert" (outlined). + static const IconData sim_card_alert_outlined = IconData(0xf39a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sim_card_download</i> — material icon named "sim card download". + static const IconData sim_card_download = IconData(0xe5b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sim_card_download</i> — material icon named "sim card download" (sharp). + static const IconData sim_card_download_sharp = IconData(0xecae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sim_card_download</i> — material icon named "sim card download" (round). + static const IconData sim_card_download_rounded = IconData(0xf018d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sim_card_download</i> — material icon named "sim card download" (outlined). + static const IconData sim_card_download_outlined = IconData(0xf39b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">single_bed</i> — material icon named "single bed". + static const IconData single_bed = IconData(0xe5ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">single_bed</i> — material icon named "single bed" (sharp). + static const IconData single_bed_sharp = IconData(0xecb0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">single_bed</i> — material icon named "single bed" (round). + static const IconData single_bed_rounded = IconData(0xf018f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">single_bed</i> — material icon named "single bed" (outlined). + static const IconData single_bed_outlined = IconData(0xf39d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sip</i> — material icon named "sip". + static const IconData sip = IconData(0xe5bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sip</i> — material icon named "sip" (sharp). + static const IconData sip_sharp = IconData(0xecb1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sip</i> — material icon named "sip" (round). + static const IconData sip_rounded = IconData(0xf0190, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sip</i> — material icon named "sip" (outlined). + static const IconData sip_outlined = IconData(0xf39e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">skateboarding</i> — material icon named "skateboarding". + static const IconData skateboarding = IconData(0xe5bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">skateboarding</i> — material icon named "skateboarding" (sharp). + static const IconData skateboarding_sharp = IconData(0xecb2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">skateboarding</i> — material icon named "skateboarding" (round). + static const IconData skateboarding_rounded = IconData(0xf0191, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">skateboarding</i> — material icon named "skateboarding" (outlined). + static const IconData skateboarding_outlined = IconData(0xf39f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">skip_next</i> — material icon named "skip next". + static const IconData skip_next = IconData(0xe5bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">skip_next</i> — material icon named "skip next" (sharp). + static const IconData skip_next_sharp = IconData(0xecb3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">skip_next</i> — material icon named "skip next" (round). + static const IconData skip_next_rounded = IconData(0xf0192, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">skip_next</i> — material icon named "skip next" (outlined). + static const IconData skip_next_outlined = IconData(0xf3a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">skip_previous</i> — material icon named "skip previous". + static const IconData skip_previous = IconData(0xe5be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">skip_previous</i> — material icon named "skip previous" (sharp). + static const IconData skip_previous_sharp = IconData(0xecb4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">skip_previous</i> — material icon named "skip previous" (round). + static const IconData skip_previous_rounded = IconData(0xf0193, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">skip_previous</i> — material icon named "skip previous" (outlined). + static const IconData skip_previous_outlined = IconData(0xf3a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sledding</i> — material icon named "sledding". + static const IconData sledding = IconData(0xe5bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sledding</i> — material icon named "sledding" (sharp). + static const IconData sledding_sharp = IconData(0xecb5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sledding</i> — material icon named "sledding" (round). + static const IconData sledding_rounded = IconData(0xf0194, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sledding</i> — material icon named "sledding" (outlined). + static const IconData sledding_outlined = IconData(0xf3a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">slideshow</i> — material icon named "slideshow". + static const IconData slideshow = IconData(0xe5c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">slideshow</i> — material icon named "slideshow" (sharp). + static const IconData slideshow_sharp = IconData(0xecb6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">slideshow</i> — material icon named "slideshow" (round). + static const IconData slideshow_rounded = IconData(0xf0195, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">slideshow</i> — material icon named "slideshow" (outlined). + static const IconData slideshow_outlined = IconData(0xf3a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">slow_motion_video</i> — material icon named "slow motion video". + static const IconData slow_motion_video = IconData(0xe5c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">slow_motion_video</i> — material icon named "slow motion video" (sharp). + static const IconData slow_motion_video_sharp = IconData(0xecb7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">slow_motion_video</i> — material icon named "slow motion video" (round). + static const IconData slow_motion_video_rounded = IconData(0xf0196, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">slow_motion_video</i> — material icon named "slow motion video" (outlined). + static const IconData slow_motion_video_outlined = IconData(0xf3a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smart_button</i> — material icon named "smart button". + static const IconData smart_button = IconData(0xe5c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smart_button</i> — material icon named "smart button" (sharp). + static const IconData smart_button_sharp = IconData(0xecb8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smart_button</i> — material icon named "smart button" (round). + static const IconData smart_button_rounded = IconData(0xf0197, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smart_button</i> — material icon named "smart button" (outlined). + static const IconData smart_button_outlined = IconData(0xf3a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smart_display</i> — material icon named "smart display". + static const IconData smart_display = IconData(0xe5c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smart_display</i> — material icon named "smart display" (sharp). + static const IconData smart_display_sharp = IconData(0xecb9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smart_display</i> — material icon named "smart display" (round). + static const IconData smart_display_rounded = IconData(0xf0198, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smart_display</i> — material icon named "smart display" (outlined). + static const IconData smart_display_outlined = IconData(0xf3a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smart_screen</i> — material icon named "smart screen". + static const IconData smart_screen = IconData(0xe5c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smart_screen</i> — material icon named "smart screen" (sharp). + static const IconData smart_screen_sharp = IconData(0xecba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smart_screen</i> — material icon named "smart screen" (round). + static const IconData smart_screen_rounded = IconData(0xf0199, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smart_screen</i> — material icon named "smart screen" (outlined). + static const IconData smart_screen_outlined = IconData(0xf3a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smart_toy</i> — material icon named "smart toy". + static const IconData smart_toy = IconData(0xe5c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smart_toy</i> — material icon named "smart toy" (sharp). + static const IconData smart_toy_sharp = IconData(0xecbb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smart_toy</i> — material icon named "smart toy" (round). + static const IconData smart_toy_rounded = IconData(0xf019a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smart_toy</i> — material icon named "smart toy" (outlined). + static const IconData smart_toy_outlined = IconData(0xf3a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smartphone</i> — material icon named "smartphone". + static const IconData smartphone = IconData(0xe5c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smartphone</i> — material icon named "smartphone" (sharp). + static const IconData smartphone_sharp = IconData(0xecbc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smartphone</i> — material icon named "smartphone" (round). + static const IconData smartphone_rounded = IconData(0xf019b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smartphone</i> — material icon named "smartphone" (outlined). + static const IconData smartphone_outlined = IconData(0xf3a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smoke_free</i> — material icon named "smoke free". + static const IconData smoke_free = IconData(0xe5c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smoke_free</i> — material icon named "smoke free" (sharp). + static const IconData smoke_free_sharp = IconData(0xecbd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smoke_free</i> — material icon named "smoke free" (round). + static const IconData smoke_free_rounded = IconData(0xf019c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smoke_free</i> — material icon named "smoke free" (outlined). + static const IconData smoke_free_outlined = IconData(0xf3aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">smoking_rooms</i> — material icon named "smoking rooms". + static const IconData smoking_rooms = IconData(0xe5c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">smoking_rooms</i> — material icon named "smoking rooms" (sharp). + static const IconData smoking_rooms_sharp = IconData(0xecbe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">smoking_rooms</i> — material icon named "smoking rooms" (round). + static const IconData smoking_rooms_rounded = IconData(0xf019d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">smoking_rooms</i> — material icon named "smoking rooms" (outlined). + static const IconData smoking_rooms_outlined = IconData(0xf3ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sms</i> — material icon named "sms". + static const IconData sms = IconData(0xe5c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sms</i> — material icon named "sms" (sharp). + static const IconData sms_sharp = IconData(0xecc0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sms</i> — material icon named "sms" (round). + static const IconData sms_rounded = IconData(0xf019f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sms</i> — material icon named "sms" (outlined). + static const IconData sms_outlined = IconData(0xf3ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sms_failed</i> — material icon named "sms failed". + static const IconData sms_failed = IconData(0xe5ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sms_failed</i> — material icon named "sms failed" (sharp). + static const IconData sms_failed_sharp = IconData(0xecbf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sms_failed</i> — material icon named "sms failed" (round). + static const IconData sms_failed_rounded = IconData(0xf019e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sms_failed</i> — material icon named "sms failed" (outlined). + static const IconData sms_failed_outlined = IconData(0xf3ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snapchat</i> — material icon named "snapchat". + static const IconData snapchat = IconData(0xf056a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">snapchat</i> — material icon named "snapchat" (sharp). + static const IconData snapchat_sharp = IconData(0xf0473, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">snapchat</i> — material icon named "snapchat" (round). + static const IconData snapchat_rounded = IconData(0xf0380, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">snapchat</i> — material icon named "snapchat" (outlined). + static const IconData snapchat_outlined = IconData(0xf0661, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snippet_folder</i> — material icon named "snippet folder". + static const IconData snippet_folder = IconData(0xe5cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">snippet_folder</i> — material icon named "snippet folder" (sharp). + static const IconData snippet_folder_sharp = IconData(0xecc1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">snippet_folder</i> — material icon named "snippet folder" (round). + static const IconData snippet_folder_rounded = IconData(0xf01a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">snippet_folder</i> — material icon named "snippet folder" (outlined). + static const IconData snippet_folder_outlined = IconData(0xf3ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snooze</i> — material icon named "snooze". + static const IconData snooze = IconData(0xe5cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">snooze</i> — material icon named "snooze" (sharp). + static const IconData snooze_sharp = IconData(0xecc2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">snooze</i> — material icon named "snooze" (round). + static const IconData snooze_rounded = IconData(0xf01a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">snooze</i> — material icon named "snooze" (outlined). + static const IconData snooze_outlined = IconData(0xf3af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snowboarding</i> — material icon named "snowboarding". + static const IconData snowboarding = IconData(0xe5cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">snowboarding</i> — material icon named "snowboarding" (sharp). + static const IconData snowboarding_sharp = IconData(0xecc3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">snowboarding</i> — material icon named "snowboarding" (round). + static const IconData snowboarding_rounded = IconData(0xf01a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">snowboarding</i> — material icon named "snowboarding" (outlined). + static const IconData snowboarding_outlined = IconData(0xf3b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snowing</i> — material icon named "snowing". + static const IconData snowing = IconData(0xf056b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snowmobile</i> — material icon named "snowmobile". + static const IconData snowmobile = IconData(0xe5ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">snowmobile</i> — material icon named "snowmobile" (sharp). + static const IconData snowmobile_sharp = IconData(0xecc4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">snowmobile</i> — material icon named "snowmobile" (round). + static const IconData snowmobile_rounded = IconData(0xf01a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">snowmobile</i> — material icon named "snowmobile" (outlined). + static const IconData snowmobile_outlined = IconData(0xf3b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">snowshoeing</i> — material icon named "snowshoeing". + static const IconData snowshoeing = IconData(0xe5cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">snowshoeing</i> — material icon named "snowshoeing" (sharp). + static const IconData snowshoeing_sharp = IconData(0xecc5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">snowshoeing</i> — material icon named "snowshoeing" (round). + static const IconData snowshoeing_rounded = IconData(0xf01a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">snowshoeing</i> — material icon named "snowshoeing" (outlined). + static const IconData snowshoeing_outlined = IconData(0xf3b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">soap</i> — material icon named "soap". + static const IconData soap = IconData(0xe5d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">soap</i> — material icon named "soap" (sharp). + static const IconData soap_sharp = IconData(0xecc6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">soap</i> — material icon named "soap" (round). + static const IconData soap_rounded = IconData(0xf01a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">soap</i> — material icon named "soap" (outlined). + static const IconData soap_outlined = IconData(0xf3b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">social_distance</i> — material icon named "social distance". + static const IconData social_distance = IconData(0xe5d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">social_distance</i> — material icon named "social distance" (sharp). + static const IconData social_distance_sharp = IconData(0xecc7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">social_distance</i> — material icon named "social distance" (round). + static const IconData social_distance_rounded = IconData(0xf01a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">social_distance</i> — material icon named "social distance" (outlined). + static const IconData social_distance_outlined = IconData(0xf3b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">solar_power</i> — material icon named "solar power". + static const IconData solar_power = IconData(0xf07c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">solar_power</i> — material icon named "solar power" (sharp). + static const IconData solar_power_sharp = IconData(0xf076e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">solar_power</i> — material icon named "solar power" (round). + static const IconData solar_power_rounded = IconData(0xf081e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">solar_power</i> — material icon named "solar power" (outlined). + static const IconData solar_power_outlined = IconData(0xf0716, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sort</i> — material icon named "sort". + static const IconData sort = IconData( + 0xe5d2, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">sort</i> — material icon named "sort" (sharp). + static const IconData sort_sharp = IconData( + 0xecc9, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">sort</i> — material icon named "sort" (round). + static const IconData sort_rounded = IconData( + 0xf01a8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">sort</i> — material icon named "sort" (outlined). + static const IconData sort_outlined = IconData( + 0xf3b6, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">sort_by_alpha</i> — material icon named "sort by alpha". + static const IconData sort_by_alpha = IconData(0xe5d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sort_by_alpha</i> — material icon named "sort by alpha" (sharp). + static const IconData sort_by_alpha_sharp = IconData(0xecc8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sort_by_alpha</i> — material icon named "sort by alpha" (round). + static const IconData sort_by_alpha_rounded = IconData(0xf01a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sort_by_alpha</i> — material icon named "sort by alpha" (outlined). + static const IconData sort_by_alpha_outlined = IconData(0xf3b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sos</i> — material icon named "sos". + static const IconData sos = IconData(0xf07c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sos</i> — material icon named "sos" (sharp). + static const IconData sos_sharp = IconData(0xf076f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sos</i> — material icon named "sos" (round). + static const IconData sos_rounded = IconData(0xf081f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sos</i> — material icon named "sos" (outlined). + static const IconData sos_outlined = IconData(0xf0717, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">soup_kitchen</i> — material icon named "soup kitchen". + static const IconData soup_kitchen = IconData(0xf056c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">soup_kitchen</i> — material icon named "soup kitchen" (sharp). + static const IconData soup_kitchen_sharp = IconData(0xf0474, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">soup_kitchen</i> — material icon named "soup kitchen" (round). + static const IconData soup_kitchen_rounded = IconData(0xf0381, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">soup_kitchen</i> — material icon named "soup kitchen" (outlined). + static const IconData soup_kitchen_outlined = IconData(0xf0662, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">source</i> — material icon named "source". + static const IconData source = IconData(0xe5d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">source</i> — material icon named "source" (sharp). + static const IconData source_sharp = IconData(0xecca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">source</i> — material icon named "source" (round). + static const IconData source_rounded = IconData(0xf01a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">source</i> — material icon named "source" (outlined). + static const IconData source_outlined = IconData(0xf3b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">south</i> — material icon named "south". + static const IconData south = IconData(0xe5d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">south</i> — material icon named "south" (sharp). + static const IconData south_sharp = IconData(0xeccc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">south</i> — material icon named "south" (round). + static const IconData south_rounded = IconData(0xf01ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">south</i> — material icon named "south" (outlined). + static const IconData south_outlined = IconData(0xf3b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">south_america</i> — material icon named "south america". + static const IconData south_america = IconData(0xf056d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">south_america</i> — material icon named "south america" (sharp). + static const IconData south_america_sharp = IconData(0xf0475, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">south_america</i> — material icon named "south america" (round). + static const IconData south_america_rounded = IconData(0xf0382, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">south_america</i> — material icon named "south america" (outlined). + static const IconData south_america_outlined = IconData(0xf0663, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">south_east</i> — material icon named "south east". + static const IconData south_east = IconData(0xe5d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">south_east</i> — material icon named "south east" (sharp). + static const IconData south_east_sharp = IconData(0xeccb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">south_east</i> — material icon named "south east" (round). + static const IconData south_east_rounded = IconData(0xf01aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">south_east</i> — material icon named "south east" (outlined). + static const IconData south_east_outlined = IconData(0xf3b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">south_west</i> — material icon named "south west". + static const IconData south_west = IconData(0xe5d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">south_west</i> — material icon named "south west" (sharp). + static const IconData south_west_sharp = IconData(0xeccd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">south_west</i> — material icon named "south west" (round). + static const IconData south_west_rounded = IconData(0xf01ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">south_west</i> — material icon named "south west" (outlined). + static const IconData south_west_outlined = IconData(0xf3ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">spa</i> — material icon named "spa". + static const IconData spa = IconData(0xe5d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">spa</i> — material icon named "spa" (sharp). + static const IconData spa_sharp = IconData(0xecce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">spa</i> — material icon named "spa" (round). + static const IconData spa_rounded = IconData(0xf01ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">spa</i> — material icon named "spa" (outlined). + static const IconData spa_outlined = IconData(0xf3bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">space_bar</i> — material icon named "space bar". + static const IconData space_bar = IconData(0xe5d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">space_bar</i> — material icon named "space bar" (sharp). + static const IconData space_bar_sharp = IconData(0xeccf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">space_bar</i> — material icon named "space bar" (round). + static const IconData space_bar_rounded = IconData(0xf01ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">space_bar</i> — material icon named "space bar" (outlined). + static const IconData space_bar_outlined = IconData(0xf3bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">space_dashboard</i> — material icon named "space dashboard". + static const IconData space_dashboard = IconData(0xe5da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">space_dashboard</i> — material icon named "space dashboard" (sharp). + static const IconData space_dashboard_sharp = IconData(0xecd0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">space_dashboard</i> — material icon named "space dashboard" (round). + static const IconData space_dashboard_rounded = IconData(0xf01af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">space_dashboard</i> — material icon named "space dashboard" (outlined). + static const IconData space_dashboard_outlined = IconData(0xf3bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">spatial_audio</i> — material icon named "spatial audio". + static const IconData spatial_audio = IconData(0xf07c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">spatial_audio</i> — material icon named "spatial audio" (sharp). + static const IconData spatial_audio_sharp = IconData(0xf0771, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">spatial_audio</i> — material icon named "spatial audio" (round). + static const IconData spatial_audio_rounded = IconData(0xf0821, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">spatial_audio</i> — material icon named "spatial audio" (outlined). + static const IconData spatial_audio_outlined = IconData(0xf0719, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">spatial_audio_off</i> — material icon named "spatial audio off". + static const IconData spatial_audio_off = IconData(0xf07c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">spatial_audio_off</i> — material icon named "spatial audio off" (sharp). + static const IconData spatial_audio_off_sharp = IconData(0xf0770, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">spatial_audio_off</i> — material icon named "spatial audio off" (round). + static const IconData spatial_audio_off_rounded = IconData(0xf0820, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">spatial_audio_off</i> — material icon named "spatial audio off" (outlined). + static const IconData spatial_audio_off_outlined = IconData(0xf0718, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">spatial_tracking</i> — material icon named "spatial tracking". + static const IconData spatial_tracking = IconData(0xf07ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">spatial_tracking</i> — material icon named "spatial tracking" (sharp). + static const IconData spatial_tracking_sharp = IconData(0xf0772, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">spatial_tracking</i> — material icon named "spatial tracking" (round). + static const IconData spatial_tracking_rounded = IconData(0xf0822, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">spatial_tracking</i> — material icon named "spatial tracking" (outlined). + static const IconData spatial_tracking_outlined = IconData(0xf071a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">speaker</i> — material icon named "speaker". + static const IconData speaker = IconData(0xe5db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">speaker</i> — material icon named "speaker" (sharp). + static const IconData speaker_sharp = IconData(0xecd5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">speaker</i> — material icon named "speaker" (round). + static const IconData speaker_rounded = IconData(0xf01b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">speaker</i> — material icon named "speaker" (outlined). + static const IconData speaker_outlined = IconData(0xf3c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">speaker_group</i> — material icon named "speaker group". + static const IconData speaker_group = IconData(0xe5dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">speaker_group</i> — material icon named "speaker group" (sharp). + static const IconData speaker_group_sharp = IconData(0xecd1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">speaker_group</i> — material icon named "speaker group" (round). + static const IconData speaker_group_rounded = IconData(0xf01b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">speaker_group</i> — material icon named "speaker group" (outlined). + static const IconData speaker_group_outlined = IconData(0xf3be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">speaker_notes</i> — material icon named "speaker notes". + static const IconData speaker_notes = IconData(0xe5dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">speaker_notes</i> — material icon named "speaker notes" (sharp). + static const IconData speaker_notes_sharp = IconData(0xecd3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">speaker_notes</i> — material icon named "speaker notes" (round). + static const IconData speaker_notes_rounded = IconData(0xf01b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">speaker_notes</i> — material icon named "speaker notes" (outlined). + static const IconData speaker_notes_outlined = IconData(0xf3c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">speaker_notes_off</i> — material icon named "speaker notes off". + static const IconData speaker_notes_off = IconData(0xe5de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">speaker_notes_off</i> — material icon named "speaker notes off" (sharp). + static const IconData speaker_notes_off_sharp = IconData(0xecd2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">speaker_notes_off</i> — material icon named "speaker notes off" (round). + static const IconData speaker_notes_off_rounded = IconData(0xf01b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">speaker_notes_off</i> — material icon named "speaker notes off" (outlined). + static const IconData speaker_notes_off_outlined = IconData(0xf3bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">speaker_phone</i> — material icon named "speaker phone". + static const IconData speaker_phone = IconData(0xe5df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">speaker_phone</i> — material icon named "speaker phone" (sharp). + static const IconData speaker_phone_sharp = IconData(0xecd4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">speaker_phone</i> — material icon named "speaker phone" (round). + static const IconData speaker_phone_rounded = IconData(0xf01b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">speaker_phone</i> — material icon named "speaker phone" (outlined). + static const IconData speaker_phone_outlined = IconData(0xf3c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">speed</i> — material icon named "speed". + static const IconData speed = IconData(0xe5e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">speed</i> — material icon named "speed" (sharp). + static const IconData speed_sharp = IconData(0xecd6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">speed</i> — material icon named "speed" (round). + static const IconData speed_rounded = IconData(0xf01b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">speed</i> — material icon named "speed" (outlined). + static const IconData speed_outlined = IconData(0xf3c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">spellcheck</i> — material icon named "spellcheck". + static const IconData spellcheck = IconData(0xe5e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">spellcheck</i> — material icon named "spellcheck" (sharp). + static const IconData spellcheck_sharp = IconData(0xecd7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">spellcheck</i> — material icon named "spellcheck" (round). + static const IconData spellcheck_rounded = IconData(0xf01b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">spellcheck</i> — material icon named "spellcheck" (outlined). + static const IconData spellcheck_outlined = IconData(0xf3c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">splitscreen</i> — material icon named "splitscreen". + static const IconData splitscreen = IconData(0xe5e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">splitscreen</i> — material icon named "splitscreen" (sharp). + static const IconData splitscreen_sharp = IconData(0xecd8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">splitscreen</i> — material icon named "splitscreen" (round). + static const IconData splitscreen_rounded = IconData(0xf01b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">splitscreen</i> — material icon named "splitscreen" (outlined). + static const IconData splitscreen_outlined = IconData(0xf3c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">spoke</i> — material icon named "spoke". + static const IconData spoke = IconData(0xf056e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">spoke</i> — material icon named "spoke" (sharp). + static const IconData spoke_sharp = IconData(0xf0476, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">spoke</i> — material icon named "spoke" (round). + static const IconData spoke_rounded = IconData(0xf0383, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">spoke</i> — material icon named "spoke" (outlined). + static const IconData spoke_outlined = IconData(0xf0664, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports</i> — material icon named "sports". + static const IconData sports = IconData(0xe5e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports</i> — material icon named "sports" (sharp). + static const IconData sports_sharp = IconData(0xece7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports</i> — material icon named "sports" (round). + static const IconData sports_rounded = IconData(0xf01c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports</i> — material icon named "sports" (outlined). + static const IconData sports_outlined = IconData(0xf3d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_bar</i> — material icon named "sports bar". + static const IconData sports_bar = IconData(0xe5e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_bar</i> — material icon named "sports bar" (sharp). + static const IconData sports_bar_sharp = IconData(0xecd9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_bar</i> — material icon named "sports bar" (round). + static const IconData sports_bar_rounded = IconData(0xf01b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_bar</i> — material icon named "sports bar" (outlined). + static const IconData sports_bar_outlined = IconData(0xf3c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_baseball</i> — material icon named "sports baseball". + static const IconData sports_baseball = IconData(0xe5e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_baseball</i> — material icon named "sports baseball" (sharp). + static const IconData sports_baseball_sharp = IconData(0xecda, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_baseball</i> — material icon named "sports baseball" (round). + static const IconData sports_baseball_rounded = IconData(0xf01b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_baseball</i> — material icon named "sports baseball" (outlined). + static const IconData sports_baseball_outlined = IconData(0xf3c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_basketball</i> — material icon named "sports basketball". + static const IconData sports_basketball = IconData(0xe5e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_basketball</i> — material icon named "sports basketball" (sharp). + static const IconData sports_basketball_sharp = IconData(0xecdb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_basketball</i> — material icon named "sports basketball" (round). + static const IconData sports_basketball_rounded = IconData(0xf01ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_basketball</i> — material icon named "sports basketball" (outlined). + static const IconData sports_basketball_outlined = IconData(0xf3c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_cricket</i> — material icon named "sports cricket". + static const IconData sports_cricket = IconData(0xe5e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_cricket</i> — material icon named "sports cricket" (sharp). + static const IconData sports_cricket_sharp = IconData(0xecdc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_cricket</i> — material icon named "sports cricket" (round). + static const IconData sports_cricket_rounded = IconData(0xf01bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_cricket</i> — material icon named "sports cricket" (outlined). + static const IconData sports_cricket_outlined = IconData(0xf3c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_esports</i> — material icon named "sports esports". + static const IconData sports_esports = IconData(0xe5e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_esports</i> — material icon named "sports esports" (sharp). + static const IconData sports_esports_sharp = IconData(0xecdd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_esports</i> — material icon named "sports esports" (round). + static const IconData sports_esports_rounded = IconData(0xf01bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_esports</i> — material icon named "sports esports" (outlined). + static const IconData sports_esports_outlined = IconData(0xf3ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_football</i> — material icon named "sports football". + static const IconData sports_football = IconData(0xe5e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_football</i> — material icon named "sports football" (sharp). + static const IconData sports_football_sharp = IconData(0xecde, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_football</i> — material icon named "sports football" (round). + static const IconData sports_football_rounded = IconData(0xf01bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_football</i> — material icon named "sports football" (outlined). + static const IconData sports_football_outlined = IconData(0xf3cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_golf</i> — material icon named "sports golf". + static const IconData sports_golf = IconData(0xe5ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_golf</i> — material icon named "sports golf" (sharp). + static const IconData sports_golf_sharp = IconData(0xecdf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_golf</i> — material icon named "sports golf" (round). + static const IconData sports_golf_rounded = IconData(0xf01be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_golf</i> — material icon named "sports golf" (outlined). + static const IconData sports_golf_outlined = IconData(0xf3cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_gymnastics</i> — material icon named "sports gymnastics". + static const IconData sports_gymnastics = IconData(0xf06c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_gymnastics</i> — material icon named "sports gymnastics" (sharp). + static const IconData sports_gymnastics_sharp = IconData(0xf06b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_gymnastics</i> — material icon named "sports gymnastics" (round). + static const IconData sports_gymnastics_rounded = IconData(0xf06d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_gymnastics</i> — material icon named "sports gymnastics" (outlined). + static const IconData sports_gymnastics_outlined = IconData(0xf06a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_handball</i> — material icon named "sports handball". + static const IconData sports_handball = IconData(0xe5eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_handball</i> — material icon named "sports handball" (sharp). + static const IconData sports_handball_sharp = IconData(0xece0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_handball</i> — material icon named "sports handball" (round). + static const IconData sports_handball_rounded = IconData(0xf01bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_handball</i> — material icon named "sports handball" (outlined). + static const IconData sports_handball_outlined = IconData(0xf3cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_hockey</i> — material icon named "sports hockey". + static const IconData sports_hockey = IconData(0xe5ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_hockey</i> — material icon named "sports hockey" (sharp). + static const IconData sports_hockey_sharp = IconData(0xece1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_hockey</i> — material icon named "sports hockey" (round). + static const IconData sports_hockey_rounded = IconData(0xf01c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_hockey</i> — material icon named "sports hockey" (outlined). + static const IconData sports_hockey_outlined = IconData(0xf3ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_kabaddi</i> — material icon named "sports kabaddi". + static const IconData sports_kabaddi = IconData(0xe5ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_kabaddi</i> — material icon named "sports kabaddi" (sharp). + static const IconData sports_kabaddi_sharp = IconData(0xece2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_kabaddi</i> — material icon named "sports kabaddi" (round). + static const IconData sports_kabaddi_rounded = IconData(0xf01c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_kabaddi</i> — material icon named "sports kabaddi" (outlined). + static const IconData sports_kabaddi_outlined = IconData(0xf3cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_martial_arts</i> — material icon named "sports martial arts". + static const IconData sports_martial_arts = IconData(0xf056f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_martial_arts</i> — material icon named "sports martial arts" (sharp). + static const IconData sports_martial_arts_sharp = IconData(0xf0477, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_martial_arts</i> — material icon named "sports martial arts" (round). + static const IconData sports_martial_arts_rounded = IconData( + 0xf0384, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">sports_martial_arts</i> — material icon named "sports martial arts" (outlined). + static const IconData sports_martial_arts_outlined = IconData( + 0xf0665, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">sports_mma</i> — material icon named "sports mma". + static const IconData sports_mma = IconData(0xe5ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_mma</i> — material icon named "sports mma" (sharp). + static const IconData sports_mma_sharp = IconData(0xece3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_mma</i> — material icon named "sports mma" (round). + static const IconData sports_mma_rounded = IconData(0xf01c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_mma</i> — material icon named "sports mma" (outlined). + static const IconData sports_mma_outlined = IconData(0xf3d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_motorsports</i> — material icon named "sports motorsports". + static const IconData sports_motorsports = IconData(0xe5ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_motorsports</i> — material icon named "sports motorsports" (sharp). + static const IconData sports_motorsports_sharp = IconData(0xece4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_motorsports</i> — material icon named "sports motorsports" (round). + static const IconData sports_motorsports_rounded = IconData(0xf01c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_motorsports</i> — material icon named "sports motorsports" (outlined). + static const IconData sports_motorsports_outlined = IconData(0xf3d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_rugby</i> — material icon named "sports rugby". + static const IconData sports_rugby = IconData(0xe5f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_rugby</i> — material icon named "sports rugby" (sharp). + static const IconData sports_rugby_sharp = IconData(0xece5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_rugby</i> — material icon named "sports rugby" (round). + static const IconData sports_rugby_rounded = IconData(0xf01c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_rugby</i> — material icon named "sports rugby" (outlined). + static const IconData sports_rugby_outlined = IconData(0xf3d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_score</i> — material icon named "sports score". + static const IconData sports_score = IconData(0xe5f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_score</i> — material icon named "sports score" (sharp). + static const IconData sports_score_sharp = IconData(0xece6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_score</i> — material icon named "sports score" (round). + static const IconData sports_score_rounded = IconData(0xf01c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_score</i> — material icon named "sports score" (outlined). + static const IconData sports_score_outlined = IconData(0xf3d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_soccer</i> — material icon named "sports soccer". + static const IconData sports_soccer = IconData(0xe5f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_soccer</i> — material icon named "sports soccer" (sharp). + static const IconData sports_soccer_sharp = IconData(0xece8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_soccer</i> — material icon named "sports soccer" (round). + static const IconData sports_soccer_rounded = IconData(0xf01c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_soccer</i> — material icon named "sports soccer" (outlined). + static const IconData sports_soccer_outlined = IconData(0xf3d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_tennis</i> — material icon named "sports tennis". + static const IconData sports_tennis = IconData(0xe5f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_tennis</i> — material icon named "sports tennis" (sharp). + static const IconData sports_tennis_sharp = IconData(0xece9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_tennis</i> — material icon named "sports tennis" (round). + static const IconData sports_tennis_rounded = IconData(0xf01c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_tennis</i> — material icon named "sports tennis" (outlined). + static const IconData sports_tennis_outlined = IconData(0xf3d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sports_volleyball</i> — material icon named "sports volleyball". + static const IconData sports_volleyball = IconData(0xe5f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sports_volleyball</i> — material icon named "sports volleyball" (sharp). + static const IconData sports_volleyball_sharp = IconData(0xecea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sports_volleyball</i> — material icon named "sports volleyball" (round). + static const IconData sports_volleyball_rounded = IconData(0xf01c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sports_volleyball</i> — material icon named "sports volleyball" (outlined). + static const IconData sports_volleyball_outlined = IconData(0xf3d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">square</i> — material icon named "square". + static const IconData square = IconData(0xf0570, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">square</i> — material icon named "square" (sharp). + static const IconData square_sharp = IconData(0xf0478, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">square</i> — material icon named "square" (round). + static const IconData square_rounded = IconData(0xf0385, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">square</i> — material icon named "square" (outlined). + static const IconData square_outlined = IconData(0xf0666, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">square_foot</i> — material icon named "square foot". + static const IconData square_foot = IconData(0xe5f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">square_foot</i> — material icon named "square foot" (sharp). + static const IconData square_foot_sharp = IconData(0xeceb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">square_foot</i> — material icon named "square foot" (round). + static const IconData square_foot_rounded = IconData(0xf01ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">square_foot</i> — material icon named "square foot" (outlined). + static const IconData square_foot_outlined = IconData(0xf3d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">ssid_chart</i> — material icon named "ssid chart". + static const IconData ssid_chart = IconData(0xf0571, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">ssid_chart</i> — material icon named "ssid chart" (sharp). + static const IconData ssid_chart_sharp = IconData(0xf0479, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">ssid_chart</i> — material icon named "ssid chart" (round). + static const IconData ssid_chart_rounded = IconData(0xf0386, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">ssid_chart</i> — material icon named "ssid chart" (outlined). + static const IconData ssid_chart_outlined = IconData(0xf0667, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stacked_bar_chart</i> — material icon named "stacked bar chart". + static const IconData stacked_bar_chart = IconData(0xe5f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stacked_bar_chart</i> — material icon named "stacked bar chart" (sharp). + static const IconData stacked_bar_chart_sharp = IconData(0xecec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stacked_bar_chart</i> — material icon named "stacked bar chart" (round). + static const IconData stacked_bar_chart_rounded = IconData(0xf01cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stacked_bar_chart</i> — material icon named "stacked bar chart" (outlined). + static const IconData stacked_bar_chart_outlined = IconData(0xf3d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stacked_line_chart</i> — material icon named "stacked line chart". + static const IconData stacked_line_chart = IconData(0xe5f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stacked_line_chart</i> — material icon named "stacked line chart" (sharp). + static const IconData stacked_line_chart_sharp = IconData(0xeced, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stacked_line_chart</i> — material icon named "stacked line chart" (round). + static const IconData stacked_line_chart_rounded = IconData(0xf01cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stacked_line_chart</i> — material icon named "stacked line chart" (outlined). + static const IconData stacked_line_chart_outlined = IconData(0xf3da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stadium</i> — material icon named "stadium". + static const IconData stadium = IconData(0xf0572, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stadium</i> — material icon named "stadium" (sharp). + static const IconData stadium_sharp = IconData(0xf047a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stadium</i> — material icon named "stadium" (round). + static const IconData stadium_rounded = IconData(0xf0387, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stadium</i> — material icon named "stadium" (outlined). + static const IconData stadium_outlined = IconData(0xf0668, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stairs</i> — material icon named "stairs". + static const IconData stairs = IconData(0xe5f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stairs</i> — material icon named "stairs" (sharp). + static const IconData stairs_sharp = IconData(0xecee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stairs</i> — material icon named "stairs" (round). + static const IconData stairs_rounded = IconData(0xf01cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stairs</i> — material icon named "stairs" (outlined). + static const IconData stairs_outlined = IconData(0xf3db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">star</i> — material icon named "star". + static const IconData star = IconData(0xe5f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">star</i> — material icon named "star" (sharp). + static const IconData star_sharp = IconData(0xecf5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">star</i> — material icon named "star" (round). + static const IconData star_rounded = IconData(0xf01d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">star</i> — material icon named "star" (outlined). + static const IconData star_outlined = IconData(0xf3e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">star_border</i> — material icon named "star border". + static const IconData star_border = IconData(0xe5fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">star_border</i> — material icon named "star border" (sharp). + static const IconData star_border_sharp = IconData(0xecf0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">star_border</i> — material icon named "star border" (round). + static const IconData star_border_rounded = IconData(0xf01cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">star_border</i> — material icon named "star border" (outlined). + static const IconData star_border_outlined = IconData(0xf3dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">star_border_purple500</i> — material icon named "star border purple500". + static const IconData star_border_purple500 = IconData(0xe5fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">star_border_purple500</i> — material icon named "star border purple500" (sharp). + static const IconData star_border_purple500_sharp = IconData(0xecef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">star_border_purple500</i> — material icon named "star border purple500" (round). + static const IconData star_border_purple500_rounded = IconData( + 0xf01ce, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">star_border_purple500</i> — material icon named "star border purple500" (outlined). + static const IconData star_border_purple500_outlined = IconData( + 0xf3dd, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">star_half</i> — material icon named "star half". + static const IconData star_half = IconData( + 0xe5fc, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">star_half</i> — material icon named "star half" (sharp). + static const IconData star_half_sharp = IconData( + 0xecf1, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">star_half</i> — material icon named "star half" (round). + static const IconData star_half_rounded = IconData( + 0xf01d0, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">star_half</i> — material icon named "star half" (outlined). + static const IconData star_half_outlined = IconData( + 0xf3de, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">star_outline</i> — material icon named "star outline". + static const IconData star_outline = IconData(0xe5fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">star_outline</i> — material icon named "star outline" (sharp). + static const IconData star_outline_sharp = IconData(0xecf2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">star_outline</i> — material icon named "star outline" (round). + static const IconData star_outline_rounded = IconData(0xf01d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">star_outline</i> — material icon named "star outline" (outlined). + static const IconData star_outline_outlined = IconData(0xf3df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">star_purple500</i> — material icon named "star purple500". + static const IconData star_purple500 = IconData(0xe5fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">star_purple500</i> — material icon named "star purple500" (sharp). + static const IconData star_purple500_sharp = IconData(0xecf3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">star_purple500</i> — material icon named "star purple500" (round). + static const IconData star_purple500_rounded = IconData(0xf01d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">star_purple500</i> — material icon named "star purple500" (outlined). + static const IconData star_purple500_outlined = IconData(0xf3e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">star_rate</i> — material icon named "star rate". + static const IconData star_rate = IconData(0xe5ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">star_rate</i> — material icon named "star rate" (sharp). + static const IconData star_rate_sharp = IconData(0xecf4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">star_rate</i> — material icon named "star rate" (round). + static const IconData star_rate_rounded = IconData(0xf01d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">star_rate</i> — material icon named "star rate" (outlined). + static const IconData star_rate_outlined = IconData(0xf3e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stars</i> — material icon named "stars". + static const IconData stars = IconData(0xe600, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stars</i> — material icon named "stars" (sharp). + static const IconData stars_sharp = IconData(0xecf6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stars</i> — material icon named "stars" (round). + static const IconData stars_rounded = IconData(0xf01d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stars</i> — material icon named "stars" (outlined). + static const IconData stars_outlined = IconData(0xf3e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">start</i> — material icon named "start". + static const IconData start = IconData(0xf0573, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">start</i> — material icon named "start" (sharp). + static const IconData start_sharp = IconData(0xf047b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">start</i> — material icon named "start" (round). + static const IconData start_rounded = IconData(0xf0388, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">start</i> — material icon named "start" (outlined). + static const IconData start_outlined = IconData(0xf0669, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stay_current_landscape</i> — material icon named "stay current landscape". + static const IconData stay_current_landscape = IconData(0xe601, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stay_current_landscape</i> — material icon named "stay current landscape" (sharp). + static const IconData stay_current_landscape_sharp = IconData( + 0xecf7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">stay_current_landscape</i> — material icon named "stay current landscape" (round). + static const IconData stay_current_landscape_rounded = IconData( + 0xf01d6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">stay_current_landscape</i> — material icon named "stay current landscape" (outlined). + static const IconData stay_current_landscape_outlined = IconData( + 0xf3e4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">stay_current_portrait</i> — material icon named "stay current portrait". + static const IconData stay_current_portrait = IconData(0xe602, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stay_current_portrait</i> — material icon named "stay current portrait" (sharp). + static const IconData stay_current_portrait_sharp = IconData(0xecf8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stay_current_portrait</i> — material icon named "stay current portrait" (round). + static const IconData stay_current_portrait_rounded = IconData( + 0xf01d7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">stay_current_portrait</i> — material icon named "stay current portrait" (outlined). + static const IconData stay_current_portrait_outlined = IconData( + 0xf3e5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">stay_primary_landscape</i> — material icon named "stay primary landscape". + static const IconData stay_primary_landscape = IconData(0xe603, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stay_primary_landscape</i> — material icon named "stay primary landscape" (sharp). + static const IconData stay_primary_landscape_sharp = IconData( + 0xecf9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">stay_primary_landscape</i> — material icon named "stay primary landscape" (round). + static const IconData stay_primary_landscape_rounded = IconData( + 0xf01d8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">stay_primary_landscape</i> — material icon named "stay primary landscape" (outlined). + static const IconData stay_primary_landscape_outlined = IconData( + 0xf3e6, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">stay_primary_portrait</i> — material icon named "stay primary portrait". + static const IconData stay_primary_portrait = IconData(0xe604, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stay_primary_portrait</i> — material icon named "stay primary portrait" (sharp). + static const IconData stay_primary_portrait_sharp = IconData(0xecfa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stay_primary_portrait</i> — material icon named "stay primary portrait" (round). + static const IconData stay_primary_portrait_rounded = IconData( + 0xf01d9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">stay_primary_portrait</i> — material icon named "stay primary portrait" (outlined). + static const IconData stay_primary_portrait_outlined = IconData( + 0xf3e7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">sticky_note_2</i> — material icon named "sticky note 2". + static const IconData sticky_note_2 = IconData(0xe605, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sticky_note_2</i> — material icon named "sticky note 2" (sharp). + static const IconData sticky_note_2_sharp = IconData(0xecfb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sticky_note_2</i> — material icon named "sticky note 2" (round). + static const IconData sticky_note_2_rounded = IconData(0xf01da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sticky_note_2</i> — material icon named "sticky note 2" (outlined). + static const IconData sticky_note_2_outlined = IconData(0xf3e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stop</i> — material icon named "stop". + static const IconData stop = IconData(0xe606, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stop</i> — material icon named "stop" (sharp). + static const IconData stop_sharp = IconData(0xecfe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stop</i> — material icon named "stop" (round). + static const IconData stop_rounded = IconData(0xf01dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stop</i> — material icon named "stop" (outlined). + static const IconData stop_outlined = IconData(0xf3ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stop_circle</i> — material icon named "stop circle". + static const IconData stop_circle = IconData(0xe607, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stop_circle</i> — material icon named "stop circle" (sharp). + static const IconData stop_circle_sharp = IconData(0xecfc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stop_circle</i> — material icon named "stop circle" (round). + static const IconData stop_circle_rounded = IconData(0xf01db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stop_circle</i> — material icon named "stop circle" (outlined). + static const IconData stop_circle_outlined = IconData(0xf3e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stop_screen_share</i> — material icon named "stop screen share". + static const IconData stop_screen_share = IconData(0xe608, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stop_screen_share</i> — material icon named "stop screen share" (sharp). + static const IconData stop_screen_share_sharp = IconData(0xecfd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stop_screen_share</i> — material icon named "stop screen share" (round). + static const IconData stop_screen_share_rounded = IconData(0xf01dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stop_screen_share</i> — material icon named "stop screen share" (outlined). + static const IconData stop_screen_share_outlined = IconData(0xf3eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">storage</i> — material icon named "storage". + static const IconData storage = IconData(0xe609, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">storage</i> — material icon named "storage" (sharp). + static const IconData storage_sharp = IconData(0xecff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">storage</i> — material icon named "storage" (round). + static const IconData storage_rounded = IconData(0xf01de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">storage</i> — material icon named "storage" (outlined). + static const IconData storage_outlined = IconData(0xf3ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">store</i> — material icon named "store". + static const IconData store = IconData(0xe60a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">store</i> — material icon named "store" (sharp). + static const IconData store_sharp = IconData(0xed01, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">store</i> — material icon named "store" (round). + static const IconData store_rounded = IconData(0xf01e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">store</i> — material icon named "store" (outlined). + static const IconData store_outlined = IconData(0xf3ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">store_mall_directory</i> — material icon named "store mall directory". + static const IconData store_mall_directory = IconData(0xe60b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">store_mall_directory</i> — material icon named "store mall directory" (sharp). + static const IconData store_mall_directory_sharp = IconData(0xed00, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">store_mall_directory</i> — material icon named "store mall directory" (round). + static const IconData store_mall_directory_rounded = IconData( + 0xf01df, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">store_mall_directory</i> — material icon named "store mall directory" (outlined). + static const IconData store_mall_directory_outlined = IconData( + 0xf3ed, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">storefront</i> — material icon named "storefront". + static const IconData storefront = IconData(0xe60c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">storefront</i> — material icon named "storefront" (sharp). + static const IconData storefront_sharp = IconData(0xed02, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">storefront</i> — material icon named "storefront" (round). + static const IconData storefront_rounded = IconData(0xf01e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">storefront</i> — material icon named "storefront" (outlined). + static const IconData storefront_outlined = IconData(0xf3ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">storm</i> — material icon named "storm". + static const IconData storm = IconData(0xe60d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">storm</i> — material icon named "storm" (sharp). + static const IconData storm_sharp = IconData(0xed03, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">storm</i> — material icon named "storm" (round). + static const IconData storm_rounded = IconData(0xf01e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">storm</i> — material icon named "storm" (outlined). + static const IconData storm_outlined = IconData(0xf3f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">straight</i> — material icon named "straight". + static const IconData straight = IconData(0xf0574, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">straight</i> — material icon named "straight" (sharp). + static const IconData straight_sharp = IconData(0xf047c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">straight</i> — material icon named "straight" (round). + static const IconData straight_rounded = IconData(0xf0389, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">straight</i> — material icon named "straight" (outlined). + static const IconData straight_outlined = IconData(0xf066a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">straighten</i> — material icon named "straighten". + static const IconData straighten = IconData(0xe60e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">straighten</i> — material icon named "straighten" (sharp). + static const IconData straighten_sharp = IconData(0xed04, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">straighten</i> — material icon named "straighten" (round). + static const IconData straighten_rounded = IconData(0xf01e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">straighten</i> — material icon named "straighten" (outlined). + static const IconData straighten_outlined = IconData(0xf3f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stream</i> — material icon named "stream". + static const IconData stream = IconData(0xe60f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stream</i> — material icon named "stream" (sharp). + static const IconData stream_sharp = IconData(0xed05, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stream</i> — material icon named "stream" (round). + static const IconData stream_rounded = IconData(0xf01e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stream</i> — material icon named "stream" (outlined). + static const IconData stream_outlined = IconData(0xf3f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">streetview</i> — material icon named "streetview". + static const IconData streetview = IconData(0xe610, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">streetview</i> — material icon named "streetview" (sharp). + static const IconData streetview_sharp = IconData(0xed06, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">streetview</i> — material icon named "streetview" (round). + static const IconData streetview_rounded = IconData(0xf01e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">streetview</i> — material icon named "streetview" (outlined). + static const IconData streetview_outlined = IconData(0xf3f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">strikethrough_s</i> — material icon named "strikethrough s". + static const IconData strikethrough_s = IconData(0xe611, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">strikethrough_s</i> — material icon named "strikethrough s" (sharp). + static const IconData strikethrough_s_sharp = IconData(0xed07, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">strikethrough_s</i> — material icon named "strikethrough s" (round). + static const IconData strikethrough_s_rounded = IconData(0xf01e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">strikethrough_s</i> — material icon named "strikethrough s" (outlined). + static const IconData strikethrough_s_outlined = IconData(0xf3f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">stroller</i> — material icon named "stroller". + static const IconData stroller = IconData(0xe612, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">stroller</i> — material icon named "stroller" (sharp). + static const IconData stroller_sharp = IconData(0xed08, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">stroller</i> — material icon named "stroller" (round). + static const IconData stroller_rounded = IconData(0xf01e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">stroller</i> — material icon named "stroller" (outlined). + static const IconData stroller_outlined = IconData(0xf3f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">style</i> — material icon named "style". + static const IconData style = IconData(0xe613, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">style</i> — material icon named "style" (sharp). + static const IconData style_sharp = IconData(0xed09, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">style</i> — material icon named "style" (round). + static const IconData style_rounded = IconData(0xf01e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">style</i> — material icon named "style" (outlined). + static const IconData style_outlined = IconData(0xf3f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">subdirectory_arrow_left</i> — material icon named "subdirectory arrow left". + static const IconData subdirectory_arrow_left = IconData(0xe614, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subdirectory_arrow_left</i> — material icon named "subdirectory arrow left" (sharp). + static const IconData subdirectory_arrow_left_sharp = IconData( + 0xed0a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">subdirectory_arrow_left</i> — material icon named "subdirectory arrow left" (round). + static const IconData subdirectory_arrow_left_rounded = IconData( + 0xf01e9, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">subdirectory_arrow_left</i> — material icon named "subdirectory arrow left" (outlined). + static const IconData subdirectory_arrow_left_outlined = IconData( + 0xf3f7, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">subdirectory_arrow_right</i> — material icon named "subdirectory arrow right". + static const IconData subdirectory_arrow_right = IconData(0xe615, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subdirectory_arrow_right</i> — material icon named "subdirectory arrow right" (sharp). + static const IconData subdirectory_arrow_right_sharp = IconData( + 0xed0b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">subdirectory_arrow_right</i> — material icon named "subdirectory arrow right" (round). + static const IconData subdirectory_arrow_right_rounded = IconData( + 0xf01ea, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">subdirectory_arrow_right</i> — material icon named "subdirectory arrow right" (outlined). + static const IconData subdirectory_arrow_right_outlined = IconData( + 0xf3f8, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">subject</i> — material icon named "subject". + static const IconData subject = IconData( + 0xe616, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">subject</i> — material icon named "subject" (sharp). + static const IconData subject_sharp = IconData( + 0xed0c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">subject</i> — material icon named "subject" (round). + static const IconData subject_rounded = IconData( + 0xf01eb, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">subject</i> — material icon named "subject" (outlined). + static const IconData subject_outlined = IconData( + 0xf3f9, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">subscript</i> — material icon named "subscript". + static const IconData subscript = IconData(0xe617, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subscript</i> — material icon named "subscript" (sharp). + static const IconData subscript_sharp = IconData(0xed0d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">subscript</i> — material icon named "subscript" (round). + static const IconData subscript_rounded = IconData(0xf01ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">subscript</i> — material icon named "subscript" (outlined). + static const IconData subscript_outlined = IconData(0xf3fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">subscriptions</i> — material icon named "subscriptions". + static const IconData subscriptions = IconData(0xe618, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subscriptions</i> — material icon named "subscriptions" (sharp). + static const IconData subscriptions_sharp = IconData(0xed0e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">subscriptions</i> — material icon named "subscriptions" (round). + static const IconData subscriptions_rounded = IconData(0xf01ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">subscriptions</i> — material icon named "subscriptions" (outlined). + static const IconData subscriptions_outlined = IconData(0xf3fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">subtitles</i> — material icon named "subtitles". + static const IconData subtitles = IconData(0xe619, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subtitles</i> — material icon named "subtitles" (sharp). + static const IconData subtitles_sharp = IconData(0xed10, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">subtitles</i> — material icon named "subtitles" (round). + static const IconData subtitles_rounded = IconData(0xf01ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">subtitles</i> — material icon named "subtitles" (outlined). + static const IconData subtitles_outlined = IconData(0xf3fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">subtitles_off</i> — material icon named "subtitles off". + static const IconData subtitles_off = IconData(0xe61a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subtitles_off</i> — material icon named "subtitles off" (sharp). + static const IconData subtitles_off_sharp = IconData(0xed0f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">subtitles_off</i> — material icon named "subtitles off" (round). + static const IconData subtitles_off_rounded = IconData(0xf01ee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">subtitles_off</i> — material icon named "subtitles off" (outlined). + static const IconData subtitles_off_outlined = IconData(0xf3fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">subway</i> — material icon named "subway". + static const IconData subway = IconData(0xe61b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">subway</i> — material icon named "subway" (sharp). + static const IconData subway_sharp = IconData(0xed11, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">subway</i> — material icon named "subway" (round). + static const IconData subway_rounded = IconData(0xf01f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">subway</i> — material icon named "subway" (outlined). + static const IconData subway_outlined = IconData(0xf3fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">summarize</i> — material icon named "summarize". + static const IconData summarize = IconData(0xe61c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">summarize</i> — material icon named "summarize" (sharp). + static const IconData summarize_sharp = IconData(0xed12, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">summarize</i> — material icon named "summarize" (round). + static const IconData summarize_rounded = IconData(0xf01f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">summarize</i> — material icon named "summarize" (outlined). + static const IconData summarize_outlined = IconData(0xf3ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sunny</i> — material icon named "sunny". + static const IconData sunny = IconData(0xf0575, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sunny_snowing</i> — material icon named "sunny snowing". + static const IconData sunny_snowing = IconData(0xf0576, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">superscript</i> — material icon named "superscript". + static const IconData superscript = IconData(0xe61d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">superscript</i> — material icon named "superscript" (sharp). + static const IconData superscript_sharp = IconData(0xed13, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">superscript</i> — material icon named "superscript" (round). + static const IconData superscript_rounded = IconData(0xf01f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">superscript</i> — material icon named "superscript" (outlined). + static const IconData superscript_outlined = IconData(0xf400, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">supervised_user_circle</i> — material icon named "supervised user circle". + static const IconData supervised_user_circle = IconData(0xe61e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">supervised_user_circle</i> — material icon named "supervised user circle" (sharp). + static const IconData supervised_user_circle_sharp = IconData( + 0xed14, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">supervised_user_circle</i> — material icon named "supervised user circle" (round). + static const IconData supervised_user_circle_rounded = IconData( + 0xf01f3, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">supervised_user_circle</i> — material icon named "supervised user circle" (outlined). + static const IconData supervised_user_circle_outlined = IconData( + 0xf401, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">supervisor_account</i> — material icon named "supervisor account". + static const IconData supervisor_account = IconData(0xe61f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">supervisor_account</i> — material icon named "supervisor account" (sharp). + static const IconData supervisor_account_sharp = IconData(0xed15, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">supervisor_account</i> — material icon named "supervisor account" (round). + static const IconData supervisor_account_rounded = IconData(0xf01f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">supervisor_account</i> — material icon named "supervisor account" (outlined). + static const IconData supervisor_account_outlined = IconData(0xf402, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">support</i> — material icon named "support". + static const IconData support = IconData(0xe620, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">support</i> — material icon named "support" (sharp). + static const IconData support_sharp = IconData(0xed17, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">support</i> — material icon named "support" (round). + static const IconData support_rounded = IconData(0xf01f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">support</i> — material icon named "support" (outlined). + static const IconData support_outlined = IconData(0xf404, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">support_agent</i> — material icon named "support agent". + static const IconData support_agent = IconData(0xe621, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">support_agent</i> — material icon named "support agent" (sharp). + static const IconData support_agent_sharp = IconData(0xed16, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">support_agent</i> — material icon named "support agent" (round). + static const IconData support_agent_rounded = IconData(0xf01f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">support_agent</i> — material icon named "support agent" (outlined). + static const IconData support_agent_outlined = IconData(0xf403, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">surfing</i> — material icon named "surfing". + static const IconData surfing = IconData(0xe622, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">surfing</i> — material icon named "surfing" (sharp). + static const IconData surfing_sharp = IconData(0xed18, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">surfing</i> — material icon named "surfing" (round). + static const IconData surfing_rounded = IconData(0xf01f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">surfing</i> — material icon named "surfing" (outlined). + static const IconData surfing_outlined = IconData(0xf405, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">surround_sound</i> — material icon named "surround sound". + static const IconData surround_sound = IconData(0xe623, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">surround_sound</i> — material icon named "surround sound" (sharp). + static const IconData surround_sound_sharp = IconData(0xed19, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">surround_sound</i> — material icon named "surround sound" (round). + static const IconData surround_sound_rounded = IconData(0xf01f8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">surround_sound</i> — material icon named "surround sound" (outlined). + static const IconData surround_sound_outlined = IconData(0xf406, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swap_calls</i> — material icon named "swap calls". + static const IconData swap_calls = IconData(0xe624, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swap_calls</i> — material icon named "swap calls" (sharp). + static const IconData swap_calls_sharp = IconData(0xed1a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swap_calls</i> — material icon named "swap calls" (round). + static const IconData swap_calls_rounded = IconData(0xf01f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swap_calls</i> — material icon named "swap calls" (outlined). + static const IconData swap_calls_outlined = IconData(0xf407, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swap_horiz</i> — material icon named "swap horiz". + static const IconData swap_horiz = IconData(0xe625, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swap_horiz</i> — material icon named "swap horiz" (sharp). + static const IconData swap_horiz_sharp = IconData(0xed1b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swap_horiz</i> — material icon named "swap horiz" (round). + static const IconData swap_horiz_rounded = IconData(0xf01fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swap_horiz</i> — material icon named "swap horiz" (outlined). + static const IconData swap_horiz_outlined = IconData(0xf408, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swap_horizontal_circle</i> — material icon named "swap horizontal circle". + static const IconData swap_horizontal_circle = IconData(0xe626, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swap_horizontal_circle</i> — material icon named "swap horizontal circle" (sharp). + static const IconData swap_horizontal_circle_sharp = IconData( + 0xed1c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">swap_horizontal_circle</i> — material icon named "swap horizontal circle" (round). + static const IconData swap_horizontal_circle_rounded = IconData( + 0xf01fb, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">swap_horizontal_circle</i> — material icon named "swap horizontal circle" (outlined). + static const IconData swap_horizontal_circle_outlined = IconData( + 0xf409, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">swap_vert</i> — material icon named "swap vert". + static const IconData swap_vert = IconData(0xe627, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swap_vert</i> — material icon named "swap vert" (sharp). + static const IconData swap_vert_sharp = IconData(0xed1d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swap_vert</i> — material icon named "swap vert" (round). + static const IconData swap_vert_rounded = IconData(0xf01fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swap_vert</i> — material icon named "swap vert" (outlined). + static const IconData swap_vert_outlined = IconData(0xf40a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swap_vert_circle</i> — material icon named "swap vert circle". + static const IconData swap_vert_circle = IconData(0xe628, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swap_vert_circle</i> — material icon named "swap vert circle" (sharp). + static const IconData swap_vert_circle_sharp = IconData(0xed1e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swap_vert_circle</i> — material icon named "swap vert circle" (round). + static const IconData swap_vert_circle_rounded = IconData(0xf01fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swap_vert_circle</i> — material icon named "swap vert circle" (outlined). + static const IconData swap_vert_circle_outlined = IconData(0xf40b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swap_vertical_circle</i> — material icon named "swap vertical circle". + static const IconData swap_vertical_circle = IconData(0xe628, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swap_vertical_circle</i> — material icon named "swap vertical circle" (sharp). + static const IconData swap_vertical_circle_sharp = IconData(0xed1e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swap_vertical_circle</i> — material icon named "swap vertical circle" (round). + static const IconData swap_vertical_circle_rounded = IconData( + 0xf01fd, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">swap_vertical_circle</i> — material icon named "swap vertical circle" (outlined). + static const IconData swap_vertical_circle_outlined = IconData( + 0xf40b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">swipe</i> — material icon named "swipe". + static const IconData swipe = IconData(0xe629, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe</i> — material icon named "swipe" (sharp). + static const IconData swipe_sharp = IconData(0xed1f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe</i> — material icon named "swipe" (round). + static const IconData swipe_rounded = IconData(0xf01fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe</i> — material icon named "swipe" (outlined). + static const IconData swipe_outlined = IconData(0xf40c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_down</i> — material icon named "swipe down". + static const IconData swipe_down = IconData(0xf0578, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_down</i> — material icon named "swipe down" (sharp). + static const IconData swipe_down_sharp = IconData(0xf047e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_down</i> — material icon named "swipe down" (round). + static const IconData swipe_down_rounded = IconData(0xf038b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_down</i> — material icon named "swipe down" (outlined). + static const IconData swipe_down_outlined = IconData(0xf066c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_down_alt</i> — material icon named "swipe down alt". + static const IconData swipe_down_alt = IconData(0xf0577, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_down_alt</i> — material icon named "swipe down alt" (sharp). + static const IconData swipe_down_alt_sharp = IconData(0xf047d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_down_alt</i> — material icon named "swipe down alt" (round). + static const IconData swipe_down_alt_rounded = IconData(0xf038a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_down_alt</i> — material icon named "swipe down alt" (outlined). + static const IconData swipe_down_alt_outlined = IconData(0xf066b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_left</i> — material icon named "swipe left". + static const IconData swipe_left = IconData(0xf057a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_left</i> — material icon named "swipe left" (sharp). + static const IconData swipe_left_sharp = IconData(0xf0480, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_left</i> — material icon named "swipe left" (round). + static const IconData swipe_left_rounded = IconData(0xf038d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_left</i> — material icon named "swipe left" (outlined). + static const IconData swipe_left_outlined = IconData(0xf066e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_left_alt</i> — material icon named "swipe left alt". + static const IconData swipe_left_alt = IconData(0xf0579, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_left_alt</i> — material icon named "swipe left alt" (sharp). + static const IconData swipe_left_alt_sharp = IconData(0xf047f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_left_alt</i> — material icon named "swipe left alt" (round). + static const IconData swipe_left_alt_rounded = IconData(0xf038c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_left_alt</i> — material icon named "swipe left alt" (outlined). + static const IconData swipe_left_alt_outlined = IconData(0xf066d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_right</i> — material icon named "swipe right". + static const IconData swipe_right = IconData(0xf057c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_right</i> — material icon named "swipe right" (sharp). + static const IconData swipe_right_sharp = IconData(0xf0482, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_right</i> — material icon named "swipe right" (round). + static const IconData swipe_right_rounded = IconData(0xf038f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_right</i> — material icon named "swipe right" (outlined). + static const IconData swipe_right_outlined = IconData(0xf0670, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_right_alt</i> — material icon named "swipe right alt". + static const IconData swipe_right_alt = IconData(0xf057b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_right_alt</i> — material icon named "swipe right alt" (sharp). + static const IconData swipe_right_alt_sharp = IconData(0xf0481, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_right_alt</i> — material icon named "swipe right alt" (round). + static const IconData swipe_right_alt_rounded = IconData(0xf038e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_right_alt</i> — material icon named "swipe right alt" (outlined). + static const IconData swipe_right_alt_outlined = IconData(0xf066f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_up</i> — material icon named "swipe up". + static const IconData swipe_up = IconData(0xf057e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_up</i> — material icon named "swipe up" (sharp). + static const IconData swipe_up_sharp = IconData(0xf0484, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_up</i> — material icon named "swipe up" (round). + static const IconData swipe_up_rounded = IconData(0xf0391, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_up</i> — material icon named "swipe up" (outlined). + static const IconData swipe_up_outlined = IconData(0xf0672, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_up_alt</i> — material icon named "swipe up alt". + static const IconData swipe_up_alt = IconData(0xf057d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_up_alt</i> — material icon named "swipe up alt" (sharp). + static const IconData swipe_up_alt_sharp = IconData(0xf0483, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_up_alt</i> — material icon named "swipe up alt" (round). + static const IconData swipe_up_alt_rounded = IconData(0xf0390, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_up_alt</i> — material icon named "swipe up alt" (outlined). + static const IconData swipe_up_alt_outlined = IconData(0xf0671, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">swipe_vertical</i> — material icon named "swipe vertical". + static const IconData swipe_vertical = IconData(0xf057f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">swipe_vertical</i> — material icon named "swipe vertical" (sharp). + static const IconData swipe_vertical_sharp = IconData(0xf0485, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">swipe_vertical</i> — material icon named "swipe vertical" (round). + static const IconData swipe_vertical_rounded = IconData(0xf0392, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">swipe_vertical</i> — material icon named "swipe vertical" (outlined). + static const IconData swipe_vertical_outlined = IconData(0xf0673, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">switch_access_shortcut</i> — material icon named "switch access shortcut". + static const IconData switch_access_shortcut = IconData(0xf0581, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_access_shortcut</i> — material icon named "switch access shortcut" (sharp). + static const IconData switch_access_shortcut_sharp = IconData( + 0xf0487, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">switch_access_shortcut</i> — material icon named "switch access shortcut" (round). + static const IconData switch_access_shortcut_rounded = IconData( + 0xf0394, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">switch_access_shortcut</i> — material icon named "switch access shortcut" (outlined). + static const IconData switch_access_shortcut_outlined = IconData( + 0xf0675, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">switch_access_shortcut_add</i> — material icon named "switch access shortcut add". + static const IconData switch_access_shortcut_add = IconData(0xf0580, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_access_shortcut_add</i> — material icon named "switch access shortcut add" (sharp). + static const IconData switch_access_shortcut_add_sharp = IconData( + 0xf0486, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">switch_access_shortcut_add</i> — material icon named "switch access shortcut add" (round). + static const IconData switch_access_shortcut_add_rounded = IconData( + 0xf0393, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">switch_access_shortcut_add</i> — material icon named "switch access shortcut add" (outlined). + static const IconData switch_access_shortcut_add_outlined = IconData( + 0xf0674, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">switch_account</i> — material icon named "switch account". + static const IconData switch_account = IconData(0xe62a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_account</i> — material icon named "switch account" (sharp). + static const IconData switch_account_sharp = IconData(0xed20, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">switch_account</i> — material icon named "switch account" (round). + static const IconData switch_account_rounded = IconData(0xf01ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">switch_account</i> — material icon named "switch account" (outlined). + static const IconData switch_account_outlined = IconData(0xf40d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">switch_camera</i> — material icon named "switch camera". + static const IconData switch_camera = IconData(0xe62b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_camera</i> — material icon named "switch camera" (sharp). + static const IconData switch_camera_sharp = IconData(0xed21, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">switch_camera</i> — material icon named "switch camera" (round). + static const IconData switch_camera_rounded = IconData(0xf0200, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">switch_camera</i> — material icon named "switch camera" (outlined). + static const IconData switch_camera_outlined = IconData(0xf40e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">switch_left</i> — material icon named "switch left". + static const IconData switch_left = IconData(0xe62c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_left</i> — material icon named "switch left" (sharp). + static const IconData switch_left_sharp = IconData(0xed22, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">switch_left</i> — material icon named "switch left" (round). + static const IconData switch_left_rounded = IconData(0xf0201, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">switch_left</i> — material icon named "switch left" (outlined). + static const IconData switch_left_outlined = IconData(0xf40f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">switch_right</i> — material icon named "switch right". + static const IconData switch_right = IconData(0xe62d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_right</i> — material icon named "switch right" (sharp). + static const IconData switch_right_sharp = IconData(0xed23, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">switch_right</i> — material icon named "switch right" (round). + static const IconData switch_right_rounded = IconData(0xf0202, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">switch_right</i> — material icon named "switch right" (outlined). + static const IconData switch_right_outlined = IconData(0xf410, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">switch_video</i> — material icon named "switch video". + static const IconData switch_video = IconData(0xe62e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">switch_video</i> — material icon named "switch video" (sharp). + static const IconData switch_video_sharp = IconData(0xed24, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">switch_video</i> — material icon named "switch video" (round). + static const IconData switch_video_rounded = IconData(0xf0203, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">switch_video</i> — material icon named "switch video" (outlined). + static const IconData switch_video_outlined = IconData(0xf411, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">synagogue</i> — material icon named "synagogue". + static const IconData synagogue = IconData(0xf0582, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">synagogue</i> — material icon named "synagogue" (sharp). + static const IconData synagogue_sharp = IconData(0xf0488, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">synagogue</i> — material icon named "synagogue" (round). + static const IconData synagogue_rounded = IconData(0xf0395, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">synagogue</i> — material icon named "synagogue" (outlined). + static const IconData synagogue_outlined = IconData(0xf0676, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sync</i> — material icon named "sync". + static const IconData sync = IconData(0xe62f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sync</i> — material icon named "sync" (sharp). + static const IconData sync_sharp = IconData(0xed28, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sync</i> — material icon named "sync" (round). + static const IconData sync_rounded = IconData(0xf0207, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sync</i> — material icon named "sync" (outlined). + static const IconData sync_outlined = IconData(0xf414, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sync_alt</i> — material icon named "sync alt". + static const IconData sync_alt = IconData(0xe630, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sync_alt</i> — material icon named "sync alt" (sharp). + static const IconData sync_alt_sharp = IconData(0xed25, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sync_alt</i> — material icon named "sync alt" (round). + static const IconData sync_alt_rounded = IconData(0xf0204, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sync_alt</i> — material icon named "sync alt" (outlined). + static const IconData sync_alt_outlined = IconData(0xf412, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sync_disabled</i> — material icon named "sync disabled". + static const IconData sync_disabled = IconData(0xe631, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sync_disabled</i> — material icon named "sync disabled" (sharp). + static const IconData sync_disabled_sharp = IconData(0xed26, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sync_disabled</i> — material icon named "sync disabled" (round). + static const IconData sync_disabled_rounded = IconData(0xf0205, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sync_disabled</i> — material icon named "sync disabled" (outlined). + static const IconData sync_disabled_outlined = IconData(0xf413, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sync_lock</i> — material icon named "sync lock". + static const IconData sync_lock = IconData(0xf0583, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sync_lock</i> — material icon named "sync lock" (sharp). + static const IconData sync_lock_sharp = IconData(0xf0489, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sync_lock</i> — material icon named "sync lock" (round). + static const IconData sync_lock_rounded = IconData(0xf0396, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sync_lock</i> — material icon named "sync lock" (outlined). + static const IconData sync_lock_outlined = IconData(0xf0677, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">sync_problem</i> — material icon named "sync problem". + static const IconData sync_problem = IconData(0xe632, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">sync_problem</i> — material icon named "sync problem" (sharp). + static const IconData sync_problem_sharp = IconData(0xed27, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">sync_problem</i> — material icon named "sync problem" (round). + static const IconData sync_problem_rounded = IconData(0xf0206, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">sync_problem</i> — material icon named "sync problem" (outlined). + static const IconData sync_problem_outlined = IconData(0xf415, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">system_security_update</i> — material icon named "system security update". + static const IconData system_security_update = IconData(0xe633, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">system_security_update</i> — material icon named "system security update" (sharp). + static const IconData system_security_update_sharp = IconData( + 0xed2a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">system_security_update</i> — material icon named "system security update" (round). + static const IconData system_security_update_rounded = IconData( + 0xf0209, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">system_security_update</i> — material icon named "system security update" (outlined). + static const IconData system_security_update_outlined = IconData( + 0xf417, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">system_security_update_good</i> — material icon named "system security update good". + static const IconData system_security_update_good = IconData(0xe634, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">system_security_update_good</i> — material icon named "system security update good" (sharp). + static const IconData system_security_update_good_sharp = IconData( + 0xed29, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">system_security_update_good</i> — material icon named "system security update good" (round). + static const IconData system_security_update_good_rounded = IconData( + 0xf0208, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">system_security_update_good</i> — material icon named "system security update good" (outlined). + static const IconData system_security_update_good_outlined = IconData( + 0xf416, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">system_security_update_warning</i> — material icon named "system security update warning". + static const IconData system_security_update_warning = IconData( + 0xe635, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">system_security_update_warning</i> — material icon named "system security update warning" (sharp). + static const IconData system_security_update_warning_sharp = IconData( + 0xed2b, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">system_security_update_warning</i> — material icon named "system security update warning" (round). + static const IconData system_security_update_warning_rounded = IconData( + 0xf020a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">system_security_update_warning</i> — material icon named "system security update warning" (outlined). + static const IconData system_security_update_warning_outlined = IconData( + 0xf418, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">system_update</i> — material icon named "system update". + static const IconData system_update = IconData(0xe636, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">system_update</i> — material icon named "system update" (sharp). + static const IconData system_update_sharp = IconData(0xed2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">system_update</i> — material icon named "system update" (round). + static const IconData system_update_rounded = IconData(0xf020c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">system_update</i> — material icon named "system update" (outlined). + static const IconData system_update_outlined = IconData(0xf41a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">system_update_alt</i> — material icon named "system update alt". + static const IconData system_update_alt = IconData(0xe637, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">system_update_alt</i> — material icon named "system update alt" (sharp). + static const IconData system_update_alt_sharp = IconData(0xed2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">system_update_alt</i> — material icon named "system update alt" (round). + static const IconData system_update_alt_rounded = IconData(0xf020b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">system_update_alt</i> — material icon named "system update alt" (outlined). + static const IconData system_update_alt_outlined = IconData(0xf419, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">system_update_tv</i> — material icon named "system update tv". + static const IconData system_update_tv = IconData(0xe637, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">system_update_tv</i> — material icon named "system update tv" (sharp). + static const IconData system_update_tv_sharp = IconData(0xed2c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">system_update_tv</i> — material icon named "system update tv" (round). + static const IconData system_update_tv_rounded = IconData(0xf020b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">system_update_tv</i> — material icon named "system update tv" (outlined). + static const IconData system_update_tv_outlined = IconData(0xf419, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tab</i> — material icon named "tab". + static const IconData tab = IconData(0xe638, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tab</i> — material icon named "tab" (sharp). + static const IconData tab_sharp = IconData(0xed2e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tab</i> — material icon named "tab" (round). + static const IconData tab_rounded = IconData(0xf020d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tab</i> — material icon named "tab" (outlined). + static const IconData tab_outlined = IconData(0xf41b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tab_unselected</i> — material icon named "tab unselected". + static const IconData tab_unselected = IconData(0xe639, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tab_unselected</i> — material icon named "tab unselected" (sharp). + static const IconData tab_unselected_sharp = IconData(0xed2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tab_unselected</i> — material icon named "tab unselected" (round). + static const IconData tab_unselected_rounded = IconData(0xf020e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tab_unselected</i> — material icon named "tab unselected" (outlined). + static const IconData tab_unselected_outlined = IconData(0xf41c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">table_bar</i> — material icon named "table bar". + static const IconData table_bar = IconData(0xf0584, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">table_bar</i> — material icon named "table bar" (sharp). + static const IconData table_bar_sharp = IconData(0xf048a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">table_bar</i> — material icon named "table bar" (round). + static const IconData table_bar_rounded = IconData(0xf0397, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">table_bar</i> — material icon named "table bar" (outlined). + static const IconData table_bar_outlined = IconData(0xf0678, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">table_chart</i> — material icon named "table chart". + static const IconData table_chart = IconData(0xe63a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">table_chart</i> — material icon named "table chart" (sharp). + static const IconData table_chart_sharp = IconData(0xed30, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">table_chart</i> — material icon named "table chart" (round). + static const IconData table_chart_rounded = IconData(0xf020f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">table_chart</i> — material icon named "table chart" (outlined). + static const IconData table_chart_outlined = IconData(0xf41d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">table_restaurant</i> — material icon named "table restaurant". + static const IconData table_restaurant = IconData(0xf0585, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">table_restaurant</i> — material icon named "table restaurant" (sharp). + static const IconData table_restaurant_sharp = IconData(0xf048b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">table_restaurant</i> — material icon named "table restaurant" (round). + static const IconData table_restaurant_rounded = IconData(0xf0398, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">table_restaurant</i> — material icon named "table restaurant" (outlined). + static const IconData table_restaurant_outlined = IconData(0xf0679, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">table_rows</i> — material icon named "table rows". + static const IconData table_rows = IconData(0xe63b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">table_rows</i> — material icon named "table rows" (sharp). + static const IconData table_rows_sharp = IconData(0xed31, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">table_rows</i> — material icon named "table rows" (round). + static const IconData table_rows_rounded = IconData(0xf0210, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">table_rows</i> — material icon named "table rows" (outlined). + static const IconData table_rows_outlined = IconData(0xf41e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">table_view</i> — material icon named "table view". + static const IconData table_view = IconData(0xe63c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">table_view</i> — material icon named "table view" (sharp). + static const IconData table_view_sharp = IconData(0xed32, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">table_view</i> — material icon named "table view" (round). + static const IconData table_view_rounded = IconData(0xf0211, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">table_view</i> — material icon named "table view" (outlined). + static const IconData table_view_outlined = IconData(0xf41f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tablet</i> — material icon named "tablet". + static const IconData tablet = IconData(0xe63d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tablet</i> — material icon named "tablet" (sharp). + static const IconData tablet_sharp = IconData(0xed35, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tablet</i> — material icon named "tablet" (round). + static const IconData tablet_rounded = IconData(0xf0214, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tablet</i> — material icon named "tablet" (outlined). + static const IconData tablet_outlined = IconData(0xf422, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tablet_android</i> — material icon named "tablet android". + static const IconData tablet_android = IconData(0xe63e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tablet_android</i> — material icon named "tablet android" (sharp). + static const IconData tablet_android_sharp = IconData(0xed33, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tablet_android</i> — material icon named "tablet android" (round). + static const IconData tablet_android_rounded = IconData(0xf0212, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tablet_android</i> — material icon named "tablet android" (outlined). + static const IconData tablet_android_outlined = IconData(0xf420, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tablet_mac</i> — material icon named "tablet mac". + static const IconData tablet_mac = IconData(0xe63f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tablet_mac</i> — material icon named "tablet mac" (sharp). + static const IconData tablet_mac_sharp = IconData(0xed34, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tablet_mac</i> — material icon named "tablet mac" (round). + static const IconData tablet_mac_rounded = IconData(0xf0213, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tablet_mac</i> — material icon named "tablet mac" (outlined). + static const IconData tablet_mac_outlined = IconData(0xf421, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tag</i> — material icon named "tag". + static const IconData tag = IconData(0xe640, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tag</i> — material icon named "tag" (sharp). + static const IconData tag_sharp = IconData(0xed37, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tag</i> — material icon named "tag" (round). + static const IconData tag_rounded = IconData(0xf0216, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tag</i> — material icon named "tag" (outlined). + static const IconData tag_outlined = IconData(0xf424, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tag_faces</i> — material icon named "tag faces". + static const IconData tag_faces = IconData(0xe641, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tag_faces</i> — material icon named "tag faces" (sharp). + static const IconData tag_faces_sharp = IconData(0xed36, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tag_faces</i> — material icon named "tag faces" (round). + static const IconData tag_faces_rounded = IconData(0xf0215, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tag_faces</i> — material icon named "tag faces" (outlined). + static const IconData tag_faces_outlined = IconData(0xf423, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">takeout_dining</i> — material icon named "takeout dining". + static const IconData takeout_dining = IconData(0xe642, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">takeout_dining</i> — material icon named "takeout dining" (sharp). + static const IconData takeout_dining_sharp = IconData(0xed38, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">takeout_dining</i> — material icon named "takeout dining" (round). + static const IconData takeout_dining_rounded = IconData(0xf0217, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">takeout_dining</i> — material icon named "takeout dining" (outlined). + static const IconData takeout_dining_outlined = IconData(0xf425, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tap_and_play</i> — material icon named "tap and play". + static const IconData tap_and_play = IconData(0xe643, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tap_and_play</i> — material icon named "tap and play" (sharp). + static const IconData tap_and_play_sharp = IconData(0xed39, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tap_and_play</i> — material icon named "tap and play" (round). + static const IconData tap_and_play_rounded = IconData(0xf0218, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tap_and_play</i> — material icon named "tap and play" (outlined). + static const IconData tap_and_play_outlined = IconData(0xf426, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tapas</i> — material icon named "tapas". + static const IconData tapas = IconData(0xe644, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tapas</i> — material icon named "tapas" (sharp). + static const IconData tapas_sharp = IconData(0xed3a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tapas</i> — material icon named "tapas" (round). + static const IconData tapas_rounded = IconData(0xf0219, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tapas</i> — material icon named "tapas" (outlined). + static const IconData tapas_outlined = IconData(0xf427, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">task</i> — material icon named "task". + static const IconData task = IconData(0xe645, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">task</i> — material icon named "task" (sharp). + static const IconData task_sharp = IconData(0xed3c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">task</i> — material icon named "task" (round). + static const IconData task_rounded = IconData(0xf021b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">task</i> — material icon named "task" (outlined). + static const IconData task_outlined = IconData(0xf429, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">task_alt</i> — material icon named "task alt". + static const IconData task_alt = IconData(0xe646, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">task_alt</i> — material icon named "task alt" (sharp). + static const IconData task_alt_sharp = IconData(0xed3b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">task_alt</i> — material icon named "task alt" (round). + static const IconData task_alt_rounded = IconData(0xf021a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">task_alt</i> — material icon named "task alt" (outlined). + static const IconData task_alt_outlined = IconData(0xf428, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">taxi_alert</i> — material icon named "taxi alert". + static const IconData taxi_alert = IconData(0xe647, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">taxi_alert</i> — material icon named "taxi alert" (sharp). + static const IconData taxi_alert_sharp = IconData(0xed3d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">taxi_alert</i> — material icon named "taxi alert" (round). + static const IconData taxi_alert_rounded = IconData(0xf021c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">taxi_alert</i> — material icon named "taxi alert" (outlined). + static const IconData taxi_alert_outlined = IconData(0xf42a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">telegram</i> — material icon named "telegram". + static const IconData telegram = IconData(0xf0586, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">telegram</i> — material icon named "telegram" (sharp). + static const IconData telegram_sharp = IconData(0xf048c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">telegram</i> — material icon named "telegram" (round). + static const IconData telegram_rounded = IconData(0xf0399, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">telegram</i> — material icon named "telegram" (outlined). + static const IconData telegram_outlined = IconData(0xf067a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">temple_buddhist</i> — material icon named "temple buddhist". + static const IconData temple_buddhist = IconData(0xf0587, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">temple_buddhist</i> — material icon named "temple buddhist" (sharp). + static const IconData temple_buddhist_sharp = IconData(0xf048d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">temple_buddhist</i> — material icon named "temple buddhist" (round). + static const IconData temple_buddhist_rounded = IconData(0xf039a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">temple_buddhist</i> — material icon named "temple buddhist" (outlined). + static const IconData temple_buddhist_outlined = IconData(0xf067b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">temple_hindu</i> — material icon named "temple hindu". + static const IconData temple_hindu = IconData(0xf0588, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">temple_hindu</i> — material icon named "temple hindu" (sharp). + static const IconData temple_hindu_sharp = IconData(0xf048e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">temple_hindu</i> — material icon named "temple hindu" (round). + static const IconData temple_hindu_rounded = IconData(0xf039b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">temple_hindu</i> — material icon named "temple hindu" (outlined). + static const IconData temple_hindu_outlined = IconData(0xf067c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">terminal</i> — material icon named "terminal". + static const IconData terminal = IconData(0xf0589, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">terminal</i> — material icon named "terminal" (sharp). + static const IconData terminal_sharp = IconData(0xf048f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">terminal</i> — material icon named "terminal" (round). + static const IconData terminal_rounded = IconData(0xf039c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">terminal</i> — material icon named "terminal" (outlined). + static const IconData terminal_outlined = IconData(0xf067d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">terrain</i> — material icon named "terrain". + static const IconData terrain = IconData(0xe648, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">terrain</i> — material icon named "terrain" (sharp). + static const IconData terrain_sharp = IconData(0xed3e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">terrain</i> — material icon named "terrain" (round). + static const IconData terrain_rounded = IconData(0xf021d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">terrain</i> — material icon named "terrain" (outlined). + static const IconData terrain_outlined = IconData(0xf42b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_decrease</i> — material icon named "text decrease". + static const IconData text_decrease = IconData(0xf058a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_decrease</i> — material icon named "text decrease" (sharp). + static const IconData text_decrease_sharp = IconData(0xf0490, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_decrease</i> — material icon named "text decrease" (round). + static const IconData text_decrease_rounded = IconData(0xf039d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_decrease</i> — material icon named "text decrease" (outlined). + static const IconData text_decrease_outlined = IconData(0xf067e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_fields</i> — material icon named "text fields". + static const IconData text_fields = IconData(0xe649, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_fields</i> — material icon named "text fields" (sharp). + static const IconData text_fields_sharp = IconData(0xed3f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_fields</i> — material icon named "text fields" (round). + static const IconData text_fields_rounded = IconData(0xf021e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_fields</i> — material icon named "text fields" (outlined). + static const IconData text_fields_outlined = IconData(0xf42c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_format</i> — material icon named "text format". + static const IconData text_format = IconData(0xe64a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_format</i> — material icon named "text format" (sharp). + static const IconData text_format_sharp = IconData(0xed40, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_format</i> — material icon named "text format" (round). + static const IconData text_format_rounded = IconData(0xf021f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_format</i> — material icon named "text format" (outlined). + static const IconData text_format_outlined = IconData(0xf42d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_increase</i> — material icon named "text increase". + static const IconData text_increase = IconData(0xf058b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_increase</i> — material icon named "text increase" (sharp). + static const IconData text_increase_sharp = IconData(0xf0491, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_increase</i> — material icon named "text increase" (round). + static const IconData text_increase_rounded = IconData(0xf039e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_increase</i> — material icon named "text increase" (outlined). + static const IconData text_increase_outlined = IconData(0xf067f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_rotate_up</i> — material icon named "text rotate up". + static const IconData text_rotate_up = IconData(0xe64b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_rotate_up</i> — material icon named "text rotate up" (sharp). + static const IconData text_rotate_up_sharp = IconData(0xed41, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_rotate_up</i> — material icon named "text rotate up" (round). + static const IconData text_rotate_up_rounded = IconData(0xf0220, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_rotate_up</i> — material icon named "text rotate up" (outlined). + static const IconData text_rotate_up_outlined = IconData(0xf42e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_rotate_vertical</i> — material icon named "text rotate vertical". + static const IconData text_rotate_vertical = IconData(0xe64c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_rotate_vertical</i> — material icon named "text rotate vertical" (sharp). + static const IconData text_rotate_vertical_sharp = IconData(0xed42, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_rotate_vertical</i> — material icon named "text rotate vertical" (round). + static const IconData text_rotate_vertical_rounded = IconData( + 0xf0221, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">text_rotate_vertical</i> — material icon named "text rotate vertical" (outlined). + static const IconData text_rotate_vertical_outlined = IconData( + 0xf42f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">text_rotation_angledown</i> — material icon named "text rotation angledown". + static const IconData text_rotation_angledown = IconData(0xe64d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_rotation_angledown</i> — material icon named "text rotation angledown" (sharp). + static const IconData text_rotation_angledown_sharp = IconData( + 0xed43, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">text_rotation_angledown</i> — material icon named "text rotation angledown" (round). + static const IconData text_rotation_angledown_rounded = IconData( + 0xf0222, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">text_rotation_angledown</i> — material icon named "text rotation angledown" (outlined). + static const IconData text_rotation_angledown_outlined = IconData( + 0xf430, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">text_rotation_angleup</i> — material icon named "text rotation angleup". + static const IconData text_rotation_angleup = IconData(0xe64e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_rotation_angleup</i> — material icon named "text rotation angleup" (sharp). + static const IconData text_rotation_angleup_sharp = IconData(0xed44, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_rotation_angleup</i> — material icon named "text rotation angleup" (round). + static const IconData text_rotation_angleup_rounded = IconData( + 0xf0223, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">text_rotation_angleup</i> — material icon named "text rotation angleup" (outlined). + static const IconData text_rotation_angleup_outlined = IconData( + 0xf431, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">text_rotation_down</i> — material icon named "text rotation down". + static const IconData text_rotation_down = IconData(0xe64f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_rotation_down</i> — material icon named "text rotation down" (sharp). + static const IconData text_rotation_down_sharp = IconData(0xed45, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_rotation_down</i> — material icon named "text rotation down" (round). + static const IconData text_rotation_down_rounded = IconData(0xf0224, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_rotation_down</i> — material icon named "text rotation down" (outlined). + static const IconData text_rotation_down_outlined = IconData(0xf432, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_rotation_none</i> — material icon named "text rotation none". + static const IconData text_rotation_none = IconData(0xe650, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_rotation_none</i> — material icon named "text rotation none" (sharp). + static const IconData text_rotation_none_sharp = IconData(0xed46, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_rotation_none</i> — material icon named "text rotation none" (round). + static const IconData text_rotation_none_rounded = IconData(0xf0225, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_rotation_none</i> — material icon named "text rotation none" (outlined). + static const IconData text_rotation_none_outlined = IconData(0xf433, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">text_snippet</i> — material icon named "text snippet". + static const IconData text_snippet = IconData(0xe651, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">text_snippet</i> — material icon named "text snippet" (sharp). + static const IconData text_snippet_sharp = IconData(0xed47, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">text_snippet</i> — material icon named "text snippet" (round). + static const IconData text_snippet_rounded = IconData(0xf0226, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">text_snippet</i> — material icon named "text snippet" (outlined). + static const IconData text_snippet_outlined = IconData(0xf434, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">textsms</i> — material icon named "textsms". + static const IconData textsms = IconData(0xe652, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">textsms</i> — material icon named "textsms" (sharp). + static const IconData textsms_sharp = IconData(0xed48, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">textsms</i> — material icon named "textsms" (round). + static const IconData textsms_rounded = IconData(0xf0227, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">textsms</i> — material icon named "textsms" (outlined). + static const IconData textsms_outlined = IconData(0xf435, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">texture</i> — material icon named "texture". + static const IconData texture = IconData(0xe653, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">texture</i> — material icon named "texture" (sharp). + static const IconData texture_sharp = IconData(0xed49, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">texture</i> — material icon named "texture" (round). + static const IconData texture_rounded = IconData(0xf0228, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">texture</i> — material icon named "texture" (outlined). + static const IconData texture_outlined = IconData(0xf436, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">theater_comedy</i> — material icon named "theater comedy". + static const IconData theater_comedy = IconData(0xe654, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">theater_comedy</i> — material icon named "theater comedy" (sharp). + static const IconData theater_comedy_sharp = IconData(0xed4a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">theater_comedy</i> — material icon named "theater comedy" (round). + static const IconData theater_comedy_rounded = IconData(0xf0229, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">theater_comedy</i> — material icon named "theater comedy" (outlined). + static const IconData theater_comedy_outlined = IconData(0xf437, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">theaters</i> — material icon named "theaters". + static const IconData theaters = IconData(0xe655, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">theaters</i> — material icon named "theaters" (sharp). + static const IconData theaters_sharp = IconData(0xed4b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">theaters</i> — material icon named "theaters" (round). + static const IconData theaters_rounded = IconData(0xf022a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">theaters</i> — material icon named "theaters" (outlined). + static const IconData theaters_outlined = IconData(0xf438, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thermostat</i> — material icon named "thermostat". + static const IconData thermostat = IconData(0xe656, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thermostat</i> — material icon named "thermostat" (sharp). + static const IconData thermostat_sharp = IconData(0xed4d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thermostat</i> — material icon named "thermostat" (round). + static const IconData thermostat_rounded = IconData(0xf022c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thermostat</i> — material icon named "thermostat" (outlined). + static const IconData thermostat_outlined = IconData(0xf43a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thermostat_auto</i> — material icon named "thermostat auto". + static const IconData thermostat_auto = IconData(0xe657, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thermostat_auto</i> — material icon named "thermostat auto" (sharp). + static const IconData thermostat_auto_sharp = IconData(0xed4c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thermostat_auto</i> — material icon named "thermostat auto" (round). + static const IconData thermostat_auto_rounded = IconData(0xf022b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thermostat_auto</i> — material icon named "thermostat auto" (outlined). + static const IconData thermostat_auto_outlined = IconData(0xf439, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumb_down</i> — material icon named "thumb down". + static const IconData thumb_down = IconData(0xe658, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumb_down</i> — material icon named "thumb down" (sharp). + static const IconData thumb_down_sharp = IconData(0xed50, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumb_down</i> — material icon named "thumb down" (round). + static const IconData thumb_down_rounded = IconData(0xf022f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumb_down</i> — material icon named "thumb down" (outlined). + static const IconData thumb_down_outlined = IconData(0xf43d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumb_down_alt</i> — material icon named "thumb down alt". + static const IconData thumb_down_alt = IconData(0xe659, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumb_down_alt</i> — material icon named "thumb down alt" (sharp). + static const IconData thumb_down_alt_sharp = IconData(0xed4e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumb_down_alt</i> — material icon named "thumb down alt" (round). + static const IconData thumb_down_alt_rounded = IconData(0xf022d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumb_down_alt</i> — material icon named "thumb down alt" (outlined). + static const IconData thumb_down_alt_outlined = IconData(0xf43b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumb_down_off_alt</i> — material icon named "thumb down off alt". + static const IconData thumb_down_off_alt = IconData(0xe65a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumb_down_off_alt</i> — material icon named "thumb down off alt" (sharp). + static const IconData thumb_down_off_alt_sharp = IconData(0xed4f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumb_down_off_alt</i> — material icon named "thumb down off alt" (round). + static const IconData thumb_down_off_alt_rounded = IconData(0xf022e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumb_down_off_alt</i> — material icon named "thumb down off alt" (outlined). + static const IconData thumb_down_off_alt_outlined = IconData(0xf43c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumb_up</i> — material icon named "thumb up". + static const IconData thumb_up = IconData(0xe65b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumb_up</i> — material icon named "thumb up" (sharp). + static const IconData thumb_up_sharp = IconData(0xed53, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumb_up</i> — material icon named "thumb up" (round). + static const IconData thumb_up_rounded = IconData(0xf0232, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumb_up</i> — material icon named "thumb up" (outlined). + static const IconData thumb_up_outlined = IconData(0xf440, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumb_up_alt</i> — material icon named "thumb up alt". + static const IconData thumb_up_alt = IconData(0xe65c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumb_up_alt</i> — material icon named "thumb up alt" (sharp). + static const IconData thumb_up_alt_sharp = IconData(0xed51, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumb_up_alt</i> — material icon named "thumb up alt" (round). + static const IconData thumb_up_alt_rounded = IconData(0xf0230, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumb_up_alt</i> — material icon named "thumb up alt" (outlined). + static const IconData thumb_up_alt_outlined = IconData(0xf43e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumb_up_off_alt</i> — material icon named "thumb up off alt". + static const IconData thumb_up_off_alt = IconData(0xe65d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumb_up_off_alt</i> — material icon named "thumb up off alt" (sharp). + static const IconData thumb_up_off_alt_sharp = IconData(0xed52, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumb_up_off_alt</i> — material icon named "thumb up off alt" (round). + static const IconData thumb_up_off_alt_rounded = IconData(0xf0231, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumb_up_off_alt</i> — material icon named "thumb up off alt" (outlined). + static const IconData thumb_up_off_alt_outlined = IconData(0xf43f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thumbs_up_down</i> — material icon named "thumbs up down". + static const IconData thumbs_up_down = IconData(0xe65e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thumbs_up_down</i> — material icon named "thumbs up down" (sharp). + static const IconData thumbs_up_down_sharp = IconData(0xed54, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thumbs_up_down</i> — material icon named "thumbs up down" (round). + static const IconData thumbs_up_down_rounded = IconData(0xf0233, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thumbs_up_down</i> — material icon named "thumbs up down" (outlined). + static const IconData thumbs_up_down_outlined = IconData(0xf441, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">thunderstorm</i> — material icon named "thunderstorm". + static const IconData thunderstorm = IconData(0xf07cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">thunderstorm</i> — material icon named "thunderstorm" (sharp). + static const IconData thunderstorm_sharp = IconData(0xf0773, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">thunderstorm</i> — material icon named "thunderstorm" (round). + static const IconData thunderstorm_rounded = IconData(0xf0823, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">thunderstorm</i> — material icon named "thunderstorm" (outlined). + static const IconData thunderstorm_outlined = IconData(0xf071b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tiktok</i> — material icon named "tiktok". + static const IconData tiktok = IconData(0xf058c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tiktok</i> — material icon named "tiktok" (sharp). + static const IconData tiktok_sharp = IconData(0xf0492, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tiktok</i> — material icon named "tiktok" (round). + static const IconData tiktok_rounded = IconData(0xf039f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tiktok</i> — material icon named "tiktok" (outlined). + static const IconData tiktok_outlined = IconData(0xf0680, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">time_to_leave</i> — material icon named "time to leave". + static const IconData time_to_leave = IconData(0xe65f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">time_to_leave</i> — material icon named "time to leave" (sharp). + static const IconData time_to_leave_sharp = IconData(0xed55, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">time_to_leave</i> — material icon named "time to leave" (round). + static const IconData time_to_leave_rounded = IconData(0xf0234, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">time_to_leave</i> — material icon named "time to leave" (outlined). + static const IconData time_to_leave_outlined = IconData(0xf442, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timelapse</i> — material icon named "timelapse". + static const IconData timelapse = IconData(0xe660, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timelapse</i> — material icon named "timelapse" (sharp). + static const IconData timelapse_sharp = IconData(0xed56, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timelapse</i> — material icon named "timelapse" (round). + static const IconData timelapse_rounded = IconData(0xf0235, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timelapse</i> — material icon named "timelapse" (outlined). + static const IconData timelapse_outlined = IconData(0xf443, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timeline</i> — material icon named "timeline". + static const IconData timeline = IconData(0xe661, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timeline</i> — material icon named "timeline" (sharp). + static const IconData timeline_sharp = IconData(0xed57, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timeline</i> — material icon named "timeline" (round). + static const IconData timeline_rounded = IconData(0xf0236, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timeline</i> — material icon named "timeline" (outlined). + static const IconData timeline_outlined = IconData(0xf444, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timer</i> — material icon named "timer". + static const IconData timer = IconData(0xe662, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timer</i> — material icon named "timer" (sharp). + static const IconData timer_sharp = IconData(0xed5d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timer</i> — material icon named "timer" (round). + static const IconData timer_rounded = IconData(0xf023c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timer</i> — material icon named "timer" (outlined). + static const IconData timer_outlined = IconData(0xf44a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timer_10</i> — material icon named "timer 10". + static const IconData timer_10 = IconData(0xe663, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timer_10</i> — material icon named "timer 10" (sharp). + static const IconData timer_10_sharp = IconData(0xed59, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timer_10</i> — material icon named "timer 10" (round). + static const IconData timer_10_rounded = IconData(0xf0237, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timer_10</i> — material icon named "timer 10" (outlined). + static const IconData timer_10_outlined = IconData(0xf445, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timer_10_select</i> — material icon named "timer 10 select". + static const IconData timer_10_select = IconData(0xe664, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timer_10_select</i> — material icon named "timer 10 select" (sharp). + static const IconData timer_10_select_sharp = IconData(0xed58, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timer_10_select</i> — material icon named "timer 10 select" (round). + static const IconData timer_10_select_rounded = IconData(0xf0238, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timer_10_select</i> — material icon named "timer 10 select" (outlined). + static const IconData timer_10_select_outlined = IconData(0xf446, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timer_3</i> — material icon named "timer 3". + static const IconData timer_3 = IconData(0xe665, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timer_3</i> — material icon named "timer 3" (sharp). + static const IconData timer_3_sharp = IconData(0xed5b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timer_3</i> — material icon named "timer 3" (round). + static const IconData timer_3_rounded = IconData(0xf0239, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timer_3</i> — material icon named "timer 3" (outlined). + static const IconData timer_3_outlined = IconData(0xf447, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timer_3_select</i> — material icon named "timer 3 select". + static const IconData timer_3_select = IconData(0xe666, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timer_3_select</i> — material icon named "timer 3 select" (sharp). + static const IconData timer_3_select_sharp = IconData(0xed5a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timer_3_select</i> — material icon named "timer 3 select" (round). + static const IconData timer_3_select_rounded = IconData(0xf023a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timer_3_select</i> — material icon named "timer 3 select" (outlined). + static const IconData timer_3_select_outlined = IconData(0xf448, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">timer_off</i> — material icon named "timer off". + static const IconData timer_off = IconData(0xe667, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">timer_off</i> — material icon named "timer off" (sharp). + static const IconData timer_off_sharp = IconData(0xed5c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">timer_off</i> — material icon named "timer off" (round). + static const IconData timer_off_rounded = IconData(0xf023b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">timer_off</i> — material icon named "timer off" (outlined). + static const IconData timer_off_outlined = IconData(0xf449, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tips_and_updates</i> — material icon named "tips and updates". + static const IconData tips_and_updates = IconData(0xf058d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tips_and_updates</i> — material icon named "tips and updates" (sharp). + static const IconData tips_and_updates_sharp = IconData(0xf0493, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tips_and_updates</i> — material icon named "tips and updates" (round). + static const IconData tips_and_updates_rounded = IconData(0xf03a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tips_and_updates</i> — material icon named "tips and updates" (outlined). + static const IconData tips_and_updates_outlined = IconData(0xf0681, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tire_repair</i> — material icon named "tire repair". + static const IconData tire_repair = IconData(0xf06c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tire_repair</i> — material icon named "tire repair" (sharp). + static const IconData tire_repair_sharp = IconData(0xf06b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tire_repair</i> — material icon named "tire repair" (round). + static const IconData tire_repair_rounded = IconData(0xf06d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tire_repair</i> — material icon named "tire repair" (outlined). + static const IconData tire_repair_outlined = IconData(0xf06aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">title</i> — material icon named "title". + static const IconData title = IconData(0xe668, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">title</i> — material icon named "title" (sharp). + static const IconData title_sharp = IconData(0xed5e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">title</i> — material icon named "title" (round). + static const IconData title_rounded = IconData(0xf023d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">title</i> — material icon named "title" (outlined). + static const IconData title_outlined = IconData(0xf44b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">toc</i> — material icon named "toc". + static const IconData toc = IconData( + 0xe669, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">toc</i> — material icon named "toc" (sharp). + static const IconData toc_sharp = IconData( + 0xed5f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">toc</i> — material icon named "toc" (round). + static const IconData toc_rounded = IconData( + 0xf023e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">toc</i> — material icon named "toc" (outlined). + static const IconData toc_outlined = IconData( + 0xf44c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">today</i> — material icon named "today". + static const IconData today = IconData(0xe66a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">today</i> — material icon named "today" (sharp). + static const IconData today_sharp = IconData(0xed60, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">today</i> — material icon named "today" (round). + static const IconData today_rounded = IconData(0xf023f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">today</i> — material icon named "today" (outlined). + static const IconData today_outlined = IconData(0xf44d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">toggle_off</i> — material icon named "toggle off". + static const IconData toggle_off = IconData(0xe66b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">toggle_off</i> — material icon named "toggle off" (sharp). + static const IconData toggle_off_sharp = IconData(0xed61, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">toggle_off</i> — material icon named "toggle off" (round). + static const IconData toggle_off_rounded = IconData(0xf0240, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">toggle_off</i> — material icon named "toggle off" (outlined). + static const IconData toggle_off_outlined = IconData(0xf44e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">toggle_on</i> — material icon named "toggle on". + static const IconData toggle_on = IconData(0xe66c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">toggle_on</i> — material icon named "toggle on" (sharp). + static const IconData toggle_on_sharp = IconData(0xed62, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">toggle_on</i> — material icon named "toggle on" (round). + static const IconData toggle_on_rounded = IconData(0xf0241, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">toggle_on</i> — material icon named "toggle on" (outlined). + static const IconData toggle_on_outlined = IconData(0xf44f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">token</i> — material icon named "token". + static const IconData token = IconData(0xf058e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">token</i> — material icon named "token" (sharp). + static const IconData token_sharp = IconData(0xf0494, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">token</i> — material icon named "token" (round). + static const IconData token_rounded = IconData(0xf03a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">token</i> — material icon named "token" (outlined). + static const IconData token_outlined = IconData(0xf0682, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">toll</i> — material icon named "toll". + static const IconData toll = IconData(0xe66d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">toll</i> — material icon named "toll" (sharp). + static const IconData toll_sharp = IconData(0xed63, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">toll</i> — material icon named "toll" (round). + static const IconData toll_rounded = IconData(0xf0242, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">toll</i> — material icon named "toll" (outlined). + static const IconData toll_outlined = IconData(0xf450, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tonality</i> — material icon named "tonality". + static const IconData tonality = IconData(0xe66e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tonality</i> — material icon named "tonality" (sharp). + static const IconData tonality_sharp = IconData(0xed64, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tonality</i> — material icon named "tonality" (round). + static const IconData tonality_rounded = IconData(0xf0243, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tonality</i> — material icon named "tonality" (outlined). + static const IconData tonality_outlined = IconData(0xf451, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">topic</i> — material icon named "topic". + static const IconData topic = IconData(0xe66f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">topic</i> — material icon named "topic" (sharp). + static const IconData topic_sharp = IconData(0xed65, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">topic</i> — material icon named "topic" (round). + static const IconData topic_rounded = IconData(0xf0244, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">topic</i> — material icon named "topic" (outlined). + static const IconData topic_outlined = IconData(0xf452, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tornado</i> — material icon named "tornado". + static const IconData tornado = IconData(0xf07cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tornado</i> — material icon named "tornado" (sharp). + static const IconData tornado_sharp = IconData(0xf0774, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tornado</i> — material icon named "tornado" (round). + static const IconData tornado_rounded = IconData(0xf0824, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tornado</i> — material icon named "tornado" (outlined). + static const IconData tornado_outlined = IconData(0xf071c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">touch_app</i> — material icon named "touch app". + static const IconData touch_app = IconData(0xe670, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">touch_app</i> — material icon named "touch app" (sharp). + static const IconData touch_app_sharp = IconData(0xed66, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">touch_app</i> — material icon named "touch app" (round). + static const IconData touch_app_rounded = IconData(0xf0245, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">touch_app</i> — material icon named "touch app" (outlined). + static const IconData touch_app_outlined = IconData(0xf453, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tour</i> — material icon named "tour". + static const IconData tour = IconData(0xe671, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tour</i> — material icon named "tour" (sharp). + static const IconData tour_sharp = IconData(0xed67, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tour</i> — material icon named "tour" (round). + static const IconData tour_rounded = IconData(0xf0246, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tour</i> — material icon named "tour" (outlined). + static const IconData tour_outlined = IconData(0xf454, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">toys</i> — material icon named "toys". + static const IconData toys = IconData(0xe672, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">toys</i> — material icon named "toys" (sharp). + static const IconData toys_sharp = IconData(0xed68, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">toys</i> — material icon named "toys" (round). + static const IconData toys_rounded = IconData(0xf0247, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">toys</i> — material icon named "toys" (outlined). + static const IconData toys_outlined = IconData(0xf455, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">track_changes</i> — material icon named "track changes". + static const IconData track_changes = IconData(0xe673, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">track_changes</i> — material icon named "track changes" (sharp). + static const IconData track_changes_sharp = IconData(0xed69, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">track_changes</i> — material icon named "track changes" (round). + static const IconData track_changes_rounded = IconData(0xf0248, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">track_changes</i> — material icon named "track changes" (outlined). + static const IconData track_changes_outlined = IconData(0xf456, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">traffic</i> — material icon named "traffic". + static const IconData traffic = IconData(0xe674, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">traffic</i> — material icon named "traffic" (sharp). + static const IconData traffic_sharp = IconData(0xed6a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">traffic</i> — material icon named "traffic" (round). + static const IconData traffic_rounded = IconData(0xf0249, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">traffic</i> — material icon named "traffic" (outlined). + static const IconData traffic_outlined = IconData(0xf457, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">train</i> — material icon named "train". + static const IconData train = IconData(0xe675, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">train</i> — material icon named "train" (sharp). + static const IconData train_sharp = IconData(0xed6b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">train</i> — material icon named "train" (round). + static const IconData train_rounded = IconData(0xf024a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">train</i> — material icon named "train" (outlined). + static const IconData train_outlined = IconData(0xf458, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tram</i> — material icon named "tram". + static const IconData tram = IconData(0xe676, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tram</i> — material icon named "tram" (sharp). + static const IconData tram_sharp = IconData(0xed6c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tram</i> — material icon named "tram" (round). + static const IconData tram_rounded = IconData(0xf024b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tram</i> — material icon named "tram" (outlined). + static const IconData tram_outlined = IconData(0xf459, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">transcribe</i> — material icon named "transcribe". + static const IconData transcribe = IconData(0xf07cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">transcribe</i> — material icon named "transcribe" (sharp). + static const IconData transcribe_sharp = IconData(0xf0775, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">transcribe</i> — material icon named "transcribe" (round). + static const IconData transcribe_rounded = IconData(0xf0825, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">transcribe</i> — material icon named "transcribe" (outlined). + static const IconData transcribe_outlined = IconData(0xf071d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">transfer_within_a_station</i> — material icon named "transfer within a station". + static const IconData transfer_within_a_station = IconData(0xe677, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">transfer_within_a_station</i> — material icon named "transfer within a station" (sharp). + static const IconData transfer_within_a_station_sharp = IconData( + 0xed6d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">transfer_within_a_station</i> — material icon named "transfer within a station" (round). + static const IconData transfer_within_a_station_rounded = IconData( + 0xf024c, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">transfer_within_a_station</i> — material icon named "transfer within a station" (outlined). + static const IconData transfer_within_a_station_outlined = IconData( + 0xf45a, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">transform</i> — material icon named "transform". + static const IconData transform = IconData(0xe678, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">transform</i> — material icon named "transform" (sharp). + static const IconData transform_sharp = IconData(0xed6e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">transform</i> — material icon named "transform" (round). + static const IconData transform_rounded = IconData(0xf024d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">transform</i> — material icon named "transform" (outlined). + static const IconData transform_outlined = IconData(0xf45b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">transgender</i> — material icon named "transgender". + static const IconData transgender = IconData(0xe679, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">transgender</i> — material icon named "transgender" (sharp). + static const IconData transgender_sharp = IconData(0xed6f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">transgender</i> — material icon named "transgender" (round). + static const IconData transgender_rounded = IconData(0xf024e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">transgender</i> — material icon named "transgender" (outlined). + static const IconData transgender_outlined = IconData(0xf45c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">transit_enterexit</i> — material icon named "transit enterexit". + static const IconData transit_enterexit = IconData(0xe67a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">transit_enterexit</i> — material icon named "transit enterexit" (sharp). + static const IconData transit_enterexit_sharp = IconData(0xed70, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">transit_enterexit</i> — material icon named "transit enterexit" (round). + static const IconData transit_enterexit_rounded = IconData(0xf024f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">transit_enterexit</i> — material icon named "transit enterexit" (outlined). + static const IconData transit_enterexit_outlined = IconData(0xf45d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">translate</i> — material icon named "translate". + static const IconData translate = IconData(0xe67b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">translate</i> — material icon named "translate" (sharp). + static const IconData translate_sharp = IconData(0xed71, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">translate</i> — material icon named "translate" (round). + static const IconData translate_rounded = IconData(0xf0250, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">translate</i> — material icon named "translate" (outlined). + static const IconData translate_outlined = IconData(0xf45e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">travel_explore</i> — material icon named "travel explore". + static const IconData travel_explore = IconData(0xe67c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">travel_explore</i> — material icon named "travel explore" (sharp). + static const IconData travel_explore_sharp = IconData(0xed72, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">travel_explore</i> — material icon named "travel explore" (round). + static const IconData travel_explore_rounded = IconData(0xf0251, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">travel_explore</i> — material icon named "travel explore" (outlined). + static const IconData travel_explore_outlined = IconData(0xf45f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">trending_down</i> — material icon named "trending down". + static const IconData trending_down = IconData( + 0xe67d, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">trending_down</i> — material icon named "trending down" (sharp). + static const IconData trending_down_sharp = IconData( + 0xed73, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">trending_down</i> — material icon named "trending down" (round). + static const IconData trending_down_rounded = IconData( + 0xf0252, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">trending_down</i> — material icon named "trending down" (outlined). + static const IconData trending_down_outlined = IconData( + 0xf460, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">trending_flat</i> — material icon named "trending flat". + static const IconData trending_flat = IconData( + 0xe67e, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">trending_flat</i> — material icon named "trending flat" (sharp). + static const IconData trending_flat_sharp = IconData( + 0xed74, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">trending_flat</i> — material icon named "trending flat" (round). + static const IconData trending_flat_rounded = IconData( + 0xf0253, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">trending_flat</i> — material icon named "trending flat" (outlined). + static const IconData trending_flat_outlined = IconData( + 0xf461, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">trending_neutral</i> — material icon named "trending neutral". + static const IconData trending_neutral = IconData(0xe67e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">trending_neutral</i> — material icon named "trending neutral" (sharp). + static const IconData trending_neutral_sharp = IconData(0xed74, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">trending_neutral</i> — material icon named "trending neutral" (round). + static const IconData trending_neutral_rounded = IconData(0xf0253, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">trending_neutral</i> — material icon named "trending neutral" (outlined). + static const IconData trending_neutral_outlined = IconData(0xf461, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">trending_up</i> — material icon named "trending up". + static const IconData trending_up = IconData( + 0xe67f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">trending_up</i> — material icon named "trending up" (sharp). + static const IconData trending_up_sharp = IconData( + 0xed75, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">trending_up</i> — material icon named "trending up" (round). + static const IconData trending_up_rounded = IconData( + 0xf0254, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">trending_up</i> — material icon named "trending up" (outlined). + static const IconData trending_up_outlined = IconData( + 0xf462, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">trip_origin</i> — material icon named "trip origin". + static const IconData trip_origin = IconData(0xe680, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">trip_origin</i> — material icon named "trip origin" (sharp). + static const IconData trip_origin_sharp = IconData(0xed76, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">trip_origin</i> — material icon named "trip origin" (round). + static const IconData trip_origin_rounded = IconData(0xf0255, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">trip_origin</i> — material icon named "trip origin" (outlined). + static const IconData trip_origin_outlined = IconData(0xf463, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">trolley</i> — material icon named "trolley". + static const IconData trolley = IconData(0xf0878, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">troubleshoot</i> — material icon named "troubleshoot". + static const IconData troubleshoot = IconData(0xf07ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">troubleshoot</i> — material icon named "troubleshoot" (sharp). + static const IconData troubleshoot_sharp = IconData(0xf0776, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">troubleshoot</i> — material icon named "troubleshoot" (round). + static const IconData troubleshoot_rounded = IconData(0xf0826, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">troubleshoot</i> — material icon named "troubleshoot" (outlined). + static const IconData troubleshoot_outlined = IconData(0xf071e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">try</i> — material icon named "try". + static const IconData try_sms_star = IconData(0xe681, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">try</i> — material icon named "try" (sharp). + static const IconData try_sms_star_sharp = IconData(0xed77, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">try</i> — material icon named "try" (round). + static const IconData try_sms_star_rounded = IconData(0xf0256, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">try</i> — material icon named "try" (outlined). + static const IconData try_sms_star_outlined = IconData(0xf464, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tsunami</i> — material icon named "tsunami". + static const IconData tsunami = IconData(0xf07cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tsunami</i> — material icon named "tsunami" (sharp). + static const IconData tsunami_sharp = IconData(0xf0777, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tsunami</i> — material icon named "tsunami" (round). + static const IconData tsunami_rounded = IconData(0xf0827, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tsunami</i> — material icon named "tsunami" (outlined). + static const IconData tsunami_outlined = IconData(0xf071f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tty</i> — material icon named "tty". + static const IconData tty = IconData(0xe682, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tty</i> — material icon named "tty" (sharp). + static const IconData tty_sharp = IconData(0xed78, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tty</i> — material icon named "tty" (round). + static const IconData tty_rounded = IconData(0xf0257, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tty</i> — material icon named "tty" (outlined). + static const IconData tty_outlined = IconData(0xf465, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tune</i> — material icon named "tune". + static const IconData tune = IconData(0xe683, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tune</i> — material icon named "tune" (sharp). + static const IconData tune_sharp = IconData(0xed79, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tune</i> — material icon named "tune" (round). + static const IconData tune_rounded = IconData(0xf0258, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tune</i> — material icon named "tune" (outlined). + static const IconData tune_outlined = IconData(0xf466, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tungsten</i> — material icon named "tungsten". + static const IconData tungsten = IconData(0xe684, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tungsten</i> — material icon named "tungsten" (sharp). + static const IconData tungsten_sharp = IconData(0xed7a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tungsten</i> — material icon named "tungsten" (round). + static const IconData tungsten_rounded = IconData(0xf0259, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tungsten</i> — material icon named "tungsten" (outlined). + static const IconData tungsten_outlined = IconData(0xf467, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turn_left</i> — material icon named "turn left". + static const IconData turn_left = IconData(0xf058f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turn_left</i> — material icon named "turn left" (sharp). + static const IconData turn_left_sharp = IconData(0xf0495, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turn_left</i> — material icon named "turn left" (round). + static const IconData turn_left_rounded = IconData(0xf03a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turn_left</i> — material icon named "turn left" (outlined). + static const IconData turn_left_outlined = IconData(0xf0683, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turn_right</i> — material icon named "turn right". + static const IconData turn_right = IconData(0xf0590, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turn_right</i> — material icon named "turn right" (sharp). + static const IconData turn_right_sharp = IconData(0xf0496, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turn_right</i> — material icon named "turn right" (round). + static const IconData turn_right_rounded = IconData(0xf03a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turn_right</i> — material icon named "turn right" (outlined). + static const IconData turn_right_outlined = IconData(0xf0684, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turn_sharp_left</i> — material icon named "turn sharp left". + static const IconData turn_sharp_left = IconData(0xf0591, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turn_sharp_left</i> — material icon named "turn sharp left" (sharp). + static const IconData turn_sharp_left_sharp = IconData(0xf0497, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turn_sharp_left</i> — material icon named "turn sharp left" (round). + static const IconData turn_sharp_left_rounded = IconData(0xf03a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turn_sharp_left</i> — material icon named "turn sharp left" (outlined). + static const IconData turn_sharp_left_outlined = IconData(0xf0685, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turn_sharp_right</i> — material icon named "turn sharp right". + static const IconData turn_sharp_right = IconData(0xf0592, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turn_sharp_right</i> — material icon named "turn sharp right" (sharp). + static const IconData turn_sharp_right_sharp = IconData(0xf0498, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turn_sharp_right</i> — material icon named "turn sharp right" (round). + static const IconData turn_sharp_right_rounded = IconData(0xf03a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turn_sharp_right</i> — material icon named "turn sharp right" (outlined). + static const IconData turn_sharp_right_outlined = IconData(0xf0686, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turn_slight_left</i> — material icon named "turn slight left". + static const IconData turn_slight_left = IconData(0xf0593, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turn_slight_left</i> — material icon named "turn slight left" (sharp). + static const IconData turn_slight_left_sharp = IconData(0xf0499, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turn_slight_left</i> — material icon named "turn slight left" (round). + static const IconData turn_slight_left_rounded = IconData(0xf03a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turn_slight_left</i> — material icon named "turn slight left" (outlined). + static const IconData turn_slight_left_outlined = IconData(0xf0687, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turn_slight_right</i> — material icon named "turn slight right". + static const IconData turn_slight_right = IconData(0xf0594, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turn_slight_right</i> — material icon named "turn slight right" (sharp). + static const IconData turn_slight_right_sharp = IconData(0xf049a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turn_slight_right</i> — material icon named "turn slight right" (round). + static const IconData turn_slight_right_rounded = IconData(0xf03a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turn_slight_right</i> — material icon named "turn slight right" (outlined). + static const IconData turn_slight_right_outlined = IconData(0xf0688, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turned_in</i> — material icon named "turned in". + static const IconData turned_in = IconData(0xe685, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turned_in</i> — material icon named "turned in" (sharp). + static const IconData turned_in_sharp = IconData(0xed7c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turned_in</i> — material icon named "turned in" (round). + static const IconData turned_in_rounded = IconData(0xf025b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turned_in</i> — material icon named "turned in" (outlined). + static const IconData turned_in_outlined = IconData(0xf469, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">turned_in_not</i> — material icon named "turned in not". + static const IconData turned_in_not = IconData(0xe686, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">turned_in_not</i> — material icon named "turned in not" (sharp). + static const IconData turned_in_not_sharp = IconData(0xed7b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">turned_in_not</i> — material icon named "turned in not" (round). + static const IconData turned_in_not_rounded = IconData(0xf025a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">turned_in_not</i> — material icon named "turned in not" (outlined). + static const IconData turned_in_not_outlined = IconData(0xf468, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tv</i> — material icon named "tv". + static const IconData tv = IconData(0xe687, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tv</i> — material icon named "tv" (sharp). + static const IconData tv_sharp = IconData(0xed7e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tv</i> — material icon named "tv" (round). + static const IconData tv_rounded = IconData(0xf025d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tv</i> — material icon named "tv" (outlined). + static const IconData tv_outlined = IconData(0xf46b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">tv_off</i> — material icon named "tv off". + static const IconData tv_off = IconData(0xe688, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">tv_off</i> — material icon named "tv off" (sharp). + static const IconData tv_off_sharp = IconData(0xed7d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">tv_off</i> — material icon named "tv off" (round). + static const IconData tv_off_rounded = IconData(0xf025c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">tv_off</i> — material icon named "tv off" (outlined). + static const IconData tv_off_outlined = IconData(0xf46a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">two_wheeler</i> — material icon named "two wheeler". + static const IconData two_wheeler = IconData(0xe689, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">two_wheeler</i> — material icon named "two wheeler" (sharp). + static const IconData two_wheeler_sharp = IconData(0xed7f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">two_wheeler</i> — material icon named "two wheeler" (round). + static const IconData two_wheeler_rounded = IconData(0xf025e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">two_wheeler</i> — material icon named "two wheeler" (outlined). + static const IconData two_wheeler_outlined = IconData(0xf46c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">type_specimen</i> — material icon named "type specimen". + static const IconData type_specimen = IconData(0xf07d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">type_specimen</i> — material icon named "type specimen" (sharp). + static const IconData type_specimen_sharp = IconData(0xf0778, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">type_specimen</i> — material icon named "type specimen" (round). + static const IconData type_specimen_rounded = IconData(0xf0828, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">type_specimen</i> — material icon named "type specimen" (outlined). + static const IconData type_specimen_outlined = IconData(0xf0720, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">u_turn_left</i> — material icon named "u turn left". + static const IconData u_turn_left = IconData(0xf0595, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">u_turn_left</i> — material icon named "u turn left" (sharp). + static const IconData u_turn_left_sharp = IconData(0xf049b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">u_turn_left</i> — material icon named "u turn left" (round). + static const IconData u_turn_left_rounded = IconData(0xf03a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">u_turn_left</i> — material icon named "u turn left" (outlined). + static const IconData u_turn_left_outlined = IconData(0xf0689, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">u_turn_right</i> — material icon named "u turn right". + static const IconData u_turn_right = IconData(0xf0596, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">u_turn_right</i> — material icon named "u turn right" (sharp). + static const IconData u_turn_right_sharp = IconData(0xf049c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">u_turn_right</i> — material icon named "u turn right" (round). + static const IconData u_turn_right_rounded = IconData(0xf03a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">u_turn_right</i> — material icon named "u turn right" (outlined). + static const IconData u_turn_right_outlined = IconData(0xf068a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">umbrella</i> — material icon named "umbrella". + static const IconData umbrella = IconData(0xe68a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">umbrella</i> — material icon named "umbrella" (sharp). + static const IconData umbrella_sharp = IconData(0xed80, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">umbrella</i> — material icon named "umbrella" (round). + static const IconData umbrella_rounded = IconData(0xf025f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">umbrella</i> — material icon named "umbrella" (outlined). + static const IconData umbrella_outlined = IconData(0xf46d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">unarchive</i> — material icon named "unarchive". + static const IconData unarchive = IconData(0xe68b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unarchive</i> — material icon named "unarchive" (sharp). + static const IconData unarchive_sharp = IconData(0xed81, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unarchive</i> — material icon named "unarchive" (round). + static const IconData unarchive_rounded = IconData(0xf0260, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unarchive</i> — material icon named "unarchive" (outlined). + static const IconData unarchive_outlined = IconData(0xf46e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">undo</i> — material icon named "undo". + static const IconData undo = IconData( + 0xe68c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">undo</i> — material icon named "undo" (sharp). + static const IconData undo_sharp = IconData( + 0xed82, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">undo</i> — material icon named "undo" (round). + static const IconData undo_rounded = IconData( + 0xf0261, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">undo</i> — material icon named "undo" (outlined). + static const IconData undo_outlined = IconData( + 0xf46f, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">unfold_less</i> — material icon named "unfold less". + static const IconData unfold_less = IconData(0xe68d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unfold_less</i> — material icon named "unfold less" (sharp). + static const IconData unfold_less_sharp = IconData(0xed83, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unfold_less</i> — material icon named "unfold less" (round). + static const IconData unfold_less_rounded = IconData(0xf0262, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unfold_less</i> — material icon named "unfold less" (outlined). + static const IconData unfold_less_outlined = IconData(0xf470, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">unfold_less_double</i> — material icon named "unfold less double". + static const IconData unfold_less_double = IconData(0xf0879, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unfold_less_double</i> — material icon named "unfold less double" (sharp). + static const IconData unfold_less_double_sharp = IconData(0xf084d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unfold_less_double</i> — material icon named "unfold less double" (round). + static const IconData unfold_less_double_rounded = IconData(0xf0896, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unfold_less_double</i> — material icon named "unfold less double" (outlined). + static const IconData unfold_less_double_outlined = IconData( + 0xf08b4, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">unfold_more</i> — material icon named "unfold more". + static const IconData unfold_more = IconData(0xe68e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unfold_more</i> — material icon named "unfold more" (sharp). + static const IconData unfold_more_sharp = IconData(0xed84, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unfold_more</i> — material icon named "unfold more" (round). + static const IconData unfold_more_rounded = IconData(0xf0263, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unfold_more</i> — material icon named "unfold more" (outlined). + static const IconData unfold_more_outlined = IconData(0xf471, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">unfold_more_double</i> — material icon named "unfold more double". + static const IconData unfold_more_double = IconData(0xf087a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unfold_more_double</i> — material icon named "unfold more double" (sharp). + static const IconData unfold_more_double_sharp = IconData(0xf084e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unfold_more_double</i> — material icon named "unfold more double" (round). + static const IconData unfold_more_double_rounded = IconData(0xf0897, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unfold_more_double</i> — material icon named "unfold more double" (outlined). + static const IconData unfold_more_double_outlined = IconData( + 0xf08b5, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">unpublished</i> — material icon named "unpublished". + static const IconData unpublished = IconData(0xe68f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unpublished</i> — material icon named "unpublished" (sharp). + static const IconData unpublished_sharp = IconData(0xed85, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unpublished</i> — material icon named "unpublished" (round). + static const IconData unpublished_rounded = IconData(0xf0264, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unpublished</i> — material icon named "unpublished" (outlined). + static const IconData unpublished_outlined = IconData(0xf472, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">unsubscribe</i> — material icon named "unsubscribe". + static const IconData unsubscribe = IconData(0xe690, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">unsubscribe</i> — material icon named "unsubscribe" (sharp). + static const IconData unsubscribe_sharp = IconData(0xed86, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">unsubscribe</i> — material icon named "unsubscribe" (round). + static const IconData unsubscribe_rounded = IconData(0xf0265, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">unsubscribe</i> — material icon named "unsubscribe" (outlined). + static const IconData unsubscribe_outlined = IconData(0xf473, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">upcoming</i> — material icon named "upcoming". + static const IconData upcoming = IconData(0xe691, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">upcoming</i> — material icon named "upcoming" (sharp). + static const IconData upcoming_sharp = IconData(0xed87, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">upcoming</i> — material icon named "upcoming" (round). + static const IconData upcoming_rounded = IconData(0xf0266, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">upcoming</i> — material icon named "upcoming" (outlined). + static const IconData upcoming_outlined = IconData(0xf474, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">update</i> — material icon named "update". + static const IconData update = IconData(0xe692, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">update</i> — material icon named "update" (sharp). + static const IconData update_sharp = IconData(0xed89, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">update</i> — material icon named "update" (round). + static const IconData update_rounded = IconData(0xf0268, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">update</i> — material icon named "update" (outlined). + static const IconData update_outlined = IconData(0xf476, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">update_disabled</i> — material icon named "update disabled". + static const IconData update_disabled = IconData(0xe693, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">update_disabled</i> — material icon named "update disabled" (sharp). + static const IconData update_disabled_sharp = IconData(0xed88, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">update_disabled</i> — material icon named "update disabled" (round). + static const IconData update_disabled_rounded = IconData(0xf0267, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">update_disabled</i> — material icon named "update disabled" (outlined). + static const IconData update_disabled_outlined = IconData(0xf475, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">upgrade</i> — material icon named "upgrade". + static const IconData upgrade = IconData(0xe694, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">upgrade</i> — material icon named "upgrade" (sharp). + static const IconData upgrade_sharp = IconData(0xed8a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">upgrade</i> — material icon named "upgrade" (round). + static const IconData upgrade_rounded = IconData(0xf0269, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">upgrade</i> — material icon named "upgrade" (outlined). + static const IconData upgrade_outlined = IconData(0xf477, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">upload</i> — material icon named "upload". + static const IconData upload = IconData(0xe695, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">upload</i> — material icon named "upload" (sharp). + static const IconData upload_sharp = IconData(0xed8c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">upload</i> — material icon named "upload" (round). + static const IconData upload_rounded = IconData(0xf026b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">upload</i> — material icon named "upload" (outlined). + static const IconData upload_outlined = IconData(0xf479, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">upload_file</i> — material icon named "upload file". + static const IconData upload_file = IconData(0xe696, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">upload_file</i> — material icon named "upload file" (sharp). + static const IconData upload_file_sharp = IconData(0xed8b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">upload_file</i> — material icon named "upload file" (round). + static const IconData upload_file_rounded = IconData(0xf026a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">upload_file</i> — material icon named "upload file" (outlined). + static const IconData upload_file_outlined = IconData(0xf478, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">usb</i> — material icon named "usb". + static const IconData usb = IconData(0xe697, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">usb</i> — material icon named "usb" (sharp). + static const IconData usb_sharp = IconData(0xed8e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">usb</i> — material icon named "usb" (round). + static const IconData usb_rounded = IconData(0xf026d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">usb</i> — material icon named "usb" (outlined). + static const IconData usb_outlined = IconData(0xf47b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">usb_off</i> — material icon named "usb off". + static const IconData usb_off = IconData(0xe698, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">usb_off</i> — material icon named "usb off" (sharp). + static const IconData usb_off_sharp = IconData(0xed8d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">usb_off</i> — material icon named "usb off" (round). + static const IconData usb_off_rounded = IconData(0xf026c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">usb_off</i> — material icon named "usb off" (outlined). + static const IconData usb_off_outlined = IconData(0xf47a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vaccines</i> — material icon named "vaccines". + static const IconData vaccines = IconData(0xf0597, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vaccines</i> — material icon named "vaccines" (sharp). + static const IconData vaccines_sharp = IconData(0xf049d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vaccines</i> — material icon named "vaccines" (round). + static const IconData vaccines_rounded = IconData(0xf03aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vaccines</i> — material icon named "vaccines" (outlined). + static const IconData vaccines_outlined = IconData(0xf068b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vape_free</i> — material icon named "vape free". + static const IconData vape_free = IconData(0xf06c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vape_free</i> — material icon named "vape free" (sharp). + static const IconData vape_free_sharp = IconData(0xf06b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vape_free</i> — material icon named "vape free" (round). + static const IconData vape_free_rounded = IconData(0xf06d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vape_free</i> — material icon named "vape free" (outlined). + static const IconData vape_free_outlined = IconData(0xf06ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vaping_rooms</i> — material icon named "vaping rooms". + static const IconData vaping_rooms = IconData(0xf06c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vaping_rooms</i> — material icon named "vaping rooms" (sharp). + static const IconData vaping_rooms_sharp = IconData(0xf06b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vaping_rooms</i> — material icon named "vaping rooms" (round). + static const IconData vaping_rooms_rounded = IconData(0xf06d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vaping_rooms</i> — material icon named "vaping rooms" (outlined). + static const IconData vaping_rooms_outlined = IconData(0xf06ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">verified</i> — material icon named "verified". + static const IconData verified = IconData(0xe699, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">verified</i> — material icon named "verified" (sharp). + static const IconData verified_sharp = IconData(0xed8f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">verified</i> — material icon named "verified" (round). + static const IconData verified_rounded = IconData(0xf026e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">verified</i> — material icon named "verified" (outlined). + static const IconData verified_outlined = IconData(0xf47c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">verified_user</i> — material icon named "verified user". + static const IconData verified_user = IconData(0xe69a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">verified_user</i> — material icon named "verified user" (sharp). + static const IconData verified_user_sharp = IconData(0xed90, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">verified_user</i> — material icon named "verified user" (round). + static const IconData verified_user_rounded = IconData(0xf026f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">verified_user</i> — material icon named "verified user" (outlined). + static const IconData verified_user_outlined = IconData(0xf47d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vertical_align_bottom</i> — material icon named "vertical align bottom". + static const IconData vertical_align_bottom = IconData(0xe69b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_align_bottom</i> — material icon named "vertical align bottom" (sharp). + static const IconData vertical_align_bottom_sharp = IconData(0xed91, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vertical_align_bottom</i> — material icon named "vertical align bottom" (round). + static const IconData vertical_align_bottom_rounded = IconData( + 0xf0270, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">vertical_align_bottom</i> — material icon named "vertical align bottom" (outlined). + static const IconData vertical_align_bottom_outlined = IconData( + 0xf47e, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">vertical_align_center</i> — material icon named "vertical align center". + static const IconData vertical_align_center = IconData(0xe69c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_align_center</i> — material icon named "vertical align center" (sharp). + static const IconData vertical_align_center_sharp = IconData(0xed92, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vertical_align_center</i> — material icon named "vertical align center" (round). + static const IconData vertical_align_center_rounded = IconData( + 0xf0271, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">vertical_align_center</i> — material icon named "vertical align center" (outlined). + static const IconData vertical_align_center_outlined = IconData( + 0xf47f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">vertical_align_top</i> — material icon named "vertical align top". + static const IconData vertical_align_top = IconData(0xe69d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_align_top</i> — material icon named "vertical align top" (sharp). + static const IconData vertical_align_top_sharp = IconData(0xed93, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vertical_align_top</i> — material icon named "vertical align top" (round). + static const IconData vertical_align_top_rounded = IconData(0xf0272, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vertical_align_top</i> — material icon named "vertical align top" (outlined). + static const IconData vertical_align_top_outlined = IconData(0xf480, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vertical_distribute</i> — material icon named "vertical distribute". + static const IconData vertical_distribute = IconData(0xe69e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_distribute</i> — material icon named "vertical distribute" (sharp). + static const IconData vertical_distribute_sharp = IconData(0xed94, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vertical_distribute</i> — material icon named "vertical distribute" (round). + static const IconData vertical_distribute_rounded = IconData( + 0xf0273, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">vertical_distribute</i> — material icon named "vertical distribute" (outlined). + static const IconData vertical_distribute_outlined = IconData( + 0xf481, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">vertical_shades</i> — material icon named "vertical shades". + static const IconData vertical_shades = IconData(0xf07d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_shades</i> — material icon named "vertical shades" (sharp). + static const IconData vertical_shades_sharp = IconData(0xf077a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vertical_shades</i> — material icon named "vertical shades" (round). + static const IconData vertical_shades_rounded = IconData(0xf082a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vertical_shades</i> — material icon named "vertical shades" (outlined). + static const IconData vertical_shades_outlined = IconData(0xf0722, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vertical_shades_closed</i> — material icon named "vertical shades closed". + static const IconData vertical_shades_closed = IconData(0xf07d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_shades_closed</i> — material icon named "vertical shades closed" (sharp). + static const IconData vertical_shades_closed_sharp = IconData( + 0xf0779, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">vertical_shades_closed</i> — material icon named "vertical shades closed" (round). + static const IconData vertical_shades_closed_rounded = IconData( + 0xf0829, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">vertical_shades_closed</i> — material icon named "vertical shades closed" (outlined). + static const IconData vertical_shades_closed_outlined = IconData( + 0xf0721, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">vertical_split</i> — material icon named "vertical split". + static const IconData vertical_split = IconData(0xe69f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vertical_split</i> — material icon named "vertical split" (sharp). + static const IconData vertical_split_sharp = IconData(0xed95, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vertical_split</i> — material icon named "vertical split" (round). + static const IconData vertical_split_rounded = IconData(0xf0274, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vertical_split</i> — material icon named "vertical split" (outlined). + static const IconData vertical_split_outlined = IconData(0xf482, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vibration</i> — material icon named "vibration". + static const IconData vibration = IconData(0xe6a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vibration</i> — material icon named "vibration" (sharp). + static const IconData vibration_sharp = IconData(0xed96, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vibration</i> — material icon named "vibration" (round). + static const IconData vibration_rounded = IconData(0xf0275, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vibration</i> — material icon named "vibration" (outlined). + static const IconData vibration_outlined = IconData(0xf483, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_call</i> — material icon named "video call". + static const IconData video_call = IconData(0xe6a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_call</i> — material icon named "video call" (sharp). + static const IconData video_call_sharp = IconData(0xed97, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_call</i> — material icon named "video call" (round). + static const IconData video_call_rounded = IconData(0xf0276, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_call</i> — material icon named "video call" (outlined). + static const IconData video_call_outlined = IconData(0xf484, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_camera_back</i> — material icon named "video camera back". + static const IconData video_camera_back = IconData(0xe6a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_camera_back</i> — material icon named "video camera back" (sharp). + static const IconData video_camera_back_sharp = IconData(0xed98, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_camera_back</i> — material icon named "video camera back" (round). + static const IconData video_camera_back_rounded = IconData(0xf0277, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_camera_back</i> — material icon named "video camera back" (outlined). + static const IconData video_camera_back_outlined = IconData(0xf485, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_camera_front</i> — material icon named "video camera front". + static const IconData video_camera_front = IconData(0xe6a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_camera_front</i> — material icon named "video camera front" (sharp). + static const IconData video_camera_front_sharp = IconData(0xed99, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_camera_front</i> — material icon named "video camera front" (round). + static const IconData video_camera_front_rounded = IconData(0xf0278, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_camera_front</i> — material icon named "video camera front" (outlined). + static const IconData video_camera_front_outlined = IconData(0xf486, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_chat</i> — material icon named "video chat". + static const IconData video_chat = IconData(0xf087b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_chat</i> — material icon named "video chat" (sharp). + static const IconData video_chat_sharp = IconData(0xf084f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_chat</i> — material icon named "video chat" (round). + static const IconData video_chat_rounded = IconData(0xf0898, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_chat</i> — material icon named "video chat" (outlined). + static const IconData video_chat_outlined = IconData(0xf08b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_collection</i> — material icon named "video collection". + static const IconData video_collection = IconData(0xe6a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_collection</i> — material icon named "video collection" (sharp). + static const IconData video_collection_sharp = IconData(0xed9b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_collection</i> — material icon named "video collection" (round). + static const IconData video_collection_rounded = IconData(0xf027a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_collection</i> — material icon named "video collection" (outlined). + static const IconData video_collection_outlined = IconData(0xf488, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_file</i> — material icon named "video file". + static const IconData video_file = IconData(0xf0598, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_file</i> — material icon named "video file" (sharp). + static const IconData video_file_sharp = IconData(0xf049e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_file</i> — material icon named "video file" (round). + static const IconData video_file_rounded = IconData(0xf03ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_file</i> — material icon named "video file" (outlined). + static const IconData video_file_outlined = IconData(0xf068c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_label</i> — material icon named "video label". + static const IconData video_label = IconData(0xe6a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_label</i> — material icon named "video label" (sharp). + static const IconData video_label_sharp = IconData(0xed9a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_label</i> — material icon named "video label" (round). + static const IconData video_label_rounded = IconData(0xf0279, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_label</i> — material icon named "video label" (outlined). + static const IconData video_label_outlined = IconData(0xf487, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_library</i> — material icon named "video library". + static const IconData video_library = IconData(0xe6a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_library</i> — material icon named "video library" (sharp). + static const IconData video_library_sharp = IconData(0xed9b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_library</i> — material icon named "video library" (round). + static const IconData video_library_rounded = IconData(0xf027a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_library</i> — material icon named "video library" (outlined). + static const IconData video_library_outlined = IconData(0xf488, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_settings</i> — material icon named "video settings". + static const IconData video_settings = IconData(0xe6a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_settings</i> — material icon named "video settings" (sharp). + static const IconData video_settings_sharp = IconData(0xed9c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_settings</i> — material icon named "video settings" (round). + static const IconData video_settings_rounded = IconData(0xf027b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_settings</i> — material icon named "video settings" (outlined). + static const IconData video_settings_outlined = IconData(0xf489, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">video_stable</i> — material icon named "video stable". + static const IconData video_stable = IconData(0xe6a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">video_stable</i> — material icon named "video stable" (sharp). + static const IconData video_stable_sharp = IconData(0xed9d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">video_stable</i> — material icon named "video stable" (round). + static const IconData video_stable_rounded = IconData(0xf027c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">video_stable</i> — material icon named "video stable" (outlined). + static const IconData video_stable_outlined = IconData(0xf48a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">videocam</i> — material icon named "videocam". + static const IconData videocam = IconData(0xe6a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">videocam</i> — material icon named "videocam" (sharp). + static const IconData videocam_sharp = IconData(0xed9f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">videocam</i> — material icon named "videocam" (round). + static const IconData videocam_rounded = IconData(0xf027e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">videocam</i> — material icon named "videocam" (outlined). + static const IconData videocam_outlined = IconData(0xf48c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">videocam_off</i> — material icon named "videocam off". + static const IconData videocam_off = IconData(0xe6a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">videocam_off</i> — material icon named "videocam off" (sharp). + static const IconData videocam_off_sharp = IconData(0xed9e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">videocam_off</i> — material icon named "videocam off" (round). + static const IconData videocam_off_rounded = IconData(0xf027d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">videocam_off</i> — material icon named "videocam off" (outlined). + static const IconData videocam_off_outlined = IconData(0xf48b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">videogame_asset</i> — material icon named "videogame asset". + static const IconData videogame_asset = IconData(0xe6aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">videogame_asset</i> — material icon named "videogame asset" (sharp). + static const IconData videogame_asset_sharp = IconData(0xeda1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">videogame_asset</i> — material icon named "videogame asset" (round). + static const IconData videogame_asset_rounded = IconData(0xf0280, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">videogame_asset</i> — material icon named "videogame asset" (outlined). + static const IconData videogame_asset_outlined = IconData(0xf48e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">videogame_asset_off</i> — material icon named "videogame asset off". + static const IconData videogame_asset_off = IconData(0xe6ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">videogame_asset_off</i> — material icon named "videogame asset off" (sharp). + static const IconData videogame_asset_off_sharp = IconData(0xeda0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">videogame_asset_off</i> — material icon named "videogame asset off" (round). + static const IconData videogame_asset_off_rounded = IconData( + 0xf027f, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">videogame_asset_off</i> — material icon named "videogame asset off" (outlined). + static const IconData videogame_asset_off_outlined = IconData( + 0xf48d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">view_agenda</i> — material icon named "view agenda". + static const IconData view_agenda = IconData(0xe6ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_agenda</i> — material icon named "view agenda" (sharp). + static const IconData view_agenda_sharp = IconData(0xeda2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_agenda</i> — material icon named "view agenda" (round). + static const IconData view_agenda_rounded = IconData(0xf0281, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_agenda</i> — material icon named "view agenda" (outlined). + static const IconData view_agenda_outlined = IconData(0xf48f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_array</i> — material icon named "view array". + static const IconData view_array = IconData(0xe6ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_array</i> — material icon named "view array" (sharp). + static const IconData view_array_sharp = IconData(0xeda3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_array</i> — material icon named "view array" (round). + static const IconData view_array_rounded = IconData(0xf0282, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_array</i> — material icon named "view array" (outlined). + static const IconData view_array_outlined = IconData(0xf490, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_carousel</i> — material icon named "view carousel". + static const IconData view_carousel = IconData(0xe6ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_carousel</i> — material icon named "view carousel" (sharp). + static const IconData view_carousel_sharp = IconData(0xeda4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_carousel</i> — material icon named "view carousel" (round). + static const IconData view_carousel_rounded = IconData(0xf0283, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_carousel</i> — material icon named "view carousel" (outlined). + static const IconData view_carousel_outlined = IconData(0xf491, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_column</i> — material icon named "view column". + static const IconData view_column = IconData(0xe6af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_column</i> — material icon named "view column" (sharp). + static const IconData view_column_sharp = IconData(0xeda5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_column</i> — material icon named "view column" (round). + static const IconData view_column_rounded = IconData(0xf0284, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_column</i> — material icon named "view column" (outlined). + static const IconData view_column_outlined = IconData(0xf492, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_comfortable</i> — material icon named "view comfortable". + static const IconData view_comfortable = IconData(0xe6b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_comfortable</i> — material icon named "view comfortable" (sharp). + static const IconData view_comfortable_sharp = IconData(0xeda6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_comfortable</i> — material icon named "view comfortable" (round). + static const IconData view_comfortable_rounded = IconData(0xf0285, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_comfortable</i> — material icon named "view comfortable" (outlined). + static const IconData view_comfortable_outlined = IconData(0xf493, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_comfy</i> — material icon named "view comfy". + static const IconData view_comfy = IconData(0xe6b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_comfy</i> — material icon named "view comfy" (sharp). + static const IconData view_comfy_sharp = IconData(0xeda6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_comfy</i> — material icon named "view comfy" (round). + static const IconData view_comfy_rounded = IconData(0xf0285, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_comfy</i> — material icon named "view comfy" (outlined). + static const IconData view_comfy_outlined = IconData(0xf493, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_comfy_alt</i> — material icon named "view comfy alt". + static const IconData view_comfy_alt = IconData(0xf0599, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_comfy_alt</i> — material icon named "view comfy alt" (sharp). + static const IconData view_comfy_alt_sharp = IconData(0xf049f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_comfy_alt</i> — material icon named "view comfy alt" (round). + static const IconData view_comfy_alt_rounded = IconData(0xf03ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_comfy_alt</i> — material icon named "view comfy alt" (outlined). + static const IconData view_comfy_alt_outlined = IconData(0xf068d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_compact</i> — material icon named "view compact". + static const IconData view_compact = IconData(0xe6b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_compact</i> — material icon named "view compact" (sharp). + static const IconData view_compact_sharp = IconData(0xeda7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_compact</i> — material icon named "view compact" (round). + static const IconData view_compact_rounded = IconData(0xf0286, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_compact</i> — material icon named "view compact" (outlined). + static const IconData view_compact_outlined = IconData(0xf494, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_compact_alt</i> — material icon named "view compact alt". + static const IconData view_compact_alt = IconData(0xf059a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_compact_alt</i> — material icon named "view compact alt" (sharp). + static const IconData view_compact_alt_sharp = IconData(0xf04a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_compact_alt</i> — material icon named "view compact alt" (round). + static const IconData view_compact_alt_rounded = IconData(0xf03ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_compact_alt</i> — material icon named "view compact alt" (outlined). + static const IconData view_compact_alt_outlined = IconData(0xf068e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_cozy</i> — material icon named "view cozy". + static const IconData view_cozy = IconData(0xf059b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_cozy</i> — material icon named "view cozy" (sharp). + static const IconData view_cozy_sharp = IconData(0xf04a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_cozy</i> — material icon named "view cozy" (round). + static const IconData view_cozy_rounded = IconData(0xf03ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_cozy</i> — material icon named "view cozy" (outlined). + static const IconData view_cozy_outlined = IconData(0xf068f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_day</i> — material icon named "view day". + static const IconData view_day = IconData(0xe6b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_day</i> — material icon named "view day" (sharp). + static const IconData view_day_sharp = IconData(0xeda8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_day</i> — material icon named "view day" (round). + static const IconData view_day_rounded = IconData(0xf0287, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_day</i> — material icon named "view day" (outlined). + static const IconData view_day_outlined = IconData(0xf495, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_headline</i> — material icon named "view headline". + static const IconData view_headline = IconData(0xe6b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_headline</i> — material icon named "view headline" (sharp). + static const IconData view_headline_sharp = IconData(0xeda9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_headline</i> — material icon named "view headline" (round). + static const IconData view_headline_rounded = IconData(0xf0288, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_headline</i> — material icon named "view headline" (outlined). + static const IconData view_headline_outlined = IconData(0xf496, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_in_ar</i> — material icon named "view in ar". + static const IconData view_in_ar = IconData(0xe6b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_in_ar</i> — material icon named "view in ar" (sharp). + static const IconData view_in_ar_sharp = IconData(0xedaa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_in_ar</i> — material icon named "view in ar" (round). + static const IconData view_in_ar_rounded = IconData(0xf0289, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_in_ar</i> — material icon named "view in ar" (outlined). + static const IconData view_in_ar_outlined = IconData(0xf497, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_kanban</i> — material icon named "view kanban". + static const IconData view_kanban = IconData(0xf059c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_kanban</i> — material icon named "view kanban" (sharp). + static const IconData view_kanban_sharp = IconData(0xf04a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_kanban</i> — material icon named "view kanban" (round). + static const IconData view_kanban_rounded = IconData(0xf03af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_kanban</i> — material icon named "view kanban" (outlined). + static const IconData view_kanban_outlined = IconData(0xf0690, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_list</i> — material icon named "view list". + static const IconData view_list = IconData( + 0xe6b5, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">view_list</i> — material icon named "view list" (sharp). + static const IconData view_list_sharp = IconData( + 0xedab, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">view_list</i> — material icon named "view list" (round). + static const IconData view_list_rounded = IconData( + 0xf028a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">view_list</i> — material icon named "view list" (outlined). + static const IconData view_list_outlined = IconData( + 0xf498, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">view_module</i> — material icon named "view module". + static const IconData view_module = IconData(0xe6b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_module</i> — material icon named "view module" (sharp). + static const IconData view_module_sharp = IconData(0xedac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_module</i> — material icon named "view module" (round). + static const IconData view_module_rounded = IconData(0xf028b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_module</i> — material icon named "view module" (outlined). + static const IconData view_module_outlined = IconData(0xf499, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_quilt</i> — material icon named "view quilt". + static const IconData view_quilt = IconData( + 0xe6b7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">view_quilt</i> — material icon named "view quilt" (sharp). + static const IconData view_quilt_sharp = IconData( + 0xedad, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">view_quilt</i> — material icon named "view quilt" (round). + static const IconData view_quilt_rounded = IconData( + 0xf028c, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">view_quilt</i> — material icon named "view quilt" (outlined). + static const IconData view_quilt_outlined = IconData( + 0xf49a, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">view_sidebar</i> — material icon named "view sidebar". + static const IconData view_sidebar = IconData(0xe6b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_sidebar</i> — material icon named "view sidebar" (sharp). + static const IconData view_sidebar_sharp = IconData(0xedae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_sidebar</i> — material icon named "view sidebar" (round). + static const IconData view_sidebar_rounded = IconData(0xf028d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_sidebar</i> — material icon named "view sidebar" (outlined). + static const IconData view_sidebar_outlined = IconData(0xf49b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_stream</i> — material icon named "view stream". + static const IconData view_stream = IconData(0xe6b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_stream</i> — material icon named "view stream" (sharp). + static const IconData view_stream_sharp = IconData(0xedaf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_stream</i> — material icon named "view stream" (round). + static const IconData view_stream_rounded = IconData(0xf028e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_stream</i> — material icon named "view stream" (outlined). + static const IconData view_stream_outlined = IconData(0xf49c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_timeline</i> — material icon named "view timeline". + static const IconData view_timeline = IconData(0xf059d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_timeline</i> — material icon named "view timeline" (sharp). + static const IconData view_timeline_sharp = IconData(0xf04a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_timeline</i> — material icon named "view timeline" (round). + static const IconData view_timeline_rounded = IconData(0xf03b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_timeline</i> — material icon named "view timeline" (outlined). + static const IconData view_timeline_outlined = IconData(0xf0691, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">view_week</i> — material icon named "view week". + static const IconData view_week = IconData(0xe6ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">view_week</i> — material icon named "view week" (sharp). + static const IconData view_week_sharp = IconData(0xedb0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">view_week</i> — material icon named "view week" (round). + static const IconData view_week_rounded = IconData(0xf028f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">view_week</i> — material icon named "view week" (outlined). + static const IconData view_week_outlined = IconData(0xf49d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vignette</i> — material icon named "vignette". + static const IconData vignette = IconData(0xe6bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vignette</i> — material icon named "vignette" (sharp). + static const IconData vignette_sharp = IconData(0xedb1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vignette</i> — material icon named "vignette" (round). + static const IconData vignette_rounded = IconData(0xf0290, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vignette</i> — material icon named "vignette" (outlined). + static const IconData vignette_outlined = IconData(0xf49e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">villa</i> — material icon named "villa". + static const IconData villa = IconData(0xe6bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">villa</i> — material icon named "villa" (sharp). + static const IconData villa_sharp = IconData(0xedb2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">villa</i> — material icon named "villa" (round). + static const IconData villa_rounded = IconData(0xf0291, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">villa</i> — material icon named "villa" (outlined). + static const IconData villa_outlined = IconData(0xf49f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">visibility</i> — material icon named "visibility". + static const IconData visibility = IconData(0xe6bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">visibility</i> — material icon named "visibility" (sharp). + static const IconData visibility_sharp = IconData(0xedb4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">visibility</i> — material icon named "visibility" (round). + static const IconData visibility_rounded = IconData(0xf0293, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">visibility</i> — material icon named "visibility" (outlined). + static const IconData visibility_outlined = IconData(0xf4a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">visibility_off</i> — material icon named "visibility off". + static const IconData visibility_off = IconData(0xe6be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">visibility_off</i> — material icon named "visibility off" (sharp). + static const IconData visibility_off_sharp = IconData(0xedb3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">visibility_off</i> — material icon named "visibility off" (round). + static const IconData visibility_off_rounded = IconData(0xf0292, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">visibility_off</i> — material icon named "visibility off" (outlined). + static const IconData visibility_off_outlined = IconData(0xf4a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">voice_chat</i> — material icon named "voice chat". + static const IconData voice_chat = IconData(0xe6bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">voice_chat</i> — material icon named "voice chat" (sharp). + static const IconData voice_chat_sharp = IconData(0xedb5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">voice_chat</i> — material icon named "voice chat" (round). + static const IconData voice_chat_rounded = IconData(0xf0294, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">voice_chat</i> — material icon named "voice chat" (outlined). + static const IconData voice_chat_outlined = IconData(0xf4a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">voice_over_off</i> — material icon named "voice over off". + static const IconData voice_over_off = IconData(0xe6c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">voice_over_off</i> — material icon named "voice over off" (sharp). + static const IconData voice_over_off_sharp = IconData(0xedb6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">voice_over_off</i> — material icon named "voice over off" (round). + static const IconData voice_over_off_rounded = IconData(0xf0295, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">voice_over_off</i> — material icon named "voice over off" (outlined). + static const IconData voice_over_off_outlined = IconData(0xf4a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">voicemail</i> — material icon named "voicemail". + static const IconData voicemail = IconData(0xe6c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">voicemail</i> — material icon named "voicemail" (sharp). + static const IconData voicemail_sharp = IconData(0xedb7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">voicemail</i> — material icon named "voicemail" (round). + static const IconData voicemail_rounded = IconData(0xf0296, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">voicemail</i> — material icon named "voicemail" (outlined). + static const IconData voicemail_outlined = IconData(0xf4a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volcano</i> — material icon named "volcano". + static const IconData volcano = IconData(0xf07d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">volcano</i> — material icon named "volcano" (sharp). + static const IconData volcano_sharp = IconData(0xf077b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">volcano</i> — material icon named "volcano" (round). + static const IconData volcano_rounded = IconData(0xf082b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">volcano</i> — material icon named "volcano" (outlined). + static const IconData volcano_outlined = IconData(0xf0723, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volume_down</i> — material icon named "volume down". + static const IconData volume_down = IconData(0xe6c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">volume_down</i> — material icon named "volume down" (sharp). + static const IconData volume_down_sharp = IconData(0xedb8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">volume_down</i> — material icon named "volume down" (round). + static const IconData volume_down_rounded = IconData(0xf0297, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">volume_down</i> — material icon named "volume down" (outlined). + static const IconData volume_down_outlined = IconData(0xf4a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volume_down_alt</i> — material icon named "volume down alt". + static const IconData volume_down_alt = IconData(0xf059e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volume_mute</i> — material icon named "volume mute". + static const IconData volume_mute = IconData(0xe6c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">volume_mute</i> — material icon named "volume mute" (sharp). + static const IconData volume_mute_sharp = IconData(0xedb9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">volume_mute</i> — material icon named "volume mute" (round). + static const IconData volume_mute_rounded = IconData(0xf0298, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">volume_mute</i> — material icon named "volume mute" (outlined). + static const IconData volume_mute_outlined = IconData(0xf4a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volume_off</i> — material icon named "volume off". + static const IconData volume_off = IconData(0xe6c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">volume_off</i> — material icon named "volume off" (sharp). + static const IconData volume_off_sharp = IconData(0xedba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">volume_off</i> — material icon named "volume off" (round). + static const IconData volume_off_rounded = IconData(0xf0299, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">volume_off</i> — material icon named "volume off" (outlined). + static const IconData volume_off_outlined = IconData(0xf4a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volume_up</i> — material icon named "volume up". + static const IconData volume_up = IconData(0xe6c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">volume_up</i> — material icon named "volume up" (sharp). + static const IconData volume_up_sharp = IconData(0xedbb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">volume_up</i> — material icon named "volume up" (round). + static const IconData volume_up_rounded = IconData(0xf029a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">volume_up</i> — material icon named "volume up" (outlined). + static const IconData volume_up_outlined = IconData(0xf4a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">volunteer_activism</i> — material icon named "volunteer activism". + static const IconData volunteer_activism = IconData(0xe6c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">volunteer_activism</i> — material icon named "volunteer activism" (sharp). + static const IconData volunteer_activism_sharp = IconData(0xedbc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">volunteer_activism</i> — material icon named "volunteer activism" (round). + static const IconData volunteer_activism_rounded = IconData(0xf029b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">volunteer_activism</i> — material icon named "volunteer activism" (outlined). + static const IconData volunteer_activism_outlined = IconData(0xf4a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vpn_key</i> — material icon named "vpn key". + static const IconData vpn_key = IconData(0xe6c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vpn_key</i> — material icon named "vpn key" (sharp). + static const IconData vpn_key_sharp = IconData(0xedbd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vpn_key</i> — material icon named "vpn key" (round). + static const IconData vpn_key_rounded = IconData(0xf029c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vpn_key</i> — material icon named "vpn key" (outlined). + static const IconData vpn_key_outlined = IconData(0xf4aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vpn_key_off</i> — material icon named "vpn key off". + static const IconData vpn_key_off = IconData(0xf059f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vpn_key_off</i> — material icon named "vpn key off" (sharp). + static const IconData vpn_key_off_sharp = IconData(0xf04a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vpn_key_off</i> — material icon named "vpn key off" (round). + static const IconData vpn_key_off_rounded = IconData(0xf03b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vpn_key_off</i> — material icon named "vpn key off" (outlined). + static const IconData vpn_key_off_outlined = IconData(0xf0692, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vpn_lock</i> — material icon named "vpn lock". + static const IconData vpn_lock = IconData(0xe6c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vpn_lock</i> — material icon named "vpn lock" (sharp). + static const IconData vpn_lock_sharp = IconData(0xedbe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vpn_lock</i> — material icon named "vpn lock" (round). + static const IconData vpn_lock_rounded = IconData(0xf029d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vpn_lock</i> — material icon named "vpn lock" (outlined). + static const IconData vpn_lock_outlined = IconData(0xf4ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">vrpano</i> — material icon named "vrpano". + static const IconData vrpano = IconData(0xe6c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">vrpano</i> — material icon named "vrpano" (sharp). + static const IconData vrpano_sharp = IconData(0xedbf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">vrpano</i> — material icon named "vrpano" (round). + static const IconData vrpano_rounded = IconData(0xf029e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">vrpano</i> — material icon named "vrpano" (outlined). + static const IconData vrpano_outlined = IconData(0xf4ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wallet</i> — material icon named "wallet". + static const IconData wallet = IconData(0xf07d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wallet</i> — material icon named "wallet" (sharp). + static const IconData wallet_sharp = IconData(0xf077c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wallet</i> — material icon named "wallet" (round). + static const IconData wallet_rounded = IconData(0xf082c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wallet</i> — material icon named "wallet" (outlined). + static const IconData wallet_outlined = IconData(0xf0724, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wallet_giftcard</i> — material icon named "wallet giftcard". + static const IconData wallet_giftcard = IconData(0xe13e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wallet_giftcard</i> — material icon named "wallet giftcard" (sharp). + static const IconData wallet_giftcard_sharp = IconData(0xe83b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wallet_giftcard</i> — material icon named "wallet giftcard" (round). + static const IconData wallet_giftcard_rounded = IconData(0xf61a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wallet_giftcard</i> — material icon named "wallet giftcard" (outlined). + static const IconData wallet_giftcard_outlined = IconData(0xef2d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wallet_membership</i> — material icon named "wallet membership". + static const IconData wallet_membership = IconData(0xe13f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wallet_membership</i> — material icon named "wallet membership" (sharp). + static const IconData wallet_membership_sharp = IconData(0xe83c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wallet_membership</i> — material icon named "wallet membership" (round). + static const IconData wallet_membership_rounded = IconData(0xf61b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wallet_membership</i> — material icon named "wallet membership" (outlined). + static const IconData wallet_membership_outlined = IconData(0xef2e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wallet_travel</i> — material icon named "wallet travel". + static const IconData wallet_travel = IconData(0xe140, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wallet_travel</i> — material icon named "wallet travel" (sharp). + static const IconData wallet_travel_sharp = IconData(0xe83d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wallet_travel</i> — material icon named "wallet travel" (round). + static const IconData wallet_travel_rounded = IconData(0xf61c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wallet_travel</i> — material icon named "wallet travel" (outlined). + static const IconData wallet_travel_outlined = IconData(0xef2f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wallpaper</i> — material icon named "wallpaper". + static const IconData wallpaper = IconData(0xe6ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wallpaper</i> — material icon named "wallpaper" (sharp). + static const IconData wallpaper_sharp = IconData(0xedc0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wallpaper</i> — material icon named "wallpaper" (round). + static const IconData wallpaper_rounded = IconData(0xf029f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wallpaper</i> — material icon named "wallpaper" (outlined). + static const IconData wallpaper_outlined = IconData(0xf4ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">warehouse</i> — material icon named "warehouse". + static const IconData warehouse = IconData(0xf05a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">warehouse</i> — material icon named "warehouse" (sharp). + static const IconData warehouse_sharp = IconData(0xf04a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">warehouse</i> — material icon named "warehouse" (round). + static const IconData warehouse_rounded = IconData(0xf03b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">warehouse</i> — material icon named "warehouse" (outlined). + static const IconData warehouse_outlined = IconData(0xf0693, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">warning</i> — material icon named "warning". + static const IconData warning = IconData(0xe6cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">warning</i> — material icon named "warning" (sharp). + static const IconData warning_sharp = IconData(0xedc2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">warning</i> — material icon named "warning" (round). + static const IconData warning_rounded = IconData(0xf02a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">warning</i> — material icon named "warning" (outlined). + static const IconData warning_outlined = IconData(0xf4af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">warning_amber</i> — material icon named "warning amber". + static const IconData warning_amber = IconData(0xe6cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">warning_amber</i> — material icon named "warning amber" (sharp). + static const IconData warning_amber_sharp = IconData(0xedc1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">warning_amber</i> — material icon named "warning amber" (round). + static const IconData warning_amber_rounded = IconData(0xf02a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">warning_amber</i> — material icon named "warning amber" (outlined). + static const IconData warning_amber_outlined = IconData(0xf4ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wash</i> — material icon named "wash". + static const IconData wash = IconData(0xe6cd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wash</i> — material icon named "wash" (sharp). + static const IconData wash_sharp = IconData(0xedc3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wash</i> — material icon named "wash" (round). + static const IconData wash_rounded = IconData(0xf02a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wash</i> — material icon named "wash" (outlined). + static const IconData wash_outlined = IconData(0xf4b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">watch</i> — material icon named "watch". + static const IconData watch = IconData(0xe6ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">watch</i> — material icon named "watch" (sharp). + static const IconData watch_sharp = IconData(0xedc5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">watch</i> — material icon named "watch" (round). + static const IconData watch_rounded = IconData(0xf02a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">watch</i> — material icon named "watch" (outlined). + static const IconData watch_outlined = IconData(0xf4b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">watch_later</i> — material icon named "watch later". + static const IconData watch_later = IconData(0xe6cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">watch_later</i> — material icon named "watch later" (sharp). + static const IconData watch_later_sharp = IconData(0xedc4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">watch_later</i> — material icon named "watch later" (round). + static const IconData watch_later_rounded = IconData(0xf02a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">watch_later</i> — material icon named "watch later" (outlined). + static const IconData watch_later_outlined = IconData(0xf4b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">watch_off</i> — material icon named "watch off". + static const IconData watch_off = IconData(0xf05a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">watch_off</i> — material icon named "watch off" (sharp). + static const IconData watch_off_sharp = IconData(0xf04a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">watch_off</i> — material icon named "watch off" (round). + static const IconData watch_off_rounded = IconData(0xf03b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">watch_off</i> — material icon named "watch off" (outlined). + static const IconData watch_off_outlined = IconData(0xf0694, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">water</i> — material icon named "water". + static const IconData water = IconData(0xe6d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">water</i> — material icon named "water" (sharp). + static const IconData water_sharp = IconData(0xedc7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">water</i> — material icon named "water" (round). + static const IconData water_rounded = IconData(0xf02a6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">water</i> — material icon named "water" (outlined). + static const IconData water_outlined = IconData(0xf4b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">water_damage</i> — material icon named "water damage". + static const IconData water_damage = IconData(0xe6d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">water_damage</i> — material icon named "water damage" (sharp). + static const IconData water_damage_sharp = IconData(0xedc6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">water_damage</i> — material icon named "water damage" (round). + static const IconData water_damage_rounded = IconData(0xf02a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">water_damage</i> — material icon named "water damage" (outlined). + static const IconData water_damage_outlined = IconData(0xf4b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">water_drop</i> — material icon named "water drop". + static const IconData water_drop = IconData(0xf05a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">water_drop</i> — material icon named "water drop" (sharp). + static const IconData water_drop_sharp = IconData(0xf04a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">water_drop</i> — material icon named "water drop" (round). + static const IconData water_drop_rounded = IconData(0xf03b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">water_drop</i> — material icon named "water drop" (outlined). + static const IconData water_drop_outlined = IconData(0xf0695, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">waterfall_chart</i> — material icon named "waterfall chart". + static const IconData waterfall_chart = IconData(0xe6d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">waterfall_chart</i> — material icon named "waterfall chart" (sharp). + static const IconData waterfall_chart_sharp = IconData(0xedc8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">waterfall_chart</i> — material icon named "waterfall chart" (round). + static const IconData waterfall_chart_rounded = IconData(0xf02a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">waterfall_chart</i> — material icon named "waterfall chart" (outlined). + static const IconData waterfall_chart_outlined = IconData(0xf4b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">waves</i> — material icon named "waves". + static const IconData waves = IconData(0xe6d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">waves</i> — material icon named "waves" (sharp). + static const IconData waves_sharp = IconData(0xedc9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">waves</i> — material icon named "waves" (round). + static const IconData waves_rounded = IconData(0xf02a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">waves</i> — material icon named "waves" (outlined). + static const IconData waves_outlined = IconData(0xf4b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">waving_hand</i> — material icon named "waving hand". + static const IconData waving_hand = IconData(0xf05a3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">waving_hand</i> — material icon named "waving hand" (sharp). + static const IconData waving_hand_sharp = IconData(0xf04a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">waving_hand</i> — material icon named "waving hand" (round). + static const IconData waving_hand_rounded = IconData(0xf03b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">waving_hand</i> — material icon named "waving hand" (outlined). + static const IconData waving_hand_outlined = IconData(0xf0696, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_auto</i> — material icon named "wb auto". + static const IconData wb_auto = IconData(0xe6d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_auto</i> — material icon named "wb auto" (sharp). + static const IconData wb_auto_sharp = IconData(0xedca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_auto</i> — material icon named "wb auto" (round). + static const IconData wb_auto_rounded = IconData(0xf02a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_auto</i> — material icon named "wb auto" (outlined). + static const IconData wb_auto_outlined = IconData(0xf4b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_cloudy</i> — material icon named "wb cloudy". + static const IconData wb_cloudy = IconData(0xe6d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_cloudy</i> — material icon named "wb cloudy" (sharp). + static const IconData wb_cloudy_sharp = IconData(0xedcb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_cloudy</i> — material icon named "wb cloudy" (round). + static const IconData wb_cloudy_rounded = IconData(0xf02aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_cloudy</i> — material icon named "wb cloudy" (outlined). + static const IconData wb_cloudy_outlined = IconData(0xf4b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_incandescent</i> — material icon named "wb incandescent". + static const IconData wb_incandescent = IconData(0xe6d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_incandescent</i> — material icon named "wb incandescent" (sharp). + static const IconData wb_incandescent_sharp = IconData(0xedcc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_incandescent</i> — material icon named "wb incandescent" (round). + static const IconData wb_incandescent_rounded = IconData(0xf02ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_incandescent</i> — material icon named "wb incandescent" (outlined). + static const IconData wb_incandescent_outlined = IconData(0xf4b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_iridescent</i> — material icon named "wb iridescent". + static const IconData wb_iridescent = IconData(0xe6d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_iridescent</i> — material icon named "wb iridescent" (sharp). + static const IconData wb_iridescent_sharp = IconData(0xedcd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_iridescent</i> — material icon named "wb iridescent" (round). + static const IconData wb_iridescent_rounded = IconData(0xf02ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_iridescent</i> — material icon named "wb iridescent" (outlined). + static const IconData wb_iridescent_outlined = IconData(0xf4ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_shade</i> — material icon named "wb shade". + static const IconData wb_shade = IconData(0xe6d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_shade</i> — material icon named "wb shade" (sharp). + static const IconData wb_shade_sharp = IconData(0xedce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_shade</i> — material icon named "wb shade" (round). + static const IconData wb_shade_rounded = IconData(0xf02ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_shade</i> — material icon named "wb shade" (outlined). + static const IconData wb_shade_outlined = IconData(0xf4bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_sunny</i> — material icon named "wb sunny". + static const IconData wb_sunny = IconData(0xe6d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_sunny</i> — material icon named "wb sunny" (sharp). + static const IconData wb_sunny_sharp = IconData(0xedcf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_sunny</i> — material icon named "wb sunny" (round). + static const IconData wb_sunny_rounded = IconData(0xf02ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_sunny</i> — material icon named "wb sunny" (outlined). + static const IconData wb_sunny_outlined = IconData(0xf4bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_twighlight</i> — material icon named "wb twighlight". + static const IconData wb_twighlight = IconData(0xe6da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wb_twilight</i> — material icon named "wb twilight". + static const IconData wb_twilight = IconData(0xe6db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wb_twilight</i> — material icon named "wb twilight" (sharp). + static const IconData wb_twilight_sharp = IconData(0xedd0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wb_twilight</i> — material icon named "wb twilight" (round). + static const IconData wb_twilight_rounded = IconData(0xf02af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wb_twilight</i> — material icon named "wb twilight" (outlined). + static const IconData wb_twilight_outlined = IconData(0xf4bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wc</i> — material icon named "wc". + static const IconData wc = IconData(0xe6dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wc</i> — material icon named "wc" (sharp). + static const IconData wc_sharp = IconData(0xedd1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wc</i> — material icon named "wc" (round). + static const IconData wc_rounded = IconData(0xf02b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wc</i> — material icon named "wc" (outlined). + static const IconData wc_outlined = IconData(0xf4be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">web</i> — material icon named "web". + static const IconData web = IconData(0xe6dd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">web</i> — material icon named "web" (sharp). + static const IconData web_sharp = IconData(0xedd4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">web</i> — material icon named "web" (round). + static const IconData web_rounded = IconData(0xf02b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">web</i> — material icon named "web" (outlined). + static const IconData web_outlined = IconData(0xf4c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">web_asset</i> — material icon named "web asset". + static const IconData web_asset = IconData(0xe6de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">web_asset</i> — material icon named "web asset" (sharp). + static const IconData web_asset_sharp = IconData(0xedd3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">web_asset</i> — material icon named "web asset" (round). + static const IconData web_asset_rounded = IconData(0xf02b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">web_asset</i> — material icon named "web asset" (outlined). + static const IconData web_asset_outlined = IconData(0xf4c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">web_asset_off</i> — material icon named "web asset off". + static const IconData web_asset_off = IconData(0xe6df, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">web_asset_off</i> — material icon named "web asset off" (sharp). + static const IconData web_asset_off_sharp = IconData(0xedd2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">web_asset_off</i> — material icon named "web asset off" (round). + static const IconData web_asset_off_rounded = IconData(0xf02b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">web_asset_off</i> — material icon named "web asset off" (outlined). + static const IconData web_asset_off_outlined = IconData(0xf4bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">web_stories</i> — material icon named "web stories". + static const IconData web_stories = IconData(0xe6e0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">web_stories</i> — material icon named "web stories" (sharp). + static const IconData web_stories_sharp = IconData(0xf0850, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">web_stories</i> — material icon named "web stories" (round). + static const IconData web_stories_rounded = IconData(0xf0899, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">web_stories</i> — material icon named "web stories" (outlined). + static const IconData web_stories_outlined = IconData(0xf08b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">webhook</i> — material icon named "webhook". + static const IconData webhook = IconData(0xf05a4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">webhook</i> — material icon named "webhook" (sharp). + static const IconData webhook_sharp = IconData(0xf04a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">webhook</i> — material icon named "webhook" (round). + static const IconData webhook_rounded = IconData(0xf03b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">webhook</i> — material icon named "webhook" (outlined). + static const IconData webhook_outlined = IconData(0xf0697, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wechat</i> — material icon named "wechat". + static const IconData wechat = IconData(0xf05a5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wechat</i> — material icon named "wechat" (sharp). + static const IconData wechat_sharp = IconData(0xf04aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wechat</i> — material icon named "wechat" (round). + static const IconData wechat_rounded = IconData(0xf03b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wechat</i> — material icon named "wechat" (outlined). + static const IconData wechat_outlined = IconData(0xf0698, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">weekend</i> — material icon named "weekend". + static const IconData weekend = IconData(0xe6e1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">weekend</i> — material icon named "weekend" (sharp). + static const IconData weekend_sharp = IconData(0xedd5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">weekend</i> — material icon named "weekend" (round). + static const IconData weekend_rounded = IconData(0xf02b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">weekend</i> — material icon named "weekend" (outlined). + static const IconData weekend_outlined = IconData(0xf4c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">west</i> — material icon named "west". + static const IconData west = IconData(0xe6e2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">west</i> — material icon named "west" (sharp). + static const IconData west_sharp = IconData(0xedd6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">west</i> — material icon named "west" (round). + static const IconData west_rounded = IconData(0xf02b5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">west</i> — material icon named "west" (outlined). + static const IconData west_outlined = IconData(0xf4c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">whatshot</i> — material icon named "whatshot". + static const IconData whatshot = IconData(0xe6e3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">whatshot</i> — material icon named "whatshot" (sharp). + static const IconData whatshot_sharp = IconData(0xedd7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">whatshot</i> — material icon named "whatshot" (round). + static const IconData whatshot_rounded = IconData(0xf02b6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">whatshot</i> — material icon named "whatshot" (outlined). + static const IconData whatshot_outlined = IconData(0xf4c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wheelchair_pickup</i> — material icon named "wheelchair pickup". + static const IconData wheelchair_pickup = IconData(0xe6e4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wheelchair_pickup</i> — material icon named "wheelchair pickup" (sharp). + static const IconData wheelchair_pickup_sharp = IconData(0xedd8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wheelchair_pickup</i> — material icon named "wheelchair pickup" (round). + static const IconData wheelchair_pickup_rounded = IconData(0xf02b7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wheelchair_pickup</i> — material icon named "wheelchair pickup" (outlined). + static const IconData wheelchair_pickup_outlined = IconData(0xf4c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">where_to_vote</i> — material icon named "where to vote". + static const IconData where_to_vote = IconData(0xe6e5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">where_to_vote</i> — material icon named "where to vote" (sharp). + static const IconData where_to_vote_sharp = IconData(0xedd9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">where_to_vote</i> — material icon named "where to vote" (round). + static const IconData where_to_vote_rounded = IconData(0xf02b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">where_to_vote</i> — material icon named "where to vote" (outlined). + static const IconData where_to_vote_outlined = IconData(0xf4c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">widgets</i> — material icon named "widgets". + static const IconData widgets = IconData(0xe6e6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">widgets</i> — material icon named "widgets" (sharp). + static const IconData widgets_sharp = IconData(0xedda, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">widgets</i> — material icon named "widgets" (round). + static const IconData widgets_rounded = IconData(0xf02b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">widgets</i> — material icon named "widgets" (outlined). + static const IconData widgets_outlined = IconData(0xf4c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">width_full</i> — material icon named "width full". + static const IconData width_full = IconData(0xf07d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">width_full</i> — material icon named "width full" (sharp). + static const IconData width_full_sharp = IconData(0xf077d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">width_full</i> — material icon named "width full" (round). + static const IconData width_full_rounded = IconData(0xf082d, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">width_full</i> — material icon named "width full" (outlined). + static const IconData width_full_outlined = IconData(0xf0725, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">width_normal</i> — material icon named "width normal". + static const IconData width_normal = IconData(0xf07d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">width_normal</i> — material icon named "width normal" (sharp). + static const IconData width_normal_sharp = IconData(0xf077e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">width_normal</i> — material icon named "width normal" (round). + static const IconData width_normal_rounded = IconData(0xf082e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">width_normal</i> — material icon named "width normal" (outlined). + static const IconData width_normal_outlined = IconData(0xf0726, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">width_wide</i> — material icon named "width wide". + static const IconData width_wide = IconData(0xf07d7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">width_wide</i> — material icon named "width wide" (sharp). + static const IconData width_wide_sharp = IconData(0xf077f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">width_wide</i> — material icon named "width wide" (round). + static const IconData width_wide_rounded = IconData(0xf082f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">width_wide</i> — material icon named "width wide" (outlined). + static const IconData width_wide_outlined = IconData(0xf0727, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi</i> — material icon named "wifi". + static const IconData wifi = IconData(0xe6e7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi</i> — material icon named "wifi" (sharp). + static const IconData wifi_sharp = IconData(0xede0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi</i> — material icon named "wifi" (round). + static const IconData wifi_rounded = IconData(0xf02bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi</i> — material icon named "wifi" (outlined). + static const IconData wifi_outlined = IconData(0xf4cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_1_bar</i> — material icon named "wifi 1 bar". + static const IconData wifi_1_bar = IconData(0xf07d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_1_bar</i> — material icon named "wifi 1 bar" (sharp). + static const IconData wifi_1_bar_sharp = IconData(0xf0780, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_1_bar</i> — material icon named "wifi 1 bar" (round). + static const IconData wifi_1_bar_rounded = IconData(0xf0830, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_1_bar</i> — material icon named "wifi 1 bar" (outlined). + static const IconData wifi_1_bar_outlined = IconData(0xf0728, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_2_bar</i> — material icon named "wifi 2 bar". + static const IconData wifi_2_bar = IconData(0xf07d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_2_bar</i> — material icon named "wifi 2 bar" (sharp). + static const IconData wifi_2_bar_sharp = IconData(0xf0781, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_2_bar</i> — material icon named "wifi 2 bar" (round). + static const IconData wifi_2_bar_rounded = IconData(0xf0831, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_2_bar</i> — material icon named "wifi 2 bar" (outlined). + static const IconData wifi_2_bar_outlined = IconData(0xf0729, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_calling</i> — material icon named "wifi calling". + static const IconData wifi_calling = IconData(0xe6e8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_calling</i> — material icon named "wifi calling" (sharp). + static const IconData wifi_calling_sharp = IconData(0xeddc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_calling</i> — material icon named "wifi calling" (round). + static const IconData wifi_calling_rounded = IconData(0xf02bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_calling</i> — material icon named "wifi calling" (outlined). + static const IconData wifi_calling_outlined = IconData(0xf4c9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_calling_3</i> — material icon named "wifi calling 3". + static const IconData wifi_calling_3 = IconData(0xe6e9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_calling_3</i> — material icon named "wifi calling 3" (sharp). + static const IconData wifi_calling_3_sharp = IconData(0xeddb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_calling_3</i> — material icon named "wifi calling 3" (round). + static const IconData wifi_calling_3_rounded = IconData(0xf02ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_calling_3</i> — material icon named "wifi calling 3" (outlined). + static const IconData wifi_calling_3_outlined = IconData(0xf4c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_channel</i> — material icon named "wifi channel". + static const IconData wifi_channel = IconData(0xf05a7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_channel</i> — material icon named "wifi channel" (sharp). + static const IconData wifi_channel_sharp = IconData(0xf04ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_channel</i> — material icon named "wifi channel" (round). + static const IconData wifi_channel_rounded = IconData(0xf03b9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_channel</i> — material icon named "wifi channel" (outlined). + static const IconData wifi_channel_outlined = IconData(0xf069a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_find</i> — material icon named "wifi find". + static const IconData wifi_find = IconData(0xf05a8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_find</i> — material icon named "wifi find" (sharp). + static const IconData wifi_find_sharp = IconData(0xf04ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_find</i> — material icon named "wifi find" (round). + static const IconData wifi_find_rounded = IconData(0xf03ba, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_find</i> — material icon named "wifi find" (outlined). + static const IconData wifi_find_outlined = IconData(0xf069b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_lock</i> — material icon named "wifi lock". + static const IconData wifi_lock = IconData(0xe6ea, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_lock</i> — material icon named "wifi lock" (sharp). + static const IconData wifi_lock_sharp = IconData(0xeddd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_lock</i> — material icon named "wifi lock" (round). + static const IconData wifi_lock_rounded = IconData(0xf02bc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_lock</i> — material icon named "wifi lock" (outlined). + static const IconData wifi_lock_outlined = IconData(0xf4ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_off</i> — material icon named "wifi off". + static const IconData wifi_off = IconData(0xe6eb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_off</i> — material icon named "wifi off" (sharp). + static const IconData wifi_off_sharp = IconData(0xedde, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_off</i> — material icon named "wifi off" (round). + static const IconData wifi_off_rounded = IconData(0xf02bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_off</i> — material icon named "wifi off" (outlined). + static const IconData wifi_off_outlined = IconData(0xf4cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_password</i> — material icon named "wifi password". + static const IconData wifi_password = IconData(0xf05a9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_password</i> — material icon named "wifi password" (sharp). + static const IconData wifi_password_sharp = IconData(0xf04ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_password</i> — material icon named "wifi password" (round). + static const IconData wifi_password_rounded = IconData(0xf03bb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_password</i> — material icon named "wifi password" (outlined). + static const IconData wifi_password_outlined = IconData(0xf069c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_protected_setup</i> — material icon named "wifi protected setup". + static const IconData wifi_protected_setup = IconData(0xe6ec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_protected_setup</i> — material icon named "wifi protected setup" (sharp). + static const IconData wifi_protected_setup_sharp = IconData(0xeddf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_protected_setup</i> — material icon named "wifi protected setup" (round). + static const IconData wifi_protected_setup_rounded = IconData( + 0xf02be, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">wifi_protected_setup</i> — material icon named "wifi protected setup" (outlined). + static const IconData wifi_protected_setup_outlined = IconData( + 0xf4cd, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">wifi_tethering</i> — material icon named "wifi tethering". + static const IconData wifi_tethering = IconData(0xe6ed, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_tethering</i> — material icon named "wifi tethering" (sharp). + static const IconData wifi_tethering_sharp = IconData(0xede3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_tethering</i> — material icon named "wifi tethering" (round). + static const IconData wifi_tethering_rounded = IconData(0xf02c2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_tethering</i> — material icon named "wifi tethering" (outlined). + static const IconData wifi_tethering_outlined = IconData(0xf4d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_tethering_error</i> — material icon named "wifi tethering error". + static const IconData wifi_tethering_error = IconData(0xf05aa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_tethering_error</i> — material icon named "wifi tethering error" (sharp). + static const IconData wifi_tethering_error_sharp = IconData(0xf04af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wifi_tethering_error</i> — material icon named "wifi tethering error". + static const IconData wifi_tethering_error_rounded = IconData( + 0xf05aa, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">wifi_tethering_error</i> — material icon named "wifi tethering error" (outlined). + static const IconData wifi_tethering_error_outlined = IconData( + 0xf069d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-sharp md-36">wifi_tethering_error_rounded</i> — material icon named "wifi tethering error rounded" (sharp). + static const IconData wifi_tethering_error_rounded_sharp = IconData( + 0xf04af, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-round md-36">wifi_tethering_error_rounded</i> — material icon named "wifi tethering error rounded" (round). + static const IconData wifi_tethering_error_rounded_rounded = IconData( + 0xf02c0, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">wifi_tethering_error_rounded</i> — material icon named "wifi tethering error rounded" (outlined). + static const IconData wifi_tethering_error_rounded_outlined = IconData( + 0xf069d, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">wifi_tethering_off</i> — material icon named "wifi tethering off". + static const IconData wifi_tethering_off = IconData(0xe6ef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wifi_tethering_off</i> — material icon named "wifi tethering off" (sharp). + static const IconData wifi_tethering_off_sharp = IconData(0xede2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wifi_tethering_off</i> — material icon named "wifi tethering off" (round). + static const IconData wifi_tethering_off_rounded = IconData(0xf02c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wifi_tethering_off</i> — material icon named "wifi tethering off" (outlined). + static const IconData wifi_tethering_off_outlined = IconData(0xf4cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wind_power</i> — material icon named "wind power". + static const IconData wind_power = IconData(0xf07da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wind_power</i> — material icon named "wind power" (sharp). + static const IconData wind_power_sharp = IconData(0xf0782, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wind_power</i> — material icon named "wind power" (round). + static const IconData wind_power_rounded = IconData(0xf0832, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wind_power</i> — material icon named "wind power" (outlined). + static const IconData wind_power_outlined = IconData(0xf072a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">window</i> — material icon named "window". + static const IconData window = IconData(0xe6f0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">window</i> — material icon named "window" (sharp). + static const IconData window_sharp = IconData(0xede4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">window</i> — material icon named "window" (round). + static const IconData window_rounded = IconData(0xf02c3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">window</i> — material icon named "window" (outlined). + static const IconData window_outlined = IconData(0xf4d1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wine_bar</i> — material icon named "wine bar". + static const IconData wine_bar = IconData(0xe6f1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wine_bar</i> — material icon named "wine bar" (sharp). + static const IconData wine_bar_sharp = IconData(0xede5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wine_bar</i> — material icon named "wine bar" (round). + static const IconData wine_bar_rounded = IconData(0xf02c4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wine_bar</i> — material icon named "wine bar" (outlined). + static const IconData wine_bar_outlined = IconData(0xf4d2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">woman</i> — material icon named "woman". + static const IconData woman = IconData(0xf05ab, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">woman</i> — material icon named "woman" (sharp). + static const IconData woman_sharp = IconData(0xf04b0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">woman</i> — material icon named "woman" (round). + static const IconData woman_rounded = IconData(0xf03bd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">woman</i> — material icon named "woman" (outlined). + static const IconData woman_outlined = IconData(0xf069e, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">woman_2</i> — material icon named "woman 2". + static const IconData woman_2 = IconData(0xf087c, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">woman_2</i> — material icon named "woman 2" (sharp). + static const IconData woman_2_sharp = IconData(0xf0851, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">woman_2</i> — material icon named "woman 2" (round). + static const IconData woman_2_rounded = IconData(0xf089a, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">woman_2</i> — material icon named "woman 2" (outlined). + static const IconData woman_2_outlined = IconData(0xf08b8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">woo_commerce</i> — material icon named "woo commerce". + static const IconData woo_commerce = IconData(0xf05ac, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">woo_commerce</i> — material icon named "woo commerce" (sharp). + static const IconData woo_commerce_sharp = IconData(0xf04b1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">woo_commerce</i> — material icon named "woo commerce" (round). + static const IconData woo_commerce_rounded = IconData(0xf03be, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">woo_commerce</i> — material icon named "woo commerce" (outlined). + static const IconData woo_commerce_outlined = IconData(0xf069f, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wordpress</i> — material icon named "wordpress". + static const IconData wordpress = IconData(0xf05ad, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wordpress</i> — material icon named "wordpress" (sharp). + static const IconData wordpress_sharp = IconData(0xf04b2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wordpress</i> — material icon named "wordpress" (round). + static const IconData wordpress_rounded = IconData(0xf03bf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wordpress</i> — material icon named "wordpress" (outlined). + static const IconData wordpress_outlined = IconData(0xf06a0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">work</i> — material icon named "work". + static const IconData work = IconData(0xe6f2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">work</i> — material icon named "work" (sharp). + static const IconData work_sharp = IconData(0xede8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">work</i> — material icon named "work" (round). + static const IconData work_rounded = IconData(0xf02c7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">work</i> — material icon named "work" (outlined). + static const IconData work_outlined = IconData(0xf4d5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">work_history</i> — material icon named "work history". + static const IconData work_history = IconData(0xf07db, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">work_history</i> — material icon named "work history" (sharp). + static const IconData work_history_sharp = IconData(0xf0783, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">work_history</i> — material icon named "work history" (round). + static const IconData work_history_rounded = IconData(0xf0833, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">work_history</i> — material icon named "work history" (outlined). + static const IconData work_history_outlined = IconData(0xf072b, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">work_off</i> — material icon named "work off". + static const IconData work_off = IconData(0xe6f3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">work_off</i> — material icon named "work off" (sharp). + static const IconData work_off_sharp = IconData(0xede6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">work_off</i> — material icon named "work off" (round). + static const IconData work_off_rounded = IconData(0xf02c5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">work_off</i> — material icon named "work off" (outlined). + static const IconData work_off_outlined = IconData(0xf4d3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">work_outline</i> — material icon named "work outline". + static const IconData work_outline = IconData(0xe6f4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">work_outline</i> — material icon named "work outline" (sharp). + static const IconData work_outline_sharp = IconData(0xede7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">work_outline</i> — material icon named "work outline" (round). + static const IconData work_outline_rounded = IconData(0xf02c6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">work_outline</i> — material icon named "work outline" (outlined). + static const IconData work_outline_outlined = IconData(0xf4d4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">workspace_premium</i> — material icon named "workspace premium". + static const IconData workspace_premium = IconData(0xf05ae, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">workspace_premium</i> — material icon named "workspace premium" (sharp). + static const IconData workspace_premium_sharp = IconData(0xf04b3, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">workspace_premium</i> — material icon named "workspace premium" (round). + static const IconData workspace_premium_rounded = IconData(0xf03c0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">workspace_premium</i> — material icon named "workspace premium" (outlined). + static const IconData workspace_premium_outlined = IconData(0xf06a1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">workspaces</i> — material icon named "workspaces". + static const IconData workspaces = IconData(0xe6f5, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">workspaces</i> — material icon named "workspaces" (sharp). + static const IconData workspaces_sharp = IconData(0xede9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">workspaces</i> — material icon named "workspaces" (round). + static const IconData workspaces_rounded = IconData(0xf02c8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">workspaces</i> — material icon named "workspaces" (outlined). + static const IconData workspaces_outlined = IconData(0xf4d6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">workspaces_filled</i> — material icon named "workspaces filled". + static const IconData workspaces_filled = IconData(0xe6f6, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">workspaces_outline</i> — material icon named "workspaces outline". + static const IconData workspaces_outline = IconData(0xe6f7, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wrap_text</i> — material icon named "wrap text". + static const IconData wrap_text = IconData( + 0xe6f8, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-sharp md-36">wrap_text</i> — material icon named "wrap text" (sharp). + static const IconData wrap_text_sharp = IconData( + 0xedea, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-round md-36">wrap_text</i> — material icon named "wrap text" (round). + static const IconData wrap_text_rounded = IconData( + 0xf02c9, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons-outlined md-36">wrap_text</i> — material icon named "wrap text" (outlined). + static const IconData wrap_text_outlined = IconData( + 0xf4d7, + fontFamily: 'MaterialIcons', + matchTextDirection: true, + ); + + /// <i class="material-icons md-36">wrong_location</i> — material icon named "wrong location". + static const IconData wrong_location = IconData(0xe6f9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wrong_location</i> — material icon named "wrong location" (sharp). + static const IconData wrong_location_sharp = IconData(0xedeb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wrong_location</i> — material icon named "wrong location" (round). + static const IconData wrong_location_rounded = IconData(0xf02ca, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wrong_location</i> — material icon named "wrong location" (outlined). + static const IconData wrong_location_outlined = IconData(0xf4d8, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">wysiwyg</i> — material icon named "wysiwyg". + static const IconData wysiwyg = IconData(0xe6fa, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">wysiwyg</i> — material icon named "wysiwyg" (sharp). + static const IconData wysiwyg_sharp = IconData(0xedec, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">wysiwyg</i> — material icon named "wysiwyg" (round). + static const IconData wysiwyg_rounded = IconData(0xf02cb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">wysiwyg</i> — material icon named "wysiwyg" (outlined). + static const IconData wysiwyg_outlined = IconData(0xf4d9, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">yard</i> — material icon named "yard". + static const IconData yard = IconData(0xe6fb, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">yard</i> — material icon named "yard" (sharp). + static const IconData yard_sharp = IconData(0xeded, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">yard</i> — material icon named "yard" (round). + static const IconData yard_rounded = IconData(0xf02cc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">yard</i> — material icon named "yard" (outlined). + static const IconData yard_outlined = IconData(0xf4da, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">youtube_searched_for</i> — material icon named "youtube searched for". + static const IconData youtube_searched_for = IconData(0xe6fc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">youtube_searched_for</i> — material icon named "youtube searched for" (sharp). + static const IconData youtube_searched_for_sharp = IconData(0xedee, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">youtube_searched_for</i> — material icon named "youtube searched for" (round). + static const IconData youtube_searched_for_rounded = IconData( + 0xf02cd, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons-outlined md-36">youtube_searched_for</i> — material icon named "youtube searched for" (outlined). + static const IconData youtube_searched_for_outlined = IconData( + 0xf4db, + fontFamily: 'MaterialIcons', + ); + + /// <i class="material-icons md-36">zoom_in</i> — material icon named "zoom in". + static const IconData zoom_in = IconData(0xe6fd, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">zoom_in</i> — material icon named "zoom in" (sharp). + static const IconData zoom_in_sharp = IconData(0xedef, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">zoom_in</i> — material icon named "zoom in" (round). + static const IconData zoom_in_rounded = IconData(0xf02ce, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">zoom_in</i> — material icon named "zoom in" (outlined). + static const IconData zoom_in_outlined = IconData(0xf4dc, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">zoom_in_map</i> — material icon named "zoom in map". + static const IconData zoom_in_map = IconData(0xf05af, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">zoom_in_map</i> — material icon named "zoom in map" (sharp). + static const IconData zoom_in_map_sharp = IconData(0xf04b4, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">zoom_in_map</i> — material icon named "zoom in map" (round). + static const IconData zoom_in_map_rounded = IconData(0xf03c1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">zoom_in_map</i> — material icon named "zoom in map" (outlined). + static const IconData zoom_in_map_outlined = IconData(0xf06a2, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">zoom_out</i> — material icon named "zoom out". + static const IconData zoom_out = IconData(0xe6fe, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">zoom_out</i> — material icon named "zoom out" (sharp). + static const IconData zoom_out_sharp = IconData(0xedf1, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">zoom_out</i> — material icon named "zoom out" (round). + static const IconData zoom_out_rounded = IconData(0xf02d0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">zoom_out</i> — material icon named "zoom out" (outlined). + static const IconData zoom_out_outlined = IconData(0xf4de, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons md-36">zoom_out_map</i> — material icon named "zoom out map". + static const IconData zoom_out_map = IconData(0xe6ff, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-sharp md-36">zoom_out_map</i> — material icon named "zoom out map" (sharp). + static const IconData zoom_out_map_sharp = IconData(0xedf0, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-round md-36">zoom_out_map</i> — material icon named "zoom out map" (round). + static const IconData zoom_out_map_rounded = IconData(0xf02cf, fontFamily: 'MaterialIcons'); + + /// <i class="material-icons-outlined md-36">zoom_out_map</i> — material icon named "zoom out map" (outlined). + static const IconData zoom_out_map_outlined = IconData(0xf4dd, fontFamily: 'MaterialIcons'); + // END GENERATED ICONS +} diff --git a/packages/material_ui/lib/src/ink_decoration.dart b/packages/material_ui/lib/src/ink_decoration.dart new file mode 100644 index 000000000000..f125347f61e0 --- /dev/null +++ b/packages/material_ui/lib/src/ink_decoration.dart @@ -0,0 +1,414 @@ +// 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. + +/// @docImport 'ink_well.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'material.dart'; + +/// A convenience widget for drawing images and other decorations on [Material] +/// widgets, so that [InkWell] and [InkResponse] splashes will render over them. +/// +/// Ink splashes and highlights, as rendered by [InkWell] and [InkResponse], +/// draw on the actual underlying [Material], under whatever widgets are drawn +/// over the material (such as [Text] and [Icon]s). If an opaque image is drawn +/// over the [Material] (maybe using a [Container] or [DecoratedBox]), these ink +/// effects will not be visible, as they will be entirely obscured by the opaque +/// graphics drawn above the [Material]. +/// +/// This widget draws the given [Decoration] directly on the [Material], in the +/// same way that [InkWell] and [InkResponse] draw there. This allows the +/// splashes to be drawn above the otherwise opaque graphics. +/// +/// An alternative solution is to use a [MaterialType.transparency] material +/// above the opaque graphics, so that the ink responses from [InkWell]s and +/// [InkResponse]s will be drawn on the transparent material on top of the +/// opaque graphics, rather than under the opaque graphics on the underlying +/// [Material]. +/// +/// ## Limitations +/// +/// This widget is subject to the same limitations as other ink effects, as +/// described in the documentation for [Material]. Most notably, the position of +/// an [Ink] widget must not change during the lifetime of the [Material] object +/// unless a [LayoutChangedNotification] is dispatched each frame that the +/// position changes. This is done automatically for [ListView] and other +/// scrolling widgets, but is not done for animated transitions such as +/// [SlideTransition]. +/// +/// Additionally, if multiple [Ink] widgets paint on the same [Material] in the +/// same location, their relative order is not guaranteed. The decorations will +/// be painted in the order that they were added to the material, which +/// generally speaking will match the order they are given in the widget tree, +/// but this order may appear to be somewhat random in more dynamic situations. +/// +/// {@tool snippet} +/// +/// This example shows how a [Material] widget can have a yellow rectangle drawn +/// on it using [Ink], while still having ink effects over the yellow rectangle: +/// +/// ```dart +/// Material( +/// color: Colors.teal[900], +/// child: Center( +/// child: Ink( +/// color: Colors.yellow, +/// width: 200.0, +/// height: 100.0, +/// child: InkWell( +/// onTap: () { /* ... */ }, +/// child: const Center( +/// child: Text('YELLOW'), +/// ) +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// {@tool snippet} +/// +/// The following example shows how an image can be printed on a [Material] +/// widget with an [InkWell] above it: +/// +/// ```dart +/// Material( +/// color: Colors.grey[800], +/// child: Center( +/// child: Ink.image( +/// image: const AssetImage('cat.jpeg'), +/// fit: BoxFit.cover, +/// width: 300.0, +/// height: 200.0, +/// child: InkWell( +/// onTap: () { /* ... */ }, +/// child: const Align( +/// alignment: Alignment.topLeft, +/// child: Padding( +/// padding: EdgeInsets.all(10.0), +/// child: Text( +/// 'KITTEN', +/// style: TextStyle( +/// fontWeight: FontWeight.w900, +/// color: Colors.white, +/// ), +/// ), +/// ), +/// ) +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// What to do if you want to clip this [Ink.image]? +/// +/// {@tool dartpad} +/// Wrapping the [Ink] in a clipping widget directly will not work since the +/// [Material] it will be printed on is responsible for clipping. +/// +/// In this example the image is not being clipped as expected. This is because +/// it is being rendered onto the Scaffold body Material, which isn't wrapped in +/// the [ClipRRect]. +/// +/// ** See code in examples/api/lib/material/ink/ink.image_clip.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// One solution would be to deliberately wrap the [Ink.image] in a [Material]. +/// This makes sure the Material that the image is painted on is also responsible +/// for clipping said content. +/// +/// ** See code in examples/api/lib/material/ink/ink.image_clip.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Container], a more generic form of this widget which paints itself, +/// rather that deferring to the nearest [Material] widget. +/// * [InkDecoration], the [InkFeature] subclass used by this widget to paint +/// on [Material] widgets. +/// * [InkWell] and [InkResponse], which also draw on [Material] widgets. +class Ink extends StatefulWidget { + /// Paints a decoration (which can be a simple color) on a [Material]. + /// + /// The [height] and [width] values include the [padding]. + /// + /// The `color` argument is a shorthand for + /// `decoration: BoxDecoration(color: color)`, which means you cannot supply + /// both a `color` and a `decoration` argument. If you want to have both a + /// `color` and a `decoration`, you can pass the color as the `color` + /// argument to the `BoxDecoration`. + /// + /// If there is no intention to render anything on this decoration, consider + /// using a [Container] with a [BoxDecoration] instead. + Ink({ + super.key, + this.padding, + Color? color, + Decoration? decoration, + this.width, + this.height, + this.child, + }) : assert(padding == null || padding.isNonNegative), + assert(decoration == null || decoration.debugAssertIsValid()), + assert( + color == null || decoration == null, + 'Cannot provide both a color and a decoration\n' + 'The color argument is just a shorthand for "decoration: BoxDecoration(color: color)".', + ), + decoration = decoration ?? (color != null ? BoxDecoration(color: color) : null); + + /// Creates a widget that shows an image (obtained from an [ImageProvider]) on + /// a [Material]. + /// + /// This argument is a shorthand for passing a [BoxDecoration] that has only + /// its [BoxDecoration.image] property set to the [Ink] constructor. The + /// properties of the [DecorationImage] of that [BoxDecoration] are set + /// according to the arguments passed to this method. + /// + /// If there is no intention to render anything on this image, consider using + /// a [Container] with a [BoxDecoration.image] instead. The `onImageError` + /// argument may be provided to listen for errors when resolving the image. + /// + /// The `alignment`, `repeat`, and `matchTextDirection` arguments must not + /// be null either, but they have default values. + /// + /// See [paintImage] for a description of the meaning of these arguments. + Ink.image({ + super.key, + this.padding, + required ImageProvider image, + ImageErrorListener? onImageError, + ColorFilter? colorFilter, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + Rect? centerSlice, + ImageRepeat repeat = ImageRepeat.noRepeat, + bool matchTextDirection = false, + this.width, + this.height, + this.child, + }) : assert(padding == null || padding.isNonNegative), + decoration = BoxDecoration( + image: DecorationImage( + image: image, + onError: onImageError, + colorFilter: colorFilter, + fit: fit, + alignment: alignment, + centerSlice: centerSlice, + repeat: repeat, + matchTextDirection: matchTextDirection, + ), + ); + + /// The [child] contained by the container. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Empty space to inscribe inside the [decoration]. The [child], if any, is + /// placed inside this padding. + /// + /// This padding is in addition to any padding inherent in the [decoration]; + /// see [Decoration.padding]. + final EdgeInsetsGeometry? padding; + + /// The decoration to paint on the nearest ancestor [Material] widget. + /// + /// A shorthand for specifying just a solid color is available in the + /// constructor: set the `color` argument instead of the [decoration] + /// argument. + /// + /// A shorthand for specifying just an image is also available using the + /// [Ink.image] constructor. + final Decoration? decoration; + + /// A width to apply to the [decoration] and the [child]. The width includes + /// any [padding]. + final double? width; + + /// A height to apply to the [decoration] and the [child]. The height includes + /// any [padding]. + final double? height; + + EdgeInsetsGeometry get _paddingIncludingDecoration { + return switch ((padding, decoration?.padding)) { + (null, null) => EdgeInsets.zero, + (null, final EdgeInsetsGeometry padding) => padding, + (final EdgeInsetsGeometry padding, null) => padding, + _ => padding!.add(decoration!.padding), + }; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty<Decoration>('bg', decoration, defaultValue: null)); + } + + @override + State<Ink> createState() => _InkState(); +} + +class _InkState extends State<Ink> { + final GlobalKey _boxKey = GlobalKey(); + InkDecoration? _ink; + + void _handleRemoved() { + _ink = null; + } + + @override + void deactivate() { + _ink?.dispose(); + assert(_ink == null); + super.deactivate(); + } + + Widget _build(BuildContext context) { + // By creating the InkDecoration from within a Builder widget, we can + // use the RenderBox of the Padding widget. + if (_ink == null) { + _ink = InkDecoration( + decoration: widget.decoration, + isVisible: Visibility.of(context), + configuration: createLocalImageConfiguration(context), + controller: Material.of(context), + referenceBox: _boxKey.currentContext!.findRenderObject()! as RenderBox, + onRemoved: _handleRemoved, + ); + } else { + _ink!.decoration = widget.decoration; + _ink!.isVisible = Visibility.of(context); + _ink!.configuration = createLocalImageConfiguration(context); + } + return widget.child ?? ConstrainedBox(constraints: const BoxConstraints.expand()); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + Widget result = Padding( + key: _boxKey, + padding: widget._paddingIncludingDecoration, + child: Builder(builder: _build), + ); + if (widget.width != null || widget.height != null) { + result = SizedBox(width: widget.width, height: widget.height, child: result); + } + return result; + } +} + +/// A decoration on a part of a [Material]. +/// +/// This object is rarely created directly. Instead of creating an ink +/// decoration directly, consider using an [Ink] widget, which uses this class +/// in combination with [Padding] and [ConstrainedBox] to draw a decoration on a +/// [Material]. +/// +/// See also: +/// +/// * [Ink], the corresponding widget. +/// * [InkResponse], which uses gestures to trigger ink highlights and ink +/// splashes in the parent [Material]. +/// * [InkWell], which is a rectangular [InkResponse] (the most common type of +/// ink response). +/// * [Material], which is the widget on which the ink is painted. +class InkDecoration extends InkFeature { + /// Draws a decoration on a [Material]. + InkDecoration({ + required Decoration? decoration, + bool isVisible = true, + required ImageConfiguration configuration, + required super.controller, + required super.referenceBox, + super.onRemoved, + }) : _configuration = configuration { + this.decoration = decoration; + this.isVisible = isVisible; + controller.addInkFeature(this); + } + + BoxPainter? _painter; + + /// What to paint on the [Material]. + /// + /// The decoration is painted at the position and size of the [referenceBox], + /// on the [Material] that owns the [controller]. + Decoration? get decoration => _decoration; + Decoration? _decoration; + set decoration(Decoration? value) { + if (value == _decoration) { + return; + } + _decoration = value; + _painter?.dispose(); + _painter = _decoration?.createBoxPainter(_handleChanged); + controller.markNeedsPaint(); + } + + /// Whether the decoration should be painted. + /// + /// Defaults to true. + bool get isVisible => _isVisible; + bool _isVisible = true; + set isVisible(bool value) { + if (value == _isVisible) { + return; + } + _isVisible = value; + controller.markNeedsPaint(); + } + + /// The configuration to pass to the [BoxPainter] obtained from the + /// [decoration], when painting. + /// + /// The [ImageConfiguration.size] field is ignored (and replaced by the size + /// of the [referenceBox], at paint time). + ImageConfiguration get configuration => _configuration; + ImageConfiguration _configuration; + set configuration(ImageConfiguration value) { + if (value == _configuration) { + return; + } + _configuration = value; + controller.markNeedsPaint(); + } + + void _handleChanged() { + controller.markNeedsPaint(); + } + + @override + void dispose() { + _painter?.dispose(); + super.dispose(); + } + + @override + void paintFeature(Canvas canvas, Matrix4 transform) { + if (_painter == null || !isVisible) { + return; + } + final Offset? originOffset = MatrixUtils.getAsTranslation(transform); + final ImageConfiguration sizedConfiguration = configuration.copyWith(size: referenceBox.size); + if (originOffset == null) { + canvas.save(); + canvas.transform(transform.storage); + _painter!.paint(canvas, Offset.zero, sizedConfiguration); + canvas.restore(); + } else { + _painter!.paint(canvas, originOffset, sizedConfiguration); + } + } +} diff --git a/packages/material_ui/lib/src/ink_highlight.dart b/packages/material_ui/lib/src/ink_highlight.dart new file mode 100644 index 000000000000..bc9e8e4ba89d --- /dev/null +++ b/packages/material_ui/lib/src/ink_highlight.dart @@ -0,0 +1,147 @@ +// 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. + +/// @docImport 'ink_decoration.dart'; +/// @docImport 'ink_splash.dart'; +/// @docImport 'ink_well.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'ink_well.dart' show InteractiveInkFeature; +import 'material.dart'; + +const Duration _kDefaultHighlightFadeDuration = Duration(milliseconds: 200); + +/// A visual emphasis on a part of a [Material] receiving user interaction. +/// +/// This object is rarely created directly. Instead of creating an ink highlight +/// directly, consider using an [InkResponse] or [InkWell] widget, which uses +/// gestures (such as tap and long-press) to trigger ink highlights. +/// +/// See also: +/// +/// * [InkResponse], which uses gestures to trigger ink highlights and ink +/// splashes in the parent [Material]. +/// * [InkWell], which is a rectangular [InkResponse] (the most common type of +/// ink response). +/// * [Material], which is the widget on which the ink highlight is painted. +/// * [InkSplash], which is an ink feature that shows a reaction to user input +/// on a [Material]. +/// * [Ink], a convenience widget for drawing images and other decorations on +/// Material widgets. +class InkHighlight extends InteractiveInkFeature { + /// Begin a highlight animation. + /// + /// The [controller] argument is typically obtained via + /// `Material.of(context)`. + /// + /// If a `rectCallback` is given, then it provides the highlight rectangle, + /// otherwise, the highlight rectangle is coincident with the [referenceBox]. + /// + /// When the highlight is removed, `onRemoved` will be called. + InkHighlight({ + required super.controller, + required super.referenceBox, + required super.color, + required TextDirection textDirection, + BoxShape shape = BoxShape.rectangle, + double? radius, + BorderRadius? borderRadius, + super.customBorder, + RectCallback? rectCallback, + super.onRemoved, + Duration fadeDuration = _kDefaultHighlightFadeDuration, + }) : _shape = shape, + _radius = radius, + _borderRadius = borderRadius ?? BorderRadius.zero, + + _textDirection = textDirection, + _rectCallback = rectCallback { + _alphaController = AnimationController(duration: fadeDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..addStatusListener(_handleAlphaStatusChanged) + ..forward(); + _alpha = _alphaController.drive(IntTween(begin: 0, end: color.alpha)); + + controller.addInkFeature(this); + } + + final BoxShape _shape; + final double? _radius; + final BorderRadius _borderRadius; + final RectCallback? _rectCallback; + final TextDirection _textDirection; + + late Animation<int> _alpha; + late AnimationController _alphaController; + + /// Whether this part of the material is being visually emphasized. + bool get active => _active; + bool _active = true; + + /// Start visually emphasizing this part of the material. + void activate() { + _active = true; + _alphaController.forward(); + } + + /// Stop visually emphasizing this part of the material. + void deactivate() { + _active = false; + _alphaController.reverse(); + } + + void _handleAlphaStatusChanged(AnimationStatus status) { + if (status.isDismissed && !_active) { + dispose(); + } + } + + @override + void dispose() { + _alphaController.dispose(); + super.dispose(); + } + + void _paintHighlight(Canvas canvas, Rect rect, Paint paint) { + canvas.save(); + if (customBorder != null) { + canvas.clipPath(customBorder!.getOuterPath(rect, textDirection: _textDirection)); + } + switch (_shape) { + case BoxShape.circle: + canvas.drawCircle(rect.center, _radius ?? Material.defaultSplashRadius, paint); + case BoxShape.rectangle: + if (_borderRadius != BorderRadius.zero) { + final clipRRect = RRect.fromRectAndCorners( + rect, + topLeft: _borderRadius.topLeft, + topRight: _borderRadius.topRight, + bottomLeft: _borderRadius.bottomLeft, + bottomRight: _borderRadius.bottomRight, + ); + canvas.drawRRect(clipRRect, paint); + } else { + canvas.drawRect(rect, paint); + } + } + canvas.restore(); + } + + @override + void paintFeature(Canvas canvas, Matrix4 transform) { + final paint = Paint()..color = color.withAlpha(_alpha.value); + final Offset? originOffset = MatrixUtils.getAsTranslation(transform); + final Rect rect = _rectCallback != null ? _rectCallback() : Offset.zero & referenceBox.size; + if (originOffset == null) { + canvas.save(); + canvas.transform(transform.storage); + _paintHighlight(canvas, rect, paint); + canvas.restore(); + } else { + _paintHighlight(canvas, rect.shift(originOffset), paint); + } + } +} diff --git a/packages/material_ui/lib/src/ink_ripple.dart b/packages/material_ui/lib/src/ink_ripple.dart new file mode 100644 index 000000000000..a2759f463f86 --- /dev/null +++ b/packages/material_ui/lib/src/ink_ripple.dart @@ -0,0 +1,258 @@ +// 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. + +/// @docImport 'button_style.dart'; +/// @docImport 'ink_highlight.dart'; +/// @docImport 'ink_splash.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'ink_well.dart'; +import 'material.dart'; + +const Duration _kUnconfirmedRippleDuration = Duration(seconds: 1); +const Duration _kFadeInDuration = Duration(milliseconds: 75); +const Duration _kRadiusDuration = Duration(milliseconds: 225); +const Duration _kFadeOutDuration = Duration(milliseconds: 375); +const Duration _kCancelDuration = Duration(milliseconds: 75); + +// The fade out begins 225ms after the _fadeOutController starts. See confirm(). +const double _kFadeOutIntervalStart = 225.0 / 375.0; + +RectCallback? _getClipCallback( + RenderBox referenceBox, + bool containedInkWell, + RectCallback? rectCallback, +) { + if (rectCallback != null) { + assert(containedInkWell); + return rectCallback; + } + if (containedInkWell) { + return () => Offset.zero & referenceBox.size; + } + return null; +} + +double _getTargetRadius( + RenderBox referenceBox, + bool containedInkWell, + RectCallback? rectCallback, + Offset position, +) { + final Size size = rectCallback != null ? rectCallback().size : referenceBox.size; + final double d1 = size.bottomRight(Offset.zero).distance; + final double d2 = (size.topRight(Offset.zero) - size.bottomLeft(Offset.zero)).distance; + return math.max(d1, d2) / 2.0; +} + +class _InkRippleFactory extends InteractiveInkFeatureFactory { + const _InkRippleFactory(); + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + return InkRipple( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: containedInkWell, + rectCallback: rectCallback, + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + textDirection: textDirection, + ); + } +} + +/// A visual reaction on a piece of [Material] to user input. +/// +/// A circular ink feature whose origin starts at the input touch point and +/// whose radius expands from 60% of the final radius. The splash origin +/// animates to the center of its [referenceBox]. +/// +/// This object is rarely created directly. Instead of creating an ink ripple, +/// consider using an [InkResponse] or [InkWell] widget, which uses +/// gestures (such as tap and long-press) to trigger ink splashes. This class +/// is used when the [Theme]'s [ThemeData.splashFactory] is [InkRipple.splashFactory]. +/// +/// See also: +/// +/// * [InkSplash], which is an ink splash feature that expands less +/// aggressively than the ripple. +/// * [InkResponse], which uses gestures to trigger ink highlights and ink +/// splashes in the parent [Material]. +/// * [InkWell], which is a rectangular [InkResponse] (the most common type of +/// ink response). +/// * [Material], which is the widget on which the ink splash is painted. +/// * [InkHighlight], which is an ink feature that emphasizes a part of a +/// [Material]. +class InkRipple extends InteractiveInkFeature { + /// Begin a ripple, centered at [position] relative to [referenceBox]. + /// + /// The [controller] argument is typically obtained via + /// `Material.of(context)`. + /// + /// If [containedInkWell] is true, then the ripple will be sized to fit + /// the well rectangle, then clipped to it when drawn. The well + /// rectangle is the box returned by [rectCallback], if provided, or + /// otherwise is the bounds of the [referenceBox]. + /// + /// If [containedInkWell] is false, then [rectCallback] should be null. + /// The ink ripple is clipped only to the edges of the [Material]. + /// This is the default. + /// + /// When the ripple is removed, [onRemoved] will be called. + InkRipple({ + required MaterialInkController controller, + required super.referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + super.customBorder, + double? radius, + super.onRemoved, + }) : _position = position, + _borderRadius = borderRadius ?? BorderRadius.zero, + _textDirection = textDirection, + _targetRadius = + radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position), + _clipCallback = _getClipCallback(referenceBox, containedInkWell, rectCallback), + super(controller: controller, color: color) { + // Immediately begin fading-in the initial splash. + _fadeInController = AnimationController(duration: _kFadeInDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..forward(); + _fadeIn = _fadeInController.drive(IntTween(begin: 0, end: color.alpha)); + + // Controls the splash radius and its center. Starts upon confirm. + _radiusController = + AnimationController(duration: _kUnconfirmedRippleDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..forward(); + // Initial splash diameter is 60% of the target diameter, final + // diameter is 10dps larger than the target diameter. + _radius = _radiusController.drive( + Tween<double>(begin: _targetRadius * 0.30, end: _targetRadius + 5.0).chain(_easeCurveTween), + ); + + // Controls the splash radius and its center. Starts upon confirm however its + // Interval delays changes until the radius expansion has completed. + _fadeOutController = AnimationController(duration: _kFadeOutDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..addStatusListener(_handleAlphaStatusChanged); + _fadeOut = _fadeOutController.drive( + IntTween(begin: color.alpha, end: 0).chain(_fadeOutIntervalTween), + ); + + controller.addInkFeature(this); + } + + final Offset _position; + final BorderRadius _borderRadius; + final double _targetRadius; + final RectCallback? _clipCallback; + final TextDirection _textDirection; + + late Animation<double> _radius; + late AnimationController _radiusController; + + late Animation<int> _fadeIn; + late AnimationController _fadeInController; + + late Animation<int> _fadeOut; + late AnimationController _fadeOutController; + + /// Used to specify this type of ink splash for an [InkWell], [InkResponse], + /// material [Theme], or [ButtonStyle]. + static const InteractiveInkFeatureFactory splashFactory = _InkRippleFactory(); + + static final Animatable<double> _easeCurveTween = CurveTween(curve: Curves.ease); + static final Animatable<double> _fadeOutIntervalTween = CurveTween( + curve: const Interval(_kFadeOutIntervalStart, 1.0), + ); + + @override + void confirm() { + _radiusController + ..duration = _kRadiusDuration + ..forward(); + // This confirm may have been preceded by a cancel. + _fadeInController.forward(); + _fadeOutController.animateTo(1.0, duration: _kFadeOutDuration); + } + + @override + void cancel() { + _fadeInController.stop(); + // Watch out: setting _fadeOutController's value to 1.0 will + // trigger a call to _handleAlphaStatusChanged() which will + // dispose _fadeOutController. + final double fadeOutValue = 1.0 - _fadeInController.value; + _fadeOutController.value = fadeOutValue; + if (fadeOutValue < 1.0) { + _fadeOutController.animateTo(1.0, duration: _kCancelDuration); + } + } + + void _handleAlphaStatusChanged(AnimationStatus status) { + if (status.isCompleted) { + dispose(); + } + } + + @override + void dispose() { + _radiusController.dispose(); + _fadeInController.dispose(); + _fadeOutController.dispose(); + super.dispose(); + } + + @override + void paintFeature(Canvas canvas, Matrix4 transform) { + final int alpha = _fadeInController.isAnimating ? _fadeIn.value : _fadeOut.value; + final paint = Paint()..color = color.withAlpha(alpha); + final Rect? rect = _clipCallback?.call(); + // Splash moves to the center of the reference box. + final Offset center = Offset.lerp( + _position, + rect != null ? rect.center : referenceBox.size.center(Offset.zero), + Curves.ease.transform(_radiusController.value), + )!; + paintInkCircle( + canvas: canvas, + transform: transform, + paint: paint, + center: center, + textDirection: _textDirection, + radius: _radius.value, + customBorder: customBorder, + borderRadius: _borderRadius, + clipCallback: _clipCallback, + ); + } +} diff --git a/packages/material_ui/lib/src/ink_sparkle.dart b/packages/material_ui/lib/src/ink_sparkle.dart new file mode 100644 index 000000000000..933d776d9d73 --- /dev/null +++ b/packages/material_ui/lib/src/ink_sparkle.dart @@ -0,0 +1,492 @@ +// 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. + +/// @docImport 'button_style.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:vector_math/vector_math_64.dart'; + +import 'ink_well.dart'; +import 'material.dart'; + +/// Begin a Material 3 ink sparkle ripple, centered at the tap or click position +/// relative to the [referenceBox]. +/// +/// To use this effect, pass an instance of [splashFactory] to the +/// `splashFactory` parameter of either the Material [ThemeData] or any +/// component that has a `splashFactory` parameter, such as buttons: +/// - [ElevatedButton] +/// - [TextButton] +/// - [OutlinedButton] +/// +/// The [controller] argument is typically obtained via +/// `Material.of(context)`. +/// +/// If `containedInkWell` is true, then the effect will be sized to fit +/// the well rectangle, and clipped to it when drawn. The well +/// rectangle is the box returned by `rectCallback`, if provided, or +/// otherwise is the bounds of the [referenceBox]. +/// +/// If `containedInkWell` is false, then `rectCallback` should be null. +/// The ink ripple is clipped only to the edges of the [Material]. +/// This is the default. +/// +/// When the ripple is removed, [onRemoved] will be called. +/// +/// {@tool snippet} +/// +/// For typical use, pass the [InkSparkle.splashFactory] to the `splashFactory` +/// parameter of a button style or [ThemeData]. +/// +/// ```dart +/// ElevatedButton( +/// style: ElevatedButton.styleFrom(splashFactory: InkSparkle.splashFactory), +/// child: const Text('Sparkle!'), +/// onPressed: () { }, +/// ) +/// ``` +/// {@end-tool} +class InkSparkle extends InteractiveInkFeature { + /// Begin a sparkly ripple effect, centered at [position] relative to + /// [referenceBox]. + /// + /// The [color] defines the color of the splash itself. The sparkles are + /// always white. + /// + /// The [controller] argument is typically obtained via + /// `Material.of(context)`. + /// + /// [textDirection] is used by [customBorder] if it is non-null. This allows + /// the [customBorder]'s path to be properly defined if it was the path was + /// expressed in terms of "start" and "end" instead of + /// "left" and "right". + /// + /// If [containedInkWell] is true, then the ripple will be sized to fit + /// the well rectangle, then clipped to it when drawn. The well + /// rectangle is the box returned by [rectCallback], if provided, or + /// otherwise is the bounds of the [referenceBox]. + /// + /// If [containedInkWell] is false, then [rectCallback] should be null. + /// The ink ripple is clipped only to the edges of the [Material]. + /// This is the default. + /// + /// Clipping can happen in 3 different ways: + /// 1. If [customBorder] is provided, it is used to determine the path for + /// clipping. + /// 2. If [customBorder] is null, and [borderRadius] is provided, then the + /// canvas is clipped by an [RRect] created from [borderRadius]. + /// 3. If [borderRadius] is the default [BorderRadius.zero], then the canvas + /// is clipped with [rectCallback]. + /// When the ripple is removed, [onRemoved] will be called. + /// + /// [turbulenceSeed] can be passed if a non random seed should be used for + /// the turbulence and sparkles. By default, the seed is a random number + /// between 0.0 and 1000.0. + /// + /// Turbulence is an input to the shader and helps to provides a more natural, + /// non-circular, "splash" effect. + /// + /// Sparkle randomization is also driven by the [turbulenceSeed]. Sparkles are + /// identified in the shader as "noise", and the sparkles are derived from + /// pseudorandom triangular noise. + InkSparkle({ + required super.controller, + required super.referenceBox, + required super.color, + required Offset position, + required TextDirection textDirection, + bool containedInkWell = true, + RectCallback? rectCallback, + BorderRadius? borderRadius, + super.customBorder, + double? radius, + super.onRemoved, + double? turbulenceSeed, + }) : assert(containedInkWell || rectCallback == null), + _color = color, + _position = position, + _borderRadius = borderRadius ?? BorderRadius.zero, + _textDirection = textDirection, + _targetRadius = + (radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position)) * + _targetRadiusMultiplier, + _clipCallback = _getClipCallback(referenceBox, containedInkWell, rectCallback) { + // InkSparkle will not be painted until the async compilation completes. + _InkSparkleFactory.initializeShader(); + controller.addInkFeature(this); + + // Immediately begin animating the ink. + _animationController = + AnimationController(duration: _animationDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..addStatusListener(_handleStatusChanged) + ..forward(); + + _radiusScale = TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>(tween: CurveTween(curve: Curves.fastOutSlowIn), weight: 75), + TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 25), + ]).animate(_animationController); + + // Functionally equivalent to Android 12's SkSL: + //`return mix(u_touch, u_resolution, saturate(in_radius_scale * 2.0))` + final centerTween = Tween<Vector2>( + begin: Vector2.array(<double>[_position.dx, _position.dy]), + end: Vector2.array(<double>[referenceBox.size.width / 2, referenceBox.size.height / 2]), + ); + final Animation<double> centerProgress = TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 50), + TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 50), + ]).animate(_radiusScale); + _center = centerTween.animate(centerProgress); + + _alpha = TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 13), + TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 27), + TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0), weight: 60), + ]).animate(_animationController); + + _sparkleAlpha = TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 13), + TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 27), + TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0), weight: 50), + ]).animate(_animationController); + + // Creates an element of randomness so that ink emanating from the same + // pixel have slightly different rings and sparkles. + assert(() { + // In tests, randomness can cause flakes. So if a seed has not + // already been specified (i.e. for the purpose of the test), set it to + // the constant turbulence seed. + turbulenceSeed ??= _InkSparkleFactory.constantSeed; + return true; + }()); + _turbulenceSeed = turbulenceSeed ?? math.Random().nextDouble() * 1000.0; + } + + void _handleStatusChanged(AnimationStatus status) { + if (status.isCompleted) { + dispose(); + } + } + + static const Duration _animationDuration = Duration(milliseconds: 617); + static const double _targetRadiusMultiplier = 2.3; + static const double _rotateRight = math.pi * 0.0078125; + static const double _rotateLeft = -_rotateRight; + static const double _noiseDensity = 2.1; + + late AnimationController _animationController; + + // The Android 12 version has these values calculated in the GLSL. They are + // constant for every pixel in the animation, so the Flutter implementation + // computes these animation values in software in order to simplify the shader + // implementation and provide better performance on most devices. + late Animation<Vector2> _center; + late Animation<double> _radiusScale; + late Animation<double> _alpha; + late Animation<double> _sparkleAlpha; + + late double _turbulenceSeed; + + final Color _color; + final Offset _position; + final BorderRadius _borderRadius; + final double _targetRadius; + final RectCallback? _clipCallback; + final TextDirection _textDirection; + + late final ui.FragmentShader _fragmentShader; + bool _fragmentShaderInitialized = false; + + /// Used to specify this type of ink splash for an [InkWell], [InkResponse], + /// material [Theme], or [ButtonStyle]. + /// + /// Since no `turbulenceSeed` is passed, the effect will be random for + /// subsequent presses in the same position. + static const InteractiveInkFeatureFactory splashFactory = _InkSparkleFactory(); + + /// Used to specify this type of ink splash for an [InkWell], [InkResponse], + /// material [Theme], or [ButtonStyle]. + /// + /// Since a `turbulenceSeed` is passed, the effect will not be random for + /// subsequent presses in the same position. This can be used for testing. + static const InteractiveInkFeatureFactory constantTurbulenceSeedSplashFactory = + _InkSparkleFactory.constantTurbulenceSeed(); + + @override + void dispose() { + _animationController.stop(); + _animationController.dispose(); + if (_fragmentShaderInitialized) { + _fragmentShader.dispose(); + } + super.dispose(); + } + + @override + void paintFeature(Canvas canvas, Matrix4 transform) { + assert(_animationController.isAnimating); + + // InkSparkle can only paint if its shader has been compiled. + if (_InkSparkleFactory._program == null) { + // Skipping paintFeature because the shader it relies on is not ready to + // be used. InkSparkleFactory.initializeShader must complete + // before InkSparkle can paint. + return; + } + + if (!_fragmentShaderInitialized) { + _fragmentShader = _InkSparkleFactory._program!.fragmentShader(); + _fragmentShaderInitialized = true; + } + + canvas.save(); + _transformCanvas(canvas: canvas, transform: transform); + if (_clipCallback != null) { + _clipCanvas( + canvas: canvas, + clipCallback: _clipCallback, + textDirection: _textDirection, + customBorder: customBorder, + borderRadius: _borderRadius, + ); + } + + _updateFragmentShader(); + + final paint = Paint()..shader = _fragmentShader; + if (_clipCallback != null) { + canvas.drawRect(_clipCallback(), paint); + } else { + canvas.drawPaint(paint); + } + canvas.restore(); + } + + double get _width => referenceBox.size.width; + double get _height => referenceBox.size.height; + + /// All double values for uniforms come from the Android 12 ripple + /// implementation from the following files: + /// - https://cs.android.com/android/platform/superproject/+/main:frameworks/base/graphics/java/android/graphics/drawable/RippleShader.java + /// - https://cs.android.com/android/platform/superproject/+/main:frameworks/base/graphics/java/android/graphics/drawable/RippleDrawable.java + /// - https://cs.android.com/android/platform/superproject/+/main:frameworks/base/graphics/java/android/graphics/drawable/RippleAnimationSession.java + void _updateFragmentShader() { + const turbulenceScale = 1.5; + final double turbulencePhase = _turbulenceSeed + _radiusScale.value; + final noisePhase = turbulencePhase; + final double rotation1 = turbulencePhase * _rotateRight + 1.7 * math.pi; + final double rotation2 = turbulencePhase * _rotateLeft + 2.0 * math.pi; + final double rotation3 = turbulencePhase * _rotateRight + 2.75 * math.pi; + + _fragmentShader + // uColor + ..setFloat(0, _color.red / 255.0) + ..setFloat(1, _color.green / 255.0) + ..setFloat(2, _color.blue / 255.0) + ..setFloat(3, _color.alpha / 255.0) + // Composite 1 (u_alpha, u_sparkle_alpha, u_blur, u_radius_scale) + ..setFloat(4, _alpha.value) + ..setFloat(5, _sparkleAlpha.value) + ..setFloat(6, 1.0) + ..setFloat(7, _radiusScale.value) + // uCenter + ..setFloat(8, _center.value.x) + ..setFloat(9, _center.value.y) + // uMaxRadius + ..setFloat(10, _targetRadius) + // uResolutionScale + ..setFloat(11, 1.0 / _width) + ..setFloat(12, 1.0 / _height) + // uNoiseScale + ..setFloat(13, _noiseDensity / _width) + ..setFloat(14, _noiseDensity / _height) + // uNoisePhase + ..setFloat(15, noisePhase / 1000.0) + // uCircle1 + ..setFloat( + 16, + turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.cos(turbulenceScale * 0.55)), + ) + ..setFloat( + 17, + turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.sin(turbulenceScale * 0.55)), + ) + // uCircle2 + ..setFloat( + 18, + turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.45)), + ) + ..setFloat( + 19, + turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.45)), + ) + // uCircle3 + ..setFloat( + 20, + turbulenceScale + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.35)), + ) + ..setFloat( + 21, + turbulenceScale + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.35)), + ) + // uRotation1 + ..setFloat(22, math.cos(rotation1)) + ..setFloat(23, math.sin(rotation1)) + // uRotation2 + ..setFloat(24, math.cos(rotation2)) + ..setFloat(25, math.sin(rotation2)) + // uRotation3 + ..setFloat(26, math.cos(rotation3)) + ..setFloat(27, math.sin(rotation3)); + } + + /// Transforms the canvas for an ink feature to be painted on the [canvas]. + /// + /// This should be called before painting ink features that do not use + /// [paintInkCircle]. + /// + /// The [transform] argument is the [Matrix4] transform that typically + /// shifts the coordinate space of the canvas to the space in which + /// the ink feature is to be painted. + /// + /// For examples on how the function is used, see [InkSparkle] and [paintInkCircle]. + void _transformCanvas({required Canvas canvas, required Matrix4 transform}) { + final Offset? originOffset = MatrixUtils.getAsTranslation(transform); + if (originOffset == null) { + canvas.transform(transform.storage); + } else { + canvas.translate(originOffset.dx, originOffset.dy); + } + } + + /// Clips the canvas for an ink feature to be painted on the [canvas]. + /// + /// This should be called before painting ink features with [paintFeature] + /// that do not use [paintInkCircle]. + /// + /// The [clipCallback] is the callback used to obtain the [Rect] used for clipping + /// the ink effect. + /// + /// If [clipCallback] is null, no clipping is performed on the ink circle. + /// + /// The [textDirection] is used by [customBorder] if it is non-null. This + /// allows the [customBorder]'s path to be properly defined if the path was + /// expressed in terms of "start" and "end" instead of "left" and "right". + /// + /// For examples on how the function is used, see [InkSparkle]. + void _clipCanvas({ + required Canvas canvas, + required RectCallback clipCallback, + TextDirection? textDirection, + ShapeBorder? customBorder, + BorderRadius borderRadius = BorderRadius.zero, + }) { + final Rect rect = clipCallback(); + if (customBorder != null) { + canvas.clipPath(customBorder.getOuterPath(rect, textDirection: textDirection)); + } else if (borderRadius != BorderRadius.zero) { + canvas.clipRRect( + RRect.fromRectAndCorners( + rect, + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight, + ), + ); + } else { + canvas.clipRect(rect); + } + } +} + +class _InkSparkleFactory extends InteractiveInkFeatureFactory { + const _InkSparkleFactory() : turbulenceSeed = null; + + const _InkSparkleFactory.constantTurbulenceSeed() + : turbulenceSeed = _InkSparkleFactory.constantSeed; + + static const double constantSeed = 1337.0; + + static void initializeShader() { + if (!_initCalled) { + ui.FragmentProgram.fromAsset('shaders/ink_sparkle.frag').then((ui.FragmentProgram program) { + _program = program; + }); + _initCalled = true; + } + } + + static bool _initCalled = false; + static ui.FragmentProgram? _program; + + final double? turbulenceSeed; + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required ui.Offset position, + required ui.Color color, + required ui.TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + ui.VoidCallback? onRemoved, + }) { + return InkSparkle( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + textDirection: textDirection, + containedInkWell: containedInkWell, + rectCallback: rectCallback, + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + turbulenceSeed: turbulenceSeed, + ); + } +} + +RectCallback? _getClipCallback( + RenderBox referenceBox, + bool containedInkWell, + RectCallback? rectCallback, +) { + if (rectCallback != null) { + assert(containedInkWell); + return rectCallback; + } + if (containedInkWell) { + return () => Offset.zero & referenceBox.size; + } + return null; +} + +double _getTargetRadius( + RenderBox referenceBox, + bool containedInkWell, + RectCallback? rectCallback, + Offset position, +) { + final Size size = rectCallback != null ? rectCallback().size : referenceBox.size; + final double d1 = size.bottomRight(Offset.zero).distance; + final double d2 = (size.topRight(Offset.zero) - size.bottomLeft(Offset.zero)).distance; + return math.max(d1, d2) / 2.0; +} diff --git a/packages/material_ui/lib/src/ink_splash.dart b/packages/material_ui/lib/src/ink_splash.dart new file mode 100644 index 000000000000..859ceeb1423d --- /dev/null +++ b/packages/material_ui/lib/src/ink_splash.dart @@ -0,0 +1,231 @@ +// 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. + +/// @docImport 'button_style.dart'; +/// @docImport 'ink_decoration.dart'; +/// @docImport 'ink_highlight.dart'; +/// @docImport 'ink_ripple.dart'; +/// @docImport 'theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'ink_well.dart'; +import 'material.dart'; + +const Duration _kUnconfirmedSplashDuration = Duration(seconds: 1); +const Duration _kSplashFadeDuration = Duration(milliseconds: 200); + +const double _kSplashInitialSize = 0.0; // logical pixels +const double _kSplashConfirmedVelocity = 1.0; // logical pixels per millisecond + +RectCallback? _getClipCallback( + RenderBox referenceBox, + bool containedInkWell, + RectCallback? rectCallback, +) { + if (rectCallback != null) { + assert(containedInkWell); + return rectCallback; + } + if (containedInkWell) { + return () => Offset.zero & referenceBox.size; + } + return null; +} + +double _getTargetRadius( + RenderBox referenceBox, + bool containedInkWell, + RectCallback? rectCallback, + Offset position, +) { + if (containedInkWell) { + final Size size = rectCallback != null ? rectCallback().size : referenceBox.size; + return _getSplashRadiusForPositionInSize(size, position); + } + return Material.defaultSplashRadius; +} + +double _getSplashRadiusForPositionInSize(Size bounds, Offset position) { + final double d1 = (position - bounds.topLeft(Offset.zero)).distance; + final double d2 = (position - bounds.topRight(Offset.zero)).distance; + final double d3 = (position - bounds.bottomLeft(Offset.zero)).distance; + final double d4 = (position - bounds.bottomRight(Offset.zero)).distance; + return math.max(math.max(d1, d2), math.max(d3, d4)).ceilToDouble(); +} + +class _InkSplashFactory extends InteractiveInkFeatureFactory { + const _InkSplashFactory(); + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + return InkSplash( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: containedInkWell, + rectCallback: rectCallback, + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + textDirection: textDirection, + ); + } +} + +/// A visual reaction on a piece of [Material] to user input. +/// +/// A circular ink feature whose origin starts at the input touch point +/// and whose radius expands from zero. +/// +/// This object is rarely created directly. Instead of creating an ink splash +/// directly, consider using an [InkResponse] or [InkWell] widget, which uses +/// gestures (such as tap and long-press) to trigger ink splashes. +/// +/// See also: +/// +/// * [InkRipple], which is an ink splash feature that expands more +/// aggressively than this class does. +/// * [InkResponse], which uses gestures to trigger ink highlights and ink +/// splashes in the parent [Material]. +/// * [InkWell], which is a rectangular [InkResponse] (the most common type of +/// ink response). +/// * [Material], which is the widget on which the ink splash is painted. +/// * [InkHighlight], which is an ink feature that emphasizes a part of a +/// [Material]. +/// * [Ink], a convenience widget for drawing images and other decorations on +/// Material widgets. +class InkSplash extends InteractiveInkFeature { + /// Begin a splash, centered at position relative to [referenceBox]. + /// + /// The [controller] argument is typically obtained via + /// `Material.of(context)`. + /// + /// If `containedInkWell` is true, then the splash will be sized to fit + /// the well rectangle, then clipped to it when drawn. The well + /// rectangle is the box returned by `rectCallback`, if provided, or + /// otherwise is the bounds of the [referenceBox]. + /// + /// If `containedInkWell` is false, then `rectCallback` should be null. + /// The ink splash is clipped only to the edges of the [Material]. + /// This is the default. + /// + /// When the splash is removed, `onRemoved` will be called. + InkSplash({ + required MaterialInkController controller, + required super.referenceBox, + required TextDirection textDirection, + Offset? position, + required Color color, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + super.customBorder, + double? radius, + super.onRemoved, + }) : _position = position, + _borderRadius = borderRadius ?? BorderRadius.zero, + _targetRadius = + radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position!), + _clipCallback = _getClipCallback(referenceBox, containedInkWell, rectCallback), + _repositionToReferenceBox = !containedInkWell, + _textDirection = textDirection, + super(controller: controller, color: color) { + _radiusController = + AnimationController(duration: _kUnconfirmedSplashDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..forward(); + _radius = _radiusController.drive( + Tween<double>(begin: _kSplashInitialSize, end: _targetRadius), + ); + _alphaController = AnimationController(duration: _kSplashFadeDuration, vsync: controller.vsync) + ..addListener(controller.markNeedsPaint) + ..addStatusListener(_handleAlphaStatusChanged); + _alpha = _alphaController!.drive(IntTween(begin: color.alpha, end: 0)); + + controller.addInkFeature(this); + } + + final Offset? _position; + final BorderRadius _borderRadius; + final double _targetRadius; + final RectCallback? _clipCallback; + final bool _repositionToReferenceBox; + final TextDirection _textDirection; + + late Animation<double> _radius; + late AnimationController _radiusController; + + late Animation<int> _alpha; + AnimationController? _alphaController; + + /// Used to specify this type of ink splash for an [InkWell], [InkResponse], + /// material [Theme], or [ButtonStyle]. + static const InteractiveInkFeatureFactory splashFactory = _InkSplashFactory(); + + @override + void confirm() { + final int duration = (_targetRadius / _kSplashConfirmedVelocity).floor(); + _radiusController + ..duration = Duration(milliseconds: duration) + ..forward(); + _alphaController!.forward(); + } + + @override + void cancel() { + _alphaController?.forward(); + } + + void _handleAlphaStatusChanged(AnimationStatus status) { + if (status.isCompleted) { + dispose(); + } + } + + @override + void dispose() { + _radiusController.dispose(); + _alphaController!.dispose(); + _alphaController = null; + super.dispose(); + } + + @override + void paintFeature(Canvas canvas, Matrix4 transform) { + final paint = Paint()..color = color.withAlpha(_alpha.value); + Offset? center = _position; + if (_repositionToReferenceBox) { + center = Offset.lerp(center, referenceBox.size.center(Offset.zero), _radiusController.value); + } + paintInkCircle( + canvas: canvas, + transform: transform, + paint: paint, + center: center!, + textDirection: _textDirection, + radius: _radius.value, + customBorder: customBorder, + borderRadius: _borderRadius, + clipCallback: _clipCallback, + ); + } +} diff --git a/packages/material_ui/lib/src/ink_well.dart b/packages/material_ui/lib/src/ink_well.dart new file mode 100644 index 000000000000..ace2711a1fae --- /dev/null +++ b/packages/material_ui/lib/src/ink_well.dart @@ -0,0 +1,1548 @@ +// 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. + +/// @docImport 'data_table.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'ink_decoration.dart'; +/// @docImport 'ink_ripple.dart'; +/// @docImport 'ink_splash.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'ink_highlight.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// An ink feature that displays a [color] "splash" in response to a user +/// gesture that can be confirmed or canceled. +/// +/// Subclasses call [confirm] when an input gesture is recognized. For +/// example a press event might trigger an ink feature that's confirmed +/// when the corresponding up event is seen. +/// +/// Subclasses call [cancel] when an input gesture is aborted before it +/// is recognized. For example a press event might trigger an ink feature +/// that's canceled when the pointer is dragged out of the reference +/// box. +/// +/// The [InkWell] and [InkResponse] widgets generate instances of this +/// class. +abstract class InteractiveInkFeature extends InkFeature { + /// Creates an InteractiveInkFeature. + InteractiveInkFeature({ + required super.controller, + required super.referenceBox, + required Color color, + ShapeBorder? customBorder, + super.onRemoved, + }) : _color = color, + _customBorder = customBorder; + + /// Called when the user input that triggered this feature's appearance was confirmed. + /// + /// Typically causes the ink to propagate faster across the material. By default this + /// method does nothing. + void confirm() {} + + /// Called when the user input that triggered this feature's appearance was canceled. + /// + /// Typically causes the ink to gradually disappear. By default this method does + /// nothing. + void cancel() {} + + /// The ink's color. + Color get color => _color; + Color _color; + set color(Color value) { + if (value == _color) { + return; + } + _color = value; + controller.markNeedsPaint(); + } + + /// The ink's optional custom border. + ShapeBorder? get customBorder => _customBorder; + ShapeBorder? _customBorder; + set customBorder(ShapeBorder? value) { + if (value == _customBorder) { + return; + } + _customBorder = value; + controller.markNeedsPaint(); + } + + /// Draws an ink splash or ink ripple on the passed in [Canvas]. + /// + /// The [transform] argument is the [Matrix4] transform that typically + /// shifts the coordinate space of the canvas to the space in which + /// the ink circle is to be painted. + /// + /// [center] is the [Offset] from origin of the canvas where the center + /// of the circle is drawn. + /// + /// [paint] takes a [Paint] object that describes the styles used to draw the ink circle. + /// For example, [paint] can specify properties like color, strokewidth, colorFilter. + /// + /// [radius] is the radius of ink circle to be drawn on canvas. + /// + /// [clipCallback] is the callback used to obtain the [Rect] used for clipping the ink effect. + /// If [clipCallback] is null, no clipping is performed on the ink circle. + /// + /// Clipping can happen in 3 different ways: + /// 1. If [customBorder] is provided, it is used to determine the path + /// for clipping. + /// 2. If [customBorder] is null, and [borderRadius] is provided, the canvas + /// is clipped by an [RRect] created from [clipCallback] and [borderRadius]. + /// 3. If [borderRadius] is the default [BorderRadius.zero], then the [Rect] provided + /// by [clipCallback] is used for clipping. + /// + /// [textDirection] is used by [customBorder] if it is non-null. This allows the [customBorder]'s path + /// to be properly defined if it was the path was expressed in terms of "start" and "end" instead of + /// "left" and "right". + /// + /// For examples on how the function is used, see [InkSplash] and [InkRipple]. + @protected + void paintInkCircle({ + required Canvas canvas, + required Matrix4 transform, + required Paint paint, + required Offset center, + required double radius, + TextDirection? textDirection, + ShapeBorder? customBorder, + BorderRadius borderRadius = BorderRadius.zero, + RectCallback? clipCallback, + }) { + final Offset? originOffset = MatrixUtils.getAsTranslation(transform); + canvas.save(); + if (originOffset == null) { + canvas.transform(transform.storage); + } else { + canvas.translate(originOffset.dx, originOffset.dy); + } + if (clipCallback != null) { + final Rect rect = clipCallback(); + if (customBorder != null) { + canvas.clipPath(customBorder.getOuterPath(rect, textDirection: textDirection)); + } else if (borderRadius != BorderRadius.zero) { + canvas.clipRRect( + RRect.fromRectAndCorners( + rect, + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight, + ), + ); + } else { + canvas.clipRect(rect); + } + } + canvas.drawCircle(center, radius, paint); + canvas.restore(); + } +} + +/// An encapsulation of an [InteractiveInkFeature] constructor used by +/// [InkWell], [InkResponse], and [ThemeData]. +/// +/// Interactive ink feature implementations should provide a static const +/// `splashFactory` value that's an instance of this class. The `splashFactory` +/// can be used to configure an [InkWell], [InkResponse] or [ThemeData]. +/// +/// See also: +/// +/// * [InkSplash.splashFactory] +/// * [InkRipple.splashFactory] +abstract class InteractiveInkFeatureFactory { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + /// + /// Subclasses should provide a const constructor. + const InteractiveInkFeatureFactory(); + + /// The factory method. + /// + /// Subclasses should override this method to return a new instance of an + /// [InteractiveInkFeature]. + @factory + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }); +} + +abstract class _ParentInkResponseState { + void markChildInkResponsePressed(_ParentInkResponseState childState, bool value); +} + +class _ParentInkResponseProvider extends InheritedWidget { + const _ParentInkResponseProvider({required this.state, required super.child}); + + final _ParentInkResponseState state; + + @override + bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => state != oldWidget.state; + + static _ParentInkResponseState? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>()?.state; + } +} + +typedef _GetRectCallback = RectCallback? Function(RenderBox referenceBox); +typedef _CheckContext = bool Function(BuildContext context); + +/// An area of a [Material] that responds to touch. Has a configurable shape and +/// can be configured to clip splashes that extend outside its bounds or not. +/// +/// For a variant of this widget that is specialized for rectangular areas that +/// always clip splashes, see [InkWell]. +/// +/// An [InkResponse] widget does two things when responding to a tap: +/// +/// * It starts to animate a _highlight_. The shape of the highlight is +/// determined by [highlightShape]. If it is a [BoxShape.circle], the +/// default, then the highlight is a circle of fixed size centered in the +/// [InkResponse]. If it is [BoxShape.rectangle], then the highlight is a box +/// the size of the [InkResponse] itself, unless [getRectCallback] is +/// provided, in which case that callback defines the rectangle. The color of +/// the highlight is set by [highlightColor]. +/// +/// * Simultaneously, it starts to animate a _splash_. This is a growing circle +/// initially centered on the tap location. If this is a [containedInkWell], +/// the splash grows to the [radius] while remaining centered at the tap +/// location. Otherwise, the splash migrates to the center of the box as it +/// grows. +/// +/// The following two diagrams show how [InkResponse] looks when tapped if the +/// [highlightShape] is [BoxShape.circle] (the default) and [containedInkWell] +/// is false (also the default). +/// +/// The first diagram shows how it looks if the [InkResponse] is relatively +/// large: +/// +/// ![The highlight is a disc centered in the box, smaller than the child widget.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_large.png) +/// +/// The second diagram shows how it looks if the [InkResponse] is small: +/// +/// ![The highlight is a disc overflowing the box, centered on the child.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_small.png) +/// +/// The main thing to notice from these diagrams is that the splashes happily +/// exceed the bounds of the widget (because [containedInkWell] is false). +/// +/// The following diagram shows the effect when the [InkResponse] has a +/// [highlightShape] of [BoxShape.rectangle] with [containedInkWell] set to +/// true. These are the values used by [InkWell]. +/// +/// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png) +/// +/// The [InkResponse] widget must have a [Material] widget as an ancestor. The +/// [Material] widget is where the ink reactions are actually painted. This +/// matches the Material Design premise wherein the [Material] is what is +/// actually reacting to touches by spreading ink. +/// +/// If a Widget uses this class directly, it should include the following line +/// at the top of its build function to call [debugCheckHasMaterial]: +/// +/// ```dart +/// assert(debugCheckHasMaterial(context)); +/// ``` +/// +/// ## Troubleshooting +/// +/// ### The ink splashes aren't visible! +/// +/// If there is an opaque graphic, e.g. painted using a [Container], [Image], or +/// [DecoratedBox], between the [Material] widget and the [InkResponse] widget, +/// then the splash won't be visible because it will be under the opaque graphic. +/// This is because ink splashes draw on the underlying [Material] itself, as +/// if the ink was spreading inside the material. +/// +/// The [Ink] widget can be used as a replacement for [Image], [Container], or +/// [DecoratedBox] to ensure that the image or decoration also paints in the +/// [Material] itself, below the ink. +/// +/// If this is not possible for some reason, e.g. because you are using an +/// opaque [CustomPaint] widget, alternatively consider using a second +/// [Material] above the opaque widget but below the [InkResponse] (as an +/// ancestor to the ink response). The [MaterialType.transparency] material +/// kind can be used for this purpose. +/// +/// See also: +/// +/// * [GestureDetector], for listening for gestures without ink splashes. +/// * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design. +/// * [IconButton], which combines [InkResponse] with an [Icon]. +class InkResponse extends StatelessWidget { + /// Creates an area of a [Material] that responds to touch. + /// + /// Must have an ancestor [Material] widget in which to cause ink reactions. + const InkResponse({ + super.key, + this.child, + this.onTap, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onDoubleTap, + this.onLongPress, + this.onLongPressUp, + this.onSecondaryTap, + this.onSecondaryTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapCancel, + this.onHighlightChanged, + this.onHover, + this.mouseCursor, + this.containedInkWell = false, + this.highlightShape = BoxShape.circle, + this.radius, + this.borderRadius, + this.customBorder, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.overlayColor, + this.splashColor, + this.splashFactory, + this.enableFeedback = true, + this.excludeFromSemantics = false, + this.focusNode, + this.canRequestFocus = true, + this.onFocusChange, + this.autofocus = false, + this.statesController, + this.hoverDuration, + }); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Called when the user taps this part of the material. + final GestureTapCallback? onTap; + + /// Called when the user taps down this part of the material. + final GestureTapDownCallback? onTapDown; + + /// Called when the user releases a tap that was started on this part of the + /// material. [onTap] is called immediately after. + final GestureTapUpCallback? onTapUp; + + /// Called when the user cancels a tap that was started on this part of the + /// material. + final GestureTapCallback? onTapCancel; + + /// Called when the user double taps this part of the material. + final GestureTapCallback? onDoubleTap; + + /// Called when the user long-presses on this part of the material. + final GestureLongPressCallback? onLongPress; + + /// Called when the user lifts their finger after a long press on the button. + /// + /// This callback is triggered at the end of a long press gesture, specifically + /// after the user holds a long press and then releases it. It does not include + /// position details. + /// + /// Common use cases include performing an action only after the long press completes, + /// such as displaying a context menu or confirming a held gesture. + /// + /// See also: + /// * [onLongPress], which is triggered when the long press gesture is first recognized. + final GestureLongPressUpCallback? onLongPressUp; + + /// Called when the user taps this part of the material with a secondary button. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapCallback? onSecondaryTap; + + /// Called when the user taps down on this part of the material with a + /// secondary button. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapDownCallback? onSecondaryTapDown; + + /// Called when the user releases a secondary button tap that was started on + /// this part of the material. [onSecondaryTap] is called immediately after. + /// + /// See also: + /// + /// * [onSecondaryTap], a handler triggered right after this one that doesn't + /// pass any details about the tap. + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapUpCallback? onSecondaryTapUp; + + /// Called when the user cancels a secondary button tap that was started on + /// this part of the material. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapCallback? onSecondaryTapCancel; + + /// Called when this part of the material either becomes highlighted or stops + /// being highlighted. + /// + /// The value passed to the callback is true if this part of the material has + /// become highlighted and false if this part of the material has stopped + /// being highlighted. + /// + /// If all of [onTap], [onDoubleTap], [onLongPress], and [onLongPressUp] become null while a + /// gesture is ongoing, then [onTapCancel] will be fired and + /// [onHighlightChanged] will be fired with the value false _during the + /// build_. This means, for instance, that in that scenario [State.setState] + /// cannot be called. + final ValueChanged<bool>? onHighlightChanged; + + /// Called when a pointer enters or exits the ink response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the material and false if a pointer has exited this part of the + /// material. + final ValueChanged<bool>? onHover; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// {@template flutter.material.InkWell.mouseCursor} + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? mouseCursor; + + /// Whether this ink response should be clipped its bounds. + /// + /// This flag also controls whether the splash migrates to the center of the + /// [InkResponse] or not. If [containedInkWell] is true, the splash remains + /// centered around the tap location. If it is false, the splash migrates to + /// the center of the [InkResponse] as it grows. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [borderRadius], which controls the corners when the box is a rectangle. + /// * [getRectCallback], which controls the size and position of the box when + /// it is a rectangle. + final bool containedInkWell; + + /// The shape (e.g., circle, rectangle) to use for the highlight drawn around + /// this part of the material when pressed, hovered over, or focused. + /// + /// The same shape is used for the pressed highlight (see [highlightColor]), + /// the focus highlight (see [focusColor]), and the hover highlight (see + /// [hoverColor]). + /// + /// If the shape is [BoxShape.circle], then the highlight is centered on the + /// [InkResponse]. If the shape is [BoxShape.rectangle], then the highlight + /// fills the [InkResponse], or the rectangle provided by [getRectCallback] if + /// the callback is specified. + /// + /// See also: + /// + /// * [containedInkWell], which controls clipping behavior. + /// * [borderRadius], which controls the corners when the box is a rectangle. + /// * [highlightColor], the color of the highlight. + /// * [getRectCallback], which controls the size and position of the box when + /// it is a rectangle. + final BoxShape highlightShape; + + /// The radius of the ink splash. + /// + /// Splashes grow up to this size. By default, this size is determined from + /// the size of the rectangle provided by [getRectCallback], or the size of + /// the [InkResponse] itself. + /// + /// See also: + /// + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final double? radius; + + /// The border radius of the containing rectangle. This is effective only if + /// [highlightShape] is [BoxShape.rectangle]. + /// + /// If this is null, it is interpreted as [BorderRadius.zero]. + final BorderRadius? borderRadius; + + /// The custom clip border. + /// + /// If this is null, the ink response will not clip its content. + final ShapeBorder? customBorder; + + /// The color of the ink response when the parent widget is focused. If this + /// property is null then the focus color of the theme, + /// [ThemeData.focusColor], will be used. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [hoverColor], the color of the hover highlight. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? focusColor; + + /// The color of the ink response when a pointer is hovering over it. If this + /// property is null then the hover color of the theme, + /// [ThemeData.hoverColor], will be used. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [highlightColor], the color of the pressed highlight. + /// * [focusColor], the color of the focus highlight. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? hoverColor; + + /// The highlight color of the ink response when pressed. If this property is + /// null then the highlight color of the theme, [ThemeData.highlightColor], + /// will be used. + /// + /// See also: + /// + /// * [hoverColor], the color of the hover highlight. + /// * [focusColor], the color of the focus highlight. + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? highlightColor; + + /// Defines the ink response focus, hover, and splash colors. + /// + /// This default null property can be used as an alternative to + /// [focusColor], [hoverColor], [highlightColor], and + /// [splashColor]. If non-null, it is resolved against one of + /// [WidgetState.focused], [WidgetState.hovered], and + /// [WidgetState.pressed]. It's convenient to use when the parent + /// widget can pass along its own WidgetStateProperty value for + /// the overlay color. + /// + /// [WidgetState.pressed] triggers a ripple (an ink splash), per + /// the current Material Design spec. The [overlayColor] doesn't map + /// a state to [highlightColor] because a separate highlight is not + /// used by the current design guidelines. See + /// https://material.io/design/interaction/states.html#pressed + /// + /// If the overlay color is null or resolves to null, then [focusColor], + /// [hoverColor], [splashColor] and their defaults are used instead. + /// + /// See also: + /// + /// * The Material Design specification for overlay colors and how they + /// match a component's state: + /// <https://material.io/design/interaction/states.html#anatomy>. + final WidgetStateProperty<Color?>? overlayColor; + + /// The splash color of the ink response. If this property is null then the + /// splash color of the theme, [ThemeData.splashColor], will be used. + /// + /// See also: + /// + /// * [splashFactory], which defines the appearance of the splash. + /// * [radius], the (maximum) size of the ink splash. + /// * [highlightColor], the color of the highlight. + final Color? splashColor; + + /// Defines the appearance of the splash. + /// + /// Defaults to the value of the theme's splash factory: [ThemeData.splashFactory]. + /// + /// See also: + /// + /// * [radius], the (maximum) size of the ink splash. + /// * [splashColor], the color of the splash. + /// * [highlightColor], the color of the highlight. + /// * [InkSplash.splashFactory], which defines the default splash. + /// * [InkRipple.splashFactory], which defines a splash that spreads out + /// more aggressively than the default. + final InteractiveInkFeatureFactory? splashFactory; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + /// Whether to exclude the gestures introduced by this widget from the + /// semantics tree. + /// + /// For example, a long-press gesture for showing a tooltip is usually + /// excluded because the tooltip itself is included in the semantics + /// tree directly and so having a gesture to show it would result in + /// duplication of information. + final bool excludeFromSemantics; + + /// {@template flutter.material.inkwell.onFocusChange} + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + /// {@endtemplate} + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.canRequestFocus} + final bool canRequestFocus; + + /// The rectangle to use for the highlight effect and for clipping + /// the splash effects if [containedInkWell] is true. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [TableRowInkWell] implements this method to return the rectangle + /// corresponding to the row that the widget is in. + /// + /// The default behavior returns null, which is equivalent to + /// returning the referenceBox argument's bounding box (though + /// slightly more efficient). + RectCallback? getRectCallback(RenderBox referenceBox) => null; + + /// {@template flutter.material.inkwell.statesController} + /// Represents the interactive "state" of this widget in terms of + /// a set of [WidgetState]s, like [WidgetState.pressed] and + /// [WidgetState.focused]. + /// + /// Classes based on this one can provide their own + /// [WidgetStatesController] to which they've added listeners. + /// They can also update the controller's [WidgetStatesController.value] + /// however, this may only be done when it's safe to call + /// [State.setState], like in an event handler. + /// {@endtemplate} + final MaterialStatesController? statesController; + + /// The duration of the animation that animates the hover effect. + /// + /// The default is 50ms. + final Duration? hoverDuration; + + @override + Widget build(BuildContext context) { + final _ParentInkResponseState? parentState = _ParentInkResponseProvider.maybeOf(context); + return _InkResponseStateWidget( + onTap: onTap, + onTapDown: onTapDown, + onTapUp: onTapUp, + onTapCancel: onTapCancel, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + onLongPressUp: onLongPressUp, + onSecondaryTap: onSecondaryTap, + onSecondaryTapUp: onSecondaryTapUp, + onSecondaryTapDown: onSecondaryTapDown, + onSecondaryTapCancel: onSecondaryTapCancel, + onHighlightChanged: onHighlightChanged, + onHover: onHover, + mouseCursor: mouseCursor, + containedInkWell: containedInkWell, + highlightShape: highlightShape, + radius: radius, + borderRadius: borderRadius, + customBorder: customBorder, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + overlayColor: overlayColor, + splashColor: splashColor, + splashFactory: splashFactory, + enableFeedback: enableFeedback, + excludeFromSemantics: excludeFromSemantics, + focusNode: focusNode, + canRequestFocus: canRequestFocus, + onFocusChange: onFocusChange, + autofocus: autofocus, + parentState: parentState, + getRectCallback: getRectCallback, + debugCheckContext: debugCheckContext, + statesController: statesController, + hoverDuration: hoverDuration, + child: child, + ); + } + + /// Asserts that the given context satisfies the prerequisites for + /// this class. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [TableRowInkWell] implements this method to verify that the widget is + /// in a table. + @mustCallSuper + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasDirectionality(context)); + return true; + } +} + +class _InkResponseStateWidget extends StatefulWidget { + const _InkResponseStateWidget({ + this.child, + this.onTap, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onDoubleTap, + this.onLongPress, + this.onLongPressUp, + this.onSecondaryTap, + this.onSecondaryTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapCancel, + this.onHighlightChanged, + this.onHover, + this.mouseCursor, + this.containedInkWell = false, + this.highlightShape = BoxShape.circle, + this.radius, + this.borderRadius, + this.customBorder, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.overlayColor, + this.splashColor, + this.splashFactory, + this.enableFeedback = true, + this.excludeFromSemantics = false, + this.focusNode, + this.canRequestFocus = true, + this.onFocusChange, + this.autofocus = false, + this.parentState, + this.getRectCallback, + required this.debugCheckContext, + this.statesController, + this.hoverDuration, + }); + + final Widget? child; + final GestureTapCallback? onTap; + final GestureTapDownCallback? onTapDown; + final GestureTapUpCallback? onTapUp; + final GestureTapCallback? onTapCancel; + final GestureTapCallback? onDoubleTap; + final GestureLongPressCallback? onLongPress; + final GestureLongPressUpCallback? onLongPressUp; + final GestureTapCallback? onSecondaryTap; + final GestureTapUpCallback? onSecondaryTapUp; + final GestureTapDownCallback? onSecondaryTapDown; + final GestureTapCallback? onSecondaryTapCancel; + final ValueChanged<bool>? onHighlightChanged; + final ValueChanged<bool>? onHover; + final MouseCursor? mouseCursor; + final bool containedInkWell; + final BoxShape highlightShape; + final double? radius; + final BorderRadius? borderRadius; + final ShapeBorder? customBorder; + final Color? focusColor; + final Color? hoverColor; + final Color? highlightColor; + final WidgetStateProperty<Color?>? overlayColor; + final Color? splashColor; + final InteractiveInkFeatureFactory? splashFactory; + final bool enableFeedback; + final bool excludeFromSemantics; + final ValueChanged<bool>? onFocusChange; + final bool autofocus; + final FocusNode? focusNode; + final bool canRequestFocus; + final _ParentInkResponseState? parentState; + final _GetRectCallback? getRectCallback; + final _CheckContext debugCheckContext; + final MaterialStatesController? statesController; + final Duration? hoverDuration; + + @override + _InkResponseState createState() => _InkResponseState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final gestures = <String>[ + if (onTap != null) 'tap', + if (onDoubleTap != null) 'double tap', + if (onLongPress != null) 'long press', + if (onLongPressUp != null) 'long press up', + if (onTapDown != null) 'tap down', + if (onTapUp != null) 'tap up', + if (onTapCancel != null) 'tap cancel', + if (onSecondaryTap != null) 'secondary tap', + if (onSecondaryTapUp != null) 'secondary tap up', + if (onSecondaryTapDown != null) 'secondary tap down', + if (onSecondaryTapCancel != null) 'secondary tap cancel', + ]; + properties.add(IterableProperty<String>('gestures', gestures, ifEmpty: '<none>')); + properties.add(DiagnosticsProperty<MouseCursor>('mouseCursor', mouseCursor)); + properties.add( + DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine), + ); + properties.add( + DiagnosticsProperty<BoxShape>( + 'highlightShape', + highlightShape, + description: '${containedInkWell ? "clipped to " : ""}$highlightShape', + showName: false, + ), + ); + } +} + +/// Used to index the allocated highlights for the different types of highlights +/// in [_InkResponseState]. +enum _HighlightType { pressed, hover, focus } + +class _InkResponseState extends State<_InkResponseStateWidget> + with AutomaticKeepAliveClientMixin<_InkResponseStateWidget> + implements _ParentInkResponseState { + Set<InteractiveInkFeature>? _splashes; + InteractiveInkFeature? _currentSplash; + bool _hovering = false; + final Map<_HighlightType, InkHighlight?> _highlights = <_HighlightType, InkHighlight?>{}; + late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{ + ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: activateOnIntent), + ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: activateOnIntent), + }; + MaterialStatesController? internalStatesController; + + bool get highlightsExist => + _highlights.values.where((InkHighlight? highlight) => highlight != null).isNotEmpty; + + final ObserverList<_ParentInkResponseState> _activeChildren = + ObserverList<_ParentInkResponseState>(); + + static const Duration _activationDuration = Duration(milliseconds: 100); + Timer? _activationTimer; + + @override + void markChildInkResponsePressed(_ParentInkResponseState childState, bool value) { + final bool lastAnyPressed = _anyChildInkResponsePressed; + if (value) { + _activeChildren.add(childState); + } else { + _activeChildren.remove(childState); + } + final bool nowAnyPressed = _anyChildInkResponsePressed; + if (nowAnyPressed != lastAnyPressed) { + widget.parentState?.markChildInkResponsePressed(this, nowAnyPressed); + } + } + + bool get _anyChildInkResponsePressed => _activeChildren.isNotEmpty; + + void activateOnIntent(Intent? intent) { + _activationTimer?.cancel(); + _activationTimer = null; + _startNewSplash(context: context); + _currentSplash?.confirm(); + _currentSplash = null; + if (widget.onTap != null) { + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onTap?.call(); + } + // Delay the call to `updateHighlight` to simulate a pressed delay + // and give MaterialStatesController listeners a chance to react. + _activationTimer = Timer(_activationDuration, () { + updateHighlight(_HighlightType.pressed, value: false); + }); + } + + void simulateTap([Intent? intent]) { + _startNewSplash(context: context); + handleTap(); + } + + void simulateLongPress() { + _startNewSplash(context: context); + handleLongPress(); + } + + void handleStatesControllerChange() { + // Force a rebuild to resolve widget.overlayColor, widget.mouseCursor + setState(() {}); + } + + MaterialStatesController get statesController => + widget.statesController ?? internalStatesController!; + + void initStatesController() { + if (widget.statesController == null) { + internalStatesController = MaterialStatesController(); + } + statesController.update(WidgetState.disabled, !enabled); + statesController.addListener(handleStatesControllerChange); + } + + @override + void initState() { + super.initState(); + initStatesController(); + FocusManager.instance.addHighlightModeListener(handleFocusHighlightModeChange); + } + + @override + void didUpdateWidget(_InkResponseStateWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.statesController != oldWidget.statesController) { + oldWidget.statesController?.removeListener(handleStatesControllerChange); + if (widget.statesController != null) { + internalStatesController?.dispose(); + internalStatesController = null; + } + initStatesController(); + } + if (widget.radius != oldWidget.radius || + widget.highlightShape != oldWidget.highlightShape || + widget.borderRadius != oldWidget.borderRadius) { + final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; + if (hoverHighlight != null) { + hoverHighlight.dispose(); + updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); + } + final InkHighlight? focusHighlight = _highlights[_HighlightType.focus]; + if (focusHighlight != null) { + focusHighlight.dispose(); + // Do not call updateFocusHighlights() here because it is called below + } + } + if (widget.customBorder != oldWidget.customBorder) { + _updateHighlightsAndSplashes(); + } + if (enabled != isWidgetEnabled(oldWidget)) { + statesController.update(WidgetState.disabled, !enabled); + if (!enabled) { + statesController.update(WidgetState.pressed, false); + // Remove the existing hover highlight immediately when enabled is false. + // Do not rely on updateHighlight or InkHighlight.deactivate to not break + // the expected lifecycle which is updating _hovering when the mouse exit. + // Manually updating _hovering here or calling InkHighlight.deactivate + // will lead to onHover not being called or call when it is not allowed. + final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; + hoverHighlight?.dispose(); + } + // Don't call widget.onHover because many widgets, including the button + // widgets, apply setState to an ancestor context from onHover. + updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); + } + updateFocusHighlights(); + } + + @override + void dispose() { + FocusManager.instance.removeHighlightModeListener(handleFocusHighlightModeChange); + statesController.removeListener(handleStatesControllerChange); + internalStatesController?.dispose(); + _activationTimer?.cancel(); + _activationTimer = null; + super.dispose(); + } + + @override + bool get wantKeepAlive => highlightsExist || (_splashes != null && _splashes!.isNotEmpty); + + Duration getFadeDurationForType(_HighlightType type) { + switch (type) { + case _HighlightType.pressed: + return const Duration(milliseconds: 200); + case _HighlightType.hover: + case _HighlightType.focus: + return widget.hoverDuration ?? const Duration(milliseconds: 50); + } + } + + void updateHighlight(_HighlightType type, {required bool value, bool callOnHover = true}) { + final InkHighlight? highlight = _highlights[type]; + void handleInkRemoval() { + assert(_highlights[type] != null); + _highlights[type] = null; + updateKeepAlive(); + } + + switch (type) { + case _HighlightType.pressed: + statesController.update(WidgetState.pressed, value); + case _HighlightType.hover: + if (callOnHover) { + statesController.update(WidgetState.hovered, value); + } + case _HighlightType.focus: + // see handleFocusUpdate() + break; + } + + if (type == _HighlightType.pressed) { + widget.parentState?.markChildInkResponsePressed(this, value); + } + if (value == (highlight != null && highlight.active)) { + return; + } + + if (value) { + if (highlight == null) { + final Color resolvedOverlayColor = + widget.overlayColor?.resolve(statesController.value) ?? + switch (type) { + // Use the backwards compatible defaults + _HighlightType.pressed => widget.highlightColor ?? Theme.of(context).highlightColor, + _HighlightType.focus => widget.focusColor ?? Theme.of(context).focusColor, + _HighlightType.hover => widget.hoverColor ?? Theme.of(context).hoverColor, + }; + final referenceBox = context.findRenderObject()! as RenderBox; + _highlights[type] = InkHighlight( + controller: Material.of(context), + referenceBox: referenceBox, + color: enabled ? resolvedOverlayColor : resolvedOverlayColor.withAlpha(0), + shape: widget.highlightShape, + radius: widget.radius, + borderRadius: widget.borderRadius, + customBorder: widget.customBorder, + rectCallback: widget.getRectCallback!(referenceBox), + onRemoved: handleInkRemoval, + textDirection: Directionality.of(context), + fadeDuration: getFadeDurationForType(type), + ); + updateKeepAlive(); + } else { + highlight.activate(); + } + } else { + highlight!.deactivate(); + } + assert(value == (_highlights[type] != null && _highlights[type]!.active)); + + switch (type) { + case _HighlightType.pressed: + widget.onHighlightChanged?.call(value); + case _HighlightType.hover: + if (callOnHover) { + widget.onHover?.call(value); + } + case _HighlightType.focus: + break; + } + } + + void _updateHighlightsAndSplashes() { + for (final InkHighlight? highlight in _highlights.values) { + highlight?.customBorder = widget.customBorder; + } + _currentSplash?.customBorder = widget.customBorder; + + if (_splashes != null && _splashes!.isNotEmpty) { + for (final InteractiveInkFeature inkFeature in _splashes!) { + inkFeature.customBorder = widget.customBorder; + } + } + } + + InteractiveInkFeature _createSplash(Offset globalPosition) { + final MaterialInkController inkController = Material.of(context); + final referenceBox = context.findRenderObject()! as RenderBox; + final Offset position = referenceBox.globalToLocal(globalPosition); + final Color color = + widget.overlayColor?.resolve(statesController.value) ?? + widget.splashColor ?? + Theme.of(context).splashColor; + final RectCallback? rectCallback = widget.containedInkWell + ? widget.getRectCallback!(referenceBox) + : null; + final BorderRadius? borderRadius = widget.borderRadius; + final ShapeBorder? customBorder = widget.customBorder; + + InteractiveInkFeature? splash; + void onRemoved() { + if (_splashes != null) { + assert(_splashes!.contains(splash)); + _splashes!.remove(splash); + if (_currentSplash == splash) { + _currentSplash = null; + } + updateKeepAlive(); + } // else we're probably in deactivate() + } + + splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create( + controller: inkController, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: widget.containedInkWell, + rectCallback: rectCallback, + radius: widget.radius, + borderRadius: borderRadius, + customBorder: customBorder, + onRemoved: onRemoved, + textDirection: Directionality.of(context), + ); + + return splash; + } + + void handleFocusHighlightModeChange(FocusHighlightMode mode) { + if (!mounted) { + return; + } + setState(() { + updateFocusHighlights(); + }); + } + + bool get _shouldShowFocus => switch (MediaQuery.maybeNavigationModeOf(context)) { + NavigationMode.traditional || null => enabled && _hasFocus, + NavigationMode.directional => _hasFocus, + }; + + void updateFocusHighlights() { + final bool showFocus = switch (FocusManager.instance.highlightMode) { + FocusHighlightMode.touch => false, + FocusHighlightMode.traditional => _shouldShowFocus, + }; + updateHighlight(_HighlightType.focus, value: showFocus); + } + + bool _hasFocus = false; + void handleFocusUpdate(bool hasFocus) { + _hasFocus = hasFocus; + // Set here rather than updateHighlight because this widget's + // (WidgetState) states include WidgetState.focused if + // the InkWell _has_ the focus, rather than if it's showing + // the focus per FocusManager.instance.highlightMode. + statesController.update(WidgetState.focused, hasFocus); + updateFocusHighlights(); + widget.onFocusChange?.call(hasFocus); + } + + void handleAnyTapDown(TapDownDetails details) { + if (_anyChildInkResponsePressed) { + return; + } + _startNewSplash(details: details); + } + + void handleTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onTapDown?.call(details); + } + + void handleTapUp(TapUpDetails details) { + widget.onTapUp?.call(details); + } + + void handleSecondaryTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onSecondaryTapDown?.call(details); + } + + void handleSecondaryTapUp(TapUpDetails details) { + widget.onSecondaryTapUp?.call(details); + } + + void _startNewSplash({TapDownDetails? details, BuildContext? context}) { + assert(details != null || context != null); + + final Offset globalPosition; + if (context != null) { + final referenceBox = context.findRenderObject()! as RenderBox; + assert( + referenceBox.hasSize, + 'InkResponse must be done with layout before starting a splash.', + ); + globalPosition = referenceBox.localToGlobal(referenceBox.paintBounds.center); + } else { + globalPosition = details!.globalPosition; + } + statesController.update(WidgetState.pressed, true); // ... before creating the splash + final InteractiveInkFeature splash = _createSplash(globalPosition); + _splashes ??= HashSet<InteractiveInkFeature>(); + _splashes!.add(splash); + _currentSplash?.cancel(); + _currentSplash = splash; + updateKeepAlive(); + updateHighlight(_HighlightType.pressed, value: true); + } + + void handleTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + if (widget.onTap != null) { + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onTap?.call(); + } + } + + void handleTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + + void handleDoubleTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onDoubleTap?.call(); + } + + void handleLongPress() { + _currentSplash?.confirm(); + _currentSplash = null; + if (widget.onLongPress != null) { + if (widget.enableFeedback) { + Feedback.forLongPress(context); + } + widget.onLongPress!(); + } + } + + void handleLongPressUp() { + _currentSplash?.confirm(); + _currentSplash = null; + widget.onLongPressUp?.call(); + } + + void handleSecondaryTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onSecondaryTap?.call(); + } + + void handleSecondaryTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onSecondaryTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + + @override + void deactivate() { + if (_splashes != null) { + final Set<InteractiveInkFeature> splashes = _splashes!; + _splashes = null; + for (final splash in splashes) { + splash.dispose(); + } + _currentSplash = null; + } + assert(_currentSplash == null); + for (final _HighlightType highlight in _highlights.keys) { + _highlights[highlight]?.dispose(); + _highlights[highlight] = null; + } + widget.parentState?.markChildInkResponsePressed(this, false); + super.deactivate(); + } + + bool isWidgetEnabled(_InkResponseStateWidget widget) { + return _primaryButtonEnabled(widget) || _secondaryButtonEnabled(widget); + } + + bool _primaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onTap != null || + widget.onDoubleTap != null || + widget.onLongPress != null || + widget.onLongPressUp != null || + widget.onTapUp != null || + widget.onTapDown != null; + } + + bool _secondaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onSecondaryTap != null || + widget.onSecondaryTapUp != null || + widget.onSecondaryTapDown != null; + } + + bool get enabled => isWidgetEnabled(widget); + bool get _primaryEnabled => _primaryButtonEnabled(widget); + bool get _secondaryEnabled => _secondaryButtonEnabled(widget); + + void handleMouseEnter(PointerEnterEvent event) { + _hovering = true; + if (enabled) { + handleHoverChange(); + } + } + + void handleMouseExit(PointerExitEvent event) { + _hovering = false; + // If the exit occurs after we've been disabled, we still + // want to take down the highlights and run widget.onHover. + handleHoverChange(); + } + + void handleHoverChange() { + updateHighlight(_HighlightType.hover, value: _hovering); + } + + bool get _canRequestFocus => switch (MediaQuery.maybeNavigationModeOf(context)) { + NavigationMode.traditional || null => enabled && widget.canRequestFocus, + NavigationMode.directional => true, + }; + + @override + Widget build(BuildContext context) { + assert(widget.debugCheckContext(context)); + super.build(context); // See AutomaticKeepAliveClientMixin. + + final ThemeData theme = Theme.of(context); + const highlightableStates = <WidgetState>{ + WidgetState.focused, + WidgetState.hovered, + WidgetState.pressed, + }; + final Set<WidgetState> nonHighlightableStates = statesController.value.difference( + highlightableStates, + ); + // Each highlightable state will be resolved separately to get the corresponding color. + // For this resolution to be correct, the non-highlightable states should be preserved. + final pressed = <WidgetState>{...nonHighlightableStates, WidgetState.pressed}; + final focused = <WidgetState>{...nonHighlightableStates, WidgetState.focused}; + final hovered = <WidgetState>{...nonHighlightableStates, WidgetState.hovered}; + + Color getHighlightColorForType(_HighlightType type) { + return switch (type) { + // The pressed state triggers a ripple (ink splash), per the current + // Material Design spec. A separate highlight is no longer used. + // See https://material.io/design/interaction/states.html#pressed + _HighlightType.pressed => + widget.overlayColor?.resolve(pressed) ?? widget.highlightColor ?? theme.highlightColor, + _HighlightType.focus => + widget.overlayColor?.resolve(focused) ?? widget.focusColor ?? theme.focusColor, + _HighlightType.hover => + widget.overlayColor?.resolve(hovered) ?? widget.hoverColor ?? theme.hoverColor, + }; + } + + for (final _HighlightType type in _highlights.keys) { + _highlights[type]?.color = getHighlightColorForType(type); + } + + _currentSplash?.color = + widget.overlayColor?.resolve(statesController.value) ?? + widget.splashColor ?? + Theme.of(context).splashColor; + + final MouseCursor effectiveMouseCursor = WidgetStateProperty.resolveAs<MouseCursor>( + widget.mouseCursor ?? WidgetStateMouseCursor.adaptiveClickable, + statesController.value, + ); + + return _ParentInkResponseProvider( + state: this, + child: Actions( + actions: _actionMap, + child: Focus( + focusNode: widget.focusNode, + canRequestFocus: _canRequestFocus, + onFocusChange: handleFocusUpdate, + autofocus: widget.autofocus, + child: MouseRegion( + cursor: effectiveMouseCursor, + onEnter: handleMouseEnter, + onExit: handleMouseExit, + child: DefaultSelectionStyle.merge( + mouseCursor: effectiveMouseCursor, + child: Semantics( + onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap, + onLongPress: widget.excludeFromSemantics || widget.onLongPress == null + ? null + : simulateLongPress, + child: GestureDetector( + onTapDown: _primaryEnabled ? handleTapDown : null, + onTapUp: _primaryEnabled ? handleTapUp : null, + onTap: _primaryEnabled ? handleTap : null, + onTapCancel: _primaryEnabled ? handleTapCancel : null, + onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null, + onLongPress: widget.onLongPress != null ? handleLongPress : null, + onLongPressUp: widget.onLongPressUp != null ? handleLongPressUp : null, + onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null, + onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp : null, + onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null, + onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null, + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + child: widget.child, + ), + ), + ), + ), + ), + ), + ); + } +} + +/// A rectangular area of a [Material] that responds to touch. +/// +/// For a variant of this widget that does not clip splashes, see [InkResponse]. +/// +/// The following diagram shows how an [InkWell] looks when tapped, when using +/// default values. +/// +/// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png) +/// +/// The [InkWell] widget must have a [Material] widget as an ancestor. The +/// [Material] widget is where the ink reactions are actually painted. This +/// matches the Material Design premise wherein the [Material] is what is +/// actually reacting to touches by spreading ink. +/// +/// If a Widget uses this class directly, it should include the following line +/// at the top of its build function to call [debugCheckHasMaterial]: +/// +/// ```dart +/// assert(debugCheckHasMaterial(context)); +/// ``` +/// +/// ## Troubleshooting +/// +/// ### The ink splashes aren't visible! +/// +/// If there is an opaque graphic, e.g. painted using a [Container], [Image], or +/// [DecoratedBox], between the [Material] widget and the [InkWell] widget, then +/// the splash won't be visible because it will be under the opaque graphic. +/// This is because ink splashes draw on the underlying [Material] itself, as +/// if the ink was spreading inside the material. +/// +/// The [Ink] widget can be used as a replacement for [Image], [Container], or +/// [DecoratedBox] to ensure that the image or decoration also paints in the +/// [Material] itself, below the ink. +/// +/// If this is not possible for some reason, e.g. because you are using an +/// opaque [CustomPaint] widget, alternatively consider using a second +/// [Material] above the opaque widget but below the [InkWell] (as an +/// ancestor to the ink well). The [MaterialType.transparency] material +/// kind can be used for this purpose. +/// +/// ### InkWell isn't clipping properly +/// +/// If you want to clip an InkWell or any [Ink] widgets you need to keep in mind +/// that the [Material] that the Ink will be printed on is responsible for clipping. +/// This means you can't wrap the [Ink] widget in a clipping widget directly, +/// since this will leave the [Material] not clipped (and by extension the printed +/// [Ink] widgets as well). +/// +/// An easy solution is to deliberately wrap the [Ink] widgets you want to clip +/// in a [Material], and wrap that in a clipping widget instead. See [Ink] for +/// an example. +/// +/// ### The ink splashes don't track the size of an animated container +/// If the size of an InkWell's [Material] ancestor changes while the InkWell's +/// splashes are expanding, you may notice that the splashes aren't clipped +/// correctly. This can't be avoided. +/// +/// An example of this situation is as follows: +/// +/// {@tool dartpad} +/// Tap the container to cause it to grow. Then, tap it again and hold before +/// the widget reaches its maximum size to observe the clipped ink splash. +/// +/// ** See code in examples/api/lib/material/ink_well/ink_well.0.dart ** +/// {@end-tool} +/// +/// An InkWell's splashes will not properly update to conform to changes if the +/// size of its underlying [Material], where the splashes are rendered, changes +/// during animation. You should avoid using InkWells within [Material] widgets +/// that are changing size. +/// +/// See also: +/// +/// * [GestureDetector], for listening for gestures without ink splashes. +/// * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design. +/// * [InkResponse], a variant of [InkWell] that doesn't force a rectangular +/// shape on the ink reaction. +class InkWell extends InkResponse { + /// Creates an ink well. + /// + /// Must have an ancestor [Material] widget in which to cause ink reactions. + const InkWell({ + super.key, + super.child, + super.onTap, + super.onDoubleTap, + super.onLongPress, + super.onLongPressUp, + super.onTapDown, + super.onTapUp, + super.onTapCancel, + super.onSecondaryTap, + super.onSecondaryTapUp, + super.onSecondaryTapDown, + super.onSecondaryTapCancel, + super.onHighlightChanged, + super.onHover, + super.mouseCursor, + super.focusColor, + super.hoverColor, + super.highlightColor, + super.overlayColor, + super.splashColor, + super.splashFactory, + super.radius, + super.borderRadius, + super.customBorder, + super.enableFeedback, + super.excludeFromSemantics, + super.focusNode, + super.canRequestFocus, + super.onFocusChange, + super.autofocus, + super.statesController, + super.hoverDuration, + }) : super(containedInkWell: true, highlightShape: BoxShape.rectangle); +} diff --git a/packages/material_ui/lib/src/input_border.dart b/packages/material_ui/lib/src/input_border.dart new file mode 100644 index 000000000000..41291a2da3e8 --- /dev/null +++ b/packages/material_ui/lib/src/input_border.dart @@ -0,0 +1,809 @@ +// 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. + +/// @docImport 'input_decorator.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/widgets.dart'; + +/// Defines the appearance of an [InputDecorator]'s border. +/// +/// An input decorator's border is specified by [InputDecoration.border]. +/// +/// The border is drawn relative to the input decorator's "container" which +/// is the optionally filled area above the decorator's helper, error, +/// and counter. +/// +/// Input border's are decorated with a line whose weight and color are defined +/// by [borderSide]. The input decorator's renderer animates the input border's +/// appearance in response to state changes, like gaining or losing the focus, +/// by creating new copies of its input border with [copyWith]. +/// +/// See also: +/// +/// * [UnderlineInputBorder], the default [InputDecorator] border which +/// draws a horizontal line at the bottom of the input decorator's container. +/// * [OutlineInputBorder], an [InputDecorator] border which draws a +/// rounded rectangle around the input decorator's container. +/// * [InputDecoration], which is used to configure an [InputDecorator]. +abstract class InputBorder extends ShapeBorder { + /// Creates a border for an [InputDecorator]. + /// + /// Applications typically do not specify a [borderSide] parameter because the + /// [InputDecorator] substitutes its own, using [copyWith], based on the + /// current theme and [InputDecorator.isFocused]. + const InputBorder({this.borderSide = BorderSide.none}); + + /// No input border. + /// + /// Use this value with [InputDecoration.border] to specify that no border + /// should be drawn. The [InputDecoration.collapsed] constructor sets + /// its border to this value. + static const InputBorder none = _NoInputBorder(); + + /// Defines the border line's color and weight. + /// + /// The [InputDecorator] creates copies of its input border, using [copyWith], + /// based on the current theme and [InputDecorator.isFocused]. + final BorderSide borderSide; + + /// Creates a copy of this input border with the specified `borderSide`. + InputBorder copyWith({BorderSide? borderSide}); + + /// True if this border will enclose the [InputDecorator]'s container. + /// + /// This property affects the alignment of container's contents. For example + /// when an input decorator is configured with an [OutlineInputBorder] its + /// label is centered with its container. + bool get isOutline; + + /// Paint this input border on [canvas]. + /// + /// The [rect] parameter bounds the [InputDecorator]'s container. + /// + /// The additional `gap` parameters reflect the state of the [InputDecorator]'s + /// floating label. When an input decorator gains the focus, its label + /// animates upwards, to make room for the input child. The [gapStart] and + /// [gapExtent] parameters define a floating label width interval, and + /// [gapPercentage] defines the animation's progress (0.0 to 1.0). + @override + void paint( + Canvas canvas, + Rect rect, { + double? gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection? textDirection, + }); +} + +// Used to create the InputBorder.none singleton. +class _NoInputBorder extends InputBorder { + const _NoInputBorder() : super(borderSide: BorderSide.none); + + @override + _NoInputBorder copyWith({BorderSide? borderSide}) => const _NoInputBorder(); + + @override + bool get isOutline => false; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + _NoInputBorder scale(double t) => const _NoInputBorder(); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path()..addRect(rect); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return Path()..addRect(rect); + } + + @override + void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { + canvas.drawRect(rect, paint); + } + + @override + bool get preferPaintInterior => true; + + @override + void paint( + Canvas canvas, + Rect rect, { + double? gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection? textDirection, + }) { + // Do not paint. + } +} + +/// Draws a horizontal line at the bottom of an [InputDecorator]'s container and +/// defines the container's shape. +/// +/// The input decorator's "container" is the optionally filled area above the +/// decorator's helper, error, and counter. +/// +/// See also: +/// +/// * [OutlineInputBorder], an [InputDecorator] border which draws a +/// rounded rectangle around the input decorator's container. +/// * [InputDecoration], which is used to configure an [InputDecorator]. +class UnderlineInputBorder extends InputBorder { + /// Creates an underline border for an [InputDecorator]. + /// + /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be + /// null). Applications typically do not specify a [borderSide] parameter + /// because the input decorator substitutes its own, using [copyWith], based + /// on the current theme and [InputDecorator.isFocused]. + /// + /// The [borderRadius] parameter defaults to a value where the top left + /// and right corners have a circular radius of 4.0. + const UnderlineInputBorder({ + super.borderSide = const BorderSide(), + this.borderRadius = const BorderRadius.only( + topLeft: Radius.circular(4.0), + topRight: Radius.circular(4.0), + ), + }); + + /// The radii of the border's rounded rectangle corners. + /// + /// When this border is used with a filled input decorator, see + /// [InputDecoration.filled], the border radius defines the shape + /// of the background fill as well as the bottom left and right + /// edges of the underline itself. + /// + /// By default the top right and top left corners have a circular radius + /// of 4.0. + final BorderRadius borderRadius; + + @override + bool get isOutline => false; + + @override + UnderlineInputBorder copyWith({BorderSide? borderSide, BorderRadius? borderRadius}) { + return UnderlineInputBorder( + borderSide: borderSide ?? this.borderSide, + borderRadius: borderRadius ?? this.borderRadius, + ); + } + + @override + EdgeInsetsGeometry get dimensions { + return EdgeInsets.only(bottom: borderSide.width); + } + + @override + UnderlineInputBorder scale(double t) { + return UnderlineInputBorder(borderSide: borderSide.scale(t)); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path()..addRect( + Rect.fromLTWH(rect.left, rect.top, rect.width, math.max(0.0, rect.height - borderSide.width)), + ); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); + } + + @override + void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { + canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint); + } + + @override + bool get preferPaintInterior => true; + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (a is UnderlineInputBorder) { + return UnderlineInputBorder( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!, + ); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (b is UnderlineInputBorder) { + return UnderlineInputBorder( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!, + ); + } + return super.lerpTo(b, t); + } + + /// Draw a horizontal line at the bottom of [rect]. + /// + /// The [borderSide] defines the line's color and weight. The `textDirection` + /// `gap` and `textDirection` parameters are ignored. + @override + void paint( + Canvas canvas, + Rect rect, { + double? gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection? textDirection, + }) { + if (borderSide.style == BorderStyle.none) { + return; + } + + if (borderRadius.bottomLeft != Radius.zero || borderRadius.bottomRight != Radius.zero) { + // This prevents the border from leaking the color due to anti-aliasing rounding errors. + final updatedBorderRadius = BorderRadius.only( + bottomLeft: borderRadius.bottomLeft.clamp(maximum: Radius.circular(rect.height / 2)), + bottomRight: borderRadius.bottomRight.clamp(maximum: Radius.circular(rect.height / 2)), + ); + + BoxBorder.paintNonUniformBorder( + canvas, + rect, + textDirection: textDirection, + borderRadius: updatedBorderRadius, + bottom: borderSide.copyWith(strokeAlign: BorderSide.strokeAlignInside), + color: borderSide.color, + ); + } else { + final alignInsideOffset = Offset(0, borderSide.width / 2); + canvas.drawLine( + rect.bottomLeft - alignInsideOffset, + rect.bottomRight - alignInsideOffset, + borderSide.toPaint(), + ); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is UnderlineInputBorder && + other.borderSide == borderSide && + other.borderRadius == borderRadius; + } + + @override + int get hashCode => Object.hash(borderSide, borderRadius); +} + +/// Draws a rounded rectangle around an [InputDecorator]'s container. +/// +/// When the input decorator's label is floating, for example because its +/// input child has the focus, the label appears in a gap in the border outline. +/// +/// The input decorator's "container" is the optionally filled area above the +/// decorator's helper, error, and counter. +/// +/// See also: +/// +/// * [UnderlineInputBorder], the default [InputDecorator] border which +/// draws a horizontal line at the bottom of the input decorator's container. +/// * [ShapedInputBorder], an [InputDecorator] border which draws a custom +/// [ShapeBorder] around the input decorator's container. +/// * [InputDecoration], which is used to configure an [InputDecorator]. +class OutlineInputBorder extends InputBorder { + /// Creates a rounded rectangle outline border for an [InputDecorator]. + /// + /// If the [borderSide] parameter is [BorderSide.none], it will not draw a + /// border. However, it will still define a shape (which you can see if + /// [InputDecoration.filled] is true). + /// + /// If an application does not specify a [borderSide] parameter of + /// value [BorderSide.none], the input decorator substitutes its own, using + /// [copyWith], based on the current theme and [InputDecorator.isFocused]. + /// + /// The [borderRadius] parameter defaults to a value where all four corners + /// have a circular radius of 4.0. The corner radii must be circular, i.e. + /// their [Radius.x] and [Radius.y] values must be the same. + /// + /// See also: + /// + /// * [InputDecoration.floatingLabelBehavior], which should be set to + /// [FloatingLabelBehavior.never] when the [borderSide] is + /// [BorderSide.none]. If left as [FloatingLabelBehavior.auto], the label + /// will extend beyond the container as if the border were still being + /// drawn. + const OutlineInputBorder({ + super.borderSide = const BorderSide(), + this.borderRadius = const BorderRadius.all(Radius.circular(4.0)), + this.gapPadding = 4.0, + }) : assert(gapPadding >= 0.0); + + // The label text's gap can extend into the corners (even both the top left + // and the top right corner). To avoid the more complicated problem of finding + // how far the gap penetrates into an elliptical corner, just require them + // to be circular. + // + // This can't be checked by the constructor because const constructor. + static bool _cornersAreCircular(BorderRadius borderRadius) { + return borderRadius.topLeft.x == borderRadius.topLeft.y && + borderRadius.bottomLeft.x == borderRadius.bottomLeft.y && + borderRadius.topRight.x == borderRadius.topRight.y && + borderRadius.bottomRight.x == borderRadius.bottomRight.y; + } + + /// Horizontal padding on either side of the border's + /// [InputDecoration.labelText] width gap. + /// + /// This value is used by the [paint] method to compute the actual gap width. + final double gapPadding; + + /// The radii of the border's rounded rectangle corners. + /// + /// The corner radii must be circular, i.e. their [Radius.x] and [Radius.y] + /// values must be the same. + final BorderRadius borderRadius; + + @override + bool get isOutline => true; + + @override + OutlineInputBorder copyWith({ + BorderSide? borderSide, + BorderRadius? borderRadius, + double? gapPadding, + }) { + return OutlineInputBorder( + borderSide: borderSide ?? this.borderSide, + borderRadius: borderRadius ?? this.borderRadius, + gapPadding: gapPadding ?? this.gapPadding, + ); + } + + @override + EdgeInsetsGeometry get dimensions { + return EdgeInsets.all(borderSide.strokeInset); + } + + @override + OutlineInputBorder scale(double t) { + return OutlineInputBorder( + borderSide: borderSide.scale(t), + borderRadius: borderRadius * t, + gapPadding: gapPadding * t, + ); + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (a is OutlineInputBorder) { + final OutlineInputBorder outline = a; + return OutlineInputBorder( + borderRadius: BorderRadius.lerp(outline.borderRadius, borderRadius, t)!, + borderSide: BorderSide.lerp(outline.borderSide, borderSide, t), + gapPadding: outline.gapPadding, + ); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (b is OutlineInputBorder) { + final OutlineInputBorder outline = b; + return OutlineInputBorder( + borderRadius: BorderRadius.lerp(borderRadius, outline.borderRadius, t)!, + borderSide: BorderSide.lerp(borderSide, outline.borderSide, t), + gapPadding: outline.gapPadding, + ); + } + return super.lerpTo(b, t); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path() + ..addRRect(borderRadius.resolve(textDirection).toRRect(rect).deflate(borderSide.strokeInset)); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); + } + + @override + void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { + canvas.drawRRect(borderRadius.resolve(textDirection).toRRect(rect), paint); + } + + @override + bool get preferPaintInterior => true; + + Path _gapBorderPath(Canvas canvas, RRect center, double outerWidth, double start, double extent) { + // When the corner radii on any side add up to be greater than the + // given height, each radius has to be scaled to not exceed the + // size of the width/height of the RRect. + final RRect scaledRRect = center.scaleRadii(); + + final tlCorner = Rect.fromLTWH( + scaledRRect.left, + scaledRRect.top, + scaledRRect.tlRadiusX * 2.0, + scaledRRect.tlRadiusY * 2.0, + ); + final trCorner = Rect.fromLTWH( + scaledRRect.right - scaledRRect.trRadiusX * 2.0, + scaledRRect.top, + scaledRRect.trRadiusX * 2.0, + scaledRRect.trRadiusY * 2.0, + ); + final brCorner = Rect.fromLTWH( + scaledRRect.right - scaledRRect.brRadiusX * 2.0, + scaledRRect.bottom - scaledRRect.brRadiusY * 2.0, + scaledRRect.brRadiusX * 2.0, + scaledRRect.brRadiusY * 2.0, + ); + final blCorner = Rect.fromLTWH( + scaledRRect.left, + scaledRRect.bottom - scaledRRect.blRadiusY * 2.0, + scaledRRect.blRadiusX * 2.0, + scaledRRect.blRadiusY * 2.0, + ); + + // This assumes that the radius is circular (x and y radius are equal). + // Currently, BorderRadius only supports circular radii. + const double cornerArcSweep = math.pi / 2.0; + final path = Path(); + + // Top left corner + if (scaledRRect.tlRadius != Radius.zero) { + final double tlCornerArcSweep = math.acos( + clampDouble(1 - start / scaledRRect.tlRadiusX, 0.0, 1.0), + ); + path.addArc(tlCorner, math.pi, tlCornerArcSweep); + } else { + // Because the path is painted with Paint.strokeCap = StrokeCap.butt, horizontal coordinate is moved + // based on strokeOffset to respect strokeAlign. + path.moveTo(scaledRRect.left + borderSide.strokeOffset / 2, scaledRRect.top); + } + + // Draw top border from top left corner to gap start. + if (start > scaledRRect.tlRadiusX) { + path.lineTo(start, scaledRRect.top); + } + + // Draw top border from gap end to top right corner and draw top right corner. + const double trCornerArcStart = (3 * math.pi) / 2.0; + const trCornerArcSweep = cornerArcSweep; + if (start + extent < outerWidth - scaledRRect.trRadiusX) { + path.moveTo(start + extent, scaledRRect.top); + path.lineTo(scaledRRect.right - scaledRRect.trRadiusX, scaledRRect.top); + if (scaledRRect.trRadius != Radius.zero) { + path.addArc(trCorner, trCornerArcStart, trCornerArcSweep); + } + } else if (start + extent < outerWidth) { + final double dx = outerWidth - (start + extent); + final double sweep = math.asin(clampDouble(1 - dx / scaledRRect.trRadiusX, 0.0, 1.0)); + path.addArc(trCorner, trCornerArcStart + sweep, trCornerArcSweep - sweep); + } + + // Draw right border and bottom right corner. + if (scaledRRect.brRadius != Radius.zero) { + path.moveTo(scaledRRect.right, scaledRRect.top + scaledRRect.trRadiusY); + } + path.lineTo(scaledRRect.right, scaledRRect.bottom - scaledRRect.brRadiusY); + if (scaledRRect.brRadius != Radius.zero) { + path.addArc(brCorner, 0.0, cornerArcSweep); + } + + // Draw bottom border and bottom left corner. + path.lineTo(scaledRRect.left + scaledRRect.blRadiusX, scaledRRect.bottom); + if (scaledRRect.blRadius != Radius.zero) { + path.addArc(blCorner, math.pi / 2.0, cornerArcSweep); + } + + // Draw left border + path.lineTo(scaledRRect.left, scaledRRect.top + scaledRRect.tlRadiusY); + + return path; + } + + /// Draw a rounded rectangle around [rect] using [borderRadius]. + /// + /// The [borderSide] defines the line's color and weight. + /// + /// The top side of the rounded rectangle may be interrupted by a single gap + /// if [gapExtent] is non-null. In that case the gap begins at + /// `gapStart - gapPadding` (assuming that the [textDirection] is [TextDirection.ltr]). + /// The gap's width is `(gapPadding + gapExtent + gapPadding) * gapPercentage`. + @override + void paint( + Canvas canvas, + Rect rect, { + double? gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection? textDirection, + }) { + assert(gapPercentage >= 0.0 && gapPercentage <= 1.0); + assert(_cornersAreCircular(borderRadius)); + + final Paint paint = borderSide.toPaint(); + final RRect outer = borderRadius.toRRect(rect); + final RRect center = outer.inflate(borderSide.strokeOffset / 2); + if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) { + canvas.drawRRect(center, paint); + } else { + final double extent = lerpDouble(0.0, gapExtent + gapPadding * 2.0, gapPercentage)!; + final double start = switch (textDirection!) { + TextDirection.rtl => gapStart + gapPadding - extent, + TextDirection.ltr => gapStart - gapPadding, + }; + final Path path = _gapBorderPath(canvas, center, outer.width, math.max(0.0, start), extent); + canvas.drawPath(path, paint); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is OutlineInputBorder && + other.borderSide == borderSide && + other.borderRadius == borderRadius && + other.gapPadding == gapPadding; + } + + @override + int get hashCode => Object.hash(borderSide, borderRadius, gapPadding); +} + +/// Draws a custom shape around an [InputDecorator]'s container. +/// +/// This border allows any [ShapeBorder] to be used as an input decorator border. +/// This provides maximum flexibility for custom border shapes while maintaining +/// the gap functionality for floating labels. +/// +/// When the input decorator's label is floating, for example because its +/// input child has the focus, the label appears in a gap in the border outline. +/// +/// The input decorator's "container" is the optionally filled area above the +/// decorator's helper, error, and counter. +/// +/// {@tool dartpad} +/// This sample shows how to use [ShapedInputBorder] with different +/// [ShapeBorder] implementations. +/// +/// ** See code in examples/api/lib/material/shaped_input_border/shaped_input_border.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [OutlineInputBorder], a traditional rounded rectangle border. +/// * [UnderlineInputBorder], the default [InputDecorator] border which +/// draws a horizontal line at the bottom of the input decorator's container. +/// * [RoundedSuperellipseBorder], which can be used with this border for iOS-style shapes. +/// * [InputDecoration], which is used to configure an [InputDecorator]. +class ShapedInputBorder extends InputBorder { + /// Creates a shaped outline border for an [InputDecorator]. + /// + /// The [shape] parameter defines the custom border shape. It can be any + /// [ShapeBorder] such as [RoundedSuperellipseBorder], [StadiumBorder], + /// [BeveledRectangleBorder], or a custom shape. + /// + /// If the [borderSide] parameter is [BorderSide.none], it will not draw a + /// border. However, it will still define a shape (which you can see if + /// [InputDecoration.filled] is true). + /// + /// If an application does not specify a [borderSide] parameter of + /// value [BorderSide.none], the input decorator substitutes its own, using + /// [copyWith], based on the current theme and [InputDecorator.isFocused]. + /// + /// See also: + /// + /// * [InputDecoration.floatingLabelBehavior], which should be set to + /// [FloatingLabelBehavior.never] when the [borderSide] is + /// [BorderSide.none]. If left as [FloatingLabelBehavior.auto], the label + /// will extend beyond the container as if the border were still being + /// drawn. + const ShapedInputBorder({ + super.borderSide = const BorderSide(), + required this.shape, + this.gapPadding = 4.0, + }) : assert(gapPadding >= 0.0); + + /// Horizontal padding on either side of the border's + /// [InputDecoration.labelText] width gap. + /// + /// This value is used by the [paint] method to compute the actual gap width. + final double gapPadding; + + /// The shape of the border. + final ShapeBorder shape; + + @override + bool get isOutline => true; + + @override + ShapedInputBorder copyWith({BorderSide? borderSide, ShapeBorder? shape, double? gapPadding}) { + return ShapedInputBorder( + borderSide: borderSide ?? this.borderSide, + shape: shape ?? this.shape, + gapPadding: gapPadding ?? this.gapPadding, + ); + } + + @override + EdgeInsetsGeometry get dimensions { + return EdgeInsets.all(borderSide.width); + } + + @override + ShapedInputBorder scale(double t) { + return ShapedInputBorder( + borderSide: borderSide.scale(t), + shape: shape.scale(t), + gapPadding: gapPadding * t, + ); + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (a is ShapedInputBorder) { + return ShapedInputBorder( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + shape: ShapeBorder.lerp(a.shape, shape, t)!, + gapPadding: a.gapPadding, + ); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (b is ShapedInputBorder) { + return ShapedInputBorder( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + shape: ShapeBorder.lerp(shape, b.shape, t)!, + gapPadding: b.gapPadding, + ); + } + return super.lerpTo(b, t); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return shape.getInnerPath(rect.deflate(borderSide.width), textDirection: textDirection); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return shape.getOuterPath(rect, textDirection: textDirection); + } + + @override + void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { + if (shape.preferPaintInterior) { + shape.paintInterior(canvas, rect, paint, textDirection: textDirection); + } else { + // Fallback for shapes that don't support paintInterior. + canvas.drawPath(shape.getOuterPath(rect, textDirection: textDirection), paint); + } + } + + @override + bool get preferPaintInterior => shape.preferPaintInterior; + + Path _gapBorderPath(Rect rect, double start, double extent, {TextDirection? textDirection}) { + // Create a continuous path for the border with a gap in the top edge. + final Path outerPath = shape.getOuterPath(rect, textDirection: textDirection); + + // If there's no meaningful gap, return the full outline. + if (start <= 0 && extent <= 0) { + return outerPath; + } + + // Create a rectangle that represents the gap area. + // The gap is on the top edge, so we create a rect that covers the gap region. + final gapLeft = start; + final double gapRight = start + extent; + + // Create a path that excludes the gap area by combining with a difference operation. + // We'll subtract a small rectangle at the top where the gap should be. + final gapRect = Path() + ..addRect( + Rect.fromLTRB( + clampDouble(gapLeft, rect.left, rect.right), + rect.top - 1.0, // Extend slightly beyond to ensure clean cut. + clampDouble(gapRight, rect.left, rect.right), + rect.top + 1.0, // Small height to only affect top edge. + ), + ); + + return Path.combine(PathOperation.difference, outerPath, gapRect); + } + + /// Draw the custom shape around [rect]. + /// + /// The [borderSide] defines the line's color and weight. + /// + /// The top side of the border may be interrupted by a single gap + /// if [gapExtent] is non-null. In that case the gap begins at + /// `gapStart - gapPadding` (assuming that the [textDirection] is [TextDirection.ltr]). + /// The gap's width is `(gapPadding + gapExtent + gapPadding) * gapPercentage`. + @override + void paint( + Canvas canvas, + Rect rect, { + double? gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection? textDirection, + }) { + assert(gapPercentage >= 0.0 && gapPercentage <= 1.0); + + final Paint paint = borderSide.toPaint(); + final Rect deflatedRect = rect.deflate(borderSide.width / 2.0); + + if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) { + // Draw the shape without a gap. + if (shape is OutlinedBorder) { + final outlinedShape = shape as OutlinedBorder; + // Create a copy with our border side. + final OutlinedBorder shapedBorder = outlinedShape.copyWith(side: borderSide); + shapedBorder.paint(canvas, deflatedRect, textDirection: textDirection); + } else { + canvas.drawPath(shape.getOuterPath(deflatedRect, textDirection: textDirection), paint); + } + } else { + final double extent = lerpDouble(0.0, gapExtent + gapPadding * 2.0, gapPercentage)!; + final double start = switch (textDirection!) { + TextDirection.rtl => gapStart + gapPadding - extent, + TextDirection.ltr => gapStart - gapPadding, + }; + final Path path = _gapBorderPath( + deflatedRect, + math.max(0.0, start), + extent, + textDirection: textDirection, + ); + canvas.drawPath(path, paint); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ShapedInputBorder && + other.borderSide == borderSide && + other.shape == shape && + other.gapPadding == gapPadding; + } + + @override + int get hashCode => Object.hash(borderSide, shape, gapPadding); +} diff --git a/packages/material_ui/lib/src/input_chip.dart b/packages/material_ui/lib/src/input_chip.dart new file mode 100644 index 000000000000..4a8a33d7099d --- /dev/null +++ b/packages/material_ui/lib/src/input_chip.dart @@ -0,0 +1,374 @@ +// 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. + +/// @docImport 'action_chip.dart'; +/// @docImport 'choice_chip.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'filter_chip.dart'; +/// @docImport 'material.dart'; +library; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/widgets.dart'; + +import 'chip.dart'; +import 'chip_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'icons.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design input chip. +/// +/// Input chips represent a complex piece of information, such as an entity +/// (person, place, or thing) or conversational text, in a compact form. +/// +/// Input chips can be made selectable by setting [onSelected], deletable by +/// setting [onDeleted], and pressable like a button with [onPressed]. They have +/// a [label], and they can have a leading icon (see [avatar]) and a trailing +/// icon ([deleteIcon]). Colors and padding can be customized. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// Input chips work together with other UI elements. They can appear: +/// +/// * In a [Wrap] widget. +/// * In a horizontally scrollable list, for example configured such as a +/// [ListView] with [ListView.scrollDirection] set to [Axis.horizontal]. +/// +/// {@tool dartpad} +/// This example shows how to create [InputChip]s with [onSelected] and +/// [onDeleted] callbacks. When the user taps the chip, the chip will be selected. +/// When the user taps the delete icon, the chip will be deleted. +/// +/// ** See code in examples/api/lib/material/input_chip/input_chip.0.dart ** +/// {@end-tool} +/// +/// +/// {@tool dartpad} +/// The following example shows how to generate [InputChip]s from +/// user text input. When the user enters a pizza topping in the text field, +/// the user is presented with a list of suggestions. When selecting one of the +/// suggestions, an [InputChip] is generated in the text field. +/// +/// ** See code in examples/api/lib/material/input_chip/input_chip.1.dart ** +/// {@end-tool} +/// +/// ## Material Design 3 +/// +/// [InputChip] can be used for Input chips from Material Design 3. +/// If [ThemeData.useMaterial3] is true, then [InputChip] +/// will be styled to match the Material Design 3 specification for Input +/// chips. +/// +/// See also: +/// +/// * [Chip], a chip that displays information and can be deleted. +/// * [ChoiceChip], allows a single selection from a set of options. Choice +/// chips contain related descriptive text or categories. +/// * [FilterChip], uses tags or descriptive words as a way to filter content. +/// * [ActionChip], represents an action related to primary content. +/// * [CircleAvatar], which shows images or initials of people. +/// * [Wrap], A widget that displays its children in multiple horizontal or +/// vertical runs. +/// * <https://material.io/design/components/chips.html> +class InputChip extends StatelessWidget + implements + ChipAttributes, + DeletableChipAttributes, + SelectableChipAttributes, + CheckmarkableChipAttributes, + DisabledChipAttributes, + TappableChipAttributes { + /// Creates an [InputChip]. + /// + /// The [onPressed] and [onSelected] callbacks must not both be specified at + /// the same time. When both [onPressed] and [onSelected] are null, the chip + /// will be disabled. + /// + /// The [pressElevation] and [elevation] must be null or non-negative. + /// Typically, [pressElevation] is greater than [elevation]. + const InputChip({ + super.key, + this.avatar, + required this.label, + this.labelStyle, + this.labelPadding, + this.selected = false, + this.isEnabled = true, + this.onSelected, + this.deleteIcon, + this.onDeleted, + this.deleteIconColor, + this.deleteButtonTooltipMessage, + this.onPressed, + this.pressElevation, + this.disabledColor, + this.selectedColor, + this.tooltip, + this.side, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.color, + this.backgroundColor, + this.padding, + this.visualDensity, + this.materialTapTargetSize, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.iconTheme, + this.selectedShadowColor, + this.showCheckmark, + this.checkmarkColor, + this.avatarBorder = const CircleBorder(), + this.avatarBoxConstraints, + this.deleteIconBoxConstraints, + this.chipAnimationStyle, + this.mouseCursor, + }) : assert(pressElevation == null || pressElevation >= 0.0), + assert(elevation == null || elevation >= 0.0); + + @override + final Widget? avatar; + @override + final Widget label; + @override + final TextStyle? labelStyle; + @override + final EdgeInsetsGeometry? labelPadding; + @override + final bool selected; + @override + final bool isEnabled; + @override + final ValueChanged<bool>? onSelected; + @override + final Widget? deleteIcon; + @override + final VoidCallback? onDeleted; + @override + final Color? deleteIconColor; + @override + final String? deleteButtonTooltipMessage; + @override + final VoidCallback? onPressed; + @override + final double? pressElevation; + @override + final Color? disabledColor; + @override + final Color? selectedColor; + @override + final String? tooltip; + @override + final BorderSide? side; + @override + final OutlinedBorder? shape; + @override + final Clip clipBehavior; + @override + final FocusNode? focusNode; + @override + final bool autofocus; + @override + final WidgetStateProperty<Color?>? color; + @override + final Color? backgroundColor; + @override + final EdgeInsetsGeometry? padding; + @override + final VisualDensity? visualDensity; + @override + final MaterialTapTargetSize? materialTapTargetSize; + @override + final double? elevation; + @override + final Color? shadowColor; + @override + final Color? surfaceTintColor; + @override + final Color? selectedShadowColor; + @override + final bool? showCheckmark; + @override + final Color? checkmarkColor; + @override + final ShapeBorder avatarBorder; + @override + final IconThemeData? iconTheme; + @override + final BoxConstraints? avatarBoxConstraints; + @override + final BoxConstraints? deleteIconBoxConstraints; + @override + final ChipAnimationStyle? chipAnimationStyle; + @override + final MouseCursor? mouseCursor; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ChipThemeData? defaults = Theme.of(context).useMaterial3 + ? _InputChipDefaultsM3(context, isEnabled, selected) + : null; + final Widget? resolvedDeleteIcon = + deleteIcon ?? (Theme.of(context).useMaterial3 ? const Icon(Icons.clear, size: 18) : null); + return RawChip( + defaultProperties: defaults, + avatar: avatar, + label: label, + labelStyle: labelStyle, + labelPadding: labelPadding, + deleteIcon: resolvedDeleteIcon, + onDeleted: onDeleted, + deleteIconColor: deleteIconColor, + deleteButtonTooltipMessage: deleteButtonTooltipMessage, + onSelected: onSelected, + onPressed: onPressed, + pressElevation: pressElevation, + selected: selected, + disabledColor: disabledColor, + selectedColor: selectedColor, + tooltip: tooltip, + side: side, + shape: shape, + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + color: color, + backgroundColor: backgroundColor, + padding: padding, + visualDensity: visualDensity, + materialTapTargetSize: materialTapTargetSize, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + selectedShadowColor: selectedShadowColor, + showCheckmark: showCheckmark, + checkmarkColor: checkmarkColor, + isEnabled: isEnabled && (onSelected != null || onDeleted != null || onPressed != null), + avatarBorder: avatarBorder, + iconTheme: iconTheme, + avatarBoxConstraints: avatarBoxConstraints, + deleteIconBoxConstraints: deleteIconBoxConstraints, + chipAnimationStyle: chipAnimationStyle, + mouseCursor: mouseCursor, + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - InputChip + +// 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. + +// dart format off +class _InputChipDefaultsM3 extends ChipThemeData { + _InputChipDefaultsM3(this.context, this.isEnabled, this.isSelected) + : super( + elevation: 0.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + showCheckmark: true, + ); + + final BuildContext context; + final bool isEnabled; + final bool isSelected; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + TextStyle? get labelStyle => _textTheme.labelLarge?.copyWith( + color: isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant + : _colors.onSurface, + ); + + @override + WidgetStateProperty<Color?>? get color => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected) && states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return _colors.secondaryContainer; + } + return null; + }); + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get checkmarkColor => isEnabled + ? isSelected + ? _colors.primary + : _colors.onSurfaceVariant + : _colors.onSurface; + + @override + Color? get deleteIconColor => isEnabled + ? isSelected + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant + : _colors.onSurface; + + @override + BorderSide? get side => !isSelected + ? isEnabled + ? BorderSide(color: _colors.outlineVariant) + : BorderSide(color: _colors.onSurface.withOpacity(0.12)) + : const BorderSide(color: Colors.transparent); + + @override + IconThemeData? get iconTheme => IconThemeData( + color: isEnabled + ? isSelected + ? _colors.primary + : _colors.onSurfaceVariant + : _colors.onSurface, + size: 18.0, + ); + + @override + EdgeInsetsGeometry? get padding => const EdgeInsets.all(8.0); + + /// The label padding of the chip scales with the font size specified in the + /// [labelStyle], and the system font size settings that scale font sizes + /// globally. + /// + /// The chip at effective font size 14.0 starts with 8px on each side and as + /// the font size scales up to closer to 28.0, the label padding is linearly + /// interpolated from 8px to 4px. Once the label has a font size of 2 or + /// higher, label padding remains 4px. + @override + EdgeInsetsGeometry? get labelPadding { + final double fontSize = labelStyle?.fontSize ?? 14.0; + final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(fontSize) / 14.0; + return EdgeInsets.lerp( + const EdgeInsets.symmetric(horizontal: 8.0), + const EdgeInsets.symmetric(horizontal: 4.0), + clampDouble(fontSizeRatio - 1.0, 0.0, 1.0), + )!; + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - InputChip diff --git a/packages/material_ui/lib/src/input_date_picker_form_field.dart b/packages/material_ui/lib/src/input_date_picker_form_field.dart new file mode 100644 index 000000000000..3ad621461959 --- /dev/null +++ b/packages/material_ui/lib/src/input_date_picker_form_field.dart @@ -0,0 +1,290 @@ +// 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. + +/// @docImport 'date_picker.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'date.dart'; +import 'date_picker_theme.dart'; +import 'input_border.dart'; +import 'input_decorator.dart'; +import 'material_localizations.dart'; +import 'text_form_field.dart'; +import 'theme.dart'; + +/// A [TextFormField] configured to accept and validate a date entered by a user. +/// +/// When the field is saved or submitted, the text will be parsed into a +/// [DateTime] according to the ambient locale's compact date format. If the +/// input text doesn't parse into a date, the [errorFormatText] message will +/// be displayed under the field. +/// +/// [firstDate], [lastDate], and [selectableDayPredicate] provide constraints on +/// what days are valid. If the input date isn't in the date range or doesn't pass +/// the given predicate, then the [errorInvalidText] message will be displayed +/// under the field. +/// +/// See also: +/// +/// * [showDatePicker], which shows a dialog that contains a Material Design +/// date picker which includes support for text entry of dates. +/// * [MaterialLocalizations.parseCompactDate], which is used to parse the text +/// input into a [DateTime]. +/// +class InputDatePickerFormField extends StatefulWidget { + /// Creates a [TextFormField] configured to accept and validate a date. + /// + /// If the optional [initialDate] is provided, then it will be used to populate + /// the text field. If the [fieldHintText] is provided, it will be shown. + /// + /// If [initialDate] is provided, it must not be before [firstDate] or after + /// [lastDate]. If [selectableDayPredicate] is provided, it must return `true` + /// for [initialDate]. + /// + /// [firstDate] must be on or before [lastDate]. + InputDatePickerFormField({ + super.key, + DateTime? initialDate, + required DateTime firstDate, + required DateTime lastDate, + this.onDateSubmitted, + this.onDateSaved, + this.selectableDayPredicate, + this.errorFormatText, + this.errorInvalidText, + this.fieldHintText, + this.fieldLabelText, + this.keyboardType, + this.autofocus = false, + this.acceptEmptyDate = false, + this.focusNode, + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate != null ? calendarDelegate.dateOnly(initialDate) : null, + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate) { + assert( + !this.lastDate.isBefore(this.firstDate), + 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + initialDate == null || !this.initialDate!.isBefore(this.firstDate), + 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + initialDate == null || !this.initialDate!.isAfter(this.lastDate), + 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.', + ); + assert( + selectableDayPredicate == null || + initialDate == null || + selectableDayPredicate!(this.initialDate!), + 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate.', + ); + } + + /// If provided, it will be used as the default value of the field. + final DateTime? initialDate; + + /// The earliest allowable [DateTime] that the user can input. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can input. + final DateTime lastDate; + + /// An optional method to call when the user indicates they are done editing + /// the text in the field. Will only be called if the input represents a valid + /// [DateTime]. + final ValueChanged<DateTime>? onDateSubmitted; + + /// An optional method to call with the final date when the form is + /// saved via [FormState.save]. Will only be called if the input represents + /// a valid [DateTime]. + final ValueChanged<DateTime>? onDateSaved; + + /// Function to provide full control over which [DateTime] can be selected. + final SelectableDayPredicate? selectableDayPredicate; + + /// The error text displayed if the entered date is not in the correct format. + final String? errorFormatText; + + /// The error text displayed if the date is not valid. + /// + /// A date is not valid if it is earlier than [firstDate], later than + /// [lastDate], or doesn't pass the [selectableDayPredicate]. + final String? errorInvalidText; + + /// The hint text displayed in the [TextField]. + /// + /// If this is null, it will default to the date format string. For example, + /// 'mm/dd/yyyy' for en_US. + final String? fieldHintText; + + /// The label text displayed in the [TextField]. + /// + /// If this is null, it will default to the words representing the date format + /// string. For example, 'Month, Day, Year' for en_US. + final String? fieldLabelText; + + /// The keyboard type of the [TextField]. + /// + /// If this is null, it will default to [TextInputType.datetime] + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// Determines if an empty date would show [errorFormatText] or not. + /// + /// Defaults to false. + /// + /// If true, [errorFormatText] is not shown when the date input field is empty. + final bool acceptEmptyDate; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate<DateTime> calendarDelegate; + + @override + State<InputDatePickerFormField> createState() => _InputDatePickerFormFieldState(); +} + +class _InputDatePickerFormFieldState extends State<InputDatePickerFormField> { + final TextEditingController _controller = TextEditingController(); + DateTime? _selectedDate; + String? _inputText; + bool _autoSelected = false; + + @override + void initState() { + super.initState(); + _selectedDate = widget.initialDate; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateValueForSelectedDate(); + } + + @override + void didUpdateWidget(InputDatePickerFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialDate != oldWidget.initialDate) { + // Can't update the form field in the middle of a build, so do it next frame + WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) { + setState(() { + _selectedDate = widget.initialDate; + _updateValueForSelectedDate(); + }); + }, debugLabel: 'InputDatePickerFormField.update'); + } + } + + void _updateValueForSelectedDate() { + if (_selectedDate != null) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + _inputText = widget.calendarDelegate.formatCompactDate(_selectedDate!, localizations); + var textEditingValue = TextEditingValue(text: _inputText!); + // Select the new text if we are auto focused and haven't selected the text before. + if (widget.autofocus && !_autoSelected) { + textEditingValue = textEditingValue.copyWith( + selection: TextSelection(baseOffset: 0, extentOffset: _inputText!.length), + ); + _autoSelected = true; + } + _controller.value = textEditingValue; + } else { + _inputText = ''; + _controller.value = TextEditingValue(text: _inputText!); + } + } + + DateTime? _parseDate(String? text) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return widget.calendarDelegate.parseCompactDate(text, localizations); + } + + bool _isValidAcceptableDate(DateTime? date) { + return date != null && + !date.isBefore(widget.firstDate) && + !date.isAfter(widget.lastDate) && + (widget.selectableDayPredicate == null || widget.selectableDayPredicate!(date)); + } + + String? _validateDate(String? text) { + if ((text == null || text.isEmpty) && widget.acceptEmptyDate) { + return null; + } + final DateTime? date = _parseDate(text); + if (date == null) { + return widget.errorFormatText ?? MaterialLocalizations.of(context).invalidDateFormatLabel; + } else if (!_isValidAcceptableDate(date)) { + return widget.errorInvalidText ?? MaterialLocalizations.of(context).dateOutOfRangeLabel; + } + return null; + } + + void _updateDate(String? text, ValueChanged<DateTime>? callback) { + final DateTime? date = _parseDate(text); + if (_isValidAcceptableDate(date)) { + _selectedDate = date; + _inputText = text; + callback?.call(_selectedDate!); + } + } + + void _handleSaved(String? text) { + _updateDate(text, widget.onDateSaved); + } + + void _handleSubmitted(String text) { + _updateDate(text, widget.onDateSubmitted); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final bool useMaterial3 = theme.useMaterial3; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final DatePickerThemeData datePickerTheme = theme.datePickerTheme; + final InputDecorationThemeData inputTheme = InputDecorationTheme.of(context); + final InputBorder effectiveInputBorder = + datePickerTheme.inputDecorationTheme?.border ?? + inputTheme.border ?? + (useMaterial3 ? const OutlineInputBorder() : const UnderlineInputBorder()); + + return Semantics( + container: true, + child: TextFormField( + decoration: + InputDecoration( + hintText: widget.fieldHintText ?? widget.calendarDelegate.dateHelpText(localizations), + labelText: widget.fieldLabelText ?? localizations.dateInputLabel, + ).applyDefaults( + inputTheme + .merge(datePickerTheme.inputDecorationTheme) + .copyWith(border: effectiveInputBorder), + ), + validator: _validateDate, + keyboardType: widget.keyboardType ?? TextInputType.datetime, + onSaved: _handleSaved, + onFieldSubmitted: _handleSubmitted, + autofocus: widget.autofocus, + controller: _controller, + focusNode: widget.focusNode, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/input_decorator.dart b/packages/material_ui/lib/src/input_decorator.dart new file mode 100644 index 000000000000..656d48c625c1 --- /dev/null +++ b/packages/material_ui/lib/src/input_decorator.dart @@ -0,0 +1,6107 @@ +// 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. + +/// @docImport 'package:flutter/widgets.dart'; +/// +/// @docImport 'dropdown.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'text_field.dart'; +/// @docImport 'text_form_field.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'icon_button_theme.dart'; +import 'input_border.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late Widget _myIcon; +// late BuildContext context; + +// The duration value extracted from: +// https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/textfield/TextInputLayout.java +const Duration _kTransitionDuration = Duration(milliseconds: 167); +const Curve _kTransitionCurve = Curves.fastOutSlowIn; +const double _kFinalLabelScale = 0.75; + +// From the M3 spec, horizontal padding is 12 pixels for the prefix icon and +// 16 pixels for the input content. +// InputDecorator default padding is set to 12 pixels because 16 pixels will move +// the prefix icon too far. +// An extra padding should be added for the input content to comply with the 16 pixels padding. +const double _kInputExtraPadding = 4.0; + +// Padding between the character counter and helper/error text to prevent overlap. +// Based on Material 3 specification for text fields. +const double _kSubtextCounterPadding = 16.0; + +typedef _SubtextSize = ({double ascent, double bottomHeight, double subtextHeight}); +typedef _ChildBaselineGetter = double Function(RenderBox child, BoxConstraints constraints); + +// The default duration for hint fade in/out transitions. +// +// Animating hint is not mentioned in the Material specification. +// The animation is kept for backward compatibility and a short duration +// is used to mitigate the UX impact. +const Duration _kHintFadeTransitionDuration = Duration(milliseconds: 20); + +// Defines the gap in the InputDecorator's outline border where the +// floating label will appear. +class _InputBorderGap extends ChangeNotifier { + double? _start; + double? get start => _start; + set start(double? value) { + if (value != _start) { + _start = value; + notifyListeners(); + } + } + + double _extent = 0.0; + double get extent => _extent; + set extent(double value) { + if (value != _extent) { + _extent = value; + notifyListeners(); + } + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes, this class is not used in collection + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is _InputBorderGap && other.start == start && other.extent == extent; + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes, this class is not used in collection + int get hashCode => Object.hash(start, extent); + + @override + String toString() => describeIdentity(this); +} + +// Used to interpolate between two InputBorders. +class _InputBorderTween extends Tween<InputBorder> { + _InputBorderTween({super.begin, super.end}); + + @override + InputBorder lerp(double t) => ShapeBorder.lerp(begin, end, t)! as InputBorder; +} + +// Passes the _InputBorderGap parameters along to an InputBorder's paint method. +class _InputBorderPainter extends CustomPainter { + _InputBorderPainter({ + required Listenable repaint, + required this.borderAnimation, + required this.border, + required this.gapAnimation, + required this.gap, + required this.textDirection, + required this.fillColor, + required this.hoverAnimation, + required this.hoverColorTween, + }) : super(repaint: repaint); + + final Animation<double> borderAnimation; + final _InputBorderTween border; + final Animation<double> gapAnimation; + final _InputBorderGap gap; + final TextDirection textDirection; + final Color fillColor; + final ColorTween hoverColorTween; + final Animation<double> hoverAnimation; + + Color get blendedColor => Color.alphaBlend(hoverColorTween.evaluate(hoverAnimation)!, fillColor); + + @override + void paint(Canvas canvas, Size size) { + final InputBorder borderValue = border.evaluate(borderAnimation); + final Rect canvasRect = Offset.zero & size; + final Color blendedFillColor = blendedColor; + if (blendedFillColor.alpha > 0) { + final paint = Paint() + ..color = blendedFillColor + ..style = PaintingStyle.fill; + if (borderValue.preferPaintInterior) { + borderValue.paintInterior(canvas, canvasRect, paint, textDirection: textDirection); + } else { + canvas.drawPath(borderValue.getOuterPath(canvasRect, textDirection: textDirection), paint); + } + } + + borderValue.paint( + canvas, + canvasRect, + gapStart: gap.start, + gapExtent: gap.extent, + gapPercentage: gapAnimation.value, + textDirection: textDirection, + ); + } + + @override + bool shouldRepaint(_InputBorderPainter oldPainter) { + return borderAnimation != oldPainter.borderAnimation || + hoverAnimation != oldPainter.hoverAnimation || + gapAnimation != oldPainter.gapAnimation || + border != oldPainter.border || + gap != oldPainter.gap || + textDirection != oldPainter.textDirection; + } + + @override + String toString() => describeIdentity(this); +} + +// An analog of AnimatedContainer, which can animate its shaped border, for +// _InputBorder. This specialized animated container is needed because the +// _InputBorderGap, which is computed at layout time, is required by the +// _InputBorder's paint method. +class _BorderContainer extends StatefulWidget { + const _BorderContainer({ + required this.border, + required this.gap, + required this.gapAnimation, + required this.fillColor, + required this.hoverColor, + required this.isHovering, + }); + + final InputBorder border; + final _InputBorderGap gap; + final Animation<double> gapAnimation; + final Color fillColor; + final Color hoverColor; + final bool isHovering; + + @override + _BorderContainerState createState() => _BorderContainerState(); +} + +class _BorderContainerState extends State<_BorderContainer> with TickerProviderStateMixin { + static const Duration _kHoverDuration = Duration(milliseconds: 15); + + late AnimationController _controller; + late AnimationController _hoverColorController; + late CurvedAnimation _borderAnimation; + late _InputBorderTween _border; + late CurvedAnimation _hoverAnimation; + late ColorTween _hoverColorTween; + + @override + void initState() { + super.initState(); + _hoverColorController = AnimationController( + duration: _kHoverDuration, + value: widget.isHovering ? 1.0 : 0.0, + vsync: this, + ); + _controller = AnimationController(duration: _kTransitionDuration, vsync: this); + _borderAnimation = CurvedAnimation( + parent: _controller, + curve: _kTransitionCurve, + reverseCurve: _kTransitionCurve.flipped, + ); + _border = _InputBorderTween(begin: widget.border, end: widget.border); + _hoverAnimation = CurvedAnimation(parent: _hoverColorController, curve: Curves.linear); + + // Animate between transparent [widget.hoverColor] and [widget.hoverColor]. + _hoverColorTween = ColorTween(begin: widget.hoverColor.withAlpha(0), end: widget.hoverColor); + } + + @override + void dispose() { + _controller.dispose(); + _hoverColorController.dispose(); + _borderAnimation.dispose(); + _hoverAnimation.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(_BorderContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.border != oldWidget.border) { + _border = _InputBorderTween(begin: oldWidget.border, end: widget.border); + _controller + ..value = 0.0 + ..forward(); + } + if (widget.hoverColor != oldWidget.hoverColor) { + // Animate between transparent [widget.hoverColor] and [widget.hoverColor]. + _hoverColorTween = ColorTween(begin: widget.hoverColor.withAlpha(0), end: widget.hoverColor); + } + if (widget.isHovering != oldWidget.isHovering) { + if (widget.isHovering) { + _hoverColorController.forward(); + } else { + _hoverColorController.reverse(); + } + } + } + + @override + Widget build(BuildContext context) { + return CustomPaint( + foregroundPainter: _InputBorderPainter( + repaint: Listenable.merge(<Listenable>[ + _borderAnimation, + widget.gap, + _hoverColorController, + ]), + borderAnimation: _borderAnimation, + border: _border, + gapAnimation: widget.gapAnimation, + gap: widget.gap, + textDirection: Directionality.of(context), + fillColor: widget.fillColor, + hoverColorTween: _hoverColorTween, + hoverAnimation: _hoverAnimation, + ), + ); + } +} + +// Display the helper and error text. When the error text appears +// it fades and the helper text fades out. The error text also +// slides upwards a little when it first appears. +class _HelperError extends StatefulWidget { + const _HelperError({ + this.textAlign, + this.helper, + this.helperText, + this.helperStyle, + this.helperMaxLines, + this.error, + this.errorText, + this.errorStyle, + this.errorMaxLines, + }); + + final TextAlign? textAlign; + final Widget? helper; + final String? helperText; + final TextStyle? helperStyle; + final int? helperMaxLines; + final Widget? error; + final String? errorText; + final TextStyle? errorStyle; + final int? errorMaxLines; + + @override + _HelperErrorState createState() => _HelperErrorState(); +} + +class _HelperErrorState extends State<_HelperError> with SingleTickerProviderStateMixin { + // If the height of this widget and the counter are zero ("empty") at + // layout time, no space is allocated for the subtext. + static const Widget empty = SizedBox.shrink(); + + late AnimationController _controller; + Widget? _helper; + Widget? _error; + + bool get _hasHelper => widget.helperText != null || widget.helper != null; + bool get _hasError => widget.errorText != null || widget.error != null; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: _kTransitionDuration, vsync: this); + if (_hasError) { + _error = _buildError(); + _controller.value = 1.0; + } else if (_hasHelper) { + _helper = _buildHelper(); + } + _controller.addListener(_handleChange); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleChange() { + setState(() { + // The _controller's value has changed. + }); + } + + @override + void didUpdateWidget(_HelperError old) { + super.didUpdateWidget(old); + + final Widget? newError = widget.error; + final String? newErrorText = widget.errorText; + final Widget? newHelper = widget.helper; + final String? newHelperText = widget.helperText; + final Widget? oldError = old.error; + final String? oldErrorText = old.errorText; + final Widget? oldHelper = old.helper; + final String? oldHelperText = old.helperText; + + final errorStateChanged = (newError != null) != (oldError != null); + final errorTextStateChanged = (newErrorText != null) != (oldErrorText != null); + final helperStateChanged = (newHelper != null) != (oldHelper != null); + final bool helperTextStateChanged = + newErrorText == null && (newHelperText != null) != (oldHelperText != null); + + if (errorStateChanged || + errorTextStateChanged || + helperStateChanged || + helperTextStateChanged) { + if (newError != null || newErrorText != null) { + _error = _buildError(); + _controller.forward(); + } else if (newHelper != null || newHelperText != null) { + _helper = _buildHelper(); + _controller.reverse(); + } else { + _controller.reverse(); + } + } + } + + Widget _buildHelper() { + assert(widget.helper != null || widget.helperText != null); + return Semantics( + container: true, + child: FadeTransition( + opacity: Tween<double>(begin: 1.0, end: 0.0).animate(_controller), + child: + widget.helper ?? + Text( + widget.helperText!, + style: widget.helperStyle, + textAlign: widget.textAlign, + overflow: TextOverflow.ellipsis, + maxLines: widget.helperMaxLines, + ), + ), + ); + } + + Widget _buildError() { + assert(widget.error != null || widget.errorText != null); + final String? capturedErrorText = widget.errorText; + Widget? capturedError = widget.error; + if (capturedError != null && widget.errorStyle != null) { + capturedError = DefaultTextStyle(style: widget.errorStyle!, child: capturedError); + } + return Builder( + builder: (BuildContext context) { + return Semantics( + container: true, + liveRegion: !MediaQuery.supportsAnnounceOf(context), + child: FadeTransition( + opacity: _controller, + child: FractionalTranslation( + translation: Tween<Offset>( + begin: const Offset(0.0, -0.25), + end: Offset.zero, + ).evaluate(_controller.view), + child: + capturedError ?? + Text( + capturedErrorText!, + style: widget.errorStyle, + textAlign: widget.textAlign, + overflow: TextOverflow.ellipsis, + maxLines: widget.errorMaxLines, + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + if (_controller.isDismissed) { + _error = null; + if (_hasHelper) { + return _helper = _buildHelper(); + } else { + _helper = null; + return empty; + } + } + + if (_controller.isCompleted) { + _helper = null; + if (_hasError) { + return _error = _buildError(); + } else { + _error = null; + return empty; + } + } + + if (_helper == null && _hasError) { + return _buildError(); + } + + if (_error == null && _hasHelper) { + return _buildHelper(); + } + + if (_hasError) { + return Stack( + children: <Widget>[ + FadeTransition( + opacity: Tween<double>(begin: 1.0, end: 0.0).animate(_controller), + child: _helper, + ), + _buildError(), + ], + ); + } + + if (_hasHelper) { + return Stack( + children: <Widget>[ + _buildHelper(), + FadeTransition(opacity: _controller, child: _error), + ], + ); + } + + return empty; + } +} + +/// Defines **how** the floating label should behave. +/// +/// See also: +/// +/// * [InputDecoration.floatingLabelBehavior] which defines the behavior for +/// [InputDecoration.label] or [InputDecoration.labelText]. +/// * [FloatingLabelAlignment] which defines **where** the floating label +/// should displayed. +enum FloatingLabelBehavior { + /// The label will always be positioned within the content, or hidden. + never, + + /// The label will float when the input is focused, or has content. + auto, + + /// The label will always float above the content. + always, +} + +/// Defines **where** the floating label should be displayed within an +/// [InputDecorator]. +/// +/// See also: +/// +/// * [InputDecoration.floatingLabelAlignment] which defines the alignment for +/// [InputDecoration.label] or [InputDecoration.labelText]. +/// * [FloatingLabelBehavior] which defines **how** the floating label should +/// behave. +@immutable +class FloatingLabelAlignment { + const FloatingLabelAlignment._(this._x) : assert(_x >= -1.0 && _x <= 1.0); + + // -1 denotes start, 0 denotes center, and 1 denotes end. + final double _x; + + /// Align the floating label on the leading edge of the [InputDecorator]. + /// + /// For left-to-right text ([TextDirection.ltr]), this is the left edge. + /// + /// For right-to-left text ([TextDirection.rtl]), this is the right edge. + static const FloatingLabelAlignment start = FloatingLabelAlignment._(-1.0); + + /// Aligns the floating label to the center of an [InputDecorator]. + static const FloatingLabelAlignment center = FloatingLabelAlignment._(0.0); + + @override + int get hashCode => _x.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is FloatingLabelAlignment && _x == other._x; + } + + static String _stringify(double x) { + return switch (x) { + -1.0 => 'FloatingLabelAlignment.start', + 0.0 => 'FloatingLabelAlignment.center', + _ => 'FloatingLabelAlignment(x: ${x.toStringAsFixed(1)})', + }; + } + + @override + String toString() => _stringify(_x); +} + +// Identifies the children of a _RenderDecorationElement. +enum _DecorationSlot { + icon, + input, + label, + hint, + prefix, + suffix, + prefixIcon, + suffixIcon, + helperError, + counter, + container, +} + +// An analog of InputDecoration for the _Decorator widget. +@immutable +class _Decoration { + const _Decoration({ + required this.contentPadding, + required this.isCollapsed, + required this.floatingLabelHeight, + required this.floatingLabelProgress, + required this.floatingLabelAlignment, + required this.border, + required this.borderGap, + required this.alignLabelWithHint, + required this.isDense, + required this.isEmpty, + required this.visualDensity, + required this.inputGap, + required this.maintainHintSize, + required this.maintainLabelSize, + this.icon, + this.input, + this.label, + this.hint, + this.prefix, + this.suffix, + this.prefixIcon, + this.suffixIcon, + this.helperError, + this.counter, + this.container, + }); + + final EdgeInsetsDirectional contentPadding; + final bool isCollapsed; + final double floatingLabelHeight; + final double floatingLabelProgress; + final FloatingLabelAlignment floatingLabelAlignment; + final InputBorder border; + final _InputBorderGap borderGap; + final bool alignLabelWithHint; + final bool? isDense; + final bool isEmpty; + final VisualDensity visualDensity; + final double inputGap; + final bool maintainHintSize; + final bool maintainLabelSize; + final Widget? icon; + final Widget? input; + final Widget? label; + final Widget? hint; + final Widget? prefix; + final Widget? suffix; + final Widget? prefixIcon; + final Widget? suffixIcon; + final Widget? helperError; + final Widget? counter; + final Widget? container; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is _Decoration && + other.contentPadding == contentPadding && + other.isCollapsed == isCollapsed && + other.floatingLabelHeight == floatingLabelHeight && + other.floatingLabelProgress == floatingLabelProgress && + other.floatingLabelAlignment == floatingLabelAlignment && + other.border == border && + other.borderGap == borderGap && + other.alignLabelWithHint == alignLabelWithHint && + other.isDense == isDense && + other.isEmpty == isEmpty && + other.visualDensity == visualDensity && + other.inputGap == inputGap && + other.maintainHintSize == maintainHintSize && + other.maintainLabelSize == maintainLabelSize && + other.icon == icon && + other.input == input && + other.label == label && + other.hint == hint && + other.prefix == prefix && + other.suffix == suffix && + other.prefixIcon == prefixIcon && + other.suffixIcon == suffixIcon && + other.helperError == helperError && + other.counter == counter && + other.container == container; + } + + @override + int get hashCode => Object.hash( + contentPadding, + floatingLabelHeight, + floatingLabelProgress, + floatingLabelAlignment, + border, + borderGap, + alignLabelWithHint, + isDense, + isEmpty, + visualDensity, + inputGap, + maintainHintSize, + maintainLabelSize, + icon, + input, + label, + hint, + prefix, + suffix, + Object.hash(prefixIcon, suffixIcon, helperError, counter, container), + ); +} + +// A container for the layout values computed by _RenderDecoration._layout. +// These values are used by _RenderDecoration.performLayout to position +// all of the renderer children of a _RenderDecoration. +class _RenderDecorationLayout { + const _RenderDecorationLayout({ + required this.inputConstraints, + required this.baseline, + required this.containerHeight, + required this.subtextSize, + required this.size, + }); + + final BoxConstraints inputConstraints; + final double baseline; + final double containerHeight; + final _SubtextSize? subtextSize; + final Size size; +} + +// The workhorse: layout and paint a _Decorator widget's _Decoration. +class _RenderDecoration extends RenderBox + with SlottedContainerRenderObjectMixin<_DecorationSlot, RenderBox> { + _RenderDecoration({ + required _Decoration decoration, + required TextDirection textDirection, + required TextBaseline textBaseline, + required bool isFocused, + required bool expands, + required bool material3, + TextAlignVertical? textAlignVertical, + }) : _decoration = decoration, + _textDirection = textDirection, + _textBaseline = textBaseline, + _textAlignVertical = textAlignVertical, + _isFocused = isFocused, + _expands = expands, + _material3 = material3; + + // TODO(bleroux): consider defining this value as a Material token and making it + // configurable by InputDecorationThemeData. + double get subtextGap => material3 ? 4.0 : 8.0; + double get prefixToInputGap => material3 ? 4.0 : 0.0; + double get inputToSuffixGap => material3 ? 4.0 : 0.0; + + RenderBox? get icon => childForSlot(_DecorationSlot.icon); + RenderBox? get input => childForSlot(_DecorationSlot.input); + RenderBox? get label => childForSlot(_DecorationSlot.label); + RenderBox? get hint => childForSlot(_DecorationSlot.hint); + RenderBox? get prefix => childForSlot(_DecorationSlot.prefix); + RenderBox? get suffix => childForSlot(_DecorationSlot.suffix); + RenderBox? get prefixIcon => childForSlot(_DecorationSlot.prefixIcon); + RenderBox? get suffixIcon => childForSlot(_DecorationSlot.suffixIcon); + RenderBox get helperError => childForSlot(_DecorationSlot.helperError)!; + RenderBox? get counter => childForSlot(_DecorationSlot.counter); + RenderBox? get container => childForSlot(_DecorationSlot.container); + + // The returned list is ordered for hit testing. + @override + Iterable<RenderBox> get children { + final RenderBox? helperError = childForSlot(_DecorationSlot.helperError); + return <RenderBox>[ + ?icon, + ?input, + ?prefixIcon, + ?suffixIcon, + ?prefix, + ?suffix, + ?label, + ?hint, + ?helperError, + ?counter, + ?container, + ]; + } + + _Decoration get decoration => _decoration; + _Decoration _decoration; + set decoration(_Decoration value) { + if (_decoration == value) { + return; + } + _decoration = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + TextBaseline get textBaseline => _textBaseline; + TextBaseline _textBaseline; + set textBaseline(TextBaseline value) { + if (_textBaseline == value) { + return; + } + _textBaseline = value; + markNeedsLayout(); + } + + TextAlignVertical get _defaultTextAlignVertical => + _isOutlineAligned ? TextAlignVertical.center : TextAlignVertical.top; + TextAlignVertical get textAlignVertical => _textAlignVertical ?? _defaultTextAlignVertical; + TextAlignVertical? _textAlignVertical; + set textAlignVertical(TextAlignVertical? value) { + if (_textAlignVertical == value) { + return; + } + // No need to relayout if the effective value is still the same. + if (textAlignVertical.y == (value?.y ?? _defaultTextAlignVertical.y)) { + _textAlignVertical = value; + return; + } + _textAlignVertical = value; + markNeedsLayout(); + } + + bool get isFocused => _isFocused; + bool _isFocused; + set isFocused(bool value) { + if (_isFocused == value) { + return; + } + _isFocused = value; + markNeedsSemanticsUpdate(); + } + + bool get expands => _expands; + bool _expands = false; + set expands(bool value) { + if (_expands == value) { + return; + } + _expands = value; + markNeedsLayout(); + } + + bool get material3 => _material3; + bool _material3 = false; + set material3(bool value) { + if (_material3 == value) { + return; + } + _material3 = value; + markNeedsLayout(); + } + + // Indicates that the decoration should be aligned to accommodate an outline + // border. + bool get _isOutlineAligned { + return !decoration.isCollapsed && decoration.border.isOutline; + } + + Offset get _densityOffset => decoration.visualDensity.baseSizeAdjustment; + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + if (icon != null) { + visitor(icon!); + } + if (prefix != null) { + visitor(prefix!); + } + if (prefixIcon != null) { + visitor(prefixIcon!); + } + + if (label != null) { + visitor(label!); + } + if (hint != null) { + if (isFocused) { + visitor(hint!); + } else if (label == null) { + visitor(hint!); + } + } + + if (input != null) { + visitor(input!); + } + if (suffixIcon != null) { + visitor(suffixIcon!); + } + if (suffix != null) { + visitor(suffix!); + } + if (container != null) { + visitor(container!); + } + visitor(helperError); + if (counter != null) { + visitor(counter!); + } + } + + static double _minWidth(RenderBox? box, double height) => + box?.getMinIntrinsicWidth(height) ?? 0.0; + static double _maxWidth(RenderBox? box, double height) => + box?.getMaxIntrinsicWidth(height) ?? 0.0; + static double _minHeight(RenderBox? box, double width) => + box?.getMinIntrinsicHeight(width) ?? 0.0; + static Size _boxSize(RenderBox? box) => box?.size ?? Size.zero; + static double _getBaseline(RenderBox box, BoxConstraints boxConstraints) { + return ChildLayoutHelper.getBaseline(box, boxConstraints, TextBaseline.alphabetic) ?? + box.size.height; + } + + static double _getDryBaseline(RenderBox box, BoxConstraints boxConstraints) { + return ChildLayoutHelper.getDryBaseline(box, boxConstraints, TextBaseline.alphabetic) ?? + ChildLayoutHelper.dryLayoutChild(box, boxConstraints).height; + } + + static BoxParentData _boxParentData(RenderBox box) => box.parentData! as BoxParentData; + + EdgeInsetsDirectional get contentPadding => decoration.contentPadding; + + _SubtextSize? _computeSubtextSizes({ + required BoxConstraints constraints, + required ChildLayouter layoutChild, + required _ChildBaselineGetter getBaseline, + }) { + final (Size counterSize, double counterAscent) = switch (counter) { + final RenderBox box => (layoutChild(box, constraints), getBaseline(box, constraints)), + null => (Size.zero, 0.0), + }; + + // Only add padding when counter is present (maxLength is used). + final double counterPadding = counter != null ? _kSubtextCounterPadding : 0.0; + final BoxConstraints helperErrorConstraints = constraints.deflate( + EdgeInsets.only(left: counterSize.width + counterPadding), + ); + final double helperErrorHeight = layoutChild(helperError, helperErrorConstraints).height; + + if (helperErrorHeight == 0.0 && counterSize.height == 0.0) { + return null; + } + + // TODO(LongCatIsLooong): the bottomHeight expression doesn't make much sense. + // Use the real descent and make sure the subtext line box is tall enough for both children. + // See https://github.com/flutter/flutter/issues/13715 + final double ascent = + math.max(counterAscent, getBaseline(helperError, helperErrorConstraints)) + subtextGap; + final double bottomHeight = math.max(counterAscent, helperErrorHeight) + subtextGap; + final double subtextHeight = math.max(counterSize.height, helperErrorHeight) + subtextGap; + return (ascent: ascent, bottomHeight: bottomHeight, subtextHeight: subtextHeight); + } + + // Returns a value used by performLayout to position all of the renderers. + // This method applies layout to all of the renderers except the container. + // For convenience, the container is laid out in performLayout(). + _RenderDecorationLayout _layout( + BoxConstraints constraints, { + required ChildLayouter layoutChild, + required _ChildBaselineGetter getBaseline, + }) { + assert( + constraints.maxWidth < double.infinity, + 'An InputDecorator, which is typically created by a TextField, cannot ' + 'have an unbounded width.\n' + 'This happens when the parent widget does not provide a finite width ' + 'constraint. For example, if the InputDecorator is contained by a Row, ' + 'then its width must be constrained. An Expanded widget or a SizedBox ' + 'can be used to constrain the width of the InputDecorator or the ' + 'TextField that contains it.', + ); + + final BoxConstraints boxConstraints = constraints.loosen(); + + // Layout all the widgets used by InputDecorator + final RenderBox? icon = this.icon; + final double iconWidth = icon == null ? 0.0 : layoutChild(icon, boxConstraints).width; + final BoxConstraints containerConstraints = boxConstraints.deflate( + EdgeInsets.only(left: iconWidth), + ); + final BoxConstraints contentConstraints = containerConstraints.deflate( + EdgeInsetsDirectional.only( + start: contentPadding.start + decoration.inputGap, + end: contentPadding.end + decoration.inputGap, + ), + ); + + // The helper or error text can occupy the full width less the space + // occupied by the icon and counter. + final _SubtextSize? subtextSize = _computeSubtextSizes( + constraints: contentConstraints, + layoutChild: layoutChild, + getBaseline: getBaseline, + ); + + final RenderBox? prefixIcon = this.prefixIcon; + final RenderBox? suffixIcon = this.suffixIcon; + final Size prefixIconSize = prefixIcon == null + ? Size.zero + : layoutChild(prefixIcon, containerConstraints); + final Size suffixIconSize = suffixIcon == null + ? Size.zero + : layoutChild(suffixIcon, containerConstraints); + final RenderBox? prefix = this.prefix; + final RenderBox? suffix = this.suffix; + final Size prefixSize = prefix == null ? Size.zero : layoutChild(prefix, contentConstraints); + final Size suffixSize = suffix == null ? Size.zero : layoutChild(suffix, contentConstraints); + + final accessoryHorizontalInsets = EdgeInsetsDirectional.only( + start: + iconWidth + + prefixSize.width + + (prefixIcon == null + ? contentPadding.start + decoration.inputGap + : prefixIconSize.width + prefixToInputGap), + end: + suffixSize.width + + (suffixIcon == null + ? contentPadding.end + decoration.inputGap + : suffixIconSize.width + inputToSuffixGap), + ); + + final double inputWidth = math.max( + 0.0, + constraints.maxWidth - accessoryHorizontalInsets.horizontal, + ); + final RenderBox? label = this.label; + final double topHeight; + if (label != null) { + final double suffixIconSpace = decoration.border.isOutline + ? lerpDouble(suffixIconSize.width, contentPadding.end, decoration.floatingLabelProgress)! + : suffixIconSize.width; + final double labelWidth = math.max( + 0.0, + constraints.maxWidth - + (decoration.inputGap * 2 + + iconWidth + + (prefixIcon == null ? contentPadding.start : prefixIconSize.width) + + (suffixIcon == null ? contentPadding.end : suffixIconSpace)), + ); + + // Increase the available width for the label when it is scaled down. + final double invertedLabelScale = lerpDouble( + 1.00, + 1 / _kFinalLabelScale, + decoration.floatingLabelProgress, + )!; + final BoxConstraints labelConstraints = boxConstraints.copyWith( + maxWidth: labelWidth * invertedLabelScale, + ); + layoutChild(label, labelConstraints); + + final double labelHeight = decoration.floatingLabelHeight; + topHeight = decoration.border.isOutline + ? math.max(labelHeight - getBaseline(label, labelConstraints), 0.0) + : labelHeight; + } else { + topHeight = 0.0; + } + + // The height of the input needs to accommodate label above and counter and + // helperError below, when they exist. + final double bottomHeight = subtextSize?.bottomHeight ?? 0.0; + final BoxConstraints inputConstraints = boxConstraints + .deflate( + EdgeInsets.only( + top: contentPadding.vertical + topHeight + bottomHeight + _densityOffset.dy, + ), + ) + .tighten(width: inputWidth); + + final RenderBox? input = this.input; + final RenderBox? hint = this.hint; + final Size inputSize = input == null ? Size.zero : layoutChild(input, inputConstraints); + final Size hintSize = hint == null + ? Size.zero + : layoutChild(hint, boxConstraints.tighten(width: inputWidth)); + final double inputBaseline = input == null ? 0.0 : getBaseline(input, inputConstraints); + final double hintBaseline = hint == null + ? 0.0 + : getBaseline(hint, boxConstraints.tighten(width: inputWidth)); + + // The field can be occupied by a hint or by the input itself. + final double inputHeight = math.max( + decoration.isEmpty || decoration.maintainHintSize ? hintSize.height : 0.0, + inputSize.height, + ); + final double inputInternalBaseline = math.max(inputBaseline, hintBaseline); + + final double prefixBaseline = prefix == null ? 0.0 : getBaseline(prefix, contentConstraints); + final double suffixBaseline = suffix == null ? 0.0 : getBaseline(suffix, contentConstraints); + + // Calculate the amount that prefix/suffix affects height above and below + // the input. + final double fixHeight = math.max(prefixBaseline, suffixBaseline); + final double fixAboveInput = math.max(0, fixHeight - inputInternalBaseline); + final double fixBelowBaseline = math.max( + prefixSize.height - prefixBaseline, + suffixSize.height - suffixBaseline, + ); + // TODO(justinmc): fixBelowInput should have no effect when there is no + // prefix/suffix below the input. + // https://github.com/flutter/flutter/issues/66050 + final double fixBelowInput = math.max( + 0, + fixBelowBaseline - (inputHeight - inputInternalBaseline), + ); + + // Calculate the height of the input text container. + final double fixIconHeight = math.max(prefixIconSize.height, suffixIconSize.height); + final double contentHeight = math.max( + fixIconHeight, + topHeight + + contentPadding.top + + fixAboveInput + + inputHeight + + fixBelowInput + + contentPadding.bottom + + _densityOffset.dy, + ); + final double minContainerHeight = decoration.isDense! || decoration.isCollapsed || expands + ? inputHeight + : kMinInteractiveDimension; + final double maxContainerHeight = math.max(0.0, boxConstraints.maxHeight - bottomHeight); + final double containerHeight = expands + ? maxContainerHeight + : math.min(math.max(contentHeight, minContainerHeight), maxContainerHeight); + + // Ensure the text is vertically centered in cases where the content is + // shorter than kMinInteractiveDimension. + final double interactiveAdjustment = minContainerHeight > contentHeight + ? (minContainerHeight - contentHeight) / 2.0 + : 0.0; + + // Try to consider the prefix/suffix as part of the text when aligning it. + // If the prefix/suffix overflows however, allow it to extend outside of the + // input and align the remaining part of the text and prefix/suffix. + final double overflow = math.max(0, contentHeight - maxContainerHeight); + // Map textAlignVertical from -1:1 to 0:1 so that it can be used to scale + // the baseline from its minimum to maximum values. + final double textAlignVerticalFactor = (textAlignVertical.y + 1.0) / 2.0; + // Adjust to try to fit top overflow inside the input on an inverse scale of + // textAlignVertical, so that top aligned text adjusts the most and bottom + // aligned text doesn't adjust at all. + final double baselineAdjustment = fixAboveInput - overflow * (1 - textAlignVerticalFactor); + + // The baselines that will be used to draw the actual input text content. + final double topInputBaseline = + contentPadding.top + + topHeight + + inputInternalBaseline + + baselineAdjustment + + interactiveAdjustment + + _densityOffset.dy / 2.0; + final double maxContentHeight = + containerHeight - contentPadding.vertical - topHeight - _densityOffset.dy; + final double alignableHeight = fixAboveInput + inputHeight + fixBelowInput; + final double maxVerticalOffset = maxContentHeight - alignableHeight; + + final double baseline; + if (_isOutlineAligned) { + // The three main alignments for the baseline when an outline is present are + // + // * top (-1.0): topmost point considering padding. + // * center (0.0): the absolute center of the input ignoring padding but + // accommodating the border and floating label. + // * bottom (1.0): bottommost point considering padding. + // + // That means that if the padding is uneven, center is not the exact + // midpoint of top and bottom. To account for this, the above center and + // below center alignments are interpolated independently. + final double outlineCenterBaseline = + inputInternalBaseline + baselineAdjustment / 2.0 + (containerHeight - inputHeight) / 2.0; + final outlineTopBaseline = topInputBaseline; + final double outlineBottomBaseline = topInputBaseline + maxVerticalOffset; + baseline = _interpolateThree( + outlineTopBaseline, + outlineCenterBaseline, + outlineBottomBaseline, + textAlignVertical, + ); + } else { + final double textAlignVerticalOffset = maxVerticalOffset * textAlignVerticalFactor; + baseline = topInputBaseline + textAlignVerticalOffset; + } + + return _RenderDecorationLayout( + inputConstraints: inputConstraints, + containerHeight: containerHeight, + baseline: baseline, + subtextSize: subtextSize, + size: Size(constraints.maxWidth, containerHeight + (subtextSize?.subtextHeight ?? 0.0)), + ); + } + + // Interpolate between three stops using textAlignVertical. This is used to + // calculate the outline baseline, which ignores padding when the alignment is + // middle. When the alignment is less than zero, it interpolates between the + // centered text box's top and the top of the content padding. When the + // alignment is greater than zero, it interpolates between the centered box's + // top and the position that would align the bottom of the box with the bottom + // padding. + static double _interpolateThree( + double begin, + double middle, + double end, + TextAlignVertical textAlignVertical, + ) { + // It's possible for begin, middle, and end to not be in order because of + // excessive padding. Those cases are handled by using middle. + final double basis = textAlignVertical.y <= 0 + ? math.max(middle - begin, 0) + : math.max(end - middle, 0); + return middle + basis * textAlignVertical.y; + } + + @override + double computeMinIntrinsicWidth(double height) { + final double inputWidth = decoration.isEmpty || decoration.maintainHintSize + ? math.max(_minWidth(input, height), _minWidth(hint, height)) + : _minWidth(input, height); + final double contentWidth = decoration.maintainLabelSize + ? math.max(inputWidth, _minWidth(label, height)) + : inputWidth; + return _minWidth(icon, height) + + (prefixIcon != null ? prefixToInputGap : contentPadding.start + decoration.inputGap) + + _minWidth(prefixIcon, height) + + _minWidth(prefix, height) + + contentWidth + + _minWidth(suffix, height) + + _minWidth(suffixIcon, height) + + (suffixIcon != null ? inputToSuffixGap : contentPadding.end + decoration.inputGap); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final double inputWidth = decoration.isEmpty || decoration.maintainHintSize + ? math.max(_maxWidth(input, height), _maxWidth(hint, height)) + : _maxWidth(input, height); + final double contentWidth = decoration.maintainLabelSize + ? math.max(inputWidth, _maxWidth(label, height)) + : inputWidth; + return _maxWidth(icon, height) + + (prefixIcon != null ? prefixToInputGap : contentPadding.start + decoration.inputGap) + + _maxWidth(prefixIcon, height) + + _maxWidth(prefix, height) + + contentWidth + + _maxWidth(suffix, height) + + _maxWidth(suffixIcon, height) + + (suffixIcon != null ? inputToSuffixGap : contentPadding.end + decoration.inputGap); + } + + double _lineHeight(double width, List<RenderBox?> boxes) { + var height = 0.0; + for (final box in boxes) { + if (box == null) { + continue; + } + height = math.max(_minHeight(box, width), height); + } + return height; + // TODO(hansmuller): this should compute the overall line height for the + // boxes when they've been baseline-aligned. + // See https://github.com/flutter/flutter/issues/13715 + } + + @override + double computeMinIntrinsicHeight(double width) { + final double iconHeight = _minHeight(icon, width); + final double iconWidth = _minWidth(icon, iconHeight); + + width = math.max(width - iconWidth, 0.0); + + final double prefixIconHeight = _minHeight(prefixIcon, width); + final double prefixIconWidth = _minWidth(prefixIcon, prefixIconHeight); + + final double suffixIconHeight = _minHeight(suffixIcon, width); + final double suffixIconWidth = _minWidth(suffixIcon, suffixIconHeight); + + width = math.max(width - contentPadding.horizontal - decoration.inputGap * 2, 0.0); + + // TODO(LongCatIsLooong): use _computeSubtextSizes for subtext intrinsic sizes. + // See https://github.com/flutter/flutter/issues/13715. + final double counterHeight = _minHeight(counter, width); + final double counterWidth = _minWidth(counter, counterHeight); + + // Only add padding when counter is present (maxLength is used). + final double counterPadding = counter != null ? _kSubtextCounterPadding : 0.0; + final double helperErrorAvailableWidth = math.max(width - counterWidth - counterPadding, 0.0); + final double helperErrorHeight = _minHeight(helperError, helperErrorAvailableWidth); + double subtextHeight = math.max(counterHeight, helperErrorHeight); + if (subtextHeight > 0.0) { + subtextHeight += subtextGap; + } + + final double prefixHeight = _minHeight(prefix, width); + final double prefixWidth = _minWidth(prefix, prefixHeight); + + final double suffixHeight = _minHeight(suffix, width); + final double suffixWidth = _minWidth(suffix, suffixHeight); + + final double availableInputWidth = math.max( + width - prefixWidth - suffixWidth - prefixIconWidth - suffixIconWidth, + 0.0, + ); + final double inputHeight = _lineHeight(availableInputWidth, <RenderBox?>[ + input, + if (decoration.isEmpty) hint, + ]); + final double inputMaxHeight = <double>[ + inputHeight, + prefixHeight, + suffixHeight, + ].reduce(math.max); + + final double contentHeight = + contentPadding.top + + (label == null ? 0.0 : decoration.floatingLabelHeight) + + inputMaxHeight + + contentPadding.bottom + + _densityOffset.dy; + final double containerHeight = <double>[ + iconHeight, + contentHeight, + prefixIconHeight, + suffixIconHeight, + ].reduce(math.max); + final double minContainerHeight = decoration.isDense! || expands + ? 0.0 + : kMinInteractiveDimension; + + return math.max(containerHeight, minContainerHeight) + subtextHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return getMinIntrinsicHeight(width); + } + + @override + double computeDistanceToActualBaseline(TextBaseline baseline) { + final RenderBox? input = this.input; + if (input == null) { + return 0.0; + } + return _boxParentData(input).offset.dy + + (input.getDistanceToActualBaseline(baseline) ?? input.size.height); + } + + // Records where the label was painted. + Matrix4? _labelTransform; + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? input = this.input; + if (input == null) { + return 0.0; + } + final _RenderDecorationLayout layout = _layout( + constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + getBaseline: _getDryBaseline, + ); + return switch (baseline) { + TextBaseline.alphabetic => 0.0, + TextBaseline.ideographic => + (input.getDryBaseline(layout.inputConstraints, TextBaseline.ideographic) ?? + input.getDryLayout(layout.inputConstraints).height) - + (input.getDryBaseline(layout.inputConstraints, TextBaseline.alphabetic) ?? + input.getDryLayout(layout.inputConstraints).height), + } + + layout.baseline; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final _RenderDecorationLayout layout = _layout( + constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + getBaseline: _getDryBaseline, + ); + return constraints.constrain(layout.size); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + _labelTransform = null; + final _RenderDecorationLayout layout = _layout( + constraints, + layoutChild: ChildLayoutHelper.layoutChild, + getBaseline: _getBaseline, + ); + size = constraints.constrain(layout.size); + assert(size.width == constraints.constrainWidth(layout.size.width)); + assert(size.height == constraints.constrainHeight(layout.size.height)); + + final double overallWidth = layout.size.width; + + final RenderBox? container = this.container; + if (container != null) { + final containerConstraints = BoxConstraints.tightFor( + height: layout.containerHeight, + width: overallWidth - _boxSize(icon).width, + ); + container.layout(containerConstraints, parentUsesSize: true); + final double x = switch (textDirection) { + TextDirection.rtl => 0.0, + TextDirection.ltr => _boxSize(icon).width, + }; + _boxParentData(container).offset = Offset(x, 0.0); + } + + final double height = layout.containerHeight; + double centerLayout(RenderBox box, double x) { + _boxParentData(box).offset = Offset(x, (height - box.size.height) / 2.0); + return box.size.width; + } + + if (icon != null) { + final double x = switch (textDirection) { + TextDirection.rtl => overallWidth - icon!.size.width, + TextDirection.ltr => 0.0, + }; + centerLayout(icon!, x); + } + + final double subtextBaseline = (layout.subtextSize?.ascent ?? 0.0) + layout.containerHeight; + final RenderBox? counter = this.counter; + final double helperErrorBaseline = helperError.getDistanceToBaseline(TextBaseline.alphabetic)!; + final double counterBaseline = counter?.getDistanceToBaseline(TextBaseline.alphabetic)! ?? 0.0; + + double start, end; + switch (textDirection) { + case TextDirection.ltr: + start = contentPadding.start + _boxSize(icon).width; + end = overallWidth - contentPadding.end; + _boxParentData(helperError).offset = Offset( + start + decoration.inputGap, + subtextBaseline - helperErrorBaseline, + ); + if (counter != null) { + _boxParentData(counter).offset = Offset( + end - counter.size.width - decoration.inputGap, + subtextBaseline - counterBaseline, + ); + } + case TextDirection.rtl: + start = overallWidth - contentPadding.start - _boxSize(icon).width; + end = contentPadding.end; + _boxParentData(helperError).offset = Offset( + start - helperError.size.width - decoration.inputGap, + subtextBaseline - helperErrorBaseline, + ); + if (counter != null) { + _boxParentData(counter).offset = Offset( + end + decoration.inputGap, + subtextBaseline - counterBaseline, + ); + } + } + + final double baseline = layout.baseline; + double baselineLayout(RenderBox box, double x) { + _boxParentData(box).offset = Offset( + x, + baseline - box.getDistanceToBaseline(TextBaseline.alphabetic)!, + ); + return box.size.width; + } + + switch (textDirection) { + case TextDirection.rtl: + { + if (prefixIcon != null) { + start += contentPadding.start; + start -= centerLayout(prefixIcon!, start - prefixIcon!.size.width); + start -= prefixToInputGap; + } else { + start -= decoration.inputGap; + } + if (label != null) { + if (decoration.alignLabelWithHint) { + baselineLayout(label!, start - label!.size.width); + } else { + centerLayout(label!, start - label!.size.width); + } + } + if (prefix != null) { + start -= baselineLayout(prefix!, start - prefix!.size.width); + } + if (input != null) { + baselineLayout(input!, start - input!.size.width); + } + if (hint != null) { + baselineLayout(hint!, start - hint!.size.width); + } + if (suffixIcon != null) { + end -= contentPadding.end; + end += centerLayout(suffixIcon!, end); + end += inputToSuffixGap; + } else { + end += decoration.inputGap; + } + if (suffix != null) { + end += baselineLayout(suffix!, end); + } + break; + } + case TextDirection.ltr: + { + if (prefixIcon != null) { + start -= contentPadding.start; + start += centerLayout(prefixIcon!, start); + start += prefixToInputGap; + } else { + start += decoration.inputGap; + } + if (label != null) { + if (decoration.alignLabelWithHint) { + baselineLayout(label!, start); + } else { + centerLayout(label!, start); + } + } + if (prefix != null) { + start += baselineLayout(prefix!, start); + } + if (input != null) { + baselineLayout(input!, start); + } + if (hint != null) { + baselineLayout(hint!, start); + } + if (suffixIcon != null) { + end += contentPadding.end; + end -= centerLayout(suffixIcon!, end - suffixIcon!.size.width); + end -= inputToSuffixGap; + } else { + end -= decoration.inputGap; + } + if (suffix != null) { + end -= baselineLayout(suffix!, end - suffix!.size.width); + } + break; + } + } + + if (label != null) { + final double labelX = _boxParentData(label!).offset.dx; + // +1 shifts the range of x from (-1.0, 1.0) to (0.0, 2.0). + final double floatAlign = decoration.floatingLabelAlignment._x + 1; + final double floatWidth = _boxSize(label).width * _kFinalLabelScale; + // When floating label is centered, its x is relative to + // _BorderContainer's x and is independent of label's x. + switch (textDirection) { + case TextDirection.rtl: + var offsetToPrefixIcon = 0.0; + if (prefixIcon != null && !decoration.alignLabelWithHint) { + offsetToPrefixIcon = material3 ? _boxSize(prefixIcon).width - contentPadding.end : 0; + } + decoration.borderGap.start = lerpDouble( + labelX + _boxSize(label).width + offsetToPrefixIcon, + _boxSize(container).width / 2.0 + floatWidth / 2.0, + floatAlign, + ); + + case TextDirection.ltr: + // The value of _InputBorderGap.start is relative to the origin of the + // _BorderContainer which is inset by the icon's width. Although, when + // floating label is centered, it's already relative to _BorderContainer. + var offsetToPrefixIcon = 0.0; + if (prefixIcon != null && !decoration.alignLabelWithHint) { + offsetToPrefixIcon = material3 + ? (-_boxSize(prefixIcon).width + contentPadding.start) + : 0; + } + decoration.borderGap.start = lerpDouble( + labelX - _boxSize(icon).width + offsetToPrefixIcon, + _boxSize(container).width / 2.0 - floatWidth / 2.0, + floatAlign, + ); + } + decoration.borderGap.extent = label!.size.width * _kFinalLabelScale; + } else { + decoration.borderGap.start = null; + decoration.borderGap.extent = 0.0; + } + } + + void _paintLabel(PaintingContext context, Offset offset) { + context.paintChild(label!, offset); + } + + @override + void paint(PaintingContext context, Offset offset) { + void doPaint(RenderBox? child) { + if (child != null) { + context.paintChild(child, _boxParentData(child).offset + offset); + } + } + + doPaint(container); + + if (label != null) { + final Offset labelOffset = _boxParentData(label!).offset; + final double labelHeight = _boxSize(label).height; + final double labelWidth = _boxSize(label).width; + // +1 shifts the range of x from (-1.0, 1.0) to (0.0, 2.0). + final double floatAlign = decoration.floatingLabelAlignment._x + 1; + final double floatWidth = labelWidth * _kFinalLabelScale; + final BorderSide borderSide = decoration.border.borderSide; + final double t = decoration.floatingLabelProgress; + // The center of the outline border label ends up a little below the + // center of the top border line. + final bool isOutlineBorder = decoration.border.isOutline; + // Temporary opt-in fix for https://github.com/flutter/flutter/issues/54028 + // Center the scaled label relative to the border. + final double outlinedFloatingY = + (-labelHeight * _kFinalLabelScale) / 2.0 - borderSide.strokeOffset / 2.0; + final double floatingY = isOutlineBorder + ? outlinedFloatingY + : contentPadding.top + _densityOffset.dy / 2; + final double scale = lerpDouble(1.0, _kFinalLabelScale, t)!; + final double centeredFloatX = + _boxParentData(container!).offset.dx + _boxSize(container).width / 2.0 - floatWidth / 2.0; + final double startX; + double floatStartX; + switch (textDirection) { + case TextDirection.rtl: // origin is on the right + startX = labelOffset.dx + labelWidth * (1.0 - scale); + floatStartX = startX; + if (prefixIcon != null && !decoration.alignLabelWithHint && isOutlineBorder) { + floatStartX += material3 ? _boxSize(prefixIcon).width - contentPadding.end : 0.0; + } + case TextDirection.ltr: // origin on the left + startX = labelOffset.dx; + floatStartX = startX; + if (prefixIcon != null && !decoration.alignLabelWithHint && isOutlineBorder) { + floatStartX += material3 ? -_boxSize(prefixIcon).width + contentPadding.start : 0.0; + } + } + final double floatEndX = lerpDouble(floatStartX, centeredFloatX, floatAlign)!; + final double dx = lerpDouble(startX, floatEndX, t)!; + final double dy = lerpDouble(0.0, floatingY - labelOffset.dy, t)!; + _labelTransform = Matrix4.identity() + ..translateByDouble(dx, labelOffset.dy + dy, 0, 1) + ..scaleByDouble(scale, scale, scale, 1); + layer = context.pushTransform( + needsCompositing, + offset, + _labelTransform!, + _paintLabel, + oldLayer: layer as TransformLayer?, + ); + } else { + layer = null; + } + + doPaint(icon); + doPaint(prefix); + doPaint(suffix); + doPaint(prefixIcon); + doPaint(suffixIcon); + if (decoration.isEmpty) { + doPaint(hint); + } + doPaint(input); + doPaint(helperError); + doPaint(counter); + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + if (child == label && _labelTransform != null) { + final Offset labelOffset = _boxParentData(label!).offset; + transform + ..multiply(_labelTransform!) + ..translateByDouble(-labelOffset.dx, -labelOffset.dy, 0, 1); + } + super.applyPaintTransform(child, transform); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + for (final RenderBox child in children) { + // The label must be handled specially since we've transformed it. + final Offset offset = _boxParentData(child).offset; + final bool isHit = result.addWithPaintOffset( + offset: offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } + + ChildSemanticsConfigurationsResult _childSemanticsConfigurationDelegate( + List<SemanticsConfiguration> childConfigs, + ) { + final builder = ChildSemanticsConfigurationsResultBuilder(); + + final mergeGroups = <SemanticsTag, List<SemanticsConfiguration>>{}; + final tags = <SemanticsTag>{ + _InputDecoratorState._kPrefixSemanticsTag, + _InputDecoratorState._kPrefixIconSemanticsTag, + _InputDecoratorState._kSuffixSemanticsTag, + _InputDecoratorState._kSuffixIconSemanticsTag, + }; + + for (final childConfig in childConfigs) { + final SemanticsTag? tag = tags.firstWhereOrNull( + (SemanticsTag tag) => childConfig.tagsChildrenWith(tag), + ); + if (tag != null) { + mergeGroups.putIfAbsent(tag, () => <SemanticsConfiguration>[]).add(childConfig); + } else { + builder.markAsMergeUp(childConfig); + } + } + + mergeGroups.values.forEach(builder.markAsSiblingMergeGroup); + return builder.build(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + config.childConfigurationsDelegate = _childSemanticsConfigurationDelegate; + } +} + +class _Decorator extends SlottedMultiChildRenderObjectWidget<_DecorationSlot, RenderBox> { + const _Decorator({ + required this.textAlignVertical, + required this.decoration, + required this.textDirection, + required this.textBaseline, + required this.isFocused, + required this.expands, + }); + + final _Decoration decoration; + final TextDirection textDirection; + final TextBaseline textBaseline; + final TextAlignVertical? textAlignVertical; + final bool isFocused; + final bool expands; + + @override + Iterable<_DecorationSlot> get slots => _DecorationSlot.values; + + @override + Widget? childForSlot(_DecorationSlot slot) { + return switch (slot) { + _DecorationSlot.icon => decoration.icon, + _DecorationSlot.input => decoration.input, + _DecorationSlot.label => decoration.label, + _DecorationSlot.hint => decoration.hint, + _DecorationSlot.prefix => decoration.prefix, + _DecorationSlot.suffix => decoration.suffix, + _DecorationSlot.prefixIcon => decoration.prefixIcon, + _DecorationSlot.suffixIcon => decoration.suffixIcon, + _DecorationSlot.helperError => decoration.helperError, + _DecorationSlot.counter => decoration.counter, + _DecorationSlot.container => decoration.container, + }; + } + + @override + _RenderDecoration createRenderObject(BuildContext context) { + return _RenderDecoration( + decoration: decoration, + textDirection: textDirection, + textBaseline: textBaseline, + textAlignVertical: textAlignVertical, + isFocused: isFocused, + expands: expands, + material3: Theme.of(context).useMaterial3, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderDecoration renderObject) { + renderObject + ..decoration = decoration + ..expands = expands + ..isFocused = isFocused + ..textAlignVertical = textAlignVertical + ..textBaseline = textBaseline + ..textDirection = textDirection; + } +} + +class _AffixText extends StatelessWidget { + const _AffixText({ + required this.labelIsFloating, + this.text, + this.style, + this.child, + this.semanticsSortKey, + required this.semanticsTag, + }); + + final bool labelIsFloating; + final String? text; + final TextStyle? style; + final Widget? child; + final SemanticsSortKey? semanticsSortKey; + final SemanticsTag semanticsTag; + + @override + Widget build(BuildContext context) { + return DefaultTextStyle.merge( + style: style, + child: IgnorePointer( + ignoring: !labelIsFloating, + child: AnimatedOpacity( + duration: _kTransitionDuration, + curve: _kTransitionCurve, + opacity: labelIsFloating ? 1.0 : 0.0, + child: Semantics( + sortKey: semanticsSortKey, + tagForChildren: semanticsTag, + child: child ?? (text == null ? null : Text(text!, style: style)), + ), + ), + ), + ); + } +} + +/// Defines the appearance of a Material Design text field. +/// +/// [InputDecorator] displays the visual elements of a Material Design text +/// field around its input [child]. The visual elements themselves are defined +/// by an [InputDecoration] object and their layout and appearance depend +/// on the `baseStyle`, `textAlign`, `isFocused`, and `isEmpty` parameters. +/// +/// [TextField] uses this widget to decorate its [EditableText] child. +/// +/// [InputDecorator] can be used to create widgets that look and behave like a +/// [TextField] but support other kinds of input. +/// +/// Requires one of its ancestors to be a [Material] widget. The [child] widget, +/// as well as the decorative widgets specified in [decoration], must have +/// non-negative baselines. +/// +/// See also: +/// +/// * [TextField], which uses an [InputDecorator] to display a border, +/// labels, and icons, around its [EditableText] child. +/// * [Decoration] and [DecoratedBox], for drawing arbitrary decorations +/// around other widgets. +class InputDecorator extends StatefulWidget { + /// Creates a widget that displays a border, labels, and icons, + /// for a [TextField]. + /// + /// The [isFocused], [isHovering], [expands], and [isEmpty] arguments must not + /// be null. + const InputDecorator({ + super.key, + required this.decoration, + this.baseStyle, + this.textAlign, + this.textAlignVertical, + this.isFocused = false, + this.isHovering = false, + this.expands = false, + this.isEmpty = false, + this.child, + }); + + /// The text and styles to use when decorating the child. + /// + /// Null [InputDecoration] properties are initialized with the corresponding + /// values from the ambient [InputDecorationThemeData]. + final InputDecoration decoration; + + /// The style on which to base the label, hint, counter, and error styles + /// if the [decoration] does not provide explicit styles. + /// + /// If null, [baseStyle] defaults to the `titleMedium` style from the + /// current [Theme], see [ThemeData.textTheme]. + /// + /// The [TextStyle.textBaseline] of the [baseStyle] is used to determine + /// the baseline used for text alignment. + final TextStyle? baseStyle; + + /// How the text in the decoration should be aligned horizontally. + final TextAlign? textAlign; + + /// {@template flutter.material.InputDecorator.textAlignVertical} + /// How the text should be aligned vertically. + /// + /// Determines the alignment of the baseline within the available space of + /// the input (typically a TextField). For example, TextAlignVertical.top will + /// place the baseline such that the text, and any attached decoration like + /// prefix and suffix, is as close to the top of the input as possible without + /// overflowing. The heights of the prefix and suffix are similarly included + /// for other alignment values. If the height is greater than the height + /// available, then the prefix and suffix will be allowed to overflow first + /// before the text scrolls. + /// {@endtemplate} + final TextAlignVertical? textAlignVertical; + + /// Whether the input field has focus. + /// + /// Determines the position of the label text and the color and weight of the + /// border. + /// + /// Defaults to false. + /// + /// See also: + /// + /// * [InputDecoration.hoverColor], which is also blended into the focus + /// color and fill color when the [isHovering] is true to produce the final + /// color. + final bool isFocused; + + /// Whether the input field is being hovered over by a mouse pointer. + /// + /// Determines the container fill color, which is a blend of + /// [InputDecoration.hoverColor] with [InputDecoration.fillColor] when + /// true, and [InputDecoration.fillColor] when not. + /// + /// Defaults to false. + final bool isHovering; + + /// If true, the height of the input field will be as large as possible. + /// + /// If wrapped in a widget that constrains its child's height, like Expanded + /// or SizedBox, the input field will only be affected if [expands] is set to + /// true. + /// + /// See [TextField.minLines] and [TextField.maxLines] for related ways to + /// affect the height of an input. When [expands] is true, both must be null + /// in order to avoid ambiguity in determining the height. + /// + /// Defaults to false. + final bool expands; + + /// Whether the input field is empty. + /// + /// Determines the position of the label text and whether to display the hint + /// text. + /// + /// Defaults to false. + final bool isEmpty; + + /// The widget below this widget in the tree. + /// + /// Typically an [EditableText], [DropdownButton], or [InkWell]. + final Widget? child; + + /// Whether the label needs to get out of the way of the input, either by + /// floating or disappearing. + /// + /// Will withdraw when not empty or when focused while enabled. + bool get _labelShouldWithdraw => !isEmpty || (isFocused && decoration.enabled); + + @override + State<InputDecorator> createState() => _InputDecoratorState(); + + /// The RenderBox that defines this decorator's "container". That's the + /// area which is filled if [InputDecoration.filled] is true. It's the area + /// adjacent to [InputDecoration.icon] and above the widgets that contain + /// [InputDecoration.helperText], [InputDecoration.errorText], and + /// [InputDecoration.counterText]. + /// + /// [TextField] renders ink splashes within the container. + static RenderBox? containerOf(BuildContext context) { + final _RenderDecoration? result = context.findAncestorRenderObjectOfType<_RenderDecoration>(); + return result?.container; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<InputDecoration>('decoration', decoration)); + properties.add(DiagnosticsProperty<TextStyle>('baseStyle', baseStyle, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('isFocused', isFocused)); + properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false)); + properties.add(DiagnosticsProperty<bool>('isEmpty', isEmpty)); + } +} + +class _InputDecoratorState extends State<InputDecorator> with TickerProviderStateMixin { + late final AnimationController _floatingLabelController; + late final CurvedAnimation _floatingLabelAnimation; + late final AnimationController _shakingLabelController; + final _InputBorderGap _borderGap = _InputBorderGap(); + // Provide a unique name to avoid mixing up sort order with sibling input + // decorators. + late final OrdinalSortKey _prefixSemanticsSortOrder = OrdinalSortKey( + 0, + name: hashCode.toString(), + ); + late final OrdinalSortKey _inputSemanticsSortOrder = OrdinalSortKey(1, name: hashCode.toString()); + late final OrdinalSortKey _suffixSemanticsSortOrder = OrdinalSortKey( + 2, + name: hashCode.toString(), + ); + static const SemanticsTag _kPrefixSemanticsTag = SemanticsTag('_InputDecoratorState.prefix'); + static const SemanticsTag _kPrefixIconSemanticsTag = SemanticsTag( + '_InputDecoratorState.prefixIcon', + ); + static const SemanticsTag _kSuffixSemanticsTag = SemanticsTag('_InputDecoratorState.suffix'); + static const SemanticsTag _kSuffixIconSemanticsTag = SemanticsTag( + '_InputDecoratorState.suffixIcon', + ); + + @override + void initState() { + super.initState(); + + _floatingLabelController = AnimationController(duration: _kTransitionDuration, vsync: this); + _floatingLabelController.addListener(_handleChange); + _floatingLabelAnimation = CurvedAnimation( + parent: _floatingLabelController, + curve: _kTransitionCurve, + reverseCurve: _kTransitionCurve.flipped, + ); + + _shakingLabelController = AnimationController(duration: _kTransitionDuration, vsync: this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _effectiveDecoration = null; + + final bool labelIsInitiallyFloating = + decoration.floatingLabelBehavior != FloatingLabelBehavior.never && labelShouldWithdraw; + _floatingLabelController.value = labelIsInitiallyFloating ? 1.0 : 0.0; + } + + @override + void dispose() { + _floatingLabelController.dispose(); + _floatingLabelAnimation.dispose(); + _shakingLabelController.dispose(); + _borderGap.dispose(); + _curvedAnimation?.dispose(); + super.dispose(); + } + + void _handleChange() { + setState(() { + // The _floatingLabelController's value has changed. + }); + } + + InputDecoration? _effectiveDecoration; + InputDecoration get decoration => + _effectiveDecoration ??= widget.decoration.applyDefaults(InputDecorationTheme.of(context)); + + TextAlign? get textAlign => widget.textAlign; + bool get isFocused => widget.isFocused; + bool get _hasError => decoration.errorText != null || decoration.error != null; + bool get isHovering => widget.isHovering && decoration.enabled; + bool get isEmpty => widget.isEmpty; + bool get _floatingLabelEnabled { + return decoration.floatingLabelBehavior != FloatingLabelBehavior.never; + } + + bool get labelShouldWithdraw => + widget._labelShouldWithdraw || + decoration.floatingLabelBehavior == FloatingLabelBehavior.always; + + @override + void didUpdateWidget(InputDecorator old) { + super.didUpdateWidget(old); + if (widget.decoration != old.decoration) { + _effectiveDecoration = null; + } + + final floatBehaviorChanged = + widget.decoration.floatingLabelBehavior != old.decoration.floatingLabelBehavior; + + if (widget._labelShouldWithdraw != old._labelShouldWithdraw || floatBehaviorChanged) { + if (_floatingLabelEnabled && labelShouldWithdraw) { + _floatingLabelController.forward(); + } else { + _floatingLabelController.reverse(); + } + } + + final String? errorText = decoration.errorText; + final String? oldErrorText = old.decoration.errorText; + + if (_floatingLabelController.isCompleted && errorText != null && errorText != oldErrorText) { + _shakingLabelController + ..value = 0.0 + ..forward(); + } + } + + Color _getDefaultM2BorderColor(ThemeData themeData) { + if (!decoration.enabled && !isFocused) { + return ((decoration.filled ?? false) && !(decoration.border?.isOutline ?? false)) + ? Colors.transparent + : themeData.disabledColor; + } + if (_hasError) { + return themeData.colorScheme.error; + } + if (isFocused) { + return themeData.colorScheme.primary; + } + if (decoration.filled!) { + return themeData.hintColor; + } + final Color enabledColor = themeData.colorScheme.onSurface.withOpacity(0.38); + if (isHovering) { + final Color hoverColor = decoration.hoverColor ?? themeData.hoverColor; + return Color.alphaBlend(hoverColor.withOpacity(0.12), enabledColor); + } + return enabledColor; + } + + Color _getFillColor(ThemeData themeData, InputDecorationThemeData defaults) { + if (decoration.filled != true) { + // filled == null same as filled == false + return Colors.transparent; + } + if (decoration.fillColor != null) { + return WidgetStateProperty.resolveAs(decoration.fillColor!, widgetState); + } + return WidgetStateProperty.resolveAs(defaults.fillColor!, widgetState); + } + + Color _getHoverColor(ThemeData themeData) { + if (decoration.filled == null || !decoration.filled! || !decoration.enabled) { + return Colors.transparent; + } + return decoration.hoverColor ?? themeData.hoverColor; + } + + Color _getIconColor(ThemeData themeData, InputDecorationThemeData defaults) { + return WidgetStateProperty.resolveAs(decoration.iconColor, widgetState) ?? + WidgetStateProperty.resolveAs(defaults.iconColor!, widgetState); + } + + Color _getPrefixIconColor( + IconButtonThemeData iconButtonTheme, + InputDecorationThemeData defaults, + ) { + return WidgetStateProperty.resolveAs(decoration.prefixIconColor, widgetState) ?? + iconButtonTheme.style?.foregroundColor?.resolve(widgetState) ?? + WidgetStateProperty.resolveAs(defaults.prefixIconColor!, widgetState); + } + + Color _getSuffixIconColor( + IconButtonThemeData iconButtonTheme, + InputDecorationThemeData defaults, + ) { + return WidgetStateProperty.resolveAs(decoration.suffixIconColor, widgetState) ?? + iconButtonTheme.style?.foregroundColor?.resolve(widgetState) ?? + WidgetStateProperty.resolveAs(defaults.suffixIconColor!, widgetState); + } + + // True if the label will be shown and the hint will not. + // If we're not focused, there's no value, labelText was provided, and + // floatingLabelBehavior isn't set to always, then the label appears where the + // hint would. + bool get _hasInlineLabel { + return !labelShouldWithdraw && (decoration.labelText != null || decoration.label != null); + } + + // If the label is a floating placeholder, it's always shown. + bool get _shouldShowLabel => _hasInlineLabel || _floatingLabelEnabled; + + // The base style for the inline label when they're displayed "inline", + // i.e. when they appear in place of the empty text field. + TextStyle _getInlineLabelStyle(ThemeData themeData, InputDecorationThemeData defaults) { + final TextStyle defaultStyle = WidgetStateProperty.resolveAs(defaults.labelStyle!, widgetState); + + final TextStyle? style = WidgetStateProperty.resolveAs(decoration.labelStyle, widgetState); + + return themeData.textTheme.titleMedium! + .merge(widget.baseStyle) + .merge(defaultStyle) + .merge(style) + .copyWith(height: 1); + } + + // The base style for the inline hint when they're displayed "inline", + // i.e. when they appear in place of the empty text field. + TextStyle _getInlineHintStyle(ThemeData themeData, InputDecorationThemeData defaults) { + final TextStyle defaultStyle = WidgetStateProperty.resolveAs(defaults.hintStyle!, widgetState); + + final TextStyle? style = WidgetStateProperty.resolveAs(decoration.hintStyle, widgetState); + + return (themeData.useMaterial3 + ? themeData.textTheme.bodyLarge! + : themeData.textTheme.titleMedium!) + .merge(widget.baseStyle) + .merge(defaultStyle) + .merge(style); + } + + TextStyle _getFloatingLabelStyle(ThemeData themeData, InputDecorationThemeData defaults) { + TextStyle defaultTextStyle = WidgetStateProperty.resolveAs( + defaults.floatingLabelStyle!, + widgetState, + ); + if (_hasError && decoration.errorStyle?.color != null) { + defaultTextStyle = defaultTextStyle.copyWith(color: decoration.errorStyle?.color); + } + defaultTextStyle = defaultTextStyle.merge( + decoration.floatingLabelStyle ?? decoration.labelStyle, + ); + + final TextStyle? style = WidgetStateProperty.resolveAs( + decoration.floatingLabelStyle, + widgetState, + ); + + return themeData.textTheme.titleMedium! + .merge(widget.baseStyle) + .merge(defaultTextStyle) + .merge(style) + .copyWith(height: 1); + } + + TextStyle _getHelperStyle(ThemeData themeData, InputDecorationThemeData defaults) { + return WidgetStateProperty.resolveAs( + defaults.helperStyle!, + widgetState, + ).merge(WidgetStateProperty.resolveAs(decoration.helperStyle, widgetState)); + } + + TextStyle _getErrorStyle(ThemeData themeData, InputDecorationThemeData defaults) { + return WidgetStateProperty.resolveAs( + defaults.errorStyle!, + widgetState, + ).merge(decoration.errorStyle); + } + + Set<WidgetState> get widgetState => <WidgetState>{ + if (!decoration.enabled) WidgetState.disabled, + if (isFocused) WidgetState.focused, + if (isHovering) WidgetState.hovered, + if (_hasError) WidgetState.error, + }; + + InputBorder _getDefaultBorder(ThemeData themeData, InputDecorationThemeData defaults) { + final InputBorder border = + WidgetStateProperty.resolveAs(decoration.border, widgetState) ?? + const UnderlineInputBorder(); + + if (decoration.border is WidgetStateProperty<InputBorder>) { + return border; + } + + if (border.borderSide == BorderSide.none) { + return border; + } + + if (themeData.useMaterial3) { + if (decoration.filled!) { + final InputDecorationThemeData decorationTheme = InputDecorationTheme.of(context); + return border.copyWith( + borderSide: WidgetStateProperty.resolveAs( + decorationTheme.activeIndicatorBorder ?? defaults.activeIndicatorBorder, + widgetState, + ), + ); + } else { + return border.copyWith( + borderSide: WidgetStateProperty.resolveAs(defaults.outlineBorder, widgetState), + ); + } + } else { + return border.copyWith( + borderSide: BorderSide( + color: _getDefaultM2BorderColor(themeData), + width: + ((decoration.isCollapsed!) || + decoration.border == InputBorder.none || + !decoration.enabled) + ? 0.0 + : isFocused + ? 2.0 + : 1.0, + ), + ); + } + } + + CurvedAnimation? _curvedAnimation; + + FadeTransition _buildTransition(Widget child, Animation<double> animation) { + if (_curvedAnimation?.parent != animation) { + _curvedAnimation?.dispose(); + _curvedAnimation = CurvedAnimation(parent: animation, curve: _kTransitionCurve); + } + + return FadeTransition(opacity: _curvedAnimation!, child: child); + } + + static Widget _topStartLayout(Widget? currentChild, List<Widget> previousChildren) { + return Stack(children: <Widget>[...previousChildren, ?currentChild]); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final VisualDensity visualDensity = decoration.visualDensity ?? themeData.visualDensity; + final bool useMaterial3 = Theme.of(context).useMaterial3; + final InputDecorationThemeData defaults = useMaterial3 + ? _InputDecoratorDefaultsM3(context) + : _InputDecoratorDefaultsM2(context); + final IconButtonThemeData iconButtonTheme = IconButtonTheme.of(context); + + final TextStyle labelStyle = _getInlineLabelStyle(themeData, defaults); + final TextBaseline textBaseline = labelStyle.textBaseline!; + + final TextStyle hintStyle = _getInlineHintStyle(themeData, defaults); + final String? hintText = decoration.hintText; + final bool maintainHintSize = decoration.maintainHintSize; + Widget? hint; + if (decoration.hint != null || hintText != null) { + final Widget hintWidget = + decoration.hint ?? + Text( + hintText!, + style: hintStyle, + textDirection: decoration.hintTextDirection, + overflow: + hintStyle.overflow ?? + (decoration.hintMaxLines == null ? null : TextOverflow.ellipsis), + textAlign: textAlign, + maxLines: decoration.hintMaxLines, + ); + final bool showHint = isEmpty && !_hasInlineLabel; + hint = maintainHintSize + ? AnimatedOpacity( + opacity: showHint ? 1.0 : 0.0, + duration: decoration.hintFadeDuration ?? _kHintFadeTransitionDuration, + curve: _kTransitionCurve, + child: hintWidget, + ) + : AnimatedSwitcher( + duration: decoration.hintFadeDuration ?? _kHintFadeTransitionDuration, + transitionBuilder: _buildTransition, + layoutBuilder: _topStartLayout, + child: showHint ? hintWidget : const SizedBox.shrink(), + ); + } + + InputBorder? border; + if (!decoration.enabled) { + border = _hasError ? decoration.errorBorder : decoration.disabledBorder; + } else if (isFocused) { + border = _hasError ? decoration.focusedErrorBorder : decoration.focusedBorder; + } else { + border = _hasError ? decoration.errorBorder : decoration.enabledBorder; + } + border ??= _getDefaultBorder(themeData, defaults); + + final Widget container = _BorderContainer( + border: border, + gap: _borderGap, + gapAnimation: _floatingLabelAnimation, + fillColor: _getFillColor(themeData, defaults), + hoverColor: _getHoverColor(themeData), + isHovering: isHovering, + ); + + Widget? label; + if ((decoration.labelText ?? decoration.label) != null) { + label = MatrixTransition( + animation: _shakingLabelController, + onTransform: (double value) { + final double shakeOffset = switch (value) { + <= 0.25 => -value, + < 0.75 => value - 0.5, + _ => (1.0 - value) * 4.0, + }; + // Shakes the floating label to the left and right + // when the errorText first appears. + return Matrix4.translationValues(shakeOffset * 4.0, 0.0, 0.0); + }, + child: AnimatedOpacity( + duration: _kTransitionDuration, + curve: _kTransitionCurve, + opacity: _shouldShowLabel ? 1.0 : 0.0, + child: AnimatedDefaultTextStyle( + duration: _kTransitionDuration, + curve: _kTransitionCurve, + style: labelShouldWithdraw ? _getFloatingLabelStyle(themeData, defaults) : labelStyle, + child: + decoration.label ?? + Text(decoration.labelText!, overflow: TextOverflow.ellipsis, textAlign: textAlign), + ), + ), + ); + } + + final bool hasPrefix = decoration.prefix != null || decoration.prefixText != null; + final bool hasSuffix = decoration.suffix != null || decoration.suffixText != null; + + Widget? input = widget.child; + // If at least two out of the three are visible, it needs semantics sort + // order. + final bool needsSemanticsSortOrder = + labelShouldWithdraw && + (input != null ? (hasPrefix || hasSuffix) : (hasPrefix && hasSuffix)); + + final Widget? prefix = hasPrefix + ? _AffixText( + labelIsFloating: labelShouldWithdraw, + text: decoration.prefixText, + style: WidgetStateProperty.resolveAs(decoration.prefixStyle, widgetState) ?? hintStyle, + semanticsSortKey: needsSemanticsSortOrder ? _prefixSemanticsSortOrder : null, + semanticsTag: _kPrefixSemanticsTag, + child: decoration.prefix, + ) + : null; + + final Widget? suffix = hasSuffix + ? _AffixText( + labelIsFloating: labelShouldWithdraw, + text: decoration.suffixText, + style: WidgetStateProperty.resolveAs(decoration.suffixStyle, widgetState) ?? hintStyle, + semanticsSortKey: needsSemanticsSortOrder ? _suffixSemanticsSortOrder : null, + semanticsTag: _kSuffixSemanticsTag, + child: decoration.suffix, + ) + : null; + + if (input != null && needsSemanticsSortOrder) { + input = Semantics(sortKey: _inputSemanticsSortOrder, child: input); + } + + final bool decorationIsDense = decoration.isDense ?? false; + final iconSize = decorationIsDense ? 18.0 : 24.0; + + final Widget? icon = decoration.icon == null + ? null + : MouseRegion( + cursor: SystemMouseCursors.basic, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 16.0), + child: IconTheme.merge( + data: IconThemeData(color: _getIconColor(themeData, defaults), size: iconSize), + child: decoration.icon!, + ), + ), + ); + + final Widget? prefixIcon = decoration.prefixIcon == null + ? null + : Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: MouseRegion( + cursor: SystemMouseCursors.basic, + child: ConstrainedBox( + constraints: + decoration.prefixIconConstraints ?? + visualDensity.effectiveConstraints( + const BoxConstraints( + minWidth: kMinInteractiveDimension, + minHeight: kMinInteractiveDimension, + ), + ), + child: IconTheme.merge( + data: IconThemeData( + color: _getPrefixIconColor(iconButtonTheme, defaults), + size: iconSize, + ), + child: IconButtonTheme( + data: IconButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll<Color>( + _getPrefixIconColor(iconButtonTheme, defaults), + ), + iconSize: WidgetStatePropertyAll<double>(iconSize), + ).merge(iconButtonTheme.style), + ), + child: Semantics( + tagForChildren: _kPrefixIconSemanticsTag, + child: decoration.prefixIcon, + ), + ), + ), + ), + ), + ); + + final Widget? suffixIcon = decoration.suffixIcon == null + ? null + : Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: MouseRegion( + cursor: SystemMouseCursors.basic, + child: ConstrainedBox( + constraints: + decoration.suffixIconConstraints ?? + visualDensity.effectiveConstraints( + const BoxConstraints( + minWidth: kMinInteractiveDimension, + minHeight: kMinInteractiveDimension, + ), + ), + child: IconTheme.merge( + data: IconThemeData( + color: _getSuffixIconColor(iconButtonTheme, defaults), + size: iconSize, + ), + child: IconButtonTheme( + data: IconButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll<Color>( + _getSuffixIconColor(iconButtonTheme, defaults), + ), + iconSize: WidgetStatePropertyAll<double>(iconSize), + ).merge(iconButtonTheme.style), + ), + child: Semantics( + tagForChildren: _kSuffixIconSemanticsTag, + child: decoration.suffixIcon, + ), + ), + ), + ), + ), + ); + + final Widget helperError = _HelperError( + textAlign: textAlign, + helper: decoration.helper, + helperText: decoration.helperText, + helperStyle: _getHelperStyle(themeData, defaults), + helperMaxLines: decoration.helperMaxLines, + error: decoration.error, + errorText: decoration.errorText, + errorStyle: _getErrorStyle(themeData, defaults), + errorMaxLines: decoration.errorMaxLines, + ); + + Widget? counter; + if (decoration.counter != null) { + counter = decoration.counter; + } else if (decoration.counterText != null && decoration.counterText != '') { + counter = Semantics( + container: true, + liveRegion: isFocused, + child: Text( + decoration.counterText!, + style: _getHelperStyle( + themeData, + defaults, + ).merge(WidgetStateProperty.resolveAs(decoration.counterStyle, widgetState)), + overflow: TextOverflow.ellipsis, + semanticsLabel: decoration.semanticCounterText, + ), + ); + } + + // The _Decoration widget and _RenderDecoration assume that contentPadding + // has been resolved to EdgeInsets. + final TextDirection textDirection = Directionality.of(context); + final bool flipHorizontal = switch (textDirection) { + TextDirection.ltr => false, + TextDirection.rtl => true, + }; + final EdgeInsets? resolvedPadding = decoration.contentPadding?.resolve(textDirection); + final EdgeInsetsDirectional? decorationContentPadding = resolvedPadding == null + ? null + : EdgeInsetsDirectional.fromSTEB( + flipHorizontal ? resolvedPadding.right : resolvedPadding.left, + resolvedPadding.top, + flipHorizontal ? resolvedPadding.left : resolvedPadding.right, + resolvedPadding.bottom, + ); + + final EdgeInsetsDirectional contentPadding; + final double floatingLabelHeight; + + if (decoration.isCollapsed!) { + floatingLabelHeight = 0.0; + contentPadding = decorationContentPadding ?? EdgeInsetsDirectional.zero; + } else if (!border.isOutline) { + // 4.0: the vertical gap between the inline elements and the floating label. + floatingLabelHeight = MediaQuery.textScalerOf( + context, + ).scale(4.0 + 0.75 * labelStyle.fontSize!); + if (decoration.filled ?? false) { + contentPadding = + decorationContentPadding ?? + (useMaterial3 + ? decorationIsDense + ? const EdgeInsetsDirectional.fromSTEB(12.0, 4.0, 12.0, 4.0) + : const EdgeInsetsDirectional.fromSTEB(12.0, 8.0, 12.0, 8.0) + : decorationIsDense + ? const EdgeInsetsDirectional.fromSTEB(12.0, 8.0, 12.0, 8.0) + : const EdgeInsetsDirectional.fromSTEB(12.0, 12.0, 12.0, 12.0)); + } else { + // No left or right padding for underline borders that aren't filled + // is a small concession to backwards compatibility. This eliminates + // the most noticeable layout change introduced by #13734. + contentPadding = + decorationContentPadding ?? + (useMaterial3 + ? decorationIsDense + ? const EdgeInsetsDirectional.fromSTEB(0.0, 4.0, 0.0, 4.0) + : const EdgeInsetsDirectional.fromSTEB(0.0, 8.0, 0.0, 8.0) + : decorationIsDense + ? const EdgeInsetsDirectional.fromSTEB(0.0, 8.0, 0.0, 8.0) + : const EdgeInsetsDirectional.fromSTEB(0.0, 12.0, 0.0, 12.0)); + } + } else { + floatingLabelHeight = 0.0; + contentPadding = + decorationContentPadding ?? + (useMaterial3 + ? decorationIsDense + ? const EdgeInsetsDirectional.fromSTEB(12.0, 16.0, 12.0, 8.0) + : const EdgeInsetsDirectional.fromSTEB(12.0, 20.0, 12.0, 12.0) + : decorationIsDense + ? const EdgeInsetsDirectional.fromSTEB(12.0, 20.0, 12.0, 12.0) + : const EdgeInsetsDirectional.fromSTEB(12.0, 24.0, 12.0, 16.0)); + } + + var inputGap = 0.0; + if (useMaterial3) { + if (border is OutlineInputBorder) { + inputGap = border.gapPadding; + } else { + inputGap = border.isOutline || (decoration.filled ?? false) ? _kInputExtraPadding : 0.0; + } + } + + final decorator = _Decorator( + decoration: _Decoration( + contentPadding: contentPadding, + isCollapsed: decoration.isCollapsed!, + inputGap: inputGap, + floatingLabelHeight: floatingLabelHeight, + floatingLabelAlignment: decoration.floatingLabelAlignment!, + floatingLabelProgress: _floatingLabelAnimation.value, + border: border, + borderGap: _borderGap, + alignLabelWithHint: decoration.alignLabelWithHint ?? false, + isDense: decoration.isDense, + isEmpty: isEmpty, + visualDensity: visualDensity, + maintainHintSize: maintainHintSize, + maintainLabelSize: decoration.maintainLabelSize, + icon: icon, + input: input, + label: label, + hint: hint, + prefix: prefix, + suffix: suffix, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + helperError: helperError, + counter: counter, + container: container, + ), + textDirection: textDirection, + textBaseline: textBaseline, + textAlignVertical: widget.textAlignVertical, + isFocused: isFocused, + expands: widget.expands, + ); + + // Pass error text to semantics so screen readers announce it along with + // the input field (via aria-description on web). The hintText is already + // in the semantics tree via the hint Text widget that merges up through + // _RenderDecoration's childSemanticsConfigurationDelegate, so we only + // need to add errorText here. Adding hintText would cause duplication. + // TODO(flutter-zl): A follow-up using aria-describedby with element IDs + // will address complex cases (custom error widgets, errors outside + // InputDecoration, custom announcement ordering). See + // https://github.com/flutter/flutter/issues/180496#issuecomment-3713178684. + final String? semanticsHint = decoration.errorText; + + final Widget result = Semantics(hint: semanticsHint, child: decorator); + + final BoxConstraints? constraints = decoration.constraints; + if (constraints != null) { + return ConstrainedBox(constraints: constraints, child: result); + } + return result; + } +} + +/// The border, labels, icons, and styles used to decorate a Material +/// Design text field. +/// +/// The [TextField] and [InputDecorator] classes use [InputDecoration] objects +/// to describe their decoration. (In fact, this class is merely the +/// configuration of an [InputDecorator], which does all the heavy lifting.) +/// +/// {@tool dartpad} +/// This sample shows how to style a `TextField` using an `InputDecorator`. The +/// TextField displays a "send message" icon to the left of the input area, +/// which is surrounded by a border an all sides. It displays the `hintText` +/// inside the input area to help the user understand what input is required. It +/// displays the `helperText` and `counterText` below the input area. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/input_decoration.png) +/// +/// ** See code in examples/api/lib/material/input_decorator/input_decoration.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows how to style a "collapsed" `TextField` using an +/// `InputDecorator`. The collapsed `TextField` surrounds the hint text and +/// input area with a border, but does not add padding around them. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/input_decoration_collapsed.png) +/// +/// ** See code in examples/api/lib/material/input_decorator/input_decoration.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows how to create a `TextField` with hint text, a red border +/// on all sides, and an error message. To display a red border and error +/// message, provide `errorText` to the [InputDecoration] constructor. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/input_decoration_error.png) +/// +/// ** See code in examples/api/lib/material/input_decorator/input_decoration.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows how to style a `TextField` with a round border and +/// additional text before and after the input area. It displays "Prefix" before +/// the input area, and "Suffix" after the input area. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/input_decoration_prefix_suffix.png) +/// +/// ** See code in examples/api/lib/material/input_decorator/input_decoration.3.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows how to style a `TextField` with a prefixIcon that changes color +/// based on the `WidgetState`. The color defaults to gray and is green while focused. +/// +/// ** See code in examples/api/lib/material/input_decorator/input_decoration.widget_state.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows how to style a `TextField` with a prefixIcon that changes color +/// based on the `WidgetState` through the use of `ThemeData`. The color defaults +/// to gray, be blue while focused and red if in an error state. +/// +/// ** See code in examples/api/lib/material/input_decorator/input_decoration.widget_state.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TextField], which is a text input widget that uses an +/// [InputDecoration]. +/// * [InputDecorator], which is a widget that draws an [InputDecoration] +/// around an input child widget. +/// * [Decoration] and [DecoratedBox], for drawing borders and backgrounds +/// around a child widget. +@immutable +class InputDecoration { + /// Creates a bundle of the border, labels, icons, and styles used to + /// decorate a Material Design text field. + /// + /// Unless specified by the ambient [InputDecorationThemeData], [InputDecorator] + /// defaults [isDense] to false and [filled] to false. The default border is + /// an instance of [UnderlineInputBorder]. If [border] is [InputBorder.none] + /// then no border is drawn. + /// + /// Only one of [prefix] and [prefixText] can be specified. + /// + /// Similarly, only one of [suffix] and [suffixText] can be specified. + const InputDecoration({ + this.icon, + this.iconColor, + this.label, + this.labelText, + this.labelStyle, + this.floatingLabelStyle, + this.helper, + this.helperText, + this.helperStyle, + this.helperMaxLines, + this.hintText, + this.hint, + this.hintStyle, + this.hintTextDirection, + this.hintMaxLines, + this.hintFadeDuration, + @Deprecated( + 'Use maintainHintSize instead. ' + 'This will maintain both hint height and hint width. ' + 'This feature was deprecated after v3.28.0-2.0.pre.', + ) + this.maintainHintHeight = true, + this.maintainHintSize = true, + this.maintainLabelSize = false, + this.error, + this.errorText, + this.errorStyle, + this.errorMaxLines, + this.floatingLabelBehavior, + this.floatingLabelAlignment, + this.isCollapsed, + this.isDense, + this.contentPadding, + this.prefixIcon, + this.prefixIconConstraints, + this.prefix, + this.prefixText, + this.prefixStyle, + this.prefixIconColor, + this.suffixIcon, + this.suffix, + this.suffixText, + this.suffixStyle, + this.suffixIconColor, + this.suffixIconConstraints, + this.counter, + this.counterText, + this.counterStyle, + this.filled, + this.fillColor, + this.focusColor, + this.hoverColor, + this.errorBorder, + this.focusedBorder, + this.focusedErrorBorder, + this.disabledBorder, + this.enabledBorder, + this.border, + this.enabled = true, + this.semanticCounterText, + this.alignLabelWithHint, + this.constraints, + this.visualDensity, + }) : assert( + !(label != null && labelText != null), + 'Declaring both label and labelText is not supported.', + ), + assert( + hint == null || hintText == null, + 'Declaring both hint and hintText is not supported.', + ), + assert( + !(helper != null && helperText != null), + 'Declaring both helper and helperText is not supported.', + ), + assert( + !(prefix != null && prefixText != null), + 'Declaring both prefix and prefixText is not supported.', + ), + assert( + !(suffix != null && suffixText != null), + 'Declaring both suffix and suffixText is not supported.', + ), + assert( + !(error != null && errorText != null), + 'Declaring both error and errorText is not supported.', + ); + + /// Defines an [InputDecorator] that is the same size as the input field. + /// + /// This type of input decoration does not include a border by default. + /// + /// A collapsed decoration cannot have [labelText], [errorText], [counter], + /// [icon], prefixes, and suffixes. + /// + /// Sets the [isCollapsed] property to true. + /// Sets the [contentPadding] property to [EdgeInsets.zero]. + const InputDecoration.collapsed({ + required this.hintText, + @Deprecated( + 'Invalid parameter because a collapsed decoration has no label. ' + 'This feature was deprecated after v3.24.0-0.1.pre.', + ) + FloatingLabelBehavior? floatingLabelBehavior, + @Deprecated( + 'Invalid parameter because a collapsed decoration has no label. ' + 'This feature was deprecated after v3.24.0-0.1.pre.', + ) + FloatingLabelAlignment? floatingLabelAlignment, + this.hintStyle, + this.hint, + this.hintTextDirection, + this.hintMaxLines, + this.hintFadeDuration, + @Deprecated( + 'Use maintainHintSize instead. ' + 'This will maintain both hint height and hint width. ' + 'This feature was deprecated after v3.28.0-2.0.pre.', + ) + this.maintainHintHeight = true, + this.maintainHintSize = true, + this.maintainLabelSize = false, + this.filled = false, + this.fillColor, + this.focusColor, + this.hoverColor, + this.border = InputBorder.none, + this.enabled = true, + this.constraints, + }) : icon = null, + iconColor = null, + label = null, + labelText = null, + labelStyle = null, + floatingLabelStyle = null, + helper = null, + helperText = null, + helperStyle = null, + helperMaxLines = null, + error = null, + errorText = null, + errorStyle = null, + errorMaxLines = null, + isDense = false, + contentPadding = EdgeInsets.zero, + isCollapsed = true, + prefixIcon = null, + prefix = null, + prefixText = null, + prefixStyle = null, + prefixIconColor = null, + prefixIconConstraints = null, + suffix = null, + suffixIcon = null, + suffixText = null, + suffixStyle = null, + suffixIconColor = null, + suffixIconConstraints = null, + counter = null, + counterText = null, + counterStyle = null, + errorBorder = null, + focusedBorder = null, + focusedErrorBorder = null, + disabledBorder = null, + enabledBorder = null, + semanticCounterText = null, + // ignore: prefer_initializing_formals, (can't use initializing formals for a deprecated parameter). + floatingLabelBehavior = floatingLabelBehavior, + // ignore: prefer_initializing_formals, (can't use initializing formals for a deprecated parameter). + floatingLabelAlignment = floatingLabelAlignment, + alignLabelWithHint = false, + visualDensity = null; + + /// An icon to show before the input field and outside of the decoration's + /// container. + /// + /// The size and color of the icon is configured automatically using an + /// [IconTheme] and therefore does not need to be explicitly given in the + /// icon widget. + /// + /// The trailing edge of the icon is padded by 16dps. + /// + /// The decoration's container is the area which is filled if [filled] is + /// true and bordered per the [border]. It's the area adjacent to + /// [icon] and above the widgets that contain [helperText], + /// [errorText], and [counterText]. + /// + /// See [Icon], [ImageIcon]. + final Widget? icon; + + /// The color of the [icon]. + /// + /// If [iconColor] is a [WidgetStateColor], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + final Color? iconColor; + + /// Optional widget that describes the input field. + /// + /// {@template flutter.material.inputDecoration.label} + /// When the input field is empty and unfocused, the label is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text may be entered in the input field). When the input field receives + /// focus (or if the field is non-empty), depending on [floatingLabelAlignment], + /// the label moves above, either vertically adjacent to, or to the center of + /// the input field. + /// {@endtemplate} + /// + /// This can be used, for example, to add multiple [TextStyle]'s to a label that would + /// otherwise be specified using [labelText], which only takes one [TextStyle]. + /// + /// {@tool dartpad} + /// This example shows a `TextField` with a [Text.rich] widget as the [label]. + /// The widget contains multiple [Text] widgets with different [TextStyle]'s. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.label.0.dart ** + /// {@end-tool} + /// + /// Only one of [label] and [labelText] can be specified. + final Widget? label; + + /// Optional text that describes the input field. + /// + /// {@macro flutter.material.inputDecoration.label} + /// + /// If a more elaborate label is required, consider using [label] instead. + /// Only one of [label] and [labelText] can be specified. + final String? labelText; + + /// {@template flutter.material.inputDecoration.labelStyle} + /// The style to use for [InputDecoration.labelText] when the label is on top + /// of the input field. + /// + /// If [labelStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// When the [InputDecoration.labelText] is above (i.e., vertically adjacent to) + /// the input field, the text uses the [floatingLabelStyle] instead. + /// + /// If null, defaults to a value derived from the base [TextStyle] for the + /// input field and the current [Theme]. + /// + /// Specifying this style will override the default behavior + /// of [InputDecoration] that changes the color of the label to the + /// [InputDecoration.errorStyle] color or [ColorScheme.error]. + /// + /// {@tool dartpad} + /// It's possible to override the label style for just the error state, or + /// just the default state, or both. + /// + /// In this example the [labelStyle] is specified with a [WidgetStateProperty] + /// which resolves to a text style whose color depends on the decorator's + /// error state. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.label_style_error.0.dart ** + /// {@end-tool} + /// {@endtemplate} + final TextStyle? labelStyle; + + /// {@template flutter.material.inputDecoration.floatingLabelStyle} + /// The style to use for [InputDecoration.labelText] when the label is + /// above (i.e., vertically adjacent to) the input field. + /// + /// When the [InputDecoration.labelText] is on top of the input field, the + /// text uses the [labelStyle] instead. + /// + /// If [floatingLabelStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to [labelStyle]. + /// + /// Specifying this style will override the default behavior + /// of [InputDecoration] that changes the color of the label to the + /// [InputDecoration.errorStyle] color or [ColorScheme.error]. + /// + /// When the input field receives focus, the font size of [InputDecoration.label] is + /// scaled down by 75%. + /// + /// {@tool dartpad} + /// It's possible to override the label style for just the error state, or + /// just the default state, or both. + /// + /// In this example the [floatingLabelStyle] is specified with a + /// [WidgetStateProperty] which resolves to a text style whose color depends + /// on the decorator's error state. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.floating_label_style_error.0.dart ** + /// {@end-tool} + /// {@endtemplate} + final TextStyle? floatingLabelStyle; + + /// Optional widget that appears below the [InputDecorator.child]. + /// + /// If non-null, the [helper] is displayed below the [InputDecorator.child], in + /// the same location as [error]. If a non-null [error] or [errorText] value is + /// specified then the [helper] is not shown. + /// + /// {@tool dartpad} + /// This example shows a `TextField` with a [Text.rich] widget as the [helper]. + /// The widget contains [Text] and [Icon] widgets with different styles. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.helper.0.dart ** + /// {@end-tool} + /// + /// Only one of [helper] and [helperText] can be specified. + final Widget? helper; + + /// Text that provides context about the [InputDecorator.child]'s value, such + /// as how the value will be used. + /// + /// If non-null, the text is displayed below the [InputDecorator.child], in + /// the same location as [errorText]. If a non-null [errorText] value is + /// specified then the helper text is not shown. + /// + /// If a more elaborate helper text is required, consider using [helper] instead. + /// + /// Only one of [helper] and [helperText] can be specified. + final String? helperText; + + /// The style to use for the [helperText]. + /// + /// If [helperStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + final TextStyle? helperStyle; + + /// The maximum number of lines the [helperText] can occupy. + /// + /// Defaults to null, which means that soft line breaks in [helperText] are + /// truncated with an ellipse while hard line breaks are respected. + /// For example, a [helperText] that overflows the width of the field will be + /// truncated with an ellipse. However, a [helperText] with explicit linebreak + /// characters (\n) will display on multiple lines. + /// + /// To cause a long [helperText] to wrap, either set [helperMaxLines] or use + /// [helper] which offers more flexibility. For instance, it can be set to a + /// [Text] widget with a specific overflow value. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the helper. + /// + /// See also: + /// + /// * [errorMaxLines], the equivalent but for the [errorText]. + final int? helperMaxLines; + + /// Text that suggests what sort of input the field accepts. + /// + /// Displayed on top of the [InputDecorator.child] (i.e., at the same location + /// on the screen where text may be entered in the [InputDecorator.child]), + /// when [InputDecorator.isEmpty] is true and either (a) [labelText] is null + /// or (b) the input has the focus. + final String? hintText; + + /// The widget to use in place of the [hintText]. + /// + /// Either [hintText] or [hint] can be specified, but not both. + final Widget? hint; + + /// The style to use for the [hintText]. + /// + /// If [hintStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// Also used for the [labelText] when the [labelText] is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text may be entered in the [InputDecorator.child]). + /// + /// If null, defaults to a value derived from the base [TextStyle] for the + /// input field and the current [Theme]. + final TextStyle? hintStyle; + + /// The direction to use for the [hintText]. + /// + /// If null, defaults to a value derived from [Directionality] for the + /// input field and the current context. + final TextDirection? hintTextDirection; + + /// The maximum number of lines the [hintText] can occupy. + /// + /// Defaults to the value of [TextField.maxLines] attribute. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the hint text. [TextOverflow.ellipsis] is + /// used to handle the overflow when it is limited to single line. + final int? hintMaxLines; + + /// The duration of the [hintText] fade in and fade out animations. + /// + /// If null, defaults to [InputDecorationThemeData.hintFadeDuration]. + /// If [InputDecorationThemeData.hintFadeDuration] is null defaults to 20ms. + final Duration? hintFadeDuration; + + /// Whether the input field's height should always be greater than or equal to + /// the height of the [hintText], even if the [hintText] is not visible. + /// + /// The [InputDecorator] widget ignores [hintText] during layout when + /// it's not visible, if this flag is set to false. + /// + /// Defaults to true. + @Deprecated( + 'Use maintainHintSize instead. ' + 'This will maintain both hint height and hint width. ' + 'This feature was deprecated after v3.28.0-2.0.pre.', + ) + final bool maintainHintHeight; + + /// Whether the input field's size should always be greater than or equal to + /// the size of the [hint] or [hintText], even if the [hint] or [hintText] are not visible. + /// + /// The [InputDecorator] widget ignores [hint] and [hintText] during layout when + /// they are not visible, if this flag is set to false. + /// + /// Defaults to true. + final bool maintainHintSize; + + /// Whether the input field's size should always be greater than or equal to + /// the size of the [label] or [labelText], even if the [label] or [labelText] are not visible. + /// + /// The [InputDecorator] widget ignores [label] and [labelText] during layout when + /// this flag is set to false. + /// + /// Defaults to false for compatibility reason. + final bool maintainLabelSize; + + /// Optional widget that appears below the [InputDecorator.child] and the border. + /// + /// If non-null, the border's color animates to red and the [helperText] is not shown. + /// + /// Only one of [error] and [errorText] can be specified. + final Widget? error; + + /// Text that appears below the [InputDecorator.child] and the border. + /// + /// If non-null, the border's color animates to red and the [helperText] is + /// not shown. + /// + /// In a [TextFormField], this is overridden by the value returned from + /// [TextFormField.validator], if that is not null. + /// + /// If a more elaborate error is required, consider using [error] instead. + /// + /// Only one of [error] and [errorText] can be specified. + final String? errorText; + + /// {@template flutter.material.inputDecoration.errorStyle} + /// The style to use for the [InputDecoration.errorText]. + /// + /// If null, defaults of a value derived from the base [TextStyle] for the + /// input field and the current [Theme]. + /// + /// By default the color of style will be used by the label of + /// [InputDecoration] if [InputDecoration.errorText] is not null. See + /// [InputDecoration.labelStyle] or [InputDecoration.floatingLabelStyle] for + /// an example of how to replicate this behavior when specifying those + /// styles. + /// {@endtemplate} + final TextStyle? errorStyle; + + /// The maximum number of lines the [errorText] can occupy. + /// + /// Defaults to null, which means that soft line breaks in [errorText] are + /// truncated with an ellipse while hard line breaks are respected. + /// For example, an [errorText] that overflows the width of the field will be + /// truncated with an ellipse. However, an [errorText] with explicit linebreak + /// characters (\n) will display on multiple lines. + /// + /// To cause a long [errorText] to wrap, either set [errorMaxLines] or use + /// [error] which offers more flexibility. For instance, it can be set to a + /// [Text] widget with a specific overflow value. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the error. + /// + /// See also: + /// + /// * [helperMaxLines], the equivalent but for the [helperText]. + final int? errorMaxLines; + + /// {@template flutter.material.inputDecoration.floatingLabelBehavior} + /// Defines **how** the floating label should behave. + /// + /// When [FloatingLabelBehavior.auto] the label will float to the top only when + /// the field is focused or has some text content, otherwise it will appear + /// in the field in place of the content. + /// + /// When [FloatingLabelBehavior.always] the label will always float at the top + /// of the field above the content. + /// + /// When [FloatingLabelBehavior.never] the label will always appear in an empty + /// field in place of the content. + /// {@endtemplate} + /// + /// If null, [InputDecorationThemeData.floatingLabelBehavior] will be used. + /// + /// See also: + /// + /// * [floatingLabelAlignment] which defines **where** the floating label + /// should be displayed. + final FloatingLabelBehavior? floatingLabelBehavior; + + /// {@template flutter.material.inputDecoration.floatingLabelAlignment} + /// Defines **where** the floating label should be displayed. + /// + /// [FloatingLabelAlignment.start] aligns the floating label to the leftmost + /// (when [TextDirection.ltr]) or rightmost (when [TextDirection.rtl]), + /// possible position, which is vertically adjacent to the label, on top of + /// the field. + /// + /// [FloatingLabelAlignment.center] aligns the floating label to the center on + /// top of the field. + /// {@endtemplate} + /// + /// If null, [InputDecorationThemeData.floatingLabelAlignment] will be used. + /// + /// See also: + /// + /// * [floatingLabelBehavior] which defines **how** the floating label should + /// behave. + final FloatingLabelAlignment? floatingLabelAlignment; + + /// Whether the [InputDecorator.child] is part of a dense form (i.e., uses less vertical + /// space). + /// + /// Defaults to false. + final bool? isDense; + + /// The padding for the input decoration's container. + /// + /// {@macro flutter.material.input_decorator.container_description} + /// + /// By default the [contentPadding] reflects [isDense] and the type of the + /// [border]. + /// + /// If [isCollapsed] is true then [contentPadding] is [EdgeInsets.zero]. + /// + /// ### Material 3 default content padding + /// + /// If `isOutline` property of [border] is false and if [filled] is true then + /// [contentPadding] is `EdgeInsets.fromLTRB(12, 4, 12, 4)` when [isDense] + /// is true and `EdgeInsets.fromLTRB(12, 8, 12, 8)` when [isDense] is false. + /// + /// If `isOutline` property of [border] is false and if [filled] is false then + /// [contentPadding] is `EdgeInsets.fromLTRB(0, 4, 0, 4)` when [isDense] is + /// true and `EdgeInsets.fromLTRB(0, 8, 0, 8)` when [isDense] is false. + /// + /// If `isOutline` property of [border] is true then [contentPadding] is + /// `EdgeInsets.fromLTRB(12, 16, 12, 8)` when [isDense] is true + /// and `EdgeInsets.fromLTRB(12, 20, 12, 12)` when [isDense] is false. + /// + /// ### Material 2 default content padding + /// + /// If `isOutline` property of [border] is false and if [filled] is true then + /// [contentPadding] is `EdgeInsets.fromLTRB(12, 8, 12, 8)` when [isDense] + /// is true and `EdgeInsets.fromLTRB(12, 12, 12, 12)` when [isDense] is false. + /// + /// If `isOutline` property of [border] is false and if [filled] is false then + /// [contentPadding] is `EdgeInsets.fromLTRB(0, 8, 0, 8)` when [isDense] is + /// true and `EdgeInsets.fromLTRB(0, 12, 0, 12)` when [isDense] is false. + /// + /// If `isOutline` property of [border] is true then [contentPadding] is + /// `EdgeInsets.fromLTRB(12, 20, 12, 12)` when [isDense] is true + /// and `EdgeInsets.fromLTRB(12, 24, 12, 16)` when [isDense] is false. + final EdgeInsetsGeometry? contentPadding; + + /// Whether the decoration is the same size as the input field. + /// + /// A collapsed decoration cannot have [labelText], [errorText], [counter], + /// [icon], prefixes, and suffixes. + /// + /// To create a collapsed input decoration, use [InputDecoration.collapsed]. + final bool? isCollapsed; + + /// An icon that appears before the [prefix] or [prefixText] and before + /// the editable part of the text field, within the decoration's container. + /// + /// The size and color of the prefix icon is configured automatically using an + /// [IconTheme] and therefore does not need to be explicitly given in the + /// icon widget. + /// + /// The prefix icon is constrained with a minimum size of 48px by 48px, but + /// can be expanded beyond that. Anything larger than 24px will require + /// additional padding to ensure it matches the Material Design spec of 12px + /// padding between the left edge of the input and leading edge of the prefix + /// icon. The following snippet shows how to pad the leading edge of the + /// prefix icon: + /// + /// ```dart + /// prefixIcon: Padding( + /// padding: const EdgeInsetsDirectional.only(start: 12.0), + /// child: _myIcon, // _myIcon is a 48px-wide widget. + /// ) + /// ``` + /// + /// {@macro flutter.material.input_decorator.container_description} + /// + /// The prefix icon alignment can be changed using [Align] with a fixed `widthFactor` and + /// `heightFactor`. + /// + /// {@tool dartpad} + /// This example shows how the prefix icon alignment can be changed using [Align] with + /// a fixed `widthFactor` and `heightFactor`. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.prefix_icon.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [Icon] and [ImageIcon], which are typically used to show icons. + /// * [prefix] and [prefixText], which are other ways to show content + /// before the text field (but after the icon). + /// * [suffixIcon], which is the same but on the trailing edge. + /// * [Align] A widget that aligns its child within itself and optionally + /// sizes itself based on the child's size. + final Widget? prefixIcon; + + /// The constraints for the prefix icon. + /// + /// This can be used to modify the [BoxConstraints] surrounding [prefixIcon]. + /// + /// This property is particularly useful for getting the decoration's height + /// less than the minimum tappable height (which is 48px when the visual + /// density is set to [VisualDensity.standard]). This can be achieved by + /// setting [isDense] to true and setting the constraints' minimum height + /// and width to a value lower than the minimum tappable size. + /// + /// {@tool dartpad} + /// This example shows the differences between two `TextField` widgets when + /// [prefixIconConstraints] is set to the default value and when one is not. + /// + /// The [isDense] property must be set to true to be able to + /// set the constraints smaller than 48px. + /// + /// If null, [BoxConstraints] with a minimum width and height of 48px is + /// used. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.prefix_icon_constraints.0.dart ** + /// {@end-tool} + final BoxConstraints? prefixIconConstraints; + + /// Optional widget to place on the line before the input. + /// + /// This can be used, for example, to add some padding to text that would + /// otherwise be specified using [prefixText], or to add a custom widget in + /// front of the input. The widget's baseline is lined up with the input + /// baseline. + /// + /// Only one of [prefix] and [prefixText] can be specified. + /// + /// The [prefix] appears after the [prefixIcon], if both are specified. + /// + /// See also: + /// + /// * [suffix], the equivalent but on the trailing edge. + final Widget? prefix; + + /// Optional text prefix to place on the line before the input. + /// + /// Uses the [prefixStyle]. Uses [hintStyle] if [prefixStyle] isn't specified. + /// The prefix text is not returned as part of the user's input. + /// + /// If a more elaborate prefix is required, consider using [prefix] instead. + /// Only one of [prefix] and [prefixText] can be specified. + /// + /// The [prefixText] appears after the [prefixIcon], if both are specified. + /// + /// See also: + /// + /// * [suffixText], the equivalent but on the trailing edge. + final String? prefixText; + + /// The style to use for the [prefixText]. + /// + /// If [prefixStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [hintStyle]. + /// + /// See also: + /// + /// * [suffixStyle], the equivalent but on the trailing edge. + final TextStyle? prefixStyle; + + /// Optional color of the prefixIcon + /// + /// Defaults to [iconColor] + /// + /// If [prefixIconColor] is a [WidgetStateColor], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + final Color? prefixIconColor; + + /// An icon that appears after the editable part of the text field and + /// after the [suffix] or [suffixText], within the decoration's container. + /// + /// The size and color of the suffix icon is configured automatically using an + /// [IconTheme] and therefore does not need to be explicitly given in the + /// icon widget. + /// + /// The suffix icon is constrained with a minimum size of 48px by 48px, but + /// can be expanded beyond that. Anything larger than 24px will require + /// additional padding to ensure it matches the Material Design spec of 12px + /// padding between the right edge of the input and trailing edge of the + /// prefix icon. The following snippet shows how to pad the trailing edge of + /// the suffix icon: + /// + /// ```dart + /// suffixIcon: Padding( + /// padding: const EdgeInsetsDirectional.only(end: 12.0), + /// child: _myIcon, // myIcon is a 48px-wide widget. + /// ) + /// ``` + /// + /// The decoration's container is the area which is filled if [filled] is + /// true and bordered per the [border]. It's the area adjacent to + /// [icon] and above the widgets that contain [helperText], + /// [errorText], and [counterText]. + /// + /// The suffix icon alignment can be changed using [Align] with a fixed `widthFactor` and + /// `heightFactor`. + /// + /// {@tool dartpad} + /// This example shows how the suffix icon alignment can be changed using [Align] with + /// a fixed `widthFactor` and `heightFactor`. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.suffix_icon.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [Icon] and [ImageIcon], which are typically used to show icons. + /// * [suffix] and [suffixText], which are other ways to show content + /// after the text field (but before the icon). + /// * [prefixIcon], which is the same but on the leading edge. + /// * [Align] A widget that aligns its child within itself and optionally + /// sizes itself based on the child's size. + final Widget? suffixIcon; + + /// Optional widget to place on the line after the input. + /// + /// This can be used, for example, to add some padding to the text that would + /// otherwise be specified using [suffixText], or to add a custom widget after + /// the input. The widget's baseline is lined up with the input baseline. + /// + /// Only one of [suffix] and [suffixText] can be specified. + /// + /// The [suffix] appears before the [suffixIcon], if both are specified. + /// + /// See also: + /// + /// * [prefix], the equivalent but on the leading edge. + final Widget? suffix; + + /// Optional text suffix to place on the line after the input. + /// + /// Uses the [suffixStyle]. Uses [hintStyle] if [suffixStyle] isn't specified. + /// The suffix text is not returned as part of the user's input. + /// + /// If a more elaborate suffix is required, consider using [suffix] instead. + /// Only one of [suffix] and [suffixText] can be specified. + /// + /// The [suffixText] appears before the [suffixIcon], if both are specified. + /// + /// See also: + /// + /// * [prefixText], the equivalent but on the leading edge. + final String? suffixText; + + /// The style to use for the [suffixText]. + /// + /// If [suffixStyle] is a [WidgetStateTextStyle], then the effective text + /// style can depend on the [WidgetState.focused] state, i.e. if the + /// [TextField] is focused or not. + /// + /// If null, defaults to the [hintStyle]. + /// + /// See also: + /// + /// * [prefixStyle], the equivalent but on the leading edge. + final TextStyle? suffixStyle; + + /// Optional color of the [suffixIcon]. + /// + /// Defaults to [iconColor] + /// + /// If [suffixIconColor] is a [WidgetStateColor], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + final Color? suffixIconColor; + + /// The constraints for the suffix icon. + /// + /// This can be used to modify the [BoxConstraints] surrounding [suffixIcon]. + /// + /// This property is particularly useful for getting the decoration's height + /// less than the minimum tappable height (which is 48px when the visual + /// density is set to [VisualDensity.standard]). This can be achieved by + /// setting [isDense] to true and setting the constraints' minimum height + /// and width to a value lower than the minimum tappable size. + /// + /// If null, a [BoxConstraints] with a minimum width and height of 48px is + /// used. + /// + /// {@tool dartpad} + /// This example shows the differences between two `TextField` widgets when + /// [suffixIconConstraints] is set to the default value and when one is not. + /// + /// The [isDense] property must be set to true to be able to + /// set the constraints smaller than 48px. + /// + /// If null, [BoxConstraints] with a minimum width and height of 48px is + /// used. + /// + /// ** See code in examples/api/lib/material/input_decorator/input_decoration.suffix_icon_constraints.0.dart ** + /// {@end-tool} + final BoxConstraints? suffixIconConstraints; + + /// Optional text to place below the line as a character count. + /// + /// Rendered using [counterStyle]. Uses [helperStyle] if [counterStyle] is + /// null. + /// + /// The semantic label can be replaced by providing a [semanticCounterText]. + /// + /// If null or an empty string and [counter] isn't specified, then nothing + /// will appear in the counter's location. + final String? counterText; + + /// Optional custom counter widget to go in the place otherwise occupied by + /// [counterText]. If this property is non null, then [counterText] is + /// ignored. + final Widget? counter; + + /// The style to use for the [counterText]. + /// + /// If [counterStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [helperStyle]. + final TextStyle? counterStyle; + + /// If true the decoration's container is filled with [fillColor]. + /// + /// When [InputDecorator.isHovering] is true, the [hoverColor] is also blended + /// into the final fill color. + /// + /// Typically this field set to true if [border] is an [UnderlineInputBorder]. + /// + /// {@template flutter.material.input_decorator.container_description} + /// The decoration's container is the area which is filled if [filled] is true + /// and bordered per the [border]. It's the area adjacent to [icon] and above + /// the widgets that contain [helperText], [errorText], and [counterText]. + /// {@endtemplate} + /// + /// This property is false by default. + final bool? filled; + + /// The base fill color of the decoration's container color. + /// + /// When [InputDecorator.isHovering] is true, the [hoverColor] is also blended + /// into the final fill color. + /// + /// By default the [fillColor] is based on the current + /// [InputDecorationThemeData.fillColor]. + /// + /// {@macro flutter.material.input_decorator.container_description} + final Color? fillColor; + + /// The fill color of the decoration's container when it has the input focus. + /// + /// By default the [focusColor] is based on the current + /// [InputDecorationThemeData.focusColor]. + /// + /// This [focusColor] is ignored by [TextField] and [TextFormField] because + /// they don't respond to focus changes by changing their decorator's + /// container color, they respond by changing their border to the + /// [focusedBorder], which you can change the color of. + /// + /// {@macro flutter.material.input_decorator.container_description} + final Color? focusColor; + + /// The color of the highlight for the decoration shown if the container + /// is being hovered over by a mouse. + /// + /// If [filled] is true, the [hoverColor] is blended with [fillColor] and + /// fills the decoration's container. + /// + /// If [filled] is false, and [InputDecorator.isFocused] is false, the color + /// is blended over the [enabledBorder]'s color. + /// + /// By default the [hoverColor] is based on the current [Theme]. + /// + /// {@macro flutter.material.input_decorator.container_description} + final Color? hoverColor; + + /// The border to display when the [InputDecorator] does not have the focus and + /// is showing an error. + /// + /// See also: + /// + /// * [InputDecorator.isFocused], which is true if the [InputDecorator]'s child + /// has the focus. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? errorBorder; + + /// The border to display when the [InputDecorator] has the focus and is not + /// showing an error. + /// + /// See also: + /// + /// * [InputDecorator.isFocused], which is true if the [InputDecorator]'s child + /// has the focus. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? focusedBorder; + + /// The border to display when the [InputDecorator] has the focus and is + /// showing an error. + /// + /// See also: + /// + /// * [InputDecorator.isFocused], which is true if the [InputDecorator]'s child + /// has the focus. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? focusedErrorBorder; + + /// The border to display when the [InputDecorator] is disabled and is not + /// showing an error. + /// + /// See also: + /// + /// * [InputDecoration.enabled], which is false if the [InputDecorator] is disabled. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? disabledBorder; + + /// The border to display when the [InputDecorator] is enabled and is not + /// showing an error. + /// + /// See also: + /// + /// * [InputDecoration.enabled], which is false if the [InputDecorator] is disabled. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + final InputBorder? enabledBorder; + + /// The shape of the border to draw around the decoration's container. + /// + /// If [border] is a [WidgetStateInputBorder] then the effective border is resolved + /// in the following states: + /// * [WidgetState.disabled]. + /// * [WidgetState.error]. + /// * [WidgetState.focused]. + /// * [WidgetState.hovered]. + /// + /// If [border] derives from [InputBorder] the border's [InputBorder.borderSide], + /// i.e. the border's color and width, will be overridden to reflect the input + /// decorator's state. Only the border's shape is used. If custom [BorderSide] + /// values are desired for a given state, all five borders – [errorBorder], + /// [focusedBorder], [enabledBorder], [disabledBorder], [focusedErrorBorder] – must be set. + /// + /// The decoration's container is the area which is filled if [filled] is + /// true and bordered per the [border]. It's the area adjacent to + /// [InputDecoration.icon] and above the widgets that contain + /// [InputDecoration.helperText], [InputDecoration.errorText], and + /// [InputDecoration.counterText]. + /// + /// The border's bounds, i.e. the value of `border.getOuterPath()`, define + /// the area to be filled. + /// + /// This property is only used when the appropriate one of [errorBorder], + /// [focusedBorder], [focusedErrorBorder], [disabledBorder], or [enabledBorder] + /// is not specified. This border's [InputBorder.borderSide] property is + /// configured by the InputDecorator, depending on the values of + /// [InputDecoration.errorText], [InputDecoration.enabled], + /// [InputDecorator.isFocused] and the current [Theme]. + /// + /// Typically one of [UnderlineInputBorder] or [OutlineInputBorder]. + /// If null, InputDecorator's default is `const UnderlineInputBorder()`. + /// + /// See also: + /// + /// * [InputBorder.none], which doesn't draw a border. + /// * [UnderlineInputBorder], which draws a horizontal line at the + /// bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + final InputBorder? border; + + /// If false the opacity of the visual elements is reduced, including [helperText],[errorText], and [counterText]. + /// + /// This property is true by default. + final bool enabled; + + /// A semantic label for the [counterText]. + /// + /// Defaults to null. + /// + /// If provided, this replaces the semantic label of the [counterText]. + final String? semanticCounterText; + + /// Typically set to true when the [InputDecorator] contains a multiline + /// [TextField] ([TextField.maxLines] is null or > 1) to override the default + /// behavior of aligning the label with the center of the [TextField]. + /// + /// Defaults to false. + final bool? alignLabelWithHint; + + /// Defines minimum and maximum sizes for the [InputDecorator]. + /// + /// Typically the decorator will fill the horizontal space it is given. For + /// larger screens, it may be useful to have the maximum width clamped to + /// a given value so it doesn't fill the whole screen. This property + /// allows you to control how big the decorator will be in its available + /// space. + /// + /// If null, then the ambient [InputDecorationThemeData.constraints] will be used. + /// If that is null then the decorator will fill the available width with + /// a default height based on text size. + final BoxConstraints? constraints; + + /// Defines how compact the decoration's layout will be. + /// + /// The vertical aspect of the default or user-specified [contentPadding] is adjusted + /// automatically based on [visualDensity]. + /// + /// When the visual density is [VisualDensity.compact], the vertical aspect of + /// [contentPadding] is reduced by 8 pixels. + /// + /// When the visual density is [VisualDensity.comfortable], the vertical aspect of + /// [contentPadding] is reduced by 4 pixels. + /// + /// When the visual density is [VisualDensity.standard] vertical aspect of + /// [contentPadding] is not changed. + /// + /// If null, then the ambient [InputDecorationThemeData.visualDensity] will be used. + /// If that is null then [ThemeData.visualDensity] will be used. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets + /// within a [Theme]. + /// * [InputDecorationThemeData.visualDensity], which can override this setting for a + /// given decorator. + final VisualDensity? visualDensity; + + /// Creates a copy of this input decoration with the given fields replaced + /// by the new values. + InputDecoration copyWith({ + Widget? icon, + Color? iconColor, + Widget? label, + String? labelText, + TextStyle? labelStyle, + TextStyle? floatingLabelStyle, + Widget? helper, + String? helperText, + TextStyle? helperStyle, + int? helperMaxLines, + String? hintText, + Widget? hint, + TextStyle? hintStyle, + TextDirection? hintTextDirection, + Duration? hintFadeDuration, + int? hintMaxLines, + bool? maintainHintHeight, + bool? maintainHintSize, + bool? maintainLabelSize, + Widget? error, + String? errorText, + TextStyle? errorStyle, + int? errorMaxLines, + FloatingLabelBehavior? floatingLabelBehavior, + FloatingLabelAlignment? floatingLabelAlignment, + bool? isCollapsed, + bool? isDense, + EdgeInsetsGeometry? contentPadding, + Widget? prefixIcon, + Widget? prefix, + String? prefixText, + BoxConstraints? prefixIconConstraints, + TextStyle? prefixStyle, + Color? prefixIconColor, + Widget? suffixIcon, + Widget? suffix, + String? suffixText, + TextStyle? suffixStyle, + Color? suffixIconColor, + BoxConstraints? suffixIconConstraints, + Widget? counter, + String? counterText, + TextStyle? counterStyle, + bool? filled, + Color? fillColor, + Color? focusColor, + Color? hoverColor, + InputBorder? errorBorder, + InputBorder? focusedBorder, + InputBorder? focusedErrorBorder, + InputBorder? disabledBorder, + InputBorder? enabledBorder, + InputBorder? border, + bool? enabled, + String? semanticCounterText, + bool? alignLabelWithHint, + BoxConstraints? constraints, + VisualDensity? visualDensity, + SemanticsService? semanticsService, + }) { + return InputDecoration( + icon: icon ?? this.icon, + iconColor: iconColor ?? this.iconColor, + label: label ?? this.label, + labelText: labelText ?? this.labelText, + labelStyle: labelStyle ?? this.labelStyle, + floatingLabelStyle: floatingLabelStyle ?? this.floatingLabelStyle, + helper: helper ?? this.helper, + helperText: helperText ?? this.helperText, + helperStyle: helperStyle ?? this.helperStyle, + helperMaxLines: helperMaxLines ?? this.helperMaxLines, + hintText: hintText ?? this.hintText, + hint: hint ?? this.hint, + hintStyle: hintStyle ?? this.hintStyle, + hintTextDirection: hintTextDirection ?? this.hintTextDirection, + hintMaxLines: hintMaxLines ?? this.hintMaxLines, + hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration, + maintainHintHeight: maintainHintHeight ?? this.maintainHintHeight, + maintainHintSize: maintainHintSize ?? this.maintainHintSize, + maintainLabelSize: maintainLabelSize ?? this.maintainLabelSize, + error: error ?? this.error, + errorText: errorText ?? this.errorText, + errorStyle: errorStyle ?? this.errorStyle, + errorMaxLines: errorMaxLines ?? this.errorMaxLines, + floatingLabelBehavior: floatingLabelBehavior ?? this.floatingLabelBehavior, + floatingLabelAlignment: floatingLabelAlignment ?? this.floatingLabelAlignment, + isCollapsed: isCollapsed ?? this.isCollapsed, + isDense: isDense ?? this.isDense, + contentPadding: contentPadding ?? this.contentPadding, + prefixIcon: prefixIcon ?? this.prefixIcon, + prefix: prefix ?? this.prefix, + prefixText: prefixText ?? this.prefixText, + prefixStyle: prefixStyle ?? this.prefixStyle, + prefixIconColor: prefixIconColor ?? this.prefixIconColor, + prefixIconConstraints: prefixIconConstraints ?? this.prefixIconConstraints, + suffixIcon: suffixIcon ?? this.suffixIcon, + suffix: suffix ?? this.suffix, + suffixText: suffixText ?? this.suffixText, + suffixStyle: suffixStyle ?? this.suffixStyle, + suffixIconColor: suffixIconColor ?? this.suffixIconColor, + suffixIconConstraints: suffixIconConstraints ?? this.suffixIconConstraints, + counter: counter ?? this.counter, + counterText: counterText ?? this.counterText, + counterStyle: counterStyle ?? this.counterStyle, + filled: filled ?? this.filled, + fillColor: fillColor ?? this.fillColor, + focusColor: focusColor ?? this.focusColor, + hoverColor: hoverColor ?? this.hoverColor, + errorBorder: errorBorder ?? this.errorBorder, + focusedBorder: focusedBorder ?? this.focusedBorder, + focusedErrorBorder: focusedErrorBorder ?? this.focusedErrorBorder, + disabledBorder: disabledBorder ?? this.disabledBorder, + enabledBorder: enabledBorder ?? this.enabledBorder, + border: border ?? this.border, + enabled: enabled ?? this.enabled, + semanticCounterText: semanticCounterText ?? this.semanticCounterText, + alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint, + constraints: constraints ?? this.constraints, + visualDensity: visualDensity ?? this.visualDensity, + ); + } + + /// Used by widgets like [TextField] and [InputDecorator] to create a new + /// [InputDecoration] with default values taken from the [inputDecorationTheme]. + /// + /// Only null valued properties from this [InputDecoration] are replaced + /// by the corresponding values from [inputDecorationTheme]. + InputDecoration applyDefaults(Object inputDecorationTheme) { + // TODO(bleroux): Clean this up once the type of `inputDecorationTheme` is changed to `InputDecorationThemeData` + if (inputDecorationTheme is! InputDecorationTheme && + inputDecorationTheme is! InputDecorationThemeData) { + throw ArgumentError( + 'inputDecorationTheme must be either a InputDecorationThemeData or a InputDecorationTheme', + ); + } + final InputDecorationThemeData theme = (inputDecorationTheme is InputDecorationTheme) + ? inputDecorationTheme.data + : inputDecorationTheme as InputDecorationThemeData; + return copyWith( + labelStyle: labelStyle ?? theme.labelStyle, + floatingLabelStyle: floatingLabelStyle ?? theme.floatingLabelStyle, + helperStyle: helperStyle ?? theme.helperStyle, + helperMaxLines: helperMaxLines ?? theme.helperMaxLines, + hintStyle: hintStyle ?? theme.hintStyle, + hintFadeDuration: hintFadeDuration ?? theme.hintFadeDuration, + hintMaxLines: hintMaxLines ?? theme.hintMaxLines, + errorStyle: errorStyle ?? theme.errorStyle, + errorMaxLines: errorMaxLines ?? theme.errorMaxLines, + floatingLabelBehavior: floatingLabelBehavior ?? theme.floatingLabelBehavior, + floatingLabelAlignment: floatingLabelAlignment ?? theme.floatingLabelAlignment, + isDense: isDense ?? theme.isDense, + contentPadding: contentPadding ?? theme.contentPadding, + isCollapsed: isCollapsed ?? theme.isCollapsed, + iconColor: iconColor ?? theme.iconColor, + prefixStyle: prefixStyle ?? theme.prefixStyle, + prefixIconColor: prefixIconColor ?? theme.prefixIconColor, + prefixIconConstraints: prefixIconConstraints ?? theme.prefixIconConstraints, + suffixStyle: suffixStyle ?? theme.suffixStyle, + suffixIconColor: suffixIconColor ?? theme.suffixIconColor, + suffixIconConstraints: suffixIconConstraints ?? theme.suffixIconConstraints, + counterStyle: counterStyle ?? theme.counterStyle, + filled: filled ?? theme.filled, + fillColor: fillColor ?? theme.fillColor, + focusColor: focusColor ?? theme.focusColor, + hoverColor: hoverColor ?? theme.hoverColor, + errorBorder: errorBorder ?? theme.errorBorder, + focusedBorder: focusedBorder ?? theme.focusedBorder, + focusedErrorBorder: focusedErrorBorder ?? theme.focusedErrorBorder, + disabledBorder: disabledBorder ?? theme.disabledBorder, + enabledBorder: enabledBorder ?? theme.enabledBorder, + border: border ?? theme.border, + alignLabelWithHint: alignLabelWithHint ?? theme.alignLabelWithHint, + constraints: constraints ?? theme.constraints, + visualDensity: visualDensity ?? theme.visualDensity, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is InputDecoration && + other.icon == icon && + other.iconColor == iconColor && + other.label == label && + other.labelText == labelText && + other.labelStyle == labelStyle && + other.floatingLabelStyle == floatingLabelStyle && + other.helper == helper && + other.helperText == helperText && + other.helperStyle == helperStyle && + other.helperMaxLines == helperMaxLines && + other.hintText == hintText && + other.hint == hint && + other.hintStyle == hintStyle && + other.hintTextDirection == hintTextDirection && + other.hintMaxLines == hintMaxLines && + other.hintFadeDuration == hintFadeDuration && + other.maintainHintHeight == maintainHintHeight && + other.maintainHintSize == maintainHintSize && + other.maintainLabelSize == maintainLabelSize && + other.error == error && + other.errorText == errorText && + other.errorStyle == errorStyle && + other.errorMaxLines == errorMaxLines && + other.floatingLabelBehavior == floatingLabelBehavior && + other.floatingLabelAlignment == floatingLabelAlignment && + other.isDense == isDense && + other.contentPadding == contentPadding && + other.isCollapsed == isCollapsed && + other.prefixIcon == prefixIcon && + other.prefixIconColor == prefixIconColor && + other.prefix == prefix && + other.prefixText == prefixText && + other.prefixStyle == prefixStyle && + other.prefixIconConstraints == prefixIconConstraints && + other.suffixIcon == suffixIcon && + other.suffixIconColor == suffixIconColor && + other.suffix == suffix && + other.suffixText == suffixText && + other.suffixStyle == suffixStyle && + other.suffixIconConstraints == suffixIconConstraints && + other.counter == counter && + other.counterText == counterText && + other.counterStyle == counterStyle && + other.filled == filled && + other.fillColor == fillColor && + other.focusColor == focusColor && + other.hoverColor == hoverColor && + other.errorBorder == errorBorder && + other.focusedBorder == focusedBorder && + other.focusedErrorBorder == focusedErrorBorder && + other.disabledBorder == disabledBorder && + other.enabledBorder == enabledBorder && + other.border == border && + other.enabled == enabled && + other.semanticCounterText == semanticCounterText && + other.alignLabelWithHint == alignLabelWithHint && + other.constraints == constraints && + other.visualDensity == visualDensity; + } + + @override + int get hashCode { + final values = <Object?>[ + icon, + iconColor, + label, + labelText, + floatingLabelStyle, + labelStyle, + helper, + helperText, + helperStyle, + helperMaxLines, + hintText, + hint, + hintStyle, + hintTextDirection, + hintMaxLines, + hintFadeDuration, + maintainHintHeight, + maintainHintSize, + maintainLabelSize, + error, + errorText, + errorStyle, + errorMaxLines, + floatingLabelBehavior, + floatingLabelAlignment, + isDense, + contentPadding, + isCollapsed, + filled, + fillColor, + focusColor, + hoverColor, + prefixIcon, + prefixIconColor, + prefix, + prefixText, + prefixStyle, + prefixIconConstraints, + suffixIcon, + suffixIconColor, + suffix, + suffixText, + suffixStyle, + suffixIconConstraints, + counter, + counterText, + counterStyle, + errorBorder, + focusedBorder, + focusedErrorBorder, + disabledBorder, + enabledBorder, + border, + enabled, + semanticCounterText, + alignLabelWithHint, + constraints, + visualDensity, + ]; + return Object.hashAll(values); + } + + @override + String toString() { + final description = <String>[ + if (icon != null) 'icon: $icon', + if (iconColor != null) 'iconColor: $iconColor', + if (label != null) 'label: $label', + if (labelText != null) 'labelText: "$labelText"', + if (floatingLabelStyle != null) 'floatingLabelStyle: "$floatingLabelStyle"', + if (helper != null) 'helper: "$helper"', + if (helperText != null) 'helperText: "$helperText"', + if (helperMaxLines != null) 'helperMaxLines: "$helperMaxLines"', + if (hintText != null) 'hintText: "$hintText"', + if (hint != null) 'hint: $hint', + if (hintMaxLines != null) 'hintMaxLines: "$hintMaxLines"', + if (hintFadeDuration != null) 'hintFadeDuration: "$hintFadeDuration"', + if (!maintainHintHeight) 'maintainHintHeight: false', + if (!maintainHintSize) 'maintainHintSize: false', + if (maintainLabelSize) 'maintainLabelSize: true', + if (error != null) 'error: "$error"', + if (errorText != null) 'errorText: "$errorText"', + if (errorStyle != null) 'errorStyle: "$errorStyle"', + if (errorMaxLines != null) 'errorMaxLines: "$errorMaxLines"', + if (floatingLabelBehavior != null) 'floatingLabelBehavior: $floatingLabelBehavior', + if (floatingLabelAlignment != null) 'floatingLabelAlignment: $floatingLabelAlignment', + if (isDense ?? false) 'isDense: $isDense', + if (contentPadding != null) 'contentPadding: $contentPadding', + if (isCollapsed ?? false) 'isCollapsed: $isCollapsed', + if (prefixIcon != null) 'prefixIcon: $prefixIcon', + if (prefixIconColor != null) 'prefixIconColor: $prefixIconColor', + if (prefix != null) 'prefix: $prefix', + if (prefixText != null) 'prefixText: $prefixText', + if (prefixStyle != null) 'prefixStyle: $prefixStyle', + if (prefixIconConstraints != null) 'prefixIconConstraints: $prefixIconConstraints', + if (suffixIcon != null) 'suffixIcon: $suffixIcon', + if (suffixIconColor != null) 'suffixIconColor: $suffixIconColor', + if (suffix != null) 'suffix: $suffix', + if (suffixText != null) 'suffixText: $suffixText', + if (suffixStyle != null) 'suffixStyle: $suffixStyle', + if (suffixIconConstraints != null) 'suffixIconConstraints: $suffixIconConstraints', + if (counter != null) 'counter: $counter', + if (counterText != null) 'counterText: $counterText', + if (counterStyle != null) 'counterStyle: $counterStyle', + if (filled ?? false) 'filled: true', + if (fillColor != null) 'fillColor: $fillColor', + if (focusColor != null) 'focusColor: $focusColor', + if (hoverColor != null) 'hoverColor: $hoverColor', + if (errorBorder != null) 'errorBorder: $errorBorder', + if (focusedBorder != null) 'focusedBorder: $focusedBorder', + if (focusedErrorBorder != null) 'focusedErrorBorder: $focusedErrorBorder', + if (disabledBorder != null) 'disabledBorder: $disabledBorder', + if (enabledBorder != null) 'enabledBorder: $enabledBorder', + if (border != null) 'border: $border', + if (!enabled) 'enabled: false', + if (semanticCounterText != null) 'semanticCounterText: $semanticCounterText', + if (alignLabelWithHint != null) 'alignLabelWithHint: $alignLabelWithHint', + if (constraints != null) 'constraints: $constraints', + if (visualDensity != null) 'visualDensity: $visualDensity', + ]; + return 'InputDecoration(${description.join(', ')})'; + } +} + +/// Defines the default appearance of [InputDecorator]s. +/// +/// Descendant widgets obtain the current theme's [InputDecorationThemeData] using +/// [InputDecorationTheme.of]. When a widget uses [InputDecorationTheme.of], it is +/// automatically rebuilt if the theme later changes. +/// +/// See also: +/// +/// * [InputDecorationThemeData], which describes the actual configuration of an +/// input decoration theme. +/// * [ThemeData.inputDecorationTheme], which specifies an input decoration theme as +/// part of the overall Material theme. +class InputDecorationTheme extends InheritedTheme with Diagnosticable { + /// Creates a [InputDecorationTheme] that controls visual parameters for + /// descendant [InputDecorator]s. + const InputDecorationTheme({ + super.key, + TextStyle? labelStyle, + TextStyle? floatingLabelStyle, + TextStyle? helperStyle, + int? helperMaxLines, + TextStyle? hintStyle, + Duration? hintFadeDuration, + int? hintMaxLines, + TextStyle? errorStyle, + int? errorMaxLines, + FloatingLabelBehavior? floatingLabelBehavior, + FloatingLabelAlignment? floatingLabelAlignment, + bool? isDense, + EdgeInsetsGeometry? contentPadding, + bool? isCollapsed, + Color? iconColor, + TextStyle? prefixStyle, + Color? prefixIconColor, + BoxConstraints? prefixIconConstraints, + TextStyle? suffixStyle, + Color? suffixIconColor, + BoxConstraints? suffixIconConstraints, + TextStyle? counterStyle, + bool? filled, + Color? fillColor, + BorderSide? activeIndicatorBorder, + BorderSide? outlineBorder, + Color? focusColor, + Color? hoverColor, + InputBorder? errorBorder, + InputBorder? focusedBorder, + InputBorder? focusedErrorBorder, + InputBorder? disabledBorder, + InputBorder? enabledBorder, + InputBorder? border, + bool? alignLabelWithHint, + BoxConstraints? constraints, + VisualDensity? visualDensity, + InputDecorationThemeData? data, + Widget? child, + }) : assert( + data == null || + (labelStyle ?? + floatingLabelStyle ?? + helperStyle ?? + helperMaxLines ?? + hintStyle ?? + hintFadeDuration ?? + hintMaxLines ?? + errorStyle ?? + errorMaxLines ?? + floatingLabelBehavior ?? + floatingLabelAlignment ?? + isDense ?? + contentPadding ?? + isCollapsed ?? + iconColor ?? + prefixStyle ?? + prefixIconColor ?? + prefixIconConstraints ?? + suffixStyle ?? + suffixIconColor ?? + suffixIconConstraints ?? + counterStyle ?? + filled ?? + fillColor ?? + activeIndicatorBorder ?? + outlineBorder ?? + focusColor ?? + hoverColor ?? + errorBorder ?? + focusedBorder ?? + focusedErrorBorder ?? + disabledBorder ?? + enabledBorder ?? + border ?? + alignLabelWithHint ?? + constraints ?? + visualDensity) == + null, + ), + _labelStyle = labelStyle, + _floatingLabelStyle = floatingLabelStyle, + _helperStyle = helperStyle, + _helperMaxLines = helperMaxLines, + _hintStyle = hintStyle, + _hintFadeDuration = hintFadeDuration, + _hintMaxLines = hintMaxLines, + _errorStyle = errorStyle, + _errorMaxLines = errorMaxLines, + _floatingLabelBehavior = floatingLabelBehavior ?? FloatingLabelBehavior.auto, + _floatingLabelAlignment = floatingLabelAlignment ?? FloatingLabelAlignment.start, + _isDense = isDense ?? false, + _contentPadding = contentPadding, + _isCollapsed = isCollapsed ?? false, + _iconColor = iconColor, + _prefixStyle = prefixStyle, + _prefixIconColor = prefixIconColor, + _prefixIconConstraints = prefixIconConstraints, + _suffixStyle = suffixStyle, + _suffixIconColor = suffixIconColor, + _suffixIconConstraints = suffixIconConstraints, + _counterStyle = counterStyle, + _filled = filled ?? false, + _fillColor = fillColor, + _activeIndicatorBorder = activeIndicatorBorder, + _outlineBorder = outlineBorder, + _focusColor = focusColor, + _hoverColor = hoverColor, + _errorBorder = errorBorder, + _focusedBorder = focusedBorder, + _focusedErrorBorder = focusedErrorBorder, + _disabledBorder = disabledBorder, + _enabledBorder = enabledBorder, + _border = border, + _alignLabelWithHint = alignLabelWithHint ?? false, + _constraints = constraints, + _visualDensity = visualDensity, + _data = data, + super(child: child ?? const SizedBox.shrink()); + + final InputDecorationThemeData? _data; + final TextStyle? _labelStyle; + final TextStyle? _floatingLabelStyle; + final TextStyle? _helperStyle; + final int? _helperMaxLines; + final TextStyle? _hintStyle; + final Duration? _hintFadeDuration; + final int? _hintMaxLines; + final TextStyle? _errorStyle; + final int? _errorMaxLines; + final FloatingLabelBehavior _floatingLabelBehavior; + final FloatingLabelAlignment _floatingLabelAlignment; + final bool _isDense; + final EdgeInsetsGeometry? _contentPadding; + final bool _isCollapsed; + final Color? _iconColor; + final TextStyle? _prefixStyle; + final Color? _prefixIconColor; + final BoxConstraints? _prefixIconConstraints; + final TextStyle? _suffixStyle; + final Color? _suffixIconColor; + final BoxConstraints? _suffixIconConstraints; + final TextStyle? _counterStyle; + final bool _filled; + final Color? _fillColor; + final BorderSide? _activeIndicatorBorder; + final BorderSide? _outlineBorder; + final Color? _focusColor; + final Color? _hoverColor; + final InputBorder? _errorBorder; + final InputBorder? _focusedBorder; + final InputBorder? _focusedErrorBorder; + final InputBorder? _disabledBorder; + final InputBorder? _enabledBorder; + final InputBorder? _border; + final bool _alignLabelWithHint; + final BoxConstraints? _constraints; + final VisualDensity? _visualDensity; + + /// Overrides the default value for [InputDecoration.labelStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.labelStyle] property in [data] instead. + TextStyle? get labelStyle => _data != null ? _data.labelStyle : _labelStyle; + + /// Overrides the default value for [InputDecoration.floatingLabelStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.floatingLabelStyle] property in [data] instead. + TextStyle? get floatingLabelStyle => + _data != null ? _data.floatingLabelStyle : _floatingLabelStyle; + + /// Overrides the default value for [InputDecoration.floatingLabelStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.helperStyle] property in [data] instead. + TextStyle? get helperStyle => _data != null ? _data.helperStyle : _helperStyle; + + /// Overrides the default value for [InputDecoration.helperMaxLines]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.helperMaxLines] property in [data] instead. + int? get helperMaxLines => _data != null ? _data.helperMaxLines : _helperMaxLines; + + /// Overrides the default value for [InputDecoration.hintStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.hintStyle] property in [data] instead. + TextStyle? get hintStyle => _data != null ? _data.hintStyle : _hintStyle; + + /// Overrides the default value for [InputDecoration.hintFadeDuration]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.hintFadeDuration] property in [data] instead. + Duration? get hintFadeDuration => _data != null ? _data.hintFadeDuration : _hintFadeDuration; + + /// Overrides the default value for [InputDecoration.hintMaxLines]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.hintMaxLines] property in [data] instead. + int? get hintMaxLines => _data != null ? _data.hintMaxLines : _hintMaxLines; + + /// Overrides the default value for [InputDecoration.errorStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.errorStyle] property in [data] instead. + TextStyle? get errorStyle => _data != null ? _data.errorStyle : _errorStyle; + + /// Overrides the default value for [InputDecoration.errorMaxLines]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.errorMaxLines] property in [data] instead. + int? get errorMaxLines => _data != null ? _data.errorMaxLines : _errorMaxLines; + + /// Overrides the default value for [InputDecoration.floatingLabelBehavior]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.floatingLabelBehavior] property in [data] instead. + FloatingLabelBehavior get floatingLabelBehavior => + _data != null ? _data.floatingLabelBehavior : _floatingLabelBehavior; + + /// Overrides the default value for [InputDecoration.floatingLabelAlignment]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.floatingLabelAlignment] property in [data] instead. + FloatingLabelAlignment get floatingLabelAlignment => + _data != null ? _data.floatingLabelAlignment : _floatingLabelAlignment; + + /// Overrides the default value for [InputDecoration.isDense]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.isDense] property in [data] instead. + bool get isDense => _data != null ? _data.isDense : _isDense; + + /// Overrides the default value for [InputDecoration.contentPadding]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.contentPadding] property in [data] instead. + EdgeInsetsGeometry? get contentPadding => _data != null ? _data.contentPadding : _contentPadding; + + /// Overrides the default value for [InputDecoration.isCollapsed]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.isCollapsed] property in [data] instead. + bool get isCollapsed => _data != null ? _data.isCollapsed : _isCollapsed; + + /// Overrides the default value for [InputDecoration.iconColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.iconColor] property in [data] instead. + Color? get iconColor => _data != null ? _data.iconColor : _iconColor; + + /// Overrides the default value for [InputDecoration.prefixStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.prefixStyle] property in [data] instead. + TextStyle? get prefixStyle => _data != null ? _data.prefixStyle : _prefixStyle; + + /// Overrides the default value for [InputDecoration.prefixIconColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.prefixIconColor] property in [data] instead. + Color? get prefixIconColor => _data != null ? _data.prefixIconColor : _prefixIconColor; + + /// Overrides the default value for [InputDecoration.prefixIconConstraints]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.prefixIconConstraints] property in [data] instead. + BoxConstraints? get prefixIconConstraints => + _data != null ? _data.prefixIconConstraints : _prefixIconConstraints; + + /// Overrides the default value for [InputDecoration.suffixStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.suffixStyle] property in [data] instead. + TextStyle? get suffixStyle => _data != null ? _data.suffixStyle : _suffixStyle; + + /// Overrides the default value for [InputDecoration.suffixIconColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.suffixIconColor] property in [data] instead. + Color? get suffixIconColor => _data != null ? _data.suffixIconColor : _suffixIconColor; + + /// Overrides the default value for [InputDecoration.suffixIconConstraints]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.suffixIconConstraints] property in [data] instead. + BoxConstraints? get suffixIconConstraints => + _data != null ? _data.suffixIconConstraints : _suffixIconConstraints; + + /// Overrides the default value for [InputDecoration.counterStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.counterStyle] property in [data] instead. + TextStyle? get counterStyle => _data != null ? _data.counterStyle : _counterStyle; + + /// Overrides the default value for [InputDecoration.filled]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.filled] property in [data] instead. + bool get filled => _data != null ? _data.filled : _filled; + + /// Overrides the default value for [InputDecoration.fillColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.fillColor] property in [data] instead. + Color? get fillColor => _data != null ? _data.fillColor : _fillColor; + + /// The borderSide of the UnderlineInputBorder with `color` and `weight`. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.activeIndicatorBorder] property in [data] instead. + BorderSide? get activeIndicatorBorder => + _data != null ? _data.activeIndicatorBorder : _activeIndicatorBorder; + + /// The borderSide of the OutlineInputBorder with `color` and `weight`. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.outlineBorder] property in [data] instead. + BorderSide? get outlineBorder => _data != null ? _data.outlineBorder : _outlineBorder; + + /// Overrides the default value for [InputDecoration.focusColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.focusColor] property in [data] instead. + Color? get focusColor => _data != null ? _data.focusColor : _focusColor; + + /// Overrides the default value for [InputDecoration.hoverColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.hoverColor] property in [data] instead. + Color? get hoverColor => _data != null ? _data.hoverColor : _hoverColor; + + /// Overrides the default value for [InputDecoration.errorBorder]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.errorBorder] property in [data] instead. + InputBorder? get errorBorder => _data != null ? _data.errorBorder : _errorBorder; + + /// Overrides the default value for [InputDecoration.focusedBorder]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.focusedBorder] property in [data] instead. + InputBorder? get focusedBorder => _data != null ? _data.focusedBorder : _focusedBorder; + + /// Overrides the default value for [InputDecoration.focusedErrorBorder]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.focusedErrorBorder] property in [data] instead. + InputBorder? get focusedErrorBorder => + _data != null ? _data.focusedErrorBorder : _focusedErrorBorder; + + /// Overrides the default value for [InputDecoration.disabledBorder]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.disabledBorder] property in [data] instead. + InputBorder? get disabledBorder => _data != null ? _data.disabledBorder : _disabledBorder; + + /// Overrides the default value for [InputDecoration.enabledBorder]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.enabledBorder] property in [data] instead. + InputBorder? get enabledBorder => _data != null ? _data.enabledBorder : _enabledBorder; + + /// Overrides the default value for [InputDecoration.border]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.border] property in [data] instead. + InputBorder? get border => _data != null ? _data.border : _border; + + /// Overrides the default value for [InputDecoration.alignLabelWithHint]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.alignLabelWithHint] property in [data] instead. + bool get alignLabelWithHint => _data != null ? _data.alignLabelWithHint : _alignLabelWithHint; + + /// Overrides the default value for [InputDecoration.constraints]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.constraints] property in [data] instead. + BoxConstraints? get constraints => _data != null ? _data.constraints : _constraints; + + /// Overrides the default value for [InputDecoration.visualDensity]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.visualDensity] property in [data] instead. + VisualDensity? get visualDensity => _data != null ? _data.visualDensity : _visualDensity; + + /// The properties used for all descendant [TabBar] widgets. + InputDecorationThemeData get data => + _data ?? + InputDecorationThemeData( + labelStyle: _labelStyle, + floatingLabelStyle: _floatingLabelStyle, + helperStyle: _helperStyle, + helperMaxLines: _helperMaxLines, + hintStyle: _hintStyle, + hintFadeDuration: _hintFadeDuration, + hintMaxLines: _hintMaxLines, + errorStyle: _errorStyle, + errorMaxLines: _errorMaxLines, + floatingLabelBehavior: _floatingLabelBehavior, + floatingLabelAlignment: _floatingLabelAlignment, + isDense: _isDense, + contentPadding: _contentPadding, + isCollapsed: _isCollapsed, + iconColor: _iconColor, + prefixStyle: _prefixStyle, + prefixIconColor: _prefixIconColor, + prefixIconConstraints: _prefixIconConstraints, + suffixStyle: _suffixStyle, + suffixIconColor: _suffixIconColor, + suffixIconConstraints: _suffixIconConstraints, + counterStyle: _counterStyle, + filled: _filled, + fillColor: _fillColor, + activeIndicatorBorder: _activeIndicatorBorder, + outlineBorder: _outlineBorder, + focusColor: _focusColor, + hoverColor: _hoverColor, + errorBorder: _errorBorder, + focusedBorder: _focusedBorder, + focusedErrorBorder: _focusedErrorBorder, + disabledBorder: _disabledBorder, + enabledBorder: _enabledBorder, + border: _border, + alignLabelWithHint: _alignLabelWithHint, + constraints: _constraints, + visualDensity: _visualDensity, + ); + + /// Returns the closest [InputDecorationThemeData] instance given the build context. + /// + /// If there is no enclosing [InputDecorationTheme] widget, then + /// [ThemeData.inputDecorationTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// InputDecorationThemeData theme = InputDecorationTheme.of(context); + /// ``` + static InputDecorationThemeData of(BuildContext context) { + final InputDecorationTheme? inputDecorationTheme = context + .dependOnInheritedWidgetOfExactType<InputDecorationTheme>(); + return inputDecorationTheme?.data ?? Theme.of(context).inputDecorationTheme; + } + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [InputDecorationThemeData.copyWith] instead. + InputDecorationTheme copyWith({ + TextStyle? labelStyle, + TextStyle? floatingLabelStyle, + TextStyle? helperStyle, + int? helperMaxLines, + TextStyle? hintStyle, + Duration? hintFadeDuration, + int? hintMaxLines, + TextStyle? errorStyle, + int? errorMaxLines, + FloatingLabelBehavior? floatingLabelBehavior, + FloatingLabelAlignment? floatingLabelAlignment, + bool? isDense, + EdgeInsetsGeometry? contentPadding, + bool? isCollapsed, + Color? iconColor, + TextStyle? prefixStyle, + Color? prefixIconColor, + BoxConstraints? prefixIconConstraints, + TextStyle? suffixStyle, + Color? suffixIconColor, + BoxConstraints? suffixIconConstraints, + TextStyle? counterStyle, + bool? filled, + Color? fillColor, + BorderSide? activeIndicatorBorder, + BorderSide? outlineBorder, + Color? focusColor, + Color? hoverColor, + InputBorder? errorBorder, + InputBorder? focusedBorder, + InputBorder? focusedErrorBorder, + InputBorder? disabledBorder, + InputBorder? enabledBorder, + InputBorder? border, + bool? alignLabelWithHint, + BoxConstraints? constraints, + VisualDensity? visualDensity, + }) { + return InputDecorationTheme( + labelStyle: labelStyle ?? this.labelStyle, + floatingLabelStyle: floatingLabelStyle ?? this.floatingLabelStyle, + helperStyle: helperStyle ?? this.helperStyle, + helperMaxLines: helperMaxLines ?? this.helperMaxLines, + hintStyle: hintStyle ?? this.hintStyle, + hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration, + hintMaxLines: hintMaxLines ?? this.hintMaxLines, + errorStyle: errorStyle ?? this.errorStyle, + errorMaxLines: errorMaxLines ?? this.errorMaxLines, + floatingLabelBehavior: floatingLabelBehavior ?? this.floatingLabelBehavior, + floatingLabelAlignment: floatingLabelAlignment ?? this.floatingLabelAlignment, + isDense: isDense ?? this.isDense, + contentPadding: contentPadding ?? this.contentPadding, + iconColor: iconColor ?? this.iconColor, + isCollapsed: isCollapsed ?? this.isCollapsed, + prefixStyle: prefixStyle ?? this.prefixStyle, + prefixIconColor: prefixIconColor ?? this.prefixIconColor, + prefixIconConstraints: prefixIconConstraints ?? this.prefixIconConstraints, + suffixStyle: suffixStyle ?? this.suffixStyle, + suffixIconColor: suffixIconColor ?? this.suffixIconColor, + suffixIconConstraints: suffixIconConstraints ?? this.suffixIconConstraints, + counterStyle: counterStyle ?? this.counterStyle, + filled: filled ?? this.filled, + fillColor: fillColor ?? this.fillColor, + activeIndicatorBorder: activeIndicatorBorder ?? this.activeIndicatorBorder, + outlineBorder: outlineBorder ?? this.outlineBorder, + focusColor: focusColor ?? this.focusColor, + hoverColor: hoverColor ?? this.hoverColor, + errorBorder: errorBorder ?? this.errorBorder, + focusedBorder: focusedBorder ?? this.focusedBorder, + focusedErrorBorder: focusedErrorBorder ?? this.focusedErrorBorder, + disabledBorder: disabledBorder ?? this.disabledBorder, + enabledBorder: enabledBorder ?? this.enabledBorder, + border: border ?? this.border, + alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint, + constraints: constraints ?? this.constraints, + visualDensity: visualDensity ?? this.visualDensity, + ); + } + + /// Returns a copy of this InputDecorationTheme where the non-null fields in + /// the given InputDecorationTheme override the corresponding nullable fields + /// in this InputDecorationTheme. + /// + /// The non-nullable fields of InputDecorationTheme, such as [floatingLabelBehavior], + /// [isDense], [isCollapsed], [filled], and [alignLabelWithHint] cannot be overridden. + /// + /// In other words, the fields of the provided [InputDecorationTheme] are used to + /// fill in the unspecified and nullable fields of this InputDecorationTheme. + InputDecorationTheme merge(InputDecorationTheme? other) { + if (other == null) { + return this; + } + return copyWith( + labelStyle: labelStyle ?? other.labelStyle, + floatingLabelStyle: floatingLabelStyle ?? other.floatingLabelStyle, + helperStyle: helperStyle ?? other.helperStyle, + helperMaxLines: helperMaxLines ?? other.helperMaxLines, + hintStyle: hintStyle ?? other.hintStyle, + hintFadeDuration: hintFadeDuration ?? other.hintFadeDuration, + hintMaxLines: hintMaxLines ?? other.hintMaxLines, + errorStyle: errorStyle ?? other.errorStyle, + errorMaxLines: errorMaxLines ?? other.errorMaxLines, + contentPadding: contentPadding ?? other.contentPadding, + iconColor: iconColor ?? other.iconColor, + prefixStyle: prefixStyle ?? other.prefixStyle, + prefixIconColor: prefixIconColor ?? other.prefixIconColor, + prefixIconConstraints: prefixIconConstraints ?? other.prefixIconConstraints, + suffixStyle: suffixStyle ?? other.suffixStyle, + suffixIconColor: suffixIconColor ?? other.suffixIconColor, + suffixIconConstraints: suffixIconConstraints ?? other.suffixIconConstraints, + counterStyle: counterStyle ?? other.counterStyle, + fillColor: fillColor ?? other.fillColor, + activeIndicatorBorder: activeIndicatorBorder ?? other.activeIndicatorBorder, + outlineBorder: outlineBorder ?? other.outlineBorder, + focusColor: focusColor ?? other.focusColor, + hoverColor: hoverColor ?? other.hoverColor, + errorBorder: errorBorder ?? other.errorBorder, + focusedBorder: focusedBorder ?? other.focusedBorder, + focusedErrorBorder: focusedErrorBorder ?? other.focusedErrorBorder, + disabledBorder: disabledBorder ?? other.disabledBorder, + enabledBorder: enabledBorder ?? other.enabledBorder, + border: border ?? other.border, + constraints: constraints ?? other.constraints, + visualDensity: visualDensity ?? other.visualDensity, + ); + } + + @override + bool updateShouldNotify(InputDecorationTheme oldWidget) => data != oldWidget.data; + + @override + Widget wrap(BuildContext context, Widget child) { + return InputDecorationTheme(data: data, child: child); + } +} + +/// Defines the default appearance of [InputDecorator]s. +/// +/// Descendant widgets obtain the current theme's [InputDecorationThemeData] using +/// [InputDecorationTheme.of]. When a widget uses [InputDecorationTheme.of], it is +/// automatically rebuilt if the theme later changes. +/// +/// The [InputDecoration.applyDefaults] method is used to combine an input +/// decoration theme with an [InputDecoration] object. +/// +/// See also: +/// +/// * [ThemeData.inputDecorationTheme], which specifies an input decoration theme as +/// part of the overall Material theme. +@immutable +class InputDecorationThemeData with Diagnosticable { + /// Creates a [InputDecorationThemeData] that can be used to override default + /// properties in a [InputDecorationTheme] widget. + const InputDecorationThemeData({ + this.labelStyle, + this.floatingLabelStyle, + this.helperStyle, + this.helperMaxLines, + this.hintStyle, + this.hintFadeDuration, + this.hintMaxLines, + this.errorStyle, + this.errorMaxLines, + this.floatingLabelBehavior = FloatingLabelBehavior.auto, + this.floatingLabelAlignment = FloatingLabelAlignment.start, + this.isDense = false, + this.contentPadding, + this.isCollapsed = false, + this.iconColor, + this.prefixStyle, + this.prefixIconColor, + this.prefixIconConstraints, + this.suffixStyle, + this.suffixIconColor, + this.suffixIconConstraints, + this.counterStyle, + this.filled = false, + this.fillColor, + this.activeIndicatorBorder, + this.outlineBorder, + this.focusColor, + this.hoverColor, + this.errorBorder, + this.focusedBorder, + this.focusedErrorBorder, + this.disabledBorder, + this.enabledBorder, + this.border, + this.alignLabelWithHint = false, + this.constraints, + this.visualDensity, + }); + + /// {@macro flutter.material.inputDecoration.labelStyle} + final TextStyle? labelStyle; + + /// {@macro flutter.material.inputDecoration.floatingLabelStyle} + final TextStyle? floatingLabelStyle; + + /// The style to use for [InputDecoration.helperText]. + /// + /// If [helperStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + final TextStyle? helperStyle; + + /// The maximum number of lines the [InputDecoration.helperText] can occupy. + /// + /// Defaults to null, which means that the [InputDecoration.helperText] will + /// be limited to a single line with [TextOverflow.ellipsis]. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the helper. + /// + /// See also: + /// + /// * [errorMaxLines], the equivalent but for the [InputDecoration.errorText]. + final int? helperMaxLines; + + /// The style to use for the [InputDecoration.hintText]. + /// + /// If [hintStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// Also used for the [InputDecoration.labelText] when the + /// [InputDecoration.labelText] is displayed on top of the input field (i.e., + /// at the same location on the screen where text may be entered in the input + /// field). + /// + /// If null, defaults to a value derived from the base [TextStyle] for the + /// input field and the current [Theme]. + final TextStyle? hintStyle; + + /// The duration of the [InputDecoration.hintText] fade in and fade out animations. + final Duration? hintFadeDuration; + + /// The maximum number of lines the [InputDecoration.hintText] can occupy. + /// + /// Defaults to null, which means that the [InputDecoration.hintText] will + /// be limited to a single line with [TextOverflow.ellipsis]. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the hint text. + final int? hintMaxLines; + + /// {@macro flutter.material.inputDecoration.errorStyle} + final TextStyle? errorStyle; + + /// The maximum number of lines the [InputDecoration.errorText] can occupy. + /// + /// Defaults to null, which means that the [InputDecoration.errorText] will be + /// limited to a single line with [TextOverflow.ellipsis]. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the error. + /// + /// See also: + /// + /// * [helperMaxLines], the equivalent but for the [InputDecoration.helperText]. + final int? errorMaxLines; + + /// {@macro flutter.material.inputDecoration.floatingLabelBehavior} + /// + /// Defaults to [FloatingLabelBehavior.auto]. + final FloatingLabelBehavior floatingLabelBehavior; + + /// {@macro flutter.material.inputDecoration.floatingLabelAlignment} + /// + /// Defaults to [FloatingLabelAlignment.start]. + final FloatingLabelAlignment floatingLabelAlignment; + + /// Whether the input decorator's child is part of a dense form (i.e., uses + /// less vertical space). + /// + /// Defaults to false. + final bool isDense; + + /// The padding for the input decoration's container. + /// + /// The decoration's container is the area which is filled if + /// [InputDecoration.filled] is true and bordered per the [border]. + /// It's the area adjacent to [InputDecoration.icon] and above the + /// [InputDecoration.icon] and above the widgets that contain + /// [InputDecoration.helperText], [InputDecoration.errorText], and + /// [InputDecoration.counterText]. + /// + /// By default the [contentPadding] reflects [visualDensity], [isDense] and + /// the type of the [border]. If [isCollapsed] is true then [contentPadding] + /// is [EdgeInsets.zero]. + final EdgeInsetsGeometry? contentPadding; + + /// Whether the decoration is the same size as the input field. + /// + /// A collapsed decoration cannot have [InputDecoration.labelText], + /// [InputDecoration.errorText], or an [InputDecoration.icon]. + final bool isCollapsed; + + /// The Color to use for the [InputDecoration.icon]. + /// + /// If [iconColor] is a [WidgetStateColor], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [ColorScheme.primary]. + final Color? iconColor; + + /// The style to use for the [InputDecoration.prefixText]. + /// + /// If [prefixStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [hintStyle]. + final TextStyle? prefixStyle; + + /// The Color to use for the [InputDecoration.prefixIcon]. + /// + /// If [prefixIconColor] is a [WidgetStateColor], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [ColorScheme.primary]. + final Color? prefixIconColor; + + /// The constraints to use for [InputDecoration.prefixIconConstraints]. + /// + /// This can be used to modify the [BoxConstraints] surrounding + /// [InputDecoration.prefixIcon]. + /// + /// This property is particularly useful for getting the decoration's height + /// less than the minimum tappable height (which is 48px when the visual + /// density is set to [VisualDensity.standard]). This can be achieved by + /// setting [isDense] to true and setting the constraints' minimum height + /// and width to a value lower than the minimum tappable size. + /// + /// If null, [BoxConstraints] with a minimum width and height of 48px is + /// used. + final BoxConstraints? prefixIconConstraints; + + /// The style to use for the [InputDecoration.suffixText]. + /// + /// If [suffixStyle] is a [WidgetStateTextStyle], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [hintStyle]. + final TextStyle? suffixStyle; + + /// The Color to use for the [InputDecoration.suffixIcon]. + /// + /// If [suffixIconColor] is a [WidgetStateColor], then the effective + /// color can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [ColorScheme.primary]. + final Color? suffixIconColor; + + /// The constraints to use for [InputDecoration.suffixIconConstraints]. + /// + /// This can be used to modify the [BoxConstraints] surrounding + /// [InputDecoration.suffixIcon]. + /// + /// This property is particularly useful for getting the decoration's height + /// less than the minimum tappable height (which is 48px when the visual + /// density is set to [VisualDensity.standard]). This can be achieved by + /// setting [isDense] to true and setting the constraints' minimum height + /// and width to a value lower than the minimum tappable size. + /// + /// If null, [BoxConstraints] with a minimum width and height of 48px is + /// used. + final BoxConstraints? suffixIconConstraints; + + /// The style to use for the [InputDecoration.counterText]. + /// + /// If [counterStyle] is a [WidgetStateTextStyle], then the effective + /// text style can depend on the [WidgetState.focused] state, i.e. + /// if the [TextField] is focused or not. + /// + /// If null, defaults to the [helperStyle]. + final TextStyle? counterStyle; + + /// If true the decoration's container is filled with [fillColor]. + /// + /// Typically this field set to true if [border] is an + /// [UnderlineInputBorder]. + /// + /// The decoration's container is the area, defined by the border's + /// [InputBorder.getOuterPath], which is filled if [filled] is + /// true and bordered per the [border]. + /// + /// This property is false by default. + final bool filled; + + /// The color to fill the decoration's container with, if [filled] is true. + /// + /// By default the fillColor is based on the current [Theme]. + /// + /// The decoration's container is the area, defined by the border's + /// [InputBorder.getOuterPath], which is filled if [filled] is + /// true and bordered per the [border]. + final Color? fillColor; + + /// The borderSide of the OutlineInputBorder with `color` and `weight`. + final BorderSide? outlineBorder; + + /// The borderSide of the UnderlineInputBorder with `color` and `weight`. + final BorderSide? activeIndicatorBorder; + + /// The color to blend with the decoration's [fillColor] with, if [filled] is + /// true and the container has the input focus. + /// + /// By default the [focusColor] is based on the current [Theme]. + /// + /// The decoration's container is the area, defined by the border's + /// [InputBorder.getOuterPath], which is filled if [filled] is + /// true and bordered per the [border]. + final Color? focusColor; + + /// The color to blend with the decoration's [fillColor] with, if the + /// decoration is being hovered over by a mouse pointer. + /// + /// By default the [hoverColor] is based on the current [Theme]. + /// + /// The decoration's container is the area, defined by the border's + /// [InputBorder.getOuterPath], which is filled if [filled] is + /// true and bordered per the [border]. + /// + /// The container will be filled when hovered over even if [filled] is false. + final Color? hoverColor; + + /// The border to display when the [InputDecorator] does not have the focus and + /// is showing an error. + /// + /// See also: + /// + /// * [InputDecorator.isFocused], which is true if the [InputDecorator]'s child + /// has the focus. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? errorBorder; + + /// The border to display when the [InputDecorator] has the focus and is not + /// showing an error. + /// + /// See also: + /// + /// * [InputDecorator.isFocused], which is true if the [InputDecorator]'s child + /// has the focus. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? focusedBorder; + + /// The border to display when the [InputDecorator] has the focus and is + /// showing an error. + /// + /// See also: + /// + /// * [InputDecorator.isFocused], which is true if the [InputDecorator]'s child + /// has the focus. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? focusedErrorBorder; + + /// The border to display when the [InputDecorator] is disabled and is not + /// showing an error. + /// + /// See also: + /// + /// * [InputDecoration.enabled], which is false if the [InputDecorator] is disabled. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [enabledBorder], displayed when [InputDecoration.enabled] is true + /// and [InputDecoration.errorText] is null. + final InputBorder? disabledBorder; + + /// The border to display when the [InputDecorator] is enabled and is not + /// showing an error. + /// + /// See also: + /// + /// * [InputDecoration.enabled], which is false if the [InputDecorator] is disabled. + /// * [InputDecoration.errorText], the error shown by the [InputDecorator], if non-null. + /// * [border], for a description of where the [InputDecorator] border appears. + /// * [UnderlineInputBorder], an [InputDecorator] border which draws a horizontal + /// line at the bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + /// * [InputBorder.none], which doesn't draw a border. + /// * [errorBorder], displayed when [InputDecorator.isFocused] is false + /// and [InputDecoration.errorText] is non-null. + /// * [focusedBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is null. + /// * [focusedErrorBorder], displayed when [InputDecorator.isFocused] is true + /// and [InputDecoration.errorText] is non-null. + /// * [disabledBorder], displayed when [InputDecoration.enabled] is false + /// and [InputDecoration.errorText] is null. + final InputBorder? enabledBorder; + + /// The shape of the border to draw around the decoration's container. + /// + /// If [border] is a [WidgetStateInputBorder], then the effective border can + /// depend on the [WidgetState.focused] state, i.e. if the [TextField] is + /// focused or not. + /// + /// The decoration's container is the area which is filled if [filled] is + /// true and bordered per the [border]. It's the area adjacent to + /// [InputDecoration.icon] and above the widgets that contain + /// [InputDecoration.helperText], [InputDecoration.errorText], and + /// [InputDecoration.counterText]. + /// + /// The border's bounds, i.e. the value of `border.getOuterPath()`, define + /// the area to be filled. + /// + /// This property is only used when the appropriate one of [errorBorder], + /// [focusedBorder], [focusedErrorBorder], [disabledBorder], or [enabledBorder] + /// is not specified. This border's [InputBorder.borderSide] property is + /// configured by the InputDecorator, depending on the values of + /// [InputDecoration.errorText], [InputDecoration.enabled], + /// [InputDecorator.isFocused] and the current [Theme]. + /// + /// Typically one of [UnderlineInputBorder] or [OutlineInputBorder]. + /// If null, InputDecorator's default is `const UnderlineInputBorder()`. + /// + /// See also: + /// + /// * [InputBorder.none], which doesn't draw a border. + /// * [UnderlineInputBorder], which draws a horizontal line at the + /// bottom of the input decorator's container. + /// * [OutlineInputBorder], an [InputDecorator] border which draws a + /// rounded rectangle around the input decorator's container. + final InputBorder? border; + + /// Typically set to true when the [InputDecorator] contains a multiline + /// [TextField] ([TextField.maxLines] is null or > 1) to override the default + /// behavior of aligning the label with the center of the [TextField]. + final bool alignLabelWithHint; + + /// Defines minimum and maximum sizes for the [InputDecorator]. + /// + /// Typically the decorator will fill the horizontal space it is given. For + /// larger screens, it may be useful to have the maximum width clamped to + /// a given value so it doesn't fill the whole screen. This property + /// allows you to control how big the decorator will be in its available + /// space. + /// + /// If null, then the decorator will fill the available width with + /// a default height based on text size. + /// + /// See also: + /// + /// * [InputDecoration.constraints], which can override this setting for a + /// given decorator. + final BoxConstraints? constraints; + + /// Defines how compact the decoration's layout will be. + /// + /// The vertical aspect of the default or user-specified [contentPadding] is adjusted + /// automatically based on [visualDensity]. + /// + /// When the visual density is [VisualDensity.compact], the vertical aspect of + /// [contentPadding] is reduced by 8 pixels. + /// + /// When the visual density is [VisualDensity.comfortable], the vertical aspect of + /// [contentPadding] is reduced by 4 pixels. + /// + /// When the visual density is [VisualDensity.standard] vertical aspect of + /// [contentPadding] is not changed. + /// + /// If null, defaults to [ThemeData.visualDensity]. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets + /// within a [Theme]. + /// * [InputDecoration.visualDensity], which can override this setting for a + /// given decorator. + final VisualDensity? visualDensity; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + InputDecorationThemeData copyWith({ + TextStyle? labelStyle, + TextStyle? floatingLabelStyle, + TextStyle? helperStyle, + int? helperMaxLines, + TextStyle? hintStyle, + Duration? hintFadeDuration, + int? hintMaxLines, + TextStyle? errorStyle, + int? errorMaxLines, + FloatingLabelBehavior? floatingLabelBehavior, + FloatingLabelAlignment? floatingLabelAlignment, + bool? isDense, + EdgeInsetsGeometry? contentPadding, + bool? isCollapsed, + Color? iconColor, + TextStyle? prefixStyle, + Color? prefixIconColor, + BoxConstraints? prefixIconConstraints, + TextStyle? suffixStyle, + Color? suffixIconColor, + BoxConstraints? suffixIconConstraints, + TextStyle? counterStyle, + bool? filled, + Color? fillColor, + BorderSide? activeIndicatorBorder, + BorderSide? outlineBorder, + Color? focusColor, + Color? hoverColor, + InputBorder? errorBorder, + InputBorder? focusedBorder, + InputBorder? focusedErrorBorder, + InputBorder? disabledBorder, + InputBorder? enabledBorder, + InputBorder? border, + bool? alignLabelWithHint, + BoxConstraints? constraints, + VisualDensity? visualDensity, + }) { + return InputDecorationThemeData( + labelStyle: labelStyle ?? this.labelStyle, + floatingLabelStyle: floatingLabelStyle ?? this.floatingLabelStyle, + helperStyle: helperStyle ?? this.helperStyle, + helperMaxLines: helperMaxLines ?? this.helperMaxLines, + hintStyle: hintStyle ?? this.hintStyle, + hintFadeDuration: hintFadeDuration ?? this.hintFadeDuration, + hintMaxLines: hintMaxLines ?? this.hintMaxLines, + errorStyle: errorStyle ?? this.errorStyle, + errorMaxLines: errorMaxLines ?? this.errorMaxLines, + floatingLabelBehavior: floatingLabelBehavior ?? this.floatingLabelBehavior, + floatingLabelAlignment: floatingLabelAlignment ?? this.floatingLabelAlignment, + isDense: isDense ?? this.isDense, + contentPadding: contentPadding ?? this.contentPadding, + iconColor: iconColor ?? this.iconColor, + isCollapsed: isCollapsed ?? this.isCollapsed, + prefixStyle: prefixStyle ?? this.prefixStyle, + prefixIconColor: prefixIconColor ?? this.prefixIconColor, + prefixIconConstraints: prefixIconConstraints ?? this.prefixIconConstraints, + suffixStyle: suffixStyle ?? this.suffixStyle, + suffixIconColor: suffixIconColor ?? this.suffixIconColor, + suffixIconConstraints: suffixIconConstraints ?? this.suffixIconConstraints, + counterStyle: counterStyle ?? this.counterStyle, + filled: filled ?? this.filled, + fillColor: fillColor ?? this.fillColor, + activeIndicatorBorder: activeIndicatorBorder ?? this.activeIndicatorBorder, + outlineBorder: outlineBorder ?? this.outlineBorder, + focusColor: focusColor ?? this.focusColor, + hoverColor: hoverColor ?? this.hoverColor, + errorBorder: errorBorder ?? this.errorBorder, + focusedBorder: focusedBorder ?? this.focusedBorder, + focusedErrorBorder: focusedErrorBorder ?? this.focusedErrorBorder, + disabledBorder: disabledBorder ?? this.disabledBorder, + enabledBorder: enabledBorder ?? this.enabledBorder, + border: border ?? this.border, + alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint, + constraints: constraints ?? this.constraints, + visualDensity: visualDensity ?? this.visualDensity, + ); + } + + /// Returns a copy of this InputDecorationThemeData where the non-null fields in + /// the given InputDecorationThemeData override the corresponding nullable fields + /// in this InputDecorationThemeData. + /// + /// The non-nullable fields of InputDecorationThemeData, such as + /// [floatingLabelBehavior], [isDense], [isCollapsed], [filled], and + /// [alignLabelWithHint] cannot be overridden. + /// + /// In other words, the fields of the provided [InputDecorationThemeData] + /// are used to fill in the unspecified and nullable fields of this + /// InputDecorationThemeData. + InputDecorationThemeData merge(InputDecorationThemeData? other) { + if (other == null) { + return this; + } + return copyWith( + labelStyle: labelStyle ?? other.labelStyle, + floatingLabelStyle: floatingLabelStyle ?? other.floatingLabelStyle, + helperStyle: helperStyle ?? other.helperStyle, + helperMaxLines: helperMaxLines ?? other.helperMaxLines, + hintStyle: hintStyle ?? other.hintStyle, + hintFadeDuration: hintFadeDuration ?? other.hintFadeDuration, + hintMaxLines: hintMaxLines ?? other.hintMaxLines, + errorStyle: errorStyle ?? other.errorStyle, + errorMaxLines: errorMaxLines ?? other.errorMaxLines, + contentPadding: contentPadding ?? other.contentPadding, + iconColor: iconColor ?? other.iconColor, + prefixStyle: prefixStyle ?? other.prefixStyle, + prefixIconColor: prefixIconColor ?? other.prefixIconColor, + prefixIconConstraints: prefixIconConstraints ?? other.prefixIconConstraints, + suffixStyle: suffixStyle ?? other.suffixStyle, + suffixIconColor: suffixIconColor ?? other.suffixIconColor, + suffixIconConstraints: suffixIconConstraints ?? other.suffixIconConstraints, + counterStyle: counterStyle ?? other.counterStyle, + fillColor: fillColor ?? other.fillColor, + activeIndicatorBorder: activeIndicatorBorder ?? other.activeIndicatorBorder, + outlineBorder: outlineBorder ?? other.outlineBorder, + focusColor: focusColor ?? other.focusColor, + hoverColor: hoverColor ?? other.hoverColor, + errorBorder: errorBorder ?? other.errorBorder, + focusedBorder: focusedBorder ?? other.focusedBorder, + focusedErrorBorder: focusedErrorBorder ?? other.focusedErrorBorder, + disabledBorder: disabledBorder ?? other.disabledBorder, + enabledBorder: enabledBorder ?? other.enabledBorder, + border: border ?? other.border, + constraints: constraints ?? other.constraints, + visualDensity: visualDensity ?? other.visualDensity, + ); + } + + @override + int get hashCode => Object.hash( + labelStyle, + floatingLabelStyle, + helperStyle, + helperMaxLines, + hintStyle, + hintMaxLines, + errorStyle, + errorMaxLines, + floatingLabelBehavior, + floatingLabelAlignment, + isDense, + contentPadding, + isCollapsed, + iconColor, + prefixStyle, + prefixIconColor, + prefixIconConstraints, + suffixStyle, + suffixIconColor, + Object.hash( + suffixIconConstraints, + counterStyle, + filled, + fillColor, + activeIndicatorBorder, + outlineBorder, + focusColor, + hoverColor, + errorBorder, + focusedBorder, + focusedErrorBorder, + disabledBorder, + enabledBorder, + border, + alignLabelWithHint, + constraints, + hintFadeDuration, + visualDensity, + ), + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is InputDecorationThemeData && + other.labelStyle == labelStyle && + other.floatingLabelStyle == floatingLabelStyle && + other.helperStyle == helperStyle && + other.helperMaxLines == helperMaxLines && + other.hintStyle == hintStyle && + other.hintFadeDuration == hintFadeDuration && + other.errorStyle == errorStyle && + other.errorMaxLines == errorMaxLines && + other.isDense == isDense && + other.contentPadding == contentPadding && + other.isCollapsed == isCollapsed && + other.iconColor == iconColor && + other.prefixStyle == prefixStyle && + other.prefixIconColor == prefixIconColor && + other.prefixIconConstraints == prefixIconConstraints && + other.suffixStyle == suffixStyle && + other.suffixIconColor == suffixIconColor && + other.suffixIconConstraints == suffixIconConstraints && + other.counterStyle == counterStyle && + other.floatingLabelBehavior == floatingLabelBehavior && + other.floatingLabelAlignment == floatingLabelAlignment && + other.filled == filled && + other.fillColor == fillColor && + other.activeIndicatorBorder == activeIndicatorBorder && + other.outlineBorder == outlineBorder && + other.focusColor == focusColor && + other.hoverColor == hoverColor && + other.errorBorder == errorBorder && + other.focusedBorder == focusedBorder && + other.focusedErrorBorder == focusedErrorBorder && + other.disabledBorder == disabledBorder && + other.enabledBorder == enabledBorder && + other.border == border && + other.hintMaxLines == hintMaxLines && + other.alignLabelWithHint == alignLabelWithHint && + other.constraints == constraints && + other.disabledBorder == disabledBorder && + other.visualDensity == visualDensity; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultTheme = InputDecorationThemeData(); + properties.add( + DiagnosticsProperty<TextStyle>( + 'labelStyle', + labelStyle, + defaultValue: defaultTheme.labelStyle, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'floatingLabelStyle', + floatingLabelStyle, + defaultValue: defaultTheme.floatingLabelStyle, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'helperStyle', + helperStyle, + defaultValue: defaultTheme.helperStyle, + ), + ); + properties.add( + IntProperty('helperMaxLines', helperMaxLines, defaultValue: defaultTheme.helperMaxLines), + ); + properties.add( + DiagnosticsProperty<TextStyle>('hintStyle', hintStyle, defaultValue: defaultTheme.hintStyle), + ); + properties.add( + DiagnosticsProperty<Duration>( + 'hintFadeDuration', + hintFadeDuration, + defaultValue: defaultTheme.hintFadeDuration, + ), + ); + properties.add( + IntProperty('hintMaxLines', hintMaxLines, defaultValue: defaultTheme.hintMaxLines), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'errorStyle', + errorStyle, + defaultValue: defaultTheme.errorStyle, + ), + ); + properties.add( + IntProperty('errorMaxLines', errorMaxLines, defaultValue: defaultTheme.errorMaxLines), + ); + properties.add( + DiagnosticsProperty<FloatingLabelBehavior>( + 'floatingLabelBehavior', + floatingLabelBehavior, + defaultValue: defaultTheme.floatingLabelBehavior, + ), + ); + properties.add( + DiagnosticsProperty<FloatingLabelAlignment>( + 'floatingLabelAlignment', + floatingLabelAlignment, + defaultValue: defaultTheme.floatingLabelAlignment, + ), + ); + properties.add( + DiagnosticsProperty<bool>('isDense', isDense, defaultValue: defaultTheme.isDense), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'contentPadding', + contentPadding, + defaultValue: defaultTheme.contentPadding, + ), + ); + properties.add( + DiagnosticsProperty<bool>('isCollapsed', isCollapsed, defaultValue: defaultTheme.isCollapsed), + ); + properties.add( + DiagnosticsProperty<Color>('iconColor', iconColor, defaultValue: defaultTheme.iconColor), + ); + properties.add( + DiagnosticsProperty<Color>( + 'prefixIconColor', + prefixIconColor, + defaultValue: defaultTheme.prefixIconColor, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'prefixIconConstraints', + prefixIconConstraints, + defaultValue: defaultTheme.prefixIconConstraints, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'prefixStyle', + prefixStyle, + defaultValue: defaultTheme.prefixStyle, + ), + ); + properties.add( + DiagnosticsProperty<Color>( + 'suffixIconColor', + suffixIconColor, + defaultValue: defaultTheme.suffixIconColor, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'suffixIconConstraints', + suffixIconConstraints, + defaultValue: defaultTheme.suffixIconConstraints, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'suffixStyle', + suffixStyle, + defaultValue: defaultTheme.suffixStyle, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'counterStyle', + counterStyle, + defaultValue: defaultTheme.counterStyle, + ), + ); + properties.add(DiagnosticsProperty<bool>('filled', filled, defaultValue: defaultTheme.filled)); + properties.add(ColorProperty('fillColor', fillColor, defaultValue: defaultTheme.fillColor)); + properties.add( + DiagnosticsProperty<BorderSide>( + 'activeIndicatorBorder', + activeIndicatorBorder, + defaultValue: defaultTheme.activeIndicatorBorder, + ), + ); + properties.add( + DiagnosticsProperty<BorderSide>( + 'outlineBorder', + outlineBorder, + defaultValue: defaultTheme.outlineBorder, + ), + ); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: defaultTheme.focusColor)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: defaultTheme.hoverColor)); + properties.add( + DiagnosticsProperty<InputBorder>( + 'errorBorder', + errorBorder, + defaultValue: defaultTheme.errorBorder, + ), + ); + properties.add( + DiagnosticsProperty<InputBorder>( + 'focusedBorder', + focusedBorder, + defaultValue: defaultTheme.focusedErrorBorder, + ), + ); + properties.add( + DiagnosticsProperty<InputBorder>( + 'focusedErrorBorder', + focusedErrorBorder, + defaultValue: defaultTheme.focusedErrorBorder, + ), + ); + properties.add( + DiagnosticsProperty<InputBorder>( + 'disabledBorder', + disabledBorder, + defaultValue: defaultTheme.disabledBorder, + ), + ); + properties.add( + DiagnosticsProperty<InputBorder>( + 'enabledBorder', + enabledBorder, + defaultValue: defaultTheme.enabledBorder, + ), + ); + properties.add( + DiagnosticsProperty<InputBorder>('border', border, defaultValue: defaultTheme.border), + ); + properties.add( + DiagnosticsProperty<bool>( + 'alignLabelWithHint', + alignLabelWithHint, + defaultValue: defaultTheme.alignLabelWithHint, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>( + 'constraints', + constraints, + defaultValue: defaultTheme.constraints, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>( + 'visualDensity', + visualDensity, + defaultValue: defaultTheme.visualDensity, + ), + ); + } +} + +class _InputDecoratorDefaultsM2 extends InputDecorationThemeData { + const _InputDecoratorDefaultsM2(this.context) : super(); + + final BuildContext context; + + @override + TextStyle? get hintStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: Theme.of(context).disabledColor); + } + return TextStyle(color: Theme.of(context).hintColor); + }); + + @override + TextStyle? get labelStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: Theme.of(context).disabledColor); + } + return TextStyle(color: Theme.of(context).hintColor); + }); + + @override + TextStyle? get floatingLabelStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: Theme.of(context).disabledColor); + } + if (states.contains(WidgetState.error)) { + return TextStyle(color: Theme.of(context).colorScheme.error); + } + if (states.contains(WidgetState.focused)) { + return TextStyle(color: Theme.of(context).colorScheme.primary); + } + return TextStyle(color: Theme.of(context).hintColor); + }); + + @override + TextStyle? get helperStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final ThemeData themeData = Theme.of(context); + if (states.contains(WidgetState.disabled)) { + return themeData.textTheme.bodySmall!.copyWith(color: Colors.transparent); + } + + return themeData.textTheme.bodySmall!.copyWith(color: themeData.hintColor); + }); + + @override + TextStyle? get errorStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final ThemeData themeData = Theme.of(context); + if (states.contains(WidgetState.disabled)) { + return themeData.textTheme.bodySmall!.copyWith(color: Colors.transparent); + } + return themeData.textTheme.bodySmall!.copyWith(color: themeData.colorScheme.error); + }); + + @override + Color? get fillColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + return switch ((Theme.brightnessOf(context), states.contains(WidgetState.disabled))) { + (Brightness.dark, true) => const Color(0x0DFFFFFF), // 5% white + (Brightness.dark, false) => const Color(0x1AFFFFFF), // 10% white + (Brightness.light, true) => const Color(0x05000000), // 2% black + (Brightness.light, false) => const Color(0x0A000000), // 4% black + }; + }); + + @override + Color? get iconColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled) && !states.contains(WidgetState.focused)) { + return Theme.of(context).disabledColor; + } + if (states.contains(WidgetState.focused)) { + return Theme.of(context).colorScheme.primary; + } + return switch (Theme.brightnessOf(context)) { + Brightness.dark => Colors.white70, + Brightness.light => Colors.black45, + }; + }); + + @override + Color? get prefixIconColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled) && !states.contains(WidgetState.focused)) { + return Theme.of(context).disabledColor; + } + if (states.contains(WidgetState.focused)) { + return Theme.of(context).colorScheme.primary; + } + return switch (Theme.brightnessOf(context)) { + Brightness.dark => Colors.white70, + Brightness.light => Colors.black45, + }; + }); + + @override + Color? get suffixIconColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled) && !states.contains(WidgetState.focused)) { + return Theme.of(context).disabledColor; + } + if (states.contains(WidgetState.error)) { + return Theme.of(context).colorScheme.error; + } + if (states.contains(WidgetState.focused)) { + return Theme.of(context).colorScheme.primary; + } + return switch (Theme.brightnessOf(context)) { + Brightness.dark => Colors.white70, + Brightness.light => Colors.black45, + }; + }); +} + +// BEGIN GENERATED TOKEN PROPERTIES - InputDecorator + +// 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. + +// dart format off +class _InputDecoratorDefaultsM3 extends InputDecorationThemeData { + _InputDecoratorDefaultsM3(this.context) + : super(); + + final BuildContext context; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + // For InputDecorator, focused state should take precedence over hovered state. + // For instance, the focused state increases border width (2dp) and applies bright + // colors (primary color or error color) while the hovered state has the same border + // than the non-focused state (1dp) and uses a color a little darker than non-focused + // state. On desktop, it is also very common that a text field is focused and hovered + // because users often rely on mouse selection. + // For other widgets, hovered state takes precedence over focused state, because it + // is mainly used to determine the overlay color, + // see https://github.com/flutter/flutter/pull/125905. + + @override + TextStyle? get hintStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: _colors.onSurface.withOpacity(0.38)); + } + return TextStyle(color: _colors.onSurfaceVariant); + }); + + @override + Color? get fillColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.04); + } + return _colors.surfaceContainerHighest; + }); + + @override + BorderSide? get activeIndicatorBorder => WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: _colors.onSurface.withOpacity(0.38)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _colors.error, width: 2.0); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _colors.onErrorContainer); + } + return BorderSide(color: _colors.error); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _colors.primary, width: 2.0); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _colors.onSurface); + } + return BorderSide(color: _colors.onSurfaceVariant); + }); + + @override + BorderSide? get outlineBorder => WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: _colors.onSurface.withOpacity(0.12)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _colors.error, width: 2.0); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _colors.onErrorContainer); + } + return BorderSide(color: _colors.error); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _colors.primary, width: 2.0); + } + if (states.contains(WidgetState.hovered)) { + return BorderSide(color: _colors.onSurface); + } + return BorderSide(color: _colors.outline); + }); + + @override + Color? get iconColor => _colors.onSurfaceVariant; + + @override + Color? get prefixIconColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onSurfaceVariant; + }); + + @override + Color? get suffixIconColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.hovered)) { + return _colors.onErrorContainer; + } + return _colors.error; + } + return _colors.onSurfaceVariant; + }); + + @override + TextStyle? get labelStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final TextStyle textStyle = _textTheme.bodyLarge ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith(color: _colors.onSurface.withOpacity(0.38)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith(color: _colors.error); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith(color: _colors.onErrorContainer); + } + return textStyle.copyWith(color: _colors.error); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith(color: _colors.primary); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith(color: _colors.onSurfaceVariant); + } + return textStyle.copyWith(color: _colors.onSurfaceVariant); + }); + + @override + TextStyle? get floatingLabelStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final TextStyle textStyle = _textTheme.bodyLarge ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith(color: _colors.onSurface.withOpacity(0.38)); + } + if (states.contains(WidgetState.error)) { + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith(color: _colors.error); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith(color: _colors.onErrorContainer); + } + return textStyle.copyWith(color: _colors.error); + } + if (states.contains(WidgetState.focused)) { + return textStyle.copyWith(color: _colors.primary); + } + if (states.contains(WidgetState.hovered)) { + return textStyle.copyWith(color: _colors.onSurfaceVariant); + } + return textStyle.copyWith(color: _colors.onSurfaceVariant); + }); + + @override + TextStyle? get helperStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final TextStyle textStyle = _textTheme.bodySmall ?? const TextStyle(); + if (states.contains(WidgetState.disabled)) { + return textStyle.copyWith(color: _colors.onSurface.withOpacity(0.38)); + } + return textStyle.copyWith(color: _colors.onSurfaceVariant); + }); + + @override + TextStyle? get errorStyle => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final TextStyle textStyle = _textTheme.bodySmall ?? const TextStyle(); + return textStyle.copyWith(color: _colors.error); + }); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - InputDecorator diff --git a/packages/material_ui/lib/src/list_tile.dart b/packages/material_ui/lib/src/list_tile.dart new file mode 100644 index 000000000000..7d388f152296 --- /dev/null +++ b/packages/material_ui/lib/src/list_tile.dart @@ -0,0 +1,1860 @@ +// 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. + +/// @docImport 'card.dart'; +/// @docImport 'checkbox.dart'; +/// @docImport 'checkbox_list_tile.dart'; +/// @docImport 'circle_avatar.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'expansion_tile.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'radio_list_tile.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'switch.dart'; +/// @docImport 'switch_list_tile.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'divider.dart'; +import 'icon_button.dart'; +import 'icon_button_theme.dart'; +import 'ink_decoration.dart'; +import 'ink_well.dart'; +import 'list_tile_theme.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// int _act = 1; + +typedef _Sizes = ({double titleY, BoxConstraints textConstraints, Size tileSize}); +typedef _PositionChild = void Function(RenderBox child, Offset offset); + +/// Defines the title font used for [ListTile] descendants of a [ListTileTheme]. +/// +/// List tiles that appear in a [Drawer] use the theme's [TextTheme.bodyLarge] +/// text style, which is a little smaller than the theme's [TextTheme.titleMedium] +/// text style, which is used by default. +enum ListTileStyle { + /// Use a title font that's appropriate for a [ListTile] in a list. + list, + + /// Use a title font that's appropriate for a [ListTile] that appears in a [Drawer]. + drawer, +} + +/// Where to place the control in widgets that use [ListTile] to position a +/// control next to a label. +/// +/// See also: +/// +/// * [CheckboxListTile], which combines a [ListTile] with a [Checkbox]. +/// * [RadioListTile], which combines a [ListTile] with a [Radio] button. +/// * [SwitchListTile], which combines a [ListTile] with a [Switch]. +/// * [ExpansionTile], which combines a [ListTile] with a button that expands +/// or collapses the tile to reveal or hide the children. +enum ListTileControlAffinity { + /// Position the control on the leading edge, and the secondary widget, if + /// any, on the trailing edge. + leading, + + /// Position the control on the trailing edge, and the secondary widget, if + /// any, on the leading edge. + trailing, + + /// Position the control relative to the text in the fashion that is typical + /// for the current platform, and place the secondary widget on the opposite + /// side. + platform, +} + +/// Defines how [ListTile.leading] and [ListTile.trailing] are +/// vertically aligned relative to the [ListTile]'s titles +/// ([ListTile.title] and [ListTile.subtitle]). +/// +/// See also: +/// +/// * [ListTile.titleAlignment], to configure the title alignment for an +/// individual [ListTile]. +/// * [ListTileThemeData.titleAlignment], to configure the title alignment +/// for all of the [ListTile]s under a [ListTileTheme]. +/// * [ThemeData.listTileTheme], to configure the [ListTileTheme] +/// for an entire app. +enum ListTileTitleAlignment { + /// The top of the [ListTile.leading] and [ListTile.trailing] widgets are + /// placed [ListTile.minVerticalPadding] below the top of the [ListTile.title] + /// if [ListTile.isThreeLine] is true, otherwise they're centered relative + /// to the [ListTile.title] and [ListTile.subtitle] widgets. + /// + /// This is the default when [ThemeData.useMaterial3] is true. + threeLine, + + /// The tops of the [ListTile.leading] and [ListTile.trailing] widgets are + /// placed 16 pixels below the top of the [ListTile.title] widget, + /// if the [ListTile]'s overall height is greater than 72, otherwise the + /// [ListTile.trailing] widget is centered relative to the [ListTile.title] and + /// [ListTile.subtitle] widgets, and the [ListTile.leading] widget is 16 pixels + /// below the top of [ListTile.title], or center-aligned with [ListTile.title], + /// whichever makes the [ListTile.leading] closer to the top edge of [ListTile.title]. + /// + /// This is the default when [ThemeData.useMaterial3] is false. + titleHeight, + + /// The tops of the [ListTile.leading] and [ListTile.trailing] widgets are + /// placed [ListTile.minVerticalPadding] below the top of the [ListTile.title]. + top, + + /// The [ListTile.leading] and [ListTile.trailing] widgets are + /// centered relative to the [ListTile]'s titles. + center, + + /// The bottoms of the [ListTile.leading] and [ListTile.trailing] widgets are + /// placed [ListTile.minVerticalPadding] above the bottom of the [ListTile]'s + /// titles. + bottom; + + // If isLeading is true the y offset is for the leading widget, otherwise it's + // for the trailing child. + double _yOffsetFor( + double childHeight, + double tileHeight, + _RenderListTile listTile, + bool isLeading, + ) { + return switch (this) { + ListTileTitleAlignment.threeLine => + listTile.isThreeLine + ? ListTileTitleAlignment.top._yOffsetFor(childHeight, tileHeight, listTile, isLeading) + : ListTileTitleAlignment.center._yOffsetFor( + childHeight, + tileHeight, + listTile, + isLeading, + ), + // This attempts to implement the redlines for the vertical position of the + // leading and trailing icons on the spec page: + // https://m2.material.io/components/lists#specs + // + // For large tiles (> 72dp), both leading and trailing controls should be + // a fixed distance from top. As per guidelines this is set to 16dp. + ListTileTitleAlignment.titleHeight when tileHeight > 72.0 => 16.0, + // For smaller tiles, trailing should always be centered. Leading can be + // centered or closer to the top. It should never be further than 16dp + // to the top. + ListTileTitleAlignment.titleHeight => + isLeading + ? math.min((tileHeight - childHeight) / 2.0, 16.0) + : (tileHeight - childHeight) / 2.0, + ListTileTitleAlignment.top => listTile.minVerticalPadding, + ListTileTitleAlignment.center => (tileHeight - childHeight) / 2.0, + ListTileTitleAlignment.bottom => tileHeight - childHeight - listTile.minVerticalPadding, + }; + } +} + +/// A single fixed-height row that typically contains some text as well as +/// a leading or trailing icon. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=l8dj0yPBvgQ} +/// +/// A list tile contains one to three lines of text optionally flanked by icons or +/// other widgets, such as check boxes. The icons (or other widgets) for the +/// tile are defined with the [leading] and [trailing] parameters. The first +/// line of text is not optional and is specified with [title]. The value of +/// [subtitle], which _is_ optional, will occupy the space allocated for an +/// additional line of text, or two lines if [isThreeLine] is true. If [dense] +/// is true then the overall height of this tile and the size of the +/// [DefaultTextStyle]s that wrap the [title] and [subtitle] widget are reduced. +/// +/// It is the responsibility of the caller to ensure that [title] does not wrap, +/// and to ensure that [subtitle] doesn't wrap (if [isThreeLine] is false) or +/// wraps to two lines (if it is true). +/// +/// The heights of the [leading] and [trailing] widgets are constrained +/// according to the +/// [Material spec](https://material.io/design/components/lists.html). +/// An exception is made for one-line ListTiles for accessibility. Please +/// see the example below to see how to adhere to both Material spec and +/// accessibility requirements. +/// +/// The [leading] and [trailing] widgets can expand as far as they wish +/// horizontally, so ensure that they are properly constrained. +/// +/// List tiles are typically used in [ListView]s, or arranged in [Column]s in +/// [Drawer]s and [Card]s. +/// +/// This widget requires a [Material] widget ancestor in the tree to paint +/// itself on, which is typically provided by the app's [Scaffold]. +/// The [tileColor], [selectedTileColor], [focusColor], and [hoverColor] +/// are not painted by the [ListTile] itself but by the [Material] widget +/// ancestor. In this case, one can wrap a [Material] widget around the +/// [ListTile], e.g.: +/// +/// {@tool snippet} +/// ```dart +/// const ColoredBox( +/// color: Colors.green, +/// child: Material( +/// child: ListTile( +/// title: Text('ListTile with red background'), +/// tileColor: Colors.red, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Performance considerations when wrapping [ListTile] with [Material] +/// +/// Wrapping a large number of [ListTile]s individually with [Material]s +/// is expensive. Consider only wrapping the [ListTile]s that require it +/// or include a common [Material] ancestor where possible. +/// +/// [ListTile] must be wrapped in a [Material] widget to animate [tileColor], +/// [selectedTileColor], [focusColor], and [hoverColor] as these colors +/// are not drawn by the list tile itself but by the material widget ancestor. +/// +/// {@tool dartpad} +/// This example showcases how [ListTile] needs to be wrapped in a [Material] +/// widget to animate colors. +/// +/// ** See code in examples/api/lib/material/list_tile/list_tile.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example uses a [ListView] to demonstrate different configurations of +/// [ListTile]s in [Card]s. +/// +/// ![Different variations of ListTile](https://flutter.github.io/assets-for-api-docs/assets/material/list_tile.png) +/// +/// ** See code in examples/api/lib/material/list_tile/list_tile.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a [ListTile] using [ThemeData.useMaterial3] flag, +/// as described in: https://m3.material.io/components/lists/overview. +/// +/// ** See code in examples/api/lib/material/list_tile/list_tile.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows [ListTile]'s [textColor] and [iconColor] can use +/// [WidgetStateColor] color to change the color of the text and icon +/// when the [ListTile] is enabled, selected, or disabled. +/// +/// ** See code in examples/api/lib/material/list_tile/list_tile.3.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows [ListTile.titleAlignment] can be used to configure the +/// [leading] and [trailing] widgets alignment relative to the [title] and +/// [subtitle] widgets. +/// +/// ** See code in examples/api/lib/material/list_tile/list_tile.4.dart ** +/// {@end-tool} +/// +/// {@tool snippet} +/// To use a [ListTile] within a [Row], it needs to be wrapped in an +/// [Expanded] widget. [ListTile] requires fixed width constraints, +/// whereas a [Row] does not constrain its children. +/// +/// ```dart +/// const Row( +/// children: <Widget>[ +/// Expanded( +/// child: ListTile( +/// leading: FlutterLogo(), +/// title: Text('These ListTiles are expanded '), +/// ), +/// ), +/// Expanded( +/// child: ListTile( +/// trailing: FlutterLogo(), +/// title: Text('to fill the available space.'), +/// ), +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// {@tool snippet} +/// +/// Tiles can be much more elaborate. Here is a tile which can be tapped, but +/// which is disabled when the `_act` variable is not 2. When the tile is +/// tapped, the whole row has an ink splash effect (see [InkWell]). +/// +/// ```dart +/// ListTile( +/// leading: const Icon(Icons.flight_land), +/// title: const Text("Trix's airplane"), +/// subtitle: _act != 2 ? const Text('The airplane is only in Act II.') : null, +/// enabled: _act == 2, +/// onTap: () { /* react to the tile being tapped */ } +/// ) +/// ``` +/// {@end-tool} +/// +/// To be accessible, tappable [leading] and [trailing] widgets have to +/// be at least 48x48 in size. However, to adhere to the Material spec, +/// [trailing] and [leading] widgets in one-line ListTiles should visually be +/// at most 32 ([dense]: true) or 40 ([dense]: false) in height, which may +/// conflict with the accessibility requirement. +/// +/// For this reason, a one-line ListTile allows the height of [leading] +/// and [trailing] widgets to be constrained by the height of the ListTile. +/// This allows for the creation of tappable [leading] and [trailing] widgets +/// that are large enough, but it is up to the developer to ensure that +/// their widgets follow the Material spec. +/// +/// {@tool snippet} +/// +/// Here is an example of a one-line, non-[dense] ListTile with a +/// tappable leading widget that adheres to accessibility requirements and +/// the Material spec. To adjust the use case below for a one-line, [dense] +/// ListTile, adjust the vertical padding to 8.0. +/// +/// ```dart +/// ListTile( +/// leading: GestureDetector( +/// behavior: HitTestBehavior.translucent, +/// onTap: () {}, +/// child: Container( +/// width: 48, +/// height: 48, +/// padding: const EdgeInsets.symmetric(vertical: 4.0), +/// alignment: Alignment.center, +/// child: const CircleAvatar(), +/// ), +/// ), +/// title: const Text('title'), +/// dense: false, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## The ListTile layout isn't exactly what I want +/// +/// If the way ListTile pads and positions its elements isn't quite what +/// you're looking for, it's easy to create custom list items with a +/// combination of other widgets, such as [Row]s and [Column]s. +/// +/// {@tool dartpad} +/// Here is an example of a custom list item that resembles a YouTube-related +/// video list item created with [Expanded] and [Container] widgets. +/// +/// ** See code in examples/api/lib/material/list_tile/custom_list_item.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// Here is an example of an article list item with multiline titles and +/// subtitles. It utilizes [Row]s and [Column]s, as well as [Expanded] and +/// [AspectRatio] widgets to organize its layout. +/// +/// ** See code in examples/api/lib/material/list_tile/custom_list_item.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListTileTheme], which defines visual properties for [ListTile]s. +/// * [ListView], which can display an arbitrary number of [ListTile]s +/// in a scrolling list. +/// * [CircleAvatar], which shows an icon representing a person and is often +/// used as the [leading] element of a ListTile. +/// * [Card], which can be used with [Column] to show a few [ListTile]s. +/// * [Divider], which can be used to separate [ListTile]s. +/// * [ListTile.divideTiles], a utility for inserting [Divider]s in between [ListTile]s. +/// * [CheckboxListTile], [RadioListTile], and [SwitchListTile], widgets +/// that combine [ListTile] with other controls. +/// * Material 3 [ListTile] specifications are referenced from <https://m3.material.io/components/lists/specs> +/// and Material 2 [ListTile] specifications are referenced from <https://material.io/design/components/lists.html> +/// * Cookbook: [Use lists](https://docs.flutter.dev/cookbook/lists/basic-list) +/// * Cookbook: [Implement swipe to dismiss](https://docs.flutter.dev/cookbook/gestures/dismissible) +class ListTile extends StatelessWidget { + /// Creates a list tile. + /// + /// If [isThreeLine] is true, then [subtitle] must not be null. + /// + /// Requires one of its ancestors to be a [Material] widget. + const ListTile({ + super.key, + this.leading, + this.title, + this.subtitle, + this.trailing, + this.isThreeLine, + this.dense, + this.visualDensity, + this.shape, + this.style, + this.selectedColor, + this.iconColor, + this.textColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingAndTrailingTextStyle, + this.contentPadding, + this.enabled = true, + this.onTap, + this.onLongPress, + this.onFocusChange, + this.mouseCursor, + this.selected = false, + this.focusColor, + this.hoverColor, + this.splashColor, + this.focusNode, + this.autofocus = false, + this.tileColor, + this.selectedTileColor, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.titleAlignment, + this.internalAddSemanticForOnTap = true, + this.statesController, + }) : assert(isThreeLine != true || subtitle != null); + + /// A widget to display before the title. + /// + /// Typically an [Icon] or a [CircleAvatar] widget. + final Widget? leading; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + /// + /// This should not wrap. To enforce the single line limit, use + /// [Text.maxLines]. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + /// + /// If [isThreeLine] is false, this should not wrap. + /// + /// If [isThreeLine] is true, this should be configured to take a maximum of + /// two lines. For example, you can use [Text.maxLines] to enforce the number + /// of lines. + /// + /// The subtitle's default [TextStyle] depends on [TextTheme.bodyMedium] except + /// [TextStyle.color]. The [TextStyle.color] depends on the value of [enabled] + /// and [selected]. + /// + /// When [enabled] is false, the text color is set to [ThemeData.disabledColor]. + /// + /// When [selected] is false, the text color is set to [ListTileTheme.textColor] + /// if it's not null and to [TextTheme.bodySmall]'s color if [ListTileTheme.textColor] + /// is null. + final Widget? subtitle; + + /// A widget to display after the title. + /// + /// Typically an [Icon] widget. + /// + /// To show right-aligned metadata (assuming left-to-right reading order; + /// left-aligned for right-to-left reading order), consider using a [Row] with + /// [CrossAxisAlignment.baseline] alignment whose first item is [Expanded] and + /// whose second child is the metadata text, instead of using the [trailing] + /// property. + final Widget? trailing; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If true, then [subtitle] must be non-null (since it is expected to give + /// the second and third lines of text). + /// + /// If false, the list tile is treated as having one line if the subtitle is + /// null and treated as having two lines if the subtitle is non-null. + /// + /// When using a [Text] widget for [title] and [subtitle], you can enforce + /// line limits using [Text.maxLines]. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final bool? isThreeLine; + + /// {@template flutter.material.ListTile.dense} + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileTheme.dense]. + /// + /// Dense list tiles default to a smaller height. + /// + /// It is not recommended to set [dense] to true when [ThemeData.useMaterial3] is true. + /// {@endtemplate} + final bool? dense; + + /// Defines how compact the list tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// {@template flutter.material.ListTile.shape} + /// Defines the tile's [InkWell.customBorder] and [Ink.decoration] shape. + /// {@endtemplate} + /// + /// If this property is null then [ListTileThemeData.shape] is used. If that + /// is also null then a rectangular [Border] will be used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final ShapeBorder? shape; + + /// Defines the color used for icons and text when the list tile is selected. + /// + /// If this property is null then [ListTileThemeData.selectedColor] + /// is used. If that is also null then [ColorScheme.primary] is used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final Color? selectedColor; + + /// Defines the default color for [leading] and [trailing] icons. + /// + /// If this property is null and [selected] is false then [ListTileThemeData.iconColor] + /// is used. If that is also null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant] + /// is used, otherwise if [ThemeData.brightness] is [Brightness.light], [Colors.black54] is used, + /// and if [ThemeData.brightness] is [Brightness.dark], the value is null. + /// + /// If this property is null and [selected] is true then [ListTileThemeData.selectedColor] + /// is used. If that is also null then [ColorScheme.primary] is used. + /// + /// If this color is a [WidgetStateColor] it will be resolved against + /// [WidgetState.selected] and [WidgetState.disabled] states. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final Color? iconColor; + + /// Defines the text color for the [title], [subtitle], [leading], and [trailing]. + /// + /// If this property is null and [selected] is false then [ListTileThemeData.textColor] + /// is used. If that is also null then default text color is used for the [title], [subtitle] + /// [leading], and [trailing]. Except for [subtitle], if [ThemeData.useMaterial3] is false, + /// [TextTheme.bodySmall] is used. + /// + /// If this property is null and [selected] is true then [ListTileThemeData.selectedColor] + /// is used. If that is also null then [ColorScheme.primary] is used. + /// + /// If this color is a [WidgetStateColor] it will be resolved against + /// [WidgetState.selected] and [WidgetState.disabled] states. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final Color? textColor; + + /// The text style for ListTile's [title]. + /// + /// If this property is null, then [ListTileThemeData.titleTextStyle] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.bodyLarge] + /// with [ColorScheme.onSurface] will be used. Otherwise, If ListTile style is + /// [ListTileStyle.list], [TextTheme.titleMedium] will be used and if ListTile style + /// is [ListTileStyle.drawer], [TextTheme.bodyLarge] will be used. + final TextStyle? titleTextStyle; + + /// The text style for ListTile's [subtitle]. + /// + /// If this property is null, then [ListTileThemeData.subtitleTextStyle] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.bodyMedium] + /// with [ColorScheme.onSurfaceVariant] will be used, otherwise [TextTheme.bodyMedium] + /// with [TextTheme.bodySmall] color will be used. + final TextStyle? subtitleTextStyle; + + /// The text style for ListTile's [leading] and [trailing]. + /// + /// If this property is null, then [ListTileThemeData.leadingAndTrailingTextStyle] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.labelSmall] + /// with [ColorScheme.onSurfaceVariant] will be used, otherwise [TextTheme.bodyMedium] + /// will be used. + final TextStyle? leadingAndTrailingTextStyle; + + /// Defines the font used for the [title]. + /// + /// If this property is null then [ListTileThemeData.style] is used. If that + /// is also null then [ListTileStyle.list] is used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final ListTileStyle? style; + + /// The tile's internal padding. + /// + /// Insets a [ListTile]'s contents: its [leading], [title], [subtitle], and [trailing] widgets. + /// + /// If this property is null, then [ListTileThemeData.contentPadding] is used. If that is also + /// null and [ThemeData.useMaterial3] is true, then a default value of + /// `EdgeInsetsDirectional.only(start: 16.0, end: 24.0)` will be used. Otherwise, a default value + /// of `EdgeInsets.symmetric(horizontal: 16.0)` will be used. + final EdgeInsetsGeometry? contentPadding; + + /// Whether this list tile is interactive. + /// + /// If false, this list tile is styled with the disabled color from the + /// current [Theme] and the [onTap] and [onLongPress] callbacks are + /// inoperative. + final bool enabled; + + /// Called when the user taps this list tile. + /// + /// Inoperative if [enabled] is false. + final GestureTapCallback? onTap; + + /// Called when the user long-presses on this list tile. + /// + /// Inoperative if [enabled] is false. + final GestureLongPressCallback? onLongPress; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged<bool>? onFocusChange; + + /// {@template flutter.material.ListTile.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [ListTileThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// If this tile is also [enabled] then icons and text are rendered with the same color. + /// + /// By default the selected color is the theme's primary color. The selected color + /// can be overridden with a [ListTileTheme]. + /// + /// {@tool dartpad} + /// Here is an example of using a [StatefulWidget] to keep track of the + /// selected index, and using that to set the [selected] property on the + /// corresponding [ListTile]. + /// + /// ** See code in examples/api/lib/material/list_tile/list_tile.selected.0.dart ** + /// {@end-tool} + final bool selected; + + /// The color for the tile's [Material] when it has the input focus. + final Color? focusColor; + + /// The color for the tile's [Material] when a pointer is hovering over it. + final Color? hoverColor; + + /// The color of splash for the tile's [Material]. + final Color? splashColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@template flutter.material.ListTile.tileColor} + /// Defines the background color of `ListTile` when [selected] is false. + /// + /// If this property is null and [selected] is false then [ListTileThemeData.tileColor] + /// is used. If that is also null and [selected] is true, [selectedTileColor] is used. + /// When that is also null, the [ListTileTheme.selectedTileColor] is used, otherwise + /// [Colors.transparent] is used. + /// + /// {@endtemplate} + final Color? tileColor; + + /// Defines the background color of `ListTile` when [selected] is true. + /// + /// When the value if null, the [selectedTileColor] is set to [ListTileTheme.selectedTileColor] + /// if it's not null and to [Colors.transparent] if it's null. + final Color? selectedTileColor; + + /// {@template flutter.material.ListTile.enableFeedback} + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// When null, the default value is true. + /// {@endtemplate} + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// {@template flutter.material.ListTile.horizontalTitleGap} + /// The horizontal gap between the titles and the leading/trailing widgets. + /// + /// If null, then the value of [ListTileTheme.horizontalTitleGap] is used. If + /// that is also null, then a default value of 16 is used. + /// {@endtemplate} + final double? horizontalTitleGap; + + /// {@template flutter.material.ListTile.minVerticalPadding} + /// The minimum padding on the top and bottom of the title and subtitle widgets. + /// + /// If null, then the value of [ListTileTheme.minVerticalPadding] is used. If + /// that is also null, then a default value of 4 is used. + /// {@endtemplate} + final double? minVerticalPadding; + + /// {@template flutter.material.ListTile.minLeadingWidth} + /// The minimum width allocated for the [ListTile.leading] widget. + /// + /// If null, then the value of [ListTileTheme.minLeadingWidth] is used. If + /// that is also null, then a default value of 40 is used. + /// {@endtemplate} + final double? minLeadingWidth; + + /// {@template flutter.material.ListTile.minTileHeight} + /// The minimum height allocated for the [ListTile] widget. + /// + /// If this is null, default tile heights are 56.0, 72.0, and 88.0 for one, + /// two, and three lines of text respectively. If `isDense` is true, these + /// defaults are changed to 48.0, 64.0, and 76.0. A visual density value or + /// a large title will also adjust the default tile heights. + /// {@endtemplate} + final double? minTileHeight; + + /// Defines how [ListTile.leading] and [ListTile.trailing] are + /// vertically aligned relative to the [ListTile]'s titles + /// ([ListTile.title] and [ListTile.subtitle]). + /// + /// If this property is null then [ListTileThemeData.titleAlignment] + /// is used. If that is also null then [ListTileTitleAlignment.threeLine] + /// is used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final ListTileTitleAlignment? titleAlignment; + + /// Whether to add button:true to the semantics if onTap is provided. + /// This is a temporary flag to help changing the behavior of ListTile onTap semantics. + /// + // TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping + // the default value to true. + final bool internalAddSemanticForOnTap; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// Add a one pixel border in between each tile. If color isn't specified the + /// [ThemeData.dividerColor] of the context's [Theme] is used. + /// + /// See also: + /// + /// * [Divider], which you can use to obtain this effect manually. + static Iterable<Widget> divideTiles({ + BuildContext? context, + required Iterable<Widget> tiles, + Color? color, + }) { + assert(color != null || context != null); + tiles = tiles.toList(); + + if (tiles.isEmpty || tiles.length == 1) { + return tiles; + } + + Widget wrapTile(Widget tile) { + return DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: Border(bottom: Divider.createBorderSide(context, color: color)), + ), + child: tile, + ); + } + + return <Widget>[...tiles.take(tiles.length - 1).map(wrapTile), tiles.last]; + } + + bool _isDenseLayout(ThemeData theme, ListTileThemeData tileTheme) { + return dense ?? tileTheme.dense ?? theme.listTileTheme.dense ?? false; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ThemeData theme = Theme.of(context); + final IconButtonThemeData iconButtonTheme = IconButtonTheme.of(context); + final ListTileThemeData tileTheme = ListTileTheme.of(context); + final ListTileStyle listTileStyle = + style ?? tileTheme.style ?? theme.listTileTheme.style ?? ListTileStyle.list; + final ListTileThemeData defaults = theme.useMaterial3 + ? _LisTileDefaultsM3(context) + : _LisTileDefaultsM2(context, listTileStyle); + + final Color backgroundColor = + tileColor ?? tileTheme.tileColor ?? theme.listTileTheme.tileColor ?? defaults.tileColor!; + final Color selectedBackgroundColor = + selectedTileColor ?? + tileTheme.selectedTileColor ?? + theme.listTileTheme.selectedTileColor ?? + defaults.tileColor!; + final effectiveTileColor = selected ? selectedBackgroundColor : backgroundColor; + final bool hasOpaqueBackground = backgroundColor.alpha > 0 || selectedBackgroundColor.alpha > 0; + if (onTap != null || onLongPress != null || hasOpaqueBackground) { + assert(_debugCheckBackgroundIsHidden(context)); + } + final states = <WidgetState>{ + if (!enabled) WidgetState.disabled, + if (selected) WidgetState.selected, + }; + + Color? resolveColor( + Color? explicitColor, + Color? selectedColor, + Color? enabledColor, [ + Color? disabledColor, + ]) { + return _IndividualOverrides( + explicitColor: explicitColor, + selectedColor: selectedColor, + enabledColor: enabledColor, + disabledColor: disabledColor, + ).resolve(states); + } + + Color? effectiveIconColor = + resolveColor(iconColor, selectedColor, iconColor) ?? + resolveColor(tileTheme.iconColor, tileTheme.selectedColor, tileTheme.iconColor) ?? + resolveColor( + theme.listTileTheme.iconColor, + theme.listTileTheme.selectedColor, + theme.listTileTheme.iconColor, + ); + + final Color? defaultEffectiveIconColor = resolveColor( + defaults.iconColor, + defaults.selectedColor, + defaults.iconColor, + theme.disabledColor, + ); + + final Color? effectiveIconButtonColor = + effectiveIconColor ?? + iconButtonTheme.style?.foregroundColor?.resolve(states) ?? + defaultEffectiveIconColor; + + effectiveIconColor ??= defaultEffectiveIconColor; + + final Color? effectiveColor = + resolveColor(textColor, selectedColor, textColor) ?? + resolveColor(tileTheme.textColor, tileTheme.selectedColor, tileTheme.textColor) ?? + resolveColor( + theme.listTileTheme.textColor, + theme.listTileTheme.selectedColor, + theme.listTileTheme.textColor, + ) ?? + resolveColor( + defaults.textColor, + defaults.selectedColor, + defaults.textColor, + theme.disabledColor, + ); + final iconThemeData = IconThemeData(color: effectiveIconColor); + final iconButtonThemeData = IconButtonThemeData( + style: + IconButtonTheme.of(context).style?.copyWith( + foregroundColor: WidgetStatePropertyAll<Color?>(effectiveIconButtonColor), + ) ?? + IconButton.styleFrom(foregroundColor: effectiveIconButtonColor), + ); + + TextStyle? leadingAndTrailingStyle; + if (leading != null || trailing != null) { + leadingAndTrailingStyle = + leadingAndTrailingTextStyle ?? + tileTheme.leadingAndTrailingTextStyle ?? + defaults.leadingAndTrailingTextStyle!; + final leadingAndTrailingTextColor = effectiveColor; + leadingAndTrailingStyle = leadingAndTrailingStyle.copyWith( + color: leadingAndTrailingTextColor, + ); + } + + Widget? leadingIcon; + if (leading != null) { + leadingIcon = AnimatedDefaultTextStyle( + style: leadingAndTrailingStyle!, + duration: kThemeChangeDuration, + child: leading!, + ); + } + + TextStyle titleStyle = titleTextStyle ?? tileTheme.titleTextStyle ?? defaults.titleTextStyle!; + final titleColor = effectiveColor; + titleStyle = titleStyle.copyWith( + color: titleColor, + fontSize: _isDenseLayout(theme, tileTheme) ? 13.0 : null, + ); + final Widget titleText = AnimatedDefaultTextStyle( + style: titleStyle, + duration: kThemeChangeDuration, + child: title ?? const SizedBox(), + ); + + Widget? subtitleText; + TextStyle? subtitleStyle; + if (subtitle != null) { + subtitleStyle = + subtitleTextStyle ?? tileTheme.subtitleTextStyle ?? defaults.subtitleTextStyle!; + final subtitleColor = effectiveColor; + subtitleStyle = subtitleStyle.copyWith( + color: subtitleColor, + fontSize: _isDenseLayout(theme, tileTheme) ? 12.0 : null, + ); + subtitleText = AnimatedDefaultTextStyle( + style: subtitleStyle, + duration: kThemeChangeDuration, + child: subtitle!, + ); + } + + Widget? trailingIcon; + if (trailing != null) { + trailingIcon = AnimatedDefaultTextStyle( + style: leadingAndTrailingStyle!, + duration: kThemeChangeDuration, + child: trailing!, + ); + } + + final TextDirection textDirection = Directionality.of(context); + final EdgeInsets resolvedContentPadding = + contentPadding?.resolve(textDirection) ?? + tileTheme.contentPadding?.resolve(textDirection) ?? + defaults.contentPadding!.resolve(textDirection); + + // Show basic cursor when ListTile isn't enabled or gesture callbacks are null. + final mouseStates = <WidgetState>{ + if (!enabled || (onTap == null && onLongPress == null)) WidgetState.disabled, + }; + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs<MouseCursor?>(mouseCursor, mouseStates) ?? + tileTheme.mouseCursor?.resolve(mouseStates) ?? + WidgetStateMouseCursor.clickable.resolve(mouseStates); + + final ListTileTitleAlignment effectiveTitleAlignment = + titleAlignment ?? + tileTheme.titleAlignment ?? + (theme.useMaterial3 + ? ListTileTitleAlignment.threeLine + : ListTileTitleAlignment.titleHeight); + + return InkWell( + customBorder: shape ?? tileTheme.shape, + onTap: enabled ? onTap : null, + onLongPress: enabled ? onLongPress : null, + onFocusChange: onFocusChange, + mouseCursor: effectiveMouseCursor, + canRequestFocus: enabled, + focusNode: focusNode, + focusColor: focusColor, + hoverColor: hoverColor, + splashColor: splashColor, + autofocus: autofocus, + enableFeedback: enableFeedback ?? tileTheme.enableFeedback ?? true, + statesController: statesController, + child: Semantics( + button: internalAddSemanticForOnTap && (onTap != null || onLongPress != null), + selected: selected, + enabled: enabled, + child: Ink( + decoration: ShapeDecoration( + shape: shape ?? tileTheme.shape ?? const Border(), + color: effectiveTileColor, + ), + child: SafeArea( + top: false, + bottom: false, + minimum: resolvedContentPadding, + child: IconTheme.merge( + data: iconThemeData, + child: IconButtonTheme( + data: iconButtonThemeData, + child: _ListTile( + leading: leadingIcon, + title: titleText, + subtitle: subtitleText, + trailing: trailingIcon, + isDense: _isDenseLayout(theme, tileTheme), + visualDensity: visualDensity ?? tileTheme.visualDensity ?? theme.visualDensity, + isThreeLine: + isThreeLine ?? + tileTheme.isThreeLine ?? + theme.listTileTheme.isThreeLine ?? + false, + textDirection: textDirection, + titleBaselineType: + titleStyle.textBaseline ?? defaults.titleTextStyle!.textBaseline!, + subtitleBaselineType: + subtitleStyle?.textBaseline ?? defaults.subtitleTextStyle!.textBaseline!, + horizontalTitleGap: horizontalTitleGap ?? tileTheme.horizontalTitleGap ?? 16, + minVerticalPadding: + minVerticalPadding ?? + tileTheme.minVerticalPadding ?? + defaults.minVerticalPadding!, + minLeadingWidth: + minLeadingWidth ?? tileTheme.minLeadingWidth ?? defaults.minLeadingWidth!, + minTileHeight: minTileHeight ?? tileTheme.minTileHeight, + titleAlignment: effectiveTitleAlignment, + ), + ), + ), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty( + 'isThreeLine', + value: isThreeLine, + ifTrue: 'THREE_LINE', + ifFalse: 'TWO_LINE', + showName: true, + ), + ); + properties.add( + FlagProperty('dense', value: dense, ifTrue: 'true', ifFalse: 'false', showName: true), + ); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<ListTileStyle>('style', style, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); + properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('titleTextStyle', titleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('subtitleTextStyle', subtitleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'leadingAndTrailingTextStyle', + leadingAndTrailingTextStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('contentPadding', contentPadding, defaultValue: null), + ); + properties.add( + FlagProperty( + 'enabled', + value: enabled, + ifTrue: 'true', + ifFalse: 'false', + showName: true, + defaultValue: true, + ), + ); + properties.add(DiagnosticsProperty<Function>('onTap', onTap, defaultValue: null)); + properties.add(DiagnosticsProperty<Function>('onLongPress', onLongPress, defaultValue: null)); + properties.add( + DiagnosticsProperty<MouseCursor>('mouseCursor', mouseCursor, defaultValue: null), + ); + properties.add( + FlagProperty( + 'selected', + value: selected, + ifTrue: 'true', + ifFalse: 'false', + showName: true, + defaultValue: false, + ), + ); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + properties.add( + FlagProperty( + 'autofocus', + value: autofocus, + ifTrue: 'true', + ifFalse: 'false', + showName: true, + defaultValue: false, + ), + ); + properties.add(ColorProperty('tileColor', tileColor, defaultValue: null)); + properties.add(ColorProperty('selectedTileColor', selectedTileColor, defaultValue: null)); + properties.add( + FlagProperty( + 'enableFeedback', + value: enableFeedback, + ifTrue: 'true', + ifFalse: 'false', + showName: true, + ), + ); + properties.add(DoubleProperty('horizontalTitleGap', horizontalTitleGap, defaultValue: null)); + properties.add(DoubleProperty('minVerticalPadding', minVerticalPadding, defaultValue: null)); + properties.add(DoubleProperty('minLeadingWidth', minLeadingWidth, defaultValue: null)); + properties.add( + DiagnosticsProperty<ListTileTitleAlignment>( + 'titleAlignment', + titleAlignment, + defaultValue: null, + ), + ); + } + + bool _debugCheckBackgroundIsHidden(BuildContext context) { + assert(() { + final Widget? intermediateWidget = _findIntermediateWidget(context); + if (intermediateWidget != null) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('ListTile background color or ink splashes may be invisible.'), + ErrorDescription( + 'The ListTile is wrapped in a ${intermediateWidget.runtimeType} that has a background color. ' + 'Because ListTile paints its background and ink splashes on the nearest Material ancestor, ' + 'this ${intermediateWidget.runtimeType} will hide those effects.', + ), + ErrorHint( + 'To fix this, wrap the ListTile in its own Material widget, ' + 'or remove the background color from the intermediate ${intermediateWidget.runtimeType}.', + ), + ]), + informationCollector: () => <DiagnosticsNode>[ + DiagnosticsProperty<ListTile>('ListTile', this, expandableValue: true), + DiagnosticsProperty<Widget>( + '${intermediateWidget.runtimeType}', + intermediateWidget, + expandableValue: true, + ), + ], + ), + ); + } + return true; + }()); + return true; + } + + Widget? _findIntermediateWidget(BuildContext context) { + Widget? intermediateWidget; + (context as Element).visitAncestorElements((Element ancestor) { + if (ancestor.widget is Material) { + return false; + } + final Widget widget = ancestor.widget; + final Color? color = switch (widget) { + ColoredBox(:final Color color) => color, + DecoratedBox(decoration: BoxDecoration(:final Color? color)) => color, + DecoratedBox(decoration: ShapeDecoration(:final Color? color)) => color, + _ => null, + }; + if (color != null && color.a > 0) { + intermediateWidget = widget; + return false; + } + return true; + }); + return intermediateWidget; + } +} + +class _IndividualOverrides extends WidgetStateProperty<Color?> { + _IndividualOverrides({ + this.explicitColor, + this.enabledColor, + this.selectedColor, + this.disabledColor, + }); + + final Color? explicitColor; + final Color? enabledColor; + final Color? selectedColor; + final Color? disabledColor; + + @override + Color? resolve(Set<WidgetState> states) { + if (explicitColor is WidgetStateColor) { + return WidgetStateProperty.resolveAs<Color?>(explicitColor, states); + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return enabledColor; + } +} + +// Identifies the children of a _ListTileElement. +enum _ListTileSlot { leading, title, subtitle, trailing } + +class _ListTile extends SlottedMultiChildRenderObjectWidget<_ListTileSlot, RenderBox> { + const _ListTile({ + this.leading, + required this.title, + this.subtitle, + this.trailing, + required this.isThreeLine, + required this.isDense, + required this.visualDensity, + required this.textDirection, + required this.titleBaselineType, + required this.horizontalTitleGap, + required this.minVerticalPadding, + required this.minLeadingWidth, + this.minTileHeight, + this.subtitleBaselineType, + required this.titleAlignment, + }); + + final Widget? leading; + final Widget title; + final Widget? subtitle; + final Widget? trailing; + final bool isThreeLine; + final bool isDense; + final VisualDensity visualDensity; + final TextDirection textDirection; + final TextBaseline titleBaselineType; + final TextBaseline? subtitleBaselineType; + final double horizontalTitleGap; + final double minVerticalPadding; + final double minLeadingWidth; + final double? minTileHeight; + final ListTileTitleAlignment titleAlignment; + + @override + Iterable<_ListTileSlot> get slots => _ListTileSlot.values; + + @override + Widget? childForSlot(_ListTileSlot slot) { + return switch (slot) { + _ListTileSlot.leading => leading, + _ListTileSlot.title => title, + _ListTileSlot.subtitle => subtitle, + _ListTileSlot.trailing => trailing, + }; + } + + @override + _RenderListTile createRenderObject(BuildContext context) { + return _RenderListTile( + isThreeLine: isThreeLine, + isDense: isDense, + visualDensity: visualDensity, + textDirection: textDirection, + titleBaselineType: titleBaselineType, + subtitleBaselineType: subtitleBaselineType, + horizontalTitleGap: horizontalTitleGap, + minVerticalPadding: minVerticalPadding, + minLeadingWidth: minLeadingWidth, + minTileHeight: minTileHeight, + titleAlignment: titleAlignment, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderListTile renderObject) { + renderObject + ..isThreeLine = isThreeLine + ..isDense = isDense + ..visualDensity = visualDensity + ..textDirection = textDirection + ..titleBaselineType = titleBaselineType + ..subtitleBaselineType = subtitleBaselineType + ..horizontalTitleGap = horizontalTitleGap + ..minLeadingWidth = minLeadingWidth + ..minTileHeight = minTileHeight + ..minVerticalPadding = minVerticalPadding + ..titleAlignment = titleAlignment; + } +} + +class _RenderListTile extends RenderBox + with SlottedContainerRenderObjectMixin<_ListTileSlot, RenderBox> { + _RenderListTile({ + required bool isDense, + required VisualDensity visualDensity, + required bool isThreeLine, + required TextDirection textDirection, + required TextBaseline titleBaselineType, + TextBaseline? subtitleBaselineType, + required double horizontalTitleGap, + required double minVerticalPadding, + required double minLeadingWidth, + double? minTileHeight, + required ListTileTitleAlignment titleAlignment, + }) : _isDense = isDense, + _visualDensity = visualDensity, + _isThreeLine = isThreeLine, + _textDirection = textDirection, + _titleBaselineType = titleBaselineType, + _subtitleBaselineType = subtitleBaselineType, + _horizontalTitleGap = horizontalTitleGap, + _minVerticalPadding = minVerticalPadding, + _minLeadingWidth = minLeadingWidth, + _minTileHeight = minTileHeight, + _titleAlignment = titleAlignment; + + RenderBox? get leading => childForSlot(_ListTileSlot.leading); + RenderBox get title => childForSlot(_ListTileSlot.title)!; + RenderBox? get subtitle => childForSlot(_ListTileSlot.subtitle); + RenderBox? get trailing => childForSlot(_ListTileSlot.trailing); + + // The returned list is ordered for hit testing. + @override + Iterable<RenderBox> get children { + final RenderBox? title = childForSlot(_ListTileSlot.title); + return <RenderBox>[?leading, ?title, ?subtitle, ?trailing]; + } + + bool get isDense => _isDense; + bool _isDense; + set isDense(bool value) { + if (_isDense == value) { + return; + } + _isDense = value; + markNeedsLayout(); + } + + VisualDensity get visualDensity => _visualDensity; + VisualDensity _visualDensity; + set visualDensity(VisualDensity value) { + if (_visualDensity == value) { + return; + } + _visualDensity = value; + markNeedsLayout(); + } + + bool get isThreeLine => _isThreeLine; + bool _isThreeLine; + set isThreeLine(bool value) { + if (_isThreeLine == value) { + return; + } + _isThreeLine = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + TextBaseline get titleBaselineType => _titleBaselineType; + TextBaseline _titleBaselineType; + set titleBaselineType(TextBaseline value) { + if (_titleBaselineType == value) { + return; + } + _titleBaselineType = value; + markNeedsLayout(); + } + + TextBaseline? get subtitleBaselineType => _subtitleBaselineType; + TextBaseline? _subtitleBaselineType; + set subtitleBaselineType(TextBaseline? value) { + if (_subtitleBaselineType == value) { + return; + } + _subtitleBaselineType = value; + markNeedsLayout(); + } + + double get horizontalTitleGap => _horizontalTitleGap; + double _horizontalTitleGap; + double get _effectiveHorizontalTitleGap => _horizontalTitleGap + visualDensity.horizontal * 2.0; + + set horizontalTitleGap(double value) { + if (_horizontalTitleGap == value) { + return; + } + _horizontalTitleGap = value; + markNeedsLayout(); + } + + double get minVerticalPadding => _minVerticalPadding; + double _minVerticalPadding; + + set minVerticalPadding(double value) { + if (_minVerticalPadding == value) { + return; + } + _minVerticalPadding = value; + markNeedsLayout(); + } + + double get minLeadingWidth => _minLeadingWidth; + double _minLeadingWidth; + + set minLeadingWidth(double value) { + if (_minLeadingWidth == value) { + return; + } + _minLeadingWidth = value; + markNeedsLayout(); + } + + double? _minTileHeight; + double? get minTileHeight => _minTileHeight; + set minTileHeight(double? value) { + if (_minTileHeight == value) { + return; + } + _minTileHeight = value; + markNeedsLayout(); + } + + ListTileTitleAlignment get titleAlignment => _titleAlignment; + ListTileTitleAlignment _titleAlignment; + set titleAlignment(ListTileTitleAlignment value) { + if (_titleAlignment == value) { + return; + } + _titleAlignment = value; + markNeedsLayout(); + } + + @override + bool get sizedByParent => false; + + static double _minWidth(RenderBox? box, double height) { + return box == null ? 0.0 : box.getMinIntrinsicWidth(height); + } + + static double _maxWidth(RenderBox? box, double height) { + return box == null ? 0.0 : box.getMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicWidth(double height) { + final double leadingWidth = leading != null + ? math.max(leading!.getMinIntrinsicWidth(height), _minLeadingWidth) + + _effectiveHorizontalTitleGap + : 0.0; + return leadingWidth + + math.max(_minWidth(title, height), _minWidth(subtitle, height)) + + _maxWidth(trailing, height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final double leadingWidth = leading != null + ? math.max(leading!.getMaxIntrinsicWidth(height), _minLeadingWidth) + + _effectiveHorizontalTitleGap + : 0.0; + return leadingWidth + + math.max(_maxWidth(title, height), _maxWidth(subtitle, height)) + + _maxWidth(trailing, height); + } + + // The target tile height to use if _minTileHeight is not specified. + double get _defaultTileHeight { + final Offset baseDensity = visualDensity.baseSizeAdjustment; + return baseDensity.dy + + switch ((isThreeLine, subtitle != null)) { + (true, _) => isDense ? 76.0 : 88.0, // 3 lines, + (false, true) => isDense ? 64.0 : 72.0, // 2 lines + (false, false) => isDense ? 48.0 : 56.0, // 1 line, + }; + } + + double get _targetTileHeight => _minTileHeight ?? _defaultTileHeight; + + @override + double computeMinIntrinsicHeight(double width) { + final double titleMinHeight = title.getMinIntrinsicHeight(width); + final double? subtitleMinHeight = subtitle?.getMinIntrinsicHeight(width); + + const topAndBottomPaddingMultiplier = 2; + final double contentHeight = + titleMinHeight + + (subtitleMinHeight ?? 0.0) + + topAndBottomPaddingMultiplier * _minVerticalPadding; + + return math.max(_targetTileHeight, contentHeight); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return getMinIntrinsicHeight(width); + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + final parentData = title.parentData! as BoxParentData; + final BaselineOffset offset = + BaselineOffset(title.getDistanceToActualBaseline(baseline)) + parentData.offset.dy; + return offset.offset; + } + + BoxConstraints get maxIconHeightConstraint => BoxConstraints( + // One-line trailing and leading widget heights do not follow + // Material specifications, but this sizing is required to adhere + // to accessibility requirements for smallest tappable widget. + // Two- and three-line trailing widget heights are constrained + // properly according to the Material spec. + maxHeight: (isDense ? 48.0 : 56.0) + visualDensity.baseSizeAdjustment.dy, + ); + + static void _positionBox(RenderBox box, Offset offset) { + final parentData = box.parentData! as BoxParentData; + parentData.offset = offset; + } + + // Implements _RenderListTile's layout algorithm. If `positionChild` is not null, + // it will be called on each child with that child's layout offset. + // + // All of the dimensions below were taken from the Material Design spec: + // https://material.io/design/components/lists.html#specs + _Sizes _computeSizes( + ChildBaselineGetter getBaseline, + ChildLayouter getSize, + BoxConstraints constraints, { + _PositionChild? positionChild, + }) { + final BoxConstraints looseConstraints = constraints.loosen(); + final double tileWidth = looseConstraints.maxWidth; + final BoxConstraints iconConstraints = looseConstraints.enforce(maxIconHeightConstraint); + final RenderBox? leading = this.leading; + final RenderBox? trailing = this.trailing; + + final Size? leadingSize = leading == null ? null : getSize(leading, iconConstraints); + final Size? trailingSize = trailing == null ? null : getSize(trailing, iconConstraints); + + assert(() { + if (tileWidth == 0.0) { + return true; + } + + String? overflowedWidget; + if (tileWidth == leadingSize?.width) { + overflowedWidget = 'Leading'; + } else if (tileWidth == trailingSize?.width) { + overflowedWidget = 'Trailing'; + } + + if (overflowedWidget == null) { + return true; + } + + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary( + '$overflowedWidget widget consumes the entire tile width (including ListTile.contentPadding).', + ), + ErrorDescription( + 'Either resize the tile width so that the ${overflowedWidget.toLowerCase()} widget plus any content padding ' + 'do not exceed the tile width, or use a sized widget, or consider replacing ' + 'ListTile with a custom widget.', + ), + ErrorHint( + 'See also: https://api.flutter.dev/flutter/material/ListTile-class.html#material.ListTile.4', + ), + ]); + }()); + + final double titleStart = leadingSize == null + ? 0.0 + : math.max(_minLeadingWidth, leadingSize.width) + _effectiveHorizontalTitleGap; + + final double adjustedTrailingWidth = trailingSize == null + ? 0.0 + : math.max(trailingSize.width + _effectiveHorizontalTitleGap, 32.0); + + final BoxConstraints textConstraints = looseConstraints.tighten( + width: tileWidth - titleStart - adjustedTrailingWidth, + ); + + final RenderBox? subtitle = this.subtitle; + final double titleHeight = getSize(title, textConstraints).height; + + final bool isLTR = switch (textDirection) { + TextDirection.ltr => true, + TextDirection.rtl => false, + }; + + final double titleY; + final double tileHeight; + if (subtitle == null) { + tileHeight = math.max(_targetTileHeight, titleHeight + 2.0 * _minVerticalPadding); + titleY = (tileHeight - titleHeight) / 2.0; + } else { + final double subtitleHeight = getSize(subtitle, textConstraints).height; + final double titleBaseline = + getBaseline(title, textConstraints, titleBaselineType) ?? titleHeight; + final double subtitleBaseline = + getBaseline(subtitle, textConstraints, subtitleBaselineType!) ?? subtitleHeight; + + final double targetTitleY = + (isThreeLine ? (isDense ? 22.0 : 28.0) : (isDense ? 28.0 : 32.0)) - titleBaseline; + final double targetSubtitleY = + (isThreeLine ? (isDense ? 42.0 : 48.0) : (isDense ? 48.0 : 52.0)) + + visualDensity.vertical * 2.0 - + subtitleBaseline; + // Prevent the title and the subtitle from overlapping by moving them away from + // each other by the same distance. + final double halfOverlap = math.max(targetTitleY + titleHeight - targetSubtitleY, 0) / 2; + final double idealTitleY = targetTitleY - halfOverlap; + final double idealSubtitleY = targetSubtitleY + halfOverlap; + // However if either component can't maintain the minimal padding from the top/bottom edges, the ListTile enters "compat mode". + final bool compact = + idealTitleY < minVerticalPadding || + idealSubtitleY + subtitleHeight + minVerticalPadding > _targetTileHeight; + + // Position subtitle. + positionChild?.call( + subtitle, + Offset( + isLTR ? titleStart : adjustedTrailingWidth, + compact ? minVerticalPadding + titleHeight : idealSubtitleY, + ), + ); + tileHeight = compact + ? 2 * _minVerticalPadding + titleHeight + subtitleHeight + : _targetTileHeight; + titleY = compact ? minVerticalPadding : idealTitleY; + } + + if (positionChild != null) { + positionChild(title, Offset(isLTR ? titleStart : adjustedTrailingWidth, titleY)); + + if (leading != null && leadingSize != null) { + positionChild( + leading, + Offset( + isLTR ? 0.0 : tileWidth - leadingSize.width, + titleAlignment._yOffsetFor(leadingSize.height, tileHeight, this, true), + ), + ); + } + + if (trailing != null && trailingSize != null) { + positionChild( + trailing, + Offset( + isLTR ? tileWidth - trailingSize.width : 0.0, + titleAlignment._yOffsetFor(trailingSize.height, tileHeight, this, false), + ), + ); + } + } + + return ( + titleY: titleY, + textConstraints: textConstraints, + tileSize: Size(tileWidth, tileHeight), + ); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final _Sizes sizes = _computeSizes( + ChildLayoutHelper.getDryBaseline, + ChildLayoutHelper.dryLayoutChild, + constraints, + ); + final BaselineOffset titleBaseline = + BaselineOffset(title.getDryBaseline(sizes.textConstraints, baseline)) + sizes.titleY; + return titleBaseline.offset; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.constrain( + _computeSizes( + ChildLayoutHelper.getDryBaseline, + ChildLayoutHelper.dryLayoutChild, + constraints, + ).tileSize, + ); + } + + @override + void performLayout() { + final Size tileSize = _computeSizes( + ChildLayoutHelper.getBaseline, + ChildLayoutHelper.layoutChild, + constraints, + positionChild: _positionBox, + ).tileSize; + + size = constraints.constrain(tileSize); + assert(size.width == constraints.constrainWidth(tileSize.width)); + assert(size.height == constraints.constrainHeight(tileSize.height)); + } + + @override + void paint(PaintingContext context, Offset offset) { + void doPaint(RenderBox? child) { + if (child != null) { + final parentData = child.parentData! as BoxParentData; + context.paintChild(child, parentData.offset + offset); + } + } + + doPaint(leading); + doPaint(title); + doPaint(subtitle); + doPaint(trailing); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + for (final RenderBox child in children) { + final parentData = child.parentData! as BoxParentData; + final bool isHit = result.addWithPaintOffset( + offset: parentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - parentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } +} + +class _LisTileDefaultsM2 extends ListTileThemeData { + _LisTileDefaultsM2(this.context, ListTileStyle style) + : super( + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + minLeadingWidth: 40, + minVerticalPadding: 4, + shape: const Border(), + style: style, + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get tileColor => Colors.transparent; + + @override + TextStyle? get titleTextStyle => switch (style!) { + ListTileStyle.drawer => _textTheme.bodyLarge, + ListTileStyle.list => _textTheme.titleMedium, + }; + + @override + TextStyle? get subtitleTextStyle => + _textTheme.bodyMedium!.copyWith(color: _textTheme.bodySmall!.color); + + @override + TextStyle? get leadingAndTrailingTextStyle => _textTheme.bodyMedium; + + @override + Color? get selectedColor => _theme.colorScheme.primary; + + @override + Color? get iconColor => switch (_theme.brightness) { + // For the sake of backwards compatibility, the default for unselected + // tiles is Colors.black45 rather than colorScheme.onSurface.withAlpha(0x73). + Brightness.light => Colors.black45, + // null -> use current icon theme color + Brightness.dark => null, + }; +} + +// BEGIN GENERATED TOKEN PROPERTIES - LisTile + +// 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. + +// dart format off +class _LisTileDefaultsM3 extends ListTileThemeData { + _LisTileDefaultsM3(this.context) + : super( + contentPadding: const EdgeInsetsDirectional.only(start: 16.0, end: 24.0), + minLeadingWidth: 24, + minVerticalPadding: 8, + shape: const RoundedRectangleBorder(), + ); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + Color? get tileColor => Colors.transparent; + + @override + TextStyle? get titleTextStyle => _textTheme.bodyLarge!.copyWith(color: _colors.onSurface); + + @override + TextStyle? get subtitleTextStyle => _textTheme.bodyMedium!.copyWith(color: _colors.onSurfaceVariant); + + @override + TextStyle? get leadingAndTrailingTextStyle => _textTheme.labelSmall!.copyWith(color: _colors.onSurfaceVariant); + + @override + Color? get selectedColor => _colors.primary; + + @override + Color? get iconColor => _colors.onSurfaceVariant; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - LisTile diff --git a/packages/material_ui/lib/src/list_tile_theme.dart b/packages/material_ui/lib/src/list_tile_theme.dart new file mode 100644 index 000000000000..9c22950d2c2f --- /dev/null +++ b/packages/material_ui/lib/src/list_tile_theme.dart @@ -0,0 +1,648 @@ +// 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. + +/// @docImport 'checkbox_list_tile.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'expansion_tile.dart'; +/// @docImport 'radio_list_tile.dart'; +/// @docImport 'switch_list_tile.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'list_tile.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Used with [ListTileTheme] to define default property values for +/// descendant [ListTile] widgets, as well as classes that build +/// [ListTile]s, like [CheckboxListTile], [RadioListTile], and +/// [SwitchListTile]. +/// +/// Descendant widgets obtain the current [ListTileThemeData] object +/// using [ListTileTheme.of]. Instances of [ListTileThemeData] can be +/// customized with [ListTileThemeData.copyWith]. +/// +/// A [ListTileThemeData] is often specified as part of the +/// overall [Theme] with [ThemeData.listTileTheme]. +/// +/// All [ListTileThemeData] properties are `null` by default. +/// When a theme property is null, the [ListTile] will provide its own +/// default based on the overall [Theme]'s textTheme and +/// colorScheme. See the individual [ListTile] properties for details. +/// +/// The [Drawer] widget specifies a list tile theme for its children that +/// defines [style] to be [ListTileStyle.drawer]. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class ListTileThemeData with Diagnosticable { + /// Creates a [ListTileThemeData]. + const ListTileThemeData({ + this.dense, + this.shape, + this.style, + this.selectedColor, + this.iconColor, + this.textColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingAndTrailingTextStyle, + this.contentPadding, + this.tileColor, + this.selectedTileColor, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.enableFeedback, + this.mouseCursor, + this.visualDensity, + this.minTileHeight, + this.titleAlignment, + this.controlAffinity, + this.isThreeLine, + }); + + /// Overrides the default value of [ListTile.dense]. + final bool? dense; + + /// Overrides the default value of [ListTile.shape]. + final ShapeBorder? shape; + + /// Overrides the default value of [ListTile.style]. + final ListTileStyle? style; + + /// Overrides the default value of [ListTile.selectedColor]. + final Color? selectedColor; + + /// Overrides the default value of [ListTile.iconColor]. + final Color? iconColor; + + /// Overrides the default value of [ListTile.textColor]. + final Color? textColor; + + /// Overrides the default value of [ListTile.titleTextStyle]. + final TextStyle? titleTextStyle; + + /// Overrides the default value of [ListTile.subtitleTextStyle]. + final TextStyle? subtitleTextStyle; + + /// Overrides the default value of [ListTile.leadingAndTrailingTextStyle]. + final TextStyle? leadingAndTrailingTextStyle; + + /// Overrides the default value of [ListTile.contentPadding]. + final EdgeInsetsGeometry? contentPadding; + + /// Overrides the default value of [ListTile.tileColor]. + final Color? tileColor; + + /// Overrides the default value of [ListTile.selectedTileColor]. + final Color? selectedTileColor; + + /// Overrides the default value of [ListTile.horizontalTitleGap]. + final double? horizontalTitleGap; + + /// Overrides the default value of [ListTile.minVerticalPadding]. + final double? minVerticalPadding; + + /// Overrides the default value of [ListTile.minLeadingWidth]. + final double? minLeadingWidth; + + /// Overrides the default value of [ListTile.minTileHeight]. + final double? minTileHeight; + + /// Overrides the default value of [ListTile.enableFeedback]. + final bool? enableFeedback; + + /// If specified, overrides the default value of [ListTile.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// If specified, overrides the default value of [ListTile.visualDensity]. + final VisualDensity? visualDensity; + + /// If specified, overrides the default value of [ListTile.titleAlignment]. + final ListTileTitleAlignment? titleAlignment; + + /// If specified, overrides the default value of [CheckboxListTile.controlAffinity] + /// or [ExpansionTile.controlAffinity] or [SwitchListTile.controlAffinity] or [RadioListTile.controlAffinity]. + final ListTileControlAffinity? controlAffinity; + + /// If specified, overrides the default value of [ListTile.isThreeLine] + /// or [CheckboxListTile.isThreeLine] or [RadioListTile.isThreeLine] or [SwitchListTile.isThreeLine]. + final bool? isThreeLine; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + ListTileThemeData copyWith({ + bool? dense, + ShapeBorder? shape, + ListTileStyle? style, + Color? selectedColor, + Color? iconColor, + Color? textColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + TextStyle? leadingAndTrailingTextStyle, + EdgeInsetsGeometry? contentPadding, + Color? tileColor, + Color? selectedTileColor, + double? horizontalTitleGap, + double? minVerticalPadding, + double? minLeadingWidth, + double? minTileHeight, + bool? enableFeedback, + WidgetStateProperty<MouseCursor?>? mouseCursor, + bool? isThreeLine, + VisualDensity? visualDensity, + ListTileTitleAlignment? titleAlignment, + ListTileControlAffinity? controlAffinity, + }) { + return ListTileThemeData( + dense: dense ?? this.dense, + shape: shape ?? this.shape, + style: style ?? this.style, + selectedColor: selectedColor ?? this.selectedColor, + iconColor: iconColor ?? this.iconColor, + textColor: textColor ?? this.textColor, + titleTextStyle: titleTextStyle ?? this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? this.subtitleTextStyle, + leadingAndTrailingTextStyle: leadingAndTrailingTextStyle ?? this.leadingAndTrailingTextStyle, + contentPadding: contentPadding ?? this.contentPadding, + tileColor: tileColor ?? this.tileColor, + selectedTileColor: selectedTileColor ?? this.selectedTileColor, + horizontalTitleGap: horizontalTitleGap ?? this.horizontalTitleGap, + minVerticalPadding: minVerticalPadding ?? this.minVerticalPadding, + minLeadingWidth: minLeadingWidth ?? this.minLeadingWidth, + minTileHeight: minTileHeight ?? this.minTileHeight, + enableFeedback: enableFeedback ?? this.enableFeedback, + mouseCursor: mouseCursor ?? this.mouseCursor, + visualDensity: visualDensity ?? this.visualDensity, + titleAlignment: titleAlignment ?? this.titleAlignment, + controlAffinity: controlAffinity ?? this.controlAffinity, + isThreeLine: isThreeLine ?? this.isThreeLine, + ); + } + + /// Linearly interpolate between ListTileThemeData objects. + static ListTileThemeData? lerp(ListTileThemeData? a, ListTileThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return ListTileThemeData( + dense: t < 0.5 ? a?.dense : b?.dense, + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + style: t < 0.5 ? a?.style : b?.style, + selectedColor: Color.lerp(a?.selectedColor, b?.selectedColor, t), + iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), + textColor: Color.lerp(a?.textColor, b?.textColor, t), + titleTextStyle: TextStyle.lerp(a?.titleTextStyle, b?.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp(a?.subtitleTextStyle, b?.subtitleTextStyle, t), + leadingAndTrailingTextStyle: TextStyle.lerp( + a?.leadingAndTrailingTextStyle, + b?.leadingAndTrailingTextStyle, + t, + ), + contentPadding: EdgeInsetsGeometry.lerp(a?.contentPadding, b?.contentPadding, t), + tileColor: Color.lerp(a?.tileColor, b?.tileColor, t), + selectedTileColor: Color.lerp(a?.selectedTileColor, b?.selectedTileColor, t), + horizontalTitleGap: lerpDouble(a?.horizontalTitleGap, b?.horizontalTitleGap, t), + minVerticalPadding: lerpDouble(a?.minVerticalPadding, b?.minVerticalPadding, t), + minLeadingWidth: lerpDouble(a?.minLeadingWidth, b?.minLeadingWidth, t), + minTileHeight: lerpDouble(a?.minTileHeight, b?.minTileHeight, t), + enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback, + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity, + titleAlignment: t < 0.5 ? a?.titleAlignment : b?.titleAlignment, + controlAffinity: t < 0.5 ? a?.controlAffinity : b?.controlAffinity, + isThreeLine: t < 0.5 ? a?.isThreeLine : b?.isThreeLine, + ); + } + + @override + int get hashCode => Object.hashAll(<Object?>[ + dense, + shape, + style, + selectedColor, + iconColor, + textColor, + titleTextStyle, + subtitleTextStyle, + leadingAndTrailingTextStyle, + contentPadding, + tileColor, + selectedTileColor, + horizontalTitleGap, + minVerticalPadding, + minLeadingWidth, + minTileHeight, + enableFeedback, + mouseCursor, + visualDensity, + titleAlignment, + controlAffinity, + isThreeLine, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ListTileThemeData && + other.dense == dense && + other.shape == shape && + other.style == style && + other.selectedColor == selectedColor && + other.iconColor == iconColor && + other.titleTextStyle == titleTextStyle && + other.subtitleTextStyle == subtitleTextStyle && + other.leadingAndTrailingTextStyle == leadingAndTrailingTextStyle && + other.textColor == textColor && + other.contentPadding == contentPadding && + other.tileColor == tileColor && + other.selectedTileColor == selectedTileColor && + other.horizontalTitleGap == horizontalTitleGap && + other.minVerticalPadding == minVerticalPadding && + other.minLeadingWidth == minLeadingWidth && + other.minTileHeight == minTileHeight && + other.enableFeedback == enableFeedback && + other.mouseCursor == mouseCursor && + other.visualDensity == visualDensity && + other.titleAlignment == titleAlignment && + other.controlAffinity == controlAffinity && + other.isThreeLine == isThreeLine; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<bool>('dense', dense, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(EnumProperty<ListTileStyle>('style', style, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); + properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('titleTextStyle', titleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('subtitleTextStyle', subtitleTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'leadingAndTrailingTextStyle', + leadingAndTrailingTextStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('contentPadding', contentPadding, defaultValue: null), + ); + properties.add(ColorProperty('tileColor', tileColor, defaultValue: null)); + properties.add(ColorProperty('selectedTileColor', selectedTileColor, defaultValue: null)); + properties.add(DoubleProperty('horizontalTitleGap', horizontalTitleGap, defaultValue: null)); + properties.add(DoubleProperty('minVerticalPadding', minVerticalPadding, defaultValue: null)); + properties.add(DoubleProperty('minLeadingWidth', minLeadingWidth, defaultValue: null)); + properties.add(DoubleProperty('minTileHeight', minTileHeight, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ListTileTitleAlignment>( + 'titleAlignment', + titleAlignment, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<ListTileControlAffinity>( + 'controlAffinity', + controlAffinity, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<bool>('isThreeLine', isThreeLine, defaultValue: null)); + } +} + +/// An inherited widget that defines color and style parameters for [ListTile]s +/// in this widget's subtree. +/// +/// Values specified here are used for [ListTile] properties that are not given +/// an explicit non-null value. +/// +/// The [Drawer] widget specifies a tile theme for its children which sets +/// [style] to [ListTileStyle.drawer]. +class ListTileTheme extends InheritedTheme { + /// Creates a list tile theme that defines the color and style parameters for + /// descendant [ListTile]s. + /// + /// Only the [data] parameter should be used. The other parameters are + /// redundant (are now obsolete) and will be deprecated in a future update. + const ListTileTheme({ + super.key, + ListTileThemeData? data, + bool? dense, + ShapeBorder? shape, + ListTileStyle? style, + Color? selectedColor, + Color? iconColor, + Color? textColor, + EdgeInsetsGeometry? contentPadding, + Color? tileColor, + Color? selectedTileColor, + bool? enableFeedback, + WidgetStateProperty<MouseCursor?>? mouseCursor, + double? horizontalTitleGap, + double? minVerticalPadding, + double? minLeadingWidth, + ListTileControlAffinity? controlAffinity, + required super.child, + }) : assert( + data == null || + (shape ?? + selectedColor ?? + iconColor ?? + textColor ?? + contentPadding ?? + tileColor ?? + selectedTileColor ?? + enableFeedback ?? + mouseCursor ?? + horizontalTitleGap ?? + minVerticalPadding ?? + minLeadingWidth ?? + controlAffinity) == + null, + ), + _data = data, + _dense = dense, + _shape = shape, + _style = style, + _selectedColor = selectedColor, + _iconColor = iconColor, + _textColor = textColor, + _contentPadding = contentPadding, + _tileColor = tileColor, + _selectedTileColor = selectedTileColor, + _enableFeedback = enableFeedback, + _mouseCursor = mouseCursor, + _horizontalTitleGap = horizontalTitleGap, + _minVerticalPadding = minVerticalPadding, + _minLeadingWidth = minLeadingWidth, + _controlAffinity = controlAffinity; + + final ListTileThemeData? _data; + final bool? _dense; + final ShapeBorder? _shape; + final ListTileStyle? _style; + final Color? _selectedColor; + final Color? _iconColor; + final Color? _textColor; + final EdgeInsetsGeometry? _contentPadding; + final Color? _tileColor; + final Color? _selectedTileColor; + final double? _horizontalTitleGap; + final double? _minVerticalPadding; + final double? _minLeadingWidth; + final bool? _enableFeedback; + final WidgetStateProperty<MouseCursor?>? _mouseCursor; + final ListTileControlAffinity? _controlAffinity; + + /// The configuration of this theme. + ListTileThemeData get data { + return _data ?? + ListTileThemeData( + dense: _dense, + shape: _shape, + style: _style, + selectedColor: _selectedColor, + iconColor: _iconColor, + textColor: _textColor, + contentPadding: _contentPadding, + tileColor: _tileColor, + selectedTileColor: _selectedTileColor, + enableFeedback: _enableFeedback, + mouseCursor: _mouseCursor, + horizontalTitleGap: _horizontalTitleGap, + minVerticalPadding: _minVerticalPadding, + minLeadingWidth: _minLeadingWidth, + controlAffinity: _controlAffinity, + ); + } + + /// Overrides the default value of [ListTile.dense]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.dense] property instead. + bool? get dense => _data != null ? _data.dense : _dense; + + /// Overrides the default value of [ListTile.shape]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.shape] property instead. + ShapeBorder? get shape => _data != null ? _data.shape : _shape; + + /// Overrides the default value of [ListTile.style]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.style] property instead. + ListTileStyle? get style => _data != null ? _data.style : _style; + + /// Overrides the default value of [ListTile.selectedColor]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.selectedColor] property instead. + Color? get selectedColor => _data != null ? _data.selectedColor : _selectedColor; + + /// Overrides the default value of [ListTile.iconColor]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.iconColor] property instead. + Color? get iconColor => _data != null ? _data.iconColor : _iconColor; + + /// Overrides the default value of [ListTile.textColor]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.textColor] property instead. + Color? get textColor => _data != null ? _data.textColor : _textColor; + + /// Overrides the default value of [ListTile.contentPadding]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.contentPadding] property instead. + EdgeInsetsGeometry? get contentPadding => _data != null ? _data.contentPadding : _contentPadding; + + /// Overrides the default value of [ListTile.tileColor]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.tileColor] property instead. + Color? get tileColor => _data != null ? _data.tileColor : _tileColor; + + /// Overrides the default value of [ListTile.selectedTileColor]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.selectedTileColor] property instead. + Color? get selectedTileColor => _data != null ? _data.selectedTileColor : _selectedTileColor; + + /// Overrides the default value of [ListTile.horizontalTitleGap]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.horizontalTitleGap] property instead. + double? get horizontalTitleGap => _data != null ? _data.horizontalTitleGap : _horizontalTitleGap; + + /// Overrides the default value of [ListTile.minVerticalPadding]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.minVerticalPadding] property instead. + double? get minVerticalPadding => _data != null ? _data.minVerticalPadding : _minVerticalPadding; + + /// Overrides the default value of [ListTile.minLeadingWidth]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.minLeadingWidth] property instead. + double? get minLeadingWidth => _data != null ? _data.minLeadingWidth : _minLeadingWidth; + + /// Overrides the default value of [ListTile.enableFeedback]. + /// + /// This property is obsolete: please use the [data] + /// [ListTileThemeData.enableFeedback] property instead. + bool? get enableFeedback => _data != null ? _data.enableFeedback : _enableFeedback; + + /// Overrides the default value of [CheckboxListTile.controlAffinity] + /// or [ExpansionTile.controlAffinity] or [SwitchListTile.controlAffinity] or [RadioListTile.controlAffinity] + /// + /// This property is obsolete: please use the + /// [ListTileThemeData.controlAffinity] property instead. + ListTileControlAffinity? get controlAffinity => + _data != null ? _data.controlAffinity : _controlAffinity; + + /// The [data] property of the closest instance of this class that + /// encloses the given context. + /// + /// If there is no enclosing [ListTileTheme] widget, then + /// [ThemeData.listTileTheme] is used (see [Theme.of]). + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ListTileThemeData theme = ListTileTheme.of(context); + /// ``` + static ListTileThemeData of(BuildContext context) { + final ListTileTheme? result = context.dependOnInheritedWidgetOfExactType<ListTileTheme>(); + return result?.data ?? Theme.of(context).listTileTheme; + } + + /// Creates a list tile theme that controls the color and style parameters for + /// [ListTile]s, and merges in the current list tile theme, if any. + static Widget merge({ + Key? key, + bool? dense, + ShapeBorder? shape, + ListTileStyle? style, + Color? selectedColor, + Color? iconColor, + Color? textColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + TextStyle? leadingAndTrailingTextStyle, + EdgeInsetsGeometry? contentPadding, + Color? tileColor, + Color? selectedTileColor, + bool? enableFeedback, + double? horizontalTitleGap, + double? minVerticalPadding, + double? minLeadingWidth, + double? minTileHeight, + ListTileTitleAlignment? titleAlignment, + WidgetStateProperty<MouseCursor?>? mouseCursor, + VisualDensity? visualDensity, + ListTileControlAffinity? controlAffinity, + bool? isThreeLine, + required Widget child, + }) { + return Builder( + builder: (BuildContext context) { + final ListTileThemeData parent = ListTileTheme.of(context); + return ListTileTheme( + key: key, + data: ListTileThemeData( + dense: dense ?? parent.dense, + shape: shape ?? parent.shape, + style: style ?? parent.style, + selectedColor: selectedColor ?? parent.selectedColor, + iconColor: iconColor ?? parent.iconColor, + textColor: textColor ?? parent.textColor, + titleTextStyle: titleTextStyle ?? parent.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? parent.subtitleTextStyle, + leadingAndTrailingTextStyle: + leadingAndTrailingTextStyle ?? parent.leadingAndTrailingTextStyle, + contentPadding: contentPadding ?? parent.contentPadding, + tileColor: tileColor ?? parent.tileColor, + selectedTileColor: selectedTileColor ?? parent.selectedTileColor, + enableFeedback: enableFeedback ?? parent.enableFeedback, + horizontalTitleGap: horizontalTitleGap ?? parent.horizontalTitleGap, + minVerticalPadding: minVerticalPadding ?? parent.minVerticalPadding, + minLeadingWidth: minLeadingWidth ?? parent.minLeadingWidth, + minTileHeight: minTileHeight ?? parent.minTileHeight, + titleAlignment: titleAlignment ?? parent.titleAlignment, + mouseCursor: mouseCursor ?? parent.mouseCursor, + visualDensity: visualDensity ?? parent.visualDensity, + controlAffinity: controlAffinity ?? parent.controlAffinity, + isThreeLine: isThreeLine ?? parent.isThreeLine, + ), + child: child, + ); + }, + ); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ListTileTheme( + data: ListTileThemeData( + dense: dense, + shape: shape, + style: style, + selectedColor: selectedColor, + iconColor: iconColor, + textColor: textColor, + contentPadding: contentPadding, + tileColor: tileColor, + selectedTileColor: selectedTileColor, + enableFeedback: enableFeedback, + horizontalTitleGap: horizontalTitleGap, + minVerticalPadding: minVerticalPadding, + minLeadingWidth: minLeadingWidth, + isThreeLine: _data?.isThreeLine, + ), + child: child, + ); + } + + @override + bool updateShouldNotify(ListTileTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/magnifier.dart b/packages/material_ui/lib/src/magnifier.dart new file mode 100644 index 000000000000..e8f37b43c4b2 --- /dev/null +++ b/packages/material_ui/lib/src/magnifier.dart @@ -0,0 +1,365 @@ +// 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:async'; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; + +/// A [Magnifier] positioned by rules dictated by the native Android magnifier. +/// +/// The positioning rules are based on [magnifierInfo], as follows: +/// +/// - The loupe tracks the gesture's _x_ coordinate, clamping to the beginning +/// and end of the currently editing line. +/// +/// - The focal point never contains anything out of the bounds of the text +/// field or other widget being magnified (the [MagnifierInfo.fieldBounds]). +/// +/// - The focal point always remains aligned with the _y_ coordinate of the touch. +/// +/// - The loupe always remains on the screen. +/// +/// - When the line targeted by the touch's _y_ coordinate changes, the position +/// is animated over [jumpBetweenLinesAnimationDuration]. +/// +/// This behavior was based on the Android 12 source code, where possible, and +/// on eyeballing a Pixel 6 running Android 12 otherwise. +class TextMagnifier extends StatefulWidget { + /// Creates a [TextMagnifier]. + /// + /// The [magnifierInfo] must be provided, and must be updated with new values + /// as the user's touch changes. + const TextMagnifier({super.key, required this.magnifierInfo}); + + /// A [TextMagnifierConfiguration] that returns a [CupertinoTextMagnifier] on + /// iOS, [TextMagnifier] on Android, and null on all other platforms, and + /// shows the editing handles only on iOS. + static TextMagnifierConfiguration adaptiveMagnifierConfiguration = TextMagnifierConfiguration( + shouldDisplayHandlesInMagnifier: defaultTargetPlatform == TargetPlatform.iOS, + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> magnifierInfo, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return CupertinoTextMagnifier(controller: controller, magnifierInfo: magnifierInfo); + case TargetPlatform.android: + return TextMagnifier(magnifierInfo: magnifierInfo); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return null; + } + }, + ); + + /// The duration that the position is animated if [TextMagnifier] just switched + /// between lines. + static const Duration jumpBetweenLinesAnimationDuration = Duration(milliseconds: 70); + + /// The current status of the user's touch. + /// + /// As the value of the [magnifierInfo] changes, the position of the loupe is + /// adjusted automatically, according to the rules described in the + /// [TextMagnifier] class description. + final ValueNotifier<MagnifierInfo> magnifierInfo; + + @override + State<TextMagnifier> createState() => _TextMagnifierState(); +} + +class _TextMagnifierState extends State<TextMagnifier> { + // Should _only_ be null on construction. This is because of the animation logic. + // + // Animations are added when `last_build_y != current_build_y`. This condition + // is true on the initial render, which would mean that the initial + // build would be animated - this is undesired. Thus, this is null for the + // first frame and the condition becomes `magnifierPosition != null && last_build_y != this_build_y`. + Offset? _magnifierPosition; + + // A timer that unsets itself after an animation duration. + // If the timer exists, then the magnifier animates its position - + // if this timer does not exist, the magnifier tracks the gesture (with respect + // to the positioning rules) directly. + Timer? _positionShouldBeAnimatedTimer; + bool get _positionShouldBeAnimated => _positionShouldBeAnimatedTimer != null; + + Offset _extraFocalPointOffset = Offset.zero; + + @override + void initState() { + super.initState(); + widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); + } + + @override + void dispose() { + widget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); + _positionShouldBeAnimatedTimer?.cancel(); + super.dispose(); + } + + @override + void didChangeDependencies() { + _determineMagnifierPositionAndFocalPoint(); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(TextMagnifier oldWidget) { + if (oldWidget.magnifierInfo != widget.magnifierInfo) { + oldWidget.magnifierInfo.removeListener(_determineMagnifierPositionAndFocalPoint); + widget.magnifierInfo.addListener(_determineMagnifierPositionAndFocalPoint); + } + super.didUpdateWidget(oldWidget); + } + + void _determineMagnifierPositionAndFocalPoint() { + final MagnifierInfo selectionInfo = widget.magnifierInfo.value; + final Rect screenRect = Offset.zero & MediaQuery.sizeOf(context); + + // Since by default we draw at the top left corner, this offset + // shifts the magnifier so we draw at the center, and then also includes + // the "above touch point" shift. + final basicMagnifierOffset = Offset( + Magnifier.kDefaultMagnifierSize.width / 2, + Magnifier.kDefaultMagnifierSize.height + Magnifier.kStandardVerticalFocalPointShift, + ); + + // Since the magnifier should not go past the edges of the line, + // but must track the gesture otherwise, constrain the X of the magnifier + // to always stay between line start and end. + final double magnifierX = clampDouble( + selectionInfo.globalGesturePosition.dx, + selectionInfo.currentLineBoundaries.left, + selectionInfo.currentLineBoundaries.right, + ); + + // Place the magnifier at the previously calculated X, and the Y should be + // exactly at the center of the handle. + final Rect unadjustedMagnifierRect = + Offset(magnifierX, selectionInfo.caretRect.center.dy) - basicMagnifierOffset & + Magnifier.kDefaultMagnifierSize; + + // Shift the magnifier so that, if we are ever out of the screen, we become in bounds. + // This probably won't have much of an effect on the X, since it is already bound + // to the currentLineBoundaries, but will shift vertically if the magnifier is out of bounds. + final Rect screenBoundsAdjustedMagnifierRect = MagnifierController.shiftWithinBounds( + bounds: screenRect, + rect: unadjustedMagnifierRect, + ); + + // Done with the magnifier position! + final Offset finalMagnifierPosition = screenBoundsAdjustedMagnifierRect.topLeft; + + // The insets, from either edge, that the focal point should not point + // past lest the magnifier displays something out of bounds. + final double horizontalMaxFocalPointEdgeInsets = + (Magnifier.kDefaultMagnifierSize.width / 2) / Magnifier._magnification; + + // Adjust the focal point horizontally such that none of the magnifier + // ever points to anything out of bounds. + final double newGlobalFocalPointX; + + // If the text field is so narrow that we must show out of bounds, + // then settle for pointing to the center all the time. + if (selectionInfo.fieldBounds.width < horizontalMaxFocalPointEdgeInsets * 2) { + newGlobalFocalPointX = selectionInfo.fieldBounds.center.dx; + } else { + // Otherwise, we can clamp the focal point to always point in bounds. + newGlobalFocalPointX = clampDouble( + screenBoundsAdjustedMagnifierRect.center.dx, + selectionInfo.fieldBounds.left + horizontalMaxFocalPointEdgeInsets, + selectionInfo.fieldBounds.right - horizontalMaxFocalPointEdgeInsets, + ); + } + + // Since the previous value is now a global offset (i.e. `newGlobalFocalPoint` + // is now a global offset), we must subtract the magnifier's global offset + // to obtain the relative shift in the focal point. + final double newRelativeFocalPointX = + newGlobalFocalPointX - screenBoundsAdjustedMagnifierRect.center.dx; + + // The Y component means that if we are pressed up against the top of the screen, + // then we should adjust the focal point such that it now points to how far we moved + // the magnifier. screenBoundsAdjustedMagnifierRect.top == unadjustedMagnifierRect.top for most cases, + // but when pressed up against the top of the screen, we adjust the focal point by + // the amount that we shifted from our "natural" position. + final focalPointAdjustmentForScreenBoundsAdjustment = Offset( + newRelativeFocalPointX, + unadjustedMagnifierRect.top - screenBoundsAdjustedMagnifierRect.top, + ); + + Timer? positionShouldBeAnimated = _positionShouldBeAnimatedTimer; + + if (_magnifierPosition != null && finalMagnifierPosition.dy != _magnifierPosition!.dy) { + if (_positionShouldBeAnimatedTimer != null && _positionShouldBeAnimatedTimer!.isActive) { + _positionShouldBeAnimatedTimer!.cancel(); + } + + // Create a timer that deletes itself when the timer is complete. + // This is `mounted` safe, since the timer is canceled in `dispose`. + positionShouldBeAnimated = Timer( + TextMagnifier.jumpBetweenLinesAnimationDuration, + () => setState(() { + _positionShouldBeAnimatedTimer = null; + }), + ); + } + + setState(() { + _magnifierPosition = finalMagnifierPosition; + _positionShouldBeAnimatedTimer = positionShouldBeAnimated; + _extraFocalPointOffset = focalPointAdjustmentForScreenBoundsAdjustment; + }); + } + + @override + Widget build(BuildContext context) { + assert( + _magnifierPosition != null, + 'Magnifier position should only be null before the first build.', + ); + + return AnimatedPositioned( + top: _magnifierPosition!.dy, + left: _magnifierPosition!.dx, + // Material magnifier typically does not animate, unless we jump between lines, + // in which case we animate between lines. + duration: _positionShouldBeAnimated + ? TextMagnifier.jumpBetweenLinesAnimationDuration + : Duration.zero, + child: Magnifier(additionalFocalPointOffset: _extraFocalPointOffset), + ); + } +} + +/// A Material-styled magnifying glass. +/// +/// {@macro flutter.widgets.magnifier.intro} +/// +/// This widget focuses on mimicking the _style_ of the magnifier on material. +/// For a widget that is focused on mimicking the _behavior_ of a material +/// magnifier, see [TextMagnifier], which uses [Magnifier]. +/// +/// The styles implemented in this widget were based on the Android 12 source +/// code, where possible, and on eyeballing a Pixel 6 running Android 12 +/// otherwise. +class Magnifier extends StatelessWidget { + /// Creates a [RawMagnifier] in the Material style. + const Magnifier({ + super.key, + this.additionalFocalPointOffset = Offset.zero, + this.borderRadius = const BorderRadius.all(Radius.circular(_borderRadius)), + this.filmColor = const Color.fromARGB(8, 158, 158, 158), + this.shadows = const <BoxShadow>[ + BoxShadow( + blurRadius: 1.5, + offset: Offset(0.0, 2.0), + spreadRadius: 0.75, + color: Color.fromARGB(25, 0, 0, 0), + ), + ], + this.clipBehavior = Clip.hardEdge, + this.size = Magnifier.kDefaultMagnifierSize, + }); + + /// The default size of this [Magnifier]. + /// + /// The size of the magnifier may be modified through the constructor; + /// [kDefaultMagnifierSize] is extracted from the default parameter of + /// [Magnifier]'s constructor so that positioners may depend on it. + static const Size kDefaultMagnifierSize = Size(77.37, 37.9); + + /// The vertical distance that the magnifier should be above the focal point. + /// + /// The [kStandardVerticalFocalPointShift] value is a constant so that + /// positioning of this [Magnifier] can be done with a guaranteed size, as + /// opposed to an estimate. + static const double kStandardVerticalFocalPointShift = 22.0; + + static const double _borderRadius = 40; + static const double _magnification = 1.25; + + /// Any additional offset the focal point requires to "point" + /// to the correct place. + /// + /// This value is added to [kStandardVerticalFocalPointShift] to obtain the + /// actual offset. + /// + /// This is useful for instances where the magnifier is not pointing to + /// something directly below it. + final Offset additionalFocalPointOffset; + + /// The border radius for this magnifier. + /// + /// The magnifier's shape is a [RoundedRectangleBorder] with this radius. + final BorderRadius borderRadius; + + /// The color to tint the image in this [Magnifier]. + /// + /// On native Android, there is a almost transparent gray tint to the + /// magnifier, in order to better distinguish the contents of the lens from + /// the background. + final Color filmColor; + + /// A list of shadows cast by the [Magnifier]. + /// + /// If the shadows use a [BlurStyle] that paints inside the shape, or if they + /// are offset, then a [clipBehavior] that enables clipping (such as the + /// default [Clip.hardEdge]) is recommended, otherwise the shadow will occlude + /// the magnifier (the shadow is drawn above the magnifier so as to not be + /// included in the magnified image). + /// + /// By default, the shadows are offset vertically by two logical pixels, so + /// clipping is recommended. + /// + /// A shadow that uses [BlurStyle.outer] and is not offset does not need + /// clipping; in that case, consider setting [clipBehavior] to [Clip.none]. + final List<BoxShadow> shadows; + + /// Whether and how to clip the [shadows] that render inside the loupe. + /// + /// Defaults to [Clip.hardEdge]. + /// + /// A value of [Clip.none] can be used if the shadow will not paint where the + /// magnified image appears, or if doing so is intentional (e.g. to blur the + /// edges of the magnified image). + /// + /// See the discussion at [shadows]. + final Clip clipBehavior; + + /// The [Size] of this [Magnifier]. + /// + /// The [shadows] are drawn outside of the [size]. + final Size size; + + @override + Widget build(BuildContext context) { + return RawMagnifier( + decoration: MagnifierDecoration( + shape: RoundedRectangleBorder(borderRadius: borderRadius), + shadows: shadows, + ), + clipBehavior: clipBehavior, + magnificationScale: _magnification, + focalPointOffset: + additionalFocalPointOffset + + Offset(0, kStandardVerticalFocalPointShift + kDefaultMagnifierSize.height / 2), + size: size, + child: ColoredBox( + // This couldn't be part of the decoration (even if the + // MagnifierDecoration supported specifying a color) because the + // decoration's shadows are offset and therefore we set a clipBehavior + // that clips the inner part of the decoration to avoid occluding the + // magnified image with the shadow. + color: filmColor, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/material.dart b/packages/material_ui/lib/src/material.dart new file mode 100644 index 000000000000..195078de2e02 --- /dev/null +++ b/packages/material_ui/lib/src/material.dart @@ -0,0 +1,981 @@ +// 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. + +/// @docImport 'card.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'colors.dart'; +/// @docImport 'ink_decoration.dart'; +/// @docImport 'ink_highlight.dart'; +/// @docImport 'ink_splash.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'material_button.dart'; +/// @docImport 'mergeable_material.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'constants.dart'; +import 'elevation_overlay.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Signature for the callback used by ink effects to obtain the rectangle for the effect. +/// +/// Used by [InkHighlight] and [InkSplash], for example. +typedef RectCallback = Rect Function(); + +/// The various kinds of material in Material Design. Used to +/// configure the default behavior of [Material] widgets. +/// +/// See also: +/// +/// * [Material], in particular [Material.type]. +/// * [kMaterialEdges] +enum MaterialType { + /// Rectangle using default theme canvas color. + canvas, + + /// Rounded edges, card theme color. + card, + + /// A circle, no color by default (used for floating action buttons). + circle, + + /// Rounded edges, no color by default (used for [MaterialButton] buttons). + button, + + /// A transparent piece of material that draws ink splashes and highlights. + /// + /// While the material metaphor describes child widgets as printed on the + /// material itself and do not hide ink effects, in practice the [Material] + /// widget draws child widgets on top of the ink effects. + /// A [Material] with type transparency can be placed on top of opaque widgets + /// to show ink effects on top of them. + /// + /// Prefer using the [Ink] widget for showing ink effects on top of opaque + /// widgets. + transparency, +} + +/// The border radii used by the various kinds of material in Material Design. +/// +/// See also: +/// +/// * [MaterialType] +/// * [Material] +const Map<MaterialType, BorderRadius?> kMaterialEdges = <MaterialType, BorderRadius?>{ + MaterialType.canvas: null, + MaterialType.card: BorderRadius.all(Radius.circular(2.0)), + MaterialType.circle: null, + MaterialType.button: BorderRadius.all(Radius.circular(2.0)), + MaterialType.transparency: null, +}; + +/// An interface for creating [InkSplash]s and [InkHighlight]s on a [Material]. +/// +/// Typically obtained via [Material.of]. +abstract class MaterialInkController { + /// The color of the material. + Color? get color; + + /// The ticker provider used by the controller. + /// + /// Ink features that are added to this controller with [addInkFeature] should + /// use this vsync to drive their animations. + TickerProvider get vsync; + + /// Add an [InkFeature], such as an [InkSplash] or an [InkHighlight]. + /// + /// The ink feature will paint as part of this controller. + void addInkFeature(InkFeature feature); + + /// Notifies the controller that one of its ink features needs to repaint. + void markNeedsPaint(); +} + +/// A piece of material. +/// +/// The Material widget is responsible for: +/// +/// 1. Clipping: If [clipBehavior] is not [Clip.none], Material clips its widget +/// sub-tree to the shape specified by [shape], [type], and [borderRadius]. +/// By default, [clipBehavior] is [Clip.none] for performance considerations. +/// See [Ink] for an example of how this affects clipping [Ink] widgets. +/// 2. Elevation: Material elevates its widget sub-tree on the Z axis by +/// [elevation] pixels, and draws the appropriate shadow. +/// 3. Ink effects: Material shows ink effects implemented by [InkFeature]s +/// like [InkSplash] and [InkHighlight] below its children. +/// +/// ## The Material Metaphor +/// +/// Material is the central metaphor in Material Design. Each piece of material +/// exists at a given elevation, which influences how that piece of material +/// visually relates to other pieces of material and how that material casts +/// shadows. +/// +/// Most user interface elements are either conceptually printed on a piece of +/// material or themselves made of material. Material reacts to user input using +/// [InkSplash] and [InkHighlight] effects. To trigger a reaction on the +/// material, use a [MaterialInkController] obtained via [Material.of]. +/// +/// In general, the features of a [Material] should not change over time (e.g. a +/// [Material] should not change its [color], [shadowColor] or [type]). +/// Changes to [elevation], [shadowColor] and [surfaceTintColor] are animated +/// for [animationDuration]. Changes to [shape] are animated if [type] is +/// not [MaterialType.transparency] and [ShapeBorder.lerp] between the previous +/// and next [shape] values is supported. Shape changes are also animated +/// for [animationDuration]. +/// +/// ## Shape +/// +/// The shape for material is determined by [shape], [type], and [borderRadius]. +/// +/// - If [shape] is non null, it determines the shape. +/// - If [shape] is null and [borderRadius] is non null, the shape is a +/// rounded rectangle, with corners specified by [borderRadius]. +/// - If [shape] and [borderRadius] are null, [type] determines the +/// shape as follows: +/// - [MaterialType.canvas]: the default material shape is a rectangle. +/// - [MaterialType.card]: the default material shape is a rectangle with +/// rounded edges. The edge radii is specified by [kMaterialEdges]. +/// - [MaterialType.circle]: the default material shape is a circle. +/// - [MaterialType.button]: the default material shape is a rectangle with +/// rounded edges. The edge radii is specified by [kMaterialEdges]. +/// - [MaterialType.transparency]: the default material shape is a rectangle. +/// +/// ## Border +/// +/// If [shape] is not null, then its border will also be painted (if any). +/// +/// ## Layout change notifications +/// +/// If the layout changes (e.g. because there's a list on the material, and it's +/// been scrolled), a [LayoutChangedNotification] must be dispatched at the +/// relevant subtree. This in particular means that transitions (e.g. +/// [SlideTransition]) should not be placed inside [Material] widgets so as to +/// move subtrees that contain [InkResponse]s, [InkWell]s, [Ink]s, or other +/// widgets that use the [InkFeature] mechanism. Otherwise, in-progress ink +/// features (e.g., ink splashes and ink highlights) won't move to account for +/// the new layout. +/// +/// ## Painting over the material +/// +/// Material widgets will often trigger reactions on their nearest material +/// ancestor. For example, [ListTile.hoverColor] triggers a reaction on the +/// tile's material when a pointer is hovering over it. These reactions will be +/// obscured if any widget in between them and the material paints in such a +/// way as to obscure the material (such as setting a [BoxDecoration.color] on +/// a [DecoratedBox]). To avoid this behavior, use [InkDecoration] to decorate +/// the material itself. +/// +/// See also: +/// +/// * [MergeableMaterial], a piece of material that can split and re-merge. +/// * [Card], a wrapper for a [Material] of [type] [MaterialType.card]. +/// * <https://material.io/design/> +/// * <https://m3.material.io/styles/color/the-color-system/color-roles> +class Material extends StatefulWidget { + /// Creates a piece of material. + /// + /// The [elevation] must be non-negative. + /// + /// If a [shape] is specified, then the [borderRadius] property must be + /// null and the [type] property must not be [MaterialType.circle]. If the + /// [borderRadius] is specified, then the [type] property must not be + /// [MaterialType.circle]. In both cases, these restrictions are intended to + /// catch likely errors. + const Material({ + super.key, + this.type = MaterialType.canvas, + this.elevation = 0.0, + this.color, + this.shadowColor, + this.surfaceTintColor, + this.textStyle, + this.borderRadius, + this.shape, + this.borderOnForeground = true, + this.clipBehavior = Clip.none, + this.animationDuration = kThemeChangeDuration, + this.child, + this.animateColor = false, + }) : assert(elevation >= 0.0), + assert(!(shape != null && borderRadius != null)), + assert(!(identical(type, MaterialType.circle) && (borderRadius != null || shape != null))); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// The kind of material to show (e.g., card or canvas). This + /// affects the shape of the widget, the roundness of its corners if + /// the shape is rectangular, and the default color. + final MaterialType type; + + /// Whether the color should be animated. + final bool animateColor; + + /// {@template flutter.material.material.elevation} + /// The z-coordinate at which to place this material relative to its parent. + /// + /// This controls the size of the shadow below the material and the opacity + /// of the elevation overlay color if it is applied. + /// + /// If this is non-zero, the contents of the material are clipped, because the + /// widget conceptually defines an independent printed piece of material. + /// + /// Defaults to 0. Changing this value will cause the shadow and the elevation + /// overlay or surface tint to animate over [Material.animationDuration]. + /// + /// The value is non-negative. + /// + /// See also: + /// + /// * [ThemeData.useMaterial3] which defines whether a surface tint or + /// elevation overlay is used to indicate elevation. + /// * [ThemeData.applyElevationOverlayColor] which controls the whether + /// an overlay color will be applied to indicate elevation. + /// * [Material.color] which may have an elevation overlay applied. + /// * [Material.shadowColor] which will be used for the color of a drop shadow. + /// * [Material.surfaceTintColor] which will be used as the overlay tint to + /// show elevation. + /// {@endtemplate} + final double elevation; + + /// The color to paint the material. + /// + /// Must be opaque. To create a transparent piece of material, use + /// [MaterialType.transparency]. + /// + /// If [ThemeData.useMaterial3] is true then an optional [surfaceTintColor] + /// overlay may be applied on top of this color to indicate elevation. + /// + /// If [ThemeData.useMaterial3] is false and [ThemeData.applyElevationOverlayColor] + /// is true and [ThemeData.brightness] is [Brightness.dark] then a + /// semi-transparent overlay color will be composited on top of this + /// color to indicate the elevation. This is no longer needed for Material + /// Design 3, which uses [surfaceTintColor]. + /// + /// By default, the color is derived from the [type] of material. + final Color? color; + + /// The color to paint the shadow below the material. + /// + /// {@template flutter.material.material.shadowColor} + /// If null and [ThemeData.useMaterial3] is true then [ThemeData]'s + /// [ColorScheme.shadow] will be used. If [ThemeData.useMaterial3] is false + /// then [ThemeData.shadowColor] will be used. + /// + /// To remove the drop shadow when [elevation] is greater than 0, set + /// [shadowColor] to [Colors.transparent]. + /// + /// See also: + /// * [ThemeData.useMaterial3], which determines the default value for this + /// property if it is null. + /// * [ThemeData.applyElevationOverlayColor], which turns elevation overlay + /// on or off for dark themes. + /// {@endtemplate} + final Color? shadowColor; + + /// The color of the surface tint overlay applied to the material color + /// to indicate elevation. + /// + /// {@template flutter.material.material.surfaceTintColor} + /// Material Design 3 introduced a new way for some components to indicate + /// their elevation by using a surface tint color overlay on top of the + /// base material [color]. This overlay is painted with an opacity that is + /// related to the [elevation] of the material. + /// + /// If [ThemeData.useMaterial3] is false, then this property is not used. + /// + /// If [ThemeData.useMaterial3] is true and [surfaceTintColor] is not null and + /// not [Colors.transparent], then it will be used to overlay the base [color] + /// with an opacity based on the [elevation]. + /// + /// Otherwise, no surface tint will be applied. + /// + /// See also: + /// + /// * [ThemeData.useMaterial3], which turns this feature on. + /// * [ElevationOverlay.applySurfaceTint], which is used to implement the + /// tint. + /// * https://m3.material.io/styles/color/the-color-system/color-roles + /// which specifies how the overlay is applied. + /// {@endtemplate} + final Color? surfaceTintColor; + + /// The typographical style to use for text within this material. + final TextStyle? textStyle; + + /// Defines the material's shape as well its shadow. + /// + /// {@template flutter.material.material.shape} + /// If shape is non null, the [borderRadius] is ignored and the material's + /// clip boundary and shadow are defined by the shape. + /// + /// A shadow is only displayed if the [elevation] is greater than + /// zero. + /// {@endtemplate} + final ShapeBorder? shape; + + /// Whether to paint the [shape] border in front of the [child]. + /// + /// The default value is true. + /// If false, the border will be painted behind the [child]. + final bool borderOnForeground; + + /// {@template flutter.material.Material.clipBehavior} + /// The content will be clipped (or not) according to this option. + /// + /// See the enum [Clip] for details of all possible options and their common + /// use cases. + /// {@endtemplate} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Defines the duration of animated changes for [shape], [elevation], + /// [shadowColor], [surfaceTintColor] and the elevation overlay if it is applied. + /// + /// The default value is [kThemeChangeDuration]. + final Duration animationDuration; + + /// If non-null, the corners of this box are rounded by this + /// [BorderRadiusGeometry] value. + /// + /// Otherwise, the corners specified for the current [type] of material are + /// used. + /// + /// If [shape] is non null then the border radius is ignored. + /// + /// Must be null if [type] is [MaterialType.circle]. + final BorderRadiusGeometry? borderRadius; + + /// The ink controller from the closest instance of this class that + /// encloses the given context within the closest [LookupBoundary]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// MaterialInkController? inkController = Material.maybeOf(context); + /// ``` + /// + /// This method can be expensive (it walks the element tree). + /// + /// See also: + /// + /// * [Material.of], which is similar to this method, but asserts if + /// no [Material] ancestor is found. + static MaterialInkController? maybeOf(BuildContext context) { + return LookupBoundary.findAncestorRenderObjectOfType<_RenderInkFeatures>(context); + } + + /// The ink controller from the closest instance of [Material] that encloses + /// the given context within the closest [LookupBoundary]. + /// + /// If no [Material] widget ancestor can be found then this method will assert + /// in debug mode, and throw an exception in release mode. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// MaterialInkController inkController = Material.of(context); + /// ``` + /// + /// This method can be expensive (it walks the element tree). + /// + /// See also: + /// + /// * [Material.maybeOf], which is similar to this method, but returns null if + /// no [Material] ancestor is found. + static MaterialInkController of(BuildContext context) { + final MaterialInkController? controller = maybeOf(context); + assert(() { + if (controller == null) { + if (LookupBoundary.debugIsHidingAncestorRenderObjectOfType<_RenderInkFeatures>(context)) { + throw FlutterError( + 'Material.of() was called with a context that does not have access to a Material widget.\n' + 'The context provided to Material.of() does have a Material widget ancestor, but it is ' + 'hidden by a LookupBoundary. This can happen because you are using a widget that looks ' + 'for a Material ancestor, but no such ancestor exists within the closest LookupBoundary.\n' + 'The context used was:\n' + ' $context', + ); + } + throw FlutterError( + 'Material.of() was called with a context that does not contain a Material widget.\n' + 'No Material widget ancestor could be found starting from the context that was passed to ' + 'Material.of(). This can happen because you are using a widget that looks for a Material ' + 'ancestor, but no such ancestor exists.\n' + 'The context used was:\n' + ' $context', + ); + } + return true; + }()); + return controller!; + } + + @override + State<Material> createState() => _MaterialState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty<MaterialType>('type', type)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: 0.0)); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + textStyle?.debugFillProperties(properties, prefix: 'textStyle.'); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<bool>('borderOnForeground', borderOnForeground, defaultValue: true), + ); + properties.add( + DiagnosticsProperty<BorderRadiusGeometry>('borderRadius', borderRadius, defaultValue: null), + ); + } + + /// The default radius of an ink splash in logical pixels. + static const double defaultSplashRadius = 35.0; +} + +class _MaterialState extends State<Material> with TickerProviderStateMixin { + final GlobalKey _inkFeatureRenderer = GlobalKey(debugLabel: 'ink renderer'); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final Color? backgroundColor = + widget.color ?? + switch (widget.type) { + MaterialType.canvas => theme.canvasColor, + MaterialType.card => theme.cardColor, + MaterialType.button || MaterialType.circle || MaterialType.transparency => null, + }; + final Color modelShadowColor = + widget.shadowColor ?? (theme.useMaterial3 ? theme.colorScheme.shadow : theme.shadowColor); + assert( + backgroundColor != null || widget.type == MaterialType.transparency, + 'If Material type is not MaterialType.transparency, a color must ' + 'either be passed in through the `color` property, or be defined ' + 'in the theme (ex. canvasColor != null if type is set to ' + 'MaterialType.canvas)', + ); + + Widget? contents = widget.child; + if (contents != null) { + contents = AnimatedDefaultTextStyle( + style: widget.textStyle ?? Theme.of(context).textTheme.bodyMedium!, + duration: widget.animationDuration, + child: contents, + ); + } + contents = NotificationListener<LayoutChangedNotification>( + onNotification: (LayoutChangedNotification notification) { + final renderer = + _inkFeatureRenderer.currentContext!.findRenderObject()! as _RenderInkFeatures; + renderer._didChangeLayout(); + return false; + }, + child: _InkFeatures( + key: _inkFeatureRenderer, + absorbHitTest: widget.type != MaterialType.transparency, + color: backgroundColor, + vsync: this, + child: contents, + ), + ); + + ShapeBorder? shape = widget.borderRadius != null + ? RoundedRectangleBorder(borderRadius: widget.borderRadius!) + : widget.shape; + + // PhysicalModel has a temporary workaround for a performance issue that + // speeds up rectangular non transparent material (the workaround is to + // skip the call to ui.Canvas.saveLayer if the border radius is 0). + // Until the saveLayer performance issue is resolved, we're keeping this + // special case here for canvas material type that is using the default + // shape (rectangle). We could go down this fast path for explicitly + // specified rectangles (e.g shape RoundedRectangleBorder with radius 0, but + // we choose not to as we want the change from the fast-path to the + // slow-path to be noticeable in the construction site of Material. + if (widget.type == MaterialType.canvas && shape == null) { + final Color color = theme.useMaterial3 + ? ElevationOverlay.applySurfaceTint( + backgroundColor!, + widget.surfaceTintColor, + widget.elevation, + ) + : ElevationOverlay.applyOverlay(context, backgroundColor!, widget.elevation); + + return AnimatedPhysicalModel( + curve: Curves.fastOutSlowIn, + duration: widget.animationDuration, + clipBehavior: widget.clipBehavior, + elevation: widget.elevation, + color: color, + shadowColor: modelShadowColor, + animateColor: widget.animateColor, + child: contents, + ); + } + + shape ??= switch (widget.type) { + MaterialType.circle => const CircleBorder(), + MaterialType.canvas || MaterialType.transparency => const RoundedRectangleBorder(), + MaterialType.card || MaterialType.button => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(2.0)), + ), + }; + + if (widget.type == MaterialType.transparency) { + return ClipPath( + clipper: ShapeBorderClipper(shape: shape, textDirection: Directionality.maybeOf(context)), + clipBehavior: widget.clipBehavior, + child: _ShapeBorderPaint(shape: shape, child: contents), + ); + } + + return _MaterialInterior( + curve: Curves.fastOutSlowIn, + duration: widget.animationDuration, + shape: shape, + borderOnForeground: widget.borderOnForeground, + clipBehavior: widget.clipBehavior, + elevation: widget.elevation, + color: backgroundColor!, + shadowColor: modelShadowColor, + surfaceTintColor: widget.surfaceTintColor, + child: contents, + ); + } +} + +class _RenderInkFeatures extends RenderProxyBox implements MaterialInkController { + _RenderInkFeatures({ + RenderBox? child, + required this.vsync, + required this.absorbHitTest, + this.color, + }) : super(child); + + // This class should exist in a 1:1 relationship with a WidgetState object, + // since there's no current support for dynamically changing the ticker + // provider. + @override + final TickerProvider vsync; + + // This is here to satisfy the MaterialInkController contract. + // The actual painting of this color is done by a Container in the + // WidgetState build method. + @override + Color? color; + + bool absorbHitTest; + + @visibleForTesting + List<InkFeature>? get debugInkFeatures { + if (kDebugMode) { + return _inkFeatures; + } + return null; + } + + List<InkFeature>? _inkFeatures; + + @override + void addInkFeature(InkFeature feature) { + assert(!feature._debugDisposed); + assert(feature._controller == this); + _inkFeatures ??= <InkFeature>[]; + assert(!_inkFeatures!.contains(feature)); + _inkFeatures!.add(feature); + markNeedsPaint(); + } + + void _removeFeature(InkFeature feature) { + assert(_inkFeatures != null); + _inkFeatures!.remove(feature); + markNeedsPaint(); + } + + void _didChangeLayout() { + if (_inkFeatures?.isNotEmpty ?? false) { + markNeedsPaint(); + } + } + + @override + bool hitTestSelf(Offset position) => absorbHitTest; + + @override + void paint(PaintingContext context, Offset offset) { + final List<InkFeature>? inkFeatures = _inkFeatures; + if (inkFeatures != null && inkFeatures.isNotEmpty) { + final Canvas canvas = context.canvas; + canvas.save(); + canvas.translate(offset.dx, offset.dy); + canvas.clipRect(Offset.zero & size); + for (final InkFeature inkFeature in inkFeatures) { + inkFeature._paint(canvas); + } + canvas.restore(); + } + assert(inkFeatures == _inkFeatures); + super.paint(context, offset); + } +} + +class _InkFeatures extends SingleChildRenderObjectWidget { + const _InkFeatures({ + super.key, + this.color, + required this.vsync, + required this.absorbHitTest, + super.child, + }); + + // This widget must be owned by a WidgetState, which must be provided as the vsync. + // This relationship must be 1:1 and cannot change for the lifetime of the WidgetState. + + final Color? color; + + final TickerProvider vsync; + + final bool absorbHitTest; + + @override + _RenderInkFeatures createRenderObject(BuildContext context) { + return _RenderInkFeatures(color: color, absorbHitTest: absorbHitTest, vsync: vsync); + } + + @override + void updateRenderObject(BuildContext context, _RenderInkFeatures renderObject) { + renderObject + ..color = color + ..absorbHitTest = absorbHitTest; + assert(vsync == renderObject.vsync); + } +} + +/// A visual reaction on a piece of [Material]. +/// +/// To add an ink feature to a piece of [Material], obtain the +/// [MaterialInkController] via [Material.of] and call +/// [MaterialInkController.addInkFeature]. +abstract class InkFeature { + /// Initializes fields for subclasses. + InkFeature({ + required MaterialInkController controller, + required this.referenceBox, + this.onRemoved, + }) : _controller = controller as _RenderInkFeatures { + assert(debugMaybeDispatchCreated('material', 'InkFeature', this)); + } + + /// The [MaterialInkController] associated with this [InkFeature]. + /// + /// Typically used by subclasses to call + /// [MaterialInkController.markNeedsPaint] when they need to repaint. + MaterialInkController get controller => _controller; + final _RenderInkFeatures _controller; + + /// The render box whose visual position defines the frame of reference for this ink feature. + final RenderBox referenceBox; + + /// Called when the ink feature is no longer visible on the material. + final VoidCallback? onRemoved; + + bool _debugDisposed = false; + + /// Free up the resources associated with this ink feature. + @mustCallSuper + void dispose() { + assert(!_debugDisposed); + assert(() { + _debugDisposed = true; + return true; + }()); + assert(debugMaybeDispatchDisposed(this)); + _controller._removeFeature(this); + onRemoved?.call(); + } + + // Returns the paint transform that allows `fromRenderObject` to perform paint + // in `toRenderObject`'s coordinate space. + // + // Returns null if either `fromRenderObject` or `toRenderObject` is not in the + // same render tree, or either of them is in an offscreen subtree (see + // RenderObject.paintsChild). + static Matrix4? _getPaintTransform(RenderObject fromRenderObject, RenderObject toRenderObject) { + // The paths to fromRenderObject and toRenderObject's common ancestor. + final fromPath = <RenderObject>[fromRenderObject]; + final toPath = <RenderObject>[toRenderObject]; + + var from = fromRenderObject; + var to = toRenderObject; + + while (!identical(from, to)) { + final int fromDepth = from.depth; + final int toDepth = to.depth; + + if (fromDepth >= toDepth) { + final RenderObject? fromParent = from.parent; + // Return early if the 2 render objects are not in the same render tree, + // or either of them is offscreen and thus won't get painted. + if (fromParent is! RenderObject || !fromParent.paintsChild(from)) { + return null; + } + fromPath.add(fromParent); + from = fromParent; + } + + if (fromDepth <= toDepth) { + final RenderObject? toParent = to.parent; + if (toParent is! RenderObject || !toParent.paintsChild(to)) { + return null; + } + toPath.add(toParent); + to = toParent; + } + } + assert(identical(from, to)); + + final transform = Matrix4.identity(); + final inverseTransform = Matrix4.identity(); + + for (int index = toPath.length - 1; index > 0; index -= 1) { + toPath[index].applyPaintTransform(toPath[index - 1], transform); + } + for (int index = fromPath.length - 1; index > 0; index -= 1) { + fromPath[index].applyPaintTransform(fromPath[index - 1], inverseTransform); + } + + final double det = inverseTransform.invert(); + return det != 0 ? (inverseTransform..multiply(transform)) : null; + } + + void _paint(Canvas canvas) { + assert(referenceBox.attached); + assert(!_debugDisposed); + // determine the transform that gets our coordinate system to be like theirs + final Matrix4? transform = _getPaintTransform(_controller, referenceBox); + if (transform != null) { + paintFeature(canvas, transform); + } + } + + /// Override this method to paint the ink feature. + /// + /// The transform argument gives the coordinate conversion from the coordinate + /// system of the canvas to the coordinate system of the [referenceBox]. + @protected + void paintFeature(Canvas canvas, Matrix4 transform); + + @override + String toString() => describeIdentity(this); +} + +/// An interpolation between two [ShapeBorder]s. +/// +/// This class specializes the interpolation of [Tween] to use [ShapeBorder.lerp]. +class ShapeBorderTween extends Tween<ShapeBorder?> { + /// Creates a [ShapeBorder] tween. + /// + /// the [begin] and [end] properties may be null; see [ShapeBorder.lerp] for + /// the null handling semantics. + ShapeBorderTween({super.begin, super.end}); + + /// Returns the value this tween has at the given animation clock value. + @override + ShapeBorder? lerp(double t) { + return ShapeBorder.lerp(begin, end, t); + } +} + +/// The interior of non-transparent material. +/// +/// Animates [elevation], [shadowColor], and [shape]. +class _MaterialInterior extends ImplicitlyAnimatedWidget { + /// Creates a const instance of [_MaterialInterior]. + /// + /// The [elevation] must be specified and greater than or equal to zero. + const _MaterialInterior({ + required this.child, + required this.shape, + this.borderOnForeground = true, + this.clipBehavior = Clip.none, + required this.elevation, + required this.color, + required this.shadowColor, + required this.surfaceTintColor, + super.curve, + required super.duration, + }) : assert(elevation >= 0.0); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The border of the widget. + /// + /// This border will be painted, and in addition the outer path of the border + /// determines the physical shape. + final ShapeBorder shape; + + /// Whether to paint the border in front of the child. + /// + /// The default value is true. + /// If false, the border will be painted behind the child. + final bool borderOnForeground; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// The target z-coordinate at which to place this physical object relative + /// to its parent. + /// + /// The value is non-negative. + final double elevation; + + /// The target background color. + final Color color; + + /// The target shadow color. + final Color shadowColor; + + /// The target surface tint color. + final Color? surfaceTintColor; + + @override + _MaterialInteriorState createState() => _MaterialInteriorState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add(DiagnosticsProperty<ShapeBorder>('shape', shape)); + description.add(DoubleProperty('elevation', elevation)); + description.add(ColorProperty('color', color)); + description.add(ColorProperty('shadowColor', shadowColor)); + } +} + +class _MaterialInteriorState extends AnimatedWidgetBaseState<_MaterialInterior> { + Tween<double>? _elevation; + ColorTween? _surfaceTintColor; + ColorTween? _shadowColor; + ShapeBorderTween? _border; + + @override + void forEachTween(TweenVisitor<dynamic> visitor) { + _elevation = + visitor( + _elevation, + widget.elevation, + (dynamic value) => Tween<double>(begin: value as double), + ) + as Tween<double>?; + _shadowColor = + visitor( + _shadowColor, + widget.shadowColor, + (dynamic value) => ColorTween(begin: value as Color), + ) + as ColorTween?; + _surfaceTintColor = widget.surfaceTintColor != null + ? visitor( + _surfaceTintColor, + widget.surfaceTintColor, + (dynamic value) => ColorTween(begin: value as Color), + ) + as ColorTween? + : null; + _border = + visitor( + _border, + widget.shape, + (dynamic value) => ShapeBorderTween(begin: value as ShapeBorder), + ) + as ShapeBorderTween?; + } + + @override + Widget build(BuildContext context) { + final ShapeBorder shape = _border!.evaluate(animation)!; + final double elevation = _elevation!.evaluate(animation); + final Color color = Theme.of(context).useMaterial3 + ? ElevationOverlay.applySurfaceTint( + widget.color, + _surfaceTintColor?.evaluate(animation), + elevation, + ) + : ElevationOverlay.applyOverlay(context, widget.color, elevation); + final Color shadowColor = _shadowColor!.evaluate(animation)!; + + return PhysicalShape( + clipper: ShapeBorderClipper(shape: shape, textDirection: Directionality.maybeOf(context)), + clipBehavior: widget.clipBehavior, + elevation: elevation, + color: color, + shadowColor: shadowColor, + child: _ShapeBorderPaint( + shape: shape, + borderOnForeground: widget.borderOnForeground, + child: widget.child, + ), + ); + } +} + +class _ShapeBorderPaint extends StatelessWidget { + const _ShapeBorderPaint({ + required this.child, + required this.shape, + this.borderOnForeground = true, + }); + + final Widget child; + final ShapeBorder shape; + final bool borderOnForeground; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: borderOnForeground + ? null + : _ShapeBorderPainter(shape, Directionality.maybeOf(context)), + foregroundPainter: borderOnForeground + ? _ShapeBorderPainter(shape, Directionality.maybeOf(context)) + : null, + child: child, + ); + } +} + +class _ShapeBorderPainter extends CustomPainter { + _ShapeBorderPainter(this.border, this.textDirection); + final ShapeBorder border; + final TextDirection? textDirection; + + @override + void paint(Canvas canvas, Size size) { + border.paint(canvas, Offset.zero & size, textDirection: textDirection); + } + + @override + bool shouldRepaint(_ShapeBorderPainter oldDelegate) { + return oldDelegate.border != border; + } +} diff --git a/packages/material_ui/lib/src/material_button.dart b/packages/material_ui/lib/src/material_button.dart new file mode 100644 index 000000000000..31133b09f3f6 --- /dev/null +++ b/packages/material_ui/lib/src/material_button.dart @@ -0,0 +1,458 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +/// @docImport 'elevated_button_theme.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'outlined_button_theme.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_button_theme.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; +import 'button_theme.dart'; +import 'constants.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A utility class for building Material buttons that depend on the +/// ambient [ButtonTheme] and [Theme]. +/// +/// 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], +/// * [OutlinedButton], [OutlinedButtonTheme], [OutlinedButtonThemeData], +/// * [FilledButton], [FilledButtonTheme], [FilledButtonThemeData] +/// +/// The button's size will expand to fit the child widget, if necessary. +/// +/// MaterialButtons whose [onPressed] and [onLongPress] callbacks are null will be disabled. To have +/// an enabled button, make sure to pass a non-null value for [onPressed] or [onLongPress]. +/// +/// To create a button directly, without inheriting theme defaults, use +/// [RawMaterialButton]. +/// +/// If you want an ink-splash effect for taps, but don't want to use a button, +/// consider using [InkWell] directly. +/// +/// See also: +/// +/// * [IconButton], to create buttons that contain icons rather than text. +class MaterialButton extends StatelessWidget { + /// Creates a Material Design button. + /// + /// To create a custom Material button consider using [TextButton], + /// [ElevatedButton], or [OutlinedButton]. + /// + /// The [elevation], [hoverElevation], [focusElevation], [highlightElevation], + /// and [disabledElevation] arguments must be non-negative, if specified. + const MaterialButton({ + super.key, + required this.onPressed, + this.onLongPress, + this.onHighlightChanged, + this.mouseCursor, + this.textTheme, + this.textColor, + this.disabledTextColor, + this.color, + this.disabledColor, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.splashColor, + this.colorBrightness, + this.elevation, + this.focusElevation, + this.hoverElevation, + this.highlightElevation, + this.disabledElevation, + this.padding, + this.visualDensity, + this.shape, + this.clipBehavior = Clip.none, + this.focusNode, + this.autofocus = false, + this.materialTapTargetSize, + this.animationDuration, + this.minWidth, + this.height, + this.enableFeedback = true, + this.child, + }) : assert(elevation == null || elevation >= 0.0), + assert(focusElevation == null || focusElevation >= 0.0), + assert(hoverElevation == null || hoverElevation >= 0.0), + assert(highlightElevation == null || highlightElevation >= 0.0), + assert(disabledElevation == null || disabledElevation >= 0.0); + + /// The callback that is called when the button is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onPressed; + + /// The callback that is called when the button is long-pressed. + /// + /// If this callback and [onPressed] are null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onLongPress; + + /// Called by the underlying [InkWell] widget's [InkWell.onHighlightChanged] + /// callback. + /// + /// If [onPressed] changes from null to non-null while a gesture is ongoing, + /// this can fire during the build phase (in which case calling + /// [State.setState] is not allowed). + final ValueChanged<bool>? onHighlightChanged; + + /// {@macro flutter.material.RawMaterialButton.mouseCursor} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] will be used. + final MouseCursor? mouseCursor; + + /// Defines the button's base colors, and the defaults for the button's minimum + /// size, internal padding, and shape. + /// + /// Defaults to `ButtonTheme.of(context).textTheme`. + final ButtonTextTheme? textTheme; + + /// The color to use for this button's text. + /// + /// The button's [Material.textStyle] will be the current theme's button text + /// style, [TextTheme.labelLarge] of [ThemeData.textTheme], configured with this + /// color. + /// + /// The default text color depends on the button theme's text theme, + /// [ButtonThemeData.textTheme]. + /// + /// If [textColor] is a [WidgetStateProperty<Color>], [disabledTextColor] + /// will be ignored. + /// + /// See also: + /// + /// * [disabledTextColor], the text color to use when the button has been + /// disabled. + final Color? textColor; + + /// The color to use for this button's text when the button is disabled. + /// + /// The button's [Material.textStyle] will be the current theme's button text + /// style, [TextTheme.labelLarge] of [ThemeData.textTheme], configured with this + /// color. + /// + /// The default value is the theme's disabled color, + /// [ThemeData.disabledColor]. + /// + /// If [textColor] is a [WidgetStateProperty<Color>], [disabledTextColor] + /// will be ignored. + /// + /// See also: + /// + /// * [textColor] - The color to use for this button's text when the button is [enabled]. + final Color? disabledTextColor; + + /// The button's fill color, displayed by its [Material], while it + /// is in its default (unpressed, [enabled]) state. + /// + /// See also: + /// + /// * [disabledColor] - the fill color of the button when the button is disabled. + final Color? color; + + /// The fill color of the button when the button is disabled. + /// + /// The default value of this color is the theme's disabled color, + /// [ThemeData.disabledColor]. + /// + /// See also: + /// + /// * [color] - the fill color of the button when the button is [enabled]. + final Color? disabledColor; + + /// The splash color of the button's [InkWell]. + /// + /// The ink splash indicates that the button has been touched. It + /// appears on top of the button's child and spreads in an expanding + /// circle beginning where the touch occurred. + /// + /// The default splash color is the current theme's splash color, + /// [ThemeData.splashColor]. + /// + /// The appearance of the splash can be configured with the theme's splash + /// factory, [ThemeData.splashFactory]. + final Color? splashColor; + + /// The fill color of the button's [Material] when it has the input focus. + /// + /// The button changed focus color when the button has the input focus. It + /// appears behind the button's child. + final Color? focusColor; + + /// The fill color of the button's [Material] when a pointer is hovering over + /// it. + /// + /// The button changes fill color when a pointer is hovering over the button. + /// It appears behind the button's child. + final Color? hoverColor; + + /// The highlight color of the button's [InkWell]. + /// + /// The highlight indicates that the button is actively being pressed. It + /// appears on top of the button's child and quickly spreads to fill + /// the button, and then fades out. + /// + /// If [textTheme] is [ButtonTextTheme.primary], the default highlight color is + /// transparent (in other words the highlight doesn't appear). Otherwise it's + /// the current theme's highlight color, [ThemeData.highlightColor]. + final Color? highlightColor; + + /// The z-coordinate at which to place this button relative to its parent. + /// + /// This controls the size of the shadow below the raised button. + /// + /// Defaults to 2, the appropriate elevation for raised buttons. The value + /// is always non-negative. + /// + /// See also: + /// + /// * [TextButton], a button with no elevation or fill color. + /// * [focusElevation], the elevation when the button is focused. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [disabledElevation], the elevation when the button is disabled. + /// * [highlightElevation], the elevation when the button is pressed. + final double? elevation; + + /// The elevation for the button's [Material] when the button + /// is [enabled] and a pointer is hovering over it. + /// + /// Defaults to 4.0. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [focusElevation], the elevation when the button is focused. + /// * [disabledElevation], the elevation when the button is disabled. + /// * [highlightElevation], the elevation when the button is pressed. + final double? hoverElevation; + + /// The elevation for the button's [Material] when the button + /// is [enabled] and has the input focus. + /// + /// Defaults to 4.0. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [disabledElevation], the elevation when the button is disabled. + /// * [highlightElevation], the elevation when the button is pressed. + final double? focusElevation; + + /// The elevation for the button's [Material] relative to its parent when the + /// button is [enabled] and pressed. + /// + /// This controls the size of the shadow below the button. When a tap + /// down gesture occurs within the button, its [InkWell] displays a + /// [highlightColor] "highlight". + /// + /// Defaults to 8.0. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [focusElevation], the elevation when the button is focused. + /// * [hoverElevation], the elevation when a pointer is hovering over the + /// button. + /// * [disabledElevation], the elevation when the button is disabled. + final double? highlightElevation; + + /// The elevation for the button's [Material] relative to its parent when the + /// button is not [enabled]. + /// + /// Defaults to 0.0. The value is always non-negative. + /// + /// See also: + /// + /// * [elevation], the default elevation. + /// * [highlightElevation], the elevation when the button is pressed. + final double? disabledElevation; + + /// The theme brightness to use for this button. + /// + /// Defaults to the theme's brightness in [ThemeData.brightness]. Setting + /// this value determines the button text's colors based on + /// [ButtonThemeData.getTextColor]. + /// + /// See also: + /// + /// * [ButtonTextTheme], uses [Brightness] to determine text color. + final Brightness? colorBrightness; + + /// The button's label. + /// + /// Often a [Text] widget in all caps. + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// Buttons are disabled by default. To enable a button, set its [onPressed] + /// or [onLongPress] properties to a non-null value. + bool get enabled => onPressed != null || onLongPress != null; + + /// The internal padding for the button's [child]. + /// + /// Defaults to the value from the current [ButtonTheme], + /// [ButtonThemeData.padding]. + final EdgeInsetsGeometry? padding; + + /// Defines how compact the button's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// The shape of the button's [Material]. + /// + /// The button's highlight and splash are clipped to this shape. If the + /// button has an elevation, then its drop shadow is defined by this + /// shape as well. + /// + /// Defaults to the value from the current [ButtonTheme], + /// [ButtonThemeData.shape]. + final ShapeBorder? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Defines the duration of animated changes for [shape] and [elevation]. + /// + /// The default value is [kThemeChangeDuration]. + final Duration? animationDuration; + + /// Configures the minimum size of the tap target. + /// + /// Defaults to [ThemeData.materialTapTargetSize]. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + final MaterialTapTargetSize? materialTapTargetSize; + + /// The smallest horizontal extent that the button will occupy. + /// + /// Defaults to the value from the current [ButtonTheme]. + final double? minWidth; + + /// The vertical extent of the button. + /// + /// Defaults to the value from the current [ButtonTheme]. + final double? height; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ButtonThemeData buttonTheme = ButtonTheme.of(context); + + return RawMaterialButton( + onPressed: onPressed, + onLongPress: onLongPress, + enableFeedback: enableFeedback, + onHighlightChanged: onHighlightChanged, + mouseCursor: mouseCursor, + fillColor: buttonTheme.getFillColor(this), + textStyle: theme.textTheme.labelLarge!.copyWith(color: buttonTheme.getTextColor(this)), + focusColor: focusColor ?? buttonTheme.getFocusColor(this), + hoverColor: hoverColor ?? buttonTheme.getHoverColor(this), + highlightColor: highlightColor ?? theme.highlightColor, + splashColor: splashColor ?? theme.splashColor, + elevation: buttonTheme.getElevation(this), + focusElevation: buttonTheme.getFocusElevation(this), + hoverElevation: buttonTheme.getHoverElevation(this), + highlightElevation: buttonTheme.getHighlightElevation(this), + padding: buttonTheme.getPadding(this), + visualDensity: visualDensity ?? theme.visualDensity, + constraints: buttonTheme.getConstraints(this).copyWith(minWidth: minWidth, minHeight: height), + shape: buttonTheme.getShape(this), + clipBehavior: clipBehavior, + focusNode: focusNode, + autofocus: autofocus, + animationDuration: buttonTheme.getAnimationDuration(this), + materialTapTargetSize: materialTapTargetSize ?? theme.materialTapTargetSize, + disabledElevation: disabledElevation ?? 0.0, + child: child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'disabled')); + properties.add( + DiagnosticsProperty<ButtonTextTheme>('textTheme', textTheme, defaultValue: null), + ); + properties.add(ColorProperty('textColor', textColor, defaultValue: null)); + properties.add(ColorProperty('disabledTextColor', disabledTextColor, defaultValue: null)); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<Brightness>('colorBrightness', colorBrightness, defaultValue: null), + ); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + materialTapTargetSize, + defaultValue: null, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/material_localizations.dart b/packages/material_ui/lib/src/material_localizations.dart new file mode 100644 index 000000000000..b6f0d3987c62 --- /dev/null +++ b/packages/material_ui/lib/src/material_localizations.dart @@ -0,0 +1,1473 @@ +// 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. + +/// @docImport 'package:flutter/services.dart'; +/// @docImport 'package:flutter_localizations/flutter_localizations.dart'; +/// +/// @docImport 'about.dart'; +/// @docImport 'action_buttons.dart'; +/// @docImport 'app.dart'; +/// @docImport 'app_bar.dart'; +/// @docImport 'bottom_sheet.dart'; +/// @docImport 'calendar_date_picker.dart'; +/// @docImport 'chip.dart'; +/// @docImport 'date_picker.dart'; +/// @docImport 'expand_icon.dart'; +/// @docImport 'expansion_tile.dart'; +/// @docImport 'input_date_picker_form_field.dart'; +/// @docImport 'paginated_data_table.dart'; +/// @docImport 'popup_menu.dart'; +/// @docImport 'refresh_indicator.dart'; +/// @docImport 'reorderable_list.dart'; +/// @docImport 'search_anchor.dart'; +/// @docImport 'tabs.dart'; +/// @docImport 'text_field.dart'; +/// @docImport 'text_theme.dart'; +/// @docImport 'theme_data.dart'; +/// @docImport 'time_picker.dart'; +/// @docImport 'user_accounts_drawer_header.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'time.dart'; +import 'typography.dart'; + +// Examples can assume: +// late BuildContext context; + +// ADDING A NEW STRING +// +// Please refer to instructions in this markdown file +// (packages/flutter_localizations/README.md) + +/// Defines the localized resource values used by the Material widgets. +/// +/// See also: +/// +/// * [DefaultMaterialLocalizations], the default, English-only, implementation +/// of this interface. +/// * [GlobalMaterialLocalizations], which provides material localizations for +/// many languages. +abstract class MaterialLocalizations { + /// The tooltip for the leading [AppBar] menu (a.k.a. 'hamburger') button. + String get openAppDrawerTooltip; + + /// The [BackButton]'s tooltip. + String get backButtonTooltip; + + /// The tooltip for the clear button to clear text on [SearchAnchor]'s search view. + String get clearButtonTooltip; + + /// The [CloseButton]'s tooltip. + String get closeButtonTooltip; + + /// The tooltip for the delete button on a [Chip]. + String get deleteButtonTooltip; + + /// The tooltip for the more button on an overflowing text selection menu. + String get moreButtonTooltip; + + /// The tooltip for the [CalendarDatePicker]'s "next month" button. + String get nextMonthTooltip; + + /// The tooltip for the [CalendarDatePicker]'s "previous month" button. + String get previousMonthTooltip; + + /// The tooltip for the [PaginatedDataTable]'s "first page" button. + String get firstPageTooltip; + + /// The tooltip for the [PaginatedDataTable]'s "last page" button. + String get lastPageTooltip; + + /// The tooltip for the [PaginatedDataTable]'s "next page" button. + String get nextPageTooltip; + + /// The tooltip for the [PaginatedDataTable]'s "previous page" button. + String get previousPageTooltip; + + /// The default [PopupMenuButton] tooltip. + String get showMenuTooltip; + + /// The default title for [AboutListTile]. + String aboutListTileTitle(String applicationName); + + /// Title for the [LicensePage] widget. + String get licensesPageTitle; + + /// Subtitle for a package in the [LicensePage] widget. + String licensesPackageDetailText(int licenseCount); + + /// Title for the [PaginatedDataTable]'s row info footer. + String pageRowsInfoTitle(int firstRow, int lastRow, int rowCount, bool rowCountIsApproximate); + + /// Title for the [PaginatedDataTable]'s "rows per page" footer. + String get rowsPerPageTitle; + + /// The accessibility label used on a tab in a [TabBar]. + /// + /// This message describes the index of the selected tab and how many tabs + /// there are, e.g. 'Tab 1 of 2' in United States English. + /// + /// `tabIndex` and `tabCount` must be greater than or equal to one. + String tabLabel({required int tabIndex, required int tabCount}); + + /// Title for the [PaginatedDataTable]'s selected row count header. + String selectedRowCountTitle(int selectedRowCount); + + /// Label for "cancel" buttons and menu items. + String get cancelButtonLabel; + + /// Label for "close" buttons and menu items. + String get closeButtonLabel; + + /// Label for "continue" buttons and menu items. + String get continueButtonLabel; + + /// Label for "copy" edit buttons and menu items. + String get copyButtonLabel; + + /// Label for "cut" edit buttons and menu items. + String get cutButtonLabel; + + /// Label for "scan text" OCR edit buttons and menu items. + String get scanTextButtonLabel; + + /// Label for OK buttons and menu items. + String get okButtonLabel; + + /// Label for "paste" edit buttons and menu items. + String get pasteButtonLabel; + + /// Label for "select all" edit buttons and menu items. + String get selectAllButtonLabel; + + /// Label for "look up" edit buttons and menu items. + String get lookUpButtonLabel; + + /// Label for "search web" edit buttons and menu items. + String get searchWebButtonLabel; + + /// Label for "share" edit buttons and menu items. + String get shareButtonLabel; + + /// Label for the [AboutDialog] button that shows the [LicensePage]. + String get viewLicensesButtonLabel; + + /// The abbreviation for ante meridiem (before noon) shown in the time picker. + String get anteMeridiemAbbreviation; + + /// The abbreviation for post meridiem (after noon) shown in the time picker. + String get postMeridiemAbbreviation; + + /// The text-to-speech announcement made when a time picker invoked using + /// [showTimePicker] is set to the hour picker mode. + String get timePickerHourModeAnnouncement; + + /// The text-to-speech announcement made when a time picker invoked using + /// [showTimePicker] is set to the minute picker mode. + String get timePickerMinuteModeAnnouncement; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) for a modal + /// barrier to indicate that a tap dismisses the barrier. + /// + /// A modal barrier can for example be found behind an alert or popup to block + /// user interaction with elements behind it. + String get modalBarrierDismissLabel; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) for a + /// context menu to indicate that a tap dismisses the context menu. + String get menuDismissLabel; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) when a + /// drawer widget is opened. + String get drawerLabel; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) when a + /// popup menu widget is opened. + String get popupMenuLabel; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) when a + /// MenuBarMenu widget is opened. + String get menuBarMenuLabel; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) when a + /// dialog widget is opened. + String get dialogLabel; + + /// Label read out by accessibility tools (TalkBack or VoiceOver) when an + /// alert dialog widget is opened. + String get alertDialogLabel; + + /// Label indicating that a text field is a search field. This will be used + /// as a hint text in the text field. + String get searchFieldLabel; + + /// Label indicating that a given date is the current date. + String get currentDateLabel; + + /// The semantics label to describe the selected date in the calendar picker + /// invoked using [showDatePicker]. + String get selectedDateLabel; + + /// Label for the scrim rendered underneath a [BottomSheet]. + String get scrimLabel; + + /// Label for a [BottomSheet], used as the `modalRouteContentName` of the + /// [scrimOnTapHint]. + String get bottomSheetLabel; + + /// Hint text announced when tapping on the scrim underneath the content of + /// a modal route. + String scrimOnTapHint(String modalRouteContentName); + + /// The format used to lay out the time picker. + /// + /// The documentation for [TimeOfDayFormat] enum values provides details on + /// each supported layout. + TimeOfDayFormat timeOfDayFormat({bool alwaysUse24HourFormat = false}); + + /// Defines the localized [TextStyle] geometry for [ThemeData.textTheme]. + /// + /// The [scriptCategory] defines the overall geometry of a [TextTheme] for + /// the [Typography.geometryThemeFor] method in terms of the + /// three language categories defined in https://material.io/go/design-typography. + /// + /// Generally speaking, font sizes for `ScriptCategory.tall` and + /// `ScriptCategory.dense` scripts - for text styles that are smaller than the + /// title style - are one unit larger than they are for + /// `ScriptCategory.englishLike` scripts. + ScriptCategory get scriptCategory; + + /// Formats [number] as a decimal, inserting locale-appropriate thousands + /// separators as necessary. + String formatDecimal(int number); + + /// Formats [TimeOfDay.hour] in the given time of day according to the value + /// of [timeOfDayFormat]. + /// + /// If [alwaysUse24HourFormat] is true, formats hour using [HourFormat.HH] + /// rather than the default for the current locale. + String formatHour(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}); + + /// Formats [TimeOfDay.minute] in the given time of day according to the value + /// of [timeOfDayFormat]. + String formatMinute(TimeOfDay timeOfDay); + + /// Formats [timeOfDay] according to the value of [timeOfDayFormat]. + /// + /// If [alwaysUse24HourFormat] is true, formats hour using [HourFormat.HH] + /// rather than the default for the current locale. This value is usually + /// passed from [MediaQueryData.alwaysUse24HourFormat], which has platform- + /// specific behavior. + String formatTimeOfDay(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}); + + /// Full unabbreviated year format, e.g. 2017 rather than 17. + String formatYear(DateTime date); + + /// Formats the date in a compact format. + /// + /// Usually just the numeric values for the for day, month and year are used. + /// + /// Examples: + /// + /// - US English: 02/21/2019 + /// - Russian: 21.02.2019 + /// + /// See also: + /// * [parseCompactDate], which will convert a compact date string to a [DateTime]. + String formatCompactDate(DateTime date); + + /// Formats the date using a short-width format. + /// + /// Includes the abbreviation of the month, the day and year. + /// + /// Examples: + /// + /// - US English: Feb 21, 2019 + /// - Russian: 21 февр. 2019 г. + String formatShortDate(DateTime date); + + /// Formats the date using a medium-width format. + /// + /// Abbreviates month and days of week. This appears in the header of the date + /// picker invoked using [showDatePicker]. + /// + /// Examples: + /// + /// - US English: Wed, Sep 27 + /// - Russian: ср, сент. 27 + String formatMediumDate(DateTime date); + + /// Formats day of week, month, day of month and year in a long-width format. + /// + /// Does not abbreviate names. Appears in spoken announcements of the date + /// picker invoked using [showDatePicker], when accessibility mode is on. + /// + /// Examples: + /// + /// - US English: Wednesday, September 27, 2017 + /// - Russian: Среда, Сентябрь 27, 2017 + String formatFullDate(DateTime date); + + /// Formats the month and the year of the given [date]. + /// + /// The returned string does not contain the day of the month. This appears + /// in the date picker invoked using [showDatePicker]. + String formatMonthYear(DateTime date); + + /// Formats the month and day of the given [date]. + /// + /// Examples: + /// + /// - US English: Feb 21 + /// - Russian: 21 февр. + String formatShortMonthDay(DateTime date); + + /// Converts the given compact date formatted string into a [DateTime]. + /// + /// The format of the string must be a valid compact date format for the + /// given locale. If the text doesn't represent a valid date, `null` will be + /// returned. + /// + /// See also: + /// * [formatCompactDate], which will convert a [DateTime] into a string in the compact format. + DateTime? parseCompactDate(String? inputString); + + /// List of week day names in narrow format, usually 1- or 2-letter + /// abbreviations of full names. + /// + /// The list begins with the value corresponding to Sunday and ends with + /// Saturday. Use [firstDayOfWeekIndex] to find the first day of week in this + /// list. + /// + /// Examples: + /// + /// - US English: S, M, T, W, T, F, S + /// - Russian: вс, пн, вт, ср, чт, пт, сб - notice that the list begins with + /// вс (Sunday) even though the first day of week for Russian is Monday. + List<String> get narrowWeekdays; + + /// Index of the first day of week, where 0 points to Sunday, and 6 points to + /// Saturday. + /// + /// This getter is compatible with [narrowWeekdays]. For example: + /// + /// ```dart + /// MaterialLocalizations localizations = MaterialLocalizations.of(context); + /// // The name of the first day of week for the current locale. + /// String firstDayOfWeek = localizations.narrowWeekdays[localizations.firstDayOfWeekIndex]; + /// ``` + int get firstDayOfWeekIndex; + + /// The character string used to separate the parts of a compact date format + /// (i.e. mm/dd/yyyy has a separator of '/'). + String get dateSeparator; + + /// The help text used on an empty [InputDatePickerFormField] to indicate + /// to the user the date format being asked for. + String get dateHelpText; + + /// The semantic label used to announce when the user has entered the year + /// selection mode of the [CalendarDatePicker] which is used in the data picker + /// dialog created with [showDatePicker]. + String get selectYearSemanticsLabel; + + /// The label used to indicate a date that has not been entered or selected + /// yet in the date picker. + String get unspecifiedDate; + + /// The label used to indicate a date range that has not been entered or + /// selected yet in the date range picker. + String get unspecifiedDateRange; + + /// The label used to describe the text field used in an [InputDatePickerFormField]. + String get dateInputLabel; + + /// The label used for the starting date input field in the date range picker + /// created with [showDateRangePicker]. + String get dateRangeStartLabel; + + /// The label used for the ending date input field in the date range picker + /// created with [showDateRangePicker]. + String get dateRangeEndLabel; + + /// The semantics label used for the selected start date in the date range + /// picker's day grid. + String dateRangeStartDateSemanticLabel(String formattedDate); + + /// The semantics label used for the selected end date in the date range + /// picker's day grid. + String dateRangeEndDateSemanticLabel(String formattedDate); + + /// Error message displayed to the user when they have entered a text string + /// in an [InputDatePickerFormField] that is not in a valid date format. + String get invalidDateFormatLabel; + + /// Error message displayed to the user when they have entered an invalid + /// date range in the input mode of the date range picker created with + /// [showDateRangePicker]. + String get invalidDateRangeLabel; + + /// Error message displayed to the user when they have entered a date that + /// is outside the valid range for the date picker. + /// [showDateRangePicker]. + String get dateOutOfRangeLabel; + + /// Label for a 'SAVE' button. Currently used by the full screen mode of the + /// date range picker. + String get saveButtonLabel; + + /// Label used in the header of the date picker dialog created with + /// [showDatePicker]. + String get datePickerHelpText; + + /// Label used in the header of the date range picker dialog created with + /// [showDateRangePicker]. + String get dateRangePickerHelpText; + + /// Tooltip used for the calendar mode button of the date pickers. + String get calendarModeButtonLabel; + + /// Tooltip used for the text input mode button of the date pickers. + String get inputDateModeButtonLabel; + + /// Label used in the header of the time picker dialog created with + /// [showTimePicker] when in [TimePickerEntryMode.dial]. + String get timePickerDialHelpText; + + /// Label used in the header of the time picker dialog created with + /// [showTimePicker] when in [TimePickerEntryMode.input]. + String get timePickerInputHelpText; + + /// Label used below the hour text field of the time picker dialog created + /// with [showTimePicker] when in [TimePickerEntryMode.input]. + String get timePickerHourLabel; + + /// Label used below the minute text field of the time picker dialog created + /// with [showTimePicker] when in [TimePickerEntryMode.input]. + String get timePickerMinuteLabel; + + /// Error message for the time picker dialog created with [showTimePicker] + /// when in [TimePickerEntryMode.input]. + String get invalidTimeLabel; + + /// Tooltip used to put the time picker into [TimePickerEntryMode.dial]. + String get dialModeButtonLabel; + + /// Tooltip used to put the time picker into [TimePickerEntryMode.input]. + String get inputTimeModeButtonLabel; + + /// The semantics label used to indicate which account is signed in the + /// [UserAccountsDrawerHeader] widget. + String get signedInLabel; + + /// The semantics label used for the button on [UserAccountsDrawerHeader] that + /// hides the list of accounts. + String get hideAccountsLabel; + + /// The semantics label used for the button on [UserAccountsDrawerHeader] that + /// shows the list of accounts. + String get showAccountsLabel; + + /// The semantics label used for [ReorderableListView] to reorder an item in the + /// list to the start of the list. + @Deprecated( + 'Use the reorderItemToStart from WidgetsLocalizations instead. ' + 'This feature was deprecated after v3.10.0-2.0.pre.', + ) + String get reorderItemToStart; + + /// The semantics label used for [ReorderableListView] to reorder an item in the + /// list to the end of the list. + @Deprecated( + 'Use the reorderItemToEnd from WidgetsLocalizations instead. ' + 'This feature was deprecated after v3.10.0-2.0.pre.', + ) + String get reorderItemToEnd; + + /// The semantics label used for [ReorderableListView] to reorder an item in the + /// list one space up the list. + @Deprecated( + 'Use the reorderItemUp from WidgetsLocalizations instead. ' + 'This feature was deprecated after v3.10.0-2.0.pre.', + ) + String get reorderItemUp; + + /// The semantics label used for [ReorderableListView] to reorder an item in the + /// list one space down the list. + @Deprecated( + 'Use the reorderItemDown from WidgetsLocalizations instead. ' + 'This feature was deprecated after v3.10.0-2.0.pre.', + ) + String get reorderItemDown; + + /// The semantics label used for [ReorderableListView] to reorder an item in the + /// list one space left in the list. + @Deprecated( + 'Use the reorderItemLeft from WidgetsLocalizations instead. ' + 'This feature was deprecated after v3.10.0-2.0.pre.', + ) + String get reorderItemLeft; + + /// The semantics label used for [ReorderableListView] to reorder an item in the + /// list one space right in the list. + @Deprecated( + 'Use the reorderItemRight from WidgetsLocalizations instead. ' + 'This feature was deprecated after v3.10.0-2.0.pre.', + ) + String get reorderItemRight; + + /// The semantics hint to describe the tap action on an expanded [ExpandIcon]. + String get expandedIconTapHint => 'Collapse'; + + /// The semantics hint to describe the tap action on a collapsed [ExpandIcon]. + String get collapsedIconTapHint => 'Expand'; + + /// The semantics hint to describe the tap action on an expanded + /// [ExpansionTile] on iOS and macOS. This is appended to the [collapsedHint] + /// hint to provide a more detailed description of the action, e.g. "Expanded + /// double tap to collapse". + String get expansionTileExpandedHint => 'double tap to collapse'; + + /// The semantics hint to describe the tap action on a collapsed + /// [ExpansionTile] on iOS and macOS. This is appended to the [expandedHint] + /// hint to provide a more detailed description of the action, e.g. "Collapsed + /// double tap to expand". + String get expansionTileCollapsedHint => 'double tap to expand'; + + /// The semantics hint to describe the tap action on an expanded [ExpansionTile]. + String get expansionTileExpandedTapHint => 'Collapse'; + + /// The semantics hint to describe the tap action on a collapsed [ExpansionTile]. + String get expansionTileCollapsedTapHint => 'Expand for more details'; + + /// The semantics hint to describe the [ExpansionTile] expanded state. + String get expandedHint => 'Collapsed'; + + /// The semantics hint to describe the [ExpansionTile] collapsed state. + String get collapsedHint => 'Expanded'; + + /// The label for the [TextField]'s character counter. + String remainingTextFieldCharacterCount(int remaining); + + /// The default semantics label for a [RefreshIndicator]. + String get refreshIndicatorSemanticLabel; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.alt]. + String get keyboardKeyAlt; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.altGraph]. + String get keyboardKeyAltGraph; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.backspace]. + String get keyboardKeyBackspace; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.capsLock]. + String get keyboardKeyCapsLock; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.channelDown]. + String get keyboardKeyChannelDown; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.channelUp]. + String get keyboardKeyChannelUp; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.control]. + String get keyboardKeyControl; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.delete]. + String get keyboardKeyDelete; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.eject]. + String get keyboardKeyEject; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.end]. + String get keyboardKeyEnd; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.escape]. + String get keyboardKeyEscape; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.fn]. + String get keyboardKeyFn; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.home]. + String get keyboardKeyHome; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.insert]. + String get keyboardKeyInsert; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.meta]. + String get keyboardKeyMeta; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.meta] on macOS. + String get keyboardKeyMetaMacOs; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.meta] on Windows. + String get keyboardKeyMetaWindows; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numLock]. + String get keyboardKeyNumLock; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad1]. + String get keyboardKeyNumpad1; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad2]. + String get keyboardKeyNumpad2; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad3]. + String get keyboardKeyNumpad3; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad4]. + String get keyboardKeyNumpad4; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad5]. + String get keyboardKeyNumpad5; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad6]. + String get keyboardKeyNumpad6; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad7]. + String get keyboardKeyNumpad7; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad8]. + String get keyboardKeyNumpad8; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad9]. + String get keyboardKeyNumpad9; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpad0]. + String get keyboardKeyNumpad0; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadAdd]. + String get keyboardKeyNumpadAdd; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadComma]. + String get keyboardKeyNumpadComma; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadDecimal]. + String get keyboardKeyNumpadDecimal; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadDivide]. + String get keyboardKeyNumpadDivide; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadEnter]. + String get keyboardKeyNumpadEnter; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadEqual]. + String get keyboardKeyNumpadEqual; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadMultiply]. + String get keyboardKeyNumpadMultiply; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadParenLeft]. + String get keyboardKeyNumpadParenLeft; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadParenRight]. + String get keyboardKeyNumpadParenRight; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.numpadSubtract]. + String get keyboardKeyNumpadSubtract; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.pageDown]. + String get keyboardKeyPageDown; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.pageUp]. + String get keyboardKeyPageUp; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.power]. + String get keyboardKeyPower; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.powerOff]. + String get keyboardKeyPowerOff; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.printScreen]. + String get keyboardKeyPrintScreen; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.scrollLock]. + String get keyboardKeyScrollLock; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.select]. + String get keyboardKeySelect; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.shift]. + String get keyboardKeyShift; + + /// The shortcut label for the keyboard key [LogicalKeyboardKey.space]. + String get keyboardKeySpace; + + /// The `MaterialLocalizations` from the closest [Localizations] instance + /// that encloses the given context. + /// + /// If no [MaterialLocalizations] are available in the given `context`, this + /// method throws an exception. + /// + /// This method is just a convenient shorthand for: + /// `Localizations.of<MaterialLocalizations>(context, MaterialLocalizations)!`. + /// + /// References to the localized resources defined by this class are typically + /// written in terms of this method. For example: + /// + /// ```dart + /// tooltip: MaterialLocalizations.of(context).backButtonTooltip, + /// ``` + static MaterialLocalizations of(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + return Localizations.of<MaterialLocalizations>(context, MaterialLocalizations)!; + } +} + +class _MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { + const _MaterialLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => locale.languageCode == 'en'; + + @override + Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale); + + @override + bool shouldReload(_MaterialLocalizationsDelegate old) => false; + + @override + String toString() => 'DefaultMaterialLocalizations.delegate(en_US)'; +} + +/// US English strings for the material widgets. +/// +/// See also: +/// +/// * [GlobalMaterialLocalizations], which provides material localizations for +/// many languages. +/// * [MaterialApp.localizationsDelegates], which automatically includes +/// [DefaultMaterialLocalizations.delegate] by default. +class DefaultMaterialLocalizations implements MaterialLocalizations { + /// Constructs an object that defines the material widgets' localized strings + /// for US English (only). + /// + /// [LocalizationsDelegate] implementations typically call the static [load] + /// function, rather than constructing this class directly. + const DefaultMaterialLocalizations(); + + // Ordered to match DateTime.monday=1, DateTime.sunday=6 + static const List<String> _shortWeekdays = <String>[ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + + // Ordered to match DateTime.monday=1, DateTime.sunday=6 + static const List<String> _weekdays = <String>[ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + + static const List<String> _narrowWeekdays = <String>['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + static const List<String> _shortMonths = <String>[ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + static const List<String> _months = <String>[ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + /// Returns the number of days in a month, according to the proleptic + /// Gregorian calendar. + /// + /// This applies the leap year logic introduced by the Gregorian reforms of + /// 1582. It will not give valid results for dates prior to that time. + int _getDaysInMonth(int year, int month) { + if (month == DateTime.february) { + final bool isLeapYear = (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0); + if (isLeapYear) { + return 29; + } + return 28; + } + const daysInMonth = <int>[31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return daysInMonth[month - 1]; + } + + @override + String formatHour(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}) { + final TimeOfDayFormat format = timeOfDayFormat(alwaysUse24HourFormat: alwaysUse24HourFormat); + switch (format) { + case TimeOfDayFormat.h_colon_mm_space_a: + return formatDecimal(timeOfDay.hourOfPeriod == 0 ? 12 : timeOfDay.hourOfPeriod); + case TimeOfDayFormat.HH_colon_mm: + return _formatTwoDigitZeroPad(timeOfDay.hour); + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.frenchCanadian: + case TimeOfDayFormat.H_colon_mm: + case TimeOfDayFormat.HH_dot_mm: + throw AssertionError('$runtimeType does not support $format.'); + } + } + + /// Formats [number] using two digits, assuming it's in the 0-99 inclusive + /// range. Not designed to format values outside this range. + String _formatTwoDigitZeroPad(int number) { + assert(0 <= number && number < 100); + + if (number < 10) { + return '0$number'; + } + + return '$number'; + } + + @override + String formatMinute(TimeOfDay timeOfDay) { + final int minute = timeOfDay.minute; + return minute < 10 ? '0$minute' : minute.toString(); + } + + @override + String formatYear(DateTime date) => date.year.toString(); + + @override + String formatCompactDate(DateTime date) { + // Assumes US mm/dd/yyyy format + final String month = _formatTwoDigitZeroPad(date.month); + final String day = _formatTwoDigitZeroPad(date.day); + final String year = date.year.toString().padLeft(4, '0'); + return '$month/$day/$year'; + } + + @override + String formatShortDate(DateTime date) { + final String month = _shortMonths[date.month - DateTime.january]; + return '$month ${date.day}, ${date.year}'; + } + + @override + String formatMediumDate(DateTime date) { + final String day = _shortWeekdays[date.weekday - DateTime.monday]; + final String month = _shortMonths[date.month - DateTime.january]; + return '$day, $month ${date.day}'; + } + + @override + String formatFullDate(DateTime date) { + final String month = _months[date.month - DateTime.january]; + return '${_weekdays[date.weekday - DateTime.monday]}, $month ${date.day}, ${date.year}'; + } + + @override + String formatMonthYear(DateTime date) { + final String year = formatYear(date); + final String month = _months[date.month - DateTime.january]; + return '$month $year'; + } + + @override + String formatShortMonthDay(DateTime date) { + final String month = _shortMonths[date.month - DateTime.january]; + return '$month ${date.day}'; + } + + @override + DateTime? parseCompactDate(String? inputString) { + if (inputString == null) { + return null; + } + + // Assumes US mm/dd/yyyy format + final List<String> inputParts = inputString.split('/'); + if (inputParts.length != 3) { + return null; + } + + final int? year = int.tryParse(inputParts[2], radix: 10); + if (year == null || year < 1) { + return null; + } + + final int? month = int.tryParse(inputParts[0], radix: 10); + if (month == null || month < 1 || month > 12) { + return null; + } + + final int? day = int.tryParse(inputParts[1], radix: 10); + if (day == null || day < 1 || day > _getDaysInMonth(year, month)) { + return null; + } + + try { + return DateTime(year, month, day); + } on ArgumentError { + return null; + } + } + + @override + List<String> get narrowWeekdays => _narrowWeekdays; + + @override + int get firstDayOfWeekIndex => 0; // narrowWeekdays[0] is 'S' for Sunday + + @override + String get dateSeparator => '/'; + + @override + String get dateHelpText => 'mm/dd/yyyy'; + + @override + String get selectYearSemanticsLabel => 'Select year'; + + @override + String get unspecifiedDate => 'Date'; + + @override + String get unspecifiedDateRange => 'Date Range'; + + @override + String get dateInputLabel => 'Enter Date'; + + @override + String get dateRangeStartLabel => 'Start Date'; + + @override + String get dateRangeEndLabel => 'End Date'; + + @override + String dateRangeStartDateSemanticLabel(String formattedDate) => 'Start date $formattedDate'; + + @override + String dateRangeEndDateSemanticLabel(String formattedDate) => 'End date $formattedDate'; + + @override + String get invalidDateFormatLabel => 'Invalid format.'; + + @override + String get invalidDateRangeLabel => 'Invalid range.'; + + @override + String get dateOutOfRangeLabel => 'Out of range.'; + + @override + String get saveButtonLabel => 'Save'; + + @override + String get datePickerHelpText => 'Select date'; + + @override + String get dateRangePickerHelpText => 'Select range'; + + @override + String get calendarModeButtonLabel => 'Switch to calendar'; + + @override + String get inputDateModeButtonLabel => 'Switch to input'; + + @override + String get timePickerDialHelpText => 'Select time'; + + @override + String get timePickerInputHelpText => 'Enter time'; + + @override + String get timePickerHourLabel => 'Hour'; + + @override + String get timePickerMinuteLabel => 'Minute'; + + @override + String get invalidTimeLabel => 'Enter a valid time'; + + @override + String get dialModeButtonLabel => 'Switch to dial picker mode'; + + @override + String get inputTimeModeButtonLabel => 'Switch to text input mode'; + + String _formatDayPeriod(TimeOfDay timeOfDay) { + return switch (timeOfDay.period) { + DayPeriod.am => anteMeridiemAbbreviation, + DayPeriod.pm => postMeridiemAbbreviation, + }; + } + + @override + String formatDecimal(int number) { + if (number > -1000 && number < 1000) { + return number.toString(); + } + + final digits = number.abs().toString(); + final result = StringBuffer(number < 0 ? '-' : ''); + final int maxDigitIndex = digits.length - 1; + for (var i = 0; i <= maxDigitIndex; i += 1) { + result.write(digits[i]); + if (i < maxDigitIndex && (maxDigitIndex - i) % 3 == 0) { + result.write(','); + } + } + return result.toString(); + } + + @override + String formatTimeOfDay(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}) { + // Not using intl.DateFormat for two reasons: + // + // - DateFormat supports more formats than our material time picker does, + // and we want to be consistent across time picker format and the string + // formatting of the time of day. + // - DateFormat operates on DateTime, which is sensitive to time eras and + // time zones, while here we want to format hour and minute within one day + // no matter what date the day falls on. + final buffer = StringBuffer(); + + // Add hour:minute. + buffer + ..write(formatHour(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat)) + ..write(':') + ..write(formatMinute(timeOfDay)); + + if (alwaysUse24HourFormat) { + // There's no AM/PM indicator in 24-hour format. + return '$buffer'; + } + + // Add AM/PM indicator. + buffer + ..write(' ') + ..write(_formatDayPeriod(timeOfDay)); + return '$buffer'; + } + + @override + String get openAppDrawerTooltip => 'Open navigation menu'; + + @override + String get backButtonTooltip => 'Back'; + + @override + String get clearButtonTooltip => 'Clear text'; + + @override + String get closeButtonTooltip => 'Close'; + + @override + String get deleteButtonTooltip => 'Delete'; + + @override + String get moreButtonTooltip => 'More'; + + @override + String get nextMonthTooltip => 'Next month'; + + @override + String get previousMonthTooltip => 'Previous month'; + + @override + String get nextPageTooltip => 'Next page'; + + @override + String get previousPageTooltip => 'Previous page'; + + @override + String get firstPageTooltip => 'First page'; + + @override + String get lastPageTooltip => 'Last page'; + + @override + String get showMenuTooltip => 'Show menu'; + + @override + String get drawerLabel => 'Navigation menu'; + + @override + String get menuBarMenuLabel => 'Menu bar menu'; + + @override + String get popupMenuLabel => 'Popup menu'; + + @override + String get dialogLabel => 'Dialog'; + + @override + String get alertDialogLabel => 'Alert'; + + @override + String get searchFieldLabel => 'Search'; + + @override + String get currentDateLabel => 'Today'; + + @override + String get selectedDateLabel => 'Selected'; + + @override + String get scrimLabel => 'Scrim'; + + @override + String get bottomSheetLabel => 'Bottom Sheet'; + + @override + String scrimOnTapHint(String modalRouteContentName) => 'Close $modalRouteContentName'; + + @override + String aboutListTileTitle(String applicationName) => 'About $applicationName'; + + @override + String get licensesPageTitle => 'Licenses'; + + @override + String licensesPackageDetailText(int licenseCount) { + assert(licenseCount >= 0); + return switch (licenseCount) { + 0 => 'No licenses.', + 1 => '1 license.', + _ => '$licenseCount licenses.', + }; + } + + @override + String pageRowsInfoTitle(int firstRow, int lastRow, int rowCount, bool rowCountIsApproximate) { + return rowCountIsApproximate + ? '$firstRow–$lastRow of about $rowCount' + : '$firstRow–$lastRow of $rowCount'; + } + + @override + String get rowsPerPageTitle => 'Rows per page:'; + + @override + String tabLabel({required int tabIndex, required int tabCount}) { + assert(tabIndex >= 1); + assert(tabCount >= 1); + return 'Tab $tabIndex of $tabCount'; + } + + @override + String selectedRowCountTitle(int selectedRowCount) { + return switch (selectedRowCount) { + 0 => 'No items selected', + 1 => '1 item selected', + _ => '$selectedRowCount items selected', + }; + } + + @override + String get cancelButtonLabel => 'Cancel'; + + @override + String get closeButtonLabel => 'Close'; + + @override + String get continueButtonLabel => 'Continue'; + + @override + String get copyButtonLabel => 'Copy'; + + @override + String get cutButtonLabel => 'Cut'; + + @override + String get scanTextButtonLabel => 'Scan text'; + + @override + String get okButtonLabel => 'OK'; + + @override + String get pasteButtonLabel => 'Paste'; + + @override + String get selectAllButtonLabel => 'Select all'; + + @override + String get lookUpButtonLabel => 'Look Up'; + + @override + String get searchWebButtonLabel => 'Search Web'; + + @override + String get shareButtonLabel => 'Share'; + + @override + String get viewLicensesButtonLabel => 'View licenses'; + + @override + String get anteMeridiemAbbreviation => 'AM'; + + @override + String get postMeridiemAbbreviation => 'PM'; + + @override + String get timePickerHourModeAnnouncement => 'Select hours'; + + @override + String get timePickerMinuteModeAnnouncement => 'Select minutes'; + + @override + String get modalBarrierDismissLabel => 'Dismiss'; + + @override + String get menuDismissLabel => 'Dismiss menu'; + + @override + ScriptCategory get scriptCategory => ScriptCategory.englishLike; + + @override + TimeOfDayFormat timeOfDayFormat({bool alwaysUse24HourFormat = false}) { + return alwaysUse24HourFormat ? TimeOfDayFormat.HH_colon_mm : TimeOfDayFormat.h_colon_mm_space_a; + } + + @override + String get signedInLabel => 'Signed in'; + + @override + String get hideAccountsLabel => 'Hide accounts'; + + @override + String get showAccountsLabel => 'Show accounts'; + + @override + String get reorderItemUp => 'Move up'; + + @override + String get reorderItemDown => 'Move down'; + + @override + String get reorderItemLeft => 'Move left'; + + @override + String get reorderItemRight => 'Move right'; + + @override + String get reorderItemToEnd => 'Move to the end'; + + @override + String get reorderItemToStart => 'Move to the start'; + + @override + String get expandedIconTapHint => 'Collapse'; + + @override + String get collapsedIconTapHint => 'Expand'; + + @override + String get expansionTileExpandedHint => 'double tap to collapse'; + + @override + String get expansionTileCollapsedHint => 'double tap to expand'; + + @override + String get expansionTileExpandedTapHint => 'Collapse'; + + @override + String get expansionTileCollapsedTapHint => 'Expand for more details'; + + @override + String get expandedHint => 'Collapsed'; + + @override + String get collapsedHint => 'Expanded'; + + @override + String get refreshIndicatorSemanticLabel => 'Refresh'; + + /// Creates an object that provides US English resource values for the material + /// library widgets. + /// + /// The [locale] parameter is ignored. + /// + /// This method is typically used to create a [LocalizationsDelegate]. + /// The [MaterialApp] does so by default. + static Future<MaterialLocalizations> load(Locale locale) { + return SynchronousFuture<MaterialLocalizations>(const DefaultMaterialLocalizations()); + } + + /// A [LocalizationsDelegate] that uses [DefaultMaterialLocalizations.load] + /// to create an instance of this class. + /// + /// [MaterialApp] automatically adds this value to [MaterialApp.localizationsDelegates]. + static const LocalizationsDelegate<MaterialLocalizations> delegate = + _MaterialLocalizationsDelegate(); + + @override + String remainingTextFieldCharacterCount(int remaining) { + return switch (remaining) { + 0 => 'No characters remaining', + 1 => '1 character remaining', + _ => '$remaining characters remaining', + }; + } + + @override + String get keyboardKeyAlt => 'Alt'; + + @override + String get keyboardKeyAltGraph => 'AltGr'; + + @override + String get keyboardKeyBackspace => 'Backspace'; + + @override + String get keyboardKeyCapsLock => 'Caps Lock'; + + @override + String get keyboardKeyChannelDown => 'Channel Down'; + + @override + String get keyboardKeyChannelUp => 'Channel Up'; + + @override + String get keyboardKeyControl => 'Ctrl'; + + @override + String get keyboardKeyDelete => 'Del'; + + @override + String get keyboardKeyEject => 'Eject'; + + @override + String get keyboardKeyEnd => 'End'; + + @override + String get keyboardKeyEscape => 'Esc'; + + @override + String get keyboardKeyFn => 'Fn'; + + @override + String get keyboardKeyHome => 'Home'; + + @override + String get keyboardKeyInsert => 'Insert'; + + @override + String get keyboardKeyMeta => 'Meta'; + + @override + String get keyboardKeyMetaMacOs => 'Command'; + + @override + String get keyboardKeyMetaWindows => 'Win'; + + @override + String get keyboardKeyNumLock => 'Num Lock'; + + @override + String get keyboardKeyNumpad1 => 'Num 1'; + + @override + String get keyboardKeyNumpad2 => 'Num 2'; + + @override + String get keyboardKeyNumpad3 => 'Num 3'; + + @override + String get keyboardKeyNumpad4 => 'Num 4'; + + @override + String get keyboardKeyNumpad5 => 'Num 5'; + + @override + String get keyboardKeyNumpad6 => 'Num 6'; + + @override + String get keyboardKeyNumpad7 => 'Num 7'; + + @override + String get keyboardKeyNumpad8 => 'Num 8'; + + @override + String get keyboardKeyNumpad9 => 'Num 9'; + + @override + String get keyboardKeyNumpad0 => 'Num 0'; + + @override + String get keyboardKeyNumpadAdd => 'Num +'; + + @override + String get keyboardKeyNumpadComma => 'Num ,'; + + @override + String get keyboardKeyNumpadDecimal => 'Num .'; + + @override + String get keyboardKeyNumpadDivide => 'Num /'; + + @override + String get keyboardKeyNumpadEnter => 'Num Enter'; + + @override + String get keyboardKeyNumpadEqual => 'Num ='; + + @override + String get keyboardKeyNumpadMultiply => 'Num *'; + + @override + String get keyboardKeyNumpadParenLeft => 'Num ('; + + @override + String get keyboardKeyNumpadParenRight => 'Num )'; + + @override + String get keyboardKeyNumpadSubtract => 'Num -'; + + @override + String get keyboardKeyPageDown => 'PgDown'; + + @override + String get keyboardKeyPageUp => 'PgUp'; + + @override + String get keyboardKeyPower => 'Power'; + + @override + String get keyboardKeyPowerOff => 'Power Off'; + + @override + String get keyboardKeyPrintScreen => 'Print Screen'; + + @override + String get keyboardKeyScrollLock => 'Scroll Lock'; + + @override + String get keyboardKeySelect => 'Select'; + + @override + String get keyboardKeyShift => 'Shift'; + + @override + String get keyboardKeySpace => 'Space'; +} diff --git a/packages/material_ui/lib/src/material_state.dart b/packages/material_ui/lib/src/material_state.dart new file mode 100644 index 000000000000..8698163f3f02 --- /dev/null +++ b/packages/material_ui/lib/src/material_state.dart @@ -0,0 +1,596 @@ +// 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. + +/// @docImport 'package:flutter/scheduler.dart'; +/// +/// @docImport 'action_chip.dart'; +/// @docImport 'button_style.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'input_decorator.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'text_field.dart'; +/// @docImport 'time_picker_theme.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'input_border.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Interactive states that some of the Material widgets can take on when +/// receiving input from the user. +/// +/// States are defined by https://material.io/design/interaction/states.html#usage. +/// +/// Some Material widgets track their current state in a `Set<MaterialState>`. +/// +/// See also: +/// +/// * [WidgetState], a general non-Material version that can be used +/// interchangeably with `MaterialState`. They functionally work the same, +/// except [WidgetState] can be used outside of Material. +/// * [MaterialStateProperty], an interface for objects that "resolve" to +/// different values depending on a widget's material state. +/// {@template flutter.material.MaterialStateProperty.implementations} +/// * [MaterialStateColor], a [Color] that implements `MaterialStateProperty` +/// which is used in APIs that need to accept either a [Color] or a +/// `MaterialStateProperty<Color>`. +/// * [MaterialStateMouseCursor], a [MouseCursor] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// a [MouseCursor] or a [MaterialStateProperty<MouseCursor>]. +/// * [MaterialStateOutlinedBorder], an [OutlinedBorder] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// an [OutlinedBorder] or a [MaterialStateProperty<OutlinedBorder>]. +/// * [MaterialStateOutlineInputBorder], an [OutlineInputBorder] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// an [OutlineInputBorder] or a [MaterialStateProperty<OutlineInputBorder>]. +/// * [MaterialStateUnderlineInputBorder], an [UnderlineInputBorder] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// an [UnderlineInputBorder] or a [MaterialStateProperty<UnderlineInputBorder>]. +/// * [MaterialStateBorderSide], a [BorderSide] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// a [BorderSide] or a [MaterialStateProperty<BorderSide>]. +/// * [MaterialStateTextStyle], a [TextStyle] that implements +/// `MaterialStateProperty` which is used in APIs that need to accept either +/// a [TextStyle] or a [MaterialStateProperty<TextStyle>]. +/// {@endtemplate} +@Deprecated( + 'Use WidgetState instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialState = WidgetState; + +/// Signature for the function that returns a value of type `T` based on a given +/// set of states. +/// +/// See also: +/// +/// * [WidgetPropertyResolver], the non-Material form of `MaterialPropertyResolver` +/// that can be used interchangeably with `MaterialPropertyResolver. +@Deprecated( + 'Use WidgetPropertyResolver instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialPropertyResolver<T> = WidgetPropertyResolver<T>; + +/// Defines a [Color] that is also a [MaterialStateProperty]. +/// +/// This class exists to enable widgets with [Color] valued properties +/// to also accept [MaterialStateProperty<Color>] values. A material +/// state color property represents a color which depends on +/// a widget's "interactive state". This state is represented as a +/// [Set] of [MaterialState]s, like [MaterialState.pressed], +/// [MaterialState.focused] and [MaterialState.hovered]. +/// +/// [MaterialStateColor] should only be used with widgets that document +/// their support, like [TimePickerThemeData.dayPeriodColor]. +/// +/// To use a [MaterialStateColor], you can either: +/// 1. Create a subclass of [MaterialStateColor] and implement the abstract `resolve` method. +/// 2. Use [MaterialStateColor.resolveWith] and pass in a callback that +/// will be used to resolve the color in the given states. +/// +/// If a [MaterialStateColor] is used for a property or a parameter that doesn't +/// support resolving [MaterialStateProperty<Color>]s, then its default color +/// value will be used for all states. +/// +/// To define a `const` [MaterialStateColor], you'll need to extend +/// [MaterialStateColor] and override its [resolve] method. You'll also need +/// to provide a `defaultValue` to the super constructor, so that we can know +/// at compile-time what its default color is. +/// +/// {@tool snippet} +/// +/// This example defines a [MaterialStateColor] with a const constructor. +/// +/// ```dart +/// // ignore: deprecated_member_use +/// class MyColor extends MaterialStateColor { +/// const MyColor() : super(_defaultColor); +/// +/// static const int _defaultColor = 0xcafefeed; +/// static const int _pressedColor = 0xdeadbeef; +/// +/// @override +/// // ignore: deprecated_member_use +/// Color resolve(Set<MaterialState> states) { +/// // ignore: deprecated_member_use +/// if (states.contains(MaterialState.pressed)) { +/// return const Color(_pressedColor); +/// } +/// return const Color(_defaultColor); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also +/// +/// * [WidgetStateColor], the non-Material version that can be used +/// interchangeably with `MaterialStateColor`. +@Deprecated( + 'Use WidgetStateColor instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStateColor = WidgetStateColor; + +/// Defines a [MouseCursor] whose value depends on a set of [MaterialState]s which +/// represent the interactive state of a component. +/// +/// This kind of [MouseCursor] is useful when the set of interactive +/// actions a widget supports varies with its state. For example, a +/// mouse pointer hovering over a disabled [ListTile] should not +/// display [SystemMouseCursors.click], since a disabled list tile +/// doesn't respond to mouse clicks. [ListTile]'s default mouse cursor +/// is a [MaterialStateMouseCursor.clickable], which resolves to +/// [SystemMouseCursors.basic] when the button is disabled. +/// +/// To use a [MaterialStateMouseCursor], you should create a subclass of +/// [MaterialStateMouseCursor] and implement the abstract `resolve` method. +/// +/// {@tool dartpad} +/// This example defines a mouse cursor that resolves to +/// [SystemMouseCursors.forbidden] when its widget is disabled. +/// +/// ** See code in examples/api/lib/material/material_state/material_state_mouse_cursor.0.dart ** +/// {@end-tool} +/// +/// This class should only be used for parameters which are documented to take +/// [MaterialStateMouseCursor], otherwise only the default state will be used. +/// +/// See also: +/// +/// * [WidgetStateMouseCursor], the non-Material version that can be used +/// interchangeably with `MaterialStateMouseCursor`. +/// * [MouseCursor] for introduction on the mouse cursor system. +/// * [SystemMouseCursors], which defines cursors that are supported by +/// native platforms. +@Deprecated( + 'Use WidgetStateMouseCursor instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStateMouseCursor = WidgetStateMouseCursor; + +/// Defines a [BorderSide] whose value depends on a set of [MaterialState]s +/// which represent the interactive state of a component. +/// +/// To use a [MaterialStateBorderSide], you should create a subclass of a +/// [MaterialStateBorderSide] and override the abstract `resolve` method. +/// +/// This class enables existing widget implementations with [BorderSide] +/// properties to be extended to also effectively support `MaterialStateProperty<BorderSide>` +/// property values. [MaterialStateBorderSide] should only be used with widgets that document +/// their support, like [ActionChip.side]. +/// +/// {@tool dartpad} +/// This example defines a subclass of [MaterialStateBorderSide], that resolves +/// to a red border side when its widget is selected. +/// +/// ** See code in examples/api/lib/material/material_state/material_state_border_side.0.dart ** +/// {@end-tool} +/// +/// This class should only be used for parameters which are documented to take +/// [MaterialStateBorderSide], otherwise only the default state will be used. +/// +/// See also: +/// +/// * [WidgetStateBorderSide], the non-Material version that can be used +/// interchangeably with `MaterialStateBorderSide`. +@Deprecated( + 'Use WidgetStateBorderSide instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStateBorderSide = WidgetStateBorderSide; + +/// Defines an [OutlinedBorder] whose value depends on a set of [MaterialState]s +/// which represent the interactive state of a component. +/// +/// To use a [MaterialStateOutlinedBorder], you should create a subclass of an +/// [OutlinedBorder] and implement [MaterialStateOutlinedBorder]'s abstract +/// `resolve` method. +/// +/// {@tool dartpad} +/// This example defines a subclass of [RoundedRectangleBorder] and an +/// implementation of [MaterialStateOutlinedBorder], that resolves to +/// [RoundedRectangleBorder] when its widget is selected. +/// +/// ** See code in examples/api/lib/widgets/widget_state/widget_state_outlined_border.0.dart ** +/// {@end-tool} +/// +/// This class should only be used for parameters which are documented to take +/// [MaterialStateOutlinedBorder], otherwise only the default state will be used. +/// +/// See also: +/// +/// * [WidgetStateOutlinedBorder], the non-Material version that can be used +/// interchangeably with `MaterialStateOutlinedBorder`. +/// * [ShapeBorder] the base class for shape outlines. +@Deprecated( + 'Use WidgetStateOutlinedBorder instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStateOutlinedBorder = WidgetStateOutlinedBorder; + +/// Defines a [TextStyle] that is also a [MaterialStateProperty]. +/// +/// This class exists to enable widgets with [TextStyle] valued properties +/// to also accept [MaterialStateProperty<TextStyle>] values. A material +/// state text style property represents a text style which depends on +/// a widget's "interactive state". This state is represented as a +/// [Set] of [MaterialState]s, like [MaterialState.pressed], +/// [MaterialState.focused] and [MaterialState.hovered]. +/// +/// [MaterialStateTextStyle] should only be used with widgets that document +/// their support, like [InputDecoration.labelStyle]. +/// +/// To use a [MaterialStateTextStyle], you can either: +/// 1. Create a subclass of [MaterialStateTextStyle] and implement the abstract `resolve` method. +/// 2. Use [MaterialStateTextStyle.resolveWith] and pass in a callback that +/// will be used to resolve the color in the given states. +/// +/// If a [MaterialStateTextStyle] is used for a property or a parameter that doesn't +/// support resolving [MaterialStateProperty<TextStyle>]s, then its default color +/// value will be used for all states. +/// +/// To define a `const` [MaterialStateTextStyle], you'll need to extend +/// [MaterialStateTextStyle] and override its [resolve] method. You'll also need +/// to provide a `defaultValue` to the super constructor, so that we can know +/// at compile-time what its default color is. +/// +/// See also: +/// +/// * [WidgetStateTextStyle], the non-Material version that can be used +/// interchangeably with `MaterialStateTextStyle`. +@Deprecated( + 'Use WidgetStateTextStyle instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStateTextStyle = WidgetStateTextStyle; + +/// Defines a [OutlineInputBorder] that is also a [MaterialStateProperty]. +/// +/// This class exists to enable widgets with [OutlineInputBorder] valued properties +/// to also accept [MaterialStateProperty<OutlineInputBorder>] values. A material +/// state input border property represents an input border which depends on +/// a widget's "interactive state". This state is represented as a +/// [Set] of [MaterialState]s, like [MaterialState.pressed], +/// [MaterialState.focused] and [MaterialState.hovered]. +/// +/// [MaterialStateOutlineInputBorder] should only be used with widgets that document +/// their support, like [InputDecoration.border]. +/// +/// To use a [MaterialStateOutlineInputBorder], you can either: +/// 1. Create a subclass of [MaterialStateOutlineInputBorder] and implement the abstract `resolve` method. +/// 2. Use [MaterialStateOutlineInputBorder.resolveWith] and pass in a callback that +/// will be used to resolve the color in the given states. +/// +/// If a [MaterialStateOutlineInputBorder] is used for a property or a parameter that doesn't +/// support resolving [MaterialStateProperty<OutlineInputBorder>]s, then its default color +/// value will be used for all states. +/// +/// To define a `const` [MaterialStateOutlineInputBorder], you'll need to extend +/// [MaterialStateOutlineInputBorder] and override its [resolve] method. You'll also need +/// to provide a `defaultValue` to the super constructor, so that we can know +/// at compile-time what its default color is. +@Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', +) +abstract class MaterialStateOutlineInputBorder extends OutlineInputBorder + implements MaterialStateProperty<InputBorder> { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + @Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', + ) + const MaterialStateOutlineInputBorder(); + + /// Creates a [MaterialStateOutlineInputBorder] from a [MaterialPropertyResolver<InputBorder>] + /// callback function. + /// + /// If used as a regular input border, the border resolved in the default state (the + /// empty set of states) will be used. + /// + /// The given callback parameter must return a non-null text style in the default + /// state. + @Deprecated( + 'Use WidgetStateInputBorder.resolveWith() instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', + ) + const factory MaterialStateOutlineInputBorder.resolveWith( + MaterialPropertyResolver<InputBorder> callback, + ) = _MaterialStateOutlineInputBorder; + + /// Returns a [InputBorder] that's to be used when a Material component is in the + /// specified state. + @override + InputBorder resolve(Set<MaterialState> states); +} + +/// A [MaterialStateOutlineInputBorder] created from a [MaterialPropertyResolver<OutlineInputBorder>] +/// callback alone. +/// +/// If used as a regular input border, the border resolved in the default state will +/// be used. +/// +/// Used by [MaterialStateTextStyle.resolveWith]. +class _MaterialStateOutlineInputBorder extends MaterialStateOutlineInputBorder { + const _MaterialStateOutlineInputBorder(this._resolve); + + final MaterialPropertyResolver<InputBorder> _resolve; + + @override + InputBorder resolve(Set<MaterialState> states) => _resolve(states); +} + +/// Defines a [UnderlineInputBorder] that is also a [MaterialStateProperty]. +/// +/// This class exists to enable widgets with [UnderlineInputBorder] valued properties +/// to also accept [MaterialStateProperty<UnderlineInputBorder>] values. A material +/// state input border property represents an input border which depends on +/// a widget's "interactive state". This state is represented as a +/// [Set] of [MaterialState]s, like [MaterialState.pressed], +/// [MaterialState.focused] and [MaterialState.hovered]. +/// +/// [MaterialStateUnderlineInputBorder] should only be used with widgets that document +/// their support, like [InputDecoration.border]. +/// +/// To use a [MaterialStateUnderlineInputBorder], you can either: +/// 1. Create a subclass of [MaterialStateUnderlineInputBorder] and implement the abstract `resolve` method. +/// 2. Use [MaterialStateUnderlineInputBorder.resolveWith] and pass in a callback that +/// will be used to resolve the color in the given states. +/// +/// If a [MaterialStateUnderlineInputBorder] is used for a property or a parameter that doesn't +/// support resolving [MaterialStateProperty<UnderlineInputBorder>]s, then its default color +/// value will be used for all states. +/// +/// To define a `const` [MaterialStateUnderlineInputBorder], you'll need to extend +/// [MaterialStateUnderlineInputBorder] and override its [resolve] method. You'll also need +/// to provide a `defaultValue` to the super constructor, so that we can know +/// at compile-time what its default color is. +@Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', +) +abstract class MaterialStateUnderlineInputBorder extends UnderlineInputBorder + implements MaterialStateProperty<InputBorder> { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + @Deprecated( + 'Use WidgetStateInputBorder instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', + ) + const MaterialStateUnderlineInputBorder(); + + /// Creates a [MaterialStateUnderlineInputBorder] from a [MaterialPropertyResolver<InputBorder>] + /// callback function. + /// + /// If used as a regular input border, the border resolved in the default state (the + /// empty set of states) will be used. + /// + /// The given callback parameter must return a non-null text style in the default + /// state. + @Deprecated( + 'Use WidgetStateInputBorder.resolveWith() instead. ' + 'Renamed to match other WidgetStateProperty objects. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', + ) + const factory MaterialStateUnderlineInputBorder.resolveWith( + MaterialPropertyResolver<InputBorder> callback, + ) = _MaterialStateUnderlineInputBorder; + + /// Returns a [InputBorder] that's to be used when a Material component is in the + /// specified state. + @override + InputBorder resolve(Set<MaterialState> states); +} + +/// A [MaterialStateUnderlineInputBorder] created from a [MaterialPropertyResolver<UnderlineInputBorder>] +/// callback alone. +/// +/// If used as a regular input border, the border resolved in the default state will +/// be used. +/// +/// Used by [MaterialStateTextStyle.resolveWith]. +class _MaterialStateUnderlineInputBorder extends MaterialStateUnderlineInputBorder { + const _MaterialStateUnderlineInputBorder(this._resolve); + + final MaterialPropertyResolver<InputBorder> _resolve; + + @override + InputBorder resolve(Set<MaterialState> states) => _resolve(states); +} + +/// Defines an [InputBorder] that is also a [WidgetStateProperty]. +/// +/// This class exists to enable widgets with [InputBorder] valued properties +/// to also accept [WidgetStateProperty] objects. +/// +/// [WidgetStateInputBorder] should only be used with widgets that document +/// their support, like [InputDecoration.border]. +/// +/// A [WidgetStateInputBorder] can be created by: +/// 1. Creating a class that extends [OutlineInputBorder] or [UnderlineInputBorder] +/// and implements [WidgetStateInputBorder]. The class would also need to +/// override the [resolve] method. +/// 2. Using [WidgetStateInputBorder.resolveWith] with a callback that +/// resolves the input border in the given states. +/// 3. Using [WidgetStateInputBorder.fromMap] to assign a border with a [WidgetStateMap]. +/// +/// {@tool dartpad} +/// This example shows how to use [WidgetStateInputBorder] to create +/// a [TextField] with an appearance that responds to user interaction. +/// +/// ** See code in examples/api/lib/material/widget_state_input_border/widget_state_input_border.0.dart ** +/// {@end-tool} +abstract interface class WidgetStateInputBorder + implements InputBorder, WidgetStateProperty<InputBorder> { + /// Creates a [WidgetStateInputBorder] using a [WidgetPropertyResolver] + /// callback. + /// + /// This constructor should only be used for fields that support + /// [WidgetStateInputBorder], such as [InputDecoration.border] + /// (if used as a regular [InputBorder], it acts the same as + /// an empty `OutlineInputBorder()` constructor). + const factory WidgetStateInputBorder.resolveWith(WidgetPropertyResolver<InputBorder> callback) = + _WidgetStateInputBorder; + + /// Creates a [WidgetStateOutlinedBorder] from a [WidgetStateMap]. + /// + /// {@macro flutter.widgets.WidgetStateProperty.fromMap} + /// It should only be used for fields that support [WidgetStateOutlinedBorder] + /// objects, such as [InputDecoration.border] + /// (throws an error if used as a regular [OutlinedBorder]). + /// + /// {@macro flutter.widgets.WidgetState.any} + const factory WidgetStateInputBorder.fromMap(WidgetStateMap<InputBorder> map) = + _WidgetInputBorderMapper; +} + +class _WidgetStateInputBorder extends OutlineInputBorder implements WidgetStateInputBorder { + const _WidgetStateInputBorder(this._resolve); + + final WidgetPropertyResolver<InputBorder> _resolve; + + @override + InputBorder resolve(Set<WidgetState> states) => _resolve(states); +} + +class _WidgetInputBorderMapper extends WidgetStateMapper<InputBorder> + implements WidgetStateInputBorder { + const _WidgetInputBorderMapper(super.map); +} + +/// Interface for classes that [resolve] to a value of type `T` based +/// on a widget's interactive "state", which is defined as a set +/// of [MaterialState]s. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=CylXr3AF3uU} +/// +/// Material state properties represent values that depend on a widget's material +/// "state". The state is encoded as a set of [MaterialState] values, like +/// [WidgetState.focused], [WidgetState.hovered], [WidgetState.pressed]. For +/// example the [InkWell.overlayColor] defines the color that fills the ink well +/// when it's pressed (the "splash color"), focused, or hovered. The [InkWell] +/// uses the overlay color's [resolve] method to compute the color for the +/// ink well's current state. +/// +/// [ButtonStyle], which is used to configure the appearance of +/// buttons like [TextButton], [ElevatedButton], and [OutlinedButton], +/// has many material state properties. The button widgets keep track +/// of their current material state and [resolve] the button style's +/// material state properties when their value is needed. +/// +/// {@tool dartpad} +/// This example shows how you can override the default text and icon +/// color (the "foreground color") of a [TextButton] with a +/// [MaterialStateProperty]. In this example, the button's text color +/// will be `Colors.blue` when the button is being pressed, hovered, +/// or focused. Otherwise, the text color will be `Colors.red`. +/// +/// ** See code in examples/api/lib/material/material_state/material_state_property.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [WidgetStateProperty], the non-Material version that can be used +/// interchangeably with `MaterialStateProperty`. +/// {@macro flutter.material.MaterialStateProperty.implementations} +@Deprecated( + 'Use WidgetStateProperty instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStateProperty<T> = WidgetStateProperty<T>; + +/// Convenience class for creating a [MaterialStateProperty] that +/// resolves to the given value for all states. +/// +/// See also: +/// +/// * [WidgetStatePropertyAll], the non-Material version that can be used +/// interchangeably with `MaterialStatePropertyAll`. +@Deprecated( + 'Use WidgetStatePropertyAll instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStatePropertyAll<T> = WidgetStatePropertyAll<T>; + +/// Manages a set of [MaterialState]s and notifies listeners of changes. +/// +/// Used by widgets that expose their internal state for the sake of +/// extensions that add support for additional states. See +/// [TextButton] for an example. +/// +/// The controller's [value] is its current set of states. Listeners +/// are notified whenever the [value] changes. The [value] should only be +/// changed with [update]; it should not be modified directly. +/// +/// The controller's [value] represents the set of states that a +/// widget's visual properties, typically [MaterialStateProperty] +/// values, are resolved against. It is _not_ the intrinsic state of +/// the widget. The widget is responsible for ensuring that the +/// controller's [value] tracks its intrinsic state. For example one +/// cannot request the keyboard focus for a widget by adding +/// [WidgetState.focused] to its controller. When the widget gains the +/// or loses the focus it will [update] its controller's [value] and +/// notify listeners of the change. +/// +/// When calling `setState` in a [MaterialStatesController] listener, use the +/// [SchedulerBinding.addPostFrameCallback] to delay the call to `setState` after +/// the frame has been rendered. It's generally prudent to use the +/// [SchedulerBinding.addPostFrameCallback] because some of the widgets that +/// depend on [MaterialStatesController] may call [update] in their build method. +/// In such cases, listener's that call `setState` - during the build phase - will cause +/// an error. +/// +/// See also: +/// +/// * [WidgetStatesController], the non-Material version that can be used +/// interchangeably with `MaterialStatesController`. +@Deprecated( + 'Use WidgetStatesController instead. ' + 'Moved to the Widgets layer to make code available outside of Material. ' + 'This feature was deprecated after v3.19.0-0.3.pre.', +) +typedef MaterialStatesController = WidgetStatesController; diff --git a/packages/material_ui/lib/src/material_state_mixin.dart b/packages/material_ui/lib/src/material_state_mixin.dart new file mode 100644 index 000000000000..ff0239b1d4c3 --- /dev/null +++ b/packages/material_ui/lib/src/material_state_mixin.dart @@ -0,0 +1,177 @@ +// 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. + +/// @docImport 'ink_well.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Mixin for [State] classes that require knowledge of changing [WidgetState] +/// values for their child widgets. +/// +/// This mixin does nothing by mere application to a [State] class, but is +/// helpful when writing `build` methods that include child [InkWell], +/// [GestureDetector], [MouseRegion], or [Focus] widgets. Instead of manually +/// creating handlers for each type of user interaction, such [State] classes can +/// instead provide a `ValueChanged<bool>` function and allow [MaterialStateMixin] +/// to manage the set of active [WidgetState]s, and the calling of [setState] +/// as necessary. +/// +/// {@tool snippet} +/// This example shows how to write a [StatefulWidget] that uses the +/// [MaterialStateMixin] class to watch [WidgetState] values. +/// +/// ```dart +/// class MyWidget extends StatefulWidget { +/// const MyWidget({super.key, required this.color, required this.child}); +/// +/// final WidgetStateColor color; +/// final Widget child; +/// +/// @override +/// State<MyWidget> createState() => MyWidgetState(); +/// } +/// +/// class MyWidgetState extends State<MyWidget> with MaterialStateMixin<MyWidget> { +/// @override +/// Widget build(BuildContext context) { +/// return InkWell( +/// onFocusChange: updateMaterialState(WidgetState.focused), +/// child: ColoredBox( +/// color: widget.color.resolve(materialStates), +/// child: widget.child, +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +@optionalTypeArgs +mixin MaterialStateMixin<T extends StatefulWidget> on State<T> { + /// Managed set of active [WidgetState] values; designed to be passed to + /// [WidgetStateProperty.resolve] methods. + /// + /// To mutate and have [setState] called automatically for you, use + /// [setMaterialState], [addMaterialState], or [removeMaterialState]. Directly + /// mutating the set is possible, and may be necessary if you need to alter its + /// list without calling [setState] (and thus triggering a re-render). + /// + /// To check for a single condition, convenience getters [isPressed], [isHovered], + /// [isFocused], etc, are available for each [WidgetState] value. + @protected + Set<WidgetState> materialStates = <WidgetState>{}; + + /// Callback factory which accepts a [WidgetState] value and returns a + /// closure to mutate [materialStates] and call [setState]. + /// + /// Accepts an optional second named parameter, `onChanged`, which allows + /// arbitrary functionality to be wired through the [MaterialStateMixin]. + /// If supplied, the [onChanged] function is only called when child widgets + /// report events that make changes to the current set of [WidgetState]s. + /// + /// {@tool snippet} + /// This example shows how to use the [updateMaterialState] callback factory + /// in other widgets, including the optional [onChanged] callback. + /// + /// ```dart + /// class MyWidget extends StatefulWidget { + /// const MyWidget({super.key, this.onPressed}); + /// + /// /// Something important this widget must do when pressed. + /// final VoidCallback? onPressed; + /// + /// @override + /// State<MyWidget> createState() => MyWidgetState(); + /// } + /// + /// class MyWidgetState extends State<MyWidget> with MaterialStateMixin<MyWidget> { + /// @override + /// Widget build(BuildContext context) { + /// return ColoredBox( + /// color: isPressed ? Colors.black : Colors.white, + /// child: InkWell( + /// onHighlightChanged: updateMaterialState( + /// WidgetState.pressed, + /// onChanged: (bool val) { + /// if (val) { + /// widget.onPressed?.call(); + /// } + /// }, + /// ), + /// ), + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + @protected + ValueChanged<bool> updateMaterialState(WidgetState key, {ValueChanged<bool>? onChanged}) { + return (bool value) { + if (materialStates.contains(key) == value) { + return; + } + setMaterialState(key, value); + onChanged?.call(value); + }; + } + + /// Mutator to mark a [WidgetState] value as either active or inactive. + @protected + void setMaterialState(WidgetState state, bool isSet) { + return isSet ? addMaterialState(state) : removeMaterialState(state); + } + + /// Mutator to mark a [WidgetState] value as active. + @protected + void addMaterialState(WidgetState state) { + if (materialStates.add(state)) { + setState(() {}); + } + } + + /// Mutator to mark a [WidgetState] value as inactive. + @protected + void removeMaterialState(WidgetState state) { + if (materialStates.remove(state)) { + setState(() {}); + } + } + + /// Getter for whether this class considers [WidgetState.disabled] to be active. + bool get isDisabled => materialStates.contains(WidgetState.disabled); + + /// Getter for whether this class considers [WidgetState.dragged] to be active. + bool get isDragged => materialStates.contains(WidgetState.dragged); + + /// Getter for whether this class considers [WidgetState.error] to be active. + bool get isErrored => materialStates.contains(WidgetState.error); + + /// Getter for whether this class considers [WidgetState.focused] to be active. + bool get isFocused => materialStates.contains(WidgetState.focused); + + /// Getter for whether this class considers [WidgetState.hovered] to be active. + bool get isHovered => materialStates.contains(WidgetState.hovered); + + /// Getter for whether this class considers [WidgetState.pressed] to be active. + bool get isPressed => materialStates.contains(WidgetState.pressed); + + /// Getter for whether this class considers [WidgetState.scrolledUnder] to be active. + bool get isScrolledUnder => materialStates.contains(WidgetState.scrolledUnder); + + /// Getter for whether this class considers [WidgetState.selected] to be active. + bool get isSelected => materialStates.contains(WidgetState.selected); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<Set<WidgetState>>( + 'materialStates', + materialStates, + defaultValue: <WidgetState>{}, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/menu_anchor.dart b/packages/material_ui/lib/src/menu_anchor.dart new file mode 100644 index 000000000000..ad16b0437d68 --- /dev/null +++ b/packages/material_ui/lib/src/menu_anchor.dart @@ -0,0 +1,4265 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'checkbox_theme.dart'; +/// @docImport 'dropdown_menu.dart'; +/// @docImport 'radio_theme.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'checkbox.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'menu_bar_theme.dart'; +import 'menu_button_theme.dart'; +import 'menu_style.dart'; +import 'menu_theme.dart'; +import 'motion.dart'; +import 'radio.dart'; +import 'scrollbar.dart'; +import 'text_button.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// bool _throwShotAway = false; +// late BuildContext context; +// enum SingingCharacter { lafayette } +// late SingingCharacter? _character; +// late StateSetter setState; +// AnimationStatus animationStatus = AnimationStatus.dismissed; + +// Enable if you want verbose logging about menu changes. +const bool _kDebugMenus = false; + +// The default size of the arrow in _MenuItemLabel that indicates that a menu +// has a submenu. +const double _kDefaultSubmenuIconSize = 24; + +// The default spacing between the leading icon, label, trailing icon, and +// shortcut label in a _MenuItemLabel. +const double _kLabelItemDefaultSpacing = 12; + +// The minimum spacing between the leading icon, label, trailing icon, and +// shortcut label in a _MenuItemLabel. +const double _kLabelItemMinSpacing = 4; + +// Navigation shortcuts that we need to make sure are active when menus are +// open. +const Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts = <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), + SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right), +}; + +// The minimum vertical spacing on the outside of menus. +const double _kMenuVerticalMinPadding = 8; + +// How close to the edge of the safe area the menu will be placed. +const double _kMenuViewPadding = 8; + +// The minimum horizontal spacing on the outside of the top level menu. +const double _kTopLevelMenuHorizontalMinPadding = 4; + +// The default opening animation duration for menus. +const Duration _kMenuOpeningDuration = Duration(milliseconds: 500); + +// The default closing animation duration for menus. +const Duration _kMenuClosingDuration = Duration(milliseconds: 150); + +/// The default curve used to animate the height of the menu panel while the +/// menu is opening. +/// +/// **NOTE**: This animation curve follows the material web implementation of +/// [Easing.emphasized](https://github.com/material-components/material-web/blob/516cbc02bf770b7c3c5c6b546f1e5d81939b9f23/internal/motion/animation.ts#L18) +/// instead of the curve recommended by the Material 3 specification, +/// [Curves.easeInOutCubicEmphasized](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs). +/// This change was made because the web curve [was more consistent with the +/// timing of the menu item fade-in +/// animations](https://github.com/flutter/flutter/pull/176494#discussion_r2545178254). +const Curve _kMenuPanelHeightForwardCurve = Cubic(.3, 0, 0, 1); + +// The default curve used to animate the height of the menu panel while the menu +// is closing. +const Curve _kMenuPanelHeightReverseCurve = _TweenCurve( + 0.35, + 1, + curve: FlippedCurve(Easing.emphasizedAccelerate), +); + +// The default curve used to animate the opacity of the menu panel when opening. +const Curve _kMenuPanelOpacityForwardCurve = Interval(0, 50 / 500); + +// The default curve used to animate the opacity of the menu panel when closing. +const Curve _kMenuPanelOpacityReverseCurve = FlippedCurve(Interval(100 / 150, 150 / 150)); + +// The default fade-in duration of each menu item as a fraction of the opening +// duration. +const double _kMenuItemRelativeFadeInDuration = 1 / 2; + +// The default fade-out duration of each menu item as a fraction of the closing +// duration. +const double _kMenuItemRelativeFadeOutDuration = 1 / 3; + +// The default delay between the start of each menu item's fade-out as a +// fraction of the closing duration. +const double _kMenuItemRelativeFadeOutDelay = 1 / 3; + +/// The type of builder function used by [MenuAnchor.builder] to build the +/// widget that the [MenuAnchor] surrounds. +/// +/// The `context` is the context that the widget is being built in. +/// +/// The `controller` is the [MenuController] that can be used to open and close +/// the menu with. +/// +/// The `child` is an optional child supplied as the [MenuAnchor.child] +/// attribute. The child is intended to be incorporated in the result of the +/// function. +typedef MenuAnchorChildBuilder = + Widget Function(BuildContext context, MenuController controller, Widget? child); + +class _MenuAnchorScope extends InheritedWidget { + const _MenuAnchorScope({ + required this.state, + required this.animationStatus, + required super.child, + }); + + final _MenuAnchorState state; + final AnimationStatus animationStatus; + + @override + bool updateShouldNotify(_MenuAnchorScope oldWidget) { + assert(oldWidget.state == state, 'The state of a MenuAnchor should not change.'); + return oldWidget.animationStatus != animationStatus; + } +} + +// A curve that linearly interpolates between two values over a given curve. +// +// For example, `_TweenCurve(0.2, 0.8, curve: Curves.easeIn)` will produce a +// curve that starts at 0.2, ends at 0.8, and follows the easeIn curve between +// those two values. The curve is applied first, and then the result is linearly +// interpolated between begin and end. +// +// This differs from an `Interval` in that an Interval changes the duration over +// which the curve is applied, whereas `_TweenCurve` changes the output range of +// the curve. +class _TweenCurve extends Curve { + const _TweenCurve(this.begin, this.end, {required this.curve}) + : assert(begin >= 0.0), + assert(begin <= 1.0), + assert(end >= 0.0), + assert(end <= 1.0), + assert(end >= begin); + + final double begin; + final double end; + final Curve curve; + + @override + double transformInternal(double t) { + t = curve.transform(t); + return ui.lerpDouble(begin, end, t)!; + } + + @override + String toString() => '_TweenCurve($begin, $end, $curve)'; +} + +/// A widget used to mark the "anchor" for a set of submenus, defining the +/// rectangle used to position the menu, which can be done either with an +/// explicit location, or with an alignment. +/// +/// When creating a menu with [MenuBar] or a [SubmenuButton], a [MenuAnchor] is +/// not needed, since they provide their own internally. +/// +/// The [MenuAnchor] is meant to be a slightly lower level interface than +/// [MenuBar], used in situations where a [MenuBar] isn't appropriate, or to +/// construct widgets or screen regions that have submenus. +/// +/// To programmatically control a [MenuAnchor], like opening or closing it, or checking its state, +/// you can get its associated [MenuController]. Use `MenuController.maybeOf(BuildContext context)` +/// to retrieve the controller for the closest [MenuAnchor] ancestor of a given [BuildContext]. +/// More detailed usage of [MenuController] is available in its class documentation. +/// +/// {@tool dartpad} +/// This example shows how to use a [MenuAnchor] to wrap a button and open a +/// cascading menu from the button. This example also shows how to use +/// [onAnimationStatusChanged] to track animation status and toggle the menu. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to use a [MenuAnchor] to create a cascading context +/// menu in a region of the view, positioned where the user clicks the mouse +/// with Ctrl pressed. The [anchorTapClosesMenu] attribute is set to true so +/// that clicks on the [MenuAnchor] area will cause the menus to be closed. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example demonstrates a simplified cascading menu using the [MenuAnchor] +/// widget. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.3.dart ** +/// {@end-tool} +/// +/// The [MenuStyle.visualDensity] setting only affects horizontal padding, +/// and it will never make it negative. Vertical padding is not affected at all. +class MenuAnchor extends StatefulWidget { + /// Creates a const [MenuAnchor]. + /// + /// The [menuChildren] argument is required. + const MenuAnchor({ + super.key, + this.controller, + this.childFocusNode, + this.style, + this.alignmentOffset = Offset.zero, + this.reservedPadding, + this.layerLink, + this.clipBehavior = Clip.hardEdge, + @Deprecated( + 'Use consumeOutsideTap instead. ' + 'This feature was deprecated after v3.16.0-8.0.pre.', + ) + this.anchorTapClosesMenu = false, + this.consumeOutsideTap = false, + this.onOpen, + this.onClose, + this.crossAxisUnconstrained = true, + this.useRootOverlay = false, + this.animated = false, + this.onAnimationStatusChanged, + required this.menuChildren, + this.builder, + this.child, + }); + + /// An optional controller that allows opening and closing of the menu from + /// other widgets. + final MenuController? controller; + + /// The [childFocusNode] attribute is the optional [FocusNode] also associated + /// to the [child] or [builder] widget that opens the menu. + /// + /// The focus node should be attached to the widget that should receive focus + /// if keyboard focus traversal moves the focus off of the submenu with the + /// arrow keys. + /// + /// If not supplied, then keyboard traversal from the menu back to the + /// controlling button when the menu is open is disabled. + final FocusNode? childFocusNode; + + /// The [MenuStyle] that defines the visual attributes of the menu bar. + /// + /// Colors and sizing of the menus is controllable via the [MenuStyle]. + /// + /// Defaults to the ambient [MenuThemeData.style]. + final MenuStyle? style; + + /// {@template flutter.material.MenuAnchor.alignmentOffset} + /// The offset of the menu relative to the alignment origin determined by + /// [MenuStyle.alignment] on the [style] attribute and the ambient + /// [Directionality]. + /// + /// Use this for adjustments of the menu placement. + /// + /// Increasing [Offset.dy] values of [alignmentOffset] move the menu position + /// down. + /// + /// If the [MenuStyle.alignment] from [style] is not an [AlignmentDirectional] + /// (e.g. [Alignment]), then increasing [Offset.dx] values of + /// [alignmentOffset] move the menu position to the right. + /// + /// If the [MenuStyle.alignment] from [style] is an [AlignmentDirectional], + /// then in a [TextDirection.ltr] [Directionality], increasing [Offset.dx] + /// values of [alignmentOffset] move the menu position to the right. In a + /// [TextDirection.rtl] directionality, increasing [Offset.dx] values of + /// [alignmentOffset] move the menu position to the left. + /// + /// Defaults to [Offset.zero]. + /// {@endtemplate} + final Offset? alignmentOffset; + + /// An optional [LayerLink] to attach the menu to the widget that this + /// [MenuAnchor] surrounds. + /// + /// When provided, the menu will follow the widget that this [MenuAnchor] + /// surrounds if it moves because of view insets changes. + final LayerLink? layerLink; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// Whether the menus will be closed if the anchor area is tapped. + /// + /// For menus opened by buttons that toggle the menu, if the button is tapped + /// when the menu is open, the button should close the menu. But if + /// [anchorTapClosesMenu] is true, then the menu will close, and + /// (surprisingly) immediately re-open. This is because tapping on the button + /// closes the menu before the `onPressed` or `onTap` handler is called + /// because of it being considered to be "outside" the menu system, and then + /// the button (seeing that the menu is closed) immediately reopens the menu. + /// The result is that the user thinks that tapping on the button does + /// nothing. So, for button-initiated menus, this value is typically false so + /// that the menu anchor area is considered "inside" of the menu system and + /// doesn't cause it to close unless [MenuController.close] is called. + /// + /// For menus that are positioned using [MenuController.open]'s `position` + /// parameter, it is often desirable that clicking on the anchor always closes + /// the menu since the anchor area isn't usually considered part of the menu + /// system by the user. In this case [anchorTapClosesMenu] should be true. + /// + /// Defaults to false. + @Deprecated( + 'Use consumeOutsideTap instead. ' + 'This feature was deprecated after v3.16.0-8.0.pre.', + ) + final bool anchorTapClosesMenu; + + /// Whether or not a tap event that closes the menu will be permitted to + /// continue on to the gesture arena. + /// + /// If false, then tapping outside of a menu when the menu is open will both + /// close the menu, and allow the tap to participate in the gesture arena. If + /// true, then it will only close the menu, and the tap event will be + /// consumed. + /// + /// Defaults to false. + final bool consumeOutsideTap; + + /// A callback that is invoked when the menu begins opening. + /// + /// Defaults to null. + final VoidCallback? onOpen; + + /// A callback that is invoked when the menu finishes closing. + /// + /// Defaults to null. + final VoidCallback? onClose; + + /// Determine if the menu panel can be wrapped by a [UnconstrainedBox] which allows + /// the panel to render at its "natural" size. + /// + /// Defaults to true as it allows developers to render the menu panel at the + /// size it should be. When it is set to false, it can be useful when the menu should + /// be constrained in both main axis and cross axis, such as a [DropdownMenu]. + final bool crossAxisUnconstrained; + + /// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay} + /// + /// Defaults to false. + final bool useRootOverlay; + + /// Whether this widget should open or close a submenu with an animation. + /// + /// Defaults to false. + final bool animated; + + /// An optional callback that is invoked when the [AnimationStatus] of the + /// menu changes during open and close animations. + /// + /// If [animated] is false, this callback will only be invoked with + /// [AnimationStatus.completed] when the menu is opened, and + /// [AnimationStatus.dismissed] when the menu is closed. + /// + /// This callback provides a way to determine when the menu is opening or + /// closing. This is necessary because the [MenuController.isOpen] property + /// remains true throughout the opening, opened, and closing phases, and + /// therefore cannot be used on its own to determine the current animation + /// direction. + /// + /// {@tool snippet} + /// This example shows how to use the [onAnimationStatusChanged] callback to + /// create a [MenuAnchor] that will toggle between opening and closing. + /// + /// ```dart + /// MenuAnchor( + /// animated: true, + /// onAnimationStatusChanged: (AnimationStatus status) { + /// // Typically, animationStatus would be stored in a State object. + /// animationStatus = status; + /// }, + /// menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Menu Item'))], + /// builder: (BuildContext context, MenuController controller, Widget? child) { + /// return IconButton( + /// onPressed: () { + /// if (animationStatus.isForwardOrCompleted) { + /// controller.close(); + /// } else { + /// controller.open(); + /// } + /// }, + /// icon: const Icon(Icons.more_vert), + /// ); + /// }, + /// ); + /// ``` + /// {@end-tool} + /// + /// Defaults to null. + final ValueChanged<AnimationStatus>? onAnimationStatusChanged; + + /// A list of children containing the menu items that are the contents of the + /// menu surrounded by this [MenuAnchor]. + /// + /// {@macro flutter.material.MenuBar.shortcuts_note} + final List<Widget> menuChildren; + + /// The widget that this [MenuAnchor] surrounds. + /// + /// Typically this is a button used to open the menu by calling + /// [MenuController.open] on the `controller` passed to the builder. + /// + /// If not supplied, then the [MenuAnchor] will be the size that its parent + /// allocates for it. + /// + /// If provided, the builder will be called each time the menu is opened or + /// closed. + final MenuAnchorChildBuilder? builder; + + /// The optional child to be passed to the [builder]. + /// + /// Supply this child if there is a portion of the widget tree built in + /// [builder] that doesn't depend on the `controller` or `context` supplied to + /// the [builder]. It will be more efficient, since Flutter doesn't then need + /// to rebuild this child when those change. + final Widget? child; + + /// The padding between the edge of the safe area and the menu panel. + /// + /// Defaults to EdgeInsets.all(8). + final EdgeInsetsGeometry? reservedPadding; + + @override + State<MenuAnchor> createState() => _MenuAnchorState(); + + @override + List<DiagnosticsNode> debugDescribeChildren() { + return menuChildren.map<DiagnosticsNode>((Widget child) => child.toDiagnosticsNode()).toList(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty('anchorTapClosesMenu', value: anchorTapClosesMenu, ifTrue: 'AUTO-CLOSE'), + ); + properties.add(DiagnosticsProperty<FocusNode?>('focusNode', childFocusNode)); + properties.add(DiagnosticsProperty<MenuStyle?>('style', style)); + properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior)); + properties.add(DiagnosticsProperty<Offset?>('alignmentOffset', alignmentOffset)); + } +} + +class _MenuAnchorState extends State<MenuAnchor> with SingleTickerProviderStateMixin { + Axis get _orientation => Axis.vertical; + MenuController get _menuController => widget.controller ?? _internalMenuController!; + MenuController? _internalMenuController; + final FocusScopeNode _menuScopeNode = FocusScopeNode(); + late final AnimationController _animationController = AnimationController(vsync: this); + late final CurvedAnimation heightAnimation = CurvedAnimation( + parent: _animationController, + curve: _kMenuPanelHeightForwardCurve, + reverseCurve: _kMenuPanelHeightReverseCurve, + ); + late final CurvedAnimation opacityAnimation = CurvedAnimation( + parent: _animationController, + curve: _kMenuPanelOpacityForwardCurve, + reverseCurve: _kMenuPanelOpacityReverseCurve, + ); + List<Widget> _menuChildren = <Widget>[]; + List<CurvedAnimation> _cachedAnimations = <CurvedAnimation>[]; + _MenuAnchorState? get _parent => _maybeOf(context); + bool get isSubmenu => MenuController.maybeOf(context) != null; + bool get isClosingOrClosed => switch (_animationController.status) { + AnimationStatus.dismissed || AnimationStatus.reverse => true, + AnimationStatus.forward || AnimationStatus.completed => false, + }; + bool get isClosing => switch (_animationController.status) { + AnimationStatus.reverse => true, + AnimationStatus.dismissed || AnimationStatus.forward || AnimationStatus.completed => false, + }; + + @override + void initState() { + super.initState(); + _resolveAnimationController(); + _resolveMenuItems(); + _animationController.addStatusListener(_handleAnimationStatusChanged); + + if (widget.controller == null) { + _internalMenuController = MenuController(); + } + } + + @override + void didUpdateWidget(MenuAnchor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (widget.controller == null) { + _internalMenuController = MenuController(); + } else { + _internalMenuController = null; + } + } + + if (oldWidget.animated != widget.animated || widget.menuChildren != oldWidget.menuChildren) { + _resolveMenuItems(); + } + + if (oldWidget.animated != widget.animated) { + _resolveAnimationController(); + } + } + + @override + void dispose() { + assert(_debugMenuInfo('Disposing of $this')); + _menuChildren.clear(); + for (final CurvedAnimation animation in _cachedAnimations) { + animation.dispose(); + } + _internalMenuController = null; + _menuScopeNode.dispose(); + heightAnimation.dispose(); + opacityAnimation.dispose(); + _animationController.stop(); + _animationController.dispose(); + super.dispose(); + } + + void _resolveAnimationController() { + if (widget.animated) { + _animationController.duration = _kMenuOpeningDuration; + _animationController.reverseDuration = _kMenuClosingDuration; + } else { + _animationController.duration = Duration.zero; + _animationController.reverseDuration = Duration.zero; + } + } + + void _resolveMenuItems() { + _menuChildren = <Widget>[]; + for (final CurvedAnimation animation in _cachedAnimations) { + animation.dispose(); + } + _cachedAnimations = <CurvedAnimation>[]; + + final int itemCount = widget.menuChildren.length; + if (itemCount == 0) { + return; + } + + if (!widget.animated) { + _menuChildren.addAll(widget.menuChildren); + return; + } + + const double forwardFinalItemOffset = 1 - _kMenuItemRelativeFadeInDuration; + const double reverseFinalItemOffset = + 1 - _kMenuItemRelativeFadeOutDuration - _kMenuItemRelativeFadeOutDelay; + + double forwardProgress = 0; + double reverseProgress = 0; + double itemFadeInGap = 0; + double itemFadeOutGap = 0; + if (itemCount > 1) { + // Spread every item evenly across the remaining time after accounting for + // the fade in/out durations. + itemFadeInGap = forwardFinalItemOffset / (itemCount - 1); + itemFadeOutGap = reverseFinalItemOffset / (itemCount - 1); + } + + for (final Widget child in widget.menuChildren) { + final forwardCurve = Interval( + forwardProgress, + forwardProgress + _kMenuItemRelativeFadeInDuration, + ); + + final reverseCurve = Interval( + reverseProgress, + reverseProgress + _kMenuItemRelativeFadeOutDuration, + ); + + final animation = CurvedAnimation( + parent: _animationController, + curve: forwardCurve, + reverseCurve: reverseCurve, + ); + + _cachedAnimations.add(animation); + _menuChildren.add( + FadeTransition(opacity: animation, alwaysIncludeSemantics: true, child: child), + ); + forwardProgress += itemFadeInGap; + reverseProgress += itemFadeOutGap; + } + } + + void _handleAnimationStatusChanged(AnimationStatus status) { + setState(() { + // Rebuild to update isClosedOrClosing and notify dependents of AnimationStatus changes. + }); + widget.onAnimationStatusChanged?.call(status); + } + + void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) { + // If this menu's parent is closing, submenus should not open. This prevents + // a submenu calling MenuController.open() after a parent menu has started + // closing. + if (_parent?.isClosing ?? false) { + return; + } + + showOverlay(); + + if (_animationController.isForwardOrCompleted) { + return; + } + + _animationController.forward(); + } + + void _handleMenuCloseRequest(VoidCallback hideOverlay) { + if (!_animationController.isForwardOrCompleted) { + return; + } + + _animationController.reverse().whenComplete(hideOverlay); + } + + @override + Widget build(BuildContext context) { + final Widget child = _MenuAnchorScope( + state: this, + animationStatus: _animationController.status, + child: RawMenuAnchor( + onOpenRequested: _handleMenuOpenRequest, + onCloseRequested: _handleMenuCloseRequest, + useRootOverlay: widget.useRootOverlay, + onOpen: widget.onOpen, + onClose: widget.onClose, + consumeOutsideTaps: widget.consumeOutsideTap, + controller: _menuController, + childFocusNode: widget.childFocusNode, + overlayBuilder: _buildOverlay, + builder: widget.builder, + child: widget.child, + ), + ); + + if (widget.layerLink == null) { + return child; + } + + return CompositedTransformTarget(link: widget.layerLink!, child: child); + } + + Widget _buildOverlay(BuildContext context, RawMenuOverlayInfo position) { + // ExcludeSemantics, ExcludeFocus, and IgnorePointer are used to effectively + // disable all interactions with the menu while it is closing. + // + // An animated menu should behave the same as a menu without animations. + // Focus should be able to move to a menu as soon as it starts to open, and + // a menu should not be interactive as soon as it starts to close. + return ExcludeSemantics( + excluding: isClosingOrClosed, + child: IgnorePointer( + ignoring: isClosingOrClosed, + child: ExcludeFocus( + excluding: isClosingOrClosed, + child: _Submenu( + fadeAnimation: opacityAnimation, + heightAnimation: heightAnimation, + layerLink: widget.layerLink, + consumeOutsideTaps: widget.consumeOutsideTap, + menuScopeNode: _menuScopeNode, + menuStyle: widget.style, + clipBehavior: widget.clipBehavior, + menuChildren: _menuChildren, + crossAxisUnconstrained: widget.crossAxisUnconstrained, + menuPosition: position, + anchor: this, + alignmentOffset: widget.alignmentOffset ?? Offset.zero, + reservedPadding: widget.reservedPadding ?? const EdgeInsets.all(_kMenuViewPadding), + ), + ), + ), + ); + } + + _MenuAnchorState get _root { + var anchor = this; + while (anchor._parent != null) { + anchor = anchor._parent!; + } + return anchor; + } + + void _focusButton() { + if (widget.childFocusNode == null) { + return; + } + assert(_debugMenuInfo('Requesting focus for ${widget.childFocusNode}')); + widget.childFocusNode!.requestFocus(); + } + + void _focusFirstMenuItem() { + if (_menuScopeNode.context?.mounted != true) { + return; + } + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); + final FocusNode? firstFocus = policy.findFirstFocus(_menuScopeNode, ignoreCurrentFocus: true); + if (firstFocus != null) { + firstFocus.requestFocus(); + } + } + + void _focusLastMenuItem() { + if (_menuScopeNode.context?.mounted != true) { + return; + } + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); + final FocusNode lastFocus = policy.findLastFocus(_menuScopeNode, ignoreCurrentFocus: true); + lastFocus.requestFocus(); + } + + static _MenuAnchorState? _maybeOf(BuildContext context) { + return context.getInheritedWidgetOfExactType<_MenuAnchorScope>()?.state; + } + + static AnimationStatus? _maybeAnimationStatusOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_MenuAnchorScope>()?.animationStatus; + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.debug}) { + return describeIdentity(this); + } +} + +/// A menu bar that manages cascading child menus. +/// +/// This is a Material Design menu bar that typically resides above the main +/// body of an application (but can go anywhere) that defines a menu system for +/// invoking callbacks in response to user selection of a menu item. +/// +/// The menus can be opened with a click or tap. Once a menu is opened, it can +/// be navigated by using the arrow and tab keys or via mouse hover. Selecting a +/// menu item can be done by pressing enter, or by clicking or tapping on the +/// menu item. Clicking or tapping on any part of the user interface that isn't +/// part of the menu system controlled by the same controller will cause all of +/// the menus controlled by that controller to close, as will pressing the +/// escape key. +/// +/// When a menu item with a submenu is clicked on, it toggles the visibility of +/// the submenu. When the menu item is hovered over, the submenu will open, and +/// hovering over other items will close the previous menu and open the newly +/// hovered one. When those open/close transitions occur, +/// [SubmenuButton.onOpen], and [SubmenuButton.onClose] are called on the +/// corresponding [SubmenuButton] child of the menu bar. +/// +/// {@template flutter.material.MenuBar.shortcuts_note} +/// Menus using [MenuItemButton] can have a [SingleActivator] or +/// [CharacterActivator] assigned to them as their [MenuItemButton.shortcut], +/// which will display an appropriate shortcut hint. Even though the shortcut +/// labels are displayed in the menu, shortcuts are not automatically handled. +/// They must be available in whatever context they are appropriate, and handled +/// via another mechanism. +/// +/// If shortcuts should be generally enabled, but are not easily defined in a +/// context surrounding the menu bar, consider registering them with a +/// [ShortcutRegistry] (one is already included in the [WidgetsApp], and thus +/// also [MaterialApp] and [CupertinoApp]), as shown in the example below. To be +/// sure that selecting a menu item and triggering the shortcut do the same +/// thing, it is recommended that they call the same callback. +/// +/// {@tool dartpad} This example shows a [MenuBar] that contains a single top +/// level menu, containing three items: "About", a checkbox menu item for +/// showing a message, and "Quit". The items are identified with an enum value, +/// and the shortcuts are registered globally with the [ShortcutRegistry]. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_bar.0.dart ** +/// {@end-tool} +/// {@endtemplate} +/// +/// {@macro flutter.material.MenuAcceleratorLabel.accelerator_sample} +/// +/// See also: +/// +/// * [MenuAnchor], a widget that creates a region with a submenu and shows it +/// when requested. +/// * [SubmenuButton], a menu item which manages a submenu. +/// * [MenuItemButton], a leaf menu item which displays the label, an optional +/// shortcut label, and optional leading and trailing icons. +/// * [PlatformMenuBar], which creates a menu bar that is rendered by the host +/// platform instead of by Flutter (on macOS, for example). +/// * [ShortcutRegistry], a registry of shortcuts that apply for the entire +/// application. +/// * [VoidCallbackIntent], to define intents that will call a [VoidCallback] and +/// work with the [Actions] and [Shortcuts] system. +/// * [CallbackShortcuts], to define shortcuts that call a callback without +/// involving [Actions]. +class MenuBar extends StatelessWidget { + /// Creates a const [MenuBar]. + /// + /// The [children] argument is required. + const MenuBar({ + super.key, + this.style, + this.clipBehavior = Clip.none, + this.controller, + required this.children, + }); + + /// The [MenuStyle] that defines the visual attributes of the menu bar. + /// + /// Colors and sizing of the menus is controllable via the [MenuStyle]. + /// + /// Defaults to the ambient [MenuThemeData.style]. + final MenuStyle? style; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// The [MenuController] to use for this menu bar. + final MenuController? controller; + + /// The list of menu items that are the top level children of the [MenuBar]. + /// + /// A Widget in Flutter is immutable, so directly modifying the [children] + /// with [List] APIs such as `someMenuBarWidget.menus.add(...)` will result in + /// incorrect behaviors. Whenever the menus list is modified, a new list + /// object must be provided. + /// + /// {@macro flutter.material.MenuBar.shortcuts_note} + final List<Widget> children; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasOverlay(context)); + return _MenuBarAnchor( + controller: controller, + clipBehavior: clipBehavior, + style: style, + menuChildren: children, + ); + } + + @override + List<DiagnosticsNode> debugDescribeChildren() { + return <DiagnosticsNode>[ + ...children.map<DiagnosticsNode>((Widget item) => item.toDiagnosticsNode()), + ]; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<MenuStyle?>('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: null)); + } +} + +/// A button for use in a [MenuBar], in a menu created with [MenuAnchor], or on +/// its own, that can be activated by click or keyboard navigation. +/// +/// This widget represents a leaf entry in a menu hierarchy that is typically +/// part of a [MenuBar], but may be used independently, or as part of a menu +/// created with a [MenuAnchor]. +/// +/// {@macro flutter.material.MenuBar.shortcuts_note} +/// +/// See also: +/// +/// * [MenuBar], a class that creates a top level menu bar in a Material Design +/// style. +/// * [MenuAnchor], a widget that creates a region with a submenu and shows it +/// when requested. +/// * [SubmenuButton], a menu item similar to this one which manages a submenu. +/// * [PlatformMenuBar], which creates a menu bar that is rendered by the host +/// platform instead of by Flutter (on macOS, for example). +/// * [ShortcutRegistry], a registry of shortcuts that apply for the entire +/// application. +/// * [VoidCallbackIntent], to define intents that will call a [VoidCallback] and +/// work with the [Actions] and [Shortcuts] system. +/// * [CallbackShortcuts] to define shortcuts that call a callback without +/// involving [Actions]. +class MenuItemButton extends StatefulWidget { + /// Creates a const [MenuItemButton]. + /// + /// The [child] attribute is required. + const MenuItemButton({ + super.key, + this.onPressed, + this.onHover, + this.requestFocusOnHover = true, + this.onFocusChange, + this.focusNode, + this.autofocus = false, + this.shortcut, + this.semanticsLabel, + this.style, + this.statesController, + this.clipBehavior = Clip.none, + this.leadingIcon, + this.trailingIcon, + this.closeOnActivate = true, + this.overflowAxis = Axis.horizontal, + this.child, + }); + + /// Called when the button is tapped or otherwise activated. + /// + /// If this callback is null, then the button will be disabled. + /// + /// See also: + /// + /// * [enabled], which is true if the button is enabled. + final VoidCallback? onPressed; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered button + /// area and false if a pointer has exited. + final ValueChanged<bool>? onHover; + + /// Determine if hovering can request focus. + /// + /// Defaults to true. + final bool requestFocusOnHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The optional shortcut that selects this [MenuItemButton]. + /// + /// {@macro flutter.material.MenuBar.shortcuts_note} + final MenuSerializableShortcut? shortcut; + + /// An optional Semantics label, applied to the entire [MenuItemButton]. + /// + /// A screen reader will default to reading the derived text on the + /// [MenuItemButton] itself, which is not guaranteed to be readable. + /// (For some shortcuts, such as comma, semicolon, and other + /// punctuation, screen readers read silence). + /// + /// Setting this label overwrites the semantics properties of the entire + /// Widget, including its children. Consider wrapping this widget in + /// [Semantics] if you want to customize other properties besides just + /// the label. + /// + /// Null by default. + final String? semanticsLabel; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding properties in + /// [themeStyleOf] and [defaultStyleOf]. [WidgetStateProperty]s that resolve + /// to non-null values will similarly override the corresponding + /// [WidgetStateProperty]s in [themeStyleOf] and [defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// An optional icon to display before the [child] label. + final Widget? leadingIcon; + + /// An optional icon to display after the [child] label. + final Widget? trailingIcon; + + /// {@template flutter.material.menu_anchor.closeOnActivate} + /// Determines if the menu will be closed when a [MenuItemButton] + /// is pressed. + /// + /// Defaults to true. + /// {@endtemplate} + final bool closeOnActivate; + + /// The direction in which the menu item expands. + /// + /// If the menu item button is a descendent of [MenuAnchor] or [MenuBar], then + /// this property is ignored. + /// + /// If [overflowAxis] is [Axis.vertical], the menu will be expanded vertically. + /// If [overflowAxis] is [Axis.horizontal], then the menu will be + /// expanded horizontally. + /// + /// Defaults to [Axis.horizontal]. + final Axis overflowAxis; + + /// The widget displayed in the center of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// To enable a button, set its [onPressed] property to a non-null value. + bool get enabled => onPressed != null; + + @override + State<MenuItemButton> createState() => _MenuItemButtonState(); + + /// Defines the button's default appearance. + /// + /// {@macro flutter.material.text_button.default_style_of} + /// + /// {@macro flutter.material.text_button.material3_defaults} + ButtonStyle defaultStyleOf(BuildContext context) { + return _MenuButtonDefaultsM3(context); + } + + /// Returns the [MenuButtonThemeData.style] of the closest + /// [MenuButtonTheme] ancestor. + ButtonStyle? themeStyleOf(BuildContext context) { + return MenuButtonTheme.of(context).style; + } + + /// A static convenience method that constructs a [MenuItemButton]'s + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] color is used to create a [WidgetStateProperty] + /// [ButtonStyle.foregroundColor] value. Specify a value for [foregroundColor] + /// to specify the color of the button's icons. Use [backgroundColor] for the + /// button's background fill color. Use [disabledForegroundColor] and + /// [disabledBackgroundColor] to specify the button's disabled icon and fill + /// color. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// All of the other parameters are either used directly or used to create a + /// [WidgetStateProperty] 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 foreground color for a + /// [MenuItemButton], as well as its overlay color, with all of the standard + /// opacity adjustments for the pressed, focused, and hovered states, one + /// could write: + /// + /// ```dart + /// MenuItemButton( + /// leadingIcon: const Icon(Icons.pets), + /// style: MenuItemButton.styleFrom(foregroundColor: Colors.green), + /// onPressed: () { + /// // ... + /// }, + /// child: const Text('Button Label'), + /// ), + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + Color? disabledIconColor, + TextStyle? textStyle, + Color? overlayColor, + double? elevation, + EdgeInsetsGeometry? padding, + Size? minimumSize, + Size? fixedSize, + Size? maximumSize, + MouseCursor? enabledMouseCursor, + MouseCursor? disabledMouseCursor, + BorderSide? side, + OutlinedBorder? shape, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + }) { + return TextButton.styleFrom( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + disabledForegroundColor: disabledForegroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + textStyle: textStyle, + overlayColor: overlayColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + fixedSize: fixedSize, + maximumSize: maximumSize, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + side: side, + shape: shape, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('enabled', value: onPressed != null, ifFalse: 'DISABLED')); + properties.add(DiagnosticsProperty<ButtonStyle?>('style', style, defaultValue: null)); + properties.add( + DiagnosticsProperty<MenuSerializableShortcut?>('shortcut', shortcut, defaultValue: null), + ); + properties.add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode, defaultValue: null)); + properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.none)); + properties.add( + DiagnosticsProperty<MaterialStatesController?>( + 'statesController', + statesController, + defaultValue: null, + ), + ); + } +} + +class _MenuItemButtonState extends State<MenuItemButton> { + // If a focus node isn't given to the widget, then we have to manage our own. + FocusNode? _internalFocusNode; + FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode!; + _MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context); + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _createInternalFocusNodeIfNeeded(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _internalFocusNode?.dispose(); + _internalFocusNode = null; + super.dispose(); + } + + @override + void didUpdateWidget(MenuItemButton oldWidget) { + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _internalFocusNode)?.removeListener(_handleFocusChange); + if (widget.focusNode != null) { + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } + _createInternalFocusNodeIfNeeded(); + _focusNode.addListener(_handleFocusChange); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + // Since we don't want to use the theme style or default style from the + // TextButton, we merge the styles, merging them in the right order when + // each type of style exists. Each "*StyleOf" function is only called once. + ButtonStyle mergedStyle = + widget.themeStyleOf(context)?.merge(widget.defaultStyleOf(context)) ?? + widget.defaultStyleOf(context); + if (widget.style != null) { + mergedStyle = widget.style!.merge(mergedStyle); + } + + Widget child = TextButton( + onPressed: widget.enabled ? _handleSelect : null, + onFocusChange: widget.enabled ? widget.onFocusChange : null, + focusNode: _focusNode, + style: mergedStyle, + autofocus: widget.enabled && widget.autofocus, + statesController: widget.statesController, + clipBehavior: widget.clipBehavior, + isSemanticButton: kIsWeb ? true : null, + child: _MenuItemLabel( + leadingIcon: widget.leadingIcon, + shortcut: widget.shortcut, + semanticsLabel: widget.semanticsLabel, + trailingIcon: widget.trailingIcon, + hasSubmenu: false, + overflowAxis: _anchor?._orientation ?? widget.overflowAxis, + child: widget.child, + ), + ); + + if (_platformSupportsAccelerators && widget.enabled) { + child = MenuAcceleratorCallbackBinding(onInvoke: _handleSelect, child: child); + } + + if (widget.onHover != null || widget.requestFocusOnHover) { + child = MouseRegion(onHover: _handlePointerHover, onExit: _handlePointerExit, child: child); + } + + return MergeSemantics(child: child); + } + + void _handleFocusChange() { + if (!_focusNode.hasPrimaryFocus) { + // Close any child menus of this button's menu. + MenuController.maybeOf(context)?.closeChildren(); + } + } + + void _handlePointerExit(PointerExitEvent event) { + if (_isHovered) { + widget.onHover?.call(false); + _isHovered = false; + } + } + + // TextButton.onHover and MouseRegion.onHover can't be used without triggering + // focus on scroll. + void _handlePointerHover(PointerHoverEvent event) { + if (!_isHovered) { + _isHovered = true; + widget.onHover?.call(true); + if (widget.requestFocusOnHover) { + assert(_debugMenuInfo('Requesting focus for $_focusNode from hover')); + _focusNode.requestFocus(); + + // Without invalidating the focus policy, switching to directional focus + // may not originate at this node. + FocusTraversalGroup.of(context).invalidateScopeData(FocusScope.of(context)); + } + } + } + + void _handleSelect() { + assert(_debugMenuInfo('Selected ${widget.child} menu')); + if (widget.closeOnActivate) { + _anchor?._root._menuController.close(); + } + // Delay the call to onPressed until post-frame so that the focus is + // restored to what it was before the menu was opened before the action is + // executed. + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + FocusManager.instance.applyFocusChangesIfNeeded(); + widget.onPressed?.call(); + }, debugLabel: 'MenuAnchor.onPressed'); + } + + void _createInternalFocusNodeIfNeeded() { + if (widget.focusNode == null) { + _internalFocusNode = FocusNode(); + assert(() { + _internalFocusNode?.debugLabel = '$MenuItemButton(${widget.child})'; + return true; + }()); + } + } +} + +/// A menu item that combines a [Checkbox] widget with a [MenuItemButton]. +/// +/// To style the checkbox separately from the button, add a [CheckboxTheme] +/// ancestor. +/// +/// {@tool dartpad} +/// This example shows a menu with a checkbox that shows a message in the body +/// of the app if checked. +/// +/// ** See code in examples/api/lib/material/menu_anchor/checkbox_menu_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// - [MenuBar], a widget that creates a menu bar of cascading menu items. +/// - [MenuAnchor], a widget that defines a region which can host a cascading +/// menu. +class CheckboxMenuButton extends StatelessWidget { + /// Creates a const [CheckboxMenuButton]. + /// + /// The [child], [value], and [onChanged] attributes are required. + const CheckboxMenuButton({ + super.key, + required this.value, + this.tristate = false, + this.isError = false, + required this.onChanged, + this.onHover, + this.onFocusChange, + this.focusNode, + this.shortcut, + this.style, + this.statesController, + this.clipBehavior = Clip.none, + this.trailingIcon, + this.closeOnActivate = true, + required this.child, + }); + + /// Whether this checkbox is checked. + /// + /// When [tristate] is true, a value of null corresponds to the mixed state. + /// When [tristate] is false, this value must not be null. + final bool? value; + + /// If true, then the checkbox's [value] can be true, false, or null. + /// + /// [CheckboxMenuButton] displays a dash when its value is null. + /// + /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged] + /// callback will be applied to true if the current value is false, to null if + /// value is true, and to false if value is null (i.e. it cycles through false + /// => true => null => false when tapped). + /// + /// If tristate is false (the default), [value] must not be null. + final bool tristate; + + /// True if this checkbox wants to show an error state. + /// + /// The checkbox will have different default container color and check color when + /// this is true. This is only used when [ThemeData.useMaterial3] is set to true. + /// + /// Defaults to false. + final bool isError; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox with the new + /// value. + /// + /// If this callback is null, the menu item will be displayed as disabled + /// and will not respond to input gestures. + /// + /// When the checkbox is tapped, if [tristate] is false (the default) then the + /// [onChanged] callback will be applied to `!value`. If [tristate] is true + /// this callback cycle from false to true to null and then back to false + /// again. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CheckboxMenuButton( + /// value: _throwShotAway, + /// child: const Text('THROW'), + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue!; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged<bool?>? onChanged; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered button + /// area and false if a pointer has exited. + final ValueChanged<bool>? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The optional shortcut that selects this [MenuItemButton]. + /// + /// {@macro flutter.material.MenuBar.shortcuts_note} + final MenuSerializableShortcut? shortcut; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding properties in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// [WidgetStateProperty]s that resolve to non-null values will similarly + /// override the corresponding [WidgetStateProperty]s in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// An optional icon to display after the [child] label. + final Widget? trailingIcon; + + /// {@macro flutter.material.menu_anchor.closeOnActivate} + final bool closeOnActivate; + + /// The widget displayed in the center of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// To enable a button, set its [onChanged] property to a non-null value. + bool get enabled => onChanged != null; + + @override + Widget build(BuildContext context) { + return MenuItemButton( + key: key, + onPressed: onChanged == null + ? null + : () { + switch (value) { + case false: + onChanged!(true); + case true: + onChanged!(tristate ? null : false); + case null: + onChanged!(false); + } + }, + onHover: onHover, + onFocusChange: onFocusChange, + focusNode: focusNode, + style: style, + shortcut: shortcut, + statesController: statesController, + leadingIcon: ExcludeFocus( + child: IgnorePointer( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: Checkbox.width, maxWidth: Checkbox.width), + child: Checkbox( + tristate: tristate, + value: value, + onChanged: onChanged, + isError: isError, + ), + ), + ), + ), + clipBehavior: clipBehavior, + trailingIcon: trailingIcon, + closeOnActivate: closeOnActivate, + child: child, + ); + } +} + +/// A menu item that combines a [Radio] widget with a [MenuItemButton]. +/// +/// To style the radio button separately from the overall button, add a +/// [RadioTheme] ancestor. +/// +/// {@tool dartpad} +/// This example shows a menu with three radio buttons with shortcuts that +/// changes the background color of the body when the buttons are selected. +/// +/// ** See code in examples/api/lib/material/menu_anchor/radio_menu_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// - [MenuBar], a widget that creates a menu bar of cascading menu items. +/// - [MenuAnchor], a widget that defines a region which can host a cascading +/// menu. +class RadioMenuButton<T> extends StatelessWidget { + /// Creates a const [RadioMenuButton]. + /// + /// The [child] attribute is required. + const RadioMenuButton({ + super.key, + required this.value, + required this.groupValue, + required this.onChanged, + this.toggleable = false, + this.onHover, + this.onFocusChange, + this.focusNode, + this.shortcut, + this.style, + this.statesController, + this.clipBehavior = Clip.none, + this.trailingIcon, + this.closeOnActivate = true, + required this.child, + }); + + /// The value represented by this radio button. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + final T value; + + /// The currently selected value for a group of radio buttons. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + final T? groupValue; + + /// Set to true if this radio button is allowed to be returned to an + /// indeterminate state by selecting it again when selected. + /// + /// To indicate returning to an indeterminate state, [onChanged] will be + /// called with null. + /// + /// If true, [onChanged] is called with [value] when selected while + /// [groupValue] != [value], and with null when selected again while + /// [groupValue] == [value]. + /// + /// If false, [onChanged] will be called with [value] when it is selected + /// while [groupValue] != [value], and only by selecting another radio button + /// in the group (i.e. changing the value of [groupValue]) can this radio + /// button be unselected. + /// + /// The default is false. + final bool toggleable; + + /// Called when the user selects this radio button. + /// + /// The radio button passes [value] as a parameter to this callback. The radio + /// button does not actually change state until the parent widget rebuilds the + /// radio button with the new [groupValue]. + /// + /// If null, the radio button will be displayed as disabled. + /// + /// The provided callback will not be invoked if this radio button is already + /// selected. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// RadioMenuButton<SingingCharacter>( + /// value: SingingCharacter.lafayette, + /// groupValue: _character, + /// onChanged: (SingingCharacter? newValue) { + /// setState(() { + /// _character = newValue; + /// }); + /// }, + /// child: const Text('Lafayette'), + /// ) + /// ``` + final ValueChanged<T?>? onChanged; + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered button + /// area and false if a pointer has exited. + final ValueChanged<bool>? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The optional shortcut that selects this [MenuItemButton]. + /// + /// {@macro flutter.material.MenuBar.shortcuts_note} + final MenuSerializableShortcut? shortcut; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding properties in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// [WidgetStateProperty]s that resolve to non-null values will similarly + /// override the corresponding [WidgetStateProperty]s in + /// [MenuItemButton.themeStyleOf] and [MenuItemButton.defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// An optional icon to display after the [child] label. + final Widget? trailingIcon; + + /// {@macro flutter.material.menu_anchor.closeOnActivate} + final bool closeOnActivate; + + /// The widget displayed in the center of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Whether the button is enabled or disabled. + /// + /// To enable a button, set its [onChanged] property to a non-null value. + bool get enabled => onChanged != null; + + @override + Widget build(BuildContext context) { + return MenuItemButton( + key: key, + onPressed: onChanged == null + ? null + : () { + if (toggleable && groupValue == value) { + return onChanged!(null); + } + onChanged!(value); + }, + onHover: onHover, + onFocusChange: onFocusChange, + focusNode: focusNode, + style: style, + shortcut: shortcut, + statesController: statesController, + leadingIcon: ExcludeFocus( + child: IgnorePointer( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: Checkbox.width, maxWidth: Checkbox.width), + child: Radio<T>( + value: value, + groupValue: groupValue, + onChanged: onChanged, + toggleable: toggleable, + ), + ), + ), + ), + clipBehavior: clipBehavior, + trailingIcon: trailingIcon, + closeOnActivate: closeOnActivate, + child: child, + ); + } +} + +/// A menu button that displays a cascading menu. +/// +/// It can be used as part of a [MenuBar], or as a standalone widget. +/// +/// This widget represents a menu item that has a submenu. Like the leaf +/// [MenuItemButton], it shows a label with an optional leading or trailing +/// icon, but additionally shows an arrow icon showing that it has a submenu. +/// +/// By default the submenu will appear to the side of the controlling button. +/// The alignment and offset of the submenu can be controlled by setting +/// [MenuStyle.alignment] on the [style] and the [alignmentOffset] argument, +/// respectively. +/// +/// When activated (by being clicked, through keyboard navigation, or via +/// hovering with a mouse), it will open a submenu containing the +/// [menuChildren]. +/// +/// If [menuChildren] is empty, then this menu item will appear disabled. +/// +/// See also: +/// +/// * [MenuItemButton], a widget that represents a leaf menu item that does not +/// host a submenu. +/// * [MenuBar], a widget that renders menu items in a row in a Material Design +/// style. +/// * [MenuAnchor], a widget that creates a region with a submenu and shows it +/// when requested. +/// * [PlatformMenuBar], a widget that renders similar menu bar items from a +/// [PlatformMenuItem] using platform-native APIs instead of Flutter. +class SubmenuButton extends StatefulWidget { + /// Creates a const [SubmenuButton]. + /// + /// The [child] and [menuChildren] attributes are required. + const SubmenuButton({ + super.key, + this.onHover, + this.onFocusChange, + this.onOpen, + this.onClose, + this.controller, + this.style, + this.menuStyle, + this.alignmentOffset, + this.clipBehavior = Clip.hardEdge, + this.focusNode, + this.statesController, + this.leadingIcon, + this.trailingIcon, + this.submenuIcon, + this.useRootOverlay = false, + this.hoverOpenDelay = Duration.zero, + this.animated = false, + this.onAnimationStatusChanged, + required this.menuChildren, + required this.child, + }); + + /// Called when a pointer enters or exits the button response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the button and false if a pointer has exited. + final ValueChanged<bool>? onHover; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's [focusNode] gains focus, and false if it + /// loses focus. + final ValueChanged<bool>? onFocusChange; + + /// A callback that is invoked when the menu is opened. + final VoidCallback? onOpen; + + /// A callback that is invoked when the menu is closed. + final VoidCallback? onClose; + + /// An optional [MenuController] for this submenu. + final MenuController? controller; + + /// Customizes this button's appearance. + /// + /// Non-null properties of this style override the corresponding properties in + /// [themeStyleOf] and [defaultStyleOf]. [WidgetStateProperty]s that resolve + /// to non-null values will similarly override the corresponding + /// [WidgetStateProperty]s in [themeStyleOf] and [defaultStyleOf]. + /// + /// Null by default. + final ButtonStyle? style; + + /// The [MenuStyle] of the menu specified by [menuChildren]. + /// + /// Defaults to the value of [MenuThemeData.style] of the ambient [MenuTheme]. + final MenuStyle? menuStyle; + + /// The offset of the menu relative to the alignment origin determined by + /// [MenuStyle.alignment] on the [style] attribute. + /// + /// Use this for fine adjustments of the menu placement. + /// + /// Defaults to an offset that takes into account the padding of the menu so + /// that the top starting corner of the first menu item is aligned with the + /// top of the [MenuAnchor] region. + final Offset? alignmentOffset; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.material.inkwell.statesController} + final MaterialStatesController? statesController; + + /// An optional icon to display before the [child]. + final Widget? leadingIcon; + + /// If provided, the widget replaces the default [SubmenuButton] arrow icon. + /// + /// Resolves in the following states: + /// * [WidgetState.disabled]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// + /// If this is null, then the value of [MenuThemeData.submenuIcon] is used. + /// If that is also null, then defaults to a right arrow icon with the size + /// of 24 pixels. + final WidgetStateProperty<Widget?>? submenuIcon; + + /// An optional icon to display after the [child]. + final Widget? trailingIcon; + + /// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay} + /// + /// Defaults to false. + final bool useRootOverlay; + + /// An optional callback that is invoked when the [AnimationStatus] of the + /// menu changes during open and close animations. + /// + /// If [animated] is false, this callback will only be invoked with + /// [AnimationStatus.completed] when the menu is opened, and + /// [AnimationStatus.dismissed] when the menu is closed. + /// + /// Because the [MenuController.isOpen] property is true while the menu is + /// opening, opened, and closing, it cannot be used to determine whether the + /// menu is in the process of closing or opening. As such, this callback + /// provides a way to determine when the menu is opening or closing. + /// + /// Defaults to null. + final ValueChanged<AnimationStatus>? onAnimationStatusChanged; + + /// The list of widgets that appear in the menu when it is opened. + /// + /// These can be any widget, but are typically either [MenuItemButton] or + /// [SubmenuButton] widgets. + /// + /// If [menuChildren] is empty, then the button for this menu item will be + /// disabled. + final List<Widget> menuChildren; + + /// The duration to wait before opening the menu when the button is hovered. + /// + /// Because [MenuBar] children can only be opened by hover if a sibling + /// [SubmenuButton] is already open, providing a [hoverOpenDelay] to direct + /// children of a [MenuBar] will throw an assertion error. This is to avoid + /// the case where the [hoverOpenDelay] for a [SubmenuButton] is longer than + /// the duration of a sibling's closing animation, which leads to that menu + /// never opening. + /// + /// Defaults to [Duration.zero]. + final Duration hoverOpenDelay; + + /// Whether the menu should open and close with an animation. + /// + /// Defaults to false. + final bool animated; + + /// The widget displayed in the middle portion of this button. + /// + /// Typically this is the button's label, using a [Text] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + @override + State<SubmenuButton> createState() => _SubmenuButtonState(); + + /// Defines the button's default appearance. + /// + /// {@macro flutter.material.text_button.default_style_of} + /// + /// {@macro flutter.material.text_button.material3_defaults} + ButtonStyle defaultStyleOf(BuildContext context) { + return _MenuButtonDefaultsM3(context); + } + + /// Returns the [MenuButtonThemeData.style] of the closest [MenuButtonTheme] + /// ancestor. + ButtonStyle? themeStyleOf(BuildContext context) { + return MenuButtonTheme.of(context).style; + } + + /// A static convenience method that constructs a [SubmenuButton]'s + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] color is used to create a [WidgetStateProperty] + /// [ButtonStyle.foregroundColor] value. Specify a value for [foregroundColor] + /// to specify the color of the button's icons. Use [backgroundColor] for the + /// button's background fill color. Use [disabledForegroundColor] and + /// [disabledBackgroundColor] to specify the button's disabled icon and fill + /// color. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// All of the other parameters are either used directly or used to create a + /// [WidgetStateProperty] 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 foreground color for a + /// [SubmenuButton], as well as its overlay color, with all of the standard + /// opacity adjustments for the pressed, focused, and hovered states, one + /// could write: + /// + /// ```dart + /// SubmenuButton( + /// leadingIcon: const Icon(Icons.pets), + /// style: SubmenuButton.styleFrom(foregroundColor: Colors.green), + /// menuChildren: const <Widget>[ /* ... */ ], + /// child: const Text('Button Label'), + /// ), + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + Color? disabledIconColor, + TextStyle? textStyle, + Color? overlayColor, + double? elevation, + EdgeInsetsGeometry? padding, + Size? minimumSize, + Size? fixedSize, + Size? maximumSize, + MouseCursor? enabledMouseCursor, + MouseCursor? disabledMouseCursor, + BorderSide? side, + OutlinedBorder? shape, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + }) { + return TextButton.styleFrom( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + disabledForegroundColor: disabledForegroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + iconColor: iconColor, + disabledIconColor: disabledIconColor, + iconSize: iconSize, + textStyle: textStyle, + overlayColor: overlayColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + fixedSize: fixedSize, + maximumSize: maximumSize, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + side: side, + shape: shape, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + ); + } + + @override + List<DiagnosticsNode> debugDescribeChildren() { + return <DiagnosticsNode>[ + ...menuChildren.map<DiagnosticsNode>((Widget child) { + return child.toDiagnosticsNode(); + }), + ]; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<FocusNode?>('focusNode', focusNode)); + properties.add(DiagnosticsProperty<MenuStyle>('menuStyle', menuStyle, defaultValue: null)); + properties.add(DiagnosticsProperty<Offset>('alignmentOffset', alignmentOffset)); + properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior)); + } +} + +class _SubmenuButtonState extends State<SubmenuButton> { + late final Map<Type, Action<Intent>> actions = <Type, Action<Intent>>{ + DirectionalFocusIntent: _SubmenuDirectionalFocusAction(submenu: this), + }; + bool _waitingToFocusMenu = false; + bool _isOpenOnFocusEnabled = true; + MenuController? _internalMenuController; + MenuController get _menuController => widget.controller ?? _internalMenuController!; + _MenuAnchorState? get _parent => _MenuAnchorState._maybeOf(context); + _MenuAnchorState? get _anchorState => _anchorKey.currentState; + FocusNode? _internalFocusNode; + final GlobalKey<_MenuAnchorState> _anchorKey = GlobalKey<_MenuAnchorState>(); + FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!; + bool get _enabled => widget.menuChildren.isNotEmpty; + bool _isHovered = false; + AnimationStatus _animationStatus = AnimationStatus.dismissed; + Timer? _hoverOpenTimer; + + @override + void initState() { + super.initState(); + if (widget.focusNode == null) { + _internalFocusNode = FocusNode(); + assert(() { + _internalFocusNode?.debugLabel = '$SubmenuButton(${widget.child})'; + return true; + }()); + } + if (widget.controller == null) { + _internalMenuController = MenuController(); + } + _buttonFocusNode.addListener(_handleFocusChange); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(_debugValidateHoverOpenDelay()); + } + + @override + void dispose() { + _clearHoverOpenTimer(); + _buttonFocusNode.removeListener(_handleFocusChange); + _internalFocusNode?.dispose(); + _internalFocusNode = null; + super.dispose(); + } + + @override + void didUpdateWidget(SubmenuButton oldWidget) { + super.didUpdateWidget(oldWidget); + assert(_debugValidateHoverOpenDelay()); + if (widget.focusNode != oldWidget.focusNode) { + if (oldWidget.focusNode == null) { + _internalFocusNode?.removeListener(_handleFocusChange); + _internalFocusNode?.dispose(); + _internalFocusNode = null; + } else { + oldWidget.focusNode!.removeListener(_handleFocusChange); + } + if (widget.focusNode == null) { + _internalFocusNode ??= FocusNode(); + assert(() { + _internalFocusNode?.debugLabel = '$SubmenuButton(${widget.child})'; + return true; + }()); + } + _buttonFocusNode.addListener(_handleFocusChange); + } + if (widget.controller != oldWidget.controller) { + _internalMenuController = (oldWidget.controller == null) ? null : MenuController(); + } + } + + @override + Widget build(BuildContext context) { + Offset menuPaddingOffset = widget.alignmentOffset ?? Offset.zero; + final EdgeInsets menuPadding = _computeMenuPadding(context); + final Axis orientation = _parent?._orientation ?? Axis.vertical; + + // Move the submenu over by the size of the menu padding, so that + // the first menu item aligns with the submenu button that opens it. + menuPaddingOffset += switch ((orientation, Directionality.of(context))) { + (Axis.horizontal, TextDirection.rtl) => Offset(menuPadding.right, 0), + (Axis.horizontal, TextDirection.ltr) => Offset(-menuPadding.left, 0), + (Axis.vertical, TextDirection.rtl) => Offset(0, -menuPadding.top), + (Axis.vertical, TextDirection.ltr) => Offset(0, -menuPadding.top), + }; + final states = <WidgetState>{ + if (!_enabled) WidgetState.disabled, + if (_isHovered) WidgetState.hovered, + if (_buttonFocusNode.hasFocus) WidgetState.focused, + }; + final Widget submenuIcon = + widget.submenuIcon?.resolve(states) ?? + MenuTheme.of(context).submenuIcon?.resolve(states) ?? + const Icon( + Icons.arrow_right, // Automatically switches with text direction. + size: _kDefaultSubmenuIconSize, + ); + + return Actions( + actions: actions, + child: MenuAnchor( + key: _anchorKey, + onAnimationStatusChanged: _handleAnimationStatusChanged, + controller: _menuController, + childFocusNode: _buttonFocusNode, + alignmentOffset: menuPaddingOffset, + clipBehavior: widget.clipBehavior, + onClose: _handleClose, + onOpen: _handleOpen, + style: widget.menuStyle, + useRootOverlay: widget.useRootOverlay, + animated: widget.animated, + builder: (BuildContext context, MenuController controller, Widget? child) { + // Since we don't want to use the theme style or default style from the + // TextButton, we merge the styles, merging them in the right order when + // each type of style exists. Each "*StyleOf" function is only called + // once. + ButtonStyle mergedStyle = + widget.themeStyleOf(context)?.merge(widget.defaultStyleOf(context)) ?? + widget.defaultStyleOf(context); + mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle; + + void toggleShowMenu() { + if (!mounted) { + return; + } + if (_animationStatus.isForwardOrCompleted) { + controller.close(); + } else { + controller.open(); + } + } + + void handlePointerExit(PointerExitEvent event) { + if (_isHovered) { + widget.onHover?.call(false); + _isHovered = false; + _clearHoverOpenTimer(); + } + } + + // MouseRegion.onEnter and TextButton.onHover are called + // if a button is hovered after scrolling. This interferes with + // focus traversal and scroll position. MouseRegion.onHover avoids + // this issue. + void handlePointerHover(PointerHoverEvent event) { + if (!_isHovered) { + _isHovered = true; + widget.onHover?.call(true); + final _MenuAnchorState root = _MenuAnchorState._maybeOf(context)!._root; + // Don't open the root menu bar menus on hover unless a sibling menu + // is already open. This means that the user has to first click to + // open a menu on the menu bar before hovering allows them to traverse + // it. + if (_parent?._orientation == Axis.horizontal && !root._menuController.isOpen) { + return; + } + + if (_buttonFocusNode.hasPrimaryFocus) { + _clearHoverOpenTimer(); + _maybeOpenMenuOnHoverOrFocus(); + } else { + _buttonFocusNode.requestFocus(); + } + } + } + + child = MergeSemantics( + child: Semantics( + expanded: _enabled && _animationStatus.isForwardOrCompleted, + child: TextButton( + style: mergedStyle, + focusNode: _buttonFocusNode, + onFocusChange: _enabled ? widget.onFocusChange : null, + onPressed: _enabled ? toggleShowMenu : null, + isSemanticButton: kIsWeb ? true : null, + child: _MenuItemLabel( + leadingIcon: widget.leadingIcon, + trailingIcon: widget.trailingIcon, + hasSubmenu: true, + showDecoration: (_parent?._orientation ?? Axis.horizontal) == Axis.vertical, + submenuIcon: submenuIcon, + child: child, + ), + ), + ), + ); + + if (!_enabled) { + return child; + } + + child = MouseRegion(onHover: handlePointerHover, onExit: handlePointerExit, child: child); + + if (_platformSupportsAccelerators) { + return MenuAcceleratorCallbackBinding( + onInvoke: toggleShowMenu, + hasSubmenu: true, + child: child, + ); + } + + return child; + }, + menuChildren: widget.menuChildren, + child: widget.child, + ), + ); + } + + void _handleAnimationStatusChanged(AnimationStatus status) { + setState(() { + _animationStatus = status; + }); + widget.onAnimationStatusChanged?.call(status); + } + + void _handleClose() { + // After closing the children of this submenu, this submenu button will + // regain focus. Because submenu buttons open on focus, this submenu will + // immediately reopen. To prevent this from happening, we prevent focus on + // SubmenuButtons that do not already have focus using the _openOnFocus + // flag. This flag is reset after one frame. + if (!_buttonFocusNode.hasFocus) { + _isOpenOnFocusEnabled = false; + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + FocusManager.instance.applyFocusChangesIfNeeded(); + _isOpenOnFocusEnabled = true; + }, debugLabel: 'MenuAnchor.preventOpenOnFocus'); + } + widget.onClose?.call(); + } + + void _handleOpen() { + if (!_waitingToFocusMenu) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _buttonFocusNode.requestFocus(); + _waitingToFocusMenu = false; + } + }, debugLabel: 'MenuAnchor.focus'); + _waitingToFocusMenu = true; + } + setState(() { + /* Rebuild with updated controller.isOpen value */ + }); + widget.onOpen?.call(); + } + + EdgeInsets _computeMenuPadding(BuildContext context) { + final WidgetStateProperty<EdgeInsetsGeometry?> insets = + widget.menuStyle?.padding ?? + MenuTheme.of(context).style?.padding ?? + _MenuDefaultsM3(context).padding!; + return insets + .resolve(widget.statesController?.value ?? const <WidgetState>{})! + .resolve(Directionality.of(context)); + } + + void _handleFocusChange() { + _clearHoverOpenTimer(); + if (!_buttonFocusNode.hasPrimaryFocus) { + if (!_anchorState!._menuScopeNode.hasFocus && _animationStatus.isForwardOrCompleted) { + _menuController.close(); + } + return; + } + + _maybeOpenMenuOnHoverOrFocus(); + } + + void _maybeOpenMenuOnHoverOrFocus() { + if (!_isOpenOnFocusEnabled) { + return; + } + + if (_menuController.isOpen) { + if (_animationStatus != AnimationStatus.reverse) { + // If the menu isn't closing, there's no reason to reopen it. + return; + } + + if (_isHovered) { + // If the button is hovered, avoid reopening the menu that the user deliberately + // closed. + return; + } + + if (_parent?._orientation == Axis.horizontal) { + // Top-level (horizontal) buttons in a menubar will try to reopen when + // closed, since focus returns to their button. + return; + } + } + + if (widget.hoverOpenDelay == Duration.zero) { + _menuController.open(); + return; + } + + _hoverOpenTimer = Timer(widget.hoverOpenDelay, () { + // The menu controller could change, so we can't use a tearoff. + _menuController.open(); + }); + } + + void _clearHoverOpenTimer() { + _hoverOpenTimer?.cancel(); + _hoverOpenTimer = null; + } + + bool _debugValidateHoverOpenDelay() { + if (_parent?._orientation == Axis.horizontal && widget.hoverOpenDelay > Duration.zero) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary( + 'A non-zero hoverOpenDelay was used in a top-level SubmenuButton situated in a MenuBar.', + ), + ErrorDescription( + 'MenuBar children can only be opened by hover if a sibling SubmenuButton is already open. When the hoverOpenDelay for a SubmenuButton is longer than the closing animation of a sibling SubmenuButton, that sibling will close before this SubmenuButton begins opening, leading to this SubmenuButton never opening.', + ), + context.describeElement('The affected SubmenuButton is'), + ]); + } + return true; + } +} + +class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { + _SubmenuDirectionalFocusAction({required this.submenu}); + + final _SubmenuButtonState submenu; + _MenuAnchorState? get _parent => submenu._parent; + _MenuAnchorState? get _anchorState => submenu._anchorState; + MenuController get _controller => submenu._menuController; + + /// The orientation of the menu that contains this submenu button. + Axis? get _orientation => _parent?._orientation; + + /// Whether the anchor that intercepted this DirectionalFocusAction is a submenu. + bool get isSubmenu => submenu._buttonFocusNode.hasPrimaryFocus; + FocusNode get _button => submenu._buttonFocusNode; + + @override + void invoke(DirectionalFocusIntent intent) { + assert(_debugMenuInfo('${intent.direction}: Invoking directional focus intent.')); + final TextDirection directionality = Directionality.of(submenu.context); + switch ((_orientation, directionality, intent.direction)) { + case (Axis.horizontal, TextDirection.ltr, TraversalDirection.left): + case (Axis.horizontal, TextDirection.rtl, TraversalDirection.right): + assert(_debugMenuInfo('Moving to previous $MenuBar item')); + // Focus this MenuBar SubmenuButton, then move focus to the previous focusable + // MenuBar item. + _button + ..requestFocus() + ..previousFocus(); + return; + case (Axis.horizontal, TextDirection.ltr, TraversalDirection.right): + case (Axis.horizontal, TextDirection.rtl, TraversalDirection.left): + assert(_debugMenuInfo('Moving to next $MenuBar item')); + // Focus this MenuBar SubmenuButton, then move focus to the next focusable + // MenuBar item. + _button + ..requestFocus() + ..nextFocus(); + return; + case (Axis.horizontal, _, TraversalDirection.down): + if (isSubmenu) { + // If this is a top-level (horizontal) button in a menubar, focus the + // first item in this button's submenu. + _anchorState?._focusFirstMenuItem(); + return; + } + case (Axis.horizontal, _, TraversalDirection.up): + if (isSubmenu) { + // If this is a top-level (horizontal) button in a menubar, focus the + // last item in this button's submenu. This makes navigating into + // upward-oriented submenus more intuitive. + _anchorState?._focusLastMenuItem(); + return; + } + case (Axis.vertical, TextDirection.ltr, TraversalDirection.left): + case (Axis.vertical, TextDirection.rtl, TraversalDirection.right): + if (_parent?._parent?._orientation == Axis.horizontal) { + if (isSubmenu) { + _parent!.widget.childFocusNode + ?..requestFocus() + ..previousFocus(); + } else { + assert(_debugMenuInfo('Exiting submenu')); + // MenuBar SubmenuButton => SubmenuButton => child + // Focus the parent SubmenuButton anchor attached to this child. + _anchorState?._focusButton(); + } + } else { + if (isSubmenu) { + if (_parent?._parent == null) { + // Moving in the closing direction while focused on a + // SubmenuButton within a root MenuAnchor menu should not close + // the menu. + return; + } + _parent?._focusButton(); + _parent?._menuController.close(); + } else { + // If focus is not on a submenu button, closing the anchor this item + // presides in will close the menu and focus the anchor button. + _controller.close(); + } + assert(_debugMenuInfo('Exiting submenu')); + } + return; + case (Axis.vertical, TextDirection.ltr, TraversalDirection.right) when isSubmenu: + case (Axis.vertical, TextDirection.rtl, TraversalDirection.left) when isSubmenu: + assert(_debugMenuInfo('Entering submenu')); + if (_controller.isOpen) { + _anchorState?._focusFirstMenuItem(); + } else { + _controller.open(); + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + if (_controller.isOpen) { + _anchorState?._focusFirstMenuItem(); + } + }); + } + return; + default: + break; + } + + Actions.maybeInvoke(submenu.context, intent); + } +} + +/// A helper class used to generate shortcut labels for a +/// [MenuSerializableShortcut] (a subset of the subclasses of +/// [ShortcutActivator]). +/// +/// This helper class is typically used by the [MenuItemButton] and +/// [SubmenuButton] classes to display a label for their assigned shortcuts. +/// +/// Call [getShortcutLabel] with the [MenuSerializableShortcut] to get a label +/// for it. +/// +/// For instance, calling [getShortcutLabel] with `SingleActivator(trigger: +/// LogicalKeyboardKey.keyA, control: true)` would return "⌃ A" on macOS, "Ctrl +/// A" in an US English locale, and "Strg A" in a German locale. +class _LocalizedShortcutLabeler { + _LocalizedShortcutLabeler._(); + + static _LocalizedShortcutLabeler? _instance; + + static final Map<LogicalKeyboardKey, String> _shortcutGraphicEquivalents = + <LogicalKeyboardKey, String>{ + LogicalKeyboardKey.arrowLeft: '←', + LogicalKeyboardKey.arrowRight: '→', + LogicalKeyboardKey.arrowUp: '↑', + LogicalKeyboardKey.arrowDown: '↓', + LogicalKeyboardKey.enter: '↵', + }; + + static final Set<LogicalKeyboardKey> _modifiers = <LogicalKeyboardKey>{ + LogicalKeyboardKey.alt, + LogicalKeyboardKey.control, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.metaRight, + LogicalKeyboardKey.shiftRight, + }; + + /// Return the instance for this singleton. + static _LocalizedShortcutLabeler get instance { + return _instance ??= _LocalizedShortcutLabeler._(); + } + + // Caches the created shortcut key maps so that creating one of these isn't + // expensive after the first time for each unique localizations object. + final Map<MaterialLocalizations, Map<LogicalKeyboardKey, String>> _cachedShortcutKeys = + <MaterialLocalizations, Map<LogicalKeyboardKey, String>>{}; + + /// Returns the label to be shown to the user in the UI when a + /// [MenuSerializableShortcut] is used as a keyboard shortcut. + /// + /// When [defaultTargetPlatform] is [TargetPlatform.macOS] or + /// [TargetPlatform.iOS], this will return graphical key representations when + /// it can. For instance, the default [LogicalKeyboardKey.shift] will return + /// '⇧', and the arrow keys will return arrows. The key + /// [LogicalKeyboardKey.meta] will show as '⌘', [LogicalKeyboardKey.control] + /// will show as '˄', and [LogicalKeyboardKey.alt] will show as '⌥'. + /// + /// The keys are joined by spaces on macOS and iOS, and by "+" on other + /// platforms. + String getShortcutLabel(MenuSerializableShortcut shortcut, MaterialLocalizations localizations) { + final ShortcutSerialization serialized = shortcut.serializeForMenu(); + final String keySeparator; + if (_usesSymbolicModifiers) { + // Use "⌃ ⇧ A" style on macOS and iOS. + keySeparator = ' '; + } else { + // Use "Ctrl+Shift+A" style. + keySeparator = '+'; + } + if (serialized.trigger != null) { + final LogicalKeyboardKey trigger = serialized.trigger!; + final modifiers = <String>[ + if (_usesSymbolicModifiers) ...<String>[ + // macOS/iOS platform convention uses this ordering, with ⌘ always last. + if (serialized.control!) _getModifierLabel(LogicalKeyboardKey.control, localizations), + if (serialized.alt!) _getModifierLabel(LogicalKeyboardKey.alt, localizations), + if (serialized.shift!) _getModifierLabel(LogicalKeyboardKey.shift, localizations), + if (serialized.meta!) _getModifierLabel(LogicalKeyboardKey.meta, localizations), + ] else ...<String>[ + // This order matches the LogicalKeySet version. + if (serialized.alt!) _getModifierLabel(LogicalKeyboardKey.alt, localizations), + if (serialized.control!) _getModifierLabel(LogicalKeyboardKey.control, localizations), + if (serialized.meta!) _getModifierLabel(LogicalKeyboardKey.meta, localizations), + if (serialized.shift!) _getModifierLabel(LogicalKeyboardKey.shift, localizations), + ], + ]; + String? shortcutTrigger; + final int logicalKeyId = trigger.keyId; + if (_shortcutGraphicEquivalents.containsKey(trigger)) { + shortcutTrigger = _shortcutGraphicEquivalents[trigger]; + } else { + // Otherwise, look it up, and if we don't have a translation for it, + // then fall back to the key label. + shortcutTrigger = _getLocalizedName(trigger, localizations); + if (shortcutTrigger == null && logicalKeyId & LogicalKeyboardKey.planeMask == 0x0) { + // If the trigger is a Unicode-character-producing key, then use the + // character. + shortcutTrigger = String.fromCharCode( + logicalKeyId & LogicalKeyboardKey.valueMask, + ).toUpperCase(); + } + // Fall back to the key label if all else fails. + shortcutTrigger ??= trigger.keyLabel; + } + return <String>[ + ...modifiers, + if (shortcutTrigger != null && shortcutTrigger.isNotEmpty) shortcutTrigger, + ].join(keySeparator); + } else if (serialized.character != null) { + final modifiers = <String>[ + // Character based shortcuts cannot check shifted keys. + if (_usesSymbolicModifiers) ...<String>[ + // macOS/iOS platform convention uses this ordering, with ⌘ always last. + if (serialized.control!) _getModifierLabel(LogicalKeyboardKey.control, localizations), + if (serialized.alt!) _getModifierLabel(LogicalKeyboardKey.alt, localizations), + if (serialized.meta!) _getModifierLabel(LogicalKeyboardKey.meta, localizations), + ] else ...<String>[ + // This order matches the LogicalKeySet version. + if (serialized.alt!) _getModifierLabel(LogicalKeyboardKey.alt, localizations), + if (serialized.control!) _getModifierLabel(LogicalKeyboardKey.control, localizations), + if (serialized.meta!) _getModifierLabel(LogicalKeyboardKey.meta, localizations), + ], + ]; + return <String>[...modifiers, serialized.character!].join(keySeparator); + } + throw UnimplementedError( + 'Shortcut labels for ShortcutActivators that do not implement ' + 'MenuSerializableShortcut (e.g. ShortcutActivators other than SingleActivator or ' + 'CharacterActivator) are not supported.', + ); + } + + // Tries to look up the key in an internal table, and if it can't find it, + // then fall back to the key's keyLabel. + String? _getLocalizedName(LogicalKeyboardKey key, MaterialLocalizations localizations) { + // Since this is an expensive table to build, we cache it based on the + // localization object. There's currently no way to clear the cache, but + // it's unlikely that more than one or two will be cached for each run, and + // they're not huge. + _cachedShortcutKeys[localizations] ??= <LogicalKeyboardKey, String>{ + LogicalKeyboardKey.altGraph: localizations.keyboardKeyAltGraph, + LogicalKeyboardKey.backspace: localizations.keyboardKeyBackspace, + LogicalKeyboardKey.capsLock: localizations.keyboardKeyCapsLock, + LogicalKeyboardKey.channelDown: localizations.keyboardKeyChannelDown, + LogicalKeyboardKey.channelUp: localizations.keyboardKeyChannelUp, + LogicalKeyboardKey.delete: localizations.keyboardKeyDelete, + LogicalKeyboardKey.eject: localizations.keyboardKeyEject, + LogicalKeyboardKey.end: localizations.keyboardKeyEnd, + LogicalKeyboardKey.escape: localizations.keyboardKeyEscape, + LogicalKeyboardKey.fn: localizations.keyboardKeyFn, + LogicalKeyboardKey.home: localizations.keyboardKeyHome, + LogicalKeyboardKey.insert: localizations.keyboardKeyInsert, + LogicalKeyboardKey.numLock: localizations.keyboardKeyNumLock, + LogicalKeyboardKey.numpad1: localizations.keyboardKeyNumpad1, + LogicalKeyboardKey.numpad2: localizations.keyboardKeyNumpad2, + LogicalKeyboardKey.numpad3: localizations.keyboardKeyNumpad3, + LogicalKeyboardKey.numpad4: localizations.keyboardKeyNumpad4, + LogicalKeyboardKey.numpad5: localizations.keyboardKeyNumpad5, + LogicalKeyboardKey.numpad6: localizations.keyboardKeyNumpad6, + LogicalKeyboardKey.numpad7: localizations.keyboardKeyNumpad7, + LogicalKeyboardKey.numpad8: localizations.keyboardKeyNumpad8, + LogicalKeyboardKey.numpad9: localizations.keyboardKeyNumpad9, + LogicalKeyboardKey.numpad0: localizations.keyboardKeyNumpad0, + LogicalKeyboardKey.numpadAdd: localizations.keyboardKeyNumpadAdd, + LogicalKeyboardKey.numpadComma: localizations.keyboardKeyNumpadComma, + LogicalKeyboardKey.numpadDecimal: localizations.keyboardKeyNumpadDecimal, + LogicalKeyboardKey.numpadDivide: localizations.keyboardKeyNumpadDivide, + LogicalKeyboardKey.numpadEnter: localizations.keyboardKeyNumpadEnter, + LogicalKeyboardKey.numpadEqual: localizations.keyboardKeyNumpadEqual, + LogicalKeyboardKey.numpadMultiply: localizations.keyboardKeyNumpadMultiply, + LogicalKeyboardKey.numpadParenLeft: localizations.keyboardKeyNumpadParenLeft, + LogicalKeyboardKey.numpadParenRight: localizations.keyboardKeyNumpadParenRight, + LogicalKeyboardKey.numpadSubtract: localizations.keyboardKeyNumpadSubtract, + LogicalKeyboardKey.pageDown: localizations.keyboardKeyPageDown, + LogicalKeyboardKey.pageUp: localizations.keyboardKeyPageUp, + LogicalKeyboardKey.power: localizations.keyboardKeyPower, + LogicalKeyboardKey.powerOff: localizations.keyboardKeyPowerOff, + LogicalKeyboardKey.printScreen: localizations.keyboardKeyPrintScreen, + LogicalKeyboardKey.scrollLock: localizations.keyboardKeyScrollLock, + LogicalKeyboardKey.select: localizations.keyboardKeySelect, + LogicalKeyboardKey.space: localizations.keyboardKeySpace, + }; + return _cachedShortcutKeys[localizations]![key]; + } + + String _getModifierLabel(LogicalKeyboardKey modifier, MaterialLocalizations localizations) { + assert(_modifiers.contains(modifier), '${modifier.keyLabel} is not a modifier key'); + if (modifier == LogicalKeyboardKey.meta || + modifier == LogicalKeyboardKey.metaLeft || + modifier == LogicalKeyboardKey.metaRight) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + return localizations.keyboardKeyMeta; + case TargetPlatform.windows: + return localizations.keyboardKeyMetaWindows; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return '⌘'; + } + } + if (modifier == LogicalKeyboardKey.alt || + modifier == LogicalKeyboardKey.altLeft || + modifier == LogicalKeyboardKey.altRight) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return localizations.keyboardKeyAlt; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return '⌥'; + } + } + if (modifier == LogicalKeyboardKey.control || + modifier == LogicalKeyboardKey.controlLeft || + modifier == LogicalKeyboardKey.controlRight) { + // '⎈' (a boat helm wheel, not an asterisk) is apparently the standard + // icon for "control", but only seems to appear on the French Canadian + // keyboard. A '✲' (an open center asterisk) appears on some Microsoft + // keyboards. For all but macOS (which has standardized on "⌃", it seems), + // we just return the local translation of "Ctrl". + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return localizations.keyboardKeyControl; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return '⌃'; + } + } + if (modifier == LogicalKeyboardKey.shift || + modifier == LogicalKeyboardKey.shiftLeft || + modifier == LogicalKeyboardKey.shiftRight) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return localizations.keyboardKeyShift; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return '⇧'; + } + } + throw ArgumentError('Keyboard key ${modifier.keyLabel} is not a modifier.'); + } +} + +/// MenuBar-specific private specialization of [MenuAnchor] so that it can act +/// differently in regards to orientation, how open works, and what gets built. +class _MenuBarAnchor extends MenuAnchor { + const _MenuBarAnchor({ + required super.menuChildren, + super.controller, + super.clipBehavior, + super.style, + }); + + @override + State<MenuAnchor> createState() => _MenuBarAnchorState(); +} + +class _MenuBarAnchorState extends _MenuAnchorState { + late final Map<Type, Action<Intent>> actions = <Type, Action<Intent>>{ + DismissIntent: DismissMenuAction(controller: _menuController), + }; + + @override + Axis get _orientation => Axis.horizontal; + + @override + Widget build(BuildContext context) { + final child = Actions( + actions: actions, + child: Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: _MenuPanel( + menuStyle: widget.style, + clipBehavior: widget.clipBehavior, + orientation: _orientation, + children: widget.menuChildren, + ), + ), + ); + return _MenuAnchorScope( + state: this, + animationStatus: _animationController.status, + child: RawMenuAnchorGroup( + controller: _menuController, + child: Builder( + builder: (BuildContext context) { + final bool isOpen = MenuController.maybeIsOpenOf(context) ?? false; + return FocusScope( + node: _menuScopeNode, + skipTraversal: !isOpen, + canRequestFocus: isOpen, + descendantsAreFocusable: true, + child: ExcludeFocus(excluding: !isOpen, child: child), + ); + }, + ), + ), + ); + } +} + +/// An [InheritedWidget] that provides a descendant [MenuAcceleratorLabel] with +/// the function to invoke when the accelerator is pressed. +/// +/// This is used when creating your own custom menu item for use with +/// [MenuAnchor] or [MenuBar]. Provided menu items such as [MenuItemButton] and +/// [SubmenuButton] already supply this wrapper internally. +class MenuAcceleratorCallbackBinding extends InheritedWidget { + /// Create a const [MenuAcceleratorCallbackBinding]. + /// + /// The [child] parameter is required. + const MenuAcceleratorCallbackBinding({ + super.key, + this.onInvoke, + this.hasSubmenu = false, + required super.child, + }); + + /// The function that pressing the accelerator defined in a descendant + /// [MenuAcceleratorLabel] will invoke. + /// + /// If set to null, then the accelerator won't be enabled. + final VoidCallback? onInvoke; + + /// Whether or not the associated label will host its own submenu or not. + /// + /// This setting determines when accelerators are active, since accelerators + /// for menu items that open submenus shouldn't be active when the submenu is + /// open. + final bool hasSubmenu; + + @override + bool updateShouldNotify(MenuAcceleratorCallbackBinding oldWidget) { + return onInvoke != oldWidget.onInvoke || hasSubmenu != oldWidget.hasSubmenu; + } + + /// Returns the active [MenuAcceleratorCallbackBinding] in the given context, if any, + /// and creates a dependency relationship that will rebuild the context when + /// [onInvoke] changes. + /// + /// If no [MenuAcceleratorCallbackBinding] is found, returns null. + /// + /// See also: + /// + /// * [of], which is similar, but asserts if no [MenuAcceleratorCallbackBinding] + /// is found. + static MenuAcceleratorCallbackBinding? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<MenuAcceleratorCallbackBinding>(); + } + + /// Returns the active [MenuAcceleratorCallbackBinding] in the given context, and + /// creates a dependency relationship that will rebuild the context when + /// [onInvoke] changes. + /// + /// If no [MenuAcceleratorCallbackBinding] is found, returns will assert in debug mode + /// and throw an exception in release mode. + /// + /// See also: + /// + /// * [maybeOf], which is similar, but returns null if no + /// [MenuAcceleratorCallbackBinding] is found. + static MenuAcceleratorCallbackBinding of(BuildContext context) { + final MenuAcceleratorCallbackBinding? result = maybeOf(context); + assert(() { + if (result == null) { + throw FlutterError( + 'MenuAcceleratorWrapper.of() was called with a context that does not ' + 'contain a MenuAcceleratorWrapper in the given context.\n' + 'No MenuAcceleratorWrapper ancestor could be found in the context that ' + 'was passed to MenuAcceleratorWrapper.of(). This can happen because ' + 'you are using a widget that looks for a MenuAcceleratorWrapper ' + 'ancestor, and do not have a MenuAcceleratorWrapper widget ancestor.\n' + 'The context used was:\n' + ' $context', + ); + } + return true; + }()); + return result!; + } +} + +/// The type of builder function used for building a [MenuAcceleratorLabel]'s +/// [MenuAcceleratorLabel.builder] function. +/// +/// {@template flutter.material.menu_anchor.menu_accelerator_child_builder.args} +/// The arguments to the function are as follows: +/// +/// * The `context` supplies the [BuildContext] to use. +/// * The `label` is the [MenuAcceleratorLabel.label] attribute for the relevant +/// [MenuAcceleratorLabel] with the accelerator markers stripped out of it. +/// * The `index` is the index of the accelerator character within the +/// `label.characters` that applies to this accelerator. If it is -1, then the +/// accelerator should not be highlighted. Otherwise, the given character +/// should be highlighted somehow in the rendered label (typically with an +/// underscore). Importantly, `index` is not an index into the [String] +/// `label`, it is an index into the [Characters] iterable returned by +/// `label.characters`, so that it is in terms of user-visible characters +/// (a.k.a. grapheme clusters), not Unicode code points. +/// {@endtemplate} +/// +/// See also: +/// +/// * [MenuAcceleratorLabel.defaultLabelBuilder], which is the implementation +/// used as the default value for [MenuAcceleratorLabel.builder]. +typedef MenuAcceleratorChildBuilder = + Widget Function(BuildContext context, String label, int index); + +/// A widget that draws the label text for a menu item (typically a +/// [MenuItemButton] or [SubmenuButton]) and renders its child with information +/// about the currently active keyboard accelerator. +/// +/// On platforms other than macOS and iOS, this widget listens for the Alt key +/// to be pressed, and when it is down, will update the label by calling the +/// builder again with the position of the accelerator in the label string. +/// While the Alt key is pressed, it registers a shortcut with the +/// [ShortcutRegistry] mapped to a [VoidCallbackIntent] containing the callback +/// defined by the nearest [MenuAcceleratorCallbackBinding]. +/// +/// Because the accelerators are registered with the [ShortcutRegistry], any +/// other shortcuts in the widget tree between the [primaryFocus] and the +/// [ShortcutRegistry] that define Alt-based shortcuts using the same keys will +/// take precedence over the accelerators. +/// +/// Because accelerators aren't used on macOS and iOS, the label ignores the Alt +/// key on those platforms, and the [builder] is always given -1 as an +/// accelerator index. Accelerator labels are still stripped of their +/// accelerator markers. +/// +/// The built-in menu items [MenuItemButton] and [SubmenuButton] already provide +/// the appropriate [MenuAcceleratorCallbackBinding], so unless you are creating +/// your own custom menu item type that takes a [MenuAcceleratorLabel], it is +/// not necessary to provide one. +/// +/// {@template flutter.material.MenuAcceleratorLabel.accelerator_sample} +/// {@tool dartpad} This example shows a [MenuBar] that handles keyboard +/// accelerators using [MenuAcceleratorLabel]. To use the accelerators, press +/// the Alt key to see which letters are underlined in the menu bar, and then +/// press the appropriate letter. Accelerators are not supported on macOS or iOS +/// since those platforms don't support them natively, so this demo will only +/// show a regular Material menu bar on those platforms. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_accelerator_label.0.dart ** +/// {@end-tool} +/// {@endtemplate} +class MenuAcceleratorLabel extends StatefulWidget { + /// Creates a const [MenuAcceleratorLabel]. + /// + /// The [label] parameter is required. + const MenuAcceleratorLabel(this.label, {super.key, this.builder = defaultLabelBuilder}); + + /// The label string that should be displayed. + /// + /// The label string provides the label text, as well as the possible + /// characters which could be used as accelerators in the menu system. + /// + /// {@template flutter.material.menu_anchor.menu_accelerator_label.label} + /// To indicate which letters in the label are to be used as accelerators, add + /// an "&" character before the character in the string. If more than one + /// character has an "&" in front of it, then the characters appearing earlier + /// in the string are preferred. To represent a literal "&", insert "&&" into + /// the string. All other ampersands will be removed from the string before + /// calling [MenuAcceleratorLabel.builder]. Bare ampersands at the end of the + /// string or before whitespace are stripped and ignored. + /// {@endtemplate} + /// + /// See also: + /// + /// * [displayLabel], which returns the [label] with all of the ampersands + /// stripped out of it, and double ampersands converted to ampersands. + /// * [stripAcceleratorMarkers], which returns the supplied string with all of + /// the ampersands stripped out of it, and double ampersands converted to + /// ampersands, and optionally calls a callback with the index of the + /// accelerator character found. + final String label; + + /// Returns the [label] with any accelerator markers removed. + /// + /// This getter just calls [stripAcceleratorMarkers] with the [label]. + String get displayLabel => stripAcceleratorMarkers(label); + + /// The optional [MenuAcceleratorChildBuilder] which is used to build the + /// widget that displays the label itself. + /// + /// The [defaultLabelBuilder] function serves as the default value for + /// [builder], rendering the label as a [RichText] widget with appropriate + /// [TextSpan]s for rendering the label with an underscore under the selected + /// accelerator for the label when accelerators have been activated. + /// + /// {@macro flutter.material.menu_anchor.menu_accelerator_child_builder.args} + /// + /// When writing the builder function, it's not necessary to take the current + /// platform into account. On platforms which don't support accelerators (e.g. + /// macOS and iOS), the passed accelerator index will always be -1, and the + /// accelerator markers will already be stripped. + final MenuAcceleratorChildBuilder builder; + + /// Whether [label] contains an accelerator definition. + /// + /// {@macro flutter.material.menu_anchor.menu_accelerator_label.label} + bool get hasAccelerator => RegExp(r'&(?!([&\s]|$))').hasMatch(label); + + /// Serves as the default value for [builder], rendering the label as a + /// [RichText] widget with appropriate [TextSpan]s for rendering the label + /// with an underscore under the selected accelerator for the label when the + /// [index] is non-negative, and a [Text] widget when the [index] is negative. + /// + /// {@macro flutter.material.menu_anchor.menu_accelerator_child_builder.args} + static Widget defaultLabelBuilder(BuildContext context, String label, int index) { + if (index < 0) { + return Text(label); + } + final TextStyle defaultStyle = DefaultTextStyle.of(context).style; + final Characters characters = label.characters; + return RichText( + text: TextSpan( + children: <TextSpan>[ + if (index > 0) + TextSpan(text: characters.getRange(0, index).toString(), style: defaultStyle), + TextSpan( + text: characters.getRange(index, index + 1).toString(), + style: defaultStyle.copyWith(decoration: TextDecoration.underline), + ), + if (index < characters.length - 1) + TextSpan(text: characters.getRange(index + 1).toString(), style: defaultStyle), + ], + ), + ); + } + + /// Strips out any accelerator markers from the given [label], and unescapes + /// any escaped ampersands. + /// + /// If [setIndex] is supplied, it will be called before this function returns + /// with the index in the returned string of the accelerator character. + /// + /// {@macro flutter.material.menu_anchor.menu_accelerator_label.label} + static String stripAcceleratorMarkers(String label, {void Function(int index)? setIndex}) { + var quotedAmpersands = 0; + final displayLabel = StringBuffer(); + var acceleratorIndex = -1; + // Use characters so that we don't split up surrogate pairs and interpret + // them incorrectly. + final Characters labelChars = label.characters; + final Characters ampersand = '&'.characters; + var lastWasAmpersand = false; + for (var i = 0; i < labelChars.length; i += 1) { + // Stop looking one before the end, since a single ampersand at the end is + // just treated as a quoted ampersand. + final Characters character = labelChars.characterAt(i); + if (lastWasAmpersand) { + lastWasAmpersand = false; + displayLabel.write(character); + continue; + } + if (character != ampersand) { + displayLabel.write(character); + continue; + } + if (i == labelChars.length - 1) { + // Strip bare ampersands at the end of a string. + break; + } + lastWasAmpersand = true; + final Characters acceleratorCharacter = labelChars.characterAt(i + 1); + if (acceleratorIndex == -1 && + acceleratorCharacter != ampersand && + acceleratorCharacter.toString().trim().isNotEmpty) { + // Don't set the accelerator index if the character is an ampersand, + // or whitespace. + acceleratorIndex = i - quotedAmpersands; + } + // As we encounter '&<character>' pairs, the following indices must be + // adjusted so that they correspond with indices in the stripped string. + quotedAmpersands += 1; + } + setIndex?.call(acceleratorIndex); + return displayLabel.toString(); + } + + @override + State<MenuAcceleratorLabel> createState() => _MenuAcceleratorLabelState(); + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return '$MenuAcceleratorLabel("$label")'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('label', label)); + } +} + +class _MenuAcceleratorLabelState extends State<MenuAcceleratorLabel> { + late String _displayLabel; + int _acceleratorIndex = -1; + MenuAcceleratorCallbackBinding? _binding; + MenuController? _menuController; + ShortcutRegistry? _shortcutRegistry; + ShortcutRegistryEntry? _shortcutRegistryEntry; + bool _showAccelerators = false; + + @override + void initState() { + super.initState(); + if (_platformSupportsAccelerators) { + _showAccelerators = _altIsPressed(); + HardwareKeyboard.instance.addHandler(_listenToKeyEvent); + } + _updateDisplayLabel(); + } + + @override + void dispose() { + assert(_platformSupportsAccelerators || _shortcutRegistryEntry == null); + _displayLabel = ''; + if (_platformSupportsAccelerators) { + _shortcutRegistryEntry?.dispose(); + _shortcutRegistryEntry = null; + _shortcutRegistry = null; + _menuController = null; + HardwareKeyboard.instance.removeHandler(_listenToKeyEvent); + } + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_platformSupportsAccelerators) { + return; + } + _binding = MenuAcceleratorCallbackBinding.maybeOf(context); + _menuController = MenuController.maybeOf(context); + _shortcutRegistry = ShortcutRegistry.maybeOf(context); + _updateAcceleratorShortcut(); + } + + @override + void didUpdateWidget(MenuAcceleratorLabel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.label != oldWidget.label) { + _updateDisplayLabel(); + } + } + + static bool _altIsPressed() { + return HardwareKeyboard.instance.logicalKeysPressed.intersection(<LogicalKeyboardKey>{ + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.alt, + }).isNotEmpty; + } + + bool _listenToKeyEvent(KeyEvent event) { + assert(_platformSupportsAccelerators); + setState(() { + _showAccelerators = _altIsPressed(); + _updateAcceleratorShortcut(); + }); + // Just listening, so it doesn't ever handle a key. + return false; + } + + void _updateAcceleratorShortcut() { + assert(_platformSupportsAccelerators); + _shortcutRegistryEntry?.dispose(); + _shortcutRegistryEntry = null; + // Before registering an accelerator as a shortcut it should meet these + // conditions: + // + // 1) Is showing accelerators (i.e. Alt key is down). + // 2) Has an accelerator marker in the label. + // 3) Has an associated action callback for the label (from the + // MenuAcceleratorCallbackBinding). + // 4) Is part of an anchor that either doesn't have a submenu, or doesn't + // have any submenus currently open (only the "deepest" open menu should + // have accelerator shortcuts registered). + if (_showAccelerators && + _acceleratorIndex != -1 && + _binding?.onInvoke != null && + (!_binding!.hasSubmenu || !(_menuController?.isOpen ?? false))) { + final String acceleratorCharacter = _displayLabel[_acceleratorIndex].toLowerCase(); + _shortcutRegistryEntry = _shortcutRegistry?.addAll(<ShortcutActivator, Intent>{ + CharacterActivator(acceleratorCharacter, alt: true): VoidCallbackIntent( + _binding!.onInvoke!, + ), + }); + } + } + + void _updateDisplayLabel() { + _displayLabel = MenuAcceleratorLabel.stripAcceleratorMarkers( + widget.label, + setIndex: (int index) { + _acceleratorIndex = index; + }, + ); + } + + @override + Widget build(BuildContext context) { + final int index = _showAccelerators ? _acceleratorIndex : -1; + return widget.builder(context, _displayLabel, index); + } +} + +/// A label widget that is used as the label for a [MenuItemButton] or +/// [SubmenuButton]. +/// +/// It not only shows the [SubmenuButton.child] or [MenuItemButton.child], but if +/// there is a shortcut associated with the [MenuItemButton], it will display a +/// mnemonic for the shortcut. For [SubmenuButton]s, it will display a visual +/// indicator that there is a submenu. +class _MenuItemLabel extends StatelessWidget { + /// Creates a const [_MenuItemLabel]. + /// + /// The [child] and [hasSubmenu] arguments are required. + const _MenuItemLabel({ + required this.hasSubmenu, + this.showDecoration = true, + this.leadingIcon, + this.trailingIcon, + this.shortcut, + this.semanticsLabel, + this.overflowAxis = Axis.vertical, + this.submenuIcon, + this.child, + }); + + /// Whether or not this menu has a submenu. + /// + /// Determines whether the submenu arrow is shown or not. + final bool hasSubmenu; + + /// Whether or not this item should show decorations like shortcut labels or + /// submenu arrows. Items in a [MenuBar] don't show these decorations when + /// they are laid out horizontally. + final bool showDecoration; + + /// The optional icon that comes before the [child]. + final Widget? leadingIcon; + + /// The optional icon that comes after the [child]. + final Widget? trailingIcon; + + /// The shortcut for this label, so that it can generate a string describing + /// the shortcut. + final MenuSerializableShortcut? shortcut; + + /// An optional Semantics label, which replaces the generated string when + /// read by a screen reader. + final String? semanticsLabel; + + /// The direction in which the menu item expands. + final Axis overflowAxis; + + /// The submenu icon that is displayed when [showDecoration] and [hasSubmenu] are true. + final Widget? submenuIcon; + + /// An optional child widget that is displayed in the label. + final Widget? child; + + @override + Widget build(BuildContext context) { + final VisualDensity density = Theme.of(context).visualDensity; + final double horizontalPadding = math.max( + _kLabelItemMinSpacing, + _kLabelItemDefaultSpacing + density.horizontal * 2, + ); + Widget leadings; + if (overflowAxis == Axis.vertical) { + leadings = Expanded( + child: ClipRect( + child: Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ?leadingIcon, + if (child != null) + Expanded( + child: ClipRect( + child: Padding( + padding: leadingIcon != null + ? EdgeInsetsDirectional.only(start: horizontalPadding) + : EdgeInsets.zero, + child: child, + ), + ), + ), + ], + ), + ), + ); + } else { + leadings = Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ?leadingIcon, + if (child != null) + Padding( + padding: leadingIcon != null + ? EdgeInsetsDirectional.only(start: horizontalPadding) + : EdgeInsets.zero, + child: child, + ), + ], + ); + } + + Widget menuItemLabel = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + leadings, + if (trailingIcon != null) + Padding( + padding: EdgeInsetsDirectional.only(start: horizontalPadding), + child: trailingIcon, + ), + if (showDecoration && shortcut != null) + Padding( + padding: EdgeInsetsDirectional.only(start: horizontalPadding), + child: Text( + _LocalizedShortcutLabeler.instance.getShortcutLabel( + shortcut!, + MaterialLocalizations.of(context), + ), + ), + ), + if (showDecoration && hasSubmenu) + Padding( + padding: EdgeInsetsDirectional.only(start: horizontalPadding), + child: submenuIcon, + ), + ], + ); + if (semanticsLabel != null) { + menuItemLabel = Semantics( + label: semanticsLabel, + excludeSemantics: true, + child: menuItemLabel, + ); + } + return menuItemLabel; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<MenuSerializableShortcut>('shortcut', shortcut, defaultValue: null), + ); + properties.add(DiagnosticsProperty<bool>('hasSubmenu', hasSubmenu)); + properties.add(DiagnosticsProperty<bool>('showDecoration', showDecoration)); + } +} + +// Positions the menu in the view while trying to keep as much as possible +// visible in the view. +class _MenuLayout extends SingleChildLayoutDelegate { + const _MenuLayout({ + required this.anchorRect, + required this.textDirection, + required this.alignment, + required this.alignmentOffset, + required this.menuPosition, + required this.menuPadding, + required this.avoidBounds, + required this.orientation, + required this.parentOrientation, + required this.reservedPadding, + required this.heightFactor, + required this.mediaQueryData, + }); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final Rect anchorRect; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The alignment to use when finding the ideal location for the menu. + final AlignmentGeometry alignment; + + // The offset from the alignment position to find the ideal location for the + // menu. + final Offset alignmentOffset; + + // The position passed to the open method, if any. + final Offset? menuPosition; + + // The padding on the inside of the menu, so it can be accounted for when + // positioning. + final EdgeInsetsGeometry menuPadding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set<Rect> avoidBounds; + + // The orientation of this menu. + final Axis orientation; + + // The orientation of this menu's parent. + final Axis parentOrientation; + + // How close to the edge of the safe area the menu will be placed. + final EdgeInsetsGeometry reservedPadding; + + // The factor by which the height of the menu is scaled. + final double heightFactor; + + // Used to ensure the menu is positioned within the safe area and respects + // view insets such as the software keyboard. + final MediaQueryData mediaQueryData; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus the view padding + // in each direction. + return BoxConstraints.loose(constraints.biggest).deflate(reservedPadding); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + final Rect overlayRect = mediaQueryData.padding.deflateRect( + mediaQueryData.viewInsets.deflateRect(Offset.zero & size), + ); + final double unconstrainedHeight = heightFactor > 0.01 ? childSize.height / heightFactor : 0; + final double childHeightEstimate = math.min(unconstrainedHeight, size.height); + final childSizeEstimate = Size(childSize.width, childHeightEstimate); + final ui.Offset finalPosition = _positionChild(childSizeEstimate, overlayRect); + + if (menuPosition != null) { + return finalPosition; + } + + // If the menu sits above the anchor when fully open, grow upward. + // Keep the bottom (attachment) fixed by shifting the top-left during animation. + final bool growsUp = finalPosition.dy + childSizeEstimate.height <= anchorRect.center.dy; + if (growsUp) { + final double dy = childHeightEstimate - childSize.height; + return Offset(finalPosition.dx, finalPosition.dy + dy); + } + + final initialPosition = Offset(finalPosition.dx, anchorRect.bottom); + return Offset.lerp(initialPosition, finalPosition, heightFactor)!; + } + + ui.Offset _positionChild(ui.Size childSize, ui.Rect overlayRect) { + double x; + double y; + if (menuPosition == null) { + Offset desiredPosition = alignment.resolve(textDirection).withinRect(anchorRect); + final Offset directionalOffset; + if (alignment is AlignmentDirectional) { + directionalOffset = switch (textDirection) { + TextDirection.rtl => Offset(-alignmentOffset.dx, alignmentOffset.dy), + TextDirection.ltr => alignmentOffset, + }; + } else { + directionalOffset = alignmentOffset; + } + desiredPosition += directionalOffset; + x = desiredPosition.dx; + y = desiredPosition.dy; + switch (textDirection) { + case TextDirection.rtl: + x -= childSize.width; + case TextDirection.ltr: + break; + } + } else { + final Offset adjustedPosition = menuPosition! + anchorRect.topLeft; + x = adjustedPosition.dx; + y = adjustedPosition.dy; + } + + final Iterable<Rect> subScreens = DisplayFeatureSubScreen.subScreensInBounds( + overlayRect, + avoidBounds, + ); + final Rect allowedRect = _closestScreen(subScreens, anchorRect.center); + bool offLeftSide(double x) => x < allowedRect.left; + bool offRightSide(double x) => x + childSize.width > allowedRect.right; + bool offTop(double y) => y < allowedRect.top; + bool offBottom(double y) => y + childSize.height > allowedRect.bottom; + // Avoid going outside an area defined as the rectangle offset from the + // edge of the screen by the button padding. If the menu is off of the screen, + // move the menu to the other side of the button first, and then if it + // doesn't fit there, then just move it over as much as needed to make it + // fit. + if (childSize.width >= allowedRect.width) { + // It just doesn't fit, so put as much on the screen as possible. + x = allowedRect.left; + } else { + if (offLeftSide(x)) { + // If the parent is a different orientation than the current one, then + // just push it over instead of trying the other side. + if (parentOrientation != orientation) { + x = allowedRect.left; + } else { + final double newX = anchorRect.right + alignmentOffset.dx; + if (!offRightSide(newX)) { + x = newX; + } else { + x = allowedRect.left; + } + } + } else if (offRightSide(x)) { + if (parentOrientation != orientation) { + x = allowedRect.right - childSize.width; + } else { + final double newX = anchorRect.left - childSize.width - alignmentOffset.dx; + if (!offLeftSide(newX)) { + x = newX; + } else { + x = allowedRect.right - childSize.width; + } + } + } + } + if (childSize.height >= allowedRect.height) { + // Too tall to fit, fit as much on as possible. + y = allowedRect.top; + } else { + if (offTop(y)) { + final double newY = anchorRect.bottom; + if (!offBottom(newY)) { + y = newY; + } else { + y = allowedRect.top; + } + } else if (offBottom(y)) { + final double newY = anchorRect.top - childSize.height; + if (!offTop(newY)) { + // Only move the menu up if its parent is horizontal (MenuAnchor/MenuBar). + if (parentOrientation == Axis.horizontal) { + y = newY - alignmentOffset.dy; + } else { + y = newY; + } + } else { + y = allowedRect.bottom - childSize.height; + } + } + } + return Offset(x, y); + } + + @override + bool shouldRelayout(_MenuLayout oldDelegate) { + return anchorRect != oldDelegate.anchorRect || + textDirection != oldDelegate.textDirection || + alignment != oldDelegate.alignment || + alignmentOffset != oldDelegate.alignmentOffset || + menuPosition != oldDelegate.menuPosition || + menuPadding != oldDelegate.menuPadding || + orientation != oldDelegate.orientation || + parentOrientation != oldDelegate.parentOrientation || + reservedPadding != oldDelegate.reservedPadding || + heightFactor != oldDelegate.heightFactor || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } + + Rect _closestScreen(Iterable<Rect> screens, Offset point) { + Rect closest = screens.first; + for (final screen in screens) { + if ((screen.center - point).distance < (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } +} + +/// A widget that manages a list of menu buttons in a menu. +/// +/// It sizes itself to the widest/tallest item it contains, and then sizes all +/// the other entries to match. +class _MenuPanel extends StatefulWidget { + const _MenuPanel({ + required this.menuStyle, + this.clipBehavior = Clip.none, + required this.orientation, + this.crossAxisUnconstrained = true, + this.heightAnimation, + required this.children, + }); + + /// The menu style that has all the attributes for this menu panel. + final MenuStyle? menuStyle; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Determine if a [UnconstrainedBox] can be applied to the menu panel to allow it to render + /// at its "natural" size. + /// + /// Defaults to true. When it is set to false, it can be useful when the menu should + /// be constrained in both main-axis and cross-axis, such as a [DropdownMenu]. + final bool crossAxisUnconstrained; + + /// The layout orientation of this panel. + final Axis orientation; + + /// The animation that controls the height of the menu panel. + final Animation<double>? heightAnimation; + + /// The list of widgets to use as children of this menu panel. + /// + /// These are the top level [SubmenuButton]s. + final List<Widget> children; + + @override + State<_MenuPanel> createState() => _MenuPanelState(); +} + +class _MenuPanelState extends State<_MenuPanel> { + ScrollController scrollController = ScrollController(); + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final (MenuStyle? themeStyle, MenuStyle defaultStyle) = switch (widget.orientation) { + Axis.horizontal => (MenuBarTheme.of(context).style, _MenuBarDefaultsM3(context)), + Axis.vertical => (MenuTheme.of(context).style, _MenuDefaultsM3(context)), + }; + final MenuStyle? widgetStyle = widget.menuStyle; + + T? effectiveValue<T>(T? Function(MenuStyle? style) getProperty) { + return getProperty(widgetStyle) ?? getProperty(themeStyle) ?? getProperty(defaultStyle); + } + + T? resolve<T>(WidgetStateProperty<T>? Function(MenuStyle? style) getProperty) { + return effectiveValue((MenuStyle? style) { + return getProperty(style)?.resolve(<WidgetState>{}); + }); + } + + final Color? backgroundColor = resolve<Color?>((MenuStyle? style) => style?.backgroundColor); + final Color? shadowColor = resolve<Color?>((MenuStyle? style) => style?.shadowColor); + final Color? surfaceTintColor = resolve<Color?>((MenuStyle? style) => style?.surfaceTintColor); + final double elevation = resolve<double?>((MenuStyle? style) => style?.elevation) ?? 0; + final Size? minimumSize = resolve<Size?>((MenuStyle? style) => style?.minimumSize); + final Size? fixedSize = resolve<Size?>((MenuStyle? style) => style?.fixedSize); + final Size? maximumSize = resolve<Size?>((MenuStyle? style) => style?.maximumSize); + final BorderSide? side = resolve<BorderSide?>((MenuStyle? style) => style?.side); + final OutlinedBorder shape = resolve<OutlinedBorder?>( + (MenuStyle? style) => style?.shape, + )!.copyWith(side: side); + final VisualDensity visualDensity = + effectiveValue((MenuStyle? style) => style?.visualDensity) ?? VisualDensity.standard; + final EdgeInsetsGeometry padding = + resolve<EdgeInsetsGeometry?>((MenuStyle? style) => style?.padding) ?? EdgeInsets.zero; + final Offset densityAdjustment = visualDensity.baseSizeAdjustment; + // Per the Material Design team: don't allow the VisualDensity + // adjustment to reduce the width of the left/right padding. If we + // did, VisualDensity.compact, the default for desktop/web, would + // reduce the horizontal padding to zero. Vertical padding + // is not affected at all. + final double dx = math.max(0, densityAdjustment.dx); + final EdgeInsetsGeometry resolvedPadding = padding + .add(EdgeInsets.symmetric(horizontal: dx)) + .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + + BoxConstraints effectiveConstraints = visualDensity.effectiveConstraints( + BoxConstraints( + minWidth: minimumSize?.width ?? 0, + minHeight: minimumSize?.height ?? 0, + maxWidth: maximumSize?.width ?? double.infinity, + maxHeight: maximumSize?.height ?? double.infinity, + ), + ); + if (fixedSize != null) { + final Size size = effectiveConstraints.constrain(fixedSize); + if (size.width.isFinite) { + effectiveConstraints = effectiveConstraints.copyWith( + minWidth: size.width, + maxWidth: size.width, + ); + } + if (size.height.isFinite) { + effectiveConstraints = effectiveConstraints.copyWith( + minHeight: size.height, + maxHeight: size.height, + ); + } + } + + // If the menu panel is horizontal, then the children should be wrapped in + // an IntrinsicWidth widget to ensure that the children are as wide as the + // widest child. + List<Widget> children = widget.children; + if (widget.orientation == Axis.horizontal) { + children = children.map<Widget>((Widget child) { + return IntrinsicWidth(child: child); + }).toList(); + } + + final bool displayScrollbar = switch (_MenuAnchorState._maybeAnimationStatusOf(context)) { + AnimationStatus.completed => true, + AnimationStatus.forward || + AnimationStatus.reverse || + AnimationStatus.dismissed || + null => false, + }; + + Widget menuPanel = Padding( + padding: resolvedPadding, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false, overscroll: false, physics: const ClampingScrollPhysics()), + child: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + thumbVisibility: displayScrollbar, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: widget.orientation, + child: Flex( + crossAxisAlignment: CrossAxisAlignment.start, + textDirection: Directionality.of(context), + direction: widget.orientation, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ), + ), + ), + ), + ); + + if (widget.heightAnimation != null) { + // An AnimatedBuilder is used instead of SizeTransition because Material + // already introduces its own ClipRRect for the shape. + menuPanel = AnimatedBuilder( + animation: widget.heightAnimation!, + builder: _buildAnimatedHeight, + child: menuPanel, + ); + } + + menuPanel = _intrinsicCrossSize( + child: Material( + elevation: elevation, + shape: shape, + color: backgroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + type: backgroundColor == null ? MaterialType.transparency : MaterialType.canvas, + clipBehavior: widget.clipBehavior, + child: menuPanel, + ), + ); + + if (widget.crossAxisUnconstrained) { + menuPanel = UnconstrainedBox( + constrainedAxis: widget.orientation, + clipBehavior: Clip.hardEdge, + alignment: AlignmentDirectional.centerStart, + child: menuPanel, + ); + } + + return ConstrainedBox(constraints: effectiveConstraints, child: menuPanel); + } + + Widget _intrinsicCrossSize({required Widget child}) { + return switch (widget.orientation) { + Axis.horizontal => IntrinsicHeight(child: child), + Axis.vertical => IntrinsicWidth(child: child), + }; + } + + Widget _buildAnimatedHeight(BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.topStart, + heightFactor: widget.heightAnimation!.value, + widthFactor: 1, + child: child, + ); + } +} + +// A widget that defines the menu drawn in the overlay. +class _Submenu extends StatelessWidget { + const _Submenu({ + required this.anchor, + required this.layerLink, + required this.menuStyle, + required this.menuPosition, + required this.alignmentOffset, + required this.consumeOutsideTaps, + required this.clipBehavior, + this.crossAxisUnconstrained = true, + required this.menuChildren, + required this.menuScopeNode, + required this.fadeAnimation, + required this.heightAnimation, + required this.reservedPadding, + }); + + final FocusScopeNode menuScopeNode; + final RawMenuOverlayInfo menuPosition; + final _MenuAnchorState anchor; + final LayerLink? layerLink; + final MenuStyle? menuStyle; + final bool consumeOutsideTaps; + final Offset alignmentOffset; + final Clip clipBehavior; + final bool crossAxisUnconstrained; + final List<Widget> menuChildren; + final Animation<double> fadeAnimation; + final Animation<double> heightAnimation; + final EdgeInsetsGeometry reservedPadding; + + @override + Widget build(BuildContext context) { + // Use the text direction of the context where the button is. + final TextDirection textDirection = Directionality.of(context); + final (MenuStyle? themeStyle, MenuStyle defaultStyle) = switch (anchor._parent?._orientation) { + Axis.horizontal || null => (MenuBarTheme.of(context).style, _MenuBarDefaultsM3(context)), + Axis.vertical => (MenuTheme.of(context).style, _MenuDefaultsM3(context)), + }; + T? effectiveValue<T>(T? Function(MenuStyle? style) getProperty) { + return getProperty(menuStyle) ?? getProperty(themeStyle) ?? getProperty(defaultStyle); + } + + T? resolve<T>(WidgetStateProperty<T>? Function(MenuStyle? style) getProperty) { + return effectiveValue((MenuStyle? style) { + return getProperty(style)?.resolve(<WidgetState>{}); + }); + } + + final WidgetStateMouseCursor mouseCursor = _MouseCursor( + (Set<WidgetState> states) => + effectiveValue((MenuStyle? style) => style?.mouseCursor?.resolve(states)), + ); + + final VisualDensity visualDensity = + effectiveValue((MenuStyle? style) => style?.visualDensity) ?? + Theme.of(context).visualDensity; + final AlignmentGeometry alignment = effectiveValue((MenuStyle? style) => style?.alignment)!; + final EdgeInsetsGeometry padding = + resolve<EdgeInsetsGeometry?>((MenuStyle? style) => style?.padding) ?? EdgeInsets.zero; + final Offset densityAdjustment = visualDensity.baseSizeAdjustment; + // Per the Material Design team: don't allow the VisualDensity + // adjustment to reduce the width of the left/right padding. If we + // did, VisualDensity.compact, the default for desktop/web, would + // reduce the horizontal padding to zero. + final double dx = math.max(0, densityAdjustment.dx); + final EdgeInsetsGeometry resolvedPadding = padding + .add(EdgeInsets.fromLTRB(dx, 0, dx, 0)) + .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); + + final Rect anchorRect = layerLink == null + ? Rect.fromLTRB( + menuPosition.anchorRect.left + dx, + menuPosition.anchorRect.top, + menuPosition.anchorRect.right, + menuPosition.anchorRect.bottom, + ) + : Rect.zero; + + final Widget menuPanel = TapRegion( + groupId: menuPosition.tapRegionGroupId, + consumeOutsideTaps: anchor._root._menuController.isOpen && anchor.widget.consumeOutsideTap, + onTapOutside: (PointerDownEvent event) { + anchor._menuController.close(); + }, + child: MouseRegion( + cursor: mouseCursor, + hitTestBehavior: HitTestBehavior.deferToChild, + child: FocusScope( + node: anchor._menuScopeNode, + skipTraversal: true, + child: Actions( + actions: <Type, Action<Intent>>{ + DismissIntent: DismissMenuAction(controller: anchor._menuController), + }, + child: Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: FadeTransition( + opacity: fadeAnimation, + alwaysIncludeSemantics: true, + child: _MenuPanel( + menuStyle: menuStyle, + clipBehavior: clipBehavior, + orientation: anchor._orientation, + crossAxisUnconstrained: crossAxisUnconstrained, + heightAnimation: heightAnimation, + children: menuChildren, + ), + ), + ), + ), + ), + ), + ); + + final Widget layout = Theme( + data: Theme.of(context).copyWith(visualDensity: visualDensity), + child: ConstrainedBox( + constraints: BoxConstraints.loose(menuPosition.overlaySize), + child: AnimatedBuilder( + animation: heightAnimation, + builder: (BuildContext context, Widget? child) { + final MediaQueryData mediaQuery = MediaQuery.of(context); + return CustomSingleChildLayout( + delegate: _MenuLayout( + anchorRect: anchorRect, + textDirection: textDirection, + avoidBounds: DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(), + menuPadding: resolvedPadding, + alignment: alignment, + alignmentOffset: alignmentOffset, + menuPosition: menuPosition.position, + orientation: anchor._orientation, + parentOrientation: anchor._parent?._orientation ?? Axis.horizontal, + reservedPadding: reservedPadding, + heightFactor: heightAnimation.value, + mediaQueryData: mediaQuery, + ), + child: menuPanel, + ); + }, + ), + ), + ); + + if (layerLink == null) { + return layout; + } + + return CompositedTransformFollower( + link: layerLink!, + targetAnchor: Alignment.bottomLeft, + child: layout, + ); + } +} + +/// Wraps the [WidgetStateMouseCursor] so that it can default to +/// [MouseCursor.uncontrolled] if none is set. +class _MouseCursor extends WidgetStateMouseCursor { + const _MouseCursor(this.resolveCallback); + + final WidgetPropertyResolver<MouseCursor?> resolveCallback; + + @override + MouseCursor resolve(Set<WidgetState> states) => + resolveCallback(states) ?? MouseCursor.uncontrolled; + + @override + String get debugDescription => 'Menu_MouseCursor'; +} + +/// A debug print function, which should only be called within an assert, like +/// so: +/// +/// assert(_debugMenuInfo('Debug Message')); +/// +/// so that the call is entirely removed in release builds. +/// +/// Enable debug printing by setting [_kDebugMenus] to true at the top of the +/// file. +bool _debugMenuInfo(String message, [Iterable<String>? details]) { + assert(() { + if (_kDebugMenus) { + debugPrint('MENU: $message'); + if (details != null && details.isNotEmpty) { + for (final String detail in details) { + debugPrint(' $detail'); + } + } + } + return true; + }()); + // Return true so that it can be easily used inside of an assert. + return true; +} + +/// Whether [defaultTargetPlatform] is an Apple platform (Mac or iOS). +bool get _isCupertino { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return true; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + } +} + +/// Whether [defaultTargetPlatform] is one that uses symbolic shortcuts. +/// +/// Mac and iOS use special symbols for modifier keys instead of their names, +/// render them in a particular order defined by Apple's human interface +/// guidelines, and format them so that the modifier keys always align. +bool get _usesSymbolicModifiers { + return _isCupertino; +} + +bool get _platformSupportsAccelerators { + // On iOS and macOS, pressing the Option key (a.k.a. the Alt key) causes a + // different set of characters to be generated, and the native menus don't + // support accelerators anyhow, so we just disable accelerators on these + // platforms. + return !_isCupertino; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Menu + +// 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. + +// dart format off +class _MenuBarDefaultsM3 extends MenuStyle { + _MenuBarDefaultsM3(this.context) + : super( + elevation: const MaterialStatePropertyAll<double?>(3.0), + shape: const MaterialStatePropertyAll<OutlinedBorder>(_defaultMenuBorder), + alignment: AlignmentDirectional.bottomStart, + ); + + static const RoundedRectangleBorder _defaultMenuBorder = + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + + final BuildContext context; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty<Color?> get backgroundColor { + return MaterialStatePropertyAll<Color?>(_colors.surfaceContainer); + } + + @override + WidgetStateProperty<Color?>? get shadowColor { + return MaterialStatePropertyAll<Color?>(_colors.shadow); + } + + @override + WidgetStateProperty<Color?>? get surfaceTintColor { + return const MaterialStatePropertyAll<Color?>(Colors.transparent); + } + + @override + WidgetStateProperty<EdgeInsetsGeometry?>? get padding { + return const MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.symmetric( + horizontal: _kTopLevelMenuHorizontalMinPadding + ), + ); + } + + @override + VisualDensity get visualDensity => Theme.of(context).visualDensity; +} + +class _MenuButtonDefaultsM3 extends ButtonStyle { + _MenuButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: AlignmentDirectional.centerStart, + ); + + final BuildContext context; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + WidgetStateProperty<Color?>? get backgroundColor { + return ButtonStyleButton.allOrNull<Color>(Colors.transparent); + } + + // No default shadow color + + // No default surface tint color + + @override + WidgetStateProperty<double>? get elevation { + return ButtonStyleButton.allOrNull<double>(0.0); + } + + @override + WidgetStateProperty<Color?>? get foregroundColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface; + } + return _colors.onSurface; + }); + } + + @override + WidgetStateProperty<Color?>? get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant; + } + return _colors.onSurfaceVariant; + }); + } + + // No default fixedSize + + @override + WidgetStateProperty<double>? get iconSize { + return const MaterialStatePropertyAll<double>(24.0); + } + + @override + WidgetStateProperty<Size>? get maximumSize { + return ButtonStyleButton.allOrNull<Size>(Size.infinite); + } + + @override + WidgetStateProperty<Size>? get minimumSize { + return ButtonStyleButton.allOrNull<Size>(const Size(64.0, 48.0)); + } + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + WidgetStateProperty<Color?>? get overlayColor { + return WidgetStateProperty.resolveWith( + (Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return Colors.transparent; + }, + ); + } + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding { + return ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(_scaledPadding(context)); + } + + // No default side + + @override + WidgetStateProperty<OutlinedBorder>? get shape { + return ButtonStyleButton.allOrNull<OutlinedBorder>(const RoundedRectangleBorder()); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + WidgetStateProperty<TextStyle?> get textStyle { + // TODO(tahatesser): This is taken from https://m3.material.io/components/menus/specs + // Update this when the token is available. + return MaterialStatePropertyAll<TextStyle?>(_textTheme.labelLarge); + } + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + // The horizontal padding number comes from the spec. + EdgeInsetsGeometry _scaledPadding(BuildContext context) { + VisualDensity visualDensity = Theme.of(context).visualDensity; + // When horizontal VisualDensity is greater than zero, set it to zero + // because the [ButtonStyleButton] has already handle the padding based on the density. + // However, the [ButtonStyleButton] doesn't allow the [VisualDensity] adjustment + // to reduce the width of the left/right padding, so we need to handle it here if + // the density is less than zero, such as on desktop platforms. + if (visualDensity.horizontal > 0) { + visualDensity = VisualDensity(vertical: visualDensity.vertical); + } + // Since the threshold paddings used below are empirical values determined + // at a font size of 14.0, 14.0 is used as the base value for scaling the + // padding. + final double fontSize = Theme.of(context).textTheme.labelLarge?.fontSize ?? 14.0; + final double fontSizeRatio = MediaQuery.textScalerOf(context).scale(fontSize) / 14.0; + return ButtonStyleButton.scaledPadding( + EdgeInsets.symmetric(horizontal: math.max( + _kMenuViewPadding, + _kLabelItemDefaultSpacing + visualDensity.baseSizeAdjustment.dx, + )), + EdgeInsets.symmetric(horizontal: math.max( + _kMenuViewPadding, + 8 + visualDensity.baseSizeAdjustment.dx, + )), + const EdgeInsets.symmetric(horizontal: _kMenuViewPadding), + fontSizeRatio, + ); + } +} + +class _MenuDefaultsM3 extends MenuStyle { + _MenuDefaultsM3(this.context) + : super( + elevation: const MaterialStatePropertyAll<double?>(3.0), + shape: const MaterialStatePropertyAll<OutlinedBorder>(_defaultMenuBorder), + alignment: AlignmentDirectional.topEnd, + ); + + static const RoundedRectangleBorder _defaultMenuBorder = + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + + final BuildContext context; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty<Color?> get backgroundColor { + return MaterialStatePropertyAll<Color?>(_colors.surfaceContainer); + } + + @override + WidgetStateProperty<Color?>? get surfaceTintColor { + return const MaterialStatePropertyAll<Color?>(Colors.transparent); + } + + @override + WidgetStateProperty<Color?>? get shadowColor { + return MaterialStatePropertyAll<Color?>(_colors.shadow); + } + + @override + WidgetStateProperty<EdgeInsetsGeometry?>? get padding { + return const MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.symmetric(vertical: _kMenuVerticalMinPadding), + ); + } + + @override + VisualDensity get visualDensity => Theme.of(context).visualDensity; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Menu diff --git a/packages/material_ui/lib/src/menu_bar_theme.dart b/packages/material_ui/lib/src/menu_bar_theme.dart new file mode 100644 index 000000000000..1cb427a48c1b --- /dev/null +++ b/packages/material_ui/lib/src/menu_bar_theme.dart @@ -0,0 +1,106 @@ +// 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. + +/// @docImport 'menu_button_theme.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'menu_anchor.dart'; +import 'menu_style.dart'; +import 'menu_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late Widget child; +// late BuildContext context; + +/// A data class that [MenuBarTheme] uses to define the visual properties of +/// [MenuBar] widgets. +/// +/// This class defines the visual properties of [MenuBar] widgets themselves, +/// but not their submenus. Those properties are defined by [MenuThemeData] or +/// [MenuButtonThemeData] instead. +/// +/// Descendant widgets obtain the current [MenuBarThemeData] object using +/// [MenuBarTheme.of]. +/// +/// Typically, a [MenuBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.menuBarTheme]. Otherwise, [MenuTheme] can be used to +/// configure its own widget subtree. +/// +/// All [MenuBarThemeData] properties are `null` by default. If any of these +/// properties are null, the menu bar will provide its own defaults. +/// +/// See also: +/// +/// * [MenuThemeData], which describes the theme for the submenus of a +/// [MenuBar]. +/// * [MenuButtonThemeData], which describes the theme for the [MenuItemButton]s +/// in a menu. +/// * [ThemeData], which describes the overall theme for the application. +@immutable +class MenuBarThemeData extends MenuThemeData { + /// Creates a const set of properties used to configure [MenuTheme]. + const MenuBarThemeData({super.style}); + + /// Linearly interpolate between two [MenuBar] themes. + static MenuBarThemeData? lerp(MenuBarThemeData? a, MenuBarThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return MenuBarThemeData(style: MenuStyle.lerp(a?.style, b?.style, t)); + } +} + +/// An inherited widget that defines the configuration for the [MenuBar] widgets +/// in this widget's descendants. +/// +/// This class defines the visual properties of [MenuBar] widgets themselves, +/// but not their submenus. Those properties are defined by [MenuTheme] or +/// [MenuButtonTheme] instead. +/// +/// Values specified here are used for [MenuBar]'s properties that are not given +/// an explicit non-null value. +/// +/// See also: +/// * [MenuStyle], a configuration object that holds attributes of a menu, and +/// is used by this theme to define those attributes. +/// * [MenuTheme], which does the same thing for the menus created by a +/// [SubmenuButton] or [MenuAnchor]. +/// * [MenuButtonTheme], which does the same thing for the [MenuItemButton]s +/// inside of the menus. +/// * [SubmenuButton], a button that manages a submenu that uses these +/// properties. +/// * [MenuBar], a widget that creates a menu bar that can use [SubmenuButton]s. +class MenuBarTheme extends InheritedTheme { + /// Creates a theme that controls the configurations for [MenuBar] and + /// [MenuItemButton] in its widget subtree. + const MenuBarTheme({super.key, required this.data, required super.child}); + + /// The properties to set for [MenuBar] in this widget's descendants. + final MenuBarThemeData data; + + /// Returns the closest instance of this class's [data] value that encloses + /// the given context. If there is no ancestor, it returns + /// [ThemeData.menuBarTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// MenuBarThemeData theme = MenuBarTheme.of(context); + /// ``` + static MenuBarThemeData of(BuildContext context) { + final MenuBarTheme? menuBarTheme = context.dependOnInheritedWidgetOfExactType<MenuBarTheme>(); + return menuBarTheme?.data ?? Theme.of(context).menuBarTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return MenuBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(MenuBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/menu_button_theme.dart b/packages/material_ui/lib/src/menu_button_theme.dart new file mode 100644 index 000000000000..51262454ce95 --- /dev/null +++ b/packages/material_ui/lib/src/menu_button_theme.dart @@ -0,0 +1,141 @@ +// 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. + +/// @docImport 'menu_theme.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'menu_anchor.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// A [ButtonStyle] theme that overrides the default appearance of +/// [SubmenuButton]s and [MenuItemButton]s. +/// +/// Descendant widgets obtain the current [MenuButtonThemeData] object +/// using [MenuButtonTheme.of]. +/// +/// A [MenuButtonThemeData] is often specified as part of the +/// overall [Theme] with [ThemeData.menuButtonTheme]. +/// +/// The [style]'s properties override [MenuItemButton]'s and [SubmenuButton]'s +/// default style, i.e. the [ButtonStyle] returned by +/// [MenuItemButton.defaultStyleOf] and [SubmenuButton.defaultStyleOf]. Only the +/// style's non-null property values or resolved non-null +/// [WidgetStateProperty] values are used. +/// +/// See also: +/// +/// * [MenuButtonTheme], the theme which is configured with this class. +/// * [MenuTheme], the theme used to configure the look of the menus these +/// buttons reside in. +/// * [MenuItemButton.defaultStyleOf] and [SubmenuButton.defaultStyleOf] which +/// return the default [ButtonStyle]s for menu buttons. +/// * [MenuItemButton.styleFrom] and [SubmenuButton.styleFrom], which converts +/// simple values into a [ButtonStyle] that's consistent with their respective +/// defaults. +/// * [WidgetStateProperty.resolve], "resolve" a material state property to a +/// simple value based on a set of [WidgetState]s. +/// * [ThemeData.menuButtonTheme], which can be used to override the default +/// [ButtonStyle] for [MenuItemButton]s and [SubmenuButton]s below the overall +/// [Theme]. +/// * [MenuAnchor], a widget which hosts cascading menus. +/// * [MenuBar], a widget which defines a menu bar of buttons hosting cascading +/// menus. +@immutable +class MenuButtonThemeData with Diagnosticable { + /// Creates a [MenuButtonThemeData]. + /// + /// The [style] may be null. + const MenuButtonThemeData({this.style}); + + /// Overrides for [SubmenuButton] and [MenuItemButton]'s default style. + /// + /// Non-null properties or non-null resolved [WidgetStateProperty] values + /// override the [ButtonStyle] returned by [SubmenuButton.defaultStyleOf] or + /// [MenuItemButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Linearly interpolate between two menu button themes. + static MenuButtonThemeData? lerp(MenuButtonThemeData? a, MenuButtonThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return MenuButtonThemeData(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 MenuButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [MenuItemButton] and +/// [SubmenuButton] descendants. +/// +/// See also: +/// +/// * [MenuButtonThemeData], which is used to configure this theme. +/// * [MenuTheme], the theme used to configure the look of the menus themselves. +/// * [MenuItemButton.defaultStyleOf] and [SubmenuButton.defaultStyleOf] which +/// return the default [ButtonStyle]s for menu buttons. +/// * [MenuItemButton.styleFrom] and [SubmenuButton.styleFrom], which converts +/// simple values into a [ButtonStyle] that's consistent with their respective +/// defaults. +/// * [ThemeData.menuButtonTheme], which can be used to override the default +/// [ButtonStyle] for [MenuItemButton]s and [SubmenuButton]s below the overall +/// [Theme]. +class MenuButtonTheme extends InheritedTheme { + /// Create a [MenuButtonTheme]. + const MenuButtonTheme({super.key, required this.data, required super.child}); + + /// The configuration of this theme. + final MenuButtonThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [MenuButtonTheme] widget, then + /// [ThemeData.menuButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// MenuButtonThemeData theme = MenuButtonTheme.of(context); + /// ``` + static MenuButtonThemeData of(BuildContext context) { + final MenuButtonTheme? buttonTheme = context + .dependOnInheritedWidgetOfExactType<MenuButtonTheme>(); + return buttonTheme?.data ?? Theme.of(context).menuButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return MenuButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(MenuButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/menu_style.dart b/packages/material_ui/lib/src/menu_style.dart new file mode 100644 index 000000000000..31201cfead53 --- /dev/null +++ b/packages/material_ui/lib/src/menu_style.dart @@ -0,0 +1,417 @@ +// 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. + +/// @docImport 'ink_well.dart'; +/// @docImport 'material.dart'; +/// @docImport 'menu_button_theme.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'menu_anchor.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late Widget child; +// late BuildContext context; +// late MenuStyle style; +// @immutable +// class MyAppHome extends StatelessWidget { +// const MyAppHome({super.key}); +// @override +// Widget build(BuildContext context) => const SizedBox(); +// } + +/// The visual properties that menus have in common. +/// +/// Menus created by [MenuBar] and [MenuAnchor] and their themes have a +/// [MenuStyle] property which defines the visual properties whose default +/// values are to be overridden. The default values are defined by the +/// individual menu widgets and are typically based on overall theme's +/// [ThemeData.colorScheme] and [ThemeData.textTheme]. +/// +/// All of the [MenuStyle] properties are null by default. +/// +/// Many of the [MenuStyle] properties are [WidgetStateProperty] objects which +/// resolve to different values depending on the menu's state. For example the +/// [Color] properties are defined with `WidgetStateProperty<Color>` and can +/// resolve to different colors depending on if the menu is pressed, hovered, +/// focused, disabled, etc. +/// +/// These properties can override the default value for just one state or all of +/// them. For example to create a [SubmenuButton] whose background color is the +/// color scheme’s primary color with 50% opacity, but only when the menu is +/// pressed, one could write: +/// +/// ```dart +/// SubmenuButton( +/// menuStyle: MenuStyle( +/// backgroundColor: WidgetStateProperty.resolveWith<Color?>( +/// (Set<WidgetState> states) { +/// if (states.contains(WidgetState.focused)) { +/// return Theme.of(context).colorScheme.primary.withValues(alpha: 0.5); +/// } +/// return null; // Use the component's default. +/// }, +/// ), +/// ), +/// menuChildren: const <Widget>[ /* ... */ ], +/// child: const Text('Fly me to the moon'), +/// ), +/// ``` +/// +/// In this case the background color for all other menu states would fall back +/// to the [SubmenuButton]'s default values. To unconditionally set the menu's +/// [backgroundColor] for all states one could write: +/// +/// ```dart +/// const SubmenuButton( +/// menuStyle: MenuStyle( +/// backgroundColor: WidgetStatePropertyAll<Color>(Colors.green), +/// ), +/// menuChildren: <Widget>[ /* ... */ ], +/// child: Text('Let me play among the stars'), +/// ), +/// ``` +/// +/// To configure all of the application's menus in the same way, specify the +/// overall theme's `menuTheme`: +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// menuTheme: const MenuThemeData( +/// style: MenuStyle(backgroundColor: WidgetStatePropertyAll<Color>(Colors.red)), +/// ), +/// ), +/// home: const MyAppHome(), +/// ), +/// ``` +/// +/// See also: +/// +/// * [MenuAnchor], a widget which hosts cascading menus. +/// * [MenuBar], a widget which defines a menu bar of buttons hosting cascading +/// menus. +/// * [MenuButtonTheme], the theme for [SubmenuButton]s and [MenuItemButton]s. +/// * [ButtonStyle], a similar configuration object for button styles. +@immutable +class MenuStyle with Diagnosticable { + /// Create a [MenuStyle]. + const MenuStyle({ + this.backgroundColor, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.padding, + this.minimumSize, + this.fixedSize, + this.maximumSize, + this.side, + this.shape, + this.mouseCursor, + this.visualDensity, + this.alignment, + }); + + /// The menu's background fill color. + final WidgetStateProperty<Color?>? backgroundColor; + + /// The shadow color of the menu's [Material]. + /// + /// The material's elevation shadow can be difficult to see for dark themes, + /// so by default the menu classes add a semi-transparent overlay to indicate + /// elevation. See [ThemeData.applyElevationOverlayColor]. + final WidgetStateProperty<Color?>? shadowColor; + + /// The surface tint color of the menu's [Material]. + /// + /// See [Material.surfaceTintColor] for more details. + final WidgetStateProperty<Color?>? surfaceTintColor; + + /// The elevation of the menu's [Material]. + final WidgetStateProperty<double?>? elevation; + + /// The padding between the menu's boundary and its child. + final WidgetStateProperty<EdgeInsetsGeometry?>? padding; + + /// The minimum size of the menu itself. + /// + /// This value must be less than or equal to [maximumSize]. + final WidgetStateProperty<Size?>? minimumSize; + + /// The menu's size. + /// + /// This size is still constrained by the style's [minimumSize] and + /// [maximumSize]. Fixed size dimensions whose value is [double.infinity] are + /// ignored. + /// + /// To specify menus with a fixed width and the default height use `fixedSize: + /// Size.fromWidth(320)`. Similarly, to specify a fixed height and the default + /// width use `fixedSize: Size.fromHeight(100)`. + final WidgetStateProperty<Size?>? fixedSize; + + /// The maximum size of the menu itself. + /// + /// A [Size.infinite] or null value for this property means that the menu's + /// maximum size is not constrained. + /// + /// This value must be greater than or equal to [minimumSize]. + final WidgetStateProperty<Size?>? maximumSize; + + /// The color and weight of the menu's outline. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. + final WidgetStateProperty<BorderSide?>? side; + + /// The shape of the menu's underlying [Material]. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. + final WidgetStateProperty<OutlinedBorder?>? shape; + + /// The cursor for a mouse pointer when it enters or is hovering over this + /// menu's [InkWell]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Defines how compact the menu's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// Determines the desired alignment of the submenu when opened relative to + /// the button that opens it. + /// + /// If there isn't sufficient space to open the menu with the given alignment, + /// and there's space on the other side of the button, then the alignment is + /// swapped to it's opposite (1 becomes -1, etc.), and the menu will try to + /// appear on the other side of the button. If there isn't enough space there + /// either, then the menu will be pushed as far over as necessary to display + /// as much of itself as possible, possibly overlapping the parent button. + final AlignmentGeometry? alignment; + + @override + int get hashCode { + final values = <Object?>[ + backgroundColor, + shadowColor, + surfaceTintColor, + elevation, + padding, + minimumSize, + fixedSize, + maximumSize, + side, + shape, + mouseCursor, + visualDensity, + alignment, + ]; + return Object.hashAll(values); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is MenuStyle && + other.backgroundColor == backgroundColor && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.elevation == elevation && + other.padding == padding && + other.minimumSize == minimumSize && + other.fixedSize == fixedSize && + other.maximumSize == maximumSize && + other.side == side && + other.shape == shape && + other.mouseCursor == mouseCursor && + other.visualDensity == visualDensity && + other.alignment == alignment; + } + + /// Returns a copy of this MenuStyle with the given fields replaced with + /// the new values. + MenuStyle copyWith({ + WidgetStateProperty<Color?>? backgroundColor, + WidgetStateProperty<Color?>? shadowColor, + WidgetStateProperty<Color?>? surfaceTintColor, + WidgetStateProperty<double?>? elevation, + WidgetStateProperty<EdgeInsetsGeometry?>? padding, + WidgetStateProperty<Size?>? minimumSize, + WidgetStateProperty<Size?>? fixedSize, + WidgetStateProperty<Size?>? maximumSize, + WidgetStateProperty<BorderSide?>? side, + WidgetStateProperty<OutlinedBorder?>? shape, + WidgetStateProperty<MouseCursor?>? mouseCursor, + VisualDensity? visualDensity, + AlignmentGeometry? alignment, + }) { + return MenuStyle( + backgroundColor: backgroundColor ?? this.backgroundColor, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + elevation: elevation ?? this.elevation, + padding: padding ?? this.padding, + minimumSize: minimumSize ?? this.minimumSize, + fixedSize: fixedSize ?? this.fixedSize, + maximumSize: maximumSize ?? this.maximumSize, + side: side ?? this.side, + shape: shape ?? this.shape, + mouseCursor: mouseCursor ?? this.mouseCursor, + visualDensity: visualDensity ?? this.visualDensity, + alignment: alignment ?? this.alignment, + ); + } + + /// Returns a copy of this MenuStyle where the non-null fields in [style] + /// have replaced the corresponding null fields in this MenuStyle. + /// + /// In other words, [style] is used to fill in unspecified (null) fields + /// this MenuStyle. + MenuStyle merge(MenuStyle? style) { + if (style == null) { + return this; + } + return copyWith( + backgroundColor: backgroundColor ?? style.backgroundColor, + shadowColor: shadowColor ?? style.shadowColor, + surfaceTintColor: surfaceTintColor ?? style.surfaceTintColor, + elevation: elevation ?? style.elevation, + padding: padding ?? style.padding, + minimumSize: minimumSize ?? style.minimumSize, + fixedSize: fixedSize ?? style.fixedSize, + maximumSize: maximumSize ?? style.maximumSize, + side: side ?? style.side, + shape: shape ?? style.shape, + mouseCursor: mouseCursor ?? style.mouseCursor, + visualDensity: visualDensity ?? style.visualDensity, + alignment: alignment ?? style.alignment, + ); + } + + /// Linearly interpolate between two [MenuStyle]s. + static MenuStyle? lerp(MenuStyle? a, MenuStyle? b, double t) { + if (identical(a, b)) { + return a; + } + return MenuStyle( + backgroundColor: WidgetStateProperty.lerp<Color?>( + a?.backgroundColor, + b?.backgroundColor, + t, + Color.lerp, + ), + shadowColor: WidgetStateProperty.lerp<Color?>(a?.shadowColor, b?.shadowColor, t, Color.lerp), + surfaceTintColor: WidgetStateProperty.lerp<Color?>( + a?.surfaceTintColor, + b?.surfaceTintColor, + t, + Color.lerp, + ), + elevation: WidgetStateProperty.lerp<double?>(a?.elevation, b?.elevation, t, lerpDouble), + padding: WidgetStateProperty.lerp<EdgeInsetsGeometry?>( + a?.padding, + b?.padding, + t, + EdgeInsetsGeometry.lerp, + ), + minimumSize: WidgetStateProperty.lerp<Size?>(a?.minimumSize, b?.minimumSize, t, Size.lerp), + fixedSize: WidgetStateProperty.lerp<Size?>(a?.fixedSize, b?.fixedSize, t, Size.lerp), + maximumSize: WidgetStateProperty.lerp<Size?>(a?.maximumSize, b?.maximumSize, t, Size.lerp), + side: WidgetStateBorderSide.lerp(a?.side, b?.side, t), + shape: WidgetStateProperty.lerp<OutlinedBorder?>(a?.shape, b?.shape, t, OutlinedBorder.lerp), + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity, + alignment: AlignmentGeometry.lerp(a?.alignment, b?.alignment, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'backgroundColor', + backgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'shadowColor', + shadowColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'surfaceTintColor', + surfaceTintColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>('elevation', elevation, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<EdgeInsetsGeometry?>>( + 'padding', + padding, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>( + 'minimumSize', + minimumSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>('fixedSize', fixedSize, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>( + 'maximumSize', + maximumSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<BorderSide?>>('side', side, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<OutlinedBorder?>>('shape', shape, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null), + ); + } +} diff --git a/packages/material_ui/lib/src/menu_theme.dart b/packages/material_ui/lib/src/menu_theme.dart new file mode 100644 index 000000000000..427433c8b5de --- /dev/null +++ b/packages/material_ui/lib/src/menu_theme.dart @@ -0,0 +1,142 @@ +// 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. + +/// @docImport 'menu_bar_theme.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'menu_anchor.dart'; +import 'menu_style.dart'; +import 'theme.dart'; + +// Examples can assume: +// late Widget child; +// late BuildContext context; + +/// Defines the configuration of the submenus created by the [SubmenuButton], +/// [MenuBar], or [MenuAnchor] widgets. +/// +/// Descendant widgets obtain the current [MenuThemeData] object using +/// [MenuTheme.of]. +/// +/// Typically, a [MenuThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.menuTheme]. Otherwise, [MenuTheme] can be used to configure +/// its own widget subtree. +/// +/// All [MenuThemeData] properties are `null` by default. If any of these +/// properties are null, the menu bar will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme for the application. +/// * [MenuBarThemeData], which describes the theme for the menu bar itself in a +/// [MenuBar] widget. +@immutable +class MenuThemeData with Diagnosticable { + /// Creates a const set of properties used to configure [MenuTheme]. + const MenuThemeData({this.style, this.submenuIcon}); + + /// The [MenuStyle] of a [SubmenuButton] menu. + /// + /// Any values not set in the [MenuStyle] will use the menu default for that + /// property. + final MenuStyle? style; + + /// If provided, the widget replaces the default [SubmenuButton] arrow icon. + /// + /// Resolves in the following states: + /// * [WidgetState.disabled]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + final WidgetStateProperty<Widget?>? submenuIcon; + + /// Linearly interpolate between two menu button themes. + static MenuThemeData? lerp(MenuThemeData? a, MenuThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return MenuThemeData( + style: MenuStyle.lerp(a?.style, b?.style, t), + submenuIcon: t < 0.5 ? a?.submenuIcon : b?.submenuIcon, + ); + } + + @override + int get hashCode => Object.hash(style, submenuIcon); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is MenuThemeData && other.style == style && other.submenuIcon == submenuIcon; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<MenuStyle>('style', style, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Widget?>>( + 'submenuIcon', + submenuIcon, + defaultValue: null, + ), + ); + } +} + +/// An inherited widget that defines the configuration in this widget's +/// descendants for menus created by the [SubmenuButton], [MenuBar], or +/// [MenuAnchor] widgets. +/// +/// Values specified here are used for [SubmenuButton]'s menu properties that +/// are not given an explicit non-null value. +/// +/// See also: +/// +/// * [MenuThemeData], a configuration object that holds attributes of a menu +/// used by this theme. +/// * [MenuBarTheme], which does the same thing for the [MenuBar] widget. +/// * [MenuBar], a widget that manages [MenuItemButton]s. +/// * [MenuAnchor], a widget that creates a region that has a submenu. +/// * [MenuItemButton], a widget that is a selectable item in a menu bar menu. +/// * [SubmenuButton], a widget that specifies an item with a cascading submenu +/// in a [MenuBar] menu. +class MenuTheme extends InheritedTheme { + /// Creates a const theme that controls the configurations for the menus + /// created by the [SubmenuButton] or [MenuAnchor] widgets. + const MenuTheme({super.key, required this.data, required super.child}); + + /// The properties for [MenuBar] and [MenuItemButton] in this widget's + /// descendants. + final MenuThemeData data; + + /// Returns the closest instance of this class's [data] value that encloses + /// the given context. If there is no ancestor, it returns + /// [ThemeData.menuTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// MenuThemeData theme = MenuTheme.of(context); + /// ``` + static MenuThemeData of(BuildContext context) { + final MenuTheme? menuTheme = context.dependOnInheritedWidgetOfExactType<MenuTheme>(); + return menuTheme?.data ?? Theme.of(context).menuTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return MenuTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(MenuTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/mergeable_material.dart b/packages/material_ui/lib/src/mergeable_material.dart new file mode 100644 index 000000000000..ea4fa84eb741 --- /dev/null +++ b/packages/material_ui/lib/src/mergeable_material.dart @@ -0,0 +1,705 @@ +// 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. + +/// @docImport 'card.dart'; +/// @docImport 'divider_theme.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'divider.dart'; +import 'material.dart'; +import 'theme.dart'; + +/// The base type for [MaterialSlice] and [MaterialGap]. +/// +/// All [MergeableMaterialItem] objects need a [LocalKey]. +@immutable +abstract class MergeableMaterialItem { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const MergeableMaterialItem(this.key); + + /// The key for this item of the list. + /// + /// The key is used to match parts of the mergeable material from frame to + /// frame so that state is maintained appropriately even as slices are added + /// or removed. + final LocalKey key; +} + +/// A class that can be used as a child to [MergeableMaterial]. It is a slice +/// of [Material] that animates merging with other slices. +/// +/// All [MaterialSlice] objects need a [LocalKey]. +class MaterialSlice extends MergeableMaterialItem { + /// Creates a slice of [Material] that's mergeable within a + /// [MergeableMaterial]. + const MaterialSlice({required LocalKey key, required this.child, this.color}) : super(key); + + /// The contents of this slice. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// Defines the color for the slice. + /// + /// By default, the value of [color] is [ThemeData.cardColor]. + final Color? color; + + @override + String toString() { + return 'MergeableSlice(key: $key, child: $child, color: $color)'; + } +} + +/// A class that represents a gap within [MergeableMaterial]. +/// +/// All [MaterialGap] objects need a [LocalKey]. +class MaterialGap extends MergeableMaterialItem { + /// Creates a Material gap with a given size. + const MaterialGap({required LocalKey key, this.size = 16.0}) : super(key); + + /// The main axis extent of this gap. For example, if the [MergeableMaterial] + /// is vertical, then this is the height of the gap. + final double size; + + @override + String toString() { + return 'MaterialGap(key: $key, child: $size)'; + } +} + +/// Displays a list of [MergeableMaterialItem] children. The list contains +/// [MaterialSlice] items whose boundaries are either "merged" with adjacent +/// items or separated by a [MaterialGap]. The [children] are distributed along +/// the given [mainAxis] in the same way as the children of a [ListBody]. When +/// the list of children changes, gaps are automatically animated open or closed +/// as needed. +/// +/// To enable this widget to correlate its list of children with the previous +/// one, each child must specify a key. +/// +/// When a new gap is added to the list of children the adjacent items are +/// animated apart. Similarly when a gap is removed the adjacent items are +/// brought back together. +/// +/// When a new slice is added or removed, the app is responsible for animating +/// the transition of the slices, while the gaps will be animated automatically. +/// +/// See also: +/// +/// * [Card], a piece of material that does not support splitting and merging +/// but otherwise looks the same. +class MergeableMaterial extends StatefulWidget { + /// Creates a mergeable Material list of items. + const MergeableMaterial({ + super.key, + this.mainAxis = Axis.vertical, + this.elevation = 2, + this.hasDividers = false, + this.children = const <MergeableMaterialItem>[], + this.dividerColor, + }); + + /// The children of the [MergeableMaterial]. + final List<MergeableMaterialItem> children; + + /// The main layout axis. + final Axis mainAxis; + + /// The z-coordinate at which to place all the [Material] slices. + /// + /// Defaults to 2, the appropriate elevation for cards. + final double elevation; + + /// Whether connected pieces of [MaterialSlice] have dividers between them. + final bool hasDividers; + + /// Defines color used for dividers if [hasDividers] is true. + /// + /// If [dividerColor] is null, then [DividerThemeData.color] is used. If that + /// is null, then [ThemeData.dividerColor] is used. + final Color? dividerColor; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty<Axis>('mainAxis', mainAxis)); + properties.add(DoubleProperty('elevation', elevation)); + } + + @override + State<MergeableMaterial> createState() => _MergeableMaterialState(); +} + +class _AnimationTuple { + _AnimationTuple({ + required this.controller, + required this.startAnimation, + required this.endAnimation, + required this.gapAnimation, + }) { + assert(debugMaybeDispatchCreated('material', '_AnimationTuple', this)); + } + + final AnimationController controller; + final CurvedAnimation startAnimation; + final CurvedAnimation endAnimation; + final CurvedAnimation gapAnimation; + double gapStart = 0.0; + + @mustCallSuper + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + controller.dispose(); + startAnimation.dispose(); + endAnimation.dispose(); + gapAnimation.dispose(); + } +} + +class _MergeableMaterialState extends State<MergeableMaterial> with TickerProviderStateMixin { + late List<MergeableMaterialItem> _children; + final Map<LocalKey, _AnimationTuple?> _animationTuples = <LocalKey, _AnimationTuple?>{}; + + @override + void initState() { + super.initState(); + _children = List<MergeableMaterialItem>.of(widget.children); + + for (var i = 0; i < _children.length; i += 1) { + final MergeableMaterialItem child = _children[i]; + if (child is MaterialGap) { + _initGap(child); + _animationTuples[child.key]!.controller.value = 1.0; // Gaps are initially full-sized. + } + } + assert(_debugGapsAreValid(_children)); + } + + void _initGap(MaterialGap gap) { + final controller = AnimationController(duration: kThemeAnimationDuration, vsync: this); + + final startAnimation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + final endAnimation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + final gapAnimation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + + controller.addListener(_handleTick); + + _animationTuples[gap.key] = _AnimationTuple( + controller: controller, + startAnimation: startAnimation, + endAnimation: endAnimation, + gapAnimation: gapAnimation, + ); + } + + @override + void dispose() { + for (final MergeableMaterialItem child in _children) { + if (child is MaterialGap) { + _animationTuples[child.key]!.dispose(); + } + } + super.dispose(); + } + + void _handleTick() { + setState(() { + // The animation's state is our build state, and it changed already. + }); + } + + bool _debugHasConsecutiveGaps(List<MergeableMaterialItem> children) { + for (var i = 0; i < widget.children.length - 1; i += 1) { + if (widget.children[i] is MaterialGap && widget.children[i + 1] is MaterialGap) { + return true; + } + } + return false; + } + + bool _debugGapsAreValid(List<MergeableMaterialItem> children) { + // Check for consecutive gaps. + if (_debugHasConsecutiveGaps(children)) { + return false; + } + + // First and last children must not be gaps. + if (children.isNotEmpty) { + if (children.first is MaterialGap || children.last is MaterialGap) { + return false; + } + } + + return true; + } + + void _insertChild(int index, MergeableMaterialItem child) { + _children.insert(index, child); + + if (child is MaterialGap) { + _initGap(child); + } + } + + void _removeChild(int index) { + final MergeableMaterialItem child = _children.removeAt(index); + + if (child is MaterialGap) { + _animationTuples[child.key]!.dispose(); + _animationTuples[child.key] = null; + } + } + + bool _isClosingGap(int index) { + if (index < _children.length - 1 && _children[index] is MaterialGap) { + return _animationTuples[_children[index].key]!.controller.status == AnimationStatus.reverse; + } + + return false; + } + + void _removeEmptyGaps() { + for (int j = _children.length - 1; j >= 0; j -= 1) { + if (_children[j] is MaterialGap && + _animationTuples[_children[j].key]!.controller.isDismissed) { + _removeChild(j); + } + } + } + + @override + void didUpdateWidget(MergeableMaterial oldWidget) { + super.didUpdateWidget(oldWidget); + + final Set<LocalKey> oldKeys = oldWidget.children + .map<LocalKey>((MergeableMaterialItem child) => child.key) + .toSet(); + final Set<LocalKey> newKeys = widget.children + .map<LocalKey>((MergeableMaterialItem child) => child.key) + .toSet(); + final Set<LocalKey> newOnly = newKeys.difference(oldKeys); + final Set<LocalKey> oldOnly = oldKeys.difference(newKeys); + + final List<MergeableMaterialItem> newChildren = widget.children; + var i = 0; + var j = 0; + + assert(_debugGapsAreValid(newChildren)); + + _removeEmptyGaps(); + + while (i < newChildren.length && j < _children.length) { + if (newOnly.contains(newChildren[i].key) || oldOnly.contains(_children[j].key)) { + final startNew = i; + final startOld = j; + + // Skip new keys. + while (newOnly.contains(newChildren[i].key)) { + i += 1; + } + + // Skip old keys. + while (oldOnly.contains(_children[j].key) || _isClosingGap(j)) { + j += 1; + } + + final int newLength = i - startNew; + final int oldLength = j - startOld; + + if (newLength > 0) { + if (oldLength > 1 || oldLength == 1 && _children[startOld] is MaterialSlice) { + if (newLength == 1 && newChildren[startNew] is MaterialGap) { + // Shrink all gaps into the size of the new one. + var gapSizeSum = 0.0; + + while (startOld < j) { + final MergeableMaterialItem child = _children[startOld]; + if (child is MaterialGap) { + final MaterialGap gap = child; + gapSizeSum += gap.size; + } + + _removeChild(startOld); + j -= 1; + } + + _insertChild(startOld, newChildren[startNew]); + _animationTuples[newChildren[startNew].key]! + ..gapStart = gapSizeSum + ..controller.forward(); + + j += 1; + } else { + // No animation if replaced items are more than one. + for (var k = 0; k < oldLength; k += 1) { + _removeChild(startOld); + } + for (var k = 0; k < newLength; k += 1) { + _insertChild(startOld + k, newChildren[startNew + k]); + } + + j += newLength - oldLength; + } + } else if (oldLength == 1) { + if (newLength == 1 && + newChildren[startNew] is MaterialGap && + _children[startOld].key == newChildren[startNew].key) { + /// Special case: gap added back. + _animationTuples[newChildren[startNew].key]!.controller.forward(); + } else { + final double gapSize = _getGapSize(startOld); + + _removeChild(startOld); + + for (var k = 0; k < newLength; k += 1) { + _insertChild(startOld + k, newChildren[startNew + k]); + } + + j += newLength - 1; + var gapSizeSum = 0.0; + + for (var k = startNew; k < i; k += 1) { + final MergeableMaterialItem newChild = newChildren[k]; + if (newChild is MaterialGap) { + gapSizeSum += newChild.size; + } + } + + // All gaps get proportional sizes of the original gap and they will + // animate to their actual size. + for (var k = startNew; k < i; k += 1) { + final MergeableMaterialItem newChild = newChildren[k]; + if (newChild is MaterialGap) { + _animationTuples[newChild.key]!.gapStart = gapSize * newChild.size / gapSizeSum; + _animationTuples[newChild.key]!.controller + ..value = 0.0 + ..forward(); + } + } + } + } else { + // Grow gaps. + for (var k = 0; k < newLength; k += 1) { + final MergeableMaterialItem newChild = newChildren[startNew + k]; + + _insertChild(startOld + k, newChild); + + if (newChild is MaterialGap) { + _animationTuples[newChild.key]!.controller.forward(); + } + } + + j += newLength; + } + } else { + // If more than a gap disappeared, just remove slices and shrink gaps. + if (oldLength > 1 || oldLength == 1 && _children[startOld] is MaterialSlice) { + var gapSizeSum = 0.0; + + while (startOld < j) { + final MergeableMaterialItem child = _children[startOld]; + if (child is MaterialGap) { + gapSizeSum += child.size; + } + + _removeChild(startOld); + j -= 1; + } + + if (gapSizeSum != 0.0) { + final gap = MaterialGap(key: UniqueKey(), size: gapSizeSum); + _insertChild(startOld, gap); + _animationTuples[gap.key]!.gapStart = 0.0; + _animationTuples[gap.key]!.controller + ..value = 1.0 + ..reverse(); + + j += 1; + } + } else if (oldLength == 1) { + // Shrink gap. + final gap = _children[startOld] as MaterialGap; + _animationTuples[gap.key]!.gapStart = 0.0; + _animationTuples[gap.key]!.controller.reverse(); + } + } + } else { + // Check whether the items are the same type. If they are, it means that + // their places have been swapped. + if ((_children[j] is MaterialGap) == (newChildren[i] is MaterialGap)) { + _children[j] = newChildren[i]; + + i += 1; + j += 1; + } else { + // This is a closing gap which we need to skip. + assert(_children[j] is MaterialGap); + j += 1; + } + } + } + + // Handle remaining items. + while (j < _children.length) { + _removeChild(j); + } + while (i < newChildren.length) { + final MergeableMaterialItem newChild = newChildren[i]; + _insertChild(j, newChild); + + if (newChild is MaterialGap) { + _animationTuples[newChild.key]!.controller.forward(); + } + + i += 1; + j += 1; + } + } + + BorderRadius _borderRadius(int index, bool start, bool end) { + assert( + kMaterialEdges[MaterialType.card]!.topLeft == kMaterialEdges[MaterialType.card]!.topRight, + ); + assert( + kMaterialEdges[MaterialType.card]!.topLeft == kMaterialEdges[MaterialType.card]!.bottomLeft, + ); + assert( + kMaterialEdges[MaterialType.card]!.topLeft == kMaterialEdges[MaterialType.card]!.bottomRight, + ); + final Radius cardRadius = kMaterialEdges[MaterialType.card]!.topLeft; + + Radius startRadius = Radius.zero; + Radius endRadius = Radius.zero; + + if (index > 0 && _children[index - 1] is MaterialGap) { + startRadius = Radius.lerp( + Radius.zero, + cardRadius, + _animationTuples[_children[index - 1].key]!.startAnimation.value, + )!; + } + if (index < _children.length - 2 && _children[index + 1] is MaterialGap) { + endRadius = Radius.lerp( + Radius.zero, + cardRadius, + _animationTuples[_children[index + 1].key]!.endAnimation.value, + )!; + } + + if (widget.mainAxis == Axis.vertical) { + return BorderRadius.vertical( + top: start ? cardRadius : startRadius, + bottom: end ? cardRadius : endRadius, + ); + } else { + return BorderRadius.horizontal( + left: start ? cardRadius : startRadius, + right: end ? cardRadius : endRadius, + ); + } + } + + double _getGapSize(int index) { + final gap = _children[index] as MaterialGap; + + return lerpDouble( + _animationTuples[gap.key]!.gapStart, + gap.size, + _animationTuples[gap.key]!.gapAnimation.value, + )!; + } + + bool _willNeedDivider(int index) { + if (index < 0) { + return false; + } + if (index >= _children.length) { + return false; + } + return _children[index] is MaterialSlice || _isClosingGap(index); + } + + @override + Widget build(BuildContext context) { + _removeEmptyGaps(); + + final widgets = <Widget>[]; + var slices = <Widget>[]; + int i; + + for (i = 0; i < _children.length; i += 1) { + if (_children[i] is MaterialGap) { + assert(slices.isNotEmpty); + widgets.add(ListBody(mainAxis: widget.mainAxis, children: slices)); + slices = <Widget>[]; + + widgets.add(switch (widget.mainAxis) { + Axis.horizontal => SizedBox(width: _getGapSize(i)), + Axis.vertical => SizedBox(height: _getGapSize(i)), + }); + } else { + final slice = _children[i] as MaterialSlice; + Widget child = slice.child; + + if (widget.hasDividers) { + final bool hasTopDivider = _willNeedDivider(i - 1); + final bool hasBottomDivider = _willNeedDivider(i + 1); + + final BorderSide divider = Divider.createBorderSide( + context, + width: + 0.5, // TODO(ianh): This probably looks terrible when the dpr isn't a power of two. + color: widget.dividerColor, + ); + + final Border border; + if (i == 0) { + border = Border(bottom: hasBottomDivider ? divider : BorderSide.none); + } else if (i == _children.length - 1) { + border = Border(top: hasTopDivider ? divider : BorderSide.none); + } else { + border = Border( + top: hasTopDivider ? divider : BorderSide.none, + bottom: hasBottomDivider ? divider : BorderSide.none, + ); + } + + child = AnimatedContainer( + key: _MergeableMaterialSliceKey(_children[i].key), + decoration: BoxDecoration(border: border), + duration: kThemeAnimationDuration, + curve: Curves.fastOutSlowIn, + child: child, + ); + } + + slices.add( + Container( + decoration: BoxDecoration( + color: (_children[i] as MaterialSlice).color ?? Theme.of(context).cardColor, + borderRadius: _borderRadius(i, i == 0, i == _children.length - 1), + ), + child: Material(type: MaterialType.transparency, child: child), + ), + ); + } + } + + if (slices.isNotEmpty) { + widgets.add(ListBody(mainAxis: widget.mainAxis, children: slices)); + slices = <Widget>[]; + } + + return _MergeableMaterialListBody( + mainAxis: widget.mainAxis, + elevation: widget.elevation, + items: _children, + children: widgets, + ); + } +} + +// The parent hierarchy can change and lead to the slice being +// rebuilt. Using a global key solves the issue. +class _MergeableMaterialSliceKey extends GlobalKey { + const _MergeableMaterialSliceKey(this.value) : super.constructor(); + + final LocalKey value; + + @override + bool operator ==(Object other) { + return other is _MergeableMaterialSliceKey && other.value == value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return '_MergeableMaterialSliceKey($value)'; + } +} + +class _MergeableMaterialListBody extends ListBody { + const _MergeableMaterialListBody({ + required super.children, + super.mainAxis, + required this.items, + required this.elevation, + }); + + final List<MergeableMaterialItem> items; + final double elevation; + + AxisDirection _getDirection(BuildContext context) { + return getAxisDirectionFromAxisReverseAndDirectionality(context, mainAxis, false); + } + + @override + RenderListBody createRenderObject(BuildContext context) { + return _RenderMergeableMaterialListBody( + axisDirection: _getDirection(context), + elevation: elevation, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderListBody renderObject) { + final materialRenderListBody = renderObject as _RenderMergeableMaterialListBody; + materialRenderListBody + ..axisDirection = _getDirection(context) + ..elevation = elevation; + } +} + +class _RenderMergeableMaterialListBody extends RenderListBody { + _RenderMergeableMaterialListBody({super.axisDirection, double elevation = 0.0}) + : _elevation = elevation; + + double get elevation => _elevation; + double _elevation; + set elevation(double value) { + if (value == _elevation) { + return; + } + _elevation = value; + markNeedsPaint(); + } + + void _paintShadows(Canvas canvas, Rect rect) { + // TODO(ianh): We should interpolate the border radii of the shadows the same way we do those of the visible Material slices. + if (elevation != 0) { + canvas.drawShadow( + Path()..addRRect(kMaterialEdges[MaterialType.card]!.toRRect(rect)), + Colors.black, + elevation, + true, // occluding object is not (necessarily) opaque + ); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + RenderBox? child = firstChild; + var index = 0; + while (child != null) { + final childParentData = child.parentData! as ListBodyParentData; + final Rect rect = (childParentData.offset + offset) & child.size; + if (index.isEven) { + _paintShadows(context.canvas, rect); + } + child = childParentData.nextSibling; + index += 1; + } + defaultPaint(context, offset); + } +} diff --git a/packages/material_ui/lib/src/motion.dart b/packages/material_ui/lib/src/motion.dart new file mode 100644 index 000000000000..b8930686f8b1 --- /dev/null +++ b/packages/material_ui/lib/src/motion.dart @@ -0,0 +1,236 @@ +// 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/animation.dart'; + +// BEGIN GENERATED TOKEN PROPERTIES - Motion + +// 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. + +// dart format off +/// The set of durations in the Material specification. +/// +/// See also: +/// +/// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) +/// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) +abstract final class Durations { + /// The short1 duration (50ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration short1 = Duration(milliseconds: 50); + + /// The short2 duration (100ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration short2 = Duration(milliseconds: 100); + + /// The short3 duration (150ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration short3 = Duration(milliseconds: 150); + + /// The short4 duration (200ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration short4 = Duration(milliseconds: 200); + + /// The medium1 duration (250ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration medium1 = Duration(milliseconds: 250); + + /// The medium2 duration (300ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration medium2 = Duration(milliseconds: 300); + + /// The medium3 duration (350ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration medium3 = Duration(milliseconds: 350); + + /// The medium4 duration (400ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration medium4 = Duration(milliseconds: 400); + + /// The long1 duration (450ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration long1 = Duration(milliseconds: 450); + + /// The long2 duration (500ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration long2 = Duration(milliseconds: 500); + + /// The long3 duration (550ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration long3 = Duration(milliseconds: 550); + + /// The long4 duration (600ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration long4 = Duration(milliseconds: 600); + + /// The extralong1 duration (700ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration extralong1 = Duration(milliseconds: 700); + + /// The extralong2 duration (800ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration extralong2 = Duration(milliseconds: 800); + + /// The extralong3 duration (900ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration extralong3 = Duration(milliseconds: 900); + + /// The extralong4 duration (1000ms) in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Duration tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#c009dec6-f29b-4503-b9f0-482af14a8bbd) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Duration extralong4 = Duration(milliseconds: 1000); +} + + +// TODO(guidezpl): Improve with description and assets, b/289870605 + +/// The set of easing curves in the Material specification. +/// +/// See also: +/// +/// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) +/// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) +/// * [Curves], for a collection of non-Material animation easing curves. +abstract final class Easing { + /// The emphasizedAccelerate easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve emphasizedAccelerate = Cubic(0.3, 0.0, 0.8, 0.15); + + /// The emphasizedDecelerate easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve emphasizedDecelerate = Cubic(0.05, 0.7, 0.1, 1.0); + + /// The linear easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve linear = Cubic(0.0, 0.0, 1.0, 1.0); + + /// The standard easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve standard = Cubic(0.2, 0.0, 0.0, 1.0); + + /// The standardAccelerate easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve standardAccelerate = Cubic(0.3, 0.0, 1.0, 1.0); + + /// The standardDecelerate easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve standardDecelerate = Cubic(0.0, 0.0, 0.0, 1.0); + + /// The legacyDecelerate easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve legacyDecelerate = Cubic(0.0, 0.0, 0.2, 1.0); + + /// The legacyAccelerate easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve legacyAccelerate = Cubic(0.4, 0.0, 1.0, 1.0); + + /// The legacy easing curve in the Material specification. + /// + /// See also: + /// + /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) + /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) + static const Curve legacy = Cubic(0.4, 0.0, 0.2, 1.0); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Motion diff --git a/packages/material_ui/lib/src/navigation_bar.dart b/packages/material_ui/lib/src/navigation_bar.dart new file mode 100644 index 000000000000..20c66878abd5 --- /dev/null +++ b/packages/material_ui/lib/src/navigation_bar.dart @@ -0,0 +1,1487 @@ +// 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. + +/// @docImport 'package:flutter/services.dart'; +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'navigation_rail.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'elevation_overlay.dart'; +import 'ink_decoration.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'navigation_bar_theme.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +const double _kIndicatorHeight = 32; +const double _kIndicatorWidth = 64; +const double _kMaxLabelTextScaleFactor = 1.3; + +// Examples can assume: +// late BuildContext context; +// late bool _isDrawerOpen; + +/// Material 3 Navigation Bar component. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=DVGYddFaLv0} +/// +/// Navigation bars offer a persistent and convenient way to switch between +/// primary destinations in an app. +/// +/// This widget does not adjust its size with the [ThemeData.visualDensity]. +/// +/// The [MediaQueryData.textScaler] does not adjust the size of this widget but +/// rather the size of the [Tooltip]s displayed on long presses of the +/// destinations. +/// +/// The style for the icons and text are not affected by parent +/// [DefaultTextStyle]s or [IconTheme]s but rather controlled by parameters or +/// the [NavigationBarThemeData]. +/// +/// This widget holds a collection of destinations (usually +/// [NavigationDestination]s). +/// +/// {@tool dartpad} +/// This example shows a [NavigationBar] as it is used within a [Scaffold] +/// widget. The [NavigationBar] has three [NavigationDestination] widgets and +/// the initial [selectedIndex] is set to index 0. The [onDestinationSelected] +/// callback changes the selected item's index and displays a corresponding +/// widget in the body of the [Scaffold]. +/// +/// ** See code in examples/api/lib/material/navigation_bar/navigation_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example showcases [NavigationBar] label behaviors. When tapping on one +/// of the label behavior options, the [labelBehavior] of the [NavigationBar] +/// will be updated. +/// +/// ** See code in examples/api/lib/material/navigation_bar/navigation_bar.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [NavigationBar] within a main [Scaffold] +/// widget that's used to control the visibility of destination pages. +/// Each destination has its own scaffold and a nested navigator that +/// provides local navigation. The example's [NavigationBar] has four +/// [NavigationDestination] widgets with different color schemes. Its +/// [onDestinationSelected] callback changes the selected +/// destination's index and displays a corresponding page with its own +/// local navigator and scaffold - all within the body of the main +/// scaffold. The destination pages are organized in a [Stack] and +/// switching destinations fades out the current page and +/// fades in the new one. Destinations that aren't visible or animating +/// are kept [Offstage]. +/// +/// ** See code in examples/api/lib/material/navigation_bar/navigation_bar.2.dart ** +/// {@end-tool} +/// See also: +/// +/// * [NavigationDestination] +/// * [BottomNavigationBar] +/// * <https://api.flutter.dev/flutter/material/NavigationDestination-class.html> +/// * <https://m3.material.io/components/navigation-bar> +class NavigationBar extends StatelessWidget { + /// Creates a Material 3 Navigation Bar component. + /// + /// The value of [destinations] must be a list of two or more + /// [NavigationDestination] values. + // TODO(goderbauer): This class cannot be const constructed, https://github.com/dart-lang/linter/issues/3366. + // ignore: prefer_const_constructors_in_immutables + NavigationBar({ + super.key, + this.animationDuration, + this.selectedIndex = 0, + required this.destinations, + this.onDestinationSelected, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.indicatorColor, + this.indicatorShape, + this.height, + this.labelBehavior, + this.overlayColor, + this.labelTextStyle, + this.labelPadding, + this.maintainBottomViewPadding = false, + }) : assert(destinations.length >= 2), + assert(0 <= selectedIndex && selectedIndex < destinations.length); + + /// Determines the transition time for each destination as it goes between + /// selected and unselected. + final Duration? animationDuration; + + /// Determines which one of the [destinations] is currently selected. + /// + /// When this is updated, the destination (from [destinations]) at + /// [selectedIndex] goes from unselected to selected. + final int selectedIndex; + + /// The list of destinations (usually [NavigationDestination]s) in this + /// [NavigationBar]. + /// + /// When [selectedIndex] is updated, the destination from this list at + /// [selectedIndex] will animate from 0 (unselected) to 1.0 (selected). When + /// the animation is increasing or completed, the destination is considered + /// selected, when the animation is decreasing or dismissed, the destination + /// is considered unselected. + final List<Widget> destinations; + + /// Called when one of the [destinations] is selected. + /// + /// This callback usually updates the int passed to [selectedIndex]. + /// + /// Upon updating [selectedIndex], the [NavigationBar] will be rebuilt. + final ValueChanged<int>? onDestinationSelected; + + /// The color of the [NavigationBar] itself. + /// + /// If null, [NavigationBarThemeData.backgroundColor] is used. If that + /// is also null, then if [ThemeData.useMaterial3] is true, the value is + /// [ColorScheme.surfaceContainer]. If that is false, the default blends [ColorScheme.surface] + /// and [ColorScheme.onSurface] using an [ElevationOverlay]. + final Color? backgroundColor; + + /// The elevation of the [NavigationBar] itself. + /// + /// If null, [NavigationBarThemeData.elevation] is used. If that + /// is also null, then if [ThemeData.useMaterial3] is true then it will + /// be 3.0 otherwise 0.0. + final double? elevation; + + /// The color used for the drop shadow to indicate elevation. + /// + /// If null, [NavigationBarThemeData.shadowColor] is used. If that + /// is also null, the default value is [Colors.transparent] which + /// indicates that no drop shadow will be displayed. + /// + /// See [Material.shadowColor] for more details on drop shadows. + final Color? shadowColor; + + /// The color used as an overlay on [backgroundColor] to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, [NavigationBarThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [Colors.transparent]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// The color of the [indicatorShape] when this destination is selected. + /// + /// If null, [NavigationBarThemeData.indicatorColor] is used. If that + /// is also null and [ThemeData.useMaterial3] is true, [ColorScheme.secondaryContainer] + /// is used. Otherwise, [ColorScheme.secondary] with an opacity of 0.24 is used. + final Color? indicatorColor; + + /// The shape of the selected indicator. + /// + /// If null, [NavigationBarThemeData.indicatorShape] is used. If that + /// is also null and [ThemeData.useMaterial3] is true, [StadiumBorder] is used. + /// Otherwise, [RoundedRectangleBorder] with a circular border radius of 16 is used. + final ShapeBorder? indicatorShape; + + /// The height of the [NavigationBar] itself. + /// + /// If this is used in [Scaffold.bottomNavigationBar] and the scaffold is + /// full-screen, the safe area padding is also added to the height + /// automatically. + /// + /// The height does not adjust with [ThemeData.visualDensity] or + /// [MediaQueryData.textScaler] as this component loses usability at + /// larger and smaller sizes due to the truncating of labels or smaller tap + /// targets. + /// + /// If null, [NavigationBarThemeData.height] is used. If that + /// is also null, the default is 80. + final double? height; + + /// Defines how the [destinations]' labels will be laid out and when they'll + /// be displayed. + /// + /// Can be used to show all labels, show only the selected label, or hide all + /// labels. + /// + /// If null, [NavigationBarThemeData.labelBehavior] is used. If that + /// is also null, the default is + /// [NavigationDestinationLabelBehavior.alwaysShow]. + final NavigationDestinationLabelBehavior? labelBehavior; + + /// The highlight color that's typically used to indicate that + /// the [NavigationDestination] is focused, hovered, or pressed. + final WidgetStateProperty<Color?>? overlayColor; + + //// The text style of the label. + /// + /// If null, [NavigationBarThemeData.labelTextStyle] is used. If that + /// is also null, the default text style is [TextTheme.labelMedium] with + /// [ColorScheme.onSurface] when the destination is selected, and + /// [ColorScheme.onSurfaceVariant] when the destination is unselected, and + /// [ColorScheme.onSurfaceVariant] with an opacity of 0.38 when the + /// destination is disabled. + /// + /// If [ThemeData.useMaterial3] is false, then the default text style is + /// [TextTheme.labelSmall] with [ColorScheme.onSurface]. + final WidgetStateProperty<TextStyle?>? labelTextStyle; + + /// The padding around the [NavigationDestination.label] widget. + /// + /// When [labelPadding] is null, [NavigationBarThemeData.labelPadding] + /// is used. If that is also null, the default padding is 4 pixels on + /// the top. + final EdgeInsetsGeometry? labelPadding; + + /// Specifies whether the underlying [SafeArea] should maintain the bottom + /// [MediaQueryData.viewPadding] instead of the bottom [MediaQueryData.padding]. + /// + /// When true, this will prevent the [NavigationBar] from shifting when opening a + /// software keyboard due to the change in the padding value, especially when the + /// app uses [SystemUiMode.edgeToEdge], which renders the system bars over the + /// application instead of outside it. + /// + /// Defaults to false. + /// + /// See also: + /// + /// * [SafeArea.maintainBottomViewPadding], which specifies whether the [SafeArea] + /// should maintain the bottom [MediaQueryData.viewPadding]. + /// * [SystemUiMode.edgeToEdge], which sets a fullscreen display with status and + /// navigation elements rendered over the application. + final bool maintainBottomViewPadding; + + VoidCallback _handleTap(int index) { + return onDestinationSelected != null ? () => onDestinationSelected!(index) : () {}; + } + + @override + Widget build(BuildContext context) { + final NavigationBarThemeData defaults = _defaultsFor(context); + + final NavigationBarThemeData navigationBarTheme = NavigationBarTheme.of(context); + final double effectiveHeight = height ?? navigationBarTheme.height ?? defaults.height!; + final NavigationDestinationLabelBehavior effectiveLabelBehavior = + labelBehavior ?? navigationBarTheme.labelBehavior ?? defaults.labelBehavior!; + + return Material( + color: backgroundColor ?? navigationBarTheme.backgroundColor ?? defaults.backgroundColor!, + elevation: elevation ?? navigationBarTheme.elevation ?? defaults.elevation!, + shadowColor: shadowColor ?? navigationBarTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: + surfaceTintColor ?? navigationBarTheme.surfaceTintColor ?? defaults.surfaceTintColor, + child: SafeArea( + maintainBottomViewPadding: maintainBottomViewPadding, + child: Semantics( + role: SemanticsRole.tabBar, + explicitChildNodes: true, + container: true, + child: SizedBox( + height: effectiveHeight, + child: Row( + children: <Widget>[ + for (int i = 0; i < destinations.length; i++) + Expanded( + child: MergeSemantics( + child: Semantics( + role: SemanticsRole.tab, + selected: i == selectedIndex, + child: _SelectableAnimatedBuilder( + duration: animationDuration ?? const Duration(milliseconds: 500), + isSelected: i == selectedIndex, + builder: (BuildContext context, Animation<double> animation) { + return _NavigationDestinationInfo( + index: i, + selectedIndex: selectedIndex, + totalNumberOfDestinations: destinations.length, + selectedAnimation: animation, + labelBehavior: effectiveLabelBehavior, + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + overlayColor: overlayColor, + onTap: _handleTap(i), + labelTextStyle: labelTextStyle, + labelPadding: labelPadding, + child: destinations[i], + ); + }, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Specifies when each [NavigationDestination]'s label should appear. +/// +/// This is used to determine the behavior of [NavigationBar]'s destinations. +enum NavigationDestinationLabelBehavior { + /// Always shows all of the labels under each navigation bar destination, + /// selected and unselected. + alwaysShow, + + /// Never shows any of the labels under the navigation bar destinations, + /// regardless of selected vs unselected. + alwaysHide, + + /// Only shows the labels of the selected navigation bar destination. + /// + /// When a destination is unselected, the label will be faded out, and the + /// icon will be centered. + /// + /// When a destination is selected, the label will fade in and the label and + /// icon will slide up so that they are both centered. + onlyShowSelected, +} + +/// A Material 3 [NavigationBar] destination. +/// +/// Displays a label below an icon. Use with [NavigationBar.destinations]. +/// +/// See also: +/// +/// * [NavigationBar], for an interactive code sample. +class NavigationDestination extends StatelessWidget { + /// Creates a navigation bar destination with an icon and a label, to be used + /// in the [NavigationBar.destinations]. + const NavigationDestination({ + super.key, + required this.icon, + this.selectedIcon, + required this.label, + this.tooltip, + this.enabled = true, + }); + + /// The [Widget] (usually an [Icon]) that's displayed for this + /// [NavigationDestination]. + /// + /// The icon will use [NavigationBarThemeData.iconTheme]. If this is + /// null, the default [IconThemeData] would use a size of 24.0 and + /// [ColorScheme.onSurface]. + final Widget icon; + + /// The optional [Widget] (usually an [Icon]) that's displayed when this + /// [NavigationDestination] is selected. + /// + /// If [selectedIcon] is non-null, the destination will fade from + /// [icon] to [selectedIcon] when this destination goes from unselected to + /// selected. + /// + /// The icon will use [NavigationBarThemeData.iconTheme] with + /// [WidgetState.selected]. If this is null, the default [IconThemeData] + /// would use a size of 24.0 and [ColorScheme.onSurface]. + final Widget? selectedIcon; + + /// The text label that appears below the icon of this + /// [NavigationDestination]. + /// + /// The accompanying [Text] widget will use [NavigationBarThemeData.labelTextStyle]. + /// If this is null, the default text style will use [TextTheme.labelMedium] with + /// [ColorScheme.onSurface] when the destination is selected and + /// [ColorScheme.onSurfaceVariant] when the destination is unselected. If + /// [ThemeData.useMaterial3] is false, then the default text style will use + /// [TextTheme.labelSmall] with [ColorScheme.onSurface]. + final String label; + + /// The text to display in the tooltip for this [NavigationDestination], when + /// the user long presses the destination. + /// + /// If [tooltip] is an empty string, no tooltip will be used. + /// + /// Defaults to null, in which case the [label] text will be used. + final String? tooltip; + + /// Indicates that this destination is selectable. + /// + /// Defaults to true. + final bool enabled; + + @override + Widget build(BuildContext context) { + final _NavigationDestinationInfo info = _NavigationDestinationInfo.of(context); + const selectedState = <WidgetState>{WidgetState.selected}; + const unselectedState = <WidgetState>{}; + const disabledState = <WidgetState>{WidgetState.disabled}; + + final NavigationBarThemeData navigationBarTheme = NavigationBarTheme.of(context); + final NavigationBarThemeData defaults = _defaultsFor(context); + final Animation<double> animation = info.selectedAnimation; + + return _NavigationDestinationBuilder( + label: label, + tooltip: tooltip, + enabled: enabled, + buildIcon: (BuildContext context) { + final IconThemeData selectedIconTheme = + navigationBarTheme.iconTheme?.resolve(selectedState) ?? + defaults.iconTheme!.resolve(selectedState)!; + final IconThemeData unselectedIconTheme = + navigationBarTheme.iconTheme?.resolve(unselectedState) ?? + defaults.iconTheme!.resolve(unselectedState)!; + final IconThemeData disabledIconTheme = + navigationBarTheme.iconTheme?.resolve(disabledState) ?? + defaults.iconTheme!.resolve(disabledState)!; + + final Widget selectedIconWidget = IconTheme.merge( + data: enabled ? selectedIconTheme : disabledIconTheme, + child: selectedIcon ?? icon, + ); + final Widget unselectedIconWidget = IconTheme.merge( + data: enabled ? unselectedIconTheme : disabledIconTheme, + child: icon, + ); + + return Stack( + alignment: Alignment.center, + children: <Widget>[ + NavigationIndicator( + animation: animation, + color: + info.indicatorColor ?? + navigationBarTheme.indicatorColor ?? + defaults.indicatorColor!, + shape: + info.indicatorShape ?? + navigationBarTheme.indicatorShape ?? + defaults.indicatorShape!, + ), + _StatusTransitionWidgetBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return animation.isForwardOrCompleted ? selectedIconWidget : unselectedIconWidget; + }, + ), + ], + ); + }, + buildLabel: (BuildContext context) { + final TextStyle? effectiveSelectedLabelTextStyle = + info.labelTextStyle?.resolve(selectedState) ?? + navigationBarTheme.labelTextStyle?.resolve(selectedState) ?? + defaults.labelTextStyle!.resolve(selectedState); + final TextStyle? effectiveUnselectedLabelTextStyle = + info.labelTextStyle?.resolve(unselectedState) ?? + navigationBarTheme.labelTextStyle?.resolve(unselectedState) ?? + defaults.labelTextStyle!.resolve(unselectedState); + final TextStyle? effectiveDisabledLabelTextStyle = + info.labelTextStyle?.resolve(disabledState) ?? + navigationBarTheme.labelTextStyle?.resolve(disabledState) ?? + defaults.labelTextStyle!.resolve(disabledState); + final EdgeInsetsGeometry labelPadding = + info.labelPadding ?? navigationBarTheme.labelPadding ?? defaults.labelPadding!; + + final textStyle = enabled + ? animation.isForwardOrCompleted + ? effectiveSelectedLabelTextStyle + : effectiveUnselectedLabelTextStyle + : effectiveDisabledLabelTextStyle; + + return Padding( + padding: labelPadding, + child: MediaQuery.withClampedTextScaling( + // Set maximum text scale factor to _kMaxLabelTextScaleFactor for the + // label to keep the visual hierarchy the same even with larger font + // sizes. To opt out, wrap the [label] widget in a [MediaQuery] widget + // with a different `TextScaler`. + maxScaleFactor: _kMaxLabelTextScaleFactor, + child: Text(label, style: textStyle), + ), + ); + }, + ); + } +} + +/// Widget that handles the semantics and layout of a navigation bar +/// destination. +/// +/// Prefer [NavigationDestination] over this widget, as it is a simpler +/// (although less customizable) way to get navigation bar destinations. +/// +/// The icon and label of this destination are built with [buildIcon] and +/// [buildLabel]. They should build the unselected and selected icon and label +/// according to [_NavigationDestinationInfo.selectedAnimation], where an +/// animation value of 0 is unselected and 1 is selected. +/// +/// See [NavigationDestination] for an example. +class _NavigationDestinationBuilder extends StatefulWidget { + /// Builds a destination (icon + label) to use in a Material 3 [NavigationBar]. + const _NavigationDestinationBuilder({ + required this.buildIcon, + required this.buildLabel, + required this.label, + this.tooltip, + this.enabled = true, + }); + + /// Builds the icon for a destination in a [NavigationBar]. + /// + /// To animate between unselected and selected, build the icon based on + /// [_NavigationDestinationInfo.selectedAnimation]. When the animation is 0, + /// the destination is unselected, when the animation is 1, the destination is + /// selected. + /// + /// The destination is considered selected as soon as the animation is + /// increasing or completed, and it is considered unselected as soon as the + /// animation is decreasing or dismissed. + final WidgetBuilder buildIcon; + + /// Builds the label for a destination in a [NavigationBar]. + /// + /// To animate between unselected and selected, build the icon based on + /// [_NavigationDestinationInfo.selectedAnimation]. When the animation is + /// 0, the destination is unselected, when the animation is 1, the destination + /// is selected. + /// + /// The destination is considered selected as soon as the animation is + /// increasing or completed, and it is considered unselected as soon as the + /// animation is decreasing or dismissed. + final WidgetBuilder buildLabel; + + /// The text value of what is in the label widget, this is required for + /// semantics so that screen readers and tooltips can read the proper label. + final String label; + + /// The text to display in the tooltip for this [NavigationDestination], when + /// the user long presses the destination. + /// + /// If [tooltip] is an empty string, no tooltip will be used. + /// + /// Defaults to null, in which case the [label] text will be used. + final String? tooltip; + + /// Indicates that this destination is selectable. + /// + /// Defaults to true. + final bool enabled; + + @override + State<_NavigationDestinationBuilder> createState() => _NavigationDestinationBuilderState(); +} + +class _NavigationDestinationBuilderState extends State<_NavigationDestinationBuilder> { + final GlobalKey iconKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final _NavigationDestinationInfo info = _NavigationDestinationInfo.of(context); + final NavigationBarThemeData navigationBarTheme = NavigationBarTheme.of(context); + final NavigationBarThemeData defaults = _defaultsFor(context); + + return _NavigationBarDestinationSemantics( + enabled: widget.enabled, + child: _NavigationBarDestinationTooltip( + message: widget.tooltip ?? widget.label, + child: _IndicatorInkWell( + iconKey: iconKey, + labelBehavior: info.labelBehavior, + customBorder: + info.indicatorShape ?? navigationBarTheme.indicatorShape ?? defaults.indicatorShape, + overlayColor: info.overlayColor ?? navigationBarTheme.overlayColor, + onTap: widget.enabled ? info.onTap : null, + child: Row( + children: <Widget>[ + Expanded( + child: _NavigationBarDestinationLayout( + icon: widget.buildIcon(context), + iconKey: iconKey, + label: widget.buildLabel(context), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _IndicatorInkWell extends InkResponse { + const _IndicatorInkWell({ + required this.iconKey, + required this.labelBehavior, + super.overlayColor, + super.customBorder, + super.onTap, + super.child, + }) : super(containedInkWell: true, highlightColor: Colors.transparent); + + final GlobalKey iconKey; + final NavigationDestinationLabelBehavior labelBehavior; + + @override + RectCallback? getRectCallback(RenderBox referenceBox) { + return () { + final iconBox = iconKey.currentContext!.findRenderObject()! as RenderBox; + final Rect iconRect = iconBox.localToGlobal(Offset.zero) & iconBox.size; + return referenceBox.globalToLocal(iconRect.topLeft) & iconBox.size; + }; + } +} + +/// Inherited widget for passing data from the [NavigationBar] to the +/// [NavigationBar.destinations] children widgets. +/// +/// Useful for building navigation destinations using: +/// `_NavigationDestinationInfo.of(context)`. +class _NavigationDestinationInfo extends InheritedWidget { + /// Adds the information needed to build a navigation destination to the + /// [child] and descendants. + const _NavigationDestinationInfo({ + required this.index, + required this.selectedIndex, + required this.totalNumberOfDestinations, + required this.selectedAnimation, + required this.labelBehavior, + required this.indicatorColor, + required this.indicatorShape, + required this.overlayColor, + required this.onTap, + this.labelTextStyle, + this.labelPadding, + required super.child, + }); + + /// Which destination index is this in the navigation bar. + /// + /// For example: + /// + /// ```dart + /// NavigationBar( + /// destinations: const <Widget>[ + /// NavigationDestination( + /// // This is destination index 0. + /// icon: Icon(Icons.surfing), + /// label: 'Surfing', + /// ), + /// NavigationDestination( + /// // This is destination index 1. + /// icon: Icon(Icons.support), + /// label: 'Support', + /// ), + /// NavigationDestination( + /// // This is destination index 2. + /// icon: Icon(Icons.local_hospital), + /// label: 'Hospital', + /// ), + /// ] + /// ) + /// ``` + /// + /// This is required for semantics, so that each destination can have a label + /// "Tab 1 of 3", for example. + final int index; + + /// This is the index of the currently selected destination. + /// + /// This is required for `_IndicatorInkWell` to apply label padding to ripple animations + /// when label behavior is [NavigationDestinationLabelBehavior.onlyShowSelected]. + final int selectedIndex; + + /// How many total destinations are in this navigation bar. + /// + /// This is required for semantics, so that each destination can have a label + /// "Tab 1 of 4", for example. + final int totalNumberOfDestinations; + + /// Indicates whether or not this destination is selected, from 0 (unselected) + /// to 1 (selected). + final Animation<double> selectedAnimation; + + /// Determines the behavior for how the labels will layout. + /// + /// Can be used to show all labels (the default), show only the selected + /// label, or hide all labels. + final NavigationDestinationLabelBehavior labelBehavior; + + /// The color of the selection indicator. + /// + /// This is used by destinations to override the indicator color. + final Color? indicatorColor; + + /// The shape of the selection indicator. + /// + /// This is used by destinations to override the indicator shape. + final ShapeBorder? indicatorShape; + + /// The highlight color that's typically used to indicate that + /// the [NavigationDestination] is focused, hovered, or pressed. + /// + /// This is used by destinations to override the overlay color. + final WidgetStateProperty<Color?>? overlayColor; + + /// The callback that should be called when this destination is tapped. + /// + /// This is computed by calling [NavigationBar.onDestinationSelected] + /// with [index] passed in. + final VoidCallback onTap; + + /// The text style of the label. + final WidgetStateProperty<TextStyle?>? labelTextStyle; + + /// The padding around the label. + /// + /// Defaults to a padding of 4 pixels on the top. + final EdgeInsetsGeometry? labelPadding; + + /// Returns a non null [_NavigationDestinationInfo]. + /// + /// This will return an error if called with no [_NavigationDestinationInfo] + /// ancestor. + /// + /// Used by widgets that are implementing a navigation destination info to + /// get information like the selected animation and destination number. + static _NavigationDestinationInfo of(BuildContext context) { + final _NavigationDestinationInfo? result = context + .dependOnInheritedWidgetOfExactType<_NavigationDestinationInfo>(); + assert( + result != null, + 'Navigation destinations need a _NavigationDestinationInfo parent, ' + 'which is usually provided by NavigationBar.', + ); + return result!; + } + + @override + bool updateShouldNotify(_NavigationDestinationInfo oldWidget) { + return index != oldWidget.index || + totalNumberOfDestinations != oldWidget.totalNumberOfDestinations || + selectedAnimation != oldWidget.selectedAnimation || + labelBehavior != oldWidget.labelBehavior || + onTap != oldWidget.onTap; + } +} + +/// Selection Indicator for the Material 3 [NavigationBar] and [NavigationRail] +/// components. +/// +/// When [animation] is 0, the indicator is not present. As [animation] grows +/// from 0 to 1, the indicator scales in on the x axis. +/// +/// Used in a [Stack] widget behind the icons in the Material 3 Navigation Bar +/// to illuminate the selected destination. +class NavigationIndicator extends StatelessWidget { + /// Builds an indicator, usually used in a stack behind the icon of a + /// navigation bar destination. + const NavigationIndicator({ + super.key, + required this.animation, + this.color, + this.width = _kIndicatorWidth, + this.height = _kIndicatorHeight, + this.borderRadius = const BorderRadius.all(Radius.circular(16)), + this.shape, + }); + + /// Determines the scale of the indicator. + /// + /// When [animation] is 0, the indicator is not present. The indicator scales + /// in as [animation] grows from 0 to 1. + final Animation<double> animation; + + /// The fill color of this indicator. + /// + /// If null, defaults to [ColorScheme.secondary]. + final Color? color; + + /// The width of this indicator. + /// + /// Defaults to `64`. + final double width; + + /// The height of this indicator. + /// + /// Defaults to `32`. + final double height; + + /// The border radius of the shape of the indicator. + /// + /// This is used to create a [RoundedRectangleBorder] shape for the indicator. + /// This is ignored if [shape] is non-null. + /// + /// Defaults to `BorderRadius.circular(16)`. + final BorderRadius borderRadius; + + /// The shape of the indicator. + /// + /// If non-null this is used as the shape used to draw the background + /// of the indicator. If null then a [RoundedRectangleBorder] with the + /// [borderRadius] is used. + final ShapeBorder? shape; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + // The scale should be 0 when the animation is unselected, as soon as + // the animation starts, the scale jumps to 40%, and then animates to + // 100% along a curve. + final double scale = animation.isDismissed + ? 0.0 + : Tween<double>(begin: .4, end: 1.0).transform( + CurveTween(curve: Curves.easeInOutCubicEmphasized).transform(animation.value), + ); + + return Transform( + alignment: Alignment.center, + // Scale in the X direction only. + transform: Matrix4.diagonal3Values(scale, 1.0, 1.0), + child: child, + ); + }, + // Fade should be a 100ms animation whenever the parent animation changes + // direction. + child: _StatusTransitionWidgetBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return _SelectableAnimatedBuilder( + isSelected: animation.isForwardOrCompleted, + duration: const Duration(milliseconds: 100), + alwaysDoFullAnimation: true, + builder: (BuildContext context, Animation<double> fadeAnimation) { + return FadeTransition( + opacity: fadeAnimation, + child: Ink( + width: width, + height: height, + decoration: ShapeDecoration( + shape: shape ?? RoundedRectangleBorder(borderRadius: borderRadius), + color: color ?? Theme.of(context).colorScheme.secondary, + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +/// Widget that handles the layout of the icon + label in a navigation bar +/// destination, based on [_NavigationDestinationInfo.labelBehavior] and +/// [_NavigationDestinationInfo.selectedAnimation]. +/// +/// Depending on the [_NavigationDestinationInfo.labelBehavior], the labels +/// will shift and fade accordingly. +class _NavigationBarDestinationLayout extends StatelessWidget { + /// Builds a widget to layout an icon + label for a destination in a Material + /// 3 [NavigationBar]. + const _NavigationBarDestinationLayout({ + required this.icon, + required this.iconKey, + required this.label, + }); + + /// The icon widget that sits on top of the label. + /// + /// See [NavigationDestination.icon]. + final Widget icon; + + /// The global key for the icon of this destination. + /// + /// This is used to determine the position of the icon. + final GlobalKey iconKey; + + /// The label widget that sits below the icon. + /// + /// This widget will sometimes be faded out, depending on + /// [_NavigationDestinationInfo.selectedAnimation]. + /// + /// See [NavigationDestination.label]. + final Widget label; + + @override + Widget build(BuildContext context) { + return _DestinationLayoutAnimationBuilder( + builder: (BuildContext context, Animation<double> animation) { + return CustomMultiChildLayout( + delegate: _NavigationDestinationLayoutDelegate(animation: animation), + children: <Widget>[ + LayoutId( + id: _NavigationDestinationLayoutDelegate.iconId, + // The key is used by the _IndicatorInkWell to query the icon position. + child: KeyedSubtree(key: iconKey, child: icon), + ), + LayoutId( + id: _NavigationDestinationLayoutDelegate.labelId, + child: FadeTransition(alwaysIncludeSemantics: true, opacity: animation, child: label), + ), + ], + ); + }, + ); + } +} + +/// Determines the appropriate [Curve] and [Animation] to use for laying out the +/// [NavigationDestination], based on +/// [_NavigationDestinationInfo.labelBehavior]. +/// +/// The animation controlling the position and fade of the labels differs +/// from the selection animation, depending on the +/// [NavigationDestinationLabelBehavior]. This widget determines what +/// animation should be used for the position and fade of the labels. +class _DestinationLayoutAnimationBuilder extends StatelessWidget { + /// Builds a child with the appropriate animation [Curve] based on the + /// [_NavigationDestinationInfo.labelBehavior]. + const _DestinationLayoutAnimationBuilder({required this.builder}); + + /// Builds the child of this widget. + /// + /// The [Animation] will be the appropriate [Animation] to use for the layout + /// and fade of the [NavigationDestination], either a curve, always + /// showing (1), or always hiding (0). + final Widget Function(BuildContext, Animation<double>) builder; + + @override + Widget build(BuildContext context) { + final _NavigationDestinationInfo info = _NavigationDestinationInfo.of(context); + switch (info.labelBehavior) { + case NavigationDestinationLabelBehavior.alwaysShow: + return builder(context, kAlwaysCompleteAnimation); + case NavigationDestinationLabelBehavior.alwaysHide: + return builder(context, kAlwaysDismissedAnimation); + case NavigationDestinationLabelBehavior.onlyShowSelected: + return _CurvedAnimationBuilder( + animation: info.selectedAnimation, + curve: Curves.easeInOutCubicEmphasized, + reverseCurve: Curves.easeInOutCubicEmphasized.flipped, + builder: builder, + ); + } + } +} + +/// Semantics widget for a navigation bar destination. +/// +/// Requires a [_NavigationDestinationInfo] parent (normally provided by the +/// [NavigationBar] by default). +/// +/// Provides localized semantic labels to the destination, for example, it will +/// read "Home, Tab 1 of 3". +/// +/// Used by [_NavigationDestinationBuilder]. +class _NavigationBarDestinationSemantics extends StatelessWidget { + /// Adds the appropriate semantics for navigation bar destinations to the + /// [child]. + const _NavigationBarDestinationSemantics({required this.enabled, required this.child}); + + /// Whether this widget is enabled. + final bool enabled; + + /// The widget that should receive the destination semantics. + final Widget child; + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final _NavigationDestinationInfo destinationInfo = _NavigationDestinationInfo.of(context); + // The AnimationStatusBuilder will make sure that the semantics update to + // "selected" when the animation status changes. + return _StatusTransitionWidgetBuilder( + animation: destinationInfo.selectedAnimation, + builder: (BuildContext context, Widget? child) { + return Semantics(enabled: enabled, button: true, child: child); + }, + child: kIsWeb + ? child + : Stack( + alignment: Alignment.center, + children: <Widget>[ + child, + Semantics( + label: localizations.tabLabel( + tabIndex: destinationInfo.index + 1, + tabCount: destinationInfo.totalNumberOfDestinations, + ), + ), + ], + ), + ); + } +} + +/// Tooltip widget for use in a [NavigationBar]. +/// +/// It appears just above the navigation bar when one of the destinations is +/// long pressed. +class _NavigationBarDestinationTooltip extends StatelessWidget { + /// Adds a tooltip to the [child] widget. + const _NavigationBarDestinationTooltip({required this.message, required this.child}); + + /// The text that is rendered in the tooltip when it appears. + final String message; + + /// The widget that, when pressed, will show a tooltip. + final Widget child; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: message, + // TODO(johnsonmh): Make this value configurable/themable. + verticalOffset: 42, + excludeFromSemantics: true, + preferBelow: false, + child: child, + ); + } +} + +/// Custom layout delegate for shifting navigation bar destinations. +/// +/// This will lay out the icon + label according to the [animation]. +/// +/// When the [animation] is 0, the icon will be centered, and the label will be +/// positioned directly below it. +/// +/// When the [animation] is 1, the label will still be positioned directly below +/// the icon, but the icon + label combination will be centered. +/// +/// Used in a [CustomMultiChildLayout] widget in the +/// [_NavigationDestinationBuilder]. +class _NavigationDestinationLayoutDelegate extends MultiChildLayoutDelegate { + _NavigationDestinationLayoutDelegate({required this.animation}) : super(relayout: animation); + + /// The selection animation that indicates whether or not this destination is + /// selected. + /// + /// See [_NavigationDestinationInfo.selectedAnimation]. + final Animation<double> animation; + + /// ID for the icon widget child. + /// + /// This is used by the [LayoutId] when this delegate is used in a + /// [CustomMultiChildLayout]. + /// + /// See [_NavigationDestinationBuilder]. + static const int iconId = 1; + + /// ID for the label widget child. + /// + /// This is used by the [LayoutId] when this delegate is used in a + /// [CustomMultiChildLayout]. + /// + /// See [_NavigationDestinationBuilder]. + static const int labelId = 2; + + @override + void performLayout(Size size) { + double halfWidth(Size size) => size.width / 2; + double halfHeight(Size size) => size.height / 2; + + final Size iconSize = layoutChild(iconId, BoxConstraints.loose(size)); + final Size labelSize = layoutChild(labelId, BoxConstraints.loose(size)); + + final double yPositionOffset = Tween<double>( + // When unselected, the icon is centered vertically. + begin: halfHeight(iconSize), + // When selected, the icon and label are centered vertically. + end: halfHeight(iconSize) + halfHeight(labelSize), + ).transform(animation.value); + final double iconYPosition = halfHeight(size) - yPositionOffset; + + // Position the icon. + positionChild( + iconId, + Offset( + // Center the icon horizontally. + halfWidth(size) - halfWidth(iconSize), + iconYPosition, + ), + ); + + // Position the label. + positionChild( + labelId, + Offset( + // Center the label horizontally. + halfWidth(size) - halfWidth(labelSize), + // Label always appears directly below the icon. + iconYPosition + iconSize.height, + ), + ); + } + + @override + bool shouldRelayout(_NavigationDestinationLayoutDelegate oldDelegate) { + return oldDelegate.animation != animation; + } +} + +/// Widget that listens to an animation, and rebuilds when the animation changes +/// [AnimationStatus]. +/// +/// This can be more efficient than just using an [AnimatedBuilder] when you +/// only need to rebuild when the [Animation.status] changes, since +/// [AnimatedBuilder] rebuilds every time the animation ticks. +class _StatusTransitionWidgetBuilder extends StatusTransitionWidget { + /// Creates a widget that rebuilds when the given animation changes status. + const _StatusTransitionWidgetBuilder({ + required super.animation, + required this.builder, + this.child, + }); + + /// Called every time the [animation] changes [AnimationStatus]. + final TransitionBuilder builder; + + /// The child widget to pass to the [builder]. + /// + /// If a [builder] callback's return value contains a subtree that does not + /// depend on the animation, it's more efficient to build that subtree once + /// instead of rebuilding it on every animation status change. + /// + /// Using this pre-built child is entirely optional, but can improve + /// performance in some cases and is therefore a good practice. + /// + /// See: [AnimatedBuilder.child] + final Widget? child; + + @override + Widget build(BuildContext context) => builder(context, child); +} + +// Builder widget for widgets that need to be animated from 0 (unselected) to +// 1.0 (selected). +// +// This widget creates and manages an [AnimationController] that it passes down +// to the child through the [builder] function. +// +// When [isSelected] is `true`, the animation controller will animate from +// 0 to 1 (for [duration] time). +// +// When [isSelected] is `false`, the animation controller will animate from +// 1 to 0 (for [duration] time). +// +// If [isSelected] is updated while the widget is animating, the animation will +// be reversed until it is either 0 or 1 again. If [alwaysDoFullAnimation] is +// true, the animation will reset to 0 or 1 before beginning the animation, so +// that the full animation is done. +// +// Usage: +// ```dart +// _SelectableAnimatedBuilder( +// isSelected: _isDrawerOpen, +// builder: (context, animation) { +// return AnimatedIcon( +// icon: AnimatedIcons.menu_arrow, +// progress: animation, +// semanticLabel: 'Show menu', +// ); +// } +// ) +// ``` +class _SelectableAnimatedBuilder extends StatefulWidget { + /// Builds and maintains an [AnimationController] that will animate from 0 to + /// 1 and back depending on when [isSelected] is true. + const _SelectableAnimatedBuilder({ + required this.isSelected, + this.duration = const Duration(milliseconds: 200), + this.alwaysDoFullAnimation = false, + required this.builder, + }); + + /// When true, the widget will animate an animation controller from 0 to 1. + /// + /// The animation controller is passed to the child widget through [builder]. + final bool isSelected; + + /// How long the animation controller should animate for when [isSelected] is + /// updated. + /// + /// If the animation is currently running and [isSelected] is updated, only + /// the [duration] left to finish the animation will be run. + final Duration duration; + + /// If true, the animation will always go all the way from 0 to 1 when + /// [isSelected] is true, and from 1 to 0 when [isSelected] is false, even + /// when the status changes mid animation. + /// + /// If this is false and the status changes mid animation, the animation will + /// reverse direction from it's current point. + /// + /// Defaults to false. + final bool alwaysDoFullAnimation; + + /// Builds the child widget based on the current animation status. + /// + /// When [isSelected] is updated to true, this builder will be called and the + /// animation will animate up to 1. When [isSelected] is updated to + /// `false`, this will be called and the animation will animate down to 0. + final Widget Function(BuildContext, Animation<double>) builder; + + @override + _SelectableAnimatedBuilderState createState() => _SelectableAnimatedBuilderState(); +} + +/// State that manages the [AnimationController] that is passed to +/// [_SelectableAnimatedBuilder.builder]. +class _SelectableAnimatedBuilderState extends State<_SelectableAnimatedBuilder> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + _controller.duration = widget.duration; + _controller.value = widget.isSelected ? 1.0 : 0.0; + } + + @override + void didUpdateWidget(_SelectableAnimatedBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.duration != widget.duration) { + _controller.duration = widget.duration; + } + if (oldWidget.isSelected != widget.isSelected) { + if (widget.isSelected) { + _controller.forward(from: widget.alwaysDoFullAnimation ? 0 : null); + } else { + _controller.reverse(from: widget.alwaysDoFullAnimation ? 1 : null); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _controller); + } +} + +/// Watches [animation] and calls [builder] with the appropriate [Curve] +/// depending on the direction of the [animation] status. +/// +/// If [Animation.status] is forward or complete, [curve] is used. If +/// [Animation.status] is reverse or dismissed, [reverseCurve] is used. +/// +/// If the [animation] changes direction while it is already running, the curve +/// used will not change, this will keep the animations smooth until it +/// completes. +/// +/// This is similar to [CurvedAnimation] except the animation status listeners +/// are removed when this widget is disposed. +class _CurvedAnimationBuilder extends StatefulWidget { + const _CurvedAnimationBuilder({ + required this.animation, + required this.curve, + required this.reverseCurve, + required this.builder, + }); + + final Animation<double> animation; + final Curve curve; + final Curve reverseCurve; + final Widget Function(BuildContext, Animation<double>) builder; + + @override + _CurvedAnimationBuilderState createState() => _CurvedAnimationBuilderState(); +} + +class _CurvedAnimationBuilderState extends State<_CurvedAnimationBuilder> { + late AnimationStatus _animationDirection; + AnimationStatus? _preservedDirection; + + @override + void initState() { + super.initState(); + _animationDirection = widget.animation.status; + _updateStatus(widget.animation.status); + widget.animation.addStatusListener(_updateStatus); + } + + @override + void dispose() { + widget.animation.removeStatusListener(_updateStatus); + super.dispose(); + } + + // Keeps track of the current animation status, as well as the "preserved + // direction" when the animation changes direction mid animation. + // + // The preserved direction is reset when the animation finishes in either + // direction. + void _updateStatus(AnimationStatus status) { + if (_animationDirection != status) { + setState(() { + _animationDirection = status; + }); + } + switch (status) { + case AnimationStatus.forward || AnimationStatus.reverse when _preservedDirection != null: + break; + case AnimationStatus.forward || AnimationStatus.reverse: + setState(() { + _preservedDirection = status; + }); + case AnimationStatus.completed || AnimationStatus.dismissed: + setState(() { + _preservedDirection = null; + }); + } + } + + @override + Widget build(BuildContext context) { + final shouldUseForwardCurve = + (_preservedDirection ?? _animationDirection) != AnimationStatus.reverse; + + final Animation<double> curvedAnimation = CurveTween( + curve: shouldUseForwardCurve ? widget.curve : widget.reverseCurve, + ).animate(widget.animation); + + return widget.builder(context, curvedAnimation); + } +} + +NavigationBarThemeData _defaultsFor(BuildContext context) { + return Theme.of(context).useMaterial3 + ? _NavigationBarDefaultsM3(context) + : _NavigationBarDefaultsM2(context); +} + +// Hand coded defaults based on Material Design 2. +class _NavigationBarDefaultsM2 extends NavigationBarThemeData { + _NavigationBarDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme, + super( + height: 80.0, + elevation: 0.0, + indicatorShape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ); + + final ThemeData _theme; + final ColorScheme _colors; + + // With Material 2, the NavigationBar uses an overlay blend for the + // default color regardless of light/dark mode. + @override + Color? get backgroundColor => + ElevationOverlay.colorWithOverlay(_colors.surface, _colors.onSurface, 3.0); + + @override + WidgetStateProperty<IconThemeData?>? get iconTheme { + return MaterialStatePropertyAll<IconThemeData>( + IconThemeData(size: 24, color: _colors.onSurface), + ); + } + + @override + Color? get indicatorColor => _colors.secondary.withOpacity(0.24); + + @override + WidgetStateProperty<TextStyle?>? get labelTextStyle => MaterialStatePropertyAll<TextStyle?>( + _theme.textTheme.labelSmall!.copyWith(color: _colors.onSurface), + ); + + @override + EdgeInsetsGeometry? get labelPadding => const EdgeInsets.only(top: 4); +} + +// BEGIN GENERATED TOKEN PROPERTIES - NavigationBar + +// 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. + +// dart format off +class _NavigationBarDefaultsM3 extends NavigationBarThemeData { + _NavigationBarDefaultsM3(this.context) + : super( + height: 80.0, + elevation: 3.0, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get backgroundColor => _colors.surfaceContainer; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + WidgetStateProperty<IconThemeData?>? get iconTheme { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + return IconThemeData( + size: 24.0, + color: states.contains(WidgetState.disabled) + ? _colors.onSurfaceVariant.withOpacity(0.38) + : states.contains(WidgetState.selected) + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant, + ); + }); + } + + @override + Color? get indicatorColor => _colors.secondaryContainer; + + @override + ShapeBorder? get indicatorShape => const StadiumBorder(); + + @override + WidgetStateProperty<TextStyle?>? get labelTextStyle { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + final TextStyle style = _textTheme.labelMedium!; + return style.apply( + color: states.contains(WidgetState.disabled) + ? _colors.onSurfaceVariant.withOpacity(0.38) + : states.contains(WidgetState.selected) + ? _colors.onSurface + : _colors.onSurfaceVariant + ); + }); + } + + @override + EdgeInsetsGeometry? get labelPadding => const EdgeInsets.only(top: 4); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - NavigationBar diff --git a/packages/material_ui/lib/src/navigation_bar_theme.dart b/packages/material_ui/lib/src/navigation_bar_theme.dart new file mode 100644 index 000000000000..284a0aa1da7e --- /dev/null +++ b/packages/material_ui/lib/src/navigation_bar_theme.dart @@ -0,0 +1,303 @@ +// 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:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'navigation_bar.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [NavigationBar] +/// widgets. +/// +/// Descendant widgets obtain the current [NavigationBarThemeData] object +/// using [NavigationBarTheme.of]. Instances of [NavigationBarThemeData] can be +/// customized with [NavigationBarThemeData.copyWith]. +/// +/// Typically a [NavigationBarThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.navigationBarTheme]. Alternatively, a +/// [NavigationBarTheme] inherited widget can be used to theme [NavigationBar]s +/// in a subtree of widgets. +/// +/// All [NavigationBarThemeData] properties are `null` by default. +/// When null, the [NavigationBar] will provide its own defaults based on the +/// overall [Theme]'s textTheme and colorScheme. See the individual +/// [NavigationBar] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class NavigationBarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.navigationBarTheme] and + /// [NavigationBarTheme]. + const NavigationBarThemeData({ + this.height, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.indicatorColor, + this.indicatorShape, + this.labelTextStyle, + this.iconTheme, + this.labelBehavior, + this.overlayColor, + this.labelPadding, + }); + + /// Overrides the default value of [NavigationBar.height]. + final double? height; + + /// Overrides the default value of [NavigationBar.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [NavigationBar.elevation]. + final double? elevation; + + /// Overrides the default value of [NavigationBar.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value of [NavigationBar.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of [NavigationBar]'s selection indicator. + final Color? indicatorColor; + + /// Overrides the default shape of the [NavigationBar]'s selection indicator. + final ShapeBorder? indicatorShape; + + /// The style to merge with the default text style for + /// [NavigationDestination] labels. + /// + /// You can use this to specify a different style when the label is selected. + final WidgetStateProperty<TextStyle?>? labelTextStyle; + + /// The theme to merge with the default icon theme for + /// [NavigationDestination] icons. + /// + /// You can use this to specify a different icon theme when the icon is + /// selected. + final WidgetStateProperty<IconThemeData?>? iconTheme; + + /// Overrides the default value of [NavigationBar.labelBehavior]. + final NavigationDestinationLabelBehavior? labelBehavior; + + /// Overrides the default value of [NavigationBar.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// Overrides the default value of [NavigationBar.labelPadding]. + final EdgeInsetsGeometry? labelPadding; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + NavigationBarThemeData copyWith({ + double? height, + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + Color? indicatorColor, + ShapeBorder? indicatorShape, + WidgetStateProperty<TextStyle?>? labelTextStyle, + WidgetStateProperty<IconThemeData?>? iconTheme, + NavigationDestinationLabelBehavior? labelBehavior, + WidgetStateProperty<Color?>? overlayColor, + EdgeInsetsGeometry? labelPadding, + }) { + return NavigationBarThemeData( + height: height ?? this.height, + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + indicatorColor: indicatorColor ?? this.indicatorColor, + indicatorShape: indicatorShape ?? this.indicatorShape, + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + iconTheme: iconTheme ?? this.iconTheme, + labelBehavior: labelBehavior ?? this.labelBehavior, + overlayColor: overlayColor ?? this.overlayColor, + labelPadding: labelPadding ?? this.labelPadding, + ); + } + + /// Linearly interpolate between two navigation rail themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static NavigationBarThemeData? lerp( + NavigationBarThemeData? a, + NavigationBarThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return NavigationBarThemeData( + height: lerpDouble(a?.height, b?.height, t), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + indicatorColor: Color.lerp(a?.indicatorColor, b?.indicatorColor, t), + indicatorShape: ShapeBorder.lerp(a?.indicatorShape, b?.indicatorShape, t), + labelTextStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.labelTextStyle, + b?.labelTextStyle, + t, + TextStyle.lerp, + ), + iconTheme: WidgetStateProperty.lerp<IconThemeData?>( + a?.iconTheme, + b?.iconTheme, + t, + IconThemeData.lerp, + ), + labelBehavior: t < 0.5 ? a?.labelBehavior : b?.labelBehavior, + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + labelPadding: EdgeInsetsGeometry.lerp(a?.labelPadding, b?.labelPadding, t), + ); + } + + @override + int get hashCode => Object.hash( + height, + backgroundColor, + elevation, + shadowColor, + surfaceTintColor, + indicatorColor, + indicatorShape, + labelTextStyle, + iconTheme, + labelBehavior, + overlayColor, + labelPadding, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is NavigationBarThemeData && + other.height == height && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.indicatorColor == indicatorColor && + other.indicatorShape == indicatorShape && + other.labelTextStyle == labelTextStyle && + other.iconTheme == iconTheme && + other.labelBehavior == labelBehavior && + other.overlayColor == overlayColor && + other.labelPadding == labelPadding; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(ColorProperty('indicatorColor', indicatorColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<ShapeBorder>('indicatorShape', indicatorShape, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'labelTextStyle', + labelTextStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<IconThemeData?>>( + 'iconTheme', + iconTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<NavigationDestinationLabelBehavior>( + 'labelBehavior', + labelBehavior, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('labelPadding', labelPadding, defaultValue: null), + ); + } +} + +/// An inherited widget that defines visual properties for [NavigationBar]s and +/// [NavigationDestination]s in this widget's subtree. +/// +/// Values specified here are used for [NavigationBar] properties that are not +/// given an explicit non-null value. +/// +/// See also: +/// +/// * [ThemeData.navigationBarTheme], which describes the +/// [NavigationBarThemeData] in the overall theme for the application. +class NavigationBarTheme extends InheritedTheme { + /// Creates a navigation rail theme that controls the + /// [NavigationBarThemeData] properties for a [NavigationBar]. + const NavigationBarTheme({super.key, required this.data, required super.child}); + + /// Specifies the background color, label text style, icon theme, and label + /// type values for descendant [NavigationBar] widgets. + final NavigationBarThemeData data; + + /// Retrieves the [NavigationBarThemeData] from the closest ancestor [NavigationBarTheme]. + /// + /// If there is no enclosing [NavigationBarTheme] widget, then + /// [ThemeData.navigationBarTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// NavigationBarThemeData theme = NavigationBarTheme.of(context); + /// ``` + static NavigationBarThemeData of(BuildContext context) { + final NavigationBarTheme? navigationBarTheme = context + .dependOnInheritedWidgetOfExactType<NavigationBarTheme>(); + return navigationBarTheme?.data ?? Theme.of(context).navigationBarTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return NavigationBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(NavigationBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/navigation_drawer.dart b/packages/material_ui/lib/src/navigation_drawer.dart new file mode 100644 index 000000000000..8a16764cb0c8 --- /dev/null +++ b/packages/material_ui/lib/src/navigation_drawer.dart @@ -0,0 +1,781 @@ +// 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. + +/// @docImport 'scaffold.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'drawer.dart'; +import 'ink_decoration.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'navigation_bar.dart'; +import 'navigation_drawer_theme.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +/// Material Design Navigation Drawer component. +/// +/// On top of [Drawer]s, Navigation drawers offer a persistent and convenient way to switch +/// between primary destinations in an app. +/// +/// The style for the icons and text are not affected by parent +/// [DefaultTextStyle]s or [IconTheme]s but rather controlled by parameters or +/// the [NavigationDrawerThemeData]. +/// +/// The [children] are a list of widgets to be displayed in the drawer. These can be a +/// mixture of any widgets, but there is special handling for [NavigationDrawerDestination]s. +/// They are treated as a group and when one is selected, the [onDestinationSelected] +/// is called with the index into the group that corresponds to the selected destination. +/// +/// {@tool dartpad} +/// This example shows a [NavigationDrawer] used within a [Scaffold] +/// widget. The [NavigationDrawer] has headline widget, divider widget and three +/// [NavigationDrawerDestination] widgets. The initial [selectedIndex] is 0. +/// The [onDestinationSelected] callback changes the selected item's index and displays +/// a corresponding widget in the body of the [Scaffold]. +/// +/// ** See code in examples/api/lib/material/navigation_drawer/navigation_drawer.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Scaffold.drawer], where one specifies a [Drawer] so that it can be +/// shown. +/// * [Scaffold.of], to obtain the current [ScaffoldState], which manages the +/// display and animation of the drawer. +/// * [ScaffoldState.openDrawer], which displays its [Drawer], if any. +/// * <https://material.io/design/components/navigation-drawer.html> +class NavigationDrawer extends StatelessWidget { + /// Creates a Material Design Navigation Drawer component. + const NavigationDrawer({ + super.key, + required this.children, + this.header, + this.footer, + this.backgroundColor, + this.shadowColor, + this.surfaceTintColor, + this.elevation, + this.indicatorColor, + this.indicatorShape, + this.onDestinationSelected, + this.selectedIndex = 0, + this.tilePadding = const EdgeInsets.symmetric(horizontal: 12.0), + }); + + /// The background color of the [Material] that holds the [NavigationDrawer]'s + /// contents. + /// + /// If this is null, then [NavigationDrawerThemeData.backgroundColor] is used. + /// If that is also null, then it falls back to [ColorScheme.surfaceContainerLow]. + final Color? backgroundColor; + + /// The color used for the drop shadow to indicate elevation. + /// + /// If null, [NavigationDrawerThemeData.shadowColor] is used. If that + /// is also null, the default value is [Colors.transparent] which + /// indicates that no drop shadow will be displayed. + /// + /// See [Material.shadowColor] for more details on drop shadows. + final Color? shadowColor; + + /// The surface tint of the [Material] that holds the [NavigationDrawer]'s + /// contents. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If this is null, then [NavigationDrawerThemeData.surfaceTintColor] is used. + /// If that is also null, the default value is [Colors.transparent]. + final Color? surfaceTintColor; + + /// The elevation of the [NavigationDrawer] itself. + /// + /// If null, [NavigationDrawerThemeData.elevation] is used. If that + /// is also null, it will be 1.0. + final double? elevation; + + /// The color of the [indicatorShape] when this destination is selected. + /// + /// If this is null, [NavigationDrawerThemeData.indicatorColor] is used. + /// If that is also null, defaults to [ColorScheme.secondaryContainer]. + final Color? indicatorColor; + + /// The shape of the selected indicator. + /// + /// If this is null, [NavigationDrawerThemeData.indicatorShape] is used. + /// If that is also null, defaults to [StadiumBorder]. + final ShapeBorder? indicatorShape; + + /// Defines the appearance of the items within the navigation drawer. + /// + /// The list contains [NavigationDrawerDestination] widgets and/or customized + /// widgets like headlines and dividers. + final List<Widget> children; + + /// A widget to display at the top of the layout. + /// + /// Typically used for titles, navigation bars, or other header content. + final Widget? header; + + /// A widget to display at the bottom of the layout. + /// + /// Typically used for actions, navigation controls, or other footer content. + final Widget? footer; + + /// The index into destinations for the current selected + /// [NavigationDrawerDestination] or null if no destination is selected. + /// + /// A valid [selectedIndex] satisfies 0 <= [selectedIndex] < number of [NavigationDrawerDestination]. + /// For an invalid [selectedIndex] like `-1`, all destinations will appear unselected. + final int? selectedIndex; + + /// Called when one of the [NavigationDrawerDestination] children is selected. + /// + /// This callback usually updates the int passed to [selectedIndex]. + /// + /// Upon updating [selectedIndex], the [NavigationDrawer] will be rebuilt. + final ValueChanged<int>? onDestinationSelected; + + /// Defines the padding for [NavigationDrawerDestination] widgets (Drawer items). + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 12.0)`. + final EdgeInsetsGeometry tilePadding; + + @override + Widget build(BuildContext context) { + final int totalNumberOfDestinations = children + .whereType<NavigationDrawerDestination>() + .toList() + .length; + + var destinationIndex = 0; + Widget wrapChild(Widget child, int index) => _SelectableAnimatedBuilder( + duration: const Duration(milliseconds: 500), + isSelected: index == selectedIndex, + builder: (BuildContext context, Animation<double> animation) { + return _NavigationDrawerDestinationInfo( + index: index, + totalNumberOfDestinations: totalNumberOfDestinations, + selectedAnimation: animation, + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + tilePadding: tilePadding, + onTap: () => onDestinationSelected?.call(index), + child: child, + ); + }, + ); + + final wrappedChildren = <Widget>[ + for (final Widget child in children) + if (child is! NavigationDrawerDestination) child else wrapChild(child, destinationIndex++), + ]; + final NavigationDrawerThemeData navigationDrawerTheme = NavigationDrawerTheme.of(context); + + return Drawer( + backgroundColor: backgroundColor ?? navigationDrawerTheme.backgroundColor, + shadowColor: shadowColor ?? navigationDrawerTheme.shadowColor, + surfaceTintColor: surfaceTintColor ?? navigationDrawerTheme.surfaceTintColor, + elevation: elevation ?? navigationDrawerTheme.elevation, + child: SafeArea( + bottom: false, + child: Column( + children: <Widget>[ + ?header, + Expanded( + child: Material( + type: MaterialType.transparency, + child: ListView(children: wrappedChildren), + ), + ), + ?footer, + ], + ), + ), + ); + } +} + +/// A Material Design [NavigationDrawer] destination. +/// +/// Displays an icon with a label, for use in [NavigationDrawer.children]. +class NavigationDrawerDestination extends StatelessWidget { + /// Creates a navigation drawer destination. + const NavigationDrawerDestination({ + super.key, + this.backgroundColor, + required this.icon, + this.selectedIcon, + required this.label, + this.enabled = true, + }); + + /// The background color of the destination. + /// + /// If this is null, no background is set and [NavigationDrawer.backgroundColor] will be visible. + /// + /// This is the background color of the whole rectangular area behind the drawer destination. + /// To customize only the indicator color consider using [NavigationDrawer.indicatorColor]. + final Color? backgroundColor; + + /// The [Widget] (usually an [Icon]) that's displayed for this + /// [NavigationDestination]. + /// + /// The icon will use [NavigationDrawerThemeData.iconTheme]. If this is + /// null, the default [IconThemeData] would use a size of 24.0 and + /// [ColorScheme.onSurfaceVariant]. + final Widget icon; + + /// The optional [Widget] (usually an [Icon]) that's displayed when this + /// [NavigationDestination] is selected. + /// + /// If [selectedIcon] is non-null, the destination will fade from + /// [icon] to [selectedIcon] when this destination goes from unselected to + /// selected. + /// + /// The icon will use [NavigationDrawerThemeData.iconTheme] with + /// [WidgetState.selected]. If this is null, the default [IconThemeData] + /// would use a size of 24.0 and [ColorScheme.onSecondaryContainer]. + final Widget? selectedIcon; + + /// The text label that appears on the right of the icon + /// + /// The accompanying [Text] widget will use + /// [NavigationDrawerThemeData.labelTextStyle]. If this are null, the default + /// text style would use [TextTheme.labelLarge] with [ColorScheme.onSurfaceVariant]. + final Widget label; + + /// Indicates that this destination is selectable. + /// + /// Defaults to true. + final bool enabled; + + @override + Widget build(BuildContext context) { + const selectedState = <WidgetState>{WidgetState.selected}; + const unselectedState = <WidgetState>{}; + const disabledState = <WidgetState>{WidgetState.disabled}; + + final NavigationDrawerThemeData navigationDrawerTheme = NavigationDrawerTheme.of(context); + final NavigationDrawerThemeData defaults = _NavigationDrawerDefaultsM3(context); + + final Animation<double> animation = _NavigationDrawerDestinationInfo.of( + context, + ).selectedAnimation; + + return _NavigationDestinationBuilder( + buildIcon: (BuildContext context) { + final Widget selectedIconWidget = IconTheme.merge( + data: + navigationDrawerTheme.iconTheme?.resolve(enabled ? selectedState : disabledState) ?? + defaults.iconTheme!.resolve(enabled ? selectedState : disabledState)!, + child: selectedIcon ?? icon, + ); + final Widget unselectedIconWidget = IconTheme.merge( + data: + navigationDrawerTheme.iconTheme?.resolve(enabled ? unselectedState : disabledState) ?? + defaults.iconTheme!.resolve(enabled ? unselectedState : disabledState)!, + child: icon, + ); + + return animation.isForwardOrCompleted ? selectedIconWidget : unselectedIconWidget; + }, + buildLabel: (BuildContext context) { + final TextStyle? effectiveSelectedLabelTextStyle = + navigationDrawerTheme.labelTextStyle?.resolve( + enabled ? selectedState : disabledState, + ) ?? + defaults.labelTextStyle!.resolve(enabled ? selectedState : disabledState); + final TextStyle? effectiveUnselectedLabelTextStyle = + navigationDrawerTheme.labelTextStyle?.resolve( + enabled ? unselectedState : disabledState, + ) ?? + defaults.labelTextStyle!.resolve(enabled ? unselectedState : disabledState); + + return DefaultTextStyle( + style: animation.isForwardOrCompleted + ? effectiveSelectedLabelTextStyle! + : effectiveUnselectedLabelTextStyle!, + child: label, + ); + }, + enabled: enabled, + backgroundColor: backgroundColor, + ); + } +} + +/// Widget that handles the semantics and layout of a navigation drawer +/// destination. +/// +/// Prefer [NavigationDestination] over this widget, as it is a simpler +/// (although less customizable) way to get navigation drawer destinations. +/// +/// The icon and label of this destination are built with [buildIcon] and +/// [buildLabel]. They should build the unselected and selected icon and label +/// according to [_NavigationDrawerDestinationInfo.selectedAnimation], where an +/// animation value of 0 is unselected and 1 is selected. +/// +/// See [NavigationDestination] for an example. +class _NavigationDestinationBuilder extends StatelessWidget { + /// Builds a destination (icon + label) to use in a Material 3 [NavigationDrawer]. + const _NavigationDestinationBuilder({ + required this.buildIcon, + required this.buildLabel, + this.enabled = true, + this.backgroundColor, + }); + + /// Builds the icon for a destination in a [NavigationDrawer]. + /// + /// To animate between unselected and selected, build the icon based on + /// [_NavigationDrawerDestinationInfo.selectedAnimation]. When the animation is 0, + /// the destination is unselected, when the animation is 1, the destination is + /// selected. + /// + /// The destination is considered selected as soon as the animation is + /// increasing or completed, and it is considered unselected as soon as the + /// animation is decreasing or dismissed. + final WidgetBuilder buildIcon; + + /// Builds the label for a destination in a [NavigationDrawer]. + /// + /// To animate between unselected and selected, build the icon based on + /// [_NavigationDrawerDestinationInfo.selectedAnimation]. When the animation is + /// 0, the destination is unselected, when the animation is 1, the destination + /// is selected. + /// + /// The destination is considered selected as soon as the animation is + /// increasing or completed, and it is considered unselected as soon as the + /// animation is decreasing or dismissed. + final WidgetBuilder buildLabel; + + /// Indicates that this destination is selectable. + /// + /// Defaults to true. + final bool enabled; + + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final _NavigationDrawerDestinationInfo info = _NavigationDrawerDestinationInfo.of(context); + final NavigationDrawerThemeData navigationDrawerTheme = NavigationDrawerTheme.of(context); + final NavigationDrawerThemeData defaults = _NavigationDrawerDefaultsM3(context); + + final inkWell = InkWell( + highlightColor: Colors.transparent, + onTap: enabled ? info.onTap : null, + customBorder: + info.indicatorShape ?? navigationDrawerTheme.indicatorShape ?? defaults.indicatorShape!, + child: Stack( + alignment: Alignment.center, + children: <Widget>[ + NavigationIndicator( + animation: info.selectedAnimation, + color: + info.indicatorColor ?? + navigationDrawerTheme.indicatorColor ?? + defaults.indicatorColor!, + shape: + info.indicatorShape ?? + navigationDrawerTheme.indicatorShape ?? + defaults.indicatorShape!, + width: (navigationDrawerTheme.indicatorSize ?? defaults.indicatorSize!).width, + height: (navigationDrawerTheme.indicatorSize ?? defaults.indicatorSize!).height, + ), + Row( + children: <Widget>[ + const SizedBox(width: 16), + buildIcon(context), + const SizedBox(width: 12), + buildLabel(context), + ], + ), + ], + ), + ); + + final Widget destination = Padding( + padding: info.tilePadding, + child: _NavigationDestinationSemantics( + child: SizedBox( + height: navigationDrawerTheme.tileHeight ?? defaults.tileHeight, + child: inkWell, + ), + ), + ); + + if (backgroundColor != null) { + return Ink(color: backgroundColor, child: destination); + } + return destination; + } +} + +/// Semantics widget for a navigation drawer destination. +/// +/// Requires a [_NavigationDrawerDestinationInfo] parent (normally provided by the +/// [NavigationDrawer] by default). +/// +/// Provides localized semantic labels to the destination, for example, it will +/// read "Home, Tab 1 of 3". +/// +/// Used by [_NavigationDestinationBuilder]. +class _NavigationDestinationSemantics extends StatelessWidget { + /// Adds the appropriate semantics for navigation drawer destinations to the + /// [child]. + const _NavigationDestinationSemantics({required this.child}); + + /// The widget that should receive the destination semantics. + final Widget child; + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final _NavigationDrawerDestinationInfo destinationInfo = _NavigationDrawerDestinationInfo.of( + context, + ); + // The AnimationStatusBuilder will make sure that the semantics update to + // "selected" when the animation status changes. + return _StatusTransitionWidgetBuilder( + animation: destinationInfo.selectedAnimation, + builder: (BuildContext context, Widget? child) { + return Semantics( + selected: destinationInfo.selectedAnimation.isForwardOrCompleted, + container: true, + child: child, + ); + }, + child: Stack( + alignment: Alignment.center, + children: <Widget>[ + child, + Semantics( + label: localizations.tabLabel( + tabIndex: destinationInfo.index + 1, + tabCount: destinationInfo.totalNumberOfDestinations, + ), + ), + ], + ), + ); + } +} + +/// Widget that listens to an animation, and rebuilds when the animation changes +/// [AnimationStatus]. +/// +/// This can be more efficient than just using an [AnimatedBuilder] when you +/// only need to rebuild when the [Animation.status] changes, since +/// [AnimatedBuilder] rebuilds every time the animation ticks. +class _StatusTransitionWidgetBuilder extends StatusTransitionWidget { + /// Creates a widget that rebuilds when the given animation changes status. + const _StatusTransitionWidgetBuilder({ + required super.animation, + required this.builder, + this.child, + }); + + /// Called every time the [animation] changes [AnimationStatus]. + final TransitionBuilder builder; + + /// The child widget to pass to the [builder]. + /// + /// If a [builder] callback's return value contains a subtree that does not + /// depend on the animation, it's more efficient to build that subtree once + /// instead of rebuilding it on every animation status change. + /// + /// Using this pre-built child is entirely optional, but can improve + /// performance in some cases and is therefore a good practice. + /// + /// See: [AnimatedBuilder.child] + final Widget? child; + + @override + Widget build(BuildContext context) => builder(context, child); +} + +/// Inherited widget for passing data from the [NavigationDrawer] to the +/// [NavigationDrawerDestination] child widgets. +/// +/// Useful for building navigation destinations using: +/// `_NavigationDrawerDestinationInfo.of(context)`. +class _NavigationDrawerDestinationInfo extends InheritedWidget { + /// Adds the information needed to build a navigation destination to the + /// [child] and descendants. + const _NavigationDrawerDestinationInfo({ + required this.index, + required this.totalNumberOfDestinations, + required this.selectedAnimation, + required this.indicatorColor, + required this.indicatorShape, + required this.onTap, + required super.child, + required this.tilePadding, + }); + + /// Which destination index is this in the navigation drawer. + /// + /// For example: + /// + /// ```dart + /// const NavigationDrawer( + /// children: <Widget>[ + /// Text('Headline'), // This doesn't have index. + /// NavigationDrawerDestination( + /// // This is destination index 0. + /// icon: Icon(Icons.surfing), + /// label: Text('Surfing'), + /// ), + /// NavigationDrawerDestination( + /// // This is destination index 1. + /// icon: Icon(Icons.support), + /// label: Text('Support'), + /// ), + /// NavigationDrawerDestination( + /// // This is destination index 2. + /// icon: Icon(Icons.local_hospital), + /// label: Text('Hospital'), + /// ), + /// ] + /// ) + /// ``` + /// + /// This is required for semantics, so that each destination can have a label + /// "Tab 1 of 3", for example. + final int index; + + /// How many total destinations are in this navigation drawer. + /// + /// This is required for semantics, so that each destination can have a label + /// "Tab 1 of 4", for example. + final int totalNumberOfDestinations; + + /// Indicates whether or not this destination is selected, from 0 (unselected) + /// to 1 (selected). + final Animation<double> selectedAnimation; + + /// The color of the indicator. + /// + /// This is used by destinations to override the indicator color. + final Color? indicatorColor; + + /// The shape of the indicator. + /// + /// This is used by destinations to override the indicator shape. + final ShapeBorder? indicatorShape; + + /// The callback that should be called when this destination is tapped. + /// + /// This is computed by calling [NavigationDrawer.onDestinationSelected] + /// with [index] passed in. + final VoidCallback onTap; + + /// Defines the padding for [NavigationDrawerDestination] widgets (Drawer items). + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 12.0)`. + final EdgeInsetsGeometry tilePadding; + + /// Returns a non null [_NavigationDrawerDestinationInfo]. + /// + /// This will return an error if called with no [_NavigationDrawerDestinationInfo] + /// ancestor. + /// + /// Used by widgets that are implementing a navigation destination info to + /// get information like the selected animation and destination number. + static _NavigationDrawerDestinationInfo of(BuildContext context) { + final _NavigationDrawerDestinationInfo? result = context + .dependOnInheritedWidgetOfExactType<_NavigationDrawerDestinationInfo>(); + assert( + result != null, + 'Navigation destinations need a _NavigationDrawerDestinationInfo parent, ' + 'which is usually provided by NavigationDrawer.', + ); + return result!; + } + + @override + bool updateShouldNotify(_NavigationDrawerDestinationInfo oldWidget) { + return index != oldWidget.index || + totalNumberOfDestinations != oldWidget.totalNumberOfDestinations || + selectedAnimation != oldWidget.selectedAnimation || + onTap != oldWidget.onTap; + } +} + +// Builder widget for widgets that need to be animated from 0 (unselected) to +// 1.0 (selected). +// +// This widget creates and manages an [AnimationController] that it passes down +// to the child through the [builder] function. +// +// When [isSelected] is `true`, the animation controller will animate from +// 0 to 1 (for [duration] time). +// +// When [isSelected] is `false`, the animation controller will animate from +// 1 to 0 (for [duration] time). +// +// If [isSelected] is updated while the widget is animating, the animation will +// be reversed until it is either 0 or 1 again. +// +// Usage: +// ```dart +// _SelectableAnimatedBuilder( +// isSelected: _isDrawerOpen, +// builder: (context, animation) { +// return AnimatedIcon( +// icon: AnimatedIcons.menu_arrow, +// progress: animation, +// semanticLabel: 'Show menu', +// ); +// } +// ) +// ``` +class _SelectableAnimatedBuilder extends StatefulWidget { + /// Builds and maintains an [AnimationController] that will animate from 0 to + /// 1 and back depending on when [isSelected] is true. + const _SelectableAnimatedBuilder({ + required this.isSelected, + this.duration = const Duration(milliseconds: 200), + required this.builder, + }); + + /// When true, the widget will animate an animation controller from 0 to 1. + /// + /// The animation controller is passed to the child widget through [builder]. + final bool isSelected; + + /// How long the animation controller should animate for when [isSelected] is + /// updated. + /// + /// If the animation is currently running and [isSelected] is updated, only + /// the [duration] left to finish the animation will be run. + final Duration duration; + + /// Builds the child widget based on the current animation status. + /// + /// When [isSelected] is updated to true, this builder will be called and the + /// animation will animate up to 1. When [isSelected] is updated to + /// `false`, this will be called and the animation will animate down to 0. + final Widget Function(BuildContext, Animation<double>) builder; + + /// + @override + _SelectableAnimatedBuilderState createState() => _SelectableAnimatedBuilderState(); +} + +/// State that manages the [AnimationController] that is passed to +/// [_SelectableAnimatedBuilder.builder]. +class _SelectableAnimatedBuilderState extends State<_SelectableAnimatedBuilder> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + _controller.duration = widget.duration; + _controller.value = widget.isSelected ? 1.0 : 0.0; + } + + @override + void didUpdateWidget(_SelectableAnimatedBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.duration != widget.duration) { + _controller.duration = widget.duration; + } + if (oldWidget.isSelected != widget.isSelected) { + if (widget.isSelected) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _controller); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - NavigationDrawer + +// 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. + +// dart format off +class _NavigationDrawerDefaultsM3 extends NavigationDrawerThemeData { + _NavigationDrawerDefaultsM3(this.context) + : super( + elevation: 1.0, + tileHeight: 56.0, + indicatorShape: const StadiumBorder(), + indicatorSize: const Size(336.0, 56.0), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get backgroundColor => _colors.surfaceContainerLow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get indicatorColor => _colors.secondaryContainer; + + @override + WidgetStateProperty<IconThemeData?>? get iconTheme { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + return IconThemeData( + size: 24.0, + color: states.contains(WidgetState.disabled) + ? _colors.onSurfaceVariant.withOpacity(0.38) + : states.contains(WidgetState.selected) + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant, + ); + }); + } + + @override + WidgetStateProperty<TextStyle?>? get labelTextStyle { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + final TextStyle style = _textTheme.labelLarge!; + return style.apply( + color: states.contains(WidgetState.disabled) + ? _colors.onSurfaceVariant.withOpacity(0.38) + : states.contains(WidgetState.selected) + ? _colors.onSecondaryContainer + : _colors.onSurfaceVariant, + ); + }); + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - NavigationDrawer diff --git a/packages/material_ui/lib/src/navigation_drawer_theme.dart b/packages/material_ui/lib/src/navigation_drawer_theme.dart new file mode 100644 index 000000000000..f26196b32989 --- /dev/null +++ b/packages/material_ui/lib/src/navigation_drawer_theme.dart @@ -0,0 +1,268 @@ +// 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. + +/// @docImport 'navigation_bar.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'navigation_drawer.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [NavigationDrawer] +/// widgets. +/// +/// Descendant widgets obtain the current [NavigationDrawerThemeData] object +/// using [NavigationDrawerTheme.of]. Instances of [NavigationDrawerThemeData] +/// can be customized with [NavigationDrawerThemeData.copyWith]. +/// +/// Typically a [NavigationDrawerThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.navigationDrawerTheme]. Alternatively, a +/// [NavigationDrawerTheme] inherited widget can be used to theme [NavigationDrawer]s +/// in a subtree of widgets. +/// +/// All [NavigationDrawerThemeData] properties are `null` by default. +/// When null, the [NavigationDrawer] will provide its own defaults based on the +/// overall [Theme]'s textTheme and colorScheme. See the individual +/// [NavigationDrawer] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class NavigationDrawerThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.navigationDrawerTheme] and + /// [NavigationDrawerTheme]. + const NavigationDrawerThemeData({ + this.tileHeight, + this.backgroundColor, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.indicatorColor, + this.indicatorShape, + this.indicatorSize, + this.labelTextStyle, + this.iconTheme, + }); + + /// Overrides the default height of [NavigationDrawerDestination]. + final double? tileHeight; + + /// Overrides the default value of [NavigationDrawer.backgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of [NavigationDrawer.elevation]. + final double? elevation; + + /// Overrides the default value of [NavigationDrawer.shadowColor]. + final Color? shadowColor; + + /// Overrides the default value of [NavigationDrawer.surfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of [NavigationDrawer]'s selection indicator. + final Color? indicatorColor; + + /// Overrides the default shape of the [NavigationDrawer]'s selection indicator. + final ShapeBorder? indicatorShape; + + /// Overrides the default size of the [NavigationDrawer]'s selection indicator. + final Size? indicatorSize; + + /// The style to merge with the default text style for + /// [NavigationDestination] labels. + /// + /// You can use this to specify a different style when the label is selected. + final WidgetStateProperty<TextStyle?>? labelTextStyle; + + /// The theme to merge with the default icon theme for + /// [NavigationDestination] icons. + /// + /// You can use this to specify a different icon theme when the icon is + /// selected. + final WidgetStateProperty<IconThemeData?>? iconTheme; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + NavigationDrawerThemeData copyWith({ + double? tileHeight, + Color? backgroundColor, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + Color? indicatorColor, + ShapeBorder? indicatorShape, + Size? indicatorSize, + WidgetStateProperty<TextStyle?>? labelTextStyle, + WidgetStateProperty<IconThemeData?>? iconTheme, + }) { + return NavigationDrawerThemeData( + tileHeight: tileHeight ?? this.tileHeight, + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + indicatorColor: indicatorColor ?? this.indicatorColor, + indicatorShape: indicatorShape ?? this.indicatorShape, + indicatorSize: indicatorSize ?? this.indicatorSize, + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + iconTheme: iconTheme ?? this.iconTheme, + ); + } + + /// Linearly interpolate between two navigation rail themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static NavigationDrawerThemeData? lerp( + NavigationDrawerThemeData? a, + NavigationDrawerThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return NavigationDrawerThemeData( + tileHeight: lerpDouble(a?.tileHeight, b?.tileHeight, t), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + indicatorColor: Color.lerp(a?.indicatorColor, b?.indicatorColor, t), + indicatorShape: ShapeBorder.lerp(a?.indicatorShape, b?.indicatorShape, t), + indicatorSize: Size.lerp(a?.indicatorSize, a?.indicatorSize, t), + labelTextStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.labelTextStyle, + b?.labelTextStyle, + t, + TextStyle.lerp, + ), + iconTheme: WidgetStateProperty.lerp<IconThemeData?>( + a?.iconTheme, + b?.iconTheme, + t, + IconThemeData.lerp, + ), + ); + } + + @override + int get hashCode => Object.hash( + tileHeight, + backgroundColor, + elevation, + shadowColor, + surfaceTintColor, + indicatorColor, + indicatorShape, + indicatorSize, + labelTextStyle, + iconTheme, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is NavigationDrawerThemeData && + other.tileHeight == tileHeight && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.indicatorColor == indicatorColor && + other.indicatorShape == indicatorShape && + other.indicatorSize == indicatorSize && + other.labelTextStyle == labelTextStyle && + other.iconTheme == iconTheme; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('tileHeight', tileHeight, defaultValue: null)); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(ColorProperty('indicatorColor', indicatorColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<ShapeBorder>('indicatorShape', indicatorShape, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Size>('indicatorSize', indicatorSize, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'labelTextStyle', + labelTextStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<IconThemeData?>>( + 'iconTheme', + iconTheme, + defaultValue: null, + ), + ); + } +} + +/// An inherited widget that defines visual properties for [NavigationDrawer]s and +/// [NavigationDestination]s in this widget's subtree. +/// +/// Values specified here are used for [NavigationDrawer] properties that are not +/// given an explicit non-null value. +/// +/// See also: +/// +/// * [ThemeData.navigationDrawerTheme], which describes the +/// [NavigationDrawerThemeData] in the overall theme for the application. +class NavigationDrawerTheme extends InheritedTheme { + /// Creates a navigation rail theme that controls the + /// [NavigationDrawerThemeData] properties for a [NavigationDrawer]. + const NavigationDrawerTheme({super.key, required this.data, required super.child}); + + /// Specifies the background color, label text style, icon theme, and label + /// type values for descendant [NavigationDrawer] widgets. + final NavigationDrawerThemeData data; + + /// Retrieves the [NavigationDrawerThemeData] from the closest + /// ancestor [NavigationDrawerTheme]. + /// + /// If there is no enclosing [NavigationDrawerTheme] widget, then + /// [ThemeData.navigationDrawerTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// NavigationDrawerThemeData theme = NavigationDrawerTheme.of(context); + /// ``` + static NavigationDrawerThemeData of(BuildContext context) { + final NavigationDrawerTheme? navigationDrawerTheme = context + .dependOnInheritedWidgetOfExactType<NavigationDrawerTheme>(); + return navigationDrawerTheme?.data ?? Theme.of(context).navigationDrawerTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return NavigationDrawerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(NavigationDrawerTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/navigation_rail.dart b/packages/material_ui/lib/src/navigation_rail.dart new file mode 100644 index 000000000000..43ed0b2ee1ea --- /dev/null +++ b/packages/material_ui/lib/src/navigation_rail.dart @@ -0,0 +1,1278 @@ +// 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. + +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'icons.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'navigation_bar.dart'; +import 'navigation_rail_theme.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +const double _kCircularIndicatorDiameter = 56; +const double _kIndicatorHeight = 32; + +/// A Material Design widget that is meant to be displayed at the left or right of an +/// app to navigate between a small number of views, typically between three and +/// five. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=y9xchtVTtqQ} +/// +/// The navigation rail is meant for layouts with wide viewports, such as a +/// desktop web or tablet landscape layout. For smaller layouts, like mobile +/// portrait, a [BottomNavigationBar] should be used instead. +/// +/// A navigation rail is usually used as the first or last element of a [Row] +/// which defines the app's [Scaffold] body. +/// +/// The appearance of all of the [NavigationRail]s within an app can be +/// specified with [NavigationRailTheme]. The default values for null theme +/// properties are based on the [Theme]'s [ThemeData.textTheme], +/// [ThemeData.iconTheme], and [ThemeData.colorScheme]. +/// +/// Adaptive layouts can build different instances of the [Scaffold] in order to +/// have a navigation rail for more horizontal layouts and a bottom navigation +/// bar for more vertical layouts. See +/// [the adaptive_scaffold.dart sample](https://github.com/flutter/demos/blob/main/web_dashboard/lib/src/widgets/third_party/adaptive_scaffold.dart) +/// for an example. +/// +/// {@tool dartpad} +/// This sample shows the creation of [NavigationRail] widget used within a Scaffold with 3 +/// [NavigationRailDestination]s, as described in: https://m3.material.io/components/navigation-rail/overview +/// +/// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Scaffold], which can display the navigation rail within a [Row] of the +/// [Scaffold.body] slot. +/// * [NavigationRailDestination], which is used as a model to create tappable +/// destinations in the navigation rail. +/// * [BottomNavigationBar], which is a similar navigation widget that's laid +/// out horizontally. +/// * <https://material.io/components/navigation-rail/> +/// * <https://m3.material.io/components/navigation-rail> +class NavigationRail extends StatefulWidget { + /// Creates a Material Design navigation rail. + /// + /// The value of [destinations] must be a list of zero or more + /// [NavigationRailDestination] values. + /// + /// If [elevation] is specified, it must be non-negative. + /// + /// If [minWidth] is specified, it must be non-negative, and if + /// [minExtendedWidth] is specified, it must be non-negative and greater than + /// [minWidth]. + /// + /// The [extended] argument can only be set to true when the [labelType] is + /// null or [NavigationRailLabelType.none]. + /// + /// If [backgroundColor], [elevation], [groupAlignment], [labelType], + /// [unselectedLabelTextStyle], [selectedLabelTextStyle], + /// [unselectedIconTheme], or [selectedIconTheme] are null, then their + /// [NavigationRailThemeData] values will be used. If the corresponding + /// [NavigationRailThemeData] property is null, then the navigation rail + /// defaults are used. See the individual properties for more information. + /// + /// Typically used within a [Row] that defines the [Scaffold.body] property. + const NavigationRail({ + super.key, + this.backgroundColor, + this.extended = false, + this.leading, + this.trailing, + required this.destinations, + required this.selectedIndex, + this.onDestinationSelected, + this.elevation, + this.groupAlignment, + this.labelType, + this.unselectedLabelTextStyle, + this.selectedLabelTextStyle, + this.unselectedIconTheme, + this.selectedIconTheme, + this.minWidth, + this.minExtendedWidth, + this.useIndicator, + this.indicatorColor, + this.indicatorShape, + this.leadingAtTop = true, + this.trailingAtBottom = false, + this.scrollable = false, + this.mainAxisAlignment, + }) : assert(selectedIndex == null || (0 <= selectedIndex && selectedIndex < destinations.length)), + assert(elevation == null || elevation > 0), + assert(minWidth == null || minWidth > 0), + assert(minExtendedWidth == null || minExtendedWidth > 0), + assert((minWidth == null || minExtendedWidth == null) || minExtendedWidth >= minWidth), + assert(!extended || (labelType == null || labelType == NavigationRailLabelType.none)); + + /// Sets the color of the Container that holds all of the [NavigationRail]'s + /// contents. + /// + /// The default value is [NavigationRailThemeData.backgroundColor]. If + /// [NavigationRailThemeData.backgroundColor] is null, then the default value + /// is based on [ColorScheme.surface] of [ThemeData.colorScheme]. + final Color? backgroundColor; + + /// Indicates that the [NavigationRail] should be in the extended state. + /// + /// The extended state has a wider rail container, and the labels are + /// positioned next to the icons. [minExtendedWidth] can be used to set the + /// minimum width of the rail when it is in this state. + /// + /// The rail will implicitly animate between the extended and normal state. + /// + /// If the rail is going to be in the extended state, then the [labelType] + /// must be set to [NavigationRailLabelType.none]. + /// + /// The default value is false. + final bool extended; + + /// The leading widget in the rail that is placed above the destinations. + /// + /// It is placed at the top of the rail, above the [destinations]. Its + /// location is not affected by [groupAlignment]. + /// + /// This is commonly a [FloatingActionButton], but may also be a non-button, + /// such as a logo. + /// + /// The default value is null. + final Widget? leading; + + /// The trailing widget in the rail that is placed below the destinations. + /// + /// The trailing widget is placed below the last [NavigationRailDestination]. + /// It's location is affected by [groupAlignment]. + /// + /// This is commonly a list of additional options or destinations that is + /// usually only rendered when [extended] is true. + /// + /// The default value is null. + final Widget? trailing; + + /// Defines the appearance of the button items that are arrayed within the + /// navigation rail. + /// + /// The value must be a list of zero or more [NavigationRailDestination] + /// values. + final List<NavigationRailDestination> destinations; + + /// The index into [destinations] for the current selected + /// [NavigationRailDestination] or null if no destination is selected. + final int? selectedIndex; + + /// Called when one of the [destinations] is selected. + /// + /// The stateful widget that creates the navigation rail needs to keep + /// track of the index of the selected [NavigationRailDestination] and call + /// `setState` to rebuild the navigation rail with the new [selectedIndex]. + final ValueChanged<int>? onDestinationSelected; + + /// The rail's elevation or z-coordinate. + /// + /// If [Directionality] is [TextDirection.ltr], the inner side is the + /// right side, and if [Directionality] is [TextDirection.rtl], it is + /// the left side. + /// + /// The default value is 0. + final double? elevation; + + /// The vertical alignment for the group of [destinations] within the rail. + /// + /// The [NavigationRailDestination]s are by default grouped together with the + /// [trailing] widget, due to [trailingAtBottom] being `false`. The [leading] + /// widget, can also be in the aligned group by setting [leadingAtTop] to + /// `false`. If these widgets are not included in the group, they are placed + /// at the top and bottom, respectively, of the rail and only the space + /// between them is considered for the alignment. + /// + /// The value must be between -1.0 and 1.0. + /// + /// If [groupAlignment] is -1.0, then the items are aligned to the top. If + /// [groupAlignment] is 0.0, then the items are aligned to the center. If + /// [groupAlignment] is 1.0, then the items are aligned to the bottom. + /// + /// The default is -1.0. + /// + /// See also: + /// * [Alignment.y] + /// + final double? groupAlignment; + + /// Defines the layout and behavior of the labels for the default, unextended + /// [NavigationRail]. + /// + /// When a navigation rail is [extended], the labels are always shown. + /// + /// The default value is [NavigationRailThemeData.labelType]. If + /// [NavigationRailThemeData.labelType] is null, then the default value is + /// [NavigationRailLabelType.none]. + /// + /// See also: + /// + /// * [NavigationRailLabelType] for information on the meaning of different + /// types. + final NavigationRailLabelType? labelType; + + /// The [TextStyle] of a destination's label when it is unselected. + /// + /// When one of the [destinations] is selected the [selectedLabelTextStyle] + /// will be used instead. + /// + /// The default value is based on the [Theme]'s [TextTheme.bodyLarge]. The + /// default color is based on the [Theme]'s [ColorScheme.onSurface]. + /// + /// Properties from this text style, or + /// [NavigationRailThemeData.unselectedLabelTextStyle] if this is null, are + /// merged into the defaults. + final TextStyle? unselectedLabelTextStyle; + + /// The [TextStyle] of a destination's label when it is selected. + /// + /// When a [NavigationRailDestination] is not selected, + /// [unselectedLabelTextStyle] will be used. + /// + /// The default value is based on the [TextTheme.bodyLarge] of + /// [ThemeData.textTheme]. The default color is based on the [Theme]'s + /// [ColorScheme.primary]. + /// + /// Properties from this text style, + /// or [NavigationRailThemeData.selectedLabelTextStyle] if this is null, are + /// merged into the defaults. + final TextStyle? selectedLabelTextStyle; + + /// The visual properties of the icon in the unselected destination. + /// + /// If this field is not provided, or provided with any null properties, then + /// a copy of the [IconThemeData.fallback] with a custom [NavigationRail] + /// specific color will be used. + /// + /// The default value is the [Theme]'s [ThemeData.iconTheme] with a color + /// of the [Theme]'s [ColorScheme.onSurface] with an opacity of 0.64. + /// Properties from this icon theme, or + /// [NavigationRailThemeData.unselectedIconTheme] if this is null, are + /// merged into the defaults. + final IconThemeData? unselectedIconTheme; + + /// The visual properties of the icon in the selected destination. + /// + /// When a [NavigationRailDestination] is not selected, + /// [unselectedIconTheme] will be used. + /// + /// The default value is the [Theme]'s [ThemeData.iconTheme] with a color + /// of the [Theme]'s [ColorScheme.primary]. Properties from this icon theme, + /// or [NavigationRailThemeData.selectedIconTheme] if this is null, are + /// merged into the defaults. + final IconThemeData? selectedIconTheme; + + /// The smallest possible width for the rail regardless of the destination's + /// icon or label size. + /// + /// The default is 72. + /// + /// This value also defines the min width and min height of the destinations. + /// + /// To make a compact rail, set this to 56 and use + /// [NavigationRailLabelType.none]. + final double? minWidth; + + /// The final width when the animation is complete for setting [extended] to + /// true. + /// + /// This is only used when [extended] is set to true. + /// + /// The default value is 256. + final double? minExtendedWidth; + + /// If `true`, adds a rounded [NavigationIndicator] behind the selected + /// destination's icon. + /// + /// The indicator's shape will be circular if [labelType] is + /// [NavigationRailLabelType.none], or a [StadiumBorder] if [labelType] is + /// [NavigationRailLabelType.all] or [NavigationRailLabelType.selected]. + /// + /// If `null`, defaults to [NavigationRailThemeData.useIndicator]. If that is + /// `null`, defaults to [ThemeData.useMaterial3]. + final bool? useIndicator; + + /// Overrides the default value of [NavigationRail]'s selection indicator color, + /// when [useIndicator] is true. + /// + /// If this is null, [NavigationRailThemeData.indicatorColor] is used. If + /// that is null, defaults to [ColorScheme.secondaryContainer]. + final Color? indicatorColor; + + /// Overrides the default value of [NavigationRail]'s selection indicator shape, + /// when [useIndicator] is true. + /// + /// If this is null, [NavigationRailThemeData.indicatorShape] is used. If + /// that is null, defaults to [StadiumBorder]. + final ShapeBorder? indicatorShape; + + /// Pin the [leading] widget to the top. + /// + /// If `true`, the [leading] widget is pinned to the top of the + /// [NavigationRail]. Otherwise it precedes directly the main group of + /// [destinations]. + /// + /// See also [scrollable] for a description of the interplay of these + /// parameters. + final bool leadingAtTop; + + /// Pin the [trailing] widget to the bottom. + /// + /// If `true`, the [trailing] widget is pinned to the bottom of the + /// [NavigationRail]. Otherwise it follows directly the main group of + /// [destinations]. + /// + /// See also [scrollable] for a description of the interplay of these + /// parameters. + final bool trailingAtBottom; + + /// Whether the main group of items should be scrollable when vertical space + /// is insufficient to show all of [destinations], [leading] and [trailing]. + /// + /// If [leadingAtTop] or [trailingAtBottom] are false, [leading] or [trailing] + /// widgets, respectively, are part of the main group in addition to + /// [destinations]. Otherwise these are statical at the top or bottom, + /// respectively. + final bool scrollable; + + /// How the [destinations] should be placed along the vertical axis. + /// + /// When there is extra vertical space in the [NavigationRail], this + /// property controls the alignment and spacing of the items. For example, + /// setting this to [MainAxisAlignment.spaceEvenly] will distribute the + /// destinations equally along the available vertical space. + /// + /// When this property is not null, [groupAlignment] is ignored. + /// + /// If null, the layout behaves as if [MainAxisAlignment.start] was + /// specified. + /// + /// See also: + /// + /// * [Column.mainAxisAlignment], which describes the different values and + /// their effects on the layout. + final MainAxisAlignment? mainAxisAlignment; + + /// Returns the animation that controls the [NavigationRail.extended] state. + /// + /// This can be used to synchronize animations in the [leading] or [trailing] + /// widget, such as an animated menu or a [FloatingActionButton] animation. + /// + /// {@tool dartpad} + /// This example shows how to use this animation to create a [FloatingActionButton] + /// that animates itself between the normal and extended states of the + /// [NavigationRail]. + /// + /// An instance of `MyNavigationRailFab` is created for [NavigationRail.leading]. + /// Pressing the FAB button toggles the "extended" state of the [NavigationRail]. + /// + /// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.extended_animation.0.dart ** + /// {@end-tool} + static Animation<double> extendedAnimation(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_ExtendedNavigationRailAnimation>()! + .animation; + } + + @override + State<NavigationRail> createState() => _NavigationRailState(); +} + +class _NavigationRailState extends State<NavigationRail> with TickerProviderStateMixin { + late List<AnimationController> _destinationControllers; + late List<Animation<double>> _destinationAnimations; + late AnimationController _extendedController; + late CurvedAnimation _extendedAnimation; + + @override + void initState() { + super.initState(); + _initControllers(); + } + + @override + void dispose() { + _disposeControllers(); + super.dispose(); + } + + @override + void didUpdateWidget(NavigationRail oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.extended != oldWidget.extended) { + if (widget.extended) { + _extendedController.forward(); + } else { + _extendedController.reverse(); + } + } + + // No animated segue if the length of the items list changes. + if (widget.destinations.length != oldWidget.destinations.length) { + _resetState(); + return; + } + + if (widget.selectedIndex != oldWidget.selectedIndex) { + if (oldWidget.selectedIndex != null) { + _destinationControllers[oldWidget.selectedIndex!].reverse(); + } + if (widget.selectedIndex != null) { + _destinationControllers[widget.selectedIndex!].forward(); + } + return; + } + } + + @override + Widget build(BuildContext context) { + final NavigationRailThemeData navigationRailTheme = NavigationRailTheme.of(context); + final NavigationRailThemeData defaults = Theme.of(context).useMaterial3 + ? _NavigationRailDefaultsM3(context) + : _NavigationRailDefaultsM2(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + final Color backgroundColor = + widget.backgroundColor ?? navigationRailTheme.backgroundColor ?? defaults.backgroundColor!; + final double elevation = + widget.elevation ?? navigationRailTheme.elevation ?? defaults.elevation!; + final double minWidth = widget.minWidth ?? navigationRailTheme.minWidth ?? defaults.minWidth!; + final double minExtendedWidth = + widget.minExtendedWidth ?? + navigationRailTheme.minExtendedWidth ?? + defaults.minExtendedWidth!; + final TextStyle unselectedLabelTextStyle = + widget.unselectedLabelTextStyle ?? + navigationRailTheme.unselectedLabelTextStyle ?? + defaults.unselectedLabelTextStyle!; + final TextStyle selectedLabelTextStyle = + widget.selectedLabelTextStyle ?? + navigationRailTheme.selectedLabelTextStyle ?? + defaults.selectedLabelTextStyle!; + final IconThemeData unselectedIconTheme = + widget.unselectedIconTheme ?? + navigationRailTheme.unselectedIconTheme ?? + defaults.unselectedIconTheme!; + final IconThemeData selectedIconTheme = + widget.selectedIconTheme ?? + navigationRailTheme.selectedIconTheme ?? + defaults.selectedIconTheme!; + final double groupAlignment = + widget.groupAlignment ?? navigationRailTheme.groupAlignment ?? defaults.groupAlignment!; + final NavigationRailLabelType labelType = + widget.labelType ?? navigationRailTheme.labelType ?? defaults.labelType!; + final bool useIndicator = + widget.useIndicator ?? navigationRailTheme.useIndicator ?? defaults.useIndicator!; + final Color? indicatorColor = + widget.indicatorColor ?? navigationRailTheme.indicatorColor ?? defaults.indicatorColor; + final ShapeBorder? indicatorShape = + widget.indicatorShape ?? navigationRailTheme.indicatorShape ?? defaults.indicatorShape; + + // For backwards compatibility, in M2 the opacity of the unselected icons needs + // to be set to the default if it isn't in the given theme. This can be removed + // when Material 3 is the default. + final IconThemeData effectiveUnselectedIconTheme = Theme.of(context).useMaterial3 + ? unselectedIconTheme + : unselectedIconTheme.copyWith( + opacity: unselectedIconTheme.opacity ?? defaults.unselectedIconTheme!.opacity, + ); + + final isRTLDirection = Directionality.of(context) == TextDirection.rtl; + + Widget mainGroup = Column( + mainAxisSize: widget.mainAxisAlignment != null ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: widget.mainAxisAlignment ?? MainAxisAlignment.start, + children: <Widget>[ + if (!widget.leadingAtTop && widget.leading != null) ...<Widget>[ + widget.leading!, + _verticalSpacer, + ], + for (int i = 0; i < widget.destinations.length; i += 1) + _RailDestination( + minWidth: minWidth, + minExtendedWidth: minExtendedWidth, + extendedTransitionAnimation: _extendedAnimation, + selected: widget.selectedIndex == i, + icon: widget.selectedIndex == i + ? widget.destinations[i].selectedIcon + : widget.destinations[i].icon, + label: widget.destinations[i].label, + destinationAnimation: _destinationAnimations[i], + labelType: labelType, + iconTheme: widget.selectedIndex == i ? selectedIconTheme : effectiveUnselectedIconTheme, + labelTextStyle: widget.selectedIndex == i + ? selectedLabelTextStyle + : unselectedLabelTextStyle, + padding: widget.destinations[i].padding, + useIndicator: useIndicator, + indicatorColor: useIndicator ? indicatorColor : null, + indicatorShape: useIndicator ? indicatorShape : null, + onTap: () { + if (widget.onDestinationSelected != null) { + widget.onDestinationSelected!(i); + } + }, + indexLabel: localizations.tabLabel( + tabIndex: i + 1, + tabCount: widget.destinations.length, + ), + disabled: widget.destinations[i].disabled, + ), + if (!widget.trailingAtBottom && widget.trailing != null) widget.trailing!, + ], + ); + + if (widget.scrollable) { + mainGroup = SingleChildScrollView(child: mainGroup); + } + + return Semantics( + container: true, + child: _ExtendedNavigationRailAnimation( + animation: _extendedAnimation, + child: Semantics( + explicitChildNodes: true, + child: Material( + elevation: elevation, + color: backgroundColor, + child: SafeArea( + right: isRTLDirection, + left: !isRTLDirection, + child: Column( + children: <Widget>[ + _verticalSpacer, + if (widget.leadingAtTop && widget.leading != null) ...<Widget>[ + widget.leading!, + _verticalSpacer, + ], + Flexible( + child: Align(alignment: Alignment(0, groupAlignment), child: mainGroup), + ), + if (widget.trailingAtBottom && widget.trailing != null) widget.trailing!, + ], + ), + ), + ), + ), + ), + ); + } + + void _disposeControllers() { + for (final AnimationController controller in _destinationControllers) { + controller.dispose(); + } + _extendedController.dispose(); + _extendedAnimation.dispose(); + } + + void _initControllers() { + _destinationControllers = List<AnimationController>.generate(widget.destinations.length, ( + int index, + ) { + return AnimationController(duration: kThemeAnimationDuration, vsync: this) + ..addListener(_rebuild); + }); + _destinationAnimations = _destinationControllers + .map((AnimationController controller) => controller.view) + .toList(); + if (widget.selectedIndex != null) { + _destinationControllers[widget.selectedIndex!].value = 1.0; + } + _extendedController = AnimationController( + duration: kThemeAnimationDuration, + vsync: this, + value: widget.extended ? 1.0 : 0.0, + ); + _extendedAnimation = CurvedAnimation(parent: _extendedController, curve: Curves.easeInOut); + _extendedController.addListener(() { + _rebuild(); + }); + } + + void _resetState() { + _disposeControllers(); + _initControllers(); + } + + void _rebuild() { + setState(() { + // Rebuilding when any of the controllers tick, i.e. when the items are + // animating. + }); + } +} + +class _RailDestination extends StatefulWidget { + const _RailDestination({ + required this.minWidth, + required this.minExtendedWidth, + required this.icon, + required this.label, + required this.destinationAnimation, + required this.extendedTransitionAnimation, + required this.labelType, + required this.selected, + required this.iconTheme, + required this.labelTextStyle, + required this.onTap, + required this.indexLabel, + this.padding, + required this.useIndicator, + this.indicatorColor, + this.indicatorShape, + this.disabled = false, + }); + + final double minWidth; + final double minExtendedWidth; + final Widget icon; + final Widget label; + final Animation<double> destinationAnimation; + final NavigationRailLabelType labelType; + final bool selected; + final Animation<double> extendedTransitionAnimation; + final IconThemeData iconTheme; + final TextStyle labelTextStyle; + final VoidCallback onTap; + final String indexLabel; + final EdgeInsetsGeometry? padding; + final bool useIndicator; + final Color? indicatorColor; + final ShapeBorder? indicatorShape; + final bool disabled; + + @override + State<_RailDestination> createState() => _RailDestinationState(); +} + +class _RailDestinationState extends State<_RailDestination> { + late CurvedAnimation _positionAnimation; + + @override + void initState() { + super.initState(); + _setPositionAnimation(); + } + + @override + void didUpdateWidget(_RailDestination oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.destinationAnimation != oldWidget.destinationAnimation) { + _positionAnimation.dispose(); + _setPositionAnimation(); + } + } + + void _setPositionAnimation() { + _positionAnimation = CurvedAnimation( + parent: ReverseAnimation(widget.destinationAnimation), + curve: Curves.easeInOut, + reverseCurve: Curves.easeInOut.flipped, + ); + } + + @override + void dispose() { + _positionAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + widget.useIndicator || widget.indicatorColor == null, + '[NavigationRail.indicatorColor] does not have an effect when [NavigationRail.useIndicator] is false', + ); + + final ThemeData theme = Theme.of(context); + final TextDirection textDirection = Directionality.of(context); + final bool material3 = theme.useMaterial3; + final EdgeInsets destinationPadding = (widget.padding ?? EdgeInsets.zero).resolve( + textDirection, + ); + Offset indicatorOffset; + var applyXOffset = false; + + final Widget themedIcon = IconTheme( + data: widget.disabled + ? widget.iconTheme.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) + : widget.iconTheme, + child: widget.icon, + ); + final Widget styledLabel = DefaultTextStyle( + style: widget.disabled + ? widget.labelTextStyle.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) + : widget.labelTextStyle, + child: widget.label, + ); + + Widget content; + + // The indicator height is fixed and equal to _kIndicatorHeight. + // When the icon height is larger than the indicator height the indicator + // vertical offset is used to vertically center the indicator. + final bool isLargeIconSize = + widget.iconTheme.size != null && widget.iconTheme.size! > _kIndicatorHeight; + final double indicatorVerticalOffset = isLargeIconSize + ? (widget.iconTheme.size! - _kIndicatorHeight) / 2 + : 0; + + switch (widget.labelType) { + case NavigationRailLabelType.none: + // Split the destination spacing across the top and bottom to keep the icon centered. + final Widget? spacing = material3 + ? const SizedBox(height: _verticalDestinationSpacingM3 / 2) + : null; + indicatorOffset = Offset( + widget.minWidth / 2 + destinationPadding.left, + _verticalDestinationSpacingM3 / 2 + destinationPadding.top + indicatorVerticalOffset, + ); + final Widget iconPart = Column( + children: <Widget>[ + ?spacing, + SizedBox( + width: widget.minWidth, + height: material3 ? null : widget.minWidth, + child: Center( + child: _AddIndicator( + addIndicator: widget.useIndicator, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, + isCircular: !material3, + indicatorAnimation: widget.destinationAnimation, + child: themedIcon, + ), + ), + ), + ?spacing, + ], + ); + if (widget.extendedTransitionAnimation.value == 0) { + content = Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: Stack( + children: <Widget>[ + iconPart, + // For semantics when label is not showing, + SizedBox.shrink(child: Visibility.maintain(visible: false, child: widget.label)), + ], + ), + ); + } else { + final Animation<double> labelFadeAnimation = widget.extendedTransitionAnimation.drive( + CurveTween(curve: const Interval(0.0, 0.25)), + ); + applyXOffset = true; + content = Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: lerpDouble( + widget.minWidth, + widget.minExtendedWidth, + widget.extendedTransitionAnimation.value, + )!, + ), + child: ClipRect( + child: Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + iconPart, + Flexible( + child: Align( + heightFactor: 1.0, + widthFactor: widget.extendedTransitionAnimation.value, + alignment: AlignmentDirectional.centerStart, + child: FadeTransition( + alwaysIncludeSemantics: true, + opacity: labelFadeAnimation, + child: styledLabel, + ), + ), + ), + SizedBox( + width: + _horizontalDestinationPadding * widget.extendedTransitionAnimation.value, + ), + ], + ), + ), + ), + ); + } + case NavigationRailLabelType.selected: + final double appearingAnimationValue = 1 - _positionAnimation.value; + final double verticalPadding = lerpDouble( + _verticalDestinationPaddingNoLabel, + _verticalDestinationPaddingWithLabel, + appearingAnimationValue, + )!; + final interval = widget.selected ? const Interval(0.25, 0.75) : const Interval(0.75, 1.0); + final Animation<double> labelFadeAnimation = widget.destinationAnimation.drive( + CurveTween(curve: interval), + ); + final double minHeight = material3 ? 0 : widget.minWidth; + final Widget topSpacing = SizedBox(height: material3 ? 0 : verticalPadding); + final Widget labelSpacing = SizedBox( + height: material3 + ? lerpDouble(0, _verticalIconLabelSpacingM3, appearingAnimationValue)! + : 0, + ); + final Widget bottomSpacing = SizedBox( + height: material3 ? _verticalDestinationSpacingM3 : verticalPadding, + ); + final double indicatorHorizontalPadding = + (destinationPadding.left / 2) - (destinationPadding.right / 2); + final double indicatorVerticalPadding = destinationPadding.top; + indicatorOffset = Offset( + widget.minWidth / 2 + indicatorHorizontalPadding, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + if (widget.minWidth < _NavigationRailDefaultsM2(context).minWidth!) { + indicatorOffset = Offset( + widget.minWidth / 2 + _horizontalDestinationSpacingM3, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + } + content = ConstrainedBox( + constraints: BoxConstraints(minWidth: widget.minWidth, minHeight: minHeight), + child: Padding( + padding: + widget.padding ?? + const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), + child: ClipRect( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + topSpacing, + _AddIndicator( + addIndicator: widget.useIndicator, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, + isCircular: false, + indicatorAnimation: widget.destinationAnimation, + child: themedIcon, + ), + labelSpacing, + Align( + alignment: Alignment.topCenter, + heightFactor: appearingAnimationValue, + widthFactor: 1.0, + child: FadeTransition( + alwaysIncludeSemantics: true, + opacity: labelFadeAnimation, + child: styledLabel, + ), + ), + bottomSpacing, + ], + ), + ), + ), + ); + case NavigationRailLabelType.all: + final double minHeight = material3 ? 0 : widget.minWidth; + final Widget topSpacing = SizedBox( + height: material3 ? 0 : _verticalDestinationPaddingWithLabel, + ); + final Widget labelSpacing = SizedBox(height: material3 ? _verticalIconLabelSpacingM3 : 0); + final Widget bottomSpacing = SizedBox( + height: material3 ? _verticalDestinationSpacingM3 : _verticalDestinationPaddingWithLabel, + ); + final double indicatorHorizontalPadding = + (destinationPadding.left / 2) - (destinationPadding.right / 2); + final double indicatorVerticalPadding = destinationPadding.top; + indicatorOffset = Offset( + widget.minWidth / 2 + indicatorHorizontalPadding, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + if (widget.minWidth < _NavigationRailDefaultsM2(context).minWidth!) { + indicatorOffset = Offset( + widget.minWidth / 2 + _horizontalDestinationSpacingM3, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + } + content = ConstrainedBox( + constraints: BoxConstraints(minWidth: widget.minWidth, minHeight: minHeight), + child: Padding( + padding: + widget.padding ?? + const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), + child: Column( + children: <Widget>[ + topSpacing, + _AddIndicator( + addIndicator: widget.useIndicator, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, + isCircular: false, + indicatorAnimation: widget.destinationAnimation, + child: themedIcon, + ), + labelSpacing, + styledLabel, + bottomSpacing, + ], + ), + ), + ); + } + + final ColorScheme colors = Theme.of(context).colorScheme; + final bool primaryColorAlphaModified = colors.primary.alpha < 255.0; + final Color effectiveSplashColor = primaryColorAlphaModified + ? colors.primary + : colors.primary.withOpacity(0.12); + final Color effectiveHoverColor = primaryColorAlphaModified + ? colors.primary + : colors.primary.withOpacity(0.04); + return Semantics( + container: true, + selected: widget.selected, + child: Stack( + children: <Widget>[ + Material( + type: MaterialType.transparency, + child: _IndicatorInkWell( + onTap: widget.disabled ? null : widget.onTap, + borderRadius: BorderRadius.all(Radius.circular(widget.minWidth / 2.0)), + customBorder: widget.indicatorShape, + splashColor: effectiveSplashColor, + hoverColor: effectiveHoverColor, + useMaterial3: material3, + indicatorOffset: indicatorOffset, + applyXOffset: applyXOffset, + textDirection: textDirection, + child: content, + ), + ), + Semantics(label: widget.indexLabel), + ], + ), + ); + } +} + +class _IndicatorInkWell extends InkResponse { + const _IndicatorInkWell({ + super.child, + super.onTap, + ShapeBorder? customBorder, + BorderRadius? borderRadius, + super.splashColor, + super.hoverColor, + required this.useMaterial3, + required this.indicatorOffset, + required this.applyXOffset, + required this.textDirection, + }) : super( + containedInkWell: true, + highlightShape: BoxShape.rectangle, + borderRadius: useMaterial3 ? null : borderRadius, + customBorder: useMaterial3 ? customBorder : null, + ); + + final bool useMaterial3; + + // The offset used to position Ink highlight. + final Offset indicatorOffset; + + // Whether the horizontal offset from indicatorOffset should be used to position Ink highlight. + // If true, Ink highlight uses the indicator horizontal offset. If false, Ink highlight is centered horizontally. + final bool applyXOffset; + + // The text direction used to adjust the indicator horizontal offset. + final TextDirection textDirection; + + @override + RectCallback? getRectCallback(RenderBox referenceBox) { + if (useMaterial3) { + final double boxWidth = referenceBox.size.width; + double indicatorHorizontalCenter = applyXOffset ? indicatorOffset.dx : boxWidth / 2; + if (textDirection == TextDirection.rtl) { + indicatorHorizontalCenter = boxWidth - indicatorHorizontalCenter; + } + return () { + return Rect.fromLTWH( + indicatorHorizontalCenter - (_kCircularIndicatorDiameter / 2), + indicatorOffset.dy, + _kCircularIndicatorDiameter, + _kIndicatorHeight, + ); + }; + } + return null; + } +} + +/// When [addIndicator] is `true`, puts [child] center aligned in a [Stack] with +/// a [NavigationIndicator] behind it, otherwise returns [child]. +/// +/// When [isCircular] is true, the indicator will be a circle, otherwise the +/// indicator will be a stadium shape. +class _AddIndicator extends StatelessWidget { + const _AddIndicator({ + required this.addIndicator, + required this.isCircular, + required this.indicatorColor, + required this.indicatorShape, + required this.indicatorAnimation, + required this.child, + }); + + final bool addIndicator; + final bool isCircular; + final Color? indicatorColor; + final ShapeBorder? indicatorShape; + final Animation<double> indicatorAnimation; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!addIndicator) { + return child; + } + late final Widget indicator; + if (isCircular) { + indicator = NavigationIndicator( + animation: indicatorAnimation, + height: _kCircularIndicatorDiameter, + width: _kCircularIndicatorDiameter, + borderRadius: const BorderRadius.all(Radius.circular(_kCircularIndicatorDiameter / 2)), + color: indicatorColor, + ); + } else { + indicator = NavigationIndicator( + animation: indicatorAnimation, + width: _kCircularIndicatorDiameter, + shape: indicatorShape, + color: indicatorColor, + ); + } + + return Stack(alignment: Alignment.center, children: <Widget>[indicator, child]); + } +} + +/// Defines the behavior of the labels of a [NavigationRail]. +/// +/// See also: +/// +/// * [NavigationRail] +enum NavigationRailLabelType { + /// Only the [NavigationRailDestination]s are shown. + none, + + /// Only the selected [NavigationRailDestination] will show its label. + /// + /// The label will animate in and out as new [NavigationRailDestination]s are + /// selected. + selected, + + /// All [NavigationRailDestination]s will show their label. + all, +} + +/// Defines a [NavigationRail] button that represents one "destination" view. +/// +/// See also: +/// +/// * [NavigationRail] +class NavigationRailDestination { + /// Creates a destination that is used with [NavigationRail.destinations]. + /// + /// When the [NavigationRail.labelType] is [NavigationRailLabelType.none], the + /// label is still used for semantics, and may still be used if + /// [NavigationRail.extended] is true. + const NavigationRailDestination({ + required this.icon, + Widget? selectedIcon, + this.indicatorColor, + this.indicatorShape, + required this.label, + this.padding, + this.disabled = false, + }) : selectedIcon = selectedIcon ?? icon; + + /// The icon of the destination. + /// + /// Typically the icon is an [Icon] or an [ImageIcon] widget. If another type + /// of widget is provided then it should configure itself to match the current + /// [IconTheme] size and color. + /// + /// If [selectedIcon] is provided, this will only be displayed when the + /// destination is not selected. + /// + /// To make the [NavigationRail] more accessible, consider choosing an + /// icon with a stroked and filled version, such as [Icons.cloud] and + /// [Icons.cloud_queue]. The [icon] should be set to the stroked version and + /// [selectedIcon] to the filled version. + final Widget icon; + + /// An alternative icon displayed when this destination is selected. + /// + /// If this icon is not provided, the [NavigationRail] will display [icon] in + /// either state. The size, color, and opacity of the + /// [NavigationRail.selectedIconTheme] will still apply. + /// + /// See also: + /// + /// * [NavigationRailDestination.icon], for a description of how to pair + /// icons. + final Widget selectedIcon; + + /// The color of the [indicatorShape] when this destination is selected. + final Color? indicatorColor; + + /// The shape of the selection indicator. + final ShapeBorder? indicatorShape; + + /// The label for the destination. + /// + /// The label must be provided when used with the [NavigationRail]. When the + /// [NavigationRail.labelType] is [NavigationRailLabelType.none], the label is + /// still used for semantics, and may still be used if + /// [NavigationRail.extended] is true. + final Widget label; + + /// The amount of space to inset the destination item. + final EdgeInsetsGeometry? padding; + + /// Indicates that this destination is inaccessible. + final bool disabled; +} + +class _ExtendedNavigationRailAnimation extends InheritedWidget { + const _ExtendedNavigationRailAnimation({required this.animation, required super.child}); + + final Animation<double> animation; + + @override + bool updateShouldNotify(_ExtendedNavigationRailAnimation old) => animation != old.animation; +} + +// There don't appear to be tokens for these values, but they are +// shown in the spec. +const double _horizontalDestinationPadding = 8.0; +const double _verticalDestinationPaddingNoLabel = 24.0; +const double _verticalDestinationPaddingWithLabel = 16.0; +const Widget _verticalSpacer = SizedBox(height: 8.0); +const double _verticalIconLabelSpacingM3 = 4.0; +const double _verticalDestinationSpacingM3 = 12.0; +const double _horizontalDestinationSpacingM3 = 12.0; + +// Hand coded defaults based on Material Design 2. +class _NavigationRailDefaultsM2 extends NavigationRailThemeData { + _NavigationRailDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme, + super( + elevation: 0, + groupAlignment: -1, + labelType: NavigationRailLabelType.none, + useIndicator: false, + minWidth: 72.0, + minExtendedWidth: 256, + ); + + final ThemeData _theme; + final ColorScheme _colors; + + @override + Color? get backgroundColor => _colors.surface; + + @override + TextStyle? get unselectedLabelTextStyle { + return _theme.textTheme.bodyLarge!.copyWith(color: _colors.onSurface.withOpacity(0.64)); + } + + @override + TextStyle? get selectedLabelTextStyle { + return _theme.textTheme.bodyLarge!.copyWith(color: _colors.primary); + } + + @override + IconThemeData? get unselectedIconTheme { + return IconThemeData(size: 24.0, color: _colors.onSurface, opacity: 0.64); + } + + @override + IconThemeData? get selectedIconTheme { + return IconThemeData(size: 24.0, color: _colors.primary, opacity: 1.0); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - NavigationRail + +// 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. + +// dart format off +class _NavigationRailDefaultsM3 extends NavigationRailThemeData { + _NavigationRailDefaultsM3(this.context) + : super( + elevation: 0.0, + groupAlignment: -1, + labelType: NavigationRailLabelType.none, + useIndicator: true, + minWidth: 80.0, + minExtendedWidth: 256, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override Color? get backgroundColor => _colors.surface; + + @override TextStyle? get unselectedLabelTextStyle { + return _textTheme.labelMedium!.copyWith(color: _colors.onSurface); + } + + @override TextStyle? get selectedLabelTextStyle { + return _textTheme.labelMedium!.copyWith(color: _colors.onSurface); + } + + @override IconThemeData? get unselectedIconTheme { + return IconThemeData( + size: 24.0, + color: _colors.onSurfaceVariant, + ); + } + + @override IconThemeData? get selectedIconTheme { + return IconThemeData( + size: 24.0, + color: _colors.onSecondaryContainer, + ); + } + + @override Color? get indicatorColor => _colors.secondaryContainer; + + @override ShapeBorder? get indicatorShape => const StadiumBorder(); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - NavigationRail diff --git a/packages/material_ui/lib/src/navigation_rail_theme.dart b/packages/material_ui/lib/src/navigation_rail_theme.dart new file mode 100644 index 000000000000..10aa495eda55 --- /dev/null +++ b/packages/material_ui/lib/src/navigation_rail_theme.dart @@ -0,0 +1,334 @@ +// 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. + +/// @docImport 'navigation_bar.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'navigation_rail.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [NavigationRail] +/// widgets. +/// +/// Descendant widgets obtain the current [NavigationRailThemeData] object +/// using [NavigationRailTheme.of]. Instances of [NavigationRailThemeData] +/// can be customized with [NavigationRailThemeData.copyWith]. +/// +/// Typically a [NavigationRailThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.navigationRailTheme]. +/// +/// All [NavigationRailThemeData] properties are `null` by default. +/// When null, the [NavigationRail] will use the values from [ThemeData] +/// if they exist, otherwise it will provide its own defaults based on the +/// overall [Theme]'s textTheme and colorScheme. See the individual +/// [NavigationRail] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class NavigationRailThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.navigationRailTheme]. + const NavigationRailThemeData({ + this.backgroundColor, + this.elevation, + this.unselectedLabelTextStyle, + this.selectedLabelTextStyle, + this.unselectedIconTheme, + this.selectedIconTheme, + this.groupAlignment, + this.labelType, + this.useIndicator, + this.indicatorColor, + this.indicatorShape, + this.minWidth, + this.minExtendedWidth, + }); + + /// Color to be used for the [NavigationRail]'s background. + final Color? backgroundColor; + + /// The z-coordinate to be used for the [NavigationRail]'s elevation. + final double? elevation; + + /// The style to merge with the default text style for + /// [NavigationRailDestination] labels, when the destination is not selected. + final TextStyle? unselectedLabelTextStyle; + + /// The style to merge with the default text style for + /// [NavigationRailDestination] labels, when the destination is selected. + final TextStyle? selectedLabelTextStyle; + + /// The theme to merge with the default icon theme for + /// [NavigationRailDestination] icons, when the destination is not selected. + final IconThemeData? unselectedIconTheme; + + /// The theme to merge with the default icon theme for + /// [NavigationRailDestination] icons, when the destination is selected. + final IconThemeData? selectedIconTheme; + + /// The alignment for the [NavigationRailDestination]s as they are positioned + /// within the [NavigationRail]. + final double? groupAlignment; + + /// The type that defines the layout and behavior of the labels in the + /// [NavigationRail]. + final NavigationRailLabelType? labelType; + + /// Whether or not the selected [NavigationRailDestination] should include a + /// [NavigationIndicator]. + final bool? useIndicator; + + /// Overrides the default value of [NavigationRail]'s selection indicator color, + /// when [useIndicator] is true. + final Color? indicatorColor; + + /// Overrides the default shape of the [NavigationRail]'s selection indicator. + final ShapeBorder? indicatorShape; + + /// Overrides the default value of [NavigationRail]'s minimum width when it + /// is not extended. + final double? minWidth; + + /// Overrides the default value of [NavigationRail]'s minimum width when it + /// is extended. + final double? minExtendedWidth; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + NavigationRailThemeData copyWith({ + Color? backgroundColor, + double? elevation, + TextStyle? unselectedLabelTextStyle, + TextStyle? selectedLabelTextStyle, + IconThemeData? unselectedIconTheme, + IconThemeData? selectedIconTheme, + double? groupAlignment, + NavigationRailLabelType? labelType, + bool? useIndicator, + Color? indicatorColor, + ShapeBorder? indicatorShape, + double? minWidth, + double? minExtendedWidth, + }) { + return NavigationRailThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + unselectedLabelTextStyle: unselectedLabelTextStyle ?? this.unselectedLabelTextStyle, + selectedLabelTextStyle: selectedLabelTextStyle ?? this.selectedLabelTextStyle, + unselectedIconTheme: unselectedIconTheme ?? this.unselectedIconTheme, + selectedIconTheme: selectedIconTheme ?? this.selectedIconTheme, + groupAlignment: groupAlignment ?? this.groupAlignment, + labelType: labelType ?? this.labelType, + useIndicator: useIndicator ?? this.useIndicator, + indicatorColor: indicatorColor ?? this.indicatorColor, + indicatorShape: indicatorShape ?? this.indicatorShape, + minWidth: minWidth ?? this.minWidth, + minExtendedWidth: minExtendedWidth ?? this.minExtendedWidth, + ); + } + + /// Linearly interpolate between two navigation rail themes. + /// + /// If both arguments are null then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static NavigationRailThemeData? lerp( + NavigationRailThemeData? a, + NavigationRailThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return NavigationRailThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + unselectedLabelTextStyle: TextStyle.lerp( + a?.unselectedLabelTextStyle, + b?.unselectedLabelTextStyle, + t, + ), + selectedLabelTextStyle: TextStyle.lerp( + a?.selectedLabelTextStyle, + b?.selectedLabelTextStyle, + t, + ), + unselectedIconTheme: a?.unselectedIconTheme == null && b?.unselectedIconTheme == null + ? null + : IconThemeData.lerp(a?.unselectedIconTheme, b?.unselectedIconTheme, t), + selectedIconTheme: a?.selectedIconTheme == null && b?.selectedIconTheme == null + ? null + : IconThemeData.lerp(a?.selectedIconTheme, b?.selectedIconTheme, t), + groupAlignment: lerpDouble(a?.groupAlignment, b?.groupAlignment, t), + labelType: t < 0.5 ? a?.labelType : b?.labelType, + useIndicator: t < 0.5 ? a?.useIndicator : b?.useIndicator, + indicatorColor: Color.lerp(a?.indicatorColor, b?.indicatorColor, t), + indicatorShape: ShapeBorder.lerp(a?.indicatorShape, b?.indicatorShape, t), + minWidth: lerpDouble(a?.minWidth, b?.minWidth, t), + minExtendedWidth: lerpDouble(a?.minExtendedWidth, b?.minExtendedWidth, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + elevation, + unselectedLabelTextStyle, + selectedLabelTextStyle, + unselectedIconTheme, + selectedIconTheme, + groupAlignment, + labelType, + useIndicator, + indicatorColor, + indicatorShape, + minWidth, + minExtendedWidth, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is NavigationRailThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.unselectedLabelTextStyle == unselectedLabelTextStyle && + other.selectedLabelTextStyle == selectedLabelTextStyle && + other.unselectedIconTheme == unselectedIconTheme && + other.selectedIconTheme == selectedIconTheme && + other.groupAlignment == groupAlignment && + other.labelType == labelType && + other.useIndicator == useIndicator && + other.indicatorColor == indicatorColor && + other.indicatorShape == indicatorShape && + other.minWidth == minWidth && + other.minExtendedWidth == minExtendedWidth; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultData = NavigationRailThemeData(); + + properties.add( + ColorProperty('backgroundColor', backgroundColor, defaultValue: defaultData.backgroundColor), + ); + properties.add(DoubleProperty('elevation', elevation, defaultValue: defaultData.elevation)); + properties.add( + DiagnosticsProperty<TextStyle>( + 'unselectedLabelTextStyle', + unselectedLabelTextStyle, + defaultValue: defaultData.unselectedLabelTextStyle, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'selectedLabelTextStyle', + selectedLabelTextStyle, + defaultValue: defaultData.selectedLabelTextStyle, + ), + ); + properties.add( + DiagnosticsProperty<IconThemeData>( + 'unselectedIconTheme', + unselectedIconTheme, + defaultValue: defaultData.unselectedIconTheme, + ), + ); + properties.add( + DiagnosticsProperty<IconThemeData>( + 'selectedIconTheme', + selectedIconTheme, + defaultValue: defaultData.selectedIconTheme, + ), + ); + properties.add( + DoubleProperty('groupAlignment', groupAlignment, defaultValue: defaultData.groupAlignment), + ); + properties.add( + DiagnosticsProperty<NavigationRailLabelType>( + 'labelType', + labelType, + defaultValue: defaultData.labelType, + ), + ); + properties.add( + DiagnosticsProperty<bool>( + 'useIndicator', + useIndicator, + defaultValue: defaultData.useIndicator, + ), + ); + properties.add( + ColorProperty('indicatorColor', indicatorColor, defaultValue: defaultData.indicatorColor), + ); + properties.add( + DiagnosticsProperty<ShapeBorder>('indicatorShape', indicatorShape, defaultValue: null), + ); + properties.add(DoubleProperty('minWidth', minWidth, defaultValue: defaultData.minWidth)); + properties.add( + DoubleProperty( + 'minExtendedWidth', + minExtendedWidth, + defaultValue: defaultData.minExtendedWidth, + ), + ); + } +} + +/// An inherited widget that defines visual properties for [NavigationRail]s and +/// [NavigationRailDestination]s in this widget's subtree. +/// +/// Values specified here are used for [NavigationRail] properties that are not +/// given an explicit non-null value. +class NavigationRailTheme extends InheritedTheme { + /// Creates a navigation rail theme that controls the + /// [NavigationRailThemeData] properties for a [NavigationRail]. + const NavigationRailTheme({super.key, required this.data, required super.child}); + + /// Specifies the background color, elevation, label text style, icon theme, + /// group alignment, and label type and border values for descendant + /// [NavigationRail] widgets. + final NavigationRailThemeData data; + + /// Retrieves the [NavigationRailThemeData] from the closest ancestor [NavigationRailTheme]. + /// + /// If there is no enclosing [NavigationRailTheme] widget, then + /// [ThemeData.navigationRailTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// NavigationRailThemeData theme = NavigationRailTheme.of(context); + /// ``` + static NavigationRailThemeData of(BuildContext context) { + final NavigationRailTheme? navigationRailTheme = context + .dependOnInheritedWidgetOfExactType<NavigationRailTheme>(); + return navigationRailTheme?.data ?? Theme.of(context).navigationRailTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return NavigationRailTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(NavigationRailTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/no_splash.dart b/packages/material_ui/lib/src/no_splash.dart new file mode 100644 index 000000000000..b9a4bd6647f0 --- /dev/null +++ b/packages/material_ui/lib/src/no_splash.dart @@ -0,0 +1,84 @@ +// 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. + +/// @docImport 'button_style.dart'; +/// @docImport 'elevated_button.dart'; +/// @docImport 'theme.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'ink_well.dart'; +import 'material.dart'; + +class _NoSplashFactory extends InteractiveInkFeatureFactory { + const _NoSplashFactory(); + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + return NoSplash( + controller: controller, + referenceBox: referenceBox, + color: color, + onRemoved: onRemoved, + ); + } +} + +/// An [InteractiveInkFeature] that doesn't paint a splash. +/// +/// Use [NoSplash.splashFactory] to defeat the default ink splash drawn by +/// an [InkWell] or [ButtonStyle]. For example, to create an [ElevatedButton] +/// that does not draw the default "ripple" ink splash when it's tapped: +/// +/// ```dart +/// ElevatedButton( +/// style: ElevatedButton.styleFrom( +/// splashFactory: NoSplash.splashFactory, +/// ), +/// onPressed: () { }, +/// child: const Text('No Splash'), +/// ) +/// ``` +class NoSplash extends InteractiveInkFeature { + /// Create an [InteractiveInkFeature] that doesn't paint a splash. + NoSplash({ + required super.controller, + required super.referenceBox, + required super.color, + super.onRemoved, + }); + + /// Used to specify this type of ink splash for an [InkWell], [InkResponse] + /// material [Theme], or [ButtonStyle]. + static const InteractiveInkFeatureFactory splashFactory = _NoSplashFactory(); + + @override + void paintFeature(Canvas canvas, Matrix4 transform) {} + + @override + void confirm() { + super.confirm(); + dispose(); + } + + @override + void cancel() { + super.cancel(); + dispose(); + } +} diff --git a/packages/material_ui/lib/src/outlined_button.dart b/packages/material_ui/lib/src/outlined_button.dart new file mode 100644 index 000000000000..d476c63ddd97 --- /dev/null +++ b/packages/material_ui/lib/src/outlined_button.dart @@ -0,0 +1,582 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +/// @docImport 'filled_button.dart'; +/// @docImport 'material.dart'; +/// @docImport 'text_button.dart'; +library; + +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 'colors.dart'; +import 'constants.dart'; +import 'ink_ripple.dart'; +import 'ink_well.dart'; +import 'material_state.dart'; +import 'outlined_button_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design "Outlined Button"; essentially a [TextButton] +/// with an outlined border. +/// +/// Outlined buttons are medium-emphasis buttons. They contain actions +/// that are important, but they aren’t the primary action in an app. +/// +/// An outlined button is a label [child] displayed on a (zero +/// elevation) [Material] widget. The label's [Text] and [Icon] +/// widgets are displayed in the [style]'s +/// [ButtonStyle.foregroundColor] and the outline's weight and color +/// are defined by [ButtonStyle.side]. The button reacts to touches +/// by filling with the [style]'s [ButtonStyle.overlayColor]. +/// +/// The outlined button's default style is defined by [defaultStyleOf]. +/// The style of this outline button can be overridden with its [style] +/// parameter. The style of all text buttons in a subtree can be +/// overridden with the [OutlinedButtonTheme] and the style of all of the +/// outlined buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.outlinedButtonTheme] property. +/// +/// Unlike [TextButton] or [ElevatedButton], outline buttons have a +/// default [ButtonStyle.side] which defines the appearance of the +/// outline. Because the default `side` is non-null, it +/// unconditionally overrides the shape's [OutlinedBorder.side]. In +/// other words, to specify an outlined button's shape _and_ the +/// appearance of its outline, both the [ButtonStyle.shape] and +/// [ButtonStyle.side] properties must be specified. +/// +/// {@tool dartpad} +/// Here is an example of a basic [OutlinedButton]. +/// +/// ** See code in examples/api/lib/material/outlined_button/outlined_button.0.dart ** +/// {@end-tool} +/// +/// The static [styleFrom] method is a convenient way to create a +/// outlined button [ButtonStyle] from simple values. +/// +/// See also: +/// +/// * [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. +/// * <https://material.io/design/components/buttons.html> +/// * <https://m3.material.io/components/buttons> +class OutlinedButton extends ButtonStyleButton { + /// Create an OutlinedButton. + const OutlinedButton({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior, + super.statesController, + required super.child, + }) : _addPadding = false; + + /// Create an outlined button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 12 logical pixels + /// at the start, and 16 at the end, with an 8 pixel gap in between. + /// + /// If [icon] is null, this constructor will create an outlined button + /// that doesn't display an icon. + /// + /// {@macro flutter.material.ButtonStyle.iconAlignment} + /// + OutlinedButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior, + super.statesController, + Widget? icon, + required Widget label, + IconAlignment? iconAlignment, + }) : _addPadding = icon != null, + super( + child: icon != null + ? _OutlinedButtonWithIconChild( + label: label, + icon: icon, + buttonStyle: style, + iconAlignment: iconAlignment, + ) + : label, + ); + + final bool _addPadding; + + /// A static convenience method that constructs an outlined button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] and [disabledForegroundColor] colors are used + /// to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], and + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// The [backgroundColor] and [disabledBackgroundColor] colors are + /// used to create a [WidgetStateProperty] [ButtonStyle.backgroundColor]. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// If [iconColor] is null, the button icon will use [foregroundColor]. If [foregroundColor] is also + /// null, the button icon will use the default icon color. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// All of the other parameters are either used directly or used to + /// create a [WidgetStateProperty] 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 shape and outline for an + /// [OutlinedButton], one could write: + /// + /// ```dart + /// OutlinedButton( + /// style: OutlinedButton.styleFrom( + /// shape: const StadiumBorder(), + /// side: const BorderSide(width: 2, color: Colors.green), + /// ), + /// child: const Text('Seasons of Love'), + /// onPressed: () { + /// // ... + /// }, + /// ), + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + IconAlignment? iconAlignment, + Color? disabledIconColor, + Color? overlayColor, + 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, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, + }) { + final WidgetStateProperty<Color?>? backgroundColorProp = switch (( + backgroundColor, + disabledBackgroundColor, + )) { + (_?, null) => WidgetStatePropertyAll<Color?>(backgroundColor), + (_, _) => ButtonStyleButton.defaultColor(backgroundColor, disabledBackgroundColor), + }; + final WidgetStateProperty<Color?>? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, Color(a: 0.0)) => WidgetStatePropertyAll<Color?>(overlayColor), + (_, final Color color) || + (final Color color, _) => WidgetStateProperty<Color?>.fromMap(<WidgetState, Color?>{ + WidgetState.pressed: color.withOpacity(0.1), + WidgetState.hovered: color.withOpacity(0.08), + WidgetState.focused: color.withOpacity(0.1), + }), + }; + + return ButtonStyle( + textStyle: ButtonStyleButton.allOrNull<TextStyle>(textStyle), + foregroundColor: ButtonStyleButton.defaultColor(foregroundColor, disabledForegroundColor), + backgroundColor: backgroundColorProp, + overlayColor: overlayColorProp, + shadowColor: ButtonStyleButton.allOrNull<Color>(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull<Color>(surfaceTintColor), + iconColor: ButtonStyleButton.defaultColor(iconColor, disabledIconColor), + iconSize: ButtonStyleButton.allOrNull<double>(iconSize), + iconAlignment: iconAlignment, + elevation: ButtonStyleButton.allOrNull<double>(elevation), + padding: ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(padding), + minimumSize: ButtonStyleButton.allOrNull<Size>(minimumSize), + fixedSize: ButtonStyleButton.allOrNull<Size>(fixedSize), + maximumSize: ButtonStyleButton.allOrNull<Size>(maximumSize), + side: ButtonStyleButton.allOrNull<BorderSide>(side), + shape: ButtonStyleButton.allOrNull<OutlinedBorder>(shape), + mouseCursor: WidgetStateProperty<MouseCursor?>.fromMap(<WidgetStatesConstraint, MouseCursor?>{ + WidgetState.disabled: disabledMouseCursor, + WidgetState.any: enabledMouseCursor, + }), + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, + ); + } + + /// Defines the button's default appearance. + /// + /// With the exception of [ButtonStyle.side], which defines the + /// outline, and [ButtonStyle.padding], the returned style is the + /// same as for [TextButton]. + /// + /// 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 and is transparent by default. + /// + /// 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)`. [WidgetStateProperty] 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 color of the [ButtonStyle.textStyle] is not used, the + /// [ButtonStyle.foregroundColor] is used instead. + /// + /// ## Material 2 defaults + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.12) + /// * `shadowColor` - Theme.shadowColor + /// * `elevation` - 0 + /// * `padding` + /// * `default font size <= 14` - horizontal(16) + /// * `14 < default font size <= 28` - lerp(horizontal(16), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - BorderSide(width: 1, color: Theme.colorScheme.onSurface(0.12)) + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - InkRipple.splashFactory + /// + /// ## Material 3 defaults + /// + /// If [ThemeData.useMaterial3] is set to true the following defaults will + /// be used: + /// + /// * `textStyle` - Theme.textTheme.labelLarge + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.1) + /// * others - null + /// * `shadowColor` - Colors.transparent, + /// * `surfaceTintColor` - null + /// * `elevation` - 0 + /// * `padding` + /// * `default font size <= 14` - horizontal(24) + /// * `14 < default font size <= 28` - lerp(horizontal(24), horizontal(12)) + /// * `28 < default font size <= 36` - lerp(horizontal(12), horizontal(6)) + /// * `36 < default font size` - horizontal(6) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` + /// * disabled - BorderSide(color: Theme.colorScheme.onSurface(0.12)) + /// * others - BorderSide(color: Theme.colorScheme.outline) + /// * `shape` - StadiumBorder() + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + /// + /// For the [OutlinedButton.icon] factory, the start (generally the left) value of + /// [ButtonStyle.padding] is reduced from 24 to 16. + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final ButtonStyle buttonStyle = theme.useMaterial3 + ? _OutlinedButtonDefaultsM3(context) + : styleFrom( + foregroundColor: colorScheme.primary, + disabledForegroundColor: colorScheme.onSurface.withOpacity(0.38), + backgroundColor: Colors.transparent, + disabledBackgroundColor: Colors.transparent, + shadowColor: theme.shadowColor, + elevation: 0, + textStyle: theme.textTheme.labelLarge, + padding: _scaledPadding(context), + minimumSize: const Size(64, 36), + maximumSize: Size.infinite, + side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + enabledMouseCursor: kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + disabledMouseCursor: SystemMouseCursors.basic, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + splashFactory: InkRipple.splashFactory, + ); + + // Only apply paddings when OutlinedButton has an Icon + if (_addPadding && theme.useMaterial3) { + final double defaultFontSize = + buttonStyle.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(16, 0, 24, 0), + const EdgeInsetsDirectional.fromSTEB(8, 0, 12, 0), + const EdgeInsetsDirectional.fromSTEB(4, 0, 6, 0), + effectiveTextScale, + ); + return buttonStyle.copyWith( + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(scaledPadding), + ); + } + + return buttonStyle; + } + + @override + ButtonStyle? themeStyleOf(BuildContext context) { + return OutlinedButtonTheme.of(context).style; + } +} + +EdgeInsetsGeometry _scaledPadding(BuildContext context) { + final ThemeData theme = Theme.of(context); + final padding1x = theme.useMaterial3 ? 24.0 : 16.0; + final double defaultFontSize = theme.textTheme.labelLarge?.fontSize ?? 14.0; + final double effectiveTextScale = MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + return ButtonStyleButton.scaledPadding( + EdgeInsets.symmetric(horizontal: padding1x), + EdgeInsets.symmetric(horizontal: padding1x / 2), + EdgeInsets.symmetric(horizontal: padding1x / 2 / 2), + effectiveTextScale, + ); +} + +class _OutlinedButtonWithIconChild extends StatelessWidget { + const _OutlinedButtonWithIconChild({ + required this.label, + required this.icon, + required this.buttonStyle, + required this.iconAlignment, + }); + + final Widget label; + final Widget icon; + final ButtonStyle? buttonStyle; + final IconAlignment? iconAlignment; + + @override + Widget build(BuildContext context) { + final double defaultFontSize = + buttonStyle?.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double scale = + clampDouble(MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0, 1.0, 2.0) - 1.0; + + final OutlinedButtonThemeData outlinedButtonTheme = OutlinedButtonTheme.of(context); + final IconAlignment effectiveIconAlignment = + iconAlignment ?? + outlinedButtonTheme.style?.iconAlignment ?? + buttonStyle?.iconAlignment ?? + IconAlignment.start; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: lerpDouble(8, 4, scale)!, + children: effectiveIconAlignment == IconAlignment.start + ? <Widget>[icon, Flexible(child: label)] + : <Widget>[Flexible(child: label), icon], + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - OutlinedButton + +// 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. + +// dart format off +class _OutlinedButtonDefaultsM3 extends ButtonStyle { + _OutlinedButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty<TextStyle?> get textStyle => + MaterialStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge); + + @override + WidgetStateProperty<Color?>? get backgroundColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return null; + }); + + @override + WidgetStateProperty<Color>? get shadowColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(0.0); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + MaterialStatePropertyAll<EdgeInsetsGeometry>(_scaledPadding(context)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(64.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(18.0); + + @override + WidgetStateProperty<Color>? get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary; + } + if (states.contains(WidgetState.focused)) { + return _colors.primary; + } + return _colors.primary; + }); + } + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + @override + WidgetStateProperty<BorderSide>? get side => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: _colors.onSurface.withOpacity(0.12)); + } + if (states.contains(WidgetState.focused)) { + return BorderSide(color: _colors.primary); + } + return BorderSide(color: _colors.outline); + }); + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - OutlinedButton diff --git a/packages/material_ui/lib/src/outlined_button_theme.dart b/packages/material_ui/lib/src/outlined_button_theme.dart new file mode 100644 index 000000000000..e50331fd8beb --- /dev/null +++ b/packages/material_ui/lib/src/outlined_button_theme.dart @@ -0,0 +1,127 @@ +// 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. + +/// @docImport 'outlined_button.dart'; +library; + +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 +/// [OutlinedButton]s when it's used with [OutlinedButtonTheme] or with the +/// overall [Theme]'s [ThemeData.outlinedButtonTheme]. +/// +/// The [style]'s properties override [OutlinedButton]'s default style, +/// i.e. the [ButtonStyle] returned by [OutlinedButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [WidgetStateProperty] values are used. +/// +/// See also: +/// +/// * [OutlinedButtonTheme], the theme which is configured with this class. +/// * [OutlinedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for outlined buttons. +/// * [OutlinedButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [OutlinedButton]'s defaults. +/// * [WidgetStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [WidgetState]s. +/// * [ThemeData.outlinedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [OutlinedButton]s below the overall [Theme]. +@immutable +class OutlinedButtonThemeData with Diagnosticable { + /// Creates a [OutlinedButtonThemeData]. + /// + /// The [style] may be null. + const OutlinedButtonThemeData({this.style}); + + /// Overrides for [OutlinedButton]'s default style. + /// + /// Non-null properties or non-null resolved [WidgetStateProperty] + /// values override the [ButtonStyle] returned by + /// [OutlinedButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Linearly interpolate between two outlined button themes. + static OutlinedButtonThemeData? lerp( + OutlinedButtonThemeData? a, + OutlinedButtonThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return OutlinedButtonThemeData(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 OutlinedButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [OutlinedButton] descendants. +/// +/// See also: +/// +/// * [OutlinedButtonThemeData], which is used to configure this theme. +/// * [OutlinedButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for outlined buttons. +/// * [OutlinedButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [OutlinedButton]'s defaults. +/// * [ThemeData.outlinedButtonTheme], which can be used to override the default +/// [ButtonStyle] for [OutlinedButton]s below the overall [Theme]. +class OutlinedButtonTheme extends InheritedTheme { + /// Create a [OutlinedButtonTheme]. + const OutlinedButtonTheme({super.key, required this.data, required super.child}); + + /// The configuration of this theme. + final OutlinedButtonThemeData data; + + /// Retrieves the [OutlinedButtonThemeData] from the closest ancestor [OutlinedButtonTheme]. + /// + /// If there is no enclosing [OutlinedButtonTheme] widget, then + /// [ThemeData.outlinedButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// OutlinedButtonThemeData theme = OutlinedButtonTheme.of(context); + /// ``` + static OutlinedButtonThemeData of(BuildContext context) { + final OutlinedButtonTheme? buttonTheme = context + .dependOnInheritedWidgetOfExactType<OutlinedButtonTheme>(); + return buttonTheme?.data ?? Theme.of(context).outlinedButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return OutlinedButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(OutlinedButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/page.dart b/packages/material_ui/lib/src/page.dart new file mode 100644 index 000000000000..d845517df204 --- /dev/null +++ b/packages/material_ui/lib/src/page.dart @@ -0,0 +1,287 @@ +// 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:cupertino_ui/cupertino_ui.dart'; + +import 'page_transitions_theme.dart'; +import 'theme.dart'; + +/// A modal route that replaces the entire screen with a platform-adaptive +/// transition. +/// +/// {@macro flutter.material.materialRouteTransitionMixin} +/// +/// By default, when a modal route is replaced by another, the previous route +/// remains in memory. To free all the resources when this is not necessary, set +/// [maintainState] to false. +/// +/// The `fullscreenDialog` property specifies whether the incoming route is a +/// fullscreen modal dialog. On iOS, those routes animate from the bottom to the +/// top rather than horizontally. +/// +/// If `barrierDismissible` is true, then pressing the escape key on the keyboard +/// will cause the current route to be popped with null as the value. +/// +/// The type `T` specifies the return type of the route which can be supplied as +/// the route is popped from the stack via [Navigator.pop] by providing the +/// optional `result` argument. +/// +/// See also: +/// +/// * [MaterialRouteTransitionMixin], which provides the material transition +/// for this route. +/// * [MaterialPage], which is a [Page] of this class. +class MaterialPageRoute<T> extends PageRoute<T> with MaterialRouteTransitionMixin<T> { + /// Construct a MaterialPageRoute whose contents are defined by [builder]. + MaterialPageRoute({ + required this.builder, + super.settings, + super.requestFocus, + this.maintainState = true, + super.fullscreenDialog, + super.allowSnapshotting = true, + super.barrierDismissible = false, + super.traversalEdgeBehavior, + super.directionalTraversalEdgeBehavior, + }) { + assert(opaque); + } + + /// Builds the primary contents of the route. + final WidgetBuilder builder; + + @override + Widget buildContent(BuildContext context) => builder(context); + + @override + final bool maintainState; + + @override + String get debugLabel => '${super.debugLabel}(${settings.name})'; +} + +/// A mixin that provides platform-adaptive transitions for a [PageRoute]. +/// +/// {@template flutter.material.materialRouteTransitionMixin} +/// For Android, the entrance transition for the page zooms in and fades in +/// while the exiting page zooms out and fades out. The exit transition is similar, +/// but in reverse. +/// +/// For iOS, the page slides in from the right and exits in reverse. The page +/// also shifts to the left in parallax when another page enters to cover it. +/// (These directions are flipped in environments with a right-to-left reading +/// direction.) +/// {@endtemplate} +/// +/// See also: +/// +/// * [PageTransitionsTheme], which defines the default page transitions used +/// by the [MaterialRouteTransitionMixin.buildTransitions]. +/// * [ZoomPageTransitionsBuilder], which is the default page transition used +/// by the [PageTransitionsTheme]. +/// * [CupertinoPageTransitionsBuilder], which is the default page transition +/// for iOS and macOS. +mixin MaterialRouteTransitionMixin<T> on PageRoute<T> { + /// Builds the primary contents of the route. + @protected + Widget buildContent(BuildContext context); + + @override + Duration get transitionDuration => + _getPageTransitionBuilder(navigator!.context)?.transitionDuration ?? + const Duration(microseconds: 300); + + @override + Duration get reverseTransitionDuration => + _getPageTransitionBuilder(navigator!.context)?.reverseTransitionDuration ?? + const Duration(microseconds: 300); + + PageTransitionsBuilder? _getPageTransitionBuilder(BuildContext context) { + final TargetPlatform platform = Theme.of(context).platform; + final PageTransitionsTheme pageTransitionsTheme = Theme.of(context).pageTransitionsTheme; + return pageTransitionsTheme.builders[platform] ?? + switch (platform) { + TargetPlatform.iOS || TargetPlatform.macOS => const CupertinoPageTransitionsBuilder(), + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.windows || + TargetPlatform.linux => const ZoomPageTransitionsBuilder(), + }; + } + + // The transitionDuration is used to create the AnimationController which is only + // built once, so when page transition builder is updated and transitionDuration + // has a new value, the AnimationController cannot be updated automatically. So we + // manually update its duration here. + // TODO(quncCccccc): Clean up this override method when controller can be updated as the transitionDuration is changed. + @override + TickerFuture didPush() { + controller?.duration = transitionDuration; + return super.didPush(); + } + + // The reverseTransitionDuration is used to create the AnimationController + // which is only built once, so when page transition builder is updated and + // reverseTransitionDuration has a new value, the AnimationController cannot + // be updated automatically. So we manually update its reverseDuration here. + // TODO(quncCccccc): Clean up this override method when controller can beupdated as the reverseTransitionDuration is changed. + @override + bool didPop(T? result) { + controller?.reverseDuration = reverseTransitionDuration; + return super.didPop(result); + } + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + DelegatedTransitionBuilder? get delegatedTransition => _delegatedTransition; + + static Widget? _delegatedTransition( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) { + final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme; + final TargetPlatform platform = Theme.of(context).platform; + final DelegatedTransitionBuilder? themeDelegatedTransition = theme.delegatedTransition( + platform, + ); + return themeDelegatedTransition != null + ? themeDelegatedTransition(context, animation, secondaryAnimation, allowSnapshotting, child) + : null; + } + + @override + bool canTransitionTo(TransitionRoute<dynamic> nextRoute) { + // Don't perform outgoing animation if the next route is a fullscreen dialog, + // or there is no matching transition to use. + // Don't perform outgoing animation if the next route is a fullscreen dialog. + final bool nextRouteIsNotFullscreen = + (nextRoute is! PageRoute<T>) || !nextRoute.fullscreenDialog; + + // If the next route has a delegated transition, then this route is able to + // use that delegated transition to smoothly sync with the next route's + // transition. + final bool nextRouteHasDelegatedTransition = + nextRoute is ModalRoute<T> && nextRoute.delegatedTransition != null; + + // Otherwise if the next route has the same route transition mixin as this + // one, then this route will already be synced with its transition. + return nextRouteIsNotFullscreen && + ((nextRoute is MaterialRouteTransitionMixin) || nextRouteHasDelegatedTransition); + } + + @override + bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) { + // Suppress previous route from transitioning if this is a fullscreenDialog route. + return previousRoute is PageRoute && !fullscreenDialog; + } + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + final Widget result = buildContent(context); + return Semantics(scopesRoute: true, explicitChildNodes: true, child: result); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme; + return theme.buildTransitions<T>(this, context, animation, secondaryAnimation, child); + } +} + +/// A page that creates a material style [PageRoute]. +/// +/// {@macro flutter.material.materialRouteTransitionMixin} +/// +/// By default, when the created route is replaced by another, the previous +/// route remains in memory. To free all the resources when this is not +/// necessary, set [maintainState] to false. +/// +/// The `fullscreenDialog` property specifies whether the created route is a +/// fullscreen modal dialog. On iOS, those routes animate from the bottom to the +/// top rather than horizontally. +/// +/// The type `T` specifies the return type of the route which can be supplied as +/// the route is popped from the stack via [Navigator.transitionDelegate] by +/// providing the optional `result` argument to the +/// [RouteTransitionRecord.markForPop] in the [TransitionDelegate.resolve]. +/// +/// See also: +/// +/// * [MaterialPageRoute], which is the [PageRoute] version of this class +class MaterialPage<T> extends Page<T> { + /// Creates a material page. + const MaterialPage({ + required this.child, + this.maintainState = true, + this.fullscreenDialog = false, + this.allowSnapshotting = true, + super.key, + super.canPop, + super.onPopInvoked, + super.name, + super.arguments, + super.restorationId, + }); + + /// The content to be shown in the [Route] created by this page. + final Widget child; + + /// {@macro flutter.widgets.ModalRoute.maintainState} + final bool maintainState; + + /// {@macro flutter.widgets.PageRoute.fullscreenDialog} + final bool fullscreenDialog; + + /// {@macro flutter.widgets.TransitionRoute.allowSnapshotting} + final bool allowSnapshotting; + + @override + Route<T> createRoute(BuildContext context) { + return _PageBasedMaterialPageRoute<T>(page: this, allowSnapshotting: allowSnapshotting); + } +} + +// A page-based version of MaterialPageRoute. +// +// This route uses the builder from the page to build its content. This ensures +// the content is up to date after page updates. +class _PageBasedMaterialPageRoute<T> extends PageRoute<T> with MaterialRouteTransitionMixin<T> { + _PageBasedMaterialPageRoute({required MaterialPage<T> page, super.allowSnapshotting}) + : super(settings: page) { + assert(opaque); + } + + MaterialPage<T> get _page => settings as MaterialPage<T>; + + @override + Widget buildContent(BuildContext context) { + return _page.child; + } + + @override + bool get maintainState => _page.maintainState; + + @override + bool get fullscreenDialog => _page.fullscreenDialog; + + @override + String get debugLabel => '${super.debugLabel}(${_page.name})'; +} diff --git a/packages/material_ui/lib/src/page_transitions_theme.dart b/packages/material_ui/lib/src/page_transitions_theme.dart new file mode 100644 index 000000000000..c9aeb3645f98 --- /dev/null +++ b/packages/material_ui/lib/src/page_transitions_theme.dart @@ -0,0 +1,1307 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'page.dart'; +/// @docImport 'predictive_back_page_transitions_builder.dart'; +library; + +import 'dart:ui' as ui; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'predictive_back_page_transitions_builder.dart'; +import 'theme.dart'; + +// Zooms and fades a new page in, zooming out the previous page. This transition +// is designed to match the Android Q activity transition. +class _ZoomPageTransition extends StatelessWidget { + /// Creates a [_ZoomPageTransition]. + /// + /// The [animation] and [secondaryAnimation] arguments are required and must + /// not be null. + const _ZoomPageTransition({ + required this.animation, + required this.secondaryAnimation, + required this.allowSnapshotting, + required this.allowEnterRouteSnapshotting, + this.backgroundColor, + this.child, + }); + + // A curve sequence that is similar to the 'fastOutExtraSlowIn' curve used in + // the native transition. + static final List<TweenSequenceItem<double>> fastOutExtraSlowInTweenSequenceItems = + <TweenSequenceItem<double>>[ + TweenSequenceItem<double>( + tween: Tween<double>( + begin: 0.0, + end: 0.4, + ).chain(CurveTween(curve: const Cubic(0.05, 0.0, 0.133333, 0.06))), + weight: 0.166666, + ), + TweenSequenceItem<double>( + tween: Tween<double>( + begin: 0.4, + end: 1.0, + ).chain(CurveTween(curve: const Cubic(0.208333, 0.82, 0.25, 1.0))), + weight: 1.0 - 0.166666, + ), + ]; + static final TweenSequence<double> _scaleCurveSequence = TweenSequence<double>( + fastOutExtraSlowInTweenSequenceItems, + ); + + /// The animation that drives the [child]'s entrance and exit. + /// + /// See also: + /// + /// * [TransitionRoute.animation], which is the value given to this property + /// when the [_ZoomPageTransition] is used as a page transition. + final Animation<double> animation; + + /// The animation that transitions [child] when new content is pushed on top + /// of it. + /// + /// See also: + /// + /// * [TransitionRoute.secondaryAnimation], which is the value given to this + /// property when the [_ZoomPageTransition] is used as a page transition. + final Animation<double> secondaryAnimation; + + /// Whether the [SnapshotWidget] will be used. + /// + /// When this value is true, performance is improved by disabling animations + /// on both the outgoing and incoming route. This also implies that ink-splashes + /// or similar animations will not animate during the transition. + /// + /// See also: + /// + /// * [TransitionRoute.allowSnapshotting], which defines whether the route + /// transition will prefer to animate a snapshot of the entering and exiting + /// routes. + final bool allowSnapshotting; + + /// The color of the scrim (background) that fades in and out during the transition. + /// + /// If not provided, defaults to current theme's [ColorScheme.surface] color. + final Color? backgroundColor; + + /// The widget below this widget in the tree. + /// + /// This widget will transition in and out as driven by [animation] and + /// [secondaryAnimation]. + final Widget? child; + + /// Whether to enable snapshotting on the entering route during the + /// transition animation. + /// + /// If not specified, defaults to true. + /// If false, the route snapshotting will not be applied to the route being + /// animating into, e.g. when transitioning from route A to route B, B will + /// not be snapshotted. + final bool allowEnterRouteSnapshotting; + + @override + Widget build(BuildContext context) { + final Color enterTransitionBackgroundColor = + backgroundColor ?? Theme.of(context).colorScheme.surface; + return DualTransitionBuilder( + animation: animation, + forwardBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomEnterTransition( + animation: animation, + allowSnapshotting: allowSnapshotting && allowEnterRouteSnapshotting, + backgroundColor: enterTransitionBackgroundColor, + child: child, + ); + }, + reverseBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomExitTransition( + animation: animation, + allowSnapshotting: allowSnapshotting, + reverse: true, + child: child, + ); + }, + child: ZoomPageTransitionsBuilder._snapshotAwareDelegatedTransition( + context, + animation, + secondaryAnimation, + child, + allowSnapshotting, + allowEnterRouteSnapshotting, + enterTransitionBackgroundColor, + ), + ); + } +} + +class _ZoomEnterTransition extends StatefulWidget { + const _ZoomEnterTransition({ + required this.animation, + this.reverse = false, + required this.allowSnapshotting, + required this.backgroundColor, + this.child, + }); + + final Animation<double> animation; + final Widget? child; + final bool allowSnapshotting; + final bool reverse; + final Color backgroundColor; + + @override + State<_ZoomEnterTransition> createState() => _ZoomEnterTransitionState(); +} + +class _ZoomEnterTransitionState extends State<_ZoomEnterTransition> + with _ZoomTransitionBase<_ZoomEnterTransition> { + // See SnapshotWidget doc comment, this is disabled on web because the canvaskit backend uses a + // single thread for UI and raster work which diminishes the impact of this performance improvement. + @override + bool get useSnapshot => !kIsWeb && widget.allowSnapshotting; + + late _ZoomEnterTransitionPainter delegate; + + static final Animatable<double> _fadeInTransition = Tween<double>( + begin: 0.0, + end: 1.00, + ).chain(CurveTween(curve: const Interval(0.125, 0.250))); + + static final Animatable<double> _scaleDownTransition = Tween<double>( + begin: 1.10, + end: 1.00, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + static final Animatable<double> _scaleUpTransition = Tween<double>( + begin: 0.85, + end: 1.00, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + static final Animatable<double?> _scrimOpacityTween = Tween<double?>( + begin: 0.0, + end: 0.60, + ).chain(CurveTween(curve: const Interval(0.2075, 0.4175))); + + void _updateAnimations() { + fadeTransition = widget.reverse + ? kAlwaysCompleteAnimation + : _fadeInTransition.animate(widget.animation); + + scaleTransition = (widget.reverse ? _scaleDownTransition : _scaleUpTransition).animate( + widget.animation, + ); + + widget.animation.addListener(onAnimationValueChange); + widget.animation.addStatusListener(onAnimationStatusChange); + } + + @override + void initState() { + _updateAnimations(); + delegate = _ZoomEnterTransitionPainter( + reverse: widget.reverse, + fade: fadeTransition, + scale: scaleTransition, + animation: widget.animation, + backgroundColor: widget.backgroundColor, + ); + super.initState(); + } + + @override + void didUpdateWidget(covariant _ZoomEnterTransition oldWidget) { + if (oldWidget.reverse != widget.reverse || oldWidget.animation != widget.animation) { + oldWidget.animation.removeListener(onAnimationValueChange); + oldWidget.animation.removeStatusListener(onAnimationStatusChange); + _updateAnimations(); + delegate.dispose(); + delegate = _ZoomEnterTransitionPainter( + reverse: widget.reverse, + fade: fadeTransition, + scale: scaleTransition, + animation: widget.animation, + backgroundColor: widget.backgroundColor, + ); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.animation.removeListener(onAnimationValueChange); + widget.animation.removeStatusListener(onAnimationStatusChange); + delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SnapshotWidget( + painter: delegate, + controller: controller, + mode: SnapshotMode.permissive, + autoresize: true, + child: widget.child, + ); + } +} + +class _ZoomExitTransition extends StatefulWidget { + const _ZoomExitTransition({ + required this.animation, + this.reverse = false, + required this.allowSnapshotting, + this.child, + }); + + final Animation<double> animation; + final bool allowSnapshotting; + final bool reverse; + final Widget? child; + + @override + State<_ZoomExitTransition> createState() => _ZoomExitTransitionState(); +} + +class _ZoomExitTransitionState extends State<_ZoomExitTransition> + with _ZoomTransitionBase<_ZoomExitTransition> { + late _ZoomExitTransitionPainter delegate; + + // See SnapshotWidget doc comment, this is disabled on web because the canvaskit backend uses a + // single thread for UI and raster work which diminishes the impact of this performance improvement. + @override + bool get useSnapshot => !kIsWeb && widget.allowSnapshotting; + + static final Animatable<double> _fadeOutTransition = Tween<double>( + begin: 1.0, + end: 0.0, + ).chain(CurveTween(curve: const Interval(0.0825, 0.2075))); + + static final Animatable<double> _scaleUpTransition = Tween<double>( + begin: 1.00, + end: 1.05, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + static final Animatable<double> _scaleDownTransition = Tween<double>( + begin: 1.00, + end: 0.90, + ).chain(_ZoomPageTransition._scaleCurveSequence); + + void _updateAnimations() { + fadeTransition = widget.reverse + ? _fadeOutTransition.animate(widget.animation) + : kAlwaysCompleteAnimation; + scaleTransition = (widget.reverse ? _scaleDownTransition : _scaleUpTransition).animate( + widget.animation, + ); + + widget.animation.addListener(onAnimationValueChange); + widget.animation.addStatusListener(onAnimationStatusChange); + } + + @override + void initState() { + _updateAnimations(); + delegate = _ZoomExitTransitionPainter( + reverse: widget.reverse, + fade: fadeTransition, + scale: scaleTransition, + animation: widget.animation, + ); + super.initState(); + } + + @override + void didUpdateWidget(covariant _ZoomExitTransition oldWidget) { + if (oldWidget.reverse != widget.reverse || oldWidget.animation != widget.animation) { + oldWidget.animation.removeListener(onAnimationValueChange); + oldWidget.animation.removeStatusListener(onAnimationStatusChange); + _updateAnimations(); + delegate.dispose(); + delegate = _ZoomExitTransitionPainter( + reverse: widget.reverse, + fade: fadeTransition, + scale: scaleTransition, + animation: widget.animation, + ); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.animation.removeListener(onAnimationValueChange); + widget.animation.removeStatusListener(onAnimationStatusChange); + delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SnapshotWidget( + painter: delegate, + controller: controller, + mode: SnapshotMode.permissive, + autoresize: true, + child: widget.child, + ); + } +} + +// This transition slides a new page in from right to left while fading it in, +// and simultaneously slides the previous page out to the left while fading it out. +// This transition is designed to match the Android U activity transition. +class _FadeForwardsPageTransition extends StatelessWidget { + const _FadeForwardsPageTransition({ + required this.animation, + required this.secondaryAnimation, + this.backgroundColor, + this.child, + }); + + final Animation<double> animation; + + final Animation<double> secondaryAnimation; + + final Color? backgroundColor; + + final Widget? child; + + // The new page slides in from right to left. + static final Animatable<Offset> _forwardTranslationTween = Tween<Offset>( + begin: const Offset(0.25, 0.0), + end: Offset.zero, + ).chain(CurveTween(curve: FadeForwardsPageTransitionsBuilder._transitionCurve)); + + // The old page slides back from left to right. + static final Animatable<Offset> _backwardTranslationTween = Tween<Offset>( + begin: Offset.zero, + end: const Offset(0.25, 0.0), + ).chain(CurveTween(curve: FadeForwardsPageTransitionsBuilder._transitionCurve)); + + @override + Widget build(BuildContext context) { + return DualTransitionBuilder( + animation: animation, + forwardBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return FadeTransition( + opacity: FadeForwardsPageTransitionsBuilder._fadeInTransition.animate(animation), + child: SlideTransition( + position: _forwardTranslationTween.animate(animation), + child: child, + ), + ); + }, + reverseBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return IgnorePointer( + ignoring: animation.status == AnimationStatus.forward, + child: FadeTransition( + opacity: FadeForwardsPageTransitionsBuilder._fadeOutTransition.animate(animation), + child: SlideTransition( + position: _backwardTranslationTween.animate(animation), + child: child, + ), + ), + ); + }, + child: FadeForwardsPageTransitionsBuilder._delegatedTransition( + context, + secondaryAnimation, + backgroundColor, + child, + ), + ); + } +} + +/// Used by [PageTransitionsTheme] to define a horizontal [MaterialPageRoute] page +/// transition animation that looks like the default page transition +/// used on Android U. +/// +/// {@tool dartpad} +/// This example shows the default page transition on Android. +/// +/// ** See code in examples/api/lib/material/page_transitions_theme/page_transitions_theme.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [FadeUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android O. +/// * [OpenUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android P. +/// * [ZoomPageTransitionsBuilder], which defines the default page transition +/// that's similar to the one provided in Android Q. +/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page +/// transition that matches native iOS page transitions. +/// * [PredictiveBackPageTransitionsBuilder], which defines a page +/// transition that allows peeking behind the current route on Android. +/// * [FadeForwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android U. +class FadeForwardsPageTransitionsBuilder extends PageTransitionsBuilder { + /// Constructs a page transition animation that matches the transition used on + /// Android U. + const FadeForwardsPageTransitionsBuilder({this.backgroundColor}); + + /// The background color during transition between two routes. + /// + /// When a new page fades in and the old page fades out, this background color + /// helps avoid a black background between two page. + /// + /// Defaults to [ColorScheme.surface] + final Color? backgroundColor; + + /// The value of [transitionDuration] in milliseconds. + /// + /// Eyeballed on a physical Pixel 9 running Android 16. This does not match + /// the actual value used by native Android, which is 800ms, because native + /// Android is using Material 3 Expressive springs that are not currently + /// supported by Flutter. So for now at least, this is an approximation. + static const int kTransitionMilliseconds = 450; + + @override + Duration get transitionDuration => const Duration(milliseconds: kTransitionMilliseconds); + + @override + DelegatedTransitionBuilder? get delegatedTransition => + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) => _delegatedTransition(context, secondaryAnimation, backgroundColor, child); + + // Used by all of the sliding transition animations. + static const Curve _transitionCurve = Curves.easeInOutCubicEmphasized; + + // The previous page slides from right to left as the current page appears. + static final Animatable<Offset> _secondaryBackwardTranslationTween = Tween<Offset>( + begin: Offset.zero, + end: const Offset(-0.25, 0.0), + ).chain(CurveTween(curve: _transitionCurve)); + + // The previous page slides from left to right as the current page disappears. + static final Animatable<Offset> _secondaryForwardTranslationTween = Tween<Offset>( + begin: const Offset(-0.25, 0.0), + end: Offset.zero, + ).chain(CurveTween(curve: _transitionCurve)); + + // The fade in transition when the new page appears. + static final Animatable<double> _fadeInTransition = Tween<double>( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: const Interval(0.0, 0.75))); + + // The fade out transition of the old page when the new page appears. + static final Animatable<double> _fadeOutTransition = Tween<double>( + begin: 1.0, + end: 0.0, + ).chain(CurveTween(curve: const Interval(0.0, 0.25))); + + static Widget _delegatedTransition( + BuildContext context, + Animation<double> secondaryAnimation, + Color? backgroundColor, + Widget? child, + ) { + final Widget builder = DualTransitionBuilder( + animation: ReverseAnimation(secondaryAnimation), + forwardBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return FadeTransition( + opacity: _fadeInTransition.animate(animation), + child: SlideTransition( + position: _secondaryForwardTranslationTween.animate(animation), + child: child, + ), + ); + }, + reverseBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return FadeTransition( + opacity: _fadeOutTransition.animate(animation), + child: SlideTransition( + position: _secondaryBackwardTranslationTween.animate(animation), + child: child, + ), + ); + }, + child: child, + ); + + final bool isOpaque = ModalRoute.opaqueOf(context) ?? true; + + if (!isOpaque) { + return builder; + } + + return ColoredBox( + color: secondaryAnimation.isAnimating + ? backgroundColor ?? ColorScheme.of(context).surface + : Colors.transparent, + child: builder, + ); + } + + @override + Widget buildTransitions<T>( + PageRoute<T>? route, + BuildContext? context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + return _FadeForwardsPageTransition( + animation: animation, + secondaryAnimation: secondaryAnimation, + backgroundColor: backgroundColor, + child: child, + ); + } +} + +/// Used by [PageTransitionsTheme] to define a zooming [MaterialPageRoute] page +/// transition animation that looks like the default page transition used on +/// Android Q. +/// +/// See also: +/// +/// * [FadeUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android O. +/// * [OpenUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android P. +/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page +/// transition that matches native iOS page transitions. +/// * [PredictiveBackPageTransitionsBuilder], which defines a page +/// transition that allows peeking behind the current route on Android. +/// * [FadeForwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android U. +class ZoomPageTransitionsBuilder extends PageTransitionsBuilder { + /// Constructs a page transition animation that matches the transition used on + /// Android Q. + const ZoomPageTransitionsBuilder({ + this.allowSnapshotting = true, + this.allowEnterRouteSnapshotting = true, + this.backgroundColor, + }); + + /// Whether zoom page transitions will prefer to animate a snapshot of the entering + /// and exiting routes. + /// + /// If not specified, defaults to true. + /// + /// When this value is true, zoom page transitions will snapshot the entering and + /// exiting routes. These snapshots are then animated in place of the underlying + /// widgets to improve performance of the transition. + /// + /// Generally this means that animations that occur on the entering/exiting route + /// while the route animation plays may appear frozen - unless they are a hero + /// animation or something that is drawn in a separate overlay. + /// + /// {@tool dartpad} + /// This example shows a [MaterialApp] that disables snapshotting for the zoom + /// transitions on Android. + /// + /// ** See code in examples/api/lib/material/page_transitions_theme/page_transitions_theme.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [PageRoute.allowSnapshotting], which enables or disables snapshotting + /// on a per route basis. + final bool allowSnapshotting; + + /// Whether to enable snapshotting on the entering route during the + /// transition animation. + /// + /// If not specified, defaults to true. + /// If false, the route snapshotting will not be applied to the route being + /// animating into, e.g. when transitioning from route A to route B, B will + /// not be snapshotted. + final bool allowEnterRouteSnapshotting; + + /// The color of the scrim (background) that fades in and out during the transition. + /// + /// If not provided, defaults to current theme's [ColorScheme.surface] color. + final Color? backgroundColor; + + // Allows devicelab benchmarks to force disable the snapshotting. This is + // intended to allow us to profile and fix the underlying performance issues + // for the Impeller backend. + static const bool _kProfileForceDisableSnapshotting = bool.fromEnvironment( + 'flutter.benchmarks.force_disable_snapshot', + ); + + @override + DelegatedTransitionBuilder? get delegatedTransition => + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) => _snapshotAwareDelegatedTransition( + context, + animation, + secondaryAnimation, + child, + allowSnapshotting && this.allowSnapshotting, + allowEnterRouteSnapshotting, + backgroundColor, + ); + + // A transition builder that takes into account the snapshotting properties of + // ZoomPageTransitionsBuilder. + static Widget _snapshotAwareDelegatedTransition( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget? child, + bool allowSnapshotting, + bool allowEnterRouteSnapshotting, + Color? backgroundColor, + ) { + final Color enterTransitionBackgroundColor = + backgroundColor ?? Theme.of(context).colorScheme.surface; + return DualTransitionBuilder( + animation: ReverseAnimation(secondaryAnimation), + forwardBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomEnterTransition( + animation: animation, + allowSnapshotting: allowSnapshotting && allowEnterRouteSnapshotting, + reverse: true, + backgroundColor: enterTransitionBackgroundColor, + child: child, + ); + }, + reverseBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomExitTransition( + animation: animation, + allowSnapshotting: allowSnapshotting, + child: child, + ); + }, + child: child, + ); + } + + @override + Widget buildTransitions<T>( + PageRoute<T> route, + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + if (_kProfileForceDisableSnapshotting) { + return _ZoomPageTransitionNoCache( + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + } + return _ZoomPageTransition( + animation: animation, + secondaryAnimation: secondaryAnimation, + allowSnapshotting: allowSnapshotting && route.allowSnapshotting, + allowEnterRouteSnapshotting: allowEnterRouteSnapshotting, + backgroundColor: backgroundColor, + child: child, + ); + } +} + +/// Defines the page transition animations used by [MaterialPageRoute] +/// for different [TargetPlatform]s. +/// +/// The [MaterialPageRoute.buildTransitions] method looks up the +/// current [PageTransitionsTheme] with `Theme.of(context).pageTransitionsTheme` +/// and delegates to [buildTransitions]. +/// +/// If a builder with a matching platform is not found, then the +/// [ZoomPageTransitionsBuilder] is used. +/// +/// {@tool dartpad} +/// This example shows a [MaterialApp] that defines a custom [PageTransitionsTheme]. +/// +/// ** See code in examples/api/lib/material/page_transitions_theme/page_transitions_theme.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ThemeData.pageTransitionsTheme], which defines the default page +/// transitions for the overall theme. +/// * [FadeUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android O. +/// * [OpenUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android P. +/// * [ZoomPageTransitionsBuilder], which defines the default page transition +/// that's similar to the one provided by Android Q. +/// * [FadeForwardsPageTransitionsBuilder], which defines the default page transition +/// that's similar to the one provided by Android U. +/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page +/// transition that matches native iOS page transitions. +@immutable +class PageTransitionsTheme with Diagnosticable { + /// Constructs an object that selects a transition based on the platform. + /// + /// By default the list of builders is: [ZoomPageTransitionsBuilder] + /// for [TargetPlatform.android], [TargetPlatform.windows] and [TargetPlatform.linux] + /// and [CupertinoPageTransitionsBuilder] for [TargetPlatform.iOS] and [TargetPlatform.macOS]. + const PageTransitionsTheme({ + Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders, + }) : _builders = builders; + + static const Map<TargetPlatform, PageTransitionsBuilder> _defaultBuilders = + <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + TargetPlatform.linux: ZoomPageTransitionsBuilder(), + }; + + /// The [PageTransitionsBuilder]s supported by this theme. + Map<TargetPlatform, PageTransitionsBuilder> get builders => _builders; + final Map<TargetPlatform, PageTransitionsBuilder> _builders; + + /// Delegates to the builder for the current [ThemeData.platform]. + /// If a builder for the current platform is not found, then the + /// [ZoomPageTransitionsBuilder] is used. + /// + /// [MaterialPageRoute.buildTransitions] delegates to this method. + Widget buildTransitions<T>( + PageRoute<T> route, + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + return _PageTransitionsThemeTransitions<T>( + builders: builders, + route: route, + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + } + + /// Provides the delegate transition for the target platform. + /// + /// {@macro flutter.widgets.delegatedTransition} + DelegatedTransitionBuilder? delegatedTransition(TargetPlatform platform) { + final PageTransitionsBuilder matchingBuilder = + builders[platform] ?? const ZoomPageTransitionsBuilder(); + + return matchingBuilder.delegatedTransition; + } + + // Map the builders to a list with one PageTransitionsBuilder per platform for + // the operator == overload. + List<PageTransitionsBuilder?> _all(Map<TargetPlatform, PageTransitionsBuilder> builders) { + return TargetPlatform.values.map((TargetPlatform platform) => builders[platform]).toList(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + if (other is PageTransitionsTheme && identical(builders, other.builders)) { + return true; + } + return other is PageTransitionsTheme && + listEquals<PageTransitionsBuilder?>(_all(other.builders), _all(builders)); + } + + @override + int get hashCode => Object.hashAll(_all(builders)); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<Map<TargetPlatform, PageTransitionsBuilder>>( + 'builders', + builders, + defaultValue: PageTransitionsTheme._defaultBuilders, + ), + ); + } +} + +class _PageTransitionsThemeTransitions<T> extends StatefulWidget { + const _PageTransitionsThemeTransitions({ + required this.builders, + required this.route, + required this.animation, + required this.secondaryAnimation, + required this.child, + }); + + final Map<TargetPlatform, PageTransitionsBuilder> builders; + final PageRoute<T> route; + final Animation<double> animation; + final Animation<double> secondaryAnimation; + final Widget child; + + @override + State<_PageTransitionsThemeTransitions<T>> createState() => + _PageTransitionsThemeTransitionsState<T>(); +} + +class _PageTransitionsThemeTransitionsState<T> extends State<_PageTransitionsThemeTransitions<T>> { + TargetPlatform? _transitionPlatform; + + @override + Widget build(BuildContext context) { + TargetPlatform platform = Theme.of(context).platform; + + // If the theme platform is changed in the middle of a pop gesture, keep the + // transition that the gesture began with until the gesture is finished. + if (widget.route.popGestureInProgress) { + _transitionPlatform ??= platform; + platform = _transitionPlatform!; + } else { + _transitionPlatform = null; + } + + final PageTransitionsBuilder matchingBuilder = + widget.builders[platform] ?? + switch (platform) { + TargetPlatform.iOS => const CupertinoPageTransitionsBuilder(), + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.windows || + TargetPlatform.macOS || + TargetPlatform.linux => const ZoomPageTransitionsBuilder(), + }; + return matchingBuilder.buildTransitions<T>( + widget.route, + context, + widget.animation, + widget.secondaryAnimation, + widget.child, + ); + } +} + +// Take an image and draw it centered and scaled. The image is already scaled by the [pixelRatio]. +void _drawImageScaledAndCentered( + PaintingContext context, + ui.Image image, + double scale, + double opacity, + double pixelRatio, +) { + if (scale <= 0.0 || opacity <= 0.0) { + return; + } + final paint = Paint() + ..filterQuality = ui.FilterQuality.medium + ..color = Color.fromRGBO(0, 0, 0, opacity); + final double logicalWidth = image.width / pixelRatio; + final double logicalHeight = image.height / pixelRatio; + final double scaledLogicalWidth = logicalWidth * scale; + final double scaledLogicalHeight = logicalHeight * scale; + final double left = (logicalWidth - scaledLogicalWidth) / 2; + final double top = (logicalHeight - scaledLogicalHeight) / 2; + final dst = Rect.fromLTWH(left, top, scaledLogicalWidth, scaledLogicalHeight); + context.canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + dst, + paint, + ); +} + +void _updateScaledTransform(Matrix4 transform, double scale, Size size) { + transform.setIdentity(); + if (scale == 1.0) { + return; + } + transform.scaleByDouble(scale, scale, scale, 1); + final double dx = ((size.width * scale) - size.width) / 2; + final double dy = ((size.height * scale) - size.height) / 2; + transform.translateByDouble(-dx, -dy, 0, 1); +} + +mixin _ZoomTransitionBase<S extends StatefulWidget> on State<S> { + bool get useSnapshot; + + // Don't rasterize if: + // 1. Rasterization is disabled by the platform. + // 2. The animation is paused/stopped. + // 3. The values of the scale/fade transition do not + // benefit from rasterization. + final SnapshotController controller = SnapshotController(); + + late Animation<double> fadeTransition; + late Animation<double> scaleTransition; + + void onAnimationValueChange() { + if ((scaleTransition.value == 1.0) && + (fadeTransition.value == 0.0 || fadeTransition.value == 1.0)) { + controller.allowSnapshotting = false; + } else { + controller.allowSnapshotting = useSnapshot; + } + } + + void onAnimationStatusChange(AnimationStatus status) { + controller.allowSnapshotting = status.isAnimating && useSnapshot; + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } +} + +class _ZoomEnterTransitionPainter extends SnapshotPainter { + _ZoomEnterTransitionPainter({ + required this.reverse, + required this.scale, + required this.fade, + required this.animation, + required this.backgroundColor, + }) { + animation.addListener(notifyListeners); + animation.addStatusListener(_onStatusChange); + scale.addListener(notifyListeners); + fade.addListener(notifyListeners); + } + + void _onStatusChange(AnimationStatus _) { + notifyListeners(); + } + + final bool reverse; + final Animation<double> animation; + final Animation<double> scale; + final Animation<double> fade; + final Color backgroundColor; + + final Matrix4 _transform = Matrix4.zero(); + final LayerHandle<OpacityLayer> _opacityHandle = LayerHandle<OpacityLayer>(); + final LayerHandle<TransformLayer> _transformHandler = LayerHandle<TransformLayer>(); + + void _drawScrim(PaintingContext context, Offset offset, Size size) { + var scrimOpacity = 0.0; + // The transition's scrim opacity only increases on the forward transition. + // In the reverse transition, the opacity should always be 0.0. + // + // Therefore, we need to only apply the scrim opacity animation when + // the transition is running forwards. + // + // The reason that we check that the animation's status is not `completed` + // instead of checking that it is `forward` is that this allows + // the interrupted reversal of the forward transition to smoothly fade + // the scrim away. This prevents a disjointed removal of the scrim. + if (!reverse && !animation.isCompleted) { + scrimOpacity = _ZoomEnterTransitionState._scrimOpacityTween.evaluate(animation)!; + } + assert(!reverse || scrimOpacity == 0.0); + if (scrimOpacity > 0.0) { + context.canvas.drawRect( + offset & size, + Paint()..color = backgroundColor.withOpacity(scrimOpacity), + ); + } + } + + @override + void paint( + PaintingContext context, + ui.Offset offset, + Size size, + PaintingContextCallback painter, + ) { + if (!animation.isAnimating) { + return painter(context, offset); + } + + _drawScrim(context, offset, size); + _updateScaledTransform(_transform, scale.value, size); + _transformHandler.layer = context.pushTransform(true, offset, _transform, ( + PaintingContext context, + Offset offset, + ) { + _opacityHandle.layer = context.pushOpacity( + offset, + (fade.value * 255).round(), + painter, + oldLayer: _opacityHandle.layer, + ); + }, oldLayer: _transformHandler.layer); + } + + @override + void paintSnapshot( + PaintingContext context, + Offset offset, + Size size, + ui.Image image, + Size sourceSize, + double pixelRatio, + ) { + _drawScrim(context, offset, size); + _drawImageScaledAndCentered(context, image, scale.value, fade.value, pixelRatio); + } + + @override + void dispose() { + animation.removeListener(notifyListeners); + animation.removeStatusListener(_onStatusChange); + scale.removeListener(notifyListeners); + fade.removeListener(notifyListeners); + _opacityHandle.layer = null; + _transformHandler.layer = null; + super.dispose(); + } + + @override + bool shouldRepaint(covariant _ZoomEnterTransitionPainter oldDelegate) { + return oldDelegate.reverse != reverse || + oldDelegate.animation.value != animation.value || + oldDelegate.scale.value != scale.value || + oldDelegate.fade.value != fade.value; + } +} + +class _ZoomExitTransitionPainter extends SnapshotPainter { + _ZoomExitTransitionPainter({ + required this.reverse, + required this.scale, + required this.fade, + required this.animation, + }) { + scale.addListener(notifyListeners); + fade.addListener(notifyListeners); + animation.addStatusListener(_onStatusChange); + } + + void _onStatusChange(AnimationStatus _) { + notifyListeners(); + } + + final bool reverse; + final Animation<double> scale; + final Animation<double> fade; + final Animation<double> animation; + final Matrix4 _transform = Matrix4.zero(); + final LayerHandle<OpacityLayer> _opacityHandle = LayerHandle<OpacityLayer>(); + final LayerHandle<TransformLayer> _transformHandler = LayerHandle<TransformLayer>(); + + @override + void paintSnapshot( + PaintingContext context, + Offset offset, + Size size, + ui.Image image, + Size sourceSize, + double pixelRatio, + ) { + _drawImageScaledAndCentered(context, image, scale.value, fade.value, pixelRatio); + } + + @override + void paint( + PaintingContext context, + ui.Offset offset, + Size size, + PaintingContextCallback painter, + ) { + if (!animation.isAnimating) { + return painter(context, offset); + } + + _updateScaledTransform(_transform, scale.value, size); + _transformHandler.layer = context.pushTransform(true, offset, _transform, ( + PaintingContext context, + Offset offset, + ) { + _opacityHandle.layer = context.pushOpacity( + offset, + (fade.value * 255).round(), + painter, + oldLayer: _opacityHandle.layer, + ); + }, oldLayer: _transformHandler.layer); + } + + @override + bool shouldRepaint(covariant _ZoomExitTransitionPainter oldDelegate) { + return oldDelegate.reverse != reverse || + oldDelegate.fade.value != fade.value || + oldDelegate.scale.value != scale.value; + } + + @override + void dispose() { + _opacityHandle.layer = null; + _transformHandler.layer = null; + scale.removeListener(notifyListeners); + fade.removeListener(notifyListeners); + animation.removeStatusListener(_onStatusChange); + super.dispose(); + } +} + +// Zooms and fades a new page in, zooming out the previous page. This transition +// is designed to match the Android Q activity transition. +// +// This was the historical implementation of the cacheless zoom page transition +// that was too slow to run on the Skia backend. This is being benchmarked on +// the Impeller backend so that we can improve performance enough to restore +// the default behavior. +class _ZoomPageTransitionNoCache extends StatelessWidget { + /// Creates a [_ZoomPageTransitionNoCache]. + /// + /// The [animation] and [secondaryAnimation] argument are required and must + /// not be null. + const _ZoomPageTransitionNoCache({ + required this.animation, + required this.secondaryAnimation, + this.child, + }); + + /// The animation that drives the [child]'s entrance and exit. + /// + /// See also: + /// + /// * [TransitionRoute.animation], which is the value given to this property + /// when the [_ZoomPageTransition] is used as a page transition. + final Animation<double> animation; + + /// The animation that transitions [child] when new content is pushed on top + /// of it. + /// + /// See also: + /// + /// * [TransitionRoute.secondaryAnimation], which is the value given to this + /// property when the [_ZoomPageTransition] is used as a page transition. + final Animation<double> secondaryAnimation; + + /// The widget below this widget in the tree. + /// + /// This widget will transition in and out as driven by [animation] and + /// [secondaryAnimation]. + final Widget? child; + + @override + Widget build(BuildContext context) { + return DualTransitionBuilder( + animation: animation, + forwardBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomEnterTransitionNoCache(animation: animation, child: child); + }, + reverseBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomExitTransitionNoCache(animation: animation, reverse: true, child: child); + }, + child: DualTransitionBuilder( + animation: ReverseAnimation(secondaryAnimation), + forwardBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomEnterTransitionNoCache(animation: animation, reverse: true, child: child); + }, + reverseBuilder: (BuildContext context, Animation<double> animation, Widget? child) { + return _ZoomExitTransitionNoCache(animation: animation, child: child); + }, + child: child, + ), + ); + } +} + +class _ZoomEnterTransitionNoCache extends StatelessWidget { + const _ZoomEnterTransitionNoCache({required this.animation, this.reverse = false, this.child}); + + final Animation<double> animation; + final Widget? child; + final bool reverse; + + @override + Widget build(BuildContext context) { + double opacity = 0; + // The transition's scrim opacity only increases on the forward transition. + // In the reverse transition, the opacity should always be 0.0. + // + // Therefore, we need to only apply the scrim opacity animation when + // the transition is running forwards. + // + // The reason that we check that the animation's status is not `completed` + // instead of checking that it is `forward` is that this allows + // the interrupted reversal of the forward transition to smoothly fade + // the scrim away. This prevents a disjointed removal of the scrim. + if (!reverse && !animation.isCompleted) { + opacity = _ZoomEnterTransitionState._scrimOpacityTween.evaluate(animation)!; + } + + final Animation<double> fadeTransition = reverse + ? kAlwaysCompleteAnimation + : _ZoomEnterTransitionState._fadeInTransition.animate(animation); + + final Animation<double> scaleTransition = + (reverse + ? _ZoomEnterTransitionState._scaleDownTransition + : _ZoomEnterTransitionState._scaleUpTransition) + .animate(animation); + + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return ColoredBox(color: Colors.black.withOpacity(opacity), child: child); + }, + child: FadeTransition( + opacity: fadeTransition, + child: ScaleTransition( + scale: scaleTransition, + filterQuality: FilterQuality.medium, + child: child, + ), + ), + ); + } +} + +class _ZoomExitTransitionNoCache extends StatelessWidget { + const _ZoomExitTransitionNoCache({required this.animation, this.reverse = false, this.child}); + + final Animation<double> animation; + final bool reverse; + final Widget? child; + + @override + Widget build(BuildContext context) { + final Animation<double> fadeTransition = reverse + ? _ZoomExitTransitionState._fadeOutTransition.animate(animation) + : kAlwaysCompleteAnimation; + final Animation<double> scaleTransition = + (reverse + ? _ZoomExitTransitionState._scaleDownTransition + : _ZoomExitTransitionState._scaleUpTransition) + .animate(animation); + + return FadeTransition( + opacity: fadeTransition, + child: ScaleTransition( + scale: scaleTransition, + filterQuality: FilterQuality.medium, + child: child, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/paginated_data_table.dart b/packages/material_ui/lib/src/paginated_data_table.dart new file mode 100644 index 000000000000..c108098d641a --- /dev/null +++ b/packages/material_ui/lib/src/paginated_data_table.dart @@ -0,0 +1,687 @@ +// 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. + +/// @docImport 'card_theme.dart'; +/// @docImport 'data_table_theme.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/widgets.dart'; + +import 'card.dart'; +import 'constants.dart'; +import 'data_table.dart'; +import 'data_table_source.dart'; +import 'debug.dart'; +import 'dropdown.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'ink_decoration.dart'; +import 'material_localizations.dart'; +import 'progress_indicator.dart'; +import 'theme.dart'; + +/// A table that follows the +/// [Material 2](https://material.io/go/design-data-tables) +/// design specification, using multiple pages to display data. +/// +/// A paginated data table shows [rowsPerPage] rows of data per page and +/// provides controls for showing other pages. +/// +/// Data is read lazily from a [DataTableSource]. The widget is presented +/// as a [Card]. +/// +/// If the [key] is a [PageStorageKey], the [initialFirstRowIndex] is persisted +/// to [PageStorage]. +/// +/// {@tool dartpad} +/// +/// This sample shows how to display a [DataTable] with three columns: name, +/// age, and role. The columns are defined by three [DataColumn] objects. The +/// table contains three rows of data for three example users, the data for +/// which is defined by three [DataRow] objects. +/// +/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// +/// This example shows how paginated data tables can supported sorted data. +/// +/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [DataTable], which is not paginated. +/// * `TableView` from the +/// [two_dimensional_scrollables](https://pub.dev/packages/two_dimensional_scrollables) +/// package, for displaying large amounts of data without pagination. +/// * <https://material.io/go/design-data-tables> +class PaginatedDataTable extends StatefulWidget { + /// Creates a widget describing a paginated [DataTable] on a [Card]. + /// + /// The [header] should give the card's header, typically a [Text] widget. + /// + /// The [columns] argument must be a list of as many [DataColumn] objects as + /// the table is to have columns, ignoring the leading checkbox column if any. + /// The [columns] argument must have a length greater than zero and cannot be + /// null. + /// + /// If the table is sorted, the column that provides the current primary key + /// should be specified by index in [sortColumnIndex], 0 meaning the first + /// column in [columns], 1 being the next one, and so forth. + /// + /// The actual sort order can be specified using [sortAscending]; if the sort + /// order is ascending, this should be true (the default), otherwise it should + /// be false. + /// + /// The [source] should be a long-lived [DataTableSource]. The same source + /// should be provided each time a particular [PaginatedDataTable] widget is + /// created; avoid creating a new [DataTableSource] with each new instance of + /// the [PaginatedDataTable] widget unless the data table really is to now + /// show entirely different data from a new source. + /// + /// Themed by [DataTableTheme]. [DataTableThemeData.decoration] is ignored. + /// To modify the border or background color of the [PaginatedDataTable], use + /// [CardTheme], since a [Card] wraps the inner [DataTable]. + PaginatedDataTable({ + super.key, + this.header, + this.actions, + required this.columns, + this.sortColumnIndex, + this.sortAscending = true, + this.onSelectAll, + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + this.headingRowHeight = 56.0, + this.horizontalMargin = 24.0, + this.columnSpacing = 56.0, + this.showCheckboxColumn = true, + this.showFirstLastButtons = false, + this.initialFirstRowIndex = 0, + this.onPageChanged, + this.rowsPerPage = defaultRowsPerPage, + this.availableRowsPerPage = const <int>[ + defaultRowsPerPage, + defaultRowsPerPage * 2, + defaultRowsPerPage * 5, + defaultRowsPerPage * 10, + ], + this.onRowsPerPageChanged, + this.dragStartBehavior = DragStartBehavior.start, + this.arrowHeadColor, + required this.source, + this.checkboxHorizontalMargin, + this.controller, + this.primary, + this.headingRowColor, + this.dividerThickness, + this.showEmptyRows = true, + }) : assert(actions == null || (header != null)), + assert(columns.isNotEmpty), + assert( + sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length), + ), + assert( + dataRowMinHeight == null || + dataRowMaxHeight == null || + dataRowMaxHeight >= dataRowMinHeight, + ), + assert( + dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null), + 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.', + ), + dataRowMinHeight = dataRowHeight ?? dataRowMinHeight, + dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight, + assert(rowsPerPage > 0), + assert(dividerThickness == null || dividerThickness >= 0), + assert(() { + if (onRowsPerPageChanged != null) { + assert(availableRowsPerPage.contains(rowsPerPage)); + } + return true; + }()), + assert( + !(controller != null && (primary ?? false)), + 'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. ' + 'You cannot both set primary to true and pass an explicit controller.', + ); + + /// The table card's optional header. + /// + /// This is typically a [Text] widget, but can also be a [Row] of + /// [TextButton]s. To show icon buttons at the top end side of the table with + /// a header, set the [actions] property. + /// + /// If items in the table are selectable, then, when the selection is not + /// empty, the header is replaced by a count of the selected items. The + /// [actions] are still visible when items are selected. + final Widget? header; + + /// Icon buttons to show at the top end side of the table. The [header] must + /// not be null to show the actions. + /// + /// Typically, the exact actions included in this list will vary based on + /// whether any rows are selected or not. + /// + /// These should be size 24.0 with default padding (8.0). + final List<Widget>? actions; + + /// The configuration and labels for the columns in the table. + final List<DataColumn> columns; + + /// The current primary sort key's column. + /// + /// See [DataTable.sortColumnIndex] for details. + /// + /// The direction of the sort is specified using [sortAscending]. + final int? sortColumnIndex; + + /// Whether the column mentioned in [sortColumnIndex], if any, is sorted + /// in ascending order. + /// + /// See [DataTable.sortAscending] for details. + final bool sortAscending; + + /// Invoked when the user selects or unselects every row, using the + /// checkbox in the heading row. + /// + /// See [DataTable.onSelectAll]. + final ValueSetter<bool?>? onSelectAll; + + /// The height of each row (excluding the row that contains column headings). + /// + /// This value is optional and defaults to kMinInteractiveDimension if not + /// specified. + @Deprecated( + 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. ' + 'This feature was deprecated after v3.7.0-5.0.pre.', + ) + double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null; + + /// The minimum height of each row (excluding the row that contains column headings). + /// + /// This value is optional and defaults to [kMinInteractiveDimension] if not + /// specified. + final double? dataRowMinHeight; + + /// The maximum height of each row (excluding the row that contains column headings). + /// + /// This value is optional and defaults to [kMinInteractiveDimension] if not + /// specified. + final double? dataRowMaxHeight; + + /// The height of the heading row. + /// + /// This value is optional and defaults to 56.0 if not specified. + final double headingRowHeight; + + /// The horizontal margin between the edges of the table and the content + /// in the first and last cells of each row. + /// + /// When a checkbox is displayed, it is also the margin between the checkbox + /// the content in the first data column. + /// + /// This value defaults to 24.0 to adhere to the Material Design specifications. + /// + /// If [checkboxHorizontalMargin] is null, then [horizontalMargin] is also the + /// margin between the edge of the table and the checkbox, as well as the + /// margin between the checkbox and the content in the first data column. + final double horizontalMargin; + + /// The horizontal margin between the contents of each data column. + /// + /// This value defaults to 56.0 to adhere to the Material Design specifications. + final double columnSpacing; + + /// {@macro flutter.material.dataTable.showCheckboxColumn} + final bool showCheckboxColumn; + + /// Flag to display the pagination buttons to go to the first and last pages. + final bool showFirstLastButtons; + + /// The index of the first row to display when the widget is first created. + final int? initialFirstRowIndex; + + /// {@macro flutter.material.dataTable.dividerThickness} + /// + /// If null, [DataTableThemeData.dividerThickness] is used. This value + /// defaults to 1.0. + final double? dividerThickness; + + /// Invoked when the user switches to another page. + /// + /// The value is the index of the first row on the currently displayed page. + final ValueChanged<int>? onPageChanged; + + /// The number of rows to show on each page. + /// + /// See also: + /// + /// * [onRowsPerPageChanged] + /// * [defaultRowsPerPage] + final int rowsPerPage; + + /// The default value for [rowsPerPage]. + /// + /// Useful when initializing the field that will hold the current + /// [rowsPerPage], when implemented [onRowsPerPageChanged]. + static const int defaultRowsPerPage = 10; + + /// The options to offer for the rowsPerPage. + /// + /// The current [rowsPerPage] must be a value in this list. + /// + /// The values in this list should be sorted in ascending order. + final List<int> availableRowsPerPage; + + /// Invoked when the user selects a different number of rows per page. + /// + /// If this is null, then the value given by [rowsPerPage] will be used + /// and no affordance will be provided to change the value. + final ValueChanged<int?>? onRowsPerPageChanged; + + /// The data source which provides data to show in each row. + /// + /// This object should generally have a lifetime longer than the + /// [PaginatedDataTable] widget itself; it should be reused each time the + /// [PaginatedDataTable] constructor is called. + final DataTableSource source; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// Horizontal margin around the checkbox, if it is displayed. + /// + /// If null, then [horizontalMargin] is used as the margin between the edge + /// of the table and the checkbox, as well as the margin between the checkbox + /// and the content in the first data column. This value defaults to 24.0. + final double? checkboxHorizontalMargin; + + /// Defines the color of the arrow heads in the footer. + final Color? arrowHeadColor; + + /// {@macro flutter.widgets.scroll_view.controller} + final ScrollController? controller; + + /// {@macro flutter.widgets.scroll_view.primary} + final bool? primary; + + /// {@macro flutter.material.dataTable.headingRowColor} + final WidgetStateProperty<Color?>? headingRowColor; + + /// Controls the visibility of empty rows on the last page of a + /// [PaginatedDataTable]. + /// + /// Defaults to `true`, which means empty rows will be populated on the + /// last page of the table if there is not enough content. + /// When set to `false`, empty rows will not be created. + final bool showEmptyRows; + + @override + PaginatedDataTableState createState() => PaginatedDataTableState(); +} + +/// Holds the state of a [PaginatedDataTable]. +/// +/// The table can be programmatically paged using the [pageTo] method. +class PaginatedDataTableState extends State<PaginatedDataTable> { + late int _firstRowIndex; + late int _rowCount; + late bool _rowCountApproximate; + int _selectedRowCount = 0; + final Map<int, DataRow?> _rows = <int, DataRow?>{}; + + @protected + @override + void initState() { + super.initState(); + _firstRowIndex = + PageStorage.maybeOf(context)?.readState(context) as int? ?? + widget.initialFirstRowIndex ?? + 0; + widget.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + + @protected + @override + void didUpdateWidget(PaginatedDataTable oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.source != widget.source) { + oldWidget.source.removeListener(_handleDataSourceChanged); + widget.source.addListener(_handleDataSourceChanged); + _updateCaches(); + } + } + + @protected + @override + void reassemble() { + super.reassemble(); + // This function is called during hot reload. + // + // Normally, if the data source changes, it would notify its listeners and + // thus trigger _handleDataSourceChanged(), which clears the row cache and + // causes the widget to rebuild. + // + // During a hot reload, though, a data source can change in ways that will + // invalidate the row cache (e.g. adding or removing columns) without ever + // triggering a notification, leaving the PaginatedDataTable in an invalid + // state. This method handles this case by clearing the cache any time the + // widget is involved in a hot reload. + _updateCaches(); + } + + @protected + @override + void dispose() { + widget.source.removeListener(_handleDataSourceChanged); + super.dispose(); + } + + void _handleDataSourceChanged() { + setState(_updateCaches); + } + + void _updateCaches() { + _rowCount = widget.source.rowCount; + _rowCountApproximate = widget.source.isRowCountApproximate; + _selectedRowCount = widget.source.selectedRowCount; + _rows.clear(); + } + + /// Ensures that the given row is visible. + void pageTo(int rowIndex) { + final int oldFirstRowIndex = _firstRowIndex; + setState(() { + final int rowsPerPage = widget.rowsPerPage; + _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage; + }); + if ((widget.onPageChanged != null) && (oldFirstRowIndex != _firstRowIndex)) { + widget.onPageChanged!(_firstRowIndex); + } + } + + DataRow _getBlankRowFor(int index) { + return DataRow.byIndex( + index: index, + cells: widget.columns.map<DataCell>((DataColumn column) => DataCell.empty).toList(), + ); + } + + DataRow _getProgressIndicatorRowFor(int index) { + var haveProgressIndicator = false; + final List<DataCell> cells = widget.columns.map<DataCell>((DataColumn column) { + if (!column.numeric) { + haveProgressIndicator = true; + return const DataCell(CircularProgressIndicator()); + } + return DataCell.empty; + }).toList(); + if (!haveProgressIndicator) { + haveProgressIndicator = true; + cells[0] = const DataCell(CircularProgressIndicator()); + } + return DataRow.byIndex(index: index, cells: cells); + } + + List<DataRow> _getRows(int firstRowIndex, int rowsPerPage) { + final result = <DataRow>[]; + final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage; + var haveProgressIndicator = false; + for (var index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) { + DataRow? row; + if (index < _rowCount || _rowCountApproximate) { + row = _rows.putIfAbsent(index, () => widget.source.getRow(index)); + if (row == null && !haveProgressIndicator) { + row ??= _getProgressIndicatorRowFor(index); + haveProgressIndicator = true; + } + } + + if (widget.showEmptyRows) { + row ??= _getBlankRowFor(index); + } + + if (row != null) { + result.add(row); + } + } + return result; + } + + void _handleFirst() { + pageTo(0); + } + + void _handlePrevious() { + pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0)); + } + + void _handleNext() { + pageTo(_firstRowIndex + widget.rowsPerPage); + } + + void _handleLast() { + pageTo(((_rowCount - 1) / widget.rowsPerPage).floor() * widget.rowsPerPage); + } + + bool _isNextPageUnavailable() => + !_rowCountApproximate && (_firstRowIndex + widget.rowsPerPage >= _rowCount); + + final GlobalKey _tableKey = GlobalKey(); + + @protected + @override + Widget build(BuildContext context) { + // TODO(ianh): This whole build function doesn't handle RTL yet. + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData themeData = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + // HEADER + final headerWidgets = <Widget>[]; + if (_selectedRowCount == 0 && widget.header != null) { + headerWidgets.add(Expanded(child: widget.header!)); + } else if (widget.header != null) { + headerWidgets.add( + Expanded(child: Text(localizations.selectedRowCountTitle(_selectedRowCount))), + ); + } + if (widget.actions != null) { + headerWidgets.addAll( + widget.actions!.map<Widget>((Widget action) { + return Padding( + // 8.0 is the default padding of an icon button + padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0), + child: action, + ); + }).toList(), + ); + } + + // FOOTER + final TextStyle? footerTextStyle = themeData.textTheme.bodySmall; + final footerWidgets = <Widget>[]; + if (widget.onRowsPerPageChanged != null) { + final List<Widget> availableRowsPerPage = widget.availableRowsPerPage + .where((int value) => value <= _rowCount || value == widget.rowsPerPage) + .map<DropdownMenuItem<int>>((int value) { + return DropdownMenuItem<int>(value: value, child: Text('$value')); + }) + .toList(); + footerWidgets.addAll(<Widget>[ + // Match trailing padding, in case we overflow and end up scrolling. + const SizedBox(width: 14.0), + Text(localizations.rowsPerPageTitle), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 64.0), // 40.0 for the text, 24.0 for the icon + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DropdownButtonHideUnderline( + child: DropdownButton<int>( + items: availableRowsPerPage.cast<DropdownMenuItem<int>>(), + value: widget.rowsPerPage, + onChanged: widget.onRowsPerPageChanged, + style: footerTextStyle, + ), + ), + ), + ), + ]); + } + footerWidgets.addAll(<Widget>[ + const SizedBox(width: 32.0), + Text( + localizations.pageRowsInfoTitle( + _firstRowIndex + 1, + math.min(_firstRowIndex + widget.rowsPerPage, _rowCount), + _rowCount, + _rowCountApproximate, + ), + ), + const SizedBox(width: 32.0), + if (widget.showFirstLastButtons) + IconButton( + icon: const Icon(Icons.skip_previous), + padding: EdgeInsets.zero, + color: widget.arrowHeadColor, + tooltip: localizations.firstPageTooltip, + onPressed: _firstRowIndex <= 0 ? null : _handleFirst, + ), + IconButton( + icon: const Icon(Icons.chevron_left), + padding: EdgeInsets.zero, + color: widget.arrowHeadColor, + tooltip: localizations.previousPageTooltip, + onPressed: _firstRowIndex <= 0 ? null : _handlePrevious, + ), + const SizedBox(width: 24.0), + IconButton( + icon: const Icon(Icons.chevron_right), + padding: EdgeInsets.zero, + color: widget.arrowHeadColor, + tooltip: localizations.nextPageTooltip, + onPressed: _isNextPageUnavailable() ? null : _handleNext, + ), + if (widget.showFirstLastButtons) + IconButton( + icon: const Icon(Icons.skip_next), + padding: EdgeInsets.zero, + color: widget.arrowHeadColor, + tooltip: localizations.lastPageTooltip, + onPressed: _isNextPageUnavailable() ? null : _handleLast, + ), + const SizedBox(width: 14.0), + ]); + + // CARD + return Card( + semanticContainer: false, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + if (headerWidgets.isNotEmpty) + Semantics( + container: true, + child: DefaultTextStyle( + // These typographic styles aren't quite the regular ones. We pick the closest ones from the regular + // list and then tweak them appropriately. + // See https://material.io/design/components/data-tables.html#tables-within-cards + style: _selectedRowCount > 0 + ? themeData.textTheme.titleMedium!.copyWith( + color: themeData.colorScheme.secondary, + ) + : themeData.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w400), + child: IconTheme.merge( + data: const IconThemeData(opacity: 0.54), + child: Ink( + height: 64.0, + color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 24, end: 14.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: headerWidgets, + ), + ), + ), + ), + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + primary: widget.primary, + controller: widget.controller, + dragStartBehavior: widget.dragStartBehavior, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.minWidth), + child: DataTable( + key: _tableKey, + columns: widget.columns, + sortColumnIndex: widget.sortColumnIndex, + sortAscending: widget.sortAscending, + onSelectAll: widget.onSelectAll, + dividerThickness: widget.dividerThickness, + // Make sure no decoration is set on the DataTable + // from the theme, as its already wrapped in a Card. + decoration: const BoxDecoration(), + dataRowMinHeight: widget.dataRowMinHeight, + dataRowMaxHeight: widget.dataRowMaxHeight, + headingRowHeight: widget.headingRowHeight, + horizontalMargin: widget.horizontalMargin, + checkboxHorizontalMargin: widget.checkboxHorizontalMargin, + columnSpacing: widget.columnSpacing, + showCheckboxColumn: widget.showCheckboxColumn, + showBottomBorder: true, + rows: _getRows(_firstRowIndex, widget.rowsPerPage), + headingRowColor: widget.headingRowColor, + ), + ), + ), + if (!widget.showEmptyRows) + SizedBox( + height: + (widget.dataRowMaxHeight ?? kMinInteractiveDimension) * + (widget.rowsPerPage - _rowCount + _firstRowIndex).clamp( + 0, + widget.rowsPerPage, + ), + ), + DefaultTextStyle( + style: footerTextStyle!, + child: IconTheme.merge( + data: const IconThemeData(opacity: 0.54), + child: SizedBox( + // TODO(bkonyi): this won't handle text zoom correctly, + // https://github.com/flutter/flutter/issues/48522 + height: 56.0, + child: SingleChildScrollView( + dragStartBehavior: widget.dragStartBehavior, + scrollDirection: Axis.horizontal, + reverse: true, + child: Row(children: footerWidgets), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/popup_menu.dart b/packages/material_ui/lib/src/popup_menu.dart new file mode 100644 index 000000000000..8b84b2760d59 --- /dev/null +++ b/packages/material_ui/lib/src/popup_menu.dart @@ -0,0 +1,1879 @@ +// 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. + +/// @docImport 'menu_anchor.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'divider.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'popup_menu_theme.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'tooltip.dart'; + +// Examples can assume: +// enum Commands { heroAndScholar, hurricaneCame } +// late bool _heroAndScholar; +// late dynamic _selection; +// late BuildContext context; +// void setState(VoidCallback fn) { } +// enum Menu { itemOne, itemTwo, itemThree, itemFour } + +const Duration _kMenuDuration = Duration(milliseconds: 300); +const double _kMenuCloseIntervalEnd = 2.0 / 3.0; +const double _kMenuDividerHeight = 16.0; +const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; +const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; +const double _kMenuWidthStep = 56.0; +const double _kMenuScreenPadding = 8.0; + +/// A base class for entries in a Material Design popup menu. +/// +/// The popup menu widget uses this interface to interact with the menu items. +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// The type `T` is the type of the value(s) the entry represents. All the +/// entries in a given menu must represent values with consistent types. +/// +/// A [PopupMenuEntry] may represent multiple values, for example a row with +/// several icons, or a single entry, for example a menu item with an icon (see +/// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]). +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +abstract class PopupMenuEntry<T> extends StatefulWidget { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const PopupMenuEntry({super.key}); + + /// The amount of vertical space occupied by this entry. + /// + /// This value is used at the time the [showMenu] method is called, if the + /// `initialValue` argument is provided, to determine the position of this + /// entry when aligning the selected entry over the given `position`. It is + /// otherwise ignored. + double get height; + + /// Whether this entry represents a particular value. + /// + /// This method is used by [showMenu], when it is called, to align the entry + /// representing the `initialValue`, if any, to the given `position`, and then + /// later is called on each entry to determine if it should be highlighted (if + /// the method returns true, the entry will have its background color set to + /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then + /// this method is not called. + /// + /// If the [PopupMenuEntry] represents a single value, this should return true + /// if the argument matches that value. If it represents multiple values, it + /// should return true if the argument matches any of them. + bool represents(T? value); +} + +/// A horizontal divider in a Material Design popup menu. +/// +/// This widget adapts the [Divider] for use in popup menus. +/// +/// See also: +/// +/// * [PopupMenuItem], for the kinds of items that this widget divides. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class PopupMenuDivider extends PopupMenuEntry<Never> { + /// Creates a horizontal divider for a popup menu. + /// + /// By default, the divider has a height of 16 logical pixels. + const PopupMenuDivider({ + super.key, + this.height = _kMenuDividerHeight, + this.thickness, + this.indent, + this.endIndent, + this.radius, + this.color, + }); + + /// The height of the divider entry. + /// + /// Defaults to 16 pixels. + @override + final double height; + + /// The thickness of the line drawn within the [PopupMenuDivider]. + /// + /// {@macro flutter.material.Divider.thickness} + final double? thickness; + + /// The amount of empty space to the leading edge of the [PopupMenuDivider]. + /// + /// {@macro flutter.material.Divider.indent} + final double? indent; + + /// The amount of empty space to the trailing edge of the [PopupMenuDivider]. + /// + /// {@macro flutter.material.Divider.endIndent} + final double? endIndent; + + /// The amount of radius for the border of the [PopupMenuDivider]. + /// + /// {@macro flutter.material.Divider.radius} + final BorderRadiusGeometry? radius; + + /// {@macro flutter.material.Divider.color} + /// + /// {@tool snippet} + /// + /// ```dart + /// const PopupMenuDivider( + /// color: Colors.deepOrange, + /// ) + /// ``` + /// {@end-tool} + final Color? color; + + @override + bool represents(void value) => false; + + @override + State<PopupMenuDivider> createState() => _PopupMenuDividerState(); +} + +class _PopupMenuDividerState extends State<PopupMenuDivider> { + @override + Widget build(BuildContext context) { + return Divider( + height: widget.height, + thickness: widget.thickness, + indent: widget.indent, + color: widget.color, + endIndent: widget.endIndent, + radius: widget.radius, + ); + } +} + +// This widget only exists to enable _PopupMenuRoute to save the sizes of +// each menu item. The sizes are used by _PopupMenuRouteLayout to compute the +// y coordinate of the menu's origin so that the center of selected menu +// item lines up with the center of its PopupMenuButton. +class _MenuItem extends SingleChildRenderObjectWidget { + const _MenuItem({required this.onLayout, required super.child}); + + final ValueChanged<Size> onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderMenuItem(onLayout); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderMenuItem renderObject) { + renderObject.onLayout = onLayout; + } +} + +class _RenderMenuItem extends RenderShiftedBox { + _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); + + ValueChanged<Size> onLayout; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return child?.getDryLayout(constraints) ?? Size.zero; + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + return child?.getDryBaseline(constraints, baseline); + } + + @override + void performLayout() { + if (child == null) { + size = Size.zero; + } else { + child!.layout(constraints, parentUsesSize: true); + size = constraints.constrain(child!.size); + final childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset.zero; + } + onLayout(size); + } +} + +/// An item in a Material Design popup menu. +/// +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// To show a checkmark next to a popup menu item, consider using +/// [CheckedPopupMenuItem]. +/// +/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More +/// elaborate menus with icons can use a [ListTile]. By default, a +/// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget +/// with a different height, it must be specified in the [height] property. +/// +/// {@tool snippet} +/// +/// Here, a [Text] widget is used with a popup menu item. The `Menu` type +/// is an enum, not shown here. +/// +/// ```dart +/// const PopupMenuItem<Menu>( +/// value: Menu.itemOne, +/// child: Text('Item 1'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See the example at [PopupMenuButton] for how this example could be used in a +/// complete menu, and see the example at [CheckedPopupMenuItem] for one way to +/// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child] +/// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem] +/// that use a [ListTile] in their [child] slot. +/// +/// See also: +/// +/// * [PopupMenuDivider], which can be used to divide items from each other. +/// * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class PopupMenuItem<T> extends PopupMenuEntry<T> { + /// Creates an item for a popup menu. + /// + /// By default, the item is [enabled]. + const PopupMenuItem({ + super.key, + this.value, + this.onTap, + this.enabled = true, + this.height = kMinInteractiveDimension, + this.padding, + this.textStyle, + this.labelTextStyle, + this.mouseCursor, + required this.child, + }); + + /// The value that will be returned by [showMenu] if this entry is selected. + final T? value; + + /// Called when the menu item is tapped. + final VoidCallback? onTap; + + /// Whether the user is permitted to select this item. + /// + /// Defaults to true. If this is false, then the item will not react to + /// touches. + final bool enabled; + + /// The minimum height of the menu item. + /// + /// Defaults to [kMinInteractiveDimension] pixels. + @override + final double height; + + /// The padding of the menu item. + /// + /// The [height] property may interact with the applied padding. For example, + /// If a [height] greater than the height of the sum of the padding and [child] + /// is provided, then the padding's effect will not be visible. + /// + /// If this is null and [ThemeData.useMaterial3] is true, the horizontal padding + /// defaults to 12.0 on both sides. + /// + /// If this is null and [ThemeData.useMaterial3] is false, the horizontal padding + /// defaults to 16.0 on both sides. + final EdgeInsets? padding; + + /// The text style of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.textStyle] is used. + /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium] + /// of [ThemeData.textTheme] is used. + final TextStyle? textStyle; + + /// The label style of the popup menu item. + /// + /// When [ThemeData.useMaterial3] is true, this styles the text of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.labelTextStyle] is used. + /// If [PopupMenuThemeData.labelTextStyle] is also null, then [TextTheme.labelLarge] + /// is used with the [ColorScheme.onSurface] color when popup menu item is enabled and + /// the [ColorScheme.onSurface] color with 0.38 opacity when the popup menu item is disabled. + final WidgetStateProperty<TextStyle?>? labelTextStyle; + + /// {@template flutter.material.popupmenu.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.adaptiveClickable] is used. + final MouseCursor? mouseCursor; + + /// The widget below this widget in the tree. + /// + /// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An + /// appropriate [DefaultTextStyle] is put in scope for the child. In either + /// case, the text should be short enough that it won't wrap. + final Widget? child; + + @override + bool represents(T? value) => value == this.value; + + @override + PopupMenuItemState<T, PopupMenuItem<T>> createState() => + PopupMenuItemState<T, PopupMenuItem<T>>(); +} + +/// The [State] for [PopupMenuItem] subclasses. +/// +/// By default this implements the basic styling and layout of Material Design +/// popup menu items. +/// +/// The [buildChild] method can be overridden to adjust exactly what gets placed +/// in the menu. By default it returns [PopupMenuItem.child]. +/// +/// The [handleTap] method can be overridden to adjust exactly what happens when +/// the item is tapped. By default, it uses [Navigator.pop] to return the +/// [PopupMenuItem.value] from the menu route. +/// +/// This class takes two type arguments. The second, `W`, is the exact type of +/// the [Widget] that is using this [State]. It must be a subclass of +/// [PopupMenuItem]. The first, `T`, must match the type argument of that widget +/// class, and is the type of values returned from this menu. +class PopupMenuItemState<T, W extends PopupMenuItem<T>> extends State<W> { + /// The menu item contents. + /// + /// Used by the [build] method. + /// + /// By default, this returns [PopupMenuItem.child]. Override this to put + /// something else in the menu entry. + @protected + Widget? buildChild() => widget.child; + + /// The handler for when the user selects the menu item. + /// + /// Used by the [InkWell] inserted by the [build] method. + /// + /// By default, uses [Navigator.pop] to return the [PopupMenuItem.value] from + /// the menu route. + @protected + void handleTap() { + // Need to pop the navigator first in case onTap may push new route onto navigator. + Navigator.pop<T>(context, widget.value); + + widget.onTap?.call(); + } + + @protected + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final states = <WidgetState>{if (!widget.enabled) WidgetState.disabled}; + + TextStyle style = theme.useMaterial3 + ? (widget.labelTextStyle?.resolve(states) ?? + popupMenuTheme.labelTextStyle?.resolve(states)! ?? + defaults.labelTextStyle!.resolve(states)!) + : (widget.textStyle ?? popupMenuTheme.textStyle ?? defaults.textStyle!); + + if (!widget.enabled && !theme.useMaterial3) { + style = style.copyWith(color: theme.disabledColor); + } + final EdgeInsetsGeometry padding = + widget.padding ?? + (theme.useMaterial3 + ? _PopupMenuDefaultsM3.menuItemPadding + : _PopupMenuDefaultsM2.menuItemPadding); + + Widget item = AnimatedDefaultTextStyle( + style: style, + duration: kThemeChangeDuration, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: widget.height), + child: Padding( + padding: padding, + child: Align(alignment: AlignmentDirectional.centerStart, child: buildChild()), + ), + ), + ); + + if (!widget.enabled) { + final isDark = theme.brightness == Brightness.dark; + item = IconTheme.merge( + data: IconThemeData(opacity: isDark ? 0.5 : 0.38), + child: item, + ); + } + + return MergeSemantics( + child: buildSemantics( + child: InkWell( + onTap: widget.enabled ? handleTap : null, + canRequestFocus: widget.enabled, + mouseCursor: _EffectiveMouseCursor(widget.mouseCursor, popupMenuTheme.mouseCursor), + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + titleTextStyle: style, + child: item, + ), + ), + ), + ); + } + + /// Builds the semantic wrapper for the popup menu item. + /// + /// This method creates the [Semantics] widget that provides accessibility + /// information for the menu item. By default, it sets the semantic role to + /// [SemanticsRole.menuItem] and includes the enabled state and button flag. + /// + /// Subclasses can override this method to customize the semantic properties. + /// For example, [CheckedPopupMenuItem] overrides this to use + /// [SemanticsRole.menuItemCheckbox] and include checked state information. + @protected + Widget buildSemantics({required Widget child}) { + return Semantics( + role: SemanticsRole.menuItem, + enabled: widget.enabled, + button: true, + child: child, + ); + } +} + +/// An item with a checkmark in a Material Design popup menu. +/// +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which +/// matches the default minimum height of a [PopupMenuItem]. The horizontal +/// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the +/// [ListTile.leading] position. +/// +/// {@tool snippet} +/// +/// Suppose a `Commands` enum exists that lists the possible commands from a +/// particular popup menu, including `Commands.heroAndScholar` and +/// `Commands.hurricaneCame`, and further suppose that there is a +/// `_heroAndScholar` member field which is a boolean. The example below shows a +/// menu with one menu item with a checkmark that can toggle the boolean, and +/// one menu item without a checkmark for selecting the second option. (It also +/// shows a divider placed between the two menu items.) +/// +/// ```dart +/// PopupMenuButton<Commands>( +/// onSelected: (Commands result) { +/// switch (result) { +/// case Commands.heroAndScholar: +/// setState(() { _heroAndScholar = !_heroAndScholar; }); +/// case Commands.hurricaneCame: +/// // ...handle hurricane option +/// break; +/// // ...other items handled here +/// } +/// }, +/// itemBuilder: (BuildContext context) => <PopupMenuEntry<Commands>>[ +/// CheckedPopupMenuItem<Commands>( +/// checked: _heroAndScholar, +/// value: Commands.heroAndScholar, +/// child: const Text('Hero and scholar'), +/// ), +/// const PopupMenuDivider(), +/// const PopupMenuItem<Commands>( +/// value: Commands.hurricaneCame, +/// child: ListTile(leading: Icon(null), title: Text('Bring hurricane')), +/// ), +/// // ...other items listed here +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// In particular, observe how the second menu item uses a [ListTile] with a +/// blank [Icon] in the [ListTile.leading] position to get the same alignment as +/// the item with the checkmark. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for picking a command (as opposed to +/// toggling a value). +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class CheckedPopupMenuItem<T> extends PopupMenuItem<T> { + /// Creates a popup menu item with a checkmark. + /// + /// By default, the menu item is [enabled] but unchecked. To mark the item as + /// checked, set [checked] to true. + const CheckedPopupMenuItem({ + super.key, + super.value, + this.checked = false, + super.enabled, + super.padding, + super.height, + super.labelTextStyle, + super.mouseCursor, + super.child, + super.onTap, + }); + + /// Whether to display a checkmark next to the menu item. + /// + /// Defaults to false. + /// + /// When true, an [Icons.done] checkmark is displayed. + /// + /// When this popup menu item is selected, the checkmark will fade in or out + /// as appropriate to represent the implied new state. + final bool checked; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for + /// the child. The text should be short enough that it won't wrap. + /// + /// This widget is placed in the [ListTile.title] slot of a [ListTile] whose + /// [ListTile.leading] slot is an [Icons.done] icon. + @override + Widget? get child => super.child; + + @override + PopupMenuItemState<T, CheckedPopupMenuItem<T>> createState() => _CheckedPopupMenuItemState<T>(); +} + +class _CheckedPopupMenuItemState<T> extends PopupMenuItemState<T, CheckedPopupMenuItem<T>> + with SingleTickerProviderStateMixin { + static const Duration _fadeDuration = Duration(milliseconds: 150); + late AnimationController _controller; + Animation<double> get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: _fadeDuration, vsync: this) + ..value = widget.checked ? 1.0 : 0.0 + ..addListener( + () => setState(() { + /* animation changed */ + }), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void handleTap() { + // This fades the checkmark in or out when tapped. + if (widget.checked) { + _controller.reverse(); + } else { + _controller.forward(); + } + super.handleTap(); + } + + @override + Widget buildSemantics({required Widget child}) { + return Semantics( + role: SemanticsRole.menuItemCheckbox, + enabled: widget.enabled, + checked: widget.checked, + button: true, + child: child, + ); + } + + @override + Widget buildChild() { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final states = <WidgetState>{if (widget.checked) WidgetState.selected}; + final WidgetStateProperty<TextStyle?>? effectiveLabelTextStyle = + widget.labelTextStyle ?? popupMenuTheme.labelTextStyle ?? defaults.labelTextStyle; + return IgnorePointer( + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + child: ListTile( + enabled: widget.enabled, + titleTextStyle: effectiveLabelTextStyle?.resolve(states), + leading: FadeTransition( + opacity: _opacity, + child: Icon(_controller.isDismissed ? null : Icons.done), + ), + title: widget.child, + ), + ), + ); + } +} + +class _PopupMenu<T> extends StatefulWidget { + const _PopupMenu({ + super.key, + required this.itemKeys, + required this.route, + required this.semanticLabel, + this.constraints, + required this.clipBehavior, + }); + + final List<GlobalKey> itemKeys; + final _PopupMenuRoute<T> route; + final String? semanticLabel; + final BoxConstraints? constraints; + final Clip clipBehavior; + + @override + State<_PopupMenu<T>> createState() => _PopupMenuState<T>(); +} + +class _PopupMenuState<T> extends State<_PopupMenu<T>> { + List<CurvedAnimation> _opacities = const <CurvedAnimation>[]; + + @override + void initState() { + super.initState(); + _setOpacities(); + } + + @override + void didUpdateWidget(covariant _PopupMenu<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.route.items.length != widget.route.items.length || + oldWidget.route.animation != widget.route.animation) { + _setOpacities(); + } + } + + void _setOpacities() { + for (final CurvedAnimation opacity in _opacities) { + opacity.dispose(); + } + final newOpacities = <CurvedAnimation>[]; + final double unit = + 1.0 / + (widget.route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + for (var i = 0; i < widget.route.items.length; i += 1) { + final double start = (i + 1) * unit; + final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); + final opacity = CurvedAnimation(parent: widget.route.animation!, curve: Interval(start, end)); + newOpacities.add(opacity); + } + _opacities = newOpacities; + } + + @override + void dispose() { + for (final CurvedAnimation opacity in _opacities) { + opacity.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double unit = + 1.0 / + (widget.route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + final children = <Widget>[]; + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + + for (var i = 0; i < widget.route.items.length; i += 1) { + final CurvedAnimation opacity = _opacities[i]; + Widget item = widget.route.items[i]; + if (widget.route.initialValue != null && + widget.route.items[i].represents(widget.route.initialValue)) { + item = ColoredBox(color: Theme.of(context).highlightColor, child: item); + } + children.add( + _MenuItem( + onLayout: (Size size) { + widget.route.itemSizes[i] = size; + }, + child: FadeTransition(key: widget.itemKeys[i], opacity: opacity, child: item), + ), + ); + } + + final opacity = CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); + final width = CurveTween(curve: Interval(0.0, unit)); + final height = CurveTween(curve: Interval(0.0, unit * widget.route.items.length)); + + final Widget child = ConstrainedBox( + constraints: + widget.constraints ?? + const BoxConstraints(minWidth: _kMenuMinWidth, maxWidth: _kMenuMaxWidth), + child: IntrinsicWidth( + stepWidth: _kMenuWidthStep, + child: Semantics( + role: SemanticsRole.menu, + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: SingleChildScrollView( + padding: widget.route.menuPadding ?? popupMenuTheme.menuPadding ?? defaults.menuPadding, + child: ListBody(children: children), + ), + ), + ), + ); + + return AnimatedBuilder( + animation: widget.route.animation!, + builder: (BuildContext context, Widget? child) { + return FadeTransition( + opacity: opacity.animate(widget.route.animation!), + child: Material( + shape: widget.route.shape ?? popupMenuTheme.shape ?? defaults.shape, + color: widget.route.color ?? popupMenuTheme.color ?? defaults.color, + clipBehavior: widget.clipBehavior, + type: MaterialType.card, + elevation: widget.route.elevation ?? popupMenuTheme.elevation ?? defaults.elevation!, + shadowColor: + widget.route.shadowColor ?? popupMenuTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: + widget.route.surfaceTintColor ?? + popupMenuTheme.surfaceTintColor ?? + defaults.surfaceTintColor, + child: Align( + alignment: AlignmentDirectional.topEnd, + widthFactor: width.evaluate(widget.route.animation!), + heightFactor: height.evaluate(widget.route.animation!), + child: child, + ), + ), + ); + }, + child: child, + ); + } +} + +// Positioning of the menu on the screen. +class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { + _PopupMenuRouteLayout( + this.position, + this.itemSizes, + this.selectedItemIndex, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // The sizes of each item are computed when the menu is laid out, and before + // the route is laid out. + List<Size?> itemSizes; + + // The index of the selected item, or null if PopupMenuButton.initialValue + // was not specified. + final int? selectedItemIndex; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set<Rect> avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by 8. If necessary, we adjust the + // child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose( + constraints.biggest, + ).deflate(const EdgeInsets.all(_kMenuScreenPadding) + padding); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final double y = position.top; + + // Find the ideal horizontal position. + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + double x; + if (position.left > position.right) { + // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. + x = size.width - position.right - childSize.width; + } else if (position.left < position.right) { + // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. + x = position.left; + } else { + // Menu button is equidistant from both edges, so grow in reading direction. + x = switch (textDirection) { + TextDirection.rtl => size.width - position.right - childSize.width, + TextDirection.ltr => position.left, + }; + } + final wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable<Rect> subScreens = DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & size, + avoidBounds, + ); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable<Rect> screens, Offset point) { + Rect closest = screens.first; + for (final screen in screens) { + if ((screen.center - point).distance < (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _kMenuScreenPadding + padding.left) { + x = screen.left + _kMenuScreenPadding + padding.left; + } else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right) { + x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; + } + if (y < screen.top + _kMenuScreenPadding + padding.top) { + y = _kMenuScreenPadding + padding.top; + } else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom) { + y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom; + } + + return Offset(x, y); + } + + @override + bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { + // If called when the old and new itemSizes have been initialized then + // we expect them to have the same length because there's no practical + // way to change length of the items list once the menu has been shown. + assert(itemSizes.length == oldDelegate.itemSizes.length); + + return position != oldDelegate.position || + selectedItemIndex != oldDelegate.selectedItemIndex || + textDirection != oldDelegate.textDirection || + !listEquals(itemSizes, oldDelegate.itemSizes) || + padding != oldDelegate.padding || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} + +class _PopupMenuRoute<T> extends PopupRoute<T> { + _PopupMenuRoute({ + this.position, + this.positionBuilder, + required this.items, + required this.itemKeys, + this.initialValue, + this.elevation, + this.surfaceTintColor, + this.shadowColor, + required this.barrierLabel, + this.semanticLabel, + this.shape, + this.menuPadding, + this.color, + required this.capturedThemes, + this.constraints, + required this.clipBehavior, + super.settings, + super.requestFocus, + this.popUpAnimationStyle, + }) : assert( + (position != null) != (positionBuilder != null), + 'Either position or positionBuilder must be provided.', + ), + itemSizes = List<Size?>.filled(items.length, null), + // Menus always cycle focus through their items irrespective of the + // focus traversal edge behavior set in the Navigator. + super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); + + final RelativeRect? position; + final PopupMenuPositionBuilder? positionBuilder; + final List<PopupMenuEntry<T>> items; + final List<GlobalKey> itemKeys; + final List<Size?> itemSizes; + final T? initialValue; + final double? elevation; + final Color? surfaceTintColor; + final Color? shadowColor; + final String? semanticLabel; + final ShapeBorder? shape; + final EdgeInsetsGeometry? menuPadding; + final Color? color; + final CapturedThemes capturedThemes; + final BoxConstraints? constraints; + final Clip clipBehavior; + final AnimationStyle? popUpAnimationStyle; + + CurvedAnimation? _animation; + + @override + Animation<double> createAnimation() { + if (popUpAnimationStyle != AnimationStyle.noAnimation) { + return _animation ??= CurvedAnimation( + parent: super.createAnimation(), + curve: popUpAnimationStyle?.curve ?? Curves.linear, + reverseCurve: + popUpAnimationStyle?.reverseCurve ?? const Interval(0.0, _kMenuCloseIntervalEnd), + ); + } + return super.createAnimation(); + } + + void scrollTo(int selectedItemIndex) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (itemKeys[selectedItemIndex].currentContext != null) { + Scrollable.ensureVisible(itemKeys[selectedItemIndex].currentContext!); + } + }); + } + + @override + Duration get transitionDuration => popUpAnimationStyle?.duration ?? _kMenuDuration; + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String barrierLabel; + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + int? selectedItemIndex; + if (initialValue != null) { + for (var index = 0; selectedItemIndex == null && index < items.length; index += 1) { + if (items[index].represents(initialValue)) { + selectedItemIndex = index; + } + } + } + if (selectedItemIndex != null) { + scrollTo(selectedItemIndex); + } + + final Widget menu = _PopupMenu<T>( + route: this, + itemKeys: itemKeys, + semanticLabel: semanticLabel, + constraints: constraints, + clipBehavior: clipBehavior, + ); + final MediaQueryData mediaQuery = MediaQuery.of(context); + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return CustomSingleChildLayout( + delegate: _PopupMenuRouteLayout( + positionBuilder?.call(context, constraints) ?? position!, + itemSizes, + selectedItemIndex, + Directionality.of(context), + mediaQuery.padding, + _avoidBounds(mediaQuery), + ), + child: capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + Set<Rect> _avoidBounds(MediaQueryData mediaQuery) { + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); + } + + @override + void dispose() { + _animation?.dispose(); + super.dispose(); + } +} + +/// A builder that creates a [RelativeRect] to position a popup menu. +/// Both [BuildContext] and [BoxConstraints] are from the [PopupRoute] that +/// displays this menu. +/// +/// The returned [RelativeRect] determines the position of the popup menu relative +/// to the bounds of the [Navigator]'s overlay. The menu dimensions are not yet +/// known when this callback is invoked, as they depend on the items and other +/// properties of the menu. +/// +/// The coordinate system used by the [RelativeRect] has its origin at the top +/// left of the [Navigator]'s overlay. Positive y coordinates are down (below the +/// origin), and positive x coordinates are to the right of the origin. +/// +/// See also: +/// +/// * [RelativeRect.fromLTRB], which creates a [RelativeRect] from left, top, +/// right, and bottom coordinates. +/// * [RelativeRect.fromRect], which creates a [RelativeRect] from two [Rect]s, +/// one representing the size of the popup menu and one representing the size +/// of the overlay. +typedef PopupMenuPositionBuilder = + RelativeRect Function(BuildContext context, BoxConstraints constraints); + +/// Shows a popup menu that contains the `items` at `position`. +/// +/// The `items` parameter must not be empty. +/// +/// Only one of [position] or [positionBuilder] should be provided. Providing both +/// throws an assertion error. The [positionBuilder] is called at the time the +/// menu is shown to compute its position and every time the layout is updated, +/// which is useful when the position needs +/// to be determined at runtime based on the current layout. +/// +/// If `initialValue` is specified then the first item with a matching value +/// will be highlighted and the value of `position` gives the rectangle whose +/// vertical center will be aligned with the vertical center of the highlighted +/// item (when possible). +/// +/// If `initialValue` is not specified then the top of the menu will be aligned +/// with the top of the `position` rectangle. +/// +/// In both cases, the menu position will be adjusted if necessary to fit on the +/// screen. +/// +/// Horizontally, the menu is positioned so that it grows in the direction that +/// has the most room. For example, if the `position` describes a rectangle on +/// the left edge of the screen, then the left edge of the menu is aligned with +/// the left edge of the `position`, and the menu grows to the right. If both +/// edges of the `position` are equidistant from the opposite edge of the +/// screen, then the ambient [Directionality] is used as a tie-breaker, +/// preferring to grow in the reading direction. +/// +/// The positioning of the `initialValue` at the `position` is implemented by +/// iterating over the `items` to find the first whose +/// [PopupMenuEntry.represents] method returns true for `initialValue`, and then +/// summing the values of [PopupMenuEntry.height] for all the preceding widgets +/// in the list. +/// +/// The `elevation` argument specifies the z-coordinate at which to place the +/// menu. The elevation defaults to 8, the appropriate elevation for popup +/// menus. +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the menu. It is only used when the method is called. Its corresponding +/// widget can be safely removed from the tree before the popup menu is closed. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// menu to the [Navigator] furthest from or nearest to the given `context`. It +/// is `false` by default. +/// +/// The `semanticLabel` argument is used by accessibility frameworks to +/// announce screen transitions when the menu is opened and closed. If this +/// label is not provided, it will default to +/// [MaterialLocalizations.popupMenuLabel]. +/// +/// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to +/// [Clip.none]. +/// +/// The `requestFocus` argument specifies whether the menu should request focus +/// when it appears. If it is null, [Navigator.requestFocus] is used instead. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [PopupMenuButton], which provides an [IconButton] that shows a menu by +/// calling this method automatically. +/// * [SemanticsConfiguration.namesRoute], for a description of edge triggered +/// semantics. +Future<T?> showMenu<T>({ + required BuildContext context, + RelativeRect? position, + PopupMenuPositionBuilder? positionBuilder, + required List<PopupMenuEntry<T>> items, + T? initialValue, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + String? semanticLabel, + ShapeBorder? shape, + EdgeInsetsGeometry? menuPadding, + Color? color, + bool useRootNavigator = false, + BoxConstraints? constraints, + Clip clipBehavior = Clip.none, + RouteSettings? routeSettings, + AnimationStyle? popUpAnimationStyle, + bool? requestFocus, +}) { + assert(items.isNotEmpty); + assert(debugCheckHasMaterialLocalizations(context)); + assert( + (position != null) != (positionBuilder != null), + 'Either position or positionBuilder must be provided.', + ); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; + } + + final menuItemKeys = List<GlobalKey>.generate(items.length, (int index) => GlobalKey()); + final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + _PopupMenuRoute<T>( + position: position, + positionBuilder: positionBuilder, + items: items, + itemKeys: menuItemKeys, + initialValue: initialValue, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + semanticLabel: semanticLabel, + barrierLabel: MaterialLocalizations.of(context).menuDismissLabel, + shape: shape, + menuPadding: menuPadding, + color: color, + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + constraints: constraints, + clipBehavior: clipBehavior, + settings: routeSettings, + popUpAnimationStyle: popUpAnimationStyle, + requestFocus: requestFocus, + ), + ); +} + +/// Signature for the callback invoked when a menu item is selected. The +/// argument is the value of the [PopupMenuItem] that caused its menu to be +/// dismissed. +/// +/// Used by [PopupMenuButton.onSelected]. +typedef PopupMenuItemSelected<T> = void Function(T value); + +/// Signature for the callback invoked when a [PopupMenuButton] is dismissed +/// without selecting an item. +/// +/// Used by [PopupMenuButton.onCanceled]. +typedef PopupMenuCanceled = void Function(); + +/// Signature used by [PopupMenuButton] to lazily construct the items shown when +/// the button is pressed. +/// +/// Used by [PopupMenuButton.itemBuilder]. +typedef PopupMenuItemBuilder<T> = List<PopupMenuEntry<T>> Function(BuildContext context); + +/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed +/// because an item was selected. The value passed to [onSelected] is the value of +/// the selected menu item. +/// +/// One of [child] or [icon] may be provided, but not both. If [icon] is provided, +/// then [PopupMenuButton] behaves like an [IconButton]. +/// +/// If both are null, then a standard overflow icon is created (depending on the +/// platform). +/// +/// ## Updating to [MenuAnchor] +/// +/// There is a Material 3 component, +/// [MenuAnchor] that is preferred for applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// The [MenuAnchor] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// <https://m3.material.io/components/menus/guidelines> for +/// more details. +/// +/// The [MenuAnchor] widget's API is also slightly different. +/// [MenuAnchor]'s were built to be lower level interface for +/// creating menus that are displayed from an anchor. +/// +/// There are a few steps you would take to migrate from +/// [PopupMenuButton] to [MenuAnchor]: +/// +/// 1. Instead of using the [PopupMenuButton.itemBuilder] to build +/// a list of [PopupMenuEntry]s, you would use the [MenuAnchor.menuChildren] +/// which takes a list of [Widget]s. Usually, you would use a list of +/// [MenuItemButton]s as shown in the example below. +/// +/// 2. Instead of using the [PopupMenuButton.onSelected] callback, you would +/// set individual callbacks for each of the [MenuItemButton]s using the +/// [MenuItemButton.onPressed] property. +/// +/// 3. To anchor the [MenuAnchor] to a widget, you would use the [MenuAnchor.builder] +/// to return the widget of choice - usually a [TextButton] or an [IconButton]. +/// +/// 4. You may want to style the [MenuItemButton]s, see the [MenuItemButton] +/// documentation for details. +/// +/// Use the sample below for an example of migrating from [PopupMenuButton] to +/// [MenuAnchor]. +/// +/// {@tool dartpad} +/// This example shows a menu with three items, selecting between an enum's +/// values and setting a `selectedMenu` field based on the selection. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to migrate the above to a [MenuAnchor]. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a popup menu, as described in: +/// https://m3.material.io/components/menus/overview +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases how to override the [PopupMenuButton] animation +/// curves and duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +class PopupMenuButton<T> extends StatefulWidget { + /// Creates a button that shows a popup menu. + const PopupMenuButton({ + super.key, + required this.itemBuilder, + this.initialValue, + this.onOpened, + this.onSelected, + this.onCanceled, + this.tooltip, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.padding = const EdgeInsets.all(8.0), + this.menuPadding, + this.child, + this.borderRadius, + this.splashRadius, + this.icon, + this.iconSize, + this.offset = Offset.zero, + this.enabled = true, + this.shape, + this.color, + this.iconColor, + this.enableFeedback, + this.constraints, + this.position, + this.clipBehavior = Clip.none, + this.useRootNavigator = false, + this.popUpAnimationStyle, + this.routeSettings, + this.style, + this.requestFocus, + }) : assert(!(child != null && icon != null), 'You can only pass [child] or [icon], not both.'); + + /// Called when the button is pressed to create the items to show in the menu. + final PopupMenuItemBuilder<T> itemBuilder; + + /// The value of the menu item, if any, that should be highlighted when the menu opens. + final T? initialValue; + + /// Called when the popup menu is shown. + final VoidCallback? onOpened; + + /// Called when the user selects a value from the popup menu created by this button. + /// + /// If the popup menu is dismissed without selecting a value, [onCanceled] is + /// called instead. + final PopupMenuItemSelected<T>? onSelected; + + /// Called when the user dismisses the popup menu without selecting an item. + /// + /// If the user selects a value, [onSelected] is called instead. + final PopupMenuCanceled? onCanceled; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// The z-coordinate at which to place the menu when open. This controls the + /// size of the shadow below the menu. + /// + /// Defaults to 8, the appropriate elevation for popup menus. + final double? elevation; + + /// The color used to paint the shadow below the menu. + /// + /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. + /// If that is null too, then the overall theme's [ThemeData.shadowColor] + /// (default black) is used. + final Color? shadowColor; + + /// The color used as an overlay on [color] to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [Colors.transparent]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// Matches IconButton's 8 dps padding by default. In some cases, notably where + /// this button appears as the trailing element of a list item, it's useful to be able + /// to set the padding to zero. + final EdgeInsetsGeometry padding; + + /// If provided, menu padding is used for empty space around the outside + /// of the popup menu. + /// + /// If this property is null, then [PopupMenuThemeData.menuPadding] is used. + /// If [PopupMenuThemeData.menuPadding] is also null, then vertical padding + /// of 8 pixels is used. + final EdgeInsetsGeometry? menuPadding; + + /// The splash radius. + /// + /// If null, default splash radius of [InkWell] or [IconButton] is used. + final double? splashRadius; + + /// If provided, [child] is the widget used for this button + /// and the button will utilize an [InkWell] for taps. + final Widget? child; + + /// The border radius for the [InkWell] that wraps the [child]. + /// + /// Defaults to null, which indicates no border radius should be applied. + final BorderRadius? borderRadius; + + /// If provided, the [icon] is used for this button + /// and the button will behave like an [IconButton]. + final Widget? icon; + + /// The offset is applied relative to the initial position + /// set by the [position]. + /// + /// When not set, the offset defaults to [Offset.zero]. + final Offset offset; + + /// Whether this popup menu button is interactive. + /// + /// Defaults to true. + /// + /// If true, the button will respond to presses by displaying the menu. + /// + /// If false, the button is styled with the disabled color from the + /// current [Theme] and will not respond to presses or show the popup + /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. + /// + /// This can be useful in situations where the app needs to show the button, + /// but doesn't currently have anything to show in the menu. + final bool enabled; + + /// If provided, the shape used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.shape] is used. + /// If [PopupMenuThemeData.shape] is also null, then the default shape for + /// [MaterialType.card] is used. This default shape is a rectangle with + /// rounded edges of BorderRadius.circular(2.0). + final ShapeBorder? shape; + + /// If provided, the background color used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.color] is used. + /// If [PopupMenuThemeData.color] is also null, then + /// [ThemeData.cardColor] is used in Material 2. In Material3, defaults to + /// [ColorScheme.surfaceContainer]. + final Color? color; + + /// If provided, this color is used for the button icon. + /// + /// If this property is null, then [PopupMenuThemeData.iconColor] is used. + /// If [PopupMenuThemeData.iconColor] is also null then defaults to + /// [IconThemeData.color]. + final Color? iconColor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// If provided, the size of the [Icon]. + /// + /// If this property is null, then [IconThemeData.size] is used. + /// If [IconThemeData.size] is also null, then + /// default size is 24.0 pixels. + final double? iconSize; + + /// Optional size constraints for the menu. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: 2.0 * 56.0, + /// maxWidth: 5.0 * 56.0, + /// ) + /// ``` + /// + /// The default constraints ensure that the menu width matches maximum width + /// recommended by the Material Design guidelines. + /// Specifying this parameter enables creation of menu wider than + /// the default maximum width. + final BoxConstraints? constraints; + + /// Whether the popup menu is positioned over or under the popup menu button. + /// + /// [offset] is used to change the position of the popup menu relative to the + /// position set by this parameter. + /// + /// If this property is `null`, then [PopupMenuThemeData.position] is used. If + /// [PopupMenuThemeData.position] is also `null`, then the position defaults + /// to [PopupMenuPosition.over] which makes the popup menu appear directly + /// over the button that was used to create it. + final PopupMenuPosition? position; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// The [clipBehavior] argument is used the clip shape of the menu. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Used to determine whether to push the menu to the [Navigator] furthest + /// from or nearest to the given `context`. + /// + /// Defaults to false. + final bool useRootNavigator; + + /// Used to override the default animation curves and durations of the popup + /// menu's open and close transitions. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the default popup animation curve. Otherwise, defaults to [Curves.linear]. + /// + /// If [AnimationStyle.reverseCurve] is provided, it will be used to + /// override the default popup animation reverse curve. Otherwise, defaults to + /// `Interval(0.0, 2.0 / 3.0)`. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the default popup animation duration. Otherwise, defaults to 300ms. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// If this is null, then the default animation will be used. + final AnimationStyle? popUpAnimationStyle; + + /// Optional route settings for the menu. + /// + /// See [RouteSettings] for details. + final RouteSettings? routeSettings; + + /// Customizes this icon button's appearance. + /// + /// The [style] is only used for Material 3 [IconButton]s. If [ThemeData.useMaterial3] + /// is set to true, [style] is preferred for icon button customization, and any + /// parameters defined in [style] will override the same parameters in [IconButton]. + /// + /// Null by default. + final ButtonStyle? style; + + /// Whether to request focus when the menu appears. + /// + /// If null, [Navigator.requestFocus] will be used instead. + final bool? requestFocus; + + @override + PopupMenuButtonState<T> createState() => PopupMenuButtonState<T>(); +} + +/// The [State] for a [PopupMenuButton]. +/// +/// See [showButtonMenu] for a way to programmatically open the popup menu +/// of your button state. +class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> { + bool _isMenuExpanded = false; + RelativeRect? _lastPosition; + late PopupMenuThemeData _popupMenuTheme; + RenderBox? _cachedButtonRenderBox; + RenderBox? _cachedOverlayRenderBox; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateCachedObjects(); + } + + /// Caches some objects relying on context used in _positionBuilder() + /// to avoid crashing when the popup menu is inactive. + /// See https://github.com/flutter/flutter/issues/171422 for more details. + void _updateCachedObjects() { + if (mounted) { + _popupMenuTheme = PopupMenuTheme.of(context); + + final RenderObject? buttonRenderObject = context.findRenderObject(); + if (buttonRenderObject is RenderBox) { + _cachedButtonRenderBox = buttonRenderObject; + } + try { + final NavigatorState navigator = Navigator.of( + context, + rootNavigator: widget.useRootNavigator, + ); + final RenderObject? overlayRenderObject = navigator.overlay?.context.findRenderObject(); + if (overlayRenderObject is RenderBox) { + _cachedOverlayRenderBox = overlayRenderObject; + } + } catch (e) { + _cachedButtonRenderBox = null; + _cachedOverlayRenderBox = null; + } + } + } + + RelativeRect _getDefaultPosition(BoxConstraints constraints) { + return _lastPosition ?? RelativeRect.fromSize(Rect.zero, constraints.biggest); + } + + RelativeRect _positionBuilder(BuildContext _, BoxConstraints constraints) { + if (!mounted) { + // When the route is displayed, the `_positionBuilder` closure is stored. + // Even after the button has been unmounted and the context becomes invalid, + // the route might keep displaying, and `_positionBuilder` must continue to + // work in that case. + return _getDefaultPosition(constraints); + } + + final PopupMenuThemeData popupMenuTheme = _popupMenuTheme; + + // Use cached render objects if available. + final RenderBox? button = _cachedButtonRenderBox; + final RenderBox? overlay = _cachedOverlayRenderBox; + + // Check if cached render objects are available and still attached. + if (button == null || overlay == null || !button.attached || !overlay.attached) { + // Render objects are not available or detached, return cached position or default. + return _getDefaultPosition(constraints); + } + final PopupMenuPosition popupMenuPosition = + widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; + late Offset offset; + switch (popupMenuPosition) { + case PopupMenuPosition.over: + offset = widget.offset; + case PopupMenuPosition.under: + offset = Offset(0.0, button.size.height) + widget.offset; + if (widget.child == null) { + // Remove the padding of the icon button. + offset -= Offset(0.0, widget.padding.vertical / 2); + } + } + final position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(offset, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + + return _lastPosition = position; + } + + /// A method to show a popup menu with the items supplied to + /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. + /// + /// By default, it is called when the user taps the button and [PopupMenuButton.enabled] + /// is set to `true`. Moreover, you can open the button by calling the method manually. + /// + /// You would access your [PopupMenuButtonState] using a [GlobalKey] and + /// show the menu of the button with `globalKey.currentState.showButtonMenu`. + void showButtonMenu() { + // Ensure cached render objects are initialized + _updateCachedObjects(); + final List<PopupMenuEntry<T>> items = widget.itemBuilder(context); + // Only show the menu if there is something to show + if (items.isNotEmpty) { + widget.onOpened?.call(); + setState(() { + _isMenuExpanded = true; + }); + showMenu<T?>( + context: context, + elevation: widget.elevation, + shadowColor: widget.shadowColor, + surfaceTintColor: widget.surfaceTintColor, + items: items, + initialValue: widget.initialValue, + positionBuilder: _positionBuilder, + shape: widget.shape, + menuPadding: widget.menuPadding, + color: widget.color, + constraints: widget.constraints, + clipBehavior: widget.clipBehavior, + useRootNavigator: widget.useRootNavigator, + popUpAnimationStyle: widget.popUpAnimationStyle, + routeSettings: widget.routeSettings, + requestFocus: widget.requestFocus, + ).then<void>((T? newValue) { + if (!mounted) { + return null; + } + setState(() { + _isMenuExpanded = false; + }); + if (newValue == null) { + widget.onCanceled?.call(); + return null; + } + widget.onSelected?.call(newValue); + }); + } + } + + bool get _canRequestFocus { + final NavigationMode mode = + MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional; + return switch (mode) { + NavigationMode.traditional => widget.enabled, + NavigationMode.directional => true, + }; + } + + @protected + @override + Widget build(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final bool enableFeedback = + widget.enableFeedback ?? PopupMenuTheme.of(context).enableFeedback ?? true; + + assert(debugCheckHasMaterialLocalizations(context)); + + if (widget.child != null) { + final Widget child = Tooltip( + message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + child: InkWell( + borderRadius: widget.borderRadius, + onTap: widget.enabled ? showButtonMenu : null, + canRequestFocus: _canRequestFocus, + radius: widget.splashRadius, + enableFeedback: enableFeedback, + child: widget.child, + ), + ); + final MaterialTapTargetSize tapTargetSize = + widget.style?.tapTargetSize ?? MaterialTapTargetSize.shrinkWrap; + if (tapTargetSize == MaterialTapTargetSize.padded) { + return ConstrainedBox( + constraints: const BoxConstraints( + minWidth: kMinInteractiveDimension, + minHeight: kMinInteractiveDimension, + ), + child: child, + ); + } + return Semantics(expanded: _isMenuExpanded, child: child); + } + + return Semantics( + child: IconButton( + key: StandardComponentType.moreButton.key, + icon: Semantics(expanded: _isMenuExpanded, child: widget.icon ?? Icon(Icons.adaptive.more)), + padding: widget.padding, + splashRadius: widget.splashRadius, + iconSize: widget.iconSize ?? popupMenuTheme.iconSize ?? iconTheme.size, + color: widget.iconColor ?? popupMenuTheme.iconColor ?? iconTheme.color, + tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + onPressed: widget.enabled ? showButtonMenu : null, + enableFeedback: enableFeedback, + style: widget.style, + ), + ); + } +} + +// This WidgetStateProperty is passed along to the menu item's InkWell which +// resolves the property against WidgetState.disabled, WidgetState.hovered, +// WidgetState.focused. +class _EffectiveMouseCursor extends WidgetStateMouseCursor { + const _EffectiveMouseCursor(this.widgetCursor, this.themeCursor); + + final MouseCursor? widgetCursor; + final WidgetStateProperty<MouseCursor?>? themeCursor; + + @override + MouseCursor resolve(Set<WidgetState> states) { + return WidgetStateProperty.resolveAs<MouseCursor?>(widgetCursor, states) ?? + themeCursor?.resolve(states) ?? + WidgetStateMouseCursor.adaptiveClickable.resolve(states); + } + + @override + String get debugDescription => 'WidgetStateMouseCursor(PopupMenuItemState)'; +} + +class _PopupMenuDefaultsM2 extends PopupMenuThemeData { + _PopupMenuDefaultsM2(this.context) : super(elevation: 8.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + TextStyle? get textStyle => _textTheme.titleMedium; + + @override + EdgeInsets? get menuPadding => const EdgeInsets.symmetric(vertical: 8.0); + + static EdgeInsets menuItemPadding = const EdgeInsets.symmetric(horizontal: 16.0); +} + +// BEGIN GENERATED TOKEN PROPERTIES - PopupMenu + +// 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. + +// dart format off +class _PopupMenuDefaultsM3 extends PopupMenuThemeData { + _PopupMenuDefaultsM3(this.context) + : super(elevation: 3.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override WidgetStateProperty<TextStyle?>? get labelTextStyle { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + // TODO(quncheng): Update this hard-coded value to use the latest tokens. + final TextStyle style = _textTheme.labelLarge!; + if (states.contains(WidgetState.disabled)) { + return style.apply(color: _colors.onSurface.withOpacity(0.38)); + } + return style.apply(color: _colors.onSurface); + }); + } + + @override + Color? get color => _colors.surfaceContainer; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + + // TODO(bleroux): This is taken from https://m3.material.io/components/menus/specs + // Update this when the token is available. + @override + EdgeInsets? get menuPadding => const EdgeInsets.symmetric(vertical: 8.0); + + // TODO(tahatesser): This is taken from https://m3.material.io/components/menus/specs + // Update this when the token is available. + static EdgeInsets menuItemPadding = const EdgeInsets.symmetric(horizontal: 12.0); +}// dart format on + +// END GENERATED TOKEN PROPERTIES - PopupMenu diff --git a/packages/material_ui/lib/src/popup_menu_theme.dart b/packages/material_ui/lib/src/popup_menu_theme.dart new file mode 100644 index 000000000000..bdf23e1928e8 --- /dev/null +++ b/packages/material_ui/lib/src/popup_menu_theme.dart @@ -0,0 +1,288 @@ +// 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. + +/// @docImport 'popup_menu.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Used to configure how the [PopupMenuButton] positions its popup menu. +enum PopupMenuPosition { + /// Menu is positioned over the anchor. + over, + + /// Menu is positioned under the anchor. + under, +} + +/// Defines the visual properties of the routes used to display popup menus +/// as well as [PopupMenuItem] and [PopupMenuDivider] widgets. +/// +/// Descendant widgets obtain the current [PopupMenuThemeData] object +/// using [PopupMenuTheme.of]. Instances of [PopupMenuThemeData] can be +/// customized with [PopupMenuThemeData.copyWith]. +/// +/// Typically, a [PopupMenuThemeData] is specified as part of the +/// overall [Theme] with [ThemeData.popupMenuTheme]. Otherwise, +/// [PopupMenuTheme] can be used to configure its own widget subtree. +/// +/// All [PopupMenuThemeData] properties are `null` by default. +/// If any of these properties are null, the popup menu will provide its +/// own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class PopupMenuThemeData with Diagnosticable { + /// Creates the set of properties used to configure [PopupMenuTheme]. + const PopupMenuThemeData({ + this.color, + this.shape, + this.menuPadding, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.textStyle, + this.labelTextStyle, + this.enableFeedback, + this.mouseCursor, + this.position, + this.iconColor, + this.iconSize, + }); + + /// The background color of the popup menu. + final Color? color; + + /// The shape of the popup menu. + final ShapeBorder? shape; + + /// If specified, the padding of the popup menu. + /// + /// If [PopupMenuButton.menuPadding] is provided, [menuPadding] is ignored. + final EdgeInsetsGeometry? menuPadding; + + /// The elevation of the popup menu. + final double? elevation; + + /// The color used to paint shadow below the popup menu. + final Color? shadowColor; + + /// The color used as an overlay on [color] of the popup menu. + final Color? surfaceTintColor; + + /// The text style of items in the popup menu. + final TextStyle? textStyle; + + /// You can use this to specify a different style of the label + /// when the popup menu item is enabled and disabled. + final WidgetStateProperty<TextStyle?>? labelTextStyle; + + /// If specified, defines the feedback property for [PopupMenuButton]. + /// + /// If [PopupMenuButton.enableFeedback] is provided, [enableFeedback] is ignored. + final bool? enableFeedback; + + /// {@macro flutter.material.popupmenu.mouseCursor} + /// + /// If specified, overrides the default value of [PopupMenuItem.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Whether the popup menu is positioned over or under the popup menu button. + /// + /// When not set, the position defaults to [PopupMenuPosition.over] which makes the + /// popup menu appear directly over the button that was used to create it. + final PopupMenuPosition? position; + + /// The color of the icon in the popup menu button. + final Color? iconColor; + + /// The size of the icon in the popup menu button. + final double? iconSize; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + PopupMenuThemeData copyWith({ + Color? color, + ShapeBorder? shape, + EdgeInsetsGeometry? menuPadding, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + TextStyle? textStyle, + WidgetStateProperty<TextStyle?>? labelTextStyle, + bool? enableFeedback, + WidgetStateProperty<MouseCursor?>? mouseCursor, + PopupMenuPosition? position, + Color? iconColor, + double? iconSize, + }) { + return PopupMenuThemeData( + color: color ?? this.color, + shape: shape ?? this.shape, + menuPadding: menuPadding ?? this.menuPadding, + elevation: elevation ?? this.elevation, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + textStyle: textStyle ?? this.textStyle, + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + enableFeedback: enableFeedback ?? this.enableFeedback, + mouseCursor: mouseCursor ?? this.mouseCursor, + position: position ?? this.position, + iconColor: iconColor ?? this.iconColor, + iconSize: iconSize ?? this.iconSize, + ); + } + + /// Linearly interpolate between two popup menu themes. + /// + /// If both arguments are null, then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static PopupMenuThemeData? lerp(PopupMenuThemeData? a, PopupMenuThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return PopupMenuThemeData( + color: Color.lerp(a?.color, b?.color, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + menuPadding: EdgeInsetsGeometry.lerp(a?.menuPadding, b?.menuPadding, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shadowColor: Color.lerp(a?.shadowColor, b?.shadowColor, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + labelTextStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.labelTextStyle, + b?.labelTextStyle, + t, + TextStyle.lerp, + ), + enableFeedback: t < 0.5 ? a?.enableFeedback : b?.enableFeedback, + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + position: t < 0.5 ? a?.position : b?.position, + iconColor: Color.lerp(a?.iconColor, b?.iconColor, t), + iconSize: lerpDouble(a?.iconSize, b?.iconSize, t), + ); + } + + @override + int get hashCode => Object.hash( + color, + shape, + menuPadding, + elevation, + shadowColor, + surfaceTintColor, + textStyle, + labelTextStyle, + enableFeedback, + mouseCursor, + position, + iconColor, + iconSize, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is PopupMenuThemeData && + other.color == color && + other.shape == shape && + other.menuPadding == menuPadding && + other.elevation == elevation && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.textStyle == textStyle && + other.labelTextStyle == labelTextStyle && + other.enableFeedback == enableFeedback && + other.mouseCursor == mouseCursor && + other.position == position && + other.iconColor == iconColor && + other.iconSize == iconSize; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>('menuPadding', menuPadding, defaultValue: null), + ); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('shadowColor', shadowColor, defaultValue: null)); + properties.add(ColorProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty<TextStyle>('text style', textStyle, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'labelTextStyle', + labelTextStyle, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add(EnumProperty<PopupMenuPosition?>('position', position, defaultValue: null)); + properties.add(ColorProperty('iconColor', iconColor, defaultValue: null)); + properties.add(DoubleProperty('iconSize', iconSize, defaultValue: null)); + } +} + +/// An inherited widget that defines the configuration for +/// popup menus in this widget's subtree. +/// +/// Values specified here are used for popup menu properties that are not +/// given an explicit non-null value. +class PopupMenuTheme extends InheritedTheme { + /// Creates a popup menu theme that controls the configurations for + /// popup menus in its widget subtree. + const PopupMenuTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant popup menu widgets. + final PopupMenuThemeData data; + + /// The closest instance of this class's [data] value that encloses the given + /// context. If there is no ancestor, it returns [ThemeData.popupMenuTheme]. + /// Applications can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// PopupMenuThemeData theme = PopupMenuTheme.of(context); + /// ``` + static PopupMenuThemeData of(BuildContext context) { + final PopupMenuTheme? popupMenuTheme = context + .dependOnInheritedWidgetOfExactType<PopupMenuTheme>(); + return popupMenuTheme?.data ?? Theme.of(context).popupMenuTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return PopupMenuTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(PopupMenuTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/predictive_back_page_transitions_builder.dart b/packages/material_ui/lib/src/predictive_back_page_transitions_builder.dart new file mode 100644 index 000000000000..8d749de278c4 --- /dev/null +++ b/packages/material_ui/lib/src/predictive_back_page_transitions_builder.dart @@ -0,0 +1,724 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'page.dart'; +library; + +import 'dart:ui' show clampDouble; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'page_transitions_theme.dart'; + +/// Used by [PageTransitionsTheme] to define a [MaterialPageRoute] page +/// transition animation that looks like the default page transition used on +/// Android U and above when using predictive back. +/// +/// Predictive back is only supported on Android U and above, and if this +/// [PageTransitionsBuilder] is used by any other platform, it will fall back to +/// [FadeForwardsPageTransitionsBuilder]. +/// +/// When used on Android U and above, animates along with the back gesture to +/// reveal the destination route. Can be canceled by dragging back towards the +/// edge of the screen. +/// +/// See also: +/// +/// * [PredictiveBackFullscreenPageTransitionsBuilder], which is another +/// variant of Android's predictive back page transition. +/// * [FadeForwardsPageTransitionsBuilder], which defines the default page transition +/// that's similar to the one provided in Android 16. +/// * [ZoomPageTransitionsBuilder], which defines the default page transition +/// that's similar to the one provided in Android 10. +/// * [OpenUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android 9. +/// * [FadeUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android 8. +/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page +/// transition that matches native iOS page transitions. +/// * https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#shared-element-transition, +/// which is the Android spec for this page transition, called the Shared +/// Element page transition. +class PredictiveBackPageTransitionsBuilder extends PageTransitionsBuilder { + /// Creates an instance of a [PageTransitionsBuilder] that matches Android U's + /// predictive back transition. + const PredictiveBackPageTransitionsBuilder({this.fallbackColor}); + + /// The color of the scrim (background) when the predictive back transition is + /// not supported. + /// + /// If not provided, the background color of a default + /// [FadeForwardsPageTransitionsBuilder] will be used. + final Color? fallbackColor; + + @override + Duration get transitionDuration => + const Duration(milliseconds: FadeForwardsPageTransitionsBuilder.kTransitionMilliseconds); + + @override + Widget buildTransitions<T>( + PageRoute<T> route, + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + return _PredictiveBackGestureDetector( + route: route, + builder: + ( + BuildContext context, + _PredictiveBackPhase phase, + PredictiveBackEvent? startBackEvent, + PredictiveBackEvent? currentBackEvent, + ) { + // Only do a predictive back transition when the user is performing a + // pop gesture. Otherwise, for things like button presses or other + // programmatic navigation, fall back to + // FadeForwardsPageTransitionsBuilder. + if (route.popGestureInProgress) { + return _PredictiveBackSharedElementPageTransition( + isDelegatedTransition: true, + animation: animation, + phase: phase, + secondaryAnimation: secondaryAnimation, + startBackEvent: startBackEvent, + currentBackEvent: currentBackEvent, + child: child, + ); + } + + return FadeForwardsPageTransitionsBuilder( + backgroundColor: fallbackColor, + ).buildTransitions(route, context, animation, secondaryAnimation, child); + }, + ); + } +} + +/// Used by [PageTransitionsTheme] to define a [MaterialPageRoute] page +/// transition animation that looks like Android's Full Screen page transition. +/// +/// Predictive back is only supported on Android U and above, and if this +/// [PageTransitionsBuilder] is used by any other platform, it will fall back to +/// [ZoomPageTransitionsBuilder]. +/// +/// When used on Android U and above, animates along with the back gesture to +/// reveal the destination route. Can be canceled by dragging back towards the +/// edge of the screen. +/// +/// See also: +/// +/// * [PredictiveBackPageTransitionsBuilder], which is the default Android +/// predictive back page transition. +/// * [FadeForwardsPageTransitionsBuilder], which defines the default page +/// transition that's similar to the one provided in Android 16. +/// * [ZoomPageTransitionsBuilder], which defines the default page transition +/// that's similar to the one provided in Android 10. +/// * [OpenUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android 9. +/// * [FadeUpwardsPageTransitionsBuilder], which defines a page transition +/// that's similar to the one provided by Android 8. +/// * [CupertinoPageTransitionsBuilder], which defines a horizontal page +/// transition that matches native iOS page transitions. +/// * https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#full-screen-surfaces, +/// which is the native Android docs for this page transition. +class PredictiveBackFullscreenPageTransitionsBuilder extends PageTransitionsBuilder { + /// Creates an instance of a [PageTransitionsBuilder] that matches Android U's + /// full screen predictive back transition. + const PredictiveBackFullscreenPageTransitionsBuilder({this.fallbackColor}); + + /// The color of the scrim (background) when the predictive back transition is + /// not supported. + /// + /// If not provided, the background color of a default + /// [ZoomPageTransitionsBuilder] will be used. + final Color? fallbackColor; + + @override + Widget buildTransitions<T>( + PageRoute<T> route, + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + return _PredictiveBackGestureDetector( + route: route, + builder: + ( + BuildContext context, + _PredictiveBackPhase phase, + PredictiveBackEvent? startBackEvent, + PredictiveBackEvent? currentBackEvent, + ) { + // Only do a predictive back transition when the user is performing a + // pop gesture. Otherwise, for things like button presses or other + // programmatic navigation, fall back to ZoomPageTransitionsBuilder. + if (route.popGestureInProgress) { + return _PredictiveBackFullscreenPageTransition( + animation: animation, + secondaryAnimation: secondaryAnimation, + getIsCurrent: () => route.isCurrent, + phase: phase, + child: child, + ); + } + + return ZoomPageTransitionsBuilder( + backgroundColor: fallbackColor, + ).buildTransitions(route, context, animation, secondaryAnimation, child); + }, + ); + } +} + +typedef _PredictiveBackGestureDetectorWidgetBuilder = + Widget Function( + BuildContext context, + _PredictiveBackPhase phase, + PredictiveBackEvent? startBackEvent, + PredictiveBackEvent? currentBackEvent, + ); + +/// The phases of a predictive back gesture. +enum _PredictiveBackPhase { + /// There is no active predictive back gesture in progress. + idle, + + /// The user pointer has contacted the screen. + start, + + /// The user pointer has moved. + update, + + /// The user pointer has released in a position in which Android has + /// determined that the back gesture is successful and the current route + /// should be popped. + commit, + + /// The user pointer has released in a position in which Android has + /// determined that the back gesture should be canceled and the original route + /// should be shown. + cancel, +} + +class _PredictiveBackGestureDetector extends StatefulWidget { + const _PredictiveBackGestureDetector({required this.route, required this.builder}); + + final _PredictiveBackGestureDetectorWidgetBuilder builder; + final PageRoute<dynamic> route; + + @override + State<_PredictiveBackGestureDetector> createState() => _PredictiveBackGestureDetectorState(); +} + +class _PredictiveBackGestureDetectorState extends State<_PredictiveBackGestureDetector> + with WidgetsBindingObserver { + /// True when the predictive back gesture is enabled. + bool get _isEnabled { + return widget.route.isCurrent && widget.route.popGestureEnabled; + } + + _PredictiveBackPhase get phase => _phase; + _PredictiveBackPhase _phase = _PredictiveBackPhase.idle; + set phase(_PredictiveBackPhase phase) { + if (_phase != phase && mounted) { + setState(() => _phase = phase); + } + } + + /// The back event when the gesture first started. + PredictiveBackEvent? get startBackEvent => _startBackEvent; + PredictiveBackEvent? _startBackEvent; + set startBackEvent(PredictiveBackEvent? startBackEvent) { + if (_startBackEvent != startBackEvent && mounted) { + setState(() => _startBackEvent = startBackEvent); + } + } + + /// The most recent back event during the gesture. + PredictiveBackEvent? get currentBackEvent => _currentBackEvent; + PredictiveBackEvent? _currentBackEvent; + set currentBackEvent(PredictiveBackEvent? currentBackEvent) { + if (_currentBackEvent != currentBackEvent && mounted) { + setState(() => _currentBackEvent = currentBackEvent); + } + } + + // Begin WidgetsBindingObserver. + + @override + bool handleStartBackGesture(PredictiveBackEvent backEvent) { + phase = _PredictiveBackPhase.start; + final bool gestureInProgress = !backEvent.isButtonEvent && _isEnabled; + if (!gestureInProgress) { + return false; + } + + widget.route.handleStartBackGesture(progress: 1 - backEvent.progress); + startBackEvent = currentBackEvent = backEvent; + return true; + } + + @override + void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) { + phase = _PredictiveBackPhase.update; + + widget.route.handleUpdateBackGestureProgress(progress: 1 - backEvent.progress); + currentBackEvent = backEvent; + } + + @override + void handleCancelBackGesture() { + phase = _PredictiveBackPhase.cancel; + + widget.route.handleCancelBackGesture(); + startBackEvent = currentBackEvent = null; + } + + @override + void handleCommitBackGesture() { + phase = _PredictiveBackPhase.commit; + + widget.route.handleCommitBackGesture(); + startBackEvent = currentBackEvent = null; + } + + // End WidgetsBindingObserver. + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final _PredictiveBackPhase effectivePhase = widget.route.popGestureInProgress + ? phase + : _PredictiveBackPhase.idle; + return widget.builder(context, effectivePhase, startBackEvent, currentBackEvent); + } +} + +/// Android's predictive back page shared element transition. +/// +/// See also: +/// +/// * <https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#shared-element-transition>, +/// which is the Android spec for this transition. +class _PredictiveBackSharedElementPageTransition extends StatefulWidget { + const _PredictiveBackSharedElementPageTransition({ + required this.isDelegatedTransition, + required this.animation, + required this.secondaryAnimation, + required this.phase, + required this.startBackEvent, + required this.currentBackEvent, + required this.child, + }); + + final bool isDelegatedTransition; + final Animation<double> animation; + final Animation<double> secondaryAnimation; + final _PredictiveBackPhase phase; + final PredictiveBackEvent? startBackEvent; + final PredictiveBackEvent? currentBackEvent; + final Widget child; + + @override + State<_PredictiveBackSharedElementPageTransition> createState() => + _PredictiveBackSharedElementPageTransitionState(); +} + +class _PredictiveBackSharedElementPageTransitionState + extends State<_PredictiveBackSharedElementPageTransition> + with SingleTickerProviderStateMixin { + // Constants as per the motion specs + // https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#motion-specs + static const double _kMinScale = 0.90; + static const double _kDivisionFactor = 20.0; + static const double _kMargin = 8.0; + static const double _kYPositionFactor = 0.1; + + // The duration of the commit transition. + // + // This is not the same as PredictiveBackPageTransitionsBuilder's duration, + // which is the duration of widget.animation, so an Interval is used. + // + // Eyeballed on a Pixel 9 running Android 16. + static const int _kCommitMilliseconds = 400; + static const Curve _kCurve = Curves.easeInOutCubicEmphasized; + static const Interval _kCommitInterval = Interval( + 0.0, + _kCommitMilliseconds / FadeForwardsPageTransitionsBuilder.kTransitionMilliseconds, + curve: _kCurve, + ); + + // A fallback corner radius used when the display corner radii are + // unavailable (e.g., on Android API levels below 31, iOS, and other + // platforms). This is a best-guess value that looks reasonable on most + // devices. + // See https://github.com/flutter/flutter/issues/97349. + static const double _kDeviceBorderRadius = 32.0; + + // Provides a smooth transition between the default radius and the + // _kDeviceBorderRadius, when the display corner radii are unavailable. + final Tween<double> _borderRadiusTween = Tween<double>(begin: 0.0, end: _kDeviceBorderRadius); + + // The route fades out after commit. + final Tween<double> _opacityTween = Tween<double>(begin: 1.0, end: 0.0); + + // The route shrinks during the gesture and animates back to normal after + // commit. + final Tween<double> _scaleTween = Tween<double>(begin: 1.0, end: _kMinScale); + + // An animation that stays constant at zero before the commit, and after the + // commit goes from zero to one. + final ProxyAnimation _commitAnimation = ProxyAnimation(); + + // An animation that goes from zero to a maximum of one during a predictive + // back gesture, and then at commit, it goes from its current value to zero. + // Used for animations that follow the gesture and then animate back to their + // original value after commit. + final ProxyAnimation _bounceAnimation = ProxyAnimation(); + double _lastBounceAnimationValue = 0.0; + + // An animation that proxies to widget.animation during the gesture and then + // to _commitAnimation after the commit. So, it goes from zero to a maximum of + // one before commit, and then after commit goes from zero to one again. + final ProxyAnimation _animation = ProxyAnimation(); + + /// The same as widget.animation but with a curve applied. + CurvedAnimation? _curvedAnimation; + + /// The reverse of _curvedAnimation. + CurvedAnimation? _curvedAnimationReversed; + + late Animation<Offset> _positionAnimation; + + Offset _lastDrag = Offset.zero; + + // This isn't done as an animation because it's based on the vertical drag + // amount, not the progression of the back gesture like widget.animation is. + double _getYShiftPosition(double screenHeight) { + final double startTouchY = widget.startBackEvent?.touchOffset?.dy ?? 0; + final double currentTouchY = widget.currentBackEvent?.touchOffset?.dy ?? 0; + + final double yShiftMax = (screenHeight / _kDivisionFactor) - _kMargin; + + final double rawYShift = currentTouchY - startTouchY; + final double easedYShift = + // This curve was eyeballed on a Pixel 9 running Android 16. + Curves.easeOut.transform(clampDouble(rawYShift.abs() / screenHeight, 0.0, 1.0)) * + rawYShift.sign * + yShiftMax; + + return clampDouble(easedYShift, -yShiftMax, yShiftMax); + } + + void _updateAnimations(Size screenSize) { + _animation.parent = switch (widget.phase) { + _PredictiveBackPhase.commit => _curvedAnimationReversed, + _ => widget.animation, + }; + + _bounceAnimation.parent = switch (widget.phase) { + _PredictiveBackPhase.commit => Tween<double>( + begin: 0.0, + end: _lastBounceAnimationValue, + ).animate(_curvedAnimation!), + _ => ReverseAnimation(widget.animation), + }; + + _commitAnimation.parent = switch (widget.phase) { + _PredictiveBackPhase.commit => _animation, + _ => kAlwaysDismissedAnimation, + }; + + final double xShift = (screenSize.width / _kDivisionFactor) - _kMargin; + _positionAnimation = _animation.drive(switch (widget.phase) { + _PredictiveBackPhase.commit => Tween<Offset>( + begin: _lastDrag, + end: Offset(screenSize.height * _kYPositionFactor, 0.0), + ), + _ => Tween<Offset>( + // The y position before commit is given by the vertical drag, not by an + // animation. + begin: switch (widget.currentBackEvent?.swipeEdge) { + SwipeEdge.left => Offset(xShift, _getYShiftPosition(screenSize.height)), + SwipeEdge.right => Offset(-xShift, _getYShiftPosition(screenSize.height)), + null => Offset(xShift, _getYShiftPosition(screenSize.height)), + }, + end: Offset.zero, + ), + }); + } + + void _updateCurvedAnimations() { + _curvedAnimation?.dispose(); + _curvedAnimationReversed?.dispose(); + _curvedAnimation = CurvedAnimation(parent: widget.animation, curve: _kCommitInterval); + _curvedAnimationReversed = CurvedAnimation( + parent: ReverseAnimation(widget.animation), + curve: _kCommitInterval, + ); + } + + // TODO(justinmc): Should have a delegatedTransition to animate the incoming + // route regardless of its page transition. + // https://github.com/flutter/flutter/issues/153577 + + @override + void initState() { + super.initState(); + } + + @override + void didUpdateWidget(_PredictiveBackSharedElementPageTransition oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.animation != oldWidget.animation) { + _updateCurvedAnimations(); + } + if (widget.phase != oldWidget.phase && widget.phase == _PredictiveBackPhase.commit) { + _updateAnimations(MediaQuery.sizeOf(context)); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateCurvedAnimations(); + _updateAnimations(MediaQuery.sizeOf(context)); + } + + @override + void dispose() { + _curvedAnimation!.dispose(); + _curvedAnimationReversed!.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.animation, + builder: (BuildContext context, Widget? child) { + _lastBounceAnimationValue = _bounceAnimation.value; + return Transform.scale( + scale: _scaleTween.evaluate(_bounceAnimation), + child: Transform.translate( + offset: switch (widget.phase) { + _PredictiveBackPhase.commit => _positionAnimation.value, + _ => _lastDrag = Offset( + _positionAnimation.value.dx, + _getYShiftPosition(MediaQuery.heightOf(context)), + ), + }, + child: Opacity( + opacity: _opacityTween.evaluate(_commitAnimation), + child: ClipRRect( + borderRadius: + MediaQuery.displayCornerRadiiOf(context) ?? + BorderRadius.circular(_borderRadiusTween.evaluate(_bounceAnimation)), + child: child, + ), + ), + ), + ); + }, + child: widget.child, + ); + } +} + +/// Android's predictive back page transition for full screen surfaces. +/// +/// See also: +/// +/// * <https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#full-screen-surfaces>, +/// which is the Android spec for this transition. +class _PredictiveBackFullscreenPageTransition extends StatefulWidget { + const _PredictiveBackFullscreenPageTransition({ + required this.animation, + required this.secondaryAnimation, + required this.getIsCurrent, + required this.phase, + required this.child, + }); + + final Animation<double> animation; + final Animation<double> secondaryAnimation; + final _PredictiveBackPhase phase; + final ValueGetter<bool> getIsCurrent; + final Widget child; + + @override + State<_PredictiveBackFullscreenPageTransition> createState() => + _PredictiveBackFullscreenPageTransitionState(); +} + +class _PredictiveBackFullscreenPageTransitionState + extends State<_PredictiveBackFullscreenPageTransition> { + // These values were eyeballed to match the Android spec for the Full Screen + // page transition: + // https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#full-screen-surfaces + static const double _kScaleStart = 1.0; + static const double _kScaleCommit = 0.95; + static const double _kOpacityFullyOpened = 1.0; + static const double _kOpacityStartTransition = 0.95; + // The point at which the drag would cause a commit instead of a cancel if it + // were released. + static const double _kCommitAt = 0.65; + static const double _kWeightPreCommit = _kCommitAt; + static const double _kWeightPostCommit = 1 - _kWeightPreCommit; + static const double _kScreenWidthDivisionFactor = 20.0; + static const double _kXShiftAdjustment = 8.0; + static const Duration _kCommitDuration = Duration(milliseconds: 100); + + final Animatable<double> _primaryOpacityTween = Tween<double>( + begin: _kOpacityStartTransition, + end: _kOpacityFullyOpened, + ); + + final Animatable<double> _primaryScaleTween = TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>( + tween: Tween<double>(begin: _kScaleStart, end: _kScaleStart), + weight: _kWeightPreCommit, + ), + TweenSequenceItem<double>( + tween: Tween<double>(begin: _kScaleCommit, end: _kScaleStart), + weight: _kWeightPostCommit, + ), + ]); + + final ConstantTween<double> _secondaryScaleTweenCurrent = ConstantTween<double>(_kScaleStart); + final TweenSequence<double> _secondaryTweenScale = + TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>( + tween: Tween<double>(begin: _kScaleCommit, end: _kScaleStart), + weight: _kWeightPreCommit, + ), + TweenSequenceItem<double>( + tween: Tween<double>(begin: _kScaleStart, end: _kScaleStart), + weight: _kWeightPostCommit, + ), + ]); + + final ConstantTween<double> _secondaryOpacityTweenCurrent = ConstantTween<double>( + _kOpacityFullyOpened, + ); + final TweenSequence<double> _secondaryOpacityTween = + TweenSequence<double>(<TweenSequenceItem<double>>[ + TweenSequenceItem<double>( + tween: Tween<double>(begin: _kOpacityFullyOpened, end: _kOpacityStartTransition), + weight: _kWeightPreCommit, + ), + TweenSequenceItem<double>( + tween: Tween<double>(begin: _kOpacityFullyOpened, end: _kOpacityFullyOpened), + weight: _kWeightPostCommit, + ), + ]); + + late Animatable<Offset> _primaryPositionTween; + late Animatable<Offset> _secondaryPositionTween; + late Animatable<Offset> _secondaryCurrentPositionTween; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final double screenWidth = MediaQuery.widthOf(context); + final double xShift = (screenWidth / _kScreenWidthDivisionFactor) - _kXShiftAdjustment; + _primaryPositionTween = TweenSequence<Offset>(<TweenSequenceItem<Offset>>[ + TweenSequenceItem<Offset>( + tween: Tween<Offset>(begin: Offset.zero, end: Offset.zero), + weight: _kWeightPreCommit, + ), + TweenSequenceItem<Offset>( + tween: Tween<Offset>(begin: Offset(xShift, 0.0), end: Offset.zero), + weight: _kWeightPostCommit, + ), + ]); + + _secondaryCurrentPositionTween = ConstantTween<Offset>(Offset.zero); + _secondaryPositionTween = Tween<Offset>(begin: Offset(xShift, 0.0), end: Offset.zero); + } + + Widget _secondaryAnimatedBuilder(BuildContext context, Widget? child) { + final bool isCurrent = widget.getIsCurrent(); + + return Transform.translate( + offset: isCurrent + ? _secondaryCurrentPositionTween.evaluate(widget.secondaryAnimation) + : _secondaryPositionTween.evaluate(widget.secondaryAnimation), + child: Transform.scale( + scale: isCurrent + ? _secondaryScaleTweenCurrent.evaluate(widget.secondaryAnimation) + : _secondaryTweenScale.evaluate(widget.secondaryAnimation), + child: Opacity( + opacity: isCurrent + ? _secondaryOpacityTweenCurrent.evaluate(widget.secondaryAnimation) + : _secondaryOpacityTween.evaluate(widget.secondaryAnimation), + child: child, + ), + ), + ); + } + + Widget _primaryAnimatedBuilder(BuildContext context, Widget? child) { + return Transform.translate( + offset: _primaryPositionTween.evaluate(widget.animation), + child: Transform.scale( + scale: _primaryScaleTween.evaluate(widget.animation), + // A slight change in opacity before reaching the commit point. + child: Opacity( + opacity: _primaryOpacityTween.evaluate(widget.animation), + // A sudden fadeout at the commit point, driven by time and not the + // gesture. + child: AnimatedOpacity( + opacity: switch (widget.phase) { + _PredictiveBackPhase.commit => 0.0, + _ => widget.animation.value < _kCommitAt ? 0.0 : 1.0, + }, + duration: _kCommitDuration, + child: child, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.secondaryAnimation, + builder: _secondaryAnimatedBuilder, + child: AnimatedBuilder( + animation: widget.animation, + builder: _primaryAnimatedBuilder, + child: ClipRRect( + borderRadius: + MediaQuery.displayCornerRadiiOf(context) ?? + const BorderRadius.all( + Radius.circular( + _PredictiveBackSharedElementPageTransitionState._kDeviceBorderRadius, + ), + ), + child: widget.child, + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/progress_indicator.dart b/packages/material_ui/lib/src/progress_indicator.dart new file mode 100644 index 000000000000..7fe596cf0720 --- /dev/null +++ b/packages/material_ui/lib/src/progress_indicator.dart @@ -0,0 +1,1640 @@ +// 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. + +/// @docImport 'package:flutter/semantics.dart'; +/// +/// @docImport 'refresh_indicator.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; + +import 'color_scheme.dart'; +import 'material.dart'; +import 'progress_indicator_theme.dart'; +import 'theme.dart'; + +// This value is extracted from +// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/res/res/anim/progress_indeterminate_material.xml;drc=9cb5b4c2d93acb9d6f5e14167e265c328c487d6b +const int _kIndeterminateLinearDuration = 1800; +// This value is extracted from +// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/res/res/anim/progress_indeterminate_rotation_material.xml;drc=077b44912b879174cec48a25307f1c19b96c2a78 +const int _kIndeterminateCircularDuration = 1333 * 2222; + +// The progress value below which the track gap is scaled proportionally to +// prevent a track gap from appearing at 0% progress. +const double _kTrackGapRampDownThreshold = 0.01; + +enum _ActivityIndicatorType { material, adaptive } + +const String _kValueControllerAssertion = + 'A progress indicator cannot have both a value and a controller.\n' + 'The "value" property is for a determinate indicator with a specific progress, ' + 'while the "controller" is for controlling the animation of an indeterminate indicator.\n' + 'To resolve this, provide only one of the two properties.'; + +/// A base class for Material Design progress indicators. +/// +/// This widget cannot be instantiated directly. For a linear progress +/// indicator, see [LinearProgressIndicator]. For a circular progress indicator, +/// see [CircularProgressIndicator]. +/// +/// See also: +/// +/// * <https://material.io/components/progress-indicators> +abstract class ProgressIndicator extends StatefulWidget { + /// Creates a progress indicator. + /// + /// {@template flutter.material.ProgressIndicator.ProgressIndicator} + /// The [value] argument can either be null for an indeterminate + /// progress indicator, or a non-null value between 0.0 and 1.0 for a + /// determinate progress indicator. + /// + /// ## Accessibility + /// + /// The [semanticsLabel] can be used to identify the purpose of this progress + /// bar for screen reading software. The [semanticsValue] property may be used + /// for determinate progress indicators to indicate how much progress has been made. + /// {@endtemplate} + const ProgressIndicator({ + super.key, + this.value, + this.backgroundColor, + this.color, + this.valueColor, + this.semanticsLabel, + this.semanticsValue, + }); + + /// If non-null, the value of this progress indicator. + /// + /// A value of 0.0 means no progress and 1.0 means that progress is complete. + /// The value will be clamped to be in the range 0.0-1.0. + /// + /// If null, this progress indicator is indeterminate, which means the + /// indicator displays a predetermined animation that does not indicate how + /// much actual progress is being made. + final double? value; + + double? get _effectiveValue => value == null ? null : clampDouble(value!, 0.0, 1.0); + + /// The progress indicator's background color. + /// + /// It is up to the subclass to implement this in whatever way makes sense + /// for the given use case. See the subclass documentation for details. + final Color? backgroundColor; + + /// {@template flutter.progress_indicator.ProgressIndicator.color} + /// The progress indicator's color. + /// + /// This is only used if [ProgressIndicator.valueColor] is null. + /// If [ProgressIndicator.color] is also null, then the ambient + /// [ProgressIndicatorThemeData.color] will be used. If that + /// is null then the current theme's [ColorScheme.primary] will + /// be used by default. + /// {@endtemplate} + final Color? color; + + /// The progress indicator's color as an animated value. + /// + /// If null, the progress indicator is rendered with [color]. If that is null, + /// then it will use the ambient [ProgressIndicatorThemeData.color]. If that + /// is also null then it defaults to the current theme's [ColorScheme.primary]. + final Animation<Color?>? valueColor; + + /// {@template flutter.progress_indicator.ProgressIndicator.semanticsLabel} + /// The [SemanticsProperties.label] for this progress indicator. + /// + /// This value indicates the purpose of the progress bar, and will be + /// read out by screen readers to indicate the purpose of this progress + /// indicator. + /// {@endtemplate} + final String? semanticsLabel; + + /// {@template flutter.progress_indicator.ProgressIndicator.semanticsValue} + /// The [SemanticsProperties.value] for this progress indicator. + /// + /// This will be used in conjunction with the [semanticsLabel] by + /// screen reading software to identify the widget, and is primarily + /// intended for use with determinate progress indicators to announce + /// how far along they are. + /// + /// For determinate progress indicators, this will be defaulted to + /// [ProgressIndicator.value] expressed as a percentage, i.e. `0.1` will + /// become '10%'. + /// {@endtemplate} + final String? semanticsValue; + + Color _getValueColor(BuildContext context, {Color? defaultColor}) { + return valueColor?.value ?? + color ?? + ProgressIndicatorTheme.of(context).color ?? + defaultColor ?? + Theme.of(context).colorScheme.primary; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(PercentProperty('value', value, showName: false, ifNull: '<indeterminate>')); + } + + Widget _buildSemanticsWrapper({required BuildContext context, required Widget child}) { + var isProgressBar = false; + String? expandedSemanticsValue = semanticsValue; + if (value != null) { + expandedSemanticsValue ??= '${(_effectiveValue! * 100).round()}'; + isProgressBar = true; + } + return Semantics( + label: semanticsLabel, + role: isProgressBar ? SemanticsRole.progressBar : SemanticsRole.loadingSpinner, + minValue: isProgressBar ? '0' : null, + maxValue: isProgressBar ? '100' : null, + value: expandedSemanticsValue, + child: child, + ); + } +} + +class _LinearProgressIndicatorPainter extends CustomPainter { + const _LinearProgressIndicatorPainter({ + required this.trackColor, + required this.valueColor, + this.value, + required this.animationValue, + required this.textDirection, + required this.indicatorBorderRadius, + required this.stopIndicatorColor, + required this.stopIndicatorRadius, + required this.trackGap, + }); + + final Color trackColor; + final Color valueColor; + final double? value; + final double animationValue; + final TextDirection textDirection; + final BorderRadiusGeometry? indicatorBorderRadius; + final Color? stopIndicatorColor; + final double? stopIndicatorRadius; + final double? trackGap; + + // The indeterminate progress animation displays two lines whose leading (head) + // and trailing (tail) endpoints are defined by the following four curves. + static const Curve line1Head = Interval( + 0.0, + 750.0 / _kIndeterminateLinearDuration, + curve: Cubic(0.2, 0.0, 0.8, 1.0), + ); + static const Curve line1Tail = Interval( + 333.0 / _kIndeterminateLinearDuration, + (333.0 + 750.0) / _kIndeterminateLinearDuration, + curve: Cubic(0.4, 0.0, 1.0, 1.0), + ); + static const Curve line2Head = Interval( + 1000.0 / _kIndeterminateLinearDuration, + (1000.0 + 567.0) / _kIndeterminateLinearDuration, + curve: Cubic(0.0, 0.0, 0.65, 1.0), + ); + static const Curve line2Tail = Interval( + 1267.0 / _kIndeterminateLinearDuration, + (1267.0 + 533.0) / _kIndeterminateLinearDuration, + curve: Cubic(0.10, 0.0, 0.45, 1.0), + ); + + @override + void paint(Canvas canvas, Size size) { + final double effectiveTrackGap = trackGap ?? 0.0; + + void drawLinearIndicator({ + required double startFraction, + required double endFraction, + required Color color, + }) { + if (endFraction - startFraction <= 0) { + return; + } + + final isLtr = textDirection == TextDirection.ltr; + final double left = (isLtr ? startFraction : 1 - endFraction) * size.width; + final double right = (isLtr ? endFraction : 1 - startFraction) * size.width; + + final rect = Rect.fromLTRB(left, 0, right, size.height); + final paint = Paint()..color = color; + + if (indicatorBorderRadius != null) { + final RRect rrect = indicatorBorderRadius!.resolve(textDirection).toRRect(rect); + canvas.drawRRect(rrect, paint); + } else { + canvas.drawRect(rect, paint); + } + } + + void drawStopIndicator() { + // Limit the stop indicator to the height of the indicator. + final double maxRadius = size.height / 2; + final double radius = math.min(stopIndicatorRadius!, maxRadius); + final indicatorPaint = Paint()..color = stopIndicatorColor!; + final Offset position = switch (textDirection) { + TextDirection.rtl => Offset(maxRadius, maxRadius), + TextDirection.ltr => Offset(size.width - maxRadius, maxRadius), + }; + canvas.drawCircle(position, radius, indicatorPaint); + } + + // Calculates a track gap fraction that is scaled proportionally to a given + // value. + // This is used for a smooth transition of the track gap's size, preventing + // it from appearing or disappearing abruptly. The returned value increases + // linearly from 0 to the full `trackGapFraction` as `currentValue` + // increases from 0 to `_kTrackGapRampDownThreshold`. + double getEffectiveTrackGapFraction(double currentValue, double trackGapFraction) { + return trackGapFraction * + clampDouble(currentValue, 0, _kTrackGapRampDownThreshold) / + _kTrackGapRampDownThreshold; + } + + final double trackGapFraction = effectiveTrackGap / size.width; + final double? effectiveValue = value == null ? null : clampDouble(value!, 0.0, 1.0); + + // Determinate progress indicator. + if (effectiveValue != null) { + final double trackStartFraction = trackGapFraction > 0 + ? effectiveValue + getEffectiveTrackGapFraction(effectiveValue, trackGapFraction) + : 0; + + // Draw the track when there is still space. + if (trackStartFraction < 1) { + drawLinearIndicator(startFraction: trackStartFraction, endFraction: 1, color: trackColor); + } + + // Draw the stop indicator. + if (stopIndicatorRadius != null && stopIndicatorRadius! > 0) { + drawStopIndicator(); + } + + // Draw the active indicator. + if (effectiveValue > 0) { + drawLinearIndicator(startFraction: 0, endFraction: effectiveValue, color: valueColor); + } + + return; + } + + // Indeterminate progress indicator. + // For LTR text direction the `head` is the right endpoint and the `tail` is + // the left endpoint. + final double firstLineHead = line1Head.transform(animationValue); + final double firstLineTail = line1Tail.transform(animationValue); + final double secondLineHead = line2Head.transform(animationValue); + final double secondLineTail = line2Tail.transform(animationValue); + + // Draw the track before line 1. Assuming text direction is LTR, this track + // appears on the right side of line 1. + if (firstLineHead < 1 - trackGapFraction) { + final double trackStartFraction = firstLineHead > 0 + ? firstLineHead + getEffectiveTrackGapFraction(firstLineHead, trackGapFraction) + : 0; + drawLinearIndicator(startFraction: trackStartFraction, endFraction: 1, color: trackColor); + } + + // Draw the line 1. + if (firstLineHead - firstLineTail > 0) { + drawLinearIndicator( + startFraction: firstLineTail, + endFraction: firstLineHead, + color: valueColor, + ); + } + + // Draw the track between line 1 and line 2. Assuming text direction is + // LTR, this track appears on the left side of line 1 and on the right side + // of line 2. + if (firstLineTail > trackGapFraction) { + final double trackStartFraction = secondLineHead > 0 + ? secondLineHead + getEffectiveTrackGapFraction(secondLineHead, trackGapFraction) + : 0; + final double trackEndFraction = firstLineTail < 1 + ? firstLineTail - getEffectiveTrackGapFraction(1 - firstLineTail, trackGapFraction) + : 1; + drawLinearIndicator( + startFraction: trackStartFraction, + endFraction: trackEndFraction, + color: trackColor, + ); + } + + // Draw the line 2. + if (secondLineHead - secondLineTail > 0) { + drawLinearIndicator( + startFraction: secondLineTail, + endFraction: secondLineHead, + color: valueColor, + ); + } + + // Draw the track after line 2. Assuming text direction is LTR, this track + // appears on the left side of line 2. + if (secondLineTail > trackGapFraction) { + final double trackEndFraction = secondLineTail < 1 + ? secondLineTail - getEffectiveTrackGapFraction(1 - secondLineTail, trackGapFraction) + : 1; + drawLinearIndicator(startFraction: 0, endFraction: trackEndFraction, color: trackColor); + } + } + + @override + bool shouldRepaint(_LinearProgressIndicatorPainter oldPainter) { + return oldPainter.trackColor != trackColor || + oldPainter.valueColor != valueColor || + oldPainter.value != value || + oldPainter.animationValue != animationValue || + oldPainter.textDirection != textDirection || + oldPainter.indicatorBorderRadius != indicatorBorderRadius || + oldPainter.stopIndicatorColor != stopIndicatorColor || + oldPainter.stopIndicatorRadius != stopIndicatorRadius || + oldPainter.trackGap != trackGap; + } +} + +/// A Material Design linear progress indicator, also known as a progress bar. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=O-rhXZLtpv0} +/// +/// A widget that shows progress along a line. There are two kinds of linear +/// progress indicators: +/// +/// * _Determinate_. Determinate progress indicators have a specific value at +/// each point in time, and the value should increase monotonically from 0.0 +/// to 1.0, at which time the indicator is complete. To create a determinate +/// progress indicator, use a non-null [value] between 0.0 and 1.0. +/// * _Indeterminate_. Indeterminate progress indicators do not have a specific +/// value at each point in time and instead indicate that progress is being +/// made without indicating how much progress remains. To create an +/// indeterminate progress indicator, use a null [value]. +/// +/// The indicator line is displayed with [valueColor], an animated value. To +/// specify a constant color value use: `AlwaysStoppedAnimation<Color>(color)`. +/// +/// The minimum height of the indicator can be specified using [minHeight]. +/// The indicator can be made taller by wrapping the widget with a [SizedBox]. +/// +/// {@tool dartpad} +/// This example showcases determinate and indeterminate [LinearProgressIndicator]s. +/// The [LinearProgressIndicator]s will use the ![updated Material 3 Design appearance](https://m3.material.io/components/progress-indicators/overview) +/// when setting the [LinearProgressIndicator.year2023] flag to false. +/// +/// ** See code in examples/api/lib/material/progress_indicator/linear_progress_indicator.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a [LinearProgressIndicator] with a changing value. +/// When toggling the switch, [LinearProgressIndicator] uses a determinate value. +/// As described in: https://m3.material.io/components/progress-indicators/overview +/// +/// ** See code in examples/api/lib/material/progress_indicator/linear_progress_indicator.1.dart ** +/// {@end-tool} +/// +/// {@macro flutter.material.ProgressIndicator.AnimationSynchronization} +/// +/// See the documentation of [CircularProgressIndicator] for an example on this +/// topic. +/// +/// See also: +/// +/// * [CircularProgressIndicator], which shows progress along a circular arc. +/// * [RefreshIndicator], which automatically displays a [CircularProgressIndicator] +/// when the underlying vertical scrollable is overscrolled. +/// * <https://material.io/design/components/progress-indicators.html#linear-progress-indicators> +class LinearProgressIndicator extends ProgressIndicator { + /// Creates a linear progress indicator. + /// + /// {@macro flutter.material.ProgressIndicator.ProgressIndicator} + const LinearProgressIndicator({ + super.key, + super.value, + super.backgroundColor, + super.color, + super.valueColor, + this.minHeight, + super.semanticsLabel, + super.semanticsValue, + this.borderRadius, + this.stopIndicatorColor, + this.stopIndicatorRadius, + this.trackGap, + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.26.0-0.1.pre.', + ) + this.year2023, + this.controller, + }) : assert(minHeight == null || minHeight > 0), + assert(value == null || controller == null, _kValueControllerAssertion); + + /// {@template flutter.material.LinearProgressIndicator.trackColor} + /// Color of the track being filled by the linear indicator. + /// + /// If [LinearProgressIndicator.backgroundColor] is null then the + /// ambient [ProgressIndicatorThemeData.linearTrackColor] will be used. + /// If that is null, then the ambient theme's [ColorScheme.background] + /// will be used to draw the track. + /// {@endtemplate} + @override + Color? get backgroundColor => super.backgroundColor; + + /// {@template flutter.material.LinearProgressIndicator.minHeight} + /// The minimum height of the line used to draw the linear indicator. + /// + /// If [LinearProgressIndicator.minHeight] is null then it will use the + /// ambient [ProgressIndicatorThemeData.linearMinHeight]. If that is null + /// it will use 4dp. + /// {@endtemplate} + final double? minHeight; + + /// The border radius of both the indicator and the track. + /// + /// If null, then the [ProgressIndicatorThemeData.borderRadius] will be used. + /// If that is also null, then defaults to radius of 2, which produces a + /// rounded shape with a rounded indicator. If [ThemeData.useMaterial3] is false, + /// then defaults to [BorderRadius.zero], which produces a rectangular shape + /// with a rectangular indicator. + final BorderRadiusGeometry? borderRadius; + + /// The color of the stop indicator. + /// + /// If [year2023] is true or [ThemeData.useMaterial3] is false, then no stop + /// indicator will be drawn. + /// + /// If null, then the [ProgressIndicatorThemeData.stopIndicatorColor] will be used. + /// If that is null, then the [ColorScheme.primary] will be used. + final Color? stopIndicatorColor; + + /// The radius of the stop indicator. + /// + /// If [year2023] is true or [ThemeData.useMaterial3] is false, then no stop + /// indicator will be drawn. + /// + /// Set [stopIndicatorRadius] to 0 to hide the stop indicator. + /// + /// If null, then the [ProgressIndicatorThemeData.stopIndicatorRadius] will be used. + /// If that is null, then defaults to 2. + final double? stopIndicatorRadius; + + /// The gap between the indicator and the track. + /// + /// If [year2023] is true or [ThemeData.useMaterial3] is false, then no track + /// gap will be drawn. + /// + /// Set [trackGap] to 0 to hide the track gap. + /// + /// If null, then the [ProgressIndicatorThemeData.trackGap] will be used. + /// If that is null, then defaults to 4. + final double? trackGap; + + /// When true, the [LinearProgressIndicator] will use the 2023 Material Design 3 + /// appearance. + /// + /// If null, then the [ProgressIndicatorThemeData.year2023] will be used. + /// If that is null, then defaults to true. + /// + /// If this is set to false, the [LinearProgressIndicator] will use the + /// latest Material Design 3 appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + final bool? year2023; + + /// {@template flutter.material.ProgressIndicator.controller} + /// An optional [AnimationController] that controls the animation of this + /// indeterminate progress indicator. + /// + /// This controller is only used when the indicator is indeterminate (i.e., + /// when [value] is null). If this property is non-null, [value] must be null. + /// + /// The controller's value is expected to be a linear progression from 0.0 to + /// 1.0, which represents one full cycle of the indeterminate animation. + /// + /// If this controller is null (and [value] is also null), the widget will + /// look for a [ProgressIndicatorThemeData.controller]. If that is also null, + /// the widget will create and manage its own internal [AnimationController] + /// to drive the default indeterminate animation. + /// {@endtemplate} + /// + /// See also: + /// + /// * [LinearProgressIndicator.defaultAnimationDuration], default duration + /// for one full cycle of the indeterminate animation. + final AnimationController? controller; + + /// The default duration for one full cycle of the indeterminate animation. + /// + /// This duration is used when the widget creates its own [AnimationController] + /// because no [controller] was provided, either directly or through a + /// [ProgressIndicatorTheme]. + static const Duration defaultAnimationDuration = Duration( + milliseconds: _kIndeterminateLinearDuration, + ); + + @override + State<LinearProgressIndicator> createState() => _LinearProgressIndicatorState(); +} + +class _LinearProgressIndicatorState extends State<LinearProgressIndicator> + with SingleTickerProviderStateMixin { + late final AnimationController _internalController; + + @override + void initState() { + super.initState(); + _internalController = AnimationController( + duration: LinearProgressIndicator.defaultAnimationDuration, + vsync: this, + ); + _updateControllerAnimatingStatus(); + } + + @override + void didUpdateWidget(LinearProgressIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + _updateControllerAnimatingStatus(); + } + + @override + void dispose() { + _internalController.dispose(); + super.dispose(); + } + + AnimationController get _controller => + widget.controller ?? + context.getInheritedWidgetOfExactType<ProgressIndicatorTheme>()?.data.controller ?? + context.findAncestorWidgetOfExactType<Theme>()?.data.progressIndicatorTheme.controller ?? + _internalController; + + void _updateControllerAnimatingStatus() { + if (widget._effectiveValue == null && !_internalController.isAnimating) { + _internalController.repeat(); + } else if (widget._effectiveValue != null && _internalController.isAnimating) { + _internalController.stop(); + } + } + + Widget _buildIndicator(BuildContext context, double animationValue, TextDirection textDirection) { + final ProgressIndicatorThemeData indicatorTheme = ProgressIndicatorTheme.of(context); + final bool year2023 = widget.year2023 ?? indicatorTheme.year2023 ?? true; + final ProgressIndicatorThemeData defaults = switch (Theme.of(context).useMaterial3) { + true => + year2023 + ? _LinearProgressIndicatorDefaultsM3Year2023(context) + : _LinearProgressIndicatorDefaultsM3(context), + false => _LinearProgressIndicatorDefaultsM2(context), + }; + final Color trackColor = + widget.backgroundColor ?? indicatorTheme.linearTrackColor ?? defaults.linearTrackColor!; + final double minHeight = + widget.minHeight ?? indicatorTheme.linearMinHeight ?? defaults.linearMinHeight!; + final BorderRadiusGeometry? borderRadius = + widget.borderRadius ?? indicatorTheme.borderRadius ?? defaults.borderRadius; + final Color? stopIndicatorColor = !year2023 + ? widget.stopIndicatorColor ?? + indicatorTheme.stopIndicatorColor ?? + defaults.stopIndicatorColor + : null; + final double? stopIndicatorRadius = !year2023 + ? widget.stopIndicatorRadius ?? + indicatorTheme.stopIndicatorRadius ?? + defaults.stopIndicatorRadius + : null; + final double? trackGap = !year2023 + ? widget.trackGap ?? indicatorTheme.trackGap ?? defaults.trackGap + : null; + + Widget result = ConstrainedBox( + constraints: BoxConstraints(minWidth: double.infinity, minHeight: minHeight), + child: CustomPaint( + painter: _LinearProgressIndicatorPainter( + trackColor: trackColor, + valueColor: widget._getValueColor(context, defaultColor: defaults.color), + value: widget._effectiveValue, // may be null + animationValue: animationValue, // ignored if widget._effectiveValue is not null + textDirection: textDirection, + indicatorBorderRadius: borderRadius, + stopIndicatorColor: stopIndicatorColor, + stopIndicatorRadius: stopIndicatorRadius, + trackGap: trackGap, + ), + ), + ); + + // Clip is only needed with indeterminate progress indicators + if (borderRadius != null && widget._effectiveValue == null) { + result = ClipRRect(borderRadius: borderRadius, child: result); + } + + return widget._buildSemanticsWrapper(context: context, child: result); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + + if (widget._effectiveValue != null) { + return _buildIndicator(context, _controller.value, textDirection); + } + + return AnimatedBuilder( + animation: _controller.view, + builder: (BuildContext context, Widget? child) { + return _buildIndicator(context, _controller.value, textDirection); + }, + ); + } +} + +class _CircularProgressIndicatorPainter extends CustomPainter { + _CircularProgressIndicatorPainter({ + this.trackColor, + required this.valueColor, + required this.value, + required this.headValue, + required this.tailValue, + required this.offsetValue, + required this.rotationValue, + required this.strokeWidth, + required this.strokeAlign, + this.strokeCap, + this.trackGap, + this.year2023 = true, + }) : arcStart = value != null + ? _startAngle + : _startAngle + + tailValue * 3 / 2 * math.pi + + rotationValue * math.pi * 2.0 + + offsetValue * 0.5 * math.pi, + arcSweep = value != null + ? clampDouble(value, 0.0, 1.0) * _sweep + : math.max(headValue * 3 / 2 * math.pi - tailValue * 3 / 2 * math.pi, _epsilon); + + final Color? trackColor; + final Color valueColor; + final double? value; + final double headValue; + final double tailValue; + final double offsetValue; + final double rotationValue; + final double strokeWidth; + final double strokeAlign; + final double arcStart; + final double arcSweep; + final StrokeCap? strokeCap; + final double? trackGap; + final bool year2023; + + static const double _twoPi = math.pi * 2.0; + static const double _epsilon = .001; + // Canvas.drawArc(r, 0, 2*PI) doesn't draw anything, so just get close. + static const double _sweep = _twoPi - _epsilon; + static const double _startAngle = -math.pi / 2.0; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = valueColor + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + // Use the negative operator as intended to keep the exposed constant value + // as users are already familiar with. + final double strokeOffset = strokeWidth / 2 * -strokeAlign; + final arcBaseOffset = Offset(strokeOffset, strokeOffset); + final arcActualSize = Size(size.width - strokeOffset * 2, size.height - strokeOffset * 2); + final bool hasGap = trackGap != null && trackGap! > 0; + + if (trackColor != null) { + final backgroundPaint = Paint() + ..color = trackColor! + ..strokeWidth = strokeWidth + ..strokeCap = strokeCap ?? StrokeCap.round + ..style = PaintingStyle.stroke; + // If hasGap is true, draw the background arc with a gap. + if (hasGap && value != null && value! > _epsilon) { + final double arcRadius = arcActualSize.shortestSide / 2; + final double strokeRadius = strokeWidth / arcRadius; + final double gapRadius = trackGap! / arcRadius; + final double startGap = strokeRadius + gapRadius; + final double endGap = value! < _epsilon ? startGap : startGap * 2; + final double startSweep = (-math.pi / 2.0) + startGap; + final double endSweep = math.max( + 0.0, + _twoPi - clampDouble(value!, 0.0, 1.0) * _twoPi - endGap, + ); + // Flip the canvas for the background arc. + canvas.save(); + canvas.scale(-1, 1); + canvas.translate(-size.width, 0); + canvas.drawArc(arcBaseOffset & arcActualSize, startSweep, endSweep, false, backgroundPaint); + // Restore the canvas to draw the foreground arc. + canvas.restore(); + } else { + canvas.drawArc(arcBaseOffset & arcActualSize, 0, _sweep, false, backgroundPaint); + } + } + + if (year2023) { + if (value == null && strokeCap == null) { + // Indeterminate + paint.strokeCap = StrokeCap.square; + } else { + // Butt when determinate (value != null) && strokeCap == null; + paint.strokeCap = strokeCap ?? StrokeCap.butt; + } + } else { + paint.strokeCap = strokeCap ?? StrokeCap.round; + } + + canvas.drawArc(arcBaseOffset & arcActualSize, arcStart, arcSweep, false, paint); + } + + @override + bool shouldRepaint(_CircularProgressIndicatorPainter oldPainter) { + return oldPainter.trackColor != trackColor || + oldPainter.valueColor != valueColor || + oldPainter.value != value || + oldPainter.headValue != headValue || + oldPainter.tailValue != tailValue || + oldPainter.offsetValue != offsetValue || + oldPainter.rotationValue != rotationValue || + oldPainter.strokeWidth != strokeWidth || + oldPainter.strokeAlign != strokeAlign || + oldPainter.strokeCap != strokeCap || + oldPainter.trackGap != trackGap || + oldPainter.year2023 != year2023; + } +} + +/// A Material Design circular progress indicator, which spins to indicate that +/// the application is busy. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=O-rhXZLtpv0} +/// +/// A widget that shows progress along a circle. There are two kinds of circular +/// progress indicators: +/// +/// * _Determinate_. Determinate progress indicators have a specific value at +/// each point in time, and the value should increase monotonically from 0.0 +/// to 1.0, at which time the indicator is complete. To create a determinate +/// progress indicator, use a non-null [value] between 0.0 and 1.0. +/// * _Indeterminate_. Indeterminate progress indicators do not have a specific +/// value at each point in time and instead indicate that progress is being +/// made without indicating how much progress remains. To create an +/// indeterminate progress indicator, use a null [value]. +/// +/// The indicator arc is displayed with [valueColor], an animated value. To +/// specify a constant color use: `AlwaysStoppedAnimation<Color>(color)`. +/// +/// {@tool dartpad} +/// This example showcases determinate and indeterminate [CircularProgressIndicator]s. +/// The [CircularProgressIndicator]s will use the ![updated Material 3 Design appearance](https://m3.material.io/components/progress-indicators/overview) +/// when setting the [CircularProgressIndicator.year2023] flag to false. +/// +/// ** See code in examples/api/lib/material/progress_indicator/circular_progress_indicator.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a [CircularProgressIndicator] with a changing value. +/// When toggling the switch, [CircularProgressIndicator] uses a determinate value. +/// As described in: https://m3.material.io/components/progress-indicators/overview +/// +/// ** See code in examples/api/lib/material/progress_indicator/circular_progress_indicator.1.dart ** +/// {@end-tool} +/// +/// {@template flutter.material.ProgressIndicator.AnimationSynchronization} +/// ## Animation synchronization +/// +/// When multiple [CircularProgressIndicator]s or [LinearProgressIndicator]s are +/// animating on screen simultaneously (e.g., in a list of loading items), their +/// uncoordinated animations can appear visually cluttered. To address this, the +/// animation of an indicator can be driven by a custom [AnimationController]. +/// +/// This allows multiple indicators to be synchronized to a single animation +/// source. The most convenient way to achieve this for a group of indicators is +/// by providing a controller via [ProgressIndicatorTheme] (see +/// [ProgressIndicatorThemeData.controller]). All [CircularProgressIndicator]s +/// or [LinearProgressIndicator]s within that theme's subtree will then share +/// the same animation, resulting in a more coordinated and visually pleasing +/// effect. +/// +/// Alternatively, a specific [AnimationController] can be passed directly to the +/// [controller] property of an individual indicator. +/// {@endtemplate} +/// +/// {@tool dartpad} +/// This sample demonstrates how to synchronize the indeterminate animations +/// of multiple [CircularProgressIndicator]s using a [Theme]. +/// +/// Tapping the buttons adds or removes indicators. By default, they all +/// share a [ProgressIndicatorThemeData.controller], which keeps their +/// animations in sync. +/// +/// Tapping the "Toggle" button sets the theme's controller to null. +/// This forces each indicator to create its own internal controller, +/// causing their animations to become desynchronized. +/// +/// ** See code in examples/api/lib/material/progress_indicator/circular_progress_indicator.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [LinearProgressIndicator], which displays progress along a line. +/// * [RefreshIndicator], which automatically displays a [CircularProgressIndicator] +/// when the underlying vertical scrollable is overscrolled. +/// * <https://material.io/design/components/progress-indicators.html#circular-progress-indicators> +class CircularProgressIndicator extends ProgressIndicator { + /// Creates a circular progress indicator. + /// + /// {@macro flutter.material.ProgressIndicator.ProgressIndicator} + const CircularProgressIndicator({ + super.key, + super.value, + super.backgroundColor, + super.color, + super.valueColor, + this.strokeWidth, + this.strokeAlign, + super.semanticsLabel, + super.semanticsValue, + this.strokeCap, + this.constraints, + this.trackGap, + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + this.year2023, + this.padding, + this.controller, + }) : assert(value == null || controller == null, _kValueControllerAssertion), + _indicatorType = _ActivityIndicatorType.material; + + /// Creates an adaptive progress indicator that is a + /// [CupertinoActivityIndicator] on [TargetPlatform.iOS] & + /// [TargetPlatform.macOS] and a [CircularProgressIndicator] in material + /// theme/non-Apple platforms. + /// + /// The [valueColor], [strokeWidth], [strokeAlign], [strokeCap], + /// [semanticsLabel], [semanticsValue], [trackGap], [year2023] will be + /// ignored on iOS & macOS. + /// + /// {@macro flutter.material.ProgressIndicator.ProgressIndicator} + const CircularProgressIndicator.adaptive({ + super.key, + super.value, + super.backgroundColor, + super.valueColor, + this.strokeWidth, + super.semanticsLabel, + super.semanticsValue, + this.strokeCap, + this.strokeAlign, + this.constraints, + this.trackGap, + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.year2023, + this.padding, + this.controller, + }) : assert(value == null || controller == null, _kValueControllerAssertion), + _indicatorType = _ActivityIndicatorType.adaptive; + + final _ActivityIndicatorType _indicatorType; + + /// {@template flutter.material.CircularProgressIndicator.trackColor} + /// Color of the circular track being filled by the circular indicator. + /// + /// If [CircularProgressIndicator.backgroundColor] is null then the + /// ambient [ProgressIndicatorThemeData.circularTrackColor] will be used. + /// If that is null, then the track will not be painted. + /// {@endtemplate} + @override + Color? get backgroundColor => super.backgroundColor; + + /// The width of the line used to draw the circle. + final double? strokeWidth; + + /// The relative position of the stroke on a [CircularProgressIndicator]. + /// + /// Values typically range from -1.0 ([strokeAlignInside], inside stroke) + /// to 1.0 ([strokeAlignOutside], outside stroke), + /// without any bound constraints (e.g., a value of -2.0 is not typical, but allowed). + /// A value of 0 ([strokeAlignCenter]) will center the border + /// on the edge of the widget. + /// + /// If [year2023] is true, then the default value is [strokeAlignCenter]. + /// Otherwise, the default value is [strokeAlignInside]. + final double? strokeAlign; + + /// The progress indicator's line ending. + /// + /// This determines the shape of the stroke ends of the progress indicator. + /// By default, [strokeCap] is null. + /// When [value] is null (indeterminate), the stroke ends are set to + /// [StrokeCap.square]. When [value] is not null, the stroke + /// ends are set to [StrokeCap.butt]. + /// + /// Setting [strokeCap] to [StrokeCap.round] will result in a rounded end. + /// Setting [strokeCap] to [StrokeCap.butt] with [value] == null will result + /// in a slightly different indeterminate animation; the indicator completely + /// disappears and reappears on its minimum value. + /// Setting [strokeCap] to [StrokeCap.square] with [value] != null will + /// result in a different display of [value]. The indicator will start + /// drawing from slightly less than the start, and end slightly after + /// the end. This will produce an alternative result, as the + /// default behavior, for example, that a [value] of 0.5 starts at 90 degrees + /// and ends at 270 degrees. With [StrokeCap.square], it could start 85 + /// degrees and end at 275 degrees. + final StrokeCap? strokeCap; + + /// Defines minimum and maximum sizes for a [CircularProgressIndicator]. + /// + /// If null, then the [ProgressIndicatorThemeData.constraints] will be used. + /// Otherwise, defaults to a minimum width and height of 36 pixels. + final BoxConstraints? constraints; + + /// The gap between the active indicator and the background track. + /// + /// If [year2023] is true or [ThemeData.useMaterial3] is false, then no track + /// gap will be drawn. + /// + /// Set [trackGap] to 0 to hide the track gap. + /// + /// If null, then the [ProgressIndicatorThemeData.trackGap] will be used. + /// If that is null, then defaults to 4. + final double? trackGap; + + /// When true, the [CircularProgressIndicator] will use the 2023 Material Design 3 + /// appearance. + /// + /// If null, then the [ProgressIndicatorThemeData.year2023] will be used. + /// If that is null, then defaults to true. + /// + /// If this is set to false, the [CircularProgressIndicator] will use the + /// latest Material Design 3 appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool? year2023; + + /// The padding around the indicator track. + /// + /// If null, then the [ProgressIndicatorThemeData.circularTrackPadding] will be + /// used. If that is null and [year2023] is false, then defaults to `EdgeInsets.all(4.0)` + /// padding. Otherwise, defaults to zero padding. + final EdgeInsetsGeometry? padding; + + /// {@macro flutter.material.ProgressIndicator.controller} + /// + /// See also: + /// + /// * [CircularProgressIndicator.defaultAnimationDuration], default duration + /// for one full cycle of the indeterminate animation. + final AnimationController? controller; + + /// The indicator stroke is drawn fully inside of the indicator path. + /// + /// This is a constant for use with [strokeAlign]. + static const double strokeAlignInside = -1.0; + + /// The indicator stroke is drawn on the center of the indicator path, + /// with half of the [strokeWidth] on the inside, and the other half + /// on the outside of the path. + /// + /// This is a constant for use with [strokeAlign]. + /// + /// This is the default value for [strokeAlign]. + static const double strokeAlignCenter = 0.0; + + /// The indicator stroke is drawn on the outside of the indicator path. + /// + /// This is a constant for use with [strokeAlign]. + static const double strokeAlignOutside = 1.0; + + /// The default duration for one full cycle of the indeterminate animation. + /// + /// During this period, the indicator completes several full rotations. + /// + /// This duration is used when the widget creates its own [AnimationController] + /// because no [controller] was provided, either directly or through a + /// [ProgressIndicatorTheme]. + static const Duration defaultAnimationDuration = Duration( + milliseconds: _kIndeterminateCircularDuration, + ); + + @override + State<CircularProgressIndicator> createState() => _CircularProgressIndicatorState(); +} + +class _CircularProgressIndicatorState extends State<CircularProgressIndicator> + with SingleTickerProviderStateMixin { + static const int _pathCount = _kIndeterminateCircularDuration ~/ 1333; + static const int _rotationCount = _kIndeterminateCircularDuration ~/ 2222; + + static final Animatable<double> _strokeHeadTween = CurveTween( + curve: const Interval(0.0, 0.5, curve: Curves.fastOutSlowIn), + ).chain(CurveTween(curve: const SawTooth(_pathCount))); + static final Animatable<double> _strokeTailTween = CurveTween( + curve: const Interval(0.5, 1.0, curve: Curves.fastOutSlowIn), + ).chain(CurveTween(curve: const SawTooth(_pathCount))); + static final Animatable<double> _offsetTween = CurveTween(curve: const SawTooth(_pathCount)); + static final Animatable<double> _rotationTween = CurveTween( + curve: const SawTooth(_rotationCount), + ); + + late final AnimationController _internalController; + + @override + void initState() { + super.initState(); + _internalController = AnimationController( + duration: CircularProgressIndicator.defaultAnimationDuration, + vsync: this, + ); + _updateControllerAnimatingStatus(); + } + + @override + void didUpdateWidget(CircularProgressIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + _updateControllerAnimatingStatus(); + } + + @override + void dispose() { + _internalController.dispose(); + super.dispose(); + } + + AnimationController get _controller => + widget.controller ?? + context.getInheritedWidgetOfExactType<ProgressIndicatorTheme>()?.data.controller ?? + context.findAncestorWidgetOfExactType<Theme>()?.data.progressIndicatorTheme.controller ?? + _internalController; + + void _updateControllerAnimatingStatus() { + if (widget._effectiveValue == null && !_internalController.isAnimating) { + _internalController.repeat(); + } else if (widget._effectiveValue != null && _internalController.isAnimating) { + _internalController.stop(); + } + } + + Widget _buildCupertinoIndicator(BuildContext context) { + final Color? tickColor = widget.backgroundColor; + final double? value = widget._effectiveValue; + if (value == null) { + return CupertinoActivityIndicator(key: widget.key, color: tickColor); + } + return CupertinoActivityIndicator.partiallyRevealed( + key: widget.key, + color: tickColor, + progress: value, + ); + } + + Widget _buildMaterialIndicator( + BuildContext context, + double headValue, + double tailValue, + double offsetValue, + double rotationValue, + ) { + final ProgressIndicatorThemeData indicatorTheme = ProgressIndicatorTheme.of(context); + final bool year2023 = widget.year2023 ?? indicatorTheme.year2023 ?? true; + final ProgressIndicatorThemeData defaults = switch (Theme.of(context).useMaterial3) { + true => + year2023 + ? _CircularProgressIndicatorDefaultsM3Year2023( + context, + indeterminate: widget._effectiveValue == null, + ) + : _CircularProgressIndicatorDefaultsM3( + context, + indeterminate: widget._effectiveValue == null, + ), + false => _CircularProgressIndicatorDefaultsM2( + context, + indeterminate: widget._effectiveValue == null, + ), + }; + final Color? trackColor = + widget.backgroundColor ?? indicatorTheme.circularTrackColor ?? defaults.circularTrackColor; + final double strokeWidth = + widget.strokeWidth ?? indicatorTheme.strokeWidth ?? defaults.strokeWidth!; + final double strokeAlign = + widget.strokeAlign ?? indicatorTheme.strokeAlign ?? defaults.strokeAlign!; + final StrokeCap? strokeCap = widget.strokeCap ?? indicatorTheme.strokeCap; + final BoxConstraints constraints = + widget.constraints ?? indicatorTheme.constraints ?? defaults.constraints!; + final double? trackGap = year2023 + ? null + : widget.trackGap ?? indicatorTheme.trackGap ?? defaults.trackGap; + final EdgeInsetsGeometry? effectivePadding = + widget.padding ?? indicatorTheme.circularTrackPadding ?? defaults.circularTrackPadding; + + Widget result = ConstrainedBox( + constraints: constraints, + child: CustomPaint( + painter: _CircularProgressIndicatorPainter( + trackColor: trackColor, + valueColor: widget._getValueColor(context, defaultColor: defaults.color), + value: widget._effectiveValue, // may be null + headValue: + headValue, // remaining arguments are ignored if widget._effectiveValue is not null + tailValue: tailValue, + offsetValue: offsetValue, + rotationValue: rotationValue, + strokeWidth: strokeWidth, + strokeAlign: strokeAlign, + strokeCap: strokeCap, + trackGap: trackGap, + year2023: year2023, + ), + ), + ); + + if (effectivePadding != null) { + result = Padding(padding: effectivePadding, child: result); + } + + return widget._buildSemanticsWrapper(context: context, child: result); + } + + Widget _buildAnimation() { + return AnimatedBuilder( + animation: _controller, + builder: (BuildContext context, Widget? child) { + return _buildMaterialIndicator( + context, + _strokeHeadTween.evaluate(_controller), + _strokeTailTween.evaluate(_controller), + _offsetTween.evaluate(_controller), + _rotationTween.evaluate(_controller), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Builder( + builder: (BuildContext context) { + switch (widget._indicatorType) { + case _ActivityIndicatorType.material: + if (widget._effectiveValue != null) { + return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0); + } + return _buildAnimation(); + case _ActivityIndicatorType.adaptive: + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _buildCupertinoIndicator(context); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (widget._effectiveValue != null) { + return _buildMaterialIndicator(context, 0.0, 0.0, 0, 0.0); + } + return _buildAnimation(); + } + } + }, + ); + } +} + +class _RefreshProgressIndicatorPainter extends _CircularProgressIndicatorPainter { + _RefreshProgressIndicatorPainter({ + required super.valueColor, + required super.value, + required super.headValue, + required super.tailValue, + required super.offsetValue, + required super.rotationValue, + required super.strokeWidth, + required super.strokeAlign, + required this.arrowheadScale, + required super.strokeCap, + }); + + final double arrowheadScale; + + void paintArrowhead(Canvas canvas, Size size) { + // ux, uy: a unit vector whose direction parallels the base of the arrowhead. + // (So ux, -uy points in the direction the arrowhead points.) + final double arcEnd = arcStart + arcSweep; + final double ux = math.cos(arcEnd); + final double uy = math.sin(arcEnd); + + assert(size.width == size.height); + final double radius = size.width / 2.0; + final double arrowheadPointX = radius + ux * radius + -uy * strokeWidth * 2.0 * arrowheadScale; + final double arrowheadPointY = radius + uy * radius + ux * strokeWidth * 2.0 * arrowheadScale; + final double arrowheadRadius = strokeWidth * 2.0 * arrowheadScale; + final double innerRadius = radius - arrowheadRadius; + final double outerRadius = radius + arrowheadRadius; + + final path = Path() + ..moveTo(radius + ux * innerRadius, radius + uy * innerRadius) + ..lineTo(radius + ux * outerRadius, radius + uy * outerRadius) + ..lineTo(arrowheadPointX, arrowheadPointY) + ..close(); + + final paint = Paint() + ..color = valueColor + ..strokeWidth = strokeWidth + ..style = PaintingStyle.fill; + canvas.drawPath(path, paint); + } + + @override + void paint(Canvas canvas, Size size) { + super.paint(canvas, size); + if (arrowheadScale > 0.0) { + paintArrowhead(canvas, size); + } + } +} + +/// An indicator for the progress of refreshing the contents of a widget. +/// +/// Typically used for swipe-to-refresh interactions. See [RefreshIndicator] for +/// a complete implementation of swipe-to-refresh driven by a [Scrollable] +/// widget. +/// +/// The indicator arc is displayed with [valueColor], an animated value. To +/// specify a constant color use: `AlwaysStoppedAnimation<Color>(color)`. +/// +/// See also: +/// +/// * [RefreshIndicator], which automatically displays a [CircularProgressIndicator] +/// when the underlying vertical scrollable is overscrolled. +class RefreshProgressIndicator extends CircularProgressIndicator { + /// Creates a refresh progress indicator. + /// + /// Rather than creating a refresh progress indicator directly, consider using + /// a [RefreshIndicator] together with a [Scrollable] widget. + /// + /// {@macro flutter.material.ProgressIndicator.ProgressIndicator} + const RefreshProgressIndicator({ + super.key, + super.value, + super.backgroundColor, + super.color, + super.valueColor, + super.strokeWidth = defaultStrokeWidth, // Different default than CircularProgressIndicator. + super.strokeAlign, + super.semanticsLabel, + super.semanticsValue, + super.strokeCap, + this.elevation = 2.0, + this.indicatorMargin = const EdgeInsets.all(4.0), + this.indicatorPadding = const EdgeInsets.all(12.0), + }); + + /// {@macro flutter.material.material.elevation} + final double elevation; + + /// The amount of space by which to inset the whole indicator. + /// It accommodates the [elevation] of the indicator. + final EdgeInsetsGeometry indicatorMargin; + + /// The amount of space by which to inset the inner refresh indicator. + final EdgeInsetsGeometry indicatorPadding; + + /// Default stroke width. + static const double defaultStrokeWidth = 2.5; + + /// {@template flutter.material.RefreshProgressIndicator.backgroundColor} + /// Background color of that fills the circle under the refresh indicator. + /// + /// If [RefreshIndicator.backgroundColor] is null then the + /// ambient [ProgressIndicatorThemeData.refreshBackgroundColor] will be used. + /// If that is null, then the ambient theme's [ThemeData.canvasColor] + /// will be used. + /// {@endtemplate} + @override + Color? get backgroundColor => super.backgroundColor; + + @override + State<CircularProgressIndicator> createState() => _RefreshProgressIndicatorState(); +} + +class _RefreshProgressIndicatorState extends _CircularProgressIndicatorState { + static const double _indicatorSize = 41.0; + + /// Interval for arrow head to fully grow. + static const double _strokeHeadInterval = 0.33; + + late final Animatable<double> _convertTween = CurveTween( + curve: const Interval(0.1, _strokeHeadInterval), + ); + + late final Animatable<double> _additionalRotationTween = TweenSequence<double>( + <TweenSequenceItem<double>>[ + // Makes arrow to expand a little bit earlier, to match the Android look. + TweenSequenceItem<double>( + tween: Tween<double>(begin: -0.1, end: -0.2), + weight: _strokeHeadInterval, + ), + // Additional rotation after the arrow expanded + TweenSequenceItem<double>( + tween: Tween<double>(begin: -0.2, end: 1.35), + weight: 1 - _strokeHeadInterval, + ), + ], + ); + + // Last value received from the widget before null. + double? _lastValue; + + /// Force casting the widget as [RefreshProgressIndicator]. + @override + RefreshProgressIndicator get widget => super.widget as RefreshProgressIndicator; + + // Always show the indeterminate version of the circular progress indicator. + // + // When value is non-null the sweep of the progress indicator arrow's arc + // varies from 0 to about 300 degrees. + // + // When value is null the arrow animation starting from wherever we left it. + @override + Widget build(BuildContext context) { + final double? value = widget._effectiveValue; + if (value != null) { + _lastValue = value; + _controller.value = + _convertTween.transform(value) * (1333 / 2 / _kIndeterminateCircularDuration); + } + return _buildAnimation(); + } + + @override + Widget _buildAnimation() { + return AnimatedBuilder( + animation: _controller, + builder: (BuildContext context, Widget? child) { + return _buildMaterialIndicator( + context, + // Lengthen the arc a little + 1.05 * _CircularProgressIndicatorState._strokeHeadTween.transform(_controller.value), + _CircularProgressIndicatorState._strokeTailTween.transform(_controller.value), + _CircularProgressIndicatorState._offsetTween.transform(_controller.value), + _CircularProgressIndicatorState._rotationTween.transform(_controller.value), + ); + }, + ); + } + + @override + Widget _buildMaterialIndicator( + BuildContext context, + double headValue, + double tailValue, + double offsetValue, + double rotationValue, + ) { + final double? value = widget._effectiveValue; + final double arrowheadScale = value == null + ? 0.0 + : const Interval(0.1, _strokeHeadInterval).transform(value); + final double rotation; + + if (value == null && _lastValue == null) { + rotation = 0.0; + } else { + rotation = math.pi * _additionalRotationTween.transform(value ?? _lastValue!); + } + + Color valueColor = widget._getValueColor(context); + final double opacity = valueColor.opacity; + valueColor = valueColor.withOpacity(1.0); + + final ProgressIndicatorThemeData defaults = switch (Theme.of(context).useMaterial3) { + true => _CircularProgressIndicatorDefaultsM3Year2023(context, indeterminate: value == null), + false => _CircularProgressIndicatorDefaultsM2(context, indeterminate: value == null), + }; + final ProgressIndicatorThemeData indicatorTheme = ProgressIndicatorTheme.of(context); + final Color backgroundColor = + widget.backgroundColor ?? + indicatorTheme.refreshBackgroundColor ?? + Theme.of(context).canvasColor; + final double strokeWidth = + widget.strokeWidth ?? indicatorTheme.strokeWidth ?? defaults.strokeWidth!; + final double strokeAlign = + widget.strokeAlign ?? indicatorTheme.strokeAlign ?? defaults.strokeAlign!; + final StrokeCap? strokeCap = widget.strokeCap ?? indicatorTheme.strokeCap; + + return widget._buildSemanticsWrapper( + context: context, + child: Padding( + padding: widget.indicatorMargin, + child: SizedBox.fromSize( + size: const Size.square(_indicatorSize), + child: Material( + type: MaterialType.circle, + color: backgroundColor, + elevation: widget.elevation, + child: Padding( + padding: widget.indicatorPadding, + child: Opacity( + opacity: opacity, + child: Transform.rotate( + angle: rotation, + child: CustomPaint( + painter: _RefreshProgressIndicatorPainter( + valueColor: valueColor, + value: null, // Draw the indeterminate progress indicator. + headValue: headValue, + tailValue: tailValue, + offsetValue: offsetValue, + rotationValue: rotationValue, + strokeWidth: strokeWidth, + strokeAlign: strokeAlign, + arrowheadScale: arrowheadScale, + strokeCap: strokeCap, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// Hand coded defaults based on Material Design 2. +class _CircularProgressIndicatorDefaultsM2 extends ProgressIndicatorThemeData { + _CircularProgressIndicatorDefaultsM2(this.context, {required this.indeterminate}); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + final bool indeterminate; + + @override + Color get color => _colors.primary; + + @override + double? get strokeWidth => 4.0; + + @override + double? get strokeAlign => CircularProgressIndicator.strokeAlignCenter; + + @override + BoxConstraints get constraints => const BoxConstraints(minWidth: 36.0, minHeight: 36.0); +} + +class _LinearProgressIndicatorDefaultsM2 extends ProgressIndicatorThemeData { + _LinearProgressIndicatorDefaultsM2(this.context); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color get color => _colors.primary; + + @override + Color get linearTrackColor => _colors.background; + + @override + double get linearMinHeight => 4.0; +} + +class _CircularProgressIndicatorDefaultsM3Year2023 extends ProgressIndicatorThemeData { + _CircularProgressIndicatorDefaultsM3Year2023(this.context, {required this.indeterminate}); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + final bool indeterminate; + + @override + Color get color => _colors.primary; + + @override + double get strokeWidth => 4.0; + + @override + double? get strokeAlign => CircularProgressIndicator.strokeAlignCenter; + + @override + BoxConstraints get constraints => const BoxConstraints(minWidth: 36.0, minHeight: 36.0); +} + +class _LinearProgressIndicatorDefaultsM3Year2023 extends ProgressIndicatorThemeData { + _LinearProgressIndicatorDefaultsM3Year2023(this.context); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color get color => _colors.primary; + + @override + Color get linearTrackColor => _colors.secondaryContainer; + + @override + double get linearMinHeight => 4.0; +} + +// BEGIN GENERATED TOKEN PROPERTIES - ProgressIndicator + +// 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. + +// dart format off +class _CircularProgressIndicatorDefaultsM3 extends ProgressIndicatorThemeData { + _CircularProgressIndicatorDefaultsM3(this.context, { required this.indeterminate }); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + final bool indeterminate; + + @override + Color get color => _colors.primary; + + @override + Color? get circularTrackColor => indeterminate ? null : _colors.secondaryContainer; + + @override + double get strokeWidth => 4.0; + + @override + double? get strokeAlign => CircularProgressIndicator.strokeAlignInside; + + @override + BoxConstraints get constraints => const BoxConstraints( + minWidth: 40.0, + minHeight: 40.0, + ); + + @override + double? get trackGap => 4.0; + + @override + EdgeInsetsGeometry? get circularTrackPadding => const EdgeInsets.all(4.0); +} + +class _LinearProgressIndicatorDefaultsM3 extends ProgressIndicatorThemeData { + _LinearProgressIndicatorDefaultsM3(this.context); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color get color => _colors.primary; + + @override + Color get linearTrackColor => _colors.secondaryContainer; + + @override + double get linearMinHeight => 4.0; + + @override + BorderRadius get borderRadius => const BorderRadius.all(Radius.circular(4.0 / 2)); + + @override + Color get stopIndicatorColor => _colors.primary; + + @override + double? get stopIndicatorRadius => 4.0 / 2; + + @override + double? get trackGap => 4.0; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - ProgressIndicator diff --git a/packages/material_ui/lib/src/progress_indicator_theme.dart b/packages/material_ui/lib/src/progress_indicator_theme.dart new file mode 100644 index 000000000000..7c2a0cb59035 --- /dev/null +++ b/packages/material_ui/lib/src/progress_indicator_theme.dart @@ -0,0 +1,362 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'progress_indicator.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default values of visual properties for descendant +/// [ProgressIndicator] widgets. +/// +/// Descendant widgets obtain the current [ProgressIndicatorThemeData] object +/// with [ProgressIndicatorTheme.of]. Instances of [ProgressIndicatorThemeData] +/// can be customized with [ProgressIndicatorThemeData.copyWith]. +/// +/// Typically a [ProgressIndicatorThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.progressIndicatorTheme]. +/// +/// See also: +/// +/// * [ProgressIndicatorTheme], an [InheritedWidget] that propagates the +/// theme down its subtree. +/// * [ThemeData.progressIndicatorTheme], which describes the defaults for +/// any progress indicators as part of the application's [ThemeData]. +@immutable +class ProgressIndicatorThemeData with Diagnosticable { + /// Creates the set of properties used to configure [ProgressIndicator] widgets. + const ProgressIndicatorThemeData({ + this.color, + this.linearTrackColor, + this.linearMinHeight, + this.circularTrackColor, + this.refreshBackgroundColor, + this.borderRadius, + this.stopIndicatorColor, + this.stopIndicatorRadius, + this.strokeWidth, + this.strokeAlign, + this.strokeCap, + this.constraints, + this.trackGap, + this.circularTrackPadding, + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.year2023, + this.controller, + }); + + /// The color of the [ProgressIndicator]'s indicator. + /// + /// If null, then it will use [ColorScheme.primary] of the ambient + /// [ThemeData.colorScheme]. + /// + /// See also: + /// + /// * [ProgressIndicator.color], which specifies the indicator color for a + /// specific progress indicator. + /// * [ProgressIndicator.valueColor], which specifies the indicator color + /// a an animated color. + final Color? color; + + /// {@macro flutter.material.LinearProgressIndicator.trackColor} + final Color? linearTrackColor; + + /// {@macro flutter.material.LinearProgressIndicator.minHeight} + final double? linearMinHeight; + + /// {@macro flutter.material.CircularProgressIndicator.trackColor} + final Color? circularTrackColor; + + /// {@macro flutter.material.RefreshProgressIndicator.backgroundColor} + final Color? refreshBackgroundColor; + + /// Overrides the border radius of the [ProgressIndicator]. + final BorderRadiusGeometry? borderRadius; + + /// Overrides the stop indicator color of the [LinearProgressIndicator]. + /// + /// If [LinearProgressIndicator.year2023] is true or [ThemeData.useMaterial3] + /// is false, then no stop indicator will be drawn. + final Color? stopIndicatorColor; + + /// Overrides the stop indicator radius of the [LinearProgressIndicator]. + /// + /// If [LinearProgressIndicator.year2023] is true or [ThemeData.useMaterial3] + /// is false, then no stop indicator will be drawn. + final double? stopIndicatorRadius; + + /// Overrides the stroke width of the [CircularProgressIndicator]. + final double? strokeWidth; + + /// Overrides the stroke align of the [CircularProgressIndicator]. + final double? strokeAlign; + + /// Overrides the stroke cap of the [CircularProgressIndicator]. + final StrokeCap? strokeCap; + + /// Overrides the constraints of the [CircularProgressIndicator]. + final BoxConstraints? constraints; + + /// Overrides the active indicator and the background track. + /// + /// If [CircularProgressIndicator.year2023] is true or [ThemeData.useMaterial3] + /// is false, then no track gap will be drawn. + /// + /// If [LinearProgressIndicator.year2023] is true or [ThemeData.useMaterial3] + /// is false, then no track gap will be drawn. + final double? trackGap; + + /// Overrides the padding of the [CircularProgressIndicator]. + final EdgeInsetsGeometry? circularTrackPadding; + + /// Overrides the [CircularProgressIndicator.year2023] and + /// [LinearProgressIndicator.year2023] properties. + /// + /// When true, the [CircularProgressIndicator] and [LinearProgressIndicator] + /// will use the 2023 Material Design 3 appearance. Defaults to true. + /// + /// If this is set to false, the [CircularProgressIndicator] and + /// [LinearProgressIndicator] will use the latest Material Design 3 appearance, + /// which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 progress indicator appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use ProgressIndicatorThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool? year2023; + + /// Defines a default [AnimationController] for descendant + /// [CircularProgressIndicator] and [LinearProgressIndicator] widgets. + /// + /// If a descendant progress indicator's `controller` property is null, this + /// controller will be used to drive its indeterminate animation. This allows + /// a single controller to synchronize the animations of multiple indicators. + /// + /// If this property is also null, the progress indicator will create and + /// manage its own internal [AnimationController]. + final AnimationController? controller; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + ProgressIndicatorThemeData copyWith({ + Color? color, + Color? linearTrackColor, + double? linearMinHeight, + Color? circularTrackColor, + Color? refreshBackgroundColor, + BorderRadiusGeometry? borderRadius, + Color? stopIndicatorColor, + double? stopIndicatorRadius, + double? strokeWidth, + double? strokeAlign, + StrokeCap? strokeCap, + BoxConstraints? constraints, + double? trackGap, + EdgeInsetsGeometry? circularTrackPadding, + bool? year2023, + AnimationController? controller, + }) { + return ProgressIndicatorThemeData( + color: color ?? this.color, + linearTrackColor: linearTrackColor ?? this.linearTrackColor, + linearMinHeight: linearMinHeight ?? this.linearMinHeight, + circularTrackColor: circularTrackColor ?? this.circularTrackColor, + refreshBackgroundColor: refreshBackgroundColor ?? this.refreshBackgroundColor, + borderRadius: borderRadius ?? this.borderRadius, + stopIndicatorColor: stopIndicatorColor ?? this.stopIndicatorColor, + stopIndicatorRadius: stopIndicatorRadius ?? this.stopIndicatorRadius, + strokeWidth: strokeWidth ?? this.strokeWidth, + strokeAlign: strokeAlign ?? this.strokeAlign, + strokeCap: strokeCap ?? this.strokeCap, + constraints: constraints ?? this.constraints, + trackGap: trackGap ?? this.trackGap, + circularTrackPadding: circularTrackPadding ?? this.circularTrackPadding, + year2023: year2023 ?? this.year2023, + controller: controller ?? this.controller, + ); + } + + /// Linearly interpolate between two progress indicator themes. + /// + /// If both arguments are null, then null is returned. + static ProgressIndicatorThemeData? lerp( + ProgressIndicatorThemeData? a, + ProgressIndicatorThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return ProgressIndicatorThemeData( + color: Color.lerp(a?.color, b?.color, t), + linearTrackColor: Color.lerp(a?.linearTrackColor, b?.linearTrackColor, t), + linearMinHeight: lerpDouble(a?.linearMinHeight, b?.linearMinHeight, t), + circularTrackColor: Color.lerp(a?.circularTrackColor, b?.circularTrackColor, t), + refreshBackgroundColor: Color.lerp(a?.refreshBackgroundColor, b?.refreshBackgroundColor, t), + borderRadius: BorderRadiusGeometry.lerp(a?.borderRadius, b?.borderRadius, t), + stopIndicatorColor: Color.lerp(a?.stopIndicatorColor, b?.stopIndicatorColor, t), + stopIndicatorRadius: lerpDouble(a?.stopIndicatorRadius, b?.stopIndicatorRadius, t), + strokeWidth: lerpDouble(a?.strokeWidth, b?.strokeWidth, t), + strokeAlign: lerpDouble(a?.strokeAlign, b?.strokeAlign, t), + strokeCap: t < 0.5 ? a?.strokeCap : b?.strokeCap, + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + trackGap: lerpDouble(a?.trackGap, b?.trackGap, t), + circularTrackPadding: EdgeInsetsGeometry.lerp( + a?.circularTrackPadding, + b?.circularTrackPadding, + t, + ), + year2023: t < 0.5 ? a?.year2023 : b?.year2023, + controller: t < 0.5 ? a?.controller : b?.controller, + ); + } + + @override + int get hashCode => Object.hash( + color, + linearTrackColor, + linearMinHeight, + circularTrackColor, + refreshBackgroundColor, + borderRadius, + stopIndicatorColor, + stopIndicatorRadius, + strokeAlign, + strokeWidth, + strokeCap, + constraints, + trackGap, + circularTrackPadding, + year2023, + controller, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ProgressIndicatorThemeData && + other.color == color && + other.linearTrackColor == linearTrackColor && + other.linearMinHeight == linearMinHeight && + other.circularTrackColor == circularTrackColor && + other.refreshBackgroundColor == refreshBackgroundColor && + other.borderRadius == borderRadius && + other.stopIndicatorColor == stopIndicatorColor && + other.stopIndicatorRadius == stopIndicatorRadius && + other.strokeAlign == strokeAlign && + other.strokeWidth == strokeWidth && + other.strokeCap == strokeCap && + other.constraints == constraints && + other.trackGap == trackGap && + other.circularTrackPadding == circularTrackPadding && + other.year2023 == year2023 && + other.controller == controller; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('linearTrackColor', linearTrackColor, defaultValue: null)); + properties.add(DoubleProperty('linearMinHeight', linearMinHeight, defaultValue: null)); + properties.add(ColorProperty('circularTrackColor', circularTrackColor, defaultValue: null)); + properties.add( + ColorProperty('refreshBackgroundColor', refreshBackgroundColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<BorderRadiusGeometry>('borderRadius', borderRadius, defaultValue: null), + ); + properties.add(ColorProperty('stopIndicatorColor', stopIndicatorColor, defaultValue: null)); + properties.add(DoubleProperty('stopIndicatorRadius', stopIndicatorRadius, defaultValue: null)); + properties.add(DoubleProperty('strokeWidth', strokeWidth, defaultValue: null)); + properties.add(DoubleProperty('strokeAlign', strokeAlign, defaultValue: null)); + properties.add(DiagnosticsProperty<StrokeCap>('strokeCap', strokeCap, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + properties.add(DoubleProperty('trackGap', trackGap, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'circularTrackPadding', + circularTrackPadding, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<bool>('year2023', year2023, defaultValue: null)); + properties.add( + DiagnosticsProperty<AnimationController>('controller', controller, defaultValue: null), + ); + } +} + +/// An inherited widget that defines the configuration for +/// [ProgressIndicator]s in this widget's subtree. +/// +/// Values specified here are used for [ProgressIndicator] properties that are not +/// given an explicit non-null value. +/// +/// {@tool snippet} +/// +/// Here is an example of a progress indicator theme that applies a red indicator +/// color. +/// +/// ```dart +/// const ProgressIndicatorTheme( +/// data: ProgressIndicatorThemeData( +/// color: Colors.red, +/// ), +/// child: LinearProgressIndicator() +/// ) +/// ``` +/// {@end-tool} +class ProgressIndicatorTheme extends InheritedTheme { + /// Creates a theme that controls the configurations for [ProgressIndicator] + /// widgets. + const ProgressIndicatorTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant [ProgressIndicator] widgets. + final ProgressIndicatorThemeData data; + + /// Returns the [data] from the closest [ProgressIndicatorTheme] ancestor. If + /// there is no ancestor, it returns [ThemeData.progressIndicatorTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ProgressIndicatorThemeData theme = ProgressIndicatorTheme.of(context); + /// ``` + static ProgressIndicatorThemeData of(BuildContext context) { + final ProgressIndicatorTheme? progressIndicatorTheme = context + .dependOnInheritedWidgetOfExactType<ProgressIndicatorTheme>(); + return progressIndicatorTheme?.data ?? Theme.of(context).progressIndicatorTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ProgressIndicatorTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ProgressIndicatorTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/radio.dart b/packages/material_ui/lib/src/radio.dart new file mode 100644 index 000000000000..cd39614b3be6 --- /dev/null +++ b/packages/material_ui/lib/src/radio.dart @@ -0,0 +1,1016 @@ +// 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. + +/// @docImport 'checkbox.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio_list_tile.dart'; +/// @docImport 'slider.dart'; +/// @docImport 'switch.dart'; +library; + +import 'package:cupertino_ui/cupertino_ui.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'radio_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late BuildContext context; +// enum SingingCharacter { lafayette } +// late SingingCharacter? _character; +// late StateSetter setState; + +enum _RadioType { material, adaptive } + +const double _kOuterRadius = 8.0; +const double _kInnerRadius = 4.5; + +/// A Material Design radio button. +/// +/// This widget builds a [RawRadio] with a material UI. +/// +/// Used to select between a number of mutually exclusive values. When one radio +/// button in a group is selected, the other radio buttons in the group cease to +/// be selected. The values are of type `T`, the type parameter of the [Radio] +/// class. Enums are commonly used for this purpose. +/// +/// This widget typically has a [RadioGroup] ancestor, which takes in a +/// [RadioGroup.groupValue], and the [Radio] under it with matching [value] +/// will be selected. +/// +/// {@tool dartpad} +/// Here is an example of Radio widgets wrapped in ListTiles, which is similar +/// to what you could get with the RadioListTile widget. +/// +/// The currently selected character is passed into `RadioGroup.groupValue`, +/// which is maintained by the example's `State`. In this case, the first [Radio] +/// will start off selected because `_character` is initialized to +/// `SingingCharacter.lafayette`. +/// +/// If the second radio button is pressed, the example's state is updated +/// with `setState`, updating `_character` to `SingingCharacter.jefferson`. +/// This causes the buttons to rebuild with the updated `RadioGroup.groupValue`, and +/// therefore the selection of the second button. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// ** See code in examples/api/lib/material/radio/radio.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// Here is an example of how the you can override the default theme of a +/// [Radio] with [WidgetStateProperty]. +/// +/// In this example: +/// - The first [Radio] uses a custom [fillColor] that changes depending on whether +/// the radio button is selected. +/// - The second [Radio] applies a different [backgroundColor] based on its selection state. +/// - The third [Radio] customizes the [side] property to display a different border color +/// when selected or unselected. +/// +/// ** See code in examples/api/lib/material/radio/radio.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [RadioListTile], which combines this widget with a [ListTile] so that +/// you can give the radio button a label. +/// * [Slider], for selecting a value in a range. +/// * [Checkbox] and [Switch], for toggling a particular value on or off. +/// * <https://material.io/design/components/selection-controls.html#radio-buttons> +class Radio<T> extends StatefulWidget { + /// Creates a Material Design radio button. + /// + /// This widget typically has a [RadioGroup] ancestor, which takes in a + /// [RadioGroup.groupValue], and the [Radio] under it with matching [value] + /// will be selected. + /// + /// The [value] is required. + const Radio({ + super.key, + required this.value, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, + this.mouseCursor, + this.toggleable = false, + this.activeColor, + this.fillColor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.autofocus = false, + this.enabled, + this.groupRegistry, + this.backgroundColor, + this.side, + this.innerRadius, + }) : _radioType = _RadioType.material, + useCupertinoCheckmarkStyle = false; + + /// Creates an adaptive [Radio] based on whether the target platform is iOS + /// or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// On iOS and macOS, this constructor creates a [CupertinoRadio], which has + /// matching functionality and presentation as Material checkboxes, and are the + /// graphics expected on iOS. On other platforms, this creates a Material + /// design [Radio]. + /// + /// If a [CupertinoRadio] is created, the following parameters are ignored: + /// [mouseCursor], [fillColor], [hoverColor], [overlayColor], [splashRadius], + /// [materialTapTargetSize], [visualDensity]. + /// + /// [useCupertinoCheckmarkStyle] is used only if a [CupertinoRadio] is created. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + const Radio.adaptive({ + super.key, + required this.value, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, + this.mouseCursor, + this.toggleable = false, + this.activeColor, + this.fillColor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.focusNode, + this.autofocus = false, + this.useCupertinoCheckmarkStyle = false, + this.enabled, + this.groupRegistry, + this.backgroundColor, + this.side, + this.innerRadius, + }) : _radioType = _RadioType.adaptive; + + /// {@macro flutter.widget.RawRadio.value} + final T value; + + /// {@template flutter.material.Radio.groupValue} + /// The currently selected value for a group of radio buttons. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + /// + /// This is deprecated, use [RadioGroup] to manage group value instead. + /// {@endtemplate} + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + final T? groupValue; + + /// {@template flutter.material.Radio.onChanged} + /// Called when the user selects this radio button. + /// + /// The radio button passes [value] as a parameter to this callback. The radio + /// button does not actually change state until the parent widget rebuilds the + /// radio button with the new [groupValue]. + /// + /// If null, the radio button will be displayed as disabled. + /// + /// The provided callback will not be invoked if this radio button is already + /// selected and [toggleable] is not set to true. + /// + /// If the [toggleable] is set to true, tapping a already selected radio will + /// invoke this callback with `null` as value. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt. + /// {@endtemplate} + /// + /// For example: + /// + /// ```dart + /// Radio<SingingCharacter>( + /// value: SingingCharacter.lafayette, + /// // ignore: deprecated_member_use + /// groupValue: _character, + /// // ignore: deprecated_member_use + /// onChanged: (SingingCharacter? newValue) { + /// setState(() { + /// _character = newValue; + /// }); + /// }, + /// ) + /// ``` + /// + /// This is deprecated, use [RadioGroup] to handle value change instead. + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + final ValueChanged<T?>? onChanged; + + /// {@macro flutter.widget.RawRadio.mouseCursor} + /// + /// If null, the value of [RadioThemeData.mouseCursor] is used. If that is + /// also null, [WidgetStateMouseCursor.adaptiveClickable] is used. + final MouseCursor? mouseCursor; + + /// {@macro flutter.widget.RawRadio.toggleable} + /// + /// {@tool dartpad} + /// This example shows how to enable deselecting a radio button by setting the + /// [toggleable] attribute. + /// + /// ** See code in examples/api/lib/material/radio/radio.toggleable.0.dart ** + /// {@end-tool} + final bool toggleable; + + /// The color to use when this radio button is selected. + /// + /// Defaults to [ColorScheme.secondary]. + /// + /// If [fillColor] returns a non-null color in the [WidgetState.selected] + /// state, it will be used instead of this color. + final Color? activeColor; + + /// {@template flutter.material.radio.fillColor} + /// The color that fills the radio button, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [fillColor] based on the current [WidgetState] + /// of the [Radio], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Radio<int>( + /// value: 1, + /// fillColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return Colors.orange.withValues(alpha: .32); + /// } + /// return Colors.orange; + /// }) + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If null, then the value of [activeColor] is used in the selected state. If + /// that is also null, then the value of [RadioThemeData.fillColor] is used. + /// If that is also null and [ThemeData.useMaterial3] is false, then + /// [ThemeData.disabledColor] is used in the disabled state, [ColorScheme.secondary] + /// is used in the selected state, and [ThemeData.unselectedWidgetColor] is used in the + /// default state; if [ThemeData.useMaterial3] is true, then [ColorScheme.onSurface] + /// is used in the disabled state, [ColorScheme.primary] is used in the + /// selected state and [ColorScheme.onSurfaceVariant] is used in the default state. + final WidgetStateProperty<Color?>? fillColor; + + /// {@template flutter.material.radio.materialTapTargetSize} + /// Configures the minimum size of the tap target. + /// {@endtemplate} + /// + /// If null, then the value of [RadioThemeData.materialTapTargetSize] is used. + /// If that is also null, then the value of [ThemeData.materialTapTargetSize] + /// is used. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@template flutter.material.radio.visualDensity} + /// Defines how compact the radio's layout will be. + /// {@endtemplate} + /// + /// {@macro flutter.material.themedata.visualDensity} + /// + /// If null, then the value of [RadioThemeData.visualDensity] is used. If that + /// is also null, then the value of [ThemeData.visualDensity] is used. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all + /// widgets within a [Theme]. + final VisualDensity? visualDensity; + + /// The color for the radio's [Material] when it has the input focus. + /// + /// If [overlayColor] returns a non-null color in the [WidgetState.focused] + /// state, it will be used instead. + /// + /// If null, then the value of [RadioThemeData.overlayColor] is used in the + /// focused state. If that is also null, then the value of + /// [ThemeData.focusColor] is used. + final Color? focusColor; + + /// {@template flutter.material.radio.hoverColor} + /// The color for the radio's [Material] when a pointer is hovering over it. + /// + /// If [overlayColor] returns a non-null color in the [WidgetState.hovered] + /// state, it will be used instead. + /// {@endtemplate} + /// + /// If null, then the value of [RadioThemeData.overlayColor] is used in the + /// hovered state. If that is also null, then the value of + /// [ThemeData.hoverColor] is used. + final Color? hoverColor; + + /// {@template flutter.material.radio.overlayColor} + /// The color for the radio's [Material]. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// {@endtemplate} + /// + /// If null, then the value of [activeColor] with alpha + /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the + /// pressed, focused and hovered state. If that is also null, + /// the value of [RadioThemeData.overlayColor] is used. If that is also null, + /// then in Material 2, the value of [ColorScheme.secondary] with alpha + /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] + /// is used in the pressed, focused and hovered state. In Material3, the default + /// values are: + /// * selected + /// * pressed - Theme.colorScheme.onSurface(0.1) + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused - Theme.colorScheme.primary(0.1) + /// * pressed - Theme.colorScheme.primary(0.1) + /// * hovered - Theme.colorScheme.onSurface(0.08) + /// * focused - Theme.colorScheme.onSurface(0.1) + final WidgetStateProperty<Color?>? overlayColor; + + /// {@template flutter.material.radio.splashRadius} + /// The splash radius of the circular [Material] ink response. + /// {@endtemplate} + /// + /// If null, then the value of [RadioThemeData.splashRadius] is used. If that + /// is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Controls whether the checkmark style is used in an iOS-style radio. + /// + /// Only usable under the [Radio.adaptive] constructor. If set to true, on + /// Apple platforms the radio button will appear as an iOS styled checkmark. + /// Controls the [CupertinoRadio] through [CupertinoRadio.useCheckmarkStyle]. + /// + /// Defaults to false. + final bool useCupertinoCheckmarkStyle; + + /// {@macro flutter.widget.RawRadio.groupRegistry} + /// + /// Unless provided, the [BuildContext] will be used to look up the ancestor + /// [RadioGroupRegistry]. + final RadioGroupRegistry<T>? groupRegistry; + + final _RadioType _radioType; + + /// {@template flutter.material.Radio.enabled} + /// Whether this widget is interactive. + /// + /// If not provided, this widget will be interactable if one of the following + /// is true: + /// + /// * A [onChanged] is provided. + /// * Having a [RadioGroup] with the same type T above this widget. + /// * A [groupRegistry] is provided. + /// + /// If this is set to true, one of the above condition must also be true. + /// Otherwise, an assertion error is thrown. + /// {@endtemplate} + final bool? enabled; + + /// {@template flutter.material.Radio.backgroundColor} + /// The color of the background of the radio button, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the ambient [RadioThemeData.backgroundColor] is used. + /// If that is also null the default value is transparent in all states. + final WidgetStateProperty<Color?>? backgroundColor; + + /// {@template flutter.material.Radio.side} + /// The side for the circular border of the radio button, in all + /// [WidgetState]s. + /// + /// This property can be a [BorderSide] or a [WidgetStateBorderSide] to leverage + /// widget state resolution. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the ambient [RadioThemeData.side] is used. If that is + /// also null, the default value is a border using the fill color. + final BorderSide? side; + + /// {@template flutter.material.Radio.innerRadius} + /// The radius of the inner circle of the radio button, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the ambient [RadioThemeData.innerRadius] is used. + /// If that is also null, the default value is `4.5` in all states. + final WidgetStateProperty<double?>? innerRadius; + + @override + State<Radio<T>> createState() => _RadioState<T>(); +} + +class _RadioState<T> extends State<Radio<T>> { + FocusNode? _internalFocusNode; + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + bool get _enabled => + widget.enabled ?? + (widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf<T>(context) != null); + + _RadioRegistry<T>? _internalRadioRegistry; + RadioGroupRegistry<T> get _effectiveRegistry { + if (widget.groupRegistry != null) { + return widget.groupRegistry!; + } + + final RadioGroupRegistry<T>? inheritedRegistry = RadioGroup.maybeOf<T>(context); + if (inheritedRegistry != null) { + return inheritedRegistry; + } + + // Handles deprecated API. + return _internalRadioRegistry ??= _RadioRegistry<T>(this); + } + + @override + void dispose() { + _internalFocusNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + !(widget.enabled ?? false) || + widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf<T>(context) != null, + 'Radio is enabled but has no Radio.onChange or registry above', + ); + assert(debugCheckHasMaterial(context)); + switch (widget._radioType) { + case _RadioType.material: + break; + + case _RadioType.adaptive: + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoRadio<T>( + value: widget.value, + groupValue: widget.groupValue, + onChanged: widget.onChanged, + mouseCursor: widget.mouseCursor, + toggleable: widget.toggleable, + activeColor: widget.activeColor, + focusColor: widget.focusColor, + focusNode: _focusNode, + autofocus: widget.autofocus, + useCheckmarkStyle: widget.useCupertinoCheckmarkStyle, + groupRegistry: _effectiveRegistry, + enabled: _enabled, + ); + } + } + + final RadioThemeData radioTheme = RadioTheme.of(context); + final WidgetStateProperty<MouseCursor> effectiveMouseCursor = + WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) { + return WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? + radioTheme.mouseCursor?.resolve(states) ?? + WidgetStateProperty.resolveAs<MouseCursor>( + WidgetStateMouseCursor.adaptiveClickable, + states, + ); + }); + return RawRadio<T>( + value: widget.value, + mouseCursor: effectiveMouseCursor, + toggleable: widget.toggleable, + focusNode: _focusNode, + autofocus: widget.autofocus, + groupRegistry: _effectiveRegistry, + enabled: _enabled, + builder: (BuildContext context, ToggleableStateMixin state) { + return _RadioPaint( + toggleableState: state, + activeColor: widget.activeColor, + fillColor: widget.fillColor, + hoverColor: widget.hoverColor, + focusColor: widget.focusColor, + overlayColor: widget.overlayColor, + splashRadius: widget.splashRadius, + visualDensity: widget.visualDensity, + materialTapTargetSize: widget.materialTapTargetSize, + backgroundColor: widget.backgroundColor, + side: widget.side, + innerRadius: widget.innerRadius, + ); + }, + ); + } +} + +/// A registry for deprecated API. +// TODO(chunhtai): Remove this once deprecated API is removed. +class _RadioRegistry<T> extends RadioGroupRegistry<T> { + _RadioRegistry(this.state); + final _RadioState<T> state; + @override + T? get groupValue => state.widget.groupValue; + + @override + ValueChanged<T?> get onChanged => state.widget.onChanged!; + + @override + void registerClient(RadioClient<T> radio) {} + + @override + void unregisterClient(RadioClient<T> radio) {} +} + +class _RadioPaint extends StatefulWidget { + const _RadioPaint({ + required this.toggleableState, + required this.activeColor, + required this.fillColor, + required this.hoverColor, + required this.focusColor, + required this.overlayColor, + required this.splashRadius, + required this.visualDensity, + required this.materialTapTargetSize, + required this.backgroundColor, + required this.side, + required this.innerRadius, + }); + + final ToggleableStateMixin toggleableState; + final Color? activeColor; + final WidgetStateProperty<Color?>? fillColor; + final Color? hoverColor; + final Color? focusColor; + final WidgetStateProperty<Color?>? overlayColor; + final double? splashRadius; + final VisualDensity? visualDensity; + final MaterialTapTargetSize? materialTapTargetSize; + final WidgetStateProperty<Color?>? backgroundColor; + final BorderSide? side; + final WidgetStateProperty<double?>? innerRadius; + + @override + State<StatefulWidget> createState() => _RadioPaintState(); +} + +class _RadioPaintState extends State<_RadioPaint> { + final _RadioPainter _painter = _RadioPainter(); + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + WidgetStateProperty<Color?> get _widgetFillColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return widget.activeColor; + } + return null; + }); + } + + BorderSide? _resolveSide(BorderSide? side, Set<WidgetState> states) { + if (side is WidgetStateProperty) { + return WidgetStateProperty.resolveAs<BorderSide?>(side, states); + } + if (!states.contains(WidgetState.selected)) { + return side; + } + return null; + } + + @override + Widget build(BuildContext context) { + final RadioThemeData radioTheme = RadioTheme.of(context); + final RadioThemeData defaults = Theme.of(context).useMaterial3 + ? _RadioDefaultsM3(context) + : _RadioDefaultsM2(context); + + // Colors need to be resolved in selected and non selected states separately + // so that they can be lerped between. + final Set<WidgetState> activeStates = widget.toggleableState.states..add(WidgetState.selected); + final Set<WidgetState> inactiveStates = widget.toggleableState.states + ..remove(WidgetState.selected); + final Color? activeColor = + widget.fillColor?.resolve(activeStates) ?? + _widgetFillColor.resolve(activeStates) ?? + radioTheme.fillColor?.resolve(activeStates); + final Color effectiveActiveColor = activeColor ?? defaults.fillColor!.resolve(activeStates)!; + final Color? inactiveColor = + widget.fillColor?.resolve(inactiveStates) ?? + _widgetFillColor.resolve(inactiveStates) ?? + radioTheme.fillColor?.resolve(inactiveStates); + final Color effectiveInactiveColor = + inactiveColor ?? defaults.fillColor!.resolve(inactiveStates)!; + final Color activeBackgroundColor = + widget.backgroundColor?.resolve(activeStates) ?? + radioTheme.backgroundColor?.resolve(activeStates) ?? + defaults.backgroundColor!.resolve(activeStates)!; + final Color inactiveBackgroundColor = + widget.backgroundColor?.resolve(inactiveStates) ?? + radioTheme.backgroundColor?.resolve(inactiveStates) ?? + defaults.backgroundColor!.resolve(inactiveStates)!; + + final Set<WidgetState> focusedStates = widget.toggleableState.states..add(WidgetState.focused); + Color effectiveFocusOverlayColor = + widget.overlayColor?.resolve(focusedStates) ?? + widget.focusColor ?? + radioTheme.overlayColor?.resolve(focusedStates) ?? + defaults.overlayColor!.resolve(focusedStates)!; + + final Set<WidgetState> hoveredStates = widget.toggleableState.states..add(WidgetState.hovered); + Color effectiveHoverOverlayColor = + widget.overlayColor?.resolve(hoveredStates) ?? + widget.hoverColor ?? + radioTheme.overlayColor?.resolve(hoveredStates) ?? + defaults.overlayColor!.resolve(hoveredStates)!; + + final activePressedStates = activeStates..add(WidgetState.pressed); + final Color effectiveActivePressedOverlayColor = + widget.overlayColor?.resolve(activePressedStates) ?? + radioTheme.overlayColor?.resolve(activePressedStates) ?? + activeColor?.withAlpha(kRadialReactionAlpha) ?? + defaults.overlayColor!.resolve(activePressedStates)!; + + final inactivePressedStates = inactiveStates..add(WidgetState.pressed); + final Color effectiveInactivePressedOverlayColor = + widget.overlayColor?.resolve(inactivePressedStates) ?? + radioTheme.overlayColor?.resolve(inactivePressedStates) ?? + inactiveColor?.withAlpha(kRadialReactionAlpha) ?? + defaults.overlayColor!.resolve(inactivePressedStates)!; + + if (widget.toggleableState.downPosition != null) { + effectiveHoverOverlayColor = widget.toggleableState.states.contains(WidgetState.selected) + ? effectiveActivePressedOverlayColor + : effectiveInactivePressedOverlayColor; + effectiveFocusOverlayColor = widget.toggleableState.states.contains(WidgetState.selected) + ? effectiveActivePressedOverlayColor + : effectiveInactivePressedOverlayColor; + } + + final MaterialTapTargetSize effectiveMaterialTapTargetSize = + widget.materialTapTargetSize ?? + radioTheme.materialTapTargetSize ?? + defaults.materialTapTargetSize!; + final VisualDensity effectiveVisualDensity = + widget.visualDensity ?? radioTheme.visualDensity ?? defaults.visualDensity!; + Size size = switch (effectiveMaterialTapTargetSize) { + MaterialTapTargetSize.padded => const Size( + kMinInteractiveDimension, + kMinInteractiveDimension, + ), + MaterialTapTargetSize.shrinkWrap => const Size( + kMinInteractiveDimension - 8.0, + kMinInteractiveDimension - 8.0, + ), + }; + size += effectiveVisualDensity.baseSizeAdjustment; + final BorderSide activeSide = + _resolveSide(widget.side, activeStates) ?? + _resolveSide(radioTheme.side, activeStates) ?? + BorderSide( + color: effectiveActiveColor, + width: 2.0, + strokeAlign: BorderSide.strokeAlignCenter, + ); + final BorderSide inactiveSide = + _resolveSide(widget.side, inactiveStates) ?? + _resolveSide(radioTheme.side, inactiveStates) ?? + BorderSide( + color: effectiveInactiveColor, + width: 2.0, + strokeAlign: BorderSide.strokeAlignCenter, + ); + + final double innerRadius = + widget.innerRadius?.resolve(activeStates) ?? + radioTheme.innerRadius?.resolve(activeStates) ?? + _kInnerRadius; + + return CustomPaint( + size: size, + painter: _painter + ..position = widget.toggleableState.position + ..reaction = widget.toggleableState.reaction + ..reactionFocusFade = widget.toggleableState.reactionFocusFade + ..reactionHoverFade = widget.toggleableState.reactionHoverFade + ..inactiveReactionColor = effectiveInactivePressedOverlayColor + ..reactionColor = effectiveActivePressedOverlayColor + ..hoverColor = effectiveHoverOverlayColor + ..focusColor = effectiveFocusOverlayColor + ..splashRadius = widget.splashRadius ?? radioTheme.splashRadius ?? kRadialReactionRadius + ..downPosition = widget.toggleableState.downPosition + ..isFocused = widget.toggleableState.states.contains(WidgetState.focused) + ..isHovered = widget.toggleableState.states.contains(WidgetState.hovered) + ..activeColor = effectiveActiveColor + ..inactiveColor = effectiveInactiveColor + ..activeBackgroundColor = activeBackgroundColor + ..inactiveBackgroundColor = inactiveBackgroundColor + ..activeSide = activeSide + ..inactiveSide = inactiveSide + ..innerRadius = innerRadius, + ); + } +} + +class _RadioPainter extends ToggleablePainter { + Color get inactiveBackgroundColor => _inactiveBackgroundColor!; + Color? _inactiveBackgroundColor; + set inactiveBackgroundColor(Color? value) { + if (_inactiveBackgroundColor == value) { + return; + } + _inactiveBackgroundColor = value; + notifyListeners(); + } + + Color get activeBackgroundColor => _activeBackgroundColor!; + Color? _activeBackgroundColor; + set activeBackgroundColor(Color? value) { + if (_activeBackgroundColor == value) { + return; + } + _activeBackgroundColor = value; + notifyListeners(); + } + + BorderSide get inactiveSide => _inactiveSide!; + BorderSide? _inactiveSide; + set inactiveSide(BorderSide? value) { + if (_inactiveSide == value) { + return; + } + _inactiveSide = value; + notifyListeners(); + } + + BorderSide get activeSide => _activeSide!; + BorderSide? _activeSide; + set activeSide(BorderSide? value) { + if (_activeSide == value) { + return; + } + _activeSide = value; + notifyListeners(); + } + + double get innerRadius => _innerRadius!; + double? _innerRadius; + set innerRadius(double? value) { + if (_innerRadius == value) { + return; + } + _innerRadius = value; + notifyListeners(); + } + + @override + void paint(Canvas canvas, Size size) { + paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero)); + + final Rect rect = Offset.zero & size; + final Offset center = rect.center; + final Rect effectiveRect = (center & const Size.square(_kOuterRadius * 2)).translate( + -_kOuterRadius, + -_kOuterRadius, + ); + + // Background + final backgroundPaint = Paint() + ..color = Color.lerp(inactiveBackgroundColor, activeBackgroundColor, position.value)! + ..style = PaintingStyle.fill; + canvas.drawCircle(center, _kOuterRadius, backgroundPaint); + + // Outer circle + final BorderSide side = BorderSide.lerp(inactiveSide, activeSide, position.value); + CircleBorder(side: side).paint(canvas, effectiveRect); + + // Inner circle + if (!position.isDismissed) { + final innerCirclePaint = Paint() + ..style = PaintingStyle.fill + ..color = Color.lerp(inactiveColor, activeColor, position.value)!; + canvas.drawCircle(center, innerRadius * position.value, innerCirclePaint); + } + } +} + +// Hand coded defaults based on Material Design 2. +class _RadioDefaultsM2 extends RadioThemeData { + _RadioDefaultsM2(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + WidgetStateProperty<Color> get fillColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _theme.disabledColor; + } + if (states.contains(WidgetState.selected)) { + return _colors.secondary; + } + return _theme.unselectedWidgetColor; + }); + } + + @override + WidgetStateProperty<Color> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return fillColor.resolve(states).withAlpha(kRadialReactionAlpha); + } + if (states.contains(WidgetState.hovered)) { + return _theme.hoverColor; + } + if (states.contains(WidgetState.focused)) { + return _theme.focusColor; + } + return Colors.transparent; + }); + } + + @override + MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; + + @override + VisualDensity get visualDensity => _theme.visualDensity; + + @override + WidgetStateProperty<Color> get backgroundColor => + WidgetStateProperty.all<Color>(Colors.transparent); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Radio<T> + +// 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. + +// dart format off +class _RadioDefaultsM3 extends RadioThemeData { + _RadioDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + WidgetStateProperty<Color> get fillColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary; + } + if (states.contains(WidgetState.focused)) { + return _colors.primary; + } + return _colors.primary; + } + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface; + } + return _colors.onSurfaceVariant; + }); + } + + @override + WidgetStateProperty<Color> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return Colors.transparent; + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return Colors.transparent; + }); + } + + @override + MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; + + @override + VisualDensity get visualDensity => _theme.visualDensity; + + @override + WidgetStateProperty<Color> get backgroundColor => + WidgetStateProperty.all<Color>(Colors.transparent); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Radio<T> diff --git a/packages/material_ui/lib/src/radio_list_tile.dart b/packages/material_ui/lib/src/radio_list_tile.dart new file mode 100644 index 000000000000..1d360c27d964 --- /dev/null +++ b/packages/material_ui/lib/src/radio_list_tile.dart @@ -0,0 +1,785 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'checkbox_list_tile.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'constants.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'material.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'switch_list_tile.dart'; +/// @docImport 'switch_theme.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'radio.dart'; +import 'radio_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// void setState(VoidCallback fn) { } +// enum Meridiem { am, pm } +// enum SingingCharacter { lafayette } +// late SingingCharacter? _character; + +enum _RadioType { material, adaptive } + +/// A [ListTile] with a [Radio]. In other words, a radio button with a label. +/// +/// The entire list tile is interactive: tapping anywhere in the tile selects +/// the radio button. +/// +/// This widget typically has a [RadioGroup] ancestor, which takes in a +/// [RadioGroup.groupValue], and the [RadioListTile] under it with matching +/// [value] will be selected. +/// +/// The [title], [subtitle], [isThreeLine], and [dense] properties are like +/// those of the same name on [ListTile]. +/// +/// The [selected] property on this widget is similar to the [ListTile.selected] +/// property. The [fillColor] in the selected state is used for the selected item's +/// text color. If it is null, the [activeColor] is used. +/// +/// This widget does not coordinate the [selected] state and the +/// [checked] state; to have the list tile appear selected when the +/// radio button is the selected radio button, set [selected] to true +/// when [value] matches [RadioGroup.groupValue]. +/// +/// The radio button is shown on the left by default in left-to-right languages +/// (i.e. the leading edge). This can be changed using [controlAffinity]. The +/// [secondary] widget is placed on the opposite side. This maps to the +/// [ListTile.leading] and [ListTile.trailing] properties of [ListTile]. +/// +/// This widget requires a [Material] widget ancestor in the tree to paint +/// itself on, which is typically provided by the app's [Scaffold]. +/// The [tileColor], and [selectedTileColor] are not painted by the +/// [RadioListTile] itself but by the [Material] widget ancestor. In this +/// case, one can wrap a [Material] widget around the [RadioListTile], e.g.: +/// +/// {@tool snippet} +/// ```dart +/// const ColoredBox( +/// color: Colors.green, +/// child: Material( +/// child: RadioListTile<Meridiem>( +/// tileColor: Colors.red, +/// title: Text('AM'), +/// value: Meridiem.am, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Performance considerations when wrapping [RadioListTile] with [Material] +/// +/// Wrapping a large number of [RadioListTile]s individually with [Material]s +/// is expensive. Consider only wrapping the [RadioListTile]s that require it +/// or include a common [Material] ancestor where possible. +/// +/// {@tool dartpad} +/// ![RadioListTile sample](https://flutter.github.io/assets-for-api-docs/assets/material/radio_list_tile.png) +/// +/// This widget shows a pair of radio buttons that control the `_character` +/// field. The field is of the type `SingingCharacter`, an enum. +/// +/// ** See code in examples/api/lib/material/radio_list_tile/radio_list_tile.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample demonstrates how [RadioListTile] positions the radio widget +/// relative to the text in different configurations. +/// +/// ** See code in examples/api/lib/material/radio_list_tile/radio_list_tile.1.dart ** +/// {@end-tool} +/// +/// ## Semantics in RadioListTile +/// +/// Since the entirety of the RadioListTile is interactive, it should represent +/// itself as a single interactive entity. +/// +/// To do so, a RadioListTile widget wraps its children with a [MergeSemantics] +/// widget. [MergeSemantics] will attempt to merge its descendant [Semantics] +/// nodes into one node in the semantics tree. Therefore, RadioListTile will +/// throw an error if any of its children requires its own [Semantics] node. +/// +/// For example, you cannot nest a [RichText] widget as a descendant of +/// RadioListTile. [RichText] has an embedded gesture recognizer that +/// requires its own [Semantics] node, which directly conflicts with +/// RadioListTile's desire to merge all its descendants' semantic nodes +/// into one. Therefore, it may be necessary to create a custom radio tile +/// widget to accommodate similar use cases. +/// +/// {@tool dartpad} +/// ![Radio list tile semantics sample](https://flutter.github.io/assets-for-api-docs/assets/material/radio_list_tile_semantics.png) +/// +/// Here is an example of a custom labeled radio widget, called +/// LinkedLabelRadio, that includes an interactive [RichText] widget that +/// handles tap gestures. +/// +/// ** See code in examples/api/lib/material/radio_list_tile/custom_labeled_radio.0.dart ** +/// {@end-tool} +/// +/// ## RadioListTile isn't exactly what I want +/// +/// If the way RadioListTile pads and positions its elements isn't quite what +/// you're looking for, you can create custom labeled radio widgets by +/// combining [Radio] with other widgets, such as [Text], [Padding] and +/// [InkWell]. +/// +/// {@tool dartpad} +/// ![Custom radio list tile sample](https://flutter.github.io/assets-for-api-docs/assets/material/radio_list_tile_custom.png) +/// +/// Here is an example of a custom LabeledRadio widget, but you can easily +/// make your own configurable widget. +/// +/// ** See code in examples/api/lib/material/radio_list_tile/custom_labeled_radio.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListTileTheme], which can be used to affect the style of list tiles, +/// including radio list tiles. +/// * [CheckboxListTile], a similar widget for checkboxes. +/// * [SwitchListTile], a similar widget for switches. +/// * [ListTile] and [Radio], the widgets from which this widget is made. +class RadioListTile<T> extends StatefulWidget { + /// Creates a combination of a list tile and a radio button. + /// + /// This widget typically has a [RadioGroup] ancestor, which takes in a + /// [RadioGroup.groupValue], and the [RadioListTile] under it with matching + /// [value] will be selected. + /// + /// [value] must be provided + const RadioListTile({ + super.key, + required this.value, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, + this.mouseCursor, + this.toggleable = false, + this.activeColor, + this.fillColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.secondary, + this.selected = false, + this.controlAffinity, + this.autofocus = false, + this.contentPadding, + this.shape, + this.tileColor, + this.selectedTileColor, + this.visualDensity, + this.focusNode, + this.statesController, + this.onFocusChange, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.radioScaleFactor = 1.0, + this.titleAlignment, + this.enabled, + this.internalAddSemanticForOnTap = false, + this.radioBackgroundColor, + this.radioSide, + this.radioInnerRadius, + }) : _radioType = _RadioType.material, + useCupertinoCheckmarkStyle = false, + assert(isThreeLine != true || subtitle != null); + + /// Creates a combination of a list tile and a platform adaptive radio. + /// + /// The checkbox uses [Radio.adaptive] to show a [CupertinoRadio] for + /// iOS platforms, or [Radio] for all others. + /// + /// All other properties are the same as [RadioListTile]. + const RadioListTile.adaptive({ + super.key, + required this.value, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, + this.mouseCursor, + this.toggleable = false, + this.activeColor, + this.fillColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.secondary, + this.selected = false, + this.controlAffinity, + this.autofocus = false, + this.contentPadding, + this.shape, + this.tileColor, + this.selectedTileColor, + this.visualDensity, + this.focusNode, + this.statesController, + this.onFocusChange, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.radioScaleFactor = 1.0, + this.enabled, + this.useCupertinoCheckmarkStyle = false, + this.titleAlignment, + this.internalAddSemanticForOnTap = false, + this.radioBackgroundColor, + this.radioSide, + this.radioInnerRadius, + }) : _radioType = _RadioType.adaptive, + assert(isThreeLine != true || subtitle != null); + + /// The value represented by this radio button. + final T value; + + /// The currently selected value for this group of radio buttons. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + /// + /// leave this unassigned or null if building this widget under [RadioGroup]. + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + final T? groupValue; + + /// Called when the user selects this radio button. + /// + /// The radio button passes [value] as a parameter to this callback. The radio + /// button does not actually change state until the parent widget rebuilds the + /// radio tile with the new [groupValue]. + /// + /// If null, the radio button will be displayed as disabled. + /// + /// The provided callback will not be invoked if this radio button is already + /// selected. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// RadioListTile<SingingCharacter>( + /// title: const Text('Lafayette'), + /// value: SingingCharacter.lafayette, + /// // ignore: deprecated_member_use + /// groupValue: _character, + /// // ignore: deprecated_member_use + /// onChanged: (SingingCharacter? newValue) { + /// setState(() { + /// _character = newValue; + /// }); + /// }, + /// ) + /// ``` + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + final ValueChanged<T?>? onChanged; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [RadioThemeData.mouseCursor] is used. + /// If that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// Set to true if this radio list tile is allowed to be returned to an + /// indeterminate state by selecting it again when selected. + /// + /// To indicate returning to an indeterminate state, [onChanged] will be + /// called with null. + /// + /// If true, [RadioGroup.onChanged] is called with [value] when selected while + /// [RadioGroup.groupValue] != [value], and with null when selected again while + /// [RadioGroup.groupValue] == [value]. + /// + /// If false, [RadioGroup.onChanged] will be called with [value] when it is + /// selected while [groupValue] != [value], and only by selecting another + /// radio button in the group (i.e. changing the value of + /// [RadioGroup.groupValue]) can this radio list tile be unselected. + /// + /// The default is false. + /// + /// {@tool dartpad} + /// This example shows how to enable deselecting a radio button by setting the + /// [toggleable] attribute. + /// + /// ** See code in examples/api/lib/material/radio_list_tile/radio_list_tile.toggleable.0.dart ** + /// {@end-tool} + final bool toggleable; + + /// The color to use when this radio button is selected. + /// + /// Defaults to [ColorScheme.secondary] of the current [Theme]. + final Color? activeColor; + + /// The color that fills the radio button. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [activeColor] is used in the selected state. If + /// that is also null, then the value of [RadioThemeData.fillColor] is used. + /// If that is also null, then the default value is used. + final WidgetStateProperty<Color?>? fillColor; + + /// {@macro flutter.material.radio.materialTapTargetSize} + /// + /// Defaults to [MaterialTapTargetSize.shrinkWrap]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@macro flutter.material.radio.hoverColor} + final Color? hoverColor; + + /// The color for the radio's [Material]. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// + /// If null, then the value of [activeColor] with alpha [kRadialReactionAlpha] + /// and [hoverColor] is used in the pressed and hovered state. If that is also + /// null, the value of [SwitchThemeData.overlayColor] is used. If that is + /// also null, then the default value is used in the pressed and hovered state. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.material.radio.splashRadius} + /// + /// If null, then the value of [RadioThemeData.splashRadius] is used. If that + /// is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// A widget to display on the opposite side of the tile from the radio button. + /// + /// Typically an [Icon] widget. + final Widget? secondary; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If null, the value from [ListTileThemeData.isThreeLine] is used. + /// If that is also null, the value from [ThemeData.listTileTheme] is used. + /// If still null, the default value is `false`. + final bool? isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileThemeData.dense]. + final bool? dense; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [checked] state. To have the list tile appear selected when the radio + /// button is the selected radio button, set [selected] to true when [value] + /// matches [RadioGroup.groupValue]. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// Where to place the control relative to the text. + final ListTileControlAffinity? controlAffinity; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Defines the insets surrounding the contents of the tile. + /// + /// Insets the [Radio], [title], [subtitle], and [secondary] widgets + /// in [RadioListTile]. + /// + /// When null, `EdgeInsets.symmetric(horizontal: 16.0)` is used. + final EdgeInsetsGeometry? contentPadding; + + /// If specified, [shape] defines the shape of the [RadioListTile]'s [InkWell] border. + final ShapeBorder? shape; + + /// If specified, defines the background color for `RadioListTile` when + /// [RadioListTile.selected] is false. + final Color? tileColor; + + /// If non-null, defines the background color when [RadioListTile.selected] is true. + final Color? selectedTileColor; + + /// Defines how compact the list tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + final VisualDensity? visualDensity; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Controls the interactive states of the backing [ListTile]. + final WidgetStatesController? statesController; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.material.ListTile.enableFeedback} + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// {@macro flutter.material.ListTile.horizontalTitleGap} + final double? horizontalTitleGap; + + /// {@macro flutter.material.ListTile.minVerticalPadding} + final double? minVerticalPadding; + + /// {@macro flutter.material.ListTile.minLeadingWidth} + final double? minLeadingWidth; + + /// {@macro flutter.material.ListTile.minTileHeight} + final double? minTileHeight; + + final _RadioType _radioType; + + /// Defines how [ListTile.leading] and [ListTile.trailing] are + /// vertically aligned relative to the [ListTile]'s titles + /// ([ListTile.title] and [ListTile.subtitle]). + /// + /// If this property is null then [ListTileThemeData.titleAlignment] + /// is used. If that is also null then [ListTileTitleAlignment.threeLine] + /// is used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final ListTileTitleAlignment? titleAlignment; + + /// Whether to add button:true to the semantics if onTap is provided. + /// This is a temporary flag to help changing the behavior of ListTile onTap semantics. + /// + // TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping + // the default value to true. + final bool internalAddSemanticForOnTap; + + /// Whether to use the checkbox style for the [CupertinoRadio] control. + /// + /// Only usable under the [RadioListTile.adaptive] constructor. If set to + /// true, on Apple platforms the radio button will appear as an iOS styled + /// checkmark. Controls the [CupertinoRadio] through + /// [CupertinoRadio.useCheckmarkStyle]. + /// + /// Defaults to false. + final bool useCupertinoCheckmarkStyle; + + /// Controls the scaling factor applied to the [Radio] within the [RadioListTile]. + /// + /// Defaults to 1.0. + final double radioScaleFactor; + + /// Whether this widget is interactable. + /// + /// If not provided, this widget will be interactable if one of the following + /// is true: + /// + /// * A [onChanged] is provided. + /// * Having a [RadioGroup] with the same type T above this widget. + /// + /// If this is set to true, one of the above condition must also be true. + /// Otherwise, an assertion error is thrown. + final bool? enabled; + + /// The color of the background of the radio button, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then it is transparent in all states. + final WidgetStateProperty<Color?>? radioBackgroundColor; + + /// The side for the circular border of the radio button, in all + /// [WidgetState]s. + /// + /// This property can be a [BorderSide] or a [WidgetStateBorderSide] to leverage + /// widget state resolution. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then it defaults to a border using the fill color. + final BorderSide? radioSide; + + /// The radius of the inner circle of the radio button, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then it defaults to `4.5` in all states. + final WidgetStateProperty<double?>? radioInnerRadius; + + /// Whether this radio button is checked. + /// + /// To control this value, set [value] and [groupValue] appropriately. + @Deprecated( + 'Use RadioGroup.groupValue to find which radio is checked. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + bool get checked => value == groupValue; + + @override + State<RadioListTile<T>> createState() => _RadioListTileState<T>(); +} + +class _RadioListTileState<T> extends State<RadioListTile<T>> with RadioClient<T> { + FocusNode? _internalFocusNode; + + @override + FocusNode get focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + @override + T get radioValue => widget.value; + + @override + bool get tristate => widget.toggleable; + + @override + bool get enabled => _enabled; + + bool get checked => radioValue == effectiveGroupValue; + + late final _RadioRegistry<T> _radioRegistry = _RadioRegistry<T>(this); + + T? get effectiveGroupValue => registry?.groupValue ?? widget.groupValue; + + bool get _enabled => widget.enabled ?? (widget.onChanged != null || registry != null); + + void _handleListTileTap() { + if (!widget.toggleable && checked) { + return; + } + T? newValue; + if (checked) { + newValue = null; + } else { + newValue = radioValue; + } + handleChange(newValue); + } + + void handleChange(T? value) { + if (registry != null) { + registry!.onChanged(value); + } + + if (widget.onChanged != null) { + widget.onChanged!(value); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + registry = RadioGroup.maybeOf(context); + } + + @override + void dispose() { + registry = null; + _internalFocusNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + !(widget.enabled ?? false) || + widget.onChanged != null || + RadioGroup.maybeOf<T>(context) != null, + 'Radio is enabled but has no RadioListTile.onChange or registry above', + ); + Widget control; + switch (widget._radioType) { + case _RadioType.material: + control = ExcludeFocus( + child: Radio<T>( + value: radioValue, + groupValue: _radioRegistry.groupValue, + toggleable: widget.toggleable, + activeColor: widget.activeColor, + materialTapTargetSize: widget.materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + autofocus: widget.autofocus, + fillColor: widget.fillColor, + mouseCursor: widget.mouseCursor, + hoverColor: widget.hoverColor, + overlayColor: widget.overlayColor, + splashRadius: widget.splashRadius, + enabled: _enabled, + groupRegistry: _radioRegistry, + backgroundColor: widget.radioBackgroundColor, + side: widget.radioSide, + innerRadius: widget.radioInnerRadius, + ), + ); + case _RadioType.adaptive: + control = ExcludeFocus( + child: Radio<T>.adaptive( + value: radioValue, + groupValue: _radioRegistry.groupValue, + toggleable: widget.toggleable, + activeColor: widget.activeColor, + materialTapTargetSize: widget.materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + autofocus: widget.autofocus, + fillColor: widget.fillColor, + mouseCursor: widget.mouseCursor, + hoverColor: widget.hoverColor, + overlayColor: widget.overlayColor, + splashRadius: widget.splashRadius, + useCupertinoCheckmarkStyle: widget.useCupertinoCheckmarkStyle, + enabled: _enabled, + groupRegistry: _radioRegistry, + backgroundColor: widget.radioBackgroundColor, + side: widget.radioSide, + innerRadius: widget.radioInnerRadius, + ), + ); + } + + if (widget.radioScaleFactor != 1.0) { + control = Transform.scale(scale: widget.radioScaleFactor, child: control); + } + + final ListTileThemeData listTileTheme = ListTileTheme.of(context); + final ListTileControlAffinity effectiveControlAffinity = + widget.controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform; + Widget? leading, trailing; + (leading, trailing) = switch (effectiveControlAffinity) { + ListTileControlAffinity.leading || + ListTileControlAffinity.platform => (control, widget.secondary), + ListTileControlAffinity.trailing => (widget.secondary, control), + }; + final ThemeData theme = Theme.of(context); + final RadioThemeData radioThemeData = RadioTheme.of(context); + final states = <WidgetState>{if (widget.selected) WidgetState.selected}; + final Color effectiveActiveColor = + widget.activeColor ?? + radioThemeData.fillColor?.resolve(states) ?? + theme.colorScheme.secondary; + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: widget.title, + subtitle: widget.subtitle, + trailing: trailing, + isThreeLine: widget.isThreeLine, + dense: widget.dense, + enabled: _enabled, + shape: widget.shape, + tileColor: widget.tileColor, + selectedTileColor: widget.selectedTileColor, + onTap: _enabled ? _handleListTileTap : null, + selected: widget.selected, + autofocus: widget.autofocus, + contentPadding: widget.contentPadding, + visualDensity: widget.visualDensity, + focusNode: focusNode, + statesController: widget.statesController, + onFocusChange: widget.onFocusChange, + enableFeedback: widget.enableFeedback, + horizontalTitleGap: widget.horizontalTitleGap, + minVerticalPadding: widget.minVerticalPadding, + minLeadingWidth: widget.minLeadingWidth, + minTileHeight: widget.minTileHeight, + titleAlignment: widget.titleAlignment, + internalAddSemanticForOnTap: widget.internalAddSemanticForOnTap, + ), + ); + } +} + +/// A registry to controls internal [Radio] and hides it from [RadioGroup] +/// ancestor. +/// +/// [RadioListTile] implements the [RadioClient] directly to register to +/// [RadioGroup] ancestor. Therefore, it has to hide the internal [Radio] from +/// participate in the [RadioGroup] ancestor. +class _RadioRegistry<T> extends RadioGroupRegistry<T> { + _RadioRegistry(this.state); + + final _RadioListTileState<T> state; + + @override + T? get groupValue => state.effectiveGroupValue; + + @override + ValueChanged<T?> get onChanged => state.handleChange; + + @override + void registerClient(RadioClient<T> radio) {} + + @override + void unregisterClient(RadioClient<T> radio) {} +} diff --git a/packages/material_ui/lib/src/radio_theme.dart b/packages/material_ui/lib/src/radio_theme.dart new file mode 100644 index 000000000000..9093bb459500 --- /dev/null +++ b/packages/material_ui/lib/src/radio_theme.dart @@ -0,0 +1,299 @@ +// 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. + +/// @docImport 'constants.dart'; +/// @docImport 'radio.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Radio] widgets. +/// +/// Descendant widgets obtain the current [RadioThemeData] object using +/// [RadioTheme.of]. Instances of [RadioThemeData] can be customized +/// with [RadioThemeData.copyWith]. +/// +/// Typically a [RadioThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.radioTheme]. +/// +/// All [RadioThemeData] properties are `null` by default. When null, the +/// [Radio] will use the values from [ThemeData] if they exist, otherwise it +/// will provide its own defaults based on the overall [Theme]'s colorScheme. +/// See the individual [Radio] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +/// * [RadioTheme], which is used by descendants to obtain the +/// [RadioThemeData]. +@immutable +class RadioThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.radioTheme]. + const RadioThemeData({ + this.mouseCursor, + this.fillColor, + this.overlayColor, + this.splashRadius, + this.materialTapTargetSize, + this.visualDensity, + this.backgroundColor, + this.side, + this.innerRadius, + }); + + /// {@macro flutter.widget.RawRadio.mouseCursor} + /// + /// If specified, overrides the default value of [Radio.mouseCursor]. The + /// default value is [WidgetStateMouseCursor.clickable]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// {@macro flutter.material.radio.fillColor} + /// + /// If specified, overrides the default value of [Radio.fillColor]. + final WidgetStateProperty<Color?>? fillColor; + + /// {@macro flutter.material.radio.overlayColor} + /// + /// If specified, overrides the default value of [Radio.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.material.radio.splashRadius} + /// + /// If specified, overrides the default value of [Radio.splashRadius]. The + /// default value is [kRadialReactionRadius]. + final double? splashRadius; + + /// {@macro flutter.material.radio.materialTapTargetSize} + /// + /// If specified, overrides the default value of + /// [Radio.materialTapTargetSize]. The default value is the value of + /// [ThemeData.materialTapTargetSize]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@macro flutter.material.radio.visualDensity} + /// + /// If specified, overrides the default value of [Radio.visualDensity]. The + /// default value is the value of [ThemeData.visualDensity]. + final VisualDensity? visualDensity; + + /// {@macro flutter.material.Radio.backgroundColor} + /// + /// If specified, overrides the default value of [Radio.backgroundColor]. The + /// default value is transparent in all states. + final WidgetStateProperty<Color?>? backgroundColor; + + /// {@macro flutter.material.Radio.side} + /// + /// If specified, overrides the default value of [Radio.side]. The default + /// value is a border using the fill color. + final BorderSide? side; + + /// {@macro flutter.material.Radio.innerRadius} + /// + /// If specified, overrides the default value of [Radio.innerRadius]. The + /// default value is `4.5` in all states. + final WidgetStateProperty<double?>? innerRadius; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + RadioThemeData copyWith({ + WidgetStateProperty<MouseCursor?>? mouseCursor, + WidgetStateProperty<Color?>? fillColor, + WidgetStateProperty<Color?>? overlayColor, + double? splashRadius, + MaterialTapTargetSize? materialTapTargetSize, + VisualDensity? visualDensity, + WidgetStateProperty<Color?>? backgroundColor, + BorderSide? side, + WidgetStateProperty<double?>? innerRadius, + }) { + return RadioThemeData( + mouseCursor: mouseCursor ?? this.mouseCursor, + fillColor: fillColor ?? this.fillColor, + overlayColor: overlayColor ?? this.overlayColor, + splashRadius: splashRadius ?? this.splashRadius, + materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize, + visualDensity: visualDensity ?? this.visualDensity, + backgroundColor: backgroundColor ?? this.backgroundColor, + side: side ?? this.side, + innerRadius: innerRadius ?? this.innerRadius, + ); + } + + // Special case because BorderSide.lerp() doesn't support null arguments. + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null && b == null) { + return null; + } + if (a is WidgetStateBorderSide) { + a = a.resolve(const <WidgetState>{}); + } + if (b is WidgetStateBorderSide) { + b = b.resolve(const <WidgetState>{}); + } + a ??= BorderSide(width: 0, color: b!.color.withAlpha(0)); + b ??= BorderSide(width: 0, color: a.color.withAlpha(0)); + + return BorderSide.lerp(a, b, t); + } + + /// Linearly interpolate between two [RadioThemeData]s. + /// + /// {@macro dart.ui.shadow.lerp} + static RadioThemeData lerp(RadioThemeData? a, RadioThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return RadioThemeData( + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + fillColor: WidgetStateProperty.lerp<Color?>(a?.fillColor, b?.fillColor, t, Color.lerp), + materialTapTargetSize: t < 0.5 ? a?.materialTapTargetSize : b?.materialTapTargetSize, + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + splashRadius: lerpDouble(a?.splashRadius, b?.splashRadius, t), + visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity, + backgroundColor: WidgetStateProperty.lerp<Color?>( + a?.backgroundColor, + b?.backgroundColor, + t, + Color.lerp, + ), + side: _lerpSides(a?.side, b?.side, t), + innerRadius: WidgetStateProperty.lerp<double?>(a?.innerRadius, b?.innerRadius, t, lerpDouble), + ); + } + + @override + int get hashCode => Object.hash( + mouseCursor, + fillColor, + overlayColor, + splashRadius, + materialTapTargetSize, + visualDensity, + backgroundColor, + side, + innerRadius, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is RadioThemeData && + other.mouseCursor == mouseCursor && + other.fillColor == fillColor && + other.overlayColor == overlayColor && + other.splashRadius == splashRadius && + other.materialTapTargetSize == materialTapTargetSize && + other.visualDensity == visualDensity && + other.backgroundColor == backgroundColor && + other.side == side && + other.innerRadius == innerRadius; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>('fillColor', fillColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('splashRadius', splashRadius, defaultValue: null)); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + materialTapTargetSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'backgroundColor', + backgroundColor, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<BorderSide>('side', side, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>( + 'innerRadius', + innerRadius, + defaultValue: null, + ), + ); + } +} + +/// Applies a radio theme to descendant [Radio] widgets. +/// +/// Descendant widgets obtain the current theme's [RadioTheme] object using +/// [RadioTheme.of]. When a widget uses [RadioTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// A radio theme can be specified as part of the overall Material theme using +/// [ThemeData.radioTheme]. +/// +/// See also: +/// +/// * [RadioThemeData], which describes the actual configuration of a radio +/// theme. +class RadioTheme extends InheritedWidget { + /// Constructs a radio theme that configures all descendant [Radio] widgets. + const RadioTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [Radio] widgets. + final RadioThemeData data; + + /// Returns the configuration [data] from the closest [RadioTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.radioTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// RadioThemeData theme = RadioTheme.of(context); + /// ``` + static RadioThemeData of(BuildContext context) { + final RadioTheme? radioTheme = context.dependOnInheritedWidgetOfExactType<RadioTheme>(); + return radioTheme?.data ?? Theme.of(context).radioTheme; + } + + @override + bool updateShouldNotify(RadioTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/range_slider.dart b/packages/material_ui/lib/src/range_slider.dart new file mode 100644 index 000000000000..dc477951d23c --- /dev/null +++ b/packages/material_ui/lib/src/range_slider.dart @@ -0,0 +1,2271 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'checkbox.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'slider.dart'; +/// @docImport 'switch.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart' show timeDilation; +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'range_slider_parts.dart'; +import 'slider_theme.dart'; +import 'slider_value_indicator_shape.dart'; +import 'theme.dart'; + +// Examples can assume: +// RangeValues _rangeValues = const RangeValues(0.3, 0.7); +// RangeValues _dollarsRange = const RangeValues(50, 100); +// void setState(VoidCallback fn) { } + +/// [RangeSlider] uses this callback to paint the value indicator on the overlay. +/// Since the value indicator is painted on the Overlay; this method paints the +/// value indicator in a [RenderBox] that appears in the [Overlay]. +typedef PaintRangeValueIndicator = void Function(PaintingContext context, Offset offset); + +/// A Material Design range slider. +/// +/// Used to select a range from a range of values. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs} +/// +/// {@tool dartpad} +/// ![A range slider widget, consisting of 5 divisions and showing the default +/// value indicator.](https://flutter.github.io/assets-for-api-docs/assets/material/range_slider.png) +/// +/// This range values are in intervals of 20 because the Range Slider has 5 +/// divisions, from 0 to 100. This means are values are split between 0, 20, 40, +/// 60, 80, and 100. The range values are initialized with 40 and 80 in this demo. +/// +/// ** See code in examples/api/lib/material/range_slider/range_slider.0.dart ** +/// {@end-tool} +/// +/// A range slider can be used to select from either a continuous or a discrete +/// set of values. The default is to use a continuous range of values from [min] +/// to [max]. To use discrete values, use a non-null value for [divisions], which +/// indicates the number of discrete intervals. For example, if [min] is 0.0 and +/// [max] is 50.0 and [divisions] is 5, then the slider can take on the +/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0. +/// +/// The terms for the parts of a slider are: +/// +/// * The "thumbs", which are the shapes that slide horizontally when the user +/// drags them to change the selected range. +/// * The "track", which is the horizontal line that the thumbs can be dragged +/// along. +/// * The "tick marks", which mark the discrete values of a discrete slider. +/// * The "overlay", which is a highlight that's drawn over a thumb in response +/// to a user tap-down gesture. +/// * The "value indicators", which are the shapes that pop up when the user +/// is dragging a thumb to show the value being selected. +/// * The "active" segment of the slider is the segment between the two thumbs. +/// * The "inactive" slider segments are the two track intervals outside of the +/// slider's thumbs. +/// +/// The range slider will be disabled if [onChanged] is null or if the range +/// given by [min]..[max] is empty (i.e. if [min] is equal to [max]). +/// +/// The range slider widget itself does not maintain any state. Instead, when +/// the state of the slider changes, the widget calls the [onChanged] callback. +/// Most widgets that use a range slider will listen for the [onChanged] callback +/// and rebuild the slider with new [values] to update the visual appearance of +/// the slider. To know when the value starts to change, or when it is done +/// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd]. +/// +/// By default, a slider will be as wide as possible, centered vertically. When +/// given unbounded constraints, it will attempt to make the track 144 pixels +/// wide (including margins on each side) and will shrink-wrap vertically. +/// +/// Requires one of its ancestors to be a [Material] widget. This is typically +/// provided by a [Scaffold] widget. +/// +/// Requires one of its ancestors to be a [MediaQuery] widget. Typically, a +/// [MediaQuery] widget is introduced by the [MaterialApp] or [WidgetsApp] +/// widget at the top of your application widget tree. +/// +/// To determine how it should be displayed (e.g. colors, thumb shape, etc.), +/// a slider uses the [SliderThemeData] available from either a [SliderTheme] +/// widget, or the [ThemeData.sliderTheme] inside a [Theme] widget above it in +/// the widget tree. You can also override some of the colors with the +/// [activeColor] and [inactiveColor] properties, although more fine-grained +/// control of the colors, and other visual properties is achieved using a +/// [SliderThemeData]. +/// +/// See also: +/// +/// * [SliderTheme] and [SliderThemeData] for information about controlling +/// the visual appearance of the slider. +/// * [Slider], for a single-valued slider. +/// * [Radio], for selecting among a set of explicit values. +/// * [Checkbox] and [Switch], for toggling a particular value on or off. +/// * <https://material.io/design/components/sliders.html> +/// * [MediaQuery], from which the text scale factor is obtained. +class RangeSlider extends StatefulWidget { + /// Creates a Material Design range slider. + /// + /// The range slider widget itself does not maintain any state. Instead, when + /// the state of the slider changes, the widget calls the [onChanged] + /// callback. Most widgets that use a range slider will listen for the + /// [onChanged] callback and rebuild the slider with new [values] to update + /// the visual appearance of the slider. To know when the value starts to + /// change, or when it is done changing, set the optional callbacks + /// [onChangeStart] and/or [onChangeEnd]. + /// + /// * [values], which determines currently selected values for this range + /// slider. + /// * [onChanged], which is called while the user is selecting a new value for + /// the range slider. + /// * [onChangeStart], which is called when the user starts to select a new + /// value for the range slider. + /// * [onChangeEnd], which is called when the user is done selecting a new + /// value for the range slider. + /// + /// You can override some of the colors with the [activeColor] and + /// [inactiveColor] properties, although more fine-grained control of the + /// appearance is achieved using a [SliderThemeData]. + /// + /// The [min] must be less than or equal to the [max]. + /// + /// The [RangeValues.start] attribute of the [values] parameter must be less + /// than or equal to its [RangeValues.end] attribute. The [RangeValues.start] + /// and [RangeValues.end] attributes of the [values] parameter must be greater + /// than or equal to the [min] parameter and less than or equal to the [max] + /// parameter. + /// + /// The [divisions] parameter must be null or greater than zero. + RangeSlider({ + super.key, + required this.values, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.labels, + this.activeColor, + this.inactiveColor, + this.overlayColor, + this.mouseCursor, + this.semanticFormatterCallback, + this.padding, + @Deprecated( + 'Set this flag to false to opt into the 2024 range slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + this.year2023, + }) : assert(min <= max), + assert(values.start <= values.end), + assert(values.start >= min && values.start <= max), + assert(values.end >= min && values.end <= max), + assert(divisions == null || divisions > 0); + + /// The currently selected values for this range slider. + /// + /// The slider's thumbs are drawn at horizontal positions that corresponds to + /// these values. + final RangeValues values; + + /// Called when the user is selecting a new value for the slider by dragging. + /// + /// The slider passes the new values to the callback but does not actually + /// change state until the parent widget rebuilds the slider with the new + /// values. + /// + /// If null, the slider will be displayed as disabled. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// {@tool snippet} + /// + /// ```dart + /// RangeSlider( + /// values: _rangeValues, + /// min: 1.0, + /// max: 10.0, + /// onChanged: (RangeValues newValues) { + /// setState(() { + /// _rangeValues = newValues; + /// }); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart], which is called when the user starts changing the + /// values. + /// * [onChangeEnd], which is called when the user stops changing the values. + final ValueChanged<RangeValues>? onChanged; + + /// Called when the user starts selecting new values for the slider. + /// + /// This callback shouldn't be used to update the slider [values] (use + /// [onChanged] for that). Rather, it should be used to be notified when the + /// user has started selecting a new value by starting a drag or with a tap. + /// + /// The values passed will be the last [values] that the slider had before the + /// change began. + /// + /// {@tool snippet} + /// + /// ```dart + /// RangeSlider( + /// values: _rangeValues, + /// min: 1.0, + /// max: 10.0, + /// onChanged: (RangeValues newValues) { + /// setState(() { + /// _rangeValues = newValues; + /// }); + /// }, + /// onChangeStart: (RangeValues startValues) { + /// print('Started change at $startValues'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeEnd] for a callback that is called when the value change is + /// complete. + final ValueChanged<RangeValues>? onChangeStart; + + /// Called when the user is done selecting new values for the slider. + /// + /// This differs from [onChanged] because it is only called once at the end + /// of the interaction, while [onChanged] is called as the value is getting + /// updated within the interaction. + /// + /// This callback shouldn't be used to update the slider [values] (use + /// [onChanged] for that). Rather, it should be used to know when the user has + /// completed selecting a new [values] by ending a drag or a click. + /// + /// {@tool snippet} + /// + /// ```dart + /// RangeSlider( + /// values: _rangeValues, + /// min: 1.0, + /// max: 10.0, + /// onChanged: (RangeValues newValues) { + /// setState(() { + /// _rangeValues = newValues; + /// }); + /// }, + /// onChangeEnd: (RangeValues endValues) { + /// print('Ended change at $endValues'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when a value change + /// begins. + final ValueChanged<RangeValues>? onChangeEnd; + + /// The minimum value the user can select. + /// + /// Defaults to 0.0. Must be less than or equal to [max]. + /// + /// If the [max] is equal to the [min], then the slider is disabled. + final double min; + + /// The maximum value the user can select. + /// + /// Defaults to 1.0. Must be greater than or equal to [min]. + /// + /// If the [max] is equal to the [min], then the slider is disabled. + final double max; + + /// The number of discrete divisions. + /// + /// Typically used with [labels] to show the current discrete values. + /// + /// If null, the slider is continuous. + final int? divisions; + + /// Labels to show as text in the [SliderThemeData.rangeValueIndicatorShape] + /// when the slider is active and [SliderThemeData.showValueIndicator] + /// is satisfied. + /// + /// There are two labels: one for the start thumb and one for the end thumb. + /// + /// Each label is rendered using the active [ThemeData]'s + /// [TextTheme.bodyLarge] text style, with the theme data's + /// [ColorScheme.onPrimary] color. The label's text style can be overridden + /// with [SliderThemeData.valueIndicatorTextStyle]. + /// + /// If null, then the value indicator will not be displayed. + /// + /// See also: + /// + /// * [RangeSliderValueIndicatorShape] for how to create a custom value + /// indicator shape. + final RangeLabels? labels; + + /// The color of the track's active segment, i.e. the span of track between + /// the thumbs. + /// + /// Defaults to [ColorScheme.primary]. + /// + /// Using a [SliderTheme] gives more fine-grained control over the + /// appearance of various components of the slider. + final Color? activeColor; + + /// The color of the track's inactive segments, i.e. the span of tracks + /// between the min and the start thumb, and the end thumb and the max. + /// + /// If null, [SliderThemeData.inactiveTrackColor] of the ambient [SliderTheme] + /// is used. If [RangeSlider.year2023] is false and [ThemeData.useMaterial3] is true, + /// then [ColorScheme.secondaryContainer] is used. Otherwise, [ColorScheme.primary] + /// with an opacity of 0.24 is used. + /// + /// Using a [SliderTheme] gives more fine-grained control over the + /// appearance of various components of the slider. + final Color? inactiveColor; + + /// The highlight color that's typically used to indicate that + /// the range slider thumb is hovered or dragged. + /// + /// If this property is null, [RangeSlider] will use [activeColor] with + /// an opacity of 0.12. If null, [SliderThemeData.overlayColor] + /// will be used, otherwise defaults to [ColorScheme.primary] with + /// an opacity of 0.12. + final WidgetStateProperty<Color?>? overlayColor; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If null, then the value of [SliderThemeData.mouseCursor] is used. If that + /// is also null, then [WidgetStateMouseCursor.clickable] is used. + /// + /// See also: + /// + /// * [WidgetStateMouseCursor], which can be used to create a [MouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// The callback used to create a semantic value from the slider's values. + /// + /// Defaults to formatting values as a percentage. + /// + /// This is used by accessibility frameworks like TalkBack on Android to + /// inform users what the currently selected value is with more context. + /// + /// {@tool snippet} + /// + /// In the example below, a slider for currency values is configured to + /// announce a value with a currency label. + /// + /// ```dart + /// RangeSlider( + /// values: _dollarsRange, + /// min: 20.0, + /// max: 330.0, + /// onChanged: (RangeValues newValues) { + /// setState(() { + /// _dollarsRange = newValues; + /// }); + /// }, + /// semanticFormatterCallback: (double newValue) { + /// return '${newValue.round()} dollars'; + /// } + /// ) + /// ``` + /// {@end-tool} + final SemanticFormatterCallback? semanticFormatterCallback; + + /// Determines the padding around the [RangeSlider]. + /// + /// If specified, this padding overrides the vertical padding and the + /// horizontal padding of the [RangeSlider]. By default, the vertical padding + /// is the height of the overlay shape, and the horizontal padding is the + /// larger size between the width of the thumb shape and overlay shape. + final EdgeInsetsGeometry? padding; + + /// When true, the [RangeSlider] will use the 2023 Material Design 3 appearance. + /// Defaults to true. + /// + /// If this is set to false, the [RangeSlider] will use the latest Material Design 3 + /// appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 range slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + final bool? year2023; + + // Touch width for the tap boundary of the slider thumbs. + static const double _minTouchTargetWidth = kMinInteractiveDimension; + + @override + State<RangeSlider> createState() => _RangeSliderState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('valueStart', values.start)); + properties.add(DoubleProperty('valueEnd', values.end)); + properties.add( + ObjectFlagProperty<ValueChanged<RangeValues>>('onChanged', onChanged, ifNull: 'disabled'), + ); + properties.add( + ObjectFlagProperty<ValueChanged<RangeValues>>.has('onChangeStart', onChangeStart), + ); + properties.add(ObjectFlagProperty<ValueChanged<RangeValues>>.has('onChangeEnd', onChangeEnd)); + properties.add(DoubleProperty('min', min)); + properties.add(DoubleProperty('max', max)); + properties.add(IntProperty('divisions', divisions)); + properties.add(StringProperty('labelStart', labels?.start)); + properties.add(StringProperty('labelEnd', labels?.end)); + properties.add(ColorProperty('activeColor', activeColor)); + properties.add(ColorProperty('inactiveColor', inactiveColor)); + properties.add( + ObjectFlagProperty<ValueChanged<double>>.has( + 'semanticFormatterCallback', + semanticFormatterCallback, + ), + ); + } +} + +class _RangeSliderState extends State<RangeSlider> with TickerProviderStateMixin { + static const Duration enableAnimationDuration = Duration(milliseconds: 75); + static const Duration valueIndicatorAnimationDuration = Duration(milliseconds: 100); + final FocusNode startFocusNode = FocusNode(); + final FocusNode endFocusNode = FocusNode(); + + // Animation controller that is run when the overlay (a.k.a radial reaction) + // changes visibility in response to user interaction. + late AnimationController overlayController; + + // Animation controller that is run when the value indicators change visibility. + late AnimationController valueIndicatorController; + + // Animation controller that is run when enabling/disabling the slider. + late AnimationController enableController; + + // Animation controllers that are run when transitioning between one value + // and the next on a discrete slider. + late AnimationController startPositionController; + late AnimationController endPositionController; + Timer? interactionTimer; + // Value Indicator paint Animation that appears on the Overlay. + PaintRangeValueIndicator? paintTopValueIndicator; + PaintRangeValueIndicator? paintBottomValueIndicator; + + bool get _enabled => widget.onChanged != null; + + bool _dragging = false; + + bool _hovering = false; + bool _showHoverHighlight = false; + void _handleHoverChanged(bool hovering) { + if (hovering != _hovering) { + setState(() { + _hovering = hovering; + _showHoverHighlight = hovering && _enabled; + }); + } + } + + // Always keep the ValueIndicator visible on the Overlay; otherwise, it cannot be updated during the build phase. + final OverlayPortalController _valueIndicatorOverlayPortalController = OverlayPortalController( + debugLabel: 'RangeSlider ValueIndicator', + )..show(); + + @override + void initState() { + super.initState(); + overlayController = AnimationController(duration: kRadialReactionDuration, vsync: this); + valueIndicatorController = AnimationController( + duration: valueIndicatorAnimationDuration, + vsync: this, + ); + enableController = AnimationController( + duration: enableAnimationDuration, + vsync: this, + value: _enabled ? 1.0 : 0.0, + ); + startPositionController = AnimationController( + duration: Duration.zero, + vsync: this, + value: _unlerp(widget.values.start), + ); + endPositionController = AnimationController( + duration: Duration.zero, + vsync: this, + value: _unlerp(widget.values.end), + ); + } + + @override + void didUpdateWidget(RangeSlider oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.onChanged == widget.onChanged) { + return; + } + final wasEnabled = oldWidget.onChanged != null; + final bool isEnabled = _enabled; + if (wasEnabled != isEnabled) { + if (isEnabled) { + enableController.forward(); + } else { + enableController.reverse(); + } + _showHoverHighlight = _hovering && isEnabled; + } + } + + @override + void dispose() { + interactionTimer?.cancel(); + overlayController.dispose(); + valueIndicatorController.dispose(); + enableController.dispose(); + startPositionController.dispose(); + endPositionController.dispose(); + startFocusNode.dispose(); + endFocusNode.dispose(); + super.dispose(); + } + + void _handleChanged(RangeValues values) { + assert(_enabled); + final RangeValues lerpValues = _lerpRangeValues(values); + if (lerpValues != widget.values) { + widget.onChanged!(lerpValues); + } + } + + void _handleDragStart(RangeValues values) { + setState(() { + _dragging = true; + }); + widget.onChangeStart?.call(_lerpRangeValues(values)); + } + + void _handleDragEnd(RangeValues values) { + setState(() { + _dragging = false; + }); + widget.onChangeEnd?.call(_lerpRangeValues(values)); + } + + // Returns a number between min and max, proportional to value, which must + // be between 0.0 and 1.0. + double _lerp(double value) => ui.lerpDouble(widget.min, widget.max, value)!; + + // Returns a new range value with the start and end lerped. + RangeValues _lerpRangeValues(RangeValues values) { + return RangeValues(_lerp(values.start), _lerp(values.end)); + } + + // Returns a number between 0.0 and 1.0, given a value between min and max. + double _unlerp(double value) { + assert(value <= widget.max); + assert(value >= widget.min); + return widget.max > widget.min ? (value - widget.min) / (widget.max - widget.min) : 0.0; + } + + // Returns a new range value with the start and end unlerped. + RangeValues _unlerpRangeValues(RangeValues values) { + return RangeValues(_unlerp(values.start), _unlerp(values.end)); + } + + // Finds the closest thumb. If both thumbs are close to each other and within + // the touch radius, neither is selected immediately while the drag + // displacement is zero. The first non-zero displacement determines which + // thumb is selected: a negative displacement selects the left thumb, + // a positive one selects the right thumb. + // If only one or zero thumbs are within the touch radius, + // the closest one is selected. + Thumb? _defaultRangeThumbSelector( + TextDirection textDirection, + RangeValues values, + double tapValue, + Size thumbSize, + Size trackSize, + double dx, // The horizontal delta or displacement of the drag update. + ) { + final double touchRadius = math.max(thumbSize.width, RangeSlider._minTouchTargetWidth) / 2; + final bool inStartTouchTarget = (tapValue - values.start).abs() * trackSize.width < touchRadius; + final bool inEndTouchTarget = (tapValue - values.end).abs() * trackSize.width < touchRadius; + + // Use dx if the thumb touch targets overlap. If dx is 0 and the drag + // position is in both touch targets, no thumb is selected because it is + // ambiguous to which thumb should be selected. If the dx is non-zero, the + // thumb selection is determined by the direction of the dx. The left thumb + // is chosen for negative dx, and the right thumb is chosen for positive dx. + if (inStartTouchTarget && inEndTouchTarget) { + final (bool towardsStart, bool towardsEnd) = switch (textDirection) { + TextDirection.ltr => (dx < 0, dx > 0), + TextDirection.rtl => (dx > 0, dx < 0), + }; + if (towardsStart) { + return Thumb.start; + } + if (towardsEnd) { + return Thumb.end; + } + } else { + // Choose the closest thumb and snap position. + if (tapValue * 2 < values.start + values.end) { + return Thumb.start; + } else { + return Thumb.end; + } + } + return null; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMediaQuery(context)); + + final ThemeData theme = Theme.of(context); + SliderThemeData sliderTheme = SliderTheme.of(context); + final bool year2023 = widget.year2023 ?? sliderTheme.year2023 ?? true; + final SliderThemeData defaults = theme.useMaterial3 && !year2023 + ? _RangeSliderDefaultsM3(context) + : _RangeSliderDefaultsM2(context); + + // If the widget has active or inactive colors specified, then we plug them + // in to the slider theme as best we can. If the developer wants more + // control than that, then they need to use a SliderTheme. The default + // colors come from the ThemeData.colorScheme. These colors, along with + // the default shapes and text styles are aligned to the Material + // Guidelines. + + final states = <WidgetState>{ + if (!_enabled) WidgetState.disabled, + if (_hovering) WidgetState.hovered, + if (_dragging) WidgetState.dragged, + }; + + // The value indicator's color is not the same as the thumb and active track + // (which can be defined by activeColor) if the + // RectangularSliderValueIndicatorShape is used. In all other cases, the + // value indicator is assumed to be the same as the active color. + final RangeSliderValueIndicatorShape valueIndicatorShape = + sliderTheme.rangeValueIndicatorShape ?? defaults.rangeValueIndicatorShape!; + final Color valueIndicatorColor; + if (valueIndicatorShape is RectangularRangeSliderValueIndicatorShape) { + valueIndicatorColor = + sliderTheme.valueIndicatorColor ?? + Color.alphaBlend( + theme.colorScheme.onSurface.withOpacity(0.60), + theme.colorScheme.surface.withOpacity(0.90), + ); + } else { + valueIndicatorColor = + widget.activeColor ?? sliderTheme.valueIndicatorColor ?? defaults.valueIndicatorColor!; + } + + Color? effectiveOverlayColor() { + return widget.overlayColor?.resolve(states) ?? + widget.activeColor?.withOpacity(0.12) ?? + WidgetStateProperty.resolveAs<Color?>(sliderTheme.overlayColor, states) ?? + defaults.overlayColor; + } + + sliderTheme = sliderTheme.copyWith( + trackHeight: sliderTheme.trackHeight ?? defaults.trackHeight, + activeTrackColor: + widget.activeColor ?? sliderTheme.activeTrackColor ?? defaults.activeTrackColor, + inactiveTrackColor: + widget.inactiveColor ?? sliderTheme.inactiveTrackColor ?? defaults.inactiveTrackColor, + disabledActiveTrackColor: + sliderTheme.disabledActiveTrackColor ?? defaults.disabledActiveTrackColor, + disabledInactiveTrackColor: + sliderTheme.disabledInactiveTrackColor ?? defaults.disabledInactiveTrackColor, + activeTickMarkColor: + widget.inactiveColor ?? sliderTheme.activeTickMarkColor ?? defaults.activeTickMarkColor, + inactiveTickMarkColor: + widget.activeColor ?? sliderTheme.inactiveTickMarkColor ?? defaults.inactiveTickMarkColor, + disabledActiveTickMarkColor: + sliderTheme.disabledActiveTickMarkColor ?? defaults.disabledActiveTickMarkColor, + disabledInactiveTickMarkColor: + sliderTheme.disabledInactiveTickMarkColor ?? defaults.disabledInactiveTickMarkColor, + thumbColor: widget.activeColor ?? sliderTheme.thumbColor ?? defaults.thumbColor, + overlappingShapeStrokeColor: + sliderTheme.overlappingShapeStrokeColor ?? defaults.overlappingShapeStrokeColor, + disabledThumbColor: sliderTheme.disabledThumbColor ?? defaults.disabledThumbColor, + overlayColor: effectiveOverlayColor(), + valueIndicatorColor: valueIndicatorColor, + rangeTrackShape: sliderTheme.rangeTrackShape ?? defaults.rangeTrackShape, + rangeTickMarkShape: sliderTheme.rangeTickMarkShape ?? defaults.rangeTickMarkShape, + rangeThumbShape: sliderTheme.rangeThumbShape ?? defaults.rangeThumbShape, + overlayShape: sliderTheme.overlayShape ?? defaults.overlayShape, + rangeValueIndicatorShape: valueIndicatorShape, + showValueIndicator: sliderTheme.showValueIndicator ?? defaults.showValueIndicator, + valueIndicatorTextStyle: + sliderTheme.valueIndicatorTextStyle ?? defaults.valueIndicatorTextStyle, + minThumbSeparation: sliderTheme.minThumbSeparation ?? defaults.minThumbSeparation, + thumbSelector: sliderTheme.thumbSelector ?? _defaultRangeThumbSelector, + padding: widget.padding ?? sliderTheme.padding, + thumbSize: sliderTheme.thumbSize ?? defaults.thumbSize, + trackGap: sliderTheme.trackGap ?? defaults.trackGap, + ); + final MouseCursor effectiveMouseCursor = + widget.mouseCursor?.resolve(states) ?? + sliderTheme.mouseCursor?.resolve(states) ?? + WidgetStateMouseCursor.clickable.resolve(states); + + // This size is used as the max bounds for the painting of the value + // indicators. It must be kept in sync with the function with the same name + // in slider.dart. + Size screenSize() => MediaQuery.sizeOf(context); + + final double fontSize = sliderTheme.valueIndicatorTextStyle?.fontSize ?? kDefaultFontSize; + final double fontSizeToScale = fontSize == 0.0 ? kDefaultFontSize : fontSize; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(fontSizeToScale) / fontSizeToScale; + + Widget result = CompositedTransformTarget( + link: _layerLink, + child: OverlayPortal( + controller: _valueIndicatorOverlayPortalController, + overlayChildBuilder: (BuildContext context) { + return _buildValueIndicator(sliderTheme.showValueIndicator!); + }, + child: _RangeSliderRenderObjectWidget( + values: _unlerpRangeValues(widget.values), + divisions: widget.divisions, + labels: widget.labels, + sliderTheme: sliderTheme, + textScaleFactor: effectiveTextScale, + screenSize: screenSize(), + onChanged: _enabled && (widget.max > widget.min) ? _handleChanged : null, + onChangeStart: _handleDragStart, + onChangeEnd: _handleDragEnd, + state: this, + semanticFormatterCallback: widget.semanticFormatterCallback, + hovering: _showHoverHighlight, + ), + ), + ); + + final EdgeInsetsGeometry? padding = widget.padding ?? sliderTheme.padding; + if (padding != null) { + result = Padding(padding: padding, child: result); + } + + return Stack( + children: <Widget>[ + // Adds two invisible focus nodes to the range slider for its two thumbs. + Row( + children: <Widget>[ + Focus( + focusNode: startFocusNode, + includeSemantics: false, + child: const SizedBox.shrink(), + ), + Focus(focusNode: endFocusNode, includeSemantics: false, child: const SizedBox.shrink()), + ], + ), + MouseRegion( + onEnter: (_) => _handleHoverChanged(true), + onExit: (_) => _handleHoverChanged(false), + cursor: effectiveMouseCursor, + child: result, + ), + ], + ); + } + + final LayerLink _layerLink = LayerLink(); + Widget _buildValueIndicator(ShowValueIndicator showValueIndicator) { + final Widget valueIndicator = CompositedTransformFollower( + link: _layerLink, + child: _ValueIndicatorRenderObjectWidget(state: this), + ); + return switch (showValueIndicator) { + ShowValueIndicator.never => const SizedBox.shrink(), + ShowValueIndicator.onlyForDiscrete => + widget.divisions != null ? valueIndicator : const SizedBox.shrink(), + ShowValueIndicator.onlyForContinuous => + widget.divisions == null ? valueIndicator : const SizedBox.shrink(), + ShowValueIndicator.alwaysVisible || + ShowValueIndicator.always || + ShowValueIndicator.onDrag => valueIndicator, + }; + } +} + +class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { + const _RangeSliderRenderObjectWidget({ + required this.values, + required this.divisions, + required this.labels, + required this.sliderTheme, + required this.textScaleFactor, + required this.screenSize, + required this.onChanged, + required this.onChangeStart, + required this.onChangeEnd, + required this.state, + required this.semanticFormatterCallback, + required this.hovering, + }); + + final RangeValues values; + final int? divisions; + final RangeLabels? labels; + final SliderThemeData sliderTheme; + final double textScaleFactor; + final Size screenSize; + final ValueChanged<RangeValues>? onChanged; + final ValueChanged<RangeValues>? onChangeStart; + final ValueChanged<RangeValues>? onChangeEnd; + final SemanticFormatterCallback? semanticFormatterCallback; + final _RangeSliderState state; + final bool hovering; + + @override + _RenderRangeSlider createRenderObject(BuildContext context) { + return _RenderRangeSlider( + values: values, + divisions: divisions, + labels: labels, + sliderTheme: sliderTheme, + theme: Theme.of(context), + textScaleFactor: textScaleFactor, + screenSize: screenSize, + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + state: state, + textDirection: Directionality.of(context), + semanticFormatterCallback: semanticFormatterCallback, + platform: Theme.of(context).platform, + hovering: hovering, + gestureSettings: MediaQuery.gestureSettingsOf(context), + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderRangeSlider renderObject) { + renderObject + // We should update the `divisions` ahead of `values`, because the `values` + // setter dependent on the `divisions`. + ..divisions = divisions + ..values = values + ..labels = labels + ..sliderTheme = sliderTheme + ..theme = Theme.of(context) + ..textScaleFactor = textScaleFactor + ..screenSize = screenSize + ..onChanged = onChanged + ..onChangeStart = onChangeStart + ..onChangeEnd = onChangeEnd + ..textDirection = Directionality.of(context) + ..semanticFormatterCallback = semanticFormatterCallback + ..platform = Theme.of(context).platform + ..hovering = hovering + ..gestureSettings = MediaQuery.gestureSettingsOf(context); + } +} + +class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderRangeSlider({ + required RangeValues values, + required int? divisions, + required RangeLabels? labels, + required SliderThemeData sliderTheme, + required ThemeData? theme, + required double textScaleFactor, + required Size screenSize, + required TargetPlatform platform, + required ValueChanged<RangeValues>? onChanged, + required SemanticFormatterCallback? semanticFormatterCallback, + required this.onChangeStart, + required this.onChangeEnd, + required _RangeSliderState state, + required TextDirection textDirection, + required bool hovering, + required DeviceGestureSettings gestureSettings, + }) : assert(values.start >= 0.0 && values.start <= 1.0), + assert(values.end >= 0.0 && values.end <= 1.0), + _platform = platform, + _semanticFormatterCallback = semanticFormatterCallback, + _labels = labels, + _values = values, + _divisions = divisions, + _sliderTheme = sliderTheme, + _theme = theme, + _textScaleFactor = textScaleFactor, + _screenSize = screenSize, + _onChanged = onChanged, + _state = state, + _textDirection = textDirection, + _hovering = hovering { + _updateLabelPainters(); + final team = GestureArenaTeam(); + _drag = HorizontalDragGestureRecognizer() + ..team = team + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..onCancel = _handleDragCancel + ..gestureSettings = gestureSettings; + _tap = TapGestureRecognizer() + ..team = team + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..gestureSettings = gestureSettings; + _overlayAnimation = CurvedAnimation( + parent: _state.overlayController, + curve: Curves.fastOutSlowIn, + ); + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + _enableAnimation = CurvedAnimation(parent: _state.enableController, curve: Curves.easeInOut); + } + + // Keep track of the last selected thumb so they can be drawn in the + // right order. + Thumb? _lastThumbSelection; + + static const Duration _positionAnimationDuration = Duration(milliseconds: 75); + + // This value is the touch target, 48, multiplied by 3. + static const double _minPreferredTrackWidth = 144.0; + + // Compute the largest width and height needed to paint the slider shapes, + // other than the track shape. It is assumed that these shapes are vertically + // centered on the track. + double get _maxSliderPartWidth => + _sliderPartSizes.map((Size size) => size.width).reduce(math.max); + double get _maxSliderPartHeight => + _sliderPartSizes.map((Size size) => size.height).reduce(math.max); + double get _thumbSizeHeight => + _sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete).height; + double get _overlayHeight => + _sliderTheme.overlayShape!.getPreferredSize(isEnabled, isDiscrete).height; + List<Size> get _sliderPartSizes => <Size>[ + Size( + _sliderTheme.overlayShape!.getPreferredSize(isEnabled, isDiscrete).width, + _sliderTheme.padding != null ? _thumbSizeHeight : _overlayHeight, + ), + _sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete), + _sliderTheme.rangeTickMarkShape!.getPreferredSize( + isEnabled: isEnabled, + sliderTheme: sliderTheme, + ), + ]; + double? get _minPreferredTrackHeight => _sliderTheme.trackHeight; + + // This rect is used in gesture calculations, where the gesture coordinates + // are relative to the sliders origin. Therefore, the offset is passed as + // (0,0). + Rect get _trackRect => _sliderTheme.rangeTrackShape!.getPreferredRect( + parentBox: this, + sliderTheme: _sliderTheme, + isDiscrete: false, + ); + + static const Duration _minimumInteractionTime = Duration(milliseconds: 500); + + final _RangeSliderState _state; + late CurvedAnimation _overlayAnimation; + late CurvedAnimation _valueIndicatorAnimation; + late CurvedAnimation _enableAnimation; + final TextPainter _startLabelPainter = TextPainter(); + final TextPainter _endLabelPainter = TextPainter(); + late HorizontalDragGestureRecognizer _drag; + late TapGestureRecognizer _tap; + bool _active = false; + late RangeValues _newValues; + Offset _startThumbCenter = Offset.zero; + Offset _endThumbCenter = Offset.zero; + Rect? overlayStartRect; + Rect? overlayEndRect; + + bool get isEnabled => onChanged != null; + + bool get isDiscrete => divisions != null && divisions! > 0; + + double get _minThumbSeparationValue => + isDiscrete ? 0 : sliderTheme.minThumbSeparation! / _trackRect.width; + + RangeValues get values => _values; + RangeValues _values; + set values(RangeValues newValues) { + assert(newValues.start >= 0.0 && newValues.start <= 1.0); + assert(newValues.end >= 0.0 && newValues.end <= 1.0); + assert(newValues.start <= newValues.end); + final RangeValues convertedValues = isDiscrete ? _discretizeRangeValues(newValues) : newValues; + if (convertedValues == _values) { + return; + } + _values = convertedValues; + if (isDiscrete) { + // Reset the duration to match the distance that we're traveling, so that + // whatever the distance, we still do it in _positionAnimationDuration, + // and if we get re-targeted in the middle, it still takes that long to + // get to the new location. + final double startDistance = (_values.start - _state.startPositionController.value).abs(); + _state.startPositionController.duration = startDistance != 0.0 + ? _positionAnimationDuration * (1.0 / startDistance) + : Duration.zero; + _state.startPositionController.animateTo(_values.start, curve: Curves.easeInOut); + final double endDistance = (_values.end - _state.endPositionController.value).abs(); + _state.endPositionController.duration = endDistance != 0.0 + ? _positionAnimationDuration * (1.0 / endDistance) + : Duration.zero; + _state.endPositionController.animateTo(_values.end, curve: Curves.easeInOut); + } else { + _state.startPositionController.value = convertedValues.start; + _state.endPositionController.value = convertedValues.end; + } + markNeedsSemanticsUpdate(); + } + + TargetPlatform _platform; + TargetPlatform get platform => _platform; + set platform(TargetPlatform value) { + if (_platform == value) { + return; + } + _platform = value; + markNeedsSemanticsUpdate(); + } + + DeviceGestureSettings? get gestureSettings => _drag.gestureSettings; + set gestureSettings(DeviceGestureSettings? gestureSettings) { + _drag.gestureSettings = gestureSettings; + _tap.gestureSettings = gestureSettings; + } + + SemanticFormatterCallback? _semanticFormatterCallback; + SemanticFormatterCallback? get semanticFormatterCallback => _semanticFormatterCallback; + set semanticFormatterCallback(SemanticFormatterCallback? value) { + if (_semanticFormatterCallback == value) { + return; + } + _semanticFormatterCallback = value; + markNeedsSemanticsUpdate(); + } + + int? get divisions => _divisions; + int? _divisions; + set divisions(int? value) { + if (value == _divisions) { + return; + } + _divisions = value; + markNeedsPaint(); + } + + RangeLabels? get labels => _labels; + RangeLabels? _labels; + set labels(RangeLabels? labels) { + if (labels == _labels) { + return; + } + _labels = labels; + _updateLabelPainters(); + } + + SliderThemeData get sliderTheme => _sliderTheme; + SliderThemeData _sliderTheme; + set sliderTheme(SliderThemeData value) { + if (value == _sliderTheme) { + return; + } + _sliderTheme = value; + markNeedsPaint(); + } + + ThemeData? get theme => _theme; + ThemeData? _theme; + set theme(ThemeData? value) { + if (value == _theme) { + return; + } + _theme = value; + markNeedsPaint(); + } + + double get textScaleFactor => _textScaleFactor; + double _textScaleFactor; + set textScaleFactor(double value) { + if (value == _textScaleFactor) { + return; + } + _textScaleFactor = value; + _updateLabelPainters(); + } + + Size get screenSize => _screenSize; + Size _screenSize; + set screenSize(Size value) { + if (value == screenSize) { + return; + } + _screenSize = value; + markNeedsPaint(); + } + + ValueChanged<RangeValues>? get onChanged => _onChanged; + ValueChanged<RangeValues>? _onChanged; + set onChanged(ValueChanged<RangeValues>? value) { + if (value == _onChanged) { + return; + } + final bool wasEnabled = isEnabled; + _onChanged = value; + if (wasEnabled != isEnabled) { + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + } + + ValueChanged<RangeValues>? onChangeStart; + ValueChanged<RangeValues>? onChangeEnd; + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + _updateLabelPainters(); + } + + /// True if this slider is being hovered over by a pointer. + bool get hovering => _hovering; + bool _hovering; + set hovering(bool value) { + if (value == _hovering) { + return; + } + _hovering = value; + _updateForHover(_hovering); + } + + /// True if the slider is interactive and the start thumb is being + /// hovered over by a pointer. + bool _hoveringStartThumb = false; + bool get hoveringStartThumb => _hoveringStartThumb; + set hoveringStartThumb(bool value) { + if (value == _hoveringStartThumb) { + return; + } + _hoveringStartThumb = value; + _updateForHover(_hovering); + } + + /// True if the slider is interactive and the end thumb is being + /// hovered over by a pointer. + bool _hoveringEndThumb = false; + bool get hoveringEndThumb => _hoveringEndThumb; + set hoveringEndThumb(bool value) { + if (value == _hoveringEndThumb) { + return; + } + _hoveringEndThumb = value; + _updateForHover(_hovering); + } + + void _updateForHover(bool hovered) { + // Only show overlay when pointer is hovering the thumb. + if (hovered && (hoveringStartThumb || hoveringEndThumb)) { + _state.overlayController.forward(); + } else { + _state.overlayController.reverse(); + } + } + + bool get shouldAlwaysShowValueIndicator => + _sliderTheme.showValueIndicator == ShowValueIndicator.alwaysVisible; + bool get shouldShowValueIndicatorWhenDragged => switch (_sliderTheme.showValueIndicator!) { + ShowValueIndicator.onlyForDiscrete => isDiscrete, + ShowValueIndicator.onlyForContinuous => !isDiscrete, + ShowValueIndicator.alwaysVisible || + ShowValueIndicator.always || + ShowValueIndicator.onDrag => true, + ShowValueIndicator.never => false, + }; + + Size get _thumbSize => _sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete); + + double get _adjustmentUnit { + switch (_platform) { + case TargetPlatform.iOS: + // Matches iOS implementation of material slider. + return 0.1; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Matches Android implementation of material slider. + return 0.05; + } + } + + void _updateLabelPainters() { + _updateLabelPainter(Thumb.start); + _updateLabelPainter(Thumb.end); + } + + void _updateLabelPainter(Thumb thumb) { + final RangeLabels? labels = this.labels; + if (labels == null) { + return; + } + + final (String text, TextPainter labelPainter) = switch (thumb) { + Thumb.start => (labels.start, _startLabelPainter), + Thumb.end => (labels.end, _endLabelPainter), + }; + + labelPainter + ..text = TextSpan(style: _sliderTheme.valueIndicatorTextStyle, text: text) + ..textDirection = textDirection + ..textScaleFactor = textScaleFactor + ..layout(); + // Changing the textDirection can result in the layout changing, because the + // bidi algorithm might line up the glyphs differently which can result in + // different ligatures, different shapes, etc. So we always markNeedsLayout. + markNeedsLayout(); + } + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + _startLabelPainter.markNeedsLayout(); + _endLabelPainter.markNeedsLayout(); + _updateLabelPainters(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _overlayAnimation.addListener(markNeedsPaint); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _enableAnimation.addListener(markNeedsPaint); + _state.startPositionController.addListener(markNeedsPaint); + _state.endPositionController.addListener(markNeedsPaint); + _state.startFocusNode.addListener(markNeedsPaint); + _state.startFocusNode.addListener(markNeedsSemanticsUpdate); + _state.endFocusNode.addListener(markNeedsPaint); + _state.endFocusNode.addListener(markNeedsSemanticsUpdate); + } + + @override + void detach() { + _overlayAnimation.removeListener(markNeedsPaint); + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _enableAnimation.removeListener(markNeedsPaint); + _state.startPositionController.removeListener(markNeedsPaint); + _state.endPositionController.removeListener(markNeedsPaint); + _state.startFocusNode.removeListener(markNeedsPaint); + _state.startFocusNode.removeListener(markNeedsSemanticsUpdate); + _state.endFocusNode.removeListener(markNeedsPaint); + _state.endFocusNode.removeListener(markNeedsSemanticsUpdate); + super.detach(); + } + + @override + void dispose() { + _drag.dispose(); + _tap.dispose(); + _startLabelPainter.dispose(); + _endLabelPainter.dispose(); + _enableAnimation.dispose(); + _valueIndicatorAnimation.dispose(); + _overlayAnimation.dispose(); + super.dispose(); + } + + double _getValueFromVisualPosition(double visualPosition) { + return switch (textDirection) { + TextDirection.rtl => 1.0 - visualPosition, + TextDirection.ltr => visualPosition, + }; + } + + double _getValueFromGlobalPosition(Offset globalPosition) { + final double visualPosition = + (globalToLocal(globalPosition).dx - _trackRect.left) / _trackRect.width; + return _getValueFromVisualPosition(visualPosition); + } + + double _discretize(double value) { + double result = clampDouble(value, 0.0, 1.0); + if (isDiscrete) { + result = (result * divisions!).round() / divisions!; + } + return result; + } + + RangeValues _discretizeRangeValues(RangeValues values) { + return RangeValues(_discretize(values.start), _discretize(values.end)); + } + + void _startInteraction(Offset globalPosition) { + if (_active) { + return; + } + + final double tapValue = clampDouble(_getValueFromGlobalPosition(globalPosition), 0.0, 1.0); + _lastThumbSelection = sliderTheme.thumbSelector!( + textDirection, + values, + tapValue, + _thumbSize, + size, + 0, + ); + + if (_lastThumbSelection != null) { + switch (_lastThumbSelection!) { + case Thumb.start: + _state.startFocusNode.requestFocus(); + case Thumb.end: + _state.endFocusNode.requestFocus(); + } + + _active = true; + // We supply the *current* values as the start locations, so that if we have + // a tap, it consists of a call to onChangeStart with the previous value and + // a call to onChangeEnd with the new value. + final RangeValues currentValues = _discretizeRangeValues(values); + _newValues = switch (_lastThumbSelection!) { + Thumb.start => RangeValues(tapValue, currentValues.end), + Thumb.end => RangeValues(currentValues.start, tapValue), + }; + _updateLabelPainter(_lastThumbSelection!); + + onChangeStart?.call(currentValues); + + onChanged!(_discretizeRangeValues(_newValues)); + + _state.overlayController.forward(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.forward(); + _state.interactionTimer?.cancel(); + _state.interactionTimer = Timer(_minimumInteractionTime * timeDilation, () { + _state.interactionTimer = null; + if (!_active && _state.valueIndicatorController.isCompleted) { + _state.valueIndicatorController.reverse(); + } + }); + } + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_state.mounted) { + return; + } + + final double dragValue = _getValueFromGlobalPosition(details.globalPosition); + + // If no selection has been made yet, test for thumb selection again now + // that the value of dx can be non-zero. If this is the first selection of + // the interaction, then onChangeStart must be called. + var shouldCallOnChangeStart = false; + if (_lastThumbSelection == null) { + _lastThumbSelection = sliderTheme.thumbSelector!( + textDirection, + values, + dragValue, + _thumbSize, + size, + details.delta.dx, + ); + if (_lastThumbSelection != null) { + shouldCallOnChangeStart = true; + _active = true; + _state.overlayController.forward(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.forward(); + } + } + } + + if (isEnabled && _lastThumbSelection != null) { + final RangeValues currentValues = _discretizeRangeValues(values); + if (onChangeStart != null && shouldCallOnChangeStart) { + onChangeStart!(currentValues); + } + final double currentDragValue = _discretize(dragValue); + + _newValues = switch (_lastThumbSelection!) { + Thumb.start => RangeValues( + math.min(currentDragValue, currentValues.end - _minThumbSeparationValue), + currentValues.end, + ), + Thumb.end => RangeValues( + currentValues.start, + math.max(currentDragValue, currentValues.start + _minThumbSeparationValue), + ), + }; + onChanged!(_discretizeRangeValues(_newValues)); + } + } + + void _endInteraction() { + if (!_state.mounted) { + return; + } + + if (shouldShowValueIndicatorWhenDragged && _state.interactionTimer == null) { + _state.valueIndicatorController.reverse(); + } + + if (_active && _state.mounted && _lastThumbSelection != null) { + final RangeValues discreteValues = _discretizeRangeValues(_newValues); + onChangeEnd?.call(discreteValues); + _active = false; + } + _state.overlayController.reverse(); + } + + void _handleDragStart(DragStartDetails details) { + _startInteraction(details.globalPosition); + } + + void _handleDragEnd(DragEndDetails details) { + _endInteraction(); + } + + void _handleDragCancel() { + _endInteraction(); + } + + void _handleTapDown(TapDownDetails details) { + _startInteraction(details.globalPosition); + } + + void _handleTapUp(TapUpDetails details) { + _endInteraction(); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + void handleEvent(PointerEvent event, HitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent && isEnabled) { + // We need to add the drag first so that it has priority. + _drag.addPointer(event); + _tap.addPointer(event); + } + if (isEnabled) { + if (overlayStartRect != null) { + hoveringStartThumb = overlayStartRect!.contains(event.localPosition); + } + if (overlayEndRect != null) { + hoveringEndThumb = overlayEndRect!.contains(event.localPosition); + } + } + } + + @override + double computeMinIntrinsicWidth(double height) => _minPreferredTrackWidth + _maxSliderPartWidth; + + @override + double computeMaxIntrinsicWidth(double height) => _minPreferredTrackWidth + _maxSliderPartWidth; + + @override + double computeMinIntrinsicHeight(double width) => + math.max(_minPreferredTrackHeight!, _maxSliderPartHeight); + + @override + double computeMaxIntrinsicHeight(double width) => + math.max(_minPreferredTrackHeight!, _maxSliderPartHeight); + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return Size( + constraints.hasBoundedWidth + ? constraints.maxWidth + : _minPreferredTrackWidth + _maxSliderPartWidth, + constraints.hasBoundedHeight + ? constraints.maxHeight + : math.max(_minPreferredTrackHeight!, _maxSliderPartHeight), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final double startValue = _state.startPositionController.value; + final double endValue = _state.endPositionController.value; + + // The visual position is the position of the thumb from 0 to 1 from left + // to right. In left to right, this is the same as the value, but it is + // reversed for right to left text. + final (double startVisualPosition, double endVisualPosition) = switch (textDirection) { + TextDirection.rtl => (1.0 - startValue, 1.0 - endValue), + TextDirection.ltr => (startValue, endValue), + }; + + final Rect trackRect = _sliderTheme.rangeTrackShape!.getPreferredRect( + parentBox: this, + offset: offset, + sliderTheme: _sliderTheme, + isDiscrete: isDiscrete, + ); + final double padding = _sliderTheme.rangeTrackShape!.isRounded ? trackRect.height : 0.0; + final double thumbYOffset = trackRect.center.dy; + final double startThumbPosition = isDiscrete + ? trackRect.left + startVisualPosition * (trackRect.width - padding) + padding / 2 + : trackRect.left + startVisualPosition * trackRect.width; + final double endThumbPosition = isDiscrete + ? trackRect.left + endVisualPosition * (trackRect.width - padding) + padding / 2 + : trackRect.left + endVisualPosition * trackRect.width; + final Size thumbPreferredSize = _sliderTheme.rangeThumbShape!.getPreferredSize( + isEnabled, + isDiscrete, + ); + final double thumbPadding = (padding > thumbPreferredSize.width / 2 ? padding / 2 : 0); + _startThumbCenter = Offset( + clampDouble( + startThumbPosition, + trackRect.left + thumbPadding, + trackRect.right - thumbPadding, + ), + thumbYOffset, + ); + _endThumbCenter = Offset( + clampDouble(endThumbPosition, trackRect.left + thumbPadding, trackRect.right - thumbPadding), + thumbYOffset, + ); + if (isEnabled) { + final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isEnabled, false); + overlayStartRect = Rect.fromCircle( + center: _startThumbCenter, + radius: overlaySize.width / 2.0, + ); + overlayEndRect = Rect.fromCircle(center: _endThumbCenter, radius: overlaySize.width / 2.0); + } + + // If [RangeSlider.year2023] is false, the thumbs uses handle thumb shape and gapped track shape. + // The handle width and track gaps are adjusted when the thumb is pressed. + double? thumbWidth = _sliderTheme.thumbSize?.resolve(<WidgetState>{})?.width; + final double? thumbHeight = _sliderTheme.thumbSize?.resolve(<WidgetState>{})?.height; + double? trackGap = _sliderTheme.trackGap; + final double? pressedThumbWidth = _sliderTheme.thumbSize?.resolve(<WidgetState>{ + WidgetState.pressed, + })?.width; + final double delta; + if (_active && thumbWidth != null && pressedThumbWidth != null && trackGap != null) { + delta = thumbWidth - pressedThumbWidth; + thumbWidth = pressedThumbWidth; + if (trackGap > 0.0) { + trackGap = trackGap - delta / 2; + } + } + + _sliderTheme.rangeTrackShape!.paint( + context, + offset, + parentBox: this, + sliderTheme: _sliderTheme.copyWith(trackGap: trackGap), + enableAnimation: _enableAnimation, + textDirection: _textDirection, + startThumbCenter: _startThumbCenter, + endThumbCenter: _endThumbCenter, + isDiscrete: isDiscrete, + isEnabled: isEnabled, + ); + + final bool startThumbSelected = _lastThumbSelection == Thumb.start && !hoveringEndThumb; + final bool endThumbSelected = _lastThumbSelection == Thumb.end && !hoveringStartThumb; + final Size resolvedscreenSize = screenSize.isEmpty ? size : screenSize; + + if (_state.startFocusNode.hasFocus) { + _sliderTheme.overlayShape!.paint( + context, + _startThumbCenter, + activationAnimation: const AlwaysStoppedAnimation<double>(1.0), + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _startLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: startValue, + textScaleFactor: _textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + } + + if (_state.endFocusNode.hasFocus) { + _sliderTheme.overlayShape!.paint( + context, + _endThumbCenter, + activationAnimation: const AlwaysStoppedAnimation<double>(1.0), + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _endLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: endValue, + textScaleFactor: _textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + } + + if (!_overlayAnimation.isDismissed) { + if (startThumbSelected || hoveringStartThumb) { + _sliderTheme.overlayShape!.paint( + context, + _startThumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _startLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: startValue, + textScaleFactor: _textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + } + if (endThumbSelected || hoveringEndThumb) { + _sliderTheme.overlayShape!.paint( + context, + _endThumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _endLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: endValue, + textScaleFactor: _textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + } + } + + if (isDiscrete) { + final double tickMarkWidth = _sliderTheme.rangeTickMarkShape! + .getPreferredSize(isEnabled: isEnabled, sliderTheme: _sliderTheme) + .width; + final double discreteTrackPadding = trackRect.height; + final double adjustedTrackWidth = trackRect.width - discreteTrackPadding; + // If the tick marks would be too dense, don't bother painting them. + if (adjustedTrackWidth / divisions! >= 3.0 * tickMarkWidth) { + final double dy = trackRect.center.dy; + for (var i = 0; i <= divisions!; i++) { + final double value = i / divisions!; + // The ticks are mapped to be within the track, so the tick mark width + // must be subtracted from the track width. + final double dx = trackRect.left + value * adjustedTrackWidth + discreteTrackPadding / 2; + final tickMarkOffset = Offset(dx, dy); + _sliderTheme.rangeTickMarkShape!.paint( + context, + tickMarkOffset, + parentBox: this, + sliderTheme: _sliderTheme, + enableAnimation: _enableAnimation, + textDirection: _textDirection, + startThumbCenter: _startThumbCenter, + endThumbCenter: _endThumbCenter, + isEnabled: isEnabled, + ); + } + } + } + + final double thumbDelta = (_endThumbCenter.dx - _startThumbCenter.dx).abs(); + + final isLastThumbStart = _lastThumbSelection == Thumb.start; + final Thumb bottomThumb = isLastThumbStart ? Thumb.end : Thumb.start; + final Thumb topThumb = isLastThumbStart ? Thumb.start : Thumb.end; + final Offset bottomThumbCenter = isLastThumbStart ? _endThumbCenter : _startThumbCenter; + final Offset topThumbCenter = isLastThumbStart ? _startThumbCenter : _endThumbCenter; + final TextPainter bottomLabelPainter = isLastThumbStart ? _endLabelPainter : _startLabelPainter; + final TextPainter topLabelPainter = isLastThumbStart ? _startLabelPainter : _endLabelPainter; + final bottomValue = isLastThumbStart ? endValue : startValue; + final topValue = isLastThumbStart ? startValue : endValue; + final bool shouldPaintValueIndicators = + isEnabled && + labels != null && + ((shouldShowValueIndicatorWhenDragged && !_valueIndicatorAnimation.isDismissed) || + shouldAlwaysShowValueIndicator); + + if (shouldPaintValueIndicators) { + _state.paintBottomValueIndicator = (PaintingContext context, Offset offset) { + if (attached) { + _sliderTheme.rangeValueIndicatorShape!.paint( + context, + bottomThumbCenter, + activationAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation<double>(1) + : _valueIndicatorAnimation, + enableAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation<double>(1) + : _enableAnimation, + isDiscrete: isDiscrete, + isOnTop: false, + labelPainter: bottomLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + thumb: bottomThumb, + value: bottomValue, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + } + }; + } + + _sliderTheme.rangeThumbShape!.paint( + context, + bottomThumbCenter, + activationAnimation: _valueIndicatorAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + isOnTop: false, + textDirection: textDirection, + sliderTheme: thumbWidth != null && thumbHeight != null + ? _sliderTheme.copyWith( + thumbSize: WidgetStatePropertyAll<Size?>(Size(thumbWidth, thumbHeight)), + ) + : _sliderTheme, + thumb: bottomThumb, + isPressed: bottomThumb == Thumb.start ? startThumbSelected : endThumbSelected, + ); + + if (shouldPaintValueIndicators) { + final double startOffset = sliderTheme.rangeValueIndicatorShape!.getHorizontalShift( + parentBox: this, + center: _startThumbCenter, + labelPainter: _startLabelPainter, + activationAnimation: _valueIndicatorAnimation, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + final double endOffset = sliderTheme.rangeValueIndicatorShape!.getHorizontalShift( + parentBox: this, + center: _endThumbCenter, + labelPainter: _endLabelPainter, + activationAnimation: _valueIndicatorAnimation, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + final double startHalfWidth = + sliderTheme.rangeValueIndicatorShape! + .getPreferredSize( + isEnabled, + isDiscrete, + labelPainter: _startLabelPainter, + textScaleFactor: textScaleFactor, + ) + .width / + 2; + final double endHalfWidth = + sliderTheme.rangeValueIndicatorShape! + .getPreferredSize( + isEnabled, + isDiscrete, + labelPainter: _endLabelPainter, + textScaleFactor: textScaleFactor, + ) + .width / + 2; + final double innerOverflow = + startHalfWidth + + endHalfWidth + + switch (textDirection) { + TextDirection.ltr => startOffset - endOffset, + TextDirection.rtl => endOffset - startOffset, + }; + + _state.paintTopValueIndicator = (PaintingContext context, Offset offset) { + if (attached) { + _sliderTheme.rangeValueIndicatorShape!.paint( + context, + topThumbCenter, + activationAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation<double>(1) + : _valueIndicatorAnimation, + enableAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation<double>(1) + : _enableAnimation, + isDiscrete: isDiscrete, + isOnTop: thumbDelta < innerOverflow, + labelPainter: topLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + thumb: topThumb, + value: topValue, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + } + }; + } + + _sliderTheme.rangeThumbShape!.paint( + context, + topThumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + isOnTop: + thumbDelta < sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete).width, + textDirection: textDirection, + sliderTheme: thumbWidth != null && thumbHeight != null + ? _sliderTheme.copyWith( + thumbSize: WidgetStatePropertyAll<Size?>(Size(thumbWidth, thumbHeight)), + ) + : _sliderTheme, + thumb: topThumb, + isPressed: topThumb == Thumb.start ? startThumbSelected : endThumbSelected, + ); + } + + /// Describe the semantics of the start thumb. + SemanticsNode? _startSemanticsNode; + + /// Describe the semantics of the end thumb. + SemanticsNode? _endSemanticsNode; + + // Create the semantics configuration for a single value. + SemanticsConfiguration _createSemanticsConfiguration( + double value, + double increasedValue, + double decreasedValue, + VoidCallback increaseAction, + VoidCallback decreaseAction, { + required bool focused, + }) { + final config = SemanticsConfiguration(); + config.isEnabled = isEnabled; + config.textDirection = textDirection; + config.isSlider = true; + config.isFocusable = true; + config.isFocused = focused; + if (isEnabled) { + config.onIncrease = increaseAction; + config.onDecrease = decreaseAction; + } + + if (semanticFormatterCallback != null) { + config.value = semanticFormatterCallback!(_state._lerp(value)); + config.increasedValue = semanticFormatterCallback!(_state._lerp(increasedValue)); + config.decreasedValue = semanticFormatterCallback!(_state._lerp(decreasedValue)); + } else { + config.value = '${(value * 100).round()}%'; + config.increasedValue = '${(increasedValue * 100).round()}%'; + config.decreasedValue = '${(decreasedValue * 100).round()}%'; + } + + return config; + } + + @override + void assembleSemanticsNode( + SemanticsNode node, + SemanticsConfiguration config, + Iterable<SemanticsNode> children, + ) { + assert(children.isEmpty); + + final SemanticsConfiguration startSemanticsConfiguration = _createSemanticsConfiguration( + values.start, + _increasedStartValue, + _decreasedStartValue, + _increaseStartAction, + _decreaseStartAction, + focused: _state.startFocusNode.hasFocus, + ); + final SemanticsConfiguration endSemanticsConfiguration = _createSemanticsConfiguration( + values.end, + _increasedEndValue, + _decreasedEndValue, + _increaseEndAction, + _decreaseEndAction, + focused: _state.endFocusNode.hasFocus, + ); + + // Split the semantics node area between the start and end nodes. + final leftRect = Rect.fromCenter( + center: _startThumbCenter, + width: kMinInteractiveDimension, + height: kMinInteractiveDimension, + ); + final rightRect = Rect.fromCenter( + center: _endThumbCenter, + width: kMinInteractiveDimension, + height: kMinInteractiveDimension, + ); + + _startSemanticsNode ??= SemanticsNode(); + _endSemanticsNode ??= SemanticsNode(); + + switch (textDirection) { + case TextDirection.ltr: + _startSemanticsNode!.rect = leftRect; + _endSemanticsNode!.rect = rightRect; + case TextDirection.rtl: + _startSemanticsNode!.rect = rightRect; + _endSemanticsNode!.rect = leftRect; + } + + _startSemanticsNode!.updateWith(config: startSemanticsConfiguration); + _endSemanticsNode!.updateWith(config: endSemanticsConfiguration); + + final finalChildren = <SemanticsNode>[_startSemanticsNode!, _endSemanticsNode!]; + + node.updateWith(config: config, childrenInInversePaintOrder: finalChildren); + } + + @override + void clearSemantics() { + super.clearSemantics(); + _startSemanticsNode = null; + _endSemanticsNode = null; + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + config.isSemanticBoundary = true; + } + + double get _semanticActionUnit => divisions != null ? 1.0 / divisions! : _adjustmentUnit; + + void _increaseStartAction() { + if (isEnabled) { + onChanged!(RangeValues(_increasedStartValue, values.end)); + } + } + + void _decreaseStartAction() { + if (isEnabled) { + onChanged!(RangeValues(_decreasedStartValue, values.end)); + } + } + + void _increaseEndAction() { + if (isEnabled) { + onChanged!(RangeValues(values.start, _increasedEndValue)); + } + } + + void _decreaseEndAction() { + if (isEnabled) { + onChanged!(RangeValues(values.start, _decreasedEndValue)); + } + } + + double get _increasedStartValue { + // Due to floating-point operations, this value can actually be greater than + // expected (e.g. 0.4 + 0.2 = 0.600000000001), so we limit to 2 decimal points. + final double increasedStartValue = double.parse( + (values.start + _semanticActionUnit).toStringAsFixed(2), + ); + return increasedStartValue <= values.end - _minThumbSeparationValue + ? increasedStartValue + : values.start; + } + + double get _decreasedStartValue { + return clampDouble(values.start - _semanticActionUnit, 0.0, 1.0); + } + + double get _increasedEndValue { + return clampDouble(values.end + _semanticActionUnit, 0.0, 1.0); + } + + double get _decreasedEndValue { + final double decreasedEndValue = values.end - _semanticActionUnit; + return decreasedEndValue >= values.start + _minThumbSeparationValue + ? decreasedEndValue + : values.end; + } +} + +class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { + const _ValueIndicatorRenderObjectWidget({required this.state}); + + final _RangeSliderState state; + + @override + _RenderValueIndicator createRenderObject(BuildContext context) { + return _RenderValueIndicator(state: state); + } + + @override + void updateRenderObject(BuildContext context, _RenderValueIndicator renderObject) { + renderObject._state = state; + } +} + +class _RenderValueIndicator extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderValueIndicator({required _RangeSliderState state}) : _state = state { + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + } + + late CurvedAnimation _valueIndicatorAnimation; + late _RangeSliderState _state; + + @override + bool get sizedByParent => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _state.startPositionController.addListener(markNeedsPaint); + _state.endPositionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _state.startPositionController.removeListener(markNeedsPaint); + _state.endPositionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _state.paintBottomValueIndicator?.call(context, offset); + _state.paintTopValueIndicator?.call(context, offset); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.smallest; + } + + @override + void dispose() { + _valueIndicatorAnimation.dispose(); + super.dispose(); + } +} + +class _RangeSliderDefaultsM2 extends SliderThemeData { + _RangeSliderDefaultsM2(this.context) : super(trackHeight: 4); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final SliderThemeData sliderTheme = SliderTheme.of(context); + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.primary.withOpacity(0.24); + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.32); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.54); + + @override + Color? get inactiveTickMarkColor => _colors.primary.withOpacity(0.54); + + @override + Color? get disabledActiveTickMarkColor => _colors.onPrimary.withOpacity(0.12); + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get thumbColor => _colors.primary; + + @override + ui.Color? get overlappingShapeStrokeColor => _colors.surface; + + @override + Color? get disabledThumbColor => + Color.alphaBlend(_colors.onSurface.withOpacity(.38), _colors.surface); + + @override + Color? get overlayColor => _colors.primary.withOpacity(0.12); + + @override + TextStyle? get valueIndicatorTextStyle => + Theme.of(context).textTheme.bodyLarge!.copyWith(color: _colors.onPrimary); + + @override + Color? get valueIndicatorColor => _colors.primary; + + @override + RangeSliderTrackShape? get rangeTrackShape => const RoundedRectRangeSliderTrackShape(); + + @override + RangeSliderTickMarkShape? get rangeTickMarkShape => const RoundRangeSliderTickMarkShape(); + + @override + RangeSliderThumbShape? get rangeThumbShape => const RoundRangeSliderThumbShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + RangeSliderValueIndicatorShape? get rangeValueIndicatorShape => + const RectangularRangeSliderValueIndicatorShape(); + + @override + ShowValueIndicator? get showValueIndicator => ShowValueIndicator.onlyForDiscrete; + + @override + double? get minThumbSeparation => 8; +} + +// BEGIN GENERATED TOKEN PROPERTIES - RangeSlider + +// 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. + +// dart format off +class _RangeSliderDefaultsM3 extends SliderThemeData { + _RangeSliderDefaultsM3(this.context) + : super(trackHeight: 16.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.secondaryContainer; + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(1.0); + + @override + Color? get inactiveTickMarkColor => _colors.onSecondaryContainer.withOpacity(1.0); + + @override + Color? get disabledActiveTickMarkColor => _colors.onInverseSurface; + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface; + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get overlappingShapeStrokeColor => _colors.surface; + + @override + Color? get disabledThumbColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get overlayColor => _colors.primary.withOpacity(0.12); + + @override + TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelLarge!.copyWith( + color: _colors.onInverseSurface, + ); + + @override + Color? get valueIndicatorColor => _colors.inverseSurface; + + @override + RangeSliderTrackShape? get rangeTrackShape => const GappedRangeSliderTrackShape(); + + @override + RangeSliderTickMarkShape? get rangeTickMarkShape => const RoundRangeSliderTickMarkShape(tickMarkRadius: 4.0 / 2); + + @override + RangeSliderThumbShape? get rangeThumbShape => const HandleRangeSliderThumbShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + RangeSliderValueIndicatorShape? get rangeValueIndicatorShape => const RoundedRectRangeSliderValueIndicatorShape(); + + @override + ShowValueIndicator? get showValueIndicator => ShowValueIndicator.onlyForDiscrete; + + @override + double? get minThumbSeparation => 0; + + @override + WidgetStateProperty<Size?>? get thumbSize { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const Size(4.0, 44.0); + } + if (states.contains(WidgetState.hovered)) { + return const Size(4.0, 44.0); + } + if (states.contains(WidgetState.focused)) { + return const Size(2.0, 44.0); + } + if (states.contains(WidgetState.pressed)) { + return const Size(2.0, 44.0); + } + return const Size(4.0, 44.0); + }); + } + + @override + double? get trackGap => 6.0; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - RangeSlider diff --git a/packages/material_ui/lib/src/range_slider_parts.dart b/packages/material_ui/lib/src/range_slider_parts.dart new file mode 100644 index 000000000000..cf618bd3d2f3 --- /dev/null +++ b/packages/material_ui/lib/src/range_slider_parts.dart @@ -0,0 +1,1645 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'range_slider.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'slider.dart'; +import 'slider_theme.dart'; +import 'slider_value_indicator_shape.dart'; + +/// Base class for [RangeSlider] thumb shapes. +/// +/// See also: +/// +/// * [RoundRangeSliderThumbShape] for the default [RangeSlider]'s thumb shape +/// that paints a solid circle. +/// * [RangeSliderTickMarkShape], which can be used to create custom shapes for +/// the [RangeSlider]'s tick marks. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [RangeSliderValueIndicatorShape], which can be used to create custom +/// shapes for the [RangeSlider]'s value indicator. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +abstract class RangeSliderThumbShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const RangeSliderThumbShape(); + + /// Returns the preferred size of the shape, based on the given conditions. + /// + /// {@template flutter.material.RangeSliderThumbShape.getPreferredSize.isDiscrete} + /// The `isDiscrete` argument is true if [RangeSlider.divisions] is non-null. + /// When true, the slider will render tick marks on top of the track. + /// {@endtemplate} + /// + /// {@template flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + /// The `isEnabled` argument is false when [RangeSlider.onChanged] is null and + /// true otherwise. When true, the slider will respond to input. + /// {@endtemplate} + Size getPreferredSize(bool isEnabled, bool isDiscrete); + + /// Paints the thumb shape based on the state passed to it. + /// + /// {@template flutter.material.RangeSliderThumbShape.paint.context} + /// The `context` argument represents the [RangeSlider]'s render box. + /// {@endtemplate} + /// + /// {@macro flutter.material.SliderComponentShape.paint.center} + /// + /// {@template flutter.material.RangeSliderThumbShape.paint.activationAnimation} + /// The `activationAnimation` argument is an animation triggered when the user + /// begins to interact with the [RangeSlider]. It reverses when the user stops + /// interacting with the slider. + /// {@endtemplate} + /// + /// {@template flutter.material.RangeSliderThumbShape.paint.enableAnimation} + /// The `enableAnimation` argument is an animation triggered when the + /// [RangeSlider] is enabled, and it reverses when the slider is disabled. The + /// [RangeSlider] is enabled when [RangeSlider.onChanged] is not null. Use + /// this to paint intermediate frames for this shape when the slider changes + /// enabled state. + /// {@endtemplate} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isDiscrete} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + /// + /// If the `isOnTop` argument is true, this thumb is painted on top of the + /// other slider thumb because this thumb is the one that was most recently + /// selected. + /// + /// {@template flutter.material.RangeSliderThumbShape.paint.sliderTheme} + /// The `sliderTheme` argument is the theme assigned to the [RangeSlider] that + /// this shape belongs to. + /// {@endtemplate} + /// + /// The `textDirection` argument can be used to determine how the orientation + /// of either slider thumb should be changed, such as drawing different + /// shapes for the left and right thumb. + /// + /// {@template flutter.material.RangeSliderThumbShape.paint.thumb} + /// The `thumb` argument is the specifier for which of the two thumbs this + /// method should paint (start or end). + /// {@endtemplate} + /// + /// The `isPressed` argument can be used to give the selected thumb + /// additional selected or pressed state visual feedback, such as a larger + /// shadow. + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool isDiscrete, + bool isEnabled, + bool isOnTop, + TextDirection textDirection, + required SliderThemeData sliderTheme, + Thumb thumb, + bool isPressed, + }); +} + +/// Base class for [RangeSlider] value indicator shapes. +/// +/// See also: +/// +/// * [PaddleRangeSliderValueIndicatorShape] for the default [RangeSlider]'s +/// value indicator shape that paints a custom path with text in it. +/// * [RangeSliderTickMarkShape], which can be used to create custom shapes for +/// the [RangeSlider]'s tick marks. +/// * [RangeSliderThumbShape], which can be used to create custom shapes for +/// the [RangeSlider]'s thumb. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +abstract class RangeSliderValueIndicatorShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const RangeSliderValueIndicatorShape(); + + /// Returns the preferred size of the shape, based on the given conditions. + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isDiscrete} + /// + /// The `labelPainter` argument helps determine the width of the shape. It is + /// variable width because it is derived from a formatted string. + /// + /// {@macro flutter.material.SliderComponentShape.paint.textScaleFactor} + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + required TextPainter labelPainter, + required double textScaleFactor, + }); + + /// Determines the best offset to keep this shape on the screen. + /// + /// Override this method when the center of the value indicator should be + /// shifted from the vertical center of the thumb. + double getHorizontalShift({ + RenderBox? parentBox, + Offset? center, + TextPainter? labelPainter, + Animation<double>? activationAnimation, + double? textScaleFactor, + Size? sizeWithOverflow, + }) { + return 0; + } + + /// Paints the value indicator shape based on the state passed to it. + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.context} + /// + /// {@macro flutter.material.SliderComponentShape.paint.center} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.activationAnimation} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.enableAnimation} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isDiscrete} + /// + /// The `isOnTop` argument is the top-most value indicator between the two value + /// indicators, which is always the indicator for the most recently selected thumb. In + /// the default case, this is used to paint a stroke around the top indicator + /// for better visibility between the two indicators. + /// + /// {@macro flutter.material.SliderComponentShape.paint.textScaleFactor} + /// + /// {@macro flutter.material.SliderComponentShape.paint.sizeWithOverflow} + /// + /// {@template flutter.material.RangeSliderValueIndicatorShape.paint.parentBox} + /// The `parentBox` argument is the [RenderBox] of the [RangeSlider]. Its + /// attributes, such as size, can be used to assist in painting this shape. + /// {@endtemplate} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.sliderTheme} + /// + /// The `textDirection` argument can be used to determine how any extra text + /// or graphics, besides the text painted by the [labelPainter] should be + /// positioned. The `labelPainter` argument already has the `textDirection` + /// set. + /// + /// The `value` argument is the current parametric value (from 0.0 to 1.0) of + /// the slider. + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.thumb} + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool isDiscrete, + bool isOnTop, + required TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + TextDirection textDirection, + double value, + Thumb thumb, + }); +} + +/// Base class for [RangeSlider] tick mark shapes. +/// +/// This is a simplified version of [SliderComponentShape] with a +/// [SliderThemeData] passed when getting the preferred size. +/// +/// See also: +/// +/// * [RoundRangeSliderTickMarkShape] for the default [RangeSlider]'s tick mark +/// shape that paints a solid circle. +/// * [RangeSliderThumbShape], which can be used to create custom shapes for +/// the [RangeSlider]'s thumb. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [RangeSliderValueIndicatorShape], which can be used to create custom +/// shapes for the [RangeSlider]'s value indicator. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +abstract class RangeSliderTickMarkShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const RangeSliderTickMarkShape(); + + /// Returns the preferred size of the shape. + /// + /// It is used to help position the tick marks within the slider. + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.sliderTheme} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + Size getPreferredSize({required SliderThemeData sliderTheme, bool isEnabled}); + + /// Paints the slider track. + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.context} + /// + /// {@macro flutter.material.SliderComponentShape.paint.center} + /// + /// {@macro flutter.material.RangeSliderValueIndicatorShape.paint.parentBox} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.sliderTheme} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.enableAnimation} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + /// + /// The `textDirection` argument can be used to determine how the tick marks + /// are painted depending on whether they are on an active track segment or not. + /// + /// {@template flutter.material.RangeSliderTickMarkShape.paint.trackSegment} + /// The track segment between the two thumbs is the active track segment. The + /// track segments between the thumb and each end of the slider are the inactive + /// track segments. In [TextDirection.ltr], the start of the slider is on the + /// left, and in [TextDirection.rtl], the start of the slider is on the right. + /// {@endtemplate} + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset startThumbCenter, + required Offset endThumbCenter, + bool isEnabled, + required TextDirection textDirection, + }); +} + +/// Base class for [RangeSlider] track shapes. +/// +/// The slider's thumbs move along the track. A discrete slider's tick marks +/// are drawn after the track, but before the thumb, and are aligned with the +/// track. +/// +/// The [getPreferredRect] helps position the slider thumbs and tick marks +/// relative to the track. +/// +/// See also: +/// +/// * [RoundedRectRangeSliderTrackShape] for the default [RangeSlider]'s track +/// shape that paints a stadium-like track. +/// * [RangeSliderTickMarkShape], which can be used to create custom shapes for +/// the [RangeSlider]'s tick marks. +/// * [RangeSliderThumbShape], which can be used to create custom shapes for +/// the [RangeSlider]'s thumb. +/// * [RangeSliderValueIndicatorShape], which can be used to create custom +/// shapes for the [RangeSlider]'s value indicator. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +abstract class RangeSliderTrackShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const RangeSliderTrackShape(); + + /// Returns the preferred bounds of the shape. + /// + /// It is used to provide horizontal boundaries for the position of the + /// thumbs, and to help position the slider thumbs and tick marks relative to + /// the track. + /// + /// The `parentBox` argument can be used to help determine the preferredRect + /// relative to attributes of the render box of the slider itself, such as + /// size. + /// + /// The `offset` argument is relative to the caller's bounding box. It can be + /// used to convert gesture coordinates from global to slider-relative + /// coordinates. + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.sliderTheme} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isDiscrete} + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled, + bool isDiscrete, + }); + + /// Paints the track shape based on the state passed to it. + /// + /// {@macro flutter.material.SliderComponentShape.paint.context} + /// + /// The `offset` argument is the offset of the origin of the `parentBox` to + /// the origin of its `context` canvas. This shape must be painted relative + /// to this offset. See [PaintingContextCallback]. + /// + /// {@macro flutter.material.RangeSliderValueIndicatorShape.paint.parentBox} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.sliderTheme} + /// + /// {@macro flutter.material.RangeSliderThumbShape.paint.enableAnimation} + /// + /// The `startThumbCenter` argument is the offset of the center of the start + /// thumb relative to the origin of the [PaintingContext.canvas]. It can be + /// used as one point that divides the track between inactive and active. + /// + /// The `endThumbCenter` argument is the offset of the center of the end + /// thumb relative to the origin of the [PaintingContext.canvas]. It can be + /// used as one point that divides the track between inactive and active. + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isEnabled} + /// + /// {@macro flutter.material.RangeSliderThumbShape.getPreferredSize.isDiscrete} + /// + /// The `textDirection` argument can be used to determine how the track + /// segments are painted depending on whether they are on an active track + /// segment or not. + /// + /// {@macro flutter.material.RangeSliderTickMarkShape.paint.trackSegment} + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset startThumbCenter, + required Offset endThumbCenter, + bool isEnabled = false, + bool isDiscrete = false, + required TextDirection textDirection, + }); + + /// Whether the track shape is rounded. This is used to determine the correct + /// position of the thumbs in relation to the track. Defaults to false. + bool get isRounded => false; +} + +/// Base range slider track shape that provides an implementation of [getPreferredRect] for +/// default sizing. +/// +/// The height is set from [SliderThemeData.trackHeight] and the width of the +/// parent box less the larger of the widths of [SliderThemeData.rangeThumbShape] and +/// [SliderThemeData.overlayShape]. +/// +/// See also: +/// +/// * [RectangularRangeSliderTrackShape], which is a track shape with sharp +/// rectangular edges +mixin BaseRangeSliderTrackShape { + /// Returns a rect that represents the track bounds that fits within the + /// [Slider]. + /// + /// The width is the width of the [RangeSlider], but padded by the max + /// of the overlay and thumb radius. The height is defined by the [SliderThemeData.trackHeight]. + /// + /// The [Rect] is centered both horizontally and vertically within the slider + /// bounds. + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + assert(sliderTheme.rangeThumbShape != null); + assert(sliderTheme.overlayShape != null); + assert(sliderTheme.trackHeight != null); + final Size thumbSize = sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete); + final double overlayWidth = sliderTheme.overlayShape! + .getPreferredSize(isEnabled, isDiscrete) + .width; + double trackHeight = sliderTheme.trackHeight!; + assert(overlayWidth >= 0); + assert(trackHeight >= 0); + + // If the track colors are transparent, then override only the track height + // to maintain overall Slider width. + if (sliderTheme.activeTrackColor == Colors.transparent && + sliderTheme.inactiveTrackColor == Colors.transparent) { + trackHeight = 0; + } + + final double trackLeft = + offset.dx + + (sliderTheme.padding == null + ? math.max(overlayWidth / 2, thumbSize.width / 2) + : (thumbSize.width / 2)); + final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; + final double trackRight = + trackLeft + + parentBox.size.width - + (sliderTheme.padding == null ? math.max(thumbSize.width, overlayWidth) : thumbSize.width); + final double trackBottom = trackTop + trackHeight; + // If the parentBox's size less than slider's size the trackRight will be less than trackLeft, so switch them. + return Rect.fromLTRB( + math.min(trackLeft, trackRight), + trackTop, + math.max(trackLeft, trackRight), + trackBottom, + ); + } +} + +/// A [RangeSlider] track that's a simple rectangle. +/// +/// It paints a solid colored rectangle, vertically centered in the +/// `parentBox`. The track rectangle extends to the bounds of the `parentBox`, +/// but is padded by the [RoundSliderOverlayShape] radius. The height is +/// defined by the [SliderThemeData.trackHeight]. The color is determined by the +/// [Slider]'s enabled state and the track segment's active state which are +/// defined by: +/// [SliderThemeData.activeTrackColor], +/// [SliderThemeData.inactiveTrackColor], +/// [SliderThemeData.disabledActiveTrackColor], +/// [SliderThemeData.disabledInactiveTrackColor]. +/// +/// {@macro flutter.material.RangeSliderTickMarkShape.paint.trackSegment} +/// +/// ![A range slider widget, consisting of 5 divisions and showing the rectangular range slider track shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rectangular_range_slider_track_shape.png) +/// +/// See also: +/// +/// * [RangeSlider], for the component that is meant to display this shape. +/// * [SliderThemeData], where an instance of this class is set to inform the +/// slider of the visual details of the its track. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [RoundedRectRangeSliderTrackShape], for a similar track with rounded +/// edges. +class RectangularRangeSliderTrackShape extends RangeSliderTrackShape + with BaseRangeSliderTrackShape { + /// Create a slider track with rectangular outer edges. + /// + /// The middle track segment is the selected range and is active, and the two + /// outer track segments are inactive. + const RectangularRangeSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double>? enableAnimation, + required Offset startThumbCenter, + required Offset endThumbCenter, + bool isEnabled = false, + bool isDiscrete = false, + required TextDirection textDirection, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.rangeThumbShape != null); + assert(enableAnimation != null); + // Assign the track segment paints, which are left: active, right: inactive, + // but reversed for right to left text. + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation!)!; + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + + final (Offset leftThumbOffset, Offset rightThumbOffset) = switch (textDirection) { + TextDirection.ltr => (startThumbCenter, endThumbCenter), + TextDirection.rtl => (endThumbCenter, startThumbCenter), + }; + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final leftTrackSegment = Rect.fromLTRB( + trackRect.left, + trackRect.top, + leftThumbOffset.dx, + trackRect.bottom, + ); + if (!leftTrackSegment.isEmpty) { + context.canvas.drawRect(leftTrackSegment, inactivePaint); + } + final middleTrackSegment = Rect.fromLTRB( + leftThumbOffset.dx, + trackRect.top, + rightThumbOffset.dx, + trackRect.bottom, + ); + if (!middleTrackSegment.isEmpty) { + context.canvas.drawRect(middleTrackSegment, activePaint); + } + final rightTrackSegment = Rect.fromLTRB( + rightThumbOffset.dx, + trackRect.top, + trackRect.right, + trackRect.bottom, + ); + if (!rightTrackSegment.isEmpty) { + context.canvas.drawRect(rightTrackSegment, inactivePaint); + } + } +} + +/// The default shape of a [RangeSlider]'s track. +/// +/// It paints a solid colored rectangle with rounded edges, vertically centered +/// in the `parentBox`. The track rectangle extends to the bounds of the +/// `parentBox`, but is padded by the larger of [RoundSliderOverlayShape]'s +/// radius and [RoundRangeSliderThumbShape]'s radius. The height is defined by +/// the [SliderThemeData.trackHeight]. The color is determined by the +/// [RangeSlider]'s enabled state and the track segment's active state which are +/// defined by: +/// [SliderThemeData.activeTrackColor], +/// [SliderThemeData.inactiveTrackColor], +/// [SliderThemeData.disabledActiveTrackColor], +/// [SliderThemeData.disabledInactiveTrackColor]. +/// +/// {@macro flutter.material.RangeSliderTickMarkShape.paint.trackSegment} +/// +/// ![A range slider widget, consisting of 5 divisions and showing the rounded rect range slider track shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rounded_rect_range_slider_track_shape.png) +/// +/// See also: +/// +/// * [RangeSlider], for the component that is meant to display this shape. +/// * [SliderThemeData], where an instance of this class is set to inform the +/// slider of the visual details of the its track. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [RectangularRangeSliderTrackShape], for a similar track with sharp edges. +class RoundedRectRangeSliderTrackShape extends RangeSliderTrackShape + with BaseRangeSliderTrackShape { + /// Create a slider track with rounded outer edges. + /// + /// The middle track segment is the selected range and is active, and the two + /// outer track segments are inactive. + const RoundedRectRangeSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset startThumbCenter, + required Offset endThumbCenter, + bool isEnabled = false, + bool isDiscrete = false, + required TextDirection textDirection, + double additionalActiveTrackHeight = 2, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.rangeThumbShape != null); + + if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) { + return; + } + + // Assign the track segment paints, which are left: active, right: inactive, + // but reversed for right to left text. + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + + final (Offset leftThumbOffset, Offset rightThumbOffset) = switch (textDirection) { + TextDirection.ltr => (startThumbCenter, endThumbCenter), + TextDirection.rtl => (endThumbCenter, startThumbCenter), + }; + final Size thumbSize = sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete); + final double thumbRadius = thumbSize.width / 2; + assert(thumbRadius > 0); + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + + final trackRadius = Radius.circular(trackRect.height / 2); + + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + trackRect.left, + trackRect.top, + leftThumbOffset.dx, + trackRect.bottom, + topLeft: trackRadius, + bottomLeft: trackRadius, + ), + inactivePaint, + ); + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + rightThumbOffset.dx, + trackRect.top, + trackRect.right, + trackRect.bottom, + topRight: trackRadius, + bottomRight: trackRadius, + ), + inactivePaint, + ); + context.canvas.drawRRect( + RRect.fromLTRBR( + leftThumbOffset.dx - (sliderTheme.trackHeight! / 2), + trackRect.top - (additionalActiveTrackHeight / 2), + rightThumbOffset.dx + (sliderTheme.trackHeight! / 2), + trackRect.bottom + (additionalActiveTrackHeight / 2), + trackRadius, + ), + activePaint, + ); + } + + @override + bool get isRounded => true; +} + +/// The default shape of each [RangeSlider] tick mark. +/// +/// Tick marks are only displayed if the slider is discrete, which can be done +/// by setting the [RangeSlider.divisions] to an integer value. +/// +/// It paints a solid circle, centered on the track. +/// The color is determined by the [Slider]'s enabled state and track's active +/// states. These colors are defined in: +/// [SliderThemeData.activeTrackColor], +/// [SliderThemeData.inactiveTrackColor], +/// [SliderThemeData.disabledActiveTrackColor], +/// [SliderThemeData.disabledInactiveTrackColor]. +/// +/// ![A slider widget, consisting of 5 divisions and showing the round range slider tick mark shape.](https://flutter.github.io/assets-for-api-docs/assets/material/round_range_slider_tick_mark_shape.png) +/// +/// See also: +/// +/// * [RangeSlider], which includes tick marks defined by this shape. +/// * [SliderTheme], which can be used to configure the tick mark shape of all +/// sliders in a widget subtree. +class RoundRangeSliderTickMarkShape extends RangeSliderTickMarkShape { + /// Create a range slider tick mark that draws a circle. + const RoundRangeSliderTickMarkShape({this.tickMarkRadius}); + + /// The preferred radius of the round tick mark. + /// + /// If it is not provided, then 1/4 of the [SliderThemeData.trackHeight] is used. + final double? tickMarkRadius; + + @override + Size getPreferredSize({required SliderThemeData sliderTheme, bool isEnabled = false}) { + assert(sliderTheme.trackHeight != null); + return Size.fromRadius(tickMarkRadius ?? sliderTheme.trackHeight! / 4); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset startThumbCenter, + required Offset endThumbCenter, + bool isEnabled = false, + required TextDirection textDirection, + }) { + assert(sliderTheme.disabledActiveTickMarkColor != null); + assert(sliderTheme.disabledInactiveTickMarkColor != null); + assert(sliderTheme.activeTickMarkColor != null); + assert(sliderTheme.inactiveTickMarkColor != null); + + final bool hasGap = sliderTheme.trackGap != null && sliderTheme.trackGap! > 0; + final bool underThumb = startThumbCenter.dx == center.dx || endThumbCenter.dx == center.dx; + if (hasGap && underThumb) { + return; + } + final bool isBetweenThumbs = switch (textDirection) { + TextDirection.ltr => startThumbCenter.dx < center.dx && center.dx < endThumbCenter.dx, + TextDirection.rtl => endThumbCenter.dx < center.dx && center.dx < startThumbCenter.dx, + }; + final Color? begin = isBetweenThumbs + ? sliderTheme.disabledActiveTickMarkColor + : sliderTheme.disabledInactiveTickMarkColor; + final Color? end = isBetweenThumbs + ? sliderTheme.activeTickMarkColor + : sliderTheme.inactiveTickMarkColor; + final paint = Paint()..color = ColorTween(begin: begin, end: end).evaluate(enableAnimation)!; + + // The tick marks are tiny circles that are the same height as the track. + final double tickMarkRadius = + getPreferredSize(isEnabled: isEnabled, sliderTheme: sliderTheme).width / 2; + if (tickMarkRadius > 0) { + context.canvas.drawCircle(center, tickMarkRadius, paint); + } + } +} + +/// The default shape of a [RangeSlider]'s thumbs. +/// +/// There is a shadow for the resting and pressed state. +/// +/// ![A slider widget, consisting of 5 divisions and showing the round range slider thumb shape.](https://flutter.github.io/assets-for-api-docs/assets/material/round_range_slider_thumb_shape.png) +/// +/// See also: +/// +/// * [RangeSlider], which includes thumbs defined by this shape. +/// * [SliderTheme], which can be used to configure the thumb shapes of all +/// range sliders in a widget subtree. +class RoundRangeSliderThumbShape extends RangeSliderThumbShape { + /// Create a slider thumb that draws a circle. + const RoundRangeSliderThumbShape({ + this.enabledThumbRadius = 10.0, + this.disabledThumbRadius, + this.elevation = 1.0, + this.pressedElevation = 6.0, + }); + + /// The preferred radius of the round thumb shape when the slider is enabled. + /// + /// If it is not provided, then the Material Design default of 10 is used. + final double enabledThumbRadius; + + /// The preferred radius of the round thumb shape when the slider is disabled. + /// + /// If no disabledRadius is provided, then it is equal to the + /// [enabledThumbRadius]. + final double? disabledThumbRadius; + double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; + + /// The resting elevation adds shadow to the unpressed thumb. + /// + /// The default is 1. + final double elevation; + + /// The pressed elevation adds shadow to the pressed thumb. + /// + /// The default is 6. + final double pressedElevation; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size.fromRadius(isEnabled ? enabledThumbRadius : _disabledThumbRadius); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool isDiscrete = false, + bool isEnabled = false, + bool? isOnTop, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + Thumb? thumb, + bool? isPressed, + }) { + assert(sliderTheme.showValueIndicator != null); + assert(sliderTheme.overlappingShapeStrokeColor != null); + final Canvas canvas = context.canvas; + final radiusTween = Tween<double>(begin: _disabledThumbRadius, end: enabledThumbRadius); + final colorTween = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ); + final double radius = radiusTween.evaluate(enableAnimation); + final elevationTween = Tween<double>(begin: elevation, end: pressedElevation); + + // Add a stroke of 1dp around the circle if this thumb would overlap + // the other thumb. + if (isOnTop ?? false) { + final strokePaint = Paint() + ..color = sliderTheme.overlappingShapeStrokeColor! + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawCircle(center, radius, strokePaint); + } + + final Color color = colorTween.evaluate(enableAnimation)!; + + final double evaluatedElevation = isPressed! + ? elevationTween.evaluate(activationAnimation) + : elevation; + final shadowPath = Path() + ..addArc( + Rect.fromCenter(center: center, width: 2 * radius, height: 2 * radius), + 0, + math.pi * 2, + ); + + var paintShadows = true; + assert(() { + if (debugDisableShadows) { + _debugDrawShadow(canvas, shadowPath, evaluatedElevation); + paintShadows = false; + } + return true; + }()); + + if (paintShadows) { + canvas.drawShadow(shadowPath, Colors.black, evaluatedElevation, true); + } + + canvas.drawCircle(center, radius, Paint()..color = color); + } +} + +/// Decides which thumbs (if any) should be selected. +/// +/// The default finds the closest thumb, but if the thumbs are close to each +/// other, it waits for movement defined by [dx] to determine the selected +/// thumb. +/// +/// Override [SliderThemeData.thumbSelector] for custom thumb selection. +typedef RangeThumbSelector = + Thumb? Function( + TextDirection textDirection, + RangeValues values, + double tapValue, + Size thumbSize, + Size trackSize, + double dx, + ); + +/// Object for representing range slider thumb values. +/// +/// This object is passed into [RangeSlider.values] to set its values, and it +/// is emitted in [RangeSlider.onChanged], [RangeSlider.onChangeStart], and +/// [RangeSlider.onChangeEnd] when the values change. +@immutable +class RangeValues { + /// Creates pair of start and end values. + const RangeValues(this.start, this.end); + + /// The value of the start thumb. + /// + /// For LTR text direction, the start is the left thumb, and for RTL text + /// direction, the start is the right thumb. + final double start; + + /// The value of the end thumb. + /// + /// For LTR text direction, the end is the right thumb, and for RTL text + /// direction, the end is the left thumb. + final double end; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is RangeValues && other.start == start && other.end == end; + } + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() { + return '${objectRuntimeType(this, 'RangeValues')}($start, $end)'; + } +} + +/// Object for setting range slider label values that appear in the value +/// indicator for each thumb. +/// +/// Used in combination with [SliderThemeData.showValueIndicator] to display +/// labels above the thumbs. +@immutable +class RangeLabels { + /// Creates pair of start and end labels. + const RangeLabels(this.start, this.end); + + /// The label of the start thumb. + /// + /// For LTR text direction, the start is the left thumb, and for RTL text + /// direction, the start is the right thumb. + final String start; + + /// The label of the end thumb. + /// + /// For LTR text direction, the end is the right thumb, and for RTL text + /// direction, the end is the left thumb. + final String end; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is RangeLabels && other.start == start && other.end == end; + } + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() { + return '${objectRuntimeType(this, 'RangeLabels')}($start, $end)'; + } +} + +void _debugDrawShadow(Canvas canvas, Path path, double elevation) { + if (elevation > 0.0) { + canvas.drawPath( + path, + Paint() + ..color = Colors.black + ..style = PaintingStyle.stroke + ..strokeWidth = elevation * 2.0, + ); + } +} + +// The gapped shape of a [RangeSlider]'s track. +/// +/// The [GappedRangeSliderTrackShape] consists of active and inactive +/// tracks. The active track uses the [SliderThemeData.activeTrackColor] and the +/// inactive tracks uses the [SliderThemeData.inactiveTrackColor]. +/// +/// The track shape uses circular corner radius for the edge corners and a corner radius +/// of 2 pixels for the inside corners. +/// +/// Between the active and inactive tracks there are gaps of size [SliderThemeData.trackGap]. +/// If the [SliderThemeData.thumbShape] is [HandleRangeSliderThumbShape] and the thumb is pressed, +/// the thumb's width is reduced; as a result, the track gaps size in [GappedRangeSliderTrackShape] +/// is also reduced. +/// +/// If [SliderThemeData.trackGap] is null, then the track gaps size defaults to 6 pixels. +/// +/// If [ThemeData.useMaterial3] is true and [RangeSlider.year2023] is false, then the [RangeSlider] +/// will use [GappedRangeSliderTrackShape] as the default track shape. +/// +/// See also: +/// +/// * [RangeSlider], which includes a track defined by this shape. +/// * [SliderTheme], which can be used to configure the track shape of all +/// range sliders in a widget subtree. +class GappedRangeSliderTrackShape extends RangeSliderTrackShape with BaseRangeSliderTrackShape { + /// Create a range slider track that draws 3 rounded rectangles with rounded outer edges. + const GappedRangeSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset startThumbCenter, + required Offset endThumbCenter, + bool isEnabled = false, + bool isDiscrete = false, + required TextDirection textDirection, + double additionalActiveTrackHeight = 2, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.rangeThumbShape != null); + + if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) { + return; + } + + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + + final trackCornerRadius = Radius.circular(trackRect.shortestSide / 2); + const trackInsideCornerRadius = Radius.circular(2.0); + + final (Offset leftThumbOffset, Offset rightThumbOffset) = switch (textDirection) { + TextDirection.ltr => (startThumbCenter, endThumbCenter), + TextDirection.rtl => (endThumbCenter, startThumbCenter), + }; + + final Size thumbSize = sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete); + final double thumbRadius = thumbSize.width / 2; + assert(thumbRadius > 0); + final double trackGap = sliderTheme.trackGap!; + + final trackRRect = RRect.fromRectAndCorners( + trackRect, + topLeft: trackCornerRadius, + bottomLeft: trackCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ); + + final leftRRect = RRect.fromLTRBAndCorners( + trackRect.left, + trackRect.top, + leftThumbOffset.dx - trackGap, + trackRect.bottom, + topLeft: trackCornerRadius, + bottomLeft: trackCornerRadius, + topRight: trackInsideCornerRadius, + bottomRight: trackInsideCornerRadius, + ); + + final rightRRect = RRect.fromLTRBAndCorners( + rightThumbOffset.dx + trackGap, + trackRect.top, + trackRect.right, + trackRect.bottom, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ); + + context.canvas + ..save() + ..clipRRect(trackRRect); + final bool drawLeftTrack = + startThumbCenter.dx > (leftRRect.left + (sliderTheme.trackHeight! / 2)); + final bool drawRightTrack = + endThumbCenter.dx < (rightRRect.right - (sliderTheme.trackHeight! / 2)); + + if (drawLeftTrack) { + context.canvas.drawRRect(leftRRect, inactivePaint); + } + if (drawRightTrack) { + context.canvas.drawRRect(rightRRect, inactivePaint); + } + + if (leftThumbOffset.dx + trackGap < rightThumbOffset.dx - trackGap) { + context.canvas.drawRRect( + RRect.fromLTRBR( + leftThumbOffset.dx + trackGap, + trackRect.top, + rightThumbOffset.dx - trackGap, + trackRect.bottom, + trackInsideCornerRadius, + ), + activePaint, + ); + } + + context.canvas.restore(); + + const stopIndicatorRadius = 2.0; + final double stopIndicatorTrailingSpace = sliderTheme.trackHeight! / 2; + final startStopIndicatorOffset = Offset( + trackRect.centerLeft.dx + stopIndicatorTrailingSpace, + trackRect.center.dy, + ); + final endStopIndicatorOffset = Offset( + trackRect.centerRight.dx - stopIndicatorTrailingSpace, + trackRect.center.dy, + ); + + final bool showStartStopIndicator = startThumbCenter.dx > startStopIndicatorOffset.dx; + if (showStartStopIndicator && !isDiscrete) { + final stopIndicatorRect = Rect.fromCircle( + center: startStopIndicatorOffset, + radius: stopIndicatorRadius, + ); + context.canvas.drawCircle(stopIndicatorRect.center, stopIndicatorRadius, activePaint); + } + + final bool showEndStopIndicator = endThumbCenter.dx < endStopIndicatorOffset.dx; + if (showEndStopIndicator && !isDiscrete) { + final stopIndicatorRect = Rect.fromCircle( + center: endStopIndicatorOffset, + radius: stopIndicatorRadius, + ); + context.canvas.drawCircle(stopIndicatorRect.center, stopIndicatorRadius, activePaint); + } + } + + @override + bool get isRounded => true; +} + +/// The bar shape of [RangeSlider]'s thumbs. +/// +/// When the range slider is enabled, the [ColorScheme.primary] color is used for the +/// thumb. When the slider is disabled, the [ColorScheme.onSurface] color with an +/// opacity of 0.38 is used for the thumb. +/// +/// The thumb bar shape width is reduced when the thumb is pressed. +/// +/// If [SliderThemeData.thumbSize] is null, then the thumb size is 4 pixels for the width +/// and 44 pixels for the height. +/// +/// If [ThemeData.useMaterial3] is true and [RangeSlider.year2023] is false, then the [RangeSlider] +/// will use [HandleRangeSliderThumbShape] as the default track shape. +/// +/// See also: +/// +/// * [RangeSlider], which includes thumbs defined by this shape. +/// * [SliderTheme], which can be used to configure the thumbs shape of all +/// range sliders in a widget subtree. +class HandleRangeSliderThumbShape extends RangeSliderThumbShape { + /// Create a range slider thumb that draws a bar. + const HandleRangeSliderThumbShape(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(4.0, 44.0); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool isDiscrete = false, + bool isEnabled = false, + bool? isOnTop, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + Thumb? thumb, + bool? isPressed, + }) { + assert(sliderTheme.showValueIndicator != null); + assert(sliderTheme.overlappingShapeStrokeColor != null); + assert(sliderTheme.disabledThumbColor != null); + assert(sliderTheme.thumbColor != null); + assert(sliderTheme.thumbSize != null); + + final colorTween = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ); + final Color color = colorTween.evaluate(enableAnimation)!; + final Canvas canvas = context.canvas; + + final Size thumbSize = sliderTheme.thumbSize!.resolve( + <WidgetState>{}, + )!; // This is resolved in the paint method. + final rrect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: thumbSize.width, height: thumbSize.height), + Radius.circular(thumbSize.shortestSide / 2), + ); + + canvas.drawRRect(rrect, Paint()..color = color); + } +} + +/// The rounded rectangle shape of a [RangeSlider]'s value indicators. +/// +/// If the [SliderThemeData.valueIndicatorColor] is null, then the shape uses the [ColorScheme.inverseSurface] +/// color to draw the value indicator. +/// +/// If the [SliderThemeData.valueIndicatorTextStyle] is null, then the indicator label text style +/// defaults to [TextTheme.labelMedium] with the color set to [ColorScheme.onInverseSurface]. If the +/// [ThemeData.useMaterial3] is set to false, then the indicator label text style defaults to +/// [TextTheme.bodyLarge] with the color set to [ColorScheme.onInverseSurface]. +/// +/// If the [SliderThemeData.valueIndicatorStrokeColor] is provided, then the value indicator is drawn with a +/// stroke border with the color provided. +/// +/// If [ThemeData.useMaterial3] is true and [RangeSlider.year2023] is false, then the [RangeSlider] +/// will use [RoundedRectRangeSliderValueIndicatorShape] as the default value indicators shape. +/// +/// See also: +/// +/// * [RangeSlider], which includes value indicators defined by this shape. +/// * [SliderTheme], which can be used to configure the range slider value indicators +/// of all range sliders in a widget subtree. +class RoundedRectRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { + /// Create range slider value indicators that resembles a rounded rectangle. + const RoundedRectRangeSliderValueIndicatorShape(); + + static const _RoundedRectSliderValueIndicatorPathPainter _pathPainter = + _RoundedRectSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool? isDiscrete, + bool? isOnTop, + required TextPainter labelPainter, + double? textScaleFactor, + Size? sizeWithOverflow, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + double? value, + Thumb? thumb, + }) { + assert(textScaleFactor != null); + assert(sizeWithOverflow != null); + assert(sliderTheme.valueIndicatorColor != null); + + final Canvas canvas = context.canvas; + final double scale = activationAnimation.value; + _pathPainter.paint( + parentBox: parentBox, + canvas: canvas, + center: center, + scale: scale, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor!, + sizeWithOverflow: sizeWithOverflow!, + backgroundPaintColor: sliderTheme.valueIndicatorColor!, + strokePaintColor: isOnTop! + ? sliderTheme.overlappingShapeStrokeColor + : sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +/// The shape of a Material 3 [RangeSlider]'s value indicators. +/// +/// If the [SliderThemeData.valueIndicatorColor] is null, then the shape uses the [ColorScheme.primary] +/// color to draw the value indicator. +/// +/// If the [SliderThemeData.valueIndicatorTextStyle] is null, then the indicator label text style +/// defaults to [TextTheme.labelMedium] with the color set to [ColorScheme.onPrimary]. If the +/// [ThemeData.useMaterial3] is set to false, then the indicator label text style defaults to +/// [TextTheme.bodyLarge] with the color set to [ColorScheme.onInverseSurface]. +/// +/// If the [SliderThemeData.valueIndicatorStrokeColor] is provided, then the value indicator is drawn with a +/// stroke border with the color provided. +/// +/// See also: +/// +/// * [RangeSlider], which includes value indicators defined by this shape. +/// * [SliderTheme], which can be used to configure the range slider value indicators +/// of all range sliders in a widget subtree. +class DropRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { + /// Create a range slider value indicator that resembles a drop shape. + const DropRangeSliderValueIndicatorShape(); + + static const _DropSliderValueIndicatorPathPainter _pathPainter = + _DropSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool? isDiscrete, + bool? isOnTop, + required TextPainter labelPainter, + double? textScaleFactor, + Size? sizeWithOverflow, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + double? value, + Thumb? thumb, + }) { + final Canvas canvas = context.canvas; + final double scale = activationAnimation.value; + _pathPainter.paint( + parentBox: parentBox, + canvas: canvas, + center: center, + scale: scale, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor!, + sizeWithOverflow: sizeWithOverflow!, + backgroundPaintColor: sliderTheme.valueIndicatorColor!, + strokePaintColor: isOnTop! + ? sliderTheme.overlappingShapeStrokeColor + : sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +class _RoundedRectSliderValueIndicatorPathPainter { + const _RoundedRectSliderValueIndicatorPathPainter(); + + static const double _labelPadding = 10.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 16.0; + static const double _rectYOffset = 10.0; + static const double _bottomTipYOffset = 16.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + + Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { + final double width = + math.max(_minLabelWidth, labelPainter.width) + (_labelPadding * 2) * textScaleFactor; + return Size(width, _preferredHeight * textScaleFactor); + } + + double getHorizontalShift({ + required RenderBox parentBox, + required Offset center, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required double scale, + }) { + assert(!sizeWithOverflow.isEmpty); + + const edgePadding = 8.0; + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + + /// Value indicator draws on the Overlay and by using the global Offset + /// we are making sure we use the bounds of the Overlay instead of the Slider. + final Offset globalCenter = parentBox.localToGlobal(center); + + // The rectangle must be shifted towards the center so that it minimizes the + // chance of it rendering outside the bounds of the render box. If the shift + // is negative, then the lobe is shifted from right to left, and if it is + // positive, then the lobe is shifted from left to right. + final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding); + final double overflowRight = math.max( + 0, + rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding), + ); + + if (rectangleWidth < sizeWithOverflow.width) { + return overflowLeft - overflowRight; + } else if (overflowLeft - overflowRight > 0) { + return overflowLeft - (edgePadding * textScaleFactor); + } else { + return -overflowRight + (edgePadding * textScaleFactor); + } + } + + double _upperRectangleWidth(TextPainter labelPainter, double scale) { + final double unscaledWidth = math.max(_minLabelWidth, labelPainter.width) + (_labelPadding * 2); + return unscaledWidth * scale; + } + + void paint({ + required RenderBox parentBox, + required Canvas canvas, + required Offset center, + required double scale, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required Color backgroundPaintColor, + Color? strokePaintColor, + }) { + if (scale == 0.0) { + // Zero scale essentially means "do not draw anything", so it's safe to just return. + return; + } + assert(!sizeWithOverflow.isEmpty); + + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + final double horizontalShift = getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: scale, + ); + + final upperRect = Rect.fromLTWH( + -rectangleWidth / 2 + horizontalShift, + -_rectYOffset - _preferredHeight, + rectangleWidth, + _preferredHeight, + ); + + final fillPaint = Paint()..color = backgroundPaintColor; + + canvas.save(); + // Prepare the canvas for the base of the tooltip, which is relative to the + // center of the thumb. + canvas.translate(center.dx, center.dy - _bottomTipYOffset); + canvas.scale(scale, scale); + + final rrect = RRect.fromRectAndRadius(upperRect, Radius.circular(upperRect.height / 2)); + if (strokePaintColor != null) { + final strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawRRect(rrect, strokePaint); + } + + canvas.drawRRect(rrect, fillPaint); + + // The label text is centered within the value indicator. + final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height; + canvas.translate(0, bottomTipToUpperRectTranslateY); + final boxCenter = Offset(horizontalShift, upperRect.height / 2.3); + final halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} + +class _DropSliderValueIndicatorPathPainter { + const _DropSliderValueIndicatorPathPainter(); + + static const double _triangleHeight = 10.0; + static const double _labelPadding = 8.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 20.0; + static const double _minRectHeight = 28.0; + static const double _rectYOffset = 6.0; + static const double _bottomTipYOffset = 16.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + static const double _upperRectRadius = 4; + + Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { + final double width = + math.max(_minLabelWidth, labelPainter.width) + _labelPadding * 2 * textScaleFactor; + return Size(width, _preferredHeight * textScaleFactor); + } + + double getHorizontalShift({ + required RenderBox parentBox, + required Offset center, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required double scale, + }) { + assert(!sizeWithOverflow.isEmpty); + + const edgePadding = 8.0; + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + + /// Value indicator draws on the Overlay and by using the global Offset + /// we are making sure we use the bounds of the Overlay instead of the Slider. + final Offset globalCenter = parentBox.localToGlobal(center); + + // The rectangle must be shifted towards the center so that it minimizes the + // chance of it rendering outside the bounds of the render box. If the shift + // is negative, then the lobe is shifted from right to left, and if it is + // positive, then the lobe is shifted from left to right. + final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding); + final double overflowRight = math.max( + 0, + rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding), + ); + + if (rectangleWidth < sizeWithOverflow.width) { + return overflowLeft - overflowRight; + } else if (overflowLeft - overflowRight > 0) { + return overflowLeft - (edgePadding * textScaleFactor); + } else { + return -overflowRight + (edgePadding * textScaleFactor); + } + } + + double _upperRectangleWidth(TextPainter labelPainter, double scale) { + final double unscaledWidth = math.max(_minLabelWidth, labelPainter.width) + _labelPadding; + return unscaledWidth * scale; + } + + BorderRadius _adjustBorderRadius(Rect rect) { + const rectness = 0.0; + return BorderRadius.lerp( + const BorderRadius.all(Radius.circular(_upperRectRadius)), + BorderRadius.all(Radius.circular(rect.shortestSide / 2.0)), + 1.0 - rectness, + )!; + } + + void paint({ + required RenderBox parentBox, + required Canvas canvas, + required Offset center, + required double scale, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required Color backgroundPaintColor, + Color? strokePaintColor, + }) { + if (scale == 0.0) { + // Zero scale essentially means "do not draw anything", so it's safe to just return. + return; + } + assert(!sizeWithOverflow.isEmpty); + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + final double horizontalShift = getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: scale, + ); + final upperRect = Rect.fromLTWH( + -rectangleWidth / 2 + horizontalShift, + -_rectYOffset - _minRectHeight, + rectangleWidth, + _minRectHeight, + ); + + final fillPaint = Paint()..color = backgroundPaintColor; + + canvas.save(); + canvas.translate(center.dx, center.dy - _bottomTipYOffset); + canvas.scale(scale, scale); + + final BorderRadius adjustedBorderRadius = _adjustBorderRadius(upperRect); + final RRect borderRect = adjustedBorderRadius + .resolve(labelPainter.textDirection) + .toRRect(upperRect); + final trianglePath = Path() + ..lineTo(-_triangleHeight, -_triangleHeight) + ..lineTo(_triangleHeight, -_triangleHeight) + ..close(); + trianglePath.addRRect(borderRect); + + if (strokePaintColor != null) { + final strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawPath(trianglePath, strokePaint); + } + + canvas.drawPath(trianglePath, fillPaint); + + // The label text is centered within the value indicator. + final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height; + canvas.translate(0, bottomTipToUpperRectTranslateY); + final boxCenter = Offset(horizontalShift, upperRect.height / 1.75); + final halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} diff --git a/packages/material_ui/lib/src/refresh_indicator.dart b/packages/material_ui/lib/src/refresh_indicator.dart new file mode 100644 index 000000000000..e83369b7d841 --- /dev/null +++ b/packages/material_ui/lib/src/refresh_indicator.dart @@ -0,0 +1,709 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart' show clampDouble; + +import 'debug.dart'; +import 'material_localizations.dart'; +import 'progress_indicator.dart'; +import 'theme.dart'; + +// The over-scroll distance that moves the indicator to its maximum +// displacement, as a percentage of the scrollable's container extent. +const double _kDragContainerExtentPercentage = 0.25; + +// How much the scroll's drag gesture can overshoot the RefreshIndicator's +// displacement; max displacement = _kDragSizeFactorLimit * displacement. +const double _kDragSizeFactorLimit = 1.5; + +// When the scroll ends, the duration of the refresh indicator's animation +// to the RefreshIndicator's displacement. +const Duration _kIndicatorSnapDuration = Duration(milliseconds: 150); + +// The duration of the ScaleTransition that starts when the refresh action +// has completed. +const Duration _kIndicatorScaleDuration = Duration(milliseconds: 200); + +/// The signature for a function that's called when the user has dragged a +/// [RefreshIndicator] far enough to demonstrate that they want the app to +/// refresh. The returned [Future] must complete when the refresh operation is +/// finished. +/// +/// Used by [RefreshIndicator.onRefresh]. +typedef RefreshCallback = Future<void> Function(); + +/// Indicates current status of Material `RefreshIndicator`. +enum RefreshIndicatorStatus { + /// Pointer is down. + drag, + + /// Dragged far enough that an up event will run the onRefresh callback. + armed, + + /// Animating to the indicator's final "displacement". + snap, + + /// Running the refresh callback. + refresh, + + /// Animating the indicator's fade-out after refreshing. + done, + + /// Animating the indicator's fade-out after not arming. + canceled, +} + +/// Used to configure how [RefreshIndicator] can be triggered. +enum RefreshIndicatorTriggerMode { + /// The indicator can be triggered regardless of the scroll position + /// of the [Scrollable] when the drag starts. + anywhere, + + /// The indicator can only be triggered if the [Scrollable] is at the edge + /// when the drag starts. + onEdge, +} + +enum _IndicatorType { material, adaptive, noSpinner } + +/// A widget that supports the Material "swipe to refresh" idiom. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ORApMlzwMdM} +/// +/// When the child's [Scrollable] descendant overscrolls, an animated circular +/// progress indicator is faded into view. When the scroll ends, if the +/// indicator has been dragged far enough for it to become completely opaque, +/// the [onRefresh] callback is called. The callback is expected to update the +/// scrollable's contents and then complete the [Future] it returns. The refresh +/// indicator disappears after the callback's [Future] has completed. +/// +/// The trigger mode is configured by [RefreshIndicator.triggerMode]. +/// +/// {@tool dartpad} +/// This example shows how [RefreshIndicator] can be triggered in different ways. +/// +/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to trigger [RefreshIndicator] in a nested scroll view using +/// the [notificationPredicate] property. +/// +/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to use [RefreshIndicator] without the spinner. +/// +/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.2.dart ** +/// {@end-tool} +/// +/// ## Troubleshooting +/// +/// ### Refresh indicator does not show up +/// +/// The [RefreshIndicator] will appear if its scrollable descendant can be +/// overscrolled, i.e. if the scrollable's content is bigger than its viewport. +/// To ensure that the [RefreshIndicator] will always appear, even if the +/// scrollable's content fits within its viewport, set the scrollable's +/// [Scrollable.physics] property to [AlwaysScrollableScrollPhysics]: +/// +/// ```dart +/// ListView( +/// physics: const AlwaysScrollableScrollPhysics(), +/// // ... +/// ) +/// ``` +/// +/// A [RefreshIndicator] can only be used with a vertical scroll view. +/// +/// See also: +/// +/// * <https://material.io/design/platform-guidance/android-swipe-to-refresh.html> +/// * [RefreshIndicatorState], can be used to programmatically show the refresh indicator. +/// * [RefreshProgressIndicator], widget used by [RefreshIndicator] to show +/// the inner circular progress spinner during refreshes. +/// * [CupertinoSliverRefreshControl], an iOS equivalent of the pull-to-refresh pattern. +/// Must be used as a sliver inside a [CustomScrollView] instead of wrapping +/// around a [ScrollView] because it's a part of the scrollable instead of +/// being overlaid on top of it. +class RefreshIndicator extends StatefulWidget { + /// Creates a refresh indicator. + /// + /// The [onRefresh], [child], and [notificationPredicate] arguments must be + /// non-null. The default + /// [displacement] is 40.0 logical pixels. + /// + /// The [semanticsLabel] is used to specify an accessibility label for this widget. + /// If it is null, it will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel]. + /// An empty string may be passed to avoid having anything read by screen reading software. + /// The [semanticsValue] may be used to specify progress on the widget. + const RefreshIndicator({ + super.key, + this.displacement = 40.0, + this.edgeOffset = 0.0, + required this.onRefresh, + this.color, + this.backgroundColor, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + this.elevation = 2.0, + required this.child, + }) : _indicatorType = _IndicatorType.material, + onStatusChange = null, + assert(elevation >= 0.0); + + /// Creates an adaptive [RefreshIndicator] based on whether the target + /// platform is iOS or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// When the descendant overscrolls, a different spinning progress indicator + /// is shown depending on platform. On iOS and macOS, + /// [CupertinoActivityIndicator] is shown, but on all other platforms, + /// [CircularProgressIndicator] appears. + /// + /// If a [CupertinoActivityIndicator] is shown, the following parameters are ignored: + /// [backgroundColor], [semanticsLabel], [semanticsValue], [strokeWidth]. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + /// + /// Notably the scrollable widget itself will have slightly different behavior + /// from [CupertinoSliverRefreshControl], due to a difference in structure. + const RefreshIndicator.adaptive({ + super.key, + this.displacement = 40.0, + this.edgeOffset = 0.0, + required this.onRefresh, + this.color, + this.backgroundColor, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + this.elevation = 2.0, + required this.child, + }) : _indicatorType = _IndicatorType.adaptive, + onStatusChange = null, + assert(elevation >= 0.0); + + /// Creates a [RefreshIndicator] with no spinner and calls `onRefresh` when + /// successfully armed by a drag event. + /// + /// Events can be optionally listened by using the `onStatusChange` callback. + const RefreshIndicator.noSpinner({ + super.key, + required this.onRefresh, + this.onStatusChange, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + this.elevation = 2.0, + required this.child, + }) : _indicatorType = _IndicatorType.noSpinner, + // The following parameters aren't used because [_IndicatorType.noSpinner] is being used, + // which involves showing no spinner, hence the following parameters are useless since + // their only use is to change the spinner's appearance. + displacement = 0.0, + edgeOffset = 0.0, + color = null, + backgroundColor = null, + strokeWidth = 0.0, + assert(elevation >= 0.0); + + /// The widget below this widget in the tree. + /// + /// The refresh indicator will be stacked on top of this child. The indicator + /// will appear when child's Scrollable descendant is over-scrolled. + /// + /// Typically a [ListView] or [CustomScrollView]. + final Widget child; + + /// The distance from the child's top or bottom [edgeOffset] where + /// the refresh indicator will settle. During the drag that exposes the refresh + /// indicator, its actual displacement may significantly exceed this value. + /// + /// In most cases, [displacement] distance starts counting from the parent's + /// edges. However, if [edgeOffset] is larger than zero then the [displacement] + /// value is calculated from that offset instead of the parent's edge. + final double displacement; + + /// The offset where [RefreshProgressIndicator] starts to appear on drag start. + /// + /// Depending whether the indicator is showing on the top or bottom, the value + /// of this variable controls how far from the parent's edge the progress + /// indicator starts to appear. This may come in handy when, for example, the + /// UI contains a top [Widget] which covers the parent's edge where the progress + /// indicator would otherwise appear. + /// + /// By default, the edge offset is set to 0. + /// + /// See also: + /// + /// * [displacement], can be used to change the distance from the edge that + /// the indicator settles. + final double edgeOffset; + + /// A function that's called when the user has dragged the refresh indicator + /// far enough to demonstrate that they want the app to refresh. The returned + /// [Future] must complete when the refresh operation is finished. + final RefreshCallback onRefresh; + + /// Called to get the current status of the [RefreshIndicator] to update the UI as needed. + /// This is an optional parameter, used to fine tune app cases. + final ValueChanged<RefreshIndicatorStatus?>? onStatusChange; + + /// The progress indicator's foreground color. The current theme's + /// [ColorScheme.primary] by default. + final Color? color; + + /// The progress indicator's background color. The current theme's + /// [ThemeData.canvasColor] by default. + final Color? backgroundColor; + + /// A check that specifies whether a [ScrollNotification] should be + /// handled by this widget. + /// + /// By default, checks whether `notification.depth == 0`. Set it to something + /// else for more complicated layouts. + final ScrollNotificationPredicate notificationPredicate; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel} + /// + /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel] + /// if it is null. + final String? semanticsLabel; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue} + final String? semanticsValue; + + /// Defines [strokeWidth] for `RefreshIndicator`. + /// + /// By default, the value of [strokeWidth] is 2.0 pixels. + final double strokeWidth; + + final _IndicatorType _indicatorType; + + /// Defines how this [RefreshIndicator] can be triggered when users overscroll. + /// + /// The [RefreshIndicator] can be pulled out in two cases, + /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position + /// when the drag starts. + /// 2, Keep dragging after overscroll occurs if the scrollable widget has + /// a non-zero scroll position when the drag starts. + /// + /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered. + /// + /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered. + /// + /// Defaults to [RefreshIndicatorTriggerMode.onEdge]. + final RefreshIndicatorTriggerMode triggerMode; + + /// Defines the elevation of the underlying [RefreshIndicator]. + /// + /// Defaults to 2.0. + final double elevation; + + @override + RefreshIndicatorState createState() => RefreshIndicatorState(); +} + +/// Contains the state for a [RefreshIndicator]. This class can be used to +/// programmatically show the refresh indicator, see the [show] method. +class RefreshIndicatorState extends State<RefreshIndicator> + with TickerProviderStateMixin<RefreshIndicator> { + late AnimationController _positionController; + late AnimationController _scaleController; + late Animation<double> _positionFactor; + late Animation<double> _scaleFactor; + late Animation<double> _value; + late Animation<Color?> _valueColor; + + RefreshIndicatorStatus? _status; + late Future<void> _pendingRefreshFuture; + bool? _isIndicatorAtTop; + double? _dragOffset; + late Color _effectiveValueColor = widget.color ?? Theme.of(context).colorScheme.primary; + + static final Animatable<double> _threeQuarterTween = Tween<double>(begin: 0.0, end: 0.75); + + static final Animatable<double> _kDragSizeFactorLimitTween = Tween<double>( + begin: 0.0, + end: _kDragSizeFactorLimit, + ); + + static final Animatable<double> _oneToZeroTween = Tween<double>(begin: 1.0, end: 0.0); + + @protected + @override + void initState() { + super.initState(); + _positionController = AnimationController(vsync: this); + _positionFactor = _positionController.drive(_kDragSizeFactorLimitTween); + + // The "value" of the circular progress indicator during a drag. + _value = _positionController.drive(_threeQuarterTween); + + _scaleController = AnimationController(vsync: this); + _scaleFactor = _scaleController.drive(_oneToZeroTween); + } + + @protected + @override + void didChangeDependencies() { + _setupColorTween(); + super.didChangeDependencies(); + } + + @protected + @override + void didUpdateWidget(covariant RefreshIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.color != widget.color) { + _setupColorTween(); + } + } + + @protected + @override + void dispose() { + _positionController.dispose(); + _scaleController.dispose(); + super.dispose(); + } + + void _setupColorTween() { + // Reset the current value color. + _effectiveValueColor = widget.color ?? Theme.of(context).colorScheme.primary; + final Color color = _effectiveValueColor; + if (color.alpha == 0x00) { + // Set an always stopped animation instead of a driven tween. + _valueColor = AlwaysStoppedAnimation<Color>(color); + } else { + // Respect the alpha of the given color. + _valueColor = _positionController.drive( + ColorTween( + begin: color.withAlpha(0), + end: color.withAlpha(color.alpha), + ).chain(CurveTween(curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit))), + ); + } + } + + bool _shouldStart(ScrollNotification notification) { + // If the notification.dragDetails is null, this scroll is not triggered by + // user dragging. It may be a result of ScrollController.jumpTo or ballistic scroll. + // In this case, we don't want to trigger the refresh indicator. + return ((notification is ScrollStartNotification && notification.dragDetails != null) || + (notification is ScrollUpdateNotification && + notification.dragDetails != null && + widget.triggerMode == RefreshIndicatorTriggerMode.anywhere)) && + ((notification.metrics.axisDirection == AxisDirection.up && + notification.metrics.extentAfter == 0.0) || + (notification.metrics.axisDirection == AxisDirection.down && + notification.metrics.extentBefore == 0.0)) && + _status == null && + _start(notification.metrics.axisDirection); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (!widget.notificationPredicate(notification)) { + return false; + } + if (_shouldStart(notification)) { + setState(() { + _status = RefreshIndicatorStatus.drag; + widget.onStatusChange?.call(_status); + }); + return false; + } + final bool? indicatorAtTopNow = switch (notification.metrics.axisDirection) { + AxisDirection.down || AxisDirection.up => true, + AxisDirection.left || AxisDirection.right => null, + }; + if (indicatorAtTopNow != _isIndicatorAtTop) { + if (_status == RefreshIndicatorStatus.drag || _status == RefreshIndicatorStatus.armed) { + _dismiss(RefreshIndicatorStatus.canceled); + } + } else if (notification is ScrollUpdateNotification) { + if (_status == RefreshIndicatorStatus.drag || _status == RefreshIndicatorStatus.armed) { + if (notification.metrics.axisDirection == AxisDirection.down) { + _dragOffset = _dragOffset! - notification.scrollDelta!; + } else if (notification.metrics.axisDirection == AxisDirection.up) { + _dragOffset = _dragOffset! + notification.scrollDelta!; + } + _checkDragOffset(notification.metrics.viewportDimension); + } + if (_status == RefreshIndicatorStatus.armed && notification.dragDetails == null) { + // On iOS start the refresh when the Scrollable bounces back from the + // overscroll (ScrollNotification indicating this don't have dragDetails + // because the scroll activity is not directly triggered by a drag). + _show(); + } + } else if (notification is OverscrollNotification) { + if (_status == RefreshIndicatorStatus.drag || _status == RefreshIndicatorStatus.armed) { + if (notification.metrics.axisDirection == AxisDirection.down) { + _dragOffset = _dragOffset! - notification.overscroll; + } else if (notification.metrics.axisDirection == AxisDirection.up) { + _dragOffset = _dragOffset! + notification.overscroll; + } + _checkDragOffset(notification.metrics.viewportDimension); + } + } else if (notification is ScrollEndNotification) { + switch (_status) { + case RefreshIndicatorStatus.armed: + if (_positionController.value < 1.0) { + _dismiss(RefreshIndicatorStatus.canceled); + } else { + _show(); + } + case RefreshIndicatorStatus.drag: + _dismiss(RefreshIndicatorStatus.canceled); + case RefreshIndicatorStatus.canceled: + case RefreshIndicatorStatus.done: + case RefreshIndicatorStatus.refresh: + case RefreshIndicatorStatus.snap: + case null: + // do nothing + break; + } + } + return false; + } + + bool _handleIndicatorNotification(OverscrollIndicatorNotification notification) { + if (notification.depth != 0 || !notification.leading) { + return false; + } + if (_status == RefreshIndicatorStatus.drag) { + notification.disallowIndicator(); + return true; + } + return false; + } + + bool _start(AxisDirection direction) { + assert(_status == null); + assert(_isIndicatorAtTop == null); + assert(_dragOffset == null); + switch (direction) { + case AxisDirection.down: + case AxisDirection.up: + _isIndicatorAtTop = true; + case AxisDirection.left: + case AxisDirection.right: + _isIndicatorAtTop = null; + // we do not support horizontal scroll views. + return false; + } + _dragOffset = 0.0; + _scaleController.value = 0.0; + _positionController.value = 0.0; + return true; + } + + void _checkDragOffset(double containerExtent) { + assert(_status == RefreshIndicatorStatus.drag || _status == RefreshIndicatorStatus.armed); + double newValue = _dragOffset! / (containerExtent * _kDragContainerExtentPercentage); + if (_status == RefreshIndicatorStatus.armed) { + newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit); + } + _positionController.value = clampDouble(newValue, 0.0, 1.0); // This triggers various rebuilds. + if (_status == RefreshIndicatorStatus.drag && + _valueColor.value!.alpha == _effectiveValueColor.alpha) { + _status = RefreshIndicatorStatus.armed; + widget.onStatusChange?.call(_status); + } + } + + // Stop showing the refresh indicator. + Future<void> _dismiss(RefreshIndicatorStatus newMode) async { + await Future<void>.value(); + // This can only be called from _show() when refreshing and + // _handleScrollNotification in response to a ScrollEndNotification or + // direction change. + assert(newMode == RefreshIndicatorStatus.canceled || newMode == RefreshIndicatorStatus.done); + setState(() { + _status = newMode; + widget.onStatusChange?.call(_status); + }); + switch (_status!) { + case RefreshIndicatorStatus.done: + await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration); + case RefreshIndicatorStatus.canceled: + await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration); + case RefreshIndicatorStatus.armed: + case RefreshIndicatorStatus.drag: + case RefreshIndicatorStatus.refresh: + case RefreshIndicatorStatus.snap: + assert(false); + } + if (mounted && _status == newMode) { + _dragOffset = null; + _isIndicatorAtTop = null; + setState(() { + _status = null; + }); + } + } + + void _show() { + assert(_status != RefreshIndicatorStatus.refresh); + assert(_status != RefreshIndicatorStatus.snap); + final completer = Completer<void>(); + _pendingRefreshFuture = completer.future; + _status = RefreshIndicatorStatus.snap; + widget.onStatusChange?.call(_status); + _positionController + .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration) + .then<void>((void value) { + if (mounted && _status == RefreshIndicatorStatus.snap) { + setState(() { + // Show the indeterminate progress indicator. + _status = RefreshIndicatorStatus.refresh; + }); + + final Future<void> refreshResult = widget.onRefresh(); + refreshResult.whenComplete(() { + if (mounted && _status == RefreshIndicatorStatus.refresh) { + completer.complete(); + _dismiss(RefreshIndicatorStatus.done); + } + }); + } + }); + } + + /// Show the refresh indicator and run the refresh callback as if it had + /// been started interactively. If this method is called while the refresh + /// callback is running, it quietly does nothing. + /// + /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>] + /// makes it possible to refer to the [RefreshIndicatorState]. + /// + /// The future returned from this method completes when the + /// [RefreshIndicator.onRefresh] callback's future completes. + /// + /// If you await the future returned by this function from a [State], you + /// should check that the state is still [mounted] before calling [setState]. + /// + /// When initiated in this manner, the refresh indicator is independent of any + /// actual scroll view. It defaults to showing the indicator at the top. To + /// show it at the bottom, set `atTop` to false. + Future<void> show({bool atTop = true}) { + if (_status != RefreshIndicatorStatus.refresh && _status != RefreshIndicatorStatus.snap) { + if (_status == null) { + _start(atTop ? AxisDirection.down : AxisDirection.up); + } + _show(); + } + return _pendingRefreshFuture; + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final Widget child = NotificationListener<ScrollNotification>( + onNotification: _handleScrollNotification, + child: NotificationListener<OverscrollIndicatorNotification>( + onNotification: _handleIndicatorNotification, + child: widget.child, + ), + ); + assert(() { + if (_status == null) { + assert(_dragOffset == null); + assert(_isIndicatorAtTop == null); + } else { + assert(_dragOffset != null); + assert(_isIndicatorAtTop != null); + } + return true; + }()); + + final bool showIndeterminateIndicator = + _status == RefreshIndicatorStatus.refresh || _status == RefreshIndicatorStatus.done; + + return Stack( + children: <Widget>[ + child, + if (_status != null) + Positioned( + top: _isIndicatorAtTop! ? widget.edgeOffset : null, + bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null, + left: 0.0, + right: 0.0, + child: SizeTransition( + alignment: AlignmentDirectional(-1.0, _isIndicatorAtTop! ? 1.0 : -1.0), + sizeFactor: _positionFactor, // This is what brings it down. + child: Padding( + padding: _isIndicatorAtTop! + ? EdgeInsets.only(top: widget.displacement) + : EdgeInsets.only(bottom: widget.displacement), + child: Align( + alignment: _isIndicatorAtTop! ? Alignment.topCenter : Alignment.bottomCenter, + child: ScaleTransition( + scale: _scaleFactor, + child: AnimatedBuilder( + animation: _positionController, + builder: (BuildContext context, Widget? child) { + final Widget materialIndicator = RefreshProgressIndicator( + semanticsLabel: + widget.semanticsLabel ?? + MaterialLocalizations.of(context).refreshIndicatorSemanticLabel, + semanticsValue: widget.semanticsValue, + value: showIndeterminateIndicator ? null : _value.value, + valueColor: _valueColor, + backgroundColor: widget.backgroundColor, + strokeWidth: widget.strokeWidth, + elevation: widget.elevation, + ); + + final Widget cupertinoIndicator = CupertinoActivityIndicator( + color: widget.color, + ); + + switch (widget._indicatorType) { + case _IndicatorType.material: + return materialIndicator; + + case _IndicatorType.adaptive: + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return materialIndicator; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return cupertinoIndicator; + } + + case _IndicatorType.noSpinner: + return Container(); + } + }, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/lib/src/reorderable_list.dart b/packages/material_ui/lib/src/reorderable_list.dart new file mode 100644 index 000000000000..0fd09dbfc355 --- /dev/null +++ b/packages/material_ui/lib/src/reorderable_list.dart @@ -0,0 +1,580 @@ +// 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. + +/// @docImport 'package:flutter_test/flutter_test.dart'; +/// +/// @docImport 'card.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'icons.dart'; +import 'material.dart'; +import 'theme.dart'; + +/// A list whose items the user can interactively reorder by dragging. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE} +/// +/// This sample shows by dragging the user can reorder the items of the list. +/// The [onReorderItem] parameter will be called when a child +/// widget is dragged to a new position. +/// +/// {@tool dartpad} +/// +/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.0.dart ** +/// {@end-tool} +/// +/// By default, on [TargetPlatformVariant.desktop] platforms each item will +/// have a drag handle added on top of it that will allow the user to grab it +/// to move the item. On [TargetPlatformVariant.mobile], no drag handle will be +/// added, but when the user long presses anywhere on the item it will start +/// moving the item. Displaying drag handles can be controlled with +/// [ReorderableListView.buildDefaultDragHandles]. +/// +/// All list items must have a key. +/// +/// This example demonstrates using the [ReorderableListView.proxyDecorator] callback +/// to customize the appearance of a list item while it's being dragged. +/// +/// {@tool dartpad} +/// While a drag is underway, the widget returned by the [ReorderableListView.proxyDecorator] +/// callback serves as a "proxy" (a substitute) for the item in the list. The proxy is +/// created with the original list item as its child. The [ReorderableListView.proxyDecorator] +/// callback in this example is similar to the default one except that it changes the +/// proxy item's background color. +/// +/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.1.dart ** +/// {@end-tool} +/// +/// This example demonstrates using the [ReorderableListView.proxyDecorator] callback to +/// customize the appearance of a [Card] while it's being dragged. +/// +/// {@tool dartpad} +/// The default [proxyDecorator] wraps the dragged item in a [Material] widget and animates +/// its elevation. This example demonstrates how to use the [ReorderableListView.proxyDecorator] +/// callback to update the dragged card elevation without inserted a new [Material] widget. +/// +/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.2.dart ** +/// {@end-tool} +class ReorderableListView extends StatefulWidget { + /// Creates a reorderable list from a pre-built list of widgets. + /// + /// This constructor is appropriate for lists with a small number of + /// children because constructing the [List] requires doing work for every + /// child that could possibly be displayed in the list view instead of just + /// those children that are actually visible. + /// + /// See also: + /// + /// * [ReorderableListView.builder], which allows you to build a reorderable + /// list where the items are built as needed when scrolling the list. + ReorderableListView({ + super.key, + required List<Widget> children, + @Deprecated( + 'Use the onReorderItem callback instead. ' + 'The onReorderItem callback adjusts the newIndex parameter for a removed item at the oldIndex. ' + 'This feature was deprecated after v3.41.0-0.0.pre.', + ) + this.onReorder, + this.onReorderItem, + this.onReorderStart, + this.onReorderEnd, + this.itemExtent, + this.itemExtentBuilder, + this.prototypeItem, + this.proxyDecorator, + this.buildDefaultDragHandles = true, + this.padding, + this.header, + this.footer, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.scrollController, + this.primary, + this.physics, + this.shrinkWrap = false, + this.anchor = 0.0, + @Deprecated( + 'Use scrollCacheExtent instead. ' + 'This feature was deprecated after v3.41.0-0.0.pre.', + ) + this.cacheExtent, + this.scrollCacheExtent, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + this.autoScrollerVelocityScalar, + this.dragBoundaryProvider, + this.mouseCursor, + }) : assert( + (itemExtent == null && prototypeItem == null) || + (itemExtent == null && itemExtentBuilder == null) || + (prototypeItem == null && itemExtentBuilder == null), + 'You can only pass one of itemExtent, prototypeItem and itemExtentBuilder.', + ), + assert( + children.every((Widget w) => w.key != null), + 'All children of this widget must have a key.', + ), + assert( + (onReorderItem != null && onReorder == null) || + (onReorderItem == null && onReorder != null), + 'The onReorder callback is obsolete and is replaced by onReorderItem. ' + 'Remove the onReorder callback when both callbacks are provided.', + ), + itemBuilder = ((BuildContext context, int index) => children[index]), + itemCount = children.length; + + /// Creates a reorderable list from widget items that are created on demand. + /// + /// This constructor is appropriate for list views with a large number of + /// children because the builder is called only for those children + /// that are actually visible. + /// + /// The `itemBuilder` callback will be called only with indices greater than + /// or equal to zero and less than `itemCount`. + /// + /// The `itemBuilder` should always return a non-null widget, and actually + /// create the widget instances when called. Avoid using a builder that + /// returns a previously-constructed widget; if the list view's children are + /// created in advance, or all at once when the [ReorderableListView] itself + /// is created, it is more efficient to use the [ReorderableListView] + /// constructor. Even more efficient, however, is to create the instances + /// on demand using this constructor's `itemBuilder` callback. + /// + /// This example creates a list using the + /// [ReorderableListView.builder] constructor. Using the [IndexedWidgetBuilder], The + /// list items are built lazily on demand. + /// {@tool dartpad} + /// + /// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0.dart ** + /// {@end-tool} + /// See also: + /// + /// * [ReorderableListView], which allows you to build a reorderable + /// list with all the items passed into the constructor. + const ReorderableListView.builder({ + super.key, + required this.itemBuilder, + required this.itemCount, + @Deprecated( + 'Use the onReorderItem callback instead. ' + 'The onReorderItem callback adjusts the newIndex parameter for a removed item at the oldIndex. ' + 'This feature was deprecated after v3.41.0-0.0.pre.', + ) + this.onReorder, + this.onReorderItem, + this.onReorderStart, + this.onReorderEnd, + this.itemExtent, + this.itemExtentBuilder, + this.prototypeItem, + this.proxyDecorator, + this.buildDefaultDragHandles = true, + this.padding, + this.header, + this.footer, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.scrollController, + this.primary, + this.physics, + this.shrinkWrap = false, + this.anchor = 0.0, + @Deprecated( + 'Use scrollCacheExtent instead. ' + 'This feature was deprecated after v3.41.0-0.0.pre.', + ) + this.cacheExtent, + this.scrollCacheExtent, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + this.autoScrollerVelocityScalar, + this.dragBoundaryProvider, + this.mouseCursor, + }) : assert(itemCount >= 0), + assert( + (itemExtent == null && prototypeItem == null) || + (itemExtent == null && itemExtentBuilder == null) || + (prototypeItem == null && itemExtentBuilder == null), + 'You can only pass one of itemExtent, prototypeItem and itemExtentBuilder.', + ), + assert( + (onReorderItem != null && onReorder == null) || + (onReorderItem == null && onReorder != null), + 'The onReorder callback is obsolete and is replaced by onReorderItem. ' + 'Remove the onReorder callback when both callbacks are provided.', + ); + + /// {@macro flutter.widgets.reorderable_list.itemBuilder} + final IndexedWidgetBuilder itemBuilder; + + /// {@macro flutter.widgets.reorderable_list.itemCount} + final int itemCount; + + /// {@macro flutter.widgets.reorderable_list.onReorder} + @Deprecated( + 'Use the onReorderItem callback instead. ' + 'The onReorderItem callback adjusts the newIndex parameter for a removed item at the oldIndex. ' + 'This feature was deprecated after v3.41.0-0.0.pre.', + ) + final ReorderCallback? onReorder; + + /// {@macro flutter.widgets.reorderable_list.onReorderItem} + final ReorderCallback? onReorderItem; + + /// {@macro flutter.widgets.reorderable_list.onReorderStart} + final void Function(int index)? onReorderStart; + + /// {@macro flutter.widgets.reorderable_list.onReorderEnd} + final void Function(int index)? onReorderEnd; + + /// {@macro flutter.widgets.reorderable_list.proxyDecorator} + final ReorderItemProxyDecorator? proxyDecorator; + + /// If true: on desktop platforms, a drag handle is stacked over the + /// center of each item's trailing edge; on mobile platforms, a long + /// press anywhere on the item starts a drag. + /// + /// The default desktop drag handle is just an [Icons.drag_handle] + /// wrapped by a [ReorderableDragStartListener]. On mobile + /// platforms, the entire item is wrapped with a + /// [ReorderableDelayedDragStartListener]. + /// + /// To change the appearance or the layout of the drag handles, make + /// this parameter false and wrap each list item, or a widget within + /// each list item, with [ReorderableDragStartListener] or + /// [ReorderableDelayedDragStartListener], or a custom subclass + /// of [ReorderableDragStartListener]. + /// + /// The following sample specifies `buildDefaultDragHandles: false`, and + /// uses a [Card] at the leading edge of each item for the item's drag handle. + /// + /// {@tool dartpad} + /// + /// + /// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.build_default_drag_handles.0.dart ** + /// {@end-tool} + final bool buildDefaultDragHandles; + + /// {@macro flutter.widgets.reorderable_list.padding} + final EdgeInsets? padding; + + /// A non-reorderable header item to show before the items of the list. + /// + /// If null, no header will appear before the list. + final Widget? header; + + /// A non-reorderable footer item to show after the items of the list. + /// + /// If null, no footer will appear after the list. + final Widget? footer; + + /// {@macro flutter.widgets.scroll_view.scrollDirection} + final Axis scrollDirection; + + /// {@macro flutter.widgets.scroll_view.reverse} + final bool reverse; + + /// {@macro flutter.widgets.scroll_view.controller} + final ScrollController? scrollController; + + /// {@macro flutter.widgets.scroll_view.primary} + + /// Defaults to true when [scrollDirection] is [Axis.vertical] and + /// [scrollController] is null. + final bool? primary; + + /// {@macro flutter.widgets.scroll_view.physics} + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scroll_view.shrinkWrap} + final bool shrinkWrap; + + /// {@macro flutter.widgets.scroll_view.anchor} + final double anchor; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + @Deprecated( + 'Use scrollCacheExtent instead. ' + 'This feature was deprecated after v3.41.0-0.0.pre.', + ) + final double? cacheExtent; + + /// {@macro flutter.rendering.RenderViewportBase.scrollCacheExtent} + final ScrollCacheExtent? scrollCacheExtent; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.scroll_view.keyboardDismissBehavior} + /// + /// If [keyboardDismissBehavior] is null then it will fallback to the inherited + /// [ScrollBehavior.getKeyboardDismissBehavior]. + final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// {@macro flutter.widgets.list_view.itemExtent} + final double? itemExtent; + + /// {@macro flutter.widgets.list_view.itemExtentBuilder} + final ItemExtentBuilder? itemExtentBuilder; + + /// {@macro flutter.widgets.list_view.prototypeItem} + final Widget? prototypeItem; + + /// {@macro flutter.widgets.EdgeDraggingAutoScroller.velocityScalar} + /// + /// {@macro flutter.widgets.SliverReorderableList.autoScrollerVelocityScalar.default} + final double? autoScrollerVelocityScalar; + + /// {@macro flutter.widgets.reorderable_list.dragBoundaryProvider} + final ReorderDragBoundaryProvider? dragBoundaryProvider; + + /// The cursor for a mouse pointer when it enters or is hovering over the drag + /// handle. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.dragged]. + /// + /// If this property is null, [SystemMouseCursors.grab] will be used when + /// hovering, and [SystemMouseCursors.grabbing] when dragging. + final MouseCursor? mouseCursor; + + @override + State<ReorderableListView> createState() => _ReorderableListViewState(); +} + +class _ReorderableListViewState extends State<ReorderableListView> { + final ValueNotifier<bool> _dragging = ValueNotifier<bool>(false); + + Widget _itemBuilder(BuildContext context, int index) { + final Widget item = widget.itemBuilder(context, index); + assert(() { + if (item.key == null) { + throw FlutterError('Every item of ReorderableListView must have a key.'); + } + return true; + }()); + + final Key itemGlobalKey = _ReorderableListViewChildGlobalKey(item.key!, this); + + if (widget.buildDefaultDragHandles) { + switch (Theme.of(context).platform) { + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + final dragHandle = ListenableBuilder( + listenable: _dragging, + builder: (BuildContext context, Widget? child) { + final MouseCursor effectiveMouseCursor = WidgetStateProperty.resolveAs<MouseCursor>( + widget.mouseCursor ?? + const WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.dragged: SystemMouseCursors.grabbing, + WidgetState.any: SystemMouseCursors.grab, + }), + <WidgetState>{if (_dragging.value) WidgetState.dragged}, + ); + return MouseRegion(cursor: effectiveMouseCursor, child: child); + }, + child: const Icon(Icons.drag_handle), + ); + switch (widget.scrollDirection) { + case Axis.horizontal: + return Stack( + key: itemGlobalKey, + children: <Widget>[ + item, + Positioned.directional( + textDirection: Directionality.of(context), + start: 0, + end: 0, + bottom: 8, + child: Align( + alignment: AlignmentDirectional.bottomCenter, + child: ReorderableDragStartListener(index: index, child: dragHandle), + ), + ), + ], + ); + case Axis.vertical: + return Stack( + key: itemGlobalKey, + children: <Widget>[ + item, + Positioned.directional( + textDirection: Directionality.of(context), + top: 0, + bottom: 0, + end: 8, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: ReorderableDragStartListener(index: index, child: dragHandle), + ), + ), + ], + ); + } + + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return ReorderableDelayedDragStartListener(key: itemGlobalKey, index: index, child: item); + } + } + + return KeyedSubtree(key: itemGlobalKey, child: item); + } + + Widget _proxyDecorator(Widget child, int index, Animation<double> animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(0, 6, animValue)!; + return Material(elevation: elevation, child: child); + }, + child: child, + ); + } + + @override + void dispose() { + _dragging.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasOverlay(context)); + + // If there is a header or footer we can't just apply the padding to the list, + // so we break it up into padding for the header, footer and padding for the list. + final EdgeInsets padding = widget.padding ?? EdgeInsets.zero; + double? start = widget.header == null ? null : 0.0; + double? end = widget.footer == null ? null : 0.0; + if (widget.reverse) { + (start, end) = (end, start); + } + + final EdgeInsets startPadding, endPadding, listPadding; + (startPadding, endPadding, listPadding) = switch (widget.scrollDirection) { + Axis.horizontal || + Axis.vertical when (start ?? end) == null => (EdgeInsets.zero, EdgeInsets.zero, padding), + Axis.horizontal => ( + padding.copyWith(left: 0), + padding.copyWith(right: 0), + padding.copyWith(left: start, right: end), + ), + Axis.vertical => ( + padding.copyWith(top: 0), + padding.copyWith(bottom: 0), + padding.copyWith(top: start, bottom: end), + ), + }; + final (EdgeInsets headerPadding, EdgeInsets footerPadding) = widget.reverse + ? (startPadding, endPadding) + : (endPadding, startPadding); + + final ScrollCacheExtent? scrollCacheExtent = + widget.scrollCacheExtent ?? + (widget.cacheExtent == null ? null : ScrollCacheExtent.pixels(widget.cacheExtent!)); + + return CustomScrollView( + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + controller: widget.scrollController, + primary: widget.primary, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + anchor: widget.anchor, + scrollCacheExtent: scrollCacheExtent, + dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + restorationId: widget.restorationId, + clipBehavior: widget.clipBehavior, + slivers: <Widget>[ + if (widget.header != null) + SliverPadding( + padding: headerPadding, + sliver: SliverToBoxAdapter(child: widget.header), + ), + SliverPadding( + padding: listPadding, + sliver: SliverReorderableList( + itemBuilder: _itemBuilder, + itemExtent: widget.itemExtent, + itemExtentBuilder: widget.itemExtentBuilder, + prototypeItem: widget.prototypeItem, + itemCount: widget.itemCount, + onReorder: widget.onReorder, + onReorderItem: widget.onReorderItem, + onReorderStart: (int index) { + _dragging.value = true; + widget.onReorderStart?.call(index); + }, + onReorderEnd: (int index) { + _dragging.value = false; + widget.onReorderEnd?.call(index); + }, + proxyDecorator: widget.proxyDecorator ?? _proxyDecorator, + autoScrollerVelocityScalar: widget.autoScrollerVelocityScalar, + dragBoundaryProvider: widget.dragBoundaryProvider, + ), + ), + if (widget.footer != null) + SliverPadding( + padding: footerPadding, + sliver: SliverToBoxAdapter(child: widget.footer), + ), + ], + ); + } +} + +// A global key that takes its identity from the object and uses a value of a +// particular type to identify itself. +// +// The difference with GlobalObjectKey is that it uses [==] instead of [identical] +// of the objects used to generate widgets. +@optionalTypeArgs +class _ReorderableListViewChildGlobalKey extends GlobalObjectKey { + const _ReorderableListViewChildGlobalKey(this.subKey, this.state) : super(subKey); + + final Key subKey; + final State state; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is _ReorderableListViewChildGlobalKey && + other.subKey == subKey && + other.state == state; + } + + @override + int get hashCode => Object.hash(subKey, state); +} diff --git a/packages/material_ui/lib/src/scaffold.dart b/packages/material_ui/lib/src/scaffold.dart new file mode 100644 index 000000000000..6f4fc67ff845 --- /dev/null +++ b/packages/material_ui/lib/src/scaffold.dart @@ -0,0 +1,3521 @@ +// 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. + +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'bottom_app_bar.dart'; +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'bottom_sheet_theme.dart'; +/// @docImport 'drawer_theme.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'tab_controller.dart'; +/// @docImport 'tabs.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior, HitTestEntry, HitTestResult; +import 'package:flutter/rendering.dart' show RenderMetaData; +import 'package:flutter/widgets.dart'; + +import 'app_bar.dart'; +import 'banner.dart'; +import 'banner_theme.dart'; +import 'bottom_sheet.dart'; +import 'colors.dart'; +import 'curves.dart'; +import 'debug.dart'; +import 'divider.dart'; +import 'drawer.dart'; +import 'flexible_space_bar.dart'; +import 'floating_action_button.dart'; +import 'floating_action_button_location.dart'; +import 'material.dart'; +import 'snack_bar.dart'; +import 'snack_bar_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late TabController tabController; +// void setState(VoidCallback fn) { } +// late String appBarTitle; +// late int tabCount; +// late TickerProvider tickerProvider; + +const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = + FloatingActionButtonLocation.endFloat; +const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = + FloatingActionButtonAnimator.scaling; + +const Curve _standardBottomSheetCurve = standardEasing; +// When the top of the BottomSheet crosses this threshold, it will start to +// shrink the FAB and show a scrim. +const double _kBottomSheetDominatesPercentage = 0.3; +const double _kMinBottomSheetScrimOpacity = 0.1; +const double _kMaxBottomSheetScrimOpacity = 0.6; + +enum _ScaffoldSlot { + body, + appBar, + bodyScrim, + bottomSheet, + snackBar, + materialBanner, + persistentFooter, + bottomNavigationBar, + floatingActionButton, + drawer, + endDrawer, + statusBar, +} + +/// Manages [SnackBar]s and [MaterialBanner]s for descendant [Scaffold]s. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=lytQi-slT5Y} +/// +/// This class provides APIs for showing snack bars and material banners at the +/// bottom and top of the screen, respectively. +/// +/// To display one of these notifications, obtain the [ScaffoldMessengerState] +/// for the current [BuildContext] via [ScaffoldMessenger.of] and use the +/// [ScaffoldMessengerState.showSnackBar] or the +/// [ScaffoldMessengerState.showMaterialBanner] functions. +/// +/// When the [ScaffoldMessenger] has nested [Scaffold] descendants, the +/// ScaffoldMessenger will only present the notification to the root Scaffold of +/// the subtree of Scaffolds. In order to show notifications for the inner, nested +/// Scaffolds, set a new scope by instantiating a new ScaffoldMessenger in +/// between the levels of nesting. +/// +/// {@tool dartpad} +/// Here is an example of showing a [SnackBar] when the user presses a button. +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold_messenger.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SnackBar], which is a temporary notification typically shown near the +/// bottom of the app using the [ScaffoldMessengerState.showSnackBar] method. +/// * [MaterialBanner], which is a temporary notification typically shown at the +/// top of the app using the [ScaffoldMessengerState.showMaterialBanner] method. +/// * [debugCheckHasScaffoldMessenger], which asserts that the given context +/// has a [ScaffoldMessenger] ancestor. +/// * Cookbook: [Display a SnackBar](https://docs.flutter.dev/cookbook/design/snackbars) +class ScaffoldMessenger extends StatefulWidget { + /// Creates a widget that manages [SnackBar]s for [Scaffold] descendants. + const ScaffoldMessenger({super.key, required this.child}); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The state from the closest instance of this class that encloses the given + /// context. + /// + /// {@tool dartpad} + /// Typical usage of the [ScaffoldMessenger.of] function is to call it in + /// response to a user gesture or an application state change. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger.of.0.dart ** + /// {@end-tool} + /// + /// A less elegant but more expedient solution is to assign a [GlobalKey] to the + /// [ScaffoldMessenger], then use the `key.currentState` property to obtain the + /// [ScaffoldMessengerState] rather than using the [ScaffoldMessenger.of] + /// function. The [MaterialApp.scaffoldMessengerKey] refers to the root + /// ScaffoldMessenger that is provided by default. + /// + /// {@tool dartpad} + /// Sometimes [SnackBar]s are produced by code that doesn't have ready access + /// to a valid [BuildContext]. One such example of this is when you show a + /// SnackBar from a method outside of the `build` function. In these + /// cases, you can assign a [GlobalKey] to the [ScaffoldMessenger]. This + /// example shows a key being used to obtain the [ScaffoldMessengerState] + /// provided by the [MaterialApp]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger.of.1.dart ** + /// {@end-tool} + /// + /// If there is no [ScaffoldMessenger] in scope, then this will assert in + /// debug mode, and throw an exception in release mode. + /// + /// See also: + /// + /// * [maybeOf], which is a similar function but will return null instead of + /// throwing if there is no [ScaffoldMessenger] ancestor. + /// * [debugCheckHasScaffoldMessenger], which asserts that the given context + /// has a [ScaffoldMessenger] ancestor. + static ScaffoldMessengerState of(BuildContext context) { + assert(debugCheckHasScaffoldMessenger(context)); + + final _ScaffoldMessengerScope scope = context + .dependOnInheritedWidgetOfExactType<_ScaffoldMessengerScope>()!; + return scope._scaffoldMessengerState; + } + + /// The state from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Will return null if a [ScaffoldMessenger] is not found in the given context. + /// + /// See also: + /// + /// * [of], which is a similar function, except that it will throw an + /// exception if a [ScaffoldMessenger] is not found in the given context. + static ScaffoldMessengerState? maybeOf(BuildContext context) { + final _ScaffoldMessengerScope? scope = context + .dependOnInheritedWidgetOfExactType<_ScaffoldMessengerScope>(); + return scope?._scaffoldMessengerState; + } + + @override + ScaffoldMessengerState createState() => ScaffoldMessengerState(); +} + +/// State for a [ScaffoldMessenger]. +/// +/// A [ScaffoldMessengerState] object can be used to [showSnackBar] or +/// [showMaterialBanner] for every registered [Scaffold] that is a descendant of +/// the associated [ScaffoldMessenger]. Scaffolds will register to receive +/// [SnackBar]s and [MaterialBanner]s from their closest ScaffoldMessenger +/// ancestor. +/// +/// Typically obtained via [ScaffoldMessenger.of]. +class ScaffoldMessengerState extends State<ScaffoldMessenger> with TickerProviderStateMixin { + final LinkedHashSet<ScaffoldState> _scaffolds = LinkedHashSet<ScaffoldState>(); + final Queue<ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>> + _materialBanners = Queue<ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>>(); + AnimationController? _materialBannerController; + final Queue<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> _snackBars = + Queue<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>(); + AnimationController? _snackBarController; + Timer? _snackBarTimer; + late bool _accessibleNavigation; + + @protected + @override + void didChangeDependencies() { + _accessibleNavigation = MediaQuery.accessibleNavigationOf(context); + super.didChangeDependencies(); + } + + void _register(ScaffoldState scaffold) { + _scaffolds.add(scaffold); + + if (_isRoot(scaffold)) { + if (_snackBars.isNotEmpty) { + scaffold._updateSnackBar(); + } + + if (_materialBanners.isNotEmpty) { + scaffold._updateMaterialBanner(); + } + } + } + + void _unregister(ScaffoldState scaffold) { + final bool removed = _scaffolds.remove(scaffold); + // ScaffoldStates should only be removed once. + assert(removed); + } + + void _updateScaffolds() { + for (final ScaffoldState scaffold in _scaffolds) { + if (_isRoot(scaffold)) { + scaffold._updateSnackBar(); + scaffold._updateMaterialBanner(); + } + } + } + + // Nested Scaffolds are handled by the ScaffoldMessenger by only presenting a + // MaterialBanner or SnackBar in the root Scaffold of the nested set. + bool _isRoot(ScaffoldState scaffold) { + final ScaffoldState? parent = scaffold.context.findAncestorStateOfType<ScaffoldState>(); + return parent == null || !_scaffolds.contains(parent); + } + + // SNACKBAR API + + /// Shows a [SnackBar] across all registered [Scaffold]s. Scaffolds register + /// to receive snack bars from their closest [ScaffoldMessenger] ancestor. + /// If there are several registered scaffolds the snack bar is shown + /// simultaneously on all of them. + /// + /// A scaffold can show at most one snack bar at a time. If this function is + /// called while another snack bar is already visible, the given snack bar + /// will be added to a queue and displayed after the earlier snack bars have + /// closed. + /// + /// To control how long a [SnackBar] remains visible, use [SnackBar.duration]. + /// + /// To remove the [SnackBar] with an exit animation, use [hideCurrentSnackBar] + /// or call [ScaffoldFeatureController.close] on the returned + /// [ScaffoldFeatureController]. To remove a [SnackBar] suddenly (without an + /// animation), use [removeCurrentSnackBar]. + /// + /// See [ScaffoldMessenger.of] for information about how to obtain the + /// [ScaffoldMessengerState]. + /// + /// {@tool dartpad} + /// Here is an example of showing a [SnackBar] when the user presses a button. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.0.dart ** + /// {@end-tool} + /// + /// ## Relative positioning of floating SnackBars + /// + /// A [SnackBar] with [SnackBar.behavior] set to [SnackBarBehavior.floating] is + /// positioned above the widgets provided to [Scaffold.floatingActionButton], + /// [Scaffold.persistentFooterButtons], and [Scaffold.bottomNavigationBar]. + /// If some or all of these widgets take up enough space such that the SnackBar + /// would not be visible when positioned above them, an error will be thrown. + /// In this case, consider constraining the size of these widgets to allow room for + /// the SnackBar to be visible. + /// + /// {@tool dartpad} + /// Here is an example showing how to display a [SnackBar] with [showSnackBar] + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// Here is an example showing that a floating [SnackBar] appears above [Scaffold.floatingActionButton]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart ** + /// {@end-tool} + /// + /// If [AnimationStyle.duration] is provided in the [snackBarAnimationStyle] + /// parameter, it will be used to override the snackbar show animation duration. + /// Otherwise, defaults to 250ms. + /// + /// If [AnimationStyle.reverseDuration] is provided in the [snackBarAnimationStyle] + /// parameter, it will be used to override the snackbar hide animation duration. + /// Otherwise, defaults to 250ms. + /// + /// To disable the snackbar animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override [SnackBar] show and hide animation + /// duration using [AnimationStyle] in [ScaffoldMessengerState.showSnackBar]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart ** + /// {@end-tool} + /// + ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSnackBar( + SnackBar snackBar, { + AnimationStyle? snackBarAnimationStyle, + }) { + assert( + _scaffolds.isNotEmpty, + 'ScaffoldMessenger.showSnackBar was called, but there are currently no ' + 'descendant Scaffolds to present to.', + ); + _didUpdateAnimationStyle(snackBarAnimationStyle); + _snackBarController ??= SnackBar.createAnimationController( + duration: snackBarAnimationStyle?.duration, + reverseDuration: snackBarAnimationStyle?.reverseDuration, + vsync: this, + )..addStatusListener(_handleSnackBarStatusChanged); + if (_snackBars.isEmpty) { + assert(_snackBarController!.isDismissed); + _snackBarController!.forward(); + } + late ScaffoldFeatureController<SnackBar, SnackBarClosedReason> controller; + controller = ScaffoldFeatureController<SnackBar, SnackBarClosedReason>._( + // We provide a fallback key so that if back-to-back snackbars happen to + // match in structure, material ink splashes and highlights don't survive + // from one to the next. + snackBar.withAnimation(_snackBarController!, fallbackKey: UniqueKey()), + Completer<SnackBarClosedReason>(), + () { + assert(_snackBars.first == controller); + hideCurrentSnackBar(); + }, + null, // SnackBar doesn't use a builder function so setState() wouldn't rebuild it + ); + try { + setState(() { + _snackBars.addLast(controller); + }); + _updateScaffolds(); + } catch (exception) { + assert(() { + if (exception is FlutterError) { + final String summary = exception.diagnostics.first.toDescription(); + if (summary == 'setState() or markNeedsBuild() called during build.') { + final information = <DiagnosticsNode>[ + ErrorSummary('The showSnackBar() method cannot be called during build.'), + ErrorDescription( + 'The showSnackBar() method was called during build, which is ' + 'prohibited as showing snack bars requires updating state. Updating ' + 'state is not possible during build.', + ), + ErrorHint( + 'Instead of calling showSnackBar() during build, call it directly ' + 'in your on tap (and related) callbacks. If you need to immediately ' + 'show a snack bar, make the call in initState() or ' + 'didChangeDependencies() instead. Otherwise, you can also schedule a ' + 'post-frame callback using SchedulerBinding.addPostFrameCallback to ' + 'show the snack bar after the current frame.', + ), + context.describeOwnershipChain( + 'The ownership chain for the particular ScaffoldMessenger is', + ), + ]; + throw FlutterError.fromParts(information); + } + } + return true; + }()); + rethrow; + } + + return controller; + } + + void _didUpdateAnimationStyle(AnimationStyle? snackBarAnimationStyle) { + if (snackBarAnimationStyle != null) { + if (_snackBarController?.duration != snackBarAnimationStyle.duration || + _snackBarController?.reverseDuration != snackBarAnimationStyle.reverseDuration) { + _snackBarController?.dispose(); + _snackBarController = null; + } + } + } + + void _handleSnackBarStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + assert(_snackBars.isNotEmpty); + setState(() { + _snackBars.removeFirst(); + }); + _updateScaffolds(); + if (_snackBars.isNotEmpty) { + _snackBarController!.forward(); + } + case AnimationStatus.completed: + setState(() { + assert(_snackBarTimer == null); + // build will create a new timer if necessary to dismiss the snackBar. + }); + _updateScaffolds(); + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + } + } + + /// Removes the current [SnackBar] (if any) immediately from registered + /// [Scaffold]s. + /// + /// The removed snack bar does not run its normal exit animation. If there are + /// any queued snack bars, they begin their entrance animation immediately. + void removeCurrentSnackBar({SnackBarClosedReason reason = SnackBarClosedReason.remove}) { + if (_snackBars.isEmpty) { + return; + } + final Completer<SnackBarClosedReason> completer = _snackBars.first._completer; + if (!completer.isCompleted) { + completer.complete(reason); + } + _snackBarTimer?.cancel(); + _snackBarTimer = null; + // This will trigger the animation's status callback. + _snackBarController!.value = 0.0; + } + + /// Removes the current [SnackBar] by running its normal exit animation. + /// + /// The closed completer is called after the animation is complete. + void hideCurrentSnackBar({SnackBarClosedReason reason = SnackBarClosedReason.hide}) { + if (_snackBars.isEmpty || _snackBarController!.isDismissed) { + return; + } + final Completer<SnackBarClosedReason> completer = _snackBars.first._completer; + if (_accessibleNavigation) { + _snackBarController!.value = 0.0; + completer.complete(reason); + } else { + _snackBarController!.reverse().then<void>((void value) { + assert(mounted); + if (!completer.isCompleted) { + completer.complete(reason); + } + }); + } + _snackBarTimer?.cancel(); + _snackBarTimer = null; + } + + /// Removes all the snackBars currently in queue by clearing the queue + /// and running normal exit animation on the current snackBar. + void clearSnackBars() { + if (_snackBars.isEmpty || _snackBarController!.isDismissed) { + return; + } + final ScaffoldFeatureController<SnackBar, SnackBarClosedReason> currentSnackbar = + _snackBars.first; + _snackBars.clear(); + _snackBars.add(currentSnackbar); + hideCurrentSnackBar(); + } + + // MATERIAL BANNER API + + /// Shows a [MaterialBanner] across all registered [Scaffold]s. Scaffolds register + /// to receive material banners from their closest [ScaffoldMessenger] ancestor. + /// If there are several registered scaffolds the material banner is shown + /// simultaneously on all of them. + /// + /// A scaffold can show at most one material banner at a time. If this function is + /// called while another material banner is already visible, the given material banner + /// will be added to a queue and displayed after the earlier material banners have + /// closed. + /// + /// To remove the [MaterialBanner] with an exit animation, use [hideCurrentMaterialBanner] + /// or call [ScaffoldFeatureController.close] on the returned + /// [ScaffoldFeatureController]. To remove a [MaterialBanner] suddenly (without an + /// animation), use [removeCurrentMaterialBanner]. + /// + /// See [ScaffoldMessenger.of] for information about how to obtain the + /// [ScaffoldMessengerState]. + /// + /// {@tool dartpad} + /// Here is an example of showing a [MaterialBanner] when the user presses a button. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_material_banner.0.dart ** + /// {@end-tool} + ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason> showMaterialBanner( + MaterialBanner materialBanner, + ) { + assert( + _scaffolds.isNotEmpty, + 'ScaffoldMessenger.showMaterialBanner was called, but there are currently no ' + 'descendant Scaffolds to present to.', + ); + _materialBannerController ??= MaterialBanner.createAnimationController(vsync: this) + ..addStatusListener(_handleMaterialBannerStatusChanged); + if (_materialBanners.isEmpty) { + assert(_materialBannerController!.isDismissed); + _materialBannerController!.forward(); + } + late ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason> controller; + controller = ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>._( + // We provide a fallback key so that if back-to-back material banners happen to + // match in structure, material ink splashes and highlights don't survive + // from one to the next. + materialBanner.withAnimation(_materialBannerController!, fallbackKey: UniqueKey()), + Completer<MaterialBannerClosedReason>(), + () { + assert(_materialBanners.first == controller); + hideCurrentMaterialBanner(); + }, + null, // MaterialBanner doesn't use a builder function so setState() wouldn't rebuild it + ); + setState(() { + _materialBanners.addLast(controller); + }); + _updateScaffolds(); + return controller; + } + + void _handleMaterialBannerStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + assert(_materialBanners.isNotEmpty); + setState(() { + _materialBanners.removeFirst(); + }); + _updateScaffolds(); + if (_materialBanners.isNotEmpty) { + _materialBannerController!.forward(); + } + case AnimationStatus.completed: + _updateScaffolds(); + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + } + } + + /// Removes the current [MaterialBanner] (if any) immediately from registered + /// [Scaffold]s. + /// + /// The removed material banner does not run its normal exit animation. If there are + /// any queued material banners, they begin their entrance animation immediately. + void removeCurrentMaterialBanner({ + MaterialBannerClosedReason reason = MaterialBannerClosedReason.remove, + }) { + if (_materialBanners.isEmpty) { + return; + } + final Completer<MaterialBannerClosedReason> completer = _materialBanners.first._completer; + if (!completer.isCompleted) { + completer.complete(reason); + } + + // This will trigger the animation's status callback. + _materialBannerController!.value = 0.0; + } + + /// Removes the current [MaterialBanner] by running its normal exit animation. + /// + /// The closed completer is called after the animation is complete. + void hideCurrentMaterialBanner({ + MaterialBannerClosedReason reason = MaterialBannerClosedReason.hide, + }) { + if (_materialBanners.isEmpty || _materialBannerController!.isDismissed) { + return; + } + final Completer<MaterialBannerClosedReason> completer = _materialBanners.first._completer; + if (_accessibleNavigation) { + _materialBannerController!.value = 0.0; + completer.complete(reason); + } else { + _materialBannerController!.reverse().then<void>((void value) { + assert(mounted); + if (!completer.isCompleted) { + completer.complete(reason); + } + }); + } + } + + /// Removes all the [MaterialBanner]s currently in queue by clearing the queue + /// and running normal exit animation on the current [MaterialBanner]. + void clearMaterialBanners() { + if (_materialBanners.isEmpty || _materialBannerController!.isDismissed) { + return; + } + final ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason> + currentMaterialBanner = _materialBanners.first; + _materialBanners.clear(); + _materialBanners.add(currentMaterialBanner); + hideCurrentMaterialBanner(); + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + _accessibleNavigation = MediaQuery.accessibleNavigationOf(context); + + if (_snackBars.isNotEmpty) { + final ModalRoute<dynamic>? route = ModalRoute.of(context); + if (route == null || route.isCurrent) { + if (_snackBarController!.isCompleted && _snackBarTimer == null) { + final SnackBar snackBar = _snackBars.first._widget; + _snackBarTimer = Timer(snackBar.duration, () { + assert(_snackBarController!.isForwardOrCompleted); + // Look up MediaQuery again in case the setting changed. + if (snackBar.persist) { + return; + } + hideCurrentSnackBar(reason: SnackBarClosedReason.timeout); + }); + } + } + } + + return _ScaffoldMessengerScope(scaffoldMessengerState: this, child: widget.child); + } + + @protected + @override + void dispose() { + _materialBannerController?.dispose(); + _snackBarController?.dispose(); + _snackBarTimer?.cancel(); + _snackBarTimer = null; + super.dispose(); + } +} + +class _ScaffoldMessengerScope extends InheritedWidget { + const _ScaffoldMessengerScope({ + required super.child, + required ScaffoldMessengerState scaffoldMessengerState, + }) : _scaffoldMessengerState = scaffoldMessengerState; + + final ScaffoldMessengerState _scaffoldMessengerState; + + @override + bool updateShouldNotify(_ScaffoldMessengerScope old) => + _scaffoldMessengerState != old._scaffoldMessengerState; +} + +/// The geometry of the [Scaffold] after all its contents have been laid out +/// except the [FloatingActionButton]. +/// +/// The [Scaffold] passes this pre-layout geometry to its +/// [FloatingActionButtonLocation], which produces an [Offset] that the +/// [Scaffold] uses to position the [FloatingActionButton]. +/// +/// For a description of the [Scaffold]'s geometry after it has +/// finished laying out, see the [ScaffoldGeometry]. +@immutable +class ScaffoldPrelayoutGeometry { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const ScaffoldPrelayoutGeometry({ + required this.bottomSheetSize, + required this.contentBottom, + required this.contentTop, + required this.floatingActionButtonSize, + required this.minInsets, + required this.minViewPadding, + required this.scaffoldSize, + required this.snackBarSize, + required this.materialBannerSize, + required this.textDirection, + }); + + /// The [Size] of [Scaffold.floatingActionButton]. + /// + /// If [Scaffold.floatingActionButton] is null, this will be [Size.zero]. + final Size floatingActionButtonSize; + + /// The [Size] of the [Scaffold]'s [BottomSheet]. + /// + /// If the [Scaffold] is not currently showing a [BottomSheet], + /// this will be [Size.zero]. + final Size bottomSheetSize; + + /// The vertical distance from the Scaffold's origin to the bottom of + /// [Scaffold.body]. + /// + /// This is useful in a [FloatingActionButtonLocation] designed to + /// place the [FloatingActionButton] at the bottom of the screen, while + /// keeping it above the [BottomSheet], the [Scaffold.bottomNavigationBar], + /// or the keyboard. + /// + /// The [Scaffold.body] is laid out with respect to [minInsets] already. This + /// means that a [FloatingActionButtonLocation] does not need to factor in + /// [EdgeInsets.bottom] of [minInsets] when aligning a [FloatingActionButton] + /// to [contentBottom]. + final double contentBottom; + + /// The vertical distance from the [Scaffold]'s origin to the top of + /// [Scaffold.body]. + /// + /// This is useful in a [FloatingActionButtonLocation] designed to + /// place the [FloatingActionButton] at the top of the screen, while + /// keeping it below the [Scaffold.appBar]. + /// + /// The [Scaffold.body] is laid out with respect to [minInsets] already. This + /// means that a [FloatingActionButtonLocation] does not need to factor in + /// [EdgeInsets.top] of [minInsets] when aligning a [FloatingActionButton] to + /// [contentTop]. + final double contentTop; + + /// The minimum padding to inset the [FloatingActionButton] by for it + /// to remain visible. + /// + /// This value is the result of calling [MediaQueryData.padding] in the + /// [Scaffold]'s [BuildContext], + /// and is useful for insetting the [FloatingActionButton] to avoid features like + /// the system status bar or the keyboard. + /// + /// If [Scaffold.resizeToAvoidBottomInset] is set to false, + /// [EdgeInsets.bottom] of [minInsets] will be 0.0. + final EdgeInsets minInsets; + + /// The minimum padding to inset interactive elements to be within a safe, + /// un-obscured space. + /// + /// This value reflects the [MediaQueryData.viewPadding] of the [Scaffold]'s + /// [BuildContext] when [Scaffold.resizeToAvoidBottomInset] is false or and + /// the [MediaQueryData.viewInsets] > 0.0. This helps distinguish between + /// different types of obstructions on the screen, such as software keyboards + /// and physical device notches. + final EdgeInsets minViewPadding; + + /// The [Size] of the whole [Scaffold]. + /// + /// If the [Size] of the [Scaffold]'s contents is modified by values such as + /// [Scaffold.resizeToAvoidBottomInset] or the keyboard opening, then the + /// [scaffoldSize] will not reflect those changes. + /// + /// This means that [FloatingActionButtonLocation]s designed to reposition + /// the [FloatingActionButton] based on events such as the keyboard popping + /// up should use [minInsets] to make sure that the [FloatingActionButton] is + /// inset by enough to remain visible. + /// + /// See [minInsets] and [MediaQueryData.padding] for more information on the + /// appropriate insets to apply. + final Size scaffoldSize; + + /// The [Size] of the [Scaffold]'s [SnackBar]. + /// + /// If the [Scaffold] is not showing a [SnackBar], this will be [Size.zero]. + final Size snackBarSize; + + /// The [Size] of the [Scaffold]'s [MaterialBanner]. + /// + /// If the [Scaffold] is not showing a [MaterialBanner], this will be [Size.zero]. + final Size materialBannerSize; + + /// The [TextDirection] of the [Scaffold]'s [BuildContext]. + final TextDirection textDirection; +} + +/// A snapshot of a transition between two [FloatingActionButtonLocation]s. +/// +/// [ScaffoldState] uses this to seamlessly change transition animations +/// when a running [FloatingActionButtonLocation] transition is interrupted by a new transition. +@immutable +class _TransitionSnapshotFabLocation extends FloatingActionButtonLocation { + const _TransitionSnapshotFabLocation(this.begin, this.end, this.animator, this.progress); + + final FloatingActionButtonLocation begin; + final FloatingActionButtonLocation end; + final FloatingActionButtonAnimator animator; + final double progress; + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + return animator.getOffset( + begin: begin.getOffset(scaffoldGeometry), + end: end.getOffset(scaffoldGeometry), + progress: progress, + ); + } + + @override + String toString() { + return '${objectRuntimeType(this, '_TransitionSnapshotFabLocation')}(begin: $begin, end: $end, progress: $progress)'; + } +} + +/// Geometry information for [Scaffold] components after layout is finished. +/// +/// To get a [ValueNotifier] for the scaffold geometry of a given +/// [BuildContext], use [Scaffold.geometryOf]. +/// +/// The ScaffoldGeometry is only available during the paint phase, because +/// its value is computed during the animation and layout phases prior to painting. +/// +/// For an example of using the [ScaffoldGeometry], see the [BottomAppBar], +/// which uses the [ScaffoldGeometry] to paint a notch around the +/// [FloatingActionButton]. +/// +/// For information about the [Scaffold]'s geometry that is used while laying +/// out the [FloatingActionButton], see [ScaffoldPrelayoutGeometry]. +@immutable +class ScaffoldGeometry { + /// Create an object that describes the geometry of a [Scaffold]. + const ScaffoldGeometry({this.bottomNavigationBarTop, this.floatingActionButtonArea}); + + /// The distance from the [Scaffold]'s top edge to the top edge of the + /// rectangle in which the [Scaffold.bottomNavigationBar] bar is laid out. + /// + /// Null if [Scaffold.bottomNavigationBar] is null. + final double? bottomNavigationBarTop; + + /// The [Scaffold.floatingActionButton]'s bounding rectangle. + /// + /// This is null when there is no floating action button showing. + final Rect? floatingActionButtonArea; + + ScaffoldGeometry _scaleFloatingActionButton(double scaleFactor) { + if (scaleFactor == 1.0) { + return this; + } + + if (scaleFactor == 0.0) { + return ScaffoldGeometry(bottomNavigationBarTop: bottomNavigationBarTop); + } + + final Rect scaledButton = Rect.lerp( + floatingActionButtonArea!.center & Size.zero, + floatingActionButtonArea, + scaleFactor, + )!; + return copyWith(floatingActionButtonArea: scaledButton); + } + + /// Creates a copy of this [ScaffoldGeometry] but with the given fields replaced with + /// the new values. + ScaffoldGeometry copyWith({double? bottomNavigationBarTop, Rect? floatingActionButtonArea}) { + return ScaffoldGeometry( + bottomNavigationBarTop: bottomNavigationBarTop ?? this.bottomNavigationBarTop, + floatingActionButtonArea: floatingActionButtonArea ?? this.floatingActionButtonArea, + ); + } +} + +class _ScaffoldGeometryNotifier extends ChangeNotifier + implements ValueListenable<ScaffoldGeometry> { + _ScaffoldGeometryNotifier(this.geometry, this.context); + + final BuildContext context; + double? floatingActionButtonScale; + ScaffoldGeometry geometry; + + @override + ScaffoldGeometry get value { + assert(() { + final RenderObject? renderObject = context.findRenderObject(); + if (renderObject == null || !renderObject.owner!.debugDoingPaint) { + throw FlutterError( + 'Scaffold.geometryOf() must only be accessed during the paint phase.\n' + 'The ScaffoldGeometry is only available during the paint phase, because ' + 'its value is computed during the animation and layout phases prior to painting.', + ); + } + return true; + }()); + + return geometry._scaleFloatingActionButton(floatingActionButtonScale!); + } + + void _updateWith({ + double? bottomNavigationBarTop, + Rect? floatingActionButtonArea, + double? floatingActionButtonScale, + }) { + this.floatingActionButtonScale = floatingActionButtonScale ?? this.floatingActionButtonScale; + geometry = geometry.copyWith( + bottomNavigationBarTop: bottomNavigationBarTop, + floatingActionButtonArea: floatingActionButtonArea, + ); + notifyListeners(); + } +} + +// Used to communicate the height of the Scaffold's bottomNavigationBar and +// persistentFooterButtons to the LayoutBuilder which builds the Scaffold's body. +// +// Scaffold expects a _BodyBoxConstraints to be passed to the _BodyBuilder +// widget's LayoutBuilder, see _ScaffoldLayout.performLayout(). The BoxConstraints +// methods that construct new BoxConstraints objects, like copyWith() have not +// been overridden here because we expect the _BodyBoxConstraintsObject to be +// passed along unmodified to the LayoutBuilder. If that changes in the future +// then _BodyBuilder will assert. +class _BodyBoxConstraints extends BoxConstraints { + const _BodyBoxConstraints({ + super.maxWidth, + super.maxHeight, + required this.bottomWidgetsHeight, + required this.appBarHeight, + required this.materialBannerHeight, + }) : assert(bottomWidgetsHeight >= 0), + assert(appBarHeight >= 0), + assert(materialBannerHeight >= 0); + + final double bottomWidgetsHeight; + final double appBarHeight; + final double materialBannerHeight; + + // RenderObject.layout() will only short-circuit its call to its performLayout + // method if the new layout constraints are not == to the current constraints. + // If the height of the bottom widgets has changed, even though the constraints' + // min and max values have not, we still want performLayout to happen. + @override + bool operator ==(Object other) { + if (super != other) { + return false; + } + return other is _BodyBoxConstraints && + other.materialBannerHeight == materialBannerHeight && + other.bottomWidgetsHeight == bottomWidgetsHeight && + other.appBarHeight == appBarHeight; + } + + @override + int get hashCode => + Object.hash(super.hashCode, materialBannerHeight, bottomWidgetsHeight, appBarHeight); +} + +// Used when Scaffold.extendBody is true to wrap the scaffold's body in a MediaQuery +// whose padding accounts for the height of the bottomNavigationBar and/or the +// persistentFooterButtons. +// +// The bottom widgets' height is passed along via the _BodyBoxConstraints parameter. +// The constraints parameter is constructed in_ScaffoldLayout.performLayout(). +class _BodyBuilder extends StatelessWidget { + const _BodyBuilder({ + required this.extendBody, + required this.extendBodyBehindAppBar, + required this.body, + }); + + final Widget body; + final bool extendBody; + final bool extendBodyBehindAppBar; + + @override + Widget build(BuildContext context) { + if (!extendBody && !extendBodyBehindAppBar) { + return body; + } + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bodyConstraints = constraints as _BodyBoxConstraints; + final MediaQueryData metrics = MediaQuery.of(context); + + final double bottom = extendBody + ? math.max(metrics.padding.bottom, bodyConstraints.bottomWidgetsHeight) + : metrics.padding.bottom; + + final double top = extendBodyBehindAppBar + ? math.max( + metrics.padding.top, + bodyConstraints.appBarHeight + bodyConstraints.materialBannerHeight, + ) + : metrics.padding.top; + + return MediaQuery( + data: metrics.copyWith( + padding: metrics.padding.copyWith(top: top, bottom: bottom), + ), + child: body, + ); + }, + ); + } +} + +class _ScaffoldLayout extends MultiChildLayoutDelegate { + _ScaffoldLayout({ + required this.minInsets, + required this.minViewPadding, + required this.textDirection, + required this.geometryNotifier, + // for floating action button + required this.previousFloatingActionButtonLocation, + required this.currentFloatingActionButtonLocation, + required this.floatingActionButtonMoveAnimation, + required this.floatingActionButtonMotionAnimator, + required this.isSnackBarFloating, + required this.snackBarWidth, + required this.extendBody, + required this.extendBodyBehindAppBar, + required this.extendBodyBehindMaterialBanner, + }) : super(relayout: floatingActionButtonMoveAnimation); + + final bool extendBody; + final bool extendBodyBehindAppBar; + final EdgeInsets minInsets; + final EdgeInsets minViewPadding; + final TextDirection textDirection; + final _ScaffoldGeometryNotifier geometryNotifier; + + final FloatingActionButtonLocation previousFloatingActionButtonLocation; + final FloatingActionButtonLocation currentFloatingActionButtonLocation; + final ValueListenable<double> floatingActionButtonMoveAnimation; + final FloatingActionButtonAnimator floatingActionButtonMotionAnimator; + + final bool isSnackBarFloating; + final double? snackBarWidth; + + final bool extendBodyBehindMaterialBanner; + + @override + void performLayout(Size size) { + final looseConstraints = BoxConstraints.loose(size); + + // This part of the layout has the same effect as putting the app bar and + // body in a column and making the body flexible. What's different is that + // in this case the app bar appears _after_ the body in the stacking order, + // so the app bar's shadow is drawn on top of the body. + + final BoxConstraints fullWidthConstraints = looseConstraints.tighten(width: size.width); + final double bottom = size.height; + var contentTop = 0.0; + var bottomWidgetsHeight = 0.0; + var appBarHeight = 0.0; + + if (hasChild(_ScaffoldSlot.appBar)) { + appBarHeight = layoutChild(_ScaffoldSlot.appBar, fullWidthConstraints).height; + contentTop = extendBodyBehindAppBar ? 0.0 : appBarHeight; + positionChild(_ScaffoldSlot.appBar, Offset.zero); + } + + double? bottomNavigationBarTop; + if (hasChild(_ScaffoldSlot.bottomNavigationBar)) { + final double bottomNavigationBarHeight = layoutChild( + _ScaffoldSlot.bottomNavigationBar, + fullWidthConstraints, + ).height; + bottomWidgetsHeight += bottomNavigationBarHeight; + bottomNavigationBarTop = math.max(0.0, bottom - bottomWidgetsHeight); + positionChild(_ScaffoldSlot.bottomNavigationBar, Offset(0.0, bottomNavigationBarTop)); + } + + if (hasChild(_ScaffoldSlot.persistentFooter)) { + final footerConstraints = BoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: math.max(0.0, bottom - bottomWidgetsHeight - contentTop), + ); + final double persistentFooterHeight = layoutChild( + _ScaffoldSlot.persistentFooter, + footerConstraints, + ).height; + bottomWidgetsHeight += persistentFooterHeight; + positionChild( + _ScaffoldSlot.persistentFooter, + Offset(0.0, math.max(0.0, bottom - bottomWidgetsHeight)), + ); + } + + Size materialBannerSize = Size.zero; + if (hasChild(_ScaffoldSlot.materialBanner)) { + materialBannerSize = layoutChild(_ScaffoldSlot.materialBanner, fullWidthConstraints); + positionChild(_ScaffoldSlot.materialBanner, Offset(0.0, appBarHeight)); + + // Push content down only if elevation is 0. + if (!extendBodyBehindMaterialBanner) { + contentTop += materialBannerSize.height; + } + } + + // Set the content bottom to account for the greater of the height of any + // bottom-anchored material widgets or of the keyboard or other + // bottom-anchored system UI. + final double contentBottom = math.max( + 0.0, + bottom - math.max(minInsets.bottom, bottomWidgetsHeight), + ); + + if (hasChild(_ScaffoldSlot.body)) { + double bodyMaxHeight = math.max(0.0, contentBottom - contentTop); + + // When extendBody is true, the body is visible underneath the bottom widgets. + // This does not apply when the area is obscured by the device keyboard. + if (extendBody && minInsets.bottom <= bottomWidgetsHeight) { + bodyMaxHeight += bottomWidgetsHeight; + bodyMaxHeight = clampDouble(bodyMaxHeight, 0.0, looseConstraints.maxHeight - contentTop); + assert(bodyMaxHeight <= math.max(0.0, looseConstraints.maxHeight - contentTop)); + } else { + bottomWidgetsHeight = 0.0; + } + + final BoxConstraints bodyConstraints = _BodyBoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: bodyMaxHeight, + materialBannerHeight: materialBannerSize.height, + bottomWidgetsHeight: bottomWidgetsHeight, + appBarHeight: appBarHeight, + ); + layoutChild(_ScaffoldSlot.body, bodyConstraints); + positionChild(_ScaffoldSlot.body, Offset(0.0, contentTop)); + } + + // The BottomSheet and the SnackBar are anchored to the bottom of the parent, + // they're as wide as the parent and are given their intrinsic height. The + // only difference is that SnackBar appears on the top side of the + // BottomNavigationBar while the BottomSheet is stacked on top of it. + // + // If all three elements are present then either the center of the FAB straddles + // the top edge of the BottomSheet or the bottom of the FAB is + // kFloatingActionButtonMargin above the SnackBar, whichever puts the FAB + // the farthest above the bottom of the parent. If only the FAB is has a + // non-zero height then it's inset from the parent's right and bottom edges + // by kFloatingActionButtonMargin. + + Size bottomSheetSize = Size.zero; + Size snackBarSize = Size.zero; + if (hasChild(_ScaffoldSlot.bodyScrim)) { + final bottomSheetScrimConstraints = BoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: contentBottom, + ); + layoutChild(_ScaffoldSlot.bodyScrim, bottomSheetScrimConstraints); + positionChild(_ScaffoldSlot.bodyScrim, Offset.zero); + } + + // Set the size of the SnackBar early if the behavior is fixed so + // the FAB can be positioned correctly. + if (hasChild(_ScaffoldSlot.snackBar) && !isSnackBarFloating) { + snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); + } + + if (hasChild(_ScaffoldSlot.bottomSheet)) { + final bottomSheetConstraints = BoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: math.max(0.0, contentBottom - contentTop), + ); + bottomSheetSize = layoutChild(_ScaffoldSlot.bottomSheet, bottomSheetConstraints); + positionChild( + _ScaffoldSlot.bottomSheet, + Offset((size.width - bottomSheetSize.width) / 2.0, contentBottom - bottomSheetSize.height), + ); + } + + late Rect floatingActionButtonRect; + if (hasChild(_ScaffoldSlot.floatingActionButton)) { + final Size fabSize = layoutChild(_ScaffoldSlot.floatingActionButton, looseConstraints); + + // To account for the FAB position being changed, we'll animate between + // the old and new positions. + final currentGeometry = ScaffoldPrelayoutGeometry( + bottomSheetSize: bottomSheetSize, + contentBottom: contentBottom, + + /// [appBarHeight] should be used instead of [contentTop] because + /// ScaffoldPrelayoutGeometry.contentTop must not be affected by [extendBodyBehindAppBar]. + contentTop: appBarHeight, + floatingActionButtonSize: fabSize, + minInsets: minInsets, + scaffoldSize: size, + snackBarSize: snackBarSize, + materialBannerSize: materialBannerSize, + textDirection: textDirection, + minViewPadding: minViewPadding, + ); + final Offset currentFabOffset = currentFloatingActionButtonLocation.getOffset( + currentGeometry, + ); + final Offset previousFabOffset = previousFloatingActionButtonLocation.getOffset( + currentGeometry, + ); + final Offset fabOffset = floatingActionButtonMotionAnimator.getOffset( + begin: previousFabOffset, + end: currentFabOffset, + progress: floatingActionButtonMoveAnimation.value, + ); + positionChild(_ScaffoldSlot.floatingActionButton, fabOffset); + floatingActionButtonRect = fabOffset & fabSize; + } + + if (hasChild(_ScaffoldSlot.snackBar)) { + final bool hasCustomWidth = snackBarWidth != null && snackBarWidth! < size.width; + if (snackBarSize == Size.zero) { + snackBarSize = layoutChild( + _ScaffoldSlot.snackBar, + hasCustomWidth ? looseConstraints : fullWidthConstraints, + ); + } + + final double snackBarYOffsetBase; + final bool showAboveFab = switch (currentFloatingActionButtonLocation) { + FloatingActionButtonLocation.startTop || + FloatingActionButtonLocation.centerTop || + FloatingActionButtonLocation.endTop || + FloatingActionButtonLocation.miniStartTop || + FloatingActionButtonLocation.miniCenterTop || + FloatingActionButtonLocation.miniEndTop => false, + FloatingActionButtonLocation.startDocked || + FloatingActionButtonLocation.startFloat || + FloatingActionButtonLocation.centerDocked || + FloatingActionButtonLocation.centerFloat || + FloatingActionButtonLocation.endContained || + FloatingActionButtonLocation.endDocked || + FloatingActionButtonLocation.endFloat || + FloatingActionButtonLocation.miniStartDocked || + FloatingActionButtonLocation.miniStartFloat || + FloatingActionButtonLocation.miniCenterDocked || + FloatingActionButtonLocation.miniCenterFloat || + FloatingActionButtonLocation.miniEndDocked || + FloatingActionButtonLocation.miniEndFloat => true, + FloatingActionButtonLocation() => true, + }; + if (floatingActionButtonRect.size != Size.zero && isSnackBarFloating && showAboveFab) { + if (bottomNavigationBarTop != null) { + snackBarYOffsetBase = math.min(bottomNavigationBarTop, floatingActionButtonRect.top); + } else { + snackBarYOffsetBase = floatingActionButtonRect.top; + } + } else { + // SnackBarBehavior.fixed applies a SafeArea automatically. + // SnackBarBehavior.floating does not since the positioning is affected + // if there is a FloatingActionButton (see condition above). If there is + // no FAB, make sure we account for safe space when the SnackBar is + // floating. + final double safeYOffsetBase = size.height - minViewPadding.bottom; + snackBarYOffsetBase = isSnackBarFloating + ? math.min(contentBottom, safeYOffsetBase) + : contentBottom; + } + + final double xOffset = hasCustomWidth ? (size.width - snackBarWidth!) / 2 : 0.0; + positionChild( + _ScaffoldSlot.snackBar, + Offset(xOffset, snackBarYOffsetBase - snackBarSize.height), + ); + + assert(() { + // Whether a floating SnackBar has been offset too high. + // + // To improve the developer experience, this assert is done after the call to positionChild. + // if we assert sooner the SnackBar is visible because its defaults position is (0,0) and + // it can cause confusion to the user as the error message states that the SnackBar is off screen. + if (isSnackBarFloating) { + final bool snackBarVisible = (snackBarYOffsetBase - snackBarSize.height) >= 0; + if (!snackBarVisible) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('Floating SnackBar presented off screen.'), + ErrorDescription( + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n', + ), + ErrorHint( + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.', + ), + ]); + } + } + return true; + }()); + } + + if (hasChild(_ScaffoldSlot.statusBar)) { + layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: minInsets.top)); + positionChild(_ScaffoldSlot.statusBar, Offset.zero); + } + + if (hasChild(_ScaffoldSlot.drawer)) { + layoutChild(_ScaffoldSlot.drawer, BoxConstraints.tight(size)); + positionChild(_ScaffoldSlot.drawer, Offset.zero); + } + + if (hasChild(_ScaffoldSlot.endDrawer)) { + layoutChild(_ScaffoldSlot.endDrawer, BoxConstraints.tight(size)); + positionChild(_ScaffoldSlot.endDrawer, Offset.zero); + } + + geometryNotifier._updateWith( + bottomNavigationBarTop: bottomNavigationBarTop, + floatingActionButtonArea: floatingActionButtonRect, + ); + } + + @override + bool shouldRelayout(_ScaffoldLayout oldDelegate) { + return oldDelegate.minInsets != minInsets || + oldDelegate.minViewPadding != minViewPadding || + oldDelegate.textDirection != textDirection || + oldDelegate.previousFloatingActionButtonLocation != previousFloatingActionButtonLocation || + oldDelegate.currentFloatingActionButtonLocation != currentFloatingActionButtonLocation || + oldDelegate.extendBody != extendBody || + oldDelegate.extendBodyBehindAppBar != extendBodyBehindAppBar; + } +} + +/// Handler for scale and rotation animations in the [FloatingActionButton]. +/// +/// Currently, there are two types of [FloatingActionButton] animations: +/// +/// * Entrance/Exit animations, which this widget triggers +/// when the [FloatingActionButton] is added, updated, or removed. +/// * Motion animations, which are triggered by the [Scaffold] +/// when its [FloatingActionButtonLocation] is updated. +class _FloatingActionButtonTransition extends StatefulWidget { + const _FloatingActionButtonTransition({ + required this.child, + required this.fabMoveAnimation, + required this.fabMotionAnimator, + required this.geometryNotifier, + required this.currentController, + }); + + final Widget? child; + final Animation<double> fabMoveAnimation; + final FloatingActionButtonAnimator fabMotionAnimator; + final _ScaffoldGeometryNotifier geometryNotifier; + + /// Controls the current child widget.child as it exits. + final AnimationController currentController; + + @override + _FloatingActionButtonTransitionState createState() => _FloatingActionButtonTransitionState(); +} + +class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> + with TickerProviderStateMixin { + // The animations applied to the Floating Action Button when it is entering or exiting. + // Controls the previous widget.child as it exits. + late AnimationController _previousController; + CurvedAnimation? _previousExitScaleAnimation; + CurvedAnimation? _previousExitRotationCurvedAnimation; + CurvedAnimation? _currentEntranceScaleAnimation; + late Animation<double> _previousScaleAnimation; + late TrainHoppingAnimation _previousRotationAnimation; + // The animations to run, considering the widget's fabMoveAnimation and the current/previous entrance/exit animations. + late Animation<double> _currentScaleAnimation; + late Animation<double> _extendedCurrentScaleAnimation; + late TrainHoppingAnimation _currentRotationAnimation; + Widget? _previousChild; + + @override + void initState() { + super.initState(); + + _previousController = AnimationController(duration: kFloatingActionButtonSegue, vsync: this) + ..addStatusListener(_handlePreviousAnimationStatusChanged); + _updateAnimations(); + + if (widget.child != null) { + // If we start out with a child, have the child appear fully visible instead + // of animating in. + widget.currentController.value = 1.0; + // With FloatingActionButtonAnimator.noAnimation, floatingActionButtonScale is null. + // Default to a scale of 1.0 to ensure the button remains visible. + _updateGeometryScale(1.0); + } else { + // If we start without a child we update the geometry object with a + // floating action button scale of 0, as it is not showing on the screen. + _updateGeometryScale(0.0); + } + } + + @override + void dispose() { + _previousController.dispose(); + _previousExitScaleAnimation?.dispose(); + _previousExitRotationCurvedAnimation?.dispose(); + _currentEntranceScaleAnimation?.dispose(); + _disposeAnimations(); + super.dispose(); + } + + @override + void didUpdateWidget(_FloatingActionButtonTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.fabMotionAnimator != widget.fabMotionAnimator || + oldWidget.fabMoveAnimation != widget.fabMoveAnimation) { + _disposeAnimations(); + // Get the right scale and rotation animations to use for this widget. + _updateAnimations(); + } + final oldChildIsNull = oldWidget.child == null; + final newChildIsNull = widget.child == null; + if (oldChildIsNull == newChildIsNull && oldWidget.child?.key == widget.child?.key) { + return; + } + if (_previousController.isDismissed) { + final double currentValue = widget.currentController.value; + if (currentValue == 0.0 || oldWidget.child == null) { + // The current child hasn't started its entrance animation yet. We can + // just skip directly to the new child's entrance. + _previousChild = null; + if (widget.child != null) { + widget.currentController.forward(); + } + } else { + // Otherwise, we need to copy the state from the current controller to + // the previous controller and run an exit animation for the previous + // widget before running the entrance animation for the new child. + _previousChild = oldWidget.child; + _previousController + ..value = currentValue + ..reverse(); + widget.currentController.value = 0.0; + } + } + } + + static final Animatable<double> _entranceTurnTween = Tween<double>( + begin: 1.0 - kFloatingActionButtonTurnInterval, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeIn)); + + void _disposeAnimations() { + _previousRotationAnimation.dispose(); + _currentRotationAnimation.dispose(); + } + + void _updateAnimations() { + _previousExitScaleAnimation?.dispose(); + // Get the animations for exit and entrance. + _previousExitScaleAnimation = CurvedAnimation( + parent: _previousController, + curve: Curves.easeIn, + ); + _previousExitRotationCurvedAnimation?.dispose(); + _previousExitRotationCurvedAnimation = CurvedAnimation( + parent: _previousController, + curve: Curves.easeIn, + ); + + final Animation<double> previousExitRotationAnimation = Tween<double>( + begin: 1.0, + end: 1.0, + ).animate(_previousExitRotationCurvedAnimation!); + + _currentEntranceScaleAnimation?.dispose(); + _currentEntranceScaleAnimation = CurvedAnimation( + parent: widget.currentController, + curve: Curves.easeIn, + ); + final Animation<double> currentEntranceRotationAnimation = widget.currentController.drive( + _entranceTurnTween, + ); + + // Get the animations for when the FAB is moving. + final Animation<double> moveScaleAnimation = widget.fabMotionAnimator.getScaleAnimation( + parent: widget.fabMoveAnimation, + ); + final Animation<double> moveRotationAnimation = widget.fabMotionAnimator.getRotationAnimation( + parent: widget.fabMoveAnimation, + ); + + // Aggregate the animations. + if (widget.fabMotionAnimator == FloatingActionButtonAnimator.noAnimation) { + _previousScaleAnimation = moveScaleAnimation; + _currentScaleAnimation = moveScaleAnimation; + _previousRotationAnimation = TrainHoppingAnimation(moveRotationAnimation, null); + _currentRotationAnimation = TrainHoppingAnimation(moveRotationAnimation, null); + } else { + _previousScaleAnimation = AnimationMin<double>( + moveScaleAnimation, + _previousExitScaleAnimation!, + ); + _currentScaleAnimation = AnimationMin<double>( + moveScaleAnimation, + _currentEntranceScaleAnimation!, + ); + _previousRotationAnimation = TrainHoppingAnimation( + previousExitRotationAnimation, + moveRotationAnimation, + ); + _currentRotationAnimation = TrainHoppingAnimation( + currentEntranceRotationAnimation, + moveRotationAnimation, + ); + } + + _extendedCurrentScaleAnimation = _currentScaleAnimation.drive( + CurveTween(curve: const Interval(0.0, 0.1)), + ); + _currentScaleAnimation.addListener(_onProgressChanged); + _previousScaleAnimation.addListener(_onProgressChanged); + } + + void _handlePreviousAnimationStatusChanged(AnimationStatus status) { + setState(() { + if (widget.child != null && status.isDismissed) { + assert(widget.currentController.isDismissed); + widget.currentController.forward(); + } + }); + } + + bool _isExtendedFloatingActionButton(Widget? widget) { + return widget is FloatingActionButton && widget.isExtended; + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.centerRight, + children: <Widget>[ + if (!_previousController.isDismissed) + if (_isExtendedFloatingActionButton(_previousChild)) + FadeTransition(opacity: _previousScaleAnimation, child: _previousChild) + else + ScaleTransition( + scale: _previousScaleAnimation, + child: RotationTransition(turns: _previousRotationAnimation, child: _previousChild), + ), + if (_isExtendedFloatingActionButton(widget.child)) + ScaleTransition( + scale: _extendedCurrentScaleAnimation, + child: FadeTransition(opacity: _currentScaleAnimation, child: widget.child), + ) + else + ScaleTransition( + scale: _currentScaleAnimation, + child: RotationTransition(turns: _currentRotationAnimation, child: widget.child), + ), + ], + ); + } + + void _onProgressChanged() { + _updateGeometryScale(math.max(_previousScaleAnimation.value, _currentScaleAnimation.value)); + } + + void _updateGeometryScale(double scale) { + widget.geometryNotifier._updateWith(floatingActionButtonScale: scale); + } +} + +/// Implements the basic Material Design visual layout structure. +/// +/// This class provides APIs for showing drawers and bottom sheets. +/// +/// To display a persistent bottom sheet, obtain the +/// [ScaffoldState] for the current [BuildContext] via [Scaffold.of] and use the +/// [ScaffoldState.showBottomSheet] function. +/// +/// {@tool dartpad} +/// This example shows a [Scaffold] with a [body] and [FloatingActionButton]. +/// The [body] is a [Text] placed in a [Center] in order to center the text +/// within the [Scaffold]. The [FloatingActionButton] is connected to a +/// callback that increments a counter. +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [Scaffold] with a blueGrey [backgroundColor], [body] +/// and [FloatingActionButton]. The [body] is a [Text] placed in a [Center] in +/// order to center the text within the [Scaffold]. The [FloatingActionButton] +/// is connected to a callback that increments a counter. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/scaffold_background_color.png) +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [Scaffold] with an [AppBar], a [BottomAppBar] and a +/// [FloatingActionButton]. The [body] is a [Text] placed in a [Center] in order +/// to center the text within the [Scaffold]. The [FloatingActionButton] is +/// centered and docked within the [BottomAppBar] using +/// [FloatingActionButtonLocation.centerDocked]. The [FloatingActionButton] is +/// connected to a callback that increments a counter. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/scaffold_bottom_app_bar.png) +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold.2.dart ** +/// {@end-tool} +/// +/// ## Scaffold layout, the keyboard, and display "notches" +/// +/// The scaffold will expand to fill the available space. That usually +/// means that it will occupy its entire window or device screen. When +/// the device's keyboard appears the Scaffold's ancestor [MediaQuery] +/// widget's [MediaQueryData.viewInsets] changes and the Scaffold will +/// be rebuilt. By default the scaffold's [body] is resized to make +/// room for the keyboard. To prevent the resize set +/// [resizeToAvoidBottomInset] to false. In either case the focused +/// widget will be scrolled into view if it's within a scrollable +/// container. +/// +/// The [MediaQueryData.padding] value defines areas that might +/// not be completely visible, like the display "notch" on the iPhone +/// X. The scaffold's [body] is not inset by this padding value +/// although an [appBar] or [bottomNavigationBar] will typically +/// cause the body to avoid the padding. The [SafeArea] +/// widget can be used within the scaffold's body to avoid areas +/// like display notches. +/// +/// ## Floating action button with a draggable scrollable bottom sheet +/// +/// If [Scaffold.bottomSheet] is a [DraggableScrollableSheet], +/// [Scaffold.floatingActionButton] is set, and the bottom sheet is dragged to +/// cover greater than 70% of the Scaffold's height, two things happen in parallel: +/// +/// * Scaffold starts to show scrim (see [ScaffoldState.showBodyScrim]), and +/// * [Scaffold.floatingActionButton] is scaled down through an animation with a [Curves.easeIn], and +/// disappears when the bottom sheet covers the entire Scaffold. +/// +/// And as soon as the bottom sheet is dragged down to cover less than 70% of the [Scaffold], the scrim +/// disappears and [Scaffold.floatingActionButton] animates back to its normal size. +/// +/// ## Troubleshooting +/// +/// ### Nested Scaffolds +/// +/// The Scaffold is designed to be a top level container for +/// a [MaterialApp]. This means that adding a Scaffold +/// to each route on a Material app will provide the app with +/// Material's basic visual layout structure. +/// +/// It is typically not necessary to nest Scaffolds. For example, in a +/// tabbed UI, where the [bottomNavigationBar] is a [TabBar] +/// and the body is a [TabBarView], you might be tempted to make each tab bar +/// view a scaffold with a differently titled AppBar. Rather, it would be +/// better to add a listener to the [TabController] that updates the +/// AppBar +/// +/// {@tool snippet} +/// Add a listener to the app's tab controller so that the [AppBar] title of the +/// app's one and only scaffold is reset each time a new tab is selected. +/// +/// ```dart +/// TabController(vsync: tickerProvider, length: tabCount)..addListener(() { +/// if (!tabController.indexIsChanging) { +/// setState(() { +/// // Rebuild the enclosing scaffold with a new AppBar title +/// appBarTitle = 'Tab ${tabController.index}'; +/// }); +/// } +/// }) +/// ``` +/// {@end-tool} +/// +/// Although there are some use cases, like a presentation app that +/// shows embedded flutter content, where nested scaffolds are +/// appropriate, it's best to avoid nesting scaffolds. +/// +/// See also: +/// +/// * [AppBar], which is a horizontal bar typically shown at the top of an app +/// using the [appBar] property. +/// * [BottomAppBar], which is a horizontal bar typically shown at the bottom +/// of an app using the [bottomNavigationBar] property. +/// * [FloatingActionButton], which is a circular button typically shown in the +/// bottom right corner of the app using the [floatingActionButton] property. +/// * [Drawer], which is a vertical panel that is typically displayed to the +/// left of the body (and often hidden on phones) using the [drawer] +/// property. +/// * [BottomNavigationBar], which is a horizontal array of buttons typically +/// shown along the bottom of the app using the [bottomNavigationBar] +/// property. +/// * [BottomSheet], which is an overlay typically shown near the bottom of the +/// app. A bottom sheet can either be persistent, in which case it is shown +/// using the [ScaffoldState.showBottomSheet] method, or modal, in which case +/// it is shown using the [showModalBottomSheet] function. +/// * [SnackBar], which is a lightweight message with an optional action which +/// briefly displays at the bottom of the screen. Use the +/// [ScaffoldMessengerState.showSnackBar] method to show snack bars. +/// * [MaterialBanner], which displays an important, succinct message, at the +/// top of the screen, below the app bar. Use the +/// [ScaffoldMessengerState.showMaterialBanner] method to show material banners. +/// * [ScaffoldState], which is the state associated with this widget. +/// * <https://material.io/design/layout/responsive-layout-grid.html> +/// * Cookbook: [Add a Drawer to a screen](https://docs.flutter.dev/cookbook/design/drawer) +class Scaffold extends StatefulWidget { + /// Creates a visual scaffold for Material Design widgets. + const Scaffold({ + super.key, + this.appBar, + this.body, + this.floatingActionButton, + this.floatingActionButtonLocation, + this.floatingActionButtonAnimator, + this.persistentFooterButtons, + this.persistentFooterAlignment = AlignmentDirectional.centerEnd, + this.persistentFooterDecoration, + this.drawer, + this.onDrawerChanged, + this.endDrawer, + this.onEndDrawerChanged, + this.bottomNavigationBar, + this.bottomSheet, + this.backgroundColor, + this.resizeToAvoidBottomInset, + this.primary = true, + this.drawerDragStartBehavior = DragStartBehavior.start, + this.extendBody = false, + this.drawerBarrierDismissible = true, + this.extendBodyBehindAppBar = false, + this.drawerScrimColor, + this.bottomSheetScrimBuilder = _defaultBottomSheetScrimBuilder, + this.drawerEdgeDragWidth, + this.drawerEnableOpenDragGesture = true, + this.endDrawerEnableOpenDragGesture = true, + this.restorationId, + }); + + /// If true, and [bottomNavigationBar] or [persistentFooterButtons] + /// is specified, then the [body] extends to the bottom of the Scaffold, + /// instead of only extending to the top of the [bottomNavigationBar] + /// or the [persistentFooterButtons]. + /// + /// If true, a [MediaQuery] widget whose bottom padding matches the height + /// of the [bottomNavigationBar] will be added above the scaffold's [body]. + /// + /// This property is often useful when the [bottomNavigationBar] has + /// a non-rectangular shape, like [CircularNotchedRectangle], which + /// adds a [FloatingActionButton] sized notch to the top edge of the bar. + /// In this case specifying `extendBody: true` ensures that scaffold's + /// body will be visible through the bottom navigation bar's notch. + /// + /// See also: + /// + /// * [extendBodyBehindAppBar], which extends the height of the body + /// to the top of the scaffold. + final bool extendBody; + + /// Whether the drawer can be dismissed by tapping on the barrier. + /// + /// If false, and a [drawer] is specified, then the barrier behind the drawer + /// will not respond to a tap event and thus remains open. + /// + /// Defaults to true, in which case the drawer will close upon the user tapping on the barrier. + final bool drawerBarrierDismissible; + + /// If true, and an [appBar] is specified, then the height of the [body] is + /// extended to include the height of the app bar and the top of the body + /// is aligned with the top of the app bar. + /// + /// This is useful if the app bar's [AppBar.backgroundColor] is not + /// completely opaque. + /// + /// This property is false by default. + /// + /// See also: + /// + /// * [extendBody], which extends the height of the body to the bottom + /// of the scaffold. + final bool extendBodyBehindAppBar; + + /// An app bar to display at the top of the scaffold. + final PreferredSizeWidget? appBar; + + /// The primary content of the scaffold. + /// + /// Displayed below the [appBar], above the bottom of the ambient + /// [MediaQuery]'s [MediaQueryData.viewInsets], and behind the + /// [floatingActionButton] and [drawer]. If [resizeToAvoidBottomInset] is + /// false then the body is not resized when the onscreen keyboard appears, + /// i.e. it is not inset by `viewInsets.bottom`. + /// + /// The widget in the body of the scaffold is positioned at the top-left of + /// the available space between the app bar and the bottom of the scaffold. To + /// center this widget instead, consider putting it in a [Center] widget and + /// having that be the body. To expand this widget instead, consider + /// putting it in a [SizedBox.expand]. + /// + /// If you have a column of widgets that should normally fit on the screen, + /// but may overflow and would in such cases need to scroll, consider using a + /// [ListView] as the body of the scaffold. This is also a good choice for + /// the case where your body is a scrollable list. + final Widget? body; + + /// A button displayed floating above [body], in the bottom right corner. + /// + /// Typically a [FloatingActionButton]. + final Widget? floatingActionButton; + + /// Responsible for determining where the [floatingActionButton] should go. + /// + /// If null, the [ScaffoldState] will use the default location, [FloatingActionButtonLocation.endFloat]. + final FloatingActionButtonLocation? floatingActionButtonLocation; + + /// Animator to move the [floatingActionButton] to a new [floatingActionButtonLocation]. + /// + /// If null, the [ScaffoldState] will use the default animator, [FloatingActionButtonAnimator.scaling]. + final FloatingActionButtonAnimator? floatingActionButtonAnimator; + + /// A set of buttons that are displayed at the bottom of the scaffold. + /// + /// Typically this is a list of [TextButton] widgets. These buttons are + /// persistently visible, even if the [body] of the scaffold scrolls. + /// + /// These widgets will be wrapped in an [OverflowBar]. + /// + /// The [persistentFooterButtons] are rendered above the + /// [bottomNavigationBar] but below the [body]. + final List<Widget>? persistentFooterButtons; + + /// The alignment of the [persistentFooterButtons] inside the [OverflowBar]. + /// + /// Defaults to [AlignmentDirectional.centerEnd]. + final AlignmentDirectional persistentFooterAlignment; + + /// Decoration for the container that holds the [persistentFooterButtons]. + /// + /// By default, this container has a top border with a width of 1.0, created by + /// [Divider.createBorderSide]. + /// + /// See also: + /// + /// * [persistentFooterButtons], which defines the buttons to show in the footer. + /// * [persistentFooterAlignment], which defines the alignment of the footer buttons. + final BoxDecoration? persistentFooterDecoration; + + /// A panel displayed to the side of the [body], often hidden on mobile + /// devices. Swipes in from either left-to-right ([TextDirection.ltr]) or + /// right-to-left ([TextDirection.rtl]) + /// + /// Typically a [Drawer]. + /// + /// To open the drawer, use the [ScaffoldState.openDrawer] function. + /// + /// To close the drawer, use either [ScaffoldState.closeDrawer], [Navigator.pop] + /// or press the escape key on the keyboard. + /// + /// {@tool dartpad} + /// To disable the drawer edge swipe on mobile, set the + /// [Scaffold.drawerEnableOpenDragGesture] to false. Then, use + /// [ScaffoldState.openDrawer] to open the drawer and [Navigator.pop] to close + /// it. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.drawer.0.dart ** + /// {@end-tool} + final Widget? drawer; + + /// Optional callback that is called when the [Scaffold.drawer] is opened or closed. + final DrawerCallback? onDrawerChanged; + + /// A panel displayed to the side of the [body], often hidden on mobile + /// devices. Swipes in from right-to-left ([TextDirection.ltr]) or + /// left-to-right ([TextDirection.rtl]) + /// + /// Typically a [Drawer]. + /// + /// To open the drawer, use the [ScaffoldState.openEndDrawer] function. + /// + /// To close the drawer, use either [ScaffoldState.closeEndDrawer], [Navigator.pop] + /// or press the escape key on the keyboard. + /// + /// {@tool dartpad} + /// To disable the drawer edge swipe, set the + /// [Scaffold.endDrawerEnableOpenDragGesture] to false. Then, use + /// [ScaffoldState.openEndDrawer] to open the drawer and [Navigator.pop] to + /// close it. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.end_drawer.0.dart ** + /// {@end-tool} + final Widget? endDrawer; + + /// Optional callback that is called when the [Scaffold.endDrawer] is opened or closed. + final DrawerCallback? onEndDrawerChanged; + + /// The color to use for the scrim that obscures primary content while a drawer is open. + /// + /// If this is null, then [DrawerThemeData.scrimColor] is used. If that + /// is also null, then it defaults to [Colors.black54]. + final Color? drawerScrimColor; + + /// A builder for the widget that obscures primary content while a bottom sheet is open. + /// + /// The builder receives the current [BuildContext] and an [Animation] as parameters. + /// The [Animation] ranges from 0.0 to 1.0 based on how much the bottom sheet covers the screen. + /// A value of 0.0 represents when the bottom sheet covers 70% of the screen, + /// and 1.0 represents when the bottom sheet fully covers the screen. + /// + /// If this is null, then a non-dismissable [ModalBarrier] with [Colors.black] is used. The + /// barrier is animated to fade in and out as the bottom sheet is opened and closed. + /// + /// If the builder returns null, then no scrim is shown. + final Widget? Function(BuildContext, Animation<double>) bottomSheetScrimBuilder; + + /// The color of the [Material] widget that underlies the entire Scaffold. + /// + /// The theme's [ThemeData.scaffoldBackgroundColor] by default. + final Color? backgroundColor; + + /// A bottom navigation bar to display at the bottom of the scaffold. + /// + /// Snack bars slide from underneath the bottom navigation bar while bottom + /// sheets are stacked on top. + /// + /// The [bottomNavigationBar] is rendered below the [persistentFooterButtons] + /// and the [body]. + final Widget? bottomNavigationBar; + + /// The persistent bottom sheet to display. + /// + /// A persistent bottom sheet shows information that supplements the primary + /// content of the app. A persistent bottom sheet remains visible even when + /// the user interacts with other parts of the app. + /// + /// A closely related widget is a modal bottom sheet, which is an alternative + /// to a menu or a dialog and prevents the user from interacting with the rest + /// of the app. Modal bottom sheets can be created and displayed with the + /// [showModalBottomSheet] function. + /// + /// Unlike the persistent bottom sheet displayed by [showBottomSheet] + /// this bottom sheet is not a [LocalHistoryEntry] and cannot be dismissed + /// with the scaffold appbar's back button. + /// + /// If a persistent bottom sheet created with [showBottomSheet] is already + /// visible, it must be closed before building the Scaffold with a new + /// [bottomSheet]. + /// + /// The value of [bottomSheet] can be any widget at all. It's unlikely to + /// actually be a [BottomSheet], which is used by the implementations of + /// [showBottomSheet] and [showModalBottomSheet]. Typically it's a widget + /// that includes [Material]. + /// + /// See also: + /// + /// * [showBottomSheet], which displays a bottom sheet as a route that can + /// be dismissed with the scaffold's back button. + /// * [showModalBottomSheet], which displays a modal bottom sheet. + /// * [BottomSheetThemeData], which can be used to customize the default + /// bottom sheet property values when using a [BottomSheet]. + final Widget? bottomSheet; + + /// If true the [body] and the scaffold's floating widgets should size + /// themselves to avoid the onscreen keyboard whose height is defined by the + /// ambient [MediaQuery]'s [MediaQueryData.viewInsets] `bottom` property. + /// + /// For example, if there is an onscreen keyboard displayed above the + /// scaffold, the body can be resized to avoid overlapping the keyboard, which + /// prevents widgets inside the body from being obscured by the keyboard. + /// + /// Defaults to true. + final bool? resizeToAvoidBottomInset; + + /// Whether this scaffold is being displayed at the top of the screen. + /// + /// If true then the height of the [appBar] will be extended by the height + /// of the screen's status bar, i.e. the top padding for [MediaQuery]. + /// + /// If true, on iOS and macOS, tapping the status bar scrolls the app's + /// [PrimaryScrollController] to the top. + /// + /// The default value of this property, like the default value of + /// [AppBar.primary], is true. + final bool primary; + + /// {@macro flutter.material.DrawerController.dragStartBehavior} + final DragStartBehavior drawerDragStartBehavior; + + /// The width of the area within which a horizontal swipe will open the + /// drawer. + /// + /// By default, the value used is 20.0 added to the padding edge of + /// `MediaQuery.paddingOf(context)` that corresponds to the surrounding + /// [TextDirection]. This ensures that the drag area for notched devices is + /// not obscured. For example, if `TextDirection.of(context)` is set to + /// [TextDirection.ltr], 20.0 will be added to + /// `MediaQuery.paddingOf(context).left`. + final double? drawerEdgeDragWidth; + + /// Determines if the [Scaffold.drawer] can be opened with a drag + /// gesture on mobile. + /// + /// On desktop platforms, the drawer is not draggable. + /// + /// By default, the drag gesture is enabled on mobile. + final bool drawerEnableOpenDragGesture; + + /// Determines if the [Scaffold.endDrawer] can be opened with a + /// gesture on mobile. + /// + /// On desktop platforms, the drawer is not draggable. + /// + /// By default, the drag gesture is enabled on mobile. + final bool endDrawerEnableOpenDragGesture; + + /// Restoration ID to save and restore the state of the [Scaffold]. + /// + /// If it is non-null, the scaffold will persist and restore whether the + /// [drawer] and [endDrawer] was open or closed. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// Finds the [ScaffoldState] from the closest instance of this class that + /// encloses the given context. + /// + /// If no instance of this class encloses the given context, will cause an + /// assert in debug mode, and throw an exception in release mode. + /// + /// This method can be expensive (it walks the element tree). + /// + /// {@tool dartpad} + /// Typical usage of the [Scaffold.of] function is to call it from within the + /// `build` method of a child of a [Scaffold]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.of.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// When the [Scaffold] is actually created in the same `build` function, the + /// `context` argument to the `build` function can't be used to find the + /// [Scaffold] (since it's "above" the widget being returned in the widget + /// tree). In such cases, the following technique with a [Builder] can be used + /// to provide a new scope with a [BuildContext] that is "under" the + /// [Scaffold]: + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.of.1.dart ** + /// {@end-tool} + /// + /// A more efficient solution is to split your build function into several + /// widgets. This introduces a new context from which you can obtain the + /// [Scaffold]. In this solution, you would have an outer widget that creates + /// the [Scaffold] populated by instances of your new inner widgets, and then + /// in these inner widgets you would use [Scaffold.of]. + /// + /// A less elegant but more expedient solution is assign a [GlobalKey] to the + /// [Scaffold], then use the `key.currentState` property to obtain the + /// [ScaffoldState] rather than using the [Scaffold.of] function. + /// + /// If there is no [Scaffold] in scope, then this will throw an exception. + /// To return null if there is no [Scaffold], use [maybeOf] instead. + static ScaffoldState of(BuildContext context) { + final ScaffoldState? result = context.findAncestorStateOfType<ScaffoldState>(); + if (result != null) { + return result; + } + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary('Scaffold.of() called with a context that does not contain a Scaffold.'), + ErrorDescription( + 'No Scaffold ancestor could be found starting from the context that was passed to Scaffold.of(). ' + 'This usually happens when the context provided is from the same StatefulWidget as that ' + 'whose build function actually creates the Scaffold widget being sought.', + ), + ErrorHint( + 'There are several ways to avoid this problem. The simplest is to use a Builder to get a ' + 'context that is "under" the Scaffold. For an example of this, please see the ' + 'documentation for Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html', + ), + ErrorHint( + 'A more efficient solution is to split your build function into several widgets. This ' + 'introduces a new context from which you can obtain the Scaffold. In this solution, ' + 'you would have an outer widget that creates the Scaffold populated by instances of ' + 'your new inner widgets, and then in these inner widgets you would use Scaffold.of().\n' + 'A less elegant but more expedient solution is assign a GlobalKey to the Scaffold, ' + 'then use the key.currentState property to obtain the ScaffoldState rather than ' + 'using the Scaffold.of() function.', + ), + context.describeElement('The context used was'), + ]); + } + + /// Finds the [ScaffoldState] from the closest instance of this class that + /// encloses the given context. + /// + /// If no instance of this class encloses the given context, will return null. + /// To throw an exception instead, use [of] instead of this function. + /// + /// This method can be expensive (it walks the element tree). + /// + /// See also: + /// + /// * [of], a similar function to this one that throws if no instance + /// encloses the given context. Also includes some sample code in its + /// documentation. + static ScaffoldState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType<ScaffoldState>(); + } + + /// Returns a [ValueListenable] for the [ScaffoldGeometry] for the closest + /// [Scaffold] ancestor of the given context. + /// + /// The [ValueListenable.value] is only available at paint time. + /// + /// Notifications are guaranteed to be sent before the first paint pass + /// with the new geometry, but there is no guarantee whether a build or + /// layout passes are going to happen between the notification and the next + /// paint pass. + /// + /// The closest [Scaffold] ancestor for the context might change, e.g when + /// an element is moved from one scaffold to another. For [StatefulWidget]s + /// using this listenable, a change of the [Scaffold] ancestor will + /// trigger a [State.didChangeDependencies]. + /// + /// A typical pattern for listening to the scaffold geometry would be to + /// call [Scaffold.geometryOf] in [State.didChangeDependencies], compare the + /// return value with the previous listenable, if it has changed, unregister + /// the listener, and register a listener to the new [ScaffoldGeometry] + /// listenable. + static ValueListenable<ScaffoldGeometry> geometryOf(BuildContext context) { + final _ScaffoldScope? scaffoldScope = context + .dependOnInheritedWidgetOfExactType<_ScaffoldScope>(); + if (scaffoldScope == null) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary( + 'Scaffold.geometryOf() called with a context that does not contain a Scaffold.', + ), + ErrorDescription( + 'This usually happens when the context provided is from the same StatefulWidget as that ' + 'whose build function actually creates the Scaffold widget being sought.', + ), + ErrorHint( + 'There are several ways to avoid this problem. The simplest is to use a Builder to get a ' + 'context that is "under" the Scaffold. For an example of this, please see the ' + 'documentation for Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html', + ), + ErrorHint( + 'A more efficient solution is to split your build function into several widgets. This ' + 'introduces a new context from which you can obtain the Scaffold. In this solution, ' + 'you would have an outer widget that creates the Scaffold populated by instances of ' + 'your new inner widgets, and then in these inner widgets you would use Scaffold.geometryOf().', + ), + context.describeElement('The context used was'), + ]); + } + return scaffoldScope.geometryNotifier; + } + + /// Whether the Scaffold that most tightly encloses the given context has a + /// drawer. + /// + /// If this is being used during a build (for example to decide whether to + /// show an "open drawer" button), set the `registerForUpdates` argument to + /// true. This will then set up an [InheritedWidget] relationship with the + /// [Scaffold] so that the client widget gets rebuilt whenever the [hasDrawer] + /// value changes. + /// + /// This method can be expensive (it walks the element tree). + /// + /// See also: + /// + /// * [Scaffold.of], which provides access to the [ScaffoldState] object as a + /// whole, from which you can show bottom sheets, and so forth. + static bool hasDrawer(BuildContext context, {bool registerForUpdates = true}) { + if (registerForUpdates) { + final _ScaffoldScope? scaffold = context.dependOnInheritedWidgetOfExactType<_ScaffoldScope>(); + return scaffold?.hasDrawer ?? false; + } else { + final ScaffoldState? scaffold = context.findAncestorStateOfType<ScaffoldState>(); + return scaffold?.hasDrawer ?? false; + } + } + + static Widget _defaultBottomSheetScrimBuilder(BuildContext context, Animation<double> animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double extentRemaining = _kBottomSheetDominatesPercentage * (1.0 - animation.value); + final double floatingButtonVisibilityValue = + extentRemaining * _kBottomSheetDominatesPercentage * 10; + + final double opacity = math.max( + _kMinBottomSheetScrimOpacity, + _kMaxBottomSheetScrimOpacity - floatingButtonVisibilityValue, + ); + + return ModalBarrier(dismissible: false, color: Colors.black.withOpacity(opacity)); + }, + ); + } + + @override + ScaffoldState createState() => ScaffoldState(); +} + +/// State for a [Scaffold]. +/// +/// Can display [BottomSheet]s. Retrieve a [ScaffoldState] from the current +/// [BuildContext] using [Scaffold.of]. +class ScaffoldState extends State<Scaffold> + with TickerProviderStateMixin, RestorationMixin, WidgetsBindingObserver { + @override + String? get restorationId => widget.restorationId; + + @protected + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_drawerOpened, 'drawer_open'); + registerForRestoration(_endDrawerOpened, 'end_drawer_open'); + } + + // DRAWER API + + final GlobalKey<DrawerControllerState> _drawerKey = GlobalKey<DrawerControllerState>(); + final GlobalKey<DrawerControllerState> _endDrawerKey = GlobalKey<DrawerControllerState>(); + + final GlobalKey _bodyKey = GlobalKey(); + late final GlobalKey _statusBarKey = GlobalKey(); + + /// Whether this scaffold has a non-null [Scaffold.appBar]. + bool get hasAppBar => widget.appBar != null; + + /// Whether this scaffold has a non-null [Scaffold.drawer]. + bool get hasDrawer => widget.drawer != null; + + /// Whether this scaffold has a non-null [Scaffold.endDrawer]. + bool get hasEndDrawer => widget.endDrawer != null; + + /// Whether this scaffold has a non-null [Scaffold.floatingActionButton]. + bool get hasFloatingActionButton => widget.floatingActionButton != null; + + double? _appBarMaxHeight; + + /// The max height the [Scaffold.appBar] uses. + /// + /// This is based on the appBar preferred height plus the top padding. + double? get appBarMaxHeight => _appBarMaxHeight; + final RestorableBool _drawerOpened = RestorableBool(false); + final RestorableBool _endDrawerOpened = RestorableBool(false); + + /// Whether the [Scaffold.drawer] is opened. + /// + /// See also: + /// + /// * [ScaffoldState.openDrawer], which opens the [Scaffold.drawer] of a + /// [Scaffold]. + bool get isDrawerOpen => _drawerOpened.value; + + /// Whether the [Scaffold.drawerBarrierDismissible] flag is set. + bool get isDrawerBarrierDismissible => widget.drawerBarrierDismissible; + + /// Whether the [Scaffold.endDrawer] is opened. + /// + /// See also: + /// + /// * [ScaffoldState.openEndDrawer], which opens the [Scaffold.endDrawer] of + /// a [Scaffold]. + bool get isEndDrawerOpen => _endDrawerOpened.value; + + void _drawerOpenedCallback(bool isOpened) { + if (_drawerOpened.value != isOpened && _drawerKey.currentState != null) { + setState(() { + _drawerOpened.value = isOpened; + }); + widget.onDrawerChanged?.call(isOpened); + } + } + + void _endDrawerOpenedCallback(bool isOpened) { + if (_endDrawerOpened.value != isOpened && _endDrawerKey.currentState != null) { + setState(() { + _endDrawerOpened.value = isOpened; + }); + widget.onEndDrawerChanged?.call(isOpened); + } + } + + /// Opens the [Drawer] (if any). + /// + /// If the scaffold has a non-null [Scaffold.drawer], this function will cause + /// the drawer to begin its entrance animation. + /// + /// Normally this is not needed since the [Scaffold] automatically shows an + /// appropriate [IconButton], and handles the edge-swipe gesture, to show the + /// drawer. + /// + /// To close the drawer, use either [ScaffoldState.closeDrawer] or + /// [Navigator.pop]. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void openDrawer() { + if (_endDrawerKey.currentState != null && _endDrawerOpened.value) { + _endDrawerKey.currentState!.close(); + } + _drawerKey.currentState?.open(); + } + + /// Opens the end side [Drawer] (if any). + /// + /// If the scaffold has a non-null [Scaffold.endDrawer], this function will cause + /// the end side drawer to begin its entrance animation. + /// + /// Normally this is not needed since the [Scaffold] automatically shows an + /// appropriate [IconButton], and handles the edge-swipe gesture, to show the + /// drawer. + /// + /// To close the drawer, use either [ScaffoldState.closeEndDrawer] or + /// [Navigator.pop]. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void openEndDrawer() { + if (_drawerKey.currentState != null && _drawerOpened.value) { + _drawerKey.currentState!.close(); + } + _endDrawerKey.currentState?.open(); + } + + // Used for both the snackbar and material banner APIs + ScaffoldMessengerState? _scaffoldMessenger; + + // SNACKBAR API + ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? _messengerSnackBar; + + // This is used to update the _messengerSnackBar by the ScaffoldMessenger. + void _updateSnackBar() { + final ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? messengerSnackBar = + _scaffoldMessenger!._snackBars.isNotEmpty ? _scaffoldMessenger!._snackBars.first : null; + + if (_messengerSnackBar != messengerSnackBar) { + setState(() { + _messengerSnackBar = messengerSnackBar; + }); + } + } + + // MATERIAL BANNER API + + // The _messengerMaterialBanner represents the current MaterialBanner being managed by + // the ScaffoldMessenger, instead of the Scaffold. + ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>? _messengerMaterialBanner; + + // This is used to update the _messengerMaterialBanner by the ScaffoldMessenger. + void _updateMaterialBanner() { + final ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>? + messengerMaterialBanner = _scaffoldMessenger!._materialBanners.isNotEmpty + ? _scaffoldMessenger!._materialBanners.first + : null; + + if (_messengerMaterialBanner != messengerMaterialBanner) { + setState(() { + _messengerMaterialBanner = messengerMaterialBanner; + }); + } + } + + // PERSISTENT BOTTOM SHEET API + + // Contains bottom sheets that may still be animating out of view. + // Important if the app/user takes an action that could repeatedly show a + // bottom sheet. + final List<_StandardBottomSheet> _dismissedBottomSheets = <_StandardBottomSheet>[]; + PersistentBottomSheetController? _currentBottomSheet; + final GlobalKey _currentBottomSheetKey = GlobalKey(); + LocalHistoryEntry? _persistentSheetHistoryEntry; + + void _maybeBuildPersistentBottomSheet() { + if (widget.bottomSheet != null && _currentBottomSheet == null) { + // The new _currentBottomSheet is not a local history entry so a "back" button + // will not be added to the Scaffold's appbar and the bottom sheet will not + // support drag or swipe to dismiss. + final AnimationController animationController = BottomSheet.createAnimationController(this) + ..value = 1.0; + bool persistentBottomSheetExtentChanged(DraggableScrollableNotification notification) { + if (notification.extent - notification.initialExtent > precisionErrorTolerance) { + if (_persistentSheetHistoryEntry == null) { + _persistentSheetHistoryEntry = LocalHistoryEntry( + onRemove: () { + DraggableScrollableActuator.reset(notification.context); + showBodyScrim(false, 0.0); + _floatingActionButtonVisibilityController.value = 1.0; + _persistentSheetHistoryEntry = null; + }, + ); + ModalRoute.of(context)!.addLocalHistoryEntry(_persistentSheetHistoryEntry!); + } + } else if (_persistentSheetHistoryEntry != null) { + _persistentSheetHistoryEntry!.remove(); + } + return false; + } + + // Stop the animation and unmount the dismissed sheets from the tree immediately, + // otherwise may cause duplicate GlobalKey assertion if the sheet sub-tree contains + // GlobalKey widgets. + if (_dismissedBottomSheets.isNotEmpty) { + final sheets = List<_StandardBottomSheet>.of(_dismissedBottomSheets, growable: false); + for (final sheet in sheets) { + sheet.animationController.reset(); + } + assert(_dismissedBottomSheets.isEmpty); + } + + _currentBottomSheet = _buildBottomSheet( + (BuildContext context) { + return NotificationListener<DraggableScrollableNotification>( + onNotification: persistentBottomSheetExtentChanged, + child: DraggableScrollableActuator( + child: StatefulBuilder( + key: _currentBottomSheetKey, + builder: (BuildContext context, StateSetter setState) { + return widget.bottomSheet ?? const SizedBox.shrink(); + }, + ), + ), + ); + }, + isPersistent: true, + animationController: animationController, + ); + } + } + + void _closeCurrentBottomSheet() { + if (_currentBottomSheet != null) { + if (!_currentBottomSheet!._isLocalHistoryEntry) { + _currentBottomSheet!.close(); + } + assert(() { + _currentBottomSheet?._completer.future.whenComplete(() { + assert(_currentBottomSheet == null); + }); + return true; + }()); + } + } + + /// Closes [Scaffold.drawer] if it is currently opened. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void closeDrawer() { + if (hasDrawer && isDrawerOpen) { + _drawerKey.currentState!.close(); + } + } + + /// Closes [Scaffold.endDrawer] if it is currently opened. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void closeEndDrawer() { + if (hasEndDrawer && isEndDrawerOpen) { + _endDrawerKey.currentState!.close(); + } + } + + void _updatePersistentBottomSheet() { + _currentBottomSheetKey.currentState!.setState(() {}); + } + + PersistentBottomSheetController _buildBottomSheet( + WidgetBuilder builder, { + required bool isPersistent, + required AnimationController animationController, + Color? backgroundColor, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + bool? enableDrag, + bool? showDragHandle, + bool shouldDisposeAnimationController = true, + }) { + assert(() { + if (widget.bottomSheet != null && isPersistent && _currentBottomSheet != null) { + throw FlutterError( + 'Scaffold.bottomSheet cannot be specified while a bottom sheet ' + 'displayed with showBottomSheet() is still visible.\n' + 'Rebuild the Scaffold with a null bottomSheet before calling showBottomSheet().', + ); + } + return true; + }()); + + final completer = Completer<void>(); + final bottomSheetKey = GlobalKey<_StandardBottomSheetState>(); + late _StandardBottomSheet bottomSheet; + + var removedEntry = false; + var doingDispose = false; + + void removePersistentSheetHistoryEntryIfNeeded() { + assert(isPersistent); + if (_persistentSheetHistoryEntry != null) { + _persistentSheetHistoryEntry!.remove(); + _persistentSheetHistoryEntry = null; + } + } + + void removeCurrentBottomSheet() { + removedEntry = true; + if (_currentBottomSheet == null) { + return; + } + assert(_currentBottomSheet!._widget == bottomSheet); + assert(bottomSheetKey.currentState != null); + _showFloatingActionButton(); + + if (isPersistent) { + removePersistentSheetHistoryEntryIfNeeded(); + } + + bottomSheetKey.currentState!.close(); + setState(() { + _showBodyScrim = false; + _bottomSheetScrimAnimationController.value = 0.0; + _currentBottomSheet = null; + }); + + if (!animationController.isDismissed) { + _dismissedBottomSheets.add(bottomSheet); + } + completer.complete(); + } + + final LocalHistoryEntry? entry = isPersistent + ? null + : LocalHistoryEntry( + onRemove: () { + if (!removedEntry && _currentBottomSheet?._widget == bottomSheet && !doingDispose) { + removeCurrentBottomSheet(); + } + }, + ); + + void removeEntryIfNeeded() { + if (!isPersistent && !removedEntry) { + assert(entry != null); + entry!.remove(); + removedEntry = true; + } + } + + bottomSheet = _StandardBottomSheet( + key: bottomSheetKey, + animationController: animationController, + enableDrag: enableDrag ?? !isPersistent, + showDragHandle: showDragHandle, + onClosing: () { + if (_currentBottomSheet == null) { + return; + } + assert(_currentBottomSheet!._widget == bottomSheet); + removeEntryIfNeeded(); + }, + onDismissed: () { + if (_dismissedBottomSheets.contains(bottomSheet)) { + setState(() { + _dismissedBottomSheets.remove(bottomSheet); + }); + } + }, + onDispose: () { + doingDispose = true; + removeEntryIfNeeded(); + if (shouldDisposeAnimationController) { + animationController.dispose(); + } + }, + builder: builder, + isPersistent: isPersistent, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + ); + + if (!isPersistent) { + ModalRoute.of(context)!.addLocalHistoryEntry(entry!); + } + + return PersistentBottomSheetController._( + bottomSheet, + completer, + entry != null ? entry.remove : removeCurrentBottomSheet, + (VoidCallback fn) { + bottomSheetKey.currentState?.setState(fn); + }, + !isPersistent, + ); + } + + /// Shows a Material Design bottom sheet in the nearest [Scaffold]. To show + /// a persistent bottom sheet, use the [Scaffold.bottomSheet]. + /// + /// Returns a controller that can be used to close and otherwise manipulate the + /// bottom sheet. + /// + /// To rebuild the bottom sheet (e.g. if it is stateful), call + /// [PersistentBottomSheetController.setState] on the controller returned by + /// this method. + /// + /// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing + /// [ModalRoute] and a back button is added to the app bar of the [Scaffold] + /// that closes the bottom sheet. + /// + /// The [transitionAnimationController] controls the bottom sheet's entrance and + /// exit animations. It's up to the owner of the controller to call + /// [AnimationController.dispose] when the controller is no longer needed. + /// + /// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and + /// does not add a back button to the enclosing Scaffold's app bar, use the + /// [Scaffold.bottomSheet] constructor parameter. + /// + /// A persistent bottom sheet shows information that supplements the primary + /// content of the app. A persistent bottom sheet remains visible even when + /// the user interacts with other parts of the app. + /// + /// A closely related widget is a modal bottom sheet, which is an alternative + /// to a menu or a dialog and prevents the user from interacting with the rest + /// of the app. Modal bottom sheets can be created and displayed with the + /// [showModalBottomSheet] function. + /// + /// {@tool dartpad} + /// This example demonstrates how to use [showBottomSheet] to display a + /// bottom sheet when a user taps a button. It also demonstrates how to + /// close a bottom sheet using the Navigator. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_state.show_bottom_sheet.0.dart ** + /// {@end-tool} + /// + /// The [sheetAnimationStyle] parameter is used to override the bottom sheet + /// animation duration and reverse animation duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the bottom sheet animation duration in the underlying + /// [BottomSheet.createAnimationController]. + /// + /// If [AnimationStyle.reverseDuration] is provided, it will be used to + /// override the bottom sheet reverse animation duration in the underlying + /// [BottomSheet.createAnimationController]. + /// + /// To disable the bottom sheet animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override the [showBottomSheet] animation + /// duration and reverse animation duration using [AnimationStyle]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_state.show_bottom_sheet.1.dart ** + /// {@end-tool} + /// See also: + /// + /// * [BottomSheet], which becomes the parent of the widget returned by the + /// `builder`. + /// * [showBottomSheet], which calls this method given a [BuildContext]. + /// * [showModalBottomSheet], which can be used to display a modal bottom + /// sheet. + /// * [Scaffold.of], for information about how to obtain the [ScaffoldState]. + /// * The Material 2 spec at <https://m2.material.io/components/sheets-bottom>. + /// * The Material 3 spec at <https://m3.material.io/components/bottom-sheets/overview>. + /// * [AnimationStyle], which is used to override the modal bottom sheet + /// animation duration and reverse animation duration. + PersistentBottomSheetController showBottomSheet( + WidgetBuilder builder, { + Color? backgroundColor, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + bool? enableDrag, + bool? showDragHandle, + AnimationController? transitionAnimationController, + AnimationStyle? sheetAnimationStyle, + }) { + assert(() { + if (widget.bottomSheet != null) { + throw FlutterError( + 'Scaffold.bottomSheet cannot be specified while a bottom sheet ' + 'displayed with showBottomSheet() is still visible.\n' + 'Rebuild the Scaffold with a null bottomSheet before calling showBottomSheet().', + ); + } + return true; + }()); + assert(debugCheckHasMediaQuery(context)); + + _closeCurrentBottomSheet(); + final AnimationController controller = + (transitionAnimationController ?? + BottomSheet.createAnimationController(this, sheetAnimationStyle: sheetAnimationStyle)) + ..forward(); + setState(() { + _currentBottomSheet = _buildBottomSheet( + builder, + isPersistent: false, + animationController: controller, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + shouldDisposeAnimationController: transitionAnimationController == null, + ); + }); + return _currentBottomSheet!; + } + + // Floating Action Button API + late AnimationController _floatingActionButtonMoveController; + late FloatingActionButtonAnimator _floatingActionButtonAnimator; + FloatingActionButtonLocation? _previousFloatingActionButtonLocation; + FloatingActionButtonLocation? _floatingActionButtonLocation; + + late AnimationController _floatingActionButtonVisibilityController; + + /// Shows the [Scaffold.floatingActionButton]. + TickerFuture _showFloatingActionButton() { + return _floatingActionButtonVisibilityController.forward(); + } + + // Moves the Floating Action Button to the new Floating Action Button Location. + void _moveFloatingActionButton(final FloatingActionButtonLocation newLocation) { + FloatingActionButtonLocation? previousLocation = _floatingActionButtonLocation; + var restartAnimationFrom = 0.0; + // If the Floating Action Button is moving right now, we need to start from a snapshot of the current transition. + if (_floatingActionButtonMoveController.isAnimating) { + previousLocation = _TransitionSnapshotFabLocation( + _previousFloatingActionButtonLocation!, + _floatingActionButtonLocation!, + _floatingActionButtonAnimator, + _floatingActionButtonMoveController.value, + ); + restartAnimationFrom = _floatingActionButtonAnimator.getAnimationRestart( + _floatingActionButtonMoveController.value, + ); + } + + setState(() { + _previousFloatingActionButtonLocation = previousLocation; + _floatingActionButtonLocation = newLocation; + }); + + // Animate the motion even when the fab is null so that if the exit animation is running, + // the old fab will start the motion transition while it exits instead of jumping to the + // new position. + _floatingActionButtonMoveController.forward(from: restartAnimationFrom); + } + + // iOS FEATURES - status bar tap, back gesture + + // On iOS, if `primary` is true, tapping the status bar scrolls the app's primary scrollable + // to the top. We implement this by looking up the primary scroll controller and + // scrolling it to the top when tapped. + @override + void handleStatusBarTap() { + super.handleStatusBarTap(); + assert(widget.primary); + final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context); + if (primaryScrollController != null && + primaryScrollController.hasClients && + // TODO(LongCatIsLooong): the iOS embedder used to send status bar tap + // evets as fake touches at Offset.zero, such that at most one Scaffold + // (usually the foreground primary Scaffold) can handle the status bar + // tap event, thanks to hit-testing and gesture disambiguation. + // To keep that behavior, this widget performs an additional hit-test here + // to make sure the status bar tap is only handled if the scaffold is + // hit-testable (thus in the foreground) + // Switch to a better solution when available: + // https://github.com/flutter/flutter/issues/182403 + _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) { + primaryScrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 1000), + curve: Curves.easeOutCirc, + ); + } + } + + // INTERNALS + + late _ScaffoldGeometryNotifier _geometryNotifier; + + bool get _resizeToAvoidBottomInset { + return widget.resizeToAvoidBottomInset ?? true; + } + + @protected + @override + void initState() { + super.initState(); + _geometryNotifier = _ScaffoldGeometryNotifier(const ScaffoldGeometry(), context); + _floatingActionButtonLocation = + widget.floatingActionButtonLocation ?? _kDefaultFloatingActionButtonLocation; + _floatingActionButtonAnimator = + widget.floatingActionButtonAnimator ?? _kDefaultFloatingActionButtonAnimator; + _previousFloatingActionButtonLocation = _floatingActionButtonLocation; + _floatingActionButtonMoveController = AnimationController( + vsync: this, + value: 1.0, + duration: kFloatingActionButtonSegue * 2, + ); + + _floatingActionButtonVisibilityController = AnimationController( + duration: kFloatingActionButtonSegue, + vsync: this, + ); + + _bottomSheetScrimAnimationController = AnimationController(vsync: this); + if (widget.primary) { + WidgetsBinding.instance.addObserver(this); + } + } + + @protected + @override + void didUpdateWidget(Scaffold oldWidget) { + super.didUpdateWidget(oldWidget); + // Update the Floating Action Button Animator, and then schedule the Floating Action Button for repositioning. + if (widget.floatingActionButtonAnimator != oldWidget.floatingActionButtonAnimator) { + _floatingActionButtonAnimator = + widget.floatingActionButtonAnimator ?? _kDefaultFloatingActionButtonAnimator; + } + if (widget.floatingActionButtonLocation != oldWidget.floatingActionButtonLocation) { + _moveFloatingActionButton( + widget.floatingActionButtonLocation ?? _kDefaultFloatingActionButtonLocation, + ); + } + if (widget.bottomSheet != oldWidget.bottomSheet) { + assert(() { + if (widget.bottomSheet != null && (_currentBottomSheet?._isLocalHistoryEntry ?? false)) { + throw FlutterError.fromParts(<DiagnosticsNode>[ + ErrorSummary( + 'Scaffold.bottomSheet cannot be specified while a bottom sheet displayed ' + 'with showBottomSheet() is still visible.', + ), + ErrorHint( + 'Use the PersistentBottomSheetController ' + 'returned by showBottomSheet() to close the old bottom sheet before creating ' + 'a Scaffold with a (non null) bottomSheet.', + ), + ]); + } + return true; + }()); + if (widget.bottomSheet == null) { + _closeCurrentBottomSheet(); + } else if (widget.bottomSheet != null && oldWidget.bottomSheet == null) { + _maybeBuildPersistentBottomSheet(); + } else { + _updatePersistentBottomSheet(); + } + } + switch ((oldWidget.primary, widget.primary)) { + case (true, false): + WidgetsBinding.instance.removeObserver(this); + case (false, true): + WidgetsBinding.instance.addObserver(this); + case (true, true) || (false, false): + } + } + + @protected + @override + void didChangeDependencies() { + // Using maybeOf is valid here since both the Scaffold and ScaffoldMessenger + // are currently available for managing SnackBars. + final ScaffoldMessengerState? currentScaffoldMessenger = ScaffoldMessenger.maybeOf(context); + // If our ScaffoldMessenger has changed, unregister with the old one first. + if (_scaffoldMessenger != null && + (currentScaffoldMessenger == null || _scaffoldMessenger != currentScaffoldMessenger)) { + _scaffoldMessenger?._unregister(this); + } + // Register with the current ScaffoldMessenger, if there is one. + _scaffoldMessenger = currentScaffoldMessenger; + _scaffoldMessenger?._register(this); + + _maybeBuildPersistentBottomSheet(); + super.didChangeDependencies(); + } + + @override + void deactivate() { + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + if (widget.primary) { + WidgetsBinding.instance.addObserver(this); + } + } + + @protected + @override + void dispose() { + _geometryNotifier.dispose(); + _floatingActionButtonMoveController.dispose(); + _floatingActionButtonVisibilityController.dispose(); + _scaffoldMessenger?._unregister(this); + _drawerOpened.dispose(); + _endDrawerOpened.dispose(); + _bottomSheetScrimAnimationController.dispose(); + super.dispose(); + } + + void _addIfNonNull( + List<LayoutId> children, + Widget? child, + Object childId, { + required bool removeLeftPadding, + required bool removeTopPadding, + required bool removeRightPadding, + required bool removeBottomPadding, + bool removeBottomInset = false, + bool maintainBottomViewPadding = false, + }) { + MediaQueryData data = MediaQuery.of(context).removePadding( + removeLeft: removeLeftPadding, + removeTop: removeTopPadding, + removeRight: removeRightPadding, + removeBottom: removeBottomPadding, + ); + if (removeBottomInset) { + data = data.removeViewInsets(removeBottom: true); + } + + if (maintainBottomViewPadding && data.viewInsets.bottom != 0.0) { + data = data.copyWith(padding: data.padding.copyWith(bottom: data.viewPadding.bottom)); + } + + if (child != null) { + children.add( + LayoutId( + id: childId, + child: MediaQuery(data: data, child: child), + ), + ); + } + } + + void _buildEndDrawer(List<LayoutId> children, TextDirection textDirection) { + if (widget.endDrawer != null) { + assert(hasEndDrawer); + _addIfNonNull( + children, + DrawerController( + key: _endDrawerKey, + alignment: DrawerAlignment.end, + drawerCallback: _endDrawerOpenedCallback, + dragStartBehavior: widget.drawerDragStartBehavior, + scrimColor: widget.drawerScrimColor, + edgeDragWidth: widget.drawerEdgeDragWidth, + enableOpenDragGesture: widget.endDrawerEnableOpenDragGesture, + isDrawerOpen: _endDrawerOpened.value, + drawerBarrierDismissible: widget.drawerBarrierDismissible, + child: widget.endDrawer!, + ), + _ScaffoldSlot.endDrawer, + // remove the side padding from the side we're not touching + removeLeftPadding: textDirection == TextDirection.ltr, + removeTopPadding: false, + removeRightPadding: textDirection == TextDirection.rtl, + removeBottomPadding: false, + ); + } + } + + void _buildDrawer(List<LayoutId> children, TextDirection textDirection) { + if (widget.drawer != null) { + assert(hasDrawer); + _addIfNonNull( + children, + DrawerController( + key: _drawerKey, + alignment: DrawerAlignment.start, + drawerCallback: _drawerOpenedCallback, + dragStartBehavior: widget.drawerDragStartBehavior, + scrimColor: widget.drawerScrimColor, + edgeDragWidth: widget.drawerEdgeDragWidth, + enableOpenDragGesture: widget.drawerEnableOpenDragGesture, + isDrawerOpen: _drawerOpened.value, + drawerBarrierDismissible: widget.drawerBarrierDismissible, + child: widget.drawer!, + ), + _ScaffoldSlot.drawer, + // remove the side padding from the side we're not touching + removeLeftPadding: textDirection == TextDirection.rtl, + removeTopPadding: false, + removeRightPadding: textDirection == TextDirection.ltr, + removeBottomPadding: false, + ); + } + } + + late AnimationController _bottomSheetScrimAnimationController; + bool _showBodyScrim = false; + + /// Updates the state of the body scrim. + /// + /// This method is used to show or hide the body scrim and to set the animation value. + void showBodyScrim(bool value, double animationValue) { + if (_showBodyScrim != value) { + setState(() { + _showBodyScrim = value; + }); + } + if (_bottomSheetScrimAnimationController.value != animationValue) { + _bottomSheetScrimAnimationController.value = animationValue; + } + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasDirectionality(context)); + final ThemeData themeData = Theme.of(context); + final TextDirection textDirection = Directionality.of(context); + + final children = <LayoutId>[]; + _addIfNonNull( + children, + widget.body == null + ? null + : _BodyBuilder( + extendBody: widget.extendBody, + extendBodyBehindAppBar: widget.extendBodyBehindAppBar, + body: KeyedSubtree(key: _bodyKey, child: widget.body!), + ), + _ScaffoldSlot.body, + removeLeftPadding: false, + removeTopPadding: widget.appBar != null, + removeRightPadding: false, + removeBottomPadding: + widget.bottomNavigationBar != null || widget.persistentFooterButtons != null, + removeBottomInset: _resizeToAvoidBottomInset, + ); + if (_showBodyScrim) { + _addIfNonNull( + children, + widget.bottomSheetScrimBuilder(context, _bottomSheetScrimAnimationController.view), + _ScaffoldSlot.bodyScrim, + removeLeftPadding: true, + removeTopPadding: true, + removeRightPadding: true, + removeBottomPadding: true, + ); + } + + if (widget.appBar != null) { + final double topPadding = widget.primary ? MediaQuery.paddingOf(context).top : 0.0; + _appBarMaxHeight = + AppBar.preferredHeightFor(context, widget.appBar!.preferredSize) + topPadding; + assert(_appBarMaxHeight! >= 0.0 && _appBarMaxHeight!.isFinite); + _addIfNonNull( + children, + ConstrainedBox( + constraints: BoxConstraints(maxHeight: _appBarMaxHeight!), + child: FlexibleSpaceBar.createSettings( + currentExtent: _appBarMaxHeight!, + child: widget.appBar!, + ), + ), + _ScaffoldSlot.appBar, + removeLeftPadding: false, + removeTopPadding: false, + removeRightPadding: false, + removeBottomPadding: true, + ); + } + + var isSnackBarFloating = false; + double? snackBarWidth; + + if (_currentBottomSheet != null || _dismissedBottomSheets.isNotEmpty) { + final Widget stack = Stack( + alignment: Alignment.bottomCenter, + children: <Widget>[..._dismissedBottomSheets, ?_currentBottomSheet?._widget], + ); + _addIfNonNull( + children, + stack, + _ScaffoldSlot.bottomSheet, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: _resizeToAvoidBottomInset, + ); + } + + // SnackBar set by ScaffoldMessenger + if (_messengerSnackBar != null) { + final SnackBarThemeData snackBarTheme = SnackBarTheme.of(context); + final SnackBarBehavior snackBarBehavior = + _messengerSnackBar?._widget.behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed; + isSnackBarFloating = snackBarBehavior == SnackBarBehavior.floating; + snackBarWidth = _messengerSnackBar?._widget.width ?? snackBarTheme.width; + + _addIfNonNull( + children, + _messengerSnackBar?._widget, + _ScaffoldSlot.snackBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: + widget.bottomNavigationBar != null || widget.persistentFooterButtons != null, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + var extendBodyBehindMaterialBanner = false; + // MaterialBanner set by ScaffoldMessenger + if (_messengerMaterialBanner != null) { + final MaterialBannerThemeData bannerTheme = MaterialBannerTheme.of(context); + final double elevation = + _messengerMaterialBanner?._widget.elevation ?? bannerTheme.elevation ?? 0.0; + extendBodyBehindMaterialBanner = elevation != 0.0; + + _addIfNonNull( + children, + _messengerMaterialBanner?._widget, + _ScaffoldSlot.materialBanner, + removeLeftPadding: false, + removeTopPadding: widget.appBar != null, + removeRightPadding: false, + removeBottomPadding: true, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + if (widget.persistentFooterButtons != null) { + _addIfNonNull( + children, + Container( + decoration: + widget.persistentFooterDecoration ?? + BoxDecoration(border: Border(top: Divider.createBorderSide(context, width: 1.0))), + child: SafeArea( + top: false, + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(8), + child: Align( + alignment: widget.persistentFooterAlignment, + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: widget.persistentFooterButtons!, + ), + ), + ), + ), + ), + ), + _ScaffoldSlot.persistentFooter, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: widget.bottomNavigationBar != null, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + if (widget.bottomNavigationBar != null) { + _addIfNonNull( + children, + widget.bottomNavigationBar, + _ScaffoldSlot.bottomNavigationBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: false, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + _addIfNonNull( + children, + _FloatingActionButtonTransition( + fabMoveAnimation: _floatingActionButtonMoveController, + fabMotionAnimator: _floatingActionButtonAnimator, + geometryNotifier: _geometryNotifier, + currentController: _floatingActionButtonVisibilityController, + child: widget.floatingActionButton, + ), + _ScaffoldSlot.floatingActionButton, + removeLeftPadding: true, + removeTopPadding: true, + removeRightPadding: true, + removeBottomPadding: true, + ); + + final Widget? statusBar = switch (themeData.platform) { + TargetPlatform.iOS || + TargetPlatform.macOS => widget.primary ? _HitTestableAtOrigin(_statusBarKey) : null, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => null, + }; + + _addIfNonNull( + children, + statusBar, + _ScaffoldSlot.statusBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: true, + ); + + if (_endDrawerOpened.value) { + _buildDrawer(children, textDirection); + _buildEndDrawer(children, textDirection); + } else { + _buildEndDrawer(children, textDirection); + _buildDrawer(children, textDirection); + } + + // The minimum insets for contents of the Scaffold to keep visible. + final EdgeInsets minInsets = MediaQuery.paddingOf( + context, + ).copyWith(bottom: _resizeToAvoidBottomInset ? MediaQuery.viewInsetsOf(context).bottom : 0.0); + + // The minimum viewPadding for interactive elements positioned by the + // Scaffold to keep within safe interactive areas. + final EdgeInsets minViewPadding = MediaQuery.viewPaddingOf(context).copyWith( + bottom: _resizeToAvoidBottomInset && MediaQuery.viewInsetsOf(context).bottom != 0.0 + ? 0.0 + : null, + ); + + return _ScaffoldScope( + hasDrawer: hasDrawer, + geometryNotifier: _geometryNotifier, + child: ScrollNotificationObserver( + child: Material( + color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, + child: Builder( + builder: (BuildContext context) { + return Actions( + actions: <Type, Action<Intent>>{DismissIntent: _DismissDrawerAction(context)}, + child: CustomMultiChildLayout( + delegate: _ScaffoldLayout( + extendBody: widget.extendBody, + extendBodyBehindAppBar: widget.extendBodyBehindAppBar, + minInsets: minInsets, + minViewPadding: minViewPadding, + currentFloatingActionButtonLocation: _floatingActionButtonLocation!, + floatingActionButtonMoveAnimation: _floatingActionButtonMoveController, + floatingActionButtonMotionAnimator: _floatingActionButtonAnimator, + geometryNotifier: _geometryNotifier, + previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation!, + textDirection: textDirection, + isSnackBarFloating: isSnackBarFloating, + extendBodyBehindMaterialBanner: extendBodyBehindMaterialBanner, + snackBarWidth: snackBarWidth, + ), + children: children, + ), + ); + }, + ), + ), + ), + ); + } +} + +class _DismissDrawerAction extends DismissAction { + _DismissDrawerAction(this.context); + + final BuildContext context; + + @override + bool isEnabled(DismissIntent intent) { + final ScaffoldState scaffold = Scaffold.of(context); + return (scaffold.isDrawerOpen || scaffold.isEndDrawerOpen) && + scaffold.isDrawerBarrierDismissible; + } + + @override + void invoke(DismissIntent intent) { + final ScaffoldState scaffold = Scaffold.of(context); + if (isEnabled(intent)) { + scaffold.closeDrawer(); + scaffold.closeEndDrawer(); + } + } +} + +/// An interface for controlling a feature of a [Scaffold]. +/// +/// Commonly obtained from [ScaffoldMessengerState.showSnackBar] or +/// [ScaffoldState.showBottomSheet]. +class ScaffoldFeatureController<T extends Widget, U> { + const ScaffoldFeatureController._(this._widget, this._completer, this.close, this.setState); + final T _widget; + final Completer<U> _completer; + + /// Completes when the feature controlled by this object is no longer visible. + Future<U> get closed => _completer.future; + + /// Remove the feature (e.g., bottom sheet, snack bar, or material banner) from the scaffold. + final VoidCallback close; + + /// Mark the feature (e.g., bottom sheet or snack bar) as needing to rebuild. + final StateSetter? setState; +} + +class _StandardBottomSheet extends StatefulWidget { + const _StandardBottomSheet({ + super.key, + required this.animationController, + this.enableDrag = true, + this.showDragHandle, + required this.onClosing, + required this.onDismissed, + required this.builder, + this.isPersistent = false, + this.backgroundColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + this.onDispose, + }); + + final AnimationController + animationController; // we control it, but it must be disposed by whoever created it. + final bool enableDrag; + final bool? showDragHandle; + final VoidCallback? onClosing; + final VoidCallback? onDismissed; + final VoidCallback? onDispose; + final WidgetBuilder builder; + final bool isPersistent; + final Color? backgroundColor; + final double? elevation; + final ShapeBorder? shape; + final Clip? clipBehavior; + final BoxConstraints? constraints; + + @override + _StandardBottomSheetState createState() => _StandardBottomSheetState(); +} + +class _StandardBottomSheetState extends State<_StandardBottomSheet> { + ParametricCurve<double> animationCurve = _standardBottomSheetCurve; + + @override + void initState() { + super.initState(); + assert(widget.animationController.isForwardOrCompleted); + widget.animationController.addStatusListener(_handleStatusChange); + } + + @override + void dispose() { + widget.animationController.removeStatusListener(_handleStatusChange); + widget.onDispose?.call(); + super.dispose(); + } + + @override + void didUpdateWidget(_StandardBottomSheet oldWidget) { + super.didUpdateWidget(oldWidget); + assert(widget.animationController == oldWidget.animationController); + } + + void close() { + widget.animationController.reverse(); + widget.onClosing?.call(); + } + + void _handleDragStart(DragStartDetails details) { + // Allow the bottom sheet to track the user's finger accurately. + animationCurve = Curves.linear; + } + + void _handleDragEnd(DragEndDetails details, {bool? isClosing}) { + // Allow the bottom sheet to animate smoothly from its current position. + animationCurve = Split(widget.animationController.value, endCurve: _standardBottomSheetCurve); + } + + void _handleStatusChange(AnimationStatus status) { + if (status.isDismissed) { + widget.onDismissed?.call(); + } + } + + bool extentChanged(DraggableScrollableNotification notification) { + final double extentRemaining = 1.0 - notification.extent; + final ScaffoldState scaffold = Scaffold.of(context); + if (extentRemaining < _kBottomSheetDominatesPercentage) { + scaffold._floatingActionButtonVisibilityController.value = + extentRemaining * _kBottomSheetDominatesPercentage * 10; + + final double scrimAnimationValue = 1 - extentRemaining / _kBottomSheetDominatesPercentage; + scaffold.showBodyScrim(true, scrimAnimationValue); + } else { + scaffold._floatingActionButtonVisibilityController.value = 1.0; + scaffold.showBodyScrim(false, 0.0); + } + // If the Scaffold.bottomSheet != null, we're a persistent bottom sheet. + if (notification.extent == notification.minExtent && + scaffold.widget.bottomSheet == null && + notification.shouldCloseOnMinExtent) { + close(); + } + return false; + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.animationController, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.topStart, + heightFactor: animationCurve.transform(widget.animationController.value), + child: child, + ); + }, + child: Semantics( + container: true, + onDismiss: !widget.isPersistent ? close : null, + child: NotificationListener<DraggableScrollableNotification>( + onNotification: extentChanged, + child: BottomSheet( + animationController: widget.animationController, + enableDrag: widget.enableDrag, + showDragHandle: widget.showDragHandle, + onDragStart: _handleDragStart, + onDragEnd: _handleDragEnd, + onClosing: widget.onClosing!, + builder: widget.builder, + backgroundColor: widget.backgroundColor, + elevation: widget.elevation, + shape: widget.shape, + clipBehavior: widget.clipBehavior, + constraints: widget.constraints, + ), + ), + ), + ); + } +} + +/// A [ScaffoldFeatureController] for standard bottom sheets. +/// +/// This is the type of objects returned by [ScaffoldState.showBottomSheet]. +/// +/// This controller is used to display both standard and persistent bottom +/// sheets. A bottom sheet is only persistent if it is set as the +/// [Scaffold.bottomSheet]. +class PersistentBottomSheetController + extends ScaffoldFeatureController<_StandardBottomSheet, void> { + const PersistentBottomSheetController._( + super.widget, + super.completer, + super.close, + StateSetter super.setState, + this._isLocalHistoryEntry, + ) : super._(); + + final bool _isLocalHistoryEntry; +} + +class _ScaffoldScope extends InheritedWidget { + const _ScaffoldScope({ + required this.hasDrawer, + required this.geometryNotifier, + required super.child, + }); + + final bool hasDrawer; + final _ScaffoldGeometryNotifier geometryNotifier; + + @override + bool updateShouldNotify(_ScaffoldScope oldWidget) { + return hasDrawer != oldWidget.hasDrawer; + } +} + +final class _HitTestableAtOrigin extends StatelessWidget { + const _HitTestableAtOrigin(this.globalKey); + + final GlobalKey globalKey; + + /// Whether the render box of the [_HitTestableAtOrigin] widget associated + /// with the given global `key` is hit-testable at [Offset.zero]. + /// + /// This is used by the `handleStatusBarTap` implementation to avoid sending + /// status bar tap events to scroll views in offscreen subtrees. + static bool hitTestableAtOrigin(GlobalKey key) { + final context = key.currentContext as Element?; + if (context == null) { + assert( + false, + 'BuildContext associated with $key is not mounted. ' + 'If you see this in a test, this is likely because the test was trying ' + 'to simulate status bar tap on a non-iOS platform', + ); + return false; + } + final renderObject = context.renderObject! as RenderMetaData; + final int viewId = View.of(context).viewId; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId); + return result.path.any((HitTestEntry entry) => entry.target == renderObject); + } + + @override + Widget build(BuildContext context) { + return MetaData( + key: globalKey, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ); + } +} diff --git a/packages/material_ui/lib/src/scrollbar.dart b/packages/material_ui/lib/src/scrollbar.dart new file mode 100644 index 000000000000..deaaf1848c04 --- /dev/null +++ b/packages/material_ui/lib/src/scrollbar.dart @@ -0,0 +1,414 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/gestures.dart'; + +import 'color_scheme.dart'; +import 'scrollbar_theme.dart'; +import 'theme.dart'; + +const double _kScrollbarThickness = 8.0; +const double _kScrollbarThicknessWithTrack = 12.0; +const double _kScrollbarMargin = 2.0; +const double _kScrollbarMinLength = 48.0; +const Radius _kScrollbarRadius = Radius.circular(8.0); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); + +/// A Material Design scrollbar. +/// +/// To add a scrollbar to a [ScrollView], wrap the scroll view +/// widget in a [Scrollbar] widget. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc} +/// +/// {@macro flutter.widgets.Scrollbar} +/// +/// Dynamically changes to a [CupertinoScrollbar], an iOS style scrollbar, by +/// default on the iOS platform. +/// +/// The color of the Scrollbar thumb will change when [WidgetState.dragged], +/// or [WidgetState.hovered] on desktop and web platforms. These stateful +/// color choices can be changed using [ScrollbarThemeData.thumbColor]. +/// +/// {@tool dartpad} +/// This sample shows a [Scrollbar] that executes a fade animation as scrolling +/// occurs. The Scrollbar will fade into view as the user scrolls, and fade out +/// when scrolling stops. +/// +/// ** See code in examples/api/lib/material/scrollbar/scrollbar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// When [thumbVisibility] is true, the scrollbar thumb will remain visible +/// without the fade animation. This requires that a [ScrollController] is +/// provided to controller, or that the [PrimaryScrollController] is available. +/// +/// When a [ScrollView.scrollDirection] is [Axis.horizontal], it is recommended +/// that the [Scrollbar] is always visible, since scrolling in the horizontal +/// axis is less discoverable. +/// +/// ** See code in examples/api/lib/material/scrollbar/scrollbar.1.dart ** +/// {@end-tool} +/// +/// A scrollbar track can be added using [trackVisibility]. This can also be +/// drawn when triggered by a hover event, or based on any [WidgetState] by +/// using [ScrollbarThemeData.trackVisibility]. +/// +/// The [thickness] of the track and scrollbar thumb can be changed dynamically +/// in response to [WidgetState]s using [ScrollbarThemeData.thickness]. +/// +/// See also: +/// +/// * [RawScrollbar], a basic scrollbar that fades in and out, extended +/// by this class to add more animations and behaviors. +/// * [ScrollbarTheme], which configures the Scrollbar's appearance. +/// * [CupertinoScrollbar], an iOS style scrollbar. +/// * [ListView], which displays a linear, scrollable list of children. +/// * [GridView], which displays a 2 dimensional, scrollable array of children. +class Scrollbar extends StatelessWidget { + /// Creates a Material Design scrollbar that by default will connect to the + /// closest Scrollable descendant of [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + /// + /// If the [controller] is null, the default behavior is to + /// enable scrollbar dragging using the [PrimaryScrollController]. + /// + /// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0 + /// pixels when on mobile platforms. A null [radius] will result in a default + /// of an 8.0 pixel circular radius about the corners of the scrollbar thumb, + /// except for when executing on [TargetPlatform.android], which will render the + /// thumb without a radius. + const Scrollbar({ + super.key, + required this.child, + this.controller, + this.thumbVisibility, + this.trackVisibility, + this.thickness, + this.radius, + this.notificationPredicate, + this.interactive, + this.scrollbarOrientation, + }); + + /// {@macro flutter.widgets.Scrollbar.child} + final Widget child; + + /// {@macro flutter.widgets.Scrollbar.controller} + final ScrollController? controller; + + /// {@macro flutter.widgets.Scrollbar.thumbVisibility} + /// + /// If this property is null, then [ScrollbarThemeData.thumbVisibility] of + /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value + /// is false. + /// + /// If the thumb visibility is related to the scrollbar's material state, + /// use the global [ScrollbarThemeData.thumbVisibility] or override the + /// sub-tree's theme data. + final bool? thumbVisibility; + + /// {@macro flutter.widgets.Scrollbar.trackVisibility} + /// + /// If this property is null, then [ScrollbarThemeData.trackVisibility] of + /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value + /// is false. + /// + /// If the track visibility is related to the scrollbar's material state, + /// use the global [ScrollbarThemeData.trackVisibility] or override the + /// sub-tree's theme data. + final bool? trackVisibility; + + /// The thickness of the scrollbar in the cross axis of the scrollable. + /// + /// If null, the default value is platform dependent. On [TargetPlatform.android], + /// the default thickness is 4.0 pixels. On [TargetPlatform.iOS], + /// [CupertinoScrollbar.defaultThickness] is used. The remaining platforms have a + /// default thickness of 8.0 pixels. + final double? thickness; + + /// The [Radius] of the scrollbar thumb's rounded rectangle corners. + /// + /// If null, the default value is platform dependent. On [TargetPlatform.android], + /// no radius is applied to the scrollbar thumb. On [TargetPlatform.iOS], + /// [CupertinoScrollbar.defaultRadius] is used. The remaining platforms have a + /// default [Radius.circular] of 8.0 pixels. + final Radius? radius; + + /// {@macro flutter.widgets.Scrollbar.interactive} + final bool? interactive; + + /// {@macro flutter.widgets.Scrollbar.notificationPredicate} + final ScrollNotificationPredicate? notificationPredicate; + + /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation} + final ScrollbarOrientation? scrollbarOrientation; + + @override + Widget build(BuildContext context) { + if (Theme.of(context).platform == TargetPlatform.iOS) { + return CupertinoScrollbar( + thumbVisibility: thumbVisibility ?? false, + thickness: thickness ?? CupertinoScrollbar.defaultThickness, + thicknessWhileDragging: thickness ?? CupertinoScrollbar.defaultThicknessWhileDragging, + radius: radius ?? CupertinoScrollbar.defaultRadius, + radiusWhileDragging: radius ?? CupertinoScrollbar.defaultRadiusWhileDragging, + controller: controller, + notificationPredicate: notificationPredicate, + scrollbarOrientation: scrollbarOrientation, + child: child, + ); + } + return _MaterialScrollbar( + controller: controller, + thumbVisibility: thumbVisibility, + trackVisibility: trackVisibility, + thickness: thickness, + radius: radius, + notificationPredicate: notificationPredicate, + interactive: interactive, + scrollbarOrientation: scrollbarOrientation, + child: child, + ); + } +} + +class _MaterialScrollbar extends RawScrollbar { + const _MaterialScrollbar({ + required super.child, + super.controller, + super.thumbVisibility, + super.trackVisibility, + super.thickness, + super.radius, + ScrollNotificationPredicate? notificationPredicate, + super.interactive, + super.scrollbarOrientation, + }) : super( + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + pressDuration: Duration.zero, + notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + @override + _MaterialScrollbarState createState() => _MaterialScrollbarState(); +} + +class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> { + late AnimationController _hoverAnimationController; + bool _dragIsActive = false; + bool _hoverIsActive = false; + late ColorScheme _colorScheme; + late ScrollbarThemeData _scrollbarTheme; + // On Android, scrollbars should match native appearance. + late bool _useAndroidScrollbar; + + @override + bool get showScrollbar => + widget.thumbVisibility ?? _scrollbarTheme.thumbVisibility?.resolve(_states) ?? false; + + @override + bool get enableGestures => + widget.interactive ?? _scrollbarTheme.interactive ?? !_useAndroidScrollbar; + + WidgetStateProperty<bool> get _trackVisibility => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + return widget.trackVisibility ?? _scrollbarTheme.trackVisibility?.resolve(states) ?? false; + }); + + Set<WidgetState> get _states => <WidgetState>{ + if (_dragIsActive) WidgetState.dragged, + if (_hoverIsActive) WidgetState.hovered, + }; + + WidgetStateProperty<Color> get _thumbColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + late Color dragColor; + late Color hoverColor; + late Color idleColor; + switch (brightness) { + case Brightness.light: + dragColor = onSurface.withOpacity(0.6); + hoverColor = onSurface.withOpacity(0.5); + idleColor = _useAndroidScrollbar + ? Theme.of(context).highlightColor.withOpacity(1.0) + : onSurface.withOpacity(0.1); + case Brightness.dark: + dragColor = onSurface.withOpacity(0.75); + hoverColor = onSurface.withOpacity(0.65); + idleColor = _useAndroidScrollbar + ? Theme.of(context).highlightColor.withOpacity(1.0) + : onSurface.withOpacity(0.3); + } + + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.dragged)) { + return _scrollbarTheme.thumbColor?.resolve(states) ?? dragColor; + } + + // If the track is visible, the thumb color hover animation is ignored and + // changes immediately. + if (_trackVisibility.resolve(states)) { + return _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor; + } + + return Color.lerp( + _scrollbarTheme.thumbColor?.resolve(states) ?? idleColor, + _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor, + _hoverAnimationController.value, + )!; + }); + } + + WidgetStateProperty<Color> get _trackColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (showScrollbar && _trackVisibility.resolve(states)) { + return _scrollbarTheme.trackColor?.resolve(states) ?? + switch (brightness) { + Brightness.light => onSurface.withOpacity(0.03), + Brightness.dark => onSurface.withOpacity(0.05), + }; + } + return const Color(0x00000000); + }); + } + + WidgetStateProperty<Color> get _trackBorderColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (showScrollbar && _trackVisibility.resolve(states)) { + return _scrollbarTheme.trackBorderColor?.resolve(states) ?? + switch (brightness) { + Brightness.light => onSurface.withOpacity(0.1), + Brightness.dark => onSurface.withOpacity(0.25), + }; + } + return const Color(0x00000000); + }); + } + + WidgetStateProperty<double> get _thickness { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered) && _trackVisibility.resolve(states)) { + return widget.thickness ?? + _scrollbarTheme.thickness?.resolve(states) ?? + _kScrollbarThicknessWithTrack; + } + // The default scrollbar thickness is smaller on mobile. + return widget.thickness ?? + _scrollbarTheme.thickness?.resolve(states) ?? + (_kScrollbarThickness / (_useAndroidScrollbar ? 2 : 1)); + }); + } + + @override + void initState() { + super.initState(); + _hoverAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _hoverAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void didChangeDependencies() { + final ThemeData theme = Theme.of(context); + _colorScheme = theme.colorScheme; + _scrollbarTheme = ScrollbarTheme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + _useAndroidScrollbar = true; + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _useAndroidScrollbar = false; + } + super.didChangeDependencies(); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = _thumbColor.resolve(_states) + ..trackColor = _trackColor.resolve(_states) + ..trackBorderColor = _trackBorderColor.resolve(_states) + ..textDirection = Directionality.of(context) + ..thickness = _thickness.resolve(_states) + ..radius = + widget.radius ?? + _scrollbarTheme.radius ?? + (_useAndroidScrollbar ? null : _kScrollbarRadius) + ..crossAxisMargin = + _scrollbarTheme.crossAxisMargin ?? (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin) + ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0 + ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength + ..padding = MediaQuery.paddingOf(context) + ..scrollbarOrientation = widget.scrollbarOrientation + ..ignorePointer = !enableGestures; + } + + @override + void handleThumbPressStart(Offset localPosition) { + super.handleThumbPressStart(localPosition); + setState(() { + _dragIsActive = true; + }); + } + + @override + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + super.handleThumbPressEnd(localPosition, velocity); + setState(() { + _dragIsActive = false; + }); + } + + @override + void handleHover(PointerHoverEvent event) { + super.handleHover(event); + // Check if the position of the pointer falls over the painted scrollbar + if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) { + // Pointer is hovering over the scrollbar + setState(() { + _hoverIsActive = true; + }); + _hoverAnimationController.forward(); + } else if (_hoverIsActive) { + // Pointer was, but is no longer over painted scrollbar. + setState(() { + _hoverIsActive = false; + }); + _hoverAnimationController.reverse(); + } + } + + @override + void handleHoverExit(PointerExitEvent event) { + super.handleHoverExit(event); + setState(() { + _hoverIsActive = false; + }); + _hoverAnimationController.reverse(); + } + + @override + void dispose() { + _hoverAnimationController.dispose(); + super.dispose(); + } +} diff --git a/packages/material_ui/lib/src/scrollbar_theme.dart b/packages/material_ui/lib/src/scrollbar_theme.dart new file mode 100644 index 000000000000..dc1f80941112 --- /dev/null +++ b/packages/material_ui/lib/src/scrollbar_theme.dart @@ -0,0 +1,329 @@ +// 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. + +/// @docImport 'scrollbar.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Scrollbar] widgets. +/// +/// Descendant widgets obtain the current [ScrollbarThemeData] object with +/// `ScrollbarTheme.of(context)`. Instances of [ScrollbarThemeData] can be +/// customized with [ScrollbarThemeData.copyWith]. +/// +/// Typically the [ScrollbarThemeData] of a [ScrollbarTheme] is specified as +/// part of the overall [Theme] with [ThemeData.scrollbarTheme]. +/// +/// All [ScrollbarThemeData] properties are `null` by default. When null, the +/// [Scrollbar] computes its own default values, typically based on the overall +/// theme's [ThemeData.colorScheme]. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class ScrollbarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.scrollbarTheme]. + const ScrollbarThemeData({ + this.thumbVisibility, + this.thickness, + this.trackVisibility, + this.radius, + this.thumbColor, + this.trackColor, + this.trackBorderColor, + this.crossAxisMargin, + this.mainAxisMargin, + this.minThumbLength, + this.interactive, + }); + + /// Overrides the default value of [Scrollbar.thumbVisibility] in all + /// descendant [Scrollbar] widgets. + final WidgetStateProperty<bool?>? thumbVisibility; + + /// Overrides the default value of [Scrollbar.thickness] in all + /// descendant [Scrollbar] widgets. + /// + /// Resolves in the following states: + /// * [WidgetState.hovered] on web and desktop platforms. + final WidgetStateProperty<double?>? thickness; + + /// Overrides the default value of [Scrollbar.trackVisibility] in all + /// descendant [Scrollbar] widgets. + final WidgetStateProperty<bool?>? trackVisibility; + + /// Overrides the default value of [Scrollbar.interactive] in all + /// descendant [Scrollbar] widgets. + final bool? interactive; + + /// Overrides the default value of [Scrollbar.radius] in all + /// descendant widgets. + final Radius? radius; + + /// Overrides the default [Color] of the [Scrollbar] thumb in all descendant + /// [Scrollbar] widgets. + /// + /// Resolves in the following states: + /// * [WidgetState.dragged]. + /// * [WidgetState.hovered] on web and desktop platforms. + final WidgetStateProperty<Color?>? thumbColor; + + /// Overrides the default [Color] of the [Scrollbar] track when + /// [trackVisibility] is true in all descendant [Scrollbar] widgets. + /// + /// Resolves in the following states: + /// * [WidgetState.hovered] on web and desktop platforms. + final WidgetStateProperty<Color?>? trackColor; + + /// Overrides the default [Color] of the [Scrollbar] track border when + /// [trackVisibility] is true in all descendant [Scrollbar] widgets. + /// + /// Resolves in the following states: + /// * [WidgetState.hovered] on web and desktop platforms. + final WidgetStateProperty<Color?>? trackBorderColor; + + /// Overrides the default value of the [ScrollbarPainter.crossAxisMargin] + /// property in all descendant [Scrollbar] widgets. + /// + /// See also: + /// + /// * [ScrollbarPainter.crossAxisMargin], which sets the distance from the + /// scrollbar's side to the nearest edge in logical pixels. + final double? crossAxisMargin; + + /// Overrides the default value of the [ScrollbarPainter.mainAxisMargin] + /// property in all descendant [Scrollbar] widgets. + /// + /// See also: + /// + /// * [ScrollbarPainter.mainAxisMargin], which sets the distance from the + /// scrollbar's start and end to the edge of the viewport in logical pixels. + final double? mainAxisMargin; + + /// Overrides the default value of the [ScrollbarPainter.minLength] + /// property in all descendant [Scrollbar] widgets. + /// + /// See also: + /// + /// * [ScrollbarPainter.minLength], which sets the preferred smallest size + /// the scrollbar can shrink to when the total scrollable extent is large, + /// the current visible viewport is small, and the viewport is not + /// overscrolled. + final double? minThumbLength; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + ScrollbarThemeData copyWith({ + WidgetStateProperty<bool?>? thumbVisibility, + WidgetStateProperty<double?>? thickness, + WidgetStateProperty<bool?>? trackVisibility, + bool? interactive, + Radius? radius, + WidgetStateProperty<Color?>? thumbColor, + WidgetStateProperty<Color?>? trackColor, + WidgetStateProperty<Color?>? trackBorderColor, + double? crossAxisMargin, + double? mainAxisMargin, + double? minThumbLength, + }) { + return ScrollbarThemeData( + thumbVisibility: thumbVisibility ?? this.thumbVisibility, + thickness: thickness ?? this.thickness, + trackVisibility: trackVisibility ?? this.trackVisibility, + interactive: interactive ?? this.interactive, + radius: radius ?? this.radius, + thumbColor: thumbColor ?? this.thumbColor, + trackColor: trackColor ?? this.trackColor, + trackBorderColor: trackBorderColor ?? this.trackBorderColor, + crossAxisMargin: crossAxisMargin ?? this.crossAxisMargin, + mainAxisMargin: mainAxisMargin ?? this.mainAxisMargin, + minThumbLength: minThumbLength ?? this.minThumbLength, + ); + } + + /// Linearly interpolate between two Scrollbar themes. + /// + /// {@macro dart.ui.shadow.lerp} + static ScrollbarThemeData lerp(ScrollbarThemeData? a, ScrollbarThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return ScrollbarThemeData( + thumbVisibility: WidgetStateProperty.lerp<bool?>( + a?.thumbVisibility, + b?.thumbVisibility, + t, + _lerpBool, + ), + thickness: WidgetStateProperty.lerp<double?>(a?.thickness, b?.thickness, t, lerpDouble), + trackVisibility: WidgetStateProperty.lerp<bool?>( + a?.trackVisibility, + b?.trackVisibility, + t, + _lerpBool, + ), + interactive: _lerpBool(a?.interactive, b?.interactive, t), + radius: Radius.lerp(a?.radius, b?.radius, t), + thumbColor: WidgetStateProperty.lerp<Color?>(a?.thumbColor, b?.thumbColor, t, Color.lerp), + trackColor: WidgetStateProperty.lerp<Color?>(a?.trackColor, b?.trackColor, t, Color.lerp), + trackBorderColor: WidgetStateProperty.lerp<Color?>( + a?.trackBorderColor, + b?.trackBorderColor, + t, + Color.lerp, + ), + crossAxisMargin: lerpDouble(a?.crossAxisMargin, b?.crossAxisMargin, t), + mainAxisMargin: lerpDouble(a?.mainAxisMargin, b?.mainAxisMargin, t), + minThumbLength: lerpDouble(a?.minThumbLength, b?.minThumbLength, t), + ); + } + + @override + int get hashCode => Object.hash( + thumbVisibility, + thickness, + trackVisibility, + interactive, + radius, + thumbColor, + trackColor, + trackBorderColor, + crossAxisMargin, + mainAxisMargin, + minThumbLength, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ScrollbarThemeData && + other.thumbVisibility == thumbVisibility && + other.thickness == thickness && + other.trackVisibility == trackVisibility && + other.interactive == interactive && + other.radius == radius && + other.thumbColor == thumbColor && + other.trackColor == trackColor && + other.trackBorderColor == trackBorderColor && + other.crossAxisMargin == crossAxisMargin && + other.mainAxisMargin == mainAxisMargin && + other.minThumbLength == minThumbLength; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<bool?>>( + 'thumbVisibility', + thumbVisibility, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>('thickness', thickness, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<bool?>>( + 'trackVisibility', + trackVisibility, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<bool>('interactive', interactive, defaultValue: null)); + properties.add(DiagnosticsProperty<Radius>('radius', radius, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'thumbColor', + thumbColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'trackColor', + trackColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'trackBorderColor', + trackBorderColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<double>('crossAxisMargin', crossAxisMargin, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<double>('mainAxisMargin', mainAxisMargin, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<double>('minThumbLength', minThumbLength, defaultValue: null), + ); + } +} + +bool? _lerpBool(bool? a, bool? b, double t) => t < 0.5 ? a : b; + +/// Applies a scrollbar theme to descendant [Scrollbar] widgets. +/// +/// Descendant widgets obtain the current theme's [ScrollbarThemeData] using +/// [ScrollbarTheme.of]. When a widget uses [ScrollbarTheme.of], it is +/// automatically rebuilt if the theme later changes. +/// +/// A scrollbar theme can be specified as part of the overall Material theme +/// using [ThemeData.scrollbarTheme]. +/// +/// See also: +/// +/// * [ScrollbarThemeData], which describes the configuration of a +/// scrollbar theme. +class ScrollbarTheme extends InheritedTheme { + /// Constructs a scrollbar theme that configures all descendant [Scrollbar] + /// widgets. + const ScrollbarTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [Scrollbar] widgets. + final ScrollbarThemeData data; + + /// Returns the configuration [data] from the closest [ScrollbarTheme] + /// ancestor. If there is no ancestor, it returns [ThemeData.scrollbarTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ScrollbarThemeData theme = ScrollbarTheme.of(context); + /// ``` + static ScrollbarThemeData of(BuildContext context) { + final ScrollbarTheme? scrollbarTheme = context + .dependOnInheritedWidgetOfExactType<ScrollbarTheme>(); + return scrollbarTheme?.data ?? Theme.of(context).scrollbarTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ScrollbarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ScrollbarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/search.dart b/packages/material_ui/lib/src/search.dart new file mode 100644 index 000000000000..e2cd7001857b --- /dev/null +++ b/packages/material_ui/lib/src/search.dart @@ -0,0 +1,661 @@ +// 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. + +/// @docImport 'action_buttons.dart'; +/// @docImport 'animated_icons.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'list_tile.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'app_bar.dart'; +import 'app_bar_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'input_border.dart'; +import 'input_decorator.dart'; +import 'material_localizations.dart'; +import 'scaffold.dart'; +import 'text_field.dart'; +import 'theme.dart'; + +/// Shows a full screen search page and returns the search result selected by +/// the user when the page is closed. +/// +/// The search page consists of an app bar with a search field and a body which +/// can either show suggested search queries or the search results. +/// +/// The appearance of the search page is determined by the provided +/// `delegate`. The initial query string is given by `query`, which defaults +/// to the empty string. When `query` is set to null, `delegate.query` will +/// be used as the initial query. +/// +/// This method returns the selected search result, which can be set in the +/// [SearchDelegate.close] call. If the search page is closed with the system +/// back button, it returns null. +/// +/// A given [SearchDelegate] can only be associated with one active [showSearch] +/// call. Call [SearchDelegate.close] before re-using the same delegate instance +/// for another [showSearch] call. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// search page to the [Navigator] furthest from or nearest to the given +/// `context`. By default, `useRootNavigator` is `false` and the search page +/// route created by this method is pushed to the nearest navigator to the +/// given `context`. It can not be `null`. +/// +/// The `maintainState` argument is used to determine if the route should remain +/// in memory when it is inactive (see [ModalRoute.maintainState] for more details]. +/// By default, `maintainState` is `false`. +/// +/// The transition to the search page triggered by this method looks best if the +/// screen triggering the transition contains an [AppBar] at the top and the +/// transition is called from an [IconButton] that's part of [AppBar.actions]. +/// The animation provided by [SearchDelegate.transitionAnimation] can be used +/// to trigger additional animations in the underlying page while the search +/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in +/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow +/// used to exit the search page. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// See also: +/// +/// * [SearchDelegate] to define the content of the search page. +Future<T?> showSearch<T>({ + required BuildContext context, + required SearchDelegate<T> delegate, + String? query = '', + bool useRootNavigator = false, + bool maintainState = false, +}) { + delegate.query = query ?? delegate.query; + delegate._currentBody = _SearchBody.suggestions; + return Navigator.of( + context, + rootNavigator: useRootNavigator, + ).push(_SearchPageRoute<T>(delegate: delegate, maintainState: maintainState)); +} + +/// Delegate for [showSearch] to define the content of the search page. +/// +/// The search page always shows an [AppBar] at the top where users can +/// enter their search queries. The buttons shown before and after the search +/// query text field can be customized via [SearchDelegate.buildLeading] +/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed +/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom]. +/// +/// The body below the [AppBar] can either show suggested queries (returned by +/// [SearchDelegate.buildSuggestions]) or - once the user submits a search - the +/// results of the search as returned by [SearchDelegate.buildResults]. +/// +/// [SearchDelegate.query] always contains the current query entered by the user +/// and should be used to build the suggestions and results. +/// +/// The results can be brought on screen by calling [SearchDelegate.showResults] +/// and you can go back to showing the suggestions by calling +/// [SearchDelegate.showSuggestions]. +/// +/// Once the user has selected a search result, [SearchDelegate.close] should be +/// called to remove the search page from the top of the navigation stack and +/// to notify the caller of [showSearch] about the selected search result. +/// +/// A given [SearchDelegate] can only be associated with one active [showSearch] +/// call. Call [SearchDelegate.close] before re-using the same delegate instance +/// for another [showSearch] call. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +abstract class SearchDelegate<T> { + /// Constructor to be called by subclasses which may specify + /// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme], + /// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel] + /// and [searchFieldDecorationTheme] may be non-null. + /// + /// {@tool snippet} + /// ```dart + /// class CustomSearchHintDelegate extends SearchDelegate<String> { + /// CustomSearchHintDelegate({ + /// required String hintText, + /// }) : super( + /// searchFieldLabel: hintText, + /// keyboardType: TextInputType.text, + /// textInputAction: TextInputAction.search, + /// ); + /// + /// @override + /// Widget buildLeading(BuildContext context) => const Text('leading'); + /// + /// @override + /// PreferredSizeWidget buildBottom(BuildContext context) { + /// return const PreferredSize( + /// preferredSize: Size.fromHeight(56.0), + /// child: Text('bottom')); + /// } + /// + /// @override + /// Widget buildSuggestions(BuildContext context) => const Text('suggestions'); + /// + /// @override + /// Widget buildResults(BuildContext context) => const Text('results'); + /// + /// @override + /// List<Widget> buildActions(BuildContext context) => <Widget>[]; + /// } + /// ``` + /// {@end-tool} + SearchDelegate({ + this.searchFieldLabel, + this.searchFieldStyle, + this.searchFieldDecorationTheme, + this.keyboardType, + this.textInputAction = TextInputAction.search, + this.autocorrect = true, + this.enableSuggestions = true, + }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null); + + /// Suggestions shown in the body of the search page while the user types a + /// query into the search field. + /// + /// The delegate method is called whenever the content of [query] changes. + /// The suggestions should be based on the current [query] string. If the query + /// string is empty, it is good practice to show suggested queries based on + /// past queries or the current context. + /// + /// Usually, this method will return a [ListView] with one [ListTile] per + /// suggestion. When [ListTile.onTap] is called, [query] should be updated + /// with the corresponding suggestion and the results page should be shown + /// by calling [showResults]. + Widget buildSuggestions(BuildContext context); + + /// The results shown after the user submits a search from the search page. + /// + /// The current value of [query] can be used to determine what the user + /// searched for. + /// + /// This method might be applied more than once to the same query. + /// If your [buildResults] method is computationally expensive, you may want + /// to cache the search results for one or more queries. + /// + /// Typically, this method returns a [ListView] with the search results. + /// When the user taps on a particular search result, [close] should be called + /// with the selected result as argument. This will close the search page and + /// communicate the result back to the initial caller of [showSearch]. + Widget buildResults(BuildContext context); + + /// A widget to display before the current query in the [AppBar]. + /// + /// Typically an [IconButton] configured with a [BackButtonIcon] that exits + /// the search with [close]. One can also use an [AnimatedIcon] driven by + /// [transitionAnimation], which animates from e.g. a hamburger menu to the + /// back button as the search overlay fades in. + /// + /// Returns null if no widget should be shown. + /// + /// See also: + /// + /// * [AppBar.leading], the intended use for the return value of this method. + Widget? buildLeading(BuildContext context); + + /// {@macro flutter.material.appbar.automaticallyImplyLeading} + bool? automaticallyImplyLeading; + + /// {@macro flutter.material.appbar.leadingWidth} + double? leadingWidth; + + /// Widgets to display after the search query in the [AppBar]. + /// + /// If the [query] is not empty, this should typically contain a button to + /// clear the query and show the suggestions again (via [showSuggestions]) if + /// the results are currently shown. + /// + /// Returns null if no widget should be shown. + /// + /// See also: + /// + /// * [AppBar.actions], the intended use for the return value of this method. + List<Widget>? buildActions(BuildContext context); + + /// Widget to display across the bottom of the [AppBar]. + /// + /// Returns null by default, i.e. a bottom widget is not included. + /// + /// See also: + /// + /// * [AppBar.bottom], the intended use for the return value of this method. + /// + PreferredSizeWidget? buildBottom(BuildContext context) => null; + + /// Widget to display a flexible space in the [AppBar]. + /// + /// Returns null by default, i.e. a flexible space widget is not included. + /// + /// See also: + /// + /// * [AppBar.flexibleSpace], the intended use for the return value of this method. + Widget? buildFlexibleSpace(BuildContext context) => null; + + /// The theme used to configure the search page. + /// + /// The returned [ThemeData] will be used to wrap the entire search page, + /// so it can be used to configure any of its components with the appropriate + /// theme properties. + /// + /// Unless overridden, the default theme will configure the AppBar containing + /// the search input text field with a white background and black text on light + /// themes. For dark themes the default is a dark grey background with light + /// color text. + /// + /// See also: + /// + /// * [AppBarTheme], which configures the AppBar's appearance. + /// * [InputDecorationTheme], which configures the appearance of the search + /// text field. + ThemeData appBarTheme(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + return theme.copyWith( + appBarTheme: AppBarThemeData( + systemOverlayStyle: colorScheme.brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + backgroundColor: colorScheme.brightness == Brightness.dark + ? Colors.grey[900] + : Colors.white, + iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey), + titleTextStyle: theme.textTheme.titleLarge, + toolbarTextStyle: theme.textTheme.bodyMedium, + ), + inputDecorationTheme: + searchFieldDecorationTheme ?? + InputDecorationTheme( + hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle, + border: InputBorder.none, + ), + ); + } + + /// The current query string shown in the [AppBar]. + /// + /// The user manipulates this string via the keyboard. + /// + /// If the user taps on a suggestion provided by [buildSuggestions] this + /// string should be updated to that suggestion via the setter. + String get query => _queryTextController.text; + + /// Changes the current query string. + /// + /// Setting the query string programmatically moves the cursor to the end of the text field. + set query(String value) { + _queryTextController.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + } + + /// Transition from the suggestions returned by [buildSuggestions] to the + /// [query] results returned by [buildResults]. + /// + /// If the user taps on a suggestion provided by [buildSuggestions] the + /// screen should typically transition to the page showing the search + /// results for the suggested query. This transition can be triggered + /// by calling this method. + /// + /// See also: + /// + /// * [showSuggestions] to show the search suggestions again. + void showResults(BuildContext context) { + _focusNode?.unfocus(); + _currentBody = _SearchBody.results; + } + + /// Transition from showing the results returned by [buildResults] to showing + /// the suggestions returned by [buildSuggestions]. + /// + /// Calling this method will also put the input focus back into the search + /// field of the [AppBar]. + /// + /// If the results are currently shown this method can be used to go back + /// to showing the search suggestions. + /// + /// See also: + /// + /// * [showResults] to show the search results. + void showSuggestions(BuildContext context) { + assert(_focusNode != null, '_focusNode must be set by route before showSuggestions is called.'); + _focusNode!.requestFocus(); + _currentBody = _SearchBody.suggestions; + } + + /// Closes the search page and returns to the underlying route. + /// + /// The value provided for `result` is used as the return value of the call + /// to [showSearch] that launched the search initially. + void close(BuildContext context, T result) { + _currentBody = null; + _focusNode?.unfocus(); + Navigator.of(context) + ..popUntil((Route<dynamic> route) => route == _route) + ..pop(result); + } + + /// Closes the search page and returns to the underlying route whitout result. + void _pop(BuildContext context) { + _currentBody = null; + _focusNode?.unfocus(); + Navigator.of(context) + ..popUntil((Route<dynamic> route) => route == _route) + ..pop(); + } + + /// The hint text that is shown in the search field when it is empty. + /// + /// If this value is set to null, the value of + /// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead. + final String? searchFieldLabel; + + /// The style of the [searchFieldLabel]. + /// + /// If this value is set to null, the value of the ambient [Theme]'s + /// [InputDecorationTheme.hintStyle] will be used instead. + /// + /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can + /// be non-null. + final TextStyle? searchFieldStyle; + + /// The [InputDecorationTheme] used to configure the search field's visuals. + /// + /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can + /// be non-null. + final InputDecorationTheme? searchFieldDecorationTheme; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to the default value specified in [TextField]. + final TextInputType? keyboardType; + + /// Whether to enable autocorrection. + /// + /// Defaults to true. + final bool autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// The text input action configuring the soft keyboard to a particular action + /// button. + /// + /// Defaults to [TextInputAction.search]. + final TextInputAction textInputAction; + + /// [Animation] triggered when the search pages fades in or out. + /// + /// This animation is commonly used to animate [AnimatedIcon]s of + /// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be + /// used to animate [IconButton]s contained within the route below the search + /// page. + Animation<double> get transitionAnimation => _proxyAnimation; + + // The focus node to use for manipulating focus on the search page. This is + // managed, owned, and set by the _SearchPageRoute using this delegate. + FocusNode? _focusNode; + + final TextEditingController _queryTextController = TextEditingController(); + + final ProxyAnimation _proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation); + + final ValueNotifier<_SearchBody?> _currentBodyNotifier = ValueNotifier<_SearchBody?>(null); + + _SearchBody? get _currentBody => _currentBodyNotifier.value; + set _currentBody(_SearchBody? value) { + _currentBodyNotifier.value = value; + } + + _SearchPageRoute<T>? _route; + + /// Releases the resources. + @mustCallSuper + void dispose() { + _currentBodyNotifier.dispose(); + _focusNode?.dispose(); + _queryTextController.dispose(); + _proxyAnimation.parent = null; + } +} + +/// Describes the body that is currently shown under the [AppBar] in the +/// search page. +enum _SearchBody { + /// Suggested queries are shown in the body. + /// + /// The suggested queries are generated by [SearchDelegate.buildSuggestions]. + suggestions, + + /// Search results are currently shown in the body. + /// + /// The search results are generated by [SearchDelegate.buildResults]. + results, +} + +class _SearchPageRoute<T> extends PageRoute<T> { + _SearchPageRoute({required this.delegate, required this.maintainState}) { + assert( + delegate._route == null, + 'The ${delegate.runtimeType} instance is currently used by another active ' + 'search. Please close that search by calling close() on the SearchDelegate ' + 'before opening another search with the same delegate instance.', + ); + delegate._route = this; + } + + final SearchDelegate<T> delegate; + + @override + final bool maintainState; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + Widget buildTransitions( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + return FadeTransition(opacity: animation, child: child); + } + + @override + Animation<double> createAnimation() { + final Animation<double> animation = super.createAnimation(); + delegate._proxyAnimation.parent = animation; + return animation; + } + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return _SearchPage<T>(delegate: delegate, animation: animation); + } + + @override + void didComplete(T? result) { + super.didComplete(result); + assert(delegate._route == this); + delegate._route = null; + delegate._currentBody = null; + } +} + +class _SearchPage<T> extends StatefulWidget { + const _SearchPage({required this.delegate, required this.animation}); + + final SearchDelegate<T> delegate; + final Animation<double> animation; + + @override + State<StatefulWidget> createState() => _SearchPageState<T>(); +} + +class _SearchPageState<T> extends State<_SearchPage<T>> { + // This node is owned, but not hosted by, the search page. Hosting is done by + // the text field. + late final FocusNode focusNode = FocusNode( + onKeyEvent: (FocusNode node, KeyEvent event) { + // When the user presses the escape key, close the search page. + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { + widget.delegate._pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + ); + + @override + void initState() { + super.initState(); + widget.delegate._queryTextController.addListener(_onQueryChanged); + widget.animation.addStatusListener(_onAnimationStatusChanged); + widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged); + focusNode.addListener(_onFocusChanged); + widget.delegate._focusNode = focusNode; + } + + @override + void dispose() { + super.dispose(); + widget.delegate._queryTextController.removeListener(_onQueryChanged); + widget.animation.removeStatusListener(_onAnimationStatusChanged); + widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate._focusNode = null; + focusNode.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus status) { + if (!status.isCompleted) { + return; + } + widget.animation.removeStatusListener(_onAnimationStatusChanged); + if (widget.delegate._currentBody == _SearchBody.suggestions) { + focusNode.requestFocus(); + } + } + + @override + void didUpdateWidget(_SearchPage<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.delegate != oldWidget.delegate) { + oldWidget.delegate._queryTextController.removeListener(_onQueryChanged); + widget.delegate._queryTextController.addListener(_onQueryChanged); + oldWidget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged); + oldWidget.delegate._focusNode = null; + widget.delegate._focusNode = focusNode; + } + } + + void _onFocusChanged() { + if (focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) { + widget.delegate.showSuggestions(context); + } + } + + void _onQueryChanged() { + setState(() { + // rebuild ourselves because query changed. + }); + } + + void _onSearchBodyChanged() { + setState(() { + // rebuild ourselves because search body changed. + }); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData theme = widget.delegate.appBarTheme(context); + final String searchFieldLabel = + widget.delegate.searchFieldLabel ?? MaterialLocalizations.of(context).searchFieldLabel; + Widget? body; + switch (widget.delegate._currentBody) { + case _SearchBody.suggestions: + body = KeyedSubtree( + key: const ValueKey<_SearchBody>(_SearchBody.suggestions), + child: widget.delegate.buildSuggestions(context), + ); + case _SearchBody.results: + body = KeyedSubtree( + key: const ValueKey<_SearchBody>(_SearchBody.results), + child: widget.delegate.buildResults(context), + ); + case null: + break; + } + + late final String routeName; + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + routeName = ''; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + routeName = searchFieldLabel; + } + + return Semantics( + explicitChildNodes: true, + scopesRoute: true, + namesRoute: true, + label: routeName, + child: Theme( + data: theme, + child: Scaffold( + appBar: AppBar( + leadingWidth: widget.delegate.leadingWidth, + automaticallyImplyLeading: widget.delegate.automaticallyImplyLeading ?? true, + leading: widget.delegate.buildLeading(context), + title: Semantics( + inputType: SemanticsInputType.search, + child: TextField( + controller: widget.delegate._queryTextController, + focusNode: focusNode, + style: widget.delegate.searchFieldStyle ?? theme.textTheme.titleLarge, + textInputAction: widget.delegate.textInputAction, + autocorrect: widget.delegate.autocorrect, + enableSuggestions: widget.delegate.enableSuggestions, + keyboardType: widget.delegate.keyboardType, + onSubmitted: (String _) => widget.delegate.showResults(context), + decoration: InputDecoration(hintText: searchFieldLabel), + ), + ), + flexibleSpace: widget.delegate.buildFlexibleSpace(context), + actions: widget.delegate.buildActions(context), + bottom: widget.delegate.buildBottom(context), + ), + body: AnimatedSwitcher(duration: const Duration(milliseconds: 300), child: body), + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/search_anchor.dart b/packages/material_ui/lib/src/search_anchor.dart new file mode 100644 index 000000000000..19cded6e9fa8 --- /dev/null +++ b/packages/material_ui/lib/src/search_anchor.dart @@ -0,0 +1,1971 @@ +// 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. + +/// @docImport 'package:flutter/services.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'back_button.dart'; +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'divider.dart'; +import 'divider_theme.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'input_border.dart'; +import 'input_decorator.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'search_bar_theme.dart'; +import 'search_view_theme.dart'; +import 'text_field.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +const int _kOpenViewMilliseconds = 600; +const Duration _kOpenViewDuration = Duration(milliseconds: _kOpenViewMilliseconds); +const Duration _kAnchorFadeDuration = Duration(milliseconds: 150); +const Curve _kViewFadeOnInterval = Interval(0.0, 1 / 2); +const Curve _kViewIconsFadeOnInterval = Interval(1 / 6, 2 / 6); +const Curve _kViewDividerFadeOnInterval = Interval(0.0, 1 / 6); +const Curve _kViewListFadeOnInterval = Interval( + 133 / _kOpenViewMilliseconds, + 233 / _kOpenViewMilliseconds, +); +const double _kDisableSearchBarOpacity = 0.38; + +/// Signature for a function that creates a [Widget] which is used to open a search view. +/// +/// The `controller` callback provided to [SearchAnchor.builder] can be used +/// to open the search view and control the editable field on the view. +typedef SearchAnchorChildBuilder = + Widget Function(BuildContext context, SearchController controller); + +/// Signature for a function that creates a [Widget] to build the suggestion list +/// based on the input in the search bar. +/// +/// The `controller` callback provided to [SearchAnchor.suggestionsBuilder] can be used +/// to close the search view and control the editable field on the view. +typedef SuggestionsBuilder = + FutureOr<Iterable<Widget>> Function(BuildContext context, SearchController controller); + +/// Signature for a function that creates a [Widget] to layout the suggestion list. +/// +/// Parameter `suggestions` is the content list that this function wants to lay out. +typedef ViewBuilder = Widget Function(Iterable<Widget> suggestions); + +/// Manages a "search view" route that allows the user to select one of the +/// suggested completions for a search query. +/// +/// The search view's route can either be shown by creating a [SearchController] +/// and then calling [SearchController.openView] or by tapping on an anchor. +/// When the anchor is tapped or [SearchController.openView] is called, the search view either +/// grows to a specific size, or grows to fill the entire screen. By default, +/// the search view only shows full screen on mobile platforms. Use [SearchAnchor.isFullScreen] +/// to override the default setting. +/// +/// The search view is usually opened by a [SearchBar], an [IconButton] or an [Icon]. +/// If [builder] returns an Icon, or any un-tappable widgets, we don't have +/// to explicitly call [SearchController.openView]. +/// +/// The search view route will be popped if the window size is changed and the +/// search view route is not in full-screen mode. However, if the search view route +/// is in full-screen mode, changing the window size, such as rotating a mobile +/// device from portrait mode to landscape mode, will not close the search view. +/// +/// {@tool dartpad} +/// This example shows how to use an IconButton to open a search view in a [SearchAnchor]. +/// It also shows how to use [SearchController] to open or close the search view route. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to set up a floating (or pinned) AppBar with a +/// [SearchAnchor] for a title. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to fetch the search suggestions from a remote API. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.3.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example demonstrates fetching the search suggestions asynchronously and +/// debouncing network calls. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.4.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SearchBar], a widget that defines a search bar. +/// * [SearchBarTheme], a widget that overrides the default configuration of a search bar. +/// * [SearchViewTheme], a widget that overrides the default configuration of a search view. +class SearchAnchor extends StatefulWidget { + /// Creates a const [SearchAnchor]. + /// + /// The [builder] and [suggestionsBuilder] arguments are required. + const SearchAnchor({ + super.key, + this.isFullScreen, + this.searchController, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewBarPadding, + this.headerHeight, + this.headerTextStyle, + this.headerHintStyle, + this.dividerColor, + this.viewConstraints, + this.viewPadding, + this.shrinkWrap, + this.textCapitalization, + this.viewOnChanged, + this.viewOnSubmitted, + this.viewOnClose, + this.viewOnOpen, + required this.builder, + required this.suggestionsBuilder, + this.textInputAction, + this.keyboardType, + this.enabled = true, + this.smartDashesType, + this.smartQuotesType, + }); + + /// Create a [SearchAnchor] that has a [SearchBar] which opens a search view. + /// + /// All the barX parameters are used to customize the anchor. Similarly, all the + /// viewX parameters are used to override the view's defaults. + /// + /// {@tool dartpad} + /// This example shows how to use a [SearchAnchor.bar] which uses a default search + /// bar to open a search view route. + /// + /// ** See code in examples/api/lib/material/search_anchor/search_anchor.0.dart ** + /// {@end-tool} + factory SearchAnchor.bar({ + Widget? barLeading, + Iterable<Widget>? barTrailing, + String? barHintText, + GestureTapCallback? onTap, + ValueChanged<String>? onSubmitted, + ValueChanged<String>? onChanged, + VoidCallback? onClose, + VoidCallback? onOpen, + WidgetStateProperty<double?>? barElevation, + WidgetStateProperty<Color?>? barBackgroundColor, + WidgetStateProperty<Color?>? barOverlayColor, + WidgetStateProperty<BorderSide?>? barSide, + WidgetStateProperty<OutlinedBorder?>? barShape, + WidgetStateProperty<EdgeInsetsGeometry?>? barPadding, + EdgeInsetsGeometry? viewBarPadding, + WidgetStateProperty<TextStyle?>? barTextStyle, + WidgetStateProperty<TextStyle?>? barHintStyle, + ViewBuilder? viewBuilder, + Widget? viewLeading, + Iterable<Widget>? viewTrailing, + String? viewHintText, + Color? viewBackgroundColor, + double? viewElevation, + BorderSide? viewSide, + OutlinedBorder? viewShape, + double? viewHeaderHeight, + TextStyle? viewHeaderTextStyle, + TextStyle? viewHeaderHintStyle, + Color? dividerColor, + BoxConstraints? constraints, + BoxConstraints? viewConstraints, + EdgeInsetsGeometry? viewPadding, + bool? shrinkWrap, + bool? isFullScreen, + SearchController searchController, + TextCapitalization textCapitalization, + required SuggestionsBuilder suggestionsBuilder, + TextInputAction? textInputAction, + TextInputType? keyboardType, + EdgeInsets scrollPadding, + EditableTextContextMenuBuilder contextMenuBuilder, + bool enabled, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + }) = _SearchAnchorWithSearchBar; + + /// Whether the search view grows to fill the entire screen when the + /// [SearchAnchor] is tapped. + /// + /// By default, the search view is full-screen on mobile devices. On other + /// platforms, the search view only grows to a specific size that is determined + /// by the anchor and the default size. + final bool? isFullScreen; + + /// An optional controller that allows opening and closing of the search view from + /// other widgets. + /// + /// If this is null, one internal search controller is created automatically + /// and it is used to open the search view when the user taps on the anchor. + final SearchController? searchController; + + /// Optional callback to obtain a widget to lay out the suggestion list of the + /// search view. + /// + /// Default view uses a [ListView] with a vertical scroll direction. + final ViewBuilder? viewBuilder; + + /// An optional widget to display before the text input field when the search + /// view is open. + /// + /// Typically the [viewLeading] widget is an [Icon] or an [IconButton]. + /// + /// Defaults to a back button which pops the view. + final Widget? viewLeading; + + /// An optional widget list to display after the text input field when the search + /// view is open. + /// + /// Typically the [viewTrailing] widget list only has one or two widgets. + /// + /// Defaults to an icon button which clears the text in the input field. + final Iterable<Widget>? viewTrailing; + + /// Text that is displayed when the search bar's input field is empty. + final String? viewHintText; + + /// The search view's background fill color. + /// + /// If null, the value of [SearchViewThemeData.backgroundColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surfaceContainerHigh]. + final Color? viewBackgroundColor; + + /// The elevation of the search view's [Material]. + /// + /// If null, the value of [SearchViewThemeData.elevation] will be used. If this + /// is also null, then default value is 6.0. + final double? viewElevation; + + /// The surface tint color of the search view's [Material]. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, the value of [SearchViewThemeData.surfaceTintColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surfaceTint]. + final Color? viewSurfaceTintColor; + + /// The color and weight of the search view's outline. + /// + /// This value is combined with [viewShape] to create a shape decorated + /// with an outline. This will be ignored if the view is full-screen. + /// + /// If null, the value of [SearchViewThemeData.side] will be used. If this is + /// also null, the search view doesn't have a side by default. + final BorderSide? viewSide; + + /// The shape of the search view's underlying [Material]. + /// + /// This shape is combined with [viewSide] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchViewThemeData.shape] will be used. + /// If this is also null, then the default value is a rectangle shape for full-screen + /// mode and a [RoundedRectangleBorder] shape with a 28.0 radius otherwise. + final OutlinedBorder? viewShape; + + /// The padding to use for the search view's search bar. + /// + /// If null, then the default value is 8.0 horizontally. + final EdgeInsetsGeometry? viewBarPadding; + + /// The height of the search field on the search view. + /// + /// If null, the value of [SearchViewThemeData.headerHeight] will be used. If + /// this is also null, the default value is 56.0. + final double? headerHeight; + + /// The style to use for the text being edited on the search view. + /// + /// If null, defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurface]. + final TextStyle? headerTextStyle; + + /// The style to use for the [viewHintText] on the search view. + /// + /// If null, the value of [SearchViewThemeData.headerHintStyle] will be used. + /// If this is also null, the value of [headerTextStyle] will be used. If this is also null, + /// defaults to the `bodyLarge` text style from the current [Theme]. The default + /// text color is [ColorScheme.onSurfaceVariant]. + final TextStyle? headerHintStyle; + + /// The color of the divider on the search view. + /// + /// If this property is null, then [SearchViewThemeData.dividerColor] is used. + /// If that is also null, the default value is [ColorScheme.outline]. + final Color? dividerColor; + + /// Optional size constraints for the search view. + /// + /// By default, the search view has the same width as the anchor and is 2/3 + /// the height of the screen. If the width and height of the view are within + /// the [viewConstraints], the view will show its default size. Otherwise, + /// the size of the view will be constrained by this property. + /// + /// If null, the value of [SearchViewThemeData.constraints] will be used. If + /// this is also null, then the constraints defaults to: + /// ```dart + /// const BoxConstraints(minWidth: 360.0, minHeight: 240.0) + /// ``` + final BoxConstraints? viewConstraints; + + /// The padding to use for the search view. + /// + /// Has no effect if the search view is full-screen. + /// + /// If null, the value of [SearchViewThemeData.padding] will be used. + final EdgeInsetsGeometry? viewPadding; + + /// Whether the search view should shrink-wrap its contents. + /// + /// Has no effect if the search view is full-screen. + /// + /// If null, the value of [SearchViewThemeData.shrinkWrap] will be used. If + /// this is also null, then the default value is `false`. + final bool? shrinkWrap; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization? textCapitalization; + + /// Called each time the user modifies the search view's text field. + /// + /// See also: + /// + /// * [viewOnSubmitted], which is called when the user indicates that they + /// are done editing the search view's text field. + final ValueChanged<String>? viewOnChanged; + + /// Called when the user indicates that they are done editing the text in the + /// text field of a search view. Typically this is called when the user presses + /// the enter key. + /// + /// See also: + /// + /// * [viewOnChanged], which is called when the user modifies the text field + /// of the search view. + final ValueChanged<String>? viewOnSubmitted; + + /// Called when the search view is closed. + final VoidCallback? viewOnClose; + + /// Called when the search view is opened. + final VoidCallback? viewOnOpen; + + /// Called to create a widget which can open a search view route when it is tapped. + /// + /// The widget returned by this builder is faded out when it is tapped. + /// At the same time a search view route is faded in. + final SearchAnchorChildBuilder builder; + + /// Called to get the suggestion list for the search view. + /// + /// This builder is called once when the search view is first displayed, + /// and subsequently every time the search text changes. + /// + /// By default, the list returned by this builder is laid out in a [ListView]. + /// To get a different layout, use [viewBuilder] to override. + final SuggestionsBuilder suggestionsBuilder; + + /// {@macro flutter.widgets.TextField.textInputAction} + final TextInputAction? textInputAction; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to the default value specified in [TextField]. + final TextInputType? keyboardType; + + /// Whether or not this widget is currently interactive. + /// + /// When false, the widget will ignore taps and appear dimmed. + /// + /// Defaults to true. + final bool enabled; + + /// Configures how smart dashes are handled in the text field + /// used by this [SearchAnchor]. + /// + /// For example, when enabled, double hyphens (`--`) may be + /// automatically replaced with an em dash (`—`) on iOS. + /// + /// Defaults to [SmartDashesType.enabled]. + /// + /// See also: + /// * [TextField.smartDashesType], which provides the same + /// configuration option on a standalone [TextField]. + final SmartDashesType? smartDashesType; + + /// Configures how smart quotes are handled in the text field + /// used by this [SearchAnchor]. + /// + /// For example, when enabled, straight quotes (`"`) may be + /// automatically replaced with curly quotes (`“ ”`) on iOS. + /// + /// Defaults to [SmartQuotesType.enabled]. + /// + /// See also: + /// * [TextField.smartQuotesType], which provides the same + /// configuration option on a standalone [TextField]. + final SmartQuotesType? smartQuotesType; + + @override + State<SearchAnchor> createState() => _SearchAnchorState(); +} + +class _SearchAnchorState extends State<SearchAnchor> { + Size? _screenSize; + bool _anchorIsVisible = true; + final GlobalKey _anchorKey = GlobalKey(); + bool get _viewIsOpen => !_anchorIsVisible; + SearchController? _internalSearchController; + SearchController get _searchController => + widget.searchController ?? (_internalSearchController ??= SearchController()); + _SearchViewRoute? _route; + + @override + void initState() { + super.initState(); + _searchController._attach(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final Size updatedScreenSize = MediaQuery.of(context).size; + if (_screenSize != null && _screenSize != updatedScreenSize) { + if (_searchController.isOpen && !getShowFullScreenView()) { + _closeView(null); + } + } + _screenSize = updatedScreenSize; + } + + @override + void didUpdateWidget(SearchAnchor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.searchController != widget.searchController) { + oldWidget.searchController?._detach(this); + _searchController._attach(this); + } + } + + @override + void dispose() { + widget.searchController?._detach(this); + _internalSearchController?._detach(this); + final usingExternalController = widget.searchController != null; + if (_route?.navigator != null) { + _route?._dismiss(disposeController: !usingExternalController); + if (usingExternalController) { + _internalSearchController?.dispose(); + } + } else { + _internalSearchController?.dispose(); + } + super.dispose(); + } + + void _openView() { + final NavigatorState navigator = Navigator.of(context); + _route = _SearchViewRoute( + viewOnChanged: widget.viewOnChanged, + viewOnSubmitted: widget.viewOnSubmitted, + viewOnClose: widget.viewOnClose, + viewOnOpen: widget.viewOnOpen, + viewLeading: widget.viewLeading, + viewTrailing: widget.viewTrailing, + viewHintText: widget.viewHintText, + viewBackgroundColor: widget.viewBackgroundColor, + viewElevation: widget.viewElevation, + viewSurfaceTintColor: widget.viewSurfaceTintColor, + viewSide: widget.viewSide, + viewShape: widget.viewShape, + viewBarPadding: widget.viewBarPadding, + viewHeaderHeight: widget.headerHeight, + viewHeaderTextStyle: widget.headerTextStyle, + viewHeaderHintStyle: widget.headerHintStyle, + dividerColor: widget.dividerColor, + viewConstraints: widget.viewConstraints, + viewPadding: widget.viewPadding, + shrinkWrap: widget.shrinkWrap, + showFullScreenView: getShowFullScreenView(), + toggleVisibility: toggleVisibility, + textDirection: Directionality.of(context), + viewBuilder: widget.viewBuilder, + anchorKey: _anchorKey, + searchController: _searchController, + suggestionsBuilder: widget.suggestionsBuilder, + textCapitalization: widget.textCapitalization, + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + ); + navigator.push(_route!); + } + + void _closeView(String? selectedText) { + if (selectedText != null) { + _searchController.value = TextEditingValue(text: selectedText); + } + Navigator.of(context).pop(); + } + + bool toggleVisibility() { + setState(() { + _anchorIsVisible = !_anchorIsVisible; + }); + return _anchorIsVisible; + } + + bool getShowFullScreenView() { + return widget.isFullScreen ?? + switch (Theme.of(context).platform) { + TargetPlatform.iOS || TargetPlatform.android || TargetPlatform.fuchsia => true, + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => false, + }; + } + + double _getOpacity() { + if (widget.enabled) { + return _anchorIsVisible ? 1.0 : 0.0; + } + return _kDisableSearchBarOpacity; + } + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + key: _anchorKey, + opacity: _getOpacity(), + duration: _kAnchorFadeDuration, + child: IgnorePointer( + ignoring: !widget.enabled, + child: GestureDetector(onTap: _openView, child: widget.builder(context, _searchController)), + ), + ); + } +} + +class _SearchViewRoute extends PopupRoute<_SearchViewRoute> { + _SearchViewRoute({ + this.viewOnChanged, + this.viewOnSubmitted, + this.viewOnClose, + this.viewOnOpen, + this.toggleVisibility, + this.textDirection, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewBarPadding, + this.viewHeaderHeight, + this.viewHeaderTextStyle, + this.viewHeaderHintStyle, + this.dividerColor, + this.viewConstraints, + this.viewPadding, + this.shrinkWrap, + this.textCapitalization, + required this.showFullScreenView, + required this.anchorKey, + required this.searchController, + required this.suggestionsBuilder, + required this.capturedThemes, + this.textInputAction, + this.keyboardType, + this.smartDashesType, + this.smartQuotesType, + }); + + final ValueChanged<String>? viewOnChanged; + final ValueChanged<String>? viewOnSubmitted; + final VoidCallback? viewOnClose; + final VoidCallback? viewOnOpen; + final ValueGetter<bool>? toggleVisibility; + final TextDirection? textDirection; + final ViewBuilder? viewBuilder; + final Widget? viewLeading; + final Iterable<Widget>? viewTrailing; + final String? viewHintText; + final Color? viewBackgroundColor; + final double? viewElevation; + final Color? viewSurfaceTintColor; + final BorderSide? viewSide; + final OutlinedBorder? viewShape; + final EdgeInsetsGeometry? viewBarPadding; + final double? viewHeaderHeight; + final TextStyle? viewHeaderTextStyle; + final TextStyle? viewHeaderHintStyle; + final Color? dividerColor; + final BoxConstraints? viewConstraints; + final EdgeInsetsGeometry? viewPadding; + final bool? shrinkWrap; + final TextCapitalization? textCapitalization; + final bool showFullScreenView; + final GlobalKey anchorKey; + final SearchController searchController; + final SuggestionsBuilder suggestionsBuilder; + final CapturedThemes capturedThemes; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final SmartDashesType? smartDashesType; + final SmartQuotesType? smartQuotesType; + CurvedAnimation? curvedAnimation; + CurvedAnimation? viewFadeOnIntervalCurve; + bool willDisposeSearchController = false; + + @override + Color? get barrierColor => Colors.transparent; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => 'Dismiss'; + + late final SearchViewThemeData viewDefaults; + late final SearchViewThemeData viewTheme; + final RectTween _rectTween = RectTween(); + + Rect? getRect() { + final BuildContext? context = anchorKey.currentContext; + if (context != null) { + final searchBarBox = context.findRenderObject()! as RenderBox; + final Size boxSize = searchBarBox.size; + final NavigatorState navigator = Navigator.of(context); + final Offset boxLocation = searchBarBox.localToGlobal( + Offset.zero, + ancestor: navigator.context.findRenderObject(), + ); + return boxLocation & boxSize; + } + return null; + } + + @override + TickerFuture didPush() { + assert(anchorKey.currentContext != null); + updateViewConfig(anchorKey.currentContext!); + updateTweens(anchorKey.currentContext!); + toggleVisibility?.call(); + viewOnOpen?.call(); + return super.didPush(); + } + + @override + bool didPop(_SearchViewRoute? result) { + assert(anchorKey.currentContext != null); + updateTweens(anchorKey.currentContext!); + toggleVisibility?.call(); + viewOnClose?.call(); + // Unfocus the anchor to prevent the Enter key from triggering unwanted + // actions (like route pops) when the view closes and focus returns to + // the anchor's search bar. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (anchorKey.currentContext != null) { + FocusScope.of(anchorKey.currentContext!).unfocus(); + } + }); + return super.didPop(result); + } + + void _dismiss({required bool disposeController}) { + willDisposeSearchController = disposeController; + if (isActive) { + navigator?.removeRoute(this); + } + } + + @override + void dispose() { + curvedAnimation?.dispose(); + viewFadeOnIntervalCurve?.dispose(); + if (willDisposeSearchController) { + searchController.dispose(); + } + super.dispose(); + } + + void updateViewConfig(BuildContext context) { + viewDefaults = _SearchViewDefaultsM3(context, isFullScreen: showFullScreenView); + viewTheme = SearchViewTheme.of(context); + } + + void updateTweens(BuildContext context) { + final navigator = Navigator.of(context).context.findRenderObject()! as RenderBox; + final Size screenSize = navigator.size; + final Rect anchorRect = getRect() ?? Rect.zero; + + final BoxConstraints effectiveConstraints = + viewConstraints ?? viewTheme.constraints ?? viewDefaults.constraints!; + _rectTween.begin = anchorRect; + + final double viewWidth = clampDouble( + anchorRect.width, + effectiveConstraints.minWidth, + effectiveConstraints.maxWidth, + ); + final double viewHeight = clampDouble( + screenSize.height * 2 / 3, + effectiveConstraints.minHeight, + effectiveConstraints.maxHeight, + ); + + switch (textDirection ?? TextDirection.ltr) { + case TextDirection.ltr: + final double viewLeftToScreenRight = screenSize.width - anchorRect.left; + final double viewTopToScreenBottom = screenSize.height - anchorRect.top; + + // Make sure the search view doesn't go off the screen. If the search view + // doesn't fit, move the top-left corner of the view to fit the window. + // If the window is smaller than the view, then we resize the view to fit the window. + Offset topLeft = anchorRect.topLeft; + if (viewLeftToScreenRight < viewWidth) { + topLeft = Offset(screenSize.width - math.min(viewWidth, screenSize.width), topLeft.dy); + } + if (viewTopToScreenBottom < viewHeight) { + topLeft = Offset(topLeft.dx, screenSize.height - math.min(viewHeight, screenSize.height)); + } + final endSize = Size(viewWidth, viewHeight); + _rectTween.end = showFullScreenView ? Offset.zero & screenSize : (topLeft & endSize); + return; + case TextDirection.rtl: + final double viewRightToScreenLeft = anchorRect.right; + final double viewTopToScreenBottom = screenSize.height - anchorRect.top; + + // Make sure the search view doesn't go off the screen. + var topLeft = Offset(math.max(anchorRect.right - viewWidth, 0.0), anchorRect.top); + if (viewRightToScreenLeft < viewWidth) { + topLeft = Offset(0.0, topLeft.dy); + } + if (viewTopToScreenBottom < viewHeight) { + topLeft = Offset(topLeft.dx, screenSize.height - math.min(viewHeight, screenSize.height)); + } + final endSize = Size(viewWidth, viewHeight); + _rectTween.end = showFullScreenView ? Offset.zero & screenSize : (topLeft & endSize); + } + } + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return Directionality( + textDirection: textDirection ?? TextDirection.ltr, + child: AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + curvedAnimation ??= CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubicEmphasized, + reverseCurve: Curves.easeInOutCubicEmphasized.flipped, + ); + + final Rect viewRect = _rectTween.evaluate(curvedAnimation!)!; + final double topPadding = showFullScreenView + ? lerpDouble(0.0, MediaQuery.paddingOf(context).top, curvedAnimation!.value)! + : 0.0; + + viewFadeOnIntervalCurve ??= CurvedAnimation( + parent: animation, + curve: _kViewFadeOnInterval, + reverseCurve: _kViewFadeOnInterval.flipped, + ); + + return FadeTransition( + opacity: viewFadeOnIntervalCurve!, + child: capturedThemes.wrap( + _ViewContent( + viewOnChanged: viewOnChanged, + viewOnSubmitted: viewOnSubmitted, + viewLeading: viewLeading, + viewTrailing: viewTrailing, + viewHintText: viewHintText, + viewBackgroundColor: viewBackgroundColor, + viewElevation: viewElevation, + viewSurfaceTintColor: viewSurfaceTintColor, + viewSide: viewSide, + viewShape: viewShape, + viewBarPadding: viewBarPadding, + viewHeaderHeight: viewHeaderHeight, + viewHeaderTextStyle: viewHeaderTextStyle, + viewHeaderHintStyle: viewHeaderHintStyle, + dividerColor: dividerColor, + viewConstraints: viewConstraints, + viewPadding: viewPadding, + shrinkWrap: shrinkWrap, + showFullScreenView: showFullScreenView, + animation: curvedAnimation!, + topPadding: topPadding, + viewMaxWidth: _rectTween.end!.width, + viewRect: viewRect, + viewBuilder: viewBuilder, + searchController: searchController, + suggestionsBuilder: suggestionsBuilder, + textCapitalization: textCapitalization, + textInputAction: textInputAction, + keyboardType: keyboardType, + smartDashesType: smartDashesType, + smartQuotesType: smartQuotesType, + ), + ), + ); + }, + ), + ); + } + + @override + Duration get transitionDuration => _kOpenViewDuration; +} + +class _ViewContent extends StatefulWidget { + const _ViewContent({ + this.viewOnChanged, + this.viewOnSubmitted, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewBarPadding, + this.viewHeaderHeight, + this.viewHeaderTextStyle, + this.viewHeaderHintStyle, + this.dividerColor, + this.viewConstraints, + this.viewPadding, + this.shrinkWrap, + this.textCapitalization, + required this.showFullScreenView, + required this.topPadding, + required this.animation, + required this.viewMaxWidth, + required this.viewRect, + required this.searchController, + required this.suggestionsBuilder, + this.textInputAction, + this.keyboardType, + this.smartDashesType, + this.smartQuotesType, + }); + + final ValueChanged<String>? viewOnChanged; + final ValueChanged<String>? viewOnSubmitted; + final ViewBuilder? viewBuilder; + final Widget? viewLeading; + final Iterable<Widget>? viewTrailing; + final String? viewHintText; + final Color? viewBackgroundColor; + final double? viewElevation; + final Color? viewSurfaceTintColor; + final BorderSide? viewSide; + final OutlinedBorder? viewShape; + final EdgeInsetsGeometry? viewBarPadding; + final double? viewHeaderHeight; + final TextStyle? viewHeaderTextStyle; + final TextStyle? viewHeaderHintStyle; + final Color? dividerColor; + final BoxConstraints? viewConstraints; + final EdgeInsetsGeometry? viewPadding; + final bool? shrinkWrap; + final TextCapitalization? textCapitalization; + final bool showFullScreenView; + final double topPadding; + final Animation<double> animation; + final double viewMaxWidth; + final Rect viewRect; + final SearchController searchController; + final SuggestionsBuilder suggestionsBuilder; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final SmartDashesType? smartDashesType; + final SmartQuotesType? smartQuotesType; + + @override + State<_ViewContent> createState() => _ViewContentState(); +} + +class _ViewContentState extends State<_ViewContent> { + Size? _screenSize; + late Rect _viewRect; + late CurvedAnimation viewIconsFadeCurve; + late CurvedAnimation viewDividerFadeCurve; + late CurvedAnimation viewListFadeOnIntervalCurve; + late final SearchController _controller; + Iterable<Widget> result = <Widget>[]; + String? searchValue; + Timer? _timer; + + @override + void initState() { + super.initState(); + _viewRect = widget.viewRect; + _controller = widget.searchController; + _controller.addListener(updateSuggestions); + _setupAnimations(); + } + + @override + void didUpdateWidget(covariant _ViewContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.viewRect != oldWidget.viewRect) { + setState(() { + _viewRect = widget.viewRect; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final Size updatedScreenSize = MediaQuery.of(context).size; + + if (_screenSize != updatedScreenSize) { + _screenSize = updatedScreenSize; + if (widget.showFullScreenView) { + _viewRect = Offset.zero & _screenSize!; + } + } + + if (searchValue != _controller.text) { + _timer?.cancel(); + _timer = Timer(Duration.zero, () async { + searchValue = _controller.text; + final Iterable<Widget> suggestions = await widget.suggestionsBuilder(context, _controller); + _timer?.cancel(); + _timer = null; + if (mounted) { + setState(() { + result = suggestions; + }); + } + }); + } + } + + @override + void dispose() { + _controller.removeListener(updateSuggestions); + _disposeAnimations(); + _timer?.cancel(); + _timer = null; + super.dispose(); + } + + void _setupAnimations() { + viewIconsFadeCurve = CurvedAnimation( + parent: widget.animation, + curve: _kViewIconsFadeOnInterval, + reverseCurve: _kViewIconsFadeOnInterval.flipped, + ); + viewDividerFadeCurve = CurvedAnimation( + parent: widget.animation, + curve: _kViewDividerFadeOnInterval, + reverseCurve: _kViewFadeOnInterval.flipped, + ); + viewListFadeOnIntervalCurve = CurvedAnimation( + parent: widget.animation, + curve: _kViewListFadeOnInterval, + reverseCurve: _kViewListFadeOnInterval.flipped, + ); + } + + void _disposeAnimations() { + viewIconsFadeCurve.dispose(); + viewDividerFadeCurve.dispose(); + viewListFadeOnIntervalCurve.dispose(); + } + + Future<void> updateSuggestions() async { + if (searchValue != _controller.text) { + searchValue = _controller.text; + final Iterable<Widget> suggestions = await widget.suggestionsBuilder(context, _controller); + if (mounted) { + setState(() { + result = suggestions; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final Widget defaultLeading = BackButton( + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + + final defaultTrailing = <Widget>[ + if (_controller.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).clearButtonTooltip, + onPressed: () { + _controller.clear(); + }, + ), + ]; + + final SearchViewThemeData viewDefaults = _SearchViewDefaultsM3( + context, + isFullScreen: widget.showFullScreenView, + ); + final SearchViewThemeData viewTheme = SearchViewTheme.of(context); + final DividerThemeData dividerTheme = DividerTheme.of(context); + + final Color effectiveBackgroundColor = + widget.viewBackgroundColor ?? viewTheme.backgroundColor ?? viewDefaults.backgroundColor!; + final Color effectiveSurfaceTint = + widget.viewSurfaceTintColor ?? viewTheme.surfaceTintColor ?? viewDefaults.surfaceTintColor!; + final double effectiveElevation = + widget.viewElevation ?? viewTheme.elevation ?? viewDefaults.elevation!; + final BorderSide? effectiveSide = widget.viewSide ?? viewTheme.side ?? viewDefaults.side; + OutlinedBorder effectiveShape = widget.viewShape ?? viewTheme.shape ?? viewDefaults.shape!; + if (effectiveSide != null) { + effectiveShape = effectiveShape.copyWith(side: effectiveSide); + } + final Color effectiveDividerColor = + widget.dividerColor ?? + viewTheme.dividerColor ?? + dividerTheme.color ?? + viewDefaults.dividerColor!; + final double? effectiveHeaderHeight = widget.viewHeaderHeight ?? viewTheme.headerHeight; + final BoxConstraints? headerConstraints = effectiveHeaderHeight == null + ? null + : BoxConstraints.tightFor(height: effectiveHeaderHeight); + final TextStyle? effectiveTextStyle = + widget.viewHeaderTextStyle ?? viewTheme.headerTextStyle ?? viewDefaults.headerTextStyle; + final TextStyle? effectiveHintStyle = + widget.viewHeaderHintStyle ?? + viewTheme.headerHintStyle ?? + widget.viewHeaderTextStyle ?? + viewTheme.headerTextStyle ?? + viewDefaults.headerHintStyle; + final EdgeInsetsGeometry? effectivePadding = + widget.viewPadding ?? viewTheme.padding ?? viewDefaults.padding; + final EdgeInsetsGeometry? effectiveBarPadding = + widget.viewBarPadding ?? viewTheme.barPadding ?? viewDefaults.barPadding; + + final BoxConstraints effectiveConstraints = + widget.viewConstraints ?? viewTheme.constraints ?? viewDefaults.constraints!; + final double minWidth = math.min(effectiveConstraints.minWidth, _viewRect.width); + final double minHeight = math.min(effectiveConstraints.minHeight, _viewRect.height); + + final bool effectiveShrinkWrap = + widget.shrinkWrap ?? viewTheme.shrinkWrap ?? viewDefaults.shrinkWrap!; + + final Widget viewDivider = DividerTheme( + data: dividerTheme.copyWith(color: effectiveDividerColor), + child: const Divider(height: 1), + ); + + return Align( + alignment: Alignment.topLeft, + child: Transform.translate( + offset: _viewRect.topLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth, + maxWidth: _viewRect.width, + minHeight: minHeight, + maxHeight: _viewRect.height, + ), + child: Padding( + padding: widget.showFullScreenView + ? EdgeInsets.zero + : (effectivePadding ?? EdgeInsets.zero), + child: Material( + clipBehavior: Clip.antiAlias, + shape: effectiveShape, + color: effectiveBackgroundColor, + surfaceTintColor: effectiveSurfaceTint, + elevation: effectiveElevation, + child: OverflowBox( + alignment: Alignment.topLeft, + maxWidth: math.min(widget.viewMaxWidth, _screenSize!.width), + minWidth: 0, + fit: OverflowBoxFit.deferToChild, + child: FadeTransition( + opacity: viewIconsFadeCurve, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Padding( + padding: EdgeInsets.only(top: widget.topPadding), + child: SafeArea( + top: false, + bottom: false, + child: SearchBar( + autoFocus: true, + constraints: + headerConstraints ?? + (widget.showFullScreenView + ? BoxConstraints( + minHeight: _SearchViewDefaultsM3.fullScreenBarHeight, + ) + : null), + padding: WidgetStatePropertyAll<EdgeInsetsGeometry?>( + effectiveBarPadding, + ), + leading: widget.viewLeading ?? defaultLeading, + trailing: widget.viewTrailing ?? defaultTrailing, + hintText: widget.viewHintText, + backgroundColor: const MaterialStatePropertyAll<Color>( + Colors.transparent, + ), + overlayColor: const MaterialStatePropertyAll<Color>(Colors.transparent), + elevation: const MaterialStatePropertyAll<double>(0.0), + textStyle: MaterialStatePropertyAll<TextStyle?>(effectiveTextStyle), + hintStyle: MaterialStatePropertyAll<TextStyle?>(effectiveHintStyle), + controller: _controller, + onChanged: (String value) { + widget.viewOnChanged?.call(value); + updateSuggestions(); + }, + onSubmitted: widget.viewOnSubmitted, + textCapitalization: widget.textCapitalization, + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + ), + ), + ), + if (!effectiveShrinkWrap || + minHeight > 0 || + widget.showFullScreenView || + result.isNotEmpty) ...<Widget>[ + FadeTransition(opacity: viewDividerFadeCurve, child: viewDivider), + Flexible( + fit: (effectiveShrinkWrap && !widget.showFullScreenView) + ? FlexFit.loose + : FlexFit.tight, + child: FadeTransition( + opacity: viewListFadeOnIntervalCurve, + child: widget.viewBuilder == null + ? MediaQuery.removePadding( + context: context, + removeTop: true, + child: ListView( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + shrinkWrap: effectiveShrinkWrap, + children: result.toList(), + ), + ) + : widget.viewBuilder!(result), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SearchAnchorWithSearchBar extends SearchAnchor { + _SearchAnchorWithSearchBar({ + Widget? barLeading, + Iterable<Widget>? barTrailing, + String? barHintText, + GestureTapCallback? onTap, + WidgetStateProperty<double?>? barElevation, + WidgetStateProperty<Color?>? barBackgroundColor, + WidgetStateProperty<Color?>? barOverlayColor, + WidgetStateProperty<BorderSide?>? barSide, + WidgetStateProperty<OutlinedBorder?>? barShape, + WidgetStateProperty<EdgeInsetsGeometry?>? barPadding, + super.viewBarPadding, + WidgetStateProperty<TextStyle?>? barTextStyle, + WidgetStateProperty<TextStyle?>? barHintStyle, + super.viewBuilder, + super.viewLeading, + super.viewTrailing, + String? viewHintText, + super.viewBackgroundColor, + super.viewElevation, + super.viewSide, + super.viewShape, + double? viewHeaderHeight, + TextStyle? viewHeaderTextStyle, + TextStyle? viewHeaderHintStyle, + super.dividerColor, + BoxConstraints? constraints, + super.viewConstraints, + super.viewPadding, + super.shrinkWrap, + super.isFullScreen, + super.searchController, + super.textCapitalization, + ValueChanged<String>? onChanged, + ValueChanged<String>? onSubmitted, + VoidCallback? onClose, + VoidCallback? onOpen, + required super.suggestionsBuilder, + super.textInputAction, + super.keyboardType, + EdgeInsets scrollPadding = const EdgeInsets.all(20.0), + EditableTextContextMenuBuilder contextMenuBuilder = SearchBar._defaultContextMenuBuilder, + super.enabled, + super.smartDashesType, + super.smartQuotesType, + }) : super( + viewHintText: viewHintText ?? barHintText, + headerHeight: viewHeaderHeight, + headerTextStyle: viewHeaderTextStyle, + headerHintStyle: viewHeaderHintStyle, + viewOnSubmitted: onSubmitted, + viewOnChanged: onChanged, + viewOnClose: onClose, + viewOnOpen: onOpen, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + constraints: constraints, + controller: controller, + onTap: () { + controller.openView(); + onTap?.call(); + }, + onChanged: (String value) { + controller.openView(); + }, + onSubmitted: onSubmitted, + hintText: barHintText, + hintStyle: barHintStyle, + textStyle: barTextStyle, + elevation: barElevation, + backgroundColor: barBackgroundColor, + overlayColor: barOverlayColor, + side: barSide, + shape: barShape, + padding: + barPadding ?? + const MaterialStatePropertyAll<EdgeInsets>(EdgeInsets.symmetric(horizontal: 16.0)), + leading: barLeading ?? const Icon(Icons.search), + trailing: barTrailing, + textCapitalization: textCapitalization, + textInputAction: textInputAction, + keyboardType: keyboardType, + scrollPadding: scrollPadding, + contextMenuBuilder: contextMenuBuilder, + smartDashesType: smartDashesType, + smartQuotesType: smartQuotesType, + ); + }, + ); +} + +/// A controller to manage a search view created by [SearchAnchor]. +/// +/// A [SearchController] is used to control a menu after it has been created, +/// with methods such as [openView] and [closeView]. It can also control the text in the +/// input field. +/// +/// To observe open/close state changes of search view, provide +/// [SearchAnchor.viewOnOpen] and/or [SearchAnchor.viewOnClose] callbacks. +/// +/// See also: +/// +/// * [SearchAnchor], a widget that defines a region that opens a search view. +/// * [TextEditingController], A controller for an editable text field. +class SearchController extends TextEditingController { + // The anchor that this controller controls. + // + // This is set automatically when a [SearchController] is given to the anchor + // it controls. + _SearchAnchorState? _anchor; + + /// Whether this controller has associated search anchor. + bool get isAttached => _anchor != null; + + /// Whether or not the associated search view is currently open. + bool get isOpen { + assert(isAttached); + return _anchor!._viewIsOpen; + } + + /// Opens the search view that this controller is associated with. + void openView() { + assert(isAttached); + _anchor!._openView(); + } + + /// Close the search view that this search controller is associated with. + /// + /// If `selectedText` is given, then the text value of the controller is set to + /// `selectedText`. + void closeView(String? selectedText) { + assert(isAttached); + _anchor!._closeView(selectedText); + } + + // ignore: use_setters_to_change_properties + void _attach(_SearchAnchorState anchor) { + _anchor = anchor; + } + + void _detach(_SearchAnchorState anchor) { + if (_anchor == anchor) { + _anchor = null; + } + } +} + +/// A Material Design search bar. +/// +/// A [SearchBar] looks like a [TextField]. Tapping a SearchBar typically shows a +/// "search view" route: a route with the search bar at the top and a list of +/// suggested completions for the search bar's text below. [SearchBar]s are +/// usually created by a [SearchAnchor.builder]. The builder provides a +/// [SearchController] that's used by the search bar's [SearchBar.onTap] or +/// [SearchBar.onChanged] callbacks to show the search view and to hide it +/// when the user selects a suggestion. +/// +/// For [TextDirection.ltr], the [leading] widget is on the left side of the bar. +/// It should contain either a navigational action (such as a menu or up-arrow) +/// or a non-functional search icon. +/// +/// The [trailing] is an optional list that appears at the other end of +/// the search bar. Typically only one or two action icons are included. +/// These actions can represent additional modes of searching (like voice search), +/// a separate high-level action (such as current location) or an overflow menu. +/// +/// {@tool dartpad} +/// This example demonstrates how to use a [SearchBar] as the return value of the +/// [SearchAnchor.builder] property. The [SearchBar] also includes a leading search +/// icon and a trailing action to toggle the brightness. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_bar.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SearchAnchor], a widget that typically uses an [IconButton] or a [SearchBar] +/// to manage a "search view" route. +/// * [SearchBarTheme], a widget that overrides the default configuration of a search bar. +/// * [SearchViewTheme], a widget that overrides the default configuration of a search view. +class SearchBar extends StatefulWidget { + /// Creates a Material Design search bar. + const SearchBar({ + super.key, + this.controller, + this.focusNode, + this.hintText, + this.leading, + this.trailing, + this.onTap, + this.onTapOutside, + this.onChanged, + this.onSubmitted, + this.constraints, + this.elevation, + this.backgroundColor, + this.shadowColor, + this.surfaceTintColor, + this.overlayColor, + this.side, + this.shape, + this.padding, + this.textStyle, + this.hintStyle, + this.textCapitalization, + this.enabled = true, + this.autoFocus = false, + this.textInputAction, + this.keyboardType, + this.scrollPadding = const EdgeInsets.all(20.0), + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.readOnly = false, + this.smartDashesType, + this.smartQuotesType, + }); + + /// Controls the text being edited in the search bar's text field. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Text that suggests what sort of input the field accepts. + /// + /// Displayed at the same location on the screen where text may be entered + /// when the input is empty. + /// + /// Defaults to null. + final String? hintText; + + /// A widget to display before the text input field. + /// + /// Typically the [leading] widget is an [Icon] or an [IconButton]. + final Widget? leading; + + /// A list of Widgets to display in a row after the text field. + /// + /// Typically these actions can represent additional modes of searching + /// (like voice search), an avatar, a separate high-level action (such as + /// current location) or an overflow menu. There should not be more than + /// two trailing actions. + final Iterable<Widget>? trailing; + + /// Called when the user taps this search bar. + final GestureTapCallback? onTap; + + /// Called when the user taps outside the search bar. + final TapRegionCallback? onTapOutside; + + /// Invoked upon user input. + final ValueChanged<String>? onChanged; + + /// Called when the user indicates that they are done editing the text in the + /// field. + final ValueChanged<String>? onSubmitted; + + /// Optional size constraints for the search bar. + /// + /// If null, the value of [SearchBarThemeData.constraints] will be used. If + /// this is also null, then the constraints defaults to: + /// ```dart + /// const BoxConstraints(minWidth: 360.0, maxWidth: 800.0, minHeight: 56.0) + /// ``` + final BoxConstraints? constraints; + + /// The elevation of the search bar's [Material]. + /// + /// If null, the value of [SearchBarThemeData.elevation] will be used. If this + /// is also null, then default value is 6.0. + final WidgetStateProperty<double?>? elevation; + + /// The search bar's background fill color. + /// + /// If null, the value of [SearchBarThemeData.backgroundColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surfaceContainerHigh]. + final WidgetStateProperty<Color?>? backgroundColor; + + /// The shadow color of the search bar's [Material]. + /// + /// If null, the value of [SearchBarThemeData.shadowColor] will be used. + /// If this is also null, then the default value is [ColorScheme.shadow]. + final WidgetStateProperty<Color?>? shadowColor; + + /// The surface tint color of the search bar's [Material]. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, the value of [SearchBarThemeData.surfaceTintColor] will be used. + /// If this is also null, then the default value is [Colors.transparent]. + final WidgetStateProperty<Color?>? surfaceTintColor; + + /// The highlight color that's typically used to indicate that + /// the search bar is focused, hovered, or pressed. + final WidgetStateProperty<Color?>? overlayColor; + + /// The color and weight of the search bar's outline. + /// + /// This value is combined with [shape] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchBarThemeData.side] will be used. If this is + /// also null, the search bar doesn't have a side by default. + final WidgetStateProperty<BorderSide?>? side; + + /// The shape of the search bar's underlying [Material]. + /// + /// This shape is combined with [side] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchBarThemeData.shape] will be used. + /// If this is also null, defaults to [StadiumBorder]. + final WidgetStateProperty<OutlinedBorder?>? shape; + + /// The padding between the search bar's boundary and its contents. + /// + /// If null, the value of [SearchBarThemeData.padding] will be used. + /// If this is also null, then the default value is 16.0 horizontally. + final WidgetStateProperty<EdgeInsetsGeometry?>? padding; + + /// The style to use for the text being edited. + /// + /// If null, defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurface]. + final WidgetStateProperty<TextStyle?>? textStyle; + + /// The style to use for the [hintText]. + /// + /// If null, the value of [SearchBarThemeData.hintStyle] will be used. If this + /// is also null, the value of [textStyle] will be used. If this is also null, + /// defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurfaceVariant]. + final WidgetStateProperty<TextStyle?>? hintStyle; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization? textCapitalization; + + /// Whether or not this widget is currently interactive. + /// + /// When false, the widget will ignore taps and appear dimmed. + /// + /// Defaults to true. + final bool enabled; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autoFocus; + + /// {@macro flutter.widgets.TextField.textInputAction} + final TextInputAction? textInputAction; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to the default value specified in [TextField]. + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + /// * [BrowserContextMenu], which allows the browser's context menu on web to + /// be disabled and Flutter-rendered context menus to appear. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// Configures how smart dashes are handled in the text field + /// used by this [SearchBar]. + /// + /// For example, when enabled, double hyphens (`--`) may be + /// automatically replaced with an em dash (`—`) on iOS. + /// + /// Defaults to [SmartDashesType.enabled]. + /// + /// See also: + /// * [TextField.smartDashesType], which provides the same + /// configuration option on a standalone [TextField]. + final SmartDashesType? smartDashesType; + + /// Configures how smart quotes are handled in the text field + /// used by this [SearchBar]. + /// + /// For example, when enabled, straight quotes (`"`) may be + /// automatically replaced with curly quotes (`“ ”`) on iOS. + /// + /// Defaults to [SmartQuotesType.enabled]. + /// + /// See also: + /// * [TextField.smartQuotesType], which provides the same + /// configuration option on a standalone [TextField]. + final SmartQuotesType? smartQuotesType; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + } + + @override + State<SearchBar> createState() => _SearchBarState(); +} + +class _SearchBarState extends State<SearchBar> { + late final MaterialStatesController _internalStatesController; + FocusNode? _internalFocusNode; + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _internalStatesController = MaterialStatesController(); + _internalStatesController.addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _internalStatesController.dispose(); + _internalFocusNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final SearchBarThemeData searchBarTheme = SearchBarTheme.of(context); + final SearchBarThemeData defaults = _SearchBarDefaultsM3(context); + + T? resolve<T>( + WidgetStateProperty<T>? widgetValue, + WidgetStateProperty<T>? themeValue, + WidgetStateProperty<T>? defaultValue, + ) { + final Set<WidgetState> states = _internalStatesController.value; + return widgetValue?.resolve(states) ?? + themeValue?.resolve(states) ?? + defaultValue?.resolve(states); + } + + final TextStyle? effectiveTextStyle = resolve<TextStyle?>( + widget.textStyle, + searchBarTheme.textStyle, + defaults.textStyle, + ); + final double? effectiveElevation = resolve<double?>( + widget.elevation, + searchBarTheme.elevation, + defaults.elevation, + ); + final Color? effectiveShadowColor = resolve<Color?>( + widget.shadowColor, + searchBarTheme.shadowColor, + defaults.shadowColor, + ); + final Color? effectiveBackgroundColor = resolve<Color?>( + widget.backgroundColor, + searchBarTheme.backgroundColor, + defaults.backgroundColor, + ); + final Color? effectiveSurfaceTintColor = resolve<Color?>( + widget.surfaceTintColor, + searchBarTheme.surfaceTintColor, + defaults.surfaceTintColor, + ); + final OutlinedBorder? effectiveShape = resolve<OutlinedBorder?>( + widget.shape, + searchBarTheme.shape, + defaults.shape, + ); + final BorderSide? effectiveSide = resolve<BorderSide?>( + widget.side, + searchBarTheme.side, + defaults.side, + ); + final EdgeInsetsGeometry? effectivePadding = resolve<EdgeInsetsGeometry?>( + widget.padding, + searchBarTheme.padding, + defaults.padding, + ); + final WidgetStateProperty<Color?>? effectiveOverlayColor = + widget.overlayColor ?? searchBarTheme.overlayColor ?? defaults.overlayColor; + final TextCapitalization effectiveTextCapitalization = + widget.textCapitalization ?? + searchBarTheme.textCapitalization ?? + defaults.textCapitalization!; + + final Set<WidgetState> states = _internalStatesController.value; + final TextStyle? effectiveHintStyle = + widget.hintStyle?.resolve(states) ?? + searchBarTheme.hintStyle?.resolve(states) ?? + widget.textStyle?.resolve(states) ?? + searchBarTheme.textStyle?.resolve(states) ?? + defaults.hintStyle?.resolve(states); + + final Color defaultColor = switch (colorScheme.brightness) { + Brightness.light => kDefaultIconDarkColor, + Brightness.dark => kDefaultIconLightColor, + }; + final IconThemeData? customTheme = switch (IconTheme.of(context)) { + final IconThemeData iconTheme when iconTheme.color != defaultColor => iconTheme, + _ => null, + }; + + Widget? leading; + if (widget.leading != null) { + leading = IconTheme.merge( + data: customTheme ?? IconThemeData(color: colorScheme.onSurface), + child: widget.leading!, + ); + } + + final List<Widget>? trailing = widget.trailing + ?.map( + (Widget trailing) => IconTheme.merge( + data: customTheme ?? IconThemeData(color: colorScheme.onSurfaceVariant), + child: trailing, + ), + ) + .toList(); + + return ConstrainedBox( + constraints: widget.constraints ?? searchBarTheme.constraints ?? defaults.constraints!, + child: Opacity( + opacity: widget.enabled ? 1 : _kDisableSearchBarOpacity, + child: Material( + elevation: effectiveElevation!, + shadowColor: effectiveShadowColor, + color: effectiveBackgroundColor, + surfaceTintColor: effectiveSurfaceTintColor, + shape: effectiveShape?.copyWith(side: effectiveSide), + child: IgnorePointer( + ignoring: !widget.enabled, + child: InkWell( + onTap: () { + widget.onTap?.call(); + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + }, + overlayColor: effectiveOverlayColor, + customBorder: effectiveShape?.copyWith(side: effectiveSide), + statesController: _internalStatesController, + child: Padding( + padding: effectivePadding!, + child: Row( + textDirection: textDirection, + children: <Widget>[ + ?leading, + Expanded( + child: Padding( + padding: effectivePadding, + child: Semantics( + inputType: SemanticsInputType.search, + child: TextField( + readOnly: widget.readOnly, + autofocus: widget.autoFocus, + onTap: widget.onTap, + onTapAlwaysCalled: true, + onTapOutside: widget.onTapOutside, + focusNode: _focusNode, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + controller: widget.controller, + style: effectiveTextStyle, + enabled: widget.enabled, + decoration: InputDecoration(hintText: widget.hintText).applyDefaults( + InputDecorationThemeData( + hintStyle: effectiveHintStyle, + // The configuration below is to make sure that the text field + // in `SearchBar` will not be overridden by the overall `InputDecorationThemeData` + enabledBorder: InputBorder.none, + border: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + // Setting `isDense` to true to allow the text field height to be + // smaller than 48.0 + isDense: true, + ), + ), + textCapitalization: effectiveTextCapitalization, + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + scrollPadding: widget.scrollPadding, + contextMenuBuilder: widget.contextMenuBuilder, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + ), + ), + ), + ), + ...?trailing, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - SearchBar + +// 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. + +// dart format off +class _SearchBarDefaultsM3 extends SearchBarThemeData { + _SearchBarDefaultsM3(this.context); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + WidgetStateProperty<Color?>? get backgroundColor => + MaterialStatePropertyAll<Color>(_colors.surfaceContainerHigh); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(6.0); + + @override + WidgetStateProperty<Color>? get shadowColor => + MaterialStatePropertyAll<Color>(_colors.shadow); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return Colors.transparent; + } + return Colors.transparent; + }); + + // No default side + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + const MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.symmetric(horizontal: 8.0)); + + @override + WidgetStateProperty<TextStyle?> get textStyle => + MaterialStatePropertyAll<TextStyle?>(_textTheme.bodyLarge?.copyWith(color: _colors.onSurface)); + + @override + WidgetStateProperty<TextStyle?> get hintStyle => + MaterialStatePropertyAll<TextStyle?>(_textTheme.bodyLarge?.copyWith(color: _colors.onSurfaceVariant)); + + @override + BoxConstraints get constraints => + const BoxConstraints(minWidth: 360.0, maxWidth: 800.0, minHeight: 56.0); + + @override + TextCapitalization get textCapitalization => TextCapitalization.none; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - SearchBar + +// BEGIN GENERATED TOKEN PROPERTIES - SearchView + +// 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. + +// dart format off +class _SearchViewDefaultsM3 extends SearchViewThemeData { + _SearchViewDefaultsM3(this.context, {required this.isFullScreen}); + + final BuildContext context; + final bool isFullScreen; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + static double fullScreenBarHeight = 72.0; + + @override + Color? get backgroundColor => _colors.surfaceContainerHigh; + + @override + double? get elevation => 6.0; + + @override + Color? get surfaceTintColor => Colors.transparent; + + // No default side + + @override + OutlinedBorder? get shape => isFullScreen + ? const RoundedRectangleBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))); + + @override + TextStyle? get headerTextStyle => _textTheme.bodyLarge?.copyWith(color: _colors.onSurface); + + @override + TextStyle? get headerHintStyle => _textTheme.bodyLarge?.copyWith(color: _colors.onSurfaceVariant); + + @override + BoxConstraints get constraints => const BoxConstraints(minWidth: 360.0, minHeight: 240.0); + + @override + EdgeInsetsGeometry? get barPadding => const EdgeInsets.symmetric(horizontal: 8.0); + + @override + bool get shrinkWrap => false; + + @override + Color? get dividerColor => _colors.outline; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - SearchView diff --git a/packages/material_ui/lib/src/search_bar_theme.dart b/packages/material_ui/lib/src/search_bar_theme.dart new file mode 100644 index 000000000000..957ffd1159c2 --- /dev/null +++ b/packages/material_ui/lib/src/search_bar_theme.dart @@ -0,0 +1,326 @@ +// 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. + +/// @docImport 'search_anchor.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [SearchBar] widgets. +/// +/// Descendant widgets obtain the current [SearchBarThemeData] object using +/// [SearchBarTheme.of]. Instances of [SearchBarThemeData] can be customized +/// with [SearchBarThemeData.copyWith]. +/// +/// Typically a [SearchBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.searchBarTheme]. +/// +/// All [SearchBarThemeData] properties are `null` by default. When null, the +/// [SearchBar] will use the values from [ThemeData] if they exist, otherwise it +/// will provide its own defaults based on the overall [Theme]'s colorScheme. +/// See the individual [SearchBar] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class SearchBarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.searchBarTheme]. + const SearchBarThemeData({ + this.elevation, + this.backgroundColor, + this.shadowColor, + this.surfaceTintColor, + this.overlayColor, + this.side, + this.shape, + this.padding, + this.textStyle, + this.hintStyle, + this.constraints, + this.textCapitalization, + }); + + /// Overrides the default value of the [SearchBar.elevation]. + final WidgetStateProperty<double?>? elevation; + + /// Overrides the default value of the [SearchBar.backgroundColor]. + final WidgetStateProperty<Color?>? backgroundColor; + + /// Overrides the default value of the [SearchBar.shadowColor]. + final WidgetStateProperty<Color?>? shadowColor; + + /// Overrides the default value of the [SearchBar.surfaceTintColor]. + final WidgetStateProperty<Color?>? surfaceTintColor; + + /// Overrides the default value of the [SearchBar.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// Overrides the default value of the [SearchBar.side]. + final WidgetStateProperty<BorderSide?>? side; + + /// Overrides the default value of the [SearchBar.shape]. + final WidgetStateProperty<OutlinedBorder?>? shape; + + /// Overrides the default value for [SearchBar.padding]. + final WidgetStateProperty<EdgeInsetsGeometry?>? padding; + + /// Overrides the default value for [SearchBar.textStyle]. + final WidgetStateProperty<TextStyle?>? textStyle; + + /// Overrides the default value for [SearchBar.hintStyle]. + final WidgetStateProperty<TextStyle?>? hintStyle; + + /// Overrides the value of size constraints for [SearchBar]. + final BoxConstraints? constraints; + + /// Overrides the value of [SearchBar.textCapitalization]. + final TextCapitalization? textCapitalization; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SearchBarThemeData copyWith({ + WidgetStateProperty<double?>? elevation, + WidgetStateProperty<Color?>? backgroundColor, + WidgetStateProperty<Color?>? shadowColor, + WidgetStateProperty<Color?>? surfaceTintColor, + WidgetStateProperty<Color?>? overlayColor, + WidgetStateProperty<BorderSide?>? side, + WidgetStateProperty<OutlinedBorder?>? shape, + WidgetStateProperty<EdgeInsetsGeometry?>? padding, + WidgetStateProperty<TextStyle?>? textStyle, + WidgetStateProperty<TextStyle?>? hintStyle, + BoxConstraints? constraints, + TextCapitalization? textCapitalization, + }) { + return SearchBarThemeData( + elevation: elevation ?? this.elevation, + backgroundColor: backgroundColor ?? this.backgroundColor, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + overlayColor: overlayColor ?? this.overlayColor, + side: side ?? this.side, + shape: shape ?? this.shape, + padding: padding ?? this.padding, + textStyle: textStyle ?? this.textStyle, + hintStyle: hintStyle ?? this.hintStyle, + constraints: constraints ?? this.constraints, + textCapitalization: textCapitalization ?? this.textCapitalization, + ); + } + + /// Linearly interpolate between two [SearchBarThemeData]s. + /// + /// {@macro dart.ui.shadow.lerp} + static SearchBarThemeData? lerp(SearchBarThemeData? a, SearchBarThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return SearchBarThemeData( + elevation: WidgetStateProperty.lerp<double?>(a?.elevation, b?.elevation, t, lerpDouble), + backgroundColor: WidgetStateProperty.lerp<Color?>( + a?.backgroundColor, + b?.backgroundColor, + t, + Color.lerp, + ), + shadowColor: WidgetStateProperty.lerp<Color?>(a?.shadowColor, b?.shadowColor, t, Color.lerp), + surfaceTintColor: WidgetStateProperty.lerp<Color?>( + a?.surfaceTintColor, + b?.surfaceTintColor, + t, + Color.lerp, + ), + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + side: WidgetStateBorderSide.lerp(a?.side, b?.side, t), + shape: WidgetStateProperty.lerp<OutlinedBorder?>(a?.shape, b?.shape, t, OutlinedBorder.lerp), + padding: WidgetStateProperty.lerp<EdgeInsetsGeometry?>( + a?.padding, + b?.padding, + t, + EdgeInsetsGeometry.lerp, + ), + textStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.textStyle, + b?.textStyle, + t, + TextStyle.lerp, + ), + hintStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.hintStyle, + b?.hintStyle, + t, + TextStyle.lerp, + ), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + textCapitalization: t < 0.5 ? a?.textCapitalization : b?.textCapitalization, + ); + } + + @override + int get hashCode => Object.hash( + elevation, + backgroundColor, + shadowColor, + surfaceTintColor, + overlayColor, + side, + shape, + padding, + textStyle, + hintStyle, + constraints, + textCapitalization, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SearchBarThemeData && + other.elevation == elevation && + other.backgroundColor == backgroundColor && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.overlayColor == overlayColor && + other.side == side && + other.shape == shape && + other.padding == padding && + other.textStyle == textStyle && + other.hintStyle == hintStyle && + other.constraints == constraints && + other.textCapitalization == textCapitalization; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>('elevation', elevation, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'backgroundColor', + backgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'shadowColor', + shadowColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'surfaceTintColor', + surfaceTintColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<BorderSide?>>('side', side, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<OutlinedBorder?>>('shape', shape, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<EdgeInsetsGeometry?>>( + 'padding', + padding, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'textStyle', + textStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'hintStyle', + hintStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextCapitalization>( + 'textCapitalization', + textCapitalization, + defaultValue: null, + ), + ); + } +} + +/// Applies a search bar theme to descendant [SearchBar] widgets. +/// +/// Descendant widgets obtain the current theme's [SearchBarTheme] object using +/// [SearchBarTheme.of]. When a widget uses [SearchBarTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// A search bar theme can be specified as part of the overall Material theme using +/// [ThemeData.searchBarTheme]. +/// +/// See also: +/// +/// * [SearchBarThemeData], which describes the actual configuration of a search bar +/// theme. +class SearchBarTheme extends InheritedWidget { + /// Constructs a search bar theme that configures all descendant [SearchBar] widgets. + const SearchBarTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [SearchBar] widgets. + final SearchBarThemeData data; + + /// Returns the configuration [data] from the closest [SearchBarTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.searchBarTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SearchBarThemeData theme = SearchBarTheme.of(context); + /// ``` + static SearchBarThemeData of(BuildContext context) { + final SearchBarTheme? searchBarTheme = context + .dependOnInheritedWidgetOfExactType<SearchBarTheme>(); + return searchBarTheme?.data ?? Theme.of(context).searchBarTheme; + } + + @override + bool updateShouldNotify(SearchBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/search_view_theme.dart b/packages/material_ui/lib/src/search_view_theme.dart new file mode 100644 index 000000000000..980c4a02c9bc --- /dev/null +++ b/packages/material_ui/lib/src/search_view_theme.dart @@ -0,0 +1,283 @@ +// 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. + +/// @docImport 'search_anchor.dart'; +/// @docImport 'search_bar_theme.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the configuration of the search views created by the [SearchAnchor] +/// widget. +/// +/// Descendant widgets obtain the current [SearchViewThemeData] object using +/// [SearchViewTheme.of]. +/// +/// Typically, a [SearchViewThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.searchViewTheme]. Otherwise, [SearchViewTheme] can be used +/// to configure its own widget subtree. +/// +/// All [SearchViewThemeData] properties are `null` by default. If any of these +/// properties are null, the search view will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme for the application. +/// * [SearchBarThemeData], which describes the theme for the search bar itself in a +/// [SearchBar] widget. +/// * [SearchAnchor], which is used to open a search view route. +@immutable +class SearchViewThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.searchViewTheme]. + const SearchViewThemeData({ + this.backgroundColor, + this.elevation, + this.surfaceTintColor, + this.constraints, + this.padding, + this.barPadding, + this.shrinkWrap, + this.side, + this.shape, + this.headerHeight, + this.headerTextStyle, + this.headerHintStyle, + this.dividerColor, + }); + + /// Overrides the default value of the [SearchAnchor.viewBackgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of the [SearchAnchor.viewElevation]. + final double? elevation; + + /// Overrides the default value of the [SearchAnchor.viewSurfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of the [SearchAnchor.viewSide]. + final BorderSide? side; + + /// Overrides the default value of the [SearchAnchor.viewShape]. + final OutlinedBorder? shape; + + /// Overrides the default value of the [SearchAnchor.headerHeight]. + final double? headerHeight; + + /// Overrides the default value for [SearchAnchor.headerTextStyle]. + final TextStyle? headerTextStyle; + + /// Overrides the default value for [SearchAnchor.headerHintStyle]. + final TextStyle? headerHintStyle; + + /// Overrides the value of size constraints for [SearchAnchor.viewConstraints]. + final BoxConstraints? constraints; + + /// Overrides the value of the padding for [SearchAnchor.viewPadding]. + final EdgeInsetsGeometry? padding; + + /// Overrides the value of the padding for [SearchAnchor.viewBarPadding] + final EdgeInsetsGeometry? barPadding; + + /// Overrides the value of the shrink wrap for [SearchAnchor.shrinkWrap]. + final bool? shrinkWrap; + + /// Overrides the value of the divider color for [SearchAnchor.dividerColor]. + final Color? dividerColor; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SearchViewThemeData copyWith({ + Color? backgroundColor, + double? elevation, + Color? surfaceTintColor, + BorderSide? side, + OutlinedBorder? shape, + double? headerHeight, + TextStyle? headerTextStyle, + TextStyle? headerHintStyle, + BoxConstraints? constraints, + EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? barPadding, + bool? shrinkWrap, + Color? dividerColor, + }) { + return SearchViewThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + side: side ?? this.side, + shape: shape ?? this.shape, + headerHeight: headerHeight ?? this.headerHeight, + headerTextStyle: headerTextStyle ?? this.headerTextStyle, + headerHintStyle: headerHintStyle ?? this.headerHintStyle, + constraints: constraints ?? this.constraints, + padding: padding ?? this.padding, + barPadding: barPadding ?? this.barPadding, + shrinkWrap: shrinkWrap ?? this.shrinkWrap, + dividerColor: dividerColor ?? this.dividerColor, + ); + } + + /// Linearly interpolate between two [SearchViewThemeData]s. + static SearchViewThemeData? lerp(SearchViewThemeData? a, SearchViewThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return SearchViewThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + side: _lerpSides(a?.side, b?.side, t), + shape: OutlinedBorder.lerp(a?.shape, b?.shape, t), + headerHeight: lerpDouble(a?.headerHeight, b?.headerHeight, t), + headerTextStyle: TextStyle.lerp(a?.headerTextStyle, b?.headerTextStyle, t), + headerHintStyle: TextStyle.lerp(a?.headerTextStyle, b?.headerTextStyle, t), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + barPadding: EdgeInsetsGeometry.lerp(a?.barPadding, b?.barPadding, t), + shrinkWrap: t < 0.5 ? a?.shrinkWrap : b?.shrinkWrap, + dividerColor: Color.lerp(a?.dividerColor, b?.dividerColor, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + elevation, + surfaceTintColor, + side, + shape, + headerHeight, + headerTextStyle, + headerHintStyle, + constraints, + padding, + barPadding, + shrinkWrap, + dividerColor, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SearchViewThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.surfaceTintColor == surfaceTintColor && + other.side == side && + other.shape == shape && + other.headerHeight == headerHeight && + other.headerTextStyle == headerTextStyle && + other.headerHintStyle == headerHintStyle && + other.constraints == constraints && + other.padding == padding && + other.barPadding == barPadding && + other.shrinkWrap == shrinkWrap && + other.dividerColor == dividerColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<Color?>('backgroundColor', backgroundColor, defaultValue: null), + ); + properties.add(DiagnosticsProperty<double?>('elevation', elevation, defaultValue: null)); + properties.add( + DiagnosticsProperty<Color?>('surfaceTintColor', surfaceTintColor, defaultValue: null), + ); + properties.add(DiagnosticsProperty<BorderSide?>('side', side, defaultValue: null)); + properties.add(DiagnosticsProperty<OutlinedBorder?>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<double?>('headerHeight', headerHeight, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle?>('headerTextStyle', headerTextStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle?>('headerHintStyle', headerHintStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry?>('padding', padding, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry?>('barPadding', barPadding, defaultValue: null), + ); + properties.add(DiagnosticsProperty<bool?>('shrinkWrap', shrinkWrap, defaultValue: null)); + properties.add(DiagnosticsProperty<Color?>('dividerColor', dividerColor, defaultValue: null)); + } + + // Special case because BorderSide.lerp() doesn't support null arguments + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null && b == null) { + return null; + } + if (a is WidgetStateBorderSide) { + a = a.resolve(const <WidgetState>{}); + } + if (b is WidgetStateBorderSide) { + b = b.resolve(const <WidgetState>{}); + } + a ??= BorderSide(width: 0, color: b!.color.withAlpha(0)); + b ??= BorderSide(width: 0, color: a.color.withAlpha(0)); + + return BorderSide.lerp(a, b, t); + } +} + +/// An inherited widget that defines the configuration in this widget's +/// descendants for search view created by the [SearchAnchor] widget. +/// +/// A search view theme can be specified as part of the overall Material theme using +/// [ThemeData.searchViewTheme]. +/// +/// See also: +/// +/// * [SearchViewThemeData], which describes the actual configuration of a search view +/// theme. +class SearchViewTheme extends InheritedTheme { + /// Creates a const theme that controls the configurations for the search view + /// created by the [SearchAnchor] widget. + const SearchViewTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [SearchAnchor] widgets. + final SearchViewThemeData data; + + /// Returns the configuration [data] from the closest [SearchViewTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.searchViewTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SearchViewThemeData theme = SearchViewTheme.of(context); + /// ``` + static SearchViewThemeData of(BuildContext context) { + final SearchViewTheme? searchViewTheme = context + .dependOnInheritedWidgetOfExactType<SearchViewTheme>(); + return searchViewTheme?.data ?? Theme.of(context).searchViewTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return SearchViewTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(SearchViewTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/segmented_button.dart b/packages/material_ui/lib/src/segmented_button.dart new file mode 100644 index 000000000000..cb056d822591 --- /dev/null +++ b/packages/material_ui/lib/src/segmented_button.dart @@ -0,0 +1,1308 @@ +// 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. + +/// @docImport 'checkbox.dart'; +/// @docImport 'choice_chip.dart'; +/// @docImport 'filter_chip.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'toggle_buttons.dart'; +library; + +import 'dart:math' as math; +import 'dart:math'; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_state.dart'; +import 'segmented_button_theme.dart'; +import 'text_button.dart'; +import 'text_button_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'tooltip.dart'; + +/// Data describing a segment of a [SegmentedButton]. +class ButtonSegment<T> { + /// Construct a [ButtonSegment]. + /// + /// One of [icon] or [label] must be non-null. + const ButtonSegment({ + required this.value, + this.icon, + this.label, + this.tooltip, + this.enabled = true, + }) : assert(icon != null || label != null); + + /// Value used to identify the segment. + /// + /// This value must be unique across all segments in a [SegmentedButton]. + final T value; + + /// Optional icon displayed in the segment. + final Widget? icon; + + /// Optional label displayed in the segment. + final Widget? label; + + /// Optional tooltip for the segment + final String? tooltip; + + /// Determines if the segment is available for selection. + final bool enabled; +} + +/// A Material button that allows the user to select from limited set of options. +/// +/// Segmented buttons are used to help people select options, switch views, or +/// sort elements. They are typically used in cases where there are only 2-5 +/// options. +/// +/// The options are represented by segments described with [ButtonSegment] +/// entries in the [segments] field. Each segment has a [ButtonSegment.value] +/// that is used to indicate which segments are selected. +/// +/// The [selected] field is a set of selected [ButtonSegment.value]s. This +/// should be updated by the app in response to [onSelectionChanged] updates. +/// +/// By default, only a single segment can be selected (for mutually exclusive +/// choices). This can be relaxed with the [multiSelectionEnabled] field. +/// +/// Like [ButtonStyleButton]s, the [SegmentedButton]'s visuals can be +/// configured with a [ButtonStyle] [style] field. However, unlike other +/// buttons, some of the style parameters are applied to the entire segmented +/// button, and others are used for each of the segments. +/// +/// By default, a checkmark icon is used to show selected items. To configure +/// this behavior, you can use the [showSelectedIcon] and [selectedIcon] fields. +/// +/// Individual segments can be enabled or disabled with their +/// [ButtonSegment.enabled] flag. If the [onSelectionChanged] field is null, +/// then the entire segmented button will be disabled, regardless of the +/// individual segment settings. +/// +/// {@tool dartpad} +/// This sample shows how to display a [SegmentedButton] with either a single or +/// multiple selection. +/// +/// ** See code in examples/api/lib/material/segmented_button/segmented_button.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases how to customize [SegmentedButton] using [SegmentedButton.styleFrom]. +/// +/// ** See code in examples/api/lib/material/segmented_button/segmented_button.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * Material Design spec: <https://m3.material.io/components/segmented-buttons/overview> +/// * [ButtonStyle], which can be used in the [style] field to configure +/// the appearance of the button and its segments. +/// * [ToggleButtons], a similar widget that was built for Material 2. +/// [SegmentedButton] should be considered as a replacement for +/// [ToggleButtons]. +/// * [Radio], an alternative way to present the user with a mutually exclusive set of options. +/// * [FilterChip], [ChoiceChip], which can be used when you need to show more than five options. +class SegmentedButton<T> extends StatefulWidget { + /// Creates a const [SegmentedButton]. + /// + /// [segments] must contain at least one segment, but it is recommended + /// to have two to five segments. If you need only single segment, + /// consider using a [Checkbox] or [Radio] widget instead. If you need + /// more than five options, consider using [FilterChip] or [ChoiceChip] + /// widgets. + /// + /// If [onSelectionChanged] is null, then the entire segmented button will + /// be disabled. + /// + /// By default [selected] must only contain one entry. However, if + /// [multiSelectionEnabled] is true, then [selected] can contain multiple + /// entries. If [emptySelectionAllowed] is true, then [selected] can be empty. + const SegmentedButton({ + super.key, + required this.segments, + required this.selected, + this.onSelectionChanged, + this.multiSelectionEnabled = false, + this.emptySelectionAllowed = false, + this.expandedInsets, + this.style, + this.showSelectedIcon = true, + this.selectedIcon, + this.direction = Axis.horizontal, + }) : assert(segments.length > 0), + assert(selected.length > 0 || emptySelectionAllowed), + assert(selected.length < 2 || multiSelectionEnabled); + + /// Descriptions of the segments in the button. + /// + /// This a required parameter and must contain at least one segment, + /// but it is recommended to contain two to five segments. If you need only + /// a single segment, consider using a [Checkbox] or [Radio] widget instead. + /// If you need more than five options, consider using [FilterChip] or + /// [ChoiceChip] widgets. + final List<ButtonSegment<T>> segments; + + /// The orientation of the button's [segments]. + /// + /// If this is [Axis.vertical], the segments will be aligned vertically + /// and the first item in [segments] will be on the top. + /// + /// Defaults to [Axis.horizontal]. + final Axis direction; + + /// The set of [ButtonSegment.value]s that indicate which [segments] are + /// selected. + /// + /// As the [SegmentedButton] does not maintain the state of the selection, + /// you will need to update this in response to [onSelectionChanged] calls. + /// + /// This is a required parameter. + final Set<T> selected; + + /// The function that is called when the selection changes. + /// + /// The callback's parameter indicates which of the segments are selected. + /// + /// When the callback is null, the entire [SegmentedButton] is disabled, + /// and will not respond to input. + /// + /// The default is null. + final void Function(Set<T>)? onSelectionChanged; + + /// Determines if multiple segments can be selected at one time. + /// + /// If true, more than one segment can be selected. When selecting a + /// segment, the other selected segments will stay selected. Selecting an + /// already selected segment will unselect it. + /// + /// If false, only one segment may be selected at a time. When a segment + /// is selected, any previously selected segment will be unselected. + /// + /// The default is false, so only a single segment may be selected at one + /// time. + final bool multiSelectionEnabled; + + /// Determines if having no selected segments is allowed. + /// + /// If true, then it is acceptable for none of the segments to be selected. + /// This means that [selected] can be empty. If the user taps on a + /// selected segment, it will be removed from the selection set passed into + /// [onSelectionChanged]. + /// + /// If false (the default), there must be at least one segment selected. If + /// the user taps on the only selected segment it will not be deselected, and + /// [onSelectionChanged] will not be called. + final bool emptySelectionAllowed; + + /// Determines the segmented button's size and padding based on [expandedInsets]. + /// + /// If null (default), the button adopts its intrinsic content size. When specified, + /// the button expands to fill its parent's space, with the [EdgeInsets] + /// defining the padding. + final EdgeInsets? expandedInsets; + + /// A static convenience method that constructs a segmented button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor], [selectedForegroundColor], and [disabledForegroundColor] + /// colors are used to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], + /// and a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// The [backgroundColor], [selectedBackgroundColor] and [disabledBackgroundColor] + /// colors are used to create a [WidgetStateProperty] [ButtonStyle.backgroundColor]. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// All of the other parameters are either used directly or used to + /// create a [WidgetStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null. By default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// {@tool snippet} + /// + /// For example, to override the default text and icon colors for a + /// [SegmentedButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ** See code in examples/api/lib/material/segmented_button/segmented_button.1.dart ** + /// + /// ```dart + /// SegmentedButton<int>( + /// style: SegmentedButton.styleFrom( + /// foregroundColor: Colors.black, + /// selectedForegroundColor: Colors.white, + /// backgroundColor: Colors.amber, + /// selectedBackgroundColor: Colors.red, + /// ), + /// segments: const <ButtonSegment<int>>[ + /// ButtonSegment<int>( + /// value: 0, + /// label: Text('0'), + /// icon: Icon(Icons.calendar_view_day), + /// ), + /// ButtonSegment<int>( + /// value: 1, + /// label: Text('1'), + /// icon: Icon(Icons.calendar_view_week), + /// ), + /// ], + /// selected: const <int>{0}, + /// onSelectionChanged: (Set<int> selection) {}, + /// ), + /// ``` + /// {@end-tool} + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? selectedForegroundColor, + Color? selectedBackgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + Color? disabledIconColor, + Color? overlayColor, + 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 WidgetStateProperty<Color?>? overlayColorProp = + (foregroundColor == null && selectedForegroundColor == null && overlayColor == null) + ? null + : switch (overlayColor) { + (final Color overlayColor) when overlayColor.value == 0 => + const WidgetStatePropertyAll<Color?>(Colors.transparent), + _ => _SegmentedButtonDefaultsM3.resolveStateColor( + foregroundColor, + selectedForegroundColor, + overlayColor, + ), + }; + return TextButton.styleFrom( + textStyle: textStyle, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + fixedSize: fixedSize, + maximumSize: maximumSize, + side: side, + shape: shape, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + ).copyWith( + foregroundColor: _defaultColor( + foregroundColor, + disabledForegroundColor, + selectedForegroundColor, + ), + backgroundColor: _defaultColor( + backgroundColor, + disabledBackgroundColor, + selectedBackgroundColor, + ), + overlayColor: overlayColorProp, + ); + } + + static WidgetStateProperty<Color?>? _defaultColor( + Color? enabled, + Color? disabled, + Color? selected, + ) { + if ((selected ?? enabled ?? disabled) == null) { + return null; + } + return WidgetStateProperty<Color?>.fromMap(<WidgetStatesConstraint, Color?>{ + WidgetState.disabled: disabled, + WidgetState.selected: selected, + WidgetState.any: enabled, + }); + } + + /// Customizes this button's appearance. + /// + /// The following style properties apply to the entire segmented button: + /// + /// * [ButtonStyle.shadowColor] + /// * [ButtonStyle.elevation] + /// * [ButtonStyle.side] - which is used for both the outer shape and + /// dividers between segments. + /// * [ButtonStyle.shape] + /// + /// The following style properties are applied to each of the individual + /// button segments. For properties that are a [WidgetStateProperty], + /// they will be resolved with the current state of the segment: + /// + /// * [ButtonStyle.textStyle] + /// * [ButtonStyle.backgroundColor] + /// * [ButtonStyle.foregroundColor] + /// * [ButtonStyle.overlayColor] + /// * [ButtonStyle.surfaceTintColor] + /// * [ButtonStyle.elevation] + /// * [ButtonStyle.padding] + /// * [ButtonStyle.iconColor] + /// * [ButtonStyle.iconSize] + /// * [ButtonStyle.mouseCursor] + /// * [ButtonStyle.visualDensity] + /// * [ButtonStyle.tapTargetSize] + /// * [ButtonStyle.animationDuration] + /// * [ButtonStyle.enableFeedback] + /// * [ButtonStyle.alignment] + /// * [ButtonStyle.splashFactory] + /// + /// If [ButtonStyle.side] is provided, [WidgetStateProperty.resolve] is used + /// for the following [WidgetState]s: + /// + /// * [WidgetState.focused]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + final ButtonStyle? style; + + /// Determines if the [selectedIcon] (usually an icon using [Icons.check]) + /// is displayed on the selected segments. + /// + /// If true, the [selectedIcon] will be displayed at the start of the segment. + /// If both the [ButtonSegment.label] and [ButtonSegment.icon] are provided, + /// then the icon will be replaced with the [selectedIcon]. If only the icon + /// or the label is present then the [selectedIcon] will be shown at the start + /// of the segment. + /// + /// If false, then the [selectedIcon] is not used and will not be displayed + /// on selected segments. + /// + /// The default is true, meaning the [selectedIcon] will be shown on selected + /// segments. + final bool showSelectedIcon; + + /// An icon that is used to indicate a segment is selected. + /// + /// If [showSelectedIcon] is true then for selected segments this icon + /// will be shown before the [ButtonSegment.label], replacing the + /// [ButtonSegment.icon] if it is specified. + /// + /// Defaults to an [Icon] with [Icons.check]. + final Widget? selectedIcon; + + @override + State<SegmentedButton<T>> createState() => SegmentedButtonState<T>(); +} + +/// State for [SegmentedButton]. +@visibleForTesting +class SegmentedButtonState<T> extends State<SegmentedButton<T>> { + bool get _enabled => widget.onSelectionChanged != null; + bool _hovering = false; + bool _focused = false; + bool get _selected => widget.selected.isNotEmpty; + + Set<WidgetState> get _states => <WidgetState>{ + if (!_enabled) WidgetState.disabled, + if (_hovering) WidgetState.hovered, + if (_focused) WidgetState.focused, + if (_selected) WidgetState.selected, + }; + + /// Controllers for the [ButtonSegment]s. + @visibleForTesting + final Map<ButtonSegment<T>, MaterialStatesController> statesControllers = + <ButtonSegment<T>, MaterialStatesController>{}; + + @protected + @override + void didUpdateWidget(covariant SegmentedButton<T> oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget != widget) { + statesControllers.removeWhere(( + ButtonSegment<T> segment, + MaterialStatesController controller, + ) { + if (widget.segments.contains(segment)) { + return false; + } else { + controller.dispose(); + return true; + } + }); + } + } + + void _handleOnPressed(T segmentValue) { + if (!_enabled) { + return; + } + final bool onlySelectedSegment = + widget.selected.length == 1 && widget.selected.contains(segmentValue); + final bool validChange = widget.emptySelectionAllowed || !onlySelectedSegment; + if (validChange) { + final bool toggle = + widget.multiSelectionEnabled || (widget.emptySelectionAllowed && onlySelectedSegment); + final pressedSegment = <T>{segmentValue}; + late final Set<T> updatedSelection; + if (toggle) { + updatedSelection = widget.selected.contains(segmentValue) + ? widget.selected.difference(pressedSegment) + : widget.selected.union(pressedSegment); + } else { + updatedSelection = pressedSegment; + } + if (!setEquals(updatedSelection, widget.selected)) { + widget.onSelectionChanged!(updatedSelection); + } + } + } + + @protected + @override + Widget build(BuildContext context) { + final SegmentedButtonThemeData theme = SegmentedButtonTheme.of(context); + final SegmentedButtonThemeData defaults = _SegmentedButtonDefaultsM3(context); + final TextDirection textDirection = Directionality.of(context); + const disabledState = <WidgetState>{WidgetState.disabled}; + + P? effectiveValue<P>(P? Function(ButtonStyle? style) getProperty) { + late final P? widgetValue = getProperty(widget.style); + late final P? themeValue = getProperty(theme.style); + late final P? defaultValue = getProperty(defaults.style); + return widgetValue ?? themeValue ?? defaultValue; + } + + P? resolve<P>( + WidgetStateProperty<P>? Function(ButtonStyle? style) getProperty, [ + Set<WidgetState>? states, + ]) { + return effectiveValue((ButtonStyle? style) => getProperty(style)?.resolve(states ?? _states)); + } + + ButtonStyle segmentStyleFor(ButtonStyle? style) { + return ButtonStyle( + textStyle: style?.textStyle, + backgroundColor: style?.backgroundColor, + foregroundColor: style?.foregroundColor, + overlayColor: style?.overlayColor, + surfaceTintColor: style?.surfaceTintColor, + elevation: style?.elevation, + padding: style?.padding, + iconColor: style?.iconColor, + iconSize: style?.iconSize, + shape: const WidgetStatePropertyAll<OutlinedBorder>(RoundedRectangleBorder()), + mouseCursor: style?.mouseCursor, + visualDensity: style?.visualDensity, + tapTargetSize: style?.tapTargetSize, + animationDuration: style?.animationDuration, + enableFeedback: style?.enableFeedback, + alignment: style?.alignment, + splashFactory: style?.splashFactory, + ); + } + + final ButtonStyle segmentStyle = segmentStyleFor(widget.style); + final ButtonStyle segmentThemeStyle = segmentStyleFor( + theme.style, + ).merge(segmentStyleFor(defaults.style)); + final Widget? selectedIcon = widget.showSelectedIcon + ? widget.selectedIcon ?? theme.selectedIcon ?? defaults.selectedIcon + : null; + + Widget buttonFor(ButtonSegment<T> segment) { + final Widget label = segment.label ?? segment.icon ?? const SizedBox.shrink(); + final bool segmentSelected = widget.selected.contains(segment.value); + final Widget? icon = (segmentSelected && widget.showSelectedIcon) + ? selectedIcon + : segment.label != null + ? segment.icon + : null; + final MaterialStatesController controller = statesControllers.putIfAbsent( + segment, + () => MaterialStatesController(), + ); + controller.update(WidgetState.selected, segmentSelected); + + var content = label; + var effectiveSegmentStyle = segmentStyle; + if (icon != null) { + // This logic is needed to get the exact same rendering as using TextButton.icon. + // It is duplicated from _TextButtonWithIcon and _TextButtonWithIconChild. + // TODO(bleroux): remove once https://github.com/flutter/flutter/issues/173944 is fixed. + final bool useMaterial3 = Theme.of(context).useMaterial3; + final double defaultFontSize = + segmentStyle.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + useMaterial3 + ? const EdgeInsetsDirectional.fromSTEB(12, 8, 16, 8) + : const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 4), + const EdgeInsets.symmetric(horizontal: 4), + effectiveTextScale, + ); + effectiveSegmentStyle = segmentStyle.copyWith( + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(scaledPadding), + ); + final double scale = clampDouble(effectiveTextScale, 1.0, 2.0) - 1.0; + final TextButtonThemeData textButtonTheme = TextButtonTheme.of(context); + final IconAlignment effectiveIconAlignment = + textButtonTheme.style?.iconAlignment ?? + segmentStyle.iconAlignment ?? + IconAlignment.start; + content = Row( + mainAxisSize: MainAxisSize.min, + spacing: lerpDouble(8, 4, scale)!, + children: effectiveIconAlignment == IconAlignment.start + ? <Widget>[icon, Flexible(child: label)] + : <Widget>[Flexible(child: label), icon], + ); + } + + final Widget button = TextButton( + style: effectiveSegmentStyle, + statesController: controller, + onHover: (bool hovering) { + setState(() { + _hovering = hovering; + }); + }, + onFocusChange: (bool focused) { + setState(() { + _focused = focused; + }); + }, + onPressed: (_enabled && segment.enabled) ? () => _handleOnPressed(segment.value) : null, + child: content, + ); + + final Widget buttonWithTooltip = segment.tooltip != null + ? Tooltip(message: segment.tooltip, child: button) + : button; + + return MergeSemantics( + child: Semantics( + selected: segmentSelected, + inMutuallyExclusiveGroup: widget.multiSelectionEnabled ? null : true, + child: buttonWithTooltip, + ), + ); + } + + final OutlinedBorder effectiveBorder = + resolve<OutlinedBorder?>((ButtonStyle? style) => style?.shape) ?? + const RoundedRectangleBorder(); + final OutlinedBorder resolvedDisabledBorder = + resolve<OutlinedBorder?>((ButtonStyle? style) => style?.shape, disabledState) ?? + const RoundedRectangleBorder(); + final BorderSide effectiveSide = + resolve<BorderSide?>((ButtonStyle? style) => style?.side) ?? BorderSide.none; + final BorderSide disabledSide = + resolve<BorderSide?>((ButtonStyle? style) => style?.side, disabledState) ?? BorderSide.none; + + final OutlinedBorder enabledBorder = effectiveBorder.copyWith(side: effectiveSide); + final OutlinedBorder disabledBorder = resolvedDisabledBorder.copyWith(side: disabledSide); + final VisualDensity resolvedVisualDensity = + segmentStyle.visualDensity ?? + segmentThemeStyle.visualDensity ?? + Theme.of(context).visualDensity; + final EdgeInsetsGeometry resolvedPadding = + resolve<EdgeInsetsGeometry?>((ButtonStyle? style) => style?.padding) ?? EdgeInsets.zero; + final MaterialTapTargetSize resolvedTapTargetSize = + segmentStyle.tapTargetSize ?? + segmentThemeStyle.tapTargetSize ?? + Theme.of(context).materialTapTargetSize; + final double fontSize = + resolve<TextStyle?>((ButtonStyle? style) => style?.textStyle)?.fontSize ?? 20.0; + + final List<Widget> buttons = widget.segments.map(buttonFor).toList(); + + final Offset densityAdjustment = resolvedVisualDensity.baseSizeAdjustment; + const textButtonMinHeight = 40.0; + + final double adjustButtonMinHeight = textButtonMinHeight + densityAdjustment.dy; + final double effectiveVerticalPadding = resolvedPadding.vertical + densityAdjustment.dy * 2; + final double effectedButtonHeight = max( + fontSize + effectiveVerticalPadding, + adjustButtonMinHeight, + ); + final double tapTargetVerticalPadding = switch (resolvedTapTargetSize) { + MaterialTapTargetSize.shrinkWrap => 0.0, + MaterialTapTargetSize.padded => max( + 0, + kMinInteractiveDimension + densityAdjustment.dy - effectedButtonHeight, + ), + }; + + return Material( + type: MaterialType.transparency, + elevation: resolve<double?>((ButtonStyle? style) => style?.elevation)!, + shadowColor: resolve<Color?>((ButtonStyle? style) => style?.shadowColor), + surfaceTintColor: resolve<Color?>((ButtonStyle? style) => style?.surfaceTintColor), + child: TextButtonTheme( + data: TextButtonThemeData(style: segmentThemeStyle), + child: Padding( + padding: widget.expandedInsets ?? EdgeInsets.zero, + child: _SegmentedButtonRenderWidget<T>( + tapTargetVerticalPadding: tapTargetVerticalPadding, + segments: widget.segments, + enabledBorder: _enabled ? enabledBorder : disabledBorder, + disabledBorder: disabledBorder, + direction: widget.direction, + textDirection: textDirection, + isExpanded: widget.expandedInsets != null, + children: buttons, + ), + ), + ), + ); + } + + @protected + @override + void dispose() { + for (final MaterialStatesController controller in statesControllers.values) { + controller.dispose(); + } + super.dispose(); + } +} + +class _SegmentedButtonRenderWidget<T> extends MultiChildRenderObjectWidget { + const _SegmentedButtonRenderWidget({ + super.key, + required this.segments, + required this.enabledBorder, + required this.disabledBorder, + required this.direction, + required this.textDirection, + required this.tapTargetVerticalPadding, + required this.isExpanded, + required super.children, + }) : assert(children.length == segments.length); + + final List<ButtonSegment<T>> segments; + final OutlinedBorder enabledBorder; + final OutlinedBorder disabledBorder; + final Axis direction; + final TextDirection textDirection; + final double tapTargetVerticalPadding; + final bool isExpanded; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSegmentedButton<T>( + segments: segments, + enabledBorder: enabledBorder, + disabledBorder: disabledBorder, + textDirection: textDirection, + direction: direction, + tapTargetVerticalPadding: tapTargetVerticalPadding, + isExpanded: isExpanded, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSegmentedButton<T> renderObject) { + renderObject + ..segments = segments + ..enabledBorder = enabledBorder + ..disabledBorder = disabledBorder + ..direction = direction + ..textDirection = textDirection; + } +} + +class _SegmentedButtonContainerBoxParentData extends ContainerBoxParentData<RenderBox> { + RRect? surroundingRect; +} + +typedef _NextChild = RenderBox? Function(RenderBox child); + +class _RenderSegmentedButton<T> extends RenderBox + with + ContainerRenderObjectMixin<RenderBox, ContainerBoxParentData<RenderBox>>, + RenderBoxContainerDefaultsMixin<RenderBox, ContainerBoxParentData<RenderBox>> { + _RenderSegmentedButton({ + required List<ButtonSegment<T>> segments, + required OutlinedBorder enabledBorder, + required OutlinedBorder disabledBorder, + required TextDirection textDirection, + required double tapTargetVerticalPadding, + required bool isExpanded, + required Axis direction, + }) : _segments = segments, + _enabledBorder = enabledBorder, + _disabledBorder = disabledBorder, + _textDirection = textDirection, + _direction = direction, + _tapTargetVerticalPadding = tapTargetVerticalPadding, + _isExpanded = isExpanded; + + List<ButtonSegment<T>> get segments => _segments; + List<ButtonSegment<T>> _segments; + set segments(List<ButtonSegment<T>> value) { + if (listEquals(segments, value)) { + return; + } + _segments = value; + markNeedsLayout(); + } + + OutlinedBorder get enabledBorder => _enabledBorder; + OutlinedBorder _enabledBorder; + set enabledBorder(OutlinedBorder value) { + if (_enabledBorder == value) { + return; + } + _enabledBorder = value; + markNeedsLayout(); + } + + OutlinedBorder get disabledBorder => _disabledBorder; + OutlinedBorder _disabledBorder; + set disabledBorder(OutlinedBorder value) { + if (_disabledBorder == value) { + return; + } + _disabledBorder = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + Axis get direction => _direction; + Axis _direction; + set direction(Axis value) { + if (value == _direction) { + return; + } + _direction = value; + markNeedsLayout(); + } + + double get tapTargetVerticalPadding => _tapTargetVerticalPadding; + double _tapTargetVerticalPadding; + set tapTargetVerticalPadding(double value) { + if (value == _tapTargetVerticalPadding) { + return; + } + _tapTargetVerticalPadding = value; + markNeedsLayout(); + } + + bool get isExpanded => _isExpanded; + bool _isExpanded; + set isExpanded(bool value) { + if (value == _isExpanded) { + return; + } + _isExpanded = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + var minWidth = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childWidth = child.getMinIntrinsicWidth(height); + minWidth = math.max(minWidth, childWidth); + child = childParentData.nextSibling; + } + return minWidth * childCount; + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + var maxWidth = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childWidth = child.getMaxIntrinsicWidth(height); + maxWidth = math.max(maxWidth, childWidth); + child = childParentData.nextSibling; + } + return maxWidth * childCount; + } + + @override + double computeMinIntrinsicHeight(double width) { + RenderBox? child = firstChild; + var minHeight = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childHeight = child.getMinIntrinsicHeight(width); + minHeight = math.max(minHeight, childHeight); + child = childParentData.nextSibling; + } + return minHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + RenderBox? child = firstChild; + var maxHeight = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final double childHeight = child.getMaxIntrinsicHeight(width); + maxHeight = math.max(maxHeight, childHeight); + child = childParentData.nextSibling; + } + return maxHeight; + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SegmentedButtonContainerBoxParentData) { + child.parentData = _SegmentedButtonContainerBoxParentData(); + } + } + + void _layoutRects(_NextChild nextChild, RenderBox? leftChild, RenderBox? rightChild) { + var child = leftChild; + var start = 0.0; + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + late final RRect rChildRect; + if (direction == Axis.vertical) { + childParentData.offset = Offset(0.0, start); + final childRect = Rect.fromLTWH( + 0.0, + childParentData.offset.dy, + child.size.width, + child.size.height, + ); + rChildRect = RRect.fromRectAndCorners(childRect); + start += child.size.height; + } else { + childParentData.offset = Offset(start, 0.0); + final childRect = Rect.fromLTWH(start, 0.0, child.size.width, child.size.height); + rChildRect = RRect.fromRectAndCorners(childRect); + start += child.size.width; + } + childParentData.surroundingRect = rChildRect; + child = nextChild(child); + } + } + + Size _calculateChildSize(BoxConstraints constraints) { + return direction == Axis.horizontal + ? _calculateHorizontalChildSize(constraints) + : _calculateVerticalChildSize(constraints); + } + + Size _calculateHorizontalChildSize(BoxConstraints constraints) { + double maxHeight = 0; + RenderBox? child = firstChild; + double childWidth; + if (_isExpanded) { + childWidth = constraints.maxWidth / childCount; + } else { + childWidth = constraints.minWidth / childCount; + while (child != null) { + childWidth = math.max(childWidth, child.getMaxIntrinsicWidth(double.infinity)); + child = childAfter(child); + } + childWidth = math.min(childWidth, constraints.maxWidth / childCount); + } + child = firstChild; + while (child != null) { + final double boxHeight = child.getMaxIntrinsicHeight(childWidth); + maxHeight = math.max(maxHeight, boxHeight); + child = childAfter(child); + } + return Size(childWidth, maxHeight); + } + + Size _calculateVerticalChildSize(BoxConstraints constraints) { + double maxWidth = 0; + RenderBox? child = firstChild; + double childHeight; + if (_isExpanded) { + childHeight = constraints.maxHeight / childCount; + } else { + childHeight = constraints.minHeight / childCount; + while (child != null) { + childHeight = math.max(childHeight, child.getMaxIntrinsicHeight(double.infinity)); + child = childAfter(child); + } + childHeight = math.min(childHeight, constraints.maxHeight / childCount); + } + child = firstChild; + while (child != null) { + final double boxWidth = child.getMaxIntrinsicWidth(maxWidth); + maxWidth = math.max(maxWidth, boxWidth); + child = childAfter(child); + } + + var childSize = Size(maxWidth, childHeight); + + // When the parent provides a tight width constraint in the vertical + // direction, use that width for layout so the visual size and the + // interactive area match. This preserves the intrinsic (shrink-wrap) + // sizing behavior when the width is unconstrained. + if (constraints.hasTightWidth && childSize.width < constraints.maxWidth) { + childSize = Size(constraints.maxWidth, childSize.height); + } + + return childSize; + } + + Size _computeOverallSizeFromChildSize(Size childSize) { + if (direction == Axis.vertical) { + return constraints.constrain(Size(childSize.width, childSize.height * childCount)); + } + return constraints.constrain(Size(childSize.width * childCount, childSize.height)); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final Size childSize = _calculateChildSize(constraints); + return _computeOverallSizeFromChildSize(childSize); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final Size childSize = _calculateChildSize(constraints); + final childConstraints = BoxConstraints.tight(childSize); + + BaselineOffset baselineOffset = BaselineOffset.noBaseline; + for (RenderBox? child = firstChild; child != null; child = childAfter(child)) { + baselineOffset = baselineOffset.minOf( + BaselineOffset(child.getDryBaseline(childConstraints, baseline)), + ); + } + return baselineOffset.offset; + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final Size childSize = _calculateChildSize(constraints); + + final childConstraints = BoxConstraints.tightFor( + width: childSize.width, + height: childSize.height, + ); + + RenderBox? child = firstChild; + while (child != null) { + child.layout(childConstraints, parentUsesSize: true); + child = childAfter(child); + } + + switch (textDirection) { + case TextDirection.rtl: + _layoutRects(childBefore, lastChild, firstChild); + case TextDirection.ltr: + _layoutRects(childAfter, firstChild, lastChild); + } + + size = _computeOverallSizeFromChildSize(childSize); + } + + @override + void paint(PaintingContext context, Offset offset) { + final Rect borderRect = + (offset + Offset(0, tapTargetVerticalPadding / 2)) & + (Size(size.width, size.height - tapTargetVerticalPadding)); + final Path borderClipPath = enabledBorder.getInnerPath( + borderRect, + textDirection: textDirection, + ); + RenderBox? child = firstChild; + RenderBox? previousChild; + var index = 0; + Path? enabledClipPath; + Path? disabledClipPath; + + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + final Rect childRect = childParentData.surroundingRect!.outerRect.shift(offset); + + context.canvas + ..save() + ..clipPath(borderClipPath); + context.paintChild(child, childParentData.offset + offset); + context.canvas.restore(); + + // Compute a clip rect for the outer border of the child. + final double segmentLeft; + final double segmentRight; + final double dividerPos; + final double borderOutset = math.max( + enabledBorder.side.strokeOutset, + disabledBorder.side.strokeOutset, + ); + switch (textDirection) { + case TextDirection.rtl: + segmentLeft = child == lastChild ? borderRect.left - borderOutset : childRect.left; + segmentRight = child == firstChild ? borderRect.right + borderOutset : childRect.right; + dividerPos = segmentRight; + case TextDirection.ltr: + segmentLeft = child == firstChild ? borderRect.left - borderOutset : childRect.left; + segmentRight = child == lastChild ? borderRect.right + borderOutset : childRect.right; + dividerPos = segmentLeft; + } + final segmentClipRect = Rect.fromLTRB( + segmentLeft, + borderRect.top - borderOutset, + segmentRight, + borderRect.bottom + borderOutset, + ); + + // Add the clip rect to the appropriate border clip path + if (segments[index].enabled) { + enabledClipPath = (enabledClipPath ?? Path())..addRect(segmentClipRect); + } else { + disabledClipPath = (disabledClipPath ?? Path())..addRect(segmentClipRect); + } + + // Paint the divider between this segment and the previous one. + if (previousChild != null) { + final BorderSide divider = segments[index - 1].enabled || segments[index].enabled + ? enabledBorder.side.copyWith(strokeAlign: 0.0) + : disabledBorder.side.copyWith(strokeAlign: 0.0); + if (direction == Axis.horizontal) { + final top = Offset(dividerPos, borderRect.top); + final bottom = Offset(dividerPos, borderRect.bottom); + context.canvas.drawLine(top, bottom, divider.toPaint()); + } else if (direction == Axis.vertical) { + final start = Offset(borderRect.left, childRect.top); + final end = Offset(borderRect.right, childRect.top); + context.canvas + ..save() + ..clipPath(borderClipPath); + context.canvas.drawLine(start, end, divider.toPaint()); + context.canvas.restore(); + } + } + + previousChild = child; + child = childAfter(child); + index += 1; + } + + // Paint the outer border for both disabled and enabled clip rect if needed. + if (disabledClipPath == null) { + // Just paint the enabled border with no clip. + enabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + } else if (enabledClipPath == null) { + // Just paint the disabled border with no. + disabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + } else { + // Paint both of them clipped appropriately for the children segments. + context.canvas + ..save() + ..clipPath(enabledClipPath); + enabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + context.canvas + ..restore() + ..save() + ..clipPath(disabledClipPath); + disabledBorder.paint(context.canvas, borderRect, textDirection: textDirection); + context.canvas.restore(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? child = lastChild; + while (child != null) { + final childParentData = child.parentData! as _SegmentedButtonContainerBoxParentData; + if (childParentData.surroundingRect!.contains(position)) { + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset localOffset) { + assert(localOffset == position - childParentData.offset); + return child!.hitTest(result, position: localOffset); + }, + ); + } + child = childParentData.previousSibling; + } + return false; + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - SegmentedButton + +// 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. + +// dart format off +class _SegmentedButtonDefaultsM3 extends SegmentedButtonThemeData { + _SegmentedButtonDefaultsM3(this.context); + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + @override ButtonStyle? get style { + return ButtonStyle( + textStyle: WidgetStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge), + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return null; + } + if (states.contains(WidgetState.selected)) { + return _colors.secondaryContainer; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSecondaryContainer; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSecondaryContainer; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSecondaryContainer; + } + return _colors.onSecondaryContainer; + } else { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface; + } + return _colors.onSurface; + } + }), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSecondaryContainer.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSecondaryContainer.withOpacity(0.1); + } + } else { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + } + return null; + }), + surfaceTintColor: const WidgetStatePropertyAll<Color>(Colors.transparent), + elevation: const WidgetStatePropertyAll<double>(0), + iconSize: const WidgetStatePropertyAll<double?>(18.0), + side: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return BorderSide(color: _colors.onSurface.withOpacity(0.12)); + } + return BorderSide(color: _colors.outline); + }), + shape: const WidgetStatePropertyAll<OutlinedBorder>(StadiumBorder()), + minimumSize: const WidgetStatePropertyAll<Size?>(Size.fromHeight(40.0)), + ); + } + @override + Widget? get selectedIcon => const Icon(Icons.check); + + static WidgetStateProperty<Color?> resolveStateColor( + Color? unselectedColor, + Color? selectedColor, + Color? overlayColor, + ) { + final Color? selected = overlayColor ?? selectedColor; + final Color? unselected = overlayColor ?? unselectedColor; + return WidgetStateProperty<Color?>.fromMap( + <WidgetStatesConstraint, Color?>{ + WidgetState.selected & WidgetState.pressed: selected?.withOpacity(0.1), + WidgetState.selected & WidgetState.hovered: selected?.withOpacity(0.08), + WidgetState.selected & WidgetState.focused: selected?.withOpacity(0.1), + WidgetState.pressed: unselected?.withOpacity(0.1), + WidgetState.hovered: unselected?.withOpacity(0.08), + WidgetState.focused: unselected?.withOpacity(0.1), + WidgetState.any: Colors.transparent, + }, + ); + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - SegmentedButton diff --git a/packages/material_ui/lib/src/segmented_button_theme.dart b/packages/material_ui/lib/src/segmented_button_theme.dart new file mode 100644 index 000000000000..b09acbcedab6 --- /dev/null +++ b/packages/material_ui/lib/src/segmented_button_theme.dart @@ -0,0 +1,175 @@ +// 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. + +/// @docImport 'segmented_button.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Overrides the default values of visual properties for descendant +/// [SegmentedButton] widgets. +/// +/// Descendant widgets obtain the current [SegmentedButtonThemeData] object with +/// [SegmentedButtonTheme.of]. Instances of [SegmentedButtonThemeData] can +/// be customized with [SegmentedButtonThemeData.copyWith]. +/// +/// Typically a [SegmentedButtonThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.segmentedButtonTheme]. +/// +/// All [SegmentedButtonThemeData] properties are null by default. When null, +/// the [SegmentedButton] computes its own default values, typically based on +/// the overall theme's [ThemeData.colorScheme], [ThemeData.textTheme], and +/// [ThemeData.iconTheme]. +@immutable +class SegmentedButtonThemeData with Diagnosticable { + /// Creates a [SegmentedButtonThemeData] that can be used to override default properties + /// in a [SegmentedButtonTheme] widget. + const SegmentedButtonThemeData({this.style, this.selectedIcon}); + + /// Overrides the [SegmentedButton]'s default style. + /// + /// Non-null properties or non-null resolved [WidgetStateProperty] + /// values override the default values used by [SegmentedButton]. + /// + /// If [style] is null, then this theme doesn't override anything. + /// + /// If [ButtonStyle.side] is provided, [WidgetStateProperty.resolve] is used + /// for the following [WidgetState]s: + /// + /// * [WidgetState.focused]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// * [WidgetState.selected]. + final ButtonStyle? style; + + /// Override for [SegmentedButton.selectedIcon] property. + /// + /// If non-null, then [selectedIcon] will be used instead of default + /// value for [SegmentedButton.selectedIcon]. + final Widget? selectedIcon; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + SegmentedButtonThemeData copyWith({ButtonStyle? style, Widget? selectedIcon}) { + return SegmentedButtonThemeData( + style: style ?? this.style, + selectedIcon: selectedIcon ?? this.selectedIcon, + ); + } + + /// Linearly interpolates between two segmented button themes. + static SegmentedButtonThemeData lerp( + SegmentedButtonThemeData? a, + SegmentedButtonThemeData? b, + double t, + ) { + if (identical(a, b) && a != null) { + return a; + } + return SegmentedButtonThemeData( + style: ButtonStyle.lerp(a?.style, b?.style, t), + selectedIcon: t < 0.5 ? a?.selectedIcon : b?.selectedIcon, + ); + } + + @override + int get hashCode => Object.hash(style, selectedIcon); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SegmentedButtonThemeData && + other.style == style && + other.selectedIcon == selectedIcon; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + } +} + +/// An inherited widget that defines the visual properties for +/// [SegmentedButton]s in this widget's subtree. +/// +/// Values specified here are used for [SegmentedButton] properties that are not +/// given an explicit non-null value. +class SegmentedButtonTheme extends InheritedTheme { + /// Creates a [SegmentedButtonTheme] that controls visual parameters for + /// descendent [SegmentedButton]s. + const SegmentedButtonTheme({super.key, required this.data, required super.child}); + + /// Specifies the visual properties used by descendant [SegmentedButton] + /// widgets. + final SegmentedButtonThemeData data; + + /// The [data] from the closest instance of this class that encloses the given + /// context. + /// + /// If there is no [SegmentedButtonTheme] in scope, this will return + /// [ThemeData.segmentedButtonTheme] from the ambient [Theme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SegmentedButtonThemeData theme = SegmentedButtonTheme.of(context); + /// ``` + /// + /// See also: + /// + /// * [maybeOf], which returns null if it doesn't find a + /// [SegmentedButtonTheme] ancestor. + static SegmentedButtonThemeData of(BuildContext context) { + return maybeOf(context) ?? Theme.of(context).segmentedButtonTheme; + } + + /// The data from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Use this function if you want to allow situations where no + /// [SegmentedButtonTheme] is in scope. Prefer using [SegmentedButtonTheme.of] + /// in situations where a [SegmentedButtonThemeData] is expected to be + /// non-null. + /// + /// If there is no [SegmentedButtonTheme] in scope, then this function will + /// return null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SegmentedButtonThemeData? theme = SegmentedButtonTheme.maybeOf(context); + /// if (theme == null) { + /// // Do something else instead. + /// } + /// ``` + /// + /// See also: + /// + /// * [of], which will return [ThemeData.segmentedButtonTheme] if it doesn't + /// find a [SegmentedButtonTheme] ancestor, instead of returning null. + static SegmentedButtonThemeData? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<SegmentedButtonTheme>()?.data; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return SegmentedButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(SegmentedButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/selectable_text.dart b/packages/material_ui/lib/src/selectable_text.dart new file mode 100644 index 000000000000..ac96b7ac241c --- /dev/null +++ b/packages/material_ui/lib/src/selectable_text.dart @@ -0,0 +1,816 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'selection_area.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'desktop_text_selection.dart'; +import 'magnifier.dart'; +import 'text_selection.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; +// late FocusNode myFocusNode; + +/// An eyeballed value that moves the cursor slightly left of where it is +/// rendered for text on Android so its positioning more accurately matches the +/// native iOS text cursor positioning. +/// +/// This value is in device pixels, not logical pixels as is typically used +/// throughout the codebase. +const int iOSHorizontalOffset = -2; + +class _TextSpanEditingController extends TextEditingController { + _TextSpanEditingController({required TextSpan textSpan}) + : _textSpan = textSpan, + super(text: textSpan.toPlainText(includeSemanticsLabels: false)); + + final TextSpan _textSpan; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + // This does not care about composing. + return TextSpan(style: style, children: <TextSpan>[_textSpan]); + } + + @override + set text(String? newText) { + // This should never be reached. + throw UnimplementedError(); + } +} + +class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { + _SelectableTextSelectionGestureDetectorBuilder({required _SelectableTextState state}) + : _state = state, + super(delegate: state); + + final _SelectableTextState _state; + + @override + void onSingleTapUp(TapDragUpDetails details) { + if (!delegate.selectionEnabled) { + return; + } + super.onSingleTapUp(details); + _state.widget.onTap?.call(); + } +} + +/// A run of selectable text with a single style. +/// +/// Consider using [SelectionArea] or [SelectableRegion] instead, which enable +/// selection on a widget subtree, including but not limited to [Text] widgets. +/// +/// The [SelectableText] widget displays a string of text with a single style. +/// The string might break across multiple lines or might all be displayed on +/// the same line depending on the layout constraints. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc} +/// +/// The [style] argument is optional. When omitted, the text will use the style +/// from the closest enclosing [DefaultTextStyle]. If the given style's +/// [TextStyle.inherit] property is true (the default), the given style will +/// be merged with the closest enclosing [DefaultTextStyle]. This merging +/// behavior is useful, for example, to make the text bold while using the +/// default font family and size. +/// +/// {@macro flutter.material.textfield.wantKeepAlive} +/// +/// {@tool snippet} +/// +/// ```dart +/// const SelectableText( +/// 'Hello! How are you?', +/// textAlign: TextAlign.center, +/// style: TextStyle(fontWeight: FontWeight.bold), +/// ) +/// ``` +/// {@end-tool} +/// +/// Using the [SelectableText.rich] constructor, the [SelectableText] widget can +/// display a paragraph with differently styled [TextSpan]s. The sample +/// that follows displays "Hello beautiful world" with different styles +/// for each word. +/// +/// {@tool snippet} +/// +/// ```dart +/// const SelectableText.rich( +/// TextSpan( +/// text: 'Hello', // default text style +/// children: <TextSpan>[ +/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), +/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Interactivity +/// +/// To make [SelectableText] react to touch events, use callback [onTap] to achieve +/// the desired behavior. +/// +/// ## Scrolling Considerations +/// +/// If this [SelectableText] is not a descendant of [Scaffold] and is being used +/// within a [Scrollable] or nested [Scrollable]s, consider placing a +/// [ScrollNotificationObserver] above the root [Scrollable] that contains this +/// [SelectableText] to ensure proper scroll coordination for [SelectableText] +/// and its components like [TextSelectionOverlay]. +/// +/// See also: +/// +/// * [Text], which is the non selectable version of this widget. +/// * [TextField], which is the editable version of this widget. +/// * [SelectionArea], which enables the selection of multiple [Text] widgets +/// and of other widgets. +class SelectableText extends StatefulWidget { + /// Creates a selectable text widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + /// + + /// If the [showCursor], [autofocus], [dragStartBehavior], + /// [selectionHeightStyle], [selectionWidthStyle] and [data] arguments are + /// specified, the [maxLines] argument must be greater than zero. + const SelectableText( + String this.data, { + super.key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + this.textScaleFactor, + this.textScaler, + this.showCursor = false, + this.autofocus = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.minLines, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.scrollPhysics, + this.scrollBehavior, + this.semanticsLabel, + this.textHeightBehavior, + this.textWidthBasis, + this.onSelectionChanged, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + }) : assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + textSpan = null; + + /// Creates a selectable text widget with a [TextSpan]. + /// + /// The [TextSpan.children] attribute of the [textSpan] parameter must only + /// contain [TextSpan]s. Other types of [InlineSpan] are not allowed. + const SelectableText.rich( + TextSpan this.textSpan, { + super.key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + this.textScaleFactor, + this.textScaler, + this.showCursor = false, + this.autofocus = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.minLines, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.scrollPhysics, + this.scrollBehavior, + this.semanticsLabel, + this.textHeightBehavior, + this.textWidthBasis, + this.onSelectionChanged, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + }) : assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + data = null; + + /// The text to display. + /// + /// This will be null if a [textSpan] is provided instead. + final String? data; + + /// The text to display as a [TextSpan]. + /// + /// This will be null if [data] is provided instead. + final TextSpan? textSpan; + + /// Defines the focus for this widget. + /// + /// Text is only selectable when widget is focused. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// myFocusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode] with + /// [FocusNode.skipTraversal] parameter set to `true`, which causes the widget + /// to be skipped over during focus traversal. + final FocusNode? focusNode; + + /// The style to use for the text. + /// + /// If null, defaults [DefaultTextStyle] of context. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign? textAlign; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.textScaleFactor} + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + final double? textScaleFactor; + + /// {@macro flutter.painting.textPainter.textScaler} + final TextScaler? textScaler; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.minLines} + final int? minLines; + + /// {@macro flutter.widgets.editableText.maxLines} + final int? maxLines; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// The color of the cursor. + /// + /// The cursor indicates the current text insertion point. + /// + /// If null then [DefaultSelectionStyle.cursorColor] is used. If that is also + /// null and [ThemeData.platform] is [TargetPlatform.iOS] or + /// [TargetPlatform.macOS], then [CupertinoThemeData.primaryColor] is used. + /// Otherwise [ColorScheme.primary] of [ThemeData.colorScheme] is used. + final Color? cursorColor; + + /// The color to use when painting the selection. + /// + /// If this property is null, this widget gets the selection color from the + /// inherited [DefaultSelectionStyle] (if any); if none, the selection + /// color is derived from the [CupertinoThemeData.primaryColor] on + /// Apple platforms and [ColorScheme.primary] of [ThemeData.colorScheme] on + /// other platforms. + final Color? selectionColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle? selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle? selectionWidthStyle; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// Configuration of toolbar options. + /// + /// Paste and cut will be disabled regardless. + /// + /// If not set, select all and copy will be enabled by default. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final ToolbarOptions? toolbarOptions; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// Called when the user taps on this selectable text. + /// + /// The selectable text builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the selectable text with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the selectable text's + /// internal gesture detector, provide this callback. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// selectable text's internal gesture detector, use a [Listener]. + final GestureTapCallback? onTap; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.scrollBehavior} + final ScrollBehavior? scrollBehavior; + + /// {@macro flutter.widgets.Text.semanticsLabel} + final String? semanticsLabel; + + /// {@macro dart.ui.textHeightBehavior} + final TextHeightBehavior? textHeightBehavior; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis? textWidthBasis; + + /// {@macro flutter.widgets.editableText.onSelectionChanged} + final SelectionChangedCallback? onSelectionChanged; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + final EditableTextContextMenuBuilder? contextMenuBuilder; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + } + + /// The configuration for the magnifier used when the text is selected. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] + /// on Android, and builds nothing on all other platforms. To suppress the + /// magnifier, consider passing [TextMagnifierConfiguration.disabled]. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration? magnifierConfiguration; + + @override + State<SelectableText> createState() => _SelectableTextState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null)); + properties.add( + DiagnosticsProperty<String>('semanticsLabel', semanticsLabel, defaultValue: null), + ); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false)); + properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); + properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null)); + properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null)); + properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); + properties.add(DiagnosticsProperty<TextScaler>('textScaler', textScaler, defaultValue: null)); + properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); + properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null)); + properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<Color>('selectionColor', selectionColor, defaultValue: null), + ); + properties.add( + FlagProperty( + 'selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled', + ), + ); + properties.add( + DiagnosticsProperty<TextSelectionControls>( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ScrollBehavior>('scrollBehavior', scrollBehavior, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextHeightBehavior>( + 'textHeightBehavior', + textHeightBehavior, + defaultValue: null, + ), + ); + } +} + +class _SelectableTextState extends State<SelectableText> + implements TextSelectionGestureDetectorBuilderDelegate { + EditableTextState? get _editableText => editableTextKey.currentState; + + late _TextSpanEditingController _controller; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => + widget.focusNode ?? (_focusNode ??= FocusNode(skipTraversal: true)); + + bool _showSelectionHandles = false; + + late _SelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + late bool forcePressEnabled; + + @override + final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>(); + + @override + bool get selectionEnabled => widget.selectionEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = _SelectableTextSelectionGestureDetectorBuilder(state: this); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data), + ); + _controller.addListener(_onControllerChanged); + _effectiveFocusNode.addListener(_handleFocusChanged); + } + + @override + void didUpdateWidget(SelectableText oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) { + _controller.removeListener(_onControllerChanged); + _controller.dispose(); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data), + ); + _controller.addListener(_onControllerChanged); + } + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) { + _showSelectionHandles = false; + } else { + _showSelectionHandles = true; + } + } + + @override + void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); + _focusNode?.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + final bool showSelectionHandles = + !_effectiveFocusNode.hasFocus || !_controller.selection.isCollapsed; + if (showSelectionHandles == _showSelectionHandles) { + return; + } + setState(() { + _showSelectionHandles = showSelectionHandles; + }); + } + + void _handleFocusChanged() { + if (!_effectiveFocusNode.hasFocus && + SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) { + // We should only clear the selection when this SelectableText loses + // focus while the application is currently running. It is possible + // that the application is not currently running, for example on desktop + // platforms, clicking on a different window switches the focus to + // the new window causing the Flutter application to go inactive. In this + // case we want to retain the selection so it remains when we return to + // the Flutter application. + _controller.value = TextEditingValue(text: _controller.value.text); + } + } + + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + widget.onSelectionChanged?.call(selection, cause); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (cause == SelectionChangedCause.longPress) { + _editableText?.bringIntoView(selection.base); + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Do nothing. + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_controller.selection.isCollapsed) { + _editableText!.toggleToolbar(); + } + } + + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + // When the text field is activated by something that doesn't trigger the + // selection overlay, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) { + return false; + } + + if (_controller.selection.isCollapsed) { + return false; + } + + if (cause == SelectionChangedCause.keyboard) { + return false; + } + + if (cause == SelectionChangedCause.longPress) { + return true; + } + + if (_controller.text.isNotEmpty) { + return true; + } + + return false; + } + + @override + Widget build(BuildContext context) { + // TODO(garyq): Assert to block WidgetSpans from being used here are removed, + // but we still do not yet have nice handling of things like carets, clipboard, + // and other features. We should add proper support. Currently, caret handling + // is blocked on SkParagraph switch and https://github.com/flutter/engine/pull/27010 + // should be landed in SkParagraph after the switch is complete. + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasDirectionality(context)); + assert( + !(widget.style != null && + !widget.style!.inherit && + (widget.style!.fontSize == null || widget.style!.textBaseline == null)), + 'inherit false style must supply fontSize and textBaseline', + ); + + final ThemeData theme = Theme.of(context); + final DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(context); + final FocusNode focusNode = _effectiveFocusNode; + + TextSelectionControls? textSelectionControls = widget.selectionControls; + final bool paintCursorAboveText; + final bool cursorOpacityAnimates; + Offset? cursorOffset; + final Color cursorColor; + final Color selectionColor; + Radius? cursorRadius = widget.cursorRadius; + + switch (theme.platform) { + case TargetPlatform.iOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = true; + textSelectionControls ??= cupertinoTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor = + widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = + selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); + + case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor = + widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = + selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); + + case TargetPlatform.linux: + case TargetPlatform.windows: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); + } + + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + TextStyle? effectiveTextStyle = widget.style; + if (effectiveTextStyle == null || effectiveTextStyle.inherit) { + effectiveTextStyle = defaultTextStyle.style.merge( + widget.style ?? _controller._textSpan.style, + ); + } + final TextScaler? effectiveScaler = + widget.textScaler ?? + switch (widget.textScaleFactor) { + null => null, + final double textScaleFactor => TextScaler.linear(textScaleFactor), + }; + final Widget child = RepaintBoundary( + child: EditableText( + key: editableTextKey, + style: effectiveTextStyle, + readOnly: true, + toolbarOptions: widget.toolbarOptions, + textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior, + showSelectionHandles: _showSelectionHandles, + showCursor: widget.showCursor, + controller: _controller, + focusNode: focusNode, + strutStyle: widget.strutStyle ?? const StrutStyle(), + textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: widget.textDirection, + textScaler: effectiveScaler, + autofocus: widget.autofocus, + forceLine: false, + minLines: widget.minLines, + maxLines: widget.maxLines ?? defaultTextStyle.maxLines, + selectionColor: widget.selectionColor ?? selectionColor, + selectionControls: widget.selectionEnabled ? textSelectionControls : null, + onSelectionChanged: _handleSelectionChanged, + onSelectionHandleTapped: _handleSelectionHandleTapped, + rendererIgnoresPointer: true, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + cursorOpacityAnimates: cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + backgroundCursorColor: CupertinoColors.inactiveGray, + enableInteractiveSelection: widget.enableInteractiveSelection, + magnifierConfiguration: + widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, + dragStartBehavior: widget.dragStartBehavior, + scrollPhysics: widget.scrollPhysics, + scrollBehavior: widget.scrollBehavior, + autofillHints: null, + contextMenuBuilder: widget.contextMenuBuilder, + ), + ); + + return Semantics( + label: widget.semanticsLabel, + excludeSemantics: widget.semanticsLabel != null, + onLongPress: () { + _effectiveFocusNode.requestFocus(); + }, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: child, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/selection_area.dart b/packages/material_ui/lib/src/selection_area.dart new file mode 100644 index 000000000000..e2e4fee6c964 --- /dev/null +++ b/packages/material_ui/lib/src/selection_area.dart @@ -0,0 +1,145 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'material_localizations.dart'; +/// @docImport 'selectable_text.dart'; +library; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/rendering.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'debug.dart'; +import 'desktop_text_selection.dart'; +import 'magnifier.dart'; +import 'text_selection.dart'; +import 'theme.dart'; + +/// A widget that introduces an area for user selections with adaptive selection +/// controls. +/// +/// This widget creates a [SelectableRegion] with platform-adaptive selection +/// controls. +/// +/// Flutter widgets are not selectable by default. To enable selection for +/// a specific screen, consider wrapping the body of the [Route] with a +/// [SelectionArea]. +/// +/// The [SelectionArea] widget must have a [Localizations] ancestor that +/// contains a [MaterialLocalizations] delegate; using the [MaterialApp] widget +/// ensures that such an ancestor is present. +/// +/// {@tool dartpad} +/// This example shows how to make a screen selectable. +/// +/// ** See code in examples/api/lib/material/selection_area/selection_area.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SelectableRegion], which provides an overview of the selection system. +/// * [SelectableText], which enables selection on a single run of text. +/// * [SelectionListener], which enables accessing the [SelectionDetails] of +/// the selectable subtree it wraps. +class SelectionArea extends StatefulWidget { + /// Creates a [SelectionArea]. + /// + /// If [selectionControls] is null, a platform specific one is used. + const SelectionArea({ + super.key, + this.focusNode, + this.selectionControls, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + this.onSelectionChanged, + required this.child, + }); + + /// The configuration for the magnifier in the selection region. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] + /// on Android, and builds nothing on all other platforms. To suppress the + /// magnifier, consider passing [TextMagnifierConfiguration.disabled]. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The delegate to build the selection handles and toolbar. + /// + /// If it is null, the platform specific selection control is used. + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the ambient + /// [ThemeData.platform]. + /// + /// {@tool dartpad} + /// This example shows how to build a custom context menu for any selected + /// content in a SelectionArea. + /// + /// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + final SelectableRegionContextMenuBuilder? contextMenuBuilder; + + /// Called when the selected content changes. + final ValueChanged<SelectedContent?>? onSelectionChanged; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + return AdaptiveTextSelectionToolbar.selectableRegion( + selectableRegionState: selectableRegionState, + ); + } + + @override + State<StatefulWidget> createState() => SelectionAreaState(); +} + +/// State for a [SelectionArea]. +class SelectionAreaState extends State<SelectionArea> { + final GlobalKey<SelectableRegionState> _selectableRegionKey = GlobalKey<SelectableRegionState>(); + + /// The [State] of the [SelectableRegion] for which this [SelectionArea] wraps. + SelectableRegionState get selectableRegion => _selectableRegionKey.currentState!; + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final TextSelectionControls controls = + widget.selectionControls ?? + switch (Theme.of(context).platform) { + TargetPlatform.android || TargetPlatform.fuchsia => materialTextSelectionHandleControls, + TargetPlatform.linux || TargetPlatform.windows => desktopTextSelectionHandleControls, + TargetPlatform.iOS => cupertinoTextSelectionHandleControls, + TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, + }; + return SelectableRegion( + key: _selectableRegionKey, + selectionControls: controls, + focusNode: widget.focusNode, + contextMenuBuilder: widget.contextMenuBuilder, + magnifierConfiguration: + widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, + onSelectionChanged: widget.onSelectionChanged, + child: widget.child, + ); + } +} diff --git a/packages/material_ui/lib/src/shaders/ink_sparkle.frag b/packages/material_ui/lib/src/shaders/ink_sparkle.frag new file mode 100644 index 000000000000..f9f3ce6be3dd --- /dev/null +++ b/packages/material_ui/lib/src/shaders/ink_sparkle.frag @@ -0,0 +1,105 @@ +#version 320 es + +// 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. + +precision highp float; + +#include <flutter/runtime_effect.glsl> + +// TODO(antrob): Put these in a more logical order (e.g. separate consts vs varying, etc) + +layout(location = 0) uniform vec4 u_color; +// u_alpha, u_sparkle_alpha, u_blur, u_radius_scale +layout(location = 1) uniform vec4 u_composite_1; +layout(location = 2) uniform vec2 u_center; +layout(location = 3) uniform float u_max_radius; +layout(location = 4) uniform vec2 u_resolution_scale; +layout(location = 5) uniform vec2 u_noise_scale; +layout(location = 6) uniform float u_noise_phase; +layout(location = 7) uniform vec2 u_circle1; +layout(location = 8) uniform vec2 u_circle2; +layout(location = 9) uniform vec2 u_circle3; +layout(location = 10) uniform vec2 u_rotation1; +layout(location = 11) uniform vec2 u_rotation2; +layout(location = 12) uniform vec2 u_rotation3; + +layout(location = 0) out vec4 fragColor; + +const float PI = 3.1415926535897932384626; +const float PI_ROTATE_RIGHT = PI * 0.0078125; +const float PI_ROTATE_LEFT = PI * -0.0078125; +const float ONE_THIRD = 1./3.; +const vec2 TURBULENCE_SCALE = vec2(0.8); + +float u_alpha = u_composite_1.x; +float u_sparkle_alpha = u_composite_1.y; +float u_blur = u_composite_1.z; +float u_radius_scale = u_composite_1.w; + +float triangle_noise(highp vec2 n) { + n = fract(n * vec2(5.3987, 5.4421)); + n += dot(n.yx, n.xy + vec2(21.5351, 14.3137)); + float xy = n.x * n.y; + return fract(xy * 95.4307) + fract(xy * 75.04961) - 1.0; +} + +float threshold(float v, float l, float h) { + return step(l, v) * (1.0 - step(h, v)); +} + +mat2 rotate2d(vec2 rad){ + return mat2(rad.x, -rad.y, rad.y, rad.x); +} + +float soft_circle(vec2 uv, vec2 xy, float radius, float blur) { + float blur_half = blur * 0.5; + float d = distance(uv, xy); + return 1.0 - smoothstep(1.0 - blur_half, 1.0 + blur_half, d / radius); +} + +float soft_ring(vec2 uv, vec2 xy, float radius, float thickness, float blur) { + float circle_outer = soft_circle(uv, xy, radius + thickness, blur); + float circle_inner = soft_circle(uv, xy, max(radius - thickness, 0.0), blur); + return clamp(circle_outer - circle_inner, 0.0, 1.0); +} + +float circle_grid(vec2 resolution, vec2 p, vec2 xy, vec2 rotation, float cell_diameter) { + p = rotate2d(rotation) * (xy - p) + xy; + p = mod(p, cell_diameter) / resolution; + float cell_uv = cell_diameter / resolution.y * 0.5; + float r = 0.65 * cell_uv; + return soft_circle(p, vec2(cell_uv), r, r * 50.0); +} + +float sparkle(vec2 uv, float t) { + float n = triangle_noise(uv); + float s = threshold(n, 0.0, 0.05); + s += threshold(n + sin(PI * (t + 0.35)), 0.1, 0.15); + s += threshold(n + sin(PI * (t + 0.7)), 0.2, 0.25); + s += threshold(n + sin(PI * (t + 1.05)), 0.3, 0.35); + return clamp(s, 0.0, 1.0) * 0.55; +} + +float turbulence(vec2 uv) { + vec2 uv_scale = uv * TURBULENCE_SCALE; + float g1 = circle_grid(TURBULENCE_SCALE, uv_scale, u_circle1, u_rotation1, 0.17); + float g2 = circle_grid(TURBULENCE_SCALE, uv_scale, u_circle2, u_rotation2, 0.2); + float g3 = circle_grid(TURBULENCE_SCALE, uv_scale, u_circle3, u_rotation3, 0.275); + float v = (g1 * g1 + g2 - g3) * 0.5; + return clamp(0.45 + 0.8 * v, 0.0, 1.0); +} + +void main() { + vec2 p = FlutterFragCoord(); + vec2 uv = p * u_resolution_scale; + vec2 density_uv = uv - mod(p, u_noise_scale); + float radius = u_max_radius * u_radius_scale; + float turbulence = turbulence(uv); + float ring = soft_ring(p, u_center, radius, 0.05 * u_max_radius, u_blur); + float sparkle = sparkle(density_uv, u_noise_phase) * ring * turbulence * u_sparkle_alpha; + float wave_alpha = soft_circle(p, u_center, radius, u_blur) * u_alpha * u_color.a; + vec4 wave_color = vec4(u_color.rgb * wave_alpha, wave_alpha); + fragColor = mix(wave_color, vec4(1.0), sparkle); +} diff --git a/packages/material_ui/lib/src/shaders/stretch_effect.frag b/packages/material_ui/lib/src/shaders/stretch_effect.frag new file mode 100644 index 000000000000..53be5a6971e1 --- /dev/null +++ b/packages/material_ui/lib/src/shaders/stretch_effect.frag @@ -0,0 +1,159 @@ +#version 320 es +// 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. + +// This shader was created based on or with reference to the implementation found at: +// https://cs.android.com/android/platform/superproject/main/+/512046e84bcc51cc241bc6599f83ab345e93ab12:frameworks/base/libs/hwui/effects/StretchEffect.cpp + +#include <flutter/runtime_effect.glsl> + +uniform vec2 u_size; +uniform sampler2D u_texture; + +// Multiplier to apply to scale effect. +uniform float u_max_stretch_intensity; + +// Normalized overscroll amount in the horizontal direction. +uniform float u_overscroll_x; + +// Normalized overscroll amount in the vertical direction. +uniform float u_overscroll_y; + +// u_interpolation_strength is the intensity of the interpolation. +uniform float u_interpolation_strength; + +float ease_in(float t, float d) { + return t * d; +} + +float compute_overscroll_start( + float in_pos, + float overscroll, + float u_stretch_affected_dist, + float u_inverse_stretch_affected_dist, + float distance_stretched, + float interpolation_strength +) { + float offset_pos = u_stretch_affected_dist - in_pos; + float pos_based_variation = mix( + 1.0, + ease_in(offset_pos, u_inverse_stretch_affected_dist), + interpolation_strength + ); + float stretch_intensity = overscroll * pos_based_variation; + return distance_stretched - (offset_pos / (1.0 + stretch_intensity)); +} + +float compute_overscroll_end( + float in_pos, + float overscroll, + float reverse_stretch_dist, + float u_stretch_affected_dist, + float u_inverse_stretch_affected_dist, + float distance_stretched, + float interpolation_strength, + float viewport_dimension +) { + float offset_pos = in_pos - reverse_stretch_dist; + float pos_based_variation = mix( + 1.0, + ease_in(offset_pos, u_inverse_stretch_affected_dist), + interpolation_strength + ); + float stretch_intensity = (-overscroll) * pos_based_variation; + return viewport_dimension - (distance_stretched - (offset_pos / (1.0 + stretch_intensity))); +} + +float compute_streched_effect( + float in_pos, + float overscroll, + float u_stretch_affected_dist, + float u_inverse_stretch_affected_dist, + float distance_stretched, + float distance_diff, + float interpolation_strength, + float viewport_dimension +) { + if (overscroll > 0.0) { + if (in_pos <= u_stretch_affected_dist) { + return compute_overscroll_start( + in_pos, overscroll, u_stretch_affected_dist, + u_inverse_stretch_affected_dist, distance_stretched, + interpolation_strength + ); + } else { + return distance_diff + in_pos; + } + } else if (overscroll < 0.0) { + float stretch_affected_dist_calc = viewport_dimension - u_stretch_affected_dist; + if (in_pos >= stretch_affected_dist_calc) { + return compute_overscroll_end( + in_pos, + overscroll, + stretch_affected_dist_calc, + u_stretch_affected_dist, + u_inverse_stretch_affected_dist, + distance_stretched, + interpolation_strength, + viewport_dimension + ); + } else { + return -distance_diff + in_pos; + } + } else { + return in_pos; + } +} + +out vec4 frag_color; + +void main() { + vec2 uv = FlutterFragCoord().xy / u_size; + float in_u_norm = uv.x; + float in_v_norm = uv.y; + + float out_u_norm; + float out_v_norm; + + bool isVertical = u_overscroll_y != 0; + float overscroll = isVertical ? u_overscroll_y : u_overscroll_x; + + float norm_distance_stretched = 1.0 / (1.0 + abs(overscroll)); + float norm_dist_diff = norm_distance_stretched - 1.0; + + const float norm_viewport = 1.0; + const float norm_stretch_affected_dist = 1.0; + const float norm_inverse_stretch_affected_dist = 1.0; + + out_u_norm = isVertical ? in_u_norm : compute_streched_effect( + in_u_norm, + overscroll, + norm_stretch_affected_dist, + norm_inverse_stretch_affected_dist, + norm_distance_stretched, + norm_dist_diff, + u_interpolation_strength, + norm_viewport + ); + + out_v_norm = isVertical ? compute_streched_effect( + in_v_norm, + overscroll, + norm_stretch_affected_dist, + norm_inverse_stretch_affected_dist, + norm_distance_stretched, + norm_dist_diff, + u_interpolation_strength, + norm_viewport + ) : in_v_norm; + + uv.x = out_u_norm; + #ifdef IMPELLER_TARGET_OPENGLES + uv.y = 1.0 - out_v_norm; + #else + uv.y = out_v_norm; + #endif + + frag_color = texture(u_texture, uv); +} diff --git a/packages/material_ui/lib/src/shadows.dart b/packages/material_ui/lib/src/shadows.dart new file mode 100644 index 000000000000..33f98987ccb0 --- /dev/null +++ b/packages/material_ui/lib/src/shadows.dart @@ -0,0 +1,209 @@ +// 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. + +/// @docImport 'package:flutter/widgets.dart'; +/// +/// @docImport 'material.dart'; +library; + +import 'package:flutter/painting.dart'; + +// Based on https://material.io/design/environment/elevation.html +// Currently, only the elevation values that are bound to one or more widgets are +// defined here. + +/// Map of elevation offsets used by Material Design to [BoxShadow] definitions. +/// +/// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24. +/// +/// Each entry has three shadows which must be combined to obtain the defined +/// effect for that elevation. +/// +/// This is useful when simulating a shadow with a [BoxDecoration] or other +/// class that uses a list of [BoxShadow] objects. +/// +/// Shadows defined by [kElevationToShadow] use [BlurStyle.normal]. To convert a +/// shadow from [kElevationToShadow] to use a different [BlurStyle] (e.g. to use +/// it in a [MagnifierDecoration]), consider an expression such as the +/// following: +/// +/// ```dart +/// kElevationToShadow[12]!.map((BoxShadow shadow) => shadow.copyWith(blurStyle: BlurStyle.outer)).toList(), +/// ``` +/// +/// See also: +/// +/// * [Material], which takes an arbitrary double for its elevation and generates +/// a shadow dynamically. +/// * <https://material.io/design/environment/elevation.html> +const Map<int, List<BoxShadow>> kElevationToShadow = + _elevationToShadow; // to hide the literal from the docs + +const Color _kKeyUmbraOpacity = Color(0x33000000); // alpha = 0.2 +const Color _kKeyPenumbraOpacity = Color(0x24000000); // alpha = 0.14 +const Color _kAmbientShadowOpacity = Color(0x1F000000); // alpha = 0.12 +const Map<int, List<BoxShadow>> _elevationToShadow = <int, List<BoxShadow>>{ + // The empty list depicts no elevation. + 0: <BoxShadow>[], + + 1: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 2.0), + blurRadius: 1.0, + spreadRadius: -1.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 1.0, color: _kKeyPenumbraOpacity), + BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 3.0, color: _kAmbientShadowOpacity), + ], + + 2: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 3.0), + blurRadius: 1.0, + spreadRadius: -2.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow(offset: Offset(0.0, 2.0), blurRadius: 2.0, color: _kKeyPenumbraOpacity), + BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 5.0, color: _kAmbientShadowOpacity), + ], + + 3: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 3.0), + blurRadius: 3.0, + spreadRadius: -2.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow(offset: Offset(0.0, 3.0), blurRadius: 4.0, color: _kKeyPenumbraOpacity), + BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 8.0, color: _kAmbientShadowOpacity), + ], + + 4: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 2.0), + blurRadius: 4.0, + spreadRadius: -1.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow(offset: Offset(0.0, 4.0), blurRadius: 5.0, color: _kKeyPenumbraOpacity), + BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 10.0, color: _kAmbientShadowOpacity), + ], + + 6: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 3.0), + blurRadius: 5.0, + spreadRadius: -1.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow(offset: Offset(0.0, 6.0), blurRadius: 10.0, color: _kKeyPenumbraOpacity), + BoxShadow(offset: Offset(0.0, 1.0), blurRadius: 18.0, color: _kAmbientShadowOpacity), + ], + + 8: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 5.0), + blurRadius: 5.0, + spreadRadius: -3.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 8.0), + blurRadius: 10.0, + spreadRadius: 1.0, + color: _kKeyPenumbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 3.0), + blurRadius: 14.0, + spreadRadius: 2.0, + color: _kAmbientShadowOpacity, + ), + ], + + 9: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 5.0), + blurRadius: 6.0, + spreadRadius: -3.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 9.0), + blurRadius: 12.0, + spreadRadius: 1.0, + color: _kKeyPenumbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 3.0), + blurRadius: 16.0, + spreadRadius: 2.0, + color: _kAmbientShadowOpacity, + ), + ], + + 12: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 7.0), + blurRadius: 8.0, + spreadRadius: -4.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 12.0), + blurRadius: 17.0, + spreadRadius: 2.0, + color: _kKeyPenumbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 5.0), + blurRadius: 22.0, + spreadRadius: 4.0, + color: _kAmbientShadowOpacity, + ), + ], + + 16: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 8.0), + blurRadius: 10.0, + spreadRadius: -5.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 16.0), + blurRadius: 24.0, + spreadRadius: 2.0, + color: _kKeyPenumbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 6.0), + blurRadius: 30.0, + spreadRadius: 5.0, + color: _kAmbientShadowOpacity, + ), + ], + + 24: <BoxShadow>[ + BoxShadow( + offset: Offset(0.0, 11.0), + blurRadius: 15.0, + spreadRadius: -7.0, + color: _kKeyUmbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 24.0), + blurRadius: 38.0, + spreadRadius: 3.0, + color: _kKeyPenumbraOpacity, + ), + BoxShadow( + offset: Offset(0.0, 9.0), + blurRadius: 46.0, + spreadRadius: 8.0, + color: _kAmbientShadowOpacity, + ), + ], +}; diff --git a/packages/material_ui/lib/src/slider.dart b/packages/material_ui/lib/src/slider.dart new file mode 100644 index 000000000000..e8a0d3cff860 --- /dev/null +++ b/packages/material_ui/lib/src/slider.dart @@ -0,0 +1,2388 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'checkbox.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'switch.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart' show timeDilation; +import 'package:flutter/services.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'material.dart'; +import 'slider_parts.dart'; +import 'slider_theme.dart'; +import 'slider_value_indicator_shape.dart'; +import 'theme.dart'; + +// Examples can assume: +// int _dollars = 0; +// int _duelCommandment = 1; +// void setState(VoidCallback fn) { } + +/// [Slider] uses this callback to paint the value indicator on the overlay. +/// +/// Since the value indicator is painted on the Overlay; this method paints the +/// value indicator in a [RenderBox] that appears in the [Overlay]. +typedef PaintValueIndicator = void Function(PaintingContext context, Offset offset); + +enum _SliderType { material, adaptive } + +/// Possible ways for a user to interact with a [Slider]. +enum SliderInteraction { + /// Allows the user to interact with a [Slider] by tapping or sliding anywhere + /// on the track. + /// + /// Essentially all possible interactions are allowed. + /// + /// This is different from [SliderInteraction.slideOnly] as when you try + /// to slide anywhere other than the thumb, the thumb will move to the first + /// point of contact. + tapAndSlide, + + /// Allows the user to interact with a [Slider] by only tapping anywhere on + /// the track. + /// + /// Sliding interaction is ignored. + tapOnly, + + /// Allows the user to interact with a [Slider] only by sliding anywhere on + /// the track. + /// + /// Tapping interaction is ignored. + slideOnly, + + /// Allows the user to interact with a [Slider] only by sliding the thumb. + /// + /// Tapping and sliding interactions on the track are ignored. + slideThumb, +} + +/// A Material Design slider. +/// +/// Used to select from a range of values. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs} +/// +/// {@tool dartpad} +/// This example showcases non-discrete and discrete [Slider]s. +/// The [Slider]s will show the updated ![Material 3 Design appearance](https://m3.material.io/components/sliders/overview) +/// when setting the [Slider.year2023] flag to false. +/// +/// ** See code in examples/api/lib/material/slider/slider.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [Slider] widget using the [Slider.secondaryTrackValue] +/// to show a secondary track in the slider. +/// +/// ** See code in examples/api/lib/material/slider/slider.1.dart ** +/// {@end-tool} +/// +/// A slider can be used to select from either a continuous or a discrete set of +/// values. The default is to use a continuous range of values from [min] to +/// [max]. To use discrete values, use a non-null value for [divisions], which +/// indicates the number of discrete intervals. For example, if [min] is 0.0 and +/// [max] is 50.0 and [divisions] is 5, then the slider can take on the +/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0. +/// +/// The terms for the parts of a slider are: +/// +/// * The "thumb", which is a shape that slides horizontally when the user +/// drags it. +/// * The "track", which is the line that the slider thumb slides along. +/// * The "value indicator", which is a shape that pops up when the user +/// is dragging the thumb to indicate the value being selected. +/// * The "active" side of the slider is the side between the thumb and the +/// minimum value. +/// * The "inactive" side of the slider is the side between the thumb and the +/// maximum value. +/// +/// The slider will be disabled if [onChanged] is null or if the range given by +/// [min]..[max] is empty (i.e. if [min] is equal to [max]). +/// +/// The slider widget itself does not maintain any state. Instead, when the state +/// of the slider changes, the widget calls the [onChanged] callback. Most +/// widgets that use a slider will listen for the [onChanged] callback and +/// rebuild the slider with a new [value] to update the visual appearance of the +/// slider. To know when the value starts to change, or when it is done +/// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd]. +/// +/// By default, a slider will be as wide as possible, centered vertically. When +/// given unbounded constraints, it will attempt to make the track 144 pixels +/// wide (with margins on each side) and will shrink-wrap vertically. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// Requires one of its ancestors to be a [MediaQuery] widget. Typically, these +/// are introduced by the [MaterialApp] or [WidgetsApp] widget at the top of +/// your application widget tree. +/// +/// To determine how it should be displayed (e.g. colors, thumb shape, etc.), +/// a slider uses the [SliderThemeData] available from either a [SliderTheme] +/// widget or the [ThemeData.sliderTheme] a [Theme] widget above it in the +/// widget tree. You can also override some of the colors with the [activeColor] +/// and [inactiveColor] properties, although more fine-grained control of the +/// look is achieved using a [SliderThemeData]. +/// +/// See also: +/// +/// * [SliderTheme] and [SliderThemeData] for information about controlling +/// the visual appearance of the slider. +/// * [Radio], for selecting among a set of explicit values. +/// * [Checkbox] and [Switch], for toggling a particular value on or off. +/// * <https://material.io/design/components/sliders.html> +/// * [MediaQuery], from which the text scale factor is obtained. +class Slider extends StatefulWidget { + /// Creates a Material Design slider. + /// + /// The slider itself does not maintain any state. Instead, when the state of + /// the slider changes, the widget calls the [onChanged] callback. Most + /// widgets that use a slider will listen for the [onChanged] callback and + /// rebuild the slider with a new [value] to update the visual appearance of + /// the slider. + /// + /// * [value] determines currently selected value for this slider. + /// * [onChanged] is called while the user is selecting a new value for the + /// slider. + /// * [onChangeStart] is called when the user starts to select a new value for + /// the slider. + /// * [onChangeEnd] is called when the user is done selecting a new value for + /// the slider. + /// + /// You can override some of the colors with the [activeColor] and + /// [inactiveColor] properties, although more fine-grained control of the + /// appearance is achieved using a [SliderThemeData]. + const Slider({ + super.key, + required this.value, + this.secondaryTrackValue, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.label, + this.activeColor, + this.inactiveColor, + this.secondaryActiveColor, + this.thumbColor, + this.overlayColor, + this.mouseCursor, + this.semanticFormatterCallback, + this.focusNode, + this.autofocus = false, + this.allowedInteraction, + this.padding, + this.showValueIndicator, + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.year2023, + }) : _sliderType = _SliderType.material, + assert(min <= max), + assert( + value >= min && value <= max, + 'Value $value is not between minimum $min and maximum $max', + ), + assert( + secondaryTrackValue == null || (secondaryTrackValue >= min && secondaryTrackValue <= max), + 'SecondaryValue $secondaryTrackValue is not between $min and $max', + ), + assert(divisions == null || divisions > 0); + + /// Creates an adaptive [Slider] based on the target platform, following + /// Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// Creates a [CupertinoSlider] if the target platform is iOS or macOS, creates a + /// Material Design slider otherwise. + /// + /// If a [CupertinoSlider] is created, the following parameters are ignored: + /// [secondaryTrackValue], [label], [inactiveColor], [secondaryActiveColor], + /// [semanticFormatterCallback], [showValueIndicator]. + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + const Slider.adaptive({ + super.key, + required this.value, + this.secondaryTrackValue, + required this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.label, + this.mouseCursor, + this.activeColor, + this.inactiveColor, + this.secondaryActiveColor, + this.thumbColor, + this.overlayColor, + this.semanticFormatterCallback, + this.focusNode, + this.autofocus = false, + this.allowedInteraction, + this.showValueIndicator, + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + this.year2023, + }) : _sliderType = _SliderType.adaptive, + padding = null, + assert(min <= max), + assert( + value >= min && value <= max, + 'Value $value is not between minimum $min and maximum $max', + ), + assert( + secondaryTrackValue == null || (secondaryTrackValue >= min && secondaryTrackValue <= max), + 'SecondaryValue $secondaryTrackValue is not between $min and $max', + ), + assert(divisions == null || divisions > 0); + + /// The currently selected value for this slider. + /// + /// The slider's thumb is drawn at a position that corresponds to this value. + final double value; + + /// The secondary track value for this slider. + /// + /// If not null, a secondary track using [Slider.secondaryActiveColor] color + /// is drawn between the thumb and this value, over the inactive track. + /// + /// If less than [Slider.value], then the secondary track is not shown. + /// + /// It can be ideal for media scenarios such as showing the buffering progress + /// while the [Slider.value] shows the play progress. + final double? secondaryTrackValue; + + /// Called during a drag when the user is selecting a new value for the slider + /// by dragging. + /// + /// The slider passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the slider with the new + /// value. + /// + /// If null, the slider will be displayed as disabled. + /// + /// The callback provided to onChanged should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// {@tool snippet} + /// + /// ```dart + /// Slider( + /// value: _duelCommandment.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// label: '$_duelCommandment', + /// onChanged: (double newValue) { + /// setState(() { + /// _duelCommandment = newValue.round(); + /// }); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when the user starts + /// changing the value. + /// * [onChangeEnd] for a callback that is called when the user stops + /// changing the value. + final ValueChanged<double>? onChanged; + + /// Called when the user starts selecting a new value for the slider. + /// + /// This callback shouldn't be used to update the slider [value] (use + /// [onChanged] for that), but rather to be notified when the user has started + /// selecting a new value by starting a drag or with a tap. + /// + /// The value passed will be the last [value] that the slider had before the + /// change began. + /// + /// {@tool snippet} + /// + /// ```dart + /// Slider( + /// value: _duelCommandment.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// label: '$_duelCommandment', + /// onChanged: (double newValue) { + /// setState(() { + /// _duelCommandment = newValue.round(); + /// }); + /// }, + /// onChangeStart: (double startValue) { + /// print('Started change at $startValue'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeEnd] for a callback that is called when the value change is + /// complete. + final ValueChanged<double>? onChangeStart; + + /// Called when the user is done selecting a new value for the slider. + /// + /// This callback shouldn't be used to update the slider [value] (use + /// [onChanged] for that), but rather to know when the user has completed + /// selecting a new [value] by ending a drag or a click. + /// + /// {@tool snippet} + /// + /// ```dart + /// Slider( + /// value: _duelCommandment.toDouble(), + /// min: 1.0, + /// max: 10.0, + /// divisions: 10, + /// label: '$_duelCommandment', + /// onChanged: (double newValue) { + /// setState(() { + /// _duelCommandment = newValue.round(); + /// }); + /// }, + /// onChangeEnd: (double newValue) { + /// print('Ended change on $newValue'); + /// }, + /// ) + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when a value change + /// begins. + final ValueChanged<double>? onChangeEnd; + + /// The minimum value the user can select. + /// + /// Defaults to 0.0. Must be less than or equal to [max]. + /// + /// If the [max] is equal to the [min], then the slider is disabled. + final double min; + + /// The maximum value the user can select. + /// + /// Defaults to 1.0. Must be greater than or equal to [min]. + /// + /// If the [max] is equal to the [min], then the slider is disabled. + final double max; + + /// The number of discrete divisions. + /// + /// Typically used with [label] to show the current discrete value. + /// + /// If null, the slider is continuous. + final int? divisions; + + /// A label to show above the slider when the slider is active and + /// [SliderThemeData.showValueIndicator] is satisfied. + /// + /// It is used to display the value of a discrete slider, and it is displayed + /// as part of the value indicator shape. + /// + /// The label is rendered using the active [ThemeData]'s [TextTheme.bodyLarge] + /// text style, with the theme data's [ColorScheme.onPrimary] color. The + /// label's text style can be overridden with + /// [SliderThemeData.valueIndicatorTextStyle]. + /// + /// If null, then the value indicator will not be displayed. + /// + /// Ignored if this slider is created with [Slider.adaptive]. + /// + /// See also: + /// + /// * [SliderComponentShape] for how to create a custom value indicator + /// shape. + final String? label; + + /// The color to use for the portion of the slider track that is active. + /// + /// The "active" side of the slider is the side between the thumb and the + /// minimum value. + /// + /// If null, [SliderThemeData.activeTrackColor] of the ambient + /// [SliderTheme] is used. If that is null, [ColorScheme.primary] of the + /// surrounding [ThemeData] is used. + /// + /// Using a [SliderTheme] gives much more fine-grained control over the + /// appearance of various components of the slider. + final Color? activeColor; + + /// The color for the inactive portion of the slider track. + /// + /// The "inactive" side of the slider is the side between the thumb and the + /// maximum value. + /// + /// If null, [SliderThemeData.inactiveTrackColor] of the ambient [SliderTheme] + /// is used. If [Slider.year2023] is false and [ThemeData.useMaterial3] is true, + /// then [ColorScheme.secondaryContainer] is used and if [ThemeData.useMaterial3] + /// is false, [ColorScheme.primary] with an opacity of 0.24 is used. Otherwise, + /// [ColorScheme.surfaceContainerHighest] is used. + /// + /// Using a [SliderTheme] gives much more fine-grained control over the + /// appearance of various components of the slider. + /// + /// Ignored if this slider is created with [Slider.adaptive]. + final Color? inactiveColor; + + /// The color to use for the portion of the slider track between the thumb and + /// the [Slider.secondaryTrackValue]. + /// + /// Defaults to the [SliderThemeData.secondaryActiveTrackColor] of the current + /// [SliderTheme]. + /// + /// If that is also null, defaults to [ColorScheme.primary] with an + /// opacity of 0.54. + /// + /// Using a [SliderTheme] gives much more fine-grained control over the + /// appearance of various components of the slider. + /// + /// Ignored if this slider is created with [Slider.adaptive]. + final Color? secondaryActiveColor; + + /// The color of the thumb. + /// + /// If this color is null, [Slider] will use [activeColor], If [activeColor] + /// is also null, [Slider] will use [SliderThemeData.thumbColor]. + /// + /// If that is also null, defaults to [ColorScheme.primary]. + /// + /// * [CupertinoSlider] will have a white thumb + /// (like the native default iOS slider). + final Color? thumbColor; + + /// The highlight color that's typically used to indicate that + /// the slider thumb is focused, hovered, or dragged. + /// + /// If this property is null, [Slider] will use [activeColor] with + /// an opacity of 0.12, If null, [SliderThemeData.overlayColor] + /// will be used. + /// + /// If that is also null, If [ThemeData.useMaterial3] is true, + /// Slider will use [ColorScheme.primary] with an opacity of 0.08 when + /// slider thumb is hovered and with an opacity of 0.1 when slider thumb + /// is focused or dragged, If [ThemeData.useMaterial3] is false, defaults + /// to [ColorScheme.primary] with an opacity of 0.12. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@template flutter.material.slider.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.dragged]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [SliderThemeData.mouseCursor] is used. If that + /// is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The callback used to create a semantic value from a slider value. + /// + /// Defaults to formatting values as a percentage. + /// + /// This is used by accessibility frameworks like TalkBack on Android to + /// inform users what the currently selected value is with more context. + /// + /// {@tool snippet} + /// + /// In the example below, a slider for currency values is configured to + /// announce a value with a currency label. + /// + /// ```dart + /// Slider( + /// value: _dollars.toDouble(), + /// min: 20.0, + /// max: 330.0, + /// label: '$_dollars dollars', + /// onChanged: (double newValue) { + /// setState(() { + /// _dollars = newValue.round(); + /// }); + /// }, + /// semanticFormatterCallback: (double newValue) { + /// return '${newValue.round()} dollars'; + /// } + /// ) + /// ``` + /// {@end-tool} + /// + /// Ignored if this slider is created with [Slider.adaptive] + final SemanticFormatterCallback? semanticFormatterCallback; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// Allowed way for the user to interact with the [Slider]. + /// + /// For example, if this is set to [SliderInteraction.tapOnly], the user can + /// interact with the slider only by tapping anywhere on the track. Sliding + /// will have no effect. + /// + /// Defaults to [SliderInteraction.tapAndSlide]. + final SliderInteraction? allowedInteraction; + + /// Determines the padding around the [Slider]. + /// + /// If specified, this padding overrides the default vertical padding of + /// the [Slider], defaults to the height of the overlay shape, and the + /// horizontal padding, defaults to the width of the thumb shape or + /// overlay shape, whichever is larger. + final EdgeInsetsGeometry? padding; + + /// Determines the conditions under which the value indicator is shown. + /// + /// If [Slider.showValueIndicator] is null then the + /// ambient [SliderThemeData.showValueIndicator] is used. If that is also + /// null, defaults to [ShowValueIndicator.onlyForDiscrete]. + final ShowValueIndicator? showValueIndicator; + + /// When true, the [Slider] will use the 2023 Material Design 3 appearance. + /// Defaults to true. + /// + /// If this is set to false, the [Slider] will use the latest Material Design 3 + /// appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + final bool? year2023; + + final _SliderType _sliderType; + + @override + State<Slider> createState() => _SliderState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('value', value)); + properties.add(DoubleProperty('secondaryTrackValue', secondaryTrackValue)); + properties.add( + ObjectFlagProperty<ValueChanged<double>>('onChanged', onChanged, ifNull: 'disabled'), + ); + properties.add(ObjectFlagProperty<ValueChanged<double>>.has('onChangeStart', onChangeStart)); + properties.add(ObjectFlagProperty<ValueChanged<double>>.has('onChangeEnd', onChangeEnd)); + properties.add(DoubleProperty('min', min)); + properties.add(DoubleProperty('max', max)); + properties.add(IntProperty('divisions', divisions)); + properties.add(StringProperty('label', label)); + properties.add(ColorProperty('activeColor', activeColor)); + properties.add(ColorProperty('inactiveColor', inactiveColor)); + properties.add(ColorProperty('secondaryActiveColor', secondaryActiveColor)); + properties.add( + ObjectFlagProperty<ValueChanged<double>>.has( + 'semanticFormatterCallback', + semanticFormatterCallback, + ), + ); + properties.add(ObjectFlagProperty<FocusNode>.has('focusNode', focusNode)); + properties.add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus')); + } +} + +class _SliderState extends State<Slider> with TickerProviderStateMixin { + static const Duration enableAnimationDuration = Duration(milliseconds: 75); + static const Duration valueIndicatorAnimationDuration = Duration(milliseconds: 100); + + // Animation controller that is run when the overlay (a.k.a radial reaction) + // is shown in response to user interaction. + late AnimationController overlayController; + // Animation controller that is run when the value indicator is being shown + // or hidden. + late AnimationController valueIndicatorController; + // Animation controller that is run when enabling/disabling the slider. + late AnimationController enableController; + // Animation controller that is run when transitioning between one value + // and the next on a discrete slider. + late AnimationController positionController; + Timer? interactionTimer; + + final GlobalKey _renderObjectKey = GlobalKey(); + + // Keyboard mapping for a focused slider. + static const Map<ShortcutActivator, Intent> _traditionalNavShortcutMap = + <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowUp): _AdjustSliderIntent.up(), + SingleActivator(LogicalKeyboardKey.arrowDown): _AdjustSliderIntent.down(), + SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), + SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), + }; + + // Keyboard mapping for a focused slider when using directional navigation. + // The vertical inputs are not handled to allow navigating out of the slider. + static const Map<ShortcutActivator, Intent> _directionalNavShortcutMap = + <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), + SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), + }; + + // Action mapping for a focused slider. + late Map<Type, Action<Intent>> _actionMap; + + bool get _enabled => widget.onChanged != null; + // Value Indicator Animation that appears on the Overlay. + PaintValueIndicator? paintValueIndicator; + + bool _dragging = false; + + // For discrete sliders, _handleChanged might receive the same value + // multiple times. To avoid calling widget.onChanged repeatedly, the + // value from _handleChanged is temporarily saved here. + double? _currentChangedValue; + + FocusNode? _focusNode; + FocusNode get focusNode => widget.focusNode ?? _focusNode!; + + // Always keep the ValueIndicator visible on the Overlay; otherwise, it cannot be updated during the build phase. + final OverlayPortalController _valueIndicatorOverlayPortalController = OverlayPortalController( + debugLabel: 'Slider ValueIndicator', + )..show(); + + @override + void initState() { + super.initState(); + overlayController = AnimationController(duration: kRadialReactionDuration, vsync: this); + valueIndicatorController = AnimationController( + duration: valueIndicatorAnimationDuration, + vsync: this, + ); + enableController = AnimationController(duration: enableAnimationDuration, vsync: this); + positionController = AnimationController(duration: Duration.zero, vsync: this); + enableController.value = widget.onChanged != null ? 1.0 : 0.0; + positionController.value = _convert(widget.value); + _actionMap = <Type, Action<Intent>>{ + _AdjustSliderIntent: CallbackAction<_AdjustSliderIntent>(onInvoke: _actionHandler), + }; + if (widget.focusNode == null) { + // Only create a new node if the widget doesn't have one. + _focusNode ??= FocusNode(); + } + } + + @override + void dispose() { + interactionTimer?.cancel(); + overlayController.dispose(); + valueIndicatorController.dispose(); + enableController.dispose(); + positionController.dispose(); + _focusNode?.dispose(); + super.dispose(); + } + + void _handleChanged(double value) { + assert(widget.onChanged != null); + final double lerpValue = _lerp(value); + if (_currentChangedValue != lerpValue) { + _currentChangedValue = lerpValue; + if (_currentChangedValue != widget.value) { + widget.onChanged!(_currentChangedValue!); + } + } + } + + void _handleDragStart(double value) { + setState(() { + _dragging = true; + }); + widget.onChangeStart?.call(_lerp(value)); + } + + void _handleDragEnd(double value) { + setState(() { + _dragging = false; + }); + _currentChangedValue = null; + widget.onChangeEnd?.call(_lerp(value)); + } + + void _actionHandler(_AdjustSliderIntent intent) { + final TextDirection directionality = Directionality.of(_renderObjectKey.currentContext!); + final bool shouldIncrease = switch (intent.type) { + _SliderAdjustmentType.up => true, + _SliderAdjustmentType.down => false, + _SliderAdjustmentType.left => directionality == TextDirection.rtl, + _SliderAdjustmentType.right => directionality == TextDirection.ltr, + }; + + final slider = _renderObjectKey.currentContext!.findRenderObject()! as _RenderSlider; + return shouldIncrease ? slider.increaseAction() : slider.decreaseAction(); + } + + bool _focused = false; + void _handleFocusHighlightChanged(bool focused) { + if (focused != _focused) { + setState(() { + _focused = focused; + }); + } + } + + bool _hovering = false; + void _handleHoverChanged(bool hovering) { + if (hovering != _hovering) { + setState(() { + _hovering = hovering; + }); + } + } + + // Returns a number between min and max, proportional to value, which must + // be between 0.0 and 1.0. + double _lerp(double value) { + assert(value >= 0.0); + assert(value <= 1.0); + return value * (widget.max - widget.min) + widget.min; + } + + double _discretize(double value) { + assert(widget.divisions != null); + assert(value >= 0.0 && value <= 1.0); + + final int divisions = widget.divisions!; + return (value * divisions).round() / divisions; + } + + double _convert(double value) { + double ret = _unlerp(value); + if (widget.divisions != null) { + ret = _discretize(ret); + } + return ret; + } + + // Returns a number between 0.0 and 1.0, given a value between min and max. + double _unlerp(double value) { + assert(value <= widget.max); + assert(value >= widget.min); + return widget.max > widget.min ? (value - widget.min) / (widget.max - widget.min) : 0.0; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMediaQuery(context)); + + switch (widget._sliderType) { + case _SliderType.material: + return _buildMaterialSlider(context); + + case _SliderType.adaptive: + { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return _buildMaterialSlider(context); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return _buildCupertinoSlider(context); + } + } + } + } + + Widget _buildMaterialSlider(BuildContext context) { + final ThemeData theme = Theme.of(context); + SliderThemeData sliderTheme = SliderTheme.of(context); + final bool year2023 = widget.year2023 ?? sliderTheme.year2023 ?? true; + final SliderThemeData defaults = switch (theme.useMaterial3) { + true => year2023 ? _SliderDefaultsM3Year2023(context) : _SliderDefaultsM3(context), + false => _SliderDefaultsM2(context), + }; + + // If the widget has active or inactive colors specified, then we plug them + // in to the slider theme as best we can. If the developer wants more + // control than that, then they need to use a SliderTheme. The default + // colors come from the ThemeData.colorScheme. These colors, along with + // the default shapes and text styles are aligned to the Material + // Guidelines. + + const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; + const SliderInteraction defaultAllowedInteraction = SliderInteraction.tapAndSlide; + + final states = <WidgetState>{ + if (!_enabled) WidgetState.disabled, + if (_hovering) WidgetState.hovered, + if (_focused) WidgetState.focused, + if (_dragging) WidgetState.dragged, + }; + + // The value indicator's color is not the same as the thumb and active track + // (which can be defined by activeColor) if the + // RectangularSliderValueIndicatorShape is used. In all other cases, the + // value indicator is assumed to be the same as the active color. + final SliderComponentShape valueIndicatorShape = + sliderTheme.valueIndicatorShape ?? defaults.valueIndicatorShape!; + final Color valueIndicatorColor; + if (valueIndicatorShape is RectangularSliderValueIndicatorShape) { + valueIndicatorColor = + sliderTheme.valueIndicatorColor ?? + Color.alphaBlend( + theme.colorScheme.onSurface.withOpacity(0.60), + theme.colorScheme.surface.withOpacity(0.90), + ); + } else { + valueIndicatorColor = + widget.activeColor ?? sliderTheme.valueIndicatorColor ?? defaults.valueIndicatorColor!; + } + + Color? effectiveOverlayColor() { + return widget.overlayColor?.resolve(states) ?? + widget.activeColor?.withOpacity(0.12) ?? + WidgetStateProperty.resolveAs<Color?>(sliderTheme.overlayColor, states) ?? + WidgetStateProperty.resolveAs<Color?>(defaults.overlayColor, states); + } + + TextStyle valueIndicatorTextStyle = + sliderTheme.valueIndicatorTextStyle ?? defaults.valueIndicatorTextStyle!; + if (MediaQuery.boldTextOf(context)) { + valueIndicatorTextStyle = valueIndicatorTextStyle.merge( + const TextStyle(fontWeight: FontWeight.bold), + ); + } + + sliderTheme = sliderTheme.copyWith( + trackHeight: sliderTheme.trackHeight ?? defaults.trackHeight, + activeTrackColor: + widget.activeColor ?? sliderTheme.activeTrackColor ?? defaults.activeTrackColor, + inactiveTrackColor: + widget.inactiveColor ?? sliderTheme.inactiveTrackColor ?? defaults.inactiveTrackColor, + secondaryActiveTrackColor: + widget.secondaryActiveColor ?? + sliderTheme.secondaryActiveTrackColor ?? + defaults.secondaryActiveTrackColor, + disabledActiveTrackColor: + sliderTheme.disabledActiveTrackColor ?? defaults.disabledActiveTrackColor, + disabledInactiveTrackColor: + sliderTheme.disabledInactiveTrackColor ?? defaults.disabledInactiveTrackColor, + disabledSecondaryActiveTrackColor: + sliderTheme.disabledSecondaryActiveTrackColor ?? + defaults.disabledSecondaryActiveTrackColor, + activeTickMarkColor: + widget.inactiveColor ?? sliderTheme.activeTickMarkColor ?? defaults.activeTickMarkColor, + inactiveTickMarkColor: + widget.activeColor ?? sliderTheme.inactiveTickMarkColor ?? defaults.inactiveTickMarkColor, + disabledActiveTickMarkColor: + sliderTheme.disabledActiveTickMarkColor ?? defaults.disabledActiveTickMarkColor, + disabledInactiveTickMarkColor: + sliderTheme.disabledInactiveTickMarkColor ?? defaults.disabledInactiveTickMarkColor, + thumbColor: + widget.thumbColor ?? widget.activeColor ?? sliderTheme.thumbColor ?? defaults.thumbColor, + disabledThumbColor: sliderTheme.disabledThumbColor ?? defaults.disabledThumbColor, + overlayColor: effectiveOverlayColor(), + valueIndicatorColor: valueIndicatorColor, + trackShape: sliderTheme.trackShape ?? defaults.trackShape, + tickMarkShape: sliderTheme.tickMarkShape ?? defaults.tickMarkShape, + thumbShape: sliderTheme.thumbShape ?? defaults.thumbShape, + overlayShape: sliderTheme.overlayShape ?? defaults.overlayShape, + valueIndicatorShape: valueIndicatorShape, + showValueIndicator: + widget.showValueIndicator ?? sliderTheme.showValueIndicator ?? defaultShowValueIndicator, + valueIndicatorTextStyle: valueIndicatorTextStyle, + padding: widget.padding ?? sliderTheme.padding, + thumbSize: sliderTheme.thumbSize ?? defaults.thumbSize, + trackGap: sliderTheme.trackGap ?? defaults.trackGap, + ); + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? + sliderTheme.mouseCursor?.resolve(states) ?? + WidgetStateMouseCursor.clickable.resolve(states); + final SliderInteraction effectiveAllowedInteraction = + widget.allowedInteraction ?? sliderTheme.allowedInteraction ?? defaultAllowedInteraction; + + // This size is used as the max bounds for the painting of the value + // indicators It must be kept in sync with the function with the same name + // in range_slider.dart. + Size screenSize() => MediaQuery.sizeOf(context); + + VoidCallback? handleDidGainAccessibilityFocus; + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + break; + case TargetPlatform.windows: + handleDidGainAccessibilityFocus = () { + // Automatically activate the slider when it receives a11y focus. + if (!focusNode.hasFocus && focusNode.canRequestFocus) { + focusNode.requestFocus(); + } + }; + } + + final Map<ShortcutActivator, Intent> shortcutMap = switch (MediaQuery.navigationModeOf( + context, + )) { + NavigationMode.directional => _directionalNavShortcutMap, + NavigationMode.traditional => _traditionalNavShortcutMap, + }; + + final double fontSize = sliderTheme.valueIndicatorTextStyle?.fontSize ?? kDefaultFontSize; + final double fontSizeToScale = fontSize == 0.0 ? kDefaultFontSize : fontSize; + final TextScaler textScaler = theme.useMaterial3 + // TODO(tahatesser): This is an eye-balled value. + // This needs to be updated when accessibility + // guidelines are available on the material specs page + // https://m3.material.io/components/sliders/accessibility. + ? MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.3) + : MediaQuery.textScalerOf(context); + final double effectiveTextScale = textScaler.scale(fontSizeToScale) / fontSizeToScale; + + Widget result = CompositedTransformTarget( + link: _layerLink, + child: _SliderRenderObjectWidget( + key: _renderObjectKey, + value: _convert(widget.value), + secondaryTrackValue: (widget.secondaryTrackValue != null) + ? _convert(widget.secondaryTrackValue!) + : null, + divisions: widget.divisions, + label: widget.label, + sliderTheme: sliderTheme, + textScaleFactor: effectiveTextScale, + screenSize: screenSize(), + onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, + onChangeStart: _handleDragStart, + onChangeEnd: _handleDragEnd, + state: this, + semanticFormatterCallback: widget.semanticFormatterCallback, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + hasFocus: _focused, + hovering: _hovering, + allowedInteraction: effectiveAllowedInteraction, + ), + ); + + final EdgeInsetsGeometry? padding = widget.padding ?? sliderTheme.padding; + if (padding != null) { + result = Padding(padding: padding, child: result); + } + result = OverlayPortal( + controller: _valueIndicatorOverlayPortalController, + overlayChildBuilder: (BuildContext context) { + return _buildValueIndicator(sliderTheme.showValueIndicator!); + }, + child: result, + ); + + return FocusableActionDetector( + actions: _actionMap, + shortcuts: shortcutMap, + focusNode: focusNode, + autofocus: widget.autofocus, + enabled: _enabled, + onShowFocusHighlight: _handleFocusHighlightChanged, + onShowHoverHighlight: _handleHoverChanged, + mouseCursor: effectiveMouseCursor, + includeFocusSemantics: false, + child: result, + ); + } + + Widget _buildCupertinoSlider(BuildContext context) { + // The render box of a slider has a fixed height but takes up the available + // width. Wrapping the [CupertinoSlider] in this manner will help maintain + // the same size. + return SizedBox( + width: double.infinity, + child: CupertinoSlider( + value: widget.value, + onChanged: widget.onChanged, + onChangeStart: widget.onChangeStart, + onChangeEnd: widget.onChangeEnd, + min: widget.min, + max: widget.max, + divisions: widget.divisions, + activeColor: widget.activeColor, + thumbColor: widget.thumbColor ?? CupertinoColors.white, + ), + ); + } + + final LayerLink _layerLink = LayerLink(); + Widget _buildValueIndicator(ShowValueIndicator showValueIndicator) { + final Widget valueIndicator = CompositedTransformFollower( + link: _layerLink, + child: _ValueIndicatorRenderObjectWidget(state: this), + ); + return switch (showValueIndicator) { + ShowValueIndicator.never => const SizedBox.shrink(), + ShowValueIndicator.onlyForDiscrete => + widget.divisions != null ? valueIndicator : const SizedBox.shrink(), + ShowValueIndicator.onlyForContinuous => + widget.divisions == null ? valueIndicator : const SizedBox.shrink(), + ShowValueIndicator.alwaysVisible || + ShowValueIndicator.always || + ShowValueIndicator.onDrag => valueIndicator, + }; + } +} + +class _SliderRenderObjectWidget extends LeafRenderObjectWidget { + const _SliderRenderObjectWidget({ + super.key, + required this.value, + required this.secondaryTrackValue, + required this.divisions, + required this.label, + required this.sliderTheme, + required this.textScaleFactor, + required this.screenSize, + required this.onChanged, + required this.onChangeStart, + required this.onChangeEnd, + required this.state, + required this.semanticFormatterCallback, + required this.onDidGainAccessibilityFocus, + required this.hasFocus, + required this.hovering, + required this.allowedInteraction, + }); + + final double value; + final double? secondaryTrackValue; + final int? divisions; + final String? label; + final SliderThemeData sliderTheme; + final double textScaleFactor; + final Size screenSize; + final ValueChanged<double>? onChanged; + final ValueChanged<double>? onChangeStart; + final ValueChanged<double>? onChangeEnd; + final SemanticFormatterCallback? semanticFormatterCallback; + final VoidCallback? onDidGainAccessibilityFocus; + final _SliderState state; + final bool hasFocus; + final bool hovering; + final SliderInteraction allowedInteraction; + + @override + _RenderSlider createRenderObject(BuildContext context) { + return _RenderSlider( + value: value, + secondaryTrackValue: secondaryTrackValue, + divisions: divisions, + label: label, + sliderTheme: sliderTheme, + textScaleFactor: textScaleFactor, + screenSize: screenSize, + onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, + state: state, + textDirection: Directionality.of(context), + semanticFormatterCallback: semanticFormatterCallback, + onDidGainAccessibilityFocus: onDidGainAccessibilityFocus, + platform: Theme.of(context).platform, + hasFocus: hasFocus, + hovering: hovering, + gestureSettings: MediaQuery.gestureSettingsOf(context), + allowedInteraction: allowedInteraction, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSlider renderObject) { + renderObject + // We should update the `divisions` ahead of `value`, because the `value` + // setter dependent on the `divisions`. + ..divisions = divisions + ..value = value + ..secondaryTrackValue = secondaryTrackValue + ..label = label + ..sliderTheme = sliderTheme + ..textScaleFactor = textScaleFactor + ..screenSize = screenSize + ..onChanged = onChanged + ..onChangeStart = onChangeStart + ..onChangeEnd = onChangeEnd + ..textDirection = Directionality.of(context) + ..semanticFormatterCallback = semanticFormatterCallback + ..onDidGainAccessibilityFocus = onDidGainAccessibilityFocus + ..platform = Theme.of(context).platform + ..hasFocus = hasFocus + ..hovering = hovering + ..gestureSettings = MediaQuery.gestureSettingsOf(context) + ..allowedInteraction = allowedInteraction; + // Ticker provider cannot change since there's a 1:1 relationship between + // the _SliderRenderObjectWidget object and the _SliderState object. + } +} + +class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderSlider({ + required double value, + required double? secondaryTrackValue, + required int? divisions, + required String? label, + required SliderThemeData sliderTheme, + required double textScaleFactor, + required Size screenSize, + required TargetPlatform platform, + required ValueChanged<double>? onChanged, + required SemanticFormatterCallback? semanticFormatterCallback, + required this.onDidGainAccessibilityFocus, + required this.onChangeStart, + required this.onChangeEnd, + required _SliderState state, + required TextDirection textDirection, + required bool hasFocus, + required bool hovering, + required DeviceGestureSettings gestureSettings, + required SliderInteraction allowedInteraction, + }) : assert(value >= 0.0 && value <= 1.0), + assert( + secondaryTrackValue == null || (secondaryTrackValue >= 0.0 && secondaryTrackValue <= 1.0), + ), + _platform = platform, + _semanticFormatterCallback = semanticFormatterCallback, + _label = label, + _value = value, + _secondaryTrackValue = secondaryTrackValue, + _divisions = divisions, + _sliderTheme = sliderTheme, + _textScaleFactor = textScaleFactor, + _screenSize = screenSize, + _onChanged = onChanged, + _state = state, + _textDirection = textDirection, + _hasFocus = hasFocus, + _hovering = hovering, + _allowedInteraction = allowedInteraction { + _updateLabelPainter(); + final team = GestureArenaTeam(); + _drag = HorizontalDragGestureRecognizer() + ..team = team + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..onCancel = _endInteraction + ..gestureSettings = gestureSettings; + _tap = TapGestureRecognizer() + ..team = team + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..gestureSettings = gestureSettings; + _overlayAnimation = CurvedAnimation( + parent: _state.overlayController, + curve: Curves.fastOutSlowIn, + ); + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + _enableAnimation = CurvedAnimation(parent: _state.enableController, curve: Curves.easeInOut); + } + static const Duration _positionAnimationDuration = Duration(milliseconds: 75); + static const Duration _minimumInteractionTime = Duration(milliseconds: 500); + + // This value is the touch target, 48, multiplied by 3. + static const double _minPreferredTrackWidth = 144.0; + + // Compute the largest width and height needed to paint the slider shapes, + // other than the track shape. It is assumed that these shapes are vertically + // centered on the track. + double get _maxSliderPartWidth => + _sliderPartSizes.map((Size size) => size.width).reduce(math.max); + double get _maxSliderPartHeight => + _sliderPartSizes.map((Size size) => size.height).reduce(math.max); + double get _thumbSizeHeight => + _sliderTheme.thumbShape!.getPreferredSize(isInteractive, isDiscrete).height; + double get _overlayHeight => + _sliderTheme.overlayShape!.getPreferredSize(isInteractive, isDiscrete).height; + List<Size> get _sliderPartSizes => <Size>[ + Size( + _sliderTheme.overlayShape!.getPreferredSize(isInteractive, isDiscrete).width, + _sliderTheme.padding != null ? _thumbSizeHeight : _overlayHeight, + ), + _sliderTheme.thumbShape!.getPreferredSize(isInteractive, isDiscrete), + _sliderTheme.tickMarkShape!.getPreferredSize( + isEnabled: isInteractive, + sliderTheme: sliderTheme, + ), + ]; + double get _minPreferredTrackHeight => _sliderTheme.trackHeight!; + + final _SliderState _state; + late CurvedAnimation _overlayAnimation; + late CurvedAnimation _valueIndicatorAnimation; + late CurvedAnimation _enableAnimation; + final TextPainter _labelPainter = TextPainter(); + late HorizontalDragGestureRecognizer _drag; + late TapGestureRecognizer _tap; + bool _active = false; + VoidCallback? onDidGainAccessibilityFocus; + double _currentDragValue = 0.0; + Rect? overlayRect; + + // This rect is used in gesture calculations, where the gesture coordinates + // are relative to the sliders origin. Therefore, the offset is passed as + // (0,0). + Rect get _trackRect => _sliderTheme.trackShape!.getPreferredRect( + parentBox: this, + sliderTheme: _sliderTheme, + isDiscrete: false, + ); + + bool get isInteractive => onChanged != null; + + bool get isDiscrete => divisions != null && divisions! > 0; + + double get value => _value; + double _value; + set value(double newValue) { + assert(newValue >= 0.0 && newValue <= 1.0); + final double convertedValue = isDiscrete ? _discretize(newValue) : newValue; + if (convertedValue == _value) { + return; + } + _value = convertedValue; + if (isDiscrete) { + // Reset the duration to match the distance that we're traveling, so that + // whatever the distance, we still do it in _positionAnimationDuration, + // and if we get re-targeted in the middle, it still takes that long to + // get to the new location. + final double distance = (_value - _state.positionController.value).abs(); + _state.positionController.duration = distance != 0.0 + ? _positionAnimationDuration * (1.0 / distance) + : Duration.zero; + _state.positionController.animateTo(convertedValue, curve: Curves.easeInOut); + } else { + _state.positionController.value = convertedValue; + } + markNeedsSemanticsUpdate(); + } + + double? get secondaryTrackValue => _secondaryTrackValue; + double? _secondaryTrackValue; + set secondaryTrackValue(double? newValue) { + assert(newValue == null || (newValue >= 0.0 && newValue <= 1.0)); + if (newValue == _secondaryTrackValue) { + return; + } + _secondaryTrackValue = newValue; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + DeviceGestureSettings? get gestureSettings => _drag.gestureSettings; + set gestureSettings(DeviceGestureSettings? gestureSettings) { + _drag.gestureSettings = gestureSettings; + _tap.gestureSettings = gestureSettings; + } + + TargetPlatform _platform; + TargetPlatform get platform => _platform; + set platform(TargetPlatform value) { + if (_platform == value) { + return; + } + _platform = value; + markNeedsSemanticsUpdate(); + } + + SemanticFormatterCallback? _semanticFormatterCallback; + SemanticFormatterCallback? get semanticFormatterCallback => _semanticFormatterCallback; + set semanticFormatterCallback(SemanticFormatterCallback? value) { + if (_semanticFormatterCallback == value) { + return; + } + _semanticFormatterCallback = value; + markNeedsSemanticsUpdate(); + } + + int? get divisions => _divisions; + int? _divisions; + set divisions(int? value) { + if (value == _divisions) { + return; + } + _divisions = value; + markNeedsPaint(); + } + + String? get label => _label; + String? _label; + set label(String? value) { + if (value == _label) { + return; + } + _label = value; + _updateLabelPainter(); + } + + SliderThemeData get sliderTheme => _sliderTheme; + SliderThemeData _sliderTheme; + set sliderTheme(SliderThemeData value) { + if (value == _sliderTheme) { + return; + } + _sliderTheme = value; + _updateLabelPainter(); + } + + double get textScaleFactor => _textScaleFactor; + double _textScaleFactor; + set textScaleFactor(double value) { + if (value == _textScaleFactor) { + return; + } + _textScaleFactor = value; + _updateLabelPainter(); + } + + Size get screenSize => _screenSize; + Size _screenSize; + set screenSize(Size value) { + if (value == _screenSize) { + return; + } + _screenSize = value; + markNeedsPaint(); + } + + ValueChanged<double>? get onChanged => _onChanged; + ValueChanged<double>? _onChanged; + set onChanged(ValueChanged<double>? value) { + if (value == _onChanged) { + return; + } + final bool wasInteractive = isInteractive; + _onChanged = value; + if (wasInteractive != isInteractive) { + if (isInteractive) { + _state.enableController.forward(); + } else { + _state.enableController.reverse(); + } + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + } + + ValueChanged<double>? onChangeStart; + ValueChanged<double>? onChangeEnd; + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + _updateLabelPainter(); + } + + /// True if this slider has the input focus. + bool get hasFocus => _hasFocus; + bool _hasFocus; + set hasFocus(bool value) { + if (value == _hasFocus) { + return; + } + _hasFocus = value; + _updateForFocus(_hasFocus); + markNeedsSemanticsUpdate(); + } + + /// True if this slider is being hovered over by a pointer. + bool get hovering => _hovering; + bool _hovering; + set hovering(bool value) { + if (value == _hovering) { + return; + } + _hovering = value; + _updateForHover(_hovering); + } + + /// True if the slider is interactive and the slider thumb is being + /// hovered over by a pointer. + bool _hoveringThumb = false; + bool get hoveringThumb => _hoveringThumb; + set hoveringThumb(bool value) { + if (value == _hoveringThumb) { + return; + } + _hoveringThumb = value; + _updateForHover(_hovering); + } + + SliderInteraction _allowedInteraction; + SliderInteraction get allowedInteraction => _allowedInteraction; + set allowedInteraction(SliderInteraction value) { + if (value == _allowedInteraction) { + return; + } + _allowedInteraction = value; + markNeedsSemanticsUpdate(); + } + + void _updateForFocus(bool focused) { + if (focused) { + _state.overlayController.forward(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.forward(); + } + } else { + _state.overlayController.reverse(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.reverse(); + } + } + } + + void _updateForHover(bool hovered) { + // Only show overlay when pointer is hovering the thumb. + if (hovered && hoveringThumb) { + _state.overlayController.forward(); + } else { + // Only remove overlay when Slider is inactive and unfocused. + if (!_active && !hasFocus) { + _state.overlayController.reverse(); + } + } + } + + bool get shouldAlwaysShowValueIndicator => + _sliderTheme.showValueIndicator == ShowValueIndicator.alwaysVisible; + bool get shouldShowValueIndicatorWhenDragged => switch (_sliderTheme.showValueIndicator!) { + ShowValueIndicator.onlyForDiscrete => isDiscrete, + ShowValueIndicator.onlyForContinuous => !isDiscrete, + ShowValueIndicator.always || ShowValueIndicator.onDrag => true, + ShowValueIndicator.never || ShowValueIndicator.alwaysVisible => false, + }; + + double get _adjustmentUnit { + switch (_platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // Matches iOS implementation of material slider. + return 0.1; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Matches Android implementation of material slider. + return 0.05; + } + } + + void _updateLabelPainter() { + if (label != null) { + _labelPainter + ..text = TextSpan(style: _sliderTheme.valueIndicatorTextStyle, text: label) + ..textDirection = textDirection + ..textScaleFactor = textScaleFactor + ..layout(); + } else { + _labelPainter.text = null; + } + // Changing the textDirection can result in the layout changing, because the + // bidi algorithm might line up the glyphs differently which can result in + // different ligatures, different shapes, etc. So we always markNeedsLayout. + markNeedsLayout(); + } + + @override + void systemFontsDidChange() { + super.systemFontsDidChange(); + _labelPainter.markNeedsLayout(); + _updateLabelPainter(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _overlayAnimation.addListener(markNeedsPaint); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _enableAnimation.addListener(markNeedsPaint); + _state.positionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _overlayAnimation.removeListener(markNeedsPaint); + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _enableAnimation.removeListener(markNeedsPaint); + _state.positionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void dispose() { + _drag.dispose(); + _tap.dispose(); + _labelPainter.dispose(); + _enableAnimation.dispose(); + _valueIndicatorAnimation.dispose(); + _overlayAnimation.dispose(); + super.dispose(); + } + + double _getValueFromVisualPosition(double visualPosition) { + return switch (textDirection) { + TextDirection.rtl => 1.0 - visualPosition, + TextDirection.ltr => visualPosition, + }; + } + + double _getValueFromGlobalPosition(Offset globalPosition) { + final double visualPosition = + (globalToLocal(globalPosition).dx - _trackRect.left) / _trackRect.width; + return _getValueFromVisualPosition(visualPosition); + } + + double _discretize(double value) { + double result = clampDouble(value, 0.0, 1.0); + if (isDiscrete) { + result = (result * divisions!).round() / divisions!; + } + return result; + } + + void _startInteraction(Offset globalPosition) { + if (!_state.mounted) { + return; + } + if (!_active && isInteractive) { + switch (allowedInteraction) { + case SliderInteraction.tapAndSlide: + case SliderInteraction.tapOnly: + _active = true; + _currentDragValue = _getValueFromGlobalPosition(globalPosition); + case SliderInteraction.slideThumb: + if (_isPointerOnOverlay(globalPosition)) { + _active = true; + _currentDragValue = value; + } + case SliderInteraction.slideOnly: + _active = true; + _currentDragValue = value; + } + + if (_active) { + // We supply the *current* value as the start location, so that if we have + // a tap, it consists of a call to onChangeStart with the previous value and + // a call to onChangeEnd with the new value. + onChangeStart?.call(_discretize(value)); + onChanged!(_discretize(_currentDragValue)); + _state.overlayController.forward(); + if (shouldShowValueIndicatorWhenDragged) { + _state.valueIndicatorController.forward(); + _state.interactionTimer?.cancel(); + _state.interactionTimer = Timer(_minimumInteractionTime * timeDilation, () { + _state.interactionTimer = null; + if (!_active && _state.valueIndicatorController.isCompleted) { + _state.valueIndicatorController.reverse(); + } + }); + } + } + } + } + + void _endInteraction() { + if (!_state.mounted) { + return; + } + + if (_active && _state.mounted) { + onChangeEnd?.call(_discretize(_currentDragValue)); + _active = false; + _currentDragValue = 0.0; + _state.overlayController.reverse(); + if (shouldShowValueIndicatorWhenDragged && _state.interactionTimer == null) { + _state.valueIndicatorController.reverse(); + } + } + } + + void _handleDragStart(DragStartDetails details) { + _startInteraction(details.globalPosition); + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_state.mounted) { + return; + } + + switch (allowedInteraction) { + case SliderInteraction.tapAndSlide: + case SliderInteraction.slideOnly: + case SliderInteraction.slideThumb: + if (_active && isInteractive) { + final double valueDelta = details.primaryDelta! / _trackRect.width; + _currentDragValue += switch (textDirection) { + TextDirection.rtl => -valueDelta, + TextDirection.ltr => valueDelta, + }; + onChanged!(_discretize(_currentDragValue)); + } + case SliderInteraction.tapOnly: + // cannot slide (drag) as its tapOnly. + break; + } + } + + void _handleDragEnd(DragEndDetails details) { + _endInteraction(); + } + + void _handleTapDown(TapDownDetails details) { + _startInteraction(details.globalPosition); + } + + void _handleTapUp(TapUpDetails details) { + _endInteraction(); + } + + bool _isPointerOnOverlay(Offset globalPosition) { + return overlayRect!.contains(globalToLocal(globalPosition)); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + if (!_state.mounted) { + return; + } + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent && isInteractive) { + // We need to add the drag first so that it has priority. + _drag.addPointer(event); + _tap.addPointer(event); + } + if (isInteractive && overlayRect != null) { + hoveringThumb = overlayRect!.contains(event.localPosition); + } + } + + @override + double computeMinIntrinsicWidth(double height) => _minPreferredTrackWidth + _maxSliderPartWidth; + + @override + double computeMaxIntrinsicWidth(double height) => _minPreferredTrackWidth + _maxSliderPartWidth; + + @override + double computeMinIntrinsicHeight(double width) => + math.max(_minPreferredTrackHeight, _maxSliderPartHeight); + + @override + double computeMaxIntrinsicHeight(double width) => + math.max(_minPreferredTrackHeight, _maxSliderPartHeight); + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return Size( + constraints.hasBoundedWidth + ? constraints.maxWidth + : _minPreferredTrackWidth + _maxSliderPartWidth, + constraints.hasBoundedHeight + ? constraints.maxHeight + : math.max(_minPreferredTrackHeight, _maxSliderPartHeight), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final double controllerValue = _state.positionController.value; + + // The visual position is the position of the thumb from 0 to 1 from left + // to right. In left to right, this is the same as the value, but it is + // reversed for right to left text. + final (double visualPosition, double? secondaryVisualPosition) = switch (textDirection) { + TextDirection.rtl when _secondaryTrackValue == null => (1.0 - controllerValue, null), + TextDirection.rtl => (1.0 - controllerValue, 1.0 - _secondaryTrackValue!), + TextDirection.ltr => (controllerValue, _secondaryTrackValue), + }; + + final Rect trackRect = _sliderTheme.trackShape!.getPreferredRect( + parentBox: this, + offset: offset, + sliderTheme: _sliderTheme, + isDiscrete: isDiscrete, + ); + + final Offset thumbCenter = _calcThumbCenter( + trackRect: trackRect, + visualPosition: visualPosition, + ); + + if (isInteractive) { + final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isInteractive, false); + overlayRect = Rect.fromCircle(center: thumbCenter, radius: overlaySize.width / 2.0); + } + final Offset? secondaryOffset = (secondaryVisualPosition != null) + ? Offset(trackRect.left + secondaryVisualPosition * trackRect.width, trackRect.center.dy) + : null; + + // If [Slider.year2023] is false, the thumb uses handle thumb shape and gapped track shape. + // The handle width and track gap are adjusted when the thumb is pressed. + double? thumbWidth = _sliderTheme.thumbSize?.resolve(<WidgetState>{})?.width; + final double? thumbHeight = _sliderTheme.thumbSize?.resolve(<WidgetState>{})?.height; + double? trackGap = _sliderTheme.trackGap; + final double? pressedThumbWidth = _sliderTheme.thumbSize?.resolve(<WidgetState>{ + WidgetState.pressed, + })?.width; + final double delta; + if (_active && thumbWidth != null && pressedThumbWidth != null && trackGap != null) { + delta = thumbWidth - pressedThumbWidth; + if (thumbWidth > 0.0) { + thumbWidth = pressedThumbWidth; + } + if (trackGap > 0.0) { + trackGap = trackGap - delta / 2; + } + } + + _sliderTheme.trackShape!.paint( + context, + offset, + parentBox: this, + sliderTheme: _sliderTheme.copyWith(trackGap: trackGap), + enableAnimation: _enableAnimation, + textDirection: _textDirection, + thumbCenter: thumbCenter, + secondaryOffset: secondaryOffset, + isDiscrete: isDiscrete, + isEnabled: isInteractive, + ); + + if (!_overlayAnimation.isDismissed) { + _sliderTheme.overlayShape!.paint( + context, + thumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: _textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + } + + if (isDiscrete) { + final double tickMarkWidth = _sliderTheme.tickMarkShape! + .getPreferredSize(isEnabled: isInteractive, sliderTheme: _sliderTheme) + .width; + final double discreteTrackPadding = trackRect.height; + final double adjustedTrackWidth = trackRect.width - discreteTrackPadding; + // If the tick marks would be too dense, don't bother painting them. + if (adjustedTrackWidth / divisions! >= 3.0 * tickMarkWidth) { + final double dy = trackRect.center.dy; + for (var i = 0; i <= divisions!; i++) { + final double value = i / divisions!; + // The ticks are mapped to be within the track, so the tick mark width + // must be subtracted from the track width. + final double dx = trackRect.left + value * adjustedTrackWidth + discreteTrackPadding / 2; + final tickMarkOffset = Offset(dx, dy); + _sliderTheme.tickMarkShape!.paint( + context, + tickMarkOffset, + parentBox: this, + sliderTheme: _sliderTheme, + enableAnimation: _enableAnimation, + textDirection: _textDirection, + thumbCenter: thumbCenter, + isEnabled: isInteractive, + ); + } + } + } + + if (isInteractive && + label != null && + ((shouldShowValueIndicatorWhenDragged && !_valueIndicatorAnimation.isDismissed) || + shouldAlwaysShowValueIndicator)) { + _state.paintValueIndicator = (PaintingContext context, Offset offset) { + if (attached && _labelPainter.text != null) { + _sliderTheme.valueIndicatorShape?.paint( + context, + offset + thumbCenter, + activationAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation<double>(1) + : _valueIndicatorAnimation, + enableAnimation: shouldAlwaysShowValueIndicator + ? const AlwaysStoppedAnimation<double>(1) + : _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + } + }; + } else { + _state.paintValueIndicator = null; + } + + _sliderTheme.thumbShape!.paint( + context, + thumbCenter, + activationAnimation: _overlayAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: thumbWidth != null && thumbHeight != null + ? _sliderTheme.copyWith( + thumbSize: WidgetStatePropertyAll<Size?>(Size(thumbWidth, thumbHeight)), + ) + : _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + } + + /// Calculates the local coordinate center of the [Slider] thumb given its + /// physical placement on the track from 0.0 (left) to 1.0 (right). + /// + /// The [visualPosition] is provided by the caller so semantics can use the + /// raw logical value while paint can use the smoothly animated value. + Offset _calcThumbCenter({required Rect trackRect, required double visualPosition}) { + final double padding = _sliderTheme.trackShape!.isRounded ? trackRect.height : 0.0; + final double thumbPosition = isDiscrete + ? trackRect.left + visualPosition * (trackRect.width - padding) + padding / 2 + : trackRect.left + visualPosition * trackRect.width; + // Apply padding to trackRect.left and trackRect.right if the track height is + // greater than the thumb radius to ensure the thumb is drawn within the track. + final Size thumbPreferredSize = _sliderTheme.thumbShape!.getPreferredSize( + isInteractive, + isDiscrete, + ); + final double thumbPadding = padding > thumbPreferredSize.width / 2 ? padding / 2 : 0; + return Offset( + clampDouble(thumbPosition, trackRect.left + thumbPadding, trackRect.right - thumbPadding), + trackRect.center.dy, + ); + } + + Offset get _semanticThumbCenter { + final double visualPosition = switch (textDirection) { + TextDirection.rtl => 1.0 - _value, + TextDirection.ltr => _value, + }; + return _calcThumbCenter(trackRect: _trackRect, visualPosition: visualPosition); + } + + @override + void assembleSemanticsNode( + SemanticsNode node, + SemanticsConfiguration config, + Iterable<SemanticsNode> children, + ) { + node.rect = Rect.fromCenter( + center: _semanticThumbCenter, + width: kMinInteractiveDimension, + height: kMinInteractiveDimension, + ); + + node.updateWith(config: config); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + + // The Slider widget has its own Focus widget. + // We mark the Focus widget with "includeFocusSemantics: false" + // and we want that semantics node to collect the semantics information here + // so that it's all in the same node. + config.isSemanticBoundary = true; + + config.isEnabled = isInteractive; + if (label != null) { + config.label = label!; + } + config.isSlider = true; + config.isFocusable = isInteractive; + config.isFocused = hasFocus; + + if (onDidGainAccessibilityFocus != null) { + config.onDidGainAccessibilityFocus = onDidGainAccessibilityFocus; + } + config.textDirection = textDirection; + if (isInteractive) { + config.onIncrease = increaseAction; + config.onDecrease = decreaseAction; + config.onFocus = onFocusAction; + } + + if (semanticFormatterCallback != null) { + config.value = semanticFormatterCallback!(_state._lerp(value)); + config.increasedValue = semanticFormatterCallback!( + _state._lerp(clampDouble(value + _semanticActionUnit, 0.0, 1.0)), + ); + config.decreasedValue = semanticFormatterCallback!( + _state._lerp(clampDouble(value - _semanticActionUnit, 0.0, 1.0)), + ); + } else { + config.value = '${(value * 100).round()}%'; + config.increasedValue = + '${(clampDouble(value + _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; + config.decreasedValue = + '${(clampDouble(value - _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; + } + } + + double get _semanticActionUnit => divisions != null ? 1.0 / divisions! : _adjustmentUnit; + + void onFocusAction() { + if (isInteractive) { + if (!_state.mounted) { + return; + } + if (!hasFocus) { + _state.focusNode.requestFocus(); + } + } + } + + void increaseAction() { + if (isInteractive) { + onChangeStart!(currentValue); + final double increase = increaseValue(); + onChanged!(increase); + onChangeEnd!(increase); + if (!_state.mounted) { + return; + } + } + } + + void decreaseAction() { + if (isInteractive) { + onChangeStart!(currentValue); + final double decrease = decreaseValue(); + onChanged!(decrease); + onChangeEnd!(decrease); + if (!_state.mounted) { + return; + } + } + } + + double get currentValue { + return clampDouble(value, 0.0, 1.0); + } + + double increaseValue() { + return clampDouble(value + _semanticActionUnit, 0.0, 1.0); + } + + double decreaseValue() { + return clampDouble(value - _semanticActionUnit, 0.0, 1.0); + } +} + +class _AdjustSliderIntent extends Intent { + const _AdjustSliderIntent({required this.type}); + + const _AdjustSliderIntent.right() : type = _SliderAdjustmentType.right; + + const _AdjustSliderIntent.left() : type = _SliderAdjustmentType.left; + + const _AdjustSliderIntent.up() : type = _SliderAdjustmentType.up; + + const _AdjustSliderIntent.down() : type = _SliderAdjustmentType.down; + + final _SliderAdjustmentType type; +} + +enum _SliderAdjustmentType { right, left, up, down } + +class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { + const _ValueIndicatorRenderObjectWidget({required this.state}); + + final _SliderState state; + + @override + _RenderValueIndicator createRenderObject(BuildContext context) { + return _RenderValueIndicator(state: state); + } + + @override + void updateRenderObject(BuildContext context, _RenderValueIndicator renderObject) { + renderObject._state = state; + } +} + +class _RenderValueIndicator extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderValueIndicator({required _SliderState state}) : _state = state { + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + } + late CurvedAnimation _valueIndicatorAnimation; + _SliderState _state; + + @override + bool get sizedByParent => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _state.positionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _state.positionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _state.paintValueIndicator?.call(context, offset); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.smallest; + } + + @override + void dispose() { + _valueIndicatorAnimation.dispose(); + super.dispose(); + } +} + +class _SliderDefaultsM2 extends SliderThemeData { + _SliderDefaultsM2(this.context) : super(trackHeight: 4.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final SliderThemeData sliderTheme = SliderTheme.of(context); + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.primary.withOpacity(0.24); + + @override + Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.32); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.54); + + @override + Color? get inactiveTickMarkColor => _colors.primary.withOpacity(0.54); + + @override + Color? get disabledActiveTickMarkColor => _colors.onPrimary.withOpacity(0.12); + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => + Color.alphaBlend(_colors.onSurface.withOpacity(.38), _colors.surface); + + @override + Color? get overlayColor => _colors.primary.withOpacity(0.12); + + @override + TextStyle? get valueIndicatorTextStyle => + Theme.of(context).textTheme.bodyLarge!.copyWith(color: _colors.onPrimary); + + @override + Color? get valueIndicatorColor { + if (sliderTheme.valueIndicatorShape is RoundedRectSliderValueIndicatorShape) { + return _colors.inverseSurface; + } + return _colors.primary; + } + + @override + SliderComponentShape? get valueIndicatorShape => const RectangularSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const RoundSliderThumbShape(); + + @override + SliderTrackShape? get trackShape => const RoundedRectSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(); +} + +class _SliderDefaultsM3Year2023 extends SliderThemeData { + _SliderDefaultsM3Year2023(this.context) : super(trackHeight: 4.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.surfaceContainerHighest; + + @override + Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38); + + @override + Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38); + + @override + Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => + Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface); + + @override + Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.dragged)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + + return Colors.transparent; + }); + + @override + TextStyle? get valueIndicatorTextStyle => + Theme.of(context).textTheme.labelMedium!.copyWith(color: _colors.onPrimary); + + @override + Color? get valueIndicatorColor => _colors.primary; + + @override + SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const RoundSliderThumbShape(); + + @override + SliderTrackShape? get trackShape => const RoundedRectSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Slider + +// 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. + +// dart format off +class _SliderDefaultsM3 extends SliderThemeData { + _SliderDefaultsM3(this.context) + : super(trackHeight: 16.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.secondaryContainer; + + @override + Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(1.0); + + @override + Color? get inactiveTickMarkColor => _colors.onSecondaryContainer.withOpacity(1.0); + + @override + Color? get disabledActiveTickMarkColor => _colors.onInverseSurface; + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface; + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.dragged)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + + return Colors.transparent; + }); + + @override + TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelLarge!.copyWith( + color: _colors.onInverseSurface, + ); + + @override + Color? get valueIndicatorColor => _colors.inverseSurface; + + @override + SliderComponentShape? get valueIndicatorShape => const RoundedRectSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const HandleThumbShape(); + + @override + SliderTrackShape? get trackShape => const GappedSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(tickMarkRadius: 4.0 / 2); + + @override + WidgetStateProperty<Size?>? get thumbSize { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const Size(4.0, 44.0); + } + if (states.contains(WidgetState.hovered)) { + return const Size(4.0, 44.0); + } + if (states.contains(WidgetState.focused)) { + return const Size(2.0, 44.0); + } + if (states.contains(WidgetState.pressed)) { + return const Size(2.0, 44.0); + } + return const Size(4.0, 44.0); + }); + } + + @override + double? get trackGap => 6.0; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Slider diff --git a/packages/material_ui/lib/src/slider_parts.dart b/packages/material_ui/lib/src/slider_parts.dart new file mode 100644 index 000000000000..04d3980188d8 --- /dev/null +++ b/packages/material_ui/lib/src/slider_parts.dart @@ -0,0 +1,1444 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'slider.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'slider.dart'; +import 'slider_theme.dart'; +import 'slider_value_indicator_shape.dart'; +import 'theme.dart'; + +/// Base class for [Slider] tick mark shapes. +/// +/// Create a subclass of this if you would like a custom slider tick mark shape. +/// +/// The tick mark painting can be skipped by specifying [noTickMark] for +/// [SliderThemeData.tickMarkShape]. +/// +/// See also: +/// +/// * [RoundSliderTickMarkShape], which is the default [Slider]'s tick mark +/// shape that paints a solid circle. +/// * [SliderTrackShape], which can be used to create custom shapes for the +/// [Slider]'s track. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +abstract class SliderTickMarkShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const SliderTickMarkShape(); + + /// Returns the preferred size of the shape. + /// + /// It is used to help position the tick marks within the slider. + /// + /// {@macro flutter.material.SliderComponentShape.paint.sliderTheme} + /// + /// {@template flutter.material.SliderTickMarkShape.getPreferredSize.isEnabled} + /// The `isEnabled` argument is false when [Slider.onChanged] is null and true + /// otherwise. When true, the slider will respond to input. + /// {@endtemplate} + Size getPreferredSize({required SliderThemeData sliderTheme, required bool isEnabled}); + + /// Paints the slider track. + /// + /// {@macro flutter.material.SliderComponentShape.paint.context} + /// + /// {@macro flutter.material.SliderComponentShape.paint.center} + /// + /// {@macro flutter.material.SliderComponentShape.paint.parentBox} + /// + /// {@macro flutter.material.SliderComponentShape.paint.sliderTheme} + /// + /// {@macro flutter.material.SliderComponentShape.paint.enableAnimation} + /// + /// {@macro flutter.material.SliderTickMarkShape.getPreferredSize.isEnabled} + /// + /// The `textDirection` argument can be used to determine how the tick marks + /// are painting depending on whether they are on an active track segment or + /// not. The track segment between the start of the slider and the thumb is + /// the active track segment. The track segment between the thumb and the end + /// of the slider is the inactive track segment. In LTR text direction, the + /// start of the slider is on the left, and in RTL text direction, the start + /// of the slider is on the right. + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset thumbCenter, + required bool isEnabled, + required TextDirection textDirection, + }); + + /// Special instance of [SliderTickMarkShape] to skip the tick mark painting. + /// + /// See also: + /// + /// * [SliderThemeData.tickMarkShape], which is the shape that the [Slider] + /// uses when painting tick marks. + static final SliderTickMarkShape noTickMark = _EmptySliderTickMarkShape(); +} + +/// Base class for slider track shapes. +/// +/// The slider's thumb moves along the track. A discrete slider's tick marks +/// are drawn after the track, but before the thumb, and are aligned with the +/// track. +/// +/// The [getPreferredRect] helps position the slider thumb and tick marks +/// relative to the track. +/// +/// See also: +/// +/// * [RoundedRectSliderTrackShape] for the default [Slider]'s track shape that +/// paints a stadium-like track. +/// * [SliderTickMarkShape], which can be used to create custom shapes for the +/// [Slider]'s tick marks. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +abstract class SliderTrackShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const SliderTrackShape(); + + /// Returns the preferred bounds of the shape. + /// + /// It is used to provide horizontal boundaries for the thumb's position, and + /// to help position the slider thumb and tick marks relative to the track. + /// + /// The `parentBox` argument can be used to help determine the preferredRect relative to + /// attributes of the render box of the slider itself, such as size. + /// + /// The `offset` argument is relative to the caller's bounding box. It can be used to + /// convert gesture coordinates from global to slider-relative coordinates. + /// + /// {@macro flutter.material.SliderComponentShape.paint.sliderTheme} + /// + /// {@macro flutter.material.SliderTickMarkShape.getPreferredSize.isEnabled} + /// + /// {@macro flutter.material.SliderComponentShape.paint.isDiscrete} + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled, + bool isDiscrete, + }); + + /// Paints the track shape based on the state passed to it. + /// + /// {@macro flutter.material.SliderComponentShape.paint.context} + /// + /// The `offset` argument the offset of the origin of the `parentBox` to the + /// origin of its `context` canvas. This shape must be painted relative to + /// this offset. See [PaintingContextCallback]. + /// + /// {@macro flutter.material.SliderComponentShape.paint.parentBox} + /// + /// {@macro flutter.material.SliderComponentShape.paint.sliderTheme} + /// + /// {@macro flutter.material.SliderComponentShape.paint.enableAnimation} + /// + /// The `thumbCenter` argument is the offset of the center of the thumb + /// relative to the origin of the [PaintingContext.canvas]. It can be used as + /// the point that divides the track into 2 segments. + /// + /// The `secondaryOffset` argument is the offset of the secondary value + /// relative to the origin of the [PaintingContext.canvas]. + /// + /// If not null, the track is divided into 3 segments. + /// + /// {@macro flutter.material.SliderTickMarkShape.getPreferredSize.isEnabled} + /// + /// {@macro flutter.material.SliderComponentShape.paint.isDiscrete} + /// + /// The `textDirection` argument can be used to determine how the track + /// segments are painted depending on whether they are active or not. + /// + /// {@template flutter.material.SliderTrackShape.paint.trackSegment} + /// The track segment between the start of the slider and the thumb is the + /// active track segment. The track segment between the thumb and the end of the + /// slider is the inactive track segment. In [TextDirection.ltr], the start of + /// the slider is on the left, and in [TextDirection.rtl], the start of the + /// slider is on the right. + /// {@endtemplate} + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isEnabled, + bool isDiscrete, + required TextDirection textDirection, + }); + + /// Whether the track shape is rounded. + /// + /// This is used to determine the correct position of the thumb in relation to the track. + bool get isRounded => false; +} + +/// Base track shape that provides an implementation of [getPreferredRect] for +/// default sizing. +/// +/// The height is set from [SliderThemeData.trackHeight] and the width of the +/// parent box less the larger of the widths of [SliderThemeData.thumbShape] and +/// [SliderThemeData.overlayShape]. +/// +/// See also: +/// +/// * [RectangularSliderTrackShape], which is a track shape with sharp +/// rectangular edges +/// * [RoundedRectSliderTrackShape], which is a track shape with round +/// stadium-like edges. +mixin BaseSliderTrackShape { + /// Returns a rect that represents the track bounds that fits within the + /// [Slider]. + /// + /// The width is the width of the [Slider] or [RangeSlider], but padded by + /// the max of the overlay and thumb radius. The height is defined by the + /// [SliderThemeData.trackHeight]. + /// + /// The [Rect] is centered both horizontally and vertically within the slider + /// bounds. + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final double thumbWidth = sliderTheme.thumbShape!.getPreferredSize(isEnabled, isDiscrete).width; + final double overlayWidth = sliderTheme.overlayShape! + .getPreferredSize(isEnabled, isDiscrete) + .width; + double trackHeight = sliderTheme.trackHeight!; + assert(overlayWidth >= 0); + assert(trackHeight >= 0); + + // If the track colors are transparent, then override only the track height + // to maintain overall Slider width. + if (sliderTheme.activeTrackColor == Colors.transparent && + sliderTheme.inactiveTrackColor == Colors.transparent) { + trackHeight = 0; + } + + final double trackLeft = + offset.dx + (sliderTheme.padding == null ? math.max(overlayWidth / 2, thumbWidth / 2) : 0); + final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; + final double trackRight = + trackLeft + + parentBox.size.width - + (sliderTheme.padding == null ? math.max(thumbWidth, overlayWidth) : 0); + final double trackBottom = trackTop + trackHeight; + // If the parentBox's size less than slider's size the trackRight will be less than trackLeft, so switch them. + return Rect.fromLTRB( + math.min(trackLeft, trackRight), + trackTop, + math.max(trackLeft, trackRight), + trackBottom, + ); + } + + /// Whether the track shape is rounded. This is used to determine the correct + /// position of the thumb in relation to the track. Defaults to false. + bool get isRounded => false; +} + +/// A [Slider] track that's a simple rectangle. +/// +/// It paints a solid colored rectangle, vertically centered in the +/// `parentBox`. The track rectangle extends to the bounds of the `parentBox`, +/// but is padded by the [RoundSliderOverlayShape] radius. The height is defined +/// by the [SliderThemeData.trackHeight]. The color is determined by the +/// [Slider]'s enabled state and the track segment's active state which are +/// defined by: +/// [SliderThemeData.activeTrackColor], +/// [SliderThemeData.inactiveTrackColor], +/// [SliderThemeData.disabledActiveTrackColor], +/// [SliderThemeData.disabledInactiveTrackColor]. +/// +/// {@macro flutter.material.SliderTrackShape.paint.trackSegment} +/// +/// ![A slider widget, consisting of 5 divisions and showing the rectangular slider track shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rectangular_slider_track_shape.png) +/// +/// See also: +/// +/// * [Slider], for the component that is meant to display this shape. +/// * [SliderThemeData], where an instance of this class is set to inform the +/// slider of the visual details of the its track. +/// * [SliderTrackShape], which can be used to create custom shapes for the +/// [Slider]'s track. +/// * [RoundedRectSliderTrackShape], for a similar track with rounded edges. +class RectangularSliderTrackShape extends SliderTrackShape with BaseSliderTrackShape { + /// Creates a slider track that draws 2 rectangles. + const RectangularSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.thumbShape != null); + // If the slider [SliderThemeData.trackHeight] is less than or equal to 0, + // then it makes no difference whether the track is painted or not, + // therefore the painting can be a no-op. + if (sliderTheme.trackHeight! <= 0) { + return; + } + + // Assign the track segment paints, which are left: active, right: inactive, + // but reversed for right to left text. + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + final (Paint leftTrackPaint, Paint rightTrackPaint) = switch (textDirection) { + TextDirection.ltr => (activePaint, inactivePaint), + TextDirection.rtl => (inactivePaint, activePaint), + }; + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + + final leftTrackSegment = Rect.fromLTRB( + trackRect.left, + trackRect.top, + thumbCenter.dx, + trackRect.bottom, + ); + if (!leftTrackSegment.isEmpty) { + context.canvas.drawRect(leftTrackSegment, leftTrackPaint); + } + final rightTrackSegment = Rect.fromLTRB( + thumbCenter.dx, + trackRect.top, + trackRect.right, + trackRect.bottom, + ); + if (!rightTrackSegment.isEmpty) { + context.canvas.drawRect(rightTrackSegment, rightTrackPaint); + } + + final bool showSecondaryTrack = + secondaryOffset != null && + switch (textDirection) { + TextDirection.rtl => secondaryOffset.dx < thumbCenter.dx, + TextDirection.ltr => secondaryOffset.dx > thumbCenter.dx, + }; + + if (showSecondaryTrack) { + final secondaryTrackColorTween = ColorTween( + begin: sliderTheme.disabledSecondaryActiveTrackColor, + end: sliderTheme.secondaryActiveTrackColor, + ); + final secondaryTrackPaint = Paint() + ..color = secondaryTrackColorTween.evaluate(enableAnimation)!; + final Rect secondaryTrackSegment = switch (textDirection) { + TextDirection.rtl => Rect.fromLTRB( + secondaryOffset.dx, + trackRect.top, + thumbCenter.dx, + trackRect.bottom, + ), + TextDirection.ltr => Rect.fromLTRB( + thumbCenter.dx, + trackRect.top, + secondaryOffset.dx, + trackRect.bottom, + ), + }; + if (!secondaryTrackSegment.isEmpty) { + context.canvas.drawRect(secondaryTrackSegment, secondaryTrackPaint); + } + } + } +} + +/// The default shape of a [Slider]'s track. +/// +/// It paints a solid colored rectangle with rounded edges, vertically centered +/// in the `parentBox`. The track rectangle extends to the bounds of the +/// `parentBox`, but is padded by the larger of [RoundSliderOverlayShape]'s +/// radius and [RoundSliderThumbShape]'s radius. The height is defined by the +/// [SliderThemeData.trackHeight]. The color is determined by the [Slider]'s +/// enabled state and the track segment's active state which are defined by: +/// [SliderThemeData.activeTrackColor], +/// [SliderThemeData.inactiveTrackColor], +/// [SliderThemeData.disabledActiveTrackColor], +/// [SliderThemeData.disabledInactiveTrackColor]. +/// +/// {@macro flutter.material.SliderTrackShape.paint.trackSegment} +/// +/// ![A slider widget, consisting of 5 divisions and showing the rounded rect slider track shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rounded_rect_slider_track_shape.png) +/// +/// See also: +/// +/// * [Slider], for the component that is meant to display this shape. +/// * [SliderThemeData], where an instance of this class is set to inform the +/// slider of the visual details of the its track. +/// * [SliderTrackShape], which can be used to create custom shapes for the +/// [Slider]'s track. +/// * [RectangularSliderTrackShape], for a similar track with sharp edges. +class RoundedRectSliderTrackShape extends SliderTrackShape with BaseSliderTrackShape { + /// Create a slider track that draws two rectangles with rounded outer edges. + const RoundedRectSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 2, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.thumbShape != null); + // If the slider [SliderThemeData.trackHeight] is less than or equal to 0, + // then it makes no difference whether the track is painted or not, + // therefore the painting can be a no-op. + if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) { + return; + } + + // Assign the track segment paints, which are leading: active and + // trailing: inactive. + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + final (Paint leftTrackPaint, Paint rightTrackPaint) = switch (textDirection) { + TextDirection.ltr => (activePaint, inactivePaint), + TextDirection.rtl => (inactivePaint, activePaint), + }; + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final trackRadius = Radius.circular(trackRect.height / 2); + final activeTrackRadius = Radius.circular((trackRect.height + additionalActiveTrackHeight) / 2); + final isLTR = textDirection == TextDirection.ltr; + final isRTL = textDirection == TextDirection.rtl; + + final bool drawInactiveTrack = + thumbCenter.dx < (trackRect.right - (sliderTheme.trackHeight! / 2)); + if (drawInactiveTrack) { + // Draw the inactive track segment. + context.canvas.drawRRect( + RRect.fromLTRBR( + thumbCenter.dx - (sliderTheme.trackHeight! / 2), + isRTL ? trackRect.top - (additionalActiveTrackHeight / 2) : trackRect.top, + trackRect.right, + isRTL ? trackRect.bottom + (additionalActiveTrackHeight / 2) : trackRect.bottom, + isLTR ? trackRadius : activeTrackRadius, + ), + rightTrackPaint, + ); + } + final bool drawActiveTrack = thumbCenter.dx > (trackRect.left + (sliderTheme.trackHeight! / 2)); + if (drawActiveTrack) { + // Draw the active track segment. + context.canvas.drawRRect( + RRect.fromLTRBR( + trackRect.left, + isLTR ? trackRect.top - (additionalActiveTrackHeight / 2) : trackRect.top, + thumbCenter.dx + (sliderTheme.trackHeight! / 2), + isLTR ? trackRect.bottom + (additionalActiveTrackHeight / 2) : trackRect.bottom, + isLTR ? activeTrackRadius : trackRadius, + ), + leftTrackPaint, + ); + } + + final bool showSecondaryTrack = + (secondaryOffset != null) && + (isLTR ? (secondaryOffset.dx > thumbCenter.dx) : (secondaryOffset.dx < thumbCenter.dx)); + + if (showSecondaryTrack) { + final secondaryTrackColorTween = ColorTween( + begin: sliderTheme.disabledSecondaryActiveTrackColor, + end: sliderTheme.secondaryActiveTrackColor, + ); + final secondaryTrackPaint = Paint() + ..color = secondaryTrackColorTween.evaluate(enableAnimation)!; + if (isLTR) { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + thumbCenter.dx, + trackRect.top, + secondaryOffset.dx, + trackRect.bottom, + topRight: trackRadius, + bottomRight: trackRadius, + ), + secondaryTrackPaint, + ); + } else { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + secondaryOffset.dx, + trackRect.top, + thumbCenter.dx, + trackRect.bottom, + topLeft: trackRadius, + bottomLeft: trackRadius, + ), + secondaryTrackPaint, + ); + } + } + } + + @override + bool get isRounded => true; +} + +/// The default shape of each [Slider] tick mark. +/// +/// Tick marks are only displayed if the slider is discrete, which can be done +/// by setting the [Slider.divisions] to an integer value. +/// +/// It paints a solid circle, centered in the on the track. +/// The color is determined by the [Slider]'s enabled state and track's active +/// states. These colors are defined in: +/// [SliderThemeData.activeTrackColor], +/// [SliderThemeData.inactiveTrackColor], +/// [SliderThemeData.disabledActiveTrackColor], +/// [SliderThemeData.disabledInactiveTrackColor]. +/// +/// ![A slider widget, consisting of 5 divisions and showing the round slider tick mark shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rounded_slider_tick_mark_shape.png) +/// +/// See also: +/// +/// * [Slider], which includes tick marks defined by this shape. +/// * [SliderTheme], which can be used to configure the tick mark shape of all +/// sliders in a widget subtree. +class RoundSliderTickMarkShape extends SliderTickMarkShape { + /// Create a slider tick mark that draws a circle. + const RoundSliderTickMarkShape({this.tickMarkRadius}); + + /// The preferred radius of the round tick mark. + /// + /// If it is not provided, then 1/4 of the [SliderThemeData.trackHeight] is used. + final double? tickMarkRadius; + + @override + Size getPreferredSize({required SliderThemeData sliderTheme, required bool isEnabled}) { + assert(sliderTheme.trackHeight != null); + // The tick marks are tiny circles. If no radius is provided, then the + // radius is defaulted to be a fraction of the + // [SliderThemeData.trackHeight]. The fraction is 1/4. + return Size.fromRadius(tickMarkRadius ?? sliderTheme.trackHeight! / 4); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + required bool isEnabled, + }) { + assert(sliderTheme.disabledActiveTickMarkColor != null); + assert(sliderTheme.disabledInactiveTickMarkColor != null); + assert(sliderTheme.activeTickMarkColor != null); + assert(sliderTheme.inactiveTickMarkColor != null); + // The paint color of the tick mark depends on its position relative + // to the thumb and the text direction. + final double xOffset = center.dx - thumbCenter.dx; + final (Color? begin, Color? end) = switch (textDirection) { + TextDirection.ltr when xOffset > 0 => ( + sliderTheme.disabledInactiveTickMarkColor, + sliderTheme.inactiveTickMarkColor, + ), + TextDirection.rtl when xOffset < 0 => ( + sliderTheme.disabledInactiveTickMarkColor, + sliderTheme.inactiveTickMarkColor, + ), + TextDirection.ltr || TextDirection.rtl => ( + sliderTheme.disabledActiveTickMarkColor, + sliderTheme.activeTickMarkColor, + ), + }; + final paint = Paint()..color = ColorTween(begin: begin, end: end).evaluate(enableAnimation)!; + + // The tick marks are tiny circles that are the same height as the track. + final double tickMarkRadius = + getPreferredSize(isEnabled: isEnabled, sliderTheme: sliderTheme).width / 2; + if (tickMarkRadius > 0) { + context.canvas.drawCircle(center, tickMarkRadius, paint); + } + } +} + +/// A special version of [SliderTickMarkShape] that has a zero size and paints +/// nothing. +/// +/// This class is used to create a special instance of a [SliderTickMarkShape] +/// that will not paint any tick mark shape. A static reference is stored in +/// [SliderTickMarkShape.noTickMark]. When this value is specified for +/// [SliderThemeData.tickMarkShape], the tick mark painting is skipped. +class _EmptySliderTickMarkShape extends SliderTickMarkShape { + @override + Size getPreferredSize({required SliderThemeData sliderTheme, required bool isEnabled}) { + return Size.zero; + } + + @override + void paint( + PaintingContext context, + Offset center, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required Offset thumbCenter, + required bool isEnabled, + required TextDirection textDirection, + }) { + // no-op. + } +} + +/// The default shape of a [Slider]'s thumb. +/// +/// There is a shadow for the resting, pressed, hovered, and focused state. +/// +/// ![A slider widget, consisting of 5 divisions and showing the round slider thumb shape.](https://flutter.github.io/assets-for-api-docs/assets/material/round_slider_thumb_shape.png) +/// +/// See also: +/// +/// * [Slider], which includes a thumb defined by this shape. +/// * [SliderTheme], which can be used to configure the thumb shape of all +/// sliders in a widget subtree. +class RoundSliderThumbShape extends SliderComponentShape { + /// Create a slider thumb that draws a circle. + const RoundSliderThumbShape({ + this.enabledThumbRadius = 10.0, + this.disabledThumbRadius, + this.elevation = 1.0, + this.pressedElevation = 6.0, + }); + + /// The preferred radius of the round thumb shape when the slider is enabled. + /// + /// If it is not provided, then the Material Design default of 10 is used. + final double enabledThumbRadius; + + /// The preferred radius of the round thumb shape when the slider is disabled. + /// + /// If no disabledRadius is provided, then it is equal to the + /// [enabledThumbRadius] + final double? disabledThumbRadius; + + double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; + + /// The resting elevation adds shadow to the unpressed thumb. + /// + /// The default is 1. + /// + /// Use 0 for no shadow. The higher the value, the larger the shadow. For + /// example, a value of 12 will create a very large shadow. + /// + final double elevation; + + /// The pressed elevation adds shadow to the pressed thumb. + /// + /// The default is 6. + /// + /// Use 0 for no shadow. The higher the value, the larger the shadow. For + /// example, a value of 12 will create a very large shadow. + final double pressedElevation; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size.fromRadius(isEnabled ? enabledThumbRadius : _disabledThumbRadius); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + assert(sliderTheme.disabledThumbColor != null); + assert(sliderTheme.thumbColor != null); + + final Canvas canvas = context.canvas; + final radiusTween = Tween<double>(begin: _disabledThumbRadius, end: enabledThumbRadius); + final colorTween = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ); + + final Color color = colorTween.evaluate(enableAnimation)!; + final double radius = radiusTween.evaluate(enableAnimation); + + final elevationTween = Tween<double>(begin: elevation, end: pressedElevation); + + final double evaluatedElevation = elevationTween.evaluate(activationAnimation); + final path = Path() + ..addArc( + Rect.fromCenter(center: center, width: 2 * radius, height: 2 * radius), + 0, + math.pi * 2, + ); + + var paintShadows = true; + assert(() { + if (debugDisableShadows) { + _debugDrawShadow(canvas, path, evaluatedElevation); + paintShadows = false; + } + return true; + }()); + + if (paintShadows) { + canvas.drawShadow(path, Colors.black, evaluatedElevation, true); + } + + canvas.drawCircle(center, radius, Paint()..color = color); + } +} + +/// The default shape of a Material 3 [Slider]'s value indicator. +/// +/// See also: +/// +/// * [Slider], which includes a value indicator defined by this shape. +/// * [SliderTheme], which can be used to configure the slider value indicator +/// of all sliders in a widget subtree. +class DropSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator that resembles a drop shape. + const DropSliderValueIndicatorShape(); + + static const _DropSliderValueIndicatorPathPainter _pathPainter = + _DropSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final double scale = activationAnimation.value; + _pathPainter.paint( + parentBox: parentBox, + canvas: canvas, + center: center, + scale: scale, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + backgroundPaintColor: sliderTheme.valueIndicatorColor!, + strokePaintColor: sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +class _DropSliderValueIndicatorPathPainter { + const _DropSliderValueIndicatorPathPainter(); + + static const double _triangleHeight = 10.0; + static const double _labelPadding = 8.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 20.0; + static const double _minRectHeight = 28.0; + static const double _rectYOffset = 6.0; + static const double _bottomTipYOffset = 16.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + static const double _upperRectRadius = 4; + + Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { + final double width = + math.max(_minLabelWidth, labelPainter.width) + _labelPadding * 2 * textScaleFactor; + return Size(width, _preferredHeight * textScaleFactor); + } + + double getHorizontalShift({ + required RenderBox parentBox, + required Offset center, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required double scale, + }) { + assert(!sizeWithOverflow.isEmpty); + + const edgePadding = 8.0; + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + + /// Value indicator draws on the Overlay and by using the global Offset + /// we are making sure we use the bounds of the Overlay instead of the Slider. + final Offset globalCenter = parentBox.localToGlobal(center); + + // The rectangle must be shifted towards the center so that it minimizes the + // chance of it rendering outside the bounds of the render box. If the shift + // is negative, then the lobe is shifted from right to left, and if it is + // positive, then the lobe is shifted from left to right. + final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding); + final double overflowRight = math.max( + 0, + rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding), + ); + + if (rectangleWidth < sizeWithOverflow.width) { + return overflowLeft - overflowRight; + } else if (overflowLeft - overflowRight > 0) { + return overflowLeft - (edgePadding * textScaleFactor); + } else { + return -overflowRight + (edgePadding * textScaleFactor); + } + } + + double _upperRectangleWidth(TextPainter labelPainter, double scale) { + final double unscaledWidth = math.max(_minLabelWidth, labelPainter.width) + _labelPadding; + return unscaledWidth * scale; + } + + BorderRadius _adjustBorderRadius(Rect rect) { + const rectness = 0.0; + return BorderRadius.lerp( + const BorderRadius.all(Radius.circular(_upperRectRadius)), + BorderRadius.all(Radius.circular(rect.shortestSide / 2.0)), + 1.0 - rectness, + )!; + } + + void paint({ + required RenderBox parentBox, + required Canvas canvas, + required Offset center, + required double scale, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required Color backgroundPaintColor, + Color? strokePaintColor, + }) { + if (scale == 0.0) { + // Zero scale essentially means "do not draw anything", so it's safe to just return. + return; + } + assert(!sizeWithOverflow.isEmpty); + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + final double horizontalShift = getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: scale, + ); + final upperRect = Rect.fromLTWH( + -rectangleWidth / 2 + horizontalShift, + -_rectYOffset - _minRectHeight, + rectangleWidth, + _minRectHeight, + ); + + final fillPaint = Paint()..color = backgroundPaintColor; + + canvas.save(); + canvas.translate(center.dx, center.dy - _bottomTipYOffset); + canvas.scale(scale, scale); + + final BorderRadius adjustedBorderRadius = _adjustBorderRadius(upperRect); + final RRect borderRect = adjustedBorderRadius + .resolve(labelPainter.textDirection) + .toRRect(upperRect); + final trianglePath = Path() + ..lineTo(-_triangleHeight, -_triangleHeight) + ..lineTo(_triangleHeight, -_triangleHeight) + ..close(); + trianglePath.addRRect(borderRect); + + if (strokePaintColor != null) { + final strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawPath(trianglePath, strokePaint); + } + + canvas.drawPath(trianglePath, fillPaint); + + // The label text is centered within the value indicator. + final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height; + canvas.translate(0, bottomTipToUpperRectTranslateY); + final boxCenter = Offset(horizontalShift, upperRect.height / 1.75); + final halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} + +/// The bar shape of a [Slider]'s thumb. +/// +/// When the slider is enabled, the [ColorScheme.primary] color is used for the +/// thumb. When the slider is disabled, the [ColorScheme.onSurface] color with an +/// opacity of 0.38 is used for the thumb. +/// +/// The thumb bar shape width is reduced when the thumb is pressed. +/// +/// If [SliderThemeData.thumbSize] is null, then the thumb size is 4 pixels for the width +/// and 44 pixels for the height. +/// +/// This is the default thumb shape for [Slider]. If [ThemeData.useMaterial3] is false, +/// then the default thumb shape is [RoundSliderThumbShape]. +/// +/// See also: +/// +/// * [Slider], which includes an overlay defined by this shape. +/// * [SliderTheme], which can be used to configure the overlay shape of all +/// sliders in a widget subtree. +class HandleThumbShape extends SliderComponentShape { + /// Create a slider thumb that draws a bar. + const HandleThumbShape(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(4.0, 44.0); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + assert(sliderTheme.disabledThumbColor != null); + assert(sliderTheme.thumbColor != null); + assert(sliderTheme.thumbSize != null); + + final colorTween = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ); + final Color color = colorTween.evaluate(enableAnimation)!; + + final Canvas canvas = context.canvas; + final Size thumbSize = sliderTheme.thumbSize!.resolve( + <WidgetState>{}, + )!; // This is resolved in the paint method. + final rrect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: thumbSize.width, height: thumbSize.height), + Radius.circular(thumbSize.shortestSide / 2), + ); + canvas.drawRRect(rrect, Paint()..color = color); + } +} + +/// The gapped shape of a [Slider]'s track. +/// +/// The [GappedSliderTrackShape] consists of active and inactive +/// tracks. The active track uses the [SliderThemeData.activeTrackColor] and the +/// inactive track uses the [SliderThemeData.inactiveTrackColor]. +/// +/// The track shape uses circular corner radius for the edge corners and a corner radius +/// of 2 pixels for the inside corners. +/// +/// Between the active and inactive tracks there is a gap of size [SliderThemeData.trackGap]. +/// If the [SliderThemeData.thumbShape] is [HandleThumbShape] and the thumb is pressed, the thumb's +/// width is reduced; as a result, the track gap size in [GappedSliderTrackShape] +/// is also reduced. +/// +/// If [SliderThemeData.trackGap] is null, then the track gap size defaults to 6 pixels. +/// +/// This is the default track shape for [Slider]. If [ThemeData.useMaterial3] is false, +/// then the default track shape is [RoundedRectSliderTrackShape]. +/// +/// See also: +/// +/// * [Slider], which includes an overlay defined by this shape. +/// * [SliderTheme], which can be used to configure the overlay shape of all +/// sliders in a widget subtree. +class GappedSliderTrackShape extends SliderTrackShape with BaseSliderTrackShape { + /// Create a slider track that draws two rectangles with rounded outer edges. + const GappedSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 2, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.thumbShape != null); + assert(sliderTheme.trackGap != null); + assert(!sliderTheme.trackGap!.isNegative); + // If the slider [SliderThemeData.trackHeight] is less than or equal to 0, + // then it makes no difference whether the track is painted or not, + // therefore the painting can be a no-op. + if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) { + return; + } + + // Assign the track segment paints, which are left: active, right: inactive, + // but reversed for right to left text. + final activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!; + final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + final Paint leftTrackPaint; + final Paint rightTrackPaint; + switch (textDirection) { + case TextDirection.ltr: + leftTrackPaint = activePaint; + rightTrackPaint = inactivePaint; + case TextDirection.rtl: + leftTrackPaint = inactivePaint; + rightTrackPaint = activePaint; + } + + // Gap, starting from the middle of the thumb. + final double trackGap = sliderTheme.trackGap!; + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + + final trackCornerRadius = Radius.circular(trackRect.shortestSide / 2); + const trackInsideCornerRadius = Radius.circular(2.0); + + final trackRRect = RRect.fromRectAndCorners( + trackRect, + topLeft: trackCornerRadius, + bottomLeft: trackCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ); + + final leftRRect = RRect.fromLTRBAndCorners( + trackRect.left, + trackRect.top, + math.max(trackRect.left, thumbCenter.dx - trackGap), + trackRect.bottom, + topLeft: trackCornerRadius, + bottomLeft: trackCornerRadius, + topRight: trackInsideCornerRadius, + bottomRight: trackInsideCornerRadius, + ); + + final rightRRect = RRect.fromLTRBAndCorners( + thumbCenter.dx + trackGap, + trackRect.top, + trackRect.right, + trackRect.bottom, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + ); + + context.canvas + ..save() + ..clipRRect(trackRRect); + final bool drawLeftTrack = thumbCenter.dx > (leftRRect.left + (sliderTheme.trackHeight! / 2)); + final bool drawRightTrack = + thumbCenter.dx < (rightRRect.right - (sliderTheme.trackHeight! / 2)); + if (drawLeftTrack) { + context.canvas.drawRRect(leftRRect, leftTrackPaint); + } + if (drawRightTrack) { + context.canvas.drawRRect(rightRRect, rightTrackPaint); + } + + final isLTR = textDirection == TextDirection.ltr; + final bool showSecondaryTrack = + (secondaryOffset != null) && + switch (isLTR) { + true => secondaryOffset.dx > thumbCenter.dx + trackGap, + false => secondaryOffset.dx < thumbCenter.dx - trackGap, + }; + + if (showSecondaryTrack) { + final secondaryTrackColorTween = ColorTween( + begin: sliderTheme.disabledSecondaryActiveTrackColor, + end: sliderTheme.secondaryActiveTrackColor, + ); + final secondaryTrackPaint = Paint() + ..color = secondaryTrackColorTween.evaluate(enableAnimation)!; + if (isLTR) { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + thumbCenter.dx + trackGap, + trackRect.top, + secondaryOffset.dx, + trackRect.bottom, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ), + secondaryTrackPaint, + ); + } else { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + secondaryOffset.dx - trackGap, + trackRect.top, + thumbCenter.dx, + trackRect.bottom, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ), + secondaryTrackPaint, + ); + } + } + context.canvas.restore(); + + const stopIndicatorRadius = 2.0; + final double stopIndicatorTrailingSpace = sliderTheme.trackHeight! / 2; + final stopIndicatorOffset = Offset( + (textDirection == TextDirection.ltr) + ? trackRect.centerRight.dx - stopIndicatorTrailingSpace + : trackRect.centerLeft.dx + stopIndicatorTrailingSpace, + trackRect.center.dy, + ); + + final bool showStopIndicator = (textDirection == TextDirection.ltr) + ? thumbCenter.dx < stopIndicatorOffset.dx + : thumbCenter.dx > stopIndicatorOffset.dx; + if (showStopIndicator && !isDiscrete) { + final stopIndicatorRect = Rect.fromCircle( + center: stopIndicatorOffset, + radius: stopIndicatorRadius, + ); + context.canvas.drawCircle(stopIndicatorRect.center, stopIndicatorRadius, activePaint); + } + } + + @override + bool get isRounded => true; +} + +/// The rounded rectangle shape of a [Slider]'s value indicator. +/// +/// If the [SliderThemeData.valueIndicatorColor] is null, then the shape uses the [ColorScheme.inverseSurface] +/// color to draw the value indicator. +/// +/// If the [SliderThemeData.valueIndicatorTextStyle] is null, then the indicator label text style +/// defaults to [TextTheme.labelMedium] with the color set to [ColorScheme.onInverseSurface]. If the +/// [ThemeData.useMaterial3] is set to false, then the indicator label text style defaults to +/// [TextTheme.bodyLarge] with the color set to [ColorScheme.onInverseSurface]. +/// +/// If the [SliderThemeData.valueIndicatorStrokeColor] is provided, then the value indicator is drawn with a +/// stroke border with the color provided. +/// +/// This is the default value indicator shape for [Slider]. If [ThemeData.useMaterial3] is false, +/// then the default value indicator shape is [RectangularSliderValueIndicatorShape]. +/// +/// See also: +/// +/// * [Slider], which includes a value indicator defined by this shape. +/// * [SliderTheme], which can be used to configure the slider value indicator +/// of all sliders in a widget subtree. +class RoundedRectSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator that resembles a rounded rectangle. + const RoundedRectSliderValueIndicatorShape(); + + static const _RoundedRectSliderValueIndicatorPathPainter _pathPainter = + _RoundedRectSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final double scale = activationAnimation.value; + _pathPainter.paint( + parentBox: parentBox, + canvas: canvas, + center: center, + scale: scale, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + backgroundPaintColor: sliderTheme.valueIndicatorColor!, + strokePaintColor: sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +class _RoundedRectSliderValueIndicatorPathPainter { + const _RoundedRectSliderValueIndicatorPathPainter(); + + static const double _labelPadding = 10.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 16.0; + static const double _rectYOffset = 10.0; + static const double _bottomTipYOffset = 16.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + + Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { + final double width = + math.max(_minLabelWidth, labelPainter.width) + (_labelPadding * 2) * textScaleFactor; + return Size(width, _preferredHeight * textScaleFactor); + } + + double getHorizontalShift({ + required RenderBox parentBox, + required Offset center, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required double scale, + }) { + assert(!sizeWithOverflow.isEmpty); + + const edgePadding = 8.0; + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + + /// Value indicator draws on the Overlay and by using the global Offset + /// we are making sure we use the bounds of the Overlay instead of the Slider. + final Offset globalCenter = parentBox.localToGlobal(center); + + // The rectangle must be shifted towards the center so that it minimizes the + // chance of it rendering outside the bounds of the render box. If the shift + // is negative, then the lobe is shifted from right to left, and if it is + // positive, then the lobe is shifted from left to right. + final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding); + final double overflowRight = math.max( + 0, + rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding), + ); + + if (rectangleWidth < sizeWithOverflow.width) { + return overflowLeft - overflowRight; + } else if (overflowLeft - overflowRight > 0) { + return overflowLeft - (edgePadding * textScaleFactor); + } else { + return -overflowRight + (edgePadding * textScaleFactor); + } + } + + double _upperRectangleWidth(TextPainter labelPainter, double scale) { + final double unscaledWidth = math.max(_minLabelWidth, labelPainter.width) + (_labelPadding * 2); + return unscaledWidth * scale; + } + + void paint({ + required RenderBox parentBox, + required Canvas canvas, + required Offset center, + required double scale, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required Color backgroundPaintColor, + Color? strokePaintColor, + }) { + if (scale == 0.0) { + // Zero scale essentially means "do not draw anything", so it's safe to just return. + return; + } + assert(!sizeWithOverflow.isEmpty); + + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale); + final double horizontalShift = getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: scale, + ); + + final upperRect = Rect.fromLTWH( + -rectangleWidth / 2 + horizontalShift, + -_rectYOffset - _preferredHeight, + rectangleWidth, + _preferredHeight, + ); + + final fillPaint = Paint()..color = backgroundPaintColor; + + canvas.save(); + // Prepare the canvas for the base of the tooltip, which is relative to the + // center of the thumb. + canvas.translate(center.dx, center.dy - _bottomTipYOffset); + canvas.scale(scale, scale); + + final rrect = RRect.fromRectAndRadius(upperRect, Radius.circular(upperRect.height / 2)); + if (strokePaintColor != null) { + final strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawRRect(rrect, strokePaint); + } + + canvas.drawRRect(rrect, fillPaint); + + // The label text is centered within the value indicator. + final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height; + canvas.translate(0, bottomTipToUpperRectTranslateY); + final boxCenter = Offset(horizontalShift, upperRect.height / 2.3); + final halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} + +void _debugDrawShadow(Canvas canvas, Path path, double elevation) { + if (elevation > 0.0) { + canvas.drawPath( + path, + Paint() + ..color = Colors.black + ..style = PaintingStyle.stroke + ..strokeWidth = elevation * 2.0, + ); + } +} diff --git a/packages/material_ui/lib/src/slider_theme.dart b/packages/material_ui/lib/src/slider_theme.dart new file mode 100644 index 000000000000..c9b07a7a6eba --- /dev/null +++ b/packages/material_ui/lib/src/slider_theme.dart @@ -0,0 +1,1156 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'range_slider.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'range_slider_parts.dart'; +import 'slider.dart'; +import 'slider_parts.dart'; +import 'slider_value_indicator_shape.dart'; +import 'theme.dart'; + +/// Applies a slider theme to descendant [Slider] widgets. +/// +/// A slider theme describes the colors and shape choices of the slider +/// components. +/// +/// Descendant widgets obtain the current theme's [SliderThemeData] object using +/// [SliderTheme.of]. When a widget uses [SliderTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// The slider is as big as the largest of +/// the [SliderComponentShape.getPreferredSize] of the thumb shape, +/// the [SliderComponentShape.getPreferredSize] of the overlay shape, +/// and the [SliderTickMarkShape.getPreferredSize] of the tick mark shape. +/// +/// See also: +/// +/// * [SliderThemeData], which describes the actual configuration of a slider +/// theme. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +/// * [SliderTrackShape], which can be used to create custom shapes for the +/// [Slider]'s track. +/// * [SliderTickMarkShape], which can be used to create custom shapes for the +/// [Slider]'s tick marks. +/// * [RangeSliderThumbShape], which can be used to create custom shapes for +/// the [RangeSlider]'s thumb. +/// * [RangeSliderValueIndicatorShape], which can be used to create custom +/// shapes for the [RangeSlider]'s value indicator. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [RangeSliderTickMarkShape], which can be used to create custom shapes for +/// the [RangeSlider]'s tick marks. +class SliderTheme extends InheritedTheme { + /// Applies the given theme [data] to [child]. + const SliderTheme({super.key, required this.data, required super.child}); + + /// Specifies the color and shape values for descendant slider widgets. + final SliderThemeData data; + + /// Returns the data from the closest [SliderTheme] instance that encloses + /// the given context. + /// + /// Defaults to the ambient [ThemeData.sliderTheme] if there is no + /// [SliderTheme] in the given build context. + /// + /// {@tool snippet} + /// + /// ```dart + /// class Launch extends StatefulWidget { + /// const Launch({super.key}); + /// + /// @override + /// State createState() => LaunchState(); + /// } + /// + /// class LaunchState extends State<Launch> { + /// double _rocketThrust = 0; + /// + /// @override + /// Widget build(BuildContext context) { + /// return SliderTheme( + /// data: SliderTheme.of(context).copyWith(activeTrackColor: const Color(0xff804040)), + /// child: Slider( + /// onChanged: (double value) { setState(() { _rocketThrust = value; }); }, + /// value: _rocketThrust, + /// ), + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [SliderThemeData], which describes the actual configuration of a slider + /// theme. + static SliderThemeData of(BuildContext context) { + final SliderTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<SliderTheme>(); + return inheritedTheme != null ? inheritedTheme.data : Theme.of(context).sliderTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return SliderTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(SliderTheme oldWidget) => data != oldWidget.data; +} + +/// Describes the conditions under which the value indicator on a [Slider] +/// will be shown. Used in [Slider.showValueIndicator] and +/// [SliderThemeData.showValueIndicator]. +/// +/// See also: +/// +/// * [Slider], a Material Design slider widget. +/// * [SliderThemeData], which describes the actual configuration of a slider +/// theme. +enum ShowValueIndicator { + /// The value indicator will only be shown while dragging for discrete sliders (sliders + /// where [Slider.divisions] is non-null). + onlyForDiscrete, + + /// The value indicator will only be shown while dragging for continuous sliders (sliders + /// where [Slider.divisions] is null). + onlyForContinuous, + + /// The value indicator is shown while dragging. + @Deprecated( + 'Use ShowValueIndicator.onDrag. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + always, + + /// The value indicator is shown while dragging. + onDrag, + + /// The value indicator is always displayed as long as the slider has a + /// [Slider.onChanged] callback. + alwaysVisible, + + /// The value indicator will never be shown. + never, +} + +/// Identifier for a thumb. +/// +/// There are 2 thumbs in a [RangeSlider], [start] and [end]. +/// +/// For [TextDirection.ltr], the [start] thumb is the left-most thumb and the +/// [end] thumb is the right-most thumb. For [TextDirection.rtl] the [start] +/// thumb is the right-most thumb, and the [end] thumb is the left-most thumb. +enum Thumb { + /// Left-most thumb for [TextDirection.ltr], otherwise, right-most thumb. + start, + + /// Right-most thumb for [TextDirection.ltr], otherwise, left-most thumb. + end, +} + +/// Overrides the default values of visual properties for descendant +/// [Slider] widgets. +/// +/// Descendant widgets obtain the current [SliderThemeData] object with +/// [SliderTheme.of]. Instances of [SliderThemeData] can +/// be customized with [SliderThemeData.copyWith]. +/// +/// Typically a [SliderThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.sliderTheme]. +/// +/// This theme is for both the [Slider] and the [RangeSlider]. The properties +/// that are only for the [Slider] are: [tickMarkShape], [thumbShape], +/// [trackShape], and [valueIndicatorShape]. The properties that are only for +/// the [RangeSlider] are [rangeTickMarkShape], [rangeThumbShape], +/// [rangeTrackShape], [rangeValueIndicatorShape], +/// [overlappingShapeStrokeColor], [minThumbSeparation], and [thumbSelector]. +/// All other properties are used by both the [Slider] and the [RangeSlider]. +/// +/// The parts of a slider are: +/// +/// * The "thumb", which is a shape that slides horizontally when the user +/// drags it. +/// * The "track", which is the line that the slider thumb slides along. +/// * The "tick marks", which are regularly spaced marks that are drawn when +/// using discrete divisions. +/// * The "value indicator", which appears when the user is dragging the thumb +/// to indicate the value being selected. +/// * The "overlay", which appears around the thumb, and is shown when the +/// thumb is pressed, focused, or hovered. It is painted underneath the +/// thumb, so it must extend beyond the bounds of the thumb itself to +/// actually be visible. +/// * The "active" side of the slider is the side between the thumb and the +/// minimum value. +/// * The "inactive" side of the slider is the side between the thumb and the +/// maximum value. +/// * The [Slider] is disabled when it is not accepting user input. See +/// [Slider] for details on when this happens. +/// +/// The thumb, track, tick marks, value indicator, and overlay can be customized +/// by creating subclasses of [SliderTrackShape], +/// [SliderComponentShape], and/or [SliderTickMarkShape]. See +/// [RoundSliderThumbShape], [RectangularSliderTrackShape], +/// [RoundSliderTickMarkShape], [RectangularSliderValueIndicatorShape], and +/// [RoundSliderOverlayShape] for examples. +/// +/// The track painting can be skipped by specifying 0 for [trackHeight]. +/// The thumb painting can be skipped by specifying +/// [SliderComponentShape.noThumb] for [SliderThemeData.thumbShape]. +/// The overlay painting can be skipped by specifying +/// [SliderComponentShape.noOverlay] for [SliderThemeData.overlayShape]. +/// The tick mark painting can be skipped by specifying +/// [SliderTickMarkShape.noTickMark] for [SliderThemeData.tickMarkShape]. +/// The value indicator painting can be skipped by specifying the +/// appropriate [ShowValueIndicator] for [SliderThemeData.showValueIndicator]. +/// +/// See also: +/// +/// * [SliderTheme] widget, which can override the slider theme of its +/// children. +/// * [Theme] widget, which performs a similar function to [SliderTheme], +/// but for overall themes. +/// * [ThemeData], which has a default [SliderThemeData]. +/// * [SliderComponentShape], which can be used to create custom shapes for +/// the [Slider]'s thumb, overlay, and value indicator and the +/// [RangeSlider]'s overlay. +/// * [SliderTrackShape], which can be used to create custom shapes for the +/// [Slider]'s track. +/// * [SliderTickMarkShape], which can be used to create custom shapes for the +/// [Slider]'s tick marks. +/// * [RangeSliderThumbShape], which can be used to create custom shapes for +/// the [RangeSlider]'s thumb. +/// * [RangeSliderValueIndicatorShape], which can be used to create custom +/// shapes for the [RangeSlider]'s value indicator. +/// * [RangeSliderTrackShape], which can be used to create custom shapes for +/// the [RangeSlider]'s track. +/// * [RangeSliderTickMarkShape], which can be used to create custom shapes for +/// the [RangeSlider]'s tick marks. +@immutable +class SliderThemeData with Diagnosticable { + /// Create a [SliderThemeData] given a set of exact values. + /// + /// This will rarely be used directly. It is used by [lerp] to + /// create intermediate themes based on two themes. + /// + /// The simplest way to create a SliderThemeData is to use + /// [copyWith] on the one you get from [SliderTheme.of], or create an + /// entirely new one with [SliderThemeData.fromPrimaryColors]. + /// + /// {@tool snippet} + /// + /// ```dart + /// class Blissful extends StatefulWidget { + /// const Blissful({super.key}); + /// + /// @override + /// State createState() => BlissfulState(); + /// } + /// + /// class BlissfulState extends State<Blissful> { + /// double _bliss = 0; + /// + /// @override + /// Widget build(BuildContext context) { + /// return SliderTheme( + /// data: SliderTheme.of(context).copyWith(activeTrackColor: const Color(0xff404080)), + /// child: Slider( + /// onChanged: (double value) { setState(() { _bliss = value; }); }, + /// value: _bliss, + /// ), + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + const SliderThemeData({ + this.trackHeight, + this.activeTrackColor, + this.inactiveTrackColor, + this.secondaryActiveTrackColor, + this.disabledActiveTrackColor, + this.disabledInactiveTrackColor, + this.disabledSecondaryActiveTrackColor, + this.activeTickMarkColor, + this.inactiveTickMarkColor, + this.disabledActiveTickMarkColor, + this.disabledInactiveTickMarkColor, + this.thumbColor, + this.overlappingShapeStrokeColor, + this.disabledThumbColor, + this.overlayColor, + this.valueIndicatorColor, + this.valueIndicatorStrokeColor, + this.overlayShape, + this.tickMarkShape, + this.thumbShape, + this.trackShape, + this.valueIndicatorShape, + this.rangeTickMarkShape, + this.rangeThumbShape, + this.rangeTrackShape, + this.rangeValueIndicatorShape, + this.showValueIndicator, + this.valueIndicatorTextStyle, + this.minThumbSeparation, + this.thumbSelector, + this.mouseCursor, + this.allowedInteraction, + this.padding, + this.thumbSize, + this.trackGap, + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.year2023, + }); + + /// Generates a SliderThemeData from three main colors. + /// + /// Usually these are the primary, dark and light colors from + /// a [ThemeData]. + /// + /// The opacities of these colors will be overridden with the Material Design + /// defaults when assigning them to the slider theme component colors. + /// + /// This is used to generate the default slider theme for a [ThemeData]. + factory SliderThemeData.fromPrimaryColors({ + required Color primaryColor, + required Color primaryColorDark, + required Color primaryColorLight, + required TextStyle valueIndicatorTextStyle, + }) { + // These are Material Design defaults, and are used to derive + // component Colors (with opacity) from base colors. + const activeTrackAlpha = 0xff; + const inactiveTrackAlpha = 0x3d; // 24% opacity + const secondaryActiveTrackAlpha = 0x8a; // 54% opacity + const disabledActiveTrackAlpha = 0x52; // 32% opacity + const disabledInactiveTrackAlpha = 0x1f; // 12% opacity + const disabledSecondaryActiveTrackAlpha = 0x1f; // 12% opacity + const activeTickMarkAlpha = 0x8a; // 54% opacity + const inactiveTickMarkAlpha = 0x8a; // 54% opacity + const disabledActiveTickMarkAlpha = 0x1f; // 12% opacity + const disabledInactiveTickMarkAlpha = 0x1f; // 12% opacity + const thumbAlpha = 0xff; + const disabledThumbAlpha = 0x52; // 32% opacity + const overlayAlpha = 0x1f; // 12% opacity + const valueIndicatorAlpha = 0xff; + + return SliderThemeData( + trackHeight: 2.0, + activeTrackColor: primaryColor.withAlpha(activeTrackAlpha), + inactiveTrackColor: primaryColor.withAlpha(inactiveTrackAlpha), + secondaryActiveTrackColor: primaryColor.withAlpha(secondaryActiveTrackAlpha), + disabledActiveTrackColor: primaryColorDark.withAlpha(disabledActiveTrackAlpha), + disabledInactiveTrackColor: primaryColorDark.withAlpha(disabledInactiveTrackAlpha), + disabledSecondaryActiveTrackColor: primaryColorDark.withAlpha( + disabledSecondaryActiveTrackAlpha, + ), + activeTickMarkColor: primaryColorLight.withAlpha(activeTickMarkAlpha), + inactiveTickMarkColor: primaryColor.withAlpha(inactiveTickMarkAlpha), + disabledActiveTickMarkColor: primaryColorLight.withAlpha(disabledActiveTickMarkAlpha), + disabledInactiveTickMarkColor: primaryColorDark.withAlpha(disabledInactiveTickMarkAlpha), + thumbColor: primaryColor.withAlpha(thumbAlpha), + overlappingShapeStrokeColor: Colors.white, + disabledThumbColor: primaryColorDark.withAlpha(disabledThumbAlpha), + overlayColor: primaryColor.withAlpha(overlayAlpha), + valueIndicatorColor: primaryColor.withAlpha(valueIndicatorAlpha), + valueIndicatorStrokeColor: primaryColor.withAlpha(valueIndicatorAlpha), + overlayShape: const RoundSliderOverlayShape(), + tickMarkShape: const RoundSliderTickMarkShape(), + thumbShape: const RoundSliderThumbShape(), + trackShape: const RoundedRectSliderTrackShape(), + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), + rangeTickMarkShape: const RoundRangeSliderTickMarkShape(), + rangeThumbShape: const RoundRangeSliderThumbShape(), + rangeTrackShape: const RoundedRectRangeSliderTrackShape(), + rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + valueIndicatorTextStyle: valueIndicatorTextStyle, + showValueIndicator: ShowValueIndicator.onlyForDiscrete, + ); + } + + /// The height of the [Slider] track. + final double? trackHeight; + + /// The color of the [Slider] track between the [Slider.min] position and the + /// current thumb position. + final Color? activeTrackColor; + + /// The color of the [Slider] track between the current thumb position and the + /// [Slider.max] position. + final Color? inactiveTrackColor; + + /// The color of the [Slider] track between the current thumb position and the + /// [Slider.secondaryTrackValue] position. + final Color? secondaryActiveTrackColor; + + /// The color of the [Slider] track between the [Slider.min] position and the + /// current thumb position when the [Slider] is disabled. + final Color? disabledActiveTrackColor; + + /// The color of the [Slider] track between the current thumb position and the + /// [Slider.secondaryTrackValue] position when the [Slider] is disabled. + final Color? disabledSecondaryActiveTrackColor; + + /// The color of the [Slider] track between the current thumb position and the + /// [Slider.max] position when the [Slider] is disabled. + final Color? disabledInactiveTrackColor; + + /// The color of the track's tick marks that are drawn between the [Slider.min] + /// position and the current thumb position. + final Color? activeTickMarkColor; + + /// The color of the track's tick marks that are drawn between the current + /// thumb position and the [Slider.max] position. + final Color? inactiveTickMarkColor; + + /// The color of the track's tick marks that are drawn between the [Slider.min] + /// position and the current thumb position when the [Slider] is disabled. + final Color? disabledActiveTickMarkColor; + + /// The color of the track's tick marks that are drawn between the current + /// thumb position and the [Slider.max] position when the [Slider] is + /// disabled. + final Color? disabledInactiveTickMarkColor; + + /// The color given to the [thumbShape] to draw itself with. + final Color? thumbColor; + + /// The color given to the perimeter of the top [rangeThumbShape] when the + /// thumbs are overlapping and the top [rangeValueIndicatorShape] when the + /// value indicators are overlapping. + final Color? overlappingShapeStrokeColor; + + /// The color given to the [thumbShape] to draw itself with when the + /// [Slider] is disabled. + final Color? disabledThumbColor; + + /// The color of the overlay drawn around the slider thumb when it is + /// pressed, focused, or hovered. + /// + /// This is typically a semi-transparent color. + final Color? overlayColor; + + /// The color given to the [valueIndicatorShape] to draw itself with. + final Color? valueIndicatorColor; + + /// The color given to the [valueIndicatorShape] stroke. + final Color? valueIndicatorStrokeColor; + + /// The shape that will be used to draw the [Slider]'s overlay. + /// + /// Both the [overlayColor] and a non default [overlayShape] may be specified. + /// The default [overlayShape] refers to the [overlayColor]. + /// + /// The default value is [RoundSliderOverlayShape]. + final SliderComponentShape? overlayShape; + + /// The shape that will be used to draw the [Slider]'s tick marks. + /// + /// The [SliderTickMarkShape.getPreferredSize] is used to help determine the + /// location of each tick mark on the track. The slider's minimum size will + /// be at least this big. + /// + /// The default value is [RoundSliderTickMarkShape]. + /// + /// See also: + /// + /// * [RoundRangeSliderTickMarkShape], which is the default tick mark + /// shape for the range slider. + final SliderTickMarkShape? tickMarkShape; + + /// The shape that will be used to draw the [Slider]'s thumb. + /// + /// The default value is [RoundSliderThumbShape]. + /// + /// See also: + /// + /// * [RoundRangeSliderThumbShape], which is the default thumb shape for + /// the [RangeSlider]. + final SliderComponentShape? thumbShape; + + /// The shape that will be used to draw the [Slider]'s track. + /// + /// The [SliderTrackShape.getPreferredRect] method is used to map + /// slider-relative gesture coordinates to the correct thumb position on the + /// track. It is also used to horizontally position tick marks, when the + /// slider is discrete. + /// + /// The default value is [RoundedRectSliderTrackShape]. + /// + /// See also: + /// + /// * [RoundedRectRangeSliderTrackShape], which is the default track + /// shape for the [RangeSlider]. + final SliderTrackShape? trackShape; + + /// The shape that will be used to draw the [Slider]'s value + /// indicator. + /// + /// The default value is [PaddleSliderValueIndicatorShape]. + /// + /// See also: + /// + /// * [PaddleRangeSliderValueIndicatorShape], which is the default value + /// indicator shape for the [RangeSlider]. + final SliderComponentShape? valueIndicatorShape; + + /// The shape that will be used to draw the [RangeSlider]'s tick marks. + /// + /// The [RangeSliderTickMarkShape.getPreferredSize] is used to help determine + /// the location of each tick mark on the track. The slider's minimum size + /// will be at least this big. + /// + /// The default value is [RoundRangeSliderTickMarkShape]. + /// + /// See also: + /// + /// * [RoundSliderTickMarkShape], which is the default tick mark shape + /// for the [Slider]. + final RangeSliderTickMarkShape? rangeTickMarkShape; + + /// The shape that will be used for the [RangeSlider]'s thumbs. + /// + /// By default the same shape is used for both thumbs, but strokes the top + /// thumb when it overlaps the bottom thumb. The top thumb is always the last + /// selected thumb. + /// + /// The default value is [RoundRangeSliderThumbShape]. + /// + /// See also: + /// + /// * [RoundSliderThumbShape], which is the default thumb shape for the + /// [Slider]. + final RangeSliderThumbShape? rangeThumbShape; + + /// The shape that will be used to draw the [RangeSlider]'s track. + /// + /// The [SliderTrackShape.getPreferredRect] method is used to map + /// slider-relative gesture coordinates to the correct thumb position on the + /// track. It is also used to horizontally position the tick marks, when the + /// slider is discrete. + /// + /// The default value is [RoundedRectRangeSliderTrackShape]. + /// + /// See also: + /// + /// * [RoundedRectSliderTrackShape], which is the default track + /// shape for the [Slider]. + final RangeSliderTrackShape? rangeTrackShape; + + /// The shape that will be used for the [RangeSlider]'s value indicators. + /// + /// The default shape uses the same value indicator for each thumb, but + /// strokes the top value indicator when it overlaps the bottom value + /// indicator. The top indicator corresponds to the top thumb, which is always + /// the most recently selected thumb. + /// + /// The default value is [PaddleRangeSliderValueIndicatorShape]. + /// + /// See also: + /// + /// * [PaddleSliderValueIndicatorShape], which is the default value + /// indicator shape for the [Slider]. + final RangeSliderValueIndicatorShape? rangeValueIndicatorShape; + + /// Whether the value indicator should be shown for different types of + /// sliders. + /// + /// By default, [showValueIndicator] is set to + /// [ShowValueIndicator.onlyForDiscrete]. The value indicator is only shown + /// when the thumb is being touched. + final ShowValueIndicator? showValueIndicator; + + /// The text style for the text on the value indicator. + final TextStyle? valueIndicatorTextStyle; + + /// Limits the thumb's separation distance. + /// + /// Use this only if you want to control the visual appearance of the thumbs + /// in terms of a logical pixel value. This can be done when you want a + /// specific look for thumbs when they are close together. To limit with the + /// real values, rather than logical pixels, the values can be restricted by + /// the parent. + final double? minThumbSeparation; + + /// Determines which thumb should be selected when the slider is interacted + /// with. + /// + /// If null, the default thumb selector finds the closest thumb, excluding + /// taps that are between the thumbs and not within any one touch target. + /// When the selection is within the touch target bounds of both thumbs, no + /// thumb is selected until the selection is moved. + /// + /// Override this for custom thumb selection. + final RangeThumbSelector? thumbSelector; + + /// {@macro flutter.material.slider.mouseCursor} + /// + /// If specified, overrides the default value of [Slider.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Allowed way for the user to interact with the [Slider]. + /// + /// If specified, overrides the default value of [Slider.allowedInteraction]. + final SliderInteraction? allowedInteraction; + + /// Determines the padding around the [Slider]. + /// + /// If specified, this padding overrides the default vertical padding of + /// the [Slider], defaults to the height of the overlay shape, and the + /// horizontal padding, defaults to the width of the thumb shape or + /// overlay shape, whichever is larger. + final EdgeInsetsGeometry? padding; + + /// The size of the [HandleThumbShape] thumb. + /// + /// If [SliderThemeData.thumbShape] is [HandleThumbShape], this property is used to + /// set the size of the thumb. Otherwise, the default thumb size is 4 pixels for the + /// width and 44 pixels for the height. + final WidgetStateProperty<Size?>? thumbSize; + + /// The size of the gap between the active and inactive tracks of the [GappedSliderTrackShape]. + /// + /// If [SliderThemeData.trackShape] is [GappedSliderTrackShape], this property + /// is used to set the gap between the active and inactive tracks. Otherwise, + /// the default gap size is 6.0 pixels. + /// + /// The Slider defaults to [GappedSliderTrackShape] when the track shape is + /// not specified, and the [trackGap] can be used to adjust the gap size. + /// + /// If [Slider.year2023] is true or [ThemeData.useMaterial3] is false, then + /// the Slider track shape defaults to [RoundedRectSliderTrackShape] and the + /// [trackGap] is ignored. In this case, set the track shape to + /// [GappedSliderTrackShape] to use the [trackGap]. + /// + /// Defaults to 6.0 pixels of gap between the active and inactive tracks. + final double? trackGap; + + /// Overrides the default value of [Slider.year2023] and [RangeSlider.year2023]. + /// + /// When true, the [Slider] and [RangeSlider] will use the 2023 Material Design 3 appearance. + /// Defaults to true. + /// + /// If this is set to false, the [Slider] and [RangeSlider] will use the latest Material Design 3 + /// appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Set this flag to false to opt into the 2024 slider appearance. Defaults to true. ' + 'In the future, this flag will default to false. Use SliderThemeData to customize individual properties. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool? year2023; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SliderThemeData copyWith({ + double? trackHeight, + Color? activeTrackColor, + Color? inactiveTrackColor, + Color? secondaryActiveTrackColor, + Color? disabledActiveTrackColor, + Color? disabledInactiveTrackColor, + Color? disabledSecondaryActiveTrackColor, + Color? activeTickMarkColor, + Color? inactiveTickMarkColor, + Color? disabledActiveTickMarkColor, + Color? disabledInactiveTickMarkColor, + Color? thumbColor, + Color? overlappingShapeStrokeColor, + Color? disabledThumbColor, + Color? overlayColor, + Color? valueIndicatorColor, + Color? valueIndicatorStrokeColor, + SliderComponentShape? overlayShape, + SliderTickMarkShape? tickMarkShape, + SliderComponentShape? thumbShape, + SliderTrackShape? trackShape, + SliderComponentShape? valueIndicatorShape, + RangeSliderTickMarkShape? rangeTickMarkShape, + RangeSliderThumbShape? rangeThumbShape, + RangeSliderTrackShape? rangeTrackShape, + RangeSliderValueIndicatorShape? rangeValueIndicatorShape, + ShowValueIndicator? showValueIndicator, + TextStyle? valueIndicatorTextStyle, + double? minThumbSeparation, + RangeThumbSelector? thumbSelector, + WidgetStateProperty<MouseCursor?>? mouseCursor, + SliderInteraction? allowedInteraction, + EdgeInsetsGeometry? padding, + WidgetStateProperty<Size?>? thumbSize, + double? trackGap, + bool? year2023, + }) { + return SliderThemeData( + trackHeight: trackHeight ?? this.trackHeight, + activeTrackColor: activeTrackColor ?? this.activeTrackColor, + inactiveTrackColor: inactiveTrackColor ?? this.inactiveTrackColor, + secondaryActiveTrackColor: secondaryActiveTrackColor ?? this.secondaryActiveTrackColor, + disabledActiveTrackColor: disabledActiveTrackColor ?? this.disabledActiveTrackColor, + disabledInactiveTrackColor: disabledInactiveTrackColor ?? this.disabledInactiveTrackColor, + disabledSecondaryActiveTrackColor: + disabledSecondaryActiveTrackColor ?? this.disabledSecondaryActiveTrackColor, + activeTickMarkColor: activeTickMarkColor ?? this.activeTickMarkColor, + inactiveTickMarkColor: inactiveTickMarkColor ?? this.inactiveTickMarkColor, + disabledActiveTickMarkColor: disabledActiveTickMarkColor ?? this.disabledActiveTickMarkColor, + disabledInactiveTickMarkColor: + disabledInactiveTickMarkColor ?? this.disabledInactiveTickMarkColor, + thumbColor: thumbColor ?? this.thumbColor, + overlappingShapeStrokeColor: overlappingShapeStrokeColor ?? this.overlappingShapeStrokeColor, + disabledThumbColor: disabledThumbColor ?? this.disabledThumbColor, + overlayColor: overlayColor ?? this.overlayColor, + valueIndicatorColor: valueIndicatorColor ?? this.valueIndicatorColor, + valueIndicatorStrokeColor: valueIndicatorStrokeColor ?? this.valueIndicatorStrokeColor, + overlayShape: overlayShape ?? this.overlayShape, + tickMarkShape: tickMarkShape ?? this.tickMarkShape, + thumbShape: thumbShape ?? this.thumbShape, + trackShape: trackShape ?? this.trackShape, + valueIndicatorShape: valueIndicatorShape ?? this.valueIndicatorShape, + rangeTickMarkShape: rangeTickMarkShape ?? this.rangeTickMarkShape, + rangeThumbShape: rangeThumbShape ?? this.rangeThumbShape, + rangeTrackShape: rangeTrackShape ?? this.rangeTrackShape, + rangeValueIndicatorShape: rangeValueIndicatorShape ?? this.rangeValueIndicatorShape, + showValueIndicator: showValueIndicator ?? this.showValueIndicator, + valueIndicatorTextStyle: valueIndicatorTextStyle ?? this.valueIndicatorTextStyle, + minThumbSeparation: minThumbSeparation ?? this.minThumbSeparation, + thumbSelector: thumbSelector ?? this.thumbSelector, + mouseCursor: mouseCursor ?? this.mouseCursor, + allowedInteraction: allowedInteraction ?? this.allowedInteraction, + padding: padding ?? this.padding, + thumbSize: thumbSize ?? this.thumbSize, + trackGap: trackGap ?? this.trackGap, + year2023: year2023 ?? this.year2023, + ); + } + + /// Linearly interpolate between two slider themes. + /// + /// {@macro dart.ui.shadow.lerp} + static SliderThemeData lerp(SliderThemeData a, SliderThemeData b, double t) { + if (identical(a, b)) { + return a; + } + return SliderThemeData( + trackHeight: lerpDouble(a.trackHeight, b.trackHeight, t), + activeTrackColor: Color.lerp(a.activeTrackColor, b.activeTrackColor, t), + inactiveTrackColor: Color.lerp(a.inactiveTrackColor, b.inactiveTrackColor, t), + secondaryActiveTrackColor: Color.lerp( + a.secondaryActiveTrackColor, + b.secondaryActiveTrackColor, + t, + ), + disabledActiveTrackColor: Color.lerp( + a.disabledActiveTrackColor, + b.disabledActiveTrackColor, + t, + ), + disabledInactiveTrackColor: Color.lerp( + a.disabledInactiveTrackColor, + b.disabledInactiveTrackColor, + t, + ), + disabledSecondaryActiveTrackColor: Color.lerp( + a.disabledSecondaryActiveTrackColor, + b.disabledSecondaryActiveTrackColor, + t, + ), + activeTickMarkColor: Color.lerp(a.activeTickMarkColor, b.activeTickMarkColor, t), + inactiveTickMarkColor: Color.lerp(a.inactiveTickMarkColor, b.inactiveTickMarkColor, t), + disabledActiveTickMarkColor: Color.lerp( + a.disabledActiveTickMarkColor, + b.disabledActiveTickMarkColor, + t, + ), + disabledInactiveTickMarkColor: Color.lerp( + a.disabledInactiveTickMarkColor, + b.disabledInactiveTickMarkColor, + t, + ), + thumbColor: Color.lerp(a.thumbColor, b.thumbColor, t), + overlappingShapeStrokeColor: Color.lerp( + a.overlappingShapeStrokeColor, + b.overlappingShapeStrokeColor, + t, + ), + disabledThumbColor: Color.lerp(a.disabledThumbColor, b.disabledThumbColor, t), + overlayColor: Color.lerp(a.overlayColor, b.overlayColor, t), + valueIndicatorColor: Color.lerp(a.valueIndicatorColor, b.valueIndicatorColor, t), + valueIndicatorStrokeColor: Color.lerp( + a.valueIndicatorStrokeColor, + b.valueIndicatorStrokeColor, + t, + ), + overlayShape: t < 0.5 ? a.overlayShape : b.overlayShape, + tickMarkShape: t < 0.5 ? a.tickMarkShape : b.tickMarkShape, + thumbShape: t < 0.5 ? a.thumbShape : b.thumbShape, + trackShape: t < 0.5 ? a.trackShape : b.trackShape, + valueIndicatorShape: t < 0.5 ? a.valueIndicatorShape : b.valueIndicatorShape, + rangeTickMarkShape: t < 0.5 ? a.rangeTickMarkShape : b.rangeTickMarkShape, + rangeThumbShape: t < 0.5 ? a.rangeThumbShape : b.rangeThumbShape, + rangeTrackShape: t < 0.5 ? a.rangeTrackShape : b.rangeTrackShape, + rangeValueIndicatorShape: t < 0.5 ? a.rangeValueIndicatorShape : b.rangeValueIndicatorShape, + showValueIndicator: t < 0.5 ? a.showValueIndicator : b.showValueIndicator, + valueIndicatorTextStyle: TextStyle.lerp( + a.valueIndicatorTextStyle, + b.valueIndicatorTextStyle, + t, + ), + minThumbSeparation: lerpDouble(a.minThumbSeparation, b.minThumbSeparation, t), + thumbSelector: t < 0.5 ? a.thumbSelector : b.thumbSelector, + mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, + allowedInteraction: t < 0.5 ? a.allowedInteraction : b.allowedInteraction, + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbSize: WidgetStateProperty.lerp<Size?>(a.thumbSize, b.thumbSize, t, Size.lerp), + trackGap: lerpDouble(a.trackGap, b.trackGap, t), + year2023: t < 0.5 ? a.year2023 : b.year2023, + ); + } + + @override + int get hashCode => Object.hash( + trackHeight, + activeTrackColor, + inactiveTrackColor, + secondaryActiveTrackColor, + disabledActiveTrackColor, + disabledInactiveTrackColor, + disabledSecondaryActiveTrackColor, + activeTickMarkColor, + inactiveTickMarkColor, + disabledActiveTickMarkColor, + disabledInactiveTickMarkColor, + thumbColor, + overlappingShapeStrokeColor, + disabledThumbColor, + overlayColor, + valueIndicatorColor, + overlayShape, + tickMarkShape, + thumbShape, + Object.hash( + trackShape, + valueIndicatorShape, + rangeTickMarkShape, + rangeThumbShape, + rangeTrackShape, + rangeValueIndicatorShape, + showValueIndicator, + valueIndicatorTextStyle, + minThumbSeparation, + thumbSelector, + mouseCursor, + allowedInteraction, + padding, + thumbSize, + trackGap, + year2023, + ), + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SliderThemeData && + other.trackHeight == trackHeight && + other.activeTrackColor == activeTrackColor && + other.inactiveTrackColor == inactiveTrackColor && + other.secondaryActiveTrackColor == secondaryActiveTrackColor && + other.disabledActiveTrackColor == disabledActiveTrackColor && + other.disabledInactiveTrackColor == disabledInactiveTrackColor && + other.disabledSecondaryActiveTrackColor == disabledSecondaryActiveTrackColor && + other.activeTickMarkColor == activeTickMarkColor && + other.inactiveTickMarkColor == inactiveTickMarkColor && + other.disabledActiveTickMarkColor == disabledActiveTickMarkColor && + other.disabledInactiveTickMarkColor == disabledInactiveTickMarkColor && + other.thumbColor == thumbColor && + other.overlappingShapeStrokeColor == overlappingShapeStrokeColor && + other.disabledThumbColor == disabledThumbColor && + other.overlayColor == overlayColor && + other.valueIndicatorColor == valueIndicatorColor && + other.valueIndicatorStrokeColor == valueIndicatorStrokeColor && + other.overlayShape == overlayShape && + other.tickMarkShape == tickMarkShape && + other.thumbShape == thumbShape && + other.trackShape == trackShape && + other.valueIndicatorShape == valueIndicatorShape && + other.rangeTickMarkShape == rangeTickMarkShape && + other.rangeThumbShape == rangeThumbShape && + other.rangeTrackShape == rangeTrackShape && + other.rangeValueIndicatorShape == rangeValueIndicatorShape && + other.showValueIndicator == showValueIndicator && + other.valueIndicatorTextStyle == valueIndicatorTextStyle && + other.minThumbSeparation == minThumbSeparation && + other.thumbSelector == thumbSelector && + other.mouseCursor == mouseCursor && + other.allowedInteraction == allowedInteraction && + other.padding == padding && + other.thumbSize == thumbSize && + other.trackGap == trackGap && + other.year2023 == year2023; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + const defaultData = SliderThemeData(); + properties.add( + DoubleProperty('trackHeight', trackHeight, defaultValue: defaultData.trackHeight), + ); + properties.add( + ColorProperty( + 'activeTrackColor', + activeTrackColor, + defaultValue: defaultData.activeTrackColor, + ), + ); + properties.add( + ColorProperty( + 'inactiveTrackColor', + inactiveTrackColor, + defaultValue: defaultData.inactiveTrackColor, + ), + ); + properties.add( + ColorProperty( + 'secondaryActiveTrackColor', + secondaryActiveTrackColor, + defaultValue: defaultData.secondaryActiveTrackColor, + ), + ); + properties.add( + ColorProperty( + 'disabledActiveTrackColor', + disabledActiveTrackColor, + defaultValue: defaultData.disabledActiveTrackColor, + ), + ); + properties.add( + ColorProperty( + 'disabledInactiveTrackColor', + disabledInactiveTrackColor, + defaultValue: defaultData.disabledInactiveTrackColor, + ), + ); + properties.add( + ColorProperty( + 'disabledSecondaryActiveTrackColor', + disabledSecondaryActiveTrackColor, + defaultValue: defaultData.disabledSecondaryActiveTrackColor, + ), + ); + properties.add( + ColorProperty( + 'activeTickMarkColor', + activeTickMarkColor, + defaultValue: defaultData.activeTickMarkColor, + ), + ); + properties.add( + ColorProperty( + 'inactiveTickMarkColor', + inactiveTickMarkColor, + defaultValue: defaultData.inactiveTickMarkColor, + ), + ); + properties.add( + ColorProperty( + 'disabledActiveTickMarkColor', + disabledActiveTickMarkColor, + defaultValue: defaultData.disabledActiveTickMarkColor, + ), + ); + properties.add( + ColorProperty( + 'disabledInactiveTickMarkColor', + disabledInactiveTickMarkColor, + defaultValue: defaultData.disabledInactiveTickMarkColor, + ), + ); + properties.add(ColorProperty('thumbColor', thumbColor, defaultValue: defaultData.thumbColor)); + properties.add( + ColorProperty( + 'overlappingShapeStrokeColor', + overlappingShapeStrokeColor, + defaultValue: defaultData.overlappingShapeStrokeColor, + ), + ); + properties.add( + ColorProperty( + 'disabledThumbColor', + disabledThumbColor, + defaultValue: defaultData.disabledThumbColor, + ), + ); + properties.add( + ColorProperty('overlayColor', overlayColor, defaultValue: defaultData.overlayColor), + ); + properties.add( + ColorProperty( + 'valueIndicatorColor', + valueIndicatorColor, + defaultValue: defaultData.valueIndicatorColor, + ), + ); + properties.add( + ColorProperty( + 'valueIndicatorStrokeColor', + valueIndicatorStrokeColor, + defaultValue: defaultData.valueIndicatorStrokeColor, + ), + ); + properties.add( + DiagnosticsProperty<SliderComponentShape>( + 'overlayShape', + overlayShape, + defaultValue: defaultData.overlayShape, + ), + ); + properties.add( + DiagnosticsProperty<SliderTickMarkShape>( + 'tickMarkShape', + tickMarkShape, + defaultValue: defaultData.tickMarkShape, + ), + ); + properties.add( + DiagnosticsProperty<SliderComponentShape>( + 'thumbShape', + thumbShape, + defaultValue: defaultData.thumbShape, + ), + ); + properties.add( + DiagnosticsProperty<SliderTrackShape>( + 'trackShape', + trackShape, + defaultValue: defaultData.trackShape, + ), + ); + properties.add( + DiagnosticsProperty<SliderComponentShape>( + 'valueIndicatorShape', + valueIndicatorShape, + defaultValue: defaultData.valueIndicatorShape, + ), + ); + properties.add( + DiagnosticsProperty<RangeSliderTickMarkShape>( + 'rangeTickMarkShape', + rangeTickMarkShape, + defaultValue: defaultData.rangeTickMarkShape, + ), + ); + properties.add( + DiagnosticsProperty<RangeSliderThumbShape>( + 'rangeThumbShape', + rangeThumbShape, + defaultValue: defaultData.rangeThumbShape, + ), + ); + properties.add( + DiagnosticsProperty<RangeSliderTrackShape>( + 'rangeTrackShape', + rangeTrackShape, + defaultValue: defaultData.rangeTrackShape, + ), + ); + properties.add( + DiagnosticsProperty<RangeSliderValueIndicatorShape>( + 'rangeValueIndicatorShape', + rangeValueIndicatorShape, + defaultValue: defaultData.rangeValueIndicatorShape, + ), + ); + properties.add( + EnumProperty<ShowValueIndicator>( + 'showValueIndicator', + showValueIndicator, + defaultValue: defaultData.showValueIndicator, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'valueIndicatorTextStyle', + valueIndicatorTextStyle, + defaultValue: defaultData.valueIndicatorTextStyle, + ), + ); + properties.add( + DoubleProperty( + 'minThumbSeparation', + minThumbSeparation, + defaultValue: defaultData.minThumbSeparation, + ), + ); + properties.add( + DiagnosticsProperty<RangeThumbSelector>( + 'thumbSelector', + thumbSelector, + defaultValue: defaultData.thumbSelector, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: defaultData.mouseCursor, + ), + ); + properties.add( + EnumProperty<SliderInteraction>( + 'allowedInteraction', + allowedInteraction, + defaultValue: defaultData.allowedInteraction, + ), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'padding', + padding, + defaultValue: defaultData.padding, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Size?>>( + 'thumbSize', + thumbSize, + defaultValue: defaultData.thumbSize, + ), + ); + properties.add(DoubleProperty('trackGap', trackGap, defaultValue: defaultData.trackGap)); + properties.add( + DiagnosticsProperty<bool>('year2023', year2023, defaultValue: defaultData.year2023), + ); + } +} + +/// A callback that formats a numeric value from a [Slider] or [RangeSlider] widget. +/// +/// See also: +/// +/// * [Slider.semanticFormatterCallback], which shows an example use case. +/// * [RangeSlider.semanticFormatterCallback], which shows an example use case. +typedef SemanticFormatterCallback = String Function(double value); diff --git a/packages/material_ui/lib/src/slider_value_indicator_shape.dart b/packages/material_ui/lib/src/slider_value_indicator_shape.dart new file mode 100644 index 000000000000..c793691882b3 --- /dev/null +++ b/packages/material_ui/lib/src/slider_value_indicator_shape.dart @@ -0,0 +1,919 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'range_slider_parts.dart'; +import 'slider.dart'; +import 'slider_theme.dart'; + +/// Base class for slider thumb, thumb overlay, and value indicator shapes. +/// +/// Create a subclass of this if you would like a custom shape. +/// +/// All shapes are painted to the same canvas and ordering is important. +/// The overlay is painted first, then the value indicator, then the thumb. +/// +/// The thumb painting can be skipped by specifying [noThumb] for +/// [SliderThemeData.thumbShape]. +/// +/// The overlay painting can be skipped by specifying [noOverlay] for +/// [SliderThemeData.overlayShape]. +/// +/// See also: +/// +/// * [RoundSliderThumbShape], which is the default [Slider]'s thumb shape that +/// paints a solid circle. +/// * [RoundSliderOverlayShape], which is the default [Slider] and +/// [RangeSlider]'s overlay shape that paints a transparent circle. +/// * [PaddleSliderValueIndicatorShape], which is the default [Slider]'s value +/// indicator shape that paints a custom path with text in it. +abstract class SliderComponentShape { + /// This abstract const constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const SliderComponentShape(); + + /// Returns the preferred size of the shape, based on the given conditions. + Size getPreferredSize(bool isEnabled, bool isDiscrete); + + /// Paints the shape, taking into account the state passed to it. + /// + /// {@template flutter.material.SliderComponentShape.paint.context} + /// The `context` argument is the same as the one that includes the [Slider]'s + /// render box. + /// {@endtemplate} + /// + /// {@template flutter.material.SliderComponentShape.paint.center} + /// The `center` argument is the offset for where this shape's center should be + /// painted. This offset is relative to the origin of the [context] canvas. + /// {@endtemplate} + /// + /// The `activationAnimation` argument is an animation triggered when the user + /// begins to interact with the slider. It reverses when the user stops interacting + /// with the slider. + /// + /// {@template flutter.material.SliderComponentShape.paint.enableAnimation} + /// The `enableAnimation` argument is an animation triggered when the [Slider] + /// is enabled, and it reverses when the slider is disabled. The [Slider] is + /// enabled when [Slider.onChanged] is not null. Use this to paint + /// intermediate frames for this shape when the slider changes enabled state. + /// {@endtemplate} + /// + /// {@template flutter.material.SliderComponentShape.paint.isDiscrete} + /// The `isDiscrete` argument is true if [Slider.divisions] is non-null. When + /// true, the slider will render tick marks on top of the track. + /// {@endtemplate} + /// + /// If the `labelPainter` argument is non-null, then [TextPainter.paint] + /// should be called on the `labelPainter` with the location that the label + /// should appear. If the `labelPainter` argument is null, then no label was + /// supplied to the [Slider]. + /// + /// {@template flutter.material.SliderComponentShape.paint.parentBox} + /// The `parentBox` argument is the [RenderBox] of the [Slider]. Its attributes, + /// such as size, can be used to assist in painting this shape. + /// {@endtemplate} + /// + /// {@template flutter.material.SliderComponentShape.paint.sliderTheme} + /// the `sliderTheme` argument is the theme assigned to the [Slider] that this + /// shape belongs to. + /// {@endtemplate} + /// + /// The `textDirection` argument can be used to determine how any extra text + /// or graphics (besides the text painted by the `labelPainter`) should be + /// positioned. The `labelPainter` already has the [textDirection] set. + /// + /// The `value` argument is the current parametric value (from 0.0 to 1.0) of + /// the slider. + /// + /// {@template flutter.material.SliderComponentShape.paint.textScaleFactor} + /// The `textScaleFactor` argument can be used to determine whether the + /// component should paint larger or smaller, depending on whether + /// [textScaleFactor] is greater than 1 for larger, and between 0 and 1 for + /// smaller. It's usually computed from [MediaQueryData.textScaler]. + /// {@endtemplate} + /// + /// {@template flutter.material.SliderComponentShape.paint.sizeWithOverflow} + /// The `sizeWithOverflow` argument can be used to determine the bounds the + /// drawing of the components that are outside of the regular slider bounds. + /// It's the size of the box, whose center is aligned with the slider's + /// bounds, that the value indicators must be drawn within. Typically, it is + /// bigger than the slider. + /// {@endtemplate} + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }); + + /// Special instance of [SliderComponentShape] to skip the thumb drawing. + /// + /// See also: + /// + /// * [SliderThemeData.thumbShape], which is the shape that the [Slider] + /// uses when painting the thumb. + static final SliderComponentShape noThumb = _EmptySliderComponentShape(); + + /// Special instance of [SliderComponentShape] to skip the overlay drawing. + /// + /// See also: + /// + /// * [SliderThemeData.overlayShape], which is the shape that the [Slider] + /// uses when painting the overlay. + static final SliderComponentShape noOverlay = _EmptySliderComponentShape(); +} + +/// A special version of [SliderComponentShape] that has a zero size and paints +/// nothing. +/// +/// This class is used to create a special instance of a [SliderComponentShape] +/// that will not paint any component shape. A static reference is stored in +/// [SliderComponentShape.noThumb] and [SliderComponentShape.noOverlay]. When this value +/// is specified for [SliderThemeData.thumbShape], the thumb painting is +/// skipped. When this value is specified for [SliderThemeData.overlayShape], +/// the overlay painting is skipped. +class _EmptySliderComponentShape extends SliderComponentShape { + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.zero; + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + // no-op. + } +} + +/// The default shape of a [Slider]'s thumb overlay. +/// +/// The shape of the overlay is a circle with the same center as the thumb, but +/// with a larger radius. It animates to full size when the thumb is pressed, +/// and animates back down to size 0 when it is released. It is painted behind +/// the thumb, and is expected to extend beyond the bounds of the thumb so that +/// it is visible. +/// +/// The overlay color is defined by [SliderThemeData.overlayColor]. +/// +/// See also: +/// +/// * [Slider], which includes an overlay defined by this shape. +/// * [SliderTheme], which can be used to configure the overlay shape of all +/// sliders in a widget subtree. +class RoundSliderOverlayShape extends SliderComponentShape { + /// Create a slider thumb overlay that draws a circle. + const RoundSliderOverlayShape({this.overlayRadius = 24.0}); + + /// The preferred radius of the round thumb shape when enabled. + /// + /// If it is not provided, then half of the [SliderThemeData.trackHeight] is + /// used. + final double overlayRadius; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size.fromRadius(overlayRadius); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final radiusTween = Tween<double>(begin: 0.0, end: overlayRadius); + + canvas.drawCircle( + center, + radiusTween.evaluate(activationAnimation), + Paint()..color = sliderTheme.overlayColor!, + ); + } +} + +/// The default shape of a [Slider]'s value indicator. +/// +/// ![A slider widget, consisting of 5 divisions and showing the rectangular slider value indicator shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rectangular_slider_value_indicator_shape.png) +/// +/// See also: +/// +/// * [Slider], which includes a value indicator defined by this shape. +/// * [SliderTheme], which can be used to configure the slider value indicator +/// of all sliders in a widget subtree. +class RectangularSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator that resembles a rectangular tooltip. + const RectangularSliderValueIndicatorShape(); + + static const _RectangularSliderValueIndicatorPathPainter _pathPainter = + _RectangularSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final double scale = activationAnimation.value; + _pathPainter.paint( + parentBox: parentBox, + canvas: canvas, + center: center, + scale: scale, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + backgroundPaintColor: sliderTheme.valueIndicatorColor!, + strokePaintColor: sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +/// The default shape of a [RangeSlider]'s value indicators. +/// +/// ![A slider widget, consisting of 5 divisions and showing the rectangular range slider value indicator shape.](https://flutter.github.io/assets-for-api-docs/assets/material/rectangular_range_slider_value_indicator_shape.png) +/// +/// See also: +/// +/// * [RangeSlider], which includes value indicators defined by this shape. +/// * [SliderTheme], which can be used to configure the range slider value +/// indicator of all sliders in a widget subtree. +class RectangularRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { + /// Create a range slider value indicator that resembles a rectangular tooltip. + const RectangularRangeSliderValueIndicatorShape(); + + static const _RectangularSliderValueIndicatorPathPainter _pathPainter = + _RectangularSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + required TextPainter labelPainter, + required double textScaleFactor, + }) { + assert(textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter, textScaleFactor); + } + + @override + double getHorizontalShift({ + RenderBox? parentBox, + Offset? center, + TextPainter? labelPainter, + Animation<double>? activationAnimation, + double? textScaleFactor, + Size? sizeWithOverflow, + }) { + return _pathPainter.getHorizontalShift( + parentBox: parentBox!, + center: center!, + labelPainter: labelPainter!, + textScaleFactor: textScaleFactor!, + sizeWithOverflow: sizeWithOverflow!, + scale: activationAnimation!.value, + ); + } + + @override + void paint( + PaintingContext context, + Offset center, { + Animation<double>? activationAnimation, + Animation<double>? enableAnimation, + bool? isDiscrete, + bool? isOnTop, + TextPainter? labelPainter, + double? textScaleFactor, + Size? sizeWithOverflow, + RenderBox? parentBox, + SliderThemeData? sliderTheme, + TextDirection? textDirection, + double? value, + Thumb? thumb, + }) { + final Canvas canvas = context.canvas; + final double scale = activationAnimation!.value; + _pathPainter.paint( + parentBox: parentBox!, + canvas: canvas, + center: center, + scale: scale, + labelPainter: labelPainter!, + textScaleFactor: textScaleFactor!, + sizeWithOverflow: sizeWithOverflow!, + backgroundPaintColor: sliderTheme!.valueIndicatorColor!, + strokePaintColor: isOnTop! + ? sliderTheme.overlappingShapeStrokeColor + : sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +class _RectangularSliderValueIndicatorPathPainter { + const _RectangularSliderValueIndicatorPathPainter(); + + static const double _triangleHeight = 8.0; + static const double _labelPadding = 16.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 16.0; + static const double _bottomTipYOffset = 14.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + static const double _upperRectRadius = 4; + + Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { + return Size( + _upperRectangleWidth(labelPainter, 1, textScaleFactor), + labelPainter.height + _labelPadding, + ); + } + + double getHorizontalShift({ + required RenderBox parentBox, + required Offset center, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required double scale, + }) { + assert(!sizeWithOverflow.isEmpty); + + const edgePadding = 8.0; + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale, textScaleFactor); + + /// Value indicator draws on the Overlay and by using the global Offset + /// we are making sure we use the bounds of the Overlay instead of the Slider. + final Offset globalCenter = parentBox.localToGlobal(center); + + // The rectangle must be shifted towards the center so that it minimizes the + // chance of it rendering outside the bounds of the render box. If the shift + // is negative, then the lobe is shifted from right to left, and if it is + // positive, then the lobe is shifted from left to right. + final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding); + final double overflowRight = math.max( + 0, + rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding), + ); + + if (rectangleWidth < sizeWithOverflow.width) { + return overflowLeft - overflowRight; + } else if (overflowLeft - overflowRight > 0) { + return overflowLeft - (edgePadding * textScaleFactor); + } else { + return -overflowRight + (edgePadding * textScaleFactor); + } + } + + double _upperRectangleWidth(TextPainter labelPainter, double scale, double textScaleFactor) { + final double unscaledWidth = + math.max(_minLabelWidth * textScaleFactor, labelPainter.width) + _labelPadding * 2; + return unscaledWidth * scale; + } + + void paint({ + required RenderBox parentBox, + required Canvas canvas, + required Offset center, + required double scale, + required TextPainter labelPainter, + required double textScaleFactor, + required Size sizeWithOverflow, + required Color backgroundPaintColor, + Color? strokePaintColor, + }) { + if (scale == 0.0) { + // Zero scale essentially means "do not draw anything", so it's safe to just return. + return; + } + assert(!sizeWithOverflow.isEmpty); + + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale, textScaleFactor); + final double horizontalShift = getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: scale, + ); + + final double rectHeight = labelPainter.height + _labelPadding; + final upperRect = Rect.fromLTWH( + -rectangleWidth / 2 + horizontalShift, + -_triangleHeight - rectHeight, + rectangleWidth, + rectHeight, + ); + + final trianglePath = Path() + ..lineTo(-_triangleHeight, -_triangleHeight) + ..lineTo(_triangleHeight, -_triangleHeight) + ..close(); + final fillPaint = Paint()..color = backgroundPaintColor; + final upperRRect = RRect.fromRectAndRadius(upperRect, const Radius.circular(_upperRectRadius)); + trianglePath.addRRect(upperRRect); + + canvas.save(); + // Prepare the canvas for the base of the tooltip, which is relative to the + // center of the thumb. + canvas.translate(center.dx, center.dy - _bottomTipYOffset); + canvas.scale(scale, scale); + if (strokePaintColor != null) { + final strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawPath(trianglePath, strokePaint); + } + canvas.drawPath(trianglePath, fillPaint); + + // The label text is centered within the value indicator. + final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height; + canvas.translate(0, bottomTipToUpperRectTranslateY); + final boxCenter = Offset(horizontalShift, upperRect.height / 2); + final halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} + +/// A variant shape of a [Slider]'s value indicator . The value indicator is in +/// the shape of an upside-down pear. +/// +/// ![A slider widget, consisting of 5 divisions and showing the paddle slider value indicator shape.](https://flutter.github.io/assets-for-api-docs/assets/material/paddle_slider_value_indicator_shape.png) +/// +/// See also: +/// +/// * [Slider], which includes a value indicator defined by this shape. +/// * [SliderTheme], which can be used to configure the slider value indicator +/// of all sliders in a widget subtree. +class PaddleSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator in the shape of an upside-down pear. + const PaddleSliderValueIndicatorShape(); + + static const _PaddleSliderValueIndicatorPathPainter _pathPainter = + _PaddleSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + assert(!sizeWithOverflow.isEmpty); + final enableColor = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.valueIndicatorColor, + ); + _pathPainter.paint( + context.canvas, + center, + Paint()..color = enableColor.evaluate(enableAnimation)!, + activationAnimation.value, + labelPainter, + textScaleFactor, + sizeWithOverflow, + sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +/// A variant shape of a [RangeSlider]'s value indicators. The value indicator +/// is in the shape of an upside-down pear. +/// +/// ![A slider widget, consisting of 5 divisions and showing the paddle range slider value indicator shape.](https://flutter.github.io/assets-for-api-docs/assets/material/paddle_range_slider_value_indicator_shape.png) +/// +/// See also: +/// +/// * [RangeSlider], which includes value indicators defined by this shape. +/// * [SliderTheme], which can be used to configure the range slider value +/// indicator of all sliders in a widget subtree. +class PaddleRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { + /// Create a slider value indicator in the shape of an upside-down pear. + const PaddleRangeSliderValueIndicatorShape(); + + static const _PaddleSliderValueIndicatorPathPainter _pathPainter = + _PaddleSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + required TextPainter labelPainter, + required double textScaleFactor, + }) { + assert(textScaleFactor >= 0); + return _pathPainter.getPreferredSize(labelPainter, textScaleFactor); + } + + @override + double getHorizontalShift({ + RenderBox? parentBox, + Offset? center, + TextPainter? labelPainter, + Animation<double>? activationAnimation, + double? textScaleFactor, + Size? sizeWithOverflow, + }) { + return _pathPainter.getHorizontalShift( + center: center!, + labelPainter: labelPainter!, + scale: activationAnimation!.value, + textScaleFactor: textScaleFactor!, + sizeWithOverflow: sizeWithOverflow!, + ); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool? isDiscrete, + bool isOnTop = false, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + Thumb? thumb, + double? value, + double? textScaleFactor, + Size? sizeWithOverflow, + }) { + assert(!sizeWithOverflow!.isEmpty); + final enableColor = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.valueIndicatorColor, + ); + // Add a stroke of 1dp around the top paddle. + _pathPainter.paint( + context.canvas, + center, + Paint()..color = enableColor.evaluate(enableAnimation)!, + activationAnimation.value, + labelPainter, + textScaleFactor!, + sizeWithOverflow!, + isOnTop ? sliderTheme.overlappingShapeStrokeColor : sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +class _PaddleSliderValueIndicatorPathPainter { + const _PaddleSliderValueIndicatorPathPainter(); + + // These constants define the shape of the default value indicator. + // The value indicator changes shape based on the size of + // the label: The top lobe spreads horizontally, and the + // top arc on the neck moves down to keep it merging smoothly + // with the top lobe as it expands. + + // Radius of the top lobe of the value indicator. + static const double _topLobeRadius = 16.0; + static const double _minLabelWidth = 16.0; + // Radius of the bottom lobe of the value indicator. + static const double _bottomLobeRadius = 10.0; + static const double _labelPadding = 8.0; + static const double _distanceBetweenTopBottomCenters = 40.0; + static const double _middleNeckWidth = 3.0; + static const double _bottomNeckRadius = 4.5; + // The base of the triangle between the top lobe center and the centers of + // the two top neck arcs. + static const double _neckTriangleBase = _topNeckRadius + _middleNeckWidth / 2; + static const double _rightBottomNeckCenterX = _middleNeckWidth / 2 + _bottomNeckRadius; + static const double _rightBottomNeckAngleStart = math.pi; + static const Offset _topLobeCenter = Offset(0.0, -_distanceBetweenTopBottomCenters); + static const double _topNeckRadius = 13.0; + // The length of the hypotenuse of the triangle formed by the center + // of the left top lobe arc and the center of the top left neck arc. + // Used to calculate the position of the center of the arc. + static const double _neckTriangleHypotenuse = _topLobeRadius + _topNeckRadius; + // Some convenience values to help readability. + static const double _twoSeventyDegrees = 3.0 * math.pi / 2.0; + static const double _ninetyDegrees = math.pi / 2.0; + static const double _thirtyDegrees = math.pi / 6.0; + static const double _preferredHeight = + _distanceBetweenTopBottomCenters + _topLobeRadius + _bottomLobeRadius; + // Set to true if you want a rectangle to be drawn around the label bubble. + // This helps with building tests that check that the label draws in the right + // place (because it prints the rect in the failed test output). It should not + // be checked in while set to "true". + static const bool _debuggingLabelLocation = false; + + Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { + assert(textScaleFactor >= 0); + final double width = + math.max(_minLabelWidth * textScaleFactor, labelPainter.width) + + _labelPadding * 2 * textScaleFactor; + return Size(width, _preferredHeight * textScaleFactor); + } + + // Adds an arc to the path that has the attributes passed in. This is + // a convenience to make adding arcs have less boilerplate. + static void _addArc(Path path, Offset center, double radius, double startAngle, double endAngle) { + assert(center.isFinite); + final arcRect = Rect.fromCircle(center: center, radius: radius); + path.arcTo(arcRect, startAngle, endAngle - startAngle, false); + } + + double getHorizontalShift({ + required Offset center, + required TextPainter labelPainter, + required double scale, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + assert(!sizeWithOverflow.isEmpty); + final double inverseTextScale = textScaleFactor != 0 ? 1.0 / textScaleFactor : 0.0; + final double labelHalfWidth = labelPainter.width / 2.0; + final double halfWidthNeeded = math.max( + 0.0, + inverseTextScale * labelHalfWidth - (_topLobeRadius - _labelPadding), + ); + final double shift = _getIdealOffset( + halfWidthNeeded, + textScaleFactor * scale, + center, + sizeWithOverflow.width, + ); + return shift * textScaleFactor; + } + + // Determines the "best" offset to keep the bubble within the slider. The + // calling code will bound that with the available movement in the paddle shape. + double _getIdealOffset( + double halfWidthNeeded, + double scale, + Offset center, + double widthWithOverflow, + ) { + const edgeMargin = 8.0; + final topLobeRect = Rect.fromLTWH( + -_topLobeRadius - halfWidthNeeded, + -_topLobeRadius - _distanceBetweenTopBottomCenters, + 2.0 * (_topLobeRadius + halfWidthNeeded), + 2.0 * _topLobeRadius, + ); + // We can just multiply by scale instead of a transform, since we're scaling + // around (0, 0). + final Offset topLeft = (topLobeRect.topLeft * scale) + center; + final Offset bottomRight = (topLobeRect.bottomRight * scale) + center; + var shift = 0.0; + + if (topLeft.dx < edgeMargin) { + shift = edgeMargin - topLeft.dx; + } + + final endGlobal = widthWithOverflow; + if (bottomRight.dx > endGlobal - edgeMargin) { + shift = endGlobal - edgeMargin - bottomRight.dx; + } + + shift = scale == 0.0 ? 0.0 : shift / scale; + if (shift < 0.0) { + // Shifting to the left. + shift = math.max(shift, -halfWidthNeeded); + } else { + // Shifting to the right. + shift = math.min(shift, halfWidthNeeded); + } + return shift; + } + + void paint( + Canvas canvas, + Offset center, + Paint paint, + double scale, + TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, + Color? strokePaintColor, + ) { + if (scale == 0.0) { + // Zero scale essentially means "do not draw anything", so it's safe to just return. Otherwise, + // our math below will attempt to divide by zero and send needless NaNs to the engine. + return; + } + assert(!sizeWithOverflow.isEmpty); + + // The entire value indicator should scale with the size of the label, + // to keep it large enough to encompass the label text. + final double overallScale = scale * textScaleFactor; + final double inverseTextScale = textScaleFactor != 0 ? 1.0 / textScaleFactor : 0.0; + final double labelHalfWidth = labelPainter.width / 2.0; + + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.scale(overallScale, overallScale); + + final double bottomNeckTriangleHypotenuse = + _bottomNeckRadius + _bottomLobeRadius / overallScale; + final double rightBottomNeckCenterY = -math.sqrt( + math.pow(bottomNeckTriangleHypotenuse, 2) - math.pow(_rightBottomNeckCenterX, 2), + ); + final double rightBottomNeckAngleEnd = + math.pi + math.atan(rightBottomNeckCenterY / _rightBottomNeckCenterX); + final path = Path()..moveTo(_middleNeckWidth / 2, rightBottomNeckCenterY); + _addArc( + path, + Offset(_rightBottomNeckCenterX, rightBottomNeckCenterY), + _bottomNeckRadius, + _rightBottomNeckAngleStart, + rightBottomNeckAngleEnd, + ); + _addArc( + path, + Offset.zero, + _bottomLobeRadius / overallScale, + rightBottomNeckAngleEnd - math.pi, + 2 * math.pi - rightBottomNeckAngleEnd, + ); + _addArc( + path, + Offset(-_rightBottomNeckCenterX, rightBottomNeckCenterY), + _bottomNeckRadius, + math.pi - rightBottomNeckAngleEnd, + 0, + ); + + // This is the needed extra width for the label. It is only positive when + // the label exceeds the minimum size contained by the round top lobe. + final double halfWidthNeeded = math.max( + 0.0, + inverseTextScale * labelHalfWidth - (_topLobeRadius - _labelPadding), + ); + + final double shift = _getIdealOffset( + halfWidthNeeded, + overallScale, + center, + sizeWithOverflow.width, + ); + final double leftWidthNeeded = halfWidthNeeded - shift; + final double rightWidthNeeded = halfWidthNeeded + shift; + + // The parameter that describes how far along the transition from round to + // stretched we are. + final double leftAmount = math.max(0.0, math.min(1.0, leftWidthNeeded / _neckTriangleBase)); + final double rightAmount = math.max(0.0, math.min(1.0, rightWidthNeeded / _neckTriangleBase)); + // The angle between the top neck arc's center and the top lobe's center + // and vertical. The base amount is chosen so that the neck is smooth, + // even when the lobe is shifted due to its size. + final double leftTheta = (1.0 - leftAmount) * _thirtyDegrees; + final double rightTheta = (1.0 - rightAmount) * _thirtyDegrees; + // The center of the top left neck arc. + final leftTopNeckCenter = Offset( + -_neckTriangleBase, + _topLobeCenter.dy + math.cos(leftTheta) * _neckTriangleHypotenuse, + ); + final neckRightCenter = Offset( + _neckTriangleBase, + _topLobeCenter.dy + math.cos(rightTheta) * _neckTriangleHypotenuse, + ); + final double leftNeckArcAngle = _ninetyDegrees - leftTheta; + final double rightNeckArcAngle = math.pi + _ninetyDegrees - rightTheta; + // The distance between the end of the bottom neck arc and the beginning of + // the top neck arc. We use this to shrink/expand it based on the scale + // factor of the value indicator. + final double neckStretchBaseline = math.max( + 0.0, + rightBottomNeckCenterY - math.max(leftTopNeckCenter.dy, neckRightCenter.dy), + ); + final t = math.pow(inverseTextScale, 3.0) as double; + final double stretch = clampDouble(neckStretchBaseline * t, 0.0, 10.0 * neckStretchBaseline); + final neckStretch = Offset(0.0, neckStretchBaseline - stretch); + + assert( + !_debuggingLabelLocation || + () { + final Offset leftCenter = _topLobeCenter - Offset(leftWidthNeeded, 0.0) + neckStretch; + final Offset rightCenter = _topLobeCenter + Offset(rightWidthNeeded, 0.0) + neckStretch; + final valueRect = Rect.fromLTRB( + leftCenter.dx - _topLobeRadius, + leftCenter.dy - _topLobeRadius, + rightCenter.dx + _topLobeRadius, + rightCenter.dy + _topLobeRadius, + ); + final outlinePaint = Paint() + ..color = const Color(0xffff0000) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawRect(valueRect, outlinePaint); + return true; + }(), + ); + + _addArc(path, leftTopNeckCenter + neckStretch, _topNeckRadius, 0.0, -leftNeckArcAngle); + _addArc( + path, + _topLobeCenter - Offset(leftWidthNeeded, 0.0) + neckStretch, + _topLobeRadius, + _ninetyDegrees + leftTheta, + _twoSeventyDegrees, + ); + _addArc( + path, + _topLobeCenter + Offset(rightWidthNeeded, 0.0) + neckStretch, + _topLobeRadius, + _twoSeventyDegrees, + _twoSeventyDegrees + math.pi - rightTheta, + ); + _addArc(path, neckRightCenter + neckStretch, _topNeckRadius, rightNeckArcAngle, math.pi); + + if (strokePaintColor != null) { + final strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawPath(path, strokePaint); + } + + canvas.drawPath(path, paint); + + // Draw the label. + canvas.save(); + canvas.translate(shift, -_distanceBetweenTopBottomCenters + neckStretch.dy); + canvas.scale(inverseTextScale, inverseTextScale); + labelPainter.paint(canvas, Offset.zero - Offset(labelHalfWidth, labelPainter.height / 2.0)); + canvas.restore(); + canvas.restore(); + } +} diff --git a/packages/material_ui/lib/src/snack_bar.dart b/packages/material_ui/lib/src/snack_bar.dart new file mode 100644 index 000000000000..4c9eb6a689d3 --- /dev/null +++ b/packages/material_ui/lib/src/snack_bar.dart @@ -0,0 +1,1002 @@ +// 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. + +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'floating_action_button.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'scaffold.dart'; +import 'snack_bar_theme.dart'; +import 'text_button.dart'; +import 'text_button_theme.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +const double _singleLineVerticalPadding = 14.0; +const Duration _snackBarTransitionDuration = Duration(milliseconds: 250); +const Duration _snackBarDisplayDuration = Duration(milliseconds: 4000); +const Curve _snackBarHeightCurve = Curves.fastOutSlowIn; +const Curve _snackBarM3HeightCurve = Curves.easeInOutQuart; + +const Curve _snackBarFadeInCurve = Interval(0.4, 1.0); +const Curve _snackBarM3FadeInCurve = Interval(0.4, 0.6, curve: Curves.easeInCirc); +const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); + +/// Specify how a [SnackBar] was closed. +/// +/// The [ScaffoldMessengerState.showSnackBar] function returns a +/// [ScaffoldFeatureController]. The value of the controller's closed property +/// is a Future that resolves to a SnackBarClosedReason. Applications that need +/// to know how a snackbar was closed can use this value. +/// +/// Example: +/// +/// ```dart +/// ScaffoldMessenger.of(context).showSnackBar( +/// const SnackBar( +/// content: Text('He likes me. I think he likes me.'), +/// ) +/// ).closed.then((SnackBarClosedReason reason) { +/// // ... +/// }); +/// ``` +enum SnackBarClosedReason { + /// The snack bar was closed after the user tapped a [SnackBarAction]. + action, + + /// The snack bar was closed through a [SemanticsAction.dismiss]. + dismiss, + + /// The snack bar was closed by a user's swipe. + swipe, + + /// The snack bar was closed by the [ScaffoldFeatureController] close callback + /// or by calling [ScaffoldMessengerState.hideCurrentSnackBar] directly. + hide, + + /// The snack bar was closed by an call to [ScaffoldMessengerState.removeCurrentSnackBar]. + remove, + + /// The snack bar was closed because its timer expired. + timeout, +} + +/// A button for a [SnackBar], known as an "action". +/// +/// Snack bar actions are always enabled. Instead of disabling a snack bar +/// action, avoid including it in the snack bar in the first place. +/// +/// Snack bar actions can only be pressed once. Subsequent presses are ignored. +/// +/// See also: +/// +/// * [SnackBar] +/// * <https://material.io/design/components/snackbars.html> +class SnackBarAction extends StatefulWidget { + /// Creates an action for a [SnackBar]. + const SnackBarAction({ + super.key, + this.textColor, + this.disabledTextColor, + this.backgroundColor, + this.disabledBackgroundColor, + required this.label, + required this.onPressed, + }) : assert( + backgroundColor is! WidgetStateColor || disabledBackgroundColor == null, + 'disabledBackgroundColor must not be provided when background color is ' + 'a WidgetStateColor', + ); + + /// The button label color. If not provided, defaults to + /// [SnackBarThemeData.actionTextColor]. + /// + /// If [textColor] is a [WidgetStateColor], then the text color will be + /// resolved against the set of [WidgetState]s that the action text + /// is in, thus allowing for different colors for states such as pressed, + /// hovered and others. + final Color? textColor; + + /// The button background fill color. If not provided, defaults to + /// [SnackBarThemeData.actionBackgroundColor]. + /// + /// If [backgroundColor] is a [WidgetStateColor], then the text color will + /// be resolved against the set of [WidgetState]s that the action text is + /// in, thus allowing for different colors for the states. + final Color? backgroundColor; + + /// The button disabled label color. This color is shown after the + /// [SnackBarAction] is dismissed. + final Color? disabledTextColor; + + /// The button disabled background color. This color is shown after the + /// [SnackBarAction] is dismissed. + /// + /// If not provided, defaults to [SnackBarThemeData.disabledActionBackgroundColor]. + final Color? disabledBackgroundColor; + + /// The button label. + final String label; + + /// The callback to be called when the button is pressed. + /// + /// This callback will be called at most once each time this action is + /// displayed in a [SnackBar]. + final VoidCallback onPressed; + + @override + State<SnackBarAction> createState() => _SnackBarActionState(); +} + +class _SnackBarActionState extends State<SnackBarAction> { + bool _haveTriggeredAction = false; + + void _handlePressed() { + if (_haveTriggeredAction) { + return; + } + setState(() { + _haveTriggeredAction = true; + }); + widget.onPressed(); + ScaffoldMessenger.of(context).hideCurrentSnackBar(reason: SnackBarClosedReason.action); + } + + @override + Widget build(BuildContext context) { + final SnackBarThemeData defaults = Theme.of(context).useMaterial3 + ? _SnackbarDefaultsM3(context) + : _SnackbarDefaultsM2(context); + final SnackBarThemeData snackBarTheme = SnackBarTheme.of(context); + + WidgetStateColor resolveForegroundColor() { + if (widget.textColor != null) { + if (widget.textColor is WidgetStateColor) { + return widget.textColor! as WidgetStateColor; + } + } else if (snackBarTheme.actionTextColor != null) { + if (snackBarTheme.actionTextColor is WidgetStateColor) { + return snackBarTheme.actionTextColor! as WidgetStateColor; + } + } else if (defaults.actionTextColor != null) { + if (defaults.actionTextColor is WidgetStateColor) { + return defaults.actionTextColor! as WidgetStateColor; + } + } + + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return widget.disabledTextColor ?? + snackBarTheme.disabledActionTextColor ?? + defaults.disabledActionTextColor!; + } + return widget.textColor ?? snackBarTheme.actionTextColor ?? defaults.actionTextColor!; + }); + } + + WidgetStateColor? resolveBackgroundColor() { + if (widget.backgroundColor is WidgetStateColor) { + return widget.backgroundColor! as WidgetStateColor; + } + if (snackBarTheme.actionBackgroundColor is WidgetStateColor) { + return snackBarTheme.actionBackgroundColor! as WidgetStateColor; + } + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return widget.disabledBackgroundColor ?? + snackBarTheme.disabledActionBackgroundColor ?? + Colors.transparent; + } + return widget.backgroundColor ?? snackBarTheme.actionBackgroundColor ?? Colors.transparent; + }); + } + + return TextButton( + style: TextButton.styleFrom(overlayColor: resolveForegroundColor()).copyWith( + foregroundColor: resolveForegroundColor(), + backgroundColor: resolveBackgroundColor(), + ), + onPressed: _haveTriggeredAction ? null : _handlePressed, + child: Text(widget.label), + ); + } +} + +/// A lightweight message with an optional action which briefly displays at the +/// bottom of the screen. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=zpO6n_oZWw0} +/// +/// To display a snack bar, call `ScaffoldMessenger.of(context).showSnackBar()`, +/// passing an instance of [SnackBar] that describes the message. +/// +/// To control how long the [SnackBar] remains visible, specify a [duration]. +/// +/// A SnackBar with an action will not time out when TalkBack or VoiceOver are +/// enabled. This is controlled by [AccessibilityFeatures.accessibleNavigation]. +/// +/// During page transitions, the [SnackBar] will smoothly animate to its +/// location on the other page. For example if the [SnackBar.behavior] is set to +/// [SnackBarBehavior.floating] and the next page has a floating action button, +/// while the current one does not, the [SnackBar] will smoothly animate above +/// the floating action button. It also works in the case of a back gesture +/// transition. +/// +/// {@tool dartpad} +/// Here is an example of a [SnackBar] with an [action] button implemented using +/// [SnackBarAction]. +/// +/// ** See code in examples/api/lib/material/snack_bar/snack_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// Here is an example of a customized [SnackBar]. It utilizes +/// [behavior], [shape], [padding], [width], and [duration] to customize the +/// location, appearance, and the duration for which the [SnackBar] is visible. +/// +/// ** See code in examples/api/lib/material/snack_bar/snack_bar.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example demonstrates the various [SnackBar] widget components, +/// including an optional icon, in either floating or fixed format. +/// +/// ** See code in examples/api/lib/material/snack_bar/snack_bar.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ScaffoldMessenger.of], to obtain the current [ScaffoldMessengerState], +/// which manages the display and animation of snack bars. +/// * [ScaffoldMessengerState.showSnackBar], which displays a [SnackBar]. +/// * [ScaffoldMessengerState.removeCurrentSnackBar], which abruptly hides the +/// currently displayed snack bar, if any, and allows the next to be displayed. +/// * [SnackBarAction], which is used to specify an [action] button to show +/// on the snack bar. +/// * [SnackBarThemeData], to configure the default property values for +/// [SnackBar] widgets. +/// * <https://material.io/design/components/snackbars.html> +class SnackBar extends StatefulWidget { + /// Creates a snack bar. + /// + /// The [elevation] must be null or non-negative. + const SnackBar({ + super.key, + required this.content, + this.backgroundColor, + this.elevation, + this.margin, + this.padding, + this.width, + this.shape, + this.hitTestBehavior, + this.behavior, + this.action, + this.actionOverflowThreshold, + this.showCloseIcon, + this.closeIconColor, + this.duration = _snackBarDisplayDuration, + bool? persist, + this.animation, + this.onVisible, + this.dismissDirection, + this.clipBehavior = Clip.hardEdge, + }) : assert(elevation == null || elevation >= 0.0), + assert(width == null || margin == null, 'Width and margin can not be used together'), + assert( + actionOverflowThreshold == null || + (actionOverflowThreshold >= 0 && actionOverflowThreshold <= 1), + 'Action overflow threshold must be between 0 and 1 inclusive', + ), + persist = persist ?? action != null; + + /// The primary content of the snack bar. + /// + /// Typically a [Text] widget. + final Widget content; + + /// The snack bar's background color. + /// + /// If not specified, the ambient [SnackBarThemeData.backgroundColor] is used. + /// If that is not specified it will default to a + /// dark variation of [ColorScheme.surface] for light themes, or + /// [ColorScheme.onSurface] for dark themes. + final Color? backgroundColor; + + /// The z-coordinate at which to place the snack bar. This controls the size + /// of the shadow below the snack bar. + /// + /// Defines the card's [Material.elevation]. + /// + /// If this property is null, then the ambient [SnackBarThemeData.elevation] + /// is used, if that is also null, the default value is 6.0. + final double? elevation; + + /// Empty space to surround the snack bar. + /// + /// This property is only used when [behavior] is [SnackBarBehavior.floating]. + /// It can not be used if [width] is specified. + /// + /// If this property is null, then the ambient [SnackBarThemeData.insetPadding] + /// is used. If that is also null, then the default is + /// `EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0)`. + /// + /// If this property is not null and [hitTestBehavior] is null, then [hitTestBehavior] default is [HitTestBehavior.deferToChild]. + final EdgeInsetsGeometry? margin; + + /// The amount of padding to apply to the snack bar's content and optional + /// action. + /// + /// If this property is null, the default padding values are as follows: + /// + /// * [content] + /// * Top and bottom paddings are 14. + /// * Left padding is 24 if [behavior] is [SnackBarBehavior.fixed], + /// 16 if [behavior] is [SnackBarBehavior.floating]. + /// * Right padding is same as start padding if there is no [action], + /// otherwise 0. + /// * [action] + /// * Top and bottom paddings are 14. + /// * Left and right paddings are half of [content]'s left padding. + /// + /// If this property is not null, the padding is as follows: + /// + /// * [content] + /// * Left, top and bottom paddings are assigned normally. + /// * Right padding is assigned normally if there is no [action], + /// otherwise 0. + /// * [action] + /// * Left padding is replaced with half the right padding. + /// * Top and bottom paddings are assigned normally. + /// * Right padding is replaced with one and a half times the + /// right padding. + final EdgeInsetsGeometry? padding; + + /// The width of the snack bar. + /// + /// If width is specified, the snack bar will be centered horizontally in the + /// available space. This property is only used when [behavior] is + /// [SnackBarBehavior.floating]. It can not be used if [margin] is specified. + /// + /// If this property is null, then the ambient [SnackBarThemeData.width] + /// is used. If that is null, the snack bar will take up the full device + /// width less the margin. + final double? width; + + /// The shape of the snack bar's [Material]. + /// + /// Defines the snack bar's [Material.shape]. + /// + /// If this property is null, then the ambient [SnackBarThemeData.shape] + /// is used. If that's null then the shape will + /// depend on the [SnackBarBehavior]. For [SnackBarBehavior.fixed], no + /// overriding shape is specified, so the [SnackBar] is rectangular. For + /// [SnackBarBehavior.floating], it uses a [RoundedRectangleBorder] with a + /// circular corner radius of 4.0. + final ShapeBorder? shape; + + /// Defines how the snack bar area, including margin, will behave during hit testing. + /// + /// If this property is null, and [margin] is not null or the ambient + /// [SnackBarThemeData.insetPadding] is not null, then + /// [HitTestBehavior.deferToChild] is used by default. + /// + /// Please refer to [HitTestBehavior] for a detailed explanation of every behavior. + final HitTestBehavior? hitTestBehavior; + + /// This defines the behavior and location of the snack bar. + /// + /// Defines where a [SnackBar] should appear within a [Scaffold] and how its + /// location should be adjusted when the scaffold also includes a + /// [FloatingActionButton] or a [BottomNavigationBar] + /// + /// If this property is null, then the ambient [SnackBarThemeData.behavior] + /// is used. If that is null, then the default is [SnackBarBehavior.fixed]. + /// + /// If this value is [SnackBarBehavior.floating], the length of the bar + /// is defined by either [width] or [margin]. + final SnackBarBehavior? behavior; + + /// (optional) An action that the user can take based on the snack bar. + /// + /// For example, the snack bar might let the user undo the operation that + /// prompted the snackbar. Snack bars can have at most one action. + /// + /// The action should not be "dismiss" or "cancel". + final SnackBarAction? action; + + /// (optional) The percentage threshold for action widget's width before it overflows + /// to a new line. + /// + /// Must be between 0 and 1. + /// If the width of the [action] divided by the total snackbar width + /// is greater than this percentage, the [action] will appear below the [content]. + /// + /// At a value of 0, the action will always overflow to a new line. + /// + /// Defaults to 0.25. + final double? actionOverflowThreshold; + + /// (optional) Whether to include a "close" icon widget. + /// + /// Tapping the icon will close the snack bar. + final bool? showCloseIcon; + + /// An optional color for the close icon, if [showCloseIcon] is + /// true. + /// + /// If this property is null, then the ambient [SnackBarThemeData.closeIconColor] + /// is used. If that is null, then the default is inverse surface. + /// + /// If [closeIconColor] is a [WidgetStateColor], then the icon color will be + /// resolved against the set of [WidgetState]s that the action text + /// is in, thus allowing for different colors for states such as pressed, + /// hovered and others. + final Color? closeIconColor; + + /// The amount of time the snack bar should be displayed. + /// + /// Defaults to 4.0s. + /// + /// See also: + /// + /// * [ScaffoldMessengerState.removeCurrentSnackBar], which abruptly hides the + /// currently displayed snack bar, if any, and allows the next to be + /// displayed. + /// * <https://material.io/design/components/snackbars.html> + final Duration duration; + + /// Whether the snack bar will stay or auto-dismiss after timeout. + /// + /// If true, the snack bar remains visible even after the timeout, until the + /// user taps the action button or the close icon. + /// + /// If false, the snack bar will be dismissed after the timeout. + /// + /// If not provided, but the snackbar action is not null, the snackbar will + /// persist as well. + final bool persist; + + /// The animation driving the entrance and exit of the snack bar. + final Animation<double>? animation; + + /// Called the first time that the snackbar is visible within a [Scaffold]. + /// + /// When multiple [Scaffold]s are registered to the same [ScaffoldMessengerState], + /// [onVisible] is called once for each scaffold. + /// + /// See also: + /// + /// * [ScaffoldMessenger], which manages [SnackBar]s for [Scaffold] descendants. + final VoidCallback? onVisible; + + /// The direction in which the SnackBar can be dismissed. + /// + /// If this property is null, then the ambient [SnackBarThemeData.dismissDirection] + /// is used. If that is null, then the default is [DismissDirection.down]. + final DismissDirection? dismissDirection; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + // API for ScaffoldMessengerState.showSnackBar(): + + /// Creates an animation controller useful for driving a snack bar's entrance and exit animation. + static AnimationController createAnimationController({ + required TickerProvider vsync, + Duration? duration, + Duration? reverseDuration, + }) { + return AnimationController( + duration: duration ?? _snackBarTransitionDuration, + reverseDuration: reverseDuration, + debugLabel: 'SnackBar', + vsync: vsync, + ); + } + + /// Creates a copy of this snack bar but with the animation replaced with the given animation. + /// + /// If the original snack bar lacks a key, the newly created snack bar will + /// use the given fallback key. + SnackBar withAnimation(Animation<double> newAnimation, {Key? fallbackKey}) { + return SnackBar( + key: key ?? fallbackKey, + content: content, + backgroundColor: backgroundColor, + elevation: elevation, + margin: margin, + padding: padding, + width: width, + shape: shape, + hitTestBehavior: hitTestBehavior, + behavior: behavior, + action: action, + actionOverflowThreshold: actionOverflowThreshold, + showCloseIcon: showCloseIcon, + closeIconColor: closeIconColor, + duration: duration, + persist: persist, + animation: newAnimation, + onVisible: onVisible, + dismissDirection: dismissDirection, + clipBehavior: clipBehavior, + ); + } + + @override + State<SnackBar> createState() => _SnackBarState(); +} + +class _SnackBarState extends State<SnackBar> { + bool _wasVisible = false; + + CurvedAnimation? _heightAnimation; + CurvedAnimation? _fadeInAnimation; + CurvedAnimation? _fadeInM3Animation; + CurvedAnimation? _fadeOutAnimation; + CurvedAnimation? _heightM3Animation; + + final Key _dismissibleKey = UniqueKey(); + + @override + void initState() { + super.initState(); + widget.animation!.addStatusListener(_onAnimationStatusChanged); + _setAnimations(); + } + + @override + void didUpdateWidget(SnackBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.animation != oldWidget.animation) { + oldWidget.animation!.removeStatusListener(_onAnimationStatusChanged); + widget.animation!.addStatusListener(_onAnimationStatusChanged); + _disposeAnimations(); + _setAnimations(); + } + } + + void _setAnimations() { + assert(widget.animation != null); + _heightAnimation = CurvedAnimation(parent: widget.animation!, curve: _snackBarHeightCurve); + _fadeInAnimation = CurvedAnimation(parent: widget.animation!, curve: _snackBarFadeInCurve); + _fadeInM3Animation = CurvedAnimation(parent: widget.animation!, curve: _snackBarM3FadeInCurve); + _fadeOutAnimation = CurvedAnimation( + parent: widget.animation!, + curve: _snackBarFadeOutCurve, + reverseCurve: const Threshold(0.0), + ); + // Material 3 Animation has a height animation on entry, but a direct fade out on exit. + _heightM3Animation = CurvedAnimation( + parent: widget.animation!, + curve: _snackBarM3HeightCurve, + reverseCurve: const Threshold(0.0), + ); + } + + void _disposeAnimations() { + _heightAnimation?.dispose(); + _fadeInAnimation?.dispose(); + _fadeInM3Animation?.dispose(); + _fadeOutAnimation?.dispose(); + _heightM3Animation?.dispose(); + _heightAnimation = null; + _fadeInAnimation = null; + _fadeInM3Animation = null; + _fadeOutAnimation = null; + _heightM3Animation = null; + } + + @override + void dispose() { + widget.animation!.removeStatusListener(_onAnimationStatusChanged); + _disposeAnimations(); + super.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus animationStatus) { + if (animationStatus.isCompleted) { + if (widget.onVisible != null && !_wasVisible) { + widget.onVisible!(); + } + _wasVisible = true; + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final bool accessibleNavigation = MediaQuery.accessibleNavigationOf(context); + assert(widget.animation != null); + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final SnackBarThemeData snackBarTheme = SnackBarTheme.of(context); + final isThemeDark = theme.brightness == Brightness.dark; + final Color buttonColor = isThemeDark ? colorScheme.primary : colorScheme.secondary; + final SnackBarThemeData defaults = theme.useMaterial3 + ? _SnackbarDefaultsM3(context) + : _SnackbarDefaultsM2(context); + + // SnackBar uses a theme that is the opposite brightness from + // the surrounding theme. + final Brightness brightness = isThemeDark ? Brightness.light : Brightness.dark; + + // Invert the theme values for Material 2. Material 3 values are tokenized to pre-inverted values. + final ThemeData effectiveTheme = theme.useMaterial3 + ? theme + : theme.copyWith( + colorScheme: ColorScheme( + primary: colorScheme.onPrimary, + secondary: buttonColor, + surface: colorScheme.onSurface, + background: defaults.backgroundColor, + error: colorScheme.onError, + onPrimary: colorScheme.primary, + onSecondary: colorScheme.secondary, + onSurface: colorScheme.surface, + onBackground: colorScheme.background, + onError: colorScheme.error, + brightness: brightness, + ), + ); + + final TextStyle? contentTextStyle = snackBarTheme.contentTextStyle ?? defaults.contentTextStyle; + final SnackBarBehavior snackBarBehavior = + widget.behavior ?? snackBarTheme.behavior ?? defaults.behavior!; + final double? width = widget.width ?? snackBarTheme.width; + assert(() { + // Whether the behavior is set through the constructor or the theme, + // assert that other properties are configured properly. + if (snackBarBehavior != SnackBarBehavior.floating) { + String message(String parameter) { + final prefix = '$parameter can only be used with floating behavior.'; + if (widget.behavior != null) { + return '$prefix SnackBarBehavior.fixed was set in the SnackBar constructor.'; + } else if (snackBarTheme.behavior != null) { + return '$prefix SnackBarBehavior.fixed was set by the inherited SnackBarThemeData.'; + } else { + return '$prefix SnackBarBehavior.fixed was set by default.'; + } + } + + assert(widget.margin == null, message('Margin')); + assert(width == null, message('Width')); + } + return true; + }()); + + final bool showCloseIcon = + widget.showCloseIcon ?? snackBarTheme.showCloseIcon ?? defaults.showCloseIcon!; + + final isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating; + final horizontalPadding = isFloatingSnackBar ? 16.0 : 24.0; + final EdgeInsetsGeometry padding = + widget.padding ?? + EdgeInsetsDirectional.only( + start: horizontalPadding, + end: widget.action != null || showCloseIcon ? 0 : horizontalPadding, + ); + + final double actionHorizontalMargin = + (widget.padding?.resolve(TextDirection.ltr).right ?? horizontalPadding) / 2; + final double iconHorizontalMargin = + (widget.padding?.resolve(TextDirection.ltr).right ?? horizontalPadding) / 12.0; + + final IconButton? iconButton = showCloseIcon + ? IconButton( + key: StandardComponentType.closeButton.key, + icon: const Icon(Icons.close), + iconSize: 24.0, + color: widget.closeIconColor ?? snackBarTheme.closeIconColor ?? defaults.closeIconColor, + onPressed: () => ScaffoldMessenger.of( + context, + ).hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + ) + : null; + + // Calculate combined width of Action, Icon, and their padding, if they are present. + final actionTextPainter = TextPainter( + text: TextSpan( + text: widget.action?.label ?? '', + style: Theme.of(context).textTheme.labelLarge, + ), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(); + final double actionAndIconWidth = + actionTextPainter.size.width + + (widget.action != null ? actionHorizontalMargin : 0) + + (showCloseIcon ? (iconButton?.iconSize ?? 0 + iconHorizontalMargin) : 0); + actionTextPainter.dispose(); + + final EdgeInsets margin = + widget.margin?.resolve(TextDirection.ltr) ?? + snackBarTheme.insetPadding ?? + defaults.insetPadding!; + + final double snackBarWidth = + widget.width ?? MediaQuery.widthOf(context) - (margin.left + margin.right); + final double actionOverflowThreshold = + widget.actionOverflowThreshold ?? + snackBarTheme.actionOverflowThreshold ?? + defaults.actionOverflowThreshold!; + + final bool willOverflowAction = actionAndIconWidth / snackBarWidth > actionOverflowThreshold; + + final maybeActionAndIcon = <Widget>[ + if (widget.action != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: actionHorizontalMargin), + child: TextButtonTheme( + data: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: buttonColor, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + ), + ), + child: widget.action!, + ), + ), + if (showCloseIcon) + Padding( + padding: EdgeInsets.symmetric(horizontal: iconHorizontalMargin), + child: iconButton, + ), + ]; + + Widget snackBar = Padding( + padding: padding, + child: Wrap( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: Padding( + padding: widget.padding == null + ? const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding) + : EdgeInsets.zero, + child: DefaultTextStyle(style: contentTextStyle!, child: widget.content), + ), + ), + if (!willOverflowAction) ...maybeActionAndIcon, + if (willOverflowAction) SizedBox(width: snackBarWidth * 0.4), + ], + ), + if (willOverflowAction) + Padding( + padding: const EdgeInsets.only(bottom: _singleLineVerticalPadding), + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: maybeActionAndIcon), + ), + ], + ), + ); + + if (!isFloatingSnackBar) { + snackBar = SafeArea(top: false, child: snackBar); + } + + final double elevation = widget.elevation ?? snackBarTheme.elevation ?? defaults.elevation!; + final Color backgroundColor = + widget.backgroundColor ?? snackBarTheme.backgroundColor ?? defaults.backgroundColor!; + final ShapeBorder? shape = + widget.shape ?? snackBarTheme.shape ?? (isFloatingSnackBar ? defaults.shape : null); + final DismissDirection dismissDirection = + widget.dismissDirection ?? snackBarTheme.dismissDirection ?? DismissDirection.down; + + snackBar = Material( + shape: shape, + elevation: elevation, + color: backgroundColor, + clipBehavior: widget.clipBehavior, + child: Theme( + data: effectiveTheme, + child: accessibleNavigation || theme.useMaterial3 + ? snackBar + : FadeTransition(opacity: _fadeOutAnimation!, child: snackBar), + ), + ); + + if (isFloatingSnackBar) { + // If width is provided, do not include horizontal margins. + if (width != null) { + snackBar = Padding( + padding: EdgeInsets.only(top: margin.top, bottom: margin.bottom), + child: SizedBox(width: width, child: snackBar), + ); + } else { + snackBar = Padding(padding: margin, child: snackBar); + } + snackBar = SafeArea(top: false, bottom: false, child: snackBar); + } + + snackBar = Semantics( + container: true, + liveRegion: true, + onDismiss: () { + ScaffoldMessenger.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss); + }, + child: Dismissible( + key: _dismissibleKey, + direction: dismissDirection, + resizeDuration: null, + behavior: + widget.hitTestBehavior ?? + (widget.margin != null || snackBarTheme.insetPadding != null + ? HitTestBehavior.deferToChild + : HitTestBehavior.opaque), + onDismissed: (DismissDirection direction) { + ScaffoldMessenger.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe); + }, + child: snackBar, + ), + ); + + final Widget snackBarTransition; + if (accessibleNavigation) { + snackBarTransition = snackBar; + } else if (isFloatingSnackBar && !theme.useMaterial3) { + snackBarTransition = FadeTransition(opacity: _fadeInAnimation!, child: snackBar); + // Is Material 3 Floating Snack Bar. + } else if (isFloatingSnackBar && theme.useMaterial3) { + snackBarTransition = FadeTransition( + opacity: _fadeInM3Animation!, + child: ValueListenableBuilder<double>( + valueListenable: _heightM3Animation!, + builder: (BuildContext context, double value, Widget? child) { + return Align(alignment: Alignment.bottomLeft, heightFactor: value, child: child); + }, + child: snackBar, + ), + ); + } else { + snackBarTransition = ValueListenableBuilder<double>( + valueListenable: _heightAnimation!, + builder: (BuildContext context, double value, Widget? child) { + return Align(alignment: AlignmentDirectional.topStart, heightFactor: value, child: child); + }, + child: snackBar, + ); + } + + return Hero( + tag: '<SnackBar Hero tag - ${widget.content}>', + transitionOnUserGestures: true, + child: ClipRect(clipBehavior: widget.clipBehavior, child: snackBarTransition), + ); + } +} + +// Hand coded defaults based on Material Design 2. +class _SnackbarDefaultsM2 extends SnackBarThemeData { + _SnackbarDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme, + super(elevation: 6.0); + + late final ThemeData _theme; + late final ColorScheme _colors; + + @override + Color get backgroundColor => _theme.brightness == Brightness.light + ? Color.alphaBlend(_colors.onSurface.withOpacity(0.80), _colors.surface) + : _colors.onSurface; + + @override + TextStyle? get contentTextStyle => ThemeData( + useMaterial3: _theme.useMaterial3, + brightness: _theme.brightness == Brightness.light ? Brightness.dark : Brightness.light, + ).textTheme.titleMedium; + + @override + SnackBarBehavior get behavior => SnackBarBehavior.fixed; + + @override + Color get actionTextColor => _colors.secondary; + + @override + Color get disabledActionTextColor => + _colors.onSurface.withOpacity(_theme.brightness == Brightness.light ? 0.38 : 0.3); + + @override + ShapeBorder get shape => + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + + @override + EdgeInsets get insetPadding => const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0); + + @override + bool get showCloseIcon => false; + + @override + Color get closeIconColor => _colors.onSurface; + + @override + double get actionOverflowThreshold => 0.25; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Snackbar + +// 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. + +// dart format off +class _SnackbarDefaultsM3 extends SnackBarThemeData { + _SnackbarDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color get backgroundColor => _colors.inverseSurface; + + @override + Color get actionTextColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.inversePrimary; + } + if (states.contains(WidgetState.pressed)) { + return _colors.inversePrimary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.inversePrimary; + } + if (states.contains(WidgetState.focused)) { + return _colors.inversePrimary; + } + return _colors.inversePrimary; + }); + + @override + Color get disabledActionTextColor => + _colors.inversePrimary; + + + @override + TextStyle get contentTextStyle => + Theme.of(context).textTheme.bodyMedium!.copyWith + (color: _colors.onInverseSurface, + ); + + @override + double get elevation => 6.0; + + @override + ShapeBorder get shape => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + + @override + SnackBarBehavior get behavior => SnackBarBehavior.fixed; + + @override + EdgeInsets get insetPadding => const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0); + + @override + bool get showCloseIcon => false; + + @override + Color? get closeIconColor => _colors.onInverseSurface; + + @override + double get actionOverflowThreshold => 0.25; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Snackbar diff --git a/packages/material_ui/lib/src/snack_bar_theme.dart b/packages/material_ui/lib/src/snack_bar_theme.dart new file mode 100644 index 000000000000..27e92014022a --- /dev/null +++ b/packages/material_ui/lib/src/snack_bar_theme.dart @@ -0,0 +1,386 @@ +// 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. + +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'colors.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'navigation_bar.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'snack_bar.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines where a [SnackBar] should appear within a [Scaffold] and how its +/// location should be adjusted when the scaffold also includes a +/// [FloatingActionButton] or a [BottomNavigationBar]. +enum SnackBarBehavior { + /// Fixes the [SnackBar] at the bottom of the [Scaffold]. + /// + /// The exception is that the [SnackBar] will be shown above a + /// [BottomNavigationBar] or a [NavigationBar]. Additionally, the [SnackBar] + /// will cause other non-fixed widgets inside [Scaffold] to be pushed above + /// (for example, the [FloatingActionButton]). + fixed, + + /// This behavior will cause [SnackBar] to be shown above other widgets in the + /// [Scaffold]. This includes being displayed above a [BottomNavigationBar] or + /// a [NavigationBar], and a [FloatingActionButton] when its location is on the + /// bottom. When the floating action button location is on the top, this behavior + /// will cause the [SnackBar] to be shown above other widgets in the [Scaffold] + /// except the floating action button. + /// + /// See <https://material.io/design/components/snackbars.html> for more details. + floating, +} + +/// Customizes default property values for [SnackBar] widgets. +/// +/// Descendant widgets obtain the current [SnackBarThemeData] object using +/// [SnackBarTheme.of]. Instances of [SnackBarThemeData] can be +/// customized with [SnackBarThemeData.copyWith]. +/// +/// Typically a [SnackBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.snackBarTheme]. The default for [ThemeData.snackBarTheme] +/// provides all `null` properties. +/// +/// All [SnackBarThemeData] properties are `null` by default. When null, the +/// [SnackBar] will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class SnackBarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.snackBarTheme]. + /// + /// The [elevation] must be null or non-negative. + const SnackBarThemeData({ + this.backgroundColor, + this.actionTextColor, + this.disabledActionTextColor, + this.contentTextStyle, + this.elevation, + this.shape, + this.behavior, + this.width, + this.insetPadding, + this.showCloseIcon, + this.closeIconColor, + this.actionOverflowThreshold, + this.actionBackgroundColor, + this.disabledActionBackgroundColor, + this.dismissDirection, + }) : assert(elevation == null || elevation >= 0.0), + assert( + width == null || identical(behavior, SnackBarBehavior.floating), + 'Width can only be set if behaviour is SnackBarBehavior.floating', + ), + assert( + actionOverflowThreshold == null || + (actionOverflowThreshold >= 0 && actionOverflowThreshold <= 1), + 'Action overflow threshold must be between 0 and 1 inclusive', + ), + assert( + actionBackgroundColor is! WidgetStateColor || disabledActionBackgroundColor == null, + 'disabledBackgroundColor must not be provided when background color is ' + 'a WidgetStateColor', + ); + + /// Overrides the default value for [SnackBar.backgroundColor]. + /// + /// If null, [SnackBar] defaults to dark grey: `Color(0xFF323232)`. + final Color? backgroundColor; + + /// Overrides the default value for [SnackBarAction.textColor]. + /// + /// If null, [SnackBarAction] defaults to [ColorScheme.secondary] of + /// [ThemeData.colorScheme]. + final Color? actionTextColor; + + /// Overrides the default value for [SnackBarAction.disabledTextColor]. + /// + /// If null, [SnackBarAction] defaults to [ColorScheme.onSurface] with its + /// opacity set to 0.30 if the [Theme]'s brightness is [Brightness.dark], 0.38 + /// otherwise. + final Color? disabledActionTextColor; + + /// Used to configure the [DefaultTextStyle] for the [SnackBar.content] widget. + /// + /// If null, [SnackBar] defines its default. + final TextStyle? contentTextStyle; + + /// Overrides the default value for [SnackBar.elevation]. + /// + /// If null, [SnackBar] uses a default of 6.0. + final double? elevation; + + /// Overrides the default value for [SnackBar.shape]. + /// + /// If null, [SnackBar] provides different defaults depending on the + /// [SnackBarBehavior]. For [SnackBarBehavior.fixed], no overriding shape is + /// specified, so the [SnackBar] is rectangular. For + /// [SnackBarBehavior.floating], it uses a [RoundedRectangleBorder] with a + /// circular corner radius of 4.0. + final ShapeBorder? shape; + + /// Overrides the default value for [SnackBar.behavior]. + /// + /// If null, [SnackBar] will default to [SnackBarBehavior.fixed]. + final SnackBarBehavior? behavior; + + /// Overrides the default value for [SnackBar.width]. + /// + /// If this property is null, then the snack bar will take up the full device + /// width less the margin. This value is only used when [behavior] is + /// [SnackBarBehavior.floating]. + final double? width; + + /// Overrides the default value for [SnackBar.margin]. + /// + /// This value is only used when [behavior] is [SnackBarBehavior.floating]. + final EdgeInsets? insetPadding; + + /// Overrides the default value for [SnackBar.showCloseIcon]. + /// + /// Whether to show an optional "Close" icon. + final bool? showCloseIcon; + + /// Overrides the default value for [SnackBar.closeIconColor]. + /// + /// This value is only used if [showCloseIcon] is true. + final Color? closeIconColor; + + /// Overrides the default value for [SnackBar.actionOverflowThreshold]. + /// + /// Must be a value between 0 and 1, if present. + final double? actionOverflowThreshold; + + /// Overrides default value for [SnackBarAction.backgroundColor]. + /// + /// If null, [SnackBarAction] falls back to [Colors.transparent]. + final Color? actionBackgroundColor; + + /// Overrides default value for [SnackBarAction.disabledBackgroundColor]. + /// + /// If null, [SnackBarAction] falls back to [Colors.transparent]. + final Color? disabledActionBackgroundColor; + + /// Overrides the default value for [SnackBar.dismissDirection]. + /// + /// If null, [SnackBar] will default to [DismissDirection.down]. + final DismissDirection? dismissDirection; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + SnackBarThemeData copyWith({ + Color? backgroundColor, + Color? actionTextColor, + Color? disabledActionTextColor, + TextStyle? contentTextStyle, + double? elevation, + ShapeBorder? shape, + SnackBarBehavior? behavior, + double? width, + EdgeInsets? insetPadding, + bool? showCloseIcon, + Color? closeIconColor, + double? actionOverflowThreshold, + Color? actionBackgroundColor, + Color? disabledActionBackgroundColor, + DismissDirection? dismissDirection, + }) { + return SnackBarThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + actionTextColor: actionTextColor ?? this.actionTextColor, + disabledActionTextColor: disabledActionTextColor ?? this.disabledActionTextColor, + contentTextStyle: contentTextStyle ?? this.contentTextStyle, + elevation: elevation ?? this.elevation, + shape: shape ?? this.shape, + behavior: behavior ?? this.behavior, + width: width ?? this.width, + insetPadding: insetPadding ?? this.insetPadding, + showCloseIcon: showCloseIcon ?? this.showCloseIcon, + closeIconColor: closeIconColor ?? this.closeIconColor, + actionOverflowThreshold: actionOverflowThreshold ?? this.actionOverflowThreshold, + actionBackgroundColor: actionBackgroundColor ?? this.actionBackgroundColor, + disabledActionBackgroundColor: + disabledActionBackgroundColor ?? this.disabledActionBackgroundColor, + dismissDirection: dismissDirection ?? this.dismissDirection, + ); + } + + /// Linearly interpolate between two SnackBar Themes. + /// + /// {@macro dart.ui.shadow.lerp} + static SnackBarThemeData lerp(SnackBarThemeData? a, SnackBarThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return SnackBarThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + actionTextColor: Color.lerp(a?.actionTextColor, b?.actionTextColor, t), + disabledActionTextColor: Color.lerp( + a?.disabledActionTextColor, + b?.disabledActionTextColor, + t, + ), + contentTextStyle: TextStyle.lerp(a?.contentTextStyle, b?.contentTextStyle, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + behavior: t < 0.5 ? a?.behavior : b?.behavior, + width: lerpDouble(a?.width, b?.width, t), + insetPadding: EdgeInsets.lerp(a?.insetPadding, b?.insetPadding, t), + closeIconColor: Color.lerp(a?.closeIconColor, b?.closeIconColor, t), + actionOverflowThreshold: lerpDouble( + a?.actionOverflowThreshold, + b?.actionOverflowThreshold, + t, + ), + actionBackgroundColor: Color.lerp(a?.actionBackgroundColor, b?.actionBackgroundColor, t), + disabledActionBackgroundColor: Color.lerp( + a?.disabledActionBackgroundColor, + b?.disabledActionBackgroundColor, + t, + ), + dismissDirection: t < 0.5 ? a?.dismissDirection : b?.dismissDirection, + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + actionTextColor, + disabledActionTextColor, + contentTextStyle, + elevation, + shape, + behavior, + width, + insetPadding, + showCloseIcon, + closeIconColor, + actionOverflowThreshold, + actionBackgroundColor, + disabledActionBackgroundColor, + dismissDirection, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SnackBarThemeData && + other.backgroundColor == backgroundColor && + other.actionTextColor == actionTextColor && + other.disabledActionTextColor == disabledActionTextColor && + other.contentTextStyle == contentTextStyle && + other.elevation == elevation && + other.shape == shape && + other.behavior == behavior && + other.width == width && + other.insetPadding == insetPadding && + other.showCloseIcon == showCloseIcon && + other.closeIconColor == closeIconColor && + other.actionOverflowThreshold == actionOverflowThreshold && + other.actionBackgroundColor == actionBackgroundColor && + other.disabledActionBackgroundColor == disabledActionBackgroundColor && + other.dismissDirection == dismissDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(ColorProperty('actionTextColor', actionTextColor, defaultValue: null)); + properties.add( + ColorProperty('disabledActionTextColor', disabledActionTextColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle>('contentTextStyle', contentTextStyle, defaultValue: null), + ); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty<SnackBarBehavior>('behavior', behavior, defaultValue: null)); + properties.add(DoubleProperty('width', width, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsets>('insetPadding', insetPadding, defaultValue: null), + ); + properties.add(DiagnosticsProperty<bool>('showCloseIcon', showCloseIcon, defaultValue: null)); + properties.add(ColorProperty('closeIconColor', closeIconColor, defaultValue: null)); + properties.add( + DoubleProperty('actionOverflowThreshold', actionOverflowThreshold, defaultValue: null), + ); + properties.add( + ColorProperty('actionBackgroundColor', actionBackgroundColor, defaultValue: null), + ); + properties.add( + ColorProperty( + 'disabledActionBackgroundColor', + disabledActionBackgroundColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<DismissDirection>( + 'dismissDirection', + dismissDirection, + defaultValue: null, + ), + ); + } +} + +/// An inherited widget that defines the configuration for +/// [SnackBar]s in this widget's subtree. +/// +/// Values specified here are used for [SnackBar] properties that are not +/// given an explicit non-null value. +class SnackBarTheme extends InheritedTheme { + /// Creates a snackbar theme that controls the configurations for + /// [SnackBar]s in its widget subtree. + const SnackBarTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant [SnackBar] widgets. + final SnackBarThemeData data; + + /// The closest instance of this class's [data] value that encloses the given + /// context. + /// + /// If there is no ancestor, it returns [ThemeData.snackBarTheme]. Applications + /// can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SnackBarThemeData theme = SnackBarTheme.of(context); + /// ``` + static SnackBarThemeData of(BuildContext context) { + final SnackBarTheme? snackBarTheme = context + .dependOnInheritedWidgetOfExactType<SnackBarTheme>(); + return snackBarTheme?.data ?? Theme.of(context).snackBarTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return SnackBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(SnackBarTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/spell_check_suggestions_toolbar.dart b/packages/material_ui/lib/src/spell_check_suggestions_toolbar.dart new file mode 100644 index 000000000000..6e8f6888da82 --- /dev/null +++ b/packages/material_ui/lib/src/spell_check_suggestions_toolbar.dart @@ -0,0 +1,245 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan; + +import 'adaptive_text_selection_toolbar.dart'; +import 'colors.dart'; +import 'material.dart'; +import 'spell_check_suggestions_toolbar_layout_delegate.dart'; +import 'text_selection_toolbar_text_button.dart'; + +// The default height of the SpellCheckSuggestionsToolbar, which +// assumes there are the maximum number of spell check suggestions available, 3. +// Size eyeballed on Pixel 4 emulator running Android API 31. +const double _kDefaultToolbarHeight = 193.0; + +/// The maximum number of suggestions in the toolbar is 3, plus a delete button. +const int _kMaxSuggestions = 3; + +/// The default spell check suggestions toolbar for Android. +/// +/// Tries to position itself below the [anchor], but if it doesn't fit, then it +/// readjusts to fit above bottom view insets. +/// +/// See also: +/// +/// * [CupertinoSpellCheckSuggestionsToolbar], which is similar but builds an +/// iOS-style spell check toolbar. +class SpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [SpellCheckSuggestionsToolbar]. + /// + /// [buttonItems] must not contain more than four items, generally three + /// suggestions and one delete button. + const SpellCheckSuggestionsToolbar({super.key, required this.anchor, required this.buttonItems}) + : assert(buttonItems.length <= _kMaxSuggestions + 1); + + /// Constructs a [SpellCheckSuggestionsToolbar] with the default children for + /// an [EditableText]. + /// + /// See also: + /// * [CupertinoSpellCheckSuggestionsToolbar.editableText], which is similar + /// but builds an iOS-style toolbar. + SpellCheckSuggestionsToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : buttonItems = buildButtonItems(editableTextState) ?? <ContextMenuButtonItem>[], + anchor = getToolbarAnchor(editableTextState.contextMenuAnchors); + + /// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor} + /// The focal point below which the toolbar attempts to position itself. + /// {@endtemplate} + final Offset anchor; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets and displayed in the spell check suggestions toolbar. + /// + /// Must not contain more than four items, typically three suggestions and a + /// delete button. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + /// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s used to build the Cupertino style spell check + /// suggestions toolbar. + final List<ContextMenuButtonItem> buttonItems; + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List<ContextMenuButtonItem>? buildButtonItems(EditableTextState editableTextState) { + // Determine if composing region is misspelled. + final SuggestionSpan? spanAtCursorIndex = editableTextState.findSuggestionSpanAtCursorIndex( + editableTextState.currentTextEditingValue.selection.baseOffset, + ); + + if (spanAtCursorIndex == null) { + return null; + } + + final buttonItems = <ContextMenuButtonItem>[]; + + // Build suggestion buttons. + for (final String suggestion in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) { + buttonItems.add( + ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText(editableTextState, suggestion, spanAtCursorIndex.range); + }, + label: suggestion, + ), + ); + } + + // Build delete button. + final deleteButton = ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText(editableTextState, '', editableTextState.currentTextEditingValue.composing); + }, + type: ContextMenuButtonType.delete, + ); + buttonItems.add(deleteButton); + + return buttonItems; + } + + static void _replaceText( + EditableTextState editableTextState, + String text, + TextRange replacementRange, + ) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText); + + final TextEditingValue newValue = editableTextState.textEditingValue.replaced( + replacementRange, + text, + ); + editableTextState.userUpdateTextEditingValue(newValue, SelectionChangedCause.toolbar); + + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + if (editableTextState.mounted) { + editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent); + } + }, debugLabel: 'SpellCheckerSuggestionsToolbar.bringIntoView'); + editableTextState.hideToolbar(); + } + + /// Determines the Offset that the toolbar will be anchored to. + static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) { + // Since this will be positioned below the anchor point, use the secondary + // anchor by default. + return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!; + } + + /// Builds the toolbar buttons based on the [buttonItems]. + List<Widget> _buildToolbarButtons(BuildContext context) { + return buttonItems.map((ContextMenuButtonItem buttonItem) { + final button = TextSelectionToolbarTextButton( + padding: const EdgeInsets.fromLTRB(20, 0, 0, 0), + onPressed: buttonItem.onPressed, + alignment: Alignment.centerLeft, + child: Text( + AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem), + style: buttonItem.type == ContextMenuButtonType.delete + ? const TextStyle(color: Colors.blue) + : null, + ), + ); + + if (buttonItem.type != ContextMenuButtonType.delete) { + return button; + } + return DecoratedBox( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Colors.grey)), + ), + child: button, + ); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (buttonItems.isEmpty) { + return const SizedBox.shrink(); + } + + // Adjust toolbar height if needed. + final double spellCheckSuggestionsToolbarHeight = + _kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length)); + // Incorporate the padding distance between the content and toolbar. + final MediaQueryData mediaQueryData = MediaQuery.of(context); + final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom; + final double paddingAbove = + mediaQueryData.padding.top + CupertinoTextSelectionToolbar.kToolbarScreenPadding; + // Makes up for the Padding. + final localAdjustment = Offset( + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + paddingAbove, + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + paddingAbove, + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + CupertinoTextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom, + ), + child: CustomSingleChildLayout( + delegate: SpellCheckSuggestionsToolbarLayoutDelegate(anchor: anchor - localAdjustment), + child: AnimatedSize( + // This duration was eyeballed on a Pixel 2 emulator running Android + // API 28 for the Material TextSelectionToolbar. + duration: const Duration(milliseconds: 140), + child: _SpellCheckSuggestionsToolbarContainer( + height: spellCheckSuggestionsToolbarHeight, + children: <Widget>[..._buildToolbarButtons(context)], + ), + ), + ), + ); + } +} + +/// The Material-styled toolbar outline for the spell check suggestions +/// toolbar. +class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget { + const _SpellCheckSuggestionsToolbarContainer({required this.height, required this.children}); + + final double height; + final List<Widget> children; + + @override + Widget build(BuildContext context) { + return Material( + // This elevation was eyeballed on a Pixel 4 emulator running Android + // API 31 for the SpellCheckSuggestionsToolbar. + elevation: 2.0, + type: MaterialType.card, + child: SizedBox( + // This width was eyeballed on a Pixel 4 emulator running Android + // API 31 for the SpellCheckSuggestionsToolbar. + width: 165.0, + height: height, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/spell_check_suggestions_toolbar_layout_delegate.dart b/packages/material_ui/lib/src/spell_check_suggestions_toolbar_layout_delegate.dart new file mode 100644 index 000000000000..69074c034185 --- /dev/null +++ b/packages/material_ui/lib/src/spell_check_suggestions_toolbar_layout_delegate.dart @@ -0,0 +1,45 @@ +// 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. + +/// @docImport 'spell_check_suggestions_toolbar.dart'; +library; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart' show TextSelectionToolbarLayoutDelegate; + +/// Positions the toolbar below [anchor] or adjusts it higher to fit above +/// the bottom view insets, if applicable. +/// +/// See also: +/// +/// * [SpellCheckSuggestionsToolbar], which uses this to position itself. +class SpellCheckSuggestionsToolbarLayoutDelegate extends SingleChildLayoutDelegate { + /// Creates an instance of [SpellCheckSuggestionsToolbarLayoutDelegate]. + SpellCheckSuggestionsToolbarLayoutDelegate({required this.anchor}); + + /// {@macro flutter.material.SpellCheckSuggestionsToolbar.anchor} + /// + /// Should be provided in local coordinates. + final Offset anchor; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.loosen(); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + return Offset( + TextSelectionToolbarLayoutDelegate.centerOn(anchor.dx, childSize.width, size.width), + // Positions child (of childSize) just enough upwards to fit within size + // if it otherwise does not fit below the anchor. + anchor.dy + childSize.height > size.height ? size.height - childSize.height : anchor.dy, + ); + } + + @override + bool shouldRelayout(SpellCheckSuggestionsToolbarLayoutDelegate oldDelegate) { + return anchor != oldDelegate.anchor; + } +} diff --git a/packages/material_ui/lib/src/stepper.dart b/packages/material_ui/lib/src/stepper.dart new file mode 100644 index 000000000000..39af17ae7c21 --- /dev/null +++ b/packages/material_ui/lib/src/stepper.dart @@ -0,0 +1,1194 @@ +// 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 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'text_button.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +// TODO(dragostis): Missing functionality: +// * mobile horizontal mode with adding/removing steps +// * alternative labeling +// * stepper feedback in the case of high-latency interactions + +/// The state of a [Step] which is used to control the style of the circle and +/// text. +/// +/// See also: +/// +/// * [Step] +enum StepState { + /// A step that displays its index in its circle. + indexed, + + /// A step that displays a pencil icon in its circle. + editing, + + /// A step that displays a tick icon in its circle. + complete, + + /// A step that is disabled and does not to react to taps. + disabled, + + /// A step that is currently having an error. e.g. the user has submitted wrong + /// input. + error, +} + +/// Defines the [Stepper]'s main axis. +enum StepperType { + /// A vertical layout of the steps with their content in-between the titles. + vertical, + + /// A horizontal layout of the steps with their content below the titles. + horizontal, +} + +/// Container for all the information necessary to build a Stepper widget's +/// forward and backward controls for any given step. +/// +/// Used by [Stepper.controlsBuilder]. +@immutable +class ControlsDetails { + /// Creates a set of details describing the Stepper. + const ControlsDetails({ + required this.currentStep, + required this.stepIndex, + this.onStepCancel, + this.onStepContinue, + }); + + /// Index that is active for the surrounding [Stepper] widget. This may be + /// different from [stepIndex] if the user has just changed steps and we are + /// currently animating toward that step. + final int currentStep; + + /// Index of the step for which these controls are being built. This is + /// not necessarily the active index, if the user has just changed steps and + /// this step is animating away. To determine whether a given builder is building + /// the active step or the step being navigated away from, see [isActive]. + final int stepIndex; + + /// The callback called when the 'continue' button is tapped. + /// + /// If null, the 'continue' button will be disabled. + final VoidCallback? onStepContinue; + + /// The callback called when the 'cancel' button is tapped. + /// + /// If null, the 'cancel' button will be disabled. + final VoidCallback? onStepCancel; + + /// True if the indicated step is also the current active step. If the user has + /// just activated the transition to a new step, some [Stepper.type] values will + /// lead to both steps being rendered for the duration of the animation shifting + /// between steps. + bool get isActive => currentStep == stepIndex; +} + +/// A builder that creates a widget given the two callbacks `onStepContinue` and +/// `onStepCancel`. +/// +/// Used by [Stepper.controlsBuilder]. +/// +/// See also: +/// +/// * [WidgetBuilder], which is similar but only takes a [BuildContext]. +typedef ControlsWidgetBuilder = Widget Function(BuildContext context, ControlsDetails details); + +/// A builder that creates the icon widget for the [Step] at [stepIndex], given +/// [stepState]. +typedef StepIconBuilder = Widget? Function(int stepIndex, StepState stepState); + +const TextStyle _kStepStyle = TextStyle(fontSize: 12.0, color: Colors.white); +const Color _kErrorLight = Colors.red; +final Color _kErrorDark = Colors.red.shade400; +const Color _kCircleActiveLight = Colors.white; +const Color _kCircleActiveDark = Colors.black87; +const Color _kDisabledLight = Colors.black38; +const Color _kDisabledDark = Colors.white38; +const double _kStepSize = 24.0; +const double _kTriangleSqrt = 0.866025; // sqrt(3.0) / 2.0 +const double _kTriangleHeight = _kStepSize * _kTriangleSqrt; +const double _kMaxStepSize = 80.0; +const EdgeInsetsDirectional _kDefaultVerticalContentPadding = EdgeInsetsDirectional.only( + start: 60.0, + end: 24.0, + bottom: 24.0, +); +const EdgeInsets _kDefaultHorizontalContentPadding = EdgeInsets.all(24.0); +const EdgeInsetsGeometry _kDefaultHeaderPadding = EdgeInsets.symmetric(horizontal: 24.0); + +/// A material step used in [Stepper]. The step can have a title and subtitle, +/// an icon within its circle, some content and a state that governs its +/// styling. +/// +/// See also: +/// +/// * [Stepper] +/// * <https://material.io/archive/guidelines/components/steppers.html> +@immutable +class Step { + /// Creates a step for a [Stepper]. + const Step({ + required this.title, + this.subtitle, + required this.content, + this.state = StepState.indexed, + this.isActive = false, + this.label, + this.stepStyle, + }); + + /// The title of the step that typically describes it. + final Widget title; + + /// The subtitle of the step that appears below the title and has a smaller + /// font size. It typically gives more details that complement the title. + /// + /// If null, the subtitle is not shown. + final Widget? subtitle; + + /// The content of the step that appears below the [title] and [subtitle]. + /// + /// Below the content, every step has a 'continue' and 'cancel' button. + final Widget content; + + /// The state of the step which determines the styling of its components + /// and whether steps are interactive. + final StepState state; + + /// Whether or not the step is active. The flag only influences styling. + final bool isActive; + + /// Only [StepperType.horizontal], Optional widget that appears under the [title]. + /// By default, uses the `bodyLarge` theme. + final Widget? label; + + /// Optional overrides for the step's default visual configuration. + final StepStyle? stepStyle; +} + +/// A material stepper widget that displays progress through a sequence of +/// steps. Steppers are particularly useful in the case of forms where one step +/// requires the completion of another one, or where multiple steps need to be +/// completed in order to submit the whole form. +/// +/// The widget is a flexible wrapper. A parent class should pass [currentStep] +/// to this widget based on some logic triggered by the three callbacks that it +/// provides. +/// +/// {@tool dartpad} +/// An example the shows how to use the [Stepper], and the [Stepper] UI +/// appearance. +/// +/// ** See code in examples/api/lib/material/stepper/stepper.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Step] +/// * <https://material.io/archive/guidelines/components/steppers.html> +class Stepper extends StatefulWidget { + /// Creates a stepper from a list of steps. + /// + /// This widget is not meant to be rebuilt with a different list of steps + /// unless a key is provided in order to distinguish the old stepper from the + /// new one. + const Stepper({ + super.key, + required this.steps, + this.controller, + this.physics, + this.type = StepperType.vertical, + this.currentStep = 0, + this.onStepTapped, + this.onStepContinue, + this.onStepCancel, + this.controlsBuilder, + this.elevation, + this.margin, + this.connectorColor, + this.connectorThickness, + this.stepIconBuilder, + this.stepIconHeight, + this.stepIconWidth, + this.stepIconMargin, + this.clipBehavior = Clip.none, + this.headerPadding, + this.contentPadding, + }) : assert(0 <= currentStep && currentStep < steps.length), + assert( + stepIconHeight == null || + (stepIconHeight >= _kStepSize && stepIconHeight <= _kMaxStepSize), + 'stepIconHeight must be greater than $_kStepSize and less or equal to $_kMaxStepSize', + ), + assert( + stepIconWidth == null || (stepIconWidth >= _kStepSize && stepIconWidth <= _kMaxStepSize), + 'stepIconWidth must be greater than $_kStepSize and less or equal to $_kMaxStepSize', + ), + assert( + stepIconHeight == null || stepIconWidth == null || stepIconHeight == stepIconWidth, + 'If either stepIconHeight or stepIconWidth is specified, both must be specified and ' + 'the values must be equal.', + ); + + /// The steps of the stepper whose titles, subtitles, icons always get shown. + /// + /// The length of [steps] must not change. + final List<Step> steps; + + /// How the stepper's scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to + /// animate after the user stops dragging the scroll view. + /// + /// If the stepper is contained within another scrollable it + /// can be helpful to set this property to [ClampingScrollPhysics]. + final ScrollPhysics? physics; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// To control the initial scroll offset of the scroll view, provide a + /// [controller] with its [ScrollController.initialScrollOffset] property set. + final ScrollController? controller; + + /// The type of stepper that determines the layout. In the case of + /// [StepperType.horizontal], the content of the current step is displayed + /// underneath as opposed to the [StepperType.vertical] case where it is + /// displayed in-between. + final StepperType type; + + /// The index into [steps] of the current step whose content is displayed. + final int currentStep; + + /// The callback called when a step is tapped, with its index passed as + /// an argument. + final ValueChanged<int>? onStepTapped; + + /// The callback called when the 'continue' button is tapped. + /// + /// If null, the 'continue' button will be disabled. + final VoidCallback? onStepContinue; + + /// The callback called when the 'cancel' button is tapped. + /// + /// If null, the 'cancel' button will be disabled. + final VoidCallback? onStepCancel; + + /// The callback for creating custom controls. + /// + /// If null, the default controls from the current theme will be used. + /// + /// This callback which takes in a context and a [ControlsDetails] object, which + /// contains step information and two functions: [onStepContinue] and [onStepCancel]. + /// These can be used to control the stepper. For example, reading the + /// [ControlsDetails.currentStep] value within the callback can change the text + /// of the continue or cancel button depending on which step users are at. + /// + /// {@tool dartpad} + /// Creates a stepper control with custom buttons. + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return Stepper( + /// controlsBuilder: + /// (BuildContext context, ControlsDetails details) { + /// return Row( + /// children: <Widget>[ + /// TextButton( + /// onPressed: details.onStepContinue, + /// child: Text('Continue to Step ${details.stepIndex + 1}'), + /// ), + /// TextButton( + /// onPressed: details.onStepCancel, + /// child: Text('Back to Step ${details.stepIndex - 1}'), + /// ), + /// ], + /// ); + /// }, + /// steps: const <Step>[ + /// Step( + /// title: Text('A'), + /// content: SizedBox( + /// width: 100.0, + /// height: 100.0, + /// ), + /// ), + /// Step( + /// title: Text('B'), + /// content: SizedBox( + /// width: 100.0, + /// height: 100.0, + /// ), + /// ), + /// ], + /// ); + /// } + /// ``` + /// ** See code in examples/api/lib/material/stepper/stepper.controls_builder.0.dart ** + /// {@end-tool} + final ControlsWidgetBuilder? controlsBuilder; + + /// The elevation of this stepper's [Material] when [type] is [StepperType.horizontal]. + final double? elevation; + + /// Custom margin on vertical stepper. + final EdgeInsetsGeometry? margin; + + /// Customize connected lines colors. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.disabled]. + /// + /// If not set then the widget will use default colors, primary for selected state + /// and grey.shade400 for disabled state. + final WidgetStateProperty<Color>? connectorColor; + + /// The thickness of the connecting lines. + final double? connectorThickness; + + /// Callback for creating custom icons for the [steps]. + /// + /// When overriding icon for [StepState.error], please return + /// a widget whose width and height are 14 pixels or less to avoid overflow. + /// + /// If null, the default icons will be used for respective [StepState]. + final StepIconBuilder? stepIconBuilder; + + /// Overrides the default step icon size height. + final double? stepIconHeight; + + /// Overrides the default step icon size width. + final double? stepIconWidth; + + /// Overrides the default step icon margin. + final EdgeInsets? stepIconMargin; + + /// The [Step.content] will be clipped to this Clip type. + /// + /// Defaults to [Clip.none]. + /// + /// See also: + /// + /// * [Clip], which explains how to use this property. + final Clip clipBehavior; + + /// The padding around the header row in both [StepperType.vertical] and + /// [StepperType.horizontal] steppers. + /// + /// Defaults to to `EdgeInsets.symmetric(horizontal: 24.0)`. + final EdgeInsetsGeometry? headerPadding; + + /// The padding around the content area in both [StepperType.vertical] and + /// [StepperType.horizontal] steppers. + /// + /// For [StepperType.horizontal], defaults to `EdgeInsets.all(24.0)`. + /// + /// For [StepperType.vertical], defaults to + /// `EdgeInsetsDirectional.only(start: 60.0, end: 24.0, bottom: 24.0)`. + /// The `start` padding is also increased by the `left` value of + /// [stepIconMargin] if it is provided. + final EdgeInsetsGeometry? contentPadding; + + @override + State<Stepper> createState() => _StepperState(); +} + +class _StepperState extends State<Stepper> with TickerProviderStateMixin { + late List<GlobalKey> _keys; + final Map<int, StepState> _oldStates = <int, StepState>{}; + + @override + void initState() { + super.initState(); + _keys = List<GlobalKey>.generate(widget.steps.length, (int i) => GlobalKey()); + + for (var i = 0; i < widget.steps.length; i += 1) { + _oldStates[i] = widget.steps[i].state; + } + } + + @override + void didUpdateWidget(Stepper oldWidget) { + super.didUpdateWidget(oldWidget); + assert(widget.steps.length == oldWidget.steps.length); + + for (var i = 0; i < oldWidget.steps.length; i += 1) { + _oldStates[i] = oldWidget.steps[i].state; + } + } + + EdgeInsetsGeometry? get _stepIconMargin => widget.stepIconMargin; + + double? get _stepIconHeight => widget.stepIconHeight; + + double? get _stepIconWidth => widget.stepIconWidth; + + EdgeInsetsGeometry get effectiveHeaderPadding => widget.headerPadding ?? _kDefaultHeaderPadding; + + double get _heightFactor { + return (_isLabel() && _stepIconHeight != null) ? 2.5 : 2.0; + } + + bool _isFirst(int index) { + return index == 0; + } + + bool _isLast(int index) { + return widget.steps.length - 1 == index; + } + + bool _isCurrent(int index) { + return widget.currentStep == index; + } + + bool _isDark() { + return Theme.brightnessOf(context) == Brightness.dark; + } + + bool _isLabel() { + return widget.steps.any((Step step) => step.label != null); + } + + StepStyle? _stepStyle(int index) { + return widget.steps[index].stepStyle; + } + + Color _connectorColor(bool isActive) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final states = <WidgetState>{if (isActive) WidgetState.selected else WidgetState.disabled}; + final Color? resolvedConnectorColor = widget.connectorColor?.resolve(states); + + return resolvedConnectorColor ?? (isActive ? colorScheme.primary : Colors.grey.shade400); + } + + Widget _buildLine(bool visible, bool isActive) { + return ColoredBox( + color: _connectorColor(isActive), + child: SizedBox(width: visible ? widget.connectorThickness ?? 1.0 : 0.0, height: 16.0), + ); + } + + Widget _buildCircleChild(int index, bool oldState) { + final StepState state = oldState ? _oldStates[index]! : widget.steps[index].state; + if (widget.stepIconBuilder?.call(index, state) case final Widget icon) { + return icon; + } + TextStyle? textStyle = _stepStyle(index)?.indexStyle; + final bool isDarkActive = _isDark() && widget.steps[index].isActive; + final Color iconColor = isDarkActive ? _kCircleActiveDark : _kCircleActiveLight; + textStyle ??= isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle; + + return switch (state) { + StepState.indexed || StepState.disabled => Text('${index + 1}', style: textStyle), + StepState.editing => Icon(Icons.edit, color: iconColor, size: 18.0), + StepState.complete => Icon(Icons.check, color: iconColor, size: 18.0), + StepState.error => const Center(child: Text('!', style: _kStepStyle)), + }; + } + + Color _circleColor(int index) { + final bool isActive = widget.steps[index].isActive; + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final states = <WidgetState>{if (isActive) WidgetState.selected else WidgetState.disabled}; + final Color? resolvedConnectorColor = widget.connectorColor?.resolve(states); + if (resolvedConnectorColor != null) { + return resolvedConnectorColor; + } + if (!_isDark()) { + return isActive ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.38); + } else { + return isActive ? colorScheme.secondary : colorScheme.background; + } + } + + Widget _buildCircle(int index, bool oldState) { + return Padding( + padding: _stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + width: _stepIconWidth ?? _kStepSize, + height: _stepIconHeight ?? _kStepSize, + child: AnimatedContainer( + curve: Curves.fastOutSlowIn, + duration: kThemeAnimationDuration, + decoration: BoxDecoration( + color: _stepStyle(index)?.color ?? _circleColor(index), + shape: BoxShape.circle, + border: _stepStyle(index)?.border, + boxShadow: _stepStyle(index)?.boxShadow != null + ? <BoxShadow>[_stepStyle(index)!.boxShadow!] + : null, + gradient: _stepStyle(index)?.gradient, + ), + child: Center( + child: _buildCircleChild( + index, + oldState && widget.steps[index].state == StepState.error, + ), + ), + ), + ), + ); + } + + Widget _buildTriangle(int index, bool oldState) { + Color? color = _stepStyle(index)?.errorColor; + color ??= _isDark() ? _kErrorDark : _kErrorLight; + + return Padding( + padding: _stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + width: _stepIconWidth ?? _kStepSize, + height: _stepIconHeight ?? _kStepSize, + child: Center( + child: SizedBox( + width: _stepIconWidth ?? _kStepSize, + height: _stepIconHeight != null ? _stepIconHeight! * _kTriangleSqrt : _kTriangleHeight, + child: CustomPaint( + painter: _TrianglePainter(color: color), + child: Align( + alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33. + child: _buildCircleChild( + index, + oldState && widget.steps[index].state != StepState.error, + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildIcon(int index) { + if (widget.steps[index].state != _oldStates[index]) { + return AnimatedCrossFade( + firstChild: _buildCircle(index, true), + secondChild: _buildTriangle(index, true), + firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn), + secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn), + sizeCurve: Curves.fastOutSlowIn, + crossFadeState: widget.steps[index].state == StepState.error + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: kThemeAnimationDuration, + ); + } else { + if (widget.steps[index].state != StepState.error) { + return _buildCircle(index, false); + } else { + return _buildTriangle(index, false); + } + } + } + + Widget _buildVerticalControls(int stepIndex) { + if (widget.controlsBuilder != null) { + return widget.controlsBuilder!( + context, + ControlsDetails( + currentStep: widget.currentStep, + onStepContinue: widget.onStepContinue, + onStepCancel: widget.onStepCancel, + stepIndex: stepIndex, + ), + ); + } + + final Color cancelColor = switch (Theme.brightnessOf(context)) { + Brightness.light => Colors.black54, + Brightness.dark => Colors.white70, + }; + + final ThemeData themeData = Theme.of(context); + final ColorScheme colorScheme = themeData.colorScheme; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + const OutlinedBorder buttonShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(2)), + ); + const buttonPadding = EdgeInsets.symmetric(horizontal: 16.0); + + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: SizedBox( + height: 48.0, + child: Row( + // The Material spec no longer includes a Stepper widget. The continue + // and cancel button styles have been configured to match the original + // version of this widget. + children: <Widget>[ + TextButton( + onPressed: widget.onStepContinue, + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.disabled) + ? null + : (_isDark() ? colorScheme.onSurface : colorScheme.onPrimary); + }), + backgroundColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return _isDark() || states.contains(WidgetState.disabled) + ? null + : colorScheme.primary; + }), + padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(buttonPadding), + shape: const MaterialStatePropertyAll<OutlinedBorder>(buttonShape), + ), + child: Text( + themeData.useMaterial3 + ? localizations.continueButtonLabel + : localizations.continueButtonLabel.toUpperCase(), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(start: 8.0), + child: TextButton( + onPressed: widget.onStepCancel, + style: TextButton.styleFrom( + foregroundColor: cancelColor, + padding: buttonPadding, + shape: buttonShape, + ), + child: Text( + themeData.useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase(), + ), + ), + ), + ], + ), + ), + ); + } + + TextStyle _titleStyle(int index) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + + switch (widget.steps[index].state) { + case StepState.indexed: + case StepState.editing: + case StepState.complete: + return textTheme.bodyLarge!; + case StepState.disabled: + return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight); + case StepState.error: + return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight); + } + } + + TextStyle _subtitleStyle(int index) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + + switch (widget.steps[index].state) { + case StepState.indexed: + case StepState.editing: + case StepState.complete: + return textTheme.bodySmall!; + case StepState.disabled: + return textTheme.bodySmall!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight); + case StepState.error: + return textTheme.bodySmall!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight); + } + } + + TextStyle _labelStyle(int index) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + + switch (widget.steps[index].state) { + case StepState.indexed: + case StepState.editing: + case StepState.complete: + return textTheme.bodyLarge!; + case StepState.disabled: + return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight); + case StepState.error: + return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight); + } + } + + Widget _buildHeaderText(int index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + AnimatedDefaultTextStyle( + style: _titleStyle(index), + duration: kThemeAnimationDuration, + curve: Curves.fastOutSlowIn, + child: widget.steps[index].title, + ), + if (widget.steps[index].subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: AnimatedDefaultTextStyle( + style: _subtitleStyle(index), + duration: kThemeAnimationDuration, + curve: Curves.fastOutSlowIn, + child: widget.steps[index].subtitle!, + ), + ), + ], + ); + } + + Widget _buildLabelText(int index) { + if (widget.steps[index].label != null) { + return AnimatedDefaultTextStyle( + style: _labelStyle(index), + duration: kThemeAnimationDuration, + child: widget.steps[index].label!, + ); + } + return const SizedBox.shrink(); + } + + Widget _buildVerticalHeader(int index) { + final bool isActive = widget.steps[index].isActive; + final bool isPreviousActive = index > 0 && widget.steps[index - 1].isActive; + return Padding( + padding: effectiveHeaderPadding, + child: Row( + children: <Widget>[ + Column( + children: <Widget>[ + // Line parts are always added in order for the ink splash to + // flood the tips of the connector lines. + _buildLine(!_isFirst(index), isPreviousActive), + _buildIcon(index), + _buildLine(!_isLast(index), isActive), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 12.0), + child: _buildHeaderText(index), + ), + ), + ], + ), + ); + } + + Widget _buildVerticalBody(int index) { + final double? marginLeft = _stepIconMargin?.resolve(TextDirection.ltr).left; + final double? marginRight = _stepIconMargin?.resolve(TextDirection.ltr).right; + final double? additionalMarginLeft = marginLeft != null ? marginLeft / 2.0 : null; + final double? additionalMarginRight = marginRight != null ? marginRight / 2.0 : null; + // Adjust vertical content padding to align with step icon when stepIconMargin is set. + final EdgeInsetsGeometry effectiveVerticalContentPadding = + (widget.contentPadding ?? _kDefaultVerticalContentPadding).add( + EdgeInsetsDirectional.only(start: marginLeft ?? 0.0), + ); + + return Stack( + children: <Widget>[ + PositionedDirectional( + // When use margin affects the left or right side of the child, we + // need to add half of the margin to the start or end of the child + // respectively to get the correct positioning. + start: 24.0 + (additionalMarginLeft ?? 0.0) + (additionalMarginRight ?? 0.0), + top: 0.0, + bottom: 0.0, + width: _stepIconWidth ?? _kStepSize, + child: Center( + // The line is drawn from the center of the circle vertically until + // it reaches the bottom and then horizontally to the edge of the + // stepper. + child: SizedBox( + width: !_isLast(index) ? (widget.connectorThickness ?? 1.0) : 0.0, + height: double.infinity, + child: ColoredBox(color: _connectorColor(widget.steps[index].isActive)), + ), + ), + ), + AnimatedCrossFade( + firstChild: const SizedBox(width: double.infinity, height: 0), + secondChild: Padding( + padding: effectiveVerticalContentPadding, + child: Column( + children: <Widget>[ + ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[index].content), + _buildVerticalControls(index), + ], + ), + ), + firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn), + secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn), + sizeCurve: Curves.fastOutSlowIn, + crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: kThemeAnimationDuration, + ), + ], + ); + } + + Widget _buildVertical() { + return ListView( + controller: widget.controller, + shrinkWrap: true, + physics: widget.physics, + children: <Widget>[ + for (int i = 0; i < widget.steps.length; i += 1) + Column( + key: _keys[i], + children: <Widget>[ + InkWell( + onTap: widget.steps[i].state != StepState.disabled + ? () { + // In the vertical case we need to scroll to the newly tapped + // step. + Scrollable.ensureVisible( + _keys[i].currentContext!, + curve: Curves.fastOutSlowIn, + duration: kThemeAnimationDuration, + ); + + widget.onStepTapped?.call(i); + } + : null, + canRequestFocus: widget.steps[i].state != StepState.disabled, + child: _buildVerticalHeader(i), + ), + _buildVerticalBody(i), + ], + ), + ], + ); + } + + Widget _buildHorizontal() { + // Effective horizontal content padding (custom or default). + final EdgeInsetsGeometry effectiveHorizontalContentPadding = + widget.contentPadding ?? _kDefaultHorizontalContentPadding; + + final children = <Widget>[ + for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[ + InkResponse( + onTap: widget.steps[i].state != StepState.disabled + ? () { + widget.onStepTapped?.call(i); + } + : null, + canRequestFocus: widget.steps[i].state != StepState.disabled, + child: Row( + children: <Widget>[ + SizedBox( + height: _isLabel() ? 104.0 : 72.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + if (widget.steps[i].label != null) const SizedBox(height: 24.0), + Center(child: _buildIcon(i)), + if (widget.steps[i].label != null) + SizedBox(height: 24.0, child: _buildLabelText(i)), + ], + ), + ), + Padding( + padding: _stepIconMargin ?? const EdgeInsetsDirectional.only(start: 12.0), + child: _buildHeaderText(i), + ), + ], + ), + ), + if (!_isLast(i)) + Expanded( + child: Padding( + padding: _stepIconMargin ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + height: + widget.steps[i].stepStyle?.connectorThickness ?? + widget.connectorThickness ?? + 1.0, + child: ColoredBox( + color: + widget.steps[i].stepStyle?.connectorColor ?? + _connectorColor(widget.steps[i].isActive), + ), + ), + ), + ), + ], + ]; + + final stepPanels = <Widget>[]; + for (var i = 0; i < widget.steps.length; i += 1) { + stepPanels.add( + Visibility( + maintainState: true, + visible: i == widget.currentStep, + child: ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[i].content), + ), + ); + } + + return Column( + children: <Widget>[ + Material( + elevation: widget.elevation ?? 2, + child: Padding( + padding: effectiveHeaderPadding, + child: SizedBox( + height: _stepIconHeight != null ? _stepIconHeight! * _heightFactor : null, + child: Row(children: children), + ), + ), + ), + Expanded( + child: ListView( + controller: widget.controller, + physics: widget.physics, + padding: effectiveHorizontalContentPadding, + children: <Widget>[ + AnimatedSize( + curve: Curves.fastOutSlowIn, + duration: kThemeAnimationDuration, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: stepPanels), + ), + _buildVerticalControls(widget.currentStep), + ], + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(() { + if (context.findAncestorWidgetOfExactType<Stepper>() != null) { + throw FlutterError( + 'Steppers must not be nested.\n' + 'The material specification advises that one should avoid embedding ' + 'steppers within steppers. ' + 'https://material.io/archive/guidelines/components/steppers.html#steppers-usage', + ); + } + return true; + }()); + return switch (widget.type) { + StepperType.vertical => _buildVertical(), + StepperType.horizontal => _buildHorizontal(), + }; + } +} + +// Paints a triangle whose base is the bottom of the bounding rectangle and its +// top vertex the middle of its top. +class _TrianglePainter extends CustomPainter { + _TrianglePainter({required this.color}); + + final Color color; + + @override + bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough. + + @override + bool shouldRepaint(_TrianglePainter oldPainter) { + return oldPainter.color != color; + } + + @override + void paint(Canvas canvas, Size size) { + final double base = size.width; + final double halfBase = size.width / 2.0; + final double height = size.height; + final points = <Offset>[Offset(0.0, height), Offset(base, height), Offset(halfBase, 0.0)]; + + canvas.drawPath(Path()..addPolygon(points, true), Paint()..color = color); + } +} + +/// This class is used to override the default visual properties of [Step] widgets within a [Stepper]. +/// +/// To customize the appearance of a [Step] create an instance of this class with non-null parameters +/// for the step properties whose default value you want to override. +/// +/// Example usage: +/// ```dart +/// Step( +/// title: const Text('Step 1'), +/// content: const Text('Content for Step 1'), +/// stepStyle: StepStyle( +/// color: Colors.blue, +/// errorColor: Colors.red, +/// border: Border.all(color: Colors.grey), +/// boxShadow: const BoxShadow(blurRadius: 3.0, color: Colors.black26), +/// gradient: const LinearGradient(colors: <Color>[Colors.red, Colors.blue]), +/// indexStyle: const TextStyle(color: Colors.white), +/// ), +/// ) +/// ``` +/// +/// {@tool dartpad} +/// An example that uses [StepStyle] to customize the appearance of each [Step] in a [Stepper]. +/// +/// ** See code in examples/api/lib/material/stepper/step_style.0.dart ** +/// {@end-tool} + +@immutable +class StepStyle with Diagnosticable { + /// Constructs a [StepStyle]. + const StepStyle({ + this.color, + this.errorColor, + this.connectorColor, + this.connectorThickness, + this.border, + this.boxShadow, + this.gradient, + this.indexStyle, + }); + + /// Overrides the default color of the circle in the step. + final Color? color; + + /// Overrides the default color of the error indicator in the step. + final Color? errorColor; + + /// Overrides the default color of the connector line between two steps. + /// + /// This property only applies when [Stepper.type] is [StepperType.horizontal]. + final Color? connectorColor; + + /// Overrides the default thickness of the connector line between two steps. + /// + /// This property only applies when [Stepper.type] is [StepperType.horizontal]. + final double? connectorThickness; + + /// Add a border around the step. + /// + /// Will be applied to the circle in the step. + final BoxBorder? border; + + /// Add a shadow around the step. + final BoxShadow? boxShadow; + + /// Add a gradient around the step. + /// + /// If [gradient] is specified, [color] will be ignored. + final Gradient? gradient; + + /// Overrides the default style of the index in the step. + final TextStyle? indexStyle; + + /// Returns a copy of this ButtonStyle with the given fields replaced with + /// the new values. + StepStyle copyWith({ + Color? color, + Color? errorColor, + Color? connectorColor, + double? connectorThickness, + BoxBorder? border, + BoxShadow? boxShadow, + Gradient? gradient, + TextStyle? indexStyle, + }) { + return StepStyle( + color: color ?? this.color, + errorColor: errorColor ?? this.errorColor, + connectorColor: connectorColor ?? this.connectorColor, + connectorThickness: connectorThickness ?? this.connectorThickness, + border: border ?? this.border, + boxShadow: boxShadow ?? this.boxShadow, + gradient: gradient ?? this.gradient, + indexStyle: indexStyle ?? this.indexStyle, + ); + } + + /// Returns a copy of this StepStyle where the non-null fields in [stepStyle] + /// have replaced the corresponding null fields in this StepStyle. + /// + /// In other words, [stepStyle] is used to fill in unspecified (null) fields + /// this StepStyle. + StepStyle merge(StepStyle? stepStyle) { + if (stepStyle == null) { + return this; + } + return copyWith( + color: stepStyle.color, + errorColor: stepStyle.errorColor, + connectorColor: stepStyle.connectorColor, + connectorThickness: stepStyle.connectorThickness, + border: stepStyle.border, + boxShadow: stepStyle.boxShadow, + gradient: stepStyle.gradient, + indexStyle: stepStyle.indexStyle, + ); + } + + @override + int get hashCode { + return Object.hash( + color, + errorColor, + connectorColor, + connectorThickness, + border, + boxShadow, + gradient, + indexStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is StepStyle && + other.color == color && + other.errorColor == errorColor && + other.connectorColor == connectorColor && + other.connectorThickness == connectorThickness && + other.border == border && + other.boxShadow == boxShadow && + other.gradient == gradient && + other.indexStyle == indexStyle; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final theme = ThemeData.fallback(); + final TextTheme defaultTextTheme = theme.textTheme; + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('errorColor', errorColor, defaultValue: null)); + properties.add(ColorProperty('connectorColor', connectorColor, defaultValue: null)); + properties.add(DoubleProperty('connectorThickness', connectorThickness, defaultValue: null)); + properties.add(DiagnosticsProperty<BoxBorder>('border', border, defaultValue: null)); + properties.add(DiagnosticsProperty<BoxShadow>('boxShadow', boxShadow, defaultValue: null)); + properties.add(DiagnosticsProperty<Gradient>('gradient', gradient, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>( + 'indexStyle', + indexStyle, + defaultValue: defaultTextTheme.bodyLarge, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/switch.dart b/packages/material_ui/lib/src/switch.dart new file mode 100644 index 000000000000..1f4c503afe33 --- /dev/null +++ b/packages/material_ui/lib/src/switch.dart @@ -0,0 +1,2397 @@ +// 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. + +/// @docImport 'checkbox.dart'; +/// @docImport 'list_tile.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio.dart'; +/// @docImport 'slider.dart'; +/// @docImport 'switch_list_tile.dart'; +library; + +import 'dart:ui'; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'material_state.dart'; +import 'shadows.dart'; +import 'switch_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// bool _giveVerse = true; +// late StateSetter setState; + +enum _SwitchType { material, adaptive } + +/// A Material Design switch. +/// +/// Used to toggle the on/off state of a single setting. +/// +/// The switch itself does not maintain any state. Instead, when the state of +/// the switch changes, the widget calls the [onChanged] callback. Most widgets +/// that use a switch will listen for the [onChanged] callback and rebuild the +/// switch with a new [value] to update the visual appearance of the switch. +/// +/// If the [onChanged] callback is null, then the switch will be disabled (it +/// will not respond to input). A disabled switch's thumb and track are rendered +/// in shades of grey by default. The default appearance of a disabled switch +/// can be overridden with [inactiveThumbColor] and [inactiveTrackColor]. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// Material Design 3 provides the option to add icons on the thumb of the [Switch]. +/// If [ThemeData.useMaterial3] is set to true, users can use [Switch.thumbIcon] +/// to add optional Icons based on the different [WidgetState]s of the [Switch]. +/// +/// {@tool dartpad} +/// This example shows a toggleable [Switch]. When the thumb slides to the other +/// side of the track, the switch is toggled between on/off. +/// +/// ** See code in examples/api/lib/material/switch/switch.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to customize [Switch] using [WidgetStateProperty] +/// switch properties. +/// +/// ** See code in examples/api/lib/material/switch/switch.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to add icons on the thumb of the [Switch] using the +/// [Switch.thumbIcon] property. +/// +/// ** See code in examples/api/lib/material/switch/switch.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to use the ambient [CupertinoThemeData] to style all +/// widgets which would otherwise use iOS defaults. +/// +/// ** See code in examples/api/lib/material/switch/switch.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SwitchListTile], which combines this widget with a [ListTile] so that +/// you can give the switch a label. +/// * [Checkbox], another widget with similar semantics. +/// * [Radio], for selecting among a set of explicit values. +/// * [Slider], for selecting a value in a range. +/// * [WidgetStateProperty], an interface for objects that "resolve" to +/// different values depending on a widget's material state. +/// * <https://material.io/design/components/selection-controls.html#switches> +class Switch extends StatelessWidget { + /// Creates a Material Design switch. + /// + /// The switch itself does not maintain any state. Instead, when the state of + /// the switch changes, the widget calls the [onChanged] callback. Most widgets + /// that use a switch will listen for the [onChanged] callback and rebuild the + /// switch with a new [value] to update the visual appearance of the switch. + /// + /// The following arguments are required: + /// + /// * [value] determines whether this switch is on or off. + /// * [onChanged] is called when the user toggles the switch on or off. + const Switch({ + super.key, + required this.value, + required this.onChanged, + @Deprecated( + 'Use activeThumbColor instead. ' + 'This feature was deprecated after v3.31.0-2.0.pre.', + ) + this.activeColor, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.trackOutlineWidth, + this.thumbIcon, + this.materialTapTargetSize, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.padding, + }) : _switchType = _SwitchType.material, + applyCupertinoTheme = false, + assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null); + + /// Creates an adaptive [Switch] based on whether the target platform is iOS + /// or macOS, following Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// Creates a switch that looks and feels native when the [ThemeData.platform] + /// is iOS or macOS, otherwise a Material Design switch is created. + /// + /// To provide a custom switch theme that's only used by this factory + /// constructor, pass a custom `Adaptation<SwitchThemeData>` class to the + /// `adaptations` parameter of [ThemeData]. This can be useful in situations + /// where you don't want the overall [ThemeData.switchTheme] to apply when + /// this adaptive constructor is used. + /// + /// {@tool dartpad} + /// This sample shows how to create and use subclasses of [Adaptation] that + /// define adaptive [SwitchThemeData]s. + /// + /// ** See code in examples/api/lib/material/switch/switch.4.dart ** + /// {@end-tool} + /// + /// The target platform is based on the current [Theme]: [ThemeData.platform]. + const Switch.adaptive({ + super.key, + required this.value, + required this.onChanged, + @Deprecated( + 'Use activeThumbColor or activeTrackColor instead. ' + 'This feature was deprecated after v3.31.0-2.0.pre.', + ) + this.activeColor, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.materialTapTargetSize, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.trackOutlineWidth, + this.thumbIcon, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.padding, + this.applyCupertinoTheme, + }) : assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null), + _switchType = _SwitchType.adaptive; + + /// Whether this switch is on or off. + final bool value; + + /// Called when the user toggles the switch on or off. + /// + /// The switch passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the switch with the new + /// value. + /// + /// If null, the switch will be displayed as disabled. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// Switch( + /// value: _giveVerse, + /// onChanged: (bool newValue) { + /// setState(() { + /// _giveVerse = newValue; + /// }); + /// }, + /// ) + /// ``` + final ValueChanged<bool>? onChanged; + + /// {@template flutter.material.switch.activeColor} + /// The color to use when this switch is on. + /// {@endtemplate} + /// + /// Defaults to [ColorScheme.secondary]. + /// + /// If [thumbColor] returns a non-null color in the [WidgetState.selected] + /// state, it will be used instead of this color. + @Deprecated( + 'Use activeThumbColor or activeTrackColor instead. ' + 'This feature was deprecated after v3.31.0-2.0.pre.', + ) + final Color? activeColor; + + /// {@template flutter.material.switch.activeThumbColor} + /// The color to use when this switch is on. + /// {@endtemplate} + /// + /// Defaults to [ColorScheme.secondary]. + /// + /// If [thumbColor] returns a non-null color in the [WidgetState.selected] + /// state, it will be used instead of this color. + final Color? activeThumbColor; + + /// {@template flutter.material.switch.activeTrackColor} + /// The color to use on the track when this switch is on. + /// {@endtemplate} + /// + /// Defaults to [ColorScheme.secondary] with the opacity set at 50%. + /// + /// If [trackColor] returns a non-null color in the [WidgetState.selected] + /// state, it will be used instead of this color. + final Color? activeTrackColor; + + /// {@template flutter.material.switch.inactiveThumbColor} + /// The color to use on the thumb when this switch is off. + /// {@endtemplate} + /// + /// Defaults to the colors described in the Material design specification. + /// + /// If [thumbColor] returns a non-null color in the default state, it will be + /// used instead of this color. + final Color? inactiveThumbColor; + + /// {@template flutter.material.switch.inactiveTrackColor} + /// The color to use on the track when this switch is off. + /// {@endtemplate} + /// + /// Defaults to the colors described in the Material design specification. + /// + /// If [trackColor] returns a non-null color in the default state, it will be + /// used instead of this color. + final Color? inactiveTrackColor; + + /// {@template flutter.material.switch.activeThumbImage} + /// An image to use on the thumb of this switch when the switch is on. + /// {@endtemplate} + final ImageProvider? activeThumbImage; + + /// {@template flutter.material.switch.onActiveThumbImageError} + /// An optional error callback for errors emitted when loading + /// [activeThumbImage]. + /// {@endtemplate} + final ImageErrorListener? onActiveThumbImageError; + + /// {@template flutter.material.switch.inactiveThumbImage} + /// An image to use on the thumb of this switch when the switch is off. + /// {@endtemplate} + final ImageProvider? inactiveThumbImage; + + /// {@template flutter.material.switch.onInactiveThumbImageError} + /// An optional error callback for errors emitted when loading + /// [inactiveThumbImage]. + /// {@endtemplate} + final ImageErrorListener? onInactiveThumbImageError; + + /// {@template flutter.material.switch.thumbColor} + /// The color of this [Switch]'s thumb. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [thumbColor] based on the current + /// [WidgetState] of the [Switch], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Switch( + /// value: true, + /// onChanged: (bool value) { }, + /// thumbColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return Colors.orange.withValues(alpha: .48); + /// } + /// return Colors.orange; + /// }), + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If null, then the value of [activeThumbColor] is used in the selected + /// state and [inactiveThumbColor] in the default state. If that is also null, + /// then the value of [SwitchThemeData.thumbColor] is used. If that is also + /// null, then the following colors are used: + /// + /// | State | Light theme | Dark theme | + /// |----------|-----------------------------------|-----------------------------------| + /// | Default | `Colors.grey.shade50` | `Colors.grey.shade400` | + /// | Selected | [ColorScheme.secondary] | [ColorScheme.secondary] | + /// | Disabled | `Colors.grey.shade400` | `Colors.grey.shade800` | + final WidgetStateProperty<Color?>? thumbColor; + + /// {@template flutter.material.switch.trackColor} + /// The color of this [Switch]'s track. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [trackColor] based on the current + /// [WidgetState] of the [Switch], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Switch( + /// value: true, + /// onChanged: (bool value) { }, + /// thumbColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return Colors.orange.withValues(alpha: .48); + /// } + /// return Colors.orange; + /// }), + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If null, then the value of [activeTrackColor] is used in the selected + /// state and [inactiveTrackColor] in the default state. If that is also null, + /// then the value of [SwitchThemeData.trackColor] is used. If that is also + /// null, then the following colors are used: + /// + /// | State | Light theme | Dark theme | + /// |----------|---------------------------------|---------------------------------| + /// | Default | `Color(0x52000000)` | `Colors.white30` | + /// | Selected | [activeThumbColor] with alpha `0x80` | [activeThumbColor] with alpha `0x80` | + /// | Disabled | `Colors.black12` | `Colors.white10` | + final WidgetStateProperty<Color?>? trackColor; + + /// {@template flutter.material.switch.trackOutlineColor} + /// The outline color of this [Switch]'s track. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [trackOutlineColor] based on the current + /// [WidgetState] of the [Switch], providing a different [Color] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Switch( + /// value: true, + /// onChanged: (bool value) { }, + /// trackOutlineColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return Colors.orange.withValues(alpha: .48); + /// } + /// return null; // Use the default color. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// In Material 3, the outline color defaults to transparent in the selected + /// state and [ColorScheme.outline] in the unselected state. In Material 2, + /// the [Switch] track has no outline by default. + final WidgetStateProperty<Color?>? trackOutlineColor; + + /// {@template flutter.material.switch.trackOutlineWidth} + /// The outline width of this [Switch]'s track. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [trackOutlineWidth] based on the current + /// [WidgetState] of the [Switch], providing a different outline width when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Switch( + /// value: true, + /// onChanged: (bool value) { }, + /// trackOutlineWidth: WidgetStateProperty.resolveWith<double?>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return 5.0; + /// } + /// return null; // Use the default width. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// Defaults to 2.0. + final WidgetStateProperty<double?>? trackOutlineWidth; + + /// {@template flutter.material.switch.thumbIcon} + /// The icon to use on the thumb of this switch + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// {@tool snippet} + /// This example resolves the [thumbIcon] based on the current + /// [WidgetState] of the [Switch], providing a different [Icon] when it is + /// [WidgetState.disabled]. + /// + /// ```dart + /// Switch( + /// value: true, + /// onChanged: (bool value) { }, + /// thumbIcon: WidgetStateProperty.resolveWith<Icon?>((Set<WidgetState> states) { + /// if (states.contains(WidgetState.disabled)) { + /// return const Icon(Icons.close); + /// } + /// return null; // All other states will use the default thumbIcon. + /// }), + /// ) + /// ``` + /// {@end-tool} + /// {@endtemplate} + /// + /// If null, then the value of [SwitchThemeData.thumbIcon] is used. If this is also null, + /// then the [Switch] does not have any icons on the thumb. + final WidgetStateProperty<Icon?>? thumbIcon; + + /// {@template flutter.material.switch.materialTapTargetSize} + /// Configures the minimum size of the tap target. + /// {@endtemplate} + /// + /// If null, then the value of [SwitchThemeData.materialTapTargetSize] is + /// used. If that is also null, then the value of + /// [ThemeData.materialTapTargetSize] is used. + /// + /// See also: + /// + /// * [MaterialTapTargetSize], for a description of how this affects tap targets. + final MaterialTapTargetSize? materialTapTargetSize; + + final _SwitchType _switchType; + + /// {@macro flutter.cupertino.CupertinoSwitch.applyTheme} + final bool? applyCupertinoTheme; + + /// {@macro flutter.cupertino.CupertinoSwitch.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.material.switch.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [SwitchThemeData.mouseCursor] is used. If that + /// is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The color for the button's [Material] when it has the input focus. + /// + /// If [overlayColor] returns a non-null color in the [WidgetState.focused] + /// state, it will be used instead. + /// + /// If null, then the value of [SwitchThemeData.overlayColor] is used in the + /// focused state. If that is also null, then the value of + /// [ThemeData.focusColor] is used. + final Color? focusColor; + + /// The color for the button's [Material] when a pointer is hovering over it. + /// + /// If [overlayColor] returns a non-null color in the [WidgetState.hovered] + /// state, it will be used instead. + /// + /// If null, then the value of [SwitchThemeData.overlayColor] is used in the + /// hovered state. If that is also null, then the value of + /// [ThemeData.hoverColor] is used. + final Color? hoverColor; + + /// {@template flutter.material.switch.overlayColor} + /// The color for the switch's [Material]. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// {@endtemplate} + /// + /// If null, then the value of [activeThumbColor] with alpha + /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the + /// pressed, focused and hovered state. If that is also null, + /// the value of [SwitchThemeData.overlayColor] is used. If that is + /// also null, then the value of [ColorScheme.secondary] with alpha + /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] + /// is used in the pressed, focused and hovered state. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@template flutter.material.switch.splashRadius} + /// The splash radius of the circular [Material] ink response. + /// {@endtemplate} + /// + /// If null, then the value of [SwitchThemeData.splashRadius] is used. If that + /// is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// The amount of space to surround the child inside the bounds of the [Switch]. + /// + /// Defaults to horizontal padding of 4 pixels. If [ThemeData.useMaterial3] is false, + /// then there is no padding by default. + final EdgeInsetsGeometry? padding; + + Size _getSwitchSize(BuildContext context) { + final ThemeData theme = Theme.of(context); + SwitchThemeData switchTheme = SwitchTheme.of(context); + final SwitchThemeData defaults = theme.useMaterial3 + ? _SwitchDefaultsM3(context) + : _SwitchDefaultsM2(context); + if (_switchType == _SwitchType.adaptive) { + final Adaptation<SwitchThemeData> switchAdaptation = + theme.getAdaptation<SwitchThemeData>() ?? const _SwitchThemeAdaptation(); + switchTheme = switchAdaptation.adapt(theme, switchTheme); + } + final _SwitchConfig switchConfig = theme.useMaterial3 + ? _SwitchConfigM3(context) + : _SwitchConfigM2(); + + final MaterialTapTargetSize effectiveMaterialTapTargetSize = + materialTapTargetSize ?? switchTheme.materialTapTargetSize ?? theme.materialTapTargetSize; + final EdgeInsetsGeometry effectivePadding = padding ?? switchTheme.padding ?? defaults.padding!; + return switch (effectiveMaterialTapTargetSize) { + MaterialTapTargetSize.padded => Size( + switchConfig.switchWidth + effectivePadding.horizontal, + switchConfig.switchHeight + effectivePadding.vertical, + ), + MaterialTapTargetSize.shrinkWrap => Size( + switchConfig.switchWidth + effectivePadding.horizontal, + switchConfig.switchHeightCollapsed + effectivePadding.vertical, + ), + }; + } + + @override + Widget build(BuildContext context) { + Color? effectiveActiveThumbColor; + Color? effectiveActiveTrackColor; + switch (_switchType) { + case _SwitchType.material: + effectiveActiveThumbColor = activeColor; + case _SwitchType.adaptive: + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + effectiveActiveThumbColor = activeColor; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + effectiveActiveTrackColor = activeColor; + } + } + return _MaterialSwitch( + value: value, + onChanged: onChanged, + size: _getSwitchSize(context), + activeThumbColor: activeThumbColor ?? effectiveActiveThumbColor, + activeTrackColor: activeTrackColor ?? effectiveActiveTrackColor, + inactiveThumbColor: inactiveThumbColor, + inactiveTrackColor: inactiveTrackColor, + activeThumbImage: activeThumbImage, + onActiveThumbImageError: onActiveThumbImageError, + inactiveThumbImage: inactiveThumbImage, + onInactiveThumbImageError: onInactiveThumbImageError, + thumbColor: thumbColor, + trackColor: trackColor, + trackOutlineColor: trackOutlineColor, + trackOutlineWidth: trackOutlineWidth, + thumbIcon: thumbIcon, + materialTapTargetSize: materialTapTargetSize, + dragStartBehavior: dragStartBehavior, + mouseCursor: mouseCursor, + focusColor: focusColor, + hoverColor: hoverColor, + overlayColor: overlayColor, + splashRadius: splashRadius, + focusNode: focusNode, + onFocusChange: onFocusChange, + autofocus: autofocus, + applyCupertinoTheme: applyCupertinoTheme, + switchType: _switchType, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off', showName: true), + ); + properties.add( + ObjectFlagProperty<ValueChanged<bool>>('onChanged', onChanged, ifNull: 'disabled'), + ); + } +} + +class _MaterialSwitch extends StatefulWidget { + const _MaterialSwitch({ + required this.value, + required this.onChanged, + required this.size, + required this.switchType, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.trackOutlineWidth, + this.thumbIcon, + this.materialTapTargetSize, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.focusColor, + this.hoverColor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.applyCupertinoTheme, + }) : assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null); + + final bool value; + final ValueChanged<bool>? onChanged; + final Color? activeThumbColor; + final Color? activeTrackColor; + final Color? inactiveThumbColor; + final Color? inactiveTrackColor; + final ImageProvider? activeThumbImage; + final ImageErrorListener? onActiveThumbImageError; + final ImageProvider? inactiveThumbImage; + final ImageErrorListener? onInactiveThumbImageError; + final WidgetStateProperty<Color?>? thumbColor; + final WidgetStateProperty<Color?>? trackColor; + final WidgetStateProperty<Color?>? trackOutlineColor; + final WidgetStateProperty<double?>? trackOutlineWidth; + final WidgetStateProperty<Icon?>? thumbIcon; + final MaterialTapTargetSize? materialTapTargetSize; + final DragStartBehavior dragStartBehavior; + final MouseCursor? mouseCursor; + final Color? focusColor; + final Color? hoverColor; + final WidgetStateProperty<Color?>? overlayColor; + final double? splashRadius; + final FocusNode? focusNode; + final ValueChanged<bool>? onFocusChange; + final bool autofocus; + final Size size; + final bool? applyCupertinoTheme; + final _SwitchType switchType; + + @override + State<StatefulWidget> createState() => _MaterialSwitchState(); +} + +class _MaterialSwitchState extends State<_MaterialSwitch> + with TickerProviderStateMixin, ToggleableStateMixin { + final _SwitchPainter _painter = _SwitchPainter(); + + @override + void didUpdateWidget(_MaterialSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + // During a drag we may have modified the curve, reset it if its possible + // to do without visual discontinuation. + if (position.value == 0.0 || position.value == 1.0) { + switch (widget.switchType) { + case _SwitchType.adaptive: + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + updateCurve(); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + position + ..curve = Curves.linear + ..reverseCurve = Curves.linear; + } + case _SwitchType.material: + updateCurve(); + } + } + animateToValue(); + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + @override + ValueChanged<bool?>? get onChanged => widget.onChanged != null ? _handleChanged : null; + + @override + bool get tristate => false; + + @override + bool? get value => widget.value; + + @override + Duration? get reactionAnimationDuration => kRadialReactionDuration; + + void updateCurve() { + if (Theme.of(context).useMaterial3) { + position + ..curve = Curves.easeOutBack + ..reverseCurve = Curves.easeOutBack.flipped; + } else { + position + ..curve = Curves.easeIn + ..reverseCurve = Curves.easeOut; + } + } + + WidgetStateProperty<Color?> get _widgetThumbColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return widget.inactiveThumbColor; + } + if (states.contains(WidgetState.selected)) { + return widget.activeThumbColor; + } + return widget.inactiveThumbColor; + }); + } + + WidgetStateProperty<Color?> get _widgetTrackColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return widget.activeTrackColor; + } + return widget.inactiveTrackColor; + }); + } + + double get _trackInnerLength { + switch (widget.switchType) { + case _SwitchType.adaptive: + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + final _SwitchConfig config = Theme.of(context).useMaterial3 + ? _SwitchConfigM3(context) + : _SwitchConfigM2(); + final double trackInnerStart = config.trackHeight / 2.0; + final double trackInnerEnd = config.trackWidth - trackInnerStart; + final double trackInnerLength = trackInnerEnd - trackInnerStart; + return trackInnerLength; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + final _SwitchConfig config = _SwitchConfigCupertino(context); + final double trackInnerStart = config.trackHeight / 2.0; + final double trackInnerEnd = config.trackWidth - trackInnerStart; + final double trackInnerLength = trackInnerEnd - trackInnerStart; + return trackInnerLength; + } + case _SwitchType.material: + final _SwitchConfig config = Theme.of(context).useMaterial3 + ? _SwitchConfigM3(context) + : _SwitchConfigM2(); + final double trackInnerStart = config.trackHeight / 2.0; + final double trackInnerEnd = config.trackWidth - trackInnerStart; + final double trackInnerLength = trackInnerEnd - trackInnerStart; + return trackInnerLength; + } + } + + void _handleDragStart(DragStartDetails details) { + if (isInteractive) { + reactionController.forward(); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (isInteractive) { + position + ..curve = Curves.linear + ..reverseCurve = null; + final double delta = details.primaryDelta! / _trackInnerLength; + positionController.value += switch (Directionality.of(context)) { + TextDirection.rtl => -delta, + TextDirection.ltr => delta, + }; + } + } + + bool _needsPositionAnimation = false; + + void _handleDragEnd(DragEndDetails details) { + if (position.value >= 0.5 != widget.value) { + widget.onChanged?.call(!widget.value); + // Wait with finishing the animation until widget.value has changed to + // !widget.value as part of the widget.onChanged call above. + setState(() { + _needsPositionAnimation = true; + }); + } else { + animateToValue(); + } + reactionController.reverse(); + } + + void _handleChanged(bool? value) { + assert(value != null); + assert(widget.onChanged != null); + widget.onChanged?.call(value!); + } + + bool isCupertino = false; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + + if (_needsPositionAnimation) { + _needsPositionAnimation = false; + animateToValue(); + } + + final ThemeData theme = Theme.of(context); + SwitchThemeData switchTheme = SwitchTheme.of(context); + final Color cupertinoPrimaryColor = + theme.cupertinoOverrideTheme?.primaryColor ?? theme.colorScheme.primary; + + _SwitchConfig switchConfig; + SwitchThemeData defaults; + var applyCupertinoTheme = false; + double disabledOpacity = 1; + switch (widget.switchType) { + case _SwitchType.material: + switchConfig = theme.useMaterial3 ? _SwitchConfigM3(context) : _SwitchConfigM2(); + defaults = theme.useMaterial3 ? _SwitchDefaultsM3(context) : _SwitchDefaultsM2(context); + case _SwitchType.adaptive: + final Adaptation<SwitchThemeData> switchAdaptation = + theme.getAdaptation<SwitchThemeData>() ?? const _SwitchThemeAdaptation(); + switchTheme = switchAdaptation.adapt(theme, switchTheme); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + switchConfig = theme.useMaterial3 ? _SwitchConfigM3(context) : _SwitchConfigM2(); + defaults = theme.useMaterial3 ? _SwitchDefaultsM3(context) : _SwitchDefaultsM2(context); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + isCupertino = true; + applyCupertinoTheme = + widget.applyCupertinoTheme ?? + theme.cupertinoOverrideTheme?.applyThemeToAll ?? + false; + disabledOpacity = 0.5; + switchConfig = _SwitchConfigCupertino(context); + defaults = _SwitchDefaultsCupertino(context); + reactionController.duration = const Duration(milliseconds: 200); + } + } + + positionController.duration = Duration(milliseconds: switchConfig.toggleDuration); + + // Colors need to be resolved in selected and non selected states separately + // so that they can be lerped between. + final Set<WidgetState> activeStates = states..add(WidgetState.selected); + final Set<WidgetState> inactiveStates = states..remove(WidgetState.selected); + + final Color? activeThumbColor = + widget.thumbColor?.resolve(activeStates) ?? + _widgetThumbColor.resolve(activeStates) ?? + switchTheme.thumbColor?.resolve(activeStates); + final Color effectiveActiveThumbColor = + activeThumbColor ?? defaults.thumbColor!.resolve(activeStates)!; + final Color? inactiveThumbColor = + widget.thumbColor?.resolve(inactiveStates) ?? + _widgetThumbColor.resolve(inactiveStates) ?? + switchTheme.thumbColor?.resolve(inactiveStates); + final Color effectiveInactiveThumbColor = + inactiveThumbColor ?? defaults.thumbColor!.resolve(inactiveStates)!; + final Color effectiveActiveTrackColor = + widget.trackColor?.resolve(activeStates) ?? + _widgetTrackColor.resolve(activeStates) ?? + (applyCupertinoTheme + ? cupertinoPrimaryColor + : switchTheme.trackColor?.resolve(activeStates)) ?? + _widgetThumbColor.resolve(activeStates)?.withAlpha(0x80) ?? + defaults.trackColor!.resolve(activeStates)!; + final Color? effectiveActiveTrackOutlineColor = + widget.trackOutlineColor?.resolve(activeStates) ?? + switchTheme.trackOutlineColor?.resolve(activeStates) ?? + defaults.trackOutlineColor!.resolve(activeStates); + final double? effectiveActiveTrackOutlineWidth = + widget.trackOutlineWidth?.resolve(activeStates) ?? + switchTheme.trackOutlineWidth?.resolve(activeStates) ?? + defaults.trackOutlineWidth?.resolve(activeStates); + + final Color effectiveInactiveTrackColor = + widget.trackColor?.resolve(inactiveStates) ?? + _widgetTrackColor.resolve(inactiveStates) ?? + switchTheme.trackColor?.resolve(inactiveStates) ?? + defaults.trackColor!.resolve(inactiveStates)!; + final Color? effectiveInactiveTrackOutlineColor = + widget.trackOutlineColor?.resolve(inactiveStates) ?? + switchTheme.trackOutlineColor?.resolve(inactiveStates) ?? + defaults.trackOutlineColor?.resolve(inactiveStates); + final double? effectiveInactiveTrackOutlineWidth = + widget.trackOutlineWidth?.resolve(inactiveStates) ?? + switchTheme.trackOutlineWidth?.resolve(inactiveStates) ?? + defaults.trackOutlineWidth?.resolve(inactiveStates); + + final Icon? effectiveActiveIcon = + widget.thumbIcon?.resolve(activeStates) ?? switchTheme.thumbIcon?.resolve(activeStates); + final Icon? effectiveInactiveIcon = + widget.thumbIcon?.resolve(inactiveStates) ?? switchTheme.thumbIcon?.resolve(inactiveStates); + + final Color effectiveActiveIconColor = + effectiveActiveIcon?.color ?? switchConfig.iconColor.resolve(activeStates); + final Color effectiveInactiveIconColor = + effectiveInactiveIcon?.color ?? switchConfig.iconColor.resolve(inactiveStates); + + final Set<WidgetState> focusedStates = states..add(WidgetState.focused); + final Color effectiveFocusOverlayColor = + widget.overlayColor?.resolve(focusedStates) ?? + widget.focusColor ?? + switchTheme.overlayColor?.resolve(focusedStates) ?? + (applyCupertinoTheme + ? HSLColor.fromColor( + cupertinoPrimaryColor.withOpacity(0.80), + ).withLightness(0.69).withSaturation(0.835).toColor() + : null) ?? + defaults.overlayColor!.resolve(focusedStates)!; + + final Set<WidgetState> hoveredStates = states..add(WidgetState.hovered); + final Color effectiveHoverOverlayColor = + widget.overlayColor?.resolve(hoveredStates) ?? + widget.hoverColor ?? + switchTheme.overlayColor?.resolve(hoveredStates) ?? + defaults.overlayColor!.resolve(hoveredStates)!; + + final activePressedStates = activeStates..add(WidgetState.pressed); + final Color effectiveActivePressedThumbColor = + widget.thumbColor?.resolve(activePressedStates) ?? + _widgetThumbColor.resolve(activePressedStates) ?? + switchTheme.thumbColor?.resolve(activePressedStates) ?? + defaults.thumbColor!.resolve(activePressedStates)!; + final Color effectiveActivePressedOverlayColor = + widget.overlayColor?.resolve(activePressedStates) ?? + switchTheme.overlayColor?.resolve(activePressedStates) ?? + activeThumbColor?.withAlpha(kRadialReactionAlpha) ?? + defaults.overlayColor!.resolve(activePressedStates)!; + + final inactivePressedStates = inactiveStates..add(WidgetState.pressed); + final Color effectiveInactivePressedThumbColor = + widget.thumbColor?.resolve(inactivePressedStates) ?? + _widgetThumbColor.resolve(inactivePressedStates) ?? + switchTheme.thumbColor?.resolve(inactivePressedStates) ?? + defaults.thumbColor!.resolve(inactivePressedStates)!; + final Color effectiveInactivePressedOverlayColor = + widget.overlayColor?.resolve(inactivePressedStates) ?? + switchTheme.overlayColor?.resolve(inactivePressedStates) ?? + inactiveThumbColor?.withAlpha(kRadialReactionAlpha) ?? + defaults.overlayColor!.resolve(inactivePressedStates)!; + + final WidgetStateProperty<MouseCursor> effectiveMouseCursor = + WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) { + return WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? + switchTheme.mouseCursor?.resolve(states) ?? + defaults.mouseCursor!.resolve(states)!; + }); + + final double effectiveActiveThumbRadius = effectiveActiveIcon == null + ? switchConfig.activeThumbRadius + : switchConfig.thumbRadiusWithIcon; + final double effectiveInactiveThumbRadius = + effectiveInactiveIcon == null && widget.inactiveThumbImage == null + ? switchConfig.inactiveThumbRadius + : switchConfig.thumbRadiusWithIcon; + final double effectiveSplashRadius = + widget.splashRadius ?? switchTheme.splashRadius ?? defaults.splashRadius!; + + return Semantics( + toggled: widget.value, + child: GestureDetector( + excludeFromSemantics: true, + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + dragStartBehavior: widget.dragStartBehavior, + child: Opacity( + opacity: onChanged == null ? disabledOpacity : 1, + child: buildToggleable( + mouseCursor: effectiveMouseCursor, + focusNode: widget.focusNode, + onFocusChange: widget.onFocusChange, + autofocus: widget.autofocus, + size: widget.size, + painter: _painter + ..position = position + ..reaction = reaction + ..reactionFocusFade = reactionFocusFade + ..reactionHoverFade = reactionHoverFade + ..inactiveReactionColor = effectiveInactivePressedOverlayColor + ..reactionColor = effectiveActivePressedOverlayColor + ..hoverColor = effectiveHoverOverlayColor + ..focusColor = effectiveFocusOverlayColor + ..splashRadius = effectiveSplashRadius + ..downPosition = downPosition + ..isFocused = states.contains(WidgetState.focused) + ..isHovered = states.contains(WidgetState.hovered) + ..activeColor = effectiveActiveThumbColor + ..inactiveColor = effectiveInactiveThumbColor + ..activePressedColor = effectiveActivePressedThumbColor + ..inactivePressedColor = effectiveInactivePressedThumbColor + ..activeThumbImage = widget.activeThumbImage + ..onActiveThumbImageError = widget.onActiveThumbImageError + ..inactiveThumbImage = widget.inactiveThumbImage + ..onInactiveThumbImageError = widget.onInactiveThumbImageError + ..activeTrackColor = effectiveActiveTrackColor + ..activeTrackOutlineColor = effectiveActiveTrackOutlineColor + ..activeTrackOutlineWidth = effectiveActiveTrackOutlineWidth + ..inactiveTrackColor = effectiveInactiveTrackColor + ..inactiveTrackOutlineColor = effectiveInactiveTrackOutlineColor + ..inactiveTrackOutlineWidth = effectiveInactiveTrackOutlineWidth + ..configuration = createLocalImageConfiguration(context) + ..isInteractive = isInteractive + ..trackInnerLength = _trackInnerLength + ..textDirection = Directionality.of(context) + ..surfaceColor = theme.colorScheme.surface + ..inactiveThumbRadius = effectiveInactiveThumbRadius + ..activeThumbRadius = effectiveActiveThumbRadius + ..pressedThumbRadius = switchConfig.pressedThumbRadius + ..thumbOffset = switchConfig.thumbOffset + ..trackHeight = switchConfig.trackHeight + ..trackWidth = switchConfig.trackWidth + ..activeIconColor = effectiveActiveIconColor + ..inactiveIconColor = effectiveInactiveIconColor + ..activeIcon = effectiveActiveIcon + ..inactiveIcon = effectiveInactiveIcon + ..iconTheme = IconTheme.of(context) + ..thumbShadow = switchConfig.thumbShadow + ..transitionalThumbSize = switchConfig.transitionalThumbSize + ..positionController = positionController + ..isCupertino = isCupertino, + ), + ), + ), + ); + } +} + +class _SwitchPainter extends ToggleablePainter { + AnimationController get positionController => _positionController!; + AnimationController? _positionController; + set positionController(AnimationController value) { + if (value == _positionController) { + return; + } + _positionController = value; + _colorAnimation?.dispose(); + _colorAnimation = CurvedAnimation( + parent: positionController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ); + notifyListeners(); + } + + CurvedAnimation? _colorAnimation; + + Icon? get activeIcon => _activeIcon; + Icon? _activeIcon; + set activeIcon(Icon? value) { + if (value == _activeIcon) { + return; + } + _activeIcon = value; + notifyListeners(); + } + + Icon? get inactiveIcon => _inactiveIcon; + Icon? _inactiveIcon; + set inactiveIcon(Icon? value) { + if (value == _inactiveIcon) { + return; + } + _inactiveIcon = value; + notifyListeners(); + } + + IconThemeData? get iconTheme => _iconTheme; + IconThemeData? _iconTheme; + set iconTheme(IconThemeData? value) { + if (value == _iconTheme) { + return; + } + _iconTheme = value; + notifyListeners(); + } + + Color get activeIconColor => _activeIconColor!; + Color? _activeIconColor; + set activeIconColor(Color value) { + if (value == _activeIconColor) { + return; + } + _activeIconColor = value; + notifyListeners(); + } + + Color get inactiveIconColor => _inactiveIconColor!; + Color? _inactiveIconColor; + set inactiveIconColor(Color value) { + if (value == _inactiveIconColor) { + return; + } + _inactiveIconColor = value; + notifyListeners(); + } + + Color get activePressedColor => _activePressedColor!; + Color? _activePressedColor; + set activePressedColor(Color value) { + if (value == _activePressedColor) { + return; + } + _activePressedColor = value; + notifyListeners(); + } + + Color get inactivePressedColor => _inactivePressedColor!; + Color? _inactivePressedColor; + set inactivePressedColor(Color value) { + if (value == _inactivePressedColor) { + return; + } + _inactivePressedColor = value; + notifyListeners(); + } + + double get activeThumbRadius => _activeThumbRadius!; + double? _activeThumbRadius; + set activeThumbRadius(double value) { + if (value == _activeThumbRadius) { + return; + } + _activeThumbRadius = value; + notifyListeners(); + } + + double get inactiveThumbRadius => _inactiveThumbRadius!; + double? _inactiveThumbRadius; + set inactiveThumbRadius(double value) { + if (value == _inactiveThumbRadius) { + return; + } + _inactiveThumbRadius = value; + notifyListeners(); + } + + double get pressedThumbRadius => _pressedThumbRadius!; + double? _pressedThumbRadius; + set pressedThumbRadius(double value) { + if (value == _pressedThumbRadius) { + return; + } + _pressedThumbRadius = value; + notifyListeners(); + } + + double? get thumbOffset => _thumbOffset; + double? _thumbOffset; + set thumbOffset(double? value) { + if (value == _thumbOffset) { + return; + } + _thumbOffset = value; + notifyListeners(); + } + + Size get transitionalThumbSize => _transitionalThumbSize!; + Size? _transitionalThumbSize; + set transitionalThumbSize(Size value) { + if (value == _transitionalThumbSize) { + return; + } + _transitionalThumbSize = value; + notifyListeners(); + } + + double get trackHeight => _trackHeight!; + double? _trackHeight; + set trackHeight(double value) { + if (value == _trackHeight) { + return; + } + _trackHeight = value; + notifyListeners(); + } + + double get trackWidth => _trackWidth!; + double? _trackWidth; + set trackWidth(double value) { + if (value == _trackWidth) { + return; + } + _trackWidth = value; + notifyListeners(); + } + + ImageProvider? get activeThumbImage => _activeThumbImage; + ImageProvider? _activeThumbImage; + set activeThumbImage(ImageProvider? value) { + if (value == _activeThumbImage) { + return; + } + _activeThumbImage = value; + notifyListeners(); + } + + ImageErrorListener? get onActiveThumbImageError => _onActiveThumbImageError; + ImageErrorListener? _onActiveThumbImageError; + set onActiveThumbImageError(ImageErrorListener? value) { + if (value == _onActiveThumbImageError) { + return; + } + _onActiveThumbImageError = value; + notifyListeners(); + } + + ImageProvider? get inactiveThumbImage => _inactiveThumbImage; + ImageProvider? _inactiveThumbImage; + set inactiveThumbImage(ImageProvider? value) { + if (value == _inactiveThumbImage) { + return; + } + _inactiveThumbImage = value; + notifyListeners(); + } + + ImageErrorListener? get onInactiveThumbImageError => _onInactiveThumbImageError; + ImageErrorListener? _onInactiveThumbImageError; + set onInactiveThumbImageError(ImageErrorListener? value) { + if (value == _onInactiveThumbImageError) { + return; + } + _onInactiveThumbImageError = value; + notifyListeners(); + } + + Color get activeTrackColor => _activeTrackColor!; + Color? _activeTrackColor; + set activeTrackColor(Color value) { + if (value == _activeTrackColor) { + return; + } + _activeTrackColor = value; + notifyListeners(); + } + + Color? get activeTrackOutlineColor => _activeTrackOutlineColor; + Color? _activeTrackOutlineColor; + set activeTrackOutlineColor(Color? value) { + if (value == _activeTrackOutlineColor) { + return; + } + _activeTrackOutlineColor = value; + notifyListeners(); + } + + Color? get inactiveTrackOutlineColor => _inactiveTrackOutlineColor; + Color? _inactiveTrackOutlineColor; + set inactiveTrackOutlineColor(Color? value) { + if (value == _inactiveTrackOutlineColor) { + return; + } + _inactiveTrackOutlineColor = value; + notifyListeners(); + } + + double? get activeTrackOutlineWidth => _activeTrackOutlineWidth; + double? _activeTrackOutlineWidth; + set activeTrackOutlineWidth(double? value) { + if (value == _activeTrackOutlineWidth) { + return; + } + _activeTrackOutlineWidth = value; + notifyListeners(); + } + + double? get inactiveTrackOutlineWidth => _inactiveTrackOutlineWidth; + double? _inactiveTrackOutlineWidth; + set inactiveTrackOutlineWidth(double? value) { + if (value == _inactiveTrackOutlineWidth) { + return; + } + _inactiveTrackOutlineWidth = value; + notifyListeners(); + } + + Color get inactiveTrackColor => _inactiveTrackColor!; + Color? _inactiveTrackColor; + set inactiveTrackColor(Color value) { + if (value == _inactiveTrackColor) { + return; + } + _inactiveTrackColor = value; + notifyListeners(); + } + + ImageConfiguration get configuration => _configuration!; + ImageConfiguration? _configuration; + set configuration(ImageConfiguration value) { + if (value == _configuration) { + return; + } + _configuration = value; + notifyListeners(); + } + + TextDirection get textDirection => _textDirection!; + TextDirection? _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + notifyListeners(); + } + + Color get surfaceColor => _surfaceColor!; + Color? _surfaceColor; + set surfaceColor(Color value) { + if (value == _surfaceColor) { + return; + } + _surfaceColor = value; + notifyListeners(); + } + + bool get isInteractive => _isInteractive!; + bool? _isInteractive; + set isInteractive(bool value) { + if (value == _isInteractive) { + return; + } + _isInteractive = value; + notifyListeners(); + } + + double get trackInnerLength => _trackInnerLength!; + double? _trackInnerLength; + set trackInnerLength(double value) { + if (value == _trackInnerLength) { + return; + } + _trackInnerLength = value; + notifyListeners(); + } + + bool get isCupertino => _isCupertino!; + bool? _isCupertino; + set isCupertino(bool value) { + if (value == _isCupertino) { + return; + } + _isCupertino = value; + notifyListeners(); + } + + List<BoxShadow>? get thumbShadow => _thumbShadow; + List<BoxShadow>? _thumbShadow; + set thumbShadow(List<BoxShadow>? value) { + if (value == _thumbShadow) { + return; + } + _thumbShadow = value; + notifyListeners(); + } + + final TextPainter _textPainter = TextPainter(); + Color? _cachedThumbColor; + ImageProvider? _cachedThumbImage; + ImageErrorListener? _cachedThumbErrorListener; + BoxPainter? _cachedThumbPainter; + + ShapeDecoration _createDefaultThumbDecoration( + Color color, + ImageProvider? image, + ImageErrorListener? errorListener, + ) { + return ShapeDecoration( + color: color, + image: image == null ? null : DecorationImage(image: image, onError: errorListener), + shape: const StadiumBorder(), + shadows: isCupertino ? null : thumbShadow, + ); + } + + bool _isPainting = false; + + void _handleDecorationChanged() { + // If the image decoration is available synchronously, we'll get called here + // during paint. There's no reason to mark ourselves as needing paint if we + // are already in the middle of painting. (In fact, doing so would trigger + // an assert). + if (!_isPainting) { + notifyListeners(); + } + } + + bool _stopPressAnimation = false; + double? _pressedInactiveThumbRadius; + double? _pressedActiveThumbRadius; + late double? _pressedThumbExtension; + + @override + void paint(Canvas canvas, Size size) { + final double currentValue = position.value; + + final double visualPosition = switch (textDirection) { + TextDirection.rtl => 1.0 - currentValue, + TextDirection.ltr => currentValue, + }; + if (reaction.status == AnimationStatus.reverse && !_stopPressAnimation) { + _stopPressAnimation = true; + } else { + _stopPressAnimation = false; + } + + // To get the thumb radius when the press ends, the value can be any number + // between activeThumbRadius/inactiveThumbRadius and pressedThumbRadius. + if (!_stopPressAnimation) { + _pressedThumbExtension = isCupertino ? reaction.value * 7 : 0; + if (reaction.isCompleted) { + // This happens when the thumb is dragged instead of being tapped. + _pressedInactiveThumbRadius = lerpDouble( + inactiveThumbRadius, + pressedThumbRadius, + reaction.value, + ); + _pressedActiveThumbRadius = lerpDouble( + activeThumbRadius, + pressedThumbRadius, + reaction.value, + ); + } + if (currentValue == 0) { + _pressedInactiveThumbRadius = lerpDouble( + inactiveThumbRadius, + pressedThumbRadius, + reaction.value, + ); + _pressedActiveThumbRadius = activeThumbRadius; + } + if (currentValue == 1) { + _pressedActiveThumbRadius = lerpDouble( + activeThumbRadius, + pressedThumbRadius, + reaction.value, + ); + _pressedInactiveThumbRadius = inactiveThumbRadius; + } + } + final inactiveThumbSize = isCupertino + ? Size( + _pressedInactiveThumbRadius! * 2 + _pressedThumbExtension!, + _pressedInactiveThumbRadius! * 2, + ) + : Size.fromRadius(_pressedInactiveThumbRadius ?? inactiveThumbRadius); + final activeThumbSize = isCupertino + ? Size( + _pressedActiveThumbRadius! * 2 + _pressedThumbExtension!, + _pressedActiveThumbRadius! * 2, + ) + : Size.fromRadius(_pressedActiveThumbRadius ?? activeThumbRadius); + Animation<Size> thumbSizeAnimation(bool isForward) { + List<TweenSequenceItem<Size>> thumbSizeSequence; + if (isForward) { + thumbSizeSequence = <TweenSequenceItem<Size>>[ + TweenSequenceItem<Size>( + tween: Tween<Size>( + begin: inactiveThumbSize, + end: transitionalThumbSize, + ).chain(CurveTween(curve: const Cubic(0.31, 0.00, 0.56, 1.00))), + weight: 11, + ), + TweenSequenceItem<Size>( + tween: Tween<Size>( + begin: transitionalThumbSize, + end: activeThumbSize, + ).chain(CurveTween(curve: const Cubic(0.20, 0.00, 0.00, 1.00))), + weight: 72, + ), + TweenSequenceItem<Size>(tween: ConstantTween<Size>(activeThumbSize), weight: 17), + ]; + } else { + thumbSizeSequence = <TweenSequenceItem<Size>>[ + TweenSequenceItem<Size>(tween: ConstantTween<Size>(inactiveThumbSize), weight: 17), + TweenSequenceItem<Size>( + tween: Tween<Size>( + begin: inactiveThumbSize, + end: transitionalThumbSize, + ).chain(CurveTween(curve: const Cubic(0.20, 0.00, 0.00, 1.00).flipped)), + weight: 72, + ), + TweenSequenceItem<Size>( + tween: Tween<Size>( + begin: transitionalThumbSize, + end: activeThumbSize, + ).chain(CurveTween(curve: const Cubic(0.31, 0.00, 0.56, 1.00).flipped)), + weight: 11, + ), + ]; + } + + return TweenSequence<Size>(thumbSizeSequence).animate(positionController); + } + + Size? thumbSize; + if (isCupertino) { + if (reaction.isCompleted) { + thumbSize = Size( + _pressedInactiveThumbRadius! * 2 + _pressedThumbExtension!, + _pressedInactiveThumbRadius! * 2, + ); + } else { + if (position.isDismissed || position.status == AnimationStatus.forward) { + thumbSize = Size.lerp(inactiveThumbSize, activeThumbSize, position.value); + } else { + thumbSize = Size.lerp(inactiveThumbSize, activeThumbSize, position.value); + } + } + } else { + if (reaction.isCompleted) { + thumbSize = Size.fromRadius(pressedThumbRadius); + } else { + if (position.isDismissed || position.status == AnimationStatus.forward) { + thumbSize = thumbSizeAnimation(true).value; + } else { + thumbSize = thumbSizeAnimation(false).value; + } + } + } + + // The thumb contracts slightly during the animation in Material 2. + final double inset = thumbOffset == null ? 0 : 1.0 - (currentValue - thumbOffset!).abs() * 2.0; + thumbSize = Size(thumbSize!.width - inset, thumbSize.height - inset); + + final double colorValue = _colorAnimation!.value; + final Color trackColor = Color.lerp(inactiveTrackColor, activeTrackColor, colorValue)!; + final Color? trackOutlineColor = + inactiveTrackOutlineColor == null || activeTrackOutlineColor == null + ? null + : Color.lerp(inactiveTrackOutlineColor, activeTrackOutlineColor, colorValue); + final double? trackOutlineWidth = lerpDouble( + inactiveTrackOutlineWidth, + activeTrackOutlineWidth, + colorValue, + ); + Color lerpedThumbColor; + if (!reaction.isDismissed) { + lerpedThumbColor = Color.lerp(inactivePressedColor, activePressedColor, colorValue)!; + } else if (positionController.status == AnimationStatus.forward) { + lerpedThumbColor = Color.lerp(inactivePressedColor, activeColor, colorValue)!; + } else if (positionController.status == AnimationStatus.reverse) { + lerpedThumbColor = Color.lerp(inactiveColor, activePressedColor, colorValue)!; + } else { + lerpedThumbColor = Color.lerp(inactiveColor, activeColor, colorValue)!; + } + + // Blend the thumb color against a `surfaceColor` background in case the + // thumbColor is not opaque. This way we do not see through the thumb to the + // track underneath. + final Color thumbColor = Color.alphaBlend(lerpedThumbColor, surfaceColor); + + final Icon? thumbIcon = currentValue < 0.5 ? inactiveIcon : activeIcon; + + final ImageProvider? thumbImage = currentValue < 0.5 ? inactiveThumbImage : activeThumbImage; + + final ImageErrorListener? thumbErrorListener = currentValue < 0.5 + ? onInactiveThumbImageError + : onActiveThumbImageError; + + final paint = Paint()..color = trackColor; + + final Offset trackPaintOffset = _computeTrackPaintOffset(size, trackWidth, trackHeight); + final Offset thumbPaintOffset = _computeThumbPaintOffset( + trackPaintOffset, + thumbSize, + visualPosition, + ); + final radialReactionOrigin = Offset( + thumbPaintOffset.dx + thumbSize.height / 2, + size.height / 2, + ); + + _paintTrackWith(canvas, paint, trackPaintOffset, trackOutlineColor, trackOutlineWidth); + paintRadialReaction(canvas: canvas, origin: radialReactionOrigin); + _paintThumbWith( + thumbPaintOffset, + canvas, + colorValue, + thumbColor, + thumbImage, + thumbErrorListener, + thumbIcon, + thumbSize, + inset, + ); + } + + /// Computes canvas offset for track's upper left corner + Offset _computeTrackPaintOffset(Size canvasSize, double trackWidth, double trackHeight) { + final double horizontalOffset = (canvasSize.width - trackWidth) / 2.0; + final double verticalOffset = (canvasSize.height - trackHeight) / 2.0; + + return Offset(horizontalOffset, verticalOffset); + } + + /// Computes canvas offset for thumb's upper left corner as if it were a + /// square + Offset _computeThumbPaintOffset(Offset trackPaintOffset, Size thumbSize, double visualPosition) { + // How much thumb radius extends beyond the track + final double trackRadius = trackHeight / 2; + final double additionalThumbRadius = thumbSize.height / 2 - trackRadius; + + final double horizontalProgress = visualPosition * (trackInnerLength - _pressedThumbExtension!); + final double thumbHorizontalOffset = + trackPaintOffset.dx + + trackRadius + + (_pressedThumbExtension! / 2) - + thumbSize.width / 2 + + horizontalProgress; + final double thumbVerticalOffset = trackPaintOffset.dy - additionalThumbRadius; + return Offset(thumbHorizontalOffset, thumbVerticalOffset); + } + + void _paintTrackWith( + Canvas canvas, + Paint paint, + Offset trackPaintOffset, + Color? trackOutlineColor, + double? trackOutlineWidth, + ) { + final trackRect = Rect.fromLTWH( + trackPaintOffset.dx, + trackPaintOffset.dy, + trackWidth, + trackHeight, + ); + final double trackRadius = trackHeight / 2; + final trackRRect = RRect.fromRectAndRadius(trackRect, Radius.circular(trackRadius)); + + canvas.drawRRect(trackRRect, paint); + + // paint track outline + if (trackOutlineColor != null) { + final outlineTrackRect = Rect.fromLTWH( + trackPaintOffset.dx + 1, + trackPaintOffset.dy + 1, + trackWidth - 2, + trackHeight - 2, + ); + final outlineTrackRRect = RRect.fromRectAndRadius( + outlineTrackRect, + Radius.circular(trackRadius), + ); + + final outlinePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = trackOutlineWidth ?? 2.0 + ..color = trackOutlineColor; + + canvas.drawRRect(outlineTrackRRect, outlinePaint); + } + + if (isCupertino) { + if (isFocused) { + final RRect focusedOutline = trackRRect.inflate(1.75); + final focusedPaint = Paint() + ..style = PaintingStyle.stroke + ..color = focusColor + ..strokeWidth = _kCupertinoFocusTrackOutline; + canvas.drawRRect(focusedOutline, focusedPaint); + } + canvas.clipRRect(trackRRect); + } + } + + void _paintThumbWith( + Offset thumbPaintOffset, + Canvas canvas, + double currentValue, + Color thumbColor, + ImageProvider? thumbImage, + ImageErrorListener? thumbErrorListener, + Icon? thumbIcon, + Size thumbSize, + double inset, + ) { + try { + _isPainting = true; + if (_cachedThumbPainter == null || + thumbColor != _cachedThumbColor || + thumbImage != _cachedThumbImage || + thumbErrorListener != _cachedThumbErrorListener) { + _cachedThumbColor = thumbColor; + _cachedThumbImage = thumbImage; + _cachedThumbErrorListener = thumbErrorListener; + _cachedThumbPainter?.dispose(); + _cachedThumbPainter = _createDefaultThumbDecoration( + thumbColor, + thumbImage, + thumbErrorListener, + ).createBoxPainter(_handleDecorationChanged); + } + final BoxPainter thumbPainter = _cachedThumbPainter!; + + if (isCupertino) { + _paintCupertinoThumbShadowAndBorder(canvas, thumbPaintOffset, thumbSize); + } + + thumbPainter.paint(canvas, thumbPaintOffset, configuration.copyWith(size: thumbSize)); + + if (thumbIcon != null && thumbIcon.icon != null) { + final Color iconColor = Color.lerp(inactiveIconColor, activeIconColor, currentValue)!; + final double iconSize = thumbIcon.size ?? _SwitchConfigM3.iconSize; + final IconData iconData = thumbIcon.icon!; + final double? iconWeight = thumbIcon.weight ?? iconTheme?.weight; + final double? iconFill = thumbIcon.fill ?? iconTheme?.fill; + final double? iconGrade = thumbIcon.grade ?? iconTheme?.grade; + final double? iconOpticalSize = thumbIcon.opticalSize ?? iconTheme?.opticalSize; + final List<Shadow>? iconShadows = thumbIcon.shadows ?? iconTheme?.shadows; + + final textSpan = TextSpan( + text: String.fromCharCode(iconData.codePoint), + style: TextStyle( + fontVariations: <FontVariation>[ + if (iconFill != null) FontVariation('FILL', iconFill), + if (iconWeight != null) FontVariation('wght', iconWeight), + if (iconGrade != null) FontVariation('GRAD', iconGrade), + if (iconOpticalSize != null) FontVariation('opsz', iconOpticalSize), + ], + color: iconColor, + fontSize: iconSize, + inherit: false, + fontFamily: iconData.fontFamily, + package: iconData.fontPackage, + shadows: iconShadows, + ), + ); + _textPainter + ..textDirection = textDirection + ..text = textSpan; + _textPainter.layout(); + final double additionalHorizontalOffset = (thumbSize.width - iconSize) / 2; + final double additionalVerticalOffset = (thumbSize.height - iconSize) / 2; + final Offset offset = + thumbPaintOffset + Offset(additionalHorizontalOffset, additionalVerticalOffset); + + _textPainter.paint(canvas, offset); + } + } finally { + _isPainting = false; + } + } + + void _paintCupertinoThumbShadowAndBorder(Canvas canvas, Offset thumbPaintOffset, Size thumbSize) { + final thumbBounds = RRect.fromLTRBR( + thumbPaintOffset.dx, + thumbPaintOffset.dy, + thumbPaintOffset.dx + thumbSize.width, + thumbPaintOffset.dy + thumbSize.height, + Radius.circular(thumbSize.height / 2.0), + ); + if (thumbShadow != null) { + for (final BoxShadow shadow in thumbShadow!) { + canvas.drawRRect(thumbBounds.shift(shadow.offset), shadow.toPaint()); + } + } + + canvas.drawRRect(thumbBounds.inflate(0.5), Paint()..color = const Color(0x0A000000)); + } + + @override + void dispose() { + _textPainter.dispose(); + _cachedThumbPainter?.dispose(); + _cachedThumbPainter = null; + _cachedThumbColor = null; + _cachedThumbImage = null; + _cachedThumbErrorListener = null; + _colorAnimation?.dispose(); + super.dispose(); + } +} + +class _SwitchThemeAdaptation extends Adaptation<SwitchThemeData> { + const _SwitchThemeAdaptation(); + + @override + SwitchThemeData adapt(ThemeData theme, SwitchThemeData defaultValue) { + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return defaultValue; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return const SwitchThemeData(); + } + } +} + +mixin _SwitchConfig { + double get trackHeight; + double get trackWidth; + double get switchWidth; + double get switchHeight; + double get switchHeightCollapsed; + double get activeThumbRadius; + double get inactiveThumbRadius; + double get pressedThumbRadius; + double get thumbRadiusWithIcon; + List<BoxShadow>? get thumbShadow; + WidgetStateProperty<Color> get iconColor; + double? get thumbOffset; + Size get transitionalThumbSize; + int get toggleDuration; + Size get switchMinSize; +} + +// Hand coded defaults for iOS/macOS Switch +class _SwitchDefaultsCupertino extends SwitchThemeData { + const _SwitchDefaultsCupertino(this.context); + + final BuildContext context; + + @override + WidgetStateProperty<MouseCursor?> get mouseCursor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.basic; + } + return kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic; + }); + } + + @override + WidgetStateProperty<Color> get thumbColor => const MaterialStatePropertyAll<Color>(Colors.white); + + @override + WidgetStateProperty<Color> get trackColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return CupertinoDynamicColor.resolve(CupertinoColors.systemGreen, context); + } + return CupertinoDynamicColor.resolve(CupertinoColors.secondarySystemFill, context); + }); + } + + @override + WidgetStateProperty<Color?> get trackOutlineColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color?> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return HSLColor.fromColor( + CupertinoDynamicColor.resolve(CupertinoColors.systemGreen, context).withOpacity(0.80), + ).withLightness(0.69).withSaturation(0.835).toColor(); + } + return Colors.transparent; + }); + } + + @override + double get splashRadius => 0.0; +} + +const double _kCupertinoFocusTrackOutline = 3.5; + +class _SwitchConfigCupertino with _SwitchConfig { + _SwitchConfigCupertino(this.context) : _colors = Theme.of(context).colorScheme; + + BuildContext context; + final ColorScheme _colors; + + @override + WidgetStateProperty<Color> get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onPrimaryContainer; + }); + } + + @override + double get activeThumbRadius => 14.0; + + @override + double get inactiveThumbRadius => 14.0; + + @override + double get pressedThumbRadius => 14.0; + + @override + double get switchHeight => switchMinSize.height + 8.0; + + @override + double get switchHeightCollapsed => switchMinSize.height; + + @override + double get switchWidth => 60.0; + + @override + double get thumbRadiusWithIcon => 14.0; + + @override + List<BoxShadow>? get thumbShadow => const <BoxShadow>[ + BoxShadow(color: Color(0x26000000), offset: Offset(0, 3), blurRadius: 8.0), + BoxShadow(color: Color(0x0F000000), offset: Offset(0, 3), blurRadius: 1.0), + ]; + + @override + double get trackHeight => 31.0; + + @override + double get trackWidth => 51.0; + + // The thumb size at the middle of the track. Hand coded default based on the animation specs. + @override + Size get transitionalThumbSize => const Size(28.0, 28.0); + + // Hand coded default by comparing with [CupertinoSwitch]. + @override + int get toggleDuration => 140; + + // Hand coded default based on the animation specs. + @override + double? get thumbOffset => null; + + @override + Size get switchMinSize => const Size.square(kMinInteractiveDimension - 8.0); +} + +// Hand coded defaults based on Material Design 2. +class _SwitchConfigM2 with _SwitchConfig { + _SwitchConfigM2(); + + @override + double get activeThumbRadius => 10.0; + + @override + WidgetStateProperty<Color> get iconColor => WidgetStateProperty.all<Color>(Colors.transparent); + + @override + double get inactiveThumbRadius => 10.0; + + @override + double get pressedThumbRadius => 10.0; + + @override + double get switchHeight => switchMinSize.height + 8.0; + + @override + double get switchHeightCollapsed => switchMinSize.height; + + @override + double get switchWidth => trackWidth - 2 * (trackHeight / 2.0) + switchMinSize.width; + + @override + double get thumbRadiusWithIcon => 10.0; + + @override + List<BoxShadow>? get thumbShadow => kElevationToShadow[1]; + + @override + double get trackHeight => 14.0; + + @override + double get trackWidth => 33.0; + + @override + double get thumbOffset => 0.5; + + @override + Size get transitionalThumbSize => const Size(20, 20); + + @override + int get toggleDuration => 200; + + @override + Size get switchMinSize => const Size.square(kMinInteractiveDimension - 8.0); +} + +class _SwitchDefaultsM2 extends SwitchThemeData { + _SwitchDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme; + + final ThemeData _theme; + final ColorScheme _colors; + + @override + WidgetStateProperty<Color> get thumbColor { + final isDark = _theme.brightness == Brightness.dark; + + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return isDark ? Colors.grey.shade800 : Colors.grey.shade400; + } + if (states.contains(WidgetState.selected)) { + return _colors.secondary; + } + return isDark ? Colors.grey.shade400 : Colors.grey.shade50; + }); + } + + @override + WidgetStateProperty<Color> get trackColor { + final isDark = _theme.brightness == Brightness.dark; + const black32 = Color(0x52000000); // Black with 32% opacity + + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return isDark ? Colors.white10 : Colors.black12; + } + if (states.contains(WidgetState.selected)) { + final Color activeColor = _colors.secondary; + return activeColor.withAlpha(0x80); + } + return isDark ? Colors.white30 : black32; + }); + } + + @override + WidgetStateProperty<Color?>? get trackOutlineColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; + + @override + WidgetStateProperty<MouseCursor> get mouseCursor => WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => WidgetStateMouseCursor.clickable.resolve(states), + ); + + @override + WidgetStateProperty<Color?> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return thumbColor.resolve(states).withAlpha(kRadialReactionAlpha); + } + if (states.contains(WidgetState.hovered)) { + return _theme.hoverColor; + } + if (states.contains(WidgetState.focused)) { + return _theme.focusColor; + } + return null; + }); + } + + @override + double get splashRadius => kRadialReactionRadius; + + @override + EdgeInsetsGeometry? get padding => EdgeInsets.zero; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Switch + +// 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. + +// dart format off +class _SwitchDefaultsM3 extends SwitchThemeData { + _SwitchDefaultsM3(this.context); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty<Color> get thumbColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _colors.surface.withOpacity(1.0); + } + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.primaryContainer; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primaryContainer; + } + if (states.contains(WidgetState.focused)) { + return _colors.primaryContainer; + } + return _colors.onPrimary; + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant; + } + return _colors.outline; + }); + } + + @override + WidgetStateProperty<Color> get trackColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.surfaceContainerHighest.withOpacity(0.12); + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary; + } + if (states.contains(WidgetState.focused)) { + return _colors.primary; + } + return _colors.primary; + } + if (states.contains(WidgetState.pressed)) { + return _colors.surfaceContainerHighest; + } + if (states.contains(WidgetState.hovered)) { + return _colors.surfaceContainerHighest; + } + if (states.contains(WidgetState.focused)) { + return _colors.surfaceContainerHighest; + } + return _colors.surfaceContainerHighest; + }); + } + + @override + WidgetStateProperty<Color?> get trackOutlineColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return Colors.transparent; + } + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.outline; + }); + } + + @override + WidgetStateProperty<Color?> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return null; + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return null; + }); + } + + @override + WidgetStateProperty<MouseCursor> get mouseCursor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) + => WidgetStateMouseCursor.clickable.resolve(states)); + } + + @override + MaterialStatePropertyAll<double> get trackOutlineWidth => const MaterialStatePropertyAll<double>(2.0); + + @override + double get splashRadius => 40.0 / 2; + + @override + EdgeInsetsGeometry? get padding => const EdgeInsets.symmetric(horizontal: 4); +} + +class _SwitchConfigM3 with _SwitchConfig { + _SwitchConfigM3(this.context) + : _colors = Theme.of(context).colorScheme; + + BuildContext context; + final ColorScheme _colors; + + static const double iconSize = 16.0; + + @override + double get activeThumbRadius => 24.0 / 2; + + @override + WidgetStateProperty<Color> get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.surfaceContainerHighest.withOpacity(0.38); + } + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimaryContainer; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimaryContainer; + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimaryContainer; + } + return _colors.onPrimaryContainer; + } + if (states.contains(WidgetState.pressed)) { + return _colors.surfaceContainerHighest; + } + if (states.contains(WidgetState.hovered)) { + return _colors.surfaceContainerHighest; + } + if (states.contains(WidgetState.focused)) { + return _colors.surfaceContainerHighest; + } + return _colors.surfaceContainerHighest; + }); + } + + @override + double get inactiveThumbRadius => 16.0 / 2; + + @override + double get pressedThumbRadius => 28.0 / 2; + + @override + double get switchHeight => switchMinSize.height + 8.0; + + @override + double get switchHeightCollapsed => switchMinSize.height; + + @override + double get switchWidth => 52.0; + + @override + double get thumbRadiusWithIcon => 24.0 / 2; + + @override + List<BoxShadow>? get thumbShadow => kElevationToShadow[0]; + + @override + double get trackHeight => 32.0; + + @override + double get trackWidth => 52.0; + + // The thumb size at the middle of the track. Hand coded default based on the animation specs. + @override + Size get transitionalThumbSize => const Size(34, 22); + + // Hand coded default based on the animation specs. + @override + int get toggleDuration => 300; + + // Hand coded default based on the animation specs. + @override + double? get thumbOffset => null; + + @override + Size get switchMinSize => const Size(kMinInteractiveDimension, kMinInteractiveDimension - 8.0); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Switch diff --git a/packages/material_ui/lib/src/switch_list_tile.dart b/packages/material_ui/lib/src/switch_list_tile.dart new file mode 100644 index 000000000000..501c38ac930e --- /dev/null +++ b/packages/material_ui/lib/src/switch_list_tile.dart @@ -0,0 +1,687 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'checkbox_list_tile.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'constants.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'material.dart'; +/// @docImport 'radio_list_tile.dart'; +/// @docImport 'scaffold.dart'; +library; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'switch.dart'; +import 'switch_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +// Examples can assume: +// void setState(VoidCallback fn) { } +// bool _isSelected = true; + +enum _SwitchListTileType { material, adaptive } + +/// A [ListTile] with a [Switch]. In other words, a switch with a label. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=0igIjvtEWNU} +/// +/// The entire list tile is interactive: tapping anywhere in the tile toggles +/// the switch. Tapping and dragging the [Switch] also triggers the [onChanged] +/// callback. +/// +/// To ensure that [onChanged] correctly triggers, the state passed +/// into [value] must be properly managed. This is typically done by invoking +/// [State.setState] in [onChanged] to toggle the state value. +/// +/// The [value], [onChanged], [activeThumbColor], [activeThumbImage], and +/// [inactiveThumbImage] properties of this widget are identical to the +/// similarly-named properties on the [Switch] widget. +/// +/// The [title], [subtitle], [isThreeLine], and [dense] properties are like +/// those of the same name on [ListTile]. +/// +/// The [selected] property on this widget is similar to the [ListTile.selected] +/// property. This tile's [activeThumbColor] is used for the selected item's text color, or +/// the theme's [SwitchThemeData.overlayColor] if [activeThumbColor] is null. +/// +/// This widget does not coordinate the [selected] state and the +/// [value]; to have the list tile appear selected when the +/// switch button is on, use the same value for both. +/// +/// The switch is shown on the right by default in left-to-right languages (i.e. +/// in the [ListTile.trailing] slot) which can be changed using [controlAffinity]. +/// The [secondary] widget is placed in the [ListTile.leading] slot. +/// +/// This widget requires a [Material] widget ancestor in the tree to paint +/// itself on, which is typically provided by the app's [Scaffold]. +/// The [tileColor], and [selectedTileColor] are not painted by the +/// [SwitchListTile] itself but by the [Material] widget ancestor. In this +/// case, one can wrap a [Material] widget around the [SwitchListTile], e.g.: +/// +/// {@tool snippet} +/// ```dart +/// ColoredBox( +/// color: Colors.green, +/// child: Material( +/// child: SwitchListTile( +/// tileColor: Colors.red, +/// title: const Text('SwitchListTile with red background'), +/// value: true, +/// onChanged:(bool? value) { }, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Performance considerations when wrapping [SwitchListTile] with [Material] +/// +/// Wrapping a large number of [SwitchListTile]s individually with [Material]s +/// is expensive. Consider only wrapping the [SwitchListTile]s that require it +/// or include a common [Material] ancestor where possible. +/// +/// To show the [SwitchListTile] as disabled, pass null as the [onChanged] +/// callback. +/// +/// {@tool dartpad} +/// ![SwitchListTile sample](https://flutter.github.io/assets-for-api-docs/assets/material/switch_list_tile.png) +/// +/// This widget shows a switch that, when toggled, changes the state of a [bool] +/// member field called `_lights`. +/// +/// ** See code in examples/api/lib/material/switch_list_tile/switch_list_tile.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample demonstrates how [SwitchListTile] positions the switch widget +/// relative to the text in different configurations. +/// +/// ** See code in examples/api/lib/material/switch_list_tile/switch_list_tile.1.dart ** +/// {@end-tool} +/// +/// ## Semantics in SwitchListTile +/// +/// Since the entirety of the SwitchListTile is interactive, it should represent +/// itself as a single interactive entity. +/// +/// To do so, a SwitchListTile widget wraps its children with a [MergeSemantics] +/// widget. [MergeSemantics] will attempt to merge its descendant [Semantics] +/// nodes into one node in the semantics tree. Therefore, SwitchListTile will +/// throw an error if any of its children requires its own [Semantics] node. +/// +/// For example, you cannot nest a [RichText] widget as a descendant of +/// SwitchListTile. [RichText] has an embedded gesture recognizer that +/// requires its own [Semantics] node, which directly conflicts with +/// SwitchListTile's desire to merge all its descendants' semantic nodes +/// into one. Therefore, it may be necessary to create a custom radio tile +/// widget to accommodate similar use cases. +/// +/// {@tool dartpad} +/// ![Switch list tile semantics sample](https://flutter.github.io/assets-for-api-docs/assets/material/switch_list_tile_semantics.png) +/// +/// Here is an example of a custom labeled radio widget, called +/// LinkedLabelRadio, that includes an interactive [RichText] widget that +/// handles tap gestures. +/// +/// ** See code in examples/api/lib/material/switch_list_tile/custom_labeled_switch.0.dart ** +/// {@end-tool} +/// +/// ## SwitchListTile isn't exactly what I want +/// +/// If the way SwitchListTile pads and positions its elements isn't quite what +/// you're looking for, you can create custom labeled switch widgets by +/// combining [Switch] with other widgets, such as [Text], [Padding] and +/// [InkWell]. +/// +/// {@tool dartpad} +/// ![Custom switch list tile sample](https://flutter.github.io/assets-for-api-docs/assets/material/switch_list_tile_custom.png) +/// +/// Here is an example of a custom LabeledSwitch widget, but you can easily +/// make your own configurable widget. +/// +/// ** See code in examples/api/lib/material/switch_list_tile/custom_labeled_switch.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListTileTheme], which can be used to affect the style of list tiles, +/// including switch list tiles. +/// * [CheckboxListTile], a similar widget for checkboxes. +/// * [RadioListTile], a similar widget for radio buttons. +/// * [ListTile] and [Switch], the widgets from which this widget is made. +class SwitchListTile extends StatelessWidget { + /// Creates a combination of a list tile and a switch. + /// + /// The switch tile itself does not maintain any state. Instead, when the + /// state of the switch changes, the widget calls the [onChanged] callback. + /// Most widgets that use a switch will listen for the [onChanged] callback + /// and rebuild the switch tile with a new [value] to update the visual + /// appearance of the switch. + /// + /// The following arguments are required: + /// + /// * [value] determines whether this switch is on or off. + /// * [onChanged] is called when the user toggles the switch on or off. + const SwitchListTile({ + super.key, + required this.value, + required this.onChanged, + @Deprecated( + 'Use activeThumbColor instead. ' + 'This feature was deprecated after v3.31.0-2.0.pre.', + ) + this.activeColor, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.thumbIcon, + this.materialTapTargetSize, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.statesController, + this.onFocusChange, + this.autofocus = false, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.contentPadding, + this.secondary, + this.selected = false, + this.controlAffinity, + this.shape, + this.selectedTileColor, + this.visualDensity, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.hoverColor, + this.internalAddSemanticForOnTap = false, + }) : _switchListTileType = _SwitchListTileType.material, + applyCupertinoTheme = false, + assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null), + assert(isThreeLine != true || subtitle != null); + + /// Creates a Material [ListTile] with an adaptive [Switch], following + /// Material design's + /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). + /// + /// This widget uses [Switch.adaptive] to change the graphics of the switch + /// component based on the ambient [ThemeData.platform]. On iOS and macOS, a + /// [CupertinoSwitch] will be used. On other platforms a Material design + /// [Switch] will be used. + /// + /// If a [CupertinoSwitch] is created, the following parameters are + /// ignored: [activeTrackColor], [inactiveThumbColor], [inactiveTrackColor], + /// [activeThumbImage], [inactiveThumbImage]. + const SwitchListTile.adaptive({ + super.key, + required this.value, + required this.onChanged, + @Deprecated( + 'Use activeThumbColor or activeTrackColor instead. ' + 'This feature was deprecated after v3.31.0-2.0.pre.', + ) + this.activeColor, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.thumbIcon, + this.materialTapTargetSize, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.statesController, + this.onFocusChange, + this.autofocus = false, + this.applyCupertinoTheme, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.contentPadding, + this.secondary, + this.selected = false, + this.controlAffinity, + this.shape, + this.selectedTileColor, + this.visualDensity, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.hoverColor, + this.internalAddSemanticForOnTap = false, + }) : _switchListTileType = _SwitchListTileType.adaptive, + assert(isThreeLine != true || subtitle != null), + assert(activeThumbImage != null || onActiveThumbImageError == null), + assert(inactiveThumbImage != null || onInactiveThumbImageError == null); + + /// Whether this switch is checked. + final bool value; + + /// Called when the user toggles the switch on or off. + /// + /// The switch passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the switch tile with the + /// new value. + /// + /// If null, the switch will be displayed as disabled. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// {@tool snippet} + /// ```dart + /// SwitchListTile( + /// value: _isSelected, + /// onChanged: (bool newValue) { + /// setState(() { + /// _isSelected = newValue; + /// }); + /// }, + /// title: const Text('Selection'), + /// ) + /// ``` + /// {@end-tool} + final ValueChanged<bool>? onChanged; + + /// {@macro flutter.material.switch.activeColor} + /// + /// Defaults to [ColorScheme.secondary] of the current [Theme]. + @Deprecated( + 'Use activeThumbColor or activeTrackColor instead. ' + 'This feature was deprecated after v3.31.0-2.0.pre.', + ) + final Color? activeColor; + + /// {@macro flutter.material.switch.activeThumbColor} + /// + /// Defaults to [ColorScheme.secondary] of the current [Theme]. + final Color? activeThumbColor; + + /// {@macro flutter.material.switch.activeTrackColor} + /// + /// Defaults to [ColorScheme.secondary] with the opacity set at 50%. + /// + /// Ignored if created with [SwitchListTile.adaptive]. + final Color? activeTrackColor; + + /// {@macro flutter.material.switch.inactiveThumbColor} + /// + /// Defaults to the colors described in the Material design specification. + /// + /// Ignored if created with [SwitchListTile.adaptive]. + final Color? inactiveThumbColor; + + /// {@macro flutter.material.switch.inactiveTrackColor} + /// + /// Defaults to the colors described in the Material design specification. + /// + /// Ignored if created with [SwitchListTile.adaptive]. + final Color? inactiveTrackColor; + + /// {@macro flutter.material.switch.activeThumbImage} + final ImageProvider? activeThumbImage; + + /// {@macro flutter.material.switch.onActiveThumbImageError} + final ImageErrorListener? onActiveThumbImageError; + + /// {@macro flutter.material.switch.inactiveThumbImage} + /// + /// Ignored if created with [SwitchListTile.adaptive]. + final ImageProvider? inactiveThumbImage; + + /// {@macro flutter.material.switch.onInactiveThumbImageError} + final ImageErrorListener? onInactiveThumbImageError; + + /// The color of this switch's thumb. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [activeThumbColor] is used in the selected state + /// and [inactiveThumbColor] in the default state. If that is also null, then + /// the value of [SwitchThemeData.thumbColor] is used. If that is also null, + /// The default value is used. + final WidgetStateProperty<Color?>? thumbColor; + + /// The color of this switch's track. + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [activeTrackColor] is used in the selected + /// state and [inactiveTrackColor] in the default state. If that is also null, + /// then the value of [SwitchThemeData.trackColor] is used. If that is also + /// null, then the default value is used. + final WidgetStateProperty<Color?>? trackColor; + + /// {@macro flutter.material.switch.trackOutlineColor} + /// + /// The [ListTile] will be focused when this [SwitchListTile] requests focus, + /// so the focused outline color of the switch will be ignored. + /// + /// In Material 3, the outline color defaults to transparent in the selected + /// state and [ColorScheme.outline] in the unselected state. In Material 2, + /// the [Switch] track has no outline. + final WidgetStateProperty<Color?>? trackOutlineColor; + + /// The icon to use on the thumb of this switch + /// + /// Resolved in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [SwitchThemeData.thumbIcon] is used. If this is + /// also null, then the [Switch] does not have any icons on the thumb. + final WidgetStateProperty<Icon?>? thumbIcon; + + /// {@macro flutter.material.switch.materialTapTargetSize} + /// + /// defaults to [MaterialTapTargetSize.shrinkWrap]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@macro flutter.cupertino.CupertinoSwitch.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.disabled]. + /// + /// If null, then the value of [SwitchThemeData.mouseCursor] is used. If that + /// is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The color for the switch's [Material]. + /// + /// Resolves in the following states: + /// * [WidgetState.pressed]. + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// + /// If null, then the value of [activeThumbColor] with alpha [kRadialReactionAlpha] + /// and [hoverColor] is used in the pressed and hovered state. If that is also + /// null, the value of [SwitchThemeData.overlayColor] is used. If that is + /// also null, then the default value is used in the pressed and hovered state. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.material.switch.splashRadius} + /// + /// If null, then the value of [SwitchThemeData.splashRadius] is used. If that + /// is also null, then [kRadialReactionRadius] is used. + final double? splashRadius; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Controls the interactive states of the backing [ListTile]. + final WidgetStatesController? statesController; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged<bool>? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.ListTile.tileColor} + final Color? tileColor; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// A widget to display on the opposite side of the tile from the switch. + /// + /// Typically an [Icon] widget. + final Widget? secondary; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If null, the value from [ListTileThemeData.isThreeLine] is used. + /// If that is also null, the value from [ThemeData.listTileTheme] is used. + /// If still null, the default value is `false`. + final bool? isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileThemeData.dense]. + final bool? dense; + + /// The tile's internal padding. + /// + /// Insets a [SwitchListTile]'s contents: its [title], [subtitle], + /// [secondary], and [Switch] widgets. + /// + /// If null, [ListTile]'s default of `EdgeInsets.symmetric(horizontal: 16.0)` + /// is used. + final EdgeInsetsGeometry? contentPadding; + + /// Whether to render icons and text in the [activeThumbColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the switch is + /// on, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// If adaptive, creates the switch with [Switch.adaptive]. + final _SwitchListTileType _switchListTileType; + + /// Defines the position of control and [secondary], relative to text. + /// + /// By default, the value of [controlAffinity] is [ListTileControlAffinity.platform]. + final ListTileControlAffinity? controlAffinity; + + /// {@macro flutter.material.ListTile.shape} + final ShapeBorder? shape; + + /// If non-null, defines the background color when [SwitchListTile.selected] is true. + final Color? selectedTileColor; + + /// Defines how compact the list tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + final VisualDensity? visualDensity; + + /// {@macro flutter.material.ListTile.enableFeedback} + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// {@macro flutter.material.ListTile.horizontalTitleGap} + final double? horizontalTitleGap; + + /// {@macro flutter.material.ListTile.minVerticalPadding} + final double? minVerticalPadding; + + /// {@macro flutter.material.ListTile.minLeadingWidth} + final double? minLeadingWidth; + + /// {@macro flutter.material.ListTile.minTileHeight} + final double? minTileHeight; + + /// The color for the tile's [Material] when a pointer is hovering over it. + final Color? hoverColor; + + /// {@macro flutter.cupertino.CupertinoSwitch.applyTheme} + final bool? applyCupertinoTheme; + + /// Whether to add button:true to the semantics if onTap is provided. + /// This is a temporary flag to help changing the behavior of ListTile onTap semantics. + /// + // TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping + // the default value to true. + final bool internalAddSemanticForOnTap; + + @override + Widget build(BuildContext context) { + final Widget control; + switch (_switchListTileType) { + case _SwitchListTileType.adaptive: + control = ExcludeFocus( + child: Switch.adaptive( + value: value, + onChanged: onChanged, + activeColor: activeColor, + activeThumbColor: activeThumbColor, + activeThumbImage: activeThumbImage, + inactiveThumbImage: inactiveThumbImage, + materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + activeTrackColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + inactiveThumbColor: inactiveThumbColor, + autofocus: autofocus, + onFocusChange: onFocusChange, + onActiveThumbImageError: onActiveThumbImageError, + onInactiveThumbImageError: onInactiveThumbImageError, + thumbColor: thumbColor, + trackColor: trackColor, + trackOutlineColor: trackOutlineColor, + thumbIcon: thumbIcon, + applyCupertinoTheme: applyCupertinoTheme, + dragStartBehavior: dragStartBehavior, + mouseCursor: mouseCursor, + splashRadius: splashRadius, + overlayColor: overlayColor, + ), + ); + + case _SwitchListTileType.material: + control = ExcludeFocus( + child: Switch( + value: value, + onChanged: onChanged, + activeColor: activeColor, + activeThumbColor: activeThumbColor, + activeThumbImage: activeThumbImage, + inactiveThumbImage: inactiveThumbImage, + materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + activeTrackColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + inactiveThumbColor: inactiveThumbColor, + autofocus: autofocus, + onFocusChange: onFocusChange, + onActiveThumbImageError: onActiveThumbImageError, + onInactiveThumbImageError: onInactiveThumbImageError, + thumbColor: thumbColor, + trackColor: trackColor, + trackOutlineColor: trackOutlineColor, + thumbIcon: thumbIcon, + dragStartBehavior: dragStartBehavior, + mouseCursor: mouseCursor, + splashRadius: splashRadius, + overlayColor: overlayColor, + ), + ); + } + + final ListTileThemeData listTileTheme = ListTileTheme.of(context); + final ListTileControlAffinity effectiveControlAffinity = + controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform; + Widget? leading, trailing; + (leading, trailing) = switch (effectiveControlAffinity) { + ListTileControlAffinity.leading => (control, secondary), + ListTileControlAffinity.trailing || ListTileControlAffinity.platform => (secondary, control), + }; + + final ThemeData theme = Theme.of(context); + final SwitchThemeData switchTheme = SwitchTheme.of(context); + final states = <WidgetState>{if (selected) WidgetState.selected}; + final Color effectiveActiveColor = + activeThumbColor ?? + activeColor ?? + switchTheme.thumbColor?.resolve(states) ?? + theme.colorScheme.secondary; + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + contentPadding: contentPadding, + enabled: onChanged != null, + onTap: onChanged != null + ? () { + onChanged!(!value); + } + : null, + selected: selected, + selectedTileColor: selectedTileColor, + autofocus: autofocus, + shape: shape, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + statesController: statesController, + onFocusChange: onFocusChange, + enableFeedback: enableFeedback, + horizontalTitleGap: horizontalTitleGap, + minVerticalPadding: minVerticalPadding, + minLeadingWidth: minLeadingWidth, + minTileHeight: minTileHeight, + hoverColor: hoverColor, + internalAddSemanticForOnTap: internalAddSemanticForOnTap, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/switch_theme.dart b/packages/material_ui/lib/src/switch_theme.dart new file mode 100644 index 000000000000..9f4ffb6475d3 --- /dev/null +++ b/packages/material_ui/lib/src/switch_theme.dart @@ -0,0 +1,296 @@ +// 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. + +/// @docImport 'switch.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Switch] widgets. +/// +/// Descendant widgets obtain the current [SwitchThemeData] object using +/// [SwitchTheme.of]. Instances of [SwitchThemeData] can be customized +/// with [SwitchThemeData.copyWith]. +/// +/// Typically a [SwitchThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.switchTheme]. +/// +/// All [SwitchThemeData] properties are `null` by default. When null, the +/// [Switch] will use the values from [ThemeData] if they exist, otherwise it +/// will provide its own defaults based on the overall [Theme]'s colorScheme. +/// See the individual [Switch] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class SwitchThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.switchTheme]. + const SwitchThemeData({ + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.trackOutlineWidth, + this.materialTapTargetSize, + this.mouseCursor, + this.overlayColor, + this.splashRadius, + this.thumbIcon, + this.padding, + }); + + /// {@macro flutter.material.switch.thumbColor} + /// + /// If specified, overrides the default value of [Switch.thumbColor]. + final WidgetStateProperty<Color?>? thumbColor; + + /// {@macro flutter.material.switch.trackColor} + /// + /// If specified, overrides the default value of [Switch.trackColor]. + final WidgetStateProperty<Color?>? trackColor; + + /// {@macro flutter.material.switch.trackOutlineColor} + /// + /// If specified, overrides the default value of [Switch.trackOutlineColor]. + final WidgetStateProperty<Color?>? trackOutlineColor; + + /// {@macro flutter.material.switch.trackOutlineWidth} + /// + /// If specified, overrides the default value of [Switch.trackOutlineWidth]. + final WidgetStateProperty<double?>? trackOutlineWidth; + + /// {@macro flutter.material.switch.materialTapTargetSize} + /// + /// If specified, overrides the default value of + /// [Switch.materialTapTargetSize]. + final MaterialTapTargetSize? materialTapTargetSize; + + /// {@macro flutter.material.switch.mouseCursor} + /// + /// If specified, overrides the default value of [Switch.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// {@macro flutter.material.switch.overlayColor} + /// + /// If specified, overrides the default value of [Switch.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.material.switch.splashRadius} + /// + /// If specified, overrides the default value of [Switch.splashRadius]. + final double? splashRadius; + + /// {@macro flutter.material.switch.thumbIcon} + /// + /// It is overridden by [Switch.thumbIcon]. + final WidgetStateProperty<Icon?>? thumbIcon; + + /// If specified, overrides the default value of [Switch.padding]. + final EdgeInsetsGeometry? padding; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SwitchThemeData copyWith({ + WidgetStateProperty<Color?>? thumbColor, + WidgetStateProperty<Color?>? trackColor, + WidgetStateProperty<Color?>? trackOutlineColor, + WidgetStateProperty<double?>? trackOutlineWidth, + MaterialTapTargetSize? materialTapTargetSize, + WidgetStateProperty<MouseCursor?>? mouseCursor, + WidgetStateProperty<Color?>? overlayColor, + double? splashRadius, + WidgetStateProperty<Icon?>? thumbIcon, + EdgeInsetsGeometry? padding, + }) { + return SwitchThemeData( + thumbColor: thumbColor ?? this.thumbColor, + trackColor: trackColor ?? this.trackColor, + trackOutlineColor: trackOutlineColor ?? this.trackOutlineColor, + trackOutlineWidth: trackOutlineWidth ?? this.trackOutlineWidth, + materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize, + mouseCursor: mouseCursor ?? this.mouseCursor, + overlayColor: overlayColor ?? this.overlayColor, + splashRadius: splashRadius ?? this.splashRadius, + thumbIcon: thumbIcon ?? this.thumbIcon, + padding: padding ?? this.padding, + ); + } + + /// Linearly interpolate between two [SwitchThemeData]s. + /// + /// {@macro dart.ui.shadow.lerp} + static SwitchThemeData lerp(SwitchThemeData? a, SwitchThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return SwitchThemeData( + thumbColor: WidgetStateProperty.lerp<Color?>(a?.thumbColor, b?.thumbColor, t, Color.lerp), + trackColor: WidgetStateProperty.lerp<Color?>(a?.trackColor, b?.trackColor, t, Color.lerp), + trackOutlineColor: WidgetStateProperty.lerp<Color?>( + a?.trackOutlineColor, + b?.trackOutlineColor, + t, + Color.lerp, + ), + trackOutlineWidth: WidgetStateProperty.lerp<double?>( + a?.trackOutlineWidth, + b?.trackOutlineWidth, + t, + lerpDouble, + ), + materialTapTargetSize: t < 0.5 ? a?.materialTapTargetSize : b?.materialTapTargetSize, + mouseCursor: t < 0.5 ? a?.mouseCursor : b?.mouseCursor, + overlayColor: WidgetStateProperty.lerp<Color?>( + a?.overlayColor, + b?.overlayColor, + t, + Color.lerp, + ), + splashRadius: lerpDouble(a?.splashRadius, b?.splashRadius, t), + thumbIcon: t < 0.5 ? a?.thumbIcon : b?.thumbIcon, + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + ); + } + + @override + int get hashCode => Object.hash( + thumbColor, + trackColor, + trackOutlineColor, + trackOutlineWidth, + materialTapTargetSize, + mouseCursor, + overlayColor, + splashRadius, + thumbIcon, + padding, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SwitchThemeData && + other.thumbColor == thumbColor && + other.trackColor == trackColor && + other.trackOutlineColor == trackOutlineColor && + other.trackOutlineWidth == trackOutlineWidth && + other.materialTapTargetSize == materialTapTargetSize && + other.mouseCursor == mouseCursor && + other.overlayColor == overlayColor && + other.splashRadius == splashRadius && + other.thumbIcon == thumbIcon && + other.padding == padding; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'thumbColor', + thumbColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'trackColor', + trackColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'trackOutlineColor', + trackOutlineColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<double?>>( + 'trackOutlineWidth', + trackOutlineWidth, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + materialTapTargetSize, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('splashRadius', splashRadius, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Icon?>>('thumbIcon', thumbIcon, defaultValue: null), + ); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + } +} + +/// Applies a switch theme to descendant [Switch] widgets. +/// +/// Descendant widgets obtain the current theme's [SwitchTheme] object using +/// [SwitchTheme.of]. When a widget uses [SwitchTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// A switch theme can be specified as part of the overall Material theme using +/// [ThemeData.switchTheme]. +/// +/// See also: +/// +/// * [SwitchThemeData], which describes the actual configuration of a switch +/// theme. +class SwitchTheme extends InheritedWidget { + /// Constructs a switch theme that configures all descendant [Switch] widgets. + const SwitchTheme({super.key, required this.data, required super.child}); + + /// The properties used for all descendant [Switch] widgets. + final SwitchThemeData data; + + /// Returns the configuration [data] from the closest [SwitchTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.switchTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SwitchThemeData theme = SwitchTheme.of(context); + /// ``` + static SwitchThemeData of(BuildContext context) { + final SwitchTheme? switchTheme = context.dependOnInheritedWidgetOfExactType<SwitchTheme>(); + return switchTheme?.data ?? Theme.of(context).switchTheme; + } + + @override + bool updateShouldNotify(SwitchTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/tab_bar_theme.dart b/packages/material_ui/lib/src/tab_bar_theme.dart new file mode 100644 index 000000000000..e659d74f6b6e --- /dev/null +++ b/packages/material_ui/lib/src/tab_bar_theme.dart @@ -0,0 +1,619 @@ +// 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 'ink_well.dart'; +import 'tabs.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [TabBar] widgets. +/// +/// Descendant widgets obtain the current [TabBarThemeData] object using +/// [TabBarTheme.of]. Instances of [TabBarThemeData] can be customized +/// with [TabBarThemeData.copyWith]. +/// +/// Typically a [TabBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.tabBarTheme]. +/// +/// See also: +/// +/// * [TabBarThemeData], which describes the actual configuration of a tab +/// bar theme. +@immutable +class TabBarTheme extends InheritedTheme with Diagnosticable { + /// Creates a tab bar theme that can be used with [ThemeData.tabBarTheme]. + const TabBarTheme({ + super.key, + Decoration? indicator, + Color? indicatorColor, + TabBarIndicatorSize? indicatorSize, + Color? dividerColor, + double? dividerHeight, + Color? labelColor, + EdgeInsetsGeometry? labelPadding, + TextStyle? labelStyle, + Color? unselectedLabelColor, + TextStyle? unselectedLabelStyle, + WidgetStateProperty<Color?>? overlayColor, + InteractiveInkFeatureFactory? splashFactory, + WidgetStateProperty<MouseCursor?>? mouseCursor, + TabAlignment? tabAlignment, + TextScaler? textScaler, + TabIndicatorAnimation? indicatorAnimation, + TabBarThemeData? data, + Widget? child, + }) : assert( + data == null || + (indicator ?? + indicatorColor ?? + indicatorSize ?? + dividerColor ?? + dividerHeight ?? + labelColor ?? + labelPadding ?? + labelStyle ?? + unselectedLabelColor ?? + unselectedLabelStyle ?? + overlayColor ?? + splashFactory ?? + mouseCursor ?? + tabAlignment ?? + textScaler ?? + indicatorAnimation) == + null, + ), + _indicator = indicator, + _indicatorColor = indicatorColor, + _indicatorSize = indicatorSize, + _dividerColor = dividerColor, + _dividerHeight = dividerHeight, + _labelColor = labelColor, + _labelPadding = labelPadding, + _labelStyle = labelStyle, + _unselectedLabelColor = unselectedLabelColor, + _unselectedLabelStyle = unselectedLabelStyle, + _overlayColor = overlayColor, + _splashFactory = splashFactory, + _mouseCursor = mouseCursor, + _tabAlignment = tabAlignment, + _textScaler = textScaler, + _indicatorAnimation = indicatorAnimation, + _data = data, + super(child: child ?? const SizedBox()); + + final TabBarThemeData? _data; + final Decoration? _indicator; + final Color? _indicatorColor; + final TabBarIndicatorSize? _indicatorSize; + final Color? _dividerColor; + final double? _dividerHeight; + final Color? _labelColor; + final EdgeInsetsGeometry? _labelPadding; + final TextStyle? _labelStyle; + final Color? _unselectedLabelColor; + final TextStyle? _unselectedLabelStyle; + final WidgetStateProperty<Color?>? _overlayColor; + final InteractiveInkFeatureFactory? _splashFactory; + final WidgetStateProperty<MouseCursor?>? _mouseCursor; + final TabAlignment? _tabAlignment; + final TextScaler? _textScaler; + final TabIndicatorAnimation? _indicatorAnimation; + + /// Overrides the default value for [TabBar.indicator]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.indicator] property in [data] instead. + Decoration? get indicator => _data != null ? _data.indicator : _indicator; + + /// Overrides the default value for [TabBar.indicatorColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.indicatorColor] property in [data] instead. + Color? get indicatorColor => _data != null ? _data.indicatorColor : _indicatorColor; + + /// Overrides the default value for [TabBar.indicatorSize]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.indicatorSize] property in [data] instead. + TabBarIndicatorSize? get indicatorSize => _data != null ? _data.indicatorSize : _indicatorSize; + + /// Overrides the default value for [TabBar.dividerColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.dividerColor] property in [data] instead. + Color? get dividerColor => _data != null ? _data.dividerColor : _dividerColor; + + /// Overrides the default value for [TabBar.dividerHeight]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.dividerHeight] property in [data] instead. + double? get dividerHeight => _data != null ? _data.dividerHeight : _dividerHeight; + + /// Overrides the default value for [TabBar.labelColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.labelColor] property in [data] instead. + Color? get labelColor => _data != null ? _data.labelColor : _labelColor; + + /// Overrides the default value for [TabBar.labelPadding]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.labelPadding] property in [data] instead. + EdgeInsetsGeometry? get labelPadding => _data != null ? _data.labelPadding : _labelPadding; + + /// Overrides the default value for [TabBar.labelStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.labelStyle] property in [data] instead. + TextStyle? get labelStyle => _data != null ? _data.labelStyle : _labelStyle; + + /// Overrides the default value for [TabBar.unselectedLabelColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.unselectedLabelColor] property in [data] instead. + Color? get unselectedLabelColor => + _data != null ? _data.unselectedLabelColor : _unselectedLabelColor; + + /// Overrides the default value for [TabBar.unselectedLabelStyle]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.unselectedLabelStyle] property in [data] instead. + TextStyle? get unselectedLabelStyle => + _data != null ? _data.unselectedLabelStyle : _unselectedLabelStyle; + + /// Overrides the default value for [TabBar.overlayColor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.overlayColor] property in [data] instead. + WidgetStateProperty<Color?>? get overlayColor => + _data != null ? _data.overlayColor : _overlayColor; + + /// Overrides the default value for [TabBar.splashFactory]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.splashFactory] property in [data] instead. + InteractiveInkFeatureFactory? get splashFactory => + _data != null ? _data.splashFactory : _splashFactory; + + /// Overrides the default value of [TabBar.mouseCursor]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.mouseCursor] property in [data] instead. + WidgetStateProperty<MouseCursor?>? get mouseCursor => + _data != null ? _data.mouseCursor : _mouseCursor; + + /// Overrides the default value for [TabBar.tabAlignment]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.tabAlignment] property in [data] instead. + TabAlignment? get tabAlignment => _data != null ? _data.tabAlignment : _tabAlignment; + + /// Overrides the default value for [TabBar.textScaler]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.textScaler] property in [data] instead. + TextScaler? get textScaler => _data != null ? _data.textScaler : _textScaler; + + /// Overrides the default value for [TabBar.indicatorAnimation]. + /// + /// This property is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.indicatorAnimation] property in [data] instead. + TabIndicatorAnimation? get indicatorAnimation => + _data != null ? _data.indicatorAnimation : _indicatorAnimation; + + /// The properties used for all descendant [TabBar] widgets. + TabBarThemeData get data => + _data ?? + TabBarThemeData( + indicator: _indicator, + indicatorColor: _indicatorColor, + indicatorSize: _indicatorSize, + dividerColor: _dividerColor, + dividerHeight: _dividerHeight, + labelColor: _labelColor, + labelPadding: _labelPadding, + labelStyle: _labelStyle, + unselectedLabelColor: _unselectedLabelColor, + unselectedLabelStyle: _unselectedLabelStyle, + overlayColor: _overlayColor, + splashFactory: _splashFactory, + mouseCursor: _mouseCursor, + tabAlignment: _tabAlignment, + textScaler: _textScaler, + indicatorAnimation: _indicatorAnimation, + ); + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.copyWith] instead. + TabBarTheme copyWith({ + Decoration? indicator, + Color? indicatorColor, + TabBarIndicatorSize? indicatorSize, + Color? dividerColor, + double? dividerHeight, + Color? labelColor, + EdgeInsetsGeometry? labelPadding, + TextStyle? labelStyle, + Color? unselectedLabelColor, + TextStyle? unselectedLabelStyle, + WidgetStateProperty<Color?>? overlayColor, + InteractiveInkFeatureFactory? splashFactory, + WidgetStateProperty<MouseCursor?>? mouseCursor, + TabAlignment? tabAlignment, + TextScaler? textScaler, + TabIndicatorAnimation? indicatorAnimation, + }) { + return TabBarTheme( + indicator: indicator ?? this.indicator, + indicatorColor: indicatorColor ?? this.indicatorColor, + indicatorSize: indicatorSize ?? this.indicatorSize, + dividerColor: dividerColor ?? this.dividerColor, + dividerHeight: dividerHeight ?? this.dividerHeight, + labelColor: labelColor ?? this.labelColor, + labelPadding: labelPadding ?? this.labelPadding, + labelStyle: labelStyle ?? this.labelStyle, + unselectedLabelColor: unselectedLabelColor ?? this.unselectedLabelColor, + unselectedLabelStyle: unselectedLabelStyle ?? this.unselectedLabelStyle, + overlayColor: overlayColor ?? this.overlayColor, + splashFactory: splashFactory ?? this.splashFactory, + mouseCursor: mouseCursor ?? this.mouseCursor, + tabAlignment: tabAlignment ?? this.tabAlignment, + textScaler: textScaler ?? this.textScaler, + indicatorAnimation: indicatorAnimation ?? this.indicatorAnimation, + ); + } + + /// Returns the configuration [data] from the closest [TabBarTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.tabBarTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TabBarThemeData theme = TabBarTheme.of(context); + /// ``` + static TabBarThemeData of(BuildContext context) { + final TabBarTheme? tabBarTheme = context.dependOnInheritedWidgetOfExactType<TabBarTheme>(); + return tabBarTheme?.data ?? Theme.of(context).tabBarTheme; + } + + /// Linearly interpolate between two tab bar themes. + /// + /// {@macro dart.ui.shadow.lerp} + /// + /// This method is obsolete and will be deprecated in a future release: + /// please use the [TabBarThemeData.lerp] instead. + static TabBarTheme lerp(TabBarTheme a, TabBarTheme b, double t) { + if (identical(a, b)) { + return a; + } + return TabBarTheme( + indicator: Decoration.lerp(a.indicator, b.indicator, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + indicatorSize: t < 0.5 ? a.indicatorSize : b.indicatorSize, + dividerColor: Color.lerp(a.dividerColor, b.dividerColor, t), + dividerHeight: t < 0.5 ? a.dividerHeight : b.dividerHeight, + labelColor: Color.lerp(a.labelColor, b.labelColor, t), + labelPadding: EdgeInsetsGeometry.lerp(a.labelPadding, b.labelPadding, t), + labelStyle: TextStyle.lerp(a.labelStyle, b.labelStyle, t), + unselectedLabelColor: Color.lerp(a.unselectedLabelColor, b.unselectedLabelColor, t), + unselectedLabelStyle: TextStyle.lerp(a.unselectedLabelStyle, b.unselectedLabelStyle, t), + overlayColor: WidgetStateProperty.lerp<Color?>(a.overlayColor, b.overlayColor, t, Color.lerp), + splashFactory: t < 0.5 ? a.splashFactory : b.splashFactory, + mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, + tabAlignment: t < 0.5 ? a.tabAlignment : b.tabAlignment, + textScaler: t < 0.5 ? a.textScaler : b.textScaler, + indicatorAnimation: t < 0.5 ? a.indicatorAnimation : b.indicatorAnimation, + ); + } + + @override + bool updateShouldNotify(TabBarTheme oldWidget) => data != oldWidget.data; + + @override + Widget wrap(BuildContext context, Widget child) { + return TabBarTheme(data: data, child: child); + } +} + +/// Defines default property values for descendant [TabBar] widgets. +/// +/// Descendant widgets obtain the current [TabBarThemeData] object using +/// [TabBarTheme.of]. Instances of [TabBarThemeData] can be customized +/// with [TabBarThemeData.copyWith]. +/// +/// Typically a [TabBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.tabBarTheme]. +/// +/// All [TabBarThemeData] properties are `null` by default. When null, the [TabBar] +/// will use the values from [ThemeData] if they exist, otherwise it will +/// provide its own defaults. See the individual [TabBar] properties for details. +/// +/// See also: +/// +/// * [TabBar], which displays a row of tabs. +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class TabBarThemeData with Diagnosticable { + /// Creates a tab bar theme that can be used with [ThemeData.tabBarTheme]. + const TabBarThemeData({ + this.indicator, + this.indicatorColor, + this.indicatorSize, + this.dividerColor, + this.dividerHeight, + this.labelColor, + this.labelPadding, + this.labelStyle, + this.unselectedLabelColor, + this.unselectedLabelStyle, + this.overlayColor, + this.splashFactory, + this.mouseCursor, + this.tabAlignment, + this.textScaler, + this.indicatorAnimation, + this.splashBorderRadius, + }); + + /// Overrides the default value for [TabBar.indicator]. + final Decoration? indicator; + + /// Overrides the default value for [TabBar.indicatorColor]. + final Color? indicatorColor; + + /// Overrides the default value for [TabBar.indicatorSize]. + final TabBarIndicatorSize? indicatorSize; + + /// Overrides the default value for [TabBar.dividerColor]. + final Color? dividerColor; + + /// Overrides the default value for [TabBar.dividerHeight]. + final double? dividerHeight; + + /// Overrides the default value for [TabBar.labelColor]. + /// + /// If [labelColor] is a [WidgetStateColor], then the effective color will + /// depend on the [WidgetState.selected] state, i.e. if the [Tab] is + /// selected or not. In case of unselected state, this [WidgetStateColor]'s + /// resolved color will be used even if [TabBar.unselectedLabelColor] or + /// [unselectedLabelColor] is non-null. + final Color? labelColor; + + /// Overrides the default value for [TabBar.labelPadding]. + /// + /// If there are few tabs with both icon and text and few + /// tabs with only icon or text, this padding is vertically + /// adjusted to provide uniform padding to all tabs. + final EdgeInsetsGeometry? labelPadding; + + /// Overrides the default value for [TabBar.labelStyle]. + final TextStyle? labelStyle; + + /// Overrides the default value for [TabBar.unselectedLabelColor]. + final Color? unselectedLabelColor; + + /// Overrides the default value for [TabBar.unselectedLabelStyle]. + final TextStyle? unselectedLabelStyle; + + /// Overrides the default value for [TabBar.overlayColor]. + final WidgetStateProperty<Color?>? overlayColor; + + /// Overrides the default value for [TabBar.splashFactory]. + final InteractiveInkFeatureFactory? splashFactory; + + /// {@macro flutter.material.tabs.mouseCursor} + /// + /// If specified, overrides the default value of [TabBar.mouseCursor]. + final WidgetStateProperty<MouseCursor?>? mouseCursor; + + /// Overrides the default value for [TabBar.tabAlignment]. + final TabAlignment? tabAlignment; + + /// Overrides the default value for [TabBar.textScaler]. + final TextScaler? textScaler; + + /// Overrides the default value for [TabBar.indicatorAnimation]. + final TabIndicatorAnimation? indicatorAnimation; + + /// Defines the clipping radius of splashes that extend outside the bounds of the tab. + final BorderRadius? splashBorderRadius; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + TabBarThemeData copyWith({ + Decoration? indicator, + Color? indicatorColor, + TabBarIndicatorSize? indicatorSize, + Color? dividerColor, + double? dividerHeight, + Color? labelColor, + EdgeInsetsGeometry? labelPadding, + TextStyle? labelStyle, + Color? unselectedLabelColor, + TextStyle? unselectedLabelStyle, + WidgetStateProperty<Color?>? overlayColor, + InteractiveInkFeatureFactory? splashFactory, + WidgetStateProperty<MouseCursor?>? mouseCursor, + TabAlignment? tabAlignment, + TextScaler? textScaler, + TabIndicatorAnimation? indicatorAnimation, + BorderRadius? splashBorderRadius, + }) { + return TabBarThemeData( + indicator: indicator ?? this.indicator, + indicatorColor: indicatorColor ?? this.indicatorColor, + indicatorSize: indicatorSize ?? this.indicatorSize, + dividerColor: dividerColor ?? this.dividerColor, + dividerHeight: dividerHeight ?? this.dividerHeight, + labelColor: labelColor ?? this.labelColor, + labelPadding: labelPadding ?? this.labelPadding, + labelStyle: labelStyle ?? this.labelStyle, + unselectedLabelColor: unselectedLabelColor ?? this.unselectedLabelColor, + unselectedLabelStyle: unselectedLabelStyle ?? this.unselectedLabelStyle, + overlayColor: overlayColor ?? this.overlayColor, + splashFactory: splashFactory ?? this.splashFactory, + mouseCursor: mouseCursor ?? this.mouseCursor, + tabAlignment: tabAlignment ?? this.tabAlignment, + textScaler: textScaler ?? this.textScaler, + indicatorAnimation: indicatorAnimation ?? this.indicatorAnimation, + splashBorderRadius: splashBorderRadius ?? this.splashBorderRadius, + ); + } + + /// Linearly interpolate between two tab bar themes. + /// + /// {@macro dart.ui.shadow.lerp} + static TabBarThemeData lerp(TabBarThemeData a, TabBarThemeData b, double t) { + if (identical(a, b)) { + return a; + } + return TabBarThemeData( + indicator: Decoration.lerp(a.indicator, b.indicator, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + indicatorSize: t < 0.5 ? a.indicatorSize : b.indicatorSize, + dividerColor: Color.lerp(a.dividerColor, b.dividerColor, t), + dividerHeight: t < 0.5 ? a.dividerHeight : b.dividerHeight, + labelColor: Color.lerp(a.labelColor, b.labelColor, t), + labelPadding: EdgeInsetsGeometry.lerp(a.labelPadding, b.labelPadding, t), + labelStyle: TextStyle.lerp(a.labelStyle, b.labelStyle, t), + unselectedLabelColor: Color.lerp(a.unselectedLabelColor, b.unselectedLabelColor, t), + unselectedLabelStyle: TextStyle.lerp(a.unselectedLabelStyle, b.unselectedLabelStyle, t), + overlayColor: WidgetStateProperty.lerp<Color?>(a.overlayColor, b.overlayColor, t, Color.lerp), + splashFactory: t < 0.5 ? a.splashFactory : b.splashFactory, + mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, + tabAlignment: t < 0.5 ? a.tabAlignment : b.tabAlignment, + textScaler: t < 0.5 ? a.textScaler : b.textScaler, + indicatorAnimation: t < 0.5 ? a.indicatorAnimation : b.indicatorAnimation, + splashBorderRadius: BorderRadius.lerp(a.splashBorderRadius, a.splashBorderRadius, t), + ); + } + + @override + int get hashCode => Object.hash( + indicator, + indicatorColor, + indicatorSize, + dividerColor, + dividerHeight, + labelColor, + labelPadding, + labelStyle, + unselectedLabelColor, + unselectedLabelStyle, + overlayColor, + splashFactory, + mouseCursor, + tabAlignment, + textScaler, + indicatorAnimation, + splashBorderRadius, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is TabBarThemeData && + other.indicator == indicator && + other.indicatorColor == indicatorColor && + other.indicatorSize == indicatorSize && + other.dividerColor == dividerColor && + other.dividerHeight == dividerHeight && + other.labelColor == labelColor && + other.labelPadding == labelPadding && + other.labelStyle == labelStyle && + other.unselectedLabelColor == unselectedLabelColor && + other.unselectedLabelStyle == unselectedLabelStyle && + other.overlayColor == overlayColor && + other.splashFactory == splashFactory && + other.mouseCursor == mouseCursor && + other.tabAlignment == tabAlignment && + other.textScaler == textScaler && + other.indicatorAnimation == indicatorAnimation && + other.splashBorderRadius == splashBorderRadius; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<Decoration?>('indicator', indicator, defaultValue: null)); + properties.add( + DiagnosticsProperty<Color?>('indicatorColor', indicatorColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TabBarIndicatorSize?>('indicatorSize', indicatorSize, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Color?>('dividerColor', dividerColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<double?>('dividerHeight', dividerHeight, defaultValue: null), + ); + properties.add(DiagnosticsProperty<Color?>('labelColor', labelColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry?>('labelPadding', labelPadding, defaultValue: null), + ); + properties.add(DiagnosticsProperty<TextStyle?>('labelStyle', labelStyle, defaultValue: null)); + properties.add( + DiagnosticsProperty<Color?>('unselectedLabelColor', unselectedLabelColor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TextStyle?>( + 'unselectedLabelStyle', + unselectedLabelStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>?>( + 'overlayColor', + overlayColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<InteractiveInkFeatureFactory?>( + 'splashFactory', + splashFactory, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<MouseCursor?>?>( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<TabAlignment?>('tabAlignment', tabAlignment, defaultValue: null), + ); + properties.add(DiagnosticsProperty<TextScaler?>('textScaler', textScaler, defaultValue: null)); + properties.add( + DiagnosticsProperty<TabIndicatorAnimation?>( + 'indicatorAnimation', + indicatorAnimation, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BorderRadius?>( + 'splashBorderRadius', + splashBorderRadius, + defaultValue: null, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/tab_controller.dart b/packages/material_ui/lib/src/tab_controller.dart new file mode 100644 index 000000000000..92b1a6c52039 --- /dev/null +++ b/packages/material_ui/lib/src/tab_controller.dart @@ -0,0 +1,520 @@ +// 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. + +/// @docImport 'app_bar.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'tabs.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'constants.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Coordinates tab selection between a [TabBar] and a [TabBarView]. +/// +/// The [index] property is the index of the selected tab and the [animation] +/// represents the current scroll positions of the tab bar and the tab bar view. +/// The selected tab's index can be changed with [animateTo]. +/// +/// A stateful widget that builds a [TabBar] or a [TabBarView] can create +/// a [TabController] and share it directly. +/// +/// When the [TabBar] and [TabBarView] don't have a convenient stateful +/// ancestor, a [TabController] can be shared by providing a +/// [DefaultTabController] inherited widget. +/// +/// {@animation 700 540 https://flutter.github.io/assets-for-api-docs/assets/material/tabs.mp4} +/// +/// {@tool snippet} +/// +/// This widget introduces a [Scaffold] with an [AppBar] and a [TabBar]. +/// +/// ```dart +/// class MyTabbedPage extends StatefulWidget { +/// const MyTabbedPage({ super.key }); +/// @override +/// State<MyTabbedPage> createState() => _MyTabbedPageState(); +/// } +/// +/// class _MyTabbedPageState extends State<MyTabbedPage> with SingleTickerProviderStateMixin { +/// static const List<Tab> myTabs = <Tab>[ +/// Tab(text: 'LEFT'), +/// Tab(text: 'RIGHT'), +/// ]; +/// +/// late TabController _tabController; +/// +/// @override +/// void initState() { +/// super.initState(); +/// _tabController = TabController(vsync: this, length: myTabs.length); +/// } +/// +/// @override +/// void dispose() { +/// _tabController.dispose(); +/// super.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: AppBar( +/// bottom: TabBar( +/// controller: _tabController, +/// tabs: myTabs, +/// ), +/// ), +/// body: TabBarView( +/// controller: _tabController, +/// children: myTabs.map((Tab tab) { +/// final String label = tab.text!.toLowerCase(); +/// return Center( +/// child: Text( +/// 'This is the $label tab', +/// style: const TextStyle(fontSize: 36), +/// ), +/// ); +/// }).toList(), +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to listen to page updates in [TabBar] and [TabBarView] +/// when using [DefaultTabController]. +/// +/// ** See code in examples/api/lib/material/tab_controller/tab_controller.1.dart ** +/// {@end-tool} +/// +class TabController extends ChangeNotifier { + /// Creates an object that manages the state required by [TabBar] and a + /// [TabBarView]. + /// + /// The [length] must not be negative. Typically it's a value greater than + /// one, i.e. typically there are two or more tabs. The [length] must match + /// [TabBar.tabs]'s and [TabBarView.children]'s length. + /// + /// The `initialIndex` must be valid given [length]. If [length] is zero, then + /// `initialIndex` must be 0 (the default). + TabController({ + int initialIndex = 0, + Duration? animationDuration, + required this.length, + required TickerProvider vsync, + }) : assert(length >= 0), + assert(initialIndex >= 0 && (length == 0 || initialIndex < length)), + _index = initialIndex, + _previousIndex = initialIndex, + _animationDuration = animationDuration ?? kTabScrollDuration, + _animationController = AnimationController.unbounded( + value: initialIndex.toDouble(), + vsync: vsync, + ) { + if (kFlutterMemoryAllocationsEnabled) { + ChangeNotifier.maybeDispatchObjectCreation(this); + } + } + + // Private constructor used by `_copyWith`. This allows a new TabController to + // be created without having to create a new animationController. + TabController._({ + required int index, + required int previousIndex, + required AnimationController? animationController, + required Duration animationDuration, + required this.length, + }) : _index = index, + _previousIndex = previousIndex, + _animationController = animationController, + _animationDuration = animationDuration { + if (kFlutterMemoryAllocationsEnabled) { + ChangeNotifier.maybeDispatchObjectCreation(this); + } + } + + /// Creates a new [TabController] with `index`, `previousIndex`, `length`, and + /// `animationDuration` if they are non-null, and disposes current instance. + /// + /// This method is used by [DefaultTabController]. + /// + /// When [DefaultTabController.length] is updated, this method is called to + /// create a new [TabController] without creating a new [AnimationController]. + /// Instead the [_animationController] is nulled in current instance and + /// passed to the new instance. + TabController _copyWithAndDispose({ + required int? index, + required int? length, + required int? previousIndex, + required Duration? animationDuration, + }) { + if (index != null) { + _animationController!.value = index.toDouble(); + } + final result = TabController._( + index: index ?? _index, + length: length ?? this.length, + animationController: _animationController, + previousIndex: previousIndex ?? _previousIndex, + animationDuration: animationDuration ?? _animationDuration, + ); + _animationController = null; + dispose(); + return result; + } + + /// An animation whose value represents the current position of the [TabBar]'s + /// selected tab indicator as well as the scrollOffsets of the [TabBar] + /// and [TabBarView]. + /// + /// The animation's value ranges from 0.0 to [length] - 1.0. After the + /// selected tab is changed, the animation's value equals [index]. The + /// animation's value can be [offset] by +/- 1.0 to reflect [TabBarView] + /// drag scrolling. + /// + /// If this [TabController] was disposed, then return null. + Animation<double>? get animation => _animationController?.view; + AnimationController? _animationController; + + /// Controls the duration of TabController and TabBarView animations. + /// + /// Defaults to kTabScrollDuration. + Duration get animationDuration => _animationDuration; + final Duration _animationDuration; + + /// The total number of tabs. + /// + /// Typically greater than one. Must match [TabBar.tabs]'s and + /// [TabBarView.children]'s length. + final int length; + + void _changeIndex(int value, {Duration? duration, Curve? curve}) { + assert(value >= 0 && (value < length || length == 0)); + assert(duration != null || curve == null); + assert(_indexIsChangingCount >= 0); + if (value == _index || length < 2) { + return; + } + _previousIndex = index; + _index = value; + if (duration != null && duration > Duration.zero) { + _indexIsChangingCount += 1; + notifyListeners(); // Because the value of indexIsChanging may have changed. + _animationController! + .animateTo(_index.toDouble(), duration: duration, curve: curve!) + .whenCompleteOrCancel(() { + if (_animationController != null) { + // don't notify if we've been disposed + _indexIsChangingCount -= 1; + notifyListeners(); + } + }); + } else { + _indexIsChangingCount += 1; + _animationController!.value = _index.toDouble(); + _indexIsChangingCount -= 1; + notifyListeners(); + } + } + + /// The index of the currently selected tab. + /// + /// Changing the index also updates [previousIndex], sets the [animation]'s + /// value to index, resets [indexIsChanging] to false, and notifies listeners. + /// + /// To change the currently selected tab and play the [animation] use [animateTo]. + /// + /// The value of [index] must be valid given [length]. If [length] is zero, + /// then [index] will also be zero. + int get index => _index; + int _index; + set index(int value) { + _changeIndex(value); + } + + /// The index of the previously selected tab. + /// + /// Initially the same as [index]. + int get previousIndex => _previousIndex; + int _previousIndex; + + /// True while we're animating from [previousIndex] to [index] as a + /// consequence of calling [animateTo]. + /// + /// This value is true during the [animateTo] animation that's triggered when + /// the user taps a [TabBar] tab. It is false when [offset] is changing as a + /// consequence of the user dragging (and "flinging") the [TabBarView]. + bool get indexIsChanging => _indexIsChangingCount != 0; + int _indexIsChangingCount = 0; + + /// Immediately sets [index] and [previousIndex] and then plays the + /// [animation] from its current value to [index]. + /// + /// While the animation is running [indexIsChanging] is true. When the + /// animation completes [offset] will be 0.0. + void animateTo(int value, {Duration? duration, Curve curve = Curves.ease}) { + _changeIndex(value, duration: duration ?? _animationDuration, curve: curve); + } + + /// The difference between the [animation]'s value and [index]. + /// + /// The offset value must be between -1.0 and 1.0. + /// + /// This property is typically set by the [TabBarView] when the user + /// drags left or right. A value between -1.0 and 0.0 implies that the + /// TabBarView has been dragged to the left. Similarly a value between + /// 0.0 and 1.0 implies that the TabBarView has been dragged to the right. + double get offset => _animationController!.value - _index.toDouble(); + set offset(double value) { + assert(value >= -1.0 && value <= 1.0); + assert(!indexIsChanging); + if (value == offset) { + return; + } + _animationController!.value = value + _index.toDouble(); + } + + @override + void dispose() { + _animationController?.dispose(); + _animationController = null; + super.dispose(); + } +} + +class _TabControllerScope extends InheritedWidget { + const _TabControllerScope({ + required this.controller, + required this.enabled, + required super.child, + }); + + final TabController controller; + final bool enabled; + + @override + bool updateShouldNotify(_TabControllerScope old) { + return enabled != old.enabled || controller != old.controller; + } +} + +/// The [TabController] for descendant widgets that don't specify one +/// explicitly. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40} +/// +/// [DefaultTabController] is an inherited widget that is used to share a +/// [TabController] with a [TabBar] or a [TabBarView]. It's used when sharing an +/// explicitly created [TabController] isn't convenient because the tab bar +/// widgets are created by a stateless parent widget or by different parent +/// widgets. +/// +/// {@animation 700 540 https://flutter.github.io/assets-for-api-docs/assets/material/tabs.mp4} +/// +/// ```dart +/// class MyDemo extends StatelessWidget { +/// const MyDemo({super.key}); +/// +/// static const List<Tab> myTabs = <Tab>[ +/// Tab(text: 'LEFT'), +/// Tab(text: 'RIGHT'), +/// ]; +/// +/// @override +/// Widget build(BuildContext context) { +/// return DefaultTabController( +/// length: myTabs.length, +/// child: Scaffold( +/// appBar: AppBar( +/// bottom: const TabBar( +/// tabs: myTabs, +/// ), +/// ), +/// body: TabBarView( +/// children: myTabs.map((Tab tab) { +/// final String label = tab.text!.toLowerCase(); +/// return Center( +/// child: Text( +/// 'This is the $label tab', +/// style: const TextStyle(fontSize: 36), +/// ), +/// ); +/// }).toList(), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +class DefaultTabController extends StatefulWidget { + /// Creates a default tab controller for the given [child] widget. + /// + /// The [length] argument is typically greater than one. The [length] must + /// match [TabBar.tabs]'s and [TabBarView.children]'s length. + const DefaultTabController({ + super.key, + required this.length, + this.initialIndex = 0, + required this.child, + this.animationDuration, + }) : assert(length >= 0), + assert(length == 0 || (initialIndex >= 0 && initialIndex < length)); + + /// The total number of tabs. + /// + /// Typically greater than one. Must match [TabBar.tabs]'s and + /// [TabBarView.children]'s length. + final int length; + + /// The initial index of the selected tab. + /// + /// Defaults to zero. + final int initialIndex; + + /// Controls the duration of DefaultTabController and TabBarView animations. + /// + /// Defaults to kTabScrollDuration. + final Duration? animationDuration; + + /// The widget below this widget in the tree. + /// + /// Typically a [Scaffold] whose [AppBar] includes a [TabBar]. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The closest instance of [DefaultTabController] that encloses the given + /// context, or null if none is found. + /// + /// {@tool snippet} Typical usage is as follows: + /// + /// ```dart + /// TabController? controller = DefaultTabController.maybeOf(context); + /// ``` + /// {@end-tool} + /// + /// Calling this method will create a dependency on the closest + /// [DefaultTabController] in the [context], if there is one. + /// + /// See also: + /// + /// * [DefaultTabController.of], which is similar to this method, but asserts + /// if no [DefaultTabController] ancestor is found. + static TabController? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_TabControllerScope>()?.controller; + } + + /// The closest instance of [DefaultTabController] that encloses the given + /// context. + /// + /// If no instance is found, this method will assert in debug mode and throw + /// an exception in release mode. + /// + /// Calling this method will create a dependency on the closest + /// [DefaultTabController] in the [context]. + /// + /// {@tool snippet} Typical usage is as follows: + /// + /// ```dart + /// TabController controller = DefaultTabController.of(context); + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [DefaultTabController.maybeOf], which is similar to this method, but + /// returns null if no [DefaultTabController] ancestor is found. + static TabController of(BuildContext context) { + final TabController? controller = maybeOf(context); + assert(() { + if (controller == null) { + throw FlutterError( + 'DefaultTabController.of() was called with a context that does not ' + 'contain a DefaultTabController widget.\n' + 'No DefaultTabController widget ancestor could be found starting from ' + 'the context that was passed to DefaultTabController.of(). This can ' + 'happen because you are using a widget that looks for a DefaultTabController ' + 'ancestor, but no such ancestor exists.\n' + 'The context used was:\n' + ' $context', + ); + } + return true; + }()); + return controller!; + } + + @override + State<DefaultTabController> createState() => _DefaultTabControllerState(); +} + +class _DefaultTabControllerState extends State<DefaultTabController> + with SingleTickerProviderStateMixin { + late TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController( + vsync: this, + length: widget.length, + initialIndex: widget.initialIndex, + animationDuration: widget.animationDuration, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _TabControllerScope( + controller: _controller, + enabled: TickerMode.of(context), + child: widget.child, + ); + } + + @override + void didUpdateWidget(DefaultTabController oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.length != widget.length) { + // If the length is shortened while the last tab is selected, we should + // automatically update the index of the controller to be the new last tab. + int? newIndex; + int previousIndex = _controller.previousIndex; + if (_controller.index >= widget.length) { + newIndex = math.max(0, widget.length - 1); + previousIndex = _controller.index; + } + _controller = _controller._copyWithAndDispose( + length: widget.length, + animationDuration: widget.animationDuration, + index: newIndex, + previousIndex: previousIndex, + ); + } + + if (oldWidget.animationDuration != widget.animationDuration) { + _controller = _controller._copyWithAndDispose( + length: widget.length, + animationDuration: widget.animationDuration, + index: _controller.index, + previousIndex: _controller.previousIndex, + ); + } + } +} diff --git a/packages/material_ui/lib/src/tab_indicator.dart b/packages/material_ui/lib/src/tab_indicator.dart new file mode 100644 index 000000000000..4f24fc703f70 --- /dev/null +++ b/packages/material_ui/lib/src/tab_indicator.dart @@ -0,0 +1,123 @@ +// 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. + +/// @docImport 'tabs.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; + +/// Used with [TabBar.indicator] to draw a horizontal line below the +/// selected tab. +/// +/// The selected tab underline is inset from the tab's boundary by [insets]. +/// The [borderSide] defines the line's color and weight. +/// +/// The [TabBar.indicatorSize] property can be used to define the indicator's +/// bounds in terms of its (centered) widget with [TabBarIndicatorSize.label], +/// or the entire tab with [TabBarIndicatorSize.tab]. +class UnderlineTabIndicator extends Decoration { + /// Create an underline style selected tab indicator. + const UnderlineTabIndicator({ + this.borderRadius, + this.borderSide = const BorderSide(width: 2.0, color: Colors.white), + this.insets = EdgeInsets.zero, + }); + + /// The radius of the indicator's corners. + /// + /// If this value is non-null, rounded rectangular tab indicator is + /// drawn, otherwise rectangular tab indicator is drawn. + final BorderRadius? borderRadius; + + /// The color and weight of the horizontal line drawn below the selected tab. + final BorderSide borderSide; + + /// Locates the selected tab's underline relative to the tab's boundary. + /// + /// The [TabBar.indicatorSize] property can be used to define the tab + /// indicator's bounds in terms of its (centered) tab widget with + /// [TabBarIndicatorSize.label], or the entire tab with + /// [TabBarIndicatorSize.tab]. + final EdgeInsetsGeometry insets; + + @override + Decoration? lerpFrom(Decoration? a, double t) { + if (a is UnderlineTabIndicator) { + return UnderlineTabIndicator( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!, + ); + } + return super.lerpFrom(a, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + if (b is UnderlineTabIndicator) { + return UnderlineTabIndicator( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!, + ); + } + return super.lerpTo(b, t); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _UnderlinePainter(this, borderRadius, onChanged); + } + + Rect _indicatorRectFor(Rect rect, TextDirection textDirection) { + final Rect indicator = insets.resolve(textDirection).deflateRect(rect); + return Rect.fromLTWH( + indicator.left, + indicator.bottom - borderSide.width, + indicator.width, + borderSide.width, + ); + } + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + if (borderRadius != null) { + return Path()..addRRect(borderRadius!.toRRect(_indicatorRectFor(rect, textDirection))); + } + return Path()..addRect(_indicatorRectFor(rect, textDirection)); + } +} + +class _UnderlinePainter extends BoxPainter { + _UnderlinePainter(this.decoration, this.borderRadius, super.onChanged); + + final UnderlineTabIndicator decoration; + final BorderRadius? borderRadius; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + assert(configuration.size != null); + final Rect rect = offset & configuration.size!; + final TextDirection textDirection = configuration.textDirection!; + final Paint paint; + if (borderRadius != null) { + paint = Paint()..color = decoration.borderSide.color; + final Rect indicator = decoration._indicatorRectFor(rect, textDirection); + final rrect = RRect.fromRectAndCorners( + indicator, + topLeft: borderRadius!.topLeft, + topRight: borderRadius!.topRight, + bottomRight: borderRadius!.bottomRight, + bottomLeft: borderRadius!.bottomLeft, + ); + canvas.drawRRect(rrect, paint); + } else { + paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.square; + final Rect indicator = decoration + ._indicatorRectFor(rect, textDirection) + .deflate(decoration.borderSide.width / 2.0); + canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint); + } + } +} diff --git a/packages/material_ui/lib/src/tabs.dart b/packages/material_ui/lib/src/tabs.dart new file mode 100644 index 000000000000..87a611a6f606 --- /dev/null +++ b/packages/material_ui/lib/src/tabs.dart @@ -0,0 +1,2944 @@ +// 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. + +/// @docImport 'no_splash.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui' show SemanticsRole, lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'app_bar.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'debug.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'tab_bar_theme.dart'; +import 'tab_controller.dart'; +import 'tab_indicator.dart'; +import 'text_theme.dart'; +import 'theme.dart'; + +const double _kTabHeight = 46.0; +const double _kTextAndIconTabHeight = 72.0; +const double _kStartOffset = 52.0; + +/// Defines how the bounds of the selected tab indicator are computed. +/// +/// See also: +/// +/// * [TabBar], which displays a row of tabs. +/// * [TabBarView], which displays a widget for the currently selected tab. +/// * [TabBar.indicator], which defines the appearance of the selected tab +/// indicator relative to the tab's bounds. +enum TabBarIndicatorSize { + /// The tab indicator's bounds are as wide as the space occupied by the tab + /// in the tab bar: from the right edge of the previous tab to the left edge + /// of the next tab. + tab, + + /// The tab's bounds are only as wide as the (centered) tab widget itself. + /// + /// This value is used to align the tab's label, typically a [Tab] + /// widget's text or icon, with the selected tab indicator. + label, +} + +/// Defines how tabs are aligned horizontally in a [TabBar]. +/// +/// See also: +/// +/// * [TabBar], which displays a row of tabs. +/// * [TabBarView], which displays a widget for the currently selected tab. +/// * [TabBar.tabAlignment], which defines the horizontal alignment of the +/// tabs within the [TabBar]. +enum TabAlignment { + // TODO(tahatesser): Add a link to the Material Design spec for + // horizontal offset when it is available. + // It's currently sourced from androidx/compose/material3/TabRow.kt. + /// If [TabBar.isScrollable] is true, tabs are aligned to the + /// start of the [TabBar]. Otherwise throws an exception. + /// + /// It is not recommended to set [TabAlignment.start] when + /// [ThemeData.useMaterial3] is false. + start, + + /// If [TabBar.isScrollable] is true, tabs are aligned to the + /// start of the [TabBar] with an offset of 52.0 pixels. + /// Otherwise throws an exception. + /// + /// It is not recommended to set [TabAlignment.startOffset] when + /// [ThemeData.useMaterial3] is false. + startOffset, + + /// If [TabBar.isScrollable] is false, tabs are stretched to fill the + /// [TabBar]. Otherwise throws an exception. + fill, + + /// Tabs are aligned to the center of the [TabBar]. + center, +} + +/// Defines how the tab indicator animates when the selected tab changes. +/// +/// See also: +/// * [TabBar], which displays a row of tabs. +/// * [TabBarThemeData], which can be used to configure the appearance of the tab +/// indicator. +enum TabIndicatorAnimation { + /// The tab indicator animates linearly. + linear, + + /// The tab indicator animates with an elastic effect. + elastic, +} + +/// A Material Design [TabBar] tab. +/// +/// If both [icon] and [text] are provided, the text is displayed below +/// the icon. +/// +/// See also: +/// +/// * [TabBar], which displays a row of tabs. +/// * [TabBarView], which displays a widget for the currently selected tab. +/// * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView]. +/// * <https://material.io/design/components/tabs.html> +class Tab extends StatelessWidget implements PreferredSizeWidget { + /// Creates a Material Design [TabBar] tab. + /// + /// At least one of [text], [icon], and [child] must be non-null. The [text] + /// and [child] arguments must not be used at the same time. The + /// [iconMargin] is only useful when [icon] and either one of [text] or + /// [child] is non-null. + const Tab({super.key, this.text, this.icon, this.iconMargin, this.height, this.child}) + : assert( + text != null || child != null || icon != null, + 'Tab requires at least one of text, child, or icon to be non-null.', + ), + assert( + text == null || child == null, + 'Provide either text or child, not both, when creating a Tab.', + ); + + /// The text to display as the tab's label. + /// + /// Must not be used in combination with [child]. + final String? text; + + /// The widget to be used as the tab's label. + /// + /// Usually a [Text] widget, possibly wrapped in a [Semantics] widget. + /// + /// Must not be used in combination with [text]. + final Widget? child; + + /// An icon to display as the tab's label. + final Widget? icon; + + /// The margin added around the tab's icon. + /// + /// Only useful when used in combination with [icon], and either one of + /// [text] or [child] is non-null. + /// + /// Defaults to 2 pixels of bottom margin. If [ThemeData.useMaterial3] is false, + /// then defaults to 10 pixels of bottom margin. + final EdgeInsetsGeometry? iconMargin; + + /// The height of the [Tab]. + /// + /// If null, the height will be calculated based on the content of the [Tab]. When `icon` is not + /// null along with `child` or `text`, the default height is 72.0 pixels. Without an `icon`, the + /// height is 46.0 pixels. + /// + /// {@tool snippet} + /// + /// The provided tab height cannot be lower than the default height. Use + /// [PreferredSize] widget to adjust the overall [TabBar] height and match + /// the provided tab [height]: + /// + /// ```dart + /// bottom: const PreferredSize( + /// preferredSize: Size.fromHeight(20.0), + /// child: TabBar( + /// tabs: <Widget>[ + /// Tab( + /// text: 'Tab 1', + /// height: 20.0, + /// ), + /// Tab( + /// text: 'Tab 2', + /// height: 20.0, + /// ), + /// ], + /// ), + /// ), + /// ``` + /// {@end-tool} + final double? height; + + Widget _buildLabelText() { + return child ?? Text(text!, softWrap: false, overflow: TextOverflow.fade); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + + final double calculatedHeight; + final Widget label; + if (icon == null) { + calculatedHeight = _kTabHeight; + label = _buildLabelText(); + } else if (text == null && child == null) { + calculatedHeight = _kTabHeight; + label = icon!; + } else { + calculatedHeight = _kTextAndIconTabHeight; + final EdgeInsetsGeometry effectiveIconMargin = + iconMargin ?? + (Theme.of(context).useMaterial3 + ? _TabsPrimaryDefaultsM3.iconMargin + : _TabsDefaultsM2.iconMargin); + label = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + Padding(padding: effectiveIconMargin, child: icon), + _buildLabelText(), + ], + ); + } + + return SizedBox( + height: height ?? calculatedHeight, + child: Center(widthFactor: 1.0, child: label), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('text', text, defaultValue: null)); + } + + @override + Size get preferredSize { + if (height != null) { + return Size.fromHeight(height!); + } else if ((text != null || child != null) && icon != null) { + return const Size.fromHeight(_kTextAndIconTabHeight); + } else { + return const Size.fromHeight(_kTabHeight); + } + } +} + +class _TabStyle extends AnimatedWidget { + const _TabStyle({ + required Animation<double> animation, + required this.isSelected, + required this.isPrimary, + required this.labelColor, + required this.unselectedLabelColor, + required this.labelStyle, + required this.unselectedLabelStyle, + required this.defaults, + required this.child, + }) : super(listenable: animation); + + final TextStyle? labelStyle; + final TextStyle? unselectedLabelStyle; + final bool isSelected; + final bool isPrimary; + final Color? labelColor; + final Color? unselectedLabelColor; + final TabBarThemeData defaults; + final Widget child; + + WidgetStateColor _resolveWithLabelColor(BuildContext context, {IconThemeData? iconTheme}) { + final ThemeData themeData = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final animation = listenable as Animation<double>; + + // labelStyle.color (and tabBarTheme.labelStyle.color) is not considered + // as it'll be a breaking change without a possible migration plan. for + // details: https://github.com/flutter/flutter/pull/109541#issuecomment-1294241417 + Color selectedColor = + labelColor ?? + tabBarTheme.labelColor ?? + labelStyle?.color ?? + tabBarTheme.labelStyle?.color ?? + defaults.labelColor!; + + final Color unselectedColor; + + if (selectedColor is WidgetStateColor) { + unselectedColor = selectedColor.resolve(const <WidgetState>{}); + selectedColor = selectedColor.resolve(const <WidgetState>{WidgetState.selected}); + } else { + // unselectedLabelColor and tabBarTheme.unselectedLabelColor are ignored + // when labelColor is a WidgetStateColor. + unselectedColor = + unselectedLabelColor ?? + tabBarTheme.unselectedLabelColor ?? + unselectedLabelStyle?.color ?? + tabBarTheme.unselectedLabelStyle?.color ?? + iconTheme?.color ?? + (themeData.useMaterial3 + ? defaults.unselectedLabelColor! + : selectedColor.withAlpha(0xB2)); // 70% alpha + } + + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return Color.lerp(selectedColor, unselectedColor, animation.value)!; + } + return Color.lerp(unselectedColor, selectedColor, animation.value)!; + }); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final animation = listenable as Animation<double>; + + final states = isSelected ? const <WidgetState>{WidgetState.selected} : const <WidgetState>{}; + + // To enable TextStyle.lerp(style1, style2, value), both styles must have + // the same value of inherit. Force that to be inherit=true here. + final TextStyle selectedStyle = defaults.labelStyle! + .merge(labelStyle ?? tabBarTheme.labelStyle) + .copyWith(inherit: true); + final TextStyle unselectedStyle = defaults.unselectedLabelStyle! + .merge(unselectedLabelStyle ?? tabBarTheme.unselectedLabelStyle ?? labelStyle) + .copyWith(inherit: true); + final TextStyle textStyle = isSelected + ? TextStyle.lerp(selectedStyle, unselectedStyle, animation.value)! + : TextStyle.lerp(unselectedStyle, selectedStyle, animation.value)!; + final Color defaultIconColor = switch (theme.colorScheme.brightness) { + Brightness.light => kDefaultIconDarkColor, + Brightness.dark => kDefaultIconLightColor, + }; + final IconThemeData? customIconTheme = switch (IconTheme.of(context)) { + final IconThemeData iconTheme when iconTheme.color != defaultIconColor => iconTheme, + _ => null, + }; + final Color iconColor = _resolveWithLabelColor( + context, + iconTheme: customIconTheme, + ).resolve(states); + final Color labelColor = _resolveWithLabelColor(context).resolve(states); + + return DefaultTextStyle( + style: textStyle.copyWith(color: labelColor), + child: IconTheme.merge( + data: IconThemeData(size: customIconTheme?.size ?? 24.0, color: iconColor), + child: child, + ), + ); + } +} + +typedef _LayoutCallback = + void Function(List<double> xOffsets, TextDirection textDirection, double width); + +class _TabLabelBarRenderer extends RenderFlex { + _TabLabelBarRenderer({ + required super.direction, + required super.mainAxisSize, + required super.mainAxisAlignment, + required super.crossAxisAlignment, + required TextDirection super.textDirection, + required super.verticalDirection, + required this.onPerformLayout, + }); + + _LayoutCallback onPerformLayout; + + @override + void performLayout() { + super.performLayout(); + // xOffsets will contain childCount+1 values, giving the offsets of the + // leading edge of the first tab as the first value, of the leading edge of + // the each subsequent tab as each subsequent value, and of the trailing + // edge of the last tab as the last value. + RenderBox? child = firstChild; + final xOffsets = <double>[]; + while (child != null) { + final childParentData = child.parentData! as FlexParentData; + xOffsets.add(childParentData.offset.dx); + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + assert(textDirection != null); + switch (textDirection!) { + case TextDirection.rtl: + xOffsets.insert(0, size.width); + case TextDirection.ltr: + xOffsets.add(size.width); + } + onPerformLayout(xOffsets, textDirection!, size.width); + } +} + +// This class and its renderer class only exist to report the widths of the tabs +// upon layout. The tab widths are only used at paint time (see _IndicatorPainter) +// or in response to input. +class _TabLabelBar extends Flex { + const _TabLabelBar({super.children, required this.onPerformLayout, required super.mainAxisSize}) + : super( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + verticalDirection: VerticalDirection.down, + ); + + final _LayoutCallback onPerformLayout; + + @override + RenderFlex createRenderObject(BuildContext context) { + return _TabLabelBarRenderer( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: getEffectiveTextDirection(context)!, + verticalDirection: verticalDirection, + onPerformLayout: onPerformLayout, + ); + } + + @override + void updateRenderObject(BuildContext context, _TabLabelBarRenderer renderObject) { + super.updateRenderObject(context, renderObject); + renderObject.onPerformLayout = onPerformLayout; + } +} + +double _indexChangeProgress(TabController controller) { + final double controllerValue = controller.animation!.value; + final double previousIndex = controller.previousIndex.toDouble(); + final double currentIndex = controller.index.toDouble(); + + // The controller's offset is changing because the user is dragging the + // TabBarView's PageView to the left or right. + if (!controller.indexIsChanging) { + return clampDouble((currentIndex - controllerValue).abs(), 0.0, 1.0); + } + + // The TabController animation's value is changing from previousIndex to currentIndex. + return (controllerValue - currentIndex).abs() / (currentIndex - previousIndex).abs(); +} + +class _DividerPainter extends CustomPainter { + _DividerPainter({required this.dividerColor, required this.dividerHeight}); + + final Color dividerColor; + final double dividerHeight; + + @override + void paint(Canvas canvas, Size size) { + if (dividerHeight <= 0.0) { + return; + } + + final paint = Paint() + ..color = dividerColor + ..strokeWidth = dividerHeight; + + canvas.drawLine( + Offset(0, size.height - (paint.strokeWidth / 2)), + Offset(size.width, size.height - (paint.strokeWidth / 2)), + paint, + ); + } + + @override + bool shouldRepaint(_DividerPainter oldDelegate) { + return oldDelegate.dividerColor != dividerColor || oldDelegate.dividerHeight != dividerHeight; + } +} + +// A ChangeNotifier for triggering repaints when async resources load. +class _IndicatorPainterNotifier extends ChangeNotifier { + void notify() { + notifyListeners(); + } + + @override + String toString() => describeIdentity(this); +} + +class _IndicatorPainter extends CustomPainter { + factory _IndicatorPainter({ + required TabController controller, + required Decoration indicator, + required TabBarIndicatorSize indicatorSize, + required List<GlobalKey> tabKeys, + required _IndicatorPainter? old, + required EdgeInsetsGeometry indicatorPadding, + required List<EdgeInsetsGeometry> labelPaddings, + Color? dividerColor, + double? dividerHeight, + required bool showDivider, + double? devicePixelRatio, + required TabIndicatorAnimation indicatorAnimation, + required TextDirection textDirection, + }) { + /// Initializing [_IndicatorPainterNotifier] here that allows the + /// repaint notifier to be used in the super constructor call + /// (within [Listenable.merge]) while also being stored as a private field. + /// + /// The notifier is to trigger a repaint when asynchronous resources, + /// like images in the indicator [Decoration], are finished loading. + return _IndicatorPainter._( + controller: controller, + indicator: indicator, + indicatorSize: indicatorSize, + tabKeys: tabKeys, + old: old, + indicatorPadding: indicatorPadding, + labelPaddings: labelPaddings, + dividerColor: dividerColor, + dividerHeight: dividerHeight, + showDivider: showDivider, + devicePixelRatio: devicePixelRatio, + indicatorAnimation: indicatorAnimation, + textDirection: textDirection, + repaint: _IndicatorPainterNotifier(), + ); + } + + _IndicatorPainter._({ + required this.controller, + required this.indicator, + required this.indicatorSize, + required this.tabKeys, + required _IndicatorPainter? old, + required this.indicatorPadding, + required this.labelPaddings, + this.dividerColor, + this.dividerHeight, + required this.showDivider, + this.devicePixelRatio, + required this.indicatorAnimation, + required this.textDirection, + required _IndicatorPainterNotifier repaint, + }) : _repaint = repaint, + super(repaint: Listenable.merge(<Listenable?>[controller.animation, repaint])) { + assert(debugMaybeDispatchCreated('material', '_IndicatorPainter', this)); + if (old != null) { + saveTabOffsets(old._currentTabOffsets, old._currentTextDirection); + } + } + + final TabController controller; + final Decoration indicator; + final TabBarIndicatorSize indicatorSize; + final EdgeInsetsGeometry indicatorPadding; + final List<GlobalKey> tabKeys; + final List<EdgeInsetsGeometry> labelPaddings; + final Color? dividerColor; + final double? dividerHeight; + final bool showDivider; + final double? devicePixelRatio; + final TabIndicatorAnimation indicatorAnimation; + final TextDirection textDirection; + final _IndicatorPainterNotifier _repaint; + + // _currentTabOffsets and _currentTextDirection are set each time TabBar + // layout is completed. These values can be null when TabBar contains no + // tabs, since there are nothing to lay out. + List<double>? _currentTabOffsets; + TextDirection? _currentTextDirection; + + Rect? _currentRect; + BoxPainter? _painter; + bool _needsPaint = false; + void markNeedsPaint() { + _needsPaint = true; + _repaint.notify(); + } + + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + _painter?.dispose(); + _repaint.dispose(); + } + + void saveTabOffsets(List<double>? tabOffsets, TextDirection? textDirection) { + _currentTabOffsets = tabOffsets; + _currentTextDirection = textDirection; + } + + // _currentTabOffsets[index] is the offset of the start edge of the tab at index, and + // _currentTabOffsets[_currentTabOffsets.length] is the end edge of the last tab. + int get maxTabIndex => _currentTabOffsets!.length - 2; + + double centerOf(int tabIndex) { + assert(_currentTabOffsets != null); + assert(_currentTabOffsets!.isNotEmpty); + assert(tabIndex >= 0); + assert(tabIndex <= maxTabIndex); + return (_currentTabOffsets![tabIndex] + _currentTabOffsets![tabIndex + 1]) / 2.0; + } + + Rect indicatorRect(Size tabBarSize, int tabIndex) { + assert(_currentTabOffsets != null); + assert(_currentTextDirection != null); + assert(_currentTabOffsets!.isNotEmpty); + assert(tabIndex >= 0); + assert(tabIndex <= maxTabIndex); + double tabLeft, tabRight; + (tabLeft, tabRight) = switch (_currentTextDirection!) { + TextDirection.rtl => (_currentTabOffsets![tabIndex + 1], _currentTabOffsets![tabIndex]), + TextDirection.ltr => (_currentTabOffsets![tabIndex], _currentTabOffsets![tabIndex + 1]), + }; + + if (indicatorSize == TabBarIndicatorSize.label) { + final double tabWidth = tabKeys[tabIndex].currentContext!.size!.width; + final EdgeInsetsGeometry labelPadding = labelPaddings[tabIndex]; + final EdgeInsets insets = labelPadding.resolve(_currentTextDirection); + final double delta = ((tabRight - tabLeft) - (tabWidth + insets.horizontal)) / 2.0; + tabLeft += delta + insets.left; + tabRight = tabLeft + tabWidth; + } + + final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection); + final rect = Rect.fromLTWH(tabLeft, 0.0, tabRight - tabLeft, tabBarSize.height); + + if (!(rect.size >= insets.collapsedSize)) { + throw FlutterError( + 'indicatorPadding insets should be less than Tab Size\n' + 'Rect Size : ${rect.size}, Insets: $insets', + ); + } + return insets.deflateRect(rect); + } + + @override + void paint(Canvas canvas, Size size) { + _needsPaint = false; + _painter ??= indicator.createBoxPainter(markNeedsPaint); + + final double value = controller.animation!.value; + + _currentRect = switch (indicatorAnimation) { + TabIndicatorAnimation.linear => _applyLinearEffect(size: size, value: value), + TabIndicatorAnimation.elastic => _applyElasticEffect(size: size, value: value), + }; + + assert(_currentRect != null); + + final configuration = ImageConfiguration( + size: _currentRect!.size, + textDirection: _currentTextDirection, + devicePixelRatio: devicePixelRatio, + ); + if (showDivider && dividerHeight! > 0) { + final dividerPaint = Paint() + ..color = dividerColor! + ..strokeWidth = dividerHeight!; + final dividerP1 = Offset(0, size.height - (dividerPaint.strokeWidth / 2)); + final dividerP2 = Offset(size.width, size.height - (dividerPaint.strokeWidth / 2)); + canvas.drawLine(dividerP1, dividerP2, dividerPaint); + } + _painter!.paint(canvas, _currentRect!.topLeft, configuration); + } + + /// Applies the linear effect to the indicator. + Rect? _applyLinearEffect({required Size size, required double value}) { + final double index = controller.index.toDouble(); + final bool ltr = index > value; + final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex); + final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex); + final Rect fromRect = indicatorRect(size, from); + final Rect toRect = indicatorRect(size, to); + return Rect.lerp(fromRect, toRect, (value - from).abs()); + } + + // Ease out sine (decelerating). + double decelerateInterpolation(double fraction) { + return math.sin((fraction * math.pi) / 2.0); + } + + // Ease in sine (accelerating). + double accelerateInterpolation(double fraction) { + return 1.0 - math.cos((fraction * math.pi) / 2.0); + } + + /// Applies the elastic effect to the indicator. + Rect? _applyElasticEffect({required Size size, required double value}) { + final double index = controller.index.toDouble(); + double progressLeft = (index - value).abs(); + + final int to = progressLeft == 0.0 || !controller.indexIsChanging + ? switch (textDirection) { + TextDirection.ltr => value.ceil(), + TextDirection.rtl => value.floor(), + }.clamp(0, maxTabIndex) + : controller.index; + final int from = progressLeft == 0.0 || !controller.indexIsChanging + ? switch (textDirection) { + TextDirection.ltr => (to - 1), + TextDirection.rtl => (to + 1), + }.clamp(0, maxTabIndex) + : controller.previousIndex; + final Rect toRect = indicatorRect(size, to); + final Rect fromRect = indicatorRect(size, from); + final Rect rect = Rect.lerp(fromRect, toRect, (value - from).abs())!; + + // If the tab animation is completed, there is no need to stretch the indicator + // This only works for the tab change animation via tab index, not when + // dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations. + if (controller.animation!.isCompleted) { + return rect; + } + + final double tabChangeProgress; + + if (controller.indexIsChanging) { + final int tabsDelta = (controller.index - controller.previousIndex).abs(); + if (tabsDelta != 0) { + progressLeft /= tabsDelta; + } + tabChangeProgress = 1 - clampDouble(progressLeft, 0.0, 1.0); + } else { + tabChangeProgress = (index - value).abs(); + } + + // If the animation has finished, there is no need to apply the stretch effect. + if (tabChangeProgress == 1.0) { + return rect; + } + + final double leftFraction; + final double rightFraction; + final bool isMovingRight = switch (textDirection) { + TextDirection.ltr => controller.indexIsChanging ? index > value : value > index, + TextDirection.rtl => controller.indexIsChanging ? value > index : index > value, + }; + if (isMovingRight) { + leftFraction = accelerateInterpolation(tabChangeProgress); + rightFraction = decelerateInterpolation(tabChangeProgress); + } else { + leftFraction = decelerateInterpolation(tabChangeProgress); + rightFraction = accelerateInterpolation(tabChangeProgress); + } + + final double lerpRectLeft; + final double lerpRectRight; + + // The controller.indexIsChanging is true when the Tab is pressed, instead of swipe to change tabs. + // If the tab is pressed then only lerp between fromRect and toRect. + if (controller.indexIsChanging) { + lerpRectLeft = lerpDouble(fromRect.left, toRect.left, leftFraction)!; + lerpRectRight = lerpDouble(fromRect.right, toRect.right, rightFraction)!; + } else { + // Switch the Rect left and right lerp order based on swipe direction. + lerpRectLeft = switch (isMovingRight) { + true => lerpDouble(fromRect.left, toRect.left, leftFraction)!, + false => lerpDouble(toRect.left, fromRect.left, leftFraction)!, + }; + lerpRectRight = switch (isMovingRight) { + true => lerpDouble(fromRect.right, toRect.right, rightFraction)!, + false => lerpDouble(toRect.right, fromRect.right, rightFraction)!, + }; + } + + return Rect.fromLTRB(lerpRectLeft, rect.top, lerpRectRight, rect.bottom); + } + + @override + bool shouldRepaint(_IndicatorPainter old) { + return _needsPaint || + controller != old.controller || + indicator != old.indicator || + tabKeys.length != old.tabKeys.length || + (!listEquals(_currentTabOffsets, old._currentTabOffsets)) || + _currentTextDirection != old._currentTextDirection; + } +} + +class _ChangeAnimation extends Animation<double> with AnimationWithParentMixin<double> { + _ChangeAnimation(this.controller); + + final TabController controller; + + @override + Animation<double> get parent => controller.animation!; + + @override + void removeStatusListener(AnimationStatusListener listener) { + if (controller.animation != null) { + super.removeStatusListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + if (controller.animation != null) { + super.removeListener(listener); + } + } + + @override + double get value => _indexChangeProgress(controller); +} + +class _DragAnimation extends Animation<double> with AnimationWithParentMixin<double> { + _DragAnimation(this.controller, this.index); + + final TabController controller; + final int index; + + @override + Animation<double> get parent => controller.animation!; + + @override + void removeStatusListener(AnimationStatusListener listener) { + if (controller.animation != null) { + super.removeStatusListener(listener); + } + } + + @override + void removeListener(VoidCallback listener) { + if (controller.animation != null) { + super.removeListener(listener); + } + } + + @override + double get value { + assert(!controller.indexIsChanging); + final double controllerMaxValue = (controller.length - 1).toDouble(); + final double controllerValue = clampDouble( + controller.animation!.value, + 0.0, + controllerMaxValue, + ); + return clampDouble((controllerValue - index.toDouble()).abs(), 0.0, 1.0); + } +} + +// This class, and TabBarScrollController, only exist to handle the case +// where a scrollable TabBar has a non-zero initialIndex. In that case we can +// only compute the scroll position's initial scroll offset (the "correct" +// pixels value) after the TabBar viewport width and scroll limits are known. +class _TabBarScrollPosition extends ScrollPositionWithSingleContext { + _TabBarScrollPosition({ + required super.physics, + required super.context, + required super.oldPosition, + required this.tabBar, + }) : super(initialPixels: null); + + final _TabBarState tabBar; + + bool _viewportDimensionWasNonZero = false; + + // The scroll position should be adjusted at least once. + bool _needsPixelsCorrection = true; + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + var result = true; + if (!_viewportDimensionWasNonZero) { + _viewportDimensionWasNonZero = viewportDimension != 0.0; + } + // If the viewport never had a non-zero dimension, we just want to jump + // to the initial scroll position to avoid strange scrolling effects in + // release mode: the viewport temporarily may have a dimension of zero + // before the actual dimension is calculated. In that scenario, setting + // the actual dimension would cause a strange scroll effect without this + // guard because the super call below would start a ballistic scroll activity. + if (!_viewportDimensionWasNonZero || _needsPixelsCorrection) { + _needsPixelsCorrection = false; + correctPixels( + tabBar._initialScrollOffset(viewportDimension, minScrollExtent, maxScrollExtent), + ); + result = false; + } + return super.applyContentDimensions(minScrollExtent, maxScrollExtent) && result; + } + + void markNeedsPixelsCorrection() { + _needsPixelsCorrection = true; + } +} + +/// The [ScrollController] for a [TabBar] widget. +final class TabBarScrollController extends ScrollController { + /// The state of the [TabBar] widget to which this controller is attached. + /// + /// Is null if this controller is not attached to a [TabBar]. + _TabBarState? _tabBarState; + + /// Asserts that this controller is currently attached to a [TabBar]'s [State]. + /// + /// To invoke this function, wrap it in an assert: `assert(debugCheckHasTabBarState());` + /// + /// Does nothing if asserts are disabled. Always returns true. + bool debugCheckHasTabBarState() { + assert(_tabBarState != null, 'This TabBarScrollController is not attached to any TabBar.'); + + return true; + } + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + assert(debugCheckHasTabBarState()); + + return _TabBarScrollPosition( + physics: physics, + context: context, + oldPosition: oldPosition, + tabBar: _tabBarState!, + ); + } + + @override + void dispose() { + _tabBarState = null; + super.dispose(); + } +} + +/// Signature for [TabBar] callbacks that report that an underlying value has +/// changed for a given [Tab] at `index`. +/// +/// Used for [TabBar.onHover] and [TabBar.onFocusChange] callbacks The provided +/// `value` being true indicates focus has been gained, or a pointer has hovered +/// over the tab, with false indicated focus has been lost or the pointer has +/// exited hovering. +typedef TabValueChanged<T> = void Function(T value, int index); + +/// A Material Design primary tab bar. +/// +/// Primary tabs are placed at the top of the content pane under a top app bar. +/// They display the main content destinations. +/// +/// Typically created as the [AppBar.bottom] part of an [AppBar] and in +/// conjunction with a [TabBarView]. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40} +/// +/// If a [TabController] is not provided, then a [DefaultTabController] ancestor +/// must be provided instead. The tab controller's [TabController.length] must +/// equal the length of the [tabs] list and the length of the +/// [TabBarView.children] list. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// Uses values from [TabBarThemeData] if it is set in the current context. +/// +/// {@tool dartpad} +/// This sample shows the implementation of [TabBar] and [TabBarView] using a [DefaultTabController]. +/// Each [Tab] corresponds to a child of the [TabBarView] in the order they are written. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// [TabBar] can also be implemented by using a [TabController] which provides more options +/// to control the behavior of the [TabBar] and [TabBarView]. This can be used instead of +/// a [DefaultTabController], demonstrated below. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases nested Material 3 [TabBar]s. It consists of a primary +/// [TabBar] with nested a secondary [TabBar]. The primary [TabBar] uses a +/// [DefaultTabController] while the secondary [TabBar] uses a [TabController]. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases how to apply custom behavior based on the scroll in [TabBar]. +/// It utilizes scroll notifications ([ScrollMetricsNotification] +/// and [ScrollNotification]) within [NotificationListener] callback +/// to monitor the scroll offset, allowing for interface customization +/// based on the obtained offset. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TabBar.secondary], for a secondary tab bar. +/// * [TabBarView], which displays page views that correspond to each tab. +/// * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView]. +/// * https://m3.material.io/components/tabs/overview, the Material 3 +/// tab bar specification. +class TabBar extends StatefulWidget implements PreferredSizeWidget { + /// Creates a Material Design primary tab bar. + /// + /// The length of the [tabs] argument must match the [controller]'s + /// [TabController.length]. + /// + /// If a [TabController] is not provided, then there must be a + /// [DefaultTabController] ancestor. + /// + /// The [indicatorWeight] parameter defaults to 2. + /// + /// The [indicatorPadding] parameter defaults to [EdgeInsets.zero]. + /// + /// If [indicator] is not null or provided from [TabBarTheme], + /// then [indicatorColor] is ignored. + /// + /// The [indicatorWeight] does not affect the visual appearance of + /// the indicator when a custom [indicator] is provided. However, + /// it may still be used to compute the TabBar's preferred size. + const TabBar({ + super.key, + required this.tabs, + this.controller, + this.scrollController, + this.isScrollable = false, + this.padding, + this.indicatorColor, + this.automaticIndicatorColorAdjustment = true, + this.indicatorWeight = 2.0, + this.indicatorPadding = EdgeInsets.zero, + this.indicator, + this.indicatorSize, + this.dividerColor, + this.dividerHeight, + this.labelColor, + this.labelStyle, + this.labelPadding, + this.unselectedLabelColor, + this.unselectedLabelStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.overlayColor, + this.mouseCursor, + this.enableFeedback, + this.onTap, + this.onHover, + this.onFocusChange, + this.physics, + this.splashFactory, + this.splashBorderRadius, + this.tabAlignment, + this.textScaler, + this.indicatorAnimation, + }) : _isPrimary = true, + assert(indicator != null || (indicatorWeight > 0.0)); + + /// Creates a Material Design secondary tab bar. + /// + /// Secondary tabs are used within a content area to further separate related + /// content and establish hierarchy. + /// + /// {@tool dartpad} + /// This sample showcases nested Material 3 [TabBar]s. It consists of a primary + /// [TabBar] with nested a secondary [TabBar]. The primary [TabBar] uses a + /// [DefaultTabController] while the secondary [TabBar] uses a [TabController]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TabBar], for a primary tab bar. + /// * [TabBarView], which displays page views that correspond to each tab. + /// * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView]. + /// * https://m3.material.io/components/tabs/overview, the Material 3 + /// tab bar specification. + const TabBar.secondary({ + super.key, + required this.tabs, + this.controller, + this.scrollController, + this.isScrollable = false, + this.padding, + this.indicatorColor, + this.automaticIndicatorColorAdjustment = true, + this.indicatorWeight = 2.0, + this.indicatorPadding = EdgeInsets.zero, + this.indicator, + this.indicatorSize, + this.dividerColor, + this.dividerHeight, + this.labelColor, + this.labelStyle, + this.labelPadding, + this.unselectedLabelColor, + this.unselectedLabelStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.overlayColor, + this.mouseCursor, + this.enableFeedback, + this.onTap, + this.onHover, + this.onFocusChange, + this.physics, + this.splashFactory, + this.splashBorderRadius, + this.tabAlignment, + this.textScaler, + this.indicatorAnimation, + }) : _isPrimary = false, + assert(indicator != null || (indicatorWeight > 0.0)); + + /// Typically a list of two or more [Tab] widgets. + /// + /// The length of this list must match the [controller]'s [TabController.length] + /// and the length of the [TabBarView.children] list. + final List<Widget> tabs; + + /// This widget's selection and animation state. + /// + /// If [TabController] is not provided, then the value of [DefaultTabController.of] + /// will be used. + final TabController? controller; + + /// The [TabBarScrollController] for this [TabBar]. + /// + /// This controller can be used to manipulate the scroll position of the [TabBar]. + final TabBarScrollController? scrollController; + + /// Whether this tab bar can be scrolled horizontally. + /// + /// If [isScrollable] is true, then each tab is as wide as needed for its label + /// and the entire [TabBar] is scrollable. Otherwise each tab gets an equal + /// share of the available space. + final bool isScrollable; + + /// The amount of space by which to inset the tab bar. + /// + /// When [isScrollable] is false, this will yield the same result as if [TabBar] was wrapped + /// in a [Padding] widget. When [isScrollable] is true, the scrollable itself is inset, + /// allowing the padding to scroll with the tab bar, rather than enclosing it. + final EdgeInsetsGeometry? padding; + + /// The color of the line that appears below the selected tab. + /// + /// If this parameter is null, then the value of the Theme's indicatorColor + /// property is used. + /// + /// If [indicator] is specified or provided from [TabBarThemeData], + /// this property is ignored. + final Color? indicatorColor; + + /// The thickness of the line that appears below the selected tab. + /// + /// The value of this parameter must be greater than zero. + /// + /// If [ThemeData.useMaterial3] is true and [TabBar] is used to create a + /// primary tab bar, the default value is 3.0. If the provided value is less + /// than 3.0, the default value is used. + /// + /// If [ThemeData.useMaterial3] is true and [TabBar.secondary] is used to + /// create a secondary tab bar, the default value is 2.0. + /// + /// If [ThemeData.useMaterial3] is false, the default value is 2.0. + /// + /// If [indicator] is specified or provided from [TabBarThemeData], + /// this property is ignored. + final double indicatorWeight; + + /// The padding for the indicator. + /// + /// The default value of this property is [EdgeInsets.zero]. + /// + /// For [isScrollable] tab bars, specifying [kTabLabelPadding] will align + /// the indicator with the tab's text for [Tab] widgets and all but the + /// shortest [Tab.text] values. + final EdgeInsetsGeometry indicatorPadding; + + /// Defines the appearance of the selected tab indicator. + /// + /// If [indicator] is specified or provided from [TabBarThemeData], + /// the [indicatorColor] and [indicatorWeight] properties are ignored. + /// + /// The default, underline-style, selected tab indicator can be defined with + /// [UnderlineTabIndicator]. + /// + /// The indicator's size is based on the tab's bounds. If [indicatorSize] + /// is [TabBarIndicatorSize.tab] the tab's bounds are as wide as the space + /// occupied by the tab in the tab bar. If [indicatorSize] is + /// [TabBarIndicatorSize.label], then the tab's bounds are only as wide as + /// the tab widget itself. + /// + /// See also: + /// + /// * [splashBorderRadius], which defines the clipping radius of the splash + /// and is generally used with [BoxDecoration.borderRadius]. + final Decoration? indicator; + + /// Whether this tab bar should automatically adjust the [indicatorColor]. + /// + /// The default value of this property is true. + /// + /// If [automaticIndicatorColorAdjustment] is true, + /// then the [indicatorColor] will be automatically adjusted to [Colors.white] + /// when the [indicatorColor] is same as [Material.color] of the [Material] + /// parent widget. + final bool automaticIndicatorColorAdjustment; + + /// Defines how the selected tab indicator's size is computed. + /// + /// The size of the selected tab indicator is defined relative to the + /// tab's overall bounds if [indicatorSize] is [TabBarIndicatorSize.tab] + /// (the default) or relative to the bounds of the tab's widget if + /// [indicatorSize] is [TabBarIndicatorSize.label]. + /// + /// The selected tab's location appearance can be refined further with + /// the [indicatorColor], [indicatorWeight], [indicatorPadding], and + /// [indicator] properties. + final TabBarIndicatorSize? indicatorSize; + + /// The color of the divider. + /// + /// If the [dividerColor] is [Colors.transparent], then the divider will not be drawn. + /// + /// If null and [ThemeData.useMaterial3] is false, [TabBarThemeData.dividerColor] + /// color is used. If that is null and [ThemeData.useMaterial3] is true, + /// [ColorScheme.outlineVariant] will be used, otherwise divider will not be drawn. + final Color? dividerColor; + + /// The height of the divider. + /// + /// If the [dividerHeight] is zero or negative, then the divider will not be drawn. + /// + /// If null and [ThemeData.useMaterial3] is true, [TabBarThemeData.dividerHeight] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, 1.0 will be used. + /// Otherwise divider will not be drawn. + final double? dividerHeight; + + /// The color of selected tab labels. + /// + /// If null, then [TabBarThemeData.labelColor] is used. If that is also null and + /// [ThemeData.useMaterial3] is true, [ColorScheme.primary] will be used, + /// otherwise the color of the [ThemeData.primaryTextTheme]'s + /// [TextTheme.bodyLarge] text color is used. + /// + /// If [labelColor] (or, if null, [TabBarThemeData.labelColor]) is a + /// [WidgetStateColor], then the effective tab color will depend on the + /// [WidgetState.selected] state, i.e. if the [Tab] is selected or not, + /// ignoring [unselectedLabelColor] even if it's non-null. + /// + /// When this color or the [TabBarThemeData.labelColor] is specified, it overrides + /// the [TextStyle.color] specified for the [labelStyle] or the + /// [TabBarThemeData.labelStyle]. + /// + /// See also: + /// + /// * [unselectedLabelColor], for color of unselected tab labels. + final Color? labelColor; + + /// The color of unselected tab labels. + /// + /// If [labelColor] (or, if null, [TabBarThemeData.labelColor]) is a + /// [WidgetStateColor], then the unselected tabs are rendered with + /// that [WidgetStateColor]'s resolved color for unselected state, even if + /// [unselectedLabelColor] is non-null. + /// + /// If null, then [TabBarThemeData.unselectedLabelColor] is used. If that is also + /// null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant] + /// will be used, otherwise unselected tab labels are rendered with + /// [labelColor] at 70% opacity. + /// + /// When this color or the [TabBarThemeData.unselectedLabelColor] is specified, it + /// overrides the [TextStyle.color] specified for the [unselectedLabelStyle] + /// or the [TabBarThemeData.unselectedLabelStyle]. + /// + /// See also: + /// + /// * [labelColor], for color of selected tab labels. + final Color? unselectedLabelColor; + + /// The text style of the selected tab labels. + /// + /// The color specified in [labelStyle] and [TabBarThemeData.labelStyle] is used + /// to style the label when [labelColor] or [TabBarThemeData.labelColor] are not + /// specified. + /// + /// If [unselectedLabelStyle] is null, then this text style will be used for + /// both selected and unselected label styles. + /// + /// If this property is null, then [TabBarThemeData.labelStyle] will be used. + /// + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall] + /// will be used, otherwise the text style of the [ThemeData.primaryTextTheme]'s + /// [TextTheme.bodyLarge] definition is used. + final TextStyle? labelStyle; + + /// The text style of the unselected tab labels. + /// + /// The color specified in [unselectedLabelStyle] and [TabBarThemeData.unselectedLabelStyle] + /// is used to style the label when [unselectedLabelColor] or [TabBarThemeData.unselectedLabelColor] + /// are not specified. + /// + /// If this property is null, then [TabBarThemeData.unselectedLabelStyle] will be used. + /// + /// If that is also null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall] + /// will be used, otherwise then the [labelStyle] value is used. If [labelStyle] is null, + /// the text style of the [ThemeData.primaryTextTheme]'s [TextTheme.bodyLarge] + /// definition is used. + final TextStyle? unselectedLabelStyle; + + /// The padding added to each of the tab labels. + /// + /// If there are few tabs with both icon and text and few + /// tabs with only icon or text, this padding is vertically + /// adjusted to provide uniform padding to all tabs. + /// + /// If this property is null, then [kTabLabelPadding] is used. + final EdgeInsetsGeometry? labelPadding; + + /// Defines the ink response focus, hover, and splash colors. + /// + /// If non-null, it is resolved against one of [WidgetState.focused], + /// [WidgetState.hovered], and [WidgetState.pressed]. + /// + /// [WidgetState.pressed] triggers a ripple (an ink splash), per + /// the current Material Design spec. + /// + /// If the overlay color is null or resolves to null, then if [ThemeData.useMaterial3] is + /// false, the default values for [InkResponse.focusColor], [InkResponse.hoverColor], [InkResponse.splashColor], + /// and [InkResponse.highlightColor] will be used instead. If [ThemeData.useMaterial3] + /// if true, the default values are: + /// * selected: + /// * pressed - ThemeData.colorScheme.primary(0.1) + /// * hovered - ThemeData.colorScheme.primary(0.08) + /// * focused - ThemeData.colorScheme.primary(0.1) + /// * pressed - ThemeData.colorScheme.primary(0.1) + /// * hovered - ThemeData.colorScheme.onSurface(0.08) + /// * focused - ThemeData.colorScheme.onSurface(0.1) + final WidgetStateProperty<Color?>? overlayColor; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.material.tabs.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// individual tab widgets. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.selected]. + /// {@endtemplate} + /// + /// If null, then the value of [TabBarThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a long-press + /// will produce a short vibration, when feedback is enabled. + /// + /// Defaults to true. + final bool? enableFeedback; + + /// An optional callback that's called when the [TabBar] is tapped. + /// + /// The callback is applied to the index of the tab where the tap occurred. + /// + /// This callback has no effect on the default handling of taps. It's for + /// applications that want to do a little extra work when a tab is tapped, + /// even if the tap doesn't change the TabController's index. TabBar [onTap] + /// callbacks should not make changes to the TabController since that would + /// interfere with the default tap handler. + final ValueChanged<int>? onTap; + + /// An optional callback that's called when a [Tab]'s hover state in the + /// [TabBar] changes. + /// + /// Called when a pointer enters or exits the ink response area of the [Tab]. + /// + /// The value passed to the callback is true if a pointer has entered the + /// [Tab] at `index` and false if a pointer has exited. + /// + /// When hover is moved from one tab directly to another, this will be called + /// twice. First to represent hover exiting the initial tab, and then second + /// for the pointer entering hover over the next tab. + /// + /// {@tool dartpad} + /// This sample shows how to customize a [Tab] in response to hovering over a + /// [TabBar]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.onHover.dart ** + /// {@end-tool} + final TabValueChanged<bool>? onHover; + + /// An optional callback that's called when a [Tab]'s focus state in the + /// [TabBar] changes. + /// + /// Called when the node for the [Tab] at `index` gains or loses focus. + /// + /// The value passed to the callback is true if the node has gained focus for + /// the [Tab] at `index` and false if focus has been lost. + /// + /// When focus is moved from one tab directly to another, this will be called + /// twice. First to represent focus being lost by the initially focused tab, + /// and then second for the next tab gaining focus. + /// + /// {@tool dartpad} + /// This sample shows how to customize a [Tab] based on focus traversal in + /// enclosing [TabBar]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.onFocusChange.dart ** + /// {@end-tool} + final TabValueChanged<bool>? onFocusChange; + + /// How the [TabBar]'s scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// Creates the tab bar's [InkWell] splash factory, which defines + /// the appearance of "ink" splashes that occur in response to taps. + /// + /// Use [NoSplash.splashFactory] to defeat ink splash rendering. For example + /// to defeat both the splash and the hover/pressed overlay, but not the + /// keyboard focused overlay: + /// + /// ```dart + /// TabBar( + /// splashFactory: NoSplash.splashFactory, + /// overlayColor: WidgetStateProperty.resolveWith<Color?>( + /// (Set<WidgetState> states) { + /// return states.contains(WidgetState.focused) ? null : Colors.transparent; + /// }, + /// ), + /// tabs: const <Widget>[ + /// // ... + /// ], + /// ) + /// ``` + final InteractiveInkFeatureFactory? splashFactory; + + /// Defines the clipping radius of splashes that extend outside the bounds of the tab. + /// + /// This can be useful to match the [BoxDecoration.borderRadius] provided as [indicator]. + /// + /// ```dart + /// TabBar( + /// indicator: BoxDecoration( + /// borderRadius: BorderRadius.circular(40), + /// ), + /// splashBorderRadius: BorderRadius.circular(40), + /// tabs: const <Widget>[ + /// // ... + /// ], + /// ) + /// ``` + /// + /// If this property is null, it is interpreted as [BorderRadius.zero]. + final BorderRadius? splashBorderRadius; + + /// Specifies the horizontal alignment of the tabs within a [TabBar]. + /// + /// If [TabBar.isScrollable] is false, only [TabAlignment.fill] and + /// [TabAlignment.center] are supported. Otherwise an exception is thrown. + /// + /// If [TabBar.isScrollable] is true, only [TabAlignment.start], [TabAlignment.startOffset], + /// and [TabAlignment.center] are supported. Otherwise an exception is thrown. + /// + /// If this is null, then the value of [TabBarThemeData.tabAlignment] is used. + /// + /// If [TabBarThemeData.tabAlignment] is null and [ThemeData.useMaterial3] is true, + /// then [TabAlignment.startOffset] is used if [isScrollable] is true, + /// otherwise [TabAlignment.fill] is used. + /// + /// If [TabBarThemeData.tabAlignment] is null and [ThemeData.useMaterial3] is false, + /// then [TabAlignment.center] is used if [isScrollable] is true, + /// otherwise [TabAlignment.fill] is used. + final TabAlignment? tabAlignment; + + /// Specifies the text scaling behavior for the [Tab] label. + /// + /// If this is null, then the value of [TabBarThemeData.textScaler] is used. If that is + /// also null, then the text scaling behavior is determined by the [MediaQueryData.textScaler] + /// from the ambient [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. + /// + /// See also: + /// * [TextScaler], which is used to scale text based on the device's text scale factor. + final TextScaler? textScaler; + + /// Specifies the animation behavior of the tab indicator. + /// + /// If this is null, then the value of [TabBarThemeData.indicatorAnimation] is used. + /// If that is also null, then the tab indicator will animate linearly if the + /// [indicatorSize] is [TabBarIndicatorSize.tab], otherwise it will animate + /// with an elastic effect if the [indicatorSize] is [TabBarIndicatorSize.label]. + /// + /// {@tool dartpad} + /// This sample shows how to customize the animation behavior of the tab indicator + /// by using the [indicatorAnimation] property. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TabIndicatorAnimation], which specifies the animation behavior of the tab indicator. + final TabIndicatorAnimation? indicatorAnimation; + + /// A size whose height depends on if the tabs have both icons and text. + /// + /// [AppBar] uses this size to compute its own preferred size. + @override + Size get preferredSize { + double maxHeight = _kTabHeight; + for (final Widget item in tabs) { + if (item is PreferredSizeWidget) { + final double itemHeight = item.preferredSize.height; + maxHeight = math.max(itemHeight, maxHeight); + } + } + return Size.fromHeight(maxHeight + indicatorWeight); + } + + /// Returns whether the [TabBar] contains a tab with both text and icon. + /// + /// [TabBar] uses this to give uniform padding to all tabs in cases where + /// there are some tabs with both text and icon and some which contain only + /// text or icon. + bool get tabHasTextAndIcon { + for (final Widget item in tabs) { + if (item is PreferredSizeWidget) { + if (item.preferredSize.height == _kTextAndIconTabHeight) { + return true; + } + } + } + return false; + } + + /// Whether this tab bar is a primary tab bar. + /// + /// Otherwise, it is a secondary tab bar. + final bool _isPrimary; + + @override + State<TabBar> createState() => _TabBarState(); +} + +class _TabBarState extends State<TabBar> { + TabBarScrollController? _internalScrollController; + TabController? _controller; + _IndicatorPainter? _indicatorPainter; + int? _currentIndex; + late double _tabStripWidth; + late List<GlobalKey> _tabKeys; + late List<EdgeInsetsGeometry> _labelPaddings; + bool _debugHasScheduledValidTabsCountCheck = false; + + @override + void initState() { + super.initState(); + // If indicatorSize is TabIndicatorSize.label, _tabKeys[i] is used to find + // the width of tab widget i. See _IndicatorPainter.indicatorRect(). + _tabKeys = widget.tabs.map((Widget tab) => GlobalKey()).toList(); + _labelPaddings = List<EdgeInsetsGeometry>.filled( + widget.tabs.length, + EdgeInsets.zero, + growable: true, + ); + } + + TabBarThemeData get _defaults { + if (Theme.of(context).useMaterial3) { + return widget._isPrimary + ? _TabsPrimaryDefaultsM3(context, widget.isScrollable) + : _TabsSecondaryDefaultsM3(context, widget.isScrollable); + } else { + return _TabsDefaultsM2(context, widget.isScrollable); + } + } + + TabBarScrollController get _effectiveScrollController { + if (widget.scrollController != null) { + _internalScrollController?.dispose(); + _internalScrollController = null; + + return widget.scrollController!; + } + + return _internalScrollController ??= TabBarScrollController(); + } + + Decoration _getIndicator(TabBarIndicatorSize indicatorSize) { + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + + if (widget.indicator != null) { + return widget.indicator!; + } + if (tabBarTheme.indicator != null) { + return tabBarTheme.indicator!; + } + + Color color = widget.indicatorColor ?? tabBarTheme.indicatorColor ?? _defaults.indicatorColor!; + // ThemeData tries to avoid this by having indicatorColor avoid being the + // primaryColor. However, it's possible that the tab bar is on a + // Material that isn't the primaryColor. In that case, if the indicator + // color ends up matching the material's color, then this overrides it. + // When that happens, automatic transitions of the theme will likely look + // ugly as the indicator color suddenly snaps to white at one end, but it's + // not clear how to avoid that any further. + // + // The material's color might be null (if it's a transparency). In that case + // there's no good way for us to find out what the color is so we don't. + // + // TODO(xu-baolin): Remove automatic adjustment to white color indicator + // with a better long-term solution. + // https://github.com/flutter/flutter/pull/68171#pullrequestreview-517753917 + if (widget.automaticIndicatorColorAdjustment && + color.value == Material.maybeOf(context)?.color?.value) { + color = Colors.white; + } + + final double effectiveIndicatorWeight = theme.useMaterial3 + ? math.max(widget.indicatorWeight, switch (widget._isPrimary) { + true => _TabsPrimaryDefaultsM3.indicatorWeight(indicatorSize), + false => _TabsSecondaryDefaultsM3.indicatorWeight, + }) + : widget.indicatorWeight; + // Only Material 3 primary TabBar with label indicatorSize should be rounded. + final bool primaryWithLabelIndicator = switch (indicatorSize) { + TabBarIndicatorSize.label => widget._isPrimary, + TabBarIndicatorSize.tab => false, + }; + final BorderRadius? effectiveBorderRadius = theme.useMaterial3 && primaryWithLabelIndicator + ? BorderRadius.only( + topLeft: Radius.circular(effectiveIndicatorWeight), + topRight: Radius.circular(effectiveIndicatorWeight), + ) + : null; + return UnderlineTabIndicator( + borderRadius: effectiveBorderRadius, + borderSide: BorderSide( + // TODO(tahatesser): Make sure this value matches Material 3 Tabs spec + // when `preferredSize`and `indicatorWeight` are updated to support Material 3 + // https://m3.material.io/components/tabs/specs#149a189f-9039-4195-99da-15c205d20e30, + // https://github.com/flutter/flutter/issues/116136 + width: effectiveIndicatorWeight, + color: color, + ), + ); + } + + // If the TabBar is rebuilt with a new tab controller, the caller should + // dispose the old one. In that case the old controller's animation will be + // null and should not be accessed. + bool get _controllerIsValid => _controller?.animation != null; + + void _updateTabController() { + final TabController? newController = widget.controller ?? DefaultTabController.maybeOf(context); + assert(() { + if (newController == null) { + throw FlutterError( + 'No TabController for ${widget.runtimeType}.\n' + 'When creating a ${widget.runtimeType}, you must either provide an explicit ' + 'TabController using the "controller" property, or you must ensure that there ' + 'is a DefaultTabController above the ${widget.runtimeType}.\n' + 'In this case, there was neither an explicit controller nor a default controller.', + ); + } + return true; + }()); + + if (newController == _controller) { + return; + } + + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + _controller!.removeListener(_handleTabControllerTick); + } + _controller = newController; + if (_controller != null) { + _controller!.animation!.addListener(_handleTabControllerAnimationTick); + _controller!.addListener(_handleTabControllerTick); + _currentIndex = _controller!.index; + } + } + + void _updateScrollController({TabBarScrollController? oldScrollController}) { + if (oldScrollController != widget.scrollController) { + oldScrollController?._tabBarState = null; + } + if (widget.scrollController != null) { + _internalScrollController?._tabBarState = null; + widget.scrollController?._tabBarState = this; + } else { + _internalScrollController ??= TabBarScrollController(); + _internalScrollController?._tabBarState = this; + } + } + + void _initIndicatorPainter() { + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final TabBarIndicatorSize indicatorSize = + widget.indicatorSize ?? tabBarTheme.indicatorSize ?? _defaults.indicatorSize!; + + final _IndicatorPainter? oldPainter = _indicatorPainter; + + final TabIndicatorAnimation defaultTabIndicatorAnimation = switch (indicatorSize) { + TabBarIndicatorSize.label => TabIndicatorAnimation.elastic, + TabBarIndicatorSize.tab => TabIndicatorAnimation.linear, + }; + + _indicatorPainter = !_controllerIsValid + ? null + : _IndicatorPainter( + controller: _controller!, + indicator: _getIndicator(indicatorSize), + indicatorSize: indicatorSize, + indicatorPadding: widget.indicatorPadding, + tabKeys: _tabKeys, + // Passing old painter so that the constructor can copy some values from it. + old: oldPainter, + labelPaddings: _labelPaddings, + dividerColor: widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor, + dividerHeight: + widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight, + showDivider: theme.useMaterial3 && !widget.isScrollable, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + indicatorAnimation: + widget.indicatorAnimation ?? + tabBarTheme.indicatorAnimation ?? + defaultTabIndicatorAnimation, + textDirection: Directionality.of(context), + ); + + oldPainter?.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateScrollController(); + _updateTabController(); + _initIndicatorPainter(); + } + + @override + void didUpdateWidget(TabBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller || + widget.scrollController != oldWidget.scrollController) { + _updateScrollController(oldScrollController: oldWidget.scrollController); + _updateTabController(); + _initIndicatorPainter(); + + // Adjust scroll position. + if (_effectiveScrollController.hasClients) { + final ScrollPosition position = _effectiveScrollController.position; + if (position is _TabBarScrollPosition) { + position.markNeedsPixelsCorrection(); + } + } + } else if (widget.indicatorColor != oldWidget.indicatorColor || + widget.indicatorWeight != oldWidget.indicatorWeight || + widget.indicatorSize != oldWidget.indicatorSize || + widget.indicatorPadding != oldWidget.indicatorPadding || + widget.indicator != oldWidget.indicator || + widget.dividerColor != oldWidget.dividerColor || + widget.dividerHeight != oldWidget.dividerHeight || + widget.indicatorAnimation != oldWidget.indicatorAnimation) { + _initIndicatorPainter(); + } + + if (widget.tabs.length > _tabKeys.length) { + final int delta = widget.tabs.length - _tabKeys.length; + _tabKeys.addAll(List<GlobalKey>.generate(delta, (int n) => GlobalKey())); + _labelPaddings.addAll(List<EdgeInsetsGeometry>.filled(delta, EdgeInsets.zero)); + } else if (widget.tabs.length < _tabKeys.length) { + _tabKeys.removeRange(widget.tabs.length, _tabKeys.length); + _labelPaddings.removeRange(widget.tabs.length, _tabKeys.length); + } + } + + @override + void dispose() { + _indicatorPainter!.dispose(); + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + _controller!.removeListener(_handleTabControllerTick); + } + _controller = null; + _internalScrollController?.dispose(); + widget.scrollController?._tabBarState = null; + // We don't own the _controller Animation, so it's not disposed here. + super.dispose(); + } + + int get maxTabIndex => _indicatorPainter!.maxTabIndex; + + double _tabScrollOffset(int index, double viewportWidth, double minExtent, double maxExtent) { + if (!widget.isScrollable) { + return 0.0; + } + double tabCenter = _indicatorPainter!.centerOf(index); + double paddingStart; + switch (Directionality.of(context)) { + case TextDirection.rtl: + paddingStart = widget.padding?.resolve(TextDirection.rtl).right ?? 0; + tabCenter = _tabStripWidth - tabCenter; + case TextDirection.ltr: + paddingStart = widget.padding?.resolve(TextDirection.ltr).left ?? 0; + } + + return clampDouble(tabCenter + paddingStart - viewportWidth / 2.0, minExtent, maxExtent); + } + + double _tabCenteredScrollOffset(int index) { + final ScrollPosition position = _effectiveScrollController.position; + + return _tabScrollOffset( + index, + position.viewportDimension, + position.minScrollExtent, + position.maxScrollExtent, + ); + } + + double _initialScrollOffset(double viewportWidth, double minExtent, double maxExtent) { + return _tabScrollOffset(_currentIndex!, viewportWidth, minExtent, maxExtent); + } + + void _scrollToCurrentIndex() { + final double offset = _tabCenteredScrollOffset(_currentIndex!); + + _effectiveScrollController.animateTo(offset, duration: kTabScrollDuration, curve: Curves.ease); + } + + void _scrollToControllerValue() { + final double? leadingPosition = _currentIndex! > 0 + ? _tabCenteredScrollOffset(_currentIndex! - 1) + : null; + final double middlePosition = _tabCenteredScrollOffset(_currentIndex!); + final double? trailingPosition = _currentIndex! < maxTabIndex + ? _tabCenteredScrollOffset(_currentIndex! + 1) + : null; + + final double index = _controller!.index.toDouble(); + final double value = _controller!.animation!.value; + final double offset = switch (value - index) { + -1.0 => leadingPosition ?? middlePosition, + 1.0 => trailingPosition ?? middlePosition, + 0 => middlePosition, + < 0 => + leadingPosition == null + ? middlePosition + : lerpDouble(middlePosition, leadingPosition, index - value)!, + _ => + trailingPosition == null + ? middlePosition + : lerpDouble(middlePosition, trailingPosition, value - index)!, + }; + + _effectiveScrollController.jumpTo(offset); + } + + void _handleTabControllerAnimationTick() { + assert(mounted); + if (!_controller!.indexIsChanging && widget.isScrollable) { + // Sync the TabBar's scroll position with the TabBarView's PageView. + _currentIndex = _controller!.index; + _scrollToControllerValue(); + } + } + + void _handleTabControllerTick() { + if (_controller!.index != _currentIndex) { + _currentIndex = _controller!.index; + if (widget.isScrollable) { + _scrollToCurrentIndex(); + } + } + setState(() { + // Rebuild the tabs after a (potentially animated) index change + // has completed. + }); + } + + // Called each time layout completes. + void _saveTabOffsets(List<double> tabOffsets, TextDirection textDirection, double width) { + _tabStripWidth = width; + _indicatorPainter?.saveTabOffsets(tabOffsets, textDirection); + } + + void _handleTap(int index) { + assert(index >= 0 && index < widget.tabs.length); + _controller!.animateTo(index); + widget.onTap?.call(index); + } + + Widget _buildStyledTab( + Widget child, + bool isSelected, + Animation<double> animation, + TabBarThemeData defaults, + ) { + return _TabStyle( + animation: animation, + isSelected: isSelected, + isPrimary: widget._isPrimary, + labelColor: widget.labelColor, + unselectedLabelColor: widget.unselectedLabelColor, + labelStyle: widget.labelStyle, + unselectedLabelStyle: widget.unselectedLabelStyle, + defaults: defaults, + child: child, + ); + } + + bool _debugScheduleCheckHasValidTabsCount() { + if (_debugHasScheduledValidTabsCountCheck) { + return true; + } + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + _debugHasScheduledValidTabsCountCheck = false; + if (!mounted) { + return; + } + assert(() { + if (_controller!.length != widget.tabs.length) { + throw FlutterError( + "Controller's length property (${_controller!.length}) does not match the " + "number of tabs (${widget.tabs.length}) present in TabBar's tabs property.", + ); + } + return true; + }()); + }, debugLabel: 'TabBar.tabsCountCheck'); + _debugHasScheduledValidTabsCountCheck = true; + return true; + } + + bool _debugTabAlignmentIsValid(TabAlignment tabAlignment) { + assert(() { + if (widget.isScrollable && tabAlignment == TabAlignment.fill) { + throw FlutterError('$tabAlignment is only valid for non-scrollable tab bars.'); + } + if (!widget.isScrollable && + (tabAlignment == TabAlignment.start || tabAlignment == TabAlignment.startOffset)) { + throw FlutterError('$tabAlignment is only valid for scrollable tab bars.'); + } + return true; + }()); + return true; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + assert(_debugScheduleCheckHasValidTabsCount()); + final ThemeData theme = Theme.of(context); + final TabBarThemeData tabBarTheme = TabBarTheme.of(context); + final TabAlignment effectiveTabAlignment = + widget.tabAlignment ?? tabBarTheme.tabAlignment ?? _defaults.tabAlignment!; + assert(_debugTabAlignmentIsValid(effectiveTabAlignment)); + + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + if (_controller!.length == 0) { + return LimitedBox( + maxWidth: 0.0, + child: SizedBox(width: double.infinity, height: _kTabHeight + widget.indicatorWeight), + ); + } + + final wrappedTabs = List<Widget>.generate(widget.tabs.length, (int index) { + EdgeInsetsGeometry padding = + widget.labelPadding ?? tabBarTheme.labelPadding ?? kTabLabelPadding; + const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight) / 2.0; + + final Widget tab = widget.tabs[index]; + if (tab is PreferredSizeWidget && + tab.preferredSize.height == _kTabHeight && + widget.tabHasTextAndIcon) { + padding = padding.add(const EdgeInsets.symmetric(vertical: verticalAdjustment)); + } + _labelPaddings[index] = padding; + + return Center( + heightFactor: 1.0, + child: Padding( + padding: _labelPaddings[index], + child: KeyedSubtree(key: _tabKeys[index], child: widget.tabs[index]), + ), + ); + }); + + // If the controller was provided by DefaultTabController and we're part + // of a Hero (typically the AppBar), then we will not be able to find the + // controller during a Hero transition. See https://github.com/flutter/flutter/issues/213. + if (_controller != null) { + final int previousIndex = _controller!.previousIndex; + + if (_controller!.indexIsChanging) { + // The user tapped on a tab, the tab controller's animation is running. + assert(_currentIndex != previousIndex); + final Animation<double> animation = _ChangeAnimation(_controller!); + wrappedTabs[_currentIndex!] = _buildStyledTab( + wrappedTabs[_currentIndex!], + true, + animation, + _defaults, + ); + wrappedTabs[previousIndex] = _buildStyledTab( + wrappedTabs[previousIndex], + false, + animation, + _defaults, + ); + } else { + // The user is dragging the TabBarView's PageView left or right. + final int tabIndex = _currentIndex!; + final Animation<double> centerAnimation = _DragAnimation(_controller!, tabIndex); + wrappedTabs[tabIndex] = _buildStyledTab( + wrappedTabs[tabIndex], + true, + centerAnimation, + _defaults, + ); + if (_currentIndex! > 0) { + final int tabIndex = _currentIndex! - 1; + final Animation<double> previousAnimation = ReverseAnimation( + _DragAnimation(_controller!, tabIndex), + ); + wrappedTabs[tabIndex] = _buildStyledTab( + wrappedTabs[tabIndex], + false, + previousAnimation, + _defaults, + ); + } + if (_currentIndex! < widget.tabs.length - 1) { + final int tabIndex = _currentIndex! + 1; + final Animation<double> nextAnimation = ReverseAnimation( + _DragAnimation(_controller!, tabIndex), + ); + wrappedTabs[tabIndex] = _buildStyledTab( + wrappedTabs[tabIndex], + false, + nextAnimation, + _defaults, + ); + } + } + } + + // Add the tap handler to each tab. If the tab bar is not scrollable, + // then give all of the tabs equal flexibility so that they each occupy + // the same share of the tab bar's overall width. + final int tabCount = widget.tabs.length; + for (var index = 0; index < tabCount; index += 1) { + final selectedState = <WidgetState>{if (index == _currentIndex) WidgetState.selected}; + + final MouseCursor effectiveMouseCursor = + WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, selectedState) ?? + tabBarTheme.mouseCursor?.resolve(selectedState) ?? + WidgetStateMouseCursor.clickable.resolve(selectedState); + + final WidgetStateProperty<Color?> defaultOverlay = WidgetStateProperty.resolveWith<Color?>(( + Set<WidgetState> states, + ) { + final Set<WidgetState> effectiveStates = selectedState.toSet()..addAll(states); + return _defaults.overlayColor?.resolve(effectiveStates); + }); + wrappedTabs[index] = InkWell( + mouseCursor: effectiveMouseCursor, + onTap: () { + _handleTap(index); + }, + onHover: (bool value) { + widget.onHover?.call(value, index); + }, + onFocusChange: (bool value) { + widget.onFocusChange?.call(value, index); + }, + enableFeedback: widget.enableFeedback ?? true, + overlayColor: widget.overlayColor ?? tabBarTheme.overlayColor ?? defaultOverlay, + splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory ?? _defaults.splashFactory, + borderRadius: + widget.splashBorderRadius ?? + tabBarTheme.splashBorderRadius ?? + _defaults.splashBorderRadius, + child: Padding( + padding: EdgeInsets.only(bottom: widget.indicatorWeight), + child: Semantics( + // This has to be wrapped above the Stack to override any role set by the child. + role: SemanticsRole.tab, + child: Stack( + children: <Widget>[ + wrappedTabs[index], + Semantics( + selected: index == _currentIndex, + label: kIsWeb + ? null + : localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount), + ), + ], + ), + ), + ), + ); + wrappedTabs[index] = MergeSemantics(child: wrappedTabs[index]); + if (!widget.isScrollable && effectiveTabAlignment == TabAlignment.fill) { + wrappedTabs[index] = Expanded(child: wrappedTabs[index]); + } + } + + Widget tabBar = Semantics( + role: SemanticsRole.tabBar, + container: true, + explicitChildNodes: true, + child: CustomPaint( + painter: _indicatorPainter, + child: _TabStyle( + animation: kAlwaysDismissedAnimation, + isSelected: false, + isPrimary: widget._isPrimary, + labelColor: widget.labelColor, + unselectedLabelColor: widget.unselectedLabelColor, + labelStyle: widget.labelStyle, + unselectedLabelStyle: widget.unselectedLabelStyle, + defaults: _defaults, + child: _TabLabelBar( + onPerformLayout: _saveTabOffsets, + mainAxisSize: effectiveTabAlignment == TabAlignment.fill + ? MainAxisSize.max + : MainAxisSize.min, + children: wrappedTabs, + ), + ), + ), + ); + + if (widget.isScrollable) { + final EdgeInsetsGeometry? effectivePadding = effectiveTabAlignment == TabAlignment.startOffset + ? const EdgeInsetsDirectional.only( + start: _kStartOffset, + ).add(widget.padding ?? EdgeInsets.zero) + : widget.padding; + + tabBar = ScrollConfiguration( + // The scrolling tabs should not show an overscroll indicator. + behavior: ScrollConfiguration.of(context).copyWith(overscroll: false), + child: SingleChildScrollView( + dragStartBehavior: widget.dragStartBehavior, + scrollDirection: Axis.horizontal, + controller: _effectiveScrollController, + padding: effectivePadding, + physics: widget.physics, + child: tabBar, + ), + ); + if (theme.useMaterial3) { + final AlignmentGeometry effectiveAlignment = switch (effectiveTabAlignment) { + TabAlignment.center => Alignment.center, + TabAlignment.start || + TabAlignment.startOffset || + TabAlignment.fill => AlignmentDirectional.centerStart, + }; + + final Color dividerColor = + widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor!; + final double dividerHeight = + widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight!; + + tabBar = Align( + heightFactor: 1.0, + widthFactor: dividerHeight > 0 ? null : 1.0, + alignment: effectiveAlignment, + child: tabBar, + ); + + if (dividerColor != Colors.transparent && dividerHeight > 0) { + tabBar = CustomPaint( + painter: _DividerPainter(dividerColor: dividerColor, dividerHeight: dividerHeight), + child: tabBar, + ); + } + } + } else if (widget.padding != null) { + tabBar = Padding(padding: widget.padding!, child: tabBar); + } + + return Material( + type: MaterialType.transparency, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: widget.textScaler ?? tabBarTheme.textScaler), + child: tabBar, + ), + ); + } +} + +/// A page view that displays the widget which corresponds to the currently +/// selected tab. +/// +/// This widget is typically used in conjunction with a [TabBar]. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40} +/// +/// If a [TabController] is not provided, then there must be a [DefaultTabController] +/// ancestor. +/// +/// The tab controller's [TabController.length] must equal the length of the +/// [children] list and the length of the [TabBar.tabs] list. +/// +/// To see a sample implementation, visit the [TabController] documentation. +class TabBarView extends StatefulWidget { + /// Creates a page view with one child per tab. + /// + /// The length of [children] must be the same as the [controller]'s length. + const TabBarView({ + super.key, + required this.children, + this.controller, + this.physics, + this.dragStartBehavior = DragStartBehavior.start, + this.viewportFraction = 1.0, + this.clipBehavior = Clip.hardEdge, + }); + + /// This widget's selection and animation state. + /// + /// If [TabController] is not provided, then the value of [DefaultTabController.of] + /// will be used. + final TabController? controller; + + /// One widget per tab. + /// + /// Its length must match the length of the [TabBar.tabs] + /// list, as well as the [controller]'s [TabController.length]. + final List<Widget> children; + + /// How the page view should respond to user input. + /// + /// For example, determines how the page view continues to animate after the + /// user stops dragging the page view. + /// + /// The physics are modified to snap to page boundaries using + /// [PageScrollPhysics] prior to being used. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.pageview.viewportFraction} + final double viewportFraction; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + @override + State<TabBarView> createState() => _TabBarViewState(); +} + +class _TabBarViewState extends State<TabBarView> { + TabController? _controller; + PageController? _pageController; + late List<Widget> _childrenWithKey; + int? _currentIndex; + int _warpUnderwayCount = 0; + int _scrollUnderwayCount = 0; + bool _debugHasScheduledValidChildrenCountCheck = false; + + // If the TabBarView is rebuilt with a new tab controller, the caller should + // dispose the old one. In that case the old controller's animation will be + // null and should not be accessed. + bool get _controllerIsValid => _controller?.animation != null; + + void _updateTabController() { + final TabController? newController = widget.controller ?? DefaultTabController.maybeOf(context); + assert(() { + if (newController == null) { + throw FlutterError( + 'No TabController for ${widget.runtimeType}.\n' + 'When creating a ${widget.runtimeType}, you must either provide an explicit ' + 'TabController using the "controller" property, or you must ensure that there ' + 'is a DefaultTabController above the ${widget.runtimeType}.\n' + 'In this case, there was neither an explicit controller nor a default controller.', + ); + } + return true; + }()); + + if (newController == _controller) { + return; + } + + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + } + _controller = newController; + if (_controller != null) { + _controller!.animation!.addListener(_handleTabControllerAnimationTick); + } + } + + void _jumpToPage(int page) { + _warpUnderwayCount += 1; + _pageController!.jumpToPage(page); + _warpUnderwayCount -= 1; + } + + Future<void> _animateToPage(int page, {required Duration duration, required Curve curve}) async { + _warpUnderwayCount += 1; + await _pageController!.animateToPage(page, duration: duration, curve: curve); + _warpUnderwayCount -= 1; + } + + @override + void initState() { + super.initState(); + _updateChildren(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateTabController(); + _currentIndex = _controller!.index; + if (_pageController == null) { + _pageController = PageController( + initialPage: _currentIndex!, + viewportFraction: widget.viewportFraction, + ); + } else { + _pageController!.jumpToPage(_currentIndex!); + } + } + + @override + void didUpdateWidget(TabBarView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _updateTabController(); + _currentIndex = _controller!.index; + _jumpToPage(_currentIndex!); + } + if (widget.viewportFraction != oldWidget.viewportFraction) { + _pageController?.dispose(); + _pageController = PageController( + initialPage: _currentIndex!, + viewportFraction: widget.viewportFraction, + ); + } + // While a warp is under way, we stop updating the tab page contents. + // This is tracked in https://github.com/flutter/flutter/issues/31269. + if (widget.children != oldWidget.children && _warpUnderwayCount == 0) { + _updateChildren(); + } + } + + @override + void dispose() { + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + } + _controller = null; + _pageController?.dispose(); + // We don't own the _controller Animation, so it's not disposed here. + super.dispose(); + } + + void _updateChildren() { + _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList( + widget.children.map<Widget>((Widget child) { + return Semantics(role: SemanticsRole.tabPanel, child: child); + }).toList(), + ); + } + + void _handleTabControllerAnimationTick() { + if (_scrollUnderwayCount > 0 || !_controller!.indexIsChanging) { + return; + } // This widget is driving the controller's animation. + + if (_controller!.index != _currentIndex) { + _currentIndex = _controller!.index; + _warpToCurrentIndex(); + } + } + + void _warpToCurrentIndex() { + if (!mounted || _pageController!.page == _currentIndex!.toDouble()) { + return; + } + + final adjacentDestination = (_currentIndex! - _controller!.previousIndex).abs() == 1; + if (adjacentDestination) { + _warpToAdjacentTab(_controller!.animationDuration); + } else { + _warpToNonAdjacentTab(_controller!.animationDuration); + } + } + + Future<void> _warpToAdjacentTab(Duration duration) async { + if (duration == Duration.zero) { + _jumpToPage(_currentIndex!); + } else { + await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); + } + if (mounted) { + setState(() { + _updateChildren(); + }); + } + return Future<void>.value(); + } + + Future<void> _warpToNonAdjacentTab(Duration duration) async { + final int previousIndex = _controller!.previousIndex; + assert((_currentIndex! - previousIndex).abs() > 1); + + // initialPage defines which page is shown when starting the animation. + // This page is adjacent to the destination page. + final int initialPage = _currentIndex! > previousIndex + ? _currentIndex! - 1 + : _currentIndex! + 1; + + setState(() { + // Needed for `RenderSliverMultiBoxAdaptor.move` and kept alive children. + // For motivation, see https://github.com/flutter/flutter/pull/29188 and + // https://github.com/flutter/flutter/issues/27010#issuecomment-486475152. + _childrenWithKey = List<Widget>.of(_childrenWithKey, growable: false); + final Widget temp = _childrenWithKey[initialPage]; + _childrenWithKey[initialPage] = _childrenWithKey[previousIndex]; + _childrenWithKey[previousIndex] = temp; + }); + + // Make a first jump to the adjacent page. + _jumpToPage(initialPage); + + // Jump or animate to the destination page. + if (duration == Duration.zero) { + _jumpToPage(_currentIndex!); + } else { + await _animateToPage(_currentIndex!, duration: duration, curve: Curves.ease); + } + + if (mounted) { + setState(() { + _updateChildren(); + }); + } + } + + void _syncControllerOffset() { + _controller!.offset = clampDouble(_pageController!.page! - _controller!.index, -1.0, 1.0); + } + + // Called when the PageView scrolls + bool _handleScrollNotification(ScrollNotification notification) { + if (_warpUnderwayCount > 0 || _scrollUnderwayCount > 0) { + return false; + } + + if (notification.depth != 0) { + return false; + } + + if (!_controllerIsValid) { + return false; + } + + _scrollUnderwayCount += 1; + final double page = _pageController!.page!; + if (notification is ScrollUpdateNotification && !_controller!.indexIsChanging) { + final bool pageChanged = (page - _controller!.index).abs() > 1.0; + if (pageChanged) { + _controller!.index = page.round(); + _currentIndex = _controller!.index; + } + _syncControllerOffset(); + } else if (notification is ScrollEndNotification) { + _controller!.index = page.round(); + _currentIndex = _controller!.index; + if (!_controller!.indexIsChanging) { + _syncControllerOffset(); + } + } + _scrollUnderwayCount -= 1; + + return false; + } + + bool _debugScheduleCheckHasValidChildrenCount() { + if (_debugHasScheduledValidChildrenCountCheck) { + return true; + } + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + _debugHasScheduledValidChildrenCountCheck = false; + if (!mounted) { + return; + } + assert(() { + if (_controller!.length != widget.children.length) { + throw FlutterError( + "Controller's length property (${_controller!.length}) does not match the " + "number of children (${widget.children.length}) present in TabBarView's children property.", + ); + } + return true; + }()); + }, debugLabel: 'TabBarView.validChildrenCountCheck'); + _debugHasScheduledValidChildrenCountCheck = true; + return true; + } + + @override + Widget build(BuildContext context) { + assert(_debugScheduleCheckHasValidChildrenCount()); + + return NotificationListener<ScrollNotification>( + onNotification: _handleScrollNotification, + child: PageView( + dragStartBehavior: widget.dragStartBehavior, + clipBehavior: widget.clipBehavior, + controller: _pageController, + physics: widget.physics == null + ? const PageScrollPhysics().applyTo(const ClampingScrollPhysics()) + : const PageScrollPhysics().applyTo(widget.physics), + children: _childrenWithKey, + ), + ); + } +} + +/// Displays a single circle with the specified size, border style, border color +/// and background colors. +/// +/// Used by [TabPageSelector] to indicate the selected page. +class TabPageSelectorIndicator extends StatelessWidget { + /// Creates an indicator used by [TabPageSelector]. + const TabPageSelectorIndicator({ + super.key, + required this.backgroundColor, + required this.borderColor, + required this.size, + this.borderStyle = BorderStyle.solid, + }); + + /// The indicator circle's background color. + final Color backgroundColor; + + /// The indicator circle's border color. + final Color borderColor; + + /// The indicator circle's diameter. + final double size; + + /// The indicator circle's border style. + /// + /// Defaults to [BorderStyle.solid] if value is not specified. + final BorderStyle borderStyle; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + margin: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor, style: borderStyle), + shape: BoxShape.circle, + ), + ); + } +} + +/// Uses [TabPageSelectorIndicator] to display a row of small circular +/// indicators, one per tab. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=Q628ue9Cq7U} +/// +/// The selected tab's indicator is highlighted. Often used in conjunction with +/// a [TabBarView]. +/// +/// If a [TabController] is not provided, then there must be a +/// [DefaultTabController] ancestor. +class TabPageSelector extends StatefulWidget { + /// Creates a compact widget that indicates which tab has been selected. + const TabPageSelector({ + super.key, + this.controller, + this.indicatorSize = 12.0, + this.color, + this.selectedColor, + this.borderStyle, + }) : assert(indicatorSize > 0.0); + + /// This widget's selection and animation state. + /// + /// If [TabController] is not provided, then the value of + /// [DefaultTabController.of] will be used. + final TabController? controller; + + /// The indicator circle's diameter (the default value is 12.0). + final double indicatorSize; + + /// The indicator circle's fill color for unselected pages. + /// + /// If this parameter is null, then the indicator is filled with [Colors.transparent]. + final Color? color; + + /// The indicator circle's fill color for selected pages and border color + /// for all indicator circles. + /// + /// If this parameter is null, then the indicator is filled with the theme's + /// [ColorScheme.secondary]. + final Color? selectedColor; + + /// The indicator circle's border style. + /// + /// Defaults to [BorderStyle.solid] if value is not specified. + final BorderStyle? borderStyle; + + @override + State<TabPageSelector> createState() => _TabPageSelectorState(); +} + +class _TabPageSelectorState extends State<TabPageSelector> { + TabController? _previousTabController; + TabController get _tabController { + final TabController? tabController = widget.controller ?? DefaultTabController.maybeOf(context); + assert(() { + if (tabController == null) { + throw FlutterError( + 'No TabController for $runtimeType.\n' + 'When creating a $runtimeType, you must either provide an explicit TabController ' + 'using the "controller" property, or you must ensure that there is a ' + 'DefaultTabController above the $runtimeType.\n' + 'In this case, there was neither an explicit controller nor a default controller.', + ); + } + return true; + }()); + return tabController!; + } + + CurvedAnimation? _animation; + + @override + void didUpdateWidget(TabPageSelector oldWidget) { + super.didUpdateWidget(oldWidget); + if (_previousTabController?.animation != _tabController.animation) { + _setAnimation(); + } + if (_previousTabController != _tabController) { + _previousTabController = _tabController; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_animation == null || _previousTabController?.animation != _tabController.animation) { + _setAnimation(); + } + if (_previousTabController != _tabController) { + _previousTabController = _tabController; + } + } + + void _setAnimation() { + _animation?.dispose(); + _animation = CurvedAnimation(parent: _tabController.animation!, curve: Curves.fastOutSlowIn); + } + + @override + void dispose() { + _animation?.dispose(); + super.dispose(); + } + + Widget _buildTabIndicator( + int tabIndex, + TabController tabController, + ColorTween selectedColorTween, + ColorTween previousColorTween, + ) { + final Color background; + if (tabController.indexIsChanging) { + // The selection's animation is animating from previousValue to value. + final double t = 1.0 - _indexChangeProgress(tabController); + if (tabController.index == tabIndex) { + background = selectedColorTween.lerp(t)!; + } else if (tabController.previousIndex == tabIndex) { + background = previousColorTween.lerp(t)!; + } else { + background = selectedColorTween.begin!; + } + } else { + // The selection's offset reflects how far the TabBarView has / been dragged + // to the previous page (-1.0 to 0.0) or the next page (0.0 to 1.0). + final double offset = tabController.offset; + if (tabController.index == tabIndex) { + background = selectedColorTween.lerp(1.0 - offset.abs())!; + } else if (tabController.index == tabIndex - 1 && offset > 0.0) { + background = selectedColorTween.lerp(offset)!; + } else if (tabController.index == tabIndex + 1 && offset < 0.0) { + background = selectedColorTween.lerp(-offset)!; + } else { + background = selectedColorTween.begin!; + } + } + return TabPageSelectorIndicator( + backgroundColor: background, + borderColor: selectedColorTween.end!, + size: widget.indicatorSize, + borderStyle: widget.borderStyle ?? BorderStyle.solid, + ); + } + + @override + Widget build(BuildContext context) { + final Color fixColor = widget.color ?? Colors.transparent; + final Color fixSelectedColor = widget.selectedColor ?? Theme.of(context).colorScheme.secondary; + final selectedColorTween = ColorTween(begin: fixColor, end: fixSelectedColor); + final previousColorTween = ColorTween(begin: fixSelectedColor, end: fixColor); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return AnimatedBuilder( + animation: _animation!, + builder: (BuildContext context, Widget? child) { + return Semantics( + label: localizations.tabLabel( + tabIndex: _tabController.index + 1, + tabCount: _tabController.length, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List<Widget>.generate(_tabController.length, (int tabIndex) { + return _buildTabIndicator( + tabIndex, + _tabController, + selectedColorTween, + previousColorTween, + ); + }).toList(), + ), + ); + }, + ); + } +} + +// Hand coded defaults based on Material Design 2. +class _TabsDefaultsM2 extends TabBarThemeData { + _TabsDefaultsM2(this.context, this.isScrollable) : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final bool isDark = Theme.brightnessOf(context) == Brightness.dark; + late final Color primaryColor = isDark ? Colors.grey[900]! : Colors.blue; + final bool isScrollable; + + @override + Color? get indicatorColor => _colors.secondary == primaryColor ? Colors.white : _colors.secondary; + + @override + Color? get labelColor => Theme.of(context).primaryTextTheme.bodyLarge!.color!; + + @override + TextStyle? get labelStyle => Theme.of(context).primaryTextTheme.bodyLarge; + + @override + TextStyle? get unselectedLabelStyle => Theme.of(context).primaryTextTheme.bodyLarge; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; + + static const EdgeInsetsGeometry iconMargin = EdgeInsets.only(bottom: 10); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Tabs + +// 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. + +// dart format off +class _TabsPrimaryDefaultsM3 extends TabBarThemeData { + _TabsPrimaryDefaultsM3(this.context, this.isScrollable) + : super(indicatorSize: TabBarIndicatorSize.label); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; + + // This value comes from Divider widget defaults. Token db deprecated 'primary-navigation-tab.divider.color' token. + @override + Color? get dividerColor => _colors.outlineVariant; + + // This value comes from Divider widget defaults. Token db deprecated 'primary-navigation-tab.divider.height' token. + @override + double? get dividerHeight => 1.0; + + @override + Color? get indicatorColor => _colors.primary; + + @override + Color? get labelColor => _colors.primary; + + @override + TextStyle? get labelStyle => _textTheme.titleSmall; + + @override + Color? get unselectedLabelColor => _colors.onSurfaceVariant; + + @override + TextStyle? get unselectedLabelStyle => _textTheme.titleSmall; + + @override + WidgetStateProperty<Color?> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return null; + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; + + static double indicatorWeight(TabBarIndicatorSize indicatorSize) { + return switch (indicatorSize) { + TabBarIndicatorSize.label => 3.0, + TabBarIndicatorSize.tab => 2.0, + }; + } + + // TODO(davidmartos96): This value doesn't currently exist in + // https://m3.material.io/components/tabs/specs + // Update this when the token is available. + static const EdgeInsetsGeometry iconMargin = EdgeInsets.only(bottom: 2); +} + +class _TabsSecondaryDefaultsM3 extends TabBarThemeData { + _TabsSecondaryDefaultsM3(this.context, this.isScrollable) + : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; + + // This value comes from Divider widget defaults. Token db deprecated 'secondary-navigation-tab.divider.color' token. + @override + Color? get dividerColor => _colors.outlineVariant; + + // This value comes from Divider widget defaults. Token db deprecated 'secondary-navigation-tab.divider.height' token. + @override + double? get dividerHeight => 1.0; + + @override + Color? get indicatorColor => _colors.primary; + + @override + Color? get labelColor => _colors.onSurface; + + @override + TextStyle? get labelStyle => _textTheme.titleSmall; + + @override + Color? get unselectedLabelColor => _colors.onSurfaceVariant; + + @override + TextStyle? get unselectedLabelStyle => _textTheme.titleSmall; + + @override + WidgetStateProperty<Color?> get overlayColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return null; + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface.withOpacity(0.1); + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; + + static double indicatorWeight = 2.0; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Tabs diff --git a/packages/material_ui/lib/src/text_button.dart b/packages/material_ui/lib/src/text_button.dart new file mode 100644 index 000000000000..272ebe00c391 --- /dev/null +++ b/packages/material_ui/lib/src/text_button.dart @@ -0,0 +1,605 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +/// @docImport 'filled_button.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +library; + +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 'colors.dart'; +import 'constants.dart'; +import 'ink_ripple.dart'; +import 'ink_well.dart'; +import 'material_state.dart'; +import 'text_button_theme.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +/// A Material Design "Text Button". +/// +/// Use text buttons on toolbars, in dialogs, or inline with other +/// content but offset from that content with padding so that the +/// button's presence is obvious. Text buttons do not have visible +/// borders and must therefore rely on their position relative to +/// other content for context. In dialogs and cards, they should be +/// grouped together in one of the bottom corners. Avoid using text +/// buttons where they would blend in with other content, for example +/// in the middle of lists. +/// +/// A text button is a label [child] displayed on a (zero elevation) +/// [Material] widget. The label's [Text] and [Icon] widgets are +/// displayed in the [style]'s [ButtonStyle.foregroundColor]. The +/// button reacts to touches by filling with the [style]'s +/// [ButtonStyle.backgroundColor]. +/// +/// The text button's default style is defined by [defaultStyleOf]. +/// The style of this text button can be overridden with its [style] +/// parameter. The style of all text buttons in a subtree can be +/// overridden with the [TextButtonTheme] and the style of all of the +/// text buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.textButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// text button [ButtonStyle] from simple values. +/// +/// If the [onPressed] and [onLongPress] callbacks are null, then this +/// button will be disabled, it will not react to touch. +/// +/// {@tool dartpad} +/// This sample shows various ways to configure TextButtons, from the +/// simplest default appearance to versions that don't resemble +/// Material Design at all. +/// +/// ** See code in examples/api/lib/material/text_button/text_button.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample demonstrates using the [statesController] parameter to create a button +/// that adds support for [WidgetState.selected]. +/// +/// ** See code in examples/api/lib/material/text_button/text_button.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [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. +/// * <https://material.io/design/components/buttons.html> +/// * <https://m3.material.io/components/buttons> +class TextButton extends ButtonStyleButton { + /// Create a [TextButton]. + const TextButton({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior, + super.statesController, + super.isSemanticButton, + required Widget super.child, + }) : _addPadding = false; + + /// Create a text button from a pair of widgets that serve as the button's + /// [icon] and [label]. + /// + /// The icon and label are arranged in a row and padded by 8 logical pixels + /// at the ends, with an 8 pixel gap in between. + /// + /// If [icon] is null, this constructor will create a [TextButton] + /// that doesn't display an icon. + /// + /// {@macro flutter.material.ButtonStyle.iconAlignment} + /// + TextButton.icon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior = Clip.none, + super.statesController, + Widget? icon, + required Widget label, + IconAlignment? iconAlignment, + }) : _addPadding = icon != null, + super( + child: icon != null + ? _TextButtonWithIconChild( + label: label, + icon: icon, + buttonStyle: style, + iconAlignment: iconAlignment, + ) + : label, + ); + + final bool _addPadding; + + /// A static convenience method that constructs a text button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor] and [disabledForegroundColor] colors are used + /// to create a [WidgetStateProperty] [ButtonStyle.foregroundColor], and + /// a derived [ButtonStyle.overlayColor] if [overlayColor] isn't specified. + /// + /// The [backgroundColor] and [disabledBackgroundColor] colors are + /// used to create a [WidgetStateProperty] [ButtonStyle.backgroundColor]. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// The [iconColor], [disabledIconColor] are used to construct + /// [ButtonStyle.iconColor] and [iconSize] is used to construct + /// [ButtonStyle.iconSize]. + /// + /// If [iconColor] is null, the button icon will use [foregroundColor]. If [foregroundColor] is also + /// null, the button icon will use the default icon color. + /// + /// If [overlayColor] is specified and its value is [Colors.transparent] + /// then the pressed/focused/hovered highlights are effectively defeated. + /// Otherwise a [WidgetStateProperty] with the same opacities as the + /// default is created. + /// + /// All of the other parameters are either used directly or used to + /// create a [WidgetStateProperty] 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 + /// [TextButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// TextButton( + /// style: TextButton.styleFrom(foregroundColor: Colors.green), + /// child: const Text('Give Kate a mix tape'), + /// onPressed: () { + /// // ... + /// }, + /// ), + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + Color? iconColor, + double? iconSize, + IconAlignment? iconAlignment, + Color? disabledIconColor, + Color? overlayColor, + 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, + ButtonLayerBuilder? backgroundBuilder, + ButtonLayerBuilder? foregroundBuilder, + }) { + final WidgetStateProperty<Color?>? backgroundColorProp = switch (( + backgroundColor, + disabledBackgroundColor, + )) { + (_?, null) => MaterialStatePropertyAll<Color?>(backgroundColor), + (_, _) => ButtonStyleButton.defaultColor(backgroundColor, disabledBackgroundColor), + }; + final WidgetStateProperty<Color?>? iconColorProp = switch ((iconColor, disabledIconColor)) { + (_?, null) => MaterialStatePropertyAll<Color?>(iconColor), + (_, _) => ButtonStyleButton.defaultColor(iconColor, disabledIconColor), + }; + final WidgetStateProperty<Color?>? overlayColorProp = switch ((foregroundColor, overlayColor)) { + (null, null) => null, + (_, Color(a: 0.0)) => WidgetStatePropertyAll<Color?>(overlayColor), + (_, final Color color) || + (final Color color, _) => WidgetStateProperty<Color?>.fromMap(<WidgetState, Color?>{ + WidgetState.pressed: color.withOpacity(0.1), + WidgetState.hovered: color.withOpacity(0.08), + WidgetState.focused: color.withOpacity(0.1), + }), + }; + + return ButtonStyle( + textStyle: ButtonStyleButton.allOrNull<TextStyle>(textStyle), + foregroundColor: ButtonStyleButton.defaultColor(foregroundColor, disabledForegroundColor), + backgroundColor: backgroundColorProp, + overlayColor: overlayColorProp, + shadowColor: ButtonStyleButton.allOrNull<Color>(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull<Color>(surfaceTintColor), + iconColor: iconColorProp, + iconSize: ButtonStyleButton.allOrNull<double>(iconSize), + iconAlignment: iconAlignment, + elevation: ButtonStyleButton.allOrNull<double>(elevation), + padding: ButtonStyleButton.allOrNull<EdgeInsetsGeometry>(padding), + minimumSize: ButtonStyleButton.allOrNull<Size>(minimumSize), + fixedSize: ButtonStyleButton.allOrNull<Size>(fixedSize), + maximumSize: ButtonStyleButton.allOrNull<Size>(maximumSize), + side: ButtonStyleButton.allOrNull<BorderSide>(side), + shape: ButtonStyleButton.allOrNull<OutlinedBorder>(shape), + mouseCursor: WidgetStateProperty<MouseCursor?>.fromMap(<WidgetStatesConstraint, MouseCursor?>{ + WidgetState.disabled: disabledMouseCursor, + WidgetState.any: enabledMouseCursor, + }), + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, + ); + } + + /// Defines the button's default appearance. + /// + /// {@template flutter.material.text_button.default_style_of} + /// 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 and is transparent by default. + /// + /// 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)`. [WidgetStateProperty] 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 "default font size" below refers to the font size specified in the + /// [defaultStyleOf] method (or 14.0 if unspecified), scaled by the + /// `MediaQuery.textScalerOf(context).scale` method. 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. + /// {@endtemplate} + /// + /// ## Material 2 defaults + /// + /// * `textStyle` - Theme.textTheme.button + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.12) + /// * `shadowColor` - Theme.shadowColor + /// * `elevation` - 0 + /// * `padding` + /// * `default font size <= 14` - (horizontal(12), vertical(8)) + /// * `14 < default font size <= 28` - lerp(all(8), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 36) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)) + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - InkRipple.splashFactory + /// + /// The default padding values for the [TextButton.icon] factory are slightly different: + /// + /// * `padding` + /// * `default font size <= 14` - all(8) + /// * `14 < default font size <= 28 `- lerp(all(8), horizontal(4)) + /// * `28 < default font size` - 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. + /// + /// ## Material 3 defaults + /// + /// If [ThemeData.useMaterial3] is set to true the following defaults will + /// be used: + /// + /// {@template flutter.material.text_button.material3_defaults} + /// * `textStyle` - Theme.textTheme.labelLarge + /// * `backgroundColor` - transparent + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.primary + /// * `overlayColor` + /// * hovered - Theme.colorScheme.primary(0.08) + /// * focused or pressed - Theme.colorScheme.primary(0.1) + /// * others - null + /// * `shadowColor` - Colors.transparent, + /// * `surfaceTintColor` - null + /// * `elevation` - 0 + /// * `padding` + /// * `default font size <= 14` - lerp(horizontal(12), horizontal(4)) + /// * `14 < default font size <= 28` - lerp(all(8), horizontal(8)) + /// * `28 < default font size <= 36` - lerp(horizontal(8), horizontal(4)) + /// * `36 < default font size` - horizontal(4) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` - WidgetStateMouseCursor.adaptiveClickable + /// * `visualDensity` - theme.visualDensity + /// * `tapTargetSize` - theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + /// + /// For the [TextButton.icon] factory, the end (generally the right) value of + /// `padding` is increased from 12 to 16. + /// {@endtemplate} + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final ButtonStyle buttonStyle = theme.useMaterial3 + ? _TextButtonDefaultsM3(context) + : styleFrom( + foregroundColor: colorScheme.primary, + disabledForegroundColor: colorScheme.onSurface.withOpacity(0.38), + backgroundColor: Colors.transparent, + disabledBackgroundColor: Colors.transparent, + shadowColor: theme.shadowColor, + elevation: 0, + textStyle: theme.textTheme.labelLarge, + padding: _scaledPadding(context), + minimumSize: const Size(64, 36), + maximumSize: Size.infinite, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + enabledMouseCursor: kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + disabledMouseCursor: SystemMouseCursors.basic, + visualDensity: theme.visualDensity, + tapTargetSize: theme.materialTapTargetSize, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + splashFactory: InkRipple.splashFactory, + ); + + // Only apply padding when TextButton has an Icon. + if (_addPadding) { + final double defaultFontSize = + buttonStyle.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double effectiveTextScale = + MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + theme.useMaterial3 + ? const EdgeInsetsDirectional.fromSTEB(12, 8, 16, 8) + : const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 4), + const EdgeInsets.symmetric(horizontal: 4), + effectiveTextScale, + ); + return buttonStyle.copyWith( + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(scaledPadding), + ); + } + + return buttonStyle; + } + + /// Returns the [TextButtonThemeData.style] of the closest + /// [TextButtonTheme] ancestor. + @override + ButtonStyle? themeStyleOf(BuildContext context) { + return TextButtonTheme.of(context).style; + } +} + +EdgeInsetsGeometry _scaledPadding(BuildContext context) { + final ThemeData theme = Theme.of(context); + final double defaultFontSize = theme.textTheme.labelLarge?.fontSize ?? 14.0; + final double effectiveTextScale = MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0; + return ButtonStyleButton.scaledPadding( + theme.useMaterial3 + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 8) + : const EdgeInsets.all(8), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsets.symmetric(horizontal: 4), + effectiveTextScale, + ); +} + +class _TextButtonWithIconChild extends StatelessWidget { + const _TextButtonWithIconChild({ + required this.label, + required this.icon, + required this.buttonStyle, + required this.iconAlignment, + }); + + final Widget label; + final Widget icon; + final ButtonStyle? buttonStyle; + final IconAlignment? iconAlignment; + + @override + Widget build(BuildContext context) { + final double defaultFontSize = + buttonStyle?.textStyle?.resolve(const <WidgetState>{})?.fontSize ?? 14.0; + final double scale = + clampDouble(MediaQuery.textScalerOf(context).scale(defaultFontSize) / 14.0, 1.0, 2.0) - 1.0; + final TextButtonThemeData textButtonTheme = TextButtonTheme.of(context); + final IconAlignment effectiveIconAlignment = + iconAlignment ?? + textButtonTheme.style?.iconAlignment ?? + buttonStyle?.iconAlignment ?? + IconAlignment.start; + return Row( + mainAxisSize: MainAxisSize.min, + spacing: lerpDouble(8, 4, scale)!, + children: effectiveIconAlignment == IconAlignment.start + ? <Widget>[icon, Flexible(child: label)] + : <Widget>[Flexible(child: label), icon], + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - TextButton + +// 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. + +// dart format off +class _TextButtonDefaultsM3 extends ButtonStyle { + _TextButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + WidgetStateProperty<TextStyle?> get textStyle => + MaterialStatePropertyAll<TextStyle?>(Theme.of(context).textTheme.labelLarge); + + @override + WidgetStateProperty<Color?>? get backgroundColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color?>? get foregroundColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.primary; + }); + + @override + WidgetStateProperty<Color?>? get overlayColor => + WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return _colors.primary.withOpacity(0.1); + } + return null; + }); + + @override + WidgetStateProperty<Color>? get shadowColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<Color>? get surfaceTintColor => + const MaterialStatePropertyAll<Color>(Colors.transparent); + + @override + WidgetStateProperty<double>? get elevation => + const MaterialStatePropertyAll<double>(0.0); + + @override + WidgetStateProperty<EdgeInsetsGeometry>? get padding => + MaterialStatePropertyAll<EdgeInsetsGeometry>(_scaledPadding(context)); + + @override + WidgetStateProperty<Size>? get minimumSize => + const MaterialStatePropertyAll<Size>(Size(64.0, 40.0)); + + // No default fixedSize + + @override + WidgetStateProperty<double>? get iconSize => + const MaterialStatePropertyAll<double>(18.0); + + @override + WidgetStateProperty<Color>? get iconColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + if (states.contains(WidgetState.pressed)) { + return _colors.primary; + } + if (states.contains(WidgetState.hovered)) { + return _colors.primary; + } + if (states.contains(WidgetState.focused)) { + return _colors.primary; + } + return _colors.primary; + }); + } + + @override + WidgetStateProperty<Size>? get maximumSize => + const MaterialStatePropertyAll<Size>(Size.infinite); + + // No default side + + @override + WidgetStateProperty<OutlinedBorder>? get shape => + const MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()); + + @override + WidgetStateProperty<MouseCursor?>? get mouseCursor => WidgetStateMouseCursor.adaptiveClickable; + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - TextButton diff --git a/packages/material_ui/lib/src/text_button_theme.dart b/packages/material_ui/lib/src/text_button_theme.dart new file mode 100644 index 000000000000..8279e634f6ad --- /dev/null +++ b/packages/material_ui/lib/src/text_button_theme.dart @@ -0,0 +1,123 @@ +// 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. + +/// @docImport 'text_button.dart'; +library; + +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 +/// [TextButton]s when it's used with [TextButtonTheme] or with the +/// overall [Theme]'s [ThemeData.textButtonTheme]. +/// +/// The [style]'s properties override [TextButton]'s default style, +/// i.e. the [ButtonStyle] returned by [TextButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [WidgetStateProperty] values are used. +/// +/// See also: +/// +/// * [TextButtonTheme], the theme which is configured with this class. +/// * [TextButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [TextButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [TextButton]'s defaults. +/// * [WidgetStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [WidgetState]s. +/// * [ThemeData.textButtonTheme], which can be used to override the default +/// [ButtonStyle] for [TextButton]s below the overall [Theme]. +@immutable +class TextButtonThemeData with Diagnosticable { + /// Creates a [TextButtonThemeData]. + /// + /// The [style] may be null. + const TextButtonThemeData({this.style}); + + /// Overrides for [TextButton]'s default style. + /// + /// Non-null properties or non-null resolved [WidgetStateProperty] + /// values override the [ButtonStyle] returned by + /// [TextButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Linearly interpolate between two text button themes. + static TextButtonThemeData? lerp(TextButtonThemeData? a, TextButtonThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return TextButtonThemeData(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 TextButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ButtonStyle>('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [TextButton] descendants. +/// +/// See also: +/// +/// * [TextButtonThemeData], which is used to configure this theme. +/// * [TextButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [TextButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [TextButton]'s defaults. +/// * [ThemeData.textButtonTheme], which can be used to override the default +/// [ButtonStyle] for [TextButton]s below the overall [Theme]. +class TextButtonTheme extends InheritedTheme { + /// Create a [TextButtonTheme]. + const TextButtonTheme({super.key, required this.data, required super.child}); + + /// The configuration of this theme. + final TextButtonThemeData data; + + /// Retrieves the [TextButtonThemeData] from the closest ancestor [TextButtonTheme]. + /// + /// If there is no enclosing [TextButtonTheme] widget, then + /// [ThemeData.textButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TextButtonThemeData theme = TextButtonTheme.of(context); + /// ``` + static TextButtonThemeData of(BuildContext context) { + final TextButtonTheme? buttonTheme = context + .dependOnInheritedWidgetOfExactType<TextButtonTheme>(); + return buttonTheme?.data ?? Theme.of(context).textButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return TextButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(TextButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/text_field.dart b/packages/material_ui/lib/src/text_field.dart new file mode 100644 index 000000000000..6937317db113 --- /dev/null +++ b/packages/material_ui/lib/src/text_field.dart @@ -0,0 +1,1899 @@ +// 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. + +/// @docImport 'input_border.dart'; +/// @docImport 'material.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'text_form_field.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'debug.dart'; +import 'desktop_text_selection.dart'; +import 'input_decorator.dart'; +import 'magnifier.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'selectable_text.dart' show iOSHorizontalOffset; +import 'spell_check_suggestions_toolbar.dart'; +import 'text_selection.dart'; +import 'theme.dart'; + +export 'package:flutter/services.dart' + show SmartDashesType, SmartQuotesType, TextCapitalization, TextInputAction, TextInputType; + +// Examples can assume: +// late BuildContext context; +// late FocusNode myFocusNode; + +/// Signature for the [TextField.buildCounter] callback. +typedef InputCounterWidgetBuilder = + Widget? Function( + /// The build context for the TextField. + BuildContext context, { + + /// The length of the string currently in the input. + required int currentLength, + + /// The maximum string length that can be entered into the TextField. + required int? maxLength, + + /// Whether or not the TextField is currently focused. Mainly provided for + /// the [liveRegion] parameter in the [Semantics] widget for accessibility. + required bool isFocused, + }); + +class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder { + _TextFieldSelectionGestureDetectorBuilder({required _TextFieldState state}) + : _state = state, + super(delegate: state); + + final _TextFieldState _state; + + @override + bool get onUserTapAlwaysCalled => _state.widget.onTapAlwaysCalled; + + @override + void onUserTap() { + _state.widget.onTap?.call(); + } +} + +/// A Material Design text field. +/// +/// A text field lets the user enter text, either with hardware keyboard or with +/// an onscreen keyboard. +/// +/// The text field calls the [onChanged] callback whenever the user changes the +/// text in the field. If the user indicates that they are done typing in the +/// field (e.g., by pressing a button on the soft keyboard), the text field +/// calls the [onSubmitted] callback. +/// +/// To control the text that is displayed in the text field, use the +/// [controller]. For example, to set the initial value of the text field, use +/// a [controller] that already contains some text. The [controller] can also +/// control the selection and composing region (and to observe changes to the +/// text, selection, and composing region). +/// +/// By default, a text field has a [decoration] that draws a divider below the +/// text field. You can use the [decoration] property to control the decoration, +/// for example by adding a label or an icon. If you set the [decoration] +/// property to null, the decoration will be removed entirely, including the +/// extra padding introduced by the decoration to save space for the labels. +/// +/// If [decoration] is non-null (which is the default), the text field requires +/// one of its ancestors to be a [Material] widget. +/// +/// To integrate the [TextField] into a [Form] with other [FormField] widgets, +/// consider using [TextFormField]. +/// +/// {@template flutter.material.textfield.wantKeepAlive} +/// When the widget has focus, it will prevent itself from disposing via its +/// underlying [EditableText]'s [AutomaticKeepAliveClientMixin.wantKeepAlive] in +/// order to avoid losing the selection. Removing the focus will allow it to be +/// disposed. +/// {@endtemplate} +/// +/// Remember to call [TextEditingController.dispose] on the [TextEditingController] +/// when it is no longer needed. This will ensure we discard any resources used +/// by the object. +/// +/// If this field is part of a scrolling container that lazily constructs its +/// children, like a [ListView] or a [CustomScrollView], then a [controller] +/// should be specified. The controller's lifetime should be managed by a +/// stateful widget ancestor of the scrolling container. +/// +/// ## Obscured Input +/// +/// {@tool dartpad} +/// This example shows how to create a [TextField] that will obscure input. The +/// [InputDecoration] surrounds the field in a border using [OutlineInputBorder] +/// and adds a label. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.0.dart ** +/// {@end-tool} +/// +/// ## Reading values +/// +/// A common way to read a value from a TextField is to use the [onSubmitted] +/// callback. This callback is applied to the text field's current value when +/// the user finishes editing. +/// +/// {@tool dartpad} +/// This sample shows how to get a value from a TextField via the [onSubmitted] +/// callback. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.1.dart ** +/// {@end-tool} +/// +/// {@macro flutter.widgets.EditableText.lifeCycle} +/// +/// For most applications the [onSubmitted] callback will be sufficient for +/// reacting to user input. +/// +/// The [onEditingComplete] callback also runs when the user finishes editing. +/// It's different from [onSubmitted] because it has a default value which +/// updates the text controller and yields the keyboard focus. Applications that +/// require different behavior can override the default [onEditingComplete] +/// callback. +/// +/// Keep in mind you can also always read the current string from a TextField's +/// [TextEditingController] using [TextEditingController.text]. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// In the live Dartpad example above, try typing the emoji 👨‍👩‍👦 +/// into the field and submitting. Because the example code measures the length +/// with `value.characters.length`, the emoji is correctly counted as a single +/// character. +/// +/// {@macro flutter.widgets.editableText.showCaretOnScreen} +/// +/// {@macro flutter.widgets.editableText.accessibility} +/// +/// {@tool dartpad} +/// This sample shows how to style a text field to match a filled or outlined +/// Material Design 3 text field. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.2.dart ** +/// {@end-tool} +/// +/// ## Scrolling Considerations +/// +/// If this [TextField] is not a descendant of [Scaffold] and is being used +/// within a [Scrollable] or nested [Scrollable]s, consider placing a +/// [ScrollNotificationObserver] above the root [Scrollable] that contains this +/// [TextField] to ensure proper scroll coordination for [TextField] and its +/// components like [TextSelectionOverlay]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use the [Shortcuts] and [Actions] widgets +/// to create a custom `Shift+Enter` keyboard shortcut for inserting a new line +/// in a [TextField]. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TextFormField], which integrates with the [Form] widget. +/// * [InputDecorator], which shows the labels and other visual elements that +/// surround the actual text editing widget. +/// * [EditableText], which is the raw text editing control at the heart of a +/// [TextField]. The [EditableText] widget is rarely used directly unless +/// you are implementing an entirely different design language, such as +/// Cupertino. +/// * <https://material.io/design/components/text-fields.html> +/// * Cookbook: [Create and style a text field](https://docs.flutter.dev/cookbook/forms/text-input) +/// * Cookbook: [Handle changes to a text field](https://docs.flutter.dev/cookbook/forms/text-field-changes) +/// * Cookbook: [Retrieve the value of a text field](https://docs.flutter.dev/cookbook/forms/retrieve-input) +/// * Cookbook: [Focus and text fields](https://docs.flutter.dev/cookbook/forms/focus) +class TextField extends StatefulWidget { + /// Creates a Material Design text field. + /// + /// If [decoration] is non-null (which is the default), the text field requires + /// one of its ancestors to be a [Material] widget. + /// + /// To remove the decoration entirely (including the extra padding introduced + /// by the decoration to save space for the labels), set the [decoration] to + /// null. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. By default, it is one, meaning this is a single-line + /// text field. [maxLines] must not be zero. + /// + /// The [maxLength] property is set to null by default, which means the + /// number of characters allowed in the text field is not restricted. If + /// [maxLength] is set a character counter will be displayed below the + /// field showing how many characters have been entered. If the value is + /// set to a positive integer it will also display the maximum allowed + /// number of characters to be entered. If the value is set to + /// [TextField.noMaxLength] then only the current length is displayed. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. + /// The [maxLength] value must be either null or greater than zero. + /// + /// If [maxLengthEnforcement] is set to [MaxLengthEnforcement.none], then more + /// than [maxLength] characters may be entered, and the error counter and + /// divider will switch to the [decoration].errorStyle when the limit is + /// exceeded. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [EditableText.defaultSelectionHeightStyle] and + /// [EditableText.defaultSelectionHeightStyle], respectively. + /// + /// See also: + /// + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const TextField({ + super.key, + this.groupId = EditableText, + this.controller, + this.focusNode, + this.undoController, + this.decoration = const InputDecoration(), + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.showCursor, + this.autofocus = false, + this.statesController, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.enabled, + this.ignorePointers, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorOpacityAnimates, + this.cursorColor, + this.cursorErrorColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.selectAllOnFocus, + this.selectionControls, + this.onTap, + this.onTapAlwaysCalled = false, + this.onTapOutside, + this.onTapUpOutside, + this.mouseCursor, + this.buildCounter, + this.scrollController, + this.scrollPhysics, + this.autofillHints = const <String>[], + this.contentInsertionConfiguration, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled, + this.enableIMEPersonalizedLearning = true, + this.enableInlinePrediction, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.canRequestFocus = true, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.hintLocales, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = + smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = + smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength == TextField.noMaxLength || maxLength > 0), + // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + keyboardType = + keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText); + + /// The configuration for the magnifier of this text field. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] + /// on Android, and builds nothing on all other platforms. To suppress the + /// magnifier, consider passing [TextMagnifierConfiguration.disabled]. + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the magnifier that this text field uses. + /// + /// ** See code in examples/api/lib/widgets/text_magnifier/text_magnifier.0.dart ** + /// {@end-tool} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// Defines the keyboard focus for this widget. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// myFocusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + /// + /// ## Keyboard + /// + /// Requesting the focus will typically cause the keyboard to be shown + /// if it's not showing already. + /// + /// On Android, the user can hide the keyboard - without changing the focus - + /// with the system back button. They can restore the keyboard's visibility + /// by tapping on a text field. The user might hide the keyboard and + /// switch to a physical keyboard, or they might just need to get it + /// out of the way for a moment, to expose something it's + /// obscuring. In this case requesting the focus again will not + /// cause the focus to change, and will not make the keyboard visible. + /// + /// This widget builds an [EditableText] and will ensure that the keyboard is + /// showing when it is tapped by calling [EditableTextState.requestKeyboard()]. + final FocusNode? focusNode; + + /// The decoration to show around the text field. + /// + /// By default, draws a horizontal line under the text field but can be + /// configured to show an icon, label, hint text, and error text. + /// + /// Specify null to remove the decoration entirely (including the + /// extra padding introduced by the decoration to save space for the labels). + final InputDecoration? decoration; + + /// {@macro flutter.widgets.editableText.keyboardType} + final TextInputType keyboardType; + + /// {@template flutter.widgets.TextField.textInputAction} + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is + /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + /// {@endtemplate} + final TextInputAction? textInputAction; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// The style to use for the text being edited. + /// + /// This text style is also used as the base style for the [decoration]. + /// + /// If null, [TextTheme.bodyLarge] will be used. When the text field is disabled, + /// [TextTheme.bodyLarge] with an opacity of 0.38 will be used instead. + /// + /// If null and [ThemeData.useMaterial3] is false, [TextTheme.titleMedium] will + /// be used. When the text field is disabled, [TextTheme.titleMedium] with + /// [ThemeData.disabledColor] will be used instead. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign textAlign; + + /// {@macro flutter.material.InputDecorator.textAlignVertical} + final TextAlignVertical? textAlignVertical; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// Represents the interactive "state" of this widget in terms of a set of + /// [WidgetState]s, including [WidgetState.disabled], [WidgetState.hovered], + /// [WidgetState.error], and [WidgetState.focused]. + /// + /// Classes based on this one can provide their own + /// [WidgetStatesController] to which they've added listeners. + /// They can also update the controller's [WidgetStatesController.value] + /// however, this may only be done when it's safe to call + /// [State.setState], like in an event handler. + /// + /// The controller's [WidgetStatesController.value] represents the set of + /// states that a widget's visual properties, typically [WidgetStateProperty] + /// values, are resolved against. It is _not_ the intrinsic state of the widget. + /// The widget is responsible for ensuring that the controller's + /// [WidgetStatesController.value] tracks its intrinsic state. For example + /// one cannot request the keyboard focus for a widget by adding [WidgetState.focused] + /// to its controller. When the widget gains the or loses the focus it will + /// [WidgetStatesController.update] its controller's [WidgetStatesController.value] + /// and notify listeners of the change. + final MaterialStatesController? statesController; + + /// {@macro flutter.widgets.editableText.obscuringCharacter} + final String obscuringCharacter; + + /// {@macro flutter.widgets.editableText.obscureText} + final bool obscureText; + + /// {@macro flutter.widgets.editableText.autocorrect} + final bool? autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} + final SmartDashesType smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} + final SmartQuotesType smartQuotesType; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// {@macro flutter.widgets.editableText.maxLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; + + /// {@macro flutter.widgets.editableText.minLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; + + /// {@macro flutter.widgets.editableText.expands} + final bool expands; + + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final ToolbarOptions? toolbarOptions; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool? showCursor; + + /// If [maxLength] is set to this value, only the "current input length" + /// part of the character counter is shown. + static const int noMaxLength = -1; + + /// The maximum number of characters (Unicode grapheme clusters) to allow in + /// the text field. + /// + /// If set, a character counter will be displayed below the + /// field showing how many characters have been entered. If set to a number + /// greater than 0, it will also display the maximum number allowed. If set + /// to [TextField.noMaxLength] then only the current character count is displayed. + /// To remove the counter, set [InputDecoration.counterText] to an empty string or + /// return null from [TextField.buildCounter] callback. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. + /// + /// This value must be either null, [TextField.noMaxLength], or greater than 0. + /// If null (the default) then there is no limit to the number of characters + /// that can be entered. If set to [TextField.noMaxLength], then no limit will + /// be enforced, but the number of characters entered will still be displayed. + /// + /// Whitespace characters (e.g. newline, space, tab) are included in the + /// character count. + /// + /// If [maxLengthEnforcement] is [MaxLengthEnforcement.none], then more than + /// [maxLength] characters may be entered, but the error counter and divider + /// will switch to the [decoration]'s [InputDecoration.errorStyle] when the + /// limit is exceeded. + /// + /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} + final int? maxLength; + + /// Determines how the [maxLength] limit should be enforced. + /// + /// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement} + /// + /// {@macro flutter.services.textFormatter.maxLengthEnforcement} + final MaxLengthEnforcement? maxLengthEnforcement; + + /// {@macro flutter.widgets.editableText.onChanged} + /// + /// See also: + /// + /// * [inputFormatters], which are called before [onChanged] + /// runs and can validate and change ("format") the input value. + /// * [onEditingComplete], [onSubmitted]: + /// which are more specialized input change notifications. + final ValueChanged<String>? onChanged; + + /// {@macro flutter.widgets.editableText.onEditingComplete} + final VoidCallback? onEditingComplete; + + /// {@macro flutter.widgets.editableText.onSubmitted} + /// + /// See also: + /// + /// * [TextInputAction.next] and [TextInputAction.previous], which + /// automatically shift the focus to the next/previous focusable item when + /// the user is done editing. + final ValueChanged<String>? onSubmitted; + + /// {@macro flutter.widgets.editableText.onAppPrivateCommand} + final AppPrivateCommandCallback? onAppPrivateCommand; + + /// {@macro flutter.widgets.editableText.inputFormatters} + final List<TextInputFormatter>? inputFormatters; + + /// If false the text field is "disabled": it ignores taps and its + /// [decoration] is rendered in grey. + /// + /// If non-null this property overrides the [decoration]'s + /// [InputDecoration.enabled] property. + /// + /// When a text field is disabled, all of its children widgets are also + /// disabled, including the [InputDecoration.suffixIcon]. If you need to keep + /// the suffix icon interactive while disabling the text field, consider using + /// [readOnly] and [enableInteractiveSelection] instead: + /// + /// ```dart + /// TextField( + /// enabled: true, + /// readOnly: true, + /// enableInteractiveSelection: false, + /// decoration: InputDecoration( + /// suffixIcon: IconButton( + /// onPressed: () { + /// // This will work because the TextField is enabled + /// }, + /// icon: const Icon(Icons.edit_outlined), + /// ), + /// ), + /// ) + /// ``` + final bool? enabled; + + /// Determines whether this widget ignores pointer events. + /// + /// Defaults to null, and when null, does nothing. + final bool? ignorePointers; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// {@macro flutter.widgets.editableText.cursorOpacityAnimates} + final bool? cursorOpacityAnimates; + + /// The color of the cursor. + /// + /// The cursor indicates the current location of text insertion point in + /// the field. + /// + /// If this is null it will default to the ambient + /// [DefaultSelectionStyle.cursorColor]. If that is null, and the + /// [ThemeData.platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS] + /// it will use [CupertinoThemeData.primaryColor]. Otherwise it will use + /// the value of [ColorScheme.primary] of [ThemeData.colorScheme]. + final Color? cursorColor; + + /// The color of the cursor when the [InputDecorator] is showing an error. + /// + /// If this is null it will default to [TextStyle.color] of + /// [InputDecoration.errorStyle]. If that is null, it will use + /// [ColorScheme.error] of [ThemeData.colorScheme]. + final Color? cursorErrorColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle? selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle? selectionWidthStyle; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// If unset, defaults to [ThemeData.brightness]. + final Brightness? keyboardAppearance; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectAllOnFocus} + final bool? selectAllOnFocus; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// {@template flutter.material.textfield.onTap} + /// Called for the first tap in a series of taps. + /// + /// The text field builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the text field with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the text field's + /// internal gesture detector, provide this callback. + /// + /// If the text field is created with [enabled] false, taps will not be + /// recognized. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// text field's internal gesture detector, use a [Listener]. + /// {@endtemplate} + /// + /// If [onTapAlwaysCalled] is enabled, this will also be called for consecutive + /// taps. + final GestureTapCallback? onTap; + + /// Whether [onTap] should be called for every tap. + /// + /// Defaults to false, so [onTap] is only called for each distinct tap. When + /// enabled, [onTap] is called for every tap including consecutive taps. + final bool onTapAlwaysCalled; + + /// {@macro flutter.widgets.editableText.onTapOutside} + /// + /// {@tool dartpad} + /// This example shows how to use a `TextFieldTapRegion` to wrap a set of + /// "spinner" buttons that increment and decrement a value in the [TextField] + /// without causing the text field to lose keyboard focus. + /// + /// This example includes a generic `SpinnerField<T>` class that you can copy + /// into your own project and customize. + /// + /// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TapRegion] for how the region group is determined. + final TapRegionCallback? onTapOutside; + + /// {@macro flutter.widgets.editableText.onTapUpOutside} + final TapRegionUpCallback? onTapUpOutside; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.error]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If this property is null, [WidgetStateMouseCursor.textable] will be used. + /// + /// The [mouseCursor] is the only property of [TextField] that controls the + /// appearance of the mouse pointer. All other properties related to "cursor" + /// stand for the text cursor, which is usually a blinking vertical line at + /// the editing position. + final MouseCursor? mouseCursor; + + /// Callback that generates a custom [InputDecoration.counter] widget. + /// + /// See [InputCounterWidgetBuilder] for an explanation of the passed in + /// arguments. The returned widget will be placed below the line in place of + /// the default widget built when [InputDecoration.counterText] is specified. + /// + /// The returned widget will be wrapped in a [Semantics] widget for + /// accessibility, but it also needs to be accessible itself. For example, + /// if returning a Text widget, set the [Text.semanticsLabel] property. + /// + /// {@tool snippet} + /// ```dart + /// Widget counter( + /// BuildContext context, + /// { + /// required int currentLength, + /// required int? maxLength, + /// required bool isFocused, + /// } + /// ) { + /// return Text( + /// '$currentLength of $maxLength characters', + /// semanticsLabel: 'character count', + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// If buildCounter returns null, then no counter and no Semantics widget will + /// be created at all. + final InputCounterWidgetBuilder? buildCounter; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.scrollController} + final ScrollController? scrollController; + + /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable<String>? autofillHints; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// {@template flutter.material.textfield.restorationId} + /// Restoration ID to save and restore the state of the text field. + /// + /// If non-null, the text field will persist and restore its current scroll + /// offset and - if no [controller] has been provided - the content of the + /// text field. If a [controller] has been provided, it is the responsibility + /// of the owner of that controller to persist and restore it, e.g. by using + /// a [RestorableTextEditingController]. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + /// {@endtemplate} + final String? restorationId; + + /// {@macro flutter.widgets.editableText.scribbleEnabled} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool scribbleEnabled; + + /// {@macro flutter.widgets.editableText.stylusHandwritingEnabled} + final bool stylusHandwritingEnabled; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + /// {@macro flutter.services.TextInputConfiguration.enableInlinePrediction} + final bool? enableInlinePrediction; + + /// {@macro flutter.widgets.editableText.contentInsertionConfiguration} + final ContentInsertionConfiguration? contentInsertionConfiguration; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + /// * [BrowserContextMenu], which allows the browser's context menu on web to + /// be disabled and Flutter-rendered context menus to appear. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + /// Determine whether this text field can request the primary focus. + /// + /// Defaults to true. If false, the text field will not request focus + /// when tapped, or when its context menu is displayed. If false it will not + /// be possible to move the focus to the text field with tab key. + final bool canRequestFocus; + + /// {@macro flutter.widgets.undoHistory.controller} + final UndoHistoryController? undoController; + + /// {@macro flutter.services.TextInputConfiguration.hintLocales} + final List<Locale>? hintLocales; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + } + + /// {@macro flutter.widgets.EditableText.spellCheckConfiguration} + /// + /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this + /// configuration, then [materialMisspelledTextStyle] is used by default. + final SpellCheckConfiguration? spellCheckConfiguration; + + /// The [TextStyle] used to indicate misspelled words in the Material style. + /// + /// See also: + /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to + /// mark misspelled words with. + /// * [CupertinoTextField.cupertinoMisspelledTextStyle], the style configured + /// to mark misspelled words with in the Cupertino style. + static const TextStyle materialMisspelledTextStyle = TextStyle( + decoration: TextDecoration.underline, + decorationColor: Colors.red, + decorationStyle: TextDecorationStyle.wavy, + ); + + /// Default builder for [TextField]'s spell check suggestions toolbar. + /// + /// On Apple platforms, builds an iOS-style toolbar. Everywhere else, builds + /// an Android-style toolbar. + /// + /// See also: + /// * [spellCheckConfiguration], where this is typically specified for + /// [TextField]. + /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + /// parameter for which this is the default value for [TextField]. + /// * [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder], which + /// is like this but specifies the default for [CupertinoTextField]. + @visibleForTesting + static Widget defaultSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoSpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return SpellCheckSuggestionsToolbar.editableText(editableTextState: editableTextState); + } + } + + /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// had any missing values replaced with their defaults for the Android + /// platform. + static SpellCheckConfiguration inferAndroidSpellCheckConfiguration( + SpellCheckConfiguration? configuration, + ) { + if (configuration == null || configuration == const SpellCheckConfiguration.disabled()) { + return const SpellCheckConfiguration.disabled(); + } + return configuration.copyWith( + misspelledTextStyle: + configuration.misspelledTextStyle ?? TextField.materialMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: + configuration.spellCheckSuggestionsToolbarBuilder ?? + TextField.defaultSpellCheckSuggestionsToolbarBuilder, + ); + } + + @override + State<TextField> createState() => _TextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty<TextEditingController>('controller', controller, defaultValue: null), + ); + properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null)); + properties.add( + DiagnosticsProperty<UndoHistoryController>( + 'undoController', + undoController, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<bool>('enabled', enabled, defaultValue: null)); + properties.add( + DiagnosticsProperty<InputDecoration>( + 'decoration', + decoration, + defaultValue: const InputDecoration(), + ), + ); + properties.add( + DiagnosticsProperty<TextInputType>( + 'keyboardType', + keyboardType, + defaultValue: TextInputType.text, + ), + ); + properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false)); + properties.add( + DiagnosticsProperty<String>('obscuringCharacter', obscuringCharacter, defaultValue: '•'), + ); + properties.add(DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false)); + properties.add(DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: null)); + properties.add( + EnumProperty<SmartDashesType>( + 'smartDashesType', + smartDashesType, + defaultValue: obscureText ? SmartDashesType.disabled : SmartDashesType.enabled, + ), + ); + properties.add( + EnumProperty<SmartQuotesType>( + 'smartQuotesType', + smartQuotesType, + defaultValue: obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled, + ), + ); + properties.add( + DiagnosticsProperty<bool>('enableSuggestions', enableSuggestions, defaultValue: true), + ); + properties.add(IntProperty('maxLines', maxLines, defaultValue: 1)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false)); + properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); + properties.add( + EnumProperty<MaxLengthEnforcement>( + 'maxLengthEnforcement', + maxLengthEnforcement, + defaultValue: null, + ), + ); + properties.add( + EnumProperty<TextInputAction>('textInputAction', textInputAction, defaultValue: null), + ); + properties.add( + EnumProperty<TextCapitalization>( + 'textCapitalization', + textCapitalization, + defaultValue: TextCapitalization.none, + ), + ); + properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: TextAlign.start)); + properties.add( + DiagnosticsProperty<TextAlignVertical>( + 'textAlignVertical', + textAlignVertical, + defaultValue: null, + ), + ); + properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null)); + properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); + properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null)); + properties.add( + DiagnosticsProperty<bool>('cursorOpacityAnimates', cursorOpacityAnimates, defaultValue: null), + ); + properties.add(ColorProperty('cursorColor', cursorColor, defaultValue: null)); + properties.add(ColorProperty('cursorErrorColor', cursorErrorColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<Brightness>('keyboardAppearance', keyboardAppearance, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<EdgeInsetsGeometry>( + 'scrollPadding', + scrollPadding, + defaultValue: const EdgeInsets.all(20.0), + ), + ); + properties.add( + FlagProperty( + 'selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled', + ), + ); + properties.add( + DiagnosticsProperty<TextSelectionControls>( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<ScrollController>( + 'scrollController', + scrollController, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge), + ); + properties.add( + DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true), + ); + properties.add( + DiagnosticsProperty<bool>( + 'stylusHandwritingEnabled', + stylusHandwritingEnabled, + defaultValue: EditableText.defaultStylusHandwritingEnabled, + ), + ); + properties.add( + DiagnosticsProperty<bool>( + 'enableIMEPersonalizedLearning', + enableIMEPersonalizedLearning, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty<bool?>( + 'enableInlinePrediction', + enableInlinePrediction, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<SpellCheckConfiguration>( + 'spellCheckConfiguration', + spellCheckConfiguration, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<List<String>>( + 'contentCommitMimeTypes', + contentInsertionConfiguration?.allowedMimeTypes ?? const <String>[], + defaultValue: contentInsertionConfiguration == null + ? const <String>[] + : kDefaultContentInsertionMimeTypes, + ), + ); + properties.add( + DiagnosticsProperty<List<Locale>?>('hintLocales', hintLocales, defaultValue: null), + ); + } +} + +class _TextFieldState extends State<TextField> + with RestorationMixin + implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { + RestorableTextEditingController? _controller; + TextEditingController get _effectiveController => widget.controller ?? _controller!.value; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); + + MaxLengthEnforcement get _effectiveMaxLengthEnforcement => + widget.maxLengthEnforcement ?? + LengthLimitingTextInputFormatter.getDefaultMaxLengthEnforcement(Theme.of(context).platform); + + bool _isHovering = false; + + bool get needsCounter => + widget.maxLength != null && + widget.decoration != null && + widget.decoration!.counterText == null; + + bool _showSelectionHandles = false; + + late _TextFieldSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + late bool forcePressEnabled; + + @override + final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>(); + + @override + bool get selectionEnabled => widget.selectionEnabled && _isEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + bool get _isEnabled => widget.enabled ?? widget.decoration?.enabled ?? true; + + int get _currentLength => _effectiveController.value.text.characters.length; + + bool get _hasIntrinsicError => + widget.maxLength != null && + widget.maxLength! > 0 && + (widget.controller == null + ? !restorePending && _effectiveController.value.text.characters.length > widget.maxLength! + : _effectiveController.value.text.characters.length > widget.maxLength!); + + bool get _hasError => + widget.decoration?.errorText != null || + widget.decoration?.error != null || + _hasIntrinsicError; + + Color get _errorColor => + widget.cursorErrorColor ?? + _getEffectiveDecoration().errorStyle?.color ?? + Theme.of(context).colorScheme.error; + + InputDecoration _getEffectiveDecoration() { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final ThemeData themeData = Theme.of(context); + final InputDecorationThemeData decorationTheme = InputDecorationTheme.of(context); + final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration()) + .applyDefaults(decorationTheme) + .copyWith( + enabled: _isEnabled, + hintMaxLines: + widget.decoration?.hintMaxLines ?? decorationTheme.hintMaxLines ?? widget.maxLines, + ); + + // No need to build anything if counter or counterText were given directly. + if (effectiveDecoration.counter != null || effectiveDecoration.counterText != null) { + return effectiveDecoration; + } + + // If buildCounter was provided, use it to generate a counter widget. + Widget? counter; + final int currentLength = _currentLength; + if (effectiveDecoration.counter == null && + effectiveDecoration.counterText == null && + widget.buildCounter != null) { + final bool isFocused = _effectiveFocusNode.hasFocus; + final Widget? builtCounter = widget.buildCounter!( + context, + currentLength: currentLength, + maxLength: widget.maxLength, + isFocused: isFocused, + ); + // If buildCounter returns null, don't add a counter widget to the field. + if (builtCounter != null) { + counter = Semantics(container: true, liveRegion: isFocused, child: builtCounter); + } + return effectiveDecoration.copyWith(counter: counter); + } + + if (widget.maxLength == null) { + return effectiveDecoration; + } // No counter widget + + var counterText = '$currentLength'; + var semanticCounterText = ''; + + // Handle a real maxLength (positive number) + if (widget.maxLength! > 0) { + // Show the maxLength in the counter + counterText += '/${widget.maxLength}'; + final int remaining = (widget.maxLength! - currentLength).clamp(0, widget.maxLength!); + semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining); + } + + if (_hasIntrinsicError) { + return effectiveDecoration.copyWith( + errorText: effectiveDecoration.errorText ?? '', + counterStyle: + effectiveDecoration.errorStyle ?? + (themeData.useMaterial3 + ? _m3CounterErrorStyle(context) + : _m2CounterErrorStyle(context)), + counterText: counterText, + semanticCounterText: semanticCounterText, + ); + } + + return effectiveDecoration.copyWith( + counterText: counterText, + semanticCounterText: semanticCounterText, + ); + } + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = _TextFieldSelectionGestureDetectorBuilder(state: this); + if (widget.controller == null) { + _createLocalController(); + } + _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled; + _effectiveFocusNode.addListener(_handleFocusChanged); + _initStatesController(); + } + + bool get _canRequestFocus { + final NavigationMode mode = + MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional; + return switch (mode) { + NavigationMode.traditional => widget.canRequestFocus && _isEnabled, + NavigationMode.directional => true, + }; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _effectiveFocusNode.canRequestFocus = _canRequestFocus; + } + + @override + void didUpdateWidget(TextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + + _effectiveFocusNode.canRequestFocus = _canRequestFocus; + + if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly && _isEnabled) { + if (_effectiveController.selection.isCollapsed) { + _showSelectionHandles = !widget.readOnly; + } + } + + if (widget.statesController == oldWidget.statesController) { + _statesController.update(WidgetState.disabled, !_isEnabled); + _statesController.update(WidgetState.hovered, _isHovering); + _statesController.update(WidgetState.focused, _effectiveFocusNode.hasFocus); + _statesController.update(WidgetState.error, _hasError); + } else { + oldWidget.statesController?.removeListener(_handleStatesControllerChange); + if (widget.statesController != null) { + _internalStatesController?.dispose(); + _internalStatesController = null; + } + _initStatesController(); + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); + _focusNode?.dispose(); + _controller?.dispose(); + _statesController.removeListener(_handleStatesControllerChange); + _internalStatesController?.dispose(); + super.dispose(); + } + + EditableTextState? get _editableText => editableTextKey.currentState; + + void _requestKeyboard() { + _editableText?.requestKeyboard(); + } + + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + // When the text field is activated by something that doesn't trigger the + // selection toolbar, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar || + !_selectionGestureDetectorBuilder.shouldShowSelectionHandles) { + return false; + } + + if (cause == SelectionChangedCause.keyboard) { + return false; + } + + if (widget.readOnly && _effectiveController.selection.isCollapsed) { + return false; + } + + if (!_isEnabled) { + return false; + } + + if (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.stylusHandwriting) { + return true; + } + + if (_effectiveController.text.isNotEmpty) { + return true; + } + + return false; + } + + void _handleFocusChanged() { + setState(() { + // Rebuild the widget on focus change to show/hide the text selection + // highlight. + }); + _statesController.update(WidgetState.focused, _effectiveFocusNode.hasFocus); + } + + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.longPress) { + _editableText?.bringIntoView(selection.extent); + } + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (cause == SelectionChangedCause.drag) { + _editableText?.hideToolbar(); + } + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_effectiveController.selection.isCollapsed) { + _editableText!.toggleToolbar(); + } + } + + void _handleHover(bool hovering) { + if (hovering != _isHovering) { + setState(() { + _isHovering = hovering; + }); + _statesController.update(WidgetState.hovered, _isHovering); + } + } + + // Material states controller. + MaterialStatesController? _internalStatesController; + + void _handleStatesControllerChange() { + // Force a rebuild to resolve WidgetStateProperty properties. + setState(() {}); + } + + MaterialStatesController get _statesController => + widget.statesController ?? _internalStatesController!; + + void _initStatesController() { + if (widget.statesController == null) { + _internalStatesController = MaterialStatesController(); + } + _statesController.update(WidgetState.disabled, !_isEnabled); + _statesController.update(WidgetState.hovered, _isHovering); + _statesController.update(WidgetState.focused, _effectiveFocusNode.hasFocus); + _statesController.update(WidgetState.error, _hasError); + _statesController.addListener(_handleStatesControllerChange); + } + + // AutofillClient implementation start. + @override + String get autofillId => _editableText!.autofillId; + + @override + void autofill(TextEditingValue newEditingValue) => _editableText!.autofill(newEditingValue); + + @override + TextInputConfiguration get textInputConfiguration { + final List<String>? autofillHints = widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: _effectiveController.value, + hintText: (widget.decoration ?? const InputDecoration()).hintText, + ) + : AutofillConfiguration.disabled; + + return _editableText!.textInputConfiguration.copyWith( + autofillConfiguration: autofillConfiguration, + ); + } + // AutofillClient implementation end. + + TextStyle _getInputStyleForState(TextStyle style) { + final ThemeData theme = Theme.of(context); + final TextStyle stateStyle = WidgetStateProperty.resolveAs( + theme.useMaterial3 ? _m3StateInputStyle(context)! : _m2StateInputStyle(context)!, + _statesController.value, + ); + final TextStyle providedStyle = WidgetStateProperty.resolveAs(style, _statesController.value); + return providedStyle.merge(stateStyle); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + assert( + !(widget.style != null && + !widget.style!.inherit && + (widget.style!.fontSize == null || widget.style!.textBaseline == null)), + 'inherit false style must supply fontSize and textBaseline', + ); + + final ThemeData theme = Theme.of(context); + final DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(context); + final TextStyle? providedStyle = WidgetStateProperty.resolveAs( + widget.style, + _statesController.value, + ); + final TextStyle style = _getInputStyleForState( + theme.useMaterial3 ? _m3InputStyle(context) : theme.textTheme.titleMedium!, + ).merge(providedStyle); + final Brightness keyboardAppearance = widget.keyboardAppearance ?? theme.brightness; + final TextEditingController controller = _effectiveController; + final FocusNode focusNode = _effectiveFocusNode; + final formatters = <TextInputFormatter>[ + ...?widget.inputFormatters, + if (widget.maxLength != null) + LengthLimitingTextInputFormatter( + widget.maxLength, + maxLengthEnforcement: _effectiveMaxLengthEnforcement, + ), + ]; + + // Set configuration as disabled if not otherwise specified. If specified, + // ensure that configuration uses the correct style for misspelled words for + // the current platform, unless a custom style is specified. + final SpellCheckConfiguration spellCheckConfiguration; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + spellCheckConfiguration = CupertinoTextField.inferIOSSpellCheckConfiguration( + widget.spellCheckConfiguration, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + spellCheckConfiguration = TextField.inferAndroidSpellCheckConfiguration( + widget.spellCheckConfiguration, + ); + } + + TextSelectionControls? textSelectionControls = widget.selectionControls; + final bool paintCursorAboveText; + bool? cursorOpacityAnimates = widget.cursorOpacityAnimates; + Offset? cursorOffset; + final Color cursorColor; + final Color selectionColor; + Color? autocorrectionTextRectColor; + Radius? cursorRadius = widget.cursorRadius; + VoidCallback? handleDidGainAccessibilityFocus; + VoidCallback? handleDidLoseAccessibilityFocus; + + switch (theme.platform) { + case TargetPlatform.iOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = true; + textSelectionControls ??= cupertinoTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates ??= true; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = + selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); + autocorrectionTextRectColor = selectionColor; + + case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = + selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); + + case TargetPlatform.linux: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + + case TargetPlatform.windows: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + } + + Widget child = RepaintBoundary( + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + readOnly: widget.readOnly || !_isEnabled, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, + controller: controller, + focusNode: focusNode, + undoController: widget.undoController, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + // Only show the selection highlight when the text field is focused. + selectionColor: focusNode.hasFocus ? selectionColor : null, + selectionControls: widget.selectionEnabled ? textSelectionControls : null, + onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onAppPrivateCommand: widget.onAppPrivateCommand, + groupId: widget.groupId, + onSelectionHandleTapped: _handleSelectionHandleTapped, + onTapOutside: widget.onTapOutside, + onTapUpOutside: widget.onTapUpOutside, + inputFormatters: formatters, + rendererIgnoresPointer: true, + mouseCursor: MouseCursor.defer, // TextField will handle the cursor + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + cursorOpacityAnimates: cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + backgroundCursorColor: CupertinoColors.inactiveGray, + scrollPadding: widget.scrollPadding, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: widget.enableInteractiveSelection, + selectAllOnFocus: widget.selectAllOnFocus, + dragStartBehavior: widget.dragStartBehavior, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + autofillHints: widget.autofillHints, + autofillClient: this, + autocorrectionTextRectColor: autocorrectionTextRectColor, + clipBehavior: widget.clipBehavior, + restorationId: 'editable', + scribbleEnabled: widget.scribbleEnabled, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + enableInlinePrediction: widget.enableInlinePrediction, + contentInsertionConfiguration: widget.contentInsertionConfiguration, + contextMenuBuilder: widget.contextMenuBuilder, + spellCheckConfiguration: spellCheckConfiguration, + magnifierConfiguration: + widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, + hintLocales: widget.hintLocales, + ), + ), + ); + + if (widget.decoration != null) { + child = AnimatedBuilder( + animation: Listenable.merge(<Listenable>[focusNode, controller]), + builder: (BuildContext context, Widget? child) { + return InputDecorator( + decoration: _getEffectiveDecoration(), + baseStyle: widget.style, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + isHovering: _isHovering, + isFocused: focusNode.hasFocus, + isEmpty: controller.value.text.isEmpty, + expands: widget.expands, + child: child, + ); + }, + child: child, + ); + } + final MouseCursor effectiveMouseCursor = WidgetStateProperty.resolveAs<MouseCursor>( + widget.mouseCursor ?? WidgetStateMouseCursor.textable, + _statesController.value, + ); + + final int? semanticsMaxValueLength; + if (_effectiveMaxLengthEnforcement != MaxLengthEnforcement.none && + widget.maxLength != null && + widget.maxLength! > 0) { + semanticsMaxValueLength = widget.maxLength; + } else { + semanticsMaxValueLength = null; + } + + return MouseRegion( + cursor: effectiveMouseCursor, + onEnter: (PointerEnterEvent event) => _handleHover(true), + onExit: (PointerExitEvent event) => _handleHover(false), + child: TextFieldTapRegion( + child: IgnorePointer( + ignoring: widget.ignorePointers ?? !_isEnabled, + child: AnimatedBuilder( + animation: controller, // changes the _currentLength + builder: (BuildContext context, Widget? child) { + return Semantics( + enabled: _isEnabled, + maxValueLength: semanticsMaxValueLength, + currentValueLength: _currentLength, + onTap: widget.readOnly + ? null + : () { + if (!_effectiveController.selection.isValid) { + _effectiveController.selection = TextSelection.collapsed( + offset: _effectiveController.text.length, + ); + } + _requestKeyboard(); + }, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + onDidLoseAccessibilityFocus: handleDidLoseAccessibilityFocus, + onFocus: _isEnabled + ? () { + assert( + _effectiveFocusNode.canRequestFocus, + 'Received SemanticsAction.focus from the engine. However, the FocusNode ' + 'of this text field cannot gain focus. This likely indicates a bug. ' + 'If this text field cannot be focused (e.g. because it is not ' + 'enabled), then its corresponding semantics node must be configured ' + 'such that the assistive technology cannot request focus on it.', + ); + + if (_effectiveFocusNode.canRequestFocus && !_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.requestFocus(); + } else if (!widget.readOnly) { + // If the platform requested focus, that means that previously the + // platform believed that the text field did not have focus (even + // though Flutter's widget system believed otherwise). This likely + // means that the on-screen keyboard is hidden, or more generally, + // there is no current editing session in this field. To correct + // that, keyboard must be requested. + // + // A concrete scenario where this can happen is when the user + // dismisses the keyboard on the web. The editing session is + // closed by the engine, but the text field widget stays focused + // in the framework. + _requestKeyboard(); + } + } + : null, + child: child, + ); + }, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: child, + ), + ), + ), + ), + ); + } +} + +TextStyle? _m2StateInputStyle(BuildContext context) => + WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final ThemeData theme = Theme.of(context); + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: theme.disabledColor); + } + return TextStyle(color: theme.textTheme.titleMedium?.color); + }); + +TextStyle _m2CounterErrorStyle(BuildContext context) => + Theme.of(context).textTheme.bodySmall!.copyWith(color: Theme.of(context).colorScheme.error); + +// BEGIN GENERATED TOKEN PROPERTIES - TextField + +// 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. + +// dart format off +TextStyle? _m3StateInputStyle(BuildContext context) => WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return TextStyle(color: Theme.of(context).textTheme.bodyLarge!.color?.withOpacity(0.38)); + } + return TextStyle(color: Theme.of(context).textTheme.bodyLarge!.color); +}); + +TextStyle _m3InputStyle(BuildContext context) => Theme.of(context).textTheme.bodyLarge!; + +TextStyle _m3CounterErrorStyle(BuildContext context) => + Theme.of(context).textTheme.bodySmall!.copyWith(color: Theme.of(context).colorScheme.error); +// dart format on + +// END GENERATED TOKEN PROPERTIES - TextField diff --git a/packages/material_ui/lib/src/text_form_field.dart b/packages/material_ui/lib/src/text_form_field.dart new file mode 100644 index 000000000000..d1ca42c51f81 --- /dev/null +++ b/packages/material_ui/lib/src/text_form_field.dart @@ -0,0 +1,452 @@ +// 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:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'adaptive_text_selection_toolbar.dart'; +import 'input_decorator.dart'; +import 'material_state.dart'; +import 'text_field.dart'; + +export 'package:flutter/services.dart' show SmartDashesType, SmartQuotesType; + +/// A [FormField] that contains a [TextField]. +/// +/// This is a convenience widget that wraps a [TextField] widget in a +/// [FormField]. +/// +/// A [Form] ancestor is not required. The [Form] allows one to +/// save, reset, or validate multiple fields at once. To use without a [Form], +/// pass a `GlobalKey<FormFieldState>` (see [GlobalKey]) to the constructor and use +/// [GlobalKey.currentState] to save or reset the form field. +/// +/// When a [controller] is specified, its [TextEditingController.text] +/// defines the [initialValue]. If this [FormField] is part of a scrolling +/// container that lazily constructs its children, like a [ListView] or a +/// [CustomScrollView], then a [controller] should be specified. +/// The controller's lifetime should be managed by a stateful widget ancestor +/// of the scrolling container. +/// +/// If a [controller] is not specified, [initialValue] can be used to give +/// the automatically generated controller an initial value. +/// +/// {@macro flutter.material.textfield.wantKeepAlive} +/// +/// Remember to call [TextEditingController.dispose] of the [TextEditingController] +/// when it is no longer needed. This will ensure any resources used by the object +/// are discarded. +/// +/// By default, `decoration` will apply the ambient [InputDecorationThemeData] for +/// the current context to the [InputDecoration], see +/// [InputDecoration.applyDefaults]. +/// +/// For a documentation about the various parameters, see [TextField]. +/// +/// {@tool snippet} +/// +/// Creates a [TextFormField] with an [InputDecoration] and validator function. +/// +/// ![If the user enters valid text, the TextField appears normally without any warnings to the user](https://flutter.github.io/assets-for-api-docs/assets/material/text_form_field.png) +/// +/// ![If the user enters invalid text, the error message returned from the validator function is displayed in dark red underneath the input](https://flutter.github.io/assets-for-api-docs/assets/material/text_form_field_error.png) +/// +/// ```dart +/// TextFormField( +/// decoration: const InputDecoration( +/// icon: Icon(Icons.person), +/// hintText: 'What do people call you?', +/// labelText: 'Name *', +/// ), +/// onSaved: (String? value) { +/// // This optional block of code can be used to run +/// // code when the user saves the form. +/// }, +/// validator: (String? value) { +/// return (value != null && value.contains('@')) ? 'Do not use the @ char.' : null; +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to move the focus to the next field when the user +/// presses the SPACE key. +/// +/// ** See code in examples/api/lib/material/text_form_field/text_form_field.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to force an error text to the field after making +/// an asynchronous call. +/// +/// ** See code in examples/api/lib/material/text_form_field/text_form_field.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * <https://material.io/design/components/text-fields.html> +/// * [TextField], which is the underlying text field without the [Form] +/// integration. +/// * [InputDecorator], which shows the labels and other visual elements that +/// surround the actual text editing widget. +/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). +class TextFormField extends FormField<String> { + /// Creates a [FormField] that contains a [TextField]. + /// + /// When a [controller] is specified, [initialValue] must be null (the + /// default). If [controller] is null, then a [TextEditingController] + /// will be constructed automatically and its `text` will be initialized + /// to [initialValue] or the empty string. + /// + /// For documentation about the various parameters, see the [TextField] class + /// and [TextField.new], the constructor. + TextFormField({ + super.key, + this.groupId = EditableText, + this.controller, + String? initialValue, + FocusNode? focusNode, + super.forceErrorText, + InputDecoration? decoration = const InputDecoration(), + TextInputType? keyboardType, + TextCapitalization textCapitalization = TextCapitalization.none, + TextInputAction? textInputAction, + TextStyle? style, + StrutStyle? strutStyle, + TextDirection? textDirection, + TextAlign textAlign = TextAlign.start, + TextAlignVertical? textAlignVertical, + bool autofocus = false, + bool readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + ToolbarOptions? toolbarOptions, + bool? showCursor, + String obscuringCharacter = '•', + bool obscureText = false, + bool autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + bool enableSuggestions = true, + MaxLengthEnforcement? maxLengthEnforcement, + int? maxLines = 1, + int? minLines, + bool expands = false, + int? maxLength, + this.onChanged, + GestureTapCallback? onTap, + bool onTapAlwaysCalled = false, + TapRegionCallback? onTapOutside, + TapRegionUpCallback? onTapUpOutside, + VoidCallback? onEditingComplete, + ValueChanged<String>? onFieldSubmitted, + super.onSaved, + super.validator, + super.errorBuilder, + List<TextInputFormatter>? inputFormatters, + bool? enabled, + bool? ignorePointers, + double cursorWidth = 2.0, + double? cursorHeight, + Radius? cursorRadius, + Color? cursorColor, + Color? cursorErrorColor, + Brightness? keyboardAppearance, + EdgeInsets scrollPadding = const EdgeInsets.all(20.0), + bool? enableInteractiveSelection, + bool? selectAllOnFocus, + TextSelectionControls? selectionControls, + InputCounterWidgetBuilder? buildCounter, + ScrollPhysics? scrollPhysics, + Iterable<String>? autofillHints, + AutovalidateMode? autovalidateMode, + ScrollController? scrollController, + super.restorationId, + bool enableIMEPersonalizedLearning = true, + MouseCursor? mouseCursor, + EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder, + SpellCheckConfiguration? spellCheckConfiguration, + TextMagnifierConfiguration? magnifierConfiguration, + UndoHistoryController? undoController, + AppPrivateCommandCallback? onAppPrivateCommand, + bool? cursorOpacityAnimates, + ui.BoxHeightStyle? selectionHeightStyle, + ui.BoxWidthStyle? selectionWidthStyle, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + ContentInsertionConfiguration? contentInsertionConfiguration, + MaterialStatesController? statesController, + Clip clipBehavior = Clip.hardEdge, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + bool scribbleEnabled = true, + bool stylusHandwritingEnabled = EditableText.defaultStylusHandwritingEnabled, + bool canRequestFocus = true, + List<Locale>? hintLocales, + }) : assert(initialValue == null || controller == null), + assert(obscuringCharacter.length == 1), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength == TextField.noMaxLength || maxLength > 0), + assert( + errorBuilder == null || decoration?.errorText == null, + 'Declaring both errorBuilder and decoration.errorText is not supported.', + ), + super( + initialValue: controller != null ? controller.text : (initialValue ?? ''), + enabled: enabled ?? decoration?.enabled ?? true, + autovalidateMode: autovalidateMode ?? AutovalidateMode.disabled, + builder: (FormFieldState<String> field) { + final state = field as _TextFormFieldState; + InputDecoration effectiveDecoration = (decoration ?? const InputDecoration()) + .applyDefaults(InputDecorationTheme.of(field.context)); + + final String? errorText = field.errorText; + if (errorText != null) { + effectiveDecoration = errorBuilder != null + ? effectiveDecoration.copyWith(error: errorBuilder(state.context, errorText)) + : effectiveDecoration.copyWith(errorText: errorText); + } + + void onChangedHandler(String value) { + field.didChange(value); + onChanged?.call(value); + } + + return UnmanagedRestorationScope( + bucket: field.bucket, + child: TextField( + groupId: groupId, + restorationId: restorationId, + controller: state._effectiveController, + focusNode: focusNode, + decoration: effectiveDecoration, + keyboardType: keyboardType, + textInputAction: textInputAction, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textAlignVertical: textAlignVertical, + textDirection: textDirection, + textCapitalization: textCapitalization, + autofocus: autofocus, + statesController: statesController, + toolbarOptions: toolbarOptions, + readOnly: readOnly, + showCursor: showCursor, + obscuringCharacter: obscuringCharacter, + obscureText: obscureText, + autocorrect: autocorrect, + smartDashesType: + smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType: + smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + enableSuggestions: enableSuggestions, + maxLengthEnforcement: maxLengthEnforcement, + maxLines: maxLines, + minLines: minLines, + expands: expands, + maxLength: maxLength, + onChanged: onChangedHandler, + onTap: onTap, + onTapAlwaysCalled: onTapAlwaysCalled, + onTapOutside: onTapOutside, + onTapUpOutside: onTapUpOutside, + onEditingComplete: onEditingComplete, + onSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + enabled: enabled ?? decoration?.enabled ?? true, + ignorePointers: ignorePointers, + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + cursorErrorColor: cursorErrorColor, + scrollPadding: scrollPadding, + scrollPhysics: scrollPhysics, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: + enableInteractiveSelection ?? (!obscureText || !readOnly), + selectAllOnFocus: selectAllOnFocus, + selectionControls: selectionControls, + buildCounter: buildCounter, + autofillHints: autofillHints, + scrollController: scrollController, + enableIMEPersonalizedLearning: enableIMEPersonalizedLearning, + mouseCursor: mouseCursor, + contextMenuBuilder: contextMenuBuilder, + spellCheckConfiguration: spellCheckConfiguration, + magnifierConfiguration: magnifierConfiguration, + undoController: undoController, + onAppPrivateCommand: onAppPrivateCommand, + cursorOpacityAnimates: cursorOpacityAnimates, + selectionHeightStyle: + selectionHeightStyle ?? EditableText.defaultSelectionHeightStyle, + selectionWidthStyle: selectionWidthStyle ?? EditableText.defaultSelectionWidthStyle, + dragStartBehavior: dragStartBehavior, + contentInsertionConfiguration: contentInsertionConfiguration, + clipBehavior: clipBehavior, + scribbleEnabled: scribbleEnabled, + stylusHandwritingEnabled: stylusHandwritingEnabled, + canRequestFocus: canRequestFocus, + hintLocales: hintLocales, + ), + ); + }, + ); + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController] and + /// initialize its [TextEditingController.text] with [initialValue]. + final TextEditingController? controller; + + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + + /// {@template flutter.material.TextFormField.onChanged} + /// Called when the user initiates a change to the TextField's + /// value: when they have inserted or deleted text or reset the form. + /// {@endtemplate} + final ValueChanged<String>? onChanged; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); + } + + @override + FormFieldState<String> createState() => _TextFormFieldState(); +} + +class _TextFormFieldState extends FormFieldState<String> { + RestorableTextEditingController? _controller; + late final String? _initialValue; + + TextEditingController get _effectiveController => _textFormField.controller ?? _controller!.value; + + TextFormField get _textFormField => super.widget as TextFormField; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + super.restoreState(oldBucket, initialRestore); + if (_controller != null) { + _registerController(); + } + // Make sure to update the internal [FormFieldState] value to sync up with + // text editing controller value. + setValue(_effectiveController.text); + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + void initState() { + super.initState(); + if (_textFormField.controller == null) { + _createLocalController( + widget.initialValue != null ? TextEditingValue(text: widget.initialValue!) : null, + ); + } else { + _textFormField.controller!.addListener(_handleControllerChanged); + } + _initialValue = _textFormField.initialValue ?? _textFormField.controller?.text; + } + + @override + void didUpdateWidget(TextFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (_textFormField.controller != oldWidget.controller) { + oldWidget.controller?.removeListener(_handleControllerChanged); + _textFormField.controller?.addListener(_handleControllerChanged); + + if (oldWidget.controller != null && _textFormField.controller == null) { + _createLocalController(oldWidget.controller!.value); + } + + if (_textFormField.controller != null) { + setValue(_textFormField.controller!.text); + if (oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + } + } + } + + @override + void dispose() { + _textFormField.controller?.removeListener(_handleControllerChanged); + _controller?.dispose(); + super.dispose(); + } + + @override + void didChange(String? value) { + super.didChange(value); + + if (_effectiveController.text != value) { + _effectiveController.value = TextEditingValue(text: value ?? ''); + } + } + + @override + void reset() { + // Set the controller value before calling super.reset() to let + // _handleControllerChanged suppress the change. + _effectiveController.value = TextEditingValue(text: _initialValue ?? ''); + super.reset(); + _textFormField.onChanged?.call(_effectiveController.text); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController.text != value) { + didChange(_effectiveController.text); + } + } +} diff --git a/packages/material_ui/lib/src/text_selection.dart b/packages/material_ui/lib/src/text_selection.dart new file mode 100644 index 000000000000..fdca41ced7fb --- /dev/null +++ b/packages/material_ui/lib/src/text_selection.dart @@ -0,0 +1,321 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'material_localizations.dart'; +import 'text_selection_theme.dart'; +import 'text_selection_toolbar.dart'; +import 'text_selection_toolbar_text_button.dart'; +import 'theme.dart'; + +const double _kHandleSize = 22.0; + +// Padding between the toolbar and the anchor. +const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0; +const double _kToolbarContentDistance = 8.0; + +/// Android Material styled text selection handle controls. +/// +/// Specifically does not manage the toolbar, which is left to +/// [EditableText.contextMenuBuilder]. +@Deprecated( + 'Use `MaterialTextSelectionControls`. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', +) +class MaterialTextSelectionHandleControls extends MaterialTextSelectionControls + with TextSelectionHandleControls {} + +/// Android Material styled text selection controls. +/// +/// The [materialTextSelectionControls] global variable has a +/// suitable instance of this class. +class MaterialTextSelectionControls extends TextSelectionControls { + /// Returns the size of the Material handle. + @override + Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize); + + /// Builder for material-style copy/paste text selection toolbar. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List<TextSelectionPoint> endpoints, + TextSelectionDelegate delegate, + ValueListenable<ClipboardStatus>? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return _TextSelectionControlsToolbar( + globalEditableRegion: globalEditableRegion, + textLineHeight: textLineHeight, + selectionMidpoint: selectionMidpoint, + endpoints: endpoints, + delegate: delegate, + clipboardStatus: clipboardStatus, + handleCut: canCut(delegate) ? () => handleCut(delegate) : null, + handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null, + handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null, + handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null, + ); + } + + /// Builder for material-style text selection handles. + @override + Widget buildHandle( + BuildContext context, + TextSelectionHandleType type, + double textHeight, [ + VoidCallback? onTap, + ]) { + final ThemeData theme = Theme.of(context); + final Color handleColor = + TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary; + final Widget handle = SizedBox.square( + dimension: _kHandleSize, + child: CustomPaint( + painter: _TextSelectionHandlePainter(color: handleColor), + child: GestureDetector(onTap: onTap, behavior: HitTestBehavior.translucent), + ), + ); + + // [handle] is a circle, with a rectangle in the top left quadrant of that + // circle (an onion pointing to 10:30). We rotate [handle] to point + // straight up or up-right depending on the handle type. + return switch (type) { + TextSelectionHandleType.left => Transform.rotate( + angle: math.pi / 2.0, + child: handle, + ), // points up-right + TextSelectionHandleType.right => handle, // points up-left + TextSelectionHandleType.collapsed => Transform.rotate( + angle: math.pi / 4.0, + child: handle, + ), // points up + }; + } + + /// Gets anchor for material-style text selection handles. + /// + /// See [TextSelectionControls.getHandleAnchor]. + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + return switch (type) { + TextSelectionHandleType.collapsed => const Offset(_kHandleSize / 2, -4), + TextSelectionHandleType.left => const Offset(_kHandleSize, 0), + TextSelectionHandleType.right => Offset.zero, + }; + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + bool canSelectAll(TextSelectionDelegate delegate) { + // Android allows SelectAll when selection is not collapsed, unless + // everything has already been selected. + final TextEditingValue value = delegate.textEditingValue; + return delegate.selectAllEnabled && + value.text.isNotEmpty && + !(value.selection.start == 0 && value.selection.end == value.text.length); + } +} + +// The label and callback for the available default text selection menu buttons. +class _TextSelectionToolbarItemData { + const _TextSelectionToolbarItemData({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; +} + +// The highest level toolbar widget, built directly by buildToolbar. +class _TextSelectionControlsToolbar extends StatefulWidget { + const _TextSelectionControlsToolbar({ + required this.clipboardStatus, + required this.delegate, + required this.endpoints, + required this.globalEditableRegion, + required this.handleCut, + required this.handleCopy, + required this.handlePaste, + required this.handleSelectAll, + required this.selectionMidpoint, + required this.textLineHeight, + }); + + final ValueListenable<ClipboardStatus>? clipboardStatus; + final TextSelectionDelegate delegate; + final List<TextSelectionPoint> endpoints; + final Rect globalEditableRegion; + final VoidCallback? handleCut; + final VoidCallback? handleCopy; + final VoidCallback? handlePaste; + final VoidCallback? handleSelectAll; + final Offset selectionMidpoint; + final double textLineHeight; + + @override + _TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState(); +} + +class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> + with TickerProviderStateMixin { + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + @override + void initState() { + super.initState(); + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + } + + @override + void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.clipboardStatus != oldWidget.clipboardStatus) { + widget.clipboardStatus?.addListener(_onChangedClipboardStatus); + oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + } + } + + @override + void dispose() { + widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // If there are no buttons to be shown, don't render anything. + if (widget.handleCut == null && + widget.handleCopy == null && + widget.handlePaste == null && + widget.handleSelectAll == null) { + return const SizedBox.shrink(); + } + // If the paste button is desired, don't render anything until the state of + // the clipboard is known, since it's used to determine if paste is shown. + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) { + return const SizedBox.shrink(); + } + + // Calculate the positioning of the menu. It is placed above the selection + // if there is enough room, or otherwise below. + final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0]; + final TextSelectionPoint endTextSelectionPoint = widget.endpoints.length > 1 + ? widget.endpoints[1] + : widget.endpoints[0]; + final double topAmountInEditableRegion = + startTextSelectionPoint.point.dy - widget.textLineHeight; + final double anchorTop = + math.max(topAmountInEditableRegion, 0) + + widget.globalEditableRegion.top - + _kToolbarContentDistance; + + final anchorAbove = Offset( + widget.globalEditableRegion.left + widget.selectionMidpoint.dx, + anchorTop, + ); + final anchorBelow = Offset( + widget.globalEditableRegion.left + widget.selectionMidpoint.dx, + widget.globalEditableRegion.top + + endTextSelectionPoint.point.dy + + _kToolbarContentDistanceBelow, + ); + + // Determine which buttons will appear so that the order and total number is + // known. A button's position in the menu can slightly affect its + // appearance. + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final itemDatas = <_TextSelectionToolbarItemData>[ + if (widget.handleCut != null) + _TextSelectionToolbarItemData( + label: localizations.cutButtonLabel, + onPressed: widget.handleCut!, + ), + if (widget.handleCopy != null) + _TextSelectionToolbarItemData( + label: localizations.copyButtonLabel, + onPressed: widget.handleCopy!, + ), + if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.pasteable) + _TextSelectionToolbarItemData( + label: localizations.pasteButtonLabel, + onPressed: widget.handlePaste!, + ), + if (widget.handleSelectAll != null) + _TextSelectionToolbarItemData( + label: localizations.selectAllButtonLabel, + onPressed: widget.handleSelectAll!, + ), + ]; + + // If there is no option available, build an empty widget. + if (itemDatas.isEmpty) { + return const SizedBox.shrink(); + } + + return TextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: itemDatas.asMap().entries.map((MapEntry<int, _TextSelectionToolbarItemData> entry) { + return TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length), + alignment: AlignmentDirectional.centerStart, + onPressed: entry.value.onPressed, + child: Text(entry.value.label), + ); + }).toList(), + ); + } +} + +/// Draws a single text selection handle which points up and to the left. +class _TextSelectionHandlePainter extends CustomPainter { + _TextSelectionHandlePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color; + final double radius = size.width / 2.0; + final circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius); + final point = Rect.fromLTWH(0.0, 0.0, radius, radius); + final path = Path() + ..addOval(circle) + ..addRect(point); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_TextSelectionHandlePainter oldPainter) { + return color != oldPainter.color; + } +} + +// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is +// deleted, when users should migrate back to materialTextSelectionControls. +// See https://github.com/flutter/flutter/pull/124262 +/// Text selection handle controls that follow the Material Design specification. +final TextSelectionControls materialTextSelectionHandleControls = + MaterialTextSelectionHandleControls(); + +/// Text selection controls that follow the Material Design specification. +final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls(); diff --git a/packages/material_ui/lib/src/text_selection_theme.dart b/packages/material_ui/lib/src/text_selection_theme.dart new file mode 100644 index 000000000000..7afa7b20ee0d --- /dev/null +++ b/packages/material_ui/lib/src/text_selection_theme.dart @@ -0,0 +1,200 @@ +// 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. + +/// @docImport 'package:cupertino_ui/cupertino_ui.dart'; +/// +/// @docImport 'input_decorator.dart'; +/// @docImport 'selectable_text.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the text selection visual properties for descendant [TextField] and +/// [SelectableText] widgets. +/// +/// Descendant widgets obtain the current [TextSelectionThemeData] object using +/// [TextSelectionTheme.of]. Instances of [TextSelectionThemeData] can be customized +/// with [TextSelectionThemeData.copyWith]. +/// +/// Typically a [TextSelectionThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.textSelectionTheme]. +/// +/// See also: +/// +/// * [TextSelectionTheme], an [InheritedWidget] that propagates the theme down its +/// subtree. +/// * [InputDecorationThemeData], which defines most other visual properties of +/// text fields. +@immutable +class TextSelectionThemeData with Diagnosticable { + /// Creates the set of properties used to configure [TextField]s. + const TextSelectionThemeData({this.cursorColor, this.selectionColor, this.selectionHandleColor}); + + /// The color of the cursor in the text field. + /// + /// The cursor indicates the current location of text insertion point in + /// the field. + final Color? cursorColor; + + /// The background color of selected text. + final Color? selectionColor; + + /// The color of the selection handles on the text field. + /// + /// Selection handles are used to indicate the bounds of the selected text, + /// or as a handle to drag the cursor to a new location in the text. + /// + /// On iOS [TextField] and [SelectableText] cannot access [selectionHandleColor]. + /// To set the [selectionHandleColor] on iOS, you can change the + /// [CupertinoThemeData.selectionHandleColor] by wrapping the subtree + /// containing your [TextField] or [SelectableText] with a [CupertinoTheme]. + final Color? selectionHandleColor; + + /// Creates a copy of this object with the given fields replaced with the + /// specified values. + TextSelectionThemeData copyWith({ + Color? cursorColor, + Color? selectionColor, + Color? selectionHandleColor, + }) { + return TextSelectionThemeData( + cursorColor: cursorColor ?? this.cursorColor, + selectionColor: selectionColor ?? this.selectionColor, + selectionHandleColor: selectionHandleColor ?? this.selectionHandleColor, + ); + } + + /// Linearly interpolate between two text field themes. + /// + /// If both arguments are null, then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static TextSelectionThemeData? lerp( + TextSelectionThemeData? a, + TextSelectionThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return TextSelectionThemeData( + cursorColor: Color.lerp(a?.cursorColor, b?.cursorColor, t), + selectionColor: Color.lerp(a?.selectionColor, b?.selectionColor, t), + selectionHandleColor: Color.lerp(a?.selectionHandleColor, b?.selectionHandleColor, t), + ); + } + + @override + int get hashCode => Object.hash(cursorColor, selectionColor, selectionHandleColor); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is TextSelectionThemeData && + other.cursorColor == cursorColor && + other.selectionColor == selectionColor && + other.selectionHandleColor == selectionHandleColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('cursorColor', cursorColor, defaultValue: null)); + properties.add(ColorProperty('selectionColor', selectionColor, defaultValue: null)); + properties.add(ColorProperty('selectionHandleColor', selectionHandleColor, defaultValue: null)); + } +} + +/// An inherited widget that defines the appearance of text selection in +/// this widget's subtree. +/// +/// Values specified here are used for [TextField] and [SelectableText] +/// properties that are not given an explicit non-null value. +/// +/// {@tool snippet} +/// +/// Here is an example of a text selection theme that applies a blue cursor +/// color with light blue selection handles to the child text field. +/// +/// ```dart +/// const TextSelectionTheme( +/// data: TextSelectionThemeData( +/// cursorColor: Colors.blue, +/// selectionHandleColor: Colors.lightBlue, +/// ), +/// child: TextField(), +/// ) +/// ``` +/// {@end-tool} +/// +/// This widget also creates a [DefaultSelectionStyle] for its subtree with +/// [data]. +class TextSelectionTheme extends InheritedTheme { + /// Creates a text selection theme widget that specifies the text + /// selection properties for all widgets below it in the widget tree. + const TextSelectionTheme({super.key, required this.data, required Widget child}) + : _child = child, + // See `get child` override below. + super(child: const _NullWidget()); + + /// The properties for descendant [TextField] and [SelectableText] widgets. + final TextSelectionThemeData data; + + // Overriding the getter to insert `DefaultSelectionStyle` into the subtree + // without breaking API. In general, this approach should be avoided + // because it relies on an implementation detail of ProxyWidget. This + // workaround is necessary because TextSelectionTheme is const. + @override + Widget get child { + return DefaultSelectionStyle( + selectionColor: data.selectionColor, + cursorColor: data.cursorColor, + child: _child, + ); + } + + final Widget _child; + + /// Returns the [data] from the closest [TextSelectionTheme] ancestor. If + /// there is no ancestor, it returns [ThemeData.textSelectionTheme]. + /// Applications can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TextSelectionThemeData theme = TextSelectionTheme.of(context); + /// ``` + static TextSelectionThemeData of(BuildContext context) { + final TextSelectionTheme? selectionTheme = context + .dependOnInheritedWidgetOfExactType<TextSelectionTheme>(); + return selectionTheme?.data ?? Theme.of(context).textSelectionTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return TextSelectionTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(TextSelectionTheme oldWidget) => data != oldWidget.data; +} + +class _NullWidget extends Widget { + const _NullWidget(); + + @override + Element createElement() => throw UnimplementedError(); +} diff --git a/packages/material_ui/lib/src/text_selection_toolbar.dart b/packages/material_ui/lib/src/text_selection_toolbar.dart new file mode 100644 index 000000000000..ccdce4851e9e --- /dev/null +++ b/packages/material_ui/lib/src/text_selection_toolbar.dart @@ -0,0 +1,839 @@ +// 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. + +/// @docImport 'adaptive_text_selection_toolbar.dart'; +/// @docImport 'spell_check_suggestions_toolbar.dart'; +/// @docImport 'text_selection_toolbar_text_button.dart'; +library; + +import 'dart:math' as math; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/rendering.dart'; + +import 'color_scheme.dart'; +import 'debug.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'theme.dart'; + +const double _kToolbarHeight = 44.0; +const double _kToolbarContentDistance = 8.0; + +/// A fully-functional Material-style text selection toolbar. +/// +/// Tries to position itself above [anchorAbove], but if it doesn't fit, then +/// it positions itself below [anchorBelow]. +/// +/// If any children don't fit in the menu, an overflow menu will automatically +/// be created. +/// +/// See also: +/// +/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current +/// platform. +/// * [CupertinoTextSelectionToolbar], which is similar, but builds an iOS- +/// style toolbar. +class TextSelectionToolbar extends StatelessWidget { + /// Creates an instance of TextSelectionToolbar. + const TextSelectionToolbar({ + super.key, + required this.anchorAbove, + required this.anchorBelow, + this.toolbarBuilder = _defaultToolbarBuilder, + required this.children, + }) : assert(children.length > 0); + + /// {@template flutter.material.TextSelectionToolbar.anchorAbove} + /// The focal point above which the toolbar attempts to position itself. + /// + /// If there is not enough room above before reaching the top of the screen, + /// then the toolbar will position itself below [anchorBelow]. + /// {@endtemplate} + final Offset anchorAbove; + + /// {@template flutter.material.TextSelectionToolbar.anchorBelow} + /// The focal point below which the toolbar attempts to position itself, if it + /// doesn't fit above [anchorAbove]. + /// {@endtemplate} + final Offset anchorBelow; + + /// {@template flutter.material.TextSelectionToolbar.children} + /// The children that will be displayed in the text selection toolbar. + /// + /// Typically these are buttons. + /// + /// Must not be empty. + /// {@endtemplate} + /// + /// See also: + /// * [TextSelectionToolbarTextButton], which builds a default Material- + /// style text selection toolbar text button. + final List<Widget> children; + + /// {@template flutter.material.TextSelectionToolbar.toolbarBuilder} + /// Builds the toolbar container. + /// + /// Useful for customizing the high-level background of the toolbar. The given + /// child Widget will contain all of the [children]. + /// {@endtemplate} + final ToolbarBuilder toolbarBuilder; + + /// The size of the text selection handles. + /// + /// See also: + /// + /// * [SpellCheckSuggestionsToolbar], which references this value to calculate + /// the padding between the toolbar and anchor. + static const double kHandleSize = 22.0; + + /// Padding between the toolbar and the anchor. + static const double kToolbarContentDistanceBelow = kHandleSize - 2.0; + + // Build the default Android Material text selection menu toolbar. + static Widget _defaultToolbarBuilder(BuildContext context, Widget child) { + return _TextSelectionToolbarContainer(child: child); + } + + @override + Widget build(BuildContext context) { + // Incorporate the padding distance between the content and toolbar. + final Offset anchorAbovePadded = anchorAbove - const Offset(0.0, _kToolbarContentDistance); + final Offset anchorBelowPadded = anchorBelow + const Offset(0.0, kToolbarContentDistanceBelow); + + const double screenPadding = CupertinoTextSelectionToolbar.kToolbarScreenPadding; + final double paddingAbove = MediaQuery.paddingOf(context).top + screenPadding; + final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove; + final bool fitsAbove = _kToolbarHeight <= availableHeight; + // Makes up for the Padding above the Stack. + final localAdjustment = Offset(screenPadding, paddingAbove); + + return Padding( + padding: EdgeInsets.fromLTRB(screenPadding, paddingAbove, screenPadding, screenPadding), + child: CustomSingleChildLayout( + delegate: TextSelectionToolbarLayoutDelegate( + anchorAbove: anchorAbovePadded - localAdjustment, + anchorBelow: anchorBelowPadded - localAdjustment, + fitsAbove: fitsAbove, + ), + child: _TextSelectionToolbarOverflowable( + isAbove: fitsAbove, + toolbarBuilder: toolbarBuilder, + children: children, + ), + ), + ); + } +} + +// A toolbar containing the given children. If they overflow the width +// available, then the overflowing children will be displayed in an overflow +// menu. +class _TextSelectionToolbarOverflowable extends StatefulWidget { + const _TextSelectionToolbarOverflowable({ + required this.isAbove, + required this.toolbarBuilder, + required this.children, + }) : assert(children.length > 0); + + final List<Widget> children; + + // When true, the toolbar fits above its anchor and will be positioned there. + final bool isAbove; + + // Builds the toolbar that will be populated with the children and fit inside + // of the layout that adjusts to overflow. + final ToolbarBuilder toolbarBuilder; + + @override + _TextSelectionToolbarOverflowableState createState() => _TextSelectionToolbarOverflowableState(); +} + +class _TextSelectionToolbarOverflowableState extends State<_TextSelectionToolbarOverflowable> + with TickerProviderStateMixin { + // Whether or not the overflow menu is open. When it is closed, the menu + // items that don't overflow are shown. When it is open, only the overflowing + // menu items are shown. + bool _overflowOpen = false; + + // The key for _TextSelectionToolbarTrailingEdgeAlign. + UniqueKey _containerKey = UniqueKey(); + + // Close the menu and reset layout calculations, as in when the menu has + // changed and saved values are no longer relevant. This should be called in + // setState or another context where a rebuild is happening. + void _reset() { + // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes + // in order to cause it to rebuild. This lets it recalculate its + // saved width for the new set of children, and it prevents AnimatedSize + // from animating the size change. + _containerKey = UniqueKey(); + // If the menu items change, make sure the overflow menu is closed. This + // prevents getting into a broken state where _overflowOpen is true when + // there are not enough children to cause overflow. + _overflowOpen = false; + } + + @override + void didUpdateWidget(_TextSelectionToolbarOverflowable oldWidget) { + super.didUpdateWidget(oldWidget); + // If the children are changing at all, the current page should be reset. + if (!listEquals(widget.children, oldWidget.children)) { + _reset(); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TextDirection textDirection = Directionality.of(context); + + return _TextSelectionToolbarTrailingEdgeAlign( + key: _containerKey, + overflowOpen: _overflowOpen, + textDirection: textDirection, + child: AnimatedSize( + // This duration was eyeballed on a Pixel 2 emulator running Android + // API 28. + duration: const Duration(milliseconds: 140), + child: widget.toolbarBuilder( + context, + _TextSelectionToolbarItemsLayout( + isAbove: widget.isAbove, + overflowOpen: _overflowOpen, + textDirection: textDirection, + children: <Widget>[ + // TODO(justinmc): This overflow button should have its own slot in + // _TextSelectionToolbarItemsLayout separate from children, similar + // to how it's done in Cupertino's text selection menu. + // https://github.com/flutter/flutter/issues/69908 + // The navButton that shows and hides the overflow menu is the + // first child. + _TextSelectionToolbarOverflowButton( + key: _overflowOpen + ? StandardComponentType.backButton.key + : StandardComponentType.moreButton.key, + icon: Icon(_overflowOpen ? Icons.arrow_back : Icons.more_vert), + onPressed: () { + setState(() { + _overflowOpen = !_overflowOpen; + }); + }, + tooltip: _overflowOpen + ? localizations.backButtonTooltip + : localizations.moreButtonTooltip, + ), + ...widget.children, + ], + ), + ), + ), + ); + } +} + +// When the overflow menu is open, it tries to align its trailing edge to the +// trailing edge of the closed menu. This widget handles this effect by +// measuring and maintaining the width of the closed menu and aligning the child +// to that side. +class _TextSelectionToolbarTrailingEdgeAlign extends SingleChildRenderObjectWidget { + const _TextSelectionToolbarTrailingEdgeAlign({ + super.key, + required Widget super.child, + required this.overflowOpen, + required this.textDirection, + }); + + final bool overflowOpen; + final TextDirection textDirection; + + @override + _TextSelectionToolbarTrailingEdgeAlignRenderBox createRenderObject(BuildContext context) { + return _TextSelectionToolbarTrailingEdgeAlignRenderBox( + overflowOpen: overflowOpen, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _TextSelectionToolbarTrailingEdgeAlignRenderBox renderObject, + ) { + renderObject + ..overflowOpen = overflowOpen + ..textDirection = textDirection; + } +} + +class _TextSelectionToolbarTrailingEdgeAlignRenderBox extends RenderProxyBox { + _TextSelectionToolbarTrailingEdgeAlignRenderBox({ + required bool overflowOpen, + required TextDirection textDirection, + }) : _textDirection = textDirection, + _overflowOpen = overflowOpen, + super(); + + // The width of the menu when it was closed. This is used to achieve the + // behavior where the open menu aligns its trailing edge to the closed menu's + // trailing edge. + double? _closedWidth; + + bool _overflowOpen; + bool get overflowOpen => _overflowOpen; + set overflowOpen(bool value) { + if (value == overflowOpen) { + return; + } + _overflowOpen = value; + markNeedsLayout(); + } + + TextDirection _textDirection; + TextDirection get textDirection => _textDirection; + set textDirection(TextDirection value) { + if (value == textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + @override + void performLayout() { + child!.layout(constraints.loosen(), parentUsesSize: true); + + // Save the width when the menu is closed. If the menu changes, this width + // is invalid, so it's important that this RenderBox be recreated in that + // case. Currently, this is achieved by providing a new key to + // _TextSelectionToolbarTrailingEdgeAlign. + if (!overflowOpen && _closedWidth == null) { + _closedWidth = child!.size.width; + } + + size = constraints.constrain( + Size( + // If the open menu is wider than the closed menu, just use its own width + // and don't worry about aligning the trailing edges. + // _closedWidth is used even when the menu is closed to allow it to + // animate its size while keeping the same edge alignment. + _closedWidth == null || child!.size.width > _closedWidth! + ? child!.size.width + : _closedWidth!, + child!.size.height, + ), + ); + + // Set the offset in the parent data such that the child will be aligned to + // the trailing edge, depending on the text direction. + final childParentData = child!.parentData! as ToolbarItemsParentData; + childParentData.offset = Offset( + textDirection == TextDirection.rtl ? 0.0 : size.width - child!.size.width, + 0.0, + ); + } + + // Paint at the offset set in the parent data. + @override + void paint(PaintingContext context, Offset offset) { + final childParentData = child!.parentData! as ToolbarItemsParentData; + context.paintChild(child!, childParentData.offset + offset); + } + + // Include the parent data offset in the hit test. + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + // The x, y parameters have the top left of the node's box as the origin. + final childParentData = child!.parentData! as ToolbarItemsParentData; + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as ToolbarItemsParentData; + transform.translateByDouble(childParentData.offset.dx, childParentData.offset.dy, 0, 1); + super.applyPaintTransform(child, transform); + } +} + +// Renders the menu items in the correct positions in the menu and its overflow +// submenu based on calculating which item would first overflow. +class _TextSelectionToolbarItemsLayout extends MultiChildRenderObjectWidget { + const _TextSelectionToolbarItemsLayout({ + required this.isAbove, + required this.overflowOpen, + required this.textDirection, + required super.children, + }); + + final bool isAbove; + final bool overflowOpen; + final TextDirection textDirection; + + @override + _RenderTextSelectionToolbarItemsLayout createRenderObject(BuildContext context) { + return _RenderTextSelectionToolbarItemsLayout( + isAbove: isAbove, + overflowOpen: overflowOpen, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderTextSelectionToolbarItemsLayout renderObject, + ) { + renderObject + ..isAbove = isAbove + ..textDirection = textDirection + ..overflowOpen = overflowOpen; + } + + @override + _TextSelectionToolbarItemsLayoutElement createElement() => + _TextSelectionToolbarItemsLayoutElement(this); +} + +class _TextSelectionToolbarItemsLayoutElement extends MultiChildRenderObjectElement { + _TextSelectionToolbarItemsLayoutElement(super.widget); + + static bool _shouldPaint(Element child) { + return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint; + } + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + children.where(_shouldPaint).forEach(visitor); + } +} + +class _RenderTextSelectionToolbarItemsLayout extends RenderBox + with ContainerRenderObjectMixin<RenderBox, ToolbarItemsParentData> { + _RenderTextSelectionToolbarItemsLayout({ + required bool isAbove, + required bool overflowOpen, + required TextDirection textDirection, + }) : _isAbove = isAbove, + _overflowOpen = overflowOpen, + _textDirection = textDirection, + super(); + + // The index of the last item that doesn't overflow. + int _lastIndexThatFits = -1; + + bool _isAbove; + bool get isAbove => _isAbove; + set isAbove(bool value) { + if (value == isAbove) { + return; + } + _isAbove = value; + markNeedsLayout(); + } + + bool _overflowOpen; + bool get overflowOpen => _overflowOpen; + set overflowOpen(bool value) { + if (value == overflowOpen) { + return; + } + _overflowOpen = value; + markNeedsLayout(); + } + + TextDirection _textDirection; + TextDirection get textDirection => _textDirection; + set textDirection(TextDirection value) { + if (value == textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + // Layout the necessary children, and figure out where the children first + // overflow, if at all. + void _layoutChildren() { + // When overflow is not open, the toolbar is always a specific height. + final BoxConstraints sizedConstraints = _overflowOpen + ? constraints + : BoxConstraints.loose(Size(constraints.maxWidth, _kToolbarHeight)); + + var i = -1; + var width = 0.0; + visitChildren((RenderObject renderObjectChild) { + i++; + + // No need to layout children inside the overflow menu when it's closed. + // The opposite is not true. It is necessary to layout the children that + // don't overflow when the overflow menu is open in order to calculate + // _lastIndexThatFits. + if (_lastIndexThatFits != -1 && !overflowOpen) { + return; + } + + final child = renderObjectChild as RenderBox; + child.layout(sizedConstraints.loosen(), parentUsesSize: true); + width += child.size.width; + + if (width > sizedConstraints.maxWidth && _lastIndexThatFits == -1) { + _lastIndexThatFits = i - 1; + } + }); + + // If the last child overflows, but only because of the width of the + // overflow button, then just show it and hide the overflow button. + final RenderBox navButton = firstChild!; + if (_lastIndexThatFits != -1 && + _lastIndexThatFits == childCount - 2 && + width - navButton.size.width <= sizedConstraints.maxWidth) { + _lastIndexThatFits = -1; + } + } + + // Returns true when the child should be painted, false otherwise. + bool _shouldPaintChild(RenderObject renderObjectChild, int index) { + // Paint the navButton when there is overflow. + if (renderObjectChild == firstChild) { + return _lastIndexThatFits != -1; + } + + // If there is no overflow, all children besides the navButton are painted. + if (_lastIndexThatFits == -1) { + return true; + } + + // When there is overflow, paint if the child is in the part of the menu + // that is currently open. Overflowing children are painted when the + // overflow menu is open, and the children that fit are painted when the + // overflow menu is closed. + return (index > _lastIndexThatFits) == overflowOpen; + } + + /// Horizontal layout. + Size _placeChildrenHorizontally() { + final RenderBox navButton = firstChild!; + final isRtl = textDirection == TextDirection.rtl; + + final contentItems = <RenderBox>[]; + + var totalWidth = 0.0; + var maxHeight = 0.0; + + // First pass: calculate dimensions and collect items. + var i = -1; + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + i++; + + if (!_shouldPaintChild(child, i)) { + // There is no need to update children that won't be painted. + childParentData.shouldPaint = false; + } else { + childParentData.shouldPaint = true; + + totalWidth += child.size.width; + maxHeight = math.max(maxHeight, child.size.height); + + if (child != navButton) { + contentItems.add(child); + } + } + }); + + // Position items based on text direction. + var currentX = 0.0; + final bool showNavButton = _lastIndexThatFits >= 0; + + if (isRtl) { + // In RTL, we want the nav button on the left and items right-aligned. + if (showNavButton) { + final navParentData = navButton.parentData! as ToolbarItemsParentData; + navParentData.offset = Offset.zero; + currentX += navButton.size.width; + } + + // Position content items from right to left. + var rightEdge = totalWidth; + for (final item in contentItems) { + rightEdge -= item.size.width; + final itemParentData = item.parentData! as ToolbarItemsParentData; + itemParentData.offset = Offset(rightEdge, 0.0); + } + } else { + // LTR: Place content items first, then nav button. + // First position all content items from left to right. + for (final item in contentItems) { + final itemParentData = item.parentData! as ToolbarItemsParentData; + itemParentData.offset = Offset(currentX, 0.0); + currentX += item.size.width; + } + + // Then place the nav button at the end. + if (showNavButton) { + final navParentData = navButton.parentData! as ToolbarItemsParentData; + navParentData.offset = Offset(currentX, 0.0); + } + } + + return Size(totalWidth, maxHeight); + } + + /// Vertical layout (overflow menu). + Size _placeChildrenVertically() { + final RenderBox navButton = firstChild!; + + var currentY = 0.0; + var maxWidth = 0.0; + + final navButtonParentData = navButton.parentData! as ToolbarItemsParentData; + + if (_shouldPaintChild(navButton, 0)) { + navButtonParentData.shouldPaint = true; + if (!isAbove) { + navButtonParentData.offset = Offset.zero; + currentY += navButton.size.height; + maxWidth = math.max(maxWidth, navButton.size.width); + } + } else { + navButtonParentData.shouldPaint = false; + } + + var i = -1; + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + + i++; + + // Ignore the navigation button. + if (renderObjectChild == navButton) { + return; + } + + // There is no need to update children that won't be painted. + if (!_shouldPaintChild(child, i)) { + childParentData.shouldPaint = false; + return; + } + + childParentData.shouldPaint = true; + childParentData.offset = Offset(0.0, currentY); + currentY += child.size.height; + maxWidth = math.max(maxWidth, child.size.width); + }); + + if (isAbove && navButtonParentData.shouldPaint) { + navButtonParentData.offset = Offset(0.0, currentY); + currentY += navButton.size.height; + maxWidth = math.max(maxWidth, navButton.size.width); + } + + return Size(maxWidth, currentY); + } + + // Decide which children will be painted, set their shouldPaint, and set the + // offset that painted children will be placed at. + void _placeChildren() { + size = overflowOpen ? _placeChildrenVertically() : _placeChildrenHorizontally(); + } + + // Horizontally expand the children when the menu overflows so they can react to + // pointer events into their whole area. + void _resizeChildrenWhenOverflow() { + if (!overflowOpen) { + return; + } + + final RenderBox navButton = firstChild!; + var i = -1; + + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + + i++; + + // Ignore the navigation button. + if (renderObjectChild == navButton) { + return; + } + + // There is no need to update children that won't be painted. + if (!_shouldPaintChild(renderObjectChild, i)) { + childParentData.shouldPaint = false; + return; + } + + child.layout(BoxConstraints.tightFor(width: size.width), parentUsesSize: true); + }); + } + + @override + void performLayout() { + _lastIndexThatFits = -1; + if (firstChild == null) { + size = constraints.smallest; + return; + } + + _layoutChildren(); + _placeChildren(); + _resizeChildrenWhenOverflow(); + } + + @override + void paint(PaintingContext context, Offset offset) { + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + if (!childParentData.shouldPaint) { + return; + } + + context.paintChild(child, childParentData.offset + offset); + }); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? child = lastChild; + while (child != null) { + // The x, y parameters have the top left of the node's box as the origin. + final childParentData = child.parentData! as ToolbarItemsParentData; + + // Don't hit test children aren't shown. + if (!childParentData.shouldPaint) { + child = childParentData.previousSibling; + continue; + } + + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + child = childParentData.previousSibling; + } + return false; + } + + // Visit only the children that should be painted. + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitChildren((RenderObject renderObjectChild) { + final child = renderObjectChild as RenderBox; + final childParentData = child.parentData! as ToolbarItemsParentData; + if (childParentData.shouldPaint) { + visitor(renderObjectChild); + } + }); + } +} + +// The Material-styled toolbar outline. Fill it with any widgets you want. No +// overflow ability. +class _TextSelectionToolbarContainer extends StatelessWidget { + const _TextSelectionToolbarContainer({required this.child}); + + final Widget child; + + // These colors were taken from a screenshot of a Pixel 6 emulator running + // Android API level 34. + static const Color _defaultColorLight = Color(0xffffffff); + static const Color _defaultColorDark = Color(0xff424242); + + static Color _getColor(ColorScheme colorScheme) { + final bool isDefaultSurface = switch (colorScheme.brightness) { + Brightness.light => identical(ThemeData().colorScheme.surface, colorScheme.surface), + Brightness.dark => identical(ThemeData.dark().colorScheme.surface, colorScheme.surface), + }; + if (!isDefaultSurface) { + return colorScheme.surface; + } + return switch (colorScheme.brightness) { + Brightness.light => _defaultColorLight, + Brightness.dark => _defaultColorDark, + }; + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Material( + // This value was eyeballed to match the native text selection menu on + // a Pixel 6 emulator running Android API level 34. + borderRadius: const BorderRadius.all(Radius.circular(_kToolbarHeight / 2)), + clipBehavior: Clip.antiAlias, + color: _getColor(theme.colorScheme), + elevation: 1.0, + type: MaterialType.card, + child: child, + ); + } +} + +// A button styled like a Material native Android text selection overflow menu +// forward and back controls. +class _TextSelectionToolbarOverflowButton extends StatelessWidget { + const _TextSelectionToolbarOverflowButton({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + }); + + final Icon icon; + final VoidCallback? onPressed; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.card, + color: const Color(0x00000000), + child: IconButton( + // TODO(justinmc): This should be an AnimatedIcon, but + // AnimatedIcons doesn't yet support arrow_back to more_vert. + // https://github.com/flutter/flutter/issues/51209 + icon: icon, + onPressed: onPressed, + tooltip: tooltip, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/text_selection_toolbar_text_button.dart b/packages/material_ui/lib/src/text_selection_toolbar_text_button.dart new file mode 100644 index 000000000000..2e655695fce4 --- /dev/null +++ b/packages/material_ui/lib/src/text_selection_toolbar_text_button.dart @@ -0,0 +1,183 @@ +// 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. + +/// @docImport 'button_style.dart'; +/// @docImport 'text_selection_toolbar.dart'; +library; + +import 'package:flutter/widgets.dart'; + +import 'color_scheme.dart'; +import 'constants.dart'; +import 'text_button.dart'; +import 'theme.dart'; + +enum _TextSelectionToolbarItemPosition { + /// The first item among multiple in the menu. + first, + + /// One of several items, not the first or last. + middle, + + /// The last item among multiple in the menu. + last, + + /// The only item in the menu. + only, +} + +/// A button styled like a Material native Android text selection menu button. +class TextSelectionToolbarTextButton extends StatelessWidget { + /// Creates an instance of TextSelectionToolbarTextButton. + const TextSelectionToolbarTextButton({ + super.key, + required this.child, + required this.padding, + this.onPressed, + this.alignment, + }); + + // These values were eyeballed to match the native text selection menu on a + // Pixel 2 running Android 10. + static const double _kMiddlePadding = 9.5; + static const double _kEndPadding = 14.5; + + /// {@template flutter.material.TextSelectionToolbarTextButton.child} + /// The child of this button. + /// + /// Usually a [Text]. + /// {@endtemplate} + final Widget child; + + /// {@template flutter.material.TextSelectionToolbarTextButton.onPressed} + /// Called when this button is pressed. + /// {@endtemplate} + final VoidCallback? onPressed; + + /// The padding between the button's edge and its child. + /// + /// In a standard Material [TextSelectionToolbar], the padding depends on the + /// button's position within the toolbar. + /// + /// See also: + /// + /// * [getPadding], which calculates the standard padding based on the + /// button's position. + /// * [ButtonStyle.padding], which is where this padding is applied. + final EdgeInsetsGeometry padding; + + /// The alignment of the button's child. + /// + /// By default, this will be [Alignment.center]. + /// + /// See also: + /// + /// * [ButtonStyle.alignment], which is where this alignment is applied. + final AlignmentGeometry? alignment; + + /// Returns the standard padding for a button at index out of a total number + /// of buttons. + /// + /// Standard Material [TextSelectionToolbar]s have buttons with different + /// padding depending on their position in the toolbar. + static EdgeInsetsGeometry getPadding(int index, int total) { + assert(total > 0 && index >= 0 && index < total); + final _TextSelectionToolbarItemPosition position = _getPosition(index, total); + return EdgeInsetsDirectional.only( + start: _getStartPadding(position), + end: _getEndPadding(position), + ); + } + + static double _getStartPadding(_TextSelectionToolbarItemPosition position) { + if (position == _TextSelectionToolbarItemPosition.first || + position == _TextSelectionToolbarItemPosition.only) { + return _kEndPadding; + } + return _kMiddlePadding; + } + + static double _getEndPadding(_TextSelectionToolbarItemPosition position) { + if (position == _TextSelectionToolbarItemPosition.last || + position == _TextSelectionToolbarItemPosition.only) { + return _kEndPadding; + } + return _kMiddlePadding; + } + + static _TextSelectionToolbarItemPosition _getPosition(int index, int total) { + if (index == 0) { + return total == 1 + ? _TextSelectionToolbarItemPosition.only + : _TextSelectionToolbarItemPosition.first; + } + if (index == total - 1) { + return _TextSelectionToolbarItemPosition.last; + } + return _TextSelectionToolbarItemPosition.middle; + } + + /// Returns a copy of the current [TextSelectionToolbarTextButton] instance + /// with specific overrides. + TextSelectionToolbarTextButton copyWith({ + Widget? child, + VoidCallback? onPressed, + EdgeInsetsGeometry? padding, + AlignmentGeometry? alignment, + }) { + return TextSelectionToolbarTextButton( + onPressed: onPressed ?? this.onPressed, + padding: padding ?? this.padding, + alignment: alignment ?? this.alignment, + child: child ?? this.child, + ); + } + + // These colors were taken from a screenshot of a Pixel 6 emulator running + // Android API level 34. + static const Color _defaultForegroundColorLight = Color(0xff000000); + static const Color _defaultForegroundColorDark = Color(0xffffffff); + + // The background color is hardcoded to transparent by default so the buttons + // are the color of the container behind them. For example TextSelectionToolbar + // hardcodes the color value, and TextSelectionToolbarTextButtons that are its + // children become that color. + static const Color _defaultBackgroundColorTransparent = Color(0x00000000); + + static Color _getForegroundColor(ColorScheme colorScheme) { + final bool isDefaultOnSurface = switch (colorScheme.brightness) { + Brightness.light => identical(ThemeData().colorScheme.onSurface, colorScheme.onSurface), + Brightness.dark => identical(ThemeData.dark().colorScheme.onSurface, colorScheme.onSurface), + }; + if (!isDefaultOnSurface) { + return colorScheme.onSurface; + } + return switch (colorScheme.brightness) { + Brightness.light => _defaultForegroundColorLight, + Brightness.dark => _defaultForegroundColorDark, + }; + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + return TextButton( + style: TextButton.styleFrom( + backgroundColor: _defaultBackgroundColorTransparent, + foregroundColor: _getForegroundColor(colorScheme), + shape: const RoundedRectangleBorder(), + minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), + padding: padding, + alignment: alignment, + textStyle: const TextStyle( + // This value was eyeballed from a screenshot of a Pixel 6 emulator + // running Android API level 34. + fontWeight: FontWeight.w400, + ), + ), + onPressed: onPressed, + child: child, + ); + } +} diff --git a/packages/material_ui/lib/src/text_theme.dart b/packages/material_ui/lib/src/text_theme.dart new file mode 100644 index 000000000000..2db569c3ffc5 --- /dev/null +++ b/packages/material_ui/lib/src/text_theme.dart @@ -0,0 +1,862 @@ +// 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. + +/// @docImport 'elevated_button.dart'; +/// @docImport 'material.dart'; +/// @docImport 'outlined_button.dart'; +/// @docImport 'text_button.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; +import 'typography.dart'; + +/// Material design text theme. +/// +/// Definitions for the various typographical styles found in Material Design +/// (e.g., labelLarge, bodySmall). Rather than creating a [TextTheme] directly, +/// you can obtain an instance as [Typography.black] or [Typography.white]. +/// +/// To obtain the current text theme, call [TextTheme.of] with the current +/// [BuildContext]. This is equivalent to calling [Theme.of] and reading +/// the [ThemeData.textTheme] property. +/// +/// The names of the TextTheme properties match this table from the +/// [Material Design spec](https://m3.material.io/styles/typography/tokens). +/// +/// ![](https://lh3.googleusercontent.com/Yvngs5mQSjXa_9T4X3JDucO62c5hdZHPDa7qeRH6DsJQvGr_q7EBrTkhkPiQd9OeR1v_Uk38Cjd9nUpP3nevDyHpKWuXSfQ1Gq78bOnBN7sr=s0) +/// +/// The Material Design typography scheme was significantly changed in the +/// current (2021) version of the specification +/// ([https://m3.material.io/styles/typography/tokens](https://m3.material.io/styles/typography/tokens)). +/// +/// The **2021** spec has fifteen text styles: +/// +/// | NAME | SIZE | HEIGHT | WEIGHT | SPACING | | +/// |----------------|------|---------|---------|----------|-------------| +/// | displayLarge | 57.0 | 64.0 | regular | -0.25 | | +/// | displayMedium | 45.0 | 52.0 | regular | 0.0 | | +/// | displaySmall | 36.0 | 44.0 | regular | 0.0 | | +/// | headlineLarge | 32.0 | 40.0 | regular | 0.0 | | +/// | headlineMedium | 28.0 | 36.0 | regular | 0.0 | | +/// | headlineSmall | 24.0 | 32.0 | regular | 0.0 | | +/// | titleLarge | 22.0 | 28.0 | regular | 0.0 | | +/// | titleMedium | 16.0 | 24.0 | medium | 0.15 | | +/// | titleSmall | 14.0 | 20.0 | medium | 0.1 | | +/// | bodyLarge | 16.0 | 24.0 | regular | 0.5 | | +/// | bodyMedium | 14.0 | 20.0 | regular | 0.25 | | +/// | bodySmall | 12.0 | 16.0 | regular | 0.4 | | +/// | labelLarge | 14.0 | 20.0 | medium | 0.1 | | +/// | labelMedium | 12.0 | 16.0 | medium | 0.5 | | +/// | labelSmall | 11.0 | 16.0 | medium | 0.5 | | +/// +/// ...where "regular" is `FontWeight.w400` and "medium" is `FontWeight.w500`. +/// +/// The names of the 2018 TextTheme properties match this table from the +/// [Material Design spec](https://material.io/design/typography/the-type-system.html#type-scale) +/// with a few exceptions: the styles called H1-H6 in the spec are +/// displayLarge-titleLarge in the API chart, body1,body2 are called +/// bodyLarge and bodyMedium, caption is now bodySmall, button is labelLarge, +/// and overline is now labelSmall. +/// +/// The **2018** spec has thirteen text styles: +/// +/// | NAME | SIZE | WEIGHT | SPACING | | +/// |----------------|------|---------|----------|-------------| +/// | displayLarge | 96.0 | light | -1.5 | | +/// | displayMedium | 60.0 | light | -0.5 | | +/// | displaySmall | 48.0 | regular | 0.0 | | +/// | headlineMedium | 34.0 | regular | 0.25 | | +/// | headlineSmall | 24.0 | regular | 0.0 | | +/// | titleLarge | 20.0 | medium | 0.15 | | +/// | titleMedium | 16.0 | regular | 0.15 | | +/// | titleSmall | 14.0 | medium | 0.1 | | +/// | bodyLarge | 16.0 | regular | 0.5 | | +/// | bodyMedium | 14.0 | regular | 0.25 | | +/// | bodySmall | 12.0 | regular | 0.4 | | +/// | labelLarge | 14.0 | medium | 1.25 | | +/// | labelSmall | 10.0 | regular | 1.5 | | +/// +/// ...where "light" is `FontWeight.w300`, "regular" is `FontWeight.w400` and +/// "medium" is `FontWeight.w500`. +/// +/// By default, text styles are initialized to match the 2018 Material Design +/// specification as listed above. To provide backwards compatibility, the 2014 +/// specification is also available. +/// +/// To explicitly configure a [Theme] for the 2018 sizes, weights, and letter +/// spacings, you can initialize its [ThemeData.typography] value using +/// [Typography.material2018]. The [Typography] constructor defaults to this +/// configuration. To configure a [Theme] for the 2014 sizes, weights, and letter +/// spacings, initialize its [ThemeData.typography] value using +/// [Typography.material2014]. +/// +/// See also: +/// +/// * [Typography], the class that generates [TextTheme]s appropriate for a platform. +/// * [Theme], for other aspects of a Material Design application that can be +/// globally adjusted, such as the color scheme. +/// * <https://material.io/design/typography/> +@immutable +class TextTheme with Diagnosticable { + /// Creates a text theme that uses the given values. + /// + /// Rather than creating a new text theme, consider using [Typography.black] + /// or [Typography.white], which implement the typography styles in the + /// Material Design specification: + /// + /// <https://material.io/design/typography/#type-scale> + /// + /// If you do decide to create your own text theme, consider using one of + /// those predefined themes as a starting point for [copyWith] or [apply]. + /// + /// The 2018 styles cannot be mixed with the 2021 styles. Only one or the + /// other is allowed in this constructor. The 2018 styles are deprecated and + /// will eventually be removed. + const TextTheme({ + this.displayLarge, + this.displayMedium, + this.displaySmall, + this.headlineLarge, + this.headlineMedium, + this.headlineSmall, + this.titleLarge, + this.titleMedium, + this.titleSmall, + this.bodyLarge, + this.bodyMedium, + this.bodySmall, + this.labelLarge, + this.labelMedium, + this.labelSmall, + }); + + /// Largest of the display styles. + /// + /// As the largest text on the screen, display styles are reserved for short, + /// important text or numerals. They work best on large screens. + final TextStyle? displayLarge; + + /// Middle size of the display styles. + /// + /// As the largest text on the screen, display styles are reserved for short, + /// important text or numerals. They work best on large screens. + final TextStyle? displayMedium; + + /// Smallest of the display styles. + /// + /// As the largest text on the screen, display styles are reserved for short, + /// important text or numerals. They work best on large screens. + final TextStyle? displaySmall; + + /// Largest of the headline styles. + /// + /// Headline styles are smaller than display styles. They're best-suited for + /// short, high-emphasis text on smaller screens. + final TextStyle? headlineLarge; + + /// Middle size of the headline styles. + /// + /// Headline styles are smaller than display styles. They're best-suited for + /// short, high-emphasis text on smaller screens. + final TextStyle? headlineMedium; + + /// Smallest of the headline styles. + /// + /// Headline styles are smaller than display styles. They're best-suited for + /// short, high-emphasis text on smaller screens. + final TextStyle? headlineSmall; + + /// Largest of the title styles. + /// + /// Titles are smaller than headline styles and should be used for shorter, + /// medium-emphasis text. + final TextStyle? titleLarge; + + /// Middle size of the title styles. + /// + /// Titles are smaller than headline styles and should be used for shorter, + /// medium-emphasis text. + final TextStyle? titleMedium; + + /// Smallest of the title styles. + /// + /// Titles are smaller than headline styles and should be used for shorter, + /// medium-emphasis text. + final TextStyle? titleSmall; + + /// Largest of the body styles. + /// + /// Body styles are used for longer passages of text. + final TextStyle? bodyLarge; + + /// Middle size of the body styles. + /// + /// Body styles are used for longer passages of text. + /// + /// The default text style for [Material]. + final TextStyle? bodyMedium; + + /// Smallest of the body styles. + /// + /// Body styles are used for longer passages of text. + final TextStyle? bodySmall; + + /// Largest of the label styles. + /// + /// Label styles are smaller, utilitarian styles, used for areas of the UI + /// such as text inside of components or very small supporting text in the + /// content body, like captions. + /// + /// Used for text on [ElevatedButton], [TextButton] and [OutlinedButton]. + final TextStyle? labelLarge; + + /// Middle size of the label styles. + /// + /// Label styles are smaller, utilitarian styles, used for areas of the UI + /// such as text inside of components or very small supporting text in the + /// content body, like captions. + final TextStyle? labelMedium; + + /// Smallest of the label styles. + /// + /// Label styles are smaller, utilitarian styles, used for areas of the UI + /// such as text inside of components or very small supporting text in the + /// content body, like captions. + final TextStyle? labelSmall; + + /// Creates a copy of this text theme but with the given fields replaced with + /// the new values. + /// + /// Consider using [Typography.black] or [Typography.white], which implement + /// the typography styles in the Material Design specification, as a starting + /// point. + /// + /// {@tool snippet} + /// + /// ```dart + /// /// A Widget that sets the ambient theme's title text color for its + /// /// descendants, while leaving other ambient theme attributes alone. + /// class TitleColorThemeCopy extends StatelessWidget { + /// const TitleColorThemeCopy({super.key, required this.titleColor, required this.child}); + /// + /// final Color titleColor; + /// final Widget child; + /// + /// @override + /// Widget build(BuildContext context) { + /// final ThemeData theme = Theme.of(context); + /// return Theme( + /// data: theme.copyWith( + /// textTheme: theme.textTheme.copyWith( + /// titleLarge: theme.textTheme.titleLarge!.copyWith( + /// color: titleColor, + /// ), + /// ), + /// ), + /// child: child, + /// ); + /// } + /// } + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [merge] is used instead of [copyWith] when you want to merge all + /// of the fields of a TextTheme instead of individual fields. + TextTheme copyWith({ + TextStyle? displayLarge, + TextStyle? displayMedium, + TextStyle? displaySmall, + TextStyle? headlineLarge, + TextStyle? headlineMedium, + TextStyle? headlineSmall, + TextStyle? titleLarge, + TextStyle? titleMedium, + TextStyle? titleSmall, + TextStyle? bodyLarge, + TextStyle? bodyMedium, + TextStyle? bodySmall, + TextStyle? labelLarge, + TextStyle? labelMedium, + TextStyle? labelSmall, + }) { + return TextTheme( + displayLarge: displayLarge ?? this.displayLarge, + displayMedium: displayMedium ?? this.displayMedium, + displaySmall: displaySmall ?? this.displaySmall, + headlineLarge: headlineLarge ?? this.headlineLarge, + headlineMedium: headlineMedium ?? this.headlineMedium, + headlineSmall: headlineSmall ?? this.headlineSmall, + titleLarge: titleLarge ?? this.titleLarge, + titleMedium: titleMedium ?? this.titleMedium, + titleSmall: titleSmall ?? this.titleSmall, + bodyLarge: bodyLarge ?? this.bodyLarge, + bodyMedium: bodyMedium ?? this.bodyMedium, + bodySmall: bodySmall ?? this.bodySmall, + labelLarge: labelLarge ?? this.labelLarge, + labelMedium: labelMedium ?? this.labelMedium, + labelSmall: labelSmall ?? this.labelSmall, + ); + } + + /// Creates a new [TextTheme] where each text style from this object has been + /// merged with the matching text style from the `other` object. + /// + /// The merging is done by calling [TextStyle.merge] on each respective pair + /// of text styles from this and the [other] text themes and is subject to + /// the value of [TextStyle.inherit] flag. For more details, see the + /// documentation on [TextStyle.merge] and [TextStyle.inherit]. + /// + /// If this theme, or the `other` theme has members that are null, then the + /// non-null one (if any) is used. If the `other` theme is itself null, then + /// this [TextTheme] is returned unchanged. If values in both are set, then + /// the values are merged using [TextStyle.merge]. + /// + /// This is particularly useful if one [TextTheme] defines one set of + /// properties and another defines a different set, e.g. having colors + /// defined in one text theme and font sizes in another, or when one + /// [TextTheme] has only some fields defined, and you want to define the rest + /// by merging it with a default theme. + /// + /// {@tool snippet} + /// + /// ```dart + /// /// A Widget that sets the ambient theme's title text color for its + /// /// descendants, while leaving other ambient theme attributes alone. + /// class TitleColorTheme extends StatelessWidget { + /// const TitleColorTheme({super.key, required this.child, required this.titleColor}); + /// + /// final Color titleColor; + /// final Widget child; + /// + /// @override + /// Widget build(BuildContext context) { + /// ThemeData theme = Theme.of(context); + /// // This partialTheme is incomplete: it only has the title style + /// // defined. Just replacing theme.textTheme with partialTheme would + /// // set the title, but everything else would be null. This isn't very + /// // useful, so merge it with the existing theme to keep all of the + /// // preexisting definitions for the other styles. + /// final TextTheme partialTheme = TextTheme(titleLarge: TextStyle(color: titleColor)); + /// theme = theme.copyWith(textTheme: theme.textTheme.merge(partialTheme)); + /// return Theme(data: theme, child: child); + /// } + /// } + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [copyWith] is used instead of [merge] when you wish to override + /// individual fields in the [TextTheme] instead of merging all of the + /// fields of two [TextTheme]s. + TextTheme merge(TextTheme? other) { + if (other == null) { + return this; + } + return copyWith( + displayLarge: displayLarge?.merge(other.displayLarge) ?? other.displayLarge, + displayMedium: displayMedium?.merge(other.displayMedium) ?? other.displayMedium, + displaySmall: displaySmall?.merge(other.displaySmall) ?? other.displaySmall, + headlineLarge: headlineLarge?.merge(other.headlineLarge) ?? other.headlineLarge, + headlineMedium: headlineMedium?.merge(other.headlineMedium) ?? other.headlineMedium, + headlineSmall: headlineSmall?.merge(other.headlineSmall) ?? other.headlineSmall, + titleLarge: titleLarge?.merge(other.titleLarge) ?? other.titleLarge, + titleMedium: titleMedium?.merge(other.titleMedium) ?? other.titleMedium, + titleSmall: titleSmall?.merge(other.titleSmall) ?? other.titleSmall, + bodyLarge: bodyLarge?.merge(other.bodyLarge) ?? other.bodyLarge, + bodyMedium: bodyMedium?.merge(other.bodyMedium) ?? other.bodyMedium, + bodySmall: bodySmall?.merge(other.bodySmall) ?? other.bodySmall, + labelLarge: labelLarge?.merge(other.labelLarge) ?? other.labelLarge, + labelMedium: labelMedium?.merge(other.labelMedium) ?? other.labelMedium, + labelSmall: labelSmall?.merge(other.labelSmall) ?? other.labelSmall, + ); + } + + /// Creates a copy of this text theme but with the given field replaced in + /// each of the individual text styles. + /// + /// The `displayColor` is applied to [displayLarge], [displayMedium], + /// [displaySmall], [headlineLarge], [headlineMedium], and [bodySmall]. The + /// `bodyColor` is applied to the remaining text styles. + /// + /// Consider using [Typography.black] or [Typography.white], which implement + /// the typography styles in the Material Design specification, as a starting + /// point. + TextTheme apply({ + String? fontFamily, + List<String>? fontFamilyFallback, + String? package, + double fontSizeFactor = 1.0, + double fontSizeDelta = 0.0, + double letterSpacingFactor = 1.0, + double letterSpacingDelta = 0.0, + double wordSpacingFactor = 1.0, + double wordSpacingDelta = 0.0, + double heightFactor = 1.0, + double heightDelta = 0.0, + Color? displayColor, + Color? bodyColor, + TextDecoration? decoration, + Color? decorationColor, + TextDecorationStyle? decorationStyle, + }) { + return TextTheme( + displayLarge: displayLarge?.apply( + color: displayColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + displayMedium: displayMedium?.apply( + color: displayColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + displaySmall: displaySmall?.apply( + color: displayColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + headlineLarge: headlineLarge?.apply( + color: displayColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + headlineMedium: headlineMedium?.apply( + color: displayColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + headlineSmall: headlineSmall?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + titleLarge: titleLarge?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + titleMedium: titleMedium?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + titleSmall: titleSmall?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + bodyLarge: bodyLarge?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + bodyMedium: bodyMedium?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + bodySmall: bodySmall?.apply( + color: displayColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + labelLarge: labelLarge?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + labelMedium: labelMedium?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + labelSmall: labelSmall?.apply( + color: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + letterSpacingDelta: letterSpacingDelta, + letterSpacingFactor: letterSpacingFactor, + wordSpacingDelta: wordSpacingDelta, + wordSpacingFactor: wordSpacingFactor, + heightFactor: heightFactor, + heightDelta: heightDelta, + package: package, + ), + ); + } + + /// Linearly interpolate between two text themes. + /// + /// {@macro dart.ui.shadow.lerp} + static TextTheme lerp(TextTheme? a, TextTheme? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + return TextTheme( + displayLarge: TextStyle.lerp(a?.displayLarge, b?.displayLarge, t), + displayMedium: TextStyle.lerp(a?.displayMedium, b?.displayMedium, t), + displaySmall: TextStyle.lerp(a?.displaySmall, b?.displaySmall, t), + headlineLarge: TextStyle.lerp(a?.headlineLarge, b?.headlineLarge, t), + headlineMedium: TextStyle.lerp(a?.headlineMedium, b?.headlineMedium, t), + headlineSmall: TextStyle.lerp(a?.headlineSmall, b?.headlineSmall, t), + titleLarge: TextStyle.lerp(a?.titleLarge, b?.titleLarge, t), + titleMedium: TextStyle.lerp(a?.titleMedium, b?.titleMedium, t), + titleSmall: TextStyle.lerp(a?.titleSmall, b?.titleSmall, t), + bodyLarge: TextStyle.lerp(a?.bodyLarge, b?.bodyLarge, t), + bodyMedium: TextStyle.lerp(a?.bodyMedium, b?.bodyMedium, t), + bodySmall: TextStyle.lerp(a?.bodySmall, b?.bodySmall, t), + labelLarge: TextStyle.lerp(a?.labelLarge, b?.labelLarge, t), + labelMedium: TextStyle.lerp(a?.labelMedium, b?.labelMedium, t), + labelSmall: TextStyle.lerp(a?.labelSmall, b?.labelSmall, t), + ); + } + + /// The [ThemeData.textTheme] property of the ambient [Theme]. + /// + /// Equivalent to `Theme.of(context).textTheme`. + /// + /// See also: + /// * [TextTheme.primaryOf], which returns the [ThemeData.primaryTextTheme] property of + /// the ambient [Theme] instead. + static TextTheme of(BuildContext context) => Theme.of(context).textTheme; + + /// The [ThemeData.primaryTextTheme] property of the ambient [Theme]. + /// + /// + /// Equivalent to `Theme.of(context).primaryTextTheme`. + /// + /// See also: + /// * [TextTheme.of], which returns the [ThemeData.textTheme] property of the ambient + /// [Theme] instead. + static TextTheme primaryOf(BuildContext context) => Theme.of(context).primaryTextTheme; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is TextTheme && + displayLarge == other.displayLarge && + displayMedium == other.displayMedium && + displaySmall == other.displaySmall && + headlineLarge == other.headlineLarge && + headlineMedium == other.headlineMedium && + headlineSmall == other.headlineSmall && + titleLarge == other.titleLarge && + titleMedium == other.titleMedium && + titleSmall == other.titleSmall && + bodyLarge == other.bodyLarge && + bodyMedium == other.bodyMedium && + bodySmall == other.bodySmall && + labelLarge == other.labelLarge && + labelMedium == other.labelMedium && + labelSmall == other.labelSmall; + } + + @override + int get hashCode => Object.hash( + displayLarge, + displayMedium, + displaySmall, + headlineLarge, + headlineMedium, + headlineSmall, + titleLarge, + titleMedium, + titleSmall, + bodyLarge, + bodyMedium, + bodySmall, + labelLarge, + labelMedium, + labelSmall, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final TextTheme defaultTheme = Typography.material2018(platform: defaultTargetPlatform).black; + properties.add( + DiagnosticsProperty<TextStyle>( + 'displayLarge', + displayLarge, + defaultValue: defaultTheme.displayLarge, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'displayMedium', + displayMedium, + defaultValue: defaultTheme.displayMedium, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'displaySmall', + displaySmall, + defaultValue: defaultTheme.displaySmall, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'headlineLarge', + headlineLarge, + defaultValue: defaultTheme.headlineLarge, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'headlineMedium', + headlineMedium, + defaultValue: defaultTheme.headlineMedium, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'headlineSmall', + headlineSmall, + defaultValue: defaultTheme.headlineSmall, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'titleLarge', + titleLarge, + defaultValue: defaultTheme.titleLarge, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'titleMedium', + titleMedium, + defaultValue: defaultTheme.titleMedium, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'titleSmall', + titleSmall, + defaultValue: defaultTheme.titleSmall, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>('bodyLarge', bodyLarge, defaultValue: defaultTheme.bodyLarge), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'bodyMedium', + bodyMedium, + defaultValue: defaultTheme.bodyMedium, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>('bodySmall', bodySmall, defaultValue: defaultTheme.bodySmall), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'labelLarge', + labelLarge, + defaultValue: defaultTheme.labelLarge, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'labelMedium', + labelMedium, + defaultValue: defaultTheme.labelMedium, + ), + ); + properties.add( + DiagnosticsProperty<TextStyle>( + 'labelSmall', + labelSmall, + defaultValue: defaultTheme.labelSmall, + ), + ); + } +} diff --git a/packages/material_ui/lib/src/theme.dart b/packages/material_ui/lib/src/theme.dart new file mode 100644 index 000000000000..0f86d6227719 --- /dev/null +++ b/packages/material_ui/lib/src/theme.dart @@ -0,0 +1,314 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'color_scheme.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; + +import 'material_localizations.dart'; +import 'theme_data.dart'; +import 'typography.dart'; + +export 'theme_data.dart' show Brightness, MaterialTapTargetSize, ThemeData; + +/// The duration over which theme changes animate by default. +const Duration kThemeAnimationDuration = Duration(milliseconds: 200); + +/// Applies a theme to descendant widgets. +/// +/// A theme describes the colors and typographic choices of an application. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=oTvQDJOBXmM} +/// +/// Descendant widgets obtain the current theme's [ThemeData] object using +/// [Theme.of]. When a widget uses [Theme.of], it is automatically rebuilt if +/// the theme later changes, so that the changes can be applied. +/// +/// The [Theme] widget implies an [IconTheme] widget, set to the value of the +/// [ThemeData.iconTheme] of the [data] for the [Theme]. +/// +/// To interact seamlessly with descendant Cupertino widgets, the [Theme] widget +/// provides a [CupertinoTheme] for its descendants with a [CupertinoThemeData] inherited +/// from the nearest ancestor [CupertinoTheme] or if none exists, derived from the +/// Material [data] for the [Theme]. The values in the Material derived [CupertinoThemeData] +/// are overridable through [ThemeData.cupertinoOverrideTheme]. The values from an +/// inherited [CupertinoThemeData] can be overridden by wrapping the desired subtree +/// with a [CupertinoTheme]. +/// +/// See also: +/// +/// * [ThemeData], which describes the actual configuration of a theme. +/// * [AnimatedTheme], which animates the [ThemeData] when it changes rather +/// than changing the theme all at once. +/// * [MaterialApp], which includes an [AnimatedTheme] widget configured via +/// the [MaterialApp.theme] argument. +class Theme extends StatelessWidget { + /// Applies the given theme [data] to [child]. + const Theme({super.key, required this.data, required this.child}); + + /// Specifies the color and typography values for descendant widgets. + final ThemeData data; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + static final ThemeData _kFallbackTheme = ThemeData.fallback(); + + /// The data from the closest [Theme] instance that encloses the given + /// context. + /// + /// If the given context is enclosed in a [Localizations] widget providing + /// [MaterialLocalizations], the returned data is localized according to the + /// nearest available [MaterialLocalizations]. + /// + /// Defaults to [ThemeData.fallback] if there is no [Theme] in the given + /// build context. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// @override + /// Widget build(BuildContext context) { + /// return Text( + /// 'Example', + /// style: Theme.of(context).textTheme.titleLarge, + /// ); + /// } + /// ``` + /// + /// When the [Theme] is actually created in the same `build` function + /// (possibly indirectly, e.g. as part of a [MaterialApp]), the `context` + /// argument to the `build` function can't be used to find the [Theme] (since + /// it's "above" the widget being returned). In such cases, the following + /// technique with a [Builder] can be used to provide a new scope with a + /// [BuildContext] that is "under" the [Theme]: + /// + /// ```dart + /// @override + /// Widget build(BuildContext context) { + /// return MaterialApp( + /// theme: ThemeData.light(), + /// home: Builder( + /// // Create an inner BuildContext so that we can refer to + /// // the Theme with Theme.of(). + /// builder: (BuildContext context) { + /// return Center( + /// child: Text( + /// 'Example', + /// style: Theme.of(context).textTheme.titleLarge, + /// ), + /// ); + /// }, + /// ), + /// ); + /// } + /// ``` + /// + /// See also: + /// + /// * [ColorScheme.of], a convenience method that returns [ThemeData.colorScheme] + /// from the closest [Theme] ancestor. (equivalent to `Theme.of(context).colorScheme`). + /// * [TextTheme.of], a convenience method that returns [ThemeData.textTheme] + /// from the closest [Theme] ancestor. (equivalent to `Theme.of(context).textTheme`). + /// * [IconTheme.of], that returns [ThemeData.iconTheme] from the closest [Theme] or + /// [IconThemeData.fallback] if there is no [IconTheme] ancestor. + static ThemeData of(BuildContext context) { + final _InheritedTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType<_InheritedTheme>(); + final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>( + context, + MaterialLocalizations, + ); + final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike; + final InheritedCupertinoTheme? inheritedCupertinoTheme = context + .dependOnInheritedWidgetOfExactType<InheritedCupertinoTheme>(); + final ThemeData theme = + inheritedTheme?.theme.data ?? + (inheritedCupertinoTheme != null + ? CupertinoBasedMaterialThemeData( + themeData: inheritedCupertinoTheme.theme.data, + ).materialTheme + : _kFallbackTheme); + return ThemeData.localize(theme, theme.typography.geometryThemeFor(category)); + } + + // The inherited themes in widgets library can not infer their values from + // Theme in material library. Wraps the child with these inherited themes to + // overrides their values directly. + Widget _wrapsWidgetThemes(BuildContext context, Widget child) { + final DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(context); + return IconTheme( + data: data.iconTheme, + child: DefaultSelectionStyle( + selectionColor: data.textSelectionTheme.selectionColor ?? selectionStyle.selectionColor, + cursorColor: data.textSelectionTheme.cursorColor ?? selectionStyle.cursorColor, + child: child, + ), + ); + } + + CupertinoThemeData _inheritedCupertinoThemeData(BuildContext context) { + final InheritedCupertinoTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType<InheritedCupertinoTheme>(); + return (inheritedTheme?.theme.data ?? MaterialBasedCupertinoThemeData(materialTheme: data)) + .resolveFrom(context); + } + + /// Retrieves the [Brightness] to use for descendant Material widgets, based + /// on the value of [ThemeData.brightness] in the given [context]. + /// + /// If no [InheritedTheme] can be found in the given [context], or its `brightness` + /// is null, it will fall back to [MediaQueryData.platformBrightness]. + /// + /// See also: + /// + /// * [maybeBrightnessOf], which returns null if no valid [InheritedTheme] or + /// [MediaQuery] exists. + /// * [ThemeData.brightness], the property that takes precedence over + /// [MediaQueryData.platformBrightness] for descendant Material widgets. + static Brightness brightnessOf(BuildContext context) { + final _InheritedTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType<_InheritedTheme>(); + return inheritedTheme?.theme.data.brightness ?? MediaQuery.platformBrightnessOf(context); + } + + /// Retrieves the [Brightness] to use for descendant Material widgets, based + /// on the value of [ThemeData.brightness] in the given [context]. + /// + /// If no [InheritedTheme] or [MediaQuery] can be found in the given [context], it will + /// return null. + /// + /// See also: + /// + /// * [ThemeData.brightness], the property that takes precedence over + /// [MediaQueryData.platformBrightness] for descendant Material widgets. + /// * [brightnessOf], which return a default value if no valid [InheritedTheme] or + /// [MediaQuery] exists, instead of returning null. + static Brightness? maybeBrightnessOf(BuildContext context) { + final _InheritedTheme? inheritedTheme = context + .dependOnInheritedWidgetOfExactType<_InheritedTheme>(); + return inheritedTheme?.theme.data.brightness ?? MediaQuery.maybePlatformBrightnessOf(context); + } + + @override + Widget build(BuildContext context) { + return _InheritedTheme( + theme: this, + child: CupertinoTheme( + // If a CupertinoThemeData doesn't exist, we're using a + // MaterialBasedCupertinoThemeData here instead of a CupertinoThemeData + // because it defers some properties to the Material ThemeData. + data: _inheritedCupertinoThemeData(context), + child: _wrapsWidgetThemes(context, child), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<ThemeData>('data', data, showName: false)); + } +} + +class _InheritedTheme extends InheritedTheme { + const _InheritedTheme({required this.theme, required super.child}); + + final Theme theme; + + @override + Widget wrap(BuildContext context, Widget child) { + return Theme(data: theme.data, child: child); + } + + @override + bool updateShouldNotify(_InheritedTheme old) => theme.data != old.theme.data; +} + +/// An interpolation between two [ThemeData]s. +/// +/// This class specializes the interpolation of [Tween<ThemeData>] to call the +/// [ThemeData.lerp] method. +/// +/// See [Tween] for a discussion on how to use interpolation objects. +class ThemeDataTween extends Tween<ThemeData> { + /// Creates a [ThemeData] tween. + /// + /// The [begin] and [end] properties must be non-null before the tween is + /// first used, but the arguments can be null if the values are going to be + /// filled in later. + ThemeDataTween({super.begin, super.end}); + + @override + ThemeData lerp(double t) => ThemeData.lerp(begin!, end!, t); +} + +/// Animated version of [Theme] which automatically transitions the colors, +/// etc, over a given duration whenever the given theme changes. +/// +/// Here's an illustration of what using this widget looks like, using a [curve] +/// of [Curves.elasticInOut]. +/// {@animation 250 266 https://flutter.github.io/assets-for-api-docs/assets/widgets/animated_theme.mp4} +/// +/// See also: +/// +/// * [Theme], which [AnimatedTheme] uses to actually apply the interpolated +/// theme. +/// * [ThemeData], which describes the actual configuration of a theme. +/// * [MaterialApp], which includes an [AnimatedTheme] widget configured via +/// the [MaterialApp.theme] argument. +class AnimatedTheme extends ImplicitlyAnimatedWidget { + /// Creates an animated theme. + /// + /// By default, the theme transition uses a linear curve. + const AnimatedTheme({ + super.key, + required this.data, + super.curve, + super.duration = kThemeAnimationDuration, + super.onEnd, + required this.child, + }); + + /// Specifies the color and typography values for descendant widgets. + final ThemeData data; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + AnimatedWidgetBaseState<AnimatedTheme> createState() => _AnimatedThemeState(); +} + +class _AnimatedThemeState extends AnimatedWidgetBaseState<AnimatedTheme> { + ThemeDataTween? _data; + + @override + void forEachTween(TweenVisitor<dynamic> visitor) { + _data = + visitor(_data, widget.data, (dynamic value) => ThemeDataTween(begin: value as ThemeData))! + as ThemeDataTween; + } + + @override + Widget build(BuildContext context) { + return Theme(data: _data!.evaluate(animation), child: widget.child); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add( + DiagnosticsProperty<ThemeDataTween>('data', _data, showName: false, defaultValue: null), + ); + } +} diff --git a/packages/material_ui/lib/src/theme_data.dart b/packages/material_ui/lib/src/theme_data.dart new file mode 100644 index 000000000000..a9ab3bb03b91 --- /dev/null +++ b/packages/material_ui/lib/src/theme_data.dart @@ -0,0 +1,3489 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:ui' show Color, SystemColor, SystemColorPalette, lerpDouble; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; + +import 'action_buttons.dart'; +import 'action_icons_theme.dart'; +import 'app_bar_theme.dart'; +import 'badge_theme.dart'; +import 'banner_theme.dart'; +import 'bottom_app_bar_theme.dart'; +import 'bottom_navigation_bar_theme.dart'; +import 'bottom_sheet_theme.dart'; +import 'button_bar_theme.dart'; +import 'button_theme.dart'; +import 'card_theme.dart'; +import 'carousel_theme.dart'; +import 'checkbox_theme.dart'; +import 'chip_theme.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'data_table_theme.dart'; +import 'date_picker_theme.dart'; +import 'dialog_theme.dart'; +import 'divider_theme.dart'; +import 'drawer_theme.dart'; +import 'dropdown_menu_theme.dart'; +import 'elevated_button.dart'; +import 'elevated_button_theme.dart'; +import 'expansion_tile_theme.dart'; +import 'filled_button.dart'; +import 'filled_button_theme.dart'; +import 'floating_action_button_theme.dart'; +import 'icon_button_theme.dart'; +import 'ink_ripple.dart'; +import 'ink_sparkle.dart'; +import 'ink_splash.dart'; +import 'ink_well.dart' show InteractiveInkFeatureFactory; +import 'input_decorator.dart'; +import 'list_tile.dart'; +import 'list_tile_theme.dart'; +import 'menu_bar_theme.dart'; +import 'menu_button_theme.dart'; +import 'menu_theme.dart'; +import 'navigation_bar_theme.dart'; +import 'navigation_drawer_theme.dart'; +import 'navigation_rail_theme.dart'; +import 'outlined_button.dart'; +import 'outlined_button_theme.dart'; +import 'page_transitions_theme.dart'; +import 'popup_menu_theme.dart'; +import 'progress_indicator_theme.dart'; +import 'radio_theme.dart'; +import 'scrollbar_theme.dart'; +import 'search_bar_theme.dart'; +import 'search_view_theme.dart'; +import 'segmented_button_theme.dart'; +import 'slider_theme.dart'; +import 'snack_bar_theme.dart'; +import 'switch_theme.dart'; +import 'tab_bar_theme.dart'; +import 'text_button.dart'; +import 'text_button_theme.dart'; +import 'text_selection_theme.dart'; +import 'text_theme.dart'; +import 'time_picker_theme.dart'; +import 'toggle_buttons_theme.dart'; +import 'tooltip_theme.dart'; +import 'typography.dart'; + +export 'package:flutter/services.dart' show Brightness; + +// Examples can assume: +// late BuildContext context; + +/// Defines a customized theme for components with an `adaptive` factory constructor. +/// +/// Currently, only [Switch.adaptive] supports this class. +class Adaptation<T> { + /// Creates an [Adaptation]. + const Adaptation(); + + /// The adaptation's type. + Type get type => T; + + /// Typically, this is overridden to return an instance of a custom component + /// ThemeData class, like [SwitchThemeData], instead of the defaultValue. + /// + /// Factory constructors that support adaptations - currently only + /// [Switch.adaptive] - look for a type-specific adaptation in + /// [ThemeData.adaptationMap] when computing their effective default component + /// theme. If a matching adaptation is not found, the component may choose to + /// use a default adaptation. For example, the [Switch.adaptive] component + /// uses an empty [SwitchThemeData] if a matching adaptation is not found, for + /// the sake of backwards compatibility. + /// + /// {@tool dartpad} + /// This sample shows how to create and use subclasses of [Adaptation] that + /// define adaptive [SwitchThemeData]s. The [adapt] method in this example is + /// overridden to only customize cupertino-style switches, but it can also be + /// used to customize any other platforms. + /// + /// ** See code in examples/api/lib/material/switch/switch.4.dart ** + /// {@end-tool} + T adapt(ThemeData theme, T defaultValue) => defaultValue; +} + +/// An interface that defines custom additions to a [ThemeData] object. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=8-szcYzFVao} +/// +/// Typically used for custom colors. To use, subclass [ThemeExtension], +/// define a number of fields (e.g. [Color]s), and implement the [copyWith] and +/// [lerp] methods. The latter will ensure smooth transitions of properties when +/// switching themes. +/// +/// {@tool dartpad} +/// This sample shows how to create and use a subclass of [ThemeExtension] that +/// defines two colors. +/// +/// ** See code in examples/api/lib/material/theme/theme_extension.1.dart ** +/// {@end-tool} +abstract class ThemeExtension<T extends ThemeExtension<T>> { + /// Enable const constructor for subclasses. + const ThemeExtension(); + + /// The extension's type. + Object get type => T; + + /// Creates a copy of this theme extension with the given fields + /// replaced by the non-null parameter values. + ThemeExtension<T> copyWith(); + + /// Linearly interpolate with another [ThemeExtension] object. + /// + /// {@macro dart.ui.shadow.lerp} + ThemeExtension<T> lerp(covariant ThemeExtension<T>? other, double t); +} + +/// Configures the tap target and layout size of certain Material widgets. +/// +/// Changing the value in [ThemeData.materialTapTargetSize] will affect the +/// accessibility experience. +/// +/// Some of the impacted widgets include: +/// +/// * [FloatingActionButton], only the mini tap target size is increased. +/// * [MaterialButton] +/// * [OutlinedButton] +/// * [TextButton] +/// * [ElevatedButton] +/// * [IconButton] +/// * The time picker widget ([showTimePicker]) +/// * [SnackBar] +/// * [Chip] +/// * [RawChip] +/// * [InputChip] +/// * [ChoiceChip] +/// * [FilterChip] +/// * [ActionChip] +/// * [Radio] +/// * [Switch] +/// * [Checkbox] +enum MaterialTapTargetSize { + /// Expands the minimum tap target size to 48px by 48px. + /// + /// This is the default value of [ThemeData.materialTapTargetSize] and the + /// recommended size to conform to Android accessibility scanner + /// recommendations. + padded, + + /// Shrinks the tap target size to the minimum provided by the Material + /// specification. + shrinkWrap, +} + +/// Defines the configuration of the overall visual [Theme] for a [MaterialApp] +/// or a widget subtree within the app. +/// +/// The [MaterialApp] theme property can be used to configure the appearance +/// of the entire app. Widget subtrees within an app can override the app's +/// theme by including a [Theme] widget at the top of the subtree. +/// +/// Widgets whose appearance should align with the overall theme can obtain the +/// current theme's configuration with [Theme.of]. Material components typically +/// depend exclusively on the [colorScheme] and [textTheme]. These properties +/// are guaranteed to have non-null values. +/// +/// The static [Theme.of] method finds the [ThemeData] value specified for the +/// nearest [BuildContext] ancestor. This lookup is inexpensive, essentially +/// just a single HashMap access. It can sometimes be a little confusing +/// because [Theme.of] can not see a [Theme] widget that is defined in the +/// current build method's context. To overcome that, create a new custom widget +/// for the subtree that appears below the new [Theme], or insert a widget +/// that creates a new BuildContext, like [Builder]. +/// +/// {@tool dartpad} +/// This example demonstrates how a typical [MaterialApp] specifies +/// and uses a custom [Theme]. The theme's [ColorScheme] is based on a +/// single "seed" color and configures itself to match the platform's +/// current light or dark color configuration. The theme overrides the +/// default configuration of [FloatingActionButton] to show how to +/// customize the appearance a class of components. +/// +/// ** See code in examples/api/lib/material/theme_data/theme_data.0.dart ** +/// {@end-tool} +/// +/// See <https://material.io/design/color/> for +/// more discussion on how to pick the right colors. + +@immutable +class ThemeData with Diagnosticable { + /// Create a [ThemeData] that's used to configure a [Theme]. + /// + /// The [colorScheme] and [textTheme] are used by the Material components to + /// compute default values for visual properties. The API documentation for + /// each component widget explains exactly how the defaults are computed. + /// + /// When providing a [ColorScheme], apps can either provide one directly + /// with the [colorScheme] parameter, or have one generated for them by + /// using the [colorSchemeSeed] and [brightness] parameters. A generated + /// color scheme will be based on the tones of [colorSchemeSeed] and all of + /// its contrasting color will meet accessibility guidelines for readability. + /// (See [ColorScheme.fromSeed] for more details.) + /// + /// If the app wants to customize a generated color scheme, it can use + /// [ColorScheme.fromSeed] directly and then [ColorScheme.copyWith] on the + /// result to override any colors that need to be replaced. The result of + /// this can be used as the [colorScheme] directly. + /// + /// For historical reasons, instead of using a [colorSchemeSeed] or + /// [colorScheme], you can provide either a [primaryColor] or [primarySwatch] + /// to construct the [colorScheme], but the results will not be as complete + /// as when using generation from a seed color. + /// + /// If [colorSchemeSeed] is non-null then [colorScheme], [primaryColor] and + /// [primarySwatch] must all be null. + /// + /// The [textTheme] [TextStyle] colors are black if the color scheme's + /// brightness is [Brightness.light], and white for [Brightness.dark]. + /// + /// To override the appearance of specific components, provide + /// a component theme parameter like [sliderTheme], [toggleButtonsTheme], + /// or [bottomNavigationBarTheme]. + /// + /// When [useSystemColors] is true and the platform supports system colors, then the system colors + /// will be used to override certain theme colors. The [colorScheme], [textTheme], + /// [elevatedButtonTheme], [outlinedButtonTheme], [textButtonTheme], [filledButtonTheme], and + /// [floatingActionButtonTheme] are overriden by the system colors. + /// + /// See also: + /// + /// * [ThemeData.from], which creates a ThemeData from a [ColorScheme]. + /// * [ThemeData.light], which creates the default light theme. + /// * [ThemeData.dark], which creates the default dark theme. + /// * [ColorScheme.fromSeed], which is used to create a [ColorScheme] from a seed color. + factory ThemeData({ + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + Iterable<Adaptation<Object>>? adaptations, + bool? applyElevationOverlayColor, + NoDefaultCupertinoThemeData? cupertinoOverrideTheme, + Iterable<ThemeExtension<dynamic>>? extensions, + // TODO(bleroux): Change the parameter type to InputDecorationThemeData. + Object? inputDecorationTheme, + MaterialTapTargetSize? materialTapTargetSize, + PageTransitionsTheme? pageTransitionsTheme, + TargetPlatform? platform, + ScrollbarThemeData? scrollbarTheme, + InteractiveInkFeatureFactory? splashFactory, + bool? useMaterial3, + bool? useSystemColors, + VisualDensity? visualDensity, + // COLOR + ColorScheme? colorScheme, + Brightness? brightness, + Color? colorSchemeSeed, + // [colorScheme] is the preferred way to configure colors. The [Color] properties + // listed below (as well as primarySwatch) will gradually be phased out, see + // https://github.com/flutter/flutter/issues/91772. + Color? canvasColor, + Color? cardColor, + Color? disabledColor, + Color? dividerColor, + Color? focusColor, + Color? highlightColor, + Color? hintColor, + Color? hoverColor, + Color? primaryColor, + Color? primaryColorDark, + Color? primaryColorLight, + MaterialColor? primarySwatch, + Color? scaffoldBackgroundColor, + Color? secondaryHeaderColor, + Color? shadowColor, + Color? splashColor, + Color? unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + String? fontFamily, + List<String>? fontFamilyFallback, + String? package, + IconThemeData? iconTheme, + IconThemeData? primaryIconTheme, + TextTheme? primaryTextTheme, + TextTheme? textTheme, + Typography? typography, + // COMPONENT THEMES + ActionIconThemeData? actionIconTheme, + // TODO(huycozy): Change the parameter type to AppBarThemeData + Object? appBarTheme, + BadgeThemeData? badgeTheme, + MaterialBannerThemeData? bannerTheme, + BottomAppBarThemeData? bottomAppBarTheme, + BottomNavigationBarThemeData? bottomNavigationBarTheme, + BottomSheetThemeData? bottomSheetTheme, + ButtonThemeData? buttonTheme, + CardThemeData? cardTheme, + CarouselViewThemeData? carouselViewTheme, + CheckboxThemeData? checkboxTheme, + ChipThemeData? chipTheme, + DataTableThemeData? dataTableTheme, + DatePickerThemeData? datePickerTheme, + DialogThemeData? dialogTheme, + DividerThemeData? dividerTheme, + DrawerThemeData? drawerTheme, + DropdownMenuThemeData? dropdownMenuTheme, + ElevatedButtonThemeData? elevatedButtonTheme, + ExpansionTileThemeData? expansionTileTheme, + FilledButtonThemeData? filledButtonTheme, + FloatingActionButtonThemeData? floatingActionButtonTheme, + IconButtonThemeData? iconButtonTheme, + ListTileThemeData? listTileTheme, + MenuBarThemeData? menuBarTheme, + MenuButtonThemeData? menuButtonTheme, + MenuThemeData? menuTheme, + NavigationBarThemeData? navigationBarTheme, + NavigationDrawerThemeData? navigationDrawerTheme, + NavigationRailThemeData? navigationRailTheme, + OutlinedButtonThemeData? outlinedButtonTheme, + PopupMenuThemeData? popupMenuTheme, + ProgressIndicatorThemeData? progressIndicatorTheme, + RadioThemeData? radioTheme, + SearchBarThemeData? searchBarTheme, + SearchViewThemeData? searchViewTheme, + SegmentedButtonThemeData? segmentedButtonTheme, + SliderThemeData? sliderTheme, + SnackBarThemeData? snackBarTheme, + SwitchThemeData? switchTheme, + TabBarThemeData? tabBarTheme, + TextButtonThemeData? textButtonTheme, + TextSelectionThemeData? textSelectionTheme, + TimePickerThemeData? timePickerTheme, + ToggleButtonsThemeData? toggleButtonsTheme, + TooltipThemeData? tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + @Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', + ) + ButtonBarThemeData? buttonBarTheme, + @Deprecated( + 'Use DialogThemeData.backgroundColor instead. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + Color? dialogBackgroundColor, + @Deprecated( + 'Use TabBarThemeData.indicatorColor instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + Color? indicatorColor, + }) { + // GENERAL CONFIGURATION + cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); + extensions ??= <ThemeExtension<dynamic>>[]; + adaptations ??= <Adaptation<Object>>[]; + // TODO(bleroux): Clean this up once the type of `inputDecorationTheme` is changed to `InputDecorationThemeData` + if (inputDecorationTheme != null) { + if (inputDecorationTheme is InputDecorationTheme) { + inputDecorationTheme = inputDecorationTheme.data; + } else if (inputDecorationTheme is! InputDecorationThemeData) { + throw ArgumentError( + 'inputDecorationTheme must be either a InputDecorationThemeData or a InputDecorationTheme', + ); + } + } + inputDecorationTheme ??= const InputDecorationThemeData(); + platform ??= defaultTargetPlatform; + switch (platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + materialTapTargetSize ??= MaterialTapTargetSize.padded; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + materialTapTargetSize ??= MaterialTapTargetSize.shrinkWrap; + } + pageTransitionsTheme ??= const PageTransitionsTheme(); + scrollbarTheme ??= const ScrollbarThemeData(); + visualDensity ??= VisualDensity.defaultDensityForPlatform(platform); + useMaterial3 ??= true; + useSystemColors ??= false; + final bool useInkSparkle = platform == TargetPlatform.android && !kIsWeb; + splashFactory ??= useMaterial3 + ? useInkSparkle + ? InkSparkle.splashFactory + : InkRipple.splashFactory + : InkSplash.splashFactory; + + // COLOR + assert( + colorScheme?.brightness == null || + brightness == null || + colorScheme!.brightness == brightness, + 'ThemeData.brightness does not match ColorScheme.brightness. ' + 'Either override ColorScheme.brightness or ThemeData.brightness to ' + 'match the other.', + ); + assert(colorSchemeSeed == null || colorScheme == null); + assert(colorSchemeSeed == null || primarySwatch == null); + assert(colorSchemeSeed == null || primaryColor == null); + final Brightness effectiveBrightness = + brightness ?? colorScheme?.brightness ?? Brightness.light; + final isDark = effectiveBrightness == Brightness.dark; + if (colorSchemeSeed != null || useMaterial3) { + if (colorSchemeSeed != null) { + colorScheme = ColorScheme.fromSeed( + seedColor: colorSchemeSeed, + brightness: effectiveBrightness, + ); + } + colorScheme ??= isDark ? _colorSchemeDarkM3 : _colorSchemeLightM3; + + // For surfaces that use primary color in light themes and surface color in dark + final Color primarySurfaceColor = isDark ? colorScheme.surface : colorScheme.primary; + final Color onPrimarySurfaceColor = isDark ? colorScheme.onSurface : colorScheme.onPrimary; + + // Default some of the color settings to values from the color scheme + primaryColor ??= primarySurfaceColor; + canvasColor ??= colorScheme.surface; + scaffoldBackgroundColor ??= colorScheme.surface; + cardColor ??= colorScheme.surface; + dividerColor ??= colorScheme.outline; + dialogBackgroundColor ??= colorScheme.surface; + indicatorColor ??= onPrimarySurfaceColor; + applyElevationOverlayColor ??= brightness == Brightness.dark; + } + applyElevationOverlayColor ??= false; + primarySwatch ??= Colors.blue; + primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch; + final Brightness estimatedPrimaryColorBrightness = estimateBrightnessForColor(primaryColor); + primaryColorLight ??= isDark ? Colors.grey[500]! : primarySwatch[100]!; + primaryColorDark ??= isDark ? Colors.black : primarySwatch[700]!; + final primaryIsDark = estimatedPrimaryColorBrightness == Brightness.dark; + focusColor ??= isDark ? Colors.white.withOpacity(0.12) : Colors.black.withOpacity(0.12); + hoverColor ??= isDark ? Colors.white.withOpacity(0.04) : Colors.black.withOpacity(0.04); + shadowColor ??= Colors.black; + canvasColor ??= isDark ? Colors.grey[850]! : Colors.grey[50]!; + scaffoldBackgroundColor ??= canvasColor; + cardColor ??= isDark ? Colors.grey[800]! : Colors.white; + dividerColor ??= isDark ? const Color(0x1FFFFFFF) : const Color(0x1F000000); + // Create a ColorScheme that is backwards compatible as possible + // with the existing default ThemeData color values. + colorScheme ??= ColorScheme.fromSwatch( + primarySwatch: primarySwatch, + accentColor: isDark ? Colors.tealAccent[200]! : primarySwatch[500]!, + cardColor: cardColor, + backgroundColor: isDark ? Colors.grey[700]! : primarySwatch[200]!, + errorColor: Colors.red[700], + brightness: effectiveBrightness, + ); + unselectedWidgetColor ??= isDark ? Colors.white70 : Colors.black54; + // Spec doesn't specify a dark theme secondaryHeaderColor, this is a guess. + secondaryHeaderColor ??= isDark ? Colors.grey[700]! : primarySwatch[50]!; + hintColor ??= isDark ? Colors.white60 : Colors.black.withOpacity(0.6); + // The default [buttonTheme] is here because it doesn't use the defaults for + // [disabledColor], [highlightColor], and [splashColor]. + buttonTheme ??= ButtonThemeData( + colorScheme: colorScheme, + buttonColor: isDark ? primarySwatch[600]! : Colors.grey[300]!, + disabledColor: disabledColor, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + splashColor: splashColor, + materialTapTargetSize: materialTapTargetSize, + ); + disabledColor ??= isDark ? Colors.white38 : Colors.black38; + highlightColor ??= isDark ? const Color(0x40CCCCCC) : const Color(0x66BCBCBC); + splashColor ??= isDark ? const Color(0x40CCCCCC) : const Color(0x66C8C8C8); + + // TYPOGRAPHY & ICONOGRAPHY + typography ??= useMaterial3 + ? Typography.material2021(platform: platform, colorScheme: colorScheme) + : Typography.material2014(platform: platform); + TextTheme defaultTextTheme = isDark ? typography.white : typography.black; + TextTheme defaultPrimaryTextTheme = primaryIsDark ? typography.white : typography.black; + if (fontFamily != null) { + defaultTextTheme = defaultTextTheme.apply(fontFamily: fontFamily); + defaultPrimaryTextTheme = defaultPrimaryTextTheme.apply(fontFamily: fontFamily); + } + if (fontFamilyFallback != null) { + defaultTextTheme = defaultTextTheme.apply(fontFamilyFallback: fontFamilyFallback); + defaultPrimaryTextTheme = defaultPrimaryTextTheme.apply( + fontFamilyFallback: fontFamilyFallback, + ); + } + if (package != null) { + defaultTextTheme = defaultTextTheme.apply(package: package); + defaultPrimaryTextTheme = defaultPrimaryTextTheme.apply(package: package); + } + textTheme = defaultTextTheme.merge(textTheme); + primaryTextTheme = defaultPrimaryTextTheme.merge(primaryTextTheme); + iconTheme ??= isDark + ? IconThemeData(color: kDefaultIconLightColor) + : IconThemeData(color: kDefaultIconDarkColor); + primaryIconTheme ??= primaryIsDark + ? const IconThemeData(color: Colors.white) + : const IconThemeData(color: Colors.black); + + // COMPONENT THEMES + // TODO(huycozy): Clean this up once the type of `appBarTheme` is changed to `appBarThemeData` + if (appBarTheme != null) { + if (appBarTheme is AppBarTheme) { + appBarTheme = appBarTheme.data; + } else if (appBarTheme is! AppBarThemeData) { + throw ArgumentError('appBarTheme must be either a AppBarThemeData or a AppBarTheme'); + } + } + badgeTheme ??= const BadgeThemeData(); + bannerTheme ??= const MaterialBannerThemeData(); + bottomAppBarTheme ??= const BottomAppBarThemeData(); + bottomNavigationBarTheme ??= const BottomNavigationBarThemeData(); + bottomSheetTheme ??= const BottomSheetThemeData(); + cardTheme ??= const CardThemeData(); + carouselViewTheme ??= const CarouselViewThemeData(); + checkboxTheme ??= const CheckboxThemeData(); + chipTheme ??= const ChipThemeData(); + dataTableTheme ??= const DataTableThemeData(); + datePickerTheme ??= const DatePickerThemeData(); + dialogTheme ??= const DialogThemeData(); + dividerTheme ??= const DividerThemeData(); + drawerTheme ??= const DrawerThemeData(); + dropdownMenuTheme ??= const DropdownMenuThemeData(); + elevatedButtonTheme ??= const ElevatedButtonThemeData(); + expansionTileTheme ??= const ExpansionTileThemeData(); + filledButtonTheme ??= const FilledButtonThemeData(); + floatingActionButtonTheme ??= const FloatingActionButtonThemeData(); + iconButtonTheme ??= const IconButtonThemeData(); + listTileTheme ??= const ListTileThemeData(); + menuBarTheme ??= const MenuBarThemeData(); + menuButtonTheme ??= const MenuButtonThemeData(); + menuTheme ??= const MenuThemeData(); + navigationBarTheme ??= const NavigationBarThemeData(); + navigationDrawerTheme ??= const NavigationDrawerThemeData(); + navigationRailTheme ??= const NavigationRailThemeData(); + outlinedButtonTheme ??= const OutlinedButtonThemeData(); + popupMenuTheme ??= const PopupMenuThemeData(); + progressIndicatorTheme ??= const ProgressIndicatorThemeData(); + radioTheme ??= const RadioThemeData(); + searchBarTheme ??= const SearchBarThemeData(); + searchViewTheme ??= const SearchViewThemeData(); + segmentedButtonTheme ??= const SegmentedButtonThemeData(); + sliderTheme ??= const SliderThemeData(); + snackBarTheme ??= const SnackBarThemeData(); + switchTheme ??= const SwitchThemeData(); + tabBarTheme ??= const TabBarThemeData(); + textButtonTheme ??= const TextButtonThemeData(); + textSelectionTheme ??= const TextSelectionThemeData(); + timePickerTheme ??= const TimePickerThemeData(); + toggleButtonsTheme ??= const ToggleButtonsThemeData(); + tooltipTheme ??= const TooltipThemeData(); + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme ??= const ButtonBarThemeData(); + dialogBackgroundColor ??= isDark ? Colors.grey[800]! : Colors.white; + indicatorColor ??= colorScheme.secondary == primaryColor ? Colors.white : colorScheme.secondary; + + var theme = ThemeData.raw( + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + adaptationMap: _createAdaptationMap(adaptations), + applyElevationOverlayColor: applyElevationOverlayColor, + cupertinoOverrideTheme: cupertinoOverrideTheme, + extensions: _themeExtensionIterableToMap(extensions), + inputDecorationTheme: inputDecorationTheme as InputDecorationThemeData, + materialTapTargetSize: materialTapTargetSize, + pageTransitionsTheme: pageTransitionsTheme, + platform: platform, + scrollbarTheme: scrollbarTheme, + splashFactory: splashFactory, + useMaterial3: useMaterial3, + visualDensity: visualDensity, + // COLOR + canvasColor: canvasColor, + cardColor: cardColor, + colorScheme: colorScheme, + disabledColor: disabledColor, + dividerColor: dividerColor, + focusColor: focusColor, + highlightColor: highlightColor, + hintColor: hintColor, + hoverColor: hoverColor, + primaryColor: primaryColor, + primaryColorDark: primaryColorDark, + primaryColorLight: primaryColorLight, + scaffoldBackgroundColor: scaffoldBackgroundColor, + secondaryHeaderColor: secondaryHeaderColor, + shadowColor: shadowColor, + splashColor: splashColor, + unselectedWidgetColor: unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme: iconTheme, + primaryTextTheme: primaryTextTheme, + textTheme: textTheme, + typography: typography, + primaryIconTheme: primaryIconTheme, + // COMPONENT THEMES + actionIconTheme: actionIconTheme, + // TODO(huycozy): Remove this type cast when appBarTheme is explicitly set to appBarThemeData + appBarTheme: (appBarTheme as AppBarThemeData?) ?? const AppBarThemeData(), + badgeTheme: badgeTheme, + bannerTheme: bannerTheme, + bottomAppBarTheme: bottomAppBarTheme, + bottomNavigationBarTheme: bottomNavigationBarTheme, + bottomSheetTheme: bottomSheetTheme, + buttonTheme: buttonTheme, + cardTheme: cardTheme, + carouselViewTheme: carouselViewTheme, + checkboxTheme: checkboxTheme, + chipTheme: chipTheme, + dataTableTheme: dataTableTheme, + datePickerTheme: datePickerTheme, + dialogTheme: dialogTheme, + dividerTheme: dividerTheme, + drawerTheme: drawerTheme, + dropdownMenuTheme: dropdownMenuTheme, + elevatedButtonTheme: elevatedButtonTheme, + expansionTileTheme: expansionTileTheme, + filledButtonTheme: filledButtonTheme, + floatingActionButtonTheme: floatingActionButtonTheme, + iconButtonTheme: iconButtonTheme, + listTileTheme: listTileTheme, + menuBarTheme: menuBarTheme, + menuButtonTheme: menuButtonTheme, + menuTheme: menuTheme, + navigationBarTheme: navigationBarTheme, + navigationDrawerTheme: navigationDrawerTheme, + navigationRailTheme: navigationRailTheme, + outlinedButtonTheme: outlinedButtonTheme, + popupMenuTheme: popupMenuTheme, + progressIndicatorTheme: progressIndicatorTheme, + radioTheme: radioTheme, + searchBarTheme: searchBarTheme, + searchViewTheme: searchViewTheme, + segmentedButtonTheme: segmentedButtonTheme, + sliderTheme: sliderTheme, + snackBarTheme: snackBarTheme, + switchTheme: switchTheme, + tabBarTheme: tabBarTheme, + textButtonTheme: textButtonTheme, + textSelectionTheme: textSelectionTheme, + timePickerTheme: timePickerTheme, + toggleButtonsTheme: toggleButtonsTheme, + tooltipTheme: tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme: buttonBarTheme, + dialogBackgroundColor: dialogBackgroundColor, + indicatorColor: indicatorColor, + ); + + if (useSystemColors) { + theme = theme._overrideWithSystemColors(); + } + return theme; + } + + /// Create a [ThemeData] given a set of exact values. Most values must be + /// specified. They all must also be non-null except for + /// [cupertinoOverrideTheme], and deprecated members. + /// + /// This will rarely be used directly. It is used by [lerp] to + /// create intermediate themes based on two themes created with the + /// [ThemeData] constructor. + const ThemeData.raw({ + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + required this.adaptationMap, + required this.applyElevationOverlayColor, + required this.cupertinoOverrideTheme, + required this.extensions, + required this.inputDecorationTheme, + required this.materialTapTargetSize, + required this.pageTransitionsTheme, + required this.platform, + required this.scrollbarTheme, + required this.splashFactory, + required this.useMaterial3, + required this.visualDensity, + // COLOR + required this.colorScheme, + // [colorScheme] is the preferred way to configure colors. The [Color] properties + // listed below (as well as primarySwatch) will gradually be phased out, see + // https://github.com/flutter/flutter/issues/91772. + required this.canvasColor, + required this.cardColor, + required this.disabledColor, + required this.dividerColor, + required this.focusColor, + required this.highlightColor, + required this.hintColor, + required this.hoverColor, + required this.primaryColor, + required this.primaryColorDark, + required this.primaryColorLight, + required this.scaffoldBackgroundColor, + required this.secondaryHeaderColor, + required this.shadowColor, + required this.splashColor, + required this.unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + required this.iconTheme, + required this.primaryIconTheme, + required this.primaryTextTheme, + required this.textTheme, + required this.typography, + // COMPONENT THEMES + required this.actionIconTheme, + required this.appBarTheme, + required this.badgeTheme, + required this.bannerTheme, + required this.bottomAppBarTheme, + required this.bottomNavigationBarTheme, + required this.bottomSheetTheme, + required this.buttonTheme, + required this.cardTheme, + required this.carouselViewTheme, + required this.checkboxTheme, + required this.chipTheme, + required this.dataTableTheme, + required this.datePickerTheme, + required this.dialogTheme, + required this.dividerTheme, + required this.drawerTheme, + required this.dropdownMenuTheme, + required this.elevatedButtonTheme, + required this.expansionTileTheme, + required this.filledButtonTheme, + required this.floatingActionButtonTheme, + required this.iconButtonTheme, + required this.listTileTheme, + required this.menuBarTheme, + required this.menuButtonTheme, + required this.menuTheme, + required this.navigationBarTheme, + required this.navigationDrawerTheme, + required this.navigationRailTheme, + required this.outlinedButtonTheme, + required this.popupMenuTheme, + required this.progressIndicatorTheme, + required this.radioTheme, + required this.searchBarTheme, + required this.searchViewTheme, + required this.segmentedButtonTheme, + required this.sliderTheme, + required this.snackBarTheme, + required this.switchTheme, + required this.tabBarTheme, + required this.textButtonTheme, + required this.textSelectionTheme, + required this.timePickerTheme, + required this.toggleButtonsTheme, + required this.tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + @Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', + ) + ButtonBarThemeData? buttonBarTheme, + @Deprecated( + 'Use DialogThemeData.backgroundColor instead. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + required this.dialogBackgroundColor, + @Deprecated( + 'Use TabBarThemeData.indicatorColor instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + required this.indicatorColor, + }) : // DEPRECATED (newest deprecations at the bottom) + // should not be `required`, use getter pattern to avoid breakages. + _buttonBarTheme = buttonBarTheme, + assert(buttonBarTheme != null); + + /// Create a [ThemeData] based on the colors in the given [colorScheme] and + /// text styles of the optional [textTheme]. + /// + /// If [colorScheme].brightness is [Brightness.dark] then + /// [ThemeData.applyElevationOverlayColor] will be set to true to support + /// the Material dark theme method for indicating elevation by applying + /// a semi-transparent onSurface color on top of the surface color. + /// + /// This is the recommended method to theme your application. As we move + /// forward we will be converting all the widget implementations to only use + /// colors or colors derived from those in [ColorScheme]. + /// + /// {@tool snippet} + /// This example will set up an application to use the baseline Material + /// Design light and dark themes. + /// + /// ```dart + /// MaterialApp( + /// theme: ThemeData.from(colorScheme: const ColorScheme.light()), + /// darkTheme: ThemeData.from(colorScheme: const ColorScheme.dark()), + /// ) + /// ``` + /// {@end-tool} + /// + /// See <https://material.io/design/color/> for + /// more discussion on how to pick the right colors. + factory ThemeData.from({ + required ColorScheme colorScheme, + TextTheme? textTheme, + bool? useMaterial3, + }) { + final isDark = colorScheme.brightness == Brightness.dark; + + // For surfaces that use primary color in light themes and surface color in dark + final Color primarySurfaceColor = isDark ? colorScheme.surface : colorScheme.primary; + final Color onPrimarySurfaceColor = isDark ? colorScheme.onSurface : colorScheme.onPrimary; + + return ThemeData( + colorScheme: colorScheme, + brightness: colorScheme.brightness, + primaryColor: primarySurfaceColor, + canvasColor: colorScheme.surface, + scaffoldBackgroundColor: colorScheme.surface, + cardColor: colorScheme.surface, + dividerColor: colorScheme.onSurface.withOpacity(0.12), + dialogBackgroundColor: colorScheme.surface, + indicatorColor: onPrimarySurfaceColor, + textTheme: textTheme, + applyElevationOverlayColor: isDark, + useMaterial3: useMaterial3, + ); + } + + /// A default light theme. + /// + /// This theme does not contain text geometry. Instead, it is expected that + /// this theme is localized using text geometry using [ThemeData.localize]. + factory ThemeData.light({bool? useMaterial3}) => + ThemeData(brightness: Brightness.light, useMaterial3: useMaterial3); + + /// A default dark theme. + /// + /// This theme does not contain text geometry. Instead, it is expected that + /// this theme is localized using text geometry using [ThemeData.localize]. + factory ThemeData.dark({bool? useMaterial3}) => + ThemeData(brightness: Brightness.dark, useMaterial3: useMaterial3); + + /// The default color theme. Same as [ThemeData.light]. + /// + /// This is used by [Theme.of] when no theme has been specified. + /// + /// This theme does not contain text geometry. Instead, it is expected that + /// this theme is localized using text geometry using [ThemeData.localize]. + /// + /// Most applications would use [Theme.of], which provides correct localized + /// text geometry. + factory ThemeData.fallback({bool? useMaterial3}) => ThemeData.light(useMaterial3: useMaterial3); + + /// Used to obtain a particular [Adaptation] from [adaptationMap]. + /// + /// To get an adaptation, use `Theme.of(context).getAdaptation<MyAdaptation>()`. + Adaptation<T>? getAdaptation<T>() => adaptationMap[T] as Adaptation<T>?; + + static Map<Type, Adaptation<Object>> _createAdaptationMap( + Iterable<Adaptation<Object>> adaptations, + ) { + final adaptationMap = <Type, Adaptation<Object>>{ + for (final Adaptation<Object> adaptation in adaptations) adaptation.type: adaptation, + }; + return adaptationMap; + } + + /// The overall theme brightness. + /// + /// The default [TextStyle] color for the [textTheme] is black if the + /// theme is constructed with [Brightness.light] and white if the + /// theme is constructed with [Brightness.dark]. + Brightness get brightness => colorScheme.brightness; + + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + + /// Apply a semi-transparent overlay color on Material surfaces to indicate + /// elevation for dark themes. + /// + /// If [useMaterial3] is true, then this flag is ignored as there is a new + /// [Material.surfaceTintColor] used to create an overlay for Material 3. + /// This flag is meant only for the Material 2 elevation overlay for dark + /// themes. + /// + /// Material drop shadows can be difficult to see in a dark theme, so the + /// elevation of a surface should be portrayed with an "overlay" in addition + /// to the shadow. As the elevation of the component increases, the + /// overlay increases in opacity. [applyElevationOverlayColor] turns the + /// application of this overlay on or off for dark themes. + /// + /// If true and [brightness] is [Brightness.dark], a + /// semi-transparent version of [ColorScheme.onSurface] will be + /// applied on top of [Material] widgets that have a [ColorScheme.surface] + /// color. The level of transparency is based on [Material.elevation] as + /// per the Material Dark theme specification. + /// + /// If false the surface color will be used unmodified. + /// + /// Defaults to false in order to maintain backwards compatibility with + /// apps that were built before the Material Dark theme specification + /// was published. New apps should set this to true for any themes + /// where [brightness] is [Brightness.dark]. + /// + /// See also: + /// + /// * [Material.elevation], which effects the level of transparency of the + /// overlay color. + /// * [ElevationOverlay.applyOverlay], which is used by [Material] to apply + /// the overlay color to its surface color. + /// * <https://material.io/design/color/dark-theme.html>, which specifies how + /// the overlay should be applied. + final bool applyElevationOverlayColor; + + /// Components of the [CupertinoThemeData] to override from the Material + /// [ThemeData] adaptation. + /// + /// By default, [cupertinoOverrideTheme] is null and Cupertino widgets + /// descendant to the Material [Theme] will adhere to a [CupertinoTheme] + /// derived from the Material [ThemeData]. e.g. [ThemeData]'s [ColorScheme] + /// will also inform the [CupertinoThemeData]'s `primaryColor` etc. + /// + /// This cascading effect for individual attributes of the [CupertinoThemeData] + /// can be overridden using attributes of this [cupertinoOverrideTheme]. + final NoDefaultCupertinoThemeData? cupertinoOverrideTheme; + + /// Arbitrary additions to this theme. + /// + /// To define extensions, pass an [Iterable] containing one or more [ThemeExtension] + /// subclasses to [ThemeData.new] or [copyWith]. + /// + /// To obtain an extension, use [extension]. + /// + /// {@tool dartpad} + /// This sample shows how to create and use a subclass of [ThemeExtension] that + /// defines two colors. + /// + /// ** See code in examples/api/lib/material/theme/theme_extension.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [extension], a convenience function for obtaining a specific extension. + final Map<Object, ThemeExtension<dynamic>> extensions; + + /// Used to obtain a particular [ThemeExtension] from [extensions]. + /// + /// Obtain with `Theme.of(context).extension<MyThemeExtension>()`. + /// + /// See [extensions] for an interactive example. + T? extension<T>() => extensions[T] as T?; + + /// A map which contains the adaptations for the theme. The entry's key is the + /// type of the adaptation; the value is the adaptation itself. + /// + /// To obtain an adaptation, use [getAdaptation]. + final Map<Type, Adaptation<Object>> adaptationMap; + + /// The default [InputDecoration] values for [InputDecorator], [TextField], + /// and [TextFormField] are based on this theme. + /// + /// See [InputDecoration.applyDefaults]. + final InputDecorationThemeData inputDecorationTheme; + + /// Configures the hit test size of certain Material widgets. + /// + /// Defaults to a [platform]-appropriate size: [MaterialTapTargetSize.padded] + /// on mobile platforms, [MaterialTapTargetSize.shrinkWrap] on desktop + /// platforms. + final MaterialTapTargetSize materialTapTargetSize; + + /// Default [MaterialPageRoute] transitions per [TargetPlatform]. + /// + /// [MaterialPageRoute.buildTransitions] delegates to a [platform] specific + /// [PageTransitionsBuilder]. If a matching builder is not found, a builder + /// whose platform is null is used. + final PageTransitionsTheme pageTransitionsTheme; + + /// The platform the material widgets should adapt to target. + /// + /// Defaults to the current platform, as exposed by [defaultTargetPlatform]. + /// This should be used in order to style UI elements according to platform + /// conventions. + /// + /// Widgets from the material library should use this getter (via [Theme.of]) + /// to determine the current platform for the purpose of emulating the + /// platform behavior (e.g. scrolling or haptic effects). Widgets and render + /// objects at lower layers that try to emulate the underlying platform + /// can depend on [defaultTargetPlatform] directly, or may require + /// that the target platform be provided as an argument. The + /// [dart:io.Platform] object should only be used directly when it's critical + /// to actually know the current platform, without any overrides possible (for + /// example, when a system API is about to be called). + /// + /// In a test environment, the platform returned is [TargetPlatform.android] + /// regardless of the host platform. (Android was chosen because the tests + /// were originally written assuming Android-like behavior, and we added + /// platform adaptations for other platforms later). Tests can check behavior + /// for other platforms by setting the [platform] of the [Theme] explicitly to + /// another [TargetPlatform] value, or by setting + /// [debugDefaultTargetPlatformOverride]. + /// + /// Determines the defaults for [typography] and [materialTapTargetSize]. + final TargetPlatform platform; + + /// A theme for customizing the colors, thickness, and shape of [Scrollbar]s. + final ScrollbarThemeData scrollbarTheme; + + /// Defines the appearance of ink splashes produces by [InkWell] + /// and [InkResponse]. + /// + /// See also: + /// + /// * [InkSplash.splashFactory], which defines the default splash. + /// * [InkRipple.splashFactory], which defines a splash that spreads out + /// more aggressively than the default. + /// * [InkSparkle.splashFactory], which defines a more aggressive and organic + /// splash with sparkle effects. + final InteractiveInkFeatureFactory splashFactory; + + /// A temporary flag that can be used to opt-out of Material 3 features. + /// + /// This flag is _true_ by default. If false, then components will + /// continue to use the colors, typography and other features of + /// Material 2. + /// + /// In the long run this flag will be deprecated and eventually + /// only Material 3 will be supported. We recommend that applications + /// migrate to Material 3 as soon as that's practical. Until that migration + /// is complete, this flag can be set to false. + /// + /// ## Defaults + /// + /// If a [ThemeData] is _constructed_ with [useMaterial3] set to true, then + /// some properties will get updated defaults. However, the + /// [ThemeData.copyWith] method with [useMaterial3] set to true will _not_ + /// change any of these properties in the resulting [ThemeData]. + /// + /// <style>table,td,th { border-collapse: collapse; padding: 0.45em; } td { border: 1px solid }</style> + /// + /// | Property | Material 3 default | Material 2 default | + /// | :-------------- | :----------------------------- | :----------------------------- | + /// | [colorScheme] | M3 baseline light color scheme | M2 baseline light color scheme | + /// | [typography] | [Typography.material2021] | [Typography.material2014] | + /// | [splashFactory] | [InkSparkle]* or [InkRipple] | [InkSplash] | + /// + /// \* if the target platform is Android and the app is not + /// running on the web, otherwise it will fallback to [InkRipple]. + /// + /// If [brightness] is [Brightness.dark] then the default color scheme will + /// be either the M3 baseline dark color scheme or the M2 baseline dark color + /// scheme depending on [useMaterial3]. + /// + /// ## Affected widgets + /// + /// This flag affects styles and components. + /// + /// ### Styles + /// * Color: [ColorScheme], [Material] (see table above) + /// * Shape: (see components below) + /// * Typography: [Typography] (see table above) + /// + /// ### Components + /// * Badges: [Badge] + /// * Bottom app bar: [BottomAppBar] + /// * Bottom sheets: [BottomSheet] + /// * Buttons + /// - Common buttons: [ElevatedButton], [FilledButton], [FilledButton.tonal], [OutlinedButton], [TextButton] + /// - FAB: [FloatingActionButton], [FloatingActionButton.extended] + /// - Icon buttons: [IconButton], [IconButton.filled] (*new*), [IconButton.filledTonal], [IconButton.outlined] + /// - Segmented buttons: [SegmentedButton] (replacing [ToggleButtons]) + /// * Cards: [Card] + /// * Checkbox: [Checkbox], [CheckboxListTile] + /// * Chips: + /// - [ActionChip] (used for Assist and Suggestion chips), + /// - [FilterChip], [ChoiceChip] (used for single selection filter chips), + /// - [InputChip] + /// * Date pickers: [showDatePicker], [showDateRangePicker], [DatePickerDialog], [DateRangePickerDialog], [InputDatePickerFormField] + /// * Dialogs: [AlertDialog], [Dialog.fullscreen] + /// * Divider: [Divider], [VerticalDivider] + /// * Lists: [ListTile] + /// * Menus: [MenuAnchor], [DropdownMenu], [MenuBar] + /// * Navigation bar: [NavigationBar] (replacing [BottomNavigationBar]) + /// * Navigation drawer: [NavigationDrawer] (replacing [Drawer]) + /// * Navigation rail: [NavigationRail] + /// * Progress indicators: [CircularProgressIndicator], [LinearProgressIndicator] + /// * Radio button: [Radio], [RadioListTile] + /// * Search: [SearchBar], [SearchAnchor], + /// * Snack bar: [SnackBar] + /// * Slider: [Slider], [RangeSlider] + /// * Switch: [Switch], [SwitchListTile] + /// * Tabs: [TabBar], [TabBar.secondary] + /// * TextFields: [TextField] together with its [InputDecoration] + /// * Time pickers: [showTimePicker], [TimePickerDialog] + /// * Top app bar: [AppBar], [SliverAppBar], [SliverAppBar.medium], [SliverAppBar.large] + /// + /// In addition, this flag enables features introduced in Android 12. + /// * Stretch overscroll: [MaterialScrollBehavior] + /// * Ripple: `splashFactory` (see table above) + /// + /// See also: + /// + /// * [Material 3 specification](https://m3.material.io/). + final bool useMaterial3; + + /// The density value for specifying the compactness of various UI components. + /// + /// {@template flutter.material.themedata.visualDensity} + /// Density, in the context of a UI, is the vertical and horizontal + /// "compactness" of the elements in the UI. It is unitless, since it means + /// different things to different UI elements. For buttons, it affects the + /// spacing around the centered label of the button. For lists, it affects the + /// distance between baselines of entries in the list. + /// + /// Typically, density values are integral, but any value in range may be + /// used. The range includes values from [VisualDensity.minimumDensity] (which + /// is -4), to [VisualDensity.maximumDensity] (which is 4), inclusive, where + /// negative values indicate a denser, more compact, UI, and positive values + /// indicate a less dense, more expanded, UI. If a component doesn't support + /// the value given, it will clamp to the nearest supported value. + /// + /// The default for visual densities is zero for both vertical and horizontal + /// densities, which corresponds to the default visual density of components + /// in the Material Design specification. + /// + /// As a rule of thumb, a change of 1 or -1 in density corresponds to 4 + /// logical pixels. However, this is not a strict relationship since + /// components interpret the density values appropriately for their needs. + /// + /// A larger value translates to a spacing increase (less dense), and a + /// smaller value translates to a spacing decrease (more dense). + /// + /// In Material Design 3, the [visualDensity] does not override the default + /// visual for the following components which are set to [VisualDensity.standard] + /// for all platforms: + /// + /// * [IconButton] - To override the default value of [IconButton.visualDensity], + /// use [ThemeData.iconButtonTheme] instead. + /// * [Checkbox] - To override the default value of [Checkbox.visualDensity], + /// use [ThemeData.checkboxTheme] instead. + /// {@endtemplate} + final VisualDensity visualDensity; + + // COLOR + + /// The default color of [MaterialType.canvas] [Material]. + final Color canvasColor; + + /// The color of [Material] when it is used as a [Card]. + final Color cardColor; + + /// {@macro flutter.material.color_scheme.ColorScheme} + /// + /// This property was added much later than the theme's set of highly specific + /// colors, like [cardColor], [canvasColor] etc. New components can be defined + /// exclusively in terms of [colorScheme]. Existing components will gradually + /// migrate to it, to the extent that is possible without significant + /// backwards compatibility breaks. + final ColorScheme colorScheme; + + /// The color used for widgets that are inoperative, regardless of + /// their state. For example, a disabled checkbox (which may be + /// checked or unchecked). + final Color disabledColor; + + /// The color of [Divider]s and [PopupMenuDivider]s, also used + /// between [ListTile]s, between rows in [DataTable]s, and so forth. + /// + /// To create an appropriate [BorderSide] that uses this color, consider + /// [Divider.createBorderSide]. + final Color dividerColor; + + /// The focus color used indicate that a component has the input focus. + final Color focusColor; + + /// The highlight color used during ink splash animations or to + /// indicate an item in a menu is selected. + final Color highlightColor; + + /// The color to use for hint text or placeholder text, e.g. in + /// [TextField] fields. + final Color hintColor; + + /// The hover color used to indicate when a pointer is hovering over a + /// component. + final Color hoverColor; + + /// The background color for major parts of the app (toolbars, tab bars, etc) + /// + /// The theme's [colorScheme] property contains [ColorScheme.primary], as + /// well as a color that contrasts well with the primary color called + /// [ColorScheme.onPrimary]. It might be simpler to just configure an app's + /// visuals in terms of the theme's [colorScheme]. + final Color primaryColor; + + /// A darker version of the [primaryColor]. + final Color primaryColorDark; + + /// A lighter version of the [primaryColor]. + final Color primaryColorLight; + + /// The default color of the [Material] that underlies the [Scaffold]. The + /// background color for a typical material app or a page within the app. + final Color scaffoldBackgroundColor; + + /// The color of the header of a [PaginatedDataTable] when there are selected rows. + // According to the spec for data tables: + // https://material.io/archive/guidelines/components/data-tables.html#data-tables-tables-within-cards + // ...this should be the "50-value of secondary app color". + final Color secondaryHeaderColor; + + /// The color that the [Material] widget uses to draw elevation shadows. + /// + /// Defaults to fully opaque black. + /// + /// Shadows can be difficult to see in a dark theme, so the elevation of a + /// surface should be rendered with an "overlay" in addition to the shadow. + /// As the elevation of the component increases, the overlay increases in + /// opacity. The [applyElevationOverlayColor] property turns the elevation + /// overlay on or off for dark themes. + final Color shadowColor; + + /// The color of ink splashes. + /// + /// See also: + /// * [splashFactory], which defines the appearance of the splash. + final Color splashColor; + + /// The color used for widgets in their inactive (but enabled) + /// state. For example, an unchecked checkbox. See also [disabledColor]. + final Color unselectedWidgetColor; + + // TYPOGRAPHY & ICONOGRAPHY + + /// An icon theme that contrasts with the card and canvas colors. + final IconThemeData iconTheme; + + /// An icon theme that contrasts with the primary color. + final IconThemeData primaryIconTheme; + + /// A text theme that contrasts with the primary color. + final TextTheme primaryTextTheme; + + /// Text with a color that contrasts with the card and canvas colors. + final TextTheme textTheme; + + /// The color and geometry [TextTheme] values used to configure [textTheme]. + /// + /// Defaults to a [platform]-appropriate typography. + final Typography typography; + + // COMPONENT THEMES + + /// A theme for customizing icons of [BackButtonIcon], [CloseButtonIcon], + /// [DrawerButtonIcon], or [EndDrawerButtonIcon]. + final ActionIconThemeData? actionIconTheme; + + /// A theme for customizing the color, elevation, brightness, iconTheme and + /// textTheme of [AppBar]s. + final AppBarThemeData appBarTheme; + + /// A theme for customizing the color of [Badge]s. + final BadgeThemeData badgeTheme; + + /// A theme for customizing the color and text style of a [MaterialBanner]. + final MaterialBannerThemeData bannerTheme; + + /// A theme for customizing the shape, elevation, and color of a [BottomAppBar]. + final BottomAppBarThemeData bottomAppBarTheme; + + /// A theme for customizing the appearance and layout of [BottomNavigationBar] + /// widgets. + final BottomNavigationBarThemeData bottomNavigationBarTheme; + + /// A theme for customizing the color, elevation, and shape of a bottom sheet. + final BottomSheetThemeData bottomSheetTheme; + + /// Defines the default configuration of button widgets, like [DropdownButton] + /// and [ButtonBar]. + final ButtonThemeData buttonTheme; + + /// The colors and styles used to render [Card]. + /// + /// This is the value returned from [CardTheme.of]. + final CardThemeData cardTheme; + + /// A theme for customizing the appearance and layout of [CarouselView] widgets. + final CarouselViewThemeData carouselViewTheme; + + /// A theme for customizing the appearance and layout of [Checkbox] widgets. + final CheckboxThemeData checkboxTheme; + + /// The colors and styles used to render [Chip]s. + /// + /// This is the value returned from [ChipTheme.of]. + final ChipThemeData chipTheme; + + /// A theme for customizing the appearance and layout of [DataTable] + /// widgets. + final DataTableThemeData dataTableTheme; + + /// A theme for customizing the appearance and layout of [DatePickerDialog] + /// widgets. + final DatePickerThemeData datePickerTheme; + + /// A theme for customizing the shape of a dialog. + final DialogThemeData dialogTheme; + + /// A theme for customizing the color, thickness, and indents of [Divider]s, + /// [VerticalDivider]s, etc. + final DividerThemeData dividerTheme; + + /// A theme for customizing the appearance and layout of [Drawer] widgets. + final DrawerThemeData drawerTheme; + + /// A theme for customizing the appearance and layout of [DropdownMenu] widgets. + final DropdownMenuThemeData dropdownMenuTheme; + + /// A theme for customizing the appearance and internal layout of + /// [ElevatedButton]s. + final ElevatedButtonThemeData elevatedButtonTheme; + + /// 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; + + /// A theme for customizing the appearance and internal layout of + /// [IconButton]s. + final IconButtonThemeData iconButtonTheme; + + /// A theme for customizing the appearance of [ListTile] widgets. + final ListTileThemeData listTileTheme; + + /// A theme for customizing the color, shape, elevation, and other [MenuStyle] + /// aspects of the menu bar created by the [MenuBar] widget. + final MenuBarThemeData menuBarTheme; + + /// A theme for customizing the color, shape, elevation, and text style of + /// cascading menu buttons created by [SubmenuButton] or [MenuItemButton]. + final MenuButtonThemeData menuButtonTheme; + + /// A theme for customizing the color, shape, elevation, and other [MenuStyle] + /// attributes of menus created by the [SubmenuButton] widget. + final MenuThemeData menuTheme; + + /// A theme for customizing the background color, text style, and icon themes + /// of a [NavigationBar]. + final NavigationBarThemeData navigationBarTheme; + + /// A theme for customizing the background color, text style, and icon themes + /// of a [NavigationDrawer]. + final NavigationDrawerThemeData navigationDrawerTheme; + + /// A theme for customizing the background color, elevation, text style, and + /// icon themes of a [NavigationRail]. + final NavigationRailThemeData navigationRailTheme; + + /// A theme for customizing the appearance and internal layout of + /// [OutlinedButton]s. + final OutlinedButtonThemeData outlinedButtonTheme; + + /// A theme for customizing the color, shape, elevation, and text style of + /// popup menus. + final PopupMenuThemeData popupMenuTheme; + + /// A theme for customizing the appearance and layout of [ProgressIndicator] widgets. + final ProgressIndicatorThemeData progressIndicatorTheme; + + /// A theme for customizing the appearance and layout of [Radio] widgets. + final RadioThemeData radioTheme; + + /// A theme for customizing the appearance and layout of [SearchBar] widgets. + final SearchBarThemeData searchBarTheme; + + /// A theme for customizing the appearance and layout of search views created by [SearchAnchor] widgets. + final SearchViewThemeData searchViewTheme; + + /// A theme for customizing the appearance and layout of [SegmentedButton] widgets. + final SegmentedButtonThemeData segmentedButtonTheme; + + /// A theme for customizing the appearance and layout of [Slider] widgets. + final SliderThemeData sliderTheme; + + /// A theme for customizing colors, shape, elevation, and behavior of a [SnackBar]. + final SnackBarThemeData snackBarTheme; + + /// A theme for customizing the appearance and layout of [Switch] widgets. + final SwitchThemeData switchTheme; + + /// A theme for customizing the size, shape, and color of the tab bar indicator. + final TabBarThemeData tabBarTheme; + + /// A theme for customizing the appearance and internal layout of + /// [TextButton]s. + final TextButtonThemeData textButtonTheme; + + /// A theme for customizing the appearance for text selection in [TextField] and + /// [SelectableText] widgets. + final TextSelectionThemeData textSelectionTheme; + + /// A theme for customizing the appearance and layout of time picker widgets. + final TimePickerThemeData timePickerTheme; + + /// A theme for customizing the appearance and layout of [ToggleButtons] widgets. + final ToggleButtonsThemeData toggleButtonsTheme; + + /// A theme for customizing the appearance and layout of [Tooltip] widgets. + final TooltipThemeData tooltipTheme; + + /// A theme for customizing the appearance and layout of [ButtonBar] widgets. + @Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', + ) + ButtonBarThemeData get buttonBarTheme => _buttonBarTheme!; + final ButtonBarThemeData? _buttonBarTheme; + + /// The background color of [Dialog] elements. + @Deprecated( + 'Use DialogThemeData.backgroundColor instead. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + final Color dialogBackgroundColor; + + /// The color of the selected tab indicator in a tab bar. + @Deprecated( + 'Use TabBarThemeData.indicatorColor instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + final Color indicatorColor; + + /// Creates a copy of this theme but with the given fields replaced with the new values. + /// + /// The [brightness] value is applied to the [colorScheme]. + ThemeData copyWith({ + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + Iterable<Adaptation<Object>>? adaptations, + bool? applyElevationOverlayColor, + NoDefaultCupertinoThemeData? cupertinoOverrideTheme, + Iterable<ThemeExtension<dynamic>>? extensions, + Object? inputDecorationTheme, + MaterialTapTargetSize? materialTapTargetSize, + PageTransitionsTheme? pageTransitionsTheme, + TargetPlatform? platform, + ScrollbarThemeData? scrollbarTheme, + InteractiveInkFeatureFactory? splashFactory, + VisualDensity? visualDensity, + // COLOR + ColorScheme? colorScheme, + Brightness? brightness, + // [colorScheme] is the preferred way to configure colors. The [Color] properties + // listed below (as well as primarySwatch) will gradually be phased out, see + // https://github.com/flutter/flutter/issues/91772. + Color? canvasColor, + Color? cardColor, + Color? disabledColor, + Color? dividerColor, + Color? focusColor, + Color? highlightColor, + Color? hintColor, + Color? hoverColor, + Color? primaryColor, + Color? primaryColorDark, + Color? primaryColorLight, + Color? scaffoldBackgroundColor, + Color? secondaryHeaderColor, + Color? shadowColor, + Color? splashColor, + Color? unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + IconThemeData? iconTheme, + IconThemeData? primaryIconTheme, + TextTheme? primaryTextTheme, + TextTheme? textTheme, + Typography? typography, + // COMPONENT THEMES + ActionIconThemeData? actionIconTheme, + // TODO(huycozy): Change the parameter type to AppBarThemeData + Object? appBarTheme, + BadgeThemeData? badgeTheme, + MaterialBannerThemeData? bannerTheme, + BottomAppBarThemeData? bottomAppBarTheme, + BottomNavigationBarThemeData? bottomNavigationBarTheme, + BottomSheetThemeData? bottomSheetTheme, + ButtonThemeData? buttonTheme, + CardThemeData? cardTheme, + CarouselViewThemeData? carouselViewTheme, + CheckboxThemeData? checkboxTheme, + ChipThemeData? chipTheme, + DataTableThemeData? dataTableTheme, + DatePickerThemeData? datePickerTheme, + DialogThemeData? dialogTheme, + DividerThemeData? dividerTheme, + DrawerThemeData? drawerTheme, + DropdownMenuThemeData? dropdownMenuTheme, + ElevatedButtonThemeData? elevatedButtonTheme, + ExpansionTileThemeData? expansionTileTheme, + FilledButtonThemeData? filledButtonTheme, + FloatingActionButtonThemeData? floatingActionButtonTheme, + IconButtonThemeData? iconButtonTheme, + ListTileThemeData? listTileTheme, + MenuBarThemeData? menuBarTheme, + MenuButtonThemeData? menuButtonTheme, + MenuThemeData? menuTheme, + NavigationBarThemeData? navigationBarTheme, + NavigationDrawerThemeData? navigationDrawerTheme, + NavigationRailThemeData? navigationRailTheme, + OutlinedButtonThemeData? outlinedButtonTheme, + PopupMenuThemeData? popupMenuTheme, + ProgressIndicatorThemeData? progressIndicatorTheme, + RadioThemeData? radioTheme, + SearchBarThemeData? searchBarTheme, + SearchViewThemeData? searchViewTheme, + SegmentedButtonThemeData? segmentedButtonTheme, + SliderThemeData? sliderTheme, + SnackBarThemeData? snackBarTheme, + SwitchThemeData? switchTheme, + TabBarThemeData? tabBarTheme, + TextButtonThemeData? textButtonTheme, + TextSelectionThemeData? textSelectionTheme, + TimePickerThemeData? timePickerTheme, + ToggleButtonsThemeData? toggleButtonsTheme, + TooltipThemeData? tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + @Deprecated( + 'Use a ThemeData constructor (.from, .light, or .dark) instead. ' + 'These constructors all have a useMaterial3 argument, ' + 'and they set appropriate default values based on its value. ' + 'See the useMaterial3 API documentation for full details. ' + 'This feature was deprecated after v3.13.0-0.2.pre.', + ) + bool? useMaterial3, + @Deprecated( + 'Use OverflowBar instead. ' + 'This feature was deprecated after v3.21.0-10.0.pre.', + ) + ButtonBarThemeData? buttonBarTheme, + @Deprecated( + 'Use DialogThemeData.backgroundColor instead. ' + 'This feature was deprecated after v3.27.0-0.1.pre.', + ) + Color? dialogBackgroundColor, + @Deprecated( + 'Use TabBarThemeData.indicatorColor instead. ' + 'This feature was deprecated after v3.28.0-1.0.pre.', + ) + Color? indicatorColor, + }) { + cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); + + // TODO(bleroux): Clean this up once the type of `inputDecorationTheme` is changed to `InputDecorationThemeData` + if (inputDecorationTheme != null) { + if (inputDecorationTheme is InputDecorationTheme) { + inputDecorationTheme = inputDecorationTheme.data; + } else if (inputDecorationTheme is! InputDecorationThemeData) { + throw ArgumentError( + 'inputDecorationTheme must be either a InputDecorationThemeData or a InputDecorationTheme', + ); + } + } + + return ThemeData.raw( + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + adaptationMap: adaptations != null ? _createAdaptationMap(adaptations) : adaptationMap, + applyElevationOverlayColor: applyElevationOverlayColor ?? this.applyElevationOverlayColor, + cupertinoOverrideTheme: cupertinoOverrideTheme ?? this.cupertinoOverrideTheme, + extensions: (extensions != null) ? _themeExtensionIterableToMap(extensions) : this.extensions, + inputDecorationTheme: + inputDecorationTheme as InputDecorationThemeData? ?? this.inputDecorationTheme, + materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize, + pageTransitionsTheme: pageTransitionsTheme ?? this.pageTransitionsTheme, + platform: platform ?? this.platform, + scrollbarTheme: scrollbarTheme ?? this.scrollbarTheme, + splashFactory: splashFactory ?? this.splashFactory, + // When deprecated useMaterial3 removed, maintain `this.useMaterial3` here + // for == evaluation. + useMaterial3: useMaterial3 ?? this.useMaterial3, + visualDensity: visualDensity ?? this.visualDensity, + // COLOR + canvasColor: canvasColor ?? this.canvasColor, + cardColor: cardColor ?? this.cardColor, + colorScheme: (colorScheme ?? this.colorScheme).copyWith(brightness: brightness), + disabledColor: disabledColor ?? this.disabledColor, + dividerColor: dividerColor ?? this.dividerColor, + focusColor: focusColor ?? this.focusColor, + highlightColor: highlightColor ?? this.highlightColor, + hintColor: hintColor ?? this.hintColor, + hoverColor: hoverColor ?? this.hoverColor, + primaryColor: primaryColor ?? this.primaryColor, + primaryColorDark: primaryColorDark ?? this.primaryColorDark, + primaryColorLight: primaryColorLight ?? this.primaryColorLight, + scaffoldBackgroundColor: scaffoldBackgroundColor ?? this.scaffoldBackgroundColor, + secondaryHeaderColor: secondaryHeaderColor ?? this.secondaryHeaderColor, + shadowColor: shadowColor ?? this.shadowColor, + splashColor: splashColor ?? this.splashColor, + unselectedWidgetColor: unselectedWidgetColor ?? this.unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme: iconTheme ?? this.iconTheme, + primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, + primaryTextTheme: primaryTextTheme ?? this.primaryTextTheme, + textTheme: textTheme ?? this.textTheme, + typography: typography ?? this.typography, + // COMPONENT THEMES + actionIconTheme: actionIconTheme ?? this.actionIconTheme, + // TODO(huycozy): Remove this check when appBarTheme is a AppBarThemeData + appBarTheme: () { + if (appBarTheme != null) { + if (appBarTheme is AppBarTheme) { + return appBarTheme.data; + } else if (appBarTheme is! AppBarThemeData) { + throw ArgumentError('appBarTheme must be either a AppBarThemeData or a AppBarTheme'); + } + } + return appBarTheme as AppBarThemeData? ?? this.appBarTheme; + }(), + badgeTheme: badgeTheme ?? this.badgeTheme, + bannerTheme: bannerTheme ?? this.bannerTheme, + bottomAppBarTheme: bottomAppBarTheme ?? this.bottomAppBarTheme, + bottomNavigationBarTheme: bottomNavigationBarTheme ?? this.bottomNavigationBarTheme, + bottomSheetTheme: bottomSheetTheme ?? this.bottomSheetTheme, + buttonTheme: buttonTheme ?? this.buttonTheme, + cardTheme: cardTheme ?? this.cardTheme, + carouselViewTheme: carouselViewTheme ?? this.carouselViewTheme, + checkboxTheme: checkboxTheme ?? this.checkboxTheme, + chipTheme: chipTheme ?? this.chipTheme, + dataTableTheme: dataTableTheme ?? this.dataTableTheme, + datePickerTheme: datePickerTheme ?? this.datePickerTheme, + dialogTheme: dialogTheme ?? this.dialogTheme, + dividerTheme: dividerTheme ?? this.dividerTheme, + drawerTheme: drawerTheme ?? this.drawerTheme, + dropdownMenuTheme: dropdownMenuTheme ?? this.dropdownMenuTheme, + elevatedButtonTheme: elevatedButtonTheme ?? this.elevatedButtonTheme, + expansionTileTheme: expansionTileTheme ?? this.expansionTileTheme, + filledButtonTheme: filledButtonTheme ?? this.filledButtonTheme, + floatingActionButtonTheme: floatingActionButtonTheme ?? this.floatingActionButtonTheme, + iconButtonTheme: iconButtonTheme ?? this.iconButtonTheme, + listTileTheme: listTileTheme ?? this.listTileTheme, + menuBarTheme: menuBarTheme ?? this.menuBarTheme, + menuButtonTheme: menuButtonTheme ?? this.menuButtonTheme, + menuTheme: menuTheme ?? this.menuTheme, + navigationBarTheme: navigationBarTheme ?? this.navigationBarTheme, + navigationDrawerTheme: navigationDrawerTheme ?? this.navigationDrawerTheme, + navigationRailTheme: navigationRailTheme ?? this.navigationRailTheme, + outlinedButtonTheme: outlinedButtonTheme ?? this.outlinedButtonTheme, + popupMenuTheme: popupMenuTheme ?? this.popupMenuTheme, + progressIndicatorTheme: progressIndicatorTheme ?? this.progressIndicatorTheme, + radioTheme: radioTheme ?? this.radioTheme, + searchBarTheme: searchBarTheme ?? this.searchBarTheme, + searchViewTheme: searchViewTheme ?? this.searchViewTheme, + segmentedButtonTheme: segmentedButtonTheme ?? this.segmentedButtonTheme, + sliderTheme: sliderTheme ?? this.sliderTheme, + snackBarTheme: snackBarTheme ?? this.snackBarTheme, + switchTheme: switchTheme ?? this.switchTheme, + tabBarTheme: tabBarTheme ?? this.tabBarTheme, + textButtonTheme: textButtonTheme ?? this.textButtonTheme, + textSelectionTheme: textSelectionTheme ?? this.textSelectionTheme, + timePickerTheme: timePickerTheme ?? this.timePickerTheme, + toggleButtonsTheme: toggleButtonsTheme ?? this.toggleButtonsTheme, + tooltipTheme: tooltipTheme ?? this.tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme: buttonBarTheme ?? _buttonBarTheme, + dialogBackgroundColor: dialogBackgroundColor ?? this.dialogBackgroundColor, + indicatorColor: indicatorColor ?? this.indicatorColor, + ); + } + + // The number 5 was chosen without any real science or research behind it. It + // just seemed like a number that's not too big (we should be able to fit 5 + // copies of ThemeData in memory comfortably) and not too small (most apps + // shouldn't have more than 5 theme/localization pairs). + static const int _localizedThemeDataCacheSize = 5; + + /// Caches localized themes to speed up the [localize] method. + static final _FifoCache<_IdentityThemeDataCacheKey, ThemeData> _localizedThemeDataCache = + _FifoCache<_IdentityThemeDataCacheKey, ThemeData>(_localizedThemeDataCacheSize); + + /// Returns a new theme built by merging the text geometry provided by the + /// [localTextGeometry] theme with the [baseTheme]. + /// + /// For those text styles in the [baseTheme] whose [TextStyle.inherit] is set + /// to true, the returned theme's text styles inherit the geometric properties + /// of [localTextGeometry]. The resulting text styles' [TextStyle.inherit] is + /// set to those provided by [localTextGeometry]. + static ThemeData localize(ThemeData baseTheme, TextTheme localTextGeometry) { + // WARNING: this method memoizes the result in a cache based on the + // previously seen baseTheme and localTextGeometry. Memoization is safe + // because all inputs and outputs of this function are deeply immutable, and + // the computations are referentially transparent. It only short-circuits + // the computation if the new inputs are identical() to the previous ones. + // It does not use the == operator, which performs a costly deep comparison. + // + // When changing this method, make sure the memoization logic is correct. + // Remember: + // + // There are only two hard things in Computer Science: cache invalidation + // and naming things. -- Phil Karlton + + return _localizedThemeDataCache.putIfAbsent( + _IdentityThemeDataCacheKey(baseTheme, localTextGeometry), + () { + return baseTheme.copyWith( + primaryTextTheme: localTextGeometry.merge(baseTheme.primaryTextTheme), + textTheme: localTextGeometry.merge(baseTheme.textTheme), + ); + }, + ); + } + + /// Determines whether the given [Color] is [Brightness.light] or + /// [Brightness.dark]. + /// + /// This compares the luminosity of the given color to a threshold value that + /// matches the Material Design specification. + static Brightness estimateBrightnessForColor(Color color) { + final double relativeLuminance = color.computeLuminance(); + + // See <https://www.w3.org/TR/WCAG20/#contrast-ratiodef> + // The spec says to use kThreshold=0.0525, but Material Design appears to bias + // more towards using light text than WCAG20 recommends. Material Design spec + // doesn't say what value to use, but 0.15 seemed close to what the Material + // Design spec shows for its color palette on + // <https://material.io/go/design-theming#color-color-palette>. + const kThreshold = 0.15; + if ((relativeLuminance + 0.05) * (relativeLuminance + 0.05) > kThreshold) { + return Brightness.light; + } + return Brightness.dark; + } + + /// Linearly interpolate between two [extensions]. + /// + /// Includes all theme extensions in [a] and [b]. + /// + /// {@macro dart.ui.shadow.lerp} + static Map<Object, ThemeExtension<dynamic>> _lerpThemeExtensions( + ThemeData a, + ThemeData b, + double t, + ) { + // Lerp [a]. + final Map<Object, ThemeExtension<dynamic>> newExtensions = a.extensions.map(( + Object id, + ThemeExtension<dynamic> extensionA, + ) { + final ThemeExtension<dynamic>? extensionB = b.extensions[id]; + return MapEntry<Object, ThemeExtension<dynamic>>(id, extensionA.lerp(extensionB, t)); + }); + // Add [b]-only extensions. + newExtensions.addEntries( + b.extensions.entries.where( + (MapEntry<Object, ThemeExtension<dynamic>> entry) => !a.extensions.containsKey(entry.key), + ), + ); + + return newExtensions; + } + + /// Convert the [extensionsIterable] passed to [ThemeData.new] or [copyWith] + /// to the stored [extensions] map, where each entry's key consists of the extension's type. + static Map<Object, ThemeExtension<dynamic>> _themeExtensionIterableToMap( + Iterable<ThemeExtension<dynamic>> extensionsIterable, + ) { + return Map<Object, ThemeExtension<dynamic>>.unmodifiable(<Object, ThemeExtension<dynamic>>{ + // Strangely, the cast is necessary for tests to run. + for (final ThemeExtension<dynamic> extension in extensionsIterable) + extension.type: extension as ThemeExtension<ThemeExtension<dynamic>>, + }); + } + + ThemeData _overrideWithSystemColors() { + if (!SystemColor.platformProvidesSystemColors) { + return this; + } + + final SystemColorPalette systemColors = brightness == Brightness.dark + ? SystemColor.dark + : SystemColor.light; + + var theme = this; + + theme = theme.copyWith( + colorScheme: colorScheme.copyWith( + secondary: systemColors.accentColor.value, + onSecondary: systemColors.accentColorText.value, + surface: systemColors.canvas.value, + onSurface: systemColors.canvasText.value, + ), + textTheme: textTheme.apply( + displayColor: systemColors.canvasText.value, + bodyColor: systemColors.canvasText.value, + ), + ); + + final bool overrideButtons = + systemColors.buttonFace.value != null || + systemColors.buttonBorder.value != null || + systemColors.buttonText.value != null; + + if (overrideButtons) { + theme = theme.copyWith( + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + foregroundColor: systemColors.buttonText.value, + backgroundColor: systemColors.buttonFace.value, + side: systemColors.buttonBorder.value == null + ? null + : BorderSide(color: systemColors.buttonBorder.value!), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: systemColors.buttonText.value, + backgroundColor: systemColors.buttonFace.value, + side: systemColors.buttonBorder.value == null + ? null + : BorderSide(color: systemColors.buttonBorder.value!), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: systemColors.buttonText.value, + backgroundColor: systemColors.buttonFace.value, + side: systemColors.buttonBorder.value == null + ? null + : BorderSide(color: systemColors.buttonBorder.value!), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + foregroundColor: systemColors.buttonText.value, + backgroundColor: systemColors.buttonFace.value, + side: systemColors.buttonBorder.value == null + ? null + : BorderSide(color: systemColors.buttonBorder.value!), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: systemColors.buttonFace.value, + foregroundColor: systemColors.buttonText.value, + ), + ); + } + + final bool overrideInputDecoration = + systemColors.field.value != null || systemColors.fieldText.value != null; + + if (overrideInputDecoration) { + theme = theme.copyWith( + inputDecorationTheme: inputDecorationTheme.copyWith( + fillColor: systemColors.field.value, + labelStyle: + inputDecorationTheme.labelStyle?.copyWith(color: systemColors.fieldText.value) ?? + TextStyle(color: systemColors.fieldText.value), + hintStyle: + inputDecorationTheme.hintStyle?.copyWith(color: systemColors.fieldText.value) ?? + TextStyle(color: systemColors.fieldText.value), + helperStyle: + inputDecorationTheme.helperStyle?.copyWith(color: systemColors.fieldText.value) ?? + TextStyle(color: systemColors.fieldText.value), + prefixStyle: + inputDecorationTheme.prefixStyle?.copyWith(color: systemColors.fieldText.value) ?? + TextStyle(color: systemColors.fieldText.value), + suffixStyle: + inputDecorationTheme.suffixStyle?.copyWith(color: systemColors.fieldText.value) ?? + TextStyle(color: systemColors.fieldText.value), + counterStyle: + inputDecorationTheme.counterStyle?.copyWith(color: systemColors.fieldText.value) ?? + TextStyle(color: systemColors.fieldText.value), + ), + ); + } + + return theme; + } + + /// Linearly interpolate between two themes. + /// + /// {@macro dart.ui.shadow.lerp} + static ThemeData lerp(ThemeData a, ThemeData b, double t) { + if (identical(a, b)) { + return a; + } + return ThemeData.raw( + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + adaptationMap: t < 0.5 ? a.adaptationMap : b.adaptationMap, + applyElevationOverlayColor: t < 0.5 + ? a.applyElevationOverlayColor + : b.applyElevationOverlayColor, + cupertinoOverrideTheme: t < 0.5 ? a.cupertinoOverrideTheme : b.cupertinoOverrideTheme, + extensions: _lerpThemeExtensions(a, b, t), + inputDecorationTheme: t < 0.5 ? a.inputDecorationTheme : b.inputDecorationTheme, + materialTapTargetSize: t < 0.5 ? a.materialTapTargetSize : b.materialTapTargetSize, + pageTransitionsTheme: t < 0.5 ? a.pageTransitionsTheme : b.pageTransitionsTheme, + platform: t < 0.5 ? a.platform : b.platform, + scrollbarTheme: ScrollbarThemeData.lerp(a.scrollbarTheme, b.scrollbarTheme, t), + splashFactory: t < 0.5 ? a.splashFactory : b.splashFactory, + useMaterial3: t < 0.5 ? a.useMaterial3 : b.useMaterial3, + visualDensity: VisualDensity.lerp(a.visualDensity, b.visualDensity, t), + // COLOR + canvasColor: Color.lerp(a.canvasColor, b.canvasColor, t)!, + cardColor: Color.lerp(a.cardColor, b.cardColor, t)!, + colorScheme: ColorScheme.lerp(a.colorScheme, b.colorScheme, t), + disabledColor: Color.lerp(a.disabledColor, b.disabledColor, t)!, + dividerColor: Color.lerp(a.dividerColor, b.dividerColor, t)!, + focusColor: Color.lerp(a.focusColor, b.focusColor, t)!, + highlightColor: Color.lerp(a.highlightColor, b.highlightColor, t)!, + hintColor: Color.lerp(a.hintColor, b.hintColor, t)!, + hoverColor: Color.lerp(a.hoverColor, b.hoverColor, t)!, + primaryColor: Color.lerp(a.primaryColor, b.primaryColor, t)!, + primaryColorDark: Color.lerp(a.primaryColorDark, b.primaryColorDark, t)!, + primaryColorLight: Color.lerp(a.primaryColorLight, b.primaryColorLight, t)!, + scaffoldBackgroundColor: Color.lerp(a.scaffoldBackgroundColor, b.scaffoldBackgroundColor, t)!, + secondaryHeaderColor: Color.lerp(a.secondaryHeaderColor, b.secondaryHeaderColor, t)!, + shadowColor: Color.lerp(a.shadowColor, b.shadowColor, t)!, + splashColor: Color.lerp(a.splashColor, b.splashColor, t)!, + unselectedWidgetColor: Color.lerp(a.unselectedWidgetColor, b.unselectedWidgetColor, t)!, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme: IconThemeData.lerp(a.iconTheme, b.iconTheme, t), + primaryIconTheme: IconThemeData.lerp(a.primaryIconTheme, b.primaryIconTheme, t), + primaryTextTheme: TextTheme.lerp(a.primaryTextTheme, b.primaryTextTheme, t), + textTheme: TextTheme.lerp(a.textTheme, b.textTheme, t), + typography: Typography.lerp(a.typography, b.typography, t), + // COMPONENT THEMES + actionIconTheme: ActionIconThemeData.lerp(a.actionIconTheme, b.actionIconTheme, t), + appBarTheme: AppBarThemeData.lerp(a.appBarTheme, b.appBarTheme, t), + badgeTheme: BadgeThemeData.lerp(a.badgeTheme, b.badgeTheme, t), + bannerTheme: MaterialBannerThemeData.lerp(a.bannerTheme, b.bannerTheme, t), + bottomAppBarTheme: BottomAppBarThemeData.lerp(a.bottomAppBarTheme, b.bottomAppBarTheme, t), + bottomNavigationBarTheme: BottomNavigationBarThemeData.lerp( + a.bottomNavigationBarTheme, + b.bottomNavigationBarTheme, + t, + ), + bottomSheetTheme: BottomSheetThemeData.lerp(a.bottomSheetTheme, b.bottomSheetTheme, t)!, + buttonTheme: t < 0.5 ? a.buttonTheme : b.buttonTheme, + cardTheme: CardThemeData.lerp(a.cardTheme, b.cardTheme, t), + carouselViewTheme: CarouselViewThemeData.lerp(a.carouselViewTheme, b.carouselViewTheme, t), + checkboxTheme: CheckboxThemeData.lerp(a.checkboxTheme, b.checkboxTheme, t), + chipTheme: ChipThemeData.lerp(a.chipTheme, b.chipTheme, t)!, + dataTableTheme: DataTableThemeData.lerp(a.dataTableTheme, b.dataTableTheme, t), + datePickerTheme: DatePickerThemeData.lerp(a.datePickerTheme, b.datePickerTheme, t), + dialogTheme: DialogThemeData.lerp(a.dialogTheme, b.dialogTheme, t), + dividerTheme: DividerThemeData.lerp(a.dividerTheme, b.dividerTheme, t), + drawerTheme: DrawerThemeData.lerp(a.drawerTheme, b.drawerTheme, t)!, + dropdownMenuTheme: DropdownMenuThemeData.lerp(a.dropdownMenuTheme, b.dropdownMenuTheme, 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)!, + menuBarTheme: MenuBarThemeData.lerp(a.menuBarTheme, b.menuBarTheme, t)!, + menuButtonTheme: MenuButtonThemeData.lerp(a.menuButtonTheme, b.menuButtonTheme, t)!, + menuTheme: MenuThemeData.lerp(a.menuTheme, b.menuTheme, t)!, + navigationBarTheme: NavigationBarThemeData.lerp( + a.navigationBarTheme, + b.navigationBarTheme, + t, + )!, + navigationDrawerTheme: NavigationDrawerThemeData.lerp( + a.navigationDrawerTheme, + b.navigationDrawerTheme, + t, + )!, + navigationRailTheme: NavigationRailThemeData.lerp( + a.navigationRailTheme, + b.navigationRailTheme, + t, + )!, + outlinedButtonTheme: OutlinedButtonThemeData.lerp( + a.outlinedButtonTheme, + b.outlinedButtonTheme, + t, + )!, + popupMenuTheme: PopupMenuThemeData.lerp(a.popupMenuTheme, b.popupMenuTheme, t)!, + progressIndicatorTheme: ProgressIndicatorThemeData.lerp( + a.progressIndicatorTheme, + b.progressIndicatorTheme, + t, + )!, + radioTheme: RadioThemeData.lerp(a.radioTheme, b.radioTheme, t), + searchBarTheme: SearchBarThemeData.lerp(a.searchBarTheme, b.searchBarTheme, t)!, + searchViewTheme: SearchViewThemeData.lerp(a.searchViewTheme, b.searchViewTheme, t)!, + segmentedButtonTheme: SegmentedButtonThemeData.lerp( + a.segmentedButtonTheme, + b.segmentedButtonTheme, + t, + ), + sliderTheme: SliderThemeData.lerp(a.sliderTheme, b.sliderTheme, t), + snackBarTheme: SnackBarThemeData.lerp(a.snackBarTheme, b.snackBarTheme, t), + switchTheme: SwitchThemeData.lerp(a.switchTheme, b.switchTheme, t), + tabBarTheme: TabBarThemeData.lerp(a.tabBarTheme, b.tabBarTheme, t), + textButtonTheme: TextButtonThemeData.lerp(a.textButtonTheme, b.textButtonTheme, t)!, + textSelectionTheme: TextSelectionThemeData.lerp( + a.textSelectionTheme, + b.textSelectionTheme, + t, + )!, + timePickerTheme: TimePickerThemeData.lerp(a.timePickerTheme, b.timePickerTheme, t), + toggleButtonsTheme: ToggleButtonsThemeData.lerp( + a.toggleButtonsTheme, + b.toggleButtonsTheme, + t, + )!, + tooltipTheme: TooltipThemeData.lerp(a.tooltipTheme, b.tooltipTheme, t)!, + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme: ButtonBarThemeData.lerp(a.buttonBarTheme, b.buttonBarTheme, t), + dialogBackgroundColor: Color.lerp(a.dialogBackgroundColor, b.dialogBackgroundColor, t)!, + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t)!, + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is ThemeData && + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + mapEquals(other.adaptationMap, adaptationMap) && + other.applyElevationOverlayColor == applyElevationOverlayColor && + other.cupertinoOverrideTheme == cupertinoOverrideTheme && + mapEquals(other.extensions, extensions) && + other.inputDecorationTheme == inputDecorationTheme && + other.materialTapTargetSize == materialTapTargetSize && + other.pageTransitionsTheme == pageTransitionsTheme && + other.platform == platform && + other.scrollbarTheme == scrollbarTheme && + other.splashFactory == splashFactory && + other.useMaterial3 == useMaterial3 && + other.visualDensity == visualDensity && + // COLOR + other.canvasColor == canvasColor && + other.cardColor == cardColor && + other.colorScheme == colorScheme && + other.disabledColor == disabledColor && + other.dividerColor == dividerColor && + other.focusColor == focusColor && + other.highlightColor == highlightColor && + other.hintColor == hintColor && + other.hoverColor == hoverColor && + other.primaryColor == primaryColor && + other.primaryColorDark == primaryColorDark && + other.primaryColorLight == primaryColorLight && + other.scaffoldBackgroundColor == scaffoldBackgroundColor && + other.secondaryHeaderColor == secondaryHeaderColor && + other.shadowColor == shadowColor && + other.splashColor == splashColor && + other.unselectedWidgetColor == unselectedWidgetColor && + // TYPOGRAPHY & ICONOGRAPHY + other.iconTheme == iconTheme && + other.primaryIconTheme == primaryIconTheme && + other.primaryTextTheme == primaryTextTheme && + other.textTheme == textTheme && + other.typography == typography && + // COMPONENT THEMES + other.actionIconTheme == actionIconTheme && + other.appBarTheme == appBarTheme && + other.badgeTheme == badgeTheme && + other.bannerTheme == bannerTheme && + other.bottomAppBarTheme == bottomAppBarTheme && + other.bottomNavigationBarTheme == bottomNavigationBarTheme && + other.bottomSheetTheme == bottomSheetTheme && + other.buttonTheme == buttonTheme && + other.cardTheme == cardTheme && + other.carouselViewTheme == carouselViewTheme && + other.checkboxTheme == checkboxTheme && + other.chipTheme == chipTheme && + other.dataTableTheme == dataTableTheme && + other.datePickerTheme == datePickerTheme && + other.dialogTheme == dialogTheme && + other.dividerTheme == dividerTheme && + other.drawerTheme == drawerTheme && + other.dropdownMenuTheme == dropdownMenuTheme && + other.elevatedButtonTheme == elevatedButtonTheme && + other.expansionTileTheme == expansionTileTheme && + other.filledButtonTheme == filledButtonTheme && + other.floatingActionButtonTheme == floatingActionButtonTheme && + other.iconButtonTheme == iconButtonTheme && + other.listTileTheme == listTileTheme && + other.menuBarTheme == menuBarTheme && + other.menuButtonTheme == menuButtonTheme && + other.menuTheme == menuTheme && + other.navigationBarTheme == navigationBarTheme && + other.navigationDrawerTheme == navigationDrawerTheme && + other.navigationRailTheme == navigationRailTheme && + other.outlinedButtonTheme == outlinedButtonTheme && + other.popupMenuTheme == popupMenuTheme && + other.progressIndicatorTheme == progressIndicatorTheme && + other.radioTheme == radioTheme && + other.searchBarTheme == searchBarTheme && + other.searchViewTheme == searchViewTheme && + other.segmentedButtonTheme == segmentedButtonTheme && + other.sliderTheme == sliderTheme && + other.snackBarTheme == snackBarTheme && + other.switchTheme == switchTheme && + other.tabBarTheme == tabBarTheme && + other.textButtonTheme == textButtonTheme && + other.textSelectionTheme == textSelectionTheme && + other.timePickerTheme == timePickerTheme && + other.toggleButtonsTheme == toggleButtonsTheme && + other.tooltipTheme == tooltipTheme && + // DEPRECATED (newest deprecations at the bottom) + other.buttonBarTheme == buttonBarTheme && + other.dialogBackgroundColor == dialogBackgroundColor && + other.indicatorColor == indicatorColor; + } + + @override + int get hashCode { + final values = <Object?>[ + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + ...adaptationMap.keys, + ...adaptationMap.values, + applyElevationOverlayColor, + cupertinoOverrideTheme, + ...extensions.keys, + ...extensions.values, + inputDecorationTheme, + materialTapTargetSize, + pageTransitionsTheme, + platform, + scrollbarTheme, + splashFactory, + useMaterial3, + visualDensity, + // COLOR + canvasColor, + cardColor, + colorScheme, + disabledColor, + dividerColor, + focusColor, + highlightColor, + hintColor, + hoverColor, + primaryColor, + primaryColorDark, + primaryColorLight, + scaffoldBackgroundColor, + secondaryHeaderColor, + shadowColor, + splashColor, + unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme, + primaryIconTheme, + primaryTextTheme, + textTheme, + typography, + // COMPONENT THEMES + actionIconTheme, + appBarTheme, + badgeTheme, + bannerTheme, + bottomAppBarTheme, + bottomNavigationBarTheme, + bottomSheetTheme, + buttonTheme, + cardTheme, + carouselViewTheme, + checkboxTheme, + chipTheme, + dataTableTheme, + datePickerTheme, + dialogTheme, + dividerTheme, + drawerTheme, + dropdownMenuTheme, + elevatedButtonTheme, + expansionTileTheme, + filledButtonTheme, + floatingActionButtonTheme, + iconButtonTheme, + listTileTheme, + menuBarTheme, + menuButtonTheme, + menuTheme, + navigationBarTheme, + navigationDrawerTheme, + navigationRailTheme, + outlinedButtonTheme, + popupMenuTheme, + progressIndicatorTheme, + radioTheme, + searchBarTheme, + searchViewTheme, + segmentedButtonTheme, + sliderTheme, + snackBarTheme, + switchTheme, + tabBarTheme, + textButtonTheme, + textSelectionTheme, + timePickerTheme, + toggleButtonsTheme, + tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme, + dialogBackgroundColor, + indicatorColor, + ]; + return Object.hashAll(values); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final defaultData = ThemeData.fallback(); + // For the sanity of the reader, make sure these properties are in the same + // order in every place that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + properties.add( + IterableProperty<Adaptation<dynamic>>( + 'adaptations', + adaptationMap.values, + defaultValue: defaultData.adaptationMap.values, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<bool>( + 'applyElevationOverlayColor', + applyElevationOverlayColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<NoDefaultCupertinoThemeData>( + 'cupertinoOverrideTheme', + cupertinoOverrideTheme, + defaultValue: defaultData.cupertinoOverrideTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + IterableProperty<ThemeExtension<dynamic>>( + 'extensions', + extensions.values, + defaultValue: defaultData.extensions.values, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<InputDecorationThemeData>( + 'inputDecorationTheme', + inputDecorationTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<MaterialTapTargetSize>( + 'materialTapTargetSize', + materialTapTargetSize, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<PageTransitionsTheme>( + 'pageTransitionsTheme', + pageTransitionsTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + EnumProperty<TargetPlatform>( + 'platform', + platform, + defaultValue: defaultTargetPlatform, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ScrollbarThemeData>( + 'scrollbarTheme', + scrollbarTheme, + defaultValue: defaultData.scrollbarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<InteractiveInkFeatureFactory>( + 'splashFactory', + splashFactory, + defaultValue: defaultData.splashFactory, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<bool>( + 'useMaterial3', + useMaterial3, + defaultValue: defaultData.useMaterial3, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<VisualDensity>( + 'visualDensity', + visualDensity, + defaultValue: defaultData.visualDensity, + level: DiagnosticLevel.debug, + ), + ); + // COLORS + properties.add( + ColorProperty( + 'canvasColor', + canvasColor, + defaultValue: defaultData.canvasColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'cardColor', + cardColor, + defaultValue: defaultData.cardColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ColorScheme>( + 'colorScheme', + colorScheme, + defaultValue: defaultData.colorScheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'disabledColor', + disabledColor, + defaultValue: defaultData.disabledColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'dividerColor', + dividerColor, + defaultValue: defaultData.dividerColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'focusColor', + focusColor, + defaultValue: defaultData.focusColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'highlightColor', + highlightColor, + defaultValue: defaultData.highlightColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'hintColor', + hintColor, + defaultValue: defaultData.hintColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'hoverColor', + hoverColor, + defaultValue: defaultData.hoverColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'primaryColorDark', + primaryColorDark, + defaultValue: defaultData.primaryColorDark, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'primaryColorLight', + primaryColorLight, + defaultValue: defaultData.primaryColorLight, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'primaryColor', + primaryColor, + defaultValue: defaultData.primaryColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'scaffoldBackgroundColor', + scaffoldBackgroundColor, + defaultValue: defaultData.scaffoldBackgroundColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'secondaryHeaderColor', + secondaryHeaderColor, + defaultValue: defaultData.secondaryHeaderColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'shadowColor', + shadowColor, + defaultValue: defaultData.shadowColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'splashColor', + splashColor, + defaultValue: defaultData.splashColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'unselectedWidgetColor', + unselectedWidgetColor, + defaultValue: defaultData.unselectedWidgetColor, + level: DiagnosticLevel.debug, + ), + ); + // TYPOGRAPHY & ICONOGRAPHY + properties.add( + DiagnosticsProperty<IconThemeData>('iconTheme', iconTheme, level: DiagnosticLevel.debug), + ); + properties.add( + DiagnosticsProperty<IconThemeData>( + 'primaryIconTheme', + primaryIconTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TextTheme>( + 'primaryTextTheme', + primaryTextTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TextTheme>('textTheme', textTheme, level: DiagnosticLevel.debug), + ); + properties.add( + DiagnosticsProperty<Typography>( + 'typography', + typography, + defaultValue: defaultData.typography, + level: DiagnosticLevel.debug, + ), + ); + // COMPONENT THEMES + properties.add( + DiagnosticsProperty<ActionIconThemeData>( + 'actionIconTheme', + actionIconTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<AppBarThemeData>( + 'appBarTheme', + appBarTheme, + defaultValue: defaultData.appBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<BadgeThemeData>( + 'badgeTheme', + badgeTheme, + defaultValue: defaultData.badgeTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<MaterialBannerThemeData>( + 'bannerTheme', + bannerTheme, + defaultValue: defaultData.bannerTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<BottomAppBarThemeData>( + 'bottomAppBarTheme', + bottomAppBarTheme, + defaultValue: defaultData.bottomAppBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<BottomNavigationBarThemeData>( + 'bottomNavigationBarTheme', + bottomNavigationBarTheme, + defaultValue: defaultData.bottomNavigationBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<BottomSheetThemeData>( + 'bottomSheetTheme', + bottomSheetTheme, + defaultValue: defaultData.bottomSheetTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ButtonThemeData>( + 'buttonTheme', + buttonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<CardThemeData>('cardTheme', cardTheme, level: DiagnosticLevel.debug), + ); + properties.add( + DiagnosticsProperty<CarouselViewThemeData>( + 'carouselViewTheme', + carouselViewTheme, + defaultValue: defaultData.carouselViewTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<CheckboxThemeData>( + 'checkboxTheme', + checkboxTheme, + defaultValue: defaultData.checkboxTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ChipThemeData>('chipTheme', chipTheme, level: DiagnosticLevel.debug), + ); + properties.add( + DiagnosticsProperty<DataTableThemeData>( + 'dataTableTheme', + dataTableTheme, + defaultValue: defaultData.dataTableTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<DatePickerThemeData>( + 'datePickerTheme', + datePickerTheme, + defaultValue: defaultData.datePickerTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<DialogThemeData>( + 'dialogTheme', + dialogTheme, + defaultValue: defaultData.dialogTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<DividerThemeData>( + 'dividerTheme', + dividerTheme, + defaultValue: defaultData.dividerTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<DrawerThemeData>( + 'drawerTheme', + drawerTheme, + defaultValue: defaultData.drawerTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<DropdownMenuThemeData>( + 'dropdownMenuTheme', + dropdownMenuTheme, + defaultValue: defaultData.dropdownMenuTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ElevatedButtonThemeData>( + 'elevatedButtonTheme', + elevatedButtonTheme, + defaultValue: defaultData.elevatedButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ExpansionTileThemeData>( + 'expansionTileTheme', + expansionTileTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<FilledButtonThemeData>( + 'filledButtonTheme', + filledButtonTheme, + defaultValue: defaultData.filledButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<FloatingActionButtonThemeData>( + 'floatingActionButtonTheme', + floatingActionButtonTheme, + defaultValue: defaultData.floatingActionButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<IconButtonThemeData>( + 'iconButtonTheme', + iconButtonTheme, + defaultValue: defaultData.iconButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ListTileThemeData>( + 'listTileTheme', + listTileTheme, + defaultValue: defaultData.listTileTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<MenuBarThemeData>( + 'menuBarTheme', + menuBarTheme, + defaultValue: defaultData.menuBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<MenuButtonThemeData>( + 'menuButtonTheme', + menuButtonTheme, + defaultValue: defaultData.menuButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<MenuThemeData>( + 'menuTheme', + menuTheme, + defaultValue: defaultData.menuTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<NavigationBarThemeData>( + 'navigationBarTheme', + navigationBarTheme, + defaultValue: defaultData.navigationBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<NavigationDrawerThemeData>( + 'navigationDrawerTheme', + navigationDrawerTheme, + defaultValue: defaultData.navigationDrawerTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<NavigationRailThemeData>( + 'navigationRailTheme', + navigationRailTheme, + defaultValue: defaultData.navigationRailTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<OutlinedButtonThemeData>( + 'outlinedButtonTheme', + outlinedButtonTheme, + defaultValue: defaultData.outlinedButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<PopupMenuThemeData>( + 'popupMenuTheme', + popupMenuTheme, + defaultValue: defaultData.popupMenuTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ProgressIndicatorThemeData>( + 'progressIndicatorTheme', + progressIndicatorTheme, + defaultValue: defaultData.progressIndicatorTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<RadioThemeData>( + 'radioTheme', + radioTheme, + defaultValue: defaultData.radioTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<SearchBarThemeData>( + 'searchBarTheme', + searchBarTheme, + defaultValue: defaultData.searchBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<SearchViewThemeData>( + 'searchViewTheme', + searchViewTheme, + defaultValue: defaultData.searchViewTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<SegmentedButtonThemeData>( + 'segmentedButtonTheme', + segmentedButtonTheme, + defaultValue: defaultData.segmentedButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<SliderThemeData>( + 'sliderTheme', + sliderTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<SnackBarThemeData>( + 'snackBarTheme', + snackBarTheme, + defaultValue: defaultData.snackBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<SwitchThemeData>( + 'switchTheme', + switchTheme, + defaultValue: defaultData.switchTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TabBarThemeData>( + 'tabBarTheme', + tabBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TextButtonThemeData>( + 'textButtonTheme', + textButtonTheme, + defaultValue: defaultData.textButtonTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TextSelectionThemeData>( + 'textSelectionTheme', + textSelectionTheme, + defaultValue: defaultData.textSelectionTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TimePickerThemeData>( + 'timePickerTheme', + timePickerTheme, + defaultValue: defaultData.timePickerTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<ToggleButtonsThemeData>( + 'toggleButtonsTheme', + toggleButtonsTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + DiagnosticsProperty<TooltipThemeData>( + 'tooltipTheme', + tooltipTheme, + level: DiagnosticLevel.debug, + ), + ); + // DEPRECATED (newest deprecations at the bottom) + properties.add( + DiagnosticsProperty<ButtonBarThemeData>( + 'buttonBarTheme', + buttonBarTheme, + defaultValue: defaultData.buttonBarTheme, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'dialogBackgroundColor', + dialogBackgroundColor, + defaultValue: defaultData.dialogBackgroundColor, + level: DiagnosticLevel.debug, + ), + ); + properties.add( + ColorProperty( + 'indicatorColor', + indicatorColor, + defaultValue: defaultData.indicatorColor, + level: DiagnosticLevel.debug, + ), + ); + } +} + +/// A [CupertinoThemeData] that defers unspecified theme attributes to an +/// upstream Material [ThemeData]. +/// +/// This type of [CupertinoThemeData] is used by the Material [Theme] to +/// harmonize the [CupertinoTheme] with the material theme's colors and text +/// styles. +/// +/// In the most basic case, [ThemeData]'s `cupertinoOverrideTheme` is null and +/// descendant Cupertino widgets' styling is derived from the Material theme. +/// +/// To override individual parts of the Material-derived Cupertino styling, +/// `cupertinoOverrideTheme`'s construction parameters can be used. +/// +/// To completely decouple the Cupertino styling from Material theme derivation, +/// another [CupertinoTheme] widget can be inserted as a descendant of the +/// Material [Theme]. On a [MaterialApp], this can be done using the `builder` +/// parameter on the constructor. +/// +/// See also: +/// +/// * [CupertinoThemeData], whose null constructor parameters default to +/// reasonable iOS styling defaults rather than harmonizing with a Material +/// theme. +/// * [Theme], widget which inserts a [CupertinoTheme] with this +/// [MaterialBasedCupertinoThemeData]. +// This class subclasses CupertinoThemeData rather than composes one because it +// _is_ a CupertinoThemeData with partially altered behavior. e.g. its textTheme +// is from the superclass and based on the primaryColor but the primaryColor +// comes from the Material theme unless overridden. +class MaterialBasedCupertinoThemeData extends CupertinoThemeData { + /// Create a [MaterialBasedCupertinoThemeData] based on a Material [ThemeData] + /// and its `cupertinoOverrideTheme`. + MaterialBasedCupertinoThemeData({required ThemeData materialTheme}) + : this._( + materialTheme, + (materialTheme.cupertinoOverrideTheme ?? const CupertinoThemeData()).noDefault(), + ); + + MaterialBasedCupertinoThemeData._(this._materialTheme, this._cupertinoOverrideTheme) + : // Pass all values to the superclass so Material-agnostic properties + // like barBackgroundColor can still behave like a normal + // CupertinoThemeData. + super.raw( + _cupertinoOverrideTheme.brightness, + _cupertinoOverrideTheme.primaryColor, + _cupertinoOverrideTheme.primaryContrastingColor, + _cupertinoOverrideTheme.textTheme, + _cupertinoOverrideTheme.barBackgroundColor, + _cupertinoOverrideTheme.scaffoldBackgroundColor, + _cupertinoOverrideTheme.selectionHandleColor ?? + _materialTheme.textSelectionTheme.selectionHandleColor, + _cupertinoOverrideTheme.applyThemeToAll, + ); + + final ThemeData _materialTheme; + final NoDefaultCupertinoThemeData _cupertinoOverrideTheme; + + @override + Brightness get brightness => _cupertinoOverrideTheme.brightness ?? _materialTheme.brightness; + + @override + Color get primaryColor => + _cupertinoOverrideTheme.primaryColor ?? _materialTheme.colorScheme.primary; + + @override + Color get primaryContrastingColor => + _cupertinoOverrideTheme.primaryContrastingColor ?? _materialTheme.colorScheme.onPrimary; + + @override + Color get scaffoldBackgroundColor => + _cupertinoOverrideTheme.scaffoldBackgroundColor ?? _materialTheme.scaffoldBackgroundColor; + + /// Copies the [ThemeData]'s `cupertinoOverrideTheme`. + /// + /// Only the specified override attributes of the [ThemeData]'s + /// `cupertinoOverrideTheme` and the newly specified parameters are in the + /// returned [CupertinoThemeData]. No derived attributes from iOS defaults or + /// from cascaded Material theme attributes are copied. + /// + /// This [copyWith] cannot change the base Material [ThemeData]. To change the + /// base Material [ThemeData], create a new Material [Theme] and use + /// [ThemeData.copyWith] on the Material [ThemeData] instead. + @override + MaterialBasedCupertinoThemeData copyWith({ + Brightness? brightness, + Color? primaryColor, + Color? primaryContrastingColor, + CupertinoTextThemeData? textTheme, + Color? barBackgroundColor, + Color? scaffoldBackgroundColor, + Color? selectionHandleColor, + bool? applyThemeToAll, + }) { + return MaterialBasedCupertinoThemeData._( + _materialTheme, + _cupertinoOverrideTheme.copyWith( + brightness: brightness, + primaryColor: primaryColor, + primaryContrastingColor: primaryContrastingColor, + textTheme: textTheme, + barBackgroundColor: barBackgroundColor, + scaffoldBackgroundColor: scaffoldBackgroundColor, + selectionHandleColor: selectionHandleColor, + applyThemeToAll: applyThemeToAll, + ), + ); + } + + @override + CupertinoThemeData resolveFrom(BuildContext context) { + // Only the cupertino override theme part will be resolved, as well as the + // default text theme. + // If the color comes from the material theme it's not resolved. + final NoDefaultCupertinoThemeData cupertinoOverrideThemeWithTextTheme = _cupertinoOverrideTheme + .copyWith(textTheme: textTheme); + return MaterialBasedCupertinoThemeData._( + _materialTheme, + cupertinoOverrideThemeWithTextTheme.resolveFrom(context), + ); + } +} + +/// A class for creating a Material theme with a color scheme based off of the +/// colors from a [CupertinoThemeData]. This is intended to be used only in the +/// case when a Material widget is unable to find a Material theme in the tree, +/// but is able to find a Cupertino theme. Most often this will occur when a +/// Material widget is used inside of a [CupertinoApp]. +/// +/// Besides the colors, this theme will use all the defaults from Material's +/// [ThemeData], so if further customization is needed, it is best to manually +/// add a Material [Theme] above the [CupertinoApp]. +class CupertinoBasedMaterialThemeData { + /// Creates a Material theme with a color scheme based off of the colors from + /// a [CupertinoThemeData]. + CupertinoBasedMaterialThemeData({required CupertinoThemeData themeData}) + : materialTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: themeData.primaryColor, + brightness: themeData.brightness ?? Brightness.light, + primary: themeData.primaryColor, + onPrimary: themeData.primaryContrastingColor, + ), + ); + + /// The Material theme data with colors based on an existing [CupertinoThemeData]. + final ThemeData materialTheme; +} + +@immutable +class _IdentityThemeDataCacheKey { + const _IdentityThemeDataCacheKey(this.baseTheme, this.localTextGeometry); + + final ThemeData baseTheme; + final TextTheme localTextGeometry; + + // Using XOR to make the hash function as fast as possible (e.g. Jenkins is + // noticeably slower). + @override + int get hashCode => identityHashCode(baseTheme) ^ identityHashCode(localTextGeometry); + + @override + bool operator ==(Object other) { + // We are explicitly ignoring the possibility that the types might not + // match in the interests of speed. + return other is _IdentityThemeDataCacheKey && + identical(other.baseTheme, baseTheme) && + identical(other.localTextGeometry, localTextGeometry); + } +} + +/// Cache of objects of limited size that uses the first in first out eviction +/// strategy (a.k.a least recently inserted). +/// +/// The key that was inserted before all other keys is evicted first, i.e. the +/// one inserted least recently. +class _FifoCache<K, V> { + _FifoCache(this._maximumSize) : assert(_maximumSize > 0); + + /// In Dart the map literal uses a linked hash-map implementation, whose keys + /// are stored such that [Map.keys] returns them in the order they were + /// inserted. + final Map<K, V> _cache = <K, V>{}; + + /// Maximum number of entries to store in the cache. + /// + /// Once this many entries have been cached, the entry inserted least recently + /// is evicted when adding a new entry. + final int _maximumSize; + + /// Returns the previously cached value for the given key, if available; + /// if not, calls the given callback to obtain it first. + V putIfAbsent(K key, V Function() loader) { + assert(key != null); + final V? result = _cache[key]; + if (result != null) { + return result; + } + if (_cache.length == _maximumSize) { + _cache.remove(_cache.keys.first); + } + return _cache[key] = loader(); + } +} + +/// Defines the visual density of user interface components. +/// +/// Density, in the context of a UI, is the vertical and horizontal +/// "compactness" of the components in the UI. It is unitless, since it means +/// different things to different UI components. +/// +/// The default for visual densities is zero for both vertical and horizontal +/// densities, which corresponds to the default visual density of components in +/// the Material Design specification. It does not affect text sizes, icon +/// sizes, or padding values. +/// +/// The default visual density varies by platform: mobile platforms (Android, iOS, +/// Fuchsia) use [VisualDensity.standard], while desktop platforms (macOS, Windows, +/// Linux) use [VisualDensity.compact]. See [defaultDensityForPlatform] for more details. +/// +/// For example, for buttons, it affects the spacing around the child of the +/// button. For lists, it affects the distance between baselines of entries in +/// the list. For chips, it only affects the vertical size, not the horizontal +/// size. +/// +/// Here are some examples of widgets that respond to density changes: +/// +/// * [Checkbox] +/// * [Chip] +/// * [ElevatedButton] +/// * [FilledButton] +/// * [IconButton] +/// * [InputDecorator] (which gives density support to [TextField], etc.) +/// * [ListTile] +/// * [MaterialButton] +/// * [OutlinedButton] +/// * [Radio] +/// * [RawMaterialButton] +/// * [TextButton] +/// +/// See also: +/// +/// * [ThemeData.visualDensity], where this property is used to specify the base +/// horizontal density of Material components. +/// * [Material design guidance on density](https://material.io/design/layout/applying-density.html). +@immutable +class VisualDensity with Diagnosticable { + /// A const constructor for [VisualDensity]. + /// + /// The [horizontal] and [vertical] arguments must be in the interval between + /// [minimumDensity] and [maximumDensity], inclusive. + const VisualDensity({this.horizontal = 0.0, this.vertical = 0.0}) + : assert(vertical <= maximumDensity), + assert(vertical >= minimumDensity), + assert(horizontal <= maximumDensity), + assert(horizontal >= minimumDensity); + + /// The minimum allowed density. + static const double minimumDensity = -4.0; + + /// The maximum allowed density. + static const double maximumDensity = 4.0; + + /// The default profile for [VisualDensity] in [ThemeData]. + /// + /// This default value represents a visual density that is less dense than + /// either [comfortable] or [compact], and corresponds to density values of + /// zero in both axes. + static const VisualDensity standard = VisualDensity(); + + /// The profile for a "comfortable" interpretation of [VisualDensity]. + /// + /// Individual components will interpret the density value independently, + /// making themselves more visually dense than [standard] and less dense than + /// [compact] to different degrees based on the Material Design specification + /// of the "comfortable" setting for their particular use case. + /// + /// It corresponds to a density value of -1 in both axes. + static const VisualDensity comfortable = VisualDensity(horizontal: -1.0, vertical: -1.0); + + /// The profile for a "compact" interpretation of [VisualDensity]. + /// + /// Individual components will interpret the density value independently, + /// making themselves more visually dense than [standard] and [comfortable] to + /// different degrees based on the Material Design specification of the + /// "comfortable" setting for their particular use case. + /// + /// It corresponds to a density value of -2 in both axes. + static const VisualDensity compact = VisualDensity(horizontal: -2.0, vertical: -2.0); + + /// Returns a [VisualDensity] that is adaptive based on the current platform + /// on which the framework is executing, from [defaultTargetPlatform]. + /// + /// When [defaultTargetPlatform] is a desktop platform, this returns + /// [compact], and for other platforms, it returns a default-constructed + /// [VisualDensity]. + /// + /// See also: + /// + /// * [defaultDensityForPlatform] which returns a [VisualDensity] that is + /// adaptive based on the platform given to it. + /// * [defaultTargetPlatform] which returns the platform on which the + /// framework is currently executing. + static VisualDensity get adaptivePlatformDensity => + defaultDensityForPlatform(defaultTargetPlatform); + + /// Returns a [VisualDensity] that is adaptive based on the given [platform]. + /// + /// For mobile platforms (Android, iOS, Fuchsia), this returns [VisualDensity.standard], + /// and for desktop platforms (macOS, Windows, Linux), it returns [VisualDensity.compact]. + /// + /// See also: + /// + /// * [adaptivePlatformDensity] which returns a [VisualDensity] that is + /// adaptive based on [defaultTargetPlatform]. + static VisualDensity defaultDensityForPlatform(TargetPlatform platform) { + return switch (platform) { + TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => standard, + TargetPlatform.linux || TargetPlatform.macOS || TargetPlatform.windows => compact, + }; + } + + /// Copy the current [VisualDensity] with the given values replacing the + /// current values. + VisualDensity copyWith({double? horizontal, double? vertical}) { + return VisualDensity( + horizontal: horizontal ?? this.horizontal, + vertical: vertical ?? this.vertical, + ); + } + + /// The horizontal visual density of UI components. + /// + /// This property affects only the horizontal spacing between and within + /// components, to allow for different UI visual densities. It does not affect + /// text sizes, icon sizes, or padding values. The default value is 0.0, + /// corresponding to the metrics specified in the Material Design + /// specification. The value can range from [minimumDensity] to + /// [maximumDensity], inclusive. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], where this property is used to specify the base + /// horizontal density of Material components. + /// * [Material design guidance on density](https://material.io/design/layout/applying-density.html). + final double horizontal; + + /// The vertical visual density of UI components. + /// + /// This property affects only the vertical spacing between and within + /// components, to allow for different UI visual densities. It does not affect + /// text sizes, icon sizes, or padding values. The default value is 0.0, + /// corresponding to the metrics specified in the Material Design + /// specification. The value can range from [minimumDensity] to + /// [maximumDensity], inclusive. + /// + /// See also: + /// + /// * [ThemeData.visualDensity], where this property is used to specify the base + /// vertical density of Material components. + /// * [Material design guidance on density](https://material.io/design/layout/applying-density.html). + final double vertical; + + /// The base adjustment in logical pixels of the visual density of UI components. + /// + /// The input density values are multiplied by a constant to arrive at a base + /// size adjustment that fits Material Design guidelines. + /// + /// Individual components may adjust this value based upon their own + /// individual interpretation of density. + Offset get baseSizeAdjustment { + // The number of logical pixels represented by an increase or decrease in + // density by one. The Material Design guidelines say to increment/decrement + // sizes in terms of four pixel increments. + const interval = 4.0; + + return Offset(horizontal, vertical) * interval; + } + + /// Linearly interpolate between two densities. + static VisualDensity lerp(VisualDensity a, VisualDensity b, double t) { + if (identical(a, b)) { + return a; + } + return VisualDensity( + horizontal: lerpDouble(a.horizontal, b.horizontal, t)!, + vertical: lerpDouble(a.vertical, b.vertical, t)!, + ); + } + + /// Return a copy of [constraints] whose minimum width and height have been + /// updated with the [baseSizeAdjustment]. + /// + /// The resulting minWidth and minHeight values are clamped to not exceed the + /// maxWidth and maxHeight values, respectively. + BoxConstraints effectiveConstraints(BoxConstraints constraints) { + assert(constraints.debugAssertIsValid()); + return constraints.copyWith( + minWidth: clampDouble( + constraints.minWidth + baseSizeAdjustment.dx, + 0.0, + constraints.maxWidth, + ), + minHeight: clampDouble( + constraints.minHeight + baseSizeAdjustment.dy, + 0.0, + constraints.maxHeight, + ), + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is VisualDensity && other.horizontal == horizontal && other.vertical == vertical; + } + + @override + int get hashCode => Object.hash(horizontal, vertical); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('horizontal', horizontal, defaultValue: 0.0)); + properties.add(DoubleProperty('vertical', vertical, defaultValue: 0.0)); + } + + @override + String toStringShort() { + return '${super.toStringShort()}(h: ${debugFormatDouble(horizontal)}, v: ${debugFormatDouble(vertical)})'; + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - ColorScheme + +// 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. + +// dart format off +const ColorScheme _colorSchemeLightM3 = ColorScheme( + brightness: Brightness.light, + primary: Color(0xFF6750A4), + onPrimary: Color(0xFFFFFFFF), + primaryContainer: Color(0xFFEADDFF), + onPrimaryContainer: Color(0xFF4F378B), + primaryFixed: Color(0xFFEADDFF), + primaryFixedDim: Color(0xFFD0BCFF), + onPrimaryFixed: Color(0xFF21005D), + onPrimaryFixedVariant: Color(0xFF4F378B), + secondary: Color(0xFF625B71), + onSecondary: Color(0xFFFFFFFF), + secondaryContainer: Color(0xFFE8DEF8), + onSecondaryContainer: Color(0xFF4A4458), + secondaryFixed: Color(0xFFE8DEF8), + secondaryFixedDim: Color(0xFFCCC2DC), + onSecondaryFixed: Color(0xFF1D192B), + onSecondaryFixedVariant: Color(0xFF4A4458), + tertiary: Color(0xFF7D5260), + onTertiary: Color(0xFFFFFFFF), + tertiaryContainer: Color(0xFFFFD8E4), + onTertiaryContainer: Color(0xFF633B48), + tertiaryFixed: Color(0xFFFFD8E4), + tertiaryFixedDim: Color(0xFFEFB8C8), + onTertiaryFixed: Color(0xFF31111D), + onTertiaryFixedVariant: Color(0xFF633B48), + error: Color(0xFFB3261E), + onError: Color(0xFFFFFFFF), + errorContainer: Color(0xFFF9DEDC), + onErrorContainer: Color(0xFF8C1D18), + background: Color(0xFFFEF7FF), + onBackground: Color(0xFF1D1B20), + surface: Color(0xFFFEF7FF), + surfaceBright: Color(0xFFFEF7FF), + surfaceContainerLowest: Color(0xFFFFFFFF), + surfaceContainerLow: Color(0xFFF7F2FA), + surfaceContainer: Color(0xFFF3EDF7), + surfaceContainerHigh: Color(0xFFECE6F0), + surfaceContainerHighest: Color(0xFFE6E0E9), + surfaceDim: Color(0xFFDED8E1), + onSurface: Color(0xFF1D1B20), + surfaceVariant: Color(0xFFE7E0EC), + onSurfaceVariant: Color(0xFF49454F), + outline: Color(0xFF79747E), + outlineVariant: Color(0xFFCAC4D0), + shadow: Color(0xFF000000), + scrim: Color(0xFF000000), + inverseSurface: Color(0xFF322F35), + onInverseSurface: Color(0xFFF5EFF7), + inversePrimary: Color(0xFFD0BCFF), + // The surfaceTint color is set to the same color as the primary. + surfaceTint: Color(0xFF6750A4), +); + +const ColorScheme _colorSchemeDarkM3 = ColorScheme( + brightness: Brightness.dark, + primary: Color(0xFFD0BCFF), + onPrimary: Color(0xFF381E72), + primaryContainer: Color(0xFF4F378B), + onPrimaryContainer: Color(0xFFEADDFF), + primaryFixed: Color(0xFFEADDFF), + primaryFixedDim: Color(0xFFD0BCFF), + onPrimaryFixed: Color(0xFF21005D), + onPrimaryFixedVariant: Color(0xFF4F378B), + secondary: Color(0xFFCCC2DC), + onSecondary: Color(0xFF332D41), + secondaryContainer: Color(0xFF4A4458), + onSecondaryContainer: Color(0xFFE8DEF8), + secondaryFixed: Color(0xFFE8DEF8), + secondaryFixedDim: Color(0xFFCCC2DC), + onSecondaryFixed: Color(0xFF1D192B), + onSecondaryFixedVariant: Color(0xFF4A4458), + tertiary: Color(0xFFEFB8C8), + onTertiary: Color(0xFF492532), + tertiaryContainer: Color(0xFF633B48), + onTertiaryContainer: Color(0xFFFFD8E4), + tertiaryFixed: Color(0xFFFFD8E4), + tertiaryFixedDim: Color(0xFFEFB8C8), + onTertiaryFixed: Color(0xFF31111D), + onTertiaryFixedVariant: Color(0xFF633B48), + error: Color(0xFFF2B8B5), + onError: Color(0xFF601410), + errorContainer: Color(0xFF8C1D18), + onErrorContainer: Color(0xFFF9DEDC), + background: Color(0xFF141218), + onBackground: Color(0xFFE6E0E9), + surface: Color(0xFF141218), + surfaceBright: Color(0xFF3B383E), + surfaceContainerLowest: Color(0xFF0F0D13), + surfaceContainerLow: Color(0xFF1D1B20), + surfaceContainer: Color(0xFF211F26), + surfaceContainerHigh: Color(0xFF2B2930), + surfaceContainerHighest: Color(0xFF36343B), + surfaceDim: Color(0xFF141218), + onSurface: Color(0xFFE6E0E9), + surfaceVariant: Color(0xFF49454F), + onSurfaceVariant: Color(0xFFCAC4D0), + outline: Color(0xFF938F99), + outlineVariant: Color(0xFF49454F), + shadow: Color(0xFF000000), + scrim: Color(0xFF000000), + inverseSurface: Color(0xFFE6E0E9), + onInverseSurface: Color(0xFF322F35), + inversePrimary: Color(0xFF6750A4), + // The surfaceTint color is set to the same color as the primary. + surfaceTint: Color(0xFFD0BCFF), +); +// dart format on + +// END GENERATED TOKEN PROPERTIES - ColorScheme diff --git a/packages/material_ui/lib/src/time.dart b/packages/material_ui/lib/src/time.dart new file mode 100644 index 000000000000..c38d7da3d106 --- /dev/null +++ b/packages/material_ui/lib/src/time.dart @@ -0,0 +1,275 @@ +// 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. + +/// @docImport 'time_picker.dart'; +library; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'debug.dart'; +import 'material_localizations.dart'; + +/// Whether the [TimeOfDay] is before or after noon. +enum DayPeriod { + /// Ante meridiem (before noon). + am, + + /// Post meridiem (after noon). + pm, +} + +/// A value representing a time during the day, independent of the date that +/// day might fall on or the time zone. +/// +/// The time is represented by [hour] and [minute] pair. Once created, both +/// values cannot be changed. +/// +/// You can create TimeOfDay using the constructor which requires both hour and +/// minute or using [DateTime] object. +/// Hours are specified between 0 and 23, as in a 24-hour clock. +/// +/// {@tool snippet} +/// +/// ```dart +/// TimeOfDay now = TimeOfDay.now(); +/// const TimeOfDay releaseTime = TimeOfDay(hour: 15, minute: 0); // 3:00pm +/// TimeOfDay roomBooked = TimeOfDay.fromDateTime(DateTime.parse('2018-10-20 16:30:04Z')); // 4:30pm +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [showTimePicker], which returns this type. +/// * [MaterialLocalizations], which provides methods for formatting values of +/// this type according to the chosen [Locale]. +/// * [DateTime], which represents date and time, and is subject to eras and +/// time zones. +@immutable +class TimeOfDay implements Comparable<TimeOfDay> { + /// Creates a time of day. + /// + /// The [hour] argument must be between 0 and 23, inclusive. The [minute] + /// argument must be between 0 and 59, inclusive. + const TimeOfDay({required this.hour, required this.minute}); + + /// Creates a time of day based on the given time. + /// + /// The [hour] is set to the time's hour and the [minute] is set to the time's + /// minute in the timezone of the given [DateTime]. + TimeOfDay.fromDateTime(DateTime time) : hour = time.hour, minute = time.minute; + + /// Creates a time of day based on the current time. + /// + /// The [hour] is set to the current hour and the [minute] is set to the + /// current minute in the local time zone. + TimeOfDay.now() : this.fromDateTime(DateTime.now()); + + /// The number of hours in one day, i.e. 24. + static const int hoursPerDay = 24; + + /// The number of hours in one day period (see also [DayPeriod]), i.e. 12. + static const int hoursPerPeriod = 12; + + /// The number of minutes in one hour, i.e. 60. + static const int minutesPerHour = 60; + + /// Returns a new TimeOfDay with the hour and/or minute replaced. + TimeOfDay replacing({int? hour, int? minute}) { + assert(hour == null || (hour >= 0 && hour < hoursPerDay)); + assert(minute == null || (minute >= 0 && minute < minutesPerHour)); + return TimeOfDay(hour: hour ?? this.hour, minute: minute ?? this.minute); + } + + /// The selected hour, in 24 hour time from 0..23. + final int hour; + + /// The selected minute. + final int minute; + + /// Whether this time of day is before or after noon. + DayPeriod get period => hour < hoursPerPeriod ? DayPeriod.am : DayPeriod.pm; + + /// Which hour of the current period (e.g., am or pm) this time is. + /// + /// For 12AM (midnight) and 12PM (noon) this returns 12. + int get hourOfPeriod => hour == 0 || hour == 12 ? 12 : hour - periodOffset; + + /// The hour at which the current period starts. + int get periodOffset => period == DayPeriod.am ? 0 : hoursPerPeriod; + + /// Returns the localized string representation of this time of day. + /// + /// This is a shortcut for [MaterialLocalizations.formatTimeOfDay]. + String format(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return localizations.formatTimeOfDay( + this, + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + } + + /// Whether this [TimeOfDay] occurs earlier than [other]. + /// + /// Does not account for day or sub-minute differences. This means + /// that "00:00" of the next day is still before "23:00" of this day. + bool isBefore(TimeOfDay other) => compareTo(other) < 0; + + /// Whether this [TimeOfDay] occurs later than [other]. + /// + /// Does not account for day or sub-minute differences. This means + /// that "00:00" of the next day is still before "23:00" of this day. + bool isAfter(TimeOfDay other) => compareTo(other) > 0; + + /// Whether this [TimeOfDay] occurs at the same time as [other]. + /// + /// Does not account for day or sub-minute differences. This means + /// that "00:00" of the next day is still before "23:00" of this day. + bool isAtSameTimeAs(TimeOfDay other) => compareTo(other) == 0; + + /// Compares this [TimeOfDay] object to [other] independent of date. + /// + /// Does not account for day or sub-minute differences. This means + /// that "00:00" of the next day is still before "23:00" of this day. + /// + /// A [compareTo] function returns: + /// * a negative value if this TimeOfDay [isBefore] [other]. + /// * `0` if this DateTime [isAtSameTimeAs] [other], and + /// * a positive value otherwise (when this TimeOfDay [isAfter] [other]). + @override + int compareTo(TimeOfDay other) { + final int hourComparison = hour.compareTo(other.hour); + return hourComparison == 0 ? minute.compareTo(other.minute) : hourComparison; + } + + @override + bool operator ==(Object other) { + return other is TimeOfDay && other.hour == hour && other.minute == minute; + } + + @override + int get hashCode => Object.hash(hour, minute); + + @override + String toString() { + String addLeadingZeroIfNeeded(int value) { + if (value < 10) { + return '0$value'; + } + return value.toString(); + } + + final String hourLabel = addLeadingZeroIfNeeded(hour); + final String minuteLabel = addLeadingZeroIfNeeded(minute); + + return '$TimeOfDay($hourLabel:$minuteLabel)'; + } +} + +/// A [RestorableValue] that knows how to save and restore [TimeOfDay]. +/// +/// {@macro flutter.widgets.RestorableNum}. +class RestorableTimeOfDay extends RestorableValue<TimeOfDay> { + /// Creates a [RestorableTimeOfDay]. + /// + /// {@macro flutter.widgets.RestorableNum.constructor} + RestorableTimeOfDay(TimeOfDay defaultValue) : _defaultValue = defaultValue; + + final TimeOfDay _defaultValue; + + @override + TimeOfDay createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(TimeOfDay? oldValue) { + assert(debugIsSerializableForRestoration(value.hour)); + assert(debugIsSerializableForRestoration(value.minute)); + notifyListeners(); + } + + @override + TimeOfDay fromPrimitives(Object? data) { + final timeData = data! as List<Object?>; + return TimeOfDay(minute: timeData[0]! as int, hour: timeData[1]! as int); + } + + @override + Object? toPrimitives() => <int>[value.minute, value.hour]; +} + +/// Determines how the time picker invoked using [showTimePicker] formats and +/// lays out the time controls. +/// +/// The time picker provides layout configurations optimized for each of the +/// enum values. +enum TimeOfDayFormat { + /// Corresponds to the ICU 'HH:mm' pattern. + /// + /// This format uses 24-hour two-digit zero-padded hours. Controls are always + /// laid out horizontally. Hours are separated from minutes by one colon + /// character. + HH_colon_mm, + + /// Corresponds to the ICU 'HH.mm' pattern. + /// + /// This format uses 24-hour two-digit zero-padded hours. Controls are always + /// laid out horizontally. Hours are separated from minutes by one dot + /// character. + HH_dot_mm, + + /// Corresponds to the ICU "HH 'h' mm" pattern used in Canadian French. + /// + /// This format uses 24-hour two-digit zero-padded hours. Controls are always + /// laid out horizontally. Hours are separated from minutes by letter 'h'. + frenchCanadian, + + /// Corresponds to the ICU 'H:mm' pattern. + /// + /// This format uses 24-hour non-padded variable-length hours. Controls are + /// always laid out horizontally. Hours are separated from minutes by one + /// colon character. + H_colon_mm, + + /// Corresponds to the ICU 'h:mm a' pattern. + /// + /// This format uses 12-hour non-padded variable-length hours with a day + /// period. Controls are laid out horizontally in portrait mode. In landscape + /// mode, the day period appears vertically after (consistent with the ambient + /// [TextDirection]) hour-minute indicator. Hours are separated from minutes + /// by one colon character. + h_colon_mm_space_a, + + /// Corresponds to the ICU 'a h:mm' pattern. + /// + /// This format uses 12-hour non-padded variable-length hours with a day + /// period. Controls are laid out horizontally in portrait mode. In landscape + /// mode, the day period appears vertically before (consistent with the + /// ambient [TextDirection]) hour-minute indicator. Hours are separated from + /// minutes by one colon character. + a_space_h_colon_mm, +} + +/// Describes how hours are formatted. +enum HourFormat { + /// Zero-padded two-digit 24-hour format ranging from "00" to "23". + HH, + + /// Non-padded variable-length 24-hour format ranging from "0" to "23". + H, + + /// Non-padded variable-length hour in day period format ranging from "1" to + /// "12". + h, +} + +/// The [HourFormat] used for the given [TimeOfDayFormat]. +HourFormat hourFormat({required TimeOfDayFormat of}) => switch (of) { + TimeOfDayFormat.h_colon_mm_space_a || TimeOfDayFormat.a_space_h_colon_mm => HourFormat.h, + TimeOfDayFormat.H_colon_mm => HourFormat.H, + TimeOfDayFormat.HH_dot_mm || + TimeOfDayFormat.HH_colon_mm || + TimeOfDayFormat.frenchCanadian => HourFormat.HH, +}; diff --git a/packages/material_ui/lib/src/time_picker.dart b/packages/material_ui/lib/src/time_picker.dart new file mode 100644 index 000000000000..8e10025e1cf5 --- /dev/null +++ b/packages/material_ui/lib/src/time_picker.dart @@ -0,0 +1,3969 @@ +// 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. + +/// @docImport 'date_picker.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'colors.dart'; +import 'constants.dart'; +import 'curves.dart'; +import 'debug.dart'; +import 'dialog.dart'; +import 'icon_button.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'input_border.dart'; +import 'input_decorator.dart'; +import 'material.dart'; +import 'material_localizations.dart'; +import 'material_state.dart'; +import 'text_button.dart'; +import 'text_form_field.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'time.dart'; +import 'time_picker_theme.dart'; + +// Examples can assume: +// late BuildContext context; + +const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200); +const Duration _kDialAnimateDuration = Duration(milliseconds: 200); +const double _kTwoPi = 2 * math.pi; +const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); + +const double _kTimePickerHeaderLandscapeWidth = 216; +const double _kTimePickerInnerDialOffset = 28; +const double _kTimePickerDialMinRadius = 50; +const double _kTimePickerDialPadding = 28; + +/// Interactive input mode of the time picker dialog. +/// +/// In [TimePickerEntryMode.dial] mode, a clock dial is displayed and the user +/// taps or drags the time they wish to select. In TimePickerEntryMode.input] +/// mode, [TextField]s are displayed and the user types in the time they wish to +/// select. +/// +/// See also: +/// +/// * [showTimePicker], a function that shows a [TimePickerDialog] and returns +/// the selected time as a [Future]. +enum TimePickerEntryMode { + /// User picks time from a clock dial. + /// + /// Can switch to [input] by activating a mode button in the dialog. + dial, + + /// User can input the time by typing it into text fields. + /// + /// Can switch to [dial] by activating a mode button in the dialog. + input, + + /// User can only pick time from a clock dial. + /// + /// There is no user interface to switch to another mode. + dialOnly, + + /// User can only input the time by typing it into text fields. + /// + /// There is no user interface to switch to another mode. + inputOnly, +} + +// Whether the dial-mode time picker is currently selecting the hour or the +// minute. +enum _HourMinuteMode { hour, minute } + +// Aspects of _TimePickerModel that can be depended upon. +enum _TimePickerAspect { + use24HourFormat, + useMaterial3, + entryMode, + hourMinuteMode, + onHourMinuteModeChanged, + onHourDoubleTapped, + onMinuteDoubleTapped, + hourDialType, + selectedTime, + onSelectedTimeChanged, + orientation, + theme, + defaultTheme, +} + +class _TimePickerModel extends InheritedModel<_TimePickerAspect> { + const _TimePickerModel({ + required this.entryMode, + required this.hourMinuteMode, + required this.onHourMinuteModeChanged, + required this.onHourDoubleTapped, + required this.onMinuteDoubleTapped, + required this.selectedTime, + required this.onSelectedTimeChanged, + required this.use24HourFormat, + required this.useMaterial3, + required this.hourDialType, + required this.orientation, + required this.theme, + required this.defaultTheme, + required super.child, + }); + + final TimePickerEntryMode entryMode; + final _HourMinuteMode hourMinuteMode; + final ValueChanged<_HourMinuteMode> onHourMinuteModeChanged; + final GestureTapCallback onHourDoubleTapped; + final GestureTapCallback onMinuteDoubleTapped; + final TimeOfDay selectedTime; + final ValueChanged<TimeOfDay> onSelectedTimeChanged; + final bool use24HourFormat; + final bool useMaterial3; + final _HourDialType hourDialType; + final Orientation orientation; + final TimePickerThemeData theme; + final _TimePickerDefaults defaultTheme; + + static _TimePickerModel of(BuildContext context, [_TimePickerAspect? aspect]) => + InheritedModel.inheritFrom<_TimePickerModel>(context, aspect: aspect)!; + static TimePickerEntryMode entryModeOf(BuildContext context) => + of(context, _TimePickerAspect.entryMode).entryMode; + static _HourMinuteMode hourMinuteModeOf(BuildContext context) => + of(context, _TimePickerAspect.hourMinuteMode).hourMinuteMode; + static TimeOfDay selectedTimeOf(BuildContext context) => + of(context, _TimePickerAspect.selectedTime).selectedTime; + static bool use24HourFormatOf(BuildContext context) => + of(context, _TimePickerAspect.use24HourFormat).use24HourFormat; + static bool useMaterial3Of(BuildContext context) => + of(context, _TimePickerAspect.useMaterial3).useMaterial3; + static _HourDialType hourDialTypeOf(BuildContext context) => + of(context, _TimePickerAspect.hourDialType).hourDialType; + static Orientation orientationOf(BuildContext context) => + of(context, _TimePickerAspect.orientation).orientation; + static TimePickerThemeData themeOf(BuildContext context) => + of(context, _TimePickerAspect.theme).theme; + static _TimePickerDefaults defaultThemeOf(BuildContext context) => + of(context, _TimePickerAspect.defaultTheme).defaultTheme; + + static void setSelectedTime(BuildContext context, TimeOfDay value) => + of(context, _TimePickerAspect.onSelectedTimeChanged).onSelectedTimeChanged(value); + static void setHourMinuteMode(BuildContext context, _HourMinuteMode value) => + of(context, _TimePickerAspect.onHourMinuteModeChanged).onHourMinuteModeChanged(value); + + @override + bool updateShouldNotifyDependent( + _TimePickerModel oldWidget, + Set<_TimePickerAspect> dependencies, + ) { + if (use24HourFormat != oldWidget.use24HourFormat && + dependencies.contains(_TimePickerAspect.use24HourFormat)) { + return true; + } + if (useMaterial3 != oldWidget.useMaterial3 && + dependencies.contains(_TimePickerAspect.useMaterial3)) { + return true; + } + if (entryMode != oldWidget.entryMode && dependencies.contains(_TimePickerAspect.entryMode)) { + return true; + } + if (hourMinuteMode != oldWidget.hourMinuteMode && + dependencies.contains(_TimePickerAspect.hourMinuteMode)) { + return true; + } + if (onHourMinuteModeChanged != oldWidget.onHourMinuteModeChanged && + dependencies.contains(_TimePickerAspect.onHourMinuteModeChanged)) { + return true; + } + if (onHourMinuteModeChanged != oldWidget.onHourDoubleTapped && + dependencies.contains(_TimePickerAspect.onHourDoubleTapped)) { + return true; + } + if (onHourMinuteModeChanged != oldWidget.onMinuteDoubleTapped && + dependencies.contains(_TimePickerAspect.onMinuteDoubleTapped)) { + return true; + } + if (hourDialType != oldWidget.hourDialType && + dependencies.contains(_TimePickerAspect.hourDialType)) { + return true; + } + if (selectedTime != oldWidget.selectedTime && + dependencies.contains(_TimePickerAspect.selectedTime)) { + return true; + } + if (onSelectedTimeChanged != oldWidget.onSelectedTimeChanged && + dependencies.contains(_TimePickerAspect.onSelectedTimeChanged)) { + return true; + } + if (orientation != oldWidget.orientation && + dependencies.contains(_TimePickerAspect.orientation)) { + return true; + } + if (theme != oldWidget.theme && dependencies.contains(_TimePickerAspect.theme)) { + return true; + } + if (defaultTheme != oldWidget.defaultTheme && + dependencies.contains(_TimePickerAspect.defaultTheme)) { + return true; + } + return false; + } + + @override + bool updateShouldNotify(_TimePickerModel oldWidget) { + return use24HourFormat != oldWidget.use24HourFormat || + useMaterial3 != oldWidget.useMaterial3 || + entryMode != oldWidget.entryMode || + hourMinuteMode != oldWidget.hourMinuteMode || + onHourMinuteModeChanged != oldWidget.onHourMinuteModeChanged || + onHourDoubleTapped != oldWidget.onHourDoubleTapped || + onMinuteDoubleTapped != oldWidget.onMinuteDoubleTapped || + hourDialType != oldWidget.hourDialType || + selectedTime != oldWidget.selectedTime || + onSelectedTimeChanged != oldWidget.onSelectedTimeChanged || + orientation != oldWidget.orientation || + theme != oldWidget.theme || + defaultTheme != oldWidget.defaultTheme; + } +} + +/// The header for the time picker in dial mode. +class _DialTimePickerHeader extends StatelessWidget { + const _DialTimePickerHeader({required this.helpText}); + + final String helpText; + + @override + Widget build(BuildContext context) { + assert(_debugDialTimePickerEntryMode(context)); + final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of( + context, + ).timeOfDayFormat(alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context)); + + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final Orientation orientation = _TimePickerModel.orientationOf(context); + final double dayPeriodHeight = orientation == Orientation.portrait + ? defaultTheme.dayPeriodPortraitSize.height + : defaultTheme.dayPeriodLandscapeSize.height; + final double minInteractiveVerticalPadding = orientation == Orientation.portrait + ? math.max(0, 2 * kMinInteractiveDimension - dayPeriodHeight) + : math.max(0, kMinInteractiveDimension - dayPeriodHeight); + + final _HourDialType hourDialType = _TimePickerModel.hourDialTypeOf(context); + final RenderObjectWidget orientationSpecificHeader = switch (orientation) { + Orientation.portrait => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Padding( + padding: EdgeInsetsDirectional.only( + bottom: + (_TimePickerModel.useMaterial3Of(context) ? 20 : 24) - + minInteractiveVerticalPadding / 2, + ), + child: Text( + helpText, + style: _TimePickerModel.themeOf(context).helpTextStyle ?? defaultTheme.helpTextStyle, + ), + ), + Row( + textDirection: timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm + ? TextDirection.rtl + : TextDirection.ltr, + spacing: 12, + children: <Widget>[ + Expanded( + child: Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: <Widget>[ + const Expanded(child: _DialHourControl()), + _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), + const Expanded(child: _DialMinuteControl()), + ], + ), + ), + if (hourDialType == _HourDialType.twelveHour) const _DayPeriodControl(), + ], + ), + ], + ), + Orientation.landscape => SizedBox( + width: _kTimePickerHeaderLandscapeWidth, + child: Stack( + children: <Widget>[ + Text( + helpText, + style: _TimePickerModel.themeOf(context).helpTextStyle ?? defaultTheme.helpTextStyle, + ), + Column( + verticalDirection: timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm + ? VerticalDirection.up + : VerticalDirection.down, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: math.max(0, 16 - minInteractiveVerticalPadding / 2), + children: <Widget>[ + Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: <Widget>[ + const Expanded(child: _DialHourControl()), + _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), + const Expanded(child: _DialMinuteControl()), + ], + ), + if (hourDialType == _HourDialType.twelveHour) const _DayPeriodControl(), + ], + ), + ], + ), + ), + }; + + return Semantics( + label: MaterialLocalizations.of(context).formatTimeOfDay( + _TimePickerModel.selectedTimeOf(context), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ), + child: orientationSpecificHeader, + ); + } +} + +/// The control label for the time selector in dial mode. +class _DialTimeSelectorControl extends StatelessWidget { + const _DialTimeSelectorControl({ + required this.text, + required this.onTap, + required this.onDoubleTap, + required this.isSelected, + }); + + final String text; + final GestureTapCallback onTap; + final GestureTapCallback onDoubleTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + assert(_debugDialTimePickerEntryMode(context)); + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final Color backgroundColor = timePickerTheme.hourMinuteColor ?? defaultTheme.hourMinuteColor; + final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? defaultTheme.hourMinuteShape; + + final states = <WidgetState>{if (isSelected) WidgetState.selected}; + final Color effectiveTextColor = WidgetStateProperty.resolveAs<Color>( + _TimePickerModel.themeOf(context).hourMinuteTextColor ?? + _TimePickerModel.defaultThemeOf(context).hourMinuteTextColor, + states, + ); + final TextStyle effectiveStyle = WidgetStateProperty.resolveAs<TextStyle>( + timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle, + states, + ).copyWith(color: effectiveTextColor); + + return SizedBox( + height: defaultTheme.hourMinuteSize.height, + child: Material( + color: WidgetStateProperty.resolveAs(backgroundColor, states), + clipBehavior: Clip.antiAlias, + shape: shape, + child: InkWell( + onTap: onTap, + onDoubleTap: isSelected ? onDoubleTap : null, + child: Center( + child: Text(text, style: effectiveStyle, textScaler: TextScaler.noScaling), + ), + ), + ), + ); + } +} + +/// Displays the hour fragment in dial mode. +/// +/// When tapped changes time picker dial mode to [_HourMinuteMode.hour]. +class _DialHourControl extends StatelessWidget { + const _DialHourControl(); + + @override + Widget build(BuildContext context) { + assert(_debugDialTimePickerEntryMode(context)); + assert(debugCheckHasMediaQuery(context)); + final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String formattedHour = localizations.formatHour( + selectedTime, + alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context), + ); + + TimeOfDay hoursFromSelected(int hoursToAdd) { + switch (_TimePickerModel.hourDialTypeOf(context)) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + final int selectedHour = selectedTime.hour; + return selectedTime.replacing(hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay); + case _HourDialType.twelveHour: + // Cycle 1 through 12 without changing day period. + final int periodOffset = selectedTime.periodOffset; + final int hours = selectedTime.hourOfPeriod; + return selectedTime.replacing( + hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod, + ); + } + } + + final TimeOfDay nextHour = hoursFromSelected(1); + final String formattedNextHour = localizations.formatHour( + nextHour, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + final TimeOfDay previousHour = hoursFromSelected(-1); + final String formattedPreviousHour = localizations.formatHour( + previousHour, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + + return Semantics( + value: '${localizations.timePickerHourModeAnnouncement} $formattedHour', + excludeSemantics: true, + increasedValue: formattedNextHour, + onIncrease: () { + _TimePickerModel.setSelectedTime(context, nextHour); + }, + decreasedValue: formattedPreviousHour, + onDecrease: () { + _TimePickerModel.setSelectedTime(context, previousHour); + }, + child: _DialTimeSelectorControl( + isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.hour, + text: formattedHour, + onTap: () => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.hour), + onDoubleTap: _TimePickerModel.of( + context, + _TimePickerAspect.onHourDoubleTapped, + ).onHourDoubleTapped, + ), + ); + } +} + +/// A passive fragment showing a string value. +/// +/// Used to display the appropriate separator between the input fields. +class _TimeSelectorSeparator extends StatelessWidget { + const _TimeSelectorSeparator({required this.timeOfDayFormat}); + + final TimeOfDayFormat timeOfDayFormat; + + String _timeSelectorSeparatorValue(TimeOfDayFormat timeOfDayFormat) => switch (timeOfDayFormat) { + TimeOfDayFormat.h_colon_mm_space_a || + TimeOfDayFormat.a_space_h_colon_mm || + TimeOfDayFormat.H_colon_mm || + TimeOfDayFormat.HH_colon_mm => ':', + TimeOfDayFormat.HH_dot_mm => '.', + TimeOfDayFormat.frenchCanadian => 'h', + }; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 + ? _TimePickerDefaultsM3(context) + : _TimePickerDefaultsM2(context); + final states = <WidgetState>{}; + + final Color effectiveTextColor = WidgetStateProperty.resolveAs<Color>( + timePickerTheme.timeSelectorSeparatorColor?.resolve(states) ?? + timePickerTheme.hourMinuteTextColor ?? + defaultTheme.timeSelectorSeparatorColor?.resolve(states) ?? + defaultTheme.hourMinuteTextColor, + states, + ); + final TextStyle effectiveStyle = WidgetStateProperty.resolveAs<TextStyle>( + timePickerTheme.timeSelectorSeparatorTextStyle?.resolve(states) ?? + timePickerTheme.hourMinuteTextStyle ?? + defaultTheme.timeSelectorSeparatorTextStyle?.resolve(states) ?? + defaultTheme.hourMinuteTextStyle, + states, + ).copyWith(color: effectiveTextColor, height: 1.0); + + final double height = switch (_TimePickerModel.entryModeOf(context)) { + TimePickerEntryMode.dial || + TimePickerEntryMode.dialOnly => defaultTheme.hourMinuteSize.height, + TimePickerEntryMode.input || + TimePickerEntryMode.inputOnly => defaultTheme.hourMinuteInputSize.height, + }; + + return ExcludeSemantics( + child: SizedBox( + width: timeOfDayFormat == TimeOfDayFormat.frenchCanadian ? 36 : 24, + height: height, + child: Center( + child: Text( + _timeSelectorSeparatorValue(timeOfDayFormat), + style: effectiveStyle, + textScaler: TextScaler.noScaling, + ), + ), + ), + ); + } +} + +/// Displays the minute fragment in dial mode. +/// +/// When tapped changes time picker dial mode to [_HourMinuteMode.minute]. +class _DialMinuteControl extends StatelessWidget { + const _DialMinuteControl(); + + @override + Widget build(BuildContext context) { + assert(_debugDialTimePickerEntryMode(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + final String formattedMinute = localizations.formatMinute(selectedTime); + final TimeOfDay nextMinute = selectedTime.replacing( + minute: (selectedTime.minute + 1) % TimeOfDay.minutesPerHour, + ); + final String formattedNextMinute = localizations.formatMinute(nextMinute); + final TimeOfDay previousMinute = selectedTime.replacing( + minute: (selectedTime.minute - 1) % TimeOfDay.minutesPerHour, + ); + final String formattedPreviousMinute = localizations.formatMinute(previousMinute); + + return Semantics( + excludeSemantics: true, + value: '${localizations.timePickerMinuteModeAnnouncement} $formattedMinute', + increasedValue: formattedNextMinute, + onIncrease: () { + _TimePickerModel.setSelectedTime(context, nextMinute); + }, + decreasedValue: formattedPreviousMinute, + onDecrease: () { + _TimePickerModel.setSelectedTime(context, previousMinute); + }, + child: _DialTimeSelectorControl( + isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.minute, + text: formattedMinute, + onTap: () => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.minute), + onDoubleTap: _TimePickerModel.of( + context, + _TimePickerAspect.onMinuteDoubleTapped, + ).onMinuteDoubleTapped, + ), + ); + } +} + +/// Displays the am/pm fragment and provides controls for switching between am +/// and pm. +class _DayPeriodControl extends StatelessWidget { + const _DayPeriodControl({this.onPeriodChanged}); + + final ValueChanged<TimeOfDay>? onPeriodChanged; + + void _togglePeriod(BuildContext context) { + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; + final TimeOfDay newTime = selectedTime.replacing(hour: newHour); + if (onPeriodChanged != null) { + onPeriodChanged!(newTime); + } else { + _TimePickerModel.setSelectedTime(context, newTime); + } + } + + void _setAm(BuildContext context) { + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + if (selectedTime.period == DayPeriod.am) { + return; + } + _togglePeriod(context); + } + + void _setPm(BuildContext context) { + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + if (selectedTime.period == DayPeriod.pm) { + return; + } + _togglePeriod(context); + } + + @override + Widget build(BuildContext context) { + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + final amSelected = selectedTime.period == DayPeriod.am; + final bool pmSelected = !amSelected; + final BorderSide resolvedSide = + timePickerTheme.dayPeriodBorderSide ?? defaultTheme.dayPeriodBorderSide; + final OutlinedBorder resolvedShape = + (timePickerTheme.dayPeriodShape ?? defaultTheme.dayPeriodShape).copyWith( + side: resolvedSide, + ); + + Size dayPeriodSize; + final Orientation orientation; + switch (_TimePickerModel.entryModeOf(context)) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + orientation = _TimePickerModel.orientationOf(context); + dayPeriodSize = switch (orientation) { + Orientation.portrait => defaultTheme.dayPeriodPortraitSize, + Orientation.landscape => defaultTheme.dayPeriodLandscapeSize, + }; + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + orientation = Orientation.portrait; + dayPeriodSize = defaultTheme.dayPeriodInputSize; + } + + var amShape = resolvedShape; + var pmShape = resolvedShape; + final bool hasRoundedBorder = + resolvedShape is RoundedRectangleBorder && resolvedShape.borderRadius is BorderRadius; + + // In order to respect Material touch target guidelines, the Semantics for + // AM and PM buttons needs to expand out of the bounds of the buttons + // (Similarly to Google Agenda). + // To achieve this, instead of using a parent Material which clips + // the Semantics, each button should manage its own shape. + // The logic below "cuts" the given period selector shape in two parts, + // one for the AM button, the other for the PM button. Each sub-shape + // is obtained by removing some rounded corners from the original shape. + switch (orientation) { + case Orientation.portrait: + if (hasRoundedBorder) { + final borderRadius = resolvedShape.borderRadius as BorderRadius; + amShape = resolvedShape.copyWith( + borderRadius: BorderRadius.only( + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + ), + ); + pmShape = resolvedShape.copyWith( + borderRadius: BorderRadius.only( + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight, + ), + ); + } + + final minInteractiveSize = Size( + dayPeriodSize.width, + math.max(dayPeriodSize.height, 2 * kMinInteractiveDimension), + ); + + final Widget amButton = _AmPmButton( + selected: amSelected, + onPressed: () => _setAm(context), + label: materialLocalizations.anteMeridiemAbbreviation, + padding: EdgeInsets.only(top: (minInteractiveSize.height - dayPeriodSize.height) / 2), + shape: amShape, + ); + + final Widget pmButton = _AmPmButton( + selected: pmSelected, + onPressed: () => _setPm(context), + label: materialLocalizations.postMeridiemAbbreviation, + padding: EdgeInsets.only(bottom: (minInteractiveSize.height - dayPeriodSize.height) / 2), + shape: pmShape, + ); + + return _DayPeriodInputPadding( + minSize: minInteractiveSize, + orientation: orientation, + child: SizedBox.fromSize( + size: minInteractiveSize, + child: Column( + children: <Widget>[ + Expanded(child: amButton), + Expanded(child: pmButton), + ], + ), + ), + ); + case Orientation.landscape: + if (hasRoundedBorder) { + final borderRadius = resolvedShape.borderRadius as BorderRadius; + amShape = resolvedShape.copyWith( + borderRadius: BorderRadius.only( + topLeft: borderRadius.topLeft, + bottomLeft: borderRadius.bottomLeft, + ), + ); + pmShape = resolvedShape.copyWith( + borderRadius: BorderRadius.only( + topRight: borderRadius.topRight, + bottomRight: borderRadius.bottomRight, + ), + ); + } + + final minInteractiveSize = Size( + dayPeriodSize.width, + math.max(dayPeriodSize.height, kMinInteractiveDimension), + ); + + final Widget amButton = _AmPmButton( + selected: amSelected, + onPressed: () => _setAm(context), + label: materialLocalizations.anteMeridiemAbbreviation, + padding: EdgeInsets.symmetric( + vertical: (minInteractiveSize.height - dayPeriodSize.height) / 2, + ), + shape: amShape, + ); + + final Widget pmButton = _AmPmButton( + selected: pmSelected, + onPressed: () => _setPm(context), + label: materialLocalizations.postMeridiemAbbreviation, + padding: EdgeInsets.symmetric( + vertical: (minInteractiveSize.height - dayPeriodSize.height) / 2, + ), + shape: pmShape, + ); + + return _DayPeriodInputPadding( + minSize: minInteractiveSize, + orientation: orientation, + child: SizedBox( + height: minInteractiveSize.height, + child: Row( + children: <Widget>[ + Expanded(child: amButton), + Expanded(child: pmButton), + ], + ), + ), + ); + } + } +} + +class _AmPmButton extends StatelessWidget { + const _AmPmButton({ + required this.onPressed, + required this.selected, + required this.label, + required this.padding, + required this.shape, + }); + + final bool selected; + final VoidCallback onPressed; + final String label; + final EdgeInsets padding; + final OutlinedBorder shape; + + @override + Widget build(BuildContext context) { + final states = <WidgetState>{if (selected) WidgetState.selected}; + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final Color resolvedBackgroundColor = WidgetStateProperty.resolveAs<Color>( + timePickerTheme.dayPeriodColor ?? defaultTheme.dayPeriodColor, + states, + ); + final Color resolvedTextColor = WidgetStateProperty.resolveAs<Color>( + timePickerTheme.dayPeriodTextColor ?? defaultTheme.dayPeriodTextColor, + states, + ); + final TextStyle? resolvedTextStyle = WidgetStateProperty.resolveAs<TextStyle?>( + timePickerTheme.dayPeriodTextStyle ?? defaultTheme.dayPeriodTextStyle, + states, + )?.copyWith(color: resolvedTextColor); + final TextScaler buttonTextScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2.0); + + return Semantics( + // Platform-specific semantics vary slightly here on iOS. + selected: defaultTargetPlatform == TargetPlatform.iOS ? selected : null, + checked: defaultTargetPlatform == TargetPlatform.iOS ? null : selected, + inMutuallyExclusiveGroup: true, + button: true, + child: Padding( + padding: padding, + child: Material( + clipBehavior: Clip.antiAlias, + color: resolvedBackgroundColor, + shape: shape, + child: InkWell( + onTap: onPressed, + child: Center( + child: Text(label, style: resolvedTextStyle, textScaler: buttonTextScaler), + ), + ), + ), + ), + ); + } +} + +/// A widget to pad the area around the [_DayPeriodControl]'s inner [Material]. +class _DayPeriodInputPadding extends SingleChildRenderObjectWidget { + const _DayPeriodInputPadding({ + required Widget super.child, + required this.minSize, + required this.orientation, + }); + + final Size minSize; + final Orientation orientation; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize, orientation); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject + ..minSize = minSize + ..orientation = orientation; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, this._orientation, [RenderBox? child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + Orientation get orientation => _orientation; + Orientation _orientation; + set orientation(Orientation value) { + if (_orientation == value) { + return; + } + _orientation = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + } + return 0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + } + return 0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + } + return 0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + } + return 0; + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double width = math.max(childSize.width, minSize.width); + final double height = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(width, height)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.dryLayoutChild); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final double? result = child.getDryBaseline(constraints, baseline); + if (result == null) { + return null; + } + // Calculate the size and child offset using the same logic as performLayout + final Size drySize = getDryLayout(constraints); + final Size childSize = child.getDryLayout(constraints); + final Offset childOffset = Alignment.center.alongOffset(drySize - childSize as Offset); + return result + childOffset.dy; + } + + @override + void performLayout() { + size = _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.layoutChild); + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (super.hitTest(result, position: position)) { + return true; + } + + if (position.dx < 0 || + position.dx > math.max(child!.size.width, minSize.width) || + position.dy < 0 || + position.dy > math.max(child!.size.height, minSize.height)) { + return false; + } + + Offset newPosition = child!.size.center(Offset.zero); + newPosition += switch (orientation) { + Orientation.portrait when position.dy > newPosition.dy => const Offset(0, 1), + Orientation.landscape when position.dx > newPosition.dx => const Offset(1, 0), + Orientation.portrait => const Offset(0, -1), + Orientation.landscape => const Offset(-1, 0), + }; + + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(newPosition), + position: newPosition, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == newPosition); + return child!.hitTest(result, position: newPosition); + }, + ); + } +} + +class _TappableLabel { + _TappableLabel({ + required this.value, + required this.inner, + required this.painter, + required this.onTap, + }); + + /// The value this label is displaying. + final int value; + + /// This value is part of the "inner" ring of values on the dial, used for 24 + /// hour input. + final bool inner; + + /// Paints the text of the label. + final TextPainter painter; + + /// Called when a tap gesture is detected on the label. + final VoidCallback onTap; +} + +class _DialPainter extends CustomPainter { + _DialPainter({ + required this.primaryLabels, + required this.selectedLabels, + required this.backgroundColor, + required this.handColor, + required this.handWidth, + required this.dotColor, + required this.dotRadius, + required this.centerRadius, + required this.theta, + required this.radius, + required this.textDirection, + required this.selectedValue, + }) : super(repaint: PaintingBinding.instance.systemFonts) { + assert(debugMaybeDispatchCreated('material', '_DialPainter', this)); + } + + final List<_TappableLabel> primaryLabels; + final List<_TappableLabel> selectedLabels; + final Color backgroundColor; + final Color handColor; + final double handWidth; + final Color dotColor; + final double dotRadius; + final double centerRadius; + final double theta; + final double radius; + final TextDirection textDirection; + final int selectedValue; + + void dispose() { + assert(debugMaybeDispatchDisposed(this)); + for (final _TappableLabel label in primaryLabels) { + label.painter.dispose(); + } + for (final _TappableLabel label in selectedLabels) { + label.painter.dispose(); + } + primaryLabels.clear(); + selectedLabels.clear(); + } + + @override + void paint(Canvas canvas, Size size) { + final double dialRadius = clampDouble( + size.shortestSide / 2, + _kTimePickerDialMinRadius + dotRadius, + double.infinity, + ); + final double labelRadius = clampDouble( + dialRadius - _kTimePickerDialPadding, + _kTimePickerDialMinRadius, + double.infinity, + ); + final double innerLabelRadius = clampDouble( + labelRadius - _kTimePickerInnerDialOffset, + 0, + double.infinity, + ); + final double handleRadius = clampDouble( + labelRadius - (radius < 0.5 ? 1 : 0) * (labelRadius - innerLabelRadius), + _kTimePickerDialMinRadius, + double.infinity, + ); + final center = Offset(size.width / 2, size.height / 2); + final centerPoint = center; + canvas.drawCircle(centerPoint, dialRadius, Paint()..color = backgroundColor); + + Offset getOffsetForTheta(double theta, double radius) { + return center + Offset(radius * math.cos(theta), -radius * math.sin(theta)); + } + + void paintLabels(List<_TappableLabel> labels, double radius) { + if (labels.isEmpty) { + return; + } + final double labelThetaIncrement = -_kTwoPi / labels.length; + double labelTheta = math.pi / 2; + + for (final label in labels) { + final TextPainter labelPainter = label.painter; + final labelOffset = Offset(-labelPainter.width / 2, -labelPainter.height / 2); + labelPainter.paint(canvas, getOffsetForTheta(labelTheta, radius) + labelOffset); + labelTheta += labelThetaIncrement; + } + } + + void paintInnerOuterLabels(List<_TappableLabel>? labels) { + if (labels == null) { + return; + } + + paintLabels(labels.where((_TappableLabel label) => !label.inner).toList(), labelRadius); + paintLabels(labels.where((_TappableLabel label) => label.inner).toList(), innerLabelRadius); + } + + paintInnerOuterLabels(primaryLabels); + + final selectorPaint = Paint()..color = handColor; + final Offset focusedPoint = getOffsetForTheta(theta, handleRadius); + canvas.drawCircle(centerPoint, centerRadius, selectorPaint); + canvas.drawCircle(focusedPoint, dotRadius, selectorPaint); + selectorPaint.strokeWidth = handWidth; + canvas.drawLine(centerPoint, focusedPoint, selectorPaint); + + // Add a dot inside the selector but only when it isn't over the labels. + // This checks that the selector's theta is between two labels. A remainder + // between 0.1 and 0.45 indicates that the selector is roughly not above any + // labels. The values were derived by manually testing the dial. + final double labelThetaIncrement = -_kTwoPi / primaryLabels.length; + if (theta % labelThetaIncrement > 0.1 && theta % labelThetaIncrement < 0.45) { + canvas.drawCircle(focusedPoint, 2, selectorPaint..color = dotColor); + } + + final focusedRect = Rect.fromCircle(center: focusedPoint, radius: dotRadius); + canvas + ..save() + ..clipPath(Path()..addOval(focusedRect)); + paintInnerOuterLabels(selectedLabels); + canvas.restore(); + } + + @override + bool shouldRepaint(_DialPainter oldPainter) { + return oldPainter.primaryLabels != primaryLabels || + oldPainter.selectedLabels != selectedLabels || + oldPainter.backgroundColor != backgroundColor || + oldPainter.handColor != handColor || + oldPainter.theta != theta; + } +} + +// Which kind of hour dial being presented. +enum _HourDialType { twentyFourHour, twentyFourHourDoubleRing, twelveHour } + +class _Dial extends StatefulWidget { + const _Dial({ + required this.selectedTime, + required this.hourMinuteMode, + required this.hourDialType, + required this.onChanged, + required this.onHourSelected, + }); + + final TimeOfDay selectedTime; + final _HourMinuteMode hourMinuteMode; + final _HourDialType hourDialType; + final ValueChanged<TimeOfDay>? onChanged; + final VoidCallback? onHourSelected; + + @override + _DialState createState() => _DialState(); +} + +class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { + late ThemeData themeData; + late MaterialLocalizations localizations; + _DialPainter? painter; + late AnimationController _animationController; + late Tween<double> _thetaTween; + late Animation<double> _theta; + late Tween<double> _radiusTween; + late Animation<double> _radius; + bool _dragging = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController(duration: _kDialAnimateDuration, vsync: this); + _thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime)); + _radiusTween = Tween<double>(begin: _getRadiusForTime(widget.selectedTime)); + _theta = _animationController.drive(CurveTween(curve: standardEasing)).drive(_thetaTween) + ..addListener( + () => setState(() { + /* _theta.value has changed */ + }), + ); + _radius = _animationController.drive(CurveTween(curve: standardEasing)).drive(_radiusTween) + ..addListener( + () => setState(() { + /* _radius.value has changed */ + }), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMediaQuery(context)); + themeData = Theme.of(context); + localizations = MaterialLocalizations.of(context); + } + + @override + void didUpdateWidget(_Dial oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.hourMinuteMode != oldWidget.hourMinuteMode || + widget.selectedTime != oldWidget.selectedTime) { + if (!_dragging) { + _animateTo(_getThetaForTime(widget.selectedTime), _getRadiusForTime(widget.selectedTime)); + } + } + } + + @override + void dispose() { + _animationController.dispose(); + painter?.dispose(); + super.dispose(); + } + + static double _nearest(double target, double a, double b) { + return ((target - a).abs() < (target - b).abs()) ? a : b; + } + + void _animateTo(double targetTheta, double targetRadius) { + void animateToValue({ + required double target, + required Animation<double> animation, + required Tween<double> tween, + required AnimationController controller, + required double min, + required double max, + }) { + double beginValue = _nearest(target, animation.value, max); + beginValue = _nearest(target, beginValue, min); + tween + ..begin = beginValue + ..end = target; + controller + ..value = 0 + ..forward(); + } + + animateToValue( + target: targetTheta, + animation: _theta, + tween: _thetaTween, + controller: _animationController, + min: _theta.value - _kTwoPi, + max: _theta.value + _kTwoPi, + ); + animateToValue( + target: targetRadius, + animation: _radius, + tween: _radiusTween, + controller: _animationController, + min: 0, + max: 1, + ); + } + + double _getRadiusForTime(TimeOfDay time) { + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + return switch (widget.hourDialType) { + _HourDialType.twentyFourHourDoubleRing => time.hour >= 12 ? 0 : 1, + _HourDialType.twentyFourHour || _HourDialType.twelveHour => 1, + }; + case _HourMinuteMode.minute: + return 1; + } + } + + double _getThetaForTime(TimeOfDay time) { + final int hoursFactor = switch (widget.hourDialType) { + _HourDialType.twentyFourHour => TimeOfDay.hoursPerDay, + _HourDialType.twentyFourHourDoubleRing => TimeOfDay.hoursPerPeriod, + _HourDialType.twelveHour => TimeOfDay.hoursPerPeriod, + }; + final double fraction = switch (widget.hourMinuteMode) { + _HourMinuteMode.hour => (time.hour / hoursFactor) % hoursFactor, + _HourMinuteMode.minute => (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour, + }; + return (math.pi / 2 - fraction * _kTwoPi) % _kTwoPi; + } + + TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false, required double radius}) { + final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1; + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + int newHour; + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay; + case _HourDialType.twentyFourHourDoubleRing: + newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; + if (radius < 0.5) { + newHour = newHour + TimeOfDay.hoursPerPeriod; + } + case _HourDialType.twelveHour: + newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; + newHour = newHour + widget.selectedTime.periodOffset; + } + return widget.selectedTime.replacing(hour: newHour); + case _HourMinuteMode.minute: + int minute = (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour; + if (roundMinutes) { + // Round the minutes to nearest 5 minute interval. + minute = ((minute + 2) ~/ 5) * 5 % TimeOfDay.minutesPerHour; + } + return widget.selectedTime.replacing(minute: minute); + } + } + + TimeOfDay _notifyOnChangedIfNeeded({bool roundMinutes = false}) { + final TimeOfDay current = _getTimeForTheta( + _theta.value, + roundMinutes: roundMinutes, + radius: _radius.value, + ); + if (widget.onChanged == null) { + return current; + } + if (current != widget.selectedTime) { + widget.onChanged!(current); + } + return current; + } + + void _updateThetaForPan({bool roundMinutes = false}) { + setState(() { + final Offset offset = _position! - _center!; + final double labelRadius = _dialSize!.shortestSide / 2 - _kTimePickerDialPadding; + final double innerRadius = labelRadius - _kTimePickerInnerDialOffset; + double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2) % _kTwoPi; + final double radius = clampDouble( + (offset.distance - innerRadius) / _kTimePickerInnerDialOffset, + 0, + 1, + ); + if (roundMinutes) { + angle = _getThetaForTime( + _getTimeForTheta(angle, roundMinutes: roundMinutes, radius: radius), + ); + } + // The controller doesn't animate during the pan gesture. + _thetaTween + ..begin = angle + ..end = angle; + _radiusTween + ..begin = radius + ..end = radius; + }); + } + + Offset? _position; + Offset? _center; + Size? _dialSize; + + void _handlePanStart(DragStartDetails details) { + assert(!_dragging); + _dragging = true; + final box = context.findRenderObject()! as RenderBox; + _position = box.globalToLocal(details.globalPosition); + _dialSize = box.size; + _center = _dialSize!.center(Offset.zero); + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanUpdate(DragUpdateDetails details) { + _position = _position! + details.delta; + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanEnd(DragEndDetails details) { + assert(_dragging); + _dragging = false; + _position = null; + _center = null; + _dialSize = null; + _animateTo(_getThetaForTime(widget.selectedTime), _getRadiusForTime(widget.selectedTime)); + if (widget.hourMinuteMode == _HourMinuteMode.hour) { + widget.onHourSelected?.call(); + } + } + + void _handleTapUp(TapUpDetails details) { + final box = context.findRenderObject()! as RenderBox; + _position = box.globalToLocal(details.globalPosition); + _center = box.size.center(Offset.zero); + _dialSize = box.size; + _updateThetaForPan(roundMinutes: true); + _notifyOnChangedIfNeeded(roundMinutes: true); + if (widget.hourMinuteMode == _HourMinuteMode.hour) { + widget.onHourSelected?.call(); + } + final TimeOfDay time = _getTimeForTheta( + _theta.value, + roundMinutes: true, + radius: _radius.value, + ); + _animateTo(_getThetaForTime(time), _getRadiusForTime(time)); + _dragging = false; + _position = null; + _center = null; + _dialSize = null; + } + + void _selectHour(int hour) { + final TimeOfDay time; + + TimeOfDay getAmPmTime() { + return switch (widget.selectedTime.period) { + DayPeriod.am => TimeOfDay(hour: hour, minute: widget.selectedTime.minute), + DayPeriod.pm => TimeOfDay( + hour: hour + TimeOfDay.hoursPerPeriod, + minute: widget.selectedTime.minute, + ), + }; + } + + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); + case _HourDialType.twelveHour: + time = getAmPmTime(); + } + case _HourMinuteMode.minute: + time = getAmPmTime(); + } + final double angle = _getThetaForTime(time); + _thetaTween + ..begin = angle + ..end = angle; + _notifyOnChangedIfNeeded(); + } + + void _selectMinute(int minute) { + final time = TimeOfDay(hour: widget.selectedTime.hour, minute: minute); + final double angle = _getThetaForTime(time); + _thetaTween + ..begin = angle + ..end = angle; + _notifyOnChangedIfNeeded(); + } + + static const List<TimeOfDay> _amHours = <TimeOfDay>[ + TimeOfDay(hour: 12, minute: 0), + TimeOfDay(hour: 1, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 3, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 5, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 7, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 9, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 11, minute: 0), + ]; + + // On M2, there's no inner ring of numbers. + static const List<TimeOfDay> _twentyFourHoursM2 = <TimeOfDay>[ + TimeOfDay(hour: 0, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 12, minute: 0), + TimeOfDay(hour: 14, minute: 0), + TimeOfDay(hour: 16, minute: 0), + TimeOfDay(hour: 18, minute: 0), + TimeOfDay(hour: 20, minute: 0), + TimeOfDay(hour: 22, minute: 0), + ]; + + static const List<TimeOfDay> _twentyFourHours = <TimeOfDay>[ + TimeOfDay(hour: 0, minute: 0), + TimeOfDay(hour: 1, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 3, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 5, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 7, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 9, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 11, minute: 0), + TimeOfDay(hour: 12, minute: 0), + TimeOfDay(hour: 13, minute: 0), + TimeOfDay(hour: 14, minute: 0), + TimeOfDay(hour: 15, minute: 0), + TimeOfDay(hour: 16, minute: 0), + TimeOfDay(hour: 17, minute: 0), + TimeOfDay(hour: 18, minute: 0), + TimeOfDay(hour: 19, minute: 0), + TimeOfDay(hour: 20, minute: 0), + TimeOfDay(hour: 21, minute: 0), + TimeOfDay(hour: 22, minute: 0), + TimeOfDay(hour: 23, minute: 0), + ]; + + _TappableLabel _buildTappableLabel({ + required TextStyle? textStyle, + required int selectedValue, + required int value, + required bool inner, + required String label, + required VoidCallback onTap, + }) { + return _TappableLabel( + value: value, + inner: inner, + painter: TextPainter( + text: TextSpan(style: textStyle, text: label), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2.0), + )..layout(), + onTap: onTap, + ); + } + + List<_TappableLabel> _build24HourRing({ + required TextStyle? textStyle, + required int selectedValue, + }) { + return <_TappableLabel>[ + if (themeData.useMaterial3) + for (final TimeOfDay timeOfDay in _twentyFourHours) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: timeOfDay.hour >= 12, + value: timeOfDay.hour, + label: + // The M3 specs for 24-hour ring show 0 hour as 00, but for 1-9, + // the specs show single digit. + timeOfDay.hour != 0 + ? localizations.formatDecimal(timeOfDay.hour) + : localizations.formatHour(timeOfDay, alwaysUse24HourFormat: true), + onTap: () { + _selectHour(timeOfDay.hour); + }, + ), + if (!themeData.useMaterial3) + for (final TimeOfDay timeOfDay in _twentyFourHoursM2) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: false, + value: timeOfDay.hour, + label: localizations.formatHour(timeOfDay, alwaysUse24HourFormat: true), + onTap: () { + _selectHour(timeOfDay.hour); + }, + ), + ]; + } + + List<_TappableLabel> _build12HourRing({ + required TextStyle? textStyle, + required int selectedValue, + }) { + return <_TappableLabel>[ + for (final TimeOfDay timeOfDay in _amHours) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: false, + value: timeOfDay.hour, + label: localizations.formatHour( + timeOfDay, + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ), + onTap: () { + _selectHour(timeOfDay.hour); + }, + ), + ]; + } + + List<_TappableLabel> _buildMinutes({required TextStyle? textStyle, required int selectedValue}) { + const minuteMarkerValues = <TimeOfDay>[ + TimeOfDay(hour: 0, minute: 0), + TimeOfDay(hour: 0, minute: 5), + TimeOfDay(hour: 0, minute: 10), + TimeOfDay(hour: 0, minute: 15), + TimeOfDay(hour: 0, minute: 20), + TimeOfDay(hour: 0, minute: 25), + TimeOfDay(hour: 0, minute: 30), + TimeOfDay(hour: 0, minute: 35), + TimeOfDay(hour: 0, minute: 40), + TimeOfDay(hour: 0, minute: 45), + TimeOfDay(hour: 0, minute: 50), + TimeOfDay(hour: 0, minute: 55), + ]; + + return <_TappableLabel>[ + for (final TimeOfDay timeOfDay in minuteMarkerValues) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: false, + value: timeOfDay.minute, + label: localizations.formatMinute(timeOfDay), + onTap: () { + _selectMinute(timeOfDay.minute); + }, + ), + ]; + } + + @override + Widget build(BuildContext context) { + assert(_debugDialTimePickerEntryMode(context)); + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 + ? _TimePickerDefaultsM3(context) + : _TimePickerDefaultsM2(context); + final Color backgroundColor = + timePickerTheme.dialBackgroundColor ?? defaultTheme.dialBackgroundColor; + final Color dialHandColor = timePickerTheme.dialHandColor ?? defaultTheme.dialHandColor; + final TextStyle labelStyle = timePickerTheme.dialTextStyle ?? defaultTheme.dialTextStyle; + final Color dialTextUnselectedColor = WidgetStateProperty.resolveAs<Color>( + timePickerTheme.dialTextColor ?? defaultTheme.dialTextColor, + <WidgetState>{}, + ); + final Color dialTextSelectedColor = WidgetStateProperty.resolveAs<Color>( + timePickerTheme.dialTextColor ?? defaultTheme.dialTextColor, + <WidgetState>{WidgetState.selected}, + ); + final TextStyle resolvedUnselectedLabelStyle = labelStyle.copyWith( + color: dialTextUnselectedColor, + ); + final TextStyle resolvedSelectedLabelStyle = labelStyle.copyWith(color: dialTextSelectedColor); + final dotColor = dialTextSelectedColor; + + List<_TappableLabel> primaryLabels; + List<_TappableLabel> selectedLabels; + final int selectedDialValue; + final double radiusValue; + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + selectedDialValue = widget.selectedTime.hour; + primaryLabels = _build24HourRing( + textStyle: resolvedUnselectedLabelStyle, + selectedValue: selectedDialValue, + ); + selectedLabels = _build24HourRing( + textStyle: resolvedSelectedLabelStyle, + selectedValue: selectedDialValue, + ); + radiusValue = theme.useMaterial3 ? _radius.value : 1; + case _HourDialType.twelveHour: + selectedDialValue = widget.selectedTime.hourOfPeriod; + primaryLabels = _build12HourRing( + textStyle: resolvedUnselectedLabelStyle, + selectedValue: selectedDialValue, + ); + selectedLabels = _build12HourRing( + textStyle: resolvedSelectedLabelStyle, + selectedValue: selectedDialValue, + ); + radiusValue = 1; + } + case _HourMinuteMode.minute: + selectedDialValue = widget.selectedTime.minute; + primaryLabels = _buildMinutes( + textStyle: resolvedUnselectedLabelStyle, + selectedValue: selectedDialValue, + ); + selectedLabels = _buildMinutes( + textStyle: resolvedSelectedLabelStyle, + selectedValue: selectedDialValue, + ); + radiusValue = 1; + } + painter?.dispose(); + painter = _DialPainter( + selectedValue: selectedDialValue, + primaryLabels: primaryLabels, + selectedLabels: selectedLabels, + backgroundColor: backgroundColor, + handColor: dialHandColor, + handWidth: defaultTheme.handWidth, + dotColor: dotColor, + dotRadius: defaultTheme.dotRadius, + centerRadius: defaultTheme.centerRadius, + theta: _theta.value, + radius: radiusValue, + textDirection: Directionality.of(context), + ); + + return GestureDetector( + excludeFromSemantics: true, + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + onTapUp: _handleTapUp, + child: CustomPaint(painter: painter), + ); + } +} + +class _TimePickerInput extends StatefulWidget { + const _TimePickerInput({ + required this.initialSelectedTime, + required this.errorInvalidText, + required this.hourLabelText, + required this.minuteLabelText, + required this.helpText, + required this.autofocusHour, + required this.autofocusMinute, + required this.emptyInitialTime, + this.restorationId, + }); + + /// The time initially selected when the dialog is shown. + final TimeOfDay initialSelectedTime; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + final String helpText; + + final bool? autofocusHour; + + final bool? autofocusMinute; + + /// Restoration ID to save and restore the state of the time picker input + /// widget. + /// + /// If it is non-null, the widget will persist and restore its state + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + final String? restorationId; + + /// If true and [TimePickerEntryMode.input] is used, hour and minute fields + /// start empty instead of using [initialSelectedTime]. + /// + /// Useful when users prefer manual input without clearing pre-filled values. + /// Ignored in dial mode. + final bool emptyInitialTime; + + @override + _TimePickerInputState createState() => _TimePickerInputState(); +} + +class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixin { + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialSelectedTime); + final RestorableBool hourHasError = RestorableBool(false); + final RestorableBool minuteHasError = RestorableBool(false); + + @override + void dispose() { + _selectedTime.dispose(); + hourHasError.dispose(); + minuteHasError.dispose(); + super.dispose(); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(hourHasError, 'hour_has_error'); + registerForRestoration(minuteHasError, 'minute_has_error'); + } + + int? _parseHour(String? value) { + if (value == null) { + return null; + } + + int? newHour = int.tryParse(value); + if (newHour == null) { + return null; + } + + if (MediaQuery.alwaysUse24HourFormatOf(context)) { + if (newHour >= 0 && newHour < 24) { + return newHour; + } + } else { + if (newHour > 0 && newHour < 13) { + if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12) || + (_selectedTime.value.period == DayPeriod.am && newHour == 12)) { + newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; + } + return newHour; + } + } + return null; + } + + int? _parseMinute(String? value) { + if (value == null) { + return null; + } + + final int? newMinute = int.tryParse(value); + if (newMinute == null) { + return null; + } + + if (newMinute >= 0 && newMinute < 60) { + return newMinute; + } + return null; + } + + void _handleHourSavedSubmitted(String? value) { + final int? newHour = _parseHour(value); + if (newHour != null) { + _selectedTime.value = TimeOfDay(hour: newHour, minute: _selectedTime.value.minute); + _TimePickerModel.setSelectedTime(context, _selectedTime.value); + FocusScope.of(context).requestFocus(); + } + } + + void _handleHourChanged(String value) { + final int? newHour = _parseHour(value); + if (newHour != null && value.length == 2) { + // If a valid hour is typed, move focus to the minute TextField. + FocusScope.of(context).nextFocus(); + } + } + + void _handleMinuteSavedSubmitted(String? value) { + final int? newMinute = _parseMinute(value); + if (newMinute != null) { + _selectedTime.value = TimeOfDay(hour: _selectedTime.value.hour, minute: int.parse(value!)); + _TimePickerModel.setSelectedTime(context, _selectedTime.value); + FocusScope.of(context).unfocus(); + } + } + + void _handleDayPeriodChanged(TimeOfDay value) { + _selectedTime.value = value; + _TimePickerModel.setSelectedTime(context, _selectedTime.value); + } + + String? _validateHour(String? value) { + final int? newHour = _parseHour(value); + setState(() { + hourHasError.value = newHour == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newHour == null ? '' : null; + } + + String? _validateMinute(String? value) { + final int? newMinute = _parseMinute(value); + setState(() { + minuteHasError.value = newMinute == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newMinute == null ? '' : null; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of( + context, + ).timeOfDayFormat(alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context)); + final use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final TextStyle hourMinuteStyle = + timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle; + + final double minInteractiveVerticalPadding = math.max( + 0, + 2 * kMinInteractiveDimension - defaultTheme.dayPeriodInputSize.height, + ); + + return Padding( + padding: _TimePickerModel.useMaterial3Of(context) + ? EdgeInsets.zero + : const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Padding( + padding: EdgeInsetsDirectional.only( + bottom: + (_TimePickerModel.useMaterial3Of(context) ? 20 : 24) - + minInteractiveVerticalPadding / 2, + ), + child: Text( + widget.helpText, + style: + _TimePickerModel.themeOf(context).helpTextStyle ?? + _TimePickerModel.defaultThemeOf(context).helpTextStyle, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + if (!use24HourDials && + timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[ + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: _DayPeriodControl(onPeriodChanged: _handleDayPeriodChanged), + ), + ], + Expanded( + child: Padding( + padding: EdgeInsetsDirectional.only(top: minInteractiveVerticalPadding / 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: <Widget>[ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _HourTextField( + restorationId: 'hour_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusHour, + inputAction: TextInputAction.next, + validator: _validateHour, + onSavedSubmitted: _handleHourSavedSubmitted, + onChanged: _handleHourChanged, + hourLabelText: widget.hourLabelText, + emptyInitialTime: widget.emptyInitialTime, + ), + ), + if (!hourHasError.value && !minuteHasError.value) + ExcludeSemantics( + child: Text( + widget.hourLabelText ?? + MaterialLocalizations.of(context).timePickerHourLabel, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + _TimeSelectorSeparator(timeOfDayFormat: timeOfDayFormat), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _MinuteTextField( + restorationId: 'minute_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusMinute, + inputAction: TextInputAction.done, + validator: _validateMinute, + onSavedSubmitted: _handleMinuteSavedSubmitted, + minuteLabelText: widget.minuteLabelText, + emptyInitialTime: widget.emptyInitialTime, + ), + ), + if (!hourHasError.value && !minuteHasError.value) + ExcludeSemantics( + child: Text( + widget.minuteLabelText ?? + MaterialLocalizations.of(context).timePickerMinuteLabel, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ), + ), + if (!use24HourDials && + timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[ + Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: _DayPeriodControl(onPeriodChanged: _handleDayPeriodChanged), + ), + ], + ], + ), + if (hourHasError.value || minuteHasError.value) + Text( + widget.errorInvalidText ?? MaterialLocalizations.of(context).invalidTimeLabel, + style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.error), + ) + else + const SizedBox(height: 2), + ], + ), + ); + } +} + +class _HourTextField extends StatelessWidget { + const _HourTextField({ + required this.selectedTime, + required this.style, + required this.autofocus, + required this.inputAction, + required this.validator, + required this.onSavedSubmitted, + required this.onChanged, + required this.hourLabelText, + required this.emptyInitialTime, + this.restorationId, + }); + + final TimeOfDay selectedTime; + final TextStyle style; + final bool? autofocus; + final TextInputAction inputAction; + final FormFieldValidator<String> validator; + final ValueChanged<String?> onSavedSubmitted; + final ValueChanged<String> onChanged; + final String? hourLabelText; + final String? restorationId; + final bool emptyInitialTime; + + @override + Widget build(BuildContext context) { + return _HourMinuteTextField( + restorationId: restorationId, + selectedTime: selectedTime, + isHour: true, + autofocus: autofocus, + inputAction: inputAction, + style: style, + semanticHintText: hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, + validator: validator, + onSavedSubmitted: onSavedSubmitted, + emptyInitialTime: emptyInitialTime, + onChanged: onChanged, + ); + } +} + +class _MinuteTextField extends StatelessWidget { + const _MinuteTextField({ + required this.selectedTime, + required this.style, + required this.autofocus, + required this.inputAction, + required this.validator, + required this.onSavedSubmitted, + required this.minuteLabelText, + required this.emptyInitialTime, + this.restorationId, + }); + + final TimeOfDay selectedTime; + final TextStyle style; + final bool? autofocus; + final TextInputAction inputAction; + final FormFieldValidator<String> validator; + final ValueChanged<String?> onSavedSubmitted; + final String? minuteLabelText; + final String? restorationId; + final bool emptyInitialTime; + + @override + Widget build(BuildContext context) { + return _HourMinuteTextField( + restorationId: restorationId, + selectedTime: selectedTime, + isHour: false, + autofocus: autofocus, + inputAction: inputAction, + style: style, + semanticHintText: minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel, + validator: validator, + emptyInitialTime: emptyInitialTime, + onSavedSubmitted: onSavedSubmitted, + ); + } +} + +class _HourMinuteTextField extends StatefulWidget { + const _HourMinuteTextField({ + required this.selectedTime, + required this.isHour, + required this.autofocus, + required this.inputAction, + required this.style, + required this.semanticHintText, + required this.validator, + required this.onSavedSubmitted, + this.restorationId, + required this.emptyInitialTime, + this.onChanged, + }); + + final TimeOfDay selectedTime; + final bool isHour; + final bool? autofocus; + final TextInputAction inputAction; + final TextStyle style; + final String semanticHintText; + final FormFieldValidator<String> validator; + final ValueChanged<String?> onSavedSubmitted; + final ValueChanged<String>? onChanged; + final String? restorationId; + final bool emptyInitialTime; + + @override + _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState(); +} + +class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + final RestorableBool controllerHasBeenSet = RestorableBool(false); + late FocusNode focusNode; + + @override + void initState() { + super.initState(); + focusNode = FocusNode() + ..addListener(() { + setState(() { + // Rebuild when focus changes. + if (kIsWeb && focusNode.hasFocus && primaryFocus?.context != null) { + Actions.maybeInvoke( + primaryFocus!.context!, + const SelectAllTextIntent(SelectionChangedCause.keyboard), + ); + } + }); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Only set the text value if it has not been populated with a localized + // version yet. + // If emptyInitialTime is true, set it to an empty string to indicate no + // initial time. + if (!controllerHasBeenSet.value) { + controllerHasBeenSet.value = true; + final String initialTextValue = widget.emptyInitialTime ? '' : _formattedValue; + controller.value.value = TextEditingValue(text: initialTextValue); + } + } + + @override + void dispose() { + controller.dispose(); + controllerHasBeenSet.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'text_editing_controller'); + registerForRestoration(controllerHasBeenSet, 'has_controller_been_set'); + } + + String get _formattedValue { + final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return !widget.isHour + ? localizations.formatMinute(widget.selectedTime) + : localizations.formatHour( + widget.selectedTime, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 + ? _TimePickerDefaultsM3(context) + : _TimePickerDefaultsM2(context); + final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); + + final InputDecorationThemeData inputDecorationTheme = + timePickerTheme.inputDecorationTheme ?? defaultTheme.inputDecorationTheme; + InputDecoration inputDecoration = InputDecoration( + // Prevent the error text from appearing when + // timePickerTheme.inputDecorationTheme is used. + // TODO(tahatesser): Remove this workaround once + // https://github.com/flutter/flutter/issues/54104 + // is fixed. + errorStyle: defaultTheme.inputDecorationTheme.errorStyle, + ).applyDefaults(inputDecorationTheme); + // Remove the hint text when focused because the centered cursor + // appears odd above the hint text. + // Clear the hint text when emptyInitialTime is true. + final String? hintText = focusNode.hasFocus || widget.emptyInitialTime ? null : _formattedValue; + + // Because the fill color is specified in both the inputDecorationTheme and + // the TimePickerTheme, if there's one in the user's input decoration theme, + // use that. If not, but there's one in the user's + // timePickerTheme.hourMinuteColor, use that, and otherwise use the default. + // We ignore the value in the fillColor of the input decoration in the + // default theme here, but it's the same as the hourMinuteColor. + final Color startingFillColor = + timePickerTheme.inputDecorationTheme?.fillColor ?? + timePickerTheme.hourMinuteColor ?? + defaultTheme.hourMinuteColor; + final Color fillColor; + if (theme.useMaterial3) { + fillColor = WidgetStateProperty.resolveAs<Color>(startingFillColor, <WidgetState>{ + if (focusNode.hasFocus) WidgetState.focused, + if (focusNode.hasFocus) WidgetState.selected, + }); + } else { + fillColor = focusNode.hasFocus ? Colors.transparent : startingFillColor; + } + + inputDecoration = inputDecoration.copyWith(hintText: hintText, fillColor: fillColor); + + final states = <WidgetState>{ + if (focusNode.hasFocus) WidgetState.focused, + if (focusNode.hasFocus) WidgetState.selected, + }; + final Color effectiveTextColor = WidgetStateProperty.resolveAs<Color>( + timePickerTheme.hourMinuteTextColor ?? defaultTheme.hourMinuteTextColor, + states, + ); + final TextStyle effectiveStyle = WidgetStateProperty.resolveAs<TextStyle>( + widget.style, + states, + ).copyWith(color: effectiveTextColor); + + return SizedBox.fromSize( + size: alwaysUse24HourFormat + ? defaultTheme.hourMinuteInputSize24Hour + : defaultTheme.hourMinuteInputSize, + child: MediaQuery.withNoTextScaling( + child: UnmanagedRestorationScope( + bucket: bucket, + child: Semantics( + label: widget.semanticHintText, + child: TextFormField( + restorationId: 'hour_minute_text_form_field', + autofocus: widget.autofocus ?? false, + expands: true, + maxLines: null, + inputFormatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(2)], + focusNode: focusNode, + textAlign: TextAlign.center, + textInputAction: widget.inputAction, + keyboardType: TextInputType.number, + style: effectiveStyle, + controller: controller.value, + decoration: inputDecoration, + validator: widget.validator, + onEditingComplete: () => widget.onSavedSubmitted(controller.value.text), + onSaved: widget.onSavedSubmitted, + onFieldSubmitted: widget.onSavedSubmitted, + onChanged: widget.onChanged, + ), + ), + ), + ), + ); + } +} + +/// Signature for when the time picker entry mode is changed. +typedef EntryModeChangeCallback = void Function(TimePickerEntryMode mode); + +/// A Material Design time picker designed to appear inside a popup dialog. +/// +/// Pass this widget to [showDialog]. The value returned by [showDialog] is the +/// selected [TimeOfDay] if the user taps the "OK" button, or null if the user +/// taps the "CANCEL" button. The selected time is reported by calling +/// [Navigator.pop]. +/// +/// Use [showTimePicker] to show a dialog already containing a [TimePickerDialog]. +class TimePickerDialog extends StatefulWidget { + /// Creates a Material Design time picker. + const TimePickerDialog({ + super.key, + required this.initialTime, + this.cancelText, + this.confirmText, + this.helpText, + this.errorInvalidText, + this.hourLabelText, + this.minuteLabelText, + this.restorationId, + this.initialEntryMode = TimePickerEntryMode.dial, + this.orientation, + this.onEntryModeChanged, + this.switchToInputEntryModeIcon, + this.switchToTimerEntryModeIcon, + this.emptyInitialInput = false, + }); + + /// The time initially selected when the dialog is shown. + final TimeOfDay initialTime; + + /// Optionally provide your own text for the cancel button. + /// + /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. + final String? cancelText; + + /// Optionally provide your own text for the confirm button. + /// + /// If null, the button uses [MaterialLocalizations.okButtonLabel]. + final String? confirmText; + + /// Optionally provide your own help text to the header of the time picker. + final String? helpText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + /// Restoration ID to save and restore the state of the [TimePickerDialog]. + /// + /// If it is non-null, the time picker will persist and restore the + /// dialog's state. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// The entry mode for the picker. Whether it's text input or a dial. + final TimePickerEntryMode initialEntryMode; + + /// The optional [orientation] parameter sets the [Orientation] to use when + /// displaying the dialog. + /// + /// By default, the orientation is derived from the [MediaQueryData.size] of + /// the ambient [MediaQuery]. If the aspect of the size is tall, then + /// [Orientation.portrait] is used, if the size is wide, then + /// [Orientation.landscape] is used. + /// + /// Use this parameter to override the default and force the dialog to appear + /// in either portrait or landscape mode regardless of the aspect of the + /// [MediaQueryData.size]. + final Orientation? orientation; + + /// Callback called when the selected entry mode is changed. + final EntryModeChangeCallback? onEntryModeChanged; + + /// {@macro flutter.material.time_picker.switchToInputEntryModeIcon} + final Icon? switchToInputEntryModeIcon; + + /// {@macro flutter.material.time_picker.switchToTimerEntryModeIcon} + final Icon? switchToTimerEntryModeIcon; + + /// If true and entry mode is [TimePickerEntryMode.input], the hour and minute + /// fields will be empty on start instead of pre-filled with [initialTime]. + /// + /// Has no effect in dial mode. + final bool emptyInitialInput; + + @override + State<TimePickerDialog> createState() => _TimePickerDialogState(); +} + +class _TimePickerDialogState extends State<TimePickerDialog> with RestorationMixin { + late final RestorableEnum<TimePickerEntryMode> _entryMode = RestorableEnum<TimePickerEntryMode>( + widget.initialEntryMode, + values: TimePickerEntryMode.values, + ); + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime); + final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); + final RestorableEnum<AutovalidateMode> _autovalidateMode = RestorableEnum<AutovalidateMode>( + AutovalidateMode.disabled, + values: AutovalidateMode.values, + ); + late final RestorableEnumN<Orientation> _orientation = RestorableEnumN<Orientation>( + widget.orientation, + values: Orientation.values, + ); + + // Base sizes + static const Size _kTimePickerPortraitSize = Size(310, 468); + static const Size _kTimePickerLandscapeSize = Size(524, 342); + static const Size _kTimePickerLandscapeSizeM2 = Size(508, 300); + static const Size _kTimePickerInputSize = Size(312, 252); + static const double _kTimePickerInputMinimumHeight = 216; + + // Absolute minimum dialog sizes, which is the point at which it begins + // scrolling to fit everything in. + static const Size _kTimePickerMinPortraitSize = Size(238, 326); + static const Size _kTimePickerMinLandscapeSize = Size(416, 248); + static const Size _kTimePickerMinInputSize = Size(312, 196); + + @override + void dispose() { + _selectedTime.dispose(); + _entryMode.dispose(); + _autovalidateMode.dispose(); + _orientation.dispose(); + super.dispose(); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(_entryMode, 'entry_mode'); + registerForRestoration(_autovalidateMode, 'autovalidate_mode'); + registerForRestoration(_orientation, 'orientation'); + } + + void _handleTimeChanged(TimeOfDay value) { + if (value != _selectedTime.value) { + setState(() { + _selectedTime.value = value; + }); + } + } + + void _handleEntryModeChanged(TimePickerEntryMode value) { + if (value != _entryMode.value) { + setState(() { + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + _autovalidateMode.value = AutovalidateMode.disabled; + case TimePickerEntryMode.input: + _formKey.currentState!.save(); + case TimePickerEntryMode.dialOnly: + break; + case TimePickerEntryMode.inputOnly: + break; + } + _entryMode.value = value; + widget.onEntryModeChanged?.call(value); + }); + } + } + + void _toggleEntryMode() { + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + _handleEntryModeChanged(TimePickerEntryMode.input); + case TimePickerEntryMode.input: + _handleEntryModeChanged(TimePickerEntryMode.dial); + case TimePickerEntryMode.dialOnly: + case TimePickerEntryMode.inputOnly: + FlutterError('Can not change entry mode from $_entryMode'); + } + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleOk() { + if (_entryMode.value == TimePickerEntryMode.input || + _entryMode.value == TimePickerEntryMode.inputOnly) { + final FormState form = _formKey.currentState!; + if (!form.validate()) { + setState(() { + _autovalidateMode.value = AutovalidateMode.always; + }); + return; + } + form.save(); + } + Navigator.pop(context, _selectedTime.value); + } + + Size _minDialogSize(BuildContext context, {required bool useMaterial3}) { + final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); + + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + return switch (orientation) { + Orientation.portrait => _kTimePickerMinPortraitSize, + Orientation.landscape => _kTimePickerMinLandscapeSize, + }; + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat( + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + final double timePickerWidth; + switch (timeOfDayFormat) { + case TimeOfDayFormat.HH_colon_mm: + case TimeOfDayFormat.HH_dot_mm: + case TimeOfDayFormat.frenchCanadian: + case TimeOfDayFormat.H_colon_mm: + final _TimePickerDefaults defaultTheme = useMaterial3 + ? _TimePickerDefaultsM3(context) + : _TimePickerDefaultsM2(context); + timePickerWidth = + _kTimePickerMinInputSize.width - defaultTheme.dayPeriodPortraitSize.width - 12; + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.h_colon_mm_space_a: + timePickerWidth = _kTimePickerMinInputSize.width - (useMaterial3 ? 32 : 0); + } + return Size(timePickerWidth, _kTimePickerMinInputSize.height); + } + } + + Size _dialogSize(BuildContext context, {required bool useMaterial3}) { + final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); + // Constrain the textScaleFactor to prevent layout issues. Since only some + // parts of the time picker scale up with textScaleFactor, we cap the factor + // to 1.1 as that provides enough space to reasonably fit all the content. + // + // 14 is a common font size used to compute the effective text scale. + const fontSizeToScale = 14.0; + final double textScaleFactor = + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.1).scale(fontSizeToScale) / + fontSizeToScale; + + final Size timePickerSize; + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + switch (orientation) { + case Orientation.portrait: + timePickerSize = _kTimePickerPortraitSize; + case Orientation.landscape: + timePickerSize = Size( + _kTimePickerLandscapeSize.width * textScaleFactor, + useMaterial3 ? _kTimePickerLandscapeSize.height : _kTimePickerLandscapeSizeM2.height, + ); + } + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat( + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + final double timePickerWidth; + switch (timeOfDayFormat) { + case TimeOfDayFormat.HH_colon_mm: + case TimeOfDayFormat.HH_dot_mm: + case TimeOfDayFormat.frenchCanadian: + case TimeOfDayFormat.H_colon_mm: + final _TimePickerDefaults defaultTheme = useMaterial3 + ? _TimePickerDefaultsM3(context) + : _TimePickerDefaultsM2(context); + timePickerWidth = + _kTimePickerInputSize.width - defaultTheme.dayPeriodPortraitSize.width - 12; + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.h_colon_mm_space_a: + timePickerWidth = _kTimePickerInputSize.width - (useMaterial3 ? 32 : 0); + } + timePickerSize = Size(timePickerWidth, _kTimePickerInputSize.height); + } + return Size(timePickerSize.width, timePickerSize.height * textScaleFactor); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + final TimePickerThemeData pickerTheme = TimePickerTheme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 + ? _TimePickerDefaultsM3(context) + : _TimePickerDefaultsM2(context); + final ShapeBorder shape = pickerTheme.shape ?? defaultTheme.shape; + final Color entryModeIconColor = + pickerTheme.entryModeIconColor ?? defaultTheme.entryModeIconColor; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + final Widget actions = Padding( + padding: EdgeInsetsDirectional.only(start: theme.useMaterial3 ? 0 : 4), + child: Row( + children: <Widget>[ + if (_entryMode.value == TimePickerEntryMode.dial || + _entryMode.value == TimePickerEntryMode.input) + IconButton( + // In material3 mode, we want to use the color as part of the + // button style which applies its own opacity. In material2 mode, + // we want to use the color as the color, which already includes + // the opacity. + color: theme.useMaterial3 ? null : entryModeIconColor, + style: theme.useMaterial3 + ? IconButton.styleFrom(foregroundColor: entryModeIconColor) + : null, + onPressed: _toggleEntryMode, + icon: _entryMode.value == TimePickerEntryMode.dial + ? widget.switchToInputEntryModeIcon ?? const Icon(Icons.keyboard_outlined) + : widget.switchToTimerEntryModeIcon ?? const Icon(Icons.access_time), + tooltip: _entryMode.value == TimePickerEntryMode.dial + ? MaterialLocalizations.of(context).inputTimeModeButtonLabel + : MaterialLocalizations.of(context).dialModeButtonLabel, + ), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 36), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: <Widget>[ + TextButton( + style: pickerTheme.cancelButtonStyle ?? defaultTheme.cancelButtonStyle, + onPressed: _handleCancel, + child: Text( + widget.cancelText ?? + (theme.useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase()), + ), + ), + TextButton( + style: pickerTheme.confirmButtonStyle ?? defaultTheme.confirmButtonStyle, + onPressed: _handleOk, + child: Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), + ), + ), + ), + ], + ), + ); + + final Offset tapTargetSizeOffset = switch (theme.materialTapTargetSize) { + MaterialTapTargetSize.padded => Offset.zero, + // _dialogSize returns "padded" sizes. + MaterialTapTargetSize.shrinkWrap => const Offset(0, -12), + }; + final Size dialogSize = + _dialogSize(context, useMaterial3: theme.useMaterial3) + tapTargetSizeOffset; + final Size minDialogSize = + _minDialogSize(context, useMaterial3: theme.useMaterial3) + tapTargetSizeOffset; + return Dialog( + shape: shape, + elevation: pickerTheme.elevation ?? defaultTheme.elevation, + backgroundColor: pickerTheme.backgroundColor ?? defaultTheme.backgroundColor, + insetPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: + (_entryMode.value == TimePickerEntryMode.input || + _entryMode.value == TimePickerEntryMode.inputOnly) + ? 0 + : 24, + ), + child: Padding( + padding: pickerTheme.padding ?? defaultTheme.padding, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Size constrainedSize = constraints.constrain(dialogSize); + final allowedSize = Size( + constrainedSize.width < minDialogSize.width + ? minDialogSize.width + : constrainedSize.width, + constrainedSize.height < minDialogSize.height + ? minDialogSize.height + : constrainedSize.height, + ); + return SingleChildScrollView( + restorationId: 'time_picker_scroll_view_horizontal', + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + restorationId: 'time_picker_scroll_view_vertical', + child: AnimatedContainer( + width: allowedSize.width, + duration: _kDialogSizeAnimationDuration, + curve: Curves.easeIn, + constraints: BoxConstraints( + minHeight: _kTimePickerInputMinimumHeight, + maxHeight: allowedSize.height, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Builder( + builder: (BuildContext context) { + final Widget child = Form( + key: _formKey, + autovalidateMode: _autovalidateMode.value, + child: _TimePicker( + time: widget.initialTime, + onTimeChanged: _handleTimeChanged, + helpText: widget.helpText, + cancelText: widget.cancelText, + confirmText: widget.confirmText, + errorInvalidText: widget.errorInvalidText, + hourLabelText: widget.hourLabelText, + minuteLabelText: widget.minuteLabelText, + restorationId: 'time_picker', + entryMode: _entryMode.value, + orientation: widget.orientation, + onEntryModeChanged: _handleEntryModeChanged, + switchToInputEntryModeIcon: widget.switchToInputEntryModeIcon, + switchToTimerEntryModeIcon: widget.switchToTimerEntryModeIcon, + emptyInitialInput: widget.emptyInitialInput, + ), + ); + if (_entryMode.value != TimePickerEntryMode.input && + _entryMode.value != TimePickerEntryMode.inputOnly) { + return Flexible(child: child); + } + return child; + }, + ), + actions, + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +// The _TimePicker widget is constructed so that in the future we could expose +// this as a public API for embedding time pickers into other non-dialog +// widgets, once we're sure we want to support that. + +/// A Time Picker widget that can be embedded into another widget. +class _TimePicker extends StatefulWidget { + /// Creates a const Material Design time picker. + const _TimePicker({ + required this.time, + required this.onTimeChanged, + this.helpText, + this.cancelText, + this.confirmText, + this.errorInvalidText, + this.hourLabelText, + this.minuteLabelText, + this.restorationId, + this.entryMode = TimePickerEntryMode.dial, + this.orientation, + this.onEntryModeChanged, + this.switchToInputEntryModeIcon, + this.switchToTimerEntryModeIcon, + required this.emptyInitialInput, + }); + + /// Optionally provide your own text for the help text at the top of the + /// control. + /// + /// If null, the widget uses [MaterialLocalizations.timePickerDialHelpText] + /// when the [entryMode] is [TimePickerEntryMode.dial], and + /// [MaterialLocalizations.timePickerInputHelpText] when the [entryMode] is + /// [TimePickerEntryMode.input]. + final String? helpText; + + /// Optionally provide your own text for the cancel button. + /// + /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. + final String? cancelText; + + /// Optionally provide your own text for the confirm button. + /// + /// If null, the button uses [MaterialLocalizations.okButtonLabel]. + final String? confirmText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + /// Restoration ID to save and restore the state of the [TimePickerDialog]. + /// + /// If it is non-null, the time picker will persist and restore the + /// dialog's state. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// The initial entry mode for the picker. Whether it's text input or a dial. + final TimePickerEntryMode entryMode; + + /// The currently selected time of day. + final TimeOfDay time; + + final ValueChanged<TimeOfDay>? onTimeChanged; + + /// The optional [orientation] parameter sets the [Orientation] to use when + /// displaying the dialog. + /// + /// By default, the orientation is derived from the [MediaQueryData.size] of + /// the ambient [MediaQuery]. If the aspect of the size is tall, then + /// [Orientation.portrait] is used, if the size is wide, then + /// [Orientation.landscape] is used. + /// + /// Use this parameter to override the default and force the dialog to appear + /// in either portrait or landscape mode regardless of the aspect of the + /// [MediaQueryData.size]. + final Orientation? orientation; + + /// Callback called when the selected entry mode is changed. + final EntryModeChangeCallback? onEntryModeChanged; + + /// {@macro flutter.material.time_picker.switchToInputEntryModeIcon} + final Icon? switchToInputEntryModeIcon; + + /// {@macro flutter.material.time_picker.switchToTimerEntryModeIcon} + final Icon? switchToTimerEntryModeIcon; + + /// If true, input fields start empty in input mode. + final bool emptyInitialInput; + + @override + State<_TimePicker> createState() => _TimePickerState(); +} + +class _TimePickerState extends State<_TimePicker> with RestorationMixin { + Timer? _vibrateTimer; + late MaterialLocalizations localizations; + final RestorableEnum<_HourMinuteMode> _hourMinuteMode = RestorableEnum<_HourMinuteMode>( + _HourMinuteMode.hour, + values: _HourMinuteMode.values, + ); + final RestorableEnumN<_HourMinuteMode> _lastModeAnnounced = RestorableEnumN<_HourMinuteMode>( + null, + values: _HourMinuteMode.values, + ); + final RestorableBoolN _autofocusHour = RestorableBoolN(null); + final RestorableBoolN _autofocusMinute = RestorableBoolN(null); + late final RestorableEnumN<Orientation> _orientation = RestorableEnumN<Orientation>( + widget.orientation, + values: Orientation.values, + ); + RestorableTimeOfDay get selectedTime => _selectedTime; + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.time); + + @override + void dispose() { + _vibrateTimer?.cancel(); + _vibrateTimer = null; + _orientation.dispose(); + _selectedTime.dispose(); + _hourMinuteMode.dispose(); + _lastModeAnnounced.dispose(); + _autofocusHour.dispose(); + _autofocusMinute.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + localizations = MaterialLocalizations.of(context); + } + + @override + void didUpdateWidget(_TimePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.orientation != widget.orientation) { + _orientation.value = widget.orientation; + } + if (oldWidget.time != widget.time) { + _selectedTime.value = widget.time; + } + } + + void _setEntryMode(TimePickerEntryMode mode) { + widget.onEntryModeChanged?.call(mode); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_hourMinuteMode, 'hour_minute_mode'); + registerForRestoration(_lastModeAnnounced, 'last_mode_announced'); + registerForRestoration(_autofocusHour, 'autofocus_hour'); + registerForRestoration(_autofocusMinute, 'autofocus_minute'); + registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(_orientation, 'orientation'); + } + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _vibrateTimer?.cancel(); + _vibrateTimer = Timer(_kVibrateCommitDelay, () { + HapticFeedback.vibrate(); + _vibrateTimer = null; + }); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + } + + void _handleHourMinuteModeChanged(_HourMinuteMode mode) { + _vibrate(); + setState(() { + _hourMinuteMode.value = mode; + }); + } + + void _handleEntryModeToggle() { + setState(() { + TimePickerEntryMode newMode = widget.entryMode; + switch (widget.entryMode) { + case TimePickerEntryMode.dial: + newMode = TimePickerEntryMode.input; + case TimePickerEntryMode.input: + _autofocusHour.value = false; + _autofocusMinute.value = false; + newMode = TimePickerEntryMode.dial; + case TimePickerEntryMode.dialOnly: + case TimePickerEntryMode.inputOnly: + FlutterError('Can not change entry mode from ${widget.entryMode}'); + } + _setEntryMode(newMode); + }); + } + + void _handleTimeChanged(TimeOfDay value) { + _vibrate(); + setState(() { + _selectedTime.value = value; + widget.onTimeChanged?.call(value); + }); + } + + void _handleHourDoubleTapped() { + _autofocusHour.value = true; + _handleEntryModeToggle(); + } + + void _handleMinuteDoubleTapped() { + _autofocusMinute.value = true; + _handleEntryModeToggle(); + } + + void _handleHourSelected() { + setState(() { + _hourMinuteMode.value = _HourMinuteMode.minute; + }); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat( + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + final ThemeData theme = Theme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 + ? _TimePickerDefaultsM3(context, entryMode: widget.entryMode) + : _TimePickerDefaultsM2(context); + final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); + final HourFormat timeOfDayHour = hourFormat(of: timeOfDayFormat); + final _HourDialType hourMode = switch (timeOfDayHour) { + HourFormat.HH || + HourFormat.H when theme.useMaterial3 => _HourDialType.twentyFourHourDoubleRing, + HourFormat.HH || HourFormat.H => _HourDialType.twentyFourHour, + HourFormat.h => _HourDialType.twelveHour, + }; + + final String helpText; + final Widget picker; + switch (widget.entryMode) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + helpText = + widget.helpText ?? + (theme.useMaterial3 + ? localizations.timePickerDialHelpText + : localizations.timePickerDialHelpText.toUpperCase()); + + // The vertical adjustment used to make both AM/PM buttons accessible. + // Because the period selector height is increased based on this value, + // the dial padding has to be decreased of the same amount. + final double portraitMinInteractiveVerticalAdjustment = math.max( + 0, + 2 * kMinInteractiveDimension - defaultTheme.dayPeriodPortraitSize.height, + ); + final EdgeInsetsGeometry dialPadding = switch (orientation) { + Orientation.portrait => EdgeInsets.only( + left: 12, + right: 12, + top: 36 - portraitMinInteractiveVerticalAdjustment / 2, + ), + Orientation.landscape => const EdgeInsetsDirectional.only(start: 64), + }; + + final Widget dial = Padding( + padding: dialPadding, + child: ExcludeSemantics( + child: SizedBox.fromSize( + size: defaultTheme.dialSize, + child: AspectRatio( + aspectRatio: 1, + child: _Dial( + hourMinuteMode: _hourMinuteMode.value, + hourDialType: hourMode, + selectedTime: _selectedTime.value, + onChanged: _handleTimeChanged, + onHourSelected: _handleHourSelected, + ), + ), + ), + ), + ); + + switch (orientation) { + case Orientation.portrait: + picker = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Padding( + padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), + child: _DialTimePickerHeader(helpText: helpText), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + // Dial grows and shrinks with the available space. + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), + child: dial, + ), + ), + ], + ), + ), + ], + ); + case Orientation.landscape: + picker = Column( + children: <Widget>[ + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + _DialTimePickerHeader(helpText: helpText), + Expanded(child: dial), + ], + ), + ), + ), + ], + ); + } + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + final String helpText = + widget.helpText ?? + (theme.useMaterial3 + ? localizations.timePickerInputHelpText + : localizations.timePickerInputHelpText.toUpperCase()); + + picker = Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + _TimePickerInput( + initialSelectedTime: _selectedTime.value, + errorInvalidText: widget.errorInvalidText, + hourLabelText: widget.hourLabelText, + minuteLabelText: widget.minuteLabelText, + helpText: helpText, + autofocusHour: _autofocusHour.value, + autofocusMinute: _autofocusMinute.value, + restorationId: 'time_picker_input', + emptyInitialTime: widget.emptyInitialInput, + ), + ], + ); + } + return _TimePickerModel( + entryMode: widget.entryMode, + selectedTime: _selectedTime.value, + hourMinuteMode: _hourMinuteMode.value, + orientation: orientation, + onHourMinuteModeChanged: _handleHourMinuteModeChanged, + onHourDoubleTapped: _handleHourDoubleTapped, + onMinuteDoubleTapped: _handleMinuteDoubleTapped, + hourDialType: hourMode, + onSelectedTimeChanged: _handleTimeChanged, + useMaterial3: theme.useMaterial3, + use24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + theme: TimePickerTheme.of(context), + defaultTheme: defaultTheme, + child: picker, + ); + } +} + +/// Shows a dialog containing a Material Design time picker. +/// +/// The returned Future resolves to the time selected by the user when the user +/// closes the dialog. If the user cancels the dialog, null is returned. +/// +/// {@tool snippet} Show a dialog with [initialTime] equal to the current time. +/// +/// ```dart +/// Future<TimeOfDay?> selectedTime = showTimePicker( +/// initialTime: TimeOfDay.now(), +/// context: context, +/// ); +/// ``` +/// {@end-tool} +/// +/// The [context], [barrierDismissible], [barrierColor], [barrierLabel], +/// [useRootNavigator] and [routeSettings] arguments are passed to [showDialog], +/// the documentation for which discusses how it is used. +/// +/// The [builder] parameter can be used to wrap the dialog widget to add +/// inherited widgets like [Localizations.override], [Directionality], or +/// [MediaQuery]. +/// +/// The `initialEntryMode` parameter can be used to determine the initial time +/// entry selection of the picker (either a clock dial or text input). +/// +/// Optional strings for the [helpText], [cancelText], [errorInvalidText], +/// [hourLabelText], [minuteLabelText] and [confirmText] can be provided to +/// override the default values. +/// +/// The optional [orientation] parameter sets the [Orientation] to use when +/// displaying the dialog. By default, the orientation is derived from the +/// [MediaQueryData.size] of the ambient [MediaQuery]: wide sizes use the +/// landscape orientation, and tall sizes use the portrait orientation. Use this +/// parameter to override the default and force the dialog to appear in either +/// portrait or landscape mode. +/// +/// {@template flutter.material.time_picker.switchToInputEntryModeIcon} +/// The optional [switchToInputEntryModeIcon] argument can be used to customize +/// the input method icon that is shown when the [TimePickerEntryMode] +/// is [TimePickerEntryMode.dial]. +/// +/// Defaults to an [Icon] widget with [Icons.keyboard_outlined] as icon. +/// {@endtemplate} +/// +/// {@template flutter.material.time_picker.switchToTimerEntryModeIcon} +/// The optional [switchToTimerEntryModeIcon] argument can be used to customize +/// the input method icon that is shown when the [TimePickerEntryMode] +/// is [TimePickerEntryMode.input]. +/// +/// Defaults to an [Icon] widget with [Icons.access_time] as icon. +/// {@endtemplate} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// By default, the time picker gets its colors from the overall theme's +/// [ColorScheme]. The time picker can be further customized by providing a +/// [TimePickerThemeData] to the overall theme. +/// +/// {@tool snippet} Show a dialog with the text direction overridden to be +/// [TextDirection.rtl]. +/// +/// ```dart +/// Future<TimeOfDay?> selectedTimeRTL = showTimePicker( +/// context: context, +/// initialTime: TimeOfDay.now(), +/// builder: (BuildContext context, Widget? child) { +/// return Directionality( +/// textDirection: TextDirection.rtl, +/// child: child!, +/// ); +/// }, +/// ); +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} Show a dialog with time unconditionally displayed in 24 hour +/// format. +/// +/// ```dart +/// Future<TimeOfDay?> selectedTime24Hour = showTimePicker( +/// context: context, +/// initialTime: const TimeOfDay(hour: 10, minute: 47), +/// builder: (BuildContext context, Widget? child) { +/// return MediaQuery( +/// data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), +/// child: child!, +/// ); +/// }, +/// ); +/// ``` +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example illustrates how to open a time picker, and allows exploring +/// some of the variations in the types of time pickers that may be shown. +/// +/// ** See code in examples/api/lib/material/time_picker/show_time_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [showDatePicker], which shows a dialog that contains a Material Design +/// date picker. +/// * [TimePickerThemeData], which allows you to customize the colors, +/// typography, and shape of the time picker. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +Future<TimeOfDay?> showTimePicker({ + required BuildContext context, + required TimeOfDay initialTime, + TransitionBuilder? builder, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useRootNavigator = true, + TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial, + String? cancelText, + String? confirmText, + String? helpText, + String? errorInvalidText, + String? hourLabelText, + String? minuteLabelText, + RouteSettings? routeSettings, + EntryModeChangeCallback? onEntryModeChanged, + Offset? anchorPoint, + Orientation? orientation, + Icon? switchToInputEntryModeIcon, + Icon? switchToTimerEntryModeIcon, + bool emptyInitialInput = false, +}) async { + assert(debugCheckHasMaterialLocalizations(context)); + + final Widget dialog = TimePickerDialog( + initialTime: initialTime, + initialEntryMode: initialEntryMode, + cancelText: cancelText, + confirmText: confirmText, + helpText: helpText, + errorInvalidText: errorInvalidText, + hourLabelText: hourLabelText, + minuteLabelText: minuteLabelText, + orientation: orientation, + onEntryModeChanged: onEntryModeChanged, + switchToInputEntryModeIcon: switchToInputEntryModeIcon, + switchToTimerEntryModeIcon: switchToTimerEntryModeIcon, + emptyInitialInput: emptyInitialInput, + ); + return showDialog<TimeOfDay>( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + builder: (BuildContext context) { + return builder == null ? dialog : builder(context, dialog); + }, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + ); +} + +// An abstract base class for the M2 and M3 defaults below, so that their return +// types can be non-nullable. +abstract class _TimePickerDefaults extends TimePickerThemeData { + @override + Color get backgroundColor; + + @override + ButtonStyle get cancelButtonStyle; + + @override + ButtonStyle get confirmButtonStyle; + + @override + BorderSide get dayPeriodBorderSide; + + @override + Color get dayPeriodColor; + + @override + OutlinedBorder get dayPeriodShape; + + Size get dayPeriodInputSize; + Size get dayPeriodLandscapeSize; + Size get dayPeriodPortraitSize; + + @override + Color get dayPeriodTextColor; + + @override + TextStyle get dayPeriodTextStyle; + + @override + Color get dialBackgroundColor; + + @override + Color get dialHandColor; + + // Sizes that are generated from the tokens, but these aren't ones we're ready + // to expose in the theme. + Size get dialSize; + double get handWidth; + double get dotRadius; + double get centerRadius; + + @override + Color get dialTextColor; + + @override + TextStyle get dialTextStyle; + + @override + double get elevation; + + @override + Color get entryModeIconColor; + + @override + TextStyle get helpTextStyle; + + @override + Color get hourMinuteColor; + + @override + ShapeBorder get hourMinuteShape; + + Size get hourMinuteSize; + Size get hourMinuteSize24Hour; + Size get hourMinuteInputSize; + Size get hourMinuteInputSize24Hour; + + @override + Color get hourMinuteTextColor; + + @override + TextStyle get hourMinuteTextStyle; + + @override + InputDecorationThemeData get inputDecorationTheme; + + @override + EdgeInsetsGeometry get padding; + + @override + ShapeBorder get shape; +} + +// These theme defaults are not auto-generated: they match the values for the +// Material 2 spec, which are not expected to change. +class _TimePickerDefaultsM2 extends _TimePickerDefaults { + _TimePickerDefaultsM2(this.context) : super(); + + final BuildContext context; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + static const OutlinedBorder _kDefaultShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + ); + + @override + Color get backgroundColor { + return _colors.surface; + } + + @override + ButtonStyle get cancelButtonStyle { + return TextButton.styleFrom(); + } + + @override + ButtonStyle get confirmButtonStyle { + return TextButton.styleFrom(); + } + + @override + BorderSide get dayPeriodBorderSide { + return BorderSide( + color: Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface), + ); + } + + @override + Color get dayPeriodColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.primary.withOpacity(_colors.brightness == Brightness.dark ? 0.24 : 0.12); + } + // The unselected day period should match the overall picker dialog color. + // Making it transparent enables that without being redundant and allows + // the optional elevation overlay for dark mode to be visible. + return Colors.transparent; + }); + } + + @override + OutlinedBorder get dayPeriodShape { + return _kDefaultShape; + } + + @override + Size get dayPeriodPortraitSize { + return const Size(52, 80); + } + + @override + Size get dayPeriodLandscapeSize { + return const Size(0, 40); + } + + @override + Size get dayPeriodInputSize { + return const Size(52, 70); + } + + @override + Color get dayPeriodTextColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.selected) + ? _colors.primary + : _colors.onSurface.withOpacity(0.60); + }); + } + + @override + TextStyle get dayPeriodTextStyle { + return _textTheme.titleMedium!.copyWith(color: dayPeriodTextColor); + } + + @override + Color get dialBackgroundColor { + return _colors.onSurface.withOpacity(_colors.brightness == Brightness.dark ? 0.12 : 0.08); + } + + @override + Color get dialHandColor { + return _colors.primary; + } + + @override + Size get dialSize { + return const Size.square(280); + } + + @override + double get handWidth { + return 2; + } + + @override + double get dotRadius { + return 22; + } + + @override + double get centerRadius { + return 4; + } + + @override + Color get dialTextColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.surface; + } + return _colors.onSurface; + }); + } + + @override + TextStyle get dialTextStyle { + return _textTheme.bodyLarge!; + } + + @override + double get elevation { + return 6; + } + + @override + Color get entryModeIconColor { + return _colors.onSurface.withOpacity(_colors.brightness == Brightness.dark ? 1.0 : 0.6); + } + + @override + TextStyle get helpTextStyle { + return _textTheme.labelSmall!; + } + + @override + Color get hourMinuteColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.selected) + ? _colors.primary.withOpacity(_colors.brightness == Brightness.dark ? 0.24 : 0.12) + : _colors.onSurface.withOpacity(0.12); + }); + } + + @override + ShapeBorder get hourMinuteShape { + return _kDefaultShape; + } + + @override + Size get hourMinuteSize { + return const Size(96, 80); + } + + @override + Size get hourMinuteSize24Hour { + return const Size(114, 80); + } + + @override + Size get hourMinuteInputSize { + return const Size(96, 70); + } + + @override + Size get hourMinuteInputSize24Hour { + return const Size(114, 70); + } + + @override + Color get hourMinuteTextColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.selected) ? _colors.primary : _colors.onSurface; + }); + } + + @override + TextStyle get hourMinuteTextStyle { + return _textTheme.displayMedium!; + } + + Color get _hourMinuteInputColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.selected) + ? Colors.transparent + : _colors.onSurface.withOpacity(0.12); + }); + } + + @override + InputDecorationThemeData get inputDecorationTheme { + return InputDecorationThemeData( + contentPadding: EdgeInsets.zero, + filled: true, + fillColor: _hourMinuteInputColor, + focusColor: Colors.transparent, + enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)), + errorBorder: OutlineInputBorder(borderSide: BorderSide(color: _colors.error, width: 2)), + focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: _colors.primary, width: 2)), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: _colors.error, width: 2), + ), + hintStyle: hourMinuteTextStyle.copyWith(color: _colors.onSurface.withOpacity(0.36)), + // Prevent the error text from appearing. + // TODO(rami-a): Remove this workaround once + // https://github.com/flutter/flutter/issues/54104 + // is fixed. + errorStyle: const TextStyle(fontSize: 0, height: 1), + ); + } + + @override + EdgeInsetsGeometry get padding { + return const EdgeInsets.fromLTRB(8, 18, 8, 8); + } + + @override + ShapeBorder get shape { + return _kDefaultShape; + } +} + +/// Ensure the widget is called in [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly] mode. +bool _debugDialTimePickerEntryMode(BuildContext context) { + final TimePickerEntryMode entryMode = _TimePickerModel.entryModeOf(context); + return entryMode == TimePickerEntryMode.dial || entryMode == TimePickerEntryMode.dialOnly; +} + +// BEGIN GENERATED TOKEN PROPERTIES - TimePicker + +// 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. + +// dart format off +class _TimePickerDefaultsM3 extends _TimePickerDefaults { + _TimePickerDefaultsM3(this.context, { this.entryMode = TimePickerEntryMode.dial }); + + final BuildContext context; + final TimePickerEntryMode entryMode; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color get backgroundColor { + return _colors.surfaceContainerHigh; + } + + @override + ButtonStyle get cancelButtonStyle { + return TextButton.styleFrom(); + } + + @override + ButtonStyle get confirmButtonStyle { + return TextButton.styleFrom(); + } + + @override + BorderSide get dayPeriodBorderSide { + return BorderSide(color: _colors.outline); + } + + @override + Color get dayPeriodColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.tertiaryContainer; + } + // The unselected day period should match the overall picker dialog color. + // Making it transparent enables that without being redundant and allows + // the optional elevation overlay for dark mode to be visible. + return Colors.transparent; + }); + } + + @override + OutlinedBorder get dayPeriodShape { + return const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))).copyWith(side: dayPeriodBorderSide); + } + + @override + Size get dayPeriodPortraitSize { + return const Size(52, 80); + } + + @override + Size get dayPeriodLandscapeSize { + return const Size(216, 38); + } + + @override + Size get dayPeriodInputSize { + // Input size is eight pixels smaller than the portrait size in the spec, + // but there's not token for it yet. + return Size(dayPeriodPortraitSize.width, dayPeriodPortraitSize.height - 8); + } + + @override + Color get dayPeriodTextColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.focused)) { + return _colors.onTertiaryContainer; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onTertiaryContainer; + } + if (states.contains(WidgetState.pressed)) { + return _colors.onTertiaryContainer; + } + return _colors.onTertiaryContainer; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurfaceVariant; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurfaceVariant; + } + if (states.contains(WidgetState.pressed)) { + return _colors.onSurfaceVariant; + } + return _colors.onSurfaceVariant; + }); + } + + @override + TextStyle get dayPeriodTextStyle { + return _textTheme.titleMedium!.copyWith(color: dayPeriodTextColor); + } + + @override + Color get dialBackgroundColor { + return _colors.surfaceContainerHighest; + } + + @override + Color get dialHandColor { + return _colors.primary; + } + + @override + Size get dialSize { + return const Size.square(256.0); + } + + @override + double get handWidth { + return const Size(2, double.infinity).width; + } + + @override + double get dotRadius { + return const Size.square(48.0).width / 2; + } + + @override + double get centerRadius { + return const Size.square(8.0).width / 2; + } + + @override + Color get dialTextColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _colors.onPrimary; + } + return _colors.onSurface; + }); + } + + @override + TextStyle get dialTextStyle { + return _textTheme.bodyLarge!; + } + + @override + double get elevation { + return 6.0; + } + + @override + Color get entryModeIconColor { + return _colors.onSurface; + } + + @override + TextStyle get helpTextStyle { + return WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final TextStyle textStyle = _textTheme.labelMedium!; + return textStyle.copyWith(color: _colors.onSurfaceVariant); + }); + } + + @override + EdgeInsetsGeometry get padding { + return const EdgeInsets.all(24); + } + + @override + Color get hourMinuteColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + Color overlayColor = _colors.primaryContainer; + if (states.contains(WidgetState.pressed)) { + overlayColor = _colors.onPrimaryContainer; + } else if (states.contains(WidgetState.hovered)) { + const hoverOpacity = 0.08; + overlayColor = _colors.onPrimaryContainer.withOpacity(hoverOpacity); + } else if (states.contains(WidgetState.focused)) { + const focusOpacity = 0.1; + overlayColor = _colors.onPrimaryContainer.withOpacity(focusOpacity); + } + return Color.alphaBlend(overlayColor, _colors.primaryContainer); + } else { + Color overlayColor = _colors.surfaceContainerHighest; + if (states.contains(WidgetState.pressed)) { + overlayColor = _colors.onSurface; + } else if (states.contains(WidgetState.hovered)) { + const hoverOpacity = 0.08; + overlayColor = _colors.onSurface.withOpacity(hoverOpacity); + } else if (states.contains(WidgetState.focused)) { + const focusOpacity = 0.1; + overlayColor = _colors.onSurface.withOpacity(focusOpacity); + } + return Color.alphaBlend(overlayColor, _colors.surfaceContainerHighest); + } + }); + } + + @override + ShapeBorder get hourMinuteShape { + return const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))); + } + + @override + Size get hourMinuteSize { + return const Size(96, 80); + } + + @override + Size get hourMinuteSize24Hour { + return Size(const Size(114, double.infinity).width, hourMinuteSize.height); + } + + @override + Size get hourMinuteInputSize { + // Input size is eight pixels smaller than the regular size in the spec, but + // there's not token for it yet. + return Size(hourMinuteSize.width, hourMinuteSize.height - 8); + } + + @override + Size get hourMinuteInputSize24Hour { + // Input size is eight pixels smaller than the regular size in the spec, but + // there's not token for it yet. + return Size(hourMinuteSize24Hour.width, hourMinuteSize24Hour.height - 8); + } + + @override + Color get hourMinuteTextColor { + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + return _hourMinuteTextColor.resolve(states); + }); + } + + WidgetStateProperty<Color> get _hourMinuteTextColor { + return WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return _colors.onPrimaryContainer; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onPrimaryContainer; + } + if (states.contains(WidgetState.focused)) { + return _colors.onPrimaryContainer; + } + return _colors.onPrimaryContainer; + } else { + // unselected + if (states.contains(WidgetState.pressed)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.hovered)) { + return _colors.onSurface; + } + if (states.contains(WidgetState.focused)) { + return _colors.onSurface; + } + return _colors.onSurface; + } + }); + } + + @override + TextStyle get hourMinuteTextStyle { + return WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + // TODO(tahatesser): Update this when https://github.com/flutter/flutter/issues/131247 is fixed. + // This is using the correct text style from Material 3 spec. + // https://m3.material.io/components/time-pickers/specs#fd0b6939-edab-4058-82e1-93d163945215 + return switch (entryMode) { + TimePickerEntryMode.dial || TimePickerEntryMode.dialOnly + => _textTheme.displayLarge!.copyWith(color: _hourMinuteTextColor.resolve(states)), + TimePickerEntryMode.input || TimePickerEntryMode.inputOnly + => _textTheme.displayMedium!.copyWith(color: _hourMinuteTextColor.resolve(states)), + }; + }); + } + + @override + InputDecorationThemeData get inputDecorationTheme { + // This is NOT correct, but there's no token for + // 'time-input.container.shape', so this is using the radius from the shape + // for the hour/minute selector. It's a BorderRadiusGeometry, so we have to + // resolve it before we can use it. + final BorderRadius selectorRadius = const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))) + .borderRadius + .resolve(Directionality.of(context)); + return InputDecorationThemeData( + contentPadding: EdgeInsets.zero, + filled: true, + // This should be derived from a token, but there isn't one for 'time-input'. + fillColor: hourMinuteColor, + // This should be derived from a token, but there isn't one for 'time-input'. + focusColor: _colors.primaryContainer, + enabledBorder: OutlineInputBorder( + borderRadius: selectorRadius, + borderSide: const BorderSide(color: Colors.transparent), + ), + errorBorder: OutlineInputBorder( + borderRadius: selectorRadius, + borderSide: BorderSide(color: _colors.error, width: 2), + ), + focusedBorder: OutlineInputBorder( + borderRadius: selectorRadius, + borderSide: BorderSide(color: _colors.primary, width: 2), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: selectorRadius, + borderSide: BorderSide(color: _colors.error, width: 2), + ), + hintStyle: hourMinuteTextStyle.copyWith(color: _colors.onSurface.withOpacity(0.36)), + // Prevent the error text from appearing. + // TODO(rami-a): Remove this workaround once + // https://github.com/flutter/flutter/issues/54104 + // is fixed. + errorStyle: const TextStyle(fontSize: 0), + ); + } + + @override + ShapeBorder get shape { + return const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))); + } + + @override + WidgetStateProperty<Color?>? get timeSelectorSeparatorColor { + // TODO(tahatesser): Update this when tokens are available. + // This is taken from https://m3.material.io/components/time-pickers/specs. + return MaterialStatePropertyAll<Color>(_colors.onSurface); + } + + @override + WidgetStateProperty<TextStyle?>? get timeSelectorSeparatorTextStyle { + // TODO(tahatesser): Update this when tokens are available. + // This is taken from https://m3.material.io/components/time-pickers/specs. + return MaterialStatePropertyAll<TextStyle?>(_textTheme.displayLarge); + } +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - TimePicker diff --git a/packages/material_ui/lib/src/time_picker_theme.dart b/packages/material_ui/lib/src/time_picker_theme.dart new file mode 100644 index 000000000000..7eced5047fd3 --- /dev/null +++ b/packages/material_ui/lib/src/time_picker_theme.dart @@ -0,0 +1,609 @@ +// 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. + +/// @docImport 'color_scheme.dart'; +/// @docImport 'dialog.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'text_field.dart'; +/// @docImport 'text_theme.dart'; +/// @docImport 'time_picker.dart'; +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'colors.dart'; +import 'input_decorator.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the visual properties of the widget displayed with [showTimePicker]. +/// +/// Descendant widgets obtain the current [TimePickerThemeData] object using +/// [TimePickerTheme.of]. Instances of [TimePickerThemeData] can be customized +/// with [TimePickerThemeData.copyWith]. +/// +/// Typically a [TimePickerThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.timePickerTheme]. +/// +/// All [TimePickerThemeData] properties are `null` by default. When null, +/// [showTimePicker] will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +/// * [TimePickerTheme], which describes the actual configuration of a time +/// picker theme. +@immutable +class TimePickerThemeData with Diagnosticable { + /// Creates a theme that can be used for [TimePickerTheme] or + /// [ThemeData.timePickerTheme]. + const TimePickerThemeData({ + this.backgroundColor, + this.cancelButtonStyle, + this.confirmButtonStyle, + this.dayPeriodBorderSide, + Color? dayPeriodColor, + this.dayPeriodShape, + this.dayPeriodTextColor, + this.dayPeriodTextStyle, + this.dialBackgroundColor, + this.dialHandColor, + this.dialTextColor, + this.dialTextStyle, + this.elevation, + this.entryModeIconColor, + this.helpTextStyle, + this.hourMinuteColor, + this.hourMinuteShape, + this.hourMinuteTextColor, + this.hourMinuteTextStyle, + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + Object? inputDecorationTheme, + this.padding, + this.shape, + this.timeSelectorSeparatorColor, + this.timeSelectorSeparatorTextStyle, + }) : assert( + inputDecorationTheme == null || + (inputDecorationTheme is InputDecorationTheme || + inputDecorationTheme is InputDecorationThemeData), + ), + _inputDecorationTheme = inputDecorationTheme, + _dayPeriodColor = dayPeriodColor; + + /// The background color of a time picker. + /// + /// If this is null, the time picker defaults to the overall theme's + /// [ColorScheme.surfaceContainerHigh]. + final Color? backgroundColor; + + /// The style of the cancel button of a [TimePickerDialog]. + final ButtonStyle? cancelButtonStyle; + + /// The style of the confirm (OK) button of a [TimePickerDialog]. + final ButtonStyle? confirmButtonStyle; + + /// The color and weight of the day period's outline. + /// + /// If this is null, the time picker defaults to: + /// + /// ```dart + /// BorderSide( + /// color: Color.alphaBlend( + /// Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38), + /// Theme.of(context).colorScheme.surface, + /// ), + /// ), + /// ``` + final BorderSide? dayPeriodBorderSide; + + /// The background color of the AM/PM toggle. + /// + /// If [dayPeriodColor] is a [WidgetStateColor], then the effective + /// background color can depend on the [WidgetState.selected] state, i.e. + /// if the segment is selected or not. + /// + /// By default, if the segment is selected, the overall theme's + /// `ColorScheme.primary.withValues(alpha: 0.12)` is used when the overall theme's + /// brightness is [Brightness.light] and + /// `ColorScheme.primary.withValues(alpha: 0.24)` is used when the overall theme's + /// brightness is [Brightness.dark]. + /// If the segment is not selected, [Colors.transparent] is used to allow the + /// [Dialog]'s color to be used. + Color? get dayPeriodColor { + if (_dayPeriodColor == null || _dayPeriodColor is WidgetStateColor) { + return _dayPeriodColor; + } + return WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return _dayPeriodColor; + } + // The unselected day period should match the overall picker dialog color. + // Making it transparent enables that without being redundant and allows + // the optional elevation overlay for dark mode to be visible. + return Colors.transparent; + }); + } + + final Color? _dayPeriodColor; + + /// The shape of the day period that the time picker uses. + /// + /// If this is null, the time picker defaults to: + /// + /// ```dart + /// const RoundedRectangleBorder( + /// borderRadius: BorderRadius.all(Radius.circular(4.0)), + /// side: BorderSide(), + /// ) + /// ``` + final OutlinedBorder? dayPeriodShape; + + /// The color of the day period text that represents AM/PM. + /// + /// If [dayPeriodTextColor] is a [WidgetStateColor], then the effective + /// text color can depend on the [WidgetState.selected] state, i.e. if the + /// text is selected or not. + /// + /// By default the overall theme's [ColorScheme.primary] color is used when + /// the text is selected and `ColorScheme.onSurface.withOpacity(0.60)` when + /// it's not selected. + final Color? dayPeriodTextColor; + + /// Used to configure the [TextStyle]s for the day period control. + /// + /// If this is null, the time picker defaults to the overall theme's + /// [TextTheme.titleMedium]. + final TextStyle? dayPeriodTextStyle; + + /// The background color of the time picker dial when the entry mode is + /// [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly]. + /// + /// If this is null and [ThemeData.useMaterial3] is true, the time picker + /// dial background color defaults [ColorScheme.surfaceContainerHighest] color. + /// + /// If this is null and [ThemeData.useMaterial3] is false, the time picker + /// dial background color defaults to [ColorScheme.onSurface] color with + /// an opacity of 0.08 when the overall theme's brightness is [Brightness.light] + /// and [ColorScheme.onSurface] color with an opacity of 0.12 when the overall + /// theme's brightness is [Brightness.dark]. + final Color? dialBackgroundColor; + + /// The color of the time picker dial's hand when the entry mode is + /// [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly]. + /// + /// If this is null, the time picker defaults to the overall theme's + /// [ColorScheme.primary]. + final Color? dialHandColor; + + /// The color of the dial text that represents specific hours and minutes. + /// + /// If [dialTextColor] is a [WidgetStateColor], then the effective + /// text color can depend on the [WidgetState.selected] state, i.e. if the + /// text is selected or not. + /// + /// If this color is null then the dial's text colors are based on the + /// theme's [ThemeData.colorScheme]. + final Color? dialTextColor; + + /// The [TextStyle] for the numbers on the time selection dial. + /// + /// If [dialTextStyle]'s [TextStyle.color] is a [WidgetStateColor], then the + /// effective text color can depend on the [WidgetState.selected] state, + /// i.e. if the text is selected or not. + /// + /// If this style is null then the dial's text style is based on the theme's + /// [ThemeData.textTheme]. + final TextStyle? dialTextStyle; + + /// The Material elevation for the time picker dialog. + final double? elevation; + + /// The color of the entry mode [IconButton]. + /// + /// If this is null, the time picker defaults to: + /// + /// ```dart + /// Theme.of(context).colorScheme.onSurface.withValues( + /// alpha: Theme.of(context).colorScheme.brightness == Brightness.dark + /// ? 1.0 + /// : 0.6, + /// ) + /// ``` + final Color? entryModeIconColor; + + /// Used to configure the [TextStyle]s for the helper text in the header. + /// + /// If this is null, the time picker defaults to the overall theme's + /// [TextTheme.labelSmall]. + final TextStyle? helpTextStyle; + + /// The background color of the hour and minute header segments. + /// + /// If [hourMinuteColor] is a [WidgetStateColor], then the effective + /// background color can depend on the [WidgetState.selected] state, i.e. + /// if the segment is selected or not. + /// + /// By default, if the segment is selected, the overall theme's + /// `ColorScheme.primary.withValues(alpha: 0.12)` is used when the overall theme's + /// brightness is [Brightness.light] and + /// `ColorScheme.primary.withValues(alpha: 0.24)` is used when the overall theme's + /// brightness is [Brightness.dark]. + /// If the segment is not selected, the overall theme's + /// `ColorScheme.onSurface.withValues(alpha: 0.12)` is used. + final Color? hourMinuteColor; + + /// The shape of the hour and minute controls that the time picker uses. + /// + /// If this is null, the time picker defaults to + /// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`. + final ShapeBorder? hourMinuteShape; + + /// The color of the header text that represents hours and minutes. + /// + /// If [hourMinuteTextColor] is a [WidgetStateColor], then the effective + /// text color can depend on the [WidgetState.selected] state, i.e. if the + /// text is selected or not. + /// + /// By default the overall theme's [ColorScheme.primary] color is used when + /// the text is selected and [ColorScheme.onSurface] when it's not selected. + final Color? hourMinuteTextColor; + + /// Used to configure the [TextStyle]s for the hour/minute controls. + /// + /// If this is null and entry mode is [TimePickerEntryMode.dial], the time + /// picker defaults to the overall theme's [TextTheme.displayLarge] with + /// the value of [hourMinuteTextColor]. + /// + /// If this is null and entry mode is [TimePickerEntryMode.input], the time + /// picker defaults to the overall theme's [TextTheme.displayMedium] with + /// the value of [hourMinuteTextColor]. + /// + /// If this is null and [ThemeData.useMaterial3] is false, the time picker + /// defaults to the overall theme's [TextTheme.displayMedium]. + final TextStyle? hourMinuteTextStyle; + + /// The input decoration theme for the [TextField]s in the time picker. + /// + /// If this is null, the time picker provides its own defaults. + // TODO(bleroux): Clean this up once `InputDecorationTheme` is fully normalized. + InputDecorationThemeData? get inputDecorationTheme { + if (_inputDecorationTheme == null) { + return null; + } + return _inputDecorationTheme is InputDecorationTheme + ? _inputDecorationTheme.data + : _inputDecorationTheme as InputDecorationThemeData; + } + + final Object? _inputDecorationTheme; + + /// The padding around the time picker dialog when the entry mode is + /// [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly]. + final EdgeInsetsGeometry? padding; + + /// The shape of the [Dialog] that the time picker is presented in. + /// + /// If this is null, the time picker defaults to + /// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`. + final ShapeBorder? shape; + + /// The color of the time selector separator between the hour and minute controls. + /// + /// if this is null, the time picker defaults to the overall theme's + /// [ColorScheme.onSurface]. + /// + /// If this is null and [ThemeData.useMaterial3] is false, then defaults to the value of + /// [hourMinuteTextColor]. + final WidgetStateProperty<Color?>? timeSelectorSeparatorColor; + + /// Used to configure the text style for the time selector separator between the hour + /// and minute controls. + /// + /// If this is null, the time picker defaults to the overall theme's + /// [TextTheme.displayLarge]. + /// + /// If this is null and [ThemeData.useMaterial3] is false, then defaults to the value of + /// [hourMinuteTextStyle]. + final WidgetStateProperty<TextStyle?>? timeSelectorSeparatorTextStyle; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + TimePickerThemeData copyWith({ + Color? backgroundColor, + ButtonStyle? cancelButtonStyle, + ButtonStyle? confirmButtonStyle, + ButtonStyle? dayPeriodButtonStyle, + BorderSide? dayPeriodBorderSide, + Color? dayPeriodColor, + OutlinedBorder? dayPeriodShape, + Color? dayPeriodTextColor, + TextStyle? dayPeriodTextStyle, + Color? dialBackgroundColor, + Color? dialHandColor, + Color? dialTextColor, + TextStyle? dialTextStyle, + double? elevation, + Color? entryModeIconColor, + TextStyle? helpTextStyle, + Color? hourMinuteColor, + ShapeBorder? hourMinuteShape, + Color? hourMinuteTextColor, + TextStyle? hourMinuteTextStyle, + InputDecorationTheme? inputDecorationTheme, + EdgeInsetsGeometry? padding, + ShapeBorder? shape, + WidgetStateProperty<Color?>? timeSelectorSeparatorColor, + WidgetStateProperty<TextStyle?>? timeSelectorSeparatorTextStyle, + }) { + return TimePickerThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + cancelButtonStyle: cancelButtonStyle ?? this.cancelButtonStyle, + confirmButtonStyle: confirmButtonStyle ?? this.confirmButtonStyle, + dayPeriodBorderSide: dayPeriodBorderSide ?? this.dayPeriodBorderSide, + dayPeriodColor: dayPeriodColor ?? this.dayPeriodColor, + dayPeriodShape: dayPeriodShape ?? this.dayPeriodShape, + dayPeriodTextColor: dayPeriodTextColor ?? this.dayPeriodTextColor, + dayPeriodTextStyle: dayPeriodTextStyle ?? this.dayPeriodTextStyle, + dialBackgroundColor: dialBackgroundColor ?? this.dialBackgroundColor, + dialHandColor: dialHandColor ?? this.dialHandColor, + dialTextColor: dialTextColor ?? this.dialTextColor, + dialTextStyle: dialTextStyle ?? this.dialTextStyle, + elevation: elevation ?? this.elevation, + entryModeIconColor: entryModeIconColor ?? this.entryModeIconColor, + helpTextStyle: helpTextStyle ?? this.helpTextStyle, + hourMinuteColor: hourMinuteColor ?? this.hourMinuteColor, + hourMinuteShape: hourMinuteShape ?? this.hourMinuteShape, + hourMinuteTextColor: hourMinuteTextColor ?? this.hourMinuteTextColor, + hourMinuteTextStyle: hourMinuteTextStyle ?? this.hourMinuteTextStyle, + inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme, + padding: padding ?? this.padding, + shape: shape ?? this.shape, + timeSelectorSeparatorColor: timeSelectorSeparatorColor ?? this.timeSelectorSeparatorColor, + timeSelectorSeparatorTextStyle: + timeSelectorSeparatorTextStyle ?? this.timeSelectorSeparatorTextStyle, + ); + } + + /// Linearly interpolate between two time picker themes. + /// + /// {@macro dart.ui.shadow.lerp} + static TimePickerThemeData lerp(TimePickerThemeData? a, TimePickerThemeData? b, double t) { + if (identical(a, b) && a != null) { + return a; + } + // Workaround since BorderSide's lerp does not allow for null arguments. + BorderSide? lerpedBorderSide; + if (a?.dayPeriodBorderSide == null && b?.dayPeriodBorderSide == null) { + lerpedBorderSide = null; + } else if (a?.dayPeriodBorderSide == null) { + lerpedBorderSide = b?.dayPeriodBorderSide; + } else if (b?.dayPeriodBorderSide == null) { + lerpedBorderSide = a?.dayPeriodBorderSide; + } else { + lerpedBorderSide = BorderSide.lerp(a!.dayPeriodBorderSide!, b!.dayPeriodBorderSide!, t); + } + return TimePickerThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + cancelButtonStyle: ButtonStyle.lerp(a?.cancelButtonStyle, b?.cancelButtonStyle, t), + confirmButtonStyle: ButtonStyle.lerp(a?.confirmButtonStyle, b?.confirmButtonStyle, t), + dayPeriodBorderSide: lerpedBorderSide, + dayPeriodColor: Color.lerp(a?.dayPeriodColor, b?.dayPeriodColor, t), + dayPeriodShape: ShapeBorder.lerp(a?.dayPeriodShape, b?.dayPeriodShape, t) as OutlinedBorder?, + dayPeriodTextColor: Color.lerp(a?.dayPeriodTextColor, b?.dayPeriodTextColor, t), + dayPeriodTextStyle: TextStyle.lerp(a?.dayPeriodTextStyle, b?.dayPeriodTextStyle, t), + dialBackgroundColor: Color.lerp(a?.dialBackgroundColor, b?.dialBackgroundColor, t), + dialHandColor: Color.lerp(a?.dialHandColor, b?.dialHandColor, t), + dialTextColor: Color.lerp(a?.dialTextColor, b?.dialTextColor, t), + dialTextStyle: TextStyle.lerp(a?.dialTextStyle, b?.dialTextStyle, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + entryModeIconColor: Color.lerp(a?.entryModeIconColor, b?.entryModeIconColor, t), + helpTextStyle: TextStyle.lerp(a?.helpTextStyle, b?.helpTextStyle, t), + hourMinuteColor: Color.lerp(a?.hourMinuteColor, b?.hourMinuteColor, t), + hourMinuteShape: ShapeBorder.lerp(a?.hourMinuteShape, b?.hourMinuteShape, t), + hourMinuteTextColor: Color.lerp(a?.hourMinuteTextColor, b?.hourMinuteTextColor, t), + hourMinuteTextStyle: TextStyle.lerp(a?.hourMinuteTextStyle, b?.hourMinuteTextStyle, t), + inputDecorationTheme: t < 0.5 ? a?.inputDecorationTheme : b?.inputDecorationTheme, + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + timeSelectorSeparatorColor: WidgetStateProperty.lerp<Color?>( + a?.timeSelectorSeparatorColor, + b?.timeSelectorSeparatorColor, + t, + Color.lerp, + ), + timeSelectorSeparatorTextStyle: WidgetStateProperty.lerp<TextStyle?>( + a?.timeSelectorSeparatorTextStyle, + b?.timeSelectorSeparatorTextStyle, + t, + TextStyle.lerp, + ), + ); + } + + @override + int get hashCode => Object.hashAll(<Object?>[ + backgroundColor, + cancelButtonStyle, + confirmButtonStyle, + dayPeriodBorderSide, + dayPeriodColor, + dayPeriodShape, + dayPeriodTextColor, + dayPeriodTextStyle, + dialBackgroundColor, + dialHandColor, + dialTextColor, + dialTextStyle, + elevation, + entryModeIconColor, + helpTextStyle, + hourMinuteColor, + hourMinuteShape, + hourMinuteTextColor, + hourMinuteTextStyle, + inputDecorationTheme, + padding, + shape, + timeSelectorSeparatorColor, + timeSelectorSeparatorTextStyle, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is TimePickerThemeData && + other.backgroundColor == backgroundColor && + other.cancelButtonStyle == cancelButtonStyle && + other.confirmButtonStyle == confirmButtonStyle && + other.dayPeriodBorderSide == dayPeriodBorderSide && + other.dayPeriodColor == dayPeriodColor && + other.dayPeriodShape == dayPeriodShape && + other.dayPeriodTextColor == dayPeriodTextColor && + other.dayPeriodTextStyle == dayPeriodTextStyle && + other.dialBackgroundColor == dialBackgroundColor && + other.dialHandColor == dialHandColor && + other.dialTextColor == dialTextColor && + other.dialTextStyle == dialTextStyle && + other.elevation == elevation && + other.entryModeIconColor == entryModeIconColor && + other.helpTextStyle == helpTextStyle && + other.hourMinuteColor == hourMinuteColor && + other.hourMinuteShape == hourMinuteShape && + other.hourMinuteTextColor == hourMinuteTextColor && + other.hourMinuteTextStyle == hourMinuteTextStyle && + other.inputDecorationTheme == inputDecorationTheme && + other.padding == padding && + other.shape == shape && + other.timeSelectorSeparatorColor == timeSelectorSeparatorColor && + other.timeSelectorSeparatorTextStyle == timeSelectorSeparatorTextStyle; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<ButtonStyle>('cancelButtonStyle', cancelButtonStyle, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<ButtonStyle>( + 'confirmButtonStyle', + confirmButtonStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<BorderSide>( + 'dayPeriodBorderSide', + dayPeriodBorderSide, + defaultValue: null, + ), + ); + properties.add(ColorProperty('dayPeriodColor', dayPeriodColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<ShapeBorder>('dayPeriodShape', dayPeriodShape, defaultValue: null), + ); + properties.add(ColorProperty('dayPeriodTextColor', dayPeriodTextColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('dayPeriodTextStyle', dayPeriodTextStyle, defaultValue: null), + ); + properties.add(ColorProperty('dialBackgroundColor', dialBackgroundColor, defaultValue: null)); + properties.add(ColorProperty('dialHandColor', dialHandColor, defaultValue: null)); + properties.add(ColorProperty('dialTextColor', dialTextColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle?>('dialTextStyle', dialTextStyle, defaultValue: null), + ); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('entryModeIconColor', entryModeIconColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>('helpTextStyle', helpTextStyle, defaultValue: null), + ); + properties.add(ColorProperty('hourMinuteColor', hourMinuteColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<ShapeBorder>('hourMinuteShape', hourMinuteShape, defaultValue: null), + ); + properties.add(ColorProperty('hourMinuteTextColor', hourMinuteTextColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<TextStyle>( + 'hourMinuteTextStyle', + hourMinuteTextStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<InputDecorationThemeData>( + 'inputDecorationTheme', + inputDecorationTheme, + defaultValue: null, + ), + ); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null)); + properties.add( + DiagnosticsProperty<WidgetStateProperty<Color?>>( + 'timeSelectorSeparatorColor', + timeSelectorSeparatorColor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty<WidgetStateProperty<TextStyle?>>( + 'timeSelectorSeparatorTextStyle', + timeSelectorSeparatorTextStyle, + defaultValue: null, + ), + ); + } +} + +/// An inherited widget that defines the configuration for time pickers +/// displayed using [showTimePicker] in this widget's subtree. +/// +/// Values specified here are used for time picker properties that are not +/// given an explicit non-null value. +class TimePickerTheme extends InheritedTheme { + /// Creates a time picker theme that controls the configurations for + /// time pickers displayed in its widget subtree. + const TimePickerTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant time picker widgets. + final TimePickerThemeData data; + + /// The [data] value of the closest [TimePickerTheme] ancestor. + /// + /// If there is no ancestor, it returns [ThemeData.timePickerTheme]. + /// Applications can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TimePickerThemeData theme = TimePickerTheme.of(context); + /// ``` + static TimePickerThemeData of(BuildContext context) { + final TimePickerTheme? timePickerTheme = context + .dependOnInheritedWidgetOfExactType<TimePickerTheme>(); + return timePickerTheme?.data ?? Theme.of(context).timePickerTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return TimePickerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(TimePickerTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/toggle_buttons.dart b/packages/material_ui/lib/src/toggle_buttons.dart new file mode 100644 index 000000000000..ed2971f77df2 --- /dev/null +++ b/packages/material_ui/lib/src/toggle_buttons.dart @@ -0,0 +1,1726 @@ +// 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. + +/// @docImport 'button_style_button.dart'; +/// @docImport 'ink_well.dart'; +/// @docImport 'segmented_button.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'color_scheme.dart'; +import 'constants.dart'; +import 'ink_ripple.dart'; +import 'material_state.dart'; +import 'text_button.dart'; +import 'theme.dart'; +import 'theme_data.dart'; +import 'toggle_buttons_theme.dart'; + +// Examples can assume: +// List<bool> isSelected = <bool>[]; +// void setState(dynamic arg) { } + +/// A set of toggle buttons. +/// +/// The list of [children] are laid out along [direction]. The state of each button +/// is controlled by [isSelected], which is a list of bools that determine +/// if a button is in an unselected or selected state. They are both +/// correlated by their index in the list. The length of [isSelected] has to +/// match the length of the [children] list. +/// +/// There is a Material 3 version of this component, [SegmentedButton], +/// that's preferred for applications that are configured for Material 3 +/// (see [ThemeData.useMaterial3]). +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=kVEguaQWGAY} +/// +/// ## Updating to [SegmentedButton] +/// +/// There is a Material 3 version of this component, [SegmentedButton], +/// that's preferred for applications that are configured for Material 3 +/// (see [ThemeData.useMaterial3]). The [SegmentedButton] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// <https://m3.material.io/components/segmented-buttons/overview> for +/// more details. The [SegmentedButton] widget's API is also slightly different. +/// While the [ToggleButtons] widget can have list of widgets, the +/// [SegmentedButton] widget has a list of [ButtonSegment]s with +/// a type value. While the [ToggleButtons] uses a list of boolean values +/// to determine the selection state of each button, the [SegmentedButton] +/// uses a set of type values to determine the selection state of each segment. +/// The [SegmentedButton.style] is a [ButtonStyle] style field, which can be +/// used to customize the entire segmented button and the individual segments. +/// +/// {@tool dartpad} +/// This sample shows how to migrate [ToggleButtons] that allows multiple +/// or no selection to [SegmentedButton] that allows multiple or no selection. +/// +/// ** See code in examples/api/lib/material/toggle_buttons/toggle_buttons.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example showcase [ToggleButtons] in various configurations. +/// +/// ** See code in examples/api/lib/material/toggle_buttons/toggle_buttons.0.dart ** +/// {@end-tool} +/// +/// ## Customizing toggle buttons +/// Each toggle's behavior can be configured by the [onPressed] callback, which +/// can update the [isSelected] list however it wants to. +/// +/// {@animation 700 150 https://flutter.github.io/assets-for-api-docs/assets/material/toggle_buttons_simple.mp4} +/// +/// Here is an implementation that allows for multiple buttons to be +/// simultaneously selected, while requiring none of the buttons to be +/// selected. +/// +/// ```dart +/// ToggleButtons( +/// isSelected: isSelected, +/// onPressed: (int index) { +/// setState(() { +/// isSelected[index] = !isSelected[index]; +/// }); +/// }, +/// children: const <Widget>[ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// ), +/// ``` +/// +/// {@animation 700 150 https://flutter.github.io/assets-for-api-docs/assets/material/toggle_buttons_required_mutually_exclusive.mp4} +/// +/// Here is an implementation that requires mutually exclusive selection while +/// requiring at least one selection. This assumes that [isSelected] was +/// properly initialized with one selection. +/// +/// ```dart +/// ToggleButtons( +/// isSelected: isSelected, +/// onPressed: (int index) { +/// setState(() { +/// for (int buttonIndex = 0; buttonIndex < isSelected.length; buttonIndex++) { +/// if (buttonIndex == index) { +/// isSelected[buttonIndex] = true; +/// } else { +/// isSelected[buttonIndex] = false; +/// } +/// } +/// }); +/// }, +/// children: const <Widget>[ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// ), +/// ``` +/// +/// {@animation 700 150 https://flutter.github.io/assets-for-api-docs/assets/material/toggle_buttons_mutually_exclusive.mp4} +/// +/// Here is an implementation that requires mutually exclusive selection, +/// but allows for none of the buttons to be selected. +/// +/// ```dart +/// ToggleButtons( +/// isSelected: isSelected, +/// onPressed: (int index) { +/// setState(() { +/// for (int buttonIndex = 0; buttonIndex < isSelected.length; buttonIndex++) { +/// if (buttonIndex == index) { +/// isSelected[buttonIndex] = !isSelected[buttonIndex]; +/// } else { +/// isSelected[buttonIndex] = false; +/// } +/// } +/// }); +/// }, +/// children: const <Widget>[ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// ), +/// ``` +/// +/// {@animation 700 150 https://flutter.github.io/assets-for-api-docs/assets/material/toggle_buttons_required.mp4} +/// +/// Here is an implementation that allows for multiple buttons to be +/// simultaneously selected, while requiring at least one selection. This +/// assumes that [isSelected] was properly initialized with one selection. +/// +/// ```dart +/// ToggleButtons( +/// isSelected: isSelected, +/// onPressed: (int index) { +/// int count = 0; +/// for (final bool value in isSelected) { +/// if (value) { +/// count += 1; +/// } +/// } +/// if (isSelected[index] && count < 2) { +/// return; +/// } +/// setState(() { +/// isSelected[index] = !isSelected[index]; +/// }); +/// }, +/// children: const <Widget>[ +/// Icon(Icons.ac_unit), +/// Icon(Icons.call), +/// Icon(Icons.cake), +/// ], +/// ), +/// ``` +/// +/// ## ToggleButton Borders +/// The toggle buttons, by default, have a solid, 1 logical pixel border +/// surrounding itself and separating each button. The toggle button borders' +/// color, width, and corner radii are configurable. +/// +/// The [selectedBorderColor] determines the border's color when the button is +/// selected, while [disabledBorderColor] determines the border's color when +/// the button is disabled. [borderColor] is used when the button is enabled. +/// +/// To remove the border, set [renderBorder] to false. Setting [borderWidth] to +/// 0.0 results in a hairline border. For more information on hairline borders, +/// see [BorderSide.width]. +/// +/// See also: +/// +/// * <https://material.io/design/components/buttons.html#toggle-button> +class ToggleButtons extends StatelessWidget { + /// Creates a set of toggle buttons. + /// + /// It displays its widgets provided in a [List] of [children] along [direction]. + /// The state of each button is controlled by [isSelected], which is a list + /// of bools that determine if a button is in an active, disabled, or + /// selected state. They are both correlated by their index in the list. + /// The length of [isSelected] has to match the length of the [children] + /// list. + /// + /// Both [children] and [isSelected] properties arguments are required. + /// + /// The [focusNodes] argument must be null or a list of nodes. If [direction] + /// is [Axis.vertical], [verticalDirection] must not be null. + const ToggleButtons({ + super.key, + required this.children, + required this.isSelected, + this.onPressed, + this.mouseCursor, + this.tapTargetSize, + this.textStyle, + this.constraints, + this.color, + this.selectedColor, + this.disabledColor, + this.fillColor, + this.focusColor, + this.highlightColor, + this.hoverColor, + this.splashColor, + this.focusNodes, + this.renderBorder = true, + this.borderColor, + this.selectedBorderColor, + this.disabledBorderColor, + this.borderRadius, + this.borderWidth, + this.direction = Axis.horizontal, + this.verticalDirection = VerticalDirection.down, + }) : assert(children.length == isSelected.length); + + static const double _defaultBorderWidth = 1.0; + + /// The toggle button widgets. + /// + /// These are typically [Icon] or [Text] widgets. The boolean selection + /// state of each widget is defined by the corresponding [isSelected] + /// list item. + /// + /// The length of children has to match the length of [isSelected]. If + /// [focusNodes] is not null, the length of children has to also match + /// the length of [focusNodes]. + final List<Widget> children; + + /// The corresponding selection state of each toggle button. + /// + /// Each value in this list represents the selection state of the [children] + /// widget at the same index. + /// + /// The length of [isSelected] has to match the length of [children]. + final List<bool> isSelected; + + /// The callback that is called when a button is tapped. + /// + /// The index parameter of the callback is the index of the button that is + /// tapped or otherwise activated. + /// + /// When the callback is null, all toggle buttons will be disabled. + final void Function(int index)? onPressed; + + /// {@macro flutter.material.RawMaterialButton.mouseCursor} + /// + /// If this property is null, [WidgetStateMouseCursor.adaptiveClickable] is used. + final MouseCursor? mouseCursor; + + /// Configures the minimum size of the area within which the buttons may + /// be pressed. + /// + /// If the [tapTargetSize] is larger than [constraints], the buttons will + /// include a transparent margin that responds to taps. + /// + /// Defaults to [ThemeData.materialTapTargetSize]. + final MaterialTapTargetSize? tapTargetSize; + + /// The [TextStyle] to apply to any text in these toggle buttons. + /// + /// [TextStyle.color] will be ignored and substituted by [color], + /// [selectedColor] or [disabledColor] depending on whether the buttons + /// are active, selected, or disabled. + final TextStyle? textStyle; + + /// Defines the button's size. + /// + /// Typically used to constrain the button's minimum size. + /// + /// If this property is null, then + /// BoxConstraints(minWidth: 48.0, minHeight: 48.0) is be used. + final BoxConstraints? constraints; + + /// The color for descendant [Text] and [Icon] widgets if the button is + /// enabled and not selected. + /// + /// If [onPressed] is not null, this color will be used for values in + /// [isSelected] that are false. + /// + /// If this property is null, then ToggleButtonTheme.of(context).color + /// is used. If [ToggleButtonsThemeData.color] is also null, then + /// Theme.of(context).colorScheme.onSurface is used. + final Color? color; + + /// The color for descendant [Text] and [Icon] widgets if the button is + /// selected. + /// + /// If [onPressed] is not null, this color will be used for values in + /// [isSelected] that are true. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).selectedColor is used. If + /// [ToggleButtonsThemeData.selectedColor] is also null, then + /// Theme.of(context).colorScheme.primary is used. + final Color? selectedColor; + + /// The color for descendant [Text] and [Icon] widgets if the button is + /// disabled. + /// + /// If [onPressed] is null, this color will be used. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).disabledColor is used. If + /// [ToggleButtonsThemeData.disabledColor] is also null, then + /// Theme.of(context).colorScheme.onSurface.withOpacity(0.38) is used. + final Color? disabledColor; + + /// The fill color for selected toggle buttons. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).fillColor is used. If + /// [ToggleButtonsThemeData.fillColor] is also null, then + /// the fill color is null. + /// + /// If fillColor is a [WidgetStateProperty<Color>], then [WidgetStateProperty.resolve] + /// is used for the following [WidgetState]s: + /// + /// * [WidgetState.disabled] + /// * [WidgetState.selected] + /// + final Color? fillColor; + + /// The color to use for filling the button when the button has input focus. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).focusColor is used. If + /// [ToggleButtonsThemeData.focusColor] is also null, then + /// Theme.of(context).focusColor is used. + final Color? focusColor; + + /// The highlight color for the button's [InkWell]. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).highlightColor is used. If + /// [ToggleButtonsThemeData.highlightColor] is also null, then + /// Theme.of(context).highlightColor is used. + final Color? highlightColor; + + /// The splash color for the button's [InkWell]. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).splashColor is used. If + /// [ToggleButtonsThemeData.splashColor] is also null, then + /// Theme.of(context).splashColor is used. + final Color? splashColor; + + /// The color to use for filling the button when the button has a pointer + /// hovering over it. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).hoverColor is used. If + /// [ToggleButtonsThemeData.hoverColor] is also null, then + /// Theme.of(context).hoverColor is used. + final Color? hoverColor; + + /// The list of [FocusNode]s, corresponding to each toggle button. + /// + /// Focus is used to determine which widget should be affected by keyboard + /// events. The focus tree keeps track of which widget is currently focused + /// on by the user. + /// + /// If not null, the length of focusNodes has to match the length of + /// [children]. + /// + /// See [FocusNode] for more information about how focus nodes are used. + final List<FocusNode>? focusNodes; + + /// Whether or not to render a border around each toggle button. + /// + /// When true, a border with [borderWidth], [borderRadius] and the + /// appropriate border color will render. Otherwise, no border will be + /// rendered. + final bool renderBorder; + + /// The border color to display when the toggle button is enabled and not + /// selected. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).borderColor is used. If + /// [ToggleButtonsThemeData.borderColor] is also null, then + /// Theme.of(context).colorScheme.onSurface is used. + final Color? borderColor; + + /// The border color to display when the toggle button is selected. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).selectedBorderColor is used. If + /// [ToggleButtonsThemeData.selectedBorderColor] is also null, then + /// Theme.of(context).colorScheme.primary is used. + final Color? selectedBorderColor; + + /// The border color to display when the toggle button is disabled. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).disabledBorderColor is used. If + /// [ToggleButtonsThemeData.disabledBorderColor] is also null, then + /// Theme.of(context).disabledBorderColor is used. + final Color? disabledBorderColor; + + /// The width of the border surrounding each toggle button. + /// + /// This applies to both the greater surrounding border, as well as the + /// borders rendered between toggle buttons. + /// + /// To render a hairline border (one physical pixel), set borderWidth to 0.0. + /// See [BorderSide.width] for more details on hairline borders. + /// + /// To omit the border entirely, set [renderBorder] to false. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).borderWidth is used. If + /// [ToggleButtonsThemeData.borderWidth] is also null, then + /// a width of 1.0 is used. + final double? borderWidth; + + /// The radii of the border's corners. + /// + /// If this property is null, then + /// ToggleButtonTheme.of(context).borderRadius is used. If + /// [ToggleButtonsThemeData.borderRadius] is also null, then + /// the buttons default to non-rounded borders. + final BorderRadius? borderRadius; + + /// The direction along which the buttons are rendered. + /// + /// Defaults to [Axis.horizontal]. + final Axis direction; + + /// If [direction] is [Axis.vertical], this parameter determines whether to lay out + /// the buttons starting from the first or last child from top to bottom. + final VerticalDirection verticalDirection; + + // Determines if this is the first child that is being laid out + // by the render object, _not_ the order of the children in its list. + bool _isFirstButton(int index, int length, TextDirection textDirection) { + switch (direction) { + case Axis.horizontal: + return switch (textDirection) { + TextDirection.rtl => index == length - 1, + TextDirection.ltr => index == 0, + }; + case Axis.vertical: + return switch (verticalDirection) { + VerticalDirection.up => index == length - 1, + VerticalDirection.down => index == 0, + }; + } + } + + // Determines if this is the last child that is being laid out + // by the render object, _not_ the order of the children in its list. + bool _isLastButton(int index, int length, TextDirection textDirection) { + switch (direction) { + case Axis.horizontal: + return switch (textDirection) { + TextDirection.rtl => index == 0, + TextDirection.ltr => index == length - 1, + }; + case Axis.vertical: + return switch (verticalDirection) { + VerticalDirection.up => index == 0, + VerticalDirection.down => index == length - 1, + }; + } + } + + BorderRadius _getEdgeBorderRadius( + int index, + int length, + TextDirection textDirection, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + final BorderRadius resultingBorderRadius = + borderRadius ?? toggleButtonsTheme.borderRadius ?? BorderRadius.zero; + + if (length == 1) { + return resultingBorderRadius; + } else if (direction == Axis.horizontal) { + if (_isFirstButton(index, length, textDirection)) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft, + bottomLeft: resultingBorderRadius.bottomLeft, + ); + } else if (_isLastButton(index, length, textDirection)) { + return BorderRadius.only( + topRight: resultingBorderRadius.topRight, + bottomRight: resultingBorderRadius.bottomRight, + ); + } + } else { + if (_isFirstButton(index, length, textDirection)) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft, + topRight: resultingBorderRadius.topRight, + ); + } else if (_isLastButton(index, length, textDirection)) { + return BorderRadius.only( + bottomLeft: resultingBorderRadius.bottomLeft, + bottomRight: resultingBorderRadius.bottomRight, + ); + } + } + + return BorderRadius.zero; + } + + BorderRadius _getClipBorderRadius( + int index, + int length, + TextDirection textDirection, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + final BorderRadius resultingBorderRadius = + borderRadius ?? toggleButtonsTheme.borderRadius ?? BorderRadius.zero; + final double resultingBorderWidth = + borderWidth ?? toggleButtonsTheme.borderWidth ?? _defaultBorderWidth; + + if (length == 1) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft - Radius.circular(resultingBorderWidth / 2.0), + bottomLeft: resultingBorderRadius.bottomLeft - Radius.circular(resultingBorderWidth / 2.0), + topRight: resultingBorderRadius.topRight - Radius.circular(resultingBorderWidth / 2.0), + bottomRight: + resultingBorderRadius.bottomRight - Radius.circular(resultingBorderWidth / 2.0), + ); + } else if (direction == Axis.horizontal) { + if (_isFirstButton(index, length, textDirection)) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft - Radius.circular(resultingBorderWidth / 2.0), + bottomLeft: + resultingBorderRadius.bottomLeft - Radius.circular(resultingBorderWidth / 2.0), + ); + } else if (_isLastButton(index, length, textDirection)) { + return BorderRadius.only( + topRight: resultingBorderRadius.topRight - Radius.circular(resultingBorderWidth / 2.0), + bottomRight: + resultingBorderRadius.bottomRight - Radius.circular(resultingBorderWidth / 2.0), + ); + } + } else { + if (_isFirstButton(index, length, textDirection)) { + return BorderRadius.only( + topLeft: resultingBorderRadius.topLeft - Radius.circular(resultingBorderWidth / 2.0), + topRight: resultingBorderRadius.topRight - Radius.circular(resultingBorderWidth / 2.0), + ); + } else if (_isLastButton(index, length, textDirection)) { + return BorderRadius.only( + bottomLeft: + resultingBorderRadius.bottomLeft - Radius.circular(resultingBorderWidth / 2.0), + bottomRight: + resultingBorderRadius.bottomRight - Radius.circular(resultingBorderWidth / 2.0), + ); + } + } + return BorderRadius.zero; + } + + BorderSide _getLeadingBorderSide( + int index, + ThemeData theme, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + if (!renderBorder) { + return BorderSide.none; + } + + final double resultingBorderWidth = + borderWidth ?? toggleButtonsTheme.borderWidth ?? _defaultBorderWidth; + if (onPressed != null && (isSelected[index] || (index != 0 && isSelected[index - 1]))) { + return BorderSide( + color: + selectedBorderColor ?? + toggleButtonsTheme.selectedBorderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else if (onPressed != null && !isSelected[index]) { + return BorderSide( + color: + borderColor ?? + toggleButtonsTheme.borderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else { + return BorderSide( + color: + disabledBorderColor ?? + toggleButtonsTheme.disabledBorderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } + } + + BorderSide _getBorderSide(int index, ThemeData theme, ToggleButtonsThemeData toggleButtonsTheme) { + if (!renderBorder) { + return BorderSide.none; + } + + final double resultingBorderWidth = + borderWidth ?? toggleButtonsTheme.borderWidth ?? _defaultBorderWidth; + if (onPressed != null && isSelected[index]) { + return BorderSide( + color: + selectedBorderColor ?? + toggleButtonsTheme.selectedBorderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else if (onPressed != null && !isSelected[index]) { + return BorderSide( + color: + borderColor ?? + toggleButtonsTheme.borderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else { + return BorderSide( + color: + disabledBorderColor ?? + toggleButtonsTheme.disabledBorderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } + } + + BorderSide _getTrailingBorderSide( + int index, + ThemeData theme, + ToggleButtonsThemeData toggleButtonsTheme, + ) { + if (!renderBorder) { + return BorderSide.none; + } + + if (index != children.length - 1) { + return BorderSide.none; + } + + final double resultingBorderWidth = + borderWidth ?? toggleButtonsTheme.borderWidth ?? _defaultBorderWidth; + if (onPressed != null && (isSelected[index])) { + return BorderSide( + color: + selectedBorderColor ?? + toggleButtonsTheme.selectedBorderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else if (onPressed != null && !isSelected[index]) { + return BorderSide( + color: + borderColor ?? + toggleButtonsTheme.borderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } else { + return BorderSide( + color: + disabledBorderColor ?? + toggleButtonsTheme.disabledBorderColor ?? + theme.colorScheme.onSurface.withOpacity(0.12), + width: resultingBorderWidth, + ); + } + } + + @override + Widget build(BuildContext context) { + assert( + () { + if (focusNodes != null) { + return focusNodes!.length == children.length; + } + return true; + }(), + 'focusNodes.length must match children.length.\n' + 'There are ${focusNodes!.length} focus nodes, while ' + 'there are ${children.length} children.', + ); + final ThemeData theme = Theme.of(context); + final ToggleButtonsThemeData toggleButtonsTheme = ToggleButtonsTheme.of(context); + final TextDirection textDirection = Directionality.of(context); + + final buttons = List<Widget>.generate(children.length, (int index) { + final BorderRadius edgeBorderRadius = _getEdgeBorderRadius( + index, + children.length, + textDirection, + toggleButtonsTheme, + ); + final BorderRadius clipBorderRadius = _getClipBorderRadius( + index, + children.length, + textDirection, + toggleButtonsTheme, + ); + + final BorderSide leadingBorderSide = _getLeadingBorderSide(index, theme, toggleButtonsTheme); + final BorderSide borderSide = _getBorderSide(index, theme, toggleButtonsTheme); + final BorderSide trailingBorderSide = _getTrailingBorderSide( + index, + theme, + toggleButtonsTheme, + ); + + final states = <WidgetState>{ + if (isSelected[index] && onPressed != null) WidgetState.selected, + if (onPressed == null) WidgetState.disabled, + }; + final Color effectiveFillColor = + _ResolveFillColor(fillColor ?? toggleButtonsTheme.fillColor).resolve(states) ?? + _DefaultFillColor(theme.colorScheme).resolve(states); + final Color currentColor; + if (onPressed != null && isSelected[index]) { + currentColor = + selectedColor ?? toggleButtonsTheme.selectedColor ?? theme.colorScheme.primary; + } else if (onPressed != null && !isSelected[index]) { + currentColor = + color ?? toggleButtonsTheme.color ?? theme.colorScheme.onSurface.withOpacity(0.87); + } else { + currentColor = + disabledColor ?? + toggleButtonsTheme.disabledColor ?? + theme.colorScheme.onSurface.withOpacity(0.38); + } + final TextStyle currentTextStyle = + textStyle ?? toggleButtonsTheme.textStyle ?? theme.textTheme.bodyMedium!; + final BoxConstraints? currentConstraints = constraints ?? toggleButtonsTheme.constraints; + final Size minimumSize = + currentConstraints?.smallest ?? const Size.square(kMinInteractiveDimension); + final Size? maximumSize = currentConstraints?.biggest; + final Size minPaddingSize; + switch (tapTargetSize ?? theme.materialTapTargetSize) { + case MaterialTapTargetSize.padded: + minPaddingSize = switch (direction) { + Axis.horizontal => const Size(0.0, kMinInteractiveDimension), + Axis.vertical => const Size(kMinInteractiveDimension, 0.0), + }; + assert(minPaddingSize.width >= 0.0); + assert(minPaddingSize.height >= 0.0); + case MaterialTapTargetSize.shrinkWrap: + minPaddingSize = Size.zero; + } + + Widget button = _SelectToggleButton( + leadingBorderSide: leadingBorderSide, + borderSide: borderSide, + trailingBorderSide: trailingBorderSide, + borderRadius: edgeBorderRadius, + isFirstButton: index == 0, + isLastButton: index == children.length - 1, + direction: direction, + verticalDirection: verticalDirection, + child: ClipRRect( + borderRadius: clipBorderRadius, + child: TextButton( + focusNode: focusNodes != null ? focusNodes![index] : null, + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(effectiveFillColor), + foregroundColor: MaterialStatePropertyAll<Color?>(currentColor), + iconSize: const MaterialStatePropertyAll<double>(24.0), + iconColor: MaterialStatePropertyAll<Color?>(currentColor), + overlayColor: _ToggleButtonDefaultOverlay( + selected: onPressed != null && isSelected[index], + unselected: onPressed != null && !isSelected[index], + colorScheme: theme.colorScheme, + disabledColor: disabledColor ?? toggleButtonsTheme.disabledColor, + focusColor: focusColor ?? toggleButtonsTheme.focusColor, + highlightColor: highlightColor ?? toggleButtonsTheme.highlightColor, + hoverColor: hoverColor ?? toggleButtonsTheme.hoverColor, + splashColor: splashColor ?? toggleButtonsTheme.splashColor, + ), + elevation: const MaterialStatePropertyAll<double>(0), + textStyle: MaterialStatePropertyAll<TextStyle?>( + currentTextStyle.copyWith(color: currentColor), + ), + padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.zero), + minimumSize: MaterialStatePropertyAll<Size?>(minimumSize), + maximumSize: MaterialStatePropertyAll<Size?>(maximumSize), + shape: const MaterialStatePropertyAll<OutlinedBorder>(RoundedRectangleBorder()), + mouseCursor: MaterialStatePropertyAll<MouseCursor?>(mouseCursor), + visualDensity: VisualDensity.standard, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + splashFactory: InkRipple.splashFactory, + ), + onPressed: onPressed != null + ? () { + onPressed!(index); + } + : null, + child: children[index], + ), + ), + ); + + if (currentConstraints != null) { + button = Center(child: button); + } + + return MergeSemantics( + child: Semantics( + container: true, + checked: isSelected[index], + enabled: onPressed != null, + child: _InputPadding(minSize: minPaddingSize, direction: direction, child: button), + ), + ); + }); + + if (direction == Axis.vertical) { + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + verticalDirection: verticalDirection, + children: buttons, + ), + ); + } + + return IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: buttons, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty( + 'disabled', + value: onPressed == null, + ifTrue: 'Buttons are disabled', + ifFalse: 'Buttons are enabled', + ), + ); + textStyle?.debugFillProperties(properties, prefix: 'textStyle.'); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('fillColor', fillColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(ColorProperty('borderColor', borderColor, defaultValue: null)); + properties.add(ColorProperty('selectedBorderColor', selectedBorderColor, defaultValue: null)); + properties.add(ColorProperty('disabledBorderColor', disabledBorderColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<BorderRadius>('borderRadius', borderRadius, defaultValue: null), + ); + properties.add(DoubleProperty('borderWidth', borderWidth, defaultValue: null)); + properties.add( + DiagnosticsProperty<Axis>('direction', direction, defaultValue: Axis.horizontal), + ); + properties.add( + DiagnosticsProperty<VerticalDirection>( + 'verticalDirection', + verticalDirection, + defaultValue: VerticalDirection.down, + ), + ); + } +} + +@immutable +class _ResolveFillColor extends WidgetStateProperty<Color?> with Diagnosticable { + _ResolveFillColor(this.primary); + + final Color? primary; + + @override + Color? resolve(Set<WidgetState> states) { + if (primary is WidgetStateProperty<Color>) { + return WidgetStateProperty.resolveAs<Color?>(primary, states); + } + return states.contains(WidgetState.selected) ? primary : null; + } +} + +@immutable +class _DefaultFillColor extends WidgetStateProperty<Color> with Diagnosticable { + _DefaultFillColor(this.colorScheme); + + final ColorScheme colorScheme; + + @override + Color resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary.withOpacity(0.12); + } + return colorScheme.surface.withOpacity(0.0); + } +} + +@immutable +class _ToggleButtonDefaultOverlay extends WidgetStateProperty<Color?> { + _ToggleButtonDefaultOverlay({ + required this.selected, + required this.unselected, + this.colorScheme, + this.focusColor, + this.highlightColor, + this.hoverColor, + this.splashColor, + this.disabledColor, + }); + + final bool selected; + final bool unselected; + final ColorScheme? colorScheme; + final Color? focusColor; + final Color? highlightColor; + final Color? hoverColor; + final Color? splashColor; + final Color? disabledColor; + + @override + Color? resolve(Set<WidgetState> states) { + if (selected) { + if (states.contains(WidgetState.pressed)) { + return splashColor ?? colorScheme?.primary.withOpacity(0.16); + } + if (states.contains(WidgetState.hovered)) { + return hoverColor ?? colorScheme?.primary.withOpacity(0.04); + } + if (states.contains(WidgetState.focused)) { + return focusColor ?? colorScheme?.primary.withOpacity(0.12); + } + } else if (unselected) { + if (states.contains(WidgetState.pressed)) { + return splashColor ?? highlightColor ?? colorScheme?.onSurface.withOpacity(0.16); + } + if (states.contains(WidgetState.hovered)) { + return hoverColor ?? colorScheme?.onSurface.withOpacity(0.04); + } + if (states.contains(WidgetState.focused)) { + return focusColor ?? colorScheme?.onSurface.withOpacity(0.12); + } + } + return null; + } + + @override + String toString() { + return ''' + { + selected: + hovered: $hoverColor, otherwise: ${colorScheme?.primary.withOpacity(0.04)}, + focused: $focusColor, otherwise: ${colorScheme?.primary.withOpacity(0.12)}, + pressed: $splashColor, otherwise: ${colorScheme?.primary.withOpacity(0.16)}, + unselected: + hovered: $hoverColor, otherwise: ${colorScheme?.onSurface.withOpacity(0.04)}, + focused: $focusColor, otherwise: ${colorScheme?.onSurface.withOpacity(0.12)}, + pressed: $splashColor, otherwise: ${colorScheme?.onSurface.withOpacity(0.16)}, + otherwise: null, + } + '''; + } +} + +class _SelectToggleButton extends SingleChildRenderObjectWidget { + const _SelectToggleButton({ + required Widget super.child, + required this.leadingBorderSide, + required this.borderSide, + required this.trailingBorderSide, + required this.borderRadius, + required this.isFirstButton, + required this.isLastButton, + required this.direction, + required this.verticalDirection, + }); + + // The width and color of the button's leading side border. + final BorderSide leadingBorderSide; + + // The width and color of the side borders. + // + // If [direction] is [Axis.horizontal], this corresponds to the width and color + // of the button's top and bottom side borders. + // + // If [direction] is [Axis.vertical], this corresponds to the width and color + // of the button's left and right side borders. + final BorderSide borderSide; + + // The width and color of the button's trailing side border. + final BorderSide trailingBorderSide; + + // The border radii of each corner of the button. + final BorderRadius borderRadius; + + // Whether or not this toggle button is the first button in the list. + final bool isFirstButton; + + // Whether or not this toggle button is the last button in the list. + final bool isLastButton; + + // The direction along which the buttons are rendered. + final Axis direction; + + // If [direction] is [Axis.vertical], this property defines whether or not this button in its list + // of buttons is laid out starting from top to bottom or from bottom to top. + final VerticalDirection verticalDirection; + + @override + _SelectToggleButtonRenderObject createRenderObject(BuildContext context) => + _SelectToggleButtonRenderObject( + leadingBorderSide, + borderSide, + trailingBorderSide, + borderRadius, + isFirstButton, + isLastButton, + direction, + verticalDirection, + Directionality.of(context), + ); + + @override + void updateRenderObject(BuildContext context, _SelectToggleButtonRenderObject renderObject) { + renderObject + ..leadingBorderSide = leadingBorderSide + ..borderSide = borderSide + ..trailingBorderSide = trailingBorderSide + ..borderRadius = borderRadius + ..isFirstButton = isFirstButton + ..isLastButton = isLastButton + ..direction = direction + ..verticalDirection = verticalDirection + ..textDirection = Directionality.of(context); + } +} + +class _SelectToggleButtonRenderObject extends RenderShiftedBox { + _SelectToggleButtonRenderObject( + this._leadingBorderSide, + this._borderSide, + this._trailingBorderSide, + this._borderRadius, + this._isFirstButton, + this._isLastButton, + this._direction, + this._verticalDirection, + this._textDirection, [ + RenderBox? child, + ]) : super(child); + + Axis get direction => _direction; + Axis _direction; + set direction(Axis value) { + if (_direction == value) { + return; + } + _direction = value; + markNeedsLayout(); + } + + VerticalDirection get verticalDirection => _verticalDirection; + VerticalDirection _verticalDirection; + set verticalDirection(VerticalDirection value) { + if (_verticalDirection == value) { + return; + } + _verticalDirection = value; + markNeedsLayout(); + } + + // The width and color of the button's leading side border. + BorderSide get leadingBorderSide => _leadingBorderSide; + BorderSide _leadingBorderSide; + set leadingBorderSide(BorderSide value) { + if (_leadingBorderSide == value) { + return; + } + _leadingBorderSide = value; + markNeedsLayout(); + } + + // The width and color of the button's top and bottom side borders. + BorderSide get borderSide => _borderSide; + BorderSide _borderSide; + set borderSide(BorderSide value) { + if (_borderSide == value) { + return; + } + _borderSide = value; + markNeedsLayout(); + } + + // The width and color of the button's trailing side border. + BorderSide get trailingBorderSide => _trailingBorderSide; + BorderSide _trailingBorderSide; + set trailingBorderSide(BorderSide value) { + if (_trailingBorderSide == value) { + return; + } + _trailingBorderSide = value; + markNeedsLayout(); + } + + // The border radii of each corner of the button. + BorderRadius get borderRadius => _borderRadius; + BorderRadius _borderRadius; + set borderRadius(BorderRadius value) { + if (_borderRadius == value) { + return; + } + _borderRadius = value; + markNeedsLayout(); + } + + // Whether or not this toggle button is the first button in the list. + bool get isFirstButton => _isFirstButton; + bool _isFirstButton; + set isFirstButton(bool value) { + if (_isFirstButton == value) { + return; + } + _isFirstButton = value; + markNeedsLayout(); + } + + // Whether or not this toggle button is the last button in the list. + bool get isLastButton => _isLastButton; + bool _isLastButton; + set isLastButton(bool value) { + if (_isLastButton == value) { + return; + } + _isLastButton = value; + markNeedsLayout(); + } + + // The direction in which text flows for this application. + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + static double _maxHeight(RenderBox? box, double width) { + return box?.getMaxIntrinsicHeight(width) ?? 0.0; + } + + static double _minHeight(RenderBox? box, double width) { + return box?.getMinIntrinsicHeight(width) ?? 0.0; + } + + static double _minWidth(RenderBox? box, double height) { + return box?.getMinIntrinsicWidth(height) ?? 0.0; + } + + static double _maxWidth(RenderBox? box, double height) { + return box?.getMaxIntrinsicWidth(height) ?? 0.0; + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + // The baseline of this widget is the baseline of its child + final childOffset = BaselineOffset(child?.getDistanceToActualBaseline(baseline)); + return switch (direction) { + Axis.horizontal => childOffset + borderSide.width, + Axis.vertical => + childOffset + + switch (verticalDirection) { + VerticalDirection.down => leadingBorderSide.width, + VerticalDirection.up => trailingBorderSide.width, + }, + }.offset; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return direction == Axis.horizontal + ? borderSide.width * 2.0 + _maxHeight(child, width) + : leadingBorderSide.width + _maxHeight(child, width) + trailingBorderSide.width; + } + + @override + double computeMinIntrinsicHeight(double width) { + return direction == Axis.horizontal + ? borderSide.width * 2.0 + _minHeight(child, width) + : leadingBorderSide.width + _maxHeight(child, width) + trailingBorderSide.width; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return direction == Axis.horizontal + ? leadingBorderSide.width + _maxWidth(child, height) + trailingBorderSide.width + : borderSide.width * 2.0 + _maxWidth(child, height); + } + + @override + double computeMinIntrinsicWidth(double height) { + return direction == Axis.horizontal + ? leadingBorderSide.width + _minWidth(child, height) + trailingBorderSide.width + : borderSide.width * 2.0 + _minWidth(child, height); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.dryLayoutChild); + } + + EdgeInsetsDirectional get _childPadding { + assert(child != null); + // It does not matter what [textDirection] or [verticalDirection] is, + // since deflating the size constraints horizontally/vertically + // and the returned size accounts for the width of both sides. + return switch (direction) { + Axis.horizontal => EdgeInsetsDirectional.only( + start: leadingBorderSide.width, + end: trailingBorderSide.width, + top: borderSide.width, + bottom: borderSide.width, + ), + Axis.vertical => EdgeInsetsDirectional.only( + start: borderSide.width, + end: borderSide.width, + top: leadingBorderSide.width, + bottom: trailingBorderSide.width, + ), + }; + } + + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + final double? childBaseline = child?.getDryBaseline( + constraints.deflate(_childPadding), + baseline, + ); + if (childBaseline == null) { + return null; + } + return childBaseline + + switch (direction) { + Axis.horizontal => borderSide.width, + Axis.vertical => switch (verticalDirection) { + VerticalDirection.down => leadingBorderSide.width, + VerticalDirection.up => trailingBorderSide.width, + }, + }; + } + + @override + void performLayout() { + size = _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.layoutChild); + if (child == null) { + return; + } + final childParentData = child!.parentData! as BoxParentData; + if (direction == Axis.horizontal) { + childParentData.offset = switch (textDirection) { + TextDirection.ltr => Offset(leadingBorderSide.width, borderSide.width), + TextDirection.rtl => Offset(trailingBorderSide.width, borderSide.width), + }; + } else { + childParentData.offset = switch (verticalDirection) { + VerticalDirection.down => Offset(borderSide.width, leadingBorderSide.width), + VerticalDirection.up => Offset(borderSide.width, trailingBorderSide.width), + }; + } + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + final RenderBox? child = this.child; + if (child == null) { + final horizontalSize = Size( + leadingBorderSide.width + trailingBorderSide.width, + borderSide.width * 2.0, + ); + return switch (direction) { + Axis.horizontal => constraints.constrain(horizontalSize), + Axis.vertical => constraints.constrain(horizontalSize.flipped), + }; + } + + final EdgeInsetsDirectional childPadding = _childPadding; + final BoxConstraints innerConstraints = constraints.deflate(childPadding); + return constraints.constrain(childPadding.inflateSize(layoutChild(child, innerConstraints))); + } + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + final Offset bottomRight = size.bottomRight(offset); + final outer = Rect.fromLTRB(offset.dx, offset.dy, bottomRight.dx, bottomRight.dy); + final Rect center = outer.deflate(borderSide.width / 2.0); + const double sweepAngle = math.pi / 2.0; + final RRect rrect = RRect.fromRectAndCorners( + center, + topLeft: (borderRadius.topLeft.x * borderRadius.topLeft.y != 0.0) + ? borderRadius.topLeft + : Radius.zero, + topRight: (borderRadius.topRight.x * borderRadius.topRight.y != 0.0) + ? borderRadius.topRight + : Radius.zero, + bottomLeft: (borderRadius.bottomLeft.x * borderRadius.bottomLeft.y != 0.0) + ? borderRadius.bottomLeft + : Radius.zero, + bottomRight: (borderRadius.bottomRight.x * borderRadius.bottomRight.y != 0.0) + ? borderRadius.bottomRight + : Radius.zero, + ).scaleRadii(); + + final tlCorner = Rect.fromLTWH( + rrect.left, + rrect.top, + rrect.tlRadiusX * 2.0, + rrect.tlRadiusY * 2.0, + ); + final blCorner = Rect.fromLTWH( + rrect.left, + rrect.bottom - (rrect.blRadiusY * 2.0), + rrect.blRadiusX * 2.0, + rrect.blRadiusY * 2.0, + ); + final trCorner = Rect.fromLTWH( + rrect.right - (rrect.trRadiusX * 2), + rrect.top, + rrect.trRadiusX * 2, + rrect.trRadiusY * 2, + ); + final brCorner = Rect.fromLTWH( + rrect.right - (rrect.brRadiusX * 2), + rrect.bottom - (rrect.brRadiusY * 2), + rrect.brRadiusX * 2, + rrect.brRadiusY * 2, + ); + + final Paint leadingPaint = leadingBorderSide.toPaint(); + // Only one button. + if (isFirstButton && isLastButton) { + final leadingPath = Path(); + final double startX = (rrect.brRadiusX == 0.0) ? outer.right : rrect.right - rrect.brRadiusX; + leadingPath + ..moveTo(startX, rrect.bottom) + ..lineTo(rrect.left + rrect.blRadiusX, rrect.bottom) + ..addArc(blCorner, math.pi / 2.0, sweepAngle) + ..lineTo(rrect.left, rrect.top + rrect.tlRadiusY) + ..addArc(tlCorner, math.pi, sweepAngle) + ..lineTo(rrect.right - rrect.trRadiusX, rrect.top) + ..addArc(trCorner, math.pi * 3.0 / 2.0, sweepAngle) + ..lineTo(rrect.right, rrect.bottom - rrect.brRadiusY) + ..addArc(brCorner, 0, sweepAngle); + context.canvas.drawPath(leadingPath, leadingPaint); + return; + } + + if (direction == Axis.horizontal) { + switch (textDirection) { + case TextDirection.ltr: + if (isLastButton) { + final leftPath = Path(); + leftPath + ..moveTo(rrect.left, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.left, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leftPath, leadingPaint); + + final Paint endingPaint = trailingBorderSide.toPaint(); + final endingPath = Path(); + endingPath + ..moveTo(rrect.left + borderSide.width / 2.0, rrect.top) + ..lineTo(rrect.right - rrect.trRadiusX, rrect.top) + ..addArc(trCorner, math.pi * 3.0 / 2.0, sweepAngle) + ..lineTo(rrect.right, rrect.bottom - rrect.brRadiusY) + ..addArc(brCorner, 0, sweepAngle) + ..lineTo(rrect.left + borderSide.width / 2.0, rrect.bottom); + context.canvas.drawPath(endingPath, endingPaint); + } else if (isFirstButton) { + final leadingPath = Path(); + leadingPath + ..moveTo(outer.right, rrect.bottom) + ..lineTo(rrect.left + rrect.blRadiusX, rrect.bottom) + ..addArc(blCorner, math.pi / 2.0, sweepAngle) + ..lineTo(rrect.left, rrect.top + rrect.tlRadiusY) + ..addArc(tlCorner, math.pi, sweepAngle) + ..lineTo(outer.right, rrect.top); + context.canvas.drawPath(leadingPath, leadingPaint); + } else { + final leadingPath = Path(); + leadingPath + ..moveTo(rrect.left, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.left, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leadingPath, leadingPaint); + + final Paint horizontalPaint = borderSide.toPaint(); + final horizontalPaths = Path(); + horizontalPaths + ..moveTo(rrect.left + borderSide.width / 2.0, rrect.top) + ..lineTo(outer.right - rrect.trRadiusX, rrect.top) + ..moveTo(rrect.left + borderSide.width / 2.0 + rrect.tlRadiusX, rrect.bottom) + ..lineTo(outer.right - rrect.trRadiusX, rrect.bottom); + context.canvas.drawPath(horizontalPaths, horizontalPaint); + } + case TextDirection.rtl: + if (isLastButton) { + final leadingPath = Path(); + leadingPath + ..moveTo(rrect.right, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.right, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leadingPath, leadingPaint); + + final Paint endingPaint = trailingBorderSide.toPaint(); + final endingPath = Path(); + endingPath + ..moveTo(rrect.right - borderSide.width / 2.0, rrect.top) + ..lineTo(rrect.left + rrect.tlRadiusX, rrect.top) + ..addArc(tlCorner, math.pi * 3.0 / 2.0, -sweepAngle) + ..lineTo(rrect.left, rrect.bottom - rrect.blRadiusY) + ..addArc(blCorner, math.pi, -sweepAngle) + ..lineTo(rrect.right - borderSide.width / 2.0, rrect.bottom); + context.canvas.drawPath(endingPath, endingPaint); + } else if (isFirstButton) { + final leadingPath = Path(); + leadingPath + ..moveTo(outer.left, rrect.bottom) + ..lineTo(rrect.right - rrect.brRadiusX, rrect.bottom) + ..addArc(brCorner, math.pi / 2.0, -sweepAngle) + ..lineTo(rrect.right, rrect.top + rrect.trRadiusY) + ..addArc(trCorner, 0, -sweepAngle) + ..lineTo(outer.left, rrect.top); + context.canvas.drawPath(leadingPath, leadingPaint); + } else { + final leadingPath = Path(); + leadingPath + ..moveTo(rrect.right, rrect.bottom + leadingBorderSide.width / 2) + ..lineTo(rrect.right, rrect.top - leadingBorderSide.width / 2); + context.canvas.drawPath(leadingPath, leadingPaint); + + final Paint horizontalPaint = borderSide.toPaint(); + final horizontalPaths = Path(); + horizontalPaths + ..moveTo(rrect.right - borderSide.width / 2.0, rrect.top) + ..lineTo(outer.left - rrect.tlRadiusX, rrect.top) + ..moveTo(rrect.right - borderSide.width / 2.0 + rrect.trRadiusX, rrect.bottom) + ..lineTo(outer.left - rrect.tlRadiusX, rrect.bottom); + context.canvas.drawPath(horizontalPaths, horizontalPaint); + } + } + } else { + switch (verticalDirection) { + case VerticalDirection.down: + if (isLastButton) { + final topPath = Path(); + topPath + ..moveTo(outer.left, outer.top + leadingBorderSide.width / 2) + ..lineTo(outer.right, outer.top + leadingBorderSide.width / 2); + context.canvas.drawPath(topPath, leadingPaint); + + final Paint endingPaint = trailingBorderSide.toPaint(); + final endingPath = Path(); + endingPath + ..moveTo(rrect.left, rrect.top + leadingBorderSide.width / 2.0) + ..lineTo(rrect.left, rrect.bottom - rrect.blRadiusY) + ..addArc(blCorner, math.pi * 3.0, -sweepAngle) + ..lineTo(rrect.right - rrect.blRadiusX, rrect.bottom) + ..addArc(brCorner, math.pi / 2.0, -sweepAngle) + ..lineTo(rrect.right, rrect.top + leadingBorderSide.width / 2.0); + context.canvas.drawPath(endingPath, endingPaint); + } else if (isFirstButton) { + final leadingPath = Path(); + leadingPath + ..moveTo(rrect.left, outer.bottom) + ..lineTo(rrect.left, rrect.top + rrect.tlRadiusX) + ..addArc(tlCorner, math.pi, sweepAngle) + ..lineTo(rrect.right - rrect.trRadiusX, rrect.top) + ..addArc(trCorner, math.pi * 3.0 / 2.0, sweepAngle) + ..lineTo(rrect.right, outer.bottom); + context.canvas.drawPath(leadingPath, leadingPaint); + } else { + final topPath = Path(); + topPath + ..moveTo(outer.left, outer.top + leadingBorderSide.width / 2) + ..lineTo(outer.right, outer.top + leadingBorderSide.width / 2); + context.canvas.drawPath(topPath, leadingPaint); + + final Paint paint = borderSide.toPaint(); + final paths = Path(); // Left and right borders. + paths + ..moveTo(rrect.left, outer.top + leadingBorderSide.width) + ..lineTo(rrect.left, outer.bottom) + ..moveTo(rrect.right, outer.top + leadingBorderSide.width) + ..lineTo(rrect.right, outer.bottom); + context.canvas.drawPath(paths, paint); + } + case VerticalDirection.up: + if (isLastButton) { + final bottomPath = Path(); + bottomPath + ..moveTo(outer.left, outer.bottom - leadingBorderSide.width / 2.0) + ..lineTo(outer.right, outer.bottom - leadingBorderSide.width / 2.0); + context.canvas.drawPath(bottomPath, leadingPaint); + + final Paint endingPaint = trailingBorderSide.toPaint(); + final endingPath = Path(); + endingPath + ..moveTo(rrect.left, rrect.bottom - leadingBorderSide.width / 2.0) + ..lineTo(rrect.left, rrect.top + rrect.tlRadiusY) + ..addArc(tlCorner, math.pi, sweepAngle) + ..lineTo(rrect.right - rrect.trRadiusX, rrect.top) + ..addArc(trCorner, math.pi * 3.0 / 2.0, sweepAngle) + ..lineTo(rrect.right, rrect.bottom - leadingBorderSide.width / 2.0); + context.canvas.drawPath(endingPath, endingPaint); + } else if (isFirstButton) { + final leadingPath = Path(); + leadingPath + ..moveTo(rrect.left, outer.top) + ..lineTo(rrect.left, rrect.bottom - rrect.blRadiusY) + ..addArc(blCorner, math.pi, -sweepAngle) + ..lineTo(rrect.right - rrect.brRadiusX, rrect.bottom) + ..addArc(brCorner, math.pi / 2.0, -sweepAngle) + ..lineTo(rrect.right, outer.top); + context.canvas.drawPath(leadingPath, leadingPaint); + } else { + final bottomPath = Path(); + bottomPath + ..moveTo(outer.left, outer.bottom - leadingBorderSide.width / 2.0) + ..lineTo(outer.right, outer.bottom - leadingBorderSide.width / 2.0); + context.canvas.drawPath(bottomPath, leadingPaint); + + final Paint paint = borderSide.toPaint(); + final paths = Path(); // Left and right borders. + paths + ..moveTo(rrect.left, outer.top) + ..lineTo(rrect.left, outer.bottom - leadingBorderSide.width) + ..moveTo(rrect.right, outer.top) + ..lineTo(rrect.right, outer.bottom - leadingBorderSide.width); + context.canvas.drawPath(paths, paint); + } + } + } + } +} + +/// A widget to pad the area around a [ToggleButtons]'s children. +/// +/// This widget is based on a similar one used in [ButtonStyleButton] but it +/// only redirects taps along one axis to ensure the correct button is tapped +/// within the [ToggleButtons]. +/// +/// This ensures that a widget takes up at least as much space as the minSize +/// parameter to ensure adequate tap target size, while keeping the widget +/// visually smaller to the user. +class _InputPadding extends SingleChildRenderObjectWidget { + const _InputPadding({super.child, required this.minSize, required this.direction}); + + final Size minSize; + final Axis direction; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize, direction); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + renderObject.direction = direction; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, this._direction, [RenderBox? child]) : super(child); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + Axis get direction => _direction; + Axis _direction; + set direction(Axis value) { + if (_direction == value) { + return; + } + _direction = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double width = math.max(childSize.width, minSize.width); + final double height = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(width, height)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.dryLayoutChild); + } + + @override + double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final double? result = child.getDryBaseline(constraints, baseline); + if (result == null) { + return null; + } + // Calculate the size and child offset using the same logic as performLayout + final Size drySize = getDryLayout(constraints); + final Size childSize = child.getDryLayout(constraints); + final Offset childOffset = Alignment.center.alongOffset(drySize - childSize as Offset); + return result + childOffset.dy; + } + + @override + void performLayout() { + size = _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.layoutChild); + if (child != null) { + final childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + // The super.hitTest() method also checks hitTestChildren(). We don't + // want that in this case because we've padded around the children per + // tapTargetSize. + if (!size.contains(position)) { + return false; + } + + // Only adjust one axis to ensure the correct button is tapped. + final Offset center = switch (direction) { + Axis.horizontal => Offset(position.dx, child!.size.height / 2), + Axis.vertical => Offset(child!.size.width / 2, position.dy), + }; + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == center); + return child!.hitTest(result, position: center); + }, + ); + } +} diff --git a/packages/material_ui/lib/src/toggle_buttons_theme.dart b/packages/material_ui/lib/src/toggle_buttons_theme.dart new file mode 100644 index 000000000000..b64e21789058 --- /dev/null +++ b/packages/material_ui/lib/src/toggle_buttons_theme.dart @@ -0,0 +1,288 @@ +// 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. + +/// @docImport 'ink_well.dart'; +/// @docImport 'toggle_buttons.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [ToggleButtons] widgets. +/// +/// Descendant widgets obtain the current [ToggleButtonsThemeData] object using +/// [ToggleButtonsTheme.of]. Instances of [ToggleButtonsThemeData] can be customized +/// with [ToggleButtonsThemeData.copyWith]. +/// +/// Typically a [ToggleButtonsThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.toggleButtonsTheme]. +/// +/// See also: +/// +/// * [ToggleButtonsTheme], which describes the actual configuration of a +/// toggle buttons theme. +@immutable +class ToggleButtonsThemeData with Diagnosticable { + /// Creates the set of color and border properties used to configure + /// [ToggleButtons]. + const ToggleButtonsThemeData({ + this.textStyle, + this.constraints, + this.color, + this.selectedColor, + this.disabledColor, + this.fillColor, + this.focusColor, + this.highlightColor, + this.hoverColor, + this.splashColor, + this.borderColor, + this.selectedBorderColor, + this.disabledBorderColor, + this.borderRadius, + this.borderWidth, + }); + + /// The default text style for [ToggleButtons.children]. + /// + /// [TextStyle.color] will be ignored and substituted by [color], + /// [selectedColor] or [disabledColor] depending on whether the buttons + /// are active, selected, or disabled. + final TextStyle? textStyle; + + /// Defines the button's size. + /// + /// Typically used to constrain the button's minimum size. + final BoxConstraints? constraints; + + /// The color for descendant [Text] and [Icon] widgets if the toggle button + /// is enabled. + final Color? color; + + /// The color for descendant [Text] and [Icon] widgets if the toggle button + /// is selected. + final Color? selectedColor; + + /// The color for descendant [Text] and [Icon] widgets if the toggle button + /// is disabled. + final Color? disabledColor; + + /// The fill color for selected toggle buttons. + final Color? fillColor; + + /// The color to use for filling the button when the button has input focus. + final Color? focusColor; + + /// The highlight color for the toggle button's [InkWell]. + final Color? highlightColor; + + /// The splash color for the toggle button's [InkWell]. + final Color? splashColor; + + /// The color to use for filling the toggle button when the button has a + /// pointer hovering over it. + final Color? hoverColor; + + /// The border color to display when the toggle button is enabled. + final Color? borderColor; + + /// The border color to display when the toggle button is selected. + final Color? selectedBorderColor; + + /// The border color to display when the toggle button is disabled. + final Color? disabledBorderColor; + + /// The width of the border surrounding each toggle button. + /// + /// This applies to both the greater surrounding border, as well as the + /// borders dividing each toggle button. + /// + /// To render a hairline border (one physical pixel), set borderWidth to 0.0. + /// See [BorderSide.width] for more details on hairline borders. + final double? borderWidth; + + /// The radii of the border's corners. + final BorderRadius? borderRadius; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + ToggleButtonsThemeData copyWith({ + TextStyle? textStyle, + BoxConstraints? constraints, + Color? color, + Color? selectedColor, + Color? disabledColor, + Color? fillColor, + Color? focusColor, + Color? highlightColor, + Color? hoverColor, + Color? splashColor, + Color? borderColor, + Color? selectedBorderColor, + Color? disabledBorderColor, + BorderRadius? borderRadius, + double? borderWidth, + }) { + return ToggleButtonsThemeData( + textStyle: textStyle ?? this.textStyle, + constraints: constraints ?? this.constraints, + color: color ?? this.color, + selectedColor: selectedColor ?? this.selectedColor, + disabledColor: disabledColor ?? this.disabledColor, + fillColor: fillColor ?? this.fillColor, + focusColor: focusColor ?? this.focusColor, + highlightColor: highlightColor ?? this.highlightColor, + hoverColor: hoverColor ?? this.hoverColor, + splashColor: splashColor ?? this.splashColor, + borderColor: borderColor ?? this.borderColor, + selectedBorderColor: selectedBorderColor ?? this.selectedBorderColor, + disabledBorderColor: disabledBorderColor ?? this.disabledBorderColor, + borderRadius: borderRadius ?? this.borderRadius, + borderWidth: borderWidth ?? this.borderWidth, + ); + } + + /// Linearly interpolate between two toggle buttons themes. + static ToggleButtonsThemeData? lerp( + ToggleButtonsThemeData? a, + ToggleButtonsThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + return ToggleButtonsThemeData( + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + color: Color.lerp(a?.color, b?.color, t), + selectedColor: Color.lerp(a?.selectedColor, b?.selectedColor, t), + disabledColor: Color.lerp(a?.disabledColor, b?.disabledColor, t), + fillColor: Color.lerp(a?.fillColor, b?.fillColor, t), + focusColor: Color.lerp(a?.focusColor, b?.focusColor, t), + highlightColor: Color.lerp(a?.highlightColor, b?.highlightColor, t), + hoverColor: Color.lerp(a?.hoverColor, b?.hoverColor, t), + splashColor: Color.lerp(a?.splashColor, b?.splashColor, t), + borderColor: Color.lerp(a?.borderColor, b?.borderColor, t), + selectedBorderColor: Color.lerp(a?.selectedBorderColor, b?.selectedBorderColor, t), + disabledBorderColor: Color.lerp(a?.disabledBorderColor, b?.disabledBorderColor, t), + borderRadius: BorderRadius.lerp(a?.borderRadius, b?.borderRadius, t), + borderWidth: lerpDouble(a?.borderWidth, b?.borderWidth, t), + ); + } + + @override + int get hashCode => Object.hash( + textStyle, + constraints, + color, + selectedColor, + disabledColor, + fillColor, + focusColor, + highlightColor, + hoverColor, + splashColor, + borderColor, + selectedBorderColor, + disabledBorderColor, + borderRadius, + borderWidth, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ToggleButtonsThemeData && + other.textStyle == textStyle && + other.constraints == constraints && + other.color == color && + other.selectedColor == selectedColor && + other.disabledColor == disabledColor && + other.fillColor == fillColor && + other.focusColor == focusColor && + other.highlightColor == highlightColor && + other.hoverColor == hoverColor && + other.splashColor == splashColor && + other.borderColor == borderColor && + other.selectedBorderColor == selectedBorderColor && + other.disabledBorderColor == disabledBorderColor && + other.borderRadius == borderRadius && + other.borderWidth == borderWidth; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + textStyle?.debugFillProperties(properties, prefix: 'textStyle.'); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('selectedColor', selectedColor, defaultValue: null)); + properties.add(ColorProperty('disabledColor', disabledColor, defaultValue: null)); + properties.add(ColorProperty('fillColor', fillColor, defaultValue: null)); + properties.add(ColorProperty('focusColor', focusColor, defaultValue: null)); + properties.add(ColorProperty('highlightColor', highlightColor, defaultValue: null)); + properties.add(ColorProperty('hoverColor', hoverColor, defaultValue: null)); + properties.add(ColorProperty('splashColor', splashColor, defaultValue: null)); + properties.add(ColorProperty('borderColor', borderColor, defaultValue: null)); + properties.add(ColorProperty('selectedBorderColor', selectedBorderColor, defaultValue: null)); + properties.add(ColorProperty('disabledBorderColor', disabledBorderColor, defaultValue: null)); + properties.add( + DiagnosticsProperty<BorderRadius>('borderRadius', borderRadius, defaultValue: null), + ); + properties.add(DoubleProperty('borderWidth', borderWidth, defaultValue: null)); + } +} + +/// An inherited widget that defines color and border parameters for +/// [ToggleButtons] in this widget's subtree. +/// +/// Values specified here are used for [ToggleButtons] properties that are not +/// given an explicit non-null value. +class ToggleButtonsTheme extends InheritedTheme { + /// Creates a toggle buttons theme that controls the color and border + /// parameters for [ToggleButtons]. + const ToggleButtonsTheme({super.key, required this.data, required super.child}); + + /// Specifies the color and border values for descendant [ToggleButtons] widgets. + final ToggleButtonsThemeData data; + + /// Retrieves the [ToggleButtonsThemeData] from the closest ancestor [ToggleButtonsTheme]. + /// + /// If there is no enclosing [ToggleButtonsTheme] widget, then + /// [ThemeData.toggleButtonsTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// ToggleButtonsThemeData theme = ToggleButtonsTheme.of(context); + /// ``` + static ToggleButtonsThemeData of(BuildContext context) { + final ToggleButtonsTheme? toggleButtonsTheme = context + .dependOnInheritedWidgetOfExactType<ToggleButtonsTheme>(); + return toggleButtonsTheme?.data ?? Theme.of(context).toggleButtonsTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return ToggleButtonsTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(ToggleButtonsTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/tooltip.dart b/packages/material_ui/lib/src/tooltip.dart new file mode 100644 index 000000000000..3ab6376355f6 --- /dev/null +++ b/packages/material_ui/lib/src/tooltip.dart @@ -0,0 +1,602 @@ +// 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. + +/// @docImport 'package:flutter/widgets.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'floating_action_button.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'popup_menu.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'text_theme.dart'; +import 'theme.dart'; +import 'tooltip_theme.dart'; +import 'tooltip_visibility.dart'; + +/// A Material Design tooltip. +/// +/// Tooltips provide text labels which help explain the function of a button or +/// other user interface action. Wrap the button in a [Tooltip] widget and provide +/// a message which will be shown when the widget is long pressed. +/// +/// Many widgets, such as [IconButton], [FloatingActionButton], and +/// [PopupMenuButton] have a `tooltip` property that, when non-null, causes the +/// widget to include a [Tooltip] in its build. +/// +/// Tooltips improve the accessibility of visual widgets by proving a textual +/// representation of the widget, which, for example, can be vocalized by a +/// screen reader. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=EeEfD5fI-5Q} +/// +/// {@tool dartpad} +/// This example show a basic [Tooltip] which has a [Text] as child. +/// [message] contains your label to be shown by the tooltip when +/// the child that Tooltip wraps is hovered over on web or desktop. On mobile, +/// the tooltip is shown when the widget is long pressed. +/// +/// This tooltip will default to showing above the [Text] instead of below +/// because its ambient [TooltipThemeData.preferBelow] is false. +/// (See the use of [MaterialApp.theme].) +/// Setting that piece of theme data is recommended to avoid having a finger or +/// cursor hide the tooltip. For other ways to set that piece of theme data see: +/// +/// * [Theme.data], [ThemeData.tooltipTheme] +/// * [TooltipTheme.data] +/// +/// or it can be set directly on each tooltip with [Tooltip.preferBelow]. +/// +/// ** See code in examples/api/lib/material/tooltip/tooltip.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example covers most of the attributes available in Tooltip. +/// `decoration` has been used to give a gradient and borderRadius to Tooltip. +/// `constraints` has been used to set the minimum width of the Tooltip. +/// `preferBelow` is true; the tooltip will prefer showing below [Tooltip]'s child widget. +/// However, it may show the tooltip above if there's not enough space +/// below the widget. +/// `textStyle` has been used to set the font size of the 'message'. +/// `showDuration` accepts a Duration to continue showing the message after the long +/// press has been released or the mouse pointer exits the child widget. +/// `waitDuration` accepts a Duration for which a mouse pointer has to hover over the child +/// widget before the tooltip is shown. +/// +/// ** See code in examples/api/lib/material/tooltip/tooltip.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a rich [Tooltip] that specifies the [richMessage] +/// parameter instead of the [message] parameter (only one of these may be +/// non-null. Any [InlineSpan] can be specified for the [richMessage] attribute, +/// including [WidgetSpan]. +/// +/// ** See code in examples/api/lib/material/tooltip/tooltip.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how [Tooltip] can be shown manually with [TooltipTriggerMode.manual] +/// by calling the [TooltipState.ensureTooltipVisible] function. +/// +/// ** See code in examples/api/lib/material/tooltip/tooltip.3.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * <https://material.io/design/components/tooltips.html> +/// * [TooltipTheme] or [ThemeData.tooltipTheme] +/// * [TooltipVisibility] +class Tooltip extends StatefulWidget { + /// Creates a tooltip. + /// + /// By default, tooltips should adhere to the + /// [Material specification](https://material.io/design/components/tooltips.html#spec). + /// If the optional constructor parameters are not defined, the values + /// provided by [TooltipTheme.of] will be used if a [TooltipTheme] is present + /// or specified in [ThemeData]. + /// + /// All parameters that are defined in the constructor will + /// override the default values _and_ the values in [TooltipTheme.of]. + /// + /// Only one of [message] and [richMessage] may be non-null. + const Tooltip({ + super.key, + this.message, + this.richMessage, + @Deprecated( + 'Use Tooltip.constraints instead. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + this.height, + this.constraints, + this.padding, + this.margin, + this.verticalOffset, + this.preferBelow, + this.excludeFromSemantics, + this.decoration, + this.textStyle, + this.textAlign, + this.waitDuration, + this.showDuration, + this.exitDuration, + this.enableTapToDismiss = true, + this.triggerMode, + this.enableFeedback, + this.onTriggered, + this.mouseCursor, + this.ignorePointer, + this.positionDelegate, + this.child, + }) : assert( + (message == null) != (richMessage == null), + 'Either `message` or `richMessage` must be specified', + ), + assert( + height == null || constraints == null, + 'Only one of `height` and `constraints` may be specified.', + ); + + /// The text to display in the tooltip. + /// + /// Only one of [message] and [richMessage] may be non-null. + final String? message; + + /// The rich text to display in the tooltip. + /// + /// Only one of [message] and [richMessage] may be non-null. + final InlineSpan? richMessage; + + /// The minimum height of the [Tooltip]'s message. + @Deprecated( + 'Use Tooltip.constraints instead. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + final double? height; + + /// Constrains the size of the [Tooltip]'s message. + /// + /// If null, then the [TooltipThemeData.constraints] of the ambient [ThemeData.tooltipTheme] + /// will be used. If that is also null, then a default value will be picked based on the current + /// platform. For desktop platforms, the default value is `BoxConstraints(minHeight: 24.0)`, + /// while for mobile platforms the default value is `BoxConstraints(minHeight: 32.0)`. + final BoxConstraints? constraints; + + /// The amount of space by which to inset the [Tooltip]'s message. + /// + /// On mobile, defaults to 16.0 logical pixels horizontally and 4.0 vertically. + /// On desktop, defaults to 8.0 logical pixels horizontally and 4.0 vertically. + final EdgeInsetsGeometry? padding; + + /// The empty space that surrounds the tooltip. + /// + /// Defines the tooltip's outer [Container.margin]. By default, a + /// long tooltip will span the width of its window. If long enough, + /// a tooltip might also span the window's height. This property allows + /// one to define how much space the tooltip must be inset from the edges + /// of their display window. + /// + /// If this property is null, then [TooltipThemeData.margin] is used. + /// If [TooltipThemeData.margin] is also null, the default margin is + /// 0.0 logical pixels on all sides. + /// + /// See also: + /// + /// * [constraints], which allow setting an explicit size for the tooltip. + final EdgeInsetsGeometry? margin; + + /// The vertical gap between the widget and the displayed tooltip. + /// + /// When [preferBelow] is set to true and tooltips have sufficient space to + /// display themselves, this property defines how much vertical space + /// tooltips will position themselves under their corresponding widgets. + /// Otherwise, tooltips will position themselves above their corresponding + /// widgets with the given offset. + final double? verticalOffset; + + /// Whether the tooltip defaults to being displayed below the widget. + /// + /// If there is insufficient space to display the tooltip in + /// the preferred direction, the tooltip will be displayed in the opposite + /// direction. + /// + /// If this property is null, then [TooltipThemeData.preferBelow] is used. + /// If that is also null, the default value is true. + /// + /// Applying [TooltipThemeData.preferBelow]: `false` for the entire app + /// is recommended to avoid having a finger or cursor hide a tooltip. + final bool? preferBelow; + + /// Whether the tooltip's [message] or [richMessage] should be excluded from + /// the semantics tree. + /// + /// Defaults to false. A tooltip will add a [Semantics] label that is set to + /// [Tooltip.message] if non-null, or the plain text value of + /// [Tooltip.richMessage] otherwise. Set this property to true if the app is + /// going to provide its own custom semantics label. + final bool? excludeFromSemantics; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Specifies the tooltip's shape and background color. + /// + /// The tooltip shape defaults to a rounded rectangle with a border radius of + /// 4.0. Tooltips will also default to an opacity of 90% and with the color + /// [Colors.grey]\[700\] if [ThemeData.brightness] is [Brightness.light], and + /// [Colors.white] if it is [Brightness.dark]. + final Decoration? decoration; + + /// The style to use for the message of the tooltip. + /// + /// If null, the message's [TextStyle] will be determined based on + /// [ThemeData]. If [ThemeData.brightness] is set to [Brightness.dark], + /// [TextTheme.bodyMedium] of [ThemeData.textTheme] will be used with + /// [Colors.white]. Otherwise, if [ThemeData.brightness] is set to + /// [Brightness.light], [TextTheme.bodyMedium] of [ThemeData.textTheme] will be + /// used with [Colors.black]. + final TextStyle? textStyle; + + /// How the message of the tooltip is aligned horizontally. + /// + /// If this property is null, then [TooltipThemeData.textAlign] is used. + /// If [TooltipThemeData.textAlign] is also null, the default value is + /// [TextAlign.start]. + final TextAlign? textAlign; + + /// {@macro flutter.widgets.RawTooltip.hoverDelay} + final Duration? waitDuration; + + /// {@macro flutter.widgets.RawTooltip.touchDelay} + /// + /// See also: + /// + /// * [exitDuration], which allows configuring the time until a pointer + /// disappears when hovering. + final Duration? showDuration; + + /// {@macro flutter.widgets.RawTooltip.dismissDelay} + /// + /// See also: + /// + /// * [showDuration], which allows configuring the length of time that a + /// tooltip will be visible after touch events are released. + final Duration? exitDuration; + + /// {@macro flutter.widgets.RawTooltip.enableTapToDismiss} + final bool enableTapToDismiss; + + /// {@macro flutter.widgets.RawTooltip.triggerMode} + /// + /// If this property is null, then [TooltipThemeData.triggerMode] is used. + /// If [TooltipThemeData.triggerMode] is also null, the default mode is + /// [TooltipTriggerMode.longPress]. + final TooltipTriggerMode? triggerMode; + + /// {@macro flutter.widgets.RawTooltip.enableFeedback} + final bool? enableFeedback; + + /// {@macro flutter.widgets.RawTooltip.onTriggered} + final TooltipTriggeredCallback? onTriggered; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If this property is null, [MouseCursor.defer] will be used. + final MouseCursor? mouseCursor; + + /// Whether this tooltip should be invisible to hit testing. + /// + /// If no value is passed, pointer events are ignored unless the tooltip has a + /// [richMessage] instead of a [message]. + /// + /// See also: + /// + /// * [IgnorePointer], for more information about how pointer events are + /// handled or ignored. + final bool? ignorePointer; + + /// {@macro flutter.widgets.RawTooltip.positionDelegate} + final TooltipPositionDelegate? positionDelegate; + + /// {@macro flutter.widgets.RawTooltip.dismissAllToolTips} + static bool dismissAllToolTips() { + return RawTooltip.dismissAllToolTips(); + } + + @override + State<Tooltip> createState() => TooltipState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + StringProperty( + 'message', + message, + showName: message == null, + defaultValue: message == null ? null : kNoDefaultValue, + ), + ); + properties.add( + StringProperty( + 'richMessage', + richMessage?.toPlainText(), + showName: richMessage == null, + defaultValue: richMessage == null ? null : kNoDefaultValue, + ), + ); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null)); + properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null)); + properties.add( + FlagProperty( + 'position', + value: preferBelow, + ifTrue: 'below', + ifFalse: 'above', + showName: true, + ), + ); + properties.add( + FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true), + ); + properties.add( + DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<Duration>('exit duration', exitDuration, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null), + ); + properties.add( + FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true), + ); + properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign, defaultValue: null)); + properties.add( + DiagnosticsProperty<TooltipPositionDelegate>( + 'positionDelegate', + positionDelegate, + defaultValue: null, + ), + ); + } +} + +/// Contains the state for a [Tooltip]. +/// +/// This class can be used to programmatically show the Tooltip, see the +/// [ensureTooltipVisible] method. +class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { + static const double _defaultVerticalOffset = 24.0; + static const bool _defaultPreferBelow = true; + static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.zero; + static const Duration _defaultShowDuration = Duration(milliseconds: 1500); + static const Duration _defaultExitDuration = Duration(milliseconds: 100); + static const Duration _defaultWaitDuration = Duration.zero; + static const bool _defaultExcludeFromSemantics = false; + static const TooltipTriggerMode _defaultTriggerMode = TooltipTriggerMode.longPress; + static const bool _defaultEnableFeedback = true; + static const TextAlign _defaultTextAlign = TextAlign.start; + + final GlobalKey<RawTooltipState> _tooltipKey = GlobalKey<RawTooltipState>(); + + // From InheritedWidgets + late bool _visible; + late TooltipThemeData _tooltipTheme; + + /// The plain text message for this tooltip. + /// + /// This value will either come from [widget.message] or [widget.richMessage]. + String get _tooltipMessage => widget.message ?? widget.richMessage!.toPlainText(); + + /// {@macro flutter.widgets.RawTooltipState.ensureTooltipVisible} + bool ensureTooltipVisible() { + return _tooltipKey.currentState?.ensureTooltipVisible() ?? false; + } + + @protected + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _visible = TooltipVisibility.of(context); + _tooltipTheme = TooltipTheme.of(context); + } + + // https://material.io/components/tooltips#specs + double _getDefaultTooltipHeight() { + return switch (Theme.of(context).platform) { + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 24.0, + TargetPlatform.android || TargetPlatform.fuchsia || TargetPlatform.iOS => 32.0, + }; + } + + EdgeInsets _getDefaultPadding() { + return switch (Theme.of(context).platform) { + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.iOS => const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + }; + } + + static double _getDefaultFontSize(TargetPlatform platform) { + return switch (platform) { + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 12.0, + TargetPlatform.android || TargetPlatform.fuchsia || TargetPlatform.iOS => 14.0, + }; + } + + Offset _getDefaultPositionDelegate(TooltipPositionContext context) { + final double effectiveVerticalOffset = + widget.verticalOffset ?? _tooltipTheme.verticalOffset ?? _defaultVerticalOffset; + final bool effectivePreferBelow = + widget.preferBelow ?? _tooltipTheme.preferBelow ?? _defaultPreferBelow; + final resolvedContext = TooltipPositionContext( + target: context.target, + targetSize: context.targetSize, + tooltipSize: context.tooltipSize, + overlaySize: context.overlaySize, + verticalOffset: effectiveVerticalOffset, + preferBelow: effectivePreferBelow, + ); + return widget.positionDelegate?.call(resolvedContext) ?? + positionDependentBox( + size: context.overlaySize, + childSize: context.tooltipSize, + target: context.target, + verticalOffset: effectiveVerticalOffset, + preferBelow: effectivePreferBelow, + ); + } + + @override + Widget build(BuildContext context) { + // If no message is provided, there is no need to create a tooltip overlay + // to show an empty container. In this case, just return the wrapped child + // as is, or SizedBox.shrink if a child is not provided. + if (_tooltipMessage.isEmpty) { + return widget.child ?? const SizedBox.shrink(); + } + final (TextStyle defaultTextStyle, BoxDecoration defaultDecoration) = switch (Theme.of( + context, + )) { + ThemeData( + brightness: Brightness.dark, + :final TextTheme textTheme, + :final TargetPlatform platform, + ) => + ( + textTheme.bodyMedium!.copyWith( + color: Colors.black, + fontSize: _getDefaultFontSize(platform), + ), + BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ThemeData( + brightness: Brightness.light, + :final TextTheme textTheme, + :final TargetPlatform platform, + ) => + ( + textTheme.bodyMedium!.copyWith( + color: Colors.white, + fontSize: _getDefaultFontSize(platform), + ), + BoxDecoration( + color: Colors.grey[700]!.withOpacity(0.9), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + }; + final defaultConstraints = BoxConstraints( + minHeight: widget.height ?? _tooltipTheme.height ?? _getDefaultTooltipHeight(), + ); + + final Widget tooltipBox = _TooltipBox( + constraints: widget.constraints ?? _tooltipTheme.constraints ?? defaultConstraints, + textStyle: widget.textStyle ?? _tooltipTheme.textStyle ?? defaultTextStyle, + textAlign: widget.textAlign ?? _tooltipTheme.textAlign ?? _defaultTextAlign, + decoration: widget.decoration ?? _tooltipTheme.decoration ?? defaultDecoration, + padding: widget.padding ?? _tooltipTheme.padding ?? _getDefaultPadding(), + margin: widget.margin ?? _tooltipTheme.margin ?? _defaultMargin, + richMessage: widget.richMessage ?? TextSpan(text: widget.message), + ); + + Widget effectiveChild = MouseRegion( + cursor: widget.mouseCursor ?? MouseCursor.defer, + child: widget.child ?? const SizedBox.shrink(), + ); + + final bool excludeFromSemantics = + widget.excludeFromSemantics ?? + _tooltipTheme.excludeFromSemantics ?? + _defaultExcludeFromSemantics; + + if (_visible) { + effectiveChild = RawTooltip( + key: _tooltipKey, + semanticsTooltip: excludeFromSemantics ? null : _tooltipMessage, + tooltipBuilder: (BuildContext context, Animation<double> animation) => + FadeTransition(opacity: animation, child: tooltipBox), + touchDelay: widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultShowDuration, + triggerMode: widget.triggerMode ?? _tooltipTheme.triggerMode ?? _defaultTriggerMode, + enableFeedback: + widget.enableFeedback ?? _tooltipTheme.enableFeedback ?? _defaultEnableFeedback, + hoverDelay: widget.waitDuration ?? _tooltipTheme.waitDuration ?? _defaultWaitDuration, + enableTapToDismiss: widget.enableTapToDismiss, + onTriggered: widget.onTriggered, + dismissDelay: widget.exitDuration ?? _tooltipTheme.exitDuration ?? _defaultExitDuration, + positionDelegate: _getDefaultPositionDelegate, + ignorePointer: widget.ignorePointer ?? widget.message != null, + child: effectiveChild, + ); + } + + return effectiveChild; + } +} + +class _TooltipBox extends StatelessWidget { + const _TooltipBox({ + required this.constraints, + required this.textStyle, + required this.textAlign, + required this.decoration, + required this.padding, + required this.margin, + required this.richMessage, + }); + + final BoxConstraints constraints; + final TextStyle textStyle; + final TextAlign textAlign; + final Decoration? decoration; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final InlineSpan richMessage; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: constraints, + child: DefaultTextStyle( + style: textStyle, + textAlign: textAlign, + child: Container( + decoration: decoration, + padding: padding, + margin: margin, + child: Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: Text.rich(richMessage, style: textStyle, textAlign: textAlign), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/lib/src/tooltip_theme.dart b/packages/material_ui/lib/src/tooltip_theme.dart new file mode 100644 index 000000000000..8df0975a4c97 --- /dev/null +++ b/packages/material_ui/lib/src/tooltip_theme.dart @@ -0,0 +1,363 @@ +// 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. + +/// @docImport 'app.dart'; +/// @docImport 'tooltip.dart'; +library; + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [Tooltip] widgets. +/// +/// Descendant widgets obtain the current [TooltipThemeData] object using +/// [TooltipTheme.of]. Instances of [TooltipThemeData] can be customized +/// with [TooltipThemeData.copyWith]. +/// +/// Typically a [TooltipThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.tooltipTheme]. +/// +/// See also: +/// +/// * [TooltipTheme], a widget which overrides the tooltip theme for a subtree. +/// * [ThemeData.tooltipTheme], which specifies a tooltip theme as part of +/// an overall theme. +/// * [MaterialApp.theme], which specifies a theme for the whole application. +@immutable +class TooltipThemeData with Diagnosticable { + /// Creates the set of properties used to configure [Tooltip]s. + const TooltipThemeData({ + @Deprecated( + 'Use TooltipThemeData.constraints instead. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + this.height, + this.constraints, + this.padding, + this.margin, + this.verticalOffset, + this.preferBelow, + this.excludeFromSemantics, + this.decoration, + this.textStyle, + this.textAlign, + this.waitDuration, + this.showDuration, + this.exitDuration, + this.triggerMode, + this.enableFeedback, + }) : assert( + height == null || constraints == null, + 'Only one of `height` and `constraints` may be specified.', + ); + + /// The minimum height of the [Tooltip]'s message. + @Deprecated( + 'Use TooltipThemeData.constraints instead. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + final double? height; + + /// Constrains the size of the [Tooltip]'s message. + final BoxConstraints? constraints; + + /// If provided, the amount of space by which to inset the [Tooltip]'s message. + final EdgeInsetsGeometry? padding; + + /// If provided, the amount of empty space to surround the [Tooltip]. + final EdgeInsetsGeometry? margin; + + /// The vertical gap between the widget and the displayed tooltip. + /// + /// When [preferBelow] is set to true and tooltips have sufficient space to + /// display themselves, this property defines how much vertical space + /// tooltips will position themselves under their corresponding widgets. + /// Otherwise, tooltips will position themselves above their corresponding + /// widgets with the given offset. + final double? verticalOffset; + + /// Whether the tooltip is displayed below its widget by default. + /// + /// If there is insufficient space to display the tooltip in the preferred + /// direction, the tooltip will be displayed in the opposite direction. + /// + /// Applying `false` for the entire app is recommended + /// to avoid having a finger or cursor hide a tooltip. + final bool? preferBelow; + + /// Whether the [Tooltip.message] should be excluded from the semantics + /// tree. + /// + /// By default, [Tooltip]s will add a [Semantics] label that is set to + /// [Tooltip.message]. Set this property to true if the app is going to + /// provide its own custom semantics label. + final bool? excludeFromSemantics; + + /// The [Tooltip]'s shape and background color. + final Decoration? decoration; + + /// The style to use for the message of [Tooltip]s. + final TextStyle? textStyle; + + /// The [TextAlign] to use for the message of [Tooltip]s. + final TextAlign? textAlign; + + /// The length of time that a pointer must hover over a tooltip's widget + /// before the tooltip will be shown. + final Duration? waitDuration; + + /// The length of time that the tooltip will be shown once it has appeared. + final Duration? showDuration; + + /// The length of time that a pointer must have stopped hovering over a + /// tooltip's widget before the tooltip will be hidden. + final Duration? exitDuration; + + /// The [TooltipTriggerMode] that will show the tooltip. + final TooltipTriggerMode? triggerMode; + + /// Whether the tooltip should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// This value is used if [Tooltip.enableFeedback] is null. + /// If this value is null, the default is true. + /// + /// See also: + /// + /// * [Feedback], for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + TooltipThemeData copyWith({ + @Deprecated( + 'Use TooltipThemeData.constraints instead. ' + 'This feature was deprecated after v3.30.0-0.1.pre.', + ) + double? height, + BoxConstraints? constraints, + EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? margin, + double? verticalOffset, + bool? preferBelow, + bool? excludeFromSemantics, + Decoration? decoration, + TextStyle? textStyle, + TextAlign? textAlign, + Duration? waitDuration, + Duration? showDuration, + Duration? exitDuration, + TooltipTriggerMode? triggerMode, + bool? enableFeedback, + }) { + return TooltipThemeData( + height: height ?? this.height, + constraints: constraints ?? this.constraints, + padding: padding ?? this.padding, + margin: margin ?? this.margin, + verticalOffset: verticalOffset ?? this.verticalOffset, + preferBelow: preferBelow ?? this.preferBelow, + excludeFromSemantics: excludeFromSemantics ?? this.excludeFromSemantics, + decoration: decoration ?? this.decoration, + textStyle: textStyle ?? this.textStyle, + textAlign: textAlign ?? this.textAlign, + waitDuration: waitDuration ?? this.waitDuration, + showDuration: showDuration ?? this.showDuration, + triggerMode: triggerMode ?? this.triggerMode, + enableFeedback: enableFeedback ?? this.enableFeedback, + ); + } + + /// Linearly interpolate between two tooltip themes. + /// + /// If both arguments are null, then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static TooltipThemeData? lerp(TooltipThemeData? a, TooltipThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return TooltipThemeData( + height: lerpDouble(a?.height, b?.height, t), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + margin: EdgeInsetsGeometry.lerp(a?.margin, b?.margin, t), + verticalOffset: lerpDouble(a?.verticalOffset, b?.verticalOffset, t), + preferBelow: t < 0.5 ? a?.preferBelow : b?.preferBelow, + excludeFromSemantics: t < 0.5 ? a?.excludeFromSemantics : b?.excludeFromSemantics, + decoration: Decoration.lerp(a?.decoration, b?.decoration, t), + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + textAlign: t < 0.5 ? a?.textAlign : b?.textAlign, + ); + } + + @override + int get hashCode => Object.hash( + height, + constraints, + padding, + margin, + verticalOffset, + preferBelow, + excludeFromSemantics, + decoration, + textStyle, + textAlign, + waitDuration, + showDuration, + exitDuration, + triggerMode, + enableFeedback, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is TooltipThemeData && + other.height == height && + other.constraints == constraints && + other.padding == padding && + other.margin == margin && + other.verticalOffset == verticalOffset && + other.preferBelow == preferBelow && + other.excludeFromSemantics == excludeFromSemantics && + other.decoration == decoration && + other.textStyle == textStyle && + other.textAlign == textAlign && + other.waitDuration == waitDuration && + other.showDuration == showDuration && + other.exitDuration == exitDuration && + other.triggerMode == triggerMode && + other.enableFeedback == enableFeedback; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add( + DiagnosticsProperty<BoxConstraints>('constraints', constraints, defaultValue: null), + ); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null)); + properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null)); + properties.add( + FlagProperty( + 'position', + value: preferBelow, + ifTrue: 'below', + ifFalse: 'above', + showName: true, + ), + ); + properties.add( + FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true), + ); + properties.add(DiagnosticsProperty<Decoration>('decoration', decoration, defaultValue: null)); + properties.add(DiagnosticsProperty<TextStyle>('textStyle', textStyle, defaultValue: null)); + properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign, defaultValue: null)); + properties.add( + DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<Duration>('exit duration', exitDuration, defaultValue: null), + ); + properties.add( + DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null), + ); + properties.add( + FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true), + ); + } +} + +/// Applies a tooltip theme to descendant [Tooltip] widgets. +/// +/// A tooltip theme describes the values to use for [Tooltip] properties +/// that are not given an explicit non-null value. +/// +/// Descendant widgets obtain the ambient tooltip theme, a [TooltipThemeData], +/// using [TooltipTheme.of]. +/// +/// {@tool snippet} +/// +/// Here is an example of a tooltip theme that applies a blue foreground +/// with non-rounded corners. +/// +/// ```dart +/// TooltipTheme( +/// data: TooltipThemeData( +/// decoration: BoxDecoration( +/// color: Colors.blue.withValues(alpha: 0.9), +/// borderRadius: BorderRadius.zero, +/// ), +/// ), +/// child: Tooltip( +/// message: 'Example tooltip', +/// child: IconButton( +/// iconSize: 36.0, +/// icon: const Icon(Icons.touch_app), +/// onPressed: () {}, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [TooltipThemeData], which describes the actual configuration of a +/// tooltip theme. +/// * [TooltipVisibility], which can be used to visually disable descendant [Tooltip]s. +class TooltipTheme extends InheritedTheme { + /// Creates a tooltip theme that controls the configurations for + /// [Tooltip]. + const TooltipTheme({super.key, required this.data, required super.child}); + + /// The properties for descendant [Tooltip] widgets. + final TooltipThemeData data; + + /// Retrieves the [TooltipThemeData] from the closest ancestor [TooltipTheme]. + /// + /// The result comes from the closest [TooltipTheme] ancestor if any, + /// and otherwise from [Theme.of] and [ThemeData.tooltipTheme]. + /// + /// When a widget uses this method, it is automatically rebuilt if the + /// tooltip theme later changes, so that the changes can be applied. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TooltipThemeData theme = TooltipTheme.of(context); + /// ``` + static TooltipThemeData of(BuildContext context) { + final TooltipTheme? tooltipTheme = context.dependOnInheritedWidgetOfExactType<TooltipTheme>(); + return tooltipTheme?.data ?? Theme.of(context).tooltipTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return TooltipTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(TooltipTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/material_ui/lib/src/tooltip_visibility.dart b/packages/material_ui/lib/src/tooltip_visibility.dart new file mode 100644 index 000000000000..9d28f8af324e --- /dev/null +++ b/packages/material_ui/lib/src/tooltip_visibility.dart @@ -0,0 +1,54 @@ +// 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. + +/// @docImport 'tooltip.dart'; +library; + +import 'package:flutter/widgets.dart'; + +class _TooltipVisibilityScope extends InheritedWidget { + const _TooltipVisibilityScope({required super.child, required this.visible}); + + final bool visible; + + @override + bool updateShouldNotify(_TooltipVisibilityScope old) { + return old.visible != visible; + } +} + +/// Overrides the visibility of descendant [Tooltip] widgets. +/// +/// If disabled, the descendant [Tooltip] widgets will not display a tooltip +/// when tapped, long-pressed, hovered by the mouse, or when +/// `ensureTooltipVisible` is called. This only visually disables tooltips but +/// continues to provide any semantic information that is provided. +class TooltipVisibility extends StatelessWidget { + /// Creates a widget that configures the visibility of [Tooltip]. + const TooltipVisibility({super.key, required this.visible, required this.child}); + + /// The widget below this widget in the tree. + /// + /// The entire app can be wrapped in this widget to globally control [Tooltip] + /// visibility. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// Determines the visibility of [Tooltip] widgets that inherit from this widget. + final bool visible; + + /// The [visible] of the closest instance of this class that encloses the + /// given context. Defaults to `true` if none are found. + static bool of(BuildContext context) { + final _TooltipVisibilityScope? visibility = context + .dependOnInheritedWidgetOfExactType<_TooltipVisibilityScope>(); + return visibility?.visible ?? true; + } + + @override + Widget build(BuildContext context) { + return _TooltipVisibilityScope(visible: visible, child: child); + } +} diff --git a/packages/material_ui/lib/src/typography.dart b/packages/material_ui/lib/src/typography.dart new file mode 100644 index 000000000000..7b6c5ebdc777 --- /dev/null +++ b/packages/material_ui/lib/src/typography.dart @@ -0,0 +1,2152 @@ +// 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. + +/// @docImport 'material_localizations.dart'; +/// @docImport 'theme.dart'; +/// @docImport 'theme_data.dart'; +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +import 'color_scheme.dart'; +import 'colors.dart'; +import 'text_theme.dart'; + +// Examples can assume: +// late TargetPlatform platform; + +/// A characterization of the of a [TextTheme]'s glyphs that is used to define +/// its localized [TextStyle] geometry for [ThemeData.textTheme]. +/// +/// The script category defines the overall geometry of a [TextTheme] for +/// the [Typography.geometryThemeFor] method in terms of the +/// three language categories defined in <https://material.io/go/design-typography>. +/// +/// Generally speaking, font sizes for `ScriptCategory.tall` and +/// `ScriptCategory.dense` scripts - for text styles that are smaller than the +/// title style - are one unit larger than they are for +/// `ScriptCategory.englishLike` scripts. +enum ScriptCategory { + /// The languages of Western, Central, and Eastern Europe and much of + /// Africa are typically written in the Latin alphabet. Vietnamese is a + /// notable exception in that, while it uses a localized form of the Latin + /// writing system, its accented glyphs can be much taller than those + /// found in Western European languages. The Greek and Cyrillic writing + /// systems are very similar to Latin. + englishLike, + + /// Language scripts that require extra line height to accommodate larger + /// glyphs, including Chinese, Japanese, and Korean. + dense, + + /// Language scripts that require extra line height to accommodate + /// larger glyphs, including South and Southeast Asian and + /// Middle-Eastern languages, like Arabic, Hindi, Telugu, Thai, and + /// Vietnamese. + tall, +} + +/// The color and geometry [TextTheme]s for Material apps. +/// +/// The text theme provided by the overall [Theme], +/// [ThemeData.textTheme], is based on the current locale's +/// [MaterialLocalizations.scriptCategory] and is created +/// by merging a color text theme - [black] for +/// [Brightness.light] themes and [white] for [Brightness.dark] +/// themes - and a geometry text theme, one of [englishLike], [dense], +/// or [tall], depending on the locale. +/// +/// To lookup the localized text theme use +/// `Theme.of(context).textTheme`. +/// +/// The color text themes are [blackMountainView], [whiteMountainView], +/// [blackCupertino], and [whiteCupertino]. The Mountain View theme [TextStyle]s +/// are based on the Roboto fonts as used on Android. The Cupertino themes are +/// based on the [San Francisco +/// font](https://developer.apple.com/design/human-interface-guidelines/typography/) +/// fonts as used by Apple on iOS. +/// +/// Two sets of geometry themes are provided: 2014 and 2018. The 2014 themes +/// correspond to the original version of the Material Design spec and are +/// the defaults. The 2018 themes correspond the second iteration of the +/// specification and feature different font sizes, font weights, and +/// letter spacing values. +/// +/// By default, [ThemeData.typography] is `Typography.material2014(platform: +/// platform)` which uses [englishLike2014], [dense2014] and [tall2014]. To use +/// the 2018 text theme geometries, specify a value using the [Typography.material2018] +/// constructor: +/// +/// ```dart +/// typography: Typography.material2018(platform: platform) +/// ``` +/// +/// See also: +/// +/// * <https://material.io/design/typography/> +/// * <https://m3.material.io/styles/typography> +@immutable +class Typography with Diagnosticable { + /// Creates a typography instance. + /// + /// This constructor is identical to [Typography.material2018]. + factory Typography({ + TargetPlatform? platform, + TextTheme? black, + TextTheme? white, + TextTheme? englishLike, + TextTheme? dense, + TextTheme? tall, + }) = Typography.material2018; + + /// Creates a typography instance using Material Design's 2014 defaults. + /// + /// If [platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS], the + /// default values for [black] and [white] are [blackCupertino] and + /// [whiteCupertino] respectively. Otherwise they are [blackMountainView] and + /// [whiteMountainView]. If [platform] is null then both [black] and [white] + /// must be specified. + /// + /// The default values for [englishLike], [dense], and [tall] are + /// [englishLike2014], [dense2014], and [tall2014]. + factory Typography.material2014({ + TargetPlatform? platform = TargetPlatform.android, + TextTheme? black, + TextTheme? white, + TextTheme? englishLike, + TextTheme? dense, + TextTheme? tall, + }) { + assert(platform != null || (black != null && white != null)); + return Typography._withPlatform( + platform, + black, + white, + englishLike ?? englishLike2014, + dense ?? dense2014, + tall ?? tall2014, + ); + } + + /// Creates a typography instance using Material Design's 2018 defaults. + /// + /// If [platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS], the + /// default values for [black] and [white] are [blackCupertino] and + /// [whiteCupertino] respectively. Otherwise they are [blackMountainView] and + /// [whiteMountainView]. If [platform] is null then both [black] and [white] + /// must be specified. + /// + /// The default values for [englishLike], [dense], and [tall] are + /// [englishLike2018], [dense2018], and [tall2018]. + factory Typography.material2018({ + TargetPlatform? platform = TargetPlatform.android, + TextTheme? black, + TextTheme? white, + TextTheme? englishLike, + TextTheme? dense, + TextTheme? tall, + }) { + assert(platform != null || (black != null && white != null)); + return Typography._withPlatform( + platform, + black, + white, + englishLike ?? englishLike2018, + dense ?? dense2018, + tall ?? tall2018, + ); + } + + /// Creates a typography instance using Material Design 3 2021 defaults. + /// + /// If [platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS], the + /// default values for [black] and [white] are [blackCupertino] and + /// [whiteCupertino] respectively. Otherwise they are [blackMountainView] and + /// [whiteMountainView]. If [platform] is null then both [black] and [white] + /// must be specified. + /// + /// The default values for [englishLike], [dense], and [tall] are + /// [englishLike2021], [dense2021], and [tall2021]. + /// + /// See also: + /// * <https://m3.material.io/styles/typography> + factory Typography.material2021({ + TargetPlatform? platform = TargetPlatform.android, + ColorScheme colorScheme = const ColorScheme.light(), + TextTheme? black, + TextTheme? white, + TextTheme? englishLike, + TextTheme? dense, + TextTheme? tall, + }) { + assert(platform != null || (black != null && white != null)); + final base = Typography._withPlatform( + platform, + black, + white, + englishLike ?? englishLike2021, + dense ?? dense2021, + tall ?? tall2021, + ); + + // Ensure they are all uniformly dark or light, with + // no color variation based on style as it was in previous + // versions of Material Design. + final Color dark = colorScheme.brightness == Brightness.light + ? colorScheme.onSurface + : colorScheme.surface; + final Color light = colorScheme.brightness == Brightness.light + ? colorScheme.surface + : colorScheme.onSurface; + return base.copyWith( + black: base.black.apply(displayColor: dark, bodyColor: dark, decorationColor: dark), + white: base.white.apply(displayColor: light, bodyColor: light, decorationColor: light), + ); + } + + factory Typography._withPlatform( + TargetPlatform? platform, + TextTheme? black, + TextTheme? white, + TextTheme englishLike, + TextTheme dense, + TextTheme tall, + ) { + assert(platform != null || (black != null && white != null)); + final (TextTheme blackResolved, TextTheme whiteResolved) = switch (platform) { + TargetPlatform.iOS => (black ?? blackCupertino, white ?? whiteCupertino), + TargetPlatform.android || + TargetPlatform.fuchsia => (black ?? blackMountainView, white ?? whiteMountainView), + TargetPlatform.windows => (black ?? blackRedmond, white ?? whiteRedmond), + TargetPlatform.macOS => (black ?? blackRedwoodCity, white ?? whiteRedwoodCity), + TargetPlatform.linux => (black ?? blackHelsinki, white ?? whiteHelsinki), + null => (black!, white!), + }; + return Typography._(blackResolved, whiteResolved, englishLike, dense, tall); + } + + const Typography._(this.black, this.white, this.englishLike, this.dense, this.tall); + + /// A Material Design text theme with dark glyphs. + /// + /// This [TextTheme] should provide color but not geometry (font size, + /// weight, etc). A text theme's geometry depends on the locale. To look + /// up a localized [TextTheme], use the overall [Theme], for example: + /// `Theme.of(context).textTheme`. + /// + /// The [englishLike], [dense], and [tall] text theme's provide locale-specific + /// geometry. + final TextTheme black; + + /// A Material Design text theme with light glyphs. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + /// A text theme's geometry depends on the locale. To look up a localized + /// [TextTheme], use the overall [Theme], for example: + /// `Theme.of(context).textTheme`. + /// + /// The [englishLike], [dense], and [tall] text theme's provide locale-specific + /// geometry. + final TextTheme white; + + /// Defines text geometry for `ScriptCategory.englishLike` scripts, such as + /// English, French, Russian, etc. + /// + /// This text theme is merged with either [black] or [white], depending + /// on the overall [ThemeData.brightness], when the current locale's + /// [MaterialLocalizations.scriptCategory] is `ScriptCategory.englishLike`. + /// + /// To look up a localized [TextTheme], use the overall [Theme], for + /// example: `Theme.of(context).textTheme`. + final TextTheme englishLike; + + /// Defines text geometry for dense scripts, such as Chinese, Japanese + /// and Korean. + /// + /// This text theme is merged with either [black] or [white], depending + /// on the overall [ThemeData.brightness], when the current locale's + /// [MaterialLocalizations.scriptCategory] is `ScriptCategory.dense`. + /// + /// To look up a localized [TextTheme], use the overall [Theme], for + /// example: `Theme.of(context).textTheme`. + final TextTheme dense; + + /// Defines text geometry for tall scripts, such as Farsi, Hindi, and Thai. + /// + /// This text theme is merged with either [black] or [white], depending + /// on the overall [ThemeData.brightness], when the current locale's + /// [MaterialLocalizations.scriptCategory] is `ScriptCategory.tall`. + /// + /// To look up a localized [TextTheme], use the overall [Theme], for + /// example: `Theme.of(context).textTheme`. + final TextTheme tall; + + /// Returns one of [englishLike], [dense], or [tall]. + TextTheme geometryThemeFor(ScriptCategory category) { + return switch (category) { + ScriptCategory.englishLike => englishLike, + ScriptCategory.dense => dense, + ScriptCategory.tall => tall, + }; + } + + /// Creates a copy of this [Typography] with the given fields + /// replaced by the non-null parameter values. + Typography copyWith({ + TextTheme? black, + TextTheme? white, + TextTheme? englishLike, + TextTheme? dense, + TextTheme? tall, + }) { + return Typography._( + black ?? this.black, + white ?? this.white, + englishLike ?? this.englishLike, + dense ?? this.dense, + tall ?? this.tall, + ); + } + + /// Linearly interpolate between two [Typography] objects. + /// + /// {@macro dart.ui.shadow.lerp} + static Typography lerp(Typography a, Typography b, double t) { + if (identical(a, b)) { + return a; + } + return Typography._( + TextTheme.lerp(a.black, b.black, t), + TextTheme.lerp(a.white, b.white, t), + TextTheme.lerp(a.englishLike, b.englishLike, t), + TextTheme.lerp(a.dense, b.dense, t), + TextTheme.lerp(a.tall, b.tall, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is Typography && + other.black == black && + other.white == white && + other.englishLike == englishLike && + other.dense == dense && + other.tall == tall; + } + + @override + int get hashCode => Object.hash(black, white, englishLike, dense, tall); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final defaultTypography = Typography.material2014(); + properties.add( + DiagnosticsProperty<TextTheme>('black', black, defaultValue: defaultTypography.black), + ); + properties.add( + DiagnosticsProperty<TextTheme>('white', white, defaultValue: defaultTypography.white), + ); + properties.add( + DiagnosticsProperty<TextTheme>( + 'englishLike', + englishLike, + defaultValue: defaultTypography.englishLike, + ), + ); + properties.add( + DiagnosticsProperty<TextTheme>('dense', dense, defaultValue: defaultTypography.dense), + ); + properties.add( + DiagnosticsProperty<TextTheme>('tall', tall, defaultValue: defaultTypography.tall), + ); + } + + /// A Material Design text theme with dark glyphs based on Roboto. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + static const TextTheme blackMountainView = TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackMountainView displayLarge', + fontFamily: 'Roboto', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'blackMountainView displayMedium', + fontFamily: 'Roboto', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'blackMountainView displaySmall', + fontFamily: 'Roboto', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'blackMountainView headlineLarge', + fontFamily: 'Roboto', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'blackMountainView headlineMedium', + fontFamily: 'Roboto', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'blackMountainView headlineSmall', + fontFamily: 'Roboto', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'blackMountainView titleLarge', + fontFamily: 'Roboto', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'blackMountainView titleMedium', + fontFamily: 'Roboto', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'blackMountainView titleSmall', + fontFamily: 'Roboto', + color: Colors.black, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'blackMountainView bodyLarge', + fontFamily: 'Roboto', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'blackMountainView bodyMedium', + fontFamily: 'Roboto', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'blackMountainView bodySmall', + fontFamily: 'Roboto', + color: Colors.black54, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'blackMountainView labelLarge', + fontFamily: 'Roboto', + color: Colors.black87, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'blackMountainView labelMedium', + fontFamily: 'Roboto', + color: Colors.black, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'blackMountainView labelSmall', + fontFamily: 'Roboto', + color: Colors.black, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with light glyphs based on Roboto. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + static const TextTheme whiteMountainView = TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteMountainView displayLarge', + fontFamily: 'Roboto', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'whiteMountainView displayMedium', + fontFamily: 'Roboto', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'whiteMountainView displaySmall', + fontFamily: 'Roboto', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'whiteMountainView headlineLarge', + fontFamily: 'Roboto', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'whiteMountainView headlineMedium', + fontFamily: 'Roboto', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'whiteMountainView headlineSmall', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'whiteMountainView titleLarge', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'whiteMountainView titleMedium', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'whiteMountainView titleSmall', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'whiteMountainView bodyLarge', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'whiteMountainView bodyMedium', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'whiteMountainView bodySmall', + fontFamily: 'Roboto', + color: Colors.white70, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'whiteMountainView labelLarge', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'whiteMountainView labelMedium', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'whiteMountainView labelSmall', + fontFamily: 'Roboto', + color: Colors.white, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with dark glyphs based on Segoe UI. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + static const TextTheme blackRedmond = TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackRedmond displayLarge', + fontFamily: 'Segoe UI', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'blackRedmond displayMedium', + fontFamily: 'Segoe UI', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'blackRedmond displaySmall', + fontFamily: 'Segoe UI', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'blackRedmond headlineLarge', + fontFamily: 'Segoe UI', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'blackRedmond headlineMedium', + fontFamily: 'Segoe UI', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'blackRedmond headlineSmall', + fontFamily: 'Segoe UI', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'blackRedmond titleLarge', + fontFamily: 'Segoe UI', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'blackRedmond titleMedium', + fontFamily: 'Segoe UI', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'blackRedmond titleSmall', + fontFamily: 'Segoe UI', + color: Colors.black, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'blackRedmond bodyLarge', + fontFamily: 'Segoe UI', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'blackRedmond bodyMedium', + fontFamily: 'Segoe UI', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'blackRedmond bodySmall', + fontFamily: 'Segoe UI', + color: Colors.black54, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'blackRedmond labelLarge', + fontFamily: 'Segoe UI', + color: Colors.black87, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'blackRedmond labelMedium', + fontFamily: 'Segoe UI', + color: Colors.black, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'blackRedmond labelSmall', + fontFamily: 'Segoe UI', + color: Colors.black, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with light glyphs based on Segoe UI. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + static const TextTheme whiteRedmond = TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteRedmond displayLarge', + fontFamily: 'Segoe UI', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'whiteRedmond displayMedium', + fontFamily: 'Segoe UI', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'whiteRedmond displaySmall', + fontFamily: 'Segoe UI', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'whiteRedmond headlineLarge', + fontFamily: 'Segoe UI', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'whiteRedmond headlineMedium', + fontFamily: 'Segoe UI', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'whiteRedmond headlineSmall', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'whiteRedmond titleLarge', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'whiteRedmond titleMedium', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'whiteRedmond titleSmall', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'whiteRedmond bodyLarge', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'whiteRedmond bodyMedium', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'whiteRedmond bodySmall', + fontFamily: 'Segoe UI', + color: Colors.white70, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'whiteRedmond labelLarge', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'whiteRedmond labelMedium', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'whiteRedmond labelSmall', + fontFamily: 'Segoe UI', + color: Colors.white, + decoration: TextDecoration.none, + ), + ); + + static const List<String> _helsinkiFontFallbacks = <String>[ + 'Ubuntu', + 'Adwaita Sans', + 'Cantarell', + 'DejaVu Sans', + 'Liberation Sans', + 'Arial', + ]; + + /// A Material Design text theme with dark glyphs based on Roboto, with + /// fallback fonts that are likely (but not guaranteed) to be installed on + /// Linux. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + static const TextTheme blackHelsinki = TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackHelsinki displayLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black54, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'blackHelsinki displayMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black54, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'blackHelsinki displaySmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'blackHelsinki headlineLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'blackHelsinki headlineMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'blackHelsinki headlineSmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'blackHelsinki titleLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'blackHelsinki titleMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'blackHelsinki titleSmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'blackHelsinki bodyLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'blackHelsinki bodyMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'blackHelsinki bodySmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black54, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'blackHelsinki labelLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black87, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'blackHelsinki labelMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'blackHelsinki labelSmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.black, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with light glyphs based on Roboto, with fallbacks of DejaVu Sans, Liberation Sans and Arial. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + static const TextTheme whiteHelsinki = TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteHelsinki displayLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white70, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'whiteHelsinki displayMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white70, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'whiteHelsinki displaySmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'whiteHelsinki headlineLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'whiteHelsinki headlineMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'whiteHelsinki headlineSmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'whiteHelsinki titleLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'whiteHelsinki titleMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'whiteHelsinki titleSmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'whiteHelsinki bodyLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'whiteHelsinki bodyMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'whiteHelsinki bodySmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white70, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'whiteHelsinki labelLarge', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'whiteHelsinki labelMedium', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'whiteHelsinki labelSmall', + fontFamily: 'Roboto', + fontFamilyFallback: _helsinkiFontFallbacks, + color: Colors.white, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with dark glyphs based on San Francisco. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + /// + /// This theme uses the iOS version of the font names. + static const TextTheme blackCupertino = TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackCupertino displayLarge', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'blackCupertino displayMedium', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'blackCupertino displaySmall', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'blackCupertino headlineLarge', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'blackCupertino headlineMedium', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'blackCupertino headlineSmall', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'blackCupertino titleLarge', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'blackCupertino titleMedium', + fontFamily: 'CupertinoSystemText', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'blackCupertino titleSmall', + fontFamily: 'CupertinoSystemText', + color: Colors.black, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'blackCupertino bodyLarge', + fontFamily: 'CupertinoSystemText', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'blackCupertino bodyMedium', + fontFamily: 'CupertinoSystemText', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'blackCupertino bodySmall', + fontFamily: 'CupertinoSystemText', + color: Colors.black54, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'blackCupertino labelLarge', + fontFamily: 'CupertinoSystemText', + color: Colors.black87, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'blackCupertino labelMedium', + fontFamily: 'CupertinoSystemText', + color: Colors.black, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'blackCupertino labelSmall', + fontFamily: 'CupertinoSystemText', + color: Colors.black, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with light glyphs based on San Francisco. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + /// + /// This theme uses the iOS version of the font names. + static const TextTheme whiteCupertino = TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteCupertino displayLarge', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'whiteCupertino displayMedium', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'whiteCupertino displaySmall', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'whiteCupertino headlineLarge', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'whiteCupertino headlineMedium', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'whiteCupertino headlineSmall', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'whiteCupertino titleLarge', + fontFamily: 'CupertinoSystemDisplay', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'whiteCupertino titleMedium', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'whiteCupertino titleSmall', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'whiteCupertino bodyLarge', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'whiteCupertino bodyMedium', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'whiteCupertino bodySmall', + fontFamily: 'CupertinoSystemText', + color: Colors.white70, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'whiteCupertino labelLarge', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'whiteCupertino labelMedium', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'whiteCupertino labelSmall', + fontFamily: 'CupertinoSystemText', + color: Colors.white, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with dark glyphs based on San Francisco. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + /// + /// This theme uses the macOS version of the font names. + static const TextTheme blackRedwoodCity = TextTheme( + displayLarge: TextStyle( + debugLabel: 'blackRedwoodCity displayLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'blackRedwoodCity displayMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.black54, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'blackRedwoodCity displaySmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'blackRedwoodCity headlineLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'blackRedwoodCity headlineMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.black54, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'blackRedwoodCity headlineSmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'blackRedwoodCity titleLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'blackRedwoodCity titleMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.black87, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'blackRedwoodCity titleSmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.black, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'blackRedwoodCity bodyLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'blackRedwoodCity bodyMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.black87, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'blackRedwoodCity bodySmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.black54, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'blackRedwoodCity labelLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.black87, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'blackRedwoodCity labelMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.black, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'blackRedwoodCity labelSmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.black, + decoration: TextDecoration.none, + ), + ); + + /// A Material Design text theme with light glyphs based on San Francisco. + /// + /// This [TextTheme] provides color but not geometry (font size, weight, etc). + /// + /// This theme uses the macOS version of the font names. + static const TextTheme whiteRedwoodCity = TextTheme( + displayLarge: TextStyle( + debugLabel: 'whiteRedwoodCity displayLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displayMedium: TextStyle( + debugLabel: 'whiteRedwoodCity displayMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.white70, + decoration: TextDecoration.none, + ), + displaySmall: TextStyle( + debugLabel: 'whiteRedwoodCity displaySmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineLarge: TextStyle( + debugLabel: 'whiteRedwoodCity headlineLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineMedium: TextStyle( + debugLabel: 'whiteRedwoodCity headlineMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.white70, + decoration: TextDecoration.none, + ), + headlineSmall: TextStyle( + debugLabel: 'whiteRedwoodCity headlineSmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleLarge: TextStyle( + debugLabel: 'whiteRedwoodCity titleLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleMedium: TextStyle( + debugLabel: 'whiteRedwoodCity titleMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + titleSmall: TextStyle( + debugLabel: 'whiteRedwoodCity titleSmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyLarge: TextStyle( + debugLabel: 'whiteRedwoodCity bodyLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodyMedium: TextStyle( + debugLabel: 'whiteRedwoodCity bodyMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + bodySmall: TextStyle( + debugLabel: 'whiteRedwoodCity bodySmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.white70, + decoration: TextDecoration.none, + ), + labelLarge: TextStyle( + debugLabel: 'whiteRedwoodCity labelLarge', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelMedium: TextStyle( + debugLabel: 'whiteRedwoodCity labelMedium', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + labelSmall: TextStyle( + debugLabel: 'whiteRedwoodCity labelSmall', + fontFamily: '.AppleSystemUIFont', + color: Colors.white, + decoration: TextDecoration.none, + ), + ); + + /// Defines text geometry for `ScriptCategory.englishLike` scripts, such as + /// English, French, Russian, etc. + static const TextTheme englishLike2014 = TextTheme( + displayLarge: TextStyle( + debugLabel: 'englishLike displayLarge 2014', + inherit: false, + fontSize: 112.0, + fontWeight: FontWeight.w100, + textBaseline: TextBaseline.alphabetic, + ), + displayMedium: TextStyle( + debugLabel: 'englishLike displayMedium 2014', + inherit: false, + fontSize: 56.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + displaySmall: TextStyle( + debugLabel: 'englishLike displaySmall 2014', + inherit: false, + fontSize: 45.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineLarge: TextStyle( + debugLabel: 'englishLike headlineLarge 2014', + inherit: false, + fontSize: 40.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineMedium: TextStyle( + debugLabel: 'englishLike headlineMedium 2014', + inherit: false, + fontSize: 34.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineSmall: TextStyle( + debugLabel: 'englishLike headlineSmall 2014', + inherit: false, + fontSize: 24.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + titleLarge: TextStyle( + debugLabel: 'englishLike titleLarge 2014', + inherit: false, + fontSize: 20.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + ), + titleMedium: TextStyle( + debugLabel: 'englishLike titleMedium 2014', + inherit: false, + fontSize: 16.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + titleSmall: TextStyle( + debugLabel: 'englishLike titleSmall 2014', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.1, + ), + bodyLarge: TextStyle( + debugLabel: 'englishLike bodyLarge 2014', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + ), + bodyMedium: TextStyle( + debugLabel: 'englishLike bodyMedium 2014', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + bodySmall: TextStyle( + debugLabel: 'englishLike bodySmall 2014', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + labelLarge: TextStyle( + debugLabel: 'englishLike labelLarge 2014', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + ), + labelMedium: TextStyle( + debugLabel: 'englishLike labelMedium 2014', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + labelSmall: TextStyle( + debugLabel: 'englishLike labelSmall 2014', + inherit: false, + fontSize: 10.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 1.5, + ), + ); + + /// Defines text geometry for `ScriptCategory.englishLike` scripts, such as + /// English, French, Russian, etc. + /// + /// The font sizes, weights, and letter spacings in this version match the + /// [2018 Material Design specification](https://material.io/go/design-typography#typography-styles). + static const TextTheme englishLike2018 = TextTheme( + displayLarge: TextStyle( + debugLabel: 'englishLike displayLarge 2018', + inherit: false, + fontSize: 96.0, + fontWeight: FontWeight.w300, + textBaseline: TextBaseline.alphabetic, + letterSpacing: -1.5, + ), + displayMedium: TextStyle( + debugLabel: 'englishLike displayMedium 2018', + inherit: false, + fontSize: 60.0, + fontWeight: FontWeight.w300, + textBaseline: TextBaseline.alphabetic, + letterSpacing: -0.5, + ), + displaySmall: TextStyle( + debugLabel: 'englishLike displaySmall 2018', + inherit: false, + fontSize: 48.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.0, + ), + headlineLarge: TextStyle( + debugLabel: 'englishLike headlineLarge 2018', + inherit: false, + fontSize: 40.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.25, + ), + headlineMedium: TextStyle( + debugLabel: 'englishLike headlineMedium 2018', + inherit: false, + fontSize: 34.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.25, + ), + headlineSmall: TextStyle( + debugLabel: 'englishLike headlineSmall 2018', + inherit: false, + fontSize: 24.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.0, + ), + titleLarge: TextStyle( + debugLabel: 'englishLike titleLarge 2018', + inherit: false, + fontSize: 20.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.15, + ), + titleMedium: TextStyle( + debugLabel: 'englishLike titleMedium 2018', + inherit: false, + fontSize: 16.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.15, + ), + titleSmall: TextStyle( + debugLabel: 'englishLike titleSmall 2018', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.1, + ), + bodyLarge: TextStyle( + debugLabel: 'englishLike bodyLarge 2018', + inherit: false, + fontSize: 16.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.5, + ), + bodyMedium: TextStyle( + debugLabel: 'englishLike bodyMedium 2018', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.25, + ), + bodySmall: TextStyle( + debugLabel: 'englishLike bodySmall 2018', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 0.4, + ), + labelLarge: TextStyle( + debugLabel: 'englishLike labelLarge 2018', + inherit: false, + fontSize: 14.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 1.25, + ), + labelMedium: TextStyle( + debugLabel: 'englishLike labelMedium 2018', + inherit: false, + fontSize: 11.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 1.5, + ), + labelSmall: TextStyle( + debugLabel: 'englishLike labelSmall 2018', + inherit: false, + fontSize: 10.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + letterSpacing: 1.5, + ), + ); + + /// Defines text geometry for dense scripts, such as Chinese, Japanese + /// and Korean. + static const TextTheme dense2014 = TextTheme( + displayLarge: TextStyle( + debugLabel: 'dense displayLarge 2014', + inherit: false, + fontSize: 112.0, + fontWeight: FontWeight.w100, + textBaseline: TextBaseline.ideographic, + ), + displayMedium: TextStyle( + debugLabel: 'dense displayMedium 2014', + inherit: false, + fontSize: 56.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + displaySmall: TextStyle( + debugLabel: 'dense displaySmall 2014', + inherit: false, + fontSize: 45.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + headlineLarge: TextStyle( + debugLabel: 'dense headlineLarge 2014', + inherit: false, + fontSize: 40.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + headlineMedium: TextStyle( + debugLabel: 'dense headlineMedium 2014', + inherit: false, + fontSize: 34.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + headlineSmall: TextStyle( + debugLabel: 'dense headlineSmall 2014', + inherit: false, + fontSize: 24.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + titleLarge: TextStyle( + debugLabel: 'dense titleLarge 2014', + inherit: false, + fontSize: 21.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + titleMedium: TextStyle( + debugLabel: 'dense titleMedium 2014', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + titleSmall: TextStyle( + debugLabel: 'dense titleSmall 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + bodyLarge: TextStyle( + debugLabel: 'dense bodyLarge 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + bodyMedium: TextStyle( + debugLabel: 'dense bodyMedium 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + bodySmall: TextStyle( + debugLabel: 'dense bodySmall 2014', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + labelLarge: TextStyle( + debugLabel: 'dense labelLarge 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + labelMedium: TextStyle( + debugLabel: 'dense labelMedium 2014', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + labelSmall: TextStyle( + debugLabel: 'dense labelSmall 2014', + inherit: false, + fontSize: 11.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + ); + + /// Defines text geometry for dense scripts, such as Chinese, Japanese + /// and Korean. + /// + /// The font sizes, weights, and letter spacings in this version match the + /// 2018 [Material Design specification](https://material.io/go/design-typography#typography-styles). + static const TextTheme dense2018 = TextTheme( + displayLarge: TextStyle( + debugLabel: 'dense displayLarge 2018', + inherit: false, + fontSize: 96.0, + fontWeight: FontWeight.w100, + textBaseline: TextBaseline.ideographic, + ), + displayMedium: TextStyle( + debugLabel: 'dense displayMedium 2018', + inherit: false, + fontSize: 60.0, + fontWeight: FontWeight.w100, + textBaseline: TextBaseline.ideographic, + ), + displaySmall: TextStyle( + debugLabel: 'dense displaySmall 2018', + inherit: false, + fontSize: 48.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + headlineLarge: TextStyle( + debugLabel: 'dense headlineLarge 2018', + inherit: false, + fontSize: 40.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + headlineMedium: TextStyle( + debugLabel: 'dense headlineMedium 2018', + inherit: false, + fontSize: 34.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + headlineSmall: TextStyle( + debugLabel: 'dense headlineSmall 2018', + inherit: false, + fontSize: 24.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + titleLarge: TextStyle( + debugLabel: 'dense titleLarge 2018', + inherit: false, + fontSize: 21.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + titleMedium: TextStyle( + debugLabel: 'dense titleMedium 2018', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + titleSmall: TextStyle( + debugLabel: 'dense titleSmall 2018', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + bodyLarge: TextStyle( + debugLabel: 'dense bodyLarge 2018', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + bodyMedium: TextStyle( + debugLabel: 'dense bodyMedium 2018', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + bodySmall: TextStyle( + debugLabel: 'dense bodySmall 2018', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + labelLarge: TextStyle( + debugLabel: 'dense labelLarge 2018', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.ideographic, + ), + labelMedium: TextStyle( + debugLabel: 'dense labelMedium 2018', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + labelSmall: TextStyle( + debugLabel: 'dense labelSmall 2018', + inherit: false, + fontSize: 11.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.ideographic, + ), + ); + + /// Defines text geometry for tall scripts, such as Farsi, Hindi, and Thai. + static const TextTheme tall2014 = TextTheme( + displayLarge: TextStyle( + debugLabel: 'tall displayLarge 2014', + inherit: false, + fontSize: 112.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + displayMedium: TextStyle( + debugLabel: 'tall displayMedium 2014', + inherit: false, + fontSize: 56.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + displaySmall: TextStyle( + debugLabel: 'tall displaySmall 2014', + inherit: false, + fontSize: 45.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineLarge: TextStyle( + debugLabel: 'tall headlineLarge 2014', + inherit: false, + fontSize: 40.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineMedium: TextStyle( + debugLabel: 'tall headlineMedium 2014', + inherit: false, + fontSize: 34.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineSmall: TextStyle( + debugLabel: 'tall headlineSmall 2014', + inherit: false, + fontSize: 24.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + titleLarge: TextStyle( + debugLabel: 'tall titleLarge 2014', + inherit: false, + fontSize: 21.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + titleMedium: TextStyle( + debugLabel: 'tall titleMedium 2014', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + titleSmall: TextStyle( + debugLabel: 'tall titleSmall 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + ), + bodyLarge: TextStyle( + debugLabel: 'tall bodyLarge 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + bodyMedium: TextStyle( + debugLabel: 'tall bodyMedium 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + bodySmall: TextStyle( + debugLabel: 'tall bodySmall 2014', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + labelLarge: TextStyle( + debugLabel: 'tall labelLarge 2014', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + labelMedium: TextStyle( + debugLabel: 'tall labelMedium 2014', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + labelSmall: TextStyle( + debugLabel: 'tall labelSmall 2014', + inherit: false, + fontSize: 11.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + ); + + /// Defines text geometry for tall scripts, such as Farsi, Hindi, and Thai. + /// + /// The font sizes, weights, and letter spacings in this version match the + /// 2018 [Material Design specification](https://material.io/go/design-typography#typography-styles). + static const TextTheme tall2018 = TextTheme( + displayLarge: TextStyle( + debugLabel: 'tall displayLarge 2018', + inherit: false, + fontSize: 96.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + displayMedium: TextStyle( + debugLabel: 'tall displayMedium 2018', + inherit: false, + fontSize: 60.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + displaySmall: TextStyle( + debugLabel: 'tall displaySmall 2018', + inherit: false, + fontSize: 48.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineLarge: TextStyle( + debugLabel: 'tall headlineLarge 2018', + inherit: false, + fontSize: 40.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineMedium: TextStyle( + debugLabel: 'tall headlineMedium 2018', + inherit: false, + fontSize: 34.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + headlineSmall: TextStyle( + debugLabel: 'tall headlineSmall 2018', + inherit: false, + fontSize: 24.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + titleLarge: TextStyle( + debugLabel: 'tall titleLarge 2018', + inherit: false, + fontSize: 21.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + titleMedium: TextStyle( + debugLabel: 'tall titleMedium 2018', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + titleSmall: TextStyle( + debugLabel: 'tall titleSmall 2018', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textBaseline: TextBaseline.alphabetic, + ), + bodyLarge: TextStyle( + debugLabel: 'tall bodyLarge 2018', + inherit: false, + fontSize: 17.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + bodyMedium: TextStyle( + debugLabel: 'tall bodyMedium 2018', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + bodySmall: TextStyle( + debugLabel: 'tall bodySmall 2018', + inherit: false, + fontSize: 13.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + labelLarge: TextStyle( + debugLabel: 'tall labelLarge 2018', + inherit: false, + fontSize: 15.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + labelMedium: TextStyle( + debugLabel: 'tall labelMedium 2018', + inherit: false, + fontSize: 12.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + labelSmall: TextStyle( + debugLabel: 'tall labelSmall 2018', + inherit: false, + fontSize: 11.0, + fontWeight: FontWeight.w400, + textBaseline: TextBaseline.alphabetic, + ), + ); + + /// Defines text geometry for `ScriptCategory.englishLike` scripts, such as + /// English, French, Russian, etc. + /// + /// The font sizes, weights, and letter spacings in this version match the + /// [2021 Material Design 3 specification](https://m3.material.io/styles/typography/overview). + static const TextTheme englishLike2021 = _M3Typography.englishLike; + + /// Defines text geometry for dense scripts, such as Chinese, Japanese + /// and Korean. + /// + /// The Material Design 3 specification does not include 'dense' text themes, + /// so this is just here to be consistent with the API. + static const TextTheme dense2021 = _M3Typography.dense; + + /// Defines text geometry for tall scripts, such as Farsi, Hindi, and Thai. + /// + /// The Material Design 3 specification does not include 'tall' text themes, + /// so this is just here to be consistent with the API. + static const TextTheme tall2021 = _M3Typography.tall; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Typography + +// 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. + +// dart format off +abstract final class _M3Typography { + static const TextTheme englishLike = TextTheme( + displayLarge: TextStyle(debugLabel: 'englishLike displayLarge 2021', inherit: false, fontSize: 57.0, fontWeight: FontWeight.w400, letterSpacing: -0.25, height: 1.12, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + displayMedium: TextStyle(debugLabel: 'englishLike displayMedium 2021', inherit: false, fontSize: 45.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.16, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + displaySmall: TextStyle(debugLabel: 'englishLike displaySmall 2021', inherit: false, fontSize: 36.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.22, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + headlineLarge: TextStyle(debugLabel: 'englishLike headlineLarge 2021', inherit: false, fontSize: 32.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.25, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + headlineMedium: TextStyle(debugLabel: 'englishLike headlineMedium 2021', inherit: false, fontSize: 28.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.29, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + headlineSmall: TextStyle(debugLabel: 'englishLike headlineSmall 2021', inherit: false, fontSize: 24.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + titleLarge: TextStyle(debugLabel: 'englishLike titleLarge 2021', inherit: false, fontSize: 22.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.27, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + titleMedium: TextStyle(debugLabel: 'englishLike titleMedium 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w500, letterSpacing: 0.15, height: 1.50, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + titleSmall: TextStyle(debugLabel: 'englishLike titleSmall 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + labelLarge: TextStyle(debugLabel: 'englishLike labelLarge 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + labelMedium: TextStyle(debugLabel: 'englishLike labelMedium 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + labelSmall: TextStyle(debugLabel: 'englishLike labelSmall 2021', inherit: false, fontSize: 11.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.45, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + bodyLarge: TextStyle(debugLabel: 'englishLike bodyLarge 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w400, letterSpacing: 0.5, height: 1.50, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + bodyMedium: TextStyle(debugLabel: 'englishLike bodyMedium 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w400, letterSpacing: 0.25, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + bodySmall: TextStyle(debugLabel: 'englishLike bodySmall 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w400, letterSpacing: 0.4, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + ); + + static const TextTheme dense = TextTheme( + displayLarge: TextStyle(debugLabel: 'dense displayLarge 2021', inherit: false, fontSize: 57.0, fontWeight: FontWeight.w400, letterSpacing: -0.25, height: 1.12, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + displayMedium: TextStyle(debugLabel: 'dense displayMedium 2021', inherit: false, fontSize: 45.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.16, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + displaySmall: TextStyle(debugLabel: 'dense displaySmall 2021', inherit: false, fontSize: 36.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.22, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + headlineLarge: TextStyle(debugLabel: 'dense headlineLarge 2021', inherit: false, fontSize: 32.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.25, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + headlineMedium: TextStyle(debugLabel: 'dense headlineMedium 2021', inherit: false, fontSize: 28.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.29, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + headlineSmall: TextStyle(debugLabel: 'dense headlineSmall 2021', inherit: false, fontSize: 24.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.33, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + titleLarge: TextStyle(debugLabel: 'dense titleLarge 2021', inherit: false, fontSize: 22.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.27, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + titleMedium: TextStyle(debugLabel: 'dense titleMedium 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w500, letterSpacing: 0.15, height: 1.50, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + titleSmall: TextStyle(debugLabel: 'dense titleSmall 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + labelLarge: TextStyle(debugLabel: 'dense labelLarge 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + labelMedium: TextStyle(debugLabel: 'dense labelMedium 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.33, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + labelSmall: TextStyle(debugLabel: 'dense labelSmall 2021', inherit: false, fontSize: 11.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.45, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + bodyLarge: TextStyle(debugLabel: 'dense bodyLarge 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w400, letterSpacing: 0.5, height: 1.50, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + bodyMedium: TextStyle(debugLabel: 'dense bodyMedium 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w400, letterSpacing: 0.25, height: 1.43, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + bodySmall: TextStyle(debugLabel: 'dense bodySmall 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w400, letterSpacing: 0.4, height: 1.33, textBaseline: TextBaseline.ideographic, leadingDistribution: TextLeadingDistribution.even), + ); + + static const TextTheme tall = TextTheme( + displayLarge: TextStyle(debugLabel: 'tall displayLarge 2021', inherit: false, fontSize: 57.0, fontWeight: FontWeight.w400, letterSpacing: -0.25, height: 1.12, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + displayMedium: TextStyle(debugLabel: 'tall displayMedium 2021', inherit: false, fontSize: 45.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.16, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + displaySmall: TextStyle(debugLabel: 'tall displaySmall 2021', inherit: false, fontSize: 36.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.22, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + headlineLarge: TextStyle(debugLabel: 'tall headlineLarge 2021', inherit: false, fontSize: 32.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.25, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + headlineMedium: TextStyle(debugLabel: 'tall headlineMedium 2021', inherit: false, fontSize: 28.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.29, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + headlineSmall: TextStyle(debugLabel: 'tall headlineSmall 2021', inherit: false, fontSize: 24.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + titleLarge: TextStyle(debugLabel: 'tall titleLarge 2021', inherit: false, fontSize: 22.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.27, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + titleMedium: TextStyle(debugLabel: 'tall titleMedium 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w500, letterSpacing: 0.15, height: 1.50, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + titleSmall: TextStyle(debugLabel: 'tall titleSmall 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + labelLarge: TextStyle(debugLabel: 'tall labelLarge 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + labelMedium: TextStyle(debugLabel: 'tall labelMedium 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + labelSmall: TextStyle(debugLabel: 'tall labelSmall 2021', inherit: false, fontSize: 11.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.45, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + bodyLarge: TextStyle(debugLabel: 'tall bodyLarge 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w400, letterSpacing: 0.5, height: 1.50, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + bodyMedium: TextStyle(debugLabel: 'tall bodyMedium 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w400, letterSpacing: 0.25, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + bodySmall: TextStyle(debugLabel: 'tall bodySmall 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w400, letterSpacing: 0.4, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even), + ); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - Typography diff --git a/packages/material_ui/lib/src/user_accounts_drawer_header.dart b/packages/material_ui/lib/src/user_accounts_drawer_header.dart new file mode 100644 index 000000000000..fd489359bcbe --- /dev/null +++ b/packages/material_ui/lib/src/user_accounts_drawer_header.dart @@ -0,0 +1,396 @@ +// 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. + +/// @docImport 'circle_avatar.dart'; +/// @docImport 'drawer.dart'; +/// @docImport 'material.dart'; +library; + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'debug.dart'; +import 'drawer_header.dart'; +import 'icons.dart'; +import 'ink_well.dart'; +import 'material_localizations.dart'; +import 'theme.dart'; + +class _AccountPictures extends StatelessWidget { + const _AccountPictures({ + this.currentAccountPicture, + this.otherAccountsPictures, + this.currentAccountPictureSize, + this.otherAccountsPicturesSize, + }); + + final Widget? currentAccountPicture; + final List<Widget>? otherAccountsPictures; + final Size? currentAccountPictureSize; + final Size? otherAccountsPicturesSize; + + @override + Widget build(BuildContext context) { + return Stack( + children: <Widget>[ + PositionedDirectional( + top: 0.0, + end: 0.0, + child: Row( + children: (otherAccountsPictures ?? <Widget>[]).take(3).map<Widget>((Widget picture) { + return Padding( + padding: const EdgeInsetsDirectional.only(start: 8.0), + child: Semantics( + container: true, + child: Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 8.0), + child: SizedBox.fromSize(size: otherAccountsPicturesSize, child: picture), + ), + ), + ); + }).toList(), + ), + ), + Positioned( + top: 0.0, + child: Semantics( + explicitChildNodes: true, + child: SizedBox.fromSize(size: currentAccountPictureSize, child: currentAccountPicture), + ), + ), + ], + ); + } +} + +class _AccountDetails extends StatefulWidget { + const _AccountDetails({ + required this.accountName, + required this.accountEmail, + this.onTap, + required this.isOpen, + this.arrowColor, + }); + + final Widget? accountName; + final Widget? accountEmail; + final VoidCallback? onTap; + final bool isOpen; + final Color? arrowColor; + + @override + _AccountDetailsState createState() => _AccountDetailsState(); +} + +class _AccountDetailsState extends State<_AccountDetails> with SingleTickerProviderStateMixin { + late final CurvedAnimation _animation; + late final AnimationController _controller; + @override + void initState() { + super.initState(); + _controller = AnimationController( + value: widget.isOpen ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _animation = + CurvedAnimation( + parent: _controller, + curve: Curves.fastOutSlowIn, + reverseCurve: Curves.fastOutSlowIn.flipped, + )..addListener( + () => setState(() { + // [animation]'s value has changed here. + }), + ); + } + + @override + void dispose() { + _controller.dispose(); + _animation.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(_AccountDetails oldWidget) { + super.didUpdateWidget(oldWidget); + // If the state of the arrow did not change, there is no need to trigger the animation + if (oldWidget.isOpen == widget.isOpen) { + return; + } + + if (widget.isOpen) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasMaterialLocalizations(context)); + + final ThemeData theme = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + Widget accountDetails = CustomMultiChildLayout( + delegate: _AccountDetailsLayout(textDirection: Directionality.of(context)), + children: <Widget>[ + if (widget.accountName != null) + LayoutId( + id: _AccountDetailsLayout.accountName, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: DefaultTextStyle( + style: theme.primaryTextTheme.bodyLarge!, + overflow: TextOverflow.ellipsis, + child: widget.accountName!, + ), + ), + ), + if (widget.accountEmail != null) + LayoutId( + id: _AccountDetailsLayout.accountEmail, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: DefaultTextStyle( + style: theme.primaryTextTheme.bodyMedium!, + overflow: TextOverflow.ellipsis, + child: widget.accountEmail!, + ), + ), + ), + if (widget.onTap != null) + LayoutId( + id: _AccountDetailsLayout.dropdownIcon, + child: Semantics( + container: true, + button: true, + onTap: widget.onTap, + child: SizedBox.square( + dimension: _kAccountDetailsHeight, + child: Center( + child: Transform.rotate( + angle: _animation.value * math.pi, + child: Icon( + Icons.arrow_drop_down, + color: widget.arrowColor, + semanticLabel: widget.isOpen + ? localizations.hideAccountsLabel + : localizations.showAccountsLabel, + ), + ), + ), + ), + ), + ), + ], + ); + + if (widget.onTap != null) { + accountDetails = InkWell( + onTap: widget.onTap, + excludeFromSemantics: true, + child: accountDetails, + ); + } + + return SizedBox(height: _kAccountDetailsHeight, child: accountDetails); + } +} + +const double _kAccountDetailsHeight = 56.0; + +class _AccountDetailsLayout extends MultiChildLayoutDelegate { + _AccountDetailsLayout({required this.textDirection}); + + static const String accountName = 'accountName'; + static const String accountEmail = 'accountEmail'; + static const String dropdownIcon = 'dropdownIcon'; + + final TextDirection textDirection; + + @override + void performLayout(Size size) { + Size? iconSize; + if (hasChild(dropdownIcon)) { + // place the dropdown icon in bottom right (LTR) or bottom left (RTL) + iconSize = layoutChild(dropdownIcon, BoxConstraints.loose(size)); + positionChild(dropdownIcon, _offsetForIcon(size, iconSize)); + } + + final String? bottomLine = hasChild(accountEmail) + ? accountEmail + : (hasChild(accountName) ? accountName : null); + + if (bottomLine != null) { + final constraintSize = iconSize == null + ? size + : Size(size.width - iconSize.width, size.height); + iconSize ??= const Size(_kAccountDetailsHeight, _kAccountDetailsHeight); + + // place bottom line center at same height as icon center + final Size bottomLineSize = layoutChild(bottomLine, BoxConstraints.loose(constraintSize)); + final Offset bottomLineOffset = _offsetForBottomLine(size, iconSize, bottomLineSize); + positionChild(bottomLine, bottomLineOffset); + + // place account name above account email + if (bottomLine == accountEmail && hasChild(accountName)) { + final Size nameSize = layoutChild(accountName, BoxConstraints.loose(constraintSize)); + positionChild(accountName, _offsetForName(size, nameSize, bottomLineOffset)); + } + } + } + + @override + bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => true; + + Offset _offsetForIcon(Size size, Size iconSize) { + return switch (textDirection) { + TextDirection.ltr => Offset(size.width - iconSize.width, size.height - iconSize.height), + TextDirection.rtl => Offset(0.0, size.height - iconSize.height), + }; + } + + Offset _offsetForBottomLine(Size size, Size iconSize, Size bottomLineSize) { + final double y = size.height - 0.5 * iconSize.height - 0.5 * bottomLineSize.height; + return switch (textDirection) { + TextDirection.ltr => Offset(0.0, y), + TextDirection.rtl => Offset(size.width - bottomLineSize.width, y), + }; + } + + Offset _offsetForName(Size size, Size nameSize, Offset bottomLineOffset) { + final double y = bottomLineOffset.dy - nameSize.height; + return switch (textDirection) { + TextDirection.ltr => Offset(0.0, y), + TextDirection.rtl => Offset(size.width - nameSize.width, y), + }; + } +} + +/// A Material Design [Drawer] header that identifies the app's user. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// See also: +/// +/// * [DrawerHeader], for a drawer header that doesn't show user accounts. +/// * <https://material.io/design/components/navigation-drawer.html#anatomy> +class UserAccountsDrawerHeader extends StatefulWidget { + /// Creates a Material Design drawer header. + /// + /// Requires one of its ancestors to be a [Material] widget. + const UserAccountsDrawerHeader({ + super.key, + this.decoration, + this.margin = const EdgeInsets.only(bottom: 8.0), + this.currentAccountPicture, + this.otherAccountsPictures, + this.currentAccountPictureSize = const Size.square(72.0), + this.otherAccountsPicturesSize = const Size.square(40.0), + required this.accountName, + required this.accountEmail, + this.onDetailsPressed, + this.arrowColor = Colors.white, + }); + + /// The header's background. If decoration is null then a [BoxDecoration] + /// with its background color set to the current theme's primaryColor is used. + final Decoration? decoration; + + /// The margin around the drawer header. + final EdgeInsetsGeometry? margin; + + /// A widget placed in the upper-left corner that represents the current + /// user's account. Normally a [CircleAvatar]. + final Widget? currentAccountPicture; + + /// A list of widgets that represent the current user's other accounts. + /// Up to three of these widgets will be arranged in a row in the header's + /// upper-right corner. Normally a list of [CircleAvatar] widgets. + final List<Widget>? otherAccountsPictures; + + /// The size of the [currentAccountPicture]. + final Size currentAccountPictureSize; + + /// The size of each widget in [otherAccountsPicturesSize]. + final Size otherAccountsPicturesSize; + + /// A widget that represents the user's current account name. It is + /// displayed on the left, below the [currentAccountPicture]. + final Widget? accountName; + + /// A widget that represents the email address of the user's current account. + /// It is displayed on the left, below the [accountName]. + final Widget? accountEmail; + + /// A callback that is called when the horizontal area which contains the + /// [accountName] and [accountEmail] is tapped. + final VoidCallback? onDetailsPressed; + + /// The [Color] of the arrow icon. + final Color arrowColor; + + @override + State<UserAccountsDrawerHeader> createState() => _UserAccountsDrawerHeaderState(); +} + +class _UserAccountsDrawerHeaderState extends State<UserAccountsDrawerHeader> { + bool _isOpen = false; + + void _handleDetailsPressed() { + setState(() { + _isOpen = !_isOpen; + }); + widget.onDetailsPressed!(); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + return Semantics( + container: true, + label: MaterialLocalizations.of(context).signedInLabel, + child: DrawerHeader( + decoration: + widget.decoration ?? BoxDecoration(color: Theme.of(context).colorScheme.primary), + margin: widget.margin, + padding: const EdgeInsetsDirectional.only(top: 16.0, start: 16.0), + child: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 16.0), + child: _AccountPictures( + currentAccountPicture: widget.currentAccountPicture, + otherAccountsPictures: widget.otherAccountsPictures, + currentAccountPictureSize: widget.currentAccountPictureSize, + otherAccountsPicturesSize: widget.otherAccountsPicturesSize, + ), + ), + ), + _AccountDetails( + accountName: widget.accountName, + accountEmail: widget.accountEmail, + isOpen: _isOpen, + onTap: widget.onDetailsPressed == null ? null : _handleDetailsPressed, + arrowColor: widget.arrowColor, + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/.gitignore b/packages/material_ui/material_ui_examples/.gitignore new file mode 100644 index 000000000000..c31b751700e0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/.gitignore @@ -0,0 +1,3 @@ +# Unused platform specific files +android/ +ios/ diff --git a/packages/material_ui/material_ui_examples/.metadata b/packages/material_ui/material_ui_examples/.metadata new file mode 100644 index 000000000000..579126128d1f --- /dev/null +++ b/packages/material_ui/material_ui_examples/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "89172fc6152056af222e0afcf6e2d6ee4e9f6f8f" + channel: "master" + +project_type: package diff --git a/packages/material_ui/material_ui_examples/README.md b/packages/material_ui/material_ui_examples/README.md new file mode 100644 index 000000000000..d215a4c02b24 --- /dev/null +++ b/packages/material_ui/material_ui_examples/README.md @@ -0,0 +1,163 @@ +# material_ui API Example Code +This directory contains the example code that is referenced in the documentation +in material_ui's source code. + +These examples were originally located [in +flutter/flutter](https://github.com/flutter/flutter/tree/master/examples/api) +before the Material library was decoupled and moved into its current home in +flutter/packages. + +The examples can be run individually by just specifying the path to the example +on the command line (or in the run configuration of an IDE). + +For example (no pun intended!), to run the first example from the +`AboutListTile` class in Chrome, you would run it like so from the [api](.) +directory: + +``` +% flutter run -d chrome lib/about/about_list_tile.0.dart +``` + +All of these same examples are available on the API docs site. + +<!-- TODO(justinmc): Include a link to the docs page with the example above like this: For instance, the example above is available on [this page](https://api.flutter.dev/flutter/animation/Curve2D-class.html#animation.Curve2D.1). +--> + +<!-- TODO(justinmc): Uncomment when Dartpads work again. +Most of the samples are available as interactive examples in +[Dartpad](https://dartpad.dev), but some (the ones marked with `{@tool sample}` +in the framework source code), just don't make sense on the web, and so are +available as standalone examples that can be run here. For instance, setting the +system overlay style doesn't make sense on the web (it only changes the +notification area background color on Android), so you can run the example for +that on an Android device like so: + +``` +% flutter run -d MyAndroidDevice lib/services/system_chrome/system_chrome.set_system_u_i_overlay_style.1.dart +``` +--> + +## Naming + +> `lib/file/class_name.n.dart` +> +> `lib/file/class_name.member_name.n.dart` + +The naming scheme corresponds to the files under [lib/src](../lib/src) where +each file is represented as a directory (without the `.dart` suffix), and each +sample in the file is a separate file in that directory. So, for the example +above, where the examples are from the +[lib/src/about.dart](../lib/src/about.dart) file, the `AboutListTile` class, the +first sample (hence the index "0") for that symbol resides in the file named +[lib/about/about_list_tile.0.dart](lib/about/about_list_tile.0.dart). + +Symbol names are converted from "CamelCase" to "snake_case". Dots are left +between symbol names, so the first example for symbol +`InputDecoration.prefixIconConstraints` would be converted to +`input_decoration.prefix_icon_constraints.0.dart`. + +If the same example is linked to from multiple symbols, the source will be in +the canonical location for one of the symbols, and the link in the API docs +block for the other symbols will point to the first symbol's example location. + +## Authoring + +> For more detailed information about authoring examples, see +> [the snippets package](https://pub.dev/packages/snippets). + +When authoring examples, first place a block in the Dartdoc documentation for +the symbol you would like to attach it to. Here's what it might look like if you +wanted to add a new example to the `AboutListTile` class: + +```dart +/// {@tool dartpad} +/// Write a description of the example here. This description will appear in the +/// API web documentation to introduce the example. +/// +/// ** See code in material_ui_examples/lib/about/about_list_tile.0.dart ** +/// {@end-tool} +``` + +The "See code in" line needs to be formatted exactly as above, with no wrapping +or newlines, one space after the "`**`" at the beginning, and one space before +the "`**`" at the end, and the words "See code in" at the beginning of the line. +This is what the snippets tool use when finding the example source code that you +are creating. + +<!-- TODO(justinmc): Uncomment when Dartpad works again. +Use `{@tool dartpad}` for Dartpad examples, and use `{@tool sample}` for +examples that shouldn't be run/shown in Dartpad. + +Once that comment block is inserted in the source code, create a new file at the +appropriate path under [`examples/api`](.). See the +[sample_templates](./lib/sample_templates/) directory for examples of different +types of samples with some best practices applied. + +The filename should match the location of the source file it is linked from, and +is named for the symbol it is attached to, in lower_snake_case, with an index +relating to their order within the doc comment. So, for the `Curve2D` example +above, since it's in the `animation` library, in a file called `curves.dart`, +and it's the first example, it should have the name +`examples/api/lib/animation/curves/curve2_d.0.dart`. +--> + +You should also add tests for your sample code under +[`material_ui_examples/test`](./test), that matches their location under [lib](./lib), +ending in `_test.dart`. See the section on [writing tests](#writing-tests) for +more information on what kinds of tests to write. + +The entire example should be in a single file, so that Dartpad can load it. + +Only packages that can be loaded by Dartpad may be imported. If you use one that +hasn't been used in an example before, you may have to add it to the +[pubspec.yaml](pubspec.yaml) in the [material_ui_examples](./) directory. + +## Snippets + +There is another type of example that can also be authored, using `{@tool +snippet}`. Snippet examples are just written inline in the source, like so: + +```dart +/// {@tool dartpad} +/// Write a description of the example here. This description will appear in the +/// API web documentation to introduce the example. +/// +/// ```dart +/// // Sample code goes here, e.g.: +/// const Widget emptyBox = SizedBox(); +/// ``` +/// {@end-tool} +``` + +The source for these snippets isn't stored under the [`material_ui_examples`](.) +directory, or available in Dartpad in the API docs, since they're not intended +to be runnable, they just show some incomplete snippet of example code. It must +compile (in the context of the sample analyzer), but doesn't need to do +anything. See [the snippets documentation]( +https://pub.dev/packages/snippets#snippet-tool) for more information about the +context that the analyzer uses. + +## Writing Tests + +Examples are required to have tests. There is already a "smoke test" that simply +builds and runs all the API examples, just to make sure that they start up +without crashing. Functionality tests are required the examples, and generally +just do what is normally done for writing tests. The one thing that makes it +more challenging to do for examples is that they can't really be written for +testability in any obvious way, since that would complicate the examples and +make them harder to explain. + +As an example, in regular framework code, you might include a parameter for a +`Platform` object that can be overridden by a test to supply a dummy platform, +but in the example. This would be unnecessarily complex for the example. In all +other ways, these are just normal tests. You don't need to re-test the +functionality of the widget being used in the example, but you should test the +functionality and integrity of the example itself. + +Tests go into a directory under [test](./test) that matches their location under +[lib](./lib). They are named the same as the example they are testing, with +`_test.dart` at the end, like other tests. For instance, an `AboutListTile` +example that resides in [`lib/about/about_list_tile.0.dart`]( +./lib/about/about_list_tile.0.dart) would have its tests in a +file named [`test/about/about_list_tile.0_test.dart`]( +./test/about/about_list_tile.0_test.dart) diff --git a/packages/material_ui/material_ui_examples/analysis_options.yaml b/packages/material_ui/material_ui_examples/analysis_options.yaml new file mode 100644 index 000000000000..ca26f0d08d76 --- /dev/null +++ b/packages/material_ui/material_ui_examples/analysis_options.yaml @@ -0,0 +1,12 @@ +# This file is also used by dev/bots/analyze_snippet_code.dart to analyze code snippets (`{@tool snippet}` sections). + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # Samples want to print things pretty often. + avoid_print: false + # Samples are sometimes incomplete and don't show usage of everything. + unreachable_from_main: false diff --git a/packages/material_ui/material_ui_examples/lib/about/about_list_tile.0.dart b/packages/material_ui/material_ui_examples/lib/about/about_list_tile.0.dart new file mode 100644 index 000000000000..63daccd2f3de --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/about/about_list_tile.0.dart @@ -0,0 +1,82 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AboutListTile]. + +void main() => runApp(const AboutListTileExampleApp()); + +class AboutListTileExampleApp extends StatelessWidget { + const AboutListTileExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: AboutListTileExample()); + } +} + +class AboutListTileExample extends StatelessWidget { + const AboutListTileExample({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle textStyle = theme.textTheme.bodyMedium!; + final List<Widget> aboutBoxChildren = <Widget>[ + const SizedBox(height: 24), + RichText( + text: TextSpan( + children: <TextSpan>[ + TextSpan( + style: textStyle, + text: + "Flutter is Google's UI toolkit for building beautiful, " + 'natively compiled applications for mobile, web, and desktop ' + 'from a single codebase. Learn more about Flutter at ', + ), + TextSpan( + style: textStyle.copyWith(color: theme.colorScheme.primary), + text: 'https://flutter.dev', + ), + TextSpan(style: textStyle, text: '.'), + ], + ), + ), + ]; + + return Scaffold( + appBar: AppBar(title: const Text('Show About Example')), + drawer: Drawer( + child: SingleChildScrollView( + child: SafeArea( + child: AboutListTile( + icon: const Icon(Icons.info), + applicationIcon: const FlutterLogo(), + applicationName: 'Show About Example', + applicationVersion: 'August 2019', + applicationLegalese: '\u{a9} 2014 The Flutter Authors', + aboutBoxChildren: aboutBoxChildren, + ), + ), + ), + ), + body: Center( + child: ElevatedButton( + child: const Text('Show About Example'), + onPressed: () { + showAboutDialog( + context: context, + applicationIcon: const FlutterLogo(), + applicationName: 'Show About Example', + applicationVersion: 'August 2019', + applicationLegalese: '\u{a9} 2014 The Flutter Authors', + children: aboutBoxChildren, + ); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/action_buttons/action_icon_theme.0.dart b/packages/material_ui/material_ui_examples/lib/action_buttons/action_icon_theme.0.dart new file mode 100644 index 000000000000..a067328807f5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/action_buttons/action_icon_theme.0.dart @@ -0,0 +1,120 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ActionIconTheme]. + +void main() { + runApp(const ActionIconThemeExampleApp()); +} + +class _CustomEndDrawerIcon extends StatelessWidget { + const _CustomEndDrawerIcon(); + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localization = MaterialLocalizations.of( + context, + ); + return Icon( + Icons.more_horiz, + semanticLabel: localization.openAppDrawerTooltip, + ); + } +} + +class _CustomDrawerIcon extends StatelessWidget { + const _CustomDrawerIcon(); + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localization = MaterialLocalizations.of( + context, + ); + return Icon( + Icons.segment, + semanticLabel: localization.openAppDrawerTooltip, + ); + } +} + +class ActionIconThemeExampleApp extends StatelessWidget { + const ActionIconThemeExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + actionIconTheme: ActionIconThemeData( + backButtonIconBuilder: (BuildContext context) { + return const Icon(Icons.arrow_back_ios_new_rounded); + }, + drawerButtonIconBuilder: (BuildContext context) { + return const _CustomDrawerIcon(); + }, + endDrawerButtonIconBuilder: (BuildContext context) { + return const _CustomEndDrawerIcon(); + }, + ), + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + drawer: Drawer( + child: Column( + children: <Widget>[ + TextButton(child: const Text('Drawer Item'), onPressed: () {}), + ], + ), + ), + body: const Center(child: NextPageButton()), + ); + } +} + +class NextPageButton extends StatelessWidget { + const NextPageButton({super.key}); + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<MySecondPage>( + builder: (BuildContext context) { + return const MySecondPage(); + }, + ), + ); + }, + icon: const Icon(Icons.arrow_forward), + label: const Text('Next page'), + ); + } +} + +class MySecondPage extends StatelessWidget { + const MySecondPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Second page')), + endDrawer: const Drawer(), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/action_chip/action_chip.0.dart b/packages/material_ui/material_ui_examples/lib/action_chip/action_chip.0.dart new file mode 100644 index 000000000000..71f5bd39dc3e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/action_chip/action_chip.0.dart @@ -0,0 +1,50 @@ +// 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 [ActionChip]. + +import 'package:material_ui/material_ui.dart'; + +void main() => runApp(const ChipApp()); + +class ChipApp extends StatelessWidget { + const ChipApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const ActionChipExample(), + ); + } +} + +class ActionChipExample extends StatefulWidget { + const ActionChipExample({super.key}); + + @override + State<ActionChipExample> createState() => _ActionChipExampleState(); +} + +class _ActionChipExampleState extends State<ActionChipExample> { + bool favorite = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ActionChip Sample')), + body: Center( + child: ActionChip( + avatar: Icon(favorite ? Icons.favorite : Icons.favorite_border), + label: const Text('Save to favorites'), + onPressed: () { + setState(() { + favorite = !favorite; + }); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/animated_icon/animated_icon.0.dart b/packages/material_ui/material_ui_examples/lib/animated_icon/animated_icon.0.dart new file mode 100644 index 000000000000..65d45099cb18 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/animated_icon/animated_icon.0.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AnimatedIcon]. + +void main() { + runApp(const AnimatedIconApp()); +} + +class AnimatedIconApp extends StatelessWidget { + const AnimatedIconApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const Scaffold(body: AnimatedIconExample()), + ); + } +} + +class AnimatedIconExample extends StatefulWidget { + const AnimatedIconExample({super.key}); + + @override + State<AnimatedIconExample> createState() => _AnimatedIconExampleState(); +} + +class _AnimatedIconExampleState extends State<AnimatedIconExample> + with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation<double> animation; + + @override + void initState() { + super.initState(); + controller = + AnimationController(vsync: this, duration: const Duration(seconds: 2)) + ..forward() + ..repeat(reverse: true); + animation = Tween<double>(begin: 0.0, end: 1.0).animate(controller); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: animation, + size: 72.0, + semanticLabel: 'Show menu', + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/animated_icon/animated_icons_data.0.dart b/packages/material_ui/material_ui_examples/lib/animated_icon/animated_icons_data.0.dart new file mode 100644 index 000000000000..07b88c39e820 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/animated_icon/animated_icons_data.0.dart @@ -0,0 +1,101 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AnimatedIcon]. + +final Map<String, AnimatedIconData> iconsList = <String, AnimatedIconData>{ + 'add_event': AnimatedIcons.add_event, + 'arrow_menu': AnimatedIcons.arrow_menu, + 'close_menu': AnimatedIcons.close_menu, + 'ellipsis_search': AnimatedIcons.ellipsis_search, + 'event_add': AnimatedIcons.event_add, + 'home_menu': AnimatedIcons.home_menu, + 'list_view': AnimatedIcons.list_view, + 'menu_arrow': AnimatedIcons.menu_arrow, + 'menu_close': AnimatedIcons.menu_close, + 'menu_home': AnimatedIcons.menu_home, + 'pause_play': AnimatedIcons.pause_play, + 'play_pause': AnimatedIcons.play_pause, + 'search_ellipsis': AnimatedIcons.search_ellipsis, + 'view_list': AnimatedIcons.view_list, +}; + +void main() { + runApp(const AnimatedIconApp()); +} + +class AnimatedIconApp extends StatelessWidget { + const AnimatedIconApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const Scaffold(body: AnimatedIconExample()), + ); + } +} + +class AnimatedIconExample extends StatefulWidget { + const AnimatedIconExample({super.key}); + + @override + State<AnimatedIconExample> createState() => _AnimatedIconExampleState(); +} + +class _AnimatedIconExampleState extends State<AnimatedIconExample> + with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation<double> animation; + + @override + void initState() { + super.initState(); + controller = + AnimationController(vsync: this, duration: const Duration(seconds: 2)) + ..forward() + ..repeat(reverse: true); + animation = Tween<double>(begin: 0.0, end: 1.0).animate(controller); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GridView( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + ), + children: iconsList.entries.map(( + MapEntry<String, AnimatedIconData> entry, + ) { + return Card( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + AnimatedIcon( + icon: entry.value, + progress: animation, + size: 72.0, + semanticLabel: entry.key, + ), + const SizedBox(height: 8.0), + Text(entry.key), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app/app.0.dart b/packages/material_ui/material_ui_examples/lib/app/app.0.dart new file mode 100644 index 000000000000..9200ee7f85b6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app/app.0.dart @@ -0,0 +1,95 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [MaterialApp]. + +void main() { + runApp(const MaterialAppExample()); +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (.defaultStyle, 'Default'), + (.custom, 'Custom'), + (.none, 'None'), + ]; + +class MaterialAppExample extends StatefulWidget { + const MaterialAppExample({super.key}); + + @override + State<MaterialAppExample> createState() => _MaterialAppExampleState(); +} + +class _MaterialAppExampleState extends State<MaterialAppExample> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + .defaultStyle, + }; + AnimationStyle? _animationStyle; + bool isDarkTheme = false; + + @override + Widget build(BuildContext context) { + return MaterialApp( + themeAnimationStyle: _animationStyle, + themeMode: isDarkTheme ? .dark : .light, + theme: ThemeData(colorSchemeSeed: Colors.green), + darkTheme: ThemeData(colorSchemeSeed: Colors.green, brightness: .dark), + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyleSelection = styles; + switch (styles.first) { + case AnimationStyles.defaultStyle: + _animationStyle = null; + case AnimationStyles.custom: + _animationStyle = const AnimationStyle( + curve: Easing.emphasizedAccelerate, + duration: Duration(seconds: 1), + ); + case AnimationStyles.none: + _animationStyle = .noAnimation; + } + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: () { + setState(() { + isDarkTheme = !isDarkTheme; + }); + }, + icon: Icon( + isDarkTheme ? Icons.wb_sunny : Icons.nightlight_round, + ), + label: const Text('Switch Theme Mode'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.0.dart b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.0.dart new file mode 100644 index 000000000000..691c81eb5628 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.0.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AppBar]. + +void main() => runApp(const AppBarApp()); + +class AppBarApp extends StatelessWidget { + const AppBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: AppBarExample()); + } +} + +class AppBarExample extends StatelessWidget { + const AppBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('AppBar Demo'), + actions: <Widget>[ + IconButton( + icon: const Icon(Icons.add_alert), + tooltip: 'Show Snackbar', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('This is a snackbar')), + ); + }, + ), + IconButton( + icon: const Icon(Icons.navigate_next), + tooltip: 'Go to the next page', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Next page')), + body: const Center( + child: Text( + 'This is the next page', + style: TextStyle(fontSize: 24), + ), + ), + ); + }, + ), + ); + }, + ), + ], + ), + body: const Center( + child: Text('This is the home page', style: TextStyle(fontSize: 24)), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.1.dart b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.1.dart new file mode 100644 index 000000000000..1f56c9017f2c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.1.dart @@ -0,0 +1,121 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AppBar]. + +final List<int> _items = List<int>.generate(51, (int index) => index); + +void main() => runApp(const AppBarApp()); + +class AppBarApp extends StatelessWidget { + const AppBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const AppBarExample(), + ); + } +} + +class AppBarExample extends StatefulWidget { + const AppBarExample({super.key}); + + @override + State<AppBarExample> createState() => _AppBarExampleState(); +} + +class _AppBarExampleState extends State<AppBarExample> { + bool shadowColor = false; + double? scrolledUnderElevation; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color oddItemColor = colorScheme.primary.withValues(alpha: 0.05); + final Color evenItemColor = colorScheme.primary.withValues(alpha: 0.15); + + return Scaffold( + appBar: AppBar( + title: const Text('AppBar Demo'), + scrolledUnderElevation: scrolledUnderElevation, + shadowColor: shadowColor ? Theme.of(context).colorScheme.shadow : null, + ), + body: GridView.builder( + itemCount: _items.length, + padding: const .all(8.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.0, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + ), + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return Center( + child: Text( + 'Scroll to see the Appbar in effect.', + style: Theme.of(context).textTheme.labelLarge, + textAlign: .center, + ), + ); + } + return Container( + alignment: .center, + // tileColor: _items[index].isOdd ? oddItemColor : evenItemColor, + decoration: BoxDecoration( + borderRadius: .circular(20.0), + color: _items[index].isOdd ? oddItemColor : evenItemColor, + ), + child: Text('Item $index'), + ); + }, + ), + bottomNavigationBar: BottomAppBar( + child: Padding( + padding: const .all(8), + child: OverflowBar( + overflowAlignment: .center, + alignment: .center, + overflowSpacing: 5.0, + children: <Widget>[ + ElevatedButton.icon( + onPressed: () { + setState(() { + shadowColor = !shadowColor; + }); + }, + icon: Icon( + shadowColor ? Icons.visibility_off : Icons.visibility, + ), + label: const Text('shadow color'), + ), + const SizedBox(width: 5), + ElevatedButton( + onPressed: () { + if (scrolledUnderElevation == null) { + setState(() { + // Default elevation is 3.0, increment by 1.0. + scrolledUnderElevation = 4.0; + }); + } else { + setState(() { + scrolledUnderElevation = scrolledUnderElevation! + 1.0; + }); + } + }, + child: Text( + 'scrolledUnderElevation: ${scrolledUnderElevation ?? 'default'}', + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.2.dart b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.2.dart new file mode 100644 index 000000000000..738ae6b96479 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.2.dart @@ -0,0 +1,45 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AppBar]. + +void main() => runApp(const AppBarApp()); + +class AppBarApp extends StatelessWidget { + const AppBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: AppBarExample()); + } +} + +class AppBarExample extends StatelessWidget { + const AppBarExample({super.key}); + + @override + Widget build(BuildContext context) { + final ButtonStyle style = TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ); + return Scaffold( + appBar: AppBar( + actions: <Widget>[ + TextButton( + style: style, + onPressed: () {}, + child: const Text('Action 1'), + ), + TextButton( + style: style, + onPressed: () {}, + child: const Text('Action 2'), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.3.dart b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.3.dart new file mode 100644 index 000000000000..b7700ef225ec --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.3.dart @@ -0,0 +1,99 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AppBar]. + +List<String> titles = <String>['Cloud', 'Beach', 'Sunny']; + +void main() => runApp(const AppBarApp()); + +class AppBarApp extends StatelessWidget { + const AppBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const AppBarExample(), + ); + } +} + +class AppBarExample extends StatelessWidget { + const AppBarExample({super.key}); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color oddItemColor = colorScheme.primary.withValues(alpha: 0.05); + final Color evenItemColor = colorScheme.primary.withValues(alpha: 0.15); + const int tabsCount = 3; + + return DefaultTabController( + initialIndex: 1, + length: tabsCount, + child: Scaffold( + appBar: AppBar( + title: const Text('AppBar Sample'), + // This check specifies which nested Scrollable's scroll notification + // should be listened to. + // + // When `ThemeData.useMaterial3` is true and scroll view has + // scrolled underneath the app bar, this updates the app bar + // background color and elevation. + // + // This sets `notification.depth == 1` to listen to the scroll + // notification from the nested `ListView.builder`. + notificationPredicate: (ScrollNotification notification) { + return notification.depth == 1; + }, + // The elevation value of the app bar when scroll view has + // scrolled underneath the app bar. + scrolledUnderElevation: 4.0, + shadowColor: Theme.of(context).shadowColor, + bottom: TabBar( + tabs: <Widget>[ + Tab(icon: const Icon(Icons.cloud_outlined), text: titles[0]), + Tab(icon: const Icon(Icons.beach_access_sharp), text: titles[1]), + Tab(icon: const Icon(Icons.brightness_5_sharp), text: titles[2]), + ], + ), + ), + body: TabBarView( + children: <Widget>[ + ListView.builder( + itemCount: 25, + itemBuilder: (BuildContext context, int index) { + return ListTile( + tileColor: index.isOdd ? oddItemColor : evenItemColor, + title: Text('${titles[0]} $index'), + ); + }, + ), + ListView.builder( + itemCount: 25, + itemBuilder: (BuildContext context, int index) { + return ListTile( + tileColor: index.isOdd ? oddItemColor : evenItemColor, + title: Text('${titles[1]} $index'), + ); + }, + ), + ListView.builder( + itemCount: 25, + itemBuilder: (BuildContext context, int index) { + return ListTile( + tileColor: index.isOdd ? oddItemColor : evenItemColor, + title: Text('${titles[2]} $index'), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.4.dart b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.4.dart new file mode 100644 index 000000000000..7740c961ebfa --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/app_bar.4.dart @@ -0,0 +1,150 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AppBar.shape]. + +void main() => runApp(const AppBarExampleApp()); + +class AppBarExampleApp extends StatelessWidget { + const AppBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: AppBarExample()); + } +} + +class AppBarExample extends StatelessWidget { + const AppBarExample({super.key}); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + shape: const CustomAppBarShape(), + backgroundColor: colorScheme.primaryContainer, + title: const Text('AppBar Sample'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(64.0), + child: Padding( + padding: const .symmetric(horizontal: 16.0), + child: TextField( + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.onPrimaryContainer), + ), + filled: true, + hintText: 'Enter a search term', + fillColor: colorScheme.surface, + prefixIcon: Icon( + Icons.search_rounded, + color: colorScheme.primary, + ), + suffixIcon: Icon( + Icons.tune_rounded, + color: colorScheme.primary, + ), + ), + ), + ), + ), + ), + body: ListView.builder( + padding: const .only(top: 45.0), + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return ListTile(title: Text('Item $index')); + }, + ), + ); + } +} + +class CustomAppBarShape extends OutlinedBorder { + // Implementing the constructor allows the CustomAppBarShape to be + // properly compared when calling the `identical` method. + const CustomAppBarShape({super.side}); + + Path _getPath(Rect rect) { + final Path path = Path(); + final Size size = Size(rect.width, rect.height * 1.5); + + final double p0 = size.height * 0.75; + path.lineTo(0.0, p0); + + final Offset controlPoint = Offset(size.width * 0.4, size.height); + final Offset endPoint = Offset(size.width, size.height / 2); + path.quadraticBezierTo( + controlPoint.dx, + controlPoint.dy, + endPoint.dx, + endPoint.dy, + ); + + path.lineTo(size.width, 0.0); + path.close(); + + return path; + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return _getPath(rect.inflate(side.width)); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return _getPath(rect); + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + if (rect.isEmpty) { + return; + } + canvas.drawPath( + getOuterPath(rect, textDirection: textDirection), + side.toPaint(), + ); + } + + @override + ShapeBorder scale(double t) { + return CustomAppBarShape(side: side.scale(t)); + } + + @override + OutlinedBorder copyWith({BorderSide? side}) { + return CustomAppBarShape(side: side ?? this.side); + } + + // The lerpFrom method is necessary for the CustomAppBarShape to be + // properly animated when changing the AppBar shape and when + // the AppBar is rebuilt. + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (a is CustomAppBarShape) { + return CustomAppBarShape(side: BorderSide.lerp(a.side, side, t)); + } + return super.lerpFrom(a, t); + } + + // The lerpTo method is necessary for the CustomAppBarShape to be + // properly animated when changing the AppBar shape and when + // the AppBar is rebuilt. + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (b is CustomAppBarShape) { + return CustomAppBarShape(side: BorderSide.lerp(b.side, side, t)); + } + return super.lerpTo(b, t); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.1.dart b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.1.dart new file mode 100644 index 000000000000..148e948fcdbe --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.1.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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SliverAppBar]. + +void main() => runApp(const AppBarApp()); + +class AppBarApp extends StatelessWidget { + const AppBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SliverAppBarExample()); + } +} + +class SliverAppBarExample extends StatefulWidget { + const SliverAppBarExample({super.key}); + + @override + State<SliverAppBarExample> createState() => _SliverAppBarExampleState(); +} + +class _SliverAppBarExampleState extends State<SliverAppBarExample> { + bool _pinned = true; + bool _snap = false; + bool _floating = false; + + // [SliverAppBar]s are typically used in [CustomScrollView.slivers], which in + // turn can be placed in a [Scaffold.body]. + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + pinned: _pinned, + snap: _snap, + floating: _floating, + expandedHeight: 160.0, + flexibleSpace: const FlexibleSpaceBar( + title: Text('SliverAppBar'), + background: FlutterLogo(), + ), + ), + const SliverToBoxAdapter( + child: SizedBox( + height: 20, + child: Center( + child: Text('Scroll to see the SliverAppBar in effect.'), + ), + ), + ), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return Container( + color: index.isOdd ? Colors.white : Colors.black12, + height: 100.0, + child: Center( + child: Text('$index', textScaler: const .linear(5)), + ), + ); + }, + ), + ], + ), + bottomNavigationBar: BottomAppBar( + child: Padding( + padding: const .all(8), + child: OverflowBar( + overflowAlignment: .center, + children: <Widget>[ + Row( + mainAxisSize: .min, + children: <Widget>[ + const Text('pinned'), + Switch( + onChanged: (bool val) { + setState(() { + _pinned = val; + }); + }, + value: _pinned, + ), + ], + ), + Row( + mainAxisSize: .min, + children: <Widget>[ + const Text('snap'), + Switch( + onChanged: (bool val) { + setState(() { + _snap = val; + // Snapping only applies when the app bar is floating. + _floating = _floating || _snap; + }); + }, + value: _snap, + ), + ], + ), + Row( + mainAxisSize: .min, + children: <Widget>[ + const Text('floating'), + Switch( + onChanged: (bool val) { + setState(() { + _floating = val; + _snap = _snap && _floating; + }); + }, + value: _floating, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.2.dart b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.2.dart new file mode 100644 index 000000000000..57160de67ffc --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.2.dart @@ -0,0 +1,53 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SliverAppBar.medium]. + +void main() { + runApp(const AppBarMediumApp()); +} + +class AppBarMediumApp extends StatelessWidget { + const AppBarMediumApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750A4)), + home: Material( + child: CustomScrollView( + slivers: <Widget>[ + SliverAppBar.medium( + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: () {}, + ), + title: const Text('Medium App Bar'), + actions: <Widget>[ + IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), + ], + ), + // Just some content big enough to have something to scroll. + SliverToBoxAdapter( + child: Card( + child: SizedBox( + height: 1200, + child: Padding( + padding: const .fromLTRB(8, 100, 8, 100), + child: Text( + 'Here be scrolling content...', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.3.dart b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.3.dart new file mode 100644 index 000000000000..84712155d017 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.3.dart @@ -0,0 +1,53 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SliverAppBar.large]. + +void main() { + runApp(const AppBarLargeApp()); +} + +class AppBarLargeApp extends StatelessWidget { + const AppBarLargeApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750A4)), + home: Material( + child: CustomScrollView( + slivers: <Widget>[ + SliverAppBar.large( + leading: IconButton( + icon: const Icon(Icons.menu), + onPressed: () {}, + ), + title: const Text('Large App Bar'), + actions: <Widget>[ + IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), + ], + ), + // Just some content big enough to have something to scroll. + SliverToBoxAdapter( + child: Card( + child: SizedBox( + height: 1200, + child: Padding( + padding: const .fromLTRB(8, 100, 8, 100), + child: Text( + 'Here be scrolling content...', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.4.dart b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.4.dart new file mode 100644 index 000000000000..0c60cf6e15bc --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/app_bar/sliver_app_bar.4.dart @@ -0,0 +1,89 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SliverAppBar]. + +void main() { + runApp(const StretchableSliverAppBar()); +} + +class StretchableSliverAppBar extends StatefulWidget { + const StretchableSliverAppBar({super.key}); + + @override + State<StretchableSliverAppBar> createState() => + _StretchableSliverAppBarState(); +} + +class _StretchableSliverAppBarState extends State<StretchableSliverAppBar> { + bool _stretch = true; + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + SliverAppBar( + stretch: _stretch, + onStretchTrigger: () async { + // Triggers when stretching + }, + // [stretchTriggerOffset] describes the amount of overscroll that must occur + // to trigger [onStretchTrigger] + // + // Setting [stretchTriggerOffset] to a value of 300.0 will trigger + // [onStretchTrigger] when the user has overscrolled by 300.0 pixels. + stretchTriggerOffset: 300.0, + expandedHeight: 200.0, + flexibleSpace: const FlexibleSpaceBar( + title: Text('SliverAppBar'), + background: FlutterLogo(), + ), + ), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return Container( + color: index.isOdd ? Colors.white : Colors.black12, + height: 100.0, + child: Center( + child: Text('$index', textScaler: const .linear(5.0)), + ), + ); + }, + ), + ], + ), + bottomNavigationBar: BottomAppBar( + child: Padding( + padding: const .all(8), + child: OverflowBar( + overflowAlignment: .center, + alignment: .center, + children: <Widget>[ + Row( + mainAxisSize: .min, + children: <Widget>[ + const Text('stretch'), + Switch( + onChanged: (bool val) { + setState(() { + _stretch = val; + }); + }, + value: _stretch, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.0.dart b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.0.dart new file mode 100644 index 000000000000..7f88b9ccdf9a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.0.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Autocomplete]. + +void main() => runApp(const AutocompleteExampleApp()); + +class AutocompleteExampleApp extends StatelessWidget { + const AutocompleteExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Autocomplete Basic')), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Type below to autocomplete the following possible results: ${AutocompleteBasicExample._kOptions}.', + ), + const AutocompleteBasicExample(), + ], + ), + ), + ), + ); + } +} + +class AutocompleteBasicExample extends StatelessWidget { + const AutocompleteBasicExample({super.key}); + + static const List<String> _kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + + @override + Widget build(BuildContext context) { + return Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == '') { + return const Iterable<String>.empty(); + } + return _kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + onSelected: (String selection) { + debugPrint('You just selected $selection'); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.1.dart b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.1.dart new file mode 100644 index 000000000000..8b89247e9f6b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.1.dart @@ -0,0 +1,89 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Autocomplete]. + +void main() => runApp(const AutocompleteExampleApp()); + +class AutocompleteExampleApp extends StatelessWidget { + const AutocompleteExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Autocomplete Basic User')), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Type below to autocomplete the following possible results: ${AutocompleteBasicUserExample._userOptions}.', + ), + const AutocompleteBasicUserExample(), + ], + ), + ), + ), + ); + } +} + +@immutable +class User { + const User({required this.email, required this.name}); + + final String email; + final String name; + + @override + String toString() { + return '$name, $email'; + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is User && other.name == name && other.email == email; + } + + @override + int get hashCode => Object.hash(email, name); +} + +class AutocompleteBasicUserExample extends StatelessWidget { + const AutocompleteBasicUserExample({super.key}); + + static const List<User> _userOptions = <User>[ + User(name: 'Alice', email: 'alice@example.com'), + User(name: 'Bob', email: 'bob@example.com'), + User(name: 'Charlie', email: 'charlie123@gmail.com'), + ]; + + static String _displayStringForOption(User option) => option.name; + + @override + Widget build(BuildContext context) { + return Autocomplete<User>( + displayStringForOption: _displayStringForOption, + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == '') { + return const Iterable<User>.empty(); + } + return _userOptions.where((User option) { + return option.toString().contains( + textEditingValue.text.toLowerCase(), + ); + }); + }, + onSelected: (User selection) { + debugPrint('You just selected ${_displayStringForOption(selection)}'); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.2.dart b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.2.dart new file mode 100644 index 000000000000..22f331aa55cf --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.2.dart @@ -0,0 +1,97 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Autocomplete] that shows how to fetch the options +/// from a remote API. + +const Duration fakeAPIDuration = Duration(seconds: 1); + +void main() => runApp(const AutocompleteExampleApp()); + +class AutocompleteExampleApp extends StatelessWidget { + const AutocompleteExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Autocomplete - async')), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Type below to autocomplete the following possible results: ${_FakeAPI._kOptions}.', + ), + const _AsyncAutocomplete(), + ], + ), + ), + ), + ); + } +} + +class _AsyncAutocomplete extends StatefulWidget { + const _AsyncAutocomplete(); + + @override + State<_AsyncAutocomplete> createState() => _AsyncAutocompleteState(); +} + +class _AsyncAutocompleteState extends State<_AsyncAutocomplete> { + // The query currently being searched for. If null, there is no pending + // request. + String? _searchingWithQuery; + + // The most recent options received from the API. + late Iterable<String> _lastOptions = <String>[]; + + @override + Widget build(BuildContext context) { + return Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) async { + _searchingWithQuery = textEditingValue.text; + final Iterable<String> options = await _FakeAPI.search( + _searchingWithQuery!, + ); + + // If another search happened after this one, throw away these options. + // Use the previous options instead and wait for the newer request to + // finish. + if (_searchingWithQuery != textEditingValue.text) { + return _lastOptions; + } + + _lastOptions = options; + return options; + }, + onSelected: (String selection) { + debugPrint('You just selected $selection'); + }, + ); + } +} + +// Mimics a remote API. +class _FakeAPI { + static const List<String> _kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + + // Searches the options, but injects a fake "network" delay. + static Future<Iterable<String>> search(String query) async { + await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay. + if (query == '') { + return const Iterable<String>.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.3.dart b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.3.dart new file mode 100644 index 000000000000..a413939668c2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.3.dart @@ -0,0 +1,172 @@ +// 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:async'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [Autocomplete] that demonstrates fetching the +/// options asynchronously and debouncing the network calls. + +const Duration fakeAPIDuration = Duration(seconds: 1); +const Duration debounceDuration = Duration(milliseconds: 500); + +void main() => runApp(const AutocompleteExampleApp()); + +class AutocompleteExampleApp extends StatelessWidget { + const AutocompleteExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Autocomplete - async and debouncing'), + ), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Type below to autocomplete the following possible results: ${_FakeAPI._kOptions}.', + ), + const _AsyncAutocomplete(), + ], + ), + ), + ), + ); + } +} + +class _AsyncAutocomplete extends StatefulWidget { + const _AsyncAutocomplete(); + + @override + State<_AsyncAutocomplete> createState() => _AsyncAutocompleteState(); +} + +class _AsyncAutocompleteState extends State<_AsyncAutocomplete> { + // The query currently being searched for. If null, there is no pending + // request. + String? _currentQuery; + + // The most recent options received from the API. + late Iterable<String> _lastOptions = <String>[]; + + late final _Debounceable<Iterable<String>?, String> _debouncedSearch; + + // Calls the "remote" API to search with the given query. Returns null when + // the call has been made obsolete. + Future<Iterable<String>?> _search(String query) async { + _currentQuery = query; + + // In a real application, there should be some error handling here. + final Iterable<String> options = await _FakeAPI.search(_currentQuery!); + + // If another search happened after this one, throw away these options. + if (_currentQuery != query) { + return null; + } + _currentQuery = null; + + return options; + } + + @override + void initState() { + super.initState(); + _debouncedSearch = _debounce<Iterable<String>?, String>(_search); + } + + @override + Widget build(BuildContext context) { + return Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) async { + final Iterable<String>? options = await _debouncedSearch( + textEditingValue.text, + ); + if (options == null) { + return _lastOptions; + } + _lastOptions = options; + return options; + }, + onSelected: (String selection) { + debugPrint('You just selected $selection'); + }, + ); + } +} + +// Mimics a remote API. +class _FakeAPI { + static const List<String> _kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + + // Searches the options, but injects a fake "network" delay. + static Future<Iterable<String>> search(String query) async { + await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay. + if (query == '') { + return const Iterable<String>.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} + +typedef _Debounceable<S, T> = Future<S?> Function(T parameter); + +/// Returns a new function that is a debounced version of the given function. +/// +/// This means that the original function will be called only after no calls +/// have been made for the given Duration. +_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { + _DebounceTimer? debounceTimer; + + return (T parameter) async { + if (debounceTimer != null && !debounceTimer!.isCompleted) { + debounceTimer!.cancel(); + } + debounceTimer = _DebounceTimer(); + try { + await debounceTimer!.future; + } on _CancelException { + return null; + } + return function(parameter); + }; +} + +// A wrapper around Timer used for debouncing. +class _DebounceTimer { + _DebounceTimer() { + _timer = Timer(debounceDuration, _onComplete); + } + + late final Timer _timer; + final Completer<void> _completer = Completer<void>(); + + void _onComplete() { + _completer.complete(); + } + + Future<void> get future => _completer.future; + + bool get isCompleted => _completer.isCompleted; + + void cancel() { + _timer.cancel(); + _completer.completeError(const _CancelException()); + } +} + +// An exception indicating that the timer was canceled. +class _CancelException implements Exception { + const _CancelException(); +} diff --git a/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.4.dart b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.4.dart new file mode 100644 index 000000000000..83c8ac5e1d8c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/autocomplete/autocomplete.4.dart @@ -0,0 +1,244 @@ +// 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:async'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [Autocomplete] that demonstrates fetching the +/// options asynchronously and debouncing the network calls, including handling +/// network errors. + +void main() => runApp(const AutocompleteExampleApp()); + +const Duration fakeAPIDuration = Duration(seconds: 1); +const Duration debounceDuration = Duration(milliseconds: 500); + +class AutocompleteExampleApp extends StatelessWidget { + const AutocompleteExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text( + 'Autocomplete - async, debouncing, and network errors', + ), + ), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Type below to autocomplete the following possible results: ${_FakeAPI._kOptions}.', + ), + const SizedBox(height: 32.0), + const _AsyncAutocomplete(), + ], + ), + ), + ), + ); + } +} + +class _AsyncAutocomplete extends StatefulWidget { + const _AsyncAutocomplete(); + + @override + State<_AsyncAutocomplete> createState() => _AsyncAutocompleteState(); +} + +class _AsyncAutocompleteState extends State<_AsyncAutocomplete> { + // The query currently being searched for. If null, there is no pending + // request. + String? _currentQuery; + + // The most recent options received from the API. + late Iterable<String> _lastOptions = <String>[]; + + late final _Debounceable<Iterable<String>?, String> _debouncedSearch; + + // Whether to consider the fake network to be offline. + bool _networkEnabled = true; + + // A network error was received on the most recent query. + bool _networkError = false; + + // Calls the "remote" API to search with the given query. Returns null when + // the call has been made obsolete. + Future<Iterable<String>?> _search(String query) async { + _currentQuery = query; + + late final Iterable<String> options; + try { + options = await _FakeAPI.search(_currentQuery!, _networkEnabled); + } on _NetworkException { + if (mounted) { + setState(() { + _networkError = true; + }); + } + return <String>[]; + } + + // If another search happened after this one, throw away these options. + if (_currentQuery != query) { + return null; + } + _currentQuery = null; + + return options; + } + + @override + void initState() { + super.initState(); + _debouncedSearch = _debounce<Iterable<String>?, String>(_search); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + _networkEnabled + ? 'Network is on, toggle to induce network errors.' + : 'Network is off, toggle to allow requests to go through.', + ), + Switch( + value: _networkEnabled, + onChanged: (bool value) { + setState(() { + _networkEnabled = !_networkEnabled; + }); + }, + ), + const SizedBox(height: 32.0), + Autocomplete<String>( + fieldViewBuilder: + ( + BuildContext context, + TextEditingController controller, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + return TextFormField( + decoration: InputDecoration( + errorText: _networkError + ? 'Network error, please try again.' + : null, + ), + controller: controller, + focusNode: focusNode, + onFieldSubmitted: (String value) { + onFieldSubmitted(); + }, + ); + }, + optionsBuilder: (TextEditingValue textEditingValue) async { + setState(() { + _networkError = false; + }); + final Iterable<String>? options = await _debouncedSearch( + textEditingValue.text, + ); + if (options == null) { + return _lastOptions; + } + _lastOptions = options; + return options; + }, + onSelected: (String selection) { + debugPrint('You just selected $selection'); + }, + ), + ], + ); + } +} + +// Mimics a remote API. +class _FakeAPI { + static const List<String> _kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + + // Searches the options, but injects a fake "network" delay. + static Future<Iterable<String>> search( + String query, + bool networkEnabled, + ) async { + await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay. + if (!networkEnabled) { + throw const _NetworkException(); + } + if (query == '') { + return const Iterable<String>.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} + +typedef _Debounceable<S, T> = Future<S?> Function(T parameter); + +/// Returns a new function that is a debounced version of the given function. +/// +/// This means that the original function will be called only after no calls +/// have been made for the given Duration. +_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { + _DebounceTimer? debounceTimer; + + return (T parameter) async { + if (debounceTimer != null && !debounceTimer!.isCompleted) { + debounceTimer!.cancel(); + } + debounceTimer = _DebounceTimer(); + try { + await debounceTimer!.future; + } on _CancelException { + return null; + } + return function(parameter); + }; +} + +// A wrapper around Timer used for debouncing. +class _DebounceTimer { + _DebounceTimer() { + _timer = Timer(debounceDuration, _onComplete); + } + + late final Timer _timer; + final Completer<void> _completer = Completer<void>(); + + void _onComplete() { + _completer.complete(); + } + + Future<void> get future => _completer.future; + + bool get isCompleted => _completer.isCompleted; + + void cancel() { + _timer.cancel(); + _completer.completeError(const _CancelException()); + } +} + +// An exception indicating that the timer was canceled. +class _CancelException implements Exception { + const _CancelException(); +} + +// An exception indicating that a network request has failed. +class _NetworkException implements Exception { + const _NetworkException(); +} diff --git a/packages/material_ui/material_ui_examples/lib/badge/badge.0.dart b/packages/material_ui/material_ui_examples/lib/badge/badge.0.dart new file mode 100644 index 000000000000..8cf8c51b661d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/badge/badge.0.dart @@ -0,0 +1,54 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Badge]. + +void main() => runApp(const BadgeExampleApp()); + +class BadgeExampleApp extends StatelessWidget { + const BadgeExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Badge Sample')), + body: const BadgeExample(), + ), + ); + } +} + +class BadgeExample extends StatelessWidget { + const BadgeExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + IconButton( + icon: const Badge( + label: Text('Your label'), + backgroundColor: Colors.blueAccent, + child: Icon(Icons.receipt), + ), + onPressed: () {}, + ), + const SizedBox(height: 20), + IconButton( + icon: Badge.count( + count: 9999, + child: const Icon(Icons.notifications), + ), + onPressed: () {}, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/banner/material_banner.0.dart b/packages/material_ui/material_ui_examples/lib/banner/material_banner.0.dart new file mode 100644 index 000000000000..bde042280ed8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/banner/material_banner.0.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [MaterialBanner]. + +void main() => runApp(const MaterialBannerExampleApp()); + +class MaterialBannerExampleApp extends StatelessWidget { + const MaterialBannerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: MaterialBannerExample()); + } +} + +class MaterialBannerExample extends StatelessWidget { + const MaterialBannerExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('The MaterialBanner is below')), + body: const MaterialBanner( + padding: .all(20), + content: Text('Hello, I am a Material Banner'), + leading: Icon(Icons.agriculture_outlined), + backgroundColor: Color(0xFFE0E0E0), + actions: <Widget>[ + TextButton(onPressed: null, child: Text('OPEN')), + TextButton(onPressed: null, child: Text('DISMISS')), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/banner/material_banner.1.dart b/packages/material_ui/material_ui_examples/lib/banner/material_banner.1.dart new file mode 100644 index 000000000000..d038a976e504 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/banner/material_banner.1.dart @@ -0,0 +1,45 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [MaterialBanner]. + +void main() => runApp(const MaterialBannerExampleApp()); + +class MaterialBannerExampleApp extends StatelessWidget { + const MaterialBannerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: MaterialBannerExample()); + } +} + +class MaterialBannerExample extends StatelessWidget { + const MaterialBannerExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('The MaterialBanner is below')), + body: Center( + child: ElevatedButton( + child: const Text('Show MaterialBanner'), + onPressed: () => ScaffoldMessenger.of(context).showMaterialBanner( + const MaterialBanner( + padding: .all(20), + content: Text('Hello, I am a Material Banner'), + leading: Icon(Icons.agriculture_outlined), + backgroundColor: Colors.green, + actions: <Widget>[ + TextButton(onPressed: null, child: Text('DISMISS')), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_app_bar/bottom_app_bar.1.dart b/packages/material_ui/material_ui_examples/lib/bottom_app_bar/bottom_app_bar.1.dart new file mode 100644 index 000000000000..6060a2dcea2b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_app_bar/bottom_app_bar.1.dart @@ -0,0 +1,154 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [BottomAppBar]. + +void main() { + runApp(const BottomAppBarDemo()); +} + +class BottomAppBarDemo extends StatefulWidget { + const BottomAppBarDemo({super.key}); + + @override + State createState() => _BottomAppBarDemoState(); +} + +class _BottomAppBarDemoState extends State<BottomAppBarDemo> { + bool _showFab = true; + bool _showNotch = true; + FloatingActionButtonLocation _fabLocation = + FloatingActionButtonLocation.endDocked; + + void _onShowNotchChanged(bool value) { + setState(() { + _showNotch = value; + }); + } + + void _onShowFabChanged(bool value) { + setState(() { + _showFab = value; + }); + } + + void _onFabLocationChanged(FloatingActionButtonLocation? value) { + setState(() { + _fabLocation = value ?? FloatingActionButtonLocation.endDocked; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: const Text('Bottom App Bar Demo'), + ), + body: RadioGroup<FloatingActionButtonLocation>( + groupValue: _fabLocation, + onChanged: (FloatingActionButtonLocation? value) => + _onFabLocationChanged(value), + child: ListView( + padding: const .only(bottom: 88), + children: <Widget>[ + SwitchListTile( + title: const Text('Floating Action Button'), + value: _showFab, + onChanged: _onShowFabChanged, + ), + SwitchListTile( + title: const Text('Notch'), + value: _showNotch, + onChanged: _onShowNotchChanged, + ), + const Padding( + padding: .all(16), + child: Text('Floating action button position'), + ), + const RadioListTile<FloatingActionButtonLocation>( + title: Text('Docked - End'), + value: FloatingActionButtonLocation.endDocked, + ), + const RadioListTile<FloatingActionButtonLocation>( + title: Text('Docked - Center'), + value: FloatingActionButtonLocation.centerDocked, + ), + const RadioListTile<FloatingActionButtonLocation>( + title: Text('Floating - End'), + value: FloatingActionButtonLocation.endFloat, + ), + const RadioListTile<FloatingActionButtonLocation>( + title: Text('Floating - Center'), + value: FloatingActionButtonLocation.centerFloat, + ), + ], + ), + ), + floatingActionButton: _showFab + ? FloatingActionButton( + onPressed: () {}, + tooltip: 'Create', + child: const Icon(Icons.add), + ) + : null, + floatingActionButtonLocation: _fabLocation, + bottomNavigationBar: _DemoBottomAppBar( + fabLocation: _fabLocation, + shape: _showNotch ? const CircularNotchedRectangle() : null, + ), + ), + ); + } +} + +class _DemoBottomAppBar extends StatelessWidget { + const _DemoBottomAppBar({ + this.fabLocation = FloatingActionButtonLocation.endDocked, + this.shape = const CircularNotchedRectangle(), + }); + + final FloatingActionButtonLocation fabLocation; + final NotchedShape? shape; + + static final List<FloatingActionButtonLocation> centerLocations = + <FloatingActionButtonLocation>[ + FloatingActionButtonLocation.centerDocked, + FloatingActionButtonLocation.centerFloat, + ]; + + @override + Widget build(BuildContext context) { + return BottomAppBar( + shape: shape, + color: Colors.blue, + child: IconTheme( + data: IconThemeData(color: Theme.of(context).colorScheme.onPrimary), + child: Row( + children: <Widget>[ + IconButton( + tooltip: 'Open navigation menu', + icon: const Icon(Icons.menu), + onPressed: () {}, + ), + if (centerLocations.contains(fabLocation)) const Spacer(), + IconButton( + tooltip: 'Search', + icon: const Icon(Icons.search), + onPressed: () {}, + ), + IconButton( + tooltip: 'Favorite', + icon: const Icon(Icons.favorite), + onPressed: () {}, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_app_bar/bottom_app_bar.2.dart b/packages/material_ui/material_ui_examples/lib/bottom_app_bar/bottom_app_bar.2.dart new file mode 100644 index 000000000000..20d8631c30ae --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_app_bar/bottom_app_bar.2.dart @@ -0,0 +1,189 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; + +/// Flutter code sample for [BottomAppBar] with Material 3. + +void main() { + runApp(const BottomAppBarDemo()); +} + +class BottomAppBarDemo extends StatefulWidget { + const BottomAppBarDemo({super.key}); + + @override + State createState() => _BottomAppBarDemoState(); +} + +class _BottomAppBarDemoState extends State<BottomAppBarDemo> { + static const List<Color> colors = <Color>[ + Colors.yellow, + Colors.orange, + Colors.pink, + Colors.purple, + Colors.cyan, + ]; + + static final List<Widget> items = List<Widget>.generate( + colors.length, + (int index) => Container(color: colors[index], height: 150.0), + ).reversed.toList(); + + late ScrollController _controller; + bool _showFab = true; + bool _isElevated = true; + bool _isVisible = true; + + FloatingActionButtonLocation get _fabLocation => _isVisible + ? FloatingActionButtonLocation.endContained + : FloatingActionButtonLocation.endFloat; + + void _listen() { + switch (_controller.position.userScrollDirection) { + case ScrollDirection.idle: + break; + case ScrollDirection.forward: + _show(); + case ScrollDirection.reverse: + _hide(); + } + } + + void _show() { + if (!_isVisible) { + setState(() => _isVisible = true); + } + } + + void _hide() { + if (_isVisible) { + setState(() => _isVisible = false); + } + } + + void _onShowFabChanged(bool value) { + setState(() { + _showFab = value; + }); + } + + void _onElevatedChanged(bool value) { + setState(() { + _isElevated = value; + }); + } + + void _addNewItem() { + setState(() { + items.insert( + 0, + Container(color: colors[items.length % 5], height: 150.0), + ); + }); + } + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _controller.addListener(_listen); + } + + @override + void dispose() { + _controller.removeListener(_listen); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Bottom App Bar Demo')), + body: Column( + children: <Widget>[ + SwitchListTile( + title: const Text('Floating Action Button'), + value: _showFab, + onChanged: _onShowFabChanged, + ), + SwitchListTile( + title: const Text('Bottom App Bar Elevation'), + value: _isElevated, + onChanged: _onElevatedChanged, + ), + Expanded( + child: ListView( + controller: _controller, + children: items.toList(), + ), + ), + ], + ), + floatingActionButton: _showFab + ? FloatingActionButton( + onPressed: _addNewItem, + tooltip: 'Add New Item', + elevation: _isVisible ? 0.0 : null, + child: const Icon(Icons.add), + ) + : null, + floatingActionButtonLocation: _fabLocation, + bottomNavigationBar: _DemoBottomAppBar( + isElevated: _isElevated, + isVisible: _isVisible, + ), + ), + ); + } +} + +class _DemoBottomAppBar extends StatelessWidget { + const _DemoBottomAppBar({required this.isElevated, required this.isVisible}); + + final bool isElevated; + final bool isVisible; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: isVisible ? 80.0 : 0, + child: BottomAppBar( + elevation: isElevated ? null : 0.0, + child: Row( + children: <Widget>[ + IconButton( + tooltip: 'Open popup menu', + icon: const Icon(Icons.more_vert), + onPressed: () { + final SnackBar snackBar = SnackBar( + content: const Text('Yay! A SnackBar!'), + action: SnackBarAction(label: 'Undo', onPressed: () {}), + ); + + // Find the ScaffoldMessenger in the widget tree + // and use it to show a SnackBar. + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, + ), + IconButton( + tooltip: 'Search', + icon: const Icon(Icons.search), + onPressed: () {}, + ), + IconButton( + tooltip: 'Favorite', + icon: const Icon(Icons.favorite), + onPressed: () {}, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.0.dart b/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.0.dart new file mode 100644 index 000000000000..587155403021 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.0.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [BottomNavigationBar]. + +void main() => runApp(const BottomNavigationBarExampleApp()); + +class BottomNavigationBarExampleApp extends StatelessWidget { + const BottomNavigationBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: BottomNavigationBarExample()); + } +} + +class BottomNavigationBarExample extends StatefulWidget { + const BottomNavigationBarExample({super.key}); + + @override + State<BottomNavigationBarExample> createState() => + _BottomNavigationBarExampleState(); +} + +class _BottomNavigationBarExampleState + extends State<BottomNavigationBarExample> { + int _selectedIndex = 0; + static const TextStyle optionStyle = TextStyle( + fontSize: 30, + fontWeight: .bold, + ); + static const List<Widget> _widgetOptions = <Widget>[ + Text('Index 0: Home', style: optionStyle), + Text('Index 1: Business', style: optionStyle), + Text('Index 2: School', style: optionStyle), + ]; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('BottomNavigationBar Sample')), + body: Center(child: _widgetOptions.elementAt(_selectedIndex)), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), + BottomNavigationBarItem( + icon: Icon(Icons.business), + label: 'Business', + ), + BottomNavigationBarItem(icon: Icon(Icons.school), label: 'School'), + ], + currentIndex: _selectedIndex, + selectedItemColor: Colors.amber[800], + onTap: _onItemTapped, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.1.dart b/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.1.dart new file mode 100644 index 000000000000..9ebe65ad7e37 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.1.dart @@ -0,0 +1,82 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [BottomNavigationBar]. + +void main() => runApp(const BottomNavigationBarExampleApp()); + +class BottomNavigationBarExampleApp extends StatelessWidget { + const BottomNavigationBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: BottomNavigationBarExample()); + } +} + +class BottomNavigationBarExample extends StatefulWidget { + const BottomNavigationBarExample({super.key}); + + @override + State<BottomNavigationBarExample> createState() => + _BottomNavigationBarExampleState(); +} + +class _BottomNavigationBarExampleState + extends State<BottomNavigationBarExample> { + int _selectedIndex = 0; + static const TextStyle optionStyle = TextStyle( + fontSize: 30, + fontWeight: .bold, + ); + static const List<Widget> _widgetOptions = <Widget>[ + Text('Index 0: Home', style: optionStyle), + Text('Index 1: Business', style: optionStyle), + Text('Index 2: School', style: optionStyle), + Text('Index 3: Settings', style: optionStyle), + ]; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('BottomNavigationBar Sample')), + body: Center(child: _widgetOptions.elementAt(_selectedIndex)), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + backgroundColor: Colors.red, + ), + BottomNavigationBarItem( + icon: Icon(Icons.business), + label: 'Business', + backgroundColor: Colors.green, + ), + BottomNavigationBarItem( + icon: Icon(Icons.school), + label: 'School', + backgroundColor: Colors.purple, + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings), + label: 'Settings', + backgroundColor: Colors.pink, + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Colors.amber[800], + onTap: _onItemTapped, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.2.dart b/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.2.dart new file mode 100644 index 000000000000..7268bccbbc9d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_navigation_bar/bottom_navigation_bar.2.dart @@ -0,0 +1,98 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [BottomNavigationBar]. + +void main() => runApp(const BottomNavigationBarExampleApp()); + +class BottomNavigationBarExampleApp extends StatelessWidget { + const BottomNavigationBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: BottomNavigationBarExample()); + } +} + +class BottomNavigationBarExample extends StatefulWidget { + const BottomNavigationBarExample({super.key}); + + @override + State<BottomNavigationBarExample> createState() => + _BottomNavigationBarExampleState(); +} + +class _BottomNavigationBarExampleState + extends State<BottomNavigationBarExample> { + int _selectedIndex = 0; + final ScrollController _homeController = ScrollController(); + + Widget _listViewBody() { + return ListView.separated( + controller: _homeController, + itemBuilder: (BuildContext context, int index) { + return Center(child: Text('Item $index')); + }, + separatorBuilder: (BuildContext context, int index) => + const Divider(thickness: 1), + itemCount: 50, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('BottomNavigationBar Sample')), + body: _listViewBody(), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), + BottomNavigationBarItem( + icon: Icon(Icons.open_in_new_rounded), + label: 'Open Dialog', + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Colors.amber[800], + onTap: (int index) { + switch (index) { + case 0: + // only scroll to top when current index is selected. + if (_selectedIndex == index) { + _homeController.animateTo( + 0.0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + } + case 1: + showModal(context); + } + setState(() { + _selectedIndex = index; + }); + }, + ), + ); + } + + void showModal(BuildContext context) { + showDialog<void>( + context: context, + builder: (BuildContext context) => AlertDialog( + content: const Text('Example Dialog'), + actions: <TextButton>[ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_bottom_sheet.0.dart b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_bottom_sheet.0.dart new file mode 100644 index 000000000000..6baf8a7e8195 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_bottom_sheet.0.dart @@ -0,0 +1,110 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showBottomSheet]. + +void main() => runApp(const BottomSheetExampleApp()); + +class BottomSheetExampleApp extends StatelessWidget { + const BottomSheetExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Bottom Sheet Sample')), + body: const BottomSheetExample(), + ), + ); + } +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), + ]; + +class BottomSheetExample extends StatefulWidget { + const BottomSheetExample({super.key}); + + @override + State<BottomSheetExample> createState() => _BottomSheetExampleState(); +} + +class _BottomSheetExampleState extends State<BottomSheetExample> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + AnimationStyles.defaultStyle, + }; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyle = switch (styles.first) { + AnimationStyles.defaultStyle => null, + AnimationStyles.custom => const AnimationStyle( + duration: Duration(seconds: 3), + reverseDuration: Duration(seconds: 1), + ), + AnimationStyles.none => AnimationStyle.noAnimation, + }; + _animationStyleSelection = styles; + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 10), + ElevatedButton( + child: const Text('showBottomSheet'), + onPressed: () { + showBottomSheet( + context: context, + sheetAnimationStyle: _animationStyle, + builder: (BuildContext context) { + return SizedBox.expand( + child: Center( + child: Column( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: <Widget>[ + const Text('Bottom sheet'), + ElevatedButton( + child: const Text('Close'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.0.dart b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.0.dart new file mode 100644 index 000000000000..4272b4b79a16 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.0.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showModalBottomSheet]. + +void main() => runApp(const BottomSheetApp()); + +class BottomSheetApp extends StatelessWidget { + const BottomSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Bottom Sheet Sample')), + body: const BottomSheetExample(), + ), + ); + } +} + +class BottomSheetExample extends StatelessWidget { + const BottomSheetExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('showModalBottomSheet'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return Container( + height: 200, + color: Colors.amber, + child: Center( + child: Column( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: <Widget>[ + const Text('Modal BottomSheet'), + ElevatedButton( + child: const Text('Close BottomSheet'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.1.dart b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.1.dart new file mode 100644 index 000000000000..813277aaa5a9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.1.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showModalBottomSheet]. + +void main() => runApp(const BottomSheetApp()); + +class BottomSheetApp extends StatelessWidget { + const BottomSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + appBar: AppBar(title: const Text('Bottom Sheet Sample')), + body: const BottomSheetExample(), + ), + ); + } +} + +class BottomSheetExample extends StatelessWidget { + const BottomSheetExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('showModalBottomSheet'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: <Widget>[ + const Text('Modal BottomSheet'), + ElevatedButton( + child: const Text('Close BottomSheet'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.2.dart b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.2.dart new file mode 100644 index 000000000000..1fb5e0efb5fd --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/bottom_sheet/show_modal_bottom_sheet.2.dart @@ -0,0 +1,111 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showModalBottomSheet]. + +void main() => runApp(const ModalBottomSheetApp()); + +class ModalBottomSheetApp extends StatelessWidget { + const ModalBottomSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Modal Bottom Sheet Sample')), + body: const ModalBottomSheetExample(), + ), + ); + } +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), + ]; + +class ModalBottomSheetExample extends StatefulWidget { + const ModalBottomSheetExample({super.key}); + + @override + State<ModalBottomSheetExample> createState() => + _ModalBottomSheetExampleState(); +} + +class _ModalBottomSheetExampleState extends State<ModalBottomSheetExample> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + AnimationStyles.defaultStyle, + }; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyle = switch (styles.first) { + AnimationStyles.defaultStyle => null, + AnimationStyles.custom => const AnimationStyle( + duration: Duration(seconds: 3), + reverseDuration: Duration(seconds: 1), + ), + AnimationStyles.none => AnimationStyle.noAnimation, + }; + _animationStyleSelection = styles; + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 10), + ElevatedButton( + child: const Text('showModalBottomSheet'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + sheetAnimationStyle: _animationStyle, + builder: (BuildContext context) { + return SizedBox.expand( + child: Center( + child: Column( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: <Widget>[ + const Text('Modal bottom sheet'), + ElevatedButton( + child: const Text('Close'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/button_style/button_style.0.dart b/packages/material_ui/material_ui_examples/lib/button_style/button_style.0.dart new file mode 100644 index 000000000000..7257258123c3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/button_style/button_style.0.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ElevatedButton]. + +void main() { + runApp(const ButtonApp()); +} + +class ButtonApp extends StatelessWidget { + const ButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + title: 'Button Types', + home: const Scaffold(body: ButtonTypesExample()), + ); + } +} + +class ButtonTypesExample extends StatelessWidget { + const ButtonTypesExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: .all(4.0), + child: Row( + children: <Widget>[ + Spacer(), + ButtonTypesGroup(enabled: true), + ButtonTypesGroup(enabled: false), + Spacer(), + ], + ), + ); + } +} + +class ButtonTypesGroup extends StatelessWidget { + const ButtonTypesGroup({super.key, required this.enabled}); + + final bool enabled; + + @override + Widget build(BuildContext context) { + final VoidCallback? onPressed = enabled ? () {} : null; + return Padding( + padding: const .all(4.0), + child: Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + ElevatedButton(onPressed: onPressed, child: const Text('Elevated')), + 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/packages/material_ui/material_ui_examples/lib/card/card.0.dart b/packages/material_ui/material_ui_examples/lib/card/card.0.dart new file mode 100644 index 000000000000..d64ce8b6f906 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/card/card.0.dart @@ -0,0 +1,64 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Card]. + +void main() => runApp(const CardExampleApp()); + +class CardExampleApp extends StatelessWidget { + const CardExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Card Sample')), + body: const CardExample(), + ), + ); + } +} + +class CardExample extends StatelessWidget { + const CardExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Card( + child: Column( + mainAxisSize: .min, + children: <Widget>[ + const ListTile( + leading: Icon(Icons.album), + title: Text('The Enchanted Nightingale'), + subtitle: Text('Music by Julie Gable. Lyrics by Sidney Stein.'), + ), + Row( + mainAxisAlignment: .end, + children: <Widget>[ + TextButton( + child: const Text('BUY TICKETS'), + onPressed: () { + /* ... */ + }, + ), + const SizedBox(width: 8), + TextButton( + child: const Text('LISTEN'), + onPressed: () { + /* ... */ + }, + ), + const SizedBox(width: 8), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/card/card.1.dart b/packages/material_ui/material_ui_examples/lib/card/card.1.dart new file mode 100644 index 000000000000..27eea9d259b2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/card/card.1.dart @@ -0,0 +1,51 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Card]. + +void main() => runApp(const CardExampleApp()); + +class CardExampleApp extends StatelessWidget { + const CardExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Card Sample')), + body: const CardExample(), + ), + ); + } +} + +class CardExample extends StatelessWidget { + const CardExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Card( + // clipBehavior is necessary because, without it, the InkWell's animation + // will extend beyond the rounded edges of the [Card] (see https://github.com/flutter/flutter/issues/109776) + // This comes with a small performance cost, and you should not set [clipBehavior] + // unless you need it. + clipBehavior: .hardEdge, + child: InkWell( + splashColor: Colors.blue.withAlpha(30), + onTap: () { + debugPrint('Card tapped.'); + }, + child: const SizedBox( + width: 300, + height: 100, + child: Text('A card that can be tapped'), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/card/card.2.dart b/packages/material_ui/material_ui_examples/lib/card/card.2.dart new file mode 100644 index 000000000000..ef485a6bed69 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/card/card.2.dart @@ -0,0 +1,48 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Card]. + +void main() { + runApp(const CardExamplesApp()); +} + +class CardExamplesApp extends StatelessWidget { + const CardExamplesApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Card Examples')), + body: const Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Card(child: _SampleCard(cardName: 'Elevated Card')), + Card.filled(child: _SampleCard(cardName: 'Filled Card')), + Card.outlined(child: _SampleCard(cardName: 'Outlined Card')), + ], + ), + ), + ), + ); + } +} + +class _SampleCard extends StatelessWidget { + const _SampleCard({required this.cardName}); + final String cardName; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 300, + height: 100, + child: Center(child: Text(cardName)), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/carousel/carousel.0.dart b/packages/material_ui/material_ui_examples/lib/carousel/carousel.0.dart new file mode 100644 index 000000000000..f4029f2fdbbc --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/carousel/carousel.0.dart @@ -0,0 +1,279 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [CarouselView]. + +void main() => runApp(const CarouselExampleApp()); + +class CarouselExampleApp extends StatelessWidget { + const CarouselExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + appBar: AppBar( + leading: const Icon(Icons.cast), + title: const Text('Flutter TV'), + actions: const <Widget>[ + Padding( + padding: .directional(end: 16.0), + child: CircleAvatar(child: Icon(Icons.account_circle)), + ), + ], + ), + body: const CarouselExample(), + ), + ); + } +} + +class CarouselExample extends StatefulWidget { + const CarouselExample({super.key}); + + @override + State<CarouselExample> createState() => _CarouselExampleState(); +} + +class _CarouselExampleState extends State<CarouselExample> { + final CarouselController controller = CarouselController(initialItem: 1); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double height = MediaQuery.sizeOf(context).height; + + return ListView( + children: <Widget>[ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: height / 2), + child: CarouselView.weighted( + controller: controller, + itemSnapping: true, + flexWeights: const <int>[1, 7, 1], + children: ImageInfo.values.map((ImageInfo image) { + return HeroLayoutCard(imageInfo: image); + }).toList(), + ), + ), + const SizedBox(height: 20), + const Padding( + padding: .directional(top: 8.0, start: 8.0), + child: Text('Multi-browse layout'), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 50), + child: CarouselView.weighted( + flexWeights: const <int>[1, 2, 3, 2, 1], + consumeMaxWeight: false, + children: List<Widget>.generate(20, (int index) { + return ColoredBox( + color: Colors.primaries[index % Colors.primaries.length] + .withValues(alpha: 0.8), + child: const SizedBox.expand(), + ); + }), + ), + ), + const SizedBox(height: 20), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: CarouselView.weighted( + flexWeights: const <int>[3, 3, 3, 2, 1], + consumeMaxWeight: false, + children: CardInfo.values.map((CardInfo info) { + return ColoredBox( + color: info.backgroundColor, + child: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Icon(info.icon, color: info.color, size: 32.0), + Text( + info.label, + style: const TextStyle(fontWeight: .bold), + overflow: .clip, + softWrap: false, + ), + ], + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 20), + const Padding( + padding: .directional(top: 8.0, start: 8.0), + child: Text('Uncontained layout'), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: CarouselView( + itemExtent: 330, + shrinkExtent: 200, + children: List<Widget>.generate(20, (int index) { + return UncontainedLayoutCard(index: index, label: 'Show $index'); + }), + ), + ), + ], + ); + } +} + +class HeroLayoutCard extends StatelessWidget { + const HeroLayoutCard({super.key, required this.imageInfo}); + + final ImageInfo imageInfo; + + @override + Widget build(BuildContext context) { + final double width = MediaQuery.sizeOf(context).width; + return Stack( + alignment: .bottomStart, + children: <Widget>[ + ClipRect( + child: OverflowBox( + maxWidth: width * 7 / 8, + minWidth: width * 7 / 8, + child: Image( + fit: .cover, + image: NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/${imageInfo.url}', + ), + ), + ), + ), + Padding( + padding: const .all(18.0), + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: <Widget>[ + Text( + imageInfo.title, + overflow: .clip, + softWrap: false, + style: Theme.of( + context, + ).textTheme.headlineLarge?.copyWith(color: Colors.white), + ), + const SizedBox(height: 10), + Text( + imageInfo.subtitle, + overflow: .clip, + softWrap: false, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.white), + ), + ], + ), + ), + ], + ); + } +} + +class UncontainedLayoutCard extends StatelessWidget { + const UncontainedLayoutCard({ + super.key, + required this.index, + required this.label, + }); + + final int index; + final String label; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.primaries[index % Colors.primaries.length].withValues( + alpha: 0.5, + ), + child: Center( + child: Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 20), + overflow: .clip, + softWrap: false, + ), + ), + ); + } +} + +enum CardInfo { + camera('Cameras', Icons.video_call, Color(0xff2354C7), Color(0xffECEFFD)), + lighting('Lighting', Icons.lightbulb, Color(0xff806C2A), Color(0xffFAEEDF)), + climate('Climate', Icons.thermostat, Color(0xffA44D2A), Color(0xffFAEDE7)), + wifi('Wifi', Icons.wifi, Color(0xff417345), Color(0xffE5F4E0)), + media('Media', Icons.library_music, Color(0xff2556C8), Color(0xffECEFFD)), + security( + 'Security', + Icons.crisis_alert, + Color(0xff794C01), + Color(0xffFAEEDF), + ), + safety( + 'Safety', + Icons.medical_services, + Color(0xff2251C5), + Color(0xffECEFFD), + ), + more('', Icons.add, Color(0xff201D1C), Color(0xffE3DFD8)); + + const CardInfo(this.label, this.icon, this.color, this.backgroundColor); + final String label; + final IconData icon; + final Color color; + final Color backgroundColor; +} + +enum ImageInfo { + image0( + 'The Flow', + 'Sponsored | Season 1 Now Streaming', + 'content_based_color_scheme_1.png', + ), + image1( + 'Through the Pane', + 'Sponsored | Season 1 Now Streaming', + 'content_based_color_scheme_2.png', + ), + image2( + 'Iridescence', + 'Sponsored | Season 1 Now Streaming', + 'content_based_color_scheme_3.png', + ), + image3( + 'Sea Change', + 'Sponsored | Season 1 Now Streaming', + 'content_based_color_scheme_4.png', + ), + image4( + 'Blue Symphony', + 'Sponsored | Season 1 Now Streaming', + 'content_based_color_scheme_5.png', + ), + image5( + 'When It Rains', + 'Sponsored | Season 1 Now Streaming', + 'content_based_color_scheme_6.png', + ); + + const ImageInfo(this.title, this.subtitle, this.url); + final String title; + final String subtitle; + final String url; +} diff --git a/packages/material_ui/material_ui_examples/lib/carousel/carousel.1.dart b/packages/material_ui/material_ui_examples/lib/carousel/carousel.1.dart new file mode 100644 index 000000000000..f95db11e4dad --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/carousel/carousel.1.dart @@ -0,0 +1,56 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [CarouselView.builder]. + +void main() => runApp(const CarouselBuilderExampleApp()); + +class CarouselBuilderExampleApp extends StatelessWidget { + const CarouselBuilderExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + appBar: AppBar(title: const Text('CarouselView.builder Sample')), + body: const CarouselBuilderExample(), + ), + ); + } +} + +class CarouselBuilderExample extends StatelessWidget { + const CarouselBuilderExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: CarouselView.builder( + itemExtent: 350, + itemCount: 1000, + itemBuilder: (BuildContext context, int index) { + return ColoredBox( + color: Colors.primaries[index % Colors.primaries.length], + child: Center( + child: Text( + 'Item $index', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: .bold, + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/checkbox/checkbox.0.dart b/packages/material_ui/material_ui_examples/lib/checkbox/checkbox.0.dart new file mode 100644 index 000000000000..d4db0e15ada0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/checkbox/checkbox.0.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Checkbox]. + +void main() => runApp(const CheckboxExampleApp()); + +class CheckboxExampleApp extends StatelessWidget { + const CheckboxExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Checkbox Sample')), + body: const Center(child: CheckboxExample()), + ), + ); + } +} + +class CheckboxExample extends StatefulWidget { + const CheckboxExample({super.key}); + + @override + State<CheckboxExample> createState() => _CheckboxExampleState(); +} + +class _CheckboxExampleState extends State<CheckboxExample> { + bool isChecked = false; + + @override + Widget build(BuildContext context) { + Color getColor(Set<WidgetState> states) { + const Set<WidgetState> interactiveStates = <WidgetState>{ + WidgetState.pressed, + WidgetState.hovered, + WidgetState.focused, + }; + if (states.any(interactiveStates.contains)) { + return Colors.blue; + } + return Colors.red; + } + + return Checkbox( + checkColor: Colors.white, + fillColor: WidgetStateProperty.resolveWith(getColor), + value: isChecked, + onChanged: (bool? value) { + setState(() { + isChecked = value!; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/checkbox/checkbox.1.dart b/packages/material_ui/material_ui_examples/lib/checkbox/checkbox.1.dart new file mode 100644 index 000000000000..a5a21416993d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/checkbox/checkbox.1.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for M3 [Checkbox] with error state. + +void main() => runApp(const CheckboxExampleApp()); + +class CheckboxExampleApp extends StatelessWidget { + const CheckboxExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + title: 'Checkbox Sample', + home: Scaffold( + appBar: AppBar(title: const Text('Checkbox Sample')), + body: const Center(child: CheckboxExample()), + ), + ); + } +} + +class CheckboxExample extends StatefulWidget { + const CheckboxExample({super.key}); + + @override + State<CheckboxExample> createState() => _CheckboxExampleState(); +} + +class _CheckboxExampleState extends State<CheckboxExample> { + bool? isChecked = true; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .center, + children: <Widget>[ + Checkbox( + tristate: true, + value: isChecked, + onChanged: (bool? value) { + setState(() { + isChecked = value; + }); + }, + ), + Checkbox( + isError: true, + tristate: true, + value: isChecked, + onChanged: (bool? value) { + setState(() { + isChecked = value; + }); + }, + ), + Checkbox( + isError: true, + tristate: true, + value: isChecked, + onChanged: null, + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/checkbox_list_tile.0.dart b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/checkbox_list_tile.0.dart new file mode 100644 index 000000000000..8ac0fb8d5765 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/checkbox_list_tile.0.dart @@ -0,0 +1,48 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/scheduler.dart' show timeDilation; + +/// Flutter code sample for [CheckboxListTile]. + +void main() => runApp(const CheckboxListTileApp()); + +class CheckboxListTileApp extends StatelessWidget { + const CheckboxListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CheckboxListTileExample()); + } +} + +class CheckboxListTileExample extends StatefulWidget { + const CheckboxListTileExample({super.key}); + + @override + State<CheckboxListTileExample> createState() => + _CheckboxListTileExampleState(); +} + +class _CheckboxListTileExampleState extends State<CheckboxListTileExample> { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('CheckboxListTile Sample')), + body: Center( + child: CheckboxListTile( + title: const Text('Animate Slowly'), + value: timeDilation != 1.0, + onChanged: (bool? value) { + setState(() { + timeDilation = value! ? 10.0 : 1.0; + }); + }, + secondary: const Icon(Icons.hourglass_empty), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/checkbox_list_tile.1.dart b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/checkbox_list_tile.1.dart new file mode 100644 index 000000000000..6a8c6ff6059f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/checkbox_list_tile.1.dart @@ -0,0 +1,81 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [CheckboxListTile]. + +void main() => runApp(const CheckboxListTileApp()); + +class CheckboxListTileApp extends StatelessWidget { + const CheckboxListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CheckboxListTileExample()); + } +} + +class CheckboxListTileExample extends StatefulWidget { + const CheckboxListTileExample({super.key}); + + @override + State<CheckboxListTileExample> createState() => + _CheckboxListTileExampleState(); +} + +class _CheckboxListTileExampleState extends State<CheckboxListTileExample> { + bool checkboxValue1 = true; + bool checkboxValue2 = true; + bool checkboxValue3 = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('CheckboxListTile Sample')), + body: Column( + children: <Widget>[ + CheckboxListTile( + value: checkboxValue1, + onChanged: (bool? value) { + setState(() { + checkboxValue1 = value!; + }); + }, + title: const Text('Headline'), + subtitle: const Text('Supporting text'), + ), + const Divider(height: 0), + CheckboxListTile( + value: checkboxValue2, + onChanged: (bool? value) { + setState(() { + checkboxValue2 = value!; + }); + }, + title: const Text('Headline'), + subtitle: const Text( + 'Longer supporting text to demonstrate how the text wraps and the checkbox is centered vertically with the text.', + ), + ), + const Divider(height: 0), + CheckboxListTile( + value: checkboxValue3, + onChanged: (bool? value) { + setState(() { + checkboxValue3 = value!; + }); + }, + title: const Text('Headline'), + subtitle: const Text( + "Longer supporting text to demonstrate how the text wraps and how setting 'CheckboxListTile.isThreeLine = true' aligns the checkbox to the top vertically with the text.", + ), + isThreeLine: true, + ), + const Divider(height: 0), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/custom_labeled_checkbox.0.dart b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/custom_labeled_checkbox.0.dart new file mode 100644 index 000000000000..2c97fdda1a30 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/custom_labeled_checkbox.0.dart @@ -0,0 +1,97 @@ +// 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/gestures.dart'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for custom labeled checkbox. + +void main() => runApp(const LabeledCheckboxApp()); + +class LabeledCheckboxApp extends StatelessWidget { + const LabeledCheckboxApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: LabeledCheckboxExample()); + } +} + +class LinkedLabelCheckbox extends StatelessWidget { + const LinkedLabelCheckbox({ + super.key, + required this.label, + required this.padding, + required this.value, + required this.onChanged, + }); + + final String label; + final EdgeInsets padding; + final bool value; + final ValueChanged<bool> onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Row( + children: <Widget>[ + Expanded( + child: RichText( + text: TextSpan( + text: label, + style: const TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + debugPrint('Label has been tapped.'); + }, + ), + ), + ), + Checkbox( + value: value, + onChanged: (bool? newValue) { + onChanged(newValue!); + }, + ), + ], + ), + ); + } +} + +class LabeledCheckboxExample extends StatefulWidget { + const LabeledCheckboxExample({super.key}); + + @override + State<LabeledCheckboxExample> createState() => _LabeledCheckboxExampleState(); +} + +class _LabeledCheckboxExampleState extends State<LabeledCheckboxExample> { + bool _isSelected = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom Labeled Checkbox Sample')), + body: Center( + child: LinkedLabelCheckbox( + label: 'Linked, tappable label text', + padding: const .symmetric(horizontal: 20.0), + value: _isSelected, + onChanged: (bool newValue) { + setState(() { + _isSelected = newValue; + }); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/custom_labeled_checkbox.1.dart b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/custom_labeled_checkbox.1.dart new file mode 100644 index 000000000000..0fc06143e4cd --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/checkbox_list_tile/custom_labeled_checkbox.1.dart @@ -0,0 +1,86 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for custom labeled checkbox. + +void main() => runApp(const LabeledCheckboxApp()); + +class LabeledCheckboxApp extends StatelessWidget { + const LabeledCheckboxApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: LabeledCheckboxExample()); + } +} + +class LabeledCheckbox extends StatelessWidget { + const LabeledCheckbox({ + super.key, + required this.label, + required this.padding, + required this.value, + required this.onChanged, + }); + + final String label; + final EdgeInsets padding; + final bool value; + final ValueChanged<bool> onChanged; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + onChanged(!value); + }, + child: Padding( + padding: padding, + child: Row( + children: <Widget>[ + Expanded(child: Text(label)), + Checkbox( + value: value, + onChanged: (bool? newValue) { + onChanged(newValue!); + }, + ), + ], + ), + ), + ); + } +} + +class LabeledCheckboxExample extends StatefulWidget { + const LabeledCheckboxExample({super.key}); + + @override + State<LabeledCheckboxExample> createState() => _LabeledCheckboxExampleState(); +} + +class _LabeledCheckboxExampleState extends State<LabeledCheckboxExample> { + bool _isSelected = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom Labeled Checkbox Sample')), + body: Center( + child: LabeledCheckbox( + label: 'This is the label text', + padding: const .symmetric(horizontal: 20.0), + value: _isSelected, + onChanged: (bool newValue) { + setState(() { + _isSelected = newValue; + }); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/chip/chip_attributes.avatar_box_constraints.0.dart b/packages/material_ui/material_ui_examples/lib/chip/chip_attributes.avatar_box_constraints.0.dart new file mode 100644 index 000000000000..bf7592357809 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/chip/chip_attributes.avatar_box_constraints.0.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ChipAttributes.avatarBoxConstraints]. + +void main() => runApp(const AvatarBoxConstraintsApp()); + +class AvatarBoxConstraintsApp extends StatelessWidget { + const AvatarBoxConstraintsApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: Center(child: AvatarBoxConstraintsExample())), + ); + } +} + +class AvatarBoxConstraintsExample extends StatelessWidget { + const AvatarBoxConstraintsExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + mainAxisAlignment: .center, + children: <Widget>[ + RawChip( + avatarBoxConstraints: BoxConstraints.tightForFinite(), + avatar: Icon(Icons.star), + label: SizedBox( + width: 150, + child: Text('One line text.', maxLines: 3, overflow: .ellipsis), + ), + ), + SizedBox(height: 10), + RawChip( + avatarBoxConstraints: BoxConstraints.tightForFinite(), + avatar: Icon(Icons.star), + label: SizedBox( + width: 150, + child: Text( + 'This text will wrap into two lines.', + maxLines: 3, + overflow: .ellipsis, + ), + ), + ), + SizedBox(height: 10), + RawChip( + avatarBoxConstraints: BoxConstraints.tightForFinite(), + avatar: Icon(Icons.star), + label: SizedBox( + width: 150, + child: Text( + 'This is a very long text that will wrap into three lines.', + maxLines: 3, + overflow: .ellipsis, + ), + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/chip/chip_attributes.chip_animation_style.0.dart b/packages/material_ui/material_ui_examples/lib/chip/chip_attributes.chip_animation_style.0.dart new file mode 100644 index 000000000000..2fdd798dcb49 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/chip/chip_attributes.chip_animation_style.0.dart @@ -0,0 +1,162 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ChipAttributes.chipAnimationStyle]. + +void main() => runApp(const ChipAnimationStyleExampleApp()); + +class ChipAnimationStyleExampleApp extends StatelessWidget { + const ChipAnimationStyleExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: Center(child: ChipAnimationStyleExample())), + ); + } +} + +class ChipAnimationStyleExample extends StatefulWidget { + const ChipAnimationStyleExample({super.key}); + + @override + State<ChipAnimationStyleExample> createState() => + _ChipAnimationStyleExampleState(); +} + +class _ChipAnimationStyleExampleState extends State<ChipAnimationStyleExample> { + bool enabled = true; + bool selected = false; + bool showCheckmark = true; + bool showDeleteIcon = true; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Row( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Column( + mainAxisSize: .min, + children: <Widget>[ + FilterChip.elevated( + chipAnimationStyle: ChipAnimationStyle( + enableAnimation: const AnimationStyle( + duration: Duration(seconds: 3), + reverseDuration: Duration(seconds: 1), + ), + ), + onSelected: !enabled ? null : (bool value) {}, + disabledColor: Colors.red.withValues(alpha: 0.12), + backgroundColor: Colors.amber, + label: Text(enabled ? 'Enabled' : 'Disabled'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + enabled = !enabled; + }); + }, + child: Text(enabled ? 'Disable' : 'Enable'), + ), + ], + ), + Column( + mainAxisSize: .min, + children: <Widget>[ + FilterChip.elevated( + chipAnimationStyle: ChipAnimationStyle( + selectAnimation: const AnimationStyle( + duration: Duration(seconds: 3), + reverseDuration: Duration(seconds: 1), + ), + ), + backgroundColor: Colors.amber, + selectedColor: Colors.blue, + selected: selected, + showCheckmark: false, + onSelected: (bool value) {}, + label: Text(selected ? 'Selected' : 'Unselected'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + selected = !selected; + }); + }, + child: Text(selected ? 'Unselect' : 'Select'), + ), + ], + ), + ], + ), + Row( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Column( + mainAxisSize: .min, + children: <Widget>[ + FilterChip.elevated( + chipAnimationStyle: ChipAnimationStyle( + avatarDrawerAnimation: const AnimationStyle( + duration: Duration(seconds: 2), + reverseDuration: Duration(seconds: 1), + ), + ), + selected: showCheckmark, + onSelected: (bool value) {}, + label: Text(showCheckmark ? 'Checked' : 'Unchecked'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + showCheckmark = !showCheckmark; + }); + }, + child: Text( + showCheckmark ? 'Hide checkmark' : 'Show checkmark', + ), + ), + ], + ), + Column( + mainAxisSize: .min, + children: <Widget>[ + FilterChip.elevated( + chipAnimationStyle: ChipAnimationStyle( + deleteDrawerAnimation: const AnimationStyle( + duration: Duration(seconds: 2), + reverseDuration: Duration(seconds: 1), + ), + ), + onDeleted: showDeleteIcon ? () {} : null, + onSelected: (bool value) {}, + label: Text(showDeleteIcon ? 'Deletable' : 'Undeletable'), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + showDeleteIcon = !showDeleteIcon; + }); + }, + child: Text( + showDeleteIcon ? 'Hide delete icon' : 'Show delete icon', + ), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/chip/deletable_chip_attributes.delete_icon_box_constraints.0.dart b/packages/material_ui/material_ui_examples/lib/chip/deletable_chip_attributes.delete_icon_box_constraints.0.dart new file mode 100644 index 000000000000..fc80ca6a5c4b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/chip/deletable_chip_attributes.delete_icon_box_constraints.0.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DeletableChipAttributes.deleteIconBoxConstraints]. + +void main() => runApp(const DeleteIconBoxConstraintsApp()); + +class DeleteIconBoxConstraintsApp extends StatelessWidget { + const DeleteIconBoxConstraintsApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: Center(child: DeleteIconBoxConstraintsExample())), + ); + } +} + +class DeleteIconBoxConstraintsExample extends StatelessWidget { + const DeleteIconBoxConstraintsExample({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .center, + children: <Widget>[ + RawChip( + deleteIconBoxConstraints: const BoxConstraints.tightForFinite(), + onDeleted: () {}, + label: const SizedBox( + width: 150, + child: Text('One line text.', maxLines: 3, overflow: .ellipsis), + ), + ), + const SizedBox(height: 10), + RawChip( + deleteIconBoxConstraints: const BoxConstraints.tightForFinite(), + onDeleted: () {}, + label: const SizedBox( + width: 150, + child: Text( + 'This text will wrap into two lines.', + maxLines: 3, + overflow: .ellipsis, + ), + ), + ), + const SizedBox(height: 10), + RawChip( + deleteIconBoxConstraints: const BoxConstraints.tightForFinite(), + onDeleted: () {}, + label: const SizedBox( + width: 150, + child: Text( + 'This is a very long text that will wrap into three lines.', + maxLines: 3, + overflow: .ellipsis, + ), + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/chip/deletable_chip_attributes.on_deleted.0.dart b/packages/material_ui/material_ui_examples/lib/chip/deletable_chip_attributes.on_deleted.0.dart new file mode 100644 index 000000000000..5d42797488b4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/chip/deletable_chip_attributes.on_deleted.0.dart @@ -0,0 +1,85 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DeletableChipAttributes.onDeleted]. + +void main() => runApp(const OnDeletedExampleApp()); + +class OnDeletedExampleApp extends StatelessWidget { + const OnDeletedExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('DeletableChipAttributes.onDeleted Sample'), + ), + body: const Center(child: OnDeletedExample()), + ), + ); + } +} + +class Actor { + const Actor(this.name, this.initials); + final String name; + final String initials; +} + +class CastList extends StatefulWidget { + const CastList({super.key}); + + @override + State createState() => CastListState(); +} + +class CastListState extends State<CastList> { + final List<Actor> _cast = <Actor>[ + const Actor('Aaron Burr', 'AB'), + const Actor('Alexander Hamilton', 'AH'), + const Actor('Eliza Hamilton', 'EH'), + const Actor('James Madison', 'JM'), + ]; + + Iterable<Widget> get actorWidgets { + return _cast.map((Actor actor) { + return Padding( + padding: const .all(4.0), + child: Chip( + avatar: CircleAvatar(child: Text(actor.initials)), + label: Text(actor.name), + onDeleted: () { + setState(() { + _cast.removeWhere((Actor entry) { + return entry.name == actor.name; + }); + }); + }, + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return Wrap(children: actorWidgets.toList()); + } +} + +class OnDeletedExample extends StatefulWidget { + const OnDeletedExample({super.key}); + + @override + State<OnDeletedExample> createState() => _OnDeletedExampleState(); +} + +class _OnDeletedExampleState extends State<OnDeletedExample> { + @override + Widget build(BuildContext context) { + return const CastList(); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/choice_chip/choice_chip.0.dart b/packages/material_ui/material_ui_examples/lib/choice_chip/choice_chip.0.dart new file mode 100644 index 000000000000..b0fd9a5bf1ed --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/choice_chip/choice_chip.0.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ChoiceChip]. + +void main() => runApp(const ChipApp()); + +class ChipApp extends StatelessWidget { + const ChipApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const ActionChoiceExample(), + ); + } +} + +class ActionChoiceExample extends StatefulWidget { + const ActionChoiceExample({super.key}); + + @override + State<ActionChoiceExample> createState() => _ActionChoiceExampleState(); +} + +class _ActionChoiceExampleState extends State<ActionChoiceExample> { + int? _value = 1; + + @override + Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar(title: const Text('ActionChoice Sample')), + body: Center( + child: Column( + crossAxisAlignment: .start, + mainAxisAlignment: .center, + children: <Widget>[ + Text('Choose an item', style: textTheme.labelLarge), + const SizedBox(height: 10.0), + Wrap( + spacing: 5.0, + children: List<Widget>.generate(3, (int index) { + return ChoiceChip( + label: Text('Item $index'), + selected: _value == index, + onSelected: (bool selected) { + setState(() { + _value = selected ? index : null; + }); + }, + ); + }).toList(), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/color_scheme/color_scheme.0.dart b/packages/material_ui/material_ui_examples/lib/color_scheme/color_scheme.0.dart new file mode 100644 index 000000000000..d69f3628f1cc --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/color_scheme/color_scheme.0.dart @@ -0,0 +1,595 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ColorScheme]. + +const Widget divider = SizedBox(height: 10); + +void main() => runApp(const ColorSchemeExample()); + +class ColorSchemeExample extends StatefulWidget { + const ColorSchemeExample({super.key}); + + @override + State<ColorSchemeExample> createState() => _ColorSchemeExampleState(); +} + +class _ColorSchemeExampleState extends State<ColorSchemeExample> { + Color selectedColor = ColorSeed.baseColor.color; + Brightness selectedBrightness = .light; + double selectedContrast = 0.0; + static const List<DynamicSchemeVariant> schemeVariants = + DynamicSchemeVariant.values; + + void updateTheme(Brightness brightness, Color color, double contrastLevel) { + setState(() { + selectedBrightness = brightness; + selectedColor = color; + selectedContrast = contrastLevel; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: selectedColor, + brightness: selectedBrightness, + contrastLevel: selectedContrast, + ), + ), + home: Scaffold( + appBar: AppBar( + title: const Text('ColorScheme'), + actions: <Widget>[ + SettingsButton( + selectedColor: selectedColor, + selectedBrightness: selectedBrightness, + selectedContrast: selectedContrast, + updateTheme: updateTheme, + ), + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: const .only(top: 5), + child: Column( + crossAxisAlignment: .start, + children: <Widget>[ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List<Widget>.generate(schemeVariants.length, ( + int index, + ) { + return ColorSchemeVariantColumn( + selectedColor: selectedColor, + brightness: selectedBrightness, + schemeVariant: schemeVariants[index], + contrastLevel: selectedContrast, + ); + }).toList(), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class Settings extends StatefulWidget { + const Settings({ + super.key, + required this.updateTheme, + required this.selectedBrightness, + required this.selectedContrast, + required this.selectedColor, + }); + + final Brightness selectedBrightness; + final double selectedContrast; + final Color selectedColor; + + final void Function(Brightness, Color, double) updateTheme; + + @override + State<Settings> createState() => _SettingsState(); +} + +class _SettingsState extends State<Settings> { + late Brightness selectedBrightness = widget.selectedBrightness; + late Color selectedColor = widget.selectedColor; + late double selectedContrast = widget.selectedContrast; + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: selectedColor, + contrastLevel: selectedContrast, + brightness: selectedBrightness, + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: Padding( + padding: const .all(20.0), + child: ListView( + children: <Widget>[ + Center( + child: Text( + 'Settings', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Row( + children: <Widget>[ + const Text('Brightness: '), + Switch( + value: selectedBrightness == Brightness.light, + onChanged: (bool value) { + setState(() { + selectedBrightness = value + ? Brightness.light + : Brightness.dark; + }); + widget.updateTheme( + selectedBrightness, + selectedColor, + selectedContrast, + ); + }, + ), + ], + ), + Wrap( + crossAxisAlignment: .center, + children: <Widget>[ + const Text('Seed color: '), + ...List<Widget>.generate(ColorSeed.values.length, ( + int index, + ) { + final Color itemColor = ColorSeed.values[index].color; + return IconButton( + icon: selectedColor == ColorSeed.values[index].color + ? Icon(Icons.circle, color: itemColor) + : Icon(Icons.circle_outlined, color: itemColor), + onPressed: () { + setState(() { + selectedColor = itemColor; + }); + widget.updateTheme( + selectedBrightness, + selectedColor, + selectedContrast, + ); + }, + ); + }), + ], + ), + Row( + children: <Widget>[ + const Text('Contrast level: '), + Expanded( + child: Slider( + divisions: 4, + label: selectedContrast.toString(), + min: -1, + value: selectedContrast, + onChanged: (double value) { + setState(() { + selectedContrast = value; + }); + widget.updateTheme( + selectedBrightness, + selectedColor, + selectedContrast, + ); + }, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class ColorSchemeVariantColumn extends StatelessWidget { + const ColorSchemeVariantColumn({ + super.key, + this.schemeVariant = DynamicSchemeVariant.tonalSpot, + this.brightness = Brightness.light, + this.contrastLevel = 0.0, + required this.selectedColor, + }); + + final DynamicSchemeVariant schemeVariant; + final Brightness brightness; + final double contrastLevel; + final Color selectedColor; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 250), + child: Column( + children: <Widget>[ + Padding( + padding: const .symmetric(vertical: 15), + child: Text( + schemeVariant.name == 'tonalSpot' + ? '${schemeVariant.name} (Default)' + : schemeVariant.name, + style: const TextStyle(fontWeight: .bold), + ), + ), + Padding( + padding: const .symmetric(horizontal: 15), + child: ColorSchemeView( + colorScheme: ColorScheme.fromSeed( + seedColor: selectedColor, + brightness: brightness, + contrastLevel: contrastLevel, + dynamicSchemeVariant: schemeVariant, + ), + ), + ), + ], + ), + ); + } +} + +class ColorSchemeView extends StatelessWidget { + const ColorSchemeView({super.key, required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + ColorGroup( + children: <ColorChip>[ + ColorChip('primary', colorScheme.primary, colorScheme.onPrimary), + ColorChip('onPrimary', colorScheme.onPrimary, colorScheme.primary), + ColorChip( + 'primaryContainer', + colorScheme.primaryContainer, + colorScheme.onPrimaryContainer, + ), + ColorChip( + 'onPrimaryContainer', + colorScheme.onPrimaryContainer, + colorScheme.primaryContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + 'primaryFixed', + colorScheme.primaryFixed, + colorScheme.onPrimaryFixed, + ), + ColorChip( + 'onPrimaryFixed', + colorScheme.onPrimaryFixed, + colorScheme.primaryFixed, + ), + ColorChip( + 'primaryFixedDim', + colorScheme.primaryFixedDim, + colorScheme.onPrimaryFixedVariant, + ), + ColorChip( + 'onPrimaryFixedVariant', + colorScheme.onPrimaryFixedVariant, + colorScheme.primaryFixedDim, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + 'secondary', + colorScheme.secondary, + colorScheme.onSecondary, + ), + ColorChip( + 'onSecondary', + colorScheme.onSecondary, + colorScheme.secondary, + ), + ColorChip( + 'secondaryContainer', + colorScheme.secondaryContainer, + colorScheme.onSecondaryContainer, + ), + ColorChip( + 'onSecondaryContainer', + colorScheme.onSecondaryContainer, + colorScheme.secondaryContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + 'secondaryFixed', + colorScheme.secondaryFixed, + colorScheme.onSecondaryFixed, + ), + ColorChip( + 'onSecondaryFixed', + colorScheme.onSecondaryFixed, + colorScheme.secondaryFixed, + ), + ColorChip( + 'secondaryFixedDim', + colorScheme.secondaryFixedDim, + colorScheme.onSecondaryFixedVariant, + ), + ColorChip( + 'onSecondaryFixedVariant', + colorScheme.onSecondaryFixedVariant, + colorScheme.secondaryFixedDim, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip('tertiary', colorScheme.tertiary, colorScheme.onTertiary), + ColorChip( + 'onTertiary', + colorScheme.onTertiary, + colorScheme.tertiary, + ), + ColorChip( + 'tertiaryContainer', + colorScheme.tertiaryContainer, + colorScheme.onTertiaryContainer, + ), + ColorChip( + 'onTertiaryContainer', + colorScheme.onTertiaryContainer, + colorScheme.tertiaryContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + 'tertiaryFixed', + colorScheme.tertiaryFixed, + colorScheme.onTertiaryFixed, + ), + ColorChip( + 'onTertiaryFixed', + colorScheme.onTertiaryFixed, + colorScheme.tertiaryFixed, + ), + ColorChip( + 'tertiaryFixedDim', + colorScheme.tertiaryFixedDim, + colorScheme.onTertiaryFixedVariant, + ), + ColorChip( + 'onTertiaryFixedVariant', + colorScheme.onTertiaryFixedVariant, + colorScheme.tertiaryFixedDim, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip('error', colorScheme.error, colorScheme.onError), + ColorChip('onError', colorScheme.onError, colorScheme.error), + ColorChip( + 'errorContainer', + colorScheme.errorContainer, + colorScheme.onErrorContainer, + ), + ColorChip( + 'onErrorContainer', + colorScheme.onErrorContainer, + colorScheme.errorContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + 'surfaceDim', + colorScheme.surfaceDim, + colorScheme.onSurface, + ), + ColorChip('surface', colorScheme.surface, colorScheme.onSurface), + ColorChip( + 'surfaceBright', + colorScheme.surfaceBright, + colorScheme.onSurface, + ), + ColorChip( + 'surfaceContainerLowest', + colorScheme.surfaceContainerLowest, + colorScheme.onSurface, + ), + ColorChip( + 'surfaceContainerLow', + colorScheme.surfaceContainerLow, + colorScheme.onSurface, + ), + ColorChip( + 'surfaceContainer', + colorScheme.surfaceContainer, + colorScheme.onSurface, + ), + ColorChip( + 'surfaceContainerHigh', + colorScheme.surfaceContainerHigh, + colorScheme.onSurface, + ), + ColorChip( + 'surfaceContainerHighest', + colorScheme.surfaceContainerHighest, + colorScheme.onSurface, + ), + ColorChip('onSurface', colorScheme.onSurface, colorScheme.surface), + ColorChip( + 'onSurfaceVariant', + colorScheme.onSurfaceVariant, + colorScheme.surfaceContainerHighest, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip('outline', colorScheme.outline, null), + ColorChip('shadow', colorScheme.shadow, null), + ColorChip( + 'inverseSurface', + colorScheme.inverseSurface, + colorScheme.onInverseSurface, + ), + ColorChip( + 'onInverseSurface', + colorScheme.onInverseSurface, + colorScheme.inverseSurface, + ), + ColorChip( + 'inversePrimary', + colorScheme.inversePrimary, + colorScheme.primary, + ), + ], + ), + ], + ); + } +} + +class ColorGroup extends StatelessWidget { + const ColorGroup({super.key, required this.children}); + + final List<Widget> children; + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: Card( + clipBehavior: .antiAlias, + child: Column(children: children), + ), + ); + } +} + +class ColorChip extends StatelessWidget { + const ColorChip(this.label, this.color, this.onColor, {super.key}); + + final Color color; + final Color? onColor; + final String label; + + static Color contrastColor(Color color) { + final Brightness brightness = ThemeData.estimateBrightnessForColor(color); + return brightness == .dark ? Colors.white : Colors.black; + } + + @override + Widget build(BuildContext context) { + final Color labelColor = onColor ?? contrastColor(color); + return ColoredBox( + color: color, + child: Padding( + padding: const .all(16), + child: Row( + children: <Expanded>[ + Expanded( + child: Text(label, style: TextStyle(color: labelColor)), + ), + ], + ), + ), + ); + } +} + +enum ColorSeed { + baseColor('M3 Baseline', Color(0xff6750a4)), + indigo('Indigo', Colors.indigo), + blue('Blue', Colors.blue), + teal('Teal', Colors.teal), + green('Green', Colors.green), + yellow('Yellow', Colors.yellow), + orange('Orange', Colors.orange), + deepOrange('Deep Orange', Colors.deepOrange), + pink('Pink', Colors.pink), + brightBlue('Bright Blue', Color(0xFF0000FF)), + brightGreen('Bright Green', Color(0xFF00FF00)), + brightRed('Bright Red', Color(0xFFFF0000)); + + const ColorSeed(this.label, this.color); + final String label; + final Color color; +} + +class SettingsButton extends StatelessWidget { + const SettingsButton({ + super.key, + required this.updateTheme, + required this.selectedBrightness, + required this.selectedContrast, + required this.selectedColor, + }); + + final Brightness selectedBrightness; + final double selectedContrast; + final Color selectedColor; + + final void Function(Brightness, Color, double) updateTheme; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + showModalBottomSheet<void>( + barrierColor: Colors.transparent, + context: context, + builder: (BuildContext context) { + return Settings( + selectedColor: selectedColor, + selectedBrightness: selectedBrightness, + selectedContrast: selectedContrast, + updateTheme: updateTheme, + ); + }, + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/color_scheme/dynamic_content_color.0.dart b/packages/material_ui/material_ui_examples/lib/color_scheme/dynamic_content_color.0.dart new file mode 100644 index 000000000000..32251aa0cb73 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/color_scheme/dynamic_content_color.0.dart @@ -0,0 +1,526 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ColorScheme.fromImageProvider] with content-based dynamic color. + +const Widget divider = SizedBox(height: 10); +const double narrowScreenWidthThreshold = 400; + +void main() => runApp(const DynamicColorExample()); + +class DynamicColorExample extends StatefulWidget { + const DynamicColorExample({this.loadColorScheme, super.key}); + + static const List<ImageProvider> images = <NetworkImage>[ + NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_1.png', + ), + NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_2.png', + ), + NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_3.png', + ), + NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_4.png', + ), + NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_5.png', + ), + NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_6.png', + ), + ]; + + final Future<ColorScheme> Function( + ImageProvider<Object> provider, + Brightness brightness, + )? + loadColorScheme; + + @override + State<DynamicColorExample> createState() => _DynamicColorExampleState(); +} + +class _DynamicColorExampleState extends State<DynamicColorExample> { + late ColorScheme currentColorScheme; + String currentHyperlinkImage = ''; + late int selectedImage; + late bool isLight; + late bool isLoading; + + @override + void initState() { + super.initState(); + selectedImage = 0; + isLight = true; + isLoading = true; + currentColorScheme = const ColorScheme.light(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateImage(DynamicColorExample.images[selectedImage]); + isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = currentColorScheme; + final Color selectedColor = currentColorScheme.primary; + + final ThemeData lightTheme = ThemeData( + colorSchemeSeed: selectedColor, + brightness: .light, + useMaterial3: false, + ); + final ThemeData darkTheme = ThemeData( + colorSchemeSeed: selectedColor, + brightness: .dark, + useMaterial3: false, + ); + + Widget schemeLabel(String brightness, ColorScheme colorScheme) { + return Padding( + padding: const .symmetric(vertical: 15), + child: Text( + brightness, + style: TextStyle( + fontWeight: .bold, + color: colorScheme.onSecondaryContainer, + ), + ), + ); + } + + Widget schemeView(ThemeData theme) { + return Padding( + padding: const .symmetric(horizontal: 15), + child: ColorSchemeView(colorScheme: theme.colorScheme), + ); + } + + return MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + debugShowCheckedModeBanner: false, + home: Builder( + builder: (BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Content Based Dynamic Color'), + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + actions: <Widget>[ + const Icon(Icons.light_mode), + Switch( + activeThumbColor: colorScheme.primary, + activeTrackColor: colorScheme.surface, + inactiveTrackColor: colorScheme.onSecondary, + value: isLight, + onChanged: (bool value) { + setState(() { + isLight = value; + _updateImage(DynamicColorExample.images[selectedImage]); + }); + }, + ), + ], + ), + body: Center( + child: isLoading + ? const CircularProgressIndicator() + : ColoredBox( + color: colorScheme.secondaryContainer, + child: Column( + children: <Widget>[ + divider, + _imagesRow( + context, + DynamicColorExample.images, + colorScheme, + ), + divider, + Expanded( + child: ColoredBox( + color: colorScheme.surface, + child: LayoutBuilder( + builder: + ( + BuildContext context, + BoxConstraints constraints, + ) { + if (constraints.maxWidth < + narrowScreenWidthThreshold) { + return SingleChildScrollView( + child: Column( + children: <Widget>[ + divider, + schemeLabel( + 'Light ColorScheme', + colorScheme, + ), + schemeView(lightTheme), + divider, + divider, + schemeLabel( + 'Dark ColorScheme', + colorScheme, + ), + schemeView(darkTheme), + ], + ), + ); + } else { + return SingleChildScrollView( + child: Padding( + padding: const .only(top: 5), + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: Column( + children: <Widget>[ + schemeLabel( + 'Light ColorScheme', + colorScheme, + ), + schemeView(lightTheme), + ], + ), + ), + Expanded( + child: Column( + children: <Widget>[ + schemeLabel( + 'Dark ColorScheme', + colorScheme, + ), + schemeView(darkTheme), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } + }, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future<void> _updateImage(ImageProvider provider) async { + final ColorScheme newColorScheme; + if (widget.loadColorScheme != null) { + newColorScheme = await widget.loadColorScheme!( + provider, + isLight ? .light : .dark, + ); + } else { + newColorScheme = await ColorScheme.fromImageProvider( + provider: provider, + brightness: isLight ? .light : .dark, + ); + } + if (!mounted) { + return; + } + setState(() { + selectedImage = DynamicColorExample.images.indexOf(provider); + currentColorScheme = newColorScheme; + }); + } + + // For small screens, have two rows of image selection. For wide screens, + // fit them onto one row. + Widget _imagesRow( + BuildContext context, + List<ImageProvider> images, + ColorScheme colorScheme, + ) { + final double windowHeight = MediaQuery.heightOf(context); + final double windowWidth = MediaQuery.widthOf(context); + return Padding( + padding: const .all(8.0), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (constraints.maxWidth > 800) { + return _adaptiveLayoutImagesRow(images, colorScheme, windowHeight); + } else { + return Column( + children: <Widget>[ + _adaptiveLayoutImagesRow( + images.sublist(0, 3), + colorScheme, + windowWidth, + ), + _adaptiveLayoutImagesRow( + images.sublist(3), + colorScheme, + windowWidth, + ), + ], + ); + } + }, + ), + ); + } + + Widget _adaptiveLayoutImagesRow( + List<ImageProvider> images, + ColorScheme colorScheme, + double windowWidth, + ) { + return Row( + mainAxisAlignment: .center, + children: images + .map( + (ImageProvider image) => Flexible( + flex: (images.length / 3).floor(), + child: GestureDetector( + onTap: () => _updateImage(image), + child: Card( + color: + DynamicColorExample.images.indexOf(image) == selectedImage + ? colorScheme.primaryContainer + : colorScheme.surface, + child: Padding( + padding: const .all(5.0), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: windowWidth * .25), + child: ClipRRect( + borderRadius: .circular(8.0), + child: Image(image: image), + ), + ), + ), + ), + ), + ), + ) + .toList(), + ); + } +} + +class ColorSchemeView extends StatelessWidget { + const ColorSchemeView({super.key, required this.colorScheme}); + + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + ColorGroup( + children: <ColorChip>[ + ColorChip( + label: 'primary', + color: colorScheme.primary, + onColor: colorScheme.onPrimary, + ), + ColorChip( + label: 'onPrimary', + color: colorScheme.onPrimary, + onColor: colorScheme.primary, + ), + ColorChip( + label: 'primaryContainer', + color: colorScheme.primaryContainer, + onColor: colorScheme.onPrimaryContainer, + ), + ColorChip( + label: 'onPrimaryContainer', + color: colorScheme.onPrimaryContainer, + onColor: colorScheme.primaryContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + label: 'secondary', + color: colorScheme.secondary, + onColor: colorScheme.onSecondary, + ), + ColorChip( + label: 'onSecondary', + color: colorScheme.onSecondary, + onColor: colorScheme.secondary, + ), + ColorChip( + label: 'secondaryContainer', + color: colorScheme.secondaryContainer, + onColor: colorScheme.onSecondaryContainer, + ), + ColorChip( + label: 'onSecondaryContainer', + color: colorScheme.onSecondaryContainer, + onColor: colorScheme.secondaryContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + label: 'tertiary', + color: colorScheme.tertiary, + onColor: colorScheme.onTertiary, + ), + ColorChip( + label: 'onTertiary', + color: colorScheme.onTertiary, + onColor: colorScheme.tertiary, + ), + ColorChip( + label: 'tertiaryContainer', + color: colorScheme.tertiaryContainer, + onColor: colorScheme.onTertiaryContainer, + ), + ColorChip( + label: 'onTertiaryContainer', + color: colorScheme.onTertiaryContainer, + onColor: colorScheme.tertiaryContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + label: 'error', + color: colorScheme.error, + onColor: colorScheme.onError, + ), + ColorChip( + label: 'onError', + color: colorScheme.onError, + onColor: colorScheme.error, + ), + ColorChip( + label: 'errorContainer', + color: colorScheme.errorContainer, + onColor: colorScheme.onErrorContainer, + ), + ColorChip( + label: 'onErrorContainer', + color: colorScheme.onErrorContainer, + onColor: colorScheme.errorContainer, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip( + label: 'surface', + color: colorScheme.surface, + onColor: colorScheme.onSurface, + ), + ColorChip( + label: 'onSurface', + color: colorScheme.onSurface, + onColor: colorScheme.surface, + ), + ColorChip( + label: 'onSurfaceVariant', + color: colorScheme.onSurfaceVariant, + onColor: colorScheme.surfaceContainerHighest, + ), + ], + ), + divider, + ColorGroup( + children: <ColorChip>[ + ColorChip(label: 'outline', color: colorScheme.outline), + ColorChip(label: 'shadow', color: colorScheme.shadow), + ColorChip( + label: 'inverseSurface', + color: colorScheme.inverseSurface, + onColor: colorScheme.onInverseSurface, + ), + ColorChip( + label: 'onInverseSurface', + color: colorScheme.onInverseSurface, + onColor: colorScheme.inverseSurface, + ), + ColorChip( + label: 'inversePrimary', + color: colorScheme.inversePrimary, + onColor: colorScheme.primary, + ), + ], + ), + ], + ); + } +} + +class ColorGroup extends StatelessWidget { + const ColorGroup({super.key, required this.children}); + + final List<Widget> children; + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: Card( + clipBehavior: .antiAlias, + child: Column(children: children), + ), + ); + } +} + +class ColorChip extends StatelessWidget { + const ColorChip({ + super.key, + required this.color, + required this.label, + this.onColor, + }); + + final Color color; + final Color? onColor; + final String label; + + static Color contrastColor(Color color) { + final Brightness brightness = ThemeData.estimateBrightnessForColor(color); + return switch (brightness) { + .dark => Colors.white, + .light => Colors.black, + }; + } + + @override + Widget build(BuildContext context) { + final Color labelColor = onColor ?? contrastColor(color); + return ColoredBox( + color: color, + child: Padding( + padding: const .all(16), + child: Row( + children: <Expanded>[ + Expanded( + child: Text(label, style: TextStyle(color: labelColor)), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/context_menu/context_menu_controller.0.dart b/packages/material_ui/material_ui_examples/lib/context_menu/context_menu_controller.0.dart new file mode 100644 index 000000000000..bb5136774c5b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/context_menu/context_menu_controller.0.dart @@ -0,0 +1,184 @@ +// 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. + +// This sample demonstrates allowing a context menu to be shown in a widget +// subtree in response to user gestures. + +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const ContextMenuControllerExampleApp()); + +/// A builder that includes an Offset to draw the context menu at. +typedef ContextMenuBuilder = + Widget Function(BuildContext context, Offset offset); + +class ContextMenuControllerExampleApp extends StatefulWidget { + const ContextMenuControllerExampleApp({super.key}); + + @override + State<ContextMenuControllerExampleApp> createState() => + _ContextMenuControllerExampleAppState(); +} + +class _ContextMenuControllerExampleAppState + extends State<ContextMenuControllerExampleApp> { + void _showDialog(BuildContext context) { + Navigator.of(context).push( + DialogRoute<void>( + context: context, + builder: (BuildContext context) => + const AlertDialog(title: Text('You clicked print!')), + ), + ); + } + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Context menu outside of text')), + body: _ContextMenuRegion( + contextMenuBuilder: (BuildContext context, Offset offset) { + // The custom context menu will look like the default context menu + // on the current platform with a single 'Print' button. + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: TextSelectionToolbarAnchors(primaryAnchor: offset), + buttonItems: <ContextMenuButtonItem>[ + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + label: 'Print', + ), + ], + ); + }, + // In this case this wraps a big open space in a GestureDetector in + // order to show the context menu, but it could also wrap a single + // widget like an Image to give it a context menu. + child: ListView( + children: <Widget>[ + Container(height: 20.0), + const Text( + 'Right click (desktop) or long press (mobile) anywhere, not just on this text, to show the custom menu.', + ), + ], + ), + ), + ), + ); + } +} + +/// Shows and hides the context menu based on user gestures. +/// +/// By default, shows the menu on right clicks and long presses. +class _ContextMenuRegion extends StatefulWidget { + /// Creates an instance of [_ContextMenuRegion]. + const _ContextMenuRegion({ + required this.child, + required this.contextMenuBuilder, + }); + + /// Builds the context menu. + final ContextMenuBuilder contextMenuBuilder; + + /// The child widget that will be listened to for gestures. + final Widget child; + + @override + State<_ContextMenuRegion> createState() => _ContextMenuRegionState(); +} + +class _ContextMenuRegionState extends State<_ContextMenuRegion> { + Offset? _longPressOffset; + + final ContextMenuController _contextMenuController = ContextMenuController(); + + static bool get _longPressEnabled { + switch (defaultTargetPlatform) { + case .android: + case .iOS: + return true; + case .macOS: + case .fuchsia: + case .linux: + case .windows: + return false; + } + } + + void _onSecondaryTapUp(TapUpDetails details) { + _show(details.globalPosition); + } + + void _onTap() { + if (!_contextMenuController.isShown) { + return; + } + _hide(); + } + + void _onLongPressStart(LongPressStartDetails details) { + _longPressOffset = details.globalPosition; + } + + void _onLongPress() { + assert(_longPressOffset != null); + _show(_longPressOffset!); + _longPressOffset = null; + } + + void _show(Offset position) { + _contextMenuController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return widget.contextMenuBuilder(context, position); + }, + ); + } + + void _hide() { + _contextMenuController.remove(); + } + + @override + void dispose() { + _hide(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapUp: _onSecondaryTapUp, + onTap: _onTap, + onLongPress: _longPressEnabled ? _onLongPress : null, + onLongPressStart: _longPressEnabled ? _onLongPressStart : null, + child: widget.child, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.0.dart b/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.0.dart new file mode 100644 index 000000000000..396c897cd496 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.0.dart @@ -0,0 +1,98 @@ +// 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. + +// This example demonstrates showing the default buttons, but customizing their +// appearance. + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const EditableTextToolbarBuilderExampleApp()); + +class EditableTextToolbarBuilderExampleApp extends StatefulWidget { + const EditableTextToolbarBuilderExampleApp({super.key}); + + @override + State<EditableTextToolbarBuilderExampleApp> createState() => + _EditableTextToolbarBuilderExampleAppState(); +} + +class _EditableTextToolbarBuilderExampleAppState + extends State<EditableTextToolbarBuilderExampleApp> { + final TextEditingController _controller = TextEditingController( + text: + 'Right click (desktop) or long press (mobile) to see the menu with custom buttons.', + ); + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom button appearance')), + body: Center( + child: Column( + children: <Widget>[ + const SizedBox(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: + ( + BuildContext context, + EditableTextState editableTextState, + ) { + return AdaptiveTextSelectionToolbar( + anchors: editableTextState.contextMenuAnchors, + // Build the default buttons, but make them look custom. + // In a real project you may want to build different + // buttons depending on the platform. + children: editableTextState.contextMenuButtonItems.map(( + ContextMenuButtonItem buttonItem, + ) { + return CupertinoButton( + color: const Color(0xffaaaa00), + disabledColor: const Color(0xffaaaaff), + onPressed: buttonItem.onPressed, + padding: const .all(10.0), + pressedOpacity: 0.7, + child: SizedBox( + width: 200.0, + child: Text( + CupertinoTextSelectionToolbarButton.getButtonLabel( + context, + buttonItem, + ), + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.1.dart b/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.1.dart new file mode 100644 index 000000000000..3eafe618cec6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.1.dart @@ -0,0 +1,115 @@ +// 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. + +// This example demonstrates showing a custom context menu only when some +// narrowly defined text is selected. + +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const EditableTextToolbarBuilderExampleApp()); + +const String emailAddress = 'me@example.com'; +const String text = 'Select the email address and open the menu: $emailAddress'; + +class EditableTextToolbarBuilderExampleApp extends StatefulWidget { + const EditableTextToolbarBuilderExampleApp({super.key}); + + @override + State<EditableTextToolbarBuilderExampleApp> createState() => + _EditableTextToolbarBuilderExampleAppState(); +} + +class _EditableTextToolbarBuilderExampleAppState + extends State<EditableTextToolbarBuilderExampleApp> { + final TextEditingController _controller = TextEditingController(text: text); + + void _showDialog(BuildContext context) { + Navigator.of(context).push( + DialogRoute<void>( + context: context, + builder: (BuildContext context) => + const AlertDialog(title: Text('You clicked send email!')), + ), + ); + } + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom button for emails')), + body: Center( + child: Column( + children: <Widget>[ + Container(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: + ( + BuildContext context, + EditableTextState editableTextState, + ) { + final List<ContextMenuButtonItem> buttonItems = + editableTextState.contextMenuButtonItems; + // Here we add an "Email" button to the default TextField + // context menu for the current platform, but only if an email + // address is currently selected. + final TextEditingValue value = _controller.value; + if (_isValidEmail( + value.selection.textInside(value.text), + )) { + buttonItems.insert( + 0, + ContextMenuButtonItem( + label: 'Send email', + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + ), + ); + } + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +bool _isValidEmail(String text) { + return RegExp( + r'(?<name>[a-zA-Z0-9]+)' + r'@' + r'(?<domain>[a-zA-Z0-9]+)' + r'\.' + r'(?<topLevelDomain>[a-zA-Z0-9]+)', + ).hasMatch(text); +} diff --git a/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.2.dart b/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.2.dart new file mode 100644 index 000000000000..cadcc58b646c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/context_menu/editable_text_toolbar_builder.2.dart @@ -0,0 +1,143 @@ +// 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. + +// This example demonstrates how to create a custom toolbar that retains the +// look of the default buttons for the current platform. + +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const EditableTextToolbarBuilderExampleApp()); + +class EditableTextToolbarBuilderExampleApp extends StatefulWidget { + const EditableTextToolbarBuilderExampleApp({super.key}); + + @override + State<EditableTextToolbarBuilderExampleApp> createState() => + _EditableTextToolbarBuilderExampleAppState(); +} + +class _EditableTextToolbarBuilderExampleAppState + extends State<EditableTextToolbarBuilderExampleApp> { + final TextEditingController _controller = TextEditingController( + text: + 'Right click (desktop) or long press (mobile) to see the menu with a custom toolbar.', + ); + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Custom toolbar, default-looking buttons'), + ), + body: Center( + child: Column( + children: <Widget>[ + const SizedBox(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: + ( + BuildContext context, + EditableTextState editableTextState, + ) { + return _MyTextSelectionToolbar( + anchor: + editableTextState.contextMenuAnchors.primaryAnchor, + // getAdaptiveButtons creates the default button widgets for + // the current platform. + children: + AdaptiveTextSelectionToolbar.getAdaptiveButtons( + context, + // These buttons just close the menu when clicked. + <ContextMenuButtonItem>[ + ContextMenuButtonItem( + label: 'One', + onPressed: () => + ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Two', + onPressed: () => + ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Three', + onPressed: () => + ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Four', + onPressed: () => + ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Five', + onPressed: () => + ContextMenuController.removeAny(), + ), + ], + ).toList(), + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +/// A simple, yet totally custom, text selection toolbar. +/// +/// Displays its children in a scrollable grid. +class _MyTextSelectionToolbar extends StatelessWidget { + const _MyTextSelectionToolbar({required this.anchor, required this.children}); + + final Offset anchor; + final List<Widget> children; + + @override + Widget build(BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + top: anchor.dy, + left: anchor.dx, + child: Container( + width: 200.0, + height: 200.0, + color: Colors.cyanAccent.withValues(alpha: 0.5), + child: GridView.count( + padding: const .all(12.0), + crossAxisCount: 2, + children: children, + ), + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/context_menu/selectable_region_toolbar_builder.0.dart b/packages/material_ui/material_ui_examples/lib/context_menu/selectable_region_toolbar_builder.0.dart new file mode 100644 index 000000000000..ed0bf29cad25 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/context_menu/selectable_region_toolbar_builder.0.dart @@ -0,0 +1,92 @@ +// 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. + +// This example demonstrates a custom context menu in non-editable text using +// SelectionArea. + +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const SelectableRegionToolbarBuilderExampleApp()); + +const String text = + 'I am some text inside of SelectionArea. Right click (desktop) or long press (mobile) me to show the customized context menu.'; + +class SelectableRegionToolbarBuilderExampleApp extends StatefulWidget { + const SelectableRegionToolbarBuilderExampleApp({super.key}); + + @override + State<SelectableRegionToolbarBuilderExampleApp> createState() => + _SelectableRegionToolbarBuilderExampleAppState(); +} + +class _SelectableRegionToolbarBuilderExampleAppState + extends State<SelectableRegionToolbarBuilderExampleApp> { + void _showDialog(BuildContext context) { + Navigator.of(context).push( + DialogRoute<void>( + context: context, + builder: (BuildContext context) => + const AlertDialog(title: Text('You clicked print!')), + ), + ); + } + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Context menu anywhere')), + body: Center( + child: SizedBox( + width: 200.0, + child: SelectionArea( + contextMenuBuilder: + ( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: selectableRegionState.contextMenuAnchors, + buttonItems: <ContextMenuButtonItem>[ + ...selectableRegionState.contextMenuButtonItems, + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + label: 'Print', + ), + ], + ); + }, + child: ListView( + children: const <Widget>[SizedBox(height: 20.0), Text(text)], + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/data_table/data_table.0.dart b/packages/material_ui/material_ui_examples/lib/data_table/data_table.0.dart new file mode 100644 index 000000000000..4e38d14e540d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/data_table/data_table.0.dart @@ -0,0 +1,73 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DataTable]. + +void main() => runApp(const DataTableExampleApp()); + +class DataTableExampleApp extends StatelessWidget { + const DataTableExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DataTable Sample')), + body: const DataTableExample(), + ), + ); + } +} + +class DataTableExample extends StatelessWidget { + const DataTableExample({super.key}); + + @override + Widget build(BuildContext context) { + return DataTable( + columns: const <DataColumn>[ + DataColumn( + label: Expanded( + child: Text('Name', style: TextStyle(fontStyle: .italic)), + ), + ), + DataColumn( + label: Expanded( + child: Text('Age', style: TextStyle(fontStyle: .italic)), + ), + ), + DataColumn( + label: Expanded( + child: Text('Role', style: TextStyle(fontStyle: .italic)), + ), + ), + ], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[ + DataCell(Text('Sarah')), + DataCell(Text('19')), + DataCell(Text('Student')), + ], + ), + DataRow( + cells: <DataCell>[ + DataCell(Text('Janine')), + DataCell(Text('43')), + DataCell(Text('Professor')), + ], + ), + DataRow( + cells: <DataCell>[ + DataCell(Text('William')), + DataCell(Text('27')), + DataCell(Text('Associate Professor')), + ], + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/data_table/data_table.1.dart b/packages/material_ui/material_ui_examples/lib/data_table/data_table.1.dart new file mode 100644 index 000000000000..e1667e016e32 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/data_table/data_table.1.dart @@ -0,0 +1,71 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DataTable]. + +void main() => runApp(const DataTableExampleApp()); + +class DataTableExampleApp extends StatelessWidget { + const DataTableExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DataTable Sample')), + body: const DataTableExample(), + ), + ); + } +} + +class DataTableExample extends StatefulWidget { + const DataTableExample({super.key}); + + @override + State<DataTableExample> createState() => _DataTableExampleState(); +} + +class _DataTableExampleState extends State<DataTableExample> { + static const int numItems = 20; + List<bool> selected = List<bool>.generate(numItems, (int index) => false); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Number'))], + rows: List<DataRow>.generate( + numItems, + (int index) => DataRow( + color: WidgetStateProperty.resolveWith<Color?>(( + Set<WidgetState> states, + ) { + // All rows will have the same selected color. + if (states.contains(WidgetState.selected)) { + return Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08); + } + // Even rows will have a grey color. + if (index.isEven) { + return Colors.grey.withValues(alpha: 0.3); + } + return null; // Use default value for other states and odd rows. + }), + cells: <DataCell>[DataCell(Text('Row $index'))], + selected: selected[index], + onSelectChanged: (bool? value) { + setState(() { + selected[index] = value!; + }); + }, + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/date_picker/custom_calendar_date_picker.0.dart b/packages/material_ui/material_ui_examples/lib/date_picker/custom_calendar_date_picker.0.dart new file mode 100644 index 000000000000..3050577866f5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/date_picker/custom_calendar_date_picker.0.dart @@ -0,0 +1,157 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample demonstrating how to use a custom [CalendarDelegate] +/// with [CalendarDatePicker] to implement a hypothetical calendar system +/// where even-numbered months have 21 days, odd-numbered months have 28 days, +/// and every month starts on a Monday. + +void main() => runApp(const CalendarDatePickerApp()); + +class CalendarDatePickerApp extends StatelessWidget { + const CalendarDatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CalendarDatePickerExample()); + } +} + +class CalendarDatePickerExample extends StatefulWidget { + const CalendarDatePickerExample({super.key}); + + @override + State<CalendarDatePickerExample> createState() => + _CalendarDatePickerExampleState(); +} + +class _CalendarDatePickerExampleState extends State<CalendarDatePickerExample> { + DateTime? selectedDate; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom Calendar')), + body: Column( + spacing: 16, + children: <Widget>[ + CalendarDatePicker( + initialDate: DateTime(2025, 2, 8), + firstDate: DateTime(2025), + lastDate: DateTime(2026), + onDateChanged: (DateTime pickedDate) { + setState(() { + selectedDate = pickedDate; + }); + }, + calendarDelegate: const CustomCalendarDelegate(), + ), + const Divider(height: 1), + Text( + selectedDate != null + ? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}' + : 'No date selected', + ), + ], + ), + ); + } +} + +/// A custom calendar system where even-numbered months have 21 days, +/// odd-numbered months have 28 days, and every month starts on a Monday. +/// +/// This hypothetical calendar follows a fixed structure: +/// - **Even-numbered months (2, 4, 6, etc.)** always have **21 days**. +/// - **Odd-numbered months (1, 3, 5, etc.)** always have **28 days**. +/// - **The first day of every month is always a Monday**, ensuring a consistent weekly alignment. +class CustomCalendarDelegate extends CalendarDelegate<DateTime> { + const CustomCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } + + // ------------------------------------------------------------------------ + // All the implementations below are based on the Gregorian calendar system. + + @override + DateTime now() => DateTime.now(); + + @override + DateTime dateOnly(DateTime date) => DateUtils.dateOnly(date); + + @override + int monthDelta(DateTime startDate, DateTime endDate) => + DateUtils.monthDelta(startDate, endDate); + + @override + DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateUtils.addMonthsToMonthDate(monthDate, monthsToAdd); + } + + @override + DateTime addDaysToDate(DateTime date, int days) => + DateUtils.addDaysToDate(date, days); + + @override + DateTime getMonth(int year, int month) => DateTime(year, month); + + @override + DateTime getDay(int year, int month, int day) => DateTime(year, month, day); + + @override + String formatMonthYear(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMonthYear(date); + } + + @override + String formatMediumDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMediumDate(date); + } + + @override + String formatShortMonthDay( + DateTime date, + MaterialLocalizations localizations, + ) { + return localizations.formatShortMonthDay(date); + } + + @override + String formatShortDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortDate(date); + } + + @override + String formatFullDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatFullDate(date); + } + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatCompactDate(date); + } + + @override + DateTime? parseCompactDate( + String? inputString, + MaterialLocalizations localizations, + ) { + return localizations.parseCompactDate(inputString); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return localizations.dateHelpText; + } +} diff --git a/packages/material_ui/material_ui_examples/lib/date_picker/date_picker_theme_day_shape.0.dart b/packages/material_ui/material_ui_examples/lib/date_picker/date_picker_theme_day_shape.0.dart new file mode 100644 index 000000000000..5433448bd2b5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/date_picker/date_picker_theme_day_shape.0.dart @@ -0,0 +1,64 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DatePickerThemeData]. + +void main() => runApp(const DatePickerApp()); + +class DatePickerApp extends StatelessWidget { + const DatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + datePickerTheme: DatePickerThemeData( + todayBackgroundColor: const WidgetStatePropertyAll<Color>( + Colors.amber, + ), + todayForegroundColor: const WidgetStatePropertyAll<Color>( + Colors.black, + ), + todayBorder: const BorderSide(width: 2), + dayShape: WidgetStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: .circular(8.0)), + ), + shape: RoundedRectangleBorder(borderRadius: .circular(16.0)), + ), + ), + home: const DatePickerExample(), + ); + } +} + +class DatePickerExample extends StatefulWidget { + const DatePickerExample({super.key}); + + @override + State<DatePickerExample> createState() => _DatePickerExampleState(); +} + +class _DatePickerExampleState extends State<DatePickerExample> { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: OutlinedButton( + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime(2021, 1, 20), + currentDate: DateTime(2021, 1, 15), + firstDate: DateTime(2021), + lastDate: DateTime(2022), + ); + }, + child: const Text('Open Date Picker'), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/date_picker/show_date_picker.0.dart b/packages/material_ui/material_ui_examples/lib/date_picker/show_date_picker.0.dart new file mode 100644 index 000000000000..bf1d8c1ec35f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/date_picker/show_date_picker.0.dart @@ -0,0 +1,110 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showDatePicker]. + +void main() => runApp(const DatePickerApp()); + +class DatePickerApp extends StatelessWidget { + const DatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + restorationScopeId: 'app', + home: DatePickerExample(restorationId: 'main'), + ); + } +} + +class DatePickerExample extends StatefulWidget { + const DatePickerExample({super.key, this.restorationId}); + + final String? restorationId; + + @override + State<DatePickerExample> createState() => _DatePickerExampleState(); +} + +/// RestorationProperty objects can be used because of RestorationMixin. +class _DatePickerExampleState extends State<DatePickerExample> + with RestorationMixin { + // In this example, the restoration ID for the mixin is passed in through + // the [StatefulWidget]'s constructor. + @override + String? get restorationId => widget.restorationId; + + final RestorableDateTime _selectedDate = RestorableDateTime( + DateTime(2021, 7, 25), + ); + late final RestorableRouteFuture<DateTime?> _restorableDatePickerRouteFuture = + RestorableRouteFuture<DateTime?>( + onComplete: _selectDate, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _datePickerRoute, + arguments: _selectedDate.value.millisecondsSinceEpoch, + ); + }, + ); + + @pragma('vm:entry-point') + static Route<DateTime> _datePickerRoute( + BuildContext context, + Object? arguments, + ) { + return DialogRoute<DateTime>( + context: context, + builder: (BuildContext context) { + return DatePickerDialog( + restorationId: 'date_picker_dialog', + initialEntryMode: .calendarOnly, + initialDate: DateTime.fromMillisecondsSinceEpoch(arguments! as int), + firstDate: DateTime(2021), + lastDate: DateTime(2022), + ); + }, + ); + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedDate, 'selected_date'); + registerForRestoration( + _restorableDatePickerRouteFuture, + 'date_picker_route_future', + ); + } + + void _selectDate(DateTime? newSelectedDate) { + if (newSelectedDate != null) { + setState(() { + _selectedDate.value = newSelectedDate; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Selected: ${_selectedDate.value.day}/${_selectedDate.value.month}/${_selectedDate.value.year}', + ), + ), + ); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: OutlinedButton( + onPressed: () { + _restorableDatePickerRouteFuture.present(); + }, + child: const Text('Open Date Picker'), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/date_picker/show_date_picker.1.dart b/packages/material_ui/material_ui_examples/lib/date_picker/show_date_picker.1.dart new file mode 100644 index 000000000000..0b5ee9a019c2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/date_picker/show_date_picker.1.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for basic [showDatePicker]. + +void main() => runApp(const DatePickerApp()); + +class DatePickerApp extends StatelessWidget { + const DatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('showDatePicker Example')), + body: const Center(child: DatePickerExample()), + ), + ); + } +} + +class DatePickerExample extends StatefulWidget { + const DatePickerExample({super.key}); + + @override + State<DatePickerExample> createState() => _DatePickerExampleState(); +} + +class _DatePickerExampleState extends State<DatePickerExample> { + DateTime? selectedDate; + + Future<void> _selectDate() async { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: DateTime(2021, 7, 25), + firstDate: DateTime(2021), + lastDate: DateTime(2022), + ); + + setState(() { + selectedDate = pickedDate; + }); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: .min, + spacing: 20, + children: <Widget>[ + Text( + selectedDate != null + ? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}' + : 'No date selected', + ), + OutlinedButton( + onPressed: _selectDate, + child: const Text('Select Date'), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/date_picker/show_date_range_picker.0.dart b/packages/material_ui/material_ui_examples/lib/date_picker/show_date_range_picker.0.dart new file mode 100644 index 000000000000..b1078433db00 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/date_picker/show_date_range_picker.0.dart @@ -0,0 +1,127 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showDateRangePicker]. + +void main() => runApp(const DatePickerApp()); + +class DatePickerApp extends StatelessWidget { + const DatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + restorationScopeId: 'app', + home: DatePickerExample(restorationId: 'main'), + ); + } +} + +class DatePickerExample extends StatefulWidget { + const DatePickerExample({super.key, this.restorationId}); + + final String? restorationId; + + @override + State<DatePickerExample> createState() => _DatePickerExampleState(); +} + +/// RestorationProperty objects can be used because of RestorationMixin. +class _DatePickerExampleState extends State<DatePickerExample> + with RestorationMixin { + // In this example, the restoration ID for the mixin is passed in through + // the [StatefulWidget]'s constructor. + @override + String? get restorationId => widget.restorationId; + + final RestorableDateTimeN _startDate = RestorableDateTimeN(DateTime(2021)); + final RestorableDateTimeN _endDate = RestorableDateTimeN( + DateTime(2021, 1, 5), + ); + late final RestorableRouteFuture<DateTimeRange?> + _restorableDateRangePickerRouteFuture = RestorableRouteFuture<DateTimeRange?>( + onComplete: _selectDateRange, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _dateRangePickerRoute, + arguments: <String, dynamic>{ + 'initialStartDate': _startDate.value?.millisecondsSinceEpoch, + 'initialEndDate': _endDate.value?.millisecondsSinceEpoch, + }, + ); + }, + ); + + void _selectDateRange(DateTimeRange? newSelectedDate) { + if (newSelectedDate != null) { + setState(() { + _startDate.value = newSelectedDate.start; + _endDate.value = newSelectedDate.end; + }); + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_startDate, 'start_date'); + registerForRestoration(_endDate, 'end_date'); + registerForRestoration( + _restorableDateRangePickerRouteFuture, + 'date_picker_route_future', + ); + } + + @pragma('vm:entry-point') + static Route<DateTimeRange?> _dateRangePickerRoute( + BuildContext context, + Object? arguments, + ) { + return DialogRoute<DateTimeRange?>( + context: context, + builder: (BuildContext context) { + return DateRangePickerDialog( + restorationId: 'date_picker_dialog', + initialDateRange: _initialDateTimeRange( + arguments! as Map<dynamic, dynamic>, + ), + firstDate: DateTime(2021), + currentDate: DateTime(2021, 1, 25), + lastDate: DateTime(2022), + ); + }, + ); + } + + static DateTimeRange? _initialDateTimeRange(Map<dynamic, dynamic> arguments) { + if (arguments['initialStartDate'] != null && + arguments['initialEndDate'] != null) { + return DateTimeRange( + start: DateTime.fromMillisecondsSinceEpoch( + arguments['initialStartDate'] as int, + ), + end: DateTime.fromMillisecondsSinceEpoch( + arguments['initialEndDate'] as int, + ), + ); + } + + return null; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: OutlinedButton( + onPressed: () { + _restorableDateRangePickerRouteFuture.present(); + }, + child: const Text('Open Date Range Picker'), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/adaptive_alert_dialog.0.dart b/packages/material_ui/material_ui_examples/lib/dialog/adaptive_alert_dialog.0.dart new file mode 100644 index 000000000000..55c813a699b3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/adaptive_alert_dialog.0.dart @@ -0,0 +1,74 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [AlertDialog]. + +void main() => runApp(const AdaptiveAlertDialogApp()); + +class AdaptiveAlertDialogApp extends StatelessWidget { + const AdaptiveAlertDialogApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + // Try this: set the platform to .android and see the difference + theme: ThemeData(platform: .iOS), + home: Scaffold( + appBar: AppBar(title: const Text('AlertDialog Sample')), + body: const Center(child: AdaptiveDialogExample()), + ), + ); + } +} + +class AdaptiveDialogExample extends StatelessWidget { + const AdaptiveDialogExample({super.key}); + + Widget adaptiveAction({ + required BuildContext context, + required VoidCallback onPressed, + required Widget child, + }) { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case .android: + case .fuchsia: + case .linux: + case .windows: + return TextButton(onPressed: onPressed, child: child); + case .iOS: + case .macOS: + return CupertinoDialogAction(onPressed: onPressed, child: child); + } + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () => showAdaptiveDialog<String>( + context: context, + builder: (BuildContext context) => AlertDialog.adaptive( + title: const Text('AlertDialog Title'), + content: const Text('AlertDialog description'), + actions: <Widget>[ + adaptiveAction( + context: context, + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Cancel'), + ), + adaptiveAction( + context: context, + onPressed: () => Navigator.pop(context, 'OK'), + child: const Text('OK'), + ), + ], + ), + ), + child: const Text('Show Dialog'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/alert_dialog.0.dart b/packages/material_ui/material_ui_examples/lib/dialog/alert_dialog.0.dart new file mode 100644 index 000000000000..61628ff1001a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/alert_dialog.0.dart @@ -0,0 +1,51 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AlertDialog]. + +void main() => runApp(const AlertDialogExampleApp()); + +class AlertDialogExampleApp extends StatelessWidget { + const AlertDialogExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('AlertDialog Sample')), + body: const Center(child: DialogExample()), + ), + ); + } +} + +class DialogExample extends StatelessWidget { + const DialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () => showDialog<String>( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('AlertDialog Title'), + content: const Text('AlertDialog description'), + actions: <Widget>[ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'OK'), + child: const Text('OK'), + ), + ], + ), + ), + child: const Text('Show Dialog'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/alert_dialog.1.dart b/packages/material_ui/material_ui_examples/lib/dialog/alert_dialog.1.dart new file mode 100644 index 000000000000..86fa960e8f0b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/alert_dialog.1.dart @@ -0,0 +1,52 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [AlertDialog]. + +void main() => runApp(const AlertDialogExampleApp()); + +class AlertDialogExampleApp extends StatelessWidget { + const AlertDialogExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + appBar: AppBar(title: const Text('AlertDialog Sample')), + body: const Center(child: DialogExample()), + ), + ); + } +} + +class DialogExample extends StatelessWidget { + const DialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () => showDialog<String>( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('AlertDialog Title'), + content: const Text('AlertDialog description'), + actions: <Widget>[ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'OK'), + child: const Text('OK'), + ), + ], + ), + ), + child: const Text('Show Dialog'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/dialog.0.dart b/packages/material_ui/material_ui_examples/lib/dialog/dialog.0.dart new file mode 100644 index 000000000000..d3156de93dcb --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/dialog.0.dart @@ -0,0 +1,84 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Dialog]. + +void main() => runApp(const DialogExampleApp()); + +class DialogExampleApp extends StatelessWidget { + const DialogExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Dialog Sample')), + body: const Center(child: DialogExample()), + ), + ); + } +} + +class DialogExample extends StatelessWidget { + const DialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .center, + children: <Widget>[ + TextButton( + onPressed: () => showDialog<String>( + context: context, + builder: (BuildContext context) => Dialog( + child: Padding( + padding: const .all(8.0), + child: Column( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + const Text('This is a typical dialog.'), + const SizedBox(height: 15), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ], + ), + ), + ), + ), + child: const Text('Show Dialog'), + ), + const SizedBox(height: 10), + TextButton( + onPressed: () => showDialog<String>( + context: context, + builder: (BuildContext context) => Dialog.fullscreen( + child: Column( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + const Text('This is a fullscreen dialog.'), + const SizedBox(height: 15), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ], + ), + ), + ), + child: const Text('Show Fullscreen Dialog'), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.0.dart b/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.0.dart new file mode 100644 index 000000000000..39eb24a7f6cd --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.0.dart @@ -0,0 +1,72 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showDialog]. + +void main() => runApp(const ShowDialogExampleApp()); + +class ShowDialogExampleApp extends StatelessWidget { + const ShowDialogExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: DialogExample()); + } +} + +class DialogExample extends StatelessWidget { + const DialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('showDialog Sample')), + body: Center( + child: OutlinedButton( + onPressed: () => _dialogBuilder(context), + child: const Text('Open Dialog'), + ), + ), + ); + } + + Future<void> _dialogBuilder(BuildContext context) { + return showDialog<void>( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Basic dialog title'), + content: const Text( + 'A dialog is a type of modal window that\n' + 'appears in front of app content to\n' + 'provide critical information, or prompt\n' + 'for a decision to be made.', + ), + actions: <Widget>[ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Disable'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Enable'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.1.dart b/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.1.dart new file mode 100644 index 000000000000..8ab30d32b24d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.1.dart @@ -0,0 +1,75 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showDialog]. + +void main() => runApp(const ShowDialogExampleApp()); + +class ShowDialogExampleApp extends StatelessWidget { + const ShowDialogExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const DialogExample(), + ); + } +} + +class DialogExample extends StatelessWidget { + const DialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('showDialog Sample')), + body: Center( + child: OutlinedButton( + onPressed: () => _dialogBuilder(context), + child: const Text('Open Dialog'), + ), + ), + ); + } + + Future<void> _dialogBuilder(BuildContext context) { + return showDialog<void>( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Basic dialog title'), + content: const Text( + 'A dialog is a type of modal window that\n' + 'appears in front of app content to\n' + 'provide critical information, or prompt\n' + 'for a decision to be made.', + ), + actions: <Widget>[ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Disable'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Enable'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.2.dart b/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.2.dart new file mode 100644 index 000000000000..4ce9b9e301c3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dialog/show_dialog.2.dart @@ -0,0 +1,78 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showDialog]. + +void main() => runApp(const ShowDialogExampleApp()); + +class ShowDialogExampleApp extends StatelessWidget { + const ShowDialogExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(restorationScopeId: 'app', home: DialogExample()); + } +} + +class DialogExample extends StatelessWidget { + const DialogExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('AlertDialog Sample')), + body: Center( + child: OutlinedButton( + onPressed: () { + Navigator.of(context).restorablePush(_dialogBuilder); + }, + child: const Text('Open Dialog'), + ), + ), + ); + } + + @pragma('vm:entry-point') + static Route<Object?> _dialogBuilder( + BuildContext context, + Object? arguments, + ) { + return DialogRoute<void>( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Basic dialog title'), + content: const Text( + 'A dialog is a type of modal window that\n' + 'appears in front of app content to\n' + 'provide critical information, or prompt\n' + 'for a decision to be made.', + ), + actions: <Widget>[ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Disable'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Enable'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/divider/divider.0.dart b/packages/material_ui/material_ui_examples/lib/divider/divider.0.dart new file mode 100644 index 000000000000..f9d1fcaba235 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/divider/divider.0.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Divider]. + +void main() => runApp(const DividerExampleApp()); + +class DividerExampleApp extends StatelessWidget { + const DividerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Divider Sample')), + body: const DividerExample(), + ), + ); + } +} + +class DividerExample extends StatelessWidget { + const DividerExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: <Widget>[ + const Expanded( + child: ColoredBox( + color: Colors.amber, + child: Center(child: Text('Above')), + ), + ), + const Divider( + height: 20, + thickness: 5, + indent: 20, + endIndent: 0, + color: Colors.black, + ), + // Subheader example from Material spec. + // https://material.io/components/dividers#types + Container( + padding: const .only(left: 20), + child: Align( + alignment: .centerStart, + child: Text( + 'Subheader', + style: Theme.of(context).textTheme.bodySmall, + textAlign: .start, + ), + ), + ), + Expanded( + child: ColoredBox( + color: Theme.of(context).colorScheme.primary, + child: const Center(child: Text('Below')), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/divider/divider.1.dart b/packages/material_ui/material_ui_examples/lib/divider/divider.1.dart new file mode 100644 index 000000000000..9222927af445 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/divider/divider.1.dart @@ -0,0 +1,44 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Divider]. + +void main() => runApp(const DividerExampleApp()); + +class DividerExampleApp extends StatelessWidget { + const DividerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + appBar: AppBar(title: const Text('Divider Sample')), + body: const DividerExample(), + ), + ); + } +} + +class DividerExample extends StatelessWidget { + const DividerExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: .all(16.0), + child: Column( + children: <Widget>[ + Expanded(child: Card(child: SizedBox.expand())), + Divider(), + Expanded(child: Card(child: SizedBox.expand())), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/divider/vertical_divider.0.dart b/packages/material_ui/material_ui_examples/lib/divider/vertical_divider.0.dart new file mode 100644 index 000000000000..a0b6cb3f8a2f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/divider/vertical_divider.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. + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [VerticalDivider]. + +void main() => runApp(const VerticalDividerExampleApp()); + +class VerticalDividerExampleApp extends StatelessWidget { + const VerticalDividerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('VerticalDivider Sample')), + body: const DividerExample(), + ), + ); + } +} + +class DividerExample extends StatelessWidget { + const DividerExample({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const .all(10), + child: Row( + children: <Widget>[ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: .circular(10), + color: Colors.deepPurpleAccent, + ), + ), + ), + const VerticalDivider( + width: 20, + thickness: 1, + indent: 20, + endIndent: 0, + color: Colors.grey, + ), + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: .circular(10), + color: Colors.deepOrangeAccent, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/divider/vertical_divider.1.dart b/packages/material_ui/material_ui_examples/lib/divider/vertical_divider.1.dart new file mode 100644 index 000000000000..5b1fee44433a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/divider/vertical_divider.1.dart @@ -0,0 +1,44 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Divider]. + +void main() => runApp(const VerticalDividerExampleApp()); + +class VerticalDividerExampleApp extends StatelessWidget { + const VerticalDividerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + appBar: AppBar(title: const Text('Divider Sample')), + body: const DividerExample(), + ), + ); + } +} + +class DividerExample extends StatelessWidget { + const DividerExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: .all(16.0), + child: Row( + children: <Widget>[ + Expanded(child: Card(child: SizedBox.expand())), + VerticalDivider(), + Expanded(child: Card(child: SizedBox.expand())), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/drawer/drawer.0.dart b/packages/material_ui/material_ui_examples/lib/drawer/drawer.0.dart new file mode 100644 index 000000000000..a332a9f282b5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/drawer/drawer.0.dart @@ -0,0 +1,78 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Drawer]. + +void main() => runApp(const DrawerApp()); + +class DrawerApp extends StatelessWidget { + const DrawerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: DrawerExample()); + } +} + +class DrawerExample extends StatefulWidget { + const DrawerExample({super.key}); + + @override + State<DrawerExample> createState() => _DrawerExampleState(); +} + +class _DrawerExampleState extends State<DrawerExample> { + String selectedPage = ''; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Drawer Example')), + drawer: Drawer( + child: ListView( + padding: .zero, + children: <Widget>[ + const DrawerHeader( + decoration: BoxDecoration(color: Colors.blue), + child: Text( + 'Drawer Header', + style: TextStyle(color: Colors.white, fontSize: 24), + ), + ), + ListTile( + leading: const Icon(Icons.message), + title: const Text('Messages'), + onTap: () { + setState(() { + selectedPage = 'Messages'; + }); + }, + ), + ListTile( + leading: const Icon(Icons.account_circle), + title: const Text('Profile'), + onTap: () { + setState(() { + selectedPage = 'Profile'; + }); + }, + ), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Settings'), + onTap: () { + setState(() { + selectedPage = 'Settings'; + }); + }, + ), + ], + ), + ), + body: Center(child: Text('Page: $selectedPage')), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.0.dart b/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.0.dart new file mode 100644 index 000000000000..e502c5e57007 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.0.dart @@ -0,0 +1,56 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DropdownButton]. + +const List<String> list = <String>['One', 'Two', 'Three', 'Four']; + +void main() => runApp(const DropdownButtonApp()); + +class DropdownButtonApp extends StatelessWidget { + const DropdownButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DropdownButton Sample')), + body: const Center(child: DropdownButtonExample()), + ), + ); + } +} + +class DropdownButtonExample extends StatefulWidget { + const DropdownButtonExample({super.key}); + + @override + State<DropdownButtonExample> createState() => _DropdownButtonExampleState(); +} + +class _DropdownButtonExampleState extends State<DropdownButtonExample> { + String dropdownValue = list.first; + + @override + Widget build(BuildContext context) { + return DropdownButton<String>( + value: dropdownValue, + icon: const Icon(Icons.arrow_downward), + elevation: 16, + style: const TextStyle(color: Colors.deepPurple), + underline: Container(height: 2, color: Colors.deepPurpleAccent), + onChanged: (String? value) { + // This is called when the user selects an item. + setState(() { + dropdownValue = value!; + }); + }, + items: list.map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.selected_item_builder.0.dart b/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.selected_item_builder.0.dart new file mode 100644 index 000000000000..9f12bf9dbe9f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.selected_item_builder.0.dart @@ -0,0 +1,85 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DropdownButton.selectedItemBuilder]. + +Map<String, String> cities = <String, String>{ + 'New York': 'NYC', + 'Los Angeles': 'LA', + 'San Francisco': 'SF', + 'Chicago': 'CH', + 'Miami': 'MI', +}; + +void main() => runApp(const DropdownButtonApp()); + +class DropdownButtonApp extends StatelessWidget { + const DropdownButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DropdownButton Sample')), + body: const Center(child: DropdownButtonExample()), + ), + ); + } +} + +class DropdownButtonExample extends StatefulWidget { + const DropdownButtonExample({super.key}); + + @override + State<DropdownButtonExample> createState() => _DropdownButtonExampleState(); +} + +class _DropdownButtonExampleState extends State<DropdownButtonExample> { + String selectedItem = cities.keys.first; + + @override + Widget build(BuildContext context) { + return Center( + child: Row( + mainAxisAlignment: .center, + children: <Widget>[ + Text('Select a city:', style: Theme.of(context).textTheme.bodyLarge), + Padding( + padding: const .symmetric(horizontal: 8.0), + child: DropdownButton<String>( + value: selectedItem, + onChanged: (String? value) { + // This is called when the user selects an item. + setState(() => selectedItem = value!); + }, + selectedItemBuilder: (BuildContext context) { + return cities.values.map<Widget>((String item) { + // This is the widget that will be shown when you select an item. + // Here custom text style, alignment and layout size can be applied + // to selected item string. + return Container( + alignment: .centerLeft, + constraints: const BoxConstraints(minWidth: 100), + child: Text( + item, + style: const TextStyle( + color: Colors.blue, + fontWeight: .w600, + ), + ), + ); + }).toList(); + }, + items: cities.keys.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>(value: item, child: Text(item)); + }).toList(), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.style.0.dart b/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.style.0.dart new file mode 100644 index 000000000000..3cee9113d63b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown/dropdown_button.style.0.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [DropdownButton.style]. + +void main() => runApp(const DropdownButtonApp()); + +class DropdownButtonApp extends StatelessWidget { + const DropdownButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DropdownButton Sample')), + body: const DropdownButtonExample(), + ), + ); + } +} + +class DropdownButtonExample extends StatefulWidget { + const DropdownButtonExample({super.key}); + + @override + State<DropdownButtonExample> createState() => _DropdownButtonExampleState(); +} + +class _DropdownButtonExampleState extends State<DropdownButtonExample> { + List<String> options = <String>['One', 'Two', 'Three', 'Four']; + String dropdownValue = 'One'; + + @override + Widget build(BuildContext context) { + return Container( + alignment: .center, + color: Colors.blue, + child: DropdownButton<String>( + value: dropdownValue, + onChanged: (String? value) { + // This is called when the user selects an item. + setState(() { + dropdownValue = value!; + }); + }, + style: const TextStyle(color: Colors.blue), + selectedItemBuilder: (BuildContext context) { + // This is the widget that will be shown when you select an item. + // Here custom text style, alignment and layout size can be applied + // to selected item string. + return options.map((String value) { + return Align( + alignment: .centerLeft, + child: Text( + dropdownValue, + style: const TextStyle(color: Colors.white), + ), + ); + }).toList(); + }, + items: options.map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.0.dart b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.0.dart new file mode 100644 index 000000000000..13ebec3a4405 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.0.dart @@ -0,0 +1,163 @@ +// 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:collection/collection.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [DropdownMenu]s. The first dropdown menu +/// has the default outlined border and demos using the +/// [DropdownMenuEntry] style parameter to customize its appearance. +/// The second dropdown menu customizes the appearance of the dropdown +/// menu's text field with its [DropdownMenu.inputDecorationTheme] parameter. + +void main() { + runApp(const DropdownMenuExample()); +} + +typedef ColorEntry = DropdownMenuEntry<ColorLabel>; + +// DropdownMenuEntry labels and values for the first dropdown menu. +enum ColorLabel { + blue('Blue', Colors.blue), + pink('Pink', Colors.pink), + green('Green', Colors.green), + yellow('Orange', Colors.orange), + grey('Grey', Colors.grey); + + const ColorLabel(this.label, this.color); + final String label; + final Color color; + + static final List<ColorEntry> entries = UnmodifiableListView<ColorEntry>( + values.map<ColorEntry>( + (ColorLabel color) => ColorEntry( + value: color, + label: color.label, + enabled: color.label != 'Grey', + style: MenuItemButton.styleFrom(foregroundColor: color.color), + ), + ), + ); +} + +typedef IconEntry = DropdownMenuEntry<IconLabel>; + +// DropdownMenuEntry labels and values for the second dropdown menu. +enum IconLabel { + smile('Smile', Icons.sentiment_satisfied_outlined), + cloud('Cloud', Icons.cloud_outlined), + brush('Brush', Icons.brush_outlined), + heart('Heart', Icons.favorite); + + const IconLabel(this.label, this.icon); + final String label; + final IconData icon; + + static final List<IconEntry> entries = UnmodifiableListView<IconEntry>( + values.map<IconEntry>( + (IconLabel icon) => IconEntry( + value: icon, + label: icon.label, + leadingIcon: Icon(icon.icon), + ), + ), + ); +} + +class DropdownMenuExample extends StatefulWidget { + const DropdownMenuExample({super.key}); + + @override + State<DropdownMenuExample> createState() => _DropdownMenuExampleState(); +} + +class _DropdownMenuExampleState extends State<DropdownMenuExample> { + final TextEditingController colorController = TextEditingController(); + final TextEditingController iconController = TextEditingController(); + ColorLabel? selectedColor; + IconLabel? selectedIcon; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: Colors.green), + home: Scaffold( + body: SafeArea( + child: Column( + children: <Widget>[ + Padding( + padding: const .symmetric(vertical: 20), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + DropdownMenu<ColorLabel>( + initialSelection: ColorLabel.green, + controller: colorController, + // The default requestFocusOnTap value depends on the platform. + // On mobile, it defaults to false, and on desktop, it defaults to true. + // Setting this to true will trigger a focus request on the text field, and + // the virtual keyboard will appear afterward. + requestFocusOnTap: true, + label: const Text('Color'), + onSelected: (ColorLabel? color) { + setState(() { + selectedColor = color; + }); + }, + dropdownMenuEntries: ColorLabel.entries, + ), + const SizedBox(width: 24), + DropdownMenu<IconLabel>( + controller: iconController, + enableFilter: true, + requestFocusOnTap: true, + leadingIcon: const Icon(Icons.search), + label: const Text('Icon'), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + contentPadding: EdgeInsets.symmetric(vertical: 5.0), + ), + onSelected: (IconLabel? icon) { + setState(() { + selectedIcon = icon; + }); + }, + dropdownMenuEntries: IconLabel.entries, + ), + ], + ), + ), + ), + if (selectedColor != null && selectedIcon != null) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'You selected a ${selectedColor?.label} ${selectedIcon?.label}', + ), + Padding( + padding: const .symmetric(horizontal: 5), + child: Icon( + selectedIcon?.icon, + color: selectedColor?.color, + ), + ), + ], + ), + ) + else + const Text('Please select a color and an icon.'), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.1.dart b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.1.dart new file mode 100644 index 000000000000..ce152561b8f5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.1.dart @@ -0,0 +1,56 @@ +// 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:collection/collection.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [DropdownMenu]. + +const List<String> list = <String>['One', 'Two', 'Three', 'Four']; + +void main() => runApp(const DropdownMenuApp()); + +class DropdownMenuApp extends StatelessWidget { + const DropdownMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DropdownMenu Sample')), + body: const Center(child: DropdownMenuExample()), + ), + ); + } +} + +class DropdownMenuExample extends StatefulWidget { + const DropdownMenuExample({super.key}); + + @override + State<DropdownMenuExample> createState() => _DropdownMenuExampleState(); +} + +typedef MenuEntry = DropdownMenuEntry<String>; + +class _DropdownMenuExampleState extends State<DropdownMenuExample> { + static final List<MenuEntry> menuEntries = UnmodifiableListView<MenuEntry>( + list.map<MenuEntry>((String name) => MenuEntry(value: name, label: name)), + ); + String dropdownValue = list.first; + + @override + Widget build(BuildContext context) { + return DropdownMenu<String>( + initialSelection: list.first, + onSelected: (String? value) { + // This is called when the user selects an item. + setState(() { + dropdownValue = value!; + }); + }, + dropdownMenuEntries: menuEntries, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.2.dart b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.2.dart new file mode 100644 index 000000000000..73c979744af6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu.2.dart @@ -0,0 +1,169 @@ +// 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:collection/collection.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [DropdownMenu]. + +const List<String> list = <String>['One', 'Two', 'Three', 'Four']; + +void main() => runApp(const DropdownMenuApp()); + +class DropdownMenuApp extends StatelessWidget { + const DropdownMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('DropdownMenu Sample')), + body: const Center(child: DropdownMenuExample()), + ), + ); + } +} + +class DropdownMenuExample extends StatefulWidget { + const DropdownMenuExample({super.key}); + + @override + State<DropdownMenuExample> createState() => _DropdownMenuExampleState(); +} + +typedef MenuEntry = DropdownMenuEntry<String>; + +class _DropdownMenuExampleState extends State<DropdownMenuExample> { + static final List<MenuEntry> menuEntries = UnmodifiableListView<MenuEntry>( + list.map<MenuEntry>((String name) => MenuEntry(value: name, label: name)), + ); + String dropdownValue = list.first; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return ListView( + children: <Widget>[ + ListTile( + tileColor: colorScheme.primaryContainer, + title: const Column( + crossAxisAlignment: .start, + children: <Widget>[ + Text('enabled: true'), + Text('requestFocusOnTap: true'), + ], + ), + subtitle: Column( + children: <Widget>[ + DropdownMenu<String>( + requestFocusOnTap: true, + initialSelection: list.first, + expandedInsets: EdgeInsets.zero, + onSelected: (String? value) { + setState(() { + dropdownValue = value!; + }); + }, + dropdownMenuEntries: menuEntries, + ), + const Text( + 'Text cursor is shown when hovering over the DropdownMenu.', + ), + ], + ), + ), + const SizedBox(height: 20), + ListTile( + tileColor: colorScheme.primaryContainer, + title: const Column( + crossAxisAlignment: .start, + children: <Widget>[ + Text('enabled: true'), + Text('requestFocusOnTap: false'), + ], + ), + subtitle: Column( + children: <Widget>[ + DropdownMenu<String>( + requestFocusOnTap: false, + initialSelection: list.first, + expandedInsets: EdgeInsets.zero, + onSelected: (String? value) { + setState(() { + dropdownValue = value!; + }); + }, + dropdownMenuEntries: menuEntries, + ), + const Text( + 'Clickable cursor is shown when hovering over the DropdownMenu.', + ), + ], + ), + ), + const SizedBox(height: 20), + ListTile( + tileColor: colorScheme.onInverseSurface, + title: const Column( + crossAxisAlignment: .start, + children: <Widget>[ + Text('enabled: false'), + Text('requestFocusOnTap: true'), + ], + ), + subtitle: Column( + children: <Widget>[ + DropdownMenu<String>( + enabled: false, + requestFocusOnTap: true, + initialSelection: list.first, + expandedInsets: EdgeInsets.zero, + onSelected: (String? value) { + setState(() { + dropdownValue = value!; + }); + }, + dropdownMenuEntries: menuEntries, + ), + const Text( + 'Default cursor is shown when hovering over the DropdownMenu.', + ), + ], + ), + ), + const SizedBox(height: 20), + ListTile( + tileColor: colorScheme.onInverseSurface, + title: const Column( + crossAxisAlignment: .start, + children: <Widget>[ + Text('enabled: false'), + Text('requestFocusOnTap: false'), + ], + ), + subtitle: Column( + children: <Widget>[ + DropdownMenu<String>( + enabled: false, + requestFocusOnTap: false, + initialSelection: list.first, + expandedInsets: EdgeInsets.zero, + onSelected: (String? value) { + setState(() { + dropdownValue = value!; + }); + }, + dropdownMenuEntries: menuEntries, + ), + const Text( + 'Default cursor is shown when hovering over the DropdownMenu.', + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu_entry_label_widget.0.dart b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu_entry_label_widget.0.dart new file mode 100644 index 000000000000..3ec42315d3ac --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/dropdown_menu/dropdown_menu_entry_label_widget.0.dart @@ -0,0 +1,94 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for the [DropdownMenuEntry] `labelWidget` property. + +enum ColorItem { + blue('Blue', Colors.blue), + pink('Pink', Colors.pink), + green('Green', Colors.green), + yellow('Yellow', Colors.yellow), + grey('Grey', Colors.grey); + + const ColorItem(this.label, this.color); + final String label; + final Color color; +} + +class DropdownMenuEntryLabelWidgetExample extends StatefulWidget { + const DropdownMenuEntryLabelWidgetExample({super.key}); + + @override + State<DropdownMenuEntryLabelWidgetExample> createState() => + _DropdownMenuEntryLabelWidgetExampleState(); +} + +class _DropdownMenuEntryLabelWidgetExampleState + extends State<DropdownMenuEntryLabelWidgetExample> { + late final TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Created by Google Bard from 'create a lyrical phrase of about 25 words that begins with "is a color"'. + const String longText = + 'is a color that sings of hope, A hue that shines like gold. It is the color of dreams, A shade that never grows old.'; + + return Scaffold( + body: Center( + child: DropdownMenu<ColorItem>( + width: 300, + controller: controller, + initialSelection: ColorItem.green, + label: const Text('Color'), + onSelected: (ColorItem? color) { + print('Selected $color'); + }, + dropdownMenuEntries: ColorItem.values + .map<DropdownMenuEntry<ColorItem>>((ColorItem item) { + final String labelText = '${item.label} $longText\n'; + return DropdownMenuEntry<ColorItem>( + value: item, + label: labelText, + // Try commenting the labelWidget out or changing + // the labelWidget's Text parameters. + labelWidget: Text( + labelText, + maxLines: 1, + overflow: .ellipsis, + ), + ); + }) + .toList(), + ), + ), + ); + } +} + +class DropdownMenuEntryLabelWidgetExampleApp extends StatelessWidget { + const DropdownMenuEntryLabelWidgetExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: DropdownMenuEntryLabelWidgetExample()); + } +} + +void main() { + runApp(const DropdownMenuEntryLabelWidgetExampleApp()); +} diff --git a/packages/material_ui/material_ui_examples/lib/elevated_button/elevated_button.0.dart b/packages/material_ui/material_ui_examples/lib/elevated_button/elevated_button.0.dart new file mode 100644 index 000000000000..342096dc8e9e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/elevated_button/elevated_button.0.dart @@ -0,0 +1,58 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ElevatedButton]. + +void main() => runApp(const ElevatedButtonExampleApp()); + +class ElevatedButtonExampleApp extends StatelessWidget { + const ElevatedButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ElevatedButton Sample')), + body: const ElevatedButtonExample(), + ), + ); + } +} + +class ElevatedButtonExample extends StatefulWidget { + const ElevatedButtonExample({super.key}); + + @override + State<ElevatedButtonExample> createState() => _ElevatedButtonExampleState(); +} + +class _ElevatedButtonExampleState extends State<ElevatedButtonExample> { + @override + Widget build(BuildContext context) { + final ButtonStyle style = ElevatedButton.styleFrom( + textStyle: const TextStyle(fontSize: 20), + ); + + return Center( + child: Column( + mainAxisSize: .min, + children: <Widget>[ + ElevatedButton( + style: style, + onPressed: null, + child: const Text('Disabled'), + ), + const SizedBox(height: 30), + ElevatedButton( + style: style, + onPressed: () {}, + child: const Text('Enabled'), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/expansible/expansible.0.dart b/packages/material_ui/material_ui_examples/lib/expansible/expansible.0.dart new file mode 100644 index 000000000000..1e822b830757 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/expansible/expansible.0.dart @@ -0,0 +1,75 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Expansible]. + +void main() => runApp(const ExpansibleApp()); + +/// An application that shows an example of how to use [Expansible]. +class ExpansibleApp extends StatelessWidget { + const ExpansibleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Expansible Widget Example')), + body: const Center(child: ExpansibleWidgetExample()), + ), + ); + } +} + +class ExpansibleWidgetExample extends StatefulWidget { + const ExpansibleWidgetExample({super.key}); + + @override + State<ExpansibleWidgetExample> createState() => + _ExpansibleWidgetExampleState(); +} + +class _ExpansibleWidgetExampleState extends State<ExpansibleWidgetExample> { + final _controller = ExpansibleController(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .all(16.0), + child: Expansible( + controller: _controller, + headerBuilder: (context, animation) => ListTile( + title: const Text('Tap to Expand'), + onTap: () { + if (_controller.isExpanded) { + _controller.collapse(); + } else { + _controller.expand(); + } + }, + trailing: RotationTransition( + turns: Tween<double>(begin: 0.0, end: 0.5).animate(animation), + child: const Icon(Icons.arrow_drop_down), + ), + ), + bodyBuilder: (context, animation) => SizeTransition( + sizeFactor: animation, + child: const Text('Hidden content revealed!'), + ), + expansibleBuilder: (context, header, body, animation) => Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [header, body], + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/expansion_panel/expansion_panel_list.0.dart b/packages/material_ui/material_ui_examples/lib/expansion_panel/expansion_panel_list.0.dart new file mode 100644 index 000000000000..0fec1ef6e63b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/expansion_panel/expansion_panel_list.0.dart @@ -0,0 +1,92 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ExpansionPanelList]. + +void main() => runApp(const ExpansionPanelListExampleApp()); + +class ExpansionPanelListExampleApp extends StatelessWidget { + const ExpansionPanelListExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ExpansionPanelList Sample')), + body: const ExpansionPanelListExample(), + ), + ); + } +} + +// stores ExpansionPanel state information +class Item { + Item({ + required this.expandedValue, + required this.headerValue, + this.isExpanded = false, + }); + + String expandedValue; + String headerValue; + bool isExpanded; +} + +List<Item> generateItems(int numberOfItems) { + return List<Item>.generate(numberOfItems, (int index) { + return Item( + headerValue: 'Panel $index', + expandedValue: 'This is item number $index', + ); + }); +} + +class ExpansionPanelListExample extends StatefulWidget { + const ExpansionPanelListExample({super.key}); + + @override + State<ExpansionPanelListExample> createState() => + _ExpansionPanelListExampleState(); +} + +class _ExpansionPanelListExampleState extends State<ExpansionPanelListExample> { + final List<Item> _data = generateItems(8); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView(child: Container(child: _buildPanel())); + } + + Widget _buildPanel() { + return ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + setState(() { + _data[index].isExpanded = isExpanded; + }); + }, + children: _data.map<ExpansionPanel>((Item item) { + return ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile(title: Text(item.headerValue)); + }, + body: ListTile( + title: Text(item.expandedValue), + subtitle: const Text( + 'To delete this panel, tap the trash can icon', + ), + trailing: const Icon(Icons.delete), + onTap: () { + setState(() { + _data.removeWhere((Item currentItem) => item == currentItem); + }); + }, + ), + isExpanded: item.isExpanded, + ); + }).toList(), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0.dart b/packages/material_ui/material_ui_examples/lib/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0.dart new file mode 100644 index 000000000000..dfe0bb37fd88 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0.dart @@ -0,0 +1,90 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ExpansionPanelList.radio]. + +void main() => runApp(const ExpansionPanelListRadioExampleApp()); + +class ExpansionPanelListRadioExampleApp extends StatelessWidget { + const ExpansionPanelListRadioExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ExpansionPanelList.radio Sample')), + body: const ExpansionPanelListRadioExample(), + ), + ); + } +} + +// stores ExpansionPanel state information +class Item { + Item({ + required this.id, + required this.expandedValue, + required this.headerValue, + }); + + int id; + String expandedValue; + String headerValue; +} + +List<Item> generateItems(int numberOfItems) { + return List<Item>.generate(numberOfItems, (int index) { + return Item( + id: index, + headerValue: 'Panel $index', + expandedValue: 'This is item number $index', + ); + }); +} + +class ExpansionPanelListRadioExample extends StatefulWidget { + const ExpansionPanelListRadioExample({super.key}); + + @override + State<ExpansionPanelListRadioExample> createState() => + _ExpansionPanelListRadioExampleState(); +} + +class _ExpansionPanelListRadioExampleState + extends State<ExpansionPanelListRadioExample> { + final List<Item> _data = generateItems(8); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView(child: Container(child: _buildPanel())); + } + + Widget _buildPanel() { + return ExpansionPanelList.radio( + initialOpenPanelValue: 2, + children: _data.map<ExpansionPanelRadio>((Item item) { + return ExpansionPanelRadio( + value: item.id, + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile(title: Text(item.headerValue)); + }, + body: ListTile( + title: Text(item.expandedValue), + subtitle: const Text( + 'To delete this panel, tap the trash can icon', + ), + trailing: const Icon(Icons.delete), + onTap: () { + setState(() { + _data.removeWhere((Item currentItem) => item == currentItem); + }); + }, + ), + ); + }).toList(), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.0.dart b/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.0.dart new file mode 100644 index 000000000000..95ecb37c2238 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.0.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ExpansionTile]. + +void main() => runApp(const ExpansionTileApp()); + +class ExpansionTileApp extends StatelessWidget { + const ExpansionTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ExpansionTile Sample')), + body: const ExpansionTileExample(), + ), + ); + } +} + +class ExpansionTileExample extends StatefulWidget { + const ExpansionTileExample({super.key}); + + @override + State<ExpansionTileExample> createState() => _ExpansionTileExampleState(); +} + +class _ExpansionTileExampleState extends State<ExpansionTileExample> { + bool _customTileExpanded = false; + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + const ExpansionTile( + title: Text('ExpansionTile 1'), + subtitle: Text('Trailing expansion arrow icon'), + children: <Widget>[ListTile(title: Text('This is tile number 1'))], + ), + ExpansionTile( + title: const Text('ExpansionTile 2'), + subtitle: const Text('Custom expansion arrow icon'), + trailing: Icon( + _customTileExpanded + ? Icons.arrow_drop_down_circle + : Icons.arrow_drop_down, + ), + children: const <Widget>[ + ListTile(title: Text('This is tile number 2')), + ], + onExpansionChanged: (bool expanded) { + setState(() { + _customTileExpanded = expanded; + }); + }, + ), + const ExpansionTile( + title: Text('ExpansionTile 3'), + subtitle: Text('Leading expansion arrow icon'), + controlAffinity: .leading, + children: <Widget>[ListTile(title: Text('This is tile number 3'))], + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.1.dart b/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.1.dart new file mode 100644 index 000000000000..9019e99e9358 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.1.dart @@ -0,0 +1,84 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ExpansionTile] and [ExpansibleController]. + +void main() { + runApp(const ExpansionTileControllerApp()); +} + +class ExpansionTileControllerApp extends StatefulWidget { + const ExpansionTileControllerApp({super.key}); + + @override + State<ExpansionTileControllerApp> createState() => + _ExpansionTileControllerAppState(); +} + +class _ExpansionTileControllerAppState + extends State<ExpansionTileControllerApp> { + final ExpansibleController controller = ExpansibleController(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ExpansionTileController Sample')), + body: Column( + children: <Widget>[ + // A controller has been provided to the ExpansionTile because it's + // going to be accessed from a component that is not within the + // tile's BuildContext. + ExpansionTile( + controller: controller, + title: const Text('ExpansionTile with explicit controller.'), + children: <Widget>[ + Container( + alignment: .center, + padding: const .all(24), + child: const Text('ExpansionTile Contents'), + ), + ], + ), + const SizedBox(height: 8), + ElevatedButton( + child: const Text('Expand/Collapse the Tile Above'), + onPressed: () { + if (controller.isExpanded) { + controller.collapse(); + } else { + controller.expand(); + } + }, + ), + const SizedBox(height: 48), + // A controller has not been provided to the ExpansionTile because + // the automatically created one can be retrieved via the tile's BuildContext. + ExpansionTile( + title: const Text('ExpansionTile with implicit controller.'), + children: <Widget>[ + Builder( + builder: (BuildContext context) { + return Container( + padding: const .all(24), + alignment: .center, + child: ElevatedButton( + child: const Text('Collapse This Tile'), + onPressed: () { + return ExpansibleController.of(context).collapse(); + }, + ), + ); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.2.dart b/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.2.dart new file mode 100644 index 000000000000..472f3ad38e0c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/expansion_tile/expansion_tile.2.dart @@ -0,0 +1,89 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ExpansionTile] and [AnimationStyle]. + +void main() { + runApp(const ExpansionTileAnimationStyleApp()); +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), + ]; + +class ExpansionTileAnimationStyleApp extends StatefulWidget { + const ExpansionTileAnimationStyleApp({super.key}); + + @override + State<ExpansionTileAnimationStyleApp> createState() => + _ExpansionTileAnimationStyleAppState(); +} + +class _ExpansionTileAnimationStyleAppState + extends State<ExpansionTileAnimationStyleApp> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + AnimationStyles.defaultStyle, + }; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyleSelection = styles; + switch (styles.first) { + case AnimationStyles.defaultStyle: + _animationStyle = null; + case AnimationStyles.custom: + _animationStyle = const AnimationStyle( + curve: Easing.emphasizedAccelerate, + duration: Durations.extralong1, + ); + case AnimationStyles.none: + _animationStyle = AnimationStyle.noAnimation; + } + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 20), + ExpansionTile( + expansionAnimationStyle: _animationStyle, + title: const Text('ExpansionTile'), + children: const <Widget>[ + ListTile(title: Text('Expanded Item 1')), + ListTile(title: Text('Expanded Item 2')), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/filled_button/filled_button.0.dart b/packages/material_ui/material_ui_examples/lib/filled_button/filled_button.0.dart new file mode 100644 index 000000000000..a587e0fd3710 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/filled_button/filled_button.0.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [FilledButton]. + +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)), + home: Scaffold( + appBar: AppBar(title: const Text('FilledButton Sample')), + body: Center( + child: Row( + mainAxisSize: .min, + children: <Widget>[ + Column( + children: <Widget>[ + 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: <Widget>[ + 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/material_ui/material_ui_examples/lib/filter_chip/filter_chip.0.dart b/packages/material_ui/material_ui_examples/lib/filter_chip/filter_chip.0.dart new file mode 100644 index 000000000000..fd2fce82dfd4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/filter_chip/filter_chip.0.dart @@ -0,0 +1,74 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [FilterChip]. + +enum ExerciseFilter { walking, running, cycling, hiking } + +void main() => runApp(const ChipApp()); + +class ChipApp extends StatelessWidget { + const ChipApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('FilterChip Sample')), + body: const FilterChipExample(), + ), + ); + } +} + +class FilterChipExample extends StatefulWidget { + const FilterChipExample({super.key}); + + @override + State<FilterChipExample> createState() => _FilterChipExampleState(); +} + +class _FilterChipExampleState extends State<FilterChipExample> { + Set<ExerciseFilter> filters = <ExerciseFilter>{}; + + @override + Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).textTheme; + + return Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text('Choose an exercise', style: textTheme.labelLarge), + const SizedBox(height: 5.0), + Wrap( + spacing: 5.0, + children: ExerciseFilter.values.map((ExerciseFilter exercise) { + return FilterChip( + label: Text(exercise.name), + selected: filters.contains(exercise), + onSelected: (bool selected) { + setState(() { + if (selected) { + filters.add(exercise); + } else { + filters.remove(exercise); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 10.0), + Text( + 'Looking for: ${filters.map((ExerciseFilter e) => e.name).join(', ')}', + style: textTheme.labelLarge, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/flexible_space_bar/flexible_space_bar.0.dart b/packages/material_ui/material_ui_examples/lib/flexible_space_bar/flexible_space_bar.0.dart new file mode 100644 index 000000000000..97400d2b76fa --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/flexible_space_bar/flexible_space_bar.0.dart @@ -0,0 +1,82 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [FlexibleSpaceBar]. + +void main() => runApp(const FlexibleSpaceBarExampleApp()); + +class FlexibleSpaceBarExampleApp extends StatelessWidget { + const FlexibleSpaceBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + ), + home: Scaffold( + body: CustomScrollView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: <Widget>[ + SliverAppBar( + stretch: true, + onStretchTrigger: () { + // Function callback for stretch + return Future<void>.value(); + }, + expandedHeight: 300.0, + flexibleSpace: FlexibleSpaceBar( + stretchModes: const <StretchMode>[ + .zoomBackground, + .blurBackground, + .fadeTitle, + ], + centerTitle: true, + title: const Text('Flight Report'), + background: Stack( + fit: .expand, + children: <Widget>[ + Image.network( + 'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg', + fit: .cover, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment(0.0, 0.5), + end: .center, + colors: <Color>[Color(0x60000000), Color(0x00000000)], + ), + ), + ), + ], + ), + ), + ), + SliverList.list( + children: const <Widget>[ + ListTile( + leading: Icon(Icons.wb_sunny), + title: Text('Sunday'), + subtitle: Text('sunny, h: 80, l: 65'), + ), + ListTile( + leading: Icon(Icons.wb_sunny), + title: Text('Monday'), + subtitle: Text('sunny, h: 80, l: 65'), + ), + // ListTiles++ + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.0.dart b/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.0.dart new file mode 100644 index 000000000000..21b56ddb3f88 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.0.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [FloatingActionButton]. + +void main() { + runApp(const FloatingActionButtonExampleApp()); +} + +class FloatingActionButtonExampleApp extends StatelessWidget { + const FloatingActionButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: FloatingActionButtonExample()); + } +} + +class FloatingActionButtonExample extends StatefulWidget { + const FloatingActionButtonExample({super.key}); + + @override + State<FloatingActionButtonExample> createState() => + _FloatingActionButtonExampleState(); +} + +class _FloatingActionButtonExampleState + extends State<FloatingActionButtonExample> { + // The FAB's foregroundColor, backgroundColor, and shape + static const List<(Color?, Color? background, ShapeBorder?)> customizations = + <(Color?, Color?, ShapeBorder?)>[ + (null, null, null), // The FAB uses its default for null parameters. + (null, Colors.green, null), + (Colors.white, Colors.green, null), + (Colors.white, Colors.green, CircleBorder()), + ]; + int index = 0; // Selects the customization. + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('FloatingActionButton Sample')), + body: const Center(child: Text('Press the button below!')), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + index = (index + 1) % customizations.length; + }); + }, + foregroundColor: customizations[index].$1, + backgroundColor: customizations[index].$2, + shape: customizations[index].$3, + child: const Icon(Icons.navigation), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.1.dart b/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.1.dart new file mode 100644 index 000000000000..70da8ba9dd4d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.1.dart @@ -0,0 +1,101 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [FloatingActionButton]. + +void main() => runApp(const FloatingActionButtonExampleApp()); + +class FloatingActionButtonExampleApp extends StatelessWidget { + const FloatingActionButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: FloatingActionButtonExample()); + } +} + +class FloatingActionButtonExample extends StatelessWidget { + const FloatingActionButtonExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('FloatingActionButton Sample')), + body: Center( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Row( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Small'), + const SizedBox(width: 16), + // An example of the small floating action button. + // + // https://m3.material.io/components/floating-action-button/specs#669a1be8-7271-48cb-a74d-dd502d73bda4 + FloatingActionButton.small( + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.add), + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Regular'), + const SizedBox(width: 16), + // An example of the regular floating action button. + // + // https://m3.material.io/components/floating-action-button/specs#71504201-7bd1-423d-8bb7-07e0291743e5 + FloatingActionButton( + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.add), + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Large'), + const SizedBox(width: 16), + // An example of the large floating action button. + // + // https://m3.material.io/components/floating-action-button/specs#9d7d3d6a-bab7-47cb-be32-5596fbd660fe + FloatingActionButton.large( + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.add), + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Extended'), + const SizedBox(width: 16), + // An example of the extended floating action button. + // + // https://m3.material.io/components/extended-fab/specs#686cb8af-87c9-48e8-a3e1-db9da6f6c69b + FloatingActionButton.extended( + onPressed: () { + // Add your onPressed code here! + }, + label: const Text('Add'), + icon: const Icon(Icons.add), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.2.dart b/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.2.dart new file mode 100644 index 000000000000..5ebd8346b557 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/floating_action_button/floating_action_button.2.dart @@ -0,0 +1,102 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [FloatingActionButton]. + +void main() => runApp(const FloatingActionButtonExampleApp()); + +class FloatingActionButtonExampleApp extends StatelessWidget { + const FloatingActionButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: FloatingActionButtonExample()); + } +} + +class FloatingActionButtonExample extends StatelessWidget { + const FloatingActionButtonExample({super.key}); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + Widget titleBox(String title) { + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.inverseSurface, + borderRadius: .circular(4), + ), + child: Padding( + padding: const .symmetric(horizontal: 8, vertical: 4), + child: Text( + title, + style: TextStyle(color: colorScheme.onInverseSurface), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('FAB Additional Color Mappings')), + body: Center( + child: Row( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + // Surface color mapping. + Column( + mainAxisSize: .min, + children: <Widget>[ + FloatingActionButton.large( + foregroundColor: colorScheme.primary, + backgroundColor: colorScheme.surface, + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.edit_outlined), + ), + const SizedBox(height: 20), + titleBox('Surface'), + ], + ), + // Secondary color mapping. + Column( + mainAxisSize: .min, + children: <Widget>[ + FloatingActionButton.large( + foregroundColor: colorScheme.onSecondaryContainer, + backgroundColor: colorScheme.secondaryContainer, + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.edit_outlined), + ), + const SizedBox(height: 20), + titleBox('Secondary'), + ], + ), + // Tertiary color mapping. + Column( + mainAxisSize: .min, + children: <Widget>[ + FloatingActionButton.large( + foregroundColor: colorScheme.onTertiaryContainer, + backgroundColor: colorScheme.tertiaryContainer, + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.edit_outlined), + ), + const SizedBox(height: 20), + titleBox('Tertiary'), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/floating_action_button_location/standard_fab_location.0.dart b/packages/material_ui/material_ui_examples/lib/floating_action_button_location/standard_fab_location.0.dart new file mode 100644 index 000000000000..31e63b62225c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/floating_action_button_location/standard_fab_location.0.dart @@ -0,0 +1,52 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [StandardFabLocation]. + +void main() => runApp(const StandardFabLocationExampleApp()); + +class StandardFabLocationExampleApp extends StatelessWidget { + const StandardFabLocationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: StandardFabLocationExample()); + } +} + +class AlmostEndFloatFabLocation extends StandardFabLocation + with FabEndOffsetX, FabFloatOffsetY { + @override + double getOffsetX( + ScaffoldPrelayoutGeometry scaffoldGeometry, + double adjustment, + ) { + final double directionalAdjustment = scaffoldGeometry.textDirection == .ltr + ? -50.0 + : 50.0; + return super.getOffsetX(scaffoldGeometry, adjustment) + + directionalAdjustment; + } +} + +class StandardFabLocationExample extends StatelessWidget { + const StandardFabLocationExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Home page')), + floatingActionButton: FloatingActionButton( + onPressed: () { + debugPrint('FAB pressed.'); + }, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: AlmostEndFloatFabLocation(), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/icon_alignment/icon_alignment.0.dart b/packages/material_ui/material_ui_examples/lib/icon_alignment/icon_alignment.0.dart new file mode 100644 index 000000000000..fe069e1e62ae --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/icon_alignment/icon_alignment.0.dart @@ -0,0 +1,146 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for the [IconAlignment] property on various Material +/// button widgets. + +void main() { + runApp(const IconAlignmentApp()); +} + +class IconAlignmentApp extends StatelessWidget { + const IconAlignmentApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: Scaffold(body: IconAlignmentExample())); + } +} + +class IconAlignmentExample extends StatefulWidget { + const IconAlignmentExample({super.key}); + + @override + State<IconAlignmentExample> createState() => _IconAlignmentExampleState(); +} + +class _IconAlignmentExampleState extends State<IconAlignmentExample> { + TextDirection _textDirection = .ltr; + IconAlignment _iconAlignment = .start; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Directionality( + key: const Key('Directionality'), + textDirection: _textDirection, + child: Center( + child: Column( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + const Spacer(), + OverflowBar( + spacing: 10, + overflowSpacing: 20, + alignment: .center, + overflowAlignment: .center, + children: <Widget>[ + ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.sunny), + label: const Text('ElevatedButton'), + iconAlignment: _iconAlignment, + ), + FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.beach_access), + label: const Text('FilledButton'), + iconAlignment: _iconAlignment, + ), + FilledButton.tonalIcon( + onPressed: () {}, + icon: const Icon(Icons.cloud), + label: const Text('FilledButton Tonal'), + iconAlignment: _iconAlignment, + ), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.light), + label: const Text('OutlinedButton'), + iconAlignment: _iconAlignment, + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.flight_takeoff), + label: const Text('TextButton'), + iconAlignment: _iconAlignment, + ), + ], + ), + const Spacer(), + OverflowBar( + alignment: .spaceEvenly, + overflowAlignment: .center, + spacing: 10, + overflowSpacing: 10, + children: <Widget>[ + Column( + children: <Widget>[ + const Text('Icon alignment'), + const SizedBox(height: 10), + SegmentedButton<IconAlignment>( + onSelectionChanged: (Set<IconAlignment> value) { + setState(() { + _iconAlignment = value.first; + }); + }, + selected: <IconAlignment>{_iconAlignment}, + segments: IconAlignment.values.map(( + IconAlignment iconAlignment, + ) { + return ButtonSegment<IconAlignment>( + value: iconAlignment, + label: Text(iconAlignment.name), + ); + }).toList(), + ), + ], + ), + Column( + children: <Widget>[ + const Text('Text direction'), + const SizedBox(height: 10), + SegmentedButton<TextDirection>( + onSelectionChanged: (Set<TextDirection> value) { + setState(() { + _textDirection = value.first; + }); + }, + selected: <TextDirection>{_textDirection}, + segments: const <ButtonSegment<TextDirection>>[ + ButtonSegment<TextDirection>( + value: TextDirection.ltr, + label: Text('LTR'), + ), + ButtonSegment<TextDirection>( + value: TextDirection.rtl, + label: Text('RTL'), + ), + ], + ), + ], + ), + ], + ), + const Spacer(), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.0.dart b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.0.dart new file mode 100644 index 000000000000..61667e582bf7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.0.dart @@ -0,0 +1,53 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [IconButton]. + +void main() => runApp(const IconButtonExampleApp()); + +class IconButtonExampleApp extends StatelessWidget { + const IconButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('IconButton Sample')), + body: const Center(child: IconButtonExample()), + ), + ); + } +} + +double _volume = 0.0; + +class IconButtonExample extends StatefulWidget { + const IconButtonExample({super.key}); + + @override + State<IconButtonExample> createState() => _IconButtonExampleState(); +} + +class _IconButtonExampleState extends State<IconButtonExample> { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: .min, + children: <Widget>[ + IconButton( + icon: const Icon(Icons.volume_up), + tooltip: 'Increase volume by 10', + onPressed: () { + setState(() { + _volume += 10; + }); + }, + ), + Text('Volume : $_volume'), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.1.dart b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.1.dart new file mode 100644 index 000000000000..8884b9e1927b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.1.dart @@ -0,0 +1,48 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [IconButton]. + +void main() => runApp(const IconButtonExampleApp()); + +class IconButtonExampleApp extends StatelessWidget { + const IconButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('IconButton Sample')), + body: const IconButtonExample(), + ), + ); + } +} + +class IconButtonExample extends StatelessWidget { + const IconButtonExample({super.key}); + + @override + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + child: Center( + child: Ink( + decoration: const ShapeDecoration( + color: Colors.lightBlue, + shape: CircleBorder(), + ), + child: IconButton( + icon: const Icon(Icons.android), + color: Colors.white, + onPressed: () {}, + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.2.dart b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.2.dart new file mode 100644 index 000000000000..3c15052379f0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.2.dart @@ -0,0 +1,85 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [IconButton]. + +void main() { + runApp(const IconButtonApp()); +} + +class IconButtonApp extends StatelessWidget { + const IconButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + title: 'Icon Button Types', + home: const Scaffold(body: ButtonTypesExample()), + ); + } +} + +class ButtonTypesExample extends StatelessWidget { + const ButtonTypesExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: .all(4.0), + child: Row( + children: <Widget>[ + Spacer(), + ButtonTypesGroup(enabled: true), + ButtonTypesGroup(enabled: false), + Spacer(), + ], + ), + ); + } +} + +class ButtonTypesGroup extends StatelessWidget { + const ButtonTypesGroup({super.key, required this.enabled}); + + final bool enabled; + + @override + Widget build(BuildContext context) { + final VoidCallback? onPressed = enabled ? () {} : null; + + return Padding( + padding: const .all(4.0), + child: Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + IconButton( + icon: const Icon(Icons.filter_drama), + onPressed: onPressed, + ), + + // Filled icon button + IconButton.filled( + onPressed: onPressed, + icon: const Icon(Icons.filter_drama), + ), + + // Filled tonal icon button + IconButton.filledTonal( + onPressed: onPressed, + icon: const Icon(Icons.filter_drama), + ), + + // Outlined icon button + IconButton.outlined( + onPressed: onPressed, + icon: const Icon(Icons.filter_drama), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.3.dart b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.3.dart new file mode 100644 index 000000000000..9aa593181f3a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/icon_button/icon_button.3.dart @@ -0,0 +1,135 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [IconButton] with toggle feature. + +void main() { + runApp(const IconButtonToggleApp()); +} + +class IconButtonToggleApp extends StatelessWidget { + const IconButtonToggleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + title: 'Icon Button Types', + home: const Scaffold(body: DemoIconToggleButtons()), + ); + } +} + +class DemoIconToggleButtons extends StatefulWidget { + const DemoIconToggleButtons({super.key}); + + @override + State<DemoIconToggleButtons> createState() => _DemoIconToggleButtonsState(); +} + +class _DemoIconToggleButtonsState extends State<DemoIconToggleButtons> { + bool standardSelected = false; + bool filledSelected = false; + bool tonalSelected = false; + bool outlinedSelected = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Row( + mainAxisAlignment: .center, + children: <Widget>[ + IconButton( + isSelected: standardSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: () { + setState(() { + standardSelected = !standardSelected; + }); + }, + ), + const SizedBox(width: 10), + IconButton( + isSelected: standardSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: null, + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + IconButton.filled( + isSelected: filledSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: () { + setState(() { + filledSelected = !filledSelected; + }); + }, + ), + const SizedBox(width: 10), + IconButton.filled( + isSelected: filledSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: null, + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + IconButton.filledTonal( + isSelected: tonalSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: () { + setState(() { + tonalSelected = !tonalSelected; + }); + }, + ), + const SizedBox(width: 10), + IconButton.filledTonal( + isSelected: tonalSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: null, + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + IconButton.outlined( + isSelected: outlinedSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: () { + setState(() { + outlinedSelected = !outlinedSelected; + }); + }, + ), + const SizedBox(width: 10), + IconButton.outlined( + isSelected: outlinedSelected, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + onPressed: null, + ), + ], + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/ink/ink.image_clip.0.dart b/packages/material_ui/material_ui_examples/lib/ink/ink.image_clip.0.dart new file mode 100644 index 000000000000..e4f11554d89c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/ink/ink.image_clip.0.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Image.frameBuilder]. + +void main() { + runApp( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Image.frameBuilder Sample')), + body: const Center( + child: ImageClipExample( + image: NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/widgets/puffin.jpg', + ), + ), + ), + ), + ), + ); +} + +class ImageClipExample extends StatelessWidget { + const ImageClipExample({super.key, required this.image}); + + final ImageProvider image; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: .circular(100), + child: Ink.image( + fit: .fill, + width: 300, + height: 300, + image: image, + child: InkWell( + onTap: () { + /* ... */ + }, + child: const Align( + child: Padding( + padding: .all(10.0), + child: Text( + 'PUFFIN', + style: TextStyle(fontWeight: .w900, color: Colors.white), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/ink/ink.image_clip.1.dart b/packages/material_ui/material_ui_examples/lib/ink/ink.image_clip.1.dart new file mode 100644 index 000000000000..6f6bc1da14f7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/ink/ink.image_clip.1.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Image.frameBuilder]. + +void main() { + runApp( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Image.frameBuilder Sample')), + body: const Center( + child: ImageClipExample( + image: NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/widgets/puffin.jpg', + ), + ), + ), + ), + ), + ); +} + +class ImageClipExample extends StatelessWidget { + const ImageClipExample({super.key, required this.image}); + + final ImageProvider image; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: .circular(100), + child: Material( + child: Ink.image( + fit: .fill, + width: 300, + height: 300, + image: image, + child: InkWell( + onTap: () { + /* ... */ + }, + child: const Align( + child: Padding( + padding: .all(10.0), + child: Text( + 'PUFFIN', + style: TextStyle(fontWeight: .w900, color: Colors.white), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/ink_well/ink_well.0.dart b/packages/material_ui/material_ui_examples/lib/ink_well/ink_well.0.dart new file mode 100644 index 000000000000..97c45c07ab6f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/ink_well/ink_well.0.dart @@ -0,0 +1,54 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InkWell]. + +void main() => runApp(const InkWellExampleApp()); + +class InkWellExampleApp extends StatelessWidget { + const InkWellExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InkWell Sample')), + body: const Center(child: InkWellExample()), + ), + ); + } +} + +class InkWellExample extends StatefulWidget { + const InkWellExample({super.key}); + + @override + State<InkWellExample> createState() => _InkWellExampleState(); +} + +class _InkWellExampleState extends State<InkWellExample> { + double sideLength = 50; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + height: sideLength, + width: sideLength, + duration: const Duration(seconds: 2), + curve: Curves.easeIn, + child: Material( + color: Colors.yellow, + child: InkWell( + onTap: () { + setState(() { + sideLength == 50 ? sideLength = 100 : sideLength = 50; + }); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_chip/input_chip.0.dart b/packages/material_ui/material_ui_examples/lib/input_chip/input_chip.0.dart new file mode 100644 index 000000000000..bb414cbe19a5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_chip/input_chip.0.dart @@ -0,0 +1,81 @@ +// 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 InputChip. + +import 'package:material_ui/material_ui.dart'; + +void main() => runApp(const ChipApp()); + +class ChipApp extends StatelessWidget { + const ChipApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const InputChipExample(), + ); + } +} + +class InputChipExample extends StatefulWidget { + const InputChipExample({super.key}); + + @override + State<InputChipExample> createState() => _InputChipExampleState(); +} + +class _InputChipExampleState extends State<InputChipExample> { + int inputs = 3; + int? selectedIndex; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('InputChip Sample')), + body: Center( + child: Column( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + Wrap( + alignment: .center, + spacing: 5.0, + children: List<Widget>.generate(inputs, (int index) { + return InputChip( + label: Text('Person ${index + 1}'), + selected: selectedIndex == index, + onSelected: (bool selected) { + setState(() { + if (selectedIndex == index) { + selectedIndex = null; + } else { + selectedIndex = index; + } + }); + }, + onDeleted: () { + setState(() { + inputs = inputs - 1; + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: () { + setState(() { + inputs = 3; + }); + }, + child: const Text('Reset'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_chip/input_chip.1.dart b/packages/material_ui/material_ui_examples/lib/input_chip/input_chip.1.dart new file mode 100644 index 000000000000..f3284e0f51de --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_chip/input_chip.1.dart @@ -0,0 +1,360 @@ +// 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:async'; + +import 'package:material_ui/material_ui.dart'; + +const List<String> _pizzaToppings = <String>[ + 'Olives', + 'Tomato', + 'Cheese', + 'Pepperoni', + 'Bacon', + 'Onion', + 'Jalapeno', + 'Mushrooms', + 'Pineapple', +]; + +void main() => runApp(const EditableChipFieldApp()); + +class EditableChipFieldApp extends StatelessWidget { + const EditableChipFieldApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: EditableChipFieldExample()); + } +} + +class EditableChipFieldExample extends StatefulWidget { + const EditableChipFieldExample({super.key}); + + @override + EditableChipFieldExampleState createState() { + return EditableChipFieldExampleState(); + } +} + +class EditableChipFieldExampleState extends State<EditableChipFieldExample> { + final FocusNode _chipFocusNode = FocusNode(); + List<String> _toppings = <String>[_pizzaToppings.first]; + List<String> _suggestions = <String>[]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Editable Chip Field Sample')), + body: Column( + children: <Widget>[ + Padding( + padding: const .symmetric(horizontal: 16), + child: ChipsInput<String>( + values: _toppings, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.local_pizza_rounded), + hintText: 'Search for toppings', + ), + strutStyle: const StrutStyle(fontSize: 15), + onChanged: _onChanged, + onSubmitted: _onSubmitted, + chipBuilder: _chipBuilder, + onTextChanged: _onSearchChanged, + ), + ), + if (_suggestions.isNotEmpty) + Expanded( + child: ListView.builder( + itemCount: _suggestions.length, + itemBuilder: (BuildContext context, int index) { + return ToppingSuggestion( + _suggestions[index], + onTap: _selectSuggestion, + ); + }, + ), + ), + ], + ), + ); + } + + Future<void> _onSearchChanged(String value) async { + final List<String> results = await _suggestionCallback(value); + setState(() { + _suggestions = results + .where((String topping) => !_toppings.contains(topping)) + .toList(); + }); + } + + Widget _chipBuilder(BuildContext context, String topping) { + return ToppingInputChip( + topping: topping, + onDeleted: _onChipDeleted, + onSelected: _onChipTapped, + ); + } + + void _selectSuggestion(String topping) { + setState(() { + _toppings.add(topping); + _suggestions = <String>[]; + }); + } + + void _onChipTapped(String topping) {} + + void _onChipDeleted(String topping) { + setState(() { + _toppings.remove(topping); + _suggestions = <String>[]; + }); + } + + void _onSubmitted(String text) { + if (text.trim().isNotEmpty) { + setState(() { + _toppings = <String>[..._toppings, text.trim()]; + }); + } else { + _chipFocusNode.unfocus(); + setState(() { + _toppings = <String>[]; + }); + } + } + + void _onChanged(List<String> data) { + setState(() { + _toppings = data; + }); + } + + FutureOr<List<String>> _suggestionCallback(String text) { + if (text.isNotEmpty) { + return _pizzaToppings.where((String topping) { + return topping.toLowerCase().contains(text.toLowerCase()); + }).toList(); + } + return const <String>[]; + } +} + +class ChipsInput<T> extends StatefulWidget { + const ChipsInput({ + super.key, + required this.values, + this.decoration = const InputDecoration(), + this.style, + this.strutStyle, + required this.chipBuilder, + required this.onChanged, + this.onChipTapped, + this.onSubmitted, + this.onTextChanged, + }); + + final List<T> values; + final InputDecoration decoration; + final TextStyle? style; + final StrutStyle? strutStyle; + + final ValueChanged<List<T>> onChanged; + final ValueChanged<T>? onChipTapped; + final ValueChanged<String>? onSubmitted; + final ValueChanged<String>? onTextChanged; + + final Widget Function(BuildContext context, T data) chipBuilder; + + @override + ChipsInputState<T> createState() => ChipsInputState<T>(); +} + +class ChipsInputState<T> extends State<ChipsInput<T>> { + @visibleForTesting + late final ChipsInputEditingController<T> controller; + + String _previousText = ''; + TextSelection? _previousSelection; + + @override + void initState() { + super.initState(); + + controller = ChipsInputEditingController<T>(<T>[ + ...widget.values, + ], widget.chipBuilder); + controller.addListener(_textListener); + } + + @override + void dispose() { + controller.removeListener(_textListener); + controller.dispose(); + + super.dispose(); + } + + void _textListener() { + final String currentText = controller.text; + + if (_previousSelection != null) { + final int currentNumber = countReplacements(currentText); + final int previousNumber = countReplacements(_previousText); + + final int cursorEnd = _previousSelection!.extentOffset; + final int cursorStart = _previousSelection!.baseOffset; + + final List<T> values = <T>[...widget.values]; + + // If the current number and the previous number of replacements are different, then + // the user has deleted the InputChip using the keyboard. In this case, we trigger + // the onChanged callback. We need to be sure also that the current number of + // replacements is different from the input chip to avoid double-deletion. + if (currentNumber < previousNumber && currentNumber != values.length) { + if (cursorStart == cursorEnd) { + values.removeRange(cursorStart - 1, cursorEnd); + } else { + if (cursorStart > cursorEnd) { + values.removeRange(cursorEnd, cursorStart); + } else { + values.removeRange(cursorStart, cursorEnd); + } + } + widget.onChanged(values); + } + } + + _previousText = currentText; + _previousSelection = controller.selection; + } + + static int countReplacements(String text) { + return text.codeUnits + .where( + (int u) => u == ChipsInputEditingController.kObjectReplacementChar, + ) + .length; + } + + @override + Widget build(BuildContext context) { + controller.updateValues(<T>[...widget.values]); + + return TextField( + minLines: 1, + maxLines: 3, + textInputAction: .done, + style: widget.style, + strutStyle: widget.strutStyle, + controller: controller, + onChanged: (String value) => + widget.onTextChanged?.call(controller.textWithoutReplacements), + onSubmitted: (String value) => + widget.onSubmitted?.call(controller.textWithoutReplacements), + ); + } +} + +class ChipsInputEditingController<T> extends TextEditingController { + ChipsInputEditingController(this.values, this.chipBuilder) + : super(text: String.fromCharCode(kObjectReplacementChar) * values.length); + + // This constant character acts as a placeholder in the TextField text value. + // There will be one character for each of the InputChip displayed. + static const int kObjectReplacementChar = 0xFFFE; + + List<T> values; + + final Widget Function(BuildContext context, T data) chipBuilder; + + /// Called whenever chip is either added or removed + /// from the outside the context of the text field. + void updateValues(List<T> values) { + if (values.length != this.values.length) { + final String char = String.fromCharCode(kObjectReplacementChar); + final int length = values.length; + value = TextEditingValue( + text: char * length, + selection: TextSelection.collapsed(offset: length), + ); + this.values = values; + } + } + + String get textWithoutReplacements { + final String char = String.fromCharCode(kObjectReplacementChar); + return text.replaceAll(RegExp(char), ''); + } + + String get textWithReplacements => text; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final Iterable<WidgetSpan> chipWidgets = values.map( + (T v) => WidgetSpan(child: chipBuilder(context, v)), + ); + + return TextSpan( + style: style, + children: <InlineSpan>[ + ...chipWidgets, + if (textWithoutReplacements.isNotEmpty) + TextSpan(text: textWithoutReplacements), + ], + ); + } +} + +class ToppingSuggestion extends StatelessWidget { + const ToppingSuggestion(this.topping, {super.key, this.onTap}); + + final String topping; + final ValueChanged<String>? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + key: ObjectKey(topping), + leading: CircleAvatar(child: Text(topping[0].toUpperCase())), + title: Text(topping), + onTap: () => onTap?.call(topping), + ); + } +} + +class ToppingInputChip extends StatelessWidget { + const ToppingInputChip({ + super.key, + required this.topping, + required this.onDeleted, + required this.onSelected, + }); + + final String topping; + final ValueChanged<String> onDeleted; + final ValueChanged<String> onSelected; + + @override + Widget build(BuildContext context) { + return Container( + margin: const .only(right: 3), + child: InputChip( + key: ObjectKey(topping), + label: Text(topping), + avatar: CircleAvatar(child: Text(topping[0].toUpperCase())), + onDeleted: () => onDeleted(topping), + onSelected: (bool value) => onSelected(topping), + materialTapTargetSize: .shrinkWrap, + padding: const .all(2), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.0.dart new file mode 100644 index 000000000000..7e7aa21d8187 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.0.dart @@ -0,0 +1,40 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration]. + +void main() => runApp(const InputDecorationExampleApp()); + +class InputDecorationExampleApp extends StatelessWidget { + const InputDecorationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const InputDecorationExample(), + ), + ); + } +} + +class InputDecorationExample extends StatelessWidget { + const InputDecorationExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration( + icon: Icon(Icons.send), + hintText: 'Hint Text', + helperText: 'Helper Text', + counterText: '0 characters', + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.1.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.1.dart new file mode 100644 index 000000000000..93b0410bc425 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.1.dart @@ -0,0 +1,37 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration]. + +void main() => runApp(const InputDecorationExampleApp()); + +class InputDecorationExampleApp extends StatelessWidget { + const InputDecorationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const InputDecorationExample(), + ), + ); + } +} + +class InputDecorationExample extends StatelessWidget { + const InputDecorationExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration.collapsed( + hintText: 'Hint Text', + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.2.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.2.dart new file mode 100644 index 000000000000..28edb4016a89 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.2.dart @@ -0,0 +1,38 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration]. + +void main() => runApp(const InputDecorationExampleApp()); + +class InputDecorationExampleApp extends StatelessWidget { + const InputDecorationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const InputDecorationExample(), + ), + ); + } +} + +class InputDecorationExample extends StatelessWidget { + const InputDecorationExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration( + hintText: 'Hint Text', + errorText: 'Error Text', + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.3.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.3.dart new file mode 100644 index 000000000000..c6065eb0b454 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.3.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration]. + +void main() => runApp(const InputDecorationExampleApp()); + +class InputDecorationExampleApp extends StatelessWidget { + const InputDecorationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const InputDecorationExample(), + ), + ); + } +} + +class InputDecorationExample extends StatelessWidget { + const InputDecorationExample({super.key}); + + @override + Widget build(BuildContext context) { + return TextFormField( + initialValue: 'abc', + decoration: const InputDecoration( + prefix: Text('Prefix'), + suffix: Text('Suffix'), + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.floating_label_style_error.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.floating_label_style_error.0.dart new file mode 100644 index 000000000000..f41e2340278e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.floating_label_style_error.0.dart @@ -0,0 +1,55 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecorator]. + +void main() => runApp(const FloatingLabelStyleErrorExampleApp()); + +class FloatingLabelStyleErrorExampleApp extends StatelessWidget { + const FloatingLabelStyleErrorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecorator Sample')), + body: const Center(child: InputDecoratorExample()), + ), + ); + } +} + +class InputDecoratorExample extends StatelessWidget { + const InputDecoratorExample({super.key}); + + @override + Widget build(BuildContext context) { + return TextFormField( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'Name', + // The WidgetStateProperty's value is a text style that is orange + // by default, but the theme's error color if the input decorator + // is in its error state. + floatingLabelStyle: WidgetStateTextStyle.resolveWith(( + Set<WidgetState> states, + ) { + final Color color = states.contains(WidgetState.error) + ? Theme.of(context).colorScheme.error + : Colors.orange; + return TextStyle(color: color, letterSpacing: 1.3); + }), + ), + validator: (String? value) { + if (value == null || value == '') { + return 'Enter name'; + } + return null; + }, + autovalidateMode: .always, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.helper.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.helper.0.dart new file mode 100644 index 000000000000..7d1012e81fe1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.helper.0.dart @@ -0,0 +1,51 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration.helper]. + +void main() => runApp(const HelperExampleApp()); + +class HelperExampleApp extends StatelessWidget { + const HelperExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration.helper Sample')), + body: const HelperExample(), + ), + ); + } +} + +class HelperExample extends StatelessWidget { + const HelperExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: TextField( + decoration: InputDecoration( + helper: Text.rich( + TextSpan( + children: <InlineSpan>[ + WidgetSpan(child: Text('Helper Text ')), + WidgetSpan( + child: Icon( + Icons.help_outline, + color: Colors.blue, + size: 20.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.label.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.label.0.dart new file mode 100644 index 000000000000..4ea801ec51c8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.label.0.dart @@ -0,0 +1,47 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration.label]. + +void main() => runApp(const LabelExampleApp()); + +class LabelExampleApp extends StatelessWidget { + const LabelExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration.label Sample')), + body: const LabelExample(), + ), + ); + } +} + +class LabelExample extends StatelessWidget { + const LabelExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: TextField( + decoration: InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + WidgetSpan(child: Text('Username')), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.label_style_error.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.label_style_error.0.dart new file mode 100644 index 000000000000..852f113d59f8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.label_style_error.0.dart @@ -0,0 +1,53 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecorator]. + +void main() => runApp(const LabelStyleErrorExampleApp()); + +class LabelStyleErrorExampleApp extends StatelessWidget { + const LabelStyleErrorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecorator Sample')), + body: const Center(child: InputDecoratorExample()), + ), + ); + } +} + +class InputDecoratorExample extends StatelessWidget { + const InputDecoratorExample({super.key}); + + @override + Widget build(BuildContext context) { + return TextFormField( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'Name', + // The WidgetStateProperty's value is a text style that is orange + // by default, but the theme's error color if the input decorator + // is in its error state. + labelStyle: WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + final Color color = states.contains(WidgetState.error) + ? Theme.of(context).colorScheme.error + : Colors.orange; + return TextStyle(color: color, letterSpacing: 1.3); + }), + ), + validator: (String? value) { + if (value == null || value == '') { + return 'Enter name'; + } + return null; + }, + autovalidateMode: .always, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.prefix_icon.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.prefix_icon.0.dart new file mode 100644 index 000000000000..2a1031c17336 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.prefix_icon.0.dart @@ -0,0 +1,37 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecorator]. + +void main() => runApp(const PrefixIconExampleApp()); + +class PrefixIconExampleApp extends StatelessWidget { + const PrefixIconExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: Scaffold(body: InputDecoratorExample())); + } +} + +class InputDecoratorExample extends StatelessWidget { + const InputDecoratorExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Enter name', + prefixIcon: Align( + widthFactor: 1.0, + heightFactor: 1.0, + child: Icon(Icons.person), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.prefix_icon_constraints.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.prefix_icon_constraints.0.dart new file mode 100644 index 000000000000..3b6e95f7c736 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.prefix_icon_constraints.0.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration.prefixIconConstraints]. + +void main() => runApp(const PrefixIconConstraintsExampleApp()); + +class PrefixIconConstraintsExampleApp extends StatelessWidget { + const PrefixIconConstraintsExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const PrefixIconConstraintsExample(), + ), + ); + } +} + +class PrefixIconConstraintsExample extends StatelessWidget { + const PrefixIconConstraintsExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: .symmetric(horizontal: 8.0), + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + TextField( + decoration: InputDecoration( + hintText: 'Normal Icon Constraints', + prefixIcon: Icon(Icons.search), + ), + ), + SizedBox(height: 10), + TextField( + decoration: InputDecoration( + isDense: true, + hintText: 'Smaller Icon Constraints', + prefixIcon: Icon(Icons.search), + prefixIconConstraints: BoxConstraints( + minHeight: 32, + minWidth: 32, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.suffix_icon.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.suffix_icon.0.dart new file mode 100644 index 000000000000..2fe3dfd6b6ad --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.suffix_icon.0.dart @@ -0,0 +1,37 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecorator]. + +void main() => runApp(const SuffixIconExampleApp()); + +class SuffixIconExampleApp extends StatelessWidget { + const SuffixIconExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: Scaffold(body: InputDecoratorExample())); + } +} + +class InputDecoratorExample extends StatelessWidget { + const InputDecoratorExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Enter password', + suffixIcon: Align( + widthFactor: 1.0, + heightFactor: 1.0, + child: Icon(Icons.remove_red_eye), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.suffix_icon_constraints.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.suffix_icon_constraints.0.dart new file mode 100644 index 000000000000..1869056753cb --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.suffix_icon_constraints.0.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration.suffixIconConstraints]. + +void main() => runApp(const SuffixIconConstraintsExampleApp()); + +class SuffixIconConstraintsExampleApp extends StatelessWidget { + const SuffixIconConstraintsExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const SuffixIconConstraintsExample(), + ), + ); + } +} + +class SuffixIconConstraintsExample extends StatelessWidget { + const SuffixIconConstraintsExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: .symmetric(horizontal: 8.0), + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + TextField( + decoration: InputDecoration( + hintText: 'Normal Icon Constraints', + suffixIcon: Icon(Icons.search), + ), + ), + SizedBox(height: 10), + TextField( + decoration: InputDecoration( + isDense: true, + hintText: 'Smaller Icon Constraints', + suffixIcon: Icon(Icons.search), + suffixIconConstraints: BoxConstraints( + minHeight: 32, + minWidth: 32, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.widget_state.0.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.widget_state.0.dart new file mode 100644 index 000000000000..70786cbea9e6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.widget_state.0.dart @@ -0,0 +1,43 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration]. + +void main() => runApp(const MaterialStateExampleApp()); + +class MaterialStateExampleApp extends StatelessWidget { + const MaterialStateExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const MaterialStateExample(), + ), + ); + } +} + +class MaterialStateExample extends StatelessWidget { + const MaterialStateExample({super.key}); + + @override + Widget build(BuildContext context) { + return TextFormField( + initialValue: 'abc', + decoration: const InputDecoration( + prefixIcon: Icon(Icons.person), + prefixIconColor: WidgetStateColor.fromMap( + <WidgetStatesConstraint, Color>{ + WidgetState.focused: Colors.green, + WidgetState.any: Colors.grey, + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.widget_state.1.dart b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.widget_state.1.dart new file mode 100644 index 000000000000..2a5d11220afa --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/input_decorator/input_decoration.widget_state.1.dart @@ -0,0 +1,50 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [InputDecoration]. + +void main() => runApp(const MaterialStateExampleApp()); + +class MaterialStateExampleApp extends StatelessWidget { + const MaterialStateExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('InputDecoration Sample')), + body: const MaterialStateExample(), + ), + ); + } +} + +class MaterialStateExample extends StatelessWidget { + const MaterialStateExample({super.key}); + + @override + Widget build(BuildContext context) { + return InputDecorationTheme( + prefixIconColor: + const WidgetStateColor.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.error: Colors.red, + WidgetState.focused: Colors.blue, + WidgetState.any: Colors.grey, + }), + child: TextFormField( + initialValue: 'example.com', + decoration: const InputDecoration(prefixIcon: Icon(Icons.web)), + autovalidateMode: .always, + validator: (String? text) { + if (text?.endsWith('.com') ?? false) { + return null; + } + return 'No .com tld'; + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/custom_list_item.0.dart b/packages/material_ui/material_ui_examples/lib/list_tile/custom_list_item.0.dart new file mode 100644 index 000000000000..e527b5143be6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/custom_list_item.0.dart @@ -0,0 +1,120 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for custom list items. + +void main() => runApp(const CustomListItemApp()); + +class CustomListItemApp extends StatelessWidget { + const CustomListItemApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CustomListItemExample()); + } +} + +class CustomListItem extends StatelessWidget { + const CustomListItem({ + super.key, + required this.thumbnail, + required this.title, + required this.user, + required this.viewCount, + }); + + final Widget thumbnail; + final String title; + final String user; + final int viewCount; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .symmetric(vertical: 5.0), + child: Row( + crossAxisAlignment: .start, + children: <Widget>[ + Expanded(flex: 2, child: thumbnail), + Expanded( + flex: 3, + child: _VideoDescription( + title: title, + user: user, + viewCount: viewCount, + ), + ), + const Icon(Icons.more_vert, size: 16.0), + ], + ), + ); + } +} + +class _VideoDescription extends StatelessWidget { + const _VideoDescription({ + required this.title, + required this.user, + required this.viewCount, + }); + + final String title; + final String user; + final int viewCount; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .fromLTRB(5.0, 0.0, 0.0, 0.0), + child: Column( + crossAxisAlignment: .start, + children: <Widget>[ + Text( + title, + style: const TextStyle(fontWeight: .w500, fontSize: 14.0), + ), + const Padding(padding: .symmetric(vertical: 2.0)), + Text(user, style: const TextStyle(fontSize: 10.0)), + const Padding(padding: .symmetric(vertical: 1.0)), + Text('$viewCount views', style: const TextStyle(fontSize: 10.0)), + ], + ), + ); + } +} + +class CustomListItemExample extends StatelessWidget { + const CustomListItemExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom List Item Sample')), + body: ListView( + padding: const .all(8.0), + itemExtent: 106.0, + children: <CustomListItem>[ + CustomListItem( + user: 'Flutter', + viewCount: 999000, + thumbnail: Container( + decoration: const BoxDecoration(color: Colors.blue), + ), + title: 'The Flutter YouTube Channel', + ), + CustomListItem( + user: 'Dash', + viewCount: 884000, + thumbnail: Container( + decoration: const BoxDecoration(color: Colors.yellow), + ), + title: 'Announcing Flutter 1.0', + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/custom_list_item.1.dart b/packages/material_ui/material_ui_examples/lib/list_tile/custom_list_item.1.dart new file mode 100644 index 000000000000..2c89a1a70b73 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/custom_list_item.1.dart @@ -0,0 +1,151 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for custom list items. + +void main() => runApp(const CustomListItemApp()); + +class CustomListItemApp extends StatelessWidget { + const CustomListItemApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CustomListItemExample()); + } +} + +class _ArticleDescription extends StatelessWidget { + const _ArticleDescription({ + required this.title, + required this.subtitle, + required this.author, + required this.publishDate, + required this.readDuration, + }); + + final String title; + final String subtitle; + final String author; + final String publishDate; + final String readDuration; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: .start, + children: <Widget>[ + Text( + title, + maxLines: 2, + overflow: .ellipsis, + style: const TextStyle(fontWeight: .bold), + ), + const Padding(padding: .only(bottom: 2.0)), + Expanded( + child: Text( + subtitle, + maxLines: 2, + overflow: .ellipsis, + style: const TextStyle(fontSize: 12.0, color: Colors.black54), + ), + ), + Text( + author, + style: const TextStyle(fontSize: 12.0, color: Colors.black87), + ), + Text( + '$publishDate - $readDuration', + style: const TextStyle(fontSize: 12.0, color: Colors.black54), + ), + ], + ); + } +} + +class CustomListItemTwo extends StatelessWidget { + const CustomListItemTwo({ + super.key, + required this.thumbnail, + required this.title, + required this.subtitle, + required this.author, + required this.publishDate, + required this.readDuration, + }); + + final Widget thumbnail; + final String title; + final String subtitle; + final String author; + final String publishDate; + final String readDuration; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .symmetric(vertical: 10.0), + child: SizedBox( + height: 100, + child: Row( + crossAxisAlignment: .start, + children: <Widget>[ + AspectRatio(aspectRatio: 1.0, child: thumbnail), + Expanded( + child: Padding( + padding: const .fromLTRB(20.0, 0.0, 2.0, 0.0), + child: _ArticleDescription( + title: title, + subtitle: subtitle, + author: author, + publishDate: publishDate, + readDuration: readDuration, + ), + ), + ), + ], + ), + ), + ); + } +} + +class CustomListItemExample extends StatelessWidget { + const CustomListItemExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom List Item Sample')), + body: ListView( + padding: const .all(10.0), + children: <Widget>[ + CustomListItemTwo( + thumbnail: Container( + decoration: const BoxDecoration(color: Colors.pink), + ), + title: 'Flutter 1.0 Launch', + subtitle: + 'Flutter continues to improve and expand its horizons. ' + 'This text should max out at two lines and clip', + author: 'Dash', + publishDate: 'Dec 28', + readDuration: '5 mins', + ), + CustomListItemTwo( + thumbnail: Container( + decoration: const BoxDecoration(color: Colors.blue), + ), + title: 'Flutter 1.2 Release - Continual updates to the framework', + subtitle: 'Flutter once again improves and makes updates.', + author: 'Flutter', + publishDate: 'Feb 26', + readDuration: '12 mins', + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.0.dart b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.0.dart new file mode 100644 index 000000000000..773f34b5a909 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.0.dart @@ -0,0 +1,151 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ListTile]. + +void main() => runApp(const ListTileApp()); + +class ListTileApp extends StatelessWidget { + const ListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + listTileTheme: const ListTileThemeData(textColor: Colors.white), + ), + home: const ListTileExample(), + ); + } +} + +class ListTileExample extends StatefulWidget { + const ListTileExample({super.key}); + + @override + State<ListTileExample> createState() => _ListTileExampleState(); +} + +class _ListTileExampleState extends State<ListTileExample> + with TickerProviderStateMixin { + late final AnimationController _fadeController; + late final AnimationController _sizeController; + late final Animation<double> _fadeAnimation; + late final Animation<double> _sizeAnimation; + + @override + void initState() { + super.initState(); + _fadeController = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(reverse: true); + + _sizeController = AnimationController( + duration: const Duration(milliseconds: 850), + vsync: this, + )..repeat(reverse: true); + + _fadeAnimation = CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + ); + + _sizeAnimation = CurvedAnimation( + parent: _sizeController, + curve: Curves.easeOut, + ); + } + + @override + void dispose() { + _fadeController.dispose(); + _sizeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ListTile Samples')), + body: Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Hero( + tag: 'ListTile-Hero', + // Wrap the ListTile in a Material widget so the ListTile has someplace + // to draw the animated colors during the hero transition. + child: Material( + child: ListTile( + title: const Text('ListTile with Hero'), + subtitle: const Text('Tap here for Hero transition'), + tileColor: Colors.cyan, + onTap: () { + Navigator.push( + context, + MaterialPageRoute<Widget>( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ListTile Hero')), + body: Center( + child: Hero( + tag: 'ListTile-Hero', + child: Material( + child: ListTile( + title: const Text('ListTile with Hero'), + subtitle: const Text('Tap here to go back'), + tileColor: Colors.blue[700], + onTap: () { + Navigator.pop(context); + }, + ), + ), + ), + ), + ); + }, + ), + ); + }, + ), + ), + ), + FadeTransition( + opacity: _fadeAnimation, + // Wrap the ListTile in a Material widget so the ListTile has someplace + // to draw the animated colors during the fade transition. + child: const Material( + child: ListTile( + title: Text('ListTile with FadeTransition'), + selectedTileColor: Colors.green, + selectedColor: Colors.white, + selected: true, + ), + ), + ), + SizedBox( + height: 100, + child: Center( + child: SizeTransition( + sizeFactor: _sizeAnimation, + alignment: .topLeft, + // Wrap the ListTile in a Material widget so the ListTile has someplace + // to draw the animated colors during the size transition. + child: const Material( + child: ListTile( + title: Text('ListTile with SizeTransition'), + tileColor: Colors.red, + minVerticalPadding: 25.0, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.1.dart b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.1.dart new file mode 100644 index 000000000000..2a0689165f53 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.1.dart @@ -0,0 +1,78 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ListTile]. + +void main() => runApp(const ListTileApp()); + +class ListTileApp extends StatelessWidget { + const ListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ListTileExample()); + } +} + +class ListTileExample extends StatelessWidget { + const ListTileExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ListTile Sample')), + body: ListView( + children: const <Widget>[ + Card(child: ListTile(title: Text('One-line ListTile'))), + Card( + child: ListTile( + leading: FlutterLogo(), + title: Text('One-line with leading widget'), + ), + ), + Card( + child: ListTile( + title: Text('One-line with trailing widget'), + trailing: Icon(Icons.more_vert), + ), + ), + Card( + child: ListTile( + leading: FlutterLogo(), + title: Text('One-line with both widgets'), + trailing: Icon(Icons.more_vert), + ), + ), + Card( + child: ListTile( + title: Text('One-line dense ListTile'), + dense: true, + ), + ), + Card( + child: ListTile( + leading: FlutterLogo(size: 56.0), + title: Text('Two-line ListTile'), + subtitle: Text('Here is a second line'), + trailing: Icon(Icons.more_vert), + ), + ), + Card( + child: ListTile( + leading: FlutterLogo(size: 72.0), + title: Text('Three-line ListTile'), + subtitle: Text( + 'A sufficiently long subtitle warrants three lines.', + ), + trailing: Icon(Icons.more_vert), + isThreeLine: true, + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.2.dart b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.2.dart new file mode 100644 index 000000000000..62510e837992 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.2.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ListTile]. + +void main() => runApp(const ListTileApp()); + +class ListTileApp extends StatelessWidget { + const ListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ListTileExample()); + } +} + +class ListTileExample extends StatelessWidget { + const ListTileExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ListTile Sample')), + body: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(child: Text('A')), + title: Text('Headline'), + subtitle: Text('Supporting text'), + trailing: Icon(Icons.favorite_rounded), + ), + Divider(height: 0), + ListTile( + leading: CircleAvatar(child: Text('B')), + title: Text('Headline'), + subtitle: Text( + 'Longer supporting text to demonstrate how the text wraps and how the leading and trailing widgets are centered vertically with the text.', + ), + trailing: Icon(Icons.favorite_rounded), + ), + Divider(height: 0), + ListTile( + leading: CircleAvatar(child: Text('C')), + title: Text('Headline'), + subtitle: Text( + "Longer supporting text to demonstrate how the text wraps and how setting 'ListTile.isThreeLine = true' aligns leading and trailing widgets to the top vertically with the text.", + ), + trailing: Icon(Icons.favorite_rounded), + isThreeLine: true, + ), + Divider(height: 0), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.3.dart b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.3.dart new file mode 100644 index 000000000000..13cd4e78d2db --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.3.dart @@ -0,0 +1,78 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ListTile]. + +void main() => runApp(const ListTileApp()); + +class ListTileApp extends StatelessWidget { + const ListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ListTileExample()); + } +} + +class ListTileExample extends StatefulWidget { + const ListTileExample({super.key}); + + @override + State<ListTileExample> createState() => _ListTileExampleState(); +} + +class _ListTileExampleState extends State<ListTileExample> { + bool _selected = false; + bool _enabled = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ListTile Sample')), + body: Center( + child: ListTile( + enabled: _enabled, + selected: _selected, + onTap: () { + setState(() { + // This is called when the user toggles the switch. + _selected = !_selected; + }); + }, + iconColor: + const WidgetStateColor.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.disabled: Colors.red, + WidgetState.selected: Colors.green, + WidgetState.any: Colors.black, + }), + // The same can be achieved using the .resolveWith() constructor. + // The text color will be identical to the icon color above. + textColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.red; + } + if (states.contains(WidgetState.selected)) { + return Colors.green; + } + return Colors.black; + }), + leading: const Icon(Icons.person), + title: const Text('Headline'), + subtitle: Text('Enabled: $_enabled, Selected: $_selected'), + trailing: Switch( + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + _enabled = value; + }); + }, + value: _enabled, + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.4.dart b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.4.dart new file mode 100644 index 000000000000..0b1b4f88c54d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.4.dart @@ -0,0 +1,80 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ListTile]. + +void main() => runApp(const ListTileApp()); + +class ListTileApp extends StatelessWidget { + const ListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ListTileExample()); + } +} + +class ListTileExample extends StatefulWidget { + const ListTileExample({super.key}); + + @override + State<ListTileExample> createState() => _ListTileExampleState(); +} + +class _ListTileExampleState extends State<ListTileExample> { + ListTileTitleAlignment? titleAlignment; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('ListTile.titleAlignment Sample')), + body: Column( + children: <Widget>[ + const Divider(), + ListTile( + titleAlignment: titleAlignment, + leading: Checkbox(value: true, onChanged: (bool? value) {}), + title: const Text('Headline Text'), + subtitle: const Text( + 'Tapping on the trailing widget will show a menu that allows you to change the title alignment. The title alignment is set to threeLine by default if `ThemeData.useMaterial3` is true. Otherwise, defaults to titleHeight.', + ), + trailing: PopupMenuButton<ListTileTitleAlignment>( + onSelected: (ListTileTitleAlignment? value) { + setState(() { + titleAlignment = value; + }); + }, + itemBuilder: (BuildContext context) => + <PopupMenuEntry<ListTileTitleAlignment>>[ + const PopupMenuItem<ListTileTitleAlignment>( + value: ListTileTitleAlignment.threeLine, + child: Text('threeLine'), + ), + const PopupMenuItem<ListTileTitleAlignment>( + value: ListTileTitleAlignment.titleHeight, + child: Text('titleHeight'), + ), + const PopupMenuItem<ListTileTitleAlignment>( + value: ListTileTitleAlignment.top, + child: Text('top'), + ), + const PopupMenuItem<ListTileTitleAlignment>( + value: ListTileTitleAlignment.center, + child: Text('center'), + ), + const PopupMenuItem<ListTileTitleAlignment>( + value: ListTileTitleAlignment.bottom, + child: Text('bottom'), + ), + ], + ), + ), + const Divider(), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.selected.0.dart b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.selected.0.dart new file mode 100644 index 000000000000..61f6269e8e8a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/list_tile/list_tile.selected.0.dart @@ -0,0 +1,50 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ListTile.selected]. + +void main() => runApp(const ListTileApp()); + +class ListTileApp extends StatelessWidget { + const ListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ListTileExample()); + } +} + +class ListTileExample extends StatefulWidget { + const ListTileExample({super.key}); + + @override + State<ListTileExample> createState() => _ListTileExampleState(); +} + +class _ListTileExampleState extends State<ListTileExample> { + int _selectedIndex = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom List Item Sample')), + body: ListView.builder( + itemCount: 10, + itemBuilder: (BuildContext context, int index) { + return ListTile( + title: Text('Item $index'), + selected: index == _selectedIndex, + onTap: () { + setState(() { + _selectedIndex = index; + }); + }, + ); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/material_state/material_state_border_side.0.dart b/packages/material_ui/material_ui_examples/lib/material_state/material_state_border_side.0.dart new file mode 100644 index 000000000000..bc20150da8d8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/material_state/material_state_border_side.0.dart @@ -0,0 +1,55 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [WidgetStateBorderSide]. + +void main() => runApp(const MaterialStateBorderSideExampleApp()); + +class MaterialStateBorderSideExampleApp extends StatelessWidget { + const MaterialStateBorderSideExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('MaterialStateBorderSide Sample')), + body: const Center(child: MaterialStateBorderSideExample()), + ), + ); + } +} + +class MaterialStateBorderSideExample extends StatefulWidget { + const MaterialStateBorderSideExample({super.key}); + + @override + State<MaterialStateBorderSideExample> createState() => + _MaterialStateBorderSideExampleState(); +} + +class _MaterialStateBorderSideExampleState + extends State<MaterialStateBorderSideExample> { + bool isSelected = true; + + @override + Widget build(BuildContext context) { + return FilterChip( + label: const Text('Select chip'), + selected: isSelected, + onSelected: (bool value) { + setState(() { + isSelected = value; + }); + }, + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const BorderSide(color: Colors.red); + } + return null; // Defer to default value on the theme or widget. + }), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/material_state/material_state_mouse_cursor.0.dart b/packages/material_ui/material_ui_examples/lib/material_state/material_state_mouse_cursor.0.dart new file mode 100644 index 000000000000..996ceaae2f51 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/material_state/material_state_mouse_cursor.0.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [WidgetStateMouseCursor]. + +void main() => runApp(const MaterialStateMouseCursorExampleApp()); + +class MaterialStateMouseCursorExampleApp extends StatelessWidget { + const MaterialStateMouseCursorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('MaterialStateMouseCursor Sample')), + body: const Center( + child: MaterialStateMouseCursorExample( + // TRY THIS: Switch to get a different mouse cursor while hovering ListTile. + enabled: false, + ), + ), + ), + ); + } +} + +class ListTileCursor extends WidgetStateMouseCursor { + const ListTileCursor(); + + @override + MouseCursor resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.forbidden; + } + return SystemMouseCursors.click; + } + + @override + String get debugDescription => 'ListTileCursor()'; +} + +class MaterialStateMouseCursorExample extends StatelessWidget { + const MaterialStateMouseCursorExample({required this.enabled, super.key}); + + final bool enabled; + + @override + Widget build(BuildContext context) { + return ListTile( + title: const Text('ListTile'), + enabled: enabled, + onTap: () {}, + mouseCursor: const ListTileCursor(), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/material_state/material_state_property.0.dart b/packages/material_ui/material_ui_examples/lib/material_state/material_state_property.0.dart new file mode 100644 index 000000000000..3686d1b7ab35 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/material_state/material_state_property.0.dart @@ -0,0 +1,50 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [WidgetStateProperty]. + +void main() => runApp(const MaterialStatePropertyExampleApp()); + +class MaterialStatePropertyExampleApp extends StatelessWidget { + const MaterialStatePropertyExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('MaterialStateProperty Sample')), + body: const Center(child: MaterialStatePropertyExample()), + ), + ); + } +} + +class MaterialStatePropertyExample extends StatelessWidget { + const MaterialStatePropertyExample({super.key}); + + @override + Widget build(BuildContext context) { + Color getColor(Set<WidgetState> states) { + const Set<WidgetState> interactiveStates = <WidgetState>{ + WidgetState.pressed, + WidgetState.hovered, + WidgetState.focused, + }; + if (states.any(interactiveStates.contains)) { + return Colors.blue; + } + return Colors.red; + } + + return TextButton( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith(getColor), + ), + onPressed: () {}, + child: const Text('TextButton'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/checkbox_menu_button.0.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/checkbox_menu_button.0.dart new file mode 100644 index 000000000000..e12eadea2614 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/checkbox_menu_button.0.dart @@ -0,0 +1,118 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [CheckboxMenuButton]. + +void main() => runApp(const MenuApp()); + +class MyCheckboxMenu extends StatefulWidget { + const MyCheckboxMenu({super.key, required this.message}); + + final String message; + + @override + State<MyCheckboxMenu> createState() => _MyCheckboxMenuState(); +} + +class _MyCheckboxMenuState extends State<MyCheckboxMenu> { + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + static const SingleActivator _showShortcut = SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ); + bool _showingMessage = false; + + @override + void dispose() { + _buttonFocusNode.dispose(); + super.dispose(); + } + + void _setMessageVisibility(bool visible) { + setState(() { + _showingMessage = visible; + }); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: <ShortcutActivator, VoidCallback>{ + _showShortcut: () { + _setMessageVisibility(!_showingMessage); + }, + }, + child: Column( + crossAxisAlignment: .start, + children: <Widget>[ + MenuAnchor( + childFocusNode: _buttonFocusNode, + menuChildren: <Widget>[ + CheckboxMenuButton( + value: _showingMessage, + onChanged: (bool? value) { + _setMessageVisibility(value!); + }, + child: const Text('Show Message'), + ), + ], + builder: + ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return TextButton( + focusNode: _buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ), + Expanded( + child: Container( + alignment: .center, + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Padding( + padding: const .all(12.0), + child: Text( + _showingMessage ? widget.message : '', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class MenuApp extends StatelessWidget { + const MenuApp({super.key}); + + static const String kMessage = '"Talk less. Smile more." - A. Burr'; + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: SafeArea(child: MyCheckboxMenu(message: kMessage)), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_accelerator_label.0.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_accelerator_label.0.dart new file mode 100644 index 000000000000..b5b559ea2e4f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_accelerator_label.0.dart @@ -0,0 +1,111 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [MenuAcceleratorLabel]. + +void main() => runApp(const MenuAcceleratorApp()); + +class MyMenuBar extends StatelessWidget { + const MyMenuBar({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + Row( + mainAxisSize: .min, + children: <Widget>[ + Expanded( + child: MenuBar( + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton( + onPressed: () { + showAboutDialog( + context: context, + applicationName: 'MenuBar Sample', + applicationVersion: '1.0.0', + ); + }, + child: const MenuAcceleratorLabel('&About'), + ), + MenuItemButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Saved!')), + ); + }, + child: const MenuAcceleratorLabel('&Save'), + ), + MenuItemButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Quit!')), + ); + }, + child: const MenuAcceleratorLabel('&Quit'), + ), + ], + child: const MenuAcceleratorLabel('&File'), + ), + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Magnify!')), + ); + }, + child: const MenuAcceleratorLabel('&Magnify'), + ), + MenuItemButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Minify!')), + ); + }, + child: const MenuAcceleratorLabel('Mi&nify'), + ), + ], + child: const MenuAcceleratorLabel('&View'), + ), + ], + ), + ), + ], + ), + Expanded( + child: FlutterLogo( + size: MediaQuery.of(context).size.shortestSide * 0.5, + ), + ), + ], + ); + } +} + +class MenuAcceleratorApp extends StatelessWidget { + const MenuAcceleratorApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Shortcuts( + shortcuts: <ShortcutActivator, Intent>{ + const SingleActivator( + LogicalKeyboardKey.keyT, + control: true, + ): VoidCallbackIntent(() { + debugDumpApp(); + }), + }, + child: const Scaffold(body: SafeArea(child: MyMenuBar())), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.0.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.0.dart new file mode 100644 index 000000000000..db1d27268db4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.0.dart @@ -0,0 +1,242 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [MenuAnchor]. + +void main() => runApp(const MenuApp()); + +/// An enhanced enum to define the available menus and their shortcuts. +/// +/// Using an enum for menu definition is not required, but this illustrates how +/// they could be used for simple menu systems. +enum MenuEntry { + about('About'), + showMessage( + 'Show Message', + SingleActivator(LogicalKeyboardKey.keyS, control: true), + ), + hideMessage( + 'Hide Message', + SingleActivator(LogicalKeyboardKey.keyS, control: true), + ), + colorMenu('Color Menu'), + colorRed( + 'Red Background', + SingleActivator(LogicalKeyboardKey.keyR, control: true), + ), + colorGreen( + 'Green Background', + SingleActivator(LogicalKeyboardKey.keyG, control: true), + ), + colorBlue( + 'Blue Background', + SingleActivator(LogicalKeyboardKey.keyB, control: true), + ); + + const MenuEntry(this.label, [this.shortcut]); + final String label; + final MenuSerializableShortcut? shortcut; +} + +class MyCascadingMenu extends StatefulWidget { + const MyCascadingMenu({super.key, required this.message}); + + final String message; + + @override + State<MyCascadingMenu> createState() => _MyCascadingMenuState(); +} + +class _MyCascadingMenuState extends State<MyCascadingMenu> { + MenuEntry? _lastSelection; + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + ShortcutRegistryEntry? _shortcutsEntry; + AnimationStatus _animationStatus = .dismissed; + + Color get backgroundColor => _backgroundColor; + Color _backgroundColor = Colors.red; + set backgroundColor(Color value) { + if (_backgroundColor != value) { + setState(() { + _backgroundColor = value; + }); + } + } + + bool get showingMessage => _showingMessage; + bool _showingMessage = false; + set showingMessage(bool value) { + if (_showingMessage != value) { + setState(() { + _showingMessage = value; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Dispose of any previously registered shortcuts, since they are about to + // be replaced. + _shortcutsEntry?.dispose(); + // Collect the shortcuts from the different menu selections so that they can + // be registered to apply to the entire app. Menus don't register their + // shortcuts, they only display the shortcut hint text. + final Map<ShortcutActivator, Intent> shortcuts = + <ShortcutActivator, Intent>{ + for (final MenuEntry item in MenuEntry.values) + if (item.shortcut != null) + item.shortcut!: VoidCallbackIntent(() => _activate(item)), + }; + // Register the shortcuts with the ShortcutRegistry so that they are + // available to the entire application. + _shortcutsEntry = ShortcutRegistry.of(context).addAll(shortcuts); + } + + @override + void dispose() { + _shortcutsEntry?.dispose(); + _buttonFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: .start, + children: <Widget>[ + MenuAnchor( + animated: true, + onAnimationStatusChanged: (AnimationStatus status) { + // Store the animation status so that it can be used to determine + // whether the menu is opening or closing when the button is + // pressed. + _animationStatus = status; + }, + childFocusNode: _buttonFocusNode, + menuChildren: <Widget>[ + MenuItemButton( + child: Text(MenuEntry.about.label), + onPressed: () => _activate(MenuEntry.about), + ), + if (_showingMessage) + MenuItemButton( + onPressed: () => _activate(MenuEntry.hideMessage), + shortcut: MenuEntry.hideMessage.shortcut, + child: Text(MenuEntry.hideMessage.label), + ), + if (!_showingMessage) + MenuItemButton( + onPressed: () => _activate(MenuEntry.showMessage), + shortcut: MenuEntry.showMessage.shortcut, + child: Text(MenuEntry.showMessage.label), + ), + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: () => _activate(MenuEntry.colorRed), + shortcut: MenuEntry.colorRed.shortcut, + child: Text(MenuEntry.colorRed.label), + ), + MenuItemButton( + onPressed: () => _activate(MenuEntry.colorGreen), + shortcut: MenuEntry.colorGreen.shortcut, + child: Text(MenuEntry.colorGreen.label), + ), + MenuItemButton( + onPressed: () => _activate(MenuEntry.colorBlue), + shortcut: MenuEntry.colorBlue.shortcut, + child: Text(MenuEntry.colorBlue.label), + ), + ], + child: const Text('Background Color'), + ), + ], + builder: + (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: _buttonFocusNode, + onPressed: () { + if (_animationStatus.isForwardOrCompleted) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ), + Expanded( + child: Container( + alignment: .center, + color: backgroundColor, + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Padding( + padding: const .all(12.0), + child: Text( + showingMessage ? widget.message : '', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Text( + _lastSelection != null + ? 'Last Selected: ${_lastSelection!.label}' + : '', + ), + ], + ), + ), + ), + ], + ); + } + + void _activate(MenuEntry selection) { + setState(() { + _lastSelection = selection; + }); + + switch (selection) { + case MenuEntry.about: + showAboutDialog( + context: context, + applicationName: 'MenuBar Sample', + applicationVersion: '1.0.0', + ); + case MenuEntry.hideMessage: + case MenuEntry.showMessage: + showingMessage = !showingMessage; + case MenuEntry.colorMenu: + break; + case MenuEntry.colorRed: + backgroundColor = Colors.red; + case MenuEntry.colorGreen: + backgroundColor = Colors.green; + case MenuEntry.colorBlue: + backgroundColor = Colors.blue; + } + } +} + +class MenuApp extends StatelessWidget { + const MenuApp({super.key}); + + static const String kMessage = '"Talk less. Smile more." - A. Burr'; + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: SafeArea(child: MyCascadingMenu(message: kMessage)), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.1.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.1.dart new file mode 100644 index 000000000000..77e57bfbaf5c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.1.dart @@ -0,0 +1,287 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [MenuAnchor]. + +void main() => runApp(const ContextMenuApp()); + +/// An enhanced enum to define the available menus and their shortcuts. +/// +/// Using an enum for menu definition is not required, but this illustrates how +/// they could be used for simple menu systems. +enum MenuEntry { + about('About'), + showMessage( + 'Show Message', + SingleActivator(LogicalKeyboardKey.keyS, control: true), + ), + hideMessage( + 'Hide Message', + SingleActivator(LogicalKeyboardKey.keyS, control: true), + ), + colorMenu('Color Menu'), + colorRed( + 'Red Background', + SingleActivator(LogicalKeyboardKey.keyR, control: true), + ), + colorGreen( + 'Green Background', + SingleActivator(LogicalKeyboardKey.keyG, control: true), + ), + colorBlue( + 'Blue Background', + SingleActivator(LogicalKeyboardKey.keyB, control: true), + ); + + const MenuEntry(this.label, [this.shortcut]); + final String label; + final MenuSerializableShortcut? shortcut; +} + +class MyContextMenu extends StatefulWidget { + const MyContextMenu({super.key, required this.message}); + + final String message; + + @override + State<MyContextMenu> createState() => _MyContextMenuState(); +} + +class _MyContextMenuState extends State<MyContextMenu> { + MenuEntry? _lastSelection; + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + final MenuController _menuController = MenuController(); + ShortcutRegistryEntry? _shortcutsEntry; + bool _menuWasEnabled = false; + + Color get backgroundColor => _backgroundColor; + Color _backgroundColor = Colors.red; + set backgroundColor(Color value) { + if (_backgroundColor != value) { + setState(() { + _backgroundColor = value; + }); + } + } + + bool get showingMessage => _showingMessage; + bool _showingMessage = false; + set showingMessage(bool value) { + if (_showingMessage != value) { + setState(() { + _showingMessage = value; + }); + } + } + + @override + void initState() { + super.initState(); + _disableContextMenu(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Dispose of any previously registered shortcuts, since they are about to + // be replaced. + _shortcutsEntry?.dispose(); + // Collect the shortcuts from the different menu selections so that they can + // be registered to apply to the entire app. Menus don't register their + // shortcuts, they only display the shortcut hint text. + final Map<ShortcutActivator, Intent> shortcuts = + <ShortcutActivator, Intent>{ + for (final MenuEntry item in MenuEntry.values) + if (item.shortcut != null) + item.shortcut!: VoidCallbackIntent(() => _activate(item)), + }; + // Register the shortcuts with the ShortcutRegistry so that they are + // available to the entire application. + _shortcutsEntry = ShortcutRegistry.of(context).addAll(shortcuts); + } + + @override + void dispose() { + _shortcutsEntry?.dispose(); + _buttonFocusNode.dispose(); + _reenableContextMenu(); + super.dispose(); + } + + Future<void> _disableContextMenu() async { + if (!kIsWeb) { + // Does nothing on non-web platforms. + return; + } + _menuWasEnabled = BrowserContextMenu.enabled; + if (_menuWasEnabled) { + await BrowserContextMenu.disableContextMenu(); + } + } + + void _reenableContextMenu() { + if (!kIsWeb) { + // Does nothing on non-web platforms. + return; + } + if (_menuWasEnabled && !BrowserContextMenu.enabled) { + BrowserContextMenu.enableContextMenu(); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .all(50), + child: GestureDetector( + onTapDown: _handleTapDown, + onSecondaryTapDown: _handleSecondaryTapDown, + child: MenuAnchor( + animated: true, + controller: _menuController, + menuChildren: <Widget>[ + MenuItemButton( + child: Text(MenuEntry.about.label), + onPressed: () => _activate(MenuEntry.about), + ), + if (_showingMessage) + MenuItemButton( + onPressed: () => _activate(MenuEntry.hideMessage), + shortcut: MenuEntry.hideMessage.shortcut, + child: Text(MenuEntry.hideMessage.label), + ), + if (!_showingMessage) + MenuItemButton( + onPressed: () => _activate(MenuEntry.showMessage), + shortcut: MenuEntry.showMessage.shortcut, + child: Text(MenuEntry.showMessage.label), + ), + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: () => _activate(MenuEntry.colorRed), + shortcut: MenuEntry.colorRed.shortcut, + child: Text(MenuEntry.colorRed.label), + ), + MenuItemButton( + onPressed: () => _activate(MenuEntry.colorGreen), + shortcut: MenuEntry.colorGreen.shortcut, + child: Text(MenuEntry.colorGreen.label), + ), + MenuItemButton( + onPressed: () => _activate(MenuEntry.colorBlue), + shortcut: MenuEntry.colorBlue.shortcut, + child: Text(MenuEntry.colorBlue.label), + ), + ], + child: const Text('Background Color'), + ), + ], + child: Container( + alignment: .center, + color: backgroundColor, + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + const Padding( + padding: .all(8.0), + child: Text( + 'Right-click anywhere on the background to show the menu.', + ), + ), + Padding( + padding: const .all(12.0), + child: Text( + showingMessage ? widget.message : '', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Text( + _lastSelection != null + ? 'Last Selected: ${_lastSelection!.label}' + : '', + ), + ], + ), + ), + ), + ), + ); + } + + void _activate(MenuEntry selection) { + setState(() { + _lastSelection = selection; + }); + switch (selection) { + case MenuEntry.about: + showAboutDialog( + context: context, + applicationName: 'MenuBar Sample', + applicationVersion: '1.0.0', + ); + case MenuEntry.showMessage: + case MenuEntry.hideMessage: + showingMessage = !showingMessage; + case MenuEntry.colorMenu: + break; + case MenuEntry.colorRed: + backgroundColor = Colors.red; + case MenuEntry.colorGreen: + backgroundColor = Colors.green; + case MenuEntry.colorBlue: + backgroundColor = Colors.blue; + } + } + + void _handleSecondaryTapDown(TapDownDetails details) { + _menuController.open(position: details.localPosition); + } + + void _handleTapDown(TapDownDetails details) { + if (_menuController.isOpen) { + _menuController.close(); + return; + } + switch (defaultTargetPlatform) { + case .android: + case .fuchsia: + case .linux: + case .windows: + // Don't open the menu on these platforms with a Ctrl-tap (or a + // tap). + break; + case .iOS: + case .macOS: + // Only open the menu on these platforms if the control button is down + // when the tap occurs. + if (HardwareKeyboard.instance.logicalKeysPressed.contains( + LogicalKeyboardKey.controlLeft, + ) || + HardwareKeyboard.instance.logicalKeysPressed.contains( + LogicalKeyboardKey.controlRight, + )) { + _menuController.open(position: details.localPosition); + } + } + } +} + +class ContextMenuApp extends StatelessWidget { + const ContextMenuApp({super.key}); + + static const String kMessage = '"Talk less. Smile more." - A. Burr'; + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: MyContextMenu(message: kMessage)), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.2.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.2.dart new file mode 100644 index 000000000000..7256eca2c8e0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.2.dart @@ -0,0 +1,68 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [MenuAnchor]. + +void main() => runApp(const MenuAnchorApp()); + +// This is the type used by the menu below. +enum SampleItem { itemOne, itemTwo, itemThree } + +class MenuAnchorApp extends StatelessWidget { + const MenuAnchorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: MenuAnchorExample()); + } +} + +class MenuAnchorExample extends StatefulWidget { + const MenuAnchorExample({super.key}); + + @override + State<MenuAnchorExample> createState() => _MenuAnchorExampleState(); +} + +class _MenuAnchorExampleState extends State<MenuAnchorExample> { + SampleItem? selectedMenu; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('MenuAnchorButton'), + backgroundColor: Theme.of(context).primaryColorLight, + ), + body: Center( + child: MenuAnchor( + builder: + (BuildContext context, MenuController controller, Widget? child) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_horiz), + tooltip: 'Show menu', + ); + }, + menuChildren: List<MenuItemButton>.generate( + 3, + (int index) => MenuItemButton( + onPressed: () => + setState(() => selectedMenu = SampleItem.values[index]), + child: Text('Item ${index + 1}'), + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.3.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.3.dart new file mode 100644 index 000000000000..319ad4070828 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_anchor.3.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SimpleCascadingMenuApp]. + +void main() => runApp(const SimpleCascadingMenuApp()); + +/// A Simple Cascading Menu example using the [MenuAnchor] Widget. +class MyCascadingMenu extends StatefulWidget { + const MyCascadingMenu({super.key}); + + @override + State<MyCascadingMenu> createState() => _MyCascadingMenuState(); +} + +class _MyCascadingMenuState extends State<MyCascadingMenu> { + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + + @override + void dispose() { + _buttonFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MenuAnchor( + childFocusNode: _buttonFocusNode, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Revert')), + MenuItemButton(onPressed: () {}, child: const Text('Setting')), + MenuItemButton(onPressed: () {}, child: const Text('Send Feedback')), + ], + builder: (_, MenuController controller, Widget? child) { + return IconButton( + focusNode: _buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert), + ); + }, + ); + } +} + +/// Top Level Application Widget. +class SimpleCascadingMenuApp extends StatelessWidget { + const SimpleCascadingMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + appBar: AppBar( + title: const Text('MenuAnchor Simple Example'), + actions: const <Widget>[MyCascadingMenu()], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_bar.0.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_bar.0.dart new file mode 100644 index 000000000000..5cb840eb200a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/menu_bar.0.dart @@ -0,0 +1,269 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [MenuBar]. + +void main() => runApp(const MenuBarApp()); + +/// A class for consolidating the definition of menu entries. +/// +/// This sort of class is not required, but illustrates one way that defining +/// menus could be done. +class MenuEntry { + const MenuEntry({ + required this.label, + this.shortcut, + this.onPressed, + this.menuChildren, + }) : assert( + menuChildren == null || onPressed == null, + 'onPressed is ignored if menuChildren are provided', + ); + final String label; + + final MenuSerializableShortcut? shortcut; + final VoidCallback? onPressed; + final List<MenuEntry>? menuChildren; + + static List<Widget> build( + List<MenuEntry> selections, [ + Duration hoverOpenDelay = .zero, + ]) { + Widget buildSelection(MenuEntry selection) { + if (selection.menuChildren != null) { + return SubmenuButton( + menuChildren: MenuEntry.build( + selection.menuChildren!, + const Duration(milliseconds: 150), + ), + child: Text(selection.label), + ); + } + return MenuItemButton( + shortcut: selection.shortcut, + onPressed: selection.onPressed, + child: Text(selection.label), + ); + } + + return selections.map<Widget>(buildSelection).toList(); + } + + static Map<MenuSerializableShortcut, Intent> shortcuts( + List<MenuEntry> selections, + ) { + final Map<MenuSerializableShortcut, Intent> result = + <MenuSerializableShortcut, Intent>{}; + for (final MenuEntry selection in selections) { + if (selection.menuChildren != null) { + result.addAll(MenuEntry.shortcuts(selection.menuChildren!)); + } else { + if (selection.shortcut != null && selection.onPressed != null) { + result[selection.shortcut!] = VoidCallbackIntent( + selection.onPressed!, + ); + } + } + } + return result; + } +} + +class MyMenuBar extends StatefulWidget { + const MyMenuBar({super.key, required this.message}); + + final String message; + + @override + State<MyMenuBar> createState() => _MyMenuBarState(); +} + +class _MyMenuBarState extends State<MyMenuBar> { + ShortcutRegistryEntry? _shortcutsEntry; + String? _lastSelection; + + Color get backgroundColor => _backgroundColor; + Color _backgroundColor = Colors.red; + set backgroundColor(Color value) { + if (_backgroundColor != value) { + setState(() { + _backgroundColor = value; + }); + } + } + + bool get showingMessage => _showMessage; + bool _showMessage = false; + set showingMessage(bool value) { + if (_showMessage != value) { + setState(() { + _showMessage = value; + }); + } + } + + @override + void dispose() { + _shortcutsEntry?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + Row( + mainAxisSize: .min, + children: <Widget>[ + Expanded(child: MenuBar(children: MenuEntry.build(_getMenus()))), + ], + ), + Expanded( + child: Container( + alignment: .center, + color: backgroundColor, + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Padding( + padding: const .all(12.0), + child: Text( + showingMessage ? widget.message : '', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Text( + _lastSelection != null + ? 'Last Selected: $_lastSelection' + : '', + ), + ], + ), + ), + ), + ], + ); + } + + List<MenuEntry> _getMenus() { + final List<MenuEntry> result = <MenuEntry>[ + MenuEntry( + label: 'Menu Demo', + menuChildren: <MenuEntry>[ + MenuEntry( + label: 'About', + onPressed: () { + showAboutDialog( + context: context, + applicationName: 'MenuBar Sample', + applicationVersion: '1.0.0', + ); + setState(() { + _lastSelection = 'About'; + }); + }, + ), + MenuEntry( + label: showingMessage ? 'Hide Message' : 'Show Message', + onPressed: () { + setState(() { + _lastSelection = showingMessage + ? 'Hide Message' + : 'Show Message'; + showingMessage = !showingMessage; + }); + }, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ), + ), + // Hides the message, but is only enabled if the message isn't + // already hidden. + MenuEntry( + label: 'Reset Message', + onPressed: showingMessage + ? () { + setState(() { + _lastSelection = 'Reset Message'; + showingMessage = false; + }); + } + : null, + shortcut: const SingleActivator(LogicalKeyboardKey.escape), + ), + MenuEntry( + label: 'Background Color', + menuChildren: <MenuEntry>[ + MenuEntry( + label: 'Red Background', + onPressed: () { + setState(() { + _lastSelection = 'Red Background'; + backgroundColor = Colors.red; + }); + }, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ), + ), + MenuEntry( + label: 'Green Background', + onPressed: () { + setState(() { + _lastSelection = 'Green Background'; + backgroundColor = Colors.green; + }); + }, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyG, + control: true, + ), + ), + MenuEntry( + label: 'Blue Background', + onPressed: () { + setState(() { + _lastSelection = 'Blue Background'; + backgroundColor = Colors.blue; + }); + }, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + ), + ), + ], + ), + ], + ), + ]; + // (Re-)register the shortcuts with the ShortcutRegistry so that they are + // available to the entire application, and update them if they've changed. + _shortcutsEntry?.dispose(); + _shortcutsEntry = ShortcutRegistry.of( + context, + ).addAll(MenuEntry.shortcuts(result)); + return result; + } +} + +class MenuBarApp extends StatelessWidget { + const MenuBarApp({super.key}); + + static const String kMessage = '"Talk less. Smile more." - A. Burr'; + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: SafeArea(child: MyMenuBar(message: kMessage)), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/menu_anchor/radio_menu_button.0.dart b/packages/material_ui/material_ui_examples/lib/menu_anchor/radio_menu_button.0.dart new file mode 100644 index 000000000000..0bbfb0f934c8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/menu_anchor/radio_menu_button.0.dart @@ -0,0 +1,125 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [RadioMenuButton]. + +void main() => runApp(const MenuApp()); + +class MyRadioMenu extends StatefulWidget { + const MyRadioMenu({super.key}); + + @override + State<MyRadioMenu> createState() => _MyRadioMenuState(); +} + +class _MyRadioMenuState extends State<MyRadioMenu> { + final FocusNode _buttonFocusNode = FocusNode(debugLabel: 'Menu Button'); + Color _backgroundColor = Colors.red; + late ShortcutRegistryEntry _entry; + + static const SingleActivator _redShortcut = SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ); + static const SingleActivator _greenShortcut = SingleActivator( + LogicalKeyboardKey.keyG, + control: true, + ); + static const SingleActivator _blueShortcut = SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _entry = ShortcutRegistry.of(context).addAll(< + ShortcutActivator, + VoidCallbackIntent + >{ + _redShortcut: VoidCallbackIntent(() => _setBackgroundColor(Colors.red)), + _greenShortcut: VoidCallbackIntent( + () => _setBackgroundColor(Colors.green), + ), + _blueShortcut: VoidCallbackIntent(() => _setBackgroundColor(Colors.blue)), + }); + } + + @override + void dispose() { + _buttonFocusNode.dispose(); + _entry.dispose(); + super.dispose(); + } + + void _setBackgroundColor(Color? color) { + setState(() { + _backgroundColor = color!; + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: .start, + children: <Widget>[ + MenuAnchor( + childFocusNode: _buttonFocusNode, + menuChildren: <Widget>[ + RadioMenuButton<Color>( + value: Colors.red, + shortcut: _redShortcut, + groupValue: _backgroundColor, + onChanged: _setBackgroundColor, + child: const Text('Red Background'), + ), + RadioMenuButton<Color>( + value: Colors.green, + shortcut: _greenShortcut, + groupValue: _backgroundColor, + onChanged: _setBackgroundColor, + child: const Text('Green Background'), + ), + RadioMenuButton<Color>( + value: Colors.blue, + shortcut: _blueShortcut, + groupValue: _backgroundColor, + onChanged: _setBackgroundColor, + child: const Text('Blue Background'), + ), + ], + builder: + (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: _buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ), + Expanded(child: Container(color: _backgroundColor)), + ], + ); + } +} + +class MenuApp extends StatelessWidget { + const MenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: SafeArea(child: MyRadioMenu())), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.0.dart b/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.0.dart new file mode 100644 index 000000000000..d49005814fbd --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.0.dart @@ -0,0 +1,139 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [NavigationBar]. + +void main() => runApp(const NavigationBarApp()); + +class NavigationBarApp extends StatelessWidget { + const NavigationBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: NavigationExample()); + } +} + +class NavigationExample extends StatefulWidget { + const NavigationExample({super.key}); + + @override + State<NavigationExample> createState() => _NavigationExampleState(); +} + +class _NavigationExampleState extends State<NavigationExample> { + int currentPageIndex = 0; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Scaffold( + bottomNavigationBar: NavigationBar( + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + indicatorColor: Colors.amber, + selectedIndex: currentPageIndex, + destinations: const <Widget>[ + NavigationDestination( + selectedIcon: Icon(Icons.home), + icon: Icon(Icons.home_outlined), + label: 'Home', + ), + NavigationDestination( + icon: Badge(child: Icon(Icons.notifications_sharp)), + label: 'Notifications', + ), + NavigationDestination( + icon: Badge(label: Text('2'), child: Icon(Icons.messenger_sharp)), + label: 'Messages', + ), + ], + ), + body: <Widget>[ + /// Home page + Card( + shadowColor: Colors.transparent, + margin: const .all(8.0), + child: SizedBox.expand( + child: Center( + child: Text('Home page', style: theme.textTheme.titleLarge), + ), + ), + ), + + /// Notifications page + const Padding( + padding: .all(8.0), + child: Column( + children: <Widget>[ + Card( + child: ListTile( + leading: Icon(Icons.notifications_sharp), + title: Text('Notification 1'), + subtitle: Text('This is a notification'), + ), + ), + Card( + child: ListTile( + leading: Icon(Icons.notifications_sharp), + title: Text('Notification 2'), + subtitle: Text('This is a notification'), + ), + ), + ], + ), + ), + + /// Messages page + ListView.builder( + reverse: true, + itemCount: 2, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return Align( + alignment: .centerRight, + child: Container( + margin: const .all(8.0), + padding: const .all(8.0), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: .circular(8.0), + ), + child: Text( + 'Hello', + style: theme.textTheme.bodyLarge!.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ); + } + return Align( + alignment: .centerLeft, + child: Container( + margin: const .all(8.0), + padding: const .all(8.0), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: .circular(8.0), + ), + child: Text( + 'Hi!', + style: theme.textTheme.bodyLarge!.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ), + ); + }, + ), + ][currentPageIndex], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.1.dart b/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.1.dart new file mode 100644 index 000000000000..2b9d8b8331a9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.1.dart @@ -0,0 +1,97 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [NavigationBar]. + +void main() => runApp(const NavigationBarApp()); + +class NavigationBarApp extends StatelessWidget { + const NavigationBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: NavigationExample()); + } +} + +class NavigationExample extends StatefulWidget { + const NavigationExample({super.key}); + + @override + State<NavigationExample> createState() => _NavigationExampleState(); +} + +class _NavigationExampleState extends State<NavigationExample> { + int currentPageIndex = 0; + NavigationDestinationLabelBehavior labelBehavior = .alwaysShow; + + @override + Widget build(BuildContext context) { + return Scaffold( + bottomNavigationBar: NavigationBar( + labelBehavior: labelBehavior, + selectedIndex: currentPageIndex, + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.explore), label: 'Explore'), + NavigationDestination(icon: Icon(Icons.commute), label: 'Commute'), + NavigationDestination( + selectedIcon: Icon(Icons.bookmark), + icon: Icon(Icons.bookmark_border), + label: 'Saved', + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text('Label behavior: ${labelBehavior.name}'), + const SizedBox(height: 10), + OverflowBar( + spacing: 10.0, + overflowAlignment: .center, + overflowSpacing: 10.0, + children: <Widget>[ + ElevatedButton( + onPressed: () { + setState(() { + labelBehavior = + NavigationDestinationLabelBehavior.alwaysShow; + }); + }, + child: const Text('alwaysShow'), + ), + ElevatedButton( + onPressed: () { + setState(() { + labelBehavior = + NavigationDestinationLabelBehavior.onlyShowSelected; + }); + }, + child: const Text('onlyShowSelected'), + ), + ElevatedButton( + onPressed: () { + setState(() { + labelBehavior = + NavigationDestinationLabelBehavior.alwaysHide; + }); + }, + child: const Text('alwaysHide'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.2.dart b/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.2.dart new file mode 100644 index 000000000000..9fa732bd2210 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/navigation_bar/navigation_bar.2.dart @@ -0,0 +1,393 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [NavigationBar] with nested [Navigator] destinations. + +void main() { + runApp(const MaterialApp(home: Home())); +} + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State<Home> createState() => _HomeState(); +} + +class _HomeState extends State<Home> with TickerProviderStateMixin<Home> { + static const List<Destination> allDestinations = <Destination>[ + Destination(0, 'Teal', Icons.home, Colors.teal), + Destination(1, 'Cyan', Icons.business, Colors.cyan), + Destination(2, 'Orange', Icons.school, Colors.orange), + Destination(3, 'Blue', Icons.flight, Colors.blue), + ]; + + late final List<GlobalKey<NavigatorState>> navigatorKeys; + late final List<GlobalKey> destinationKeys; + late final List<AnimationController> destinationFaders; + late final List<Widget> destinationViews; + int selectedIndex = 0; + + AnimationController buildFaderController() { + return AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + )..addStatusListener((AnimationStatus status) { + if (status.isDismissed) { + setState(() {}); // Rebuild unselected destinations offstage. + } + }); + } + + @override + void initState() { + super.initState(); + + navigatorKeys = List<GlobalKey<NavigatorState>>.generate( + allDestinations.length, + (int index) => GlobalKey(), + ).toList(); + + destinationFaders = List<AnimationController>.generate( + allDestinations.length, + (int index) => buildFaderController(), + ).toList(); + destinationFaders[selectedIndex].value = 1.0; + + final CurveTween tween = CurveTween(curve: Curves.fastOutSlowIn); + destinationViews = allDestinations.map<Widget>((Destination destination) { + return FadeTransition( + opacity: destinationFaders[destination.index].drive(tween), + child: DestinationView( + destination: destination, + navigatorKey: navigatorKeys[destination.index], + ), + ); + }).toList(); + } + + @override + void dispose() { + for (final AnimationController controller in destinationFaders) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return NavigatorPopHandler( + onPopWithResult: (void result) { + final NavigatorState navigator = + navigatorKeys[selectedIndex].currentState!; + navigator.pop(); + }, + child: Scaffold( + body: SafeArea( + top: false, + child: Stack( + fit: .expand, + children: allDestinations.map((Destination destination) { + final int index = destination.index; + final Widget view = destinationViews[index]; + if (index == selectedIndex) { + destinationFaders[index].forward(); + return Offstage(offstage: false, child: view); + } else { + destinationFaders[index].reverse(); + if (destinationFaders[index].isAnimating) { + return IgnorePointer(child: view); + } + return Offstage(child: view); + } + }).toList(), + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (int index) { + setState(() { + selectedIndex = index; + }); + }, + destinations: allDestinations.map<NavigationDestination>(( + Destination destination, + ) { + return NavigationDestination( + icon: Icon(destination.icon, color: destination.color), + label: destination.title, + ); + }).toList(), + ), + ), + ); + } +} + +class Destination { + const Destination(this.index, this.title, this.icon, this.color); + final int index; + final String title; + final IconData icon; + final MaterialColor color; +} + +class RootPage extends StatelessWidget { + const RootPage({super.key, required this.destination}); + + final Destination destination; + + Widget _buildDialog(BuildContext context) { + return AlertDialog( + title: Text('${destination.title} AlertDialog'), + actions: <Widget>[ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('OK'), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final TextStyle headlineSmall = Theme.of(context).textTheme.headlineSmall!; + final ButtonStyle buttonStyle = ElevatedButton.styleFrom( + backgroundColor: destination.color, + foregroundColor: Colors.white, + visualDensity: .comfortable, + padding: const .symmetric(vertical: 12, horizontal: 16), + textStyle: headlineSmall, + ); + + return Scaffold( + appBar: AppBar( + title: Text('${destination.title} RootPage - /'), + backgroundColor: destination.color, + foregroundColor: Colors.white, + ), + backgroundColor: destination.color[50], + body: Center( + child: Column( + mainAxisSize: .min, + children: <Widget>[ + ElevatedButton( + style: buttonStyle, + onPressed: () { + Navigator.pushNamed(context, '/list'); + }, + child: const Text('Push /list'), + ), + const SizedBox(height: 16), + ElevatedButton( + style: buttonStyle, + onPressed: () { + showDialog<void>( + context: context, + useRootNavigator: false, + builder: _buildDialog, + ); + }, + child: const Text('Local Dialog'), + ), + const SizedBox(height: 16), + ElevatedButton( + style: buttonStyle, + onPressed: () { + showDialog<void>( + context: context, + useRootNavigator: + true, // ignore: avoid_redundant_argument_values + builder: _buildDialog, + ); + }, + child: const Text('Root Dialog'), + ), + const SizedBox(height: 16), + Builder( + builder: (BuildContext context) { + return ElevatedButton( + style: buttonStyle, + onPressed: () { + showBottomSheet( + context: context, + builder: (BuildContext context) { + return Container( + padding: const .all(16), + width: double.infinity, + child: Text( + '${destination.title} BottomSheet\n' + 'Tap the back button to dismiss', + style: headlineSmall, + softWrap: true, + textAlign: .center, + ), + ); + }, + ); + }, + child: const Text('Local BottomSheet'), + ); + }, + ), + ], + ), + ), + ); + } +} + +class ListPage extends StatelessWidget { + const ListPage({super.key, required this.destination}); + + final Destination destination; + + @override + Widget build(BuildContext context) { + const int itemCount = 50; + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final ButtonStyle buttonStyle = OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: .circular(8), + side: BorderSide(color: colorScheme.onSurface.withValues(alpha: 0.12)), + ), + foregroundColor: destination.color, + fixedSize: const Size.fromHeight(64), + textStyle: Theme.of(context).textTheme.headlineSmall, + ); + return Scaffold( + appBar: AppBar( + title: Text('${destination.title} ListPage - /list'), + backgroundColor: destination.color, + foregroundColor: Colors.white, + ), + backgroundColor: destination.color[50], + body: SizedBox.expand( + child: ListView.builder( + itemCount: itemCount, + itemBuilder: (BuildContext context, int index) { + return Padding( + padding: const .symmetric(vertical: 4, horizontal: 8), + child: OutlinedButton( + style: buttonStyle.copyWith( + backgroundColor: WidgetStatePropertyAll<Color>( + Color.lerp( + destination.color[100], + Colors.white, + index / itemCount, + )!, + ), + ), + onPressed: () { + Navigator.pushNamed(context, '/text'); + }, + child: Text('Push /text [$index]'), + ), + ); + }, + ), + ), + ); + } +} + +class TextPage extends StatefulWidget { + const TextPage({super.key, required this.destination}); + + final Destination destination; + + @override + State<TextPage> createState() => _TextPageState(); +} + +class _TextPageState extends State<TextPage> { + late final TextEditingController textController; + + @override + void initState() { + super.initState(); + textController = TextEditingController(text: 'Sample Text'); + } + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text('${widget.destination.title} TextPage - /list/text'), + backgroundColor: widget.destination.color, + foregroundColor: Colors.white, + ), + backgroundColor: widget.destination.color[50], + body: Container( + padding: const .all(32.0), + alignment: .center, + child: TextField( + controller: textController, + style: theme.primaryTextTheme.headlineMedium?.copyWith( + color: widget.destination.color, + ), + decoration: InputDecoration( + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: widget.destination.color, + width: 3.0, + ), + ), + ), + ), + ), + ); + } +} + +class DestinationView extends StatefulWidget { + const DestinationView({ + super.key, + required this.destination, + required this.navigatorKey, + }); + + final Destination destination; + final Key navigatorKey; + + @override + State<DestinationView> createState() => _DestinationViewState(); +} + +class _DestinationViewState extends State<DestinationView> { + @override + Widget build(BuildContext context) { + return Navigator( + key: widget.navigatorKey, + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + switch (settings.name) { + case '/': + return RootPage(destination: widget.destination); + case '/list': + return ListPage(destination: widget.destination); + case '/text': + return TextPage(destination: widget.destination); + } + assert(false); + return const SizedBox(); + }, + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/navigation_drawer/navigation_drawer.0.dart b/packages/material_ui/material_ui_examples/lib/navigation_drawer/navigation_drawer.0.dart new file mode 100644 index 000000000000..89679a36f59a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/navigation_drawer/navigation_drawer.0.dart @@ -0,0 +1,187 @@ +// 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. + +// Builds an adaptive navigation widget layout. When the screen width is less than +// 450, A [NavigationBar] will be displayed. Otherwise, a [NavigationRail] will be +// displayed on the left side, and also a button to open the [NavigationDrawer]. +// All of these navigation widgets are built from an identical list of data. + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [NavigationDrawer]. + +void main() => runApp(const NavigationDrawerApp()); + +class ExampleDestination { + const ExampleDestination(this.label, this.icon, this.selectedIcon); + + final String label; + final Widget icon; + final Widget selectedIcon; +} + +const List<ExampleDestination> destinations = <ExampleDestination>[ + ExampleDestination( + 'Messages', + Icon(Icons.widgets_outlined), + Icon(Icons.widgets), + ), + ExampleDestination( + 'Profile', + Icon(Icons.format_paint_outlined), + Icon(Icons.format_paint), + ), + ExampleDestination( + 'Settings', + Icon(Icons.settings_outlined), + Icon(Icons.settings), + ), +]; + +class NavigationDrawerApp extends StatelessWidget { + const NavigationDrawerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: NavigationDrawerExample(), + ); + } +} + +class NavigationDrawerExample extends StatefulWidget { + const NavigationDrawerExample({super.key}); + + @override + State<NavigationDrawerExample> createState() => + _NavigationDrawerExampleState(); +} + +class _NavigationDrawerExampleState extends State<NavigationDrawerExample> { + final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); + + int screenIndex = 0; + late bool showNavigationDrawer; + + void handleScreenChanged(int selectedScreen) { + setState(() { + screenIndex = selectedScreen; + }); + } + + void openDrawer() { + scaffoldKey.currentState!.openEndDrawer(); + } + + Widget buildBottomBarScaffold() { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[Text('Page Index = $screenIndex')], + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: screenIndex, + onDestinationSelected: (int index) { + setState(() { + screenIndex = index; + }); + }, + destinations: destinations.map((ExampleDestination destination) { + return NavigationDestination( + label: destination.label, + icon: destination.icon, + selectedIcon: destination.selectedIcon, + tooltip: destination.label, + ); + }).toList(), + ), + ); + } + + Widget buildDrawerScaffold(BuildContext context) { + return Scaffold( + key: scaffoldKey, + body: SafeArea( + bottom: false, + top: false, + child: Row( + children: <Widget>[ + Padding( + padding: const .symmetric(horizontal: 5), + child: NavigationRail( + minWidth: 50, + destinations: destinations.map(( + ExampleDestination destination, + ) { + return NavigationRailDestination( + label: Text(destination.label), + icon: destination.icon, + selectedIcon: destination.selectedIcon, + ); + }).toList(), + selectedIndex: screenIndex, + useIndicator: true, + onDestinationSelected: (int index) { + setState(() { + screenIndex = index; + }); + }, + ), + ), + const VerticalDivider(thickness: 1, width: 1), + Expanded( + child: Column( + mainAxisAlignment: .spaceEvenly, + children: <Widget>[ + Text('Page Index = $screenIndex'), + ElevatedButton( + onPressed: openDrawer, + child: const Text('Open Drawer'), + ), + ], + ), + ), + ], + ), + ), + endDrawer: NavigationDrawer( + onDestinationSelected: handleScreenChanged, + selectedIndex: screenIndex, + children: <Widget>[ + Padding( + padding: const .fromLTRB(28, 16, 16, 10), + child: Text( + 'Header', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ...destinations.map((ExampleDestination destination) { + return NavigationDrawerDestination( + label: Text(destination.label), + icon: destination.icon, + selectedIcon: destination.selectedIcon, + ); + }), + const Padding(padding: .fromLTRB(28, 16, 28, 10), child: Divider()), + ], + ), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + showNavigationDrawer = MediaQuery.widthOf(context) >= 450; + } + + @override + Widget build(BuildContext context) { + return showNavigationDrawer + ? buildDrawerScaffold(context) + : buildBottomBarScaffold(); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/navigation_rail/navigation_rail.0.dart b/packages/material_ui/material_ui_examples/lib/navigation_rail/navigation_rail.0.dart new file mode 100644 index 000000000000..69bf83a34ed8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/navigation_rail/navigation_rail.0.dart @@ -0,0 +1,208 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [NavigationRail]. + +void main() => runApp(const NavigationRailExampleApp()); + +class NavigationRailExampleApp extends StatelessWidget { + const NavigationRailExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: NavRailExample()); + } +} + +class NavRailExample extends StatefulWidget { + const NavRailExample({super.key}); + + @override + State<NavRailExample> createState() => _NavRailExampleState(); +} + +class _NavRailExampleState extends State<NavRailExample> { + int _selectedIndex = 0; + NavigationRailLabelType labelType = .all; + bool showLeading = false; + bool showTrailing = false; + double groupAlignment = -1.0; + MainAxisAlignment? alignment; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: _selectedIndex, + groupAlignment: groupAlignment, + mainAxisAlignment: alignment, + onDestinationSelected: (int index) { + setState(() { + _selectedIndex = index; + }); + }, + labelType: labelType, + leading: showLeading + ? FloatingActionButton( + elevation: 0, + onPressed: () { + // Add your onPressed code here! + }, + child: const Icon(Icons.add), + ) + : null, + trailing: showTrailing + ? IconButton( + onPressed: () { + // Add your onPressed code here! + }, + icon: const Icon(Icons.more_horiz_rounded), + ) + : null, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('First'), + ), + NavigationRailDestination( + icon: Badge(child: Icon(Icons.bookmark_border)), + selectedIcon: Badge(child: Icon(Icons.book)), + label: Text('Second'), + ), + NavigationRailDestination( + icon: Badge(label: Text('4'), child: Icon(Icons.star_border)), + selectedIcon: Badge( + label: Text('4'), + child: Icon(Icons.star), + ), + label: Text('Third'), + ), + ], + ), + const VerticalDivider(thickness: 1, width: 1), + // This is the main content. + Expanded( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text('selectedIndex: $_selectedIndex'), + const SizedBox(height: 20), + Text('Label type: ${labelType.name}'), + const SizedBox(height: 10), + SegmentedButton<NavigationRailLabelType>( + segments: const <ButtonSegment<NavigationRailLabelType>>[ + ButtonSegment<NavigationRailLabelType>( + value: NavigationRailLabelType.none, + label: Text('None'), + ), + ButtonSegment<NavigationRailLabelType>( + value: NavigationRailLabelType.selected, + label: Text('Selected'), + ), + ButtonSegment<NavigationRailLabelType>( + value: NavigationRailLabelType.all, + label: Text('All'), + ), + ], + selected: <NavigationRailLabelType>{labelType}, + onSelectionChanged: + (Set<NavigationRailLabelType> newSelection) { + setState(() { + labelType = newSelection.first; + }); + }, + ), + const SizedBox(height: 20), + Text('Group alignment: $groupAlignment'), + const SizedBox(height: 10), + SegmentedButton<double>( + segments: const <ButtonSegment<double>>[ + ButtonSegment<double>(value: -1.0, label: Text('Top')), + ButtonSegment<double>(value: 0.0, label: Text('Center')), + ButtonSegment<double>(value: 1.0, label: Text('Bottom')), + ], + selected: <double>{groupAlignment}, + onSelectionChanged: (Set<double> newSelection) { + setState(() { + groupAlignment = newSelection.first; + }); + }, + ), + const SizedBox(height: 20), + const Text('Main Axis Alignment:'), + const SizedBox(height: 10), + SegmentedButton<MainAxisAlignment?>( + segments: const <ButtonSegment<MainAxisAlignment?>>[ + ButtonSegment<MainAxisAlignment?>( + value: null, + label: Text('Default'), + ), + ButtonSegment<MainAxisAlignment?>( + value: MainAxisAlignment.start, + label: Text('Start'), + ), + ButtonSegment<MainAxisAlignment?>( + value: MainAxisAlignment.end, + label: Text('End'), + ), + ButtonSegment<MainAxisAlignment?>( + value: MainAxisAlignment.center, + label: Text('Center'), + ), + ButtonSegment<MainAxisAlignment?>( + value: MainAxisAlignment.spaceEvenly, + label: Text('Space Evenly'), + ), + ButtonSegment<MainAxisAlignment?>( + value: MainAxisAlignment.spaceBetween, + label: Text('Space Between'), + ), + ButtonSegment<MainAxisAlignment?>( + value: MainAxisAlignment.spaceAround, + label: Text('Space Around'), + ), + ], + selected: <MainAxisAlignment?>{alignment}, + onSelectionChanged: (Set<MainAxisAlignment?> newSelection) { + setState(() { + alignment = newSelection.first; + }); + }, + ), + const SizedBox(height: 20), + SwitchListTile( + title: Text(showLeading ? 'Hide Leading' : 'Show Leading'), + value: showLeading, + onChanged: (bool value) { + setState(() { + showLeading = value; + }); + }, + ), + SwitchListTile( + title: Text( + showTrailing ? 'Hide Trailing' : 'Show Trailing', + ), + value: showTrailing, + onChanged: (bool value) { + setState(() { + showTrailing = value; + }); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/navigation_rail/navigation_rail.extended_animation.0.dart b/packages/material_ui/material_ui_examples/lib/navigation_rail/navigation_rail.extended_animation.0.dart new file mode 100644 index 000000000000..e18f4eca077e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/navigation_rail/navigation_rail.extended_animation.0.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 'dart:ui'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [NavigationRail.extendedAnimation]. + +void main() => runApp(const ExtendedAnimationExampleApp()); + +class ExtendedAnimationExampleApp extends StatelessWidget { + const ExtendedAnimationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: Scaffold(body: MyNavigationRail())); + } +} + +class MyNavigationRail extends StatefulWidget { + const MyNavigationRail({super.key}); + + @override + State<MyNavigationRail> createState() => _MyNavigationRailState(); +} + +class _MyNavigationRailState extends State<MyNavigationRail> { + int _selectedIndex = 0; + bool _extended = false; + + @override + Widget build(BuildContext context) { + return Row( + children: <Widget>[ + NavigationRail( + selectedIndex: _selectedIndex, + extended: _extended, + leading: MyNavigationRailFab( + onPressed: () { + setState(() { + _extended = !_extended; + }); + }, + ), + onDestinationSelected: (int index) { + setState(() { + _selectedIndex = index; + }); + }, + labelType: .none, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('First'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.book), + label: Text('Second'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Third'), + ), + ], + ), + const VerticalDivider(thickness: 1, width: 1), + // This is the main content. + Expanded( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Tap on FloatingActionButton to expand'), + const SizedBox(height: 20), + Text('selectedIndex: $_selectedIndex'), + ], + ), + ), + ), + ], + ); + } +} + +class MyNavigationRailFab extends StatelessWidget { + const MyNavigationRailFab({super.key, this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final Animation<double> animation = NavigationRail.extendedAnimation( + context, + ); + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + // The extended fab has a shorter height than the regular fab. + return Container( + height: 56, + padding: .symmetric(vertical: lerpDouble(0, 6, animation.value)!), + child: animation.value == 0 + ? FloatingActionButton( + onPressed: onPressed, + child: const Icon(Icons.add), + ) + : Align( + alignment: .centerStart, + widthFactor: animation.value, + child: Padding( + padding: const .directional(start: 8), + child: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('CREATE'), + onPressed: onPressed, + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/outlined_button/outlined_button.0.dart b/packages/material_ui/material_ui_examples/lib/outlined_button/outlined_button.0.dart new file mode 100644 index 000000000000..6745bf49945e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/outlined_button/outlined_button.0.dart @@ -0,0 +1,37 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [OutlinedButton]. + +void main() => runApp(const OutlinedButtonExampleApp()); + +class OutlinedButtonExampleApp extends StatelessWidget { + const OutlinedButtonExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('OutlinedButton Sample')), + body: const Center(child: OutlinedButtonExample()), + ), + ); + } +} + +class OutlinedButtonExample extends StatelessWidget { + const OutlinedButtonExample({super.key}); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () { + debugPrint('Received click'); + }, + child: const Text('Click Me'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.0.dart b/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.0.dart new file mode 100644 index 000000000000..73355fea9618 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.0.dart @@ -0,0 +1,76 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [PageTransitionsTheme]. + +void main() => runApp(const PageTransitionsThemeApp()); + +class PageTransitionsThemeApp extends StatelessWidget { + const PageTransitionsThemeApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + // Defines the page transition animations used by MaterialPageRoute + // for different target platforms. + // Non-specified target platforms will default to + // ZoomPageTransitionsBuilder(). + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + .iOS: CupertinoPageTransitionsBuilder(), + .linux: OpenUpwardsPageTransitionsBuilder(), + .macOS: FadeUpwardsPageTransitionsBuilder(), + }, + ), + ), + home: const HomePage(), + ); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.blueGrey, + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<SecondPage>( + builder: (BuildContext context) => const SecondPage(), + ), + ); + }, + child: const Text('To SecondPage'), + ), + ), + ); + } +} + +class SecondPage extends StatelessWidget { + const SecondPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.purple[200], + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Back to HomePage'), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.1.dart b/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.1.dart new file mode 100644 index 000000000000..46d1d0d58263 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.1.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [PageTransitionsTheme]. + +void main() => runApp(const PageTransitionsThemeApp()); + +class PageTransitionsThemeApp extends StatelessWidget { + const PageTransitionsThemeApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + .android: ZoomPageTransitionsBuilder(allowSnapshotting: false), + }, + ), + ), + home: const HomePage(), + ); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.blueGrey, + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<SecondPage>( + builder: (BuildContext context) => const SecondPage(), + ), + ); + }, + child: const Text('To SecondPage'), + ), + ), + ); + } +} + +class SecondPage extends StatelessWidget { + const SecondPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.purple[200], + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Back to HomePage'), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.3.dart b/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.3.dart new file mode 100644 index 000000000000..fca5b5e5f52d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/page_transitions_theme/page_transitions_theme.3.dart @@ -0,0 +1,153 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for the default Android U page transition theme +/// [FadeForwardsPageTransitionsBuilder]. Tapping each list tile navigates to +/// a second page, which slides in from right to left while fading in. +/// Simultaneously, the first page slides out in the same direction while +/// fading out. + +void main() => runApp(const PageTransitionsThemeApp()); + +class PageTransitionsThemeApp extends StatelessWidget { + const PageTransitionsThemeApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: Map<TargetPlatform, PageTransitionsBuilder>.fromIterable( + TargetPlatform.values, + value: (_) => const FadeForwardsPageTransitionsBuilder(), + ), + ), + ), + home: const HomePage(), + ); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.dehaze), onPressed: () {}), + actions: <Widget>[ + IconButton(icon: const Icon(Icons.search), onPressed: () {}), + IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), + ], + ), + body: Column( + children: <Widget>[ + Text('Messages', style: Theme.of(context).textTheme.headlineLarge), + Expanded( + child: Padding( + padding: const .all(20.0), + child: Card( + clipBehavior: .antiAlias, + elevation: 0, + color: Theme.of(context).colorScheme.surfaceContainerLowest, + child: ListView( + children: List<Widget>.generate(Colors.primaries.length, ( + int index, + ) { + final Text kittenName = Text('Kitten $index'); + final CircleAvatar avatar = CircleAvatar( + backgroundColor: Colors.primaries[index], + ); + final String message = index.isEven + ? 'Hello hooman! My name is Kitten $index' + : "What's up hooman! My name is Kitten $index"; + return ListTile( + leading: avatar, + title: kittenName, + subtitle: Text(message), + trailing: Text('$index seconds ago'), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute<SecondPage>( + builder: (BuildContext context) => SecondPage( + kittenName: kittenName, + avatar: avatar, + message: message, + ), + ), + ); + }, + ); + }), + ), + ), + ), + ), + ], + ), + ); + } +} + +class SecondPage extends StatelessWidget { + const SecondPage({ + super.key, + required this.kittenName, + required this.avatar, + required this.message, + }); + final Text kittenName; + final CircleAvatar avatar; + final String message; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: kittenName, + centerTitle: false, + actions: <Widget>[ + IconButton(icon: const Icon(Icons.search), onPressed: () {}), + IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}), + ], + ), + body: Padding( + padding: const .all(20.0), + child: IntrinsicHeight( + child: Row( + children: <Widget>[ + avatar, + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 50), + child: Card( + elevation: 0.0, + shape: const RoundedRectangleBorder( + borderRadius: .only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(5), + bottomRight: Radius.circular(20), + ), + ), + color: Theme.of(context).colorScheme.surfaceContainerLowest, + child: Center( + child: Padding( + padding: const .symmetric(horizontal: 15.0), + child: Text(message), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/paginated_data_table/paginated_data_table.0.dart b/packages/material_ui/material_ui_examples/lib/paginated_data_table/paginated_data_table.0.dart new file mode 100644 index 000000000000..ebde02711e30 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/paginated_data_table/paginated_data_table.0.dart @@ -0,0 +1,84 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [PaginatedDataTable]. + +class MyDataSource extends DataTableSource { + @override + int get rowCount => 3; + + @override + DataRow? getRow(int index) { + switch (index) { + case 0: + return const DataRow( + cells: <DataCell>[ + DataCell(Text('Sarah')), + DataCell(Text('19')), + DataCell(Text('Student')), + ], + ); + case 1: + return const DataRow( + cells: <DataCell>[ + DataCell(Text('Janine')), + DataCell(Text('43')), + DataCell(Text('Professor')), + ], + ); + case 2: + return const DataRow( + cells: <DataCell>[ + DataCell(Text('William')), + DataCell(Text('27')), + DataCell(Text('Associate Professor')), + ], + ); + default: + return null; + } + } + + @override + bool get isRowCountApproximate => false; + + @override + int get selectedRowCount => 0; +} + +final DataTableSource dataSource = MyDataSource(); + +void main() => runApp(const DataTableExampleApp()); + +class DataTableExampleApp extends StatelessWidget { + const DataTableExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: SingleChildScrollView( + padding: .all(12.0), + child: DataTableExample(), + ), + ); + } +} + +class DataTableExample extends StatelessWidget { + const DataTableExample({super.key}); + + @override + Widget build(BuildContext context) { + return PaginatedDataTable( + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Age')), + DataColumn(label: Text('Role')), + ], + source: dataSource, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/paginated_data_table/paginated_data_table.1.dart b/packages/material_ui/material_ui_examples/lib/paginated_data_table/paginated_data_table.1.dart new file mode 100644 index 000000000000..97b1cdddbe5c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/paginated_data_table/paginated_data_table.1.dart @@ -0,0 +1,299 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [PaginatedDataTable]. + +class MyDataSource extends DataTableSource { + static const List<int> _displayIndexToRawIndex = <int>[0, 3, 4, 5, 6]; + + late List<List<Comparable<Object>>> sortedData; + void setData( + List<List<Comparable<Object>>> rawData, + int sortColumn, + bool sortAscending, + ) { + sortedData = rawData.toList() + ..sort((List<Comparable<Object>> a, List<Comparable<Object>> b) { + final Comparable<Object> cellA = a[_displayIndexToRawIndex[sortColumn]]; + final Comparable<Object> cellB = b[_displayIndexToRawIndex[sortColumn]]; + return cellA.compareTo(cellB) * (sortAscending ? 1 : -1); + }); + notifyListeners(); + } + + @override + int get rowCount => sortedData.length; + + static DataCell cellFor(Object data) { + String value; + if (data is DateTime) { + value = + '${data.year}-${data.month.toString().padLeft(2, '0')}-${data.day.toString().padLeft(2, '0')}'; + } else { + value = data.toString(); + } + return DataCell(Text(value)); + } + + @override + DataRow? getRow(int index) { + return DataRow.byIndex( + index: sortedData[index][0] as int, + cells: <DataCell>[ + cellFor( + 'S${sortedData[index][1]}E${sortedData[index][2].toString().padLeft(2, '0')}', + ), + cellFor(sortedData[index][3]), + cellFor(sortedData[index][4]), + cellFor(sortedData[index][5]), + cellFor(sortedData[index][6]), + ], + ); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get selectedRowCount => 0; +} + +void main() => runApp(const DataTableExampleApp()); + +class DataTableExampleApp extends StatelessWidget { + const DataTableExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: SingleChildScrollView( + padding: .all(12.0), + child: DataTableExample(), + ), + ); + } +} + +class DataTableExample extends StatefulWidget { + const DataTableExample({super.key}); + + @override + State<DataTableExample> createState() => _DataTableExampleState(); +} + +class _DataTableExampleState extends State<DataTableExample> { + final MyDataSource dataSource = MyDataSource()..setData(episodes, 0, true); + + int _columnIndex = 0; + bool _columnAscending = true; + + void _sort(int columnIndex, bool ascending) { + setState(() { + _columnIndex = columnIndex; + _columnAscending = ascending; + dataSource.setData(episodes, _columnIndex, _columnAscending); + }); + } + + @override + Widget build(BuildContext context) { + return PaginatedDataTable( + sortColumnIndex: _columnIndex, + sortAscending: _columnAscending, + columns: <DataColumn>[ + DataColumn(label: const Text('Episode'), onSort: _sort), + DataColumn(label: const Text('Title'), onSort: _sort), + DataColumn(label: const Text('Director'), onSort: _sort), + DataColumn(label: const Text('Writer(s)'), onSort: _sort), + DataColumn(label: const Text('Air Date'), onSort: _sort), + ], + source: dataSource, + ); + } +} + +final List<List<Comparable<Object>>> episodes = <List<Comparable<Object>>>[ + <Comparable<Object>>[ + 1, + 1, + 1, + 'Strange New Worlds', + 'Akiva Goldsman', + 'Akiva Goldsman, Alex Kurtzman, Jenny Lumet', + DateTime(2022, 5, 5), + ], + <Comparable<Object>>[ + 2, + 1, + 2, + 'Children of the Comet', + 'Maja Vrvilo', + 'Henry Alonso Myers, Sarah Tarkoff', + DateTime(2022, 5, 12), + ], + <Comparable<Object>>[ + 3, + 1, + 3, + 'Ghosts of Illyria', + 'Leslie Hope', + 'Akela Cooper, Bill Wolkoff', + DateTime(2022, 5, 19), + ], + <Comparable<Object>>[ + 4, + 1, + 4, + 'Memento Mori', + 'Dan Liu', + 'Davy Perez, Beau DeMayo', + DateTime(2022, 5, 26), + ], + <Comparable<Object>>[ + 5, + 1, + 5, + 'Spock Amok', + 'Rachel Leiterman', + 'Henry Alonso Myers, Robin Wasserman', + DateTime(2022, 6, 2), + ], + <Comparable<Object>>[ + 6, + 1, + 6, + 'Lift Us Where Suffering Cannot Reach', + 'Andi Armaganian', + 'Robin Wasserman, Bill Wolkoff', + DateTime(2022, 6, 9), + ], + <Comparable<Object>>[ + 7, + 1, + 7, + 'The Serene Squall', + 'Sydney Freeland', + 'Beau DeMayo, Sarah Tarkoff', + DateTime(2022, 6, 16), + ], + <Comparable<Object>>[ + 8, + 1, + 8, + 'The Elysian Kingdom', + 'Amanda Row', + 'Akela Cooper, Onitra Johnson', + DateTime(2022, 6, 23), + ], + <Comparable<Object>>[ + 9, + 1, + 9, + 'All Those Who Wander', + 'Christopher J. Byrne', + 'Davy Perez', + DateTime(2022, 6, 30), + ], + <Comparable<Object>>[ + 10, + 2, + 10, + 'A Quality of Mercy', + 'Chris Fisher', + 'Henry Alonso Myers, Akiva Goldsman', + DateTime(2022, 7, 7), + ], + <Comparable<Object>>[ + 11, + 2, + 1, + 'The Broken Circle', + 'Chris Fisher', + 'Henry Alonso Myers, Akiva Goldsman', + DateTime(2023, 6, 15), + ], + <Comparable<Object>>[ + 12, + 2, + 2, + 'Ad Astra per Aspera', + 'Valerie Weiss', + 'Dana Horgan', + DateTime(2023, 6, 22), + ], + <Comparable<Object>>[ + 13, + 2, + 3, + 'Tomorrow and Tomorrow and Tomorrow', + 'Amanda Row', + 'David Reed', + DateTime(2023, 6, 29), + ], + <Comparable<Object>>[ + 14, + 2, + 4, + 'Among the Lotus Eaters', + 'Eduardo Sánchez', + 'Kirsten Beyer, Davy Perez', + DateTime(2023, 7, 6), + ], + <Comparable<Object>>[ + 15, + 2, + 5, + 'Charades', + 'Jordan Canning', + 'Kathryn Lyn, Henry Alonso Myers', + DateTime(2023, 7, 13), + ], + <Comparable<Object>>[ + 16, + 2, + 6, + 'Lost in Translation', + 'Dan Liu', + 'Onitra Johnson, David Reed', + DateTime(2023, 7, 20), + ], + <Comparable<Object>>[ + 17, + 2, + 7, + 'Those Old Scientists', + 'Jonathan Frakes', + 'Kathryn Lyn, Bill Wolkoff', + DateTime(2023, 7, 22), + ], + <Comparable<Object>>[ + 18, + 2, + 8, + 'Under the Cloak of War', + '', + 'Davy Perez', + DateTime(2023, 7, 27), + ], + <Comparable<Object>>[ + 19, + 2, + 9, + 'Subspace Rhapsody', + '', + 'Dana Horgan, Bill Wolkoff', + DateTime(2023, 8, 3), + ], + <Comparable<Object>>[ + 20, + 2, + 10, + 'Hegemony', + '', + 'Henry Alonso Myers', + DateTime(2023, 8, 10), + ], +]; diff --git a/packages/material_ui/material_ui_examples/lib/platform_menu_bar/platform_menu_bar.0.dart b/packages/material_ui/material_ui_examples/lib/platform_menu_bar/platform_menu_bar.0.dart new file mode 100644 index 000000000000..f594c7b87e5c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/platform_menu_bar/platform_menu_bar.0.dart @@ -0,0 +1,140 @@ +// 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. + +// THIS SAMPLE ONLY WORKS ON MACOS. + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [PlatformMenuBar]. + +void main() => runApp(const ExampleApp()); + +enum MenuSelection { about, showMessage } + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: Scaffold(body: PlatformMenuBarExample())); + } +} + +class PlatformMenuBarExample extends StatefulWidget { + const PlatformMenuBarExample({super.key}); + + @override + State<PlatformMenuBarExample> createState() => _PlatformMenuBarExampleState(); +} + +class _PlatformMenuBarExampleState extends State<PlatformMenuBarExample> { + String _message = 'Hello'; + bool _showMessage = false; + + void _handleMenuSelection(MenuSelection value) { + switch (value) { + case MenuSelection.about: + showAboutDialog( + context: context, + applicationName: 'MenuBar Sample', + applicationVersion: '1.0.0', + ); + case MenuSelection.showMessage: + setState(() { + _showMessage = !_showMessage; + }); + } + } + + @override + Widget build(BuildContext context) { + //////////////////////////////////// + // THIS SAMPLE ONLY WORKS ON MACOS. + //////////////////////////////////// + + // This builds a menu hierarchy that looks like this: + // Flutter API Sample + // ├ About + // ├ ──────── (group divider) + // ├ Hide/Show Message + // ├ Messages + // │ ├ I am not throwing away my shot. + // │ └ There's a million things I haven't done, but just you wait. + // └ Quit + return PlatformMenuBar( + menus: <PlatformMenuItem>[ + PlatformMenu( + label: 'Flutter API Sample', + menus: <PlatformMenuItem>[ + PlatformMenuItemGroup( + members: <PlatformMenuItem>[ + PlatformMenuItem( + label: 'About', + onSelected: () { + _handleMenuSelection(MenuSelection.about); + }, + ), + ], + ), + PlatformMenuItemGroup( + members: <PlatformMenuItem>[ + PlatformMenuItem( + onSelected: () { + _handleMenuSelection(MenuSelection.showMessage); + }, + shortcut: const CharacterActivator('m'), + label: _showMessage ? 'Hide Message' : 'Show Message', + ), + PlatformMenu( + label: 'Messages', + menus: <PlatformMenuItem>[ + PlatformMenuItem( + label: 'I am not throwing away my shot.', + shortcut: const SingleActivator( + LogicalKeyboardKey.digit1, + meta: true, + ), + onSelected: () { + setState(() { + _message = 'I am not throwing away my shot.'; + }); + }, + ), + PlatformMenuItem( + label: + "There's a million things I haven't done, but just you wait.", + shortcut: const SingleActivator( + LogicalKeyboardKey.digit2, + meta: true, + ), + onSelected: () { + setState(() { + _message = + "There's a million things I haven't done, but just you wait."; + }); + }, + ), + ], + ), + ], + ), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.quit, + )) + const PlatformProvidedMenuItem(type: .quit), + ], + ), + ], + child: Center( + child: Text( + _showMessage + ? _message + : 'This space intentionally left blank.\n' + 'Show a message here using the menu.', + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.0.dart b/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.0.dart new file mode 100644 index 000000000000..9c59487f6e0e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.0.dart @@ -0,0 +1,63 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [PopupMenuButton]. + +// This is the type used by the popup menu below. +enum SampleItem { itemOne, itemTwo, itemThree } + +void main() => runApp(const PopupMenuApp()); + +class PopupMenuApp extends StatelessWidget { + const PopupMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: PopupMenuExample()); + } +} + +class PopupMenuExample extends StatefulWidget { + const PopupMenuExample({super.key}); + + @override + State<PopupMenuExample> createState() => _PopupMenuExampleState(); +} + +class _PopupMenuExampleState extends State<PopupMenuExample> { + SampleItem? selectedItem; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('PopupMenuButton')), + body: Center( + child: PopupMenuButton<SampleItem>( + initialValue: selectedItem, + onSelected: (SampleItem item) { + setState(() { + selectedItem = item; + }); + }, + itemBuilder: (BuildContext context) => <PopupMenuEntry<SampleItem>>[ + const PopupMenuItem<SampleItem>( + value: SampleItem.itemOne, + child: Text('Item 1'), + ), + const PopupMenuItem<SampleItem>( + value: SampleItem.itemTwo, + child: Text('Item 2'), + ), + const PopupMenuItem<SampleItem>( + value: SampleItem.itemThree, + child: Text('Item 3'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.1.dart b/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.1.dart new file mode 100644 index 000000000000..428e6d52c5db --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.1.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [PopupMenuButton]. + +// This is the type used by the popup menu below. +enum SampleItem { itemOne, itemTwo, itemThree } + +void main() => runApp(const PopupMenuApp()); + +class PopupMenuApp extends StatelessWidget { + const PopupMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const PopupMenuExample(), + ); + } +} + +class PopupMenuExample extends StatefulWidget { + const PopupMenuExample({super.key}); + + @override + State<PopupMenuExample> createState() => _PopupMenuExampleState(); +} + +class _PopupMenuExampleState extends State<PopupMenuExample> { + SampleItem? selectedItem; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('PopupMenuButton')), + body: Center( + child: PopupMenuButton<SampleItem>( + initialValue: selectedItem, + onSelected: (SampleItem item) { + setState(() { + selectedItem = item; + }); + }, + itemBuilder: (BuildContext context) => <PopupMenuEntry<SampleItem>>[ + const PopupMenuItem<SampleItem>( + value: SampleItem.itemOne, + child: Text('Item 1'), + ), + const PopupMenuItem<SampleItem>( + value: SampleItem.itemTwo, + child: Text('Item 2'), + ), + const PopupMenuItem<SampleItem>( + value: SampleItem.itemThree, + child: Text('Item 3'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.2.dart b/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.2.dart new file mode 100644 index 000000000000..3ef47ad01b6e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/popup_menu/popup_menu.2.dart @@ -0,0 +1,136 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [PopupMenuButton]. + +void main() => runApp(const PopupMenuApp()); + +class PopupMenuApp extends StatelessWidget { + const PopupMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: PopupMenuExample()); + } +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), + ]; + +enum Menu { preview, share, getLink, remove, download } + +class PopupMenuExample extends StatefulWidget { + const PopupMenuExample({super.key}); + + @override + State<PopupMenuExample> createState() => _PopupMenuExampleState(); +} + +class _PopupMenuExampleState extends State<PopupMenuExample> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + AnimationStyles.defaultStyle, + }; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const .only(top: 50), + child: Align( + alignment: .topCenter, + child: Column( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyleSelection = styles; + switch (styles.first) { + case AnimationStyles.defaultStyle: + _animationStyle = null; + case AnimationStyles.custom: + _animationStyle = const AnimationStyle( + curve: Easing.emphasizedDecelerate, + duration: Duration(seconds: 3), + ); + case AnimationStyles.none: + _animationStyle = AnimationStyle.noAnimation; + } + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 10), + PopupMenuButton<Menu>( + popUpAnimationStyle: _animationStyle, + icon: const Icon(Icons.more_vert), + onSelected: (Menu item) {}, + itemBuilder: (BuildContext context) => <PopupMenuEntry<Menu>>[ + const PopupMenuItem<Menu>( + value: Menu.preview, + child: ListTile( + leading: Icon(Icons.visibility_outlined), + title: Text('Preview'), + ), + ), + const PopupMenuItem<Menu>( + value: Menu.share, + child: ListTile( + leading: Icon(Icons.share_outlined), + title: Text('Share'), + ), + ), + const PopupMenuItem<Menu>( + value: Menu.getLink, + child: ListTile( + leading: Icon(Icons.link_outlined), + title: Text('Get link'), + ), + ), + const PopupMenuDivider(), + const PopupMenuItem<Menu>( + value: Menu.remove, + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Remove'), + ), + ), + const PopupMenuItem<Menu>( + value: Menu.download, + child: ListTile( + leading: Icon(Icons.download_outlined), + title: Text('Download'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.0.dart b/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.0.dart new file mode 100644 index 000000000000..994ce8e1b0a3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.0.dart @@ -0,0 +1,89 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [CircularProgressIndicator]. + +void main() => runApp(const ProgressIndicatorExampleApp()); + +class ProgressIndicatorExampleApp extends StatelessWidget { + const ProgressIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ProgressIndicatorExample()); + } +} + +class ProgressIndicatorExample extends StatefulWidget { + const ProgressIndicatorExample({super.key}); + + @override + State<ProgressIndicatorExample> createState() => + _ProgressIndicatorExampleState(); +} + +class _ProgressIndicatorExampleState extends State<ProgressIndicatorExample> + with TickerProviderStateMixin { + late AnimationController controller; + bool year2023 = true; + + @override + void initState() { + super.initState(); + controller = + AnimationController(vsync: this, duration: const Duration(seconds: 5)) + ..addListener(() { + setState(() {}); + }) + ..repeat(reverse: true); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + spacing: 16.0, + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Determinate CircularProgressIndicator'), + Padding( + padding: const .symmetric(horizontal: 16), + child: CircularProgressIndicator( + // ignore: deprecated_member_use + year2023: year2023, + value: controller.value, + ), + ), + const Text('Indeterminate CircularProgressIndicator'), + Padding( + padding: const .symmetric(horizontal: 16), + // ignore: deprecated_member_use + child: CircularProgressIndicator(year2023: year2023), + ), + SwitchListTile( + value: year2023, + title: year2023 + ? const Text('Switch to latest M3 style') + : const Text('Switch to year2023 M3 style'), + onChanged: (bool value) { + setState(() { + year2023 = !year2023; + }); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.1.dart b/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.1.dart new file mode 100644 index 000000000000..365f9074dce7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.1.dart @@ -0,0 +1,104 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [CircularProgressIndicator]. + +void main() => runApp(const ProgressIndicatorExampleApp()); + +class ProgressIndicatorExampleApp extends StatelessWidget { + const ProgressIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const ProgressIndicatorExample(), + ); + } +} + +class ProgressIndicatorExample extends StatefulWidget { + const ProgressIndicatorExample({super.key}); + + @override + State<ProgressIndicatorExample> createState() => + _ProgressIndicatorExampleState(); +} + +class _ProgressIndicatorExampleState extends State<ProgressIndicatorExample> + with TickerProviderStateMixin { + late AnimationController controller; + bool determinate = false; + + @override + void initState() { + controller = + AnimationController( + /// [AnimationController]s can be created with `vsync: this` because of + /// [TickerProviderStateMixin]. + vsync: this, + duration: const Duration(seconds: 2), + )..addListener(() { + setState(() {}); + }); + controller.repeat(reverse: true); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const .all(20.0), + child: Column( + spacing: 16.0, + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Circular progress indicator', + style: Theme.of(context).textTheme.titleLarge, + ), + CircularProgressIndicator( + value: controller.value, + semanticsLabel: 'Circular progress indicator', + ), + Row( + children: <Widget>[ + Expanded( + child: Text( + 'determinate Mode', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Switch( + value: determinate, + onChanged: (bool value) { + setState(() { + determinate = value; + if (determinate) { + controller.stop(); + } else { + controller + ..forward(from: controller.value) + ..repeat(); + } + }); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.2.dart b/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.2.dart new file mode 100644 index 000000000000..7afb41b09831 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/progress_indicator/circular_progress_indicator.2.dart @@ -0,0 +1,142 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [CircularProgressIndicator]. + +void main() => runApp(const ProgressIndicatorExampleApp()); + +class ProgressIndicatorExampleApp extends StatelessWidget { + const ProgressIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: const ProgressIndicatorExample(), + ); + } +} + +class ProgressIndicatorExample extends StatefulWidget { + const ProgressIndicatorExample({super.key}); + + @override + State<ProgressIndicatorExample> createState() => + _ProgressIndicatorExampleState(); +} + +class _ProgressIndicatorExampleState extends State<ProgressIndicatorExample> + with TickerProviderStateMixin { + late AnimationController controller; + int indicatorNum = 1; + bool hasThemeController = true; + + @override + void initState() { + controller = AnimationController( + vsync: this, + duration: CircularProgressIndicator.defaultAnimationDuration * 0.8, + ); + controller.repeat(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Theme( + data: ThemeData( + progressIndicatorTheme: hasThemeController + ? ProgressIndicatorThemeData(controller: controller) + : null, + ), + child: Padding( + padding: const .all(20.0), + child: Column( + spacing: 8.0, + children: <Widget>[ + Row( + mainAxisAlignment: .center, + children: <Widget>[ + TextButton( + onPressed: () { + setState(() { + indicatorNum += 1; + }); + }, + child: const Text('More indicators'), + ), + TextButton( + onPressed: () { + setState(() { + indicatorNum -= 1; + }); + }, + child: const Text('Fewer indicators'), + ), + ], + ), + Row( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Theme controller? ${hasThemeController ? 'Yes' : 'No'}', + ), + TextButton( + onPressed: () { + setState(() { + hasThemeController = !hasThemeController; + }); + }, + child: const Text('Toggle'), + ), + ], + ), + ManyProgressIndicators(indicatorNum: indicatorNum), + ], + ), + ), + ), + ); + } +} + +/// Display several [CircularProgressIndicator] in nested `Container`s. +class ManyProgressIndicators extends StatelessWidget { + const ManyProgressIndicators({super.key, required this.indicatorNum}); + + final int indicatorNum; + + Widget _nestIndicator({required Widget child}) { + return Container( + padding: const .all(5), + margin: const .all(5), + decoration: BoxDecoration( + color: const Color.fromARGB(100, 240, 240, 0), + border: .all(), + ), + child: Column( + mainAxisAlignment: .center, + children: <Widget>[const CircularProgressIndicator(), child], + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget child = const SizedBox(); + for (int i = 0; i < indicatorNum; i++) { + child = _nestIndicator(child: child); + } + return child; + } +} diff --git a/packages/material_ui/material_ui_examples/lib/progress_indicator/linear_progress_indicator.0.dart b/packages/material_ui/material_ui_examples/lib/progress_indicator/linear_progress_indicator.0.dart new file mode 100644 index 000000000000..5e0a2430f400 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/progress_indicator/linear_progress_indicator.0.dart @@ -0,0 +1,92 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [LinearProgressIndicator]. + +void main() => runApp(const ProgressIndicatorExampleApp()); + +class ProgressIndicatorExampleApp extends StatelessWidget { + const ProgressIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ProgressIndicatorExample()); + } +} + +class ProgressIndicatorExample extends StatefulWidget { + const ProgressIndicatorExample({super.key}); + + @override + State<ProgressIndicatorExample> createState() => + _ProgressIndicatorExampleState(); +} + +class _ProgressIndicatorExampleState extends State<ProgressIndicatorExample> + with TickerProviderStateMixin { + late AnimationController controller; + bool year2023 = true; + + @override + void initState() { + super.initState(); + controller = + AnimationController( + /// [AnimationController]s can be created with `vsync: this` because of + /// [TickerProviderStateMixin]. + vsync: this, + duration: const Duration(seconds: 5), + ) + ..addListener(() { + setState(() {}); + }) + ..repeat(reverse: true); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + spacing: 16.0, + mainAxisAlignment: .center, + children: <Widget>[ + const Text('Determinate LinearProgressIndicator'), + Padding( + padding: const .symmetric(horizontal: 16), + child: LinearProgressIndicator( + // ignore: deprecated_member_use + year2023: year2023, + value: controller.value, + ), + ), + const Text('Indeterminate LinearProgressIndicator'), + Padding( + padding: const .symmetric(horizontal: 16), + // ignore: deprecated_member_use + child: LinearProgressIndicator(year2023: year2023), + ), + SwitchListTile( + value: year2023, + title: year2023 + ? const Text('Switch to latest M3 style') + : const Text('Switch to year2023 M3 style'), + onChanged: (bool value) { + setState(() { + year2023 = !year2023; + }); + }, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/progress_indicator/linear_progress_indicator.1.dart b/packages/material_ui/material_ui_examples/lib/progress_indicator/linear_progress_indicator.1.dart new file mode 100644 index 000000000000..95bcfb6d3f69 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/progress_indicator/linear_progress_indicator.1.dart @@ -0,0 +1,102 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [LinearProgressIndicator]. + +void main() => runApp(const ProgressIndicatorExampleApp()); + +class ProgressIndicatorExampleApp extends StatelessWidget { + const ProgressIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ProgressIndicatorExample()); + } +} + +class ProgressIndicatorExample extends StatefulWidget { + const ProgressIndicatorExample({super.key}); + + @override + State<ProgressIndicatorExample> createState() => + _ProgressIndicatorExampleState(); +} + +class _ProgressIndicatorExampleState extends State<ProgressIndicatorExample> + with TickerProviderStateMixin { + late AnimationController controller; + bool determinate = false; + + @override + void initState() { + super.initState(); + controller = + AnimationController( + /// [AnimationController]s can be created with `vsync: this` because of + /// [TickerProviderStateMixin]. + vsync: this, + duration: const Duration(seconds: 2), + ) + ..addListener(() { + setState(() {}); + }) + ..repeat(reverse: true); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const .all(20.0), + child: Column( + spacing: 16.0, + mainAxisAlignment: .center, + children: <Widget>[ + const Text( + 'Linear progress indicator', + style: TextStyle(fontSize: 20), + ), + LinearProgressIndicator( + value: determinate ? controller.value : null, + semanticsLabel: 'Linear progress indicator', + ), + Row( + children: <Widget>[ + Expanded( + child: Text( + '${determinate ? 'Determinate' : 'Indeterminate'} Mode', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Switch( + value: determinate, + onChanged: (bool value) { + setState(() { + determinate = value; + if (determinate) { + controller.stop(); + } else { + controller + ..forward(from: controller.value) + ..repeat(); + } + }); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio/radio.0.dart b/packages/material_ui/material_ui_examples/lib/radio/radio.0.dart new file mode 100644 index 000000000000..febcf75c357f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio/radio.0.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Radio]. + +void main() => runApp(const RadioExampleApp()); + +class RadioExampleApp extends StatelessWidget { + const RadioExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Radio Sample')), + body: const Center(child: RadioExample()), + ), + ); + } +} + +enum SingingCharacter { lafayette, jefferson } + +class RadioExample extends StatefulWidget { + const RadioExample({super.key}); + + @override + State<RadioExample> createState() => _RadioExampleState(); +} + +class _RadioExampleState extends State<RadioExample> { + SingingCharacter? _character = .lafayette; + + @override + Widget build(BuildContext context) { + return RadioGroup<SingingCharacter>( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: const Column( + children: <Widget>[ + ListTile( + title: Text('Lafayette'), + leading: Radio<SingingCharacter>(value: SingingCharacter.lafayette), + ), + ListTile( + title: Text('Thomas Jefferson'), + leading: Radio<SingingCharacter>(value: SingingCharacter.jefferson), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio/radio.1.dart b/packages/material_ui/material_ui_examples/lib/radio/radio.1.dart new file mode 100644 index 000000000000..525ee82cb4d8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio/radio.1.dart @@ -0,0 +1,113 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Radio] to showcase how to customize radio style. + +void main() => runApp(const RadioExampleApp()); + +class RadioExampleApp extends StatelessWidget { + const RadioExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Radio Sample')), + body: const Center(child: RadioExample()), + ), + ); + } +} + +enum RadioType { fillColor, backgroundColor, side, innerRadius } + +class RadioExample extends StatefulWidget { + const RadioExample({super.key}); + + @override + State<RadioExample> createState() => _RadioExampleState(); +} + +class _RadioExampleState extends State<RadioExample> { + RadioType? _radioType = .fillColor; + + @override + Widget build(BuildContext context) { + return RadioGroup<RadioType>( + groupValue: _radioType, + onChanged: (RadioType? value) { + setState(() { + _radioType = value; + }); + }, + child: Column( + children: <Widget>[ + ListTile( + title: const Text('Fill color'), + leading: Radio<RadioType>( + value: RadioType.fillColor, + fillColor: WidgetStateColor.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.selected)) { + return Colors.deepPurple; + } else { + return Colors.deepPurple.shade200; + } + }), + ), + ), + ListTile( + title: const Text('Background color'), + leading: Radio<RadioType>( + value: RadioType.backgroundColor, + backgroundColor: WidgetStateColor.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.selected)) { + return Colors.greenAccent.withValues(alpha: 0.5); + } else { + return Colors.grey.shade300.withValues(alpha: 0.3); + } + }), + ), + ), + ListTile( + title: const Text('Side'), + leading: Radio<RadioType>( + value: RadioType.side, + side: WidgetStateBorderSide.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.selected)) { + return const BorderSide( + color: Colors.red, + width: 4, + strokeAlign: BorderSide.strokeAlignCenter, + ); + } else { + return const BorderSide( + color: Colors.grey, + width: 1.5, + strokeAlign: BorderSide.strokeAlignCenter, + ); + } + }), + ), + ), + + const ListTile( + title: Text('Inner radius'), + leading: Radio<RadioType>( + value: RadioType.innerRadius, + innerRadius: WidgetStatePropertyAll<double>(6), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio/radio.toggleable.0.dart b/packages/material_ui/material_ui_examples/lib/radio/radio.toggleable.0.dart new file mode 100644 index 000000000000..fb2a5dc0e884 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio/radio.toggleable.0.dart @@ -0,0 +1,72 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Radio.toggleable]. + +void main() => runApp(const ToggleableExampleApp()); + +class ToggleableExampleApp extends StatelessWidget { + const ToggleableExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Radio Sample')), + body: const ToggleableExample(), + ), + ); + } +} + +class ToggleableExample extends StatefulWidget { + const ToggleableExample({super.key}); + + @override + State<ToggleableExample> createState() => _ToggleableExampleState(); +} + +class _ToggleableExampleState extends State<ToggleableExample> { + int? groupValue; + static const List<String> selections = <String>[ + 'Hercules Mulligan', + 'Eliza Hamilton', + 'Philip Schuyler', + 'Maria Reynolds', + 'Samuel Seabury', + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? value) { + setState(() { + groupValue = value; + }); + }, + child: ListView.builder( + itemBuilder: (BuildContext context, int index) { + return Row( + mainAxisSize: .min, + children: <Widget>[ + Radio<int>( + value: index, + // TRY THIS: Try setting the toggleable value to false and + // see how that changes the behavior of the widget. + toggleable: true, + ), + Text(selections[index]), + ], + ); + }, + itemCount: selections.length, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio_list_tile/custom_labeled_radio.0.dart b/packages/material_ui/material_ui_examples/lib/radio_list_tile/custom_labeled_radio.0.dart new file mode 100644 index 000000000000..b82cdbd84d94 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio_list_tile/custom_labeled_radio.0.dart @@ -0,0 +1,103 @@ +// 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/gestures.dart'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for custom labeled radio. + +void main() => runApp(const LabeledRadioApp()); + +class LabeledRadioApp extends StatelessWidget { + const LabeledRadioApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom Labeled Radio Sample')), + body: const LabeledRadioExample(), + ), + ); + } +} + +class LinkedLabelRadio extends StatelessWidget { + const LinkedLabelRadio({ + super.key, + required this.label, + required this.padding, + required this.value, + }); + + final String label; + final EdgeInsets padding; + final bool value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Row( + children: <Widget>[ + Radio<bool>(value: value), + RichText( + text: TextSpan( + text: label, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + debugPrint('Label has been tapped.'); + }, + ), + ), + ], + ), + ); + } +} + +class LabeledRadioExample extends StatefulWidget { + const LabeledRadioExample({super.key}); + + @override + State<LabeledRadioExample> createState() => _LabeledRadioExampleState(); +} + +class _LabeledRadioExampleState extends State<LabeledRadioExample> { + bool _isRadioSelected = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: RadioGroup<bool>( + groupValue: _isRadioSelected, + onChanged: (bool? newValue) { + setState(() { + _isRadioSelected = newValue!; + }); + }, + child: const Column( + mainAxisAlignment: .center, + children: <Widget>[ + LinkedLabelRadio( + label: 'First tappable label text', + padding: .symmetric(horizontal: 5.0), + value: true, + ), + LinkedLabelRadio( + label: 'Second tappable label text', + padding: .symmetric(horizontal: 5.0), + value: false, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio_list_tile/custom_labeled_radio.1.dart b/packages/material_ui/material_ui_examples/lib/radio_list_tile/custom_labeled_radio.1.dart new file mode 100644 index 000000000000..57c16b0e606b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio_list_tile/custom_labeled_radio.1.dart @@ -0,0 +1,94 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for custom labeled radio. + +void main() => runApp(const LabeledRadioApp()); + +class LabeledRadioApp extends StatelessWidget { + const LabeledRadioApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom Labeled Radio Sample')), + body: const LabeledRadioExample(), + ), + ); + } +} + +class LabeledRadio extends StatelessWidget { + const LabeledRadio({ + super.key, + required this.label, + required this.padding, + required this.value, + }); + + final String label; + final EdgeInsets padding; + final bool value; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + RadioGroup.maybeOf<bool>(context)?.onChanged(value); + }, + child: Padding( + padding: padding, + child: Row( + children: <Widget>[ + Radio<bool>(value: value), + Text(label), + ], + ), + ), + ); + } +} + +class LabeledRadioExample extends StatefulWidget { + const LabeledRadioExample({super.key}); + + @override + State<LabeledRadioExample> createState() => _LabeledRadioExampleState(); +} + +class _LabeledRadioExampleState extends State<LabeledRadioExample> { + bool _isRadioSelected = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: RadioGroup<bool>( + groupValue: _isRadioSelected, + onChanged: (bool? newValue) { + setState(() { + _isRadioSelected = newValue!; + }); + }, + child: const Column( + mainAxisAlignment: .center, + children: <LabeledRadio>[ + LabeledRadio( + label: 'This is the first label text', + padding: .symmetric(horizontal: 5.0), + value: true, + ), + LabeledRadio( + label: 'This is the second label text', + padding: .symmetric(horizontal: 5.0), + value: false, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.0.dart b/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.0.dart new file mode 100644 index 000000000000..479c367f74b7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.0.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [RadioListTile]. + +void main() => runApp(const RadioListTileApp()); + +class RadioListTileApp extends StatelessWidget { + const RadioListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('RadioListTile Sample')), + body: const RadioListTileExample(), + ), + ); + } +} + +enum SingingCharacter { lafayette, jefferson } + +class RadioListTileExample extends StatefulWidget { + const RadioListTileExample({super.key}); + + @override + State<RadioListTileExample> createState() => _RadioListTileExampleState(); +} + +class _RadioListTileExampleState extends State<RadioListTileExample> { + SingingCharacter? _character = .lafayette; + + @override + Widget build(BuildContext context) { + return RadioGroup<SingingCharacter>( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: const Column( + children: <Widget>[ + RadioListTile<SingingCharacter>( + title: Text('Lafayette'), + value: SingingCharacter.lafayette, + ), + RadioListTile<SingingCharacter>( + title: Text('Thomas Jefferson'), + value: SingingCharacter.jefferson, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.1.dart b/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.1.dart new file mode 100644 index 000000000000..c0c51f58fedb --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.1.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [RadioListTile]. + +void main() => runApp(const RadioListTileApp()); + +class RadioListTileApp extends StatelessWidget { + const RadioListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: RadioListTileExample()); + } +} + +enum Groceries { pickles, tomato, lettuce } + +class RadioListTileExample extends StatefulWidget { + const RadioListTileExample({super.key}); + + @override + State<RadioListTileExample> createState() => _RadioListTileExampleState(); +} + +class _RadioListTileExampleState extends State<RadioListTileExample> { + Groceries? _groceryItem = .pickles; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('RadioListTile Sample')), + body: RadioGroup<Groceries>( + groupValue: _groceryItem, + onChanged: (Groceries? value) { + setState(() { + _groceryItem = value; + }); + }, + child: const Column( + children: <Widget>[ + RadioListTile<Groceries>( + value: Groceries.pickles, + title: Text('Pickles'), + subtitle: Text('Supporting text'), + ), + RadioListTile<Groceries>( + value: Groceries.tomato, + title: Text('Tomato'), + subtitle: Text( + 'Longer supporting text to demonstrate how the text wraps and the radio is centered vertically with the text.', + ), + ), + RadioListTile<Groceries>( + value: Groceries.lettuce, + title: Text('Lettuce'), + subtitle: Text( + "Longer supporting text to demonstrate how the text wraps and how setting 'RadioListTile.isThreeLine = true' aligns the radio to the top vertically with the text.", + ), + isThreeLine: true, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.toggleable.0.dart b/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.toggleable.0.dart new file mode 100644 index 000000000000..a5b46dada6b5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/radio_list_tile/radio_list_tile.toggleable.0.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [RadioListTile.toggleable]. + +void main() => runApp(const RadioListTileApp()); + +class RadioListTileApp extends StatelessWidget { + const RadioListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('RadioListTile.toggleable Sample')), + body: const RadioListTileExample(), + ), + ); + } +} + +class RadioListTileExample extends StatefulWidget { + const RadioListTileExample({super.key}); + + @override + State<RadioListTileExample> createState() => _RadioListTileExampleState(); +} + +class _RadioListTileExampleState extends State<RadioListTileExample> { + int? groupValue; + static const List<String> selections = <String>[ + 'Hercules Mulligan', + 'Eliza Hamilton', + 'Philip Schuyler', + 'Maria Reynolds', + 'Samuel Seabury', + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? value) { + setState(() { + groupValue = value; + }); + }, + child: ListView.builder( + itemBuilder: (BuildContext context, int index) { + return RadioListTile<int>( + value: index, + toggleable: true, + title: Text(selections[index]), + ); + }, + itemCount: selections.length, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/range_slider/range_slider.0.dart b/packages/material_ui/material_ui_examples/lib/range_slider/range_slider.0.dart new file mode 100644 index 000000000000..6a269ee732f0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/range_slider/range_slider.0.dart @@ -0,0 +1,52 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [RangeSlider]. + +void main() => runApp(const RangeSliderExampleApp()); + +class RangeSliderExampleApp extends StatelessWidget { + const RangeSliderExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('RangeSlider Sample')), + body: const RangeSliderExample(), + ), + ); + } +} + +class RangeSliderExample extends StatefulWidget { + const RangeSliderExample({super.key}); + + @override + State<RangeSliderExample> createState() => _RangeSliderExampleState(); +} + +class _RangeSliderExampleState extends State<RangeSliderExample> { + RangeValues _currentRangeValues = const RangeValues(40, 80); + + @override + Widget build(BuildContext context) { + return RangeSlider( + values: _currentRangeValues, + max: 100, + divisions: 5, + labels: RangeLabels( + _currentRangeValues.start.round().toString(), + _currentRangeValues.end.round().toString(), + ), + onChanged: (RangeValues values) { + setState(() { + _currentRangeValues = values; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.0.dart b/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.0.dart new file mode 100644 index 000000000000..0717317f78fe --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.0.dart @@ -0,0 +1,64 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [RefreshIndicator]. + +void main() => runApp(const RefreshIndicatorExampleApp()); + +class RefreshIndicatorExampleApp extends StatelessWidget { + const RefreshIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: RefreshIndicatorExample()); + } +} + +class RefreshIndicatorExample extends StatefulWidget { + const RefreshIndicatorExample({super.key}); + + @override + State<RefreshIndicatorExample> createState() => + _RefreshIndicatorExampleState(); +} + +class _RefreshIndicatorExampleState extends State<RefreshIndicatorExample> { + final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey = + GlobalKey<RefreshIndicatorState>(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('RefreshIndicator Sample')), + body: RefreshIndicator( + key: _refreshIndicatorKey, + color: Colors.white, + backgroundColor: Colors.blue, + strokeWidth: 4.0, + onRefresh: () async { + // Replace this delay with the code to be executed during refresh + // and return a Future when code finishes execution. + return Future<void>.delayed(const Duration(seconds: 3)); + }, + // Pull from top to show refresh indicator. + child: ListView.builder( + itemCount: 25, + itemBuilder: (BuildContext context, int index) { + return ListTile(title: Text('Item $index')); + }, + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + // Show refresh indicator programmatically on button tap. + _refreshIndicatorKey.currentState?.show(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Show Indicator'), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.1.dart b/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.1.dart new file mode 100644 index 000000000000..2a3f58c9214a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.1.dart @@ -0,0 +1,100 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [RefreshIndicator]. + +void main() => runApp(const RefreshIndicatorExampleApp()); + +class RefreshIndicatorExampleApp extends StatelessWidget { + const RefreshIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + ), + home: const RefreshIndicatorExample(), + ); + } +} + +class RefreshIndicatorExample extends StatelessWidget { + const RefreshIndicatorExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('RefreshIndicator Sample')), + body: RefreshIndicator( + color: Colors.white, + backgroundColor: Colors.blue, + onRefresh: () async { + // Replace this delay with the code to be executed during refresh + // and return asynchronous code + return Future<void>.delayed(const Duration(seconds: 3)); + }, + // This check is used to customize listening to scroll notifications + // from the widget's children. + // + // By default this is set to `notification.depth == 0`, which ensures + // the only the scroll notifications from the first scroll view are listened to. + // + // Here setting `notification.depth == 1` triggers the refresh indicator + // when overscrolling the nested scroll view. + notificationPredicate: (ScrollNotification notification) { + return notification.depth == 1; + }, + child: CustomScrollView( + slivers: <Widget>[ + SliverToBoxAdapter( + child: Container( + height: 100, + alignment: .center, + color: Colors.pink[100], + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text( + 'Pull down here', + style: Theme.of(context).textTheme.headlineMedium, + ), + const Text("RefreshIndicator won't trigger"), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Container( + color: Colors.green[100], + height: 300, + child: ListView.builder( + itemCount: 25, + itemBuilder: (BuildContext context, int index) { + return const ListTile( + title: Text('Pull down here'), + subtitle: Text('RefreshIndicator will trigger'), + ); + }, + ), + ), + ), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return const ListTile( + title: Text('Pull down here'), + subtitle: Text("Refresh indicator won't trigger"), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.2.dart b/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.2.dart new file mode 100644 index 000000000000..450ed0249c0b --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/refresh_indicator/refresh_indicator.2.dart @@ -0,0 +1,105 @@ +// 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:ui'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [RefreshIndicator.noSpinner]. + +void main() => runApp(const RefreshIndicatorExampleApp()); + +class RefreshIndicatorExampleApp extends StatelessWidget { + const RefreshIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: <PointerDeviceKind>{ + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + }, + ), + home: const RefreshIndicatorExample(), + ); + } +} + +class RefreshIndicatorExample extends StatefulWidget { + const RefreshIndicatorExample({super.key}); + + @override + State<RefreshIndicatorExample> createState() => + _RefreshIndicatorExampleState(); +} + +class _RefreshIndicatorExampleState extends State<RefreshIndicatorExample> { + bool _isRefreshing = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('RefreshIndicator.noSpinner Sample')), + body: Stack( + children: <Widget>[ + RefreshIndicator.noSpinner( + // Callback function used by the app to listen to the + // status of the RefreshIndicator pull-down action. + onStatusChange: (RefreshIndicatorStatus? status) { + if (status == RefreshIndicatorStatus.done) { + setState(() { + _isRefreshing = false; + }); + } + }, + + // Callback that gets called whenever the user pulls down to refresh. + onRefresh: () async { + // This can be also done in onStatusChange when the status is RefreshIndicatorStatus.refresh. + setState(() { + _isRefreshing = true; + }); + + // Replace this delay with the code to be executed during refresh + // and return asynchronous code. + return Future<void>.delayed(const Duration(seconds: 3)); + }, + + child: CustomScrollView( + slivers: <Widget>[ + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return ListTile( + tileColor: Colors.green[100], + title: const Text('Pull down here'), + subtitle: const Text( + 'A custom refresh indicator will be shown', + ), + ); + }, + ), + ], + ), + ), + + // Shows an overlay with a CircularProgressIndicator when refreshing. + if (_isRefreshing) + ColoredBox( + color: Colors.black45, + child: Align( + child: CircularProgressIndicator( + color: Colors.purple[500], + strokeWidth: 10, + semanticsLabel: 'Circular progress indicator', + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.0.dart b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.0.dart new file mode 100644 index 000000000000..9301c69b216e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.0.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ReorderableListView]. + +void main() => runApp(const ReorderableApp()); + +class ReorderableApp extends StatelessWidget { + const ReorderableApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ReorderableListView Sample')), + body: const ReorderableExample(), + ), + ); + } +} + +class ReorderableExample extends StatefulWidget { + const ReorderableExample({super.key}); + + @override + State<ReorderableExample> createState() => _ReorderableListViewExampleState(); +} + +class _ReorderableListViewExampleState extends State<ReorderableExample> { + final List<int> _items = List<int>.generate(50, (int index) => index); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color oddItemColor = colorScheme.primary.withValues(alpha: 0.05); + final Color evenItemColor = colorScheme.primary.withValues(alpha: 0.15); + + return ReorderableListView( + padding: const .symmetric(horizontal: 40), + children: <Widget>[ + for (int index = 0; index < _items.length; index += 1) + ListTile( + key: Key('$index'), + tileColor: _items[index].isOdd ? oddItemColor : evenItemColor, + title: Text('Item ${_items[index]}'), + ), + ], + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + final int item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.1.dart b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.1.dart new file mode 100644 index 000000000000..06aa478e4a55 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.1.dart @@ -0,0 +1,84 @@ +// 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:ui'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [ReorderableListView]. + +void main() => runApp(const ReorderableApp()); + +class ReorderableApp extends StatelessWidget { + const ReorderableApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ReorderableListView Sample')), + body: const ReorderableExample(), + ), + ); + } +} + +class ReorderableExample extends StatefulWidget { + const ReorderableExample({super.key}); + + @override + State<ReorderableExample> createState() => _ReorderableExampleState(); +} + +class _ReorderableExampleState extends State<ReorderableExample> { + final List<int> _items = List<int>.generate(50, (int index) => index); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color oddItemColor = colorScheme.secondary.withValues(alpha: 0.05); + final Color evenItemColor = colorScheme.secondary.withValues(alpha: 0.15); + final Color draggableItemColor = colorScheme.secondary; + + Widget proxyDecorator( + Widget child, + int index, + Animation<double> animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(0, 6, animValue)!; + return Material( + elevation: elevation, + color: draggableItemColor, + shadowColor: draggableItemColor, + child: child, + ); + }, + child: child, + ); + } + + return ReorderableListView( + padding: const .symmetric(horizontal: 40), + proxyDecorator: proxyDecorator, + children: <Widget>[ + for (int index = 0; index < _items.length; index += 1) + ListTile( + key: Key('$index'), + tileColor: _items[index].isOdd ? oddItemColor : evenItemColor, + title: Text('Item ${_items[index]}'), + ), + ], + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + final int item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.2.dart b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.2.dart new file mode 100644 index 000000000000..72e2caefc8c2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.2.dart @@ -0,0 +1,92 @@ +// 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:ui'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [ReorderableListView]. + +void main() => runApp(const ReorderableApp()); + +class ReorderableApp extends StatelessWidget { + const ReorderableApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ReorderableListView Sample')), + body: const ReorderableExample(), + ), + ); + } +} + +class ReorderableExample extends StatefulWidget { + const ReorderableExample({super.key}); + + @override + State<ReorderableExample> createState() => _ReorderableExampleState(); +} + +class _ReorderableExampleState extends State<ReorderableExample> { + final List<int> _items = List<int>.generate(50, (int index) => index); + + @override + Widget build(BuildContext context) { + final Color oddItemColor = Colors.lime.shade100; + final Color evenItemColor = Colors.deepPurple.shade100; + + final List<Card> cards = <Card>[ + for (int index = 0; index < _items.length; index += 1) + Card( + key: Key('$index'), + color: _items[index].isOdd ? oddItemColor : evenItemColor, + child: SizedBox( + height: 80, + child: Center(child: Text('Card ${_items[index]}')), + ), + ), + ]; + + Widget proxyDecorator( + Widget child, + int index, + Animation<double> animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(1, 6, animValue)!; + final double scale = lerpDouble(1, 1.02, animValue)!; + return Transform.scale( + scale: scale, + // Create a Card based on the color and the content of the dragged one + // and set its elevation to the animated value. + child: Card( + elevation: elevation, + color: cards[index].color, + child: cards[index].child, + ), + ); + }, + child: child, + ); + } + + return ReorderableListView( + padding: const .symmetric(horizontal: 40), + proxyDecorator: proxyDecorator, + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + final int item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + }, + children: cards, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.build_default_drag_handles.0.dart b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.build_default_drag_handles.0.dart new file mode 100644 index 000000000000..5b9bf7fbac5d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.build_default_drag_handles.0.dart @@ -0,0 +1,72 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ReorderableListView.buildDefaultDragHandles]. + +void main() => runApp(const ReorderableApp()); + +class ReorderableApp extends StatelessWidget { + const ReorderableApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ReorderableListView Sample')), + body: const ReorderableExample(), + ), + ); + } +} + +class ReorderableExample extends StatefulWidget { + const ReorderableExample({super.key}); + + @override + State<ReorderableExample> createState() => _ReorderableExampleState(); +} + +class _ReorderableExampleState extends State<ReorderableExample> { + final List<int> _items = List<int>.generate(50, (int index) => index); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color oddItemColor = colorScheme.primary.withValues(alpha: 0.05); + final Color evenItemColor = colorScheme.primary.withValues(alpha: 0.15); + + return ReorderableListView( + buildDefaultDragHandles: false, + children: <Widget>[ + for (int index = 0; index < _items.length; index++) + ColoredBox( + key: Key('$index'), + color: _items[index].isOdd ? oddItemColor : evenItemColor, + child: Row( + children: <Widget>[ + Container( + width: 64, + height: 64, + padding: const .all(8), + child: ReorderableDragStartListener( + index: index, + child: Card(color: colorScheme.primary, elevation: 2), + ), + ), + Text('Item ${_items[index]}'), + ], + ), + ), + ], + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + final int item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0.dart b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0.dart new file mode 100644 index 000000000000..89cba1064e34 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ReorderableListView.builder]. + +void main() => runApp(const ReorderableApp()); + +class ReorderableApp extends StatelessWidget { + const ReorderableApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ReorderableListView Sample')), + body: const Center(child: ReorderableExample()), + ), + ); + } +} + +class ReorderableExample extends StatefulWidget { + const ReorderableExample({super.key}); + + @override + State<ReorderableExample> createState() => _ReorderableExampleState(); +} + +class _ReorderableExampleState extends State<ReorderableExample> { + final List<int> _items = List<int>.generate(50, (int index) => index); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color oddItemColor = colorScheme.primary.withValues(alpha: 0.05); + final Color evenItemColor = colorScheme.primary.withValues(alpha: 0.15); + + return ReorderableListView.builder( + padding: const .symmetric(horizontal: 40), + itemCount: _items.length, + itemBuilder: (BuildContext context, int index) { + return ListTile( + key: Key('$index'), + tileColor: _items[index].isOdd ? oddItemColor : evenItemColor, + title: Text('Item ${_items[index]}'), + ); + }, + onReorderItem: (int oldIndex, int newIndex) { + setState(() { + final int item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.0.dart new file mode 100644 index 000000000000..a8e6954b90bc --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.0.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold]. + +void main() => runApp(const ScaffoldExampleApp()); + +class ScaffoldExampleApp extends StatelessWidget { + const ScaffoldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ScaffoldExample()); + } +} + +class ScaffoldExample extends StatefulWidget { + const ScaffoldExample({super.key}); + + @override + State<ScaffoldExample> createState() => _ScaffoldExampleState(); +} + +class _ScaffoldExampleState extends State<ScaffoldExample> { + int _count = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sample Code')), + body: Center(child: Text('You have pressed the button $_count times.')), + floatingActionButton: FloatingActionButton( + onPressed: () => setState(() => _count++), + tooltip: 'Increment Counter', + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.1.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.1.dart new file mode 100644 index 000000000000..e48a65b9da6d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.1.dart @@ -0,0 +1,43 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold]. + +void main() => runApp(const ScaffoldExampleApp()); + +class ScaffoldExampleApp extends StatelessWidget { + const ScaffoldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ScaffoldExample()); + } +} + +class ScaffoldExample extends StatefulWidget { + const ScaffoldExample({super.key}); + + @override + State<ScaffoldExample> createState() => _ScaffoldExampleState(); +} + +class _ScaffoldExampleState extends State<ScaffoldExample> { + int _count = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sample Code')), + body: Center(child: Text('You have pressed the button $_count times.')), + backgroundColor: Colors.blueGrey.shade200, + floatingActionButton: FloatingActionButton( + onPressed: () => setState(() => _count++), + tooltip: 'Increment Counter', + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.2.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.2.dart new file mode 100644 index 000000000000..a3b20f3471e7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.2.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold]. + +void main() => runApp(const ScaffoldExampleApp()); + +class ScaffoldExampleApp extends StatelessWidget { + const ScaffoldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ScaffoldExample()); + } +} + +class ScaffoldExample extends StatefulWidget { + const ScaffoldExample({super.key}); + + @override + State<ScaffoldExample> createState() => _ScaffoldExampleState(); +} + +class _ScaffoldExampleState extends State<ScaffoldExample> { + int _count = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sample Code')), + body: Center(child: Text('You have pressed the button $_count times.')), + bottomNavigationBar: BottomAppBar( + shape: const CircularNotchedRectangle(), + child: Container(height: 50.0), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => setState(() { + _count++; + }), + tooltip: 'Increment Counter', + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.drawer.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.drawer.0.dart new file mode 100644 index 000000000000..369d4c639201 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.drawer.0.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold.drawer]. + +void main() => runApp(const DrawerExampleApp()); + +class DrawerExampleApp extends StatelessWidget { + const DrawerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: DrawerExample()); + } +} + +class DrawerExample extends StatefulWidget { + const DrawerExample({super.key}); + + @override + State<DrawerExample> createState() => _DrawerExampleState(); +} + +class _DrawerExampleState extends State<DrawerExample> { + final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); + + void _openDrawer() { + _scaffoldKey.currentState!.openDrawer(); + } + + void _closeDrawer() { + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + appBar: AppBar(title: const Text('Drawer Demo')), + body: Center( + child: ElevatedButton( + onPressed: _openDrawer, + child: const Text('Open Drawer'), + ), + ), + drawer: Drawer( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('This is the Drawer'), + ElevatedButton( + onPressed: _closeDrawer, + child: const Text('Close Drawer'), + ), + ], + ), + ), + ), + // Disable opening the drawer with a swipe gesture. + drawerEnableOpenDragGesture: false, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.end_drawer.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.end_drawer.0.dart new file mode 100644 index 000000000000..fcefc165b341 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.end_drawer.0.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold.endDrawer]. + +void main() => runApp(const EndDrawerExampleApp()); + +class EndDrawerExampleApp extends StatelessWidget { + const EndDrawerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: EndDrawerExample()); + } +} + +class EndDrawerExample extends StatefulWidget { + const EndDrawerExample({super.key}); + + @override + State<EndDrawerExample> createState() => _EndDrawerExampleState(); +} + +class _EndDrawerExampleState extends State<EndDrawerExample> { + final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); + + void _openEndDrawer() { + _scaffoldKey.currentState!.openEndDrawer(); + } + + void _closeEndDrawer() { + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + appBar: AppBar(title: const Text('Drawer Demo')), + body: Center( + child: ElevatedButton( + onPressed: _openEndDrawer, + child: const Text('Open End Drawer'), + ), + ), + endDrawer: Drawer( + child: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('This is the Drawer'), + ElevatedButton( + onPressed: _closeEndDrawer, + child: const Text('Close Drawer'), + ), + ], + ), + ), + ), + // Disable opening the end drawer with a swipe gesture. + endDrawerEnableOpenDragGesture: false, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.floating_action_button_animator.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.floating_action_button_animator.0.dart new file mode 100644 index 000000000000..47dc60685349 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.floating_action_button_animator.0.dart @@ -0,0 +1,135 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold.floatingActionButtonAnimator]. + +void main() => runApp(const ScaffoldFloatingActionButtonAnimatorApp()); + +class ScaffoldFloatingActionButtonAnimatorApp extends StatelessWidget { + const ScaffoldFloatingActionButtonAnimatorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: ScaffoldFloatingActionButtonAnimatorExample(), + ); + } +} + +enum FabAnimator { defaultStyle, none } + +const List<(FabAnimator, String)> fabAnimatoregments = <(FabAnimator, String)>[ + (FabAnimator.defaultStyle, 'Default'), + (FabAnimator.none, 'None'), +]; + +enum FabLocation { centerFloat, endFloat, endTop } + +const List<(FabLocation, String)> fabLocationegments = <(FabLocation, String)>[ + (FabLocation.centerFloat, 'centerFloat'), + (FabLocation.endFloat, 'endFloat'), + (FabLocation.endTop, 'endTop'), +]; + +class ScaffoldFloatingActionButtonAnimatorExample extends StatefulWidget { + const ScaffoldFloatingActionButtonAnimatorExample({super.key}); + + @override + State<ScaffoldFloatingActionButtonAnimatorExample> createState() => + _ScaffoldFloatingActionButtonAnimatorExampleState(); +} + +class _ScaffoldFloatingActionButtonAnimatorExampleState + extends State<ScaffoldFloatingActionButtonAnimatorExample> { + Set<FabAnimator> _selectedFabAnimator = <FabAnimator>{ + FabAnimator.defaultStyle, + }; + Set<FabLocation> _selectedFabLocation = <FabLocation>{FabLocation.endFloat}; + FloatingActionButtonAnimator? _floatingActionButtonAnimator; + FloatingActionButtonLocation? _floatingActionButtonLocation; + bool _showFab = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButtonLocation: _floatingActionButtonLocation, + floatingActionButtonAnimator: _floatingActionButtonAnimator, + appBar: AppBar(title: const Text('FloatingActionButtonAnimator Sample')), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<FabAnimator>( + selected: _selectedFabAnimator, + onSelectionChanged: (Set<FabAnimator> styles) { + setState(() { + _floatingActionButtonAnimator = switch (styles.first) { + FabAnimator.defaultStyle => null, + FabAnimator.none => + FloatingActionButtonAnimator.noAnimation, + }; + _selectedFabAnimator = styles; + }); + }, + segments: fabAnimatoregments.map<ButtonSegment<FabAnimator>>(( + (FabAnimator, String) fabAnimator, + ) { + final FabAnimator animator = fabAnimator.$1; + final String label = fabAnimator.$2; + return ButtonSegment<FabAnimator>( + value: animator, + label: Text(label), + ); + }).toList(), + ), + const SizedBox(height: 10), + SegmentedButton<FabLocation>( + selected: _selectedFabLocation, + onSelectionChanged: (Set<FabLocation> styles) { + setState(() { + _floatingActionButtonLocation = switch (styles.first) { + FabLocation.centerFloat => + FloatingActionButtonLocation.centerFloat, + FabLocation.endFloat => + FloatingActionButtonLocation.endFloat, + FabLocation.endTop => FloatingActionButtonLocation.endTop, + }; + _selectedFabLocation = styles; + }); + }, + segments: fabLocationegments.map<ButtonSegment<FabLocation>>(( + (FabLocation, String) fabLocation, + ) { + final FabLocation location = fabLocation.$1; + final String label = fabLocation.$2; + return ButtonSegment<FabLocation>( + value: location, + label: Text(label), + ); + }).toList(), + ), + const SizedBox(height: 10), + FilledButton.icon( + onPressed: () { + setState(() { + _showFab = !_showFab; + }); + }, + icon: Icon(_showFab ? Icons.visibility_off : Icons.visibility), + label: const Text('Toggle FAB'), + ), + ], + ), + ), + floatingActionButton: !_showFab + ? null + : FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.of.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.of.0.dart new file mode 100644 index 000000000000..33b07cb98046 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.of.0.dart @@ -0,0 +1,62 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold.of]. + +void main() => runApp(const OfExampleApp()); + +class OfExampleApp extends StatelessWidget { + const OfExampleApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(primarySwatch: Colors.blue), + home: Scaffold( + body: const MyScaffoldBody(), + appBar: AppBar(title: const Text('Scaffold.of Example')), + ), + color: Colors.white, + ); + } +} + +class MyScaffoldBody extends StatelessWidget { + const MyScaffoldBody({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('SHOW BOTTOM SHEET'), + onPressed: () { + Scaffold.of(context).showBottomSheet((BuildContext context) { + return Container( + alignment: .center, + height: 200, + color: Colors.amber, + child: Center( + child: Column( + mainAxisSize: .min, + children: <Widget>[ + const Text('BottomSheet'), + ElevatedButton( + child: const Text('Close BottomSheet'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.of.1.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.of.1.dart new file mode 100644 index 000000000000..88ab3772ebf7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold.of.1.dart @@ -0,0 +1,63 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scaffold.of]. + +void main() => runApp(const OfExampleApp()); + +class OfExampleApp extends StatelessWidget { + const OfExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: OfExample()); + } +} + +class OfExample extends StatelessWidget { + const OfExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Scaffold.of Example')), + body: Builder( + // Create an inner BuildContext so that the onPressed methods + // can refer to the Scaffold with Scaffold.of(). + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('SHOW BOTTOM SHEET'), + onPressed: () { + Scaffold.of(context).showBottomSheet((BuildContext context) { + return Container( + alignment: .center, + height: 200, + color: Colors.amber, + child: Center( + child: Column( + mainAxisSize: .min, + children: <Widget>[ + const Text('BottomSheet'), + ElevatedButton( + child: const Text('Close BottomSheet'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }); + }, + ), + ); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.0.dart new file mode 100644 index 000000000000..903dd6399f4d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.0.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldMessenger]. + +void main() => runApp(const ScaffoldMessengerExampleApp()); + +class ScaffoldMessengerExampleApp extends StatelessWidget { + const ScaffoldMessengerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldMessenger Sample')), + body: const Center(child: ScaffoldMessengerExample()), + ), + ); + } +} + +class ScaffoldMessengerExample extends StatelessWidget { + const ScaffoldMessengerExample({super.key}); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('A SnackBar has been shown.')), + ); + }, + child: const Text('Show SnackBar'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.of.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.of.0.dart new file mode 100644 index 000000000000..a905a37f5715 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.of.0.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldMessenger.of]. + +void main() => runApp(const OfExampleApp()); + +class OfExampleApp extends StatelessWidget { + const OfExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldMessenger.of Sample')), + body: const Center(child: OfExample()), + ), + ); + } +} + +class OfExample extends StatelessWidget { + const OfExample({super.key}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + child: const Text('SHOW A SNACKBAR'), + onPressed: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Have a snack!'))); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.of.1.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.of.1.dart new file mode 100644 index 000000000000..68736071a93e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger.of.1.dart @@ -0,0 +1,60 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldMessenger.of]. + +void main() => runApp(const OfExampleApp()); + +class OfExampleApp extends StatefulWidget { + const OfExampleApp({super.key}); + + @override + State<OfExampleApp> createState() => _OfExampleAppState(); +} + +class _OfExampleAppState extends State<OfExampleApp> { + final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey = + GlobalKey<ScaffoldMessengerState>(); + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + if (_counter % 10 == 0) { + _scaffoldMessengerKey.currentState!.showSnackBar( + const SnackBar(content: Text('A multiple of ten!')), + ); + } + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + scaffoldMessengerKey: _scaffoldMessengerKey, + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldMessenger Demo')), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('You have pushed the button this many times:'), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_material_banner.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_material_banner.0.dart new file mode 100644 index 000000000000..46db41c495f4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_material_banner.0.dart @@ -0,0 +1,44 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldMessengerState.showMaterialBanner]. + +void main() => runApp(const ShowMaterialBannerExampleApp()); + +class ShowMaterialBannerExampleApp extends StatelessWidget { + const ShowMaterialBannerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldMessengerState Sample')), + body: const Center(child: ShowMaterialBannerExample()), + ), + ); + } +} + +class ShowMaterialBannerExample extends StatelessWidget { + const ShowMaterialBannerExample({super.key}); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () { + ScaffoldMessenger.of(context).showMaterialBanner( + const MaterialBanner( + content: Text('This is a MaterialBanner'), + actions: <Widget>[ + TextButton(onPressed: null, child: Text('DISMISS')), + ], + ), + ); + }, + child: const Text('Show MaterialBanner'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.0.dart new file mode 100644 index 000000000000..2586dcae20a0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.0.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldMessengerState.showSnackBar]. + +void main() => runApp(const ShowSnackBarExampleApp()); + +class ShowSnackBarExampleApp extends StatelessWidget { + const ShowSnackBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldMessengerState Sample')), + body: const Center(child: ShowSnackBarExample()), + ), + ); + } +} + +class ShowSnackBarExample extends StatelessWidget { + const ShowSnackBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('A SnackBar has been shown.')), + ); + }, + child: const Text('Show SnackBar'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.1.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.1.dart new file mode 100644 index 000000000000..2eedf6f654d3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.1.dart @@ -0,0 +1,74 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SnackBar]. + +void main() => runApp(const SnackBarApp()); + +class SnackBarApp extends StatelessWidget { + const SnackBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SnackBarExample()); + } +} + +class SnackBarExample extends StatefulWidget { + const SnackBarExample({super.key}); + + @override + State<SnackBarExample> createState() => _SnackBarExampleState(); +} + +class _SnackBarExampleState extends State<SnackBarExample> { + bool _largeLogo = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + body: Padding( + padding: const .all(8.0), + child: Column( + children: <Widget>[ + ElevatedButton( + onPressed: () { + const SnackBar snackBar = SnackBar( + content: Text('A SnackBar has been shown.'), + behavior: .floating, + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, + child: const Text('Show SnackBar'), + ), + const SizedBox(height: 8.0), + ElevatedButton( + onPressed: () { + setState(() => _largeLogo = !_largeLogo); + }, + child: Text(_largeLogo ? 'Shrink Logo' : 'Grow Logo'), + ), + ], + ), + ), + // A floating [SnackBar] is positioned above [Scaffold.floatingActionButton]. + // If the Widget provided to the floatingActionButton slot takes up too much space + // for the SnackBar to be visible, an error will be thrown. + floatingActionButton: Container( + constraints: BoxConstraints.tightFor( + width: 150, + height: _largeLogo ? double.infinity : 150, + ), + decoration: const BoxDecoration( + color: Colors.blueGrey, + borderRadius: .all(Radius.circular(20)), + ), + child: const FlutterLogo(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.2.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.2.dart new file mode 100644 index 000000000000..2922ebf6fbef --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_messenger_state.show_snack_bar.2.dart @@ -0,0 +1,98 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SnackBar]. + +void main() => runApp(const SnackBarApp()); + +class SnackBarApp extends StatelessWidget { + const SnackBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SnackBarExample()); + } +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), + ]; + +class SnackBarExample extends StatefulWidget { + const SnackBarExample({super.key}); + + @override + State<SnackBarExample> createState() => _SnackBarExampleState(); +} + +class _SnackBarExampleState extends State<SnackBarExample> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + AnimationStyles.defaultStyle, + }; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyle = switch (styles.first) { + AnimationStyles.defaultStyle => null, + AnimationStyles.custom => const AnimationStyle( + duration: Duration(seconds: 3), + reverseDuration: Duration(seconds: 1), + ), + AnimationStyles.none => AnimationStyle.noAnimation, + }; + _animationStyleSelection = styles; + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 10), + Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + showCloseIcon: true, + ), + snackBarAnimationStyle: _animationStyle, + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_state.show_bottom_sheet.0.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_state.show_bottom_sheet.0.dart new file mode 100644 index 000000000000..a08e97ee9151 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_state.show_bottom_sheet.0.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldState.showBottomSheet]. + +void main() => runApp(const ShowBottomSheetExampleApp()); + +class ShowBottomSheetExampleApp extends StatelessWidget { + const ShowBottomSheetExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldState Sample')), + body: const ShowBottomSheetExample(), + ), + ); + } +} + +class ShowBottomSheetExample extends StatelessWidget { + const ShowBottomSheetExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('showBottomSheet'), + onPressed: () { + Scaffold.of(context).showBottomSheet((BuildContext context) { + return Container( + height: 200, + color: Colors.amber, + child: Center( + child: Column( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: <Widget>[ + const Text('BottomSheet'), + ElevatedButton( + child: const Text('Close BottomSheet'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_state.show_bottom_sheet.1.dart b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_state.show_bottom_sheet.1.dart new file mode 100644 index 000000000000..a5327e88e81e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scaffold/scaffold_state.show_bottom_sheet.1.dart @@ -0,0 +1,112 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ScaffoldState.showBottomSheet]. + +void main() => runApp(const ShowBottomSheetExampleApp()); + +class ShowBottomSheetExampleApp extends StatelessWidget { + const ShowBottomSheetExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ScaffoldState BottomSheet Sample')), + body: const ShowBottomSheetExample(), + ), + ); + } +} + +enum AnimationStyles { defaultStyle, custom, none } + +const List<(AnimationStyles, String)> animationStyleSegments = + <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), + ]; + +class ShowBottomSheetExample extends StatefulWidget { + const ShowBottomSheetExample({super.key}); + + @override + State<ShowBottomSheetExample> createState() => _ShowBottomSheetExampleState(); +} + +class _ShowBottomSheetExampleState extends State<ShowBottomSheetExample> { + Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{ + AnimationStyles.defaultStyle, + }; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + SegmentedButton<AnimationStyles>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<AnimationStyles> styles) { + setState(() { + _animationStyle = switch (styles.first) { + AnimationStyles.defaultStyle => null, + AnimationStyles.custom => const AnimationStyle( + duration: Duration(seconds: 3), + reverseDuration: Duration(seconds: 1), + ), + AnimationStyles.none => AnimationStyle.noAnimation, + }; + _animationStyleSelection = styles; + }); + }, + segments: animationStyleSegments + .map<ButtonSegment<AnimationStyles>>(( + (AnimationStyles, String) shirt, + ) { + return ButtonSegment<AnimationStyles>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 10), + ElevatedButton( + child: const Text('showBottomSheet'), + onPressed: () { + Scaffold.of(context).showBottomSheet( + sheetAnimationStyle: _animationStyle, + (BuildContext context) { + return SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisAlignment: .center, + mainAxisSize: .min, + children: <Widget>[ + const Text('BottomSheet'), + ElevatedButton( + child: const Text('Close'), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scrollbar/scrollbar.0.dart b/packages/material_ui/material_ui_examples/lib/scrollbar/scrollbar.0.dart new file mode 100644 index 000000000000..ae8eb8d5ed92 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scrollbar/scrollbar.0.dart @@ -0,0 +1,43 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scrollbar]. + +void main() => runApp(const ScrollbarExampleApp()); + +class ScrollbarExampleApp extends StatelessWidget { + const ScrollbarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Scrollbar Sample')), + body: const ScrollbarExample(), + ), + ); + } +} + +class ScrollbarExample extends StatelessWidget { + const ScrollbarExample({super.key}); + + @override + Widget build(BuildContext context) { + return Scrollbar( + child: GridView.builder( + primary: true, + itemCount: 120, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemBuilder: (BuildContext context, int index) { + return Center(child: Text('item $index')); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/scrollbar/scrollbar.1.dart b/packages/material_ui/material_ui_examples/lib/scrollbar/scrollbar.1.dart new file mode 100644 index 000000000000..a2981e1f47d1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/scrollbar/scrollbar.1.dart @@ -0,0 +1,52 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Scrollbar]. + +void main() => runApp(const ScrollbarExampleApp()); + +class ScrollbarExampleApp extends StatelessWidget { + const ScrollbarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Scrollbar Sample')), + body: const ScrollbarExample(), + ), + ); + } +} + +class ScrollbarExample extends StatefulWidget { + const ScrollbarExample({super.key}); + + @override + State<ScrollbarExample> createState() => _ScrollbarExampleState(); +} + +class _ScrollbarExampleState extends State<ScrollbarExample> { + final ScrollController _controllerOne = ScrollController(); + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: _controllerOne, + thumbVisibility: true, + child: GridView.builder( + controller: _controllerOne, + itemCount: 120, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemBuilder: (BuildContext context, int index) { + return Center(child: Text('item $index')); + }, + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.0.dart b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.0.dart new file mode 100644 index 000000000000..2a915762e7c8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.0.dart @@ -0,0 +1,146 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SearchAnchor.bar]. + +void main() => runApp(const SearchBarApp()); + +class SearchBarApp extends StatefulWidget { + const SearchBarApp({super.key}); + + @override + State<SearchBarApp> createState() => _SearchBarAppState(); +} + +class _SearchBarAppState extends State<SearchBarApp> { + Color? selectedColorSeed; + List<ColorLabel> searchHistory = <ColorLabel>[]; + + Iterable<Widget> getHistoryList(SearchController controller) { + return searchHistory.map( + (ColorLabel color) => ListTile( + leading: const Icon(Icons.history), + title: Text(color.label), + trailing: IconButton( + icon: const Icon(Icons.call_missed), + onPressed: () { + controller.text = color.label; + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + }, + ), + ), + ); + } + + Iterable<Widget> getSuggestions(SearchController controller) { + final String input = controller.value.text; + return ColorLabel.values + .where((ColorLabel color) => color.label.contains(input)) + .map( + (ColorLabel filteredColor) => ListTile( + leading: CircleAvatar(backgroundColor: filteredColor.color), + title: Text(filteredColor.label), + trailing: IconButton( + icon: const Icon(Icons.call_missed), + onPressed: () { + controller.text = filteredColor.label; + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + }, + ), + onTap: () { + controller.closeView(filteredColor.label); + handleSelection(filteredColor); + }, + ), + ); + } + + void handleSelection(ColorLabel selectedColor) { + setState(() { + selectedColorSeed = selectedColor.color; + if (searchHistory.length >= 5) { + searchHistory.removeLast(); + } + searchHistory.insert(0, selectedColor); + }); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = ThemeData(colorSchemeSeed: selectedColorSeed); + final ColorScheme colors = themeData.colorScheme; + + return MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Search Bar Sample')), + body: Align( + alignment: .topCenter, + child: Column( + children: <Widget>[ + SearchAnchor.bar( + barHintText: 'Search colors', + suggestionsBuilder: + (BuildContext context, SearchController controller) { + if (controller.text.isEmpty) { + if (searchHistory.isNotEmpty) { + return getHistoryList(controller); + } + return <Widget>[ + Center( + child: Text( + 'No search history.', + style: TextStyle(color: colors.outline), + ), + ), + ]; + } + return getSuggestions(controller); + }, + ), + cardSize, + Card(color: colors.primary, child: cardSize), + Card(color: colors.onPrimary, child: cardSize), + Card(color: colors.primaryContainer, child: cardSize), + Card(color: colors.onPrimaryContainer, child: cardSize), + Card(color: colors.secondary, child: cardSize), + Card(color: colors.onSecondary, child: cardSize), + ], + ), + ), + ), + ); + } +} + +SizedBox cardSize = const SizedBox(width: 80, height: 30); + +enum ColorLabel { + red('red', Colors.red), + orange('orange', Colors.orange), + yellow('yellow', Colors.yellow), + green('green', Colors.green), + blue('blue', Colors.blue), + indigo('indigo', Colors.indigo), + violet('violet', Color(0xFF8F00FF)), + purple('purple', Colors.purple), + pink('pink', Colors.pink), + silver('silver', Color(0xFF808080)), + gold('gold', Color(0xFFFFD700)), + beige('beige', Color(0xFFF5F5DC)), + brown('brown', Colors.brown), + grey('grey', Colors.grey), + black('black', Colors.black), + white('white', Colors.white); + + const ColorLabel(this.label, this.color); + final String label; + final Color color; +} diff --git a/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.1.dart b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.1.dart new file mode 100644 index 000000000000..40778c39dfd9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.1.dart @@ -0,0 +1,86 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for pinned [SearchAnchor] while scrolling. + +void main() { + runApp(const PinnedSearchBarApp()); +} + +class PinnedSearchBarApp extends StatefulWidget { + const PinnedSearchBarApp({super.key}); + + @override + State<PinnedSearchBarApp> createState() => _PinnedSearchBarAppState(); +} + +class _PinnedSearchBarAppState extends State<PinnedSearchBarApp> { + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + clipBehavior: .none, + shape: const StadiumBorder(), + scrolledUnderElevation: 0.0, + titleSpacing: 0.0, + backgroundColor: Colors.transparent, + floating: + true, // We can also uncomment this line and set `pinned` to true to see a pinned search bar. + title: SearchAnchor.bar( + suggestionsBuilder: + (BuildContext context, SearchController controller) { + return List<Widget>.generate(5, (int index) { + return ListTile( + titleAlignment: .center, + title: Text('Initial list item $index'), + ); + }); + }, + ), + ), + // The listed items below are just for filling the screen + // so we can see the scrolling effect. + SliverToBoxAdapter( + child: Padding( + padding: const .all(20), + child: SizedBox( + height: 100.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 10, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + width: 100.0, + child: Card( + child: Center(child: Text('Card $index')), + ), + ); + }, + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const .symmetric(horizontal: 20), + child: Container( + height: 1000, + color: Colors.deepPurple.withValues(alpha: 0.5), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.2.dart b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.2.dart new file mode 100644 index 000000000000..77700e3e3f1d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.2.dart @@ -0,0 +1,63 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SearchAnchor]. + +void main() => runApp(const SearchBarApp()); + +class SearchBarApp extends StatefulWidget { + const SearchBarApp({super.key}); + + @override + State<SearchBarApp> createState() => _SearchBarAppState(); +} + +class _SearchBarAppState extends State<SearchBarApp> { + final SearchController controller = SearchController(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Search Anchor Sample')), + body: Column( + children: <Widget>[ + SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: + (BuildContext context, SearchController controller) { + return List<ListTile>.generate(5, (int index) { + final String item = 'item $index'; + return ListTile( + title: Text(item), + onTap: () { + setState(() { + controller.closeView(item); + }); + }, + ); + }); + }, + ), + Center( + child: controller.text.isEmpty + ? const Text('No item selected') + : Text('Selected item: ${controller.value.text}'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.3.dart b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.3.dart new file mode 100644 index 000000000000..e50b19b5a5c0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.3.dart @@ -0,0 +1,96 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SearchAnchor]. + +const Duration fakeAPIDuration = Duration(seconds: 1); + +void main() => runApp(const SearchAnchorAsyncExampleApp()); + +class SearchAnchorAsyncExampleApp extends StatelessWidget { + const SearchAnchorAsyncExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SearchAnchor - async')), + body: const Center(child: _AsyncSearchAnchor()), + ), + ); + } +} + +class _AsyncSearchAnchor extends StatefulWidget { + const _AsyncSearchAnchor(); + + @override + State<_AsyncSearchAnchor> createState() => _AsyncSearchAnchorState(); +} + +class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor> { + // The query currently being searched for. If null, there is no pending + // request. + String? _searchingWithQuery; + + // The most recent options received from the API. + late Iterable<Widget> _lastOptions = <Widget>[]; + + @override + Widget build(BuildContext context) { + return SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: + (BuildContext context, SearchController controller) async { + _searchingWithQuery = controller.text; + final List<String> options = (await _FakeAPI.search( + _searchingWithQuery!, + )).toList(); + + // If another search happened after this one, throw away these options. + // Use the previous options instead and wait for the newer request to + // finish. + if (_searchingWithQuery != controller.text) { + return _lastOptions; + } + + _lastOptions = List<ListTile>.generate(options.length, (int index) { + final String item = options[index]; + return ListTile(title: Text(item)); + }); + + return _lastOptions; + }, + ); + } +} + +// Mimics a remote API. +class _FakeAPI { + static const List<String> _kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + + // Searches the options, but injects a fake "network" delay. + static Future<Iterable<String>> search(String query) async { + await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay. + if (query == '') { + return const Iterable<String>.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.4.dart b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.4.dart new file mode 100644 index 000000000000..7c1b2a7f02ed --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/search_anchor/search_anchor.4.dart @@ -0,0 +1,176 @@ +// 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:async'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [SearchAnchor]. + +const Duration fakeAPIDuration = Duration(seconds: 1); +const Duration debounceDuration = Duration(milliseconds: 500); + +void main() => runApp(const SearchAnchorAsyncExampleApp()); + +class SearchAnchorAsyncExampleApp extends StatelessWidget { + const SearchAnchorAsyncExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('SearchAnchor - async and debouncing'), + ), + body: const Center(child: _AsyncSearchAnchor()), + ), + ); + } +} + +class _AsyncSearchAnchor extends StatefulWidget { + const _AsyncSearchAnchor(); + + @override + State<_AsyncSearchAnchor> createState() => _AsyncSearchAnchorState(); +} + +class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor> { + // The query currently being searched for. If null, there is no pending + // request. + String? _currentQuery; + + // The most recent suggestions received from the API. + late Iterable<Widget> _lastOptions = <Widget>[]; + + late final _Debounceable<Iterable<String>?, String> _debouncedSearch; + + // Calls the "remote" API to search with the given query. Returns null when + // the call has been made obsolete. + Future<Iterable<String>?> _search(String query) async { + _currentQuery = query; + + // In a real application, there should be some error handling here. + final Iterable<String> options = await _FakeAPI.search(_currentQuery!); + + // If another search happened after this one, throw away these options. + if (_currentQuery != query) { + return null; + } + _currentQuery = null; + + return options; + } + + @override + void initState() { + super.initState(); + _debouncedSearch = _debounce<Iterable<String>?, String>(_search); + } + + @override + Widget build(BuildContext context) { + return SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: + (BuildContext context, SearchController controller) async { + final List<String>? options = (await _debouncedSearch( + controller.text, + ))?.toList(); + if (options == null) { + return _lastOptions; + } + _lastOptions = List<ListTile>.generate(options.length, (int index) { + final String item = options[index]; + return ListTile( + title: Text(item), + onTap: () { + debugPrint('You just selected $item'); + }, + ); + }); + + return _lastOptions; + }, + ); + } +} + +// Mimics a remote API. +class _FakeAPI { + static const List<String> _kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + + // Searches the options, but injects a fake "network" delay. + static Future<Iterable<String>> search(String query) async { + await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay. + if (query == '') { + return const Iterable<String>.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} + +typedef _Debounceable<S, T> = Future<S?> Function(T parameter); + +/// Returns a new function that is a debounced version of the given function. +/// +/// This means that the original function will be called only after no calls +/// have been made for the given Duration. +_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { + _DebounceTimer? debounceTimer; + + return (T parameter) async { + if (debounceTimer != null && !debounceTimer!.isCompleted) { + debounceTimer!.cancel(); + } + debounceTimer = _DebounceTimer(); + try { + await debounceTimer!.future; + } on _CancelException { + return null; + } + return function(parameter); + }; +} + +// A wrapper around Timer used for debouncing. +class _DebounceTimer { + _DebounceTimer() { + _timer = Timer(debounceDuration, _onComplete); + } + + late final Timer _timer; + final Completer<void> _completer = Completer<void>(); + + void _onComplete() { + _completer.complete(); + } + + Future<void> get future => _completer.future; + + bool get isCompleted => _completer.isCompleted; + + void cancel() { + _timer.cancel(); + _completer.completeError(const _CancelException()); + } +} + +// An exception indicating that the timer was canceled. +class _CancelException implements Exception { + const _CancelException(); +} diff --git a/packages/material_ui/material_ui_examples/lib/search_anchor/search_bar.0.dart b/packages/material_ui/material_ui_examples/lib/search_anchor/search_bar.0.dart new file mode 100644 index 000000000000..ba202edba882 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/search_anchor/search_bar.0.dart @@ -0,0 +1,81 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SearchBar]. + +void main() => runApp(const SearchBarApp()); + +class SearchBarApp extends StatefulWidget { + const SearchBarApp({super.key}); + + @override + State<SearchBarApp> createState() => _SearchBarAppState(); +} + +class _SearchBarAppState extends State<SearchBarApp> { + bool isDark = false; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = ThemeData(brightness: isDark ? .dark : .light); + + return MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Search Bar Sample')), + body: Padding( + padding: const .all(8.0), + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return SearchBar( + controller: controller, + padding: const WidgetStatePropertyAll<EdgeInsets>( + EdgeInsets.symmetric(horizontal: 16.0), + ), + onTap: () { + controller.openView(); + }, + onChanged: (_) { + controller.openView(); + }, + leading: const Icon(Icons.search), + trailing: <Widget>[ + Tooltip( + message: 'Change brightness mode', + child: IconButton( + isSelected: isDark, + onPressed: () { + setState(() { + isDark = !isDark; + }); + }, + icon: const Icon(Icons.wb_sunny_outlined), + selectedIcon: const Icon(Icons.brightness_2_outlined), + ), + ), + ], + ); + }, + suggestionsBuilder: + (BuildContext context, SearchController controller) { + return List<ListTile>.generate(5, (int index) { + final String item = 'item $index'; + return ListTile( + title: Text(item), + onTap: () { + setState(() { + controller.closeView(item); + }); + }, + ); + }); + }, + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/segmented_button/segmented_button.0.dart b/packages/material_ui/material_ui_examples/lib/segmented_button/segmented_button.0.dart new file mode 100644 index 000000000000..90543a637d8d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/segmented_button/segmented_button.0.dart @@ -0,0 +1,120 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SegmentedButton]. + +void main() { + runApp(const SegmentedButtonApp()); +} + +class SegmentedButtonApp extends StatelessWidget { + const SegmentedButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Spacer(), + Text('Single choice'), + SingleChoice(), + SizedBox(height: 20), + Text('Multiple choice'), + MultipleChoice(), + Spacer(), + ], + ), + ), + ), + ); + } +} + +enum Calendar { day, week, month, year } + +class SingleChoice extends StatefulWidget { + const SingleChoice({super.key}); + + @override + State<SingleChoice> createState() => _SingleChoiceState(); +} + +class _SingleChoiceState extends State<SingleChoice> { + Calendar calendarView = .day; + + @override + Widget build(BuildContext context) { + return SegmentedButton<Calendar>( + segments: const <ButtonSegment<Calendar>>[ + ButtonSegment<Calendar>( + value: Calendar.day, + label: Text('Day'), + icon: Icon(Icons.calendar_view_day), + ), + ButtonSegment<Calendar>( + value: Calendar.week, + label: Text('Week'), + icon: Icon(Icons.calendar_view_week), + ), + ButtonSegment<Calendar>( + value: Calendar.month, + label: Text('Month'), + icon: Icon(Icons.calendar_view_month), + ), + ButtonSegment<Calendar>( + value: Calendar.year, + label: Text('Year'), + icon: Icon(Icons.calendar_today), + ), + ], + selected: <Calendar>{calendarView}, + onSelectionChanged: (Set<Calendar> newSelection) { + setState(() { + // By default there is only a single segment that can be + // selected at one time, so its value is always the first + // item in the selected set. + calendarView = newSelection.first; + }); + }, + ); + } +} + +enum Sizes { extraSmall, small, medium, large, extraLarge } + +class MultipleChoice extends StatefulWidget { + const MultipleChoice({super.key}); + + @override + State<MultipleChoice> createState() => _MultipleChoiceState(); +} + +class _MultipleChoiceState extends State<MultipleChoice> { + Set<Sizes> selection = <Sizes>{Sizes.large, Sizes.extraLarge}; + + @override + Widget build(BuildContext context) { + return SegmentedButton<Sizes>( + segments: const <ButtonSegment<Sizes>>[ + ButtonSegment<Sizes>(value: Sizes.extraSmall, label: Text('XS')), + ButtonSegment<Sizes>(value: Sizes.small, label: Text('S')), + ButtonSegment<Sizes>(value: Sizes.medium, label: Text('M')), + ButtonSegment<Sizes>(value: Sizes.large, label: Text('L')), + ButtonSegment<Sizes>(value: Sizes.extraLarge, label: Text('XL')), + ], + selected: selection, + onSelectionChanged: (Set<Sizes> newSelection) { + setState(() { + selection = newSelection; + }); + }, + multiSelectionEnabled: true, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/segmented_button/segmented_button.1.dart b/packages/material_ui/material_ui_examples/lib/segmented_button/segmented_button.1.dart new file mode 100644 index 000000000000..96b195fb35d6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/segmented_button/segmented_button.1.dart @@ -0,0 +1,78 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SegmentedButton.styleFrom]. + +void main() { + runApp(const SegmentedButtonApp()); +} + +class SegmentedButtonApp extends StatelessWidget { + const SegmentedButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold(body: Center(child: SegmentedButtonExample())), + ); + } +} + +class SegmentedButtonExample extends StatefulWidget { + const SegmentedButtonExample({super.key}); + + @override + State<SegmentedButtonExample> createState() => _SegmentedButtonExampleState(); +} + +enum Calendar { day, week, month, year } + +class _SegmentedButtonExampleState extends State<SegmentedButtonExample> { + Calendar calendarView = .week; + + @override + Widget build(BuildContext context) { + return SegmentedButton<Calendar>( + style: SegmentedButton.styleFrom( + backgroundColor: Colors.grey[200], + foregroundColor: Colors.red, + selectedForegroundColor: Colors.white, + selectedBackgroundColor: Colors.green, + ), + segments: const <ButtonSegment<Calendar>>[ + ButtonSegment<Calendar>( + value: Calendar.day, + label: Text('Day'), + icon: Icon(Icons.calendar_view_day), + ), + ButtonSegment<Calendar>( + value: Calendar.week, + label: Text('Week'), + icon: Icon(Icons.calendar_view_week), + ), + ButtonSegment<Calendar>( + value: Calendar.month, + label: Text('Month'), + icon: Icon(Icons.calendar_view_month), + ), + ButtonSegment<Calendar>( + value: Calendar.year, + label: Text('Year'), + icon: Icon(Icons.calendar_today), + ), + ], + selected: <Calendar>{calendarView}, + onSelectionChanged: (Set<Calendar> newSelection) { + setState(() { + // By default there is only a single segment that can be + // selected at one time, so its value is always the first + // item in the selected set. + calendarView = newSelection.first; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/selectable_region/selectable_region.0.dart b/packages/material_ui/material_ui_examples/lib/selectable_region/selectable_region.0.dart new file mode 100644 index 000000000000..6d429f9d5581 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/selectable_region/selectable_region.0.dart @@ -0,0 +1,377 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; + +/// Flutter code sample for [SelectableRegion]. + +void main() => runApp(const SelectableRegionExampleApp()); + +class SelectableRegionExampleApp extends StatelessWidget { + const SelectableRegionExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: SelectableRegion( + selectionControls: materialTextSelectionControls, + child: Scaffold( + appBar: AppBar(title: const Text('SelectableRegion Sample')), + body: const Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text('Select this icon', style: TextStyle(fontSize: 30)), + SizedBox(height: 10), + MySelectableAdapter(child: Icon(Icons.key, size: 30)), + ], + ), + ), + ), + ), + ); + } +} + +class MySelectableAdapter extends StatelessWidget { + const MySelectableAdapter({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); + if (registrar == null) { + return child; + } + return MouseRegion( + cursor: SystemMouseCursors.text, + child: _SelectableAdapter(registrar: registrar, child: child), + ); + } +} + +class _SelectableAdapter extends SingleChildRenderObjectWidget { + const _SelectableAdapter({required this.registrar, required Widget child}) + : super(child: child); + + final SelectionRegistrar registrar; + + @override + _RenderSelectableAdapter createRenderObject(BuildContext context) { + return _RenderSelectableAdapter( + DefaultSelectionStyle.of(context).selectionColor!, + registrar, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderSelectableAdapter renderObject, + ) { + renderObject + ..selectionColor = DefaultSelectionStyle.of(context).selectionColor! + ..registrar = registrar; + } +} + +class _RenderSelectableAdapter extends RenderProxyBox + with Selectable, SelectionRegistrant { + _RenderSelectableAdapter(Color selectionColor, SelectionRegistrar registrar) + : _selectionColor = selectionColor, + _geometry = ValueNotifier<SelectionGeometry>(_noSelection) { + this.registrar = registrar; + _geometry.addListener(markNeedsPaint); + } + + static const SelectionGeometry _noSelection = SelectionGeometry( + status: SelectionStatus.none, + hasContent: true, + ); + final ValueNotifier<SelectionGeometry> _geometry; + + Color get selectionColor => _selectionColor; + Color _selectionColor; + set selectionColor(Color value) { + if (_selectionColor == value) { + return; + } + _selectionColor = value; + markNeedsPaint(); + } + + // ValueListenable APIs + + @override + void addListener(VoidCallback listener) => _geometry.addListener(listener); + + @override + void removeListener(VoidCallback listener) => + _geometry.removeListener(listener); + + @override + SelectionGeometry get value => _geometry.value; + + // Selectable APIs. + + @override + List<Rect> get boundingBoxes => <Rect>[paintBounds]; + + // Adjust this value to enlarge or shrink the selection highlight. + static const double _padding = 10.0; + Rect _getSelectionHighlightRect() { + return Rect.fromLTWH( + 0 - _padding, + 0 - _padding, + size.width + _padding * 2, + size.height + _padding * 2, + ); + } + + Offset? _start; + Offset? _end; + void _updateGeometry() { + if (_start == null || _end == null) { + _geometry.value = _noSelection; + return; + } + final Rect renderObjectRect = Rect.fromLTWH(0, 0, size.width, size.height); + final Rect selectionRect = Rect.fromPoints(_start!, _end!); + if (renderObjectRect.intersect(selectionRect).isEmpty) { + _geometry.value = _noSelection; + } else { + final Rect selectionRect = _getSelectionHighlightRect(); + final SelectionPoint firstSelectionPoint = SelectionPoint( + localPosition: selectionRect.bottomLeft, + lineHeight: selectionRect.size.height, + handleType: TextSelectionHandleType.left, + ); + final SelectionPoint secondSelectionPoint = SelectionPoint( + localPosition: selectionRect.bottomRight, + lineHeight: selectionRect.size.height, + handleType: TextSelectionHandleType.right, + ); + final bool isReversed; + if (_start!.dy > _end!.dy) { + isReversed = true; + } else if (_start!.dy < _end!.dy) { + isReversed = false; + } else { + isReversed = _start!.dx > _end!.dx; + } + _geometry.value = SelectionGeometry( + status: SelectionStatus.uncollapsed, + hasContent: true, + startSelectionPoint: isReversed + ? secondSelectionPoint + : firstSelectionPoint, + endSelectionPoint: isReversed + ? firstSelectionPoint + : secondSelectionPoint, + selectionRects: <Rect>[selectionRect], + ); + } + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + SelectionResult result = .none; + switch (event.type) { + case SelectionEventType.startEdgeUpdate: + case SelectionEventType.endEdgeUpdate: + final Rect renderObjectRect = Rect.fromLTWH( + 0, + 0, + size.width, + size.height, + ); + // Normalize offset in case it is out side of the rect. + final Offset point = globalToLocal( + (event as SelectionEdgeUpdateEvent).globalPosition, + ); + final Offset adjustedPoint = SelectionUtils.adjustDragOffset( + renderObjectRect, + point, + ); + if (event.type == SelectionEventType.startEdgeUpdate) { + _start = adjustedPoint; + } else { + _end = adjustedPoint; + } + result = SelectionUtils.getResultBasedOnRect(renderObjectRect, point); + case SelectionEventType.clear: + _start = _end = null; + case SelectionEventType.selectAll: + case SelectionEventType.selectWord: + case SelectionEventType.selectParagraph: + _start = Offset.zero; + _end = Offset.infinite; + case SelectionEventType.granularlyExtendSelection: + result = SelectionResult.end; + final GranularlyExtendSelectionEvent extendSelectionEvent = + event as GranularlyExtendSelectionEvent; + // Initialize the offset it there is no ongoing selection. + if (_start == null || _end == null) { + if (extendSelectionEvent.forward) { + _start = _end = Offset.zero; + } else { + _start = _end = Offset.infinite; + } + } + // Move the corresponding selection edge. + final Offset newOffset = extendSelectionEvent.forward + ? Offset.infinite + : Offset.zero; + if (extendSelectionEvent.isEnd) { + if (newOffset == _end) { + result = extendSelectionEvent.forward + ? SelectionResult.next + : SelectionResult.previous; + } + _end = newOffset; + } else { + if (newOffset == _start) { + result = extendSelectionEvent.forward + ? SelectionResult.next + : SelectionResult.previous; + } + _start = newOffset; + } + case SelectionEventType.directionallyExtendSelection: + result = SelectionResult.end; + final DirectionallyExtendSelectionEvent extendSelectionEvent = + event as DirectionallyExtendSelectionEvent; + // Convert to local coordinates. + final double horizontalBaseLine = globalToLocal(Offset(event.dx, 0)).dx; + final Offset newOffset; + final bool forward; + switch (extendSelectionEvent.direction) { + case SelectionExtendDirection.backward: + case SelectionExtendDirection.previousLine: + forward = false; + // Initialize the offset it there is no ongoing selection. + if (_start == null || _end == null) { + _start = _end = Offset.infinite; + } + // Move the corresponding selection edge. + if (extendSelectionEvent.direction == + SelectionExtendDirection.previousLine || + horizontalBaseLine < 0) { + newOffset = Offset.zero; + } else { + newOffset = Offset.infinite; + } + case SelectionExtendDirection.nextLine: + case SelectionExtendDirection.forward: + forward = true; + // Initialize the offset it there is no ongoing selection. + if (_start == null || _end == null) { + _start = _end = Offset.zero; + } + // Move the corresponding selection edge. + if (extendSelectionEvent.direction == + SelectionExtendDirection.nextLine || + horizontalBaseLine > size.width) { + newOffset = Offset.infinite; + } else { + newOffset = Offset.zero; + } + } + if (extendSelectionEvent.isEnd) { + if (newOffset == _end) { + result = forward ? SelectionResult.next : SelectionResult.previous; + } + _end = newOffset; + } else { + if (newOffset == _start) { + result = forward ? SelectionResult.next : SelectionResult.previous; + } + _start = newOffset; + } + } + _updateGeometry(); + return result; + } + + // This method is called when users want to copy selected content in this + // widget into clipboard. + @override + SelectedContent? getSelectedContent() { + return value.hasSelection + ? const SelectedContent(plainText: 'Custom Text') + : null; + } + + @override + SelectedContentRange? getSelection() { + if (!value.hasSelection) { + return null; + } + return const SelectedContentRange(startOffset: 0, endOffset: 1); + } + + @override + int get contentLength => 1; + + LayerLink? _startHandle; + LayerLink? _endHandle; + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { + if (_startHandle == startHandle && _endHandle == endHandle) { + return; + } + _startHandle = startHandle; + _endHandle = endHandle; + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + super.paint(context, offset); + if (!_geometry.value.hasSelection) { + return; + } + // Draw the selection highlight. + final Paint selectionPaint = Paint() + ..style = PaintingStyle.fill + ..color = _selectionColor; + context.canvas.drawRect( + _getSelectionHighlightRect().shift(offset), + selectionPaint, + ); + + // Push the layer links if any. + if (_startHandle != null) { + context.pushLayer( + LeaderLayer( + link: _startHandle!, + offset: offset + value.startSelectionPoint!.localPosition, + ), + (PaintingContext context, Offset offset) {}, + Offset.zero, + ); + } + if (_endHandle != null) { + context.pushLayer( + LeaderLayer( + link: _endHandle!, + offset: offset + value.endSelectionPoint!.localPosition, + ), + (PaintingContext context, Offset offset) {}, + Offset.zero, + ); + } + } + + @override + void dispose() { + _geometry.dispose(); + _startHandle = null; + _endHandle = null; + super.dispose(); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.0.dart b/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.0.dart new file mode 100644 index 000000000000..0d8fba02ad93 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.0.dart @@ -0,0 +1,30 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SelectionArea]. + +void main() => runApp(const SelectionAreaExampleApp()); + +class SelectionAreaExampleApp extends StatelessWidget { + const SelectionAreaExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: SelectionArea( + child: Scaffold( + appBar: AppBar(title: const Text('SelectionArea Sample')), + body: const Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[Text('Row 1'), Text('Row 2'), Text('Row 3')], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.1.dart b/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.1.dart new file mode 100644 index 000000000000..8ee196dd1474 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.1.dart @@ -0,0 +1,153 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SelectionArea]. + +void main() => runApp(const SelectionAreaSelectionListenerExampleApp()); + +class SelectionAreaSelectionListenerExampleApp extends StatelessWidget { + const SelectionAreaSelectionListenerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State<MyHomePage> createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State<MyHomePage> { + final SelectionListenerNotifier _selectionNotifier = + SelectionListenerNotifier(); + SelectableRegionSelectionStatus? _selectableRegionStatus; + + void _handleOnSelectionStateChanged(SelectableRegionSelectionStatus status) { + setState(() { + _selectableRegionStatus = status; + }); + } + + @override + void dispose() { + _selectionNotifier.dispose(); + _selectableRegionStatus = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text(widget.title), + ), + body: Center( + child: Column( + children: <Widget>[ + Column( + crossAxisAlignment: .start, + children: <Widget>[ + for (final (int? offset, String label) + in <(int? offset, String label)>[ + ( + _selectionNotifier.registered + ? _selectionNotifier.selection.range?.startOffset + : null, + 'StartOffset', + ), + ( + _selectionNotifier.registered + ? _selectionNotifier.selection.range?.endOffset + : null, + 'EndOffset', + ), + ]) + Text('Selection $label: $offset'), + Text( + 'Selection Status: ${_selectionNotifier.registered ? _selectionNotifier.selection.status : 'SelectionListenerNotifier not registered.'}', + ), + Text('Selectable Region Status: $_selectableRegionStatus'), + ], + ), + const SizedBox(height: 15.0), + SelectionArea( + child: MySelectableText( + selectionNotifier: _selectionNotifier, + onChanged: _handleOnSelectionStateChanged, + ), + ), + ], + ), + ), + ); + } +} + +class MySelectableText extends StatefulWidget { + const MySelectableText({ + super.key, + required this.selectionNotifier, + required this.onChanged, + }); + + final SelectionListenerNotifier selectionNotifier; + final ValueChanged<SelectableRegionSelectionStatus> onChanged; + + @override + State<MySelectableText> createState() => _MySelectableTextState(); +} + +class _MySelectableTextState extends State<MySelectableText> { + ValueListenable<SelectableRegionSelectionStatus>? _selectableRegionScope; + + void _handleOnSelectableRegionChanged() { + if (_selectableRegionScope == null) { + return; + } + widget.onChanged.call(_selectableRegionScope!.value); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = SelectableRegionSelectionStatusScope.maybeOf( + context, + ); + _selectableRegionScope?.addListener(_handleOnSelectableRegionChanged); + } + + @override + void dispose() { + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionListener( + selectionNotifier: widget.selectionNotifier, + child: const Text( + 'This is some text under a SelectionArea that can be selected.', + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.2.dart b/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.2.dart new file mode 100644 index 000000000000..72629aeca916 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/selection_area/selection_area.2.dart @@ -0,0 +1,502 @@ +// 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'; +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; + +/// Flutter code sample for [SelectionArea]. + +void main() => runApp(const SelectionAreaColorTextRedExampleApp()); + +class SelectionAreaColorTextRedExampleApp extends StatelessWidget { + const SelectionAreaColorTextRedExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State<MyHomePage> createState() => _MyHomePageState(); +} + +typedef LocalSpanRange = ({int startOffset, int endOffset}); + +class _MyHomePageState extends State<MyHomePage> { + final SelectionListenerNotifier _selectionNotifier = + SelectionListenerNotifier(); + final ContextMenuController _menuController = ContextMenuController(); + final GlobalKey<SelectionAreaState> selectionAreaKey = + GlobalKey<SelectionAreaState>(); + + // The data of the top level TextSpans. Each TextSpan is mapped to a LocalSpanRange, + // which is the range the textspan covers relative to the SelectionListener it is under. + Map<LocalSpanRange, TextSpan> dataSourceMap = <LocalSpanRange, TextSpan>{}; + // The data of the bulleted list contained within a WidgetSpan. Each bullet is mapped + // to a LocalSpanRange, being the range the bullet covers relative to the SelectionListener + // it is under. + Map<LocalSpanRange, TextSpan> bulletSourceMap = <LocalSpanRange, TextSpan>{}; + Map<int, Map<LocalSpanRange, TextSpan>> widgetSpanMaps = + <int, Map<LocalSpanRange, TextSpan>>{}; + // The origin data used to restore the demo to its initial state. + late final Map<LocalSpanRange, TextSpan> originSourceData; + late final Map<LocalSpanRange, TextSpan> originBulletSourceData; + + void _initData() { + const String bulletListTitle = 'This is some bulleted list:\n'; + final List<String> bullets = <String>[ + for (int i = 1; i <= 7; i += 1) '• Bullet $i', + ]; + final TextSpan bulletedList = TextSpan( + text: bulletListTitle, + children: <InlineSpan>[ + WidgetSpan( + child: Column( + children: <Widget>[ + for (final String bullet in bullets) + Padding(padding: const .only(left: 20.0), child: Text(bullet)), + ], + ), + ), + ], + ); + + int currentOffset = 0; + // Map bulleted list span to a local range using its concrete length calculated + // from the length of its title and each individual bullet. + dataSourceMap[( + startOffset: currentOffset, + endOffset: bulletListTitle.length + bullets.join().length, + )] = + bulletedList; + currentOffset += bulletListTitle.length; + widgetSpanMaps[currentOffset] = bulletSourceMap; + // Map individual bullets to a local range. + for (final String bullet in bullets) { + bulletSourceMap[( + startOffset: currentOffset, + endOffset: currentOffset + bullet.length, + )] = TextSpan( + text: bullet, + ); + currentOffset += bullet.length; + } + + const TextSpan secondTextParagraph = TextSpan( + text: 'This is some text in a text widget.', + children: <InlineSpan>[ + TextSpan(text: ' This is some more text in the same text widget.'), + ], + ); + const TextSpan thirdTextParagraph = TextSpan( + text: 'This is some text in another text widget.', + ); + // Map second and third paragraphs to local ranges. + dataSourceMap[( + startOffset: currentOffset, + endOffset: + currentOffset + + secondTextParagraph + .toPlainText(includeSemanticsLabels: false) + .length, + )] = + secondTextParagraph; + currentOffset += secondTextParagraph + .toPlainText(includeSemanticsLabels: false) + .length; + dataSourceMap[( + startOffset: currentOffset, + endOffset: + currentOffset + + thirdTextParagraph + .toPlainText(includeSemanticsLabels: false) + .length, + )] = + thirdTextParagraph; + + // Save the origin data so we can revert our changes. + originSourceData = <LocalSpanRange, TextSpan>{}; + for (final MapEntry<LocalSpanRange, TextSpan> entry + in dataSourceMap.entries) { + originSourceData[entry.key] = entry.value; + } + originBulletSourceData = <LocalSpanRange, TextSpan>{}; + for (final MapEntry<LocalSpanRange, TextSpan> entry + in bulletSourceMap.entries) { + originBulletSourceData[entry.key] = entry.value; + } + } + + void _handleSelectableRegionStatusChanged( + SelectableRegionSelectionStatus status, + ) { + if (_menuController.isShown) { + ContextMenuController.removeAny(); + } + if (_selectionNotifier.selection.status != SelectionStatus.uncollapsed || + status != SelectableRegionSelectionStatus.finalized) { + return; + } + if (selectionAreaKey.currentState == null || + !selectionAreaKey.currentState!.mounted || + selectionAreaKey + .currentState! + .selectableRegion + .contextMenuAnchors + .secondaryAnchor == + null) { + return; + } + final SelectedContentRange? selectedContentRange = + _selectionNotifier.selection.range; + if (selectedContentRange == null) { + return; + } + _menuController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return TapRegion( + onTapOutside: (PointerDownEvent event) { + if (_menuController.isShown) { + ContextMenuController.removeAny(); + } + }, + child: AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: <ContextMenuButtonItem>[ + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _colorSelectionRed( + selectedContentRange, + dataMap: dataSourceMap, + coloringChildSpan: false, + ); + selectionAreaKey.currentState!.selectableRegion + .clearSelection(); + }, + label: 'Color Text Red', + ), + ], + anchors: TextSelectionToolbarAnchors( + primaryAnchor: selectionAreaKey + .currentState! + .selectableRegion + .contextMenuAnchors + .secondaryAnchor!, + ), + ), + ); + }, + ); + } + + void _colorSelectionRed( + SelectedContentRange selectedContentRange, { + required Map<LocalSpanRange, TextSpan> dataMap, + required bool coloringChildSpan, + }) { + for (final MapEntry<LocalSpanRange, TextSpan> entry in dataMap.entries) { + final LocalSpanRange entryLocalRange = entry.key; + final int normalizedStartOffset = min( + selectedContentRange.startOffset, + selectedContentRange.endOffset, + ); + final int normalizedEndOffset = max( + selectedContentRange.startOffset, + selectedContentRange.endOffset, + ); + if (normalizedStartOffset > entryLocalRange.endOffset) { + continue; + } + if (normalizedEndOffset < entryLocalRange.startOffset) { + continue; + } + // The selection details is covering the current entry so let's color the range red. + final TextSpan rawSpan = entry.value; + // Determine local ranges relative to rawSpan. + final int clampedLocalStart = + normalizedStartOffset < entryLocalRange.startOffset + ? entryLocalRange.startOffset + : normalizedStartOffset; + final int clampedLocalEnd = + normalizedEndOffset > entryLocalRange.endOffset + ? entryLocalRange.endOffset + : normalizedEndOffset; + final int startOffset = (clampedLocalStart - entryLocalRange.startOffset) + .abs(); + final int endOffset = + startOffset + (clampedLocalEnd - clampedLocalStart).abs(); + final List<InlineSpan> beforeSelection = <InlineSpan>[]; + final List<InlineSpan> insideSelection = <InlineSpan>[]; + final List<InlineSpan> afterSelection = <InlineSpan>[]; + int count = 0; + rawSpan.visitChildren((InlineSpan child) { + if (child is TextSpan) { + final String? rawText = child.text; + if (rawText != null) { + if (count < startOffset) { + final int newStart = min(startOffset - count, rawText.length); + final int globalNewStart = count + newStart; + // Collect spans before selection. + beforeSelection.add( + TextSpan( + style: child.style, + text: rawText.substring(0, newStart), + ), + ); + // Check if this span also contains the selection. + if (globalNewStart == startOffset && newStart < rawText.length) { + final int newStartAfterSelection = min( + newStart + (endOffset - startOffset), + rawText.length, + ); + final int globalNewStartAfterSelection = + count + newStartAfterSelection; + insideSelection.add( + TextSpan( + style: const TextStyle( + color: Colors.red, + ).merge(entry.value.style), + text: rawText.substring(newStart, newStartAfterSelection), + ), + ); + // Check if this span contains content after the selection. + if (globalNewStartAfterSelection == endOffset && + newStartAfterSelection < rawText.length) { + afterSelection.add( + TextSpan( + style: child.style, + text: rawText.substring(newStartAfterSelection), + ), + ); + } + } + } else if (count >= endOffset) { + // Collect spans after selection. + afterSelection.add(TextSpan(style: child.style, text: rawText)); + } else { + // Collect spans inside selection. + final int newStart = min(endOffset - count, rawText.length); + final int globalNewStart = count + newStart; + insideSelection.add( + TextSpan( + style: const TextStyle(color: Colors.red), + text: rawText.substring(0, newStart), + ), + ); + // Check if this span contains content after the selection. + if (globalNewStart == endOffset && newStart < rawText.length) { + afterSelection.add( + TextSpan( + style: child.style, + text: rawText.substring(newStart), + ), + ); + } + } + count += rawText.length; + } + } else if (child is WidgetSpan) { + if (!widgetSpanMaps.containsKey(count)) { + // We have arrived at a WidgetSpan but it is unaccounted for. + return true; + } + final Map<LocalSpanRange, TextSpan> widgetSpanSourceMap = + widgetSpanMaps[count]!; + if (count < startOffset && + count + + (widgetSpanSourceMap.keys.last.endOffset - + widgetSpanSourceMap.keys.first.startOffset) + .abs() < + startOffset) { + // When the count is less than the startOffset and we are at a widgetspan + // it is still possible that the startOffset is somewhere within the widgetspan, + // so we should try to color the selection red for the widgetspan. + // + // If the calculated widgetspan length would not extend the count past the + // startOffset then add this widgetspan to the beforeSelection, and + // continue walking the tree. + beforeSelection.add(child); + count += + (widgetSpanSourceMap.keys.last.endOffset - + widgetSpanSourceMap.keys.first.startOffset) + .abs(); + return true; + } else if (count >= endOffset) { + afterSelection.add(child); + count += + (widgetSpanSourceMap.keys.last.endOffset - + widgetSpanSourceMap.keys.first.startOffset) + .abs(); + return true; + } + // Update widgetspan data. + _colorSelectionRed( + selectedContentRange, + dataMap: widgetSpanSourceMap, + coloringChildSpan: true, + ); + // Re-create widgetspan. + if (count == 28) { + // The index where the bulleted list begins. + insideSelection.add( + WidgetSpan( + child: Column( + children: <Widget>[ + for (final MapEntry<LocalSpanRange, TextSpan> entry + in widgetSpanSourceMap.entries) + Padding( + padding: const .only(left: 20.0), + child: Text.rich(widgetSpanSourceMap[entry.key]!), + ), + ], + ), + ), + ); + } + count += + (widgetSpanSourceMap.keys.last.endOffset - + widgetSpanSourceMap.keys.first.startOffset) + .abs(); + return true; + } + return true; + }); + dataMap[entry.key] = TextSpan( + style: dataMap[entry.key]!.style, + children: <InlineSpan>[ + ...beforeSelection, + ...insideSelection, + ...afterSelection, + ], + ); + } + // Avoid clearing the selection and setting the state + // before we have colored all parts of the selection. + if (!coloringChildSpan) { + setState(() {}); + } + } + + @override + void initState() { + super.initState(); + _initData(); + } + + @override + void dispose() { + _selectionNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text(widget.title), + ), + body: SelectionArea( + key: selectionAreaKey, + child: MySelectableTextColumn( + selectionNotifier: _selectionNotifier, + dataSourceMap: dataSourceMap, + onChanged: _handleSelectableRegionStatusChanged, + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + // Resets the state to the origin data. + for (final MapEntry<LocalSpanRange, TextSpan> entry + in originSourceData.entries) { + dataSourceMap[entry.key] = entry.value; + } + for (final MapEntry<LocalSpanRange, TextSpan> entry + in originBulletSourceData.entries) { + bulletSourceMap[entry.key] = entry.value; + } + }); + }, + child: const Icon(Icons.undo), + ), + ); + } +} + +class MySelectableTextColumn extends StatefulWidget { + const MySelectableTextColumn({ + super.key, + required this.selectionNotifier, + required this.dataSourceMap, + required this.onChanged, + }); + + final SelectionListenerNotifier selectionNotifier; + final Map<LocalSpanRange, TextSpan> dataSourceMap; + final ValueChanged<SelectableRegionSelectionStatus> onChanged; + + @override + State<MySelectableTextColumn> createState() => _MySelectableTextColumnState(); +} + +class _MySelectableTextColumnState extends State<MySelectableTextColumn> { + ValueListenable<SelectableRegionSelectionStatus>? _selectableRegionScope; + + void _handleOnSelectableRegionChanged() { + if (_selectableRegionScope == null) { + return; + } + widget.onChanged.call(_selectableRegionScope!.value); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = SelectableRegionSelectionStatusScope.maybeOf( + context, + ); + _selectableRegionScope?.addListener(_handleOnSelectableRegionChanged); + } + + @override + void dispose() { + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionListener( + selectionNotifier: widget.selectionNotifier, + child: Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + for (final MapEntry<LocalSpanRange, TextSpan> entry + in widget.dataSourceMap.entries) + Text.rich(entry.value), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/selection_container/selection_container.0.dart b/packages/material_ui/material_ui_examples/lib/selection_container/selection_container.0.dart new file mode 100644 index 000000000000..ecbbd130a754 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/selection_container/selection_container.0.dart @@ -0,0 +1,137 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; + +/// Flutter code sample for [SelectionContainer]. + +void main() => runApp(const SelectionContainerExampleApp()); + +class SelectionContainerExampleApp extends StatelessWidget { + const SelectionContainerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: SelectionArea( + child: Scaffold( + appBar: AppBar(title: const Text('SelectionContainer Sample')), + body: const Center( + child: SelectionAllOrNoneContainer( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[Text('Row 1'), Text('Row 2'), Text('Row 3')], + ), + ), + ), + ), + ), + ); + } +} + +class SelectionAllOrNoneContainer extends StatefulWidget { + const SelectionAllOrNoneContainer({super.key, required this.child}); + + final Widget child; + + @override + State<StatefulWidget> createState() => _SelectionAllOrNoneContainerState(); +} + +class _SelectionAllOrNoneContainerState + extends State<SelectionAllOrNoneContainer> { + final SelectAllOrNoneContainerDelegate delegate = + SelectAllOrNoneContainerDelegate(); + + @override + void dispose() { + delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionContainer(delegate: delegate, child: widget.child); + } +} + +class SelectAllOrNoneContainerDelegate + extends MultiSelectableSelectionContainerDelegate { + Offset? _adjustedStartEdge; + Offset? _adjustedEndEdge; + bool _isSelected = false; + + // This method is called when newly added selectable is in the current + // selected range. + @override + void ensureChildUpdated(Selectable selectable) { + if (_isSelected) { + dispatchSelectionEventToChild( + selectable, + const SelectAllSelectionEvent(), + ); + } + } + + @override + SelectionResult handleSelectWord(SelectWordSelectionEvent event) { + // Treat select word as select all. + return handleSelectAll(const SelectAllSelectionEvent()); + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + final Rect containerRect = Rect.fromLTWH( + 0, + 0, + containerSize.width, + containerSize.height, + ); + final Matrix4 globalToLocal = getTransformTo(null)..invert(); + final Offset localOffset = MatrixUtils.transformPoint( + globalToLocal, + event.globalPosition, + ); + final Offset adjustOffset = SelectionUtils.adjustDragOffset( + containerRect, + localOffset, + ); + if (event.type == SelectionEventType.startEdgeUpdate) { + _adjustedStartEdge = adjustOffset; + } else { + _adjustedEndEdge = adjustOffset; + } + // Select all content if the selection rect intercepts with the rect. + if (_adjustedStartEdge != null && _adjustedEndEdge != null) { + final Rect selectionRect = Rect.fromPoints( + _adjustedStartEdge!, + _adjustedEndEdge!, + ); + if (!selectionRect.intersect(containerRect).isEmpty) { + handleSelectAll(const SelectAllSelectionEvent()); + } else { + super.handleClearSelection(const ClearSelectionEvent()); + } + } else { + super.handleClearSelection(const ClearSelectionEvent()); + } + return SelectionUtils.getResultBasedOnRect(containerRect, localOffset); + } + + @override + SelectionResult handleClearSelection(ClearSelectionEvent event) { + _adjustedStartEdge = null; + _adjustedEndEdge = null; + _isSelected = false; + return super.handleClearSelection(event); + } + + @override + SelectionResult handleSelectAll(SelectAllSelectionEvent event) { + _isSelected = true; + return super.handleSelectAll(event); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/selection_container/selection_container_disabled.0.dart b/packages/material_ui/material_ui_examples/lib/selection_container/selection_container_disabled.0.dart new file mode 100644 index 000000000000..23264794fe26 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/selection_container/selection_container_disabled.0.dart @@ -0,0 +1,34 @@ +// 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 example for [SelectionContainer.disabled]. + +import 'package:material_ui/material_ui.dart'; + +void main() => runApp(const SelectionContainerDisabledExampleApp()); + +class SelectionContainerDisabledExampleApp extends StatelessWidget { + const SelectionContainerDisabledExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SelectionContainer.disabled Sample')), + body: const Center( + child: SelectionArea( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Text('Selectable text'), + SelectionContainer.disabled(child: Text('Non-selectable text')), + Text('Selectable text'), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/shaped_input_border/shaped_input_border.0.dart b/packages/material_ui/material_ui_examples/lib/shaped_input_border/shaped_input_border.0.dart new file mode 100644 index 000000000000..f6ed7f39b4b9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/shaped_input_border/shaped_input_border.0.dart @@ -0,0 +1,119 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ShapedInputBorder]. + +void main() => runApp(const ExampleApp()); + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ShapedInputBorder Sample')), + body: const ShapedInputBorderExample(), + ), + ); + } +} + +class ShapedInputBorderExample extends StatelessWidget { + const ShapedInputBorderExample({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const .all(16.0), + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + // Superellipse border (iOS-style) + TextField( + decoration: InputDecoration( + labelText: 'Superellipse Border', + hintText: 'iOS-style smooth border', + border: ShapedInputBorder( + shape: const RoundedSuperellipseBorder( + borderRadius: .all(Radius.circular(16.0)), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + ), + enabledBorder: ShapedInputBorder( + shape: const RoundedSuperellipseBorder( + borderRadius: .all(Radius.circular(16.0)), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + focusedBorder: ShapedInputBorder( + shape: const RoundedSuperellipseBorder( + borderRadius: .all(Radius.circular(16.0)), + ), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + ), + ), + ), + const SizedBox(height: 24), + // Stadium border + TextField( + decoration: InputDecoration( + labelText: 'Stadium Border', + hintText: 'Pill-shaped border', + border: ShapedInputBorder( + shape: const StadiumBorder(), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + ), + ), + ), + const SizedBox(height: 24), + // Beveled border + const TextField( + decoration: InputDecoration( + labelText: 'Beveled Border', + hintText: 'Angular beveled corners', + border: ShapedInputBorder( + shape: BeveledRectangleBorder( + borderRadius: .all(Radius.circular(12.0)), + ), + ), + ), + ), + const SizedBox(height: 24), + // Filled with custom shape + TextField( + decoration: InputDecoration( + labelText: 'Filled with Superellipse', + hintText: 'Filled background', + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + border: const ShapedInputBorder( + shape: RoundedSuperellipseBorder( + borderRadius: .all(Radius.circular(12.0)), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/slider/slider.0.dart b/packages/material_ui/material_ui_examples/lib/slider/slider.0.dart new file mode 100644 index 000000000000..f742fbff0e1f --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/slider/slider.0.dart @@ -0,0 +1,82 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Slider]. +/// set to false. + +void main() => runApp(const SliderExampleApp()); + +class SliderExampleApp extends StatelessWidget { + const SliderExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SliderExample()); + } +} + +class SliderExample extends StatefulWidget { + const SliderExample({super.key}); + + @override + State<SliderExample> createState() => _SliderExampleState(); +} + +class _SliderExampleState extends State<SliderExample> { + double _currentSliderValue = 20; + double _currentDiscreteSliderValue = 60; + bool year2023 = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Slider')), + body: Center( + child: Column( + mainAxisAlignment: .center, + spacing: 16, + children: <Widget>[ + Slider( + // ignore: deprecated_member_use + year2023: year2023, + value: _currentSliderValue, + max: 100, + onChanged: (double value) { + setState(() { + _currentSliderValue = value; + }); + }, + ), + Slider( + // ignore: deprecated_member_use + year2023: year2023, + value: _currentDiscreteSliderValue, + max: 100, + divisions: 5, + label: _currentDiscreteSliderValue.round().toString(), + onChanged: (double value) { + setState(() { + _currentDiscreteSliderValue = value; + }); + }, + ), + SwitchListTile( + value: year2023, + title: year2023 + ? const Text('Switch to latest M3 style') + : const Text('Switch to year2023 M3 style'), + onChanged: (bool value) { + setState(() { + year2023 = !year2023; + }); + }, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/slider/slider.1.dart b/packages/material_ui/material_ui_examples/lib/slider/slider.1.dart new file mode 100644 index 000000000000..f52d32a29d4d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/slider/slider.1.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. + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [Slider]. + +void main() => runApp(const SliderApp()); + +class SliderApp extends StatelessWidget { + const SliderApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SliderExample()); + } +} + +class SliderExample extends StatefulWidget { + const SliderExample({super.key}); + + @override + State<SliderExample> createState() => _SliderExampleState(); +} + +class _SliderExampleState extends State<SliderExample> { + double _currentSliderPrimaryValue = 0.2; + double _currentSliderSecondaryValue = 0.5; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Slider')), + body: Column( + mainAxisAlignment: .center, + children: <Widget>[ + Slider( + value: _currentSliderPrimaryValue, + secondaryTrackValue: _currentSliderSecondaryValue, + label: _currentSliderPrimaryValue.round().toString(), + onChanged: (double value) { + setState(() { + _currentSliderPrimaryValue = value; + }); + }, + ), + Slider( + value: _currentSliderSecondaryValue, + label: _currentSliderSecondaryValue.round().toString(), + onChanged: (double value) { + setState(() { + _currentSliderSecondaryValue = value; + }); + }, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.0.dart b/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.0.dart new file mode 100644 index 000000000000..87f6b5dedad3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.0.dart @@ -0,0 +1,47 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SnackBar]. + +void main() => runApp(const SnackBarExampleApp()); + +class SnackBarExampleApp extends StatelessWidget { + const SnackBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + body: const Center(child: SnackBarExample()), + ), + ); + } +} + +class SnackBarExample extends StatelessWidget { + const SnackBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + child: const Text('Show Snackbar'), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Awesome Snackbar!'), + action: SnackBarAction( + label: 'Action', + onPressed: () { + // Code to execute. + }, + ), + ), + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.1.dart b/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.1.dart new file mode 100644 index 000000000000..cbe5af23b554 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.1.dart @@ -0,0 +1,54 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SnackBar]. + +void main() => runApp(const SnackBarExampleApp()); + +class SnackBarExampleApp extends StatelessWidget { + const SnackBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + body: const Center(child: SnackBarExample()), + ), + ); + } +} + +class SnackBarExample extends StatelessWidget { + const SnackBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + child: const Text('Show Snackbar'), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + action: SnackBarAction( + label: 'Action', + onPressed: () { + // Code to execute. + }, + ), + content: const Text('Awesome SnackBar!'), + duration: const Duration(milliseconds: 1500), + width: 280.0, // Width of the SnackBar. + padding: const .symmetric( + horizontal: 8.0, // Inner padding for SnackBar content. + ), + behavior: .floating, + shape: RoundedRectangleBorder(borderRadius: .circular(10.0)), + ), + ); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.2.dart b/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.2.dart new file mode 100644 index 000000000000..94f48687c753 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/snack_bar/snack_bar.2.dart @@ -0,0 +1,162 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SnackBar]. + +void main() => runApp(const SnackBarExampleApp()); + +/// A Material 3 [SnackBar] demonstrating an optional icon, in either floating +/// or fixed format. +class SnackBarExampleApp extends StatelessWidget { + const SnackBarExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: SnackBarExample()); + } +} + +class SnackBarExample extends StatefulWidget { + const SnackBarExample({super.key}); + + @override + State<SnackBarExample> createState() => _SnackBarExampleState(); +} + +class _SnackBarExampleState extends State<SnackBarExample> { + SnackBarBehavior? _snackBarBehavior = .floating; + bool _withIcon = true; + bool _withAction = true; + bool _multiLine = false; + bool _longActionLabel = false; + double _sliderValue = 0.25; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar(_snackBar()); + }, + icon: const Icon(Icons.play_arrow), + label: const Text('Show Snackbar'), + ), + body: ListView( + children: <Widget>[ + RadioGroup<SnackBarBehavior>( + groupValue: _snackBarBehavior, + onChanged: (SnackBarBehavior? value) { + setState(() { + _snackBarBehavior = value; + }); + }, + child: const ExpansionTile( + title: Text('Behavior'), + initiallyExpanded: true, + children: <Widget>[ + RadioListTile<SnackBarBehavior>( + title: Text('Fixed'), + value: SnackBarBehavior.fixed, + ), + RadioListTile<SnackBarBehavior>( + title: Text('Floating'), + value: SnackBarBehavior.floating, + ), + ], + ), + ), + ExpansionTile( + title: const Text('Content'), + initiallyExpanded: true, + children: <Widget>[ + SwitchListTile( + title: const Text('Include close Icon'), + value: _withIcon, + onChanged: (bool value) { + setState(() { + _withIcon = value; + }); + }, + ), + SwitchListTile( + title: const Text('Multi Line Text'), + value: _multiLine, + onChanged: (bool value) { + setState(() { + _multiLine = value; + }); + }, + ), + SwitchListTile( + title: const Text('Include Action'), + value: _withAction, + onChanged: (bool value) { + setState(() { + _withAction = value; + }); + }, + ), + SwitchListTile( + title: const Text('Long Action Label'), + value: _longActionLabel, + onChanged: !_withAction + ? null + : (bool value) => setState(() { + _longActionLabel = value; + }), + ), + ], + ), + ExpansionTile( + title: const Text('Action new-line overflow threshold'), + initiallyExpanded: true, + children: <Widget>[ + Slider( + value: _sliderValue, + divisions: 20, + label: _sliderValue.toStringAsFixed(2), + onChanged: (double value) => setState(() { + _sliderValue = value; + }), + ), + ], + ), + // Avoid hiding content behind the floating action button + const SizedBox(height: 100), + ], + ), + ); + } + + SnackBar _snackBar() { + final SnackBarAction? action = _withAction + ? SnackBarAction( + label: _longActionLabel ? 'Long Action Text' : 'Action', + onPressed: () { + // Code to execute. + }, + ) + : null; + final double? width = _snackBarBehavior == SnackBarBehavior.floating + ? 400.0 + : null; + final String label = _multiLine + ? 'A Snack Bar with quite a lot of text which spans across multiple ' + 'lines. You can look at how the Action Label moves around when trying ' + 'to layout this text.' + : 'Single Line Snack Bar'; + return SnackBar( + content: Text(label), + showCloseIcon: _withIcon, + width: width, + behavior: _snackBarBehavior, + action: action, + duration: const Duration(seconds: 3), + actionOverflowThreshold: _sliderValue, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/stepper/step_style.0.dart b/packages/material_ui/material_ui_examples/lib/stepper/step_style.0.dart new file mode 100644 index 000000000000..dbba820cd6cb --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/stepper/step_style.0.dart @@ -0,0 +1,87 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [StepStyle]. + +void main() => runApp(const StepStyleExampleApp()); + +class StepStyleExampleApp extends StatelessWidget { + const StepStyleExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Step Style Example')), + body: const Center(child: StepStyleExample()), + ), + ); + } +} + +class StepStyleExample extends StatefulWidget { + const StepStyleExample({super.key}); + + @override + State<StepStyleExample> createState() => _StepStyleExampleState(); +} + +class _StepStyleExampleState extends State<StepStyleExample> { + final StepStyle _stepStyle = StepStyle( + connectorThickness: 10, + color: Colors.white, + connectorColor: Colors.red, + indexStyle: const TextStyle(color: Colors.black, fontSize: 20), + border: .all(width: 2), + ); + + @override + Widget build(BuildContext context) { + return Stepper( + type: .horizontal, + stepIconHeight: 48, + stepIconWidth: 48, + stepIconMargin: .zero, + steps: <Step>[ + Step( + title: const SizedBox.shrink(), + content: const SizedBox.shrink(), + isActive: true, + stepStyle: _stepStyle, + ), + Step( + title: const SizedBox.shrink(), + content: const SizedBox.shrink(), + isActive: true, + stepStyle: _stepStyle.copyWith( + connectorColor: Colors.orange, + gradient: const LinearGradient( + colors: <Color>[Colors.white, Colors.black], + ), + ), + ), + Step( + title: const SizedBox.shrink(), + content: const SizedBox.shrink(), + isActive: true, + stepStyle: _stepStyle.copyWith(connectorColor: Colors.blue), + ), + Step( + title: const SizedBox.shrink(), + content: const SizedBox.shrink(), + isActive: true, + stepStyle: _stepStyle.merge( + StepStyle( + color: Colors.white, + indexStyle: const TextStyle(color: Colors.black, fontSize: 20), + border: .all(width: 2), + ), + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/stepper/stepper.0.dart b/packages/material_ui/material_ui_examples/lib/stepper/stepper.0.dart new file mode 100644 index 000000000000..08dead488ceb --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/stepper/stepper.0.dart @@ -0,0 +1,73 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Stepper]. + +void main() => runApp(const StepperExampleApp()); + +class StepperExampleApp extends StatelessWidget { + const StepperExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Stepper Sample')), + body: const Center(child: StepperExample()), + ), + ); + } +} + +class StepperExample extends StatefulWidget { + const StepperExample({super.key}); + + @override + State<StepperExample> createState() => _StepperExampleState(); +} + +class _StepperExampleState extends State<StepperExample> { + int _index = 0; + + @override + Widget build(BuildContext context) { + return Stepper( + currentStep: _index, + onStepCancel: () { + if (_index > 0) { + setState(() { + _index -= 1; + }); + } + }, + onStepContinue: () { + if (_index <= 0) { + setState(() { + _index += 1; + }); + } + }, + onStepTapped: (int index) { + setState(() { + _index = index; + }); + }, + steps: <Step>[ + Step( + title: const Text('Step 1 title'), + content: Container( + alignment: .centerLeft, + child: const Text('Content for Step 1'), + ), + ), + const Step( + title: Text('Step 2 title'), + content: Text('Content for Step 2'), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/stepper/stepper.controls_builder.0.dart b/packages/material_ui/material_ui_examples/lib/stepper/stepper.controls_builder.0.dart new file mode 100644 index 000000000000..9c03f8940feb --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/stepper/stepper.controls_builder.0.dart @@ -0,0 +1,51 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Stepper.controlsBuilder]. + +void main() => runApp(const ControlsBuilderExampleApp()); + +class ControlsBuilderExampleApp extends StatelessWidget { + const ControlsBuilderExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Stepper Sample')), + body: const ControlsBuilderExample(), + ), + ); + } +} + +class ControlsBuilderExample extends StatelessWidget { + const ControlsBuilderExample({super.key}); + + @override + Widget build(BuildContext context) { + return Stepper( + controlsBuilder: (BuildContext context, ControlsDetails details) { + return Row( + children: <Widget>[ + TextButton( + onPressed: details.onStepContinue, + child: const Text('NEXT'), + ), + TextButton( + onPressed: details.onStepCancel, + child: const Text('CANCEL'), + ), + ], + ); + }, + steps: const <Step>[ + Step(title: Text('A'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('B'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch/switch.0.dart b/packages/material_ui/material_ui_examples/lib/switch/switch.0.dart new file mode 100644 index 000000000000..4d6afd4d30a5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch/switch.0.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Switch]. + +void main() => runApp(const SwitchApp()); + +class SwitchApp extends StatelessWidget { + const SwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Switch Sample')), + body: const Center(child: SwitchExample()), + ), + ); + } +} + +class SwitchExample extends StatefulWidget { + const SwitchExample({super.key}); + + @override + State<SwitchExample> createState() => _SwitchExampleState(); +} + +class _SwitchExampleState extends State<SwitchExample> { + bool light = true; + + @override + Widget build(BuildContext context) { + return Switch( + // This bool value toggles the switch. + value: light, + activeThumbColor: Colors.red, + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + light = value; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch/switch.1.dart b/packages/material_ui/material_ui_examples/lib/switch/switch.1.dart new file mode 100644 index 000000000000..d8d11b7276ec --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch/switch.1.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Switch]. + +void main() => runApp(const SwitchApp()); + +class SwitchApp extends StatelessWidget { + const SwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Switch Sample')), + body: const Center(child: SwitchExample()), + ), + ); + } +} + +class SwitchExample extends StatefulWidget { + const SwitchExample({super.key}); + + @override + State<SwitchExample> createState() => _SwitchExampleState(); +} + +class _SwitchExampleState extends State<SwitchExample> { + bool light = true; + + @override + Widget build(BuildContext context) { + // This object sets amber as the track color when the switch is selected. + // Otherwise, it resolves to null and defers to values from the theme data. + const WidgetStateProperty<Color?> trackColor = + WidgetStateProperty<Color?>.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.selected: Colors.amber, + }); + // This object sets the track color based on two WidgetState attributes. + // If neither state applies, it resolves to null. + final WidgetStateProperty<Color?> overlayColor = + WidgetStateProperty<Color?>.fromMap(<WidgetState, Color>{ + WidgetState.selected: Colors.amber.withValues(alpha: 0.54), + WidgetState.disabled: Colors.grey.shade400, + }); + + return Switch( + // This bool value toggles the switch. + value: light, + overlayColor: overlayColor, + trackColor: trackColor, + thumbColor: const WidgetStatePropertyAll<Color>(Colors.black), + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + light = value; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch/switch.2.dart b/packages/material_ui/material_ui_examples/lib/switch/switch.2.dart new file mode 100644 index 000000000000..cbdb0935e1ca --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch/switch.2.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Switch]. + +void main() => runApp(const SwitchApp()); + +class SwitchApp extends StatelessWidget { + const SwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Switch Sample')), + body: const Center(child: SwitchExample()), + ), + ); + } +} + +class SwitchExample extends StatefulWidget { + const SwitchExample({super.key}); + + @override + State<SwitchExample> createState() => _SwitchExampleState(); +} + +class _SwitchExampleState extends State<SwitchExample> { + bool light0 = true; + bool light1 = true; + + static const WidgetStateProperty<Icon> thumbIcon = + WidgetStateProperty<Icon>.fromMap(<WidgetStatesConstraint, Icon>{ + WidgetState.selected: Icon(Icons.check), + WidgetState.any: Icon(Icons.close), + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .center, + children: <Widget>[ + Switch( + value: light0, + onChanged: (bool value) { + setState(() { + light0 = value; + }); + }, + ), + Switch( + thumbIcon: thumbIcon, + value: light1, + onChanged: (bool value) { + setState(() { + light1 = value; + }); + }, + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch/switch.3.dart b/packages/material_ui/material_ui_examples/lib/switch/switch.3.dart new file mode 100644 index 000000000000..a3f634b2f4fd --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch/switch.3.dart @@ -0,0 +1,67 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [Switch]. + +void main() => runApp(const SwitchApp()); + +class SwitchApp extends StatelessWidget { + const SwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + // Use the ambient CupertinoThemeData to style all widgets which would + // otherwise use iOS defaults. + cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true), + ), + home: Scaffold( + appBar: AppBar(title: const Text('Switch Sample')), + body: const Center(child: SwitchExample()), + ), + ); + } +} + +class SwitchExample extends StatefulWidget { + const SwitchExample({super.key}); + + @override + State<SwitchExample> createState() => _SwitchExampleState(); +} + +class _SwitchExampleState extends State<SwitchExample> { + bool light = true; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: .center, + children: <Widget>[ + Switch.adaptive( + value: light, + onChanged: (bool value) { + setState(() { + light = value; + }); + }, + ), + Switch.adaptive( + // Don't use the ambient CupertinoThemeData to style this switch. + applyCupertinoTheme: false, + value: light, + onChanged: (bool value) { + setState(() { + light = value; + }); + }, + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch/switch.4.dart b/packages/material_ui/material_ui_examples/lib/switch/switch.4.dart new file mode 100644 index 000000000000..03ecb7dbb55e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch/switch.4.dart @@ -0,0 +1,137 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Switch.adaptive]. + +void main() => runApp(const SwitchApp()); + +class SwitchApp extends StatefulWidget { + const SwitchApp({super.key}); + + @override + State<SwitchApp> createState() => _SwitchAppState(); +} + +class _SwitchAppState extends State<SwitchApp> { + bool isMaterial = true; + bool isCustomized = false; + + @override + Widget build(BuildContext context) { + final ThemeData theme = ThemeData( + platform: isMaterial ? .android : .iOS, + adaptations: <Adaptation<Object>>[ + if (isCustomized) const _SwitchThemeAdaptation(), + ], + ); + final ButtonStyle style = OutlinedButton.styleFrom( + fixedSize: const Size(220, 40), + ); + + return MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar(title: const Text('Adaptive Switches')), + body: Column( + mainAxisAlignment: .center, + children: <Widget>[ + OutlinedButton( + style: style, + onPressed: () { + setState(() { + isMaterial = !isMaterial; + }); + }, + child: isMaterial + ? const Text('Show cupertino style') + : const Text('Show material style'), + ), + OutlinedButton( + style: style, + onPressed: () { + setState(() { + isCustomized = !isCustomized; + }); + }, + child: isCustomized + ? const Text('Remove customization') + : const Text('Add customization'), + ), + const SizedBox(height: 20), + const SwitchWithLabel(label: 'enabled', enabled: true), + const SwitchWithLabel(label: 'disabled', enabled: false), + ], + ), + ), + ); + } +} + +class SwitchWithLabel extends StatefulWidget { + const SwitchWithLabel({ + super.key, + required this.enabled, + required this.label, + }); + + final bool enabled; + final String label; + + @override + State<SwitchWithLabel> createState() => _SwitchWithLabelState(); +} + +class _SwitchWithLabelState extends State<SwitchWithLabel> { + bool active = true; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: .center, + children: <Widget>[ + Container( + width: 150, + padding: const .only(right: 20), + child: Text(widget.label), + ), + Switch.adaptive( + value: active, + onChanged: !widget.enabled + ? null + : (bool value) { + setState(() { + active = value; + }); + }, + ), + ], + ); + } +} + +class _SwitchThemeAdaptation extends Adaptation<SwitchThemeData> { + const _SwitchThemeAdaptation(); + + @override + SwitchThemeData adapt(ThemeData theme, SwitchThemeData defaultValue) { + switch (theme.platform) { + case .android: + case .fuchsia: + case .linux: + case .windows: + return defaultValue; + case .iOS: + case .macOS: + return const SwitchThemeData( + thumbColor: WidgetStateProperty<Color?>.fromMap(<WidgetState, Color>{ + WidgetState.selected: Colors.yellow, + // Resolves to null if not selected, deferring to default values. + }), + trackColor: WidgetStatePropertyAll<Color>(Colors.brown), + ); + } + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch_list_tile/custom_labeled_switch.0.dart b/packages/material_ui/material_ui_examples/lib/switch_list_tile/custom_labeled_switch.0.dart new file mode 100644 index 000000000000..15bdcd9418e3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch_list_tile/custom_labeled_switch.0.dart @@ -0,0 +1,97 @@ +// 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/gestures.dart'; + +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for custom labeled switch. + +void main() => runApp(const LabeledSwitchApp()); + +class LabeledSwitchApp extends StatelessWidget { + const LabeledSwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom Labeled Switch Sample')), + body: const Center(child: LabeledSwitchExample()), + ), + ); + } +} + +class LinkedLabelSwitch extends StatelessWidget { + const LinkedLabelSwitch({ + super.key, + required this.label, + required this.padding, + required this.value, + required this.onChanged, + }); + + final String label; + final EdgeInsets padding; + final bool value; + final ValueChanged<bool> onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Row( + children: <Widget>[ + Expanded( + child: RichText( + text: TextSpan( + text: label, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + debugPrint('Label has been tapped.'); + }, + ), + ), + ), + Switch( + value: value, + onChanged: (bool newValue) { + onChanged(newValue); + }, + ), + ], + ), + ); + } +} + +class LabeledSwitchExample extends StatefulWidget { + const LabeledSwitchExample({super.key}); + + @override + State<LabeledSwitchExample> createState() => _LabeledSwitchExampleState(); +} + +class _LabeledSwitchExampleState extends State<LabeledSwitchExample> { + bool _isSelected = false; + + @override + Widget build(BuildContext context) { + return LinkedLabelSwitch( + label: 'Linked, tappable label text', + padding: const .symmetric(horizontal: 20.0), + value: _isSelected, + onChanged: (bool newValue) { + setState(() { + _isSelected = newValue; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch_list_tile/custom_labeled_switch.1.dart b/packages/material_ui/material_ui_examples/lib/switch_list_tile/custom_labeled_switch.1.dart new file mode 100644 index 000000000000..39742dd42e42 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch_list_tile/custom_labeled_switch.1.dart @@ -0,0 +1,86 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for custom labeled switch. + +void main() => runApp(const LabeledSwitchApp()); + +class LabeledSwitchApp extends StatelessWidget { + const LabeledSwitchApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom Labeled Switch Sample')), + body: const Center(child: LabeledSwitchExample()), + ), + ); + } +} + +class LabeledSwitch extends StatelessWidget { + const LabeledSwitch({ + super.key, + required this.label, + required this.padding, + required this.value, + required this.onChanged, + }); + + final String label; + final EdgeInsets padding; + final bool value; + final ValueChanged<bool> onChanged; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + onChanged(!value); + }, + child: Padding( + padding: padding, + child: Row( + children: <Widget>[ + Expanded(child: Text(label)), + Switch( + value: value, + onChanged: (bool newValue) { + onChanged(newValue); + }, + ), + ], + ), + ), + ); + } +} + +class LabeledSwitchExample extends StatefulWidget { + const LabeledSwitchExample({super.key}); + + @override + State<LabeledSwitchExample> createState() => _LabeledSwitchExampleState(); +} + +class _LabeledSwitchExampleState extends State<LabeledSwitchExample> { + bool _isSelected = false; + + @override + Widget build(BuildContext context) { + return LabeledSwitch( + label: 'This is the label text', + padding: const .symmetric(horizontal: 20.0), + value: _isSelected, + onChanged: (bool newValue) { + setState(() { + _isSelected = newValue; + }); + }, + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch_list_tile/switch_list_tile.0.dart b/packages/material_ui/material_ui_examples/lib/switch_list_tile/switch_list_tile.0.dart new file mode 100644 index 000000000000..dcf9b52abffc --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch_list_tile/switch_list_tile.0.dart @@ -0,0 +1,48 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SwitchListTile]. + +void main() => runApp(const SwitchListTileApp()); + +class SwitchListTileApp extends StatelessWidget { + const SwitchListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SwitchListTile Sample')), + body: const Center(child: SwitchListTileExample()), + ), + ); + } +} + +class SwitchListTileExample extends StatefulWidget { + const SwitchListTileExample({super.key}); + + @override + State<SwitchListTileExample> createState() => _SwitchListTileExampleState(); +} + +class _SwitchListTileExampleState extends State<SwitchListTileExample> { + bool _lights = false; + + @override + Widget build(BuildContext context) { + return SwitchListTile( + title: const Text('Lights'), + value: _lights, + onChanged: (bool value) { + setState(() { + _lights = value; + }); + }, + secondary: const Icon(Icons.lightbulb_outline), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/switch_list_tile/switch_list_tile.1.dart b/packages/material_ui/material_ui_examples/lib/switch_list_tile/switch_list_tile.1.dart new file mode 100644 index 000000000000..6778c7889c9c --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/switch_list_tile/switch_list_tile.1.dart @@ -0,0 +1,84 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [SwitchListTile]. + +void main() => runApp(const SwitchListTileApp()); + +class SwitchListTileApp extends StatelessWidget { + const SwitchListTileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('SwitchListTile Sample')), + body: const SwitchListTileExample(), + ), + ); + } +} + +class SwitchListTileExample extends StatefulWidget { + const SwitchListTileExample({super.key}); + + @override + State<SwitchListTileExample> createState() => _SwitchListTileExampleState(); +} + +class _SwitchListTileExampleState extends State<SwitchListTileExample> { + bool switchValue1 = true; + bool switchValue2 = true; + bool switchValue3 = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: <Widget>[ + SwitchListTile( + value: switchValue1, + onChanged: (bool value) { + setState(() { + switchValue1 = value; + }); + }, + title: const Text('Headline'), + subtitle: const Text('Supporting text'), + ), + const Divider(height: 0), + SwitchListTile( + value: switchValue2, + onChanged: (bool value) { + setState(() { + switchValue2 = value; + }); + }, + title: const Text('Headline'), + subtitle: const Text( + 'Longer supporting text to demonstrate how the text wraps and the switch is centered vertically with the text.', + ), + ), + const Divider(height: 0), + SwitchListTile( + value: switchValue3, + onChanged: (bool value) { + setState(() { + switchValue3 = value; + }); + }, + title: const Text('Headline'), + subtitle: const Text( + "Longer supporting text to demonstrate how the text wraps and how setting 'SwitchListTile.isThreeLine = true' aligns the switch to the top vertically with the text.", + ), + isThreeLine: true, + ), + const Divider(height: 0), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tab_controller/tab_controller.1.dart b/packages/material_ui/material_ui_examples/lib/tab_controller/tab_controller.1.dart new file mode 100644 index 000000000000..650eb3f3f5a3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tab_controller/tab_controller.1.dart @@ -0,0 +1,123 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabController]. + +void main() => runApp(const TabControllerExampleApp()); + +class TabControllerExampleApp extends StatelessWidget { + const TabControllerExampleApp({super.key}); + + static const List<Tab> tabs = <Tab>[ + Tab(text: 'Zeroth'), + Tab(text: 'First'), + Tab(text: 'Second'), + ]; + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabControllerExample(tabs: tabs)); + } +} + +class TabControllerExample extends StatelessWidget { + const TabControllerExample({required this.tabs, super.key}); + + final List<Tab> tabs; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: tabs.length, + child: DefaultTabControllerListener( + onTabChanged: (int index) { + debugPrint('tab changed: $index'); + }, + child: Scaffold( + appBar: AppBar(bottom: TabBar(tabs: tabs)), + body: TabBarView( + children: tabs.map((Tab tab) { + return Center( + child: Text( + '${tab.text!} Tab', + style: Theme.of(context).textTheme.headlineSmall, + ), + ); + }).toList(), + ), + ), + ), + ); + } +} + +class DefaultTabControllerListener extends StatefulWidget { + const DefaultTabControllerListener({ + required this.onTabChanged, + required this.child, + super.key, + }); + + final ValueChanged<int> onTabChanged; + + final Widget child; + + @override + State<DefaultTabControllerListener> createState() => + _DefaultTabControllerListenerState(); +} + +class _DefaultTabControllerListenerState + extends State<DefaultTabControllerListener> { + TabController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final TabController? defaultTabController = DefaultTabController.maybeOf( + context, + ); + + assert(() { + if (defaultTabController == null) { + throw FlutterError( + 'No DefaultTabController for ${widget.runtimeType}.\n' + 'When creating a ${widget.runtimeType}, you must ensure that there ' + 'is a DefaultTabController above the ${widget.runtimeType}.', + ); + } + return true; + }()); + + if (defaultTabController != _controller) { + _controller?.removeListener(_listener); + _controller = defaultTabController; + _controller?.addListener(_listener); + } + } + + void _listener() { + final TabController? controller = _controller; + + if (controller == null || controller.indexIsChanging) { + return; + } + + widget.onTabChanged(controller.index); + } + + @override + void dispose() { + _controller?.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.0.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.0.dart new file mode 100644 index 000000000000..e47aacac968a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.0.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabBar]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatelessWidget { + const TabBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar Sample'), + bottom: const TabBar( + tabs: <Widget>[ + Tab(icon: Icon(Icons.cloud_outlined)), + Tab(icon: Icon(Icons.beach_access_sharp)), + Tab(icon: Icon(Icons.brightness_5_sharp)), + ], + ), + ), + body: const TabBarView( + children: <Widget>[ + Center(child: Text("It's cloudy here")), + Center(child: Text("It's rainy here")), + Center(child: Text("It's sunny here")), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.1.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.1.dart new file mode 100644 index 000000000000..ffb41a3bbb15 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.1.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabBar]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State<TabBarExample> createState() => _TabBarExampleState(); +} + +/// [AnimationController]s can be created with `vsync: this` because of +/// [TickerProviderStateMixin]. +class _TabBarExampleState extends State<TabBarExample> + with TickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('TabBar Sample'), + bottom: TabBar( + controller: _tabController, + tabs: const <Widget>[ + Tab(icon: Icon(Icons.cloud_outlined)), + Tab(icon: Icon(Icons.beach_access_sharp)), + Tab(icon: Icon(Icons.brightness_5_sharp)), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const <Widget>[ + Center(child: Text("It's cloudy here")), + Center(child: Text("It's rainy here")), + Center(child: Text("It's sunny here")), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.2.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.2.dart new file mode 100644 index 000000000000..c3690c04657e --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.2.dart @@ -0,0 +1,108 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabBar]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatelessWidget { + const TabBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Primary and secondary TabBar'), + bottom: const TabBar( + dividerColor: Colors.transparent, + tabs: <Widget>[ + Tab(text: 'Flights', icon: Icon(Icons.flight)), + Tab(text: 'Trips', icon: Icon(Icons.luggage)), + Tab(text: 'Explore', icon: Icon(Icons.explore)), + ], + ), + ), + body: const TabBarView( + children: <Widget>[ + NestedTabBar('Flights'), + NestedTabBar('Trips'), + NestedTabBar('Explore'), + ], + ), + ), + ); + } +} + +class NestedTabBar extends StatefulWidget { + const NestedTabBar(this.outerTab, {super.key}); + + final String outerTab; + + @override + State<NestedTabBar> createState() => _NestedTabBarState(); +} + +class _NestedTabBarState extends State<NestedTabBar> + with TickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + TabBar.secondary( + controller: _tabController, + tabs: const <Widget>[ + Tab(text: 'Overview'), + Tab(text: 'Specifications'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: <Widget>[ + Card( + margin: const .all(16.0), + child: Center(child: Text('${widget.outerTab}: Overview tab')), + ), + Card( + margin: const .all(16.0), + child: Center( + child: Text('${widget.outerTab}: Specifications tab'), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.3.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.3.dart new file mode 100644 index 000000000000..dce98907c8af --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.3.dart @@ -0,0 +1,192 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for a [TabBar] that displays custom effects on top of +/// the tab bar itself when there are more tabs in the scroll direction. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State<TabBarExample> createState() => _TabBarExampleState(); +} + +class _TabBarExampleState extends State<TabBarExample> { + double scrollOffset = 0; + double maxScrollExtent = 0; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 20, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar with scroll notifications'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(56.0), + child: NotificationListener<Notification>( + onNotification: (Notification notification) { + // ScrollMetricsNotification is for initial layout. + // ScrollNotification is for real-time scroll updates. + final ScrollMetrics? metrics = switch (notification) { + ScrollMetricsNotification(:final metrics) => metrics, + ScrollNotification(:final metrics) => metrics, + _ => null, + }; + if (metrics != null) { + setState(() { + scrollOffset = metrics.pixels; + maxScrollExtent = metrics.maxScrollExtent; + }); + } + return false; + }, + child: Stack( + children: [ + TabBar( + isScrollable: true, + tabs: List<Widget>.generate( + 20, + (int index) => Tab(text: 'Tab $index'), + ), + ), + // When the selected tab is not at the beginning or end + // (indicating TabBar is scrollable), add a gradient mask + // to left or right. + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: GradientMasks( + scrollOffset: scrollOffset, + maxScrollExtent: maxScrollExtent, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class GradientMasks extends StatelessWidget { + final double scrollOffset; + final double maxScrollExtent; + + const GradientMasks({ + super.key, + required this.scrollOffset, + required this.maxScrollExtent, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (scrollOffset > 0) const LeftMask(), + const Spacer(), + if (scrollOffset < maxScrollExtent) const RightMask(), + ], + ); + } +} + +/// This mask shows when the selected tab is not at the beginning. +class LeftMask extends StatelessWidget { + const LeftMask({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.srcOver, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.white.withValues(alpha: 0.8), + Colors.white.withValues(alpha: 0.2), + ], + ), + ), + child: Align( + alignment: .centerLeft, + child: Padding( + padding: .only(left: 4), + child: Icon( + Icons.chevron_left, + color: Colors.black.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// This mask shows when the selected tab is not at the end. +class RightMask extends StatelessWidget { + const RightMask({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ClipRect( + child: BackdropFilter( + filter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.2), + BlendMode.srcOver, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [ + Colors.white.withValues(alpha: 0.8), + Colors.white.withValues(alpha: 0.2), + ], + ), + ), + child: Align( + alignment: .centerRight, + child: Padding( + padding: .only(right: 4), + child: Icon( + Icons.chevron_right, + color: Colors.black.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.indicator_animation.0.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.indicator_animation.0.dart new file mode 100644 index 000000000000..63b2d5947a14 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.indicator_animation.0.dart @@ -0,0 +1,101 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabBar.indicatorAnimation]. + +void main() => runApp(const IndicatorAnimationExampleApp()); + +class IndicatorAnimationExampleApp extends StatelessWidget { + const IndicatorAnimationExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: IndicatorAnimationExample()); + } +} + +const List<(TabIndicatorAnimation, String)> indicatorAnimationSegments = + <(TabIndicatorAnimation, String)>[ + (TabIndicatorAnimation.linear, 'Linear'), + (TabIndicatorAnimation.elastic, 'Elastic'), + ]; + +class IndicatorAnimationExample extends StatefulWidget { + const IndicatorAnimationExample({super.key}); + + @override + State<IndicatorAnimationExample> createState() => + _IndicatorAnimationExampleState(); +} + +class _IndicatorAnimationExampleState extends State<IndicatorAnimationExample> { + Set<TabIndicatorAnimation> _animationStyleSelection = <TabIndicatorAnimation>{ + TabIndicatorAnimation.linear, + }; + TabIndicatorAnimation _tabIndicatorAnimation = .linear; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 6, + child: Scaffold( + appBar: AppBar( + title: const Text('Indicator Animation Example'), + bottom: TabBar( + indicatorAnimation: _tabIndicatorAnimation, + isScrollable: true, + tabAlignment: .start, + tabs: const <Widget>[ + Tab(text: 'Short Tab'), + Tab(text: 'Very Very Very Long Tab'), + Tab(text: 'Short Tab'), + Tab(text: 'Very Very Very Long Tab'), + Tab(text: 'Short Tab'), + Tab(text: 'Very Very Very Long Tab'), + ], + ), + ), + body: Column( + children: <Widget>[ + const SizedBox(height: 16), + SegmentedButton<TabIndicatorAnimation>( + selected: _animationStyleSelection, + onSelectionChanged: (Set<TabIndicatorAnimation> styles) { + setState(() { + _animationStyleSelection = styles; + _tabIndicatorAnimation = styles.first; + }); + }, + segments: indicatorAnimationSegments + .map<ButtonSegment<TabIndicatorAnimation>>(( + (TabIndicatorAnimation, String) shirt, + ) { + return ButtonSegment<TabIndicatorAnimation>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }) + .toList(), + ), + const SizedBox(height: 16), + const Expanded( + child: TabBarView( + children: <Widget>[ + Center(child: Text('Short Tab Page')), + Center(child: Text('Very Very Very Long Tab Page')), + Center(child: Text('Short Tab Page')), + Center(child: Text('Very Very Very Long Tab Page')), + Center(child: Text('Short Tab Page')), + Center(child: Text('Very Very Very Long Tab Page')), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.onFocusChange.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.onFocusChange.dart new file mode 100644 index 000000000000..63f4ade090ec --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.onFocusChange.dart @@ -0,0 +1,79 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabBar.onFocusChange]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State<TabBarExample> createState() => _TabBarExampleState(); +} + +class _TabBarExampleState extends State<TabBarExample> { + int? focusedIndex; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar Sample'), + bottom: TabBar( + onFocusChange: (bool value, int index) { + setState(() { + focusedIndex = switch (value) { + true => index, + false => null, + }; + }); + }, + tabs: <Widget>[ + Tab( + icon: Icon( + Icons.cloud_outlined, + size: focusedIndex == 0 ? 35 : 25, + ), + ), + Tab( + icon: Icon( + Icons.beach_access_sharp, + size: focusedIndex == 1 ? 35 : 25, + ), + ), + Tab( + icon: Icon( + Icons.brightness_5_sharp, + size: focusedIndex == 2 ? 35 : 25, + ), + ), + ], + ), + ), + body: const TabBarView( + children: <Widget>[ + Center(child: Text("It's cloudy here")), + Center(child: Text("It's rainy here")), + Center(child: Text("It's sunny here")), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.onHover.dart b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.onHover.dart new file mode 100644 index 000000000000..dc6e311396f4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tabs/tab_bar.onHover.dart @@ -0,0 +1,68 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TabBar.onFocusChange]. + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TabBarExample()); + } +} + +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); + + @override + State<TabBarExample> createState() => _TabBarExampleState(); +} + +class _TabBarExampleState extends State<TabBarExample> { + final List<Color> tabColors = <Color>[ + Colors.purple, + Colors.purple, + Colors.purple, + ]; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('TabBar Sample'), + bottom: TabBar( + onHover: (bool value, int index) { + setState(() { + tabColors[index] = switch (value) { + true => Colors.pink, + false => Colors.purple, + }; + }); + }, + tabs: <Widget>[ + Tab(icon: Icon(Icons.cloud_outlined, color: tabColors[0])), + Tab(icon: Icon(Icons.beach_access_sharp, color: tabColors[1])), + Tab(icon: Icon(Icons.brightness_5_sharp, color: tabColors[2])), + ], + ), + ), + body: const TabBarView( + children: <Widget>[ + Center(child: Text("It's cloudy here")), + Center(child: Text("It's rainy here")), + Center(child: Text("It's sunny here")), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/text_button/text_button.0.dart b/packages/material_ui/material_ui_examples/lib/text_button/text_button.0.dart new file mode 100644 index 000000000000..26a3c13aac63 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_button/text_button.0.dart @@ -0,0 +1,495 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TextButton]. + +void main() { + runApp(const TextButtonExampleApp()); +} + +class TextButtonExampleApp extends StatefulWidget { + const TextButtonExampleApp({super.key}); + + @override + State<TextButtonExampleApp> createState() => _TextButtonExampleAppState(); +} + +class _TextButtonExampleAppState extends State<TextButtonExampleApp> { + bool darkMode = false; + + @override + Widget build(BuildContext context) { + return MaterialApp( + themeMode: darkMode ? .dark : .light, + theme: ThemeData(brightness: .light), + darkTheme: ThemeData(brightness: .dark), + home: Scaffold( + body: Padding( + padding: const .all(16), + child: TextButtonExample( + darkMode: darkMode, + updateDarkMode: (bool value) { + setState(() { + darkMode = value; + }); + }, + ), + ), + ), + ); + } +} + +class TextButtonExample extends StatefulWidget { + const TextButtonExample({ + super.key, + required this.darkMode, + required this.updateDarkMode, + }); + + final bool darkMode; + final ValueChanged<bool> updateDarkMode; + + @override + State<TextButtonExample> createState() => _TextButtonExampleState(); +} + +class _TextButtonExampleState extends State<TextButtonExample> { + TextDirection textDirection = .ltr; + ThemeMode themeMode = .light; + late final ScrollController scrollController; + Future<void>? currentAction; + + static const Widget verticalSpacer = SizedBox(height: 16); + static const Widget horizontalSpacer = SizedBox(width: 32); + + static const ImageProvider grassImage = NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_grass.jpeg', + ); + static const ImageProvider defaultImage = NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_nhu_default.png', + ); + static const ImageProvider hoveredImage = NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_nhu_hovered.png', + ); + static const ImageProvider pressedImage = NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_nhu_pressed.png', + ); + static const ImageProvider runningImage = NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/text_button_nhu_end.png', + ); + + @override + void initState() { + scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + // Adapt colors that are not part of the color scheme to + // the current dark/light mode. Used to define TextButton #7's + // gradients. + final ( + Color color1, + Color color2, + Color color3, + ) = switch (colorScheme.brightness) { + .light => (Colors.blue, Colors.orange, Colors.yellow), + .dark => (Colors.purple, Colors.cyan, Colors.yellow), + }; + + // This gradient's appearance reflects the button's state. + // Always return a gradient decoration so that AnimatedContainer + // can interpolate in between. Used by TextButton #7. + Decoration? statesToDecoration(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return BoxDecoration( + gradient: LinearGradient( + colors: <Color>[color2, color2], + ), // solid fill + ); + } + return BoxDecoration( + gradient: LinearGradient( + colors: switch (states.contains(WidgetState.hovered)) { + true => <Color>[color1, color2], + false => <Color>[color2, color1], + }, + ), + ); + } + + // To make this method a little easier to read, the buttons that + // appear in the two columns to the right of the demo switches + // Card are broken out below. + + final List<Widget> columnOneButtons = <Widget>[ + TextButton(onPressed: () {}, child: const Text('Enabled')), + verticalSpacer, + + const TextButton(onPressed: null, child: Text('Disabled')), + verticalSpacer, + + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.access_alarm), + label: const Text('TextButton.icon #1'), + ), + verticalSpacer, + + // Override the foreground and background colors. + // + // In this example, and most of the ones that follow, we're using + // the TextButton.styleFrom() convenience method to create a ButtonStyle. + // The styleFrom method is a little easier because it creates + // ButtonStyle WidgetStateProperty parameters for you. + // In this case, Specifying foregroundColor overrides the text, + // icon and overlay (splash and highlight) colors a little differently + // depending on the button's state. BackgroundColor is just the background + // color for all states. + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: colorScheme.onError, + backgroundColor: colorScheme.error, + ), + onPressed: () {}, + icon: const Icon(Icons.access_alarm), + label: const Text('TextButton.icon #2'), + ), + verticalSpacer, + + // Override the button's shape and its border. + // + // In this case we've specified a shape that has border - the + // RoundedRectangleBorder's side parameter. If the styleFrom + // side parameter was also specified, or if the TextButtonTheme + // defined above included a side parameter, then that would + // override the RoundedRectangleBorder's side. + TextButton( + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: const .all(Radius.circular(8)), + side: BorderSide(color: colorScheme.primary, width: 5), + ), + ), + onPressed: () {}, + child: const Text('TextButton #3'), + ), + verticalSpacer, + + // Override overlay: the ink splash and highlight colors. + // + // The styleFrom method turns the specified overlayColor + // into a value MaterialStyleProperty<Color> ButtonStyle.overlay + // value that uses opacities depending on the button's state. + // If the overlayColor was Colors.transparent, no splash + // or highlights would be shown. + TextButton( + style: TextButton.styleFrom(overlayColor: Colors.yellow), + onPressed: () {}, + child: const Text('TextButton #4'), + ), + ]; + + final List<Widget> columnTwoButtons = <Widget>[ + // Override the foregroundBuilder: apply a ShaderMask. + // + // Apply a ShaderMask to the button's child. This kind of thing + // can be applied to one button easily enough by just wrapping the + // button's child directly. However to affect all buttons in this + // way you can specify a similar foregroundBuilder in a TextButton + // theme or the MaterialApp theme's ThemeData.textButtonTheme. + TextButton( + style: TextButton.styleFrom( + foregroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + return ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: <Color>[ + colorScheme.primary, + colorScheme.onPrimary, + ], + ).createShader(bounds); + }, + blendMode: BlendMode.srcATop, + child: child, + ); + }, + ), + onPressed: () {}, + child: const Text('TextButton #5'), + ), + verticalSpacer, + + // Override the foregroundBuilder: add an underline. + // + // Add a border around button's child. In this case the + // border only appears when the button is hovered or pressed + // (if it's pressed it's always hovered too). Not that this + // border is different than the one specified with the styleFrom + // side parameter (or the ButtonStyle.side property). The foregroundBuilder + // is applied to a widget that contains the child and has already + // included the button's padding. It is unaffected by the button's shape. + // The styleFrom side parameter controls the button's outermost border and it + // outlines the button's shape. + TextButton( + style: TextButton.styleFrom( + foregroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: BoxDecoration( + border: states.contains(WidgetState.hovered) + ? Border(bottom: BorderSide(color: colorScheme.primary)) + : const Border(), // essentially "no border" + ), + child: child, + ); + }, + ), + onPressed: () {}, + child: const Text('TextButton #6'), + ), + verticalSpacer, + + // Override the backgroundBuilder to add a state specific gradient background + // and add an outline that only appears when the button is hovered or pressed. + // + // The gradient background decoration is computed by the statesToDecoration() + // method. The gradient flips horizontally when the button is hovered (watch + // closely). Because we want the outline to only appear when the button is hovered + // we can't use the styleFrom() side parameter, because that creates the same + // outline for all states. The ButtonStyle.copyWith() method is used to add + // a WidgetState<BorderSide?> property that does the right thing. + // + // The gradient background is translucent - all of the colors have opacity 0.5 - + // so the overlay's splash and highlight colors are visible even though they're + // drawn on the Material widget that's effectively behind the background. The + // border is also translucent, so if you look carefully, you'll see that the + // background - which is part of the button's Material but is drawn on top of the + // the background gradient - shows through the border. + TextButton( + onPressed: () {}, + style: + TextButton.styleFrom( + overlayColor: color2, + backgroundBuilder: + ( + BuildContext context, + Set<WidgetState> states, + Widget? child, + ) { + return AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: statesToDecoration(states), + child: child, + ); + }, + ).copyWith( + side: WidgetStateProperty.resolveWith<BorderSide?>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return BorderSide(width: 3, color: color3); + } + return null; // defer to the default + }), + ), + child: const Text('TextButton #7'), + ), + verticalSpacer, + + // Override the backgroundBuilder to add a grass image background. + // + // The image is clipped to the button's shape. We've included an Ink widget + // because the background image is opaque and would otherwise obscure the splash + // and highlight overlays that are painted on the button's Material widget + // by default. They're drawn on the Ink widget instead. The foreground color + // was overridden as well because white shows up a little better on the mottled + // green background. + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + return Ink( + decoration: const BoxDecoration( + image: DecorationImage(image: grassImage, fit: .cover), + ), + child: child, + ); + }, + ), + child: const Text('TextButton #8'), + ), + verticalSpacer, + + // Override the foregroundBuilder to specify images for the button's pressed + // hovered and default states. We switch to an additional image while the + // button's callback is "running". + // + // This is an example of completely changing the default appearance of a button + // by specifying images for each state and by turning off the overlays by + // overlayColor: Colors.transparent. AnimatedContainer takes care of the + // fade in and out segues between images. + // + // This foregroundBuilder function ignores its child parameter. Unfortunately + // TextButton's child parameter is required, so we still have + // to provide one. + TextButton( + onPressed: () async { + // This is slightly complicated so that if the user presses the button + // while the current Future.delayed action is running, the currentAction + // flag is only reset to null after the _new_ action completes. + late final Future<void> thisAction; + thisAction = Future<void>.delayed(const Duration(seconds: 1), () { + if (currentAction == thisAction) { + setState(() { + currentAction = null; + }); + } + }); + setState(() { + currentAction = thisAction; + }); + }, + style: TextButton.styleFrom( + overlayColor: Colors.transparent, + foregroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + late final ImageProvider image; + if (currentAction != null) { + image = runningImage; + } else if (states.contains(WidgetState.pressed)) { + image = pressedImage; + } else if (states.contains(WidgetState.hovered)) { + image = hoveredImage; + } else { + image = defaultImage; + } + return AnimatedContainer( + width: 64, + height: 64, + duration: const Duration(milliseconds: 300), + curve: Curves.fastOutSlowIn, + decoration: BoxDecoration( + image: DecorationImage(image: image, fit: .contain), + ), + ); + }, + ), + child: const Text('This child is not used'), + ), + ]; + + return Row( + children: <Widget>[ + // The dark/light and LTR/RTL switches. We use the updateDarkMode function + // provided by the parent TextButtonExampleApp to rebuild the MaterialApp + // in the appropriate dark/light ThemeMdoe. The directionality of the rest + // of the UI is controlled by the Directionality widget below, and the + // textDirection local state variable. + TextButtonExampleSwitches( + darkMode: widget.darkMode, + updateDarkMode: widget.updateDarkMode, + textDirection: textDirection, + updateRTL: (bool value) { + setState(() { + textDirection = value ? .rtl : .ltr; + }); + }, + ), + horizontalSpacer, + + Expanded( + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + child: Row( + mainAxisAlignment: .spaceEvenly, + mainAxisSize: .min, + children: <Widget>[ + Directionality( + textDirection: textDirection, + child: Column(children: columnOneButtons), + ), + horizontalSpacer, + + Directionality( + textDirection: textDirection, + child: Column(children: columnTwoButtons), + ), + horizontalSpacer, + ], + ), + ), + ), + ), + ], + ); + } +} + +class TextButtonExampleSwitches extends StatelessWidget { + const TextButtonExampleSwitches({ + super.key, + required this.darkMode, + required this.updateDarkMode, + required this.textDirection, + required this.updateRTL, + }); + + final bool darkMode; + final ValueChanged<bool> updateDarkMode; + final TextDirection textDirection; + final ValueChanged<bool> updateRTL; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const .all(16), + child: IntrinsicWidth( + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + const Expanded(child: Text('Dark Mode')), + const SizedBox(width: 4), + Switch(value: darkMode, onChanged: updateDarkMode), + ], + ), + const SizedBox(height: 16), + Row( + children: <Widget>[ + const Expanded(child: Text('RTL Text')), + const SizedBox(width: 4), + Switch(value: textDirection == .rtl, onChanged: updateRTL), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/text_button/text_button.1.dart b/packages/material_ui/material_ui_examples/lib/text_button/text_button.1.dart new file mode 100644 index 000000000000..3f4efda18fb5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_button/text_button.1.dart @@ -0,0 +1,99 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TextButton]. + +void main() { + runApp(const MaterialApp(home: Home())); +} + +class SelectableButton extends StatefulWidget { + const SelectableButton({ + super.key, + required this.selected, + this.style, + required this.onPressed, + required this.child, + }); + + final bool selected; + final ButtonStyle? style; + final VoidCallback? onPressed; + final Widget child; + + @override + State<SelectableButton> createState() => _SelectableButtonState(); +} + +class _SelectableButtonState extends State<SelectableButton> { + late final WidgetStatesController statesController; + + @override + void initState() { + super.initState(); + statesController = WidgetStatesController(<WidgetState>{ + if (widget.selected) WidgetState.selected, + }); + } + + @override + void didUpdateWidget(SelectableButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selected != oldWidget.selected) { + statesController.update(WidgetState.selected, widget.selected); + } + } + + @override + Widget build(BuildContext context) { + return TextButton( + statesController: statesController, + style: widget.style, + onPressed: widget.onPressed, + child: widget.child, + ); + } +} + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State<Home> createState() => _HomeState(); +} + +class _HomeState extends State<Home> { + bool selected = false; + + /// Sets the button's foreground and background colors. + /// If not selected, resolves to null and defers to default values. + static const ButtonStyle style = ButtonStyle( + foregroundColor: WidgetStateProperty<Color?>.fromMap(<WidgetState, Color>{ + WidgetState.selected: Colors.white, + }), + backgroundColor: WidgetStateProperty<Color?>.fromMap(<WidgetState, Color>{ + WidgetState.selected: Colors.indigo, + }), + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SelectableButton( + selected: selected, + style: style, + onPressed: () { + setState(() { + selected = !selected; + }); + }, + child: const Text('toggle selected'), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/text_field/text_field.0.dart b/packages/material_ui/material_ui_examples/lib/text_field/text_field.0.dart new file mode 100644 index 000000000000..d08ac27d3ba5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_field/text_field.0.dart @@ -0,0 +1,40 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TextField]. + +class ObscuredTextFieldSample extends StatelessWidget { + const ObscuredTextFieldSample({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 250, + child: TextField( + obscureText: true, + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Password', + ), + ), + ); + } +} + +class TextFieldExampleApp extends StatelessWidget { + const TextFieldExampleApp({super.key}); + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Obscured Textfield')), + body: const Center(child: ObscuredTextFieldSample()), + ), + ); + } +} + +void main() => runApp(const TextFieldExampleApp()); diff --git a/packages/material_ui/material_ui_examples/lib/text_field/text_field.1.dart b/packages/material_ui/material_ui_examples/lib/text_field/text_field.1.dart new file mode 100644 index 000000000000..33012213f39a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_field/text_field.1.dart @@ -0,0 +1,73 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TextField]. + +void main() => runApp(const TextFieldExampleApp()); + +class TextFieldExampleApp extends StatelessWidget { + const TextFieldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TextFieldExample()); + } +} + +class TextFieldExample extends StatefulWidget { + const TextFieldExample({super.key}); + + @override + State<TextFieldExample> createState() => _TextFieldExampleState(); +} + +class _TextFieldExampleState extends State<TextFieldExample> { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: TextField( + controller: _controller, + onSubmitted: (String value) async { + await showDialog<void>( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Thanks!'), + content: Text( + 'You typed "$value", which has length ${value.characters.length}.', + ), + actions: <Widget>[ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/text_field/text_field.2.dart b/packages/material_ui/material_ui_examples/lib/text_field/text_field.2.dart new file mode 100644 index 000000000000..cb6fde0d11e2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_field/text_field.2.dart @@ -0,0 +1,77 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for Material Design 3 [TextField]s. + +void main() { + runApp(const TextFieldExamplesApp()); +} + +class TextFieldExamplesApp extends StatelessWidget { + const TextFieldExamplesApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4)), + home: Scaffold( + appBar: AppBar(title: const Text('TextField Examples')), + body: const Column( + children: <Widget>[ + Spacer(), + FilledTextFieldExample(), + OutlinedTextFieldExample(), + Spacer(), + ], + ), + ), + ); + } +} + +/// An example of the filled text field type. +/// +/// A filled [TextField] with default settings matching the spec: +/// https://m3.material.io/components/text-fields/specs#6d654d1d-262e-4697-858c-9a75e8e7c81d +class FilledTextFieldExample extends StatelessWidget { + const FilledTextFieldExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration( + prefixIcon: Icon(Icons.search), + suffixIcon: Icon(Icons.clear), + labelText: 'Filled', + hintText: 'hint text', + helperText: 'supporting text', + filled: true, + ), + ); + } +} + +/// An example of the outlined text field type. +/// +/// A Outlined [TextField] with default settings matching the spec: +/// https://m3.material.io/components/text-fields/specs#68b00bd6-ab40-4b4f-93d9-ed1fbbc5d06e +class OutlinedTextFieldExample extends StatelessWidget { + const OutlinedTextFieldExample({super.key}); + + @override + Widget build(BuildContext context) { + return const TextField( + decoration: InputDecoration( + prefixIcon: Icon(Icons.search), + suffixIcon: Icon(Icons.clear), + labelText: 'Outlined', + hintText: 'hint text', + helperText: 'supporting text', + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/text_field/text_field.3.dart b/packages/material_ui/material_ui_examples/lib/text_field/text_field.3.dart new file mode 100644 index 000000000000..cd4678fde914 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_field/text_field.3.dart @@ -0,0 +1,126 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [TextField]. + +void main() { + runApp(const TextFieldExampleApp()); +} + +class TextFieldExampleApp extends StatelessWidget { + const TextFieldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('TextField Shift+Enter Example')), + body: const TextFieldShiftEnterExample(), + ), + ); + } +} + +class TextFieldShiftEnterExample extends StatefulWidget { + const TextFieldShiftEnterExample({super.key}); + + @override + State<TextFieldShiftEnterExample> createState() => + _TextFieldShiftEnterExampleState(); +} + +class _TextFieldShiftEnterExampleState + extends State<TextFieldShiftEnterExample> { + final FocusNode _focusNode = FocusNode(); + + final TextEditingController _controller = TextEditingController(); + + String? _submittedText; + + @override + void dispose() { + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + Expanded( + child: Center( + child: Text( + _submittedText == null + ? 'Please submit some text\n\n' + 'Press Shift+Enter for a new line\n' + 'Press Enter to submit' + : 'Submitted text:\n\n${_submittedText!}', + textAlign: .center, + ), + ), + ), + Shortcuts( + shortcuts: <ShortcutActivator, Intent>{ + // Map the `Shift+Enter` combination to our custom intent. + const SingleActivator(LogicalKeyboardKey.enter, shift: true): + _InsertNewLineTextIntent(), + }, + child: Actions( + actions: <Type, Action<Intent>>{ + // When the _InsertNewLineTextIntent is invoked, CallbackAction's + // onInvoke callback is executed. + _InsertNewLineTextIntent: + CallbackAction<_InsertNewLineTextIntent>( + onInvoke: (_InsertNewLineTextIntent intent) { + final TextEditingValue value = _controller.value; + final String newText = value.text.replaceRange( + value.selection.start, + value.selection.end, + '\n', + ); + _controller.value = value.copyWith( + text: newText, + selection: TextSelection.collapsed( + offset: value.selection.start + 1, + ), + ); + + return null; + }, + ), + }, + child: Padding( + padding: const .all(12), + child: TextField( + focusNode: _focusNode, + autofocus: true, + controller: _controller, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Text', + ), + maxLines: null, + textInputAction: .done, + onSubmitted: (String? text) { + setState(() { + _submittedText = text; + _controller.clear(); + _focusNode.requestFocus(); + }); + }, + ), + ), + ), + ), + ], + ); + } +} + +/// A custom [Intent] to represent the action of inserting a newline. +class _InsertNewLineTextIntent extends Intent {} diff --git a/packages/material_ui/material_ui_examples/lib/text_form_field/text_form_field.1.dart b/packages/material_ui/material_ui_examples/lib/text_form_field/text_form_field.1.dart new file mode 100644 index 000000000000..e06a5c7eeed4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_form_field/text_form_field.1.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for [TextFormField]. + +void main() => runApp(const TextFormFieldExampleApp()); + +class TextFormFieldExampleApp extends StatelessWidget { + const TextFormFieldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TextFormFieldExample()); + } +} + +class TextFormFieldExample extends StatefulWidget { + const TextFormFieldExample({super.key}); + + @override + State<TextFormFieldExample> createState() => _TextFormFieldExampleState(); +} + +class _TextFormFieldExampleState extends State<TextFormFieldExample> { + @override + Widget build(BuildContext context) { + return Material( + child: Center( + child: Shortcuts( + shortcuts: const <ShortcutActivator, Intent>{ + // Pressing space in the field will now move to the next field. + SingleActivator(LogicalKeyboardKey.space): NextFocusIntent(), + }, + child: FocusTraversalGroup( + child: Form( + autovalidateMode: .always, + onChanged: () { + Form.of(primaryFocus!.context!).save(); + }, + child: Wrap( + children: List<Widget>.generate(5, (int index) { + return Padding( + padding: const .all(8.0), + child: ConstrainedBox( + constraints: BoxConstraints.tight(const Size(200, 50)), + child: TextFormField( + onSaved: (String? value) { + debugPrint( + 'Value for field $index saved as "$value"', + ); + }, + ), + ), + ); + }), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/text_form_field/text_form_field.2.dart b/packages/material_ui/material_ui_examples/lib/text_form_field/text_form_field.2.dart new file mode 100644 index 000000000000..05572477d55d --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/text_form_field/text_form_field.2.dart @@ -0,0 +1,133 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [TextFormField]. + +const Duration kFakeHttpRequestDuration = Duration(seconds: 3); + +void main() => runApp(const TextFormFieldExampleApp()); + +class TextFormFieldExampleApp extends StatelessWidget { + const TextFormFieldExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: TextFormFieldExample()); + } +} + +class TextFormFieldExample extends StatefulWidget { + const TextFormFieldExample({super.key}); + + @override + State<TextFormFieldExample> createState() => _TextFormFieldExampleState(); +} + +class _TextFormFieldExampleState extends State<TextFormFieldExample> { + final TextEditingController controller = TextEditingController(); + final GlobalKey<FormState> formKey = GlobalKey<FormState>(); + String? forceErrorText; + bool isLoading = false; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + String? validator(String? value) { + if (value == null || value.isEmpty) { + return 'This field is required'; + } + if (value.length != value.replaceAll(' ', '').length) { + return 'Username must not contain any spaces'; + } + if (int.tryParse(value[0]) != null) { + return 'Username must not start with a number'; + } + if (value.length <= 2) { + return 'Username should be at least 3 characters long'; + } + return null; + } + + void onChanged(String value) { + // Nullify forceErrorText if the input changed. + if (forceErrorText != null) { + setState(() { + forceErrorText = null; + }); + } + } + + Future<void> onSave() async { + // Providing a default value in case this was called on the + // first frame, the [fromKey.currentState] will be null. + final bool isValid = formKey.currentState?.validate() ?? false; + if (!isValid) { + return; + } + + setState(() => isLoading = true); + final String? errorText = await validateUsernameFromServer(controller.text); + + if (context.mounted) { + setState(() => isLoading = false); + + if (errorText != null) { + setState(() { + forceErrorText = errorText; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Material( + child: Padding( + padding: const .symmetric(horizontal: 24.0), + child: Center( + child: Form( + key: formKey, + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + TextFormField( + forceErrorText: forceErrorText, + controller: controller, + decoration: const InputDecoration( + hintText: 'Please write a username', + ), + validator: validator, + onChanged: onChanged, + ), + const SizedBox(height: 40.0), + if (isLoading) + const CircularProgressIndicator() + else + TextButton(onPressed: onSave, child: const Text('Save')), + ], + ), + ), + ), + ), + ); + } +} + +Future<String?> validateUsernameFromServer(String username) async { + final Set<String> takenUsernames = <String>{'jack', 'alex'}; + + await Future<void>.delayed(kFakeHttpRequestDuration); + + final bool isValid = !takenUsernames.contains(username); + if (isValid) { + return null; + } + + return 'Username $username is already taken'; +} diff --git a/packages/material_ui/material_ui_examples/lib/theme/theme_extension.1.dart b/packages/material_ui/material_ui_examples/lib/theme/theme_extension.1.dart new file mode 100644 index 000000000000..d81957e327ad --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/theme/theme_extension.1.dart @@ -0,0 +1,115 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/scheduler.dart'; + +/// Flutter code sample for [ThemeExtension]. + +@immutable +class MyColors extends ThemeExtension<MyColors> { + const MyColors({required this.brandColor, required this.danger}); + + final Color? brandColor; + final Color? danger; + + @override + MyColors copyWith({Color? brandColor, Color? danger}) { + return MyColors( + brandColor: brandColor ?? this.brandColor, + danger: danger ?? this.danger, + ); + } + + @override + MyColors lerp(MyColors? other, double t) { + if (other is! MyColors) { + return this; + } + return MyColors( + brandColor: Color.lerp(brandColor, other.brandColor, t), + danger: Color.lerp(danger, other.danger, t), + ); + } + + // Optional + @override + String toString() => 'MyColors(brandColor: $brandColor, danger: $danger)'; +} + +void main() { + // Slow down time to see lerping. + timeDilation = 5.0; + runApp(const ThemeExtensionExampleApp()); +} + +class ThemeExtensionExampleApp extends StatefulWidget { + const ThemeExtensionExampleApp({super.key}); + + @override + State<ThemeExtensionExampleApp> createState() => + _ThemeExtensionExampleAppState(); +} + +class _ThemeExtensionExampleAppState extends State<ThemeExtensionExampleApp> { + bool isLightTheme = true; + + void toggleTheme() { + setState(() => isLightTheme = !isLightTheme); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + extensions: const <ThemeExtension<dynamic>>[ + MyColors(brandColor: Color(0xFF1E88E5), danger: Color(0xFFE53935)), + ], + ), + darkTheme: ThemeData.dark().copyWith( + extensions: <ThemeExtension<dynamic>>[ + const MyColors( + brandColor: Color(0xFF90CAF9), + danger: Color(0xFFEF9A9A), + ), + ], + ), + themeMode: isLightTheme ? .light : .dark, + home: Home(isLightTheme: isLightTheme, toggleTheme: toggleTheme), + ); + } +} + +class Home extends StatelessWidget { + const Home({ + super.key, + required this.isLightTheme, + required this.toggleTheme, + }); + + final bool isLightTheme; + final void Function() toggleTheme; + + @override + Widget build(BuildContext context) { + final MyColors myColors = Theme.of(context).extension<MyColors>()!; + return Material( + child: Center( + child: Row( + mainAxisAlignment: .center, + children: <Widget>[ + Container(width: 100, height: 100, color: myColors.brandColor), + const SizedBox(width: 10), + Container(width: 100, height: 100, color: myColors.danger), + const SizedBox(width: 50), + IconButton( + icon: Icon(isLightTheme ? Icons.nightlight : Icons.wb_sunny), + onPressed: toggleTheme, + ), + ], + ), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/theme_data/theme_data.0.dart b/packages/material_ui/material_ui_examples/lib/theme_data/theme_data.0.dart new file mode 100644 index 000000000000..3c244e734861 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/theme_data/theme_data.0.dart @@ -0,0 +1,110 @@ +// 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:material_ui/material_ui.dart'; + +void main() { + runApp(const ThemeDataExampleApp()); +} + +// This app's theme specifies an overall ColorScheme as well as overrides +// for the default configuration of FloatingActionButtons. To customize +// the appearance of other components, add additional component specific +// themes, rather than tweaking the color scheme. +// +// Creating an entire color scheme from a single seed color is a good +// way to ensure a visually appealing color palette where the default +// component colors have sufficient contrast for accessibility. Another +// good way to create an app's color scheme is to use +// ColorScheme.fromImageProvider. +// +// The color scheme reflects the platform's light or dark setting +// which is retrieved with `MediaQuery.platformBrightnessOf`. The color +// scheme's colors will be different for light and dark settings although +// they'll all be related to the seed color in both cases. +// +// Color scheme colors have been used where component defaults have +// been overridden so that the app will look good and remain accessible +// in both light and dark modes. +// +// Text styles are derived from the theme's textTheme (not the obsolete +// primaryTextTheme property) and then customized using copyWith. +// Using the _on_ version of a color scheme color as the foreground, +// as in `tertiary` and `onTertiary`, guarantees sufficient contrast +// for readability/accessibility. + +class ThemeDataExampleApp extends StatelessWidget { + const ThemeDataExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = ColorScheme.fromSeed( + brightness: MediaQuery.platformBrightnessOf(context), + seedColor: Colors.indigo, + ); + return MaterialApp( + title: 'ThemeData Demo', + theme: ThemeData( + colorScheme: colorScheme, + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.tertiary, + foregroundColor: colorScheme.onTertiary, + ), + ), + home: const Home(), + ); + } +} + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State<Home> createState() => _HomeState(); +} + +class _HomeState extends State<Home> { + int buttonPressCount = 0; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final double pointCount = 8 + (buttonPressCount % 6); + + return Scaffold( + appBar: AppBar(title: const Text('Press the + Button')), + // An AnimatedContainer makes the decoration changes entertaining. + body: AnimatedContainer( + duration: const Duration(milliseconds: 500), + margin: const .all(32), + alignment: .center, + decoration: ShapeDecoration( + color: colorScheme.tertiaryContainer, + shape: StarBorder( + points: pointCount, + pointRounding: 0.4, + valleyRounding: 0.6, + side: BorderSide(width: 9, color: colorScheme.tertiary), + ), + ), + child: Text( + '${pointCount.toInt()} Points', + style: theme.textTheme.headlineMedium!.copyWith( + color: colorScheme.onPrimaryContainer, + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + buttonPressCount += 1; + }); + }, + tooltip: "Change the shape's point count", + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/time_picker/show_time_picker.0.dart b/packages/material_ui/material_ui_examples/lib/time_picker/show_time_picker.0.dart new file mode 100644 index 000000000000..ccd26d44bc1a --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/time_picker/show_time_picker.0.dart @@ -0,0 +1,345 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [showTimePicker]. + +void main() { + runApp(const ShowTimePickerApp()); +} + +class ShowTimePickerApp extends StatefulWidget { + const ShowTimePickerApp({super.key}); + + @override + State<ShowTimePickerApp> createState() => _ShowTimePickerAppState(); +} + +class _ShowTimePickerAppState extends State<ShowTimePickerApp> { + ThemeMode themeMode = .dark; + bool useMaterial3 = true; + + void setThemeMode(ThemeMode mode) { + setState(() { + themeMode = mode; + }); + } + + void setUseMaterial3(bool? value) { + setState(() { + useMaterial3 = value!; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(useMaterial3: useMaterial3), + darkTheme: ThemeData.dark(useMaterial3: useMaterial3), + themeMode: themeMode, + home: TimePickerOptions( + themeMode: themeMode, + useMaterial3: useMaterial3, + setThemeMode: setThemeMode, + setUseMaterial3: setUseMaterial3, + ), + ); + } +} + +class TimePickerOptions extends StatefulWidget { + const TimePickerOptions({ + super.key, + required this.themeMode, + required this.useMaterial3, + required this.setThemeMode, + required this.setUseMaterial3, + }); + + final ThemeMode themeMode; + final bool useMaterial3; + final ValueChanged<ThemeMode> setThemeMode; + final ValueChanged<bool?> setUseMaterial3; + + @override + State<TimePickerOptions> createState() => _TimePickerOptionsState(); +} + +class _TimePickerOptionsState extends State<TimePickerOptions> { + TimeOfDay? selectedTime; + TimePickerEntryMode entryMode = .dial; + Orientation? orientation; + TextDirection textDirection = .ltr; + MaterialTapTargetSize tapTargetSize = .padded; + bool use24HourTime = false; + + void _entryModeChanged(TimePickerEntryMode? value) { + if (value != entryMode) { + setState(() { + entryMode = value!; + }); + } + } + + void _orientationChanged(Orientation? value) { + if (value != orientation) { + setState(() { + orientation = value; + }); + } + } + + void _textDirectionChanged(TextDirection? value) { + if (value != textDirection) { + setState(() { + textDirection = value!; + }); + } + } + + void _tapTargetSizeChanged(MaterialTapTargetSize? value) { + if (value != tapTargetSize) { + setState(() { + tapTargetSize = value!; + }); + } + } + + void _use24HourTimeChanged(bool? value) { + if (value != use24HourTime) { + setState(() { + use24HourTime = value!; + }); + } + } + + void _themeModeChanged(ThemeMode? value) { + widget.setThemeMode(value!); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Column( + children: <Widget>[ + Expanded( + child: GridView( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 350, + mainAxisSpacing: 4, + mainAxisExtent: 200, + crossAxisSpacing: 4, + ), + children: <Widget>[ + EnumCard<TimePickerEntryMode>( + choices: TimePickerEntryMode.values, + value: entryMode, + onChanged: _entryModeChanged, + ), + EnumCard<ThemeMode>( + choices: ThemeMode.values, + value: widget.themeMode, + onChanged: _themeModeChanged, + ), + EnumCard<TextDirection>( + choices: TextDirection.values, + value: textDirection, + onChanged: _textDirectionChanged, + ), + EnumCard<MaterialTapTargetSize>( + choices: MaterialTapTargetSize.values, + value: tapTargetSize, + onChanged: _tapTargetSizeChanged, + ), + ChoiceCard<Orientation?>( + choices: const <Orientation?>[...Orientation.values, null], + value: orientation, + title: '$Orientation', + choiceLabels: <Orientation?, String>{ + for (final Orientation choice in Orientation.values) + choice: choice.name, + null: 'from MediaQuery', + }, + onChanged: _orientationChanged, + ), + ChoiceCard<bool>( + choices: const <bool>[false, true], + value: use24HourTime, + onChanged: _use24HourTimeChanged, + title: 'Time Mode', + choiceLabels: const <bool, String>{ + false: '12-hour am/pm time', + true: '24-hour time', + }, + ), + ChoiceCard<bool>( + choices: const <bool>[false, true], + value: widget.useMaterial3, + onChanged: widget.setUseMaterial3, + title: 'Material Version', + choiceLabels: const <bool, String>{ + false: 'Material 2', + true: 'Material 3', + }, + ), + ], + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: .center, + children: <Widget>[ + Padding( + padding: const .all(12.0), + child: ElevatedButton( + child: const Text('Open time picker'), + onPressed: () async { + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: selectedTime ?? TimeOfDay.now(), + initialEntryMode: entryMode, + orientation: orientation, + builder: (BuildContext context, Widget? child) { + // We just wrap these environmental changes around the + // child in this builder so that we can apply the + // options selected above. In regular usage, this is + // rarely necessary, because the default values are + // usually used as-is. + return Theme( + data: Theme.of( + context, + ).copyWith(materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + alwaysUse24HourFormat: use24HourTime, + ), + child: child!, + ), + ), + ); + }, + ); + setState(() { + selectedTime = time; + }); + }, + ), + ), + if (selectedTime != null) + Text('Selected time: ${selectedTime!.format(context)}'), + ], + ), + ), + ], + ), + ); + } +} + +// This is a simple card that presents a set of radio buttons (inside of a +// RadioSelection, defined below) for the user to select from. +class ChoiceCard<T extends Object?> extends StatelessWidget { + const ChoiceCard({ + super.key, + required this.value, + required this.choices, + required this.onChanged, + required this.choiceLabels, + required this.title, + }); + + final T value; + final Iterable<T> choices; + final Map<T, String> choiceLabels; + final String title; + final ValueChanged<T?> onChanged; + + @override + Widget build(BuildContext context) { + return Card( + // If the card gets too small, let it scroll both directions. + child: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const .all(8.0), + child: RadioGroup<T>( + groupValue: value, + onChanged: onChanged, + child: Column( + crossAxisAlignment: .start, + children: <Widget>[ + Padding(padding: const .all(8.0), child: Text(title)), + for (final T choice in choices) + RadioSelection<T>( + value: choice, + child: Text(choiceLabels[choice]!), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// This aggregates a ChoiceCard so that it presents a set of radio buttons for +// the allowed enum values for the user to select from. +class EnumCard<T extends Enum> extends StatelessWidget { + const EnumCard({ + super.key, + required this.value, + required this.choices, + required this.onChanged, + }); + + final T value; + final Iterable<T> choices; + final ValueChanged<T?> onChanged; + + @override + Widget build(BuildContext context) { + return ChoiceCard<T>( + value: value, + choices: choices, + onChanged: onChanged, + choiceLabels: <T, String>{ + for (final T choice in choices) choice: choice.name, + }, + title: value.runtimeType.toString(), + ); + } +} + +// A button that has a radio button on one side and a label child. Tapping on +// the label or the radio button selects the item. +class RadioSelection<T extends Object?> extends StatelessWidget { + const RadioSelection({super.key, required this.value, required this.child}); + + final T value; + final Widget child; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: .min, + children: <Widget>[ + Padding( + padding: const .directional(end: 8), + child: Radio<T>(value: value), + ), + GestureDetector( + onTap: () => RadioGroup.maybeOf<T>(context)?.onChanged(value), + child: child, + ), + ], + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/toggle_buttons/toggle_buttons.0.dart b/packages/material_ui/material_ui_examples/lib/toggle_buttons/toggle_buttons.0.dart new file mode 100644 index 000000000000..c1dbba2108df --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/toggle_buttons/toggle_buttons.0.dart @@ -0,0 +1,154 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [ToggleButtons]. + +const List<Widget> fruits = <Widget>[ + Text('Apple'), + Text('Banana'), + Text('Orange'), +]; + +const List<Widget> vegetables = <Widget>[ + Text('Tomatoes'), + Text('Potatoes'), + Text('Carrots'), +]; + +const List<Widget> icons = <Widget>[ + Icon(Icons.sunny), + Icon(Icons.cloud), + Icon(Icons.ac_unit), +]; + +void main() => runApp(const ToggleButtonsExampleApp()); + +class ToggleButtonsExampleApp extends StatelessWidget { + const ToggleButtonsExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: ToggleButtonsSample(title: 'ToggleButtons Sample'), + ); + } +} + +class ToggleButtonsSample extends StatefulWidget { + const ToggleButtonsSample({super.key, required this.title}); + + final String title; + + @override + State<ToggleButtonsSample> createState() => _ToggleButtonsSampleState(); +} + +class _ToggleButtonsSampleState extends State<ToggleButtonsSample> { + final List<bool> _selectedFruits = <bool>[true, false, false]; + final List<bool> _selectedVegetables = <bool>[false, true, false]; + final List<bool> _selectedWeather = <bool>[false, false, true]; + bool vertical = false; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: <Widget>[ + // ToggleButtons with a single selection. + Text('Single-select', style: theme.textTheme.titleSmall), + const SizedBox(height: 5), + ToggleButtons( + direction: vertical ? Axis.vertical : Axis.horizontal, + onPressed: (int index) { + setState(() { + // The button that is tapped is set to true, and the others to false. + for (int i = 0; i < _selectedFruits.length; i++) { + _selectedFruits[i] = i == index; + } + }); + }, + borderRadius: const .all(Radius.circular(8)), + selectedBorderColor: Colors.red[700], + selectedColor: Colors.white, + fillColor: Colors.red[200], + color: Colors.red[400], + constraints: const BoxConstraints( + minHeight: 40.0, + minWidth: 80.0, + ), + isSelected: _selectedFruits, + children: fruits, + ), + const SizedBox(height: 20), + // ToggleButtons with a multiple selection. + Text('Multi-select', style: theme.textTheme.titleSmall), + const SizedBox(height: 5), + ToggleButtons( + direction: vertical ? Axis.vertical : Axis.horizontal, + onPressed: (int index) { + // All buttons are selectable. + setState(() { + _selectedVegetables[index] = !_selectedVegetables[index]; + }); + }, + borderRadius: const .all(Radius.circular(8)), + selectedBorderColor: Colors.green[700], + selectedColor: Colors.white, + fillColor: Colors.green[200], + color: Colors.green[400], + constraints: const BoxConstraints( + minHeight: 40.0, + minWidth: 80.0, + ), + isSelected: _selectedVegetables, + children: vegetables, + ), + const SizedBox(height: 20), + // ToggleButtons with icons only. + Text('Icon-only', style: theme.textTheme.titleSmall), + const SizedBox(height: 5), + ToggleButtons( + direction: vertical ? Axis.vertical : Axis.horizontal, + onPressed: (int index) { + setState(() { + // The button that is tapped is set to true, and the others to false. + for (int i = 0; i < _selectedWeather.length; i++) { + _selectedWeather[i] = i == index; + } + }); + }, + borderRadius: const .all(Radius.circular(8)), + selectedBorderColor: Colors.blue[700], + selectedColor: Colors.white, + fillColor: Colors.blue[200], + color: Colors.blue[400], + isSelected: _selectedWeather, + children: icons, + ), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + setState(() { + // When the button is pressed, ToggleButtons direction is changed. + vertical = !vertical; + }); + }, + icon: const Icon(Icons.screen_rotation_outlined), + label: Text(vertical ? 'Horizontal' : 'Vertical'), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/toggle_buttons/toggle_buttons.1.dart b/packages/material_ui/material_ui_examples/lib/toggle_buttons/toggle_buttons.1.dart new file mode 100644 index 000000000000..6ce26e66c925 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/toggle_buttons/toggle_buttons.1.dart @@ -0,0 +1,104 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for migrating from [ToggleButtons] to [SegmentedButton]. + +void main() { + runApp(const ToggleButtonsApp()); +} + +class ToggleButtonsApp extends StatelessWidget { + const ToggleButtonsApp({super.key}); + @override + Widget build(BuildContext context) { + return const MaterialApp(home: Scaffold(body: ToggleButtonsExample())); + } +} + +enum ShirtSize { extraSmall, small, medium, large, extraLarge } + +const List<(ShirtSize, String)> shirtSizeOptions = <(ShirtSize, String)>[ + (ShirtSize.extraSmall, 'XS'), + (ShirtSize.small, 'S'), + (ShirtSize.medium, 'M'), + (ShirtSize.large, 'L'), + (ShirtSize.extraLarge, 'XL'), +]; + +class ToggleButtonsExample extends StatefulWidget { + const ToggleButtonsExample({super.key}); + + @override + State<ToggleButtonsExample> createState() => _ToggleButtonsExampleState(); +} + +class _ToggleButtonsExampleState extends State<ToggleButtonsExample> { + final List<bool> _toggleButtonsSelection = ShirtSize.values + .map((ShirtSize e) => e == ShirtSize.medium) + .toList(); + Set<ShirtSize> _segmentedButtonSelection = <ShirtSize>{ShirtSize.medium}; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: .center, + children: <Widget>[ + const Text('ToggleButtons'), + const SizedBox(height: 10), + // This ToggleButtons allows multiple or no selection. + ToggleButtons( + // ToggleButtons uses a List<bool> to track its selection state. + isSelected: _toggleButtonsSelection, + // This callback return the index of the child that was pressed. + onPressed: (int index) { + setState(() { + _toggleButtonsSelection[index] = + !_toggleButtonsSelection[index]; + }); + }, + // Constraints are used to determine the size of each child widget. + constraints: const BoxConstraints(minHeight: 32.0, minWidth: 56.0), + // ToggleButtons uses a List<Widget> to build its children. + children: shirtSizeOptions + .map(((ShirtSize, String) shirt) => Text(shirt.$2)) + .toList(), + ), + const SizedBox(height: 20), + const Text('SegmentedButton'), + const SizedBox(height: 10), + SegmentedButton<ShirtSize>( + // ToggleButtons above allows multiple or no selection. + // Set `multiSelectionEnabled` and `emptySelectionAllowed` to true + // to match the behavior of ToggleButtons. + multiSelectionEnabled: true, + emptySelectionAllowed: true, + // Hide the selected icon to match the behavior of ToggleButtons. + showSelectedIcon: false, + // SegmentedButton uses a Set<T> to track its selection state. + selected: _segmentedButtonSelection, + // This callback updates the set of selected segment values. + onSelectionChanged: (Set<ShirtSize> newSelection) { + setState(() { + _segmentedButtonSelection = newSelection; + }); + }, + // SegmentedButton uses a List<ButtonSegment<T>> to build its children + // instead of a List<Widget> like ToggleButtons. + segments: shirtSizeOptions.map<ButtonSegment<ShirtSize>>(( + (ShirtSize, String) shirt, + ) { + return ButtonSegment<ShirtSize>( + value: shirt.$1, + label: Text(shirt.$2), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.0.dart b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.0.dart new file mode 100644 index 000000000000..6d7f71e7d065 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.0.dart @@ -0,0 +1,38 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Tooltip]. + +void main() => runApp(const TooltipExampleApp()); + +class TooltipExampleApp extends StatelessWidget { + const TooltipExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData(preferBelow: false), + ), + home: Scaffold( + appBar: AppBar(title: const Text('Tooltip Sample')), + body: const Center(child: TooltipSample()), + ), + ); + } +} + +class TooltipSample extends StatelessWidget { + const TooltipSample({super.key}); + + @override + Widget build(BuildContext context) { + return const Tooltip( + message: 'I am a Tooltip', + child: Text('Hover over the text to show a tooltip.'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.1.dart b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.1.dart new file mode 100644 index 000000000000..2398748635d8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.1.dart @@ -0,0 +1,50 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Tooltip]. + +void main() => runApp(const TooltipExampleApp()); + +class TooltipExampleApp extends StatelessWidget { + const TooltipExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData(preferBelow: false), + ), + home: Scaffold( + appBar: AppBar(title: const Text('Tooltip Sample')), + body: const Center(child: TooltipSample()), + ), + ); + } +} + +class TooltipSample extends StatelessWidget { + const TooltipSample({super.key}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'I am a Tooltip', + decoration: BoxDecoration( + borderRadius: .circular(25), + gradient: const LinearGradient( + colors: <Color>[Colors.amber, Colors.red], + ), + ), + constraints: const BoxConstraints(minWidth: 250), + padding: const .all(8.0), + preferBelow: true, + textStyle: const TextStyle(fontSize: 24), + showDuration: const Duration(seconds: 2), + waitDuration: const Duration(seconds: 1), + child: const Text('Tap this text and hold down to show a tooltip.'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.2.dart b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.2.dart new file mode 100644 index 000000000000..3028f2d418d0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.2.dart @@ -0,0 +1,47 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Tooltip]. + +void main() => runApp(const TooltipExampleApp()); + +class TooltipExampleApp extends StatelessWidget { + const TooltipExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData(preferBelow: false), + ), + home: Scaffold( + appBar: AppBar(title: const Text('Tooltip Sample')), + body: const Center(child: TooltipSample()), + ), + ); + } +} + +class TooltipSample extends StatelessWidget { + const TooltipSample({super.key}); + + @override + Widget build(BuildContext context) { + return const Tooltip( + richMessage: TextSpan( + text: 'I am a rich tooltip. ', + style: TextStyle(color: Colors.red), + children: <InlineSpan>[ + TextSpan( + text: 'I am another span of this rich tooltip', + style: TextStyle(fontWeight: .bold), + ), + ], + ), + child: Text('Tap this text and hold down to show a tooltip.'), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.3.dart b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.3.dart new file mode 100644 index 000000000000..6880d8df48d1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/tooltip/tooltip.3.dart @@ -0,0 +1,56 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [Tooltip]. + +void main() => runApp(const TooltipExampleApp()); + +class TooltipExampleApp extends StatelessWidget { + const TooltipExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData(preferBelow: false), + ), + home: const TooltipSample(title: 'Tooltip Sample'), + ); + } +} + +class TooltipSample extends StatelessWidget { + const TooltipSample({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final GlobalKey<TooltipState> tooltipkey = GlobalKey<TooltipState>(); + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Center( + child: Tooltip( + // Provide a global key with the "TooltipState" type to show + // the tooltip manually when trigger mode is set to manual. + key: tooltipkey, + triggerMode: .manual, + showDuration: const Duration(seconds: 1), + message: 'I am a Tooltip', + child: const Text('Tap on the FAB'), + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + // Show Tooltip programmatically on button tap. + tooltipkey.currentState?.ensureTooltipVisible(); + }, + label: const Text('Show Tooltip'), + ), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/lib/widget_state_input_border/widget_state_input_border.0.dart b/packages/material_ui/material_ui_examples/lib/widget_state_input_border/widget_state_input_border.0.dart new file mode 100644 index 000000000000..3ed6dde7fb81 --- /dev/null +++ b/packages/material_ui/material_ui_examples/lib/widget_state_input_border/widget_state_input_border.0.dart @@ -0,0 +1,112 @@ +// 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:material_ui/material_ui.dart'; + +/// Flutter code sample for [WidgetStateInputBorder]. + +void main() => runApp(const WidgetStateInputBorderExampleApp()); + +/// This extension isn't necessary when WidgetState properties are +/// configured using [WidgetStateMapper] objects. +/// +/// But sometimes it makes sense to use a resolveWith() callback, +/// and these getters make those callbacks a bit more readable! +extension WidgetStateHelpers on Set<WidgetState> { + bool get focused => contains(WidgetState.focused); + bool get hovered => contains(WidgetState.hovered); + bool get disabled => contains(WidgetState.disabled); +} + +class WidgetStateInputBorderExampleApp extends StatelessWidget { + const WidgetStateInputBorderExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('WidgetStateInputBorder Example')), + body: const Center(child: PageContent()), + ), + ); + } +} + +class PageContent extends StatefulWidget { + const PageContent({super.key}); + + @override + State<PageContent> createState() => _PageContentState(); +} + +class _PageContentState extends State<PageContent> { + bool enabled = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: .min, + children: <Widget>[ + const Spacer(flex: 8), + Focus(child: WidgetStateInputBorderExample(enabled: enabled)), + const Spacer(), + FilterChip( + label: const Text('enable text field'), + selected: enabled, + onSelected: (bool selected) { + setState(() { + enabled = selected; + }); + }, + ), + const Spacer(flex: 8), + ], + ); + } +} + +class WidgetStateInputBorderExample extends StatelessWidget { + const WidgetStateInputBorderExample({super.key, required this.enabled}); + + final bool enabled; + + /// A global or static function can be referenced in a `const` constructor, + /// such as [WidgetStateInputBorder.resolveWith]. + /// + /// Constant values can be useful for promoting accurate equality checks, + /// such as when rebuilding a [Theme] widget. + static UnderlineInputBorder veryCoolBorder(Set<WidgetState> states) { + if (states.disabled) { + return const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ); + } + + const Color dullViolet = Color(0xFF502080); + + return UnderlineInputBorder( + borderSide: BorderSide( + width: states.hovered ? 6 : (states.focused ? 3 : 1.5), + color: states.focused ? Colors.deepPurpleAccent : dullViolet, + ), + ); + } + + @override + Widget build(BuildContext context) { + final InputDecoration decoration = InputDecoration( + border: const WidgetStateInputBorder.resolveWith(veryCoolBorder), + labelText: enabled + ? 'Type something awesome…' + : '(click below to enable)', + ); + + return AnimatedFractionallySizedBox( + duration: Durations.medium1, + curve: Curves.ease, + widthFactor: Focus.of(context).hasFocus ? 0.9 : 0.6, + child: TextField(decoration: decoration, enabled: enabled), + ); + } +} diff --git a/packages/material_ui/material_ui_examples/pubspec.yaml b/packages/material_ui/material_ui_examples/pubspec.yaml new file mode 100644 index 000000000000..a3d731ede89a --- /dev/null +++ b/packages/material_ui/material_ui_examples/pubspec.yaml @@ -0,0 +1,40 @@ +name: material_ui_examples +description: API code samples for the material_ui package. +publish_to: 'none' + +version: 1.0.0 + +environment: + sdk: ^3.10.0-0 +resolution: workspace + +dependencies: + flutter: + sdk: flutter + + collection: any + vector_math: any + web: any + test: any + material_ui: + path: .. + cupertino_ui: + # TODO(justinmc): Use the pub.dev package when published. + path: ../../cupertino_ui + +dev_dependencies: + integration_test: + sdk: flutter + flutter_driver: + sdk: flutter + flutter_goldens: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_test: + sdk: flutter + flutter_web_plugins: + sdk: flutter + flutter_lints: ^6.0.0 +flutter: + uses-material-design: true diff --git a/packages/material_ui/material_ui_examples/test/about/about_list_tile.0_test.dart b/packages/material_ui/material_ui_examples/test/about/about_list_tile.0_test.dart new file mode 100644 index 000000000000..08f6dea03e47 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/about/about_list_tile.0_test.dart @@ -0,0 +1,75 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/about/about_list_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('It should show the about dialog after clicking on the button', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AboutListTileExampleApp()); + + expect(find.widgetWithText(AppBar, 'Show About Example'), findsOne); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Show About Example')); + await tester.pumpAndSettle(); + + expect(find.byType(AboutDialog), findsOne); + expect(find.widgetWithText(AboutDialog, 'Show About Example'), findsOne); + expect(find.text('August 2019'), findsOne); + expect(find.byType(FlutterLogo), findsOne); + expect(find.text('\u{a9} 2014 The Flutter Authors'), findsOne); + expect( + find.text( + "Flutter is Google's UI toolkit for building beautiful, " + 'natively compiled applications for mobile, web, and desktop ' + 'from a single codebase. Learn more about Flutter at ' + 'https://flutter.dev.', + findRichText: true, + ), + findsOne, + ); + }); + + testWidgets( + 'It should show the about dialog after clicking on about list tile in the drawer', + (WidgetTester tester) async { + await tester.pumpWidget(const example.AboutListTileExampleApp()); + + expect(find.widgetWithText(AppBar, 'Show About Example'), findsOne); + + await tester.tap(find.byType(DrawerButton)); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOne); + expect( + find.widgetWithText(AboutListTile, 'About Show About Example'), + findsOne, + ); + expect(find.widgetWithIcon(AboutListTile, Icons.info), findsOne); + + await tester.tap(find.widgetWithIcon(AboutListTile, Icons.info)); + await tester.pumpAndSettle(); + + expect(find.byType(AboutDialog), findsOne); + expect(find.widgetWithText(AboutDialog, 'Show About Example'), findsOne); + expect(find.text('August 2019'), findsOne); + expect(find.byType(FlutterLogo), findsOne); + expect(find.text('\u{a9} 2014 The Flutter Authors'), findsOne); + expect( + find.text( + "Flutter is Google's UI toolkit for building beautiful, " + 'natively compiled applications for mobile, web, and desktop ' + 'from a single codebase. Learn more about Flutter at ' + 'https://flutter.dev.', + findRichText: true, + ), + findsOne, + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/action_buttons/action_icon_theme.0_test.dart b/packages/material_ui/material_ui_examples/test/action_buttons/action_icon_theme.0_test.dart new file mode 100644 index 000000000000..085e05e96556 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/action_buttons/action_icon_theme.0_test.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/action_buttons/action_icon_theme.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Action Icon Buttons', (WidgetTester tester) async { + await tester.pumpWidget(const example.ActionIconThemeExampleApp()); + + expect(find.byType(DrawerButton), findsOneWidget); + final Icon drawerButtonIcon = tester.widget( + find.descendant( + of: find.byType(DrawerButton), + matching: find.byType(Icon), + ), + ); + expect(drawerButtonIcon.icon, Icons.segment); + + // open next page + await tester.tap(find.byType(example.NextPageButton)); + await tester.pumpAndSettle(); + + expect(find.byType(EndDrawerButton), findsOneWidget); + final Icon endDrawerButtonIcon = tester.widget( + find.descendant( + of: find.byType(EndDrawerButton), + matching: find.byType(Icon), + ), + ); + expect(endDrawerButtonIcon.icon, Icons.more_horiz); + + expect(find.byType(BackButton), findsOneWidget); + final Icon backButtonIcon = tester.widget( + find.descendant(of: find.byType(BackButton), matching: find.byType(Icon)), + ); + expect(backButtonIcon.icon, Icons.arrow_back_ios_new_rounded); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/action_chip/action_chip.0_test.dart b/packages/material_ui/material_ui_examples/test/action_chip/action_chip.0_test.dart new file mode 100644 index 000000000000..b421463c98c7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/action_chip/action_chip.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/action_chip/action_chip.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ActionChip updates avatar when tapped', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ChipApp()); + + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + + await tester.tap(find.byType(ActionChip)); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.favorite), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/animated_icon/animated_icon.0_test.dart b/packages/material_ui/material_ui_examples/test/animated_icon/animated_icon.0_test.dart new file mode 100644 index 000000000000..f25b26daf9bd --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/animated_icon/animated_icon.0_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/animated_icon/animated_icon.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AnimatedIcon animates', (WidgetTester tester) async { + await tester.pumpWidget(const example.AnimatedIconApp()); + + // Test the AnimatedIcon size. + final Size iconSize = tester.getSize(find.byType(AnimatedIcon)); + expect(iconSize.width, 72.0); + expect(iconSize.height, 72.0); + + // Check if AnimatedIcon is animating. + await tester.pump(const Duration(milliseconds: 500)); + AnimatedIcon animatedIcon = tester.widget(find.byType(AnimatedIcon)); + expect(animatedIcon.progress.value, 0.25); + + // Check if animation is completed. + await tester.pump(const Duration(milliseconds: 1500)); + animatedIcon = tester.widget(find.byType(AnimatedIcon)); + expect(animatedIcon.progress.value, 1.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/animated_icon/animated_icons_data.0_test.dart b/packages/material_ui/material_ui_examples/test/animated_icon/animated_icons_data.0_test.dart new file mode 100644 index 000000000000..b3f502d731c8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/animated_icon/animated_icons_data.0_test.dart @@ -0,0 +1,35 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/animated_icon/animated_icons_data.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show all the animated icons', (WidgetTester tester) async { + await tester.pumpWidget(const example.AnimatedIconApp()); + + // Check if the total number of AnimatedIcons matches the icons list. + expect( + find.byType(AnimatedIcon, skipOffstage: false), + findsNWidgets(example.iconsList.length), + ); + + // Test the AnimatedIcon size. + final Size iconSize = tester.getSize(find.byType(AnimatedIcon).first); + expect(iconSize.width, 72.0); + expect(iconSize.height, 72.0); + + // Check if AnimatedIcon is animating. + await tester.pump(const Duration(milliseconds: 500)); + AnimatedIcon animatedIcon = tester.widget(find.byType(AnimatedIcon).first); + expect(animatedIcon.progress.value, 0.25); + + // Check if animation is completed. + await tester.pump(const Duration(milliseconds: 1500)); + animatedIcon = tester.widget(find.byType(AnimatedIcon).first); + expect(animatedIcon.progress.value, 1.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app/app.0_test.dart b/packages/material_ui/material_ui_examples/test/app/app.0_test.dart new file mode 100644 index 000000000000..56c984d2edc4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app/app.0_test.dart @@ -0,0 +1,81 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app/app.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Theme animation can be customized using AnimationStyle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialAppExample()); + + Material getScaffoldMaterial() { + return tester.widget<Material>( + find.descendant( + of: find.byType(Scaffold), + matching: find.byType(Material).first, + ), + ); + } + + final ThemeData lightTheme = ThemeData(colorSchemeSeed: Colors.green); + final ThemeData darkTheme = ThemeData( + colorSchemeSeed: Colors.green, + brightness: .dark, + ); + + // Test the default animation. + expect(getScaffoldMaterial().color, lightTheme.colorScheme.surface); + + await tester.tap(find.text('Switch Theme Mode')); + await tester.pump(); + // Advance the animation by half of the default duration. + await tester.pump(const Duration(milliseconds: 100)); + + // The Scaffold background color is updated. + expect( + getScaffoldMaterial().color, + Color.lerp( + lightTheme.colorScheme.surface, + darkTheme.colorScheme.surface, + 0.5, + ), + ); + + await tester.pumpAndSettle(); + + // The Scaffold background color is now fully dark. + expect(getScaffoldMaterial().color, darkTheme.colorScheme.surface); + + // Test the custom animation curve and duration. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Switch Theme Mode')); + await tester.pump(); + // Advance the animation by half of the custom duration. + await tester.pump(const Duration(milliseconds: 500)); + + // The Scaffold background color is updated. + expect(getScaffoldMaterial().color, isSameColorAs(const Color(0xff333731))); + + await tester.pumpAndSettle(); + + // The Scaffold background color is now fully light. + expect(getScaffoldMaterial().color, lightTheme.colorScheme.surface); + + // Test the no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Switch Theme Mode')); + // Advance the animation by only one frame. + await tester.pump(); + + // The Scaffold background color is updated immediately. + expect(getScaffoldMaterial().color, darkTheme.colorScheme.surface); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/app_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.0_test.dart new file mode 100644 index 000000000000..2c35d0711e86 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.0_test.dart @@ -0,0 +1,22 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/app_bar.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Appbar updates on navigation', (WidgetTester tester) async { + await tester.pumpWidget(const example.AppBarApp()); + + expect(find.widgetWithText(AppBar, 'AppBar Demo'), findsOneWidget); + expect(find.text('This is the home page'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.navigate_next)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Next page'), findsOneWidget); + expect(find.text('This is the next page'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/app_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.1_test.dart new file mode 100644 index 000000000000..103aa0b981a3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.1_test.dart @@ -0,0 +1,55 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/app_bar.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset _kOffset = Offset(0.0, -100.0); + +void main() { + testWidgets('Appbar Material 3 test', (WidgetTester tester) async { + await tester.pumpWidget(const example.AppBarApp()); + + expect(find.widgetWithText(AppBar, 'AppBar Demo'), findsOneWidget); + Material appbarMaterial = _getAppBarMaterial(tester); + expect(appbarMaterial.shadowColor, Colors.transparent); + expect(appbarMaterial.elevation, 0); + + await tester.drag( + find.text('Item 4'), + _kOffset, + touchSlopY: 0, + warnIfMissed: false, + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.text('shadow color')); + await tester.pumpAndSettle(); + appbarMaterial = _getAppBarMaterial(tester); + expect(appbarMaterial.shadowColor, Colors.black); + expect(appbarMaterial.elevation, 3.0); + + await tester.tap(find.text('scrolledUnderElevation: default')); + await tester.pumpAndSettle(); + + appbarMaterial = _getAppBarMaterial(tester); + expect(appbarMaterial.shadowColor, Colors.black); + expect(appbarMaterial.elevation, 4.0); + + await tester.tap(find.text('scrolledUnderElevation: 4.0')); + await tester.pumpAndSettle(); + appbarMaterial = _getAppBarMaterial(tester); + expect(appbarMaterial.shadowColor, Colors.black); + expect(appbarMaterial.elevation, 5.0); + }); +} + +Material _getAppBarMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/app_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.2_test.dart new file mode 100644 index 000000000000..91f5e442789c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.2_test.dart @@ -0,0 +1,17 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/app_bar.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Appbar and actions', (WidgetTester tester) async { + await tester.pumpWidget(const example.AppBarApp()); + + expect(find.byType(AppBar), findsOneWidget); + expect(find.widgetWithText(TextButton, 'Action 1'), findsOneWidget); + expect(find.widgetWithText(TextButton, 'Action 2'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/app_bar.3_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.3_test.dart new file mode 100644 index 000000000000..79aa7e31d893 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.3_test.dart @@ -0,0 +1,38 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/app_bar.3.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'AppBar elevates when nested scroll view is scrolled underneath the AppBar', + (WidgetTester tester) async { + Material getMaterial() => tester.widget<Material>( + find + .descendant( + of: find.byType(AppBar), + matching: find.byType(Material), + ) + .first, + ); + + await tester.pumpWidget(const example.AppBarApp()); + + // Starts with the base elevation. + expect(getMaterial().elevation, 0.0); + + await tester.fling( + find.text('Beach 3'), + const Offset(0.0, -600.0), + 2000.0, + ); + await tester.pumpAndSettle(); + + // After scrolling it should be the scrolledUnderElevation. + expect(getMaterial().elevation, 4.0); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/app_bar.4_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.4_test.dart new file mode 100644 index 000000000000..fc49fc95ac2e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/app_bar.4_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/app_bar.4.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('AppBar uses custom shape', (WidgetTester tester) async { + await tester.pumpWidget(const example.AppBarExampleApp()); + + Material getMaterial() => tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + expect(getMaterial().shape, const example.CustomAppBarShape()); + }); + + testWidgets('AppBar bottom contains TextField', (WidgetTester tester) async { + await tester.pumpWidget(const example.AppBarExampleApp()); + + final Finder textFieldFinder = find.descendant( + of: find.byType(AppBar), + matching: find.byType(TextField), + ); + + expect(textFieldFinder, findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.1_test.dart new file mode 100644 index 000000000000..6e877e32b29b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.1_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/sliver_app_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset _kOffset = Offset(0.0, -200.0); + +void main() { + testWidgets('SliverAppbar can be pinned', (WidgetTester tester) async { + await tester.pumpWidget(const example.AppBarApp()); + + expect(find.widgetWithText(SliverAppBar, 'SliverAppBar'), findsOneWidget); + expect(tester.getBottomLeft(find.text('SliverAppBar')).dy, 144.0); + + await tester.drag( + find.text('0'), + _kOffset, + touchSlopY: 0, + warnIfMissed: false, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.getBottomLeft(find.text('SliverAppBar')).dy, 40.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.2_test.dart new file mode 100644 index 000000000000..598e2cfb2895 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.2_test.dart @@ -0,0 +1,55 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/sliver_app_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Visibility and interaction of crucial widgets', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AppBarMediumApp()); + + const String title = 'Medium App Bar'; + + expect( + find.descendant( + of: find.byType(CustomScrollView), + matching: find.widgetWithText(SliverAppBar, title), + ), + findsOne, + ); + + expect( + find.descendant( + of: find.byType(SliverAppBar), + matching: find.byType(IconButton), + ), + findsExactly(2), + ); + + // Based on https://m3.material.io/components/top-app-bar/specs the title of + // the SliverAppBar.medium widget is formatted with the headlineSmall style. + final BuildContext context = tester.element(find.byType(MaterialApp)); + final TextStyle expectedTitleStyle = Theme.of( + context, + ).textTheme.headlineSmall!; + + // There are two Text widgets: expanded and collapsed. The expanded is first. + final Finder titleFinder = find.text(title).first; + final TextStyle actualTitleStyle = DefaultTextStyle.of( + tester.element(titleFinder), + ).style; + + expect(actualTitleStyle, expectedTitleStyle); + + // Scrolling the screen moves the title up. + expect(tester.getBottomLeft(titleFinder).dy, 96.0); + await tester.drag(titleFinder, const Offset(0.0, -200.0)); + await tester.pump(); + expect(tester.getBottomLeft(titleFinder).dy, 48.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.3_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.3_test.dart new file mode 100644 index 000000000000..05d3b60a6066 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.3_test.dart @@ -0,0 +1,55 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/sliver_app_bar.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Visibility and interaction of crucial widgets', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AppBarLargeApp()); + + const String title = 'Large App Bar'; + + expect( + find.descendant( + of: find.byType(CustomScrollView), + matching: find.widgetWithText(SliverAppBar, title), + ), + findsOne, + ); + + expect( + find.descendant( + of: find.byType(SliverAppBar), + matching: find.byType(IconButton), + ), + findsExactly(2), + ); + + // Based on https://m3.material.io/components/top-app-bar/specs the title of + // the SliverAppBar.large widget is formatted with the headlineMedium style. + final BuildContext context = tester.element(find.byType(MaterialApp)); + final TextStyle expectedTitleStyle = Theme.of( + context, + ).textTheme.headlineMedium!; + + // There are two Text widgets: expanded and collapsed. The expanded is first. + final Finder titleFinder = find.text(title).first; + final TextStyle actualTitleStyle = DefaultTextStyle.of( + tester.element(titleFinder), + ).style; + + expect(actualTitleStyle, expectedTitleStyle); + + // Scrolling the screen moves the title up. + expect(tester.getBottomLeft(titleFinder).dy, 124.0); + await tester.drag(titleFinder, const Offset(0.0, -200.0)); + await tester.pump(); + expect(tester.getBottomLeft(titleFinder).dy, 36.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.4_test.dart b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.4_test.dart new file mode 100644 index 000000000000..c49664d3e349 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/app_bar/sliver_app_bar.4_test.dart @@ -0,0 +1,54 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/app_bar/sliver_app_bar.4.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset _kOffset = Offset(0.0, 200.0); + +void main() { + testWidgets('SliverAppbar can be stretched', (WidgetTester tester) async { + await tester.pumpWidget(const example.StretchableSliverAppBar()); + + final Finder switchFinder = find.byType(Switch); + Switch materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, true); + + expect(find.widgetWithText(SliverAppBar, 'SliverAppBar'), findsOneWidget); + expect(tester.getBottomLeft(find.text('SliverAppBar')).dy, 184.0); + + await tester.drag( + find.text('0'), + _kOffset, + touchSlopY: 0, + warnIfMissed: false, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.widgetWithText(SliverAppBar, 'SliverAppBar'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('SliverAppBar')).dy, + 187.63506380825314, + ); + + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, false); + + await tester.drag( + find.text('0'), + _kOffset, + touchSlopY: 0, + warnIfMissed: false, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('SliverAppBar'), findsOneWidget); + expect(tester.getBottomLeft(find.text('SliverAppBar')).dy, 184.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.0_test.dart b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.0_test.dart new file mode 100644 index 000000000000..0b1015e0750a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.0_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/autocomplete/autocomplete.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('can search and find options', (WidgetTester tester) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'a'); + await tester.pump(); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsOneWidget); + expect(find.text('chameleon'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'aa'); + await tester.pump(); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.1_test.dart b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.1_test.dart new file mode 100644 index 000000000000..a3c54546134a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.1_test.dart @@ -0,0 +1,34 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/autocomplete/autocomplete.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('can search and find options by email and name', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + expect(find.text('Alice'), findsNothing); + expect(find.text('Bob'), findsNothing); + expect(find.text('Charlie'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'Ali'); + await tester.pump(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Bob'), findsNothing); + expect(find.text('Charlie'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'gmail'); + await tester.pump(); + + expect(find.text('Alice'), findsNothing); + expect(find.text('Bob'), findsNothing); + expect(find.text('Charlie'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.2_test.dart b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.2_test.dart new file mode 100644 index 000000000000..50369dcbbcbf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.2_test.dart @@ -0,0 +1,35 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/autocomplete/autocomplete.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'can search and find options after waiting for fake network delay', + (WidgetTester tester) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'a'); + await tester.pump(example.fakeAPIDuration); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsOneWidget); + expect(find.text('chameleon'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'aa'); + await tester.pump(example.fakeAPIDuration); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.3_test.dart b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.3_test.dart new file mode 100644 index 000000000000..a08f9834757a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.3_test.dart @@ -0,0 +1,127 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/autocomplete/autocomplete.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'can search and find options after waiting for fake network delay and debounce delay', + (WidgetTester tester) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'a'); + await tester.pump(example.fakeAPIDuration); + + // No results yet, need to also wait for the debounce duration. + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.pump(example.debounceDuration); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsOneWidget); + expect(find.text('chameleon'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'aa'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + }, + ); + + testWidgets('debounce is reset each time a character is entered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + await tester.enterText(find.byType(TextFormField), 'c'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'ch'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'cha'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'cham'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + // Despite the total elapsed time being greater than debounceDuration + + // fakeAPIDuration, the search has not yet completed, because the debounce + // was reset each time text input happened. + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'chame'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsOneWidget); + }); + + testWidgets('multiple pending requests', (WidgetTester tester) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + await tester.enterText(find.byType(TextFormField), 'a'); + + // Wait until the debounce duration has expired, but the request is still + // pending. + await tester.pump(example.debounceDuration); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'aa'); + + // Wait until the first request has completed. + await tester.pump(example.fakeAPIDuration - example.debounceDuration); + + // The results from the first request are thrown away since the query has + // changed. + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + // Wait until the second request has completed. + await tester.pump(example.fakeAPIDuration); + + // The results of the second request are reflected. + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.4_test.dart b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.4_test.dart new file mode 100644 index 000000000000..1bcdefa1f19a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/autocomplete/autocomplete.4_test.dart @@ -0,0 +1,135 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/autocomplete/autocomplete.4.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'can search and find options after waiting for fake network delay and debounce delay', + (WidgetTester tester) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'a'); + await tester.pump(example.fakeAPIDuration); + + // No results yet, need to also wait for the debounce duration. + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.pump(example.debounceDuration); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsOneWidget); + expect(find.text('chameleon'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'aa'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsOneWidget); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + }, + ); + + testWidgets('debounce is reset each time a character is entered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + await tester.enterText(find.byType(TextFormField), 'c'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'ch'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'cha'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'cham'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + // Despite the total elapsed time being greater than debounceDuration + + // fakeAPIDuration, the search has not yet completed, because the debounce + // was reset each time text input happened. + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'chame'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsOneWidget); + }); + + testWidgets('shows an error message for network errors', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.AutocompleteExampleApp()); + + await tester.enterText(find.byType(TextFormField), 'chame'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsOneWidget); + InputDecorator inputDecorator = tester.widget(find.byType(InputDecorator)); + expect(inputDecorator.decoration.errorText, isNull); + + // Turn the network off. + await tester.tap(find.byType(Switch)); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField), 'chamel'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsNothing); + inputDecorator = tester.widget(find.byType(InputDecorator)); + expect(inputDecorator.decoration.errorText, isNotNull); + + // Turn the network back on. + await tester.tap(find.byType(Switch)); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField), 'chamele'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.text('aardvark'), findsNothing); + expect(find.text('bobcat'), findsNothing); + expect(find.text('chameleon'), findsOneWidget); + inputDecorator = tester.widget(find.byType(InputDecorator)); + expect(inputDecorator.decoration.errorText, isNull); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/badge/badge.0_test.dart b/packages/material_ui/material_ui_examples/test/badge/badge.0_test.dart new file mode 100644 index 000000000000..38083ffb3b62 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/badge/badge.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/badge/badge.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Verify Badges have label and count', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BadgeExampleApp()); + // Verify that two Badge(s) are present + expect(find.byType(Badge), findsNWidgets(2)); + + // Verify that Badge.count displays label 999+ when count is greater than 999 + expect(find.text('999+'), findsOneWidget); + + // Verify that Badge displays custom label + expect(find.text('Your label'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/banner/material_banner.0_test.dart b/packages/material_ui/material_ui_examples/test/banner/material_banner.0_test.dart new file mode 100644 index 000000000000..847a3d9c13a9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/banner/material_banner.0_test.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/banner/material_banner.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Shows all elements', (WidgetTester tester) async { + await tester.pumpWidget(const example.MaterialBannerExampleApp()); + + expect(find.byType(MaterialBanner), findsOneWidget); + expect(find.byType(AppBar), findsOneWidget); + expect(find.byType(TextButton), findsNWidgets(2)); + expect(find.text('Hello, I am a Material Banner'), findsOneWidget); + expect(find.text('The MaterialBanner is below'), findsOneWidget); + expect(find.text('OPEN'), findsOneWidget); + expect(find.text('DISMISS'), findsOneWidget); + expect(find.byIcon(Icons.agriculture_outlined), findsOneWidget); + }); + + testWidgets('BottomNavigationBar Updates Screen Content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialBannerExampleApp()); + + expect(find.byType(MaterialBanner), findsOne); + expect(find.text('Hello, I am a Material Banner'), findsOne); + expect(find.byIcon(Icons.agriculture_outlined), findsOne); + expect(find.widgetWithText(TextButton, 'OPEN'), findsOne); + expect(find.widgetWithText(TextButton, 'DISMISS'), findsOne); + }); + + testWidgets('The banner is below the text saying so', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialBannerExampleApp()); + + expect(find.byType(MaterialBanner), findsOneWidget); + expect(find.text('The MaterialBanner is below'), findsOneWidget); + final double bannerY = tester.getCenter(find.byType(MaterialBanner)).dy; + final double textY = tester + .getCenter(find.text('The MaterialBanner is below')) + .dy; + expect(bannerY, greaterThan(textY)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/banner/material_banner.1_test.dart b/packages/material_ui/material_ui_examples/test/banner/material_banner.1_test.dart new file mode 100644 index 000000000000..b3cfb149260a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/banner/material_banner.1_test.dart @@ -0,0 +1,64 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/banner/material_banner.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Shows all elements when needed', (WidgetTester tester) async { + await tester.pumpWidget(const example.MaterialBannerExampleApp()); + await tester.pumpAndSettle(); + expect(find.text('The MaterialBanner is below'), findsOneWidget); + expect(find.text('Show MaterialBanner'), findsOneWidget); + expect(find.byType(MaterialBanner), findsNothing); + expect(find.text('DISMISS'), findsNothing); + expect(find.byIcon(Icons.agriculture_outlined), findsNothing); + + await tester.tap(find.text('Show MaterialBanner')); + await tester.pumpAndSettle(); + expect(find.byType(MaterialBanner), findsOneWidget); + expect(find.text('DISMISS'), findsOneWidget); + expect(find.byIcon(Icons.agriculture_outlined), findsOneWidget); + + final MaterialBanner banner = tester.widget<MaterialBanner>( + find.byType(MaterialBanner), + ); + expect(banner.backgroundColor, Colors.green); + }); + + testWidgets('BottomNavigationBar Updates Screen Content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialBannerExampleApp()); + + expect(find.byType(MaterialBanner), findsNothing); + await tester.tap( + find.widgetWithText(ElevatedButton, 'Show MaterialBanner'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MaterialBanner), findsOne); + expect(find.text('Hello, I am a Material Banner'), findsOne); + expect(find.byIcon(Icons.agriculture_outlined), findsOne); + expect(find.widgetWithText(TextButton, 'DISMISS'), findsOne); + }); + + testWidgets('The banner is below the text saying so', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialBannerExampleApp()); + await tester.tap(find.text('Show MaterialBanner')); + await tester.pumpAndSettle(); + + expect(find.byType(MaterialBanner), findsOneWidget); + expect(find.text('The MaterialBanner is below'), findsOneWidget); + final double bannerY = tester.getCenter(find.byType(MaterialBanner)).dy; + final double textY = tester + .getCenter(find.text('The MaterialBanner is below')) + .dy; + expect(bannerY, greaterThan(textY)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_app_bar/bottom_app_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/bottom_app_bar/bottom_app_bar.1_test.dart new file mode 100644 index 000000000000..42387ac50fd7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_app_bar/bottom_app_bar.1_test.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_app_bar/bottom_app_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'BottomAppBarDemo shows FloatingActionButton and responds to toggle', + (WidgetTester tester) async { + await tester.pumpWidget(const example.BottomAppBarDemo()); + + expect(find.byType(FloatingActionButton), findsOneWidget); + + // Tap the 'Floating Action Button' switch to hide the FAB. + await tester.tap(find.byType(SwitchListTile).first); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsNothing); + }, + ); + + testWidgets('Notch can be toggled on and off', (WidgetTester tester) async { + await tester.pumpWidget(const example.BottomAppBarDemo()); + + // Check the BottomAppBar has a notch initially. + BottomAppBar bottomAppBar = tester.widget(find.byType(BottomAppBar)); + expect(bottomAppBar.shape, isNotNull); + + // Toggle the 'Notch' switch to remove the notch. + await tester.tap(find.byType(SwitchListTile).last); + await tester.pump(); + + bottomAppBar = tester.widget(find.byType(BottomAppBar)); + expect(bottomAppBar.shape, isNull); + }); + + testWidgets('FAB location can be changed', (WidgetTester tester) async { + await tester.pumpWidget(const example.BottomAppBarDemo()); + + final Offset initialPosition = tester.getCenter( + find.byType(FloatingActionButton), + ); + + // Verify the initial position is near the right side (docked to the end). + final Size screenSize = tester.getSize(find.byType(Scaffold)); + expect(initialPosition.dx, greaterThan(screenSize.width * 0.5)); + + // Tap the radio button to move the FAB to centerDocked. + await tester.tap(find.text('Docked - Center')); + await tester.pumpAndSettle(); + + // Get the new FAB position (centerDocked). + final Offset newPosition = tester.getCenter( + find.byType(FloatingActionButton), + ); + + expect( + newPosition.dx, + closeTo(screenSize.width * 0.5, 10), // Center of the screen. + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_app_bar/bottom_app_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/bottom_app_bar/bottom_app_bar.2_test.dart new file mode 100644 index 000000000000..c996b213488d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_app_bar/bottom_app_bar.2_test.dart @@ -0,0 +1,80 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_app_bar/bottom_app_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Floating Action Button visibility can be toggled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomAppBarDemo()); + + expect(find.byType(FloatingActionButton), findsOneWidget); + + // Tap the switch to hide the FAB. + await tester.tap(find.byType(SwitchListTile).first); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsNothing); + }); + + testWidgets('BottomAppBar elevation can be toggled', ( + WidgetTester tester, + ) async { + // Build the app. + await tester.pumpWidget(const example.BottomAppBarDemo()); + + // Verify the BottomAppBar has elevation initially. + BottomAppBar bottomAppBar = tester.widget(find.byType(BottomAppBar)); + expect(bottomAppBar.elevation, isNot(0.0)); + + await tester.tap(find.text('Bottom App Bar Elevation')); + await tester.pumpAndSettle(); + + bottomAppBar = tester.widget(find.byType(BottomAppBar)); + expect(bottomAppBar.elevation, equals(0.0)); + }); + + testWidgets('BottomAppBar hides on scroll down and shows on scroll up', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomAppBarDemo()); + + // Ensure the BottomAppBar is visible initially. + expect(find.byType(BottomAppBar), findsOneWidget); + + // Scroll down to hide the BottomAppBar. + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + + // Verify the BottomAppBar is hidden. + final Size hiddenSize = tester.getSize(find.byType(AnimatedContainer)); + expect(hiddenSize.height, equals(0.0)); // AnimatedContainer's height + + // Scroll up to show the BottomAppBar again. + await tester.drag(find.byType(ListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + + // Verify the BottomAppBar is visible again. + final Size visibleSize = tester.getSize(find.byType(AnimatedContainer)); + expect(visibleSize.height, equals(80.0)); + }); + + testWidgets('SnackBar is shown when Open popup menu is pressed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomAppBarDemo()); + + // Trigger the SnackBar. + await tester.tap(find.byTooltip('Open popup menu')); + await tester.pump(); + + expect(find.text('Yay! A SnackBar!'), findsOneWidget); + + expect(find.text('Undo'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.0_test.dart new file mode 100644 index 000000000000..88c4166ecaa4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.0_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_navigation_bar/bottom_navigation_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('BottomNavigationBar Updates Screen Content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomNavigationBarExampleApp()); + + expect( + find.widgetWithText(AppBar, 'BottomNavigationBar Sample'), + findsOneWidget, + ); + expect(find.byType(BottomNavigationBar), findsOneWidget); + expect(find.widgetWithText(Center, 'Index 0: Home'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.business)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 1: Business'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.school)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 2: School'), findsOneWidget); + + // Verify we can go back + await tester.tap(find.byIcon(Icons.home)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 0: Home'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.1_test.dart new file mode 100644 index 000000000000..2fa575d373a4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.1_test.dart @@ -0,0 +1,40 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_navigation_bar/bottom_navigation_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('BottomNavigationBar Updates Screen Content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomNavigationBarExampleApp()); + + expect( + find.widgetWithText(AppBar, 'BottomNavigationBar Sample'), + findsOneWidget, + ); + expect(find.byType(BottomNavigationBar), findsOneWidget); + expect(find.widgetWithText(Center, 'Index 0: Home'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.business)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 1: Business'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.school)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 2: School'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.settings)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 3: Settings'), findsOneWidget); + + // Verify we can go back + await tester.tap(find.byIcon(Icons.home)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Index 0: Home'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.2_test.dart new file mode 100644 index 000000000000..e0ea7bfc4e3e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_navigation_bar/bottom_navigation_bar.2_test.dart @@ -0,0 +1,41 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_navigation_bar/bottom_navigation_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('BottomNavigationBar Updates Screen Content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomNavigationBarExampleApp()); + + expect( + find.widgetWithText(AppBar, 'BottomNavigationBar Sample'), + findsOneWidget, + ); + expect(find.byType(BottomNavigationBar), findsOneWidget); + expect(find.widgetWithText(Center, 'Item 0'), findsOneWidget); + + await tester.scrollUntilVisible( + find.widgetWithText(Center, 'Item 49'), + 100, + ); + await tester.pumpAndSettle(); + expect(find.widgetWithText(Center, 'Item 49'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.home)); + await tester.tap(find.byIcon(Icons.home)); + await tester.pumpAndSettle(); + + final Scrollable bodyScrollView = tester.widget(find.byType(Scrollable)); + expect(bodyScrollView.controller?.offset, 0.0); + + await tester.tap(find.byIcon(Icons.open_in_new_rounded)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_sheet/show_bottom_sheet.0_test.dart b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_bottom_sheet.0_test.dart new file mode 100644 index 000000000000..f582306caf7d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_bottom_sheet.0_test.dart @@ -0,0 +1,67 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_sheet/show_bottom_sheet.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Bottom sheet animation can be customized using AnimationStyle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.BottomSheetExampleApp()); + + // Show the bottom sheet with default animation style. + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, closeTo(178.0, 0.1)); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(56.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close')); + await tester.pumpAndSettle(); + + // Select custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + // Show the bottom sheet with custom animation style. + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pump(); + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 1500)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, closeTo(178.0, 0.1)); + + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 1500)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(56.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close')); + await tester.pumpAndSettle(); + + // Select no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + // Show the bottom sheet with no animation style. + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pump(); + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(56.0)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.0_test.dart b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.0_test.dart new file mode 100644 index 000000000000..c0d42b5a2626 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.0_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_sheet/show_modal_bottom_sheet.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('BottomSheet can be opened and closed', ( + WidgetTester tester, + ) async { + const String titleText = 'Modal BottomSheet'; + const String closeText = 'Close BottomSheet'; + + await tester.pumpWidget(const example.BottomSheetApp()); + + expect(find.text(titleText), findsNothing); + expect(find.text(closeText), findsNothing); + + // Open the bottom sheet. + await tester.tap( + find.widgetWithText(ElevatedButton, 'showModalBottomSheet'), + ); + await tester.pumpAndSettle(); + + // Verify that the bottom sheet is open. + expect(find.text(titleText), findsOneWidget); + expect(find.text(closeText), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.1_test.dart b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.1_test.dart new file mode 100644 index 000000000000..635be2d0818f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.1_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_sheet/show_modal_bottom_sheet.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('BottomSheet can be opened and closed', ( + WidgetTester tester, + ) async { + const String titleText = 'Modal BottomSheet'; + const String closeText = 'Close BottomSheet'; + + await tester.pumpWidget(const example.BottomSheetApp()); + + expect(find.text(titleText), findsNothing); + expect(find.text(closeText), findsNothing); + + // Open the bottom sheet. + await tester.tap( + find.widgetWithText(ElevatedButton, 'showModalBottomSheet'), + ); + await tester.pumpAndSettle(); + + // Verify that the bottom sheet is open. + expect(find.text(titleText), findsOneWidget); + expect(find.text(closeText), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.2_test.dart b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.2_test.dart new file mode 100644 index 000000000000..e20f58a78e03 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/bottom_sheet/show_modal_bottom_sheet.2_test.dart @@ -0,0 +1,80 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/bottom_sheet/show_modal_bottom_sheet.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Modal bottom sheet animation can be customized using AnimationStyle', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ModalBottomSheetApp()); + + // Show the bottom sheet with default animation style. + await tester.tap( + find.widgetWithText(ElevatedButton, 'showModalBottomSheet'), + ); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is partially visible. + expect( + tester.getTopLeft(find.byType(BottomSheet)).dy, + closeTo(316.7, 0.1), + ); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(262.5)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close')); + await tester.pumpAndSettle(); + + // Select custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + // Show the bottom sheet with custom animation style. + await tester.tap( + find.widgetWithText(ElevatedButton, 'showModalBottomSheet'), + ); + await tester.pump(); + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 1500)); + + // The bottom sheet is partially visible. + expect( + tester.getTopLeft(find.byType(BottomSheet)).dy, + closeTo(316.7, 0.1), + ); + + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 1500)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(262.5)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close')); + await tester.pumpAndSettle(); + + // Select no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + // Show the bottom sheet with no animation style. + await tester.tap( + find.widgetWithText(ElevatedButton, 'showModalBottomSheet'), + ); + await tester.pump(); + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(262.5)); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/button_style/button_style.0_test.dart b/packages/material_ui/material_ui_examples/test/button_style/button_style.0_test.dart new file mode 100644 index 000000000000..8d9791a10ee7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/button_style/button_style.0_test.dart @@ -0,0 +1,75 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/button_style/button_style.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Shows ElevatedButtons, FilledButtons, OutlinedButtons and TextButtons in enabled and disabled states', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ButtonApp()); + + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ElevatedButton && widget.onPressed == null; + }), + findsOne, + ); + + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ElevatedButton && widget.onPressed != null; + }), + findsOne, + ); + + // One OutlinedButton with onPressed null. + expect( + find.byWidgetPredicate((Widget widget) { + return widget is OutlinedButton && widget.onPressed == null; + }), + findsOne, + ); + + // One OutlinedButton with onPressed not null. + expect( + find.byWidgetPredicate((Widget widget) { + return widget is OutlinedButton && widget.onPressed != null; + }), + findsOne, + ); + + expect( + find.byWidgetPredicate((Widget widget) { + return widget is TextButton && widget.onPressed == null; + }), + findsOne, + ); + + expect( + find.byWidgetPredicate((Widget widget) { + return widget is TextButton && widget.onPressed != null; + }), + findsOne, + ); + + expect( + find.byWidgetPredicate((Widget widget) { + return widget is FilledButton && widget.onPressed != null; + }), + findsNWidgets(2), + ); + + expect( + find.byWidgetPredicate((Widget widget) { + return widget is FilledButton && widget.onPressed == null; + }), + findsNWidgets(2), + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/card/card.0_test.dart b/packages/material_ui/material_ui_examples/test/card/card.0_test.dart new file mode 100644 index 000000000000..962a8fdb7769 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/card/card.0_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/card/card.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Card Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.CardExampleApp()); + expect(find.byType(Card), findsOneWidget); + expect(find.widgetWithIcon(Card, Icons.album), findsOneWidget); + expect( + find.widgetWithText(Card, 'The Enchanted Nightingale'), + findsOneWidget, + ); + expect( + find.widgetWithText( + Card, + 'Music by Julie Gable. Lyrics by Sidney Stein.', + ), + findsOneWidget, + ); + expect(find.widgetWithText(Card, 'BUY TICKETS'), findsOneWidget); + expect(find.widgetWithText(Card, 'LISTEN'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/card/card.1_test.dart b/packages/material_ui/material_ui_examples/test/card/card.1_test.dart new file mode 100644 index 000000000000..d5fe34f9d04c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/card/card.1_test.dart @@ -0,0 +1,16 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/card/card.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Card has clip applied', (WidgetTester tester) async { + await tester.pumpWidget(const example.CardExampleApp()); + + final Card card = tester.firstWidget(find.byType(Card)); + expect(card.clipBehavior, Clip.hardEdge); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/card/card.2_test.dart b/packages/material_ui/material_ui_examples/test/card/card.2_test.dart new file mode 100644 index 000000000000..2b9bca547ef2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/card/card.2_test.dart @@ -0,0 +1,64 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/card/card.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Card variants', (WidgetTester tester) async { + await tester.pumpWidget(const example.CardExamplesApp()); + + expect(find.byType(Card), findsNWidgets(3)); + + expect(find.widgetWithText(Card, 'Elevated Card'), findsOneWidget); + expect(find.widgetWithText(Card, 'Filled Card'), findsOneWidget); + expect(find.widgetWithText(Card, 'Outlined Card'), findsOneWidget); + + Material getCardMaterial(WidgetTester tester, int cardIndex) { + return tester.widget<Material>( + find.descendant( + of: find.byType(Card).at(cardIndex), + matching: find.byType(Material), + ), + ); + } + + final Material defaultCard = getCardMaterial(tester, 0); + expect(defaultCard.clipBehavior, Clip.none); + expect(defaultCard.elevation, 1.0); + expect( + defaultCard.shape, + const RoundedRectangleBorder(borderRadius: .all(Radius.circular(12.0))), + ); + expect(defaultCard.color, const Color(0xfff7f2fa)); + expect(defaultCard.shadowColor, const Color(0xff000000)); + expect(defaultCard.surfaceTintColor, Colors.transparent); + + final Material filledCard = getCardMaterial(tester, 1); + expect(filledCard.clipBehavior, Clip.none); + expect(filledCard.elevation, 0.0); + expect( + filledCard.shape, + const RoundedRectangleBorder(borderRadius: .all(Radius.circular(12.0))), + ); + expect(filledCard.color, const Color(0xffe6e0e9)); + expect(filledCard.shadowColor, const Color(0xff000000)); + expect(filledCard.surfaceTintColor, const Color(0x00000000)); + + final Material outlinedCard = getCardMaterial(tester, 2); + expect(outlinedCard.clipBehavior, Clip.none); + expect(outlinedCard.elevation, 0.0); + expect( + outlinedCard.shape, + const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffcac4d0)), + borderRadius: .all(Radius.circular(12.0)), + ), + ); + expect(outlinedCard.color, const Color(0xfffef7ff)); + expect(outlinedCard.shadowColor, const Color(0xff000000)); + expect(outlinedCard.surfaceTintColor, Colors.transparent); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/carousel/carousel.0_test.dart b/packages/material_ui/material_ui_examples/test/carousel/carousel.0_test.dart new file mode 100644 index 000000000000..4ac416d6d77d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/carousel/carousel.0_test.dart @@ -0,0 +1,52 @@ +// 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:io'; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/carousel/carousel.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // The app being tested loads images via HTTP which the test + // framework defeats by default. + setUpAll(() { + HttpOverrides.global = null; + }); + + testWidgets('Carousel Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.CarouselExampleApp()); + + expect( + find.widgetWithText(example.HeroLayoutCard, 'Through the Pane'), + findsOneWidget, + ); + final Finder firstCarousel = find.byType(CarouselView).first; + await tester.drag(firstCarousel, const Offset(150, 0)); + await tester.pumpAndSettle(); + expect( + find.widgetWithText(example.HeroLayoutCard, 'The Flow'), + findsOneWidget, + ); + + await tester.drag(firstCarousel, const Offset(0, -200)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CarouselView, 'Cameras'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Lighting'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Climate'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Wifi'), findsOneWidget); + + await tester.drag( + find.widgetWithText(CarouselView, 'Cameras'), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + + expect(find.text('Uncontained layout'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Show 0'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Show 1'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/carousel/carousel.1_test.dart b/packages/material_ui/material_ui_examples/test/carousel/carousel.1_test.dart new file mode 100644 index 000000000000..9e1293420cd2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/carousel/carousel.1_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/carousel/carousel.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CarouselView.builder creates items lazily', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CarouselBuilderExampleApp()); + + expect(find.byType(CarouselView), findsOneWidget); + + expect(find.text('Item 0'), findsOneWidget); + + expect(find.text('Item 999'), findsNothing); + + final Finder carousel = find.byType(CarouselView); + await tester.drag(carousel, const Offset(-350, 0)); + await tester.pumpAndSettle(); + + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 0'), findsNothing); + + for (int i = 0; i < 5; i++) { + await tester.drag(carousel, const Offset(-350, 0)); + await tester.pumpAndSettle(); + } + + expect(find.text('Item 6'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/checkbox/checkbox.0_test.dart b/packages/material_ui/material_ui_examples/test/checkbox/checkbox.0_test.dart new file mode 100644 index 000000000000..90126fa599f9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/checkbox/checkbox.0_test.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/checkbox/checkbox.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Checkbox can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.CheckboxExampleApp()); + + Checkbox checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isFalse); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + + checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isTrue); + + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + + checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isFalse); + }); + + testWidgets('Checkbox color can be changed', (WidgetTester tester) async { + await tester.pumpWidget(const example.CheckboxExampleApp()); + final Checkbox checkbox = tester.widget(find.byType(Checkbox)); + + expect(checkbox.checkColor, Colors.white); + expect(checkbox.fillColor!.resolve(<WidgetState>{}), Colors.red); + expect( + checkbox.fillColor!.resolve(<WidgetState>{WidgetState.pressed}), + Colors.blue, + ); + expect( + checkbox.fillColor!.resolve(<WidgetState>{WidgetState.hovered}), + Colors.blue, + ); + expect( + checkbox.fillColor!.resolve(<WidgetState>{WidgetState.focused}), + Colors.blue, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/checkbox/checkbox.1_test.dart b/packages/material_ui/material_ui_examples/test/checkbox/checkbox.1_test.dart new file mode 100644 index 000000000000..7160fbccf3e8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/checkbox/checkbox.1_test.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/checkbox/checkbox.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Checkbox can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.CheckboxExampleApp()); + + expect(find.byType(Checkbox), findsNWidgets(3)); + Checkbox checkbox = tester.widget(find.byType(Checkbox).first); + Checkbox checkboxWithError = tester.widget(find.byType(Checkbox).at(1)); + Checkbox checkboxDisabled = tester.widget(find.byType(Checkbox).last); + + // Verify the initial state of the checkboxes. + expect(checkbox.value, isTrue); + expect(checkboxWithError.value, isTrue); + expect(checkboxDisabled.value, isTrue); + + expect(checkboxWithError.isError, isTrue); + expect(checkboxDisabled.onChanged, null); + + expect(checkbox.tristate, isTrue); + expect(checkboxWithError.tristate, isTrue); + expect(checkboxDisabled.tristate, isTrue); + + // Tap the first Checkbox and verify the state change. + await tester.tap(find.byType(Checkbox).first); + await tester.pump(); + checkbox = tester.widget(find.byType(Checkbox).first); + checkboxWithError = tester.widget(find.byType(Checkbox).at(1)); + checkboxDisabled = tester.widget(find.byType(Checkbox).last); + + expect(checkbox.value, isNull); + expect(checkboxWithError.value, isNull); + expect(checkboxDisabled.value, isNull); + + // Tap the second Checkbox and verify the state change. + await tester.tap(find.byType(Checkbox).at(1)); + await tester.pump(); + + checkbox = tester.widget(find.byType(Checkbox).first); + checkboxWithError = tester.widget(find.byType(Checkbox).at(1)); + checkboxDisabled = tester.widget(find.byType(Checkbox).last); + + expect(checkbox.value, isFalse); + expect(checkboxWithError.value, isFalse); + expect(checkboxDisabled.value, isFalse); + + // Tap the third Checkbox and verify that should remain unchanged. + await tester.tap(find.byType(Checkbox).last); + await tester.pump(); + + checkbox = tester.widget(find.byType(Checkbox).first); + checkboxWithError = tester.widget(find.byType(Checkbox).at(1)); + checkboxDisabled = tester.widget(find.byType(Checkbox).last); + + expect(checkbox.value, isFalse); + expect(checkboxWithError.value, isFalse); + expect(checkboxDisabled.value, isFalse); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/checkbox_list_tile/checkbox_list_tile.0_test.dart b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/checkbox_list_tile.0_test.dart new file mode 100644 index 000000000000..deaee39e1de4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/checkbox_list_tile.0_test.dart @@ -0,0 +1,33 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:material_ui_examples/checkbox_list_tile/checkbox_list_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CheckboxListTile can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.CheckboxListTileApp()); + + CheckboxListTile checkboxListTile = tester.widget( + find.byType(CheckboxListTile), + ); + expect(checkboxListTile.value, isFalse); + + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + timeDilation = 1.0; + + checkboxListTile = tester.widget(find.byType(CheckboxListTile)); + expect(checkboxListTile.value, isTrue); + + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + + checkboxListTile = tester.widget(find.byType(CheckboxListTile)); + expect(checkboxListTile.value, isFalse); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/checkbox_list_tile/checkbox_list_tile.1_test.dart b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/checkbox_list_tile.1_test.dart new file mode 100644 index 000000000000..dc35a2ff63ce --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/checkbox_list_tile.1_test.dart @@ -0,0 +1,72 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/checkbox_list_tile/checkbox_list_tile.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Checkbox aligns appropriately', (WidgetTester tester) async { + await tester.pumpWidget(const example.CheckboxListTileApp()); + + expect(find.byType(CheckboxListTile), findsNWidgets(3)); + + Offset tileTopLeft = tester.getTopLeft(find.byType(CheckboxListTile).at(0)); + Offset checkboxTopLeft = tester.getTopLeft(find.byType(Checkbox).at(0)); + + // The checkbox is centered vertically with the text. + expect(checkboxTopLeft - tileTopLeft, const Offset(736.0, 16.0)); + + tileTopLeft = tester.getTopLeft(find.byType(CheckboxListTile).at(1)); + checkboxTopLeft = tester.getTopLeft(find.byType(Checkbox).at(1)); + + // The checkbox is centered vertically with the text. + expect(checkboxTopLeft - tileTopLeft, const Offset(736.0, 30.0)); + + tileTopLeft = tester.getTopLeft(find.byType(CheckboxListTile).at(2)); + checkboxTopLeft = tester.getTopLeft(find.byType(Checkbox).at(2)); + + // The checkbox is aligned to the top vertically with the text. + expect(checkboxTopLeft - tileTopLeft, const Offset(736.0, 8.0)); + }); + + testWidgets('Checkboxes can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.CheckboxListTileApp()); + + expect(find.byType(CheckboxListTile), findsNWidgets(3)); + + // All checkboxes are checked. + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(0)).value, isTrue); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(1)).value, isTrue); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(2)).value, isTrue); + + // Tap the first checkbox. + await tester.tap(find.byType(Checkbox).at(0)); + await tester.pumpAndSettle(); + + // The first checkbox is unchecked. + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(0)).value, isFalse); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(1)).value, isTrue); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(2)).value, isTrue); + + // Tap the second checkbox. + await tester.tap(find.byType(Checkbox).at(1)); + await tester.pumpAndSettle(); + + // The first and second checkboxes are unchecked. + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(0)).value, isFalse); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(1)).value, isFalse); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(2)).value, isTrue); + + // Tap the third checkbox. + await tester.tap(find.byType(Checkbox).at(2)); + await tester.pumpAndSettle(); + + // All checkboxes are unchecked. + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(0)).value, isFalse); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(1)).value, isFalse); + expect(tester.widget<Checkbox>(find.byType(Checkbox).at(2)).value, isFalse); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/checkbox_list_tile/custom_labeled_checkbox.0_test.dart b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/custom_labeled_checkbox.0_test.dart new file mode 100644 index 000000000000..94ae7092706b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/custom_labeled_checkbox.0_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/checkbox_list_tile/custom_labeled_checkbox.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('LinkedLabelCheckbox contains RichText and Checkbox', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabeledCheckboxApp()); + + // Label text is in a RichText widget with the correct text. + final RichText richText = tester.widget(find.byType(RichText).first); + expect(richText.text.toPlainText(), 'Linked, tappable label text'); + + // Checkbox is initially unchecked. + Checkbox checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isFalse); + + // Tap the checkbox to check it. + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + // Checkbox is now checked. + checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/checkbox_list_tile/custom_labeled_checkbox.1_test.dart b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/custom_labeled_checkbox.1_test.dart new file mode 100644 index 000000000000..dfe86516e849 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/checkbox_list_tile/custom_labeled_checkbox.1_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/checkbox_list_tile/custom_labeled_checkbox.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tapping LabeledCheckbox toggles the checkbox', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabeledCheckboxApp()); + + // Checkbox is initially unchecked. + Checkbox checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isFalse); + + // Tap the LabeledCheckBoxApp to toggle the checkbox. + await tester.tap(find.byType(example.LabeledCheckbox)); + await tester.pumpAndSettle(); + + // Checkbox is now checked. + checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.value, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/chip/chip_attributes.avatar_box_constraints.0_test.dart b/packages/material_ui/material_ui_examples/test/chip/chip_attributes.avatar_box_constraints.0_test.dart new file mode 100644 index 000000000000..2b0102b3a9b4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/chip/chip_attributes.avatar_box_constraints.0_test.dart @@ -0,0 +1,68 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/chip/chip_attributes.avatar_box_constraints.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('RawChip.avatarBoxConstraints updates avatar size constraints', ( + WidgetTester tester, + ) async { + const double border = 1.0; + const double iconSize = 18.0; + const double padding = 8.0; + + await tester.pumpWidget(const example.AvatarBoxConstraintsApp()); + + expect(tester.getSize(find.byType(RawChip).at(0)).width, equals(202.0)); + expect(tester.getSize(find.byType(RawChip).at(0)).height, equals(58.0)); + + Offset chipTopLeft = tester.getTopLeft( + find.byWidget( + tester.widget<Material>( + find.descendant( + of: find.byType(RawChip).at(0), + matching: find.byType(Material), + ), + ), + ), + ); + Offset avatarCenter = tester.getCenter(find.byIcon(Icons.star).at(0)); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + + expect(tester.getSize(find.byType(RawChip).at(1)).width, equals(202.0)); + expect(tester.getSize(find.byType(RawChip).at(1)).height, equals(78.0)); + + chipTopLeft = tester.getTopLeft( + find.byWidget( + tester.widget<Material>( + find.descendant( + of: find.byType(RawChip).at(1), + matching: find.byType(Material), + ), + ), + ), + ); + avatarCenter = tester.getCenter(find.byIcon(Icons.star).at(1)); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + + expect(tester.getSize(find.byType(RawChip).at(2)).width, equals(202.0)); + expect(tester.getSize(find.byType(RawChip).at(2)).height, equals(78.0)); + + chipTopLeft = tester.getTopLeft( + find.byWidget( + tester.widget<Material>( + find.descendant( + of: find.byType(RawChip).at(2), + matching: find.byType(Material), + ), + ), + ), + ); + avatarCenter = tester.getCenter(find.byIcon(Icons.star).at(2)); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/chip/chip_attributes.chip_animation_style.0_test.dart b/packages/material_ui/material_ui_examples/test/chip/chip_attributes.chip_animation_style.0_test.dart new file mode 100644 index 000000000000..260708bb746f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/chip/chip_attributes.chip_animation_style.0_test.dart @@ -0,0 +1,202 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/chip/chip_attributes.chip_animation_style.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'ChipAnimationStyle.enableAnimation overrides chip enable animation', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ChipAnimationStyleExampleApp()); + + final RenderBox materialBox = tester.firstRenderObject<RenderBox>( + find.descendant( + of: find.widgetWithText(RawChip, 'Enabled'), + matching: find.byType(CustomPaint), + ), + ); + + expect(materialBox, paints..rrect(color: const Color(0xffffc107))); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Disable')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance enable animation by 500ms. + + expect(materialBox, paints..rrect(color: const Color(0x1f882f2b))); + + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance enable animation by 500ms. + + expect(materialBox, paints..rrect(color: const Color(0x1ff44336))); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Enable')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 1500), + ); // Advance enable animation by 1500ms. + + expect(materialBox, paints..rrect(color: const Color(0xfffbd980))); + + await tester.pump( + const Duration(milliseconds: 1500), + ); // Advance enable animation by 1500ms. + + expect(materialBox, paints..rrect(color: const Color(0xffffc107))); + }, + ); + + testWidgets( + 'ChipAnimationStyle.selectAnimation overrides chip select animation', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ChipAnimationStyleExampleApp()); + + final RenderBox materialBox = tester.firstRenderObject<RenderBox>( + find.descendant( + of: find.widgetWithText(RawChip, 'Unselected'), + matching: find.byType(CustomPaint), + ), + ); + + expect(materialBox, paints..rrect(color: const Color(0xffffc107))); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Select')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 1500), + ); // Advance select animation by 1500ms. + + expect(materialBox, paints..rrect(color: const Color(0xff4da6f4))); + + await tester.pump( + const Duration(milliseconds: 1500), + ); // Advance select animation by 1500ms. + + expect(materialBox, paints..rrect(color: const Color(0xff2196f3))); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Unselect')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance select animation by 500ms. + + expect(materialBox, paints..rrect(color: const Color(0xfff8e7c3))); + + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance select animation by 500ms. + + expect(materialBox, paints..rrect(color: const Color(0xffffc107))); + }, + ); + + testWidgets( + 'ChipAnimationStyle.avatarDrawerAnimation overrides chip checkmark animation', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ChipAnimationStyleExampleApp()); + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Checked')).width, + closeTo(152.6, 0.1), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Hide checkmark')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance avatar animation by 500ms. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Unchecked')).width, + closeTo(160.9, 0.1), + ); + + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance avatar animation by 500ms. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Unchecked')).width, + closeTo(160.9, 0.1), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Show checkmark')); + await tester.pump(); + await tester.pump( + const Duration(seconds: 1), + ); // Advance avatar animation by 1sec. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Checked')).width, + closeTo(132.7, 0.1), + ); + + await tester.pump( + const Duration(seconds: 1), + ); // Advance avatar animation by 1sec. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Checked')).width, + closeTo(152.6, 0.1), + ); + }, + ); + + testWidgets( + 'ChipAnimationStyle.deleteDrawerAnimation overrides chip delete icon animation', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ChipAnimationStyleExampleApp()); + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Deletable')).width, + closeTo(180.9, 0.1), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Hide delete icon')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance delete icon animation by 500ms. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Undeletable')).width, + closeTo(204.6, 0.1), + ); + + await tester.pump( + const Duration(milliseconds: 500), + ); // Advance delete icon animation by 500ms. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Undeletable')).width, + closeTo(189.1, 0.1), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Show delete icon')); + await tester.pump(); + await tester.pump( + const Duration(seconds: 1), + ); // Advance delete icon animation by 1sec. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Deletable')).width, + closeTo(176.4, 0.1), + ); + + await tester.pump( + const Duration(seconds: 1), + ); // Advance delete icon animation by 1sec. + + expect( + tester.getSize(find.widgetWithText(RawChip, 'Deletable')).width, + closeTo(180.9, 0.1), + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/chip/deletable_chip_attributes.delete_icon_box_constraints.0_test.dart b/packages/material_ui/material_ui_examples/test/chip/deletable_chip_attributes.delete_icon_box_constraints.0_test.dart new file mode 100644 index 000000000000..6567eff76428 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/chip/deletable_chip_attributes.delete_icon_box_constraints.0_test.dart @@ -0,0 +1,80 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/chip/deletable_chip_attributes.delete_icon_box_constraints.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'RawChip.deleteIconBoxConstraints updates delete icon size constraints', + (WidgetTester tester) async { + const double border = 1.0; + const double iconSize = 18.0; + const double padding = 8.0; + + await tester.pumpWidget(const example.DeleteIconBoxConstraintsApp()); + + expect(tester.getSize(find.byType(RawChip).at(0)).width, equals(202.0)); + expect(tester.getSize(find.byType(RawChip).at(0)).height, equals(58.0)); + + Offset chipToRight = tester.getTopRight( + find.byWidget( + tester.widget<Material>( + find.descendant( + of: find.byType(RawChip).at(0), + matching: find.byType(Material), + ), + ), + ), + ); + Offset deleteIconCenter = tester.getCenter( + find.byIcon(Icons.cancel).at(0), + ); + expect( + chipToRight.dx, + deleteIconCenter.dx + (iconSize / 2) + padding + border, + ); + + expect(tester.getSize(find.byType(RawChip).at(1)).width, equals(202.0)); + expect(tester.getSize(find.byType(RawChip).at(1)).height, equals(78.0)); + + chipToRight = tester.getTopRight( + find.byWidget( + tester.widget<Material>( + find.descendant( + of: find.byType(RawChip).at(1), + matching: find.byType(Material), + ), + ), + ), + ); + deleteIconCenter = tester.getCenter(find.byIcon(Icons.cancel).at(1)); + expect( + chipToRight.dx, + deleteIconCenter.dx + (iconSize / 2) + padding + border, + ); + + expect(tester.getSize(find.byType(RawChip).at(2)).width, equals(202.0)); + expect(tester.getSize(find.byType(RawChip).at(2)).height, equals(78.0)); + + chipToRight = tester.getTopRight( + find.byWidget( + tester.widget<Material>( + find.descendant( + of: find.byType(RawChip).at(2), + matching: find.byType(Material), + ), + ), + ), + ); + deleteIconCenter = tester.getCenter(find.byIcon(Icons.cancel).at(2)); + expect( + chipToRight.dx, + deleteIconCenter.dx + (iconSize / 2) + padding + border, + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/chip/deletable_chip_attributes.on_deleted.0_test.dart b/packages/material_ui/material_ui_examples/test/chip/deletable_chip_attributes.on_deleted.0_test.dart new file mode 100644 index 000000000000..04fbb9ee4c0e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/chip/deletable_chip_attributes.on_deleted.0_test.dart @@ -0,0 +1,68 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/chip/deletable_chip_attributes.on_deleted.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Chip.onDeleted can be used to delete chips', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.OnDeletedExampleApp()); + + expect( + find.widgetWithText(AppBar, 'DeletableChipAttributes.onDeleted Sample'), + findsOne, + ); + expect(find.widgetWithText(Chip, 'Aaron Burr'), findsOne); + expect(find.widgetWithText(Chip, 'Alexander Hamilton'), findsOne); + expect(find.widgetWithText(Chip, 'Eliza Hamilton'), findsOne); + expect(find.widgetWithText(Chip, 'James Madison'), findsOne); + + Finder cancelIconFinder(String chipText) { + return find.descendant( + of: find.widgetWithText(Chip, chipText), + matching: find.byIcon(Icons.cancel), + ); + } + + // Delete Alexander Hamilton. + await tester.tap(cancelIconFinder('Alexander Hamilton')); + await tester.pump(); + + expect(find.widgetWithText(Chip, 'Aaron Burr'), findsOne); + expect(find.widgetWithText(Chip, 'Alexander Hamilton'), findsNothing); + expect(find.widgetWithText(Chip, 'Eliza Hamilton'), findsOne); + expect(find.widgetWithText(Chip, 'James Madison'), findsOne); + + // Delete James Madison. + await tester.tap(cancelIconFinder('James Madison')); + await tester.pump(); + + expect(find.widgetWithText(Chip, 'Aaron Burr'), findsOne); + expect(find.widgetWithText(Chip, 'Alexander Hamilton'), findsNothing); + expect(find.widgetWithText(Chip, 'Eliza Hamilton'), findsOne); + expect(find.widgetWithText(Chip, 'James Madison'), findsNothing); + + // Delete Aaron Burr. + await tester.tap(cancelIconFinder('Aaron Burr')); + await tester.pump(); + + expect(find.widgetWithText(Chip, 'Aaron Burr'), findsNothing); + expect(find.widgetWithText(Chip, 'Alexander Hamilton'), findsNothing); + expect(find.widgetWithText(Chip, 'Eliza Hamilton'), findsOne); + expect(find.widgetWithText(Chip, 'James Madison'), findsNothing); + + // Delete Eliza Hamilton. + await tester.tap(cancelIconFinder('Eliza Hamilton')); + await tester.pump(); + + expect(find.widgetWithText(Chip, 'Aaron Burr'), findsNothing); + expect(find.widgetWithText(Chip, 'Alexander Hamilton'), findsNothing); + expect(find.widgetWithText(Chip, 'Eliza Hamilton'), findsNothing); + expect(find.widgetWithText(Chip, 'James Madison'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/choice_chip/choice_chip.0_test.dart b/packages/material_ui/material_ui_examples/test/choice_chip/choice_chip.0_test.dart new file mode 100644 index 000000000000..8226fbd596b6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/choice_chip/choice_chip.0_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/choice_chip/choice_chip.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can choose an item using ChoiceChip', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ChipApp()); + + ChoiceChip chosenChip = tester.widget(find.byType(ChoiceChip).at(1)); + expect(chosenChip.selected, true); + + await tester.tap(find.byType(ChoiceChip).at(0)); + await tester.pumpAndSettle(); + + chosenChip = tester.widget(find.byType(ChoiceChip).at(0)); + expect(chosenChip.selected, true); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/color_scheme/color_scheme.0_test.dart b/packages/material_ui/material_ui_examples/test/color_scheme/color_scheme.0_test.dart new file mode 100644 index 000000000000..1c189f272257 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/color_scheme/color_scheme.0_test.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/color_scheme/color_scheme.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ColorScheme Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.ColorSchemeExample()); + expect(find.text('tonalSpot (Default)'), findsOneWidget); + + expect(find.byType(example.ColorChip), findsNWidgets(43 * 9)); + }); + + testWidgets('Change color seed', (WidgetTester tester) async { + await tester.pumpWidget(const example.ColorSchemeExample()); + + ColoredBox coloredBox() { + return tester.widget<ColoredBox>( + find.descendant( + of: find.widgetWithText(example.ColorChip, 'primary').first, + matching: find.byType(ColoredBox), + ), + ); + } + + expect(coloredBox().color, const Color(0xff65558f)); + await tester.tap(find.byType(example.SettingsButton)); + await tester.pumpAndSettle(); + expect(find.text('Settings'), findsOneWidget); + await tester.tap(find.byType(IconButton).at(6)); + await tester.pumpAndSettle(); + + expect(coloredBox().color, const Color(0xFF685F12)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/color_scheme/dynamic_content_color.0_test.dart b/packages/material_ui/material_ui_examples/test/color_scheme/dynamic_content_color.0_test.dart new file mode 100644 index 000000000000..86409c2c51d7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/color_scheme/dynamic_content_color.0_test.dart @@ -0,0 +1,169 @@ +// 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:io'; +import 'dart:math'; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/color_scheme/dynamic_content_color.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final List<(ImageProvider<Object>, Brightness)> loadColorSchemeCalls = + <(ImageProvider<Object>, Brightness)>[]; + + Future<ColorScheme> fakeColorSchemeLoader( + ImageProvider<Object> provider, + Brightness brightness, + ) async { + loadColorSchemeCalls.add((provider, brightness)); + final int index = example.DynamicColorExample.images.indexOf(provider); + final int seedColor = 0xf * pow(0x10, index).toInt(); + return ColorScheme.fromSeed( + seedColor: Color(seedColor), + brightness: brightness, + ); + } + + setUp(() { + loadColorSchemeCalls.clear(); + }); + + // The app being tested loads images via HTTP which the test + // framework defeats by default. + setUpAll(() { + HttpOverrides.global = null; + }); + + testWidgets('The content is visible', (WidgetTester tester) async { + await tester.pumpWidget( + example.DynamicColorExample(loadColorScheme: fakeColorSchemeLoader), + ); + await tester.pump(); + + expect( + find.widgetWithText(AppBar, 'Content Based Dynamic Color'), + findsOne, + ); + expect(find.byType(Switch), findsOne); + expect(find.byIcon(Icons.light_mode), findsOne); + + expect(find.text('Light ColorScheme'), findsOne); + expect(find.text('Dark ColorScheme'), findsOne); + expect(find.text('primary'), findsExactly(2)); + expect(find.text('onPrimary'), findsExactly(2)); + expect(find.text('primaryContainer'), findsExactly(2)); + expect(find.text('onPrimaryContainer'), findsExactly(2)); + expect(find.text('secondary'), findsExactly(2)); + expect(find.text('onSecondary'), findsExactly(2)); + expect(find.text('secondaryContainer'), findsExactly(2)); + expect(find.text('onSecondaryContainer'), findsExactly(2)); + expect(find.text('tertiary'), findsExactly(2)); + expect(find.text('onTertiary'), findsExactly(2)); + expect(find.text('tertiaryContainer'), findsExactly(2)); + expect(find.text('onTertiaryContainer'), findsExactly(2)); + expect(find.text('error'), findsExactly(2)); + expect(find.text('onError'), findsExactly(2)); + expect(find.text('errorContainer'), findsExactly(2)); + expect(find.text('onErrorContainer'), findsExactly(2)); + expect(find.text('surface'), findsExactly(2)); + expect(find.text('onSurface'), findsExactly(2)); + expect(find.text('onSurfaceVariant'), findsExactly(2)); + expect(find.text('outline'), findsExactly(2)); + expect(find.text('shadow'), findsExactly(2)); + expect(find.text('inverseSurface'), findsExactly(2)); + expect(find.text('onInverseSurface'), findsExactly(2)); + expect(find.text('inversePrimary'), findsExactly(2)); + + expect(loadColorSchemeCalls, hasLength(1)); + expect( + loadColorSchemeCalls.single.$1, + isA<NetworkImage>().having( + (NetworkImage provider) => provider.url, + 'url', + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_1.png', + ), + ); + expect(loadColorSchemeCalls.single.$2, Brightness.light); + + await tester.pumpAndSettle(); // Clears the timers from image loading. + }); + + testWidgets('The brightness can be changed', (WidgetTester tester) async { + await tester.pumpWidget( + example.DynamicColorExample(loadColorScheme: fakeColorSchemeLoader), + ); + await tester.pump(); + + expect(loadColorSchemeCalls, hasLength(1)); + expect( + loadColorSchemeCalls.single.$1, + isA<NetworkImage>().having( + (NetworkImage provider) => provider.url, + 'url', + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_1.png', + ), + ); + expect(loadColorSchemeCalls.single.$2, Brightness.light); + await tester.pump(); + await tester.pump(kThemeChangeDuration); + + ThemeData themeData = Theme.of(tester.element(find.byType(Scaffold))); + + expect(themeData.colorScheme.primary, const Color(0xff565992)); + expect(themeData.colorScheme.secondary, const Color(0xff5c5d72)); + + await tester.tap(find.byType(Switch)); + await tester.pump(); + + expect(loadColorSchemeCalls, hasLength(2)); + expect( + loadColorSchemeCalls.last.$1, + isA<NetworkImage>().having( + (NetworkImage provider) => provider.url, + 'url', + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_1.png', + ), + ); + expect(loadColorSchemeCalls.last.$2, Brightness.dark); + + await tester.pump(kThemeChangeDuration); + + themeData = Theme.of(tester.element(find.byType(Scaffold))); + + expect(themeData.colorScheme.primary, const Color(0xffbfc2ff)); + expect(themeData.colorScheme.secondary, const Color(0xffc5c4dd)); + }); + + testWidgets('Tapping an image loads a new color scheme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + example.DynamicColorExample(loadColorScheme: fakeColorSchemeLoader), + ); + await tester.pump(); + + await tester.tapAt(tester.getCenter(find.byType(Image).at(3))); + await tester.pump(); + + expect(loadColorSchemeCalls, hasLength(2)); + expect( + loadColorSchemeCalls.last.$1, + isA<NetworkImage>().having( + (NetworkImage provider) => provider.url, + 'url', + 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_4.png', + ), + ); + expect(loadColorSchemeCalls.last.$2, Brightness.light); + + await tester.pump(kThemeChangeDuration); + + final ThemeData themeData = Theme.of(tester.element(find.byType(Scaffold))); + + expect(themeData.colorScheme.primary, const Color(0xff406836)); + expect(themeData.colorScheme.secondary, const Color(0xff54634d)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/context_menu/context_menu_controller.0_test.dart b/packages/material_ui/material_ui_examples/test/context_menu/context_menu_controller.0_test.dart new file mode 100644 index 000000000000..1f1a91c47a58 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/context_menu/context_menu_controller.0_test.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/context_menu/context_menu_controller.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('showing and hiding the custom context menu in the whole app', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ContextMenuControllerExampleApp()); + + expect(BrowserContextMenu.enabled, !kIsWeb); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Right clicking the middle of the app shows the custom context menu. + final Offset center = tester.getCenter(find.byType(Scaffold)); + final TestGesture gesture = await tester.startGesture( + center, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text('Print'), findsOneWidget); + + // Tap to dismiss. + await tester.tapAt(center); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Long pressing also shows the custom context menu. + await tester.longPressAt(center); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text('Print'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.0_test.dart b/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.0_test.dart new file mode 100644 index 000000000000..f37aca62cd83 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.0_test.dart @@ -0,0 +1,44 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/context_menu/editable_text_toolbar_builder.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'showing and hiding the context menu in TextField with custom buttons', + (WidgetTester tester) async { + await tester.pumpWidget( + const example.EditableTextToolbarBuilderExampleApp(), + ); + + expect(BrowserContextMenu.enabled, !kIsWeb); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Long pressing the field shows the default context menu but with custom + // buttons. + await tester.longPress(find.byType(EditableText)); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(CupertinoButton), findsAtLeastNWidgets(1)); + + // Tap to dismiss. + await tester.tapAt(tester.getTopLeft(find.byType(EditableText))); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoButton), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.1_test.dart b/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.1_test.dart new file mode 100644 index 000000000000..aca4fb6b5c66 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.1_test.dart @@ -0,0 +1,80 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/context_menu/editable_text_toolbar_builder.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'showing and hiding the custom context menu in TextField with a specific selection', + (WidgetTester tester) async { + await tester.pumpWidget( + const example.EditableTextToolbarBuilderExampleApp(), + ); + + expect(BrowserContextMenu.enabled, !kIsWeb); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Right clicking the Text in the TextField shows the custom context menu, + // but no email button since no email address is selected. + TestGesture gesture = await tester.startGesture( + tester.getTopLeft(find.text(example.text)), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text('Send email'), findsNothing); + + // Tap to dismiss. + await tester.tapAt(tester.getTopLeft(find.byType(EditableText))); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Select the email address. + final EditableTextState state = tester.state<EditableTextState>( + find.byType(EditableText), + ); + state.updateEditingValue( + state.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: example.text.indexOf(example.emailAddress), + extentOffset: example.text.length, + ), + ), + ); + await tester.pump(); + + // Right clicking the Text in the TextField shows the custom context menu + // with the email button. + gesture = await tester.startGesture( + tester.getCenter(find.text(example.text)), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text('Send email'), findsOneWidget); + + // Tap to dismiss. + await tester.tapAt(tester.getTopLeft(find.byType(EditableText))); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.2_test.dart b/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.2_test.dart new file mode 100644 index 000000000000..23b08a59a298 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/context_menu/editable_text_toolbar_builder.2_test.dart @@ -0,0 +1,82 @@ +// 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:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/context_menu/editable_text_toolbar_builder.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'showing and hiding the context menu in TextField with a custom toolbar', + (WidgetTester tester) async { + await tester.pumpWidget( + const example.EditableTextToolbarBuilderExampleApp(), + ); + + expect(BrowserContextMenu.enabled, !kIsWeb); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Long pressing the field shows the custom context menu. + await tester.longPress(find.byType(EditableText)); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // The buttons use the default widgets but with custom labels. + switch (defaultTargetPlatform) { + case .iOS: + expect( + find.byType(CupertinoTextSelectionToolbarButton), + findsAtLeastNWidgets(1), + ); + case .android: + case .fuchsia: + expect( + find.byType(TextSelectionToolbarTextButton), + findsAtLeastNWidgets(1), + ); + case .linux: + case .windows: + expect( + find.byType(DesktopTextSelectionToolbarButton), + findsAtLeastNWidgets(1), + ); + case .macOS: + expect( + find.byType(CupertinoDesktopTextSelectionToolbarButton), + findsAtLeastNWidgets(1), + ); + } + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + + // Tap to dismiss. + await tester.tapAt(tester.getTopLeft(find.byType(EditableText))); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing); + expect(find.byType(TextSelectionToolbarTextButton), findsNothing); + expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing); + expect( + find.byType(CupertinoDesktopTextSelectionToolbarButton), + findsNothing, + ); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/context_menu/selectable_region_toolbar_builder.0_test.dart b/packages/material_ui/material_ui_examples/test/context_menu/selectable_region_toolbar_builder.0_test.dart new file mode 100644 index 000000000000..4a4db7dc48a9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/context_menu/selectable_region_toolbar_builder.0_test.dart @@ -0,0 +1,55 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/context_menu/selectable_region_toolbar_builder.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('showing and hiding the custom context menu on SelectionArea', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.SelectableRegionToolbarBuilderExampleApp(), + ); + + expect(BrowserContextMenu.enabled, !kIsWeb); + + // Allow the selection overlay geometry to be created. + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Right clicking the Text in the SelectionArea shows the custom context + // menu. + final TestGesture primaryMouseButtonGesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.text(example.text)), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text('Print'), findsOneWidget); + + // Tap to dismiss. + await primaryMouseButtonGesture.down( + tester.getCenter(find.byType(Scaffold)), + ); + await tester.pump(); + await primaryMouseButtonGesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/data_table/data_table.0_test.dart b/packages/material_ui/material_ui_examples/test/data_table/data_table.0_test.dart new file mode 100644 index 000000000000..ace873ee9265 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/data_table/data_table.0_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/data_table/data_table.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DataTable Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.DataTableExampleApp()); + expect(find.widgetWithText(AppBar, 'DataTable Sample'), findsOneWidget); + expect(find.byType(DataTable), findsOneWidget); + final DataTable dataTable = tester.widget<DataTable>( + find.byType(DataTable), + ); + expect(dataTable.columns.length, 3); + expect(dataTable.rows.length, 3); + for (int i = 0; i < dataTable.rows.length; i++) { + expect(dataTable.rows[i].cells.length, 3); + } + expect(find.text('Name'), findsOneWidget); + expect(find.text('Age'), findsOneWidget); + expect(find.text('Role'), findsOneWidget); + expect(find.text('Sarah'), findsOneWidget); + expect(find.text('19'), findsOneWidget); + expect(find.text('Student'), findsOneWidget); + expect(find.text('Janine'), findsOneWidget); + expect(find.text('43'), findsOneWidget); + expect(find.text('Professor'), findsOneWidget); + expect(find.text('William'), findsOneWidget); + expect(find.text('27'), findsOneWidget); + expect(find.text('Associate Professor'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/data_table/data_table.1_test.dart b/packages/material_ui/material_ui_examples/test/data_table/data_table.1_test.dart new file mode 100644 index 000000000000..71150d16726a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/data_table/data_table.1_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/data_table/data_table.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DataTable is scrollable', (WidgetTester tester) async { + await tester.pumpWidget(const example.DataTableExampleApp()); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + + expect(tester.getTopLeft(find.text('Row 5')), const Offset(66.0, 366.0)); + + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0.0, -200.0), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Row 5')), const Offset(66.0, 186.0)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/date_picker/custom_calendar_date_picker.0_test.dart b/packages/material_ui/material_ui_examples/test/date_picker/custom_calendar_date_picker.0_test.dart new file mode 100644 index 000000000000..b6d40e48eea3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/date_picker/custom_calendar_date_picker.0_test.dart @@ -0,0 +1,50 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/date_picker/custom_calendar_date_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Text getLastDayText(WidgetTester tester) { + final Finder dayFinder = find.descendant( + of: find.byType(Ink), + matching: find.byType(Text), + ); + return tester.widget(dayFinder.last); + } + + testWidgets('Days are based on the calendar delegate', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CalendarDatePickerApp()); + + final Finder nextMonthButton = find.byIcon(Icons.chevron_right); + + Text lastDayText = getLastDayText(tester); + expect(find.text('February 2025'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(find.text('March 2025'), findsOneWidget); + expect(lastDayText.data, equals('28')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(find.text('April 2025'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(lastDayText.data, equals('28')); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/date_picker/date_picker_theme_day_shape.0_test.dart b/packages/material_ui/material_ui_examples/test/date_picker/date_picker_theme_day_shape.0_test.dart new file mode 100644 index 000000000000..55b9769cc231 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/date_picker/date_picker_theme_day_shape.0_test.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/date_picker/date_picker_theme_day_shape.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'DatePickerThemeData.dayShape updates day selection shape decoration', + (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + final OutlinedBorder dayShape = RoundedRectangleBorder( + borderRadius: .circular(8.0), + ); + const Color todayBackgroundColor = Colors.amber; + const Color todayForegroundColor = Colors.black; + const BorderSide todayBorder = BorderSide(width: 2); + + ShapeDecoration? findDayDecoration(WidgetTester tester, String day) { + return tester + .widget<Ink>( + find.ancestor(of: find.text(day), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; + } + + await tester.pumpWidget(const example.DatePickerApp()); + + await tester.tap(find.text('Open Date Picker')); + await tester.pumpAndSettle(); + + // Test the current day shape decoration. + ShapeDecoration dayShapeDecoration = findDayDecoration(tester, '15')!; + expect(dayShapeDecoration.color, todayBackgroundColor); + expect( + dayShapeDecoration.shape, + dayShape.copyWith( + side: todayBorder.copyWith(color: todayForegroundColor), + ), + ); + + // Test the selected day shape decoration. + dayShapeDecoration = findDayDecoration(tester, '20')!; + expect(dayShapeDecoration.color, theme.colorScheme.primary); + expect(dayShapeDecoration.shape, dayShape); + + // Tap to select current day as the selected day. + await tester.tap(find.text('15')); + await tester.pumpAndSettle(); + + // Test the selected day shape decoration. + dayShapeDecoration = findDayDecoration(tester, '15')!; + expect(dayShapeDecoration.color, todayBackgroundColor); + expect( + dayShapeDecoration.shape, + dayShape.copyWith( + side: todayBorder.copyWith(color: todayForegroundColor), + ), + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/date_picker/show_date_picker.0_test.dart b/packages/material_ui/material_ui_examples/test/date_picker/show_date_picker.0_test.dart new file mode 100644 index 000000000000..270640804e72 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/date_picker/show_date_picker.0_test.dart @@ -0,0 +1,45 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/date_picker/show_date_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can show date picker', (WidgetTester tester) async { + const String datePickerTitle = 'Select date'; + const String initialDate = 'Sun, Jul 25'; + + await tester.pumpWidget(const example.DatePickerApp()); + + // The date picker is not shown initially. + expect(find.text(datePickerTitle), findsNothing); + expect(find.text(initialDate), findsNothing); + + // Tap the button to show the date picker. + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + + // The initial date is shown. + expect(find.text(datePickerTitle), findsOneWidget); + expect(find.text(initialDate), findsOneWidget); + + // Tap another date to select it. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + + // The selected date is shown. + expect(find.text(datePickerTitle), findsOneWidget); + expect(find.text('Fri, Jul 30'), findsOneWidget); + + // Tap OK to confirm the selection and close the date picker. + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // The date picker is closed and the selected date is shown. + expect(find.text(datePickerTitle), findsNothing); + expect(find.text('Selected: 30/7/2021'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/date_picker/show_date_picker.1_test.dart b/packages/material_ui/material_ui_examples/test/date_picker/show_date_picker.1_test.dart new file mode 100644 index 000000000000..c235e2e38573 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/date_picker/show_date_picker.1_test.dart @@ -0,0 +1,48 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/date_picker/show_date_picker.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can show date picker', (WidgetTester tester) async { + const String datePickerTitle = 'Select date'; + const String initialDate = 'Sun, Jul 25'; + + await tester.pumpWidget(const example.DatePickerApp()); + + // The date picker is not shown initially. + expect(find.text(datePickerTitle), findsNothing); + expect(find.text(initialDate), findsNothing); + + expect(find.text('No date selected'), findsOneWidget); + expect(find.byType(OutlinedButton), findsOneWidget); + + // Tap the button to show the date picker. + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + + // The initial date is shown. + expect(find.text(datePickerTitle), findsOneWidget); + expect(find.text(initialDate), findsOneWidget); + + // Tap another date to select it. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + + // The selected date is shown. + expect(find.text(datePickerTitle), findsOneWidget); + expect(find.text('Fri, Jul 30'), findsOneWidget); + + // Tap OK to confirm the selection and close the date picker. + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // The date picker is closed and the selected date is shown. + expect(find.text(datePickerTitle), findsNothing); + expect(find.text('30/7/2021'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/date_picker/show_date_range_picker.0_test.dart b/packages/material_ui/material_ui_examples/test/date_picker/show_date_range_picker.0_test.dart new file mode 100644 index 000000000000..83881a206181 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/date_picker/show_date_range_picker.0_test.dart @@ -0,0 +1,50 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/date_picker/show_date_range_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can show date range picker', (WidgetTester tester) async { + const String datePickerTitle = 'Select range'; + + await tester.pumpWidget(const example.DatePickerApp()); + + // The date range picker is not shown initially. + expect(find.text(datePickerTitle), findsNothing); + expect(find.text('Jan 1'), findsNothing); + expect(find.text('Jan 5, 2021'), findsNothing); + + // Tap the button to show the date range picker. + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + + // The date range picker shows initial date range. + expect(find.text(datePickerTitle), findsOneWidget); + expect(find.text('Jan 1'), findsOneWidget); + expect(find.text('Jan 5, 2021'), findsOneWidget); + + // Tap to select new date range. + await tester.tap(find.text('18').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('22').first); + await tester.pumpAndSettle(); + + // The selected date range is shown. + expect(find.text(datePickerTitle), findsOneWidget); + expect(find.text('Jan 18'), findsOneWidget); + expect(find.text('Jan 22, 2021'), findsOneWidget); + + // Tap Save to confirm the selection and close the date range picker. + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // The date range picker is closed. + expect(find.text(datePickerTitle), findsNothing); + expect(find.text('Jan 18'), findsNothing); + expect(find.text('Jan 22, 2021'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/adaptive_alert_dialog.0_test.dart b/packages/material_ui/material_ui_examples/test/dialog/adaptive_alert_dialog.0_test.dart new file mode 100644 index 000000000000..3b814f21abb7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/adaptive_alert_dialog.0_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dialog/adaptive_alert_dialog.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show Adaptive Alert dialog', (WidgetTester tester) async { + const String dialogTitle = 'AlertDialog Title'; + await tester.pumpWidget(const example.AdaptiveAlertDialogApp()); + + expect(find.text(dialogTitle), findsNothing); + + await tester.tap(find.widgetWithText(TextButton, 'Show Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/alert_dialog.0_test.dart b/packages/material_ui/material_ui_examples/test/dialog/alert_dialog.0_test.dart new file mode 100644 index 000000000000..220376aeae7c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/alert_dialog.0_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dialog/alert_dialog.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show Alert dialog', (WidgetTester tester) async { + const String dialogTitle = 'AlertDialog Title'; + await tester.pumpWidget(const example.AlertDialogExampleApp()); + + expect(find.text(dialogTitle), findsNothing); + + await tester.tap(find.widgetWithText(TextButton, 'Show Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/alert_dialog.1_test.dart b/packages/material_ui/material_ui_examples/test/dialog/alert_dialog.1_test.dart new file mode 100644 index 000000000000..52c1b7108594 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/alert_dialog.1_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dialog/alert_dialog.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show Alert dialog', (WidgetTester tester) async { + const String dialogTitle = 'AlertDialog Title'; + await tester.pumpWidget(const example.AlertDialogExampleApp()); + + expect(find.text(dialogTitle), findsNothing); + + await tester.tap(find.widgetWithText(TextButton, 'Show Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/dialog.0_test.dart b/packages/material_ui/material_ui_examples/test/dialog/dialog.0_test.dart new file mode 100644 index 000000000000..999c573fca99 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/dialog.0_test.dart @@ -0,0 +1,40 @@ +// 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:material_ui_examples/dialog/dialog.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show Dialog', (WidgetTester tester) async { + const String dialogText = 'This is a typical dialog.'; + + await tester.pumpWidget(const example.DialogExampleApp()); + + expect(find.text(dialogText), findsNothing); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogText), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.text(dialogText), findsNothing); + }); + + testWidgets('Show Dialog.fullscreen', (WidgetTester tester) async { + const String dialogText = 'This is a fullscreen dialog.'; + + await tester.pumpWidget(const example.DialogExampleApp()); + + expect(find.text(dialogText), findsNothing); + + await tester.tap(find.text('Show Fullscreen Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogText), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.text(dialogText), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/show_dialog.0_test.dart b/packages/material_ui/material_ui_examples/test/dialog/show_dialog.0_test.dart new file mode 100644 index 000000000000..91a045eac958 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/show_dialog.0_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dialog/show_dialog.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show dialog', (WidgetTester tester) async { + const String dialogTitle = 'Basic dialog title'; + await tester.pumpWidget(const example.ShowDialogExampleApp()); + + expect(find.text(dialogTitle), findsNothing); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Open Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsOneWidget); + + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/show_dialog.1_test.dart b/packages/material_ui/material_ui_examples/test/dialog/show_dialog.1_test.dart new file mode 100644 index 000000000000..dfdb375e6360 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/show_dialog.1_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dialog/show_dialog.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show dialog', (WidgetTester tester) async { + const String dialogTitle = 'Basic dialog title'; + await tester.pumpWidget(const example.ShowDialogExampleApp()); + + expect(find.text(dialogTitle), findsNothing); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Open Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsOneWidget); + + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dialog/show_dialog.2_test.dart b/packages/material_ui/material_ui_examples/test/dialog/show_dialog.2_test.dart new file mode 100644 index 000000000000..724a9554eea1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dialog/show_dialog.2_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dialog/show_dialog.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show dialog', (WidgetTester tester) async { + const String dialogTitle = 'Basic dialog title'; + await tester.pumpWidget(const example.ShowDialogExampleApp()); + + expect(find.text(dialogTitle), findsNothing); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Open Dialog')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsOneWidget); + + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + expect(find.text(dialogTitle), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/divider/divider.0_test.dart b/packages/material_ui/material_ui_examples/test/divider/divider.0_test.dart new file mode 100644 index 000000000000..5672b42b2bf4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/divider/divider.0_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/divider/divider.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Horizontal Divider', (WidgetTester tester) async { + await tester.pumpWidget(const example.DividerExampleApp()); + + expect(find.byType(Divider), findsOneWidget); + + // Divider is positioned horizontally. + final Offset container = tester.getBottomLeft( + find + .descendant( + of: find.byType(example.DividerExample), + matching: find.byType(ColoredBox), + ) + .first, + ); + expect(container.dy, tester.getTopLeft(find.byType(Divider)).dy); + + final Offset subheader = tester.getTopLeft(find.text('Subheader')); + expect(subheader.dy, tester.getBottomLeft(find.byType(Divider)).dy); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/divider/divider.1_test.dart b/packages/material_ui/material_ui_examples/test/divider/divider.1_test.dart new file mode 100644 index 000000000000..804bea2826da --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/divider/divider.1_test.dart @@ -0,0 +1,22 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/divider/divider.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Horizontal Divider', (WidgetTester tester) async { + await tester.pumpWidget(const example.DividerExampleApp()); + + expect(find.byType(Divider), findsOneWidget); + + // Divider is positioned horizontally. + Offset card = tester.getBottomLeft(find.byType(Card).first); + expect(card.dy, tester.getTopLeft(find.byType(Divider)).dy); + + card = tester.getTopLeft(find.byType(Card).last); + expect(card.dy, tester.getBottomLeft(find.byType(Divider)).dy); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/divider/vertical_divider.0_test.dart b/packages/material_ui/material_ui_examples/test/divider/vertical_divider.0_test.dart new file mode 100644 index 000000000000..6fec9087bfb6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/divider/vertical_divider.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/divider/vertical_divider.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Vertical Divider', (WidgetTester tester) async { + await tester.pumpWidget(const example.VerticalDividerExampleApp()); + + expect(find.byType(VerticalDivider), findsOneWidget); + + // Divider is positioned horizontally. + Offset expanded = tester.getTopRight(find.byType(Expanded).first); + expect(expanded.dx, tester.getTopLeft(find.byType(VerticalDivider)).dx); + + expanded = tester.getTopLeft(find.byType(Expanded).last); + expect(expanded.dx, tester.getTopRight(find.byType(VerticalDivider)).dx); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/divider/vertical_divider.1_test.dart b/packages/material_ui/material_ui_examples/test/divider/vertical_divider.1_test.dart new file mode 100644 index 000000000000..bb2475bef93d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/divider/vertical_divider.1_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/divider/vertical_divider.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Vertical Divider', (WidgetTester tester) async { + await tester.pumpWidget(const example.VerticalDividerExampleApp()); + + expect(find.byType(VerticalDivider), findsOneWidget); + + // Divider is positioned vertically. + Offset card = tester.getTopRight(find.byType(Card).first); + expect(card.dx, tester.getTopLeft(find.byType(VerticalDivider)).dx); + + card = tester.getTopLeft(find.byType(Card).last); + expect(card.dx, tester.getTopRight(find.byType(VerticalDivider)).dx); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/drawer/drawer.0_test.dart b/packages/material_ui/material_ui_examples/test/drawer/drawer.0_test.dart new file mode 100644 index 000000000000..a89270b10b96 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/drawer/drawer.0_test.dart @@ -0,0 +1,47 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/drawer/drawer.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Navigation bar updates destination on tap', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DrawerApp()); + + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + + /// NavigationDestinations must be rendered + expect(find.text('Messages'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + + /// Initial index must be zero + expect(find.text('Page: '), findsOneWidget); + + /// Switch to second tab + await tester.tap( + find.ancestor(of: find.text('Messages'), matching: find.byType(InkWell)), + ); + await tester.pumpAndSettle(); + expect(find.text('Page: Messages'), findsOneWidget); + + /// Switch to third tab + await tester.tap( + find.ancestor(of: find.text('Profile'), matching: find.byType(InkWell)), + ); + await tester.pumpAndSettle(); + expect(find.text('Page: Profile'), findsOneWidget); + + /// Switch to fourth tab + await tester.tap( + find.ancestor(of: find.text('Settings'), matching: find.byType(InkWell)), + ); + await tester.pumpAndSettle(); + expect(find.text('Page: Settings'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.0_test.dart b/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.0_test.dart new file mode 100644 index 000000000000..30d95a294f33 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui_examples/dropdown/dropdown_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Select an item from DropdownButton', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DropdownButtonApp()); + + expect(find.text('One'), findsOneWidget); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Two').last); + await tester.pumpAndSettle(); + expect(find.text('Two'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.selected_item_builder.0_test.dart b/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.selected_item_builder.0_test.dart new file mode 100644 index 000000000000..9ff8f6543410 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.selected_item_builder.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui_examples/dropdown/dropdown_button.selected_item_builder.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Select an item from DropdownButton', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DropdownButtonApp()); + + expect(find.text('NYC'), findsOneWidget); + + await tester.tap(find.text('NYC')); + await tester.pumpAndSettle(); + await tester.tap(find.text('San Francisco').last); + await tester.pumpAndSettle(); + expect(find.text('SF'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.style.0_test.dart b/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.style.0_test.dart new file mode 100644 index 000000000000..8f5cdb2e0bda --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown/dropdown_button.style.0_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui_examples/dropdown/dropdown_button.style.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Select an item from DropdownButton', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DropdownButtonApp()); + + expect(find.text('One'), findsOneWidget); + expect(find.text('One', skipOffstage: false), findsNWidgets(4)); + + await tester.tap(find.text('One').first); + await tester.pumpAndSettle(); + expect(find.text('Two'), findsOneWidget); + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + expect(find.text('Two'), findsOneWidget); + expect(find.text('Two', skipOffstage: false), findsNWidgets(4)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.0_test.dart b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.0_test.dart new file mode 100644 index 000000000000..0c2a5f56492b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.0_test.dart @@ -0,0 +1,113 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dropdown_menu/dropdown_menu.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget(const example.DropdownMenuExample()); + + expect(find.text('You selected a Blue Smile'), findsNothing); + + final Finder colorMenu = find.byType(DropdownMenu<example.ColorLabel>); + final Finder iconMenu = find.byType(DropdownMenu<example.IconLabel>); + expect(colorMenu, findsOneWidget); + expect(iconMenu, findsOneWidget); + + Finder findMenuItem(String label) { + return find.widgetWithText(MenuItemButton, label).last; + } + + await tester.tap(colorMenu); + await tester.pumpAndSettle(); + expect(findMenuItem('Blue'), findsOneWidget); + expect(findMenuItem('Pink'), findsOneWidget); + expect(findMenuItem('Green'), findsOneWidget); + expect(findMenuItem('Orange'), findsOneWidget); + expect(findMenuItem('Grey'), findsOneWidget); + + await tester.tap(findMenuItem('Blue')); + + // The DropdownMenu's onSelected callback is delayed + // with SchedulerBinding.instance.addPostFrameCallback + // to give the focus a chance to return to where it was + // before the menu appeared. The pumpAndSettle() + // give the callback a chance to run. + await tester.pumpAndSettle(); + + await tester.tap(iconMenu); + await tester.pumpAndSettle(); + expect(findMenuItem('Smile'), findsOneWidget); + expect(findMenuItem('Cloud'), findsOneWidget); + expect(findMenuItem('Brush'), findsOneWidget); + expect(findMenuItem('Heart'), findsOneWidget); + + await tester.tap(findMenuItem('Smile')); + await tester.pumpAndSettle(); + + expect(find.text('You selected a Blue Smile'), findsOneWidget); + }); + + testWidgets('DropdownMenu has focus when tapping on the text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DropdownMenuExample()); + + // Make sure the dropdown menus are there. + final Finder colorMenu = find.byType(DropdownMenu<example.ColorLabel>); + final Finder iconMenu = find.byType(DropdownMenu<example.IconLabel>); + expect(colorMenu, findsOneWidget); + expect(iconMenu, findsOneWidget); + + // Tap on the color menu and make sure it is focused. + await tester.tap(colorMenu); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(colorMenu)).hasFocus, isTrue); + + // Tap on the icon menu and make sure it is focused. + await tester.tap(iconMenu); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(iconMenu)).hasFocus, isTrue); + }); + + testWidgets('DropdownMenu on small screen', (WidgetTester tester) async { + await tester.pumpWidget(const example.DropdownMenuExample()); + + final Finder colorMenu = find.byType(DropdownMenu<example.ColorLabel>); + final Finder iconMenu = find.byType(DropdownMenu<example.IconLabel>); + expect(colorMenu, findsOneWidget); + expect(iconMenu, findsOneWidget); + + Finder findMenuItem(String label) { + return find.widgetWithText(MenuItemButton, label).last; + } + + await tester.tap(colorMenu); + await tester.pumpAndSettle(); + final Finder menuBlue = findMenuItem('Blue'); + await tester.ensureVisible(menuBlue); + await tester.tap(menuBlue); + await tester.pumpAndSettle(); + + await tester.tap(iconMenu); + await tester.pumpAndSettle(); + final Finder menuSmile = findMenuItem('Smile'); + await tester.ensureVisible(menuSmile); + await tester.tap(menuSmile); + await tester.pumpAndSettle(); + + expect(find.text('You selected a Blue Smile'), findsOneWidget); + + // Resize the screen to small screen and make sure no overflowed error appears. + tester.view.physicalSize = const Size(200, 160); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.1_test.dart b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.1_test.dart new file mode 100644 index 000000000000..359644906e3a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.1_test.dart @@ -0,0 +1,58 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dropdown_menu/dropdown_menu.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The DropdownMenu should display a menu with the list of entries the user can select', + (WidgetTester tester) async { + await tester.pumpWidget(const example.DropdownMenuApp()); + + expect(find.widgetWithText(TextField, 'One'), findsOne); + final Finder menu = find.byType(DropdownMenu<String>); + expect(menu, findsOne); + + Finder findMenuItem(String label) { + return find.widgetWithText(MenuItemButton, label).last; + } + + await tester.tap(menu); + await tester.pumpAndSettle(); + expect(findMenuItem('One'), findsOne); + expect(findMenuItem('Two'), findsOne); + expect(findMenuItem('Three'), findsOne); + expect(findMenuItem('Four'), findsOne); + + await tester.tap(findMenuItem('Two')); + + // The DropdownMenu's onSelected callback is delayed + // with SchedulerBinding.instance.addPostFrameCallback + // to give the focus a chance to return to where it was + // before the menu appeared. The pumpAndSettle() + // give the callback a chance to run. + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'Two'), findsOne); + }, + ); + + testWidgets('DropdownMenu has focus when tapping on the text field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.DropdownMenuApp()); + + // Make sure the dropdown menus are there. + final Finder menu = find.byType(DropdownMenu<String>); + expect(menu, findsOne); + + // Tap on the menu and make sure it is focused. + await tester.tap(menu); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(menu)).hasFocus, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.2_test.dart b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.2_test.dart new file mode 100644 index 000000000000..67a9a1fd363a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu.2_test.dart @@ -0,0 +1,57 @@ +// 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:ui'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui_examples/dropdown_menu/dropdown_menu.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DropdownMenu cursor behavior', (WidgetTester tester) async { + await tester.pumpWidget(const example.DropdownMenuApp()); + + Finder textFieldFinder(int index) { + return find.byType(TextField).at(index); + } + + // Hover over the "enabled and requestFocusOnTap set to true" text field. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.moveTo(tester.getCenter(textFieldFinder(0))); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Hover over the "enabled and requestFocusOnTap set to false" text field. + await gesture.moveTo(tester.getCenter(textFieldFinder(1))); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Hover over the "disabled and requestFocusOnTap set to true" text field. + await gesture.moveTo(tester.getCenter(textFieldFinder(2))); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Hover over the "disabled and requestFocusOnTap set to false" text field. + await gesture.moveTo(tester.getCenter(textFieldFinder(3))); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu_entry_label_widget.0_test.dart b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu_entry_label_widget.0_test.dart new file mode 100644 index 000000000000..a2aef6e50e4d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/dropdown_menu/dropdown_menu_entry_label_widget.0_test.dart @@ -0,0 +1,40 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/dropdown_menu/dropdown_menu_entry_label_widget.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DropdownEntryLabelWidget appears', (WidgetTester tester) async { + await tester.pumpWidget( + const example.DropdownMenuEntryLabelWidgetExampleApp(), + ); + + const String longText = + 'is a color that sings of hope, A hue that shines like gold. It is the color of dreams, A shade that never grows old.'; + Finder findMenuItemText(String label) { + final String labelText = '$label $longText\n'; + return find + .descendant( + of: find.widgetWithText(MenuItemButton, labelText), + matching: find.byType(Text), + ) + .last; + } + + // Open the menu + await tester.tap(find.byType(TextField)); + expect(findMenuItemText('Blue'), findsOneWidget); + expect(findMenuItemText('Pink'), findsOneWidget); + expect(findMenuItemText('Green'), findsOneWidget); + expect(findMenuItemText('Yellow'), findsOneWidget); + expect(findMenuItemText('Grey'), findsOneWidget); + + // Close the menu + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/elevated_button/elevated_button.0_test.dart b/packages/material_ui/material_ui_examples/test/elevated_button/elevated_button.0_test.dart new file mode 100644 index 000000000000..752736764b00 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/elevated_button/elevated_button.0_test.dart @@ -0,0 +1,34 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/elevated_button/elevated_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ElevatedButton Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.ElevatedButtonExampleApp()); + + expect( + find.widgetWithText(AppBar, 'ElevatedButton Sample'), + findsOneWidget, + ); + final Finder disabledButton = find.widgetWithText( + ElevatedButton, + 'Disabled', + ); + expect(disabledButton, findsOneWidget); + expect( + tester.widget<ElevatedButton>(disabledButton).onPressed.runtimeType, + Null, + ); + final Finder enabledButton = find.widgetWithText(ElevatedButton, 'Enabled'); + expect(enabledButton, findsOneWidget); + expect( + tester.widget<ElevatedButton>(enabledButton).onPressed.runtimeType, + VoidCallback, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/expansible/expansible.0_test.dart b/packages/material_ui/material_ui_examples/test/expansible/expansible.0_test.dart new file mode 100644 index 000000000000..9aa4ecd489c3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/expansible/expansible.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui_examples/expansible/expansible.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Expansible can be expanded', (WidgetTester tester) async { + await tester.pumpWidget(const example.ExpansibleApp()); + + // Verify that the expanded content is not visible initially. + expect(find.text('Hidden content revealed!'), findsNothing); + + // Tap the header to expand. + await tester.tap(find.text('Tap to Expand')); + await tester.pumpAndSettle(); + + // Verify that the expanded content is now visible. + expect(find.text('Hidden content revealed!'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/expansion_panel/expansion_panel_list.0_test.dart b/packages/material_ui/material_ui_examples/test/expansion_panel/expansion_panel_list.0_test.dart new file mode 100644 index 000000000000..3cac5c630b30 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/expansion_panel/expansion_panel_list.0_test.dart @@ -0,0 +1,89 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/expansion_panel/expansion_panel_list.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ExpansionPanel can be expanded', (WidgetTester tester) async { + await tester.pumpWidget(const example.ExpansionPanelListExampleApp()); + + // Verify the first tile is collapsed. + expect( + tester.widget<ExpandIcon>(find.byType(ExpandIcon).first).isExpanded, + false, + ); + + // Tap to expand the first tile. + await tester.tap(find.byType(ExpandIcon).first); + await tester.pumpAndSettle(); + + // Verify that the first tile is expanded. + expect( + tester.widget<ExpandIcon>(find.byType(ExpandIcon).first).isExpanded, + true, + ); + }); + + testWidgets('Tap to delete a ExpansionPanel', (WidgetTester tester) async { + const int index = 3; + + await tester.pumpWidget(const example.ExpansionPanelListExampleApp()); + + expect(find.widgetWithText(ListTile, 'Panel $index'), findsOneWidget); + expect( + tester.widget<ExpandIcon>(find.byType(ExpandIcon).at(index)).isExpanded, + false, + ); + + // Tap to expand the tile at index 3. + await tester.tap(find.byType(ExpandIcon).at(index)); + await tester.pumpAndSettle(); + + expect( + tester.widget<ExpandIcon>(find.byType(ExpandIcon).at(index)).isExpanded, + true, + ); + + // Tap to delete the tile at index 3. + await tester.tap(find.byIcon(Icons.delete).at(index)); + await tester.pumpAndSettle(); + + // Verify that the tile at index 3 is deleted. + expect(find.widgetWithText(ListTile, 'Panel $index'), findsNothing); + }); + + testWidgets('ExpansionPanelList is scrollable', (WidgetTester tester) async { + await tester.pumpWidget(const example.ExpansionPanelListExampleApp()); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + + // Expand all the tiles. + for (int i = 0; i < 8; i++) { + await tester.tap(find.byType(ExpandIcon).at(i)); + } + await tester.pumpAndSettle(); + + // Check panel 3 tile position. + Offset tilePosition = tester.getBottomLeft( + find.widgetWithText(ListTile, 'Panel 3'), + ); + expect(tilePosition.dy, 656.0); + + // Scroll up. + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + + // Verify panel 3 tile position is updated after scrolling. + tilePosition = tester.getBottomLeft( + find.widgetWithText(ListTile, 'Panel 3'), + ); + expect(tilePosition.dy, 376.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0_test.dart b/packages/material_ui/material_ui_examples/test/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0_test.dart new file mode 100644 index 000000000000..2ff97fae6d6b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0_test.dart @@ -0,0 +1,48 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/expansion_panel/expansion_panel_list.expansion_panel_list_radio.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ExpansionPanelList.radio can expand one item at the time', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ExpansionPanelListRadioExampleApp()); + + expect( + find.widgetWithText(AppBar, 'ExpansionPanelList.radio Sample'), + findsOne, + ); + expect(find.byType(ExpansionPanelList), findsOne); + for (int i = 0; i < 8; i++) { + expect(find.widgetWithText(ListTile, 'Panel $i'), findsOne); + } + + // The default expanded item is 2. + for (int i = 0; i < 8; i++) { + expect( + tester.widget<ExpandIcon>(find.byType(ExpandIcon).at(i)).isExpanded, + i == 2, + reason: 'Only the panel 2 should be expanded', + ); + } + + // Open all the panels one by one. + for (int index = 0; index < 8; index++) { + await tester.ensureVisible(find.byType(ExpandIcon).at(index)); + await tester.tap(find.byType(ExpandIcon).at(index)); + await tester.pumpAndSettle(); + for (int i = 0; i < 8; i++) { + expect( + tester.widget<ExpandIcon>(find.byType(ExpandIcon).at(i)).isExpanded, + i == index, + reason: 'Only the panel $index should be expanded', + ); + } + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.0_test.dart b/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.0_test.dart new file mode 100644 index 000000000000..9379f5f68fa4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.0_test.dart @@ -0,0 +1,41 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/expansion_tile/expansion_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('When expansion tiles are expanded tile numbers are revealed', ( + WidgetTester tester, + ) async { + const int totalTiles = 3; + + await tester.pumpWidget(const example.ExpansionTileApp()); + + expect(find.byType(ExpansionTile), findsNWidgets(totalTiles)); + + const String tileOne = 'This is tile number 1'; + expect(find.text(tileOne), findsNothing); + + await tester.tap(find.text('ExpansionTile 1')); + await tester.pumpAndSettle(); + expect(find.text(tileOne), findsOneWidget); + + const String tileTwo = 'This is tile number 2'; + expect(find.text(tileTwo), findsNothing); + + await tester.tap(find.text('ExpansionTile 2')); + await tester.pumpAndSettle(); + expect(find.text(tileTwo), findsOneWidget); + + const String tileThree = 'This is tile number 3'; + expect(find.text(tileThree), findsNothing); + + await tester.tap(find.text('ExpansionTile 3')); + await tester.pumpAndSettle(); + expect(find.text(tileThree), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.1_test.dart b/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.1_test.dart new file mode 100644 index 000000000000..b0cb8ef20c0b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.1_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui_examples/expansion_tile/expansion_tile.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Test the basics of ExpansionTileControllerApp', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ExpansionTileControllerApp()); + + expect(find.text('ExpansionTile Contents'), findsNothing); + expect(find.text('Collapse This Tile'), findsNothing); + + await tester.tap(find.text('Expand/Collapse the Tile Above')); + await tester.pumpAndSettle(); + expect(find.text('ExpansionTile Contents'), findsOneWidget); + await tester.tap(find.text('Expand/Collapse the Tile Above')); + await tester.pumpAndSettle(); + expect(find.text('ExpansionTile Contents'), findsNothing); + + await tester.tap(find.text('ExpansionTile with implicit controller.')); + await tester.pumpAndSettle(); + expect(find.text('Collapse This Tile'), findsOneWidget); + await tester.tap(find.text('Collapse This Tile')); + await tester.pumpAndSettle(); + expect(find.text('Collapse This Tile'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.2_test.dart b/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.2_test.dart new file mode 100644 index 000000000000..a0cac475b1be --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/expansion_tile/expansion_tile.2_test.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/expansion_tile/expansion_tile.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'ExpansionTile animation can be customized using AnimationStyle', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ExpansionTileAnimationStyleApp()); + + double getHeight(WidgetTester tester) { + return tester.getSize(find.byType(ExpansionTile)).height; + } + + expect(getHeight(tester), 58.0); + + // Test the default animation style. + await tester.tap(find.text('ExpansionTile')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(getHeight(tester), closeTo(93.4, 0.1)); + + await tester.pumpAndSettle(); + + expect(getHeight(tester), 170.0); + + // Tap to collapse. + await tester.tap(find.text('ExpansionTile')); + await tester.pumpAndSettle(); + + // Test the custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('ExpansionTile')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(getHeight(tester), closeTo(59.2, 0.1)); + + await tester.pumpAndSettle(); + + expect(getHeight(tester), 170.0); + + // Tap to collapse. + await tester.tap(find.text('ExpansionTile')); + await tester.pumpAndSettle(); + + // Test the no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('ExpansionTile')); + await tester.pump(); + + expect(getHeight(tester), 170.0); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/filled_button/filled_button.0_test.dart b/packages/material_ui/material_ui_examples/test/filled_button/filled_button.0_test.dart new file mode 100644 index 000000000000..bfb1928e2571 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/filled_button/filled_button.0_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/filled_button/filled_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('FilledButton Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.FilledButtonApp()); + + expect(find.widgetWithText(AppBar, 'FilledButton Sample'), findsOneWidget); + final Finder disabledButton = find.widgetWithText(FilledButton, 'Disabled'); + expect(disabledButton, findsNWidgets(2)); + expect( + tester.widget<FilledButton>(disabledButton.first).onPressed.runtimeType, + Null, + ); + expect( + tester.widget<FilledButton>(disabledButton.last).onPressed.runtimeType, + Null, + ); + final Finder enabledButton = find.widgetWithText(FilledButton, 'Enabled'); + expect(enabledButton, findsNWidgets(2)); + expect( + tester.widget<FilledButton>(enabledButton.first).onPressed.runtimeType, + VoidCallback, + ); + expect( + tester.widget<FilledButton>(enabledButton.last).onPressed.runtimeType, + VoidCallback, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/filter_chip/filter_chip.0_test.dart b/packages/material_ui/material_ui_examples/test/filter_chip/filter_chip.0_test.dart new file mode 100644 index 000000000000..953f806620b4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/filter_chip/filter_chip.0_test.dart @@ -0,0 +1,35 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/filter_chip/filter_chip.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Filter exercises using FilterChip', (WidgetTester tester) async { + const String baseText = 'Looking for: '; + + await tester.pumpWidget(const example.ChipApp()); + + expect(find.text(baseText), findsOneWidget); + + FilterChip filterChip = tester.widget(find.byType(FilterChip).at(2)); + expect(filterChip.selected, false); + + await tester.tap(find.byType(FilterChip).at(2)); + await tester.pumpAndSettle(); + filterChip = tester.widget(find.byType(FilterChip).at(2)); + expect(filterChip.selected, true); + + expect(find.text('${baseText}cycling'), findsOneWidget); + + await tester.tap(find.byType(FilterChip).at(3)); + await tester.pumpAndSettle(); + filterChip = tester.widget(find.byType(FilterChip).at(3)); + expect(filterChip.selected, true); + + expect(find.text('${baseText}cycling, hiking'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/flexible_space_bar/flexible_space_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/flexible_space_bar/flexible_space_bar.0_test.dart new file mode 100644 index 000000000000..c27c07dbc2ab --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/flexible_space_bar/flexible_space_bar.0_test.dart @@ -0,0 +1,45 @@ +// 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:io'; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/flexible_space_bar/flexible_space_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // The app being tested loads images via HTTP which the test + // framework defeats by default. + setUpAll(() { + HttpOverrides.global = null; + }); + + testWidgets( + 'The app bar stretches when over-scrolled', + (WidgetTester tester) async { + await tester.pumpWidget(const example.FlexibleSpaceBarExampleApp()); + + expect(find.text('Flight Report'), findsOne); + + expect(find.widgetWithText(ListTile, 'Sunday'), findsOne); + expect(find.widgetWithText(ListTile, 'Monday'), findsOne); + expect(find.text('sunny, h: 80, l: 65'), findsExactly(2)); + expect(find.byIcon(Icons.wb_sunny), findsExactly(2)); + + final Finder appBarContainer = find.byType(Image); + final Size sizeBeforeScroll = tester.getSize(appBarContainer); + final Offset target = tester.getCenter(find.byType(ListTile).first); + final TestGesture gesture = await tester.startGesture(target); + await gesture.moveBy(const Offset(0.0, 100.0)); + await tester.pump(const Duration(milliseconds: 10)); + await gesture.up(); + final Size sizeAfterScroll = tester.getSize(appBarContainer); + + expect(sizeBeforeScroll.height, lessThan(sizeAfterScroll.height)); + // Verifies ScrollBehavior.dragDevices is correctly set. + }, + variant: TargetPlatformVariant.all(), + ); +} diff --git a/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.0_test.dart b/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.0_test.dart new file mode 100644 index 000000000000..349e748a42bf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.0_test.dart @@ -0,0 +1,46 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/floating_action_button/floating_action_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('FloatingActionButton', (WidgetTester tester) async { + await tester.pumpWidget(const example.FloatingActionButtonExampleApp()); + + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byIcon(Icons.navigation), findsOneWidget); + + RawMaterialButton getRawMaterialButtonWidget() { + return tester.widget<RawMaterialButton>(find.byType(RawMaterialButton)); + } + + Color? getIconColor() { + final RichText iconRichText = tester.widget<RichText>( + find.descendant( + of: find.byIcon(Icons.navigation), + matching: find.byType(RichText), + ), + ); + return iconRichText.text.style?.color; + } + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); // Wait for the animation to finish. + expect(getRawMaterialButtonWidget().fillColor, Colors.green); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); // Wait for the animation to finish. + expect(getRawMaterialButtonWidget().fillColor, Colors.green); + expect(getIconColor(), Colors.white); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); // Wait for the animation to finish. + expect(getRawMaterialButtonWidget().fillColor, Colors.green); + expect(getIconColor(), Colors.white); + expect(getRawMaterialButtonWidget().shape, const CircleBorder()); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.1_test.dart b/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.1_test.dart new file mode 100644 index 000000000000..7ff1a480bb45 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.1_test.dart @@ -0,0 +1,86 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/floating_action_button/floating_action_button.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('FloatingActionButton variants', (WidgetTester tester) async { + RawMaterialButton getRawMaterialButtonWidget(Finder finder) { + return tester.widget<RawMaterialButton>(finder); + } + + await tester.pumpWidget(const example.FloatingActionButtonExampleApp()); + + final ThemeData theme = ThemeData(); + + expect(find.byType(FloatingActionButton), findsNWidgets(4)); + expect(find.byIcon(Icons.add), findsNWidgets(4)); + + final Finder smallFabMaterialButton = find.byType(RawMaterialButton).at(0); + final RenderBox smallFabRenderBox = tester.renderObject( + smallFabMaterialButton, + ); + expect(smallFabRenderBox.size, const Size(48.0, 48.0)); + expect( + getRawMaterialButtonWidget(smallFabMaterialButton).fillColor, + theme.colorScheme.primaryContainer, + ); + expect( + getRawMaterialButtonWidget(smallFabMaterialButton).shape, + RoundedRectangleBorder(borderRadius: .circular(12.0)), + ); + + final Finder regularFABMaterialButton = find + .byType(RawMaterialButton) + .at(1); + final RenderBox regularFABRenderBox = tester.renderObject( + regularFABMaterialButton, + ); + expect(regularFABRenderBox.size, const Size(56.0, 56.0)); + expect( + getRawMaterialButtonWidget(regularFABMaterialButton).fillColor, + theme.colorScheme.primaryContainer, + ); + expect( + getRawMaterialButtonWidget(regularFABMaterialButton).shape, + RoundedRectangleBorder(borderRadius: .circular(16.0)), + ); + + final Finder largeFABMaterialButton = find.byType(RawMaterialButton).at(2); + final RenderBox largeFABRenderBox = tester.renderObject( + largeFABMaterialButton, + ); + expect(largeFABRenderBox.size, const Size(96.0, 96.0)); + expect( + getRawMaterialButtonWidget(largeFABMaterialButton).fillColor, + theme.colorScheme.primaryContainer, + ); + expect( + getRawMaterialButtonWidget(largeFABMaterialButton).shape, + RoundedRectangleBorder(borderRadius: .circular(28.0)), + ); + + final Finder extendedFABMaterialButton = find + .byType(RawMaterialButton) + .at(3); + final RenderBox extendedFABRenderBox = tester.renderObject( + extendedFABMaterialButton, + ); + expect( + extendedFABRenderBox.size, + within(distance: 0.01, from: const Size(110.3, 56.0)), + ); + expect( + getRawMaterialButtonWidget(extendedFABMaterialButton).fillColor, + theme.colorScheme.primaryContainer, + ); + expect( + getRawMaterialButtonWidget(extendedFABMaterialButton).shape, + RoundedRectangleBorder(borderRadius: .circular(16.0)), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.2_test.dart b/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.2_test.dart new file mode 100644 index 000000000000..95836de1a8cf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/floating_action_button/floating_action_button.2_test.dart @@ -0,0 +1,35 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/floating_action_button/floating_action_button.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('FloatingActionButton variants', (WidgetTester tester) async { + await tester.pumpWidget(const example.FloatingActionButtonExampleApp()); + + FloatingActionButton getFAB(Finder finder) { + return tester.widget<FloatingActionButton>(finder); + } + + final ColorScheme colorScheme = ThemeData().colorScheme; + + // Test the FAB with surface color mapping. + FloatingActionButton fab = getFAB(find.byType(FloatingActionButton).at(0)); + expect(fab.foregroundColor, colorScheme.primary); + expect(fab.backgroundColor, colorScheme.surface); + + // Test the FAB with secondary color mapping. + fab = getFAB(find.byType(FloatingActionButton).at(1)); + expect(fab.foregroundColor, colorScheme.onSecondaryContainer); + expect(fab.backgroundColor, colorScheme.secondaryContainer); + + // Test the FAB with tertiary color mapping. + fab = getFAB(find.byType(FloatingActionButton).at(2)); + expect(fab.foregroundColor, colorScheme.onTertiaryContainer); + expect(fab.backgroundColor, colorScheme.tertiaryContainer); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/floating_action_button_location/standard_fab_location.0_test.dart b/packages/material_ui/material_ui_examples/test/floating_action_button_location/standard_fab_location.0_test.dart new file mode 100644 index 000000000000..5e33a2fe83bc --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/floating_action_button_location/standard_fab_location.0_test.dart @@ -0,0 +1,20 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/floating_action_button_location/standard_fab_location.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The FloatingActionButton should have a right padding', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.StandardFabLocationExampleApp()); + + expect(find.widgetWithIcon(FloatingActionButton, Icons.add), findsOne); + final double right = tester.getCenter(find.byType(FloatingActionButton)).dx; + expect(right, closeTo(706, 1)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/icon_alignment/icon_alignment.0_test.dart b/packages/material_ui/material_ui_examples/test/icon_alignment/icon_alignment.0_test.dart new file mode 100644 index 000000000000..40c7b12192ac --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/icon_alignment/icon_alignment.0_test.dart @@ -0,0 +1,104 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/icon_alignment/icon_alignment.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets("IconAlignment updates buttons' icons alignment", ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.IconAlignmentApp()); + + Finder findButtonMaterial(String text) { + return find + .ancestor(of: find.text(text), matching: find.byType(Material)) + .first; + } + + void expectedLeftIconPosition({ + required double iconOffset, + required double textButtonIconOffset, + }) { + expect( + tester.getTopLeft(findButtonMaterial('ElevatedButton')).dx, + tester.getTopLeft(find.byIcon(Icons.sunny)).dx - iconOffset, + ); + expect( + tester.getTopLeft(findButtonMaterial('FilledButton')).dx, + tester.getTopLeft(find.byIcon(Icons.beach_access)).dx - iconOffset, + ); + expect( + tester.getTopLeft(findButtonMaterial('FilledButton Tonal')).dx, + tester.getTopLeft(find.byIcon(Icons.cloud)).dx - iconOffset, + ); + expect( + tester.getTopLeft(findButtonMaterial('OutlinedButton')).dx, + tester.getTopLeft(find.byIcon(Icons.light)).dx - iconOffset, + ); + expect( + tester.getTopLeft(findButtonMaterial('TextButton')).dx, + tester.getTopLeft(find.byIcon(Icons.flight_takeoff)).dx - + textButtonIconOffset, + ); + } + + void expectedRightIconPosition({ + required double iconOffset, + required double textButtonIconOffset, + }) { + expect( + tester.getTopRight(findButtonMaterial('ElevatedButton')).dx, + tester.getTopRight(find.byIcon(Icons.sunny)).dx + iconOffset, + ); + expect( + tester.getTopRight(findButtonMaterial('FilledButton')).dx, + tester.getTopRight(find.byIcon(Icons.beach_access)).dx + iconOffset, + ); + expect( + tester.getTopRight(findButtonMaterial('FilledButton Tonal')).dx, + tester.getTopRight(find.byIcon(Icons.cloud)).dx + iconOffset, + ); + expect( + tester.getTopRight(findButtonMaterial('OutlinedButton')).dx, + tester.getTopRight(find.byIcon(Icons.light)).dx + iconOffset, + ); + expect( + tester.getTopRight(findButtonMaterial('TextButton')).dx, + tester.getTopRight(find.byIcon(Icons.flight_takeoff)).dx + + textButtonIconOffset, + ); + } + + // Test initial icon alignment in LTR. + expectedLeftIconPosition(iconOffset: 16, textButtonIconOffset: 12); + + // Update icon alignment to end. + await tester.tap(find.text('end')); + await tester.pumpAndSettle(); + + // Test icon alignment end in LTR. + expectedRightIconPosition(iconOffset: 24, textButtonIconOffset: 16); + + // Reset icon alignment to start. + await tester.tap(find.text('start')); + await tester.pumpAndSettle(); + + // Change text direction to RTL. + await tester.tap(find.text('RTL')); + await tester.pumpAndSettle(); + + // Test icon alignment start in LTR. + expectedRightIconPosition(iconOffset: 16, textButtonIconOffset: 12); + + // Update icon alignment to end. + await tester.tap(find.text('end')); + await tester.pumpAndSettle(); + + // Test icon alignment end in LTR. + expectedLeftIconPosition(iconOffset: 24, textButtonIconOffset: 16); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/icon_button/icon_button.0_test.dart b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.0_test.dart new file mode 100644 index 000000000000..6ec19b850ec9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.0_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/icon_button/icon_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('IconButton increments volume when tapped', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.IconButtonExampleApp()); + + expect(find.byIcon(Icons.volume_up), findsOneWidget); + expect(find.text('Volume : 0.0'), findsOneWidget); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(find.text('Volume : 10.0'), findsOneWidget); + }); + + testWidgets('IconButton shows tooltip when long pressed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.IconButtonExampleApp()); + + expect(find.text('Increase volume by 10'), findsNothing); + await tester.longPress(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(find.text('Increase volume by 10'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/icon_button/icon_button.1_test.dart b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.1_test.dart new file mode 100644 index 000000000000..4683aa269225 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.1_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/icon_button/icon_button.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('IconButton', (WidgetTester tester) async { + await tester.pumpWidget(const example.IconButtonExampleApp()); + + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.android), findsOneWidget); + final Ink ink = tester.widget<Ink>( + find.ancestor(of: find.byIcon(Icons.android), matching: find.byType(Ink)), + ); + + final ShapeDecoration decoration = ink.decoration! as ShapeDecoration; + expect(decoration.color, Colors.lightBlue); + expect(decoration.shape, const CircleBorder()); + + final IconButton iconButton = ink.child! as IconButton; + expect(iconButton.color, Colors.white); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/icon_button/icon_button.2_test.dart b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.2_test.dart new file mode 100644 index 000000000000..9e387c33d745 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.2_test.dart @@ -0,0 +1,31 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/icon_button/icon_button.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('IconButton Types', (WidgetTester tester) async { + await tester.pumpWidget(const example.IconButtonApp()); + expect( + find.widgetWithIcon(IconButton, Icons.filter_drama), + findsNWidgets(8), + ); + final Finder iconButtons = find.widgetWithIcon( + IconButton, + Icons.filter_drama, + ); + for (int i = 0; i <= 3; i++) { + expect( + tester.widget<IconButton>(iconButtons.at(i)).onPressed is VoidCallback, + isTrue, + ); + } + for (int i = 4; i <= 7; i++) { + expect(tester.widget<IconButton>(iconButtons.at(i)).onPressed, isNull); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/icon_button/icon_button.3_test.dart b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.3_test.dart new file mode 100644 index 000000000000..45304edfc626 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/icon_button/icon_button.3_test.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/icon_button/icon_button.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('It should select and unselect the icon buttons', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.IconButtonToggleApp()); + + expect( + find.widgetWithIcon(IconButton, Icons.settings_outlined), + findsExactly(8), + ); + final Finder unselectedIconButtons = find.widgetWithIcon( + IconButton, + Icons.settings_outlined, + ); + for (int i = 0; i <= 6; i++) { + final IconButton button = tester.widget<IconButton>( + unselectedIconButtons.at(i), + ); + expect(button.onPressed, i.isEven ? isA<VoidCallback>() : isNull); + expect(button.isSelected, isFalse); + } + + // Select the icons buttons. + for (int i = 0; i <= 3; i++) { + await tester.tap(unselectedIconButtons.at(2 * i)); + } + await tester.pump(); + + expect(find.widgetWithIcon(IconButton, Icons.settings), findsExactly(8)); + final Finder selectedIconButtons = find.widgetWithIcon( + IconButton, + Icons.settings, + ); + for (int i = 0; i <= 6; i++) { + final IconButton button = tester.widget<IconButton>( + selectedIconButtons.at(i), + ); + expect(button.onPressed, i.isEven ? isA<VoidCallback>() : isNull); + expect(button.isSelected, isTrue); + } + + // Unselect the icons buttons. + for (int i = 0; i <= 3; i++) { + await tester.tap(selectedIconButtons.at(2 * i)); + } + await tester.pump(); + + expect( + find.widgetWithIcon(IconButton, Icons.settings_outlined), + findsExactly(8), + ); + for (int i = 0; i <= 6; i++) { + final IconButton button = tester.widget<IconButton>( + unselectedIconButtons.at(i), + ); + expect(button.onPressed, i.isEven ? isA<VoidCallback>() : isNull); + expect(button.isSelected, isFalse); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/ink/ink.image_clip.0_test.dart b/packages/material_ui/material_ui_examples/test/ink/ink.image_clip.0_test.dart new file mode 100644 index 000000000000..3ac1a706e404 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/ink/ink.image_clip.0_test.dart @@ -0,0 +1,102 @@ +// 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:typed_data'; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/ink/ink.image_clip.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const List<int> kTransparentImage = <int>[ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + ]; + + testWidgets('Ink ancestor material is not clipped', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: example.ImageClipExample( + image: MemoryImage(Uint8List.fromList(kTransparentImage)), + ), + ), + ), + ); + + final Finder inkMaterialFinder = find.ancestor( + of: find.byType(Ink), + matching: find.byType(Material), + ); + expect( + find.ancestor(of: inkMaterialFinder, matching: find.byType(ClipRRect)), + findsNothing, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/ink/ink.image_clip.1_test.dart b/packages/material_ui/material_ui_examples/test/ink/ink.image_clip.1_test.dart new file mode 100644 index 000000000000..6c9ee435d35f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/ink/ink.image_clip.1_test.dart @@ -0,0 +1,100 @@ +// 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:typed_data'; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/ink/ink.image_clip.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const List<int> kTransparentImage = <int>[ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + ]; + + testWidgets('Ink ancestor material is clipped', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: example.ImageClipExample( + image: MemoryImage(Uint8List.fromList(kTransparentImage)), + ), + ), + ), + ); + + final Finder inkMaterialFinder = find.ancestor( + of: find.byType(Ink), + matching: find.byType(Material), + ); + expect( + find.ancestor(of: inkMaterialFinder, matching: find.byType(ClipRRect)), + findsOneWidget, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/ink_well/ink_well.0_test.dart b/packages/material_ui/material_ui_examples/test/ink_well/ink_well.0_test.dart new file mode 100644 index 000000000000..7f96b3ce0bda --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/ink_well/ink_well.0_test.dart @@ -0,0 +1,41 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/ink_well/ink_well.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Clicking on InkWell changes the Size of 50x50 AnimatedContainer to 100x100 and vice versa', + (WidgetTester tester) async { + await tester.pumpWidget(const example.InkWellExampleApp()); + expect(find.widgetWithText(AppBar, 'InkWell Sample'), findsOneWidget); + final Finder inkWell = find.byType(InkWell); + final InkWell inkWellWidget = tester.widget<InkWell>(inkWell); + final Finder animatedContainer = find.byType(AnimatedContainer); + AnimatedContainer animatedContainerWidget = tester + .widget<AnimatedContainer>(animatedContainer); + expect(inkWell, findsOneWidget); + expect(inkWellWidget.onTap.runtimeType, VoidCallback); + expect(animatedContainerWidget.constraints?.minWidth, 50); + expect(animatedContainerWidget.constraints?.minHeight, 50); + await tester.tap(inkWell); + await tester.pumpAndSettle(); + animatedContainerWidget = tester.widget<AnimatedContainer>( + animatedContainer, + ); + expect(animatedContainerWidget.constraints?.minWidth, 100); + expect(animatedContainerWidget.constraints?.minHeight, 100); + await tester.tap(inkWell); + await tester.pumpAndSettle(); + animatedContainerWidget = tester.widget<AnimatedContainer>( + animatedContainer, + ); + expect(animatedContainerWidget.constraints?.minWidth, 50); + expect(animatedContainerWidget.constraints?.minHeight, 50); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/input_chip/input_chip.0_test.dart b/packages/material_ui/material_ui_examples/test/input_chip/input_chip.0_test.dart new file mode 100644 index 000000000000..210b19727a2e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_chip/input_chip.0_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_chip/input_chip.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('', (WidgetTester tester) async { + await tester.pumpWidget(const example.ChipApp()); + + expect(find.byType(InputChip), findsNWidgets(3)); + + await tester.tap(find.byIcon(Icons.clear).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNWidgets(2)); + + await tester.tap(find.byIcon(Icons.clear).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNWidgets(1)); + + await tester.tap(find.byIcon(Icons.clear).at(0)); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNWidgets(0)); + + await tester.tap(find.text('Reset')); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNWidgets(3)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_chip/input_chip.1_test.dart b/packages/material_ui/material_ui_examples/test/input_chip/input_chip.1_test.dart new file mode 100644 index 000000000000..a39be1a62482 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_chip/input_chip.1_test.dart @@ -0,0 +1,66 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_chip/input_chip.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final String replacementChar = String.fromCharCode( + example.ChipsInputEditingController.kObjectReplacementChar, + ); + + testWidgets('User input generates InputChips', (WidgetTester tester) async { + await tester.pumpWidget(const example.EditableChipFieldApp()); + await tester.pumpAndSettle(); + + expect(find.byType(example.EditableChipFieldApp), findsOneWidget); + expect(find.byType(example.ChipsInput<String>), findsOneWidget); + expect(find.byType(InputChip), findsOneWidget); + + example.ChipsInputState<String> state = tester.state( + find.byType(example.ChipsInput<String>), + ); + expect(state.controller.textWithoutReplacements.isEmpty, true); + + await tester.tap(find.byType(example.ChipsInput<String>)); + await tester.pumpAndSettle(); + expect(tester.testTextInput.isVisible, true); + // Simulating text typing on the input field. + tester.testTextInput.enterText('${replacementChar}ham'); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsOneWidget); + + state = tester.state(find.byType(example.ChipsInput<String>)); + await tester.pumpAndSettle(); + expect(state.controller.textWithoutReplacements, 'ham'); + + // Add new InputChip by sending the "done" action. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(state.controller.textWithoutReplacements.isEmpty, true); + + expect(find.byType(InputChip), findsNWidgets(2)); + + // Simulate item deletion. + await tester.tap( + find.descendant( + of: find.byType(InputChip), + matching: find.byType(InkWell).last, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsOneWidget); + + await tester.tap( + find.descendant( + of: find.byType(InputChip), + matching: find.byType(InkWell).last, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(InputChip), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.0_test.dart new file mode 100644 index 000000000000..3385219baf19 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.0_test.dart @@ -0,0 +1,22 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextField is decorated', (WidgetTester tester) async { + await tester.pumpWidget(const example.InputDecorationExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Hint Text'), findsOneWidget); + expect(find.text('Helper Text'), findsOneWidget); + expect(find.text('0 characters'), findsOneWidget); + + expect(find.byIcon(Icons.send), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.1_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.1_test.dart new file mode 100644 index 000000000000..5ec7557848ad --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.1_test.dart @@ -0,0 +1,30 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextField is decorated', (WidgetTester tester) async { + await tester.pumpWidget(const example.InputDecorationExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Hint Text'), findsOneWidget); + + expect( + tester + .widget<TextField>(find.byType(TextField)) + .decoration + ?.contentPadding, + EdgeInsets.zero, + ); + expect( + tester.widget<TextField>(find.byType(TextField)).decoration?.border, + const OutlineInputBorder(), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.2_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.2_test.dart new file mode 100644 index 000000000000..5347266c3dbd --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.2_test.dart @@ -0,0 +1,24 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextField is decorated', (WidgetTester tester) async { + await tester.pumpWidget(const example.InputDecorationExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Hint Text'), findsOneWidget); + expect(find.text('Error Text'), findsOneWidget); + + expect( + tester.widget<TextField>(find.byType(TextField)).decoration?.border, + isNotNull, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.3_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.3_test.dart new file mode 100644 index 000000000000..213b581f0f2f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.3_test.dart @@ -0,0 +1,38 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextFormField is decorated', (WidgetTester tester) async { + await tester.pumpWidget(const example.InputDecorationExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextFormField), findsOneWidget); + expect(find.text('Prefix'), findsOneWidget); + expect(find.text('abc'), findsOneWidget); + expect(find.text('Suffix'), findsOneWidget); + expect( + tester.widget<TextField>(find.byType(TextField)).decoration?.border, + const OutlineInputBorder(), + ); + }); + + testWidgets('Decorations are correctly ordered', (WidgetTester tester) async { + await tester.pumpWidget(const example.InputDecorationExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextFormField), findsOneWidget); + + final double prefixX = tester.getCenter(find.text('Prefix')).dx; + final double contentX = tester.getCenter(find.text('abc')).dx; + final double suffixX = tester.getCenter(find.text('Suffix')).dx; + + expect(prefixX, lessThan(contentX)); + expect(contentX, lessThan(suffixX)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.floating_label_style_error.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.floating_label_style_error.0_test.dart new file mode 100644 index 000000000000..b572f0973021 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.floating_label_style_error.0_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.floating_label_style_error.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('InputDecorator label uses error color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.FloatingLabelStyleErrorExampleApp()); + final Theme theme = tester.firstWidget(find.byType(Theme)); + + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + + final AnimatedDefaultTextStyle label = tester.firstWidget( + find.ancestor( + of: find.text('Name'), + matching: find.byType(AnimatedDefaultTextStyle), + ), + ); + expect(label.style.color, theme.data.colorScheme.error); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.helper.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.helper.0_test.dart new file mode 100644 index 000000000000..0909eac8eed0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.helper.0_test.dart @@ -0,0 +1,20 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.helper.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Shows multi element InputDecorator help decoration', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.HelperExampleApp()); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Helper Text '), findsOneWidget); + expect(find.byIcon(Icons.help_outline), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.label.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.label.0_test.dart new file mode 100644 index 000000000000..97b514f8b1ae --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.label.0_test.dart @@ -0,0 +1,21 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.label.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Decorates TextField in sample app with label', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabelExampleApp()); + expect(find.text('InputDecoration.label Sample'), findsOneWidget); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Username'), findsOneWidget); + expect(find.text('*'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.label_style_error.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.label_style_error.0_test.dart new file mode 100644 index 000000000000..040bdbf50d33 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.label_style_error.0_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.label_style_error.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('InputDecorator label uses error color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabelStyleErrorExampleApp()); + final Theme theme = tester.firstWidget(find.byType(Theme)); + + final AnimatedDefaultTextStyle label = tester.firstWidget( + find.ancestor( + of: find.text('Name'), + matching: find.byType(AnimatedDefaultTextStyle), + ), + ); + expect(label.style.color, theme.data.colorScheme.error); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.prefix_icon.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.prefix_icon.0_test.dart new file mode 100644 index 000000000000..900c3bafa921 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.prefix_icon.0_test.dart @@ -0,0 +1,17 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.prefix_icon.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('InputDecorator prefixIcon alignment', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PrefixIconExampleApp()); + expect(tester.getCenter(find.byIcon(Icons.person)).dy, 28.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.prefix_icon_constraints.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.prefix_icon_constraints.0_test.dart new file mode 100644 index 000000000000..375e0e2fd9ba --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.prefix_icon_constraints.0_test.dart @@ -0,0 +1,73 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.prefix_icon_constraints.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Shows two TextFields decorated with prefix icon sizes matching their hint text', + (WidgetTester tester) async { + await tester.pumpWidget(const example.PrefixIconConstraintsExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.byIcon(Icons.search), findsNWidgets(2)); + expect(find.text('Normal Icon Constraints'), findsOneWidget); + expect(find.text('Smaller Icon Constraints'), findsOneWidget); + + final Finder normalIcon = find.descendant( + of: find.ancestor( + of: find.text('Normal Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + final Finder smallerIcon = find.descendant( + of: find.ancestor( + of: find.text('Smaller Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + + expect( + tester.getSize(normalIcon).longestSide, + greaterThan(tester.getSize(smallerIcon).longestSide), + ); + }, + ); + + testWidgets('prefixIcons are placed left of hintText', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PrefixIconConstraintsExampleApp()); + + final Finder normalIcon = find.descendant( + of: find.ancestor( + of: find.text('Normal Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + final Finder smallerIcon = find.descendant( + of: find.ancestor( + of: find.text('Smaller Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + + expect( + tester.getCenter(find.text('Normal Icon Constraints')).dx, + greaterThan(tester.getCenter(normalIcon).dx), + ); + expect( + tester.getCenter(find.text('Smaller Icon Constraints')).dx, + greaterThan(tester.getCenter(smallerIcon).dx), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.suffix_icon.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.suffix_icon.0_test.dart new file mode 100644 index 000000000000..78ab1c6f026c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.suffix_icon.0_test.dart @@ -0,0 +1,17 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.suffix_icon.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('InputDecorator suffixIcon alignment', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SuffixIconExampleApp()); + expect(tester.getCenter(find.byIcon(Icons.remove_red_eye)).dy, 28.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.suffix_icon_constraints.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.suffix_icon_constraints.0_test.dart new file mode 100644 index 000000000000..386d639598bd --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.suffix_icon_constraints.0_test.dart @@ -0,0 +1,73 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.suffix_icon_constraints.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Shows two TextFields decorated with suffix icon sizes matching their hint text', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SuffixIconConstraintsExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.byIcon(Icons.search), findsNWidgets(2)); + expect(find.text('Normal Icon Constraints'), findsOneWidget); + expect(find.text('Smaller Icon Constraints'), findsOneWidget); + + final Finder normalIcon = find.descendant( + of: find.ancestor( + of: find.text('Normal Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + final Finder smallerIcon = find.descendant( + of: find.ancestor( + of: find.text('Smaller Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + + expect( + tester.getSize(normalIcon).longestSide, + greaterThan(tester.getSize(smallerIcon).longestSide), + ); + }, + ); + + testWidgets('suffixIcons are placed right of hintText', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SuffixIconConstraintsExampleApp()); + + final Finder normalIcon = find.descendant( + of: find.ancestor( + of: find.text('Normal Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + final Finder smallerIcon = find.descendant( + of: find.ancestor( + of: find.text('Smaller Icon Constraints'), + matching: find.byType(TextField), + ), + matching: find.byIcon(Icons.search), + ); + + expect( + tester.getCenter(find.text('Normal Icon Constraints')).dx, + lessThan(tester.getCenter(normalIcon).dx), + ); + expect( + tester.getCenter(find.text('Smaller Icon Constraints')).dx, + lessThan(tester.getCenter(smallerIcon).dx), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.widget_state.0_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.widget_state.0_test.dart new file mode 100644 index 000000000000..fb6617f276e3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.widget_state.0_test.dart @@ -0,0 +1,40 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.widget_state.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextFormField updates decorations depending on state', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialStateExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextFormField), findsOneWidget); + expect(find.text('abc'), findsOneWidget); + expect(find.byIcon(Icons.person), findsOneWidget); + + expect( + tester + .widget<TextField>(find.byType(TextField)) + .decoration + ?.prefixIconColor, + isA<WidgetStateColor>() + .having( + (WidgetStateColor color) => color.resolve(<WidgetState>{}), + 'default', + Colors.grey, + ) + .having( + (WidgetStateColor color) => + color.resolve(<WidgetState>{WidgetState.focused}), + 'focused', + Colors.green, + ), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.widget_state.1_test.dart b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.widget_state.1_test.dart new file mode 100644 index 000000000000..50a9618cfced --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/input_decorator/input_decoration.widget_state.1_test.dart @@ -0,0 +1,63 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/input_decorator/input_decoration.widget_state.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextFormField updates decorations depending on state', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialStateExampleApp()); + expect(find.text('InputDecoration Sample'), findsOneWidget); + + expect(find.byType(TextFormField), findsOneWidget); + expect(find.text('example.com'), findsOneWidget); + expect(find.byIcon(Icons.web), findsOneWidget); + + expect( + tester + .widget<TextField>(find.byType(TextField)) + .decoration + ?.prefixIconColor, + isA<WidgetStateColor>() + .having( + (WidgetStateColor color) => color.resolve(<WidgetState>{}), + 'default', + Colors.grey, + ) + .having( + (WidgetStateColor color) => + color.resolve(<WidgetState>{WidgetState.focused}), + 'focused', + Colors.blue, + ) + .having( + (WidgetStateColor color) => + color.resolve(<WidgetState>{WidgetState.error}), + 'error', + Colors.red, + ) + .having( + (WidgetStateColor color) => color.resolve(<WidgetState>{ + WidgetState.error, + WidgetState.focused, + }), + 'error', + Colors.red, + ), + ); + }); + + testWidgets('Validates field input', (WidgetTester tester) async { + await tester.pumpWidget(const example.MaterialStateExampleApp()); + + expect(find.text('No .com tld'), findsNothing); + await tester.enterText(find.byType(TextFormField), 'noUrl'); + await tester.pump(); + expect(find.text('No .com tld'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/custom_list_item.0_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/custom_list_item.0_test.dart new file mode 100644 index 000000000000..d597d877804b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/custom_list_item.0_test.dart @@ -0,0 +1,52 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/custom_list_item.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Custom list item uses Expanded widgets for the layout', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.CustomListItemApp()); + + // The Expanded widget is used to control the size of the thumbnail. + Expanded thumbnailExpanded = tester.widget( + find.ancestor( + of: find.byType(Container).first, + matching: find.byType(Expanded), + ), + ); + expect(thumbnailExpanded.flex, 2); + + // The Expanded widget is used to control the size of the text. + Expanded textExpanded = tester.widget( + find.ancestor( + of: find.text('The Flutter YouTube Channel'), + matching: find.byType(Expanded), + ), + ); + expect(textExpanded.flex, 3); + + // The Expanded widget is used to control the size of the thumbnail. + thumbnailExpanded = tester.widget( + find.ancestor( + of: find.byType(Container).last, + matching: find.byType(Expanded), + ), + ); + expect(thumbnailExpanded.flex, 2); + + // The Expanded widget is used to control the size of the text. + textExpanded = tester.widget( + find.ancestor( + of: find.text('Announcing Flutter 1.0'), + matching: find.byType(Expanded), + ), + ); + expect(textExpanded.flex, 3); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/custom_list_item.1_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/custom_list_item.1_test.dart new file mode 100644 index 000000000000..f9076953741c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/custom_list_item.1_test.dart @@ -0,0 +1,44 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/custom_list_item.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Custom list item uses AspectRatio and Expanded widgets for the layout', + (WidgetTester tester) async { + await tester.pumpWidget(const example.CustomListItemApp()); + + // The AspectRatio widget is used to constrain the size of the thumbnail. + AspectRatio thumbnailAspectRatio = tester.widget( + find.ancestor( + of: find.byType(Container).first, + matching: find.byType(AspectRatio), + ), + ); + expect(thumbnailAspectRatio.aspectRatio, 1.0); + + // The Expanded widget is used to control the size of the text. + final Expanded textExpanded = tester.widget( + find.ancestor( + of: find.text('Flutter 1.0 Launch'), + matching: find.byType(Expanded).at(0), + ), + ); + expect(textExpanded.flex, 1); + + // The AspectRatio widget is used to constrain the size of the thumbnail. + thumbnailAspectRatio = tester.widget( + find.ancestor( + of: find.byType(Container).last, + matching: find.byType(AspectRatio), + ), + ); + expect(thumbnailAspectRatio.aspectRatio, 1.0); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/list_tile.0_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.0_test.dart new file mode 100644 index 000000000000..cf94bafbf363 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.0_test.dart @@ -0,0 +1,34 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/list_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ListTile with Hero does not throw', (WidgetTester tester) async { + const int totalTiles = 3; + + await tester.pumpWidget(const example.ListTileApp()); + + expect(find.byType(ListTile), findsNWidgets(totalTiles)); + + const String heroTransitionText = 'Tap here for Hero transition'; + const String goBackText = 'Tap here to go back'; + + expect(find.text(heroTransitionText), findsOneWidget); + expect(find.text(goBackText), findsNothing); + + // Tap on the ListTile widget to trigger the Hero transition. + await tester.tap(find.text(heroTransitionText)); + await tester.pumpAndSettle(); + + // The Hero transition is triggered and tap to go back text is displayed. + expect(find.text(heroTransitionText), findsNothing); + expect(find.text(goBackText), findsOneWidget); + + expect(tester.takeException(), null); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/list_tile.1_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.1_test.dart new file mode 100644 index 000000000000..2aa1950021d1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.1_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/list_tile.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ListTiles wrapped in Card widgets', (WidgetTester tester) async { + const int totalTiles = 7; + + await tester.pumpWidget(const example.ListTileApp()); + + expect(find.byType(ListTile), findsNWidgets(totalTiles)); + + // The ListTile widget is wrapped in a Card widget. + for (int i = 0; i < totalTiles; i++) { + expect( + find.ancestor( + of: find.byType(ListTile).at(i), + matching: find.byType(Card).at(i), + ), + findsOneWidget, + ); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/list_tile.2_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.2_test.dart new file mode 100644 index 000000000000..60ea1b08321d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.2_test.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/list_tile.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ListTile leading and trailing widgets are aligned appropriately', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ListTileApp()); + + expect(find.byType(ListTile), findsNWidgets(3)); + + Offset listTileTopLeft = tester.getTopLeft(find.byType(ListTile).at(0)); + Offset leadingTopLeft = tester.getTopLeft(find.byType(CircleAvatar).at(0)); + Offset trailingTopLeft = tester.getTopLeft(find.byType(Icon).at(0)); + + // The leading and trailing widgets are centered vertically with the text. + expect(leadingTopLeft - listTileTopLeft, const Offset(16.0, 16.0)); + expect(trailingTopLeft - listTileTopLeft, const Offset(752.0, 24.0)); + + listTileTopLeft = tester.getTopLeft(find.byType(ListTile).at(1)); + leadingTopLeft = tester.getTopLeft(find.byType(CircleAvatar).at(1)); + trailingTopLeft = tester.getTopLeft(find.byType(Icon).at(1)); + + // The leading and trailing widgets are centered vertically with the text. + expect(leadingTopLeft - listTileTopLeft, const Offset(16.0, 30.0)); + expect(trailingTopLeft - listTileTopLeft, const Offset(752.0, 38.0)); + + listTileTopLeft = tester.getTopLeft(find.byType(ListTile).at(2)); + leadingTopLeft = tester.getTopLeft(find.byType(CircleAvatar).at(2)); + trailingTopLeft = tester.getTopLeft(find.byType(Icon).at(2)); + + // The leading and trailing widgets are aligned to the top vertically with the text. + expect(leadingTopLeft - listTileTopLeft, const Offset(16.0, 8.0)); + expect(trailingTopLeft - listTileTopLeft, const Offset(752.0, 8.0)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/list_tile.3_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.3_test.dart new file mode 100644 index 000000000000..fd011c7bd85f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.3_test.dart @@ -0,0 +1,58 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui_examples/list_tile/list_tile.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ListTile color properties respect Material state color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ListTileApp()); + ListTile listTile = tester.widget(find.byType(ListTile)); + + // Enabled list tile uses black color for icon and headline. + expect(listTile.enabled, true); + expect(listTile.selected, false); + RenderParagraph headline = _getTextRenderObject(tester, 'Headline'); + expect(headline.text.style!.color, Colors.black); + RichText icon = tester.widget(find.byType(RichText).at(0)); + expect(icon.text.style!.color, Colors.black); + + // Tap list tile to select it. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + // Selected list tile uses green color for icon and headline. + listTile = tester.widget(find.byType(ListTile)); + expect(listTile.enabled, true); + expect(listTile.selected, true); + headline = _getTextRenderObject(tester, 'Headline'); + expect(headline.text.style!.color, Colors.green); + icon = tester.widget(find.byType(RichText).at(0)); + expect(icon.text.style!.color, Colors.green); + + // Tap switch to disable list tile. + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + // Disabled list tile uses red color for icon and headline. + listTile = tester.widget(find.byType(ListTile)); + expect(listTile.enabled, false); + expect(listTile.selected, true); + headline = _getTextRenderObject(tester, 'Headline'); + expect(headline.text.style!.color, Colors.red); + icon = tester.widget(find.byType(RichText).at(0)); + expect(icon.text.style!.color, Colors.red); + }); +} + +RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject( + find.descendant(of: find.byType(ListTile), matching: find.text(text)), + ); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/list_tile.4_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.4_test.dart new file mode 100644 index 000000000000..3865f85434e9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.4_test.dart @@ -0,0 +1,54 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/list_tile.4.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can choose different title alignments from popup menu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ListTileApp()); + + Offset titleOffset = tester.getTopLeft(find.text('Headline Text')); + Offset leadingOffset = tester.getTopLeft(find.byType(Checkbox)); + Offset trailingOffset = tester.getTopRight( + find.byIcon(Icons.adaptive.more), + ); + + // The default title alignment is threeLine. + expect(leadingOffset.dy - titleOffset.dy, 48.0); + expect(trailingOffset.dy - titleOffset.dy, 60.0); + + await tester.tap(find.byIcon(Icons.adaptive.more)); + await tester.pumpAndSettle(); + + // Change the title alignment to titleHeight. + await tester.tap(find.text('titleHeight')); + await tester.pumpAndSettle(); + + titleOffset = tester.getTopLeft(find.text('Headline Text')); + leadingOffset = tester.getTopLeft(find.byType(Checkbox)); + trailingOffset = tester.getTopRight(find.byIcon(Icons.adaptive.more)); + + expect(leadingOffset.dy - titleOffset.dy, 8.0); + expect(trailingOffset.dy - titleOffset.dy, 20.0); + + await tester.tap(find.byIcon(Icons.adaptive.more)); + await tester.pumpAndSettle(); + + // Change the title alignment to bottom. + await tester.tap(find.text('bottom')); + await tester.pumpAndSettle(); + + titleOffset = tester.getTopLeft(find.text('Headline Text')); + leadingOffset = tester.getTopLeft(find.byType(Checkbox)); + trailingOffset = tester.getTopRight(find.byIcon(Icons.adaptive.more)); + + expect(leadingOffset.dy - titleOffset.dy, 96.0); + expect(trailingOffset.dy - titleOffset.dy, 108.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/list_tile/list_tile.selected.0_test.dart b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.selected.0_test.dart new file mode 100644 index 000000000000..61653f725226 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/list_tile/list_tile.selected.0_test.dart @@ -0,0 +1,30 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/list_tile/list_tile.selected.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ListTile item can be selected', (WidgetTester tester) async { + await tester.pumpWidget(const example.ListTileApp()); + + expect(find.byType(ListTile), findsNWidgets(10)); + + // The first item is selected by default. + expect(tester.widget<ListTile>(find.byType(ListTile).at(0)).selected, true); + + // Tap a list item to select it. + await tester.tap(find.byType(ListTile).at(5)); + await tester.pump(); + + // The first item is no longer selected. + expect( + tester.widget<ListTile>(find.byType(ListTile).at(0)).selected, + false, + ); + expect(tester.widget<ListTile>(find.byType(ListTile).at(5)).selected, true); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/material_state/material_state_border_side.0_test.dart b/packages/material_ui/material_ui_examples/test/material_state/material_state_border_side.0_test.dart new file mode 100644 index 000000000000..af79322e92e2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/material_state/material_state_border_side.0_test.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/material_state/material_state_border_side.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Finder findBorderColor(Color color) { + return find.byWidgetPredicate((Widget widget) { + if (widget is! Material) { + return false; + } + final ShapeBorder? shape = widget.shape; + if (shape is! OutlinedBorder) { + return false; + } + return shape.side.color == color; + }); + } + + testWidgets('FilterChip displays the correct border when selected', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialStateBorderSideExampleApp()); + + expect(findBorderColor(Colors.red), findsOne); + }); + + testWidgets('FilterChip displays the correct border when not selected', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialStateBorderSideExampleApp()); + + await tester.tap(find.byType(FilterChip)); + await tester.pumpAndSettle(); + + expect(findBorderColor(const Color(0xffcac4d0)), findsOne); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/material_state/material_state_mouse_cursor.0_test.dart b/packages/material_ui/material_ui_examples/test/material_state/material_state_mouse_cursor.0_test.dart new file mode 100644 index 000000000000..03ec447bdcd3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/material_state/material_state_mouse_cursor.0_test.dart @@ -0,0 +1,68 @@ +// 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:ui'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui_examples/material_state/material_state_mouse_cursor.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('MaterialStateMouseCursorExampleApp displays ListTile', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialStateMouseCursorExampleApp()); + + expect(find.byType(ListTile), findsOneWidget); + expect(find.text('ListTile'), findsOneWidget); + }); + + testWidgets('ListTile displays correct mouse cursor when enabled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: example.MaterialStateMouseCursorExample(enabled: true), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(ListTile))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('ListTile displays correct mouse cursor when disabled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: example.MaterialStateMouseCursorExample(enabled: false), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(ListTile))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/material_state/material_state_property.0_test.dart b/packages/material_ui/material_ui_examples/test/material_state/material_state_property.0_test.dart new file mode 100644 index 000000000000..44483f751899 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/material_state/material_state_property.0_test.dart @@ -0,0 +1,47 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/material_state/material_state_property.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Color getButtonForegroundColor(WidgetTester tester) { + final Material widget = tester.widget( + find.descendant( + of: find.byType(example.MaterialStatePropertyExample), + matching: find.widgetWithText(Material, 'TextButton'), + ), + ); + return widget.textStyle!.color!; + } + + testWidgets( + 'The foreground color of the TextButton should be red by default', + (WidgetTester tester) async { + await tester.pumpWidget(const example.MaterialStatePropertyExampleApp()); + + expect(getButtonForegroundColor(tester), Colors.red); + }, + ); + + testWidgets( + 'The foreground color of the TextButton should be blue when hovered', + (WidgetTester tester) async { + await tester.pumpWidget(const example.MaterialStatePropertyExampleApp()); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer( + location: tester.getCenter(find.byType(TextButton)), + ); + await tester.pump(); + + expect(getButtonForegroundColor(tester), Colors.blue); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/checkbox_menu_button.0_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/checkbox_menu_button.0_test.dart new file mode 100644 index 000000000000..f5eec3a71567 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/checkbox_menu_button.0_test.dart @@ -0,0 +1,43 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/menu_anchor/checkbox_menu_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu and show message', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuApp()); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.text('Show Message'), findsOneWidget); + expect(find.text(example.MenuApp.kMessage), findsNothing); + + await tester.tap(find.text('Show Message')); + await tester.pumpAndSettle(); + + expect(find.text('Show Message'), findsNothing); + expect(find.text(example.MenuApp.kMessage), findsOneWidget); + }); + + testWidgets('MenuAnchor is wrapped in a SafeArea', ( + WidgetTester tester, + ) async { + const double safeAreaPadding = 100.0; + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(padding: .symmetric(vertical: safeAreaPadding)), + child: example.MenuApp(), + ), + ); + + expect( + tester.getTopLeft(find.byType(MenuAnchor)), + const Offset(0.0, safeAreaPadding), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/menu_accelerator_label.0_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_accelerator_label.0_test.dart new file mode 100644 index 000000000000..1078fa07ec7e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_accelerator_label.0_test.dart @@ -0,0 +1,71 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/menu_anchor/menu_accelerator_label.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu', (WidgetTester tester) async { + Finder findMenu(String label) { + return find + .ancestor( + of: find.text(label, findRichText: true), + matching: find.byType(FocusScope), + ) + .first; + } + + await tester.pumpWidget(const example.MenuAcceleratorApp()); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF, character: 'f'); + await tester.pumpAndSettle(); + await tester.pump(); + + expect(find.text('About', findRichText: true), findsOneWidget); + expect(tester.getRect(findMenu('About')).left, equals(4.0)); + expect(tester.getRect(findMenu('About')).top, equals(48.0)); + expect(tester.getRect(findMenu('About')).right, closeTo(98.5, 0.1)); + expect(tester.getRect(findMenu('About')).bottom, equals(208.0)); + + expect(find.text('Save', findRichText: true), findsOneWidget); + expect(find.text('Quit', findRichText: true), findsOneWidget); + expect(find.text('Magnify', findRichText: true), findsNothing); + expect(find.text('Minify', findRichText: true), findsNothing); + + // Open the About dialog. + await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); + + expect(find.text('Save', findRichText: true), findsNothing); + expect(find.text('Quit', findRichText: true), findsNothing); + expect(find.text('Magnify', findRichText: true), findsNothing); + expect(find.text('Minify', findRichText: true), findsNothing); + expect(find.text('Close'), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.text('Close'), findsNothing); + }); + + testWidgets('MenuBar is wrapped in a SafeArea', (WidgetTester tester) async { + const double safeAreaPadding = 100.0; + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(padding: .symmetric(vertical: safeAreaPadding)), + child: example.MenuAcceleratorApp(), + ), + ); + + expect( + tester.getTopLeft(find.byType(MenuBar)), + const Offset(0.0, safeAreaPadding), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.0_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.0_test.dart new file mode 100644 index 000000000000..5becab421da7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.0_test.dart @@ -0,0 +1,172 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/menu_anchor/menu_anchor.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuApp()); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(find.text(example.MenuEntry.about.label), findsOneWidget); + expect(find.text(example.MenuEntry.showMessage.label), findsOneWidget); + expect(find.text(example.MenuEntry.hideMessage.label), findsNothing); + expect(find.text('Background Color'), findsOneWidget); + expect(find.text(example.MenuEntry.colorRed.label), findsNothing); + expect(find.text(example.MenuEntry.colorGreen.label), findsNothing); + expect(find.text(example.MenuEntry.colorBlue.label), findsNothing); + expect(find.text(example.MenuApp.kMessage), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Background Color'), findsOneWidget); + expect(find.text(example.MenuEntry.colorRed.label), findsOneWidget); + expect(find.text(example.MenuEntry.colorGreen.label), findsOneWidget); + expect(find.text(example.MenuEntry.colorBlue.label), findsOneWidget); + + await tester.tap(find.text('Background Color')); + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.text(example.MenuEntry.colorRed.label), findsNothing); + expect(find.text(example.MenuEntry.colorGreen.label), findsNothing); + expect(find.text(example.MenuEntry.colorBlue.label), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text(example.MenuApp.kMessage), findsOneWidget); + expect( + find.text('Last Selected: ${example.MenuEntry.showMessage.label}'), + findsOneWidget, + ); + }); + + testWidgets('Shortcuts work', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuApp()); + + // Open the menu so we can watch state changes resulting from the shortcuts + // firing. + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(find.text(example.MenuEntry.showMessage.label), findsOneWidget); + expect(find.text(example.MenuEntry.hideMessage.label), findsNothing); + expect(find.text(example.MenuApp.kMessage), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + // Need to pump twice because of the one frame delay in the notification to + // update the overlay entry. + await tester.pump(); + + expect(find.text(example.MenuEntry.showMessage.label), findsNothing); + expect(find.text(example.MenuEntry.hideMessage.label), findsOneWidget); + expect(find.text(example.MenuApp.kMessage), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect(find.text(example.MenuEntry.showMessage.label), findsOneWidget); + expect(find.text(example.MenuEntry.hideMessage.label), findsNothing); + expect(find.text(example.MenuApp.kMessage), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect( + find.text('Last Selected: ${example.MenuEntry.colorRed.label}'), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect( + find.text('Last Selected: ${example.MenuEntry.colorGreen.label}'), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect( + find.text('Last Selected: ${example.MenuEntry.colorBlue.label}'), + findsOneWidget, + ); + }); + + testWidgets('MenuAnchor is wrapped in a SafeArea', ( + WidgetTester tester, + ) async { + const double safeAreaPadding = 100.0; + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(padding: .symmetric(vertical: safeAreaPadding)), + child: example.MenuApp(), + ), + ); + + expect( + tester.getTopLeft(find.byType(MenuAnchor)), + const Offset(0.0, safeAreaPadding), + ); + }); + + testWidgets('MenuAnchor can toggle between opening and closing', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MenuApp()); + + await tester.tap(find.text('OPEN MENU')); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + Finder panel = find + .descendant( + of: find.byType(MenuAnchor), + matching: find.byType(FadeTransition), + ) + .first; + + final double panelHeight = tester.getSize(panel).height; + // Height differs based on platform, so use a large range. + expect(panelHeight, closeTo(135, 20)); + + await tester.tap(find.text('OPEN MENU')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final double panelHeightAfterClose = tester.getSize(panel).height; + expect(panelHeightAfterClose, closeTo(90, 20)); + + await tester.tap(find.text('OPEN MENU')); + await tester.pump(); + await tester.pumpAndSettle(); + + final double panelHeightAfterReopen = tester.getSize(panel).height; + expect(panelHeightAfterReopen, closeTo(140, 20)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.1_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.1_test.dart new file mode 100644 index 000000000000..cd8d20bb1da9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.1_test.dart @@ -0,0 +1,149 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/menu_anchor/menu_anchor.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu', (WidgetTester tester) async { + Finder findMenu() { + return find + .ancestor( + of: find.text(example.MenuEntry.about.label), + matching: find.byType(FocusScope), + ) + .first; + } + + await tester.pumpWidget(const example.ContextMenuApp()); + + await tester.tapAt(const Offset(100, 200), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + expect(tester.getRect(findMenu()).left, equals(100.0)); + expect(tester.getRect(findMenu()).top, equals(200.0)); + expect(tester.getRect(findMenu()).right, closeTo(389.8, 0.1)); + expect(tester.getRect(findMenu()).bottom, equals(360.0)); + + // Make sure tapping in a different place causes the menu to move. + await tester.tapAt(const Offset(200, 100), buttons: kSecondaryButton); + await tester.pump(); + + expect(tester.getRect(findMenu()).left, equals(200.0)); + expect(tester.getRect(findMenu()).top, equals(100.0)); + expect(tester.getRect(findMenu()).right, closeTo(489.8, 0.1)); + expect(tester.getRect(findMenu()).bottom, equals(260.0)); + + expect(find.text(example.MenuEntry.about.label), findsOneWidget); + expect(find.text(example.MenuEntry.showMessage.label), findsOneWidget); + expect(find.text(example.MenuEntry.hideMessage.label), findsNothing); + expect(find.text('Background Color'), findsOneWidget); + expect(find.text(example.MenuEntry.colorRed.label), findsNothing); + expect(find.text(example.MenuEntry.colorGreen.label), findsNothing); + expect(find.text(example.MenuEntry.colorBlue.label), findsNothing); + expect(find.text(example.ContextMenuApp.kMessage), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(find.text('Background Color'), findsOneWidget); + + // Focusing the background color item with the keyboard caused the submenu + // to open. Tapping it should cause it to close. + await tester.tap(find.text('Background Color')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(example.MenuEntry.colorRed.label), findsNothing); + expect(find.text(example.MenuEntry.colorGreen.label), findsNothing); + expect(find.text(example.MenuEntry.colorBlue.label), findsNothing); + + await tester.tap(find.text('Background Color')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(example.MenuEntry.colorRed.label), findsOneWidget); + expect(find.text(example.MenuEntry.colorGreen.label), findsOneWidget); + expect(find.text(example.MenuEntry.colorBlue.label), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text(example.ContextMenuApp.kMessage), findsOneWidget); + expect( + find.text('Last Selected: ${example.MenuEntry.showMessage.label}'), + findsOneWidget, + ); + }); + + testWidgets('Shortcuts work', (WidgetTester tester) async { + await tester.pumpWidget(const example.ContextMenuApp()); + + // Open the menu so we can look for state changes reflected in the menu. + await tester.tapAt(const Offset(100, 200), buttons: kSecondaryButton); + await tester.pump(); + + expect(find.text(example.MenuEntry.showMessage.label), findsOneWidget); + expect(find.text(example.MenuEntry.hideMessage.label), findsNothing); + expect(find.text(example.ContextMenuApp.kMessage), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + // Need to pump twice because of the one frame delay in the notification to + // update the overlay entry. + await tester.pump(); + + expect(find.text(example.MenuEntry.showMessage.label), findsNothing); + expect(find.text(example.MenuEntry.hideMessage.label), findsOneWidget); + expect(find.text(example.ContextMenuApp.kMessage), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect(find.text(example.MenuEntry.showMessage.label), findsOneWidget); + expect(find.text(example.MenuEntry.hideMessage.label), findsNothing); + expect(find.text(example.ContextMenuApp.kMessage), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect( + find.text('Last Selected: ${example.MenuEntry.colorRed.label}'), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect( + find.text('Last Selected: ${example.MenuEntry.colorGreen.label}'), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect( + find.text('Last Selected: ${example.MenuEntry.colorBlue.label}'), + findsOneWidget, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.2_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.2_test.dart new file mode 100644 index 000000000000..cb86233f7aec --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.2_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/menu_anchor/menu_anchor.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The menu should display three items', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MenuAnchorApp()); + + expect(find.widgetWithText(AppBar, 'MenuAnchorButton'), findsOne); + expect( + find.descendant( + of: find.byType(MenuAnchor), + matching: find.widgetWithIcon(IconButton, Icons.more_horiz), + ), + findsOne, + ); + + await tester.tap(find.byIcon(Icons.more_horiz)); + await tester.pumpAndSettle(); + + for (int i = 1; i <= 3; i++) { + expect(find.widgetWithText(MenuItemButton, 'Item $i'), findsOne); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.3_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.3_test.dart new file mode 100644 index 000000000000..6340c05f9225 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_anchor.3_test.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/menu_anchor/menu_anchor.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Menu button opens and closes the menu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SimpleCascadingMenuApp()); + + // Find the menu button. + final Finder menuButton = find.byType(IconButton); + expect(menuButton, findsOneWidget); + + // Tap the menu button to open the menu. + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // Verify that the menu is open. + expect(find.text('Revert'), findsOneWidget); + + // Tap the menu button again to close the menu. + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + // Verify that the menu is closed. + expect(find.text('Revert'), findsNothing); + }); + + testWidgets('Does not show debug banner', (WidgetTester tester) async { + await tester.pumpWidget(const example.SimpleCascadingMenuApp()); + expect(find.byType(CheckedModeBanner), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/menu_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_bar.0_test.dart new file mode 100644 index 000000000000..455412a2df55 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/menu_bar.0_test.dart @@ -0,0 +1,107 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/menu_anchor/menu_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuBarApp()); + + final Finder menuBarFinder = find.byType(MenuBar); + final MenuBar menuBar = tester.widget<MenuBar>(menuBarFinder); + expect(menuBar.children, isNotEmpty); + expect(menuBar.children.length, equals(1)); + + final Finder menuButtonFinder = find.byType(SubmenuButton).first; + await tester.tap(menuButtonFinder); + await tester.pumpAndSettle(); + + expect(find.text('About'), findsOneWidget); + expect(find.text('Show Message'), findsOneWidget); + expect(find.text('Reset Message'), findsOneWidget); + expect(find.text('Background Color'), findsOneWidget); + expect(find.text('Red Background'), findsNothing); + expect(find.text('Green Background'), findsNothing); + expect(find.text('Blue Background'), findsNothing); + expect(find.text(example.MenuBarApp.kMessage), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(find.text('About'), findsOneWidget); + expect(find.text('Show Message'), findsOneWidget); + expect(find.text('Reset Message'), findsOneWidget); + expect(find.text('Background Color'), findsOneWidget); + expect(find.text('Red Background'), findsOneWidget); + expect(find.text('Green Background'), findsOneWidget); + expect(find.text('Blue Background'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text(example.MenuBarApp.kMessage), findsOneWidget); + expect(find.text('Last Selected: Show Message'), findsOneWidget); + }); + + testWidgets('Shortcuts work', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuBarApp()); + + expect(find.text(example.MenuBarApp.kMessage), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(find.text(example.MenuBarApp.kMessage), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(find.text(example.MenuBarApp.kMessage), findsNothing); + expect(find.text('Last Selected: Reset Message'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(find.text('Last Selected: Red Background'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(find.text('Last Selected: Green Background'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(find.text('Last Selected: Blue Background'), findsOneWidget); + }); + + testWidgets('MenuBar is wrapped in a SafeArea', (WidgetTester tester) async { + const double safeAreaPadding = 100.0; + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(padding: .symmetric(vertical: safeAreaPadding)), + child: example.MenuBarApp(), + ), + ); + + expect( + tester.getTopLeft(find.byType(MenuBar)), + const Offset(0.0, safeAreaPadding), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/menu_anchor/radio_menu_button.0_test.dart b/packages/material_ui/material_ui_examples/test/menu_anchor/radio_menu_button.0_test.dart new file mode 100644 index 000000000000..54f9071e3ebf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/menu_anchor/radio_menu_button.0_test.dart @@ -0,0 +1,130 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/menu_anchor/radio_menu_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open menu', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuApp()); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + await tester.pump(); + + expect(find.text('Red Background'), findsOneWidget); + expect(find.text('Green Background'), findsOneWidget); + expect(find.text('Blue Background'), findsOneWidget); + expect(find.byType(Radio<Color>), findsNWidgets(3)); + expect( + tester.widget<Container>(find.byType(Container)).color, + equals(Colors.red), + ); + + await tester.tap(find.text('Green Background')); + await tester.pumpAndSettle(); + + expect( + tester.widget<Container>(find.byType(Container)).color, + equals(Colors.green), + ); + }); + + testWidgets('Shortcuts work', (WidgetTester tester) async { + await tester.pumpWidget(const example.MenuApp()); + + // Open the menu so we can watch state changes resulting from the shortcuts + // firing. + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + expect(find.text('Red Background'), findsOneWidget); + expect(find.text('Green Background'), findsOneWidget); + expect(find.text('Blue Background'), findsOneWidget); + expect(find.byType(Radio<Color>), findsNWidgets(3)); + expect( + tester.widget<Container>(find.byType(Container)).color, + equals(Colors.red), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + // Need to pump twice because of the one frame delay in the notification to + // update the overlay entry. + await tester.pump(); + + expect( + tester + .widget<RadioMenuButton<Color>>( + find.byType(RadioMenuButton<Color>).at(0), + ) + .groupValue, + equals(Colors.green), + ); + expect( + tester.widget<Container>(find.byType(Container)).color, + equals(Colors.green), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect( + tester + .widget<RadioMenuButton<Color>>( + find.byType(RadioMenuButton<Color>).at(0), + ) + .groupValue, + equals(Colors.red), + ); + expect( + tester.widget<Container>(find.byType(Container)).color, + equals(Colors.red), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + + expect( + tester + .widget<RadioMenuButton<Color>>( + find.byType(RadioMenuButton<Color>).at(0), + ) + .groupValue, + equals(Colors.blue), + ); + expect( + tester.widget<Container>(find.byType(Container)).color, + equals(Colors.blue), + ); + }); + + testWidgets('MenuAnchor is wrapped in a SafeArea', ( + WidgetTester tester, + ) async { + const double safeAreaPadding = 100.0; + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(padding: .symmetric(vertical: safeAreaPadding)), + child: example.MenuApp(), + ), + ); + + expect( + tester.getTopLeft(find.byType(MenuAnchor)), + const Offset(0.0, safeAreaPadding), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.0_test.dart new file mode 100644 index 000000000000..ca2c59f4f376 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.0_test.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/navigation_bar/navigation_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Navigation bar updates destination on tap', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationBarApp()); + final NavigationBar navigationBarWidget = tester.firstWidget( + find.byType(NavigationBar), + ); + + /// NavigationDestinations must be rendered + expect(find.text('Home'), findsOneWidget); + expect(find.text('Notifications'), findsOneWidget); + expect(find.text('Messages'), findsOneWidget); + + /// Test notification badge. + final Badge notificationBadge = tester.firstWidget( + find.ancestor( + of: find.byIcon(Icons.notifications_sharp), + matching: find.byType(Badge), + ), + ); + expect(notificationBadge.label, null); + + /// Test messages badge. + final Badge messagesBadge = tester.firstWidget( + find.ancestor( + of: find.byIcon(Icons.messenger_sharp), + matching: find.byType(Badge), + ), + ); + expect(messagesBadge.label, isNotNull); + + /// Initial index must be zero + expect(navigationBarWidget.selectedIndex, 0); + expect(find.text('Home page'), findsOneWidget); + + /// Switch to second tab + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + expect(find.text('This is a notification'), findsNWidgets(2)); + + /// Switch to third tab + await tester.tap(find.text('Messages')); + await tester.pumpAndSettle(); + expect(find.text('Hi!'), findsOneWidget); + expect(find.text('Hello'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.1_test.dart new file mode 100644 index 000000000000..615fbf3264e7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.1_test.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/navigation_bar/navigation_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Navigation bar updates label behavior when tapping buttons', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationBarApp()); + NavigationBar navigationBarWidget = tester.firstWidget( + find.byType(NavigationBar), + ); + + expect(find.text('Label behavior: alwaysShow'), findsOneWidget); + + /// Test alwaysShow label behavior button. + await tester.tap(find.widgetWithText(ElevatedButton, 'alwaysShow')); + await tester.pumpAndSettle(); + + expect(find.text('Label behavior: alwaysShow'), findsOneWidget); + expect( + navigationBarWidget.labelBehavior, + NavigationDestinationLabelBehavior.alwaysShow, + ); + + /// Test onlyShowSelected label behavior button. + await tester.tap(find.widgetWithText(ElevatedButton, 'onlyShowSelected')); + await tester.pumpAndSettle(); + + expect(find.text('Label behavior: onlyShowSelected'), findsOneWidget); + navigationBarWidget = tester.firstWidget(find.byType(NavigationBar)); + expect( + navigationBarWidget.labelBehavior, + NavigationDestinationLabelBehavior.onlyShowSelected, + ); + + /// Test alwaysHide label behavior button. + await tester.tap(find.widgetWithText(ElevatedButton, 'alwaysHide')); + await tester.pumpAndSettle(); + + expect(find.text('Label behavior: alwaysHide'), findsOneWidget); + navigationBarWidget = tester.firstWidget(find.byType(NavigationBar)); + expect( + navigationBarWidget.labelBehavior, + NavigationDestinationLabelBehavior.alwaysHide, + ); + }); + + testWidgets('Overflow buttons are aligned in the center', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationBarApp()); + + final OverflowBar overflowBar = tester.widget<OverflowBar>( + find.byType(OverflowBar), + ); + expect(overflowBar.overflowAlignment, OverflowBarAlignment.center); + expect(overflowBar.overflowSpacing, 10.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.2_test.dart new file mode 100644 index 000000000000..febd482991b3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/navigation_bar/navigation_bar.2_test.dart @@ -0,0 +1,110 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/navigation_bar/navigation_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('RootPage: only selected destination is on stage', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: example.Home())); + + const String tealTitle = 'Teal RootPage - /'; + const String cyanTitle = 'Cyan RootPage - /'; + const String orangeTitle = 'Orange RootPage - /'; + const String blueTitle = 'Blue RootPage - /'; + + await tester.tap(find.widgetWithText(NavigationDestination, 'Teal')); + await tester.pumpAndSettle(); + expect(find.text(tealTitle), findsOneWidget); + expect(find.text(cyanTitle), findsNothing); + expect(find.text(orangeTitle), findsNothing); + expect(find.text(blueTitle), findsNothing); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Cyan')); + await tester.pumpAndSettle(); + expect(find.text(tealTitle), findsNothing); + expect(find.text(cyanTitle), findsOneWidget); + expect(find.text(orangeTitle), findsNothing); + expect(find.text(blueTitle), findsNothing); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Orange')); + await tester.pumpAndSettle(); + expect(find.text(tealTitle), findsNothing); + expect(find.text(cyanTitle), findsNothing); + expect(find.text(orangeTitle), findsOneWidget); + expect(find.text(blueTitle), findsNothing); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Blue')); + await tester.pumpAndSettle(); + expect(find.text(tealTitle), findsNothing); + expect(find.text(cyanTitle), findsNothing); + expect(find.text(orangeTitle), findsNothing); + expect(find.text(blueTitle), findsOneWidget); + }); + + testWidgets('RootPage', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: example.Home())); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Teal')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Local Dialog')); + await tester.pumpAndSettle(); + expect(find.text('Teal AlertDialog'), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Teal AlertDialog'), findsNothing); + + await tester.pumpAndSettle(); + await tester.tap(find.text('Root Dialog')); + await tester.pumpAndSettle(); + expect(find.text('Teal AlertDialog'), findsOneWidget); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(find.text('Teal AlertDialog'), findsNothing); + + await tester.tap(find.text('Local BottomSheet')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + + await tester.tap(find.text('Push /list')); + await tester.pumpAndSettle(); + expect(find.text('Teal ListPage - /list'), findsOneWidget); + }); + + testWidgets('ListPage', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: example.Home())); + expect(find.text('Teal RootPage - /'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Push /list')); + await tester.pumpAndSettle(); + expect(find.text('Teal ListPage - /list'), findsOneWidget); + expect(find.text('Push /text [0]'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Orange')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ElevatedButton, 'Push /list')); + await tester.pumpAndSettle(); + expect(find.text('Orange ListPage - /list'), findsOneWidget); + expect(find.text('Push /text [0]'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Orange RootPage - /'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Teal')); + await tester.pumpAndSettle(); + expect(find.text('Teal ListPage - /list'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Teal RootPage - /'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/navigation_drawer/navigation_drawer.0_test.dart b/packages/material_ui/material_ui_examples/test/navigation_drawer/navigation_drawer.0_test.dart new file mode 100644 index 000000000000..a1880cf5e049 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/navigation_drawer/navigation_drawer.0_test.dart @@ -0,0 +1,46 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/navigation_drawer/navigation_drawer.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Navigation bar updates destination on tap', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationDrawerApp()); + + await tester.tap(find.text('Open Drawer')); + await tester.pumpAndSettle(); + + final NavigationDrawer navigationDrawerWidget = tester.firstWidget( + find.byType(NavigationDrawer), + ); + + /// NavigationDestinations must be rendered + expect(find.text('Messages'), findsNWidgets(2)); + expect(find.text('Profile'), findsNWidgets(2)); + expect(find.text('Settings'), findsNWidgets(2)); + + /// Initial index must be zero + expect(navigationDrawerWidget.selectedIndex, 0); + expect(find.text('Page Index = 0'), findsOneWidget); + + /// Switch to second tab + await tester.tap( + find.ancestor(of: find.text('Profile'), matching: find.byType(InkWell)), + ); + await tester.pumpAndSettle(); + expect(find.text('Page Index = 1'), findsOneWidget); + + /// Switch to fourth tab + await tester.tap( + find.ancestor(of: find.text('Settings'), matching: find.byType(InkWell)), + ); + await tester.pumpAndSettle(); + expect(find.text('Page Index = 2'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/navigation_rail/navigation_rail.0_test.dart b/packages/material_ui/material_ui_examples/test/navigation_rail/navigation_rail.0_test.dart new file mode 100644 index 000000000000..db25ae070f79 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/navigation_rail/navigation_rail.0_test.dart @@ -0,0 +1,147 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/navigation_rail/navigation_rail.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('NavigationRail updates destination on tap', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationRailExampleApp()); + final NavigationRail navigationRailWidget = tester.firstWidget( + find.byType(NavigationRail), + ); + + /// NavigationRailDestinations must be rendered + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + expect(find.text('Third'), findsOneWidget); + + /// initial index must be zero + expect(navigationRailWidget.selectedIndex, 0); + + /// switch to second tab + await tester.tap(find.text('Second')); + await tester.pumpAndSettle(); + expect(find.text('selectedIndex: 1'), findsOneWidget); + + /// switch to third tab + await tester.tap(find.text('Third')); + await tester.pumpAndSettle(); + expect(find.text('selectedIndex: 2'), findsOneWidget); + }); + + testWidgets('NavigationRail updates label type', (WidgetTester tester) async { + await tester.pumpWidget(const example.NavigationRailExampleApp()); + + // initial label type set to all. + expect(find.text('Label type: all'), findsOneWidget); + + // switch to selected label type + await tester.tap(find.text('Selected')); + await tester.pumpAndSettle(); + expect(find.text('Label type: selected'), findsOneWidget); + + // switch to none label type + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + expect(find.text('Label type: none'), findsOneWidget); + }); + + testWidgets('Navigation rail updates group alignment', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationRailExampleApp()); + + // initial group alignment set top top. + expect(find.text('Group alignment: -1.0'), findsOneWidget); + + // switch to center alignment + await tester.tap( + find.descendant( + of: find.byType(SegmentedButton<double>), + matching: find.text('Center'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Group alignment: 0.0'), findsOneWidget); + + // switch to bottom alignment + await tester.tap(find.text('Bottom')); + await tester.pumpAndSettle(); + expect(find.text('Group alignment: 1.0'), findsOneWidget); + }); + + testWidgets('Navigation rail updates main axis alignment', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationRailExampleApp()); + + // Switch to End alignment + await tester.tap(find.text('End')); + await tester.pumpAndSettle(); + + NavigationRail rail = tester.firstWidget(find.byType(NavigationRail)); + expect(rail.mainAxisAlignment, MainAxisAlignment.end); + + // Switch to Center alignment (disambiguated) + await tester.tap( + find.descendant( + of: find.byType(SegmentedButton<MainAxisAlignment?>), + matching: find.text('Center'), + ), + ); + await tester.pumpAndSettle(); + + rail = tester.firstWidget(find.byType(NavigationRail)); + expect(rail.mainAxisAlignment, MainAxisAlignment.center); + }); + + testWidgets('NavigationRail shows leading/trailing widgets', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.NavigationRailExampleApp()); + + // Initially leading/trailing widgets are hidden. + expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Tap to show leading Widget. + await tester.tap(find.text('Show Leading')); + await tester.pumpAndSettle(); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + + // Tap to show trailing Widget. + await tester.tap(find.text('Show Trailing')); + await tester.pumpAndSettle(); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); + }); + + testWidgets('Destinations have badge', (WidgetTester tester) async { + await tester.pumpWidget(const example.NavigationRailExampleApp()); + + // Test badge without label. + final Badge notificationBadge = tester.firstWidget( + find.ancestor( + of: find.byIcon(Icons.bookmark_border), + matching: find.byType(Badge), + ), + ); + expect(notificationBadge.label, null); + + // Test badge with label. + final Badge messagesBadge = tester.firstWidget( + find.ancestor( + of: find.byIcon(Icons.star_border), + matching: find.byType(Badge), + ), + ); + expect(messagesBadge.label, isNotNull); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/navigation_rail/navigation_rail.extended_animation.0_test.dart b/packages/material_ui/material_ui_examples/test/navigation_rail/navigation_rail.extended_animation.0_test.dart new file mode 100644 index 000000000000..ef93ddabcced --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/navigation_rail/navigation_rail.extended_animation.0_test.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/navigation_rail/navigation_rail.extended_animation.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Navigation rail animates itself between the normal and extended state', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ExtendedAnimationExampleApp()); + + expect(find.text('Tap on FloatingActionButton to expand'), findsOne); + expect(find.widgetWithIcon(FloatingActionButton, Icons.add), findsOne); + expect(find.byIcon(Icons.favorite), findsOne); + expect(find.text('First'), findsOne); + expect(find.byIcon(Icons.bookmark_border), findsOne); + expect(find.text('Second'), findsOne); + expect(find.byIcon(Icons.star_border), findsOne); + expect(find.text('First'), findsOne); + + // The navigation rail should be in the normal state. + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(const Offset(40, 36), epsilon: 0.1), + ); + expect(find.widgetWithText(FloatingActionButton, 'CREATE'), findsNothing); + + // Expand the navigation rail. + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + await tester.pump(kThemeAnimationDuration * 0.5); + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(const Offset(128.1, 36), epsilon: 0.1), + ); + + await tester.pump(kThemeAnimationDuration * 0.5); + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(const Offset(132, 36), epsilon: 0.1), + ); + expect(find.widgetWithText(FloatingActionButton, 'CREATE'), findsOne); + + // Collapse the navigation rail. + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + await tester.pump(kThemeAnimationDuration * 0.5); + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(const Offset(128.1, 36), epsilon: 0.1), + ); + + await tester.pump(kThemeAnimationDuration * 0.5); + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(const Offset(40, 36), epsilon: 0.1), + ); + expect(find.widgetWithText(FloatingActionButton, 'CREATE'), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/outlined_button/outlined_button.0_test.dart b/packages/material_ui/material_ui_examples/test/outlined_button/outlined_button.0_test.dart new file mode 100644 index 000000000000..651a2f9df451 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/outlined_button/outlined_button.0_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/outlined_button/outlined_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('OutlinedButton Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.OutlinedButtonExampleApp()); + + expect( + find.widgetWithText(AppBar, 'OutlinedButton Sample'), + findsOneWidget, + ); + final Finder outlinedButton = find.widgetWithText( + OutlinedButton, + 'Click Me', + ); + expect(outlinedButton, findsOneWidget); + final OutlinedButton outlinedButtonWidget = tester.widget<OutlinedButton>( + outlinedButton, + ); + expect(outlinedButtonWidget.onPressed.runtimeType, VoidCallback); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.0_test.dart b/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.0_test.dart new file mode 100644 index 000000000000..db0e012e3ac0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.0_test.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. + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/page_transitions_theme/page_transitions_theme.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('MaterialApp defines a custom PageTransitionsTheme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PageTransitionsThemeApp()); + + final Finder homePage = find.byType(example.HomePage); + expect(homePage, findsOneWidget); + + final PageTransitionsTheme theme = Theme.of( + tester.element(homePage), + ).pageTransitionsTheme; + expect(theme.builders, isNotNull); + + // Check defined page transitions builder for each platform. + for (final TargetPlatform platform in TargetPlatform.values) { + switch (platform) { + case .iOS: + expect( + theme.builders[platform], + isA<CupertinoPageTransitionsBuilder>(), + ); + case .linux: + expect( + theme.builders[platform], + isA<OpenUpwardsPageTransitionsBuilder>(), + ); + case .macOS: + expect( + theme.builders[platform], + isA<FadeUpwardsPageTransitionsBuilder>(), + ); + case .android: + case .fuchsia: + case .windows: + expect(theme.builders[platform], isNull); + } + } + + // Can navigate to the second page. + expect(find.text('To SecondPage'), findsOneWidget); + await tester.tap(find.text('To SecondPage')); + await tester.pumpAndSettle(); + + // Can navigate back to the home page. + expect(find.text('Back to HomePage'), findsOneWidget); + await tester.tap(find.text('Back to HomePage')); + await tester.pumpAndSettle(); + expect(find.text('To SecondPage'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.1_test.dart b/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.1_test.dart new file mode 100644 index 000000000000..8cd5a5e796de --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.1_test.dart @@ -0,0 +1,52 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/page_transitions_theme/page_transitions_theme.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('MaterialApp defines a custom PageTransitionsTheme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PageTransitionsThemeApp()); + + final Finder homePage = find.byType(example.HomePage); + expect(homePage, findsOneWidget); + + final PageTransitionsTheme theme = Theme.of( + tester.element(homePage), + ).pageTransitionsTheme; + expect(theme.builders, isNotNull); + + // Check defined page transitions builder for each platform. + for (final TargetPlatform platform in TargetPlatform.values) { + switch (platform) { + case .android: + expect(theme.builders[platform], isA<ZoomPageTransitionsBuilder>()); + final ZoomPageTransitionsBuilder builder = + theme.builders[platform]! as ZoomPageTransitionsBuilder; + expect(builder.allowSnapshotting, isFalse); + case .iOS: + case .macOS: + case .linux: + case .fuchsia: + case .windows: + expect(theme.builders[platform], isNull); + } + } + + // Can navigate to the second page. + expect(find.text('To SecondPage'), findsOneWidget); + await tester.tap(find.text('To SecondPage')); + await tester.pumpAndSettle(); + + // Can navigate back to the home page. + expect(find.text('Back to HomePage'), findsOneWidget); + await tester.tap(find.text('Back to HomePage')); + await tester.pumpAndSettle(); + expect(find.text('To SecondPage'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.3_test.dart b/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.3_test.dart new file mode 100644 index 000000000000..7c53795ea09b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/page_transitions_theme/page_transitions_theme.3_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/page_transitions_theme/page_transitions_theme.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Page transition', (WidgetTester tester) async { + await tester.pumpWidget(const example.PageTransitionsThemeApp()); + + final Finder homePage = find.byType(example.HomePage); + expect(homePage, findsOneWidget); + + final Finder kitten0 = find.widgetWithText(ListTile, 'Kitten 0'); + expect(kitten0, findsOneWidget); + + await tester.tap(kitten0); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Kitten 0'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(ListTile, 'Kitten 0'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/paginated_data_table/paginated_data_table.0_test.dart b/packages/material_ui/material_ui_examples/test/paginated_data_table/paginated_data_table.0_test.dart new file mode 100644 index 000000000000..91ddc783aaec --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/paginated_data_table/paginated_data_table.0_test.dart @@ -0,0 +1,14 @@ +// 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:material_ui_examples/paginated_data_table/paginated_data_table.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('PaginatedDataTable 0', (WidgetTester tester) async { + await tester.pumpWidget(const example.DataTableExampleApp()); + expect(find.text('Associate Professor'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/paginated_data_table/paginated_data_table.1_test.dart b/packages/material_ui/material_ui_examples/test/paginated_data_table/paginated_data_table.1_test.dart new file mode 100644 index 000000000000..bee563aec1b2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/paginated_data_table/paginated_data_table.1_test.dart @@ -0,0 +1,24 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/paginated_data_table/paginated_data_table.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('PaginatedDataTable 1', (WidgetTester tester) async { + await tester.pumpWidget(const example.DataTableExampleApp()); + expect(find.text('Strange New Worlds'), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_upward).at(1)); + await tester.pump(); + expect(find.text('Strange New Worlds'), findsNothing); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pump(); + expect(find.text('Strange New Worlds'), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_upward).at(1)); + await tester.pump(); + expect(find.text('Strange New Worlds'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/platform_menu_bar/platform_menu_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/platform_menu_bar/platform_menu_bar.0_test.dart new file mode 100644 index 000000000000..2b27413175db --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/platform_menu_bar/platform_menu_bar.0_test.dart @@ -0,0 +1,134 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/platform_menu_bar/platform_menu_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeMenuChannel fakeMenuChannel; + late PlatformMenuDelegate originalDelegate; + late DefaultPlatformMenuDelegate delegate; + + setUp(() { + fakeMenuChannel = _FakeMenuChannel((MethodCall call) async {}); + delegate = DefaultPlatformMenuDelegate(channel: fakeMenuChannel); + originalDelegate = WidgetsBinding.instance.platformMenuDelegate; + WidgetsBinding.instance.platformMenuDelegate = delegate; + }); + + tearDown(() { + WidgetsBinding.instance.platformMenuDelegate = originalDelegate; + }); + + testWidgets( + 'PlatformMenuBar creates a menu', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ExampleApp()); + + expect( + find.text( + 'This space intentionally left blank.\nShow a message here using the menu.', + ), + findsOne, + ); + expect(find.byType(PlatformMenuBar), findsOne); + + expect(fakeMenuChannel.outgoingCalls.last.method, 'Menu.setMenus'); + expect( + fakeMenuChannel.outgoingCalls.last.arguments, + equals(const <String, Object?>{ + '0': <Map<String, Object>>[ + <String, Object>{ + 'id': 11, + 'label': 'Flutter API Sample', + 'enabled': true, + 'children': <Map<String, Object>>[ + <String, Object>{'id': 2, 'label': 'About', 'enabled': true}, + <String, Object>{'id': 3, 'isDivider': true}, + <String, Object>{ + 'id': 5, + 'label': 'Show Message', + 'enabled': true, + 'shortcutCharacter': 'm', + 'shortcutModifiers': 0, + }, + <String, Object>{ + 'id': 8, + 'label': 'Messages', + 'enabled': true, + 'children': <Map<String, Object>>[ + <String, Object>{ + 'id': 6, + 'label': 'I am not throwing away my shot.', + 'enabled': true, + 'shortcutTrigger': 49, + 'shortcutModifiers': 1, + }, + <String, Object>{ + 'id': 7, + 'label': + "There's a million things I haven't done, but just you wait.", + 'enabled': true, + 'shortcutTrigger': 50, + 'shortcutModifiers': 1, + }, + ], + }, + <String, Object>{'id': 9, 'isDivider': true}, + <String, Object>{ + 'id': 10, + 'enabled': true, + 'platformProvidedMenu': 1, + }, + ], + }, + ], + }), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{.macOS}), + ); +} + +class _FakeMenuChannel implements MethodChannel { + _FakeMenuChannel(this.outgoing); + + Future<dynamic> Function(MethodCall) outgoing; + Future<void> Function(MethodCall)? incoming; + + List<MethodCall> outgoingCalls = <MethodCall>[]; + + @override + BinaryMessenger get binaryMessenger => throw UnimplementedError(); + + @override + MethodCodec get codec => const StandardMethodCodec(); + + @override + Future<List<T>> invokeListMethod<T>(String method, [dynamic arguments]) => + throw UnimplementedError(); + + @override + Future<Map<K, V>> invokeMapMethod<K, V>(String method, [dynamic arguments]) => + throw UnimplementedError(); + + @override + Future<T> invokeMethod<T>(String method, [dynamic arguments]) async { + final MethodCall call = MethodCall(method, arguments); + outgoingCalls.add(call); + return await outgoing(call) as T; + } + + @override + String get name => 'flutter/menu'; + + @override + void setMethodCallHandler(Future<void> Function(MethodCall call)? handler) => + incoming = handler; +} diff --git a/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.0_test.dart b/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.0_test.dart new file mode 100644 index 000000000000..bb83454bf0f3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.0_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/popup_menu/popup_menu.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open popup menu', (WidgetTester tester) async { + const String menuItem = 'Item 1'; + + await tester.pumpWidget(const example.PopupMenuApp()); + + expect(find.text(menuItem), findsNothing); + + // Open popup menu. + await tester.tap(find.byIcon(Icons.adaptive.more)); + await tester.pumpAndSettle(); + expect(find.text(menuItem), findsOneWidget); + + // Close popup menu. + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + expect(find.text(menuItem), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.1_test.dart b/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.1_test.dart new file mode 100644 index 000000000000..b062a881544f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.1_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/popup_menu/popup_menu.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open popup menu', (WidgetTester tester) async { + const String menuItem = 'Item 1'; + + await tester.pumpWidget(const example.PopupMenuApp()); + + expect(find.text(menuItem), findsNothing); + + // Open popup menu. + await tester.tap(find.byIcon(Icons.adaptive.more)); + await tester.pumpAndSettle(); + expect(find.text(menuItem), findsOneWidget); + + // Close popup menu. + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + expect(find.text(menuItem), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.2_test.dart b/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.2_test.dart new file mode 100644 index 000000000000..f6385d117119 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/popup_menu/popup_menu.2_test.dart @@ -0,0 +1,79 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/popup_menu/popup_menu.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Popup animation can be customized using AnimationStyle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PopupMenuApp()); + + // Test the default popup animation. + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pump(); + // Advance the animation by half of the default duration. + await tester.pump(const Duration(milliseconds: 100)); + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(224.0, 130.0)), + ); + + // Let the animation finish. + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(224.0, 312.0)), + ); + + // Tap outside the popup menu to close it. + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + // Test the custom animation curve and duration. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pump(); + // Advance the animation by one third of the custom duration. + await tester.pump(const Duration(milliseconds: 1000)); + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(224.0, 312.0)), + ); + + // Let the animation finish. + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(224.0, 312.0)), + ); + + // Tap outside the popup menu to close it. + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + // Test the no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + // Advance the animation by only one frame. + await tester.pump(); + + // The popup menu is shown immediately. + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(224.0, 312.0)), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.0_test.dart b/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.0_test.dart new file mode 100644 index 000000000000..4235e938c7c4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.0_test.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/progress_indicator/circular_progress_indicator.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Determinate CircularProgressIndicator uses the provided value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + await tester.pump(const Duration(milliseconds: 2500)); + + final Finder indicatorFinder = find.byType(CircularProgressIndicator).first; + final CircularProgressIndicator progressIndicator = tester.widget( + indicatorFinder, + ); + expect(progressIndicator.value, equals(0.5)); + }); + + testWidgets('Indeterminate CircularProgressIndicator does not have a value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + await tester.pump(const Duration(milliseconds: 2500)); + + final Finder indicatorFinder = find.byType(CircularProgressIndicator).last; + final CircularProgressIndicator progressIndicator = tester.widget( + indicatorFinder, + ); + expect(progressIndicator.value, null); + }); + + testWidgets('Progress indicators year2023 flag can be toggled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + + CircularProgressIndicator determinateIndicator = tester + .widget<CircularProgressIndicator>( + find.byType(CircularProgressIndicator).first, + ); + // ignore: deprecated_member_use + expect(determinateIndicator.year2023, true); + CircularProgressIndicator indeterminateIndicator = tester + .widget<CircularProgressIndicator>( + find.byType(CircularProgressIndicator).last, + ); + // ignore: deprecated_member_use + expect(indeterminateIndicator.year2023, true); + + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(); + + determinateIndicator = tester.widget<CircularProgressIndicator>( + find.byType(CircularProgressIndicator).first, + ); + // ignore: deprecated_member_use + expect(determinateIndicator.year2023, false); + indeterminateIndicator = tester.widget<CircularProgressIndicator>( + find.byType(CircularProgressIndicator).last, + ); + // ignore: deprecated_member_use + expect(indeterminateIndicator.year2023, false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.1_test.dart b/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.1_test.dart new file mode 100644 index 000000000000..972e51f893d2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.1_test.dart @@ -0,0 +1,34 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/progress_indicator/circular_progress_indicator.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Finds CircularProgressIndicator', (WidgetTester tester) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + + expect( + find.bySemanticsLabel('Circular progress indicator').first, + findsOneWidget, + ); + + // Test if CircularProgressIndicator is animating. + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + + // Test determinate mode button. + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(tester.hasRunningAnimations, isFalse); + + await tester.tap(find.byType(Switch)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.2_test.dart b/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.2_test.dart new file mode 100644 index 000000000000..fbd0e07bc350 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/progress_indicator/circular_progress_indicator.2_test.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. + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/progress_indicator/circular_progress_indicator.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Buttons work', (WidgetTester tester) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + + expect(find.byType(CircularProgressIndicator), findsOne); + + await tester.tap(find.text('More indicators')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNWidgets(2)); + + await tester.tap(find.text('More indicators')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNWidgets(3)); + + await tester.tap(find.text('Fewer indicators')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsNWidgets(2)); + + expect(find.text('Theme controller? Yes'), findsOne); + await tester.tap(find.text('Toggle')); + await tester.pump(); + expect(find.text('Theme controller? No'), findsOne); + }); + + testWidgets('Theme controller can coordinate progress', ( + WidgetTester tester, + ) async { + final AnimationController controller = AnimationController( + vsync: tester, + value: 0.5, + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + Theme( + data: ThemeData( + progressIndicatorTheme: ProgressIndicatorThemeData( + controller: controller, + ), + ), + child: const example.ManyProgressIndicators(indicatorNum: 4), + ), + ); + expect(find.byType(CircularProgressIndicator), findsNWidgets(4)); + for (int i = 0; i < 4; i++) { + expect( + find.byType(CircularProgressIndicator).at(i), + paints..arc(startAngle: 1.5707963267948966, sweepAngle: 0.001), + ); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/progress_indicator/linear_progress_indicator.0_test.dart b/packages/material_ui/material_ui_examples/test/progress_indicator/linear_progress_indicator.0_test.dart new file mode 100644 index 000000000000..f1272a151f2d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/progress_indicator/linear_progress_indicator.0_test.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/progress_indicator/linear_progress_indicator.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Determinate LinearProgressIndicator uses the provided value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + await tester.pump(const Duration(milliseconds: 2500)); + + final Finder indicatorFinder = find.byType(LinearProgressIndicator).first; + final LinearProgressIndicator progressIndicator = tester.widget( + indicatorFinder, + ); + expect(progressIndicator.value, equals(0.5)); + }); + + testWidgets('Indeterminate LinearProgressIndicator does not have a value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + await tester.pump(const Duration(milliseconds: 2500)); + + final Finder indicatorFinder = find.byType(LinearProgressIndicator).last; + final LinearProgressIndicator progressIndicator = tester.widget( + indicatorFinder, + ); + expect(progressIndicator.value, null); + }); + + testWidgets('Progress indicators year2023 flag can be toggled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + + LinearProgressIndicator determinateIndicator = tester + .widget<LinearProgressIndicator>( + find.byType(LinearProgressIndicator).first, + ); + // ignore: deprecated_member_use + expect(determinateIndicator.year2023, true); + LinearProgressIndicator indeterminateIndicator = tester + .widget<LinearProgressIndicator>( + find.byType(LinearProgressIndicator).last, + ); + // ignore: deprecated_member_use + expect(indeterminateIndicator.year2023, true); + + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(); + + determinateIndicator = tester.widget<LinearProgressIndicator>( + find.byType(LinearProgressIndicator).first, + ); + // ignore: deprecated_member_use + expect(determinateIndicator.year2023, false); + indeterminateIndicator = tester.widget<LinearProgressIndicator>( + find.byType(LinearProgressIndicator).last, + ); + // ignore: deprecated_member_use + expect(indeterminateIndicator.year2023, false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/progress_indicator/linear_progress_indicator.1_test.dart b/packages/material_ui/material_ui_examples/test/progress_indicator/linear_progress_indicator.1_test.dart new file mode 100644 index 000000000000..7f37430f1c12 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/progress_indicator/linear_progress_indicator.1_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/progress_indicator/linear_progress_indicator.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can control LinearProgressIndicator value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ProgressIndicatorExampleApp()); + + expect( + find.bySemanticsLabel('Linear progress indicator').first, + findsOneWidget, + ); + + // Test if LinearProgressIndicator is animating. + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + + // Test determinate mode button. + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(tester.hasRunningAnimations, isFalse); + + await tester.tap(find.byType(Switch)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio/radio.0_test.dart b/packages/material_ui/material_ui_examples/test/radio/radio.0_test.dart new file mode 100644 index 000000000000..c4d547e6d51a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio/radio.0_test.dart @@ -0,0 +1,58 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio/radio.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Radio Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioExampleApp()); + + expect(find.widgetWithText(AppBar, 'Radio Sample'), findsOneWidget); + final Finder listTile1 = find.widgetWithText(ListTile, 'Lafayette'); + expect(listTile1, findsOneWidget); + final Finder listTile2 = find.widgetWithText(ListTile, 'Thomas Jefferson'); + expect(listTile2, findsOneWidget); + + final Finder radioButton1 = find + .byType(Radio<example.SingingCharacter>) + .first; + final Finder radioButton2 = find + .byType(Radio<example.SingingCharacter>) + .last; + final Finder radioGroup = find + .byType(RadioGroup<example.SingingCharacter>) + .last; + + await tester.tap(radioButton1); + await tester.pumpAndSettle(); + expect( + tester + .widget<RadioGroup<example.SingingCharacter>>(radioGroup) + .groupValue, + tester.widget<Radio<example.SingingCharacter>>(radioButton1).value, + ); + expect( + tester + .widget<RadioGroup<example.SingingCharacter>>(radioGroup) + .groupValue, + isNot(tester.widget<Radio<example.SingingCharacter>>(radioButton2).value), + ); + await tester.tap(radioButton2); + await tester.pumpAndSettle(); + expect( + tester + .widget<RadioGroup<example.SingingCharacter>>(radioGroup) + .groupValue, + isNot(tester.widget<Radio<example.SingingCharacter>>(radioButton1).value), + ); + expect( + tester + .widget<RadioGroup<example.SingingCharacter>>(radioGroup) + .groupValue, + tester.widget<Radio<example.SingingCharacter>>(radioButton2).value, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio/radio.1_test.dart b/packages/material_ui/material_ui_examples/test/radio/radio.1_test.dart new file mode 100644 index 000000000000..cbb9408d5316 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio/radio.1_test.dart @@ -0,0 +1,83 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio/radio.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Radio colors can be changed', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioExampleApp()); + + expect(find.widgetWithText(AppBar, 'Radio Sample'), findsOne); + expect(find.widgetWithText(ListTile, 'Fill color'), findsOne); + expect(find.widgetWithText(ListTile, 'Background color'), findsOne); + expect(find.widgetWithText(ListTile, 'Side'), findsOne); + expect(find.widgetWithText(ListTile, 'Inner radius'), findsOne); + + final Radio<example.RadioType> radioFillColor = tester + .widget<Radio<example.RadioType>>( + find.byType(Radio<example.RadioType>).first, + ); + expect( + radioFillColor.fillColor!.resolve(const <WidgetState>{ + WidgetState.selected, + }), + Colors.deepPurple, + ); + expect( + radioFillColor.fillColor!.resolve(const <WidgetState>{}), + Colors.deepPurple.shade200, + ); + + final Radio<example.RadioType> radioBackgroundColor = tester + .widget<Radio<example.RadioType>>( + find.byType(Radio<example.RadioType>).at(1), + ); + expect( + radioBackgroundColor.backgroundColor!.resolve(const <WidgetState>{ + WidgetState.selected, + }), + Colors.greenAccent.withValues(alpha: 0.5), + ); + expect( + radioBackgroundColor.backgroundColor!.resolve(const <WidgetState>{}), + Colors.grey.shade300.withValues(alpha: 0.3), + ); + + final Radio<example.RadioType> radioSide = tester + .widget<Radio<example.RadioType>>( + find.byType(Radio<example.RadioType>).at(2), + ); + expect( + (radioSide.side! as WidgetStateBorderSide).resolve(const <WidgetState>{ + WidgetState.selected, + }), + const BorderSide( + color: Colors.red, + width: 4, + strokeAlign: BorderSide.strokeAlignCenter, + ), + ); + expect( + (radioSide.side! as WidgetStateBorderSide).resolve(const <WidgetState>{}), + const BorderSide( + color: Colors.grey, + width: 1.5, + strokeAlign: BorderSide.strokeAlignCenter, + ), + ); + + final Radio<example.RadioType> radioInnerRadius = tester + .widget<Radio<example.RadioType>>( + find.byType(Radio<example.RadioType>).last, + ); + expect( + radioInnerRadius.innerRadius!.resolve(const <WidgetState>{ + WidgetState.selected, + }), + 6, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio/radio.toggleable.0_test.dart b/packages/material_ui/material_ui_examples/test/radio/radio.toggleable.0_test.dart new file mode 100644 index 000000000000..6a56fa9daecf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio/radio.toggleable.0_test.dart @@ -0,0 +1,35 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio/radio.toggleable.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('StreamBuilder listens to internal stream', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ToggleableExampleApp()); + + expect(find.byType(Radio<int>), findsExactly(5)); + expect(find.text('Hercules Mulligan'), findsOne); + expect(find.text('Eliza Hamilton'), findsOne); + expect(find.text('Philip Schuyler'), findsOne); + expect(find.text('Maria Reynolds'), findsOne); + expect(find.text('Samuel Seabury'), findsOne); + + for (int i = 0; i < 5; i++) { + await tester.tap(find.byType(Radio<int>).at(i)); + await tester.pump(); + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is RadioGroup<int> && widget.groupValue == i, + ), + findsOne, + ); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio_list_tile/custom_labeled_radio.0_test.dart b/packages/material_ui/material_ui_examples/test/radio_list_tile/custom_labeled_radio.0_test.dart new file mode 100644 index 000000000000..fa51acb71a83 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio_list_tile/custom_labeled_radio.0_test.dart @@ -0,0 +1,34 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio_list_tile/custom_labeled_radio.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('LinkedLabelRadio contains RichText and Radio', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabeledRadioApp()); + + // Label text is in a RichText widget with the correct text. + final RichText richText = tester.widget(find.byType(RichText).first); + expect(richText.text.toPlainText(), 'First tappable label text'); + + RadioGroup<bool> group = tester.widget<RadioGroup<bool>>( + find.byType(RadioGroup<bool>), + ); + // Second radio is checked. + expect(group.groupValue, isFalse); + + // Tap the first radio. + await tester.tap(find.byType(Radio<bool>).first); + await tester.pump(); + + // First Radio is now checked. + group = tester.widget<RadioGroup<bool>>(find.byType(RadioGroup<bool>)); + expect(group.groupValue, true); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio_list_tile/custom_labeled_radio.1_test.dart b/packages/material_ui/material_ui_examples/test/radio_list_tile/custom_labeled_radio.1_test.dart new file mode 100644 index 000000000000..4847e5de8515 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio_list_tile/custom_labeled_radio.1_test.dart @@ -0,0 +1,30 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio_list_tile/custom_labeled_radio.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tapping LabeledRadio toggles the radio', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabeledRadioApp()); + + RadioGroup<bool> group = tester.widget<RadioGroup<bool>>( + find.byType(RadioGroup<bool>), + ); + // Second radio is checked. + expect(group.groupValue, isFalse); + + // Tap the first labeled radio to toggle the Radio widget. + await tester.tap(find.byType(example.LabeledRadio).first); + await tester.pumpAndSettle(); + + group = tester.widget<RadioGroup<bool>>(find.byType(RadioGroup<bool>)); + // Second radio is checked. + expect(group.groupValue, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.0_test.dart b/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.0_test.dart new file mode 100644 index 000000000000..8c450d8f7202 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.0_test.dart @@ -0,0 +1,41 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio_list_tile/radio_list_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can update RadioListTile group value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RadioListTileApp()); + + // Find the number of RadioListTiles. + expect( + find.byType(RadioListTile<example.SingingCharacter>), + findsNWidgets(2), + ); + + // The initial group value is lafayette. + RadioGroup<example.SingingCharacter> group = tester + .widget<RadioGroup<example.SingingCharacter>>( + find.byType(RadioGroup<example.SingingCharacter>), + ); + // Second radio is checked. + expect(group.groupValue, example.SingingCharacter.lafayette); + + // Tap the last RadioListTile to change the group value to jefferson. + await tester.tap(find.byType(RadioListTile<example.SingingCharacter>).last); + await tester.pump(); + + // The group value is now jefferson. + group = tester.widget<RadioGroup<example.SingingCharacter>>( + find.byType(RadioGroup<example.SingingCharacter>), + ); + // Second radio is checked. + expect(group.groupValue, example.SingingCharacter.jefferson); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.1_test.dart b/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.1_test.dart new file mode 100644 index 000000000000..baf242d41ce0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.1_test.dart @@ -0,0 +1,79 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio_list_tile/radio_list_tile.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Radio aligns appropriately', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioListTileApp()); + + expect(find.byType(RadioListTile<example.Groceries>), findsNWidgets(3)); + + Offset tileTopLeft = tester.getTopLeft( + find.byType(RadioListTile<example.Groceries>).at(0), + ); + Offset radioTopLeft = tester.getTopLeft( + find.byType(Radio<example.Groceries>).at(0), + ); + + // The radio is centered vertically with the text. + expect(radioTopLeft - tileTopLeft, const Offset(16.0, 16.0)); + + tileTopLeft = tester.getTopLeft( + find.byType(RadioListTile<example.Groceries>).at(1), + ); + radioTopLeft = tester.getTopLeft( + find.byType(Radio<example.Groceries>).at(1), + ); + + // The radio is centered vertically with the text. + expect(radioTopLeft - tileTopLeft, const Offset(16.0, 30.0)); + + tileTopLeft = tester.getTopLeft( + find.byType(RadioListTile<example.Groceries>).at(2), + ); + radioTopLeft = tester.getTopLeft( + find.byType(Radio<example.Groceries>).at(2), + ); + + // The radio is aligned to the top vertically with the text. + expect(radioTopLeft - tileTopLeft, const Offset(16.0, 8.0)); + }); + + testWidgets('Radios can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioListTileApp()); + + expect(find.byType(RadioListTile<example.Groceries>), findsNWidgets(3)); + + // Initially the first radio is checked. + RadioGroup<example.Groceries> group = tester + .widget<RadioGroup<example.Groceries>>( + find.byType(RadioGroup<example.Groceries>), + ); + expect(group.groupValue, example.Groceries.pickles); + + // Tap the second radio. + await tester.tap(find.byType(Radio<example.Groceries>).at(1)); + await tester.pumpAndSettle(); + + // The second radio is checked. + group = tester.widget<RadioGroup<example.Groceries>>( + find.byType(RadioGroup<example.Groceries>), + ); + expect(group.groupValue, example.Groceries.tomato); + + // Tap the third radio. + await tester.tap(find.byType(Radio<example.Groceries>).at(2)); + await tester.pumpAndSettle(); + + // The third radio is checked. + group = tester.widget<RadioGroup<example.Groceries>>( + find.byType(RadioGroup<example.Groceries>), + ); + expect(group.groupValue, example.Groceries.lettuce); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.toggleable.0_test.dart b/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.toggleable.0_test.dart new file mode 100644 index 000000000000..617ee0abe5a1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/radio_list_tile/radio_list_tile.toggleable.0_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/radio_list_tile/radio_list_tile.toggleable.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('RadioListTile is toggleable', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioListTileApp()); + + // Initially the third radio button is not selected. + RadioGroup<int> group = tester.widget<RadioGroup<int>>( + find.byType(RadioGroup<int>), + ); + expect(group.groupValue, null); + + // Tap the third radio button. + await tester.tap(find.text('Philip Schuyler')); + await tester.pumpAndSettle(); + + // The third radio button is now selected. + group = tester.widget<RadioGroup<int>>(find.byType(RadioGroup<int>)); + expect(group.groupValue, 2); + + // Tap the third radio button again. + await tester.tap(find.text('Philip Schuyler')); + await tester.pumpAndSettle(); + + // The third radio button is now unselected. + group = tester.widget<RadioGroup<int>>(find.byType(RadioGroup<int>)); + expect(group.groupValue, null); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/range_slider/range_slider.0_test.dart b/packages/material_ui/material_ui_examples/test/range_slider/range_slider.0_test.dart new file mode 100644 index 000000000000..dfb2d4dbffa8 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/range_slider/range_slider.0_test.dart @@ -0,0 +1,95 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/range_slider/range_slider.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The range slider should have 5 divisions from 0 to 100', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RangeSliderExampleApp()); + + expect(find.widgetWithText(AppBar, 'RangeSlider Sample'), findsOne); + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is RangeSlider && widget.values == const RangeValues(40, 80), + ), + findsOne, + ); + + final Rect rangeSliderRect = tester.getRect(find.byType(RangeSlider)); + + final double y = rangeSliderRect.centerRight.dy; + final double startX = rangeSliderRect.centerLeft.dx; + final double endX = rangeSliderRect.centerRight.dx; + + // Drag the start to 0. + final TestGesture drag = await tester.startGesture( + Offset(startX + (endX - startX) * 0.4, y), + ); + await tester.pump(kPressTimeout); + await drag.moveTo(rangeSliderRect.centerLeft); + await drag.up(); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is RangeSlider && widget.values == const RangeValues(0, 80), + ), + findsOne, + ); + + // Drag the start to 20. + await drag.down(rangeSliderRect.centerLeft); + await tester.pump(kPressTimeout); + await drag.moveTo(Offset(startX + (endX - startX) * 0.2, y)); + await drag.up(); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is RangeSlider && widget.values == const RangeValues(20, 80), + ), + findsOne, + ); + + // Drag the end to 60. + await drag.down(Offset(startX + (endX - startX) * 0.8, y)); + await tester.pump(kPressTimeout); + await drag.moveTo(Offset(startX + (endX - startX) * 0.6, y)); + await drag.up(); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is RangeSlider && widget.values == const RangeValues(20, 60), + ), + findsOne, + ); + + // Drag the end to 100. + await drag.down(Offset(startX + (endX - startX) * 0.6, y)); + await tester.pump(kPressTimeout); + await drag.moveTo(rangeSliderRect.centerRight); + await drag.up(); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is RangeSlider && + widget.values == const RangeValues(20, 100), + ), + findsOne, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.0_test.dart b/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.0_test.dart new file mode 100644 index 000000000000..3169c4f2fbaf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.0_test.dart @@ -0,0 +1,40 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/refresh_indicator/refresh_indicator.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Trigger RefreshIndicator - Pull from top', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RefreshIndicatorExampleApp()); + + await tester.fling(find.text('Item 1'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect( + tester.getCenter(find.byType(RefreshProgressIndicator)).dy, + lessThan(300.0), + ); + await tester.pumpAndSettle(); // Advance pending time + }); + + testWidgets('Trigger RefreshIndicator - Button', (WidgetTester tester) async { + await tester.pumpWidget(const example.RefreshIndicatorExampleApp()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect( + tester.getCenter(find.byType(RefreshProgressIndicator)).dy, + lessThan(300.0), + ); + await tester.pumpAndSettle(); // Advance pending time + }); +} diff --git a/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.1_test.dart b/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.1_test.dart new file mode 100644 index 000000000000..cfa9d84f962b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.1_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/refresh_indicator/refresh_indicator.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Pulling from nested scroll view triggers refresh indicator', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RefreshIndicatorExampleApp()); + + // Pull from the upper scroll view. + await tester.fling( + find.text('Pull down here').first, + const Offset(0.0, 300.0), + 1000.0, + ); + await tester.pump(); + expect(find.byType(RefreshProgressIndicator), findsNothing); + await tester.pumpAndSettle(); // Advance pending time + + // Pull from the nested scroll view. + await tester.fling( + find.text('Pull down here').at(3), + const Offset(0.0, 300.0), + 1000.0, + ); + await tester.pump(); + expect(find.byType(RefreshProgressIndicator), findsOneWidget); + await tester.pumpAndSettle(); // Advance pending time + }); +} diff --git a/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.2_test.dart b/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.2_test.dart new file mode 100644 index 000000000000..6cf379c00587 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/refresh_indicator/refresh_indicator.2_test.dart @@ -0,0 +1,62 @@ +// 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:ui'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/refresh_indicator/refresh_indicator.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Pulling from scroll view triggers a refresh indicator which shows a CircularProgressIndicator', + (WidgetTester tester) async { + await tester.pumpWidget(const example.RefreshIndicatorExampleApp()); + + // Pull the first item. + await tester.fling( + find.text('Pull down here').first, + const Offset(0.0, 300.0), + 1000.0, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(RefreshProgressIndicator), findsNothing); + expect( + find.bySemanticsLabel('Circular progress indicator'), + findsOneWidget, + ); + + await tester.pumpAndSettle(); // Advance pending time. + }, + ); + + testWidgets('Pulling with mouse pointer triggers a refresh indicator', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RefreshIndicatorExampleApp()); + + // Simulate a mouse drag gesture to trigger the refresh. + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.text('Pull down here').first), + kind: PointerDeviceKind.mouse, + ); + await gesture.moveBy(const Offset(0.0, 300.0)); + await tester.pump(); + await gesture.up(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + + expect( + find.bySemanticsLabel('Circular progress indicator'), + findsOneWidget, + ); + + await tester.pumpAndSettle(); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.0_test.dart b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.0_test.dart new file mode 100644 index 000000000000..225d9dff3d32 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.0_test.dart @@ -0,0 +1,37 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/reorderable_list/reorderable_list_view.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Content is reordered after a drag', (WidgetTester tester) async { + await tester.pumpWidget(const example.ReorderableApp()); + + bool item1IsBeforeItem2() { + final Iterable<Text> texts = tester.widgetList<Text>(find.byType(Text)); + final List<String?> labels = texts + .map((final Text text) => text.data) + .toList(); + return labels.indexOf('Item 1') < labels.indexOf('Item 2'); + } + + expect(item1IsBeforeItem2(), true); + + // Drag 'Item 1' after 'Item 4'. + final TestGesture drag = await tester.startGesture( + tester.getCenter(find.text('Item 1')), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + await drag.moveTo(tester.getCenter(find.text('Item 4'))); + await drag.up(); + await tester.pumpAndSettle(); + + expect(item1IsBeforeItem2(), false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.1_test.dart b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.1_test.dart new file mode 100644 index 000000000000..73985b3db0d6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.1_test.dart @@ -0,0 +1,33 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/reorderable_list/reorderable_list_view.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Dragged item color is updated', (WidgetTester tester) async { + await tester.pumpWidget(const example.ReorderableApp()); + + final ThemeData theme = Theme.of(tester.element(find.byType(MaterialApp))); + + // Dragged item is wrapped in a Material widget with correct color. + final TestGesture drag = await tester.startGesture( + tester.getCenter(find.text('Item 1')), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + final Material material = tester.widget<Material>( + find.ancestor(of: find.text('Item 1'), matching: find.byType(Material)), + ); + expect(material.color, theme.colorScheme.secondary); + + // Ends the drag gesture. + await drag.moveTo(tester.getCenter(find.text('Item 4'))); + await drag.up(); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.2_test.dart b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.2_test.dart new file mode 100644 index 000000000000..da1763fb3910 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.2_test.dart @@ -0,0 +1,38 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/reorderable_list/reorderable_list_view.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Dragged Card is elevated', (WidgetTester tester) async { + await tester.pumpWidget(const example.ReorderableApp()); + + Card findCardOne() { + return tester.widget<Card>( + find.ancestor(of: find.text('Card 1'), matching: find.byType(Card)), + ); + } + + // Card has default elevation when not dragged. + expect(findCardOne().elevation, null); + + // Dragged card is elevated. + final TestGesture drag = await tester.startGesture( + tester.getCenter(find.text('Card 1')), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + expect(findCardOne().elevation, 6); + + // After the drag gesture ends, the card elevation has default value. + await drag.moveTo(tester.getCenter(find.text('Card 4'))); + await drag.up(); + await tester.pumpAndSettle(); + expect(findCardOne().elevation, null); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.build_default_drag_handles.0_test.dart b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.build_default_drag_handles.0_test.dart new file mode 100644 index 000000000000..0a4e41537cfb --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.build_default_drag_handles.0_test.dart @@ -0,0 +1,35 @@ +// 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/gestures.dart'; +import 'package:material_ui_examples/reorderable_list/reorderable_list_view.build_default_drag_handles.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future<void> longPressDrag( + WidgetTester tester, + Offset start, + Offset end, + ) async { + final TestGesture drag = await tester.startGesture(start); + await tester.pump(kLongPressTimeout + kPressTimeout); + await drag.moveTo(end); + await tester.pump(kPressTimeout); + await drag.up(); + } + + testWidgets('Reorder list item', (WidgetTester tester) async { + await tester.pumpWidget(const example.ReorderableApp()); + + expect(tester.getCenter(find.text('Item 3')).dy, 280.0); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 3')), + tester.getCenter(find.text('Item 2')), + ); + await tester.pumpAndSettle(); + expect(tester.getCenter(find.text('Item 3')).dy, 216.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0_test.dart b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0_test.dart new file mode 100644 index 000000000000..884b318ae313 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0_test.dart @@ -0,0 +1,35 @@ +// 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/gestures.dart'; +import 'package:material_ui_examples/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future<void> longPressDrag( + WidgetTester tester, + Offset start, + Offset end, + ) async { + final TestGesture drag = await tester.startGesture(start); + await tester.pump(kLongPressTimeout + kPressTimeout); + await drag.moveTo(end); + await tester.pump(kPressTimeout); + await drag.up(); + } + + testWidgets('Reorder list item', (WidgetTester tester) async { + await tester.pumpWidget(const example.ReorderableApp()); + + expect(tester.getCenter(find.text('Item 3')).dy, 252.0); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 3')), + tester.getCenter(find.text('Item 2')), + ); + await tester.pumpAndSettle(); + expect(tester.getCenter(find.text('Item 3')).dy, 196.0); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.0_test.dart new file mode 100644 index 000000000000..75ad48da9e08 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.0_test.dart @@ -0,0 +1,27 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The count should be incremented when the floating action button is tapped', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ScaffoldExampleApp()); + + expect(find.widgetWithText(AppBar, 'Sample Code'), findsOne); + expect(find.widgetWithIcon(FloatingActionButton, Icons.add), findsOne); + expect(find.text('You have pressed the button 0 times.'), findsOne); + + for (int i = 1; i <= 5; i++) { + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + expect(find.text('You have pressed the button $i times.'), findsOne); + } + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.1_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.1_test.dart new file mode 100644 index 000000000000..d6733fda1f23 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.1_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The count should be incremented when the floating action button is tapped', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ScaffoldExampleApp()); + + expect(find.widgetWithText(AppBar, 'Sample Code'), findsOne); + expect(find.widgetWithIcon(FloatingActionButton, Icons.add), findsOne); + expect(find.text('You have pressed the button 0 times.'), findsOne); + + for (int i = 1; i <= 5; i++) { + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + expect(find.text('You have pressed the button $i times.'), findsOne); + } + + final Scaffold scaffold = tester.firstWidget<Scaffold>( + find.byType(Scaffold), + ); + expect(scaffold.backgroundColor, Colors.blueGrey.shade200); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.2_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.2_test.dart new file mode 100644 index 000000000000..075073b2b76c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.2_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The count should be incremented when the centered floating action button is tapped', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ScaffoldExampleApp()); + + expect(find.widgetWithText(AppBar, 'Sample Code'), findsOne); + expect(find.widgetWithIcon(FloatingActionButton, Icons.add), findsOne); + expect(find.text('You have pressed the button 0 times.'), findsOne); + expect(find.byType(BottomAppBar), findsOne); + expect(tester.getCenter(find.byType(FloatingActionButton)).dx, 400); + + for (int i = 1; i <= 5; i++) { + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + expect(find.text('You have pressed the button $i times.'), findsOne); + } + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.drawer.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.drawer.0_test.dart new file mode 100644 index 000000000000..44106f154d66 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.drawer.0_test.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.drawer.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The page should contain a drawer than can be opened and closed', + (WidgetTester tester) async { + await tester.pumpWidget(const example.DrawerExampleApp()); + + expect(find.byType(Drawer), findsNothing); + + // Open the drawer by tapping the button at the center of the screen. + await tester.tap(find.widgetWithText(ElevatedButton, 'Open Drawer')); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOne); + expect( + tester.getCenter(find.byType(Drawer)).dx, + lessThan(400), + reason: 'The drawer should be on the left side of the screen', + ); + expect(find.text('This is the Drawer'), findsOne); + + // Close the drawer by tapping the button inside the drawer. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close Drawer')); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsNothing); + + // Open the drawer by tapping the drawer button in the app bar. + expect( + tester.getCenter(find.byType(DrawerButton)).dx, + lessThan(400), + reason: 'The drawer button should be on the left side of the app bar', + ); + await tester.tap(find.byType(DrawerButton)); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOne); + expect(find.text('This is the Drawer'), findsOne); + + // Close the drawer by tapping outside the drawer. + final Rect drawerRect = tester.getRect(find.byType(Drawer)); + final Offset outsideDrawer = drawerRect.centerRight + const Offset(50, 0); + await tester.tapAt(outsideDrawer); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.end_drawer.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.end_drawer.0_test.dart new file mode 100644 index 000000000000..b1f7d0c05fe4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.end_drawer.0_test.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.end_drawer.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The page should contain an end drawer than can be opened and closed', + (WidgetTester tester) async { + await tester.pumpWidget(const example.EndDrawerExampleApp()); + + expect(find.byType(Drawer), findsNothing); + + // Open the drawer by tapping the button at the center of the screen. + await tester.tap(find.widgetWithText(ElevatedButton, 'Open End Drawer')); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOne); + expect( + tester.getCenter(find.byType(Drawer)).dx, + greaterThan(400), + reason: 'The drawer should be on the right side of the screen', + ); + expect(find.text('This is the Drawer'), findsOne); + + // Close the drawer by tapping the button inside the drawer. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close Drawer')); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsNothing); + + // Open the drawer by tapping the drawer button in the app bar. + expect( + tester.getCenter(find.byType(EndDrawerButton)).dx, + greaterThan(400), + reason: 'The drawer button should be on the right side of the app bar', + ); + await tester.tap(find.byType(EndDrawerButton)); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOne); + expect(find.text('This is the Drawer'), findsOne); + + // Close the drawer by tapping outside the drawer. + final Rect drawerRect = tester.getRect(find.byType(Drawer)); + final Offset outsideDrawer = drawerRect.centerLeft - const Offset(50, 0); + await tester.tapAt(outsideDrawer); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.floating_action_button_animator.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.floating_action_button_animator.0_test.dart new file mode 100644 index 000000000000..82396117cb7e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.floating_action_button_animator.0_test.dart @@ -0,0 +1,88 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.floating_action_button_animator.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('FloatingActionButton animation can be customized', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.ScaffoldFloatingActionButtonAnimatorApp(), + ); + + expect(find.byType(FloatingActionButton), findsNothing); + + // Test default FloatingActionButtonAnimator. + // Tap the toggle button to show the FAB. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance animation by 100ms. + // FAB is partially animated in. + expect( + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + closeTo(743.8, 0.1), + ); + + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance animation by 100ms. + // FAB is fully animated in. + expect( + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + equals(728.0), + ); + + // Tap the toggle button to hide the FAB. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance animation by 100ms. + // FAB is partially animated out. + expect( + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + closeTo(747.1, 0.1), + ); + + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance animation by 100ms. + // FAB is fully animated out. + expect( + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + equals(756.0), + ); + + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance animation by 50ms. + // FAB is hidden. + expect(find.byType(FloatingActionButton), findsNothing); + + // Select 'None' to disable animation. + await tester.tap(find.text('None')); + await tester.pump(); + + // Test no animation FloatingActionButtonAnimator. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + // FAB is immediately shown. + expect( + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + equals(728.0), + ); + + // Tap the toggle button to hide the FAB. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + // FAB is immediately hidden. + expect(find.byType(FloatingActionButton), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.of.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.of.0_test.dart new file mode 100644 index 000000000000..409c841c5966 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.of.0_test.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.of.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Verify correct labels are displayed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.text('Scaffold.of Example'), findsOneWidget); + expect(find.text('SHOW BOTTOM SHEET'), findsOneWidget); + }); + + testWidgets('Bottom sheet can be shown', (WidgetTester tester) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.text('BottomSheet'), findsNothing); + expect( + find.widgetWithText(ElevatedButton, 'Close BottomSheet'), + findsNothing, + ); + + // Tap the button to show the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'SHOW BOTTOM SHEET')); + await tester.pumpAndSettle(); + + expect(find.text('BottomSheet'), findsOneWidget); + expect( + find.widgetWithText(ElevatedButton, 'Close BottomSheet'), + findsOneWidget, + ); + }); + + testWidgets('Bottom sheet can be closed', (WidgetTester tester) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.text('BottomSheet'), findsNothing); + + // Tap the button to show the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'SHOW BOTTOM SHEET')); + await tester.pumpAndSettle(); + + expect(find.text('BottomSheet'), findsOneWidget); + + // Tap the button to close the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close BottomSheet')); + await tester.pumpAndSettle(); + + expect(find.text('BottomSheet'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold.of.1_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.of.1_test.dart new file mode 100644 index 000000000000..f72f87e9ccb6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold.of.1_test.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold.of.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Verify correct labels are displayed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.text('Scaffold.of Example'), findsOneWidget); + expect(find.text('SHOW BOTTOM SHEET'), findsOneWidget); + }); + + testWidgets('Bottom sheet can be shown', (WidgetTester tester) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.text('BottomSheet'), findsNothing); + expect( + find.widgetWithText(ElevatedButton, 'Close BottomSheet'), + findsNothing, + ); + + // Tap the button to show the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'SHOW BOTTOM SHEET')); + await tester.pumpAndSettle(); + + expect(find.text('BottomSheet'), findsOneWidget); + expect( + find.widgetWithText(ElevatedButton, 'Close BottomSheet'), + findsOneWidget, + ); + }); + + testWidgets('Bottom sheet can be closed', (WidgetTester tester) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.text('BottomSheet'), findsNothing); + + // Tap the button to show the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'SHOW BOTTOM SHEET')); + await tester.pumpAndSettle(); + + expect(find.text('BottomSheet'), findsOneWidget); + + // Tap the button to close the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close BottomSheet')); + await tester.pumpAndSettle(); + + expect(find.text('BottomSheet'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.0_test.dart new file mode 100644 index 000000000000..714917965783 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.0_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The snack bar should be visible after tapping the button', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ScaffoldMessengerExampleApp()); + + expect(find.widgetWithText(AppBar, 'ScaffoldMessenger Sample'), findsOne); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Show SnackBar')); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(SnackBar, 'A SnackBar has been shown.'), + findsOne, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.of.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.of.0_test.dart new file mode 100644 index 000000000000..c6a303f6b07b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.of.0_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger.of.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The snack bar should be visible after tapping the button', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect( + find.widgetWithText(AppBar, 'ScaffoldMessenger.of Sample'), + findsOne, + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'SHOW A SNACKBAR')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(SnackBar, 'Have a snack!'), findsOne); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.of.1_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.of.1_test.dart new file mode 100644 index 000000000000..ca1e3cb65eb9 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger.of.1_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger.of.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('A snack bar is displayed after 10 taps', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.OfExampleApp()); + + expect(find.widgetWithText(AppBar, 'ScaffoldMessenger Demo'), findsOne); + expect(find.text('You have pushed the button this many times:'), findsOne); + + for (int i = 0; i < 9; i++) { + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + expect(find.text('${i + 1}'), findsOne); + } + expect(find.byType(SnackBar), findsNothing); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(find.text('10'), findsOne); + + expect(find.widgetWithText(SnackBar, 'A multiple of ten!'), findsOne); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_material_banner.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_material_banner.0_test.dart new file mode 100644 index 000000000000..67eb762779b4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_material_banner.0_test.dart @@ -0,0 +1,37 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger_state.show_material_banner.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Pressing the button should show a material banner', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ShowMaterialBannerExampleApp()); + + expect( + find.widgetWithText(AppBar, 'ScaffoldMessengerState Sample'), + findsOne, + ); + await tester.tap( + find.widgetWithText(OutlinedButton, 'Show MaterialBanner'), + ); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(MaterialBanner, 'This is a MaterialBanner'), + findsOne, + ); + expect( + find.descendant( + of: find.byType(MaterialBanner), + matching: find.widgetWithText(TextButton, 'DISMISS'), + ), + findsOne, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.0_test.dart new file mode 100644 index 000000000000..47634ae5fb73 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.0_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger_state.show_snack_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Pressing the button should display a snack bar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ShowSnackBarExampleApp()); + + expect( + find.widgetWithText(AppBar, 'ScaffoldMessengerState Sample'), + findsOne, + ); + await tester.tap(find.widgetWithText(OutlinedButton, 'Show SnackBar')); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(SnackBar, 'A SnackBar has been shown.'), + findsOne, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart new file mode 100644 index 000000000000..9716028e7523 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger_state.show_snack_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Floating SnackBar is visible', (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarApp()); + + final Finder buttonFinder = find.byType(ElevatedButton); + await tester.tap(buttonFinder.first); + // Have the SnackBar fully animate out. + await tester.pumpAndSettle(); + + final Finder snackBarFinder = find.byType(SnackBar); + expect(snackBarFinder, findsOneWidget); + + // Grow logo to send SnackBar off screen. + await tester.tap(buttonFinder.last); + await tester.pumpAndSettle(); + + final AssertionError exception = tester.takeException() as AssertionError; + const String message = + 'Floating SnackBar presented off screen.\n' + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n' + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.'; + expect(exception.message, message); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.2_test.dart new file mode 100644 index 000000000000..09692195cfcb --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_messenger_state.show_snack_bar.2_test.dart @@ -0,0 +1,106 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_messenger_state.show_snack_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'ScaffoldMessenger showSnackBar animation can be customized using AnimationStyle', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarApp()); + + // Tap the button to show the SnackBar with default animation style. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 125), + ); // Advance the animation by 125ms. + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(576.7, 0.1), + ); + + await tester.pump( + const Duration(milliseconds: 125), + ); // Advance the animation by 125ms. + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(566, 0.1), + ); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 250), + ); // Advance the animation by 250ms. + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(614, 0.1), + ); + + // Select custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + // Tap the button to show the SnackBar with custom animation style. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 1500), + ); // Advance the animation by 125ms. + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(576.7, 0.1), + ); + + await tester.pump( + const Duration(milliseconds: 1500), + ); // Advance the animation by 125ms. + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(566, 0.1), + ); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump( + const Duration(seconds: 1), + ); // Advance the animation by 1sec. + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(614, 0.1), + ); + + // Select no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + // Tap the button to show the SnackBar with no animation style. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect( + tester.getTopLeft(find.text('I am a snack bar.')).dy, + closeTo(566, 0.1), + ); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + expect(find.text('I am a snack bar.'), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_state.show_bottom_sheet.0_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_state.show_bottom_sheet.0_test.dart new file mode 100644 index 000000000000..0f260d8bc192 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_state.show_bottom_sheet.0_test.dart @@ -0,0 +1,27 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_state.show_bottom_sheet.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The button should show a bottom sheet when pressed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ShowBottomSheetExampleApp()); + + expect(find.widgetWithText(AppBar, 'ScaffoldState Sample'), findsOne); + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(BottomSheet, 'BottomSheet'), findsOne); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Close BottomSheet')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(BottomSheet, 'BottomSheet'), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scaffold/scaffold_state.show_bottom_sheet.1_test.dart b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_state.show_bottom_sheet.1_test.dart new file mode 100644 index 000000000000..d4f2a37767d4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scaffold/scaffold_state.show_bottom_sheet.1_test.dart @@ -0,0 +1,74 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scaffold/scaffold_state.show_bottom_sheet.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Scaffold showBottomSheet animation can be customized using AnimationStyle', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ShowBottomSheetExampleApp()); + + // Show the bottom sheet with default animation style. + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is partially visible. + expect( + tester.getTopLeft(find.byType(BottomSheet)).dy, + closeTo(444.8, 0.1), + ); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(400.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close')); + await tester.pumpAndSettle(); + + // Select custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + // Show the bottom sheet with custom animation style. + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pump(); + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 1500)); + + // The bottom sheet is partially visible. + expect( + tester.getTopLeft(find.byType(BottomSheet)).dy, + closeTo(444.8, 0.1), + ); + + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 1500)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(400.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(ElevatedButton, 'Close')); + await tester.pumpAndSettle(); + + // Select no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + // Show the bottom sheet with no animation style. + await tester.tap(find.widgetWithText(ElevatedButton, 'showBottomSheet')); + await tester.pump(); + expect(tester.getTopLeft(find.byType(BottomSheet)).dy, equals(400.0)); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/scrollbar/scrollbar.0_test.dart b/packages/material_ui/material_ui_examples/test/scrollbar/scrollbar.0_test.dart new file mode 100644 index 000000000000..b9e7a9047412 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scrollbar/scrollbar.0_test.dart @@ -0,0 +1,47 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scrollbar/scrollbar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Scrollbar.0 works well on all platforms', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ScrollbarExampleApp()); + + final Finder buttonFinder = find.byType(Scrollbar); + await tester.drag(buttonFinder.last, const Offset(0, 100.0)); + + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('The scrollbar should be painted when the user scrolls', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ScrollbarExampleApp()); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 10), + ); // Wait for the thumb to start appearing. + + expect(find.text('item 0'), findsOne); + expect(find.text('item 9'), findsNothing); + expect(find.byType(Scrollbar), isNot(paints..rect())); + + await tester.fling( + find.byType(Scrollbar).last, + const Offset(0, -300), + 10.0, + ); + + expect(find.text('item 0'), findsNothing); + expect(find.text('item 9'), findsOne); + expect(find.byType(Scrollbar).last, paints..rect()); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/scrollbar/scrollbar.1_test.dart b/packages/material_ui/material_ui_examples/test/scrollbar/scrollbar.1_test.dart new file mode 100644 index 000000000000..7170bfedc91c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/scrollbar/scrollbar.1_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/scrollbar/scrollbar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The scrollbar thumb should be visible at all time', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.ScrollbarExampleApp()); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 10), + ); // Wait for the thumb to start appearing. + + expect(find.widgetWithText(AppBar, 'Scrollbar Sample'), findsOne); + + expect(find.text('item 0'), findsOne); + expect(find.text('item 9'), findsNothing); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.fling( + find.byType(Scrollbar).last, + const Offset(0, -300), + 10.0, + ); + + expect(find.text('item 0'), findsNothing); + expect(find.text('item 9'), findsOne); + expect(find.byType(Scrollbar), paints..rect()); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.0_test.dart b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.0_test.dart new file mode 100644 index 000000000000..96b26c711f65 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.0_test.dart @@ -0,0 +1,81 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_anchor.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Search a color in the search bar and choosing an option changes the color scheme', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SearchBarApp()); + + expect(find.widgetWithText(AppBar, 'Search Bar Sample'), findsOne); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Card && widget.color == const Color(0xff6750a4), + ), + findsOne, + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(find.text('No search history.'), findsOne); + await tester.enterText(find.byType(SearchBar).last, 're'); + await tester.pump(); + + expect(find.widgetWithText(ListTile, 'red'), findsOne); + expect(find.widgetWithText(ListTile, 'green'), findsOne); + expect(find.widgetWithText(ListTile, 'grey'), findsOne); + + await tester.tap(find.widgetWithText(ListTile, 'red')); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Card && widget.color == const Color(0xff904a42), + ), + findsOne, + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + + expect( + find.widgetWithText(ListTile, 'red'), + findsOne, + reason: 'The search history should be displayed', + ); + expect(find.widgetWithIcon(ListTile, Icons.history), findsOne); + + await tester.enterText(find.byType(SearchBar).last, 'b'); + await tester.pump(); + + expect(find.widgetWithText(ListTile, 'blue'), findsOne); + expect(find.widgetWithText(ListTile, 'beige'), findsOne); + expect(find.widgetWithText(ListTile, 'brown'), findsOne); + expect(find.widgetWithText(ListTile, 'black'), findsOne); + + await tester.tap(find.widgetWithText(ListTile, 'blue')); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Card && widget.color == const Color(0xff36618e), + ), + findsOne, + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.1_test.dart b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.1_test.dart new file mode 100644 index 000000000000..4de8f9456fe0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.1_test.dart @@ -0,0 +1,52 @@ +// 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:ui' as ui; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_anchor.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The SearchAnchor should be floating', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.PinnedSearchBarApp()); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i++) { + expect(find.widgetWithText(ListTile, 'Initial list item $i'), findsOne); + } + + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + expect(find.byType(SearchBar), findsOne); + + final double searchBarHeight = tester + .getSize(find.byType(SearchBar)) + .height; + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + testPointer.hover(tester.getCenter(find.byType(CustomScrollView))); + await tester.sendEventToBinding( + testPointer.scroll(Offset(0.0, 2 * searchBarHeight)), + ); + await tester.pump(); + expect(find.byType(SearchBar), findsNothing); + + await tester.sendEventToBinding( + testPointer.scroll(Offset(0.0, -0.5 * searchBarHeight)), + ); + await tester.pump(); + expect(find.byType(SearchBar), findsOne); + + await tester.sendEventToBinding( + testPointer.scroll(Offset(0.0, 0.5 * searchBarHeight)), + ); + await tester.pump(); + expect(find.byType(SearchBar), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.2_test.dart b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.2_test.dart new file mode 100644 index 000000000000..cb8a10b8c8cd --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.2_test.dart @@ -0,0 +1,39 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_anchor.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Suggestion of the search bar can be selected', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SearchBarApp()); + + expect(find.widgetWithText(AppBar, 'Search Anchor Sample'), findsOne); + expect(find.text('No item selected'), findsOne); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i++) { + expect(find.widgetWithText(ListTile, 'item $i'), findsOne); + } + + await tester.tap(find.text('item 2')); + await tester.pumpAndSettle(); + + expect(find.text('Selected item: item 2'), findsOne); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ListTile, 'item 3')); + await tester.pumpAndSettle(); + + expect(find.text('Selected item: item 3'), findsOne); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.3_test.dart b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.3_test.dart new file mode 100644 index 000000000000..3109bf743f08 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.3_test.dart @@ -0,0 +1,38 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_anchor.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'can search and find options after waiting for fake network delay', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp()); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'a'); + await tester.pump(example.fakeAPIDuration); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'bobcat'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget); + + await tester.enterText(find.byType(SearchBar), 'aa'); + await tester.pump(example.fakeAPIDuration); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.4_test.dart b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.4_test.dart new file mode 100644 index 000000000000..259760feddd6 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/search_anchor/search_anchor.4_test.dart @@ -0,0 +1,100 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_anchor.4.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'can search and find options after waiting for fake network delay and debounce delay', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp()); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'a'); + await tester.pump(example.fakeAPIDuration); + + // No results yet, need to also wait for the debounce duration. + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.pump(example.debounceDuration); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'bobcat'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget); + + await tester.enterText(find.byType(SearchBar), 'aa'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + }, + ); + + testWidgets('debounce is reset each time a character is entered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp()); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(SearchBar), 'c'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'ch'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'cha'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'cham'); + await tester.pump( + example.debounceDuration - const Duration(milliseconds: 100), + ); + + // Despite the total elapsed time being greater than debounceDuration + + // fakeAPIDuration, the search has not yet completed, because the debounce + // was reset each time text input happened. + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing); + + await tester.enterText(find.byType(SearchBar), 'chame'); + await tester.pump(example.debounceDuration + example.fakeAPIDuration); + + expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing); + expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing); + expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/search_anchor/search_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/search_anchor/search_bar.0_test.dart new file mode 100644 index 000000000000..1996a5ef757d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/search_anchor/search_bar.0_test.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open search view', (WidgetTester tester) async { + await tester.pumpWidget(const example.SearchBarApp()); + + final Finder searchBarFinder = find.byType(SearchBar); + final SearchBar searchBar = tester.widget<SearchBar>(searchBarFinder); + expect(find.byIcon(Icons.search), findsOneWidget); + expect(searchBar.trailing, isNotEmpty); + expect(searchBar.trailing?.length, equals(1)); + final Finder trailingButtonFinder = find.widgetWithIcon( + IconButton, + Icons.wb_sunny_outlined, + ); + expect(trailingButtonFinder, findsOneWidget); + + await tester.tap(trailingButtonFinder); + await tester.pumpAndSettle(); + + expect( + find.widgetWithIcon(IconButton, Icons.brightness_2_outlined), + findsOneWidget, + ); + + await tester.tap(searchBarFinder); + await tester.pumpAndSettle(); + + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + expect(find.text('item 3'), findsOneWidget); + expect(find.text('item 4'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/segmented_button/segmented_button.0_test.dart b/packages/material_ui/material_ui_examples/test/segmented_button/segmented_button.0_test.dart new file mode 100644 index 000000000000..41b25b2f4acf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/segmented_button/segmented_button.0_test.dart @@ -0,0 +1,117 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/segmented_button/segmented_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Segmented button can be used with a single selection', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SegmentedButtonApp()); + + void expectOneCalendarButton(example.Calendar period) { + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SegmentedButton<example.Calendar> && + setEquals(widget.selected, <example.Calendar>{period}), + ), + findsOne, + ); + } + + expect(find.text('Single choice'), findsOne); + expect(find.text('Day'), findsOne); + expect(find.text('Week'), findsOne); + expect(find.text('Month'), findsOne); + expect(find.text('Year'), findsOne); + + expectOneCalendarButton(example.Calendar.day); + + // Select the day. + await tester.tap(find.text('Week')); + await tester.pump(); + + expectOneCalendarButton(example.Calendar.week); + + // Select the month. + await tester.tap(find.text('Month')); + await tester.pump(); + + expectOneCalendarButton(example.Calendar.month); + + // Select the year. + await tester.tap(find.text('Year')); + await tester.pump(); + + expectOneCalendarButton(example.Calendar.year); + + // Select the day. + await tester.tap(find.text('Day')); + await tester.pump(); + + expectOneCalendarButton(example.Calendar.day); + + // Try to unselect the day. + await tester.tap(find.text('Day')); + await tester.pump(); + + expectOneCalendarButton(example.Calendar.day); + }); + + testWidgets('Segmented button can be used with a multiple selection', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SegmentedButtonApp()); + + void expectSizeButtons(Set<example.Sizes> sizes) { + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SegmentedButton<example.Sizes> && + setEquals(widget.selected, sizes), + ), + findsOne, + ); + } + + expect(find.text('Multiple choice'), findsOne); + expect(find.text('XS'), findsOne); + expect(find.text('S'), findsOne); + expect(find.text('M'), findsOne); + expect(find.text('L'), findsOne); + expect(find.text('XL'), findsOne); + + expectSizeButtons(const <example.Sizes>{ + example.Sizes.large, + example.Sizes.extraLarge, + }); + + // Select everything. + await tester.tap(find.text('XS')); + await tester.pump(); + await tester.tap(find.text('S')); + await tester.pump(); + await tester.tap(find.text('M')); + await tester.pump(); + + expectSizeButtons(example.Sizes.values.toSet()); + + // Unselect everything but XS. + await tester.tap(find.text('S')); + await tester.pump(); + await tester.tap(find.text('M')); + await tester.pump(); + await tester.tap(find.text('L')); + await tester.pump(); + await tester.tap(find.text('XL')); + await tester.pump(); + + expectSizeButtons(const <example.Sizes>{example.Sizes.extraSmall}); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/segmented_button/segmented_button.1_test.dart b/packages/material_ui/material_ui_examples/test/segmented_button/segmented_button.1_test.dart new file mode 100644 index 000000000000..6b707c2bd7bc --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/segmented_button/segmented_button.1_test.dart @@ -0,0 +1,38 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/segmented_button/segmented_button.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Can use SegmentedButton.styleFrom to customize SegmentedButton', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SegmentedButtonApp()); + + final Color unselectedBackgroundColor = Colors.grey[200]!; + const Color unselectedForegroundColor = Colors.red; + const Color selectedBackgroundColor = Colors.green; + const Color selectedForegroundColor = Colors.white; + + Material getMaterial(String text) { + return tester.widget<Material>( + find + .ancestor(of: find.text(text), matching: find.byType(Material)) + .first, + ); + } + + // Verify the unselected button style. + expect(getMaterial('Day').textStyle?.color, unselectedForegroundColor); + expect(getMaterial('Day').color, unselectedBackgroundColor); + + // Verify the selected button style. + expect(getMaterial('Week').textStyle?.color, selectedForegroundColor); + expect(getMaterial('Week').color, selectedBackgroundColor); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/selectable_region/selectable_region.0_test.dart b/packages/material_ui/material_ui_examples/test/selectable_region/selectable_region.0_test.dart new file mode 100644 index 000000000000..86178b78ded5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/selectable_region/selectable_region.0_test.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/selectable_region/selectable_region.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future<void> sendKeyCombination( + WidgetTester tester, + LogicalKeyboardKey key, + ) async { + final LogicalKeyboardKey modifier = switch (defaultTargetPlatform) { + .iOS || .macOS => LogicalKeyboardKey.meta, + _ => LogicalKeyboardKey.control, + }; + await tester.sendKeyDownEvent(modifier); + await tester.sendKeyDownEvent(key); + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(modifier); + await tester.pump(); + } + + testWidgets('The icon can be selected with the text', ( + WidgetTester tester, + ) async { + String? clipboard; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'Clipboard.setData') { + clipboard = + (methodCall.arguments as Map<String, dynamic>)['text'] as String; + } + return null; + }, + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpWidget(const example.SelectableRegionExampleApp()); + + await tester.tap(find.byIcon(Icons.key)); // Focus the application. + await tester.pump(); + + // Keyboard select all. + await sendKeyCombination(tester, LogicalKeyboardKey.keyA); + // Keyboard copy. + await sendKeyCombination(tester, LogicalKeyboardKey.keyC); + + expect(clipboard, 'SelectableRegion SampleSelect this iconCustom Text'); + }, variant: TargetPlatformVariant.all()); +} diff --git a/packages/material_ui/material_ui_examples/test/selection_area/selection_area.0_test.dart b/packages/material_ui/material_ui_examples/test/selection_area/selection_area.0_test.dart new file mode 100644 index 000000000000..0174d68f55f2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/selection_area/selection_area.0_test.dart @@ -0,0 +1,41 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/selection_area/selection_area.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Texts are descendant of the SelectionArea', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SelectionAreaExampleApp()); + + expect( + find.descendant( + of: find.byType(SelectionArea), + matching: find.byType(Text), + ), + findsExactly(4), + ); + + final List<String> selectableTexts = <String>[ + 'SelectionArea Sample', + 'Row 1', + 'Row 2', + 'Row 3', + ]; + + for (final String text in selectableTexts) { + expect( + find.descendant( + of: find.byType(SelectionArea), + matching: find.text(text), + ), + findsExactly(1), + ); + } + }); +} diff --git a/packages/material_ui/material_ui_examples/test/selection_area/selection_area.1_test.dart b/packages/material_ui/material_ui_examples/test/selection_area/selection_area.1_test.dart new file mode 100644 index 000000000000..d86f5b032b7a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/selection_area/selection_area.1_test.dart @@ -0,0 +1,29 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/selection_area/selection_area.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SelectionArea SelectionListener Example Smoke Test', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.SelectionAreaSelectionListenerExampleApp(), + ); + expect(find.byType(Column), findsNWidgets(2)); + expect(find.textContaining('Selection StartOffset:'), findsOneWidget); + expect(find.textContaining('Selection EndOffset:'), findsOneWidget); + expect(find.textContaining('Selection Status:'), findsOneWidget); + expect(find.textContaining('Selectable Region Status:'), findsOneWidget); + expect( + find.textContaining( + 'This is some text under a SelectionArea that can be selected.', + ), + findsOneWidget, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/selection_area/selection_area.2_test.dart b/packages/material_ui/material_ui_examples/test/selection_area/selection_area.2_test.dart new file mode 100644 index 000000000000..759b342f3705 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/selection_area/selection_area.2_test.dart @@ -0,0 +1,302 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui_examples/selection_area/selection_area.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +// This was taken directly from selectable_region_test.dart +// in the frameworks' Widget library tests. +Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { + const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); + final Offset localOffset = + paragraph.getOffsetForCaret(TextPosition(offset: offset), caret) + + Offset(0.0, paragraph.preferredLineHeight); + return paragraph.localToGlobal(localOffset) + + const Offset(kIsWeb ? 1.0 : 0.0, -2.0); +} + +void main() { + testWidgets('SelectionArea Color Text Red Example Smoke Test', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.SelectionAreaColorTextRedExampleApp(), + ); + expect( + find.widgetWithIcon(FloatingActionButton, Icons.undo), + findsOneWidget, + ); + expect(find.byType(Column), findsNWidgets(2)); + expect( + find.textContaining('This is some bulleted list:\n'), + findsOneWidget, + ); + for (int i = 1; i <= 7; i += 1) { + expect(find.widgetWithText(Text, '• Bullet $i'), findsOneWidget); + } + expect( + find.textContaining('This is some text in a text widget.'), + findsOneWidget, + ); + expect( + find.textContaining(' This is some more text in the same text widget.'), + findsOneWidget, + ); + expect( + find.textContaining('This is some text in another text widget.'), + findsOneWidget, + ); + }); + + testWidgets( + 'SelectionArea Color Text Red Example - colors selected range red', + (WidgetTester tester) async { + await tester.pumpWidget( + const example.SelectionAreaColorTextRedExampleApp(), + ); + await tester.pumpAndSettle(); + final Finder paragraph1Finder = find.descendant( + of: find.textContaining('This is some bulleted list').first, + matching: find.byType(RichText).first, + ); + final Finder paragraph3Finder = find.descendant( + of: find.textContaining('This is some text in another text widget.'), + matching: find.byType(RichText), + ); + final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>( + paragraph1Finder, + ); + final List<RenderParagraph> bullets = tester + .renderObjectList<RenderParagraph>( + find.descendant( + of: find.textContaining('• Bullet'), + matching: find.byType(RichText), + ), + ) + .toList(); + expect(bullets.length, 7); + final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.textContaining('This is some text in a text widget.'), + matching: find.byType(RichText), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>( + paragraph3Finder, + ); + // Drag to select from paragraph 1 position 4 to paragraph 3 position 25. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph1, 4), + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(textOffsetToPosition(paragraph3, 25)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Verify selection. + // Bulleted list title. + expect(paragraph1.selections.length, 1); + expect( + paragraph1.selections[0], + const TextSelection(baseOffset: 4, extentOffset: 28), + ); + // Bulleted list. + for (final RenderParagraph paragraphBullet in bullets) { + expect(paragraphBullet.selections.length, 1); + expect( + paragraphBullet.selections[0], + const TextSelection(baseOffset: 0, extentOffset: 10), + ); + } + // Second text widget. + expect(paragraph2.selections.length, 1); + expect( + paragraph2.selections[0], + const TextSelection(baseOffset: 0, extentOffset: 83), + ); + // Third text widget. + expect(paragraph3.selections.length, 1); + expect( + paragraph3.selections[0], + const TextSelection(baseOffset: 0, extentOffset: 25), + ); + + // Color selection red. + expect(find.textContaining('Color Text Red'), findsOneWidget); + await tester.tap(find.textContaining('Color Text Red')); + await tester.pumpAndSettle(); + + // Verify selection is red. + final TextSpan paragraph1ResultingSpan = paragraph1.text as TextSpan; + final TextSpan paragraph2ResultingSpan = paragraph2.text as TextSpan; + final TextSpan paragraph3ResultingSpan = paragraph3.text as TextSpan; + // Title of bulleted list is partially red. + expect(paragraph1ResultingSpan.children, isNotNull); + expect(paragraph1ResultingSpan.children!.length, 1); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan).children, + isNotNull, + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan).children!.length, + 3, + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan).children![0].style, + isNull, + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan).children![1], + isA<TextSpan>(), + ); + expect( + ((paragraph1ResultingSpan.children![0] as TextSpan).children![1] + as TextSpan) + .text, + isNotNull, + ); + expect( + ((paragraph1ResultingSpan.children![0] as TextSpan).children![1] + as TextSpan) + .text, + ' is some bulleted list:\n', + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan).children![1].style, + isNotNull, + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan) + .children![1] + .style! + .color, + isNotNull, + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan) + .children![1] + .style! + .color, + Colors.red, + ); + expect( + (paragraph1ResultingSpan.children![0] as TextSpan).children![2], + isA<WidgetSpan>(), + ); + // Bullets are red. + for (final RenderParagraph paragraphBullet in bullets) { + final TextSpan resultingBulletSpan = paragraphBullet.text as TextSpan; + expect(resultingBulletSpan.children, isNotNull); + expect(resultingBulletSpan.children!.length, 1); + expect(resultingBulletSpan.children![0], isA<TextSpan>()); + expect( + (resultingBulletSpan.children![0] as TextSpan).children, + isNotNull, + ); + expect( + (resultingBulletSpan.children![0] as TextSpan).children!.length, + 1, + ); + expect( + (resultingBulletSpan.children![0] as TextSpan).children![0], + isA<TextSpan>(), + ); + expect( + ((resultingBulletSpan.children![0] as TextSpan).children![0] + as TextSpan) + .style, + isNotNull, + ); + expect( + ((resultingBulletSpan.children![0] as TextSpan).children![0] + as TextSpan) + .style! + .color, + isNotNull, + ); + expect( + ((resultingBulletSpan.children![0] as TextSpan).children![0] + as TextSpan) + .style! + .color, + Colors.red, + ); + } + // Second text widget is red. + expect(paragraph2ResultingSpan.children, isNotNull); + expect(paragraph2ResultingSpan.children!.length, 1); + expect(paragraph2ResultingSpan.children![0], isA<TextSpan>()); + expect( + (paragraph2ResultingSpan.children![0] as TextSpan).children, + isNotNull, + ); + for (final InlineSpan span + in (paragraph2ResultingSpan.children![0] as TextSpan).children!) { + if (span is TextSpan) { + expect(span.style, isNotNull); + expect(span.style!.color, isNotNull); + expect(span.style!.color, Colors.red); + } + } + // Part of third text widget is red. + expect(paragraph3ResultingSpan.children, isNotNull); + expect(paragraph3ResultingSpan.children!.length, 1); + expect(paragraph3ResultingSpan.children![0], isA<TextSpan>()); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan).children, + isNotNull, + ); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan).children!.length, + 2, + ); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan).children![0], + isA<TextSpan>(), + ); + expect( + ((paragraph3ResultingSpan.children![0] as TextSpan).children![0] + as TextSpan) + .text, + isNotNull, + ); + expect( + ((paragraph3ResultingSpan.children![0] as TextSpan).children![0] + as TextSpan) + .text, + 'This is some text in anot', + ); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan).children![0].style, + isNotNull, + ); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan) + .children![0] + .style! + .color, + isNotNull, + ); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan) + .children![0] + .style! + .color, + Colors.red, + ); + expect( + (paragraph3ResultingSpan.children![0] as TextSpan).children![1].style, + isNull, + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/selection_container/selection_container.0_test.dart b/packages/material_ui/material_ui_examples/test/selection_container/selection_container.0_test.dart new file mode 100644 index 000000000000..4b8eea812e73 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/selection_container/selection_container.0_test.dart @@ -0,0 +1,67 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui_examples/selection_container/selection_container.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'The SelectionContainer should transform the partial selection into an all selection', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SelectionContainerExampleApp()); + + expect( + find.widgetWithText(AppBar, 'SelectionContainer Sample'), + findsOne, + ); + + final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.text('Row 1'), + matching: find.byType(RichText), + ), + ); + final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.text('Row 2'), + matching: find.byType(RichText), + ), + ); + final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.text('Row 3'), + matching: find.byType(RichText), + ), + ); + final Rect paragraph1Rect = tester.getRect(find.text('Row 1')); + final TestGesture gesture = await tester.startGesture( + paragraph1Rect.topLeft, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(paragraph1Rect.center); + await tester.pump(); + expect( + paragraph1.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 5), + ); + expect( + paragraph2.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 5), + ); + expect( + paragraph3.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 5), + ); + + await gesture.up(); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/selection_container/selection_container_disabled.0_test.dart b/packages/material_ui/material_ui_examples/test/selection_container/selection_container_disabled.0_test.dart new file mode 100644 index 000000000000..aa82086ffc83 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/selection_container/selection_container_disabled.0_test.dart @@ -0,0 +1,88 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui_examples/selection_container/selection_container_disabled.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('A SelectionContainer.disabled should disable selections', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.SelectionContainerDisabledExampleApp(), + ); + + expect( + find.widgetWithText(AppBar, 'SelectionContainer.disabled Sample'), + findsOne, + ); + + final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.text('Selectable text').first, + matching: find.byType(RichText), + ), + ); + final Rect paragraph1Rect = tester.getRect( + find.text('Selectable text').first, + ); + final TestGesture gesture = await tester.startGesture( + paragraph1Rect.centerLeft, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(paragraph1Rect.center); + await tester.pump(); + expect( + paragraph1.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.text('Non-selectable text'), + matching: find.byType(RichText), + ), + ); + final Rect paragraph2Rect = tester.getRect( + find.text('Non-selectable text'), + ); + await gesture.moveTo(paragraph2Rect.center); + // Should select the rest of paragraph 1. + expect( + paragraph1.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 15), + ); + // paragraph2 is in a disabled container. + expect(paragraph2.selections, isEmpty); + + final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>( + find.descendant( + of: find.text('Selectable text').last, + matching: find.byType(RichText), + ), + ); + final Rect paragraph3Rect = tester.getRect( + find.text('Selectable text').last, + ); + await gesture.moveTo(paragraph3Rect.center); + expect( + paragraph1.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 15), + ); + expect(paragraph2.selections, isEmpty); + expect( + paragraph3.selections.first, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + await gesture.up(); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/shaped_input_border/shaped_input_border.0_test.dart b/packages/material_ui/material_ui_examples/test/shaped_input_border/shaped_input_border.0_test.dart new file mode 100644 index 000000000000..898f4c11769d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/shaped_input_border/shaped_input_border.0_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/shaped_input_border/shaped_input_border.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ShapedInputBorder example', (WidgetTester tester) async { + await tester.pumpWidget(const example.ExampleApp()); + + // Verify that we have four TextField widgets with different ShapedInputBorder shapes + expect(find.byType(TextField), findsNWidgets(4)); + + // Verify the labels are present + expect(find.text('Superellipse Border'), findsOneWidget); + expect(find.text('Stadium Border'), findsOneWidget); + expect(find.text('Beveled Border'), findsOneWidget); + expect(find.text('Filled with Superellipse'), findsOneWidget); + + // Verify that the widgets render without errors + expect(tester.takeException(), isNull); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/slider/slider.0_test.dart b/packages/material_ui/material_ui_examples/test/slider/slider.0_test.dart new file mode 100644 index 000000000000..9a7d02cc575e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/slider/slider.0_test.dart @@ -0,0 +1,58 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/slider/slider.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Sliders can change their value', (WidgetTester tester) async { + await tester.pumpWidget(const example.SliderExampleApp()); + + expect(find.byType(Slider), findsNWidgets(2)); + + Finder sliderFinder = find.byType(Slider).first; + Slider slider = tester.widget<Slider>(sliderFinder); + expect(slider.value, equals(20)); + + await tester.tapAt(tester.getCenter(sliderFinder)); + await tester.pump(); + + slider = tester.widget(sliderFinder); + expect(slider.value, equals(50)); + + sliderFinder = find.byType(Slider).last; + slider = tester.widget(sliderFinder); + expect(slider.value, equals(60)); + + await tester.tapAt(tester.getTopLeft(sliderFinder)); + await tester.pump(); + + slider = tester.widget(sliderFinder); + expect(slider.value, equals(0)); + }); + + testWidgets('Sliders year2023 flag can be toggled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SliderExampleApp()); + + Slider slider = tester.widget<Slider>(find.byType(Slider).first); + // ignore: deprecated_member_use + expect(slider.year2023, true); + Slider discreteSlider = tester.widget<Slider>(find.byType(Slider).last); + // ignore: deprecated_member_use + expect(discreteSlider.year2023, true); + + await tester.tap(find.byType(SwitchListTile)); + await tester.pumpAndSettle(); + + slider = tester.widget<Slider>(find.byType(Slider).first); + // ignore: deprecated_member_use + expect(slider.year2023, false); + discreteSlider = tester.widget<Slider>(find.byType(Slider).last); + // ignore: deprecated_member_use + expect(discreteSlider.year2023, false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/slider/slider.1_test.dart b/packages/material_ui/material_ui_examples/test/slider/slider.1_test.dart new file mode 100644 index 000000000000..c0b1b3dc82a1 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/slider/slider.1_test.dart @@ -0,0 +1,36 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/slider/slider.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Slider shows secondary track', (WidgetTester tester) async { + await tester.pumpWidget(const example.SliderApp()); + + expect(find.byType(Slider), findsNWidgets(2)); + + final Finder slider1Finder = find.byType(Slider).at(0); + final Finder slider2Finder = find.byType(Slider).at(1); + + Slider slider1 = tester.widget(slider1Finder); + Slider slider2 = tester.widget(slider2Finder); + expect(slider1.secondaryTrackValue, slider2.value); + + const double targetValue = 0.8; + final Rect rect = tester.getRect(slider2Finder); + final Offset target = Offset( + rect.left + (rect.right - rect.left) * targetValue, + rect.top + (rect.bottom - rect.top) / 2, + ); + await tester.tapAt(target); + await tester.pump(); + + slider1 = tester.widget(slider1Finder); + slider2 = tester.widget(slider2Finder); + expect(slider1.secondaryTrackValue, closeTo(targetValue, 0.05)); + expect(slider1.secondaryTrackValue, slider2.value); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.0_test.dart new file mode 100644 index 000000000000..7a325b39bdea --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.0_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/snack_bar/snack_bar.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Clicking on Button shows a SnackBar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + expect(find.widgetWithText(AppBar, 'SnackBar Sample'), findsOneWidget); + expect( + find.widgetWithText(ElevatedButton, 'Show Snackbar'), + findsOneWidget, + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Show Snackbar')); + await tester.pump(); + expect(find.text('Awesome Snackbar!'), findsOneWidget); + expect(find.text('Action'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.1_test.dart new file mode 100644 index 000000000000..900e6860e9dd --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.1_test.dart @@ -0,0 +1,71 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/snack_bar/snack_bar.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tapping on button shows snackbar', (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + expect(find.byType(SnackBar), findsNothing); + expect(find.widgetWithText(AppBar, 'SnackBar Sample'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Show Snackbar')); + await tester.pump(); + + expect(find.text('Awesome SnackBar!'), findsOneWidget); + expect(find.widgetWithText(SnackBarAction, 'Action'), findsOneWidget); + + final SnackBar bar = tester.widget<SnackBar>( + find.ancestor( + of: find.text('Awesome SnackBar!'), + matching: find.byType(SnackBar), + ), + ); + expect(bar.behavior, SnackBarBehavior.floating); + }); + + testWidgets('Snackbar is styled correctly', (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect(find.byType(SnackBar), findsOneWidget); + + final SnackBar bar = tester.widget<SnackBar>(find.byType(SnackBar)); + expect(bar.behavior, SnackBarBehavior.floating); + expect(bar.width, 280.0); + expect( + bar.shape, + isA<RoundedRectangleBorder>().having( + (RoundedRectangleBorder b) => b.borderRadius, + 'radius', + BorderRadius.circular(10.0), + ), + ); + }); + + testWidgets( + 'Snackbar should not disappear after timeout, unless tapping the action button', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + expect(find.byType(SnackBar), findsNothing); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1500)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + + await tester.tap(find.widgetWithText(SnackBarAction, 'Action')); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.2_test.dart new file mode 100644 index 000000000000..807eef3b0f6e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/snack_bar/snack_bar.2_test.dart @@ -0,0 +1,184 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/snack_bar/snack_bar.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Shows correct static elements', (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + expect(find.byType(SnackBar), findsNothing); + expect(find.widgetWithText(AppBar, 'SnackBar Sample'), findsOneWidget); + expect( + find.widgetWithText(FloatingActionButton, 'Show Snackbar'), + findsOneWidget, + ); + expect(find.text('Behavior'), findsOneWidget); + expect(find.text('Fixed'), findsOneWidget); + expect(find.text('Floating'), findsOneWidget); + expect(find.text('Content'), findsOneWidget); + expect( + find.widgetWithText(SwitchListTile, 'Include close Icon'), + findsOneWidget, + ); + expect( + find.widgetWithText(SwitchListTile, 'Multi Line Text'), + findsOneWidget, + ); + expect( + find.widgetWithText(SwitchListTile, 'Include Action'), + findsOneWidget, + ); + expect( + find.widgetWithText(SwitchListTile, 'Long Action Label'), + findsOneWidget, + ); + + await tester.scrollUntilVisible(find.byType(Slider), 30); + expect(find.text('Action new-line overflow threshold'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets('Applies default configuration to snackbar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + expect(find.byType(SnackBar), findsNothing); + expect(find.text('Single Line Snack Bar'), findsNothing); + expect(find.textContaining('spans across multiple lines'), findsNothing); + expect(find.text('Long Action Text'), findsNothing); + expect(find.text('Action'), findsNothing); + expect(find.byIcon(Icons.close), findsNothing); + + await tester.tap(find.text('Show Snackbar')); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Single Line Snack Bar'), findsOneWidget); + expect(find.text('Action'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect( + tester.widget<SnackBar>(find.byType(SnackBar)).behavior, + SnackBarBehavior.floating, + ); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('Can configure fixed snack bar with long text', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + await tester.tap(find.text('Fixed')); + await tester.tap(find.text('Multi Line Text')); + await tester.tap(find.text('Long Action Label')); + await tester.tap(find.text('Show Snackbar')); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.textContaining('spans across multiple lines'), findsOneWidget); + expect(find.text('Long Action Text'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect( + tester.widget<SnackBar>(find.byType(SnackBar)).behavior, + SnackBarBehavior.fixed, + ); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('Can configure to remove action and close icon', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + await tester.tap(find.text('Include close Icon')); + await tester.tap(find.text('Include Action')); + await tester.tap(find.text('Show Snackbar')); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.byType(SnackBarAction), findsNothing); + expect(find.byIcon(Icons.close), findsNothing); + }); + + testWidgets('Higher overflow threshold leads to smaller snack bars', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + await tester.tap(find.text('Fixed')); + await tester.tap(find.text('Multi Line Text')); + await tester.tap(find.text('Long Action Label')); + + // Establish max size with low threshold (causes overflow) + await tester.scrollUntilVisible(find.byType(Slider), 30); + TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveTo(tester.getBottomLeft(find.byType(Slider))); + await gesture.up(); + await tester.tapAt(tester.getBottomLeft(find.byType(Slider))); + await tester.tap(find.text('Show Snackbar')); + await tester.pumpAndSettle(); + + final double highSnackBar = tester.getSize(find.byType(SnackBar)).height; + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + // Configure high threshold (everything is in one row) + gesture = await tester.startGesture(tester.getCenter(find.byType(Slider))); + await gesture.moveTo(tester.getTopRight(find.byType(Slider))); + await gesture.up(); + await tester.tapAt(tester.getTopRight(find.byType(Slider))); + await tester.tap(find.text('Show Snackbar')); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(SnackBar)).height, + lessThan(highSnackBar), + ); + }); + + testWidgets('Disable unusable elements', (WidgetTester tester) async { + await tester.pumpWidget(const example.SnackBarExampleApp()); + + expect(find.text('Long Action Label'), findsOneWidget); + expect( + tester + .widget<SwitchListTile>( + find.ancestor( + of: find.text('Long Action Label'), + matching: find.byType(SwitchListTile), + ), + ) + .onChanged, + isNotNull, + ); + + await tester.tap(find.text('Include Action')); + await tester.pumpAndSettle(); + + expect( + tester + .widget<SwitchListTile>( + find.ancestor( + of: find.text('Long Action Label'), + matching: find.byType(SwitchListTile), + ), + ) + .onChanged, + isNull, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/stepper/step_style.0_test.dart b/packages/material_ui/material_ui_examples/test/stepper/step_style.0_test.dart new file mode 100644 index 000000000000..97ce650cb408 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/stepper/step_style.0_test.dart @@ -0,0 +1,69 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/stepper/step_style.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('StepStyle Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.StepStyleExampleApp()); + + expect(find.widgetWithText(AppBar, 'Step Style Example'), findsOneWidget); + + final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper)); + // Check that the stepper has the correct properties. + expect(stepper.type, StepperType.horizontal); + expect(stepper.stepIconHeight, 48); + expect(stepper.stepIconWidth, 48); + expect(stepper.stepIconMargin, EdgeInsets.zero); + + // Check that the first step has the correct properties. + final Step firstStep = stepper.steps[0]; + expect(firstStep.title, isA<SizedBox>()); + expect(firstStep.content, isA<SizedBox>()); + expect(firstStep.isActive, true); + expect(firstStep.stepStyle?.connectorThickness, 10); + expect(firstStep.stepStyle?.color, Colors.white); + expect(firstStep.stepStyle?.connectorColor, Colors.red); + expect(firstStep.stepStyle?.indexStyle?.color, Colors.black); + expect(firstStep.stepStyle?.indexStyle?.fontSize, 20); + expect(firstStep.stepStyle?.border, Border.all(width: 2)); + + // Check that the second step has the correct properties. + final Step secondStep = stepper.steps[1]; + expect(secondStep.title, isA<SizedBox>()); + expect(secondStep.content, isA<SizedBox>()); + expect(secondStep.isActive, true); + expect(secondStep.stepStyle?.connectorThickness, 10); + expect(secondStep.stepStyle?.connectorColor, Colors.orange); + expect( + secondStep.stepStyle?.gradient, + const LinearGradient(colors: <Color>[Colors.white, Colors.black]), + ); + + // Check that the third step has the correct properties. + final Step thirdStep = stepper.steps[2]; + expect(thirdStep.title, isA<SizedBox>()); + expect(thirdStep.content, isA<SizedBox>()); + expect(thirdStep.isActive, true); + expect(thirdStep.stepStyle?.connectorThickness, 10); + expect(thirdStep.stepStyle?.color, Colors.white); + expect(thirdStep.stepStyle?.connectorColor, Colors.blue); + expect(thirdStep.stepStyle?.indexStyle?.color, Colors.black); + expect(thirdStep.stepStyle?.indexStyle?.fontSize, 20); + expect(thirdStep.stepStyle?.border, Border.all(width: 2)); + + // Check that the fourth step has the correct properties. + final Step fourthStep = stepper.steps[3]; + expect(fourthStep.title, isA<SizedBox>()); + expect(fourthStep.content, isA<SizedBox>()); + expect(fourthStep.isActive, true); + expect(fourthStep.stepStyle?.color, Colors.white); + expect(fourthStep.stepStyle?.indexStyle?.color, Colors.black); + expect(fourthStep.stepStyle?.indexStyle?.fontSize, 20); + expect(fourthStep.stepStyle?.border, Border.all(width: 2)); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/stepper/stepper.0_test.dart b/packages/material_ui/material_ui_examples/test/stepper/stepper.0_test.dart new file mode 100644 index 000000000000..fc2c6fbf4091 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/stepper/stepper.0_test.dart @@ -0,0 +1,68 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/stepper/stepper.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Stepper Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget(const example.StepperExampleApp()); + + expect(find.widgetWithText(AppBar, 'Stepper Sample'), findsOneWidget); + expect(find.text('Step 1 title').hitTestable(), findsOneWidget); + expect(find.text('Step 2 title').hitTestable(), findsOneWidget); + expect(find.text('Content for Step 1').hitTestable(), findsOneWidget); + expect(find.text('Content for Step 2').hitTestable(), findsNothing); + final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper)); + + // current: 0 & clicks cancel + stepper.onStepCancel?.call(); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsOneWidget); + expect(find.text('Content for Step 2').hitTestable(), findsNothing); + + // current: 0 & clicks 0th step + stepper.onStepTapped?.call(0); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsOneWidget); + expect(find.text('Content for Step 2').hitTestable(), findsNothing); + + // current: 0 & clicks continue + stepper.onStepContinue?.call(); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsNothing); + expect(find.text('Content for Step 2').hitTestable(), findsOneWidget); + + // current: 1 & clicks 1st step + stepper.onStepTapped?.call(1); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsNothing); + expect(find.text('Content for Step 2').hitTestable(), findsOneWidget); + + // current: 1 & clicks continue + stepper.onStepContinue?.call(); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsNothing); + expect(find.text('Content for Step 2').hitTestable(), findsOneWidget); + + // current: 1 & clicks cancel + stepper.onStepCancel?.call(); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsOneWidget); + expect(find.text('Content for Step 2').hitTestable(), findsNothing); + + // current: 0 & clicks 1st step + stepper.onStepTapped?.call(1); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsNothing); + expect(find.text('Content for Step 2').hitTestable(), findsOneWidget); + + // current: 1 & clicks 0th step + stepper.onStepTapped?.call(0); + await tester.pumpAndSettle(); + expect(find.text('Content for Step 1').hitTestable(), findsOneWidget); + expect(find.text('Content for Step 2').hitTestable(), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/stepper/stepper.controls_builder.0_test.dart b/packages/material_ui/material_ui_examples/test/stepper/stepper.controls_builder.0_test.dart new file mode 100644 index 000000000000..04595f4cc840 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/stepper/stepper.controls_builder.0_test.dart @@ -0,0 +1,23 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/stepper/stepper.controls_builder.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Stepper control builder can be overridden to display custom buttons', + (WidgetTester tester) async { + await tester.pumpWidget(const example.ControlsBuilderExampleApp()); + + expect(find.widgetWithText(AppBar, 'Stepper Sample'), findsOne); + expect(find.text('A').hitTestable(), findsOne); + expect(find.text('B').hitTestable(), findsOne); + expect(find.widgetWithText(TextButton, 'NEXT').hitTestable(), findsOne); + expect(find.widgetWithText(TextButton, 'CANCEL').hitTestable(), findsOne); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/switch/switch.0_test.dart b/packages/material_ui/material_ui_examples/test/switch/switch.0_test.dart new file mode 100644 index 000000000000..2e8d3dc0e18c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch/switch.0_test.dart @@ -0,0 +1,22 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch/switch.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can toggle switch', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchApp()); + + final Finder switchFinder = find.byType(Switch); + Switch materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, true); + + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch/switch.1_test.dart b/packages/material_ui/material_ui_examples/test/switch/switch.1_test.dart new file mode 100644 index 000000000000..d1f8e1a5863f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch/switch.1_test.dart @@ -0,0 +1,22 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch/switch.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can toggle switch', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchApp()); + + final Finder switchFinder = find.byType(Switch); + Switch materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, true); + + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch/switch.2_test.dart b/packages/material_ui/material_ui_examples/test/switch/switch.2_test.dart new file mode 100644 index 000000000000..b76d451f2317 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch/switch.2_test.dart @@ -0,0 +1,26 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch/switch.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch thumb icon supports material states', ( + WidgetTester tester, + ) async { + const Set<WidgetState> selected = <WidgetState>{WidgetState.selected}; + const Set<WidgetState> unselected = <WidgetState>{}; + + await tester.pumpWidget(const example.SwitchApp()); + + Switch materialSwitch = tester.widget<Switch>(find.byType(Switch).first); + expect(materialSwitch.thumbIcon, null); + + materialSwitch = tester.widget<Switch>(find.byType(Switch).last); + expect(materialSwitch.thumbIcon, isNotNull); + expect(materialSwitch.thumbIcon!.resolve(selected)!.icon, Icons.check); + expect(materialSwitch.thumbIcon!.resolve(unselected)!.icon, Icons.close); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch/switch.3_test.dart b/packages/material_ui/material_ui_examples/test/switch/switch.3_test.dart new file mode 100644 index 000000000000..7f75f2f7c74a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch/switch.3_test.dart @@ -0,0 +1,22 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch/switch.3.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can toggle switch', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchApp()); + + final Finder switchFinder = find.byType(Switch).first; + Switch materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, true); + + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + materialSwitch = tester.widget<Switch>(switchFinder); + expect(materialSwitch.value, false); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch/switch.4_test.dart b/packages/material_ui/material_ui_examples/test/switch/switch.4_test.dart new file mode 100644 index 000000000000..cddee143c279 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch/switch.4_test.dart @@ -0,0 +1,75 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch/switch.4.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Show adaptive switch theme', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchApp()); + + // Default is material style switches + expect(find.text('Show cupertino style'), findsOneWidget); + expect(find.text('Show material style'), findsNothing); + + Finder adaptiveSwitch = find.byType(Switch).first; + expect( + adaptiveSwitch, + paints + ..rrect(color: const Color(0xff6750a4)) // M3 primary color. + ..rrect() + ..rrect(color: Colors.white), // Thumb color + ); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Add customization')); + await tester.pumpAndSettle(); + + // Theme adaptation does not affect material-style switch. + adaptiveSwitch = find.byType(Switch).first; + expect( + adaptiveSwitch, + paints + ..rrect(color: const Color(0xff6750a4)) // M3 primary color. + ..rrect() + ..rrect(color: Colors.white), // Thumb color + ); + + await tester.tap( + find.widgetWithText(OutlinedButton, 'Show cupertino style'), + ); + await tester.pumpAndSettle(); + + expect( + adaptiveSwitch, + paints + ..rrect( + color: const Color(0xff795548), + ) // Customized track color only for cupertino. + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect( + color: const Color(0xffffeb3b), + ), // Customized thumb color only for cupertino. + ); + + await tester.tap( + find.widgetWithText(OutlinedButton, 'Remove customization'), + ); + await tester.pumpAndSettle(); + + expect( + adaptiveSwitch, + paints + ..rrect(color: const Color(0xff34c759)) // Cupertino system green. + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: Colors.white), // Thumb color + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch_list_tile/custom_labeled_switch.0_test.dart b/packages/material_ui/material_ui_examples/test/switch_list_tile/custom_labeled_switch.0_test.dart new file mode 100644 index 000000000000..117e32983e3f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch_list_tile/custom_labeled_switch.0_test.dart @@ -0,0 +1,32 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch_list_tile/custom_labeled_switch.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('LinkedLabelSwitch contains RichText and Switch', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabeledSwitchApp()); + + // Label text is in a RichText widget with the correct text. + final RichText richText = tester.widget(find.byType(RichText).first); + expect(richText.text.toPlainText(), 'Linked, tappable label text'); + + // Switch is initially off. + Switch switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, isFalse); + + // Tap to toggle the switch. + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + // Switch is now on. + switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch_list_tile/custom_labeled_switch.1_test.dart b/packages/material_ui/material_ui_examples/test/switch_list_tile/custom_labeled_switch.1_test.dart new file mode 100644 index 000000000000..8080fdb9af9b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch_list_tile/custom_labeled_switch.1_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch_list_tile/custom_labeled_switch.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tapping LabeledSwitch toggles the switch', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.LabeledSwitchApp()); + + // Switch is initially off. + Switch switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, isFalse); + + // Tap to toggle the switch. + await tester.tap(find.byType(example.LabeledSwitch)); + await tester.pumpAndSettle(); + + // Switch is now on. + switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch_list_tile/switch_list_tile.0_test.dart b/packages/material_ui/material_ui_examples/test/switch_list_tile/switch_list_tile.0_test.dart new file mode 100644 index 000000000000..dbe5d8fb560a --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch_list_tile/switch_list_tile.0_test.dart @@ -0,0 +1,25 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch_list_tile/switch_list_tile.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SwitchListTile can be toggled', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchListTileApp()); + + expect(find.byType(SwitchListTile), findsOneWidget); + + SwitchListTile switchListTile = tester.widget(find.byType(SwitchListTile)); + expect(switchListTile.value, isFalse); + + await tester.tap(find.byType(SwitchListTile)); + await tester.pumpAndSettle(); + + switchListTile = tester.widget(find.byType(SwitchListTile)); + expect(switchListTile.value, isTrue); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/switch_list_tile/switch_list_tile.1_test.dart b/packages/material_ui/material_ui_examples/test/switch_list_tile/switch_list_tile.1_test.dart new file mode 100644 index 000000000000..d2dbea2e17f3 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/switch_list_tile/switch_list_tile.1_test.dart @@ -0,0 +1,72 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/switch_list_tile/switch_list_tile.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch aligns appropriately', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchListTileApp()); + + expect(find.byType(SwitchListTile), findsNWidgets(3)); + + Offset tileTopLeft = tester.getTopLeft(find.byType(SwitchListTile).at(0)); + Offset switchTopLeft = tester.getTopLeft(find.byType(Switch).at(0)); + + // The switch is centered vertically with the text. + expect(switchTopLeft - tileTopLeft, const Offset(716.0, 16.0)); + + tileTopLeft = tester.getTopLeft(find.byType(SwitchListTile).at(1)); + switchTopLeft = tester.getTopLeft(find.byType(Switch).at(1)); + + // The switch is centered vertically with the text. + expect(switchTopLeft - tileTopLeft, const Offset(716.0, 30.0)); + + tileTopLeft = tester.getTopLeft(find.byType(SwitchListTile).at(2)); + switchTopLeft = tester.getTopLeft(find.byType(Switch).at(2)); + + // The switch is aligned to the top vertically with the text. + expect(switchTopLeft - tileTopLeft, const Offset(716.0, 8.0)); + }); + + testWidgets('Switches can be checked', (WidgetTester tester) async { + await tester.pumpWidget(const example.SwitchListTileApp()); + + expect(find.byType(SwitchListTile), findsNWidgets(3)); + + // All switches are on. + expect(tester.widget<Switch>(find.byType(Switch).at(0)).value, isTrue); + expect(tester.widget<Switch>(find.byType(Switch).at(1)).value, isTrue); + expect(tester.widget<Switch>(find.byType(Switch).at(2)).value, isTrue); + + // Tap the first switch. + await tester.tap(find.byType(Switch).at(0)); + await tester.pumpAndSettle(); + + // The first switch is off. + expect(tester.widget<Switch>(find.byType(Switch).at(0)).value, isFalse); + expect(tester.widget<Switch>(find.byType(Switch).at(1)).value, isTrue); + expect(tester.widget<Switch>(find.byType(Switch).at(2)).value, isTrue); + + // Tap the second switch. + await tester.tap(find.byType(Switch).at(1)); + await tester.pumpAndSettle(); + + // The first and second switches are off. + expect(tester.widget<Switch>(find.byType(Switch).at(0)).value, isFalse); + expect(tester.widget<Switch>(find.byType(Switch).at(1)).value, isFalse); + expect(tester.widget<Switch>(find.byType(Switch).at(2)).value, isTrue); + + // Tap the third switch. + await tester.tap(find.byType(Switch).at(2)); + await tester.pumpAndSettle(); + + // All switches are off. + expect(tester.widget<Switch>(find.byType(Switch).at(0)).value, isFalse); + expect(tester.widget<Switch>(find.byType(Switch).at(1)).value, isFalse); + expect(tester.widget<Switch>(find.byType(Switch).at(2)).value, isFalse); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tab_controller/tab_controller.1_test.dart b/packages/material_ui/material_ui_examples/test/tab_controller/tab_controller.1_test.dart new file mode 100644 index 000000000000..3994bb3fda82 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tab_controller/tab_controller.1_test.dart @@ -0,0 +1,99 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tab_controller/tab_controller.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Verify first tab is selected by default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TabControllerExampleApp()); + + final Tab firstTab = example.TabControllerExampleApp.tabs.first; + + expect( + find.descendant( + of: find.byType(TabBarView), + matching: find.text('${firstTab.text} Tab'), + ), + findsOneWidget, + ); + }); + + testWidgets('Verify tabs can be changed', (WidgetTester tester) async { + final List<String?> log = <String?>[]; + + final DebugPrintCallback originalDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + log.add(message); + }; + + await tester.pumpWidget(const example.TabControllerExampleApp()); + + const List<Tab> tabs = example.TabControllerExampleApp.tabs; + final List<Tab> tabsTraversalOrder = <Tab>[]; + + // The traverse order is from the second tab from the start to the last, + // and then from the second tab from the end to the first. + tabsTraversalOrder.addAll(tabs.skip(1)); + tabsTraversalOrder.addAll(tabs.reversed.skip(1)); + + for (final Tab tab in tabsTraversalOrder) { + // Tap on the TabBar's tab to select it. + await tester.tap( + find.descendant( + of: find.byType(TabBar), + matching: find.text(tab.text!), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(TabBarView), + matching: find.text('${tab.text} Tab'), + ), + findsOneWidget, + ); + + expect(log.length, equals(1)); + expect(log.last, equals('tab changed: ${tabs.indexOf(tab)}')); + + log.clear(); + } + + debugPrint = originalDebugPrint; + }); + + testWidgets( + 'DefaultTabControllerListener throws when no DefaultTabController above', + (WidgetTester tester) async { + await tester.pumpWidget( + example.DefaultTabControllerListener( + onTabChanged: (_) {}, + child: const SizedBox.shrink(), + ), + ); + + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + + final FlutterError error = exception as FlutterError; + expect( + error.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' No DefaultTabController for DefaultTabControllerListener.\n' + ' When creating a DefaultTabControllerListener, you must ensure\n' + ' that there is a DefaultTabController above the\n' + ' DefaultTabControllerListener.\n', + ), + ); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.0_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.0_test.dart new file mode 100644 index 000000000000..5fac0316ffd4 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.0_test.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch tabs in the TabBar', (WidgetTester tester) async { + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar tabBar = tester.widget<TabBar>(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + final Finder tab1 = find.widgetWithIcon(Tab, Icons.cloud_outlined); + final Finder tab2 = find.widgetWithIcon(Tab, Icons.beach_access_sharp); + final Finder tab3 = find.widgetWithIcon(Tab, Icons.brightness_5_sharp); + + const String tabBarViewText1 = "It's cloudy here"; + const String tabBarViewText2 = "It's rainy here"; + const String tabBarViewText3 = "It's sunny here"; + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsOneWidget); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab1); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsOneWidget); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab2); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsOneWidget); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab3); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.1_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.1_test.dart new file mode 100644 index 000000000000..d3237eddae0c --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.1_test.dart @@ -0,0 +1,49 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch tabs in the TabBar', (WidgetTester tester) async { + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar tabBar = tester.widget<TabBar>(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + final Finder tab1 = find.widgetWithIcon(Tab, Icons.cloud_outlined); + final Finder tab2 = find.widgetWithIcon(Tab, Icons.beach_access_sharp); + final Finder tab3 = find.widgetWithIcon(Tab, Icons.brightness_5_sharp); + + const String tabBarViewText1 = "It's cloudy here"; + const String tabBarViewText2 = "It's rainy here"; + const String tabBarViewText3 = "It's sunny here"; + + expect(find.text(tabBarViewText1), findsOneWidget); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab1); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsOneWidget); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab2); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsOneWidget); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab3); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.2_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.2_test.dart new file mode 100644 index 000000000000..b0daea64d428 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.2_test.dart @@ -0,0 +1,68 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch tabs in the TabBar', (WidgetTester tester) async { + const String primaryTabLabel1 = 'Flights'; + const String primaryTabLabel2 = 'Trips'; + const String primaryTabLabel3 = 'Explore'; + const String secondaryTabLabel1 = 'Overview'; + const String secondaryTabLabel2 = 'Specifications'; + + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar primaryTabBar = tester.widget<TabBar>( + find.byType(TabBar).last, + ); + expect(primaryTabBar.tabs.length, 3); + + final TabBar secondaryTabBar = tester.widget<TabBar>( + find.byType(TabBar).first, + ); + expect(secondaryTabBar.tabs.length, 2); + + String tabBarViewText = '$primaryTabLabel2: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(find.text(primaryTabLabel1)); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel1: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(find.text(secondaryTabLabel2)); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel1: $secondaryTabLabel2 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(find.text(primaryTabLabel2)); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel2: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(find.text(secondaryTabLabel2)); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel2: $secondaryTabLabel2 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(find.text(primaryTabLabel3)); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel3: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(find.text(secondaryTabLabel2)); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel3: $secondaryTabLabel2 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.3_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.3_test.dart new file mode 100644 index 000000000000..488725ffd30e --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.3_test.dart @@ -0,0 +1,59 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.3.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Expected mask displays when switching tabs in the TabBar', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const example.TabBarApp()); + await tester.pump(); + + final TabBar primaryTabBar = tester.widget<TabBar>( + find.byType(TabBar).last, + ); + expect(primaryTabBar.tabs.length, 20); + + // In initialization, the first tab is selected, the right mask should be displayed. + String tabBarText = 'Tab 0'; + expect(find.text(tabBarText), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + + // Tap the last visible tab on screen. + final Finder lastVisibleTabFinder = find.byElementPredicate(( + Element element, + ) { + if (element.widget is! Tab) { + return false; + } + final RenderBox box = element.renderObject! as RenderBox; + final Offset center = box.localToGlobal(box.size.center(Offset.zero)); + return center.dx >= 0 && center.dx <= tester.view.physicalSize.width; + }).last; + + await tester.tap(lastVisibleTabFinder); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + + // Jump to the end of the scrollable to verify the right mask is hidden. + final ScrollableState scrollable = tester.state( + find.byType(Scrollable).last, + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pumpAndSettle(); + tabBarText = 'Tab 19'; + final Finder currentTab = find.text(tabBarText); + expect(currentTab, findsOneWidget); + + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.indicator_animation.0_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.indicator_animation.0_test.dart new file mode 100644 index 000000000000..943bed905e62 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.indicator_animation.0_test.dart @@ -0,0 +1,101 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.indicator_animation.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'TabBar.indicatorAnimation can customize tab indicator animation', + (WidgetTester tester) async { + await tester.pumpWidget(const example.IndicatorAnimationExampleApp()); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>( + find.byType(TabBar), + ); + + late RRect indicatorRRect; + + expect( + tabBarBox, + paints..something((Symbol method, List<dynamic> arguments) { + if (method != #drawRRect) { + return false; + } + indicatorRRect = arguments[0] as RRect; + return true; + }), + ); + expect(indicatorRRect.left, equals(16.0)); + expect(indicatorRRect.top, equals(45.0)); + expect(indicatorRRect.right, closeTo(142.9, 0.1)); + expect(indicatorRRect.bottom, equals(48.0)); + + // Tap the long tab. + await tester.tap(find.text('Very Very Very Long Tab').first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + tabBarBox, + paints..something((Symbol method, List<dynamic> arguments) { + if (method != #drawRRect) { + return false; + } + indicatorRRect = arguments[0] as RRect; + return true; + }), + ); + expect(indicatorRRect.left, closeTo(107.5, 0.1)); + expect(indicatorRRect.top, equals(45.0)); + expect(indicatorRRect.right, closeTo(348.2, 0.1)); + expect(indicatorRRect.bottom, equals(48.0)); + + // Tap to go to the first tab. + await tester.tap(find.text('Short Tab').first); + await tester.pumpAndSettle(); + + expect( + tabBarBox, + paints..something((Symbol method, List<dynamic> arguments) { + if (method != #drawRRect) { + return false; + } + indicatorRRect = arguments[0] as RRect; + return true; + }), + ); + expect(indicatorRRect.left, equals(16.0)); + expect(indicatorRRect.top, equals(45.0)); + expect(indicatorRRect.right, closeTo(142.9, 0.1)); + expect(indicatorRRect.bottom, equals(48.0)); + + // Select the elastic animation. + await tester.tap(find.text('Elastic')); + await tester.pumpAndSettle(); + + // Tap the long tab. + await tester.tap(find.text('Very Very Very Long Tab').first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + tabBarBox, + paints..something((Symbol method, List<dynamic> arguments) { + if (method != #drawRRect) { + return false; + } + indicatorRRect = arguments[0] as RRect; + return true; + }), + ); + expect(indicatorRRect.left, closeTo(76.7, 0.1)); + expect(indicatorRRect.top, equals(45.0)); + expect(indicatorRRect.right, closeTo(423.1, 0.1)); + expect(indicatorRRect.bottom, equals(48.0)); + }, + ); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.onFocusChange_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.onFocusChange_test.dart new file mode 100644 index 000000000000..e060141f25bf --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.onFocusChange_test.dart @@ -0,0 +1,62 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.onFocusChange.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tabs change in response to focus', (WidgetTester tester) async { + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar tabBar = tester.widget<TabBar>(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + expect(tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).size, 25); + + // Focus on the first tab. + Element tabElement = tester.element(find.byIcon(Icons.cloud_outlined)); + FocusNode node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).size, 35); + expect(tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).size, 25); + + // Move focus to the second tab + tabElement = tester.element(find.byIcon(Icons.beach_access_sharp)); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).size, 35); + expect(tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).size, 25); + + // And the third + tabElement = tester.element(find.byIcon(Icons.brightness_5_sharp)); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).size, 35); + + // Unfocus + node.unfocus(); + await tester.pump(); + await tester.pump(); + + expect(tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).size, 25); + expect(tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).size, 25); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tabs/tab_bar.onHover_test.dart b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.onHover_test.dart new file mode 100644 index 000000000000..90a500ac5e2f --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tabs/tab_bar.onHover_test.dart @@ -0,0 +1,108 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tabs/tab_bar.onHover.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tabs change in response to hover', (WidgetTester tester) async { + await tester.pumpWidget(const example.TabBarApp()); + + final TabBar tabBar = tester.widget<TabBar>(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + expect( + tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).color, + Colors.purple, + ); + + // Hover over the first tab. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.cloud_outlined))); + await tester.pump(); + await tester.pump(); + expect( + tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).color, + Colors.pink, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).color, + Colors.purple, + ); + + // Hover over the second tab + await gesture.moveTo( + tester.getCenter(find.byIcon(Icons.beach_access_sharp)), + ); + await tester.pump(); + await tester.pump(); + expect( + tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).color, + Colors.pink, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).color, + Colors.purple, + ); + + // And the third + await gesture.moveTo( + tester.getCenter(find.byIcon(Icons.brightness_5_sharp)), + ); + await tester.pump(); + await tester.pump(); + expect( + tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).color, + Colors.pink, + ); + + // Remove hover + await gesture.removePointer(); + await tester.pump(); + await tester.pump(); + expect( + tester.widget<Icon>(find.byIcon(Icons.cloud_outlined)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.beach_access_sharp)).color, + Colors.purple, + ); + expect( + tester.widget<Icon>(find.byIcon(Icons.brightness_5_sharp)).color, + Colors.purple, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_button/text_button.0_test.dart b/packages/material_ui/material_ui_examples/test/text_button/text_button.0_test.dart new file mode 100644 index 000000000000..a481088f47b2 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_button/text_button.0_test.dart @@ -0,0 +1,103 @@ +// 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:io'; + +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/text_button/text_button.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // The app being tested loads images via HTTP which the test + // framework defeats by default. + setUpAll(() { + HttpOverrides.global = null; + }); + + testWidgets('TextButtonExample smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const example.TextButtonExampleApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Enabled')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Disabled')); + await tester.pumpAndSettle(); + + // TextButton.icon buttons are _TextButtonWithIcons rather than TextButtons. + // For the purposes of this test, just tapping in the right place is OK. + + await tester.tap(find.text('TextButton.icon #1')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('TextButton.icon #2')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #3')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #4')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #5')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #6')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #7')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'TextButton #8')); + await tester.pumpAndSettle(); + + final Finder smileyButton = find.byType(TextButton).last; + await tester.tap(smileyButton); + await tester.pump(); + + String smileyButtonImageUrl() { + final AnimatedContainer container = tester.widget<AnimatedContainer>( + find.descendant( + of: smileyButton, + matching: find.byType(AnimatedContainer), + ), + ); + final BoxDecoration decoration = container.decoration! as BoxDecoration; + final NetworkImage image = decoration.image!.image as NetworkImage; + return image.url; + } + + // The smiley button's onPressed method changes the button image + // for one second to simulate a long action running. The button's + // image changes while the action is running. + expect(smileyButtonImageUrl().endsWith('text_button_nhu_end.png'), isTrue); + await tester.pump(const Duration(seconds: 1)); + expect( + smileyButtonImageUrl().endsWith('text_button_nhu_default.png'), + isTrue, + ); + + // Pressing the smiley button while the one second action is + // underway starts a new one section action. The button's image + // doesn't change until the second action has finished. + await tester.tap(smileyButton); + await tester.pump(const Duration(milliseconds: 500)); + expect(smileyButtonImageUrl().endsWith('text_button_nhu_end.png'), isTrue); + await tester.tap(smileyButton); // Second button press. + await tester.pump(const Duration(milliseconds: 500)); + expect(smileyButtonImageUrl().endsWith('text_button_nhu_end.png'), isTrue); + await tester.pump(const Duration(milliseconds: 500)); + expect( + smileyButtonImageUrl().endsWith('text_button_nhu_default.png'), + isTrue, + ); + + await tester.tap(find.byType(Switch).at(0)); // Dark Mode Switch + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Switch).at(1)); // RTL Text Switch + await tester.pumpAndSettle(); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_button/text_button.1_test.dart b/packages/material_ui/material_ui_examples/test/text_button/text_button.1_test.dart new file mode 100644 index 000000000000..812c495d2c43 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_button/text_button.1_test.dart @@ -0,0 +1,58 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/text_button/text_button.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SelectableButton', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: const ColorScheme.light()), + home: const example.Home(), + ), + ); + + final Finder button = find.byType(example.SelectableButton); + + example.SelectableButton buttonWidget() => + tester.widget<example.SelectableButton>(button); + + Material buttonMaterial() { + return tester.widget<Material>( + find.descendant( + of: find.byType(example.SelectableButton), + matching: find.byType(Material), + ), + ); + } + + expect(buttonWidget().selected, false); + expect( + buttonMaterial().textStyle!.color, + const ColorScheme.light().primary, + ); // default button foreground color + expect( + buttonMaterial().color, + Colors.transparent, + ); // default button background color + + await tester.tap(button); // Toggles the button's selected property. + await tester.pumpAndSettle(); + expect(buttonWidget().selected, true); + expect(buttonMaterial().textStyle!.color, Colors.white); + expect(buttonMaterial().color, Colors.indigo); + + await tester.tap(button); // Toggles the button's selected property. + await tester.pumpAndSettle(); + expect(buttonWidget().selected, false); + expect( + buttonMaterial().textStyle!.color, + const ColorScheme.light().primary, + ); + expect(buttonMaterial().color, Colors.transparent); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_field/text_field.0_test.dart b/packages/material_ui/material_ui_examples/test/text_field/text_field.0_test.dart new file mode 100644 index 000000000000..311312ea6576 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_field/text_field.0_test.dart @@ -0,0 +1,24 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/text_field/text_field.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TextField is obscured and has "Password" as labelText', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TextFieldExampleApp()); + + expect(find.byType(TextField), findsOneWidget); + + final TextField textField = tester.widget<TextField>( + find.byType(TextField), + ); + expect(textField.obscureText, isTrue); + expect(textField.decoration!.labelText, 'Password'); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_field/text_field.1_test.dart b/packages/material_ui/material_ui_examples/test/text_field/text_field.1_test.dart new file mode 100644 index 000000000000..c9bc8c07cbbe --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_field/text_field.1_test.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/text_field/text_field.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Dialog shows submitted TextField value', ( + WidgetTester tester, + ) async { + // This example is also used to illustrate special character counting. + const String sampleText = 'Some sample text 👨‍👩‍👦'; + await tester.pumpWidget(const example.TextFieldExampleApp()); + + expect(find.byType(TextField), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Thanks!'), findsNothing); + expect(find.widgetWithText(TextButton, 'OK'), findsNothing); + expect( + find.text( + 'You typed "$sampleText", which has the length ${sampleText.length}.', + ), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), sampleText); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Thanks!'), findsOneWidget); + expect(find.widgetWithText(TextButton, 'OK'), findsOneWidget); + expect( + find.text( + 'You typed "$sampleText", which has length ${sampleText.characters.length}.', + ), + findsOneWidget, + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_field/text_field.2_test.dart b/packages/material_ui/material_ui_examples/test/text_field/text_field.2_test.dart new file mode 100644 index 000000000000..8754537564f5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_field/text_field.2_test.dart @@ -0,0 +1,57 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/text_field/text_field.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Creates styled text fields', (WidgetTester tester) async { + await tester.pumpWidget(const example.TextFieldExamplesApp()); + + expect(find.text('TextField Examples'), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.byType(example.FilledTextFieldExample), findsOneWidget); + expect(find.byType(example.OutlinedTextFieldExample), findsOneWidget); + + final TextField filled = tester.widget<TextField>( + find.descendant( + of: find.byType(example.FilledTextFieldExample), + matching: find.byType(TextField), + ), + ); + expect( + filled.decoration!.prefixIcon, + isA<Icon>().having((Icon icon) => icon.icon, 'icon', Icons.search), + ); + expect( + filled.decoration!.suffixIcon, + isA<Icon>().having((Icon icon) => icon.icon, 'icon', Icons.clear), + ); + expect(filled.decoration!.labelText, 'Filled'); + expect(filled.decoration!.hintText, 'hint text'); + expect(filled.decoration!.helperText, 'supporting text'); + expect(filled.decoration!.filled, true); + + final TextField outlined = tester.widget<TextField>( + find.descendant( + of: find.byType(example.OutlinedTextFieldExample), + matching: find.byType(TextField), + ), + ); + expect( + outlined.decoration!.prefixIcon, + isA<Icon>().having((Icon icon) => icon.icon, 'icon', Icons.search), + ); + expect( + outlined.decoration!.suffixIcon, + isA<Icon>().having((Icon icon) => icon.icon, 'icon', Icons.clear), + ); + expect(outlined.decoration!.labelText, 'Outlined'); + expect(outlined.decoration!.hintText, 'hint text'); + expect(outlined.decoration!.helperText, 'supporting text'); + expect(outlined.decoration!.border, isA<OutlineInputBorder>()); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_field/text_field.3_test.dart b/packages/material_ui/material_ui_examples/test/text_field/text_field.3_test.dart new file mode 100644 index 000000000000..e2404c2291b7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_field/text_field.3_test.dart @@ -0,0 +1,77 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/text_field/text_field.3.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TextFieldExampleApp', () { + Future<void> pressShiftEnter(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + } + + testWidgets('displays correct label', (WidgetTester tester) async { + await tester.pumpWidget(const example.TextFieldExampleApp()); + + expect( + find.text( + 'Please submit some text\n\n' + 'Press Shift+Enter for a new line\n' + 'Press Enter to submit', + ), + findsOneWidget, + ); + }); + + testWidgets('adds new line when Shift+Enter is pressed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TextFieldExampleApp()); + + final Finder textFieldFinder = find.byType(TextField); + + await tester.enterText(textFieldFinder, 'Hello'); + expect( + find.descendant(of: textFieldFinder, matching: find.text('Hello')), + findsOneWidget, + ); + + await pressShiftEnter(tester); + + expect( + find.descendant(of: textFieldFinder, matching: find.text('Hello\n')), + findsOneWidget, + ); + }); + + testWidgets('displays entered text when TextField is submitted', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TextFieldExampleApp()); + + final Finder textFieldFinder = find.byType(TextField); + + await tester.enterText(textFieldFinder, 'Hello'); + expect( + find.descendant(of: textFieldFinder, matching: find.text('Hello')), + findsOneWidget, + ); + + await pressShiftEnter(tester); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect( + find.descendant(of: textFieldFinder, matching: find.text('')), + findsOneWidget, + ); + expect(find.text('Submitted text:\n\nHello\n'), findsOneWidget); + }); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_form_field/text_form_field.1_test.dart b/packages/material_ui/material_ui_examples/test/text_form_field/text_form_field.1_test.dart new file mode 100644 index 000000000000..652b9c3d40f5 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_form_field/text_form_field.1_test.dart @@ -0,0 +1,65 @@ +// 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:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui_examples/text_form_field/text_form_field.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Pressing space should focus the next field', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TextFormFieldExampleApp()); + final Finder textFormField = find.byType(TextFormField); + + expect(textFormField, findsExactly(5)); + + final Finder editableText = find.byType(EditableText); + expect(editableText, findsExactly(5)); + + List<bool> getFocuses() { + return editableText + .evaluate() + .map( + (Element finderResult) => + (finderResult.widget as EditableText).focusNode.hasFocus, + ) + .toList(); + } + + expect(getFocuses(), const <bool>[false, false, false, false, false]); + + await tester.tap(textFormField.first); + await tester.pump(); + + expect(getFocuses(), const <bool>[true, false, false, false, false]); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(getFocuses(), const <bool>[false, true, false, false, false]); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(getFocuses(), const <bool>[false, false, true, false, false]); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(getFocuses(), const <bool>[false, false, false, true, false]); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(getFocuses(), const <bool>[false, false, false, false, true]); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + + expect(getFocuses(), const <bool>[true, false, false, false, false]); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/text_form_field/text_form_field.2_test.dart b/packages/material_ui/material_ui_examples/test/text_form_field/text_form_field.2_test.dart new file mode 100644 index 000000000000..0495b8ce47ec --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/text_form_field/text_form_field.2_test.dart @@ -0,0 +1,99 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/text_form_field/text_form_field.2.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TextFormFieldExample2 Widget Tests', () { + testWidgets( + 'Input validation handles empty, incorrect, and short usernames', + (WidgetTester tester) async { + await tester.pumpWidget(const example.TextFormFieldExampleApp()); + final Finder textFormField = find.byType(TextFormField); + final Finder saveButton = find.byType(TextButton); + + await tester.enterText(textFormField, ''); + await tester.pump(); + await tester.tap(saveButton); + await tester.pump(); + expect(find.text('This field is required'), findsOneWidget); + + await tester.enterText(textFormField, 'jo hn'); + await tester.tap(saveButton); + await tester.pump(); + expect( + find.text('Username must not contain any spaces'), + findsOneWidget, + ); + + await tester.enterText(textFormField, 'jo'); + await tester.tap(saveButton); + await tester.pump(); + expect( + find.text('Username should be at least 3 characters long'), + findsOneWidget, + ); + + await tester.enterText(textFormField, '1jo'); + await tester.tap(saveButton); + await tester.pump(); + expect( + find.text('Username must not start with a number'), + findsOneWidget, + ); + }, + ); + + testWidgets('Async validation feedback is handled correctly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TextFormFieldExampleApp()); + final Finder textFormField = find.byType(TextFormField); + final Finder saveButton = find.byType(TextButton); + + // Simulate entering a username already taken. + await tester.enterText(textFormField, 'jack'); + await tester.pump(); + await tester.tap(saveButton); + await tester.pump(); + expect(find.text('Username jack is already taken'), findsNothing); + await tester.pump(example.kFakeHttpRequestDuration); + expect(find.text('Username jack is already taken'), findsOneWidget); + + await tester.enterText(textFormField, 'alex'); + await tester.pump(); + await tester.tap(saveButton); + await tester.pump(); + expect(find.text('Username alex is already taken'), findsNothing); + await tester.pump(example.kFakeHttpRequestDuration); + expect(find.text('Username alex is already taken'), findsOneWidget); + + await tester.enterText(textFormField, 'jack'); + await tester.pump(); + await tester.tap(saveButton); + await tester.pump(); + expect(find.text('Username jack is already taken'), findsNothing); + await tester.pump(example.kFakeHttpRequestDuration); + expect(find.text('Username jack is already taken'), findsOneWidget); + }); + + testWidgets('Loading spinner displays correctly when saving', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.TextFormFieldExampleApp()); + final Finder textFormField = find.byType(TextFormField); + final Finder saveButton = find.byType(TextButton); + await tester.enterText(textFormField, 'alexander'); + await tester.pump(); + await tester.tap(saveButton); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(example.kFakeHttpRequestDuration); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/theme/theme_extension.1_test.dart b/packages/material_ui/material_ui_examples/test/theme/theme_extension.1_test.dart new file mode 100644 index 000000000000..a6753c439eff --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/theme/theme_extension.1_test.dart @@ -0,0 +1,95 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/theme/theme_extension.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ThemeExtension can be obtained', (WidgetTester tester) async { + await tester.pumpWidget(const example.ThemeExtensionExampleApp()); + + final ThemeData theme = Theme.of(tester.element(find.byType(example.Home))); + final example.MyColors colors = theme.extension<example.MyColors>()!; + + expect(colors.brandColor, equals(const Color(0xFF1E88E5))); + expect(colors.danger, equals(const Color(0xFFE53935))); + }); + + testWidgets('ThemeExtension can be changed', (WidgetTester tester) async { + await tester.pumpWidget(const example.ThemeExtensionExampleApp()); + + ThemeData theme = Theme.of(tester.element(find.byType(example.Home))); + example.MyColors colors = theme.extension<example.MyColors>()!; + + expect(colors.brandColor, equals(const Color(0xFF1E88E5))); + expect(colors.danger, equals(const Color(0xFFE53935))); + + // Tap the IconButton to switch theme mode from light to dark. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + theme = Theme.of(tester.element(find.byType(example.Home))); + colors = theme.extension<example.MyColors>()!; + + expect(colors.brandColor, equals(const Color(0xFF90CAF9))); + expect(colors.danger, equals(const Color(0xFFEF9A9A))); + }); + + testWidgets('Home uses MyColors extension correctly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: const <ThemeExtension<dynamic>>[ + example.MyColors( + brandColor: Color(0xFF0000FF), + danger: Color(0xFFFF0000), + ), + ], + ), + home: example.Home(isLightTheme: true, toggleTheme: () {}), + ), + ); + + expect( + find.byType(example.Home), + paints + ..rect(color: const Color(0xFF0000FF)) + ..rect(color: const Color(0xFFFF0000)), + ); + }); + + testWidgets('Home updates IconButton correctly', (WidgetTester tester) async { + await tester.pumpWidget(const example.ThemeExtensionExampleApp()); + + example.Home home = tester.widget(find.byType(example.Home)); + IconButton iconButton = tester.widget(find.byType(IconButton)); + ThemeData theme = Theme.of(tester.element(find.byType(example.Home))); + + expect(theme.brightness, equals(Brightness.light)); + expect(home.isLightTheme, isTrue); + expect( + iconButton.icon, + isA<Icon>().having((Icon i) => i.icon, 'icon', equals(Icons.nightlight)), + ); + + // Tap the IconButton to switch theme mode from light to dark. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + home = tester.widget(find.byType(example.Home)); + iconButton = tester.widget(find.byType(IconButton)); + theme = Theme.of(tester.element(find.byType(example.Home))); + + expect(theme.brightness, equals(Brightness.dark)); + expect(home.isLightTheme, isFalse); + expect( + iconButton.icon, + isA<Icon>().having((Icon i) => i.icon, 'icon', equals(Icons.wb_sunny)), + ); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/theme_data/theme_data.0_test.dart b/packages/material_ui/material_ui_examples/test/theme_data/theme_data.0_test.dart new file mode 100644 index 000000000000..178329b35a87 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/theme_data/theme_data.0_test.dart @@ -0,0 +1,42 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/theme_data/theme_data.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ThemeData basics', (WidgetTester tester) async { + await tester.pumpWidget(const example.ThemeDataExampleApp()); + + final ColorScheme colorScheme = ColorScheme.fromSeed( + seedColor: Colors.indigo, + ); + + final Material fabMaterial = tester.widget<Material>( + find.descendant( + of: find.byType(FloatingActionButton), + matching: find.byType(Material), + ), + ); + expect(fabMaterial.color, colorScheme.tertiary); + + final RichText iconRichText = tester.widget<RichText>( + find.descendant( + of: find.byIcon(Icons.add), + matching: find.byType(RichText), + ), + ); + expect(iconRichText.text.style!.color, colorScheme.onTertiary); + + expect(find.text('8 Points'), isNotNull); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(find.text('9 Points'), isNotNull); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(find.text('10 Points'), isNotNull); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/time_picker/show_time_picker.0_test.dart b/packages/material_ui/material_ui_examples/test/time_picker/show_time_picker.0_test.dart new file mode 100644 index 000000000000..82bc3e36518d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/time_picker/show_time_picker.0_test.dart @@ -0,0 +1,70 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/time_picker/show_time_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Can open and modify time picker', (WidgetTester tester) async { + const String openPicker = 'Open time picker'; + final List<String> options = <String>[ + '$TimePickerEntryMode', + ...TimePickerEntryMode.values.map<String>( + (TimePickerEntryMode value) => value.name, + ), + '$ThemeMode', + ...ThemeMode.values.map<String>((ThemeMode value) => value.name), + '$TextDirection', + ...TextDirection.values.map<String>((TextDirection value) => value.name), + '$MaterialTapTargetSize', + ...MaterialTapTargetSize.values.map<String>( + (MaterialTapTargetSize value) => value.name, + ), + '$Orientation', + ...Orientation.values.map<String>((Orientation value) => value.name), + 'Time Mode', + '12-hour am/pm time', + '24-hour time', + 'Material Version', + 'Material 2', + 'Material 3', + openPicker, + ]; + + await tester.pumpWidget(const example.ShowTimePickerApp()); + + for (final String option in options) { + expect( + find.text(option), + findsOneWidget, + reason: 'Unable to find $option widget in example.', + ); + } + + // Open time picker + await tester.tap(find.text(openPicker)); + await tester.pumpAndSettle(); + expect(find.text('Select time'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('OK'), findsOneWidget); + + // Close time picker + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + expect(find.text('Select time'), findsNothing); + expect(find.text('Cancel'), findsNothing); + expect(find.text('OK'), findsNothing); + + // Change an option. + await tester.tap(find.text('Material 2')); + await tester.pumpAndSettle(); + await tester.tap(find.text(openPicker)); + await tester.pumpAndSettle(); + expect(find.text('SELECT TIME'), findsOneWidget); + expect(find.text('CANCEL'), findsOneWidget); + expect(find.text('OK'), findsOneWidget); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/toggle_buttons/toggle_buttons.0_test.dart b/packages/material_ui/material_ui_examples/test/toggle_buttons/toggle_buttons.0_test.dart new file mode 100644 index 000000000000..f75375107c9b --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/toggle_buttons/toggle_buttons.0_test.dart @@ -0,0 +1,153 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/toggle_buttons/toggle_buttons.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Single-select ToggleButtons', (WidgetTester tester) async { + TextButton findButton(String text) { + return tester.widget<TextButton>(find.widgetWithText(TextButton, text)); + } + + await tester.pumpWidget(const example.ToggleButtonsExampleApp()); + + TextButton firstButton = findButton('Apple'); + TextButton secondButton = findButton('Banana'); + TextButton thirdButton = findButton('Orange'); + + const Color selectedColor = Color(0xffef9a9a); + const Color unselectedColor = Color(0x00fef7ff); + + /// First button is selected. + expect(firstButton.style!.backgroundColor!.resolve(enabled), selectedColor); + expect( + secondButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + expect( + thirdButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + + /// Tap on second button. + await tester.tap(find.widgetWithText(TextButton, 'Banana')); + await tester.pumpAndSettle(); + + firstButton = findButton('Apple'); + secondButton = findButton('Banana'); + thirdButton = findButton('Orange'); + + /// Only second button is selected. + expect( + firstButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + expect( + secondButton.style!.backgroundColor!.resolve(enabled), + selectedColor, + ); + expect( + thirdButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + }); + + testWidgets('Multi-select ToggleButtons', (WidgetTester tester) async { + TextButton findButton(String text) { + return tester.widget<TextButton>(find.widgetWithText(TextButton, text)); + } + + await tester.pumpWidget(const example.ToggleButtonsExampleApp()); + + TextButton firstButton = findButton('Tomatoes'); + TextButton secondButton = findButton('Potatoes'); + TextButton thirdButton = findButton('Carrots'); + + const Color selectedColor = Color(0xffa5d6a7); + const Color unselectedColor = Color(0x00fef7ff); + + /// Second button is selected. + expect( + firstButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + expect( + secondButton.style!.backgroundColor!.resolve(enabled), + selectedColor, + ); + expect( + thirdButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + + /// Tap on other two buttons. + await tester.tap(find.widgetWithText(TextButton, 'Tomatoes')); + await tester.tap(find.widgetWithText(TextButton, 'Carrots')); + await tester.pumpAndSettle(); + + firstButton = findButton('Tomatoes'); + secondButton = findButton('Potatoes'); + thirdButton = findButton('Carrots'); + + /// All buttons are selected. + expect(firstButton.style!.backgroundColor!.resolve(enabled), selectedColor); + expect( + secondButton.style!.backgroundColor!.resolve(enabled), + selectedColor, + ); + expect(thirdButton.style!.backgroundColor!.resolve(enabled), selectedColor); + }); + + testWidgets('Icon-only ToggleButtons', (WidgetTester tester) async { + TextButton findButton(IconData iconData) { + return tester.widget<TextButton>( + find.widgetWithIcon(TextButton, iconData), + ); + } + + await tester.pumpWidget(const example.ToggleButtonsExampleApp()); + + TextButton firstButton = findButton(Icons.sunny); + TextButton secondButton = findButton(Icons.cloud); + TextButton thirdButton = findButton(Icons.ac_unit); + + const Color selectedColor = Color(0xff90caf9); + const Color unselectedColor = Color(0x00fef7ff); + + /// Third button is selected. + expect( + firstButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + expect( + secondButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + expect(thirdButton.style!.backgroundColor!.resolve(enabled), selectedColor); + + /// Tap on the first button. + await tester.tap(find.widgetWithIcon(TextButton, Icons.sunny)); + await tester.pumpAndSettle(); + + firstButton = findButton(Icons.sunny); + secondButton = findButton(Icons.cloud); + thirdButton = findButton(Icons.ac_unit); + + /// First button os selected. + expect(firstButton.style!.backgroundColor!.resolve(enabled), selectedColor); + expect( + secondButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + expect( + thirdButton.style!.backgroundColor!.resolve(enabled), + unselectedColor, + ); + }); +} + +Set<WidgetState> enabled = <WidgetState>{}; diff --git a/packages/material_ui/material_ui_examples/test/toggle_buttons/toggle_buttons.1_test.dart b/packages/material_ui/material_ui_examples/test/toggle_buttons/toggle_buttons.1_test.dart new file mode 100644 index 000000000000..099546f78c90 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/toggle_buttons/toggle_buttons.1_test.dart @@ -0,0 +1,136 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/toggle_buttons/toggle_buttons.1.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ToggleButtons allows multiple or no selection', ( + WidgetTester tester, + ) async { + final ThemeData theme = ThemeData(); + Finder findButton(String text) { + return find.descendant( + of: find.byType(ToggleButtons), + matching: find.widgetWithText(TextButton, text), + ); + } + + await tester.pumpWidget(const example.ToggleButtonsApp()); + + TextButton toggleMButton = tester.widget<TextButton>(findButton('M')); + TextButton toggleXLButton = tester.widget<TextButton>(findButton('XL')); + + // Initially, only M is selected. + expect( + toggleMButton.style!.backgroundColor!.resolve(enabled), + isSameColorAs(theme.colorScheme.primary.withValues(alpha: 0.1216)), + ); + expect( + toggleXLButton.style!.backgroundColor!.resolve(enabled), + theme.colorScheme.surface.withValues(alpha: 0.0), + ); + + // Tap on XL. + await tester.tap(findButton('XL')); + await tester.pumpAndSettle(); + + // Now both M and XL are selected. + toggleMButton = tester.widget<TextButton>(findButton('M')); + toggleXLButton = tester.widget<TextButton>(findButton('XL')); + + expect( + toggleMButton.style!.backgroundColor!.resolve(enabled), + isSameColorAs(theme.colorScheme.primary.withValues(alpha: 0.1216)), + ); + expect( + toggleXLButton.style!.backgroundColor!.resolve(enabled), + isSameColorAs(theme.colorScheme.primary.withValues(alpha: 0.1216)), + ); + + // Tap M to deselect it. + await tester.tap(findButton('M')); + await tester.pumpAndSettle(); + + // Tap XL to deselect it. + await tester.tap(findButton('XL')); + await tester.pumpAndSettle(); + + // Now neither M nor XL are selected. + toggleMButton = tester.widget<TextButton>(findButton('M')); + toggleXLButton = tester.widget<TextButton>(findButton('XL')); + + expect( + toggleMButton.style!.backgroundColor!.resolve(enabled), + theme.colorScheme.surface.withValues(alpha: 0.0), + ); + expect( + toggleXLButton.style!.backgroundColor!.resolve(enabled), + theme.colorScheme.surface.withValues(alpha: 0.0), + ); + }); + + testWidgets('SegmentedButton allows multiple or no selection', ( + WidgetTester tester, + ) async { + final ThemeData theme = ThemeData(); + Finder findButton(String text) { + return find.descendant( + of: find.byType(SegmentedButton<example.ShirtSize>), + matching: find.widgetWithText(TextButton, text), + ); + } + + await tester.pumpWidget(const example.ToggleButtonsApp()); + + Material segmentMButton = tester.widget<Material>( + find.descendant(of: findButton('M'), matching: find.byType(Material)), + ); + Material segmentXLButton = tester.widget<Material>( + find.descendant(of: findButton('XL'), matching: find.byType(Material)), + ); + + // Initially, only M is selected. + expect(segmentMButton.color, theme.colorScheme.secondaryContainer); + expect(segmentXLButton.color, Colors.transparent); + + // Tap on XL. + await tester.tap(findButton('XL')); + await tester.pumpAndSettle(); + + // // Now both M and XL are selected. + segmentMButton = tester.widget<Material>( + find.descendant(of: findButton('M'), matching: find.byType(Material)), + ); + segmentXLButton = tester.widget<Material>( + find.descendant(of: findButton('XL'), matching: find.byType(Material)), + ); + + expect(segmentMButton.color, theme.colorScheme.secondaryContainer); + expect(segmentXLButton.color, theme.colorScheme.secondaryContainer); + + // Tap M to deselect it. + await tester.tap(findButton('M')); + await tester.pumpAndSettle(); + + // Tap XL to deselect it. + await tester.tap(findButton('XL')); + await tester.pumpAndSettle(); + + // Now neither M nor XL are selected. + segmentMButton = tester.widget<Material>( + find.descendant(of: findButton('M'), matching: find.byType(Material)), + ); + segmentXLButton = tester.widget<Material>( + find.descendant(of: findButton('XL'), matching: find.byType(Material)), + ); + + expect(segmentMButton.color, Colors.transparent); + expect(segmentXLButton.color, Colors.transparent); + }); +} + +Set<WidgetState> enabled = <WidgetState>{}; diff --git a/packages/material_ui/material_ui_examples/test/tooltip/tooltip.0_test.dart b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.0_test.dart new file mode 100644 index 000000000000..5af6c48800d0 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.0_test.dart @@ -0,0 +1,42 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tooltip/tooltip.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tooltip is visible when hovering over text', ( + WidgetTester tester, + ) async { + const String tooltipText = 'I am a Tooltip'; + + await tester.pumpWidget(const example.TooltipExampleApp()); + + TestGesture? gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(() async { + if (gesture != null) { + return gesture.removePointer(); + } + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + expect(find.text(tooltipText), findsNothing); + // Move the mouse over the text and wait for the tooltip to appear. + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(tooltipText), findsOneWidget); + // Move the mouse away and wait for the tooltip to disappear. + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + expect(find.text(tooltipText), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tooltip/tooltip.1_test.dart b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.1_test.dart new file mode 100644 index 000000000000..816f6b8a9b2d --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.1_test.dart @@ -0,0 +1,44 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tooltip/tooltip.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tooltip wait and show duration', (WidgetTester tester) async { + const String tooltipText = 'I am a Tooltip'; + + await tester.pumpWidget(const example.TooltipExampleApp()); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + expect(find.text(tooltipText), findsNothing); + + // Move the mouse over the text and wait for the tooltip to appear. + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(tester.getCenter(tooltip)); + // Wait half a second and the tooltip should still not be visible. + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text(tooltipText), findsNothing); + // Wait another half a second and the tooltip should be visible now. + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text(tooltipText), findsOneWidget); + // Move the mouse away and wait for the tooltip to disappear. + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + // Wait another second and the tooltip should be gone. + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tooltip/tooltip.2_test.dart b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.2_test.dart new file mode 100644 index 000000000000..387de9350c24 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.2_test.dart @@ -0,0 +1,43 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tooltip/tooltip.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tooltip is visible when hovering over text', ( + WidgetTester tester, + ) async { + const String tooltipText = + 'I am a rich tooltip. I am another span of this rich tooltip'; + + await tester.pumpWidget(const example.TooltipExampleApp()); + + TestGesture? gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(() async { + if (gesture != null) { + return gesture.removePointer(); + } + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + expect(find.text(tooltipText), findsNothing); + // Move the mouse over the text and wait for the tooltip to appear. + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(tooltipText), findsOneWidget); + // Move the mouse away and wait for the tooltip to disappear. + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + expect(find.text(tooltipText), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/tooltip/tooltip.3_test.dart b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.3_test.dart new file mode 100644 index 000000000000..12dbf63b83a7 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/tooltip/tooltip.3_test.dart @@ -0,0 +1,28 @@ +// 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:material_ui/material_ui.dart'; +import 'package:material_ui_examples/tooltip/tooltip.3.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tooltip is visible when tapping button', ( + WidgetTester tester, + ) async { + const String tooltipText = 'I am a Tooltip'; + + await tester.pumpWidget(const example.TooltipExampleApp()); + + // Tooltip is not visible before tapping the button. + expect(find.text(tooltipText), findsNothing); + // Tap on the button and wait for the tooltip to appear. + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(tooltipText), findsOneWidget); + // Tap on the tooltip and wait for the tooltip to disappear. + await tester.tap(find.byTooltip(tooltipText)); + await tester.pump(const Duration(seconds: 1)); + expect(find.text(tooltipText), findsNothing); + }); +} diff --git a/packages/material_ui/material_ui_examples/test/widget_state_input_border/widget_state_input_border.0_test.dart b/packages/material_ui/material_ui_examples/test/widget_state_input_border/widget_state_input_border.0_test.dart new file mode 100644 index 000000000000..b2b2750a1763 --- /dev/null +++ b/packages/material_ui/material_ui_examples/test/widget_state_input_border/widget_state_input_border.0_test.dart @@ -0,0 +1,58 @@ +// 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/gestures.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/widget_state_input_border/widget_state_input_border.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('InputBorder appearance matches configuration', ( + WidgetTester tester, + ) async { + const WidgetStateInputBorder inputBorder = + WidgetStateInputBorder.resolveWith( + example.WidgetStateInputBorderExample.veryCoolBorder, + ); + + void expectBorderToMatch(Set<WidgetState> states) { + final RenderBox renderBox = tester.renderObject( + find.descendant( + of: find.byType(TextField), + matching: find.byType(CustomPaint), + ), + ); + + final BorderSide side = inputBorder.resolve(states).borderSide; + expect( + renderBox, + paints..line(color: side.color, strokeWidth: side.width), + ); + } + + await tester.pumpWidget(const example.WidgetStateInputBorderExampleApp()); + expectBorderToMatch(const <WidgetState>{WidgetState.disabled}); + + await tester.tap(find.byType(FilterChip)); + await tester.pumpAndSettle(); + expectBorderToMatch(const <WidgetState>{}); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expectBorderToMatch(const <WidgetState>{WidgetState.focused}); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer( + location: tester.getCenter(find.byType(TextField)), + ); + await tester.pumpAndSettle(); + expectBorderToMatch(const <WidgetState>{ + WidgetState.focused, + WidgetState.hovered, + }); + }); +} diff --git a/packages/material_ui/pubspec.yaml b/packages/material_ui/pubspec.yaml index 1700a3878450..fd3a390fb54a 100644 --- a/packages/material_ui/pubspec.yaml +++ b/packages/material_ui/pubspec.yaml @@ -3,12 +3,19 @@ description: The official Flutter Material UI Library, implementing Google's Mat version: 0.0.1 repository: https://github.com/flutter/packages/tree/main/packages/material_ui issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A%20material%20design%22 +publish_to: 'none' environment: sdk: ^3.9.0 flutter: ">=3.35.0" +workspace: + - material_ui_examples + dependencies: + cupertino_ui: + # TODO(justinmc): Use the pub.dev package when published. + path: ../cupertino_ui flutter: sdk: flutter diff --git a/packages/material_ui/test/material/about_test.dart b/packages/material_ui/test/material/about_test.dart new file mode 100644 index 000000000000..c7537a19e5fa --- /dev/null +++ b/packages/material_ui/test/material/about_test.dart @@ -0,0 +1,2051 @@ +// 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:async'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(() { + LicenseRegistry.reset(); + }); + + testWidgets('Material3 has sentence case labels', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: Builder( + builder: (BuildContext context) => ElevatedButton( + onPressed: () { + showAboutDialog(context: context, useRootNavigator: false, applicationName: 'A'); + }, + child: const Text('Show About Dialog'), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text('Close'), findsOneWidget); + expect(find.text('View licenses'), findsOneWidget); + }); + + testWidgets('Material2 - AboutListTile control test', (WidgetTester tester) async { + const logo = FlutterLogo(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + title: 'Pirate app', + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + drawer: Drawer( + child: ListView( + children: const <Widget>[ + AboutListTile( + applicationVersion: '0.1.2', + applicationIcon: logo, + applicationLegalese: 'I am the very model of a modern major general.', + aboutBoxChildren: <Widget>[Text('About box')], + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('About Pirate app'), findsNothing); + expect(find.text('0.1.2'), findsNothing); + expect(find.byWidget(logo), findsNothing); + expect(find.text('I am the very model of a modern major general.'), findsNothing); + expect(find.text('About box'), findsNothing); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(find.text('About Pirate app'), findsOneWidget); + expect(find.text('0.1.2'), findsNothing); + expect(find.byWidget(logo), findsNothing); + expect(find.text('I am the very model of a modern major general.'), findsNothing); + expect(find.text('About box'), findsNothing); + + await tester.tap(find.text('About Pirate app')); + await tester.pumpAndSettle(); + + expect(find.text('About Pirate app'), findsOneWidget); + expect(find.text('0.1.2'), findsOneWidget); + expect(find.byWidget(logo), findsOneWidget); + expect(find.text('I am the very model of a modern major general.'), findsOneWidget); + expect(find.text('About box'), findsOneWidget); + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['Pirate package '], 'Pirate license'), + ]); + }); + + await tester.tap(find.text('VIEW LICENSES')); + await tester.pumpAndSettle(); + + expect(find.text('Pirate app'), findsOneWidget); + expect(find.text('0.1.2'), findsOneWidget); + expect(find.byWidget(logo), findsOneWidget); + expect(find.text('I am the very model of a modern major general.'), findsOneWidget); + await tester.tap(find.text('Pirate package ')); + await tester.pumpAndSettle(); + expect(find.text('Pirate license'), findsOneWidget); + }); + + testWidgets('Material3 - AboutListTile control test', (WidgetTester tester) async { + const logo = FlutterLogo(); + + await tester.pumpWidget( + MaterialApp( + title: 'Pirate app', + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + drawer: Drawer( + child: ListView( + children: const <Widget>[ + AboutListTile( + applicationVersion: '0.1.2', + applicationIcon: logo, + applicationLegalese: 'I am the very model of a modern major general.', + aboutBoxChildren: <Widget>[Text('About box')], + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('About Pirate app'), findsNothing); + expect(find.text('0.1.2'), findsNothing); + expect(find.byWidget(logo), findsNothing); + expect(find.text('I am the very model of a modern major general.'), findsNothing); + expect(find.text('About box'), findsNothing); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(find.text('About Pirate app'), findsOneWidget); + expect(find.text('0.1.2'), findsNothing); + expect(find.byWidget(logo), findsNothing); + expect(find.text('I am the very model of a modern major general.'), findsNothing); + expect(find.text('About box'), findsNothing); + + await tester.tap(find.text('About Pirate app')); + await tester.pumpAndSettle(); + + expect(find.text('About Pirate app'), findsOneWidget); + expect(find.text('0.1.2'), findsOneWidget); + expect(find.byWidget(logo), findsOneWidget); + expect(find.text('I am the very model of a modern major general.'), findsOneWidget); + expect(find.text('About box'), findsOneWidget); + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['Pirate package '], 'Pirate license'), + ]); + }); + + await tester.tap(find.text('View licenses')); + await tester.pumpAndSettle(); + + expect(find.text('Pirate app'), findsOneWidget); + expect(find.text('0.1.2'), findsOneWidget); + expect(find.byWidget(logo), findsOneWidget); + expect(find.text('I am the very model of a modern major general.'), findsOneWidget); + await tester.tap(find.text('Pirate package ')); + await tester.pumpAndSettle(); + expect(find.text('Pirate license'), findsOneWidget); + }); + + testWidgets('About box logic defaults to executable name for app name', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + title: 'flutter_tester', + home: Material(child: AboutListTile()), + ), + ); + expect(find.text('About flutter_tester'), findsOneWidget); + }); + + testWidgets('LicensePage control test', (WidgetTester tester) async { + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['Another package'], 'Another license'), + ]); + }); + + await tester.pumpWidget(const MaterialApp(home: Center(child: LicensePage()))); + + expect(find.text('AAA'), findsNothing); + expect(find.text('BBB'), findsNothing); + expect(find.text('Another package'), findsNothing); + expect(find.text('Another license'), findsNothing); + + await tester.pumpAndSettle(); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + expect(find.text('Another package'), findsOneWidget); + + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + expect(find.text('BBB'), findsOneWidget); + + /// Go back to list of packages. + await tester.pageBack(); + await tester.pumpAndSettle(); + + /// Check license is displayed after entering into license page for + /// 'Another package'. + await tester.tap(find.text('Another package')); + await tester.pumpAndSettle(); + expect(find.text('Another license'), findsOneWidget); + }); + + testWidgets('LicensePage control test with all properties', (WidgetTester tester) async { + const logo = FlutterLogo(); + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['Another package'], 'Another license'), + ]); + }); + + await tester.pumpWidget( + const MaterialApp( + title: 'Pirate app', + home: Center( + child: LicensePage( + applicationName: 'LicensePage test app', + applicationVersion: '0.1.2', + applicationIcon: logo, + applicationLegalese: 'I am the very model of a modern major general.', + ), + ), + ), + ); + + expect(find.text('Pirate app'), findsNothing); + expect(find.text('LicensePage test app'), findsOneWidget); + expect(find.text('0.1.2'), findsOneWidget); + expect(find.byWidget(logo), findsOneWidget); + expect(find.text('I am the very model of a modern major general.'), findsOneWidget); + expect(find.text('AAA'), findsNothing); + expect(find.text('BBB'), findsNothing); + expect(find.text('Another package'), findsNothing); + expect(find.text('Another license'), findsNothing); + + await tester.pumpAndSettle(); + + expect(find.text('Pirate app'), findsNothing); + expect(find.text('LicensePage test app'), findsOneWidget); + expect(find.text('0.1.2'), findsOneWidget); + expect(find.byWidget(logo), findsOneWidget); + expect(find.text('I am the very model of a modern major general.'), findsOneWidget); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + expect(find.text('Another package'), findsOneWidget); + + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + expect(find.text('BBB'), findsOneWidget); + + /// Go back to list of packages. + await tester.pageBack(); + await tester.pumpAndSettle(); + + /// Check license is displayed after entering into license page for + /// 'Another package'. + await tester.tap(find.text('Another package')); + await tester.pumpAndSettle(); + expect(find.text('Another license'), findsOneWidget); + }); + + testWidgets('_PackagesView includes safe area padding', (WidgetTester tester) async { + const safeAreaBottom = 34.0; + const safeAreaLeft = 20.0; + const safeAreaRight = 12.0; + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData( + padding: EdgeInsets.only( + bottom: safeAreaBottom, + left: safeAreaLeft, + right: safeAreaRight, + ), + ), + child: Center(child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); + + final ListView listView = tester.widget<ListView>(find.byType(ListView)); + final listPadding = listView.padding! as EdgeInsets; + + expect(listPadding.bottom, safeAreaBottom); + expect(listPadding.left, safeAreaLeft); + expect(listPadding.right, safeAreaRight); + }); + + testWidgets('_PackageLicensePage includes safe area padding', (WidgetTester tester) async { + const safeAreaBottom = 34.0; + const safeAreaLeft = 20.0; + const safeAreaRight = 12.0; + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData( + padding: EdgeInsets.only( + bottom: safeAreaBottom, + left: safeAreaLeft, + right: safeAreaRight, + ), + ), + child: Center(child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Navigate to the package license page. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + + // Find the ListView that displays license entries and verify its padding + // includes the bottom safe area inset. + final ListView listView = tester.widget<ListView>(find.byType(ListView)); + final listPadding = listView.padding! as EdgeInsets; + + // The bottom padding should include both the gutter size and the bottom + // safe area padding from MediaQuery. + final expectedGutter = MediaQuery.widthOf(tester.element(find.byType(ListView))) >= 720 + ? 24.0 + : 12.0; + expect(listPadding.bottom, expectedGutter + safeAreaBottom); + expect(listPadding.left, expectedGutter + safeAreaLeft); + expect(listPadding.right, expectedGutter + safeAreaRight); + }); + + testWidgets('Material2 - _PackageLicensePage title style without AppBarTheme', ( + WidgetTester tester, + ) async { + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + const titleTextStyle = TextStyle(fontSize: 20, color: Colors.black, inherit: false); + const subtitleTextStyle = TextStyle(fontSize: 15, color: Colors.red, inherit: false); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + primaryTextTheme: const TextTheme( + titleLarge: titleTextStyle, + titleSmall: subtitleTextStyle, + ), + ), + home: const Center(child: LicensePage()), + ), + ); + await tester.pumpAndSettle(); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + + // Check for titles style. + final Text title = tester.widget(find.text('AAA')); + expect(title.style, titleTextStyle); + final Text subtitle = tester.widget(find.text('1 license.')); + expect(subtitle.style, subtitleTextStyle); + }); + + testWidgets('Material3 - _PackageLicensePage title style without AppBarTheme', ( + WidgetTester tester, + ) async { + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + const titleTextStyle = TextStyle(fontSize: 20, color: Colors.black, inherit: false); + const subtitleTextStyle = TextStyle(fontSize: 15, color: Colors.red, inherit: false); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textTheme: const TextTheme(titleLarge: titleTextStyle, titleSmall: subtitleTextStyle), + ), + home: const Center(child: LicensePage()), + ), + ); + await tester.pumpAndSettle(); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + + // Check for titles style. + final Text title = tester.widget(find.text('AAA')); + expect(title.style, titleTextStyle); + final Text subtitle = tester.widget(find.text('1 license.')); + expect(subtitle.style, subtitleTextStyle); + }); + + testWidgets('_PackageLicensePage title style with AppBarTheme', (WidgetTester tester) async { + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + const titleTextStyle = TextStyle(fontSize: 20, color: Colors.indigo); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + // Not used because appBarTheme is prioritized. + primaryTextTheme: const TextTheme( + titleLarge: TextStyle(fontSize: 12, color: Colors.grey), + titleSmall: TextStyle(fontSize: 10, color: Colors.grey), + ), + appBarTheme: const AppBarTheme( + titleTextStyle: titleTextStyle, + foregroundColor: Colors.indigo, + ), + ), + home: const Center(child: LicensePage()), + ), + ); + await tester.pumpAndSettle(); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + + // Check for titles style. + final Text title = tester.widget(find.text('AAA')); + expect(title.style, titleTextStyle); + }); + + testWidgets('Material2 - LicensePage respects the notch', (WidgetTester tester) async { + const safeareaPadding = 27.0; + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const MediaQuery( + data: MediaQueryData(padding: EdgeInsets.all(safeareaPadding)), + child: LicensePage(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // The position of the top left of app bar title should indicate whether + // the safe area is sufficiently respected. + expect( + tester.getTopLeft(find.text('Licenses')), + const Offset(16.0 + safeareaPadding, 18.0 + safeareaPadding), + ); + }); + + testWidgets('Material3 - LicensePage respects the notch', (WidgetTester tester) async { + const safeareaPadding = 27.0; + + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData(padding: EdgeInsets.all(safeareaPadding)), + child: LicensePage(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // The position of the top left of app bar title should indicate whether + // the safe area is sufficiently respected. + expect( + tester.getTopLeft(find.text('Licenses')), + const Offset(16.0 + safeareaPadding, 14.0 + safeareaPadding), + ); + }); + + testWidgets('LicensePage returns early if unmounted', (WidgetTester tester) async { + final licenseCompleter = Completer<LicenseEntry>(); + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromFuture(licenseCompleter.future); + }); + + await tester.pumpWidget(const MaterialApp(home: LicensePage())); + await tester.pump(); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + await tester.pumpAndSettle(); + final licenseEntry = FakeLicenseEntry(); + licenseCompleter.complete(licenseEntry); + expect(licenseEntry.packagesCalled, false); + }); + + testWidgets('LicensePage returns late if unmounted', (WidgetTester tester) async { + final licenseCompleter = Completer<LicenseEntry>(); + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromFuture(licenseCompleter.future); + }); + + await tester.pumpWidget(const MaterialApp(home: LicensePage())); + await tester.pump(); + final licenseEntry = FakeLicenseEntry(); + licenseCompleter.complete(licenseEntry); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + await tester.pumpAndSettle(); + expect(licenseEntry.packagesCalled, true); + }); + + testWidgets('LicensePage logic defaults to executable name for app name', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + title: 'flutter_tester', + home: Material(child: LicensePage()), + ), + ); + expect(find.text('flutter_tester'), findsOneWidget); + }); + + testWidgets('AboutListTile dense property is applied', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: AboutListTile())), + ), + ); + Rect tileRect = tester.getRect(find.byType(AboutListTile)); + expect(tileRect.height, 56.0); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: AboutListTile(dense: false))), + ), + ); + tileRect = tester.getRect(find.byType(AboutListTile)); + expect(tileRect.height, 56.0); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: AboutListTile(dense: true))), + ), + ); + tileRect = tester.getRect(find.byType(AboutListTile)); + expect(tileRect.height, 48.0); + }); + + testWidgets('showLicensePage uses nested navigator by default', (WidgetTester tester) async { + final rootObserver = LicensePageObserver(); + final nestedObserver = LicensePageObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + initialRoute: '/', + onGenerateRoute: (_) { + return PageRouteBuilder<dynamic>( + pageBuilder: (_, _, _) => Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, _, _) { + return ElevatedButton( + onPressed: () { + showLicensePage(context: context, applicationName: 'A'); + }, + child: const Text('Show License Page'), + ); + }, + ); + }, + ), + ); + }, + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.licensePageCount, 0); + expect(nestedObserver.licensePageCount, 1); + }); + + testWidgets('showLicensePage uses root navigator if useRootNavigator is true', ( + WidgetTester tester, + ) async { + final rootObserver = LicensePageObserver(); + final nestedObserver = LicensePageObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + initialRoute: '/', + onGenerateRoute: (_) { + return PageRouteBuilder<dynamic>( + pageBuilder: (_, _, _) => Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder<dynamic>( + pageBuilder: (BuildContext context, _, _) { + return ElevatedButton( + onPressed: () { + showLicensePage( + context: context, + useRootNavigator: true, + applicationName: 'A', + ); + }, + child: const Text('Show License Page'), + ); + }, + ); + }, + ), + ); + }, + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.licensePageCount, 1); + expect(nestedObserver.licensePageCount, 0); + }); + + group('Barrier dismissible', () { + late AboutDialogObserver rootObserver; + + setUp(() { + rootObserver = AboutDialogObserver(); + }); + + testWidgets('Barrier is dismissible with default parameter', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showAboutDialog(context: context), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(rootObserver.dialogCount, 1); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + expect(rootObserver.dialogCount, 0); + }); + + testWidgets('Barrier is not dismissible with barrierDismissible is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showAboutDialog(context: context, barrierDismissible: false), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(rootObserver.dialogCount, 1); + + // Tap on the barrier, which shouldn't do anything this time. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + expect(rootObserver.dialogCount, 1); + }); + }); + + testWidgets('Barrier color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showAboutDialog(context: context), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.black54); + + // Dismiss the dialog. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showAboutDialog(context: context, barrierColor: Colors.pink), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.pink); + }); + + testWidgets('Barrier Label', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showAboutDialog(context: context, barrierLabel: 'Custom Label'), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect( + tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).semanticsLabel, + 'Custom Label', + ); + }); + + testWidgets('showAboutDialog uses root navigator by default', (WidgetTester tester) async { + final rootObserver = AboutDialogObserver(); + final nestedObserver = AboutDialogObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showAboutDialog(context: context, applicationName: 'A'); + }, + child: const Text('Show About Dialog'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.dialogCount, 1); + expect(nestedObserver.dialogCount, 0); + }); + + testWidgets('showAboutDialog uses nested navigator if useRootNavigator is false', ( + WidgetTester tester, + ) async { + final rootObserver = AboutDialogObserver(); + final nestedObserver = AboutDialogObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showAboutDialog( + context: context, + useRootNavigator: false, + applicationName: 'A', + ); + }, + child: const Text('Show About Dialog'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.dialogCount, 0); + expect(nestedObserver.dialogCount, 1); + }); + + group('showAboutDialog avoids overlapping display features', () { + testWidgets('default positioning', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: Builder( + builder: (BuildContext context) => ElevatedButton( + onPressed: () { + showAboutDialog(context: context, useRootNavigator: false, applicationName: 'A'); + }, + child: const Text('Show About Dialog'), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(AboutDialog)), Offset.zero); + expect(tester.getBottomRight(find.byType(AboutDialog)), const Offset(390.0, 600.0)); + }); + + testWidgets('positioning using anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: Builder( + builder: (BuildContext context) => ElevatedButton( + onPressed: () { + showAboutDialog( + context: context, + useRootNavigator: false, + applicationName: 'A', + anchorPoint: const Offset(1000, 0), + ); + }, + child: const Text('Show About Dialog'), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + // The anchorPoint hits the right side of the display + expect(tester.getTopLeft(find.byType(AboutDialog)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(AboutDialog)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning using Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: Builder( + builder: (BuildContext context) => ElevatedButton( + onPressed: () { + showAboutDialog(context: context, useRootNavigator: false, applicationName: 'A'); + }, + child: const Text('Show About Dialog'), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + // Since this is rtl, the first screen is the on the right + expect(tester.getTopLeft(find.byType(AboutDialog)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(AboutDialog)), const Offset(800.0, 600.0)); + }); + }); + + testWidgets("AboutListTile's child should not be offset when the icon is not specified.", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: AboutListTile(child: Text('About'))), + ), + ); + + expect( + find.descendant(of: find.byType(AboutListTile), matching: find.byType(Icon)), + findsNothing, + ); + }); + + testWidgets("AboutDialog's contents are scrollable", (WidgetTester tester) async { + final Key contentKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showAboutDialog( + context: context, + useRootNavigator: false, + applicationName: 'A', + children: <Widget>[ + Container(key: contentKey, color: Colors.orange, height: 500), + ], + ); + }, + child: const Text('Show About Dialog'), + ); + }, + ); + }, + ), + ), + ); + + await tester.tap(find.text('Show About Dialog')); + await tester.pumpAndSettle(); + + // Try dragging by the [AboutDialog]'s title. + RenderBox box = tester.renderObject(find.text('A')); + Offset originalOffset = box.localToGlobal(Offset.zero); + await tester.drag(find.byKey(contentKey), const Offset(0.0, -20.0)); + + expect(box.localToGlobal(Offset.zero), equals(originalOffset.translate(0.0, -20.0))); + + // Try dragging by the additional children in contents. + box = tester.renderObject(find.byKey(contentKey)); + originalOffset = box.localToGlobal(Offset.zero); + await tester.drag(find.byKey(contentKey), const Offset(0.0, -20.0)); + + expect(box.localToGlobal(Offset.zero), equals(originalOffset.translate(0.0, -20.0))); + }); + + testWidgets("Material2 - LicensePage's color must be same whether loading or done", ( + WidgetTester tester, + ) async { + const scaffoldColor = Color(0xFF123456); + const cardColor = Color(0xFF654321); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light( + useMaterial3: false, + ).copyWith(scaffoldBackgroundColor: scaffoldColor, cardColor: cardColor), + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) => GestureDetector( + child: const Text('Show licenses'), + onTap: () { + showLicensePage( + context: context, + applicationName: 'MyApp', + applicationVersion: '1.0.0', + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show licenses')); + await tester.pump(); + await tester.pump(); + + // Check color when loading. + final List<Material> materialLoadings = tester + .widgetList<Material>(find.byType(Material)) + .toList(); + expect(materialLoadings.length, equals(4)); + expect(materialLoadings[1].color, scaffoldColor); + expect(materialLoadings[2].color, cardColor); + + await tester.pumpAndSettle(); + + // Check color when done. + expect(find.byKey(const ValueKey<ConnectionState>(ConnectionState.done)), findsOneWidget); + final List<Material> materialDones = tester + .widgetList<Material>(find.byType(Material)) + .toList(); + expect(materialDones.length, equals(3)); + expect(materialDones[0].color, scaffoldColor); + expect(materialDones[1].color, cardColor); + }); + + testWidgets("Material3 - LicensePage's color must be same whether loading or done", ( + WidgetTester tester, + ) async { + const scaffoldColor = Color(0xFF123456); + const cardColor = Color(0xFF654321); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scaffoldBackgroundColor: scaffoldColor, cardColor: cardColor), + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) => GestureDetector( + child: const Text('Show licenses'), + onTap: () { + showLicensePage( + context: context, + applicationName: 'MyApp', + applicationVersion: '1.0.0', + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show licenses')); + await tester.pump(); + await tester.pump(); + + // Check color when loading. + final List<Material> materialLoadings = tester + .widgetList<Material>(find.byType(Material)) + .toList(); + expect(materialLoadings.length, equals(5)); + expect(materialLoadings[1].color, scaffoldColor); + expect(materialLoadings[2].color, cardColor); + + await tester.pumpAndSettle(); + + // Check color when done. + expect(find.byKey(const ValueKey<ConnectionState>(ConnectionState.done)), findsOneWidget); + final List<Material> materialDones = tester + .widgetList<Material>(find.byType(Material)) + .toList(); + expect(materialDones.length, equals(4)); + expect(materialDones[0].color, scaffoldColor); + expect(materialDones[1].color, cardColor); + }); + + testWidgets( + 'Conflicting scrollbars are not applied by ScrollBehavior to _PackageLicensePage', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/83819 + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['AAA'], 'BBB'), + ]); + }); + + await tester.pumpWidget(const MaterialApp(home: Center(child: LicensePage()))); + await tester.pumpAndSettle(); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + + // The inherited ScrollBehavior should not apply Scrollbars since they are + // already built in to the widget. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(find.byType(CupertinoScrollbar), findsNothing); + case TargetPlatform.iOS: + expect(find.byType(CupertinoScrollbar), findsOneWidget); + case null: + break; + } + expect(find.byType(Scrollbar), findsOneWidget); + expect(find.byType(RawScrollbar), findsNothing); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('ListView of license entries is primary', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/120710 + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + LicenseEntryWithLineBreaks( + <String>['AAA'], + // Add enough content to scroll + List<String>.generate(500, (int index) => 'BBBB').join('\n'), + ), + ]); + }); + + await tester.pumpWidget( + MaterialApp( + title: 'Flutter Code Sample', + home: Scaffold( + body: Builder( + builder: (BuildContext context) => TextButton( + child: const Text('Show License Page'), + onPressed: () { + showLicensePage(context: context); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Show License Page'), findsOneWidget); + await tester.tap(find.text('Show License Page')); + await tester.pumpAndSettle(); + + // Check for packages. + expect(find.text('AAA'), findsOneWidget); + // Check license is displayed after entering into license page for 'AAA'. + await tester.tap(find.text('AAA')); + await tester.pumpAndSettle(); + + // The inherited ScrollBehavior should not apply Scrollbars since they are + // already built in to the widget. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(find.byType(CupertinoScrollbar), findsNothing); + case TargetPlatform.iOS: + expect(find.byType(CupertinoScrollbar), findsOneWidget); + case null: + break; + } + expect(find.byType(Scrollbar), findsOneWidget); + expect(find.byType(RawScrollbar), findsNothing); + await tester.drag(find.byType(ListView), const Offset(0.0, 20.0)); + await tester.pumpAndSettle(); // No exception triggered. + }, variant: TargetPlatformVariant.all()); + + testWidgets('LicensePage padding', (WidgetTester tester) async { + const logo = FlutterLogo(); + + await tester.pumpWidget( + const MaterialApp( + title: 'Pirate app', + home: Center( + child: LicensePage( + applicationName: 'LicensePage test app', + applicationIcon: logo, + applicationVersion: '0.1.2', + applicationLegalese: 'I am the very model of a modern major general.', + ), + ), + ), + ); + + final Finder appName = find.text('LicensePage test app'); + final Finder appIcon = find.byType(FlutterLogo); + final Finder appVersion = find.text('0.1.2'); + final Finder appLegalese = find.text('I am the very model of a modern major general.'); + final Finder appPowered = find.text('Powered by Flutter'); + + expect(appName, findsOneWidget); + expect(appIcon, findsOneWidget); + expect(appVersion, findsOneWidget); + expect(appLegalese, findsOneWidget); + expect(appPowered, findsOneWidget); + + // Bottom padding is applied to the app version and app legalese text. + final double appNameBottomPadding = + tester.getTopLeft(appIcon).dy - tester.getBottomLeft(appName).dy; + expect(appNameBottomPadding, 0.0); + + final double appIconBottomPadding = + tester.getTopLeft(appVersion).dy - tester.getBottomLeft(appIcon).dy; + expect(appIconBottomPadding, 0.0); + + final double appVersionBottomPadding = + tester.getTopLeft(appLegalese).dy - tester.getBottomLeft(appVersion).dy; + expect(appVersionBottomPadding, 18.0); + + final double appLegaleseBottomPadding = + tester.getTopLeft(appPowered).dy - tester.getBottomLeft(appLegalese).dy; + expect(appLegaleseBottomPadding, 18.0); + }); + + testWidgets('LicensePage has no extra padding between app icon and app powered text', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/99559 + + const logo = FlutterLogo(); + + await tester.pumpWidget( + const MaterialApp( + title: 'Pirate app', + home: Center(child: LicensePage(applicationIcon: logo)), + ), + ); + + final Finder appName = find.text('LicensePage test app'); + final Finder appIcon = find.byType(FlutterLogo); + final Finder appVersion = find.text('0.1.2'); + final Finder appLegalese = find.text('I am the very model of a modern major general.'); + final Finder appPowered = find.text('Powered by Flutter'); + + expect(appName, findsNothing); + expect(appIcon, findsOneWidget); + expect(appVersion, findsNothing); + expect(appLegalese, findsNothing); + expect(appPowered, findsOneWidget); + + // Padding between app icon and app powered text. + final double appIconBottomPadding = + tester.getTopLeft(appPowered).dy - tester.getBottomLeft(appIcon).dy; + expect(appIconBottomPadding, 18.0); + }); + + testWidgets('Material2 - Error handling test', (WidgetTester tester) async { + LicenseRegistry.addLicense(() => Stream<LicenseEntry>.error(Exception('Injected failure'))); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material(child: AboutListTile()), + ), + ); + await tester.tap(find.byType(ListTile)); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + await tester.tap(find.text('VIEW LICENSES')); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + final Finder finder = find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_PackagesView', + ); + // force the stream to complete (has to be done in a runAsync block since it's areal async process) + await tester.runAsync(() => (tester.firstState(finder) as dynamic).licenses as Future<dynamic>); + expect(tester.takeException().toString(), 'Exception: Injected failure'); + await tester.pumpAndSettle(); + expect(tester.takeException().toString(), 'Exception: Injected failure'); + expect(find.text('Exception: Injected failure'), findsOneWidget); + }); + + testWidgets('Material3 - Error handling test', (WidgetTester tester) async { + LicenseRegistry.addLicense(() => Stream<LicenseEntry>.error(Exception('Injected failure'))); + await tester.pumpWidget(const MaterialApp(home: Material(child: AboutListTile()))); + await tester.tap(find.byType(ListTile)); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + await tester.tap(find.text('View licenses')); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + final Finder finder = find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_PackagesView', + ); + // force the stream to complete (has to be done in a runAsync block since it's areal async process) + await tester.runAsync(() => (tester.firstState(finder) as dynamic).licenses as Future<dynamic>); + expect(tester.takeException().toString(), 'Exception: Injected failure'); + await tester.pumpAndSettle(); + expect(tester.takeException().toString(), 'Exception: Injected failure'); + expect(find.text('Exception: Injected failure'), findsOneWidget); + }); + + testWidgets('Material2 - LicensePage master view layout position - ltr', ( + WidgetTester tester, + ) async { + const TextDirection textDirection = TextDirection.ltr; + const defaultSize = Size(800.0, 600.0); + const wideSize = Size(1200.0, 600.0); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + // Configure to show the default layout. + await tester.binding.setSurfaceSize(defaultSize); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + title: title, + home: const Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is less than 840.0 pixels, nested layout is + // used which positions license page title at the top center. + Offset titleOffset = tester.getCenter(find.text(title)); + expect(titleOffset, Offset(defaultSize.width / 2, 92.0)); + expect(tester.getCenter(find.byType(ListView)), Offset(defaultSize.width / 2, 328.0)); + + // Configure a wide window to show the lateral UI. + await tester.binding.setSurfaceSize(wideSize); + + await tester.pumpWidget( + const MaterialApp( + title: title, + home: Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is greater than 840.0 pixels, lateral UI layout + // is used which positions license page title and packageList + // at the top left. + titleOffset = tester.getTopRight(find.text(title)); + expect(titleOffset, const Offset(292.0, 136.0)); + expect(titleOffset.dx, lessThan(wideSize.width - 320)); // Default master view width is 320.0. + expect(tester.getCenter(find.byType(ListView)), const Offset(160, 356)); + }); + + testWidgets('Material3 - LicensePage master view layout position - ltr', ( + WidgetTester tester, + ) async { + const TextDirection textDirection = TextDirection.ltr; + const defaultSize = Size(800.0, 600.0); + const wideSize = Size(1200.0, 600.0); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + // Configure to show the default layout. + await tester.binding.setSurfaceSize(defaultSize); + + await tester.pumpWidget( + const MaterialApp( + title: title, + home: Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is less than 840.0 pixels, nested layout is + // used which positions license page title at the top center. + Offset titleOffset = tester.getCenter(find.text(title)); + expect(titleOffset, Offset(defaultSize.width / 2, 96.0)); + expect(tester.getCenter(find.byType(ListView)), Offset(defaultSize.width / 2, 328.0)); + + // Configure a wide window to show the lateral UI. + await tester.binding.setSurfaceSize(wideSize); + + await tester.pumpWidget( + const MaterialApp( + title: title, + home: Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is greater than 840.0 pixels, lateral UI layout + // is used which positions license page title and packageList + // at the top left. + titleOffset = tester.getTopRight(find.text(title)); + expect(titleOffset, const Offset(292.0, 136.0)); + expect(titleOffset.dx, lessThan(wideSize.width - 320)); // Default master view width is 320.0. + expect(tester.getCenter(find.byType(ListView)), const Offset(160, 356)); + }); + + testWidgets('Material2 - LicensePage master view layout position - rtl', ( + WidgetTester tester, + ) async { + const TextDirection textDirection = TextDirection.rtl; + const defaultSize = Size(800.0, 600.0); + const wideSize = Size(1200.0, 600.0); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + // Configure to show the default layout. + await tester.binding.setSurfaceSize(defaultSize); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + title: title, + home: const Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is less than 840.0 pixels, nested layout is + // used which positions license page title at the top center. + Offset titleOffset = tester.getCenter(find.text(title)); + expect(titleOffset, Offset(defaultSize.width / 2, 92.0)); + expect(tester.getCenter(find.byType(ListView)), Offset(defaultSize.width / 2, 328.0)); + + // Configure a wide window to show the lateral UI. + await tester.binding.setSurfaceSize(wideSize); + + await tester.pumpWidget( + const MaterialApp( + title: title, + home: Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is greater than 840.0 pixels, lateral UI layout + // is used which positions license page title and packageList + // at the top right. + titleOffset = tester.getTopLeft(find.text(title)); + expect(titleOffset, const Offset(908.0, 136.0)); + expect( + titleOffset.dx, + greaterThan(wideSize.width - 320), + ); // Default master view width is 320.0. + expect(tester.getCenter(find.byType(ListView)), const Offset(1040.0, 356.0)); + }); + + testWidgets('Material3 - LicensePage master view layout position - rtl', ( + WidgetTester tester, + ) async { + const TextDirection textDirection = TextDirection.rtl; + const defaultSize = Size(800.0, 600.0); + const wideSize = Size(1200.0, 600.0); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + // Configure to show the default layout. + await tester.binding.setSurfaceSize(defaultSize); + + await tester.pumpWidget( + const MaterialApp( + title: title, + home: Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is less than 840.0 pixels, nested layout is + // used which positions license page title at the top center. + Offset titleOffset = tester.getCenter(find.text(title)); + expect(titleOffset, Offset(defaultSize.width / 2, 96.0)); + expect(tester.getCenter(find.byType(ListView)), Offset(defaultSize.width / 2, 328.0)); + + // Configure a wide window to show the lateral UI. + await tester.binding.setSurfaceSize(wideSize); + + await tester.pumpWidget( + const MaterialApp( + title: title, + home: Scaffold( + body: Directionality(textDirection: textDirection, child: LicensePage()), + ), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // If the layout width is greater than 840.0 pixels, lateral UI layout + // is used which positions license page title and packageList + // at the top right. + titleOffset = tester.getTopLeft(find.text(title)); + expect(titleOffset, const Offset(908.0, 136.0)); + expect( + titleOffset.dx, + greaterThan(wideSize.width - 320), + ); // Default master view width is 320.0. + expect(tester.getCenter(find.byType(ListView)), const Offset(1040.0, 356.0)); + }); + + testWidgets('License page title in lateral UI does not use AppBarTheme.foregroundColor', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/108991 + final theme = ThemeData(appBarTheme: const AppBarTheme(foregroundColor: Color(0xFFFFFFFF))); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + // Configure a wide window to show the lateral UI. + await tester.binding.setSurfaceSize(const Size(1200.0, 600.0)); + + await tester.pumpWidget( + MaterialApp( + title: title, + theme: theme, + home: const Scaffold(body: LicensePage()), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + final renderParagraph = tester.renderObject(find.text('ABC').last) as RenderParagraph; + + // License page title should not use AppBarTheme's foregroundColor. + expect(renderParagraph.text.style!.color, isNot(theme.appBarTheme.foregroundColor)); + + // License page title in the lateral UI uses default text style color. + expect(renderParagraph.text.style!.color, theme.textTheme.titleLarge!.color); + }); + + testWidgets('License page default title text color in the nested UI', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/108991 + final theme = ThemeData(); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + await tester.pumpWidget( + MaterialApp( + title: title, + theme: theme, + home: const Scaffold(body: LicensePage()), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // Currently in the master view. + expect(find.text('License ABC'), findsOneWidget); + + // Navigate to the license page. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + + // Master view is no longer visible. + expect(find.text('License ABC'), findsNothing); + + final renderParagraph = tester.renderObject(find.text('ABC').first) as RenderParagraph; + expect(renderParagraph.text.style!.color, theme.textTheme.titleLarge!.color); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('License page default title text color in the nested UI', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/108991 + final theme = ThemeData(useMaterial3: false); + const title = 'License ABC'; + LicenseRegistry.addLicense(() { + return Stream<LicenseEntry>.fromIterable(<LicenseEntry>[ + const LicenseEntryWithLineBreaks(<String>['ABC'], 'DEF'), + ]); + }); + + await tester.pumpWidget( + MaterialApp( + title: title, + theme: theme, + home: const Scaffold(body: LicensePage()), + ), + ); + + await tester.pumpAndSettle(); // Finish rendering the page. + + // Currently in the master view. + expect(find.text('License ABC'), findsOneWidget); + + // Navigate to the license page. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + + // Master view is no longer visible. + expect(find.text('License ABC'), findsNothing); + + final renderParagraph = tester.renderObject(find.text('ABC').first) as RenderParagraph; + expect(renderParagraph.text.style!.color, theme.primaryTextTheme.titleLarge!.color); + }); + }); + + testWidgets('Adaptive AboutDialog shows correct widget on each platform', ( + WidgetTester tester, + ) async { + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + showAdaptiveAboutDialog( + context: context, + applicationIcon: const Icon(Icons.abc), + applicationName: 'Test', + applicationVersion: '1.0.0', + applicationLegalese: 'Application Legalese', + children: <Widget>[const Text('Test1')], + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(CupertinoDialogAction), findsWidgets); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + showAboutDialog( + context: context, + applicationIcon: const Icon(Icons.abc), + applicationName: 'Test', + applicationVersion: '1.0.0', + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(CupertinoDialogAction), findsNothing); + } + }); + + testWidgets('Adaptive AboutDialog closes correctly on each platform', ( + WidgetTester tester, + ) async { + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + showAdaptiveAboutDialog( + context: context, + applicationName: 'Test', + applicationVersion: '1.0.0', + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(CupertinoDialogAction), findsWidgets); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoAlertDialog), findsNothing); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + showAdaptiveAboutDialog( + context: context, + applicationName: 'Test', + applicationVersion: '1.0.0', + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(TextButton), findsWidgets); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + } + }); + + testWidgets('showLicensePage inherits ambient Theme', (WidgetTester tester) async { + final theme = ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0XFFFF0000))); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: theme, + child: Builder( + builder: (BuildContext context) => ElevatedButton( + onPressed: () { + showAboutDialog( + context: context, + applicationName: 'Sample Test', + applicationVersion: 'v1.0.0', // Version of the app + ); + }, + child: const Text('Show About Dialog'), + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('View licenses')); + await tester.pumpAndSettle(); + + final ThemeData licensePageTheme = Theme.of(tester.element(find.text('Powered by Flutter'))); + expect(theme.colorScheme.primary, licensePageTheme.colorScheme.primary); + }); + + testWidgets('AboutDialog renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: AboutDialog(children: <Widget>[Text('X')])), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); + + testWidgets('AboutListTile renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: AboutListTile(child: Text('X'))), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); + + testWidgets('LicensePage renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: LicensePage(applicationName: 'X')), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); +} + +class FakeLicenseEntry extends LicenseEntry { + FakeLicenseEntry(); + + bool get packagesCalled => _packagesCalled; + bool _packagesCalled = false; + + @override + Iterable<LicenseParagraph> paragraphs = <LicenseParagraph>[]; + + @override + Iterable<String> get packages { + _packagesCalled = true; + return <String>[]; + } +} + +class LicensePageObserver extends NavigatorObserver { + int licensePageCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is MaterialPageRoute<dynamic>) { + licensePageCount++; + } + super.didPush(route, previousRoute); + } +} + +class AboutDialogObserver extends NavigatorObserver { + int dialogCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + dialogCount++; + } + super.didPush(route, previousRoute); + } + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + dialogCount--; + } + super.didPop(route, previousRoute); + } +} diff --git a/packages/material_ui/test/material/action_chip_test.dart b/packages/material_ui/test/material/action_chip_test.dart new file mode 100644 index 000000000000..0d0c9bebbffc --- /dev/null +++ b/packages/material_ui/test/material/action_chip_test.dart @@ -0,0 +1,628 @@ +// 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:ui'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Adds the basic requirements for a Chip. +Widget wrapForChip({ + required Widget child, + TextDirection textDirection = TextDirection.ltr, + TextScaler textScaler = TextScaler.noScaling, + Brightness brightness = Brightness.light, +}) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material(child: child), + ), + ), + ); +} + +RenderBox getMaterialBox(WidgetTester tester, Finder type) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: type, matching: find.byType(CustomPaint)), + ); +} + +Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(ActionChip), matching: find.byType(Material)), + ); +} + +IconThemeData getIconData(WidgetTester tester) { + final IconTheme iconTheme = tester.firstWidget( + find.descendant(of: find.byType(RawChip), matching: find.byType(IconTheme)), + ); + return iconTheme.data; +} + +DefaultTextStyle getLabelStyle(WidgetTester tester, String labelText) { + return tester.widget( + find.ancestor(of: find.text(labelText), matching: find.byType(DefaultTextStyle)).first, + ); +} + +void checkChipMaterialClipBehavior(WidgetTester tester, Clip clipBehavior) { + final Iterable<Material> materials = tester.widgetList<Material>(find.byType(Material)); + // There should be two Material widgets, first Material is from the "_wrapForChip" and + // last Material is from the "RawChip". + expect(materials.length, 2); + // The last Material from `RawChip` should have the clip behavior. + expect(materials.last.clipBehavior, clipBehavior); +} + +void main() { + testWidgets('Material2 - ActionChip defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + const label = 'action chip'; + + // Test enabled ActionChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ActionChip(onPressed: () {}, label: const Text(label)), + ), + ), + ), + ); + + // Test default chip size. + expect(tester.getSize(find.byType(ActionChip)), const Size(178.0, 48.0)); + // Test default label style. + expect( + getLabelStyle(tester, label).style.color, + theme.textTheme.bodyLarge!.color!.withAlpha(0xde), + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x1f)); + + // Test disabled ActionChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ActionChip(label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black38); + }); + + testWidgets('Material3 - ActionChip defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const label = 'action chip'; + + // Test enabled ActionChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ActionChip(onPressed: () {}, label: const Text(label)), + ), + ), + ), + ); + + // Test default chip size. + expect( + tester.getSize(find.byType(ActionChip)), + within<Size>(distance: 0.01, from: const Size(189.1, 48.0)), + ); + // Test default label style. + expect(getLabelStyle(tester, label).style.color!.value, theme.colorScheme.onSurface.value); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.transparent); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, null); + + // Test disabled ActionChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ActionChip(label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.transparent); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: theme.colorScheme.onSurface.withOpacity(0.12)), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, null); + }); + + testWidgets('Material3 - ActionChip.elevated defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const label = 'action chip'; + + // Test enabled ActionChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ActionChip.elevated(onPressed: () {}, label: const Text(label)), + ), + ), + ), + ); + + // Test default chip size. + expect( + tester.getSize(find.byType(ActionChip)), + within<Size>(distance: 0.01, from: const Size(189.1, 48.0)), + ); + // Test default label style. + expect(getLabelStyle(tester, label).style.color!.value, theme.colorScheme.onSurface.value); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 1); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.surfaceContainerLow); + + // Test disabled ActionChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ActionChip.elevated(label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('ActionChip.color resolves material states', (WidgetTester tester) async { + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + final WidgetStateProperty<Color?> color = WidgetStateProperty.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + return backgroundColor; + }); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: Column( + children: <Widget>[ + ActionChip( + onPressed: enabled ? () {} : null, + color: color, + label: const Text('ActionChip'), + ), + ActionChip.elevated( + onPressed: enabled ? () {} : null, + color: color, + label: const Text('ActionChip.elevated'), + ), + ], + ), + ); + } + + // Test enabled state. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled ActionChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: backgroundColor), + ); + // Enabled elevated ActionChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: backgroundColor), + ); + + // Test disabled state. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled ActionChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: disabledColor)); + // Disabled elevated ActionChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: disabledColor)); + }); + + testWidgets('ActionChip uses provided state color properties', (WidgetTester tester) async { + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: Column( + children: <Widget>[ + ActionChip( + onPressed: enabled ? () {} : null, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + label: const Text('ActionChip'), + ), + ActionChip.elevated( + onPressed: enabled ? () {} : null, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + label: const Text('ActionChip.elevated'), + ), + ], + ), + ); + } + + // Test enabled state. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled ActionChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: backgroundColor), + ); + // Enabled elevated ActionChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: backgroundColor), + ); + + // Test disabled state. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled ActionChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: disabledColor)); + // Disabled elevated ActionChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: disabledColor)); + }); + + testWidgets('ActionChip can be tapped', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ActionChip(onPressed: () {}, label: const Text('action chip')), + ), + ), + ); + + await tester.tap(find.byType(ActionChip)); + expect(tester.takeException(), null); + }); + + testWidgets('ActionChip clipBehavior properly passes through to the Material', ( + WidgetTester tester, + ) async { + const label = Text('label'); + await tester.pumpWidget( + wrapForChip( + child: ActionChip(label: label, onPressed: () {}), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.none); + + await tester.pumpWidget( + wrapForChip( + child: ActionChip(label: label, clipBehavior: Clip.antiAlias, onPressed: () {}), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.antiAlias); + }); + + testWidgets('ActionChip uses provided iconTheme', (WidgetTester tester) async { + Widget buildChip({IconThemeData? iconTheme}) { + return MaterialApp( + home: Material( + child: ActionChip( + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + onPressed: () {}, + label: const Text('action chip'), + ), + ), + ); + } + + // Test default icon theme. + await tester.pumpWidget(buildChip()); + + expect(getIconData(tester).color, ThemeData().colorScheme.primary); + + // Test provided icon theme. + await tester.pumpWidget(buildChip(iconTheme: const IconThemeData(color: Color(0xff00ff00)))); + + expect(getIconData(tester).color, const Color(0xff00ff00)); + }); + + testWidgets('ActionChip avatar layout constraints can be customized', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? avatarBoxConstraints}) { + return wrapForChip( + child: Center( + child: ActionChip( + avatarBoxConstraints: avatarBoxConstraints, + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default avatar layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(ActionChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(ActionChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (labelSize.width / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (labelSize.width / 2) + labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget(buildChip(avatarBoxConstraints: const BoxConstraints.tightForFinite())); + await tester.pump(); + + expect(tester.getSize(find.byType(ActionChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(ActionChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('ActionChip.chipAnimationStyle is passed to RawChip', (WidgetTester tester) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: const AnimationStyle(duration: Durations.extralong4), + selectAnimation: AnimationStyle.noAnimation, + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: ActionChip( + chipAnimationStyle: chipAnimationStyle, + label: const Text('ActionChip'), + ), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('Elevated ActionChip.chipAnimationStyle is passed to RawChip', ( + WidgetTester tester, + ) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: const AnimationStyle(duration: Durations.extralong4), + selectAnimation: AnimationStyle.noAnimation, + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: ActionChip.elevated( + chipAnimationStyle: chipAnimationStyle, + label: const Text('ActionChip'), + ), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('ActionChip has expected default mouse cursor on hover', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: Center( + child: ActionChip(label: const Text('Chip'), onPressed: () {}), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('ActionChip mouse cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + wrapForChip( + child: const Center( + child: ActionChip(mouseCursor: customCursor, label: Text('Chip')), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('Mouse cursor resolves in focused/unfocused/disabled states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Chip'); + addTearDown(focusNode.dispose); + + Widget buildChip({required bool enabled}) { + return wrapForChip( + child: Center( + child: ActionChip( + mouseCursor: const WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + WidgetState.focused: SystemMouseCursors.grab, + WidgetState.any: SystemMouseCursors.basic, + }), + focusNode: focusNode, + label: const Text('Chip'), + onPressed: enabled ? () {} : null, + ), + ), + ); + } + + await tester.pumpWidget(buildChip(enabled: true)); + + // Unfocused case. + final TestGesture gesture1 = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture1.removePointer); + await gesture1.addPointer(location: tester.getCenter(find.text('Chip'))); + await tester.pump(); + await gesture1.moveTo(tester.getCenter(find.text('Chip'))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Focused case. + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Disabled case. + await tester.pumpWidget(buildChip(enabled: false)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('ActionChip renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: ActionChip(label: Text('X'))), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); +} diff --git a/packages/material_ui/test/material/action_icons_theme_test.dart b/packages/material_ui/test/material/action_icons_theme_test.dart new file mode 100644 index 000000000000..db0322a90217 --- /dev/null +++ b/packages/material_ui/test/material/action_icons_theme_test.dart @@ -0,0 +1,229 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ActionIconThemeData copyWith, ==, hashCode basics', () { + expect(const ActionIconThemeData(), const ActionIconThemeData().copyWith()); + expect(const ActionIconThemeData().hashCode, const ActionIconThemeData().copyWith().hashCode); + }); + + testWidgets('ActionIconThemeData copyWith overrides all properties', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/126762. + Widget originalButtonBuilder(BuildContext context) { + return const SizedBox(); + } + + Widget newButtonBuilder(BuildContext context) { + return const Icon(Icons.add); + } + + // Create a ActionIconThemeData with all properties set. + final original = ActionIconThemeData( + backButtonIconBuilder: originalButtonBuilder, + closeButtonIconBuilder: originalButtonBuilder, + drawerButtonIconBuilder: originalButtonBuilder, + endDrawerButtonIconBuilder: originalButtonBuilder, + ); + // Check if the all properties are copied. + final ActionIconThemeData copy = original.copyWith(); + expect(copy.backButtonIconBuilder, originalButtonBuilder); + expect(copy.closeButtonIconBuilder, originalButtonBuilder); + expect(copy.drawerButtonIconBuilder, originalButtonBuilder); + expect(copy.endDrawerButtonIconBuilder, originalButtonBuilder); + + // Check if the properties are overridden. + final ActionIconThemeData overridden = original.copyWith( + backButtonIconBuilder: newButtonBuilder, + closeButtonIconBuilder: newButtonBuilder, + drawerButtonIconBuilder: newButtonBuilder, + endDrawerButtonIconBuilder: newButtonBuilder, + ); + expect(overridden.backButtonIconBuilder, newButtonBuilder); + expect(overridden.closeButtonIconBuilder, newButtonBuilder); + expect(overridden.drawerButtonIconBuilder, newButtonBuilder); + expect(overridden.endDrawerButtonIconBuilder, newButtonBuilder); + }); + + test('ActionIconThemeData defaults', () { + const themeData = ActionIconThemeData(); + expect(themeData.backButtonIconBuilder, null); + expect(themeData.closeButtonIconBuilder, null); + expect(themeData.drawerButtonIconBuilder, null); + expect(themeData.endDrawerButtonIconBuilder, null); + }); + + testWidgets('Default ActionIconThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ActionIconThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ActionIconThemeData implements debugFillProperties', (WidgetTester tester) async { + Widget actionButtonIconBuilder(BuildContext context) { + return const Icon(IconData(0)); + } + + final builder = DiagnosticPropertiesBuilder(); + ActionIconThemeData( + backButtonIconBuilder: actionButtonIconBuilder, + closeButtonIconBuilder: actionButtonIconBuilder, + drawerButtonIconBuilder: actionButtonIconBuilder, + endDrawerButtonIconBuilder: actionButtonIconBuilder, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + final Matcher containsBuilderCallback = contains('Closure: (BuildContext) =>'); + expect(description, <dynamic>[ + allOf(startsWith('backButtonIconBuilder:'), containsBuilderCallback), + allOf(startsWith('closeButtonIconBuilder:'), containsBuilderCallback), + allOf(startsWith('drawerButtonIconBuilder:'), containsBuilderCallback), + allOf(startsWith('endDrawerButtonIconBuilder:'), containsBuilderCallback), + ]); + }); + + testWidgets('Action buttons use ThemeData action icon theme', (WidgetTester tester) async { + const green = Color(0xff00ff00); + const icon = IconData(0); + + Widget buildSampleIcon(BuildContext context) { + return const Icon(icon, size: 20, color: green); + } + + final actionIconTheme = ActionIconThemeData( + backButtonIconBuilder: buildSampleIcon, + closeButtonIconBuilder: buildSampleIcon, + drawerButtonIconBuilder: buildSampleIcon, + endDrawerButtonIconBuilder: buildSampleIcon, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(actionIconTheme: actionIconTheme), + home: const Material( + child: Column( + children: <Widget>[BackButton(), CloseButton(), DrawerButton(), EndDrawerButton()], + ), + ), + ), + ); + + final Icon backButtonIcon = tester.widget( + find.descendant(of: find.byType(BackButton), matching: find.byType(Icon)), + ); + final Icon closeButtonIcon = tester.widget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(Icon)), + ); + final Icon drawerButtonIcon = tester.widget( + find.descendant(of: find.byType(DrawerButton), matching: find.byType(Icon)), + ); + final Icon endDrawerButtonIcon = tester.widget( + find.descendant(of: find.byType(EndDrawerButton), matching: find.byType(Icon)), + ); + + expect(backButtonIcon.icon == icon, isTrue); + expect(closeButtonIcon.icon == icon, isTrue); + expect(drawerButtonIcon.icon == icon, isTrue); + expect(endDrawerButtonIcon.icon == icon, isTrue); + + final RichText backButtonIconText = tester.widget( + find.descendant(of: find.byType(BackButton), matching: find.byType(RichText)), + ); + final RichText closeButtonIconText = tester.widget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(RichText)), + ); + final RichText drawerButtonIconText = tester.widget( + find.descendant(of: find.byType(DrawerButton), matching: find.byType(RichText)), + ); + final RichText endDrawerButtonIconText = tester.widget( + find.descendant(of: find.byType(EndDrawerButton), matching: find.byType(RichText)), + ); + + expect(backButtonIconText.text.style!.color, green); + expect(closeButtonIconText.text.style!.color, green); + expect(drawerButtonIconText.text.style!.color, green); + expect(endDrawerButtonIconText.text.style!.color, green); + }); + + // This test is essentially the same as 'Action buttons use ThemeData action icon theme'. In + // this case the theme is introduced with the ActionIconTheme widget instead of + // ThemeData.actionIconTheme. + testWidgets('Action buttons use ActionIconTheme', (WidgetTester tester) async { + const green = Color(0xff00ff00); + const icon = IconData(0); + + Widget buildSampleIcon(BuildContext context) { + return const Icon(icon, size: 20, color: green); + } + + final actionIconTheme = ActionIconThemeData( + backButtonIconBuilder: buildSampleIcon, + closeButtonIconBuilder: buildSampleIcon, + drawerButtonIconBuilder: buildSampleIcon, + endDrawerButtonIconBuilder: buildSampleIcon, + ); + + await tester.pumpWidget( + MaterialApp( + home: ActionIconTheme( + data: actionIconTheme, + child: const Material( + child: Column( + children: <Widget>[BackButton(), CloseButton(), DrawerButton(), EndDrawerButton()], + ), + ), + ), + ), + ); + + final Icon backButtonIcon = tester.widget( + find.descendant(of: find.byType(BackButton), matching: find.byType(Icon)), + ); + final Icon closeButtonIcon = tester.widget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(Icon)), + ); + final Icon drawerButtonIcon = tester.widget( + find.descendant(of: find.byType(DrawerButton), matching: find.byType(Icon)), + ); + final Icon endDrawerButtonIcon = tester.widget( + find.descendant(of: find.byType(EndDrawerButton), matching: find.byType(Icon)), + ); + + expect(backButtonIcon.icon == icon, isTrue); + expect(closeButtonIcon.icon == icon, isTrue); + expect(drawerButtonIcon.icon == icon, isTrue); + expect(endDrawerButtonIcon.icon == icon, isTrue); + + final RichText backButtonIconText = tester.widget( + find.descendant(of: find.byType(BackButton), matching: find.byType(RichText)), + ); + final RichText closeButtonIconText = tester.widget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(RichText)), + ); + final RichText drawerButtonIconText = tester.widget( + find.descendant(of: find.byType(DrawerButton), matching: find.byType(RichText)), + ); + final RichText endDrawerButtonIconText = tester.widget( + find.descendant(of: find.byType(EndDrawerButton), matching: find.byType(RichText)), + ); + + expect(backButtonIconText.text.style!.color, green); + expect(closeButtonIconText.text.style!.color, green); + expect(drawerButtonIconText.text.style!.color, green); + expect(endDrawerButtonIconText.text.style!.color, green); + }); +} diff --git a/packages/material_ui/test/material/adaptive_text_selection_toolbar_test.dart b/packages/material_ui/test/material/adaptive_text_selection_toolbar_test.dart new file mode 100644 index 000000000000..94cc08b96f2f --- /dev/null +++ b/packages/material_ui/test/material/adaptive_text_selection_toolbar_test.dart @@ -0,0 +1,450 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/clipboard_utils.dart'; +import '../widgets/text_selection_toolbar_utils.dart'; +import 'editable_text_utils.dart'; +import 'live_text_utils.dart'; + +void main() { + final mockClipboard = MockClipboard(); + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + testWidgets( + 'Builds the right toolbar on each platform, including web, and shows buttonItems', + (WidgetTester tester) async { + const buttonText = 'Click me'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: AdaptiveTextSelectionToolbar.buttonItems( + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + buttonItems: <ContextMenuButtonItem>[ + ContextMenuButtonItem(label: buttonText, onPressed: () {}), + ], + ), + ), + ), + ), + ); + + expect(find.text(buttonText), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(find.byType(TextSelectionToolbar), findsOneWidget); + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + expect(find.byType(DesktopTextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing); + case TargetPlatform.iOS: + expect(find.byType(TextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget); + expect(find.byType(DesktopTextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing); + case TargetPlatform.macOS: + expect(find.byType(TextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + expect(find.byType(DesktopTextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(TextSelectionToolbar), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + expect(find.byType(DesktopTextSelectionToolbar), findsOneWidget); + expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing); + } + }, + variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382 + ); + + testWidgets('Can build children directly as well', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: AdaptiveTextSelectionToolbar( + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + children: <Widget>[Container(key: key)], + ), + ), + ), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets( + 'Can build from EditableTextState', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final controller = TextEditingController(); + final focusNode = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 400, + child: EditableText( + controller: controller, + backgroundCursorColor: const Color(0xff00ffff), + focusNode: focusNode, + style: const TextStyle(), + cursorColor: const Color(0xff00ffff), + selectionControls: materialTextSelectionHandleControls, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + key: key, + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(find.byKey(key), findsNothing); + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state<EditableTextState>(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + expect(find.byKey(key), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget); + case TargetPlatform.iOS: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget); + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget); + case TargetPlatform.macOS: + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget); + } + controller.dispose(); + focusNode.dispose(); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'Can build for editable text from raw parameters', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: AdaptiveTextSelectionToolbar.editable( + key: key, + anchors: const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + clipboardStatus: ClipboardStatus.pasteable, + onCopy: () {}, + onCut: () {}, + onPaste: () {}, + onSelectAll: () {}, + onLiveTextInput: () {}, + onLookUp: () {}, + onSearchWeb: () {}, + onShare: () {}, + ), + ), + ), + ), + ); + + expect(find.byKey(key), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(find.byType(TextSelectionToolbarTextButton), findsNWidgets(6)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect( + findMaterialOverflowNextButton(), + findsOneWidget, + ); // Material overflow buttons are not TextSelectionToolbarTextButton. + + await tapMaterialOverflowNextButton(tester); + + expect(find.byType(TextSelectionToolbarTextButton), findsNWidgets(2)); + expect(find.text('Search Web'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + expect( + findMaterialOverflowBackButton(), + findsOneWidget, + ); // Material overflow buttons are not TextSelectionToolbarTextButton. + + case TargetPlatform.iOS: + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(6)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(findCupertinoOverflowNextButton(), findsOneWidget); + + await tapCupertinoOverflowNextButton(tester); + + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4)); + expect(findCupertinoOverflowBackButton(), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share...'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + + case TargetPlatform.fuchsia: + expect(find.byType(TextSelectionToolbarTextButton), findsNWidgets(8)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(DesktopTextSelectionToolbarButton), findsNWidgets(8)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + + case TargetPlatform.macOS: + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(8)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select All'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share...'), findsOneWidget); + expect(findLiveTextButton(), findsOneWidget); + } + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'Builds empty toolbar when children and buttonItems are null', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: AdaptiveTextSelectionToolbar( + anchors: TextSelectionToolbarAnchors(primaryAnchor: Offset.zero), + children: null, + ), + ), + ), + ); + + expect(tester.getSize(find.byType(AdaptiveTextSelectionToolbar)), Size.zero); + expect(tester.takeException(), isNull); + }, + skip: isBrowser, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.all(), + ); + + group('buttonItems', () { + testWidgets( + 'getEditableTextButtonItems builds the correct button items per-platform', + (WidgetTester tester) async { + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + + var buttonTypes = <ContextMenuButtonType>{}; + final controller = TextEditingController(); + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: const TextStyle(), + cursorColor: Colors.red, + selectionControls: materialTextSelectionHandleControls, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + buttonTypes = editableTextState.contextMenuButtonItems + .map((ContextMenuButtonItem buttonItem) => buttonItem.type) + .toSet(); + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + + // With no text in the field. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(); + expect(state.showToolbar(), true); + await tester.pump(); + + expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut))); + expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy))); + expect(buttonTypes, contains(ContextMenuButtonType.paste)); + expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll))); + + // With text but no selection. + const text = 'lorem ipsum'; + controller.value = const TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + await tester.pump(); + + expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut))); + expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy))); + expect(buttonTypes, contains(ContextMenuButtonType.paste)); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + case TargetPlatform.macOS: + expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll))); + } + + // With text and selection. + controller.value = controller.value.copyWith( + selection: const TextSelection(baseOffset: 0, extentOffset: 'lorem'.length), + ); + await tester.pump(); + + expect(buttonTypes, contains(ContextMenuButtonType.cut)); + expect(buttonTypes, contains(ContextMenuButtonType.copy)); + expect(buttonTypes, contains(ContextMenuButtonType.paste)); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll))); + } + + focusNode.dispose(); + controller.dispose(); + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] + ); + + testWidgets( + 'getAdaptiveButtons builds the correct button widgets per-platform', + (WidgetTester tester) async { + const buttonText = 'Click me'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + final buttonItems = <ContextMenuButtonItem>[ + ContextMenuButtonItem(label: buttonText, onPressed: () {}), + ]; + return ListView( + children: AdaptiveTextSelectionToolbar.getAdaptiveButtons( + context, + buttonItems, + ).toList(), + ); + }, + ), + ), + ), + ), + ); + + expect(find.text(buttonText), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.fuchsia: + case TargetPlatform.android: + expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing); + expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing); + case TargetPlatform.iOS: + expect(find.byType(TextSelectionToolbarTextButton), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget); + expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing); + case TargetPlatform.macOS: + expect(find.byType(TextSelectionToolbarTextButton), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing); + expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing); + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget); + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(TextSelectionToolbarTextButton), findsNothing); + expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing); + expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget); + expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing); + } + }, + variant: TargetPlatformVariant.all(), + ); + }); +} diff --git a/packages/material_ui/test/material/animated_icons_test.dart b/packages/material_ui/test/material/animated_icons_test.dart new file mode 100644 index 000000000000..ee3b95746050 --- /dev/null +++ b/packages/material_ui/test/material/animated_icons_test.dart @@ -0,0 +1,332 @@ +// 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. + +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:math' as math show pi; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +class MockCanvas extends Fake implements Canvas { + late Path capturedPath; + late Paint capturedPaint; + + @override + void drawPath(Path path, Paint paint) { + capturedPath = path; + capturedPaint = paint; + } + + late double capturedSx; + late double capturedSy; + + @override + void scale(double sx, [double? sy]) { + capturedSx = sx; + capturedSy = sy!; + invocations.add(RecordedScale(sx, sy)); + } + + final List<RecordedCanvasCall> invocations = <RecordedCanvasCall>[]; + + @override + void rotate(double radians) { + invocations.add(RecordedRotate(radians)); + } + + @override + void translate(double dx, double dy) { + invocations.add(RecordedTranslate(dx, dy)); + } +} + +@immutable +abstract class RecordedCanvasCall { + const RecordedCanvasCall(); +} + +class RecordedRotate extends RecordedCanvasCall { + const RecordedRotate(this.radians); + + final double radians; + + @override + bool operator ==(Object other) { + return other is RecordedRotate && other.radians == radians; + } + + @override + int get hashCode => radians.hashCode; +} + +class RecordedTranslate extends RecordedCanvasCall { + const RecordedTranslate(this.dx, this.dy); + + final double dx; + final double dy; + + @override + bool operator ==(Object other) { + return other is RecordedTranslate && other.dx == dx && other.dy == dy; + } + + @override + int get hashCode => Object.hash(dx, dy); +} + +class RecordedScale extends RecordedCanvasCall { + const RecordedScale(this.sx, this.sy); + + final double sx; + final double sy; + + @override + bool operator ==(Object other) { + return other is RecordedScale && other.sx == sx && other.sy == sy; + } + + @override + int get hashCode => Object.hash(sx, sy); +} + +void main() { + testWidgets('IconTheme color', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666)), + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(48.0, 48.0)); + expect(canvas.capturedPaint, hasColor(0xFF666666)); + }); + + testWidgets('IconTheme opacity', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666), opacity: 0.5), + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(48.0, 48.0)); + expect(canvas.capturedPaint, hasColor(0x80666666)); + }); + + testWidgets('color overrides IconTheme color', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666)), + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + color: Color(0xFF0000FF), + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(48.0, 48.0)); + expect(canvas.capturedPaint, hasColor(0xFF0000FF)); + }); + + testWidgets('IconTheme size', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666), size: 12.0), + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(12.0, 12.0)); + // arrow_menu default size is 48x48 so we expect it to be scaled by 0.25. + expect(canvas.capturedSx, 0.25); + expect(canvas.capturedSy, 0.25); + }); + + testWidgets('size overridesIconTheme size', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666), size: 12.0), + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + size: 96.0, + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(12.0, 12.0)); + // arrow_menu default size is 48x48 so we expect it to be scaled by 2. + expect(canvas.capturedSx, 2); + expect(canvas.capturedSy, 2); + }); + + testWidgets('Semantic label', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + size: 96.0, + semanticLabel: 'a label', + ), + ), + ); + + expect(semantics, includesNodeWith(label: 'a label')); + + semantics.dispose(); + }); + + testWidgets('Inherited text direction rtl', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.rtl, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666)), + child: RepaintBoundary( + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + ), + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(48.0, 48.0)); + expect(canvas.invocations, const <RecordedCanvasCall>[ + RecordedRotate(math.pi), + RecordedTranslate(-48, -48), + RecordedScale(0.5, 0.5), + ]); + await expectLater( + find.byType(AnimatedIcon), + matchesGoldenFile('animated_icons_test.icon.rtl.png'), + ); + }); + + testWidgets('Inherited text direction ltr', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666)), + child: RepaintBoundary( + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + ), + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(48.0, 48.0)); + expect(canvas.invocations, const <RecordedCanvasCall>[RecordedScale(0.5, 0.5)]); + await expectLater( + find.byType(AnimatedIcon), + matchesGoldenFile('animated_icons_test.icon.ltr.png'), + ); + }); + + testWidgets('Inherited text direction overridden', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: IconTheme( + data: IconThemeData(color: Color(0xFF666666)), + child: AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + textDirection: TextDirection.rtl, + ), + ), + ), + ); + final CustomPaint customPaint = tester.widget(find.byType(CustomPaint)); + final canvas = MockCanvas(); + customPaint.painter!.paint(canvas, const Size(48.0, 48.0)); + expect(canvas.invocations, const <RecordedCanvasCall>[ + RecordedRotate(math.pi), + RecordedTranslate(-48, -48), + RecordedScale(0.5, 0.5), + ]); + }); + + testWidgets('Direction has no effect on position of widget', (WidgetTester tester) async { + const icon = AnimatedIcon( + progress: AlwaysStoppedAnimation<double>(0.0), + icon: AnimatedIcons.arrow_menu, + ); + await tester.pumpWidget(const Directionality(textDirection: TextDirection.rtl, child: icon)); + final Rect rtlRect = tester.getRect(find.byType(AnimatedIcon)); + await tester.pumpWidget(const Directionality(textDirection: TextDirection.ltr, child: icon)); + final Rect ltrRect = tester.getRect(find.byType(AnimatedIcon)); + expect(rtlRect, ltrRect); + }); +} + +PaintColorMatcher hasColor(int color, {double threshold = 1 / 255}) { + return PaintColorMatcher(color, threshold); +} + +class PaintColorMatcher extends Matcher { + const PaintColorMatcher(this.expectedColor, this.threshold); + + final int expectedColor; + final double threshold; + + @override + Description describe(Description description) => description.add('color was not $expectedColor'); + + @override + bool matches(dynamic item, Map<dynamic, dynamic> matchState) { + final actualPaint = item as Paint; + final expected = Color(expectedColor); + return actualPaint.color.colorSpace == expected.colorSpace && + (actualPaint.color.a - expected.a).abs() < threshold && + (actualPaint.color.r - expected.r).abs() < threshold && + (actualPaint.color.g - expected.g).abs() < threshold && + (actualPaint.color.b - expected.b).abs() < threshold; + } +} diff --git a/packages/material_ui/test/material/app_bar_sliver_test.dart b/packages/material_ui/test/material/app_bar_sliver_test.dart new file mode 100644 index 000000000000..b719953f23c6 --- /dev/null +++ b/packages/material_ui/test/material/app_bar_sliver_test.dart @@ -0,0 +1,2463 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; +import 'app_bar_utils.dart'; + +Widget buildSliverAppBarApp({ + bool floating = false, + bool pinned = false, + double? collapsedHeight, + double? expandedHeight, + bool snap = false, + double toolbarHeight = kToolbarHeight, +}) { + return MaterialApp( + home: Scaffold( + body: DefaultTabController( + length: 3, + child: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar( + title: const Text('AppBar Title'), + floating: floating, + pinned: pinned, + collapsedHeight: collapsedHeight, + expandedHeight: expandedHeight, + toolbarHeight: toolbarHeight, + snap: snap, + bottom: TabBar( + tabs: <String>[ + 'A', + 'B', + 'C', + ].map<Widget>((String t) => Tab(text: 'TAB $t')).toList(), + ), + ), + SliverToBoxAdapter(child: Container(height: 1200.0, color: Colors.orange[400])), + ], + ), + ), + ), + ); +} + +void main() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('SliverAppBar large & medium title respects automaticallyImplyLeading', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/121511 + const title = 'AppBar Title'; + const titleSpacing = 16.0; + + Widget buildWidget() { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: FilledButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar.large(title: Text(title)), + SliverToBoxAdapter( + child: Container(height: 1200, color: Colors.orange[400]), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Go to page'), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.byType(BackButton), findsNothing); + + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + final Finder collapsedTitle = find.text(title).last; + // Get the offset of the Center widget that wraps the IconButton. + final Offset backButtonOffset = tester.getTopRight( + find.ancestor(of: find.byType(IconButton), matching: find.byType(Center)), + ); + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + expect(titleOffset.dx, backButtonOffset.dx + titleSpacing); + }); + + testWidgets( + 'SliverAppBar does not draw menu for end drawer if automaticallyImplyActions is false and actions is null', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + endDrawer: const Drawer(), + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar(automaticallyImplyActions: false), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + expect(find.byIcon(Icons.menu), findsNothing); + }, + ); + + testWidgets( + 'SliverAppBar draws menu for end drawer if automaticallyImplyActions is true (default) and actions is null', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + endDrawer: const Drawer(), + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar(), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + expect(find.byIcon(Icons.menu), findsOneWidget); + }, + ); + + testWidgets( + 'SliverAppBar does not draw menu for end drawer if automaticallyImplyActions is true (default) but actions are explicitly provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + endDrawer: const Drawer(), + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar(actions: <Widget>[Icon(Icons.settings)]), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + expect(find.byIcon(Icons.menu), findsNothing); + expect(find.byIcon(Icons.settings), findsOneWidget); + }, + ); + + testWidgets('SliverAppBar.medium with bottom widget', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/115091 + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 112; + const double bottomHeight = 48; + const title = 'Medium App Bar'; + + Widget buildWidget() { + return MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.medium( + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title), + bottom: const TabBar( + tabs: <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight + bottomHeight); + + final Finder expandedTitle = find.text(title).first; + final Offset expandedTitleOffset = tester.getBottomLeft(expandedTitle); + final Offset tabOffset = tester.getTopLeft(find.byType(TabBar)); + expect(expandedTitleOffset.dy, tabOffset.dy); + + // Scroll CustomScrollView to collapse SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(160); + await tester.pumpAndSettle(); + + expect(appBarHeight(tester), collapsedAppBarHeight + bottomHeight); + }); + + testWidgets('SliverAppBar.large with bottom widget', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/115091 + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 152; + const double bottomHeight = 48; + const title = 'Large App Bar'; + + Widget buildWidget() { + return MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.large( + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title), + bottom: const TabBar( + tabs: <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight + bottomHeight); + + final Finder expandedTitle = find.text(title).first; + final Offset expandedTitleOffset = tester.getBottomLeft(expandedTitle); + final Offset tabOffset = tester.getTopLeft(find.byType(TabBar)); + expect(expandedTitleOffset.dy, tabOffset.dy); + + // Scroll CustomScrollView to collapse SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(200); + await tester.pumpAndSettle(); + + expect(appBarHeight(tester), collapsedAppBarHeight + bottomHeight); + }); + + testWidgets('SliverAppBar.medium expanded title has upper limit on text scaling', ( + WidgetTester tester, + ) async { + const title = 'Medium AppBar'; + Widget buildAppBar({double textScaleFactor = 1.0}) { + return MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Material( + child: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar.medium(title: Text(title)), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildAppBar()); + + final Finder expandedTitle = find.text(title).first; + expect(tester.getRect(expandedTitle).height, 32.0); + verifyTextNotClipped(expandedTitle, tester); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 2.0)); + expect(tester.getRect(expandedTitle).height, 43.0); + verifyTextNotClipped(expandedTitle, tester); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 3.0)); + expect(tester.getRect(expandedTitle).height, 43.0); + verifyTextNotClipped(expandedTitle, tester); + }); + + testWidgets('SliverAppBar.large expanded title has upper limit on text scaling', ( + WidgetTester tester, + ) async { + const title = 'Large AppBar'; + Widget buildAppBar({double textScaleFactor = 1.0}) { + return MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Material( + child: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar.large(title: Text(title, maxLines: 1)), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildAppBar()); + + final Finder expandedTitle = find.text(title).first; + expect(tester.getRect(expandedTitle).height, 36.0); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 2.0)); + expect(tester.getRect(expandedTitle).height, closeTo(48.0, 0.1)); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 3.0)); + expect(tester.getRect(expandedTitle).height, closeTo(48.0, 0.1)); + }); + + testWidgets('SliverAppBar.medium expanded title position is adjusted with textScaleFactor', ( + WidgetTester tester, + ) async { + const title = 'Medium AppBar'; + Widget buildAppBar({double textScaleFactor = 1.0}) { + return MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Material( + child: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar.medium(title: Text(title, maxLines: 1)), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildAppBar()); + + final Finder expandedTitle = find.text(title).first; + expect(tester.getBottomLeft(expandedTitle).dy, 96.0); + verifyTextNotClipped(expandedTitle, tester); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 2.0)); + expect(tester.getBottomLeft(expandedTitle).dy, 107.0); + verifyTextNotClipped(expandedTitle, tester); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 3.0)); + expect(tester.getBottomLeft(expandedTitle).dy, 107.0); + verifyTextNotClipped(expandedTitle, tester); + }); + + testWidgets('SliverAppBar.large expanded title position is adjusted with textScaleFactor', ( + WidgetTester tester, + ) async { + const title = 'Large AppBar'; + Widget buildAppBar({double textScaleFactor = 1.0}) { + return MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Material( + child: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar.large(title: Text(title, maxLines: 1)), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildAppBar()); + final Finder expandedTitle = find.text(title).first; + final RenderSliver renderSliverAppBar = tester.renderObject(find.byType(SliverAppBar)); + expect( + tester.getBottomLeft(expandedTitle).dy, + renderSliverAppBar.geometry!.scrollExtent - 28.0, + reason: 'bottom padding of a large expanded title should be 28.', + ); + verifyTextNotClipped(expandedTitle, tester); + + await tester.pumpWidget(buildAppBar(textScaleFactor: 2.0)); + expect( + tester.getBottomLeft(expandedTitle).dy, + renderSliverAppBar.geometry!.scrollExtent - 28.0, + reason: 'bottom padding of a large expanded title should be 28.', + ); + verifyTextNotClipped(expandedTitle, tester); + + // The bottom padding of the expanded title needs to be reduced for it to be + // fully visible. + await tester.pumpWidget(buildAppBar(textScaleFactor: 3.0)); + expect(tester.getBottomLeft(expandedTitle).dy, 124.0); + verifyTextNotClipped(expandedTitle, tester); + }); + + testWidgets('SliverAppBar.medium collapsed title does not overlap with leading/actions widgets', ( + WidgetTester tester, + ) async { + const title = 'Medium SliverAppBar Very Long Title'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 200), + sliver: SliverAppBar.medium( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + title: const Text(title, maxLines: 1), + centerTitle: true, + actions: const <Widget>[ + Icon(Icons.search), + Icon(Icons.sort), + Icon(Icons.more_vert), + ], + ), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + // Scroll to collapse the SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(45); + await tester.pumpAndSettle(); + + final Offset leadingOffset = tester.getTopRight(find.byIcon(Icons.menu)); + Offset titleOffset = tester.getTopLeft(find.text(title).last); + // The title widget should be to the right of the leading widget. + expect(titleOffset.dx, greaterThan(leadingOffset.dx)); + + titleOffset = tester.getTopRight(find.text(title).last); + final Offset searchOffset = tester.getTopLeft(find.byIcon(Icons.search)); + // The title widget should be to the left of the search icon. + expect(titleOffset.dx, lessThan(searchOffset.dx)); + }); + + testWidgets('SliverAppBar.large collapsed title does not overlap with leading/actions widgets', ( + WidgetTester tester, + ) async { + const title = 'Large SliverAppBar Very Long Title'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 200), + sliver: SliverAppBar.large( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + title: const Text(title, maxLines: 1), + centerTitle: true, + actions: const <Widget>[ + Icon(Icons.search), + Icon(Icons.sort), + Icon(Icons.more_vert), + ], + ), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + // Scroll to collapse the SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(45); + await tester.pumpAndSettle(); + + final Offset leadingOffset = tester.getTopRight(find.byIcon(Icons.menu)); + Offset titleOffset = tester.getTopLeft(find.text(title).last); + // The title widget should be to the right of the leading widget. + expect(titleOffset.dx, greaterThan(leadingOffset.dx)); + + titleOffset = tester.getTopRight(find.text(title).last); + final Offset searchOffset = tester.getTopLeft(find.byIcon(Icons.search)); + // The title widget should be to the left of the search icon. + expect(titleOffset.dx, lessThan(searchOffset.dx)); + }); + + testWidgets('SliverAppBar.medium respects title spacing', (WidgetTester tester) async { + const title = 'Medium SliverAppBar Very Long Title'; + const titleSpacing = 16.0; + + Widget buildWidget({double? titleSpacing, bool? centerTitle}) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 200), + sliver: SliverAppBar.medium( + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title, maxLines: 1), + centerTitle: centerTitle, + titleSpacing: titleSpacing, + actions: <Widget>[ + IconButton(onPressed: () {}, icon: const Icon(Icons.sort)), + IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)), + ], + ), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Finder collapsedTitle = find.text(title).last; + + // Scroll to collapse the SliverAppBar. + ScrollController controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // By default, title widget should be to the right of the + // leading widget and title spacing should be respected. + Offset titleOffset = tester.getTopLeft(collapsedTitle); + Offset iconButtonOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconButtonOffset.dx + titleSpacing); + + await tester.pumpWidget(buildWidget(centerTitle: true)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // By default, title widget should be to the left of the first + // trailing widget and title spacing should be respected. + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx - titleSpacing); + + // Test custom title spacing, set to 0.0. + await tester.pumpWidget(buildWidget(titleSpacing: 0.0)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // The title widget should be to the right of the leading + // widget with no spacing. + titleOffset = tester.getTopLeft(collapsedTitle); + iconButtonOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconButtonOffset.dx); + + // Set centerTitle to true so the end of the title can reach + // the action widgets. + await tester.pumpWidget(buildWidget(titleSpacing: 0.0, centerTitle: true)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // The title widget should be to the left of the first + // leading widget with no spacing. + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx); + }); + + testWidgets('SliverAppBar.large respects title spacing', (WidgetTester tester) async { + const title = 'Large SliverAppBar Very Long Title'; + const titleSpacing = 16.0; + + Widget buildWidget({double? titleSpacing, bool? centerTitle}) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 200), + sliver: SliverAppBar.large( + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title, maxLines: 1), + centerTitle: centerTitle, + titleSpacing: titleSpacing, + actions: <Widget>[ + IconButton(onPressed: () {}, icon: const Icon(Icons.sort)), + IconButton(onPressed: () {}, icon: const Icon(Icons.more_vert)), + ], + ), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Finder collapsedTitle = find.text(title).last; + + // Scroll to collapse the SliverAppBar. + ScrollController controller = primaryScrollController(tester); + controller.jumpTo(160); + await tester.pumpAndSettle(); + + // By default, title widget should be to the right of the leading + // widget and title spacing should be respected. + Offset titleOffset = tester.getTopLeft(collapsedTitle); + Offset iconButtonOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconButtonOffset.dx + titleSpacing); + + await tester.pumpWidget(buildWidget(centerTitle: true)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(160); + await tester.pumpAndSettle(); + + // By default, title widget should be to the left of the + // leading widget and title spacing should be respected. + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx - titleSpacing); + + // Test custom title spacing, set to 0.0. + await tester.pumpWidget(buildWidget(titleSpacing: 0.0)); + controller = primaryScrollController(tester); + controller.jumpTo(160); + await tester.pumpAndSettle(); + + // The title widget should be to the right of the leading + // widget with no spacing. + titleOffset = tester.getTopLeft(collapsedTitle); + iconButtonOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconButtonOffset.dx); + + // Set centerTitle to true so the end of the title can reach + // the action widgets. + await tester.pumpWidget(buildWidget(titleSpacing: 0.0, centerTitle: true)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(160); + await tester.pumpAndSettle(); + + // The title widget should be to the left of the first + // leading widget with no spacing. + titleOffset = tester.getTopRight(collapsedTitle); + iconButtonOffset = tester.getTopLeft(find.widgetWithIcon(IconButton, Icons.sort)); + expect(titleOffset.dx, iconButtonOffset.dx); + }); + + testWidgets('SliverAppBar.medium without the leading widget updates collapsed title padding', ( + WidgetTester tester, + ) async { + const title = 'Medium SliverAppBar Title'; + const leadingPadding = 56.0; + const titleSpacing = 16.0; + + Widget buildWidget({bool showLeading = true}) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.medium( + automaticallyImplyLeading: false, + leading: showLeading + ? IconButton(icon: const Icon(Icons.menu), onPressed: () {}) + : null, + title: const Text(title), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Finder collapsedTitle = find.text(title).last; + + // Scroll to collapse the SliverAppBar. + ScrollController controller = primaryScrollController(tester); + controller.jumpTo(45); + await tester.pumpAndSettle(); + + // If the leading widget is present, the title widget should be to the + // right of the leading widget and title spacing should be respected. + Offset titleOffset = tester.getTopLeft(collapsedTitle); + expect(titleOffset.dx, leadingPadding + titleSpacing); + + // Hide the leading widget. + await tester.pumpWidget(buildWidget(showLeading: false)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(45); + await tester.pumpAndSettle(); + + // If the leading widget is not present, the title widget will + // only have the default title spacing. + titleOffset = tester.getTopLeft(collapsedTitle); + expect(titleOffset.dx, titleSpacing); + }); + + testWidgets('SliverAppBar.large without the leading widget updates collapsed title padding', ( + WidgetTester tester, + ) async { + const title = 'Large SliverAppBar Title'; + const leadingPadding = 56.0; + const titleSpacing = 16.0; + + Widget buildWidget({bool showLeading = true}) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.large( + automaticallyImplyLeading: false, + leading: showLeading + ? IconButton(icon: const Icon(Icons.menu), onPressed: () {}) + : null, + title: const Text(title), + ), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Finder collapsedTitle = find.text(title).last; + + // Scroll CustomScrollView to collapse SliverAppBar. + ScrollController controller = primaryScrollController(tester); + controller.jumpTo(45); + await tester.pumpAndSettle(); + + // If the leading widget is present, the title widget should be to the + // right of the leading widget and title spacing should be respected. + Offset titleOffset = tester.getTopLeft(collapsedTitle); + expect(titleOffset.dx, leadingPadding + titleSpacing); + + // Hide the leading widget. + await tester.pumpWidget(buildWidget(showLeading: false)); + // Scroll to collapse the SliverAppBar. + controller = primaryScrollController(tester); + controller.jumpTo(45); + await tester.pumpAndSettle(); + + // If the leading widget is not present, the title widget will + // only have the default title spacing. + titleOffset = tester.getTopLeft(collapsedTitle); + expect(titleOffset.dx, titleSpacing); + }); + + group('WidgetStateColor scrolledUnder', () { + const double collapsedHeight = kToolbarHeight; + const expandedHeight = 200.0; + const scrolledColor = Color(0xff00ff00); + const defaultColor = Color(0xff0000ff); + + Widget buildSliverApp({ + required double contentHeight, + bool reverse = false, + bool includeFlexibleSpace = false, + }) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + reverse: reverse, + slivers: <Widget>[ + SliverAppBar( + elevation: 0, + backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.scrolledUnder) ? scrolledColor : defaultColor; + }), + expandedHeight: expandedHeight, + pinned: true, + flexibleSpace: includeFlexibleSpace + ? const FlexibleSpaceBar(title: Text('SliverAppBar')) + : null, + ), + SliverList.list( + children: <Widget>[Container(height: contentHeight, color: Colors.teal)], + ), + ], + ), + ), + ); + } + + testWidgets('backgroundColor', (WidgetTester tester) async { + await tester.pumpWidget(buildSliverApp(contentHeight: 1200.0)); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace', (WidgetTester tester) async { + await tester.pumpWidget(buildSliverApp(contentHeight: 1200.0, includeFlexibleSpace: true)); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor - reverse', (WidgetTester tester) async { + await tester.pumpWidget(buildSliverApp(contentHeight: 1200.0, reverse: true)); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - reverse', (WidgetTester tester) async { + await tester.pumpWidget( + buildSliverApp(contentHeight: 1200.0, reverse: true, includeFlexibleSpace: true), + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor - not triggered in reverse for short content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(buildSliverApp(contentHeight: 200, reverse: true)); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - not triggered in reverse for short content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildSliverApp(contentHeight: 200, reverse: true, includeFlexibleSpace: true), + ); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + }); + + testWidgets('SliverAppBar default configuration', (WidgetTester tester) async { + await tester.pumpWidget(buildSliverAppBarApp()); + + final ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(find.byType(SliverAppBar), findsOneWidget); + + final double initialAppBarHeight = appBarHeight(tester); + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar partially out of view + controller.jumpTo(50.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar out of view + controller.jumpTo(600.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsNothing); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar back into view + controller.jumpTo(0.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); + + testWidgets('SliverAppBar expandedHeight, pinned', (WidgetTester tester) async { + await tester.pumpWidget(buildSliverAppBarApp(pinned: true, expandedHeight: 128.0)); + + final ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), 128.0); + + const initialAppBarHeight = 128.0; + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar, collapsing the expanded height. At this + // point both the toolbar and the tabbar are visible. + controller.jumpTo(600.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(tabBarHeight(tester), initialTabBarHeight); + expect(appBarHeight(tester), lessThan(initialAppBarHeight)); + expect(appBarHeight(tester), greaterThan(initialTabBarHeight)); + + // Scroll the not-pinned appbar back into view + controller.jumpTo(0.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); + + testWidgets('SliverAppBar expandedHeight, pinned and floating', (WidgetTester tester) async { + await tester.pumpWidget( + buildSliverAppBarApp(floating: true, pinned: true, expandedHeight: 128.0), + ); + + final ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), 128.0); + + const initialAppBarHeight = 128.0; + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the floating-pinned appbar, collapsing the expanded height. At this + // point only the tabBar is visible. + controller.jumpTo(600.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(tabBarHeight(tester), initialTabBarHeight); + expect(appBarHeight(tester), lessThan(initialAppBarHeight)); + expect(appBarHeight(tester), initialTabBarHeight); + + // Scroll the floating-pinned appbar back into view + controller.jumpTo(0.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), initialAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); + + testWidgets('SliverAppBar expandedHeight, floating with snap:true', (WidgetTester tester) async { + await tester.pumpWidget( + buildSliverAppBarApp(floating: true, snap: true, expandedHeight: 128.0), + ); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), 128.0); + expect(appBarBottom(tester), 128.0); + + // Scroll to the middle of the list. The (floating) appbar is no longer visible. + final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; + position.jumpTo(256.00); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsNothing); + expect(appBarTop(tester), lessThanOrEqualTo(-128.0)); + + // Drag the scrollable up and down. The app bar should not snap open, its + // height should just track the drag offset. + TestGesture gesture = await tester.startGesture(const Offset(50.0, 256.0)); + await gesture.moveBy(const Offset(0.0, 128.0)); // drag the appbar all the way open + await tester.pump(); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), 128.0); + + await gesture.moveBy(const Offset(0.0, -50.0)); + await tester.pump(); + expect(appBarBottom(tester), 78.0); // 78 == 128 - 50 + + // Trigger the snap open animation: drag down and release + await gesture.moveBy(const Offset(0.0, 10.0)); + await gesture.up(); + + // Now verify that the appbar is animating open + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + double bottom = appBarBottom(tester); + expect(bottom, greaterThan(88.0)); // 88 = 78 + 10 + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(appBarBottom(tester), greaterThan(bottom)); + + // The animation finishes when the appbar is full height. + await tester.pumpAndSettle(); + expect(appBarHeight(tester), 128.0); + + // Now that the app bar is open, perform the same drag scenario + // in reverse: drag the appbar up and down and then trigger the + // snap closed animation. + gesture = await tester.startGesture(const Offset(50.0, 256.0)); + await gesture.moveBy(const Offset(0.0, -128.0)); // drag the appbar closed + await tester.pump(); + expect(appBarBottom(tester), 0.0); + + await gesture.moveBy(const Offset(0.0, 100.0)); + await tester.pump(); + expect(appBarBottom(tester), 100.0); + + // Trigger the snap close animation: drag upwards and release + await gesture.moveBy(const Offset(0.0, -10.0)); + await gesture.up(); + + // Now verify that the appbar is animating closed + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + bottom = appBarBottom(tester); + expect(bottom, lessThan(90.0)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(appBarBottom(tester), lessThan(bottom)); + + // The animation finishes when the appbar is off screen. + await tester.pumpAndSettle(); + expect(appBarTop(tester), lessThanOrEqualTo(0.0)); + expect(appBarBottom(tester), lessThanOrEqualTo(0.0)); + }); + + testWidgets('SliverAppBar expandedHeight, floating and pinned with snap:true', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildSliverAppBarApp(floating: true, pinned: true, snap: true, expandedHeight: 128.0), + ); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), 128.0); + expect(appBarBottom(tester), 128.0); + + // Scroll to the middle of the list. The only the tab bar is visible + // because this is a pinned appbar. + final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; + position.jumpTo(256.0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), kTextTabBarHeight); + + // Drag the scrollable up and down. The app bar should not snap open, the + // bottom of the appbar should just track the drag offset. + TestGesture gesture = await tester.startGesture(const Offset(50.0, 200.0)); + await gesture.moveBy(const Offset(0.0, 100.0)); + await tester.pump(); + expect(appBarHeight(tester), 100.0); + + await gesture.moveBy(const Offset(0.0, -25.0)); + await tester.pump(); + expect(appBarHeight(tester), 75.0); + + // Trigger the snap animation: drag down and release + await gesture.moveBy(const Offset(0.0, 10.0)); + await gesture.up(); + + // Now verify that the appbar is animating open + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final double height = appBarHeight(tester); + expect(height, greaterThan(85.0)); + expect(height, lessThan(128.0)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(appBarHeight(tester), greaterThan(height)); + expect(appBarHeight(tester), lessThan(128.0)); + + // The animation finishes when the appbar is fully expanded + await tester.pumpAndSettle(); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), 128.0); + expect(appBarBottom(tester), 128.0); + + // Now that the appbar is fully expanded, Perform the same drag + // scenario in reverse: drag the appbar up and down and then trigger + // the snap closed animation. + gesture = await tester.startGesture(const Offset(50.0, 256.0)); + await gesture.moveBy(const Offset(0.0, -128.0)); + await tester.pump(); + expect(appBarBottom(tester), kTextTabBarHeight); + + await gesture.moveBy(const Offset(0.0, 100.0)); + await tester.pump(); + expect(appBarBottom(tester), 100.0); + + // Trigger the snap close animation: drag upwards and release + await gesture.moveBy(const Offset(0.0, -10.0)); + await gesture.up(); + + // Now verify that the appbar is animating closed + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final double bottom = appBarBottom(tester); + expect(bottom, lessThan(90.0)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(appBarBottom(tester), lessThan(bottom)); + + // The animation finishes when the appbar shrinks back to its pinned height + await tester.pumpAndSettle(); + expect(appBarTop(tester), lessThanOrEqualTo(0.0)); + expect(appBarBottom(tester), kTextTabBarHeight); + }); + + testWidgets('SliverAppBar expandedHeight, collapsedHeight', (WidgetTester tester) async { + const expandedAppBarHeight = 400.0; + const collapsedAppBarHeight = 200.0; + + await tester.pumpWidget( + buildSliverAppBarApp( + collapsedHeight: collapsedAppBarHeight, + expandedHeight: expandedAppBarHeight, + ), + ); + + final ScrollController controller = primaryScrollController(tester); + expect(controller.offset, 0.0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar partially out of view. + controller.jumpTo(50.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 50.0); + expect(tabBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar out of view, to its collapsed height. + controller.jumpTo(600.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsNothing); + expect(appBarHeight(tester), collapsedAppBarHeight + initialTabBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + + // Scroll the not-pinned appbar back into view. + controller.jumpTo(0.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tabBarHeight(tester), initialTabBarHeight); + }); + + testWidgets('Material3 - SliverAppBar.medium defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 112; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar.medium(title: Text('AppBar Title')), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final ScrollController controller = primaryScrollController(tester); + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; + final Finder expandedTitleClip = find + .ancestor(of: expandedTitle, matching: find.byType(ClipRect)) + .first; + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), + ); + + // Default, fully expanded app bar. + expect(controller.offset, 0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset.dx, 16.0); + expect(titleOffset.dy, 96.0); + + verifyTextNotClipped(expandedTitle, tester); + + // Test the expanded title default color. + expect( + tester.renderObject<RenderParagraph>(expandedTitle).text.style!.color, + theme.colorScheme.onSurface, + ); + + // Scroll the expanded app bar partially out of view. + controller.jumpTo(45); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 45); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight - 45, + ); + + // Scroll so that it is completely collapsed. + controller.jumpTo(600); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), collapsedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 1); + expect(tester.getSize(expandedTitleClip).height, 0); + + // Scroll back to fully expanded. + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + }); + + testWidgets('Material3 - SliverAppBar.large defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 152; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar.large(title: Text('AppBar Title')), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final ScrollController controller = primaryScrollController(tester); + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; + final Finder expandedTitleClip = find + .ancestor(of: expandedTitle, matching: find.byType(ClipRect)) + .first; + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), + ); + + // Default, fully expanded app bar. + expect(controller.offset, 0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset.dx, 16.0); + final RenderSliver renderSliverAppBar = tester.renderObject(find.byType(SliverAppBar)); + // The expanded title and the bottom padding fits in the flexible space. + expect( + titleOffset.dy, + renderSliverAppBar.geometry!.scrollExtent - 28.0, + reason: 'bottom padding of a large expanded title should be 28.', + ); + verifyTextNotClipped(expandedTitle, tester); + + // Test the expanded title default color. + expect( + tester.renderObject<RenderParagraph>(expandedTitle).text.style!.color, + theme.colorScheme.onSurface, + ); + + // Scroll the expanded app bar partially out of view. + controller.jumpTo(45); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 45); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight - 45, + ); + + // Scroll so that it is completely collapsed. + controller.jumpTo(600); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), collapsedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 1); + expect(tester.getSize(expandedTitleClip).height, 0); + + // Scroll back to fully expanded. + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); + }); + + group('SliverAppBar elevation', () { + Widget buildSliverAppBar(bool forceElevated, {double? elevation, double? themeElevation}) { + return MaterialApp( + theme: ThemeData( + appBarTheme: AppBarTheme( + elevation: themeElevation, + scrolledUnderElevation: themeElevation, + ), + ), + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + title: const Text('Title'), + forceElevated: forceElevated, + elevation: elevation, + scrolledUnderElevation: elevation, + ), + ], + ), + ); + } + + testWidgets('Respects forceElevated parameter', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/59158. + AppBar getAppBar() => tester.widget<AppBar>(find.byType(AppBar)); + Material getMaterial() => tester.widget<Material>(find.byType(Material)); + final bool useMaterial3 = ThemeData().useMaterial3; + + // When forceElevated is off, SliverAppBar should not be elevated. + await tester.pumpWidget(buildSliverAppBar(false)); + expect(getMaterial().elevation, 0.0); + + // Default elevation should be used by the material, but + // the AppBar's elevation should not be specified by SliverAppBar. + // When useMaterial3 is true, and forceElevated is true, the default elevation + // should be the value of `scrolledUnderElevation` which is 3.0 + await tester.pumpWidget(buildSliverAppBar(true)); + expect(getMaterial().elevation, useMaterial3 ? 3.0 : 4.0); + expect(getAppBar().elevation, null); + + // SliverAppBar should use the specified elevation. + await tester.pumpWidget(buildSliverAppBar(true, elevation: 8.0)); + expect(getMaterial().elevation, 8.0); + }); + + testWidgets('Uses elevation of AppBarTheme by default', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/73525. + Material getMaterial() => tester.widget<Material>(find.byType(Material)); + + await tester.pumpWidget(buildSliverAppBar(false, themeElevation: 12.0)); + expect(getMaterial().elevation, 0.0); + + await tester.pumpWidget(buildSliverAppBar(true, themeElevation: 12.0)); + expect(getMaterial().elevation, 12.0); + + await tester.pumpWidget(buildSliverAppBar(true, elevation: 8.0, themeElevation: 12.0)); + expect(getMaterial().elevation, 8.0); + }); + }); + + group('SliverAppBar.forceMaterialTransparency', () { + Material getSliverAppBarMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(SliverAppBar), matching: find.byType(Material)).first, + ); + } + + // Generates a MaterialApp with a SliverAppBar in a CustomScrollView. + // The first cell of the scroll view contains a button at its top, and is + // initially scrolled so that it is beneath the SliverAppBar. + (ScrollController, Widget) buildWidget({ + required bool forceMaterialTransparency, + required VoidCallback onPressed, + }) { + const double appBarHeight = 120; + final controller = ScrollController(initialScrollOffset: appBarHeight); + + return ( + controller, + MaterialApp( + home: Scaffold( + body: CustomScrollView( + controller: controller, + slivers: <Widget>[ + SliverAppBar( + collapsedHeight: appBarHeight, + expandedHeight: appBarHeight, + pinned: true, + elevation: 0, + backgroundColor: Colors.transparent, + forceMaterialTransparency: forceMaterialTransparency, + title: const Text('AppBar'), + ), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + height: appBarHeight, + child: index == 0 + ? Align( + alignment: Alignment.topCenter, + child: TextButton(onPressed: onPressed, child: const Text('press')), + ) + : const SizedBox(), + ); + }, + ), + ], + ), + ), + ), + ); + } + + testWidgets('forceMaterialTransparency == true allows gestures beneath the app bar', ( + WidgetTester tester, + ) async { + var buttonWasPressed = false; + final (ScrollController controller, Widget widget) = buildWidget( + forceMaterialTransparency: true, + onPressed: () { + buttonWasPressed = true; + }, + ); + await tester.pumpWidget(widget); + + final Material material = getSliverAppBarMaterial(tester); + expect(material.type, MaterialType.transparency); + + final Finder buttonFinder = find.byType(TextButton); + await tester.tap(buttonFinder); + await tester.pump(); + expect(buttonWasPressed, isTrue); + + controller.dispose(); + }); + + testWidgets('forceMaterialTransparency == false does not allow gestures beneath the app bar', ( + WidgetTester tester, + ) async { + // Set this, and tester.tap(warnIfMissed:false), to suppress + // errors/warning that the button is not hittable (which is expected). + WidgetController.hitTestWarningShouldBeFatal = false; + + var buttonWasPressed = false; + final (ScrollController controller, Widget widget) = buildWidget( + forceMaterialTransparency: false, + onPressed: () { + buttonWasPressed = true; + }, + ); + await tester.pumpWidget(widget); + + final Material material = getSliverAppBarMaterial(tester); + expect(material.type, MaterialType.canvas); + + final Finder buttonFinder = find.byType(TextButton); + await tester.tap(buttonFinder, warnIfMissed: false); + await tester.pump(); + expect(buttonWasPressed, isFalse); + + controller.dispose(); + }); + }); + + testWidgets('SliverAppBar positioning of leading and trailing widgets with top padding', ( + WidgetTester tester, + ) async { + const topPadding100 = MediaQueryData(padding: EdgeInsets.only(top: 100.0)); + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: topPadding100, + child: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar( + leading: Placeholder(key: leadingKey), + title: Placeholder(key: titleKey, fallbackHeight: kToolbarHeight), + actions: <Widget>[Placeholder(key: trailingKey)], + ), + ], + ), + ), + ), + ), + ); + expect(tester.getTopLeft(find.byType(AppBar)), Offset.zero); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 100.0)); + expect(tester.getTopLeft(find.byKey(titleKey)), const Offset(416.0, 100.0)); + expect(tester.getTopLeft(find.byKey(trailingKey)), const Offset(0.0, 100.0)); + }); + + testWidgets('SliverAppBar positioning of leading and trailing widgets with bottom padding', ( + WidgetTester tester, + ) async { + const topPadding100 = MediaQueryData(padding: EdgeInsets.only(top: 100.0, bottom: 50.0)); + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: topPadding100, + child: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar( + leading: Placeholder(key: leadingKey), + title: Placeholder(key: titleKey), + actions: <Widget>[Placeholder(key: trailingKey)], + ), + ], + ), + ), + ), + ), + ); + expect( + tester.getRect(find.byType(AppBar)), + const Rect.fromLTRB(0.0, 0.0, 800.00, 100.0 + 56.0), + ); + expect( + tester.getRect(find.byKey(leadingKey)), + const Rect.fromLTRB(800.0 - 56.0, 100.0, 800.0, 100.0 + 56.0), + ); + expect( + tester.getRect(find.byKey(trailingKey)), + const Rect.fromLTRB(0.0, 100.0, 400.0, 100.0 + 56.0), + ); + }); + + testWidgets('SliverAppBar provides correct semantics in LTR', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: const Text('Leading'), + title: const Text('Title'), + actions: const <Widget>[Text('Action 1'), Text('Action 2'), Text('Action 3')], + bottom: const PreferredSize( + preferredSize: Size(0.0, kToolbarHeight), + child: Text('Bottom'), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics(label: 'Leading', textDirection: TextDirection.ltr), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.namesRoute, + SemanticsFlag.isHeader, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + TestSemantics(label: 'Action 1', textDirection: TextDirection.ltr), + TestSemantics(label: 'Action 2', textDirection: TextDirection.ltr), + TestSemantics(label: 'Action 3', textDirection: TextDirection.ltr), + TestSemantics(label: 'Bottom', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SliverAppBar provides correct semantics in RTL', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Semantics( + textDirection: TextDirection.rtl, + child: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: AppBar( + leading: const Text('Leading'), + title: const Text('Title'), + actions: const <Widget>[Text('Action 1'), Text('Action 2'), Text('Action 3')], + bottom: const PreferredSize( + preferredSize: Size(0.0, kToolbarHeight), + child: Text('Bottom'), + ), + ), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.rtl, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics(label: 'Leading', textDirection: TextDirection.rtl), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.namesRoute, + SemanticsFlag.isHeader, + ], + label: 'Title', + textDirection: TextDirection.rtl, + ), + TestSemantics(label: 'Action 1', textDirection: TextDirection.rtl), + TestSemantics(label: 'Action 2', textDirection: TextDirection.rtl), + TestSemantics(label: 'Action 3', textDirection: TextDirection.rtl), + TestSemantics(label: 'Bottom', textDirection: TextDirection.rtl), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SliverAppBar excludes header semantics correctly', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + leading: Text('Leading'), + flexibleSpace: ExcludeSemantics(child: Text('Title')), + actions: <Widget>[Text('Action 1')], + excludeHeaderSemantics: true, + ), + ], + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Leading', + textDirection: TextDirection.ltr, + ), + TestSemantics( + label: 'Action 1', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics(), + ], + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SliverAppBar with flexible space has correct semantics order', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/64922. + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + leading: Text('Leading'), + flexibleSpace: Text('Flexible space'), + actions: <Widget>[Text('Action 1')], + ), + ], + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Leading', + textDirection: TextDirection.ltr, + ), + TestSemantics( + label: 'Action 1', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isHeader], + label: 'Flexible space', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Changing SliverAppBar snap from true to false', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/17598 + const appBarHeight = 256.0; + var snap = true; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + expandedHeight: appBarHeight, + floating: true, + snap: snap, + actions: <Widget>[ + TextButton( + child: const Text('snap=false'), + onPressed: () { + setState(() { + snap = false; + }); + }, + ), + ], + flexibleSpace: FlexibleSpaceBar( + background: Container(height: appBarHeight, color: Colors.orange), + ), + ), + SliverList.list( + children: <Widget>[Container(height: 1200.0, color: Colors.teal)], + ), + ], + ), + ); + }, + ), + ), + ); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -100.0)); + await gesture.up(); + + await tester.tap(find.text('snap=false')); + await tester.pumpAndSettle(); + + gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -100.0)); + await gesture.up(); + await tester.pump(); + }); + + testWidgets('SliverAppBar shape default', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + leading: Text('L'), + title: Text('No Scaffold'), + actions: <Widget>[Text('A1'), Text('A2')], + ), + ], + ), + ), + ); + + final Finder sliverAppBarFinder = find.byType(SliverAppBar); + SliverAppBar getSliverAppBarWidget(Finder finder) => tester.widget<SliverAppBar>(finder); + expect(getSliverAppBarWidget(sliverAppBarFinder).shape, null); + + final Finder materialFinder = find.byType(Material); + Material getMaterialWidget(Finder finder) => tester.widget<Material>(finder); + expect(getMaterialWidget(materialFinder).shape, null); + }); + + testWidgets('SliverAppBar with shape', (WidgetTester tester) async { + const roundedRectangleBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15.0)), + ); + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + leading: Text('L'), + title: Text('No Scaffold'), + actions: <Widget>[Text('A1'), Text('A2')], + shape: roundedRectangleBorder, + ), + ], + ), + ), + ); + + final Finder sliverAppBarFinder = find.byType(SliverAppBar); + SliverAppBar getSliverAppBarWidget(Finder finder) => tester.widget<SliverAppBar>(finder); + expect(getSliverAppBarWidget(sliverAppBarFinder).shape, roundedRectangleBorder); + + final Finder materialFinder = find.byType(Material); + Material getMaterialWidget(Finder finder) => tester.widget<Material>(finder); + expect(getMaterialWidget(materialFinder).shape, roundedRectangleBorder); + }); + + testWidgets('SliverAppBar configures the delegate properly', (WidgetTester tester) async { + Future<void> buildAndVerifyDelegate({ + required bool pinned, + required bool floating, + required bool snap, + }) async { + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + title: const Text('Jumbo'), + pinned: pinned, + floating: floating, + snap: snap, + ), + ], + ), + ), + ); + + final SliverPersistentHeaderDelegate delegate = tester + .widget<SliverPersistentHeader>(find.byType(SliverPersistentHeader)) + .delegate; + + // Ensure we have a non-null vsync when it's needed. + if (!floating || + (delegate.snapConfiguration == null && delegate.showOnScreenConfiguration == null)) { + expect(delegate.vsync, isNotNull); + } + + expect(delegate.showOnScreenConfiguration != null, snap && floating); + } + + await buildAndVerifyDelegate(pinned: false, floating: true, snap: false); + await buildAndVerifyDelegate(pinned: false, floating: true, snap: true); + + await buildAndVerifyDelegate(pinned: true, floating: true, snap: false); + await buildAndVerifyDelegate(pinned: true, floating: true, snap: true); + }); + + testWidgets('SliverAppBar default collapsedHeight with respect to toolbarHeight', ( + WidgetTester tester, + ) async { + const toolbarHeight = 100.0; + + await tester.pumpWidget(buildSliverAppBarApp(toolbarHeight: toolbarHeight)); + + final ScrollController controller = primaryScrollController(tester); + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar out of view, to its collapsed height. + controller.jumpTo(300.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsNothing); + // By default, the collapsedHeight is toolbarHeight + bottom.preferredSize.height, + // in this case initialTabBarHeight. + expect(appBarHeight(tester), toolbarHeight + initialTabBarHeight); + }); + + testWidgets('SliverAppBar collapsedHeight with toolbarHeight', (WidgetTester tester) async { + const toolbarHeight = 100.0; + const collapsedHeight = 150.0; + + await tester.pumpWidget( + buildSliverAppBarApp(toolbarHeight: toolbarHeight, collapsedHeight: collapsedHeight), + ); + + final ScrollController controller = primaryScrollController(tester); + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar out of view, to its collapsed height. + controller.jumpTo(300.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsNothing); + expect(appBarHeight(tester), collapsedHeight + initialTabBarHeight); + }); + + testWidgets('SliverAppBar collapsedHeight', (WidgetTester tester) async { + const collapsedHeight = 56.0; + + await tester.pumpWidget(buildSliverAppBarApp(collapsedHeight: collapsedHeight)); + + final ScrollController controller = primaryScrollController(tester); + final double initialTabBarHeight = tabBarHeight(tester); + + // Scroll the not-pinned appbar out of view, to its collapsed height. + controller.jumpTo(300.0); + await tester.pump(); + expect(find.byType(SliverAppBar), findsNothing); + expect(appBarHeight(tester), collapsedHeight + initialTabBarHeight); + }); + + testWidgets('SliverAppBar respects leadingWidth', (WidgetTester tester) async { + const key = Key('leading'); + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + leading: Placeholder(key: key), + leadingWidth: 100, + title: Text('Title'), + ), + ], + ), + ), + ); + + // By default toolbarHeight is 56.0. + expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0, 0, 100, 56)); + }); + + testWidgets('SliverAppBar.titleSpacing defaults to NavigationToolbar.kMiddleSpacing', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(buildSliverAppBarApp()); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolBar.middleSpacing, NavigationToolbar.kMiddleSpacing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/158158. + testWidgets('SliverAppBar should update TabBar before TabBar build', (WidgetTester tester) async { + final tabs = <Tab>[const Tab(text: 'initial tab')]; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DefaultTabController( + length: tabs.length, + child: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + actions: <Widget>[ + TextButton( + child: const Text('Add Tab'), + onPressed: () { + setState(() { + tabs.add(Tab(text: 'Tab ${tabs.length}')); + }); + }, + ), + ], + bottom: TabBar(tabs: tabs), + ), + ], + ), + ), + ); + }, + ), + ), + ); + + // Initializes with only initial tabs. + expect(find.text('initial tab'), findsOneWidget); + expect(find.text('Tab 1'), findsNothing); + expect(find.text('Tab 2'), findsNothing); + + // No crash after tabs added. + await tester.tap(find.text('Add Tab')); + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsNothing); + expect(tester.takeException(), isNull); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Material2 - SliverAppBar.medium defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 112; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar.medium(title: Text('AppBar Title')), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final ScrollController controller = primaryScrollController(tester); + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; + final Finder expandedTitleClip = find.ancestor( + of: expandedTitle, + matching: find.byType(ClipRect), + ); + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), + ); + + // Default, fully expanded app bar. + expect(controller.offset, 0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight, + ); + + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset, const Offset(16.0, 92.0)); + + // Test the expanded title default color. + expect( + tester.renderObject<RenderParagraph>(expandedTitle).text.style!.color, + theme.colorScheme.onPrimary, + ); + + // Scroll the expanded app bar partially out of view. + controller.jumpTo(45); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 45); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight - 45, + ); + + // Scroll so that it is completely collapsed. + controller.jumpTo(600); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), collapsedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 1); + expect(tester.getSize(expandedTitleClip).height, 0); + + // Scroll back to fully expanded. + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight, + ); + }); + + testWidgets('Material2 - SliverAppBar.large defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + const double collapsedAppBarHeight = 64; + const double expandedAppBarHeight = 152; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar.large(title: Text('AppBar Title')), + SliverToBoxAdapter(child: Container(height: 1200, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final ScrollController controller = primaryScrollController(tester); + // There are two widgets for the title. The first title is a larger version + // that is shown at the bottom when the app bar is expanded. It scrolls under + // the main row until it is completely hidden and then the first title is + // faded in. The last is the title on the mainrow with the icons. It is + // transparent when the app bar is expanded, and opaque when it is collapsed. + final Finder expandedTitle = find.text('AppBar Title').first; + final Finder expandedTitleClip = find.ancestor( + of: expandedTitle, + matching: find.byType(ClipRect), + ); + final Finder collapsedTitle = find.text('AppBar Title').last; + final Finder collapsedTitleOpacity = find.ancestor( + of: collapsedTitle, + matching: find.byType(AnimatedOpacity), + ); + + // Default, fully expanded app bar. + expect(controller.offset, 0); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight, + ); + + // Test the expanded title is positioned correctly. + final Offset titleOffset = tester.getBottomLeft(expandedTitle); + expect(titleOffset, const Offset(16.0, 124.0)); + + // Test the expanded title default color. + expect( + tester.renderObject<RenderParagraph>(expandedTitle).text.style!.color, + theme.colorScheme.onPrimary, + ); + + // Scroll the expanded app bar partially out of view. + controller.jumpTo(45); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight - 45); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight - 45, + ); + + // Scroll so that it is completely collapsed. + controller.jumpTo(600); + await tester.pump(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), collapsedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 1); + expect(tester.getSize(expandedTitleClip).height, 0); + + // Scroll back to fully expanded. + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(find.byType(SliverAppBar), findsOneWidget); + expect(appBarHeight(tester), expandedAppBarHeight); + expect(tester.widget<AnimatedOpacity>(collapsedTitleOpacity).opacity, 0); + expect( + tester.getSize(expandedTitleClip).height, + expandedAppBarHeight - collapsedAppBarHeight, + ); + }); + }); +} diff --git a/packages/material_ui/test/material/app_bar_test.dart b/packages/material_ui/test/material/app_bar_test.dart new file mode 100644 index 000000000000..8bf52bb914be --- /dev/null +++ b/packages/material_ui/test/material/app_bar_test.dart @@ -0,0 +1,3751 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; +import 'app_bar_utils.dart'; + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon).first, matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + +void main() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('AppBar centers title on iOS', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold(appBar: AppBar(title: const Text('X'))), + ), + ); + + final Finder title = find.text('X'); + Offset center = tester.getCenter(title); + Size size = tester.getSize(title); + expect(center.dx, lessThan(400 - size.width / 2.0)); + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold(appBar: AppBar(title: const Text('X'))), + ), + ); + + center = tester.getCenter(title); + size = tester.getSize(title); + expect(center.dx, greaterThan(400 - size.width / 2.0), reason: 'on ${platform.name}'); + expect(center.dx, lessThan(400 + size.width / 2.0), reason: 'on ${platform.name}'); + + // One action is still centered. + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + appBar: AppBar(title: const Text('X'), actions: const <Widget>[Icon(Icons.thumb_up)]), + ), + ), + ); + + center = tester.getCenter(title); + size = tester.getSize(title); + expect(center.dx, greaterThan(400 - size.width / 2.0), reason: 'on ${platform.name}'); + expect(center.dx, lessThan(400 + size.width / 2.0), reason: 'on ${platform.name}'); + + // Two actions is left aligned again. + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + appBar: AppBar( + title: const Text('X'), + actions: const <Widget>[Icon(Icons.thumb_up), Icon(Icons.thumb_up)], + ), + ), + ), + ); + + center = tester.getCenter(title); + size = tester.getSize(title); + expect(center.dx, lessThan(400 - size.width / 2.0), reason: 'on ${platform.name}'); + } + }); + + testWidgets('AppBar centerTitle:true centers on Android', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold(appBar: AppBar(centerTitle: true, title: const Text('X'))), + ), + ); + + final Finder title = find.text('X'); + final Offset center = tester.getCenter(title); + final Size size = tester.getSize(title); + expect(center.dx, greaterThan(400 - size.width / 2.0)); + expect(center.dx, lessThan(400 + size.width / 2.0)); + }); + + testWidgets('AppBar centerTitle:false title start edge is 16.0 (LTR)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(centerTitle: false, title: const Placeholder(key: Key('X'))), + ), + ), + ); + + final Finder titleWidget = find.byKey(const Key('X')); + expect(tester.getTopLeft(titleWidget).dx, 16.0); + expect(tester.getTopRight(titleWidget).dx, 800 - 16.0); + }); + + testWidgets('AppBar centerTitle:false title start edge is 16.0 (RTL)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar(centerTitle: false, title: const Placeholder(key: Key('X'))), + ), + ), + ), + ); + + final Finder titleWidget = find.byKey(const Key('X')); + expect(tester.getTopRight(titleWidget).dx, 800.0 - 16.0); + expect(tester.getTopLeft(titleWidget).dx, 16.0); + }); + + testWidgets('AppBar titleSpacing:32 title start edge is 32.0 (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 32.0, + title: const Placeholder(key: Key('X')), + ), + ), + ), + ); + + final Finder titleWidget = find.byKey(const Key('X')); + expect(tester.getTopLeft(titleWidget).dx, 32.0); + expect(tester.getTopRight(titleWidget).dx, 800 - 32.0); + }); + + testWidgets('AppBar titleSpacing:32 title start edge is 32.0 (RTL)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 32.0, + title: const Placeholder(key: Key('X')), + ), + ), + ), + ), + ); + + final Finder titleWidget = find.byKey(const Key('X')); + expect(tester.getTopRight(titleWidget).dx, 800.0 - 32.0); + expect(tester.getTopLeft(titleWidget).dx, 32.0); + }); + + testWidgets('AppBar centerTitle:false leading button title left edge is 72.0 (LTR)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(centerTitle: false, title: const Text('X')), + // A drawer causes a leading hamburger. + drawer: const Drawer(), + ), + ), + ); + + expect(tester.getTopLeft(find.text('X')).dx, 72.0); + }); + + testWidgets('AppBar centerTitle:false leading button title left edge is 72.0 (RTL)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar(centerTitle: false, title: const Text('X')), + // A drawer causes a leading hamburger. + drawer: const Drawer(), + ), + ), + ), + ); + + expect(tester.getTopRight(find.text('X')).dx, 800.0 - 72.0); + }); + + testWidgets('AppBar centerTitle:false title overflow OK', (WidgetTester tester) async { + // The app bar's title should be constrained to fit within the available space + // between the leading and actions widgets. + + final Key titleKey = UniqueKey(); + Widget leading = Container(); + var actions = <Widget>[]; + + Widget buildApp() { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: leading, + centerTitle: false, + title: Container( + key: titleKey, + constraints: BoxConstraints.loose(const Size(1000.0, 1000.0)), + ), + actions: actions, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final Finder title = find.byKey(titleKey); + expect(tester.getTopLeft(title).dx, 72.0); + expect( + tester.getSize(title).width, + equals( + 800.0 // Screen width. + - + 56.0 // Leading button width. + - + 16.0 // Leading button to title padding. + - + 16.0, // Title right side padding. + ), + ); + + actions = <Widget>[const SizedBox(width: 100.0), const SizedBox(width: 100.0)]; + await tester.pumpWidget(buildApp()); + + expect(tester.getTopLeft(title).dx, 72.0); + // The title shrinks by 200.0 to allow for the actions widgets. + expect( + tester.getSize(title).width, + equals( + 800.0 // Screen width. + - + 56.0 // Leading button width. + - + 16.0 // Leading button to title padding. + - + 16.0 // Title to actions padding + - + 200.0, + ), + ); // Actions' width. + + leading = Container(); // AppBar will constrain the width to 24.0 + await tester.pumpWidget(buildApp()); + expect(tester.getTopLeft(title).dx, 72.0); + // Adding a leading widget shouldn't effect the title's size + expect(tester.getSize(title).width, equals(800.0 - 56.0 - 16.0 - 16.0 - 200.0)); + }); + + testWidgets('AppBar centerTitle:true title overflow OK (LTR)', (WidgetTester tester) async { + // The app bar's title should be constrained to fit within the available space + // between the leading and actions widgets. When it's also centered it may + // also be start or end justified if it doesn't fit in the overall center. + + final Key titleKey = UniqueKey(); + var titleWidth = 700.0; + Widget? leading = Container(); + var actions = <Widget>[]; + + Widget buildApp() { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: leading, + centerTitle: true, + title: Container( + key: titleKey, + constraints: BoxConstraints.loose(Size(titleWidth, 1000.0)), + ), + actions: actions, + ), + ), + ); + } + + // Centering a title with width 700 within the 800 pixel wide test widget + // would mean that its start edge would have to be 50. The material spec says + // that the start edge of the title must be at least 72. + await tester.pumpWidget(buildApp()); + + final Finder title = find.byKey(titleKey); + expect(tester.getTopLeft(title).dx, 72.0); + expect(tester.getSize(title).width, equals(700.0)); + + // Centering a title with width 620 within the 800 pixel wide test widget + // would mean that its start edge would have to be 90. We reserve 72 + // on the start and the padded actions occupy 96 on the end. That + // leaves 632, so the title is end justified but its width isn't changed. + + await tester.pumpWidget(buildApp()); + leading = null; + titleWidth = 620.0; + actions = <Widget>[const SizedBox(width: 48.0), const SizedBox(width: 48.0)]; + await tester.pumpWidget(buildApp()); + expect(tester.getTopLeft(title).dx, 800 - 620 - 48 - 48 - 16); + expect(tester.getSize(title).width, equals(620.0)); + }); + + testWidgets('AppBar centerTitle:true title overflow OK (RTL)', (WidgetTester tester) async { + // The app bar's title should be constrained to fit within the available space + // between the leading and actions widgets. When it's also centered it may + // also be start or end justified if it doesn't fit in the overall center. + + final Key titleKey = UniqueKey(); + var titleWidth = 700.0; + Widget? leading = Container(); + var actions = <Widget>[]; + + Widget buildApp() { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar( + leading: leading, + centerTitle: true, + title: Container( + key: titleKey, + constraints: BoxConstraints.loose(Size(titleWidth, 1000.0)), + ), + actions: actions, + ), + ), + ), + ); + } + + // Centering a title with width 700 within the 800 pixel wide test widget + // would mean that its start edge would have to be 50. The material spec says + // that the start edge of the title must be at least 72. + await tester.pumpWidget(buildApp()); + + final Finder title = find.byKey(titleKey); + expect(tester.getTopRight(title).dx, 800.0 - 72.0); + expect(tester.getSize(title).width, equals(700.0)); + + // Centering a title with width 620 within the 800 pixel wide test widget + // would mean that its start edge would have to be 90. We reserve 72 + // on the start and the padded actions occupy 96 on the end. That + // leaves 632, so the title is end justified but its width isn't changed. + + await tester.pumpWidget(buildApp()); + leading = null; + titleWidth = 620.0; + actions = <Widget>[const SizedBox(width: 48.0), const SizedBox(width: 48.0)]; + await tester.pumpWidget(buildApp()); + expect(tester.getTopRight(title).dx, 620 + 48 + 48 + 16); + expect(tester.getSize(title).width, equals(620.0)); + }); + + testWidgets('AppBar with no Scaffold', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + height: kToolbarHeight, + child: AppBar( + leading: const Text('L'), + title: const Text('No Scaffold'), + actions: const <Widget>[Text('A1'), Text('A2')], + ), + ), + ), + ); + + expect(find.text('L'), findsOneWidget); + expect(find.text('No Scaffold'), findsOneWidget); + expect(find.text('A1'), findsOneWidget); + expect(find.text('A2'), findsOneWidget); + }); + + testWidgets('AppBar render at zero size', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(appBar: AppBar(title: const Text('X'))), + ), + ), + ), + ); + + final Finder title = find.text('X'); + expect(tester.getSize(title).isEmpty, isTrue); + }); + + testWidgets('AppBar actions are vertically centered', (WidgetTester tester) async { + final appBarKey = UniqueKey(); + final leadingKey = UniqueKey(); + final titleKey = UniqueKey(); + final action0Key = UniqueKey(); + final action1Key = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + key: appBarKey, + leading: SizedBox(key: leadingKey, height: 50.0), + title: SizedBox(key: titleKey, height: 40.0), + actions: <Widget>[ + SizedBox(key: action0Key, height: 20.0), + SizedBox(key: action1Key, height: 30.0), + ], + ), + ), + ), + ); + + // The vertical center of the widget with key, in global coordinates. + double yCenter(Key key) => tester.getCenter(find.byKey(key)).dy; + + expect(yCenter(appBarKey), equals(yCenter(leadingKey))); + expect(yCenter(appBarKey), equals(yCenter(titleKey))); + expect(yCenter(appBarKey), equals(yCenter(action0Key))); + expect(yCenter(appBarKey), equals(yCenter(action1Key))); + }); + + testWidgets('AppBar drawer icon has default size', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!')), + drawer: const Drawer(), + ), + ), + ); + final double iconSize = const IconThemeData.fallback().size!; + expect(tester.getSize(find.byIcon(Icons.menu)), equals(Size(iconSize, iconSize))); + }); + + testWidgets('Material3 - AppBar drawer icon has default color', (WidgetTester tester) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!')), + drawer: const Drawer(), + ), + ), + ); + + expect(_iconStyle(tester, Icons.menu)?.color, themeData.colorScheme.onSurfaceVariant); + }); + + testWidgets('AppBar drawer icon is sized by iconTheme', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!'), iconTheme: const IconThemeData(size: 30)), + drawer: const Drawer(), + ), + ), + ); + expect(tester.getSize(find.byIcon(Icons.menu)), equals(const Size(30, 30))); + }); + + testWidgets('AppBar drawer icon is colored by iconTheme', (WidgetTester tester) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + const color = Color(0xFF2196F3); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + title: const Text('Howdy!'), + iconTheme: const IconThemeData(color: color), + ), + drawer: const Drawer(), + ), + ), + ); + + expect(_iconStyle(tester, Icons.menu)?.color, color); + }); + + testWidgets('AppBar endDrawer icon has default size', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!')), + endDrawer: const Drawer(), + ), + ), + ); + + final double iconSize = const IconThemeData.fallback().size!; + expect(tester.getSize(find.byIcon(Icons.menu)), equals(Size(iconSize, iconSize))); + }); + + testWidgets('Material3 - AppBar endDrawer icon has default color', (WidgetTester tester) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!')), + endDrawer: const Drawer(), + ), + ), + ); + + expect(_iconStyle(tester, Icons.menu)?.color, themeData.colorScheme.onSurfaceVariant); + }); + + testWidgets('AppBar endDrawer icon is sized by iconTheme', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!'), iconTheme: const IconThemeData(size: 30)), + endDrawer: const Drawer(), + ), + ), + ); + expect(tester.getSize(find.byIcon(Icons.menu)), equals(const Size(30, 30))); + }); + + testWidgets('AppBar endDrawer icon is colored by iconTheme', (WidgetTester tester) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + const color = Color(0xFF2196F3); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + title: const Text('Howdy!'), + iconTheme: const IconThemeData(color: color), + ), + endDrawer: const Drawer(), + ), + ), + ); + + expect(_iconStyle(tester, Icons.menu)?.color, color); + }); + + testWidgets('Material3 - leading widget extends to edge and is square', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(platform: TargetPlatform.android); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + title: const Text('X'), + ), + drawer: const Column(), // Doesn't really matter. Triggers a hamburger regardless. + ), + ), + ); + + // Default IconButton has a size of (48x48). + final Finder hamburger = find.byType(IconButton); + expect(tester.getTopLeft(hamburger), const Offset(4.0, 4.0)); + expect(tester.getSize(hamburger), const Size(48.0, 48.0)); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(leading: Container(), title: const Text('X')), + ), + ), + ); + + // Default leading widget has a size of (56x56). + final Finder leadingBox = find.byType(Container); + expect(tester.getTopLeft(leadingBox), Offset.zero); + expect(tester.getSize(leadingBox), const Size(56.0, 56.0)); + + // The custom leading widget should still be 56x56 even if its size is smaller. + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + leading: const SizedBox(height: 36, width: 36), + title: const Text('X'), + ), // Doesn't really matter. Triggers a hamburger regardless. + ), + ), + ); + + final Finder leading = find.byType(SizedBox); + expect(tester.getTopLeft(leading), Offset.zero); + expect(tester.getSize(leading), const Size(56.0, 56.0)); + }); + + testWidgets('Material3 - Action is 4dp from edge and 48dp min', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar( + title: const Text('X'), + actions: const <Widget>[ + IconButton( + icon: Icon(Icons.share), + onPressed: null, + tooltip: 'Share', + iconSize: 20.0, + ), + IconButton(icon: Icon(Icons.add), onPressed: null, tooltip: 'Add', iconSize: 60.0), + ], + ), + ), + ), + ); + + final Finder addButton = find.widgetWithIcon(IconButton, Icons.add); + expect(tester.getTopRight(addButton), const Offset(800.0, 0.0)); + // It's still the size it was plus the 2 * 8dp padding from IconButton. + expect(tester.getSize(addButton), const Size(60.0 + 2 * 8.0, 56.0)); + + final Finder shareButton = find.widgetWithIcon(IconButton, Icons.share); + // The 20dp icon is expanded to fill the IconButton's touch target to 48dp. + expect(tester.getSize(shareButton), const Size(48.0, 48.0)); + }); + + testWidgets('Material3 - AppBar uses the specified elevation or defaults to 0', ( + WidgetTester tester, + ) async { + Widget buildAppBar([double? elevation]) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Title'), elevation: elevation), + ), + ); + } + + Material getMaterial() => tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + + // Default elevation should be used for the material. + await tester.pumpWidget(buildAppBar()); + expect(getMaterial().elevation, 0); + + // AppBar should use the specified elevation. + await tester.pumpWidget(buildAppBar(8.0)); + expect(getMaterial().elevation, 8.0); + }); + + testWidgets('scrolledUnderElevation', (WidgetTester tester) async { + Widget buildAppBar({double? elevation, double? scrolledUnderElevation}) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Title'), + elevation: elevation, + scrolledUnderElevation: scrolledUnderElevation, + ), + body: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => ListTile(title: Text('Item $index')), + ), + ), + ); + } + + Material getMaterial() => tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + + await tester.pumpWidget(buildAppBar(elevation: 2, scrolledUnderElevation: 10)); + // Starts with the base elevation. + expect(getMaterial().elevation, 2); + + await tester.fling(find.text('Item 2'), const Offset(0.0, -600.0), 2000.0); + await tester.pumpAndSettle(); + + // After scrolling it should be the scrolledUnderElevation. + expect(getMaterial().elevation, 10); + }); + + testWidgets('Material3 - scrolledUnderElevation with nested scroll view', ( + WidgetTester tester, + ) async { + Widget buildAppBar({double? scrolledUnderElevation}) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Title'), + scrolledUnderElevation: scrolledUnderElevation, + notificationPredicate: (ScrollNotification notification) { + return notification.depth == 1; + }, + ), + body: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 4, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + height: 600.0, + width: 800.0, + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) => + ListTile(title: Text('Item $index')), + ), + ); + }, + ), + ), + ); + } + + Material getMaterial() => tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + + await tester.pumpWidget(buildAppBar(scrolledUnderElevation: 10)); + // Starts with the base elevation. + expect(getMaterial().elevation, 0.0); + + await tester.fling(find.text('Item 2'), const Offset(0.0, -600.0), 2000.0); + await tester.pumpAndSettle(); + + // After scrolling it should be the scrolledUnderElevation. + expect(getMaterial().elevation, 10); + }); + + testWidgets('AppBar dimensions, with and without bottom, primary', (WidgetTester tester) async { + const topPadding100 = MediaQueryData(padding: EdgeInsets.only(top: 100.0)); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: topPadding100, + child: Scaffold(primary: false, appBar: AppBar()), + ), + ), + ), + ); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), kToolbarHeight); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: topPadding100, + child: Scaffold(appBar: AppBar(title: const Text('title'))), + ), + ), + ), + ); + expect(appBarTop(tester), 0.0); + expect(tester.getTopLeft(find.text('title')).dy, greaterThan(100.0)); + expect(appBarHeight(tester), kToolbarHeight + 100.0); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: topPadding100, + child: Scaffold( + primary: false, + appBar: AppBar( + bottom: PreferredSize( + preferredSize: const Size.fromHeight(200.0), + child: Container(), + ), + ), + ), + ), + ), + ), + ); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), kToolbarHeight + 200.0); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: topPadding100, + child: Scaffold( + appBar: AppBar( + bottom: PreferredSize( + preferredSize: const Size.fromHeight(200.0), + child: Container(), + ), + ), + ), + ), + ), + ), + ); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), kToolbarHeight + 100.0 + 200.0); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: topPadding100, + child: AppBar(primary: false, title: const Text('title')), + ), + ), + ), + ); + expect(appBarTop(tester), 0.0); + expect(tester.getTopLeft(find.text('title')).dy, lessThan(100.0)); + }); + + testWidgets('AppBar in body excludes bottom SafeArea padding', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/26163 + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.symmetric(vertical: 100.0)), + child: Scaffold( + body: Column(children: <Widget>[AppBar(title: const Text('title'))]), + ), + ), + ), + ), + ); + expect(appBarTop(tester), 0.0); + expect(appBarHeight(tester), kToolbarHeight + 100.0); + }); + + testWidgets('AppBar.title sees the correct padding from MediaQuery', (WidgetTester tester) async { + var titleBuilt = false; + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(12, 34, 56, 78)), + child: Scaffold( + appBar: AppBar( + title: Builder( + builder: (BuildContext context) { + titleBuilt = true; + final EdgeInsets padding = MediaQuery.paddingOf(context); + expect(padding, EdgeInsets.zero); + return const Text('heh'); + }, + ), + ), + ), + ), + ), + ), + ); + expect(titleBuilt, isTrue); + }); + + testWidgets('AppBar updates when you add a drawer', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(appBar: AppBar()))); + expect(find.byIcon(Icons.menu), findsNothing); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(drawer: const Drawer(), appBar: AppBar()), + ), + ); + expect(find.byIcon(Icons.menu), findsOneWidget); + }); + + testWidgets('AppBar does not draw menu for drawer if automaticallyImplyLeading is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(drawer: const Drawer(), appBar: AppBar(automaticallyImplyLeading: false)), + ), + ); + expect(find.byIcon(Icons.menu), findsNothing); + }); + + testWidgets( + 'AppBar does not draw menu for end drawer if automaticallyImplyActions is false and actions is null', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + endDrawer: const Drawer(), + appBar: AppBar(automaticallyImplyActions: false), + ), + ), + ); + expect(find.byIcon(Icons.menu), findsNothing); + }, + ); + + testWidgets( + 'AppBar draws menu for end drawer if automaticallyImplyActions is true (default) and actions is null', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(endDrawer: const Drawer(), appBar: AppBar()), + ), + ); + expect(find.byIcon(Icons.menu), findsOneWidget); + }, + ); + + testWidgets( + 'AppBar does not draw menu for end drawer if automaticallyImplyActions is true (default) but actions are explicitly provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + endDrawer: const Drawer(), + appBar: AppBar(actions: const <Widget>[Icon(Icons.settings)]), + ), + ), + ); + expect(find.byIcon(Icons.menu), findsNothing); + expect(find.byIcon(Icons.settings), findsOneWidget); + }, + ); + + testWidgets('AppBar does not update the leading if a route is popped case 1', ( + WidgetTester tester, + ) async { + final Page<void> page1 = MaterialPage<void>( + key: const ValueKey<String>('1'), + child: Scaffold(key: const ValueKey<String>('1'), appBar: AppBar()), + ); + final Page<void> page2 = MaterialPage<void>( + key: const ValueKey<String>('2'), + child: Scaffold(key: const ValueKey<String>('2'), appBar: AppBar()), + ); + var pages = <Page<void>>[page1]; + await tester.pumpWidget( + MaterialApp( + home: Navigator(pages: pages, onPopPage: (Route<dynamic> route, dynamic result) => false), + ), + ); + expect(find.byType(BackButton), findsNothing); + // Update pages + pages = <Page<void>>[page2]; + await tester.pumpWidget( + MaterialApp( + home: Navigator(pages: pages, onPopPage: (Route<dynamic> route, dynamic result) => false), + ), + ); + expect(find.byType(BackButton), findsNothing); + }); + + testWidgets('AppBar does not update the leading if a route is popped case 2', ( + WidgetTester tester, + ) async { + final Page<void> page1 = MaterialPage<void>( + key: const ValueKey<String>('1'), + child: Scaffold(key: const ValueKey<String>('1'), appBar: AppBar()), + ); + final Page<void> page2 = MaterialPage<void>( + key: const ValueKey<String>('2'), + child: Scaffold(key: const ValueKey<String>('2'), appBar: AppBar()), + ); + var pages = <Page<void>>[page1, page2]; + await tester.pumpWidget( + MaterialApp( + home: Navigator(pages: pages, onPopPage: (Route<dynamic> route, dynamic result) => false), + ), + ); + // The page2 should have a back button + expect( + find.descendant( + of: find.byKey(const ValueKey<String>('2')), + matching: find.byType(BackButton), + ), + findsOneWidget, + ); + // Update pages + pages = <Page<void>>[page1]; + await tester.pumpWidget( + MaterialApp( + home: Navigator(pages: pages, onPopPage: (Route<dynamic> route, dynamic result) => false), + ), + ); + await tester.pump(const Duration(milliseconds: 10)); + // The back button should persist during the pop animation. + expect( + find.descendant( + of: find.byKey(const ValueKey<String>('2')), + matching: find.byType(BackButton), + ), + findsOneWidget, + ); + }); + + testWidgets('Material3 - AppBar ink splash draw on the correct canvas', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/58665 + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + // Test was designed against InkSplash so need to make sure that is used. + theme: ThemeData(splashFactory: InkSplash.splashFactory), + home: Center( + child: AppBar( + title: const Text('Abc'), + actions: <Widget>[ + IconButton( + key: key, + icon: const Icon(Icons.add_circle), + tooltip: 'First button', + onPressed: () {}, + ), + ], + flexibleSpace: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: const Alignment(-0.04, 1.0), + colors: <Color>[Colors.blue.shade500, Colors.blue.shade800], + ), + ), + ), + ), + ), + ), + ); + final RenderObject painter = tester.renderObject( + find.descendant( + of: find.descendant(of: find.byType(AppBar), matching: find.byType(Stack)), + matching: find.byType(Material).last, + ), + ); + await tester.tap(find.byKey(key)); + expect( + painter, + paints + ..save() + ..translate() + ..save() + ..translate() + ..circle(x: 20.0, y: 20.0), + ); + }); + + testWidgets('AppBar handles loose children 0', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: Placeholder(key: key), + title: const Text('Abc'), + actions: const <Widget>[ + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + ], + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byKey(key)).localToGlobal(Offset.zero), Offset.zero); + expect(tester.renderObject<RenderBox>(find.byKey(key)).size, const Size(56.0, 56.0)); + }); + + testWidgets('AppBar handles loose children 1', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: Placeholder(key: key), + title: const Text('Abc'), + actions: const <Widget>[ + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + ], + flexibleSpace: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: const Alignment(-0.04, 1.0), + colors: <Color>[Colors.blue.shade500, Colors.blue.shade800], + ), + ), + ), + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byKey(key)).localToGlobal(Offset.zero), Offset.zero); + expect(tester.renderObject<RenderBox>(find.byKey(key)).size, const Size(56.0, 56.0)); + }); + + testWidgets('AppBar handles loose children 2', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: Placeholder(key: key), + title: const Text('Abc'), + actions: const <Widget>[ + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + ], + flexibleSpace: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: const Alignment(-0.04, 1.0), + colors: <Color>[Colors.blue.shade500, Colors.blue.shade800], + ), + ), + ), + bottom: PreferredSize( + preferredSize: const Size(0.0, kToolbarHeight), + child: Container( + height: 50.0, + padding: const EdgeInsets.all(4.0), + child: const Placeholder(color: Color(0xFFFFFFFF)), + ), + ), + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byKey(key)).localToGlobal(Offset.zero), Offset.zero); + expect(tester.renderObject<RenderBox>(find.byKey(key)).size, const Size(56.0, 56.0)); + }); + + testWidgets('AppBar handles loose children 3', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: Placeholder(key: key), + title: const Text('Abc'), + actions: const <Widget>[ + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + Placeholder(fallbackWidth: 10.0), + ], + bottom: PreferredSize( + preferredSize: const Size(0.0, kToolbarHeight), + child: Container( + height: 50.0, + padding: const EdgeInsets.all(4.0), + child: const Placeholder(color: Color(0xFFFFFFFF)), + ), + ), + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byKey(key)).localToGlobal(Offset.zero), Offset.zero); + expect(tester.renderObject<RenderBox>(find.byKey(key)).size, const Size(56.0, 56.0)); + }); + + testWidgets('AppBar positioning of leading and trailing widgets with top padding', ( + WidgetTester tester, + ) async { + const topPadding100 = MediaQueryData(padding: EdgeInsets.only(top: 100)); + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: topPadding100, + child: Scaffold( + primary: false, + appBar: AppBar( + leading: Placeholder( + key: leadingKey, + ), // Forced to 56x56, see _kLeadingWidth in app_bar.dart. + title: Placeholder(key: titleKey, fallbackHeight: kToolbarHeight), + actions: <Widget>[Placeholder(key: trailingKey, fallbackWidth: 10)], + ), + ), + ), + ), + ), + ); + expect(tester.getTopLeft(find.byType(AppBar)), Offset.zero); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 100)); + expect(tester.getTopLeft(find.byKey(trailingKey)), const Offset(0.0, 100)); + + // Because the topPadding eliminates the vertical space for the + // NavigationToolbar within the AppBar, the toolbar is constrained + // with minHeight=maxHeight=0. The _AppBarTitle widget vertically centers + // the title, so its Y coordinate relative to the toolbar is -kToolbarHeight / 2 + // (-28). The top of the toolbar is at (screen coordinates) y=100, so the + // top of the title is 100 + -28 = 72. The toolbar clips its contents + // so the title isn't actually visible. + expect( + tester.getTopLeft(find.byKey(titleKey)), + const Offset(10 + NavigationToolbar.kMiddleSpacing, 72), + ); + }); + + testWidgets('AppBar excludes header semantics correctly', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: const Text('Leading'), + title: const ExcludeSemantics(child: Text('Title')), + excludeHeaderSemantics: true, + actions: const <Widget>[Text('Action 1')], + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics(label: 'Leading', textDirection: TextDirection.ltr), + TestSemantics(label: 'Action 1', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + testWidgets('AppBar has default semantics order', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: Semantics(sortKey: const OrdinalSortKey(0), child: const Text('Leading')), + title: Semantics(sortKey: const OrdinalSortKey(2), child: const Text('Title')), + flexibleSpace: Semantics( + sortKey: const OrdinalSortKey(1), + child: const Text('Flexible Space'), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics( + id: 8, + label: 'Leading', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + SemanticsFlag.namesRoute, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + id: 5, + children: <TestSemantics>[ + TestSemantics( + id: 6, + label: 'Flexible Space', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + testWidgets('AppBar can customize sort keys for flexible space', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: AppBar( + leading: Semantics(sortKey: const OrdinalSortKey(0), child: const Text('Leading')), + title: Semantics(sortKey: const OrdinalSortKey(2), child: const Text('Title')), + flexibleSpace: Semantics( + sortKey: const OrdinalSortKey(1), + child: const Text('Flexible Space'), + ), + useDefaultSemanticsOrder: false, + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 6, + label: 'Leading', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 5, + label: 'Flexible Space', + textDirection: TextDirection.ltr, + ), + + TestSemantics( + id: 7, + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + SemanticsFlag.namesRoute, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/176566 + testWidgets( + 'AppBar title Semantics.namesRoute flag should be null on iOS/macOS platforms regardless of theme platform', + (WidgetTester tester) async { + // Regression test for VoiceOver accessibility when theme platform differs from device platform. + // When someone sets theme.platform to TargetPlatform.android on an iOS/macOS device, + // VoiceOver should still work correctly by not having a namesRoute flag in the title's semantics. + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: AppBar(title: const Text('Title')), + ), + ); + + final expectedFlags = <SemanticsFlag>[SemanticsFlag.isHeader]; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 5, + flags: expectedFlags, + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + // Regression test for https://github.com/flutter/flutter/issues/176566 + testWidgets( + 'AppBar title Semantics.namesRoute flag should be non-null on Android/Fuchsia/Linux/Windows platforms regardless of theme platform', + (WidgetTester tester) async { + // When someone sets theme.platform to TargetPlatform.iOS on an Android device, + // TalkBack should still work correctly by having a namesRoute flag in the title's semantics. + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: AppBar(title: const Text('Title')), + ), + ); + + final expectedFlags = <SemanticsFlag>[SemanticsFlag.isHeader, SemanticsFlag.namesRoute]; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 5, + flags: expectedFlags, + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('Material3 - AppBar draws a light system bar for a dark background', ( + WidgetTester tester, + ) async { + final darkTheme = ThemeData.dark(); + await tester.pumpWidget( + MaterialApp( + theme: darkTheme, + home: Scaffold(appBar: AppBar(title: const Text('test'))), + ), + ); + + expect(darkTheme.colorScheme.brightness, Brightness.dark); + expect( + SystemChrome.latestStyle, + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: Brightness.dark, + statusBarIconBrightness: Brightness.light, + ), + ); + }); + + testWidgets('Material3 - AppBar draws a dark system bar for a light background', ( + WidgetTester tester, + ) async { + final lightTheme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + home: Scaffold(appBar: AppBar(title: const Text('test'))), + ), + ); + + expect(lightTheme.colorScheme.brightness, Brightness.light); + expect( + SystemChrome.latestStyle, + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + ), + ); + }); + + testWidgets( + 'Material3 - Default system bar brightness based on AppBar background color brightness.', + (WidgetTester tester) async { + Widget buildAppBar(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ); + } + + // Using a light theme. + { + await tester.pumpWidget(buildAppBar(ThemeData())); + final Material appBarMaterial = tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + final Brightness appBarBrightness = ThemeData.estimateBrightnessForColor( + appBarMaterial.color!, + ); + final Brightness onAppBarBrightness = appBarBrightness == Brightness.light + ? Brightness.dark + : Brightness.light; + + expect( + SystemChrome.latestStyle, + SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: appBarBrightness, + statusBarIconBrightness: onAppBarBrightness, + ), + ); + } + + // Using a dark theme. + { + await tester.pumpWidget(buildAppBar(ThemeData.dark())); + final Material appBarMaterial = tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + final Brightness appBarBrightness = ThemeData.estimateBrightnessForColor( + appBarMaterial.color!, + ); + final Brightness onAppBarBrightness = appBarBrightness == Brightness.light + ? Brightness.dark + : Brightness.light; + + expect( + SystemChrome.latestStyle, + SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: appBarBrightness, + statusBarIconBrightness: onAppBarBrightness, + ), + ); + } + }, + ); + + testWidgets('Material3 - Default status bar color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + key: GlobalKey(), + theme: ThemeData(appBarTheme: const AppBarThemeData()), + home: Scaffold(appBar: AppBar(title: const Text('title'))), + ), + ); + + expect(SystemChrome.latestStyle!.statusBarColor, Colors.transparent); + }); + + testWidgets('AppBar systemOverlayStyle is use to style status bar and navigation bar', ( + WidgetTester tester, + ) async { + final SystemUiOverlayStyle systemOverlayStyle = SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.red, + systemNavigationBarColor: Colors.green, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('test'), systemOverlayStyle: systemOverlayStyle), + ), + ), + ); + + expect(SystemChrome.latestStyle!.statusBarColor, Colors.red); + expect(SystemChrome.latestStyle!.systemNavigationBarColor, Colors.green); + }); + + testWidgets('AppBar shape default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: AppBar( + leading: const Text('L'), + title: const Text('No Scaffold'), + actions: const <Widget>[Text('A1'), Text('A2')], + ), + ), + ); + + final Finder appBarFinder = find.byType(AppBar); + AppBar getAppBarWidget(Finder finder) => tester.widget<AppBar>(finder); + expect(getAppBarWidget(appBarFinder).shape, null); + + final Finder materialFinder = find.byType(Material); + Material getMaterialWidget(Finder finder) => tester.widget<Material>(finder); + expect(getMaterialWidget(materialFinder).shape, null); + }); + + testWidgets('AppBar with shape', (WidgetTester tester) async { + const roundedRectangleBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15.0)), + ); + await tester.pumpWidget( + MaterialApp( + home: AppBar( + leading: const Text('L'), + title: const Text('No Scaffold'), + actions: const <Widget>[Text('A1'), Text('A2')], + shape: roundedRectangleBorder, + ), + ), + ); + + final Finder appBarFinder = find.byType(AppBar); + AppBar getAppBarWidget(Finder finder) => tester.widget<AppBar>(finder); + expect(getAppBarWidget(appBarFinder).shape, roundedRectangleBorder); + + final Finder materialFinder = find.byType(Material); + Material getMaterialWidget(Finder finder) => tester.widget<Material>(finder); + expect(getMaterialWidget(materialFinder).shape, roundedRectangleBorder); + }); + + testWidgets('AppBars title has upper limit on text scaling, textScaleFactor = 1, 1.34, 2', ( + WidgetTester tester, + ) async { + late double textScaleFactor; + + Widget buildFrame() { + return MaterialApp( + // Test designed against 2014 font sizes. + theme: ThemeData(textTheme: Typography.englishLike2014), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Scaffold( + appBar: AppBar( + centerTitle: false, + title: const Text('Jumbo', style: TextStyle(fontSize: 18)), + ), + ), + ); + }, + ), + ); + } + + final Finder appBarTitle = find.text('Jumbo'); + + textScaleFactor = 1; + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle).height, 18); + + textScaleFactor = 1.34; + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle).height, 24); + + textScaleFactor = 2; + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle).height, 24); + }); + + testWidgets('AppBars with jumbo titles, textScaleFactor = 3, 3.5, 4', ( + WidgetTester tester, + ) async { + var textScaleFactor = 1.0; + TextDirection textDirection = TextDirection.ltr; + var centerTitle = false; + + Widget buildFrame() { + return MaterialApp( + // Test designed against 2014 font sizes. + theme: ThemeData(textTheme: Typography.englishLike2014), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: textDirection, + child: Builder( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: centerTitle, + title: MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: const Text('Jumbo'), + ), + ), + ); + }, + ), + ); + }, + ), + ); + } + + final Finder appBarTitle = find.text('Jumbo'); + final Finder toolbar = find.byType(NavigationToolbar); + + // Overall screen size is 800x600 + // Left or right justified title is padded by 16 on the "start" side. + // Toolbar height is 56. + // "Jumbo" title is 100x20. + + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle), const Rect.fromLTRB(16, 18, 116, 38)); + expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); + + textScaleFactor = 3; // "Jumbo" title is 300x60. + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle), const Rect.fromLTRB(16, -2, 316, 58)); + expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); + + textScaleFactor = 3.5; // "Jumbo" title is 350x70. + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle), const Rect.fromLTRB(16, -7, 366, 63)); + expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); + + textScaleFactor = 4; // "Jumbo" title is 400x80. + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle), const Rect.fromLTRB(16, -12, 416, 68)); + expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); + + textDirection = TextDirection.rtl; // Changed to rtl. "Jumbo" title is still 400x80. + await tester.pumpWidget(buildFrame()); + expect( + tester.getRect(appBarTitle), + const Rect.fromLTRB(800.0 - 400.0 - 16.0, -12, 800.0 - 16.0, 68), + ); + expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); + + centerTitle = true; // Changed to true. "Jumbo" title is still 400x80. + await tester.pumpWidget(buildFrame()); + expect(tester.getRect(appBarTitle), const Rect.fromLTRB(200, -12, 800.0 - 200.0, 68)); + expect(tester.getCenter(appBarTitle).dy, tester.getCenter(toolbar).dy); + }); + + testWidgets('AppBar respects toolbarHeight', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Title'), toolbarHeight: 48), + body: Container(), + ), + ), + ); + + expect(appBarHeight(tester), 48); + }); + + testWidgets('AppBar respects leadingWidth', (WidgetTester tester) async { + const key = Key('leading'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: const Placeholder(key: key), + leadingWidth: 100, + title: const Text('Title'), + ), + ), + ), + ); + + // By default toolbarHeight is 56.0. + expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0, 0, 100, 56)); + }); + + testWidgets("AppBar with EndDrawer doesn't have leading", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(appBar: AppBar(), endDrawer: const Drawer()), + ), + ); + + final Finder endDrawerFinder = find.byTooltip('Open navigation menu'); + await tester.tap(endDrawerFinder); + await tester.pump(); + + final Finder appBarFinder = find.byType(NavigationToolbar); + NavigationToolbar getAppBarWidget(Finder finder) => tester.widget<NavigationToolbar>(finder); + expect(getAppBarWidget(appBarFinder).leading, null); + }); + + testWidgets('AppBar.titleSpacing defaults to NavigationToolbar.kMiddleSpacing', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolBar.middleSpacing, NavigationToolbar.kMiddleSpacing); + }); + + testWidgets('AppBar foregroundColor and backgroundColor', (WidgetTester tester) async { + const foregroundColor = Color(0xff00ff00); + const backgroundColor = Color(0xff00ffff); + final Key leadingIconKey = UniqueKey(); + final Key actionIconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + leading: Icon(Icons.add_circle, key: leadingIconKey), + title: const Text('title'), + actions: <Widget>[ + Icon(Icons.ac_unit, key: actionIconKey), + const Text('action'), + ], + ), + ), + ), + ); + + final Material appBarMaterial = tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + expect(appBarMaterial.color, backgroundColor); + + final TextStyle titleTextStyle = tester + .widget<DefaultTextStyle>( + find.ancestor(of: find.text('title'), matching: find.byType(DefaultTextStyle)).first, + ) + .style; + expect(titleTextStyle.color, foregroundColor); + + final IconThemeData leadingIconTheme = tester + .widget<IconTheme>( + find.ancestor(of: find.byKey(leadingIconKey), matching: find.byType(IconTheme)).first, + ) + .data; + expect(leadingIconTheme.color, foregroundColor); + + final IconThemeData actionIconTheme = tester + .widget<IconTheme>( + find.ancestor(of: find.byKey(actionIconKey), matching: find.byType(IconTheme)).first, + ) + .data; + expect(actionIconTheme.color, foregroundColor); + + // Test icon color + Color? leadingIconColor() => _iconStyle(tester, Icons.add_circle)?.color; + Color? actionIconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + + expect(leadingIconColor(), foregroundColor); + expect(actionIconColor(), foregroundColor); + }); + + testWidgets('Leading, title, and actions show correct default colors', ( + WidgetTester tester, + ) async { + final themeData = ThemeData.from( + colorScheme: const ColorScheme.light( + onPrimary: Colors.blue, + onSurface: Colors.red, + onSurfaceVariant: Colors.yellow, + ), + ); + final bool material3 = themeData.useMaterial3; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + leading: const Icon(Icons.add_circle), + title: const Text('title'), + actions: const <Widget>[Icon(Icons.ac_unit)], + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject<RenderParagraph>(find.text('title')).text.style!.color!; + } + + Color? leadingIconColor() => _iconStyle(tester, Icons.add_circle)?.color; + Color? actionIconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + + // M2 default color are onPrimary, and M3 has onSurface for leading and title, + // onSurfaceVariant for actions. + expect(textColor(), material3 ? Colors.red : Colors.blue); + expect(leadingIconColor(), material3 ? Colors.red : Colors.blue); + expect(actionIconColor(), material3 ? Colors.yellow : Colors.blue); + }); + + // Regression test for https://github.com/flutter/flutter/issues/107305 + group('Material3 - Icons are colored correctly by IconTheme and ActionIconTheme', () { + testWidgets('Material3 - Icons and IconButtons are colored by IconTheme', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xff00ff00); + final Key leadingIconKey = UniqueKey(); + final Key actionIconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + appBar: AppBar( + iconTheme: const IconThemeData(color: iconColor), + leading: Icon(Icons.add_circle, key: leadingIconKey), + title: const Text('title'), + actions: <Widget>[ + Icon(Icons.ac_unit, key: actionIconKey), + IconButton(icon: const Icon(Icons.add), onPressed: () {}), + ], + ), + ), + ), + ); + + Color? leadingIconColor() => _iconStyle(tester, Icons.add_circle)?.color; + Color? actionIconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconColor(), iconColor); + expect(actionIconColor(), iconColor); + expect(actionIconButtonColor(), iconColor); + }); + + testWidgets('Material3 - Action icons and IconButtons are colored by ActionIconTheme', ( + WidgetTester tester, + ) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + + const actionsIconColor = Color(0xff0000ff); + final Key leadingIconKey = UniqueKey(); + final Key actionIconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + actionsIconTheme: const IconThemeData(color: actionsIconColor), + leading: Icon(Icons.add_circle, key: leadingIconKey), + title: const Text('title'), + actions: <Widget>[ + Icon(Icons.ac_unit, key: actionIconKey), + IconButton(icon: const Icon(Icons.add), onPressed: () {}), + ], + ), + ), + ), + ); + + Color? leadingIconColor() => _iconStyle(tester, Icons.add_circle)?.color; + Color? actionIconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconColor(), themeData.colorScheme.onSurface); + expect(actionIconColor(), actionsIconColor); + expect(actionIconButtonColor(), actionsIconColor); + }); + + testWidgets('Material3 - The actionIconTheme property overrides iconTheme', ( + WidgetTester tester, + ) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + + const overallIconColor = Color(0xff00ff00); + const actionsIconColor = Color(0xff0000ff); + final Key leadingIconKey = UniqueKey(); + final Key actionIconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + iconTheme: const IconThemeData(color: overallIconColor), + actionsIconTheme: const IconThemeData(color: actionsIconColor), + leading: Icon(Icons.add_circle, key: leadingIconKey), + title: const Text('title'), + actions: <Widget>[ + Icon(Icons.ac_unit, key: actionIconKey), + IconButton(icon: const Icon(Icons.add), onPressed: () {}), + ], + ), + ), + ), + ); + + Color? leadingIconColor() => _iconStyle(tester, Icons.add_circle)?.color; + Color? actionIconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconColor(), overallIconColor); + expect(actionIconColor(), actionsIconColor); + expect(actionIconButtonColor(), actionsIconColor); + }); + + testWidgets( + 'Material3 - AppBar.iconTheme should override any IconButtonTheme present in the theme', + (WidgetTester tester) async { + final themeData = ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red, iconSize: 32.0), + ), + ); + + const overallIconTheme = IconThemeData(color: Colors.yellow, size: 30.0); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + iconTheme: overallIconTheme, + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + title: const Text('title'), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + double? leadingIconButtonSize() => _iconStyle(tester, Icons.menu)?.fontSize; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + double? actionIconButtonSize() => _iconStyle(tester, Icons.menu)?.fontSize; + + expect(leadingIconButtonColor(), Colors.yellow); + expect(leadingIconButtonSize(), 30.0); + expect(actionIconButtonColor(), Colors.yellow); + expect(actionIconButtonSize(), 30.0); + }, + ); + + testWidgets( + 'Material3 - AppBar.iconTheme should override any IconButtonTheme present in the theme for widgets containing an iconButton', + (WidgetTester tester) async { + final themeData = ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red, iconSize: 32.0), + ), + ); + + const overallIconTheme = IconThemeData(color: Colors.yellow, size: 30.0); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + iconTheme: overallIconTheme, + leading: BackButton(onPressed: () {}), + title: const Text('title'), + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.arrow_back)?.color; + double? leadingIconButtonSize() => _iconStyle(tester, Icons.arrow_back)?.fontSize; + + expect(leadingIconButtonColor(), Colors.yellow); + expect(leadingIconButtonSize(), 30.0); + }, + ); + + testWidgets( + 'Material3 - AppBar.actionsIconTheme should override any IconButtonTheme present in the theme', + (WidgetTester tester) async { + final themeData = ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red, iconSize: 32.0), + ), + ); + + const actionsIconTheme = IconThemeData(color: Colors.yellow, size: 30.0); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + actionsIconTheme: actionsIconTheme, + title: const Text('title'), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + double? leadingIconButtonSize() => _iconStyle(tester, Icons.menu)?.fontSize; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + double? actionIconButtonSize() => _iconStyle(tester, Icons.add)?.fontSize; + + // The leading icon button uses the style in the IconButtonTheme because only actionsIconTheme is provided. + expect(leadingIconButtonColor(), Colors.red); + expect(leadingIconButtonSize(), 32.0); + expect(actionIconButtonColor(), Colors.yellow); + expect(actionIconButtonSize(), 30.0); + }, + ); + + testWidgets( + 'Material3 - AppBar.actionsIconTheme should override any IconButtonTheme present in the theme for widgets containing an iconButton', + (WidgetTester tester) async { + final themeData = ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red, iconSize: 32.0), + ), + ); + + const actionsIconTheme = IconThemeData(color: Colors.yellow, size: 30.0); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + actionsIconTheme: actionsIconTheme, + title: const Text('title'), + actions: <Widget>[BackButton(onPressed: () {})], + ), + ), + ), + ); + + Color? actionIconButtonColor() => _iconStyle(tester, Icons.arrow_back)?.color; + double? actionIconButtonSize() => _iconStyle(tester, Icons.arrow_back)?.fontSize; + + expect(actionIconButtonColor(), Colors.yellow); + expect(actionIconButtonSize(), 30.0); + }, + ); + + testWidgets( + 'Material3 - The foregroundColor property of the AppBar overrides any IconButtonTheme present in the theme', + (WidgetTester tester) async { + final themeData = ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red), + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + foregroundColor: Colors.purple, + title: const Text('title'), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor(), Colors.purple); + expect(actionIconButtonColor(), Colors.purple); + }, + ); + + // This is a regression test for https://github.com/flutter/flutter/issues/130485. + testWidgets('Material3 - AppBar.iconTheme is correctly applied in dark mode', ( + WidgetTester tester, + ) async { + final themeData = ThemeData( + colorScheme: const ColorScheme.dark().copyWith(onSurfaceVariant: Colors.red), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + iconTheme: const IconThemeData(color: Colors.white), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor(), Colors.white); + expect(actionIconButtonColor(), Colors.white); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/130485. + testWidgets('Material3 - AppBar.foregroundColor is correctly applied in dark mode', ( + WidgetTester tester, + ) async { + final themeData = ThemeData( + colorScheme: const ColorScheme.dark().copyWith(onSurfaceVariant: Colors.red), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + foregroundColor: Colors.white, + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor(), Colors.white); + expect(actionIconButtonColor(), Colors.white); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/130485. + testWidgets('Material3 - AppBar.iconTheme is correctly applied in light mode', ( + WidgetTester tester, + ) async { + final themeData = ThemeData( + colorScheme: const ColorScheme.light().copyWith(onSurfaceVariant: Colors.red), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + iconTheme: const IconThemeData(color: Colors.black87), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor(), Colors.black87); + expect(actionIconButtonColor(), Colors.black87); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/130485. + testWidgets('Material3 - AppBar.foregroundColor is correctly applied in light mode', ( + WidgetTester tester, + ) async { + final themeData = ThemeData( + colorScheme: const ColorScheme.light().copyWith(onSurfaceVariant: Colors.red), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + foregroundColor: Colors.black87, + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor(), Colors.black87); + expect(actionIconButtonColor(), Colors.black87); + }); + }); + + group('WidgetStateColor scrolledUnder', () { + const scrolledColor = Color(0xff00ff00); + const defaultColor = Color(0xff0000ff); + + Widget buildAppBar({ + required double contentHeight, + bool reverse = false, + bool includeFlexibleSpace = false, + bool animateColor = false, + double? scrolledUnderElevation, + }) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + scrolledUnderElevation: scrolledUnderElevation, + backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.scrolledUnder) ? scrolledColor : defaultColor; + }), + title: const Text('AppBar'), + flexibleSpace: includeFlexibleSpace + ? const FlexibleSpaceBar(title: Text('FlexibleSpace')) + : null, + animateColor: animateColor, + ), + body: ListView( + reverse: reverse, + children: <Widget>[Container(height: contentHeight, color: Colors.teal)], + ), + ), + ); + } + + testWidgets('backgroundColor for horizontal scrolling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.scrolledUnder) ? scrolledColor : defaultColor; + }), + title: const Text('AppBar'), + notificationPredicate: (ScrollNotification notification) { + // Represents both scroll views below being treated as a + // single viewport. + return notification.depth <= 1; + }, + ), + body: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container(height: 1200, width: 1200, color: Colors.teal), + ), + ), + ), + ), + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await tester.pump(); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + // Scroll horizontally + await gesture.moveBy(const Offset(-kToolbarHeight, 0.0)); + await tester.pump(); + await gesture.moveBy(const Offset(-kToolbarHeight, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); + // The app bar is still scrolled under vertically, so it should not have + // changed back in response to horizontal scrolling. + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor', (WidgetTester tester) async { + await tester.pumpWidget(buildAppBar(contentHeight: 1200.0)); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor animation', (WidgetTester tester) async { + await tester.pumpWidget( + buildAppBar(contentHeight: 1200.0, scrolledUnderElevation: 0, animateColor: true), + ); + + expect(getAppBarAnimatedBackgroundColor(tester), defaultColor); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pump(); + + expect(getAppBarAnimatedBackgroundColor(tester), defaultColor); + await tester.pumpAndSettle(); + expect(getAppBarAnimatedBackgroundColor(tester), scrolledColor); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pump(); + + expect(getAppBarAnimatedBackgroundColor(tester), scrolledColor); + + // Check intermediate color values. + await tester.pump(const Duration(milliseconds: 50)); + expect(getAppBarAnimatedBackgroundColor(tester), isSameColorAs(const Color(0xFF00C33C))); + await tester.pump(const Duration(milliseconds: 50)); + expect(getAppBarAnimatedBackgroundColor(tester), isSameColorAs(const Color(0xFF0039C6))); + + await tester.pumpAndSettle(); + expect(getAppBarAnimatedBackgroundColor(tester), defaultColor); + }); + + testWidgets('backgroundColor with FlexibleSpace', (WidgetTester tester) async { + await tester.pumpWidget(buildAppBar(contentHeight: 1200.0, includeFlexibleSpace: true)); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor - reverse', (WidgetTester tester) async { + await tester.pumpWidget(buildAppBar(contentHeight: 1200.0, reverse: true)); + await tester.pump(); + + // In this test case, the content always extends under the AppBar, so it + // should always be the scrolledColor. + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - reverse', (WidgetTester tester) async { + await tester.pumpWidget( + buildAppBar(contentHeight: 1200.0, reverse: true, includeFlexibleSpace: true), + ); + await tester.pump(); + + // In this test case, the content always extends under the AppBar, so it + // should always be the scrolledColor. + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('_handleScrollNotification safely calls setState()', (WidgetTester tester) async { + // Regression test for failures found in Google internal issue b/185192049. + final controller = ScrollController(initialScrollOffset: 400); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('AppBar')), + body: Scrollbar( + thumbVisibility: true, + controller: controller, + child: ListView( + controller: controller, + children: <Widget>[Container(height: 1200.0, color: Colors.teal)], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + + controller.dispose(); + }); + + testWidgets('does not trigger on horizontal scroll', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.scrolledUnder) ? scrolledColor : defaultColor; + }), + title: const Text('AppBar'), + ), + body: ListView( + scrollDirection: Axis.horizontal, + children: <Widget>[Container(height: 600.0, width: 1200.0, color: Colors.teal)], + ), + ), + ), + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(-100.0, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + + gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(100.0, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + }); + + testWidgets('backgroundColor - not triggered in reverse for short content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(buildAppBar(contentHeight: 200.0, reverse: true)); + await tester.pump(); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - not triggered in reverse for short content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildAppBar(contentHeight: 200.0, reverse: true, includeFlexibleSpace: true), + ); + await tester.pump(); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('scrolledUnderElevation should be maintained when drawer is opened', ( + WidgetTester tester, + ) async { + final GlobalKey drawerListKey = GlobalKey(); + final GlobalKey bodyListKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.scrolledUnder) ? scrolledColor : defaultColor; + }), + title: const Text('AppBar'), + ), + drawer: Drawer( + child: ListView( + key: drawerListKey, + children: <Widget>[Container(height: 1200, color: Colors.red)], + ), + ), + body: ListView( + key: bodyListKey, + children: <Widget>[Container(height: 1200, color: Colors.teal)], + ), + ), + ), + ); + + // Initial state: AppBar should have the default color. + expect(getAppBarBackgroundColor(tester), defaultColor); + + // Scroll the list view. + await tester.drag(find.byKey(bodyListKey), const Offset(0, -300)); + await tester.pumpAndSettle(); + + // The AppBar should now have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Open the drawer. + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Scroll the list inside the drawer. + await tester.drag(find.byKey(drawerListKey), const Offset(0, -300)); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Scroll list inside the drawer back to the top. + await tester.drag(find.byKey(drawerListKey), const Offset(0, 300)); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Close the drawer using the Scaffold's method. + tester.state<ScaffoldState>(find.byType(Scaffold)).closeDrawer(); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Scroll the list view back to the top. + await tester.drag(find.byKey(bodyListKey), const Offset(0, 300)); + await tester.pumpAndSettle(); + + // The AppBar should be back to the default color. + expect(getAppBarBackgroundColor(tester), defaultColor); + }); + + testWidgets('scrolledUnderElevation should be maintained when endDrawer is opened', ( + WidgetTester tester, + ) async { + final GlobalKey drawerListKey = GlobalKey(); + final GlobalKey bodyListKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.scrolledUnder) ? scrolledColor : defaultColor; + }), + title: const Text('AppBar'), + ), + endDrawer: Drawer( + child: ListView( + key: drawerListKey, + children: <Widget>[Container(height: 1200, color: Colors.red)], + ), + ), + body: ListView( + key: bodyListKey, + children: <Widget>[Container(height: 1200, color: Colors.teal)], + ), + ), + ), + ); + + // Initial state: AppBar should have the default color. + expect(getAppBarBackgroundColor(tester), defaultColor); + + // Scroll the list view. + await tester.drag(find.byKey(bodyListKey), const Offset(0, -300)); + await tester.pumpAndSettle(); + + // The AppBar should now have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Open the drawer. + await tester.tap(find.byIcon(Icons.menu)); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Scroll the list inside the drawer. + await tester.drag(find.byKey(drawerListKey), const Offset(0, -300)); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Scroll list inside the drawer back to the top. + await tester.drag(find.byKey(drawerListKey), const Offset(0, 300)); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Close the drawer using the Scaffold's method. + tester.state<ScaffoldState>(find.byType(Scaffold)).closeEndDrawer(); + await tester.pumpAndSettle(); + + // The AppBar should still have the scrolled color. + expect(getAppBarBackgroundColor(tester), scrolledColor); + + // Scroll the list view back to the top. + await tester.drag(find.byKey(bodyListKey), const Offset(0, 300)); + await tester.pumpAndSettle(); + + // The AppBar should be back to the default color. + expect(getAppBarBackgroundColor(tester), defaultColor); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/80256 + testWidgets('The second page should have a back button even it has an end drawer', ( + WidgetTester tester, + ) async { + final Page<void> page1 = MaterialPage<void>( + key: const ValueKey<String>('1'), + child: Scaffold( + key: const ValueKey<String>('1'), + appBar: AppBar(), + endDrawer: const Drawer(), + ), + ); + final Page<void> page2 = MaterialPage<void>( + key: const ValueKey<String>('2'), + child: Scaffold( + key: const ValueKey<String>('2'), + appBar: AppBar(), + endDrawer: const Drawer(), + ), + ); + final pages = <Page<void>>[page1, page2]; + await tester.pumpWidget( + MaterialApp( + home: Navigator(pages: pages, onPopPage: (Route<Object?> route, Object? result) => false), + ), + ); + + // The page2 should have a back button. + expect( + find.descendant( + of: find.byKey(const ValueKey<String>('2')), + matching: find.byType(BackButton), + ), + findsOneWidget, + ); + }); + + testWidgets('Only local entries that imply app bar dismissal will introduce an back button', ( + WidgetTester tester, + ) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(key: key, appBar: AppBar()), + ), + ); + expect(find.byType(BackButton), findsNothing); + + // Push one entry that doesn't imply app bar dismissal. + ModalRoute.of( + key.currentContext!, + )!.addLocalHistoryEntry(LocalHistoryEntry(onRemove: () {}, impliesAppBarDismissal: false)); + await tester.pump(); + expect(find.byType(BackButton), findsNothing); + + // Push one entry that implies app bar dismissal. + ModalRoute.of(key.currentContext!)!.addLocalHistoryEntry(LocalHistoryEntry(onRemove: () {})); + await tester.pump(); + expect(find.byType(BackButton), findsOneWidget); + }); + + testWidgets('AppBar.preferredHeightFor', (WidgetTester tester) async { + late double preferredHeight; + late Size preferredSize; + + Widget buildFrame({double? themeToolbarHeight, double? appBarToolbarHeight}) { + final appBar = AppBar(toolbarHeight: appBarToolbarHeight); + return MaterialApp( + theme: ThemeData(appBarTheme: AppBarThemeData(toolbarHeight: themeToolbarHeight)), + home: Builder( + builder: (BuildContext context) { + preferredHeight = AppBar.preferredHeightFor(context, appBar.preferredSize); + preferredSize = appBar.preferredSize; + return Scaffold(appBar: appBar, body: const Placeholder()); + }, + ), + ); + } + + await tester.pumpWidget(buildFrame()); + expect(tester.getSize(find.byType(AppBar)).height, kToolbarHeight); + expect(preferredHeight, kToolbarHeight); + expect(preferredSize.height, kToolbarHeight); + + await tester.pumpWidget(buildFrame(themeToolbarHeight: 96)); + await tester.pumpAndSettle(); // Animate MaterialApp theme change. + expect(tester.getSize(find.byType(AppBar)).height, 96); + expect(preferredHeight, 96); + // Special case: AppBarTheme.toolbarHeight specified, + // AppBar.theme.toolbarHeight is null. + expect(preferredSize.height, kToolbarHeight); + + await tester.pumpWidget(buildFrame(appBarToolbarHeight: 64)); + await tester.pumpAndSettle(); // Animate MaterialApp theme change. + expect(tester.getSize(find.byType(AppBar)).height, 64); + expect(preferredHeight, 64); + expect(preferredSize.height, 64); + + await tester.pumpWidget(buildFrame(appBarToolbarHeight: 64, themeToolbarHeight: 96)); + await tester.pumpAndSettle(); // Animate MaterialApp theme change. + expect(tester.getSize(find.byType(AppBar)).height, 64); + expect(preferredHeight, 64); + expect(preferredSize.height, 64); + }); + + testWidgets('AppBar title with actions should have the same position regardless of centerTitle', ( + WidgetTester tester, + ) async { + final Key titleKey = UniqueKey(); + var centerTitle = false; + + Widget buildApp() { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + centerTitle: centerTitle, + title: Container( + key: titleKey, + constraints: BoxConstraints.loose(const Size(1000.0, 1000.0)), + ), + actions: const <Widget>[SizedBox(width: 48.0)], + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + final Finder title = find.byKey(titleKey); + expect(tester.getTopLeft(title).dx, 16.0); + + centerTitle = true; + await tester.pumpWidget(buildApp()); + expect(tester.getTopLeft(title).dx, 16.0); + }); + + testWidgets('AppBar leading widget can take up arbitrary space', (WidgetTester tester) async { + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + late double leadingWidth; + + Widget buildApp() { + return MaterialApp( + home: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + leadingWidth = constraints.maxWidth / 2; + return Scaffold( + appBar: AppBar( + leading: Container(key: leadingKey, width: leadingWidth), + leadingWidth: leadingWidth, + title: Text('Title', key: titleKey), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + expect(tester.getTopLeft(find.byKey(titleKey)).dx, leadingWidth + 16.0); + expect(tester.getSize(find.byKey(leadingKey)).width, leadingWidth); + }); + + group('AppBar.forceMaterialTransparency', () { + Material getAppBarMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)).first, + ); + } + + // Generates a MaterialApp with an AppBar with a TextButton beneath it + // (via extendBodyBehindAppBar = true). + Widget buildWidget({required bool forceMaterialTransparency, required VoidCallback onPressed}) { + return MaterialApp( + home: Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + forceMaterialTransparency: forceMaterialTransparency, + elevation: 3, + backgroundColor: Colors.red, + title: const Text('AppBar'), + ), + body: Align( + alignment: Alignment.topCenter, + child: TextButton(onPressed: onPressed, child: const Text('press me')), + ), + ), + ); + } + + testWidgets('forceMaterialTransparency == true allows gestures beneath the app bar', ( + WidgetTester tester, + ) async { + var buttonWasPressed = false; + final Widget widget = buildWidget( + forceMaterialTransparency: true, + onPressed: () { + buttonWasPressed = true; + }, + ); + await tester.pumpWidget(widget); + + final Material material = getAppBarMaterial(tester); + expect(material.type, MaterialType.transparency); + + final Finder buttonFinder = find.byType(TextButton); + await tester.tap(buttonFinder); + await tester.pump(); + expect(buttonWasPressed, isTrue); + }); + + testWidgets('forceMaterialTransparency == false does not allow gestures beneath the app bar', ( + WidgetTester tester, + ) async { + // Set this, and tester.tap(warnIfMissed:false), to suppress + // errors/warning that the button is not hittable (which is expected). + WidgetController.hitTestWarningShouldBeFatal = false; + + var buttonWasPressed = false; + final Widget widget = buildWidget( + forceMaterialTransparency: false, + onPressed: () { + buttonWasPressed = true; + }, + ); + await tester.pumpWidget(widget); + + final Material material = getAppBarMaterial(tester); + expect(material.type, MaterialType.canvas); + + final Finder buttonFinder = find.byType(TextButton); + await tester.tap(buttonFinder, warnIfMissed: false); + await tester.pump(); + expect(buttonWasPressed, isFalse); + }); + }); + + testWidgets('AppBar.leading size with custom IconButton', (WidgetTester tester) async { + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + const titleSpacing = 16.0; + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: IconButton(key: leadingKey, onPressed: () {}, icon: const Icon(Icons.menu)), + centerTitle: false, + title: Text('Title', key: titleKey), + ), + ), + ), + ); + + final Finder buttonFinder = find.byType(IconButton); + expect(tester.getSize(buttonFinder), const Size(48.0, 48.0)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + expect( + buttonFinder, + paints..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 40.0), + color: theme.colorScheme.onSurface.withOpacity(0.08), + ), + ); + + // Get the offset of the Center widget that wraps the IconButton. + final Offset backButtonOffset = tester.getTopRight( + find.ancestor(of: buttonFinder, matching: find.byType(Center)), + ); + final Offset titleOffset = tester.getTopLeft(find.byKey(titleKey)); + expect(titleOffset.dx, backButtonOffset.dx + titleSpacing); + }); + + testWidgets('AppBar.leading size with custom BackButton', (WidgetTester tester) async { + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + const titleSpacing = 16.0; + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: BackButton(key: leadingKey, onPressed: () {}), + centerTitle: false, + title: Text('Title', key: titleKey), + ), + ), + ), + ); + + final Finder buttonFinder = find.byType(BackButton); + expect(tester.getSize(buttonFinder), const Size(48.0, 48.0)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + expect( + buttonFinder, + paints..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 40.0), + color: theme.colorScheme.onSurface.withOpacity(0.08), + ), + ); + + // Get the offset of the Center widget that wraps the IconButton. + final Offset backButtonOffset = tester.getTopRight( + find.ancestor(of: buttonFinder, matching: find.byType(Center)), + ); + final Offset titleOffset = tester.getTopLeft(find.byKey(titleKey)); + expect(titleOffset.dx, backButtonOffset.dx + titleSpacing); + }); + + testWidgets('AppBar.leading size with custom CloseButton', (WidgetTester tester) async { + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + const titleSpacing = 16.0; + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: CloseButton(key: leadingKey, onPressed: () {}), + centerTitle: false, + title: Text('Title', key: titleKey), + ), + ), + ), + ); + + final Finder buttonFinder = find.byType(CloseButton); + expect(tester.getSize(buttonFinder), const Size(48.0, 48.0)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + expect( + buttonFinder, + paints..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 40.0), + color: theme.colorScheme.onSurface.withOpacity(0.08), + ), + ); + + // Get the offset of the Center widget that wraps the IconButton. + final Offset backButtonOffset = tester.getTopRight( + find.ancestor(of: buttonFinder, matching: find.byType(Center)), + ); + final Offset titleOffset = tester.getTopLeft(find.byKey(titleKey)); + expect(titleOffset.dx, backButtonOffset.dx + titleSpacing); + }); + + testWidgets('AppBar.leading size with custom DrawerButton', (WidgetTester tester) async { + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + const titleSpacing = 16.0; + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: DrawerButton(key: leadingKey, onPressed: () {}), + centerTitle: false, + title: Text('Title', key: titleKey), + ), + ), + ), + ); + + final Finder buttonFinder = find.byType(DrawerButton); + expect(tester.getSize(buttonFinder), const Size(48.0, 48.0)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + expect( + buttonFinder, + paints..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 40.0), + color: theme.colorScheme.onSurface.withOpacity(0.08), + ), + ); + + // Get the offset of the Center widget that wraps the IconButton. + final Offset backButtonOffset = tester.getTopRight( + find.ancestor(of: buttonFinder, matching: find.byType(Center)), + ); + final Offset titleOffset = tester.getTopLeft(find.byKey(titleKey)); + expect(titleOffset.dx, backButtonOffset.dx + titleSpacing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/152315 + testWidgets('AppBar back button navigates to previous page on tap with TooltipTriggerMode.tap', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tooltipTheme: const TooltipThemeData(triggerMode: TooltipTriggerMode.tap)), + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>( + builder: (_) => + Scaffold(appBar: AppBar(title: const Text('Second Screen'))), + ), + ); + }, + child: const Text('Go to second screen'), + ); + }, + ), + ), + ), + ), + ); + + expect(find.text('Second Screen'), findsNothing); + + await tester.tap(find.text('Go to second screen')); + await tester.pumpAndSettle(); + + expect(find.text('Second Screen'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Second Screen'), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/152315 + testWidgets( + 'Material2 - AppBar back button navigates to previous page on tap with TooltipTriggerMode.tap', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + tooltipTheme: const TooltipThemeData(triggerMode: TooltipTriggerMode.tap), + ), + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>( + builder: (_) => + Scaffold(appBar: AppBar(title: const Text('Second Screen'))), + ), + ); + }, + child: const Text('Go to second screen'), + ); + }, + ), + ), + ), + ), + ); + + expect(find.text('Second Screen'), findsNothing); + + await tester.tap(find.text('Go to second screen')); + await tester.pumpAndSettle(); + + expect(find.text('Second Screen'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.text('Second Screen'), findsNothing); + }, + ); + + testWidgets('AppBar actions padding can be adjusted', (WidgetTester tester) async { + final Key appBarKey = UniqueKey(); + final Key actionKey = UniqueKey(); + + Widget buildAppBar({EdgeInsetsGeometry? actionsPadding}) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + key: appBarKey, + actions: <Widget>[SizedBox.square(key: actionKey, dimension: 40.0)], + actionsPadding: actionsPadding, + ), + ), + ); + } + + await tester.pumpWidget(buildAppBar()); + + // Actions padding default to zero padding. + Offset actionsOffset = tester.getTopRight(find.byKey(actionKey)); + final Offset appBarOffset = tester.getTopRight(find.byKey(appBarKey)); + expect(appBarOffset.dx - actionsOffset.dx, 0); + + const actionsPadding = EdgeInsets.only(right: 8.0); + await tester.pumpWidget(buildAppBar(actionsPadding: actionsPadding)); + actionsOffset = tester.getTopRight(find.byKey(actionKey)); + expect(actionsOffset.dx, equals(appBarOffset.dx - actionsPadding.right)); + }); + + group('Material 2', () { + testWidgets('Material2 - AppBar draws a light system bar for a dark background', ( + WidgetTester tester, + ) async { + final darkTheme = ThemeData.dark(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: darkTheme, + home: Scaffold(appBar: AppBar(title: const Text('test'))), + ), + ); + + expect(darkTheme.colorScheme.brightness, Brightness.dark); + expect( + SystemChrome.latestStyle, + const SystemUiOverlayStyle( + statusBarBrightness: Brightness.dark, + statusBarIconBrightness: Brightness.light, + ), + ); + }); + + testWidgets('Material2 - AppBar drawer icon has default color', (WidgetTester tester) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!')), + drawer: const Drawer(), + ), + ), + ); + + expect(_iconStyle(tester, Icons.menu)?.color, themeData.colorScheme.onPrimary); + }); + + testWidgets('Material2 - AppBar endDrawer icon has default color', (WidgetTester tester) async { + final themeData = ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Howdy!')), + endDrawer: const Drawer(), + ), + ), + ); + + expect(_iconStyle(tester, Icons.menu)?.color, themeData.colorScheme.onPrimary); + }); + + testWidgets('Material2 - leading widget extends to edge and is square', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(platform: TargetPlatform.android, useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + title: const Text('X'), + ), + drawer: const Column(), // Doesn't really matter. Triggers a hamburger regardless. + ), + ), + ); + + // Default IconButton has a size of (56x56). + final Finder hamburger = find.byType(IconButton); + expect(tester.getTopLeft(hamburger), Offset.zero); + expect(tester.getSize(hamburger), const Size(56.0, 56.0)); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(leading: Container(), title: const Text('X')), + ), + ), + ); + + // Default leading widget has a size of (56x56). + final Finder leadingBox = find.byType(Container); + expect(tester.getTopLeft(leadingBox), Offset.zero); + expect(tester.getSize(leadingBox), const Size(56.0, 56.0)); + + // The custom leading widget should still be 56x56 even if its size is smaller. + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + leading: const SizedBox(height: 36, width: 36), + title: const Text('X'), + ), // Doesn't really matter. Triggers a hamburger regardless. + ), + ), + ); + + final Finder leading = find.byType(SizedBox); + expect(tester.getTopLeft(leading), Offset.zero); + expect(tester.getSize(leading), const Size(56.0, 56.0)); + }); + + testWidgets('Material2 - Action is 4dp from edge and 48dp min', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android, useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar( + title: const Text('X'), + actions: const <Widget>[ + IconButton( + icon: Icon(Icons.share), + onPressed: null, + tooltip: 'Share', + iconSize: 20.0, + ), + IconButton(icon: Icon(Icons.add), onPressed: null, tooltip: 'Add', iconSize: 60.0), + ], + ), + ), + ), + ); + + final Finder addButton = find.widgetWithIcon(IconButton, Icons.add); + expect(tester.getTopRight(addButton), const Offset(800.0, 0.0)); + // It's still the size it was plus the 2 * 8dp padding from IconButton. + expect(tester.getSize(addButton), const Size(60.0 + 2 * 8.0, 56.0)); + + final Finder shareButton = find.widgetWithIcon(IconButton, Icons.share); + // The 20dp icon is expanded to fill the IconButton's touch target to 48dp. + expect(tester.getSize(shareButton), const Size(48.0, 56.0)); + }); + + testWidgets('Material2 - AppBar uses the specified elevation or defaults to 4.0', ( + WidgetTester tester, + ) async { + Widget buildAppBar([double? elevation]) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + appBar: AppBar(title: const Text('Title'), elevation: elevation), + ), + ); + } + + Material getMaterial() => tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + + // Default elevation should be used for the material. + await tester.pumpWidget(buildAppBar()); + expect(getMaterial().elevation, 4); + + // AppBar should use the specified elevation. + await tester.pumpWidget(buildAppBar(8.0)); + expect(getMaterial().elevation, 8.0); + }); + + testWidgets('Material2 - AppBar ink splash draw on the correct canvas', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/58665 + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + // Test was designed against InkSplash so need to make sure that is used. + theme: ThemeData(useMaterial3: false, splashFactory: InkSplash.splashFactory), + home: Center( + child: AppBar( + title: const Text('Abc'), + actions: <Widget>[ + IconButton( + key: key, + icon: const Icon(Icons.add_circle), + tooltip: 'First button', + onPressed: () {}, + ), + ], + flexibleSpace: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: const Alignment(-0.04, 1.0), + colors: <Color>[Colors.blue.shade500, Colors.blue.shade800], + ), + ), + ), + ), + ), + ), + ); + final RenderObject painter = tester.renderObject( + find.descendant( + of: find.descendant(of: find.byType(AppBar), matching: find.byType(Stack)), + matching: find.byType(Material), + ), + ); + await tester.tap(find.byKey(key)); + expect( + painter, + paints + ..save() + ..translate() + ..save() + ..translate() + ..circle(x: 24.0, y: 28.0), + ); + }); + + testWidgets('Material2 - Default status bar color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + key: GlobalKey(), + theme: ThemeData(useMaterial3: false, appBarTheme: const AppBarThemeData()), + home: Scaffold(appBar: AppBar(title: const Text('title'))), + ), + ); + + expect(SystemChrome.latestStyle!.statusBarColor, null); + }); + + testWidgets('Material2 - AppBar draws a dark system bar for a light background', ( + WidgetTester tester, + ) async { + final lightTheme = ThemeData(primarySwatch: Colors.lightBlue, useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + home: Scaffold(appBar: AppBar(title: const Text('test'))), + ), + ); + + expect(lightTheme.colorScheme.brightness, Brightness.light); + expect( + SystemChrome.latestStyle, + const SystemUiOverlayStyle( + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + ), + ); + }); + + testWidgets( + 'Material2 - Default system bar brightness based on AppBar background color brightness.', + (WidgetTester tester) async { + Widget buildAppBar(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ); + } + + // Using a light theme. + { + await tester.pumpWidget(buildAppBar(ThemeData(useMaterial3: false))); + final Material appBarMaterial = tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + final Brightness appBarBrightness = ThemeData.estimateBrightnessForColor( + appBarMaterial.color!, + ); + final Brightness onAppBarBrightness = appBarBrightness == Brightness.light + ? Brightness.dark + : Brightness.light; + + expect( + SystemChrome.latestStyle, + SystemUiOverlayStyle( + statusBarBrightness: appBarBrightness, + statusBarIconBrightness: onAppBarBrightness, + ), + ); + } + + // Using a dark theme. + { + await tester.pumpWidget(buildAppBar(ThemeData.dark(useMaterial3: false))); + final Material appBarMaterial = tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ); + final Brightness appBarBrightness = ThemeData.estimateBrightnessForColor( + appBarMaterial.color!, + ); + final Brightness onAppBarBrightness = appBarBrightness == Brightness.light + ? Brightness.dark + : Brightness.light; + + expect( + SystemChrome.latestStyle, + SystemUiOverlayStyle( + statusBarBrightness: appBarBrightness, + statusBarIconBrightness: onAppBarBrightness, + ), + ); + } + }, + ); + }); +} diff --git a/packages/material_ui/test/material/app_bar_theme_test.dart b/packages/material_ui/test/material/app_bar_theme_test.dart new file mode 100644 index 000000000000..5f26474d5310 --- /dev/null +++ b/packages/material_ui/test/material/app_bar_theme_test.dart @@ -0,0 +1,1523 @@ +// 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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const appBarTheme = AppBarThemeData( + backgroundColor: Color(0xff00ff00), + foregroundColor: Color(0xff00ffff), + elevation: 4.0, + scrolledUnderElevation: 6.0, + shadowColor: Color(0xff1212ff), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14.0))), + iconTheme: IconThemeData(color: Color(0xffff0000)), + actionsIconTheme: IconThemeData(color: Color(0xff0000ff)), + centerTitle: false, + titleSpacing: 10.0, + titleTextStyle: TextStyle(fontSize: 22.0, fontStyle: FontStyle.italic), + ); + + ScrollController primaryScrollController(WidgetTester tester) { + return PrimaryScrollController.of(tester.element(find.byType(CustomScrollView))); + } + + test('AppBarThemeData copyWith, ==, hashCode basics', () { + expect(const AppBarThemeData(), const AppBarThemeData().copyWith()); + expect(const AppBarThemeData().hashCode, const AppBarThemeData().copyWith().hashCode); + + expect(const AppBarThemeData().backgroundColor, null); + expect(const AppBarThemeData().foregroundColor, null); + expect(const AppBarThemeData().elevation, null); + expect(const AppBarThemeData().scrolledUnderElevation, null); + expect(const AppBarThemeData().shadowColor, null); + expect(const AppBarThemeData().surfaceTintColor, null); + expect(const AppBarThemeData().shape, null); + expect(const AppBarThemeData().iconTheme, null); + expect(const AppBarThemeData().actionsIconTheme, null); + expect(const AppBarThemeData().centerTitle, null); + expect(const AppBarThemeData().titleSpacing, null); + expect(const AppBarThemeData().leadingWidth, null); + expect(const AppBarThemeData().toolbarHeight, null); + expect(const AppBarThemeData().toolbarTextStyle, null); + expect(const AppBarThemeData().titleTextStyle, null); + expect(const AppBarThemeData().systemOverlayStyle, null); + expect(const AppBarThemeData().actionsPadding, null); + }); + + test('AppBarTheme lerp special cases', () { + const data = AppBarTheme(); + expect(identical(AppBarTheme.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Material2 - Passing no AppBarTheme returns defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar( + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ), + ), + ); + + final Material widget = _getAppBarMaterial(tester); + final IconTheme iconTheme = _getAppBarIconTheme(tester); + final IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + final RichText actionIconText = _getAppBarIconRichText(tester); + final DefaultTextStyle text = _getAppBarText(tester); + + expect( + SystemChrome.latestStyle!.statusBarBrightness, + SystemUiOverlayStyle.light.statusBarBrightness, + ); + expect(widget.color, Colors.blue); + expect(widget.elevation, 4.0); + expect(widget.shadowColor, Colors.black); + expect(widget.surfaceTintColor, null); + expect(widget.shape, null); + expect(iconTheme.data, const IconThemeData(color: Colors.white)); + expect(actionsIconTheme.data, const IconThemeData(color: Colors.white)); + expect(actionIconText.text.style!.color, Colors.white); + expect( + text.style, + Typography.material2014().englishLike.bodyMedium!.merge( + Typography.material2014().white.bodyMedium, + ), + ); + expect(tester.getSize(find.byType(AppBar)).height, kToolbarHeight); + expect(tester.getSize(find.byType(AppBar)).width, 800); + }); + + testWidgets('Material3 - Passing no AppBarTheme returns defaults', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar( + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ), + ), + ); + + final Material widget = _getAppBarMaterial(tester); + final IconTheme iconTheme = _getAppBarIconTheme(tester); + final IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + final RichText actionIconText = _getAppBarIconRichText(tester); + final DefaultTextStyle text = _getAppBarText(tester); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.light); + expect(widget.color, theme.colorScheme.surface); + expect(widget.elevation, 0); + expect(widget.shadowColor, Colors.transparent); + expect(widget.surfaceTintColor, theme.colorScheme.surfaceTint); + expect(widget.shape, null); + expect(iconTheme.data, IconThemeData(color: theme.colorScheme.onSurface, size: 24)); + expect( + actionsIconTheme.data, + IconThemeData(color: theme.colorScheme.onSurfaceVariant, size: 24), + ); + expect(actionIconText.text.style!.color, theme.colorScheme.onSurfaceVariant); + expect( + text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .copyWith( + color: theme.colorScheme.onSurface, + decorationColor: theme.colorScheme.onSurface, + ), + ); + expect(tester.getSize(find.byType(AppBar)).height, kToolbarHeight); + expect(tester.getSize(find.byType(AppBar)).width, 800); + }); + + testWidgets('AppBar uses values from AppBarTheme', (WidgetTester tester) async { + final AppBarThemeData appBarTheme = _appBarTheme(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: Scaffold( + appBar: AppBar( + title: const Text('App Bar Title'), + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ), + ), + ); + + final Material widget = _getAppBarMaterial(tester); + final IconTheme iconTheme = _getAppBarIconTheme(tester); + final IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + final RichText actionIconText = _getAppBarIconRichText(tester); + final DefaultTextStyle text = _getAppBarText(tester); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.light); + expect(widget.color, appBarTheme.backgroundColor); + expect(widget.elevation, appBarTheme.elevation); + expect(widget.shadowColor, appBarTheme.shadowColor); + expect(widget.surfaceTintColor, appBarTheme.surfaceTintColor); + expect(widget.shape, const StadiumBorder()); + expect(iconTheme.data, appBarTheme.iconTheme); + expect(actionsIconTheme.data, appBarTheme.actionsIconTheme); + expect(actionIconText.text.style!.color, appBarTheme.actionsIconTheme!.color); + expect(text.style, appBarTheme.toolbarTextStyle); + expect(tester.getSize(find.byType(AppBar)).height, appBarTheme.toolbarHeight); + expect(tester.getSize(find.byType(AppBar)).width, 800); + }); + + testWidgets('AppBar widget properties take priority over theme', (WidgetTester tester) async { + const Brightness brightness = Brightness.dark; + const SystemUiOverlayStyle systemOverlayStyle = SystemUiOverlayStyle.light; + const Color color = Colors.orange; + const elevation = 3.0; + const Color shadowColor = Colors.purple; + const Color surfaceTintColor = Colors.brown; + const ShapeBorder shape = RoundedRectangleBorder(); + const iconThemeData = IconThemeData(color: Colors.green); + const actionsIconThemeData = IconThemeData(color: Colors.lightBlue); + const toolbarTextStyle = TextStyle(color: Colors.pink); + const titleTextStyle = TextStyle(color: Colors.orange); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(appBarTheme: _appBarTheme()), + home: Scaffold( + appBar: AppBar( + backgroundColor: color, + systemOverlayStyle: systemOverlayStyle, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + iconTheme: iconThemeData, + actionsIconTheme: actionsIconThemeData, + toolbarTextStyle: toolbarTextStyle, + titleTextStyle: titleTextStyle, + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ), + ), + ); + + final Material widget = _getAppBarMaterial(tester); + final IconTheme iconTheme = _getAppBarIconTheme(tester); + final IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + final RichText actionIconText = _getAppBarIconRichText(tester); + final DefaultTextStyle text = _getAppBarText(tester); + + expect(SystemChrome.latestStyle!.statusBarBrightness, brightness); + expect(widget.color, color); + expect(widget.elevation, elevation); + expect(widget.shadowColor, shadowColor); + expect(widget.surfaceTintColor, surfaceTintColor); + expect(widget.shape, shape); + expect(iconTheme.data, iconThemeData); + expect(actionsIconTheme.data, actionsIconThemeData); + expect(actionIconText.text.style!.color, actionsIconThemeData.color); + expect(text.style, toolbarTextStyle); + }); + + testWidgets('AppBar icon color takes priority over everything', (WidgetTester tester) async { + const Color color = Colors.lime; + const iconThemeData = IconThemeData(color: Colors.green); + const actionsIconThemeData = IconThemeData(color: Colors.lightBlue); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + appBar: AppBar( + iconTheme: iconThemeData, + actionsIconTheme: actionsIconThemeData, + actions: <Widget>[ + IconButton(icon: const Icon(Icons.share), color: color, onPressed: () {}), + ], + ), + ), + ), + ); + + final RichText actionIconText = _getAppBarIconRichText(tester); + expect(actionIconText.text.style!.color, color); + }); + + testWidgets('AppBarTheme properties take priority over ThemeData properties', ( + WidgetTester tester, + ) async { + final AppBarThemeData appBarTheme = _appBarTheme(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(appBarTheme: _appBarTheme()), + home: Scaffold( + appBar: AppBar( + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ), + ), + ); + + final Material widget = _getAppBarMaterial(tester); + final IconTheme iconTheme = _getAppBarIconTheme(tester); + final IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + final RichText actionIconText = _getAppBarIconRichText(tester); + final DefaultTextStyle text = _getAppBarText(tester); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.light); + expect(widget.color, appBarTheme.backgroundColor); + expect(widget.elevation, appBarTheme.elevation); + expect(widget.shadowColor, appBarTheme.shadowColor); + expect(widget.surfaceTintColor, appBarTheme.surfaceTintColor); + expect(iconTheme.data, appBarTheme.iconTheme); + expect(actionsIconTheme.data, appBarTheme.actionsIconTheme); + expect(actionIconText.text.style!.color, appBarTheme.actionsIconTheme!.color); + expect(text.style, appBarTheme.toolbarTextStyle); + }); + + testWidgets('Material2 - ThemeData colorScheme is used when no AppBarTheme is set', ( + WidgetTester tester, + ) async { + final lightTheme = ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: false); + final darkTheme = ThemeData.from(colorScheme: const ColorScheme.dark(), useMaterial3: false); + Widget buildFrame(ThemeData appTheme) { + return MaterialApp( + theme: appTheme, + home: Builder( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ); + }, + ), + ); + } + + // AppBar M2 defaults for light themes: + // - elevation: 4 + // - shadow color: black + // - surface tint color: null + // - background color: ColorScheme.primary + // - foreground color: ColorScheme.onPrimary + // - actions text: style bodyMedium, foreground color + // - status bar brightness: light (based on color scheme brightness) + await tester.pumpWidget(buildFrame(lightTheme)); + + Material widget = _getAppBarMaterial(tester); + IconTheme iconTheme = _getAppBarIconTheme(tester); + IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + RichText actionIconText = _getAppBarIconRichText(tester); + DefaultTextStyle text = _getAppBarText(tester); + + expect( + SystemChrome.latestStyle!.statusBarBrightness, + SystemUiOverlayStyle.light.statusBarBrightness, + ); + expect(widget.color, lightTheme.colorScheme.primary); + expect(widget.elevation, 4.0); + expect(widget.shadowColor, Colors.black); + expect(widget.surfaceTintColor, null); + expect(iconTheme.data.color, lightTheme.colorScheme.onPrimary); + expect(actionsIconTheme.data.color, lightTheme.colorScheme.onPrimary); + expect(actionIconText.text.style!.color, lightTheme.colorScheme.onPrimary); + expect( + text.style, + Typography.material2014().englishLike.bodyMedium! + .merge(Typography.material2014().black.bodyMedium) + .copyWith(color: lightTheme.colorScheme.onPrimary), + ); + + // AppBar M2 defaults for dark themes: + // - elevation: 4 + // - shadow color: black + // - surface tint color: null + // - background color: ColorScheme.surface + // - foreground color: ColorScheme.onSurface + // - actions text: style bodyMedium, foreground color + // - status bar brightness: dark (based on background color) + await tester.pumpWidget(buildFrame(darkTheme)); + await tester.pumpAndSettle(); // Theme change animation + + widget = _getAppBarMaterial(tester); + iconTheme = _getAppBarIconTheme(tester); + actionsIconTheme = _getAppBarActionsIconTheme(tester); + actionIconText = _getAppBarIconRichText(tester); + text = _getAppBarText(tester); + + expect( + SystemChrome.latestStyle!.statusBarBrightness, + SystemUiOverlayStyle.light.statusBarBrightness, + ); + expect(widget.color, darkTheme.colorScheme.surface); + expect(widget.elevation, 4.0); + expect(widget.shadowColor, Colors.black); + expect(widget.surfaceTintColor, null); + expect(iconTheme.data.color, darkTheme.colorScheme.onSurface); + expect(actionsIconTheme.data.color, darkTheme.colorScheme.onSurface); + expect(actionIconText.text.style!.color, darkTheme.colorScheme.onSurface); + expect( + text.style, + Typography.material2014().englishLike.bodyMedium! + .merge(Typography.material2014().black.bodyMedium) + .copyWith(color: darkTheme.colorScheme.onSurface), + ); + }); + + testWidgets('Material3 - ThemeData colorScheme is used when no AppBarTheme is set', ( + WidgetTester tester, + ) async { + final lightTheme = ThemeData.from(colorScheme: const ColorScheme.light()); + final darkTheme = ThemeData.from(colorScheme: const ColorScheme.dark()); + Widget buildFrame(ThemeData appTheme) { + return MaterialApp( + theme: appTheme, + home: Builder( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ); + }, + ), + ); + } + + // M3 AppBar defaults for light themes: + // - elevation: 0 + // - shadow color: Colors.transparent + // - surface tint color: ColorScheme.surfaceTint + // - background color: ColorScheme.surface + // - foreground color: ColorScheme.onSurface + // - actions text: style bodyMedium, foreground color + // - status bar brightness: light (based on color scheme brightness) + await tester.pumpWidget(buildFrame(lightTheme)); + + Material widget = _getAppBarMaterial(tester); + IconTheme iconTheme = _getAppBarIconTheme(tester); + IconTheme actionsIconTheme = _getAppBarActionsIconTheme(tester); + RichText actionIconText = _getAppBarIconRichText(tester); + DefaultTextStyle text = _getAppBarText(tester); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.light); + expect(widget.color, lightTheme.colorScheme.surface); + expect(widget.elevation, 0); + expect(widget.shadowColor, Colors.transparent); + expect(widget.surfaceTintColor, lightTheme.colorScheme.surfaceTint); + expect(iconTheme.data.color, lightTheme.colorScheme.onSurface); + expect(actionsIconTheme.data.color, lightTheme.colorScheme.onSurface); + expect(actionIconText.text.style!.color, lightTheme.colorScheme.onSurface); + expect( + text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .copyWith(color: lightTheme.colorScheme.onSurface), + ); + + // M3 AppBar defaults for dark themes: + // - elevation: 0 + // - shadow color: Colors.transparent + // - surface tint color: ColorScheme.surfaceTint + // - background color: ColorScheme.surface + // - foreground color: ColorScheme.onSurface + // - actions text: style bodyMedium, foreground color + // - status bar brightness: dark (based on background color) + await tester.pumpWidget(buildFrame(darkTheme)); + await tester.pumpAndSettle(); // Theme change animation + + widget = _getAppBarMaterial(tester); + iconTheme = _getAppBarIconTheme(tester); + actionsIconTheme = _getAppBarActionsIconTheme(tester); + actionIconText = _getAppBarIconRichText(tester); + text = _getAppBarText(tester); + + expect(SystemChrome.latestStyle!.statusBarBrightness, Brightness.dark); + expect(widget.color, darkTheme.colorScheme.surface); + expect(widget.elevation, 0); + expect(widget.shadowColor, Colors.transparent); + expect(widget.surfaceTintColor, darkTheme.colorScheme.surfaceTint); + expect(iconTheme.data.color, darkTheme.colorScheme.onSurface); + expect(actionsIconTheme.data.color, darkTheme.colorScheme.onSurface); + expect(actionIconText.text.style!.color, darkTheme.colorScheme.onSurface); + expect( + text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .copyWith( + color: darkTheme.colorScheme.onSurface, + decorationColor: darkTheme.colorScheme.onSurface, + ), + ); + }); + + testWidgets('AppBar iconTheme with color=null defers to outer IconTheme', ( + WidgetTester tester, + ) async { + // Verify claim made in https://github.com/flutter/flutter/pull/71184#issuecomment-737419215 + + Widget buildFrame({Color? appIconColor, Color? appBarIconColor}) { + return MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: IconTheme( + data: IconThemeData(color: appIconColor), + child: Builder( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar( + iconTheme: IconThemeData(color: appBarIconColor), + actions: <Widget>[IconButton(icon: const Icon(Icons.share), onPressed: () {})], + ), + ); + }, + ), + ), + ); + } + + RichText getIconText() { + return tester.widget<RichText>( + find.descendant(of: find.byType(Icon), matching: find.byType(RichText)), + ); + } + + await tester.pumpWidget(buildFrame(appIconColor: Colors.lime)); + await tester.pumpAndSettle(); + expect(getIconText().text.style!.color, Colors.lime); + + await tester.pumpWidget(buildFrame(appIconColor: Colors.lime, appBarIconColor: Colors.purple)); + await tester.pumpAndSettle(); + expect(getIconText().text.style!.color, Colors.purple); + }); + + testWidgets('AppBar uses AppBarTheme.centerTitle when centerTitle is null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(centerTitle: true)), + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolBar.centerMiddle, true); + }); + + testWidgets('AppBar.centerTitle takes priority over AppBarTheme.centerTitle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(centerTitle: true)), + home: Scaffold(appBar: AppBar(title: const Text('Title'), centerTitle: false)), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + // The AppBar.centerTitle should be used instead of AppBarThemeData.centerTitle. + expect(navToolBar.centerMiddle, false); + }); + + testWidgets( + 'AppBar.centerTitle adapts to TargetPlatform when AppBarThemeData.centerTitle is null', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + // When ThemeData.platform is TargetPlatform.iOS, and AppBarThemeData is null, + // the value of NavigationToolBar.centerMiddle should be true. + expect(navToolBar.centerMiddle, true); + }, + ); + + testWidgets('AppBar.shadowColor takes priority over AppBarThemeData.shadowColor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(shadowColor: Colors.red)), + home: Scaffold( + appBar: AppBar(title: const Text('Title'), shadowColor: Colors.yellow), + ), + ), + ); + + final AppBar appBar = tester.widget(find.byType(AppBar)); + // The AppBar.shadowColor should be used instead of AppBarThemeData.shadowColor. + expect(appBar.shadowColor, Colors.yellow); + }); + + testWidgets('AppBar.surfaceTintColor takes priority over AppBarThemeData.surfaceTintColor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(surfaceTintColor: Colors.red)), + home: Scaffold( + appBar: AppBar(title: const Text('Title'), surfaceTintColor: Colors.yellow), + ), + ), + ); + + final AppBar appBar = tester.widget(find.byType(AppBar)); + // The AppBar.surfaceTintColor should be used instead of AppBarThemeData.surfaceTintColor. + expect(appBar.surfaceTintColor, Colors.yellow); + }); + + testWidgets( + 'Material3 - AppBarThemeData.iconTheme.color takes priority over IconButtonTheme.foregroundColor', + (WidgetTester tester) async { + const overallIconTheme = IconThemeData(color: Colors.yellow); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red), + ), + appBarTheme: const AppBarThemeData(iconTheme: overallIconTheme), + ), + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + title: const Text('Title'), + ), + ), + ), + ); + + final Color? leadingIconButtonColor = _iconStyle(tester, Icons.menu)?.color; + final Color? actionIconButtonColor = _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor, overallIconTheme.color); + expect(actionIconButtonColor, overallIconTheme.color); + }, + ); + + testWidgets( + 'Material3 - AppBarThemeData.iconTheme.size takes priority over IconButtonTheme.iconSize', + (WidgetTester tester) async { + const overallIconTheme = IconThemeData(size: 30.0); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + iconButtonTheme: IconButtonThemeData(style: IconButton.styleFrom(iconSize: 32.0)), + appBarTheme: const AppBarThemeData(iconTheme: overallIconTheme), + ), + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + title: const Text('Title'), + ), + ), + ), + ); + + final double? leadingIconButtonSize = _iconStyle(tester, Icons.menu)?.fontSize; + final double? actionIconButtonSize = _iconStyle(tester, Icons.add)?.fontSize; + + expect(leadingIconButtonSize, overallIconTheme.size); + expect(actionIconButtonSize, overallIconTheme.size); + }, + ); + + testWidgets( + 'Material3 - AppBarThemeData.actionsIconTheme.color takes priority over IconButtonTheme.foregroundColor', + (WidgetTester tester) async { + const actionsIconTheme = IconThemeData(color: Colors.yellow); + final iconButtonTheme = IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + iconButtonTheme: iconButtonTheme, + appBarTheme: const AppBarThemeData(actionsIconTheme: actionsIconTheme), + ), + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + title: const Text('Title'), + ), + ), + ), + ); + + final Color? leadingIconButtonColor = _iconStyle(tester, Icons.menu)?.color; + final Color? actionIconButtonColor = _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor, Colors.red); // leading color should come from iconButtonTheme + expect(actionIconButtonColor, actionsIconTheme.color); + }, + ); + + testWidgets( + 'Material3 - AppBarThemeData.actionsIconTheme.size takes priority over IconButtonTheme.iconSize', + (WidgetTester tester) async { + const actionsIconTheme = IconThemeData(size: 30.0); + final iconButtonTheme = IconButtonThemeData(style: IconButton.styleFrom(iconSize: 32.0)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + iconButtonTheme: iconButtonTheme, + appBarTheme: const AppBarThemeData(actionsIconTheme: actionsIconTheme), + ), + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + title: const Text('Title'), + ), + ), + ), + ); + + final double? leadingIconButtonSize = _iconStyle(tester, Icons.menu)?.fontSize; + final double? actionIconButtonSize = _iconStyle(tester, Icons.add)?.fontSize; + + expect( + leadingIconButtonSize, + 32.0, + ); // The size of leading icon button should come from iconButtonTheme + expect(actionIconButtonSize, actionsIconTheme.size); + }, + ); + + testWidgets( + 'Material3 - AppBarThemeData.foregroundColor takes priority over IconButtonTheme.foregroundColor', + (WidgetTester tester) async { + final iconButtonTheme = IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.red), + ); + const appBarTheme = AppBarThemeData(foregroundColor: Colors.green); + final themeData = ThemeData(iconButtonTheme: iconButtonTheme, appBarTheme: appBarTheme); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + title: const Text('title'), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + final Color? leadingIconButtonColor = _iconStyle(tester, Icons.menu)?.color; + final Color? actionIconButtonColor = _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor, appBarTheme.foregroundColor); + expect(actionIconButtonColor, appBarTheme.foregroundColor); + }, + ); + + testWidgets('AppBar uses AppBarThemeData.titleSpacing', (WidgetTester tester) async { + const double kTitleSpacing = 10; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(titleSpacing: kTitleSpacing)), + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolBar.middleSpacing, kTitleSpacing); + }); + + testWidgets('AppBar.titleSpacing takes priority over AppBarThemeData.titleSpacing', ( + WidgetTester tester, + ) async { + const double kTitleSpacing = 10; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(titleSpacing: kTitleSpacing)), + home: Scaffold(appBar: AppBar(title: const Text('Title'), titleSpacing: 40)), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolBar.middleSpacing, 40); + }); + + testWidgets('AppBar uses AppBarThemeData.leadingWidth', (WidgetTester tester) async { + const double kLeadingWidth = 80; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(leadingWidth: kLeadingWidth)), + home: Scaffold(appBar: AppBar(leading: const Icon(Icons.chevron_left))), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + final BoxConstraints leadingConstraints = (navToolBar.leading! as ConstrainedBox).constraints; + expect(leadingConstraints.maxWidth, kLeadingWidth); + expect(leadingConstraints.minWidth, kLeadingWidth); + }); + + testWidgets('AppBar.leadingWidth takes priority over AppBarThemeData.leadingWidth', ( + WidgetTester tester, + ) async { + const double kLeadingWidth = 80; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(leadingWidth: kLeadingWidth)), + home: Scaffold(appBar: AppBar(leading: const Icon(Icons.chevron_left), leadingWidth: 40)), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + final BoxConstraints leadingConstraints = (navToolBar.leading! as ConstrainedBox).constraints; + expect(leadingConstraints.maxWidth, 40); + expect(leadingConstraints.minWidth, 40); + }); + + testWidgets('SliverAppBar uses AppBarThemeData.titleSpacing', (WidgetTester tester) async { + const double kTitleSpacing = 10; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(titleSpacing: kTitleSpacing)), + home: const CustomScrollView(slivers: <Widget>[SliverAppBar(title: Text('Title'))]), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolBar.middleSpacing, kTitleSpacing); + }); + + testWidgets('SliverAppBar.titleSpacing takes priority over AppBarThemeData.titleSpacing ', ( + WidgetTester tester, + ) async { + const double kTitleSpacing = 10; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(titleSpacing: kTitleSpacing)), + home: const CustomScrollView( + slivers: <Widget>[SliverAppBar(title: Text('Title'), titleSpacing: 40)], + ), + ), + ); + + final NavigationToolbar navToolbar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolbar.middleSpacing, 40); + }); + + testWidgets('SliverAppBar uses AppBarThemeData.leadingWidth', (WidgetTester tester) async { + const double kLeadingWidth = 80; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(leadingWidth: kLeadingWidth)), + home: const CustomScrollView( + slivers: <Widget>[SliverAppBar(leading: Icon(Icons.chevron_left))], + ), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + final BoxConstraints leadingConstraints = (navToolBar.leading! as ConstrainedBox).constraints; + expect(leadingConstraints.maxWidth, kLeadingWidth); + expect(leadingConstraints.minWidth, kLeadingWidth); + }); + + testWidgets('SliverAppBar.leadingWidth takes priority over AppBarThemeData.leadingWidth ', ( + WidgetTester tester, + ) async { + const double kLeadingWidth = 80; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: const AppBarThemeData(leadingWidth: kLeadingWidth)), + home: const CustomScrollView( + slivers: <Widget>[SliverAppBar(leading: Icon(Icons.chevron_left), leadingWidth: 40)], + ), + ), + ); + + final NavigationToolbar navToolBar = tester.widget(find.byType(NavigationToolbar)); + final BoxConstraints leadingConstraints = (navToolBar.leading! as ConstrainedBox).constraints; + expect(leadingConstraints.maxWidth, 40); + expect(leadingConstraints.minWidth, 40); + }); + + testWidgets('SliverAppBar.medium uses AppBarThemeData properties', (WidgetTester tester) async { + const title = 'Medium App Bar'; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.medium( + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title), + actions: <Widget>[IconButton(onPressed: () {}, icon: const Icon(Icons.search))], + ), + ], + ), + ), + ); + + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style!.fontSize, appBarTheme.titleTextStyle!.fontSize); + expect(titleText.text.style!.fontStyle, appBarTheme.titleTextStyle!.fontStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(SliverAppBar), matching: find.byType(Material).first), + ); + expect(material.color, appBarTheme.backgroundColor); + expect(material.shadowColor, appBarTheme.shadowColor); + expect(material.shape, appBarTheme.shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, appBarTheme.actionsIconTheme!.color); + + // Scroll to collapse the SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconOffset.dx + appBarTheme.titleSpacing!); + }); + + testWidgets('SliverAppBar.medium properties take priority over AppBarThemeData properties', ( + WidgetTester tester, + ) async { + const title = 'Medium App Bar'; + const backgroundColor = Color(0xff000099); + const foregroundColor = Color(0xff00ff98); + const shadowColor = Color(0xff00ff97); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only(bottomStart: Radius.circular(12.0)), + ); + const iconTheme = IconThemeData(color: Color(0xff00ff96)); + const actionsIconTheme = IconThemeData(color: Color(0xff00ff95)); + const titleSpacing = 18.0; + const titleTextStyle = TextStyle(fontSize: 22.9, fontStyle: FontStyle.italic); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.medium( + centerTitle: false, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + shadowColor: shadowColor, + shape: shape, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + titleSpacing: titleSpacing, + titleTextStyle: titleTextStyle, + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title), + actions: <Widget>[IconButton(onPressed: () {}, icon: const Icon(Icons.search))], + ), + ], + ), + ), + ); + + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style, titleTextStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(SliverAppBar), matching: find.byType(Material).first), + ); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.shape, shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, actionsIconTheme.color); + + // Scroll to collapse the SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconOffset.dx + titleSpacing); + }); + + testWidgets('SliverAppBar.large uses AppBarThemeData properties', (WidgetTester tester) async { + const title = 'Large App Bar'; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.large( + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title), + actions: <Widget>[IconButton(onPressed: () {}, icon: const Icon(Icons.search))], + ), + ], + ), + ), + ); + + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style!.fontSize, appBarTheme.titleTextStyle!.fontSize); + expect(titleText.text.style!.fontStyle, appBarTheme.titleTextStyle!.fontStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(SliverAppBar), matching: find.byType(Material).first), + ); + expect(material.color, appBarTheme.backgroundColor); + expect(material.shadowColor, appBarTheme.shadowColor); + expect(material.shape, appBarTheme.shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, appBarTheme.actionsIconTheme!.color); + + // Scroll to collapse the SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconOffset.dx + appBarTheme.titleSpacing!); + }); + + testWidgets('SliverAppBar.large properties take priority over AppBarThemeData properties', ( + WidgetTester tester, + ) async { + const title = 'Large App Bar'; + const backgroundColor = Color(0xff000099); + const foregroundColor = Color(0xff00ff98); + const shadowColor = Color(0xff00ff97); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only(bottomStart: Radius.circular(12.0)), + ); + const iconTheme = IconThemeData(color: Color(0xff00ff96)); + const actionsIconTheme = IconThemeData(color: Color(0xff00ff95)); + const titleSpacing = 18.0; + const titleTextStyle = TextStyle(fontSize: 22.9, fontStyle: FontStyle.italic); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.large( + centerTitle: false, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + shadowColor: shadowColor, + shape: shape, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + titleSpacing: titleSpacing, + titleTextStyle: titleTextStyle, + leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)), + title: const Text(title), + actions: <Widget>[IconButton(onPressed: () {}, icon: const Icon(Icons.search))], + ), + ], + ), + ), + ); + + // Test title. + final RichText titleText = tester.firstWidget(find.byType(RichText)); + expect(titleText.text.style, titleTextStyle); + + // Test background color, shadow color, and shape. + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(SliverAppBar), matching: find.byType(Material).first), + ); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.shape, shape); + + final RichText actionIcon = tester.widget(find.byType(RichText).last); + expect(actionIcon.text.style!.color, actionsIconTheme.color); + + // Scroll to collapse the SliverAppBar. + final ScrollController controller = primaryScrollController(tester); + controller.jumpTo(120); + await tester.pumpAndSettle(); + + // Test title spacing. + final Finder collapsedTitle = find.text(title).last; + final Offset titleOffset = tester.getTopLeft(collapsedTitle); + final Offset iconOffset = tester.getTopRight( + find.ancestor( + of: find.widgetWithIcon(IconButton, Icons.menu), + matching: find.byType(ConstrainedBox), + ), + ); + expect(titleOffset.dx, iconOffset.dx + titleSpacing); + }); + + testWidgets('SliverAppBar medium & large supports foregroundColor', (WidgetTester tester) async { + const title = 'AppBar title'; + const appBarTheme = AppBarThemeData(foregroundColor: Color(0xff00ff20)); + const foregroundColor = Color(0xff001298); + + Widget buildWidget({Color? color, AppBarThemeData? appBarTheme}) { + return MaterialApp( + theme: ThemeData(appBarTheme: appBarTheme), + home: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverAppBar.medium(foregroundColor: color, title: const Text(title)), + SliverAppBar.large(foregroundColor: color, title: const Text(title)), + ], + ), + ); + } + + await tester.pumpWidget(buildWidget(appBarTheme: appBarTheme)); + + // Test AppBarThemeData.foregroundColor parameter. + RichText mediumTitle = tester.widget(find.byType(RichText).first); + expect(mediumTitle.text.style!.color, appBarTheme.foregroundColor); + RichText largeTitle = tester.widget(find.byType(RichText).first); + expect(largeTitle.text.style!.color, appBarTheme.foregroundColor); + + await tester.pumpWidget(buildWidget(color: foregroundColor, appBarTheme: appBarTheme)); + + // Test foregroundColor parameter. + mediumTitle = tester.widget(find.byType(RichText).first); + expect(mediumTitle.text.style!.color, foregroundColor); + largeTitle = tester.widget(find.byType(RichText).first); + expect(largeTitle.text.style!.color, foregroundColor); + }); + + testWidgets('Default AppBarThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const AppBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('AppBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const AppBarThemeData( + backgroundColor: Color(0xff000000), + foregroundColor: Color(0xff000001), + elevation: 8.0, + scrolledUnderElevation: 3, + shadowColor: Color(0xff000002), + surfaceTintColor: Color(0xff000003), + shape: StadiumBorder(), + iconTheme: IconThemeData(color: Color(0xff000004)), + actionsIconTheme: IconThemeData(color: Color(0xff000004)), + centerTitle: true, + titleSpacing: 40.0, + leadingWidth: 96, + toolbarHeight: 96, + toolbarTextStyle: TextStyle(color: Color(0xff000005)), + titleTextStyle: TextStyle(color: Color(0xff000006)), + systemOverlayStyle: SystemUiOverlayStyle(systemNavigationBarColor: Color(0xff000007)), + actionsPadding: EdgeInsets.symmetric(horizontal: 8.0), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'backgroundColor: ${const Color(0xff000000)}', + 'foregroundColor: ${const Color(0xff000001)}', + 'elevation: 8.0', + 'scrolledUnderElevation: 3.0', + 'shadowColor: ${const Color(0xff000002)}', + 'surfaceTintColor: ${const Color(0xff000003)}', + 'shape: StadiumBorder(BorderSide(width: 0.0, style: none))', + 'iconTheme: IconThemeData#00000(color: ${const Color(0xff000004)})', + 'actionsIconTheme: IconThemeData#00000(color: ${const Color(0xff000004)})', + 'centerTitle: true', + 'titleSpacing: 40.0', + 'leadingWidth: 96.0', + 'toolbarHeight: 96.0', + 'toolbarTextStyle: TextStyle(inherit: true, color: ${const Color(0xff000005)})', + 'titleTextStyle: TextStyle(inherit: true, color: ${const Color(0xff000006)})', + 'systemOverlayStyle: SystemUiOverlayStyle(systemNavigationBarColor: ${const Color(0xff000007)})', + 'actionsPadding: EdgeInsets(8.0, 0.0, 8.0, 0.0)', + ]), + ); + + // On the web, Dart doubles and ints are backed by the same kind of object because + // JavaScript does not support integers. So, the Dart double "4.0" is identical + // to "4", which results in the web evaluating to the value "4" regardless of which + // one is used. This results in a difference for doubles in debugFillProperties between + // the web and the rest of Flutter's target platforms. + }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/87364 + + testWidgets('Local AppBarTheme overrides defaults', (WidgetTester tester) async { + const Color backgroundColor = Colors.blueAccent; + const Color foregroundColor = Colors.white; + const elevation = 1.0; + const scrolledUnderElevation = 2.0; + const Color shadowColor = Colors.black87; + const Color surfaceTintColor = Colors.transparent; + const ShapeBorder shape = RoundedRectangleBorder(); + const iconTheme = IconThemeData(color: Colors.red); + const actionsIconTheme = IconThemeData(color: Color(0xFF6750A4)); + const centerTitle = true; + const titleSpacing = 20.0; + const leadingWidth = 80.0; + const toolbarHeight = 100.0; + const toolbarTextStyle = TextStyle(color: Colors.yellow); + const titleTextStyle = TextStyle(color: Colors.orange); + const SystemUiOverlayStyle systemOverlayStyle = SystemUiOverlayStyle.dark; + const EdgeInsetsGeometry actionsPadding = EdgeInsets.all(8); + + const appbarThemeData = AppBarThemeData( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + elevation: elevation, + scrolledUnderElevation: scrolledUnderElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + centerTitle: centerTitle, + titleSpacing: titleSpacing, + leadingWidth: leadingWidth, + toolbarHeight: toolbarHeight, + toolbarTextStyle: toolbarTextStyle, + titleTextStyle: titleTextStyle, + systemOverlayStyle: systemOverlayStyle, + actionsPadding: actionsPadding, + ); + + await tester.pumpWidget( + MaterialApp( + home: AppBarTheme( + data: appbarThemeData, + child: Scaffold( + appBar: AppBar( + title: const Text('Title'), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ), + ); + + final Material material = _getAppBarMaterial(tester); + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shadowColor, shadowColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shape, shape); + + final IconTheme leadingIconTheme = _getAppBarIconTheme(tester); + expect(leadingIconTheme.data, iconTheme); + + final IconTheme actionsIconThemeWidget = _getAppBarActionsIconTheme(tester); + expect(actionsIconThemeWidget.data.color, appbarThemeData.actionsIconTheme!.color); + expect(actionsIconThemeWidget.data.size, appbarThemeData.actionsIconTheme!.size); + + final NavigationToolbar navToolbar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolbar.centerMiddle, centerTitle); + expect(navToolbar.middleSpacing, titleSpacing); + + final BoxConstraints leadingConstraints = (navToolbar.leading! as ConstrainedBox).constraints; + expect(leadingConstraints.maxWidth, leadingWidth); + expect(leadingConstraints.minWidth, leadingWidth); + + expect(tester.getSize(find.byType(AppBar)).height, toolbarHeight); + + final DefaultTextStyle text = _getAppBarText(tester); + expect(text.style, toolbarTextStyle); + + final RichText titleText = tester.widget<RichText>( + find.descendant(of: find.text('Title'), matching: find.byType(RichText)), + ); + expect(titleText.text.style, titleTextStyle); + + expect(SystemChrome.latestStyle, systemOverlayStyle); + + final Padding actionsPaddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(NavigationToolbar), matching: find.byType(Padding).last), + ); + expect(actionsPaddingWidget.padding, actionsPadding); + + final Size appBarSize = tester.getSize(find.byType(AppBar)); + expect(appBarSize.height, toolbarHeight); + }); + + testWidgets('Local AppBarTheme can override global AppBarTheme', (WidgetTester tester) async { + const Color backgroundColor = Colors.blueAccent; + const Color foregroundColor = Colors.white; + const elevation = 1.0; + const scrolledUnderElevation = 2.0; + const Color shadowColor = Colors.black87; + const Color surfaceTintColor = Colors.transparent; + const ShapeBorder shape = RoundedRectangleBorder(); + const iconTheme = IconThemeData(color: Colors.red); + const actionsIconTheme = IconThemeData(color: Color(0xFF6750A4)); + const centerTitle = true; + const titleSpacing = 20.0; + const leadingWidth = 80.0; + const toolbarHeight = 100.0; + const toolbarTextStyle = TextStyle(color: Colors.yellow); + const titleTextStyle = TextStyle(color: Colors.orange); + const SystemUiOverlayStyle systemOverlayStyle = SystemUiOverlayStyle.dark; + const EdgeInsetsGeometry actionsPadding = EdgeInsets.all(8); + + const appbarThemeData = AppBarThemeData( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + elevation: elevation, + scrolledUnderElevation: scrolledUnderElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + iconTheme: iconTheme, + actionsIconTheme: actionsIconTheme, + centerTitle: centerTitle, + titleSpacing: titleSpacing, + leadingWidth: leadingWidth, + toolbarHeight: toolbarHeight, + toolbarTextStyle: toolbarTextStyle, + titleTextStyle: titleTextStyle, + systemOverlayStyle: systemOverlayStyle, + actionsPadding: actionsPadding, + ); + const globalAppbarThemeData = AppBarThemeData( + backgroundColor: Colors.red, + foregroundColor: Colors.green, + elevation: 0.0, + scrolledUnderElevation: 0.0, + shadowColor: Colors.blue, + surfaceTintColor: Colors.yellow, + shape: RoundedRectangleBorder(), + iconTheme: IconThemeData(color: Colors.black), + actionsIconTheme: IconThemeData(color: Colors.purple), + centerTitle: false, + titleSpacing: 10.0, + leadingWidth: 50.0, + toolbarHeight: 50.0, + toolbarTextStyle: TextStyle(color: Colors.white), + titleTextStyle: TextStyle(color: Colors.black), + systemOverlayStyle: SystemUiOverlayStyle.light, + actionsPadding: EdgeInsets.zero, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(appBarTheme: globalAppbarThemeData), + home: AppBarTheme( + data: appbarThemeData, + child: Scaffold( + appBar: AppBar( + title: const Text('Title'), + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ), + ); + + final Material material = _getAppBarMaterial(tester); + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shadowColor, shadowColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shape, shape); + + final IconTheme leadingIconTheme = _getAppBarIconTheme(tester); + expect(leadingIconTheme.data, iconTheme); + + final IconTheme actionsIconThemeWidget = _getAppBarActionsIconTheme(tester); + expect(actionsIconThemeWidget.data.color, appbarThemeData.actionsIconTheme!.color); + expect(actionsIconThemeWidget.data.size, appbarThemeData.actionsIconTheme!.size); + + final NavigationToolbar navToolbar = tester.widget(find.byType(NavigationToolbar)); + expect(navToolbar.centerMiddle, centerTitle); + expect(navToolbar.middleSpacing, titleSpacing); + + final BoxConstraints leadingConstraints = (navToolbar.leading! as ConstrainedBox).constraints; + expect(leadingConstraints.maxWidth, leadingWidth); + expect(leadingConstraints.minWidth, leadingWidth); + + expect(tester.getSize(find.byType(AppBar)).height, toolbarHeight); + + final DefaultTextStyle text = _getAppBarText(tester); + expect(text.style, toolbarTextStyle); + + final RichText titleText = tester.widget<RichText>( + find.descendant(of: find.text('Title'), matching: find.byType(RichText)), + ); + expect(titleText.text.style, titleTextStyle); + + expect(SystemChrome.latestStyle, systemOverlayStyle); + + final Padding actionsPaddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(NavigationToolbar), matching: find.byType(Padding).last), + ); + expect(actionsPaddingWidget.padding, actionsPadding); + + final Size appBarSize = tester.getSize(find.byType(AppBar)); + expect(appBarSize.height, toolbarHeight); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/130485. + testWidgets( + 'Material3 - AppBarThemeData.iconTheme correctly applies custom white color in dark mode', + (WidgetTester tester) async { + final themeData = ThemeData( + brightness: Brightness.dark, + appBarTheme: const AppBarThemeData(iconTheme: IconThemeData(color: Colors.white)), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.menu), onPressed: () {}), + actions: <Widget>[IconButton(icon: const Icon(Icons.add), onPressed: () {})], + ), + ), + ), + ); + + Color? leadingIconButtonColor() => _iconStyle(tester, Icons.menu)?.color; + Color? actionIconButtonColor() => _iconStyle(tester, Icons.add)?.color; + + expect(leadingIconButtonColor(), Colors.white); + expect(actionIconButtonColor(), Colors.white); + }, + ); +} + +AppBarThemeData _appBarTheme() { + const SystemUiOverlayStyle systemOverlayStyle = SystemUiOverlayStyle.dark; + const Color backgroundColor = Colors.lightBlue; + const elevation = 6.0; + const Color shadowColor = Colors.red; + const Color surfaceTintColor = Colors.green; + const iconThemeData = IconThemeData(color: Colors.black); + const actionsIconThemeData = IconThemeData(color: Colors.pink); + return const AppBarThemeData( + actionsIconTheme: actionsIconThemeData, + systemOverlayStyle: systemOverlayStyle, + backgroundColor: backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: StadiumBorder(), + iconTheme: iconThemeData, + toolbarHeight: 96, + toolbarTextStyle: TextStyle(color: Colors.yellow), + titleTextStyle: TextStyle(color: Colors.pink), + ); +} + +Material _getAppBarMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)).first, + ); +} + +IconTheme _getAppBarIconTheme(WidgetTester tester) { + return tester.widget<IconTheme>( + find.descendant(of: find.byType(AppBar), matching: find.byType(IconTheme)).first, + ); +} + +IconTheme _getAppBarActionsIconTheme(WidgetTester tester) { + return tester.widget<IconTheme>( + find.descendant(of: find.byType(NavigationToolbar), matching: find.byType(IconTheme)).first, + ); +} + +RichText _getAppBarIconRichText(WidgetTester tester) { + return tester.widget<RichText>( + find.descendant(of: find.byType(Icon), matching: find.byType(RichText)).first, + ); +} + +DefaultTextStyle _getAppBarText(WidgetTester tester) { + return tester.widget<DefaultTextStyle>( + find + .descendant( + of: find.byType(CustomSingleChildLayout), + matching: find.byType(DefaultTextStyle), + ) + .first, + ); +} + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon).first, matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} diff --git a/packages/material_ui/test/material/app_bar_utils.dart b/packages/material_ui/test/material/app_bar_utils.dart new file mode 100644 index 000000000000..d71990a0c8d6 --- /dev/null +++ b/packages/material_ui/test/material/app_bar_utils.dart @@ -0,0 +1,53 @@ +// 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'; + +Finder findAppBarPhysicalModel() { + return find.descendant(of: find.byType(AppBar), matching: find.byType(PhysicalModel)).first; +} + +Color? getAppBarAnimatedBackgroundColor(WidgetTester tester) { + return tester.widget<PhysicalModel>(findAppBarPhysicalModel()).color; +} + +Finder findAppBarMaterial() { + return find.descendant(of: find.byType(AppBar), matching: find.byType(Material)).first; +} + +Color? getAppBarBackgroundColor(WidgetTester tester) { + return tester.widget<Material>(findAppBarMaterial()).color; +} + +double appBarHeight(WidgetTester tester) { + return tester.getSize(find.byType(AppBar, skipOffstage: false)).height; +} + +double appBarTop(WidgetTester tester) { + return tester.getTopLeft(find.byType(AppBar, skipOffstage: false)).dy; +} + +double appBarBottom(WidgetTester tester) { + return tester.getBottomLeft(find.byType(AppBar, skipOffstage: false)).dy; +} + +double tabBarHeight(WidgetTester tester) { + return tester.getSize(find.byType(TabBar, skipOffstage: false)).height; +} + +ScrollController primaryScrollController(WidgetTester tester) { + return PrimaryScrollController.of(tester.element(find.byType(CustomScrollView))); +} + +void verifyTextNotClipped(Finder textFinder, WidgetTester tester) { + final Rect clipRect = tester.getRect( + find.ancestor(of: textFinder, matching: find.byType(ClipRect)).first, + ); + final Rect textRect = tester.getRect(textFinder); + expect(textRect.top, inInclusiveRange(clipRect.top, clipRect.bottom)); + expect(textRect.bottom, inInclusiveRange(clipRect.top, clipRect.bottom)); + expect(textRect.left, inInclusiveRange(clipRect.left, clipRect.right)); + expect(textRect.right, inInclusiveRange(clipRect.left, clipRect.right)); +} diff --git a/packages/material_ui/test/material/app_builder_test.dart b/packages/material_ui/test/material/app_builder_test.dart new file mode 100644 index 000000000000..20e8fe00415f --- /dev/null +++ b/packages/material_ui/test/material/app_builder_test.dart @@ -0,0 +1,44 @@ +// 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("builder doesn't get called if app doesn't change", (WidgetTester tester) async { + final log = <String>[]; + final Widget app = MaterialApp( + home: const Placeholder(), + builder: (BuildContext context, Widget? child) { + log.add('build'); + expect(Directionality.of(context), TextDirection.ltr); + expect(child, isA<FocusScope>()); + return const Placeholder(); + }, + ); + await tester.pumpWidget(Directionality(textDirection: TextDirection.rtl, child: app)); + expect(log, <String>['build']); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, child: app)); + expect(log, <String>['build']); + }); + + testWidgets("builder doesn't get called if app doesn't change", (WidgetTester tester) async { + final log = <String>[]; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + log.add('build'); + expect(Directionality.of(context), TextDirection.rtl); + return const Placeholder(); + }, + ), + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: TextDirection.rtl, child: child!); + }, + ), + ); + expect(log, <String>['build']); + }); +} diff --git a/packages/material_ui/test/material/app_test.dart b/packages/material_ui/test/material/app_test.dart new file mode 100644 index 000000000000..19049cf7ba7e --- /dev/null +++ b/packages/material_ui/test/material/app_test.dart @@ -0,0 +1,1808 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +class StateMarker extends StatefulWidget { + const StateMarker({super.key, this.child}); + + final Widget? child; + + @override + StateMarkerState createState() => StateMarkerState(); +} + +class StateMarkerState extends State<StateMarker> { + late String marker; + + @override + Widget build(BuildContext context) { + return widget.child ?? Container(); + } +} + +void main() { + group('ThemeMode getters', () { + test('ThemeMode.system', () { + expect(ThemeMode.system.isSystem, isTrue); + expect(ThemeMode.system.isLight, isFalse); + expect(ThemeMode.system.isDark, isFalse); + }); + + test('ThemeMode.light', () { + expect(ThemeMode.light.isSystem, isFalse); + expect(ThemeMode.light.isLight, isTrue); + expect(ThemeMode.light.isDark, isFalse); + }); + + test('ThemeMode.dark', () { + expect(ThemeMode.dark.isSystem, isFalse); + expect(ThemeMode.dark.isLight, isFalse); + expect(ThemeMode.dark.isDark, isTrue); + }); + }); + + testWidgets('Can nest apps', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: MaterialApp(home: Text('Home sweet home')))); + + expect(find.text('Home sweet home'), findsOneWidget); + }); + + testWidgets('Focus handling', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode, autofocus: true)), + ), + ), + ); + + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('Can place app inside FocusScope', (WidgetTester tester) async { + final focusScopeNode = FocusScopeNode(); + addTearDown(focusScopeNode.dispose); + + await tester.pumpWidget( + FocusScope( + autofocus: true, + node: focusScopeNode, + child: const MaterialApp(home: Text('Home')), + ), + ); + + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('Can show grid without losing sync', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: StateMarker())); + + final StateMarkerState state1 = tester.state(find.byType(StateMarker)); + state1.marker = 'original'; + + await tester.pumpWidget(const MaterialApp(debugShowMaterialGrid: true, home: StateMarker())); + + final StateMarkerState state2 = tester.state(find.byType(StateMarker)); + expect(state1, equals(state2)); + expect(state2.marker, equals('original')); + }); + + testWidgets('Do not rebuild page during a route transition', (WidgetTester tester) async { + var buildCounter = 0; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Material( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).pushNamed('/next'); + }, + ), + ); + }, + ), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return Builder( + builder: (BuildContext context) { + ++buildCounter; + return const Text('Y'); + }, + ); + }, + }, + ), + ); + + expect(buildCounter, 0); + await tester.tap(find.text('X')); + expect(buildCounter, 0); + await tester.pump(); + expect(buildCounter, 1); + await tester.pump(const Duration(milliseconds: 10)); + expect(buildCounter, 1); + await tester.pump(const Duration(milliseconds: 10)); + expect(buildCounter, 1); + await tester.pump(const Duration(milliseconds: 10)); + expect(buildCounter, 1); + await tester.pump(const Duration(milliseconds: 10)); + expect(buildCounter, 1); + await tester.pump(const Duration(seconds: 1)); + expect(buildCounter, 1); + expect(find.text('Y'), findsOneWidget); + }); + + testWidgets('Do rebuild the home page if it changes', (WidgetTester tester) async { + var buildCounter = 0; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + ++buildCounter; + return const Text('A'); + }, + ), + ), + ); + expect(buildCounter, 1); + expect(find.text('A'), findsOneWidget); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + ++buildCounter; + return const Text('B'); + }, + ), + ), + ); + expect(buildCounter, 2); + expect(find.text('B'), findsOneWidget); + }); + + testWidgets('Do not rebuild the home page if it does not actually change', ( + WidgetTester tester, + ) async { + var buildCounter = 0; + final Widget home = Builder( + builder: (BuildContext context) { + ++buildCounter; + return const Placeholder(); + }, + ); + await tester.pumpWidget(MaterialApp(home: home)); + expect(buildCounter, 1); + await tester.pumpWidget(MaterialApp(home: home)); + expect(buildCounter, 1); + }); + + testWidgets('Do rebuild pages that come from the routes table if the MaterialApp changes', ( + WidgetTester tester, + ) async { + var buildCounter = 0; + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) { + ++buildCounter; + return const Placeholder(); + }, + }; + await tester.pumpWidget(MaterialApp(routes: routes)); + expect(buildCounter, 1); + await tester.pumpWidget(MaterialApp(routes: routes)); + expect(buildCounter, 2); + }); + + testWidgets('Cannot pop the initial route', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Text('Home'))); + + expect(find.text('Home'), findsOneWidget); + + final NavigatorState navigator = tester.state(find.byType(Navigator)); + final bool result = await navigator.maybePop(); + + expect(result, isFalse); + + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('Default initialRoute', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + routes: <String, WidgetBuilder>{'/': (BuildContext context) => const Text('route "/"')}, + ), + ); + + expect(find.text('route "/"'), findsOneWidget); + }); + + testWidgets('One-step initial route', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + initialRoute: '/a', + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) => const Text('route "/"'), + '/a': (BuildContext context) => const Text('route "/a"'), + '/a/b': (BuildContext context) => const Text('route "/a/b"'), + '/b': (BuildContext context) => const Text('route "/b"'), + }, + ), + ); + + expect(find.text('route "/"', skipOffstage: false), findsOneWidget); + expect(find.text('route "/a"'), findsOneWidget); + expect(find.text('route "/a/b"', skipOffstage: false), findsNothing); + expect(find.text('route "/b"', skipOffstage: false), findsNothing); + }); + + testWidgets('Return value from pop is correct', (WidgetTester tester) async { + late Future<Object?> result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Material( + child: ElevatedButton( + child: const Text('X'), + onPressed: () async { + result = Navigator.of(context).pushNamed<Object?>('/a'); + }, + ), + ); + }, + ), + routes: <String, WidgetBuilder>{ + '/a': (BuildContext context) { + return Material( + child: ElevatedButton( + child: const Text('Y'), + onPressed: () { + Navigator.of(context).pop('all done'); + }, + ), + ); + }, + }, + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Y'), findsOneWidget); + await tester.tap(find.text('Y')); + await tester.pump(); + + expect(await result, equals('all done')); + }); + + testWidgets('Two-step initial route', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => const Text('route "/"'), + '/a': (BuildContext context) => const Text('route "/a"'), + '/a/b': (BuildContext context) => const Text('route "/a/b"'), + '/b': (BuildContext context) => const Text('route "/b"'), + }; + + await tester.pumpWidget(MaterialApp(initialRoute: '/a/b', routes: routes)); + expect(find.text('route "/"', skipOffstage: false), findsOneWidget); + expect(find.text('route "/a"', skipOffstage: false), findsOneWidget); + expect(find.text('route "/a/b"'), findsOneWidget); + expect(find.text('route "/b"', skipOffstage: false), findsNothing); + }); + + testWidgets('Initial route with missing step', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => const Text('route "/"'), + '/a': (BuildContext context) => const Text('route "/a"'), + '/a/b': (BuildContext context) => const Text('route "/a/b"'), + '/b': (BuildContext context) => const Text('route "/b"'), + }; + + await tester.pumpWidget(MaterialApp(initialRoute: '/a/b/c', routes: routes)); + final dynamic exception = tester.takeException(); + expect(exception, isA<String>()); + if (exception is String) { + expect(exception.startsWith('Could not navigate to initial route.'), isTrue); + expect(find.text('route "/"'), findsOneWidget); + expect(find.text('route "/a"'), findsNothing); + expect(find.text('route "/a/b"'), findsNothing); + expect(find.text('route "/b"'), findsNothing); + } + }); + + testWidgets('Make sure initialRoute is only used the first time', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => const Text('route "/"'), + '/a': (BuildContext context) => const Text('route "/a"'), + '/b': (BuildContext context) => const Text('route "/b"'), + }; + + await tester.pumpWidget(MaterialApp(initialRoute: '/a', routes: routes)); + expect(find.text('route "/"', skipOffstage: false), findsOneWidget); + expect(find.text('route "/a"'), findsOneWidget); + expect(find.text('route "/b"', skipOffstage: false), findsNothing); + + // changing initialRoute has no effect + await tester.pumpWidget(MaterialApp(initialRoute: '/b', routes: routes)); + expect(find.text('route "/"', skipOffstage: false), findsOneWidget); + expect(find.text('route "/a"'), findsOneWidget); + expect(find.text('route "/b"', skipOffstage: false), findsNothing); + + // removing it has no effect + await tester.pumpWidget(MaterialApp(routes: routes)); + expect(find.text('route "/"', skipOffstage: false), findsOneWidget); + expect(find.text('route "/a"'), findsOneWidget); + expect(find.text('route "/b"', skipOffstage: false), findsNothing); + }); + + testWidgets( + 'onGenerateRoute / onUnknownRoute', + experimentalLeakTesting: LeakTesting.settings + .withIgnoredAll(), // leaking by design because of exception + (WidgetTester tester) async { + final log = <String>[]; + await tester.pumpWidget( + MaterialApp( + onGenerateRoute: (RouteSettings settings) { + log.add('onGenerateRoute ${settings.name}'); + return null; + }, + onUnknownRoute: (RouteSettings settings) { + log.add('onUnknownRoute ${settings.name}'); + return null; + }, + ), + ); + expect(tester.takeException(), isFlutterError); + expect(log, <String>['onGenerateRoute /', 'onUnknownRoute /']); + }, + ); + + testWidgets('MaterialApp with builder and no route information works.', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/18904 + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return const SizedBox(); + }, + ), + ); + }); + + testWidgets("WidgetsApp doesn't rebuild routes when MediaQuery updates", ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/37878 + addTearDown(tester.platformDispatcher.clearAllTestValues); + addTearDown(tester.view.reset); + + var routeBuildCount = 0; + var dependentBuildCount = 0; + + await tester.pumpWidget( + WidgetsApp( + color: const Color.fromARGB(255, 255, 255, 255), + onGenerateRoute: (_) { + return PageRouteBuilder<void>( + pageBuilder: (_, _, _) { + routeBuildCount++; + return Builder( + builder: (BuildContext context) { + dependentBuildCount++; + MediaQuery.of(context); + return Container(); + }, + ); + }, + ); + }, + ), + ); + + expect(routeBuildCount, equals(1)); + expect(dependentBuildCount, equals(1)); + + // didChangeMetrics + tester.view.physicalSize = const Size(42, 42); + + await tester.pump(); + + expect(routeBuildCount, equals(1)); + expect(dependentBuildCount, equals(2)); + + // didChangeTextScaleFactor + tester.platformDispatcher.textScaleFactorTestValue = 42; + + await tester.pump(); + + expect(routeBuildCount, equals(1)); + expect(dependentBuildCount, equals(3)); + + // didChangePlatformBrightness + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + + await tester.pump(); + + expect(routeBuildCount, equals(1)); + expect(dependentBuildCount, equals(4)); + + // didChangeAccessibilityFeatures + tester.platformDispatcher.accessibilityFeaturesTestValue = FakeAccessibilityFeatures.allOn; + + await tester.pumpAndSettle(); + + expect(routeBuildCount, equals(1)); + expect(dependentBuildCount, equals(5)); + }); + + testWidgets('Can get text scale from media query', (WidgetTester tester) async { + TextScaler? textScaler; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + textScaler = MediaQuery.textScalerOf(context); + return Container(); + }, + ), + ), + ); + expect(textScaler, isSystemTextScaler(withScaleFactor: 1.0)); + }); + + testWidgets('MaterialApp.navigatorKey', (WidgetTester tester) async { + final key = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + MaterialApp(navigatorKey: key, color: const Color(0xFF112233), home: const Placeholder()), + ); + expect(key.currentState, isA<NavigatorState>()); + await tester.pumpWidget(const MaterialApp(color: Color(0xFF112233), home: Placeholder())); + expect(key.currentState, isNull); + await tester.pumpWidget( + MaterialApp(navigatorKey: key, color: const Color(0xFF112233), home: const Placeholder()), + ); + expect(key.currentState, isA<NavigatorState>()); + }); + + testWidgets('Has default material and cupertino localizations', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + Text(MaterialLocalizations.of(context).selectAllButtonLabel), + Text(CupertinoLocalizations.of(context).selectAllButtonLabel), + ], + ); + }, + ), + ), + ); + + // Default US "select all" text. + expect(find.text('Select all'), findsOneWidget); + // Default Cupertino US "select all" text. + expect(find.text('Select All'), findsOneWidget); + }); + + testWidgets('MaterialApp uses regular theme when themeMode is light', ( + WidgetTester tester, + ) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a light platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + + late ThemeData appliedTheme; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + themeMode: ThemeMode.light, + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + expect(appliedTheme.brightness, Brightness.light); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + themeMode: ThemeMode.light, + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + expect(appliedTheme.brightness, Brightness.light); + }); + + testWidgets('MaterialApp uses darkTheme when themeMode is dark', (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a light platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + + late ThemeData appliedTheme; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + themeMode: ThemeMode.dark, + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + expect(appliedTheme.brightness, Brightness.dark); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + themeMode: ThemeMode.dark, + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + expect(appliedTheme.brightness, Brightness.dark); + }); + + testWidgets( + 'MaterialApp uses regular theme when themeMode is system and platformBrightness is light', + (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a light platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.brightness, Brightness.light); + }, + ); + + testWidgets( + 'MaterialApp uses darkTheme when themeMode is system and platformBrightness is dark', + (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + + late ThemeData appliedTheme; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + expect(appliedTheme.brightness, Brightness.dark); + }, + ); + + testWidgets( + 'MaterialApp uses light theme when platformBrightness is dark but no dark theme is provided', + (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.brightness, Brightness.light); + }, + ); + + testWidgets( + 'MaterialApp uses fallback light theme when platformBrightness is dark but no theme is provided at all', + (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.brightness, Brightness.light); + }, + ); + + testWidgets( + 'MaterialApp uses fallback light theme when platformBrightness is light and a dark theme is provided', + (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + darkTheme: ThemeData(brightness: Brightness.dark), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.brightness, Brightness.light); + }, + ); + + testWidgets('MaterialApp uses dark theme when platformBrightness is dark', ( + WidgetTester tester, + ) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a dark platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.brightness, Brightness.dark); + }); + + testWidgets('MaterialApp uses high contrast theme when appropriate', (WidgetTester tester) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + tester.platformDispatcher.accessibilityFeaturesTestValue = FakeAccessibilityFeatures.allOn; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: Colors.lightBlue), + highContrastTheme: ThemeData(primaryColor: Colors.blue), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.primaryColor, Colors.blue); + }); + + testWidgets('MaterialApp uses high contrast dark theme when appropriate', ( + WidgetTester tester, + ) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + tester.platformDispatcher.accessibilityFeaturesTestValue = FakeAccessibilityFeatures.allOn; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: Colors.lightBlue), + darkTheme: ThemeData(primaryColor: Colors.lightGreen), + highContrastTheme: ThemeData(primaryColor: Colors.blue), + highContrastDarkTheme: ThemeData(primaryColor: Colors.green), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.primaryColor, Colors.green); + }); + + testWidgets('MaterialApp uses dark theme when no high contrast dark theme is provided', ( + WidgetTester tester, + ) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + tester.platformDispatcher.accessibilityFeaturesTestValue = FakeAccessibilityFeatures.allOn; + + late ThemeData appliedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: Colors.lightBlue), + darkTheme: ThemeData(primaryColor: Colors.lightGreen), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.primaryColor, Colors.lightGreen); + }); + + testWidgets('MaterialApp animates theme changes', (WidgetTester tester) async { + final lightTheme = ThemeData(); + final darkTheme = ThemeData.dark(); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: ThemeMode.light, + home: Builder( + builder: (BuildContext context) { + return const Scaffold(); + }, + ), + ), + ); + expect( + tester.widget<Material>(find.byType(Material)).color, + lightTheme.scaffoldBackgroundColor, + ); + + // Change to dark theme + await tester.pumpWidget( + MaterialApp( + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.dark, + home: Builder( + builder: (BuildContext context) { + return const Scaffold(); + }, + ), + ), + ); + + // Wait half kThemeAnimationDuration = 200ms. + await tester.pump(const Duration(milliseconds: 100)); + + // Default curve is linear so background should be half way between + // the two colors. + final Color halfBGColor = Color.lerp( + lightTheme.scaffoldBackgroundColor, + darkTheme.scaffoldBackgroundColor, + 0.5, + )!; + expect(tester.widget<Material>(find.byType(Material)).color, halfBGColor); + }); + + testWidgets('MaterialApp theme animation can be turned off', (WidgetTester tester) async { + final lightTheme = ThemeData(); + final darkTheme = ThemeData.dark(); + var scaffoldRebuilds = 0; + + final Widget scaffold = Builder( + builder: (BuildContext context) { + scaffoldRebuilds++; + // Use Theme.of() to ensure we are building when the theme changes. + return Scaffold(backgroundColor: Theme.of(context).scaffoldBackgroundColor); + }, + ); + + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: ThemeMode.light, + themeAnimationDuration: Duration.zero, + home: scaffold, + ), + ); + expect( + tester.widget<Material>(find.byType(Material)).color, + lightTheme.scaffoldBackgroundColor, + ); + expect(scaffoldRebuilds, 1); + + // Change to dark theme + await tester.pumpWidget( + MaterialApp( + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.dark, + themeAnimationDuration: Duration.zero, + home: scaffold, + ), + ); + + // Wait for any animation to finish. + await tester.pumpAndSettle(); + expect(tester.widget<Material>(find.byType(Material)).color, darkTheme.scaffoldBackgroundColor); + expect(scaffoldRebuilds, 2); + }); + + testWidgets('MaterialApp switches themes when the platformBrightness changes.', ( + WidgetTester tester, + ) async { + addTearDown(tester.platformDispatcher.clearAllTestValues); + + // Mock the test to explicitly report a light platformBrightness. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + + ThemeData? themeBeforeBrightnessChange; + ThemeData? themeAfterBrightnessChange; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + home: Builder( + builder: (BuildContext context) { + if (themeBeforeBrightnessChange == null) { + themeBeforeBrightnessChange = Theme.of(context); + } else { + themeAfterBrightnessChange = Theme.of(context); + } + return const SizedBox(); + }, + ), + ), + ); + + // Switch the platformBrightness from light to dark and pump the widget tree + // to process changes. + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pumpAndSettle(); + + expect(themeBeforeBrightnessChange!.brightness, Brightness.light); + expect(themeAfterBrightnessChange!.brightness, Brightness.dark); + }); + + testWidgets('Material2 - MaterialApp provides default overscroll color', ( + WidgetTester tester, + ) async { + Future<void> slowDrag(WidgetTester tester, Offset start, Offset offset) async { + final TestGesture gesture = await tester.startGesture(start); + for (var index = 0; index < 10; index += 1) { + await gesture.moveBy(offset); + await tester.pump(const Duration(milliseconds: 20)); + } + await gesture.up(); + } + + // The overscroll color should be a transparent version of the colorScheme's + // secondary color. + const secondaryColor = Color(0xff008800); + final Color glowSecondaryColor = secondaryColor.withOpacity(0.05); + final theme = ThemeData.from( + useMaterial3: false, + colorScheme: const ColorScheme.light().copyWith(secondary: secondaryColor), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const SingleChildScrollView(child: SizedBox(height: 2000.0)), + ), + ); + + final RenderObject painter = tester.renderObject(find.byType(CustomPaint).first); + await slowDrag(tester, const Offset(200.0, 200.0), const Offset(0.0, 5.0)); + expect(painter, paints..circle(color: glowSecondaryColor)); + }); + + testWidgets('MaterialApp can customize initial routes', (WidgetTester tester) async { + final navigatorKey = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + onGenerateInitialRoutes: (String initialRoute) { + expect(initialRoute, '/abc'); + return <Route<void>>[ + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Text('non-regular page one'); + }, + ), + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Text('non-regular page two'); + }, + ), + ]; + }, + initialRoute: '/abc', + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) => const Text('regular page one'), + '/abc': (BuildContext context) => const Text('regular page two'), + }, + ), + ); + expect(find.text('non-regular page two'), findsOneWidget); + expect(find.text('non-regular page one'), findsNothing); + expect(find.text('regular page one'), findsNothing); + expect(find.text('regular page two'), findsNothing); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.text('non-regular page two'), findsNothing); + expect(find.text('non-regular page one'), findsOneWidget); + expect(find.text('regular page one'), findsNothing); + expect(find.text('regular page two'), findsNothing); + }); + + testWidgets('MaterialApp does create HeroController with the MaterialRectArcTween', ( + WidgetTester tester, + ) async { + final HeroController controller = MaterialApp.createMaterialHeroController(); + addTearDown(controller.dispose); + final Tween<Rect?> tween = controller.createRectTween!( + const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0), + const Rect.fromLTRB(0.0, 0.0, 20.0, 20.0), + ); + expect(tween, isA<MaterialRectArcTween>()); + }); + + testWidgets('MaterialApp.navigatorKey can be updated', (WidgetTester tester) async { + final key1 = GlobalKey<NavigatorState>(); + await tester.pumpWidget(MaterialApp(navigatorKey: key1, home: const Placeholder())); + expect(key1.currentState, isA<NavigatorState>()); + final key2 = GlobalKey<NavigatorState>(); + await tester.pumpWidget(MaterialApp(navigatorKey: key2, home: const Placeholder())); + expect(key2.currentState, isA<NavigatorState>()); + expect(key1.currentState, isNull); + }); + + testWidgets('MaterialApp.router works', (WidgetTester tester) async { + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + addTearDown(provider.dispose); + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + ), + ); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('MaterialApp.router works with onNavigationNotification', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/139903. + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + addTearDown(provider.dispose); + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + + var navigationCount = 0; + + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + onNavigationNotification: (NavigationNotification? notification) { + navigationCount += 1; + return true; + }, + ), + ); + expect(find.text('initial'), findsOneWidget); + + expect(navigationCount, greaterThan(0)); + final navigationCountAfterBuild = navigationCount; + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + + expect(navigationCount, greaterThan(navigationCountAfterBuild)); + }); + + testWidgets('MaterialApp.router route information parser is optional', ( + WidgetTester tester, + ) async { + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + delegate.routeInformation = RouteInformation(uri: Uri.parse('initial')); + await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate)); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets( + 'MaterialApp.router throw if route information provider is provided but no route information parser', + (WidgetTester tester) async { + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + delegate.routeInformation = RouteInformation(uri: Uri.parse('initial')); + final provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ); + await tester.pumpWidget( + MaterialApp.router(routeInformationProvider: provider, routerDelegate: delegate), + ); + expect(tester.takeException(), isAssertionError); + provider.dispose(); + }, + ); + + testWidgets( + 'MaterialApp.router throw if route configuration is provided along with other delegate', + (WidgetTester tester) async { + final delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ); + addTearDown(delegate.dispose); + delegate.routeInformation = RouteInformation(uri: Uri.parse('initial')); + final routerConfig = RouterConfig<RouteInformation>(routerDelegate: delegate); + await tester.pumpWidget( + MaterialApp.router(routerDelegate: delegate, routerConfig: routerConfig), + ); + expect(tester.takeException(), isAssertionError); + }, + ); + + testWidgets('MaterialApp.router router config works', (WidgetTester tester) async { + late SimpleNavigatorRouterDelegate routerDelegate; + addTearDown(() => routerDelegate.dispose()); + late PlatformRouteInformationProvider provider; + addTearDown(() => provider.dispose()); + final routerConfig = RouterConfig<RouteInformation>( + routeInformationProvider: provider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation(uri: Uri.parse('initial')), + ), + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: routerDelegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.uri.toString()); + }, + onPopPage: (Route<void> route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = RouteInformation(uri: Uri.parse('popped')); + return route.didPop(result); + }, + ), + backButtonDispatcher: RootBackButtonDispatcher(), + ); + await tester.pumpWidget(MaterialApp.router(routerConfig: routerConfig)); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('MaterialApp.builder can build app without a Navigator', (WidgetTester tester) async { + Widget? builderChild; + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + builderChild = child; + return Container(); + }, + ), + ); + expect(builderChild, isNull); + }); + + testWidgets('MaterialApp has correct default ScrollBehavior', (WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + expect(ScrollConfiguration.of(capturedContext).runtimeType, MaterialScrollBehavior); + }); + + testWidgets('MaterialApp has correct default KeyboardDismissBehavior', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + + expect( + ScrollConfiguration.of(capturedContext).getKeyboardDismissBehavior(capturedContext), + ScrollViewKeyboardDismissBehavior.manual, + ); + }); + + testWidgets('MaterialApp can override default KeyboardDismissBehavior', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + scrollBehavior: const MaterialScrollBehavior().copyWith( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + ), + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + + expect( + ScrollConfiguration.of(capturedContext).getKeyboardDismissBehavior(capturedContext), + ScrollViewKeyboardDismissBehavior.onDrag, + ); + }); + + testWidgets('A ScrollBehavior can be set for MaterialApp', (WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + scrollBehavior: const MockScrollBehavior(), + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return const Placeholder(); + }, + ), + ), + ); + final ScrollBehavior scrollBehavior = ScrollConfiguration.of(capturedContext); + expect(scrollBehavior.runtimeType, MockScrollBehavior); + expect( + scrollBehavior.getScrollPhysics(capturedContext).runtimeType, + NeverScrollableScrollPhysics, + ); + }); + + testWidgets( + 'Material2 - ScrollBehavior default android overscroll indicator', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + scrollBehavior: const MaterialScrollBehavior(), + home: ListView( + children: const <Widget>[SizedBox(height: 1000.0, width: 1000.0, child: Text('Test'))], + ), + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsNothing); + expect(find.byType(GlowingOverscrollIndicator), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Material3 - ScrollBehavior default android overscroll indicator', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + scrollBehavior: const MaterialScrollBehavior(), + home: ListView( + children: const <Widget>[SizedBox(height: 1000.0, width: 1000.0, child: Text('Test'))], + ), + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'MaterialScrollBehavior default stretch android overscroll indicator', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: ListView( + children: const <Widget>[SizedBox(height: 1000.0, width: 1000.0, child: Text('Test'))], + ), + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Overscroll indicator can be set by theme', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + // The current default is M3 and stretch overscroll, setting via the theme should override. + theme: ThemeData().copyWith(useMaterial3: false), + home: ListView( + children: const <Widget>[SizedBox(height: 1000.0, width: 1000.0, child: Text('Test'))], + ), + ), + ); + + expect(find.byType(GlowingOverscrollIndicator), findsOneWidget); + expect(find.byType(StretchingOverscrollIndicator), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Material3 - ListView clip behavior updates overscroll indicator clip behavior', + (WidgetTester tester) async { + Widget buildFrame(Clip clipBehavior) { + return MaterialApp( + home: Column( + children: <Widget>[ + SizedBox( + height: 300, + child: ListView.builder( + itemCount: 20, + clipBehavior: clipBehavior, + itemBuilder: (BuildContext context, int index) { + return Padding( + padding: const EdgeInsets.all(10.0), + child: Text('Index $index'), + ); + }, + ), + ), + Opacity(opacity: 0.5, child: Container(color: const Color(0xD0FF0000), height: 100)), + ], + ), + ); + } + + // Test default clip behavior. + await tester.pumpWidget(buildFrame(Clip.hardEdge)); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + expect(find.text('Index 1'), findsOneWidget); + + RenderClipRect renderClip = tester.allRenderObjects.whereType<RenderClipRect>().first; + // Currently not clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll the start. + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(0)); + renderClip = tester.allRenderObjects.whereType<RenderClipRect>().first; + // Now clipping + expect(renderClip.clipBehavior, equals(Clip.hardEdge)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Test custom clip behavior. + await tester.pumpWidget(buildFrame(Clip.none)); + + renderClip = tester.allRenderObjects.whereType<RenderClipRect>().first; + // Currently not clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + gesture = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll the start. + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(0)); + renderClip = tester.allRenderObjects.whereType<RenderClipRect>().first; + // Now clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'When `useInheritedMediaQuery` is true an existing MediaQuery is used if one is available', + (WidgetTester tester) async { + late BuildContext capturedContext; + final uniqueKey = UniqueKey(); + await tester.pumpWidget( + MediaQuery( + key: uniqueKey, + data: const MediaQueryData(), + child: MaterialApp( + useInheritedMediaQuery: true, + builder: (BuildContext context, Widget? child) { + capturedContext = context; + return const Placeholder(); + }, + color: const Color(0xFF123456), + ), + ), + ); + expect(capturedContext.dependOnInheritedWidgetOfExactType<MediaQuery>()?.key, uniqueKey); + }, + ); + + testWidgets( + 'Assert in buildScrollbar that controller != null when using it (vertical)', + (WidgetTester tester) async { + const ScrollBehavior defaultBehavior = MaterialScrollBehavior(); + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ScrollConfiguration( + // Avoid the default ones here. + behavior: const MaterialScrollBehavior().copyWith(scrollbars: false), + child: SingleChildScrollView( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(height: 1000.0); + }, + ), + ), + ), + ), + ); + + const details = ScrollableDetails(direction: AxisDirection.down); + final Widget child = Container(); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // Does not throw if we aren't using it. + defaultBehavior.buildScrollbar(capturedContext, child, details); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect( + () { + defaultBehavior.buildScrollbar(capturedContext, child, details); + }, + throwsA( + isA<AssertionError>().having( + (AssertionError error) => error.toString(), + 'description', + contains('details.controller != null'), + ), + ), + ); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'Assert in buildScrollbar that controller != null when using it (horizontal)', + (WidgetTester tester) async { + const ScrollBehavior defaultBehavior = MaterialScrollBehavior(); + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ScrollConfiguration( + // Avoid the default ones here. + behavior: const MaterialScrollBehavior().copyWith(scrollbars: false), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(height: 1000.0); + }, + ), + ), + ), + ), + ); + + const details = ScrollableDetails(direction: AxisDirection.left); + final Widget child = Container(); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Does not throw if we aren't using it. + // Horizontal axis gets no scrollbars for all platforms. + defaultBehavior.buildScrollbar(capturedContext, child, details); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('Override theme animation using AnimationStyle', (WidgetTester tester) async { + final lightTheme = ThemeData(); + final darkTheme = ThemeData.dark(); + + Widget buildWidget({ThemeMode themeMode = ThemeMode.light, AnimationStyle? animationStyle}) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + themeAnimationStyle: animationStyle, + home: const Scaffold(body: Text('body')), + ); + } + + // Test the initial Scaffold background color. + await tester.pumpWidget(buildWidget()); + + expect( + tester.widget<Material>(find.byType(Material)).color, + isSameColorAs(lightTheme.colorScheme.surface), + ); + + // Test the Scaffold background color animation from light to dark theme. + await tester.pumpWidget(buildWidget(themeMode: ThemeMode.dark)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // Advance animation by 50 milliseconds. + + // Scaffold background color is slightly updated. + expect( + tester.widget<Material>(find.byType(Material)).color, + isSameColorAs(const Color(0xffc3bdc5)), + ); + + // Let the animation finish. + await tester.pumpAndSettle(); + + // Scaffold background color is fully updated to dark theme. + expect( + tester.widget<Material>(find.byType(Material)).color, + isSameColorAs(darkTheme.colorScheme.surface), + ); + + // Reset to light theme to compare the Scaffold background color animation + // with the default animation curve. + await tester.pumpWidget(buildWidget()); + await tester.pumpAndSettle(); + + // Switch to dark theme with overridden animation curve. + await tester.pumpWidget( + buildWidget( + themeMode: ThemeMode.dark, + animationStyle: const AnimationStyle(curve: Curves.easeIn), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Scaffold background color is slightly updated but with a different + // color than the default animation curve. + expect( + tester.widget<Material>(find.byType(Material)).color, + isSameColorAs(const Color(0xffe7e1e9)), + ); + + // Let the animation finish. + await tester.pumpAndSettle(); + + // Scaffold background color is fully updated to dark theme. + expect( + tester.widget<Material>(find.byType(Material)).color, + isSameColorAs(darkTheme.colorScheme.surface), + ); + + // Switch from dark to light theme with overridden animation duration. + await tester.pumpWidget(buildWidget(animationStyle: AnimationStyle.noAnimation)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect( + tester.widget<Material>(find.byType(Material)).color, + isNot(darkTheme.colorScheme.surface), + ); + expect( + tester.widget<Material>(find.byType(Material)).color, + isSameColorAs(lightTheme.colorScheme.surface), + ); + }); + + testWidgets('AnimationStyle.noAnimation removes AnimatedTheme from the tree', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(themeAnimationStyle: AnimationStyle())); + + expect(find.byType(AnimatedTheme), findsOneWidget); + expect(find.byType(Theme), findsOneWidget); + + await tester.pumpWidget(const MaterialApp(themeAnimationStyle: AnimationStyle.noAnimation)); + + expect(find.byType(AnimatedTheme), findsNothing); + expect(find.byType(Theme), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/137875. + testWidgets('MaterialApp works in an unconstrained environment', (WidgetTester tester) async { + await tester.pumpWidget( + const UnconstrainedBox(child: MaterialApp(home: SizedBox(width: 123, height: 456))), + ); + + expect(tester.getSize(find.byType(MaterialApp)), const Size(123, 456)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/156959. + testWidgets( + 'MaterialApp with builder works when themeAnimationStyle is AnimationStyle.noAnimation', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + themeAnimationStyle: AnimationStyle.noAnimation, + builder: (BuildContext context, Widget? child) { + return const Text('Works'); + }, + ), + ); + expect(find.text('Works'), findsOne); + }, + ); + + testWidgets('MaterialApp does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const Center( + child: SizedBox.shrink(child: MaterialApp(home: Text('X'))), + ), + ); + expect(tester.getSize(find.byType(MaterialApp)), Size.zero); + }); +} + +class MockScrollBehavior extends ScrollBehavior { + const MockScrollBehavior(); + + @override + ScrollPhysics getScrollPhysics(BuildContext context) => const NeverScrollableScrollPhysics(); +} + +typedef SimpleRouterDelegateBuilder = + Widget Function(BuildContext context, RouteInformation information); +typedef SimpleNavigatorRouterDelegatePopPage<T> = + bool Function(Route<T> route, T result, SimpleNavigatorRouterDelegate delegate); + +class SimpleRouteInformationParser extends RouteInformationParser<RouteInformation> { + SimpleRouteInformationParser(); + + @override + Future<RouteInformation> parseRouteInformation(RouteInformation information) { + return SynchronousFuture<RouteInformation>(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleNavigatorRouterDelegate extends RouterDelegate<RouteInformation> + with PopNavigatorRouterDelegateMixin<RouteInformation>, ChangeNotifier { + SimpleNavigatorRouterDelegate({required this.builder, required this.onPopPage}) { + ChangeNotifier.maybeDispatchObjectCreation(this); + } + + @override + GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); + + RouteInformation get routeInformation => _routeInformation; + late RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleNavigatorRouterDelegatePopPage<void> onPopPage; + + @override + Future<void> setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture<void>(null); + } + + bool _handlePopPage(Route<void> route, void data) { + return onPopPage(route, data, this); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onPopPage: _handlePopPage, + pages: <Page<void>>[ + // We need at least two pages for the pop to propagate through. + // Otherwise, the navigator will bubble the pop to the system navigator. + const MaterialPage<void>(child: Text('base')), + MaterialPage<void>( + key: ValueKey<String>(routeInformation.uri.toString()), + child: builder(context, routeInformation), + ), + ], + ); + } +} diff --git a/packages/material_ui/test/material/arc_test.dart b/packages/material_ui/test/material/arc_test.dart new file mode 100644 index 000000000000..90bfc470e5e3 --- /dev/null +++ b/packages/material_ui/test/material/arc_test.dart @@ -0,0 +1,96 @@ +// 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() { + test('MaterialPointArcTween control test', () { + final a = MaterialPointArcTween(begin: Offset.zero, end: const Offset(0.0, 10.0)); + + final b = MaterialPointArcTween(begin: Offset.zero, end: const Offset(0.0, 10.0)); + + expect(a, hasOneLineDescription); + expect(a.toString(), equals(b.toString())); + }); + + test('MaterialRectArcTween control test', () { + final a = MaterialRectArcTween( + begin: const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0), + end: const Rect.fromLTWH(0.0, 10.0, 10.0, 10.0), + ); + + final b = MaterialRectArcTween( + begin: const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0), + end: const Rect.fromLTWH(0.0, 10.0, 10.0, 10.0), + ); + expect(a, hasOneLineDescription); + expect(a.toString(), equals(b.toString())); + }); + + test('on-axis MaterialPointArcTween', () { + var tween = MaterialPointArcTween(begin: Offset.zero, end: const Offset(0.0, 10.0)); + expect(tween.lerp(0.5), equals(const Offset(0.0, 5.0))); + expect(tween, hasOneLineDescription); + + tween = MaterialPointArcTween(begin: Offset.zero, end: const Offset(10.0, 0.0)); + expect(tween.lerp(0.5), equals(const Offset(5.0, 0.0))); + }); + + test('on-axis MaterialRectArcTween', () { + var tween = MaterialRectArcTween( + begin: const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0), + end: const Rect.fromLTWH(0.0, 10.0, 10.0, 10.0), + ); + expect(tween.lerp(0.5), equals(const Rect.fromLTWH(0.0, 5.0, 10.0, 10.0))); + expect(tween, hasOneLineDescription); + + tween = MaterialRectArcTween( + begin: const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0), + end: const Rect.fromLTWH(10.0, 0.0, 10.0, 10.0), + ); + expect(tween.lerp(0.5), equals(const Rect.fromLTWH(5.0, 0.0, 10.0, 10.0))); + }); + + test('MaterialPointArcTween', () { + const begin = Offset(180.0, 110.0); + const end = Offset(37.0, 250.0); + + var tween = MaterialPointArcTween(begin: begin, end: end); + expect(tween.lerp(0.0), begin); + expect(tween.lerp(0.25), within<Offset>(distance: 2.0, from: const Offset(126.0, 120.0))); + expect(tween.lerp(0.75), within<Offset>(distance: 2.0, from: const Offset(48.0, 196.0))); + expect(tween.lerp(1.0), end); + + tween = MaterialPointArcTween(begin: end, end: begin); + expect(tween.lerp(0.0), end); + expect(tween.lerp(0.25), within<Offset>(distance: 2.0, from: const Offset(91.0, 239.0))); + expect(tween.lerp(0.75), within<Offset>(distance: 2.0, from: const Offset(168.3, 163.8))); + expect(tween.lerp(1.0), begin); + }); + + test('MaterialRectArcTween', () { + const begin = Rect.fromLTRB(180.0, 100.0, 330.0, 200.0); + const end = Rect.fromLTRB(32.0, 275.0, 132.0, 425.0); + + bool sameRect(Rect a, Rect b) { + return (a.left - b.left).abs() < 2.0 && + (a.top - b.top).abs() < 2.0 && + (a.right - b.right).abs() < 2.0 && + (a.bottom - b.bottom).abs() < 2.0; + } + + var tween = MaterialRectArcTween(begin: begin, end: end); + expect(tween.lerp(0.0), begin); + expect(sameRect(tween.lerp(0.25), const Rect.fromLTRB(120.0, 113.0, 259.0, 237.0)), isTrue); + expect(sameRect(tween.lerp(0.75), const Rect.fromLTRB(42.3, 206.5, 153.5, 354.7)), isTrue); + expect(tween.lerp(1.0), end); + + tween = MaterialRectArcTween(begin: end, end: begin); + expect(tween.lerp(0.0), end); + expect(sameRect(tween.lerp(0.25), const Rect.fromLTRB(92.0, 262.0, 203.0, 388.0)), isTrue); + expect(sameRect(tween.lerp(0.75), const Rect.fromLTRB(169.7, 168.5, 308.5, 270.3)), isTrue); + expect(tween.lerp(1.0), begin); + }); +} diff --git a/packages/material_ui/test/material/autocomplete_test.dart b/packages/material_ui/test/material/autocomplete_test.dart new file mode 100644 index 000000000000..3b58f494e0d4 --- /dev/null +++ b/packages/material_ui/test/material/autocomplete_test.dart @@ -0,0 +1,974 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +class User { + const User({required this.email, required this.name}); + + final String email; + final String name; + + @override + String toString() { + return '$name, $email'; + } +} + +void main() { + const kOptions = <String>[ + 'aardvark', + 'bobcat', + 'chameleon', + 'dingo', + 'elephant', + 'flamingo', + 'goose', + 'hippopotamus', + 'iguana', + 'jaguar', + 'koala', + 'lemur', + 'mouse', + 'northern white rhinoceros', + ]; + + const kOptionsUsers = <User>[ + User(name: 'Alice', email: 'alice@example.com'), + User(name: 'Bob', email: 'bob@example.com'), + User(name: 'Charlie', email: 'charlie123@gmail.com'), + ]; + + testWidgets('can filter and select a list of string options', (WidgetTester tester) async { + late String lastSelection; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + onSelected: (String selection) { + lastSelection = selection; + }, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + // The field is always rendered, but the options are not unless needed. + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + + // Focus the empty field. All the options are displayed. + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + var list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, kOptions.length); + + // Enter text. The options are filtered by the text. + await tester.enterText(find.byType(TextFormField), 'ele'); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + list = find.byType(ListView).evaluate().first.widget as ListView; + // 'chameleon' and 'elephant' are displayed. + expect(list.semanticChildCount, 2); + + // Select a option. The options hide and the field updates to show the + // selection. + await tester.tap(find.byType(InkWell).first); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + final field = find.byType(TextFormField).evaluate().first.widget as TextFormField; + expect(field.controller!.text, 'chameleon'); + expect(lastSelection, 'chameleon'); + + // Modify the field text. The options appear again and are filtered. + await tester.enterText(find.byType(TextFormField), 'e'); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + list = find.byType(ListView).evaluate().first.widget as ListView; + // 'chameleon', 'elephant', 'goose', 'lemur', 'mouse', and + // 'northern white rhinoceros' are displayed. + expect(list.semanticChildCount, 6); + }); + + testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<User>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptionsUsers.where((User option) { + return option.toString().contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + // The field is always rendered, but the options are not unless needed. + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + + // Focus the empty field. All the options are displayed. + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + var list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, kOptionsUsers.length); + + // Enter text. The options are filtered by the text. + await tester.enterText(find.byType(TextFormField), 'example'); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + list = find.byType(ListView).evaluate().first.widget as ListView; + // 'Alice' and 'Bob' are displayed because they have "example.com" emails. + expect(list.semanticChildCount, 2); + + // Select a option. The options hide and the field updates to show the + // selection. + await tester.tap(find.byType(InkWell).first); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + final field = find.byType(TextFormField).evaluate().first.widget as TextFormField; + expect(field.controller!.text, 'Alice, alice@example.com'); + + // Modify the field text. The options appear again and are filtered. + await tester.enterText(find.byType(TextFormField), 'B'); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + list = find.byType(ListView).evaluate().first.widget as ListView; + // 'Bob' is displayed. + expect(list.semanticChildCount, 1); + }); + + testWidgets('displayStringForOption is displayed in the options', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<User>( + displayStringForOption: (User option) { + return option.name; + }, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptionsUsers.where((User option) { + return option.toString().contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + // The field is always rendered, but the options are not unless needed. + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + + // Focus the empty field. All the options are displayed, and the string that + // is used comes from displayStringForOption. + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + final list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, kOptionsUsers.length); + for (var i = 0; i < kOptionsUsers.length; i++) { + expect(find.text(kOptionsUsers[i].name), findsOneWidget); + } + + // Select a option. The options hide and the field updates to show the + // selection. The text in the field is given by displayStringForOption. + await tester.tap(find.byType(InkWell).first); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + final field = find.byType(TextFormField).evaluate().first.widget as TextFormField; + expect(field.controller!.text, kOptionsUsers.first.name); + }); + + testWidgets('can build a custom field', (WidgetTester tester) async { + final GlobalKey fieldKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + fieldViewBuilder: + ( + BuildContext context, + TextEditingController textEditingController, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + return Container(key: fieldKey); + }, + ), + ), + ), + ); + + // The custom field is rendered and not the default TextFormField. + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + }); + + testWidgets('can build custom options', (WidgetTester tester) async { + final GlobalKey optionsKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: + ( + BuildContext context, + AutocompleteOnSelected<String> onSelected, + Iterable<String> options, + ) { + return Container(key: optionsKey); + }, + ), + ), + ), + ); + + // The default field is rendered but not the options, yet. + expect(find.byKey(optionsKey), findsNothing); + expect(find.byType(TextFormField), findsOneWidget); + + // Focus the empty field. The custom options is displayed. + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + expect(find.byKey(optionsKey), findsOneWidget); + }); + + testWidgets('the default Autocomplete options widget has a maximum height of 200', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + final Finder listFinder = find.byType(ListView); + final Finder inputFinder = find.byType(TextFormField); + await tester.tap(inputFinder); + await tester.enterText(inputFinder, ''); + await tester.pump(); + final Size baseSize = tester.getSize(listFinder); + final double resultingHeight = baseSize.height; + expect(resultingHeight, equals(200)); + }); + + testWidgets('the options height restricts to max desired height', (WidgetTester tester) async { + const desiredHeight = 150.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + optionsMaxHeight: desiredHeight, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + /// entering "a" returns 9 items from kOptions so basically the + /// height of 9 options would be beyond `desiredHeight=150`, + /// so height gets restricted to desiredHeight. + final Finder listFinder = find.byType(ListView); + final Finder inputFinder = find.byType(TextFormField); + await tester.tap(inputFinder); + await tester.enterText(inputFinder, 'a'); + await tester.pump(); + final Size baseSize = tester.getSize(listFinder); + final double resultingHeight = baseSize.height; + + /// expected desired Height =150.0 + expect(resultingHeight, equals(desiredHeight)); + }); + + testWidgets( + 'The height of options shrinks to height of resulting items, if less than maxHeight', + (WidgetTester tester) async { + // Returns a Future with the height of the default [Autocomplete] options widget + // after the provided text had been entered into the [Autocomplete] field. + Future<double> getDefaultOptionsHeight(WidgetTester tester, String enteredText) async { + final Finder listFinder = find.byType(ListView); + final Finder inputFinder = find.byType(TextFormField); + final field = inputFinder.evaluate().first.widget as TextFormField; + field.controller!.clear(); + await tester.tap(inputFinder); + await tester.enterText(inputFinder, enteredText); + await tester.pump(); + final Size baseSize = tester.getSize(listFinder); + return baseSize.height; + } + + const maxOptionsHeight = 250.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + optionsMaxHeight: maxOptionsHeight, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + final Finder listFinder = find.byType(ListView); + expect(listFinder, findsNothing); + + // Entering `a` returns 9 items(height > `maxOptionsHeight`) from the kOptions + // so height gets restricted to `maxOptionsHeight =250`. + final double nineItemsHeight = await getDefaultOptionsHeight(tester, 'a'); + expect(nineItemsHeight, equals(maxOptionsHeight)); + + // Returns 2 Items (height < `maxOptionsHeight`) + // so options height shrinks to 2 Items combined height. + final double twoItemsHeight = await getDefaultOptionsHeight(tester, 'el'); + expect(twoItemsHeight, lessThan(maxOptionsHeight)); + + // Returns 1 item (height < `maxOptionsHeight`) from `kOptions` + // so options height shrinks to 1 items height. + final double oneItemsHeight = await getDefaultOptionsHeight(tester, 'elep'); + expect(oneItemsHeight, lessThan(twoItemsHeight)); + }, + ); + + testWidgets('initialValue sets initial text field value', (WidgetTester tester) async { + late String lastSelection; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + initialValue: const TextEditingValue(text: 'lem'), + onSelected: (String selection) { + lastSelection = selection; + }, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + // The field is always rendered, but the options are not unless needed. + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + expect(tester.widget<TextFormField>(find.byType(TextFormField)).controller!.text, 'lem'); + + // Focus the empty field. All the options are displayed. + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + final list = find.byType(ListView).evaluate().first.widget as ListView; + // Displays just one option ('lemur'). + expect(list.semanticChildCount, 1); + + // Select a option. The options hide and the field updates to show the + // selection. + await tester.tap(find.byType(InkWell).first); + await tester.pump(); + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(ListView), findsNothing); + final field = find.byType(TextFormField).evaluate().first.widget as TextFormField; + expect(field.controller!.text, 'lemur'); + expect(lastSelection, 'lemur'); + }); + + // Ensures that the option with the given label has a given background color + // if given, or no background if color is null. + void checkOptionHighlight(WidgetTester tester, String label, Color? color) { + final RenderBox renderBox = tester.renderObject<RenderBox>( + find.ancestor(matching: find.byType(Container), of: find.text(label)), + ); + if (color != null) { + // Check to see that the container is painted with the highlighted background color. + expect(renderBox, paints..rect(color: color)); + } else { + // There should only be a paragraph painted. + expect(renderBox, paintsExactlyCountTimes(const Symbol('drawRect'), 0)); + expect(renderBox, paints..paragraph()); + } + } + + testWidgets('keyboard navigation of the options properly highlights the option', ( + WidgetTester tester, + ) async { + const highlightColor = Color(0xFF112233); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(focusColor: highlightColor), + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(TextFormField)); + await tester.enterText(find.byType(TextFormField), 'el'); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + final list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, 2); + + // Initially the first option should be highlighted + checkOptionHighlight(tester, 'chameleon', highlightColor); + checkOptionHighlight(tester, 'elephant', null); + + // Move the selection down + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + // Highlight should be moved to the second item + checkOptionHighlight(tester, 'chameleon', null); + checkOptionHighlight(tester, 'elephant', highlightColor); + }); + + testWidgets('keyboard navigation keeps the highlighted option scrolled into view', ( + WidgetTester tester, + ) async { + const highlightColor = Color(0xFF112233); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(focusColor: highlightColor), + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(TextFormField)); + await tester.enterText(find.byType(TextFormField), 'e'); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + final list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, 6); + + final Rect optionsGroupRect = tester.getRect(find.byType(ListView)); + const optionsGroupPadding = 16.0; + + // Highlighted item should be at the top. + checkOptionHighlight(tester, 'chameleon', highlightColor); + expect( + tester.getTopLeft(find.text('chameleon')).dy, + equals(optionsGroupRect.top + optionsGroupPadding), + ); + + // Move down the list of options. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Select 'elephant'. + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Select 'goose'. + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Select 'lemur'. + await tester.pumpAndSettle(); + + // Highlighted item 'lemur' should be centered in the options popup. + checkOptionHighlight(tester, 'lemur', highlightColor); + expect(tester.getCenter(find.text('lemur')).dy, equals(optionsGroupRect.center.dy)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Select 'mouse'. + await tester.pumpAndSettle(); + + checkOptionHighlight(tester, 'mouse', highlightColor); + + // First item should have scrolled off the top, and not be selected. + expect(find.text('chameleon'), findsNothing); + + // The other items on screen should not be selected. + checkOptionHighlight(tester, 'goose', null); + checkOptionHighlight(tester, 'lemur', null); + checkOptionHighlight(tester, 'northern white rhinoceros', null); + }); + + group('optionsViewOpenDirection', () { + testWidgets('default (down)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'], + ), + ), + ), + ); + final OptionsViewOpenDirection actual = tester + .widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>)) + .optionsViewOpenDirection; + expect(actual, equals(OptionsViewOpenDirection.down)); + }); + + testWidgets('down', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete<String>( + // ignore: avoid_redundant_argument_values + optionsViewOpenDirection: OptionsViewOpenDirection.down, + optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'], + ), + ), + ), + ); + final OptionsViewOpenDirection actual = tester + .widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>)) + .optionsViewOpenDirection; + expect(actual, equals(OptionsViewOpenDirection.down)); + }); + + testWidgets('up', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Autocomplete<String>( + optionsViewOpenDirection: OptionsViewOpenDirection.up, + optionsBuilder: (TextEditingValue textEditingValue) => <String>['aa'], + ), + ), + ), + ), + ); + final OptionsViewOpenDirection actual = tester + .widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>)) + .optionsViewOpenDirection; + expect(actual, equals(OptionsViewOpenDirection.up)); + + await tester.tap(find.byType(RawAutocomplete<String>)); + await tester.enterText(find.byType(RawAutocomplete<String>), 'a'); + await tester.pump(); + expect(find.text('aa').hitTestable(), findsOneWidget); + }); + + testWidgets('automatic: open in the direction with more space', (WidgetTester tester) async { + final GlobalKey fieldKey = GlobalKey(); + final GlobalKey optionsKey = GlobalKey(); + late StateSetter setState; + Alignment alignment = Alignment.topCenter; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return Align( + alignment: alignment, + child: Autocomplete<String>( + optionsViewOpenDirection: OptionsViewOpenDirection.mostSpace, + optionsBuilder: (TextEditingValue textEditingValue) => <String>['a', 'b', 'c'], + fieldViewBuilder: + ( + BuildContext context, + TextEditingController controller, + FocusNode focusNode, + VoidCallback onFieldSubmitted, + ) { + return TextField( + key: fieldKey, + controller: controller, + focusNode: focusNode, + ); + }, + optionsViewBuilder: + ( + BuildContext context, + AutocompleteOnSelected<String> onSelected, + Iterable<String> options, + ) { + return Material( + child: ListView( + key: optionsKey, + children: options.map((String option) => Text(option)).toList(), + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + + // Show the options. It should open downwards since there is more space. + await tester.tap(find.byKey(fieldKey)); + await tester.pump(); + + expect( + tester.getBottomLeft(find.byKey(fieldKey)), + offsetMoreOrLessEquals(tester.getTopLeft(find.byKey(optionsKey))), + ); + + // Move the field to the bottom. + setState(() { + alignment = Alignment.bottomCenter; + }); + await tester.pump(); + + // The options should now open upwards, since there is more space above. + expect( + tester.getTopLeft(find.byKey(fieldKey)), + offsetMoreOrLessEquals(tester.getBottomLeft(find.byKey(optionsKey))), + ); + + // Move the field to the center. + setState(() { + alignment = Alignment.center; + }); + await tester.pump(); + + // Show the options. It should open downwards since there is more space. + expect( + tester.getBottomLeft(find.byKey(fieldKey)), + offsetMoreOrLessEquals(tester.getTopLeft(find.byKey(optionsKey))), + ); + }); + }); + + testWidgets('can jump to options that are not yet built', (WidgetTester tester) async { + const highlightColor = Color(0xFF112233); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(focusColor: highlightColor), + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(TextFormField)); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + final list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, kOptions.length); + + Finder optionFinder(int index) { + return find.ancestor( + matching: find.byType(Container), + of: find.text(kOptions.elementAt(index)), + ); + } + + expect(optionFinder(0), findsOneWidget); + expect(optionFinder(kOptions.length - 1), findsNothing); + + // Jump to the bottom. + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pumpAndSettle(); + expect(optionFinder(0), findsNothing); + expect(optionFinder(kOptions.length - 1), findsOneWidget); + checkOptionHighlight(tester, kOptions.last, highlightColor); + + // Jump to the top. + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pumpAndSettle(); + expect(optionFinder(0), findsOneWidget); + expect(optionFinder(kOptions.length - 1), findsNothing); + checkOptionHighlight(tester, kOptions.first, highlightColor); + }); + + testWidgets( + 'passes textEditingController, focusNode to textEditingController, focusNode RawAutocomplete', + (WidgetTester tester) async { + final textEditingController = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(textEditingController.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Autocomplete<String>( + focusNode: focusNode, + textEditingController: textEditingController, + optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'], + ), + ), + ), + ), + ); + + final RawAutocomplete<String> rawAutocomplete = tester.widget( + find.byType(RawAutocomplete<String>), + ); + expect(rawAutocomplete.textEditingController, textEditingController); + expect(rawAutocomplete.focusNode, focusNode); + }, + ); + + testWidgets('when field scrolled offscreen, reshown selected value when scrolled back', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + final textEditingController = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(textEditingController.dispose); + addTearDown(focusNode.dispose); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: scrollController, + children: <Widget>[ + Autocomplete<String>( + focusNode: focusNode, + textEditingController: textEditingController, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + const SizedBox(height: 1000.0), + ], + ), + ), + ), + ); + + /// Select an option. + await tester.tap(find.byType(TextField)); + await tester.pump(); + const textSelection = 'chameleon'; + await tester.tap(find.text(textSelection)); + + // Unfocus and scroll to deconstruct the widge + final field = find.byType(TextField).evaluate().first.widget as TextField; + field.focusNode?.unfocus(); + scrollController.jumpTo(2000.0); + await tester.pumpAndSettle(); + + /// Scroll to go back to the widget. + scrollController.jumpTo(0.0); + await tester.pumpAndSettle(); + + /// Checks that the option selected is still present. + final field2 = find.byType(TextField).evaluate().first.widget as TextField; + expect(field2.controller!.text, textSelection); + }); + + testWidgets('Autocomplete suggestions are hit-tested before ListTiles', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + const options = <String>['Apple', 'Banana', 'Cherry']; + return options.where( + (String option) => option.toLowerCase().contains(textEditingValue.text), + ); + }, + ), + for (int i = 0; i < 3; i++) ListTile(title: Text('Item $i'), onTap: () {}), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final Finder cherryFinder = find.text('Cherry'); + expect(cherryFinder, findsOneWidget); + + await tester.tap(cherryFinder); + await tester.pump(); + + expect(find.widgetWithText(TextField, 'Cherry'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('Autocomplete renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold( + body: Autocomplete<String>( + initialValue: const TextEditingValue(text: 'X'), + optionsBuilder: (TextEditingValue textEditingValue) => <String>['Y'], + ), + ), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText), Size.zero); + }); + + testWidgets('autocomplete options have button semantics', (WidgetTester tester) async { + const highlightColor = Color(0xFF112233); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(focusColor: highlightColor), + home: Scaffold( + body: Autocomplete<String>( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + await tester.tap(find.byType(TextField)); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'aa'); + await tester.pump(); + expect( + tester.getSemantics(find.text('aardvark')), + matchesSemantics( + isButton: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + label: 'aardvark', + ), + ); + }); + + testWidgets('Same option in Autocomplete should be selectable again after text is cleared', ( + WidgetTester tester, + ) async { + final textCtrl = TextEditingController(); + addTearDown(textCtrl.dispose); + final textFocus = FocusNode(); + addTearDown(textFocus.dispose); + final listItem = <String>['test', 'abc', 'dexter']; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: <Widget>[ + Expanded( + child: Autocomplete<String>( + textEditingController: textCtrl, + focusNode: textFocus, + optionsBuilder: (TextEditingValue textEditingValue) { + return listItem.where( + (String e) => e.toLowerCase().contains(textEditingValue.text.toLowerCase()), + ); + }, + ), + ), + IconButton( + key: const ValueKey<String>('clear'), + onPressed: () { + textCtrl.clear(); + }, + icon: const Icon(Icons.add), + ), + ], + ), + ), + ), + ); + + // Open the popup menu. + await tester.enterText(find.byType(TextField), ''); + await tester.pumpAndSettle(); + expect(find.text('test'), findsOneWidget); + + // Select option 'test' + await tester.tap(find.text('test')); + await tester.pumpAndSettle(); + expect(textCtrl.text, 'test'); + + // Clear text using the icon button + await tester.tap(find.byKey(const ValueKey<String>('clear'))); + textFocus.unfocus(); + await tester.pumpAndSettle(); + expect(textCtrl.text, ''); + + // Select 'test' again + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(find.text('test'), findsWidgets); + await tester.tap(find.text('test').last); + await tester.pumpAndSettle(); + + // The text field should be updated to 'test'. + expect(textCtrl.text, 'test'); + }); +} diff --git a/packages/material_ui/test/material/back_button_test.dart b/packages/material_ui/test/material/back_button_test.dart new file mode 100644 index 000000000000..728b923f6a53 --- /dev/null +++ b/packages/material_ui/test/material/back_button_test.dart @@ -0,0 +1,474 @@ +// 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:ui' show PointerDeviceKind; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RendererBinding; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BackButton', () { + testWidgets('BackButton control test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Material(child: Center(child: BackButton())); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + + await tester.tap(find.byType(BackButton)); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('BackButton onPressed overrides default pop behavior', (WidgetTester tester) async { + var customCallbackWasCalled = false; + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return Material( + child: Center(child: BackButton(onPressed: () => customCallbackWasCalled = true)), + ); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); // Start off on the second page. + expect(customCallbackWasCalled, false); // customCallbackWasCalled should still be false. + await tester.tap(find.byType(BackButton)); + + await tester.pumpAndSettle(); + + // We're still on the second page. + expect(find.text('Home'), findsNothing); + // But the custom callback is called. + expect(customCallbackWasCalled, true); + }); + + testWidgets('BackButton icon', (WidgetTester tester) async { + final Key androidKey = UniqueKey(); + final Key iOSKey = UniqueKey(); + final Key linuxKey = UniqueKey(); + final Key macOSKey = UniqueKey(); + final Key windowsKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Column( + children: <Widget>[ + Theme( + data: ThemeData(platform: TargetPlatform.android), + child: BackButtonIcon(key: androidKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.iOS), + child: BackButtonIcon(key: iOSKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.linux), + child: BackButtonIcon(key: linuxKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.macOS), + child: BackButtonIcon(key: macOSKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.windows), + child: BackButtonIcon(key: windowsKey), + ), + ], + ), + ), + ); + + final Icon androidIcon = tester.widget( + find.descendant(of: find.byKey(androidKey), matching: find.byType(Icon)), + ); + final Icon iOSIcon = tester.widget( + find.descendant(of: find.byKey(iOSKey), matching: find.byType(Icon)), + ); + final Icon linuxIcon = tester.widget( + find.descendant(of: find.byKey(linuxKey), matching: find.byType(Icon)), + ); + final Icon macOSIcon = tester.widget( + find.descendant(of: find.byKey(macOSKey), matching: find.byType(Icon)), + ); + final Icon windowsIcon = tester.widget( + find.descendant(of: find.byKey(windowsKey), matching: find.byType(Icon)), + ); + expect(iOSIcon.icon == androidIcon.icon, kIsWeb ? isTrue : isFalse); + expect(linuxIcon.icon == androidIcon.icon, isTrue); + expect(macOSIcon.icon == androidIcon.icon, kIsWeb ? isTrue : isFalse); + expect(macOSIcon.icon == iOSIcon.icon, isTrue); + expect(windowsIcon.icon == androidIcon.icon, isTrue); + }); + + testWidgets('BackButton color', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: BackButton(color: Colors.red)), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(BackButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('BackButton color with ButtonStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: BackButton( + style: ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + ), + ), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(BackButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('BackButton.style.iconColor parameter overrides BackButton.color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: BackButton( + color: Colors.green, + style: ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + ), + ), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(BackButton), matching: find.byType(RichText)), + ); + + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('BackButton semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Material(child: Center(child: BackButton())); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + final String? expectedLabel; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expectedLabel = 'Back'; + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expectedLabel = null; + } + expect( + tester.getSemantics(find.byType(BackButton)), + matchesSemantics( + tooltip: 'Back', + label: expectedLabel, + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: defaultTargetPlatform != TargetPlatform.iOS, + isFocusable: true, + ), + ); + handle.dispose(); + }, variant: TargetPlatformVariant.all()); + }); + + group('CloseButton', () { + testWidgets('CloseButton semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Material(child: Center(child: CloseButton())); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + final String? expectedLabel; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expectedLabel = 'Close'; + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expectedLabel = null; + } + expect( + tester.getSemantics(find.byType(CloseButton)), + matchesSemantics( + tooltip: 'Close', + label: expectedLabel, + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: defaultTargetPlatform != TargetPlatform.iOS, + isFocusable: true, + ), + ); + handle.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('CloseButton color', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: CloseButton(color: Colors.red)), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('CloseButton color with ButtonStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: CloseButton( + style: ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + ), + ), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('CloseButton.style.iconColor parameter overrides CloseButton.color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: CloseButton( + color: Colors.green, + style: ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + ), + ), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(CloseButton), matching: find.byType(RichText)), + ); + + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('CloseButton onPressed overrides default pop behavior', ( + WidgetTester tester, + ) async { + var customCallbackWasCalled = false; + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Home')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return Material( + child: Center(child: CloseButton(onPressed: () => customCallbackWasCalled = true)), + ); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pumpAndSettle(); + expect(find.text('Home'), findsNothing); // Start off on the second page. + expect(customCallbackWasCalled, false); // customCallbackWasCalled should still be false. + await tester.tap(find.byType(CloseButton)); + + await tester.pumpAndSettle(); + + // We're still on the second page. + expect(find.text('Home'), findsNothing); + // The custom callback is called, setting customCallbackWasCalled to true. + expect(customCallbackWasCalled, true); + }); + }); + + testWidgets('EndDrawerButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Scaffold(body: EndDrawerButton())), + ), + ), + ); + expect(tester.getSize(find.byType(EndDrawerButton)), Size.zero); + }); + + testWidgets('CloseButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Scaffold(body: CloseButton())), + ), + ), + ); + expect(tester.getSize(find.byType(CloseButton)), Size.zero); + }); + + testWidgets('BackButton renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Scaffold(body: BackButton())), + ), + ), + ); + final Finder backButtonIcon = find.byType(BackButtonIcon); + expect(tester.getSize(backButtonIcon).isEmpty, isTrue); + }); + + testWidgets('BackButton has expected default mouse cursor on hover', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: BackButton()))); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(tester.getCenter(find.byType(BackButton))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CloseButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: CloseButton()))); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(tester.getCenter(find.byType(CloseButton))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('BackButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: BackButton( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(BackButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('CloseButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CloseButton( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(CloseButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); +} diff --git a/packages/material_ui/test/material/backdrop_filter_test.dart b/packages/material_ui/test/material/backdrop_filter_test.dart new file mode 100644 index 000000000000..ec53fca0fd1c --- /dev/null +++ b/packages/material_ui/test/material/backdrop_filter_test.dart @@ -0,0 +1,191 @@ +// 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. + +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets("Material2 - BackdropFilter's cull rect does not shrink", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Stack( + fit: StackFit.expand, + children: <Widget>[ + Text('0 0 ' * 10000), + Center( + // ClipRect needed for filtering the 200x200 area instead of the + // whole screen. + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + child: Container( + alignment: Alignment.center, + width: 200.0, + height: 200.0, + child: const Text('Hello World'), + ), + ), + ), + ), + ], + ), + ), + ), + ); + await expectLater( + find.byType(RepaintBoundary).first, + matchesGoldenFile('m2_backdrop_filter_test.cull_rect.png'), + ); + }); + + testWidgets("Material3 - BackdropFilter's cull rect does not shrink", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + fit: StackFit.expand, + children: <Widget>[ + Text('0 0 ' * 10000), + Center( + // ClipRect needed for filtering the 200x200 area instead of the + // whole screen. + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + child: Container( + alignment: Alignment.center, + width: 200.0, + height: 200.0, + child: const Text('Hello World'), + ), + ), + ), + ), + ], + ), + ), + ), + ); + await expectLater( + find.byType(RepaintBoundary).first, + matchesGoldenFile('m3_backdrop_filter_test.cull_rect.png'), + ); + }); + + testWidgets('Material2 - BackdropFilter blendMode on saveLayer', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Opacity( + opacity: 0.9, + child: Stack( + fit: StackFit.expand, + children: <Widget>[ + Text('0 0 ' * 10000), + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // ClipRect needed for filtering the 200x200 area instead of the + // whole screen. + children: <Widget>[ + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + child: Container( + alignment: Alignment.center, + width: 200.0, + height: 200.0, + color: Colors.yellow.withAlpha(0x7), + ), + ), + ), + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + blendMode: BlendMode.src, + child: Container( + alignment: Alignment.center, + width: 200.0, + height: 200.0, + color: Colors.yellow.withAlpha(0x7), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + await expectLater( + find.byType(RepaintBoundary).first, + matchesGoldenFile('m2_backdrop_filter_test.saveLayer.blendMode.png'), + ); + }); + + testWidgets('Material3 - BackdropFilter blendMode on saveLayer', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Opacity( + opacity: 0.9, + child: Stack( + fit: StackFit.expand, + children: <Widget>[ + Text('0 0 ' * 10000), + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // ClipRect needed for filtering the 200x200 area instead of the + // whole screen. + children: <Widget>[ + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + child: Container( + alignment: Alignment.center, + width: 200.0, + height: 200.0, + color: Colors.yellow.withAlpha(0x7), + ), + ), + ), + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + blendMode: BlendMode.src, + child: Container( + alignment: Alignment.center, + width: 200.0, + height: 200.0, + color: Colors.yellow.withAlpha(0x7), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + await expectLater( + find.byType(RepaintBoundary).first, + matchesGoldenFile('m3_backdrop_filter_test.saveLayer.blendMode.png'), + ); + }); +} diff --git a/packages/material_ui/test/material/badge_test.dart b/packages/material_ui/test/material/badge_test.dart new file mode 100644 index 000000000000..c7964273ff19 --- /dev/null +++ b/packages/material_ui/test/material/badge_test.dart @@ -0,0 +1,529 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Large Badge defaults', (WidgetTester tester) async { + late final ThemeData theme; + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (BuildContext context) { + // theme.textTheme is updated when the MaterialApp is built. + theme = Theme.of(context); + return const Badge(label: Text('0'), child: Icon(Icons.add)); + }, + ), + ), + ), + ); + + expect( + tester.renderObject<RenderParagraph>(find.text('0')).text.style, + theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError), + ); + + // default badge alignment = AlignmentDirection.topEnd + // default offset for LTR = Offset(4, -4) + // default padding = EdgeInsets.symmetric(horizontal: 4) + // default largeSize = 16 + // '0'.width = 12 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + expect(tester.getTopLeft(find.text('0')), const Offset(16, -4)); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + final rrect = RRect.fromLTRBR(12, -4, 31.5, 12, const Radius.circular(8)); + expect(box, paints..rrect(rrect: rrect, color: theme.colorScheme.error)); + }); + + testWidgets('Large Badge defaults with RTL', (WidgetTester tester) async { + late final ThemeData theme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (BuildContext context) { + // theme.textTheme is updated when the MaterialApp is built. + theme = Theme.of(context); + return const Badge(label: Text('0'), child: Icon(Icons.add)); + }, + ), + ), + ), + ), + ); + + expect( + tester.renderObject<RenderParagraph>(find.text('0')).text.style, + theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError), + ); + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + expect(tester.getTopLeft(find.text('0')), const Offset(0, -4)); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + final rrect = RRect.fromLTRBR(-4, -4, 15.5, 12, const Radius.circular(8)); + expect(box, paints..rrect(rrect: rrect, color: theme.colorScheme.error)); + }); + + // Essentially the same as 'Large Badge defaults' + testWidgets('Badge.count', (WidgetTester tester) async { + late final ThemeData theme; + + Widget buildFrame(int count) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (BuildContext context) { + // theme.textTheme is updated when the MaterialApp is built. + if (count == 0) { + theme = Theme.of(context); + } + return Badge.count(count: count, child: const Icon(Icons.add)); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(0)); + + expect( + tester.renderObject<RenderParagraph>(find.text('0')).text.style, + theme.textTheme.labelSmall!.copyWith(color: theme.colorScheme.onError), + ); + + // default badge alignment = AlignmentDirectional(12, -4) + // default padding = EdgeInsets.symmetric(horizontal: 4) + // default largeSize = 16 + // '0'.width = 12 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + // x = alignment.start + padding.left + // y = alignment.top + expect(tester.getTopLeft(find.text('0')), const Offset(16, -4)); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // '0'.width = 12 + // L = alignment.start + // T = alignment.top + // R = L + '0'.width + padding.width + // B = T + largeSize, R = largeSize/2 + final rrect = RRect.fromLTRBR(12, -4, 31.5, 12, const Radius.circular(8)); + expect(box, paints..rrect(rrect: rrect, color: theme.colorScheme.error)); + + await tester.pumpWidget(buildFrame(1000)); + expect(find.text('999+'), findsOneWidget); + }); + + testWidgets('Small Badge defaults', (WidgetTester tester) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Align( + alignment: Alignment.topLeft, + child: Badge(child: Icon(Icons.add)), + ), + ), + ); + + // default badge location is end=0, top=0 + // default padding = EdgeInsets.symmetric(horizontal: 4) + // default smallSize = 6 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // L = icon.size.width - smallSize + // T = 0 + // R = icon.size.width + // B = smallSize + expect( + box, + paints..rrect( + rrect: RRect.fromLTRBR(18, 0, 24, 6, const Radius.circular(3)), + color: theme.colorScheme.error, + ), + ); + }); + + testWidgets('Small Badge RTL defaults', (WidgetTester tester) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topLeft, + child: Badge(child: Icon(Icons.add)), + ), + ), + ), + ); + + // default badge location is end=0, top=0 + // default smallSize = 6 + // icon.width = 24 + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + // L = 0 + // T = 0 + // R = smallSize + // B = smallSize + expect( + box, + paints..rrect( + rrect: RRect.fromLTRBR(0, 0, 6, 6, const Radius.circular(3)), + color: theme.colorScheme.error, + ), + ); + }); + + testWidgets('Large Badge textStyle and colors', (WidgetTester tester) async { + final theme = ThemeData(); + const green = Color(0xff00ff00); + const black = Color(0xff000000); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Align( + alignment: Alignment.topLeft, + child: Badge( + textColor: green, + backgroundColor: black, + textStyle: TextStyle(fontSize: 10), + label: Text('0'), + child: Icon(Icons.add), + ), + ), + ), + ); + + final TextStyle textStyle = tester.renderObject<RenderParagraph>(find.text('0')).text.style!; + expect(textStyle.fontSize, 10); + expect(textStyle.color, green); + expect(tester.renderObject(find.byType(Badge)), paints..rrect(color: black)); + }); + + testWidgets('isLabelVisible', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge(label: Text('0'), isLabelVisible: false, child: Icon(Icons.add)), + ), + ), + ); + + expect(find.text('0'), findsNothing); + expect(find.byType(Icon), findsOneWidget); + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + final RenderBox box = tester.renderObject(find.byType(Badge)); + expect(box, isNot(paints..rrect())); + }); + + testWidgets('Large Badge alignment', (WidgetTester tester) async { + const badgeRadius = Radius.circular(8); + + Widget buildFrame(Alignment alignment, [Offset offset = Offset.zero]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge( + // Default largeSize = 16, badge with label is "large". + label: Container(width: 8, height: 8, color: Colors.blue), + alignment: alignment, + offset: offset, + child: Container(color: const Color(0xFF00FF00), width: 200, height: 200), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Alignment.topLeft)); + final RenderBox box = tester.renderObject(find.byType(Badge)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 16, 16, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.topCenter)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 0, 100 + 8, 16, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.topRight)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 0, 200, 16, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.centerLeft)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100, 16, 100 + 16, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.centerRight)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 100, 200, 100 + 16, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomLeft)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200, 16, 200 + 16, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomCenter)); + expect( + box, + paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 200, 100 + 8, 200 + 16, badgeRadius)), + ); + + await tester.pumpWidget(buildFrame(Alignment.bottomRight)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 200, 200, 200 + 16, badgeRadius))); + + const offset = Offset(5, 10); + + await tester.pumpWidget(buildFrame(Alignment.topLeft, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 16, 16, badgeRadius).shift(offset))); + + await tester.pumpWidget(buildFrame(Alignment.topCenter, offset)); + expect( + box, + paints..rrect(rrect: RRect.fromLTRBR(100 - 8, 0, 100 + 8, 16, badgeRadius).shift(offset)), + ); + + await tester.pumpWidget(buildFrame(Alignment.topRight, offset)); + expect( + box, + paints..rrect(rrect: RRect.fromLTRBR(200 - 16, 0, 200, 16, badgeRadius).shift(offset)), + ); + + await tester.pumpWidget(buildFrame(Alignment.centerLeft, offset)); + expect( + box, + paints..rrect(rrect: RRect.fromLTRBR(0, 100, 16, 100 + 16, badgeRadius).shift(offset)), + ); + + await tester.pumpWidget(buildFrame(Alignment.centerRight, offset)); + expect( + box, + paints + ..rrect(rrect: RRect.fromLTRBR(200 - 16, 100, 200, 100 + 16, badgeRadius).shift(offset)), + ); + + await tester.pumpWidget(buildFrame(Alignment.bottomLeft, offset)); + expect( + box, + paints..rrect(rrect: RRect.fromLTRBR(0, 200, 16, 200 + 16, badgeRadius).shift(offset)), + ); + + await tester.pumpWidget(buildFrame(Alignment.bottomCenter, offset)); + expect( + box, + paints + ..rrect(rrect: RRect.fromLTRBR(100 - 8, 200, 100 + 8, 200 + 16, badgeRadius).shift(offset)), + ); + + await tester.pumpWidget(buildFrame(Alignment.bottomRight, offset)); + expect( + box, + paints + ..rrect(rrect: RRect.fromLTRBR(200 - 16, 200, 200, 200 + 16, badgeRadius).shift(offset)), + ); + }); + + testWidgets('Small Badge alignment', (WidgetTester tester) async { + const badgeRadius = Radius.circular(3); + + Widget buildFrame(Alignment alignment, [Offset offset = Offset.zero]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge( + // Default smallSize = 6, badge without label is "small". + alignment: alignment, + offset: offset, // Not used for smallSize badges. + child: Container(color: const Color(0xFF00FF00), width: 200, height: 200), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Alignment.topLeft)); + final RenderBox box = tester.renderObject(find.byType(Badge)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 6, 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.topCenter)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 0, 100 + 3, 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.topRight)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 0, 200, 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.centerLeft)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100, 6, 100 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.centerRight)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 100, 200, 100 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomLeft)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200, 6, 200 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomCenter)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 200, 100 + 3, 200 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomRight)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 200, 200, 200 + 6, badgeRadius))); + + const offset = Offset(5, 10); // Not used for smallSize Badges. + + await tester.pumpWidget(buildFrame(Alignment.topLeft, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 0, 6, 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.topCenter, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 0, 100 + 3, 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.topRight, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 0, 200, 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.centerLeft, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 100, 6, 100 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.centerRight, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 100, 200, 100 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomLeft, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, 200, 6, 200 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomCenter, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(100 - 3, 200, 100 + 3, 200 + 6, badgeRadius))); + + await tester.pumpWidget(buildFrame(Alignment.bottomRight, offset)); + expect(box, paints..rrect(rrect: RRect.fromLTRBR(200 - 6, 200, 200, 200 + 6, badgeRadius))); + }); + + testWidgets('Badge Larger than large size', (WidgetTester tester) async { + const badgeRadius = Radius.circular(15); + + Widget buildFrame(Alignment alignment, [Offset offset = Offset.zero]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge( + // LargeSize = 16, make content of badge bigger than the default. + label: Container(width: 30, height: 30, color: Colors.blue), + alignment: alignment, + offset: offset, + child: Container(color: const Color(0xFF00FF00), width: 200, height: 200), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Alignment.topLeft)); + final RenderBox box = tester.renderObject(find.byType(Badge)); + // Badge should scale with content + expect(box, paints..rrect(rrect: RRect.fromLTRBR(0, -7, 30 + 8, 23, badgeRadius))); + }); + + testWidgets('Badge renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Badge(label: Text('X'))), + ), + ), + ); + final Finder label = find.text('X'); + expect(tester.getSize(label), Size.zero); + }); + + testWidgets('Badge.count maxCount limits displayed value', (WidgetTester tester) async { + Widget buildFrame(int count, [int maxCount = 999]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge.count(count: count, maxCount: maxCount, child: const Icon(Icons.add)), + ), + ); + } + + await tester.pumpWidget(buildFrame(5, 99)); + expect(find.text('5'), findsOneWidget); + + await tester.pumpWidget(buildFrame(99, 99)); + expect(find.text('99'), findsOneWidget); + + await tester.pumpWidget(buildFrame(100, 99)); + expect(find.text('99+'), findsOneWidget); + + await tester.pumpWidget(buildFrame(999)); + expect(find.text('999'), findsOneWidget); + + await tester.pumpWidget(buildFrame(1000)); + expect(find.text('999+'), findsOneWidget); + + // Test default maxCount (999) + await tester.pumpWidget(buildFrame(1001)); + expect(find.text('999+'), findsOneWidget); + }); + + testWidgets('Badge.count asserts on negative count', (WidgetTester tester) async { + Widget buildFrame(int count, [int maxCount = 999]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge.count(count: count, maxCount: maxCount, child: const Icon(Icons.add)), + ), + ); + } + + expect(() => buildFrame(-1), throwsAssertionError); + }); + + testWidgets('Badge.count asserts on non-positive maxCount', (WidgetTester tester) async { + Widget buildFrame(int count, [int maxCount = 999]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge.count(count: count, maxCount: maxCount, child: const Icon(Icons.add)), + ), + ); + } + + expect(() => buildFrame(5, 0), throwsAssertionError); + }); + + testWidgets('Badge.count displays "0" when count is zero', (WidgetTester tester) async { + Widget buildFrame(int count, [int maxCount = 999]) { + return MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Badge.count(count: count, maxCount: maxCount, child: const Icon(Icons.add)), + ), + ); + } + + await tester.pumpWidget(buildFrame(0, 5)); + expect(find.text('0'), findsOneWidget); + }); +} diff --git a/packages/material_ui/test/material/badge_theme_test.dart b/packages/material_ui/test/material/badge_theme_test.dart new file mode 100644 index 000000000000..2c51184727e8 --- /dev/null +++ b/packages/material_ui/test/material/badge_theme_test.dart @@ -0,0 +1,160 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('BadgeThemeData copyWith, ==, hashCode basics', () { + expect(const BadgeThemeData(), const BadgeThemeData().copyWith()); + expect(const BadgeThemeData().hashCode, const BadgeThemeData().copyWith().hashCode); + }); + + test('BadgeThemeData lerp special cases', () { + expect(BadgeThemeData.lerp(null, null, 0), const BadgeThemeData()); + const data = BadgeThemeData(); + expect(identical(BadgeThemeData.lerp(data, data, 0.5), data), true); + }); + + test('BadgeThemeData defaults', () { + const themeData = BadgeThemeData(); + expect(themeData.backgroundColor, null); + expect(themeData.textColor, null); + expect(themeData.smallSize, null); + expect(themeData.largeSize, null); + expect(themeData.textStyle, null); + expect(themeData.padding, null); + expect(themeData.alignment, null); + expect(themeData.offset, null); + }); + + testWidgets('Default BadgeThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const BadgeThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('BadgeThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const BadgeThemeData( + backgroundColor: Color(0xfffffff0), + textColor: Color(0xfffffff1), + smallSize: 1, + largeSize: 2, + textStyle: TextStyle(fontSize: 4), + padding: EdgeInsets.all(5), + alignment: AlignmentDirectional(6, 7), + offset: Offset.zero, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'backgroundColor: ${const Color(0xfffffff0)}', + 'textColor: ${const Color(0xfffffff1)}', + 'smallSize: 1.0', + 'largeSize: 2.0', + 'textStyle: TextStyle(inherit: true, size: 4.0)', + 'padding: EdgeInsets.all(5.0)', + 'alignment: AlignmentDirectional(6.0, 7.0)', + 'offset: Offset(0.0, 0.0)', + ]); + }); + + testWidgets('Badge uses ThemeData badge theme', (WidgetTester tester) async { + const green = Color(0xff00ff00); + const black = Color(0xff000000); + const badgeTheme = BadgeThemeData( + backgroundColor: green, + textColor: black, + smallSize: 5, + largeSize: 20, + textStyle: TextStyle(fontSize: 12), + padding: EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.topRight, + offset: Offset(24, 0), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(badgeTheme: badgeTheme), + home: const Scaffold( + body: Badge(label: Text('1234'), child: Icon(Icons.add)), + ), + ), + ); + + // text width = 48 = fontSize * 4, text height = fontSize + expect(tester.getSize(find.text('1234')), const Size(48, 12)); + + expect(tester.getTopLeft(find.text('1234')), const Offset(33, 2)); + + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + + final TextStyle textStyle = tester.renderObject<RenderParagraph>(find.text('1234')).text.style!; + expect(textStyle.fontSize, 12); + expect(textStyle.color, black); + + final RenderBox box = tester.renderObject(find.byType(Badge)); + expect( + box, + paints + ..rrect(rrect: RRect.fromLTRBR(28, -2, 86, 18, const Radius.circular(10)), color: green), + ); + }); + + // This test is essentially the same as 'Badge uses ThemeData badge theme'. In + // this case the theme is introduced with the BadgeTheme widget instead of + // ThemeData.badgeTheme. + testWidgets('Badge uses BadgeTheme', (WidgetTester tester) async { + const green = Color(0xff00ff00); + const black = Color(0xff000000); + const badgeTheme = BadgeThemeData( + backgroundColor: green, + textColor: black, + smallSize: 5, + largeSize: 20, + textStyle: TextStyle(fontSize: 12), + padding: EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.topRight, + offset: Offset(24, 0), + ); + + await tester.pumpWidget( + const MaterialApp( + home: BadgeTheme( + data: badgeTheme, + child: Scaffold( + body: Badge(label: Text('1234'), child: Icon(Icons.add)), + ), + ), + ), + ); + + expect(tester.getSize(find.text('1234')), const Size(48, 12)); + expect(tester.getTopLeft(find.text('1234')), const Offset(33, 2)); + expect(tester.getSize(find.byType(Badge)), const Size(24, 24)); // default Icon size + expect(tester.getTopLeft(find.byType(Badge)), Offset.zero); + final TextStyle textStyle = tester.renderObject<RenderParagraph>(find.text('1234')).text.style!; + expect(textStyle.fontSize, 12); + expect(textStyle.color, black); + final RenderBox box = tester.renderObject(find.byType(Badge)); + expect( + box, + paints + ..rrect(rrect: RRect.fromLTRBR(28, -2, 86, 18, const Radius.circular(10)), color: green), + ); + }); +} diff --git a/packages/material_ui/test/material/banner_test.dart b/packages/material_ui/test/material/banner_test.dart new file mode 100644 index 000000000000..2c3b66f913bb --- /dev/null +++ b/packages/material_ui/test/material/banner_test.dart @@ -0,0 +1,1247 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('MaterialBanner properties are respected', (WidgetTester tester) async { + const contentText = 'Content'; + const Color backgroundColor = Colors.pink; + const Color surfaceTintColor = Colors.green; + const Color shadowColor = Colors.blue; + const Color dividerColor = Colors.yellow; + const contentTextStyle = TextStyle(color: Colors.pink); + + await tester.pumpWidget( + MaterialApp( + home: MaterialBanner( + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + dividerColor: dividerColor, + contentTextStyle: contentTextStyle, + content: const Text(contentText), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ); + + final Material material = _getMaterialFromBanner(tester); + expect(material.elevation, 0.0); + expect(material.color, backgroundColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shadowColor, shadowColor); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, contentTextStyle); + + final Divider divider = tester.widget<Divider>(find.byType(Divider)); + expect(divider.color, dividerColor); + }); + + testWidgets('MaterialBanner properties are respected when presented by ScaffoldMessenger', ( + WidgetTester tester, + ) async { + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + const Color backgroundColor = Colors.pink; + const Color surfaceTintColor = Colors.green; + const Color shadowColor = Colors.blue; + const Color dividerColor = Colors.yellow; + const contentTextStyle = TextStyle(color: Colors.pink); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + dividerColor: dividerColor, + contentTextStyle: contentTextStyle, + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.elevation, 0.0); + expect(material.color, backgroundColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shadowColor, shadowColor); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, contentTextStyle); + + final Divider divider = tester.widget<Divider>(find.byType(Divider)); + expect(divider.color, dividerColor); + }); + + testWidgets('Actions laid out below content if more than one action', ( + WidgetTester tester, + ) async { + const contentText = 'Content'; + + await tester.pumpWidget( + MaterialApp( + home: MaterialBanner( + content: const Text(contentText), + actions: <Widget>[ + TextButton(child: const Text('Action 1'), onPressed: () {}), + TextButton(child: const Text('Action 2'), onPressed: () {}), + ], + ), + ), + ); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, lessThan(actionsTopLeft.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); + }); + + testWidgets( + 'Actions laid out below content if more than one action when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + actions: <Widget>[ + TextButton( + child: const Text('OK'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, lessThan(actionsTopLeft.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); + }, + ); + + testWidgets('Actions laid out beside content if only one action', (WidgetTester tester) async { + const contentText = 'Content'; + + await tester.pumpWidget( + MaterialApp( + home: MaterialBanner( + content: const Text(contentText), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopRight = tester.getTopRight(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, greaterThan(actionsTopRight.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopRight.dx)); + }); + + testWidgets( + 'Actions laid out beside content if only one action when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopRight = tester.getTopRight(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, greaterThan(actionsTopRight.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopRight.dx)); + }, + ); + + testWidgets('material banner content can scale and has maxScaleFactor', ( + WidgetTester tester, + ) async { + const label = 'A'; + Widget buildApp({required TextScaler textScaler}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: MaterialBanner( + forceActionsBelow: true, + content: const SizedBox(child: Center(child: Text(label))), + actions: <Widget>[TextButton(child: const Text('B'), onPressed: () {})], + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: TextScaler.noScaling)); + expect(find.text(label), findsOneWidget); + + expect(tester.getSize(find.text(label)), const Size(14.25, 20.0)); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(1.1))); + await tester.pumpAndSettle(); + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(15.65, 22.0)), true); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(1.5))); + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(21.25, 30)), true); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(4))); + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(21.25, 30)), true); + }); + + group('MaterialBanner elevation', () { + Widget buildBanner(Key tapTarget, {double? elevation, double? themeElevation}) { + return MaterialApp( + theme: ThemeData(bannerTheme: MaterialBannerThemeData(elevation: themeElevation)), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text('MaterialBanner'), + elevation: elevation, + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ); + } + + testWidgets('Elevation defaults to 0', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + + await tester.pumpWidget(buildBanner(tapTarget)); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + expect(_getMaterialFromBanner(tester).elevation, 0.0); + await tester.tap(find.text('DISMISS')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildBanner(tapTarget, themeElevation: 6.0)); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + expect(_getMaterialFromBanner(tester).elevation, 6.0); + await tester.tap(find.text('DISMISS')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildBanner(tapTarget, elevation: 3.0, themeElevation: 6.0)); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + expect(_getMaterialFromBanner(tester).elevation, 3.0); + await tester.tap(find.text('DISMISS')); + await tester.pumpAndSettle(); + }); + + testWidgets('Uses elevation of MaterialBannerTheme by default', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + + await tester.pumpWidget(buildBanner(tapTarget, themeElevation: 6.0)); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + expect(_getMaterialFromBanner(tester).elevation, 6.0); + await tester.tap(find.text('DISMISS')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildBanner(tapTarget, elevation: 3.0, themeElevation: 6.0)); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + expect(_getMaterialFromBanner(tester).elevation, 3.0); + await tester.tap(find.text('DISMISS')); + await tester.pumpAndSettle(); + }); + + testWidgets('Scaffold body is pushed down if elevation is 0', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + + await tester.pumpWidget(buildBanner(tapTarget, elevation: 0.0)); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentTopLeft = tester.getTopLeft(find.byKey(tapTarget)); + final Offset bannerBottomLeft = tester.getBottomLeft(find.byType(MaterialBanner)); + + expect(contentTopLeft.dx, 0.0); + expect(contentTopLeft.dy, greaterThanOrEqualTo(bannerBottomLeft.dy)); + }); + }); + + testWidgets('MaterialBanner control test', (WidgetTester tester) async { + const helloMaterialBanner = 'Hello MaterialBanner'; + const tapTarget = Key('tap-target'); + const dismissTarget = Key('dismiss-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(helloMaterialBanner), + actions: <Widget>[ + TextButton( + key: dismissTarget, + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + expect(find.text(helloMaterialBanner), findsNothing); + await tester.tap(find.byKey(tapTarget)); + expect(find.text(helloMaterialBanner), findsNothing); + await tester.pump(); // schedule animation + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.tap(find.byKey(dismissTarget)); + await tester.pump(); // begin animation + expect(find.text(helloMaterialBanner), findsOneWidget); // frame 0 of dismiss animation + await tester + .pumpAndSettle(); // 3.75s // last frame of animation, material banner removed from build + expect(find.text(helloMaterialBanner), findsNothing); + }); + + testWidgets('MaterialBanner twice test', (WidgetTester tester) async { + var materialBannerCount = 0; + const tapTarget = Key('tap-target'); + const dismissTarget = Key('dismiss-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + materialBannerCount += 1; + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: Text('banner$materialBannerCount'), + actions: <Widget>[ + TextButton( + key: dismissTarget, + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsNothing); + await tester.tap(find.byKey(tapTarget)); // queue banner1 + await tester.tap(find.byKey(tapTarget)); // queue banner2 + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsNothing); + await tester.pump(); // schedule animation for banner1 + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(); // begin animation + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 0.75s // animation last frame + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.tap(find.byKey(dismissTarget)); + await tester.pump(); // begin animation + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 3.75s // last frame of animation, material banner removed from build, new material banner put in its place + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 4.50s // animation last frame + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 5.25s + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 6.00s + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.tap(find.byKey(dismissTarget)); // reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 7.50s // last frame of animation, material banner removed from build + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsNothing); + }); + + testWidgets('ScaffoldMessenger does not duplicate a MaterialBanner when presenting a SnackBar.', ( + WidgetTester tester, + ) async { + const materialBannerTapTarget = Key('materialbanner-tap-target'); + const snackBarTapTarget = Key('snackbar-tap-target'); + const snackBarText = 'SnackBar'; + const materialBannerText = 'MaterialBanner'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + GestureDetector( + key: snackBarTapTarget, + onTap: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text(snackBarText))); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ), + GestureDetector( + key: materialBannerTapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(materialBannerText), + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(snackBarTapTarget)); + await tester.tap(find.byKey(materialBannerTapTarget)); + await tester.pumpAndSettle(); + + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(materialBannerText), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/39574 + testWidgets('Single action laid out beside content but aligned to the trailing edge', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MaterialBanner( + content: const Text('Content'), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ); + + final Offset actionsTopRight = tester.getTopRight(find.byType(OverflowBar)); + final Offset bannerTopRight = tester.getTopRight(find.byType(MaterialBanner)); + expect(actionsTopRight.dx + 8, bannerTopRight.dx); // actions OverflowBar is padded by 8 + }); + + // Regression test for https://github.com/flutter/flutter/issues/39574 + testWidgets( + 'Single action laid out beside content but aligned to the trailing edge when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text('Content'), + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset actionsTopRight = tester.getTopRight(find.byType(OverflowBar)); + final Offset bannerTopRight = tester.getTopRight(find.byType(MaterialBanner)); + expect(actionsTopRight.dx + 8, bannerTopRight.dx); // actions OverflowBar is padded by 8 + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/39574 + testWidgets('Single action laid out beside content but aligned to the trailing edge - RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: MaterialBanner( + content: const Text('Content'), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ), + ); + + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + final Offset bannerTopLeft = tester.getTopLeft(find.byType(MaterialBanner)); + expect( + actionsTopLeft.dx - 8, + moreOrLessEquals(bannerTopLeft.dx), + ); // actions OverflowBar is padded by 8 + }); + + testWidgets( + 'Single action laid out beside content but aligned to the trailing edge when presented by ScaffoldMessenger - RTL', + (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text('Content'), + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + final Offset bannerTopLeft = tester.getTopLeft(find.byType(MaterialBanner)); + expect( + actionsTopLeft.dx - 8, + moreOrLessEquals(bannerTopLeft.dx), + ); // actions OverflowBar is padded by 8 + }, + ); + + testWidgets('Actions laid out below content if forced override', (WidgetTester tester) async { + const contentText = 'Content'; + + await tester.pumpWidget( + MaterialApp( + home: MaterialBanner( + forceActionsBelow: true, + content: const Text(contentText), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, lessThan(actionsTopLeft.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); + }); + + testWidgets( + 'Actions laid out below content if forced override when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + forceActionsBelow: true, + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, lessThan(actionsTopLeft.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); + }, + ); + + testWidgets('Action widgets layout', (WidgetTester tester) async { + // This regression test ensures that the action widgets layout matches what + // it was, before ButtonBar was replaced by OverflowBar. + Widget buildFrame(int actionCount, TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: MaterialBanner( + content: const SizedBox(width: 100, height: 100), + actions: List<Widget>.generate(actionCount, (int index) { + return SizedBox(width: 64, height: 48, key: ValueKey<int>(index)); + }), + ), + ), + ); + } + + final Finder action0 = find.byKey(const ValueKey<int>(0)); + final Finder action1 = find.byKey(const ValueKey<int>(1)); + final Finder action2 = find.byKey(const ValueKey<int>(2)); + // The action coordinates that follow were obtained by running + // the test code, before ButtonBar was replaced by OverflowBar. + + await tester.pumpWidget(buildFrame(1, TextDirection.ltr)); + expect(tester.getTopLeft(action0), const Offset(728, 28)); + + await tester.pumpWidget(buildFrame(1, TextDirection.rtl)); + expect(tester.getTopLeft(action0), const Offset(8, 28)); + + await tester.pumpWidget(buildFrame(3, TextDirection.ltr)); + expect(tester.getTopLeft(action0), const Offset(584, 130)); + expect(tester.getTopLeft(action1), const Offset(656, 130)); + expect(tester.getTopLeft(action2), const Offset(728, 130)); + + await tester.pumpWidget(buildFrame(3, TextDirection.rtl)); + expect(tester.getTopLeft(action0), const Offset(152, 130)); + expect(tester.getTopLeft(action1), const Offset(80, 130)); + expect(tester.getTopLeft(action2), const Offset(8, 130)); + }); + + testWidgets('Action widgets layout when presented by ScaffoldMessenger', ( + WidgetTester tester, + ) async { + // This regression test ensures that the action widgets layout matches what + // it was, before ButtonBar was replaced by OverflowBar. + + Widget buildFrame(int actionCount, TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: const ValueKey<String>('tap-target'), + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const SizedBox(width: 100, height: 100), + actions: List<Widget>.generate(actionCount, (int index) { + if (index == 0) { + return SizedBox( + width: 64, + height: 48, + key: ValueKey<int>(index), + child: GestureDetector( + key: const ValueKey<String>('dismiss-target'), + onTap: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ); + } + + return SizedBox(width: 64, height: 48, key: ValueKey<int>(index)); + }), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + final Finder tapTarget = find.byKey(const ValueKey<String>('tap-target')); + final Finder dismissTarget = find.byKey(const ValueKey<String>('dismiss-target')); + final Finder action0 = find.byKey(const ValueKey<int>(0)); + final Finder action1 = find.byKey(const ValueKey<int>(1)); + final Finder action2 = find.byKey(const ValueKey<int>(2)); + + // The action coordinates that follow were obtained by running + // the test code, before ButtonBar was replaced by OverflowBar. + + await tester.pumpWidget(buildFrame(1, TextDirection.ltr)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(728, 28)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(1, TextDirection.rtl)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(8, 28)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(3, TextDirection.ltr)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(584, 130)); + expect(tester.getTopLeft(action1), const Offset(656, 130)); + expect(tester.getTopLeft(action2), const Offset(728, 130)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(3, TextDirection.rtl)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(152, 130)); + expect(tester.getTopLeft(action1), const Offset(80, 130)); + expect(tester.getTopLeft(action2), const Offset(8, 130)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + }); + + testWidgets('Action widgets layout with overflow', (WidgetTester tester) async { + // This regression test ensures that the action widgets layout matches what + // it was, before ButtonBar was replaced by OverflowBar. + const actionCount = 4; + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: MaterialBanner( + content: const SizedBox(width: 100, height: 100), + actions: List<Widget>.generate(actionCount, (int index) { + return SizedBox(width: 200, height: 10, key: ValueKey<int>(index)); + }), + ), + ), + ); + } + // The action coordinates that follow were obtained by running + // the test code, before ButtonBar was replaced by OverflowBar. + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(592, 134.0 + index * 10)); + } + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(8, 134.0 + index * 10)); + } + }); + + testWidgets('Action widgets layout with overflow when presented by ScaffoldMessenger', ( + WidgetTester tester, + ) async { + // This regression test ensures that the action widgets layout matches what + // it was, before ButtonBar was replaced by OverflowBar. + + const actionCount = 4; + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: const ValueKey<String>('tap-target'), + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const SizedBox(width: 100, height: 100), + actions: List<Widget>.generate(actionCount, (int index) { + if (index == 0) { + return SizedBox( + width: 200, + height: 10, + key: ValueKey<int>(index), + child: GestureDetector( + key: const ValueKey<String>('dismiss-target'), + onTap: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ); + } + + return SizedBox(width: 200, height: 10, key: ValueKey<int>(index)); + }), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + // The action coordinates that follow were obtained by running + // the test code, before ButtonBar was replaced by OverflowBar. + + final Finder tapTarget = find.byKey(const ValueKey<String>('tap-target')); + final Finder dismissTarget = find.byKey(const ValueKey<String>('dismiss-target')); + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(592, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(8, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + }); + + testWidgets('[overflowAlignment] test', (WidgetTester tester) async { + const actionCount = 4; + Widget buildFrame(TextDirection textDirection, OverflowBarAlignment overflowAlignment) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: MaterialBanner( + overflowAlignment: overflowAlignment, + content: const SizedBox(width: 100, height: 100), + actions: List<Widget>.generate(actionCount, (int index) { + return SizedBox(width: 200, height: 10, key: ValueKey<int>(index)); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.start)); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(8, 134.0 + index * 10)); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.center)); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(300, 134.0 + index * 10)); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.end)); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(592, 134.0 + index * 10)); + } + }); + + testWidgets('[overflowAlignment] test when presented by ScaffoldMessenger', ( + WidgetTester tester, + ) async { + const actionCount = 4; + Widget buildFrame(TextDirection textDirection, OverflowBarAlignment overflowAlignment) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: const ValueKey<String>('tap-target'), + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + overflowAlignment: overflowAlignment, + content: const SizedBox(width: 100, height: 100), + actions: List<Widget>.generate(actionCount, (int index) { + if (index == 0) { + return SizedBox( + width: 200, + height: 10, + key: ValueKey<int>(index), + child: GestureDetector( + key: const ValueKey<String>('dismiss-target'), + onTap: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ); + } + + return SizedBox(width: 200, height: 10, key: ValueKey<int>(index)); + }), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + final Finder tapTarget = find.byKey(const ValueKey<String>('tap-target')); + final Finder dismissTarget = find.byKey(const ValueKey<String>('dismiss-target')); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.start)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(8, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.center)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(300, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.end)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (var index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey<int>(index))), Offset(592, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + }); + + testWidgets('ScaffoldMessenger will alert for MaterialBanners that cannot be presented', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/103004 + await tester.pumpWidget(const MaterialApp(home: Center())); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state<ScaffoldMessengerState>( + find.byType(ScaffoldMessenger), + ); + expect( + () { + scaffoldMessengerState.showMaterialBanner( + const MaterialBanner(content: Text('Banner'), actions: <Widget>[]), + ); + }, + throwsA( + isA<AssertionError>().having( + (AssertionError error) => error.toString(), + 'description', + contains( + 'ScaffoldMessenger.showMaterialBanner was called, but there are currently ' + 'no descendant Scaffolds to present to.', + ), + ), + ), + ); + }); + + testWidgets('Custom Margin respected', (WidgetTester tester) async { + const margin = EdgeInsets.all(30); + await tester.pumpWidget( + MaterialApp( + home: MaterialBanner( + margin: margin, + content: const Text('I am a banner'), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ); + + final Offset topLeft = tester.getTopLeft( + find.descendant(of: find.byType(MaterialBanner), matching: find.byType(Material)).first, + ); + + /// Compare the offset of banner from top left + expect(topLeft.dx, margin.left); + }); + + testWidgets('minActionBarHeight is respected', (WidgetTester tester) async { + const minActionBarHeight = 20.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + body: const MaterialBanner( + minActionBarHeight: minActionBarHeight, + padding: EdgeInsets.zero, + margin: EdgeInsets.zero, + content: SizedBox.shrink(), + actions: <Widget>[SizedBox.shrink()], + ), + ), + ), + ); + + final Size size = tester.getSize(find.byType(MaterialBanner)); + expect(size.height, equals(minActionBarHeight)); + }); + + testWidgets('minimumActionBarHeight is respected when presented by ScaffoldMessenger', ( + WidgetTester tester, + ) async { + const tapTarget = Key('tap-target'); + const minActionBarHeight = 20.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + const MaterialBanner( + content: SizedBox.shrink(), + padding: EdgeInsets.zero, + margin: EdgeInsets.zero, + minActionBarHeight: minActionBarHeight, + actions: <Widget>[SizedBox.shrink()], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Size materialBarSize = tester.getSize(find.byType(MaterialBanner)); + expect(materialBarSize.height, equals(minActionBarHeight)); + }); + + testWidgets('MaterialBanner renders at zero size', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: MaterialBanner(content: Text('X'), actions: <Widget>[SizedBox.shrink()]), + ), + ), + ), + ); + final Finder content = find.text('X'); + expect(tester.getSize(content).isEmpty, isTrue); + }); +} + +Material _getMaterialFromBanner(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(MaterialBanner), matching: find.byType(Material)).first, + ); +} + +Material _getMaterialFromText(WidgetTester tester, String text) { + return tester.widget<Material>(find.widgetWithText(Material, text).first); +} + +RenderParagraph _getTextRenderObjectFromDialog(WidgetTester tester, String text) { + return tester + .element<StatelessElement>( + find.descendant(of: find.byType(MaterialBanner), matching: find.text(text)), + ) + .renderObject! + as RenderParagraph; +} + +bool _sizeAlmostEqual(Size a, Size b, {double maxDiff = 0.05}) { + return (a.width - b.width).abs() <= maxDiff && (a.height - b.height).abs() <= maxDiff; +} diff --git a/packages/material_ui/test/material/banner_theme_test.dart b/packages/material_ui/test/material/banner_theme_test.dart new file mode 100644 index 000000000000..839736247179 --- /dev/null +++ b/packages/material_ui/test/material/banner_theme_test.dart @@ -0,0 +1,591 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('MaterialBannerThemeData copyWith, ==, hashCode basics', () { + expect(const MaterialBannerThemeData(), const MaterialBannerThemeData().copyWith()); + expect( + const MaterialBannerThemeData().hashCode, + const MaterialBannerThemeData().copyWith().hashCode, + ); + }); + + test('MaterialBannerThemeData null fields by default', () { + const bannerTheme = MaterialBannerThemeData(); + expect(bannerTheme.backgroundColor, null); + expect(bannerTheme.surfaceTintColor, null); + expect(bannerTheme.shadowColor, null); + expect(bannerTheme.dividerColor, null); + expect(bannerTheme.contentTextStyle, null); + expect(bannerTheme.elevation, null); + expect(bannerTheme.padding, null); + expect(bannerTheme.leadingPadding, null); + }); + + testWidgets('Default MaterialBannerThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const MaterialBannerThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('MaterialBannerThemeData implements debugFillProperties', ( + WidgetTester tester, + ) async { + final builder = DiagnosticPropertiesBuilder(); + const MaterialBannerThemeData( + backgroundColor: Color(0xfffffff0), + surfaceTintColor: Color(0xfffffff1), + shadowColor: Color(0xfffffff2), + dividerColor: Color(0xfffffff3), + contentTextStyle: TextStyle(color: Color(0xfffffff4)), + elevation: 4.0, + padding: EdgeInsets.all(20.0), + leadingPadding: EdgeInsets.only(left: 8.0), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'backgroundColor: ${const Color(0xfffffff0)}', + 'surfaceTintColor: ${const Color(0xfffffff1)}', + 'shadowColor: ${const Color(0xfffffff2)}', + 'dividerColor: ${const Color(0xfffffff3)}', + 'contentTextStyle: TextStyle(inherit: true, color: ${const Color(0xfffffff4)})', + 'elevation: 4.0', + 'padding: EdgeInsets.all(20.0)', + 'leadingPadding: EdgeInsets(8.0, 0.0, 0.0, 0.0)', + ]); + }); + + testWidgets('Material3 - Passing no MaterialBannerThemeData returns defaults', ( + WidgetTester tester, + ) async { + const contentText = 'Content'; + final theme = ThemeData(); + late final ThemeData localizedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + builder: (BuildContext context, Widget? child) { + localizedTheme = Theme.of(context); + return child!; + }, + home: Scaffold( + body: MaterialBanner( + content: const Text(contentText), + leading: const Icon(Icons.umbrella), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ), + ); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, theme.colorScheme.surfaceContainerLow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.shadowColor, null); + expect(material.elevation, 0.0); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, localizedTheme.textTheme.bodyMedium); + + final Offset rowTopLeft = tester.getTopLeft(find.byType(Row)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.umbrella)); + expect(rowTopLeft.dy - materialTopLeft.dy, 2.0); // Default single line top padding. + expect(rowTopLeft.dx - materialTopLeft.dx, 16.0); // Default single line start padding. + expect(leadingTopLeft.dy - materialTopLeft.dy, 16); // Default leading padding. + expect(leadingTopLeft.dx - materialTopLeft.dx, 16); // Default leading padding. + + final Divider divider = tester.widget<Divider>(find.byType(Divider)); + expect(divider.color, theme.colorScheme.outlineVariant); + }); + + testWidgets( + 'Material3 - Passing no MaterialBannerThemeData returns defaults when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + final theme = ThemeData(); + late final ThemeData localizedTheme; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + localizedTheme = Theme.of(context); + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + leading: const Icon(Icons.umbrella), + actions: <Widget>[ + TextButton(child: const Text('Action'), onPressed: () {}), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, theme.colorScheme.surfaceContainerLow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.shadowColor, null); + expect(material.elevation, 0.0); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, localizedTheme.textTheme.bodyMedium); + + final Offset rowTopLeft = tester.getTopLeft(find.byType(Row)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.umbrella)); + expect(rowTopLeft.dy - materialTopLeft.dy, 2.0); // Default single line top padding. + expect(rowTopLeft.dx - materialTopLeft.dx, 16.0); // Default single line start padding. + expect(leadingTopLeft.dy - materialTopLeft.dy, 16); // Default leading padding. + expect(leadingTopLeft.dx - materialTopLeft.dx, 16); // Default leading padding. + + final Divider divider = tester.widget<Divider>(find.byType(Divider)); + expect(divider.color, theme.colorScheme.outlineVariant); + }, + ); + + testWidgets('MaterialBanner uses values from MaterialBannerThemeData', ( + WidgetTester tester, + ) async { + final MaterialBannerThemeData bannerTheme = _bannerTheme(); + const contentText = 'Content'; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bannerTheme: bannerTheme), + home: Scaffold( + body: MaterialBanner( + leading: const Icon(Icons.ac_unit), + content: const Text(contentText), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ), + ); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, bannerTheme.backgroundColor); + expect(material.surfaceTintColor, bannerTheme.surfaceTintColor); + expect(material.shadowColor, bannerTheme.shadowColor); + expect(material.elevation, bannerTheme.elevation); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, bannerTheme.contentTextStyle); + + final Offset contentTopLeft = tester.getTopLeft(_textFinder(contentText)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.ac_unit)); + expect(contentTopLeft.dy - materialTopLeft.dy, 24); + expect(contentTopLeft.dx - materialTopLeft.dx, 41); + expect(leadingTopLeft.dy - materialTopLeft.dy, 19); + expect(leadingTopLeft.dx - materialTopLeft.dx, 11); + + expect(find.byType(Divider), findsNothing); + }); + + testWidgets( + 'MaterialBanner uses values from MaterialBannerThemeData when presented by ScaffoldMessenger', + (WidgetTester tester) async { + final MaterialBannerThemeData bannerTheme = _bannerTheme(); + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bannerTheme: bannerTheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + leading: const Icon(Icons.ac_unit), + content: const Text(contentText), + actions: <Widget>[ + TextButton(child: const Text('Action'), onPressed: () {}), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, bannerTheme.backgroundColor); + expect(material.surfaceTintColor, bannerTheme.surfaceTintColor); + expect(material.shadowColor, bannerTheme.shadowColor); + expect(material.elevation, bannerTheme.elevation); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, bannerTheme.contentTextStyle); + + final Offset contentTopLeft = tester.getTopLeft(_textFinder(contentText)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.ac_unit)); + expect(contentTopLeft.dy - materialTopLeft.dy, 24); + expect(contentTopLeft.dx - materialTopLeft.dx, 41); + expect(leadingTopLeft.dy - materialTopLeft.dy, 19); + expect(leadingTopLeft.dx - materialTopLeft.dx, 11); + + expect(find.byType(Divider), findsNothing); + }, + ); + + testWidgets('MaterialBanner widget properties take priority over theme', ( + WidgetTester tester, + ) async { + const Color backgroundColor = Colors.purple; + const Color surfaceTintColor = Colors.red; + const Color shadowColor = Colors.orange; + const textStyle = TextStyle(color: Colors.green); + final MaterialBannerThemeData bannerTheme = _bannerTheme(); + const contentText = 'Content'; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bannerTheme: bannerTheme), + home: Scaffold( + body: MaterialBanner( + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + elevation: 6.0, + leading: const Icon(Icons.ac_unit), + contentTextStyle: textStyle, + content: const Text(contentText), + padding: const EdgeInsets.all(10), + leadingPadding: const EdgeInsets.all(12), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ), + ); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, backgroundColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, 6.0); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, textStyle); + + final Offset contentTopLeft = tester.getTopLeft(_textFinder(contentText)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.ac_unit)); + expect(contentTopLeft.dy - materialTopLeft.dy, 29); + expect(contentTopLeft.dx - materialTopLeft.dx, 58); + expect(leadingTopLeft.dy - materialTopLeft.dy, 24); + expect(leadingTopLeft.dx - materialTopLeft.dx, 22); + + expect(find.byType(Divider), findsNothing); + }); + + testWidgets( + 'MaterialBanner widget properties take priority over theme when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const Color backgroundColor = Colors.purple; + const elevation = 6.0; + const textStyle = TextStyle(color: Colors.green); + final MaterialBannerThemeData bannerTheme = _bannerTheme(); + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bannerTheme: bannerTheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + backgroundColor: backgroundColor, + elevation: elevation, + leading: const Icon(Icons.ac_unit), + contentTextStyle: textStyle, + content: const Text(contentText), + padding: const EdgeInsets.all(10), + leadingPadding: const EdgeInsets.all(12), + actions: <Widget>[ + TextButton(child: const Text('Action'), onPressed: () {}), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, textStyle); + + final Offset contentTopLeft = tester.getTopLeft(_textFinder(contentText)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.ac_unit)); + expect(contentTopLeft.dy - materialTopLeft.dy, 29); + expect(contentTopLeft.dx - materialTopLeft.dx, 58); + expect(leadingTopLeft.dy - materialTopLeft.dy, 24); + expect(leadingTopLeft.dx - materialTopLeft.dx, 22); + + expect(find.byType(Divider), findsNothing); + }, + ); + + testWidgets('MaterialBanner uses color scheme when necessary', (WidgetTester tester) async { + final ColorScheme colorScheme = const ColorScheme.light().copyWith(surface: Colors.purple); + const contentText = 'Content'; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + home: Scaffold( + body: MaterialBanner( + content: const Text(contentText), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ), + ); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, colorScheme.surfaceContainerLow); + }); + + testWidgets( + 'MaterialBanner uses color scheme when necessary when presented by ScaffoldMessenger', + (WidgetTester tester) async { + final ColorScheme colorScheme = const ColorScheme.light().copyWith(surface: Colors.purple); + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + actions: <Widget>[ + TextButton(child: const Text('Action'), onPressed: () {}), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, colorScheme.surfaceContainerLow); + }, + ); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Material2 - Passing no MaterialBannerThemeData returns defaults', ( + WidgetTester tester, + ) async { + const contentText = 'Content'; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: MaterialBanner( + content: const Text(contentText), + leading: const Icon(Icons.umbrella), + actions: <Widget>[TextButton(child: const Text('Action'), onPressed: () {})], + ), + ), + ), + ); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, const Color(0xffffffff)); + expect(material.surfaceTintColor, null); + expect(material.shadowColor, null); + expect(material.elevation, 0.0); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + // Default value for ThemeData.typography is Typography.material2014() + expect( + content.text.style, + Typography.material2014().englishLike.bodyMedium!.merge( + Typography.material2014().black.bodyMedium, + ), + ); + + final Offset rowTopLeft = tester.getTopLeft(find.byType(Row)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.umbrella)); + expect(rowTopLeft.dy - materialTopLeft.dy, 2.0); // Default single line top padding. + expect(rowTopLeft.dx - materialTopLeft.dx, 16.0); // Default single line start padding. + expect(leadingTopLeft.dy - materialTopLeft.dy, 16); // Default leading padding. + expect(leadingTopLeft.dx - materialTopLeft.dx, 16); // Default leading padding. + + final Divider divider = tester.widget<Divider>(find.byType(Divider)); + expect(divider.color, null); + }); + + testWidgets( + 'Material2 - Passing no MaterialBannerThemeData returns defaults when presented by ScaffoldMessenger', + (WidgetTester tester) async { + const contentText = 'Content'; + const tapTarget = Key('tap-target'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(contentText), + leading: const Icon(Icons.umbrella), + actions: <Widget>[ + TextButton(child: const Text('Action'), onPressed: () {}), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Material material = _getMaterialFromText(tester, contentText); + expect(material.color, const Color(0xffffffff)); + expect(material.surfaceTintColor, null); + expect(material.shadowColor, null); + expect(material.elevation, 0.0); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + // Default value for ThemeData.typography is Typography.material2014() + expect( + content.text.style, + Typography.material2014().englishLike.bodyMedium!.merge( + Typography.material2014().black.bodyMedium, + ), + ); + + final Offset rowTopLeft = tester.getTopLeft(find.byType(Row)); + final Offset materialTopLeft = tester.getTopLeft(_materialFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.umbrella)); + expect(rowTopLeft.dy - materialTopLeft.dy, 2.0); // Default single line top padding. + expect(rowTopLeft.dx - materialTopLeft.dx, 16.0); // Default single line start padding. + expect(leadingTopLeft.dy - materialTopLeft.dy, 16); // Default leading padding. + expect(leadingTopLeft.dx - materialTopLeft.dx, 16); // Default leading padding. + + final Divider divider = tester.widget<Divider>(find.byType(Divider)); + expect(divider.color, null); + }, + ); + }); +} + +MaterialBannerThemeData _bannerTheme() { + return const MaterialBannerThemeData( + backgroundColor: Colors.orange, + surfaceTintColor: Colors.yellow, + shadowColor: Colors.red, + dividerColor: Colors.green, + contentTextStyle: TextStyle(color: Colors.pink), + elevation: 4.0, + padding: EdgeInsets.all(5), + leadingPadding: EdgeInsets.all(6), + ); +} + +Material _getMaterialFromText(WidgetTester tester, String text) { + return tester.widget<Material>(find.widgetWithText(Material, text).first); +} + +Finder _materialFinder() { + return find.descendant(of: find.byType(MaterialBanner), matching: find.byType(Material)).first; +} + +RenderParagraph _getTextRenderObjectFromDialog(WidgetTester tester, String text) { + return tester.element<StatelessElement>(_textFinder(text)).renderObject! as RenderParagraph; +} + +Finder _textFinder(String text) { + return find.descendant(of: find.byType(MaterialBanner), matching: find.text(text)); +} diff --git a/packages/material_ui/test/material/bottom_app_bar_test.dart b/packages/material_ui/test/material/bottom_app_bar_test.dart new file mode 100644 index 000000000000..ea076059182d --- /dev/null +++ b/packages/material_ui/test/material/bottom_app_bar_test.dart @@ -0,0 +1,829 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Material3 - Shadow effect is not doubled', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/123064 + debugDisableShadows = false; + + const double elevation = 1; + const Color shadowColor = Colors.black; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomAppBar(elevation: elevation, shadowColor: shadowColor), + ), + ), + ); + + final Finder finder = find.byType(BottomAppBar); + expect(finder, paints..shadow(color: shadowColor, elevation: elevation)); + expect(finder, paintsExactlyCountTimes(#drawShadow, 1)); + + debugDisableShadows = true; + }); + + testWidgets('Material3 - Only one layer with `color` is painted', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/122667 + const Color bottomAppBarColor = Colors.black45; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomAppBar( + color: bottomAppBarColor, + // Avoid getting a surface tint color, to keep the color check below simple + elevation: 0, + ), + ), + ), + ); + + // There should be just one color layer, and with the specified color. + final Finder finder = find.descendant( + of: find.byType(BottomAppBar), + matching: find.byWidgetPredicate((Widget widget) { + // A color layer is probably a [PhysicalShape] or [PhysicalModel], + // either used directly or backing a [Material] (one without + // [MaterialType.transparency]). + return widget is PhysicalShape || widget is PhysicalModel; + }), + ); + switch (tester.widgetList(finder).single) { + case PhysicalShape(:final Color color) || PhysicalModel(:final Color color): + expect(color, bottomAppBarColor); + default: + assert(false); // Should be unreachable: compare with the finder. + } + }); + + testWidgets('No overlap with floating action button', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null), + bottomNavigationBar: ShapeListener(BottomAppBar(child: SizedBox(height: 100.0))), + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox renderBox = tester.renderObject(find.byType(BottomAppBar)); + final expectedPath = Path()..addRect(Offset.zero & renderBox.size); + + final Path actualPath = shapeListenerState.cache.value; + expect( + actualPath, + coversSameAreaAs(expectedPath, areaToCompare: (Offset.zero & renderBox.size).inflate(5.0)), + ); + }); + + testWidgets('Material2 - Custom shape', (WidgetTester tester) async { + final Key key = UniqueKey(); + Future<void> pump(FloatingActionButtonLocation location) async { + await tester.pumpWidget( + SizedBox.square( + dimension: 200, + child: RepaintBoundary( + key: key, + child: MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}), + floatingActionButtonLocation: location, + bottomNavigationBar: const BottomAppBar( + shape: AutomaticNotchedShape( + BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), + ContinuousRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(30.0)), + ), + ), + notchMargin: 10.0, + color: Colors.green, + child: SizedBox(height: 100.0), + ), + ), + ), + ), + ), + ); + } + + await pump(FloatingActionButtonLocation.endDocked); + await expectLater(find.byKey(key), matchesGoldenFile('m2_bottom_app_bar.custom_shape.1.png')); + await pump(FloatingActionButtonLocation.centerDocked); + await tester.pumpAndSettle(); + await expectLater(find.byKey(key), matchesGoldenFile('m2_bottom_app_bar.custom_shape.2.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44572 + + testWidgets('Material3 - Custom shape', (WidgetTester tester) async { + final Key key = UniqueKey(); + Future<void> pump(FloatingActionButtonLocation location) async { + await tester.pumpWidget( + SizedBox.square( + dimension: 200, + child: RepaintBoundary( + key: key, + child: MaterialApp( + theme: ThemeData(), + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}), + floatingActionButtonLocation: location, + bottomNavigationBar: const BottomAppBar( + shape: AutomaticNotchedShape( + BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), + ContinuousRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(30.0)), + ), + ), + notchMargin: 10.0, + color: Colors.green, + child: SizedBox(height: 100.0), + ), + ), + ), + ), + ), + ); + } + + await pump(FloatingActionButtonLocation.endDocked); + await expectLater(find.byKey(key), matchesGoldenFile('m3_bottom_app_bar.custom_shape.1.png')); + await pump(FloatingActionButtonLocation.centerDocked); + await tester.pumpAndSettle(); + await expectLater(find.byKey(key), matchesGoldenFile('m3_bottom_app_bar.custom_shape.2.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/44572 + + testWidgets('Custom Padding', (WidgetTester tester) async { + const customPadding = EdgeInsets.all(10); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return const Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: BottomAppBar( + padding: customPadding, + child: ColoredBox(color: Colors.green, child: SizedBox(width: 300, height: 60)), + ), + ), + ); + }, + ), + ), + ); + + final BottomAppBar bottomAppBar = tester.widget(find.byType(BottomAppBar)); + expect(bottomAppBar.padding, customPadding); + final Rect babRect = tester.getRect(find.byType(BottomAppBar)); + final Rect childRect = tester.getRect( + find.descendant(of: find.byType(BottomAppBar), matching: find.byType(ColoredBox)), + ); + expect(childRect, const Rect.fromLTRB(250, 530, 550, 590)); + expect(babRect, const Rect.fromLTRB(240, 520, 560, 600)); + }); + + testWidgets('Material2 - Color defaults to Theme.bottomAppBarColor', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Theme( + data: Theme.of( + context, + ).copyWith(bottomAppBarTheme: const BottomAppBarThemeData(color: Color(0xffffff00))), + child: const Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null), + bottomNavigationBar: BottomAppBar(), + ), + ); + }, + ), + ), + ); + + final PhysicalShape physicalShape = tester.widget(find.byType(PhysicalShape).at(0)); + + expect(physicalShape.color, const Color(0xffffff00)); + }); + + testWidgets('Material2 - Color overrides theme color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Theme( + data: Theme.of( + context, + ).copyWith(bottomAppBarTheme: const BottomAppBarThemeData(color: Color(0xffffff00))), + child: const Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null), + bottomNavigationBar: BottomAppBar(color: Color(0xff0000ff)), + ), + ); + }, + ), + ), + ); + + final PhysicalShape physicalShape = tester.widget(find.byType(PhysicalShape).at(0)); + final Material material = tester.widget(find.byType(Material).at(1)); + + expect(physicalShape.color, const Color(0xff0000ff)); + expect(material.color, null); /* no value in Material 2. */ + }); + + testWidgets('Material3 - Color overrides theme color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomAppBarTheme: const BottomAppBarThemeData(color: Color(0xffffff00))), + home: Builder( + builder: (BuildContext context) { + return const Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null), + bottomNavigationBar: BottomAppBar( + color: Color(0xff0000ff), + surfaceTintColor: Colors.transparent, + ), + ); + }, + ), + ), + ); + + final PhysicalShape physicalShape = tester.widget( + find.descendant(of: find.byType(BottomAppBar), matching: find.byType(PhysicalShape)), + ); + + expect(physicalShape.color, const Color(0xff0000ff)); + }); + + testWidgets('Material3 - Shadow color is transparent', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null), + bottomNavigationBar: BottomAppBar(color: Color(0xff0000ff)), + ), + ), + ); + + final PhysicalShape physicalShape = tester.widget( + find.descendant(of: find.byType(BottomAppBar), matching: find.byType(PhysicalShape)), + ); + + expect(physicalShape.shadowColor, Colors.transparent); + }); + + testWidgets('Material2 - Dark theme applies an elevation overlay color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(useMaterial3: false, colorScheme: const ColorScheme.dark()), + home: Scaffold(bottomNavigationBar: BottomAppBar(color: const ColorScheme.dark().surface)), + ), + ); + + final PhysicalShape physicalShape = tester.widget(find.byType(PhysicalShape).at(0)); + + // For the default dark theme the overlay color for elevation 8 is 0xFF2D2D2D + expect(physicalShape.color, isSameColorAs(const Color(0xFF2D2D2D))); + }); + + testWidgets('Material3 - Dark theme applies an elevation overlay color', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.dark(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold(bottomNavigationBar: BottomAppBar(color: colorScheme.surfaceContainer)), + ), + ); + + final PhysicalShape physicalShape = tester.widget(find.byType(PhysicalShape).at(0)); + + const elevation = 3.0; // Default for M3. + final Color overlayColor = ElevationOverlay.applySurfaceTint( + colorScheme.surfaceContainer, + colorScheme.surfaceTint, + elevation, + ); + expect(physicalShape.color, isNot(overlayColor)); + expect(physicalShape.color, colorScheme.surfaceContainer); + }); + + // This is a regression test for a bug we had where toggling the notch on/off + // would crash, as the shouldReclip method of ShapeBorderClipper or + // _BottomAppBarClipper would try an illegal downcast. + testWidgets('toggle shape to null', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(bottomNavigationBar: BottomAppBar(shape: RectangularNotch())), + ), + ); + + await tester.pumpWidget(const MaterialApp(home: Scaffold(bottomNavigationBar: BottomAppBar()))); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(bottomNavigationBar: BottomAppBar(shape: RectangularNotch())), + ), + ); + }); + + testWidgets('no notch when notch param is null', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: ShapeListener(BottomAppBar()), + floatingActionButton: FloatingActionButton(onPressed: null, child: Icon(Icons.add)), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox renderBox = tester.renderObject(find.byType(BottomAppBar)); + final expectedPath = Path()..addRect(Offset.zero & renderBox.size); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs(expectedPath, areaToCompare: (Offset.zero & renderBox.size).inflate(5.0)), + ); + }); + + testWidgets('notch no margin', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: ShapeListener( + BottomAppBar( + shape: RectangularNotch(), + notchMargin: 0.0, + child: SizedBox(height: 100.0), + ), + ), + floatingActionButton: FloatingActionButton(onPressed: null, child: Icon(Icons.add)), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox babBox = tester.renderObject(find.byType(BottomAppBar)); + final Size babSize = babBox.size; + final RenderBox fabBox = tester.renderObject(find.byType(FloatingActionButton)); + final Size fabSize = fabBox.size; + + final double fabLeft = (babSize.width / 2.0) - (fabSize.width / 2.0); + final double fabRight = fabLeft + fabSize.width; + final double fabBottom = fabSize.height / 2.0; + + final expectedPath = Path() + ..moveTo(0.0, 0.0) + ..lineTo(fabLeft, 0.0) + ..lineTo(fabLeft, fabBottom) + ..lineTo(fabRight, fabBottom) + ..lineTo(fabRight, 0.0) + ..lineTo(babSize.width, 0.0) + ..lineTo(babSize.width, babSize.height) + ..lineTo(0.0, babSize.height) + ..close(); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs(expectedPath, areaToCompare: (Offset.zero & babSize).inflate(5.0)), + ); + }); + + testWidgets('notch with margin', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: ShapeListener( + BottomAppBar( + shape: RectangularNotch(), + notchMargin: 6.0, + child: SizedBox(height: 100.0), + ), + ), + floatingActionButton: FloatingActionButton(onPressed: null, child: Icon(Icons.add)), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox babBox = tester.renderObject(find.byType(BottomAppBar)); + final Size babSize = babBox.size; + final RenderBox fabBox = tester.renderObject(find.byType(FloatingActionButton)); + final Size fabSize = fabBox.size; + + final double fabLeft = (babSize.width / 2.0) - (fabSize.width / 2.0) - 6.0; + final double fabRight = fabLeft + fabSize.width + 6.0; + final double fabBottom = 6.0 + fabSize.height / 2.0; + + final expectedPath = Path() + ..moveTo(0.0, 0.0) + ..lineTo(fabLeft, 0.0) + ..lineTo(fabLeft, fabBottom) + ..lineTo(fabRight, fabBottom) + ..lineTo(fabRight, 0.0) + ..lineTo(babSize.width, 0.0) + ..lineTo(babSize.width, babSize.height) + ..lineTo(0.0, babSize.height) + ..close(); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs(expectedPath, areaToCompare: (Offset.zero & babSize).inflate(5.0)), + ); + }); + + testWidgets('Material2 - Observes safe area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const MediaQuery( + data: MediaQueryData(padding: EdgeInsets.all(50.0)), + child: Scaffold( + bottomNavigationBar: BottomAppBar(child: Center(child: Text('safe'))), + ), + ), + ), + ); + + expect(tester.getBottomLeft(find.widgetWithText(Center, 'safe')), const Offset(50.0, 550.0)); + }); + + testWidgets('Material3 - Observes safe area', (WidgetTester tester) async { + const safeAreaPadding = 50.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const MediaQuery( + data: MediaQueryData(padding: EdgeInsets.all(safeAreaPadding)), + child: Scaffold( + bottomNavigationBar: BottomAppBar(child: Center(child: Text('safe'))), + ), + ), + ), + ); + + const appBarVerticalPadding = 12.0; + const appBarHorizontalPadding = 16.0; + expect( + tester.getBottomLeft(find.widgetWithText(Center, 'safe')), + const Offset( + safeAreaPadding + appBarHorizontalPadding, + 600 - safeAreaPadding - appBarVerticalPadding, + ), + ); + }); + + testWidgets('clipBehavior is propagated', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomAppBar( + shape: RectangularNotch(), + notchMargin: 0.0, + child: SizedBox(height: 100.0), + ), + ), + ), + ); + + PhysicalShape physicalShape = tester.widget(find.byType(PhysicalShape)); + expect(physicalShape.clipBehavior, Clip.none); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomAppBar( + shape: RectangularNotch(), + notchMargin: 0.0, + clipBehavior: Clip.antiAliasWithSaveLayer, + child: SizedBox(height: 100.0), + ), + ), + ), + ); + + physicalShape = tester.widget(find.byType(PhysicalShape)); + expect(physicalShape.clipBehavior, Clip.antiAliasWithSaveLayer); + }); + + testWidgets('Material2 - BottomAppBar with shape when Scaffold.bottomNavigationBar == null', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/80878 + final theme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton( + backgroundColor: Colors.green, + child: const Icon(Icons.home), + onPressed: () {}, + ), + body: Stack( + children: <Widget>[ + Container(color: Colors.amber), + Container( + alignment: Alignment.bottomCenter, + child: BottomAppBar( + color: Colors.green, + shape: const CircularNotchedRectangle(), + child: Container(height: 50), + ), + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(FloatingActionButton)), + const Rect.fromLTRB(372, 528, 428, 584), + ); + expect(tester.getSize(find.byType(BottomAppBar)), const Size(800, 50)); + }); + + testWidgets('Material3 - BottomAppBar with shape when Scaffold.bottomNavigationBar == null', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/80878 + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton( + backgroundColor: Colors.green, + child: const Icon(Icons.home), + onPressed: () {}, + ), + body: Stack( + children: <Widget>[ + Container(color: Colors.amber), + Container( + alignment: Alignment.bottomCenter, + child: BottomAppBar( + color: Colors.green, + shape: const CircularNotchedRectangle(), + child: Container(height: 50), + ), + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(FloatingActionButton)), + const Rect.fromLTRB(372, 528, 428, 584), + ); + expect(tester.getSize(find.byType(BottomAppBar)), const Size(800, 80)); + }); + + testWidgets('notch with margin and top padding, home safe area', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/90024 + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(padding: EdgeInsets.only(top: 128)), + child: MaterialApp( + useInheritedMediaQuery: true, + home: SafeArea( + child: Scaffold( + bottomNavigationBar: ShapeListener( + BottomAppBar( + shape: RectangularNotch(), + notchMargin: 6.0, + child: SizedBox(height: 100.0), + ), + ), + floatingActionButton: FloatingActionButton(onPressed: null, child: Icon(Icons.add)), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox babBox = tester.renderObject(find.byType(BottomAppBar)); + final Size babSize = babBox.size; + final RenderBox fabBox = tester.renderObject(find.byType(FloatingActionButton)); + final Size fabSize = fabBox.size; + + final double fabLeft = (babSize.width / 2.0) - (fabSize.width / 2.0) - 6.0; + final double fabRight = fabLeft + fabSize.width + 6.0; + final double fabBottom = 6.0 + fabSize.height / 2.0; + + final expectedPath = Path() + ..moveTo(0.0, 0.0) + ..lineTo(fabLeft, 0.0) + ..lineTo(fabLeft, fabBottom) + ..lineTo(fabRight, fabBottom) + ..lineTo(fabRight, 0.0) + ..lineTo(babSize.width, 0.0) + ..lineTo(babSize.width, babSize.height) + ..lineTo(0.0, babSize.height) + ..close(); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs(expectedPath, areaToCompare: (Offset.zero & babSize).inflate(5.0)), + ); + }); + + testWidgets('BottomAppBar does not apply custom clipper without FAB', ( + WidgetTester tester, + ) async { + Widget buildWidget({Widget? fab}) { + return MaterialApp( + home: Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButton: fab, + bottomNavigationBar: BottomAppBar( + color: Colors.green, + shape: const CircularNotchedRectangle(), + child: Container(height: 50), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget(fab: FloatingActionButton(onPressed: () {}))); + + PhysicalShape physicalShape = tester.widget(find.byType(PhysicalShape).at(0)); + expect(physicalShape.clipper.toString(), '_BottomAppBarClipper'); + + await tester.pumpWidget(buildWidget()); + + physicalShape = tester.widget(find.byType(PhysicalShape).at(0)); + expect(physicalShape.clipper.toString(), 'ShapeBorderClipper'); + }); + + testWidgets('Material3 - BottomAppBar adds bottom padding to height', ( + WidgetTester tester, + ) async { + const bottomPadding = 35.0; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(bottom: bottomPadding), + viewPadding: EdgeInsets.only(bottom: bottomPadding), + ), + child: MaterialApp( + theme: ThemeData(), + home: Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.endContained, + floatingActionButton: FloatingActionButton(onPressed: () {}), + bottomNavigationBar: BottomAppBar( + child: IconButton(icon: const Icon(Icons.search), onPressed: () {}), + ), + ), + ), + ), + ); + + final Rect bottomAppBar = tester.getRect(find.byType(BottomAppBar)); + final Rect iconButton = tester.getRect(find.widgetWithIcon(IconButton, Icons.search)); + final Rect fab = tester.getRect(find.byType(FloatingActionButton)); + + // The height of the bottom app bar should be its height(default is 80.0) + bottom safe area height. + expect(bottomAppBar.height, 80.0 + bottomPadding); + + // The vertical position of the icon button and fab should be center of the area excluding the bottom padding. + final double barCenter = bottomAppBar.topLeft.dy + (bottomAppBar.height - bottomPadding) / 2; + expect(iconButton.center.dy, barCenter); + expect(fab.center.dy, barCenter); + }); + + testWidgets('BottomAppBar renders at zero size', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: SizedBox.shrink(child: BottomAppBar(child: Text('X'))), + ), + ), + ); + final Finder bottomAppBarChild = find.text('X'); + expect(tester.getSize(bottomAppBarChild).isEmpty, isTrue); + }); +} + +// The bottom app bar clip path computation is only available at paint time. +// In order to examine the notch path we implement this caching painter which +// at paint time looks for a descendant PhysicalShape and caches the +// clip path it is using. +class ClipCachePainter extends CustomPainter { + ClipCachePainter(this.context); + + late Path value; + BuildContext context; + + @override + void paint(Canvas canvas, Size size) { + final RenderPhysicalShape physicalShape = findPhysicalShapeChild(context)!; + value = physicalShape.clipper!.getClip(size); + } + + RenderPhysicalShape? findPhysicalShapeChild(BuildContext context) { + RenderPhysicalShape? result; + context.visitChildElements((Element e) { + final RenderObject renderObject = e.findRenderObject()!; + if (renderObject.runtimeType == RenderPhysicalShape) { + assert(result == null); + result = renderObject as RenderPhysicalShape; + } else { + result = findPhysicalShapeChild(e); + } + }); + return result; + } + + @override + bool shouldRepaint(ClipCachePainter oldDelegate) { + return true; + } +} + +class ShapeListener extends StatefulWidget { + const ShapeListener(this.child, {super.key}); + + final Widget child; + + @override + State createState() => ShapeListenerState(); +} + +class ShapeListenerState extends State<ShapeListener> { + @override + Widget build(BuildContext context) { + return CustomPaint(painter: cache, child: widget.child); + } + + late ClipCachePainter cache; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + cache = ClipCachePainter(context); + } +} + +class RectangularNotch extends NotchedShape { + const RectangularNotch(); + + @override + Path getOuterPath(Rect host, Rect? guest) { + if (guest == null) { + return Path()..addRect(host); + } + return Path() + ..moveTo(host.left, host.top) + ..lineTo(guest.left, host.top) + ..lineTo(guest.left, guest.bottom) + ..lineTo(guest.right, guest.bottom) + ..lineTo(guest.right, host.top) + ..lineTo(host.right, host.top) + ..lineTo(host.right, host.bottom) + ..lineTo(host.left, host.bottom) + ..close(); + } +} diff --git a/packages/material_ui/test/material/bottom_app_bar_theme_test.dart b/packages/material_ui/test/material/bottom_app_bar_theme_test.dart new file mode 100644 index 000000000000..e70c8d25e405 --- /dev/null +++ b/packages/material_ui/test/material/bottom_app_bar_theme_test.dart @@ -0,0 +1,382 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('BottomAppBarThemeData copyWith, ==, hashCode, defaults', () { + expect(const BottomAppBarThemeData(), const BottomAppBarThemeData().copyWith()); + expect( + const BottomAppBarThemeData().hashCode, + const BottomAppBarThemeData().copyWith().hashCode, + ); + expect(const BottomAppBarThemeData().color, null); + expect(const BottomAppBarThemeData().elevation, null); + expect(const BottomAppBarThemeData().shadowColor, null); + expect(const BottomAppBarThemeData().shape, null); + expect(const BottomAppBarThemeData().height, null); + expect(const BottomAppBarThemeData().surfaceTintColor, null); + expect(const BottomAppBarThemeData().padding, null); + }); + + test('BottomAppBarThemeData lerp special cases', () { + const theme = BottomAppBarThemeData(); + expect(identical(BottomAppBarThemeData.lerp(theme, theme, 0.5), theme), true); + }); + + testWidgets('Default BottomAppBarThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const BottomAppBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('BottomAppBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const BottomAppBarThemeData( + color: Color(0xffff0000), + elevation: 1.0, + shape: CircularNotchedRectangle(), + height: 1.0, + shadowColor: Color(0xff0000ff), + surfaceTintColor: Color(0xff00ff00), + padding: EdgeInsets.all(8), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'color: ${const Color(0xffff0000)}', + 'elevation: 1.0', + "shape: Instance of 'CircularNotchedRectangle'", + 'height: 1.0', + 'surfaceTintColor: ${const Color(0xff00ff00)}', + 'shadowColor: ${const Color(0xff0000ff)}', + 'padding: EdgeInsets.all(8.0)', + ]); + }); + + testWidgets('Local BottomAppBarTheme overrides defaults', (WidgetTester tester) async { + const Color color = Colors.blueAccent; + const elevation = 1.0; + const Color shadowColor = Colors.black87; + const height = 100.0; + const Color surfaceTintColor = Colors.transparent; + const NotchedShape shape = CircularNotchedRectangle(); + const EdgeInsetsGeometry padding = EdgeInsets.all(8); + const themeData = BottomAppBarThemeData( + color: color, + elevation: elevation, + shadowColor: shadowColor, + shape: shape, + height: height, + surfaceTintColor: surfaceTintColor, + padding: padding, + ); + + await tester.pumpWidget(_withTheme(localBABTheme: themeData)); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, themeData.color); + expect(widget.elevation, themeData.elevation); + expect(widget.shadowColor, themeData.shadowColor); + + final RenderBox renderBox = tester.renderObject<RenderBox>(find.byType(BottomAppBar)); + expect(renderBox.size.height, themeData.height); + + final bool hasFab = Scaffold.of( + tester.element(find.byType(BottomAppBar)), + ).hasFloatingActionButton; + if (hasFab) { + expect(widget.clipper.toString(), '_BottomAppBarClipper'); + } else { + expect(widget.clipper, isA<ShapeBorderClipper>()); + final clipper = widget.clipper as ShapeBorderClipper; + expect(clipper.shape, isA<RoundedRectangleBorder>()); + } + + final Color effectiveColor = ElevationOverlay.applySurfaceTint( + themeData.color!, + themeData.surfaceTintColor, + themeData.elevation!, + ); + expect(widget.color, effectiveColor); + + // The BottomAppBar has two Padding widgets in its hierarchy: + // 1. The first Padding is from the SafeArea widget. + // 2. The second Padding is the one that applies the theme's padding. + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(BottomAppBar), matching: find.byType(Padding).at(1)), + ); + expect(paddingWidget.padding, padding); + }); + + group('Material 2 tests', () { + testWidgets('Material2 - BAB theme overrides color', (WidgetTester tester) async { + const Color themedColor = Colors.black87; + const theme = BottomAppBarThemeData(color: themedColor); + + await tester.pumpWidget(_withTheme(babTheme: theme, useMaterial3: false)); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, themedColor); + }); + + testWidgets('Material2 - BAB color - Widget', (WidgetTester tester) async { + const Color babThemeColor = Colors.black87; + const Color babColor = Colors.pink; + const theme = BottomAppBarThemeData(color: babThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false, bottomAppBarTheme: theme), + home: const Scaffold(body: BottomAppBar(color: babColor)), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, babColor); + }); + + testWidgets('Material2 - BAB color - BabTheme', (WidgetTester tester) async { + const Color babThemeColor = Colors.black87; + const theme = BottomAppBarThemeData(color: babThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false, bottomAppBarTheme: theme), + home: const Scaffold(body: BottomAppBar()), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, babThemeColor); + }); + + testWidgets('Material2 - BAB color - Theme', (WidgetTester tester) async { + const Color themeColor = Colors.white10; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + bottomAppBarTheme: const BottomAppBarThemeData(color: themeColor), + ), + home: const Scaffold(body: BottomAppBar()), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, themeColor); + }); + + testWidgets('Material2 - BAB color - Default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(body: BottomAppBar()), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + + expect(widget.color, Colors.white); + }); + + testWidgets('Material2 - BAB theme customizes shape', (WidgetTester tester) async { + const theme = BottomAppBarThemeData( + color: Colors.white30, + shape: CircularNotchedRectangle(), + elevation: 1.0, + ); + + await tester.pumpWidget(_withTheme(babTheme: theme, useMaterial3: false)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('bottom_app_bar_theme.custom_shape.png'), + ); + }); + + testWidgets('Material2 - BAB theme does not affect defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(body: BottomAppBar()), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + + expect(widget.color, Colors.white); + expect(widget.elevation, equals(8.0)); + }); + }); + + group('Material 3 tests', () { + testWidgets('Material3 - BAB theme overrides color', (WidgetTester tester) async { + const Color themedColor = Colors.black87; + const theme = BottomAppBarThemeData(color: themedColor, elevation: 0); + await tester.pumpWidget(_withTheme(babTheme: theme)); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, themedColor); + }); + + testWidgets('Material3 - BAB color - Widget', (WidgetTester tester) async { + const Color babThemeColor = Colors.black87; + const Color babColor = Colors.pink; + const theme = BottomAppBarThemeData(color: babThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomAppBarTheme: theme), + home: const Scaffold( + body: BottomAppBar(color: babColor, surfaceTintColor: Colors.transparent), + ), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, babColor); + }); + + testWidgets('Material3 - BAB color - BabTheme', (WidgetTester tester) async { + const Color babThemeColor = Colors.black87; + const theme = BottomAppBarThemeData(color: babThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomAppBarTheme: theme), + home: const Scaffold(body: BottomAppBar(surfaceTintColor: Colors.transparent)), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, babThemeColor); + }); + + testWidgets('Material3 - BAB theme does not affect defaults', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: BottomAppBar(surfaceTintColor: Colors.transparent)), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + + expect(widget.color, theme.colorScheme.surfaceContainer); + expect(widget.elevation, equals(3.0)); + }); + + testWidgets('Material3 - BAB theme overrides surfaceTintColor', (WidgetTester tester) async { + const Color color = Colors.blue; // base color that the surface tint will be applied to + const Color babThemeSurfaceTintColor = Colors.black87; + const theme = BottomAppBarThemeData( + color: color, + surfaceTintColor: babThemeSurfaceTintColor, + elevation: 0, + ); + await tester.pumpWidget(_withTheme(babTheme: theme)); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, ElevationOverlay.applySurfaceTint(color, babThemeSurfaceTintColor, 0)); + }); + + testWidgets('Material3 - BAB theme overrides shadowColor', (WidgetTester tester) async { + const Color babThemeShadowColor = Colors.yellow; + const theme = BottomAppBarThemeData(shadowColor: babThemeShadowColor, elevation: 0); + await tester.pumpWidget(_withTheme(babTheme: theme)); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.shadowColor, babThemeShadowColor); + }); + + testWidgets('Material3 - BAB surfaceTintColor - Widget', (WidgetTester tester) async { + const Color color = Colors.white10; // base color that the surface tint will be applied to + const Color babThemeSurfaceTintColor = Colors.black87; + const Color babSurfaceTintColor = Colors.pink; + const theme = BottomAppBarThemeData(surfaceTintColor: babThemeSurfaceTintColor); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomAppBarTheme: theme), + home: const Scaffold( + body: BottomAppBar(color: color, surfaceTintColor: babSurfaceTintColor), + ), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, ElevationOverlay.applySurfaceTint(color, babSurfaceTintColor, 3.0)); + }); + + testWidgets('Material3 - BAB surfaceTintColor - BabTheme', (WidgetTester tester) async { + const Color color = Colors.blue; // base color that the surface tint will be applied to + const Color babThemeColor = Colors.black87; + const theme = BottomAppBarThemeData(surfaceTintColor: babThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomAppBarTheme: theme), + home: const Scaffold(body: BottomAppBar(color: color)), + ), + ); + + final PhysicalShape widget = _getBabRenderObject(tester); + expect(widget.color, ElevationOverlay.applySurfaceTint(color, babThemeColor, 3.0)); + }); + }); +} + +PhysicalShape _getBabRenderObject(WidgetTester tester) { + return tester.widget<PhysicalShape>( + find.descendant(of: find.byType(BottomAppBar), matching: find.byType(PhysicalShape)), + ); +} + +final Key _painterKey = UniqueKey(); + +Widget _withTheme({ + BottomAppBarThemeData? babTheme, + BottomAppBarThemeData? localBABTheme, + bool useMaterial3 = true, +}) { + Widget babWidget = const BottomAppBar( + child: Row( + children: <Widget>[ + Icon(Icons.add), + Expanded(child: SizedBox()), + Icon(Icons.add), + ], + ), + ); + if (localBABTheme != null) { + babWidget = BottomAppBarTheme(data: localBABTheme, child: babWidget); + } + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3, bottomAppBarTheme: babTheme), + home: Scaffold( + floatingActionButton: const FloatingActionButton(onPressed: null), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: RepaintBoundary(key: _painterKey, child: babWidget), + ), + ); +} diff --git a/packages/material_ui/test/material/bottom_navigation_bar_test.dart b/packages/material_ui/test/material/bottom_navigation_bar_test.dart new file mode 100644 index 000000000000..f58dad87d01b --- /dev/null +++ b/packages/material_ui/test/material/bottom_navigation_bar_test.dart @@ -0,0 +1,3187 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math_64.dart' show Vector3; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('BottomNavigationBar callback test', (WidgetTester tester) async { + late int mutatedIndex; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onTap: (int index) { + mutatedIndex = index; + }, + ), + ), + ), + ); + + await tester.tap(find.text('Alarm')); + + expect(mutatedIndex, 1); + }); + + testWidgets('Material2 - BottomNavigationBar content test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + expect(box.size.height, kBottomNavigationBarHeight); + expect(find.text('AC'), findsOneWidget); + expect(find.text('Alarm'), findsOneWidget); + }); + + testWidgets('Material3 - BottomNavigationBar content test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + // kBottomNavigationBarHeight is a minimum dimension. + expect(box.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + expect(find.text('AC'), findsOneWidget); + expect(find.text('Alarm'), findsOneWidget); + }); + + testWidgets('Material2 - Fixed BottomNavigationBar defaults', (WidgetTester tester) async { + const primaryColor = Color(0xFF000001); + const unselectedWidgetColor = Color(0xFF000002); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(useMaterial3: false).copyWith( + colorScheme: const ColorScheme.light().copyWith(primary: primaryColor), + unselectedWidgetColor: unselectedWidgetColor, + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + const selectedFontSize = 14.0; + const unselectedFontSize = 12.0; + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + final TextStyle unselectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('Alarm')) + .text + .style!; + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + expect(selectedFontStyle.color, equals(primaryColor)); + expect(selectedFontStyle.fontSize, selectedFontSize); + expect(selectedFontStyle.fontWeight, equals(FontWeight.w400)); + expect(selectedFontStyle.height, isNull); + expect(unselectedFontStyle.color, equals(unselectedWidgetColor)); + expect(unselectedFontStyle.fontWeight, equals(FontWeight.w400)); + expect(unselectedFontStyle.height, isNull); + // Unselected label has a font size of 14 but is scaled down to be font size 12. + expect( + tester + .firstWidget<Transform>( + find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ) + .transform, + equals(Matrix4.diagonal3(Vector3.all(unselectedFontSize / selectedFontSize))), + ); + expect(selectedIcon.color, equals(primaryColor)); + expect(selectedIcon.fontSize, equals(24.0)); + expect(unselectedIcon.color, equals(unselectedWidgetColor)); + expect(unselectedIcon.fontSize, equals(24.0)); + // There should not be any [Opacity] or [FadeTransition] widgets + // since showUnselectedLabels and showSelectedLabels are true. + final Finder findOpacity = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(Opacity), + ); + final Finder findFadeTransition = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(FadeTransition), + ); + expect(findOpacity, findsNothing); + expect(findFadeTransition, findsNothing); + expect(_getMaterial(tester).elevation, equals(8.0)); + }); + + testWidgets('Material3 - Fixed BottomNavigationBar defaults', (WidgetTester tester) async { + const primaryColor = Color(0xFF000001); + const unselectedWidgetColor = Color(0xFF000002); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: const ColorScheme.light().copyWith(primary: primaryColor), + unselectedWidgetColor: unselectedWidgetColor, + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + const selectedFontSize = 14.0; + const unselectedFontSize = 12.0; + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + final TextStyle unselectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('Alarm')) + .text + .style!; + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + expect(selectedFontStyle.color, equals(primaryColor)); + expect(selectedFontStyle.fontSize, selectedFontSize); + expect(selectedFontStyle.fontWeight, equals(FontWeight.w400)); + expect(selectedFontStyle.height, 1.43); + expect(unselectedFontStyle.color, equals(unselectedWidgetColor)); + expect(unselectedFontStyle.fontWeight, equals(FontWeight.w400)); + expect(unselectedFontStyle.height, 1.43); + // Unselected label has a font size of 14 but is scaled down to be font size 12. + expect( + tester + .firstWidget<Transform>( + find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ) + .transform, + equals(Matrix4.diagonal3(Vector3.all(unselectedFontSize / selectedFontSize))), + ); + expect(selectedIcon.color, equals(primaryColor)); + expect(selectedIcon.fontSize, equals(24.0)); + expect(unselectedIcon.color, equals(unselectedWidgetColor)); + expect(unselectedIcon.fontSize, equals(24.0)); + // There should not be any [Opacity] or [FadeTransition] widgets + // since showUnselectedLabels and showSelectedLabels are true. + final Finder findOpacity = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(Opacity), + ); + final Finder findFadeTransition = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(FadeTransition), + ); + expect(findOpacity, findsNothing); + expect(findFadeTransition, findsNothing); + expect(_getMaterial(tester).elevation, equals(8.0)); + }); + + testWidgets('Custom selected and unselected font styles', (WidgetTester tester) async { + const selectedTextStyle = TextStyle(fontWeight: FontWeight.w200, fontSize: 18.0); + const unselectedTextStyle = TextStyle(fontWeight: FontWeight.w600, fontSize: 12.0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedLabelStyle: selectedTextStyle, + unselectedLabelStyle: unselectedTextStyle, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + final TextStyle unselectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('Alarm')) + .text + .style!; + expect(selectedFontStyle.fontSize, equals(selectedTextStyle.fontSize)); + expect(selectedFontStyle.fontWeight, equals(selectedTextStyle.fontWeight)); + expect( + tester + .firstWidget<Transform>( + find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ) + .transform, + equals( + Matrix4.diagonal3(Vector3.all(unselectedTextStyle.fontSize! / selectedTextStyle.fontSize!)), + ), + ); + expect(unselectedFontStyle.fontWeight, equals(unselectedTextStyle.fontWeight)); + }); + + testWidgets('font size on text styles overrides font size params', (WidgetTester tester) async { + const selectedTextStyle = TextStyle(fontSize: 18.0); + const unselectedTextStyle = TextStyle(fontSize: 12.0); + const selectedFontSize = 17.0; + const unselectedFontSize = 11.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedLabelStyle: selectedTextStyle, + unselectedLabelStyle: unselectedTextStyle, + selectedFontSize: selectedFontSize, + unselectedFontSize: unselectedFontSize, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + expect(selectedFontStyle.fontSize, equals(selectedTextStyle.fontSize)); + expect( + tester + .firstWidget<Transform>( + find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ) + .transform, + equals( + Matrix4.diagonal3(Vector3.all(unselectedTextStyle.fontSize! / selectedTextStyle.fontSize!)), + ), + ); + }); + + testWidgets('Custom selected and unselected icon themes', (WidgetTester tester) async { + const selectedIconTheme = IconThemeData(size: 36, color: Color(0x00000001)); + const unselectedIconTheme = IconThemeData(size: 18, color: Color(0x00000002)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + expect(selectedIcon.color, equals(selectedIconTheme.color)); + expect(selectedIcon.fontSize, equals(selectedIconTheme.size)); + expect(unselectedIcon.color, equals(unselectedIconTheme.color)); + expect(unselectedIcon.fontSize, equals(unselectedIconTheme.size)); + }); + + testWidgets('color on icon theme overrides selected and unselected item colors', ( + WidgetTester tester, + ) async { + const selectedIconTheme = IconThemeData(size: 36, color: Color(0x00000001)); + const unselectedIconTheme = IconThemeData(size: 18, color: Color(0x00000002)); + const selectedItemColor = Color(0x00000003); + const unselectedItemColor = Color(0x00000004); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + selectedItemColor: selectedItemColor, + unselectedItemColor: unselectedItemColor, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + final TextStyle unselectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('Alarm')) + .text + .style!; + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + expect(selectedIcon.color, equals(selectedIconTheme.color)); + expect(unselectedIcon.color, equals(unselectedIconTheme.color)); + expect(selectedFontStyle.color, equals(selectedItemColor)); + expect(unselectedFontStyle.color, equals(unselectedItemColor)); + }); + + testWidgets('Padding is calculated properly on items - all labels', (WidgetTester tester) async { + const selectedFontSize = 16.0; + const selectedIconSize = 36.0; + const unselectedIconSize = 20.0; + const selectedIconTheme = IconThemeData(size: selectedIconSize); + const unselectedIconTheme = IconThemeData(size: unselectedIconSize); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + showSelectedLabels: true, + showUnselectedLabels: true, + selectedFontSize: selectedFontSize, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final EdgeInsets selectedItemPadding = _itemPadding(tester, Icons.ac_unit); + expect(selectedItemPadding.top, equals(selectedFontSize / 2.0)); + expect(selectedItemPadding.bottom, equals(selectedFontSize / 2.0)); + final EdgeInsets unselectedItemPadding = _itemPadding(tester, Icons.access_alarm); + const double expectedUnselectedPadding = + (selectedIconSize - unselectedIconSize) / 2.0 + selectedFontSize / 2.0; + expect(unselectedItemPadding.top, equals(expectedUnselectedPadding)); + expect(unselectedItemPadding.bottom, equals(expectedUnselectedPadding)); + }); + + testWidgets('Padding is calculated properly on items - selected labels only', ( + WidgetTester tester, + ) async { + const selectedFontSize = 16.0; + const selectedIconSize = 36.0; + const unselectedIconSize = 20.0; + const selectedIconTheme = IconThemeData(size: selectedIconSize); + const unselectedIconTheme = IconThemeData(size: unselectedIconSize); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + showSelectedLabels: true, + showUnselectedLabels: false, + selectedFontSize: selectedFontSize, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final EdgeInsets selectedItemPadding = _itemPadding(tester, Icons.ac_unit); + expect(selectedItemPadding.top, equals(selectedFontSize / 2.0)); + expect(selectedItemPadding.bottom, equals(selectedFontSize / 2.0)); + final EdgeInsets unselectedItemPadding = _itemPadding(tester, Icons.access_alarm); + expect( + unselectedItemPadding.top, + equals((selectedIconSize - unselectedIconSize) / 2.0 + selectedFontSize), + ); + expect(unselectedItemPadding.bottom, equals((selectedIconSize - unselectedIconSize) / 2.0)); + }); + + testWidgets('Padding is calculated properly on items - no labels', (WidgetTester tester) async { + const selectedFontSize = 16.0; + const selectedIconSize = 36.0; + const unselectedIconSize = 20.0; + const selectedIconTheme = IconThemeData(size: selectedIconSize); + const unselectedIconTheme = IconThemeData(size: unselectedIconSize); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + showSelectedLabels: false, + showUnselectedLabels: false, + selectedFontSize: selectedFontSize, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final EdgeInsets selectedItemPadding = _itemPadding(tester, Icons.ac_unit); + expect(selectedItemPadding.top, equals(selectedFontSize)); + expect(selectedItemPadding.bottom, equals(0.0)); + final EdgeInsets unselectedItemPadding = _itemPadding(tester, Icons.access_alarm); + expect( + unselectedItemPadding.top, + equals((selectedIconSize - unselectedIconSize) / 2.0 + selectedFontSize), + ); + expect(unselectedItemPadding.bottom, equals((selectedIconSize - unselectedIconSize) / 2.0)); + }); + + testWidgets('Material2 - Shifting BottomNavigationBar defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + const selectedFontSize = 14.0; + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.fontSize, + selectedFontSize, + ); + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(Colors.white), + ); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + expect(_getMaterial(tester).elevation, equals(8.0)); + }); + + testWidgets('Material3 - Shifting BottomNavigationBar defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + const selectedFontSize = 14.0; + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.fontSize, + selectedFontSize, + ); + final ThemeData theme = Theme.of(tester.element(find.text('AC'))); + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(theme.colorScheme.surface), + ); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + expect(_getMaterial(tester).elevation, equals(8.0)); + }); + + testWidgets('Fixed BottomNavigationBar custom font size, color', (WidgetTester tester) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedColor = Color(0xFF0004FF); + const unselectedColor = Color(0xFFE5FF00); + const selectedFontSize = 18.0; + const unselectedFontSize = 14.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: primaryColor, unselectedWidgetColor: unselectedWidgetColor), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedFontSize: selectedFontSize, + unselectedFontSize: unselectedFontSize, + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.fontSize, + selectedFontSize, + ); + // Unselected label has a font size of 18 but is scaled down to be font size 14. + expect( + tester.renderObject<RenderParagraph>(find.text('Alarm')).text.style!.fontSize, + selectedFontSize, + ); + expect( + tester + .firstWidget<Transform>( + find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ) + .transform, + equals(Matrix4.diagonal3(Vector3.all(unselectedFontSize / selectedFontSize))), + ); + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(selectedColor), + ); + expect( + tester.renderObject<RenderParagraph>(find.text('Alarm')).text.style!.color, + equals(unselectedColor), + ); + expect(selectedIcon.color, equals(selectedColor)); + expect(unselectedIcon.color, equals(unselectedColor)); + // There should not be any [Opacity] or [FadeTransition] widgets + // since showUnselectedLabels and showSelectedLabels are true. + final Finder findOpacity = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(Opacity), + ); + final Finder findFadeTransition = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(FadeTransition), + ); + expect(findOpacity, findsNothing); + expect(findFadeTransition, findsNothing); + }); + + testWidgets('Shifting BottomNavigationBar custom font size, color', (WidgetTester tester) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedColor = Color(0xFF0004FF); + const unselectedColor = Color(0xFFE5FF00); + const selectedFontSize = 18.0; + const unselectedFontSize = 14.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: primaryColor, unselectedWidgetColor: unselectedWidgetColor), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + selectedFontSize: selectedFontSize, + unselectedFontSize: unselectedFontSize, + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.fontSize, + selectedFontSize, + ); + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(selectedColor), + ); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + + expect(selectedIcon.color, equals(selectedColor)); + expect(unselectedIcon.color, equals(unselectedColor)); + }); + + testWidgets( + 'label style color should override itemColor only for the label for BottomNavigationBarType.fixed', + (WidgetTester tester) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedColor = Color(0xFF0004FF); + const unselectedColor = Color(0xFFE5FF00); + const selectedLabelColor = Color(0xFFFF9900); + const unselectedLabelColor = Color(0xFF92F74E); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryColor: primaryColor, + unselectedWidgetColor: unselectedWidgetColor, + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedLabelStyle: const TextStyle(color: selectedLabelColor), + unselectedLabelStyle: const TextStyle(color: unselectedLabelColor), + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + useLegacyColorScheme: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect(selectedIcon.color, equals(selectedColor)); + expect(unselectedIcon.color, equals(unselectedColor)); + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(selectedLabelColor), + ); + expect( + tester.renderObject<RenderParagraph>(find.text('Alarm')).text.style!.color, + equals(unselectedLabelColor), + ); + }, + ); + + testWidgets( + 'label style color should override itemColor only for the label for BottomNavigationBarType.shifting', + (WidgetTester tester) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedColor = Color(0xFF0004FF); + const unselectedColor = Color(0xFFE5FF00); + const selectedLabelColor = Color(0xFFFF9900); + const unselectedLabelColor = Color(0xFF92F74E); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryColor: primaryColor, + unselectedWidgetColor: unselectedWidgetColor, + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + selectedLabelStyle: const TextStyle(color: selectedLabelColor), + unselectedLabelStyle: const TextStyle(color: unselectedLabelColor), + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + useLegacyColorScheme: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect(selectedIcon.color, equals(selectedColor)); + expect(unselectedIcon.color, equals(unselectedColor)); + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(selectedLabelColor), + ); + expect( + tester.renderObject<RenderParagraph>(find.text('Alarm')).text.style!.color, + equals(unselectedLabelColor), + ); + }, + ); + + testWidgets('iconTheme color should override itemColor for BottomNavigationBarType.fixed', ( + WidgetTester tester, + ) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedColor = Color(0xFF0004FF); + const unselectedColor = Color(0xFFE5FF00); + const selectedLabelColor = Color(0xFFFF9900); + const unselectedLabelColor = Color(0xFF92F74E); + const selectedIconThemeColor = Color(0xFF1E7723); + const unselectedIconThemeColor = Color(0xFF009688); + const selectedIconTheme = IconThemeData(size: 20, color: selectedIconThemeColor); + const unselectedIconTheme = IconThemeData(size: 18, color: unselectedIconThemeColor); + const selectedTextStyle = TextStyle(fontSize: 18.0, color: selectedLabelColor); + const unselectedTextStyle = TextStyle(fontSize: 18.0, color: unselectedLabelColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: primaryColor, unselectedWidgetColor: unselectedWidgetColor), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedLabelStyle: selectedTextStyle, + unselectedLabelStyle: unselectedTextStyle, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + useLegacyColorScheme: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect(selectedIcon.color, equals(selectedIconThemeColor)); + expect(unselectedIcon.color, equals(unselectedIconThemeColor)); + }); + + testWidgets('iconTheme color should override itemColor for BottomNavigationBarType.shifted', ( + WidgetTester tester, + ) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedLabelColor = Color(0xFFFF9900); + const unselectedLabelColor = Color(0xFF92F74E); + const selectedIconThemeColor = Color(0xFF1E7723); + const unselectedIconThemeColor = Color(0xFF009688); + const selectedIconTheme = IconThemeData(size: 20, color: selectedIconThemeColor); + const unselectedIconTheme = IconThemeData(size: 18, color: unselectedIconThemeColor); + const selectedTextStyle = TextStyle(fontSize: 18.0, color: selectedLabelColor); + const unselectedTextStyle = TextStyle(fontSize: 18.0, color: unselectedLabelColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: primaryColor, unselectedWidgetColor: unselectedWidgetColor), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + selectedLabelStyle: selectedTextStyle, + unselectedLabelStyle: unselectedTextStyle, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + useLegacyColorScheme: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect(selectedIcon.color, equals(selectedIconThemeColor)); + expect(unselectedIcon.color, equals(unselectedIconThemeColor)); + }); + + testWidgets('iconTheme color should override itemColor color for BottomNavigationBarType.fixed', ( + WidgetTester tester, + ) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedIconThemeColor = Color(0xFF1E7723); + const unselectedIconThemeColor = Color(0xFF009688); + const selectedIconTheme = IconThemeData(size: 20, color: selectedIconThemeColor); + const unselectedIconTheme = IconThemeData(size: 18, color: unselectedIconThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: primaryColor, unselectedWidgetColor: unselectedWidgetColor), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + useLegacyColorScheme: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect(selectedIcon.color, equals(selectedIconThemeColor)); + expect(unselectedIcon.color, equals(unselectedIconThemeColor)); + }); + + testWidgets('iconTheme color should override itemColor for BottomNavigationBarType.shifted', ( + WidgetTester tester, + ) async { + const primaryColor = Color(0xFF000000); + const unselectedWidgetColor = Color(0xFFD501FF); + const selectedColor = Color(0xFF0004FF); + const unselectedColor = Color(0xFFE5FF00); + const selectedIconThemeColor = Color(0xFF1E7723); + const unselectedIconThemeColor = Color(0xFF009688); + const selectedIconTheme = IconThemeData(size: 20, color: selectedIconThemeColor); + const unselectedIconTheme = IconThemeData(size: 18, color: unselectedIconThemeColor); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(primaryColor: primaryColor, unselectedWidgetColor: unselectedWidgetColor), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + selectedItemColor: selectedColor, + unselectedItemColor: unselectedColor, + useLegacyColorScheme: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + + expect(selectedIcon.color, equals(selectedIconThemeColor)); + expect(unselectedIcon.color, equals(unselectedIconThemeColor)); + }); + + testWidgets('Fixed BottomNavigationBar can hide unselected labels', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + showUnselectedLabels: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + expect(_getOpacity(tester, 'AC'), equals(1.0)); + expect(_getOpacity(tester, 'Alarm'), equals(0.0)); + }); + + testWidgets('Fixed BottomNavigationBar can update background color', (WidgetTester tester) async { + const Color color = Colors.yellow; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + backgroundColor: color, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + expect(_getMaterial(tester).color, equals(color)); + }); + + testWidgets('Shifting BottomNavigationBar background color is overridden by item color', ( + WidgetTester tester, + ) async { + const Color itemColor = Colors.yellow; + const Color backgroundColor = Colors.blue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + backgroundColor: backgroundColor, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + label: 'AC', + backgroundColor: itemColor, + ), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + expect(_getMaterial(tester).color, equals(itemColor)); + }); + + testWidgets('Specifying both selectedItemColor and fixedColor asserts', ( + WidgetTester tester, + ) async { + expect(() { + return BottomNavigationBar( + selectedItemColor: Colors.black, + fixedColor: Colors.black, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ); + }, throwsAssertionError); + }); + + testWidgets('Fixed BottomNavigationBar uses fixedColor when selectedItemColor not provided', ( + WidgetTester tester, + ) async { + const Color fixedColor = Colors.black; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + fixedColor: fixedColor, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + expect( + tester.renderObject<RenderParagraph>(find.text('AC')).text.style!.color, + equals(fixedColor), + ); + }); + + testWidgets('setting selectedFontSize to zero hides all labels', (WidgetTester tester) async { + const customElevation = 3.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + elevation: customElevation, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + expect(_getMaterial(tester).elevation, equals(customElevation)); + }); + + testWidgets('Material2 - BottomNavigationBar adds bottom padding to height', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: const MediaQueryData(viewPadding: EdgeInsets.only(bottom: 40.0)), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ), + ); + + const double expectedHeight = kBottomNavigationBarHeight + 40.0; + expect(tester.getSize(find.byType(BottomNavigationBar)).height, expectedHeight); + }); + + testWidgets('Material3 - BottomNavigationBar adds bottom padding to height', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(viewPadding: EdgeInsets.only(bottom: 40.0)), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ), + ); + + const double expectedMinHeight = kBottomNavigationBarHeight + 40.0; + expect(tester.getSize(find.byType(BottomNavigationBar)).height >= expectedMinHeight, isTrue); + }); + + testWidgets('BottomNavigationBar adds bottom padding to height with a custom font size', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(viewPadding: EdgeInsets.only(bottom: 40.0)), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + selectedFontSize: 8, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ), + ); + + const double expectedHeight = kBottomNavigationBarHeight + 40.0; + expect(tester.getSize(find.byType(BottomNavigationBar)).height, expectedHeight); + }); + + testWidgets('BottomNavigationBar height will not change when toggle keyboard', ( + WidgetTester tester, + ) async { + final Widget child = Scaffold( + bottomNavigationBar: BottomNavigationBar( + selectedFontSize: 8, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ); + + // Test the bar height is correct when not showing the keyboard. + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 40.0), + padding: EdgeInsets.only(bottom: 40.0), + ), + child: child, + ), + ), + ); + + // Expect the height is the correct. + const double expectedHeight = kBottomNavigationBarHeight + 40.0; + expect(tester.getSize(find.byType(BottomNavigationBar)).height, expectedHeight); + + // Now we show the keyboard. + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 40.0), + viewInsets: EdgeInsets.only(bottom: 336.0), + ), + child: child, + ), + ), + ); + + // Expect the height is the same. + expect(tester.getSize(find.byType(BottomNavigationBar)).height, expectedHeight); + }); + + testWidgets('BottomNavigationBar action size test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + Iterable<RenderBox> actions = tester.renderObjectList(find.byType(InkResponse)); + expect(actions.length, 2); + expect(actions.elementAt(0).size.width, 480.0); + expect(actions.elementAt(1).size.width, 320.0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + currentIndex: 1, + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 200)); + + actions = tester.renderObjectList(find.byType(InkResponse)); + expect(actions.length, 2); + expect(actions.elementAt(0).size.width, 320.0); + expect(actions.elementAt(1).size.width, 480.0); + }); + + testWidgets('BottomNavigationBar multiple taps test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + BottomNavigationBarItem(icon: Icon(Icons.access_time), label: 'Time'), + BottomNavigationBarItem(icon: Icon(Icons.add), label: 'Add'), + ], + ), + ), + ), + ); + + // We want to make sure that the last label does not get displaced, + // irrespective of how many taps happen on the first N - 1 labels and how + // they grow. + + Iterable<RenderBox> actions = tester.renderObjectList(find.byType(InkResponse)); + final Offset originalOrigin = actions.elementAt(3).localToGlobal(Offset.zero); + + await tester.tap(find.text('AC')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + actions = tester.renderObjectList(find.byType(InkResponse)); + expect(actions.elementAt(3).localToGlobal(Offset.zero), equals(originalOrigin)); + + await tester.tap(find.text('Alarm')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + actions = tester.renderObjectList(find.byType(InkResponse)); + expect(actions.elementAt(3).localToGlobal(Offset.zero), equals(originalOrigin)); + + await tester.tap(find.text('Time')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + actions = tester.renderObjectList(find.byType(InkResponse)); + expect(actions.elementAt(3).localToGlobal(Offset.zero), equals(originalOrigin)); + }); + + testWidgets('BottomNavigationBar inherits shadowed app theme for shifting navbar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + home: Theme( + data: ThemeData(brightness: Brightness.dark), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + BottomNavigationBarItem(icon: Icon(Icons.access_time), label: 'Time'), + BottomNavigationBarItem(icon: Icon(Icons.add), label: 'Add'), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Alarm')); + await tester.pump(const Duration(seconds: 1)); + expect(Theme.of(tester.element(find.text('Alarm'))).brightness, equals(Brightness.dark)); + }); + + testWidgets('BottomNavigationBar inherits shadowed app theme for fixed navbar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.light), + home: Theme( + data: ThemeData(brightness: Brightness.dark), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + BottomNavigationBarItem(icon: Icon(Icons.access_time), label: 'Time'), + BottomNavigationBarItem(icon: Icon(Icons.add), label: 'Add'), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Alarm')); + await tester.pump(const Duration(seconds: 1)); + expect(Theme.of(tester.element(find.text('Alarm'))).brightness, equals(Brightness.dark)); + }); + + testWidgets('BottomNavigationBar iconSize test', (WidgetTester tester) async { + late double builderIconSize; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + iconSize: 12.0, + items: <BottomNavigationBarItem>[ + const BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem( + label: 'B', + icon: Builder( + builder: (BuildContext context) { + builderIconSize = IconTheme.of(context).size!; + return SizedBox(width: builderIconSize, height: builderIconSize); + }, + ), + ), + ], + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(Icon)); + expect(box.size.width, equals(12.0)); + expect(box.size.height, equals(12.0)); + expect(builderIconSize, 12.0); + }); + + testWidgets('Material2 - BottomNavigationBar responds to textScaleFactor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + final RenderBox defaultBox = tester.renderObject(find.byType(BottomNavigationBar)); + expect(defaultBox.size.height, equals(kBottomNavigationBarHeight)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + final RenderBox shiftingBox = tester.renderObject(find.byType(BottomNavigationBar)); + expect(shiftingBox.size.height, equals(kBottomNavigationBarHeight)); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: 2.0, + maxScaleFactor: 2.0, + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + expect(box.size.height, equals(56.0)); + }); + + testWidgets('Material3 - BottomNavigationBar responds to textScaleFactor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + final RenderBox defaultBox = tester.renderObject(find.byType(BottomNavigationBar)); + // kBottomNavigationBarHeight is a minimum dimension. + expect(defaultBox.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + final RenderBox shiftingBox = tester.renderObject(find.byType(BottomNavigationBar)); + // kBottomNavigationBarHeight is a minimum dimension. + expect(shiftingBox.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: 2.0, + maxScaleFactor: 2.0, + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + // kBottomNavigationBarHeight is a minimum dimension. + expect(box.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + }); + + testWidgets( + 'Material2 - BottomNavigationBar does not grow with textScaleFactor when labels are provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + + final RenderBox defaultBox = tester.renderObject(find.byType(BottomNavigationBar)); + expect(defaultBox.size.height, equals(kBottomNavigationBarHeight)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + + final RenderBox shiftingBox = tester.renderObject(find.byType(BottomNavigationBar)); + expect(shiftingBox.size.height, equals(kBottomNavigationBarHeight)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2.0)), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + expect(box.size.height, equals(kBottomNavigationBarHeight)); + }, + ); + + testWidgets( + 'Material3 - BottomNavigationBar does not grow with textScaleFactor when labels are provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + + final RenderBox defaultBox = tester.renderObject(find.byType(BottomNavigationBar)); + // kBottomNavigationBarHeight is a minimum dimension. + expect(defaultBox.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + + final RenderBox shiftingBox = tester.renderObject(find.byType(BottomNavigationBar)); + // kBottomNavigationBarHeight is a minimum dimension. + expect(shiftingBox.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2.0)), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + expect(box.size.height, equals(defaultBox.size.height)); + expect(box.size.height, equals(shiftingBox.size.height)); + }, + ); + + testWidgets( + 'Material2 - BottomNavigationBar shows tool tips with text scaling on long press when labels are provided', + (WidgetTester tester) async { + const label = 'Foo'; + + Widget buildApp({required double textScaleFactor}) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: label, + icon: Icon(Icons.ac_unit), + tooltip: label, + ), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaleFactor: 1.0)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(find.text(label), findsNWidgets(2)); + expect(tester.getSize(find.text(label).last), equals(const Size(42.0, 14.0))); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await tester.pumpWidget(buildApp(textScaleFactor: 4.0)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(tester.getSize(find.text(label).last), equals(const Size(168.0, 56.0))); + }, + ); + + testWidgets( + 'Material3 - BottomNavigationBar shows tool tips with text scaling on long press when labels are provided', + (WidgetTester tester) async { + const label = 'Foo'; + + Widget buildApp({required TextScaler textScaler}) { + return MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: label, + icon: Icon(Icons.ac_unit), + tooltip: label, + ), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: TextScaler.noScaling)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(find.text(label), findsNWidgets(2)); + expect(tester.getSize(find.text(label).last).height, equals(20.0)); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(4.0))); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(tester.getSize(find.text(label).last).height, equals(80.0)); + }, + ); + + testWidgets('Different behaviour of tool tip in BottomNavigationBarItem', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', tooltip: 'A tooltip', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + BottomNavigationBarItem(label: 'C', icon: Icon(Icons.cake), tooltip: ''), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsOneWidget); + await tester.longPress(find.text('A')); + expect(find.byTooltip('A tooltip'), findsOneWidget); + + expect(find.text('B'), findsOneWidget); + await tester.longPress(find.text('B')); + expect(find.byTooltip('B'), findsNothing); + + expect(find.text('C'), findsOneWidget); + await tester.longPress(find.text('C')); + expect(find.byTooltip('C'), findsNothing); + }); + + testWidgets('BottomNavigationBar limits width of tiles with long labels', ( + WidgetTester tester, + ) async { + final longTextA = List<String>.generate(100, (int index) => 'A').toString(); + final longTextB = List<String>.generate(100, (int index) => 'B').toString(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: longTextA, icon: const Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: longTextB, icon: const Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + expect(box.size.height, greaterThanOrEqualTo(kBottomNavigationBarHeight)); + + final RenderBox itemBoxA = tester.renderObject(find.text(longTextA)); + expect(itemBoxA.size.width, equals(400.0)); + final RenderBox itemBoxB = tester.renderObject(find.text(longTextB)); + expect(itemBoxB.size.width, equals(400.0)); + }); + + testWidgets('Material2 - BottomNavigationBar paints circles', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(BottomNavigationBar)); + expect(box, isNot(paints..circle())); + + await tester.tap(find.text('A')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(box, paints..circle(x: 200.0)); + + await tester.tap(find.text('B')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect( + box, + paints + ..circle(x: 200.0) + ..translate(x: 400.0) + ..circle(x: 200.0), + ); + + // Now we flip the directionality and verify that the circles switch positions. + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.rtl, + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ); + + expect( + box, + paints + ..translate() + ..save() + ..translate(x: 400.0) + ..circle(x: 200.0) + ..restore() + ..circle(x: 200.0), + ); + + await tester.tap(find.text('A')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect( + box, + paints + ..translate(x: 0.0, y: 0.0) + ..save() + ..translate(x: 400.0) + ..circle(x: 200.0) + ..restore() + ..circle(x: 200.0) + ..translate(x: 400.0) + ..circle(x: 200.0), + ); + }); + + testWidgets('BottomNavigationBar inactiveIcon shown', (WidgetTester tester) async { + const filled = Key('filled'); + const stroked = Key('stroked'); + var selectedItem = 0; + + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + currentIndex: selectedItem, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + activeIcon: Icon(Icons.favorite, key: filled), + icon: Icon(Icons.favorite_border, key: stroked), + label: 'Favorite', + ), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ); + + expect(find.byKey(filled), findsOneWidget); + expect(find.byKey(stroked), findsNothing); + selectedItem = 1; + + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + currentIndex: selectedItem, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + activeIcon: Icon(Icons.favorite, key: filled), + icon: Icon(Icons.favorite_border, key: stroked), + label: 'Favorite', + ), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ); + + expect(find.byKey(filled), findsNothing); + expect(find.byKey(stroked), findsOneWidget); + }); + + testWidgets('BottomNavigationBar.fixed semantics', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + BottomNavigationBarItem(icon: Icon(Icons.hot_tub), label: 'Hot Tub'), + ], + ), + ), + ); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 3', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + isSelected: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 3', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Hot Tub')), + matchesSemantics( + label: 'Hot Tub\nTab 3 of 3', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }); + + testWidgets('BottomNavigationBar.shifting semantics', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + BottomNavigationBarItem(icon: Icon(Icons.hot_tub), label: 'Hot Tub'), + ], + ), + ), + ); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 3', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + isSelected: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 3', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Hot Tub')), + matchesSemantics( + label: 'Hot Tub\nTab 3 of 3', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }); + + testWidgets('BottomNavigationBar handles items.length changes', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/10322 + + Widget buildFrame(int itemCount) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + items: List<BottomNavigationBarItem>.generate(itemCount, (int itemIndex) { + return BottomNavigationBarItem( + icon: const Icon(Icons.android), + label: 'item $itemIndex', + ); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(3)); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + expect(find.text('item 3'), findsNothing); + + await tester.pumpWidget(buildFrame(4)); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + expect(find.text('item 3'), findsOneWidget); + + await tester.pumpWidget(buildFrame(2)); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsNothing); + expect(find.text('item 3'), findsNothing); + }); + + testWidgets('BottomNavigationBar change backgroundColor test', (WidgetTester tester) async { + // Regression test for: https://github.com/flutter/flutter/issues/19653 + + Color backgroundColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: Center( + child: ElevatedButton( + child: const Text('green'), + onPressed: () { + setState(() { + backgroundColor = Colors.green; + }); + }, + ), + ), + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: 'Page 1', + backgroundColor: backgroundColor, + icon: const Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + label: 'Page 2', + backgroundColor: backgroundColor, + icon: const Icon(Icons.menu), + ), + ], + ), + ); + }, + ), + ), + ); + + final Finder backgroundMaterial = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byWidgetPredicate((Widget w) { + if (w is Material) { + return w.type == MaterialType.canvas; + } + return false; + }), + ); + + expect(backgroundColor, Colors.red); + expect(tester.widget<Material>(backgroundMaterial).color, Colors.red); + await tester.tap(find.text('green')); + await tester.pumpAndSettle(); + expect(backgroundColor, Colors.green); + expect(tester.widget<Material>(backgroundMaterial).color, Colors.green); + }); + + group('Material2 - BottomNavigationBar shifting backgroundColor with transition', () { + // Regression test for: https://github.com/flutter/flutter/issues/22226 + Widget runTest() { + var currentIndex = 0; + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + bottomNavigationBar: RepaintBoundary( + child: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + currentIndex: currentIndex, + onTap: (int index) { + setState(() { + currentIndex = index; + }); + }, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: 'Red', + backgroundColor: Colors.red, + icon: Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + label: 'Green', + backgroundColor: Colors.green, + icon: Icon(Icons.menu), + ), + ], + ), + ), + ); + }, + ), + ); + } + + for (var pump = 1; pump < 9; pump++) { + testWidgets('pump $pump', (WidgetTester tester) async { + await tester.pumpWidget(runTest()); + await tester.tap(find.text('Green')); + + for (var i = 0; i < pump; i++) { + await tester.pump(const Duration(milliseconds: 30)); + } + await expectLater( + find.byType(BottomNavigationBar), + matchesGoldenFile('m2_bottom_navigation_bar.shifting_transition.${pump - 1}.png'), + ); + }); + } + }); + + group('Material3 - BottomNavigationBar shifting backgroundColor with transition', () { + // Regression test for: https://github.com/flutter/flutter/issues/22226 + Widget runTest() { + var currentIndex = 0; + + return MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + bottomNavigationBar: RepaintBoundary( + child: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + currentIndex: currentIndex, + onTap: (int index) { + setState(() { + currentIndex = index; + }); + }, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: 'Red', + backgroundColor: Colors.red, + icon: Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + label: 'Green', + backgroundColor: Colors.green, + icon: Icon(Icons.menu), + ), + ], + ), + ), + ); + }, + ), + ); + } + + for (var pump = 1; pump < 9; pump++) { + testWidgets('pump $pump', (WidgetTester tester) async { + await tester.pumpWidget(runTest()); + await tester.tap(find.text('Green')); + + for (var i = 0; i < pump; i++) { + await tester.pump(const Duration(milliseconds: 30)); + } + await expectLater( + find.byType(BottomNavigationBar), + matchesGoldenFile('m3_bottom_navigation_bar.shifting_transition.${pump - 1}.png'), + ); + }); + } + }); + + testWidgets('BottomNavigationBar item label should not be nullable', (WidgetTester tester) async { + expect(() { + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm)), + ], + ), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('BottomNavigationBar [showSelectedLabels]=false and [showUnselectedLabels]=false ' + 'for shifting navbar, expect that there is no rendered text', (WidgetTester tester) async { + final Widget widget = MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: 'Red', + backgroundColor: Colors.red, + icon: Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + label: 'Green', + backgroundColor: Colors.green, + icon: Icon(Icons.menu), + ), + ], + ), + ); + }, + ), + ); + await tester.pumpWidget(widget); + expect(find.text('Red'), findsOneWidget); + expect(find.text('Green'), findsOneWidget); + expect(tester.widget<Visibility>(find.byType(Visibility).first).visible, false); + expect(tester.widget<Visibility>(find.byType(Visibility).last).visible, false); + }); + + testWidgets('BottomNavigationBar [showSelectedLabels]=false and [showUnselectedLabels]=false ' + 'for fixed navbar, expect that there is no rendered text', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.fixed, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + label: 'Red', + backgroundColor: Colors.red, + icon: Icon(Icons.dashboard), + ), + BottomNavigationBarItem( + label: 'Green', + backgroundColor: Colors.green, + icon: Icon(Icons.menu), + ), + ], + ), + ); + }, + ), + ), + ); + expect(find.text('Red'), findsOneWidget); + expect(find.text('Green'), findsOneWidget); + expect(tester.widget<Visibility>(find.byType(Visibility).first).visible, false); + expect(tester.widget<Visibility>(find.byType(Visibility).last).visible, false); + }); + + testWidgets( + 'BottomNavigationBar.fixed [showSelectedLabels]=false and [showUnselectedLabels]=false semantics', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'Red'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Green'), + ], + ), + ), + ); + + expect( + tester.getSemantics(find.text('Red')), + matchesSemantics( + label: 'Red\nTab 1 of 2', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + isSelected: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Green')), + matchesSemantics( + label: 'Green\nTab 2 of 2', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }, + ); + + testWidgets( + 'BottomNavigationBar.shifting [showSelectedLabels]=false and [showUnselectedLabels]=false semantics', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + type: BottomNavigationBarType.shifting, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'Red'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Green'), + ], + ), + ), + ); + + expect( + tester.getSemantics(find.text('Red')), + matchesSemantics( + label: 'Red\nTab 1 of 2', + textDirection: TextDirection.ltr, + isButton: true, + isFocusable: true, + hasSelectedState: true, + isSelected: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Green')), + matchesSemantics( + label: 'Green\nTab 2 of 2', + textDirection: TextDirection.ltr, + isButton: true, + hasSelectedState: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }, + ); + + testWidgets('BottomNavigationBar changes mouse cursor when the tile is hovered over', ( + WidgetTester tester, + ) async { + // Test BottomNavigationBar() constructor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: BottomNavigationBar( + mouseCursor: SystemMouseCursors.text, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('AC'))); + + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + Widget feedbackBoilerplate({bool? enableFeedback, bool? enableFeedbackTheme}) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBarTheme( + data: BottomNavigationBarThemeData(enableFeedback: enableFeedbackTheme), + child: BottomNavigationBar( + enableFeedback: enableFeedback, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + } + + testWidgets('BottomNavigationBar with enabled feedback', (WidgetTester tester) async { + const enableFeedback = true; + + await tester.pumpWidget(feedbackBoilerplate(enableFeedback: enableFeedback)); + + await tester.tap(find.byType(InkResponse).first); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('BottomNavigationBar with disabled feedback', (WidgetTester tester) async { + const enableFeedback = false; + + await tester.pumpWidget(feedbackBoilerplate(enableFeedback: enableFeedback)); + + await tester.tap(find.byType(InkResponse).first); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets('BottomNavigationBar with enabled feedback by default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(feedbackBoilerplate()); + + await tester.tap(find.byType(InkResponse).first); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('BottomNavigationBar with disabled feedback using BottomNavigationBarTheme', ( + WidgetTester tester, + ) async { + const enableFeedbackTheme = false; + + await tester.pumpWidget(feedbackBoilerplate(enableFeedbackTheme: enableFeedbackTheme)); + + await tester.tap(find.byType(InkResponse).first); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets( + 'BottomNavigationBar.enableFeedback overrides BottomNavigationBarTheme.enableFeedback', + (WidgetTester tester) async { + const enableFeedbackTheme = false; + const enableFeedback = true; + + await tester.pumpWidget( + feedbackBoilerplate( + enableFeedbackTheme: enableFeedbackTheme, + enableFeedback: enableFeedback, + ), + ); + + await tester.tap(find.byType(InkResponse).first); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }, + ); + }); + + testWidgets('BottomNavigationBar excludes semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(label: 'A', icon: Icon(Icons.ac_unit)), + BottomNavigationBarItem(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'A\nTab 1 of 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'B\nTab 2 of 2', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Material2 - BottomNavigationBar default layout', (WidgetTester tester) async { + final Key icon0 = UniqueKey(); + final Key icon1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: SizedBox(key: icon0, width: 200, height: 10), + label: 'Title0', + ), + BottomNavigationBarItem( + icon: SizedBox(key: icon1, width: 200, height: 10), + label: 'Title1', + ), + ], + ), + ); + }, + ), + ), + ); + expect( + tester.getSize(find.byType(BottomNavigationBar)), + const Size(800, kBottomNavigationBarHeight), + ); + expect( + tester.getRect(find.byType(BottomNavigationBar)), + const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600), + ); + + // The height of the navigation bar is kBottomNavigationBarHeight = 56 + // The top of the navigation bar is 600 - 56 = 544 + // The top and bottom of the selected item is defined by its centered icon/label column: + // top = 544 + ((56 - (10 + 10)) / 2) = 562 + // bottom = top + 10 + 10 = 582 + expect(tester.getRect(find.byKey(icon0)).top, 560.0); + expect(tester.getRect(find.text('Title0')).bottom, 584.0); + + // The items are padded horizontally according to + // MainAxisAlignment.spaceAround. Left/right padding is: + // 800 - (200 * 2) / 4 = 100 + // The layout of the unselected item's label is slightly different; not + // checking that here. + expect(tester.getRect(find.text('Title0')), const Rect.fromLTRB(158.0, 570.0, 242.0, 584.0)); + expect(tester.getRect(find.byKey(icon0)), const Rect.fromLTRB(100.0, 560.0, 300.0, 570.0)); + expect(tester.getRect(find.byKey(icon1)), const Rect.fromLTRB(500.0, 560.0, 700.0, 570.0)); + }); + + testWidgets('Material3 - BottomNavigationBar default layout', (WidgetTester tester) async { + final Key icon0 = UniqueKey(); + final Key icon1 = UniqueKey(); + const double iconHeight = 10; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: SizedBox(key: icon0, width: 200, height: iconHeight), + label: 'Title0', + ), + BottomNavigationBarItem( + icon: SizedBox(key: icon1, width: 200, height: iconHeight), + label: 'Title1', + ), + ], + ), + ); + }, + ), + ), + ); + expect( + tester.getSize(find.byType(BottomNavigationBar)), + const Size(800, kBottomNavigationBarHeight), + ); + expect( + tester.getRect(find.byType(BottomNavigationBar)), + const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600), + ); + + const double navigationBarTop = 600 - kBottomNavigationBarHeight; // 544 + const selectedFontSize = 14.0; + const m3LineHeight = 1.43; + final double labelHeight = (selectedFontSize * m3LineHeight).floorToDouble(); // 20 + const double navigationTileVerticalPadding = selectedFontSize / 2; // 7.0 + final double navigationTileHeight = + iconHeight + labelHeight + 2 * navigationTileVerticalPadding; + + // Navigation tiles parent is a Row with crossAxisAlignment set to center. + final double navigationTileVerticalOffset = + (kBottomNavigationBarHeight - navigationTileHeight) / 2; + + final double iconTop = + navigationBarTop + navigationTileVerticalOffset + navigationTileVerticalPadding; + final double labelBottom = 600 - (navigationTileVerticalOffset + navigationTileVerticalPadding); + + expect(tester.getRect(find.byKey(icon0)).top, iconTop); + expect(tester.getRect(find.text('Title0')).bottom, labelBottom); + + // The items are padded horizontally according to + // MainAxisAlignment.spaceAround. Left/right padding is: + // 800 - (200 * 2) / 4 = 100 + // The layout of the unselected item's label is slightly different; not + // checking that here. + final double firstLabelWidth = tester.getSize(find.text('Title0')).width; + const double itemsWidth = 800 / 2; // 2 items. + const double firstLabelCenter = itemsWidth / 2; + expect( + tester.getRect(find.text('Title0')), + Rect.fromLTRB( + firstLabelCenter - firstLabelWidth / 2, + labelBottom - labelHeight, + firstLabelCenter + firstLabelWidth / 2, + labelBottom, + ), + ); + expect( + tester.getRect(find.byKey(icon0)), + Rect.fromLTRB(100.0, iconTop, 300.0, iconTop + iconHeight), + ); + expect( + tester.getRect(find.byKey(icon1)), + Rect.fromLTRB(500.0, iconTop, 700.0, iconTop + iconHeight), + ); + }); + + testWidgets('Material2 - BottomNavigationBar centered landscape layout', ( + WidgetTester tester, + ) async { + final Key icon0 = UniqueKey(); + final Key icon1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + landscapeLayout: BottomNavigationBarLandscapeLayout.centered, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: SizedBox(key: icon0, width: 200, height: 10), + label: 'Title0', + ), + BottomNavigationBarItem( + icon: SizedBox(key: icon1, width: 200, height: 10), + label: 'Title1', + ), + ], + ), + ); + }, + ), + ), + ); + + expect( + tester.getSize(find.byType(BottomNavigationBar)), + const Size(800, kBottomNavigationBarHeight), + ); + expect( + tester.getRect(find.byType(BottomNavigationBar)), + const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600), + ); + + // The items are laid out as in the default case, within width = 600 + // (the "portrait" width) and the result is centered with the + // landscape width = 800. + // So item 0's left edges are: + // ((800 - 600) / 2) + ((600 - 400) / 4) = 150. + // Item 1's right edge is: + // 800 - 150 = 650 + // The layout of the unselected item's label is slightly different; not + // checking that here. + expect(tester.getRect(find.text('Title0')), const Rect.fromLTRB(208.0, 570.0, 292.0, 584.0)); + expect(tester.getRect(find.byKey(icon0)), const Rect.fromLTRB(150.0, 560.0, 350.0, 570.0)); + expect(tester.getRect(find.byKey(icon1)), const Rect.fromLTRB(450.0, 560.0, 650.0, 570.0)); + }); + + testWidgets('Material3 - BottomNavigationBar centered landscape layout', ( + WidgetTester tester, + ) async { + final Key icon0 = UniqueKey(); + final Key icon1 = UniqueKey(); + const double iconWidth = 200; + const double iconHeight = 10; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + landscapeLayout: BottomNavigationBarLandscapeLayout.centered, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: SizedBox(key: icon0, width: iconWidth, height: iconHeight), + label: 'Title0', + ), + BottomNavigationBarItem( + icon: SizedBox(key: icon1, width: iconWidth, height: iconHeight), + label: 'Title1', + ), + ], + ), + ); + }, + ), + ), + ); + + expect( + tester.getSize(find.byType(BottomNavigationBar)), + const Size(800, kBottomNavigationBarHeight), + ); + expect( + tester.getRect(find.byType(BottomNavigationBar)), + const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600), + ); + + const double navigationBarTop = 600 - kBottomNavigationBarHeight; // 544 + const selectedFontSize = 14.0; + const m3LineHeight = 1.43; + final double labelHeight = (selectedFontSize * m3LineHeight).floorToDouble(); // 20 + const double navigationTileVerticalPadding = selectedFontSize / 2; // 7.0 + final double navigationTileHeight = + iconHeight + labelHeight + 2 * navigationTileVerticalPadding; + + // Navigation tiles parent is a Row with crossAxisAlignment sets to center. + final double navigationTileVerticalOffset = + (kBottomNavigationBarHeight - navigationTileHeight) / 2; + + final double iconTop = + navigationBarTop + navigationTileVerticalOffset + navigationTileVerticalPadding; + final double labelBottom = 600 - (navigationTileVerticalOffset + navigationTileVerticalPadding); + + // The items are laid out as in the default case, within width = 600 + // (the "portrait" width) and the result is centered with the + // landscape width = 800. + // So item 0's left edges are: + // ((800 - 600) / 2) + ((600 - 400) / 4) = 150. + // Item 1's right edge is: + // 800 - 150 = 650 + // The layout of the unselected item's label is slightly different; not + // checking that here. + final double firstLabelWidth = tester.getSize(find.text('Title0')).width; + const itemWidth = iconWidth; // 200 + const double firstItemLeft = 150; + const double firstLabelCenter = firstItemLeft + itemWidth / 2; // 250 + + expect( + tester.getRect(find.text('Title0')), + Rect.fromLTRB( + firstLabelCenter - firstLabelWidth / 2, + labelBottom - labelHeight, + firstLabelCenter + firstLabelWidth / 2, + labelBottom, + ), + ); + expect( + tester.getRect(find.byKey(icon0)), + Rect.fromLTRB(150.0, iconTop, 350.0, iconTop + iconHeight), + ); + expect( + tester.getRect(find.byKey(icon1)), + Rect.fromLTRB(450.0, iconTop, 650.0, iconTop + iconHeight), + ); + }); + + testWidgets('Material2 - BottomNavigationBar linear landscape layout', ( + WidgetTester tester, + ) async { + final Key icon0 = UniqueKey(); + final Key icon1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + landscapeLayout: BottomNavigationBarLandscapeLayout.linear, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: SizedBox(key: icon0, width: 100, height: 20), + label: 'Title0', + ), + BottomNavigationBarItem( + icon: SizedBox(key: icon1, width: 100, height: 20), + label: 'Title1', + ), + ], + ), + ); + }, + ), + ), + ); + + expect( + tester.getSize(find.byType(BottomNavigationBar)), + const Size(800, kBottomNavigationBarHeight), + ); + expect( + tester.getRect(find.byType(BottomNavigationBar)), + const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600), + ); + + // The items are laid out as in the default case except each + // item's icon/label is arranged in a row, with 8 pixels in + // between the icon and label. The layout of the unselected + // item's label is slightly different; not checking that here. + expect(tester.getRect(find.text('Title0')), const Rect.fromLTRB(212.0, 565.0, 296.0, 579.0)); + expect(tester.getRect(find.byKey(icon0)), const Rect.fromLTRB(104.0, 562.0, 204.0, 582.0)); + expect(tester.getRect(find.byKey(icon1)), const Rect.fromLTRB(504.0, 562.0, 604.0, 582.0)); + }); + + testWidgets('Material3 - BottomNavigationBar linear landscape layout', ( + WidgetTester tester, + ) async { + final Key icon0 = UniqueKey(); + final Key icon1 = UniqueKey(); + const double iconWidth = 100; + const double iconHeight = 20; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + landscapeLayout: BottomNavigationBarLandscapeLayout.linear, + items: <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: SizedBox(key: icon0, width: iconWidth, height: iconHeight), + label: 'Title0', + ), + BottomNavigationBarItem( + icon: SizedBox(key: icon1, width: iconWidth, height: iconHeight), + label: 'Title1', + ), + ], + ), + ); + }, + ), + ), + ); + + expect( + tester.getSize(find.byType(BottomNavigationBar)), + const Size(800, kBottomNavigationBarHeight), + ); + expect( + tester.getRect(find.byType(BottomNavigationBar)), + const Rect.fromLTRB(0, 600 - kBottomNavigationBarHeight, 800, 600), + ); + + const double navigationBarTop = 600 - kBottomNavigationBarHeight; // 544 + const selectedFontSize = 14.0; + const m3LineHeight = 1.43; + final double labelHeight = (selectedFontSize * m3LineHeight).floorToDouble(); // 20 + const double navigationTileVerticalPadding = selectedFontSize / 2; // 7.0 + // Icon and label are in the same row. + final double navigationTileHeight = + max(iconHeight, labelHeight) + 2 * navigationTileVerticalPadding; + + // Navigation tiles parent is a Row with crossAxisAlignment sets to center. + final double navigationTileVerticalOffset = + (kBottomNavigationBarHeight - navigationTileHeight) / 2; + + final double iconTop = + navigationBarTop + navigationTileVerticalOffset + navigationTileVerticalPadding; + final double labelBottom = 600 - (navigationTileVerticalOffset + navigationTileVerticalPadding); + + // The items are laid out as in the default case except each + // item's icon/label is arranged in a row, with 8 pixels in + // between the icon and label. The layout of the unselected + // item's label is slightly different; not checking that here. + const double itemFullWith = 800 / 2; // Two items in the navigation bar. + const double separatorWidth = 8; + final double firstLabelWidth = tester.getSize(find.text('Title0')).width; + final double firstItemContentWidth = iconWidth + separatorWidth + firstLabelWidth; + final double firstItemLeft = itemFullWith / 2 - firstItemContentWidth / 2; + final double secondLabelWidth = tester.getSize(find.text('Title1')).width; + final double secondItemContentWidth = iconWidth + separatorWidth + secondLabelWidth; + final double secondItemLeft = itemFullWith + itemFullWith / 2 - secondItemContentWidth / 2; + + expect( + tester.getRect(find.text('Title0')), + Rect.fromLTRB( + firstItemLeft + iconWidth + separatorWidth, + labelBottom - labelHeight, + firstItemLeft + iconWidth + separatorWidth + firstLabelWidth, + labelBottom, + ), + ); + expect( + tester.getRect(find.byKey(icon0)), + Rect.fromLTRB(firstItemLeft, iconTop, firstItemLeft + iconWidth, iconTop + iconHeight), + ); + expect( + tester.getRect(find.byKey(icon1)), + Rect.fromLTRB(secondItemLeft, iconTop, secondItemLeft + iconWidth, iconTop + iconHeight), + ); + }); + + testWidgets('BottomNavigationBar linear landscape layout label RenderFlex overflow', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/112163 + + tester.view.physicalSize = const Size(540, 340); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + landscapeLayout: BottomNavigationBarLandscapeLayout.linear, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: Icon(Icons.home_rounded), + label: 'Home Challenges', + backgroundColor: Colors.grey, + tooltip: '', + ), + BottomNavigationBarItem( + icon: Icon(Icons.date_range_rounded), + label: 'Daily Challenges', + backgroundColor: Colors.grey, + tooltip: '', + ), + BottomNavigationBarItem( + icon: Icon(Icons.wind_power), + label: 'Awards Challenges', + backgroundColor: Colors.grey, + tooltip: '', + ), + BottomNavigationBarItem( + icon: Icon(Icons.bar_chart_rounded), + label: 'Statistics Challenges', + backgroundColor: Colors.grey, + tooltip: '', + ), + ], + ), + ); + }, + ), + ), + ); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('bottom_navigation_bar.label_overflow.png'), + ); + }); + + testWidgets('BottomNavigationBar keys passed through', (WidgetTester tester) async { + const key1 = Key('key1'); + const key2 = Key('key2'); + + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + key: key1, + icon: Icon(Icons.favorite_border), + label: 'Favorite', + ), + BottomNavigationBarItem(key: key2, icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ); + + expect(find.byKey(key1), findsOneWidget); + expect(find.byKey(key2), findsOneWidget); + }); + + testWidgets('BottomNavigationBar and BottomNavigationBarItem render at zero size', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.favorite_border), label: 'X'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Y'), + ], + ), + ), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); + + testWidgets('BottomNavigationBarItem.semanticsLabel overrides Text semantics', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + textDirection: TextDirection.ltr, + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem( + icon: Icon(Icons.ac_unit), + label: 'A', + semanticsLabel: 'Custom A label', + ), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'B'), + ], + ), + ), + ); + + expect(tester.getSemantics(find.text('A')), isSemantics(label: 'Custom A label\nTab 1 of 2')); + + expect(tester.getSemantics(find.text('B')), isSemantics(label: 'B\nTab 2 of 2')); + }); +} + +Widget boilerplate({ + Widget? bottomNavigationBar, + required TextDirection textDirection, + bool? useMaterial3, +}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: const MediaQueryData(), + child: Material(child: Scaffold(bottomNavigationBar: bottomNavigationBar)), + ), + ), + ), + ); +} + +double _getOpacity(WidgetTester tester, String textValue) { + final FadeTransition opacityWidget = tester.widget<FadeTransition>( + find.ancestor(of: find.text(textValue), matching: find.byType(FadeTransition)).first, + ); + return opacityWidget.opacity.value; +} + +Material _getMaterial(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(BottomNavigationBar), matching: find.byType(Material)), + ); +} + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; +} + +EdgeInsets _itemPadding(WidgetTester tester, IconData icon) { + return tester + .widget<Padding>( + find + .descendant( + of: find.ancestor(of: find.byIcon(icon), matching: find.byType(InkResponse)), + matching: find.byType(Padding), + ) + .first, + ) + .padding + .resolve(TextDirection.ltr); +} diff --git a/packages/material_ui/test/material/bottom_navigation_bar_theme_test.dart b/packages/material_ui/test/material/bottom_navigation_bar_theme_test.dart new file mode 100644 index 000000000000..816668c6de07 --- /dev/null +++ b/packages/material_ui/test/material/bottom_navigation_bar_theme_test.dart @@ -0,0 +1,451 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math_64.dart' show Vector3; + +void main() { + test('BottomNavigationBarThemeData copyWith, ==, hashCode basics', () { + expect(const BottomNavigationBarThemeData(), const BottomNavigationBarThemeData().copyWith()); + expect( + const BottomNavigationBarThemeData().hashCode, + const BottomNavigationBarThemeData().copyWith().hashCode, + ); + }); + + test('BottomNavigationBarThemeData lerp special cases', () { + const data = BottomNavigationBarThemeData(); + expect(identical(BottomNavigationBarThemeData.lerp(data, data, 0.5), data), true); + }); + + test('BottomNavigationBarThemeData defaults', () { + const themeData = BottomNavigationBarThemeData(); + expect(themeData.backgroundColor, null); + expect(themeData.elevation, null); + expect(themeData.selectedIconTheme, null); + expect(themeData.unselectedIconTheme, null); + expect(themeData.selectedItemColor, null); + expect(themeData.unselectedItemColor, null); + expect(themeData.selectedLabelStyle, null); + expect(themeData.unselectedLabelStyle, null); + expect(themeData.showSelectedLabels, null); + expect(themeData.showUnselectedLabels, null); + expect(themeData.type, null); + expect(themeData.landscapeLayout, null); + expect(themeData.mouseCursor, null); + + const theme = BottomNavigationBarTheme(data: BottomNavigationBarThemeData(), child: SizedBox()); + expect(theme.data.backgroundColor, null); + expect(theme.data.elevation, null); + expect(theme.data.selectedIconTheme, null); + expect(theme.data.unselectedIconTheme, null); + expect(theme.data.selectedItemColor, null); + expect(theme.data.unselectedItemColor, null); + expect(theme.data.selectedLabelStyle, null); + expect(theme.data.unselectedLabelStyle, null); + expect(theme.data.showSelectedLabels, null); + expect(theme.data.showUnselectedLabels, null); + expect(theme.data.type, null); + expect(themeData.landscapeLayout, null); + expect(themeData.mouseCursor, null); + }); + + testWidgets('Default BottomNavigationBarThemeData debugFillProperties', ( + WidgetTester tester, + ) async { + final builder = DiagnosticPropertiesBuilder(); + const BottomNavigationBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('BottomNavigationBarThemeData implements debugFillProperties', ( + WidgetTester tester, + ) async { + final builder = DiagnosticPropertiesBuilder(); + const BottomNavigationBarThemeData( + backgroundColor: Color(0xfffffff0), + elevation: 10.0, + selectedIconTheme: IconThemeData(size: 1.0), + unselectedIconTheme: IconThemeData(size: 2.0), + selectedItemColor: Color(0xfffffff1), + unselectedItemColor: Color(0xfffffff2), + selectedLabelStyle: TextStyle(fontSize: 3.0), + unselectedLabelStyle: TextStyle(fontSize: 4.0), + showSelectedLabels: true, + showUnselectedLabels: true, + type: BottomNavigationBarType.fixed, + mouseCursor: WidgetStateMouseCursor.clickable, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'backgroundColor: ${const Color(0xfffffff0)}'); + expect(description[1], 'elevation: 10.0'); + + // Ignore instance address for IconThemeData. + expect(description[2].contains('selectedIconTheme: IconThemeData'), isTrue); + expect(description[2].contains('(size: 1.0)'), isTrue); + expect(description[3].contains('unselectedIconTheme: IconThemeData'), isTrue); + expect(description[3].contains('(size: 2.0)'), isTrue); + + expect(description[4], 'selectedItemColor: ${const Color(0xfffffff1)}'); + expect(description[5], 'unselectedItemColor: ${const Color(0xfffffff2)}'); + expect(description[6], 'selectedLabelStyle: TextStyle(inherit: true, size: 3.0)'); + expect(description[7], 'unselectedLabelStyle: TextStyle(inherit: true, size: 4.0)'); + expect(description[8], 'showSelectedLabels: true'); + expect(description[9], 'showUnselectedLabels: true'); + expect(description[10], 'type: BottomNavigationBarType.fixed'); + expect(description[11], 'mouseCursor: WidgetStateMouseCursor(clickable)'); + }); + + testWidgets('BottomNavigationBar is themeable', (WidgetTester tester) async { + const backgroundColor = Color(0xFF000001); + const selectedItemColor = Color(0xFF000002); + const unselectedItemColor = Color(0xFF000003); + const selectedIconTheme = IconThemeData(size: 10); + const unselectedIconTheme = IconThemeData(size: 11); + const selectedTextStyle = TextStyle(fontSize: 22); + const unselectedTextStyle = TextStyle(fontSize: 21); + const elevation = 9.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: backgroundColor, + selectedItemColor: selectedItemColor, + unselectedItemColor: unselectedItemColor, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + elevation: elevation, + showUnselectedLabels: true, + showSelectedLabels: true, + type: BottomNavigationBarType.fixed, + selectedLabelStyle: selectedTextStyle, + unselectedLabelStyle: unselectedTextStyle, + mouseCursor: WidgetStateProperty.resolveWith<MouseCursor?>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return SystemMouseCursors.grab; + } + return SystemMouseCursors.move; + }), + ), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final Finder findACTransform = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.ancestor(of: find.text('AC'), matching: find.byType(Transform)), + ); + final Finder findAlarmTransform = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ); + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + expect(selectedFontStyle.fontSize, selectedFontStyle.fontSize); + // Unselected label has a font size of 22 but is scaled down to be font size 21. + expect( + tester.firstWidget<Transform>(findAlarmTransform).transform, + equals( + Matrix4.diagonal3(Vector3.all(unselectedTextStyle.fontSize! / selectedTextStyle.fontSize!)), + ), + ); + expect(selectedIcon.color, equals(selectedItemColor)); + expect(selectedIcon.fontSize, equals(selectedIconTheme.size)); + expect(unselectedIcon.color, equals(unselectedItemColor)); + expect(unselectedIcon.fontSize, equals(unselectedIconTheme.size)); + // There should not be any [Opacity] or [FadeTransition] widgets + // since showUnselectedLabels and showSelectedLabels are true. + final Finder findOpacity = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(Opacity), + ); + final Finder findFadeTransition = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(FadeTransition), + ); + expect(findOpacity, findsNothing); + expect(findFadeTransition, findsNothing); + expect(_material(tester).elevation, equals(elevation)); + expect(_material(tester).color, equals(backgroundColor)); + + final Offset selectedBarItem = tester.getCenter(findACTransform); + final Offset unselectedBarItem = tester.getCenter(findAlarmTransform); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(selectedBarItem); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + await gesture.moveTo(unselectedBarItem); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.move, + ); + }); + + testWidgets('BottomNavigationBar properties are taken over the theme values', ( + WidgetTester tester, + ) async { + const themeBackgroundColor = Color(0xFF000001); + const themeSelectedItemColor = Color(0xFF000002); + const themeUnselectedItemColor = Color(0xFF000003); + const themeSelectedIconTheme = IconThemeData(size: 10); + const themeUnselectedIconTheme = IconThemeData(size: 11); + const themeSelectedTextStyle = TextStyle(fontSize: 22); + const themeUnselectedTextStyle = TextStyle(fontSize: 21); + const themeElevation = 9.0; + const BottomNavigationBarLandscapeLayout themeLandscapeLayout = + BottomNavigationBarLandscapeLayout.centered; + const WidgetStateMouseCursor themeCursor = WidgetStateMouseCursor.clickable; + + const backgroundColor = Color(0xFF000004); + const selectedItemColor = Color(0xFF000005); + const unselectedItemColor = Color(0xFF000006); + const selectedIconTheme = IconThemeData(size: 15); + const unselectedIconTheme = IconThemeData(size: 16); + const selectedTextStyle = TextStyle(fontSize: 25); + const unselectedTextStyle = TextStyle(fontSize: 26); + const elevation = 7.0; + const BottomNavigationBarLandscapeLayout landscapeLayout = + BottomNavigationBarLandscapeLayout.spread; + const WidgetStateMouseCursor cursor = WidgetStateMouseCursor.textable; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: themeBackgroundColor, + selectedItemColor: themeSelectedItemColor, + unselectedItemColor: themeUnselectedItemColor, + selectedIconTheme: themeSelectedIconTheme, + unselectedIconTheme: themeUnselectedIconTheme, + elevation: themeElevation, + showUnselectedLabels: false, + showSelectedLabels: false, + type: BottomNavigationBarType.shifting, + selectedLabelStyle: themeSelectedTextStyle, + unselectedLabelStyle: themeUnselectedTextStyle, + landscapeLayout: themeLandscapeLayout, + mouseCursor: themeCursor, + ), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + backgroundColor: backgroundColor, + selectedItemColor: selectedItemColor, + unselectedItemColor: unselectedItemColor, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + elevation: elevation, + showUnselectedLabels: true, + showSelectedLabels: true, + type: BottomNavigationBarType.fixed, + selectedLabelStyle: selectedTextStyle, + unselectedLabelStyle: unselectedTextStyle, + landscapeLayout: landscapeLayout, + mouseCursor: cursor, + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + Finder findDescendantOfBottomNavigationBar(Finder finder) { + return find.descendant(of: find.byType(BottomNavigationBar), matching: finder); + } + + final TextStyle selectedFontStyle = tester + .renderObject<RenderParagraph>(find.text('AC')) + .text + .style!; + final TextStyle selectedIcon = _iconStyle(tester, Icons.ac_unit); + final TextStyle unselectedIcon = _iconStyle(tester, Icons.access_alarm); + expect(selectedFontStyle.fontSize, selectedFontStyle.fontSize); + // Unselected label has a font size of 22 but is scaled down to be font size 21. + expect( + tester + .firstWidget<Transform>( + findDescendantOfBottomNavigationBar( + find.ancestor(of: find.text('Alarm'), matching: find.byType(Transform)), + ), + ) + .transform, + equals( + Matrix4.diagonal3(Vector3.all(unselectedTextStyle.fontSize! / selectedTextStyle.fontSize!)), + ), + ); + expect(selectedIcon.color, equals(selectedItemColor)); + expect(selectedIcon.fontSize, equals(selectedIconTheme.size)); + expect(unselectedIcon.color, equals(unselectedItemColor)); + expect(unselectedIcon.fontSize, equals(unselectedIconTheme.size)); + // There should not be any [Opacity] or [FadeTransition] widgets + // since showUnselectedLabels and showSelectedLabels are true. + final Finder findOpacity = findDescendantOfBottomNavigationBar(find.byType(Opacity)); + final Finder findFadeTransition = findDescendantOfBottomNavigationBar( + find.byType(FadeTransition), + ); + expect(findOpacity, findsNothing); + expect(findFadeTransition, findsNothing); + expect(_material(tester).elevation, equals(elevation)); + expect(_material(tester).color, equals(backgroundColor)); + + final Offset barItem = tester.getCenter( + findDescendantOfBottomNavigationBar( + find.ancestor(of: find.text('AC'), matching: find.byType(Transform)), + ), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(barItem); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + + testWidgets('BottomNavigationBarTheme can be used to hide all labels', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/66738. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + showSelectedLabels: false, + showUnselectedLabels: false, + ), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final Finder findVisibility = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(Visibility), + ); + + expect(findVisibility, findsNWidgets(2)); + expect(tester.widget<Visibility>(findVisibility.at(0)).visible, false); + expect(tester.widget<Visibility>(findVisibility.at(1)).visible, false); + }); + + testWidgets('BottomNavigationBarTheme can be used to hide selected labels', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/66738. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + showSelectedLabels: false, + showUnselectedLabels: true, + ), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final Finder findFadeTransition = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(FadeTransition), + ); + + expect(findFadeTransition, findsNWidgets(2)); + expect(tester.widget<FadeTransition>(findFadeTransition.at(0)).opacity.value, 0.0); + expect(tester.widget<FadeTransition>(findFadeTransition.at(1)).opacity.value, 1.0); + }); + + testWidgets('BottomNavigationBarTheme can be used to hide unselected labels', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + showSelectedLabels: true, + showUnselectedLabels: false, + ), + ), + home: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'AC'), + BottomNavigationBarItem(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ); + + final Finder findFadeTransition = find.descendant( + of: find.byType(BottomNavigationBar), + matching: find.byType(FadeTransition), + ); + + expect(findFadeTransition, findsNWidgets(2)); + expect(tester.widget<FadeTransition>(findFadeTransition.at(0)).opacity.value, 1.0); + expect(tester.widget<FadeTransition>(findFadeTransition.at(1)).opacity.value, 0.0); + }); +} + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; +} + +Material _material(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(BottomNavigationBar), matching: find.byType(Material)), + ); +} diff --git a/packages/material_ui/test/material/bottom_sheet_test.dart b/packages/material_ui/test/material/bottom_sheet_test.dart new file mode 100644 index 000000000000..b9ad96f3b9be --- /dev/null +++ b/packages/material_ui/test/material/bottom_sheet_test.dart @@ -0,0 +1,3302 @@ +// 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:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + // Pumps and ensures that the BottomSheet animates non-linearly. + Future<void> checkNonLinearAnimation(WidgetTester tester) async { + final Offset firstPosition = tester.getCenter(find.text('BottomSheet')); + await tester.pump(const Duration(milliseconds: 30)); + final Offset secondPosition = tester.getCenter(find.text('BottomSheet')); + await tester.pump(const Duration(milliseconds: 30)); + final Offset thirdPosition = tester.getCenter(find.text('BottomSheet')); + + final double dyDelta1 = secondPosition.dy - firstPosition.dy; + final double dyDelta2 = thirdPosition.dy - secondPosition.dy; + + // If the animation were linear, these two values would be the same. + expect(dyDelta1, isNot(moreOrLessEquals(dyDelta2, epsilon: 0.1))); + } + + testWidgets('Throw if enable drag without an animation controller', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/89168 + await tester.pumpWidget( + MaterialApp( + home: BottomSheet( + onClosing: () {}, + builder: (_) => + Container(height: 200, color: Colors.red, child: const Text('BottomSheet')), + ), + ), + ); + + final FlutterExceptionHandler? handler = FlutterError.onError; + FlutterErrorDetails? error; + FlutterError.onError = (FlutterErrorDetails details) { + error = details; + }; + + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + + expect(error, isNotNull); + FlutterError.onError = handler; + }); + + testWidgets('Disposing app while bottom sheet is disappearing does not crash', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + // Bring up bottom sheet. + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Start closing animation of Bottom sheet. + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + + // Dispose app by replacing it with a container. This shouldn't crash. + await tester.pumpWidget(Container()); + }); + + testWidgets('Swiping down a BottomSheet should dismiss it by default', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + var showBottomSheetThenCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + await tester.pump(); + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsNothing); + + scaffoldKey.currentState! + .showBottomSheet((BuildContext context) { + return const SizedBox(height: 200.0, child: Text('BottomSheet')); + }) + .closed + .whenComplete(() { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Swiping down a BottomSheet should not dismiss it when enableDrag is false', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + var showBottomSheetThenCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + await tester.pump(); + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsNothing); + + scaffoldKey.currentState! + .showBottomSheet((BuildContext context) { + return const SizedBox(height: 200.0, child: Text('BottomSheet')); + }, enableDrag: false) + .closed + .whenComplete(() { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + + // Swipe the bottom sheet, attempting to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet should not dismiss. + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + }); + + testWidgets('Swiping down a BottomSheet should dismiss it when enableDrag is true', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + var showBottomSheetThenCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + await tester.pump(); + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsNothing); + + scaffoldKey.currentState! + .showBottomSheet((BuildContext context) { + return const SizedBox(height: 200.0, child: Text('BottomSheet')); + }, enableDrag: true) + .closed + .whenComplete(() { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Tapping on a BottomSheet should not trigger a rebuild when enableDrag is true', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/126833. + final scaffoldKey = GlobalKey<ScaffoldState>(); + var buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + await tester.pump(); + expect(buildCount, 0); + expect(find.text('BottomSheet'), findsNothing); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + buildCount++; + return const SizedBox(height: 200.0, child: Text('BottomSheet')); + }, enableDrag: true); + + await tester.pumpAndSettle(); + expect(buildCount, 1); + expect(find.text('BottomSheet'), findsOneWidget); + + // Tap on bottom sheet should not trigger a rebuild. + await tester.tap(find.text('BottomSheet')); + await tester.pumpAndSettle(); + expect(buildCount, 1); + expect(find.text('BottomSheet'), findsOneWidget); + }); + + testWidgets('Modal BottomSheet builder should only be called once', (WidgetTester tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + var numBuilderCalls = 0; + showModalBottomSheet<void>( + context: savedContext, + isDismissible: false, + builder: (BuildContext context) { + numBuilderCalls++; + return const Text('BottomSheet'); + }, + ); + + await tester.pumpAndSettle(); + expect(numBuilderCalls, 1); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(numBuilderCalls, 1); + }); + + testWidgets('Tapping on a modal BottomSheet should not dismiss it', (WidgetTester tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Tap on the bottom sheet itself, it should not be dismissed + await tester.tap(find.text('BottomSheet')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + }); + + testWidgets('Tapping outside a modal BottomSheet should dismiss it by default', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Tap above the bottom sheet to dismiss it. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Tapping outside a modal BottomSheet should dismiss it when isDismissible=true', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Tap above the bottom sheet to dismiss it. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Verify that the BottomSheet animates non-linearly', (WidgetTester tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => const Text('BottomSheet'), + ); + await tester.pump(); + + await checkNonLinearAnimation(tester); + await tester.pumpAndSettle(); + + // Tap above the bottom sheet to dismiss it. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pump(); + await checkNonLinearAnimation(tester); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(find.text('BottomSheet'), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/121098 + testWidgets('Verify that accessibleNavigation has no impact on the BottomSheet animation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery(data: const MediaQueryData(accessibleNavigation: true), child: child!); + }, + home: const Center(child: Text('Test')), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + final BuildContext homeContext = tester.element(find.text('Test')); + showModalBottomSheet<void>( + context: homeContext, + builder: (BuildContext context) => const Text('BottomSheet'), + ); + await tester.pump(); + + await checkNonLinearAnimation(tester); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'Tapping outside a modal BottomSheet should not dismiss it when isDismissible=false', + (WidgetTester tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => const Text('BottomSheet'), + isDismissible: false, + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Tap above the bottom sheet, attempting to dismiss it. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); // Bottom sheet should not dismiss. + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + }, + ); + + testWidgets('Swiping down a modal BottomSheet should dismiss it by default', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + isDismissible: false, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Swiping down a modal BottomSheet should not dismiss it when enableDrag is false', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + isDismissible: false, + enableDrag: false, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Swipe the bottom sheet, attempting to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet should not dismiss. + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + }); + + testWidgets('Swiping down a modal BottomSheet should dismiss it when enableDrag is true', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.text('BottomSheet'), findsNothing); + + var showBottomSheetThenCalled = false; + showModalBottomSheet<void>( + context: savedContext, + isDismissible: false, + builder: (BuildContext context) => const Text('BottomSheet'), + ).then<void>((void value) { + showBottomSheetThenCalled = true; + }); + + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(showBottomSheetThenCalled, isFalse); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Modal BottomSheet builder should only be called once', (WidgetTester tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + var numBuilderCalls = 0; + showModalBottomSheet<void>( + context: savedContext, + isDismissible: false, + builder: (BuildContext context) { + numBuilderCalls++; + return const Text('BottomSheet'); + }, + ); + + await tester.pumpAndSettle(); + expect(numBuilderCalls, 1); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(numBuilderCalls, 1); + }); + + testWidgets('Verify that a downwards fling dismisses a persistent BottomSheet', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + var showBottomSheetThenCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsNothing); + + scaffoldKey.currentState! + .showBottomSheet((BuildContext context) { + return Container(margin: const EdgeInsets.all(40.0), child: const Text('BottomSheet')); + }) + .closed + .whenComplete(() { + showBottomSheetThenCalled = true; + }); + + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsNothing); + + await tester.pump(); // bottom sheet show animation starts + + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); // animation done + + expect(showBottomSheetThenCalled, isFalse); + expect(find.text('BottomSheet'), findsOneWidget); + + // The fling below must be such that the velocity estimation examines an + // offset greater than the kTouchSlop. Too slow or too short a distance, and + // it won't trigger. Also, it must not be so much that it drags the bottom + // sheet off the screen, or we won't see it after we pump! + await tester.fling(find.text('BottomSheet'), const Offset(0.0, 50.0), 2000.0); + await tester.pump(); // drain the microtask queue (Future completion callback) + + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.pump(); // bottom sheet dismiss animation starts + + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); // animation done + + expect(showBottomSheetThenCalled, isTrue); + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('Verify that dragging past the bottom dismisses a persistent BottomSheet', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/5528 + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return Container(margin: const EdgeInsets.all(40.0), child: const Text('BottomSheet')); + }); + + await tester.pump(); // bottom sheet show animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.fling(find.text('BottomSheet'), const Offset(0.0, 400.0), 1000.0); + await tester.pump(); // drain the microtask queue (Future completion callback) + await tester.pump(); // bottom sheet dismiss animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + + expect(find.text('BottomSheet'), findsNothing); + }); + + testWidgets('modal BottomSheet has no top MediaQuery', (WidgetTester tester) async { + late BuildContext outerContext; + late BuildContext innerContext; + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(50.0), size: Size(400.0, 600.0)), + child: Navigator( + onGenerateRoute: (_) { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + outerContext = context; + return Container(); + }, + ); + }, + ), + ), + ), + ), + ); + + showModalBottomSheet<void>( + context: outerContext, + builder: (BuildContext context) { + innerContext = context; + return Container(); + }, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(MediaQuery.of(outerContext).padding, const EdgeInsets.all(50.0)); + expect( + MediaQuery.of(innerContext).padding, + const EdgeInsets.only(left: 50.0, right: 50.0, bottom: 50.0), + ); + }); + + testWidgets('modal BottomSheet can insert a SafeArea', (WidgetTester tester) async { + late BuildContext outerContext; + late BuildContext innerContext; + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(50.0), size: Size(400.0, 600.0)), + child: Navigator( + onGenerateRoute: (_) { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + outerContext = context; + return Container(); + }, + ); + }, + ), + ), + ), + ), + ); + + // Without a SafeArea (useSafeArea is false by default) + showModalBottomSheet<void>( + context: outerContext, + builder: (BuildContext context) { + innerContext = context; + return Container(); + }, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Top padding is consumed and there is no SafeArea + expect(MediaQuery.of(innerContext).padding.top, 0); + expect(find.byType(SafeArea), findsNothing); + + // With a SafeArea + showModalBottomSheet<void>( + context: outerContext, + useSafeArea: true, + builder: (BuildContext context) { + innerContext = context; + return Container(); + }, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // A SafeArea is inserted, with left / top / right true but bottom false. + final Finder safeAreaWidgetFinder = find.byType(SafeArea); + expect(safeAreaWidgetFinder, findsOneWidget); + final safeAreaWidget = safeAreaWidgetFinder.evaluate().single.widget as SafeArea; + expect(safeAreaWidget.left, true); + expect(safeAreaWidget.top, true); + expect(safeAreaWidget.right, true); + expect(safeAreaWidget.bottom, false); + + // Because that SafeArea is inserted, no left / top / right padding remains + // for `builder` to consume. Bottom padding does remain. + expect(MediaQuery.of(innerContext).padding, const EdgeInsets.fromLTRB(0, 0, 0, 50.0)); + }); + + testWidgets('modal BottomSheet has semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + + await tester.pump(); // bottom sheet show animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: <TestSemantics>[ + TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Scrim', + hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'), + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ignoreId: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('Verify that visual properties are passed through', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const Color color = Colors.pink; + const elevation = 9.0; + const ShapeBorder shape = BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ); + const Clip clipBehavior = Clip.antiAlias; + const Color barrierColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + backgroundColor: color, + barrierColor: barrierColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final BottomSheet bottomSheet = tester.widget(find.byType(BottomSheet)); + expect(bottomSheet.backgroundColor, color); + expect(bottomSheet.elevation, elevation); + expect(bottomSheet.shape, shape); + expect(bottomSheet.clipBehavior, clipBehavior); + + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect(modalBarrier.color, barrierColor); + }); + + testWidgets('Material3 - BottomSheet uses fallback values', (WidgetTester tester) async { + const Color surfaceColor = Colors.pink; + const Color surfaceTintColor = Colors.blue; + const ShapeBorder defaultShape = RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28.0)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: const ColorScheme.light( + surface: surfaceColor, + surfaceTint: surfaceTintColor, + ), + ), + home: Scaffold( + body: BottomSheet( + onClosing: () {}, + builder: (BuildContext context) { + return Container(); + }, + ), + ), + ), + ); + + final Finder finder = find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(Material), + ); + final Material material = tester.widget<Material>(finder); + + expect(material.color, surfaceColor); + // Surface tint is no longer used by default. + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 1.0); + expect(material.shape, defaultShape); + expect(tester.getSize(finder).width, 640); + }); + + testWidgets('Material3 - BottomSheet has transparent shadow', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BottomSheet( + onClosing: () {}, + builder: (BuildContext context) { + return Container(); + }, + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.shadowColor, Colors.transparent); + }); + + testWidgets('Material2 - Modal BottomSheet with ScrollController has semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + builder: (BuildContext context) { + return DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return SingleChildScrollView(controller: controller, child: const Text('BottomSheet')); + }, + ); + }, + ); + + await tester.pump(); // bottom sheet show animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + label: 'BottomSheet', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Scrim', + hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'), + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ignoreId: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('Material3 - Modal BottomSheet with ScrollController has semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + builder: (BuildContext context) { + return DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return SingleChildScrollView(controller: controller, child: const Text('BottomSheet')); + }, + ); + }, + ); + + await tester.pump(); // bottom sheet show animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + label: 'BottomSheet', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Scrim', + hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'), + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ignoreId: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('Material3 - Modal BottomSheet with drag handle has semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + showDragHandle: true, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + + await tester.pump(); // bottom sheet show animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Dialog', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isButton], + actions: <SemanticsAction>[SemanticsAction.tap], + label: 'Dismiss', + textDirection: TextDirection.ltr, + ), + TestSemantics(label: 'BottomSheet', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Scrim', + hintOverrides: const SemanticsHintOverrides(onTapHint: 'Close Bottom Sheet'), + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ignoreId: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('Drag handle color can take WidgetStateProperty', (WidgetTester tester) async { + const Color defaultColor = Colors.blue; + const Color hoveringColor = Colors.green; + + Future<void> checkDragHandleAndColors() async { + await tester.pump(); // bottom sheet show animation starts + await tester.pump(const Duration(seconds: 1)); // animation done + + final Finder dragHandle = find.bySemanticsLabel('Dismiss'); + expect(tester.getSize(dragHandle), const Size(48, 48)); + final Offset center = tester.getCenter(dragHandle); + final Offset edge = tester.getTopLeft(dragHandle) - const Offset(1, 1); + + // Shows default drag handle color + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: edge); + await tester.pump(); + var boxDecoration = + tester + .widget<Container>( + find.descendant( + of: dragHandle, + matching: find.byWidgetPredicate( + (Widget widget) => widget is Container && widget.decoration != null, + ), + ), + ) + .decoration! + as BoxDecoration; + expect(boxDecoration.color, defaultColor); + + // Shows hovering drag handle color + await gesture.moveTo(center); + await tester.pump(); + boxDecoration = + tester + .widget<Container>( + find.descendant( + of: dragHandle, + matching: find.byWidgetPredicate( + (Widget widget) => widget is Container && widget.decoration != null, + ), + ), + ) + .decoration! + as BoxDecoration; + + expect(boxDecoration.color, hoveringColor); + await gesture.removePointer(); + } + + Widget buildScaffold(GlobalKey scaffoldKey) { + return MaterialApp( + theme: ThemeData( + bottomSheetTheme: BottomSheetThemeData( + dragHandleColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveringColor; + } + return defaultColor; + }), + ), + ), + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ); + } + + var scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget(buildScaffold(scaffoldKey)); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + showDragHandle: true, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + + await checkDragHandleAndColors(); + + await tester.pumpWidget(Container()); // Reset + scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget(buildScaffold(scaffoldKey)); + + scaffoldKey.currentState!.showBottomSheet((_) { + return Builder( + builder: (BuildContext context) { + return const SizedBox(height: 200.0, child: Text('Bottom Sheet')); + }, + ); + }, showDragHandle: true); + + await checkDragHandleAndColors(); + }); + + testWidgets('Drag handle interactive area size at minimum possible size', ( + WidgetTester tester, + ) async { + Widget buildScaffold(GlobalKey scaffoldKey, {Size? dragHandleSize}) { + return MaterialApp( + theme: ThemeData(bottomSheetTheme: BottomSheetThemeData(dragHandleSize: dragHandleSize)), + home: Scaffold(key: scaffoldKey), + ); + } + + const smallerDragHandleSize = Size(20, 20); + + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget(buildScaffold(scaffoldKey, dragHandleSize: smallerDragHandleSize)); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + showDragHandle: true, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + + await tester.pump(); // Bottom sheet show animation starts. + await tester.pump(const Duration(seconds: 1)); // Animation done. + + final Finder dragHandle = find.bySemanticsLabel('Dismiss'); + expect( + tester.getSize(dragHandle), + const Size(kMinInteractiveDimension, kMinInteractiveDimension), + ); + }); + + testWidgets('Drag handle interactive area size at given dragHandleSize', ( + WidgetTester tester, + ) async { + Widget buildScaffold(GlobalKey scaffoldKey, {Size? dragHandleSize}) { + return MaterialApp( + theme: ThemeData(bottomSheetTheme: BottomSheetThemeData(dragHandleSize: dragHandleSize)), + home: Scaffold(key: scaffoldKey), + ); + } + + const extendedDragHandleSize = Size(100, 50); + + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget(buildScaffold(scaffoldKey, dragHandleSize: extendedDragHandleSize)); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + showDragHandle: true, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + + await tester.pump(); // Bottom sheet show animation starts. + await tester.pump(const Duration(seconds: 1)); // Animation done. + + final Finder dragHandle = find.bySemanticsLabel('Dismiss'); + expect(tester.getSize(dragHandle), extendedDragHandleSize); + }); + + testWidgets('showModalBottomSheet does not use root Navigator by default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Navigator( + onGenerateRoute: (RouteSettings settings) => MaterialPageRoute<void>( + builder: (_) { + return const _TestPage(); + }, + ), + ), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'Item 1'), + BottomNavigationBarItem(icon: Icon(Icons.style), label: 'Item 2'), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Show bottom sheet')); + await tester.pumpAndSettle(); + + // Bottom sheet is displayed in correct position within the inner navigator + // and above the BottomNavigationBar. + final double tabBarHeight = tester.getSize(find.byType(BottomNavigationBar)).height; + expect(tester.getBottomLeft(find.byType(BottomSheet)).dy, 600 - tabBarHeight); + }); + + testWidgets('showModalBottomSheet uses root Navigator when specified', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Navigator( + onGenerateRoute: (RouteSettings settings) => MaterialPageRoute<void>( + builder: (_) { + return const _TestPage(useRootNavigator: true); + }, + ), + ), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.ac_unit), label: 'Item 1'), + BottomNavigationBarItem(icon: Icon(Icons.style), label: 'Item 2'), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Show bottom sheet')); + await tester.pumpAndSettle(); + + // Bottom sheet is displayed in correct position above all content including + // the BottomNavigationBar. + expect(tester.getBottomLeft(find.byType(BottomSheet)).dy, 600.0); + }); + + testWidgets('Verify that route settings can be set in the showModalBottomSheet', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const routeSettings = RouteSettings(name: 'route_name', arguments: 'route_argument'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + late RouteSettings retrievedRouteSettings; + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + routeSettings: routeSettings, + builder: (BuildContext context) { + retrievedRouteSettings = ModalRoute.settingsOf(context)!; + return const Text('BottomSheet'); + }, + ); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(retrievedRouteSettings, routeSettings); + }); + + testWidgets('Verify showModalBottomSheet use AnimationController if provided.', ( + WidgetTester tester, + ) async { + const tapTarget = Key('tap-target'); + final controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 2), + reverseDuration: const Duration(seconds: 2), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + showModalBottomSheet<void>( + context: context, + // The default duration and reverseDuration is 1 second + transitionAnimationController: controller, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + expect(find.text('BottomSheet'), findsNothing); + + await tester.tap(find.byKey(tapTarget)); // Opening animation will start after tapping + await tester.pump(); + + expect(find.text('BottomSheet'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + expect(find.text('BottomSheet'), findsOneWidget); + + // Tapping above the bottom sheet to dismiss it. + await tester.tapAt(const Offset(20.0, 20.0)); // Closing animation will start after tapping + await tester.pump(); + + expect(find.text('BottomSheet'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + // The bottom sheet should still be present at the very end of the animation. + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1)); + // The bottom sheet should not be showing any longer. + expect(find.text('BottomSheet'), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/87592 + testWidgets('the framework do not dispose the transitionAnimationController provided by user.', ( + WidgetTester tester, + ) async { + const tapTarget = Key('tap-target'); + final controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 2), + reverseDuration: const Duration(seconds: 2), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + showModalBottomSheet<void>( + context: context, + // The default duration and reverseDuration is 1 second + transitionAnimationController: controller, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + expect(find.text('BottomSheet'), findsNothing); + + await tester.tap(find.byKey(tapTarget)); // Opening animation will start after tapping + await tester.pump(); + + expect(find.text('BottomSheet'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + expect(find.text('BottomSheet'), findsOneWidget); + + // Tapping above the bottom sheet to dismiss it. + await tester.tapAt(const Offset(20.0, 20.0)); // Closing animation will start after tapping + await tester.pump(); + + expect(find.text('BottomSheet'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + // The bottom sheet should still be present at the very end of the animation. + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1)); + // The bottom sheet should not be showing any longer. + expect(find.text('BottomSheet'), findsNothing); + + controller.dispose(); + // Double disposal will throw. + expect(tester.takeException(), isNull); + }); + + testWidgets('Verify persistence BottomSheet use AnimationController if provided.', ( + WidgetTester tester, + ) async { + const tapTarget = Key('tap-target'); + const tapTargetToClose = Key('tap-target-to-close'); + final controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 2), + reverseDuration: const Duration(seconds: 2), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + showBottomSheet( + context: context, + // The default duration and reverseDuration is 1 second + transitionAnimationController: controller, + builder: (BuildContext context) { + return ElevatedButton( + key: tapTargetToClose, + onPressed: () => Navigator.pop(context), + child: const Text('BottomSheet'), + ); + }, + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + expect(find.text('BottomSheet'), findsNothing); + + await tester.tap(find.byKey(tapTarget)); // Opening animation will start after tapping + await tester.pump(); + + expect(find.text('BottomSheet'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + expect(find.text('BottomSheet'), findsOneWidget); + + // Tapping button on the bottom sheet to dismiss it. + await tester.tap(find.byKey(tapTargetToClose)); // Closing animation will start after tapping + await tester.pump(); + + expect(find.text('BottomSheet'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 2000)); + // The bottom sheet should still be present at the very end of the animation. + expect(find.text('BottomSheet'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1)); + // The bottom sheet should not be showing any longer. + expect(find.text('BottomSheet'), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/87708 + testWidgets('Each of the internal animation controllers should be disposed by the framework.', ( + WidgetTester tester, + ) async { + final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((_) { + return Builder( + builder: (BuildContext context) { + return Container(height: 200.0); + }, + ); + }); + + await tester.pump(); + expect(find.byType(BottomSheet), findsOneWidget); + + // The first sheet's animation is still running. + + // Trigger the second sheet will remove the first sheet from tree. + scaffoldKey.currentState!.showBottomSheet((_) { + return Builder( + builder: (BuildContext context) { + return Container(height: 200.0); + }, + ); + }); + await tester.pump(); + expect(find.byType(BottomSheet), findsOneWidget); + + // Remove the Scaffold from the tree. + await tester.pumpWidget(const SizedBox.shrink()); + + // If the internal animation controller do not dispose will throw + // FlutterError:<ScaffoldState#1981a(tickers: tracking 1 ticker) was disposed with an active + // Ticker. + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/99627 + testWidgets('The old route entry should be removed when a new sheet popup', ( + WidgetTester tester, + ) async { + final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey(); + PersistentBottomSheetController? sheetController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + final ModalRoute<dynamic> route = ModalRoute.of(scaffoldKey.currentContext!)!; + expect(route.canPop, false); + + scaffoldKey.currentState!.showBottomSheet((_) { + return Builder( + builder: (BuildContext context) { + return Container(height: 200.0); + }, + ); + }); + + await tester.pump(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(route.canPop, true); + + // Trigger the second sheet will remove the first sheet from tree. + sheetController = scaffoldKey.currentState!.showBottomSheet((_) { + return Builder( + builder: (BuildContext context) { + return Container(height: 200.0); + }, + ); + }); + await tester.pump(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(route.canPop, true); + + sheetController.close(); + + expect(route.canPop, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/87708 + testWidgets( + 'The framework does not dispose of the transitionAnimationController provided by user.', + (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + const tapTargetToClose = Key('tap-target-to-close'); + final controller = AnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 2), + reverseDuration: const Duration(seconds: 2), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + showBottomSheet( + context: context, + transitionAnimationController: controller, + builder: (BuildContext context) { + return ElevatedButton( + key: tapTargetToClose, + onPressed: () => Navigator.pop(context), + child: const Text('BottomSheet'), + ); + }, + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + expect(find.text('BottomSheet'), findsNothing); + + await tester.tap(find.byKey(tapTarget)); // Open the sheet. + await tester.pumpAndSettle(); // Finish the animation. + expect(find.text('BottomSheet'), findsOneWidget); + + // Tapping button on the bottom sheet to dismiss it. + await tester.tap(find.byKey(tapTargetToClose)); // Closing the sheet. + await tester.pumpAndSettle(); // Finish the animation. + expect(find.text('BottomSheet'), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + + // Double dispose will throw. + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'The framework removes all animation listeners from foreign controllers when disposing.', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final controller = _StatusTestAnimationController( + vsync: const TestVSync(), + duration: const Duration(seconds: 2), + reverseDuration: const Duration(seconds: 2), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + await tester.pump(); + expect(controller.isListening, isFalse); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return const SizedBox(height: 200.0, child: Text('BottomSheet')); + }, transitionAnimationController: controller); + + await tester.pumpAndSettle(); + expect(controller.isListening, isTrue); + expect(find.text('BottomSheet'), findsOneWidget); + + // Swipe the bottom sheet to dismiss it. + await tester.drag(find.text('BottomSheet'), const Offset(0.0, 150.0)); + await tester.pumpAndSettle(); // Bottom sheet dismiss animation. + expect(controller.isListening, isFalse); + expect(find.text('BottomSheet'), findsNothing); + }, + ); + + testWidgets( + 'Calling PersistentBottomSheetController.close does not crash when it is not the current bottom sheet', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/93717 + PersistentBottomSheetController? sheetController1; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return SafeArea( + child: Column( + children: <Widget>[ + ElevatedButton( + child: const Text('show 1'), + onPressed: () { + sheetController1 = Scaffold.of( + context, + ).showBottomSheet((BuildContext context) => const Text('BottomSheet 1')); + }, + ), + ElevatedButton( + child: const Text('show 2'), + onPressed: () { + Scaffold.of( + context, + ).showBottomSheet((BuildContext context) => const Text('BottomSheet 2')); + }, + ), + ElevatedButton( + child: const Text('close 1'), + onPressed: () { + sheetController1!.close(); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('show 1')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet 1'), findsOneWidget); + + await tester.tap(find.text('show 2')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet 2'), findsOneWidget); + + // This will throw an assertion if regressed + await tester.tap(find.text('close 1')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet 2'), findsOneWidget); + }, + ); + + testWidgets('ModalBottomSheetRoute shows BottomSheet correctly', (WidgetTester tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + expect(find.byType(BottomSheet), findsNothing); + + // Bring up bottom sheet. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + ModalBottomSheetRoute<void>( + isScrollControlled: false, + builder: (BuildContext context) => Container(), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + }); + + group('Modal BottomSheet avoids overlapping display features', () { + testWidgets('positioning using anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 410); + expect(tester.getBottomRight(find.byType(Placeholder)).dx, 800); + }); + + testWidgets('positioning using Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // This is RTL, so it should place the dialog on the right screen + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 410); + expect(tester.getBottomRight(find.byType(Placeholder)).dx, 800); + }); + + testWidgets('default positioning', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(Placeholder)).dx, 0.0); + expect(tester.getBottomRight(find.byType(Placeholder)).dx, 390.0); + }); + }); + + group('constraints', () { + testWidgets('Material3 - Default constraints are max width 640', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(1000, 1000)), + child: Scaffold( + body: Center(child: Text('body')), + bottomSheet: Placeholder(fallbackWidth: 800), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Placeholder)).width, 640); + }); + + testWidgets('Material2 - No constraints by default for bottomSheet property', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + // This test is specific to Material2 because Material3 sets constraints by default for BottomSheet. + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + body: Center(child: Text('body')), + bottomSheet: Text('BottomSheet'), + ), + ), + ); + expect(find.text('BottomSheet'), findsOneWidget); + expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(0, 586, 154, 600)); + }); + + testWidgets('No constraints by default for showBottomSheet', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + // This test is specific to Material2 because Material3 sets constraints by default for BottomSheet. + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + Scaffold.of( + context, + ).showBottomSheet((BuildContext context) => const Text('BottomSheet')); + }, + ), + ); + }, + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsNothing); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(0, 586, 154, 600)); + }); + + testWidgets('No constraints by default for showModalBottomSheet', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + // This test is specific to Material2 because Material3 sets constraints by default for BottomSheet. + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) => const Text('BottomSheet'), + ); + }, + ), + ); + }, + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsNothing); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(0, 586, 800, 600)); + }); + + testWidgets('Material3 - Theme constraints used for bottomSheet property', ( + WidgetTester tester, + ) async { + const sheetMaxWidth = 80.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomSheetTheme: const BottomSheetThemeData( + constraints: BoxConstraints(maxWidth: sheetMaxWidth), + ), + ), + home: Scaffold( + body: const Center(child: Text('body')), + bottomSheet: const Text('BottomSheet'), + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsOneWidget); + + // Should be centered and only 80dp wide. + final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet')); + expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2); + expect(bottomSheetRect.width, sheetMaxWidth); + + // Ensure the FAB is overlapping the top of the sheet. + expect(find.byIcon(Icons.add), findsOneWidget); + final Rect iconRect = tester.getRect(find.byIcon(Icons.add)); + expect(iconRect.top, bottomSheetRect.top - iconRect.height / 2); + }); + + testWidgets('Material2 - Theme constraints used for bottomSheet property', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + bottomSheetTheme: const BottomSheetThemeData(constraints: BoxConstraints(maxWidth: 80)), + ), + home: Scaffold( + body: const Center(child: Text('body')), + bottomSheet: const Text('BottomSheet'), + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsOneWidget); + // Should be centered and only 80dp wide + expect(tester.getRect(find.text('BottomSheet')), const Rect.fromLTRB(360, 558, 440, 600)); + // Ensure the FAB is overlapping the top of the sheet + expect(find.byIcon(Icons.add), findsOneWidget); + expect(tester.getRect(find.byIcon(Icons.add)), const Rect.fromLTRB(744, 544, 768, 568)); + }); + + testWidgets('Theme constraints used for showBottomSheet', (WidgetTester tester) async { + const sheetMaxWidth = 80.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomSheetTheme: const BottomSheetThemeData( + constraints: BoxConstraints(maxWidth: sheetMaxWidth), + ), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + Scaffold.of( + context, + ).showBottomSheet((BuildContext context) => const Text('BottomSheet')); + }, + ), + ); + }, + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsNothing); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + + // Should be centered and only 80dp wide. + final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet')); + expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2); + expect(bottomSheetRect.width, sheetMaxWidth); + }); + + testWidgets('Theme constraints used for showModalBottomSheet', (WidgetTester tester) async { + const sheetMaxWidth = 80.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomSheetTheme: const BottomSheetThemeData( + constraints: BoxConstraints(maxWidth: sheetMaxWidth), + ), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) => const Text('BottomSheet'), + ); + }, + ), + ); + }, + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsNothing); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + + // Should be centered and only 80dp wide. + final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet')); + expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2); + expect(bottomSheetRect.width, sheetMaxWidth); + }); + + testWidgets('constraints param overrides theme for showBottomSheet', ( + WidgetTester tester, + ) async { + const sheetMaxWidth = 100.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomSheetTheme: const BottomSheetThemeData(constraints: BoxConstraints(maxWidth: 80)), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + Scaffold.of(context).showBottomSheet( + (BuildContext context) => const Text('BottomSheet'), + constraints: const BoxConstraints(maxWidth: sheetMaxWidth), + ); + }, + ), + ); + }, + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsNothing); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + + // Should be centered and only 80dp wide. + final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet')); + expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2); + expect(bottomSheetRect.width, sheetMaxWidth); + }); + + testWidgets('constraints param overrides theme for showModalBottomSheet', ( + WidgetTester tester, + ) async { + const sheetMaxWidth = 100.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomSheetTheme: const BottomSheetThemeData(constraints: BoxConstraints(maxWidth: 80)), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) => const Text('BottomSheet'), + constraints: const BoxConstraints(maxWidth: sheetMaxWidth), + ); + }, + ), + ); + }, + ), + ), + ), + ); + expect(find.text('BottomSheet'), findsNothing); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect(find.text('BottomSheet'), findsOneWidget); + + // Should be centered and only 80dp wide. + final Rect bottomSheetRect = tester.getRect(find.text('BottomSheet')); + expect(bottomSheetRect.left, 800 / 2 - sheetMaxWidth / 2); + expect(bottomSheetRect.width, sheetMaxWidth); + }); + + group('scrollControlDisabledMaxHeightRatio', () { + Future<void> test( + WidgetTester tester, + bool isScrollControlled, + double scrollControlDisabledMaxHeightRatio, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('Press me'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + builder: (BuildContext context) => + const SizedBox.expand(child: Text('BottomSheet')), + ); + }, + ), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Press me')); + await tester.pumpAndSettle(); + expect( + tester.getRect(find.text('BottomSheet')), + Rect.fromLTRB( + 80, + 600 * (isScrollControlled ? 0 : (1 - scrollControlDisabledMaxHeightRatio)), + 720, + 600, + ), + ); + } + + testWidgets('works at 9 / 16', (WidgetTester tester) { + return test(tester, false, 9.0 / 16.0); + }); + testWidgets('works at 8 / 16', (WidgetTester tester) { + return test(tester, false, 8.0 / 16.0); + }); + testWidgets('works at isScrollControlled', (WidgetTester tester) { + return test(tester, true, 8.0 / 16.0); + }); + }); + }); + + group('showModalBottomSheet modalBarrierDismissLabel', () { + testWidgets('Verify that modalBarrierDismissLabel is used if provided', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const customLabel = 'custom label'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + barrierLabel: 'custom label', + context: scaffoldKey.currentContext!, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect(modalBarrier.semanticsLabel, customLabel); + }); + + testWidgets( + 'Verify that modalBarrierDismissLabel from context is used if barrierLabel is not provided', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + showModalBottomSheet<void>( + context: scaffoldKey.currentContext!, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect( + modalBarrier.semanticsLabel, + MaterialLocalizations.of(scaffoldKey.currentContext!).scrimLabel, + ); + }, + ); + }); + + testWidgets('Bottom sheet animation can be customized', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + Widget buildWidget({AnimationStyle? sheetAnimationStyle}) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showBottomSheet( + context: context, + sheetAnimationStyle: sheetAnimationStyle, + builder: (BuildContext context) { + return SizedBox.expand( + child: ColoredBox( + key: sheetKey, + color: Theme.of(context).colorScheme.primary, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ), + ); + }, + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ); + } + + // Test custom animation style. + await tester.pumpWidget( + buildWidget( + sheetAnimationStyle: const AnimationStyle( + duration: Duration(milliseconds: 800), + reverseDuration: Duration(milliseconds: 400), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 400)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1)); + + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 400)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + // Advance the animation by 1/2 of the custom reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1)); + + // Advance the animation by 1/2 of the custom reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The bottom sheet is dismissed. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0)); + + // Test no animation style. + await tester.pumpWidget(buildWidget(sheetAnimationStyle: AnimationStyle.noAnimation)); + await tester.pumpAndSettle(); + await tester.tap(find.text('X')); + await tester.pump(); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + + // The bottom sheet is dismissed. + expect(find.byKey(sheetKey), findsNothing); + }); + + testWidgets('Modal bottom sheet default animation', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + // Test default modal bottom sheet animation. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return SizedBox.expand( + child: ColoredBox( + key: sheetKey, + color: Theme.of(context).colorScheme.primary, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ), + ); + }, + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + // Tap the 'X' to show the bottom sheet. + await tester.tap(find.text('X')); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The modal bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1)); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The modal bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + // Advance the animation by 1/2 of the default reverse duration. + await tester.pump(const Duration(milliseconds: 100)); + + // The modal bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1)); + + // Advance the animation by 1/2 of the default reverse duration. + await tester.pump(const Duration(milliseconds: 100)); + + // The modal bottom sheet is dismissed. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0)); + }); + + testWidgets('Modal bottom sheet animation can be customized', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + Widget buildWidget({AnimationStyle? sheetAnimationStyle}) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showModalBottomSheet<void>( + context: context, + sheetAnimationStyle: sheetAnimationStyle, + builder: (BuildContext context) { + return SizedBox.expand( + child: ColoredBox( + key: sheetKey, + color: Theme.of(context).colorScheme.primary, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ), + ); + }, + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ); + } + + // Test custom animation style. + await tester.pumpWidget( + buildWidget( + sheetAnimationStyle: const AnimationStyle( + duration: Duration(milliseconds: 800), + reverseDuration: Duration(milliseconds: 400), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 400)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1)); + + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 400)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + // Advance the animation by 1/2 of the custom reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(316.7, 0.1)); + + // Advance the animation by 1/2 of the custom reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The bottom sheet is dismissed. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0)); + + // Test no animation style. + await tester.pumpWidget(buildWidget(sheetAnimationStyle: AnimationStyle.noAnimation)); + await tester.pumpAndSettle(); + await tester.tap(find.text('X')); + await tester.pump(); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(262.5)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + + // The bottom sheet is dismissed. + expect(find.byKey(sheetKey), findsNothing); + }); + + testWidgets( + 'Setting ModalBottomSheetRoute.requestFocus to false does not request focus on the bottom sheet', + (WidgetTester tester) async { + late BuildContext savedContext; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + savedContext = context; + return TextField(focusNode: focusNode); + }, + ), + ), + ), + ); + await tester.pump(); + + FocusNode? getTextFieldFocusNode() { + return tester + .widget<Focus>( + find.descendant(of: find.byType(TextField), matching: find.byType(Focus)), + ) + .focusNode; + } + + // Initially, there is no bottom sheet and the text field has no focus. + expect(find.byType(BottomSheet), findsNothing); + expect(getTextFieldFocusNode()?.hasFocus, false); + + // Request focus on the text field. + focusNode.requestFocus(); + await tester.pump(); + expect(getTextFieldFocusNode()?.hasFocus, true); + + // Bring up bottom sheet. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + ModalBottomSheetRoute<void>( + isScrollControlled: false, + builder: (BuildContext context) => Container(), + ), + ); + await tester.pump(); + + // The bottom sheet is showing and the text field has lost focus. + expect(find.byType(BottomSheet), findsOneWidget); + expect(getTextFieldFocusNode()?.hasFocus, false); + + // Dismiss the bottom sheet. + navigator.pop(); + await tester.pump(); + + // The bottom sheet is dismissed and the focus is shifted back to the text field. + expect(find.byType(BottomSheet), findsNothing); + expect(getTextFieldFocusNode()?.hasFocus, true); + + // Bring up bottom sheet again with requestFocus to false. + navigator.push( + ModalBottomSheetRoute<void>( + requestFocus: false, + isScrollControlled: false, + builder: (BuildContext context) => Container(), + ), + ); + await tester.pump(); + + // The bottom sheet is showing and the text field still has focus. + expect(find.byType(BottomSheet), findsOneWidget); + expect(getTextFieldFocusNode()?.hasFocus, true); + }, + ); + + testWidgets('requestFocus works correctly in showModalBottomSheet.', (WidgetTester tester) async { + final navigatorKey = GlobalKey<NavigatorState>(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: Scaffold(body: TextField(focusNode: focusNode)), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + + showModalBottomSheet<void>( + context: navigatorKey.currentContext!, + requestFocus: true, + builder: (BuildContext context) => const Text('BottomSheet'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('BottomSheet'))).hasFocus, true); + expect(focusNode.hasFocus, false); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + + showModalBottomSheet<void>( + context: navigatorKey.currentContext!, + requestFocus: false, + builder: (BuildContext context) => const Text('BottomSheet'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('BottomSheet'))).hasFocus, false); + expect(focusNode.hasFocus, true); + }); + + testWidgets('BottomSheet does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: BottomSheet( + onClosing: () {}, + builder: (BuildContext context) => const Text('X'), + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(BottomSheet)), Size.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177004 + testWidgets('ModalBottomSheet semantics for mismatched platforms', (WidgetTester tester) async { + const localizations = DefaultMaterialLocalizations(); + + Future<void> pumpModalBottomSheetWithTheme(TargetPlatform themePlatform) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: themePlatform), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return OutlinedButton( + onPressed: () { + showModalBottomSheet<void>( + context: context, + showDragHandle: true, + builder: (BuildContext context) { + return const Text('BottomSheet'); + }, + ); + }, + child: const Text('open'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final Finder popupFinder = find.bySemanticsLabel(localizations.dialogLabel); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(popupFinder, findsNothing); // Apple platforms don't show label. + case _: + expect(popupFinder, findsOneWidget); // Non-Apple platforms show label. + } + } + + // Test with theme.platform = Android on different real platforms. + await pumpModalBottomSheetWithTheme(TargetPlatform.android); + + // Dismiss the first bottom sheet. + Navigator.of(tester.element(find.text('BottomSheet'))).pop(); + await tester.pumpAndSettle(); + + // Test with theme.platform = iOS on different real platforms. + await pumpModalBottomSheetWithTheme(TargetPlatform.iOS); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Modal bottom sheet has hitTestBehavior.opaque to prevent dismissal on empty areas', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + + await tester.pump(); + + showModalBottomSheet<void>( + context: savedContext, + builder: (BuildContext context) => Container( + height: 200, + color: Colors.blue, + child: const Center(child: Text('Modal Bottom Sheet')), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Modal Bottom Sheet'), findsOneWidget); + + // Verify the route-level Semantics has opaque hitTestBehavior + // This prevents clicks inside the bottom sheet from passing through to the barrier + final List<Semantics> allSemantics = tester + .widgetList<Semantics>( + find.ancestor(of: find.text('Modal Bottom Sheet'), matching: find.byType(Semantics)), + ) + .toList(); + + final Semantics routeSemantics = allSemantics.firstWhere( + (Semantics s) => s.properties.hitTestBehavior == SemanticsHitTestBehavior.opaque, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + final Semantics widgetSemantics = allSemantics.firstWhere( + (Semantics s) => s.properties.scopesRoute ?? false, + ); + + expect(widgetSemantics.properties.scopesRoute, true); + + semantics.dispose(); + }); + + testWidgets('ModalBottomSheet uses AnimationStyle curve', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showModalBottomSheet<void>( + context: context, + sheetAnimationStyle: const AnimationStyle(curve: Curves.linear), + builder: (BuildContext context) { + return SizedBox.expand( + key: sheetKey, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ); + }, + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); + + // Advance the animation by 50ms. + await tester.pump(const Duration(milliseconds: 50)); + final double openExtent1 = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // Advance the animation by an additional 50ms. + await tester.pump(const Duration(milliseconds: 50)); + final double openExtent2 = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // Advance the animation by an additional 50ms. + await tester.pump(const Duration(milliseconds: 50)); + final double openExtent3 = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // For the linear curve, the distance covered in each time interval should + // be the same. + expect(openExtent1 - openExtent2, closeTo(openExtent2 - openExtent3, 0.1)); + await tester.pumpAndSettle(); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pumpAndSettle(); + }); + + testWidgets('ModalBottomSheet uses AnimationStyle reverseCurve', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showModalBottomSheet<void>( + context: context, + sheetAnimationStyle: const AnimationStyle(reverseCurve: Curves.linear), + builder: (BuildContext context) { + return SizedBox.expand( + key: sheetKey, + child: FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ); + }, + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Start the dismissal. + await tester.tap(find.text('Close')); + await tester.pump(); + + // Advance the animation by 50ms during the close transition. + await tester.pump(const Duration(milliseconds: 50)); + final double closeExtent1 = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // Advance the animation by an additional 50ms. + await tester.pump(const Duration(milliseconds: 50)); + final double closeExtent2 = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // Advance the animation by an additional 50ms. + await tester.pump(const Duration(milliseconds: 50)); + final double closeExtent3 = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // For the linear curve, the distance covered in each time interval should + // be the same. + expect(closeExtent2 - closeExtent1, closeTo(closeExtent3 - closeExtent2, 0.1)); + + await tester.pumpAndSettle(); + }); + + testWidgets('ModalBottomSheet with AnimationStyle.noAnimation opens and closes immediately', ( + WidgetTester tester, + ) async { + final Key sheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showModalBottomSheet<void>( + context: context, + sheetAnimationStyle: AnimationStyle.noAnimation, + builder: (BuildContext context) { + return SizedBox( + key: sheetKey, + child: FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ); + }, + ); + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pump(); + + expect(find.byKey(sheetKey), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pump(); + + expect(find.byKey(sheetKey), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/183299. + testWidgets('ModalBottomSheet does not jump when drag gesture ends', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showModalBottomSheet<void>( + context: context, + showDragHandle: true, + builder: (context) => SizedBox.expand(key: sheetKey, child: const SizedBox()), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final Finder dragHandle = find.bySemanticsLabel('Dismiss'); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(dragHandle)); + + await gesture.moveBy(const Offset(0, 50)); + await tester.pump(); + + await gesture.moveBy(const Offset(0, 50)); + await tester.pump(); + + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + final double yBeforeUp = tester.getTopLeft(find.byKey(sheetKey)).dy; + + await gesture.up(); + await tester.pump(); + + final double yAfterUp = tester.getTopLeft(find.byKey(sheetKey)).dy; + + // The bottom sheet should not jump when the drag gesture ends. + // Its position immediately after releasing the gesture should remain + // approximately the same as the last dragged position, ensuring the + // animation continues from the current visual offset. + expect(yAfterUp, closeTo(yBeforeUp, 0.1)); + }); +} + +class _TestPage extends StatelessWidget { + const _TestPage({this.useRootNavigator}); + + final bool? useRootNavigator; + + @override + Widget build(BuildContext context) { + return Center( + child: TextButton( + child: const Text('Show bottom sheet'), + onPressed: () { + if (useRootNavigator != null) { + showModalBottomSheet<void>( + useRootNavigator: useRootNavigator!, + context: context, + builder: (_) => const Text('Modal bottom sheet'), + ); + } else { + showModalBottomSheet<void>( + context: context, + builder: (_) => const Text('Modal bottom sheet'), + ); + } + }, + ), + ); + } +} + +class _StatusTestAnimationController extends AnimationController with AnimationLazyListenerMixin { + _StatusTestAnimationController({super.duration, super.reverseDuration, required super.vsync}); + + @override + void didStartListening() {} + + @override + void didStopListening() {} +} diff --git a/packages/material_ui/test/material/bottom_sheet_theme_test.dart b/packages/material_ui/test/material/bottom_sheet_theme_test.dart new file mode 100644 index 000000000000..da7ef706185f --- /dev/null +++ b/packages/material_ui/test/material/bottom_sheet_theme_test.dart @@ -0,0 +1,422 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('BottomSheetThemeData copyWith, ==, hashCode basics', () { + expect(const BottomSheetThemeData(), const BottomSheetThemeData().copyWith()); + expect(const BottomSheetThemeData().hashCode, const BottomSheetThemeData().copyWith().hashCode); + }); + + test('BottomSheetThemeData lerp special cases', () { + expect(BottomSheetThemeData.lerp(null, null, 0), null); + const data = BottomSheetThemeData(); + expect(identical(BottomSheetThemeData.lerp(data, data, 0.5), data), true); + }); + + test('BottomSheetThemeData lerp special cases', () { + expect(BottomSheetThemeData.lerp(null, null, 0), null); + const data = BottomSheetThemeData(); + expect(identical(BottomSheetThemeData.lerp(data, data, 0.5), data), true); + }); + + test('BottomSheetThemeData null fields by default', () { + const bottomSheetTheme = BottomSheetThemeData(); + expect(bottomSheetTheme.backgroundColor, null); + expect(bottomSheetTheme.shadowColor, null); + expect(bottomSheetTheme.elevation, null); + expect(bottomSheetTheme.shape, null); + expect(bottomSheetTheme.clipBehavior, null); + expect(bottomSheetTheme.constraints, null); + expect(bottomSheetTheme.dragHandleColor, null); + expect(bottomSheetTheme.dragHandleSize, null); + }); + + testWidgets('Default BottomSheetThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const BottomSheetThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('BottomSheetThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const BottomSheetThemeData( + backgroundColor: Color(0xFFFFFFFF), + elevation: 2.0, + shadowColor: Color(0xFF00FFFF), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + clipBehavior: Clip.antiAlias, + constraints: BoxConstraints(minWidth: 200, maxWidth: 640), + dragHandleColor: Color(0xFFFFFFFF), + dragHandleSize: Size(20, 20), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'backgroundColor: ${const Color(0xffffffff)}', + 'elevation: 2.0', + 'shadowColor: ${const Color(0xff00ffff)}', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(2.0))', + 'dragHandleColor: ${const Color(0xffffffff)}', + 'dragHandleSize: Size(20.0, 20.0)', + 'clipBehavior: Clip.antiAlias', + 'constraints: BoxConstraints(200.0<=w<=640.0, 0.0<=h<=Infinity)', + ]); + }); + + testWidgets('Material3 - Passing no BottomSheetThemeData returns defaults', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BottomSheet( + onClosing: () {}, + builder: (BuildContext context) { + return Container(); + }, + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + + final ThemeData theme = Theme.of(tester.element(find.byType(Scaffold))); + expect(material.color, theme.colorScheme.surfaceContainerLow); + expect(material.elevation, 1.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))), + ); + expect(material.clipBehavior, Clip.none); + }); + + testWidgets('Material2 - Passing no BottomSheetThemeData returns defaults', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: BottomSheet( + onClosing: () {}, + builder: (BuildContext context) { + return Container(); + }, + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.color, null); + expect(material.elevation, 0.0); + expect(material.shape, null); + expect(material.clipBehavior, Clip.none); + }); + + testWidgets('BottomSheet uses values from BottomSheetThemeData', (WidgetTester tester) async { + final BottomSheetThemeData bottomSheetTheme = _bottomSheetTheme(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomSheetTheme: bottomSheetTheme), + home: Scaffold( + body: BottomSheet( + onClosing: () {}, + builder: (BuildContext context) { + return Container(); + }, + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.color, bottomSheetTheme.backgroundColor); + expect(material.elevation, bottomSheetTheme.elevation); + expect(material.shape, bottomSheetTheme.shape); + expect(material.clipBehavior, bottomSheetTheme.clipBehavior); + }); + + testWidgets('BottomSheet widget properties take priority over theme', ( + WidgetTester tester, + ) async { + const Color backgroundColor = Colors.purple; + const Color shadowColor = Colors.blue; + const elevation = 7.0; + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + const Clip clipBehavior = Clip.hardEdge; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(bottomSheetTheme: _bottomSheetTheme()), + home: Scaffold( + body: BottomSheet( + backgroundColor: backgroundColor, + shadowColor: shadowColor, + elevation: elevation, + shape: shape, + clipBehavior: Clip.hardEdge, + onClosing: () {}, + builder: (BuildContext context) { + return Container(); + }, + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(material.clipBehavior, clipBehavior); + }); + + testWidgets('Modal bottom sheet-specific parameters are used for modal bottom sheets', ( + WidgetTester tester, + ) async { + const modalElevation = 5.0; + const persistentElevation = 7.0; + const Color modalBackgroundColor = Colors.yellow; + const Color modalBarrierColor = Colors.blue; + const Color persistentBackgroundColor = Colors.red; + const bottomSheetTheme = BottomSheetThemeData( + elevation: persistentElevation, + modalElevation: modalElevation, + backgroundColor: persistentBackgroundColor, + modalBackgroundColor: modalBackgroundColor, + modalBarrierColor: modalBarrierColor, + ); + + await tester.pumpWidget(bottomSheetWithElevations(bottomSheetTheme)); + await tester.tap(find.text('Show Modal')); + await tester.pumpAndSettle(); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.elevation, modalElevation); + expect(material.color, modalBackgroundColor); + + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect(modalBarrier.color, modalBarrierColor); + }); + + testWidgets( + 'General bottom sheet parameters take priority over modal bottom sheet-specific parameters for persistent bottom sheets', + (WidgetTester tester) async { + const modalElevation = 5.0; + const persistentElevation = 7.0; + const Color modalBackgroundColor = Colors.yellow; + const Color persistentBackgroundColor = Colors.red; + const bottomSheetTheme = BottomSheetThemeData( + elevation: persistentElevation, + modalElevation: modalElevation, + backgroundColor: persistentBackgroundColor, + modalBackgroundColor: modalBackgroundColor, + ); + + await tester.pumpWidget(bottomSheetWithElevations(bottomSheetTheme)); + await tester.tap(find.text('Show Persistent')); + await tester.pumpAndSettle(); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.elevation, persistentElevation); + expect(material.color, persistentBackgroundColor); + }, + ); + + testWidgets( + "Material3 - Modal bottom sheet-specific parameters don't apply to persistent bottom sheets", + (WidgetTester tester) async { + const modalElevation = 5.0; + const Color modalBackgroundColor = Colors.yellow; + const bottomSheetTheme = BottomSheetThemeData( + modalElevation: modalElevation, + modalBackgroundColor: modalBackgroundColor, + ); + + await tester.pumpWidget(bottomSheetWithElevations(bottomSheetTheme)); + await tester.tap(find.text('Show Persistent')); + await tester.pumpAndSettle(); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.elevation, 1.0); + final ThemeData theme = Theme.of(tester.element(find.byType(Scaffold))); + expect(material.color, theme.colorScheme.surfaceContainerLow); + }, + ); + + testWidgets( + "Material2 - Modal bottom sheet-specific parameters don't apply to persistent bottom sheets", + (WidgetTester tester) async { + const modalElevation = 5.0; + const Color modalBackgroundColor = Colors.yellow; + const bottomSheetTheme = BottomSheetThemeData( + modalElevation: modalElevation, + modalBackgroundColor: modalBackgroundColor, + ); + + await tester.pumpWidget(bottomSheetWithElevations(bottomSheetTheme, useMaterial3: false)); + await tester.tap(find.text('Show Persistent')); + await tester.pumpAndSettle(); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(material.elevation, 0); + expect(material.color, null); + }, + ); + + testWidgets('Modal bottom sheets respond to theme changes', (WidgetTester tester) async { + const lightElevation = 5.0; + const darkElevation = 3.0; + const Color lightBackgroundColor = Colors.green; + const Color darkBackgroundColor = Colors.grey; + const Color lightShadowColor = Colors.blue; + const Color darkShadowColor = Colors.purple; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + bottomSheetTheme: const BottomSheetThemeData( + elevation: lightElevation, + backgroundColor: lightBackgroundColor, + shadowColor: lightShadowColor, + ), + ), + darkTheme: ThemeData.dark().copyWith( + bottomSheetTheme: const BottomSheetThemeData( + elevation: darkElevation, + backgroundColor: darkBackgroundColor, + shadowColor: darkShadowColor, + ), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + RawMaterialButton( + child: const Text('Show Modal'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return const Text('This is a modal bottom sheet.'); + }, + ); + }, + ), + ], + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Show Modal')); + await tester.pumpAndSettle(); + + final Material lightMaterial = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(lightMaterial.elevation, lightElevation); + expect(lightMaterial.color, lightBackgroundColor); + expect(lightMaterial.shadowColor, lightShadowColor); + + // Simulate the user changing to dark theme + tester.binding.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pumpAndSettle(); + + final Material darkMaterial = tester.widget<Material>( + find.descendant(of: find.byType(BottomSheet), matching: find.byType(Material)), + ); + expect(darkMaterial.elevation, darkElevation); + expect(darkMaterial.color, darkBackgroundColor); + expect(darkMaterial.shadowColor, darkShadowColor); + }); +} + +Widget bottomSheetWithElevations( + BottomSheetThemeData bottomSheetTheme, { + bool useMaterial3 = true, +}) { + return MaterialApp( + theme: ThemeData(bottomSheetTheme: bottomSheetTheme, useMaterial3: useMaterial3), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + RawMaterialButton( + child: const Text('Show Modal'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext _) { + return const Text('This is a modal bottom sheet.'); + }, + ); + }, + ), + RawMaterialButton( + child: const Text('Show Persistent'), + onPressed: () { + showBottomSheet( + context: context, + builder: (BuildContext _) { + return const Text('This is a persistent bottom sheet.'); + }, + ); + }, + ), + ], + ); + }, + ), + ), + ); +} + +BottomSheetThemeData _bottomSheetTheme() { + return const BottomSheetThemeData( + backgroundColor: Colors.orange, + elevation: 12.0, + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + clipBehavior: Clip.antiAlias, + ); +} diff --git a/packages/material_ui/test/material/button_bar_test.dart b/packages/material_ui/test/material/button_bar_test.dart new file mode 100644 index 000000000000..0186bc76c289 --- /dev/null +++ b/packages/material_ui/test/material/button_bar_test.dart @@ -0,0 +1,635 @@ +// 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('ButtonBar default control smoketest', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality(textDirection: TextDirection.ltr, child: ButtonBar()), + ); + }); + + group('alignment', () { + testWidgets('default alignment is MainAxisAlignment.end', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: ButtonBar(children: <Widget>[SizedBox(width: 10.0, height: 10.0)])), + ); + + final Finder child = find.byType(SizedBox); + // Should be positioned to the right of the bar, + expect(tester.getRect(child).left, 782.0); // bar width - default padding - 10 + expect(tester.getRect(child).right, 792.0); // bar width - default padding + }); + + testWidgets('ButtonBarTheme.alignment overrides default', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: ButtonBarTheme( + data: ButtonBarThemeData(alignment: MainAxisAlignment.center), + child: ButtonBar(children: <Widget>[SizedBox(width: 10.0, height: 10.0)]), + ), + ), + ); + + final Finder child = find.byType(SizedBox); + // Should be positioned in the center + expect(tester.getRect(child).left, 395.0); // (bar width - padding) / 2 - 10 / 2 + expect(tester.getRect(child).right, 405.0); // (bar width - padding) / 2 - 10 / 2 + 10 + }); + + testWidgets('ButtonBar.alignment overrides ButtonBarTheme.alignment and default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: ButtonBarTheme( + data: ButtonBarThemeData(alignment: MainAxisAlignment.center), + child: ButtonBar( + alignment: MainAxisAlignment.start, + children: <Widget>[SizedBox(width: 10.0, height: 10.0)], + ), + ), + ), + ); + + final Finder child = find.byType(SizedBox); + // Should be positioned on the left + expect(tester.getRect(child).left, 8.0); // padding + expect(tester.getRect(child).right, 18.0); // padding + 10 + }); + }); + + group('mainAxisSize', () { + testWidgets('Default mainAxisSize is MainAxisSize.max', (WidgetTester tester) async { + const buttonBarKey = Key('row'); + const child0Key = Key('child0'); + const child1Key = Key('child1'); + const child2Key = Key('child2'); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: ButtonBar( + key: buttonBarKey, + // buttonPadding set to zero to simplify test calculations. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: child0Key, width: 100.0, height: 100.0), + SizedBox(key: child1Key, width: 100.0, height: 100.0), + SizedBox(key: child2Key, width: 100.0, height: 100.0), + ], + ), + ), + ), + ); + + // ButtonBar should take up all the space it is provided by its parent. + final Rect buttonBarRect = tester.getRect(find.byKey(buttonBarKey)); + expect(buttonBarRect.size.width, equals(800.0)); + expect(buttonBarRect.size.height, equals(100.0)); + + // The children of [ButtonBar] are aligned by [MainAxisAlignment.end] by + // default. + Rect childRect; + childRect = tester.getRect(find.byKey(child0Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + expect(childRect.right, 800.0 - 200.0); + + childRect = tester.getRect(find.byKey(child1Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + expect(childRect.right, 800.0 - 100.0); + + childRect = tester.getRect(find.byKey(child2Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + expect(childRect.right, 800.0); + }); + + testWidgets('ButtonBarTheme.mainAxisSize overrides default', (WidgetTester tester) async { + const buttonBarKey = Key('row'); + const child0Key = Key('child0'); + const child1Key = Key('child1'); + const child2Key = Key('child2'); + await tester.pumpWidget( + const MaterialApp( + home: ButtonBarTheme( + data: ButtonBarThemeData(mainAxisSize: MainAxisSize.min), + child: Center( + child: ButtonBar( + key: buttonBarKey, + // buttonPadding set to zero to simplify test calculations. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: child0Key, width: 100.0, height: 100.0), + SizedBox(key: child1Key, width: 100.0, height: 100.0), + SizedBox(key: child2Key, width: 100.0, height: 100.0), + ], + ), + ), + ), + ), + ); + + // ButtonBar should take up minimum space it requires. + final Rect buttonBarRect = tester.getRect(find.byKey(buttonBarKey)); + expect(buttonBarRect.size.width, equals(300.0)); + expect(buttonBarRect.size.height, equals(100.0)); + + Rect childRect; + childRect = tester.getRect(find.byKey(child0Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + // Should be a center aligned because of [Center] widget. + // First child is on the left side of the button bar. + expect(childRect.left, (800.0 - buttonBarRect.width) / 2.0); + + childRect = tester.getRect(find.byKey(child1Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + // Should be a center aligned because of [Center] widget. + // Second child is on the center the button bar. + expect(childRect.left, ((800.0 - buttonBarRect.width) / 2.0) + 100.0); + + childRect = tester.getRect(find.byKey(child2Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + // Should be a center aligned because of [Center] widget. + // Third child is on the right side of the button bar. + expect(childRect.left, ((800.0 - buttonBarRect.width) / 2.0) + 200.0); + }); + + testWidgets('ButtonBar.mainAxisSize overrides ButtonBarTheme.mainAxisSize and default', ( + WidgetTester tester, + ) async { + const buttonBarKey = Key('row'); + const child0Key = Key('child0'); + const child1Key = Key('child1'); + const child2Key = Key('child2'); + await tester.pumpWidget( + const MaterialApp( + home: ButtonBarTheme( + data: ButtonBarThemeData(mainAxisSize: MainAxisSize.min), + child: Center( + child: ButtonBar( + key: buttonBarKey, + // buttonPadding set to zero to simplify test calculations. + buttonPadding: EdgeInsets.zero, + mainAxisSize: MainAxisSize.max, + children: <Widget>[ + SizedBox(key: child0Key, width: 100.0, height: 100.0), + SizedBox(key: child1Key, width: 100.0, height: 100.0), + SizedBox(key: child2Key, width: 100.0, height: 100.0), + ], + ), + ), + ), + ), + ); + + // ButtonBar should take up all the space it is provided by its parent. + final Rect buttonBarRect = tester.getRect(find.byKey(buttonBarKey)); + expect(buttonBarRect.size.width, equals(800.0)); + expect(buttonBarRect.size.height, equals(100.0)); + + // The children of [ButtonBar] are aligned by [MainAxisAlignment.end] by + // default. + Rect childRect; + childRect = tester.getRect(find.byKey(child0Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + expect(childRect.right, 800.0 - 200.0); + + childRect = tester.getRect(find.byKey(child1Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + expect(childRect.right, 800.0 - 100.0); + + childRect = tester.getRect(find.byKey(child2Key)); + expect(childRect.size.width, equals(100.0)); + expect(childRect.size.height, equals(100.0)); + expect(childRect.right, 800.0); + }); + }); + + group('button properties override ButtonTheme', () { + testWidgets('default button properties override ButtonTheme properties', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + children: <Widget>[ + Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(); + }, + ), + ], + ), + ), + ); + final ButtonThemeData buttonTheme = ButtonTheme.of(capturedContext); + expect(buttonTheme.textTheme, equals(ButtonTextTheme.primary)); + expect(buttonTheme.minWidth, equals(64.0)); + expect(buttonTheme.height, equals(36.0)); + expect(buttonTheme.padding, equals(const EdgeInsets.symmetric(horizontal: 8.0))); + expect(buttonTheme.alignedDropdown, equals(false)); + expect(buttonTheme.layoutBehavior, equals(ButtonBarLayoutBehavior.padded)); + }); + + testWidgets('ButtonBarTheme button properties override defaults and ButtonTheme properties', ( + WidgetTester tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: ButtonBarTheme( + data: const ButtonBarThemeData( + buttonTextTheme: ButtonTextTheme.primary, + buttonMinWidth: 42.0, + buttonHeight: 84.0, + buttonPadding: EdgeInsets.fromLTRB(10, 20, 30, 40), + buttonAlignedDropdown: true, + layoutBehavior: ButtonBarLayoutBehavior.constrained, + ), + child: ButtonBar( + children: <Widget>[ + Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(); + }, + ), + ], + ), + ), + ), + ); + final ButtonThemeData buttonTheme = ButtonTheme.of(capturedContext); + expect(buttonTheme.textTheme, equals(ButtonTextTheme.primary)); + expect(buttonTheme.minWidth, equals(42.0)); + expect(buttonTheme.height, equals(84.0)); + expect(buttonTheme.padding, equals(const EdgeInsets.fromLTRB(10, 20, 30, 40))); + expect(buttonTheme.alignedDropdown, equals(true)); + expect(buttonTheme.layoutBehavior, equals(ButtonBarLayoutBehavior.constrained)); + }); + + testWidgets( + 'ButtonBar button properties override ButtonBarTheme, defaults and ButtonTheme properties', + (WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: ButtonBarTheme( + data: const ButtonBarThemeData( + buttonTextTheme: ButtonTextTheme.accent, + buttonMinWidth: 4242.0, + buttonHeight: 8484.0, + buttonPadding: EdgeInsets.fromLTRB(50, 60, 70, 80), + buttonAlignedDropdown: false, + layoutBehavior: ButtonBarLayoutBehavior.padded, + ), + child: ButtonBar( + buttonTextTheme: ButtonTextTheme.primary, + buttonMinWidth: 42.0, + buttonHeight: 84.0, + buttonPadding: const EdgeInsets.fromLTRB(10, 20, 30, 40), + buttonAlignedDropdown: true, + layoutBehavior: ButtonBarLayoutBehavior.constrained, + children: <Widget>[ + Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(); + }, + ), + ], + ), + ), + ), + ); + final ButtonThemeData buttonTheme = ButtonTheme.of(capturedContext); + expect(buttonTheme.textTheme, equals(ButtonTextTheme.primary)); + expect(buttonTheme.minWidth, equals(42.0)); + expect(buttonTheme.height, equals(84.0)); + expect(buttonTheme.padding, equals(const EdgeInsets.fromLTRB(10, 20, 30, 40))); + expect(buttonTheme.alignedDropdown, equals(true)); + expect(buttonTheme.layoutBehavior, equals(ButtonBarLayoutBehavior.constrained)); + }, + ); + }); + + group('layoutBehavior', () { + testWidgets('ButtonBar has a min height of 52 when using ButtonBarLayoutBehavior.constrained', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const SingleChildScrollView( + child: ListBody( + children: <Widget>[ + Directionality( + textDirection: TextDirection.ltr, + child: ButtonBar( + layoutBehavior: ButtonBarLayoutBehavior.constrained, + children: <Widget>[SizedBox(width: 10.0, height: 10.0)], + ), + ), + ], + ), + ), + ); + + final Finder buttonBar = find.byType(ButtonBar); + expect(tester.getBottomRight(buttonBar).dy - tester.getTopRight(buttonBar).dy, 52.0); + }); + + testWidgets('ButtonBar has padding applied when using ButtonBarLayoutBehavior.padded', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const SingleChildScrollView( + child: ListBody( + children: <Widget>[ + Directionality( + textDirection: TextDirection.ltr, + child: ButtonBar( + layoutBehavior: ButtonBarLayoutBehavior.padded, + children: <Widget>[SizedBox(width: 10.0, height: 10.0)], + ), + ), + ], + ), + ), + ); + + final Finder buttonBar = find.byType(ButtonBar); + expect(tester.getBottomRight(buttonBar).dy - tester.getTopRight(buttonBar).dy, 26.0); + }); + }); + + group("ButtonBar's children wrap when they overflow horizontally", () { + testWidgets("ButtonBar's children wrap when buttons overflow", (WidgetTester tester) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 800.0), + SizedBox(key: keyTwo, height: 50.0, width: 800.0), + ], + ), + ), + ); + + // Second [Container] should wrap around to the next column since + // they take up max width constraint. + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + expect(containerOneRect.bottom, containerTwoRect.top); + expect(containerOneRect.left, containerTwoRect.left); + }); + + testWidgets("ButtonBar's children overflow defaults - MainAxisAlignment.end", ( + WidgetTester tester, + ) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + final Rect buttonBarRect = tester.getRect(find.byType(ButtonBar)); + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + // Second [Container] should wrap around to the next row. + expect(containerOneRect.bottom, containerTwoRect.top); + // Second [Container] should align to the start of the ButtonBar. + expect(containerOneRect.right, containerTwoRect.right); + expect(containerOneRect.right, buttonBarRect.right); + }); + + testWidgets("ButtonBar's children overflow - MainAxisAlignment.start", ( + WidgetTester tester, + ) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.start, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + final Rect buttonBarRect = tester.getRect(find.byType(ButtonBar)); + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + // Second [Container] should wrap around to the next row. + expect(containerOneRect.bottom, containerTwoRect.top); + // [Container]s should align to the end of the ButtonBar. + expect(containerOneRect.left, containerTwoRect.left); + expect(containerOneRect.left, buttonBarRect.left); + }); + + testWidgets("ButtonBar's children overflow - MainAxisAlignment.center", ( + WidgetTester tester, + ) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.center, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + final Rect buttonBarRect = tester.getRect(find.byType(ButtonBar)); + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + // Second [Container] should wrap around to the next row. + expect(containerOneRect.bottom, containerTwoRect.top); + // [Container]s should center themselves in the ButtonBar. + expect(containerOneRect.center.dx, containerTwoRect.center.dx); + expect(containerOneRect.center.dx, buttonBarRect.center.dx); + }); + + testWidgets("ButtonBar's children default to MainAxisAlignment.start for horizontal " + 'alignment when overflowing in spaceBetween, spaceAround and spaceEvenly ' + 'cases when overflowing.', (WidgetTester tester) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.spaceEvenly, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + Rect buttonBarRect = tester.getRect(find.byType(ButtonBar)); + Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + // Second [Container] should wrap around to the next row. + expect(containerOneRect.bottom, containerTwoRect.top); + // Should align horizontally to the start of the button bar. + expect(containerOneRect.left, containerTwoRect.left); + expect(containerOneRect.left, buttonBarRect.left); + + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.spaceAround, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + buttonBarRect = tester.getRect(find.byType(ButtonBar)); + containerOneRect = tester.getRect(find.byKey(keyOne)); + containerTwoRect = tester.getRect(find.byKey(keyTwo)); + // Second [Container] should wrap around to the next row. + expect(containerOneRect.bottom, containerTwoRect.top); + // Should align horizontally to the start of the button bar. + expect(containerOneRect.left, containerTwoRect.left); + expect(containerOneRect.left, buttonBarRect.left); + }); + + testWidgets("ButtonBar's children respects verticalDirection when overflowing", ( + WidgetTester tester, + ) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.center, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + // Set the vertical direction to start from the bottom and lay + // out upwards. + overflowDirection: VerticalDirection.up, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + // Second [Container] should appear above first container. + expect(containerTwoRect.bottom, lessThanOrEqualTo(containerOneRect.top)); + }); + + testWidgets('ButtonBar has no spacing by default when overflowing', ( + WidgetTester tester, + ) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.center, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + expect(containerOneRect.bottom, containerTwoRect.top); + }); + + testWidgets("ButtonBar's children respects overflowButtonSpacing when overflowing", ( + WidgetTester tester, + ) async { + final Key keyOne = UniqueKey(); + final Key keyTwo = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: ButtonBar( + alignment: MainAxisAlignment.center, + // Set padding to zero to align buttons with edge of button bar. + buttonPadding: EdgeInsets.zero, + // Set the overflow button spacing to ensure add some space between + // buttons in an overflow case. + overflowButtonSpacing: 10.0, + children: <Widget>[ + SizedBox(key: keyOne, height: 50.0, width: 500.0), + SizedBox(key: keyTwo, height: 50.0, width: 500.0), + ], + ), + ), + ); + + final Rect containerOneRect = tester.getRect(find.byKey(keyOne)); + final Rect containerTwoRect = tester.getRect(find.byKey(keyTwo)); + expect(containerOneRect.bottom, containerTwoRect.top - 10.0); + }); + }); + + testWidgets('_RenderButtonBarRow.constraints does not work before layout', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: ButtonBar()), + duration: Duration.zero, + phase: EnginePhase.build, + ); + + final Finder buttonBar = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_ButtonBarRow', + ); + final renderButtonBar = tester.renderObject(buttonBar) as RenderBox; + + expect(renderButtonBar.debugNeedsLayout, isTrue); + expect(() => renderButtonBar.constraints, throwsStateError); + }); +} diff --git a/packages/material_ui/test/material/button_bar_theme_test.dart b/packages/material_ui/test/material/button_bar_theme_test.dart new file mode 100644 index 000000000000..9864efc29881 --- /dev/null +++ b/packages/material_ui/test/material/button_bar_theme_test.dart @@ -0,0 +1,163 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ButtonBarThemeData lerp special cases', () { + expect(ButtonBarThemeData.lerp(null, null, 0), null); + const data = ButtonBarThemeData(); + expect(identical(ButtonBarThemeData.lerp(data, data, 0.5), data), true); + }); + + test('ButtonBarThemeData null fields by default', () { + const buttonBarTheme = ButtonBarThemeData(); + expect(buttonBarTheme.alignment, null); + expect(buttonBarTheme.mainAxisSize, null); + expect(buttonBarTheme.buttonTextTheme, null); + expect(buttonBarTheme.buttonMinWidth, null); + expect(buttonBarTheme.buttonHeight, null); + expect(buttonBarTheme.buttonPadding, null); + expect(buttonBarTheme.buttonAlignedDropdown, null); + expect(buttonBarTheme.layoutBehavior, null); + expect(buttonBarTheme.overflowDirection, null); + }); + + test('ThemeData uses default ButtonBarThemeData', () { + expect(ThemeData().buttonBarTheme, equals(const ButtonBarThemeData())); + }); + + test('ButtonBarThemeData copyWith, ==, hashCode basics', () { + expect(const ButtonBarThemeData(), const ButtonBarThemeData().copyWith()); + expect(const ButtonBarThemeData().hashCode, const ButtonBarThemeData().copyWith().hashCode); + }); + + testWidgets('ButtonBarThemeData lerps correctly', (WidgetTester tester) async { + const barThemePrimary = ButtonBarThemeData( + alignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + buttonTextTheme: ButtonTextTheme.primary, + buttonMinWidth: 20.0, + buttonHeight: 20.0, + buttonPadding: EdgeInsets.symmetric(vertical: 5.0), + buttonAlignedDropdown: false, + layoutBehavior: ButtonBarLayoutBehavior.padded, + overflowDirection: VerticalDirection.down, + ); + const barThemeAccent = ButtonBarThemeData( + alignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + buttonTextTheme: ButtonTextTheme.accent, + buttonMinWidth: 10.0, + buttonHeight: 40.0, + buttonPadding: EdgeInsets.symmetric(horizontal: 10.0), + buttonAlignedDropdown: true, + layoutBehavior: ButtonBarLayoutBehavior.constrained, + overflowDirection: VerticalDirection.up, + ); + + final ButtonBarThemeData lerp = ButtonBarThemeData.lerp(barThemePrimary, barThemeAccent, 0.5)!; + expect(lerp.alignment, equals(MainAxisAlignment.center)); + expect(lerp.mainAxisSize, equals(MainAxisSize.max)); + expect(lerp.buttonTextTheme, equals(ButtonTextTheme.accent)); + expect(lerp.buttonMinWidth, equals(15.0)); + expect(lerp.buttonHeight, equals(30.0)); + expect(lerp.buttonPadding, equals(const EdgeInsets.fromLTRB(5.0, 2.5, 5.0, 2.5))); + expect(lerp.buttonAlignedDropdown, isTrue); + expect(lerp.layoutBehavior, equals(ButtonBarLayoutBehavior.constrained)); + expect(lerp.overflowDirection, equals(VerticalDirection.up)); + }); + + testWidgets('Default ButtonBarThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ButtonBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ButtonBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ButtonBarThemeData( + alignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + buttonTextTheme: ButtonTextTheme.accent, + buttonMinWidth: 10.0, + buttonHeight: 42.0, + buttonPadding: EdgeInsets.symmetric(horizontal: 7.3), + buttonAlignedDropdown: true, + layoutBehavior: ButtonBarLayoutBehavior.constrained, + overflowDirection: VerticalDirection.up, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'alignment: MainAxisAlignment.center', + 'mainAxisSize: MainAxisSize.max', + 'textTheme: ButtonTextTheme.accent', + 'minWidth: 10.0', + 'height: 42.0', + 'padding: EdgeInsets(7.3, 0.0, 7.3, 0.0)', + 'dropdown width matches button', + 'layoutBehavior: ButtonBarLayoutBehavior.constrained', + 'overflowDirection: VerticalDirection.up', + ]); + }); + + testWidgets('ButtonBarTheme.of falls back to ThemeData.buttonBarTheme', ( + WidgetTester tester, + ) async { + const buttonBarTheme = ButtonBarThemeData(buttonMinWidth: 42.0); + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(buttonBarTheme: buttonBarTheme), + home: Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(); + }, + ), + ), + ); + expect(ButtonBarTheme.of(capturedContext), equals(buttonBarTheme)); + expect(ButtonBarTheme.of(capturedContext).buttonMinWidth, equals(42.0)); + }); + + testWidgets('ButtonBarTheme overrides ThemeData.buttonBarTheme', (WidgetTester tester) async { + const defaultBarTheme = ButtonBarThemeData(buttonMinWidth: 42.0); + const buttonBarTheme = ButtonBarThemeData(buttonMinWidth: 84.0); + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(buttonBarTheme: defaultBarTheme), + home: Builder( + builder: (BuildContext context) { + return ButtonBarTheme( + data: buttonBarTheme, + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(); + }, + ), + ); + }, + ), + ), + ); + expect(ButtonBarTheme.of(capturedContext), equals(buttonBarTheme)); + expect(ButtonBarTheme.of(capturedContext).buttonMinWidth, equals(84.0)); + }); +} diff --git a/packages/material_ui/test/material/button_style_test.dart b/packages/material_ui/test/material/button_style_test.dart new file mode 100644 index 000000000000..6d616bf236c1 --- /dev/null +++ b/packages/material_ui/test/material/button_style_test.dart @@ -0,0 +1,248 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ButtonStyle copyWith, merge, ==, hashCode basics', () { + expect(const ButtonStyle(), const ButtonStyle().copyWith()); + expect(const ButtonStyle().merge(const ButtonStyle()), const ButtonStyle()); + expect(const ButtonStyle().hashCode, const ButtonStyle().copyWith().hashCode); + }); + + test('ButtonStyle lerp special cases', () { + expect(ButtonStyle.lerp(null, null, 0), null); + const data = ButtonStyle(); + expect(identical(ButtonStyle.lerp(data, data, 0.5), data), true); + }); + + test('ButtonStyle defaults', () { + const style = ButtonStyle(); + expect(style.textStyle, isNull); + expect(style.backgroundColor, isNull); + expect(style.foregroundColor, isNull); + expect(style.overlayColor, isNull); + expect(style.shadowColor, isNull); + expect(style.surfaceTintColor, isNull); + expect(style.elevation, isNull); + expect(style.padding, isNull); + expect(style.minimumSize, isNull); + expect(style.fixedSize, isNull); + expect(style.maximumSize, isNull); + expect(style.iconColor, isNull); + expect(style.iconSize, isNull); + expect(style.side, isNull); + expect(style.shape, isNull); + expect(style.mouseCursor, isNull); + expect(style.visualDensity, isNull); + expect(style.tapTargetSize, isNull); + expect(style.animationDuration, isNull); + expect(style.enableFeedback, isNull); + expect(style.alignment, isNull); + expect(style.splashFactory, isNull); + expect(style.backgroundBuilder, isNull); + expect(style.foregroundBuilder, isNull); + }); + + testWidgets('Default ButtonStyle debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ButtonStyle().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ButtonStyle debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ButtonStyle( + textStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 10.0)), + backgroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff1)), + foregroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff2)), + overlayColor: MaterialStatePropertyAll<Color>(Color(0xfffffff3)), + shadowColor: MaterialStatePropertyAll<Color>(Color(0xfffffff4)), + surfaceTintColor: MaterialStatePropertyAll<Color>(Color(0xfffffff5)), + elevation: MaterialStatePropertyAll<double>(1.5), + padding: MaterialStatePropertyAll<EdgeInsets>(EdgeInsets.all(1.0)), + minimumSize: MaterialStatePropertyAll<Size>(Size(1.0, 2.0)), + side: MaterialStatePropertyAll<BorderSide>(BorderSide(width: 4.0, color: Color(0xfffffff6))), + maximumSize: MaterialStatePropertyAll<Size>(Size(100.0, 200.0)), + iconColor: MaterialStatePropertyAll<Color>(Color(0xfffffff6)), + iconSize: MaterialStatePropertyAll<double>(48.1), + shape: MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()), + mouseCursor: MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.forbidden), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + animationDuration: Duration(seconds: 1), + enableFeedback: true, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'textStyle: WidgetStatePropertyAll(TextStyle(inherit: true, size: 10.0))', + 'backgroundColor: WidgetStatePropertyAll(${const Color(0xfffffff1)})', + 'foregroundColor: WidgetStatePropertyAll(${const Color(0xfffffff2)})', + 'overlayColor: WidgetStatePropertyAll(${const Color(0xfffffff3)})', + 'shadowColor: WidgetStatePropertyAll(${const Color(0xfffffff4)})', + 'surfaceTintColor: WidgetStatePropertyAll(${const Color(0xfffffff5)})', + 'elevation: WidgetStatePropertyAll(1.5)', + 'padding: WidgetStatePropertyAll(EdgeInsets.all(1.0))', + 'minimumSize: WidgetStatePropertyAll(Size(1.0, 2.0))', + 'maximumSize: WidgetStatePropertyAll(Size(100.0, 200.0))', + 'iconColor: WidgetStatePropertyAll(${const Color(0xfffffff6)})', + 'iconSize: WidgetStatePropertyAll(48.1)', + 'side: WidgetStatePropertyAll(BorderSide(color: ${const Color(0xfffffff6)}, width: 4.0))', + 'shape: WidgetStatePropertyAll(StadiumBorder(BorderSide(width: 0.0, style: none)))', + 'mouseCursor: WidgetStatePropertyAll(SystemMouseCursor(forbidden))', + 'tapTargetSize: shrinkWrap', + 'animationDuration: 0:00:01.000000', + 'enableFeedback: true', + ]); + }); + + testWidgets('ButtonStyle copyWith, merge', (WidgetTester tester) async { + const WidgetStateProperty<TextStyle> textStyle = MaterialStatePropertyAll<TextStyle>( + TextStyle(fontSize: 10), + ); + const WidgetStateProperty<Color> backgroundColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff1), + ); + const WidgetStateProperty<Color> foregroundColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff2), + ); + const WidgetStateProperty<Color> overlayColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff3), + ); + const WidgetStateProperty<Color> shadowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff4), + ); + const WidgetStateProperty<Color> surfaceTintColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff5), + ); + const WidgetStateProperty<double> elevation = MaterialStatePropertyAll<double>(1); + const WidgetStateProperty<EdgeInsets> padding = MaterialStatePropertyAll<EdgeInsets>( + EdgeInsets.all(1), + ); + const WidgetStateProperty<Size> minimumSize = MaterialStatePropertyAll<Size>(Size(1, 2)); + const WidgetStateProperty<Size> fixedSize = MaterialStatePropertyAll<Size>(Size(3, 4)); + const WidgetStateProperty<Size> maximumSize = MaterialStatePropertyAll<Size>(Size(5, 6)); + const WidgetStateProperty<Color> iconColor = MaterialStatePropertyAll<Color>(Color(0xfffffff6)); + const WidgetStateProperty<double> iconSize = MaterialStatePropertyAll<double>(48.0); + const WidgetStateProperty<BorderSide> side = MaterialStatePropertyAll<BorderSide>(BorderSide()); + const WidgetStateProperty<OutlinedBorder> shape = MaterialStatePropertyAll<OutlinedBorder>( + StadiumBorder(), + ); + const WidgetStateProperty<MouseCursor> mouseCursor = MaterialStatePropertyAll<MouseCursor>( + SystemMouseCursors.forbidden, + ); + const VisualDensity visualDensity = VisualDensity.compact; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const animationDuration = Duration(seconds: 1); + const enableFeedback = true; + + const style = ButtonStyle( + textStyle: textStyle, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + overlayColor: overlayColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + fixedSize: fixedSize, + maximumSize: maximumSize, + iconColor: iconColor, + iconSize: iconSize, + side: side, + shape: shape, + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ); + + expect( + style, + const ButtonStyle().copyWith( + textStyle: textStyle, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + overlayColor: overlayColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + elevation: elevation, + padding: padding, + minimumSize: minimumSize, + fixedSize: fixedSize, + maximumSize: maximumSize, + iconColor: iconColor, + iconSize: iconSize, + side: side, + shape: shape, + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + ), + ); + + expect(style, const ButtonStyle().merge(style)); + + expect(style.copyWith(), style.merge(const ButtonStyle())); + }); + + test('ButtonStyle.lerp BorderSide', () { + // This is regression test for https://github.com/flutter/flutter/pull/78051 + expect(ButtonStyle.lerp(null, null, 0), null); + expect(ButtonStyle.lerp(null, null, 0.5), null); + expect(ButtonStyle.lerp(null, null, 1), null); + + const blackSide = BorderSide(); + const whiteSide = BorderSide(color: Color(0xFFFFFFFF)); + const emptyBlackSide = BorderSide(width: 0, color: Color(0x00000000)); + + const blackStyle = ButtonStyle(side: MaterialStatePropertyAll<BorderSide>(blackSide)); + const whiteStyle = ButtonStyle(side: MaterialStatePropertyAll<BorderSide>(whiteSide)); + + // WidgetState.all<Foo>(value) properties resolve to value + // for any set of MaterialStates. + const states = <WidgetState>{}; + + expect(ButtonStyle.lerp(blackStyle, blackStyle, 0)?.side?.resolve(states), blackSide); + expect(ButtonStyle.lerp(blackStyle, blackStyle, 0.5)?.side?.resolve(states), blackSide); + expect(ButtonStyle.lerp(blackStyle, blackStyle, 1)?.side?.resolve(states), blackSide); + + expect(ButtonStyle.lerp(blackStyle, null, 0)?.side?.resolve(states), blackSide); + expect( + ButtonStyle.lerp(blackStyle, null, 0.5)?.side?.resolve(states), + BorderSide.lerp(blackSide, emptyBlackSide, 0.5), + ); + expect(ButtonStyle.lerp(blackStyle, null, 1)?.side?.resolve(states), emptyBlackSide); + + expect(ButtonStyle.lerp(null, blackStyle, 0)?.side?.resolve(states), emptyBlackSide); + expect( + ButtonStyle.lerp(null, blackStyle, 0.5)?.side?.resolve(states), + BorderSide.lerp(emptyBlackSide, blackSide, 0.5), + ); + expect(ButtonStyle.lerp(null, blackStyle, 1)?.side?.resolve(states), blackSide); + + expect(ButtonStyle.lerp(blackStyle, whiteStyle, 0)?.side?.resolve(states), blackSide); + expect( + ButtonStyle.lerp(blackStyle, whiteStyle, 0.5)?.side?.resolve(states), + BorderSide.lerp(blackSide, whiteSide, 0.5), + ); + expect(ButtonStyle.lerp(blackStyle, whiteStyle, 1)?.side?.resolve(states), whiteSide); + }); +} diff --git a/packages/material_ui/test/material/button_theme_test.dart b/packages/material_ui/test/material/button_theme_test.dart new file mode 100644 index 000000000000..82a6b8d7d3f7 --- /dev/null +++ b/packages/material_ui/test/material/button_theme_test.dart @@ -0,0 +1,153 @@ +// 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() { + test('ButtonThemeData defaults', () { + const theme = ButtonThemeData(); + expect(theme.textTheme, ButtonTextTheme.normal); + expect(theme.constraints, const BoxConstraints(minWidth: 88.0, minHeight: 36.0)); + expect(theme.padding, const EdgeInsets.symmetric(horizontal: 16.0)); + expect( + theme.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + ); + expect(theme.alignedDropdown, false); + expect(theme.layoutBehavior, ButtonBarLayoutBehavior.padded); + }); + + test('ButtonThemeData default overrides', () { + const theme = ButtonThemeData( + textTheme: ButtonTextTheme.primary, + minWidth: 100.0, + height: 200.0, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(), + alignedDropdown: true, + ); + expect(theme.textTheme, ButtonTextTheme.primary); + expect(theme.constraints, const BoxConstraints(minWidth: 100.0, minHeight: 200.0)); + expect(theme.padding, EdgeInsets.zero); + expect(theme.shape, const RoundedRectangleBorder()); + expect(theme.alignedDropdown, true); + }); + + test('ButtonThemeData.copyWith', () { + ButtonThemeData theme = const ButtonThemeData().copyWith(); + expect(theme.textTheme, ButtonTextTheme.normal); + expect(theme.layoutBehavior, ButtonBarLayoutBehavior.padded); + expect(theme.constraints, const BoxConstraints(minWidth: 88.0, minHeight: 36.0)); + expect(theme.padding, const EdgeInsets.symmetric(horizontal: 16.0)); + expect( + theme.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + ); + expect(theme.alignedDropdown, false); + expect(theme.colorScheme, null); + + theme = const ButtonThemeData().copyWith( + textTheme: ButtonTextTheme.primary, + layoutBehavior: ButtonBarLayoutBehavior.constrained, + minWidth: 100.0, + height: 200.0, + padding: EdgeInsets.zero, + shape: const StadiumBorder(), + alignedDropdown: true, + colorScheme: const ColorScheme.dark(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + expect(theme.textTheme, ButtonTextTheme.primary); + expect(theme.layoutBehavior, ButtonBarLayoutBehavior.constrained); + expect(theme.constraints, const BoxConstraints(minWidth: 100.0, minHeight: 200.0)); + expect(theme.padding, EdgeInsets.zero); + expect(theme.shape, const StadiumBorder()); + expect(theme.alignedDropdown, true); + expect(theme.colorScheme, const ColorScheme.dark()); + }); + + testWidgets('ButtonTheme alignedDropdown', (WidgetTester tester) async { + final Key dropdownKey = UniqueKey(); + + Widget buildFrame({required bool alignedDropdown, required TextDirection textDirection}) { + return MaterialApp( + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: textDirection, child: child!); + }, + home: ButtonTheme( + alignedDropdown: alignedDropdown, + child: Material( + child: Builder( + builder: (BuildContext context) { + return Container( + alignment: Alignment.center, + child: DropdownButtonHideUnderline( + child: SizedBox( + width: 200.0, + child: DropdownButton<String>( + key: dropdownKey, + onChanged: (String? value) {}, + value: 'foo', + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: 'foo', child: Text('foo')), + DropdownMenuItem<String>(value: 'bar', child: Text('bar')), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + final Finder button = find.byKey(dropdownKey); + final Finder menu = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_DropdownMenu<String>', + ); + + await tester.pumpWidget(buildFrame(alignedDropdown: false, textDirection: TextDirection.ltr)); + await tester.tap(button); + await tester.pumpAndSettle(); + + // 240 = 200.0 (button width) + _kUnalignedMenuMargin (20.0 left and right) + expect(tester.getSize(button).width, 200.0); + expect(tester.getSize(menu).width, 240.0); + + // Dismiss the menu. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + + await tester.pumpWidget(buildFrame(alignedDropdown: true, textDirection: TextDirection.ltr)); + await tester.tap(button); + await tester.pumpAndSettle(); + + // Aligneddropdown: true means the button and menu widths match + expect(tester.getSize(button).width, 200.0); + expect(tester.getSize(menu).width, 200.0); + + // There are two 'foo' widgets: the selected menu item's label and the drop + // down button's label. The should both appear at the same location. + final Finder fooText = find.text('foo'); + expect(fooText, findsNWidgets(2)); + expect(tester.getRect(fooText.at(0)), tester.getRect(fooText.at(1))); + + // Dismiss the menu. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + + // Same test as above except RTL + await tester.pumpWidget(buildFrame(alignedDropdown: true, textDirection: TextDirection.rtl)); + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(fooText, findsNWidgets(2)); + expect(tester.getRect(fooText.at(0)), tester.getRect(fooText.at(1))); + }); +} diff --git a/packages/material_ui/test/material/calendar_date_picker_test.dart b/packages/material_ui/test/material/calendar_date_picker_test.dart new file mode 100644 index 000000000000..a9a521f7d50b --- /dev/null +++ b/packages/material_ui/test/material/calendar_date_picker_test.dart @@ -0,0 +1,2195 @@ +// 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:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final Finder nextMonthIcon = find.byWidgetPredicate( + (Widget w) => + w is IconButton && + ((w.tooltip?.startsWith('Next month') ?? false) || + ((w.icon as Icon).semanticLabel?.startsWith('Next month') ?? false)), + ); + final Finder previousMonthIcon = find.byWidgetPredicate( + (Widget w) => + w is IconButton && + ((w.tooltip?.startsWith('Previous month') ?? false) || + ((w.icon as Icon).semanticLabel?.startsWith('Previous month') ?? false)), + ); + + Widget calendarDatePicker({ + Key? key, + DateTime? initialDate, + DateTime? firstDate, + DateTime? lastDate, + DateTime? currentDate, + ValueChanged<DateTime>? onDateChanged, + ValueChanged<DateTime>? onDisplayedMonthChanged, + DatePickerMode initialCalendarMode = DatePickerMode.day, + SelectableDayPredicate? selectableDayPredicate, + TextDirection textDirection = TextDirection.ltr, + ThemeData? theme, + bool? useMaterial3, + }) { + return MaterialApp( + theme: theme ?? ThemeData(useMaterial3: useMaterial3), + home: Material( + child: Directionality( + textDirection: textDirection, + child: CalendarDatePicker( + key: key, + initialDate: initialDate, + firstDate: firstDate ?? DateTime(2001), + lastDate: lastDate ?? DateTime(2031, DateTime.december, 31), + currentDate: currentDate ?? DateTime(2016, DateTime.january, 3), + onDateChanged: onDateChanged ?? (DateTime date) {}, + onDisplayedMonthChanged: onDisplayedMonthChanged, + initialCalendarMode: initialCalendarMode, + selectableDayPredicate: selectableDayPredicate, + ), + ), + ), + ); + } + + Widget yearPicker({ + Key? key, + DateTime? selectedDate, + DateTime? initialDate, + DateTime? firstDate, + DateTime? lastDate, + DateTime? currentDate, + ValueChanged<DateTime>? onChanged, + TextDirection textDirection = TextDirection.ltr, + }) { + return MaterialApp( + home: Material( + child: Directionality( + textDirection: textDirection, + child: YearPicker( + key: key, + selectedDate: selectedDate ?? DateTime(2016, DateTime.january, 15), + firstDate: firstDate ?? DateTime(2001), + lastDate: lastDate ?? DateTime(2031, DateTime.december, 31), + currentDate: currentDate ?? DateTime(2016, DateTime.january, 3), + onChanged: onChanged ?? (DateTime date) {}, + ), + ), + ), + ); + } + + group('CalendarDatePicker', () { + testWidgets('Can select a day', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + await tester.tap(find.text('12')); + expect(selectedDate, equals(DateTime(2016, DateTime.january, 12))); + }); + + testWidgets('Can select a day with nothing first selected', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker(onDateChanged: (DateTime date) => selectedDate = date), + ); + await tester.tap(find.text('12')); + expect(selectedDate, equals(DateTime(2016, DateTime.january, 12))); + }); + + testWidgets('Can select a month', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + expect(find.text('January 2016'), findsOneWidget); + + // Go back two months + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('December 2015'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2015, DateTime.december))); + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('November 2015'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2015, DateTime.november))); + + // Go forward a month + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('December 2015'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2015, DateTime.december))); + }); + + testWidgets('Can select a month with nothing first selected', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker(onDisplayedMonthChanged: (DateTime date) => displayedMonth = date), + ); + expect(find.text('January 2016'), findsOneWidget); + + // Go back two months + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('December 2015'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2015, DateTime.december))); + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('November 2015'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2015, DateTime.november))); + + // Go forward a month + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('December 2015'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2015, DateTime.december))); + }); + + testWidgets('Can select a year', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(find.text('January 2018'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2018))); + }); + + testWidgets('Can select a year with nothing first selected', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker(onDisplayedMonthChanged: (DateTime date) => displayedMonth = date), + ); + + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(find.text('January 2018'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2018))); + }); + + testWidgets('Selecting date does not change displayed month', (WidgetTester tester) async { + DateTime? selectedDate; + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2020, DateTime.march, 15), + onDateChanged: (DateTime date) => selectedDate = date, + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + expect(find.text('April 2020'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2020, DateTime.april))); + + await tester.tap(find.text('25')); + await tester.pumpAndSettle(); + expect(find.text('April 2020'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2020, DateTime.april))); + expect(selectedDate, equals(DateTime(2020, DateTime.april, 25))); + // There isn't a 31 in April so there shouldn't be one if it is showing April. + expect(find.text('31'), findsNothing); + }); + + testWidgets('Changing year does change selected date', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + await tester.tap(find.text('4')); + expect(selectedDate, equals(DateTime(2016, DateTime.january, 4))); + await tester.tap(find.text('January 2016')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(selectedDate, equals(DateTime(2018, DateTime.january, 4))); + }); + + testWidgets('Changing year for february 29th', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2020, DateTime.february, 29), + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + await tester.tap(find.text('February 2020')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(selectedDate, equals(DateTime(2018, DateTime.february, 28))); + await tester.tap(find.text('February 2018')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2020')); + await tester.pumpAndSettle(); + // Changing back to 2020 the 29th is not selected anymore. + expect(selectedDate, equals(DateTime(2020, DateTime.february, 28))); + }); + + testWidgets('Changing year does not change the month', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + await tester.tap(find.text('March 2016')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(find.text('March 2018'), findsOneWidget); + expect(displayedMonth, equals(DateTime(2018, DateTime.march))); + }); + + testWidgets('Can select a year and then a day', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pumpAndSettle(); + await tester.tap(find.text('2017')); + await tester.pumpAndSettle(); + await tester.tap(find.text('19')); + expect(selectedDate, equals(DateTime(2017, DateTime.january, 19))); + }); + + testWidgets('Cannot select a day outside bounds', (WidgetTester tester) async { + final validDate = DateTime(2017, DateTime.january, 15); + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: validDate, + firstDate: validDate, + lastDate: validDate, + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + + // Earlier than firstDate. Should be ignored. + await tester.tap(find.text('10')); + expect(selectedDate, isNull); + + // Later than lastDate. Should be ignored. + await tester.tap(find.text('20')); + expect(selectedDate, isNull); + + // This one is just right. + await tester.tap(find.text('15')); + expect(selectedDate, validDate); + }); + + testWidgets('Cannot navigate to a month outside bounds', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, DateTime.december, 15), + initialDate: DateTime(2017, DateTime.january, 15), + lastDate: DateTime(2017, DateTime.february, 15), + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + expect(displayedMonth, equals(DateTime(2017, DateTime.february))); + // Shouldn't be possible to keep going forward into March. + expect(tester.getSemantics(nextMonthIcon).flagsCollection.isEnabled, Tristate.isFalse); + + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(); + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(); + expect(displayedMonth, equals(DateTime(2016, DateTime.december))); + // Shouldn't be possible to keep going backward into November. + expect(tester.getSemantics(previousMonthIcon).flagsCollection.isEnabled, Tristate.isFalse); + }); + + testWidgets('Cannot select disabled year', (WidgetTester tester) async { + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2018, DateTime.june, 9), + initialDate: DateTime(2018, DateTime.july, 4), + lastDate: DateTime(2018, DateTime.december, 15), + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + await tester.tap(find.text('July 2018')); // Switch to year mode. + await tester.pumpAndSettle(); + await tester.tap(find.text('2016')); // Disabled, doesn't change the year. + await tester.pumpAndSettle(); + await tester.tap(find.text('2020')); // Disabled, doesn't change the year. + await tester.pumpAndSettle(); + + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + // Nothing should have changed. + expect(displayedMonth, isNull); + }); + + testWidgets('Selecting firstDate year respects firstDate', (WidgetTester tester) async { + DateTime? selectedDate; + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, DateTime.june, 9), + initialDate: DateTime(2018, DateTime.may, 4), + lastDate: DateTime(2019, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + await tester.tap(find.text('May 2018')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2016')); + await tester.pumpAndSettle(); + // Month should be clamped to June as the range starts at June 2016. + expect(find.text('June 2016'), findsOneWidget); + expect(displayedMonth, DateTime(2016, DateTime.june)); + expect(selectedDate, DateTime(2016, DateTime.june, 9)); + }); + + testWidgets('Selecting lastDate year respects lastDate', (WidgetTester tester) async { + DateTime? selectedDate; + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, DateTime.june, 9), + initialDate: DateTime(2018, DateTime.may, 4), + lastDate: DateTime(2019, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + // Selected date is now 2018-05-04 (initialDate). + await tester.tap(find.text('May 2018')); + // Selected date is still 2018-05-04. + await tester.pumpAndSettle(); + await tester.tap(find.text('2019')); + // Selected date would become 2019-05-04 but gets clamped to the month of lastDate, so 2019-01-04. + await tester.pumpAndSettle(); + expect(find.text('January 2019'), findsOneWidget); + expect(displayedMonth, DateTime(2019)); + expect(selectedDate, DateTime(2019, DateTime.january, 4)); + }); + + testWidgets('Selecting lastDate year respects lastDate', (WidgetTester tester) async { + DateTime? selectedDate; + DateTime? displayedMonth; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, DateTime.june, 9), + initialDate: DateTime(2018, DateTime.may, 15), + lastDate: DateTime(2019, DateTime.january, 4), + onDateChanged: (DateTime date) => selectedDate = date, + onDisplayedMonthChanged: (DateTime date) => displayedMonth = date, + ), + ); + // Selected date is now 2018-05-15 (initialDate). + await tester.tap(find.text('May 2018')); + // Selected date is still 2018-05-15. + await tester.pumpAndSettle(); + await tester.tap(find.text('2019')); + // Selected date would become 2019-05-15 but gets clamped to the month of lastDate, so 2019-01-15. + // Day is now beyond the lastDate so that also gets clamped, to 2019-01-04. + await tester.pumpAndSettle(); + expect(find.text('January 2019'), findsOneWidget); + expect(displayedMonth, DateTime(2019)); + expect(selectedDate, DateTime(2019, DateTime.january, 4)); + }); + + testWidgets('Only predicate days are selectable', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2017, DateTime.january, 10), + initialDate: DateTime(2017, DateTime.january, 16), + lastDate: DateTime(2017, DateTime.january, 20), + onDateChanged: (DateTime date) => selectedDate = date, + selectableDayPredicate: (DateTime date) => date.day.isEven, + ), + ); + await tester.tap(find.text('13')); // Odd, doesn't work. + expect(selectedDate, isNull); + await tester.tap(find.text('10')); // Even, works. + expect(selectedDate, DateTime(2017, DateTime.january, 10)); + await tester.tap(find.text('17')); // Odd, doesn't work. + expect(selectedDate, DateTime(2017, DateTime.january, 10)); + }); + + testWidgets('Can select initial calendar picker mode', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2014, DateTime.january, 15), + initialCalendarMode: DatePickerMode.year, + ), + ); + // 2018 wouldn't be available if the year picker wasn't showing. + // The initial current year is 2014. + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(find.text('January 2018'), findsOneWidget); + }); + + testWidgets('Material2 - currentDate is highlighted', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker( + useMaterial3: false, + initialDate: DateTime(2016, DateTime.january, 15), + currentDate: DateTime(2016, 1, 2), + ), + ); + const todayColor = Color(0xff2196f3); // default primary color + expect( + Material.of(tester.element(find.text('2'))), + // The current day should be painted with a circle outline. + paints..circle(color: todayColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Material3 - currentDate is highlighted', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + currentDate: DateTime(2016, 1, 2), + ), + ); + const todayColor = Color(0xff6750a4); // default primary color + expect( + Material.of(tester.element(find.text('2'))), + // The current day should be painted with a circle outline. + paints..circle(color: todayColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Material2 - currentDate is highlighted even if it is disabled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + calendarDatePicker( + useMaterial3: false, + firstDate: DateTime(2016, 1, 3), + lastDate: DateTime(2016, 1, 31), + currentDate: DateTime(2016, 1, 2), // not between first and last + initialDate: DateTime(2016, 1, 5), + ), + ); + const disabledColor = Color(0x61000000); // default disabled color + expect( + Material.of(tester.element(find.text('2'))), + // The current day should be painted with a circle outline. + paints..circle(color: disabledColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Material3 - currentDate is highlighted even if it is disabled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, 1, 3), + lastDate: DateTime(2016, 1, 31), + currentDate: DateTime(2016, 1, 2), // not between first and last + initialDate: DateTime(2016, 1, 5), + ), + ); + const disabledColor = Color(0x616750a4); // default disabled color + expect( + Material.of(tester.element(find.text('2'))), + // The current day should be painted with a circle outline. + paints..circle(color: disabledColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Non-null todayBorder color should be respected over foreground color', ( + WidgetTester tester, + ) async { + const Color customBorderColor = Colors.red; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, 1, 15), + currentDate: DateTime(2016, 1, 2), + theme: ThemeData( + datePickerTheme: DatePickerThemeData( + todayBorder: const BorderSide(color: customBorderColor), + todayForegroundColor: WidgetStateProperty.all(Colors.blue), + ), + ), + ), + ); + expect( + Material.of(tester.element(find.text('2'))), + // The current day should be painted with the custom border color. + paints..circle(color: customBorderColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Non-null todayBorder color is used even when disabled', ( + WidgetTester tester, + ) async { + const Color customBorderColor = Colors.red; + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, 1, 3), + lastDate: DateTime(2016, 1, 31), + currentDate: DateTime(2016, 1, 2), // not between first and last + initialDate: DateTime(2016, 1, 5), + theme: ThemeData( + datePickerTheme: DatePickerThemeData( + todayBorder: const BorderSide(color: customBorderColor), + todayForegroundColor: WidgetStateProperty.all(Colors.blue), + ), + ), + ), + ); + expect( + Material.of(tester.element(find.text('2'))), + // The current day should be painted with the custom border color, + // not with foreground color opacity applied, even it's disabled day. + paints..circle(color: customBorderColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Transparent todayBorder should fall back to foreground color', ( + WidgetTester tester, + ) async { + const Color customForegroundColor = Colors.green; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, 1, 15), + currentDate: DateTime(2016, 1, 2), + theme: ThemeData( + datePickerTheme: DatePickerThemeData( + todayBorder: const BorderSide(color: Color(0x00000000)), + todayForegroundColor: WidgetStateProperty.all(customForegroundColor), + ), + ), + ), + ); + expect( + Material.of(tester.element(find.text('2'))), + // The current day should use the foreground color since + // todayBorder color is transparent. + paints..circle(color: customForegroundColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + + testWidgets('Selecting date does not switch picker to year selection', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2020, DateTime.may, 10), + initialCalendarMode: DatePickerMode.year, + ), + ); + await tester.tap(find.text('2017')); + await tester.pumpAndSettle(); + expect(find.text('May 2017'), findsOneWidget); + await tester.tap(find.text('10')); + await tester.pumpAndSettle(); + expect(find.text('May 2017'), findsOneWidget); + expect(find.text('2017'), findsNothing); + }); + + testWidgets('Selecting disabled date does not change current selection', ( + WidgetTester tester, + ) async { + DateTime day(int day) => DateTime(2020, DateTime.may, day); + + DateTime selection = day(2); + await tester.pumpWidget( + calendarDatePicker( + initialDate: selection, + firstDate: day(2), + lastDate: day(3), + onDateChanged: (DateTime date) { + selection = date; + }, + ), + ); + + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(selection, day(3)); + await tester.tap(find.text('4')); + await tester.pumpAndSettle(); + expect(selection, day(3)); + await tester.tap(find.text('5')); + await tester.pumpAndSettle(); + expect(selection, day(3)); + }); + + for (final useMaterial3 in <bool>[false, true]) { + testWidgets( + 'Updates to initialDate parameter are not reflected in the state (useMaterial3=$useMaterial3)', + (WidgetTester tester) async { + final Key pickerKey = UniqueKey(); + final initialDate = DateTime(2020, 1, 21); + final updatedDate = DateTime(1976, 2, 23); + final firstDate = DateTime(1970); + final lastDate = DateTime(2099, 31, 12); + final selectedColor = useMaterial3 + ? const Color(0xff6750a4) + : const Color(0xff2196f3); // default primary color + + await tester.pumpWidget( + calendarDatePicker( + key: pickerKey, + useMaterial3: useMaterial3, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + onDateChanged: (DateTime value) {}, + ), + ); + await tester.pumpAndSettle(); + + // Month should show as January 2020. + expect(find.text('January 2020'), findsOneWidget); + // Selected date should be painted with a colored circle. + expect( + Material.of(tester.element(find.text('21'))), + paints..circle(color: selectedColor, style: PaintingStyle.fill), + ); + + // Change to the updated initialDate. + // This should have no effect, the initialDate is only the _initial_ date. + await tester.pumpWidget( + calendarDatePicker( + key: pickerKey, + useMaterial3: useMaterial3, + initialDate: updatedDate, + firstDate: firstDate, + lastDate: lastDate, + onDateChanged: (DateTime value) {}, + ), + ); + // Wait for the page scroll animation to finish. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + // Month should show as January 2020 still. + expect(find.text('January 2020'), findsOneWidget); + expect(find.text('February 1976'), findsNothing); + // Selected date should be painted with a colored circle. + expect( + Material.of(tester.element(find.text('21'))), + paints..circle(color: selectedColor, style: PaintingStyle.fill), + ); + }, + ); + } + + testWidgets('Updates to initialCalendarMode parameter is not reflected in the state', ( + WidgetTester tester, + ) async { + final Key pickerKey = UniqueKey(); + + await tester.pumpWidget( + calendarDatePicker( + key: pickerKey, + initialDate: DateTime(2016, DateTime.january, 15), + initialCalendarMode: DatePickerMode.year, + ), + ); + await tester.pumpAndSettle(); + + // Should be in year mode. + expect(find.text('January 2016'), findsOneWidget); // Day/year selector + expect(find.text('15'), findsNothing); // day 15 in grid + expect(find.text('2016'), findsOneWidget); // 2016 in year grid + + await tester.pumpWidget( + calendarDatePicker(key: pickerKey, initialDate: DateTime(2016, DateTime.january, 15)), + ); + await tester.pumpAndSettle(); + + // Should be in year mode still; updating an _initial_ parameter has no effect. + expect(find.text('January 2016'), findsOneWidget); // Day/year selector + expect(find.text('15'), findsNothing); // day 15 in grid + expect(find.text('2016'), findsOneWidget); // 2016 in year grid + }); + + testWidgets('Dragging more than half the width should not cause a jump', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + calendarDatePicker(initialDate: DateTime(2016, DateTime.january, 15)), + ); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(PageView)), + ); + // This initial drag is required for the PageView to recognize the gesture, as it uses DragStartBehavior.start. + // It does not count towards the drag distance. + await gesture.moveBy(const Offset(100, 0)); + // Dragging for a bit less than half the width should reveal the previous month. + await gesture.moveBy(const Offset(800 / 2 - 1, 0)); + await tester.pumpAndSettle(); + expect(find.text('January 2016'), findsOneWidget); + expect(find.text('1'), findsNWidgets(2)); + // Dragging a bit over the half should still show both. + await gesture.moveBy(const Offset(2, 0)); + await tester.pumpAndSettle(); + expect(find.text('December 2015'), findsOneWidget); + expect(find.text('1'), findsNWidgets(2)); + }); + + group('Keyboard navigation', () { + testWidgets('Can toggle to year mode', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker(initialDate: DateTime(2016, DateTime.january, 15)), + ); + expect(find.text('2016'), findsNothing); + expect(find.text('January 2016'), findsOneWidget); + // Navigate to the year selector and activate it. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // The years should be visible. + expect(find.text('2016'), findsOneWidget); + expect(find.text('January 2016'), findsOneWidget); + }); + + testWidgets('Can navigate next/previous months', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker(initialDate: DateTime(2016, DateTime.january, 15)), + ); + expect(find.text('January 2016'), findsOneWidget); + // Navigate to the previous month button and activate it twice. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Should be showing Nov 2015 + expect(find.text('November 2015'), findsOneWidget); + + // Navigate to the next month button and activate it four times. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Should be on Mar 2016. + expect(find.text('March 2016'), findsOneWidget); + }); + + testWidgets('Can navigate date grid with arrow keys', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate from Jan 15 to Jan 18 with arrow keys. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it. + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18. + expect(selectedDate, DateTime(2016, DateTime.january, 18)); + }); + + testWidgets('Navigating with arrow keys scrolls months', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + ), + ); + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to Dec 31 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Should have scrolled to Dec 2015. + expect(find.text('December 2015'), findsOneWidget); + + // Navigate from Dec 31 to Nov 26 with arrow keys. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + // Should have scrolled to Nov 2015. + expect(find.text('November 2015'), findsOneWidget); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18. + expect(selectedDate, DateTime(2015, DateTime.november, 26)); + }); + + testWidgets('RTL text direction reverses the horizontal arrow key navigation', ( + WidgetTester tester, + ) async { + DateTime? selectedDate; + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + onDateChanged: (DateTime date) => selectedDate = date, + textDirection: TextDirection.rtl, + ), + ); + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to 19 with arrow keys. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it. + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 19. + expect(selectedDate, DateTime(2016, DateTime.january, 19)); + }); + }); + + group('Haptic feedback', () { + const hapticFeedbackInterval = Duration(milliseconds: 10); + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('Selecting date vibrates', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker(initialDate: DateTime(2016, DateTime.january, 15)), + ); + await tester.tap(find.text('10')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 1); + await tester.tap(find.text('12')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 2); + await tester.tap(find.text('14')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 3); + }); + + testWidgets('Tapping unselectable date does not vibrate', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 10), + selectableDayPredicate: (DateTime date) => date.day.isEven, + ), + ); + await tester.tap(find.text('11')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 0); + await tester.tap(find.text('13')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 0); + await tester.tap(find.text('15')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 0); + }); + + testWidgets('Changing modes and year vibrates', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker(initialDate: DateTime(2016, DateTime.january, 15)), + ); + await tester.tap(find.text('January 2016')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 1); + await tester.tap(find.text('2018')); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 2); + }); + }); + + group('Semantics', () { + testWidgets('day mode', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + calendarDatePicker(initialDate: DateTime(2016, DateTime.january, 15)), + ); + + // Year mode drop down button. + expect( + tester.getSemantics(find.text('January 2016')), + matchesSemantics( + label: 'Select year\nJanuary 2016', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + // Prev/Next month buttons. + expect( + tester.getSemantics(previousMonthIcon), + matchesSemantics( + tooltip: 'Previous month', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + expect( + tester.getSemantics(nextMonthIcon), + matchesSemantics( + tooltip: 'Next month', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + + // Day grid. + expect( + tester.getSemantics(find.text('1')), + matchesSemantics( + label: '1, Friday, January 1, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('2')), + matchesSemantics( + label: '2, Saturday, January 2, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('3')), + matchesSemantics( + label: '3, Sunday, January 3, 2016, Today', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('4')), + matchesSemantics( + label: '4, Monday, January 4, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('5')), + matchesSemantics( + label: '5, Tuesday, January 5, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('6')), + matchesSemantics( + label: '6, Wednesday, January 6, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('7')), + matchesSemantics( + label: '7, Thursday, January 7, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('8')), + matchesSemantics( + label: '8, Friday, January 8, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('9')), + matchesSemantics( + label: '9, Saturday, January 9, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('10')), + matchesSemantics( + label: '10, Sunday, January 10, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('11')), + matchesSemantics( + label: '11, Monday, January 11, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('12')), + matchesSemantics( + label: '12, Tuesday, January 12, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('13')), + matchesSemantics( + label: '13, Wednesday, January 13, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('14')), + matchesSemantics( + label: '14, Thursday, January 14, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('15')), + matchesSemantics( + label: '15, Friday, January 15, 2016', + isButton: true, + hasTapAction: true, + hasEnabledState: true, + hasSelectedState: true, + hasFocusAction: true, + isSelected: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('16')), + matchesSemantics( + label: '16, Saturday, January 16, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('17')), + matchesSemantics( + label: '17, Sunday, January 17, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('18')), + matchesSemantics( + label: '18, Monday, January 18, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('19')), + matchesSemantics( + label: '19, Tuesday, January 19, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('20')), + matchesSemantics( + label: '20, Wednesday, January 20, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('21')), + matchesSemantics( + label: '21, Thursday, January 21, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('22')), + matchesSemantics( + label: '22, Friday, January 22, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('23')), + matchesSemantics( + label: '23, Saturday, January 23, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('24')), + matchesSemantics( + label: '24, Sunday, January 24, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('25')), + matchesSemantics( + label: '25, Monday, January 25, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('26')), + matchesSemantics( + label: '26, Tuesday, January 26, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('27')), + matchesSemantics( + label: '27, Wednesday, January 27, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('28')), + matchesSemantics( + label: '28, Thursday, January 28, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('29')), + matchesSemantics( + label: '29, Friday, January 29, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + expect( + tester.getSemantics(find.text('30')), + matchesSemantics( + label: '30, Saturday, January 30, 2016', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('day mode disabled dates are announced', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + firstDate: DateTime(2016, DateTime.january, 15), + ), + ); + + expect( + tester.getSemantics(find.text('14')), + matchesSemantics( + label: '14, Thursday, January 14, 2016', + hasEnabledState: true, + hasSelectedState: true, + isButton: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('Disabled next and previous month buttons have meaningful labels', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, DateTime.january, 14), + initialDate: DateTime(2016, DateTime.january, 15), + lastDate: DateTime(2016, DateTime.january, 16), + ), + ); + + // Prev/Next month buttons. + expect( + tester.getSemantics(previousMonthIcon), + matchesSemantics(label: 'Previous month', isButton: true, hasEnabledState: true), + ); + expect( + tester.getSemantics(nextMonthIcon), + matchesSemantics(label: 'Next month', isButton: true, hasEnabledState: true), + ); + + semantics.dispose(); + }); + + testWidgets('calendar year mode', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + initialCalendarMode: DatePickerMode.year, + ), + ); + + // Year mode drop down button. + expect( + tester.getSemantics(find.text('January 2016')), + matchesSemantics( + label: 'Select year\nJanuary 2016', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + // Year grid only shows 2010 - 2024. + for (var year = 2010; year <= 2024; year++) { + expect( + tester.getSemantics(find.text('$year')), + matchesSemantics( + label: '$year', + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + isSelected: year == 2016, + hasSelectedState: true, + isFocusable: true, + isEnabled: true, + isButton: true, + ), + ); + } + semantics.dispose(); + }); + + testWidgets('calendar year mode disabled years are announced', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + calendarDatePicker( + initialDate: DateTime(2016, DateTime.january, 15), + firstDate: DateTime(2016, DateTime.january, 15), + initialCalendarMode: DatePickerMode.year, + ), + ); + + expect( + tester.getSemantics(find.text('2015')), + matchesSemantics( + label: '2015', + hasEnabledState: true, + hasSelectedState: true, + isButton: true, + ), + ); + semantics.dispose(); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/143439. + testWidgets( + 'Selected date Semantics announcement on onDateChanged', + (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + final initialDate = DateTime(2016, DateTime.january, 15); + DateTime? selectedDate; + + await tester.pumpWidget( + calendarDatePicker( + initialDate: initialDate, + onDateChanged: (DateTime value) { + selectedDate = value; + }, + ), + ); + + final bool isToday = DateUtils.isSameDay(initialDate, selectedDate); + final semanticLabelSuffix = isToday ? ', ${localizations.currentDateLabel}' : ''; + + // The initial date should be announced. + expect( + tester.takeAnnouncements().last, + isAccessibilityAnnouncement( + '${localizations.formatFullDate(initialDate)}$semanticLabelSuffix', + ), + ); + + // Select a new date. + await tester.tap(find.text('20')); + await tester.pumpAndSettle(); + + // The selected date should be announced. + expect( + tester.takeAnnouncements().last, + isAccessibilityAnnouncement( + '${localizations.selectedDateLabel} ${localizations.formatFullDate(selectedDate!)}$semanticLabelSuffix', + ), + ); + + // Select the initial date. + await tester.tap(find.text('15')); + + // The initial date should be announced as selected. + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement( + '${localizations.selectedDateLabel} ${localizations.formatFullDate(initialDate)}$semanticLabelSuffix', + ), + ); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.desktop(), + ); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/141350. + testWidgets('Default day selection overlay', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2016, DateTime.december, 15), + initialDate: DateTime(2017, DateTime.january, 15), + lastDate: DateTime(2017, DateTime.february, 15), + onDisplayedMonthChanged: (DateTime date) {}, + theme: theme, + ), + ); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + isNot( + paints..circle(radius: 35.0, color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ), + ); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 0)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('25'))); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints + ..circle() + ..circle(radius: 35.0, color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 1)); + + final expectedClipRect = Rect.fromCircle(center: const Offset(400.0, 241.0), radius: 35.0); + final expectedClipPath = Path()..addRect(expectedClipRect); + expect( + inkFeatures, + paints..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect, + sampleSize: 100, + ), + ), + ); + }); + + testWidgets('CalendarDatePicker renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox.shrink( + child: CalendarDatePicker( + initialDate: DateTime(2025), + firstDate: DateTime(2024), + lastDate: DateTime(2026), + onDateChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox.shrink( + child: CalendarDatePicker( + initialDate: DateTime(2025), + firstDate: DateTime(2024), + lastDate: DateTime(2026), + onDateChanged: (_) {}, + initialCalendarMode: DatePickerMode.year, + ), + ), + ), + ), + ); + }); + + testWidgets('Ink feature paints on inner Material', (WidgetTester tester) async { + await tester.pumpWidget( + calendarDatePicker( + firstDate: DateTime(2025, DateTime.june), + initialDate: DateTime(2025, DateTime.july, 20), + lastDate: DateTime(2025, DateTime.august, 31), + ), + ); + + // Material outside the PageView. + final MaterialInkController outerMaterial = Material.of( + tester.element(find.byType(FocusableActionDetector)), + ); + // Material directly wrapping the PageView. + final MaterialInkController innerMaterial = Material.of( + tester.element(find.byType(PageView)), + ); + + // Only the inner Material should have ink features. + expect((outerMaterial as dynamic).debugInkFeatures, isNull); + expect((innerMaterial as dynamic).debugInkFeatures, hasLength(31)); + }); + }); + + group('when supportsAnnounce is true, check announcement', () { + testWidgets('Initial date announcement', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + final initialDate = DateTime(2016, DateTime.january, 15); + final String expectedLabel = localizations.formatFullDate(initialDate); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(supportsAnnounce: true), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement(expectedLabel, textDirection: TextDirection.ltr), + ]); + + semantics.dispose(); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets( + 'CalendarDatePicker reports error when SemanticsService.sendAnnouncement fails during navigation', + (WidgetTester tester) async { + final errors = <FlutterErrorDetails>[]; + final void Function(FlutterErrorDetails)? originalOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + if (details.exception is TestFailure) { + originalOnError?.call(details); + return; + } + errors.add(details); + }; + addTearDown(() { + FlutterError.onError = originalOnError; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler( + SystemChannels.accessibility.name, + null, + ); + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler( + SystemChannels.accessibility.name, + (ByteData? message) async { + const codec = StandardMessageCodec(); + final Object? decoded = codec.decodeMessage(message); + if (decoded is Map && decoded['type'] == 'announce') { + final data = ByteData(1); + data.setUint8(0, 255); // Invalid type byte + return data; + } + return null; // Success for other events + }, + ); + + final initialDate = DateTime(2016, DateTime.january, 15); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(supportsAnnounce: true), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + await tester.tap(nextMonthIcon); + await tester.pump(); + + expect(errors, isNotEmpty); + final bool hasAnnouncementError = errors.any( + (e) => + e.exception.toString().contains('FormatException') && + e.context.toString().contains('while sending semantics announcement'), + ); + expect(hasAnnouncementError, isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Month navigation announcement on Android', + (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final initialDate = DateTime(2016, DateTime.january, 15); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(supportsAnnounce: true), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + // Clear any initial announcements. + tester.takeAnnouncements(); + + // Tap next month. + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + + const expectedLabel = 'February 2016'; + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement(expectedLabel, textDirection: TextDirection.ltr), + ]); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Mode toggle announcement on Android', + (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final initialDate = DateTime(2016, DateTime.january, 15); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(supportsAnnounce: true), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + // Clear any initial announcements. + tester.takeAnnouncements(); + + // Switch to year mode. + final Finder modeToggleButton = find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_DatePickerModeToggleButton', + ); + await tester.tap(modeToggleButton); + await tester.pumpAndSettle(); + + const yearLabel = '2016'; + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement(yearLabel, textDirection: TextDirection.ltr), + ]); + + // Switch back to day mode. + await tester.tap(modeToggleButton); + await tester.pumpAndSettle(); + + const dayLabel = 'January 2016'; + expect(tester.takeAnnouncements(), [ + isAccessibilityAnnouncement(dayLabel, textDirection: TextDirection.ltr), + ]); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + }); + + group('when supportsAnnounce is false, use live region to announce', () { + testWidgets('Initial date announcement', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + final initialDate = DateTime(2016, DateTime.january, 15); + final String expectedLabel = localizations.formatFullDate(initialDate); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + // Verify that the live region exists and has the correct label. + final Finder liveRegionFinder = find.byWidgetPredicate( + (Widget widget) => + widget is Semantics && + widget.properties.label == expectedLabel && + (widget.properties.liveRegion ?? false), + ); + expect( + tester.getSemantics(liveRegionFinder), + matchesSemantics(label: expectedLabel, isLiveRegion: true), + ); + + semantics.dispose(); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets( + 'Month navigation announcement on Android', + (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final initialDate = DateTime(2016, DateTime.january, 15); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + // Tap next month. + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + + // The _MonthPicker live region should be updated. + // Verify that the live region exists and has the correct label. + const expectedLabel = 'February 2016'; + final Finder liveRegionFinder = find.byWidgetPredicate( + (Widget widget) => + widget is Semantics && + widget.properties.label == expectedLabel && + (widget.properties.liveRegion ?? false), + ); + expect( + tester.getSemantics(liveRegionFinder), + matchesSemantics(label: expectedLabel, isLiveRegion: true), + ); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Mode toggle announcement on Android', + (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final initialDate = DateTime(2016, DateTime.january, 15); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: calendarDatePicker(initialDate: initialDate), + ), + ); + + // Switch to year mode. + final Finder modeToggleButton = find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_DatePickerModeToggleButton', + ); + await tester.tap(modeToggleButton); + await tester.pumpAndSettle(); + + // Verify that the live region exists and has the correct label. + const yearLabel = '2016'; + final Finder yearLiveRegionFinder = find.byWidgetPredicate( + (Widget widget) => + widget is Semantics && + widget.properties.label == yearLabel && + (widget.properties.liveRegion ?? false), + ); + expect( + tester.getSemantics(yearLiveRegionFinder), + matchesSemantics(label: yearLabel, isLiveRegion: true), + ); + + // Switch back to day mode. + await tester.tap(modeToggleButton); + await tester.pumpAndSettle(); + + // Verify that the live region exists and has the correct label. + const dayLabel = 'January 2016'; + final Finder dayLiveRegionFinder = find.byWidgetPredicate( + (Widget widget) => + widget is Semantics && + widget.properties.label == dayLabel && + (widget.properties.liveRegion ?? false), + ); + expect( + tester.getSemantics(dayLiveRegionFinder), + matchesSemantics(label: dayLabel, isLiveRegion: true), + ); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + }); + + group('YearPicker', () { + testWidgets('Current year is visible in year picker', (WidgetTester tester) async { + await tester.pumpWidget(yearPicker()); + expect(find.text('2016'), findsOneWidget); + }); + + testWidgets('Can select a year', (WidgetTester tester) async { + DateTime? selectedDate; + await tester.pumpWidget(yearPicker(onChanged: (DateTime date) => selectedDate = date)); + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(selectedDate, equals(DateTime(2018))); + }); + + testWidgets('Cannot select disabled year', (WidgetTester tester) async { + DateTime? selectedYear; + await tester.pumpWidget( + yearPicker( + firstDate: DateTime(2018, DateTime.june, 9), + selectedDate: DateTime(2018, DateTime.july, 4), + lastDate: DateTime(2018, DateTime.december, 15), + onChanged: (DateTime date) => selectedYear = date, + ), + ); + await tester.tap(find.text('2016')); // Disabled, doesn't change the year. + await tester.pumpAndSettle(); + expect(selectedYear, isNull); + await tester.tap(find.text('2020')); // Disabled, doesn't change the year. + await tester.pumpAndSettle(); + expect(selectedYear, isNull); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(selectedYear, equals(DateTime(2018, DateTime.july))); + }); + + testWidgets('Selecting year with no selected month uses earliest month', ( + WidgetTester tester, + ) async { + DateTime? selectedYear; + await tester.pumpWidget( + yearPicker( + firstDate: DateTime(2018, DateTime.june, 9), + lastDate: DateTime(2019, DateTime.december, 15), + onChanged: (DateTime date) => selectedYear = date, + ), + ); + await tester.tap(find.text('2018')); + expect(selectedYear, equals(DateTime(2018, DateTime.june))); + await tester.pumpWidget( + yearPicker( + firstDate: DateTime(2018, DateTime.june, 9), + lastDate: DateTime(2019, DateTime.december, 15), + selectedDate: DateTime(2018, DateTime.june), + onChanged: (DateTime date) => selectedYear = date, + ), + ); + await tester.tap(find.text('2019')); + expect(selectedYear, equals(DateTime(2019, DateTime.june))); + }); + + testWidgets('Selecting year with no selected month uses January', (WidgetTester tester) async { + DateTime? selectedYear; + await tester.pumpWidget( + yearPicker( + firstDate: DateTime(2018, DateTime.june, 9), + lastDate: DateTime(2019, DateTime.december, 15), + onChanged: (DateTime date) => selectedYear = date, + ), + ); + await tester.tap(find.text('2019')); + expect(selectedYear, equals(DateTime(2019))); // january implied + await tester.pumpWidget( + yearPicker( + firstDate: DateTime(2018, DateTime.june, 9), + lastDate: DateTime(2019, DateTime.december, 15), + selectedDate: DateTime(2018), + onChanged: (DateTime date) => selectedYear = date, + ), + ); + await tester.tap(find.text('2018')); + expect(selectedYear, equals(DateTime(2018, DateTime.june))); + }); + + testWidgets('YearPicker renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox.shrink( + child: YearPicker( + selectedDate: DateTime(2025), + firstDate: DateTime(2024), + lastDate: DateTime(2026), + onChanged: (_) {}, + ), + ), + ), + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/155198. + testWidgets('Ink features are painted on inner Material', (WidgetTester tester) async { + await tester.pumpWidget( + yearPicker( + firstDate: DateTime(2020), + lastDate: DateTime(2030), + selectedDate: DateTime(2025), + ), + ); + + expect(find.byType(Material), findsNWidgets(2)); + + // Material outside the GridView. + final MaterialInkController outerMaterial = Material.of( + tester.element(find.byType(YearPicker)), + ); + // Material directly wrapping the GridView. + final MaterialInkController innerMaterial = Material.of( + tester.element(find.byType(GridView)), + ); + + expect(outerMaterial, isNot(same(innerMaterial))); + expect((outerMaterial as dynamic).debugInkFeatures, isNull); + expect((innerMaterial as dynamic).debugInkFeatures, isNull); + + // Hover over the 2022 year item to trigger the ink highlight. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.text('2022'))); + addTearDown(gesture.removePointer); + await tester.pump(); + + // Only the inner Material should have ink features. + expect((outerMaterial as dynamic).debugInkFeatures, isNull); + expect((innerMaterial as dynamic).debugInkFeatures, hasLength(1)); + }); + }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CalendarDatePicker( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2025, DateTime.may), + onDateChanged: (DateTime value) {}, + ), + ), + ), + ); + + final CalendarDatePicker calendarPicker = tester.widget(find.byType(CalendarDatePicker)); + expect(calendarPicker.calendarDelegate, isA<GregorianCalendarDelegate>()); + + final Finder datePickerModeToggleButton = find.descendant( + of: find.byType(InkWell), + matching: find.text('February 2025'), + ); + await tester.tap(datePickerModeToggleButton); + await tester.pumpAndSettle(); + + final YearPicker yearPicker = tester.widget(find.byType(YearPicker)); + expect(yearPicker.calendarDelegate, isA<GregorianCalendarDelegate>()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CalendarDatePicker( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2025, DateTime.may), + onDateChanged: (DateTime value) {}, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final CalendarDatePicker calendarPicker = tester.widget(find.byType(CalendarDatePicker)); + expect(calendarPicker.calendarDelegate, isA<TestCalendarDelegate>()); + + final Finder datePickerModeToggleButton = find.descendant( + of: find.byType(InkWell), + matching: find.text('February 2025'), + ); + await tester.tap(datePickerModeToggleButton); + await tester.pumpAndSettle(); + + final YearPicker yearPicker = tester.widget(find.byType(YearPicker)); + expect(yearPicker.calendarDelegate, isA<TestCalendarDelegate>()); + }); + }); +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/material_ui/test/material/card_test.dart b/packages/material_ui/test/material/card_test.dart new file mode 100644 index 000000000000..2f68eb2517ea --- /dev/null +++ b/packages/material_ui/test/material/card_test.dart @@ -0,0 +1,304 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Material3 - Card defaults (Elevated card)', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Card()), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, Clip.none); + expect(material.elevation, 1.0); + expect(padding.padding, const EdgeInsets.all(4.0)); + expect(material.color, colors.surfaceContainerLow); + expect(material.shadowColor, colors.shadow); + expect( + material.surfaceTintColor, + Colors.transparent, + ); // Don't use surface tint. Toned surface container is used instead. + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + }); + + testWidgets('Material3 - Card.filled defaults', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Card.filled()), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, Clip.none); + expect(material.elevation, 0.0); + expect(padding.padding, const EdgeInsets.all(4.0)); + expect(material.color, colors.surfaceContainerHighest); + expect(material.shadowColor, colors.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + }); + + testWidgets('Material3 - Card.outlined defaults', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Card.outlined()), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, Clip.none); + expect(material.elevation, 0.0); + expect(padding.padding, const EdgeInsets.all(4.0)); + expect(material.color, colors.surface); + expect(material.shadowColor, colors.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect( + material.shape, + RoundedRectangleBorder( + side: BorderSide(color: colors.outlineVariant), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + ); + }); + + testWidgets('Card can take semantic text from multiple children', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Card( + semanticContainer: false, + child: Column( + children: <Widget>[ + const Text('I am text!'), + const Text('Moar text!!1'), + ElevatedButton(onPressed: () {}, child: const Text('Button')), + ], + ), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics(id: 1, label: 'I am text!', textDirection: TextDirection.ltr), + TestSemantics(id: 2, label: 'Moar text!!1', textDirection: TextDirection.ltr), + TestSemantics( + id: 3, + label: 'Button', + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Card merges children when it is a semanticContainer', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + debugResetSemanticsIdCounter(); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Card( + child: Column(children: <Widget>[Text('First child'), Text('Second child')]), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + label: 'First child\nSecond child', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Card margin', (WidgetTester tester) async { + const Key contentsKey = ValueKey<String>('contents'); + + await tester.pumpWidget( + Container( + alignment: Alignment.topLeft, + child: Card( + child: Container( + key: contentsKey, + color: const Color(0xFF00FF00), + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + // Default margin is 4 + expect(tester.getTopLeft(find.byType(Card)), Offset.zero); + expect(tester.getSize(find.byType(Card)), const Size(108.0, 108.0)); + + expect(tester.getTopLeft(find.byKey(contentsKey)), const Offset(4.0, 4.0)); + expect(tester.getSize(find.byKey(contentsKey)), const Size(100.0, 100.0)); + + await tester.pumpWidget( + Container( + alignment: Alignment.topLeft, + child: Card( + margin: EdgeInsets.zero, + child: Container( + key: contentsKey, + color: const Color(0xFF00FF00), + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + // Specified margin is zero + expect(tester.getTopLeft(find.byType(Card)), Offset.zero); + expect(tester.getSize(find.byType(Card)), const Size(100.0, 100.0)); + + expect(tester.getTopLeft(find.byKey(contentsKey)), Offset.zero); + expect(tester.getSize(find.byKey(contentsKey)), const Size(100.0, 100.0)); + }); + + testWidgets('Card clipBehavior property passes through to the Material', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const Card()); + expect(tester.widget<Material>(find.byType(Material)).clipBehavior, Clip.none); + + await tester.pumpWidget(const Card(clipBehavior: Clip.antiAlias)); + expect(tester.widget<Material>(find.byType(Material)).clipBehavior, Clip.antiAlias); + }); + + testWidgets('Card clipBehavior property defers to theme when null', (WidgetTester tester) async { + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final ThemeData themeData = Theme.of(context); + return Theme( + data: themeData.copyWith( + cardTheme: themeData.cardTheme.copyWith(clipBehavior: Clip.antiAliasWithSaveLayer), + ), + child: const Card(), + ); + }, + ), + ); + expect( + tester.widget<Material>(find.byType(Material)).clipBehavior, + Clip.antiAliasWithSaveLayer, + ); + }); + + testWidgets('Card shadowColor', (WidgetTester tester) async { + Material getCardMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Card), matching: find.byType(Material)), + ); + } + + Card getCard(WidgetTester tester) { + return tester.widget<Card>(find.byType(Card)); + } + + await tester.pumpWidget(const Card()); + + expect(getCard(tester).shadowColor, null); + expect(getCardMaterial(tester).shadowColor, const Color(0xFF000000)); + + await tester.pumpWidget(const Card(shadowColor: Colors.red)); + + expect(getCardMaterial(tester).shadowColor, getCard(tester).shadowColor); + expect(getCardMaterial(tester).shadowColor, Colors.red); + }); + + testWidgets('Card renders at zero size', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: Card(child: Text('X'))), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); +} + +Material _getCardMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Card), matching: find.byType(Material)), + ); +} + +Padding _getCardPadding(WidgetTester tester) { + return tester.widget<Padding>( + find.descendant(of: find.byType(Card), matching: find.byType(Padding)), + ); +} diff --git a/packages/material_ui/test/material/card_theme_test.dart b/packages/material_ui/test/material/card_theme_test.dart new file mode 100644 index 000000000000..3cae27cbc5bd --- /dev/null +++ b/packages/material_ui/test/material/card_theme_test.dart @@ -0,0 +1,451 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('CardThemeData copyWith, ==, hashCode basics', () { + expect(const CardThemeData(), const CardThemeData().copyWith()); + expect(const CardThemeData().hashCode, const CardThemeData().copyWith().hashCode); + }); + + test('CardThemeData lerp special cases', () { + expect(CardThemeData.lerp(null, null, 0), const CardThemeData()); + const theme = CardThemeData(); + expect(identical(CardThemeData.lerp(theme, theme, 0.5), theme), true); + }); + + test('CardThemeData defaults', () { + const cardThemeData = CardThemeData(); + + expect(cardThemeData.clipBehavior, null); + expect(cardThemeData.color, null); + expect(cardThemeData.elevation, null); + expect(cardThemeData.margin, null); + expect(cardThemeData.shadowColor, null); + expect(cardThemeData.shape, null); + expect(cardThemeData.surfaceTintColor, null); + + const cardTheme = CardTheme(data: CardThemeData(), child: SizedBox()); + expect(cardTheme.clipBehavior, null); + expect(cardTheme.color, null); + expect(cardTheme.elevation, null); + expect(cardTheme.margin, null); + expect(cardTheme.shadowColor, null); + expect(cardTheme.shape, null); + expect(cardTheme.surfaceTintColor, null); + }); + + testWidgets('Default CardThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CardThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('CardThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CardThemeData( + clipBehavior: Clip.antiAlias, + color: Colors.amber, + elevation: 10.5, + margin: EdgeInsets.all(20.5), + shadowColor: Colors.green, + surfaceTintColor: Colors.purple, + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20.5))), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'clipBehavior: Clip.antiAlias'); + expect(description[1], 'color: MaterialColor(primary value: ${const Color(0xffffc107)})'); + expect(description[2], 'shadowColor: MaterialColor(primary value: ${const Color(0xff4caf50)})'); + expect( + description[3], + 'surfaceTintColor: MaterialColor(primary value: ${const Color(0xff9c27b0)})', + ); + expect(description[4], 'elevation: 10.5'); + expect(description[5], 'margin: EdgeInsets.all(20.5)'); + expect( + description[6], + 'shape: BeveledRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(20.5))', + ); + }); + + testWidgets('Material3 - Passing no CardTheme returns defaults', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Card()), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, Clip.none); + expect(material.color, theme.colorScheme.surfaceContainerLow); + expect(material.shadowColor, theme.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); // Default primary color + expect(material.elevation, 1.0); + expect(padding.padding, const EdgeInsets.all(4.0)); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + }); + + testWidgets('Card uses values from CardTheme', (WidgetTester tester) async { + final CardThemeData cardTheme = _cardTheme(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(cardTheme: cardTheme), + home: const Scaffold(body: Card()), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, cardTheme.clipBehavior); + expect(material.color, cardTheme.color); + expect(material.shadowColor, cardTheme.shadowColor); + expect(material.surfaceTintColor, cardTheme.surfaceTintColor); + expect(material.elevation, cardTheme.elevation); + expect(padding.padding, cardTheme.margin); + expect(material.shape, cardTheme.shape); + }); + + testWidgets('Card widget properties take priority over theme', (WidgetTester tester) async { + const Clip clip = Clip.hardEdge; + const Color color = Colors.orange; + const Color shadowColor = Colors.pink; + const elevation = 7.0; + const margin = EdgeInsets.all(3.0); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: _themeData().copyWith(cardTheme: _cardTheme()), + home: const Scaffold( + body: Card( + clipBehavior: clip, + color: color, + shadowColor: shadowColor, + elevation: elevation, + margin: margin, + shape: shape, + ), + ), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, clip); + expect(material.color, color); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(padding.padding, margin); + expect(material.shape, shape); + }); + + testWidgets('CardTheme properties take priority over ThemeData properties', ( + WidgetTester tester, + ) async { + final CardThemeData cardTheme = _cardTheme(); + final ThemeData themeData = _themeData().copyWith(cardTheme: cardTheme); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold(body: Card()), + ), + ); + + final Material material = _getCardMaterial(tester); + expect(material.color, cardTheme.color); + }); + + testWidgets('Material3 - ThemeData properties are used when no CardTheme is set', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold(body: Card()), + ), + ); + + final Material material = _getCardMaterial(tester); + expect(material.color, themeData.colorScheme.surfaceContainerLow); + }); + + testWidgets('Material3 - CardTheme customizes shape', (WidgetTester tester) async { + const cardTheme = CardThemeData( + color: Colors.white, + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(7))), + elevation: 1.0, + ); + + final Key painterKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(cardTheme: cardTheme), + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Center( + child: Card(child: SizedBox.fromSize(size: const Size(200, 300))), + ), + ), + ), + ), + ); + + await expectLater(find.byKey(painterKey), matchesGoldenFile('card_theme.custom_shape.png')); + }); + + testWidgets('Card properties are taken over the theme values', (WidgetTester tester) async { + const Clip themeClipBehavior = Clip.antiAlias; + const Color themeColor = Colors.red; + const Color themeShadowColor = Colors.orange; + const themeElevation = 10.0; + const themeMargin = EdgeInsets.all(12.0); + const ShapeBorder themeShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15.0)), + ); + + const Clip clipBehavior = Clip.hardEdge; + const Color color = Colors.yellow; + const Color shadowColor = Colors.green; + const elevation = 20.0; + const margin = EdgeInsets.all(18.0); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25.0)), + ); + + final themeData = ThemeData( + cardTheme: const CardThemeData( + clipBehavior: themeClipBehavior, + color: themeColor, + shadowColor: themeShadowColor, + elevation: themeElevation, + margin: themeMargin, + shape: themeShape, + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: Card( + clipBehavior: clipBehavior, + color: color, + shadowColor: shadowColor, + elevation: elevation, + margin: margin, + shape: shape, + child: SizedBox(width: 200, height: 200), + ), + ), + ), + ); + + final Padding cardMargin = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, clipBehavior); + expect(material.color, color); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(cardMargin.padding, margin); + }); + + testWidgets('Local CardTheme can override global CardTheme', (WidgetTester tester) async { + const Clip globalClipBehavior = Clip.antiAlias; + const Color globalColor = Colors.red; + const Color globalShadowColor = Colors.orange; + const globalElevation = 10.0; + const globalMargin = EdgeInsets.all(12.0); + const ShapeBorder globalShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15.0)), + ); + + const Clip localClipBehavior = Clip.hardEdge; + const Color localColor = Colors.yellow; + const Color localShadowColor = Colors.green; + const localElevation = 20.0; + const localMargin = EdgeInsets.all(18.0); + const ShapeBorder localShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25.0)), + ); + + final themeData = ThemeData( + cardTheme: const CardThemeData( + clipBehavior: globalClipBehavior, + color: globalColor, + shadowColor: globalShadowColor, + elevation: globalElevation, + margin: globalMargin, + shape: globalShape, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: CardTheme( + data: CardThemeData( + clipBehavior: localClipBehavior, + color: localColor, + shadowColor: localShadowColor, + elevation: localElevation, + margin: localMargin, + shape: localShape, + ), + child: Card(child: SizedBox(width: 200, height: 200)), + ), + ), + ), + ); + + final Padding cardMargin = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, localClipBehavior); + expect(material.color, localColor); + expect(material.shadowColor, localShadowColor); + expect(material.elevation, localElevation); + expect(material.shape, localShape); + expect(cardMargin.padding, localMargin); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Material2 - ThemeData properties are used when no CardTheme is set', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold(body: Card()), + ), + ); + + final Material material = _getCardMaterial(tester); + expect(material.color, themeData.cardColor); + }); + + testWidgets('Material2 - Passing no CardTheme returns defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(body: Card()), + ), + ); + + final Padding padding = _getCardPadding(tester); + final Material material = _getCardMaterial(tester); + + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.white); + expect(material.shadowColor, Colors.black); + expect(material.surfaceTintColor, null); + expect(material.elevation, 1.0); + expect(padding.padding, const EdgeInsets.all(4.0)); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + }); + + testWidgets('Material2 - CardTheme customizes shape', (WidgetTester tester) async { + const cardTheme = CardThemeData( + color: Colors.white, + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(7))), + elevation: 1.0, + ); + + final Key painterKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(cardTheme: cardTheme, useMaterial3: false), + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Center( + child: Card(child: SizedBox.fromSize(size: const Size(200, 300))), + ), + ), + ), + ), + ); + + await expectLater( + find.byKey(painterKey), + matchesGoldenFile('card_theme.custom_shape_m2.png'), + ); + }); + }); +} + +CardThemeData _cardTheme() { + return const CardThemeData( + clipBehavior: Clip.antiAlias, + color: Colors.green, + shadowColor: Colors.red, + surfaceTintColor: Colors.purple, + elevation: 6.0, + margin: EdgeInsets.all(7.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5.0))), + ); +} + +ThemeData _themeData() { + return ThemeData(cardColor: Colors.pink); +} + +Material _getCardMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Card), matching: find.byType(Material)), + ); +} + +Padding _getCardPadding(WidgetTester tester) { + return tester.widget<Padding>( + find.descendant(of: find.byType(Card), matching: find.byType(Padding)), + ); +} diff --git a/packages/material_ui/test/material/carousel_test.dart b/packages/material_ui/test/material/carousel_test.dart new file mode 100644 index 000000000000..0d40cb5782b1 --- /dev/null +++ b/packages/material_ui/test/material/carousel_test.dart @@ -0,0 +1,3022 @@ +// 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:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CarouselView defaults', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + itemExtent: 200, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Finder carouselViewMaterial = find + .descendant(of: find.byType(CarouselView), matching: find.byType(Material)) + .first; + + final Material material = tester.widget<Material>(carouselViewMaterial); + expect(material.clipBehavior, Clip.antiAlias); + expect(material.color, colorScheme.surface); + expect(material.elevation, 0.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + ); + }); + + testWidgets('CarouselView items customization', (WidgetTester tester) async { + final Key key = UniqueKey(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + padding: const EdgeInsets.all(20.0), + backgroundColor: Colors.amber, + elevation: 10.0, + shape: const StadiumBorder(), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return Colors.yellow; + } + if (states.contains(WidgetState.hovered)) { + return Colors.red; + } + if (states.contains(WidgetState.focused)) { + return Colors.purple; + } + return null; + }), + itemExtent: 200, + children: List<Widget>.generate(10, (int index) { + if (index == 0) { + return Center( + key: key, + child: Center(child: Text('Item $index')), + ); + } + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Finder carouselViewMaterial = find + .descendant(of: find.byType(CarouselView), matching: find.byType(Material)) + .first; + + expect( + tester.getSize(carouselViewMaterial).width, + 200 - 20 - 20, + ); // Padding is 20 on both side. + final Material material = tester.widget<Material>(carouselViewMaterial); + expect(material.color, Colors.amber); + expect(material.elevation, 10.0); + expect(material.shape, const StadiumBorder()); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // On hovered. + final TestGesture gesture = await hoverPointerOverCarouselItem(tester, key); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: Colors.red.withOpacity(1.0))); + + // On pressed. + await tester.pumpAndSettle(); + await gesture.down(tester.getCenter(find.byKey(key))); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints + ..rect() + ..rect(color: Colors.yellow.withOpacity(1.0)), + ); + + await tester.pumpAndSettle(); + await gesture.up(); + await gesture.removePointer(); + + // On focused. + final Element inkWellElement = tester.element( + find.descendant(of: carouselViewMaterial, matching: find.byType(InkWell)), + ); + expect(inkWellElement.widget, isA<InkWell>()); + final inkWell = inkWellElement.widget as InkWell; + + const WidgetState state = WidgetState.focused; + + // Check overlay color in focused state. + expect(inkWell.overlayColor?.resolve(<WidgetState>{state}), Colors.purple); + }); + + testWidgets('CarouselView respects onTap', (WidgetTester tester) async { + final keys = List<Key>.generate(10, (_) => UniqueKey()); + final theme = ThemeData(); + var tapIndex = 0; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + itemExtent: 50, + onTap: (int index) { + tapIndex = index; + }, + children: List<Widget>.generate(10, (int index) { + return Center(key: keys[index], child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Finder item1 = find.byKey(keys[1]); + await tester.tap(find.ancestor(of: item1, matching: find.byType(Stack))); + await tester.pump(); + expect(tapIndex, 1); + + final Finder item2 = find.byKey(keys[2]); + await tester.tap(find.ancestor(of: item2, matching: find.byType(Stack))); + await tester.pump(); + expect(tapIndex, 2); + }); + + testWidgets('CarouselView layout (Uncontained layout)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 250, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 0'), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 250.0, 600.0)); + + expect(find.text('Item 1'), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + expect(rect1, const Rect.fromLTRB(250.0, 0.0, 500.0, 600.0)); + + expect(find.text('Item 2'), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + expect(rect2, const Rect.fromLTRB(500.0, 0.0, 750.0, 600.0)); + + expect(find.text('Item 3'), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + expect(rect3, const Rect.fromLTRB(750.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + }); + + testWidgets('CarouselView.weighted layout', (WidgetTester tester) async { + Widget buildCarouselView({required List<int> weights}) { + return MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: weights, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarouselView(weights: <int>[4, 3, 2, 1])); + + final Size viewportSize = MediaQuery.of(tester.element(find.byType(CarouselView))).size; + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 0'), findsOneWidget); + Rect rect0 = tester.getRect(getItem(0)); + // Item width is 4/10 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 320.0, 600.0)); + + expect(find.text('Item 1'), findsOneWidget); + Rect rect1 = tester.getRect(getItem(1)); + // Item width is 3/10 of the viewport. + expect(rect1, const Rect.fromLTRB(320.0, 0.0, 560.0, 600.0)); + + expect(find.text('Item 2'), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item width is 2/10 of the viewport. + expect(rect2, const Rect.fromLTRB(560.0, 0.0, 720.0, 600.0)); + + expect(find.text('Item 3'), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item width is 1/10 of the viewport. + expect(rect3, const Rect.fromLTRB(720.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + + // Test shorter weight list. + await tester.pumpWidget(buildCarouselView(weights: <int>[7, 1])); + await tester.pumpAndSettle(); + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 0'), findsOneWidget); + rect0 = tester.getRect(getItem(0)); + // Item width is 7/8 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + + expect(find.text('Item 1'), findsOneWidget); + rect1 = tester.getRect(getItem(1)); + // Item width is 1/8 of the viewport. + expect(rect1, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 2'), findsNothing); + }); + + testWidgets('CarouselController initialItem', (WidgetTester tester) async { + final controller = CarouselController(initialItem: 5); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + controller: controller, + itemExtent: 400, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 5'), findsOneWidget); + final Rect rect5 = tester.getRect(getItem(5)); + // Item width is 400. + expect(rect5, const Rect.fromLTRB(0.0, 0.0, 400.0, 600.0)); + + expect(find.text('Item 6'), findsOneWidget); + final Rect rect6 = tester.getRect(getItem(6)); + // Item width is 400. + expect(rect6, const Rect.fromLTRB(400.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets('CarouselView.weighted respects CarouselController.initialItem', ( + WidgetTester tester, + ) async { + final controller = CarouselController(initialItem: 5); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: controller, + flexWeights: const <int>[7, 1], + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.of(tester.element(find.byType(CarouselView))).size; + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 5'), findsOneWidget); + final Rect rect5 = tester.getRect(getItem(5)); + // Item width is 7/8 of the viewport. + expect(rect5, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + + expect(find.text('Item 6'), findsOneWidget); + final Rect rect6 = tester.getRect(getItem(6)); + // Item width is 1/8 of the viewport. + expect(rect6, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets('The initialItem should be the first item with expanded size(max extent)', ( + WidgetTester tester, + ) async { + final controller = CarouselController(initialItem: 5); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: controller, + flexWeights: const <int>[1, 8, 1], + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.of(tester.element(find.byType(CarouselView))).size; + expect(viewportSize, const Size(800, 600)); + + // Item 5 should have be the expanded item. + expect(find.text('Item 5'), findsOneWidget); + final Rect rect5 = tester.getRect(getItem(5)); + // Item width is 8/10 of the viewport. + expect(rect5, const Rect.fromLTRB(80.0, 0.0, 720.0, 600.0)); + + expect(find.text('Item 6'), findsOneWidget); + final Rect rect6 = tester.getRect(getItem(6)); + // Item width is 1/10 of the viewport. + expect(rect6, const Rect.fromLTRB(720.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsOneWidget); + final Rect rect4 = tester.getRect(getItem(4)); + // Item width is 1/10 of the viewport. + expect(rect4, const Rect.fromLTRB(0.0, 0.0, 80.0, 600.0)); + + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets('CarouselView respects itemSnapping', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemSnapping: true, + itemExtent: 300, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + void checkOriginalExpectations() { + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + } + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(-150, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(100, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap to the next item. + await tester.drag(getItem(0), const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + }); + + testWidgets('CarouselView.weighted respects itemSnapping', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + itemSnapping: true, + consumeMaxWeight: false, + flexWeights: const <int>[1, 7], + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + void checkOriginalExpectations() { + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsNothing); + } + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(-20, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(50, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap to the next item. + await tester.drag(getItem(0), const Offset(-70, 0)); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + }); + + testWidgets('CarouselView respect itemSnapping when fling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemSnapping: true, + itemExtent: 300, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + // Show item 0, 1, and 2. + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + + // Snap to the next item. Show item 1, 2 and 3. + await tester.fling(getItem(0), const Offset(-100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + + // Snap to the next item. Show item 2, 3 and 4. + await tester.fling(getItem(1), const Offset(-100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsNothing); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + expect(getItem(5), findsNothing); + + // Fling back to the previous item. Show item 1, 2 and 3. + await tester.fling(getItem(2), const Offset(100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + }); + + testWidgets('CarouselView.weighted respect itemSnapping when fling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + itemSnapping: true, + consumeMaxWeight: false, + flexWeights: const <int>[1, 8, 1], + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('$index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + Finder getItem(int index) => find.descendant( + of: find.byType(CarouselView), + matching: find.ancestor(of: find.text('$index'), matching: find.byType(Padding)), + ); + + // Show item 0, 1, and 2. + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + + // Should snap to item 2 because of a long drag(-100). Show item 2, 3 and 4. + await tester.fling(getItem(0), const Offset(-100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsNothing); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + + // Fling to the next item (item 3). Show item 3, 4 and 5. + await tester.fling(getItem(2), const Offset(-50, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(2), findsNothing); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + expect(getItem(5), findsOneWidget); + + // Fling back to the previous item. Show item 2, 3 and 4. + await tester.fling(getItem(3), const Offset(50, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + expect(getItem(5), findsNothing); + }); + + testWidgets('CarouselView respects scrollingDirection: Axis.vertical', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + padding: EdgeInsets.zero, + scrollDirection: Axis.vertical, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item width is 200 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0)); + + // Simulate a scroll up + await tester.drag( + find.byType(CarouselView), + const Offset(0, -200), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(3), findsOneWidget); + }); + + testWidgets('CarouselView.weighted respects scrollingDirection: Axis.vertical', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[3, 2, 1], + padding: EdgeInsets.zero, + scrollDirection: Axis.vertical, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item width is 3/6 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 800.0, 300.0)); + + // Simulate a scroll up + await tester.drag( + find.byType(CarouselView), + const Offset(0, -300), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(3), findsOneWidget); + }); + + testWidgets( + 'CarouselView.weighted respects scrollingDirection: Axis.vertical + itemSnapping: true', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + itemSnapping: true, + flexWeights: const <int>[3, 2, 1], + scrollDirection: Axis.vertical, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item width is 3/6 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 800.0, 300.0)); + + // Simulate a scroll up but less than half of the leading item, the leading + // item should go back to the original position because itemSnapping is set + // to true. + await tester.drag( + find.byType(CarouselView), + const Offset(0, -149), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + + // Simulate a scroll up more than half of the leading item, the leading + // item continue to scrolling and will disappear when animation ends because + // itemSnapping is set to true. + await tester.drag( + find.byType(CarouselView), + const Offset(0, -151), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(3), findsOneWidget); + }, + ); + + testWidgets('CarouselView respects reverse', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + reverse: true, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + // Item 0 should be placed on the end of the screen. + expect(rect0, const Rect.fromLTRB(600.0, 0.0, 800.0, 600.0)); + + expect(getItem(1), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + // Item 1 should be placed before item 0. + expect(rect1, const Rect.fromLTRB(400.0, 0.0, 600.0, 600.0)); + + expect(getItem(2), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item 2 should be placed before item 1. + expect(rect2, const Rect.fromLTRB(200.0, 0.0, 400.0, 600.0)); + + expect(getItem(3), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item 3 should be placed before item 2. + expect(rect3, const Rect.fromLTRB(0.0, 0.0, 200.0, 600.0)); + }); + + testWidgets('CarouselView.weighted respects reverse', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[4, 3, 2, 1], + reverse: true, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + // Item 0 should be placed on the end of the screen. + const int item0Width = 80 * 4; + expect(rect0, const Rect.fromLTRB(800.0 - item0Width, 0.0, 800.0, 600.0)); + + expect(getItem(1), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + // Item 1 should be placed before item 0. + const int item1Width = 80 * 3; + expect( + rect1, + const Rect.fromLTRB(800.0 - item0Width - item1Width, 0.0, 800.0 - item0Width, 600.0), + ); + + expect(getItem(2), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item 2 should be placed before item 1. + const int item2Width = 80 * 2; + expect( + rect2, + const Rect.fromLTRB( + 800.0 - item0Width - item1Width - item2Width, + 0.0, + 800.0 - item0Width - item1Width, + 600.0, + ), + ); + + expect(getItem(3), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item 3 should be placed before item 2. + expect( + rect3, + const Rect.fromLTRB(0.0, 0.0, 800.0 - item0Width - item1Width - item2Width, 600.0), + ); + }); + + testWidgets('CarouselView.weighted respects reverse + vertical scroll direction', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + reverse: true, + flexWeights: const <int>[4, 3, 2, 1], + scrollDirection: Axis.vertical, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + // Item 0 should be placed on the end of the screen. + const int item0Height = 60 * 4; + expect(rect0, const Rect.fromLTRB(0.0, 600.0 - item0Height, 800.0, 600.0)); + + expect(getItem(1), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + // Item 1 should be placed before item 0. + const int item1Height = 60 * 3; + expect( + rect1, + const Rect.fromLTRB(0.0, 600.0 - item0Height - item1Height, 800.0, 600.0 - item0Height), + ); + + expect(getItem(2), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item 2 should be placed before item 1. + const int item2Height = 60 * 2; + expect( + rect2, + const Rect.fromLTRB( + 0.0, + 600.0 - item0Height - item1Height - item2Height, + 800.0, + 600.0 - item0Height - item1Height, + ), + ); + + expect(getItem(3), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item 3 should be placed before item 2. + expect( + rect3, + const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0 - item0Height - item1Height - item2Height), + ); + }); + + testWidgets('CarouselView.weighted respects reverse + vertical scroll direction + itemSnapping', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + reverse: true, + flexWeights: const <int>[4, 3, 2, 1], + scrollDirection: Axis.vertical, + itemSnapping: true, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item height is 4/10 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 360.0, 800.0, 600.0)); + + // Simulate a scroll down but less than half of the leading item, the leading + // item should go back to the original position because itemSnapping is set + // to true. + await tester.drag( + find.byType(CarouselView), + const Offset(0, 240 / 2 - 1), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + + // Simulate a scroll down more than half of the leading item, the leading + // item continue to scrolling and will disappear when animation ends because + // itemSnapping is set to true. + await tester.drag( + find.byType(CarouselView), + const Offset(0, 240 / 2 + 1), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(4), findsOneWidget); + }); + + testWidgets('CarouselView respects shrinkExtent', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + shrinkExtent: 300, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final Rect rect0 = tester.getRect(getItem(0)); + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 350.0, 600.0)); + + final Rect rect1 = tester.getRect(getItem(1)); + expect(rect1, const Rect.fromLTRB(350.0, 0.0, 700.0, 600.0)); + + final Rect rect2 = tester.getRect(getItem(2)); + // The extent of item 2 is 300, and only 100 is on screen. + expect(rect2, const Rect.fromLTRB(700.0, 0.0, 1000.0, 600.0)); + + await tester.drag( + find.byType(CarouselView), + const Offset(-50, 0), + kind: PointerDeviceKind.trackpad, + ); + await tester.pump(); + // The item 0 should be pinned and has a size change from 350 to 50. + expect(tester.getRect(getItem(0)), const Rect.fromLTRB(0.0, 0.0, 300.0, 600.0)); + // Keep dragging to left, extent of item 0 won't change (still 300) and part of item 0 will + // be off screen. + await tester.drag( + find.byType(CarouselView), + const Offset(-50, 0), + kind: PointerDeviceKind.trackpad, + ); + await tester.pump(); + expect(tester.getRect(getItem(0)), const Rect.fromLTRB(-50, 0.0, 250, 600)); + }); + + testWidgets('CarouselView.weighted respects consumeMaxWeight', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 2, 4, 2, 1], + itemSnapping: true, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + // The initial item is item 0. To make sure the layout stays the same, the + // first item should be placed at the middle of the screen and there are some + // white space as if there are two more shinked items before the first item. + final Rect rect0 = tester.getRect(getItem(0)); + expect(rect0, const Rect.fromLTRB(240.0, 0.0, 560.0, 600.0)); + + for (var i = 0; i < 7; i++) { + await tester.drag(find.byType(CarouselView), const Offset(-80.0, 0.0)); + await tester.pumpAndSettle(); + } + + // After scrolling the carousel 7 times, the last item(item 9) should be on + // the end of the screen. + expect(getItem(9), findsOneWidget); + expect(tester.getRect(getItem(9)), const Rect.fromLTRB(720.0, 0.0, 800.0, 600.0)); + + // Keep snapping twice. Item 9 should be fully expanded to the max size. + for (var i = 0; i < 2; i++) { + await tester.drag(find.byType(CarouselView), const Offset(-80.0, 0.0)); + await tester.pumpAndSettle(); + } + expect(getItem(9), findsOneWidget); + expect(tester.getRect(getItem(9)), const Rect.fromLTRB(240.0, 0.0, 560.0, 600.0)); + }); + + testWidgets('The initialItem stays when the flexWeights is updated', (WidgetTester tester) async { + final controller = CarouselController(initialItem: 3); + addTearDown(controller.dispose); + + Widget buildCarousel(List<int> flexWeights) { + return MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: controller, + flexWeights: flexWeights, + itemSnapping: true, + children: List<Widget>.generate(20, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarousel(<int>[1, 1, 6, 1, 1])); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + for (var i = 1; i <= 5; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + Rect rect3 = tester.getRect(getItem(3)); + expect(rect3.center.dx, 400.0); + expect(rect3.center.dy, 300.0); + + expect(find.text('Item 6'), findsNothing); + + await tester.pumpWidget(buildCarousel(<int>[7, 1])); + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + rect3 = tester.getRect(getItem(3)); + expect(rect3, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + final Rect rect4 = tester.getRect(getItem(4)); + expect(rect4, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + }); + + testWidgets('The item that currently occupies max weight stays when the flexWeights is updated', ( + WidgetTester tester, + ) async { + final controller = CarouselController(initialItem: 3); + addTearDown(controller.dispose); + + Widget buildCarousel(List<int> flexWeights) { + return MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: controller, + flexWeights: flexWeights, + itemSnapping: true, + children: List<Widget>.generate(20, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarousel(<int>[1, 1, 6, 1, 1])); + await tester.pumpAndSettle(); + // Item 3 is centered. + final Rect rect3 = tester.getRect(getItem(3)); + expect(rect3.center.dx, 400.0); + expect(rect3.center.dy, 300.0); + + // Simulate scroll to right and show item 4 to be the centered max item. + await tester.drag(find.byType(CarouselView), const Offset(-80.0, 0.0)); + await tester.pumpAndSettle(); + + expect(find.text('Item 1'), findsNothing); + for (var i = 2; i <= 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + Rect rect4 = tester.getRect(getItem(4)); + expect(rect4.center.dx, 400.0); + expect(rect4.center.dy, 300.0); + + await tester.pumpWidget(buildCarousel(<int>[7, 1])); + await tester.pumpAndSettle(); + + rect4 = tester.getRect(getItem(4)); + expect(rect4, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + final Rect rect5 = tester.getRect(getItem(5)); + expect(rect5, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + }); + + testWidgets('The initialItem stays when the itemExtent is updated', (WidgetTester tester) async { + final controller = CarouselController(initialItem: 3); + addTearDown(controller.dispose); + + Widget buildCarousel(double itemExtent) { + return MaterialApp( + home: Scaffold( + body: CarouselView( + controller: controller, + itemExtent: itemExtent, + itemSnapping: true, + children: List<Widget>.generate(20, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarousel(234.0)); + await tester.pumpAndSettle(); + + Offset rect3BottomRight = tester.getRect(getItem(3)).bottomRight; + expect(rect3BottomRight.dx, 234.0); + expect(rect3BottomRight.dy, 600.0); + + await tester.pumpWidget(buildCarousel(400.0)); + await tester.pumpAndSettle(); + + rect3BottomRight = tester.getRect(getItem(3)).bottomRight; + expect(rect3BottomRight.dx, 400.0); + expect(rect3BottomRight.dy, 600.0); + + await tester.pumpWidget(buildCarousel(100.0)); + await tester.pumpAndSettle(); + + rect3BottomRight = tester.getRect(getItem(3)).bottomRight; + expect(rect3BottomRight.dx, 100.0); + expect(rect3BottomRight.dy, 600.0); + }); + + testWidgets( + 'While scrolling, one extra item will show at the end of the screen during items transition', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 2, 4, 2, 1], + consumeMaxWeight: false, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (var i = 0; i < 5; i++) { + expect(getItem(i), findsOneWidget); + } + + // Drag the first item to the middle. So the progress for the first item size change + // is 50%, original width is 80. + await tester.drag(getItem(0), const Offset(-40.0, 0.0), kind: PointerDeviceKind.trackpad); + await tester.pump(); + expect(tester.getRect(getItem(0)).width, 40.0); + + // The size of item 1 is changing to the size of item 0, so the size of item 1 + // now should be item1.originalExtent - 50% * (item1.extent - item0.extent). + // Item1 originally should be 2/(1+2+4+2+1) * 800 = 160.0. + expect(tester.getRect(getItem(1)).width, 160 - 0.5 * (160 - 80)); + + // The extent of item 2 should be: item2.originalExtent - 50% * (item2.extent - item1.extent). + // the extent of item 2 originally should be 4/(1+2+4+2+1) * 800 = 320.0. + expect(tester.getRect(getItem(2)).width, 320 - 0.5 * (320 - 160)); + + // The extent of item 3 should be: item3.originalExtent + 50% * (item2.extent - item3.extent). + // the extent of item 3 originally should be 2/(1+2+4+2+1) * 800 = 160.0. + expect(tester.getRect(getItem(3)).width, 160 + 0.5 * (320 - 160)); + + // The extent of item 4 should be: item4.originalExtent + 50% * (item3.extent - item4.extent). + // the extent of item 4 originally should be 1/(1+2+4+2+1) * 800 = 80.0. + expect(tester.getRect(getItem(4)).width, 80 + 0.5 * (160 - 80)); + + // The sum of the first 5 items during transition is less than the screen width. + double sum = 0; + for (var i = 0; i < 5; i++) { + sum += tester.getRect(getItem(i)).width; + } + expect(sum, lessThan(MediaQuery.of(tester.element(find.byType(CarouselView))).size.width)); + final double difference = + MediaQuery.of(tester.element(find.byType(CarouselView))).size.width - sum; + + // One more item should show on screen to fill the rest of the viewport. + expect(getItem(5), findsOneWidget); + expect(tester.getRect(getItem(5)).width, difference); + }, + ); + + testWidgets('Updating CarouselView does not cause exception', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/152787 + var isLight = true; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + theme: Theme.of( + context, + ).copyWith(brightness: isLight ? Brightness.light : Brightness.dark), + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + Switch( + value: isLight, + onChanged: (bool value) { + setState(() { + isLight = value; + }); + }, + ), + ], + ), + body: CarouselView( + itemExtent: 100, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ); + }, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + // No exception. + expect(tester.takeException(), isNull); + }); + + testWidgets('The shrinkExtent should keep the same when the item is tapped', ( + WidgetTester tester, + ) async { + final children = List<Widget>.generate(20, (int index) { + return Center(child: Text('Item $index')); + }); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: CarouselView( + itemExtent: 330, + onTap: (int idx) => setState(() {}), + children: children, + ), + ), + ), + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.getRect(getItem(0)).width, 330.0); + + final Finder item1 = find.text('Item 1'); + await tester.tap(find.ancestor(of: item1, matching: find.byType(Stack))); + + await tester.pumpAndSettle(); + + expect(tester.getRect(getItem(0)).width, 330.0); + expect(tester.getRect(getItem(1)).width, 330.0); + // This should be less than 330.0 because the item is shrunk; width is 800.0 - 330.0 - 330.0 + expect(tester.getRect(getItem(2)).width, 140.0); + }); + + testWidgets('CarouselView onTap is clickable', (WidgetTester tester) async { + var tappedIndex = -1; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + onTap: (int index) { + tappedIndex = index; + }, + children: List<Widget>.generate(3, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final Finder carouselItem = find.text('Item 1'); + await tester.tap(carouselItem, warnIfMissed: false); + await tester.pumpAndSettle(); + + // Verify that the onTap callback was called with the correct index. + expect(tappedIndex, 1); + + // Tap another item. + final Finder anotherCarouselItem = find.text('Item 2'); + await tester.tap(anotherCarouselItem, warnIfMissed: false); + await tester.pumpAndSettle(); + + // Verify that the onTap callback was called with the new index. + expect(tappedIndex, 2); + }); + + testWidgets('CarouselView with enableSplash true - children are not directly interactive', ( + WidgetTester tester, + ) async { + var buttonPressed = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + children: List<Widget>.generate(3, (int index) { + return Center( + child: ElevatedButton( + onPressed: () => buttonPressed = true, + child: Text('Button $index'), + ), + ); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Button 1'), warnIfMissed: false); + expect(buttonPressed, isFalse); + }); + + testWidgets('CarouselView with enableSplash false - children are directly interactive', ( + WidgetTester tester, + ) async { + var buttonPressed = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + enableSplash: false, + children: List<Widget>.generate(3, (int index) { + return Center( + child: ElevatedButton( + onPressed: () => buttonPressed = true, + child: Text('Button $index'), + ), + ); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Button 1')); + expect(buttonPressed, isTrue); + }); + + testWidgets( + 'CarouselView with enableSplash false - container is clickable without triggering children onTap', + (WidgetTester tester) async { + var tappedIndex = -1; + var buttonPressed = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + enableSplash: false, + onTap: (int index) { + tappedIndex = index; + }, + children: List<Widget>.generate(3, (int index) { + return Column( + children: <Widget>[ + Text('Item $index'), + ElevatedButton( + onPressed: () => buttonPressed = true, + child: Text('Button $index'), + ), + ], + ); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final Finder carouselItem = find.text('Item 1'); + await tester.tap(carouselItem, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(tappedIndex, 1); + expect(buttonPressed, false); + + final Finder anotherCarouselItem = find.text('Item 2'); + await tester.tap(anotherCarouselItem, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(tappedIndex, 2); + expect(buttonPressed, false); + + await tester.tap(find.text('Button 1'), warnIfMissed: false); + expect(buttonPressed, isTrue); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/160679 + testWidgets('CarouselView does not crash if itemExtent is zero', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 100, + child: CarouselView( + itemExtent: 0, + children: <Widget>[Container(color: Colors.red, width: 100, height: 100)], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/166067. + testWidgets('CarouselView should not crash when using PageStorageKey', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return const <Widget>[SliverAppBar()]; + }, + body: CustomScrollView( + key: const PageStorageKey<String>('key1'), + slivers: <Widget>[ + SliverToBoxAdapter( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 50), + child: CarouselView.weighted( + flexWeights: const <int>[1, 2], + consumeMaxWeight: false, + children: List<Widget>.generate(20, (int index) { + return ColoredBox( + color: Colors.primaries[index % Colors.primaries.length].withValues( + alpha: 0.8, + ), + child: const SizedBox.expand(), + ); + }), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/160679. + testWidgets('Does not crash when parent size is zero', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: 0, + child: CarouselView(itemExtent: 40.0, children: <Widget>[FlutterLogo()]), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('itemExtent can be set to double.infinity', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CarouselView(itemExtent: double.infinity, children: <Widget>[FlutterLogo()]), + ), + ), + ); + + // Item extent is clamped to screen size. + final Size logoSize = tester.getSize(find.byType(FlutterLogo)); + const itemHorizontalPadding = 8.0; // Default padding. + expect(logoSize.width, 800.0 - itemHorizontalPadding); + }); + + // Regression test for https://github.com/flutter/flutter/issues/163436. + testWidgets('Does not crash when initial viewport dimension is zero and itemExtent is fixed', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(Size.zero); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + const fixedItemExtent = 60.0; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CarouselView(itemExtent: fixedItemExtent, children: <Widget>[FlutterLogo()]), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/163436. + testWidgets('Does not crash when initial viewport dimension is zero and itemExtent is infinite', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(Size.zero); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CarouselView(itemExtent: double.infinity, children: <Widget>[FlutterLogo()]), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/163436. + testWidgets('itemExtent is applied when viewport dimension is updated', ( + WidgetTester tester, + ) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + + const itemExtent = 60.0; + var showScrollbars = false; + + Future<void> updateSurfaceSizeAndPump(Size size) async { + await tester.binding.setSurfaceSize(size); + + // At startup, a warm-up frame can be produced before the Flutter engine has reported the + // initial view metrics. As a result, the first frame can be produced with a size of zero. + // This leads to several instances of _CarouselPosition being created and + // _CarouselPosition.absorb to be called. + // To correctly simulate this behavior in the test environment, one solution is to + // update the ScrollConfiguration. For instance by changing the ScrollBehavior.scrollbars + // value on each build. + showScrollbars = !showScrollbars; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: showScrollbars), + child: const CarouselView( + itemExtent: itemExtent, + children: <Widget>[FlutterLogo()], + ), + ), + ), + ), + ), + ); + } + + // Simulate an initial zero viewport dimension. + await updateSurfaceSizeAndPump(Size.zero); + await updateSurfaceSizeAndPump(const Size(500, 400)); + + final Size logoSize = tester.getSize(find.byType(FlutterLogo)); + const itemHorizontalPadding = 8.0; // Default padding. + expect(logoSize.width, itemExtent - itemHorizontalPadding); + }); + + // Regression test for https://github.com/flutter/flutter/issues/167621. + testWidgets('CarouselView.weighted does not crash when parent size is zero', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: 0, + child: CarouselView.weighted( + flexWeights: <int>[1, 2], + children: <Widget>[FlutterLogo(), FlutterLogo()], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/167621. + testWidgets('CarouselView.weighted does not crash when initial viewport dimension is zero', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(Size.zero); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: <int>[1, 2], + children: <Widget>[FlutterLogo(), FlutterLogo()], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/167621. + testWidgets('CarouselView.weigted weigths are applied when viewport dimension is updated', ( + WidgetTester tester, + ) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + final controller = CarouselController(initialItem: 1); + addTearDown(controller.dispose); + + const firstWeight = 2; + const secondWeight = 3; + var showScrollbars = false; + + Future<void> updateSurfaceSizeAndPump(Size size) async { + await tester.binding.setSurfaceSize(size); + + // At startup, a warm-up frame can be produced before the Flutter engine has reported the + // initial view metrics. As a result, the first frame can be produced with a size of zero. + // This leads to several instances of _CarouselPosition being created and + // _CarouselPosition.absorb to be called. + // To correctly simulate this behavior in the test environment, one solution is to + // update the ScrollConfiguration. For instance by changing the ScrollBehavior.scrollbars + // value on each build. + showScrollbars = !showScrollbars; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: showScrollbars), + child: CarouselView.weighted( + controller: controller, + flexWeights: const <int>[firstWeight, secondWeight], + children: List<Widget>.generate(20, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ), + ), + ); + } + + // Simulate an initial zero viewport dimension. + await updateSurfaceSizeAndPump(Size.zero); + const double surfaceWidth = 500; + await updateSurfaceSizeAndPump(const Size(surfaceWidth, 400)); + + const int totalWeight = firstWeight + secondWeight; + + expect(find.text('Item 0'), findsOne); + expect(find.text('Item 1'), findsOne); + + final double firstItemWidth = tester.getRect(getItem(0)).width; + expect(firstItemWidth, surfaceWidth * firstWeight / totalWeight); + final double secondItemWidth = tester.getRect(getItem(1)).width; + expect(secondItemWidth, surfaceWidth * secondWeight / totalWeight); + }); + + testWidgets('CarouselView.builder creates items lazily', (WidgetTester tester) async { + final builtItems = <int>[]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.builder( + itemExtent: 300.0, + itemCount: 1000, + itemBuilder: (BuildContext context, int index) { + builtItems.add(index); + return Container( + color: Colors.blue[index % 9 * 100], + child: Center(child: Text('Item $index')), + ); + }, + ), + ), + ), + ); + + // Only visible items should be built initially. + expect(builtItems.length, lessThan(10)); + expect(builtItems, contains(0)); + expect(builtItems, contains(1)); + + // Scroll to a far item. + await tester.drag(find.byType(CarouselView), const Offset(-2000.0, 0.0)); + await tester.pumpAndSettle(); + + // Clear built items to see what's built after scrolling. + builtItems.clear(); + + // Force rebuild by scrolling a bit more. + await tester.drag(find.byType(CarouselView), const Offset(-300.0, 0.0)); + await tester.pump(); + + // Should have built new items, not the initial ones. + expect(builtItems, isNotEmpty); + expect(builtItems.every((int index) => index > 3), isTrue); + }); + + group('CarouselController.animateToItem', () { + testWidgets('CarouselView.weighted horizontal, not reversed, flexWeights [7,1]', ( + WidgetTester tester, + ) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[7, 1], + numberOfChildren: 20, + scrollDirection: Axis.horizontal, + reverse: false, + ); + }); + + testWidgets('CarouselView.weighted horizontal, reversed, flexWeights [7,1]', ( + WidgetTester tester, + ) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[7, 1], + numberOfChildren: 20, + scrollDirection: Axis.horizontal, + reverse: true, + ); + }); + + testWidgets('CarouselView.weighted vertical, not reversed, flexWeights [7,1]', ( + WidgetTester tester, + ) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[7, 1], + numberOfChildren: 20, + scrollDirection: Axis.vertical, + reverse: false, + ); + }); + + testWidgets('CarouselView.weighted vertical, reversed, flexWeights [7,1]', ( + WidgetTester tester, + ) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[7, 1], + numberOfChildren: 20, + scrollDirection: Axis.vertical, + reverse: true, + ); + }); + + testWidgets( + 'CarouselView.weighted horizontal, not reversed, flexWeights [1,7] and consumeMaxWeight false', + (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[1, 7], + numberOfChildren: 20, + scrollDirection: Axis.horizontal, + reverse: false, + consumeMaxWeight: false, + ); + }, + ); + + testWidgets('CarouselView.weighted horizontal, reversed, flexWeights [1,7]', ( + WidgetTester tester, + ) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[1, 7], + numberOfChildren: 20, + scrollDirection: Axis.horizontal, + reverse: true, + ); + }); + + testWidgets( + 'CarouselView.weighted vertical, not reversed, flexWeights [1,7] and consumeMaxWeight false', + (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[1, 7], + numberOfChildren: 20, + scrollDirection: Axis.vertical, + consumeMaxWeight: false, + reverse: false, + ); + }, + ); + + testWidgets( + 'CarouselView.weighted vertical, reversed, flexWeights [1,7] and consumeMaxWeight false', + (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[1, 7], + numberOfChildren: 20, + scrollDirection: Axis.vertical, + consumeMaxWeight: false, + reverse: true, + ); + }, + ); + + testWidgets( + 'CarouselView.weighted vertical, reversed, flexWeights [1,7] and consumeMaxWeight', + (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + flexWeights: <int>[1, 7], + numberOfChildren: 20, + scrollDirection: Axis.vertical, + reverse: true, + ); + }, + ); + + testWidgets('CarouselView.weightedBuilder creates items lazily with flex weights', ( + WidgetTester tester, + ) async { + final builtItems = <int>[]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weightedBuilder( + flexWeights: const <int>[2, 3, 1], + itemCount: 1000, + itemBuilder: (BuildContext context, int index) { + builtItems.add(index); + return Container( + color: Colors.blue[index % 9 * 100], + child: Center(child: Text('Item $index')), + ); + }, + ), + ), + ), + ); + + // Only visible items should be built initially. + expect(builtItems.length, lessThan(10)); + expect(builtItems, contains(0)); + expect(builtItems, contains(1)); + + // Scroll to a far item. + await tester.drag(find.byType(CarouselView), const Offset(-2000.0, 0.0)); + await tester.pumpAndSettle(); + + // Clear built items to see what's built after scrolling. + builtItems.clear(); + + // Force rebuild by scrolling a bit more. + await tester.drag(find.byType(CarouselView), const Offset(-300.0, 0.0)); + await tester.pump(); + + // Should have built new items, not the initial ones. + expect(builtItems, isNotEmpty); + expect(builtItems.every((int index) => index > 3), isTrue); + }); + + testWidgets('CarouselView horizontal, not reversed', (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + numberOfChildren: 20, + scrollDirection: Axis.horizontal, + reverse: false, + ); + }); + + testWidgets('CarouselView horizontal, reversed', (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + numberOfChildren: 10, + scrollDirection: Axis.horizontal, + reverse: true, + ); + }); + + testWidgets('CarouselView vertical, not reversed', (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + numberOfChildren: 10, + scrollDirection: Axis.vertical, + reverse: false, + ); + }); + + testWidgets('CarouselView vertical, reversed', (WidgetTester tester) async { + await runCarouselTest( + tester: tester, + numberOfChildren: 10, + scrollDirection: Axis.vertical, + reverse: true, + ); + }); + + testWidgets('CarouselView positions items correctly', (WidgetTester tester) async { + const numberOfChildren = 5; + final controller = CarouselController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[2, 3, 1], + controller: controller, + itemSnapping: true, + children: List<Widget>.generate(numberOfChildren, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Get the RenderBox of the CarouselView to determine its position and boundaries. + final RenderBox carouselBox = tester.renderObject(find.byType(CarouselView)); + final Offset carouselPos = carouselBox.localToGlobal(Offset.zero); + final double carouselLeft = carouselPos.dx; + final double carouselRight = carouselLeft + carouselBox.size.width; + + for (var i = 0; i < numberOfChildren; i++) { + controller.animateToItem(i, curve: Curves.easeInOut); + await tester.pumpAndSettle(); + + expect(find.text('Item $i'), findsOneWidget); + + // Get the item's RenderBox and determine its position. + final RenderBox itemBox = tester.renderObject(find.text('Item $i')); + final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; + + // Validate that the item is positioned within the CarouselView boundaries. + expect(itemRect.left, greaterThanOrEqualTo(carouselLeft)); + expect(itemRect.right, lessThanOrEqualTo(carouselRight)); + } + }); + + testWidgets('CarouselView infinite', (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + infinite: true, + controller: controller, + children: List<Widget>.generate(3, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + // Verify animating to an index beyond the array length. + controller.animateToItem(5); + await tester.pumpAndSettle(); + + // Should show last item 2 times based on size. + expect(find.textContaining('Item 2'), findsAtLeastNWidgets(2)); + }); + }); + + testWidgets('CarouselView infinite scrolling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + infinite: true, + children: List<Widget>.generate(3, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + // Initial state: viewport is 800 wide, each item is 200 wide. + // Visible items: Item 0 at [0-200], Item 1 at [200-400], Item 2 at [400-600], Item 0 at [600-800] (wraps). + expect(find.text('Item 0'), findsNWidgets(2)); // Item 0 appears twice due to wrap. + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + + Rect rect0 = tester.getRect(getItem(0).first); + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 200.0, 600.0)); + Rect rect1 = tester.getRect(getItem(1).first); + expect(rect1, const Rect.fromLTRB(200.0, 0.0, 400.0, 600.0)); + Rect rect2 = tester.getRect(getItem(2).first); + expect(rect2, const Rect.fromLTRB(400.0, 0.0, 600.0, 600.0)); + + // Scroll forward by 400 pixels (dragging left). + await tester.drag(find.byType(CarouselView), const Offset(-400, 0)); + await tester.pumpAndSettle(); + + // After scrolling 400 pixels: + // Visible items: Item 2 at [0-200], Item 0 at [200-400], Item 1 at [400-600], Item 2 at [600-800]. + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsNWidgets(2)); // Item 2 appears twice. + + rect2 = tester.getRect(getItem(2).first); + expect(rect2, const Rect.fromLTRB(0.0, 0.0, 200.0, 600.0)); + rect0 = tester.getRect(getItem(0).first); + expect(rect0, const Rect.fromLTRB(200.0, 0.0, 400.0, 600.0)); + rect1 = tester.getRect(getItem(1).first); + expect(rect1, const Rect.fromLTRB(400.0, 0.0, 600.0, 600.0)); + }); + + testWidgets('CarouselView infinite scrolling supports bidirectional scrolling', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + infinite: true, + children: List<Widget>.generate(3, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + expect(find.text('Item 0'), findsNWidgets(2)); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + + final double initialItem0Left = tester.getRect(getItem(0).first).left; + + await tester.drag(find.byType(CarouselView), const Offset(200, 0)); + await tester.pumpAndSettle(); + + // After scrolling backward, Item 2 should now appear twice. + expect(find.text('Item 2'), findsNWidgets(2)); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + + final double afterItem2Left = tester.getRect(getItem(2).first).left; + expect(afterItem2Left, moreOrLessEquals(initialItem0Left, epsilon: 10.0)); + + await tester.drag(find.byType(CarouselView), const Offset(-200, 0)); + await tester.pumpAndSettle(); + + // Should be back to initial state. + expect(find.text('Item 0'), findsNWidgets(2)); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + }); + + testWidgets('CarouselView.weighted infinite scrolling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 2, 1], + infinite: true, + children: List<Widget>.generate(3, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + // With flexWeights [1, 2, 1], total weight = 4, viewport = 800. + // Item widths: small = 1/4 * 800 = 200, large = 2/4 * 800 = 400. + // With consumeMaxWeight (default true), Item 0 is placed at the max weight position (index 1). + // With infinite scrolling, items wrap to fill the entire viewport: + // Item 2 (small) at [0, 200], Item 0 (large) at [200, 600], Item 1 (small) at [600, 800]. + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); // Wraps to fill the padding area + + final Rect rect2 = tester.getRect(getItem(2).first); + expect(rect2, const Rect.fromLTRB(0.0, 0.0, 200.0, 600.0)); + final Rect rect0 = tester.getRect(getItem(0).first); + expect(rect0, const Rect.fromLTRB(200.0, 0.0, 600.0, 600.0)); + final Rect rect1 = tester.getRect(getItem(1).first); + expect(rect1, const Rect.fromLTRB(600.0, 0.0, 800.0, 600.0)); + + // Scroll forward by 400 pixels (one large item width). + await tester.drag(find.byType(CarouselView), const Offset(-400, 0)); + await tester.pumpAndSettle(); + + // After scrolling, items shift and cycle infinitely. + // With only 3 items and infinite scroll, items wrap around. + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + }); + + testWidgets('CarouselView.weighted with hero layout (1,7,1) and infinite scrolling', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 7, 1], + infinite: true, + children: List<Widget>.generate(6, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + // With flexWeights [1, 7, 1], total weight = 9, viewport = 800. + // Item widths: small ≈ 88.89, large ≈ 622.22. + // With consumeMaxWeight (default true), Item 0 is placed at the max weight position (index 1). + // Initial layout: [padding small] Item 0 (large), Item 1 (small). + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsNothing); + + // Verify Item 0 is in the large/hero position with correct width. + final Rect rect0 = tester.getRect(getItem(0).first); + expect(rect0.left, moreOrLessEquals(800.0 / 9, epsilon: 0.1)); // ~88.89 + expect(rect0.width, moreOrLessEquals(800.0 * 7 / 9, epsilon: 0.1)); // ~622.22 + + // Scroll forward - should continue infinitely and show different items. + await tester.drag(find.byType(CarouselView), const Offset(-622, 0)); + await tester.pumpAndSettle(); + + // After scrolling, later items should be visible. + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + + // Scroll backward to return to initial position. + await tester.drag(find.byType(CarouselView), const Offset(622, 0)); + await tester.pumpAndSettle(); + + // Should show Item 0 again in the hero position. + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + + // Scroll backward beyond initial position - should work due to infinite. + // This tests true bidirectional infinite scrolling. + await tester.drag(find.byType(CarouselView), const Offset(200, 0)); + await tester.pumpAndSettle(); + + // After scrolling backward, items from the "end" of the list should appear + // (wrapping around). With 6 items, scrolling backward from Item 0 shows Item 5. + expect(find.text('Item 5'), findsOneWidget); + }); + + testWidgets('CarouselView.weighted with multi-browse layout and infinite scrolling', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 2, 3, 2, 1], + infinite: true, + consumeMaxWeight: false, + children: List<Widget>.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + // With flexWeights [1, 2, 3, 2, 1] and consumeMaxWeight = false, multiple items are visible. + // Items have varying widths based on the weight cycle. + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + + // Scroll forward by one item worth (first item extent = 800 * 1/9 ≈ 88.89 pixels). + await tester.drag(find.byType(CarouselView), const Offset(-89, 0)); + await tester.pumpAndSettle(); + + // Items should have shifted, with later items now visible. + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 3'), findsOneWidget); + + // Scroll backward beyond initial position (should still work due to infinite). + await tester.drag(find.byType(CarouselView), const Offset(200, 0)); + await tester.pumpAndSettle(); + expect(find.text('Item 0'), findsOneWidget); + }); + + testWidgets('CarouselView infinite leadingItem wraps correctly when scrolling forwards', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + final reportedIndices = <int>[]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + itemSnapping: true, + infinite: true, + controller: controller, + onIndexChanged: (int index) { + reportedIndices.add(index); + }, + children: List<Widget>.generate(5, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final int initialItem = controller.leadingItem; + + // Scroll forward by dragging left. Each 200px drag moves one item. + // We'll scroll through all items and verify the leadingItem wraps correctly. + for (var i = 1; i <= 7; i++) { + await tester.drag(find.byType(CarouselView), const Offset(-200, 0)); + await tester.pumpAndSettle(); + // The leadingItem should wrap to [0, itemCount - 1] range. + expect(controller.leadingItem, (initialItem + i) % 5); + } + + // Verify the onIndexChanged callback was invoked with correct wrapped indices. + expect(reportedIndices.length, greaterThanOrEqualTo(7)); + for (final index in reportedIndices) { + expect(index, inInclusiveRange(0, 4)); + } + }); + + testWidgets('CarouselView infinite leadingItem wraps correctly when scrolling backwards', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + final reportedIndices = <int>[]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + itemSnapping: true, + infinite: true, + controller: controller, + onIndexChanged: (int index) { + reportedIndices.add(index); + }, + children: List<Widget>.generate(5, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final int initialItem = controller.leadingItem; + + // Scroll backward by dragging right. Each 200px drag moves one item backwards. + // We'll scroll backward through items and verify the leadingItem wraps correctly. + for (var i = 1; i <= 7; i++) { + await tester.drag(find.byType(CarouselView), const Offset(200, 0)); + await tester.pumpAndSettle(); + // When scrolling backwards, wrap negative indices to positive. + // (initialItem - i) % 5 handles wrapping for negative values in Dart. + final int expectedItem = ((initialItem - i) % 5 + 5) % 5; + expect(controller.leadingItem, expectedItem); + } + + // Verify the onIndexChanged callback was invoked with correct wrapped indices. + expect(reportedIndices.length, greaterThanOrEqualTo(7)); + for (final index in reportedIndices) { + expect(index, inInclusiveRange(0, 4)); + } + }); + + testWidgets('CarouselView infinite animateToItem scrolls forward to next item', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + itemSnapping: true, + infinite: true, + controller: controller, + children: List<Widget>.generate(5, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final double initialOffset = controller.offset; + final int initialItem = controller.leadingItem; + + // Animate forward by one item. The offset should increase (scroll forward), + // not decrease (scroll backward). + controller.animateToItem( + initialItem + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals((initialItem + 1) % 5)); + expect(controller.offset, greaterThan(initialOffset)); + }); + + testWidgets('CarouselView.weighted infinite animateToItem scrolls forward to next item', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 7, 1], + itemSnapping: true, + infinite: true, + controller: controller, + children: List<Widget>.generate(5, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final double initialOffset = controller.offset; + + // With consumeMaxWeight and [1,7,1], animateToItem(1) places item 1 in the + // max-weight position. This is one item forward from initial (item 0 in hero). + // The offset should increase (scroll forward), not decrease (scroll backward). + controller.animateToItem(1, duration: const Duration(milliseconds: 200), curve: Curves.linear); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(initialOffset)); + // Item 1 should now be visible in the hero position. + expect(find.text('Item 1'), findsOneWidget); + }); + + testWidgets('CarouselView infinite animateToItem scrolls forward even to item just behind', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + itemSnapping: true, + infinite: true, + controller: controller, + children: List<Widget>.generate(5, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + // First scroll forward to item 2. + controller.animateToItem(2, duration: const Duration(milliseconds: 200), curve: Curves.linear); + await tester.pumpAndSettle(); + expect(controller.leadingItem, equals(2)); + + final double offsetAtItem2 = controller.offset; + + // Now animate to item 1 which is just behind. In infinite mode this should + // scroll forward through items 3 → 4 → 0 → 1, not backward. + controller.animateToItem(1, duration: const Duration(milliseconds: 200), curve: Curves.linear); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(1)); + expect(controller.offset, greaterThan(offsetAtItem2)); + }); + + testWidgets( + 'CarouselView.weighted infinite animateToItem scrolls forward even to item just behind', + (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 7, 1], + itemSnapping: true, + infinite: true, + controller: controller, + children: List<Widget>.generate(5, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + // First scroll forward to item 2 (in hero position). + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + final double offsetAtItem2 = controller.offset; + + // Now animate to item 1 which is just behind. In infinite mode this should + // scroll forward through the remaining items, not backward. + controller.animateToItem( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(offsetAtItem2)); + expect(find.text('Item 1'), findsOneWidget); + }, + ); + + group('CarouselView onIndexChanged callback', () { + Widget buildCarousel({ + required CarouselController controller, + ValueChanged<int>? onIndexChanged, + bool weighted = false, + bool reverse = false, + bool itemSnapping = false, + List<int>? flexWeights, + int itemCount = 6, + }) { + return MaterialApp( + home: Scaffold( + body: weighted + ? CarouselView.weighted( + flexWeights: flexWeights!, + reverse: reverse, + itemSnapping: itemSnapping, + controller: controller, + onIndexChanged: onIndexChanged, + children: List.generate(itemCount, (i) => Text('Item $i')), + ) + : CarouselView( + itemExtent: 300, + reverse: reverse, + itemSnapping: itemSnapping, + controller: controller, + onIndexChanged: onIndexChanged, + children: List.generate(itemCount, (i) => Text('Item $i')), + ), + ), + ); + } + + testWidgets('CarouselView shows correct item after animation', (tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + buildCarousel(controller: controller, onIndexChanged: (i) => leadingIndex = i), + ); + await tester.pumpAndSettle(); + + controller.animateToItem( + 3, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(3)); + expect(leadingIndex, equals(3)); + + controller.animateToItem( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); + }); + + testWidgets( + 'CarouselView.weighted shows correct item after animation with symmetric flexWeights', + (tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + buildCarousel( + weighted: true, + flexWeights: const [2, 5, 2], + controller: controller, + onIndexChanged: (i) => leadingIndex = i, + ), + ); + await tester.pumpAndSettle(); + + // Animate the carousel so item 4 is placed in the first position with max weight (5) + // in `flexWeights`, resulting in item 3 as the leading item. + controller.animateToItem( + 4, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(3)); + expect(leadingIndex, equals(3)); + expect(find.text('Item 3'), findsOneWidget); + }, + ); + + testWidgets( + 'CarouselView.weighted shows correct item after animation with asymmetric flexWeights', + (tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + buildCarousel( + weighted: true, + flexWeights: const [1, 2, 3, 4], + controller: controller, + onIndexChanged: (i) => leadingIndex = i, + ), + ); + await tester.pumpAndSettle(); + + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(0)); + expect(leadingIndex, equals(0)); + }, + ); + + testWidgets('CarouselView shows the correct item after dragging', (tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + buildCarousel( + weighted: true, + flexWeights: const [2, 5, 2], + itemSnapping: true, + itemCount: 5, + controller: controller, + onIndexChanged: (i) => leadingIndex = i, + ), + ); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(CarouselView), const Offset(-300, 0)); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); + }); + + testWidgets('CarouselView with reverse=true reports correct leading item after animation', ( + tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + buildCarousel( + reverse: true, + controller: controller, + onIndexChanged: (i) => leadingIndex = i, + ), + ); + await tester.pumpAndSettle(); + + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(2)); + expect(leadingIndex, equals(2)); + }); + + testWidgets('CarouselView.weighted with reverse=true reports correct leading item after drag', ( + tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + buildCarousel( + weighted: true, + reverse: true, + itemSnapping: true, + itemCount: 5, + flexWeights: const [2, 5, 2], + controller: controller, + onIndexChanged: (i) => leadingIndex = i, + ), + ); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(CarouselView), const Offset(300, 0)); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); + }); + }); + + group('CarouselView item clipBehavior', () { + testWidgets('CarouselView Item clipBehavior defaults to Clip.antiAlias', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + children: List<Widget>.generate(3, (int index) { + return Text('Item $index'); + }), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.ancestor(of: find.text('Item 0'), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.antiAlias); + }); + + testWidgets('CarouselView.weighted Item clipBehavior defaults to Clip.antiAlias', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 1, 1], + children: List<Widget>.generate(3, (int index) { + return Text('Item $index'); + }), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.ancestor(of: find.text('Item 0'), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.antiAlias); + }); + + testWidgets('CarouselView Item clipBehavior respects theme', (WidgetTester tester) async { + final theme = ThemeData( + carouselViewTheme: const CarouselViewThemeData(itemClipBehavior: Clip.hardEdge), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + itemExtent: 350, + children: List<Widget>.generate(3, (int index) { + return Text('Item $index'); + }), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.ancestor(of: find.text('Item 0'), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.hardEdge); + }); + + testWidgets('CarouselView.weighted item clipBehavior respects theme', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + carouselViewTheme: const CarouselViewThemeData(itemClipBehavior: Clip.hardEdge), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 1, 1], + children: List<Widget>.generate(3, (int index) { + return Text('Item $index'); + }), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.ancestor(of: find.text('Item 0'), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.hardEdge); + }); + }); + + testWidgets('CarouselView item clipBehavior respects custom itemClipBehavior', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + itemClipBehavior: Clip.hardEdge, + children: List<Widget>.generate(3, (int index) { + return Text('Item $index'); + }), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.ancestor(of: find.text('Item 0'), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.hardEdge); + }); + + testWidgets('CarouselView.weighted item clipBehavior respects custom itemClipBehavior', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const <int>[1, 1, 1], + itemClipBehavior: Clip.hardEdge, + children: List<Widget>.generate(3, (int index) { + return Text('Item $index'); + }), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.ancestor(of: find.text('Item 0'), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.hardEdge); + }); + + testWidgets('scrolls correctly in RTL', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: CarouselView( + itemExtent: 600.0, + children: [ + Center(key: ValueKey(0), child: Text('Item 0')), + Center(key: ValueKey(1), child: Text('Item 1')), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final Finder item0 = find.byKey(const ValueKey(0)); + final double before = tester.getCenter(item0).dx; + + await tester.drag(find.byType(CarouselView), const Offset(500, 0)); + await tester.pumpAndSettle(); + + final double after = tester.getCenter(item0).dx; + expect(after, greaterThan(before)); + + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.axisDirection, AxisDirection.left); + }); + + testWidgets('scrolls correctly in RTL with reverse', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: CarouselView( + reverse: true, + itemExtent: 600.0, + children: [ + Center(key: ValueKey(0), child: Text('Item 0')), + Center(key: ValueKey(1), child: Text('Item 1')), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final Finder item0 = find.byKey(const ValueKey(0)); + final double before = tester.getCenter(item0).dx; + + await tester.drag(find.byType(CarouselView), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + final double after = tester.getCenter(item0).dx; + expect(after, lessThan(before)); + + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.axisDirection, AxisDirection.right); + }); + + testWidgets('snaps back to item boundary after partial scroll', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 600.0, + itemSnapping: true, + children: [ + SizedBox(key: ValueKey(0), width: 600, height: 600), + SizedBox(key: ValueKey(1), width: 600, height: 600), + ], + ), + ), + ), + ); + + await tester.drag(find.byType(CarouselView), const Offset(-100, 0)); + + await tester.pumpAndSettle(); + + final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position; + + expect(position.pixels, 0.0); + }); +} + +Finder getItem(int index) { + return find.descendant( + of: find.byType(CarouselView), + matching: find.ancestor(of: find.text('Item $index'), matching: find.byType(Padding)), + ); +} + +Future<TestGesture> hoverPointerOverCarouselItem(WidgetTester tester, Key key) async { + final Offset center = tester.getCenter(find.byKey(key)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + + // On hovered. + await gesture.addPointer(); + await gesture.moveTo(center); + return gesture; +} + +Future<void> runCarouselTest({ + required WidgetTester tester, + List<int> flexWeights = const <int>[], + bool consumeMaxWeight = true, + required int numberOfChildren, + required Axis scrollDirection, + required bool reverse, +}) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: flexWeights.isEmpty + ? CarouselView( + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + itemSnapping: true, + itemExtent: 300, + children: List<Widget>.generate(numberOfChildren, (int index) { + return Center(child: Text('Item $index')); + }), + ) + : CarouselView.weighted( + flexWeights: flexWeights, + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + itemSnapping: true, + consumeMaxWeight: consumeMaxWeight, + children: List<Widget>.generate(numberOfChildren, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + double realOffset() { + return tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels; + } + + // Calculate the index of the middle item. + // The calculation depends on the scroll direction (normal or reverse). + // For reverse scrolling, the middle item is calculated taking into account the end of the list, + // reversing the calculation so that the item that appears in the middle when scrolling is the correct one. + // For normal scrolling, we simply get the middle item. + final int middleIndex = reverse + ? (numberOfChildren - 1 - (numberOfChildren / 2).round()) + : (numberOfChildren / 2).round(); + + controller.animateToItem( + middleIndex, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + ); + await tester.pumpAndSettle(); + + // Verify that the middle item is visible. + expect(find.text('Item $middleIndex'), findsOneWidget); + expect(realOffset(), controller.offset); + + // Scroll to the first item. + controller.animateToItem(0, duration: const Duration(milliseconds: 100), curve: Curves.easeInOut); + await tester.pumpAndSettle(); + + // Verify that the first item is visible. + expect(find.text('Item 0'), findsOneWidget); + expect(realOffset(), controller.offset); +} diff --git a/packages/material_ui/test/material/carousel_theme_test.dart b/packages/material_ui/test/material/carousel_theme_test.dart new file mode 100644 index 000000000000..8831b51031e0 --- /dev/null +++ b/packages/material_ui/test/material/carousel_theme_test.dart @@ -0,0 +1,254 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('CarouselViewThemeData copyWith, ==, hashCode basics', () { + expect(const CarouselViewThemeData(), const CarouselViewThemeData().copyWith()); + expect( + const CarouselViewThemeData().hashCode, + const CarouselViewThemeData().copyWith().hashCode, + ); + }); + + test('CarouselViewThemeData null fields by default', () { + const carouselViewTheme = CarouselViewThemeData(); + expect(carouselViewTheme.backgroundColor, null); + expect(carouselViewTheme.elevation, null); + expect(carouselViewTheme.overlayColor, null); + expect(carouselViewTheme.padding, null); + expect(carouselViewTheme.shape, null); + expect(carouselViewTheme.itemClipBehavior, null); + }); + + testWidgets('Default CarouselViewThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CarouselViewThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('CarouselViewThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CarouselViewThemeData( + backgroundColor: Color(0xFFFFFFFF), + elevation: 5.0, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(), + overlayColor: MaterialStatePropertyAll<Color>(Colors.red), + itemClipBehavior: Clip.hardEdge, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'backgroundColor: ${const Color(0xffffffff)}', + 'elevation: 5.0', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + 'overlayColor: WidgetStatePropertyAll(${Colors.red})', + 'padding: EdgeInsets.zero', + 'itemClipBehavior: hardEdge', + ]); + }); + + testWidgets('Uses value from CarouselViewThemeData', (WidgetTester tester) async { + final CarouselViewThemeData carouselViewTheme = _carouselViewThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(carouselViewTheme: carouselViewTheme), + home: const Scaffold( + body: Center( + child: CarouselView( + itemExtent: 100, + children: <Widget>[SizedBox(width: 100, height: 100)], + ), + ), + ), + ), + ); + expect(find.byType(CarouselView), findsOneWidget); + + final Finder padding = find.descendant( + of: find.byType(CarouselView), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Padding && widget.child is Material, + ), + ); + + expect(padding, findsOneWidget); + final Padding paddingWidget = tester.widget<Padding>(padding); + final material = paddingWidget.child! as Material; + + final InkWell inkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(CarouselView), matching: find.byType(InkWell)), + ); + + expect(paddingWidget.padding, carouselViewTheme.padding); + expect(material.color, carouselViewTheme.backgroundColor); + expect(material.elevation, carouselViewTheme.elevation); + expect(material.shape, carouselViewTheme.shape); + expect(material.borderRadius, null); + expect(inkWell.overlayColor, carouselViewTheme.overlayColor); + expect(material.clipBehavior, carouselViewTheme.itemClipBehavior); + }); + + testWidgets('Widgets properties override theme', (WidgetTester tester) async { + final CarouselViewThemeData carouselViewTheme = _carouselViewThemeData(); + const backgroundColor = Color(0xFFFF0000); + const elevation = 10.0; + const padding = EdgeInsets.all(15.0); + const OutlinedBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ); + const WidgetStateProperty<Color?> overlayColor = MaterialStatePropertyAll<Color>(Colors.green); + const Clip itemClipBehavior = Clip.hardEdge; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(carouselViewTheme: carouselViewTheme), + home: const Scaffold( + body: Center( + child: CarouselView( + backgroundColor: backgroundColor, + elevation: elevation, + padding: padding, + shape: shape, + overlayColor: overlayColor, + itemExtent: 100, + itemClipBehavior: itemClipBehavior, + children: <Widget>[SizedBox(width: 100, height: 100)], + ), + ), + ), + ), + ); + expect(find.byType(CarouselView), findsOneWidget); + + final Finder paddingFinder = find.descendant( + of: find.byType(CarouselView), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Padding && widget.child is Material, + ), + ); + + expect(paddingFinder, findsOneWidget); + final Padding paddingWidget = tester.widget<Padding>(paddingFinder); + final material = paddingWidget.child! as Material; + + final InkWell inkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(CarouselView), matching: find.byType(InkWell)), + ); + + expect(paddingWidget.padding, padding); + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(inkWell.overlayColor, overlayColor); + expect(material.clipBehavior, Clip.hardEdge); + }); + + testWidgets('CarouselViewTheme can override Theme.carouselViewTheme', ( + WidgetTester tester, + ) async { + const globalBackgroundColor = Color(0xfffffff1); + const globalOverlayColor = Color(0xff000000); + const globalElevation = 5.0; + const globalPadding = EdgeInsets.all(10.0); + const OutlinedBorder globalShape = RoundedRectangleBorder(); + const Clip globalItemClipBehavior = Clip.hardEdge; + + const localBackgroundColor = Color(0xffff0000); + const localOverlayColor = Color(0xffffffff); + const localElevation = 10.0; + const localPadding = EdgeInsets.all(15.0); + const OutlinedBorder localShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ); + const Clip localItemClipBehavior = Clip.antiAlias; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + carouselViewTheme: const CarouselViewThemeData( + backgroundColor: globalBackgroundColor, + overlayColor: MaterialStatePropertyAll<Color>(globalOverlayColor), + elevation: globalElevation, + padding: globalPadding, + shape: globalShape, + itemClipBehavior: globalItemClipBehavior, + ), + ), + home: const Scaffold( + body: Center( + child: CarouselViewTheme( + data: CarouselViewThemeData( + backgroundColor: localBackgroundColor, + overlayColor: MaterialStatePropertyAll<Color>(localOverlayColor), + elevation: localElevation, + padding: localPadding, + shape: localShape, + itemClipBehavior: localItemClipBehavior, + ), + child: CarouselView( + itemExtent: 100, + children: <Widget>[SizedBox(width: 100, height: 100)], + ), + ), + ), + ), + ), + ); + + final Finder padding = find.descendant( + of: find.byType(CarouselView), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Padding && widget.child is Material, + ), + ); + + expect(padding, findsOneWidget); + final Padding paddingWidget = tester.widget<Padding>(padding); + final material = paddingWidget.child! as Material; + + final InkWell inkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(CarouselView), matching: find.byType(InkWell)), + ); + + expect(paddingWidget.padding, localPadding); + expect(material.color, localBackgroundColor); + expect(material.elevation, localElevation); + expect(material.shape, localShape); + expect(inkWell.overlayColor?.resolve(<WidgetState>{}), localOverlayColor); + expect(material.clipBehavior, localItemClipBehavior); + }); +} + +CarouselViewThemeData _carouselViewThemeData() { + const backgroundColor = Color(0xFF0000FF); + const elevation = 5.0; + const padding = EdgeInsets.all(10.0); + const OutlinedBorder shape = RoundedRectangleBorder(); + const WidgetStateProperty<Color?> overlayColor = MaterialStatePropertyAll<Color>(Colors.red); + const Clip itemClipBehavior = Clip.hardEdge; + + return const CarouselViewThemeData( + backgroundColor: backgroundColor, + elevation: elevation, + padding: padding, + shape: shape, + overlayColor: overlayColor, + itemClipBehavior: itemClipBehavior, + ); +} diff --git a/packages/material_ui/test/material/checkbox_list_tile_test.dart b/packages/material_ui/test/material/checkbox_list_tile_test.dart new file mode 100644 index 000000000000..5f14076d551e --- /dev/null +++ b/packages/material_ui/test/material/checkbox_list_tile_test.dart @@ -0,0 +1,2237 @@ +// 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/cupertino.dart'; +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 '../widgets/feedback_tester.dart'; + +Widget wrap({required Widget child}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: child), + ), + ); +} + +void main() { + testWidgets('CheckboxListTile control test', (WidgetTester tester) async { + final log = <dynamic>[]; + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + value: true, + onChanged: (bool? value) { + log.add(value); + }, + title: const Text('Hello'), + ), + ), + ); + await tester.tap(find.text('Hello')); + log.add('-'); + await tester.tap(find.byType(Checkbox)); + expect(log, equals(<dynamic>[false, '-', false])); + }); + testWidgets('CheckboxListTile forwards statesController to ListTile', ( + WidgetTester tester, + ) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CheckboxListTile( + value: true, + onChanged: (_) {}, + title: const Text('Checkbox'), + statesController: controller, + ), + ), + ), + ); + + final ListTile tile = tester.widget(find.byType(ListTile)); + expect(tile.statesController, controller); + }); + + testWidgets('Material2 - CheckboxListTile checkColor test', (WidgetTester tester) async { + const checkBoxBorderColor = Color(0xff2196f3); + var checkBoxCheckColor = const Color(0xffFFFFFF); + + Widget buildFrame(Color? color) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: CheckboxListTile(value: true, checkColor: color, onChanged: (bool? value) {}), + ), + ); + } + + RenderBox getCheckboxListTileRenderer() { + return tester.renderObject<RenderBox>(find.byType(CheckboxListTile)); + } + + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + expect( + getCheckboxListTileRenderer(), + paints + ..rrect(color: checkBoxBorderColor) + ..path(color: checkBoxCheckColor), + ); + + checkBoxCheckColor = const Color(0xFF000000); + + await tester.pumpWidget(buildFrame(checkBoxCheckColor)); + await tester.pumpAndSettle(); + expect( + getCheckboxListTileRenderer(), + paints + ..rrect(color: checkBoxBorderColor) + ..path(color: checkBoxCheckColor), + ); + }); + + testWidgets('Material3 - CheckboxListTile checkColor test', (WidgetTester tester) async { + const checkBoxBorderColor = Color(0xff6750a4); + var checkBoxCheckColor = const Color(0xffFFFFFF); + + Widget buildFrame(Color? color) { + return MaterialApp( + home: Material( + child: CheckboxListTile(value: true, checkColor: color, onChanged: (bool? value) {}), + ), + ); + } + + RenderBox getCheckboxListTileRenderer() { + return tester.renderObject<RenderBox>(find.byType(CheckboxListTile)); + } + + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + expect( + getCheckboxListTileRenderer(), + paints + ..rrect(color: checkBoxBorderColor) + ..path(color: checkBoxCheckColor), + ); + + checkBoxCheckColor = const Color(0xFF000000); + + await tester.pumpWidget(buildFrame(checkBoxCheckColor)); + await tester.pumpAndSettle(); + expect( + getCheckboxListTileRenderer(), + paints + ..rrect(color: checkBoxBorderColor) + ..path(color: checkBoxCheckColor), + ); + }); + + testWidgets('CheckboxListTile activeColor test', (WidgetTester tester) async { + Widget buildFrame(Color? themeColor, Color? activeColor) { + return wrap( + child: Theme( + data: ThemeData( + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.selected) ? themeColor : null; + }), + ), + ), + child: CheckboxListTile( + value: true, + activeColor: activeColor, + onChanged: (bool? value) {}, + ), + ), + ); + } + + RenderBox getCheckboxListTileRenderer() { + return tester.renderObject<RenderBox>(find.byType(CheckboxListTile)); + } + + await tester.pumpWidget(buildFrame(const Color(0xFF000000), null)); + await tester.pumpAndSettle(); + expect(getCheckboxListTileRenderer(), paints..rrect(color: const Color(0xFF000000))); + + await tester.pumpWidget(buildFrame(const Color(0xFF000000), const Color(0xFFFFFFFF))); + await tester.pumpAndSettle(); + expect(getCheckboxListTileRenderer(), paints..rrect(color: const Color(0xFFFFFFFF))); + }); + + testWidgets('CheckboxListTile can autofocus unless disabled.', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + value: true, + onChanged: (_) {}, + title: Text('Hello', key: childKey), + autofocus: true, + ), + ), + ); + + await tester.pump(); + expect(Focus.maybeOf(childKey.currentContext!)!.hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + value: true, + onChanged: null, + title: Text('Hello', key: childKey), + autofocus: true, + ), + ), + ); + + await tester.pump(); + expect(Focus.maybeOf(childKey.currentContext!)!.hasPrimaryFocus, isFalse); + }); + + testWidgets('CheckboxListTile contentPadding test', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + child: const Center( + child: CheckboxListTile( + value: false, + onChanged: null, + title: Text('Title'), + contentPadding: EdgeInsets.fromLTRB(10, 18, 4, 2), + ), + ), + ), + ); + + final Rect paddingRect = tester.getRect(find.byType(SafeArea)); + final Rect checkboxRect = tester.getRect(find.byType(Checkbox)); + final Rect titleRect = tester.getRect(find.text('Title')); + + final tallerWidget = checkboxRect.height > titleRect.height ? checkboxRect : titleRect; + + // Check the offsets of Checkbox and title after padding is applied. + expect(paddingRect.right, checkboxRect.right + 4); + expect(paddingRect.left, titleRect.left - 10); + + // Calculate the remaining height from the default ListTile height. + final double remainingHeight = 56 - tallerWidget.height; + expect(paddingRect.top, tallerWidget.top - remainingHeight / 2 - 18); + expect(paddingRect.bottom, tallerWidget.bottom + remainingHeight / 2 + 2); + }); + + testWidgets('CheckboxListTile tristate test', (WidgetTester tester) async { + bool? value = false; + var tristate = false; + + await tester.pumpWidget( + Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return wrap( + child: CheckboxListTile( + title: const Text('Title'), + tristate: tristate, + value: value, + onChanged: (bool? v) { + setState(() { + value = v; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, false); + + // Tap the checkbox when tristate is disabled. + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(value, true); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(value, false); + + // Tap the listTile when tristate is disabled. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + expect(value, true); + + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + expect(value, false); + + // Enable tristate + tristate = true; + await tester.pumpAndSettle(); + + expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, false); + + // Tap the checkbox when tristate is enabled. + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(value, true); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(value, null); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(value, false); + + // Tap the listTile when tristate is enabled. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + expect(value, true); + + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + expect(value, null); + + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + expect(value, false); + }); + + testWidgets('CheckboxListTile respects shape', (WidgetTester tester) async { + const ShapeBorder shapeBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal(right: Radius.circular(100)), + ); + + await tester.pumpWidget( + wrap( + child: const CheckboxListTile( + value: false, + onChanged: null, + title: Text('Title'), + shape: shapeBorder, + ), + ), + ); + + expect(tester.widget<InkWell>(find.byType(InkWell)).customBorder, shapeBorder); + }); + + testWidgets('CheckboxListTile respects tileColor', (WidgetTester tester) async { + final Color tileColor = Colors.red.shade500; + + await tester.pumpWidget( + wrap( + child: Center( + child: CheckboxListTile( + value: false, + onChanged: null, + title: const Text('Title'), + tileColor: tileColor, + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: tileColor)); + }); + + testWidgets('CheckboxListTile respects selectedTileColor', (WidgetTester tester) async { + final Color selectedTileColor = Colors.green.shade500; + + await tester.pumpWidget( + wrap( + child: Center( + child: CheckboxListTile( + value: false, + onChanged: null, + title: const Text('Title'), + selected: true, + selectedTileColor: selectedTileColor, + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: selectedTileColor)); + }); + + testWidgets('CheckboxListTile selected item text Color', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/76908 + + const activeColor = Color(0xff00ff00); + + Widget buildFrame({Color? activeColor, Color? fillColor}) { + return MaterialApp( + theme: ThemeData( + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.selected) ? fillColor : null; + }), + ), + ), + home: Scaffold( + body: Center( + child: CheckboxListTile( + activeColor: activeColor, + selected: true, + title: const Text('title'), + value: true, + onChanged: (bool? value) {}, + ), + ), + ), + ); + } + + Color? textColor(String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style?.color; + } + + await tester.pumpWidget(buildFrame(fillColor: activeColor)); + expect(textColor('title'), activeColor); + + await tester.pumpWidget(buildFrame(activeColor: activeColor)); + expect(textColor('title'), activeColor); + }); + + testWidgets('CheckboxListTile respects checkbox shape and side', (WidgetTester tester) async { + Widget buildApp(BorderSide side, OutlinedBorder shape) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + value: false, + onChanged: (bool? newValue) {}, + side: side, + checkboxShape: shape, + ); + }, + ), + ), + ), + ); + } + + const border1 = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))); + const side1 = BorderSide(color: Color(0xfff44336)); + await tester.pumpWidget(buildApp(side1, border1)); + expect(tester.widget<CheckboxListTile>(find.byType(CheckboxListTile)).side, side1); + expect(tester.widget<CheckboxListTile>(find.byType(CheckboxListTile)).checkboxShape, border1); + expect(tester.widget<Checkbox>(find.byType(Checkbox)).side, side1); + expect(tester.widget<Checkbox>(find.byType(Checkbox)).shape, border1); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..drrect( + color: const Color(0xfff44336), + outer: RRect.fromLTRBR(11.0, 11.0, 29.0, 29.0, const Radius.circular(5)), + inner: RRect.fromLTRBR(12.0, 12.0, 28.0, 28.0, const Radius.circular(4)), + ), + ); + const border2 = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))); + const side2 = BorderSide(width: 4.0, color: Color(0xff424242)); + await tester.pumpWidget(buildApp(side2, border2)); + expect(tester.widget<Checkbox>(find.byType(Checkbox)).side, side2); + expect(tester.widget<Checkbox>(find.byType(Checkbox)).shape, border2); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..drrect( + color: const Color(0xff424242), + outer: RRect.fromLTRBR(11.0, 11.0, 29.0, 29.0, const Radius.circular(5)), + inner: RRect.fromLTRBR(15.0, 15.0, 25.0, 25.0, const Radius.circular(1)), + ), + ); + }); + + testWidgets('CheckboxListTile respects visualDensity', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + wrap( + child: Center( + child: CheckboxListTile( + key: key, + value: false, + onChanged: (bool? value) {}, + autofocus: true, + visualDensity: visualDensity, + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byKey(key)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 56))); + }); + + testWidgets('CheckboxListTile respects focusNode', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + wrap( + child: Center( + child: CheckboxListTile( + value: false, + title: Text('A', key: childKey), + onChanged: (bool? value) {}, + ), + ), + ), + ); + + await tester.pump(); + final FocusNode tileNode = Focus.of(childKey.currentContext!); + tileNode.requestFocus(); + await tester.pump(); // Let the focus take effect. + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + expect(tileNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('CheckboxListTile onFocusChange callback', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'CheckboxListTile onFocusChange'); + var gotFocus = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CheckboxListTile( + value: true, + focusNode: node, + onFocusChange: (bool focused) { + gotFocus = focused; + }, + onChanged: (bool? value) {}, + ), + ), + ), + ); + + node.requestFocus(); + await tester.pump(); + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + + node.dispose(); + }); + + testWidgets('CheckboxListTile can be disabled', (WidgetTester tester) async { + bool? value = false; + var enabled = true; + + await tester.pumpWidget( + Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return wrap( + child: CheckboxListTile( + title: const Text('Title'), + enabled: enabled, + value: value, + onChanged: (bool? v) { + setState(() { + value = v; + enabled = !enabled; + }); + }, + ), + ); + }, + ), + ), + ); + + final Finder checkbox = find.byType(Checkbox); + // verify initial values + expect(tester.widget<Checkbox>(checkbox).value, false); + expect(enabled, true); + + // Tap the checkbox to disable CheckboxListTile + await tester.tap(checkbox); + await tester.pumpAndSettle(); + expect(tester.widget<Checkbox>(checkbox).value, true); + expect(enabled, false); + await tester.tap(checkbox); + await tester.pumpAndSettle(); + expect(tester.widget<Checkbox>(checkbox).value, true); + }); + + testWidgets('CheckboxListTile respects mouseCursor when hovered', (WidgetTester tester) async { + // Test Checkbox() constructor + await tester.pumpWidget( + wrap( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CheckboxListTile( + mouseCursor: SystemMouseCursors.text, + value: true, + onChanged: (_) {}, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Checkbox))); + + await tester.pump(); + + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget(wrap(child: CheckboxListTile(value: true, onChanged: (_) {}))); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + wrap( + child: const MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CheckboxListTile(value: true, onChanged: null), + ), + ), + ); + + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test cursor when tristate + await tester.pumpWidget( + wrap( + child: const MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: CheckboxListTile( + value: null, + tristate: true, + onChanged: null, + mouseCursor: _SelectedGrabMouseCursor(), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('CheckboxListTile respects fillColor in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledFillColor = Color(0xFF000001); + const activeDisabledFillColor = Color(0xFF000002); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return activeDisabledFillColor; + } + return activeEnabledFillColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildFrame({required bool enabled}) { + return wrap( + child: CheckboxListTile( + value: true, + fillColor: fillColor, + onChanged: enabled ? (bool? value) {} : null, + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeEnabledFillColor)); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeDisabledFillColor)); + }); + + testWidgets('CheckboxListTile respects fillColor in hovered state', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredFillColor = Color(0xFF000001); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredFillColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildFrame() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile(value: true, fillColor: fillColor, onChanged: (bool? value) {}); + }, + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect(getCheckboxRenderer(), paints..rrect(color: hoveredFillColor)); + }); + + testWidgets('CheckboxListTile respects hoverColor', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(style: PaintingStyle.fill) + ..path(style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..rrect(style: PaintingStyle.fill) + ..path(style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check what happens when disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(style: PaintingStyle.fill) + ..path(style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets( + 'Material2 - CheckboxListTile respects overlayColor in active/pressed/hovered states', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const fillColor = Color(0xFF000000); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const hoverColor = Color(0xFF000005); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Widget buildCheckbox({bool active = false, bool useOverlay = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: CheckboxListTile( + value: active, + onChanged: (_) {}, + fillColor: const MaterialStatePropertyAll<Color>(fillColor), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + splashRadius: splashRadius, + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox(useOverlay: false)); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle() + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default inactive pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true, useOverlay: false)); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle() + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default active pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture3 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle() + ..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + final TestGesture gesture4 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle() + ..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + // Start hovering + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildCheckbox()); + await tester.pumpAndSettle(); + + final TestGesture gesture5 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture5.addPointer(); + await gesture5.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: hoverOverlayColor, radius: splashRadius), + reason: 'Hovered Checkbox should use overlay color $hoverOverlayColor over $hoverColor', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await gesture3.up(); + await gesture4.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'Material3 - CheckboxListTile respects overlayColor in active/pressed/hovered states', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const fillColor = Color(0xFF000000); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const hoverColor = Color(0xFF000005); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Widget buildCheckbox({bool active = false, bool useOverlay = true}) { + return MaterialApp( + home: Material( + child: CheckboxListTile( + value: active, + onChanged: (_) {}, + fillColor: const MaterialStatePropertyAll<Color>(fillColor), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + splashRadius: splashRadius, + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox(useOverlay: false)); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + kIsWeb + ? (paints + ..circle() + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius)) + : (paints + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius)), + reason: 'Default inactive pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true, useOverlay: false)); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + kIsWeb + ? (paints + ..circle() + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius)) + : (paints + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius)), + reason: 'Default active pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture3 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + kIsWeb + ? (paints + ..circle() + ..circle(color: inactivePressedOverlayColor, radius: splashRadius)) + : (paints..circle(color: inactivePressedOverlayColor, radius: splashRadius)), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + final TestGesture gesture4 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + kIsWeb + ? (paints + ..circle() + ..circle(color: activePressedOverlayColor, radius: splashRadius)) + : (paints..circle(color: activePressedOverlayColor, radius: splashRadius)), + reason: 'Active pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + // Start hovering + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildCheckbox()); + await tester.pumpAndSettle(); + + final TestGesture gesture5 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture5.addPointer(); + await gesture5.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: hoverOverlayColor, radius: splashRadius), + reason: 'Hovered Checkbox should use overlay color $hoverOverlayColor over $hoverColor', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await gesture3.up(); + await gesture4.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('CheckboxListTile respects splashRadius', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 30; + Widget buildApp() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + value: false, + onChanged: (bool? newValue) {}, + hoverColor: Colors.orange[500], + splashRadius: splashRadius, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('CheckboxListTile respects materialTapTargetSize', (WidgetTester tester) async { + await tester.pumpWidget( + wrap(child: CheckboxListTile(value: true, onChanged: (bool? newValue) {})), + ); + + // default test + expect(tester.getSize(find.byType(Checkbox)), const Size(40.0, 40.0)); + + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + materialTapTargetSize: MaterialTapTargetSize.padded, + value: true, + onChanged: (bool? newValue) {}, + ), + ), + ); + + expect(tester.getSize(find.byType(Checkbox)), const Size(48.0, 48.0)); + }); + + testWidgets('Material3 - CheckboxListTile respects isError', (WidgetTester tester) async { + final themeData = ThemeData(); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp() { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile( + isError: true, + value: value, + onChanged: (bool? newValue) { + setState(() { + value = newValue; + }); + }, + ); + }, + ), + ), + ), + ); + } + + // Default color + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(color: themeData.colorScheme.error) + ..path(color: themeData.colorScheme.onError), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: themeData.colorScheme.error.withOpacity(0.08)) + ..rrect(color: themeData.colorScheme.error), + ); + }); + + testWidgets('CheckboxListTile.adaptive shows the correct checkbox platform widget', ( + WidgetTester tester, + ) async { + Widget buildApp(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CheckboxListTile.adaptive(value: false, onChanged: (bool? newValue) {}); + }, + ), + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoCheckbox), findsOneWidget); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoCheckbox), findsNothing); + } + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('CheckboxListTile respects enableFeedback', (WidgetTester tester) async { + Future<void> buildTest(bool enableFeedback) async { + return tester.pumpWidget( + wrap( + child: Center( + child: CheckboxListTile( + value: false, + onChanged: (bool? value) {}, + enableFeedback: enableFeedback, + ), + ), + ), + ); + } + + await buildTest(false); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await buildTest(true); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + }); + + testWidgets('CheckboxListTile has proper semantics', (WidgetTester tester) async { + final log = <dynamic>[]; + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + wrap( + child: CheckboxListTile( + value: true, + onChanged: (bool? value) { + log.add(value); + }, + title: const Text('Hello'), + checkboxSemanticLabel: 'there', + internalAddSemanticForOnTap: true, + ), + ), + ); + + expect( + tester.getSemantics(find.byType(CheckboxListTile)), + matchesSemantics( + isButton: true, + hasCheckedState: true, + isChecked: true, + hasEnabledState: true, + isEnabled: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + label: 'Hello\nthere', + ), + ); + + handle.dispose(); + }); + + testWidgets('CheckboxListTile.control widget should not request focus on traversal', ( + WidgetTester tester, + ) async { + final GlobalKey firstChildKey = GlobalKey(); + final GlobalKey secondChildKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + CheckboxListTile( + value: true, + onChanged: (bool? value) {}, + title: Text('Hey', key: firstChildKey), + ), + CheckboxListTile( + value: true, + onChanged: (bool? value) {}, + title: Text('There', key: secondChildKey), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + Focus.of(firstChildKey.currentContext!).requestFocus(); + await tester.pump(); + expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isTrue); + Focus.of(firstChildKey.currentContext!).nextFocus(); + await tester.pump(); + expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse); + expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue); + }); + + testWidgets('CheckboxListTile uses ListTileTheme controlAffinity', (WidgetTester tester) async { + Widget buildListTile(ListTileControlAffinity controlAffinity) { + return MaterialApp( + home: Material( + child: ListTileTheme( + data: ListTileThemeData(controlAffinity: controlAffinity), + child: CheckboxListTile(value: false, onChanged: (bool? value) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildListTile(ListTileControlAffinity.trailing)); + final Finder trailing = find.byType(Checkbox); + final Offset offsetTrailing = tester.getTopLeft(trailing); + expect(offsetTrailing, const Offset(736.0, 8.0)); + + await tester.pumpWidget(buildListTile(ListTileControlAffinity.leading)); + final Finder leading = find.byType(Checkbox); + final Offset offsetLeading = tester.getTopLeft(leading); + expect(offsetLeading, const Offset(16.0, 8.0)); + + await tester.pumpWidget(buildListTile(ListTileControlAffinity.platform)); + final Finder platform = find.byType(Checkbox); + final Offset offsetPlatform = tester.getTopLeft(platform); + expect(offsetPlatform, const Offset(736.0, 8.0)); + }); + + testWidgets('CheckboxListTile renders with default scale', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: CheckboxListTile(value: false, onChanged: null))), + ); + + final Finder transformFinder = find.ancestor( + of: find.byType(Checkbox), + matching: find.byType(Transform), + ); + + expect(transformFinder, findsNothing); + }); + + testWidgets('CheckboxListTile respects checkboxScaleFactor', (WidgetTester tester) async { + const scale = 1.5; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: CheckboxListTile(value: false, onChanged: null, checkboxScaleFactor: scale), + ), + ), + ); + + final Transform widget = tester.widget( + find.ancestor(of: find.byType(Checkbox), matching: find.byType(Transform)), + ); + + expect(widget.transform.getMaxScaleOnAxis(), scale); + }); + + testWidgets('CheckboxListTile isThreeLine', (WidgetTester tester) async { + const double height = 300; + const switchTop = 130.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: themeDataIsThreeLine != null + ? ThemeData(listTileTheme: ListTileThemeData(isThreeLine: themeDataIsThreeLine)) + : null, + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + CheckboxListTile( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + value: false, + onChanged: null, + ), + CheckboxListTile( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A'), + value: false, + onChanged: null, + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(CheckboxListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Checkbox).at(0)), + const Rect.fromLTWH(800.0 - 40.0 - 24.0, switchTop, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(CheckboxListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(Checkbox).at(1)), + const Rect.fromLTWH(800.0 - 40.0 - 24.0, height + 16, 40.0, 40.0), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(CheckboxListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Checkbox).at(0)), + const Rect.fromLTWH(800.0 - 40.0 - 24.0, 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(CheckboxListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(Checkbox).at(1)), + const Rect.fromLTWH(800.0 - 40.0 - 24.0, height + 8.0, 40.0, 40.0), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('CheckboxListTile.adaptive isThreeLine', (WidgetTester tester) async { + const double height = 300; + const switchTop = 128.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: ThemeData( + platform: TargetPlatform.iOS, + listTileTheme: themeDataIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeDataIsThreeLine) + : null, + ), + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + CheckboxListTile.adaptive( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + value: false, + onChanged: null, + ), + CheckboxListTile.adaptive( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A'), + value: false, + onChanged: null, + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(CheckboxListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Checkbox).at(0)), + const Rect.fromLTWH(800.0 - 44.0 - 24.0, switchTop, 44.0, 44.0), + ); + expect( + tester.getRect(find.byType(CheckboxListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(Checkbox).at(1)), + const Rect.fromLTWH(800.0 - 44.0 - 24.0, height + 14, 44.0, 44.0), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(CheckboxListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Checkbox).at(0)), + const Rect.fromLTWH(800.0 - 44.0 - 24.0, 8.0, 44.0, 44.0), + ); + expect( + tester.getRect(find.byType(CheckboxListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(Checkbox).at(1)), + const Rect.fromLTWH(800.0 - 44.0 - 24.0, height + 8.0, 44.0, 44.0), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('titleAlignment position with title widget', (WidgetTester tester) async { + const secondaryKey = Key('secondary'); + const titleHeight = 50.0; + const secondaryHeight = 24.0; + // The default vertical padding for material 3 is 8.0. + const minVerticalPadding = 8.0; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + home: Material( + child: Center( + child: CheckboxListTile( + titleAlignment: titleAlignment, + controlAffinity: ListTileControlAffinity.leading, + value: true, + onChanged: (bool? newValue) {}, + title: const SizedBox(width: 20.0, height: titleHeight), + secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight), + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, the default title alignment is + // [ListTileTitleAlignment.threeLine], which positions the leading and + // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] + // property is false. + await tester.pumpWidget(buildFrame()); + final double checkboxHeight = tester.getSize(find.byType(Checkbox)).height; + final double tileHeight = tester.getSize(find.byType(ListTile)).height; + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == null; + }), + findsOne, + ); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + final double centerPositionCheckbox = (tileHeight / 2) - (checkboxHeight / 2); + final double centerPositionSecondary = (tileHeight / 2) - (secondaryHeight / 2); + expect(checkboxOffset.dy - tileOffset.dy, centerPositionCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(checkboxOffset.dy - tileOffset.dy, centerPositionCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + expect(checkboxOffset.dy - tileOffset.dy, (tileHeight - checkboxHeight) / 2); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. The default for material 3 is 8.0. + const topPosition = minVerticalPadding; + expect(checkboxOffset.dy - tileOffset.dy, topPosition); + expect(secondaryOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + expect(checkboxOffset.dy - tileOffset.dy, centerPositionCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + final double bottomPositionCheckbox = tileHeight - minVerticalPadding - checkboxHeight; + final double bottomPositionSecondary = tileHeight - minVerticalPadding - secondaryHeight; + expect(checkboxOffset.dy - tileOffset.dy, bottomPositionCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary); + }); + + testWidgets('titleAlignment position with title and subtitle widgets', ( + WidgetTester tester, + ) async { + const secondaryKey = Key('secondary'); + const titleHeight = 50.0; + const subtitleHeight = 50.0; + const secondaryHeight = 24.0; + const verticalPadding = 8.0; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + home: Material( + child: Center( + child: CheckboxListTile( + titleAlignment: titleAlignment, + controlAffinity: ListTileControlAffinity.leading, + title: const SizedBox(width: 20.0, height: titleHeight), + subtitle: const SizedBox(width: 20.0, height: subtitleHeight), + secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight), + value: true, + onChanged: (bool? newValue) {}, + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, the default title alignment is + // [ListTileTitleAlignment.threeLine], which positions the leading and + // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] + // property is false. + await tester.pumpWidget(buildFrame()); + final double tileHeight = tester.getSize(find.byType(ListTile)).height; + final double checkboxHeight = tester.getSize(find.byType(Checkbox)).height; + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == null; + }), + findsOne, + ); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + final double centerPositionOffsetCheckbox = (tileHeight / 2) - (checkboxHeight / 2); + final double centerPositionOffsetSecondary = (tileHeight / 2) - (secondaryHeight / 2); + expect(checkboxOffset.dy - tileOffset.dy, centerPositionOffsetCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(checkboxOffset.dy - tileOffset.dy, centerPositionOffsetCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are positioned 16.0 pixels below the + // top of the title widget. + const titlePosition = 16.0; + expect(checkboxOffset.dy - tileOffset.dy, titlePosition); + expect(secondaryOffset.dy - tileOffset.dy, titlePosition); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. + const topPosition = verticalPadding; + expect(checkboxOffset.dy - tileOffset.dy, topPosition); + expect(secondaryOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + expect(checkboxOffset.dy - tileOffset.dy, centerPositionOffsetCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + checkboxOffset = tester.getTopLeft(find.byType(Checkbox)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + final double bottomPositionCheckbox = tileHeight - verticalPadding - checkboxHeight; + final double bottomPositionSecondary = tileHeight - verticalPadding - secondaryHeight; + expect(checkboxOffset.dy - tileOffset.dy, bottomPositionCheckbox); + expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary); + }); + + testWidgets('CheckboxListTile does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold( + body: wrap( + child: CheckboxListTile(value: true, onChanged: (_) {}, title: const Text('X')), + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CheckboxListTile)), Size.zero); + }); + + testWidgets('CheckboxListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeHorizontalTitleGap, + double? widgetHorizontalTitleGap, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + horizontalTitleGap: widgetHorizontalTitleGap, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + }); + + testWidgets( + 'CheckboxListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', + (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(left('title'), 72.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(right('title'), 728.0); + }, + ); + + testWidgets('CheckboxListTile horizontalTitleGap with visualDensity', ( + WidgetTester tester, + ) async { + Widget buildFrame({double? horizontalTitleGap, VisualDensity? visualDensity}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + visualDensity: visualDensity, + horizontalTitleGap: horizontalTitleGap, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + + // Pump another frame of the same widget to ensure the underlying render + // object did not cache the original horizontalTitleGap calculation based on the + // visualDensity + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + }); + + testWidgets('CheckboxListTile minVerticalPadding = 80.0 Material 3', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile( + minVerticalPadding: widgetMinVerticalPadding, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 24(Title) = 184 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 24(Title) = 184 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + }); + + testWidgets('CheckboxListTile minVerticalPadding = 80.0 Material 2', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile( + minVerticalPadding: widgetMinVerticalPadding, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + }); + + testWidgets('CheckboxListTile minLeadingWidth = 60.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinLeadingWidth, + double? widgetMinLeadingWidth, + }) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minLeadingWidth: themeMinLeadingWidth), + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile( + controlAffinity: ListTileControlAffinity.leading, + minLeadingWidth: widgetMinLeadingWidth, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 92.0 = 16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0 + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 708.0 = 800.0 - (16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0) + expect(right('title'), 708.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + }); + + testWidgets('CheckboxListTile minTileHeight', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection, {double? minTileHeight}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: CheckboxListTile(value: true, minTileHeight: minTileHeight, onChanged: (_) {}), + ), + ), + ), + ); + } + + // Default list tile with height = 56.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + + // Set list tile height = 30.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 30)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 30.0)); + + // Set list tile height = 60.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 60.0)); + }); +} + +class _SelectedGrabMouseCursor extends WidgetStateMouseCursor { + const _SelectedGrabMouseCursor(); + + @override + MouseCursor resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return SystemMouseCursors.grab; + } + return SystemMouseCursors.basic; + } + + @override + String get debugDescription => '_SelectedGrabMouseCursor()'; +} diff --git a/packages/material_ui/test/material/checkbox_test.dart b/packages/material_ui/test/material/checkbox_test.dart new file mode 100644 index 000000000000..b19ca5d11912 --- /dev/null +++ b/packages/material_ui/test/material/checkbox_test.dart @@ -0,0 +1,2502 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/src/gestures/constants.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + final theme = ThemeData(); + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('Checkbox size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Theme( + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center(child: Checkbox(value: true, onChanged: (bool? newValue) {})), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(Checkbox)), const Size(48.0, 48.0)); + + await tester.pumpWidget( + Theme( + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center(child: Checkbox(value: true, onChanged: (bool? newValue) {})), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(Checkbox)), const Size(40.0, 40.0)); + }); + + testWidgets('Checkbox semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material(child: Checkbox(value: false, onChanged: (bool? b) {})), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material(child: Checkbox(value: true, onChanged: (bool? b) {})), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Checkbox(value: false, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(Checkbox)), + matchesSemantics( + hasCheckedState: true, + hasEnabledState: true, + // isFocusable is delayed by 1 frame. + isFocusable: true, + hasFocusAction: true, + ), + ); + + await tester.pump(); + // isFocusable should be false now after the 1 frame delay. + expect( + tester.getSemantics(find.byType(Checkbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Checkbox(value: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(Checkbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true, isChecked: true), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Checkbox(value: null, tristate: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(Checkbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true, isCheckStateMixed: true), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Checkbox(value: true, tristate: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(Checkbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true, isChecked: true), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Checkbox(value: false, tristate: true, onChanged: null)), + ), + ); + + expect( + tester.getSemantics(find.byType(Checkbox)), + matchesSemantics(hasCheckedState: true, hasEnabledState: true), + ); + + // Check if semanticLabel is there. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: theme, + child: Material( + child: Checkbox(semanticLabel: 'checkbox', value: true, onChanged: (bool? b) {}), + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + label: 'checkbox', + textDirection: TextDirection.ltr, + hasCheckedState: true, + hasEnabledState: true, + isChecked: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('Can wrap Checkbox with Semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Semantics( + label: 'foo', + textDirection: TextDirection.ltr, + child: Checkbox(value: false, onChanged: (bool? b) {}), + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(Focus).last), + matchesSemantics( + label: 'foo', + textDirection: TextDirection.ltr, + hasCheckedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('Checkbox tristate: true', (WidgetTester tester) async { + bool? checkBoxValue; + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + tristate: true, + value: checkBoxValue, + onChanged: (bool? value) { + setState(() { + checkBoxValue = value; + }); + }, + ); + }, + ), + ), + ), + ); + + expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, null); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + + checkBoxValue = true; + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + checkBoxValue = null; + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); + + testWidgets('has semantics for tristate', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Checkbox(tristate: true, value: null, onChanged: (bool? newValue) {}), + ), + ), + ); + + expect( + semantics.nodesWith( + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isCheckStateMixed, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + hasLength(1), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Checkbox(tristate: true, value: true, onChanged: (bool? newValue) {}), + ), + ), + ); + + expect( + semantics.nodesWith( + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isChecked, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + hasLength(1), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Checkbox(tristate: true, value: false, onChanged: (bool? newValue) {}), + ), + ), + ); + + expect( + semantics.nodesWith( + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + hasLength(1), + ); + + semantics.dispose(); + }); + + testWidgets('has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + bool? checkboxValue = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: checkboxValue, + onChanged: (bool? value) { + setState(() { + checkboxValue = value; + }); + }, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(Checkbox)); + final RenderObject object = tester.firstRenderObject(find.byType(Checkbox)); + + expect(checkboxValue, true); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + semanticsTester.dispose(); + }); + + testWidgets('Material2 - Checkbox tristate rendering, programmatic transitions', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + Widget buildFrame(bool? checkboxValue) { + return Theme( + data: theme, + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox(tristate: true, value: checkboxValue, onChanged: (bool? value) {}); + }, + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..rrect(color: Colors.transparent), + ); // paint transparent border + expect(getCheckboxRenderer(), isNot(paints..line())); // null is rendered as a line (a "dash") + expect(getCheckboxRenderer(), paints..drrect()); // empty checkbox + + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: theme.colorScheme.secondary) + ..path(color: const Color(0xFFFFFFFF)), + ); // checkmark is rendered as a path + + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..rrect(color: Colors.transparent), + ); // paint transparent border + expect(getCheckboxRenderer(), isNot(paints..line())); // null is rendered as a line (a "dash") + expect(getCheckboxRenderer(), paints..drrect()); // empty checkbox + + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..line()); // null is rendered as a line (a "dash") + + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: theme.colorScheme.secondary) + ..path(color: const Color(0xFFFFFFFF)), + ); // checkmark is rendered as a path + + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..line()); // null is rendered as a line (a "dash") + }); + + testWidgets('Material3 - Checkbox tristate rendering, programmatic transitions', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + Widget buildFrame(bool? checkboxValue) { + return Theme( + data: theme, + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox(tristate: true, value: checkboxValue, onChanged: (bool? value) {}); + }, + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..rrect(color: Colors.transparent), + ); // paint transparent border + expect(getCheckboxRenderer(), isNot(paints..line())); // null is rendered as a line (a "dash") + expect(getCheckboxRenderer(), paints..drrect()); // empty checkbox + + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: theme.colorScheme.primary) + ..path(color: theme.colorScheme.onPrimary), + ); // checkmark is rendered as a path + + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..rrect(color: Colors.transparent), + ); // paint transparent border + expect(getCheckboxRenderer(), isNot(paints..line())); // null is rendered as a line (a "dash") + expect(getCheckboxRenderer(), paints..drrect()); // empty checkbox + + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..line()); // null is rendered as a line (a "dash") + + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: theme.colorScheme.primary) + ..path(color: theme.colorScheme.onPrimary), + ); // checkmark is rendered as a path + + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..line()); // null is rendered as a line (a "dash") + }); + + testWidgets('Material2 - Checkbox color rendering', (WidgetTester tester) async { + var theme = ThemeData(useMaterial3: false); + const borderColor = Color(0xff2196f3); + var checkColor = const Color(0xffFFFFFF); + Color activeColor; + + Widget buildFrame({Color? activeColor, Color? checkColor, ThemeData? themeData}) { + return Material( + child: Theme( + data: themeData ?? theme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: true, + activeColor: activeColor, + checkColor: checkColor, + onChanged: (bool? value) {}, + ); + }, + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(checkColor: checkColor)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: borderColor) + ..path(color: checkColor), + ); // paints's color is 0xFFFFFFFF (default color) + + checkColor = const Color(0xFF000000); + + await tester.pumpWidget(buildFrame(checkColor: checkColor)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: borderColor) + ..path(color: checkColor), + ); // paints's color is 0xFF000000 (params) + + activeColor = const Color(0xFF00FF00); + + final ColorScheme colorScheme = const ColorScheme.light().copyWith(secondary: activeColor); + theme = theme.copyWith(colorScheme: colorScheme); + await tester.pumpWidget(buildFrame(themeData: theme)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..rrect(color: activeColor), + ); // paints's color is 0xFF00FF00 (theme) + + activeColor = const Color(0xFF000000); + + await tester.pumpWidget(buildFrame(activeColor: activeColor)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeColor)); + }); + + testWidgets('Material3 - Checkbox color rendering', (WidgetTester tester) async { + var theme = ThemeData(); + const borderColor = Color(0xFF6750A4); + var checkColor = const Color(0xffFFFFFF); + Color activeColor; + + Widget buildFrame({Color? activeColor, Color? checkColor, ThemeData? themeData}) { + return Material( + child: Theme( + data: themeData ?? theme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: true, + activeColor: activeColor, + checkColor: checkColor, + onChanged: (bool? value) {}, + ); + }, + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(checkColor: checkColor)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: borderColor) + ..path(color: checkColor), + ); // paints's color is 0xFFFFFFFF (default color) + + checkColor = const Color(0xFF000000); + + await tester.pumpWidget(buildFrame(checkColor: checkColor)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints + ..rrect(color: borderColor) + ..path(color: checkColor), + ); // paints's color is 0xFF000000 (params) + + activeColor = const Color(0xFF00FF00); + + final ColorScheme colorScheme = const ColorScheme.light().copyWith(primary: activeColor); + theme = theme.copyWith(colorScheme: colorScheme); + await tester.pumpWidget(buildFrame(themeData: theme)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..rrect(color: activeColor), + ); // paints's color is 0xFF00FF00 (theme) + + activeColor = const Color(0xFF000000); + + await tester.pumpWidget(buildFrame(activeColor: activeColor)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeColor)); + }); + + testWidgets('Material2 - Checkbox is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..rrect(color: const Color(0xff2196f3)) + ..path(color: Colors.white), + ); + + // Check the false value. + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..drrect( + color: const Color(0x8a000000), + outer: RRect.fromLTRBR(15.0, 15.0, 33.0, 33.0, const Radius.circular(1.0)), + inner: RRect.fromLTRBR(17.0, 17.0, 31.0, 31.0, Radius.zero), + ), + ); + + // Check what happens when disabled. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..drrect( + color: const Color(0x61000000), + outer: RRect.fromLTRBR(15.0, 15.0, 33.0, 33.0, const Radius.circular(1.0)), + inner: RRect.fromLTRBR(17.0, 17.0, 31.0, 31.0, Radius.zero), + ), + ); + }); + + testWidgets('Material3 - Checkbox is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + final theme = ThemeData(); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..rrect(color: theme.colorScheme.primary) + ..path(color: theme.colorScheme.onPrimary), + ); + + // Check the false value. + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..drrect( + color: theme.colorScheme.onSurface, + outer: RRect.fromLTRBR(15.0, 15.0, 33.0, 33.0, const Radius.circular(2.0)), + inner: RRect.fromLTRBR(17.0, 17.0, 31.0, 31.0, Radius.zero), + ), + ); + + // Check what happens when disabled. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..drrect( + color: theme.colorScheme.onSurface.withOpacity(0.38), + outer: RRect.fromLTRBR(15.0, 15.0, 33.0, 33.0, const Radius.circular(2.0)), + inner: RRect.fromLTRBR(17.0, 17.0, 31.0, 31.0, Radius.zero), + ), + ); + }); + + testWidgets('Checkbox with splash radius set', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 30; + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: false, + onChanged: (bool? newValue) {}, + focusColor: Colors.orange[500], + autofocus: true, + splashRadius: splashRadius, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('Checkbox starts the splash in center, even when tap is on the corner', ( + WidgetTester tester, + ) async { + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox(value: false, onChanged: (bool? newValue) {}); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + final Offset checkboxTopLeftGlobal = tester.getTopLeft(find.byType(Checkbox)); + final Offset checkboxCenterGlobal = tester.getCenter(find.byType(Checkbox)); + final Offset checkboxCenterLocal = checkboxCenterGlobal - checkboxTopLeftGlobal; + final TestGesture gesture = await tester.startGesture(checkboxTopLeftGlobal); + await tester.pump(); + // Wait for the splash to be drawn, but not long enough for it to animate towards the center, since + // we want to catch it in its starting position. + await tester.pump(const Duration(milliseconds: 1)); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(x: checkboxCenterLocal.dx, y: checkboxCenterLocal.dy), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Material2 - Checkbox can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + final theme = ThemeData(useMaterial3: false); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(color: const Color(0xff2196f3)) + ..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..rrect(color: const Color(0xff2196f3)) + ..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check what happens when disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(color: const Color(0x61000000)) + ..path(color: const Color(0xffffffff), style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets('Material3 - Checkbox can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + final theme = ThemeData(); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(color: const Color(0xff6750a4)) + ..path(color: theme.colorScheme.onPrimary, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: Colors.orange[500]) + ..rrect(color: const Color(0xff6750a4)) + ..path(color: theme.colorScheme.onPrimary, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check what happens when disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(color: theme.colorScheme.onSurface.withOpacity(0.38)) + ..path(color: theme.colorScheme.surface, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets('Checkbox can be toggled by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + onChanged: enabled + ? (bool? newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + // On web, checkboxes don't respond to the enter key. + expect(value, kIsWeb ? isTrue : isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(value, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isTrue); + }); + + testWidgets( + 'Material3 - Checkbox visual density cannot be overriden by ThemeData.visualDensity', + (WidgetTester tester) async { + const key = Key('test'); + Widget buldCheckbox() { + return MaterialApp( + theme: theme.copyWith(visualDensity: VisualDensity.compact), + home: Material( + child: Center( + child: Checkbox(key: key, value: true, onChanged: (bool? value) {}), + ), + ), + ); + } + + await tester.pumpWidget(buldCheckbox()); + await tester.pumpAndSettle(); + final RenderBox box = tester.renderObject(find.byKey(key)); + expect(box.size, equals(const Size(48, 48))); + }, + ); + + testWidgets( + 'Material3 - Checkbox with MaterialTapTargetSize.padded meets Material Guidelines on desktop', + (WidgetTester tester) async { + const key = Key('test'); + Widget buldCheckbox() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Checkbox( + key: key, + materialTapTargetSize: MaterialTapTargetSize.padded, + value: true, + onChanged: (bool? value) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buldCheckbox()); + await tester.pumpAndSettle(); + final RenderBox box = tester.renderObject(find.byKey(key)); + expect(box.size, equals(const Size(48, 48))); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('Checkbox responds to density changes', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest({VisualDensity? visualDensity}) async { + return tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Checkbox( + visualDensity: visualDensity, + key: key, + onChanged: (bool? value) {}, + value: true, + ), + ), + ), + ), + ); + } + + // Test the default visual density. + await buildTest(); + await tester.pumpAndSettle(); + final RenderBox box = tester.renderObject(find.byKey(key)); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(visualDensity: VisualDensity.standard); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(visualDensity: VisualDensity.compact); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(40, 40))); + + await buildTest(visualDensity: const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(60, 60))); + + await buildTest(visualDensity: const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(36, 36))); + + await buildTest(visualDensity: const VisualDensity(horizontal: 3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(60, 36))); + }); + + testWidgets('Checkbox stops hover animation when removed from the tree.', ( + WidgetTester tester, + ) async { + const checkboxKey = Key('checkbox'); + bool? checkboxVal = true; + + await tester.pumpWidget( + Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: StatefulBuilder( + builder: (_, StateSetter setState) => Checkbox( + key: checkboxKey, + value: checkboxVal, + onChanged: (bool? newValue) => setState(() { + checkboxVal = newValue; + }), + ), + ), + ), + ), + ), + ), + ); + + expect(find.byKey(checkboxKey), findsOneWidget); + final Offset checkboxCenter = tester.getCenter(find.byKey(checkboxKey)); + final TestGesture testGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await testGesture.moveTo(checkboxCenter); + + await tester.pump(); // start animation + await tester.pump( + const Duration(milliseconds: 25), + ); // hover animation duration is 50 ms. It is half-way. + + await tester.pumpWidget( + Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Center(child: Container())), + ), + ), + ); + + // Hover animation should not trigger an exception when the checkbox is removed + // before the hover animation should complete. + expect(tester.takeException(), isNull); + + await testGesture.removePointer(); + }); + + testWidgets('Checkbox changes mouse cursor when hovered', (WidgetTester tester) async { + // Test Checkbox() constructor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Checkbox( + mouseCursor: SystemMouseCursors.text, + value: true, + onChanged: (_) {}, + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Checkbox))); + addTearDown(gesture.removePointer); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Checkbox(value: true, onChanged: (_) {}), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Checkbox(value: true, onChanged: null), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test cursor when tristate + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Checkbox( + value: null, + tristate: true, + onChanged: null, + mouseCursor: _SelectedGrabMouseCursor(), + ), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('Checkbox fill color resolves in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledFillColor = Color(0xFF000001); + const activeDisabledFillColor = Color(0xFF000002); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return activeDisabledFillColor; + } + return activeEnabledFillColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildFrame({required bool enabled}) { + return Material( + child: Theme( + data: theme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: true, + fillColor: fillColor, + onChanged: enabled ? (bool? value) {} : null, + ); + }, + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeEnabledFillColor)); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..rrect(color: activeDisabledFillColor)); + }); + + testWidgets('Checkbox fill color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'checkbox'); + addTearDown(focusNode.dispose); + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredFillColor = Color(0xFF000001); + const focusedFillColor = Color(0xFF000002); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredFillColor; + } + if (states.contains(WidgetState.focused)) { + return focusedFillColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + Widget buildFrame() { + return Material( + child: Theme( + data: theme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + focusNode: focusNode, + autofocus: true, + value: true, + fillColor: fillColor, + onChanged: (bool? value) {}, + ); + }, + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect(getCheckboxRenderer(), paints..rrect(color: focusedFillColor)); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect(getCheckboxRenderer(), paints..rrect(color: hoveredFillColor)); + }); + + testWidgets('Checkbox respects shape and side', (WidgetTester tester) async { + const roundedRectangleBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ); + + const side = BorderSide(width: 4, color: Color(0xfff44336)); + + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: false, + onChanged: (bool? newValue) {}, + shape: roundedRectangleBorder, + side: side, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(tester.widget<Checkbox>(find.byType(Checkbox)).shape, roundedRectangleBorder); + expect(tester.widget<Checkbox>(find.byType(Checkbox)).side, side); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..drrect( + color: const Color(0xfff44336), + outer: RRect.fromLTRBR(15.0, 15.0, 33.0, 33.0, const Radius.circular(5)), + inner: RRect.fromLTRBR(19.0, 19.0, 29.0, 29.0, const Radius.circular(1)), + ), + ); + }); + + testWidgets( + 'Material2 - Checkbox default overlay color in active/pressed/focused/hovered states', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + final theme = ThemeData(useMaterial3: false); + final ColorScheme colors = theme.colorScheme; + Widget buildCheckbox({bool active = false, bool focused = false}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Checkbox( + focusNode: focusNode, + autofocus: focused, + value: active, + onChanged: (_) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: theme.unselectedWidgetColor.withAlpha(kRadialReactionAlpha)), + reason: + 'Default inactive pressed Checkbox should have overlay color from default fillColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: colors.secondary.withAlpha(kRadialReactionAlpha)), + reason: 'Default active pressed Checkbox should have overlay color from default fillColor', + ); + + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildCheckbox(focused: true)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: theme.focusColor), + reason: 'Focused Checkbox should use default focused overlay color', + ); + + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture3 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture3.addPointer(); + addTearDown(gesture3.removePointer); + await gesture3.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: theme.hoverColor), + reason: 'Hovered Checkbox should use default hovered overlay color', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'Material3 - Checkbox default overlay color in active/pressed/focused/hovered states', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + Widget buildCheckbox({bool active = false, bool focused = false}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Checkbox( + focusNode: focusNode, + autofocus: focused, + value: active, + onChanged: (_) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: colors.primary.withOpacity(0.1)), + reason: + 'Default inactive pressed Checkbox should have overlay color from default fillColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: colors.onSurface.withOpacity(0.1)), + reason: 'Default active pressed Checkbox should have overlay color from default fillColor', + ); + + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildCheckbox(focused: true)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: colors.onSurface.withOpacity(0.1)), + reason: 'Focused Checkbox should use default focused overlay color', + ); + + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture3 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture3.addPointer(); + addTearDown(gesture3.removePointer); + await gesture3.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: colors.onSurface.withOpacity(0.08)), + reason: 'Hovered Checkbox should use default hovered overlay color', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Checkbox overlay color resolves in active/pressed/focused/hovered states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const fillColor = Color(0xFF000000); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const focusOverlayColor = Color(0xFF000004); + const hoverColor = Color(0xFF000005); + const focusColor = Color(0xFF000006); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Widget buildCheckbox({bool active = false, bool focused = false, bool useOverlay = true}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Checkbox( + focusNode: focusNode, + autofocus: focused, + value: active, + onChanged: (_) {}, + fillColor: const MaterialStatePropertyAll<Color>(fillColor), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + focusColor: focusColor, + splashRadius: splashRadius, + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox(useOverlay: false)); + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default inactive pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true, useOverlay: false)); + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default active pressed Checkbox should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildCheckbox()); + final TestGesture gesture3 = await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + final TestGesture gesture4 = await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildCheckbox(focused: true)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: focusOverlayColor, radius: splashRadius), + reason: 'Focused Checkbox should use overlay color $focusOverlayColor over $focusColor', + ); + + // Start hovering + final TestGesture gesture5 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture5.addPointer(); + addTearDown(gesture5.removePointer); + await gesture5.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: hoverOverlayColor, radius: splashRadius), + reason: 'Hovered Checkbox should use overlay color $hoverOverlayColor over $hoverColor', + ); + + // Finish gestures to release resources. + await gesture1.up(); + await gesture2.up(); + await gesture3.up(); + await gesture4.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Tristate Checkbox overlay color resolves in pressed active/inactive states', ( + WidgetTester tester, + ) async { + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + TestGesture gesture; + bool? value = false; + + Widget buildTristateCheckbox() { + return MaterialApp( + theme: theme, + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + value: value, + tristate: true, + onChanged: (bool? v) { + setState(() { + value = v; + }); + }, + overlayColor: WidgetStateProperty.resolveWith(getOverlayColor), + splashRadius: splashRadius, + ); + }, + ), + ), + ); + } + + // The checkbox is inactive. + await tester.pumpWidget(buildTristateCheckbox()); + gesture = await tester.press(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(value, false); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + // The checkbox is active. + await gesture.up(); + gesture = await tester.press(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(value, true); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + // The checkbox is active in tri-state. + await gesture.up(); + gesture = await tester.press(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(value, null); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: + 'Active (tristate) pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + // The checkbox is inactive again. + await gesture.up(); + gesture = await tester.press(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(value, false); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + await gesture.up(); + }); + + testWidgets('Do not crash when widget disappears while pointer is down', ( + WidgetTester tester, + ) async { + Widget buildCheckbox(bool show) { + return MaterialApp( + theme: theme, + home: Material( + child: Center(child: show ? Checkbox(value: true, onChanged: (_) {}) : Container()), + ), + ); + } + + await tester.pumpWidget(buildCheckbox(true)); + final Offset center = tester.getCenter(find.byType(Checkbox)); + // Put a pointer down on the screen. + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); + // While the pointer is down, the widget disappears. + await tester.pumpWidget(buildCheckbox(false)); + expect(find.byType(Checkbox), findsNothing); + // Release pointer after widget disappeared. + await gesture.up(); + }); + + testWidgets('Checkbox BorderSide side only applies when unselected in M2', ( + WidgetTester tester, + ) async { + const borderColor = Color(0xfff44336); + const activeColor = Color(0xff123456); + const side = BorderSide(width: 4, color: borderColor); + + Widget buildApp({bool? value, bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Checkbox( + value: value, + tristate: value == null, + activeColor: activeColor, + onChanged: enabled ? (bool? newValue) {} : null, + side: side, + ), + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + void expectBorder() { + expect( + getCheckboxRenderer(), + paints..drrect( + color: borderColor, + outer: RRect.fromLTRBR(15, 15, 33, 33, const Radius.circular(1)), + inner: RRect.fromLTRBR(19, 19, 29, 29, Radius.zero), + ), + ); + } + + // Checkbox is unselected, so the specified BorderSide appears. + + await tester.pumpWidget(buildApp(value: false)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp(value: false, enabled: false)); + await tester.pumpAndSettle(); + expectBorder(); + + // Checkbox is selected/indeterminate, so the specified BorderSide is transparent + + await tester.pumpWidget(buildApp(value: true)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..drrect(color: Colors.transparent)); + expect(getCheckboxRenderer(), paints..rrect(color: activeColor)); // checkbox fill + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..drrect(color: Colors.transparent)); + expect(getCheckboxRenderer(), paints..rrect(color: activeColor)); // checkbox fill + }); + + testWidgets('Material2 - Checkbox WidgetStateBorderSide applies unconditionally', ( + WidgetTester tester, + ) async { + const borderColor = Color(0xfff44336); + const side = BorderSide(width: 4, color: borderColor); + final theme = ThemeData(useMaterial3: false); + + Widget buildApp({bool? value, bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Checkbox( + value: value, + tristate: value == null, + onChanged: enabled ? (bool? newValue) {} : null, + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) => side), + ), + ), + ), + ); + } + + void expectBorder() { + expect( + tester.renderObject<RenderBox>(find.byType(Checkbox)), + paints..drrect( + color: borderColor, + outer: RRect.fromLTRBR(15, 15, 33, 33, const Radius.circular(1)), + inner: RRect.fromLTRBR(19, 19, 29, 29, Radius.zero), + ), + ); + } + + await tester.pumpWidget(buildApp(value: false)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp(value: false, enabled: false)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp(value: true)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expectBorder(); + }); + + testWidgets('Material3 - Checkbox WidgetStateBorderSide applies unconditionally', ( + WidgetTester tester, + ) async { + const borderColor = Color(0xfff44336); + const side = BorderSide(width: 4, color: borderColor); + final theme = ThemeData(); + + Widget buildApp({bool? value, bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Checkbox( + value: value, + tristate: value == null, + onChanged: enabled ? (bool? newValue) {} : null, + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) => side), + ), + ), + ), + ); + } + + void expectBorder() { + expect( + tester.renderObject<RenderBox>(find.byType(Checkbox)), + paints..drrect( + color: borderColor, + outer: RRect.fromLTRBR(15, 15, 33, 33, const Radius.circular(2)), + inner: RRect.fromLTRBR(19, 19, 29, 29, Radius.zero), + ), + ); + } + + await tester.pumpWidget(buildApp(value: false)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp(value: false, enabled: false)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp(value: true)); + await tester.pumpAndSettle(); + expectBorder(); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expectBorder(); + }); + + testWidgets('disabled checkbox shows tooltip', (WidgetTester tester) async { + const longPressTooltip = 'long press tooltip'; + const tapTooltip = 'tap tooltip'; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Tooltip(message: longPressTooltip, child: Checkbox(value: true, onChanged: null)), + ), + ), + ); + + // Default tooltip shows up after long pressed. + final Finder tooltip0 = find.byType(Tooltip); + expect(find.text(longPressTooltip), findsNothing); + + await tester.tap(tooltip0); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(longPressTooltip), findsNothing); + + final TestGesture gestureLongPress = await tester.startGesture(tester.getCenter(tooltip0)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gestureLongPress.up(); + await tester.pump(); + + expect(find.text(longPressTooltip), findsOneWidget); + + // Tooltip shows up after tapping when set triggerMode to TooltipTriggerMode.tap. + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: tapTooltip, + child: Checkbox(value: true, onChanged: null), + ), + ), + ), + ); + + await tester.pump(const Duration(days: 1)); + await tester.pumpAndSettle(); + expect(find.text(tapTooltip), findsNothing); + expect(find.text(longPressTooltip), findsNothing); + + final Finder tooltip1 = find.byType(Tooltip); + await tester.tap(tooltip1); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(tapTooltip), findsOneWidget); + }); + + testWidgets('Material3 - Checkbox has default error color when isError is set to true', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + final themeData = ThemeData(); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = true; + Widget buildApp({bool autoFocus = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + isError: true, + value: value, + onChanged: (bool? newValue) { + setState(() { + value = newValue; + }); + }, + autofocus: autoFocus, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + // Focused + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: themeData.colorScheme.error.withOpacity(0.1)) + ..rrect(color: themeData.colorScheme.error) + ..path(color: themeData.colorScheme.onError), + ); + + // Default color + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp(autoFocus: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..rrect(color: themeData.colorScheme.error) + ..path(color: themeData.colorScheme.onError), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: themeData.colorScheme.error.withOpacity(0.08)) + ..rrect(color: themeData.colorScheme.error), + ); + + // Start pressing + final TestGesture gestureLongPress = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pump(); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints + ..circle(color: themeData.colorScheme.error.withOpacity(0.1)) + ..rrect(color: themeData.colorScheme.error), + ); + await gestureLongPress.up(); + await tester.pump(); + }); + + testWidgets('Material3 - Checkbox WidgetStateBorderSide applies in error states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Checkbox'); + addTearDown(focusNode.dispose); + final themeData = ThemeData(); + const borderColor = Color(0xffffeb3b); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + bool? value = false; + Widget buildApp({bool autoFocus = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox( + isError: true, + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.error)) { + return const BorderSide(color: borderColor, width: 4); + } + return const BorderSide(color: Colors.red, width: 2); + }), + value: value, + onChanged: (bool? newValue) { + setState(() { + value = newValue; + }); + }, + autofocus: autoFocus, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + void expectBorder() { + expect( + tester.renderObject<RenderBox>(find.byType(Checkbox)), + paints..drrect( + color: borderColor, + outer: RRect.fromLTRBR(15, 15, 33, 33, const Radius.circular(2)), + inner: RRect.fromLTRBR(19, 19, 29, 29, Radius.zero), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expectBorder(); + + // Focused + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expectBorder(); + + // Default color + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp(autoFocus: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expectBorder(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + expectBorder(); + + // Start pressing + final TestGesture gestureLongPress = await tester.startGesture( + tester.getCenter(find.byType(Checkbox)), + ); + await tester.pump(); + expectBorder(); + await gestureLongPress.up(); + await tester.pump(); + }); + + testWidgets('Material3 - Checkbox has correct default shape', (WidgetTester tester) async { + final themeData = ThemeData(); + + Widget buildApp() { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox(value: false, onChanged: (bool? newValue) {}); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + final OutlinedBorder? expectedShape = themeData.checkboxTheme.shape; + expect(tester.widget<Checkbox>(find.byType(Checkbox)).shape, expectedShape); + expect( + Material.of(tester.element(find.byType(Checkbox))), + paints..drrect( + outer: RRect.fromLTRBR(15.0, 15.0, 33.0, 33.0, const Radius.circular(2)), + inner: RRect.fromLTRBR(17.0, 17.0, 31.0, 31.0, Radius.zero), + ), + ); + }); + + testWidgets('Checkbox.adaptive shows the correct platform widget', (WidgetTester tester) async { + Widget buildApp(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Checkbox.adaptive(value: false, onChanged: (bool? newValue) {}); + }, + ), + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoCheckbox), findsOneWidget); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoCheckbox), findsNothing); + } + }); + + testWidgets( + 'Checkbox.adaptive respects Checkbox.mouseCursor on iOS/macOS', + (WidgetTester tester) async { + Widget buildApp({MouseCursor? mouseCursor}) { + return MaterialApp( + home: Material( + child: Checkbox.adaptive( + value: true, + onChanged: (bool? newValue) {}, + mouseCursor: mouseCursor, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(CupertinoCheckbox))); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(CupertinoCheckbox))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test mouse cursor can be configured. + await tester.pumpWidget(buildApp(mouseCursor: SystemMouseCursors.click)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test Checkbox.adaptive can resolve a WidgetStateMouseCursor. + await tester.pumpWidget(buildApp(mouseCursor: const _SelectedGrabMouseCursor())); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + await gesture.removePointer(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('Material2 - Checkbox respects fillColor when it is unchecked', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + const activeBackgroundColor = Color(0xff123456); + const inactiveBackgroundColor = Color(0xff654321); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Checkbox( + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeBackgroundColor; + } + return inactiveBackgroundColor; + }), + value: false, + onChanged: enabled ? (bool? newValue) {} : null, + ), + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + // Checkbox is unselected, so the default BorderSide appears and fillColor is checkbox's background color. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..drrect(color: theme.unselectedWidgetColor)); + expect(getCheckboxRenderer(), paints..rrect(color: inactiveBackgroundColor)); + + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..drrect(color: theme.disabledColor)); + expect(getCheckboxRenderer(), paints..rrect(color: inactiveBackgroundColor)); + }); + + testWidgets('Material3 - Checkbox respects fillColor when it is unchecked', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + const activeBackgroundColor = Color(0xff123456); + const inactiveBackgroundColor = Color(0xff654321); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Checkbox( + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeBackgroundColor; + } + return inactiveBackgroundColor; + }), + value: false, + onChanged: enabled ? (bool? newValue) {} : null, + ), + ), + ), + ); + } + + RenderBox getCheckboxRenderer() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + // Checkbox is unselected, so the default BorderSide appears and fillColor is checkbox's background color. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(getCheckboxRenderer(), paints..drrect(color: theme.colorScheme.onSurfaceVariant)); + expect(getCheckboxRenderer(), paints..rrect(color: inactiveBackgroundColor)); + + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + getCheckboxRenderer(), + paints..drrect(color: theme.colorScheme.onSurface.withOpacity(0.38)), + ); + expect(getCheckboxRenderer(), paints..rrect(color: inactiveBackgroundColor)); + }); + + testWidgets('Checkbox renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Scaffold(body: Checkbox(value: true, onChanged: null))), + ), + ), + ); + expect(tester.takeException(), isNull); + }); +} + +class _SelectedGrabMouseCursor extends WidgetStateMouseCursor { + const _SelectedGrabMouseCursor(); + + @override + MouseCursor resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return SystemMouseCursors.grab; + } + return SystemMouseCursors.basic; + } + + @override + String get debugDescription => '_SelectedGrabMouseCursor()'; +} diff --git a/packages/material_ui/test/material/checkbox_theme_test.dart b/packages/material_ui/test/material/checkbox_theme_test.dart new file mode 100644 index 000000000000..354bf08a3be8 --- /dev/null +++ b/packages/material_ui/test/material/checkbox_theme_test.dart @@ -0,0 +1,582 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('CheckboxThemeData copyWith, ==, hashCode basics', () { + expect(const CheckboxThemeData(), const CheckboxThemeData().copyWith()); + expect(const CheckboxThemeData().hashCode, const CheckboxThemeData().copyWith().hashCode); + }); + + test('CheckboxThemeData lerp special cases', () { + expect(CheckboxThemeData.lerp(null, null, 0), const CheckboxThemeData()); + const data = CheckboxThemeData(); + expect(identical(CheckboxThemeData.lerp(data, data, 0.5), data), true); + }); + + test('CheckboxThemeData defaults', () { + const themeData = CheckboxThemeData(); + expect(themeData.mouseCursor, null); + expect(themeData.fillColor, null); + expect(themeData.checkColor, null); + expect(themeData.overlayColor, null); + expect(themeData.splashRadius, null); + expect(themeData.materialTapTargetSize, null); + expect(themeData.visualDensity, null); + + const theme = CheckboxTheme(data: CheckboxThemeData(), child: SizedBox()); + expect(theme.data.mouseCursor, null); + expect(theme.data.fillColor, null); + expect(theme.data.checkColor, null); + expect(theme.data.overlayColor, null); + expect(theme.data.splashRadius, null); + expect(theme.data.materialTapTargetSize, null); + expect(theme.data.visualDensity, null); + }); + + testWidgets('Default CheckboxThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CheckboxThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('CheckboxThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CheckboxThemeData( + mouseCursor: MaterialStatePropertyAll<MouseCursor?>(SystemMouseCursors.click), + fillColor: MaterialStatePropertyAll<Color>(Color(0xfffffff0)), + checkColor: MaterialStatePropertyAll<Color>(Color(0xfffffff1)), + overlayColor: MaterialStatePropertyAll<Color>(Color(0xfffffff2)), + splashRadius: 1.0, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.standard, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'mouseCursor: WidgetStatePropertyAll(SystemMouseCursor(click))', + 'fillColor: WidgetStatePropertyAll(${const Color(0xfffffff0)})', + 'checkColor: WidgetStatePropertyAll(${const Color(0xfffffff1)})', + 'overlayColor: WidgetStatePropertyAll(${const Color(0xfffffff2)})', + 'splashRadius: 1.0', + 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap', + 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', + ]), + ); + }); + + testWidgets('Checkbox is themeable', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const MouseCursor mouseCursor = SystemMouseCursors.text; + const defaultFillColor = Color(0xfffffff0); + const selectedFillColor = Color(0xfffffff1); + const defaultCheckColor = Color(0xfffffff2); + const focusedCheckColor = Color(0xfffffff3); + const focusOverlayColor = Color(0xfffffff4); + const hoverOverlayColor = Color(0xfffffff5); + const splashRadius = 1.0; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const visualDensity = VisualDensity(vertical: 1.0, horizontal: 1.0); + + Widget buildCheckbox({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + checkboxTheme: CheckboxThemeData( + mouseCursor: const MaterialStatePropertyAll<MouseCursor?>(mouseCursor), + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedFillColor; + } + return defaultFillColor; + }), + checkColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusedCheckColor; + } + return defaultCheckColor; + }), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + }), + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize, + visualDensity: visualDensity, + ), + ), + home: Scaffold( + body: Checkbox(onChanged: (bool? value) {}, value: selected, autofocus: autofocus), + ), + ); + } + + // Checkbox. + await tester.pumpWidget(buildCheckbox()); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: defaultFillColor)); + // Size from MaterialTapTargetSize.shrinkWrap with added VisualDensity. + expect( + tester.getSize(find.byType(Checkbox)), + const Size(40.0, 40.0) + visualDensity.baseSizeAdjustment, + ); + + // Selected checkbox. + await tester.pumpWidget(buildCheckbox(selected: true)); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: selectedFillColor)); + expect( + _getCheckboxMaterial(tester), + paints + ..rrect(color: selectedFillColor) + ..path(color: defaultCheckColor), + ); + + // Checkbox with hover. + await tester.pumpWidget(buildCheckbox()); + await _pointGestureToCheckbox(tester); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(_getCheckboxMaterial(tester), paints..circle(color: hoverOverlayColor)); + + // Checkbox with focus. + await tester.pumpWidget(buildCheckbox(autofocus: true, selected: true)); + await tester.pumpAndSettle(); + expect( + _getCheckboxMaterial(tester), + paints..circle(color: focusOverlayColor, radius: splashRadius), + ); + expect( + _getCheckboxMaterial(tester), + paints + ..rrect(color: selectedFillColor) + ..path(color: focusedCheckColor), + ); + }); + + testWidgets('Checkbox properties are taken over the theme values', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const MouseCursor themeMouseCursor = SystemMouseCursors.click; + const themeDefaultFillColor = Color(0xfffffff0); + const themeSelectedFillColor = Color(0xfffffff1); + const themeCheckColor = Color(0xfffffff2); + const themeFocusOverlayColor = Color(0xfffffff3); + const themeHoverOverlayColor = Color(0xfffffff4); + const themeSplashRadius = 1.0; + const MaterialTapTargetSize themeMaterialTapTargetSize = MaterialTapTargetSize.padded; + const VisualDensity themeVisualDensity = VisualDensity.standard; + + const MouseCursor mouseCursor = SystemMouseCursors.text; + const defaultFillColor = Color(0xfffffff5); + const selectedFillColor = Color(0xfffffff6); + const checkColor = Color(0xfffffff7); + const focusColor = Color(0xfffffff8); + const hoverColor = Color(0xfffffff9); + const splashRadius = 2.0; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const VisualDensity visualDensity = VisualDensity.standard; + + Widget buildCheckbox({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + checkboxTheme: CheckboxThemeData( + mouseCursor: const MaterialStatePropertyAll<MouseCursor?>(themeMouseCursor), + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedFillColor; + } + return themeDefaultFillColor; + }), + checkColor: const MaterialStatePropertyAll<Color?>(themeCheckColor), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return themeFocusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return themeHoverOverlayColor; + } + return null; + }), + splashRadius: themeSplashRadius, + materialTapTargetSize: themeMaterialTapTargetSize, + visualDensity: themeVisualDensity, + ), + ), + home: Scaffold( + body: Checkbox( + onChanged: (bool? value) {}, + value: selected, + autofocus: autofocus, + mouseCursor: mouseCursor, + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedFillColor; + } + return defaultFillColor; + }), + checkColor: checkColor, + focusColor: focusColor, + hoverColor: hoverColor, + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize, + visualDensity: visualDensity, + ), + ), + ); + } + + // Checkbox. + await tester.pumpWidget(buildCheckbox()); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: defaultFillColor)); + // Size from MaterialTapTargetSize.shrinkWrap with added VisualDensity. + expect( + tester.getSize(find.byType(Checkbox)), + const Size(40.0, 40.0) + visualDensity.baseSizeAdjustment, + ); + + // Selected checkbox. + await tester.pumpWidget(buildCheckbox(selected: true)); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: selectedFillColor)); + expect( + _getCheckboxMaterial(tester), + paints + ..rrect(color: selectedFillColor) + ..path(color: checkColor), + ); + + // Checkbox with hover. + await tester.pumpWidget(buildCheckbox()); + await _pointGestureToCheckbox(tester); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(_getCheckboxMaterial(tester), paints..circle(color: hoverColor)); + + // Checkbox with focus. + await tester.pumpWidget(buildCheckbox(autofocus: true)); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..circle(color: focusColor, radius: splashRadius)); + }); + + testWidgets('Checkbox activeColor property is taken over the theme', (WidgetTester tester) async { + const themeSelectedFillColor = Color(0xfffffff1); + const themeDefaultFillColor = Color(0xfffffff0); + const selectedFillColor = Color(0xfffffff6); + + Widget buildCheckbox({bool selected = false}) { + return MaterialApp( + theme: ThemeData( + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedFillColor; + } + return themeDefaultFillColor; + }), + ), + ), + home: Scaffold( + body: Checkbox( + onChanged: (bool? value) {}, + value: selected, + activeColor: selectedFillColor, + ), + ), + ); + } + + // Unselected checkbox. + await tester.pumpWidget(buildCheckbox()); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: themeDefaultFillColor)); + + // Selected checkbox. + await tester.pumpWidget(buildCheckbox(selected: true)); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: selectedFillColor)); + }); + + testWidgets('Checkbox theme overlay color resolves in active/pressed states', ( + WidgetTester tester, + ) async { + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Widget buildCheckbox({required bool active}) { + return MaterialApp( + theme: ThemeData( + checkboxTheme: CheckboxThemeData( + overlayColor: WidgetStateProperty.resolveWith(getOverlayColor), + splashRadius: splashRadius, + ), + ), + home: Scaffold( + body: Checkbox(value: active, onChanged: (_) {}), + ), + ); + } + + await tester.pumpWidget(buildCheckbox(active: false)); + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + _getCheckboxMaterial(tester), + paints..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Checkbox should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildCheckbox(active: true)); + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(Checkbox))); + await tester.pumpAndSettle(); + + expect( + _getCheckboxMaterial(tester), + paints..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Checkbox should have overlay color: $activePressedOverlayColor', + ); + + // Finish gesture to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Local CheckboxTheme can override global CheckboxTheme', (WidgetTester tester) async { + const globalThemeFillColor = Color(0xfffffff1); + const globalThemeCheckColor = Color(0xff000000); + const localThemeFillColor = Color(0xffff0000); + const localThemeCheckColor = Color(0xffffffff); + + Widget buildCheckbox({required bool active}) { + return MaterialApp( + theme: ThemeData( + checkboxTheme: const CheckboxThemeData( + checkColor: MaterialStatePropertyAll<Color>(globalThemeCheckColor), + fillColor: MaterialStatePropertyAll<Color>(globalThemeFillColor), + ), + ), + home: Scaffold( + body: CheckboxTheme( + data: const CheckboxThemeData( + fillColor: MaterialStatePropertyAll<Color>(localThemeFillColor), + checkColor: MaterialStatePropertyAll<Color>(localThemeCheckColor), + ), + child: Checkbox(value: active, onChanged: (_) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildCheckbox(active: true)); + await tester.pumpAndSettle(); + expect(_getCheckboxMaterial(tester), paints..rrect(color: localThemeFillColor)); + expect( + _getCheckboxMaterial(tester), + paints + ..rrect(color: localThemeFillColor) + ..path(color: localThemeCheckColor), + ); + }); + + test('CheckboxThemeData lerp with null parameters', () { + final CheckboxThemeData lerped = CheckboxThemeData.lerp(null, null, 0.25); + + expect(lerped.mouseCursor, null); + expect(lerped.fillColor, null); + expect(lerped.checkColor, null); + expect(lerped.overlayColor, null); + expect(lerped.splashRadius, null); + expect(lerped.materialTapTargetSize, null); + expect(lerped.visualDensity, null); + expect(lerped.shape, null); + expect(lerped.side, null); + }); + + test('CheckboxThemeData lerp from populated to null parameters', () { + final theme = CheckboxThemeData( + fillColor: WidgetStateProperty.all(const Color(0xfffffff0)), + checkColor: WidgetStateProperty.all(const Color(0xfffffff1)), + overlayColor: WidgetStateProperty.all(const Color(0xfffffff2)), + splashRadius: 3.0, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity(vertical: 1.0, horizontal: 1.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + side: const BorderSide(width: 4.0), + ); + final CheckboxThemeData lerped = CheckboxThemeData.lerp(theme, null, 0.5); + + expect(lerped.fillColor!.resolve(<WidgetState>{}), isSameColorAs(const Color(0x80fffff0))); + expect(lerped.checkColor!.resolve(<WidgetState>{}), isSameColorAs(const Color(0x80fffff1))); + expect(lerped.overlayColor!.resolve(<WidgetState>{}), isSameColorAs(const Color(0x80fffff2))); + expect(lerped.splashRadius, 1.5); + expect(lerped.materialTapTargetSize, null); + expect(lerped.visualDensity, null); + expect( + lerped.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + ); + expect(lerped.side!.width, 2.0); + expect(lerped.side!.color, isSameColorAs(const Color(0x80000000))); + }); + + test('CheckboxThemeData lerp from null to populated parameters', () { + final theme = CheckboxThemeData( + fillColor: WidgetStateProperty.all(const Color(0xfffffff0)), + checkColor: WidgetStateProperty.all(const Color(0xfffffff1)), + overlayColor: WidgetStateProperty.all(const Color(0xfffffff2)), + splashRadius: 4.0, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity(vertical: 1.0, horizontal: 1.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + side: const BorderSide(width: 4.0), + ); + final CheckboxThemeData lerped = CheckboxThemeData.lerp(null, theme, 0.25); + + expect( + lerped.fillColor!.resolve(const <WidgetState>{}), + isSameColorAs(const Color(0x40fffff0)), + ); + expect( + lerped.checkColor!.resolve(const <WidgetState>{}), + isSameColorAs(const Color(0x40fffff1)), + ); + expect( + lerped.overlayColor!.resolve(const <WidgetState>{}), + isSameColorAs(const Color(0x40fffff2)), + ); + expect(lerped.splashRadius, 1); + expect(lerped.materialTapTargetSize, null); + expect(lerped.visualDensity, null); + expect( + lerped.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(1.0))), + ); + expect(lerped.side!.width, 1.0); + expect(lerped.side!.color, isSameColorAs(const Color(0x40000000))); + }); + + test('CheckboxThemeData lerp from populated parameters', () { + final themeA = CheckboxThemeData( + fillColor: WidgetStateProperty.all(const Color(0xfffffff0)), + checkColor: WidgetStateProperty.all(const Color(0xfffffff1)), + overlayColor: WidgetStateProperty.all(const Color(0xfffffff2)), + splashRadius: 3.0, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity(vertical: 1.0, horizontal: 1.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + side: const BorderSide(width: 4.0), + ); + final themeB = CheckboxThemeData( + fillColor: WidgetStateProperty.all(const Color(0xfffffff3)), + checkColor: WidgetStateProperty.all(const Color(0xfffffff4)), + overlayColor: WidgetStateProperty.all(const Color(0xfffffff5)), + splashRadius: 9.0, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity(vertical: 2.0, horizontal: 2.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(1.0))), + side: const BorderSide(width: 3.0), + ); + final CheckboxThemeData lerped = CheckboxThemeData.lerp(themeA, themeB, 0.5); + + expect(lerped.fillColor!.resolve(<WidgetState>{}), isSameColorAs(const Color(0xfffffff1))); + expect(lerped.checkColor!.resolve(<WidgetState>{}), isSameColorAs(const Color(0xfffffff2))); + expect(lerped.overlayColor!.resolve(<WidgetState>{}), isSameColorAs(const Color(0xfffffff3))); + expect(lerped.splashRadius, 6); + expect(lerped.materialTapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(lerped.visualDensity, const VisualDensity(vertical: 2.0, horizontal: 2.0)); + expect( + lerped.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.5))), + ); + expect(lerped.side, const BorderSide(width: 3.5)); + }); + + testWidgets('WidgetStateBorderSide properly lerp in CheckboxThemeData.side', ( + WidgetTester tester, + ) async { + late ColorScheme colorScheme; + + Widget buildCheckbox({required Color seedColor}) { + colorScheme = ColorScheme.fromSeed(seedColor: seedColor); + return MaterialApp( + theme: ThemeData( + colorScheme: colorScheme, + checkboxTheme: CheckboxThemeData( + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + return BorderSide(color: colorScheme.primary, width: 4.0); + }), + ), + ), + home: Scaffold(body: Checkbox(value: false, onChanged: (_) {})), + ); + } + + await tester.pumpWidget(buildCheckbox(seedColor: Colors.red)); + await tester.pumpAndSettle(); + + RenderBox getCheckboxRenderBox() { + return tester.renderObject<RenderBox>(find.byType(Checkbox)); + } + + expect(getCheckboxRenderBox(), paints..drrect(color: colorScheme.primary)); + + await tester.pumpWidget(buildCheckbox(seedColor: Colors.blue)); + await tester.pump(kPressTimeout); + + expect(getCheckboxRenderBox(), paints..drrect(color: colorScheme.primary)); + }); +} + +Future<void> _pointGestureToCheckbox(WidgetTester tester) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Checkbox))); +} + +MaterialInkController? _getCheckboxMaterial(WidgetTester tester) { + return Material.of(tester.element(find.byType(Checkbox))); +} diff --git a/packages/material_ui/test/material/chip_test.dart b/packages/material_ui/test/material/chip_test.dart new file mode 100644 index 000000000000..d83ed9c65678 --- /dev/null +++ b/packages/material_ui/test/material/chip_test.dart @@ -0,0 +1,6477 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +Finder findRenderChipElement() { + return find.byElementPredicate((Element e) => '${e.renderObject.runtimeType}' == '_RenderChip'); +} + +RenderBox getMaterialBox(WidgetTester tester) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(RawChip), matching: find.byType(CustomPaint)), + ); +} + +Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(RawChip), matching: find.byType(Material)), + ); +} + +IconThemeData getIconData(WidgetTester tester) { + final IconTheme iconTheme = tester.firstWidget( + find.descendant(of: find.byType(RawChip), matching: find.byType(IconTheme)), + ); + return iconTheme.data; +} + +DefaultTextStyle getLabelStyle(WidgetTester tester, String labelText) { + return tester.widget( + find.ancestor(of: find.text(labelText), matching: find.byType(DefaultTextStyle)).first, + ); +} + +TextStyle? getIconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon).first, matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + +dynamic getRenderChip(WidgetTester tester) { + if (!tester.any(findRenderChipElement())) { + return null; + } + final Element element = tester.element(findRenderChipElement().first); + return element.renderObject; +} + +double getSelectProgress(WidgetTester tester) { + // ignore: avoid_dynamic_calls + return getRenderChip(tester)?.checkmarkAnimation?.value as double; +} + +double getAvatarDrawerProgress(WidgetTester tester) { + // ignore: avoid_dynamic_calls + return getRenderChip(tester)?.avatarDrawerAnimation?.value as double; +} + +double getDeleteDrawerProgress(WidgetTester tester) { + // ignore: avoid_dynamic_calls + return getRenderChip(tester)?.deleteDrawerAnimation?.value as double; +} + +/// Adds the basic requirements for a Chip. +Widget wrapForChip({ + required Widget child, + TextDirection textDirection = TextDirection.ltr, + TextScaler textScaler = TextScaler.noScaling, + ThemeData? theme, +}) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material(child: child), + ), + ), + ); +} + +/// Tests that a [Chip] that has its size constrained by its parent is +/// further constraining the size of its child, the label widget. +/// Optionally, adding an avatar or delete icon to the chip should not +/// cause the chip or label to exceed its constrained height. +Future<void> testConstrainedLabel( + WidgetTester tester, { + CircleAvatar? avatar, + VoidCallback? onDeleted, +}) async { + const labelWidth = 100.0; + const labelHeight = 50.0; + const chipParentWidth = 75.0; + const chipParentHeight = 25.0; + final Key labelKey = UniqueKey(); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: SizedBox( + width: chipParentWidth, + height: chipParentHeight, + child: Chip( + avatar: avatar, + label: SizedBox(key: labelKey, width: labelWidth, height: labelHeight), + onDeleted: onDeleted, + ), + ), + ), + ), + ); + + final Size labelSize = tester.getSize(find.byKey(labelKey)); + expect(labelSize.width, lessThan(chipParentWidth)); + expect(labelSize.height, lessThanOrEqualTo(chipParentHeight)); + + final Size chipSize = tester.getSize(find.byType(Chip)); + expect(chipSize.width, chipParentWidth); + expect(chipSize.height, chipParentHeight); +} + +void doNothing() {} + +Widget chipWithOptionalDeleteButton({ + Key? deleteButtonKey, + Key? labelKey, + required bool deletable, + TextDirection textDirection = TextDirection.ltr, + String? chipTooltip, + String? deleteButtonTooltipMessage, + double? size, + VoidCallback? onPressed = doNothing, + ThemeData? themeData, +}) { + return wrapForChip( + textDirection: textDirection, + theme: themeData, + child: Wrap( + children: <Widget>[ + RawChip( + tooltip: chipTooltip, + onPressed: onPressed, + onDeleted: deletable ? doNothing : null, + deleteIcon: Icon(key: deleteButtonKey, size: size, Icons.close), + deleteButtonTooltipMessage: deleteButtonTooltipMessage, + label: Text( + deletable ? 'Chip with Delete Button' : 'Chip without Delete Button', + key: labelKey, + ), + ), + ], + ), + ); +} + +bool offsetsAreClose(Offset a, Offset b) => (a - b).distance < 1.0; +bool radiiAreClose(double a, double b) => (a - b).abs() < 1.0; + +// Ripple pattern matches if there exists at least one ripple +// with the [expectedCenter] and [expectedRadius]. +// This ensures the existence of a ripple. +PaintPattern ripplePattern(Offset expectedCenter, double expectedRadius) { + return paints..something((Symbol method, List<dynamic> arguments) { + if (method != #drawCircle) { + return false; + } + final center = arguments[0] as Offset; + final radius = arguments[1] as double; + return offsetsAreClose(center, expectedCenter) && radiiAreClose(radius, expectedRadius); + }); +} + +// Unique ripple pattern matches if there does not exist ripples +// other than ones with the [expectedCenter] and [expectedRadius]. +// This ensures the nonexistence of two different ripples. +PaintPattern uniqueRipplePattern(Offset expectedCenter, double expectedRadius) { + return paints..everything((Symbol method, List<dynamic> arguments) { + if (method != #drawCircle) { + return true; + } + final center = arguments[0] as Offset; + final radius = arguments[1] as double; + if (offsetsAreClose(center, expectedCenter) && radiiAreClose(radius, expectedRadius)) { + return true; + } + throw ''' + Expected: center == $expectedCenter, radius == $expectedRadius + Found: center == $center radius == $radius'''; + }); +} + +// Finds any container of a tooltip. +Finder findTooltipContainer(String tooltipText) { + return find.ancestor(of: find.text(tooltipText), matching: find.byType(Container)); +} + +void main() { + testWidgets('M3 Chip defaults', (WidgetTester tester) async { + late TextTheme textTheme; + final lightTheme = ThemeData(); + final darkTheme = ThemeData.dark(); + + Widget buildFrame(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + textTheme = Theme.of(context).textTheme; + return Chip( + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onDeleted: () {}, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(lightTheme)); + expect(getMaterial(tester).color, null); + expect(getMaterial(tester).elevation, 0); + expect( + getMaterial(tester).shape, + RoundedRectangleBorder( + side: BorderSide(color: lightTheme.colorScheme.outlineVariant), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + ); + expect(getIconData(tester).color, lightTheme.colorScheme.primary); + expect(getIconData(tester).opacity, null); + expect(getIconData(tester).size, 18); + + TextStyle labelStyle = getLabelStyle(tester, 'Chip A').style; + expect(labelStyle.color, lightTheme.colorScheme.onSurfaceVariant); + expect(labelStyle.fontFamily, textTheme.labelLarge?.fontFamily); + expect(labelStyle.fontFamilyFallback, textTheme.labelLarge?.fontFamilyFallback); + expect(labelStyle.fontFeatures, textTheme.labelLarge?.fontFeatures); + expect(labelStyle.fontSize, textTheme.labelLarge?.fontSize); + expect(labelStyle.fontStyle, textTheme.labelLarge?.fontStyle); + expect(labelStyle.fontWeight, textTheme.labelLarge?.fontWeight); + expect(labelStyle.height, textTheme.labelLarge?.height); + expect(labelStyle.inherit, textTheme.labelLarge?.inherit); + expect(labelStyle.leadingDistribution, textTheme.labelLarge?.leadingDistribution); + expect(labelStyle.letterSpacing, textTheme.labelLarge?.letterSpacing); + expect(labelStyle.overflow, textTheme.labelLarge?.overflow); + expect(labelStyle.textBaseline, textTheme.labelLarge?.textBaseline); + expect(labelStyle.wordSpacing, textTheme.labelLarge?.wordSpacing); + + await tester.pumpWidget(buildFrame(darkTheme)); + await tester.pumpAndSettle(); // Theme transition animation + expect(getMaterial(tester).color, null); + expect(getMaterial(tester).elevation, 0); + expect( + getMaterial(tester).shape, + RoundedRectangleBorder( + side: BorderSide(color: darkTheme.colorScheme.outlineVariant), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + ); + expect(getIconData(tester).color, darkTheme.colorScheme.primary); + expect(getIconData(tester).opacity, null); + expect(getIconData(tester).size, 18); + + labelStyle = getLabelStyle(tester, 'Chip A').style; + expect(labelStyle.color, darkTheme.colorScheme.onSurfaceVariant); + expect(labelStyle.fontFamily, textTheme.labelLarge?.fontFamily); + expect(labelStyle.fontFamilyFallback, textTheme.labelLarge?.fontFamilyFallback); + expect(labelStyle.fontFeatures, textTheme.labelLarge?.fontFeatures); + expect(labelStyle.fontSize, textTheme.labelLarge?.fontSize); + expect(labelStyle.fontStyle, textTheme.labelLarge?.fontStyle); + expect(labelStyle.fontWeight, textTheme.labelLarge?.fontWeight); + expect(labelStyle.height, textTheme.labelLarge?.height); + expect(labelStyle.inherit, textTheme.labelLarge?.inherit); + expect(labelStyle.leadingDistribution, textTheme.labelLarge?.leadingDistribution); + expect(labelStyle.letterSpacing, textTheme.labelLarge?.letterSpacing); + expect(labelStyle.overflow, textTheme.labelLarge?.overflow); + expect(labelStyle.textBaseline, textTheme.labelLarge?.textBaseline); + expect(labelStyle.wordSpacing, textTheme.labelLarge?.wordSpacing); + }); + + testWidgets('Chip control test', (WidgetTester tester) async { + final feedback = FeedbackTester(); + final deletedChipLabels = <String>[]; + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onDeleted: () { + deletedChipLabels.add('A'); + }, + deleteButtonTooltipMessage: 'Delete chip A', + ), + Chip( + avatar: const CircleAvatar(child: Text('B')), + label: const Text('Chip B'), + onDeleted: () { + deletedChipLabels.add('B'); + }, + deleteButtonTooltipMessage: 'Delete chip B', + ), + ], + ), + ), + ); + + expect(tester.widget(find.byTooltip('Delete chip A')), isNotNull); + expect(tester.widget(find.byTooltip('Delete chip B')), isNotNull); + + expect(feedback.clickSoundCount, 0); + + expect(deletedChipLabels, isEmpty); + await tester.tap(find.byTooltip('Delete chip A')); + expect(deletedChipLabels, equals(<String>['A'])); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + + await tester.tap(find.byTooltip('Delete chip B')); + expect(deletedChipLabels, equals(<String>['A', 'B'])); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + + feedback.dispose(); + }); + + testWidgets('Chip does not constrain size of label widget if it does not exceed ' + 'the available space', (WidgetTester tester) async { + const labelWidth = 50.0; + const labelHeight = 30.0; + final Key labelKey = UniqueKey(); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: SizedBox.square( + dimension: 500.0, + child: Column( + children: <Widget>[ + Chip( + label: SizedBox(key: labelKey, width: labelWidth, height: labelHeight), + ), + ], + ), + ), + ), + ), + ); + + final Size labelSize = tester.getSize(find.byKey(labelKey)); + expect(labelSize.width, labelWidth); + expect(labelSize.height, labelHeight); + }); + + testWidgets('Chip constrains the size of the label widget when it exceeds the ' + 'available space', (WidgetTester tester) async { + await testConstrainedLabel(tester); + }); + + testWidgets('Chip constrains the size of the label widget when it exceeds the ' + 'available space and the avatar is present', (WidgetTester tester) async { + await testConstrainedLabel(tester, avatar: const CircleAvatar(child: Text('A'))); + }); + + testWidgets('Chip constrains the size of the label widget when it exceeds the ' + 'available space and the delete icon is present', (WidgetTester tester) async { + await testConstrainedLabel(tester, onDeleted: () {}); + }); + + testWidgets('Chip constrains the size of the label widget when it exceeds the ' + 'available space and both avatar and delete icons are present', (WidgetTester tester) async { + await testConstrainedLabel( + tester, + avatar: const CircleAvatar(child: Text('A')), + onDeleted: () {}, + ); + }); + + testWidgets('Chip constrains the avatar, label, and delete icons to the bounds of ' + 'the chip when it exceeds the available space', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/11523 + Widget chipBuilder(String text, {Widget? avatar, VoidCallback? onDeleted}) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 150, + child: Column( + children: <Widget>[Chip(avatar: avatar, label: Text(text), onDeleted: onDeleted)], + ), + ), + ), + ); + } + + void chipRectContains(Rect chipRect, Rect rect) { + expect(chipRect.contains(rect.topLeft), true); + expect(chipRect.contains(rect.topRight), true); + expect(chipRect.contains(rect.bottomLeft), true); + expect(chipRect.contains(rect.bottomRight), true); + } + + Rect chipRect; + Rect avatarRect; + Rect labelRect; + Rect deleteIconRect; + const text = 'Very long text that will be clipped'; + + await tester.pumpWidget(chipBuilder(text)); + + chipRect = tester.getRect(find.byType(Chip)); + labelRect = tester.getRect(find.text(text)); + chipRectContains(chipRect, labelRect); + + await tester.pumpWidget(chipBuilder(text, avatar: const CircleAvatar(child: Text('A')))); + await tester.pumpAndSettle(); + + chipRect = tester.getRect(find.byType(Chip)); + avatarRect = tester.getRect(find.byType(CircleAvatar)); + chipRectContains(chipRect, avatarRect); + + labelRect = tester.getRect(find.text(text)); + chipRectContains(chipRect, labelRect); + + await tester.pumpWidget( + chipBuilder( + text, + avatar: const CircleAvatar(child: Text('A')), + onDeleted: () {}, + ), + ); + await tester.pumpAndSettle(); + + chipRect = tester.getRect(find.byType(Chip)); + avatarRect = tester.getRect(find.byType(CircleAvatar)); + chipRectContains(chipRect, avatarRect); + + labelRect = tester.getRect(find.text(text)); + chipRectContains(chipRect, labelRect); + + deleteIconRect = tester.getRect(find.byIcon(Icons.cancel)); + chipRectContains(chipRect, deleteIconRect); + }); + + testWidgets('Material2 - Chip in row works ok', (WidgetTester tester) async { + const style = TextStyle(fontSize: 10.0); + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: const Row( + children: <Widget>[Chip(label: Text('Test'), labelStyle: style)], + ), + ), + ); + expect(tester.getSize(find.byType(Text)), const Size(40.0, 10.0)); + expect(tester.getSize(find.byType(Chip)), const Size(64.0, 48.0)); + await tester.pumpWidget( + wrapForChip( + child: const Row( + children: <Widget>[ + Flexible( + child: Chip(label: Text('Test'), labelStyle: style), + ), + ], + ), + ), + ); + expect(tester.getSize(find.byType(Text)), const Size(40.0, 10.0)); + expect(tester.getSize(find.byType(Chip)), const Size(64.0, 48.0)); + await tester.pumpWidget( + wrapForChip( + child: const Row( + children: <Widget>[ + Expanded( + child: Chip(label: Text('Test'), labelStyle: style), + ), + ], + ), + ), + ); + expect(tester.getSize(find.byType(Text)), const Size(40.0, 10.0)); + expect(tester.getSize(find.byType(Chip)), const Size(800.0, 48.0)); + }); + + testWidgets('Material3 - Chip in row works ok', (WidgetTester tester) async { + const style = TextStyle(fontSize: 10.0); + await tester.pumpWidget( + wrapForChip( + child: const Row( + children: <Widget>[Chip(label: Text('Test'), labelStyle: style)], + ), + ), + ); + expect(tester.getSize(find.byType(Text)).width, closeTo(40.4, 0.01)); + expect(tester.getSize(find.byType(Text)).height, equals(14.0)); + expect(tester.getSize(find.byType(Chip)).width, closeTo(74.4, 0.01)); + expect(tester.getSize(find.byType(Chip)).height, equals(48.0)); + await tester.pumpWidget( + wrapForChip( + child: const Row( + children: <Widget>[ + Flexible( + child: Chip(label: Text('Test'), labelStyle: style), + ), + ], + ), + ), + ); + expect(tester.getSize(find.byType(Text)).width, closeTo(40.4, 0.01)); + expect(tester.getSize(find.byType(Text)).height, equals(14.0)); + expect(tester.getSize(find.byType(Chip)).width, closeTo(74.4, 0.01)); + expect(tester.getSize(find.byType(Chip)).height, equals(48.0)); + await tester.pumpWidget( + wrapForChip( + child: const Row( + children: <Widget>[ + Expanded( + child: Chip(label: Text('Test'), labelStyle: style), + ), + ], + ), + ), + ); + expect(tester.getSize(find.byType(Text)).width, closeTo(40.4, 0.01)); + expect(tester.getSize(find.byType(Text)).height, equals(14.0)); + expect(tester.getSize(find.byType(Chip)), const Size(800.0, 48.0)); + }); + + testWidgets('Material2 - Chip responds to materialTapTargetSize', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: const Column( + children: <Widget>[ + Chip(label: Text('X'), materialTapTargetSize: MaterialTapTargetSize.padded), + Chip(label: Text('X'), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + ], + ), + ), + ); + expect(tester.getSize(find.byType(Chip).first), const Size(48.0, 48.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(38.0, 32.0)); + }); + + testWidgets('Material3 - Chip responds to materialTapTargetSize', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: const Column( + children: <Widget>[ + Chip(label: Text('X'), materialTapTargetSize: MaterialTapTargetSize.padded), + Chip(label: Text('X'), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + ], + ), + ), + ); + + expect(tester.getSize(find.byType(Chip).first).width, closeTo(48.1, 0.01)); + expect(tester.getSize(find.byType(Chip).first).height, equals(48.0)); + expect(tester.getSize(find.byType(Chip).last).width, closeTo(48.1, 0.01)); + expect(tester.getSize(find.byType(Chip).last).height, equals(38.0)); + }); + + testWidgets('Delete button tap target is the right proportion of the chip', ( + WidgetTester tester, + ) async { + final deleteKey = UniqueKey(); + var calledDelete = false; + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + label: const Text('Really Long Label'), + deleteIcon: Icon(Icons.delete, key: deleteKey), + onDeleted: () { + calledDelete = true; + }, + ), + ], + ), + ), + ); + + // Test correct tap target size. + await tester.tapAt( + tester.getCenter(find.byKey(deleteKey)) - const Offset(18.0, 0.0), + ); // Half the width of the delete button + right label padding. + await tester.pump(); + expect(calledDelete, isTrue); + calledDelete = false; + + // Test incorrect tap target size. + await tester.tapAt(tester.getCenter(find.byKey(deleteKey)) - const Offset(19.0, 0.0)); + await tester.pump(); + expect(calledDelete, isFalse); + calledDelete = false; + + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + label: const SizedBox(), // Short label + deleteIcon: Icon(Icons.cancel, key: deleteKey), + onDeleted: () { + calledDelete = true; + }, + ), + ], + ), + ), + ); + + // Chip width is 48 with padding, 40 without padding, so halfway is at 20. Cancel + // icon is 24x24, so since 24 > 20 the split location should be halfway across the + // chip, which is at 12 + 8 = 20 from the right side. Since the split is just + // slightly less than 50%, 8 from the center of the delete button should hit the + // chip, not the delete button. + await tester.tapAt(tester.getCenter(find.byKey(deleteKey)) - const Offset(7.0, 0.0)); + await tester.pump(); + expect(calledDelete, isTrue); + calledDelete = false; + + await tester.tapAt(tester.getCenter(find.byKey(deleteKey)) - const Offset(8.0, 0.0)); + await tester.pump(); + expect(calledDelete, isFalse); + }); + + testWidgets('Chip elements are ordered horizontally for locale', (WidgetTester tester) async { + final iconKey = UniqueKey(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + final Widget test = Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Material( + child: Chip( + deleteIcon: Icon(Icons.delete, key: iconKey), + onDeleted: () {}, + label: const Text('ABC'), + ), + ); + }, + ), + ], + ); + + await tester.pumpWidget(wrapForChip(child: test, textDirection: TextDirection.rtl)); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + expect( + tester.getCenter(find.text('ABC')).dx, + greaterThan(tester.getCenter(find.byKey(iconKey)).dx), + ); + await tester.pumpWidget(wrapForChip(child: test)); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + expect( + tester.getCenter(find.text('ABC')).dx, + lessThan(tester.getCenter(find.byKey(iconKey)).dx), + ); + }); + + testWidgets('Material2 - Chip responds to textScaleFactor', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: const Column( + children: <Widget>[ + Chip( + avatar: CircleAvatar(child: Text('A')), + label: Text('Chip A'), + ), + Chip( + avatar: CircleAvatar(child: Text('B')), + label: Text('Chip B'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.text('Chip A')), const Size(84.0, 14.0)); + expect(tester.getSize(find.text('Chip B')), const Size(84.0, 14.0)); + expect(tester.getSize(find.byType(Chip).first), const Size(132.0, 48.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(132.0, 48.0)); + + await tester.pumpWidget( + wrapForChip( + textScaler: const TextScaler.linear(3.0), + child: const Column( + children: <Widget>[ + Chip( + avatar: CircleAvatar(child: Text('A')), + label: Text('Chip A'), + ), + Chip( + avatar: CircleAvatar(child: Text('B')), + label: Text('Chip B'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.text('Chip A')), const Size(252.0, 42.0)); + expect(tester.getSize(find.text('Chip B')), const Size(252.0, 42.0)); + expect(tester.getSize(find.byType(Chip).first), const Size(310.0, 50.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(310.0, 50.0)); + + // Check that individual text scales are taken into account. + await tester.pumpWidget( + wrapForChip( + child: const Column( + children: <Widget>[ + Chip( + avatar: CircleAvatar(child: Text('A')), + label: Text('Chip A', textScaleFactor: 3.0), + ), + Chip( + avatar: CircleAvatar(child: Text('B')), + label: Text('Chip B'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.text('Chip A')), const Size(252.0, 42.0)); + expect(tester.getSize(find.text('Chip B')), const Size(84.0, 14.0)); + expect(tester.getSize(find.byType(Chip).first), const Size(318.0, 50.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(132.0, 48.0)); + }); + + testWidgets('Material3 - Chip responds to textScaleFactor', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: const Column( + children: <Widget>[ + Chip( + avatar: CircleAvatar(child: Text('A')), + label: Text('Chip A'), + ), + Chip( + avatar: CircleAvatar(child: Text('B')), + label: Text('Chip B'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.text('Chip A')).width, closeTo(84.5, 0.1)); + expect(tester.getSize(find.text('Chip A')).height, equals(20.0)); + expect(tester.getSize(find.text('Chip B')).width, closeTo(84.5, 0.1)); + expect(tester.getSize(find.text('Chip B')).height, equals(20.0)); + + await tester.pumpWidget( + wrapForChip( + textScaler: const TextScaler.linear(3.0), + child: const Column( + children: <Widget>[ + Chip( + avatar: CircleAvatar(child: Text('A')), + label: Text('Chip A'), + ), + Chip( + avatar: CircleAvatar(child: Text('B')), + label: Text('Chip B'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.text('Chip A')).width, closeTo(252.6, 0.1)); + expect(tester.getSize(find.text('Chip A')).height, equals(60.0)); + expect(tester.getSize(find.text('Chip B')).width, closeTo(252.6, 0.1)); + expect(tester.getSize(find.text('Chip B')).height, equals(60.0)); + expect(tester.getSize(find.byType(Chip).first).width, closeTo(338.6, 0.1)); + expect(tester.getSize(find.byType(Chip).first).height, equals(78.0)); + expect(tester.getSize(find.byType(Chip).last).width, closeTo(338.6, 0.1)); + expect(tester.getSize(find.byType(Chip).last).height, equals(78.0)); + + // Check that individual text scales are taken into account. + await tester.pumpWidget( + wrapForChip( + child: const Column( + children: <Widget>[ + Chip( + avatar: CircleAvatar(child: Text('A')), + label: Text('Chip A', textScaleFactor: 3.0), + ), + Chip( + avatar: CircleAvatar(child: Text('B')), + label: Text('Chip B'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.text('Chip A')).width, closeTo(252.6, 0.01)); + expect(tester.getSize(find.text('Chip A')).height, equals(60.0)); + expect(tester.getSize(find.text('Chip B')).width, closeTo(84.59, 0.01)); + expect(tester.getSize(find.text('Chip B')).height, equals(20.0)); + expect(tester.getSize(find.byType(Chip).first).width, closeTo(346.6, 0.01)); + expect(tester.getSize(find.byType(Chip).first).height, equals(78.0)); + expect(tester.getSize(find.byType(Chip).last).width, closeTo(138.59, 0.01)); + expect(tester.getSize(find.byType(Chip).last).height, equals(48.0)); + }); + + testWidgets('Material2 - Labels can be non-text widgets', (WidgetTester tester) async { + final Key keyA = GlobalKey(); + final Key keyB = GlobalKey(); + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: Column( + children: <Widget>[ + Chip( + avatar: const CircleAvatar(child: Text('A')), + label: Text('Chip A', key: keyA), + ), + Chip( + avatar: const CircleAvatar(child: Text('B')), + label: SizedBox(key: keyB, width: 10.0, height: 10.0), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.byKey(keyA)), const Size(84.0, 14.0)); + expect(tester.getSize(find.byKey(keyB)), const Size(10.0, 10.0)); + expect(tester.getSize(find.byType(Chip).first), const Size(132.0, 48.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(58.0, 48.0)); + }); + + testWidgets('Material3 - Labels can be non-text widgets', (WidgetTester tester) async { + final Key keyA = GlobalKey(); + final Key keyB = GlobalKey(); + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + avatar: const CircleAvatar(child: Text('A')), + label: Text('Chip A', key: keyA), + ), + Chip( + avatar: const CircleAvatar(child: Text('B')), + label: SizedBox(key: keyB, width: 10.0, height: 10.0), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.byKey(keyA)).width, moreOrLessEquals(84.5, epsilon: 0.1)); + expect(tester.getSize(find.byKey(keyA)).height, equals(20.0)); + expect(tester.getSize(find.byKey(keyB)), const Size(10.0, 10.0)); + expect(tester.getSize(find.byType(Chip).first).width, moreOrLessEquals(138.5, epsilon: 0.1)); + expect(tester.getSize(find.byType(Chip).first).height, equals(48.0)); + expect(tester.getSize(find.byType(Chip).last), const Size(60.0, 48.0)); + }); + + testWidgets('Avatars can be non-circle avatar widgets', (WidgetTester tester) async { + final Key keyA = GlobalKey(); + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + avatar: SizedBox(key: keyA, width: 20.0, height: 20.0), + label: const Text('Chip A'), + ), + ], + ), + ), + ); + + expect(tester.getSize(find.byKey(keyA)), equals(const Size(20.0, 20.0))); + }); + + testWidgets('Delete icons can be non-icon widgets', (WidgetTester tester) async { + final Key keyA = GlobalKey(); + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + deleteIcon: SizedBox(key: keyA, width: 20.0, height: 20.0), + label: const Text('Chip A'), + onDeleted: () {}, + ), + ], + ), + ), + ); + + expect(tester.getSize(find.byKey(keyA)), equals(const Size(20.0, 20.0))); + }); + + testWidgets('Chip padding - LTR', (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget( + wrapForChip( + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Material( + child: Center( + child: Chip( + avatar: Placeholder(key: keyA), + label: SizedBox(key: keyB, width: 40.0, height: 40.0), + onDeleted: () {}, + ), + ), + ); + }, + ), + ], + ), + ), + ); + expect(tester.getTopLeft(find.byKey(keyA)), const Offset(332.0, 280.0)); + expect(tester.getBottomRight(find.byKey(keyA)), const Offset(372.0, 320.0)); + expect(tester.getTopLeft(find.byKey(keyB)), const Offset(380.0, 280.0)); + expect(tester.getBottomRight(find.byKey(keyB)), const Offset(420.0, 320.0)); + expect(tester.getTopLeft(find.byType(Icon)), const Offset(439.0, 291.0)); + expect(tester.getBottomRight(find.byType(Icon)), const Offset(457.0, 309.0)); + }); + + testWidgets('Chip padding - RTL', (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + wrapForChip( + textDirection: TextDirection.rtl, + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Material( + child: Center( + child: Chip( + avatar: Placeholder(key: keyA), + label: SizedBox(key: keyB, width: 40.0, height: 40.0), + onDeleted: () {}, + ), + ), + ); + }, + ), + ], + ), + ), + ); + + expect(tester.getTopLeft(find.byKey(keyA)), const Offset(428.0, 280.0)); + expect(tester.getBottomRight(find.byKey(keyA)), const Offset(468.0, 320.0)); + expect(tester.getTopLeft(find.byKey(keyB)), const Offset(380.0, 280.0)); + expect(tester.getBottomRight(find.byKey(keyB)), const Offset(420.0, 320.0)); + expect(tester.getTopLeft(find.byType(Icon)), const Offset(343.0, 291.0)); + expect(tester.getBottomRight(find.byType(Icon)), const Offset(361.0, 309.0)); + }); + + testWidgets('Material2 - Avatar drawer works as expected on RawChip', ( + WidgetTester tester, + ) async { + final GlobalKey labelKey = GlobalKey(); + Future<void> pushChip({Widget? avatar}) async { + return tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: Wrap( + children: <Widget>[ + RawChip( + avatar: avatar, + label: Text('Chip', key: labelKey), + shape: const StadiumBorder(), + ), + ], + ), + ), + ); + } + + // No avatar + await pushChip(); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(80.0, 48.0))); + final GlobalKey avatarKey = GlobalKey(); + + // Add an avatar + await pushChip( + avatar: Container(key: avatarKey, color: const Color(0xff000000), width: 40.0, height: 40.0), + ); + // Avatar drawer should start out closed. + expect(tester.getSize(find.byType(RawChip)), equals(const Size(80.0, 48.0))); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)), equals(const Offset(-20.0, 12.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Avatar drawer should start expanding. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(81.2, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-18.8, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(13.2, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(86.7, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-13.3, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(18.6, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(94.7, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-5.3, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(26.7, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(99.5, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-0.5, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(31.5, epsilon: 0.1)); + + // Wait for being done with animation, and make sure it didn't change + // height. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(104.0, 48.0))); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)), equals(const Offset(4.0, 12.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(36.0, 17.0))); + + // Remove the avatar again + await pushChip(); + // Avatar drawer should start out open. + expect(tester.getSize(find.byType(RawChip)), equals(const Size(104.0, 48.0))); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)), equals(const Offset(4.0, 12.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(36.0, 17.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Avatar drawer should start contracting. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(102.9, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(2.9, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(34.9, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(98.0, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-2.0, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(30.0, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(84.1, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-15.9, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(16.1, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(80.0, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-20.0, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(12.0, epsilon: 0.1)); + + // Wait for being done with animation, make sure it didn't change + // height, and make sure that the avatar is no longer drawn. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(80.0, 48.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + expect(find.byKey(avatarKey), findsNothing); + }); + + testWidgets('Material3 - Avatar drawer works as expected on RawChip', ( + WidgetTester tester, + ) async { + final GlobalKey labelKey = GlobalKey(); + Future<void> pushChip({Widget? avatar}) async { + return tester.pumpWidget( + wrapForChip( + child: Wrap( + children: <Widget>[ + RawChip( + avatar: avatar, + label: Text('Chip', key: labelKey), + shape: const StadiumBorder(), + ), + ], + ), + ), + ); + } + + // No avatar + await pushChip(); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + final GlobalKey avatarKey = GlobalKey(); + + // Add an avatar + await pushChip( + avatar: Container(key: avatarKey, color: const Color(0xff000000), width: 40.0, height: 40.0), + ); + // Avatar drawer should start out closed. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)), equals(const Offset(-11.0, 14.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Avatar drawer should start expanding. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(91.3, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-10, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(17.9, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(95.9, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-5.4, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(22.5, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(102.6, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(1.2, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(29.2, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(106.6, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(5.2, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(33.2, epsilon: 0.1)); + + // Wait for being done with animation, and make sure it didn't change + // height. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(110.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)), equals(const Offset(9.0, 14.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(37.0, 14.0))); + + // Remove the avatar again + await pushChip(); + // Avatar drawer should start out open. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(110.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)), equals(const Offset(9.0, 14.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(37.0, 14.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Avatar drawer should start contracting. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(109.5, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(8.1, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(36.1, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(105.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(4.0, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(32.0, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(93.7, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-7.6, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(20.3, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(avatarKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(avatarKey)).dx, moreOrLessEquals(-11.0, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)).dx, moreOrLessEquals(17.0, epsilon: 0.1)); + + // Wait for being done with animation, make sure it didn't change + // height, and make sure that the avatar is no longer drawn. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + expect(find.byKey(avatarKey), findsNothing); + }); + + testWidgets('Material2 - Delete button drawer works as expected on RawChip', ( + WidgetTester tester, + ) async { + const labelKey = Key('label'); + const deleteButtonKey = Key('delete'); + var wasDeleted = false; + Future<void> pushChip({bool deletable = false}) async { + return tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + onDeleted: deletable + ? () { + setState(() { + wasDeleted = true; + }); + } + : null, + deleteIcon: Container( + width: 40.0, + height: 40.0, + color: Colors.blue, + key: deleteButtonKey, + ), + label: const Text('Chip', key: labelKey), + shape: const StadiumBorder(), + ); + }, + ), + ], + ), + ), + ); + } + + // No delete button + await pushChip(); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(80.0, 48.0))); + + // Add a delete button + await pushChip(deletable: true); + // Delete button drawer should start out closed. + expect(tester.getSize(find.byType(RawChip)), equals(const Size(80.0, 48.0))); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)), equals(const Offset(52.0, 12.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Delete button drawer should start expanding. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(81.2, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(53.2, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(86.7, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(58.7, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(94.7, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(66.7, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(99.5, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(71.5, epsilon: 0.1)); + + // Wait for being done with animation, and make sure it didn't change + // height. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(104.0, 48.0))); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)), equals(const Offset(76.0, 12.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + + // Test the tap work for the delete button, but not the rest of the chip. + expect(wasDeleted, isFalse); + await tester.tap(find.byKey(labelKey)); + expect(wasDeleted, isFalse); + await tester.tap(find.byKey(deleteButtonKey)); + expect(wasDeleted, isTrue); + + // Remove the delete button again + await pushChip(); + // Delete button drawer should start out open. + expect(tester.getSize(find.byType(RawChip)), equals(const Size(104.0, 48.0))); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)), equals(const Offset(76.0, 12.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Delete button drawer should start contracting. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(103.8, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(75.8, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(102.9, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(74.9, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(101.0, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(73.0, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(97.5, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(24.0, 24.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(69.5, epsilon: 0.1)); + + // Wait for being done with animation, make sure it didn't change + // height, and make sure that the delete button is no longer drawn. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(80.0, 48.0))); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(12.0, 17.0))); + expect(find.byKey(deleteButtonKey), findsNothing); + }); + + testWidgets('Material3 - Delete button drawer works as expected on RawChip', ( + WidgetTester tester, + ) async { + const labelKey = Key('label'); + const deleteButtonKey = Key('delete'); + var wasDeleted = false; + Future<void> pushChip({bool deletable = false}) async { + return tester.pumpWidget( + wrapForChip( + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + onDeleted: deletable + ? () { + setState(() { + wasDeleted = true; + }); + } + : null, + deleteIcon: Container( + width: 40.0, + height: 40.0, + color: Colors.blue, + key: deleteButtonKey, + ), + label: const Text('Chip', key: labelKey), + shape: const StadiumBorder(), + ); + }, + ), + ], + ), + ), + ); + } + + // No delete button + await pushChip(); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.01)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + + // Add a delete button + await pushChip(deletable: true); + // Delete button drawer should start out closed. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.01)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect( + tester.getTopLeft(find.byKey(deleteButtonKey)), + offsetMoreOrLessEquals(const Offset(61.4, 14.0), epsilon: 0.01), + ); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Delete button drawer should start expanding. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(91.3, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(62.3, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(95.9, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(66.9, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(102.6, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(73.6, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(106.6, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(77.6, epsilon: 0.1)); + + // Wait for being done with animation, and make sure it didn't change + // height. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(110.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect( + tester.getTopLeft(find.byKey(deleteButtonKey)), + offsetMoreOrLessEquals(const Offset(81.4, 14.0), epsilon: 0.01), + ); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + + // Test the tap work for the delete button, but not the rest of the chip. + expect(wasDeleted, isFalse); + await tester.tap(find.byKey(labelKey)); + expect(wasDeleted, isFalse); + await tester.tap(find.byKey(deleteButtonKey)); + expect(wasDeleted, isTrue); + + // Remove the delete button again + await pushChip(); + // Delete button drawer should start out open. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(110.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect( + tester.getTopLeft(find.byKey(deleteButtonKey)), + offsetMoreOrLessEquals(const Offset(81.4, 14.0), epsilon: 0.01), + ); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + + await tester.pump(const Duration(milliseconds: 20)); + // Delete button drawer should start contracting. + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(110.1, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(81.1, epsilon: 0.1)); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(109.4, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(80.4, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(107.9, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(78.9, epsilon: 0.1)); + + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(104.9, epsilon: 0.1)); + expect(tester.getSize(find.byKey(deleteButtonKey)), equals(const Size(20.0, 20.0))); + expect(tester.getTopLeft(find.byKey(deleteButtonKey)).dx, moreOrLessEquals(75.9, epsilon: 0.1)); + + // Wait for being done with animation, make sure it didn't change + // height, and make sure that the delete button is no longer drawn. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(tester.getSize(find.byType(RawChip)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + expect(tester.getSize(find.byType(RawChip)).height, equals(48.0)); + expect(tester.getTopLeft(find.byKey(labelKey)), equals(const Offset(17.0, 14.0))); + expect(find.byKey(deleteButtonKey), findsNothing); + }); + + testWidgets('Delete button takes up at most half of the chip', (WidgetTester tester) async { + final chipKey = UniqueKey(); + var chipPressed = false; + var deletePressed = false; + + await tester.pumpWidget( + wrapForChip( + child: Wrap( + children: <Widget>[ + RawChip( + key: chipKey, + onPressed: () { + chipPressed = true; + }, + onDeleted: () { + deletePressed = true; + }, + label: const Text(''), + ), + ], + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byKey(chipKey))); + await tester.pump(); + expect(chipPressed, isTrue); + expect(deletePressed, isFalse); + chipPressed = false; + + await tester.tapAt(tester.getCenter(find.byKey(chipKey)) + const Offset(1.0, 0.0)); + await tester.pump(); + expect(chipPressed, isFalse); + expect(deletePressed, isTrue); + }); + + testWidgets('Material2 - Chip creates centered, unique ripple when label is tapped', ( + WidgetTester tester, + ) async { + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + themeData: ThemeData(useMaterial3: false), + labelKey: labelKey, + deleteButtonKey: deleteButtonKey, + deletable: true, + ), + ); + + final RenderBox box = getMaterialBox(tester); + + // Taps at a location close to the center of the label. + final Offset centerOfLabel = tester.getCenter(find.byKey(labelKey)); + final Offset tapLocationOfLabel = centerOfLabel + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocationOfLabel); + await tester.pump(); + + // Waits for 100 ms. + await tester.pump(const Duration(milliseconds: 100)); + + // There should be one unique, centered ink ripple. + expect(box, ripplePattern(const Offset(163.0, 6.0), 20.9)); + expect(box, uniqueRipplePattern(const Offset(163.0, 6.0), 20.9)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 100 ms again. + await tester.pump(const Duration(milliseconds: 100)); + + // The ripple should grow, with the same center. + expect(box, ripplePattern(const Offset(163.0, 6.0), 41.8)); + expect(box, uniqueRipplePattern(const Offset(163.0, 6.0), 41.8)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + await tester.pumpAndSettle(); + + // There should still be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + await gesture.up(); + }); + + testWidgets('Material3 - Chip creates centered, unique sparkle when label is tapped', ( + WidgetTester tester, + ) async { + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + labelKey: labelKey, + deleteButtonKey: deleteButtonKey, + deletable: true, + ), + ); + + // Taps at a location close to the center of the label. + final Offset centerOfLabel = tester.getCenter(find.byKey(labelKey)); + final Offset tapLocationOfLabel = centerOfLabel + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocationOfLabel); + await tester.pump(); + + // Waits for 100 ms. + await tester.pump(const Duration(milliseconds: 100)); + + // There should be one unique, centered ink sparkle. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.label_tapped.ink_sparkle.0.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 100 ms again. + await tester.pump(const Duration(milliseconds: 100)); + + // The sparkle should grow, with the same center. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.label_tapped.ink_sparkle.1.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + await tester.pumpAndSettle(); + + // There should still be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + await gesture.up(); + }); + + testWidgets('Delete button is focusable', (WidgetTester tester) async { + final GlobalKey labelKey = GlobalKey(); + final GlobalKey deleteButtonKey = GlobalKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + labelKey: labelKey, + deleteButtonKey: deleteButtonKey, + deletable: true, + ), + ); + + Focus.of(deleteButtonKey.currentContext!).requestFocus(); + await tester.pump(); + + // They shouldn't have the same focus node. + expect( + Focus.of(deleteButtonKey.currentContext!), + isNot(equals(Focus.of(labelKey.currentContext!))), + ); + expect(Focus.of(deleteButtonKey.currentContext!).hasFocus, isTrue); + expect(Focus.of(deleteButtonKey.currentContext!).hasPrimaryFocus, isTrue); + // Delete button is a child widget of the Chip, so the Chip should have focus if + // the delete button does. + expect(Focus.of(labelKey.currentContext!).hasFocus, isTrue); + expect(Focus.of(labelKey.currentContext!).hasPrimaryFocus, isFalse); + + Focus.of(labelKey.currentContext!).requestFocus(); + await tester.pump(); + + expect(Focus.of(deleteButtonKey.currentContext!).hasFocus, isFalse); + expect(Focus.of(deleteButtonKey.currentContext!).hasPrimaryFocus, isFalse); + expect(Focus.of(labelKey.currentContext!).hasFocus, isTrue); + expect(Focus.of(labelKey.currentContext!).hasPrimaryFocus, isTrue); + }); + + testWidgets('Material2 - Delete button creates centered, unique ripple when tapped', ( + WidgetTester tester, + ) async { + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + themeData: ThemeData(useMaterial3: false), + labelKey: labelKey, + deleteButtonKey: deleteButtonKey, + deletable: true, + ), + ); + + final RenderBox box = getMaterialBox(tester); + + // Taps at a location close to the center of the delete icon. + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final Offset tapLocationOfDeleteButton = centerOfDeleteButton + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocationOfDeleteButton); + await tester.pump(); + + // Waits for 200 ms. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // There should be one unique ink ripple. + expect(box, ripplePattern(Offset.zero, 1.44)); + expect(box, uniqueRipplePattern(Offset.zero, 1.44)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 200 ms again. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // The ripple should grow, but the center should move, + // Towards the center of the delete icon. + expect(box, ripplePattern(const Offset(2.0, 2.0), 4.32)); + expect(box, uniqueRipplePattern(const Offset(2.0, 2.0), 4.32)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + // This is pressing and holding the delete button. + await tester.pumpAndSettle(); + + // There should be a tooltip. + expect(findTooltipContainer('Delete'), findsOneWidget); + + await gesture.up(); + }); + + testWidgets('Material3 - Delete button creates non-centered, unique sparkle when tapped', ( + WidgetTester tester, + ) async { + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + labelKey: labelKey, + deleteButtonKey: deleteButtonKey, + deletable: true, + size: 18.0, + ), + ); + + // Taps at a location close to the center of the delete icon. + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final Offset tapLocationOfDeleteButton = centerOfDeleteButton + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocationOfDeleteButton); + await tester.pump(); + + // Waits for 200 ms. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // There should be one unique ink sparkle. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.delete_button_tapped.ink_sparkle.0.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 200 ms again. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // The sparkle should grow, but the center should move, + // towards the center of the delete icon. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.delete_button_tapped.ink_sparkle.1.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + // This is pressing and holding the delete button. + await tester.pumpAndSettle(); + + // There should be a tooltip. + expect(findTooltipContainer('Delete'), findsOneWidget); + + await gesture.up(); + }); + + testWidgets( + 'Material2 - Delete button in a chip with null onPressed creates ripple when tapped', + (WidgetTester tester) async { + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + themeData: ThemeData(useMaterial3: false), + labelKey: labelKey, + onPressed: null, + deleteButtonKey: deleteButtonKey, + deletable: true, + ), + ); + + final RenderBox box = getMaterialBox(tester); + + // Taps at a location close to the center of the delete icon. + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final Offset tapLocationOfDeleteButton = centerOfDeleteButton + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocationOfDeleteButton); + await tester.pump(); + + // Waits for 200 ms. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // There should be one unique ink ripple. + expect(box, ripplePattern(Offset.zero, 1.44)); + expect(box, uniqueRipplePattern(Offset.zero, 1.44)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 200 ms again. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // The ripple should grow, but the center should move, + // Towards the center of the delete icon. + expect(box, ripplePattern(const Offset(2.0, 2.0), 4.32)); + expect(box, uniqueRipplePattern(const Offset(2.0, 2.0), 4.32)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + // This is pressing and holding the delete button. + await tester.pumpAndSettle(); + + // There should be a tooltip. + expect(findTooltipContainer('Delete'), findsOneWidget); + + await gesture.up(); + }, + ); + + testWidgets( + 'Material3 - Delete button in a chip with null onPressed creates sparkle when tapped', + (WidgetTester tester) async { + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + labelKey: labelKey, + onPressed: null, + deleteButtonKey: deleteButtonKey, + deletable: true, + size: 18.0, + ), + ); + + // Taps at a location close to the center of the delete icon. + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final Offset tapLocationOfDeleteButton = centerOfDeleteButton + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocationOfDeleteButton); + await tester.pump(); + + // Waits for 200 ms. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // There should be one unique ink sparkle. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.delete_button_tapped.disabled.ink_sparkle.0.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 200 ms again. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // The sparkle should grow, but the center should move, + // towards the center of the delete icon. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.delete_button_tapped.disabled.ink_sparkle.1.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + // This is pressing and holding the delete button. + await tester.pumpAndSettle(); + + // There should be a tooltip. + expect(findTooltipContainer('Delete'), findsOneWidget); + + await gesture.up(); + }, + ); + + testWidgets('RTL delete button responds to tap on the left of the chip', ( + WidgetTester tester, + ) async { + // Creates an RTL chip with a delete button. + final labelKey = UniqueKey(); + final deleteButtonKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + labelKey: labelKey, + deleteButtonKey: deleteButtonKey, + deletable: true, + textDirection: TextDirection.rtl, + ), + ); + + // Taps at a location close to the center of the delete icon, + // Which is on the left side of the chip. + final Offset topLeftOfInkWell = tester.getTopLeft(find.byType(InkWell).first); + final Offset tapLocation = topLeftOfInkWell + const Offset(8, 8); + final TestGesture gesture = await tester.startGesture(tapLocation); + await tester.pump(); + + await tester.pumpAndSettle(); + + // The existence of a 'Delete' tooltip indicates the delete icon is tapped, + // Instead of the label. + expect(findTooltipContainer('Delete'), findsOneWidget); + + await gesture.up(); + }); + + testWidgets('Material2 - Chip without delete button creates correct ripple', ( + WidgetTester tester, + ) async { + // Creates a chip with a delete button. + final labelKey = UniqueKey(); + + await tester.pumpWidget( + chipWithOptionalDeleteButton( + themeData: ThemeData(useMaterial3: false), + labelKey: labelKey, + deletable: false, + ), + ); + + final RenderBox box = getMaterialBox(tester); + + // Taps at a location close to the bottom-right corner of the chip. + final Offset bottomRightOfInkWell = tester.getBottomRight(find.byType(InkWell)); + final Offset tapLocation = bottomRightOfInkWell + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocation); + await tester.pump(); + + // Waits for 100 ms. + await tester.pump(const Duration(milliseconds: 100)); + + // There should be exactly one ink-creating widget. + expect(find.byType(InkWell), findsOneWidget); + expect(find.byType(InkResponse), findsNothing); + + // There should be one unique, centered ink ripple. + expect(box, ripplePattern(const Offset(378.0, 22.0), 37.9)); + expect(box, uniqueRipplePattern(const Offset(378.0, 22.0), 37.9)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 100 ms again. + await tester.pump(const Duration(milliseconds: 100)); + + // The ripple should grow, with the same center. + // This indicates that the tap is not on a delete icon. + expect(box, ripplePattern(const Offset(378.0, 22.0), 75.8)); + expect(box, uniqueRipplePattern(const Offset(378.0, 22.0), 75.8)); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + await tester.pumpAndSettle(); + + // There should still be no tooltip. + // This indicates that the tap is not on a delete icon. + expect(findTooltipContainer('Delete'), findsNothing); + + await gesture.up(); + }); + + testWidgets('Material3 - Chip without delete button creates correct sparkle', ( + WidgetTester tester, + ) async { + // Creates a chip with a delete button. + final labelKey = UniqueKey(); + + await tester.pumpWidget(chipWithOptionalDeleteButton(labelKey: labelKey, deletable: false)); + + // Taps at a location close to the bottom-right corner of the chip. + final Offset bottomRightOfInkWell = tester.getBottomRight(find.byType(InkWell)); + final Offset tapLocation = bottomRightOfInkWell + const Offset(-10, -10); + final TestGesture gesture = await tester.startGesture(tapLocation); + await tester.pump(); + + // Waits for 100 ms. + await tester.pump(const Duration(milliseconds: 100)); + + // There should be exactly one ink-creating widget. + expect(find.byType(InkWell), findsOneWidget); + expect(find.byType(InkResponse), findsNothing); + + // There should be one unique, centered ink sparkle. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.without_delete_button.ink_sparkle.0.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for 100 ms again. + await tester.pump(const Duration(milliseconds: 100)); + + // The sparkle should grow, with the same center. + // This indicates that the tap is not on a delete icon. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('chip.without_delete_button.ink_sparkle.1.png'), + ); + + // There should be no tooltip. + expect(findTooltipContainer('Delete'), findsNothing); + + // Waits for a very long time. + await tester.pumpAndSettle(); + + // There should still be no tooltip. + // This indicates that the tap is not on a delete icon. + expect(findTooltipContainer('Delete'), findsNothing); + + await gesture.up(); + }); + + testWidgets('Material2 - Selection with avatar works as expected on RawChip', ( + WidgetTester tester, + ) async { + var selected = false; + final labelKey = UniqueKey(); + Future<void> pushChip({Widget? avatar, bool selectable = false}) async { + return tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + avatar: avatar, + onSelected: selectable + ? (bool value) { + setState(() { + selected = value; + }); + } + : null, + selected: selected, + label: Text('Long Chip Label', key: labelKey), + shape: const StadiumBorder(), + ); + }, + ), + ], + ), + ), + ); + } + + // With avatar, but not selectable. + final avatarKey = UniqueKey(); + await pushChip(avatar: SizedBox(width: 40.0, height: 40.0, key: avatarKey)); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(258.0, 48.0))); + + // Turn on selection. + await pushChip(avatar: SizedBox(width: 40.0, height: 40.0, key: avatarKey), selectable: true); + await tester.pumpAndSettle(); + + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + // Simulate a tap on the label to select the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(true)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.002, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.54, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(1.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pumpAndSettle(); + + // Simulate another tap on the label to deselect the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(false)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.875, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.13, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(0.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + }); + + testWidgets('Material3 - Selection with avatar works as expected on RawChip', ( + WidgetTester tester, + ) async { + var selected = false; + final labelKey = UniqueKey(); + Future<void> pushChip({Widget? avatar, bool selectable = false}) async { + return tester.pumpWidget( + wrapForChip( + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + avatar: avatar, + onSelected: selectable + ? (bool value) { + setState(() { + selected = value; + }); + } + : null, + selected: selected, + label: Text('Long Chip Label', key: labelKey), + shape: const StadiumBorder(), + ); + }, + ), + ], + ), + ), + ); + } + + // With avatar, but not selectable. + final avatarKey = UniqueKey(); + await pushChip(avatar: SizedBox(width: 40.0, height: 40.0, key: avatarKey)); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(265.5, 48.0))); + + // Turn on selection. + await pushChip(avatar: SizedBox(width: 40.0, height: 40.0, key: avatarKey), selectable: true); + await tester.pumpAndSettle(); + + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + // Simulate a tap on the label to select the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(true)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(kIsWeb ? 3 : 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.002, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.54, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(1.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pumpAndSettle(); + + // Simulate another tap on the label to deselect the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(false)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(kIsWeb ? 3 : 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.875, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.13, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(0.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + }); + + testWidgets('Material2 - Selection without avatar works as expected on RawChip', ( + WidgetTester tester, + ) async { + var selected = false; + final labelKey = UniqueKey(); + Future<void> pushChip({bool selectable = false}) async { + return tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + onSelected: selectable + ? (bool value) { + setState(() { + selected = value; + }); + } + : null, + selected: selected, + label: Text('Long Chip Label', key: labelKey), + shape: const StadiumBorder(), + ); + }, + ), + ], + ), + ), + ); + } + + // Without avatar, but not selectable. + await pushChip(); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(234.0, 48.0))); + + // Turn on selection. + await pushChip(selectable: true); + await tester.pumpAndSettle(); + + // Simulate a tap on the label to select the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(true)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.002, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.459, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.54, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.92, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(1.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + + await tester.pumpAndSettle(); + + // Simulate another tap on the label to deselect the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(false)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.875, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.96, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.13, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.75, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(0.0)); + expect(getAvatarDrawerProgress(tester), equals(0.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + }); + + testWidgets('Material3 - Selection without avatar works as expected on RawChip', ( + WidgetTester tester, + ) async { + var selected = false; + final labelKey = UniqueKey(); + Future<void> pushChip({bool selectable = false}) async { + return tester.pumpWidget( + wrapForChip( + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + onSelected: selectable + ? (bool value) { + setState(() { + selected = value; + }); + } + : null, + selected: selected, + label: Text('Long Chip Label', key: labelKey), + shape: const StadiumBorder(), + ); + }, + ), + ], + ), + ), + ); + } + + // Without avatar, but not selectable. + await pushChip(); + expect(tester.getSize(find.byType(RawChip)), equals(const Size(245.5, 48.0))); + + // Turn on selection. + await pushChip(selectable: true); + await tester.pumpAndSettle(); + + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + // Simulate a tap on the label to select the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(true)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(kIsWeb ? 3 : 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.002, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.459, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.54, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.92, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(1.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + + await tester.pumpAndSettle(); + + // Simulate another tap on the label to deselect the chip. + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(false)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(kIsWeb ? 3 : 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.875, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.96, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 20)); + expect(getSelectProgress(tester), moreOrLessEquals(0.13, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), moreOrLessEquals(0.75, epsilon: 0.01)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(0.0)); + expect(getAvatarDrawerProgress(tester), equals(0.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + }); + + testWidgets('Material2 - Activation works as expected on RawChip', (WidgetTester tester) async { + var selected = false; + final labelKey = UniqueKey(); + Future<void> pushChip({Widget? avatar, bool selectable = false}) async { + return tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + avatar: avatar, + onSelected: selectable + ? (bool value) { + setState(() { + selected = value; + }); + } + : null, + selected: selected, + label: Text('Long Chip Label', key: labelKey), + shape: const StadiumBorder(), + showCheckmark: false, + ); + }, + ), + ], + ), + ), + ); + } + + final avatarKey = UniqueKey(); + await pushChip(avatar: SizedBox(width: 40.0, height: 40.0, key: avatarKey), selectable: true); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(true)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.002, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.54, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(1.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pumpAndSettle(); + }); + + testWidgets('Material3 - Activation works as expected on RawChip', (WidgetTester tester) async { + var selected = false; + final labelKey = UniqueKey(); + Future<void> pushChip({Widget? avatar, bool selectable = false}) async { + return tester.pumpWidget( + wrapForChip( + child: Wrap( + children: <Widget>[ + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + avatar: avatar, + onSelected: selectable + ? (bool value) { + setState(() { + selected = value; + }); + } + : null, + selected: selected, + label: Text('Long Chip Label', key: labelKey), + shape: const StadiumBorder(), + showCheckmark: false, + ); + }, + ), + ], + ), + ), + ); + } + + final avatarKey = UniqueKey(); + await pushChip(avatar: SizedBox(width: 40.0, height: 40.0, key: avatarKey), selectable: true); + await tester.pumpAndSettle(); + + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + await tester.tap(find.byKey(labelKey)); + expect(selected, equals(true)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(kIsWeb ? 3 : 1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.002, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getSelectProgress(tester), moreOrLessEquals(0.54, epsilon: 0.01)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(getSelectProgress(tester), equals(1.0)); + expect(getAvatarDrawerProgress(tester), equals(1.0)); + expect(getDeleteDrawerProgress(tester), equals(0.0)); + await tester.pumpAndSettle(); + }); + + testWidgets('Chip uses ThemeData chip theme if present', (WidgetTester tester) async { + final theme = ThemeData(chipTheme: const ChipThemeData(backgroundColor: Color(0xffff0000))); + + Widget buildChip() { + return wrapForChip( + child: Theme( + data: theme, + child: InputChip(label: const Text('Label'), onPressed: () {}), + ), + ); + } + + await tester.pumpWidget(buildChip()); + + final RenderBox materialBox = tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(RawChip), matching: find.byType(CustomPaint)), + ); + + expect(materialBox, paints..rrect(color: theme.chipTheme.backgroundColor)); + }); + + testWidgets('Chip merges ChipThemeData label style with the provided label style', ( + WidgetTester tester, + ) async { + // The font family should be preserved even if the chip overrides some label style properties + final theme = ThemeData(fontFamily: 'MyFont'); + + Widget buildChip() { + return wrapForChip( + child: Theme( + data: theme, + child: const Chip( + label: Text('Label'), + labelStyle: TextStyle(fontWeight: FontWeight.w200), + ), + ), + ); + } + + await tester.pumpWidget(buildChip()); + + final TextStyle labelStyle = getLabelStyle(tester, 'Label').style; + expect(labelStyle.inherit, false); + expect(labelStyle.fontFamily, 'MyFont'); + expect(labelStyle.fontWeight, FontWeight.w200); + }); + + testWidgets('ChipTheme labelStyle with inherit:true', (WidgetTester tester) async { + Widget buildChip() { + return wrapForChip( + child: Theme( + data: ThemeData( + chipTheme: const ChipThemeData( + labelStyle: TextStyle(height: 4), // inherit: true + ), + ), + child: const Chip(label: Text('Label')), // labelStyle: null + ), + ); + } + + await tester.pumpWidget(buildChip()); + final TextStyle labelStyle = getLabelStyle(tester, 'Label').style; + expect(labelStyle.inherit, true); // because chipTheme.labelStyle.merge(null) + expect(labelStyle.height, 4); + }); + + testWidgets('Chip does not merge inherit:false label style with the theme label style', ( + WidgetTester tester, + ) async { + Widget buildChip() { + return wrapForChip( + child: Theme( + data: ThemeData(fontFamily: 'MyFont'), + child: const DefaultTextStyle( + style: TextStyle(height: 8), + child: Chip( + label: Text('Label'), + labelStyle: TextStyle(fontWeight: FontWeight.w200, inherit: false), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildChip()); + final TextStyle labelStyle = getLabelStyle(tester, 'Label').style; + expect(labelStyle.inherit, false); + expect(labelStyle.fontFamily, null); + expect(labelStyle.height, null); + expect(labelStyle.fontWeight, FontWeight.w200); + }); + + testWidgets('Material2 - Chip size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + final Key key1 = UniqueKey(); + await tester.pumpWidget( + wrapForChip( + child: Theme( + data: ThemeData(useMaterial3: false, materialTapTargetSize: MaterialTapTargetSize.padded), + child: Center( + child: RawChip(key: key1, label: const Text('test')), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key1)), const Size(80.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget( + wrapForChip( + child: Theme( + data: ThemeData( + useMaterial3: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Center( + child: RawChip(key: key2, label: const Text('test')), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key2)), const Size(80.0, 32.0)); + }); + + testWidgets('Material3 - Chip size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + final Key key1 = UniqueKey(); + await tester.pumpWidget( + wrapForChip( + child: Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Center( + child: RawChip(key: key1, label: const Text('test')), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key1)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + expect(tester.getSize(find.byKey(key1)).height, equals(48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget( + wrapForChip( + child: Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Center( + child: RawChip(key: key2, label: const Text('test')), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key2)).width, moreOrLessEquals(90.4, epsilon: 0.1)); + expect(tester.getSize(find.byKey(key2)).height, equals(38.0)); + }); + + testWidgets('Chip uses the right theme colors for the right components', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final defaultChipTheme = ChipThemeData.fromDefaults( + brightness: themeData.brightness, + secondaryColor: Colors.blue, + labelStyle: themeData.textTheme.bodyLarge!, + ); + var value = false; + Widget buildApp({ + ChipThemeData? chipTheme, + Widget? avatar, + Widget? deleteIcon, + bool isSelectable = true, + bool isPressable = false, + bool isDeletable = true, + bool showCheckmark = true, + }) { + chipTheme ??= defaultChipTheme; + return wrapForChip( + child: Theme( + data: themeData, + child: ChipTheme( + data: chipTheme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + showCheckmark: showCheckmark, + onDeleted: isDeletable ? () {} : null, + avatar: avatar, + deleteIcon: deleteIcon, + isEnabled: isSelectable || isPressable, + shape: chipTheme?.shape, + selected: isSelectable && value, + label: Text('$value'), + onSelected: isSelectable + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + onPressed: isPressable + ? () { + setState(() { + value = true; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + RenderBox materialBox = getMaterialBox(tester); + IconThemeData iconData = getIconData(tester); + DefaultTextStyle labelStyle = getLabelStyle(tester, 'false'); + + // Check default theme for enabled widget. + expect(materialBox, paints..rrect(color: defaultChipTheme.backgroundColor)); + expect(iconData.color, equals(const Color(0xde000000))); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + expect(materialBox, paints..rrect(color: defaultChipTheme.selectedColor)); + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + + // Check default theme with disabled widget. + await tester.pumpWidget(buildApp(isSelectable: false)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + labelStyle = getLabelStyle(tester, 'false'); + expect(materialBox, paints..rrect(color: defaultChipTheme.disabledColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + + // Apply a custom theme. + const customColor1 = Color(0xcafefeed); + const customColor2 = Color(0xdeadbeef); + const customColor3 = Color(0xbeefcafe); + const customColor4 = Color(0xaddedabe); + final ChipThemeData customTheme = defaultChipTheme.copyWith( + brightness: Brightness.dark, + backgroundColor: customColor1, + disabledColor: customColor2, + selectedColor: customColor3, + deleteIconColor: customColor4, + ); + await tester.pumpWidget(buildApp(chipTheme: customTheme)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + iconData = getIconData(tester); + labelStyle = getLabelStyle(tester, 'false'); + + // Check custom theme for enabled widget. + expect(materialBox, paints..rrect(color: customTheme.backgroundColor)); + expect(iconData.color, equals(customTheme.deleteIconColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + expect(materialBox, paints..rrect(color: customTheme.selectedColor)); + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + + // Check custom theme with disabled widget. + await tester.pumpWidget(buildApp(chipTheme: customTheme, isSelectable: false)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + labelStyle = getLabelStyle(tester, 'false'); + expect(materialBox, paints..rrect(color: customTheme.disabledColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + }); + + testWidgets('Chip uses the resolved color based on its state for the delete icon color', ( + WidgetTester tester, + ) async { + Widget buildApp({required ChipThemeData chipTheme, bool isSelected = false}) { + return wrapForChip( + child: ChipTheme( + data: chipTheme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return InputChip( + label: const Text('Label'), + selected: isSelected, + onSelected: (_) {}, + onDeleted: () {}, + ); + }, + ), + ), + ); + } + + final chipTheme = ChipThemeData( + deleteIconColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return Colors.green; + } + return Colors.blue; + }), + ); + + await tester.pumpWidget(buildApp(chipTheme: chipTheme)); + + IconThemeData iconData = getIconData(tester); + expect(iconData.color, equals(Colors.blue)); + + await tester.pumpWidget(buildApp(chipTheme: chipTheme, isSelected: true)); + + iconData = getIconData(tester); + expect(iconData.color, equals(Colors.green)); + }); + + group('Chip semantics', () { + testWidgets('label only', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: RawChip(label: Text('test'))), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + semanticsTester.dispose(); + }); + + testWidgets('delete', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawChip(label: const Text('test'), onDeleted: () {}), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + ], + children: <TestSemantics>[ + TestSemantics( + tooltip: 'Delete', + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + semanticsTester.dispose(); + }); + + testWidgets('with onPressed', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawChip(label: const Text('test'), onPressed: () {}), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semanticsTester.dispose(); + }); + + testWidgets('with onSelected', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + var selected = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawChip( + label: const Text('test'), + selected: selected, + onSelected: (bool value) { + selected = value; + }, + ), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + await tester.tap(find.byType(RawChip)); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawChip( + label: const Text('test'), + selected: selected, + onSelected: (bool value) { + selected = value; + }, + ), + ), + ), + ); + + expect(selected, true); + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + if (kIsWeb) ...<SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + ], + if (!kIsWeb) ...<SemanticsFlag>[ + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + ], + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semanticsTester.dispose(); + }); + + testWidgets('disabled', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawChip(isEnabled: false, onPressed: () {}, label: const Text('test')), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + ], + actions: <SemanticsAction>[], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semanticsTester.dispose(); + }); + + testWidgets('tapEnabled explicitly false', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: RawChip(tapEnabled: false, label: Text('test'))), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + // Must not be a button when tapping is disabled. + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + ], + actions: <SemanticsAction>[], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semanticsTester.dispose(); + }); + + testWidgets('enabled when tapEnabled and canTap', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + + // These settings make a Chip which can be tapped, both in general and at this moment. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawChip(onPressed: () {}, label: const Text('test')), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semanticsTester.dispose(); + }); + + testWidgets('disabled when tapEnabled but not canTap', (WidgetTester tester) async { + final semanticsTester = SemanticsTester(tester); + // These settings make a Chip which _could_ be tapped, but not currently (ensures `canTap == false`). + await tester.pumpWidget( + const MaterialApp( + home: Material(child: RawChip(label: Text('test'))), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'test', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.hasCheckedState, + if (!kIsWeb) SemanticsFlag.hasSelectedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semanticsTester.dispose(); + }); + }); + + testWidgets('can be tapped outside of chip delete icon', (WidgetTester tester) async { + var deleted = false; + await tester.pumpWidget( + wrapForChip( + child: Row( + children: <Widget>[ + Chip( + materialTapTargetSize: MaterialTapTargetSize.padded, + shape: const RoundedRectangleBorder(), + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onDeleted: () { + deleted = true; + }, + deleteIcon: const Icon(Icons.delete), + ), + ], + ), + ), + ); + + await tester.tapAt(tester.getTopRight(find.byType(Chip)) - const Offset(2.0, -2.0)); + await tester.pumpAndSettle(); + expect(deleted, true); + }); + + testWidgets('Chips can be tapped', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: RawChip(label: Text('raw chip'))), + ), + ); + + await tester.tap(find.byType(RawChip)); + expect(tester.takeException(), null); + }); + + testWidgets('Material2 - Chip elevation and shadow color work correctly', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + useMaterial3: false, + platform: TargetPlatform.android, + primarySwatch: Colors.red, + ); + + var inputChip = const InputChip(label: Text('Label')); + + Widget buildChip() { + return wrapForChip( + child: Theme(data: theme, child: inputChip), + ); + } + + await tester.pumpWidget(buildChip()); + Material material = getMaterial(tester); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.black); + + inputChip = const InputChip( + label: Text('Label'), + elevation: 4.0, + shadowColor: Colors.green, + selectedShadowColor: Colors.blue, + ); + + await tester.pumpWidget(buildChip()); + await tester.pumpAndSettle(); + material = getMaterial(tester); + expect(material.elevation, 4.0); + expect(material.shadowColor, Colors.green); + + inputChip = const InputChip( + label: Text('Label'), + selected: true, + shadowColor: Colors.green, + selectedShadowColor: Colors.blue, + ); + + await tester.pumpWidget(buildChip()); + await tester.pumpAndSettle(); + material = getMaterial(tester); + expect(material.shadowColor, Colors.blue); + }); + + testWidgets('Material3 - Chip elevation and shadow color work correctly', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + + var inputChip = const InputChip(label: Text('Label')); + + Widget buildChip() { + return wrapForChip(theme: theme, child: inputChip); + } + + await tester.pumpWidget(buildChip()); + Material material = getMaterial(tester); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + + inputChip = const InputChip( + label: Text('Label'), + elevation: 4.0, + shadowColor: Colors.green, + selectedShadowColor: Colors.blue, + ); + + await tester.pumpWidget(buildChip()); + await tester.pumpAndSettle(); + material = getMaterial(tester); + expect(material.elevation, 4.0); + expect(material.shadowColor, Colors.green); + + inputChip = const InputChip( + label: Text('Label'), + selected: true, + shadowColor: Colors.green, + selectedShadowColor: Colors.blue, + ); + + await tester.pumpWidget(buildChip()); + await tester.pumpAndSettle(); + material = getMaterial(tester); + expect(material.shadowColor, Colors.blue); + }); + + testWidgets('can be tapped outside of chip body', (WidgetTester tester) async { + var pressed = false; + await tester.pumpWidget( + wrapForChip( + child: Row( + children: <Widget>[ + InputChip( + materialTapTargetSize: MaterialTapTargetSize.padded, + shape: const RoundedRectangleBorder(), + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onPressed: () { + pressed = true; + }, + ), + ], + ), + ), + ); + + await tester.tapAt(tester.getRect(find.byType(InputChip)).topCenter); + await tester.pumpAndSettle(); + expect(pressed, true); + }); + + testWidgets('is hitTestable', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: InputChip( + shape: const RoundedRectangleBorder(), + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onPressed: () {}, + ), + ), + ); + + expect(find.byType(InputChip).hitTestable(), findsOneWidget); + }); + + void checkChipMaterialClipBehavior(WidgetTester tester, Clip clipBehavior) { + final Iterable<Material> materials = tester.widgetList<Material>(find.byType(Material)); + expect(materials.length, 2); + expect(materials.last.clipBehavior, clipBehavior); + } + + testWidgets('Chip clipBehavior properly passes through to the Material', ( + WidgetTester tester, + ) async { + const label = Text('label'); + await tester.pumpWidget(wrapForChip(child: const Chip(label: label))); + checkChipMaterialClipBehavior(tester, Clip.none); + + await tester.pumpWidget( + wrapForChip( + child: const Chip(label: label, clipBehavior: Clip.antiAlias), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.antiAlias); + }); + + testWidgets('Material2 - selected chip and avatar draw darkened layer within avatar circle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: const FilterChip( + avatar: CircleAvatar(child: Text('t')), + label: Text('test'), + selected: true, + onSelected: null, + ), + ), + ); + final RenderBox rawChip = tester.firstRenderObject<RenderBox>( + find.descendant( + of: find.byType(RawChip), + matching: find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_ChipRenderWidget'; + }), + ), + ); + const avatarX = 16.0; + const avatarY = 16.0; + const avatarRadius = 12.0; + const selectScrimColor = Color(0x60191919); + expect( + rawChip, + paints + // CircleAvatar background. + ..circle(x: avatarX, y: avatarY, radius: avatarRadius) + // Selection scrim overlay. + ..circle(color: selectScrimColor, x: avatarX, y: avatarY, radius: avatarRadius), + ); + }); + + testWidgets('Material3 - selected chip and avatar draw darkened layer within avatar circle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + wrapForChip( + child: const FilterChip( + avatar: CircleAvatar(child: Text('t')), + label: Text('test'), + selected: true, + onSelected: null, + ), + ), + ); + final RenderBox rawChip = tester.firstRenderObject<RenderBox>( + find.descendant( + of: find.byType(RawChip), + matching: find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_ChipRenderWidget'; + }), + ), + ); + const avatarX = 18.0; + const avatarY = 18.0; + const avatarRadius = 10.0; + const selectScrimColor = Color(0x60191919); + expect( + rawChip, + paints + // CircleAvatar background. + ..circle(x: avatarX, y: avatarY, radius: avatarRadius) + // Selection scrim overlay. + ..circle(color: selectScrimColor, x: avatarX, y: avatarY, radius: avatarRadius), + ); + }); + + testWidgets('Chips should use InkWell instead of InkResponse.', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/28646 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ActionChip(onPressed: () {}, label: const Text('action chip')), + ), + ), + ); + expect(find.byType(InkWell), findsOneWidget); + }); + + testWidgets('Chip uses stateful color for text color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + const disabledColor = Color(0x00000006); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + + return defaultColor; + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + labelStyle: TextStyle(color: WidgetStateColor.resolveWith(getTextColor)), + ), + ), + ), + ); + } + + Color textColor() { + return tester.renderObject<RenderParagraph>(find.text('Chip')).text.style!.color!; + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect(textColor(), equals(defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(textColor(), selectedColor); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(), hoverColor); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(textColor(), pressedColor); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(textColor(), disabledColor); + }); + + testWidgets('Material2 - Chip uses stateful border side color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + const disabledColor = Color(0x00000006); + + BorderSide getBorderSide(Set<WidgetState> states) { + var sideColor = defaultColor; + if (states.contains(WidgetState.disabled)) { + sideColor = disabledColor; + } else if (states.contains(WidgetState.pressed)) { + sideColor = pressedColor; + } else if (states.contains(WidgetState.hovered)) { + sideColor = hoverColor; + } else if (states.contains(WidgetState.focused)) { + sideColor = focusedColor; + } else if (states.contains(WidgetState.selected)) { + sideColor = selectedColor; + } + return BorderSide(color: sideColor); + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: _TestWidgetStateBorderSide(getBorderSide), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: defaultColor), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: selectedColor), + ); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: focusedColor), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: hoverColor), + ); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: pressedColor), + ); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: disabledColor), + ); + }); + + testWidgets('Material3 - Chip uses stateful border side color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + const disabledColor = Color(0x00000006); + + BorderSide getBorderSide(Set<WidgetState> states) { + var sideColor = defaultColor; + if (states.contains(WidgetState.disabled)) { + sideColor = disabledColor; + } else if (states.contains(WidgetState.pressed)) { + sideColor = pressedColor; + } else if (states.contains(WidgetState.hovered)) { + sideColor = hoverColor; + } else if (states.contains(WidgetState.focused)) { + sideColor = focusedColor; + } else if (states.contains(WidgetState.selected)) { + sideColor = selectedColor; + } + return BorderSide(color: sideColor); + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: _TestWidgetStateBorderSide(getBorderSide), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..drrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(find.byType(RawChip), paints..drrect(color: selectedColor)); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: hoverColor)); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: pressedColor)); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: disabledColor)); + }); + + testWidgets('Material2 - Chip uses stateful border side color from resolveWith', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + const disabledColor = Color(0x00000006); + + BorderSide getBorderSide(Set<WidgetState> states) { + var sideColor = defaultColor; + if (states.contains(WidgetState.disabled)) { + sideColor = disabledColor; + } else if (states.contains(WidgetState.pressed)) { + sideColor = pressedColor; + } else if (states.contains(WidgetState.hovered)) { + sideColor = hoverColor; + } else if (states.contains(WidgetState.focused)) { + sideColor = focusedColor; + } else if (states.contains(WidgetState.selected)) { + sideColor = selectedColor; + } + return BorderSide(color: sideColor); + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: WidgetStateBorderSide.resolveWith(getBorderSide), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: defaultColor), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: selectedColor), + ); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: focusedColor), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: hoverColor), + ); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: pressedColor), + ); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: disabledColor), + ); + }); + + testWidgets('Material3 - Chip uses stateful border side color from resolveWith', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + const disabledColor = Color(0x00000006); + + BorderSide getBorderSide(Set<WidgetState> states) { + var sideColor = defaultColor; + if (states.contains(WidgetState.disabled)) { + sideColor = disabledColor; + } else if (states.contains(WidgetState.pressed)) { + sideColor = pressedColor; + } else if (states.contains(WidgetState.hovered)) { + sideColor = hoverColor; + } else if (states.contains(WidgetState.focused)) { + sideColor = focusedColor; + } else if (states.contains(WidgetState.selected)) { + sideColor = selectedColor; + } + return BorderSide(color: sideColor); + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: WidgetStateBorderSide.resolveWith(getBorderSide), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..drrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(find.byType(RawChip), paints..drrect(color: selectedColor)); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: hoverColor)); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: pressedColor)); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: disabledColor)); + }); + + testWidgets('Material2 - Chip uses stateful nullable border side color from resolveWith', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const disabledColor = Color(0x00000006); + + const fallbackThemeColor = Color(0x00000007); + const defaultBorderSide = BorderSide(color: fallbackThemeColor, width: 10.0); + + BorderSide? getBorderSide(Set<WidgetState> states) { + var sideColor = defaultColor; + if (states.contains(WidgetState.disabled)) { + sideColor = disabledColor; + } else if (states.contains(WidgetState.pressed)) { + sideColor = pressedColor; + } else if (states.contains(WidgetState.hovered)) { + sideColor = hoverColor; + } else if (states.contains(WidgetState.focused)) { + sideColor = focusedColor; + } else if (states.contains(WidgetState.selected)) { + return null; + } + return BorderSide(color: sideColor); + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChipTheme( + data: ThemeData().chipTheme.copyWith(side: defaultBorderSide), + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: WidgetStateBorderSide.resolveWith(getBorderSide), + ), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: defaultColor), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + // Because the resolver returns `null` for this value, we should fall back + // to the theme. + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: fallbackThemeColor), + ); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: focusedColor), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: hoverColor), + ); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: pressedColor), + ); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: disabledColor), + ); + }); + + testWidgets('Material3 - Chip uses stateful nullable border side color from resolveWith', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const disabledColor = Color(0x00000006); + + const fallbackThemeColor = Color(0x00000007); + const defaultBorderSide = BorderSide(color: fallbackThemeColor, width: 10.0); + + BorderSide? getBorderSide(Set<WidgetState> states) { + var sideColor = defaultColor; + if (states.contains(WidgetState.disabled)) { + sideColor = disabledColor; + } else if (states.contains(WidgetState.pressed)) { + sideColor = pressedColor; + } else if (states.contains(WidgetState.hovered)) { + sideColor = hoverColor; + } else if (states.contains(WidgetState.focused)) { + sideColor = focusedColor; + } else if (states.contains(WidgetState.selected)) { + return null; + } + return BorderSide(color: sideColor); + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChipTheme( + data: ThemeData().chipTheme.copyWith(side: defaultBorderSide), + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + side: WidgetStateBorderSide.resolveWith(getBorderSide), + ), + ), + ), + ), + ); + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..drrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + // Because the resolver returns `null` for this value, we should fall back + // to the theme + expect(find.byType(RawChip), paints..drrect(color: fallbackThemeColor)); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: hoverColor)); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: pressedColor)); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(find.byType(RawChip), paints..drrect(color: disabledColor)); + }); + + testWidgets('Material2 - Chip uses stateful shape in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + OutlinedBorder? getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const BeveledRectangleBorder(); + } else if (states.contains(WidgetState.pressed)) { + return const CircleBorder(); + } else if (states.contains(WidgetState.hovered)) { + return const ContinuousRectangleBorder(); + } else if (states.contains(WidgetState.focused)) { + return const RoundedRectangleBorder(); + } else if (states.contains(WidgetState.selected)) { + return const BeveledRectangleBorder(); + } + return null; + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + selected: selected, + label: const Text('Chip'), + shape: _TestWidgetStateOutlinedBorder(getShape), + onSelected: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + // Default, not disabled. Defers to default shape. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA<StadiumBorder>()); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA<BeveledRectangleBorder>()); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<ContinuousRectangleBorder>()); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<CircleBorder>()); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<BeveledRectangleBorder>()); + }); + + testWidgets('Material3 - Chip uses stateful shape in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + OutlinedBorder? getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const BeveledRectangleBorder(); + } else if (states.contains(WidgetState.pressed)) { + return const CircleBorder(); + } else if (states.contains(WidgetState.hovered)) { + return const ContinuousRectangleBorder(); + } else if (states.contains(WidgetState.focused)) { + return const RoundedRectangleBorder(); + } else if (states.contains(WidgetState.selected)) { + return const BeveledRectangleBorder(); + } + return null; + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + selected: selected, + label: const Text('Chip'), + shape: _TestWidgetStateOutlinedBorder(getShape), + onSelected: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + // Default, not disabled. Defers to default shape. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA<BeveledRectangleBorder>()); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<ContinuousRectangleBorder>()); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<CircleBorder>()); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(getMaterial(tester).shape, isA<BeveledRectangleBorder>()); + }); + + testWidgets('Material2 - Chip defers to theme, if shape and side resolves to null', ( + WidgetTester tester, + ) async { + const OutlinedBorder themeShape = StadiumBorder(); + const OutlinedBorder selectedShape = RoundedRectangleBorder(); + const themeBorderSide = BorderSide(color: Color(0x00000001)); + const selectedBorderSide = BorderSide(color: Color(0x00000002)); + + OutlinedBorder? getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedShape; + } + return null; + } + + BorderSide? getBorderSide(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedBorderSide; + } + return null; + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData( + useMaterial3: false, + chipTheme: ThemeData().chipTheme.copyWith(shape: themeShape, side: themeBorderSide), + ), + home: Scaffold( + body: ChoiceChip( + selected: selected, + label: const Text('Chip'), + shape: _TestWidgetStateOutlinedBorder(getShape), + side: _TestWidgetStateBorderSide(getBorderSide), + onSelected: enabled ? (_) {} : null, + ), + ), + ); + } + + // Default, not disabled. Defer to theme. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA<StadiumBorder>()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: themeBorderSide.color), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + expect( + find.byType(RawChip), + paints + ..rect() + ..drrect(color: selectedBorderSide.color), + ); + }); + + testWidgets('Chip defers to theme, if shape and side resolves to null', ( + WidgetTester tester, + ) async { + const OutlinedBorder themeShape = StadiumBorder(); + const OutlinedBorder selectedShape = RoundedRectangleBorder(); + const themeBorderSide = BorderSide(color: Color(0x00000001)); + const selectedBorderSide = BorderSide(color: Color(0x00000002)); + + OutlinedBorder? getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedShape; + } + return null; + } + + BorderSide? getBorderSide(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedBorderSide; + } + return null; + } + + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData( + chipTheme: ThemeData().chipTheme.copyWith(shape: themeShape, side: themeBorderSide), + ), + home: Scaffold( + body: ChoiceChip( + selected: selected, + label: const Text('Chip'), + shape: _TestWidgetStateOutlinedBorder(getShape), + side: _TestWidgetStateBorderSide(getBorderSide), + onSelected: enabled ? (_) {} : null, + ), + ), + ); + } + + // Default, not disabled. Defer to theme. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA<StadiumBorder>()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: themeBorderSide.color), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + expect( + find.byType(RawChip), + paints + ..rect() + ..drrect(color: selectedBorderSide.color), + ); + }); + + testWidgets('Material2 - Chip responds to density changes', (WidgetTester tester) async { + const key = Key('test'); + const textKey = Key('test text'); + const iconKey = Key('test icon'); + const avatarKey = Key('test avatar'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Column( + children: <Widget>[ + InputChip( + visualDensity: visualDensity, + key: key, + onPressed: () {}, + onDeleted: () {}, + label: const Text('Test', key: textKey), + deleteIcon: const Icon(Icons.delete, key: iconKey), + avatar: const Icon(Icons.play_arrow, key: avatarKey), + ), + ], + ), + ), + ), + ), + ); + } + + // The Chips only change in size vertically in response to density, so + // horizontal changes aren't expected. + await buildTest(VisualDensity.standard); + Rect box = tester.getRect(find.byKey(key)); + Rect textBox = tester.getRect(find.byKey(textKey)); + Rect iconBox = tester.getRect(find.byKey(iconKey)); + Rect avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size, equals(const Size(128, 32.0 + 16.0))); + expect(textBox.size, equals(const Size(56, 14))); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(17)); + expect(box.bottom - textBox.bottom, equals(17)); + expect(textBox.left, equals(372)); + expect(box.right - textBox.right, equals(36)); + + // Try decreasing density (with higher density numbers). + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + box = tester.getRect(find.byKey(key)); + textBox = tester.getRect(find.byKey(textKey)); + iconBox = tester.getRect(find.byKey(iconKey)); + avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size, equals(const Size(128, 60))); + expect(textBox.size, equals(const Size(56, 14))); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(23)); + expect(box.bottom - textBox.bottom, equals(23)); + expect(textBox.left, equals(372)); + expect(box.right - textBox.right, equals(36)); + + // Try increasing density (with lower density numbers). + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + box = tester.getRect(find.byKey(key)); + textBox = tester.getRect(find.byKey(textKey)); + iconBox = tester.getRect(find.byKey(iconKey)); + avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size, equals(const Size(128, 36))); + expect(textBox.size, equals(const Size(56, 14))); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(11)); + expect(box.bottom - textBox.bottom, equals(11)); + expect(textBox.left, equals(372)); + expect(box.right - textBox.right, equals(36)); + + // Now test that horizontal and vertical are wired correctly. Negating the + // horizontal should have no change over what's above. + await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + box = tester.getRect(find.byKey(key)); + textBox = tester.getRect(find.byKey(textKey)); + iconBox = tester.getRect(find.byKey(iconKey)); + avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size, equals(const Size(128, 36))); + expect(textBox.size, equals(const Size(56, 14))); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(11)); + expect(box.bottom - textBox.bottom, equals(11)); + expect(textBox.left, equals(372)); + expect(box.right - textBox.right, equals(36)); + + // Make sure the "Comfortable" setting is the spec'd size + await buildTest(VisualDensity.comfortable); + await tester.pumpAndSettle(); + box = tester.getRect(find.byKey(key)); + expect(box.size, equals(const Size(128, 28.0 + 16.0))); + + // Make sure the "Compact" setting is the spec'd size + await buildTest(VisualDensity.compact); + await tester.pumpAndSettle(); + box = tester.getRect(find.byKey(key)); + expect(box.size, equals(const Size(128, 24.0 + 16.0))); + }); + + testWidgets('Material3 - Chip responds to density changes', (WidgetTester tester) async { + const key = Key('test'); + const textKey = Key('test text'); + const iconKey = Key('test icon'); + const avatarKey = Key('test avatar'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Column( + children: <Widget>[ + InputChip( + visualDensity: visualDensity, + key: key, + onPressed: () {}, + onDeleted: () {}, + label: const Text('Test', key: textKey), + deleteIcon: const Icon(Icons.delete, key: iconKey), + avatar: const Icon(Icons.play_arrow, key: avatarKey), + ), + ], + ), + ), + ), + ), + ); + } + + // The Chips only change in size vertically in response to density, so + // horizontal changes aren't expected. + await buildTest(VisualDensity.standard); + Rect box = tester.getRect(find.byKey(key)); + Rect textBox = tester.getRect(find.byKey(textKey)); + Rect iconBox = tester.getRect(find.byKey(iconKey)); + Rect avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size.width, moreOrLessEquals(130.4, epsilon: 0.1)); + expect(box.size.height, equals(32.0 + 16.0)); + expect(textBox.size.width, moreOrLessEquals(56.4, epsilon: 0.1)); + expect(textBox.size.height, equals(20.0)); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(14)); + expect(box.bottom - textBox.bottom, equals(14)); + expect(textBox.left, moreOrLessEquals(371.79, epsilon: 0.1)); + expect(box.right - textBox.right, equals(37)); + + // Try decreasing density (with higher density numbers). + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + box = tester.getRect(find.byKey(key)); + textBox = tester.getRect(find.byKey(textKey)); + iconBox = tester.getRect(find.byKey(iconKey)); + avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size.width, moreOrLessEquals(130.4, epsilon: 0.1)); + expect(box.size.height, equals(60)); + expect(textBox.size.width, moreOrLessEquals(56.4, epsilon: 0.1)); + expect(textBox.size.height, equals(20.0)); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(20)); + expect(box.bottom - textBox.bottom, equals(20)); + expect(textBox.left, moreOrLessEquals(371.79, epsilon: 0.1)); + expect(box.right - textBox.right, equals(37)); + + // Try increasing density (with lower density numbers). + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + box = tester.getRect(find.byKey(key)); + textBox = tester.getRect(find.byKey(textKey)); + iconBox = tester.getRect(find.byKey(iconKey)); + avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size.width, moreOrLessEquals(130.4, epsilon: 0.1)); + expect(box.size.height, equals(36)); + expect(textBox.size.width, moreOrLessEquals(56.4, epsilon: 0.1)); + expect(textBox.size.height, equals(20.0)); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(8)); + expect(box.bottom - textBox.bottom, equals(8)); + expect(textBox.left, moreOrLessEquals(371.79, epsilon: 0.1)); + expect(box.right - textBox.right, equals(37)); + + // Now test that horizontal and vertical are wired correctly. Negating the + // horizontal should have no change over what's above. + await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + box = tester.getRect(find.byKey(key)); + textBox = tester.getRect(find.byKey(textKey)); + iconBox = tester.getRect(find.byKey(iconKey)); + avatarBox = tester.getRect(find.byKey(avatarKey)); + expect(box.size.width, moreOrLessEquals(130.4, epsilon: 0.1)); + expect(box.size.height, equals(36)); + expect(textBox.size.width, moreOrLessEquals(56.4, epsilon: 0.1)); + expect(textBox.size.height, equals(20.0)); + expect(iconBox.size, equals(const Size(18, 18))); + expect(avatarBox.size, equals(const Size(18, 18))); + expect(textBox.top, equals(8)); + expect(box.bottom - textBox.bottom, equals(8)); + expect(textBox.left, moreOrLessEquals(371.79, epsilon: 0.1)); + expect(box.right - textBox.right, equals(37)); + + // Make sure the "Comfortable" setting is the spec'd size + await buildTest(VisualDensity.comfortable); + await tester.pumpAndSettle(); + box = tester.getRect(find.byKey(key)); + expect(box.size.width, moreOrLessEquals(130.4, epsilon: 0.1)); + expect(box.size.height, equals(28.0 + 16.0)); + + // Make sure the "Compact" setting is the spec'd size + await buildTest(VisualDensity.compact); + await tester.pumpAndSettle(); + box = tester.getRect(find.byKey(key)); + expect(box.size.width, moreOrLessEquals(130.4, epsilon: 0.1)); + expect(box.size.height, equals(24.0 + 16.0)); + }); + + testWidgets('Chip delete button tooltip is disabled if deleteButtonTooltipMessage is empty', ( + WidgetTester tester, + ) async { + final deleteButtonKey = UniqueKey(); + await tester.pumpWidget( + chipWithOptionalDeleteButton( + deleteButtonKey: deleteButtonKey, + deletable: true, + deleteButtonTooltipMessage: '', + ), + ); + + // Hover over the delete icon of the chip + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.moveTo(centerOfDeleteButton); + addTearDown(hoverGesture.removePointer); + + await tester.pump(); + + // Wait for some more time while hovering over the delete button + await tester.pumpAndSettle(); + + // There should be no delete button tooltip + expect(findTooltipContainer(''), findsNothing); + }); + + testWidgets('Disabling delete button tooltip does not disable chip tooltip', ( + WidgetTester tester, + ) async { + final deleteButtonKey = UniqueKey(); + await tester.pumpWidget( + chipWithOptionalDeleteButton( + deleteButtonKey: deleteButtonKey, + deletable: true, + deleteButtonTooltipMessage: '', + chipTooltip: 'Chip Tooltip', + ), + ); + + // Hover over the delete icon of the chip + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.moveTo(centerOfDeleteButton); + addTearDown(hoverGesture.removePointer); + + await tester.pump(); + + // Wait for some more time while hovering over the delete button + await tester.pumpAndSettle(); + + // There should be no delete button tooltip + expect(findTooltipContainer(''), findsNothing); + // There should be a chip tooltip, however. + expect(findTooltipContainer('Chip Tooltip'), findsOneWidget); + }); + + testWidgets('Triggering delete button tooltip does not trigger Chip tooltip', ( + WidgetTester tester, + ) async { + final deleteButtonKey = UniqueKey(); + await tester.pumpWidget( + chipWithOptionalDeleteButton( + deleteButtonKey: deleteButtonKey, + deletable: true, + chipTooltip: 'Chip Tooltip', + ), + ); + + // Hover over the delete icon of the chip + final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey)); + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.moveTo(centerOfDeleteButton); + addTearDown(hoverGesture.removePointer); + + await tester.pump(); + + // Wait for some more time while hovering over the delete button + await tester.pumpAndSettle(); + + // There should not be a chip tooltip + expect(findTooltipContainer('Chip Tooltip'), findsNothing); + // There should be a delete button tooltip + expect(findTooltipContainer('Delete'), findsOneWidget); + }); + + testWidgets('intrinsicHeight implementation meets constraints', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/49478. + await tester.pumpWidget( + wrapForChip( + child: const Chip(label: Text('text'), padding: EdgeInsets.symmetric(horizontal: 20)), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Material2 - Chip background color and shape are drawn on Ink', ( + WidgetTester tester, + ) async { + const backgroundColor = Color(0xff00ff00); + const OutlinedBorder shape = ContinuousRectangleBorder(); + + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(useMaterial3: false), + child: const RawChip(label: Text('text'), backgroundColor: backgroundColor, shape: shape), + ), + ); + + final Ink ink = tester.widget( + find.descendant(of: find.byType(RawChip), matching: find.byType(Ink)), + ); + final decoration = ink.decoration! as ShapeDecoration; + expect(decoration.color, backgroundColor); + expect(decoration.shape, shape); + }); + + testWidgets('Material3 - Chip background color and shape are drawn on Ink', ( + WidgetTester tester, + ) async { + const backgroundColor = Color(0xff00ff00); + const OutlinedBorder shape = ContinuousRectangleBorder(); + final theme = ThemeData(); + + await tester.pumpWidget( + wrapForChip( + theme: theme, + child: const RawChip(label: Text('text'), backgroundColor: backgroundColor, shape: shape), + ), + ); + + final Ink ink = tester.widget( + find.descendant(of: find.byType(RawChip), matching: find.byType(Ink)), + ); + final decoration = ink.decoration! as ShapeDecoration; + expect(decoration.color, backgroundColor); + expect( + decoration.shape, + shape.copyWith(side: BorderSide(color: theme.colorScheme.outlineVariant)), + ); + }); + + testWidgets('Chip highlight color is drawn on top of the backgroundColor', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'RawChip'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const backgroundColor = Color(0xff00ff00); + + await tester.pumpWidget( + wrapForChip( + child: RawChip( + label: const Text('text'), + backgroundColor: backgroundColor, + autofocus: true, + focusNode: focusNode, + onPressed: () {}, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(Material).last, + paints + // Background color is drawn first. + ..rrect(color: backgroundColor) + // Highlight color is drawn on top of the background color. + ..rect(color: const Color(0x1f000000)), + ); + }); + + testWidgets('RawChip.color resolves material states', (WidgetTester tester) async { + const disabledSelectedColor = Color(0xffffff00); + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: RawChip( + isEnabled: enabled, + selected: selected, + color: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return disabledSelectedColor; + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return backgroundColor; + }), + label: const Text('RawChip'), + ), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled chip should have the provided backgroundColor. + expect(getMaterialBox(tester), paints..rrect(color: backgroundColor)); + + // Test disabled chip. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled chip should have the provided disabledColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledColor)); + + // Test enabled & selected chip. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected chip should have the provided selectedColor. + expect(getMaterialBox(tester), paints..rrect(color: selectedColor)); + + // Test disabled & selected chip. + await tester.pumpWidget(buildApp(enabled: false, selected: true)); + await tester.pumpAndSettle(); + + // Disabled & selected chip should have the provided disabledSelectedColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledSelectedColor)); + }); + + testWidgets('RawChip uses provided state color properties', (WidgetTester tester) async { + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: RawChip( + isEnabled: enabled, + selected: selected, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + label: const Text('RawChip'), + ), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled chip should have the provided backgroundColor. + expect(getMaterialBox(tester), paints..rrect(color: backgroundColor)); + + // Test disabled chip. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled chip should have the provided disabledColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledColor)); + + // Test enabled & selected chip. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected chip should have the provided selectedColor. + expect(getMaterialBox(tester), paints..rrect(color: selectedColor)); + }); + + testWidgets('Delete button tap target area does not include label', (WidgetTester tester) async { + var calledDelete = false; + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + label: const Text('Chip'), + onDeleted: () { + calledDelete = true; + }, + ), + ], + ), + ), + ); + + // Tap on the delete button. + await tester.tapAt(tester.getCenter(find.byType(Icon))); + await tester.pump(); + expect(calledDelete, isTrue); + calledDelete = false; + + final Offset labelCenter = tester.getCenter(find.text('Chip')); + + // Tap on the label. + await tester.tapAt(labelCenter); + await tester.pump(); + expect(calledDelete, isFalse); + + // Tap before end of the label. + final Size labelSize = tester.getSize(find.text('Chip')); + await tester.tapAt(Offset(labelCenter.dx + (labelSize.width / 2) - 1, labelCenter.dy)); + await tester.pump(); + expect(calledDelete, isFalse); + + // Tap after end of the label. + await tester.tapAt(Offset(labelCenter.dx + (labelSize.width / 2) + 0.01, labelCenter.dy)); + await tester.pump(); + expect(calledDelete, isTrue); + }); + + // This is a regression test for https://github.com/flutter/flutter/pull/133615. + testWidgets('Material3 - Custom shape without provided side uses default side', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: Center( + child: RawChip( + // No side provided. + shape: StadiumBorder(), + label: Text('RawChip'), + ), + ), + ), + ), + ); + + // Chip should have the default side. + expect( + getMaterial(tester).shape, + StadiumBorder(side: BorderSide(color: theme.colorScheme.outlineVariant)), + ); + }); + + testWidgets("Material3 - RawChip.shape's side is used when provided", ( + WidgetTester tester, + ) async { + Widget buildChip({OutlinedBorder? shape, BorderSide? side}) { + return MaterialApp( + home: Material( + child: Center( + child: RawChip(shape: shape, side: side, label: const Text('RawChip')), + ), + ), + ); + } + + // Test [RawChip.shape] with a side. + await tester.pumpWidget( + buildChip( + shape: const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffff00ff)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + ), + ); + + // Chip should have the provided shape and the side from [RawChip.shape]. + expect( + getMaterial(tester).shape, + const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffff00ff)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + ); + + // Test [RawChip.shape] with a side and [RawChip.side]. + await tester.pumpWidget( + buildChip( + shape: const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffff00ff)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + side: const BorderSide(color: Color(0xfffff000)), + ), + ); + await tester.pumpAndSettle(); + + // Chip use shape from [RawChip.shape] and the side from [RawChip.side]. + // [RawChip.shape]'s side should be ignored. + expect( + getMaterial(tester).shape, + const RoundedRectangleBorder( + side: BorderSide(color: Color(0xfffff000)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + ); + }); + + testWidgets('Material3 - Chip.iconTheme respects default iconTheme.size', ( + WidgetTester tester, + ) async { + Widget buildChip({IconThemeData? iconTheme}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildChip(iconTheme: const IconThemeData(color: Color(0xff332211)))); + + // Icon should have the default chip iconSize. + expect(getIconData(tester).size, 18.0); + expect(getIconData(tester).color, const Color(0xff332211)); + + // Icon should have the provided iconSize. + await tester.pumpWidget( + buildChip(iconTheme: const IconThemeData(color: Color(0xff112233), size: 23.0)), + ); + await tester.pumpAndSettle(); + + expect(getIconData(tester).size, 23.0); + expect(getIconData(tester).color, const Color(0xff112233)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/138287. + testWidgets("Enabling and disabling Chip with Tooltip doesn't throw an exception", ( + WidgetTester tester, + ) async { + var isEnabled = true; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + RawChip( + tooltip: 'tooltip', + isEnabled: isEnabled, + onPressed: isEnabled ? () {} : null, + label: const Text('RawChip'), + ), + ElevatedButton( + onPressed: () { + setState(() { + isEnabled = !isEnabled; + }); + }, + child: Text('${isEnabled ? 'Disable' : 'Enable'} Chip'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + // Tap the elevated button to disable the chip with a tooltip. + await tester.tap(find.widgetWithText(ElevatedButton, 'Disable Chip')); + await tester.pumpAndSettle(); + + // No exception should be thrown. + expect(tester.takeException(), isNull); + + // Tap the elevated button to enable the chip with a tooltip. + await tester.tap(find.widgetWithText(ElevatedButton, 'Enable Chip')); + await tester.pumpAndSettle(); + + // No exception should be thrown. + expect(tester.takeException(), isNull); + }); + + testWidgets('Delete button is visible on disabled RawChip', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: RawChip(isEnabled: false, label: const Text('Label'), onDeleted: () {}), + ), + ); + + // Delete button should be visible. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('raw_chip.disabled.delete_button.png'), + ); + }); + + testWidgets('Delete button tooltip is not shown on disabled RawChip', ( + WidgetTester tester, + ) async { + Widget buildChip({bool enabled = true}) { + return wrapForChip( + child: RawChip(isEnabled: enabled, label: const Text('Label'), onDeleted: () {}), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildChip()); + + final Offset deleteButtonLocation = tester.getCenter(find.byType(Icon)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(deleteButtonLocation); + await tester.pump(); + + // Delete button tooltip should be visible. + expect(findTooltipContainer('Delete'), findsOneWidget); + + // Test disabled chip. + await tester.pumpWidget(buildChip(enabled: false)); + await tester.pump(); + + // Delete button tooltip should not be visible. + expect(findTooltipContainer('Delete'), findsNothing); + }); + + testWidgets('Chip avatar layout constraints can be customized', (WidgetTester tester) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? avatarBoxConstraints}) { + return wrapForChip( + child: Center( + child: Chip( + avatarBoxConstraints: avatarBoxConstraints, + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default avatar layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(Chip)).width, equals(234.0)); + expect(tester.getSize(find.byType(Chip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (labelSize.width / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (labelSize.width / 2) + labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget(buildChip(avatarBoxConstraints: const BoxConstraints.tightForFinite())); + await tester.pump(); + + expect(tester.getSize(find.byType(Chip)).width, equals(152.0)); + expect(tester.getSize(find.byType(Chip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('RawChip avatar layout constraints can be customized', (WidgetTester tester) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? avatarBoxConstraints}) { + return wrapForChip( + child: Center( + child: RawChip( + avatarBoxConstraints: avatarBoxConstraints, + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default avatar layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(RawChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(RawChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (labelSize.width / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (labelSize.width / 2) + labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget(buildChip(avatarBoxConstraints: const BoxConstraints.tightForFinite())); + await tester.pump(); + + expect(tester.getSize(find.byType(RawChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(RawChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('Chip delete icon layout constraints can be customized', (WidgetTester tester) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? deleteIconBoxConstraints}) { + return wrapForChip( + child: Center( + child: Chip( + deleteIconBoxConstraints: deleteIconBoxConstraints, + onDeleted: () {}, + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default delete icon layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(Chip)).width, equals(234.0)); + expect(tester.getSize(find.byType(Chip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + Offset chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + final Offset deleteIconCenter = tester.getCenter(find.byIcon(Icons.cancel)); + expect(chipTopRight.dx, deleteIconCenter.dx + (labelSize.width / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + Offset labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (labelSize.width / 2) - labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget( + buildChip(deleteIconBoxConstraints: const BoxConstraints.tightForFinite()), + ); + await tester.pump(); + + expect(tester.getSize(find.byType(Chip)).width, equals(152.0)); + expect(tester.getSize(find.byType(Chip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + expect(chipTopRight.dx, deleteIconCenter.dx + (iconSize / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (iconSize / 2) - labelPadding); + }); + + testWidgets('RawChip delete icon layout constraints can be customized', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? deleteIconBoxConstraints}) { + return wrapForChip( + child: Center( + child: RawChip( + deleteIconBoxConstraints: deleteIconBoxConstraints, + onDeleted: () {}, + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default delete icon layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(RawChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(RawChip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + Offset chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + final Offset deleteIconCenter = tester.getCenter(find.byIcon(Icons.cancel)); + expect(chipTopRight.dx, deleteIconCenter.dx + (labelSize.width / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + Offset labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (labelSize.width / 2) - labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget( + buildChip(deleteIconBoxConstraints: const BoxConstraints.tightForFinite()), + ); + await tester.pump(); + + expect(tester.getSize(find.byType(RawChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(RawChip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + expect(chipTopRight.dx, deleteIconCenter.dx + (iconSize / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (iconSize / 2) - labelPadding); + }); + + testWidgets('Default delete button InkWell shape', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: Center( + child: RawChip(onDeleted: () {}, label: const Text('RawChip')), + ), + ), + ); + + final InkWell deleteButtonInkWell = tester.widget<InkWell>( + find.ancestor(of: find.byIcon(Icons.cancel), matching: find.byType(InkWell).last), + ); + expect(deleteButtonInkWell.customBorder, const CircleBorder()); + }); + + testWidgets('Default delete button overlay', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + wrapForChip( + child: Center( + child: RawChip(onDeleted: () {}, label: const Text('RawChip')), + ), + theme: theme, + ), + ); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, isNot(paints..rect(color: theme.hoverColor))); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 0)); + + // Hover over the delete icon. + final Offset centerOfDeleteButton = tester.getCenter(find.byType(Icon)); + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.moveTo(centerOfDeleteButton); + addTearDown(hoverGesture.removePointer); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..rect(color: theme.hoverColor)); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 1)); + + const expectedClipRect = Rect.fromLTRB(124.7, 10.0, 142.7, 28.0); + final expectedClipPath = Path()..addRect(expectedClipRect); + expect( + inkFeatures, + paints..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(48.0), + sampleSize: 100, + ), + ), + ); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('M2 Chip defaults', (WidgetTester tester) async { + late TextTheme textTheme; + + Widget buildFrame(Brightness brightness) { + return MaterialApp( + theme: ThemeData(brightness: brightness, useMaterial3: false), + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + textTheme = Theme.of(context).textTheme; + return Chip( + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onDeleted: () {}, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Brightness.light)); + expect( + getMaterialBox(tester), + paints + ..rrect() + ..circle(color: const Color(0xff1976d2)), + ); + expect(tester.getSize(find.byType(Chip)), const Size(156.0, 48.0)); + expect(getMaterial(tester).color, null); + expect(getMaterial(tester).elevation, 0); + expect(getMaterial(tester).shape, const StadiumBorder()); + expect(getIconData(tester).color, const Color(0xdd000000)); + expect(getIconData(tester).opacity, null); + expect(getIconData(tester).size, 18.0); + + TextStyle labelStyle = getLabelStyle(tester, 'Chip A').style; + expect(labelStyle.color?.value, 0xde000000); + expect(labelStyle.fontFamily, textTheme.bodyLarge?.fontFamily); + expect(labelStyle.fontFamilyFallback, textTheme.bodyLarge?.fontFamilyFallback); + expect(labelStyle.fontFeatures, textTheme.bodyLarge?.fontFeatures); + expect(labelStyle.fontSize, textTheme.bodyLarge?.fontSize); + expect(labelStyle.fontStyle, textTheme.bodyLarge?.fontStyle); + expect(labelStyle.fontWeight, textTheme.bodyLarge?.fontWeight); + expect(labelStyle.height, textTheme.bodyLarge?.height); + expect(labelStyle.inherit, textTheme.bodyLarge?.inherit); + expect(labelStyle.leadingDistribution, textTheme.bodyLarge?.leadingDistribution); + expect(labelStyle.letterSpacing, textTheme.bodyLarge?.letterSpacing); + expect(labelStyle.overflow, textTheme.bodyLarge?.overflow); + expect(labelStyle.textBaseline, textTheme.bodyLarge?.textBaseline); + expect(labelStyle.wordSpacing, textTheme.bodyLarge?.wordSpacing); + + await tester.pumpWidget(buildFrame(Brightness.dark)); + await tester.pumpAndSettle(); // Theme transition animation + expect(getMaterialBox(tester), paints..rrect(color: const Color(0x1fffffff))); + expect(tester.getSize(find.byType(Chip)), const Size(156.0, 48.0)); + expect(getMaterial(tester).color, null); + expect(getMaterial(tester).elevation, 0); + expect(getMaterial(tester).shape, const StadiumBorder()); + expect(getIconData(tester).color?.value, 0xffffffff); + expect(getIconData(tester).opacity, null); + expect(getIconData(tester).size, 18.0); + + labelStyle = getLabelStyle(tester, 'Chip A').style; + expect(labelStyle.color?.value, 0xdeffffff); + expect(labelStyle.fontFamily, textTheme.bodyLarge?.fontFamily); + expect(labelStyle.fontFamilyFallback, textTheme.bodyLarge?.fontFamilyFallback); + expect(labelStyle.fontFeatures, textTheme.bodyLarge?.fontFeatures); + expect(labelStyle.fontSize, textTheme.bodyLarge?.fontSize); + expect(labelStyle.fontStyle, textTheme.bodyLarge?.fontStyle); + expect(labelStyle.fontWeight, textTheme.bodyLarge?.fontWeight); + expect(labelStyle.height, textTheme.bodyLarge?.height); + expect(labelStyle.inherit, textTheme.bodyLarge?.inherit); + expect(labelStyle.leadingDistribution, textTheme.bodyLarge?.leadingDistribution); + expect(labelStyle.letterSpacing, textTheme.bodyLarge?.letterSpacing); + expect(labelStyle.overflow, textTheme.bodyLarge?.overflow); + expect(labelStyle.textBaseline, textTheme.bodyLarge?.textBaseline); + expect(labelStyle.wordSpacing, textTheme.bodyLarge?.wordSpacing); + }); + + testWidgets('Chip uses the right theme colors for the right components', ( + WidgetTester tester, + ) async { + final themeData = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + useMaterial3: false, + ); + final defaultChipTheme = ChipThemeData.fromDefaults( + brightness: themeData.brightness, + secondaryColor: Colors.blue, + labelStyle: themeData.textTheme.bodyLarge!, + ); + var value = false; + Widget buildApp({ + ChipThemeData? chipTheme, + Widget? avatar, + Widget? deleteIcon, + bool isSelectable = true, + bool isPressable = false, + bool isDeletable = true, + bool showCheckmark = true, + }) { + chipTheme ??= defaultChipTheme; + return wrapForChip( + child: Theme( + data: themeData, + child: ChipTheme( + data: chipTheme, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RawChip( + showCheckmark: showCheckmark, + onDeleted: isDeletable ? () {} : null, + avatar: avatar, + deleteIcon: deleteIcon, + isEnabled: isSelectable || isPressable, + shape: chipTheme?.shape, + selected: isSelectable && value, + label: Text('$value'), + onSelected: isSelectable + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + onPressed: isPressable + ? () { + setState(() { + value = true; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + RenderBox materialBox = getMaterialBox(tester); + IconThemeData iconData = getIconData(tester); + DefaultTextStyle labelStyle = getLabelStyle(tester, 'false'); + + // Check default theme for enabled chip. + expect(materialBox, paints..rrect(color: defaultChipTheme.backgroundColor)); + expect(iconData.color, equals(const Color(0xde000000))); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + + // Check default theme for disabled chip. + await tester.pumpWidget(buildApp(isSelectable: false)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + labelStyle = getLabelStyle(tester, 'false'); + expect(materialBox, paints..rrect(color: defaultChipTheme.disabledColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + + // Check default theme for enabled and selected chip. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + expect(materialBox, paints..rrect(color: defaultChipTheme.selectedColor)); + + // Check default theme for disabled and selected chip. + await tester.pumpWidget(buildApp(isSelectable: false)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + labelStyle = getLabelStyle(tester, 'true'); + expect(materialBox, paints..rrect(color: defaultChipTheme.disabledColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + + // Enable the chip again. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + // Tap to unselect the chip. + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + + // Apply a custom theme. + const customColor1 = Color(0xcafefeed); + const customColor2 = Color(0xdeadbeef); + const customColor3 = Color(0xbeefcafe); + const customColor4 = Color(0xaddedabe); + final ChipThemeData customTheme = defaultChipTheme.copyWith( + brightness: Brightness.dark, + backgroundColor: customColor1, + disabledColor: customColor2, + selectedColor: customColor3, + deleteIconColor: customColor4, + ); + await tester.pumpWidget(buildApp(chipTheme: customTheme)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + iconData = getIconData(tester); + labelStyle = getLabelStyle(tester, 'false'); + + // Check custom theme for enabled chip. + expect(materialBox, paints..rrect(color: customTheme.backgroundColor)); + expect(iconData.color, equals(customTheme.deleteIconColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + + // Check custom theme with disabled widget. + await tester.pumpWidget(buildApp(chipTheme: customTheme, isSelectable: false)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + labelStyle = getLabelStyle(tester, 'false'); + expect(materialBox, paints..rrect(color: customTheme.disabledColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + + // Check custom theme for enabled and selected chip. + await tester.pumpWidget(buildApp(chipTheme: customTheme)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(RawChip)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + expect(materialBox, paints..rrect(color: customTheme.selectedColor)); + + // Check custom theme for disabled and selected chip. + await tester.pumpWidget(buildApp(chipTheme: customTheme, isSelectable: false)); + await tester.pumpAndSettle(); + materialBox = getMaterialBox(tester); + labelStyle = getLabelStyle(tester, 'true'); + expect(materialBox, paints..rrect(color: customTheme.disabledColor)); + expect(labelStyle.style.color, equals(Colors.black.withAlpha(0xde))); + }); + }); + + testWidgets('Chip Baseline location', (WidgetTester tester) async { + const text = Text('A', style: TextStyle(fontSize: 10.0, height: 1.0)); + await tester.pumpWidget( + wrapForChip( + child: const Align( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + text, + RawChip(label: text), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsNWidgets(2)); + // Baseline aligning text. + expect(tester.getTopLeft(find.text('A').first).dy, tester.getTopLeft(find.text('A').last).dy); + }); + + testWidgets('ChipThemeData.iconTheme updates avatar and delete icons', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xffff00ff); + const iconSize = 28.0; + const IconData avatarIcon = Icons.favorite; + const IconData deleteIcon = Icons.delete; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: RawChip( + iconTheme: const IconThemeData(color: iconColor, size: iconSize), + avatar: const Icon(Icons.favorite), + deleteIcon: const Icon(Icons.delete), + onDeleted: () {}, + label: const SizedBox(height: 100), + ), + ), + ), + ), + ); + + // Test rendered icon size. + final RenderBox avatarIconBox = tester.renderObject(find.byIcon(avatarIcon)); + final RenderBox deleteIconBox = tester.renderObject(find.byIcon(deleteIcon)); + expect(avatarIconBox.size.width, equals(iconSize)); + expect(deleteIconBox.size.width, equals(iconSize)); + + // Test rendered icon color. + expect(getIconStyle(tester, avatarIcon)?.color, iconColor); + expect(getIconStyle(tester, deleteIcon)?.color, iconColor); + }); + + testWidgets('RawChip.deleteIconColor overrides iconTheme color', (WidgetTester tester) async { + const iconColor = Color(0xffff00ff); + const deleteIconColor = Color(0xffff00ff); + const IconData deleteIcon = Icons.delete; + + Widget buildChip({Color? deleteIconColor, Color? iconColor}) { + return MaterialApp( + home: Material( + child: Center( + child: RawChip( + deleteIconColor: deleteIconColor, + iconTheme: IconThemeData(color: iconColor), + deleteIcon: const Icon(Icons.delete), + onDeleted: () {}, + label: const SizedBox(height: 100), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildChip(iconColor: iconColor)); + + // Test rendered icon color. + expect(getIconStyle(tester, deleteIcon)?.color, iconColor); + + await tester.pumpWidget(buildChip(deleteIconColor: deleteIconColor, iconColor: iconColor)); + + // Test rendered icon color. + expect(getIconStyle(tester, deleteIcon)?.color, deleteIconColor); + }); + + testWidgets('Chip label only does layout once', (WidgetTester tester) async { + final renderLayoutCount = RenderLayoutCount(); + final Widget layoutCounter = Center( + key: GlobalKey(), + child: WidgetToRenderBoxAdapter(renderBox: renderLayoutCount), + ); + + await tester.pumpWidget(wrapForChip(child: RawChip(label: layoutCounter))); + + expect(renderLayoutCount.layoutCount, 1); + }); + + testWidgets('ChipAnimationStyle.enableAnimation overrides chip enable animation duration', ( + WidgetTester tester, + ) async { + const disabledColor = Color(0xffff0000); + const backgroundColor = Color(0xff00ff00); + var enabled = true; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + RawChip( + chipAnimationStyle: ChipAnimationStyle( + enableAnimation: const AnimationStyle( + duration: Duration(milliseconds: 300), + reverseDuration: Duration(milliseconds: 150), + ), + ), + isEnabled: enabled, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + label: const Text('RawChip'), + ), + ElevatedButton( + onPressed: () { + setState(() { + enabled = !enabled; + }); + }, + child: Text('${enabled ? 'Disable' : 'Enable'} Chip'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + final RenderBox materialBox = tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(RawChip), matching: find.byType(CustomPaint)), + ); + + // Test background color when the chip is enabled. + expect(materialBox, paints..rrect(color: backgroundColor)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Disable Chip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 75)); + + expect(materialBox, paints..rrect(color: const Color(0x80ff0000))); + + await tester.pump(const Duration(milliseconds: 75)); + + // Test background color when the chip is disabled. + expect(materialBox, paints..rrect(color: disabledColor)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Enable Chip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(materialBox, paints..rrect(color: const Color(0x8000ff00))); + + await tester.pump(const Duration(milliseconds: 150)); + + // Test background color when the chip is enabled. + expect(materialBox, paints..rrect(color: backgroundColor)); + }); + + testWidgets('ChipAnimationStyle.selectAnimation overrides chip selection animation duration', ( + WidgetTester tester, + ) async { + const backgroundColor = Color(0xff00ff00); + const selectedColor = Color(0xff0000ff); + var selected = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + RawChip( + chipAnimationStyle: ChipAnimationStyle( + selectAnimation: const AnimationStyle( + duration: Duration(milliseconds: 600), + reverseDuration: Duration(milliseconds: 300), + ), + ), + backgroundColor: backgroundColor, + selectedColor: selectedColor, + selected: selected, + onSelected: (bool value) {}, + label: const Text('RawChip'), + ), + ElevatedButton( + onPressed: () { + setState(() { + selected = !selected; + }); + }, + child: Text('${selected ? 'Unselect' : 'Select'} Chip'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + final RenderBox materialBox = tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(RawChip), matching: find.byType(CustomPaint)), + ); + + // Test background color when the chip is unselected. + expect(materialBox, paints..rrect(color: backgroundColor)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Select Chip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(materialBox, paints..rrect(color: const Color(0xc60000ff))); + + await tester.pump(const Duration(milliseconds: 300)); + + // Test background color when the chip is selected. + expect(materialBox, paints..rrect(color: selectedColor)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Unselect Chip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(materialBox, paints..rrect(color: const Color(0x3900ff00))); + + await tester.pump(const Duration(milliseconds: 150)); + + // Test background color when the chip is unselected. + expect(materialBox, paints..rrect(color: backgroundColor)); + }); + + testWidgets('ChipAnimationStyle.avatarDrawerAnimation overrides chip avatar animation duration', ( + WidgetTester tester, + ) async { + const checkmarkColor = Color(0xffff0000); + var selected = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + RawChip( + chipAnimationStyle: ChipAnimationStyle( + avatarDrawerAnimation: const AnimationStyle( + duration: Duration(milliseconds: 800), + reverseDuration: Duration(milliseconds: 400), + ), + ), + checkmarkColor: checkmarkColor, + selected: selected, + onSelected: (bool value) {}, + label: const Text('RawChip'), + ), + ElevatedButton( + onPressed: () { + setState(() { + selected = !selected; + }); + }, + child: Text('${selected ? 'Unselect' : 'Select'} Chip'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + final RenderBox materialBox = tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(RawChip), matching: find.byType(CustomPaint)), + ); + + // Test the checkmark is not visible yet. + expect(materialBox, isNot(paints..path(color: checkmarkColor))); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(132.6, 0.1)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Select Chip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(materialBox, paints..path(color: checkmarkColor)); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(148.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 400)); + + // Test the checkmark is fully visible. + expect(materialBox, paints..path(color: checkmarkColor)); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(152.6, 0.1)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Unselect Chip')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(materialBox, isNot(paints..path(color: checkmarkColor))); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(148.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 200)); + + // Test if checkmark is removed. + expect(materialBox, isNot(paints..path(color: checkmarkColor))); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(132.6, 0.1)); + }); + + testWidgets( + 'ChipAnimationStyle.deleteDrawerAnimation overrides chip delete icon animation duration', + (WidgetTester tester) async { + var showDeleteIcon = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + RawChip( + chipAnimationStyle: ChipAnimationStyle( + deleteDrawerAnimation: const AnimationStyle( + duration: Duration(milliseconds: 500), + reverseDuration: Duration(milliseconds: 250), + ), + ), + onDeleted: showDeleteIcon ? () {} : null, + label: const Text('RawChip'), + ), + ElevatedButton( + onPressed: () { + setState(() { + showDeleteIcon = !showDeleteIcon; + }); + }, + child: Text('${showDeleteIcon ? 'Hide' : 'Show'} delete icon'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + // Test the delete icon is not visible yet. + expect(find.byIcon(Icons.cancel), findsNothing); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(132.6, 0.1)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Show delete icon')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + expect(find.byIcon(Icons.cancel), findsOneWidget); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(148.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 250)); + + // Test the delete icon is fully visible. + expect(find.byIcon(Icons.cancel), findsOneWidget); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(152.6, 0.1)); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Hide delete icon')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); + + expect(find.byIcon(Icons.cancel), findsOneWidget); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(148.2, 0.1)); + + await tester.pump(const Duration(milliseconds: 125)); + + // Test if delete icon is removed. + expect(find.byIcon(Icons.cancel), findsNothing); + expect(tester.getSize(find.byType(RawChip)).width, closeTo(132.6, 0.1)); + }, + ); + + testWidgets('Chip.chipAnimationStyle is passed to RawChip', (WidgetTester tester) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: AnimationStyle.noAnimation, + selectAnimation: const AnimationStyle(duration: Durations.long3), + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: Chip(chipAnimationStyle: chipAnimationStyle, label: const Text('Chip')), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + // Regression test for https://github.com/flutter/flutter/issues/157622. + testWidgets('Chip does not glitch on hover when providing ThemeData.hoverColor', ( + WidgetTester tester, + ) async { + const themeDataHoverColor = Color(0xffff0000); + const hoverColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + await tester.pumpWidget( + wrapForChip( + theme: ThemeData(hoverColor: themeDataHoverColor), + child: Center( + child: RawChip( + color: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + return backgroundColor; + }), + label: const Text('Chip'), + onPressed: () {}, + ), + ), + ), + ); + + expect(getMaterialBox(tester), paints..rrect(color: backgroundColor)); + + // Hover over the chip. + final Offset center = tester.getCenter(find.byType(RawChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + + expect( + getMaterialBox(tester), + paints + ..rrect(color: hoverColor) + ..rect(color: Colors.transparent), + ); + expect( + getMaterialBox(tester), + isNot( + paints + ..rrect(color: hoverColor) + ..rect(color: themeDataHoverColor), + ), + ); + }); + + testWidgets('Chip mouse cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + wrapForChip( + child: const Center( + child: Chip(mouseCursor: customCursor, label: Text('Chip')), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('Mouse cursor resolves in disabled states', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + await tester.pumpWidget( + wrapForChip( + child: const Center( + child: Chip( + mouseCursor: WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + }), + label: Text('Chip'), + ), + ), + ), + ); + // Unfocused case. + final TestGesture gesture1 = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture1.removePointer); + await gesture1.addPointer(location: tester.getCenter(find.text('Chip'))); + await tester.pump(); + await gesture1.moveTo(tester.getCenter(find.text('Chip'))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('Delete button semantic tap target complies with Material guideline', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final deleteKey = UniqueKey(); + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + label: const Text('Label'), + deleteIcon: Icon(Icons.delete, key: deleteKey), + onDeleted: () {}, + ), + ], + ), + ), + ); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + + final Finder deleteIcon = find.byKey(deleteKey); + final Size iconSize = tester.getSize(deleteIcon); + final Rect semanticRect = tester.getSemantics(deleteIcon).rect; + + // Semantic rect is centered around the icon. + expect(semanticRect.center, Offset(iconSize.width / 2, iconSize.height / 2)); + + handle.dispose(); + }); + + testWidgets('Delete button semantic tap target is 32x32 on desktop', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final deleteKey = UniqueKey(); + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + label: const Text('Label'), + deleteIcon: Icon(Icons.delete, key: deleteKey), + onDeleted: () {}, + ), + ], + ), + ), + ); + + final Finder deleteIcon = find.byKey(deleteKey); + final Size iconSize = tester.getSize(deleteIcon); + final Rect semanticRect = tester.getSemantics(deleteIcon).rect; + + expect(semanticRect.size, const Size(32.0, 32.0)); + + // Semantic rect is centered around the icon. + expect(semanticRect.center, Offset(iconSize.width / 2, iconSize.height / 2)); + + handle.dispose(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'Delete button semantic tap target can be larger than the minimum interactive dimension', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final deleteKey = UniqueKey(); + const double iconHeight = 60; + await tester.pumpWidget( + wrapForChip( + child: Column( + children: <Widget>[ + Chip( + label: const Text('Label', style: TextStyle(fontSize: iconHeight)), + deleteIcon: Icon(Icons.delete, key: deleteKey, size: iconHeight), + onDeleted: () {}, + ), + ], + ), + ), + ); + + final Finder deleteIcon = find.byKey(deleteKey); + final Size iconSize = tester.getSize(deleteIcon); + final Rect semanticRect = tester.getSemantics(deleteIcon).rect; + + expect(semanticRect.size, iconSize); + + handle.dispose(); + }, + ); + + testWidgets('Chip renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: Chip(label: Text('X'))), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); + + testWidgets('RawChip renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: RawChip(label: Text('X'))), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); +} + +class _TestWidgetStateOutlinedBorder extends StadiumBorder implements WidgetStateOutlinedBorder { + const _TestWidgetStateOutlinedBorder(this.resolver); + + final WidgetPropertyResolver<OutlinedBorder?> resolver; + + @override + OutlinedBorder? resolve(Set<WidgetState> states) => resolver(states); +} + +class _TestWidgetStateBorderSide extends WidgetStateBorderSide { + const _TestWidgetStateBorderSide(this.resolver); + + final WidgetPropertyResolver<BorderSide?> resolver; + + @override + BorderSide? resolve(Set<WidgetState> states) => resolver(states); +} + +class RenderLayoutCount extends RenderBox { + int layoutCount = 0; + + @override + Size computeDryLayout(covariant BoxConstraints constraints) => constraints.biggest; + + @override + void performLayout() { + layoutCount += 1; + size = constraints.biggest; + } +} diff --git a/packages/material_ui/test/material/chip_theme_test.dart b/packages/material_ui/test/material/chip_theme_test.dart new file mode 100644 index 000000000000..8fb07a826870 --- /dev/null +++ b/packages/material_ui/test/material/chip_theme_test.dart @@ -0,0 +1,1543 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +RenderBox getMaterialBox(WidgetTester tester) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(RawChip), matching: find.byType(CustomPaint)), + ); +} + +Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(RawChip), matching: find.byType(Material)), + ); +} + +IconThemeData getIconData(WidgetTester tester) { + final IconTheme iconTheme = tester.firstWidget( + find.descendant(of: find.byType(RawChip), matching: find.byType(IconTheme)), + ); + return iconTheme.data; +} + +DefaultTextStyle getLabelStyle(WidgetTester tester) { + return tester.widget( + find.descendant(of: find.byType(RawChip), matching: find.byType(DefaultTextStyle)).last, + ); +} + +TextStyle? getIconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon).first, matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + +void main() { + test('ChipThemeData copyWith, ==, hashCode basics', () { + expect(const ChipThemeData(), const ChipThemeData().copyWith()); + expect(const ChipThemeData().hashCode, const ChipThemeData().copyWith().hashCode); + }); + + test('ChipThemeData lerp special cases', () { + expect(ChipThemeData.lerp(null, null, 0), null); + const data = ChipThemeData(); + expect(identical(ChipThemeData.lerp(data, data, 0.5), data), true); + }); + + test('ChipThemeData defaults', () { + const themeData = ChipThemeData(); + expect(themeData.color, null); + expect(themeData.backgroundColor, null); + expect(themeData.deleteIconColor, null); + expect(themeData.disabledColor, null); + expect(themeData.selectedColor, null); + expect(themeData.secondarySelectedColor, null); + expect(themeData.shadowColor, null); + expect(themeData.surfaceTintColor, null); + expect(themeData.selectedShadowColor, null); + expect(themeData.showCheckmark, null); + expect(themeData.checkmarkColor, null); + expect(themeData.labelPadding, null); + expect(themeData.padding, null); + expect(themeData.side, null); + expect(themeData.shape, null); + expect(themeData.labelStyle, null); + expect(themeData.secondaryLabelStyle, null); + expect(themeData.brightness, null); + expect(themeData.elevation, null); + expect(themeData.pressElevation, null); + expect(themeData.iconTheme, null); + expect(themeData.avatarBoxConstraints, null); + expect(themeData.deleteIconBoxConstraints, null); + }); + + testWidgets('Default ChipThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ChipThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ChipThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ChipThemeData( + color: MaterialStatePropertyAll<Color>(Color(0xfffffff0)), + backgroundColor: Color(0xfffffff1), + deleteIconColor: Color(0xfffffff2), + disabledColor: Color(0xfffffff3), + selectedColor: Color(0xfffffff4), + secondarySelectedColor: Color(0xfffffff5), + shadowColor: Color(0xfffffff6), + surfaceTintColor: Color(0xfffffff7), + selectedShadowColor: Color(0xfffffff8), + showCheckmark: true, + checkmarkColor: Color(0xfffffff9), + labelPadding: EdgeInsets.all(1), + padding: EdgeInsets.all(2), + side: BorderSide(width: 10), + shape: RoundedRectangleBorder(), + labelStyle: TextStyle(fontSize: 10), + secondaryLabelStyle: TextStyle(fontSize: 20), + brightness: Brightness.dark, + elevation: 5, + pressElevation: 6, + iconTheme: IconThemeData(color: Color(0xffffff10)), + avatarBoxConstraints: BoxConstraints.tightForFinite(), + deleteIconBoxConstraints: BoxConstraints.tightForFinite(), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'color: WidgetStatePropertyAll(${const Color(0xfffffff0)})', + 'backgroundColor: ${const Color(0xfffffff1)}', + 'deleteIconColor: ${const Color(0xfffffff2)}', + 'disabledColor: ${const Color(0xfffffff3)}', + 'selectedColor: ${const Color(0xfffffff4)}', + 'secondarySelectedColor: ${const Color(0xfffffff5)}', + 'shadowColor: ${const Color(0xfffffff6)}', + 'surfaceTintColor: ${const Color(0xfffffff7)}', + 'selectedShadowColor: ${const Color(0xfffffff8)}', + 'showCheckmark: true', + 'checkMarkColor: ${const Color(0xfffffff9)}', + 'labelPadding: EdgeInsets.all(1.0)', + 'padding: EdgeInsets.all(2.0)', + 'side: BorderSide(width: 10.0)', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + 'labelStyle: TextStyle(inherit: true, size: 10.0)', + 'secondaryLabelStyle: TextStyle(inherit: true, size: 20.0)', + 'brightness: dark', + 'elevation: 5.0', + 'pressElevation: 6.0', + 'iconTheme: IconThemeData#00000(color: ${const Color(0xffffff10)})', + 'avatarBoxConstraints: BoxConstraints(unconstrained)', + 'deleteIconBoxConstraints: BoxConstraints(unconstrained)', + ]), + ); + }); + + testWidgets('Material3 - Chip uses ThemeData chip theme', (WidgetTester tester) async { + const chipTheme = ChipThemeData( + backgroundColor: Color(0xff112233), + elevation: 4, + padding: EdgeInsets.all(50), + labelPadding: EdgeInsets.all(25), + shape: RoundedRectangleBorder(), + labelStyle: TextStyle(fontSize: 32), + iconTheme: IconThemeData(color: Color(0xff332211)), + ); + final theme = ThemeData(chipTheme: chipTheme); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(chipTheme: chipTheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..rect(color: chipTheme.backgroundColor)); + expect(getMaterial(tester).elevation, chipTheme.elevation); + expect( + tester.getSize(find.byType(RawChip)), + const Size(402, 252), + ); // label + padding + labelPadding + expect( + getMaterial(tester).shape, + chipTheme.shape?.copyWith(side: BorderSide(color: theme.colorScheme.outlineVariant)), + ); + expect(getLabelStyle(tester).style.fontSize, 32); + expect(getIconData(tester).color, chipTheme.iconTheme!.color); + }); + + testWidgets('Material2 - Chip uses ThemeData chip theme', (WidgetTester tester) async { + const chipTheme = ChipThemeData( + backgroundColor: Color(0xff112233), + elevation: 4, + padding: EdgeInsets.all(50), + labelPadding: EdgeInsets.all(25), + shape: RoundedRectangleBorder(), + labelStyle: TextStyle(fontSize: 32), + iconTheme: IconThemeData(color: Color(0xff332211)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(chipTheme: chipTheme, useMaterial3: false), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..rect(color: chipTheme.backgroundColor)); + expect(getMaterial(tester).elevation, chipTheme.elevation); + expect( + tester.getSize(find.byType(RawChip)), + const Size(400, 250), + ); // label + padding + labelPadding + expect(getMaterial(tester).shape, chipTheme.shape); + expect(getLabelStyle(tester).style.fontSize, 32); + expect(getIconData(tester).color, chipTheme.iconTheme!.color); + }); + + testWidgets('Material3 - Chip uses local ChipTheme', (WidgetTester tester) async { + const chipTheme = ChipThemeData( + backgroundColor: Color(0xff112233), + elevation: 4, + padding: EdgeInsets.all(50), + labelPadding: EdgeInsets.all(25), + labelStyle: TextStyle(fontSize: 32), + shape: RoundedRectangleBorder(), + iconTheme: IconThemeData(color: Color(0xff332211)), + ); + final theme = ThemeData(chipTheme: const ChipThemeData()); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: ChipTheme( + data: chipTheme, + child: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..rect(color: chipTheme.backgroundColor)); + expect( + tester.getSize(find.byType(RawChip)), + const Size(402, 252), + ); // label + padding + labelPadding + expect(getMaterial(tester).elevation, chipTheme.elevation); + expect( + getMaterial(tester).shape, + chipTheme.shape?.copyWith(side: BorderSide(color: theme.colorScheme.outlineVariant)), + ); + expect(getLabelStyle(tester).style.fontSize, 32); + expect(getIconData(tester).color, chipTheme.iconTheme!.color); + }); + + testWidgets('Material2 - Chip uses local ChipTheme', (WidgetTester tester) async { + const chipTheme = ChipThemeData( + backgroundColor: Color(0xff112233), + elevation: 4, + padding: EdgeInsets.all(50), + labelPadding: EdgeInsets.all(25), + labelStyle: TextStyle(fontSize: 32), + shape: RoundedRectangleBorder(), + iconTheme: IconThemeData(color: Color(0xff332211)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(chipTheme: const ChipThemeData(), useMaterial3: false), + home: ChipTheme( + data: chipTheme, + child: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..rect(color: chipTheme.backgroundColor)); + expect( + tester.getSize(find.byType(RawChip)), + const Size(400, 250), + ); // label + padding + labelPadding + expect(getMaterial(tester).elevation, chipTheme.elevation); + expect(getMaterial(tester).shape, chipTheme.shape); + expect(getLabelStyle(tester).style.fontSize, 32); + expect(getIconData(tester).color, chipTheme.iconTheme!.color); + }); + + testWidgets('Chip properties overrides ChipTheme', (WidgetTester tester) async { + const chipTheme = ChipThemeData( + backgroundColor: Color(0xff112233), + elevation: 4, + padding: EdgeInsets.all(50), + labelPadding: EdgeInsets.all(25), + labelStyle: TextStyle(fontSize: 32), + shape: RoundedRectangleBorder(), + iconTheme: IconThemeData(color: Color(0xff332211)), + ); + + const backgroundColor = Color(0xff000000); + const elevation = 6.0; + const padding = EdgeInsets.all(10); + const labelPadding = EdgeInsets.all(5); + const labelStyle = TextStyle(fontSize: 20); + const shape = RoundedRectangleBorder(side: BorderSide(color: Color(0xff0000ff))); + const iconTheme = IconThemeData(color: Color(0xff00ff00)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(chipTheme: chipTheme), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + backgroundColor: backgroundColor, + elevation: elevation, + padding: padding, + labelPadding: labelPadding, + labelStyle: labelStyle, + shape: shape, + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ); + }, + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..rect(color: backgroundColor)); + expect( + tester.getSize(find.byType(RawChip)), + const Size(242, 132), + ); // label + padding + labelPadding + expect(getMaterial(tester).elevation, elevation); + expect(getMaterial(tester).shape, shape); + expect(getLabelStyle(tester).style.fontSize, labelStyle.fontSize); + expect(getIconData(tester).color, iconTheme.color); + }); + + testWidgets('Material3 - Chip uses constructor parameters', (WidgetTester tester) async { + const backgroundColor = Color(0xff332211); + const double elevation = 3; + const double fontSize = 32; + const OutlinedBorder shape = CircleBorder(side: BorderSide(color: Color(0xff0000ff))); + const iconTheme = IconThemeData(color: Color(0xff443322)); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + backgroundColor: backgroundColor, + elevation: elevation, + padding: const EdgeInsets.all(50), + labelPadding: const EdgeInsets.all(25), + labelStyle: const TextStyle(fontSize: fontSize), + shape: shape, + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ); + }, + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..circle(color: backgroundColor)); + expect( + tester.getSize(find.byType(RawChip)), + const Size(402, 252), + ); // label + padding + labelPadding + expect(getMaterial(tester).elevation, elevation); + expect(getMaterial(tester).shape, shape); + expect(getLabelStyle(tester).style.fontSize, 32); + expect(getIconData(tester).color, iconTheme.color); + }); + + testWidgets('Material2 - Chip uses constructor parameters', (WidgetTester tester) async { + const backgroundColor = Color(0xff332211); + const double elevation = 3; + const double fontSize = 32; + const OutlinedBorder shape = CircleBorder(); + const iconTheme = IconThemeData(color: Color(0xff443322)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + backgroundColor: backgroundColor, + elevation: elevation, + padding: const EdgeInsets.all(50), + labelPadding: const EdgeInsets.all(25), + labelStyle: const TextStyle(fontSize: fontSize), + shape: shape, + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ); + }, + ), + ), + ); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..circle(color: backgroundColor)); + expect( + tester.getSize(find.byType(RawChip)), + const Size(400, 250), + ); // label + padding + labelPadding + expect(getMaterial(tester).elevation, elevation); + expect(getMaterial(tester).shape, shape); + expect(getLabelStyle(tester).style.fontSize, 32); + expect(getIconData(tester).color, iconTheme.color); + }); + + testWidgets('ChipTheme.fromDefaults', (WidgetTester tester) async { + const labelStyle = TextStyle(); + var chipTheme = ChipThemeData.fromDefaults( + brightness: Brightness.light, + secondaryColor: Colors.red, + labelStyle: labelStyle, + ); + expect(chipTheme.backgroundColor, Colors.black.withAlpha(0x1f)); + expect(chipTheme.deleteIconColor, Colors.black.withAlpha(0xde)); + expect(chipTheme.disabledColor, Colors.black.withAlpha(0x0c)); + expect(chipTheme.selectedColor, Colors.black.withAlpha(0x3d)); + expect(chipTheme.secondarySelectedColor, Colors.red.withAlpha(0x3d)); + expect(chipTheme.shadowColor, Colors.black); + expect(chipTheme.surfaceTintColor, null); + expect(chipTheme.selectedShadowColor, Colors.black); + expect(chipTheme.showCheckmark, true); + expect(chipTheme.checkmarkColor, null); + expect(chipTheme.labelPadding, null); + expect(chipTheme.padding, const EdgeInsets.all(4.0)); + expect(chipTheme.side, null); + expect(chipTheme.shape, null); + expect(chipTheme.labelStyle, labelStyle.copyWith(color: Colors.black.withAlpha(0xde))); + expect(chipTheme.secondaryLabelStyle, labelStyle.copyWith(color: Colors.red.withAlpha(0xde))); + expect(chipTheme.brightness, Brightness.light); + expect(chipTheme.elevation, 0.0); + expect(chipTheme.pressElevation, 8.0); + + chipTheme = ChipThemeData.fromDefaults( + brightness: Brightness.dark, + secondaryColor: Colors.tealAccent[200]!, + labelStyle: const TextStyle(), + ); + expect(chipTheme.backgroundColor, Colors.white.withAlpha(0x1f)); + expect(chipTheme.deleteIconColor, Colors.white.withAlpha(0xde)); + expect(chipTheme.disabledColor, Colors.white.withAlpha(0x0c)); + expect(chipTheme.selectedColor, Colors.white.withAlpha(0x3d)); + expect(chipTheme.secondarySelectedColor, Colors.tealAccent[200]!.withAlpha(0x3d)); + expect(chipTheme.shadowColor, Colors.black); + expect(chipTheme.selectedShadowColor, Colors.black); + expect(chipTheme.showCheckmark, true); + expect(chipTheme.checkmarkColor, null); + expect(chipTheme.labelPadding, null); + expect(chipTheme.padding, const EdgeInsets.all(4.0)); + expect(chipTheme.side, null); + expect(chipTheme.shape, null); + expect(chipTheme.labelStyle, labelStyle.copyWith(color: Colors.white.withAlpha(0xde))); + expect( + chipTheme.secondaryLabelStyle, + labelStyle.copyWith(color: Colors.tealAccent[200]!.withAlpha(0xde)), + ); + expect(chipTheme.brightness, Brightness.dark); + expect(chipTheme.elevation, 0.0); + expect(chipTheme.pressElevation, 8.0); + }); + + testWidgets('ChipThemeData generates correct opacities for defaults', ( + WidgetTester tester, + ) async { + const customColor1 = Color(0xcafefeed); + const customColor2 = Color(0xdeadbeef); + final TextStyle customStyle = ThemeData.fallback().textTheme.bodyLarge!.copyWith( + color: customColor2, + ); + + final lightTheme = ChipThemeData.fromDefaults( + secondaryColor: customColor1, + brightness: Brightness.light, + labelStyle: customStyle, + ); + + expect(lightTheme.backgroundColor, equals(Colors.black.withAlpha(0x1f))); + expect(lightTheme.deleteIconColor, equals(Colors.black.withAlpha(0xde))); + expect(lightTheme.disabledColor, equals(Colors.black.withAlpha(0x0c))); + expect(lightTheme.selectedColor, equals(Colors.black.withAlpha(0x3d))); + expect(lightTheme.secondarySelectedColor, equals(customColor1.withAlpha(0x3d))); + expect(lightTheme.labelPadding, isNull); + expect(lightTheme.padding, equals(const EdgeInsets.all(4.0))); + expect(lightTheme.side, isNull); + expect(lightTheme.shape, isNull); + expect(lightTheme.labelStyle?.color, equals(Colors.black.withAlpha(0xde))); + expect(lightTheme.secondaryLabelStyle?.color, equals(customColor1.withAlpha(0xde))); + expect(lightTheme.brightness, equals(Brightness.light)); + + final darkTheme = ChipThemeData.fromDefaults( + secondaryColor: customColor1, + brightness: Brightness.dark, + labelStyle: customStyle, + ); + + expect(darkTheme.backgroundColor, equals(Colors.white.withAlpha(0x1f))); + expect(darkTheme.deleteIconColor, equals(Colors.white.withAlpha(0xde))); + expect(darkTheme.disabledColor, equals(Colors.white.withAlpha(0x0c))); + expect(darkTheme.selectedColor, equals(Colors.white.withAlpha(0x3d))); + expect(darkTheme.secondarySelectedColor, equals(customColor1.withAlpha(0x3d))); + expect(darkTheme.labelPadding, isNull); + expect(darkTheme.padding, equals(const EdgeInsets.all(4.0))); + expect(darkTheme.side, isNull); + expect(darkTheme.shape, isNull); + expect(darkTheme.labelStyle?.color, equals(Colors.white.withAlpha(0xde))); + expect(darkTheme.secondaryLabelStyle?.color, equals(customColor1.withAlpha(0xde))); + expect(darkTheme.brightness, equals(Brightness.dark)); + + final customTheme = ChipThemeData.fromDefaults( + primaryColor: customColor1, + secondaryColor: customColor2, + labelStyle: customStyle, + ); + + //expect(customTheme.backgroundColor, equals(customColor1.withAlpha(0x1f))); + expect(customTheme.deleteIconColor, equals(customColor1.withAlpha(0xde))); + expect(customTheme.disabledColor, equals(customColor1.withAlpha(0x0c))); + expect(customTheme.selectedColor, equals(customColor1.withAlpha(0x3d))); + expect(customTheme.secondarySelectedColor, equals(customColor2.withAlpha(0x3d))); + expect(customTheme.labelPadding, isNull); + expect(customTheme.padding, equals(const EdgeInsets.all(4.0))); + expect(customTheme.side, isNull); + expect(customTheme.shape, isNull); + expect(customTheme.labelStyle?.color, equals(customColor1.withAlpha(0xde))); + expect(customTheme.secondaryLabelStyle?.color, equals(customColor2.withAlpha(0xde))); + expect(customTheme.brightness, equals(Brightness.light)); + }); + + testWidgets('ChipThemeData lerps correctly', (WidgetTester tester) async { + final ChipThemeData chipThemeBlack = + ChipThemeData.fromDefaults( + secondaryColor: Colors.black, + brightness: Brightness.dark, + labelStyle: ThemeData.fallback().textTheme.bodyLarge!.copyWith(color: Colors.black), + ).copyWith( + elevation: 1.0, + labelPadding: const EdgeInsets.symmetric(horizontal: 8.0), + shape: const StadiumBorder(), + side: const BorderSide(), + pressElevation: 4.0, + shadowColor: Colors.black, + surfaceTintColor: Colors.black, + selectedShadowColor: Colors.black, + showCheckmark: false, + checkmarkColor: Colors.black, + iconTheme: const IconThemeData(size: 26.0), + ); + final ChipThemeData chipThemeWhite = + ChipThemeData.fromDefaults( + secondaryColor: Colors.white, + brightness: Brightness.light, + labelStyle: ThemeData.fallback().textTheme.bodyLarge!.copyWith(color: Colors.white), + ).copyWith( + padding: const EdgeInsets.all(2.0), + labelPadding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + shape: const BeveledRectangleBorder(), + side: const BorderSide(color: Colors.white), + elevation: 5.0, + pressElevation: 10.0, + shadowColor: Colors.white, + surfaceTintColor: Colors.white, + selectedShadowColor: Colors.white, + showCheckmark: true, + checkmarkColor: Colors.white, + iconTheme: const IconThemeData(size: 22.0), + ); + + final ChipThemeData lerp = ChipThemeData.lerp(chipThemeBlack, chipThemeWhite, 0.5)!; + const middleGrey = Color(0xff7f7f7f); + expect(lerp.backgroundColor, isSameColorAs(middleGrey.withAlpha(0x1f))); + expect(lerp.deleteIconColor, isSameColorAs(middleGrey.withAlpha(0xde))); + expect(lerp.disabledColor, isSameColorAs(middleGrey.withAlpha(0x0c))); + expect(lerp.selectedColor, isSameColorAs(middleGrey.withAlpha(0x3d))); + expect(lerp.secondarySelectedColor, isSameColorAs(middleGrey.withAlpha(0x3d))); + expect(lerp.shadowColor, isSameColorAs(middleGrey)); + expect(lerp.surfaceTintColor, isSameColorAs(middleGrey)); + expect(lerp.selectedShadowColor, isSameColorAs(middleGrey)); + expect(lerp.showCheckmark, equals(true)); + expect(lerp.labelPadding, equals(const EdgeInsets.all(4.0))); + expect(lerp.padding, equals(const EdgeInsets.all(3.0))); + expect(lerp.side!.color, isSameColorAs(middleGrey)); + expect(lerp.shape, isA<BeveledRectangleBorder>()); + expect(lerp.labelStyle?.color, isSameColorAs(middleGrey.withAlpha(0xde))); + expect(lerp.secondaryLabelStyle?.color, isSameColorAs(middleGrey.withAlpha(0xde))); + expect(lerp.brightness, equals(Brightness.light)); + expect(lerp.elevation, 3.0); + expect(lerp.pressElevation, 7.0); + expect(lerp.checkmarkColor, isSameColorAs(middleGrey)); + expect(lerp.iconTheme, const IconThemeData(size: 24.0)); + + expect(ChipThemeData.lerp(null, null, 0.25), isNull); + + final ChipThemeData lerpANull25 = ChipThemeData.lerp(null, chipThemeWhite, 0.25)!; + expect(lerpANull25.backgroundColor, isSameColorAs(Colors.black.withAlpha(0x08))); + expect(lerpANull25.deleteIconColor, isSameColorAs(Colors.black.withAlpha(0x38))); + expect(lerpANull25.disabledColor, isSameColorAs(Colors.black.withAlpha(0x03))); + expect(lerpANull25.selectedColor, isSameColorAs(Colors.black.withAlpha(0x0f))); + expect(lerpANull25.secondarySelectedColor, isSameColorAs(Colors.white.withAlpha(0x0f))); + expect(lerpANull25.shadowColor, isSameColorAs(Colors.white.withAlpha(0x40))); + expect(lerpANull25.surfaceTintColor, isSameColorAs(Colors.white.withAlpha(0x40))); + expect(lerpANull25.selectedShadowColor, isSameColorAs(Colors.white.withAlpha(0x40))); + expect(lerpANull25.showCheckmark, equals(true)); + expect(lerpANull25.labelPadding, equals(const EdgeInsets.only(top: 2.0, bottom: 2.0))); + expect(lerpANull25.padding, equals(const EdgeInsets.all(0.5))); + expect(lerpANull25.side!.color, isSameColorAs(Colors.white.withAlpha(0x3f))); + expect(lerpANull25.shape, isA<BeveledRectangleBorder>()); + expect(lerpANull25.labelStyle?.color, isSameColorAs(Colors.black.withAlpha(0x38))); + expect(lerpANull25.secondaryLabelStyle?.color, isSameColorAs(Colors.white.withAlpha(0x38))); + expect(lerpANull25.brightness, equals(Brightness.light)); + expect(lerpANull25.elevation, 1.25); + expect(lerpANull25.pressElevation, 2.5); + expect(lerpANull25.checkmarkColor, isSameColorAs(Colors.white.withAlpha(0x40))); + expect(lerpANull25.iconTheme, const IconThemeData(size: 5.5)); + + final ChipThemeData lerpANull75 = ChipThemeData.lerp(null, chipThemeWhite, 0.75)!; + expect(lerpANull75.backgroundColor, isSameColorAs(Colors.black.withAlpha(0x17))); + expect(lerpANull75.deleteIconColor, isSameColorAs(Colors.black.withAlpha(0xa7))); + expect(lerpANull75.disabledColor, isSameColorAs(Colors.black.withAlpha(0x09))); + expect(lerpANull75.selectedColor, isSameColorAs(Colors.black.withAlpha(0x2e))); + expect(lerpANull75.secondarySelectedColor, isSameColorAs(Colors.white.withAlpha(0x2e))); + expect(lerpANull75.shadowColor, isSameColorAs(Colors.white.withAlpha(0xbf))); + expect(lerpANull75.surfaceTintColor, isSameColorAs(Colors.white.withAlpha(0xbf))); + expect(lerpANull75.selectedShadowColor, isSameColorAs(Colors.white.withAlpha(0xbf))); + expect(lerpANull75.showCheckmark, equals(true)); + expect(lerpANull75.labelPadding, equals(const EdgeInsets.only(top: 6.0, bottom: 6.0))); + expect(lerpANull75.padding, equals(const EdgeInsets.all(1.5))); + expect(lerpANull75.side!.color, isSameColorAs(Colors.white.withAlpha(0xbf))); + expect(lerpANull75.shape, isA<BeveledRectangleBorder>()); + expect(lerpANull75.labelStyle?.color, isSameColorAs(Colors.black.withAlpha(0xa7))); + expect(lerpANull75.secondaryLabelStyle?.color, isSameColorAs(Colors.white.withAlpha(0xa7))); + expect(lerpANull75.brightness, equals(Brightness.light)); + expect(lerpANull75.elevation, 3.75); + expect(lerpANull75.pressElevation, 7.5); + expect(lerpANull75.checkmarkColor, isSameColorAs(Colors.white.withAlpha(0xbf))); + expect(lerpANull75.iconTheme, const IconThemeData(size: 16.5)); + + final ChipThemeData lerpBNull25 = ChipThemeData.lerp(chipThemeBlack, null, 0.25)!; + expect(lerpBNull25.backgroundColor, isSameColorAs(Colors.white.withAlpha(0x17))); + expect(lerpBNull25.deleteIconColor, isSameColorAs(Colors.white.withAlpha(0xa7))); + expect(lerpBNull25.disabledColor, isSameColorAs(Colors.white.withAlpha(0x09))); + expect(lerpBNull25.selectedColor, isSameColorAs(Colors.white.withAlpha(0x2e))); + expect(lerpBNull25.secondarySelectedColor, isSameColorAs(Colors.black.withAlpha(0x2e))); + expect(lerpBNull25.shadowColor, isSameColorAs(Colors.black.withAlpha(0xbf))); + expect(lerpBNull25.surfaceTintColor, isSameColorAs(Colors.black.withAlpha(0xbf))); + expect(lerpBNull25.selectedShadowColor, isSameColorAs(Colors.black.withAlpha(0xbf))); + expect(lerpBNull25.showCheckmark, equals(false)); + expect(lerpBNull25.labelPadding, equals(const EdgeInsets.only(left: 6.0, right: 6.0))); + expect(lerpBNull25.padding, equals(const EdgeInsets.all(3.0))); + expect(lerpBNull25.side!.color, isSameColorAs(Colors.black.withAlpha(0xbf))); + expect(lerpBNull25.shape, isA<StadiumBorder>()); + expect(lerpBNull25.labelStyle?.color, isSameColorAs(Colors.white.withAlpha(0xa7))); + expect(lerpBNull25.secondaryLabelStyle?.color, isSameColorAs(Colors.black.withAlpha(0xa7))); + expect(lerpBNull25.brightness, equals(Brightness.dark)); + expect(lerpBNull25.elevation, 0.75); + expect(lerpBNull25.pressElevation, 3.0); + expect(lerpBNull25.checkmarkColor, isSameColorAs(Colors.black.withAlpha(0xbf))); + expect(lerpBNull25.iconTheme, const IconThemeData(size: 19.5)); + + final ChipThemeData lerpBNull75 = ChipThemeData.lerp(chipThemeBlack, null, 0.75)!; + expect(lerpBNull75.backgroundColor, isSameColorAs(Colors.white.withAlpha(0x08))); + expect(lerpBNull75.deleteIconColor, isSameColorAs(Colors.white.withAlpha(0x38))); + expect(lerpBNull75.disabledColor, isSameColorAs(Colors.white.withAlpha(0x03))); + expect(lerpBNull75.selectedColor, isSameColorAs(Colors.white.withAlpha(0x0f))); + expect(lerpBNull75.secondarySelectedColor, isSameColorAs(Colors.black.withAlpha(0x0f))); + expect(lerpBNull75.shadowColor, isSameColorAs(Colors.black.withAlpha(0x40))); + expect(lerpBNull75.surfaceTintColor, isSameColorAs(Colors.black.withAlpha(0x40))); + expect(lerpBNull75.selectedShadowColor, isSameColorAs(Colors.black.withAlpha(0x40))); + expect(lerpBNull75.showCheckmark, equals(true)); + expect(lerpBNull75.labelPadding, equals(const EdgeInsets.only(left: 2.0, right: 2.0))); + expect(lerpBNull75.padding, equals(const EdgeInsets.all(1.0))); + expect(lerpBNull75.side!.color, isSameColorAs(Colors.black.withAlpha(0x3f))); + expect(lerpBNull75.shape, isA<StadiumBorder>()); + expect(lerpBNull75.labelStyle?.color, isSameColorAs(Colors.white.withAlpha(0x38))); + expect(lerpBNull75.secondaryLabelStyle?.color, isSameColorAs(Colors.black.withAlpha(0x38))); + expect(lerpBNull75.brightness, equals(Brightness.light)); + expect(lerpBNull75.elevation, 0.25); + expect(lerpBNull75.pressElevation, 1.0); + expect(lerpBNull75.checkmarkColor, isSameColorAs(Colors.black.withAlpha(0x40))); + expect(lerpBNull75.iconTheme, const IconThemeData(size: 6.5)); + }); + + testWidgets('Chip uses stateful color from chip theme', (WidgetTester tester) async { + final focusNode = FocusNode(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + const disabledColor = Color(0x00000006); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + + return defaultColor; + } + + final labelStyle = TextStyle(color: WidgetStateColor.resolveWith(getTextColor)); + Widget chipWidget({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData( + chipTheme: ThemeData().chipTheme.copyWith( + labelStyle: labelStyle, + secondaryLabelStyle: labelStyle, + ), + ), + home: Scaffold( + body: Focus( + focusNode: focusNode, + child: ChoiceChip( + label: const Text('Chip'), + selected: selected, + onSelected: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + Color textColor() { + return tester.renderObject<RenderParagraph>(find.text('Chip')).text.style!.color!; + } + + // Default, not disabled. + await tester.pumpWidget(chipWidget()); + expect(textColor(), equals(defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(textColor(), selectedColor); + + // Focused. + final FocusNode chipFocusNode = focusNode.children.first; + chipFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ChoiceChip)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(), hoverColor); + + // Pressed. + await gesture.down(center); + await tester.pumpAndSettle(); + expect(textColor(), pressedColor); + + // Disabled. + await tester.pumpWidget(chipWidget(enabled: false)); + await tester.pumpAndSettle(); + expect(textColor(), disabledColor); + + focusNode.dispose(); + }); + + testWidgets('Material2 - Chip uses stateful border side from resolveWith pattern', ( + WidgetTester tester, + ) async { + const selectedColor = Color(0x00000001); + const defaultColor = Color(0x00000002); + + BorderSide getBorderSide(Set<WidgetState> states) { + var color = defaultColor; + + if (states.contains(WidgetState.selected)) { + color = selectedColor; + } + + return BorderSide(color: color); + } + + Widget chipWidget({bool selected = false}) { + return MaterialApp( + theme: ThemeData( + useMaterial3: false, + chipTheme: ThemeData().chipTheme.copyWith( + side: WidgetStateBorderSide.resolveWith(getBorderSide), + ), + ), + home: Scaffold( + body: ChoiceChip(label: const Text('Chip'), selected: selected, onSelected: (_) {}), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: defaultColor), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: selectedColor), + ); + }); + + testWidgets('Material3 - Chip uses stateful border side from resolveWith pattern', ( + WidgetTester tester, + ) async { + const selectedColor = Color(0x00000001); + const defaultColor = Color(0x00000002); + + BorderSide getBorderSide(Set<WidgetState> states) { + var color = defaultColor; + + if (states.contains(WidgetState.selected)) { + color = selectedColor; + } + + return BorderSide(color: color); + } + + Widget chipWidget({bool selected = false}) { + return MaterialApp( + theme: ThemeData( + chipTheme: ChipThemeData(side: WidgetStateBorderSide.resolveWith(getBorderSide)), + ), + home: Scaffold( + body: ChoiceChip(label: const Text('Chip'), selected: selected, onSelected: (_) {}), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..drrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(find.byType(RawChip), paints..drrect(color: selectedColor)); + }); + + testWidgets('Material2 - Chip uses stateful border side from chip theme', ( + WidgetTester tester, + ) async { + const selectedColor = Color(0x00000001); + const defaultColor = Color(0x00000002); + + BorderSide getBorderSide(Set<WidgetState> states) { + var color = defaultColor; + if (states.contains(WidgetState.selected)) { + color = selectedColor; + } + return BorderSide(color: color); + } + + final ChipThemeData chipTheme = ChipThemeData.fromDefaults( + brightness: Brightness.light, + secondaryColor: Colors.blue, + labelStyle: const TextStyle(), + ).copyWith(side: _TestWidgetStateBorderSide(getBorderSide)); + + Widget chipWidget({bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false, chipTheme: chipTheme), + home: Scaffold( + body: ChoiceChip(label: const Text('Chip'), selected: selected, onSelected: (_) {}), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: defaultColor), + ); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect( + find.byType(RawChip), + paints + ..rrect() + ..rrect(color: selectedColor), + ); + }); + + testWidgets('Material3 - Chip uses stateful border side from chip theme', ( + WidgetTester tester, + ) async { + const selectedColor = Color(0x00000001); + const defaultColor = Color(0x00000002); + + BorderSide getBorderSide(Set<WidgetState> states) { + var color = defaultColor; + if (states.contains(WidgetState.selected)) { + color = selectedColor; + } + return BorderSide(color: color); + } + + final chipTheme = ChipThemeData(side: _TestWidgetStateBorderSide(getBorderSide)); + + Widget chipWidget({bool selected = false}) { + return MaterialApp( + theme: ThemeData(chipTheme: chipTheme), + home: Scaffold( + body: ChoiceChip(label: const Text('Chip'), selected: selected, onSelected: (_) {}), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect(find.byType(RawChip), paints..drrect(color: defaultColor)); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(find.byType(RawChip), paints..drrect(color: selectedColor)); + }); + + testWidgets('Material2 - Chip uses stateful shape from chip theme', (WidgetTester tester) async { + OutlinedBorder? getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const RoundedRectangleBorder(); + } + return null; + } + + final ChipThemeData chipTheme = ChipThemeData.fromDefaults( + brightness: Brightness.light, + secondaryColor: Colors.blue, + labelStyle: const TextStyle(), + ).copyWith(shape: _TestWidgetStateOutlinedBorder(getShape)); + + Widget chipWidget({bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false, chipTheme: chipTheme), + home: Scaffold( + body: ChoiceChip(label: const Text('Chip'), selected: selected, onSelected: (_) {}), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA<StadiumBorder>()); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + }); + + testWidgets('Material3 - Chip uses stateful shape from chip theme', (WidgetTester tester) async { + OutlinedBorder? getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const StadiumBorder(); + } + return null; + } + + final chipTheme = ChipThemeData(shape: _TestWidgetStateOutlinedBorder(getShape)); + + Widget chipWidget({bool selected = false}) { + return MaterialApp( + theme: ThemeData(chipTheme: chipTheme), + home: Scaffold( + body: ChoiceChip(label: const Text('Chip'), selected: selected, onSelected: (_) {}), + ), + ); + } + + // Default. + await tester.pumpWidget(chipWidget()); + expect(getMaterial(tester).shape, isA<RoundedRectangleBorder>()); + + // Selected. + await tester.pumpWidget(chipWidget(selected: true)); + expect(getMaterial(tester).shape, isA<StadiumBorder>()); + }); + + testWidgets('RawChip uses material state color from ChipTheme', (WidgetTester tester) async { + const disabledSelectedColor = Color(0xffffff00); + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return MaterialApp( + theme: ThemeData( + chipTheme: ChipThemeData( + color: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return disabledSelectedColor; + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return backgroundColor; + }), + ), + ), + home: Material( + child: RawChip(isEnabled: enabled, selected: selected, label: const Text('RawChip')), + ), + ); + } + + // Check theme color for enabled chip. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + await tester.pumpAndSettle(); + + // Enabled chip should have the provided backgroundColor. + expect(getMaterialBox(tester), paints..rrect(color: backgroundColor)); + + // Check theme color for disabled chip. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled chip should have the provided disabledColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledColor)); + + // Check theme color for enabled and selected chip. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected chip should have the provided selectedColor. + expect(getMaterialBox(tester), paints..rrect(color: selectedColor)); + + // Check theme color for disabled & selected chip. + await tester.pumpWidget(buildApp(enabled: false, selected: true)); + await tester.pumpAndSettle(); + + // Disabled & selected chip should have the provided disabledSelectedColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledSelectedColor)); + }); + + testWidgets('RawChip uses state colors from ChipTheme', (WidgetTester tester) async { + const chipTheme = ChipThemeData( + disabledColor: Color(0xadfefafe), + backgroundColor: Color(0xcafefeed), + selectedColor: Color(0xbeefcafe), + ); + Widget buildApp({required bool enabled, required bool selected}) { + return MaterialApp( + theme: ThemeData(chipTheme: chipTheme), + home: Material( + child: RawChip(isEnabled: enabled, selected: selected, label: const Text('RawChip')), + ), + ); + } + + // Check theme color for enabled chip. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + await tester.pumpAndSettle(); + + // Enabled chip should have the provided backgroundColor. + expect(getMaterialBox(tester), paints..rrect(color: chipTheme.backgroundColor)); + + // Check theme color for disabled chip. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled chip should have the provided disabledColor. + expect(getMaterialBox(tester), paints..rrect(color: chipTheme.disabledColor)); + + // Check theme color for enabled and selected chip. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected chip should have the provided selectedColor. + expect(getMaterialBox(tester), paints..rrect(color: chipTheme.selectedColor)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/119163. + testWidgets('RawChip respects checkmark properties from ChipTheme', (WidgetTester tester) async { + Widget buildRawChip({ChipThemeData? chipTheme}) { + return MaterialApp( + theme: ThemeData(chipTheme: chipTheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + selected: true, + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ), + ); + } + + // Test that the checkmark is painted. + await tester.pumpWidget( + buildRawChip(chipTheme: const ChipThemeData(checkmarkColor: Color(0xffff0000))), + ); + + RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..path(color: const Color(0xffff0000), style: PaintingStyle.stroke)); + + // Test that the checkmark is not painted when ChipThemeData.showCheckmark is false. + await tester.pumpWidget( + buildRawChip( + chipTheme: const ChipThemeData(showCheckmark: false, checkmarkColor: Color(0xffff0000)), + ), + ); + await tester.pumpAndSettle(); + + materialBox = getMaterialBox(tester); + expect( + materialBox, + isNot(paints..path(color: const Color(0xffff0000), style: PaintingStyle.stroke)), + ); + }); + + testWidgets("Material3 - RawChip.shape's side is used when provided", ( + WidgetTester tester, + ) async { + Widget buildChip({OutlinedBorder? shape, BorderSide? side}) { + return MaterialApp( + theme: ThemeData( + chipTheme: ChipThemeData(shape: shape, side: side), + ), + home: const Material( + child: Center(child: RawChip(label: Text('RawChip'))), + ), + ); + } + + // Test [RawChip.shape] with a side. + await tester.pumpWidget( + buildChip( + shape: const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffff00ff)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + ), + ); + + // Chip should have the provided shape and the side from [RawChip.shape]. + expect( + getMaterial(tester).shape, + const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffff00ff)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + ); + + // Test [RawChip.shape] with a side and [RawChip.side]. + await tester.pumpWidget( + buildChip( + shape: const RoundedRectangleBorder( + side: BorderSide(color: Color(0xffff00ff)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + side: const BorderSide(color: Color(0xfffff000)), + ), + ); + await tester.pumpAndSettle(); + + // Chip use shape from [RawChip.shape] and the side from [RawChip.side]. + // [RawChip.shape]'s side should be ignored. + expect( + getMaterial(tester).shape, + const RoundedRectangleBorder( + side: BorderSide(color: Color(0xfffff000)), + borderRadius: BorderRadius.all(Radius.circular(7.0)), + ), + ); + }); + + testWidgets('Material3 - ChipThemeData.iconTheme respects default iconTheme.size', ( + WidgetTester tester, + ) async { + Widget buildChip({IconThemeData? iconTheme}) { + return MaterialApp( + theme: ThemeData(chipTheme: ChipThemeData(iconTheme: iconTheme)), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.add), + label: const SizedBox(width: 100, height: 100), + onSelected: (bool newValue) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildChip(iconTheme: const IconThemeData(color: Color(0xff332211)))); + + // Icon should have the default chip iconSize. + expect(getIconData(tester).size, 18.0); + expect(getIconData(tester).color, const Color(0xff332211)); + + // Icon should have the provided iconSize. + await tester.pumpWidget( + buildChip(iconTheme: const IconThemeData(color: Color(0xff112233), size: 23.0)), + ); + await tester.pumpAndSettle(); + + expect(getIconData(tester).size, 23.0); + expect(getIconData(tester).color, const Color(0xff112233)); + }); + + testWidgets('ChipThemeData.avatarBoxConstraints updates avatar size constraints', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(75, 75); + + // Test default avatar layout constraints. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + chipTheme: const ChipThemeData(avatarBoxConstraints: BoxConstraints.tightForFinite()), + ), + home: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(RawChip)).width, equals(127.0)); + expect(tester.getSize(find.byType(RawChip)).height, equals(93.0)); + + // Calculate the distance between avatar and chip edges. + final Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + final Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('ChipThemeData.deleteIconBoxConstraints updates delete icon size constraints', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(75, 75); + + // Test custom delete layout constraints. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + chipTheme: const ChipThemeData(deleteIconBoxConstraints: BoxConstraints.tightForFinite()), + ), + home: Material( + child: Center( + child: RawChip( + onDeleted: () {}, + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(RawChip)).width, equals(127.0)); + expect(tester.getSize(find.byType(RawChip)).height, equals(93.0)); + + // Calculate the distance between delete icon and chip edges. + final Offset chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + final Offset deleteIconCenter = tester.getCenter(find.byIcon(Icons.cancel)); + expect(chipTopRight.dx, deleteIconCenter.dx + (iconSize / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + final Offset labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (iconSize / 2) - labelPadding); + }); + + testWidgets('ChipThemeData.iconTheme updates avatar and delete icons', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xffff0000); + const iconSize = 32.0; + const IconData avatarIcon = Icons.favorite; + const IconData deleteIcon = Icons.delete; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + chipTheme: const ChipThemeData( + iconTheme: IconThemeData(color: iconColor, size: iconSize), + ), + ), + home: Material( + child: Center( + child: RawChip( + avatar: const Icon(Icons.favorite), + deleteIcon: const Icon(Icons.delete), + onDeleted: () {}, + label: const SizedBox(height: 100), + ), + ), + ), + ), + ); + + // Test rendered icon size. + final RenderBox avatarIconBox = tester.renderObject(find.byIcon(avatarIcon)); + final RenderBox deleteIconBox = tester.renderObject(find.byIcon(deleteIcon)); + expect(avatarIconBox.size.width, equals(iconSize)); + expect(deleteIconBox.size.width, equals(iconSize)); + + // Test rendered icon color. + expect(getIconStyle(tester, avatarIcon)?.color, iconColor); + expect(getIconStyle(tester, deleteIcon)?.color, iconColor); + }); + + testWidgets('ChipThemeData.deleteIconColor overrides ChipThemeData.iconTheme color', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xffff00ff); + const deleteIconColor = Color(0xffff00ff); + const IconData deleteIcon = Icons.delete; + + Widget buildChip({Color? deleteIconColor, Color? iconColor}) { + return MaterialApp( + theme: ThemeData( + chipTheme: ChipThemeData( + deleteIconColor: deleteIconColor, + iconTheme: IconThemeData(color: iconColor), + ), + ), + home: Material( + child: Center( + child: RawChip( + deleteIcon: const Icon(Icons.delete), + onDeleted: () {}, + label: const SizedBox(height: 100), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildChip(iconColor: iconColor)); + + // Test rendered icon color. + expect(getIconStyle(tester, deleteIcon)?.color, iconColor); + + await tester.pumpWidget(buildChip(deleteIconColor: deleteIconColor, iconColor: iconColor)); + + // Test rendered icon color. + expect(getIconStyle(tester, deleteIcon)?.color, deleteIconColor); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/135136. + testWidgets('WidgetStateBorderSide properly lerp in ChipThemeData.side', ( + WidgetTester tester, + ) async { + late ColorScheme colorScheme; + + Widget buildChip({required Color seedColor}) { + colorScheme = ColorScheme.fromSeed(seedColor: seedColor); + return MaterialApp( + theme: ThemeData( + colorScheme: colorScheme, + chipTheme: ChipThemeData( + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + return BorderSide(color: colorScheme.primary, width: 4.0); + }), + ), + ), + home: const Scaffold(body: RawChip(label: Text('Chip'))), + ); + } + + await tester.pumpWidget(buildChip(seedColor: Colors.red)); + await tester.pumpAndSettle(); + + RenderBox getChipRenderBox() { + return tester.renderObject<RenderBox>(find.byType(RawChip)); + } + + expect(getChipRenderBox(), paints..drrect(color: colorScheme.primary)); + + await tester.pumpWidget(buildChip(seedColor: Colors.blue)); + await tester.pump(kPressTimeout); + + expect(getChipRenderBox(), paints..drrect(color: colorScheme.primary)); + }); +} + +class _TestWidgetStateOutlinedBorder extends StadiumBorder implements WidgetStateOutlinedBorder { + const _TestWidgetStateOutlinedBorder(this.resolver); + + final WidgetPropertyResolver<OutlinedBorder?> resolver; + + @override + OutlinedBorder? resolve(Set<WidgetState> states) => resolver(states); +} + +class _TestWidgetStateBorderSide extends WidgetStateBorderSide { + const _TestWidgetStateBorderSide(this.resolver); + + final WidgetPropertyResolver<BorderSide?> resolver; + + @override + BorderSide? resolve(Set<WidgetState> states) => resolver(states); +} diff --git a/packages/material_ui/test/material/choice_chip_test.dart b/packages/material_ui/test/material/choice_chip_test.dart new file mode 100644 index 000000000000..4f411f056247 --- /dev/null +++ b/packages/material_ui/test/material/choice_chip_test.dart @@ -0,0 +1,894 @@ +// 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:ui'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +RenderBox getMaterialBox(WidgetTester tester, Finder type) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: type, matching: find.byType(CustomPaint)), + ); +} + +Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(ChoiceChip), matching: find.byType(Material)), + ); +} + +IconThemeData getIconData(WidgetTester tester) { + final IconTheme iconTheme = tester.firstWidget( + find.descendant(of: find.byType(RawChip), matching: find.byType(IconTheme)), + ); + return iconTheme.data; +} + +DefaultTextStyle getLabelStyle(WidgetTester tester, String labelText) { + return tester.widget( + find.ancestor(of: find.text(labelText), matching: find.byType(DefaultTextStyle)).first, + ); +} + +/// Adds the basic requirements for a Chip. +Widget wrapForChip({ + required Widget child, + TextDirection textDirection = TextDirection.ltr, + TextScaler textScaler = TextScaler.noScaling, + Brightness brightness = Brightness.light, + bool? useMaterial3, +}) { + return MaterialApp( + theme: ThemeData(brightness: brightness, useMaterial3: useMaterial3), + home: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material(child: child), + ), + ), + ); +} + +void checkChipMaterialClipBehavior(WidgetTester tester, Clip clipBehavior) { + final Iterable<Material> materials = tester.widgetList<Material>(find.byType(Material)); + // There should be two Material widgets, first Material is from the "_wrapForChip" and + // last Material is from the "RawChip". + expect(materials.length, 2); + // The last Material from `RawChip` should have the clip behavior. + expect(materials.last.clipBehavior, clipBehavior); +} + +void main() { + testWidgets('Material2 - ChoiceChip defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + const label = 'choice chip'; + + // Test enabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ChoiceChip( + selected: false, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ), + ); + + // Test default chip size. + expect(tester.getSize(find.byType(ChoiceChip)), const Size(178.0, 48.0)); + // Test default label style. + expect( + getLabelStyle(tester, label).style.color, + theme.textTheme.bodyLarge!.color!.withAlpha(0xde), + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x1f)); + + // Test disabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ChoiceChip(selected: false, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black38); + + // Test selected enabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: ChoiceChip( + selected: true, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x3d)); + + // Test selected disabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ChoiceChip(selected: true, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x3d)); + }); + + testWidgets('Material3 - ChoiceChip defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const label = 'choice chip'; + + // Test enabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ChoiceChip( + selected: false, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ), + ); + + // Test default chip size. + expect( + tester.getSize(find.byType(ChoiceChip)), + within(distance: 0.01, from: const Size(189.1, 48.0)), + ); + // Test default label style. + expect( + getLabelStyle(tester, label).style.color!.value, + theme.colorScheme.onSurfaceVariant.value, + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.transparent); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, null); + + // Test disabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ChoiceChip(selected: false, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.transparent); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: theme.colorScheme.onSurface.withOpacity(0.12)), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, null); + + // Test selected enabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: ChoiceChip( + selected: true, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.secondaryContainer); + + // Test selected disabled ChoiceChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ChoiceChip(selected: true, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('Material3 - ChoiceChip.elevated defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const label = 'choice chip'; + + // Test enabled ChoiceChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ChoiceChip.elevated( + selected: false, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ), + ); + + // Test default chip size. + expect( + tester.getSize(find.byType(ChoiceChip)), + within(distance: 0.01, from: const Size(189.1, 48.0)), + ); + // Test default label style. + expect( + getLabelStyle(tester, label).style.color!.value, + theme.colorScheme.onSurfaceVariant.value, + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 1); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.surfaceContainerLow); + + // Test disabled ChoiceChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ChoiceChip.elevated(selected: false, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + + // Test selected enabled ChoiceChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: ChoiceChip.elevated( + selected: true, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 1); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.secondaryContainer); + + // Test selected disabled ChoiceChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: ChoiceChip.elevated(selected: false, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('ChoiceChip.color resolves material states', (WidgetTester tester) async { + const disabledSelectedColor = Color(0xffffff00); + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + final WidgetStateProperty<Color?> color = WidgetStateProperty.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return disabledSelectedColor; + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return backgroundColor; + }); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: Column( + children: <Widget>[ + ChoiceChip( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + color: color, + label: const Text('ChoiceChip'), + ), + ChoiceChip.elevated( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + color: color, + label: const Text('ChoiceChip.elevated'), + ), + ], + ), + ); + } + + // Test enabled state. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled ChoiceChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: backgroundColor), + ); + // Enabled elevated ChoiceChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: backgroundColor), + ); + + // Test disabled state. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled ChoiceChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: disabledColor)); + // Disabled elevated ChoiceChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: disabledColor)); + + // Test enabled & selected state. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected ChoiceChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: selectedColor)); + // Enabled & selected elevated ChoiceChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: selectedColor)); + + // Test disabled & selected state. + await tester.pumpWidget(buildApp(enabled: false, selected: true)); + await tester.pumpAndSettle(); + + // Disabled & selected ChoiceChip should have the provided disabledSelectedColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: disabledSelectedColor), + ); + // Disabled & selected elevated ChoiceChip should have the provided disabledSelectedColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: disabledSelectedColor), + ); + }); + + testWidgets('ChoiceChip uses provided state color properties', (WidgetTester tester) async { + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: Column( + children: <Widget>[ + ChoiceChip( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + label: const Text('ChoiceChip'), + ), + ChoiceChip.elevated( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + label: const Text('ChoiceChip.elevated'), + ), + ], + ), + ); + } + + // Test enabled chips. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled ChoiceChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: backgroundColor), + ); + // Enabled elevated ChoiceChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: backgroundColor), + ); + + // Test disabled chips. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled ChoiceChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: disabledColor)); + // Disabled elevated ChoiceChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: disabledColor)); + + // Test enabled & selected chips. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected ChoiceChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: selectedColor)); + // Enabled & selected elevated ChoiceChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: selectedColor)); + }); + + testWidgets('ChoiceChip can be tapped', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: ChoiceChip(selected: false, label: Text('choice chip'))), + ), + ); + + await tester.tap(find.byType(ChoiceChip)); + expect(tester.takeException(), null); + }); + + testWidgets('ChoiceChip clipBehavior properly passes through to the Material', ( + WidgetTester tester, + ) async { + const label = Text('label'); + await tester.pumpWidget(wrapForChip(child: const ChoiceChip(label: label, selected: false))); + checkChipMaterialClipBehavior(tester, Clip.none); + + await tester.pumpWidget( + wrapForChip( + child: const ChoiceChip(label: label, selected: false, clipBehavior: Clip.antiAlias), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.antiAlias); + }); + + testWidgets('ChoiceChip passes iconTheme property to RawChip', (WidgetTester tester) async { + const iconTheme = IconThemeData(color: Colors.red); + await tester.pumpWidget( + wrapForChip( + child: const ChoiceChip(label: Text('Test'), selected: true, iconTheme: iconTheme), + ), + ); + final RawChip rawChip = tester.widget(find.byType(RawChip)); + expect(rawChip.iconTheme, iconTheme); + }); + + testWidgets('ChoiceChip passes showCheckmark from ChipTheme to RawChip', ( + WidgetTester tester, + ) async { + const showCheckmark = false; + await tester.pumpWidget( + wrapForChip( + child: const ChipTheme( + data: ChipThemeData(showCheckmark: showCheckmark), + child: ChoiceChip(label: Text('Test'), selected: true), + ), + ), + ); + final RawChip rawChip = tester.widget(find.byType(RawChip)); + expect(rawChip.showCheckmark, showCheckmark); + }); + + testWidgets('ChoiceChip passes checkmark properties to RawChip', (WidgetTester tester) async { + const showCheckmark = false; + const checkmarkColor = Color(0xff0000ff); + await tester.pumpWidget( + wrapForChip( + child: const ChoiceChip( + label: Text('Test'), + selected: true, + showCheckmark: showCheckmark, + checkmarkColor: checkmarkColor, + ), + ), + ); + final RawChip rawChip = tester.widget(find.byType(RawChip)); + expect(rawChip.showCheckmark, showCheckmark); + expect(rawChip.checkmarkColor, checkmarkColor); + }); + + testWidgets('ChoiceChip uses provided iconTheme', (WidgetTester tester) async { + final theme = ThemeData(); + + Widget buildChip({IconThemeData? iconTheme}) { + return MaterialApp( + theme: theme, + home: Material( + child: ChoiceChip( + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const Text('Test'), + selected: false, + onSelected: (bool _) {}, + ), + ), + ); + } + + // Test default icon theme. + await tester.pumpWidget(buildChip()); + + expect(getIconData(tester).color, theme.colorScheme.primary); + + // Test provided icon theme. + await tester.pumpWidget(buildChip(iconTheme: const IconThemeData(color: Color(0xff00ff00)))); + + expect(getIconData(tester).color, const Color(0xff00ff00)); + }); + + testWidgets('ChoiceChip avatar layout constraints can be customized', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? avatarBoxConstraints}) { + return wrapForChip( + child: Center( + child: ChoiceChip( + avatarBoxConstraints: avatarBoxConstraints, + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + selected: false, + ), + ), + ); + } + + // Test default avatar layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(ChoiceChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(ChoiceChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (labelSize.width / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (labelSize.width / 2) + labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget(buildChip(avatarBoxConstraints: const BoxConstraints.tightForFinite())); + await tester.pump(); + + expect(tester.getSize(find.byType(ChoiceChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(ChoiceChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('ChoiceChip.chipAnimationStyle is passed to RawChip', (WidgetTester tester) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: const AnimationStyle(duration: Durations.extralong4), + selectAnimation: AnimationStyle.noAnimation, + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: ChoiceChip( + chipAnimationStyle: chipAnimationStyle, + selected: true, + label: const Text('ChoiceChip'), + ), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('Elevated ChoiceChip.chipAnimationStyle is passed to RawChip', ( + WidgetTester tester, + ) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: const AnimationStyle(duration: Durations.extralong4), + selectAnimation: AnimationStyle.noAnimation, + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: ChoiceChip.elevated( + chipAnimationStyle: chipAnimationStyle, + selected: true, + label: const Text('ChoiceChip'), + ), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('ChoiceChip has expected default mouse cursor on hover', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: Center( + child: ChoiceChip( + selected: false, + label: const Text('Chip'), + onSelected: (bool value) {}, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(10, 10)); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.byType(ChoiceChip)); + await gesture.moveTo(chip); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('ChoiceChip mouse cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + wrapForChip( + child: const Center( + child: ChoiceChip(selected: false, mouseCursor: customCursor, label: Text('Chip')), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('Mouse cursor resolves in focused/unfocused/disabled states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Chip'); + addTearDown(focusNode.dispose); + + Widget buildChip({required bool enabled}) { + return wrapForChip( + child: Center( + child: ChoiceChip( + mouseCursor: const WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + WidgetState.focused: SystemMouseCursors.grab, + WidgetState.selected: SystemMouseCursors.click, + WidgetState.any: SystemMouseCursors.basic, + }), + focusNode: focusNode, + label: const Text('Chip'), + onSelected: enabled ? (bool value) {} : null, + selected: false, + ), + ), + ); + } + + // Unfocused case. + await tester.pumpWidget(buildChip(enabled: true)); + final TestGesture gesture1 = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture1.removePointer); + await gesture1.addPointer(location: tester.getCenter(find.text('Chip'))); + await tester.pump(); + await gesture1.moveTo(tester.getCenter(find.text('Chip'))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Focused case. + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Disabled case. + await tester.pumpWidget(buildChip(enabled: false)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('ChoiceChip renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(body: ChoiceChip(label: Text('X'), selected: true)), + ), + ), + ), + ); + final Finder xText = find.text('X'); + expect(tester.getSize(xText).isEmpty, isTrue); + }); +} diff --git a/packages/material_ui/test/material/circle_avatar_test.dart b/packages/material_ui/test/material/circle_avatar_test.dart new file mode 100644 index 000000000000..dd7fe9a7c9ab --- /dev/null +++ b/packages/material_ui/test/material/circle_avatar_test.dart @@ -0,0 +1,340 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../image_data.dart'; +import '../painting/mocks_for_image_cache.dart'; + +void main() { + testWidgets('CircleAvatar with dark background color', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: CircleAvatar(backgroundColor: backgroundColor, radius: 50.0, child: const Text('Z')), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(ThemeData.fallback().primaryColorLight)); + }); + + testWidgets('CircleAvatar with light background color', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade100; + await tester.pumpWidget( + wrap( + child: CircleAvatar(backgroundColor: backgroundColor, radius: 50.0, child: const Text('Z')), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(ThemeData.fallback().primaryColorDark)); + }); + + testWidgets('CircleAvatar with image background', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + child: CircleAvatar( + backgroundImage: MemoryImage(Uint8List.fromList(kTransparentImage)), + radius: 50.0, + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.image!.fit, equals(BoxFit.cover)); + }); + + testWidgets('CircleAvatar with image foreground', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + child: CircleAvatar( + foregroundImage: MemoryImage(Uint8List.fromList(kBlueRectPng)), + radius: 50.0, + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.image!.fit, equals(BoxFit.cover)); + }); + + testWidgets('CircleAvatar backgroundImage is used as a fallback for foregroundImage', ( + WidgetTester tester, + ) async { + addTearDown(imageCache.clear); + final errorImage = ErrorImageProvider(); + var caughtForegroundImageError = false; + await tester.pumpWidget( + wrap( + child: RepaintBoundary( + child: CircleAvatar( + foregroundImage: errorImage, + backgroundImage: MemoryImage(Uint8List.fromList(kBlueRectPng)), + radius: 50.0, + onForegroundImageError: (_, _) => caughtForegroundImageError = true, + ), + ), + ), + ); + + expect(caughtForegroundImageError, true); + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.image!.fit, equals(BoxFit.cover)); + await expectLater(find.byType(CircleAvatar), matchesGoldenFile('circle_avatar.fallback.png')); + }); + + testWidgets('CircleAvatar with foreground color', (WidgetTester tester) async { + final Color foregroundColor = Colors.red.shade100; + await tester.pumpWidget( + wrap( + child: CircleAvatar(foregroundColor: foregroundColor, child: const Text('Z')), + ), + ); + + final fallback = ThemeData.fallback(); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(40.0, 40.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(fallback.primaryColorDark)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(foregroundColor)); + }); + + testWidgets('Material3 - CircleAvatar default colors', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + wrap( + child: Theme( + data: theme, + child: const CircleAvatar(child: Text('Z')), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(theme.colorScheme.primaryContainer)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(theme.colorScheme.onPrimaryContainer)); + }); + + testWidgets('CircleAvatar text does not expand with textScaler', (WidgetTester tester) async { + final Color foregroundColor = Colors.red.shade100; + await tester.pumpWidget( + wrap( + child: CircleAvatar(foregroundColor: foregroundColor, child: const Text('Z')), + ), + ); + + expect(tester.getSize(find.text('Z')), equals(const Size(16.0, 16.0))); + + await tester.pumpWidget( + wrap( + child: MediaQuery( + data: const MediaQueryData( + textScaler: TextScaler.linear(2.0), + size: Size(111.0, 111.0), + devicePixelRatio: 1.1, + padding: EdgeInsets.all(11.0), + ), + child: CircleAvatar( + child: Builder( + builder: (BuildContext context) { + final MediaQueryData data = MediaQuery.of(context); + + // These should not change. + expect(data.size, equals(const Size(111.0, 111.0))); + expect(data.devicePixelRatio, equals(1.1)); + expect(data.padding, equals(const EdgeInsets.all(11.0))); + + // This should be overridden to 1.0. + expect(data.textScaler, TextScaler.noScaling); + return const Text('Z'); + }, + ), + ), + ), + ), + ); + expect(tester.getSize(find.text('Z')), equals(const Size(16.0, 16.0))); + }); + + testWidgets('CircleAvatar respects minRadius', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: UnconstrainedBox( + child: CircleAvatar( + backgroundColor: backgroundColor, + minRadius: 50.0, + child: const Text('Z'), + ), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(ThemeData.fallback().primaryColorLight)); + }); + + testWidgets('CircleAvatar respects maxRadius', (WidgetTester tester) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: CircleAvatar( + backgroundColor: backgroundColor, + maxRadius: 50.0, + child: const Text('Z'), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(ThemeData.fallback().primaryColorLight)); + }); + + testWidgets('CircleAvatar respects setting both minRadius and maxRadius', ( + WidgetTester tester, + ) async { + final Color backgroundColor = Colors.blue.shade900; + await tester.pumpWidget( + wrap( + child: CircleAvatar( + backgroundColor: backgroundColor, + maxRadius: 50.0, + minRadius: 50.0, + child: const Text('Z'), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + expect(box.size, equals(const Size(100.0, 100.0))); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(backgroundColor)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(ThemeData.fallback().primaryColorLight)); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Material2 - CircleAvatar default colors with light theme', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false, primaryColor: Colors.grey.shade100); + await tester.pumpWidget( + wrap( + child: Theme( + data: theme, + child: const CircleAvatar(child: Text('Z')), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(theme.primaryColorLight)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(theme.primaryTextTheme.titleLarge!.color)); + }); + + testWidgets('Material2 - CircleAvatar default colors with dark theme', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false, primaryColor: Colors.grey.shade800); + await tester.pumpWidget( + wrap( + child: Theme( + data: theme, + child: const CircleAvatar(child: Text('Z')), + ), + ), + ); + + final RenderConstrainedBox box = tester.renderObject(find.byType(CircleAvatar)); + final child = box.child! as RenderDecoratedBox; + final decoration = child.decoration as BoxDecoration; + expect(decoration.color, equals(theme.primaryColorDark)); + + final RenderParagraph paragraph = tester.renderObject(find.text('Z')); + expect(paragraph.text.style!.color, equals(theme.primaryTextTheme.titleLarge!.color)); + }); + }); + + testWidgets('CircleAvatar renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SizedBox.shrink(child: CircleAvatar(child: Text('X'))), + ), + ); + }); +} + +Widget wrap({required Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Center(child: child), + ), + ), + ); +} diff --git a/packages/material_ui/test/material/color_scheme_test.dart b/packages/material_ui/test/material/color_scheme_test.dart new file mode 100644 index 000000000000..0c82759a0f27 --- /dev/null +++ b/packages/material_ui/test/material/color_scheme_test.dart @@ -0,0 +1,1348 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; + +import '../image_data.dart'; + +void main() { + test('ColorScheme lerp special cases', () { + const scheme = ColorScheme.light(); + expect(identical(ColorScheme.lerp(scheme, scheme, 0.5), scheme), true); + }); + + test('light scheme matches the spec', () { + // Colors should match the Material Design baseline default theme: + // https://material.io/design/color/dark-theme.html#ui-application + // with the new Material 3 colors defaulting to values from the M2 + // baseline. + const scheme = ColorScheme.light(); + expect(scheme.brightness, Brightness.light); + expect(scheme.primary, const Color(0xff6200ee)); + expect(scheme.onPrimary, const Color(0xffffffff)); + expect(scheme.primaryContainer, scheme.primary); + expect(scheme.onPrimaryContainer, scheme.onPrimary); + expect(scheme.primaryFixed, scheme.primary); + expect(scheme.primaryFixedDim, scheme.primary); + expect(scheme.onPrimaryFixed, scheme.onPrimary); + expect(scheme.onPrimaryFixedVariant, scheme.onPrimary); + expect(scheme.secondary, const Color(0xff03dac6)); + expect(scheme.onSecondary, const Color(0xff000000)); + expect(scheme.secondaryContainer, scheme.secondary); + expect(scheme.onSecondaryContainer, scheme.onSecondary); + expect(scheme.secondaryFixed, scheme.secondary); + expect(scheme.secondaryFixedDim, scheme.secondary); + expect(scheme.onSecondaryFixed, scheme.onSecondary); + expect(scheme.onSecondaryFixedVariant, scheme.onSecondary); + expect(scheme.tertiary, scheme.secondary); + expect(scheme.onTertiary, scheme.onSecondary); + expect(scheme.tertiaryContainer, scheme.tertiary); + expect(scheme.onTertiaryContainer, scheme.onTertiary); + expect(scheme.tertiaryFixed, scheme.tertiary); + expect(scheme.tertiaryFixedDim, scheme.tertiary); + expect(scheme.onTertiaryFixed, scheme.onTertiary); + expect(scheme.onTertiaryFixedVariant, scheme.onTertiary); + expect(scheme.error, const Color(0xffb00020)); + expect(scheme.onError, const Color(0xffffffff)); + expect(scheme.errorContainer, scheme.error); + expect(scheme.onErrorContainer, scheme.onError); + expect(scheme.background, const Color(0xffffffff)); + expect(scheme.onBackground, const Color(0xff000000)); + expect(scheme.surface, const Color(0xffffffff)); + expect(scheme.surfaceBright, scheme.surface); + expect(scheme.surfaceDim, scheme.surface); + expect(scheme.surfaceContainerLowest, scheme.surface); + expect(scheme.surfaceContainerLow, scheme.surface); + expect(scheme.surfaceContainer, scheme.surface); + expect(scheme.surfaceContainerHigh, scheme.surface); + expect(scheme.surfaceContainerHighest, scheme.surface); + expect(scheme.onSurface, const Color(0xff000000)); + expect(scheme.surfaceVariant, scheme.surface); + expect(scheme.onSurfaceVariant, scheme.onSurface); + expect(scheme.outline, scheme.onBackground); + expect(scheme.outlineVariant, scheme.onBackground); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.inverseSurface, scheme.onSurface); + expect(scheme.onInverseSurface, scheme.surface); + expect(scheme.inversePrimary, scheme.onPrimary); + expect(scheme.surfaceTint, scheme.primary); + }); + + test('dark scheme matches the spec', () { + // Colors should match the Material Design baseline dark theme: + // https://material.io/design/color/dark-theme.html#ui-application + // with the new Material 3 colors defaulting to values from the M2 + // baseline. + const scheme = ColorScheme.dark(); + expect(scheme.brightness, Brightness.dark); + expect(scheme.primary, const Color(0xffbb86fc)); + expect(scheme.onPrimary, const Color(0xff000000)); + expect(scheme.primaryContainer, scheme.primary); + expect(scheme.onPrimaryContainer, scheme.onPrimary); + expect(scheme.primaryFixed, scheme.primary); + expect(scheme.primaryFixedDim, scheme.primary); + expect(scheme.onPrimaryFixed, scheme.onPrimary); + expect(scheme.onPrimaryFixedVariant, scheme.onPrimary); + expect(scheme.secondary, const Color(0xff03dac6)); + expect(scheme.onSecondary, const Color(0xff000000)); + expect(scheme.secondaryContainer, scheme.secondary); + expect(scheme.onSecondaryContainer, scheme.onSecondary); + expect(scheme.secondaryFixed, scheme.secondary); + expect(scheme.secondaryFixedDim, scheme.secondary); + expect(scheme.onSecondaryFixed, scheme.onSecondary); + expect(scheme.onSecondaryFixedVariant, scheme.onSecondary); + expect(scheme.tertiary, scheme.secondary); + expect(scheme.onTertiary, scheme.onSecondary); + expect(scheme.tertiaryContainer, scheme.tertiary); + expect(scheme.onTertiaryContainer, scheme.onTertiary); + expect(scheme.tertiaryFixed, scheme.tertiary); + expect(scheme.tertiaryFixedDim, scheme.tertiary); + expect(scheme.onTertiaryFixed, scheme.onTertiary); + expect(scheme.onTertiaryFixedVariant, scheme.onTertiary); + expect(scheme.error, const Color(0xffcf6679)); + expect(scheme.onError, const Color(0xff000000)); + expect(scheme.errorContainer, scheme.error); + expect(scheme.onErrorContainer, scheme.onError); + expect(scheme.background, const Color(0xff121212)); + expect(scheme.onBackground, const Color(0xffffffff)); + expect(scheme.surface, const Color(0xff121212)); + expect(scheme.surfaceBright, scheme.surface); + expect(scheme.surfaceDim, scheme.surface); + expect(scheme.surfaceContainerLowest, scheme.surface); + expect(scheme.surfaceContainerLow, scheme.surface); + expect(scheme.surfaceContainer, scheme.surface); + expect(scheme.surfaceContainerHigh, scheme.surface); + expect(scheme.surfaceContainerHighest, scheme.surface); + expect(scheme.onSurface, const Color(0xffffffff)); + expect(scheme.surfaceVariant, scheme.surface); + expect(scheme.onSurfaceVariant, scheme.onSurface); + expect(scheme.outline, scheme.onBackground); + expect(scheme.outlineVariant, scheme.onBackground); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.inverseSurface, scheme.onSurface); + expect(scheme.onInverseSurface, scheme.surface); + expect(scheme.inversePrimary, scheme.onPrimary); + expect(scheme.surfaceTint, scheme.primary); + }); + + test('high contrast light scheme matches the spec', () { + // Colors are based off of the Material Design baseline default theme: + // https://material.io/design/color/dark-theme.html#ui-application + // with the new Material 3 colors defaulting to values from the M2 + // baseline. + const scheme = ColorScheme.highContrastLight(); + expect(scheme.brightness, Brightness.light); + expect(scheme.primary, const Color(0xff0000ba)); + expect(scheme.onPrimary, const Color(0xffffffff)); + expect(scheme.primaryContainer, scheme.primary); + expect(scheme.onPrimaryContainer, scheme.onPrimary); + expect(scheme.primaryFixed, scheme.primary); + expect(scheme.primaryFixedDim, scheme.primary); + expect(scheme.onPrimaryFixed, scheme.onPrimary); + expect(scheme.onPrimaryFixedVariant, scheme.onPrimary); + expect(scheme.secondary, const Color(0xff66fff9)); + expect(scheme.onSecondary, const Color(0xff000000)); + expect(scheme.secondaryContainer, scheme.secondary); + expect(scheme.onSecondaryContainer, scheme.onSecondary); + expect(scheme.secondaryFixed, scheme.secondary); + expect(scheme.secondaryFixedDim, scheme.secondary); + expect(scheme.onSecondaryFixed, scheme.onSecondary); + expect(scheme.onSecondaryFixedVariant, scheme.onSecondary); + expect(scheme.tertiary, scheme.secondary); + expect(scheme.onTertiary, scheme.onSecondary); + expect(scheme.tertiaryContainer, scheme.tertiary); + expect(scheme.onTertiaryContainer, scheme.onTertiary); + expect(scheme.tertiaryFixed, scheme.tertiary); + expect(scheme.tertiaryFixedDim, scheme.tertiary); + expect(scheme.onTertiaryFixed, scheme.onTertiary); + expect(scheme.onTertiaryFixedVariant, scheme.onTertiary); + expect(scheme.error, const Color(0xff790000)); + expect(scheme.onError, const Color(0xffffffff)); + expect(scheme.errorContainer, scheme.error); + expect(scheme.onErrorContainer, scheme.onError); + expect(scheme.background, const Color(0xffffffff)); + expect(scheme.onBackground, const Color(0xff000000)); + expect(scheme.surface, const Color(0xffffffff)); + expect(scheme.surfaceBright, scheme.surface); + expect(scheme.surfaceDim, scheme.surface); + expect(scheme.surfaceContainerLowest, scheme.surface); + expect(scheme.surfaceContainerLow, scheme.surface); + expect(scheme.surfaceContainer, scheme.surface); + expect(scheme.surfaceContainerHigh, scheme.surface); + expect(scheme.surfaceContainerHighest, scheme.surface); + expect(scheme.onSurface, const Color(0xff000000)); + expect(scheme.surfaceVariant, scheme.surface); + expect(scheme.onSurfaceVariant, scheme.onSurface); + expect(scheme.outline, scheme.onBackground); + expect(scheme.outlineVariant, scheme.onBackground); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.inverseSurface, scheme.onSurface); + expect(scheme.onInverseSurface, scheme.surface); + expect(scheme.inversePrimary, scheme.onPrimary); + expect(scheme.surfaceTint, scheme.primary); + }); + + test('high contrast dark scheme matches the spec', () { + // Colors are based off of the Material Design baseline dark theme: + // https://material.io/design/color/dark-theme.html#ui-application + // with the new Material 3 colors defaulting to values from the M2 + // baseline. + const scheme = ColorScheme.highContrastDark(); + expect(scheme.brightness, Brightness.dark); + expect(scheme.primary, const Color(0xffefb7ff)); + expect(scheme.onPrimary, const Color(0xff000000)); + expect(scheme.primaryContainer, scheme.primary); + expect(scheme.onPrimaryContainer, scheme.onPrimary); + expect(scheme.primaryFixed, scheme.primary); + expect(scheme.primaryFixedDim, scheme.primary); + expect(scheme.onPrimaryFixed, scheme.onPrimary); + expect(scheme.onPrimaryFixedVariant, scheme.onPrimary); + expect(scheme.secondary, const Color(0xff66fff9)); + expect(scheme.onSecondary, const Color(0xff000000)); + expect(scheme.secondaryContainer, scheme.secondary); + expect(scheme.onSecondaryContainer, scheme.onSecondary); + expect(scheme.secondaryFixed, scheme.secondary); + expect(scheme.secondaryFixedDim, scheme.secondary); + expect(scheme.onSecondaryFixed, scheme.onSecondary); + expect(scheme.onSecondaryFixedVariant, scheme.onSecondary); + expect(scheme.tertiary, scheme.secondary); + expect(scheme.onTertiary, scheme.onSecondary); + expect(scheme.tertiaryContainer, scheme.tertiary); + expect(scheme.onTertiaryContainer, scheme.onTertiary); + expect(scheme.tertiaryFixed, scheme.tertiary); + expect(scheme.tertiaryFixedDim, scheme.tertiary); + expect(scheme.onTertiaryFixed, scheme.onTertiary); + expect(scheme.onTertiaryFixedVariant, scheme.onTertiary); + expect(scheme.error, const Color(0xff9b374d)); + expect(scheme.onError, const Color(0xff000000)); + expect(scheme.errorContainer, scheme.error); + expect(scheme.onErrorContainer, scheme.onError); + expect(scheme.background, const Color(0xff121212)); + expect(scheme.onBackground, const Color(0xffffffff)); + expect(scheme.surface, const Color(0xff121212)); + expect(scheme.surfaceBright, scheme.surface); + expect(scheme.surfaceDim, scheme.surface); + expect(scheme.surfaceContainerLowest, scheme.surface); + expect(scheme.surfaceContainerLow, scheme.surface); + expect(scheme.surfaceContainer, scheme.surface); + expect(scheme.surfaceContainerHigh, scheme.surface); + expect(scheme.surfaceContainerHighest, scheme.surface); + expect(scheme.onSurface, const Color(0xffffffff)); + expect(scheme.surfaceVariant, scheme.surface); + expect(scheme.onSurfaceVariant, scheme.onSurface); + expect(scheme.outline, scheme.onBackground); + expect(scheme.outlineVariant, scheme.onBackground); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.inverseSurface, scheme.onSurface); + expect(scheme.onInverseSurface, scheme.surface); + expect(scheme.inversePrimary, scheme.onPrimary); + expect(scheme.surfaceTint, scheme.primary); + }); + + test('can generate a light scheme from a seed color', () { + final scheme = ColorScheme.fromSeed(seedColor: Colors.blue); + expect(scheme.primary, const Color(0xff36618e)); + expect(scheme.onPrimary, const Color(0xffffffff)); + expect(scheme.primaryContainer, const Color(0xffd1e4ff)); + expect(scheme.onPrimaryContainer, const Color(0xff194975)); + expect(scheme.primaryFixed, const Color(0xffd1e4ff)); + expect(scheme.primaryFixedDim, const Color(0xffa0cafd)); + expect(scheme.onPrimaryFixed, const Color(0xff001d36)); + expect(scheme.onPrimaryFixedVariant, const Color(0xff194975)); + expect(scheme.secondary, const Color(0xff535f70)); + expect(scheme.onSecondary, const Color(0xffffffff)); + expect(scheme.secondaryContainer, const Color(0xffd7e3f7)); + expect(scheme.onSecondaryContainer, const Color(0xff3b4858)); + expect(scheme.secondaryFixed, const Color(0xffd7e3f7)); + expect(scheme.secondaryFixedDim, const Color(0xffbbc7db)); + expect(scheme.onSecondaryFixed, const Color(0xff101c2b)); + expect(scheme.onSecondaryFixedVariant, const Color(0xff3b4858)); + expect(scheme.tertiary, const Color(0xff6b5778)); + expect(scheme.onTertiary, const Color(0xffffffff)); + expect(scheme.tertiaryContainer, const Color(0xfff2daff)); + expect(scheme.onTertiaryContainer, const Color(0xff523f5f)); + expect(scheme.tertiaryFixed, const Color(0xfff2daff)); + expect(scheme.tertiaryFixedDim, const Color(0xffd6bee4)); + expect(scheme.onTertiaryFixed, const Color(0xff251431)); + expect(scheme.onTertiaryFixedVariant, const Color(0xff523f5f)); + expect(scheme.error, const Color(0xffba1a1a)); + expect(scheme.onError, const Color(0xffffffff)); + expect(scheme.errorContainer, const Color(0xffffdad6)); + expect(scheme.onErrorContainer, const Color(0xff93000a)); + expect(scheme.outline, const Color(0xff73777f)); + expect(scheme.outlineVariant, const Color(0xffc3c7cf)); + expect(scheme.background, const Color(0xfff8f9ff)); + expect(scheme.onBackground, const Color(0xff191c20)); + expect(scheme.surface, const Color(0xfff8f9ff)); + expect(scheme.surfaceBright, const Color(0xfff8f9ff)); + expect(scheme.surfaceDim, const Color(0xffd8dae0)); + expect(scheme.surfaceContainerLowest, const Color(0xffffffff)); + expect(scheme.surfaceContainerLow, const Color(0xfff2f3fa)); + expect(scheme.surfaceContainer, const Color(0xffeceef4)); + expect(scheme.surfaceContainerHigh, const Color(0xffe6e8ee)); + expect(scheme.surfaceContainerHighest, const Color(0xffe1e2e8)); + expect(scheme.onSurface, const Color(0xff191c20)); + expect(scheme.surfaceVariant, const Color(0xffdfe2eb)); + expect(scheme.onSurfaceVariant, const Color(0xff43474e)); + expect(scheme.inverseSurface, const Color(0xff2e3135)); + expect(scheme.onInverseSurface, const Color(0xffeff0f7)); + expect(scheme.inversePrimary, const Color(0xffa0cafd)); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.surfaceTint, const Color(0xff36618e)); + expect(scheme.brightness, Brightness.light); + }); + + test('copyWith overrides given colors', () { + final ColorScheme scheme = const ColorScheme.light().copyWith( + brightness: Brightness.dark, + primary: const Color(0x00000001), + onPrimary: const Color(0x00000002), + primaryContainer: const Color(0x00000003), + onPrimaryContainer: const Color(0x00000004), + primaryFixed: const Color(0x0000001D), + primaryFixedDim: const Color(0x0000001E), + onPrimaryFixed: const Color(0x0000001F), + onPrimaryFixedVariant: const Color(0x00000020), + secondary: const Color(0x00000005), + onSecondary: const Color(0x00000006), + secondaryContainer: const Color(0x00000007), + onSecondaryContainer: const Color(0x00000008), + secondaryFixed: const Color(0x00000021), + secondaryFixedDim: const Color(0x00000022), + onSecondaryFixed: const Color(0x00000023), + onSecondaryFixedVariant: const Color(0x00000024), + tertiary: const Color(0x00000009), + onTertiary: const Color(0x0000000A), + tertiaryContainer: const Color(0x0000000B), + onTertiaryContainer: const Color(0x0000000C), + tertiaryFixed: const Color(0x00000025), + tertiaryFixedDim: const Color(0x00000026), + onTertiaryFixed: const Color(0x00000027), + onTertiaryFixedVariant: const Color(0x00000028), + error: const Color(0x0000000D), + onError: const Color(0x0000000E), + errorContainer: const Color(0x0000000F), + onErrorContainer: const Color(0x00000010), + background: const Color(0x00000011), + onBackground: const Color(0x00000012), + surface: const Color(0x00000013), + surfaceDim: const Color(0x00000029), + surfaceBright: const Color(0x0000002A), + surfaceContainerLowest: const Color(0x0000002B), + surfaceContainerLow: const Color(0x0000002C), + surfaceContainer: const Color(0x0000002D), + surfaceContainerHigh: const Color(0x0000002E), + surfaceContainerHighest: const Color(0x0000002F), + onSurface: const Color(0x00000014), + surfaceVariant: const Color(0x00000015), + onSurfaceVariant: const Color(0x00000016), + outline: const Color(0x00000017), + outlineVariant: const Color(0x00000117), + shadow: const Color(0x00000018), + scrim: const Color(0x00000118), + inverseSurface: const Color(0x00000019), + onInverseSurface: const Color(0x0000001A), + inversePrimary: const Color(0x0000001B), + surfaceTint: const Color(0x0000001C), + ); + + expect(scheme.brightness, Brightness.dark); + expect(scheme.primary, const Color(0x00000001)); + expect(scheme.onPrimary, const Color(0x00000002)); + expect(scheme.primaryContainer, const Color(0x00000003)); + expect(scheme.onPrimaryContainer, const Color(0x00000004)); + expect(scheme.primaryFixed, const Color(0x0000001D)); + expect(scheme.primaryFixedDim, const Color(0x0000001E)); + expect(scheme.onPrimaryFixed, const Color(0x0000001F)); + expect(scheme.onPrimaryFixedVariant, const Color(0x00000020)); + expect(scheme.secondary, const Color(0x00000005)); + expect(scheme.onSecondary, const Color(0x00000006)); + expect(scheme.secondaryContainer, const Color(0x00000007)); + expect(scheme.onSecondaryContainer, const Color(0x00000008)); + expect(scheme.secondaryFixed, const Color(0x00000021)); + expect(scheme.secondaryFixedDim, const Color(0x00000022)); + expect(scheme.onSecondaryFixed, const Color(0x00000023)); + expect(scheme.onSecondaryFixedVariant, const Color(0x00000024)); + expect(scheme.tertiary, const Color(0x00000009)); + expect(scheme.onTertiary, const Color(0x0000000A)); + expect(scheme.tertiaryContainer, const Color(0x0000000B)); + expect(scheme.onTertiaryContainer, const Color(0x0000000C)); + expect(scheme.tertiaryFixed, const Color(0x00000025)); + expect(scheme.tertiaryFixedDim, const Color(0x00000026)); + expect(scheme.onTertiaryFixed, const Color(0x00000027)); + expect(scheme.onTertiaryFixedVariant, const Color(0x00000028)); + expect(scheme.error, const Color(0x0000000D)); + expect(scheme.onError, const Color(0x0000000E)); + expect(scheme.errorContainer, const Color(0x0000000F)); + expect(scheme.onErrorContainer, const Color(0x00000010)); + expect(scheme.background, const Color(0x00000011)); + expect(scheme.onBackground, const Color(0x00000012)); + expect(scheme.surface, const Color(0x00000013)); + expect(scheme.surfaceDim, const Color(0x00000029)); + expect(scheme.surfaceBright, const Color(0x0000002A)); + expect(scheme.surfaceContainerLowest, const Color(0x0000002B)); + expect(scheme.surfaceContainerLow, const Color(0x0000002C)); + expect(scheme.surfaceContainer, const Color(0x0000002D)); + expect(scheme.surfaceContainerHigh, const Color(0x0000002E)); + expect(scheme.surfaceContainerHighest, const Color(0x0000002F)); + expect(scheme.onSurface, const Color(0x00000014)); + expect(scheme.surfaceVariant, const Color(0x00000015)); + expect(scheme.onSurfaceVariant, const Color(0x00000016)); + expect(scheme.outline, const Color(0x00000017)); + expect(scheme.outlineVariant, const Color(0x00000117)); + expect(scheme.shadow, const Color(0x00000018)); + expect(scheme.scrim, const Color(0x00000118)); + expect(scheme.inverseSurface, const Color(0x00000019)); + expect(scheme.onInverseSurface, const Color(0x0000001A)); + expect(scheme.inversePrimary, const Color(0x0000001B)); + expect(scheme.surfaceTint, const Color(0x0000001C)); + }); + + test('can generate a dark scheme from a seed color', () { + final scheme = ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark); + expect(scheme.primary, const Color(0xffa0cafd)); + expect(scheme.onPrimary, const Color(0xff003258)); + expect(scheme.primaryContainer, const Color(0xff194975)); + expect(scheme.onPrimaryContainer, const Color(0xffd1e4ff)); + expect(scheme.primaryFixed, const Color(0xffd1e4ff)); + expect(scheme.primaryFixedDim, const Color(0xffa0cafd)); + expect(scheme.onPrimaryFixed, const Color(0xff001d36)); + expect(scheme.onPrimaryFixedVariant, const Color(0xff194975)); + expect(scheme.secondary, const Color(0xffbbc7db)); + expect(scheme.onSecondary, const Color(0xff253140)); + expect(scheme.secondaryContainer, const Color(0xff3b4858)); + expect(scheme.onSecondaryContainer, const Color(0xffd7e3f7)); + expect(scheme.secondaryFixed, const Color(0xffd7e3f7)); + expect(scheme.secondaryFixedDim, const Color(0xffbbc7db)); + expect(scheme.onSecondaryFixed, const Color(0xff101c2b)); + expect(scheme.onSecondaryFixedVariant, const Color(0xff3b4858)); + expect(scheme.tertiary, const Color(0xffd6bee4)); + expect(scheme.onTertiary, const Color(0xff3b2948)); + expect(scheme.tertiaryContainer, const Color(0xff523f5f)); + expect(scheme.onTertiaryContainer, const Color(0xfff2daff)); + expect(scheme.tertiaryFixed, const Color(0xfff2daff)); + expect(scheme.tertiaryFixedDim, const Color(0xffd6bee4)); + expect(scheme.onTertiaryFixed, const Color(0xff251431)); + expect(scheme.onTertiaryFixedVariant, const Color(0xff523f5f)); + expect(scheme.error, const Color(0xffffb4ab)); + expect(scheme.onError, const Color(0xff690005)); + expect(scheme.errorContainer, const Color(0xff93000a)); + expect(scheme.onErrorContainer, const Color(0xffffdad6)); + expect(scheme.outline, const Color(0xff8d9199)); + expect(scheme.outlineVariant, const Color(0xff43474e)); + expect(scheme.background, const Color(0xff111418)); + expect(scheme.onBackground, const Color(0xffe1e2e8)); + expect(scheme.surface, const Color(0xff111418)); + expect(scheme.surfaceDim, const Color(0xff111418)); + expect(scheme.surfaceBright, const Color(0xff36393e)); + expect(scheme.surfaceContainerLowest, const Color(0xff0b0e13)); + expect(scheme.surfaceContainerLow, const Color(0xff191c20)); + expect(scheme.surfaceContainer, const Color(0xff1d2024)); + expect(scheme.surfaceContainerHigh, const Color(0xff272a2f)); + expect(scheme.surfaceContainerHighest, const Color(0xff32353a)); + expect(scheme.onSurface, const Color(0xffe1e2e8)); + expect(scheme.surfaceVariant, const Color(0xff43474e)); + expect(scheme.onSurfaceVariant, const Color(0xffc3c7cf)); + expect(scheme.inverseSurface, const Color(0xffe1e2e8)); + expect(scheme.onInverseSurface, const Color(0xff2e3135)); + expect(scheme.inversePrimary, const Color(0xff36618e)); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.surfaceTint, const Color(0xffa0cafd)); + expect(scheme.brightness, Brightness.dark); + }); + + test('can override specific colors in a generated scheme', () { + final baseScheme = ColorScheme.fromSeed(seedColor: Colors.blue); + const primaryOverride = Color(0xffabcdef); + final scheme = ColorScheme.fromSeed(seedColor: Colors.blue, primary: primaryOverride); + expect(scheme.primary, primaryOverride); + // The rest should be the same. + expect(scheme.onPrimary, baseScheme.onPrimary); + expect(scheme.primaryContainer, baseScheme.primaryContainer); + expect(scheme.onPrimaryContainer, baseScheme.onPrimaryContainer); + expect(scheme.primaryFixed, baseScheme.primaryFixed); + expect(scheme.primaryFixedDim, baseScheme.primaryFixedDim); + expect(scheme.onPrimaryFixed, baseScheme.onPrimaryFixed); + expect(scheme.onPrimaryFixedVariant, baseScheme.onPrimaryFixedVariant); + expect(scheme.secondary, baseScheme.secondary); + expect(scheme.onSecondary, baseScheme.onSecondary); + expect(scheme.secondaryContainer, baseScheme.secondaryContainer); + expect(scheme.onSecondaryContainer, baseScheme.onSecondaryContainer); + expect(scheme.secondaryFixed, baseScheme.secondaryFixed); + expect(scheme.secondaryFixedDim, baseScheme.secondaryFixedDim); + expect(scheme.onSecondaryFixed, baseScheme.onSecondaryFixed); + expect(scheme.onSecondaryFixedVariant, baseScheme.onSecondaryFixedVariant); + expect(scheme.tertiary, baseScheme.tertiary); + expect(scheme.onTertiary, baseScheme.onTertiary); + expect(scheme.tertiaryContainer, baseScheme.tertiaryContainer); + expect(scheme.onTertiaryContainer, baseScheme.onTertiaryContainer); + expect(scheme.tertiaryFixed, baseScheme.tertiaryFixed); + expect(scheme.tertiaryFixedDim, baseScheme.tertiaryFixedDim); + expect(scheme.onTertiaryFixed, baseScheme.onTertiaryFixed); + expect(scheme.onTertiaryFixedVariant, baseScheme.onTertiaryFixedVariant); + expect(scheme.error, baseScheme.error); + expect(scheme.onError, baseScheme.onError); + expect(scheme.errorContainer, baseScheme.errorContainer); + expect(scheme.onErrorContainer, baseScheme.onErrorContainer); + expect(scheme.outline, baseScheme.outline); + expect(scheme.outlineVariant, baseScheme.outlineVariant); + expect(scheme.background, baseScheme.background); + expect(scheme.onBackground, baseScheme.onBackground); + expect(scheme.surface, baseScheme.surface); + expect(scheme.surfaceBright, baseScheme.surfaceBright); + expect(scheme.surfaceDim, baseScheme.surfaceDim); + expect(scheme.surfaceContainerLowest, baseScheme.surfaceContainerLowest); + expect(scheme.surfaceContainerLow, baseScheme.surfaceContainerLow); + expect(scheme.surfaceContainer, baseScheme.surfaceContainer); + expect(scheme.surfaceContainerHigh, baseScheme.surfaceContainerHigh); + expect(scheme.surfaceContainerHighest, baseScheme.surfaceContainerHighest); + expect(scheme.onSurface, baseScheme.onSurface); + expect(scheme.surfaceVariant, baseScheme.surfaceVariant); + expect(scheme.onSurfaceVariant, baseScheme.onSurfaceVariant); + expect(scheme.inverseSurface, baseScheme.inverseSurface); + expect(scheme.onInverseSurface, baseScheme.onInverseSurface); + expect(scheme.inversePrimary, baseScheme.inversePrimary); + expect(scheme.shadow, baseScheme.shadow); + expect(scheme.scrim, baseScheme.shadow); + expect(scheme.surfaceTint, baseScheme.surfaceTint); + expect(scheme.brightness, baseScheme.brightness); + }); + + test( + 'can generate a light scheme from an imageProvider', + () async { + final blueSquareBytes = Uint8List.fromList(kBlueSquarePng); + final ImageProvider image = MemoryImage(blueSquareBytes); + + final ColorScheme scheme = await ColorScheme.fromImageProvider(provider: image); + + expect(scheme.brightness, Brightness.light); + expect(scheme.primary, const Color(0xff575992)); + expect(scheme.onPrimary, const Color(0xffffffff)); + expect(scheme.primaryContainer, const Color(0xffe1e0ff)); + expect(scheme.onPrimaryContainer, const Color(0xff3f4178)); + expect(scheme.primaryFixed, const Color(0xffe1e0ff)); + expect(scheme.primaryFixedDim, const Color(0xffc0c1ff)); + expect(scheme.onPrimaryFixed, const Color(0xff13144b)); + expect(scheme.onPrimaryFixedVariant, const Color(0xff3f4178)); + expect(scheme.secondary, const Color(0xff5d5c72)); + expect(scheme.onSecondary, const Color(0xffffffff)); + expect(scheme.secondaryContainer, const Color(0xffe2e0f9)); + expect(scheme.onSecondaryContainer, const Color(0xff454559)); + expect(scheme.secondaryFixed, const Color(0xffe2e0f9)); + expect(scheme.secondaryFixedDim, const Color(0xffc6c4dd)); + expect(scheme.onSecondaryFixed, const Color(0xff191a2c)); + expect(scheme.onSecondaryFixedVariant, const Color(0xff454559)); + expect(scheme.tertiary, const Color(0xff79536a)); + expect(scheme.onTertiary, const Color(0xffffffff)); + expect(scheme.tertiaryContainer, const Color(0xffffd8ec)); + expect(scheme.onTertiaryContainer, const Color(0xff5f3c51)); + expect(scheme.tertiaryFixed, const Color(0xffffd8ec)); + expect(scheme.tertiaryFixedDim, const Color(0xffe9b9d3)); + expect(scheme.onTertiaryFixed, const Color(0xff2e1125)); + expect(scheme.onTertiaryFixedVariant, const Color(0xff5f3c51)); + expect(scheme.error, const Color(0xffba1a1a)); + expect(scheme.onError, const Color(0xffffffff)); + expect(scheme.errorContainer, const Color(0xffffdad6)); + expect(scheme.onErrorContainer, const Color(0xff93000a)); + expect(scheme.background, const Color(0xfffcf8ff)); + expect(scheme.onBackground, const Color(0xff1b1b21)); + expect(scheme.surface, const Color(0xfffcf8ff)); + expect(scheme.surfaceDim, const Color(0xffdcd9e0)); + expect(scheme.surfaceBright, const Color(0xfffcf8ff)); + expect(scheme.surfaceContainerLowest, const Color(0xffffffff)); + expect(scheme.surfaceContainerLow, const Color(0xfff6f2fa)); + expect(scheme.surfaceContainer, const Color(0xfff0ecf4)); + expect(scheme.surfaceContainerHigh, const Color(0xffeae7ef)); + expect(scheme.surfaceContainerHighest, const Color(0xffe4e1e9)); + expect(scheme.onSurface, const Color(0xff1b1b21)); + expect(scheme.surfaceVariant, const Color(0xffe4e1ec)); + expect(scheme.onSurfaceVariant, const Color(0xff46464f)); + expect(scheme.outline, const Color(0xff777680)); + expect(scheme.outlineVariant, const Color(0xffc8c5d0)); + expect(scheme.shadow, const Color(0xff000000)); + expect(scheme.scrim, const Color(0xff000000)); + expect(scheme.inverseSurface, const Color(0xff303036)); + expect(scheme.onInverseSurface, const Color(0xfff3eff7)); + expect(scheme.inversePrimary, const Color(0xffc0c1ff)); + expect(scheme.surfaceTint, const Color(0xff575992)); + }, + skip: isBrowser, // [intended] uses dart:typed_data. + ); + + test( + 'can generate a dark scheme from an imageProvider', + () async { + final blueSquareBytes = Uint8List.fromList(kBlueSquarePng); + final ImageProvider image = MemoryImage(blueSquareBytes); + + final ColorScheme scheme = await ColorScheme.fromImageProvider( + provider: image, + brightness: Brightness.dark, + ); + + expect(scheme.primary, const Color(0xffc0c1ff)); + expect(scheme.onPrimary, const Color(0xff292a60)); + expect(scheme.primaryContainer, const Color(0xff3f4178)); + expect(scheme.onPrimaryContainer, const Color(0xffe1e0ff)); + expect(scheme.primaryFixed, const Color(0xffe1e0ff)); + expect(scheme.primaryFixedDim, const Color(0xffc0c1ff)); + expect(scheme.onPrimaryFixed, const Color(0xff13144b)); + expect(scheme.onPrimaryFixedVariant, const Color(0xff3f4178)); + expect(scheme.secondary, const Color(0xffc6c4dd)); + expect(scheme.onSecondary, const Color(0xff2e2f42)); + expect(scheme.secondaryContainer, const Color(0xff454559)); + expect(scheme.onSecondaryContainer, const Color(0xffe2e0f9)); + expect(scheme.secondaryFixed, const Color(0xffe2e0f9)); + expect(scheme.secondaryFixedDim, const Color(0xffc6c4dd)); + expect(scheme.onSecondaryFixed, const Color(0xff191a2c)); + expect(scheme.onSecondaryFixedVariant, const Color(0xff454559)); + expect(scheme.tertiary, const Color(0xffe9b9d3)); + expect(scheme.onTertiary, const Color(0xff46263a)); + expect(scheme.tertiaryContainer, const Color(0xff5f3c51)); + expect(scheme.onTertiaryContainer, const Color(0xffffd8ec)); + expect(scheme.tertiaryFixed, const Color(0xffffd8ec)); + expect(scheme.tertiaryFixedDim, const Color(0xffe9b9d3)); + expect(scheme.onTertiaryFixed, const Color(0xff2e1125)); + expect(scheme.onTertiaryFixedVariant, const Color(0xff5f3c51)); + expect(scheme.error, const Color(0xffffb4ab)); + expect(scheme.onError, const Color(0xff690005)); + expect(scheme.errorContainer, const Color(0xff93000a)); + expect(scheme.onErrorContainer, const Color(0xffffdad6)); + expect(scheme.background, const Color(0xff131318)); + expect(scheme.onBackground, const Color(0xffe4e1e9)); + expect(scheme.surface, const Color(0xff131318)); + expect(scheme.surfaceDim, const Color(0xff131318)); + expect(scheme.surfaceBright, const Color(0xff39383f)); + expect(scheme.surfaceContainerLowest, const Color(0xff0e0e13)); + expect(scheme.surfaceContainerLow, const Color(0xff1b1b21)); + expect(scheme.surfaceContainer, const Color(0xff1f1f25)); + expect(scheme.surfaceContainerHigh, const Color(0xff2a292f)); + expect(scheme.surfaceContainerHighest, const Color(0xff35343a)); + expect(scheme.onSurface, const Color(0xffe4e1e9)); + expect(scheme.surfaceVariant, const Color(0xff46464f)); + expect(scheme.onSurfaceVariant, const Color(0xffc8c5d0)); + expect(scheme.outline, const Color(0xff918f9a)); + expect(scheme.outlineVariant, const Color(0xff46464f)); + expect(scheme.inverseSurface, const Color(0xffe4e1e9)); + expect(scheme.onInverseSurface, const Color(0xff303036)); + expect(scheme.inversePrimary, const Color(0xff575992)); + expect(scheme.surfaceTint, const Color(0xffc0c1ff)); + }, + skip: isBrowser, // [intended] uses dart:isolate and io. + ); + + test('fromSeed() asserts on invalid contrast levels', () { + expect(() { + ColorScheme.fromSeed(seedColor: Colors.blue, contrastLevel: -1.5); + }, throwsAssertionError); + + expect(() { + ColorScheme.fromSeed(seedColor: Colors.blue, contrastLevel: 1.5); + }, throwsAssertionError); + }); + + test('fromImageProvider() asserts on invalid contrast levels', () async { + final blueSquareBytes = Uint8List.fromList(kBlueSquarePng); + final ImageProvider image = MemoryImage(blueSquareBytes); + + expect( + () => ColorScheme.fromImageProvider(provider: image, contrastLevel: -1.5), + throwsAssertionError, + ); + + expect( + () => ColorScheme.fromImageProvider(provider: image, contrastLevel: 1.5), + throwsAssertionError, + ); + }); + + test( + 'fromImageProvider() propagates TimeoutException or Failed to render image when image cannot be rendered', + () async { + final blueSquareBytes = Uint8List.fromList(kBlueSquarePng); + + // Corrupt the image's bytelist so it cannot be read. + final Uint8List corruptImage = blueSquareBytes.sublist(5); + final ImageProvider image = MemoryImage(corruptImage); + + expect( + () async => ColorScheme.fromImageProvider(provider: image), + throwsA( + isA<Exception>().having( + (Exception e) => e.toString(), + 'image', + anyOf(contains('Failed to render image'), contains('TimeoutException')), + ), + ), + ); + }, + ); + + testWidgets( + 'generated scheme "on" colors meet a11y contrast guidelines', + (WidgetTester tester) async { + final colors = ColorScheme.fromSeed(seedColor: Colors.teal); + + Widget label(String text, Color textColor, Color background) { + return Container( + color: background, + padding: const EdgeInsets.all(8), + child: Text(text, style: TextStyle(color: textColor)), + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colors), + home: Scaffold( + body: Column( + children: <Widget>[ + label('primary', colors.onPrimary, colors.primary), + label('secondary', colors.onSecondary, colors.secondary), + label('tertiary', colors.onTertiary, colors.tertiary), + label('error', colors.onError, colors.error), + label('background', colors.onBackground, colors.background), + label('surface', colors.onSurface, colors.surface), + ], + ), + ), + ), + ); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets( + 'Color values in ColorScheme.fromSeed with different variants matches values in DynamicScheme', + (WidgetTester tester) async { + const Color seedColor = Colors.orange; + final Hct sourceColor = Hct.fromInt(seedColor.value); + for (final DynamicSchemeVariant schemeVariant in DynamicSchemeVariant.values) { + final DynamicScheme dynamicScheme = switch (schemeVariant) { + DynamicSchemeVariant.tonalSpot => SchemeTonalSpot( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.fidelity => SchemeFidelity( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.content => SchemeContent( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.monochrome => SchemeMonochrome( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.neutral => SchemeNeutral( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.vibrant => SchemeVibrant( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.expressive => SchemeExpressive( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.rainbow => SchemeRainbow( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + DynamicSchemeVariant.fruitSalad => SchemeFruitSalad( + sourceColorHct: sourceColor, + isDark: false, + contrastLevel: 0.0, + ), + }; + final colorScheme = ColorScheme.fromSeed( + seedColor: seedColor, + dynamicSchemeVariant: schemeVariant, + ); + + expect(colorScheme.primary.value, MaterialDynamicColors.primary.getArgb(dynamicScheme)); + expect(colorScheme.onPrimary.value, MaterialDynamicColors.onPrimary.getArgb(dynamicScheme)); + expect( + colorScheme.primaryContainer.value, + MaterialDynamicColors.primaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onPrimaryContainer.value, + MaterialDynamicColors.onPrimaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.primaryFixed.value, + MaterialDynamicColors.primaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.primaryFixedDim.value, + MaterialDynamicColors.primaryFixedDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.onPrimaryFixed.value, + MaterialDynamicColors.onPrimaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.onPrimaryFixedVariant.value, + MaterialDynamicColors.onPrimaryFixedVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.secondary.value, MaterialDynamicColors.secondary.getArgb(dynamicScheme)); + expect( + colorScheme.onSecondary.value, + MaterialDynamicColors.onSecondary.getArgb(dynamicScheme), + ); + expect( + colorScheme.secondaryContainer.value, + MaterialDynamicColors.secondaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSecondaryContainer.value, + MaterialDynamicColors.onSecondaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.secondaryFixed.value, + MaterialDynamicColors.secondaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.secondaryFixedDim.value, + MaterialDynamicColors.secondaryFixedDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSecondaryFixed.value, + MaterialDynamicColors.onSecondaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSecondaryFixedVariant.value, + MaterialDynamicColors.onSecondaryFixedVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.tertiary.value, MaterialDynamicColors.tertiary.getArgb(dynamicScheme)); + expect( + colorScheme.onTertiary.value, + MaterialDynamicColors.onTertiary.getArgb(dynamicScheme), + ); + expect( + colorScheme.tertiaryContainer.value, + MaterialDynamicColors.tertiaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onTertiaryContainer.value, + MaterialDynamicColors.onTertiaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.tertiaryFixed.value, + MaterialDynamicColors.tertiaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.tertiaryFixedDim.value, + MaterialDynamicColors.tertiaryFixedDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.onTertiaryFixed.value, + MaterialDynamicColors.onTertiaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.onTertiaryFixedVariant.value, + MaterialDynamicColors.onTertiaryFixedVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.error.value, MaterialDynamicColors.error.getArgb(dynamicScheme)); + expect(colorScheme.onError.value, MaterialDynamicColors.onError.getArgb(dynamicScheme)); + expect( + colorScheme.errorContainer.value, + MaterialDynamicColors.errorContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onErrorContainer.value, + MaterialDynamicColors.onErrorContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.background.value, + MaterialDynamicColors.background.getArgb(dynamicScheme), + ); + expect( + colorScheme.onBackground.value, + MaterialDynamicColors.onBackground.getArgb(dynamicScheme), + ); + expect(colorScheme.surface.value, MaterialDynamicColors.surface.getArgb(dynamicScheme)); + expect( + colorScheme.surfaceDim.value, + MaterialDynamicColors.surfaceDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceBright.value, + MaterialDynamicColors.surfaceBright.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerLowest.value, + MaterialDynamicColors.surfaceContainerLowest.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerLow.value, + MaterialDynamicColors.surfaceContainerLow.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainer.value, + MaterialDynamicColors.surfaceContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerHigh.value, + MaterialDynamicColors.surfaceContainerHigh.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerHighest.value, + MaterialDynamicColors.surfaceContainerHighest.getArgb(dynamicScheme), + ); + expect(colorScheme.onSurface.value, MaterialDynamicColors.onSurface.getArgb(dynamicScheme)); + expect( + colorScheme.surfaceVariant.value, + MaterialDynamicColors.surfaceVariant.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSurfaceVariant.value, + MaterialDynamicColors.onSurfaceVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.outline.value, MaterialDynamicColors.outline.getArgb(dynamicScheme)); + expect( + colorScheme.outlineVariant.value, + MaterialDynamicColors.outlineVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.shadow.value, MaterialDynamicColors.shadow.getArgb(dynamicScheme)); + expect(colorScheme.scrim.value, MaterialDynamicColors.scrim.getArgb(dynamicScheme)); + expect( + colorScheme.inverseSurface.value, + MaterialDynamicColors.inverseSurface.getArgb(dynamicScheme), + ); + expect( + colorScheme.onInverseSurface.value, + MaterialDynamicColors.inverseOnSurface.getArgb(dynamicScheme), + ); + expect( + colorScheme.inversePrimary.value, + MaterialDynamicColors.inversePrimary.getArgb(dynamicScheme), + ); + } + }, + ); + + testWidgets('ColorScheme.fromSeed with different variants spot checks', ( + WidgetTester tester, + ) async { + // Default (Variant.tonalSpot). + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed(seedColor: const Color(0xFF000000)), + const Color(0xFF8C4A60), + ); + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed(seedColor: const Color(0xFF00FF00)), + const Color(0xFF406836), + ); + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed(seedColor: const Color(0xFF6559F5)), + const Color(0xFF5B5891), + ); + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed(seedColor: const Color(0xFFFFFFFF)), + const Color(0xFF006874), + ); + + // Variant.fidelity. + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed( + seedColor: const Color(0xFF000000), + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + const Color(0xFF000000), + ); + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed( + seedColor: const Color(0xFF00FF00), + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + const Color(0xFF026E00), + ); + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed( + seedColor: const Color(0xFF6559F5), + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + const Color(0xFF4C3CDB), + ); + await _testFilledButtonColor( + tester, + ColorScheme.fromSeed( + seedColor: const Color(0xFFFFFFFF), + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + const Color(0xFF5D5F5F), + ); + }); + + testWidgets('Colors in high-contrast color scheme matches colors in DynamicScheme', ( + WidgetTester tester, + ) async { + const Color seedColor = Colors.blue; + final Hct sourceColor = Hct.fromInt(seedColor.value); + + void colorsMatchDynamicSchemeColors( + DynamicSchemeVariant schemeVariant, + Brightness brightness, + double contrastLevel, + ) { + final isDark = brightness == Brightness.dark; + final DynamicScheme dynamicScheme = switch (schemeVariant) { + DynamicSchemeVariant.tonalSpot => SchemeTonalSpot( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.fidelity => SchemeFidelity( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.content => SchemeContent( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.monochrome => SchemeMonochrome( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.neutral => SchemeNeutral( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.vibrant => SchemeVibrant( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.expressive => SchemeExpressive( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.rainbow => SchemeRainbow( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + DynamicSchemeVariant.fruitSalad => SchemeFruitSalad( + sourceColorHct: sourceColor, + isDark: isDark, + contrastLevel: contrastLevel, + ), + }; + + final colorScheme = ColorScheme.fromSeed( + seedColor: seedColor, + brightness: brightness, + dynamicSchemeVariant: schemeVariant, + contrastLevel: contrastLevel, + ); + + expect(colorScheme.primary.value, MaterialDynamicColors.primary.getArgb(dynamicScheme)); + expect(colorScheme.onPrimary.value, MaterialDynamicColors.onPrimary.getArgb(dynamicScheme)); + expect( + colorScheme.primaryContainer.value, + MaterialDynamicColors.primaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onPrimaryContainer.value, + MaterialDynamicColors.onPrimaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.primaryFixed.value, + MaterialDynamicColors.primaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.primaryFixedDim.value, + MaterialDynamicColors.primaryFixedDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.onPrimaryFixed.value, + MaterialDynamicColors.onPrimaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.onPrimaryFixedVariant.value, + MaterialDynamicColors.onPrimaryFixedVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.secondary.value, MaterialDynamicColors.secondary.getArgb(dynamicScheme)); + expect( + colorScheme.onSecondary.value, + MaterialDynamicColors.onSecondary.getArgb(dynamicScheme), + ); + expect( + colorScheme.secondaryContainer.value, + MaterialDynamicColors.secondaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSecondaryContainer.value, + MaterialDynamicColors.onSecondaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.secondaryFixed.value, + MaterialDynamicColors.secondaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.secondaryFixedDim.value, + MaterialDynamicColors.secondaryFixedDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSecondaryFixed.value, + MaterialDynamicColors.onSecondaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSecondaryFixedVariant.value, + MaterialDynamicColors.onSecondaryFixedVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.tertiary.value, MaterialDynamicColors.tertiary.getArgb(dynamicScheme)); + expect(colorScheme.onTertiary.value, MaterialDynamicColors.onTertiary.getArgb(dynamicScheme)); + expect( + colorScheme.tertiaryContainer.value, + MaterialDynamicColors.tertiaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onTertiaryContainer.value, + MaterialDynamicColors.onTertiaryContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.tertiaryFixed.value, + MaterialDynamicColors.tertiaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.tertiaryFixedDim.value, + MaterialDynamicColors.tertiaryFixedDim.getArgb(dynamicScheme), + ); + expect( + colorScheme.onTertiaryFixed.value, + MaterialDynamicColors.onTertiaryFixed.getArgb(dynamicScheme), + ); + expect( + colorScheme.onTertiaryFixedVariant.value, + MaterialDynamicColors.onTertiaryFixedVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.error.value, MaterialDynamicColors.error.getArgb(dynamicScheme)); + expect(colorScheme.onError.value, MaterialDynamicColors.onError.getArgb(dynamicScheme)); + expect( + colorScheme.errorContainer.value, + MaterialDynamicColors.errorContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.onErrorContainer.value, + MaterialDynamicColors.onErrorContainer.getArgb(dynamicScheme), + ); + expect(colorScheme.background.value, MaterialDynamicColors.background.getArgb(dynamicScheme)); + expect( + colorScheme.onBackground.value, + MaterialDynamicColors.onBackground.getArgb(dynamicScheme), + ); + expect(colorScheme.surface.value, MaterialDynamicColors.surface.getArgb(dynamicScheme)); + expect(colorScheme.surfaceDim.value, MaterialDynamicColors.surfaceDim.getArgb(dynamicScheme)); + expect( + colorScheme.surfaceBright.value, + MaterialDynamicColors.surfaceBright.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerLowest.value, + MaterialDynamicColors.surfaceContainerLowest.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerLow.value, + MaterialDynamicColors.surfaceContainerLow.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainer.value, + MaterialDynamicColors.surfaceContainer.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerHigh.value, + MaterialDynamicColors.surfaceContainerHigh.getArgb(dynamicScheme), + ); + expect( + colorScheme.surfaceContainerHighest.value, + MaterialDynamicColors.surfaceContainerHighest.getArgb(dynamicScheme), + ); + expect(colorScheme.onSurface.value, MaterialDynamicColors.onSurface.getArgb(dynamicScheme)); + expect( + colorScheme.surfaceVariant.value, + MaterialDynamicColors.surfaceVariant.getArgb(dynamicScheme), + ); + expect( + colorScheme.onSurfaceVariant.value, + MaterialDynamicColors.onSurfaceVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.outline.value, MaterialDynamicColors.outline.getArgb(dynamicScheme)); + expect( + colorScheme.outlineVariant.value, + MaterialDynamicColors.outlineVariant.getArgb(dynamicScheme), + ); + expect(colorScheme.shadow.value, MaterialDynamicColors.shadow.getArgb(dynamicScheme)); + expect(colorScheme.scrim.value, MaterialDynamicColors.scrim.getArgb(dynamicScheme)); + expect( + colorScheme.inverseSurface.value, + MaterialDynamicColors.inverseSurface.getArgb(dynamicScheme), + ); + expect( + colorScheme.onInverseSurface.value, + MaterialDynamicColors.inverseOnSurface.getArgb(dynamicScheme), + ); + expect( + colorScheme.inversePrimary.value, + MaterialDynamicColors.inversePrimary.getArgb(dynamicScheme), + ); + } + + for (final DynamicSchemeVariant schemeVariant in DynamicSchemeVariant.values) { + colorsMatchDynamicSchemeColors(schemeVariant, Brightness.light, 1.0); // High contrast + colorsMatchDynamicSchemeColors(schemeVariant, Brightness.dark, 1.0); + + colorsMatchDynamicSchemeColors(schemeVariant, Brightness.light, 0.5); // Medium contrast + colorsMatchDynamicSchemeColors(schemeVariant, Brightness.dark, 0.5); + } + }); + + testWidgets('ColorScheme.of(context) is equivalent to Theme.of(context).colorScheme', ( + WidgetTester tester, + ) async { + const sizedBoxKey = Key('sizedBox'); + final colorScheme = ColorScheme.fromSeed(seedColor: Colors.red); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: const SizedBox(key: sizedBoxKey), + ), + ); + + final BuildContext context = tester.element(find.byKey(sizedBoxKey)); + final ColorScheme colorSchemeOfTheme = Theme.of(context).colorScheme; + final ColorScheme colorSchemeFromContext = ColorScheme.of(context); + + expect(colorSchemeOfTheme, colorScheme); + expect(colorSchemeFromContext, colorScheme); + }); + + testWidgets( + 'ColorScheme from an invalid network image should only throw one error', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170413 + final errors = <FlutterErrorDetails>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails error) => errors.add(error); + + await tester.pumpWidget( + const MaterialApp(home: _NetworkImageScheme(imageUrl: 'random_non_exist_image.png')), + ); + + FlutterError.onError = oldHandler; + + expect(errors.single.exception, isA<Exception>()); + expect(errors.single.exception.toString(), contains('Failed to render image:')); + + // Skip this test on Web. Testing on Web requires mocking the HTTP request + // factory (as in `_network_image_test_web.dart`) so that the HTTP + // requests can fail. The target issue is about the number of thrown + // errors, which is handled by `ColorScheme`, and testing it only on + // non-Web should be fine. + }, + skip: kIsWeb, // [intended] + ); +} + +Future<void> _testFilledButtonColor( + WidgetTester tester, + ColorScheme scheme, + Color expectation, +) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(Container()); // reset + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: scheme), + home: FilledButton(key: key, onPressed: () {}, child: const SizedBox.square(dimension: 200)), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + ); + final Material material = tester.widget<Material>(buttonMaterial); + + expect(material.color, expectation); +} + +// This widget fetches a [ColorScheme] from a network image, and displays +// its content based on the scheme's color. +class _NetworkImageScheme extends StatefulWidget { + const _NetworkImageScheme({required this.imageUrl}); + + final String imageUrl; + + @override + _NetworkImageSchemeState createState() => _NetworkImageSchemeState(); +} + +class _NetworkImageSchemeState extends State<_NetworkImageScheme> { + Color? _textColors; + + @override + void initState() { + super.initState(); + _init(); + } + + Future<void> _init() async { + try { + final ColorScheme dynamicColorScheme = await ColorScheme.fromImageProvider( + provider: NetworkImage(widget.imageUrl), + ); + setState(() { + _textColors = dynamicColorScheme.primary; + }); + } catch (e) { + FlutterError.reportError(FlutterErrorDetails(exception: e)); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Text(style: TextStyle(color: _textColors ?? Colors.black), 'Dynamic color text'), + ), + ); + } +} diff --git a/packages/material_ui/test/material/colors_test.dart b/packages/material_ui/test/material/colors_test.dart new file mode 100644 index 000000000000..57e9947338ee --- /dev/null +++ b/packages/material_ui/test/material/colors_test.dart @@ -0,0 +1,86 @@ +// 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'; + +const List<int> primaryKeys = <int>[50, 100, 200, 300, 400, 500, 600, 700, 800, 900]; +const List<int> accentKeys = <int>[100, 200, 400, 700]; + +void main() { + test('MaterialColor basic functionality', () { + const color = MaterialColor(500, <int, Color>{ + 50: Color(0x00000050), + 100: Color(0x00000100), + 200: Color(0x00000200), + 300: Color(0x00000300), + 400: Color(0x00000400), + 500: Color(0x00000500), + 600: Color(0x00000600), + 700: Color(0x00000700), + 800: Color(0x00000800), + 900: Color(0x00000900), + }); + + expect(color.value, 500); + + expect(color[50]!.value, 0x00000050); + expect(color[100]!.value, 0x00000100); + expect(color[200]!.value, 0x00000200); + expect(color[300]!.value, 0x00000300); + expect(color[400]!.value, 0x00000400); + expect(color[500]!.value, 0x00000500); + expect(color[600]!.value, 0x00000600); + expect(color[700]!.value, 0x00000700); + expect(color[800]!.value, 0x00000800); + expect(color[900]!.value, 0x00000900); + + expect(color.shade50.value, 0x00000050); + expect(color.shade100.value, 0x00000100); + expect(color.shade200.value, 0x00000200); + expect(color.shade300.value, 0x00000300); + expect(color.shade400.value, 0x00000400); + expect(color.shade500.value, 0x00000500); + expect(color.shade600.value, 0x00000600); + expect(color.shade700.value, 0x00000700); + expect(color.shade800.value, 0x00000800); + expect(color.shade900.value, 0x00000900); + }); + + test('Colors swatches do not contain duplicates', () { + for (final MaterialColor color in Colors.primaries) { + expect(primaryKeys.map<Color>((int key) => color[key]!).toSet().length, primaryKeys.length); + } + + expect( + primaryKeys.map<Color>((int key) => Colors.grey[key]!).toSet().length, + primaryKeys.length, + ); + + for (final MaterialAccentColor color in Colors.accents) { + expect(accentKeys.map<Color>((int key) => color[key]!).toSet().length, accentKeys.length); + } + }); + + test('All color swatch colors are opaque and equal their primary color', () { + for (final MaterialColor color in Colors.primaries) { + expect(color.value, color.shade500.value); + for (final int key in primaryKeys) { + expect(color[key]!.alpha, 0xFF); + } + } + + expect(Colors.grey.value, Colors.grey.shade500.value); + for (final int key in primaryKeys) { + expect(Colors.grey[key]!.alpha, 0xFF); + } + + for (final MaterialAccentColor color in Colors.accents) { + expect(color.value, color.shade200.value); + for (final int key in accentKeys) { + expect(color[key]!.alpha, 0xFF); + } + } + }); +} diff --git a/packages/material_ui/test/material/cupertino/README.md b/packages/material_ui/test/material/cupertino/README.md new file mode 100644 index 000000000000..4bf0edc918ba --- /dev/null +++ b/packages/material_ui/test/material/cupertino/README.md @@ -0,0 +1,3 @@ +# Tests for the Cupertino+Material mixed usages + +Testing interactions between Material and Cupertino is the responsibility of the Material library. diff --git a/packages/material_ui/test/material/cupertino/cupertino_app_material_theme_test.dart b/packages/material_ui/test/material/cupertino/cupertino_app_material_theme_test.dart new file mode 100644 index 000000000000..b11fdf235039 --- /dev/null +++ b/packages/material_ui/test/material/cupertino/cupertino_app_material_theme_test.dart @@ -0,0 +1,28 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoApp creates a Material theme with colors based off of Cupertino theme', ( + WidgetTester tester, + ) async { + late ThemeData appliedTheme; + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(primaryColor: CupertinoColors.activeGreen), + home: Builder( + builder: (BuildContext context) { + appliedTheme = Theme.of(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(appliedTheme.colorScheme.primary, CupertinoColors.activeGreen); + }); +} diff --git a/packages/material_ui/test/material/cupertino/cupertino_form_row_material_app_brightness_test.dart b/packages/material_ui/test/material/cupertino/cupertino_form_row_material_app_brightness_test.dart new file mode 100644 index 000000000000..97b309f3c1ca --- /dev/null +++ b/packages/material_ui/test/material/cupertino/cupertino_form_row_material_app_brightness_test.dart @@ -0,0 +1,46 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoFormRow adapts to MaterialApp dark mode', (WidgetTester tester) async { + const Widget prefix = Text('Prefix'); + const Widget helper = Text('Helper'); + + Widget buildFormRow(Brightness brightness) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: const Center( + child: CupertinoFormRow(prefix: prefix, helper: helper, child: CupertinoTextField()), + ), + ); + } + + // CupertinoFormRow with light theme. + await tester.pumpWidget(buildFormRow(Brightness.light)); + RenderParagraph helperParagraph = tester.renderObject(find.text('Helper')); + expect(helperParagraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(helperParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + RenderParagraph prefixParagraph = tester.renderObject(find.text('Prefix')); + expect(prefixParagraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(prefixParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoFormRow with light theme. + await tester.pumpWidget(buildFormRow(Brightness.dark)); + helperParagraph = tester.renderObject(find.text('Helper')); + expect(helperParagraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(helperParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + prefixParagraph = tester.renderObject(find.text('Prefix')); + expect(prefixParagraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(prefixParagraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); +} diff --git a/packages/material_ui/test/material/cupertino/cupertino_picker_material_app_brightness_test.dart b/packages/material_ui/test/material/cupertino/cupertino_picker_material_app_brightness_test.dart new file mode 100644 index 000000000000..c8f9e79d4024 --- /dev/null +++ b/packages/material_ui/test/material/cupertino/cupertino_picker_material_app_brightness_test.dart @@ -0,0 +1,99 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CupertinoPicker adapts to MaterialApp dark mode', (WidgetTester tester) async { + Widget buildCupertinoPicker(Brightness brightness) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 300.0, + child: CupertinoPicker( + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: List<Widget>.generate(3, (int index) { + return SizedBox(height: 50.0, width: 300.0, child: Text(index.toString())); + }), + ), + ), + ), + ); + } + + // CupertinoPicker with light theme. + await tester.pumpWidget(buildCupertinoPicker(Brightness.light)); + RenderParagraph paragraph = tester.renderObject(find.text('1')); + expect(paragraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoPicker with dark theme. + await tester.pumpWidget(buildCupertinoPicker(Brightness.dark)); + paragraph = tester.renderObject(find.text('1')); + expect(paragraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); + + testWidgets('CupertinoDatePicker adapts to MaterialApp dark mode', (WidgetTester tester) async { + Widget buildDatePicker(Brightness brightness) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + onDateTimeChanged: (DateTime neData) {}, + initialDateTime: DateTime(2018, 10, 10), + ), + ); + } + + // CupertinoDatePicker with light theme. + await tester.pumpWidget(buildDatePicker(Brightness.light)); + RenderParagraph paragraph = tester.renderObject(find.text('October').first); + expect(paragraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoDatePicker with dark theme. + await tester.pumpWidget(buildDatePicker(Brightness.dark)); + paragraph = tester.renderObject(find.text('October').first); + expect(paragraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); + + testWidgets('CupertinoTimerPicker adapts to MaterialApp dark mode', (WidgetTester tester) async { + Widget buildTimerPicker(Brightness brightness) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: CupertinoTimerPicker( + mode: CupertinoTimerPickerMode.hm, + onTimerDurationChanged: (Duration newDuration) {}, + initialTimerDuration: const Duration(hours: 12, minutes: 30, seconds: 59), + ), + ); + } + + // CupertinoTimerPicker with light theme. + await tester.pumpWidget(buildTimerPicker(Brightness.light)); + RenderParagraph paragraph = tester.renderObject(find.text('hours')); + expect(paragraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + + // CupertinoTimerPicker with light theme. + await tester.pumpWidget(buildTimerPicker(Brightness.dark)); + paragraph = tester.renderObject(find.text('hours')); + expect(paragraph.text.style!.color, CupertinoColors.label); + // Text style should not return unresolved color. + expect(paragraph.text.style!.color.toString().contains('UNRESOLVED'), isFalse); + }); +} diff --git a/packages/material_ui/test/material/cupertino/cupertino_sheet_material_snackbar_test.dart b/packages/material_ui/test/material/cupertino/cupertino_sheet_material_snackbar_test.dart new file mode 100644 index 000000000000..8458f314f971 --- /dev/null +++ b/packages/material_ui/test/material/cupertino/cupertino_sheet_material_snackbar_test.dart @@ -0,0 +1,99 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/163572. + testWidgets('showCupertinoSheet shows snackbar at bottom of screen', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldMessengerState>(); + + void showSheet(BuildContext context) { + showCupertinoSheet<void>( + context: context, + pageBuilder: (BuildContext context) { + return Scaffold( + body: Column( + children: <Widget>[ + const Text('Cupertino Sheet'), + CupertinoButton( + onPressed: () { + scaffoldKey.currentState?.showSnackBar( + const SnackBar(content: Text('SnackBar'), backgroundColor: Colors.red), + ); + }, + child: const Text('Show SnackBar'), + ), + ], + ), + ); + }, + ); + } + + await tester.pumpWidget( + MaterialApp( + scaffoldMessengerKey: scaffoldKey, + home: Scaffold( + body: Center( + child: Column( + children: <Widget>[ + const Text('Page 1'), + Builder( + builder: (BuildContext context) { + return CupertinoButton( + onPressed: () { + showSheet(context); + }, + child: const Text('Show Cupertino Sheet'), + ); + }, + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.text('Show Cupertino Sheet')); + await tester.pumpAndSettle(); + + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Cupertino Sheet'), matching: find.byType(Scaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Show SnackBar')); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsAtLeast(1)); + expect( + tester.getBottomLeft(find.byType(Scaffold).first).dy, + equals(tester.getBottomLeft(find.byType(SnackBar).first).dy), + ); + + final TestGesture gesture = await tester.startGesture(const Offset(200, 400)); + await tester.pump(); + expect( + tester.getBottomLeft(find.byType(Scaffold).first).dy, + equals(tester.getBottomLeft(find.byType(SnackBar).first).dy), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(find.byType(Scaffold).first).dy, + equals(tester.getBottomLeft(find.byType(SnackBar).first).dy), + ); + }); +} diff --git a/packages/material_ui/test/material/cupertino/material_app_cupertino_override_theme_test.dart b/packages/material_ui/test/material/cupertino/material_app_cupertino_override_theme_test.dart new file mode 100644 index 000000000000..285ce509f858 --- /dev/null +++ b/packages/material_ui/test/material/cupertino/material_app_cupertino_override_theme_test.dart @@ -0,0 +1,110 @@ +// 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/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // A color that depends on color vibrancy, accessibility contrast, as well as user + // interface elevation. + const dynamicColor = CupertinoDynamicColor( + color: Color(0xFF000000), + darkColor: Color(0xFF000001), + elevatedColor: Color(0xFF000002), + highContrastColor: Color(0xFF000003), + darkElevatedColor: Color(0xFF000004), + darkHighContrastColor: Color(0xFF000005), + highContrastElevatedColor: Color(0xFF000006), + darkHighContrastElevatedColor: Color(0xFF000007), + ); + + testWidgets('dynamic color works in cupertino override theme in MaterialApp', ( + WidgetTester tester, + ) async { + Color? color; + + CupertinoDynamicColor typedColor() => color! as CupertinoDynamicColor; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + brightness: Brightness.dark, + primaryColor: dynamicColor, + ), + ), + home: MediaQuery( + data: const MediaQueryData(), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.base, + child: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor; + return const Placeholder(); + }, + ), + ), + ), + ), + ); + + // Explicit brightness is respected. + expect(typedColor().value, dynamicColor.darkColor.value); + color = null; + + // Changing dependencies works. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + brightness: Brightness.dark, + primaryColor: dynamicColor, + ), + ), + home: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark, highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor; + return const Placeholder(); + }, + ), + ), + ), + ), + ); + + expect(typedColor().value, dynamicColor.darkHighContrastElevatedColor.value); + }); + + testWidgets('dynamic color does not work in a material theme', (WidgetTester tester) async { + Color? color; + + await tester.pumpWidget( + MaterialApp( + // This will create a MaterialBasedCupertinoThemeData with primaryColor set to `dynamicColor`. + theme: ThemeData(colorScheme: const ColorScheme.dark(primary: dynamicColor)), + home: MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark, highContrast: true), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: Builder( + builder: (BuildContext context) { + color = CupertinoTheme.of(context).primaryColor; + return const Placeholder(); + }, + ), + ), + ), + ), + ); + + // The color is not resolved. + expect(color, dynamicColor); + expect(color, isNot(dynamicColor.darkHighContrastElevatedColor)); + }); +} diff --git a/packages/material_ui/test/material/data_table_test.dart b/packages/material_ui/test/material/data_table_test.dart new file mode 100644 index 000000000000..d87b100d1900 --- /dev/null +++ b/packages/material_ui/test/material/data_table_test.dart @@ -0,0 +1,2443 @@ +// 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. + +@TestOn('!chrome') +library; + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix3; + +import '../widgets/semantics_tester.dart'; +import 'data_table_test_utils.dart'; + +void main() { + testWidgets('DataTable control test', (WidgetTester tester) async { + final log = <String>[]; + + Widget buildTable({int? sortColumnIndex, bool sortAscending = true}) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + onSelectAll: (bool? value) { + log.add('select-all: $value'); + }, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) { + log.add('column-sort: $columnIndex $ascending'); + }, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) { + log.add('row-selected: ${dessert.name}'); + }, + onLongPress: () { + log.add('onLongPress: ${dessert.name}'); + }, + onHover: (bool hovering) { + if (hovering) { + log.add('onHover: ${dessert.name}'); + } + }, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell( + Text('${dessert.calories}'), + showEditIcon: true, + onTap: () { + log.add('cell-tap: ${dessert.calories}'); + }, + onDoubleTap: () { + log.add('cell-doubleTap: ${dessert.calories}'); + }, + onLongPress: () { + log.add('cell-longPress: ${dessert.calories}'); + }, + onTapCancel: () { + log.add('cell-tapCancel: ${dessert.calories}'); + }, + onTapDown: (TapDownDetails details) { + log.add('cell-tapDown: ${dessert.calories}'); + }, + ), + ], + ); + }).toList(), + ); + } + + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + + await tester.tap(find.byType(Checkbox).first); + + expect(log, <String>['select-all: true']); + log.clear(); + + await tester.tap(find.text('Cupcake')); + + expect(log, <String>['row-selected: Cupcake']); + log.clear(); + + await tester.longPress(find.text('Cupcake')); + + expect(log, <String>['onLongPress: Cupcake']); + log.clear(); + + TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.text('Cupcake'))); + + expect(log, <String>['onHover: Cupcake']); + log.clear(); + + await tester.tap(find.text('Calories')); + + expect(log, <String>['column-sort: 1 true']); + log.clear(); + + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable(sortColumnIndex: 1)))); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + await tester.tap(find.text('Calories')); + + expect(log, <String>['column-sort: 1 false']); + log.clear(); + + await tester.pumpWidget( + MaterialApp(home: Material(child: buildTable(sortColumnIndex: 1, sortAscending: false))), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + await tester.tap(find.text('375')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.text('375')); + + expect(log, <String>['cell-doubleTap: 375']); + log.clear(); + + await tester.longPress(find.text('375')); + // The tap down is triggered on gesture down. + // Then, the cancel is triggered when the gesture arena + // recognizes that the long press overrides the tap event + // so it triggers a tap cancel, followed by the long press. + expect(log, <String>['cell-tapDown: 375', 'cell-tapCancel: 375', 'cell-longPress: 375']); + log.clear(); + + gesture = await tester.startGesture(tester.getRect(find.text('375')).center); + await tester.pump(const Duration(milliseconds: 100)); + // onTapDown callback is registered. + expect(log, equals(<String>['cell-tapDown: 375'])); + await gesture.up(); + + await tester.pump(const Duration(seconds: 1)); + // onTap callback is registered after the gesture is removed. + expect(log, equals(<String>['cell-tapDown: 375', 'cell-tap: 375'])); + log.clear(); + + // dragging off the bounds of the cell calls the cancel callback + gesture = await tester.startGesture(tester.getRect(find.text('375')).center); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveBy(const Offset(0.0, 200.0)); + await gesture.cancel(); + expect(log, equals(<String>['cell-tapDown: 375', 'cell-tapCancel: 375'])); + + log.clear(); + + await tester.tap(find.byType(Checkbox).last); + + expect(log, <String>['row-selected: KitKat']); + log.clear(); + }); + + testWidgets('DataTable control test - tristate', (WidgetTester tester) async { + final log = <String>[]; + const numItems = 3; + Widget buildTable(List<bool> selected, {int? disabledIndex}) { + return DataTable( + onSelectAll: (bool? value) { + log.add('select-all: $value'); + }, + columns: const <DataColumn>[DataColumn(label: Text('Name'), tooltip: 'Name')], + rows: List<DataRow>.generate( + numItems, + (int index) => DataRow( + cells: <DataCell>[DataCell(Text('Row $index'))], + selected: selected[index], + onSelectChanged: index == disabledIndex + ? null + : (bool? value) { + log.add('row-selected: $index'); + }, + ), + ), + ); + } + + // Tapping the parent checkbox when no rows are selected, selects all. + await tester.pumpWidget( + MaterialApp(home: Material(child: buildTable(<bool>[false, false, false]))), + ); + await tester.tap(find.byType(Checkbox).first); + + expect(log, <String>['select-all: true']); + log.clear(); + + // Tapping the parent checkbox when some rows are selected, selects all. + await tester.pumpWidget( + MaterialApp(home: Material(child: buildTable(<bool>[true, false, true]))), + ); + await tester.tap(find.byType(Checkbox).first); + + expect(log, <String>['select-all: true']); + log.clear(); + + // Tapping the parent checkbox when all rows are selected, deselects all. + await tester.pumpWidget( + MaterialApp(home: Material(child: buildTable(<bool>[true, true, true]))), + ); + await tester.tap(find.byType(Checkbox).first); + + expect(log, <String>['select-all: false']); + log.clear(); + + // Tapping the parent checkbox when all rows are selected and one is + // disabled, deselects all. + await tester.pumpWidget( + MaterialApp(home: Material(child: buildTable(<bool>[true, true, false], disabledIndex: 2))), + ); + await tester.tap(find.byType(Checkbox).first); + + expect(log, <String>['select-all: false']); + log.clear(); + }); + + testWidgets('DataTable control test - no checkboxes', (WidgetTester tester) async { + final log = <String>[]; + + Widget buildTable({bool checkboxes = false}) { + return DataTable( + showCheckboxColumn: checkboxes, + onSelectAll: (bool? value) { + log.add('select-all: $value'); + }, + columns: const <DataColumn>[ + DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn(label: Text('Calories'), tooltip: 'Calories', numeric: true), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) { + log.add('row-selected: ${dessert.name}'); + }, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell( + Text('${dessert.calories}'), + showEditIcon: true, + onTap: () { + log.add('cell-tap: ${dessert.calories}'); + }, + ), + ], + ); + }).toList(), + ); + } + + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + + expect(find.byType(Checkbox), findsNothing); + await tester.tap(find.text('Cupcake')); + + expect(log, <String>['row-selected: Cupcake']); + log.clear(); + + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable(checkboxes: true)))); + + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + final Finder checkboxes = find.byType(Checkbox); + expect(checkboxes, findsNWidgets(11)); + await tester.tap(checkboxes.first); + + expect(log, <String>['select-all: true']); + log.clear(); + }); + + testWidgets('DataTable overflow test - header', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + headingTextStyle: const TextStyle( + fontSize: 14.0, + letterSpacing: 0.0, // Will overflow if letter spacing is larger than 0.0. + ), + columns: <DataColumn>[DataColumn(label: Text('X' * 2000))], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('X'))]), + ], + ), + ), + ), + ); + + expect(tester.renderObject<RenderBox>(find.byType(Text).first).size.width, greaterThan(800.0)); + expect(tester.renderObject<RenderBox>(find.byType(Row).first).size.width, greaterThan(800.0)); + expect( + tester.takeException(), + isNull, + ); // column overflows table, but text doesn't overflow cell + }); + + testWidgets('DataTable overflow test - header with spaces', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + columns: <DataColumn>[ + DataColumn( + label: Text('X ' * 2000), // has soft wrap points, but they should be ignored + ), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('X'))]), + ], + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byType(Text).first).size.width, greaterThan(800.0)); + expect(tester.renderObject<RenderBox>(find.byType(Row).first).size.width, greaterThan(800.0)); + expect( + tester.takeException(), + isNull, + ); // column overflows table, but text doesn't overflow cell + }, skip: true); // https://github.com/flutter/flutter/issues/13512 + + testWidgets('DataTable overflow test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('X'))], + rows: <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('X' * 2000))]), + ], + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byType(Text).first).size.width, lessThan(800.0)); + expect(tester.renderObject<RenderBox>(find.byType(Row).first).size.width, greaterThan(800.0)); + expect(tester.takeException(), isNull); // cell overflows table, but text doesn't overflow cell + }); + + testWidgets('DataTable overflow test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('X'))], + rows: <DataRow>[ + DataRow( + cells: <DataCell>[ + DataCell( + Text('X ' * 2000), // wraps + ), + ], + ), + ], + ), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byType(Text).first).size.width, lessThan(800.0)); + expect(tester.renderObject<RenderBox>(find.byType(Row).first).size.width, lessThan(800.0)); + expect(tester.takeException(), isNull); + }); + + testWidgets('DataTable column onSort test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Dessert'))], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[ + DataCell( + Text('Lollipop'), // wraps + ), + ], + ), + ], + ), + ), + ), + ); + await tester.tap(find.text('Dessert')); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('DataTable sort indicator orientation', (WidgetTester tester) async { + Widget buildTable({bool sortAscending = true}) { + return DataTable( + sortColumnIndex: 0, + sortAscending: sortAscending, + columns: <DataColumn>[ + DataColumn( + label: const Text('Name'), + tooltip: 'Name', + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow(cells: <DataCell>[DataCell(Text(dessert.name))]); + }).toList(), + ); + } + + // Check for ascending list + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + final Finder iconFinder = find.descendant( + of: find.byType(DataTable), + matching: find.widgetWithIcon(Transform, Icons.arrow_upward), + ); + // The `tester.widget` ensures that there is exactly one upward arrow. + Transform transformOfArrow = tester.widget<Transform>(iconFinder); + expect(transformOfArrow.transform.getRotation(), equals(Matrix3.identity())); + + // Check for descending list. + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable(sortAscending: false)))); + await tester.pumpAndSettle(); + // The `tester.widget` ensures that there is exactly one upward arrow. + transformOfArrow = tester.widget<Transform>(iconFinder); + expect(transformOfArrow.transform.getRotation(), equals(Matrix3.rotationZ(math.pi))); + }); + + testWidgets('DataTable sort indicator orientation does not change on state update', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/43724 + Widget buildTable({String title = 'Name1'}) { + return DataTable( + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn( + label: Text(title), + tooltip: 'Name', + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow(cells: <DataCell>[DataCell(Text(dessert.name))]); + }).toList(), + ); + } + + // Check for ascending list + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + final Finder iconFinder = find.descendant( + of: find.byType(DataTable), + matching: find.widgetWithIcon(Transform, Icons.arrow_upward), + ); + // The `tester.widget` ensures that there is exactly one upward arrow. + Transform transformOfArrow = tester.widget<Transform>(iconFinder); + expect(transformOfArrow.transform.getRotation(), equals(Matrix3.identity())); + + // Cause a rebuild by updating the widget + await tester.pumpWidget( + MaterialApp( + home: Material(child: buildTable(title: 'Name2')), + ), + ); + await tester.pumpAndSettle(); + // The `tester.widget` ensures that there is exactly one upward arrow. + transformOfArrow = tester.widget<Transform>(iconFinder); + expect( + transformOfArrow.transform.getRotation(), + equals(Matrix3.identity()), // Should not have changed + ); + }); + + testWidgets('DataTable sort indicator orientation does not change on state update - reverse', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/43724 + Widget buildTable({String title = 'Name1'}) { + return DataTable( + sortColumnIndex: 0, + sortAscending: false, + columns: <DataColumn>[ + DataColumn( + label: Text(title), + tooltip: 'Name', + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow(cells: <DataCell>[DataCell(Text(dessert.name))]); + }).toList(), + ); + } + + // Check for ascending list + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + final Finder iconFinder = find.descendant( + of: find.byType(DataTable), + matching: find.widgetWithIcon(Transform, Icons.arrow_upward), + ); + // The `tester.widget` ensures that there is exactly one upward arrow. + Transform transformOfArrow = tester.widget<Transform>(iconFinder); + expect(transformOfArrow.transform.getRotation(), equals(Matrix3.rotationZ(math.pi))); + + // Cause a rebuild by updating the widget + await tester.pumpWidget( + MaterialApp( + home: Material(child: buildTable(title: 'Name2')), + ), + ); + await tester.pumpAndSettle(); + // The `tester.widget` ensures that there is exactly one upward arrow. + transformOfArrow = tester.widget<Transform>(iconFinder); + expect( + transformOfArrow.transform.getRotation(), + equals(Matrix3.rotationZ(math.pi)), // Should not have changed + ); + }); + + testWidgets('DataTable row onSelectChanged test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Dessert'))], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[ + DataCell( + Text('Lollipop'), // wraps + ), + ], + ), + ], + ), + ), + ), + ); + await tester.tap(find.text('Lollipop')); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('DataTable custom row height', (WidgetTester tester) async { + Widget buildCustomTable({ + int? sortColumnIndex, + bool sortAscending = true, + double? dataRowMinHeight, + double? dataRowMaxHeight, + double headingRowHeight = 56.0, + }) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + onSelectAll: (bool? value) {}, + dataRowMinHeight: dataRowMinHeight, + dataRowMaxHeight: dataRowMaxHeight, + headingRowHeight: headingRowHeight, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) {}, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + // DEFAULT VALUES + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + onSelectAll: (bool? value) {}, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) {}, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ), + ), + ), + ); + + // The finder matches with the Container of the cell content, as well as the + // Container wrapping the whole table. The first one is used to test row + // heights. + Finder findFirstContainerFor(String text) => find.widgetWithText(Container, text).first; + + expect(tester.getSize(findFirstContainerFor('Name')).height, 56.0); + expect(tester.getSize(findFirstContainerFor('Frozen yogurt')).height, kMinInteractiveDimension); + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomTable(headingRowHeight: 48.0))), + ); + expect(tester.getSize(findFirstContainerFor('Name')).height, 48.0); + + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomTable(headingRowHeight: 64.0))), + ); + expect(tester.getSize(findFirstContainerFor('Name')).height, 64.0); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: buildCustomTable(dataRowMinHeight: 30.0, dataRowMaxHeight: 30.0)), + ), + ); + expect(tester.getSize(findFirstContainerFor('Frozen yogurt')).height, 30.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: buildCustomTable(dataRowMinHeight: 0.0, dataRowMaxHeight: double.infinity), + ), + ), + ); + expect(tester.getSize(findFirstContainerFor('Frozen yogurt')).height, greaterThan(0.0)); + }); + + testWidgets('DataTable custom row height one row taller than others', ( + WidgetTester tester, + ) async { + const multilineText = 'Line one.\nLine two.\nLine three.\nLine four.'; + + Widget buildCustomTable({double? dataRowMinHeight, double? dataRowMaxHeight}) { + return DataTable( + dataRowMinHeight: dataRowMinHeight, + dataRowMaxHeight: dataRowMaxHeight, + columns: const <DataColumn>[ + DataColumn(label: Text('SingleRowColumn')), + DataColumn(label: Text('MultiRowColumn')), + ], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[ + DataCell(Text('Data')), + DataCell(Column(children: <Widget>[Text(multilineText)])), + ], + ), + ], + ); + } + + Finder findFirstContainerFor(String text) => find.widgetWithText(Container, text).first; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: buildCustomTable(dataRowMinHeight: 0.0, dataRowMaxHeight: double.infinity), + ), + ), + ); + + final double singleLineRowHeight = tester.getSize(findFirstContainerFor('Data')).height; + final double multilineRowHeight = tester.getSize(findFirstContainerFor(multilineText)).height; + + expect(multilineRowHeight, greaterThan(singleLineRowHeight)); + }); + + testWidgets('DataTable custom row height - separate test for deprecated dataRowHeight', ( + WidgetTester tester, + ) async { + Widget buildCustomTable({double dataRowHeight = 48.0}) { + return DataTable( + onSelectAll: (bool? value) {}, + dataRowHeight: dataRowHeight, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) {}, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + // The finder matches with the Container of the cell content, as well as the + // Container wrapping the whole table. The first one is used to test row + // heights. + Finder findFirstContainerFor(String text) => find.widgetWithText(Container, text).first; + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomTable(dataRowHeight: 30.0))), + ); + expect(tester.getSize(findFirstContainerFor('Frozen yogurt')).height, 30.0); + }); + + testWidgets('DataTable custom horizontal padding - checkbox', (WidgetTester tester) async { + const defaultHorizontalMargin = 24.0; + const defaultColumnSpacing = 56.0; + const customHorizontalMargin = 10.0; + const customColumnSpacing = 15.0; + Finder cellContent; + Finder checkbox; + Finder padding; + + Widget buildDefaultTable({int? sortColumnIndex, bool sortAscending = true}) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + onSelectAll: (bool? value) {}, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + DataColumn( + label: const Text('Fat'), + tooltip: 'Fat', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) {}, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + DataCell(Text('${dessert.fat}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + // DEFAULT VALUES + await tester.pumpWidget(MaterialApp(home: Material(child: buildDefaultTable()))); + + // default checkbox padding + checkbox = find.byType(Checkbox).first; + padding = find.ancestor(of: checkbox, matching: find.byType(Padding)); + expect(tester.getRect(checkbox).left - tester.getRect(padding).left, defaultHorizontalMargin); + expect( + tester.getRect(padding).right - tester.getRect(checkbox).right, + defaultHorizontalMargin / 2, + ); + + // default first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt'); + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt', + ); // DataTable wraps its DataCells in an Align widget + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultHorizontalMargin / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default middle column padding + padding = find.widgetWithText(Padding, '159'); + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default last column padding + padding = find.widgetWithText(Padding, '6.0'); + cellContent = find.widgetWithText(Align, '6.0'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultHorizontalMargin, + ); + + Widget buildCustomTable({ + int? sortColumnIndex, + bool sortAscending = true, + double? horizontalMargin, + double? columnSpacing, + }) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + onSelectAll: (bool? value) {}, + horizontalMargin: horizontalMargin, + columnSpacing: columnSpacing, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + DataColumn( + label: const Text('Fat'), + tooltip: 'Fat', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) {}, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + DataCell(Text('${dessert.fat}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp( + home: Material( + child: buildCustomTable( + horizontalMargin: customHorizontalMargin, + columnSpacing: customColumnSpacing, + ), + ), + ), + ); + + // custom checkbox padding + checkbox = find.byType(Checkbox).first; + padding = find.ancestor(of: checkbox, matching: find.byType(Padding)); + expect(tester.getRect(checkbox).left - tester.getRect(padding).left, customHorizontalMargin); + expect( + tester.getRect(padding).right - tester.getRect(checkbox).right, + customHorizontalMargin / 2, + ); + + // custom first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt').first; + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt', + ); // DataTable wraps its DataCells in an Align widget + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customHorizontalMargin / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom middle column padding + padding = find.widgetWithText(Padding, '159'); + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom last column padding + padding = find.widgetWithText(Padding, '6.0'); + cellContent = find.widgetWithText(Align, '6.0'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customHorizontalMargin, + ); + }); + + testWidgets('DataTable custom horizontal padding - no checkbox', (WidgetTester tester) async { + const defaultHorizontalMargin = 24.0; + const defaultColumnSpacing = 56.0; + const customHorizontalMargin = 10.0; + const customColumnSpacing = 15.0; + Finder cellContent; + Finder padding; + + Widget buildDefaultTable({int? sortColumnIndex, bool sortAscending = true}) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + DataColumn( + label: const Text('Fat'), + tooltip: 'Fat', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + DataCell(Text('${dessert.fat}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + // DEFAULT VALUES + await tester.pumpWidget(MaterialApp(home: Material(child: buildDefaultTable()))); + + // default first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt'); + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt', + ); // DataTable wraps its DataCells in an Align widget + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultHorizontalMargin, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default middle column padding + padding = find.widgetWithText(Padding, '159'); + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default last column padding + padding = find.widgetWithText(Padding, '6.0'); + cellContent = find.widgetWithText(Align, '6.0'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultHorizontalMargin, + ); + + Widget buildCustomTable({ + int? sortColumnIndex, + bool sortAscending = true, + double? horizontalMargin, + double? columnSpacing, + }) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + horizontalMargin: horizontalMargin, + columnSpacing: columnSpacing, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + DataColumn( + label: const Text('Fat'), + tooltip: 'Fat', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + DataCell(Text('${dessert.fat}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp( + home: Material( + child: buildCustomTable( + horizontalMargin: customHorizontalMargin, + columnSpacing: customColumnSpacing, + ), + ), + ), + ); + + // custom first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt'); + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt', + ); // DataTable wraps its DataCells in an Align widget + expect(tester.getRect(cellContent).left - tester.getRect(padding).left, customHorizontalMargin); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom middle column padding + padding = find.widgetWithText(Padding, '159'); + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom last column padding + padding = find.widgetWithText(Padding, '6.0'); + cellContent = find.widgetWithText(Align, '6.0'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customHorizontalMargin, + ); + }); + + testWidgets('DataTable set border width test', (WidgetTester tester) async { + const columns = <DataColumn>[ + DataColumn(label: Text('column1')), + DataColumn(label: Text('column2')), + ]; + + const cells = <DataCell>[DataCell(Text('cell1')), DataCell(Text('cell2'))]; + + const rows = <DataRow>[DataRow(cells: cells), DataRow(cells: cells)]; + + // no thickness provided - border should be default: i.e "1.0" as it + // set in DataTable constructor + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable(columns: columns, rows: rows), + ), + ), + ); + + Table table = tester.widget(find.byType(Table)); + TableRow tableRow = table.children.last; + var boxDecoration = tableRow.decoration! as BoxDecoration; + expect(boxDecoration.border!.top.width, 1.0); + + const thickness = 4.2; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable(dividerThickness: thickness, columns: columns, rows: rows), + ), + ), + ); + table = tester.widget(find.byType(Table)); + tableRow = table.children.last; + boxDecoration = tableRow.decoration! as BoxDecoration; + expect(boxDecoration.border!.top.width, thickness); + }); + + testWidgets('DataTable set show bottom border', (WidgetTester tester) async { + const columns = <DataColumn>[ + DataColumn(label: Text('column1')), + DataColumn(label: Text('column2')), + ]; + + const cells = <DataCell>[DataCell(Text('cell1')), DataCell(Text('cell2'))]; + + const rows = <DataRow>[DataRow(cells: cells), DataRow(cells: cells)]; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable(showBottomBorder: true, columns: columns, rows: rows), + ), + ), + ); + + Table table = tester.widget(find.byType(Table)); + TableRow tableRow = table.children.last; + var boxDecoration = tableRow.decoration! as BoxDecoration; + expect(boxDecoration.border!.bottom.width, 1.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable(columns: columns, rows: rows), + ), + ), + ); + table = tester.widget(find.byType(Table)); + tableRow = table.children.last; + boxDecoration = tableRow.decoration! as BoxDecoration; + expect(boxDecoration.border!.bottom.width, 0.0); + }); + + testWidgets('DataTable column heading cell - with and without sorting', ( + WidgetTester tester, + ) async { + Widget buildTable({int? sortColumnIndex, bool sortEnabled = true}) { + return DataTable( + sortColumnIndex: sortColumnIndex, + columns: <DataColumn>[ + DataColumn( + label: const Expanded(child: Center(child: Text('Name'))), + tooltip: 'Name', + onSort: sortEnabled ? (_, _) {} : null, + ), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('A long desert name'))]), + ], + ); + } + + // Start with without sorting + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable(sortEnabled: false)))); + + { + final Finder nameText = find.text('Name'); + expect(nameText, findsOneWidget); + final Finder nameCell = find + .ancestor(of: find.text('Name'), matching: find.byType(Container)) + .first; + expect(tester.getCenter(nameText), equals(tester.getCenter(nameCell))); + expect(find.descendant(of: nameCell, matching: find.byType(Icon)), findsNothing); + } + + // Turn on sorting + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + + { + final Finder nameText = find.text('Name'); + expect(nameText, findsOneWidget); + final Finder nameCell = find + .ancestor(of: find.text('Name'), matching: find.byType(Container)) + .first; + expect(find.descendant(of: nameCell, matching: find.byType(Icon)), findsOneWidget); + } + + // Turn off sorting again + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable(sortEnabled: false)))); + + { + final Finder nameText = find.text('Name'); + expect(nameText, findsOneWidget); + final Finder nameCell = find + .ancestor(of: find.text('Name'), matching: find.byType(Container)) + .first; + expect(tester.getCenter(nameText), equals(tester.getCenter(nameCell))); + expect(find.descendant(of: nameCell, matching: find.byType(Icon)), findsNothing); + } + }); + + testWidgets('DataTable correctly renders with a mouse', (WidgetTester tester) async { + // Regression test for a bug described in + // https://github.com/flutter/flutter/pull/43735#issuecomment-589459947 + // Filed at https://github.com/flutter/flutter/issues/51152 + Widget buildTable({int? sortColumnIndex}) { + return DataTable( + sortColumnIndex: sortColumnIndex, + columns: <DataColumn>[ + const DataColumn( + label: Expanded(child: Center(child: Text('column1'))), + tooltip: 'Column1', + ), + DataColumn( + label: const Expanded(child: Center(child: Text('column2'))), + tooltip: 'Column2', + onSort: (_, _) {}, + ), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Content1')), DataCell(Text('Content2'))]), + ], + ); + } + + await tester.pumpWidget(MaterialApp(home: Material(child: buildTable()))); + + expect(tester.renderObject(find.text('column1')).attached, true); + expect(tester.renderObject(find.text('column2')).attached, true); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + + await tester.pumpAndSettle(); + expect(tester.renderObject(find.text('column1')).attached, true); + expect(tester.renderObject(find.text('column2')).attached, true); + + // Wait for the tooltip timer to expire to prevent it scheduling a new frame + // after the view is destroyed, which causes exceptions. + await tester.pumpAndSettle(const Duration(seconds: 1)); + }); + + testWidgets('DataRow renders default selected row colors', (WidgetTester tester) async { + final themeData = ThemeData(); + Widget buildTable({bool selected = false}) { + return MaterialApp( + theme: themeData, + home: Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + onSelectChanged: (bool? checked) {}, + selected: selected, + cells: const <DataCell>[DataCell(Text('Content1'))], + ), + ], + ), + ), + ); + } + + BoxDecoration lastTableRowBoxDecoration() { + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children.last; + return tableRow.decoration! as BoxDecoration; + } + + await tester.pumpWidget(buildTable()); + expect(lastTableRowBoxDecoration().color, null); + + await tester.pumpWidget(buildTable(selected: true)); + expect(lastTableRowBoxDecoration().color, themeData.colorScheme.primary.withOpacity(0.08)); + }); + + testWidgets('DataRow renders checkbox with colors from CheckboxTheme', ( + WidgetTester tester, + ) async { + const fillColor = Color(0xFF00FF00); + const checkColor = Color(0xFF0000FF); + + final themeData = ThemeData( + checkboxTheme: const CheckboxThemeData( + fillColor: MaterialStatePropertyAll<Color?>(fillColor), + checkColor: MaterialStatePropertyAll<Color?>(checkColor), + ), + ); + Widget buildTable() { + return MaterialApp( + theme: themeData, + home: Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + selected: true, + onSelectChanged: (bool? checked) {}, + cells: const <DataCell>[DataCell(Text('Content1'))], + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildTable()); + + expect( + Material.of(tester.element(find.byType(Checkbox).last)), + paints + ..rrect(color: fillColor) + ..path(color: checkColor), + ); + }); + + testWidgets('DataRow renders custom colors when selected', (WidgetTester tester) async { + const Color selectedColor = Colors.green; + const Color defaultColor = Colors.red; + + Widget buildTable({bool selected = false}) { + return Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + selected: selected, + color: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + }), + cells: const <DataCell>[DataCell(Text('Content1'))], + ), + ], + ), + ); + } + + BoxDecoration lastTableRowBoxDecoration() { + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children.last; + return tableRow.decoration! as BoxDecoration; + } + + await tester.pumpWidget(MaterialApp(home: buildTable())); + expect(lastTableRowBoxDecoration().color, defaultColor); + + await tester.pumpWidget(MaterialApp(home: buildTable(selected: true))); + expect(lastTableRowBoxDecoration().color, selectedColor); + }); + + testWidgets('DataRow renders custom colors when disabled', (WidgetTester tester) async { + const Color disabledColor = Colors.grey; + const Color defaultColor = Colors.red; + + Widget buildTable({bool disabled = false}) { + return Material( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + cells: const <DataCell>[DataCell(Text('Content1'))], + onSelectChanged: (bool? value) {}, + ), + DataRow( + color: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + return defaultColor; + }), + cells: const <DataCell>[DataCell(Text('Content2'))], + onSelectChanged: disabled ? null : (bool? value) {}, + ), + ], + ), + ); + } + + BoxDecoration lastTableRowBoxDecoration() { + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children.last; + return tableRow.decoration! as BoxDecoration; + } + + await tester.pumpWidget(MaterialApp(home: buildTable())); + expect(lastTableRowBoxDecoration().color, defaultColor); + + await tester.pumpWidget(MaterialApp(home: buildTable(disabled: true))); + expect(lastTableRowBoxDecoration().color, disabledColor); + }); + + testWidgets('Material2 - DataRow renders custom colors when pressed', ( + WidgetTester tester, + ) async { + const pressedColor = Color(0xff4caf50); + Widget buildTable() { + return DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + color: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + return Colors.transparent; + }), + onSelectChanged: (bool? value) {}, + cells: const <DataCell>[DataCell(Text('Content1'))], + ), + ], + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material(child: buildTable()), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Content1'))); + await tester.pump(const Duration(milliseconds: 200)); // splash is well underway + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect(box, paints..circle(x: 68.0, y: 24.0, color: pressedColor)); + await gesture.up(); + }); + + testWidgets('Material3 - DataRow renders custom colors when pressed', ( + WidgetTester tester, + ) async { + const pressedColor = Color(0xff4caf50); + Widget buildTable() { + return DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + color: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + return Colors.transparent; + }), + onSelectChanged: (bool? value) {}, + cells: const <DataCell>[DataCell(Text('Content1'))], + ), + ], + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Material(child: buildTable()), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Content1'))); + await tester.pump(const Duration(milliseconds: 200)); // splash is well underway + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. + expect( + box, + paints + ..rect() + ..rect( + rect: const Rect.fromLTRB(0.0, 56.0, 800.0, 104.0), + color: pressedColor.withOpacity(0.0), + ), + ); + await gesture.up(); + }); + + testWidgets('DataTable can render inside an AlertDialog', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: AlertDialog( + content: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Col1'))], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('1'))]), + ], + ), + scrollable: true, + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('DataTable renders with border and background decoration', ( + WidgetTester tester, + ) async { + const double width = 800; + const double height = 600; + const borderHorizontal = 5.0; + const borderVertical = 10.0; + const borderColor = Color(0xff2196f3); + const backgroundColor = Color(0xfff5f5f5); + + await tester.pumpWidget( + MaterialApp( + home: DataTable( + decoration: const BoxDecoration( + color: backgroundColor, + border: Border.symmetric( + vertical: BorderSide(width: borderVertical, color: borderColor), + horizontal: BorderSide(width: borderHorizontal, color: borderColor), + ), + ), + columns: const <DataColumn>[DataColumn(label: Text('Col1'))], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('1'))]), + ], + ), + ), + ); + + expect( + find.ancestor(of: find.byType(Table), matching: find.byType(Container)), + paints..rect( + rect: const Rect.fromLTRB( + borderVertical / 2, + borderHorizontal / 2, + width - borderVertical / 2, + height - borderHorizontal / 2, + ), + color: backgroundColor, + ), + ); + expect( + find.ancestor(of: find.byType(Table), matching: find.byType(Container)), + paints..path(color: borderColor), + ); + expect(tester.getTopLeft(find.byType(Table)), const Offset(borderVertical, borderHorizontal)); + expect( + tester.getBottomRight(find.byType(Table)), + const Offset(width - borderVertical, height - borderHorizontal), + ); + }); + + testWidgets('checkboxHorizontalMargin properly applied', (WidgetTester tester) async { + const customCheckboxHorizontalMargin = 15.0; + const customHorizontalMargin = 10.0; + Finder cellContent; + Finder checkbox; + Finder padding; + + Widget buildCustomTable({ + int? sortColumnIndex, + bool sortAscending = true, + double? horizontalMargin, + double? checkboxHorizontalMargin, + }) { + return DataTable( + sortColumnIndex: sortColumnIndex, + sortAscending: sortAscending, + onSelectAll: (bool? value) {}, + horizontalMargin: horizontalMargin, + checkboxHorizontalMargin: checkboxHorizontalMargin, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + DataColumn( + label: const Text('Fat'), + tooltip: 'Fat', + numeric: true, + onSort: (int columnIndex, bool ascending) {}, + ), + ], + rows: kDesserts.map<DataRow>((Dessert dessert) { + return DataRow( + key: ValueKey<String>(dessert.name), + onSelectChanged: (bool? selected) {}, + cells: <DataCell>[ + DataCell(Text(dessert.name)), + DataCell(Text('${dessert.calories}'), showEditIcon: true, onTap: () {}), + DataCell(Text('${dessert.fat}'), showEditIcon: true, onTap: () {}), + ], + ); + }).toList(), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: buildCustomTable( + checkboxHorizontalMargin: customCheckboxHorizontalMargin, + horizontalMargin: customHorizontalMargin, + ), + ), + ), + ); + + // Custom checkbox padding. + checkbox = find.byType(Checkbox).first; + padding = find.ancestor(of: checkbox, matching: find.byType(Padding)); + expect( + tester.getRect(checkbox).left - tester.getRect(padding).left, + customCheckboxHorizontalMargin, + ); + expect( + tester.getRect(padding).right - tester.getRect(checkbox).right, + customCheckboxHorizontalMargin, + ); + + // First column padding. + padding = find.widgetWithText(Padding, 'Frozen yogurt').first; + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt', + ); // DataTable wraps its DataCells in an Align widget. + expect(tester.getRect(cellContent).left - tester.getRect(padding).left, customHorizontalMargin); + }); + + testWidgets('DataRow is disabled when onSelectChanged is not set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + columns: const <DataColumn>[ + DataColumn(label: Text('Col1')), + DataColumn(label: Text('Col2')), + ], + rows: <DataRow>[ + DataRow( + cells: const <DataCell>[DataCell(Text('Hello')), DataCell(Text('world'))], + onSelectChanged: (bool? value) {}, + ), + const DataRow(cells: <DataCell>[DataCell(Text('Bug')), DataCell(Text('report'))]), + const DataRow(cells: <DataCell>[DataCell(Text('GitHub')), DataCell(Text('issue'))]), + ], + ), + ), + ), + ); + + expect(find.widgetWithText(TableRowInkWell, 'Hello'), findsOneWidget); + expect(find.widgetWithText(TableRowInkWell, 'Bug'), findsNothing); + expect(find.widgetWithText(TableRowInkWell, 'GitHub'), findsNothing); + }); + + testWidgets('DataTable set interior border test', (WidgetTester tester) async { + const columns = <DataColumn>[ + DataColumn(label: Text('column1')), + DataColumn(label: Text('column2')), + ]; + + const cells = <DataCell>[DataCell(Text('cell1')), DataCell(Text('cell2'))]; + + const rows = <DataRow>[DataRow(cells: cells), DataRow(cells: cells)]; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + border: TableBorder.all(width: 2, color: Colors.red), + columns: columns, + rows: rows, + ), + ), + ), + ); + + final Finder finder = find.byType(DataTable); + expect(tester.getSize(finder), equals(const Size(800, 600))); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable( + border: TableBorder.all(color: Colors.red), + columns: columns, + rows: rows, + ), + ), + ), + ); + + Table table = tester.widget(find.byType(Table)); + TableBorder? tableBorder = table.border; + expect(tableBorder!.top.color, Colors.red); + expect(tableBorder.bottom.width, 1); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DataTable(columns: columns, rows: rows), + ), + ), + ); + + table = tester.widget(find.byType(Table)); + tableBorder = table.border; + expect(tableBorder?.bottom.width, null); + expect(tableBorder?.top.color, null); + }); + + // Regression test for https://github.com/flutter/flutter/issues/100952 + testWidgets('Do not crashes when paint borders in a narrow space', (WidgetTester tester) async { + const columns = <DataColumn>[ + DataColumn(label: Text('column1')), + DataColumn(label: Text('column2')), + ]; + + const cells = <DataCell>[DataCell(Text('cell1')), DataCell(Text('cell2'))]; + + const rows = <DataRow>[DataRow(cells: cells), DataRow(cells: cells)]; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 117.0, + child: DataTable( + border: TableBorder.all(width: 2, color: Colors.red), + columns: columns, + rows: rows, + ), + ), + ), + ), + ), + ); + + // Go without crashes. + }); + + testWidgets('DataTable clip behavior', (WidgetTester tester) async { + const Color selectedColor = Colors.green; + const Color defaultColor = Colors.red; + const borderRadius = BorderRadius.all(Radius.circular(30)); + + Widget buildTable({bool selected = false, required Clip clipBehavior}) { + return Material( + child: DataTable( + clipBehavior: clipBehavior, + border: TableBorder.all(borderRadius: borderRadius), + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: <DataRow>[ + DataRow( + selected: selected, + color: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + }), + cells: const <DataCell>[DataCell(Text('Content1'))], + ), + ], + ), + ); + } + + // Test default clip behavior. + await tester.pumpWidget(MaterialApp(home: buildTable(clipBehavior: Clip.none))); + + Material material = tester.widget<Material>(find.byType(Material).last); + expect(material.clipBehavior, Clip.none); + expect(material.borderRadius, borderRadius); + + await tester.pumpWidget(MaterialApp(home: buildTable(clipBehavior: Clip.hardEdge))); + + material = tester.widget<Material>(find.byType(Material).last); + expect(material.clipBehavior, Clip.hardEdge); + expect(material.borderRadius, borderRadius); + }); + + testWidgets('DataTable dataRowMinHeight smaller or equal dataRowMaxHeight validation', ( + WidgetTester tester, + ) async { + DataTable createDataTable() => DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: const <DataRow>[], + dataRowMinHeight: 2.0, + dataRowMaxHeight: 1.0, + ); + + expect( + () => createDataTable(), + throwsA( + predicate( + (AssertionError e) => e.toString().contains('dataRowMaxHeight >= dataRowMinHeight'), + ), + ), + ); + }); + + testWidgets( + 'DataTable dataRowHeight is not used together with dataRowMinHeight or dataRowMaxHeight', + (WidgetTester tester) async { + DataTable createDataTable({ + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + }) => DataTable( + columns: const <DataColumn>[DataColumn(label: Text('Column1'))], + rows: const <DataRow>[], + dataRowHeight: dataRowHeight, + dataRowMinHeight: dataRowMinHeight, + dataRowMaxHeight: dataRowMaxHeight, + ); + + expect( + () => createDataTable(dataRowHeight: 1.0, dataRowMinHeight: 2.0, dataRowMaxHeight: 2.0), + throwsA( + predicate( + (AssertionError e) => e.toString().contains( + 'dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null)', + ), + ), + ), + ); + + expect( + () => createDataTable(dataRowHeight: 1.0, dataRowMaxHeight: 2.0), + throwsA( + predicate( + (AssertionError e) => e.toString().contains( + 'dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null)', + ), + ), + ), + ); + + expect( + () => createDataTable(dataRowHeight: 1.0, dataRowMinHeight: 2.0), + throwsA( + predicate( + (AssertionError e) => e.toString().contains( + 'dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null)', + ), + ), + ), + ); + }, + ); + + group('TableRowInkWell', () { + testWidgets('can handle secondary taps', (WidgetTester tester) async { + var secondaryTapped = false; + var secondaryTappedDown = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Table( + children: <TableRow>[ + TableRow( + children: <Widget>[ + TableRowInkWell( + onSecondaryTap: () { + secondaryTapped = true; + }, + onSecondaryTapDown: (TapDownDetails details) { + secondaryTappedDown = true; + }, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ], + ), + ), + ), + ); + + expect(secondaryTapped, isFalse); + expect(secondaryTappedDown, isFalse); + + expect(find.byType(TableRowInkWell), findsOneWidget); + await tester.tap(find.byType(TableRowInkWell), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + + expect(secondaryTapped, isTrue); + expect(secondaryTappedDown, isTrue); + }); + + testWidgets('TableRowInkWell renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox.shrink( + child: Table( + children: const <TableRow>[ + TableRow(children: <Widget>[TableRowInkWell(child: Text('X'))]), + ], + ), + ), + ), + ), + ); + }); + }); + + testWidgets('Heading cell cursor resolves WidgetStateMouseCursor correctly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DataTable( + sortColumnIndex: 0, + columns: <DataColumn>[ + // This column can be sorted. + DataColumn( + mouseCursor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.forbidden; + } + return SystemMouseCursors.copy; + }), + + onSort: (int columnIndex, bool ascending) {}, + label: const Text('A'), + ), + // This column cannot be sorted. + DataColumn( + mouseCursor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.forbidden; + } + return SystemMouseCursors.copy; + }), + label: const Text('B'), + ), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data 1')), DataCell(Text('Data 2'))]), + DataRow(cells: <DataCell>[DataCell(Text('Data 3')), DataCell(Text('Data 4'))]), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('A'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.copy, + ); + + await gesture.moveTo(tester.getCenter(find.text('B'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('DataRow cursor resolves WidgetStateMouseCursor correctly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DataTable( + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: <DataRow>[ + // This row can be selected. + DataRow( + mouseCursor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return SystemMouseCursors.copy; + } + return SystemMouseCursors.forbidden; + }), + onSelectChanged: (bool? selected) {}, + cells: const <DataCell>[DataCell(Text('Data 1')), DataCell(Text('Data 2'))], + ), + // This row is selected. + DataRow( + selected: true, + onSelectChanged: (bool? selected) {}, + mouseCursor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return SystemMouseCursors.copy; + } + return SystemMouseCursors.forbidden; + }), + cells: const <DataCell>[DataCell(Text('Data 3')), DataCell(Text('Data 4'))], + ), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('Data 1'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + + await gesture.moveTo(tester.getCenter(find.text('Data 3'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.copy, + ); + }); + + testWidgets("DataRow cursor doesn't update checkbox cursor", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DataTable( + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: <DataRow>[ + DataRow( + onSelectChanged: (bool? selected) {}, + mouseCursor: const MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.copy), + cells: const <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))], + ), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Checkbox).last)); + await tester.pump(); + + // Test that the checkbox cursor is not changed. + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.text('Data'))); + await tester.pump(); + + // Test that cursor is updated for the row. + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.copy, + ); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/114470. + testWidgets('DataTable text styles are merged with default text style', ( + WidgetTester tester, + ) async { + late DefaultTextStyle defaultTextStyle; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + defaultTextStyle = DefaultTextStyle.of(context); + return DataTable( + headingTextStyle: const TextStyle(), + dataTextStyle: const TextStyle(), + columns: const <DataColumn>[ + DataColumn(label: Text('Header 1')), + DataColumn(label: Text('Header 2')), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data 1')), DataCell(Text('Data 2'))]), + ], + ); + }, + ), + ), + ), + ); + + final TextStyle? headingTextStyle = _getTextRenderObject(tester, 'Header 1').text.style; + expect(headingTextStyle, defaultTextStyle.style); + + final TextStyle? dataTextStyle = _getTextRenderObject(tester, 'Data 1').text.style; + expect(dataTextStyle, defaultTextStyle.style); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/143340. + testWidgets('DataColumn label can be centered', (WidgetTester tester) async { + const horizontalMargin = 24.0; + + Widget buildTable({MainAxisAlignment? headingRowAlignment, bool sortEnabled = false}) { + return MaterialApp( + home: Material( + child: DataTable( + columns: <DataColumn>[ + DataColumn( + headingRowAlignment: headingRowAlignment, + onSort: sortEnabled ? (int columnIndex, bool ascending) {} : null, + label: const Text('Header'), + ), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data'))]), + ], + ), + ), + ); + } + + // Test mainAxisAlignment without sort arrow. + await tester.pumpWidget(buildTable()); + + Offset headerTopLeft = tester.getTopLeft(find.text('Header')); + expect(headerTopLeft.dx, equals(horizontalMargin)); + + // Test mainAxisAlignment.center without sort arrow. + await tester.pumpWidget(buildTable(headingRowAlignment: MainAxisAlignment.center)); + + Offset headerCenter = tester.getCenter(find.text('Header')); + expect(headerCenter.dx, equals(400)); + + // Test mainAxisAlignment with sort arrow. + await tester.pumpWidget(buildTable(sortEnabled: true)); + + headerTopLeft = tester.getTopLeft(find.text('Header')); + expect(headerTopLeft.dx, equals(horizontalMargin)); + + // Test mainAxisAlignment.center with sort arrow. + await tester.pumpWidget( + buildTable(headingRowAlignment: MainAxisAlignment.center, sortEnabled: true), + ); + + headerCenter = tester.getCenter(find.text('Header')); + expect(headerCenter.dx, equals(400)); + }); + + testWidgets('DataTable with custom column widths - checkbox', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 500, + child: DataTable( + columns: const <DataColumn>[ + DataColumn( + label: Text('Flex Numeric'), + columnWidth: FlexColumnWidth(), + numeric: true, + ), + DataColumn(label: Text('Numeric'), numeric: true), + DataColumn(label: Text('Text')), + ], + rows: <DataRow>[ + DataRow( + onSelectChanged: (bool? value) {}, + cells: const <DataCell>[ + DataCell(Text('1')), + DataCell(Text('1')), + DataCell(Text('D')), + ], + ), + ], + ), + ), + ), + ), + ); + + final Table table = tester.widget(find.byType(Table)); + expect(table.columnWidths![0], isA<FixedColumnWidth>()); // Checkbox column + expect(table.columnWidths![1], const FlexColumnWidth()); + expect(table.columnWidths![2], const IntrinsicColumnWidth()); + expect(table.columnWidths![3], const IntrinsicColumnWidth(flex: 1)); + }); + + testWidgets('DataTable with custom column widths - no checkbox', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 500, + child: DataTable( + columns: const <DataColumn>[ + DataColumn( + label: Text('Flex Numeric'), + columnWidth: FlexColumnWidth(), + numeric: true, + ), + DataColumn(label: Text('Numeric'), numeric: true), + DataColumn(label: Text('Text')), + ], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[DataCell(Text('1')), DataCell(Text('1')), DataCell(Text('D'))], + ), + ], + ), + ), + ), + ), + ); + + final Table table = tester.widget(find.byType(Table)); + expect(table.columnWidths![0], const FlexColumnWidth()); + expect(table.columnWidths![1], const IntrinsicColumnWidth()); + expect(table.columnWidths![2], const IntrinsicColumnWidth(flex: 1)); + }); + + testWidgets('DataTable has correct roles in semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DataTable( + columns: const <DataColumn>[ + DataColumn(label: Text('Column 1')), + DataColumn(label: Text('Column 2')), + ], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[DataCell(Text('Data Cell 1')), DataCell(Text('Data Cell 2'))], + ), + ], + ), + ), + ), + ); + + final expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.table, + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.row, + children: <TestSemantics>[ + TestSemantics( + label: 'Column 1', + textDirection: TextDirection.ltr, + role: SemanticsRole.columnHeader, + ), + TestSemantics( + label: 'Column 2', + textDirection: TextDirection.ltr, + role: SemanticsRole.columnHeader, + ), + ], + ), + TestSemantics( + role: SemanticsRole.row, + children: <TestSemantics>[ + TestSemantics( + label: 'Data Cell 1', + textDirection: TextDirection.ltr, + role: SemanticsRole.cell, + ), + TestSemantics( + label: 'Data Cell 2', + textDirection: TextDirection.ltr, + role: SemanticsRole.cell, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true), + ); + + semantics.dispose(); + }); + + testWidgets('Semantic nodes do not throw an error after clearSemantics', ( + WidgetTester tester, + ) async { + var semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DataTable( + columns: const <DataColumn>[ + DataColumn(label: Text('Column 1')), + DataColumn(label: Text('Column 2')), + ], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[DataCell(Text('Data Cell 1')), DataCell(Text('Data Cell 2'))], + ), + ], + ), + ), + ), + ); + + // Dispose the semantics to trigger clearSemantics. + semantics.dispose(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + // Initialize the semantics again. + semantics = SemanticsTester(tester); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + semantics.dispose(); + }, semanticsEnabled: false); + + // Regression test for https://github.com/flutter/flutter/issues/171264 + testWidgets('DataTable cell has correct semantics rect ', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DataTable( + dataRowMaxHeight: double.infinity, + dataRowMinHeight: 70, + columns: const <DataColumn>[ + // Set width so the Column width is not determined by text. + DataColumn(label: SizedBox(width: 250, child: Text('Column 1'))), + DataColumn(label: SizedBox(width: 250, child: Text('Column 2'))), + ], + rows: const <DataRow>[ + DataRow( + cells: <DataCell>[DataCell(Text('Data Cell 1')), DataCell(Text('Data Cell 2'))], + ), + ], + ), + ), + ), + ); + + final SemanticsFinder cell1 = find.semantics.byLabel('Data Cell 1'); + + expect(cell1, findsOne); + + final SemanticsNode cell1Node = cell1.evaluate().first; + + // The semantics node of cell 1 should not have a transform + expect(cell1Node.transform, null); + expect(cell1Node.rect, const Rect.fromLTRB(0.0, 0.0, 302.0, 70.0)); + + semantics.dispose(); + }); + + testWidgets('DataTable, DataColumn, DataRow, and DataCell render at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SizedBox.shrink( + child: DataTable( + columns: const <DataColumn>[DataColumn(label: Text('X'))], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('X'))]), + ], + ), + ), + ), + ); + }); +} + +RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject(find.text(text)); +} diff --git a/packages/material_ui/test/material/data_table_test_utils.dart b/packages/material_ui/test/material/data_table_test_utils.dart new file mode 100644 index 000000000000..ce4fed538d8c --- /dev/null +++ b/packages/material_ui/test/material/data_table_test_utils.dart @@ -0,0 +1,38 @@ +// 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. + +class Dessert { + Dessert( + this.name, + this.calories, + this.fat, + this.carbs, + this.protein, + this.sodium, + this.calcium, + this.iron, + ); + + final String name; + final int calories; + final double fat; + final int carbs; + final double protein; + final int sodium; + final int calcium; + final int iron; +} + +final List<Dessert> kDesserts = <Dessert>[ + Dessert('Frozen yogurt', 159, 6.0, 24, 4.0, 87, 14, 1), + Dessert('Ice cream sandwich', 237, 9.0, 37, 4.3, 129, 8, 1), + Dessert('Eclair', 262, 16.0, 24, 6.0, 337, 6, 7), + Dessert('Cupcake', 305, 3.7, 67, 4.3, 413, 3, 8), + Dessert('Gingerbread', 356, 16.0, 49, 3.9, 327, 7, 16), + Dessert('Jelly bean', 375, 0.0, 94, 0.0, 50, 0, 0), + Dessert('Lollipop', 392, 0.2, 98, 0.0, 38, 0, 2), + Dessert('Honeycomb', 408, 3.2, 87, 6.5, 562, 0, 45), + Dessert('Donut', 452, 25.0, 51, 4.9, 326, 2, 22), + Dessert('KitKat', 518, 26.0, 65, 7.0, 54, 12, 6), +]; diff --git a/packages/material_ui/test/material/data_table_theme_test.dart b/packages/material_ui/test/material/data_table_theme_test.dart new file mode 100644 index 000000000000..f4aba605b962 --- /dev/null +++ b/packages/material_ui/test/material/data_table_theme_test.dart @@ -0,0 +1,706 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('DataTableThemeData copyWith, ==, hashCode basics', () { + expect(const DataTableThemeData(), const DataTableThemeData().copyWith()); + expect(const DataTableThemeData().hashCode, const DataTableThemeData().copyWith().hashCode); + }); + + test('DataTableThemeData copyWith dataRowHeight', () { + const themeData = DataTableThemeData(dataRowMinHeight: 10, dataRowMaxHeight: 10); + expect(themeData, themeData.copyWith()); + expect( + themeData.copyWith(dataRowMinHeight: 20, dataRowMaxHeight: 20), + themeData.copyWith(dataRowHeight: 20), + ); + }); + + test('DataTableThemeData lerp special cases', () { + const data = DataTableThemeData(); + expect(identical(DataTableThemeData.lerp(data, data, 0.5), data), true); + }); + + test('DataTableThemeData defaults', () { + const themeData = DataTableThemeData(); + expect(themeData.decoration, null); + expect(themeData.dataRowColor, null); + expect(themeData.dataRowHeight, null); + expect(themeData.dataRowMinHeight, null); + expect(themeData.dataRowMaxHeight, null); + expect(themeData.dataTextStyle, null); + expect(themeData.headingRowColor, null); + expect(themeData.headingRowHeight, null); + expect(themeData.headingTextStyle, null); + expect(themeData.horizontalMargin, null); + expect(themeData.columnSpacing, null); + expect(themeData.dividerThickness, null); + expect(themeData.checkboxHorizontalMargin, null); + expect(themeData.headingCellCursor, null); + expect(themeData.dataRowCursor, null); + expect(themeData.headingRowAlignment, null); + + const theme = DataTableTheme(data: DataTableThemeData(), child: SizedBox()); + expect(theme.data.decoration, null); + expect(theme.data.dataRowColor, null); + expect(theme.data.dataRowHeight, null); + expect(theme.data.dataRowMinHeight, null); + expect(theme.data.dataRowMaxHeight, null); + expect(theme.data.dataTextStyle, null); + expect(theme.data.headingRowColor, null); + expect(theme.data.headingRowHeight, null); + expect(theme.data.headingTextStyle, null); + expect(theme.data.horizontalMargin, null); + expect(theme.data.columnSpacing, null); + expect(theme.data.dividerThickness, null); + expect(theme.data.checkboxHorizontalMargin, null); + expect(theme.data.headingCellCursor, null); + expect(theme.data.dataRowCursor, null); + expect(theme.data.headingRowAlignment, null); + }); + + testWidgets('Default DataTableThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DataTableThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('DataTableThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + DataTableThemeData( + decoration: const BoxDecoration(color: Color(0xfffffff0)), + dataRowColor: WidgetStateProperty.resolveWith<Color>( + (Set<WidgetState> states) => const Color(0xfffffff1), + ), + dataRowMinHeight: 41.0, + dataRowMaxHeight: 42.0, + dataTextStyle: const TextStyle(fontSize: 12.0), + headingRowColor: WidgetStateProperty.resolveWith<Color>( + (Set<WidgetState> states) => const Color(0xfffffff2), + ), + headingRowHeight: 52.0, + headingTextStyle: const TextStyle(fontSize: 14.0), + horizontalMargin: 3.0, + columnSpacing: 4.0, + dividerThickness: 5.0, + checkboxHorizontalMargin: 6.0, + headingCellCursor: const MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.grab), + dataRowCursor: const MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.forbidden), + headingRowAlignment: MainAxisAlignment.center, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'decoration: BoxDecoration(color: ${const Color(0xfffffff0)})'); + expect(description[1], "dataRowColor: Instance of '_WidgetStatePropertyWith<Color>'"); + expect(description[2], 'dataRowMinHeight: 41.0'); + expect(description[3], 'dataRowMaxHeight: 42.0'); + expect(description[4], 'dataTextStyle: TextStyle(inherit: true, size: 12.0)'); + expect(description[5], "headingRowColor: Instance of '_WidgetStatePropertyWith<Color>'"); + expect(description[6], 'headingRowHeight: 52.0'); + expect(description[7], 'headingTextStyle: TextStyle(inherit: true, size: 14.0)'); + expect(description[8], 'horizontalMargin: 3.0'); + expect(description[9], 'columnSpacing: 4.0'); + expect(description[10], 'dividerThickness: 5.0'); + expect(description[11], 'checkboxHorizontalMargin: 6.0'); + expect(description[12], 'headingCellCursor: WidgetStatePropertyAll(SystemMouseCursor(grab))'); + expect(description[13], 'dataRowCursor: WidgetStatePropertyAll(SystemMouseCursor(forbidden))'); + expect(description[14], 'headingRowAlignment: center'); + }); + + testWidgets('DataTable is themeable', (WidgetTester tester) async { + const decoration = BoxDecoration(color: Color(0xfffffff0)); + const WidgetStateProperty<Color> dataRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff1), + ); + const minMaxDataRowHeight = 41.0; + const dataTextStyle = TextStyle(fontSize: 12.5); + const WidgetStateProperty<Color> headingRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff2), + ); + const headingRowHeight = 52.0; + const headingTextStyle = TextStyle(fontSize: 14.5); + const horizontalMargin = 3.0; + const columnSpacing = 4.0; + const dividerThickness = 5.0; + const WidgetStateProperty<MouseCursor> headingCellCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.grab); + const WidgetStateProperty<MouseCursor> dataRowCursor = MaterialStatePropertyAll<MouseCursor>( + SystemMouseCursors.forbidden, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData( + decoration: decoration, + dataRowColor: dataRowColor, + dataRowMinHeight: minMaxDataRowHeight, + dataRowMaxHeight: minMaxDataRowHeight, + dataTextStyle: dataTextStyle, + headingRowColor: headingRowColor, + headingRowHeight: headingRowHeight, + headingTextStyle: headingTextStyle, + horizontalMargin: horizontalMargin, + columnSpacing: columnSpacing, + dividerThickness: dividerThickness, + headingCellCursor: headingCellCursor, + dataRowCursor: dataRowCursor, + ), + ), + home: Scaffold( + body: DataTable( + sortColumnIndex: 0, + showCheckboxColumn: false, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: <DataRow>[ + DataRow( + cells: const <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))], + onSelectChanged: (bool? value) {}, + ), + ], + ), + ), + ), + ); + + final Finder tableContainerFinder = find.ancestor( + of: find.byType(Table), + matching: find.byType(Container), + ); + expect(tester.widgetList<Container>(tableContainerFinder).first.decoration, decoration); + + final TextStyle dataRowTextStyle = tester + .renderObject<RenderParagraph>(find.text('Data')) + .text + .style!; + expect(dataRowTextStyle.fontSize, dataTextStyle.fontSize); + expect( + _tableRowBoxDecoration(tester: tester, index: 1).color, + dataRowColor.resolve(<WidgetState>{}), + ); + expect(_tableRowBoxDecoration(tester: tester, index: 1).border!.top.width, dividerThickness); + expect(tester.getSize(_findFirstContainerFor('Data')).height, minMaxDataRowHeight); + + final TextStyle headingRowTextStyle = tester + .renderObject<RenderParagraph>(find.text('A')) + .text + .style!; + expect(headingRowTextStyle.fontSize, headingTextStyle.fontSize); + expect( + _tableRowBoxDecoration(tester: tester, index: 0).color, + headingRowColor.resolve(<WidgetState>{}), + ); + + expect(tester.getSize(_findFirstContainerFor('A')).height, headingRowHeight); + expect(tester.getTopLeft(find.text('A')).dx, horizontalMargin); + expect( + tester.getTopLeft(find.text('Data 2')).dx - tester.getTopRight(find.text('Data')).dx, + columnSpacing, + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('A'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + await gesture.moveTo(tester.getCenter(find.text('Data'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('DataTable is themeable - separate test for deprecated dataRowHeight', ( + WidgetTester tester, + ) async { + const dataRowHeight = 51.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(dataTableTheme: const DataTableThemeData(dataRowHeight: dataRowHeight)), + home: Scaffold( + body: DataTable( + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))]), + ], + ), + ), + ), + ); + + expect(tester.getSize(_findFirstContainerFor('Data')).height, dataRowHeight); + }); + + testWidgets('DataTable properties are taken over the theme values', (WidgetTester tester) async { + const themeDecoration = BoxDecoration(color: Color(0xfffffff1)); + const WidgetStateProperty<Color> themeDataRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff0), + ); + const minMaxThemeDataRowHeight = 50.0; + const themeDataTextStyle = TextStyle(fontSize: 11.5); + const WidgetStateProperty<Color> themeHeadingRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff1), + ); + const themeHeadingRowHeight = 51.0; + const themeHeadingTextStyle = TextStyle(fontSize: 13.5); + const themeHorizontalMargin = 2.0; + const themeColumnSpacing = 3.0; + const themeDividerThickness = 4.0; + const WidgetStateProperty<MouseCursor> themeHeadingCellCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.copy); + const WidgetStateProperty<MouseCursor> themeDataRowCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.copy); + + const decoration = BoxDecoration(color: Color(0xfffffff0)); + const WidgetStateProperty<Color> dataRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff1), + ); + const minMaxDataRowHeight = 51.0; + const dataTextStyle = TextStyle(fontSize: 12.5); + const WidgetStateProperty<Color> headingRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff2), + ); + const headingRowHeight = 52.0; + const headingTextStyle = TextStyle(fontSize: 14.5); + const horizontalMargin = 3.0; + const columnSpacing = 4.0; + const dividerThickness = 5.0; + const WidgetStateProperty<MouseCursor> headingCellCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.forbidden); + const WidgetStateProperty<MouseCursor> dataRowCursor = MaterialStatePropertyAll<MouseCursor>( + SystemMouseCursors.forbidden, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData( + decoration: themeDecoration, + dataRowColor: themeDataRowColor, + dataRowMinHeight: minMaxThemeDataRowHeight, + dataRowMaxHeight: minMaxThemeDataRowHeight, + dataTextStyle: themeDataTextStyle, + headingRowColor: themeHeadingRowColor, + headingRowHeight: themeHeadingRowHeight, + headingTextStyle: themeHeadingTextStyle, + horizontalMargin: themeHorizontalMargin, + columnSpacing: themeColumnSpacing, + dividerThickness: themeDividerThickness, + headingCellCursor: themeHeadingCellCursor, + dataRowCursor: themeDataRowCursor, + ), + ), + home: Scaffold( + body: DataTable( + showCheckboxColumn: false, + decoration: decoration, + dataRowColor: dataRowColor, + dataRowMinHeight: minMaxDataRowHeight, + dataRowMaxHeight: minMaxDataRowHeight, + dataTextStyle: dataTextStyle, + headingRowColor: headingRowColor, + headingRowHeight: headingRowHeight, + headingTextStyle: headingTextStyle, + horizontalMargin: horizontalMargin, + columnSpacing: columnSpacing, + dividerThickness: dividerThickness, + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn( + label: const Text('A'), + mouseCursor: headingCellCursor, + onSort: (int columnIndex, bool ascending) {}, + ), + const DataColumn(label: Text('B')), + ], + rows: <DataRow>[ + DataRow( + mouseCursor: dataRowCursor, + onSelectChanged: (bool? selected) {}, + cells: const <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))], + ), + ], + ), + ), + ), + ); + + final Finder tableContainerFinder = find.ancestor( + of: find.byType(Table), + matching: find.byType(Container), + ); + expect(tester.widget<Container>(tableContainerFinder).decoration, decoration); + + final TextStyle dataRowTextStyle = tester + .renderObject<RenderParagraph>(find.text('Data')) + .text + .style!; + expect(dataRowTextStyle.fontSize, dataTextStyle.fontSize); + expect( + _tableRowBoxDecoration(tester: tester, index: 1).color, + dataRowColor.resolve(<WidgetState>{}), + ); + expect(_tableRowBoxDecoration(tester: tester, index: 1).border!.top.width, dividerThickness); + expect(tester.getSize(_findFirstContainerFor('Data')).height, minMaxDataRowHeight); + + final TextStyle headingRowTextStyle = tester + .renderObject<RenderParagraph>(find.text('A')) + .text + .style!; + expect(headingRowTextStyle.fontSize, headingTextStyle.fontSize); + expect( + _tableRowBoxDecoration(tester: tester, index: 0).color, + headingRowColor.resolve(<WidgetState>{}), + ); + + expect(tester.getSize(_findFirstContainerFor('A')).height, headingRowHeight); + expect(tester.getTopLeft(find.text('A')).dx, horizontalMargin); + expect( + tester.getTopLeft(find.text('Data 2')).dx - tester.getTopRight(find.text('Data')).dx, + columnSpacing, + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('A'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + headingCellCursor.resolve(<WidgetState>{}), + ); + + await gesture.moveTo(tester.getCenter(find.text('Data'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + dataRowCursor.resolve(<WidgetState>{}), + ); + }); + + testWidgets( + 'DataTable properties are taken over the theme values - separate test for deprecated dataRowHeight', + (WidgetTester tester) async { + const themeDataRowHeight = 50.0; + const dataRowHeight = 51.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData(dataRowHeight: themeDataRowHeight), + ), + home: Scaffold( + body: DataTable( + dataRowHeight: dataRowHeight, + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))]), + ], + ), + ), + ), + ); + + expect(tester.getSize(_findFirstContainerFor('Data')).height, dataRowHeight); + }, + ); + + testWidgets('Local DataTableTheme can override global DataTableTheme', ( + WidgetTester tester, + ) async { + const globalThemeDecoration = BoxDecoration(color: Color(0xfffffff1)); + const WidgetStateProperty<Color> globalThemeDataRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff0), + ); + const minMaxGlobalThemeDataRowHeight = 50.0; + const globalThemeDataTextStyle = TextStyle(fontSize: 11.5); + const WidgetStateProperty<Color> globalThemeHeadingRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff1), + ); + const globalThemeHeadingRowHeight = 51.0; + const globalThemeHeadingTextStyle = TextStyle(fontSize: 13.5); + const globalThemeHorizontalMargin = 2.0; + const globalThemeColumnSpacing = 3.0; + const globalThemeDividerThickness = 4.0; + const WidgetStateProperty<MouseCursor> globalHeadingCellCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.allScroll); + const WidgetStateProperty<MouseCursor> globalDataRowCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.allScroll); + + const localThemeDecoration = BoxDecoration(color: Color(0xfffffff0)); + const WidgetStateProperty<Color> localThemeDataRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff1), + ); + const minMaxLocalThemeDataRowHeight = 51.0; + const localThemeDataTextStyle = TextStyle(fontSize: 12.5); + const WidgetStateProperty<Color> localThemeHeadingRowColor = MaterialStatePropertyAll<Color>( + Color(0xfffffff2), + ); + const localThemeHeadingRowHeight = 52.0; + const localThemeHeadingTextStyle = TextStyle(fontSize: 14.5); + const localThemeHorizontalMargin = 3.0; + const localThemeColumnSpacing = 4.0; + const localThemeDividerThickness = 5.0; + const WidgetStateProperty<MouseCursor> localHeadingCellCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.move); + const WidgetStateProperty<MouseCursor> localDataRowCursor = + MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.move); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData( + decoration: globalThemeDecoration, + dataRowColor: globalThemeDataRowColor, + dataRowMinHeight: minMaxGlobalThemeDataRowHeight, + dataRowMaxHeight: minMaxGlobalThemeDataRowHeight, + dataTextStyle: globalThemeDataTextStyle, + headingRowColor: globalThemeHeadingRowColor, + headingRowHeight: globalThemeHeadingRowHeight, + headingTextStyle: globalThemeHeadingTextStyle, + horizontalMargin: globalThemeHorizontalMargin, + columnSpacing: globalThemeColumnSpacing, + dividerThickness: globalThemeDividerThickness, + headingCellCursor: globalHeadingCellCursor, + dataRowCursor: globalDataRowCursor, + ), + ), + home: Scaffold( + body: DataTableTheme( + data: const DataTableThemeData( + decoration: localThemeDecoration, + dataRowColor: localThemeDataRowColor, + dataRowMinHeight: minMaxLocalThemeDataRowHeight, + dataRowMaxHeight: minMaxLocalThemeDataRowHeight, + dataTextStyle: localThemeDataTextStyle, + headingRowColor: localThemeHeadingRowColor, + headingRowHeight: localThemeHeadingRowHeight, + headingTextStyle: localThemeHeadingTextStyle, + horizontalMargin: localThemeHorizontalMargin, + columnSpacing: localThemeColumnSpacing, + dividerThickness: localThemeDividerThickness, + headingCellCursor: localHeadingCellCursor, + dataRowCursor: localDataRowCursor, + ), + child: DataTable( + showCheckboxColumn: false, + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: <DataRow>[ + DataRow( + onSelectChanged: (bool? selected) {}, + cells: const <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))], + ), + ], + ), + ), + ), + ), + ); + + final Finder tableContainerFinder = find.ancestor( + of: find.byType(Table), + matching: find.byType(Container), + ); + expect( + tester.widgetList<Container>(tableContainerFinder).first.decoration, + localThemeDecoration, + ); + + final TextStyle dataRowTextStyle = tester + .renderObject<RenderParagraph>(find.text('Data')) + .text + .style!; + expect(dataRowTextStyle.fontSize, localThemeDataTextStyle.fontSize); + expect( + _tableRowBoxDecoration(tester: tester, index: 1).color, + localThemeDataRowColor.resolve(<WidgetState>{}), + ); + expect( + _tableRowBoxDecoration(tester: tester, index: 1).border!.top.width, + localThemeDividerThickness, + ); + expect(tester.getSize(_findFirstContainerFor('Data')).height, minMaxLocalThemeDataRowHeight); + + final TextStyle headingRowTextStyle = tester + .renderObject<RenderParagraph>(find.text('A')) + .text + .style!; + expect(headingRowTextStyle.fontSize, localThemeHeadingTextStyle.fontSize); + expect( + _tableRowBoxDecoration(tester: tester, index: 0).color, + localThemeHeadingRowColor.resolve(<WidgetState>{}), + ); + + expect(tester.getSize(_findFirstContainerFor('A')).height, localThemeHeadingRowHeight); + expect(tester.getTopLeft(find.text('A')).dx, localThemeHorizontalMargin); + expect( + tester.getTopLeft(find.text('Data 2')).dx - tester.getTopRight(find.text('Data')).dx, + localThemeColumnSpacing, + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('A'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + localHeadingCellCursor.resolve(<WidgetState>{}), + ); + + await gesture.moveTo(tester.getCenter(find.text('Data'))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + localDataRowCursor.resolve(<WidgetState>{}), + ); + }); + + testWidgets( + 'Local DataTableTheme can override global DataTableTheme - separate test for deprecated dataRowHeight', + (WidgetTester tester) async { + const globalThemeDataRowHeight = 50.0; + const localThemeDataRowHeight = 51.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData(dataRowHeight: globalThemeDataRowHeight), + ), + home: Scaffold( + body: DataTableTheme( + data: const DataTableThemeData(dataRowHeight: localThemeDataRowHeight), + child: DataTable( + sortColumnIndex: 0, + columns: <DataColumn>[ + DataColumn(label: const Text('A'), onSort: (int columnIndex, bool ascending) {}), + const DataColumn(label: Text('B')), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data')), DataCell(Text('Data 2'))]), + ], + ), + ), + ), + ), + ); + + expect(tester.getSize(_findFirstContainerFor('Data')).height, localThemeDataRowHeight); + }, + ); + + // This is a regression test for https://github.com/flutter/flutter/issues/143340. + testWidgets('DataColumn label can be centered with DataTableTheme.headingRowAlignment', ( + WidgetTester tester, + ) async { + const horizontalMargin = 24.0; + + Widget buildTable({MainAxisAlignment? headingRowAlignment, bool sortEnabled = false}) { + return MaterialApp( + theme: ThemeData( + dataTableTheme: DataTableThemeData(headingRowAlignment: headingRowAlignment), + ), + home: Material( + child: DataTable( + columns: <DataColumn>[ + DataColumn( + onSort: sortEnabled ? (int columnIndex, bool ascending) {} : null, + label: const Text('Header'), + ), + ], + rows: const <DataRow>[ + DataRow(cells: <DataCell>[DataCell(Text('Data'))]), + ], + ), + ), + ); + } + + // Test mainAxisAlignment without sort arrow. + await tester.pumpWidget(buildTable()); + + Offset headerTopLeft = tester.getTopLeft(find.text('Header')); + expect(headerTopLeft.dx, equals(horizontalMargin)); + + // Test mainAxisAlignment.center without sort arrow. + await tester.pumpWidget(buildTable(headingRowAlignment: MainAxisAlignment.center)); + await tester.pumpAndSettle(); + + Offset headerCenter = tester.getCenter(find.text('Header')); + expect(headerCenter.dx, equals(400)); + + // Test mainAxisAlignment with sort arrow. + await tester.pumpWidget(buildTable(sortEnabled: true)); + await tester.pumpAndSettle(); + + headerTopLeft = tester.getTopLeft(find.text('Header')); + expect(headerTopLeft.dx, equals(horizontalMargin)); + + // Test mainAxisAlignment.center with sort arrow. + await tester.pumpWidget( + buildTable(headingRowAlignment: MainAxisAlignment.center, sortEnabled: true), + ); + await tester.pumpAndSettle(); + + headerCenter = tester.getCenter(find.text('Header')); + expect(headerCenter.dx, equals(400)); + }); +} + +BoxDecoration _tableRowBoxDecoration({required WidgetTester tester, required int index}) { + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children[index]; + return tableRow.decoration! as BoxDecoration; +} + +// The finder matches with the Container of the cell content, as well as the +// Container wrapping the whole table. The first one is used to test row +// heights. +Finder _findFirstContainerFor(String text) => find.widgetWithText(Container, text).first; diff --git a/packages/material_ui/test/material/date_picker_test.dart b/packages/material_ui/test/material/date_picker_test.dart new file mode 100644 index 000000000000..860424633dca --- /dev/null +++ b/packages/material_ui/test/material/date_picker_test.dart @@ -0,0 +1,2898 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/clipboard_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late DateTime firstDate; + late DateTime lastDate; + late DateTime? initialDate; + late DateTime today; + late SelectableDayPredicate? selectableDayPredicate; + late DatePickerEntryMode initialEntryMode; + late DatePickerMode initialCalendarMode; + late DatePickerEntryMode currentMode; + + String? cancelText; + String? confirmText; + String? errorFormatText; + String? errorInvalidText; + String? fieldHintText; + String? fieldLabelText; + String? helpText; + TextInputType? keyboardType; + + final Finder nextMonthIcon = find.byWidgetPredicate( + (Widget w) => w is IconButton && (w.tooltip?.startsWith('Next month') ?? false), + ); + final Finder previousMonthIcon = find.byWidgetPredicate( + (Widget w) => w is IconButton && (w.tooltip?.startsWith('Previous month') ?? false), + ); + final Finder switchToInputIcon = find.byIcon(Icons.edit); + final Finder switchToCalendarIcon = find.byIcon(Icons.calendar_today); + + TextField textField(WidgetTester tester) { + return tester.widget<TextField>(find.byType(TextField)); + } + + setUp(() { + firstDate = DateTime(2001); + lastDate = DateTime(2031, DateTime.december, 31); + initialDate = DateTime(2016, DateTime.january, 15); + today = DateTime(2016, DateTime.january, 3); + selectableDayPredicate = null; + initialEntryMode = DatePickerEntryMode.calendar; + initialCalendarMode = DatePickerMode.day; + + cancelText = null; + confirmText = null; + errorFormatText = null; + errorInvalidText = null; + fieldHintText = null; + fieldLabelText = null; + helpText = null; + keyboardType = null; + currentMode = initialEntryMode; + }); + + const wideWindowSize = Size(1920.0, 1080.0); + const narrowWindowSize = Size(1070.0, 1770.0); + + Future<void> prepareDatePicker( + WidgetTester tester, + Future<void> Function(Future<DateTime?> date) callback, { + TextDirection textDirection = TextDirection.ltr, + bool useMaterial3 = false, + ThemeData? theme, + TextScaler textScaler = TextScaler.noScaling, + }) async { + late BuildContext buttonContext; + await tester.pumpWidget( + MaterialApp( + theme: theme ?? ThemeData(useMaterial3: useMaterial3), + home: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + buttonContext = context; + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + expect(buttonContext, isNotNull); + + final Future<DateTime?> date = showDatePicker( + context: buttonContext, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + currentDate: today, + selectableDayPredicate: selectableDayPredicate, + initialDatePickerMode: initialCalendarMode, + initialEntryMode: initialEntryMode, + cancelText: cancelText, + confirmText: confirmText, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + fieldHintText: fieldHintText, + fieldLabelText: fieldLabelText, + helpText: helpText, + keyboardType: keyboardType, + onDatePickerModeChange: (DatePickerEntryMode value) { + currentMode = value; + }, + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: textDirection, child: child ?? const SizedBox()); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + await callback(date); + } + + ShapeDecoration? findDayDecoration(WidgetTester tester, String day) { + return tester + .widget<Ink>(find.ancestor(of: find.text(day), matching: find.byType(Ink))) + .decoration + as ShapeDecoration?; + } + + MaterialInkController findDayGridMaterial(WidgetTester tester) { + // All days are painted on the same Material widget. + // Use an arbitrary day to find this Material. + return Material.of(tester.element(find.text('17'))); + } + + group('showDatePicker Dialog', () { + testWidgets('Default dialog size', (WidgetTester tester) async { + Future<void> showPicker(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, useMaterial3: true); + } + + const calendarLandscapeDialogSize = Size(496.0, 346.0); + const calendarPortraitDialogSizeM3 = Size(360.0, 568.0); + + // Test landscape layout. + await showPicker(tester, wideWindowSize); + + Size dialogContainerSize = tester.getSize(find.byType(AnimatedContainer)); + expect(dialogContainerSize, calendarLandscapeDialogSize); + + // Close the dialog. + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Test portrait layout. + await showPicker(tester, narrowWindowSize); + + dialogContainerSize = tester.getSize(find.byType(AnimatedContainer)); + expect(dialogContainerSize, calendarPortraitDialogSizeM3); + }); + + testWidgets('Default dialog properties', (WidgetTester tester) async { + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surfaceContainerHigh); + expect(dialogMaterial.shadowColor, Colors.transparent); + expect(dialogMaterial.surfaceTintColor, Colors.transparent); + expect(dialogMaterial.elevation, 6.0); + expect( + dialogMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + ); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)); + }, useMaterial3: theme.useMaterial3); + }); + + testWidgets('Material3 uses sentence case labels', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.text('Select date'), findsOneWidget); + }, useMaterial3: true); + }); + + testWidgets('Cancel, confirm, and help text is used', (WidgetTester tester) async { + cancelText = 'nope'; + confirmText = 'yep'; + helpText = 'help'; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.text(cancelText!), findsOneWidget); + expect(find.text(confirmText!), findsOneWidget); + expect(find.text(helpText!), findsOneWidget); + }); + }); + + testWidgets('Initial date is the default', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('OK')); + expect(await date, DateTime(2016, DateTime.january, 15)); + }); + }); + + testWidgets('Can cancel', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('CANCEL')); + expect(await date, isNull); + }); + }); + + testWidgets('Can switch from calendar to input entry mode', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsNothing); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + }); + }); + + testWidgets('Can switch from input to calendar entry mode', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.input; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsOneWidget); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + }); + + testWidgets('Can not switch out of calendarOnly mode', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.calendarOnly; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + }); + }); + + testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.inputOnly; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsOneWidget); + expect(find.byIcon(Icons.calendar_today), findsNothing); + }); + }); + + testWidgets('Switching to input mode keeps selected date', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('12')); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + expect(await date, DateTime(2016, DateTime.january, 12)); + }); + }); + + testWidgets('Input only mode should validate date', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.inputOnly; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Enter text input mode and type an invalid date to get error. + await tester.enterText(find.byType(TextField), '1234567'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Invalid format.'), findsOneWidget); + }); + }); + + testWidgets('Switching to input mode resets input error state', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Enter text input mode and type an invalid date to get error. + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '1234567'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Invalid format.'), findsOneWidget); + + // Toggle to calendar mode and then back to input mode + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + expect(find.text('Invalid format.'), findsNothing); + + // Edit the text, the error should not be showing until ok is tapped + await tester.enterText(find.byType(TextField), '1234567'); + await tester.pumpAndSettle(); + expect(find.text('Invalid format.'), findsNothing); + }); + }); + + testWidgets('builder parameter', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) { + return Directionality( + textDirection: textDirection, + child: child ?? const SizedBox(), + ); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + final double ltrOkRight = tester.getBottomRight(find.text('OK')).dx; + + await tester.tap(find.text('OK')); // Dismiss the dialog. + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Verify that the time picker is being laid out RTL. + // We expect the left edge of the 'OK' button in the RTL + // layout to match the gap between right edge of the 'OK' + // button and the right edge of the 800 wide view. + expect(tester.getBottomLeft(find.text('OK')).dx, moreOrLessEquals(800 - ltrOkRight)); + }); + + group('Barrier dismissible', () { + late _DatePickerObserver rootObserver; + + setUp(() { + rootObserver = _DatePickerObserver(); + }); + + testWidgets('Barrier is dismissible with default parameter', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) => const SizedBox(), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(rootObserver.datePickerCount, 1); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + expect(rootObserver.datePickerCount, 0); + }); + + testWidgets('Barrier is not dismissible with barrierDismissible is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + barrierDismissible: false, + builder: (BuildContext context, Widget? child) => const SizedBox(), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(rootObserver.datePickerCount, 1); + + // Tap on the barrier, which shouldn't do anything this time. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + expect(rootObserver.datePickerCount, 1); + }); + }); + + testWidgets('Barrier color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) => const SizedBox(), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.black54); + + // Dismiss the dialog. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showDatePicker( + context: context, + barrierColor: Colors.pink, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) => const SizedBox(), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.pink); + }); + + testWidgets('Barrier Label', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showDatePicker( + context: context, + barrierLabel: 'Custom Label', + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) => const SizedBox(), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect( + tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).semanticsLabel, + 'Custom Label', + ); + }); + + testWidgets('uses nested navigator if useRootNavigator is false', (WidgetTester tester) async { + final rootObserver = _DatePickerObserver(); + final nestedObserver = _DatePickerObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDatePicker( + context: context, + useRootNavigator: false, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) => const SizedBox(), + ); + }, + child: const Text('Show Date Picker'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.datePickerCount, 0); + expect(nestedObserver.datePickerCount, 1); + }); + + testWidgets('honors DialogTheme for shape and elevation', (WidgetTester tester) async { + // Test that the defaults work + const datePickerDefaultDialogTheme = DialogTheme( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + elevation: 24, + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + ); + }, + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + final Material defaultDialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + expect(defaultDialogMaterial.shape, datePickerDefaultDialogTheme.shape); + expect(defaultDialogMaterial.elevation, datePickerDefaultDialogTheme.elevation); + + // Test that it honors ThemeData.dialogTheme settings + const customDialogTheme = DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(40.0))), + elevation: 50, + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.fallback(useMaterial3: false).copyWith(dialogTheme: customDialogTheme), + home: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + ); + }, + ); + }, + ), + ), + ), + ); + await tester.pump(); // start theme animation + await tester.pump(const Duration(seconds: 5)); // end theme animation + final Material themeDialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + expect(themeDialogMaterial.shape, customDialogTheme.shape); + expect(themeDialogMaterial.elevation, customDialogTheme.elevation); + }); + + testWidgets('OK Cancel button layout', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime(2016, DateTime.january, 15), + firstDate: DateTime(2001), + lastDate: DateTime(2031, DateTime.december, 31), + builder: (BuildContext context, Widget? child) { + return Directionality( + textDirection: textDirection, + child: child ?? const SizedBox(), + ); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + // Default landscape layout. + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('OK')).dx, 622); + expect(tester.getBottomLeft(find.text('OK')).dx, 594); + expect(tester.getBottomRight(find.text('CANCEL')).dx, 560); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('OK')).dx, 206); + expect(tester.getBottomLeft(find.text('OK')).dx, 178); + expect(tester.getBottomRight(find.text('CANCEL')).dx, 324); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Portrait layout. + + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(900, 1200); + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('OK')).dx, 258); + expect(tester.getBottomLeft(find.text('OK')).dx, 230); + expect(tester.getBottomRight(find.text('CANCEL')).dx, 196); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('OK')).dx, 70); + expect(tester.getBottomLeft(find.text('OK')).dx, 42); + expect(tester.getBottomRight(find.text('CANCEL')).dx, 188); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + }); + + testWidgets('honors switchToInputEntryModeIcon', (WidgetTester tester) async { + Widget buildApp({bool? useMaterial3, Icon? switchToInputEntryModeIcon}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3 ?? false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Click X'), + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + switchToInputEntryModeIcon: switchToInputEntryModeIcon, + ); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.edit), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(useMaterial3: true)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(switchToInputEntryModeIcon: const Icon(Icons.keyboard))); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.keyboard), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + }); + + testWidgets('honors switchToCalendarEntryModeIcon', (WidgetTester tester) async { + Widget buildApp({bool? useMaterial3, Icon? switchToCalendarEntryModeIcon}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3 ?? false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Click X'), + onPressed: () { + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + initialEntryMode: DatePickerEntryMode.input, + ); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.calendar_today), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(useMaterial3: true)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.calendar_today), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(switchToCalendarEntryModeIcon: const Icon(Icons.favorite))); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + }); + }); + + group('Calendar mode', () { + testWidgets('Default Calendar mode layout (Landscape)', (WidgetTester tester) async { + final Finder helpText = find.text('Select date'); + final Finder headerText = find.text('Fri, Jan 15'); + final Finder subHeaderText = find.text('January 2016'); + final Finder cancelButtonText = find.text('Cancel'); + final Finder okButtonText = find.text('OK'); + const insetPadding = EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0); + + tester.view.physicalSize = wideWindowSize; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + expect(helpText, findsOneWidget); + expect(headerText, findsOneWidget); + expect(subHeaderText, findsOneWidget); + expect(cancelButtonText, findsOneWidget); + expect(okButtonText, findsOneWidget); + + // Test help text position. + final Offset dialogTopLeft = tester.getTopLeft(find.byType(AnimatedContainer)); + final Offset helpTextTopLeft = tester.getTopLeft(helpText); + expect(helpTextTopLeft.dx, dialogTopLeft.dx + (insetPadding.horizontal / 2)); + expect(helpTextTopLeft.dy, dialogTopLeft.dy + 16.0); + + // Test header text position. + final Offset headerTextTopLeft = tester.getTopLeft(headerText); + final Offset helpTextBottomLeft = tester.getBottomLeft(helpText); + expect(headerTextTopLeft.dx, dialogTopLeft.dx + (insetPadding.horizontal / 2)); + expect(headerTextTopLeft.dy, helpTextBottomLeft.dy + 16.0); + + // Test switch button position. + final Finder switchButtonM3 = find.widgetWithIcon(IconButton, Icons.edit_outlined); + final Offset switchButtonTopLeft = tester.getTopLeft(switchButtonM3); + final Offset switchButtonBottomLeft = tester.getBottomLeft(switchButtonM3); + final Offset headerTextBottomLeft = tester.getBottomLeft(headerText); + final Offset dialogBottomLeft = tester.getBottomLeft(find.byType(AnimatedContainer)); + expect(switchButtonTopLeft.dx, dialogTopLeft.dx + 8.0); + expect(switchButtonTopLeft.dy, headerTextBottomLeft.dy); + expect(switchButtonBottomLeft.dx, dialogTopLeft.dx + 8.0); + expect(switchButtonBottomLeft.dy, dialogBottomLeft.dy - 6.0); + + // Test vertical divider position. + final Finder divider = find.byType(VerticalDivider); + final Offset dividerTopLeft = tester.getTopLeft(divider); + final Offset headerTextTopRight = tester.getTopRight(headerText); + expect(dividerTopLeft.dx, headerTextTopRight.dx + 16.0); + expect(dividerTopLeft.dy, dialogTopLeft.dy); + + // Test sub header text position. + final Offset subHeaderTextTopLeft = tester.getTopLeft(subHeaderText); + final Offset dividerTopRight = tester.getTopRight(divider); + expect(subHeaderTextTopLeft.dx, dividerTopRight.dx + 24.0); + expect(subHeaderTextTopLeft.dy, dialogTopLeft.dy + 16.0); + + // Test sub header icon position. + final Finder subHeaderIcon = find.byIcon(Icons.arrow_drop_down); + final Offset subHeaderIconTopLeft = tester.getTopLeft(subHeaderIcon); + final Offset subHeaderTextTopRight = tester.getTopRight(subHeaderText); + expect(subHeaderIconTopLeft.dx, subHeaderTextTopRight.dx); + expect(subHeaderIconTopLeft.dy, dialogTopLeft.dy + 14.0); + + // Test calendar page view position. + final Finder calendarPageView = find.byType(PageView); + final Offset calendarPageViewTopLeft = tester.getTopLeft(calendarPageView); + final Offset subHeaderTextBottomLeft = tester.getBottomLeft(subHeaderText); + expect(calendarPageViewTopLeft.dx, dividerTopRight.dx); + expect(calendarPageViewTopLeft.dy, subHeaderTextBottomLeft.dy + 16.0); + + // Test month navigation icons position. + final Finder previousMonthButton = find.widgetWithIcon(IconButton, Icons.chevron_left); + final Finder nextMonthButton = find.widgetWithIcon(IconButton, Icons.chevron_right); + final Offset previousMonthButtonTopRight = tester.getTopRight(previousMonthButton); + final Offset nextMonthButtonTopRight = tester.getTopRight(nextMonthButton); + final Offset dialogTopRight = tester.getTopRight(find.byType(AnimatedContainer)); + expect(nextMonthButtonTopRight.dx, dialogTopRight.dx - 4.0); + expect(nextMonthButtonTopRight.dy, dialogTopRight.dy + 2.0); + expect(previousMonthButtonTopRight.dx, nextMonthButtonTopRight.dx - 48.0); + + // Test action buttons position. + final Offset dialogBottomRight = tester.getBottomRight(find.byType(AnimatedContainer)); + final Offset okButtonTopRight = tester.getTopRight(find.widgetWithText(TextButton, 'OK')); + final Offset cancelButtonTopRight = tester.getTopRight( + find.widgetWithText(TextButton, 'Cancel'), + ); + final Offset calendarPageViewBottomRight = tester.getBottomRight(calendarPageView); + expect(okButtonTopRight.dx, dialogBottomRight.dx - 8); + expect(okButtonTopRight.dy, calendarPageViewBottomRight.dy + 2); + final Offset okButtonTopLeft = tester.getTopLeft(find.widgetWithText(TextButton, 'OK')); + expect(cancelButtonTopRight.dx, okButtonTopLeft.dx - 8); + }); + + testWidgets('Default Calendar mode layout (Portrait)', (WidgetTester tester) async { + final Finder helpText = find.text('Select date'); + final Finder headerText = find.text('Fri, Jan 15'); + final Finder subHeaderText = find.text('January 2016'); + final Finder cancelButtonText = find.text('Cancel'); + final Finder okButtonText = find.text('OK'); + + tester.view.physicalSize = narrowWindowSize; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + expect(helpText, findsOneWidget); + expect(headerText, findsOneWidget); + expect(subHeaderText, findsOneWidget); + expect(cancelButtonText, findsOneWidget); + expect(okButtonText, findsOneWidget); + + // Test help text position. + final Offset dialogTopLeft = tester.getTopLeft(find.byType(AnimatedContainer)); + final Offset helpTextTopLeft = tester.getTopLeft(helpText); + expect(helpTextTopLeft.dx, dialogTopLeft.dx + 24.0); + expect(helpTextTopLeft.dy, dialogTopLeft.dy + 16.0); + + // Test header text position + final Offset headerTextTextTopLeft = tester.getTopLeft(headerText); + final Offset helpTextBottomLeft = tester.getBottomLeft(helpText); + expect(headerTextTextTopLeft.dx, dialogTopLeft.dx + 24.0); + expect(headerTextTextTopLeft.dy, helpTextBottomLeft.dy + 28.0); + + // Test switch button position. + final Finder switchButtonM3 = find.widgetWithIcon(IconButton, Icons.edit_outlined); + final Offset switchButtonTopRight = tester.getTopRight(switchButtonM3); + final Offset dialogTopRight = tester.getTopRight(find.byType(AnimatedContainer)); + expect(switchButtonTopRight.dx, dialogTopRight.dx - 12.0); + expect(switchButtonTopRight.dy, headerTextTextTopLeft.dy - 4.0); + + // Test horizontal divider position. + final Finder divider = find.byType(Divider); + final Offset dividerTopLeft = tester.getTopLeft(divider); + final Offset headerTextBottomLeft = tester.getBottomLeft(headerText); + expect(dividerTopLeft.dx, dialogTopLeft.dx); + expect(dividerTopLeft.dy, headerTextBottomLeft.dy + 16.0); + + // Test subHeaderText position. + final Offset subHeaderTextTopLeft = tester.getTopLeft(subHeaderText); + final Offset dividerBottomLeft = tester.getBottomLeft(divider); + expect(subHeaderTextTopLeft.dx, dialogTopLeft.dx + 24.0); + expect(subHeaderTextTopLeft.dy, dividerBottomLeft.dy + 16.0); + + // Test sub header icon position. + final Finder subHeaderIcon = find.byIcon(Icons.arrow_drop_down); + final Offset subHeaderIconTopLeft = tester.getTopLeft(subHeaderIcon); + final Offset subHeaderTextTopRight = tester.getTopRight(subHeaderText); + expect(subHeaderIconTopLeft.dx, subHeaderTextTopRight.dx); + expect(subHeaderIconTopLeft.dy, dividerBottomLeft.dy + 14.0); + + // Test month navigation icons position. + final Finder previousMonthButton = find.widgetWithIcon(IconButton, Icons.chevron_left); + final Finder nextMonthButton = find.widgetWithIcon(IconButton, Icons.chevron_right); + final Offset previousMonthButtonTopRight = tester.getTopRight(previousMonthButton); + final Offset nextMonthButtonTopRight = tester.getTopRight(nextMonthButton); + expect(nextMonthButtonTopRight.dx, dialogTopRight.dx - 4.0); + expect(nextMonthButtonTopRight.dy, dividerBottomLeft.dy + 2.0); + expect(previousMonthButtonTopRight.dx, nextMonthButtonTopRight.dx - 48.0); + + // Test calendar page view position. + final Finder calendarPageView = find.byType(PageView); + final Offset calendarPageViewTopLeft = tester.getTopLeft(calendarPageView); + final Offset subHeaderTextBottomLeft = tester.getBottomLeft(subHeaderText); + expect(calendarPageViewTopLeft.dx, dialogTopLeft.dx); + expect(calendarPageViewTopLeft.dy, subHeaderTextBottomLeft.dy + 16.0); + + // Test action buttons position. + final Offset dialogBottomRight = tester.getBottomRight(find.byType(AnimatedContainer)); + final Offset okButtonTopRight = tester.getTopRight(find.widgetWithText(TextButton, 'OK')); + final Offset cancelButtonTopRight = tester.getTopRight( + find.widgetWithText(TextButton, 'Cancel'), + ); + final Offset calendarPageViewBottomRight = tester.getBottomRight(calendarPageView); + final Offset okButtonTopLeft = tester.getTopLeft(find.widgetWithText(TextButton, 'OK')); + expect(okButtonTopRight.dx, dialogBottomRight.dx - 8); + expect(okButtonTopRight.dy, calendarPageViewBottomRight.dy + 2); + expect(cancelButtonTopRight.dx, okButtonTopLeft.dx - 8); + }); + + testWidgets('Can select a day', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('12')); + await tester.tap(find.text('OK')); + expect(await date, equals(DateTime(2016, DateTime.january, 12))); + }); + }); + + testWidgets('Can select a month', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.tap(find.text('25')); + await tester.tap(find.text('OK')); + expect(await date, DateTime(2015, DateTime.december, 25)); + }); + }); + + testWidgets('Can select a year', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pump(); + await tester.tap(find.text('2018')); + await tester.pump(); + expect(find.text('January 2018'), findsOneWidget); + }); + }); + + testWidgets('Can select a day with no initial date', (WidgetTester tester) async { + initialDate = null; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('12')); + await tester.tap(find.text('OK')); + expect(await date, equals(DateTime(2016, DateTime.january, 12))); + }); + }); + + testWidgets('Can select a month with no initial date', (WidgetTester tester) async { + initialDate = null; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await tester.tap(find.text('25')); + await tester.tap(find.text('OK')); + expect(await date, DateTime(2015, DateTime.december, 25)); + }); + }); + + testWidgets('Can select a year with no initial date', (WidgetTester tester) async { + initialDate = null; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pump(); + await tester.tap(find.text('2018')); + await tester.pump(); + expect(find.text('January 2018'), findsOneWidget); + }); + }); + + testWidgets('Selecting date does not change displayed month', (WidgetTester tester) async { + initialDate = DateTime(2020, DateTime.march, 15); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('April 2020'), findsOneWidget); + await tester.tap(find.text('25')); + await tester.pumpAndSettle(); + expect(find.text('April 2020'), findsOneWidget); + // There isn't a 31 in April so there shouldn't be one if it is showing April + expect(find.text('31'), findsNothing); + }); + }); + + testWidgets('Changing year does change selected date', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('January 2016')); + await tester.pump(); + await tester.tap(find.text('2018')); + await tester.pump(); + await tester.tap(find.text('OK')); + expect(await date, equals(DateTime(2018, DateTime.january, 15))); + }); + }); + + testWidgets('Changing year does not change the month', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(); + await tester.tap(find.text('March 2016')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2018')); + await tester.pumpAndSettle(); + expect(find.text('March 2018'), findsOneWidget); + }); + }); + + testWidgets('Can select a year and then a day', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pump(); + await tester.tap(find.text('2017')); + await tester.pump(); + await tester.tap(find.text('19')); + await tester.tap(find.text('OK')); + expect(await date, DateTime(2017, DateTime.january, 19)); + }); + }); + + testWidgets('Current year is visible in year picker', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('January 2016')); // Switch to year mode. + await tester.pump(); + expect(find.text('2016'), findsOneWidget); + }); + }); + + testWidgets('Cannot select a day outside bounds', (WidgetTester tester) async { + initialDate = DateTime(2017, DateTime.january, 15); + firstDate = initialDate!; + lastDate = initialDate!; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Earlier than firstDate. Should be ignored. + await tester.tap(find.text('10')); + // Later than lastDate. Should be ignored. + await tester.tap(find.text('20')); + await tester.tap(find.text('OK')); + // We should still be on the initial date. + expect(await date, initialDate); + }); + }); + + testWidgets('Cannot select a month past last date', (WidgetTester tester) async { + initialDate = DateTime(2017, DateTime.january, 15); + firstDate = initialDate!; + lastDate = DateTime(2017, DateTime.february, 20); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(nextMonthIcon); + await tester.pumpAndSettle(const Duration(seconds: 1)); + // Shouldn't be possible to keep going into March. + expect(nextMonthIcon, findsNothing); + }); + }); + + testWidgets('Cannot select a month before first date', (WidgetTester tester) async { + initialDate = DateTime(2017, DateTime.january, 15); + firstDate = DateTime(2016, DateTime.december, 10); + lastDate = initialDate!; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(previousMonthIcon); + await tester.pumpAndSettle(const Duration(seconds: 1)); + // Shouldn't be possible to keep going into November. + expect(previousMonthIcon, findsNothing); + }); + }); + + testWidgets('Cannot select disabled year', (WidgetTester tester) async { + initialDate = DateTime(2018, DateTime.july, 4); + firstDate = DateTime(2018, DateTime.june, 9); + lastDate = DateTime(2018, DateTime.december, 15); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('July 2018')); // Switch to year mode. + await tester.pumpAndSettle(); + await tester.tap(find.text('2016')); // Disabled, doesn't change the year. + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(await date, DateTime(2018, DateTime.july, 4)); + }); + }); + + testWidgets('Selecting firstDate year respects firstDate', (WidgetTester tester) async { + initialDate = DateTime(2018, DateTime.may, 4); + firstDate = DateTime(2016, DateTime.june, 9); + lastDate = DateTime(2019, DateTime.january, 15); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('May 2018')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2016')); + await tester.pumpAndSettle(); + // Month should be clamped to June as the range starts at June 2016 + expect(find.text('June 2016'), findsOneWidget); + }); + }); + + testWidgets('Selecting lastDate year respects lastDate', (WidgetTester tester) async { + initialDate = DateTime(2018, DateTime.may, 4); + firstDate = DateTime(2016, DateTime.june, 9); + lastDate = DateTime(2019, DateTime.january, 15); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('May 2018')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2019')); + await tester.pumpAndSettle(); + // Month should be clamped to January as the range ends at January 2019 + expect(find.text('January 2019'), findsOneWidget); + }); + }); + + testWidgets('Only predicate days are selectable', (WidgetTester tester) async { + initialDate = DateTime(2017, DateTime.january, 16); + firstDate = DateTime(2017, DateTime.january, 10); + lastDate = DateTime(2017, DateTime.january, 20); + selectableDayPredicate = (DateTime day) => day.day.isEven; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('13')); // Odd, doesn't work. + await tester.tap(find.text('10')); // Even, works. + await tester.tap(find.text('17')); // Odd, doesn't work. + await tester.tap(find.text('OK')); + expect(await date, DateTime(2017, DateTime.january, 10)); + }); + }); + + testWidgets('Can select initial calendar picker mode', (WidgetTester tester) async { + initialDate = DateTime(2014, DateTime.january, 15); + initialCalendarMode = DatePickerMode.year; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.pump(); + // 2018 wouldn't be available if the year picker wasn't showing. + // The initial current year is 2014. + await tester.tap(find.text('2018')); + await tester.pump(); + expect(find.text('January 2018'), findsOneWidget); + }); + }); + + testWidgets('currentDate is highlighted', (WidgetTester tester) async { + today = DateTime(2016, 1, 2); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.pump(); + const todayColor = Color(0xff2196f3); // default primary color + expect( + findDayGridMaterial(tester), + // The current day should be painted with a circle outline + paints..circle(color: todayColor, style: PaintingStyle.stroke, strokeWidth: 1.0), + ); + }); + }); + + testWidgets('Date picker dayOverlayColor resolves hovered state', (WidgetTester tester) async { + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves focused state', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate to day 30. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.10)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves pressed state', (WidgetTester tester) async { + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.down(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle() // Hovered decoration. + ..circle(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.10)), + ); + await gesture.up(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/130586. + testWidgets('Date picker dayOverlayColor resolves selected and hovered state', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Select day 30. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + final ShapeDecoration day30Decoration = findDayDecoration(tester, '30')!; + expect(day30Decoration.color, theme.colorScheme.primary); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onPrimary.withOpacity(0.08)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves selected and focused state', ( + WidgetTester tester, + ) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Select day 30. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + final ShapeDecoration day30Decoration = findDayDecoration(tester, '30')!; + expect(day30Decoration.color, theme.colorScheme.primary); + + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Day 30 is selected and focused. + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onPrimary.withOpacity(0.10)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves selected and pressed state', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Select day 30. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + final ShapeDecoration day30Decoration = findDayDecoration(tester, '30')!; + expect(day30Decoration.color, theme.colorScheme.primary); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.down(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle() // Hovered decoration. + ..circle(color: theme.colorScheme.onPrimary.withOpacity(0.10)), + ); + await gesture.up(); + }); + + testWidgets('Selecting date does not switch picker to year selection', ( + WidgetTester tester, + ) async { + initialDate = DateTime(2020, DateTime.may, 10); + initialCalendarMode = DatePickerMode.year; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.pump(); + await tester.tap(find.text('2017')); + await tester.pump(); + expect(find.text('May 2017'), findsOneWidget); + await tester.tap(find.text('10')); + await tester.pump(); + expect(find.text('May 2017'), findsOneWidget); + expect(find.text('2017'), findsNothing); + }); + }); + + testWidgets('Calendar dialog contents are visible - textScaler 0.88, 1.0, 2.0', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + final scales = <double>[0.88, 1.0, 2.0]; + + for (final scale in scales) { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Material( + child: DatePickerDialog( + firstDate: DateTime(2001), + lastDate: DateTime(2031, DateTime.december, 31), + initialDate: DateTime(2016, DateTime.january, 15), + initialEntryMode: DatePickerEntryMode.calendarOnly, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Dialog), + matchesGoldenFile('date_picker.calendar.contents.visible.$scale.png'), + ); + } + }); + }); + + group('Input mode', () { + setUp(() { + firstDate = DateTime(2015); + lastDate = DateTime(2017, DateTime.december, 31); + initialDate = DateTime(2016, DateTime.january, 15); + initialEntryMode = DatePickerEntryMode.input; + }); + + testWidgets('Default InputDecoration', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final InputDecoration decoration = tester + .widget<TextField>(find.byType(TextField)) + .decoration!; + expect(decoration.border, const OutlineInputBorder()); + expect(decoration.filled, false); + expect(decoration.hintText, 'mm/dd/yyyy'); + expect(decoration.labelText, 'Enter Date'); + expect(decoration.errorText, null); + }, useMaterial3: true); + }); + + testWidgets('Initial entry mode is used', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsOneWidget); + }); + }); + + testWidgets('Hint, label, and help text is used', (WidgetTester tester) async { + cancelText = 'nope'; + confirmText = 'yep'; + fieldHintText = 'hint'; + fieldLabelText = 'label'; + helpText = 'help'; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.text(cancelText!), findsOneWidget); + expect(find.text(confirmText!), findsOneWidget); + expect(find.text(fieldHintText!), findsOneWidget); + expect(find.text(fieldLabelText!), findsOneWidget); + expect(find.text(helpText!), findsOneWidget); + }); + }); + + testWidgets('KeyboardType is used', (WidgetTester tester) async { + keyboardType = TextInputType.text; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + expect(field.keyboardType, TextInputType.text); + }); + }); + + testWidgets('Initial date is the default', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('OK')); + expect(await date, DateTime(2016, DateTime.january, 15)); + }); + }); + + testWidgets('Can toggle to calendar entry mode', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsOneWidget); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + }); + + testWidgets('Toggle to calendar mode keeps selected date', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + field.controller!.clear(); + + await tester.enterText(find.byType(TextField), '12/25/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + expect(await date, DateTime(2016, DateTime.december, 25)); + }); + }); + + testWidgets('Entered text returns date', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + field.controller!.clear(); + + await tester.enterText(find.byType(TextField), '12/25/2016'); + await tester.tap(find.text('OK')); + expect(await date, DateTime(2016, DateTime.december, 25)); + }); + }); + + testWidgets('Too short entered text shows error', (WidgetTester tester) async { + errorFormatText = 'oops'; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + field.controller!.clear(); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '1225'); + expect(find.text(errorFormatText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText!), findsOneWidget); + }); + }); + + testWidgets('Bad format entered text shows error', (WidgetTester tester) async { + errorFormatText = 'oops'; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + field.controller!.clear(); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '20 days, 3 months, 2003'); + expect(find.text('20 days, 3 months, 2003'), findsOneWidget); + expect(find.text(errorFormatText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText!), findsOneWidget); + }); + }); + + testWidgets('Invalid entered text shows error', (WidgetTester tester) async { + errorInvalidText = 'oops'; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + field.controller!.clear(); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '08/10/1969'); + expect(find.text(errorInvalidText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidText!), findsOneWidget); + }); + }); + + testWidgets('Invalid entered text shows error on autovalidate', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/126397. + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final TextField field = textField(tester); + field.controller!.clear(); + + // Enter some text to trigger autovalidate. + await tester.enterText(find.byType(TextField), 'xyz'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Invalid format validation error should be shown. + expect(find.text('Invalid format.'), findsOneWidget); + + // Clear the text. + field.controller!.clear(); + + // Enter an invalid date that is too long while autovalidate is still on. + await tester.enterText(find.byType(TextField), '10/05/2023666777889'); + await tester.pump(); + + // Invalid format validation error should be shown. + expect(find.text('Invalid format.'), findsOneWidget); + // Should not throw an exception. + expect(tester.takeException(), null); + }); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/131989. + testWidgets('Dialog contents do not overflow when resized during orientation change', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + // Initial window size is wide for landscape mode. + tester.view.physicalSize = wideWindowSize; + tester.view.devicePixelRatio = 1.0; + + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Change window size to narrow for portrait mode. + tester.view.physicalSize = narrowWindowSize; + await tester.pump(); + expect(tester.takeException(), null); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/140311. + testWidgets('Text field stays visible when orientation is portrait and height is reduced', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(720, 1280); + tester.view.devicePixelRatio = 1.0; + initialEntryMode = DatePickerEntryMode.input; + + // Text field and header are visible by default. + await prepareDatePicker(tester, useMaterial3: true, (Future<DateTime?> range) async { + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Select date'), findsOne); + }); + + // Simulate the portait mode on a device with a small display when the virtual + // keyboard is visible. + tester.view.viewInsets = const FakeViewPadding(bottom: 1000); + await tester.pumpAndSettle(); + + // Text field is visible and header is hidden. + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Select date'), findsNothing); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139120. + testWidgets('Dialog contents are visible - textScaler 0.88, 1.0, 2.0', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + final scales = <double>[0.88, 1.0, 2.0]; + + for (final scale in scales) { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Material( + child: DatePickerDialog( + firstDate: DateTime(2001), + lastDate: DateTime(2031, DateTime.december, 31), + initialDate: DateTime(2016, DateTime.january, 15), + initialEntryMode: DatePickerEntryMode.input, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Dialog), + matchesGoldenFile('date_picker.dialog.contents.visible.$scale.png'), + ); + } + }); + }); + + group('Semantics', () { + testWidgets('calendar mode', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Header + expect( + tester.getSemantics(find.text('SELECT DATE')), + matchesSemantics(label: 'SELECT DATE\nFri, Jan 15'), + ); + + expect( + tester.getSemantics(find.text('3')), + matchesSemantics( + label: '3, Sunday, January 3, 2016, Today', + isButton: true, + hasEnabledState: true, + hasTapAction: true, + hasSelectedState: true, + hasFocusAction: true, + isFocusable: true, + isEnabled: true, + ), + ); + + // Input mode toggle button + expect( + tester.getSemantics(switchToInputIcon), + matchesSemantics( + tooltip: 'Switch to input', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + + // The semantics of the CalendarDatePicker are tested in its tests. + + // Ok/Cancel buttons + expect( + tester.getSemantics(find.text('OK')), + matchesSemantics( + label: 'OK', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + expect( + tester.getSemantics(find.text('CANCEL')), + matchesSemantics( + label: 'CANCEL', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + }); + semantics.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/158325. + testWidgets('Calendar mode respects tap target guidelines in portrait orientation', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + + await prepareDatePicker(tester, useMaterial3: true, (Future<DateTime?> date) async { + expect(find.byType(DatePickerDialog), findsOneWidget); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + }); + }); + + testWidgets('input mode', (WidgetTester tester) async { + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + final mockClipboard = MockClipboard(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + final SemanticsHandle semantics = tester.ensureSemantics(); + + initialEntryMode = DatePickerEntryMode.input; + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Header + expect( + tester.getSemantics(find.text('SELECT DATE')), + matchesSemantics(label: 'SELECT DATE\nFri, Jan 15'), + ); + + // Input mode toggle button + expect( + tester.getSemantics(switchToCalendarIcon), + matchesSemantics( + tooltip: 'Switch to calendar', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + + expect( + tester.getSemantics(find.byType(EditableText)), + matchesSemantics( + label: 'Enter Date', + isEnabled: true, + hasEnabledState: true, + isTextField: true, + isFocusable: true, + isFocused: true, + value: '01/15/2016', + hasTapAction: true, + hasFocusAction: true, + hasSetTextAction: true, + hasSetSelectionAction: true, + hasCopyAction: true, + hasCutAction: true, + hasPasteAction: true, + hasMoveCursorBackwardByCharacterAction: true, + hasMoveCursorBackwardByWordAction: true, + validationResult: SemanticsValidationResult.valid, + ), + ); + + // Ok/Cancel buttons + expect( + tester.getSemantics(find.text('OK')), + matchesSemantics( + label: 'OK', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + expect( + tester.getSemantics(find.text('CANCEL')), + matchesSemantics( + label: 'CANCEL', + isButton: true, + hasTapAction: true, + hasFocusAction: true, + isEnabled: true, + hasEnabledState: true, + isFocusable: true, + ), + ); + }); + semantics.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/pull/152705 + testWidgets('datepicker dialog semantics node not focusable', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final SemanticsNode node = tester.semantics.find(find.byType(DatePickerDialog)); + final SemanticsData semanticsData = node.getSemanticsData(); + expect(semanticsData.flagsCollection.isFocused, Tristate.none); + }); + }); + + group('Keyboard navigation', () { + testWidgets('Can toggle to calendar entry mode', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.byType(TextField), findsNothing); + // Navigate to the entry toggle button and activate it + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Should be in the input mode + expect(find.byType(TextField), findsOneWidget); + }); + }); + + testWidgets('Can toggle to year mode', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.text('2016'), findsNothing); + // Navigate to the year selector and activate it + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // The years should be visible + expect(find.text('2016'), findsOneWidget); + }); + }); + + testWidgets('Can navigate next/previous months', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + expect(find.text('January 2016'), findsOneWidget); + // Navigate to the previous month button and activate it twice + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Should be showing Nov 2015 + expect(find.text('November 2015'), findsOneWidget); + + // Navigate to the next month button and activate it four times + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Should be on Mar 2016 + expect(find.text('March 2016'), findsOneWidget); + }); + }); + + testWidgets('Can navigate date grid with arrow keys', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate from Jan 15 to Jan 18 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 + expect(await date, DateTime(2016, DateTime.january, 18)); + }); + }); + + testWidgets('Navigating with arrow keys scrolls months', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to Dec 31 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Should have scrolled to Dec 2015 + expect(find.text('December 2015'), findsOneWidget); + + // Navigate from Dec 31 to Nov 26 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + // Should have scrolled to Nov 2015 + expect(find.text('November 2015'), findsOneWidget); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 + expect(await date, DateTime(2015, DateTime.november, 26)); + }); + }); + + testWidgets('RTL text direction reverses the horizontal arrow key navigation', ( + WidgetTester tester, + ) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to 19 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 + expect(await date, DateTime(2016, DateTime.january, 19)); + }, textDirection: TextDirection.rtl); + }); + }); + + group('Screen configurations', () { + // Test various combinations of screen sizes, orientations and text scales + // to ensure the layout doesn't overflow and cause an exception to be thrown. + + // Regression tests for https://github.com/flutter/flutter/issues/21383 + // Regression tests for https://github.com/flutter/flutter/issues/19744 + // Regression tests for https://github.com/flutter/flutter/issues/17745 + + // Common screen size roughly based on a Pixel 1 + const kCommonScreenSizePortrait = Size(1070, 1770); + const kCommonScreenSizeLandscape = Size(1770, 1070); + + // Small screen size based on a LG K130 + const kSmallScreenSizePortrait = Size(320, 521); + const kSmallScreenSizeLandscape = Size(521, 320); + + Future<void> showPicker(WidgetTester tester, Size size, [double textScaleFactor = 1.0]) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.text('OK')); + }); + await tester.pumpAndSettle(); + } + + testWidgets('common screen size - portrait', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizePortrait); + expect(tester.takeException(), isNull); + }); + + testWidgets('common screen size - landscape', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizeLandscape); + expect(tester.takeException(), isNull); + }); + + testWidgets('common screen size - portrait - textScale 1.3', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizePortrait, 1.3); + expect(tester.takeException(), isNull); + }); + + testWidgets('common screen size - landscape - textScale 1.3', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizeLandscape, 1.3); + expect(tester.takeException(), isNull); + }); + + testWidgets('small screen size - portrait', (WidgetTester tester) async { + await showPicker(tester, kSmallScreenSizePortrait); + expect(tester.takeException(), isNull); + }); + + testWidgets('small screen size - landscape', (WidgetTester tester) async { + await showPicker(tester, kSmallScreenSizeLandscape); + expect(tester.takeException(), isNull); + }); + + testWidgets('small screen size - portrait -textScale 1.3', (WidgetTester tester) async { + await showPicker(tester, kSmallScreenSizePortrait, 1.3); + expect(tester.takeException(), isNull); + }); + + testWidgets('small screen size - landscape - textScale 1.3', (WidgetTester tester) async { + await showPicker(tester, kSmallScreenSizeLandscape, 1.3); + expect(tester.takeException(), isNull); + }); + }); + + group('showDatePicker avoids overlapping display features', () { + testWidgets('positioning with anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(DatePickerDialog)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(DatePickerDialog)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning with Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the right screen + expect(tester.getTopLeft(find.byType(DatePickerDialog)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(DatePickerDialog)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning with defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2018), + lastDate: DateTime(2030), + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(DatePickerDialog)), Offset.zero); + expect(tester.getBottomRight(find.byType(DatePickerDialog)), const Offset(390.0, 600.0)); + }); + }); + + testWidgets('DatePickerDialog is state restorable', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(restorationScopeId: 'app', home: _RestorableDatePickerDialogTestWidget()), + ); + + // The date picker should be closed. + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('25/7/2021'), findsOneWidget); + + // Open the date picker. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // The date picker should be open after restoring. + expect(find.byType(DatePickerDialog), findsOneWidget); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + + // The date picker should be closed, the text value updated to the + // newly selected date. + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('25/7/2021'), findsOneWidget); + + // The date picker should be open after restoring. + await tester.restoreFrom(restorationData); + expect(find.byType(DatePickerDialog), findsOneWidget); + + // Select a different date. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + + // Restart after the new selection. It should remain selected. + await tester.restartAndRestore(); + + // Close the date picker. + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // The date picker should be closed, the text value updated to the + // newly selected date. + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('30/7/2021'), findsOneWidget); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + testWidgets('DatePickerDialog state restoration - DatePickerEntryMode', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + restorationScopeId: 'app', + home: _RestorableDatePickerDialogTestWidget( + datePickerEntryMode: DatePickerEntryMode.calendarOnly, + ), + ), + ); + + // The date picker should be closed. + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('25/7/2021'), findsOneWidget); + + // Open the date picker. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + + // Only in calendar mode and cannot switch out. + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // The date picker should be open after restoring. + expect(find.byType(DatePickerDialog), findsOneWidget); + // Only in calendar mode and cannot switch out. + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + + // The date picker should be closed, the text value should be the same + // as before. + expect(find.byType(DatePickerDialog), findsNothing); + expect(find.text('25/7/2021'), findsOneWidget); + + // The date picker should be open after restoring. + await tester.restoreFrom(restorationData); + expect(find.byType(DatePickerDialog), findsOneWidget); + // Only in calendar mode and cannot switch out. + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + testWidgets('Test Callback on Toggle of DatePicker Mode', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + await tester.tap(find.byIcon(Icons.edit)); + expect(currentMode, DatePickerEntryMode.input); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + await tester.tap(find.byIcon(Icons.calendar_today)); + expect(currentMode, DatePickerEntryMode.calendar); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + }); + + testWidgets('DatePickerDialog with updated insetPadding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + insetPadding: const EdgeInsets.fromLTRB(10.0, 20.0, 30.0, 40.0), + ), + ), + ), + ); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, const EdgeInsets.fromLTRB(10.0, 20.0, 30.0, 40.0)); + }); + + group('Landscape input-only date picker headers use headlineSmall', () { + // Regression test for https://github.com/flutter/flutter/issues/122056 + + // Common screen size roughly based on a Pixel 1 + const kCommonScreenSizePortrait = Size(1070, 1770); + const kCommonScreenSizeLandscape = Size(1770, 1070); + + Future<void> showPicker(WidgetTester tester, Size size) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + initialEntryMode = DatePickerEntryMode.input; + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, useMaterial3: true); + } + + testWidgets('portrait', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizePortrait); + expect(tester.widget<Text>(find.text('Fri, Jan 15')).style?.fontSize, 32); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('landscape', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizeLandscape); + expect(tester.widget<Text>(find.text('Fri, Jan 15')).style?.fontSize, 24); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + group('showDatePicker Dialog', () { + testWidgets('Default dialog size', (WidgetTester tester) async { + Future<void> showPicker(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}); + } + + const wideWindowSize = Size(1920.0, 1080.0); + const narrowWindowSize = Size(1070.0, 1770.0); + const calendarLandscapeDialogSize = Size(496.0, 346.0); + const calendarPortraitDialogSizeM2 = Size(330.0, 518.0); + + // Test landscape layout. + await showPicker(tester, wideWindowSize); + + Size dialogContainerSize = tester.getSize(find.byType(AnimatedContainer)); + expect(dialogContainerSize, calendarLandscapeDialogSize); + + // Close the dialog. + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + // Test portrait layout. + await showPicker(tester, narrowWindowSize); + + dialogContainerSize = tester.getSize(find.byType(AnimatedContainer)); + expect(dialogContainerSize, calendarPortraitDialogSizeM2); + }); + + testWidgets('Default dialog properties', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surface); + expect(dialogMaterial.shadowColor, theme.shadowColor); + expect(dialogMaterial.elevation, 24.0); + expect( + dialogMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)); + }, useMaterial3: theme.useMaterial3); + }); + }); + + group('Input mode', () { + setUp(() { + firstDate = DateTime(2015); + lastDate = DateTime(2017, DateTime.december, 31); + initialDate = DateTime(2016, DateTime.january, 15); + initialEntryMode = DatePickerEntryMode.input; + }); + + testWidgets('Default InputDecoration', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future<DateTime?> date) async { + final InputDecoration decoration = tester + .widget<TextField>(find.byType(TextField)) + .decoration!; + expect(decoration.border, const UnderlineInputBorder()); + expect(decoration.filled, false); + expect(decoration.hintText, 'mm/dd/yyyy'); + expect(decoration.labelText, 'Enter Date'); + expect(decoration.errorText, null); + }); + }); + }); + + testWidgets('Date picker dayOverlayColor resolves hovered state', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves focused state', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate to day 30. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.12)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves pressed state', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.down(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle() // Hovered decoration. + ..circle(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.12)), + ); + await gesture.up(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/130586. + testWidgets('Date picker dayOverlayColor resolves selected and hovered state', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Select day 30. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + final ShapeDecoration day30Decoration = findDayDecoration(tester, '30')!; + expect(day30Decoration.color, theme.colorScheme.primary); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onPrimary.withOpacity(0.08)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves selected and focused state', ( + WidgetTester tester, + ) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Select day 30. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + final ShapeDecoration day30Decoration = findDayDecoration(tester, '30')!; + expect(day30Decoration.color, theme.colorScheme.primary); + + // Navigate to the grid. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Day 30 is selected and focused. + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: theme.colorScheme.onPrimary.withOpacity(0.12)), + ); + }); + + testWidgets('Date picker dayOverlayColor resolves selected and pressed state', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + await prepareDatePicker(tester, (Future<DateTime?> date) async {}, theme: theme); + + // Select day 30. + await tester.tap(find.text('30')); + await tester.pumpAndSettle(); + final ShapeDecoration day30Decoration = findDayDecoration(tester, '30')!; + expect(day30Decoration.color, theme.colorScheme.primary); + + final Offset center = tester.getCenter(find.text('30')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.down(center); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle() // Hovered decoration. + ..circle(color: theme.colorScheme.onPrimary.withOpacity(0.38)), + ); + await gesture.up(); + }); + }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final DatePickerDialog dialog = tester.widget(find.byType(DatePickerDialog)); + expect(dialog.calendarDelegate, isA<GregorianCalendarDelegate>()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final DatePickerDialog dialog = tester.widget(find.byType(DatePickerDialog)); + expect(dialog.calendarDelegate, isA<TestCalendarDelegate>()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + Text getLastDayText() { + final Finder dayFinder = find.descendant(of: find.byType(Ink), matching: find.byType(Text)); + return tester.widget(dayFinder.last); + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder nextMonthButton = find.byIcon(Icons.chevron_right); + + Text lastDayText = getLastDayText(); + expect(find.text('January 2016'), findsOneWidget); + expect(lastDayText.data, equals('28')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(); + expect(find.text('February 2016'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(); + expect(find.text('March 2016'), findsOneWidget); + expect(lastDayText.data, equals('28')); + }); + }); + + testWidgets('DatePickerDialog renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: DatePickerDialog(firstDate: firstDate, lastDate: lastDate), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DatePickerDialog)).isEmpty, isTrue); + }); +} + +class _RestorableDatePickerDialogTestWidget extends StatefulWidget { + const _RestorableDatePickerDialogTestWidget({ + this.datePickerEntryMode = DatePickerEntryMode.calendar, + }); + + final DatePickerEntryMode datePickerEntryMode; + + @override + _RestorableDatePickerDialogTestWidgetState createState() => + _RestorableDatePickerDialogTestWidgetState(); +} + +@pragma('vm:entry-point') +class _RestorableDatePickerDialogTestWidgetState + extends State<_RestorableDatePickerDialogTestWidget> + with RestorationMixin { + @override + String? get restorationId => 'scaffold_state'; + + final RestorableDateTime _selectedDate = RestorableDateTime(DateTime(2021, 7, 25)); + late final RestorableRouteFuture<DateTime?> _restorableDatePickerRouteFuture = + RestorableRouteFuture<DateTime?>( + onComplete: _selectDate, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _datePickerRoute, + arguments: <String, dynamic>{ + 'selectedDate': _selectedDate.value.millisecondsSinceEpoch, + 'datePickerEntryMode': widget.datePickerEntryMode.index, + }, + ); + }, + ); + + @override + void dispose() { + _selectedDate.dispose(); + _restorableDatePickerRouteFuture.dispose(); + super.dispose(); + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedDate, 'selected_date'); + registerForRestoration(_restorableDatePickerRouteFuture, 'date_picker_route_future'); + } + + void _selectDate(DateTime? newSelectedDate) { + if (newSelectedDate != null) { + setState(() { + _selectedDate.value = newSelectedDate; + }); + } + } + + @pragma('vm:entry-point') + static Route<DateTime> _datePickerRoute(BuildContext context, Object? arguments) { + return DialogRoute<DateTime>( + context: context, + builder: (BuildContext context) { + final args = arguments! as Map<dynamic, dynamic>; + return DatePickerDialog( + restorationId: 'date_picker_dialog', + initialEntryMode: DatePickerEntryMode.values[args['datePickerEntryMode'] as int], + initialDate: DateTime.fromMillisecondsSinceEpoch(args['selectedDate'] as int), + firstDate: DateTime(2021), + lastDate: DateTime(2022), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final DateTime selectedDateTime = _selectedDate.value; + // Example: "25/7/1994" + final selectedDateTimeString = + '${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}'; + return Scaffold( + body: Center( + child: Column( + children: <Widget>[ + OutlinedButton( + onPressed: () { + _restorableDatePickerRouteFuture.present(); + }, + child: const Text('X'), + ), + Text(selectedDateTimeString), + ], + ), + ), + ); + } +} + +class _DatePickerObserver extends NavigatorObserver { + int datePickerCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + datePickerCount++; + } + super.didPush(route, previousRoute); + } + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + datePickerCount--; + } + super.didPop(route, previousRoute); + } +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/material_ui/test/material/date_picker_theme_test.dart b/packages/material_ui/test/material/date_picker_theme_test.dart new file mode 100644 index 000000000000..2305404708ad --- /dev/null +++ b/packages/material_ui/test/material/date_picker_theme_test.dart @@ -0,0 +1,1380 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const datePickerTheme = DatePickerThemeData( + backgroundColor: Color(0xfffffff0), + elevation: 6, + shadowColor: Color(0xfffffff1), + surfaceTintColor: Color(0xfffffff2), + shape: RoundedRectangleBorder(), + headerBackgroundColor: Color(0xfffffff3), + headerForegroundColor: Color(0xfffffff4), + headerHeadlineStyle: TextStyle(fontSize: 10), + headerHelpStyle: TextStyle(fontSize: 11), + weekdayStyle: TextStyle(fontSize: 12), + dayStyle: TextStyle(fontSize: 13), + dayForegroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff5)), + dayBackgroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff6)), + dayOverlayColor: MaterialStatePropertyAll<Color>(Color(0xfffffff7)), + dayShape: MaterialStatePropertyAll<OutlinedBorder>(RoundedRectangleBorder()), + todayForegroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff8)), + todayBackgroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff9)), + todayBorder: BorderSide(width: 3, color: Color(0x00000000)), + yearStyle: TextStyle(fontSize: 13), + yearForegroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffffa)), + yearBackgroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffffb)), + yearOverlayColor: MaterialStatePropertyAll<Color>(Color(0xfffffffc)), + yearShape: MaterialStatePropertyAll<OutlinedBorder>(RoundedRectangleBorder()), + rangePickerBackgroundColor: Color(0xfffffffd), + rangePickerElevation: 7, + rangePickerShadowColor: Color(0xfffffffe), + rangePickerSurfaceTintColor: Color(0xffffffff), + rangePickerShape: RoundedRectangleBorder(), + rangePickerHeaderBackgroundColor: Color(0xffffff0f), + rangePickerHeaderForegroundColor: Color(0xffffff1f), + rangePickerHeaderHeadlineStyle: TextStyle(fontSize: 14), + rangePickerHeaderHelpStyle: TextStyle(fontSize: 15), + rangeSelectionBackgroundColor: Color(0xffffff2f), + rangeSelectionOverlayColor: MaterialStatePropertyAll<Color>(Color(0xffffff3f)), + dividerColor: Color(0xffffff4f), + inputDecorationTheme: InputDecorationTheme( + fillColor: Color(0xffffff5f), + border: UnderlineInputBorder(), + ), + cancelButtonStyle: ButtonStyle( + foregroundColor: MaterialStatePropertyAll<Color>(Color(0xffffff6f)), + ), + confirmButtonStyle: ButtonStyle( + foregroundColor: MaterialStatePropertyAll<Color>(Color(0xffffff7f)), + ), + locale: Locale('en'), + subHeaderForegroundColor: Color(0xffffff8f), + toggleButtonTextStyle: TextStyle(fontSize: 13), + ); + + Material findDialogMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + } + + Material findHeaderMaterial(WidgetTester tester, String text) { + return tester.widget<Material>( + find.ancestor(of: find.text(text), matching: find.byType(Material)).first, + ); + } + + ShapeDecoration? findTextDecoration(WidgetTester tester, String date) { + final Container container = tester.widget<Container>( + find.ancestor(of: find.text(date), matching: find.byType(Container)).first, + ); + return container.decoration as ShapeDecoration?; + } + + ShapeDecoration? findDayDecoration(WidgetTester tester, String day) { + return tester + .widget<Ink>(find.ancestor(of: find.text(day), matching: find.byType(Ink))) + .decoration + as ShapeDecoration?; + } + + ButtonStyle actionButtonStyle(WidgetTester tester, String text) { + return tester.widget<TextButton>(find.widgetWithText(TextButton, text)).style!; + } + + const wideWindowSize = Size(1920.0, 1080.0); + const narrowWindowSize = Size(1070.0, 1770.0); + + test('DatePickerThemeData copyWith, ==, hashCode basics', () { + expect(const DatePickerThemeData(), const DatePickerThemeData().copyWith()); + expect(const DatePickerThemeData().hashCode, const DatePickerThemeData().copyWith().hashCode); + }); + + test('DatePickerThemeData lerp special cases', () { + const data = DatePickerThemeData(); + expect(identical(DatePickerThemeData.lerp(data, data, 0.5), data), true); + }); + + test('DatePickerThemeData defaults', () { + const theme = DatePickerThemeData(); + expect(theme.backgroundColor, null); + expect(theme.elevation, null); + expect(theme.shadowColor, null); + expect(theme.surfaceTintColor, null); + expect(theme.shape, null); + expect(theme.headerBackgroundColor, null); + expect(theme.headerForegroundColor, null); + expect(theme.headerHeadlineStyle, null); + expect(theme.headerHelpStyle, null); + expect(theme.weekdayStyle, null); + expect(theme.dayStyle, null); + expect(theme.dayForegroundColor, null); + expect(theme.dayBackgroundColor, null); + expect(theme.dayOverlayColor, null); + expect(theme.dayShape, null); + expect(theme.todayForegroundColor, null); + expect(theme.todayBackgroundColor, null); + expect(theme.todayBorder, null); + expect(theme.yearStyle, null); + expect(theme.yearForegroundColor, null); + expect(theme.yearBackgroundColor, null); + expect(theme.yearOverlayColor, null); + expect(theme.rangePickerBackgroundColor, null); + expect(theme.rangePickerElevation, null); + expect(theme.rangePickerShadowColor, null); + expect(theme.rangePickerSurfaceTintColor, null); + expect(theme.rangePickerShape, null); + expect(theme.rangePickerHeaderBackgroundColor, null); + expect(theme.rangePickerHeaderForegroundColor, null); + expect(theme.rangePickerHeaderHeadlineStyle, null); + expect(theme.rangePickerHeaderHelpStyle, null); + expect(theme.rangeSelectionBackgroundColor, null); + expect(theme.rangeSelectionOverlayColor, null); + expect(theme.dividerColor, null); + expect(theme.inputDecorationTheme, null); + expect(theme.cancelButtonStyle, null); + expect(theme.confirmButtonStyle, null); + expect(theme.locale, null); + expect(theme.subHeaderForegroundColor, null); + expect(theme.toggleButtonTextStyle, null); + }); + + testWidgets('DatePickerTheme.defaults M3 defaults', (WidgetTester tester) async { + late final DatePickerThemeData m3; // M3 Defaults + late final ThemeData theme; + late final ColorScheme colorScheme; + late final TextTheme textTheme; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + m3 = DatePickerTheme.defaults(context); + theme = Theme.of(context); + colorScheme = theme.colorScheme; + textTheme = theme.textTheme; + return Container(); + }, + ), + ), + ); + + expect(m3.backgroundColor, colorScheme.surfaceContainerHigh); + expect(m3.elevation, 6); + expect(m3.shadowColor, const Color(0x00000000)); // Colors.transparent + expect(m3.surfaceTintColor, Colors.transparent); + expect( + m3.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28))), + ); + expect(m3.headerBackgroundColor, const Color(0x00000000)); // Colors.transparent + expect(m3.headerForegroundColor, colorScheme.onSurfaceVariant); + expect(m3.headerHeadlineStyle, textTheme.headlineLarge); + expect(m3.headerHelpStyle, textTheme.labelLarge); + expect(m3.weekdayStyle, textTheme.bodyLarge?.apply(color: colorScheme.onSurface)); + expect(m3.dayStyle, textTheme.bodyLarge); + expect(m3.dayForegroundColor?.resolve(<WidgetState>{}), colorScheme.onSurface); + expect( + m3.dayForegroundColor?.resolve(<WidgetState>{WidgetState.selected}), + colorScheme.onPrimary, + ); + expect( + m3.dayForegroundColor?.resolve(<WidgetState>{WidgetState.disabled}), + colorScheme.onSurface.withOpacity(0.38), + ); + expect(m3.dayBackgroundColor?.resolve(<WidgetState>{}), null); + expect( + m3.dayBackgroundColor?.resolve(<WidgetState>{WidgetState.selected}), + colorScheme.primary, + ); + expect(m3.dayOverlayColor?.resolve(<WidgetState>{}), null); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.hovered}), + colorScheme.onPrimary.withOpacity(0.08), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.focused}), + colorScheme.onPrimary.withOpacity(0.1), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + colorScheme.onSurfaceVariant.withOpacity(0.08), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.focused}), + colorScheme.onSurfaceVariant.withOpacity(0.1), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.pressed}), + colorScheme.onSurfaceVariant.withOpacity(0.1), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.hovered, + WidgetState.focused, + }), + colorScheme.onPrimary.withOpacity(0.08), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.hovered, + WidgetState.pressed, + }), + colorScheme.onPrimary.withOpacity(0.1), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.hovered, WidgetState.focused}), + colorScheme.onSurfaceVariant.withOpacity(0.08), + ); + expect( + m3.dayOverlayColor?.resolve(<WidgetState>{WidgetState.hovered, WidgetState.pressed}), + colorScheme.onSurfaceVariant.withOpacity(0.1), + ); + expect(m3.dayShape?.resolve(<WidgetState>{}), const CircleBorder()); + expect(m3.todayForegroundColor?.resolve(<WidgetState>{}), colorScheme.primary); + expect( + m3.todayForegroundColor?.resolve(<WidgetState>{WidgetState.disabled}), + colorScheme.primary.withOpacity(0.38), + ); + expect(m3.todayBorder, BorderSide(color: colorScheme.primary)); + expect(m3.yearStyle, textTheme.bodyLarge); + expect(m3.yearForegroundColor?.resolve(<WidgetState>{}), colorScheme.onSurfaceVariant); + expect( + m3.yearForegroundColor?.resolve(<WidgetState>{WidgetState.selected}), + colorScheme.onPrimary, + ); + expect( + m3.yearForegroundColor?.resolve(<WidgetState>{WidgetState.disabled}), + colorScheme.onSurfaceVariant.withOpacity(0.38), + ); + expect(m3.yearBackgroundColor?.resolve(<WidgetState>{}), null); + expect( + m3.yearBackgroundColor?.resolve(<WidgetState>{WidgetState.selected}), + colorScheme.primary, + ); + expect(m3.yearOverlayColor?.resolve(<WidgetState>{}), null); + expect( + m3.yearOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.hovered}), + colorScheme.onPrimary.withOpacity(0.08), + ); + expect( + m3.yearOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.focused}), + colorScheme.onPrimary.withOpacity(0.1), + ); + expect( + m3.yearOverlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + colorScheme.onSurfaceVariant.withOpacity(0.08), + ); + expect( + m3.yearOverlayColor?.resolve(<WidgetState>{WidgetState.focused}), + colorScheme.onSurfaceVariant.withOpacity(0.1), + ); + expect( + m3.yearOverlayColor?.resolve(<WidgetState>{WidgetState.pressed}), + colorScheme.onSurfaceVariant.withOpacity(0.1), + ); + expect(m3.rangePickerElevation, 0); + expect(m3.rangePickerShape, const RoundedRectangleBorder()); + expect(m3.rangePickerShadowColor, Colors.transparent); + expect(m3.rangePickerSurfaceTintColor, Colors.transparent); + expect(m3.rangeSelectionOverlayColor?.resolve(<WidgetState>{}), null); + expect(m3.rangePickerHeaderBackgroundColor, Colors.transparent); + expect(m3.rangePickerHeaderForegroundColor, colorScheme.onSurfaceVariant); + expect(m3.rangePickerHeaderHeadlineStyle, textTheme.titleLarge); + expect(m3.rangePickerHeaderHelpStyle, textTheme.titleSmall); + expect(m3.dividerColor, null); + expect(m3.inputDecorationTheme, null); + expect( + m3.cancelButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + expect( + m3.confirmButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + expect(m3.locale, null); + expect(m3.subHeaderForegroundColor, colorScheme.onSurface.withOpacity(0.60)); + expect( + m3.toggleButtonTextStyle, + textTheme.titleSmall?.apply(color: m3.subHeaderForegroundColor), + ); + }); + + testWidgets('DatePickerTheme.defaults M2 defaults', (WidgetTester tester) async { + late final DatePickerThemeData m2; // M2 defaults + late final ThemeData theme; + late final ColorScheme colorScheme; + late final TextTheme textTheme; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + m2 = DatePickerTheme.defaults(context); + theme = Theme.of(context); + colorScheme = theme.colorScheme; + textTheme = theme.textTheme; + return Container(); + }, + ), + ), + ); + + expect(m2.elevation, 24); + expect( + m2.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + expect(m2.headerBackgroundColor, colorScheme.primary); + expect(m2.headerForegroundColor, colorScheme.onPrimary); + expect(m2.headerHeadlineStyle, textTheme.headlineSmall); + expect(m2.headerHelpStyle, textTheme.labelSmall); + expect( + m2.weekdayStyle, + textTheme.bodySmall?.apply(color: colorScheme.onSurface.withOpacity(0.60)), + ); + expect(m2.dayStyle, textTheme.bodySmall); + expect(m2.dayForegroundColor?.resolve(<WidgetState>{}), colorScheme.onSurface); + expect( + m2.dayForegroundColor?.resolve(<WidgetState>{WidgetState.selected}), + colorScheme.onPrimary, + ); + expect( + m2.dayForegroundColor?.resolve(<WidgetState>{WidgetState.disabled}), + colorScheme.onSurface.withOpacity(0.38), + ); + expect(m2.dayBackgroundColor?.resolve(<WidgetState>{}), null); + expect( + m2.dayBackgroundColor?.resolve(<WidgetState>{WidgetState.selected}), + colorScheme.primary, + ); + expect(m2.dayOverlayColor?.resolve(<WidgetState>{}), null); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.hovered}), + colorScheme.onPrimary.withOpacity(0.08), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.focused}), + colorScheme.onPrimary.withOpacity(0.12), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{WidgetState.selected, WidgetState.pressed}), + colorScheme.onPrimary.withOpacity(0.38), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.hovered, + WidgetState.focused, + }), + colorScheme.onPrimary.withOpacity(0.08), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.hovered, + WidgetState.pressed, + }), + colorScheme.onPrimary.withOpacity(0.38), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + colorScheme.onSurfaceVariant.withOpacity(0.08), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{WidgetState.focused}), + colorScheme.onSurfaceVariant.withOpacity(0.12), + ); + expect( + m2.dayOverlayColor?.resolve(<WidgetState>{WidgetState.pressed}), + colorScheme.onSurfaceVariant.withOpacity(0.12), + ); + expect(m2.dayShape?.resolve(<WidgetState>{}), const CircleBorder()); + expect(m2.todayForegroundColor?.resolve(<WidgetState>{}), colorScheme.primary); + expect( + m2.todayForegroundColor?.resolve(<WidgetState>{WidgetState.disabled}), + colorScheme.onSurface.withOpacity(0.38), + ); + expect(m2.todayBorder, BorderSide(color: colorScheme.primary)); + expect(m2.yearStyle, textTheme.bodyLarge); + expect(m2.rangePickerBackgroundColor, colorScheme.surface); + expect(m2.rangePickerElevation, 0); + expect(m2.rangePickerShape, const RoundedRectangleBorder()); + expect(m2.rangePickerShadowColor, Colors.transparent); + expect(m2.rangePickerSurfaceTintColor, Colors.transparent); + expect(m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{}), null); + expect( + m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.hovered, + }), + colorScheme.onPrimary.withOpacity(0.08), + ); + expect( + m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.focused, + }), + colorScheme.onPrimary.withOpacity(0.12), + ); + expect( + m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{ + WidgetState.selected, + WidgetState.pressed, + }), + colorScheme.onPrimary.withOpacity(0.38), + ); + expect( + m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + colorScheme.onSurfaceVariant.withOpacity(0.08), + ); + expect( + m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{WidgetState.focused}), + colorScheme.onSurfaceVariant.withOpacity(0.12), + ); + expect( + m2.rangeSelectionOverlayColor?.resolve(<WidgetState>{WidgetState.pressed}), + colorScheme.onSurfaceVariant.withOpacity(0.12), + ); + expect(m2.rangePickerHeaderBackgroundColor, colorScheme.primary); + expect(m2.rangePickerHeaderForegroundColor, colorScheme.onPrimary); + expect(m2.rangePickerHeaderHeadlineStyle, textTheme.headlineSmall); + expect(m2.rangePickerHeaderHelpStyle, textTheme.labelSmall); + expect(m2.dividerColor, null); + expect(m2.inputDecorationTheme, null); + expect( + m2.cancelButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + expect( + m2.confirmButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + expect(m2.locale, null); + expect(m2.yearShape?.resolve(<WidgetState>{}), const StadiumBorder()); + expect(m2.subHeaderForegroundColor, colorScheme.onSurface.withOpacity(0.60)); + expect( + m2.toggleButtonTextStyle, + textTheme.titleSmall?.apply(color: m2.subHeaderForegroundColor), + ); + }); + + testWidgets('Default DatePickerThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DatePickerThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('DatePickerThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + datePickerTheme.debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'backgroundColor: ${const Color(0xfffffff0)}', + 'elevation: 6.0', + 'shadowColor: ${const Color(0xfffffff1)}', + 'surfaceTintColor: ${const Color(0xfffffff2)}', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + 'headerBackgroundColor: ${const Color(0xfffffff3)}', + 'headerForegroundColor: ${const Color(0xfffffff4)}', + 'headerHeadlineStyle: TextStyle(inherit: true, size: 10.0)', + 'headerHelpStyle: TextStyle(inherit: true, size: 11.0)', + 'weekDayStyle: TextStyle(inherit: true, size: 12.0)', + 'dayStyle: TextStyle(inherit: true, size: 13.0)', + 'dayForegroundColor: WidgetStatePropertyAll(${const Color(0xfffffff5)})', + 'dayBackgroundColor: WidgetStatePropertyAll(${const Color(0xfffffff6)})', + 'dayOverlayColor: WidgetStatePropertyAll(${const Color(0xfffffff7)})', + 'dayShape: WidgetStatePropertyAll(RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero))', + 'todayForegroundColor: WidgetStatePropertyAll(${const Color(0xfffffff8)})', + 'todayBackgroundColor: WidgetStatePropertyAll(${const Color(0xfffffff9)})', + 'todayBorder: BorderSide(color: Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.0000, colorSpace: ColorSpace.sRGB), width: 3.0)', + 'yearStyle: TextStyle(inherit: true, size: 13.0)', + 'yearForegroundColor: WidgetStatePropertyAll(${const Color(0xfffffffa)})', + 'yearBackgroundColor: WidgetStatePropertyAll(${const Color(0xfffffffb)})', + 'yearOverlayColor: WidgetStatePropertyAll(${const Color(0xfffffffc)})', + 'yearShape: WidgetStatePropertyAll(RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero))', + 'rangePickerBackgroundColor: ${const Color(0xfffffffd)}', + 'rangePickerElevation: 7.0', + 'rangePickerShadowColor: ${const Color(0xfffffffe)}', + 'rangePickerSurfaceTintColor: ${const Color(0xffffffff)}', + 'rangePickerShape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + 'rangePickerHeaderBackgroundColor: ${const Color(0xffffff0f)}', + 'rangePickerHeaderForegroundColor: ${const Color(0xffffff1f)}', + 'rangePickerHeaderHeadlineStyle: TextStyle(inherit: true, size: 14.0)', + 'rangePickerHeaderHelpStyle: TextStyle(inherit: true, size: 15.0)', + 'rangeSelectionBackgroundColor: ${const Color(0xffffff2f)}', + 'rangeSelectionOverlayColor: WidgetStatePropertyAll(${const Color(0xffffff3f)})', + 'dividerColor: ${const Color(0xffffff4f)}', + 'inputDecorationTheme: InputDecorationThemeData#00000(fillColor: ${const Color(0xffffff5f)}, border: UnderlineInputBorder())', + 'cancelButtonStyle: ButtonStyle#00000(foregroundColor: WidgetStatePropertyAll(${const Color(0xffffff6f)}))', + 'confirmButtonStyle: ButtonStyle#00000(foregroundColor: WidgetStatePropertyAll(${const Color(0xffffff7f)}))', + 'locale: en', + 'toggleButtonTextStyle: TextStyle(inherit: true, size: 13.0)', + 'subHeaderForegroundColor: ${const Color(0xffffff8f)}', + ]), + ); + }); + + testWidgets('DatePickerDialog uses ThemeData datePicker theme (calendar mode)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: datePickerTheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DatePickerDialog( + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ), + ), + ), + ); + + final Material material = findDialogMaterial(tester); + expect(material.color, datePickerTheme.backgroundColor); + expect(material.elevation, datePickerTheme.elevation); + expect(material.shadowColor, datePickerTheme.shadowColor); + expect(material.surfaceTintColor, datePickerTheme.surfaceTintColor); + expect(material.shape, datePickerTheme.shape); + + final Text selectDate = tester.widget<Text>(find.text('Select date')); + final Material headerMaterial = findHeaderMaterial(tester, 'Select date'); + expect(selectDate.style?.color, datePickerTheme.headerForegroundColor); + expect(selectDate.style?.fontSize, datePickerTheme.headerHelpStyle?.fontSize); + expect(headerMaterial.color, datePickerTheme.headerBackgroundColor); + + final Text weekday = tester.widget<Text>(find.text('W')); + expect(weekday.style?.color, datePickerTheme.weekdayStyle?.color); + expect(weekday.style?.fontSize, datePickerTheme.weekdayStyle?.fontSize); + + final Text selectedDate = tester.widget<Text>(find.text('Wed, Jan 25')); + expect(selectedDate.style?.color, datePickerTheme.headerForegroundColor); + expect(selectedDate.style?.fontSize, datePickerTheme.headerHeadlineStyle?.fontSize); + + final Text day31 = tester.widget<Text>(find.text('31')); + final ShapeDecoration day31Decoration = findDayDecoration(tester, '31')!; + expect(day31.style?.color, datePickerTheme.dayForegroundColor?.resolve(<WidgetState>{})); + expect(day31.style?.fontSize, datePickerTheme.dayStyle?.fontSize); + expect(day31Decoration.color, datePickerTheme.dayBackgroundColor?.resolve(<WidgetState>{})); + expect(day31Decoration.shape, datePickerTheme.dayShape?.resolve(<WidgetState>{})); + + final Text day24 = tester.widget<Text>(find.text('24')); // DatePickerDialog.currentDate + final ShapeDecoration day24Decoration = findDayDecoration(tester, '24')!; + final day24Shape = day24Decoration.shape as OutlinedBorder; + expect(day24.style?.fontSize, datePickerTheme.dayStyle?.fontSize); + expect(day24.style?.color, datePickerTheme.todayForegroundColor?.resolve(<WidgetState>{})); + expect(day24Decoration.color, datePickerTheme.todayBackgroundColor?.resolve(<WidgetState>{})); + expect( + day24Decoration.shape, + datePickerTheme.dayShape + ?.resolve(<WidgetState>{})! + .copyWith( + side: datePickerTheme.todayBorder?.copyWith( + color: datePickerTheme.todayForegroundColor?.resolve(<WidgetState>{}), + ), + ), + ); + expect(day24Shape.side.width, datePickerTheme.todayBorder?.width); + + // Test the toggle mode button style. + final Text january2023 = tester.widget<Text>(find.text('January 2023')); + expect(january2023.style?.fontSize, datePickerTheme.toggleButtonTextStyle?.fontSize); + expect(january2023.style?.color, datePickerTheme.subHeaderForegroundColor); + final Icon arrowIcon = tester.widget<Icon>(find.byIcon(Icons.arrow_drop_down)); + expect(arrowIcon.color, datePickerTheme.subHeaderForegroundColor); + + // Test the day overlay color. + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('25'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..circle(color: datePickerTheme.dayOverlayColor?.resolve(<WidgetState>{})), + ); + + // Show the year selector. + + await tester.tap(find.text('January 2023')); + await tester.pumpAndSettle(); + + final Text year2022 = tester.widget<Text>(find.text('2022')); + final ShapeDecoration year2022Decoration = findTextDecoration(tester, '2022')!; + expect(year2022.style?.fontSize, datePickerTheme.yearStyle?.fontSize); + expect(year2022.style?.color, datePickerTheme.yearForegroundColor?.resolve(<WidgetState>{})); + expect(year2022Decoration.color, datePickerTheme.yearBackgroundColor?.resolve(<WidgetState>{})); + expect(year2022Decoration.shape, datePickerTheme.yearShape?.resolve(<WidgetState>{})); + + final Text year2023 = tester.widget<Text>(find.text('2023')); // DatePickerDialog.currentDate + final ShapeDecoration year2023Decoration = findTextDecoration(tester, '2023')!; + expect(year2023.style?.fontSize, datePickerTheme.yearStyle?.fontSize); + expect(year2023.style?.color, datePickerTheme.todayForegroundColor?.resolve(<WidgetState>{})); + expect( + year2023Decoration.color, + datePickerTheme.todayBackgroundColor?.resolve(<WidgetState>{}), + ); + final roundedRectangleBorder = year2023Decoration.shape as RoundedRectangleBorder; + expect(roundedRectangleBorder.side.width, datePickerTheme.todayBorder?.width); + expect( + roundedRectangleBorder.side.color, + datePickerTheme.todayForegroundColor?.resolve(<WidgetState>{}), + ); + + // Test the year overlay color. + await gesture.moveTo(tester.getCenter(find.text('2024'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..rect(color: datePickerTheme.yearOverlayColor?.resolve(<WidgetState>{})), + ); + + final ButtonStyle cancelButtonStyle = actionButtonStyle(tester, 'Cancel'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(datePickerTheme.cancelButtonStyle.toString()), + ); + + final ButtonStyle confirmButtonStyle = actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(datePickerTheme.confirmButtonStyle.toString()), + ); + }); + + testWidgets('DatePickerDialog uses ThemeData datePicker theme (input mode)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: datePickerTheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DatePickerDialog( + initialEntryMode: DatePickerEntryMode.input, + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ), + ), + ), + ); + + final Material material = findDialogMaterial(tester); + expect(material.color, datePickerTheme.backgroundColor); + expect(material.elevation, datePickerTheme.elevation); + expect(material.shadowColor, datePickerTheme.shadowColor); + expect(material.surfaceTintColor, datePickerTheme.surfaceTintColor); + expect(material.shape, datePickerTheme.shape); + + final Text selectDate = tester.widget<Text>(find.text('Select date')); + final Material headerMaterial = findHeaderMaterial(tester, 'Select date'); + expect(selectDate.style?.color, datePickerTheme.headerForegroundColor); + expect(selectDate.style?.fontSize, datePickerTheme.headerHelpStyle?.fontSize); + expect(headerMaterial.color, datePickerTheme.headerBackgroundColor); + + final InputDecoration inputDecoration = tester + .widget<TextField>(find.byType(TextField)) + .decoration!; + expect(inputDecoration.fillColor, datePickerTheme.inputDecorationTheme?.fillColor); + + final ButtonStyle cancelButtonStyle = actionButtonStyle(tester, 'Cancel'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(datePickerTheme.cancelButtonStyle.toString()), + ); + + final ButtonStyle confirmButtonStyle = actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(datePickerTheme.confirmButtonStyle.toString()), + ); + }); + + testWidgets('DateRangePickerDialog uses ThemeData datePicker theme', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: datePickerTheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DateRangePickerDialog( + firstDate: DateTime(2023), + lastDate: DateTime(2023, DateTime.january, 31), + initialDateRange: DateTimeRange( + start: DateTime(2023, DateTime.january, 17), + end: DateTime(2023, DateTime.january, 20), + ), + currentDate: DateTime(2023, DateTime.january, 23), + ), + ), + ), + ), + ), + ); + + final Material material = findDialogMaterial(tester); + expect(material.color, datePickerTheme.backgroundColor); + expect( + tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor, + datePickerTheme.rangePickerBackgroundColor, + ); + expect(material.elevation, datePickerTheme.rangePickerElevation); + expect(material.shadowColor, datePickerTheme.rangePickerShadowColor); + expect(material.surfaceTintColor, datePickerTheme.rangePickerSurfaceTintColor); + expect(material.shape, datePickerTheme.rangePickerShape); + + final AppBar appBar = tester.widget<AppBar>(find.byType(AppBar)); + expect(appBar.backgroundColor, datePickerTheme.rangePickerHeaderBackgroundColor); + + final Text selectRange = tester.widget<Text>(find.text('Select range')); + expect(selectRange.style?.color, datePickerTheme.rangePickerHeaderForegroundColor); + expect(selectRange.style?.fontSize, datePickerTheme.rangePickerHeaderHelpStyle?.fontSize); + + final Text selectedDate = tester.widget<Text>(find.text('Jan 17')); + expect(selectedDate.style?.color, datePickerTheme.rangePickerHeaderForegroundColor); + expect(selectedDate.style?.fontSize, datePickerTheme.rangePickerHeaderHeadlineStyle?.fontSize); + + // Test the day overlay color. + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('16'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..circle(color: datePickerTheme.dayOverlayColor?.resolve(<WidgetState>{})), + ); + + // Test the range selection overlay color. + await gesture.moveTo(tester.getCenter(find.text('18'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..circle(color: datePickerTheme.rangeSelectionOverlayColor?.resolve(<WidgetState>{})), + ); + }); + + testWidgets('Material2 - DateRangePickerDialog uses ThemeData datePicker theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: datePickerTheme, useMaterial3: false), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DateRangePickerDialog( + firstDate: DateTime(2023), + lastDate: DateTime(2023, DateTime.january, 31), + initialDateRange: DateTimeRange( + start: DateTime(2023, DateTime.january, 17), + end: DateTime(2023, DateTime.january, 20), + ), + currentDate: DateTime(2023, DateTime.january, 23), + ), + ), + ), + ), + ), + ); + + final Material material = findDialogMaterial(tester); + expect(material.color, datePickerTheme.backgroundColor); + expect( + tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor, + datePickerTheme.rangePickerBackgroundColor, + ); + expect(material.elevation, datePickerTheme.rangePickerElevation); + expect(material.shadowColor, datePickerTheme.rangePickerShadowColor); + expect(material.surfaceTintColor, datePickerTheme.rangePickerSurfaceTintColor); + expect(material.shape, datePickerTheme.rangePickerShape); + + final AppBar appBar = tester.widget<AppBar>(find.byType(AppBar)); + expect(appBar.backgroundColor, datePickerTheme.rangePickerHeaderBackgroundColor); + + final Text selectRange = tester.widget<Text>(find.text('SELECT RANGE')); + expect(selectRange.style?.color, datePickerTheme.rangePickerHeaderForegroundColor); + expect(selectRange.style?.fontSize, datePickerTheme.rangePickerHeaderHelpStyle?.fontSize); + + final Text selectedDate = tester.widget<Text>(find.text('Jan 17')); + expect(selectedDate.style?.color, datePickerTheme.rangePickerHeaderForegroundColor); + expect(selectedDate.style?.fontSize, datePickerTheme.rangePickerHeaderHeadlineStyle?.fontSize); + + // Test the day overlay color. + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('16'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..circle(color: datePickerTheme.dayOverlayColor?.resolve(<WidgetState>{})), + ); + + // Test the range selection overlay color. + await gesture.moveTo(tester.getCenter(find.text('18'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..circle(color: datePickerTheme.rangeSelectionOverlayColor?.resolve(<WidgetState>{})), + ); + }); + + testWidgets('Dividers use DatePickerThemeData.dividerColor', (WidgetTester tester) async { + Future<void> showPicker(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: datePickerTheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DatePickerDialog( + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ), + ), + ), + ); + } + + await showPicker(tester, wideWindowSize); + + // Test vertical divider. + final VerticalDivider verticalDivider = tester.widget(find.byType(VerticalDivider)); + expect(verticalDivider.color, datePickerTheme.dividerColor); + + // Test portrait layout. + await showPicker(tester, narrowWindowSize); + + // Test horizontal divider. + final Divider horizontalDivider = tester.widget(find.byType(Divider)); + expect(horizontalDivider.color, datePickerTheme.dividerColor); + }); + + testWidgets('DatePicker uses ThemeData.inputDecorationTheme properties ' + 'which are null in DatePickerThemeData.inputDecorationTheme', (WidgetTester tester) async { + Widget buildWidget({ + InputDecorationThemeData? inputDecorationTheme, + DatePickerThemeData? datePickerTheme, + }) { + return MaterialApp( + theme: ThemeData( + inputDecorationTheme: inputDecorationTheme, + datePickerTheme: datePickerTheme, + ), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DatePickerDialog( + initialEntryMode: DatePickerEntryMode.input, + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ), + ), + ); + } + + // Test DatePicker with DatePickerThemeData.inputDecorationTheme. + await tester.pumpWidget( + buildWidget( + inputDecorationTheme: const InputDecorationThemeData(filled: true), + datePickerTheme: datePickerTheme, + ), + ); + InputDecoration inputDecoration = tester.widget<TextField>(find.byType(TextField)).decoration!; + expect(inputDecoration.fillColor, datePickerTheme.inputDecorationTheme!.fillColor); + expect(inputDecoration.border, datePickerTheme.inputDecorationTheme!.border); + + // Test DatePicker with ThemeData.inputDecorationTheme. + await tester.pumpWidget( + buildWidget( + inputDecorationTheme: const InputDecorationThemeData( + filled: true, + fillColor: Color(0xFF00FF00), + border: OutlineInputBorder(), + ), + ), + ); + await tester.pumpAndSettle(); + + inputDecoration = tester.widget<TextField>(find.byType(TextField)).decoration!; + expect(inputDecoration.fillColor, const Color(0xFF00FF00)); + expect(inputDecoration.border, const OutlineInputBorder()); + }); + + testWidgets('DatePickerDialog resolves DatePickerTheme.dayOverlayColor states', ( + WidgetTester tester, + ) async { + final WidgetStateProperty<Color> dayOverlayColor = WidgetStateProperty.resolveWith<Color>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.focused)) { + return const Color(0xffff00ff); + } + if (states.contains(WidgetState.pressed)) { + return const Color(0xffffff00); + } + return Colors.transparent; + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: DatePickerThemeData(dayOverlayColor: dayOverlayColor)), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Focus( + child: DatePickerDialog( + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ), + ), + ), + ), + ); + + MaterialInkController findDayGridMaterial(WidgetTester tester) { + // All days are painted on the same Material widget. + // Use an arbitrary day to find this Material. + return Material.of(tester.element(find.text('17'))); + } + + // Test the hover overlay color. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('20'))); + await tester.pumpAndSettle(); + + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.hovered})), + ); + + // Test the pressed overlay color. + await gesture.down(tester.getCenter(find.text('20'))); + await tester.pumpAndSettle(); + if (kIsWeb) { + // An extra circle is painted on the web for the hovered state. + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.pressed})), + ); + } else { + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.pressed})), + ); + } + + await gesture.removePointer(); + await tester.pumpAndSettle(); + + // Focus day selection. + for (var i = 0; i < 5; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + } + + // Test the focused overlay color. + expect( + findDayGridMaterial(tester), + paints + ..circle() // Today decoration. + ..circle() // Selected day decoration. + ..circle(color: dayOverlayColor.resolve(<WidgetState>{WidgetState.focused})), + ); + }); + + testWidgets('DatePickerDialog resolves DatePickerTheme.yearOverlayColor states', ( + WidgetTester tester, + ) async { + final WidgetStateProperty<Color> yearOverlayColor = WidgetStateProperty.resolveWith<Color>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.focused)) { + return const Color(0xffff00ff); + } + if (states.contains(WidgetState.pressed)) { + return const Color(0xffffff00); + } + return Colors.transparent; + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(datePickerTheme: DatePickerThemeData(yearOverlayColor: yearOverlayColor)), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Focus( + child: DatePickerDialog( + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + initialCalendarMode: DatePickerMode.year, + ), + ), + ), + ), + ), + ), + ); + + // Test the hover overlay color. + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('2022'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..rect(color: yearOverlayColor.resolve(<WidgetState>{WidgetState.hovered})), + ); + + // Test the pressed overlay color. + await gesture.down(tester.getCenter(find.text('2022'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints + ..rect(color: yearOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..rect(color: yearOverlayColor.resolve(<WidgetState>{WidgetState.pressed})), + ); + + await gesture.removePointer(); + await tester.pumpAndSettle(); + + // Focus year selection. + for (var i = 0; i < 3; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + } + + // Test the focused overlay color. + expect( + inkFeatures, + paints..rect(color: yearOverlayColor.resolve(<WidgetState>{WidgetState.focused})), + ); + }); + + testWidgets('DateRangePickerDialog resolves DatePickerTheme.rangeSelectionOverlayColor states', ( + WidgetTester tester, + ) async { + final WidgetStateProperty<Color> rangeSelectionOverlayColor = + WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.pressed)) { + return const Color(0xffffff00); + } + return Colors.transparent; + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + datePickerTheme: DatePickerThemeData( + rangeSelectionOverlayColor: rangeSelectionOverlayColor, + ), + ), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DateRangePickerDialog( + firstDate: DateTime(2023), + lastDate: DateTime(2023, DateTime.january, 31), + initialDateRange: DateTimeRange( + start: DateTime(2023, DateTime.january, 17), + end: DateTime(2023, DateTime.january, 20), + ), + currentDate: DateTime(2023, DateTime.january, 23), + ), + ), + ), + ), + ), + ); + + // Test the hover overlay color. + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('18'))); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..circle(color: rangeSelectionOverlayColor.resolve(<WidgetState>{WidgetState.hovered})), + ); + + // Test the pressed overlay color. + await gesture.down(tester.getCenter(find.text('18'))); + await tester.pumpAndSettle(); + if (kIsWeb) { + // An extra circle is painted on the web for the hovered state. + expect( + inkFeatures, + paints + ..circle(color: rangeSelectionOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..circle(color: rangeSelectionOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..circle(color: rangeSelectionOverlayColor.resolve(<WidgetState>{WidgetState.pressed})), + ); + } else { + expect( + inkFeatures, + paints + ..circle(color: rangeSelectionOverlayColor.resolve(<WidgetState>{WidgetState.hovered})) + ..circle(color: rangeSelectionOverlayColor.resolve(<WidgetState>{WidgetState.pressed})), + ); + } + }); + + testWidgets('YearPicker maintains default year shape at textScaleFactor 1, 1.5, 2', ( + WidgetTester tester, + ) async { + var textScaleFactor = 1.0; + Widget buildFrame() { + return MaterialApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Scaffold( + body: YearPicker( + currentDate: DateTime(2025), + firstDate: DateTime(2021), + lastDate: DateTime(2030), + selectedDate: DateTime(2025), + onChanged: (DateTime value) {}, + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + // Find container whose child is text 2025. + final Finder yearContainer = find + .ancestor(of: find.text('2025'), matching: find.byType(Container)) + .first; + + expect( + tester.renderObject(yearContainer), + paints..rrect( + rrect: RRect.fromLTRBR(0.5, 0.5, 71.5, 35.5, const Radius.circular(17.5)), + color: const Color(0xFF6750A4), + ), + ); + + textScaleFactor = 1.5; + await tester.pumpWidget(buildFrame()); + + expect( + tester.renderObject(yearContainer), + paints..rrect( + rrect: RRect.fromLTRBR(0.5, 0.5, 107.5, 51.5, const Radius.circular(25.5)), + color: const Color(0xFF6750A4), + ), + ); + + textScaleFactor = 2; + await tester.pumpWidget(buildFrame()); + + expect( + tester.renderObject(yearContainer), + paints..rrect( + rrect: RRect.fromLTRBR(0.5, 0.5, 143.5, 51.5, const Radius.circular(25.5)), + color: const Color(0xFF6750A4), + ), + ); + }); + + testWidgets('YearPicker applies shape from DatePickerThemeData.yearShape correctly', ( + WidgetTester tester, + ) async { + const OutlinedBorder yearShpae = CircleBorder(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + datePickerTheme: datePickerTheme.copyWith( + yearShape: WidgetStateProperty.all<OutlinedBorder>(yearShpae), + ), + ), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: YearPicker( + currentDate: DateTime(2025), + firstDate: DateTime(2021), + lastDate: DateTime(2030), + selectedDate: DateTime(2025), + onChanged: (DateTime value) {}, + ), + ), + ), + ), + ), + ); + + final ShapeDecoration year2022Decoration = findTextDecoration(tester, '2022')!; + final OutlinedBorder year2022roundedRectangleBorder = year2022Decoration.shape as CircleBorder; + expect(year2022roundedRectangleBorder.side.width, 0.0); + expect(year2022roundedRectangleBorder.side.color, yearShpae.side.color); + + final ShapeDecoration year2025Decoration = findTextDecoration(tester, '2025')!; + final OutlinedBorder year2022RoundedRectangleBorder = year2025Decoration.shape as CircleBorder; + expect(year2022RoundedRectangleBorder.side.width, datePickerTheme.todayBorder?.width); + expect( + year2022RoundedRectangleBorder.side.color, + datePickerTheme.todayForegroundColor?.resolve(<WidgetState>{}), + ); + }); + + testWidgets('Toggle button uses DatePickerTheme.toggleButtonTextStyle.color when it is defined', ( + WidgetTester tester, + ) async { + const toggleButtonTextColor = Color(0xff00ff00); + const subHeaderForegroundColor = Color(0xffff0000); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + datePickerTheme: const DatePickerThemeData( + toggleButtonTextStyle: TextStyle(color: toggleButtonTextColor), + subHeaderForegroundColor: subHeaderForegroundColor, + ), + ), + home: DatePickerDialog( + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ); + + final Text toggleButtonText = tester.widget(find.text('January 2023')); + expect(toggleButtonText.style?.color, toggleButtonTextColor); + }); + + testWidgets( + 'Toggle button uses DatePickerTheme.subHeaderForegroundColor when DatePickerTheme.toggleButtonTextStyle.color is not defined', + (WidgetTester tester) async { + const subHeaderForegroundColor = Color(0xffff0000); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + datePickerTheme: const DatePickerThemeData( + toggleButtonTextStyle: TextStyle(), + subHeaderForegroundColor: subHeaderForegroundColor, + ), + ), + home: DatePickerDialog( + initialDate: DateTime(2023, DateTime.january, 25), + firstDate: DateTime(2022), + lastDate: DateTime(2024, DateTime.december, 31), + currentDate: DateTime(2023, DateTime.january, 24), + ), + ), + ); + + final Text toggleButtonText = tester.widget(find.text('January 2023')); + expect(toggleButtonText.style?.color, subHeaderForegroundColor); + }, + ); +} diff --git a/packages/material_ui/test/material/date_range_picker_test.dart b/packages/material_ui/test/material/date_range_picker_test.dart new file mode 100644 index 000000000000..725ddc6f7518 --- /dev/null +++ b/packages/material_ui/test/material/date_range_picker_test.dart @@ -0,0 +1,2194 @@ +// 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:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; + +void main() { + late DateTime firstDate; + late DateTime lastDate; + late DateTime? currentDate; + late DateTimeRange? initialDateRange; + late DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar; + + String? cancelText; + String? confirmText; + String? errorInvalidRangeText; + String? errorFormatText; + String? errorInvalidText; + String? fieldStartHintText; + String? fieldEndHintText; + String? fieldStartLabelText; + String? fieldEndLabelText; + String? helpText; + String? saveText; + + setUp(() { + firstDate = DateTime(2015); + lastDate = DateTime(2016, DateTime.december, 31); + currentDate = null; + initialDateRange = DateTimeRange( + start: DateTime(2016, DateTime.january, 15), + end: DateTime(2016, DateTime.january, 25), + ); + initialEntryMode = DatePickerEntryMode.calendar; + + cancelText = null; + confirmText = null; + errorInvalidRangeText = null; + errorFormatText = null; + errorInvalidText = null; + fieldStartHintText = null; + fieldEndHintText = null; + fieldStartLabelText = null; + fieldEndLabelText = null; + helpText = null; + saveText = null; + }); + + const wideWindowSize = Size(1920.0, 1080.0); + const narrowWindowSize = Size(1070.0, 1770.0); + + Future<void> preparePicker( + WidgetTester tester, + Future<void> Function(Future<DateTimeRange?> date) callback, { + TextDirection textDirection = TextDirection.ltr, + bool useMaterial3 = false, + SelectableDayForRangePredicate? selectableDayPredicate, + CalendarDelegate<DateTime> calendarDelegate = const GregorianCalendarDelegate(), + }) async { + late BuildContext buttonContext; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + buttonContext = context; + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + expect(buttonContext, isNotNull); + + final Future<DateTimeRange?> range = showDateRangePicker( + context: buttonContext, + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + initialEntryMode: initialEntryMode, + cancelText: cancelText, + confirmText: confirmText, + errorInvalidRangeText: errorInvalidRangeText, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + fieldStartHintText: fieldStartHintText, + fieldEndHintText: fieldEndHintText, + fieldStartLabelText: fieldStartLabelText, + fieldEndLabelText: fieldEndLabelText, + helpText: helpText, + saveText: saveText, + selectableDayPredicate: selectableDayPredicate, + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: textDirection, child: child ?? const SizedBox()); + }, + calendarDelegate: calendarDelegate, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + await callback(range); + } + + testWidgets('Default layout (calendar mode)', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Finder helpText = find.text('Select range'); + final Finder firstDateHeaderText = find.text('Jan 15'); + final Finder lastDateHeaderText = find.text('Jan 25, 2016'); + final Finder saveText = find.text('Save'); + + expect(helpText, findsOneWidget); + expect(firstDateHeaderText, findsOneWidget); + expect(lastDateHeaderText, findsOneWidget); + expect(saveText, findsOneWidget); + + // Test the close button position. + final Offset closeButtonBottomRight = tester.getBottomRight( + find.ancestor(of: find.byType(IconButton), matching: find.byType(Center)), + ); + final Offset helpTextTopLeft = tester.getTopLeft(helpText); + expect(closeButtonBottomRight.dx, 56.0); + expect(closeButtonBottomRight.dy, helpTextTopLeft.dy); + + // Test the save and entry buttons position. + final Offset saveButtonBottomLeft = tester.getBottomLeft(find.byType(TextButton)); + final Offset entryButtonBottomLeft = tester.getBottomLeft( + find.widgetWithIcon(IconButton, Icons.edit_outlined), + ); + expect(saveButtonBottomLeft.dx, moreOrLessEquals(711.6, epsilon: 1e-5)); + expect(saveButtonBottomLeft.dy, helpTextTopLeft.dy); + expect(entryButtonBottomLeft.dx, saveButtonBottomLeft.dx - 48.0); + expect(entryButtonBottomLeft.dy, helpTextTopLeft.dy); + + // Test help text position. + final Offset helpTextBottomLeft = tester.getBottomLeft(helpText); + expect(helpTextBottomLeft.dx, 72.0); + expect(helpTextBottomLeft.dy, closeButtonBottomRight.dy + 20.0); + + // Test the header position. + final Offset firstDateHeaderTopLeft = tester.getTopLeft(firstDateHeaderText); + final Offset lastDateHeaderTopLeft = tester.getTopLeft(lastDateHeaderText); + expect(firstDateHeaderTopLeft.dx, 72.0); + expect(firstDateHeaderTopLeft.dy, helpTextBottomLeft.dy + 8.0); + final Offset firstDateHeaderTopRight = tester.getTopRight(firstDateHeaderText); + expect(lastDateHeaderTopLeft.dx, firstDateHeaderTopRight.dx + 66.0); + expect(lastDateHeaderTopLeft.dy, helpTextBottomLeft.dy + 8.0); + + // Test the day headers position. + final Offset dayHeadersGridTopLeft = tester.getTopLeft(find.byType(GridView).first); + final Offset firstDateHeaderBottomLeft = tester.getBottomLeft(firstDateHeaderText); + expect(dayHeadersGridTopLeft.dx, (800 - 384) / 2); + expect(dayHeadersGridTopLeft.dy, firstDateHeaderBottomLeft.dy + 16.0); + + // Test the calendar custom scroll view position. + final Offset calendarScrollViewTopLeft = tester.getTopLeft(find.byType(CustomScrollView)); + final Offset dayHeadersGridBottomLeft = tester.getBottomLeft(find.byType(GridView).first); + expect(calendarScrollViewTopLeft.dx, 0.0); + expect(calendarScrollViewTopLeft.dy, dayHeadersGridBottomLeft.dy); + }, useMaterial3: true); + }); + + testWidgets('Default Dialog properties (calendar mode)', (WidgetTester tester) async { + final theme = ThemeData(); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surfaceContainerHigh); + expect(dialogMaterial.shadowColor, Colors.transparent); + expect(dialogMaterial.surfaceTintColor, Colors.transparent); + expect(dialogMaterial.elevation, 0.0); + expect(dialogMaterial.shape, const RoundedRectangleBorder()); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, EdgeInsets.zero); + }, useMaterial3: theme.useMaterial3); + }); + + testWidgets('Default Dialog properties (input mode)', (WidgetTester tester) async { + final theme = ThemeData(); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surfaceContainerHigh); + expect(dialogMaterial.shadowColor, Colors.transparent); + expect(dialogMaterial.surfaceTintColor, Colors.transparent); + expect(dialogMaterial.elevation, 0.0); + expect(dialogMaterial.shape, const RoundedRectangleBorder()); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, EdgeInsets.zero); + }, useMaterial3: theme.useMaterial3); + }); + + testWidgets('Scaffold and AppBar defaults', (WidgetTester tester) async { + final theme = ThemeData(); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Scaffold scaffold = tester.widget<Scaffold>(find.byType(Scaffold)); + expect(scaffold.backgroundColor, null); + + final AppBar appBar = tester.widget<AppBar>(find.byType(AppBar)); + final iconTheme = IconThemeData(color: theme.colorScheme.onSurfaceVariant); + expect(appBar.iconTheme, iconTheme); + expect(appBar.actionsIconTheme, iconTheme); + expect(appBar.elevation, 0); + expect(appBar.scrolledUnderElevation, 0); + expect(appBar.backgroundColor, Colors.transparent); + }, useMaterial3: theme.useMaterial3); + }); + + group('Landscape input-only date picker headers use headlineSmall', () { + // Regression test for https://github.com/flutter/flutter/issues/122056 + + // Common screen size roughly based on a Pixel 1 + const kCommonScreenSizePortrait = Size(1070, 1770); + const kCommonScreenSizeLandscape = Size(1770, 1070); + + Future<void> showPicker(WidgetTester tester, Size size) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + initialEntryMode = DatePickerEntryMode.input; + await preparePicker(tester, (Future<DateTimeRange?> range) async {}, useMaterial3: true); + } + + testWidgets('portrait', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizePortrait); + expect(tester.widget<Text>(find.text('Jan 15 – Jan 25, 2016')).style?.fontSize, 32); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('landscape', (WidgetTester tester) async { + await showPicker(tester, kCommonScreenSizeLandscape); + expect(tester.widget<Text>(find.text('Jan 15 – Jan 25, 2016')).style?.fontSize, 24); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + }); + + testWidgets('Save and help text is used', (WidgetTester tester) async { + helpText = 'help'; + saveText = 'make it so'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.text(helpText!), findsOneWidget); + expect(find.text(saveText!), findsOneWidget); + }); + }); + + testWidgets('Long helpText does not cutoff the save button', (WidgetTester tester) async { + helpText = 'long helpText' * 100; + saveText = 'make it so'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.text(helpText!), findsOneWidget); + expect(find.text(saveText!), findsOneWidget); + expect(tester.takeException(), null); + }); + }); + + testWidgets('Material3 has sentence case labels', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.text('Save'), findsOneWidget); + expect(find.text('Select range'), findsOneWidget); + }, useMaterial3: true); + }); + + testWidgets('Initial date is the default', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('SAVE')); + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 15), + end: DateTime(2016, DateTime.january, 25), + ), + ); + }); + }); + + testWidgets('Last month header should be visible if last date is selected', ( + WidgetTester tester, + ) async { + firstDate = DateTime(2015); + lastDate = DateTime(2016, DateTime.december, 31); + initialDateRange = DateTimeRange(start: lastDate, end: lastDate); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // December header should be showing, but no November + expect(find.text('December 2016'), findsOneWidget); + expect(find.text('November 2016'), findsNothing); + }); + }); + + testWidgets('First month header should be visible if first date is selected', ( + WidgetTester tester, + ) async { + firstDate = DateTime(2015); + lastDate = DateTime(2016, DateTime.december, 31); + initialDateRange = DateTimeRange(start: firstDate, end: firstDate); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // January and February headers should be showing, but no March + expect(find.text('January 2015'), findsOneWidget); + expect(find.text('February 2015'), findsOneWidget); + expect(find.text('March 2015'), findsNothing); + }); + }); + + testWidgets('Current month header should be visible if no date is selected', ( + WidgetTester tester, + ) async { + firstDate = DateTime(2015); + lastDate = DateTime(2016, DateTime.december, 31); + currentDate = DateTime(2016, DateTime.september); + initialDateRange = null; + + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // September and October headers should be showing, but no August + expect(find.text('September 2016'), findsOneWidget); + expect(find.text('October 2016'), findsOneWidget); + expect(find.text('August 2016'), findsNothing); + }); + }); + + testWidgets('Can cancel', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.byIcon(Icons.close)); + expect(await range, isNull); + }); + }); + + testWidgets('Can select a range', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('14').first); + await tester.tap(find.text('SAVE')); + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 14), + ), + ); + }); + }); + + testWidgets('Tapping earlier date resets selected range', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('11').first); + await tester.tap(find.text('15').first); + await tester.tap(find.text('SAVE')); + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 11), + end: DateTime(2016, DateTime.january, 15), + ), + ); + }); + }); + + testWidgets('Can select single day range', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('12').first); + await tester.tap(find.text('SAVE')); + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 12), + ), + ); + }); + }); + + testWidgets('Cannot select a day outside bounds', (WidgetTester tester) async { + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 13), + end: DateTime(2017, DateTime.january, 15), + ); + firstDate = DateTime(2017, DateTime.january, 12); + lastDate = DateTime(2017, DateTime.january, 16); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // Earlier than firstDate. Should be ignored. + await tester.tap(find.text('10')); + // Later than lastDate. Should be ignored. + await tester.tap(find.text('20')); + await tester.tap(find.text('SAVE')); + // We should still be on the initial date. + expect(await range, initialDateRange); + }); + }); + + testWidgets('Can select a range even if the range includes non selectable days', ( + WidgetTester tester, + ) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('14').first); + await tester.tap(find.text('SAVE')); + // The day 13 is not selectable, but the range is still valid. + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 14), + ), + ); + }, selectableDayPredicate: (DateTime day, _, _) => day.day != 13); + }); + + testWidgets('Cannot select a day inside bounds but not selectable', (WidgetTester tester) async { + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 13), + end: DateTime(2017, DateTime.january, 14), + ); + firstDate = DateTime(2017, DateTime.january, 12); + lastDate = DateTime(2017, DateTime.january, 16); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // Non-selectable date. Should be ignored. + await tester.tap(find.text('15')); + await tester.tap(find.text('SAVE')); + // We should still be on the initial date. + expect(await range, initialDateRange); + }, selectableDayPredicate: (DateTime day, _, _) => day.day != 15); + }); + + testWidgets('Selectable date becoming non selectable when selected start day', ( + WidgetTester tester, + ) async { + await preparePicker( + tester, + (Future<DateTimeRange?> range) async { + await tester.tap(find.text('12').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('11').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('14').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('SAVE')); + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 14), + ), + ); + }, + selectableDayPredicate: (DateTime day, DateTime? selectedStart, DateTime? selectedEnd) { + if (selectedEnd == null && selectedStart != null) { + return day == selectedStart || day.isAfter(selectedStart); + } + return true; + }, + ); + }); + + testWidgets('selectableDayPredicate should be called with the selected start and end dates', ( + WidgetTester tester, + ) async { + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 13), + end: DateTime(2017, DateTime.january, 15), + ); + firstDate = DateTime(2017, DateTime.january, 12); + lastDate = DateTime(2017, DateTime.january, 16); + await preparePicker( + tester, + (Future<DateTimeRange?> range) async {}, + selectableDayPredicate: + (DateTime day, DateTime? selectedStartDate, DateTime? selectedEndDate) { + expect(selectedStartDate, DateTime(2017, DateTime.january, 13)); + expect(selectedEndDate, DateTime(2017, DateTime.january, 15)); + return true; + }, + ); + }); + + testWidgets('Can switch from calendar to input entry mode', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNothing); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNWidgets(2)); + }); + }); + + testWidgets('Can switch from input to calendar entry mode', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.input; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNWidgets(2)); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + }); + + testWidgets('Can not switch out of calendarOnly mode', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.calendarOnly; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + }); + }); + + testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.inputOnly; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.byIcon(Icons.calendar_today), findsNothing); + }); + }); + + testWidgets('Input only mode should validate date', (WidgetTester tester) async { + initialEntryMode = DatePickerEntryMode.inputOnly; + errorInvalidText = 'oops'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '08/08/2014'); + await tester.enterText(find.byType(TextField).at(1), '08/08/2014'); + expect(find.text(errorInvalidText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidText!), findsNWidgets(2)); + }); + }); + + testWidgets('Switching to input mode keeps selected date', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('12').first); + await tester.tap(find.text('14').first); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 12), + end: DateTime(2016, DateTime.january, 14), + ), + ); + }); + }); + + group('Toggle from input entry mode validates dates', () { + setUp(() { + initialEntryMode = DatePickerEntryMode.input; + }); + + testWidgets('Invalid start date', (WidgetTester tester) async { + // Invalid start date should have neither a start nor end date selected in + // calendar mode + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/1918'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + + expect(find.text('Start Date'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }); + }); + + testWidgets('Non-selectable start date', (WidgetTester tester) async { + // Even if start and end dates are selected, the start date is not selectable + // ending up to no date selected at all in calendar mode. + await preparePicker( + tester, + (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/24/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + + expect(find.text('Start Date'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }, + selectableDayPredicate: (DateTime day, DateTime? selectedStart, DateTime? selectedEnd) { + return day != DateTime(2016, DateTime.december, 24); + }, + ); + }); + + testWidgets('Invalid end date', (WidgetTester tester) async { + // Invalid end date should only have a start date selected + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/24/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2050'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + + expect(find.text('Dec 24'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }); + }); + + testWidgets('Non-selectable end date', (WidgetTester tester) async { + // The end date is not selectable, so only the start date should be selected. + await preparePicker( + tester, + (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/24/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + + expect(find.text('Dec 24'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }, + selectableDayPredicate: (DateTime day, DateTime? selectedStart, DateTime? selectedEnd) { + return day != DateTime(2016, DateTime.december, 25); + }, + ); + }); + + testWidgets('Invalid range', (WidgetTester tester) async { + // Start date after end date should just use the start date + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/24/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + + expect(find.text('Dec 25'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }); + }); + }); + + testWidgets('OK Cancel button layout', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showDateRangePicker( + context: context, + firstDate: DateTime(2001), + lastDate: DateTime(2031, DateTime.december, 31), + builder: (BuildContext context, Widget? child) { + return Directionality( + textDirection: textDirection, + child: child ?? const SizedBox(), + ); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + Future<void> showOkCancelDialog(TextDirection textDirection) async { + await tester.pumpWidget(buildFrame(textDirection)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + } + + Future<void> dismissOkCancelDialog() async { + await tester.tap(find.text('CANCEL')); + await tester.pumpAndSettle(); + } + + await showOkCancelDialog(TextDirection.ltr); + expect(tester.getBottomRight(find.text('OK')).dx, 622); + expect(tester.getBottomLeft(find.text('OK')).dx, 594); + expect(tester.getBottomRight(find.text('CANCEL')).dx, 560); + await dismissOkCancelDialog(); + + await showOkCancelDialog(TextDirection.rtl); + expect(tester.getBottomRight(find.text('OK')).dx, 206); + expect(tester.getBottomLeft(find.text('OK')).dx, 178); + expect(tester.getBottomRight(find.text('CANCEL')).dx, 324); + await dismissOkCancelDialog(); + }); + + group('Haptic feedback', () { + const hapticFeedbackInterval = Duration(milliseconds: 10); + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + ); + firstDate = DateTime(2017, DateTime.january, 10); + lastDate = DateTime(2018, DateTime.january, 20); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('Selecting dates vibrates', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('10').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 1); + await tester.tap(find.text('12').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 2); + await tester.tap(find.text('14').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 3); + }); + }); + + testWidgets('Tapping unselectable date does not vibrate', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('8').first); + await tester.pump(hapticFeedbackInterval); + expect(feedback.hapticCount, 0); + }); + }); + }); + + group('Keyboard navigation', () { + testWidgets('Can toggle to calendar entry mode', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNothing); + // Navigate to the entry toggle button and activate it + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + // Should be in the input mode + expect(find.byType(TextField), findsNWidgets(2)); + }); + }); + + testWidgets('Can navigate date grid with arrow keys', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to Jan 18 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it to select the beginning of the range + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate to Jan 29 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + // Activate it to select the end of the range + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 - Jan 29 + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 18), + end: DateTime(2016, DateTime.january, 29), + ), + ); + }); + }); + + testWidgets('Navigating with arrow keys scrolls as needed', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // Jan and Feb headers should be showing, but no March + expect(find.text('January 2016'), findsOneWidget); + expect(find.text('February 2016'), findsOneWidget); + expect(find.text('March 2016'), findsNothing); + + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate from Jan 15 to Jan 18 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + // Activate it to select the beginning of the range + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate to Mar 17 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + // Jan should have scrolled off, Mar should be visible + expect(find.text('January 2016'), findsNothing); + expect(find.text('February 2016'), findsOneWidget); + expect(find.text('March 2016'), findsOneWidget); + + // Activate it to select the end of the range + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 - Mar 17 + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 18), + end: DateTime(2016, DateTime.march, 17), + ), + ); + }); + }); + + testWidgets('RTL text direction reverses the horizontal arrow key navigation', ( + WidgetTester tester, + ) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate from Jan 15 to 19 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate to Jan 21 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 19 - Mar 21 + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.january, 19), + end: DateTime(2016, DateTime.january, 21), + ), + ); + }, textDirection: TextDirection.rtl); + }); + }); + + group('Input mode', () { + setUp(() { + firstDate = DateTime(2015); + lastDate = DateTime(2017, DateTime.december, 31); + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + ); + initialEntryMode = DatePickerEntryMode.input; + }); + + testWidgets('Default Dialog properties (input mode)', (WidgetTester tester) async { + final theme = ThemeData(); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surfaceContainerHigh); + expect(dialogMaterial.shadowColor, Colors.transparent); + expect(dialogMaterial.surfaceTintColor, Colors.transparent); + expect(dialogMaterial.elevation, 6.0); + expect( + dialogMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + ); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)); + }, useMaterial3: theme.useMaterial3); + }); + + testWidgets('Default InputDecoration', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final InputDecoration startDateDecoration = tester + .widget<TextField>(find.byType(TextField).first) + .decoration!; + expect(startDateDecoration.border, const OutlineInputBorder()); + expect(startDateDecoration.filled, false); + expect(startDateDecoration.hintText, 'mm/dd/yyyy'); + expect(startDateDecoration.labelText, 'Start Date'); + expect(startDateDecoration.errorText, null); + + final InputDecoration endDateDecoration = tester + .widget<TextField>(find.byType(TextField).last) + .decoration!; + expect(endDateDecoration.border, const OutlineInputBorder()); + expect(endDateDecoration.filled, false); + expect(endDateDecoration.hintText, 'mm/dd/yyyy'); + expect(endDateDecoration.labelText, 'End Date'); + expect(endDateDecoration.errorText, null); + }, useMaterial3: true); + }); + + testWidgets('Initial entry mode is used', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNWidgets(2)); + }); + }); + + testWidgets('All custom strings are used', (WidgetTester tester) async { + initialDateRange = null; + cancelText = 'nope'; + confirmText = 'yep'; + fieldStartHintText = 'hint1'; + fieldEndHintText = 'hint2'; + fieldStartLabelText = 'label1'; + fieldEndLabelText = 'label2'; + helpText = 'help'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.text(cancelText!), findsOneWidget); + expect(find.text(confirmText!), findsOneWidget); + expect(find.text(fieldStartHintText!), findsOneWidget); + expect(find.text(fieldEndHintText!), findsOneWidget); + expect(find.text(fieldStartLabelText!), findsOneWidget); + expect(find.text(fieldEndLabelText!), findsOneWidget); + expect(find.text(helpText!), findsOneWidget); + }); + }); + + testWidgets('Initial date is the default', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.tap(find.text('OK')); + expect( + await range, + DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + ), + ); + }); + }); + + testWidgets('Can toggle to calendar entry mode', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNWidgets(2)); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + }); + + testWidgets('Toggle to calendar mode keeps selected date', (WidgetTester tester) async { + initialDateRange = null; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/27/2016'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + await tester.tap(find.text('SAVE')); + + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.december, 25), + end: DateTime(2016, DateTime.december, 27), + ), + ); + }); + }); + + testWidgets('Entered text returns range', (WidgetTester tester) async { + initialDateRange = null; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/27/2016'); + await tester.tap(find.text('OK')); + + expect( + await range, + DateTimeRange( + start: DateTime(2016, DateTime.december, 25), + end: DateTime(2016, DateTime.december, 27), + ), + ); + }); + }); + + testWidgets('Too short entered text shows error', (WidgetTester tester) async { + initialDateRange = null; + errorFormatText = 'oops'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/25'); + await tester.enterText(find.byType(TextField).at(1), '12/25'); + expect(find.text(errorFormatText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText!), findsNWidgets(2)); + }); + }); + + testWidgets('Bad format entered text shows error', (WidgetTester tester) async { + initialDateRange = null; + errorFormatText = 'oops'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '20202014'); + await tester.enterText(find.byType(TextField).at(1), '20212014'); + expect(find.text(errorFormatText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText!), findsNWidgets(2)); + }); + }); + + testWidgets('Invalid entered text shows error', (WidgetTester tester) async { + initialDateRange = null; + errorInvalidText = 'oops'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '08/08/2014'); + await tester.enterText(find.byType(TextField).at(1), '08/08/2014'); + expect(find.text(errorInvalidText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidText!), findsNWidgets(2)); + }); + }); + + testWidgets('End before start date shows error', (WidgetTester tester) async { + initialDateRange = null; + errorInvalidRangeText = 'oops'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + expect(find.text(errorInvalidRangeText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidRangeText!), findsOneWidget); + }); + }); + + testWidgets('Error text only displayed for invalid date', (WidgetTester tester) async { + initialDateRange = null; + errorInvalidText = 'oops'; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/2016'); + await tester.enterText(find.byType(TextField).at(1), '01/01/2018'); + expect(find.text(errorInvalidText!), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text(errorInvalidText!), findsOneWidget); + }); + }); + + testWidgets('End before start date does not get passed to calendar mode', ( + WidgetTester tester, + ) async { + initialDateRange = null; + await preparePicker(tester, (Future<DateTimeRange?> range) async { + await tester.enterText(find.byType(TextField).at(0), '12/27/2016'); + await tester.enterText(find.byType(TextField).at(1), '12/25/2016'); + + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pumpAndSettle(); + await tester.tap(find.text('SAVE')); + await tester.pumpAndSettle(); + + // Save button should be disabled, so dialog should still be up + // with the first date selected, but no end date + expect(find.text('Dec 27'), findsOneWidget); + expect(find.text('End Date'), findsOneWidget); + }); + }); + + testWidgets('Input decoration theme is honored', (WidgetTester tester) async { + // Given a custom paint for an input decoration, extract the border and + // fill color and test them against the expected values. + void testInputDecorator( + CustomPaint decoratorPaint, + InputBorder expectedBorder, + Color expectedContainerColor, + ) { + final dynamic /*_InputBorderPainter*/ inputBorderPainter = decoratorPaint.foregroundPainter; + // ignore: avoid_dynamic_calls + final dynamic /*_InputBorderTween*/ inputBorderTween = inputBorderPainter.border; + // ignore: avoid_dynamic_calls + final animation = inputBorderPainter.borderAnimation as Animation<double>; + // ignore: avoid_dynamic_calls + final actualBorder = inputBorderTween.evaluate(animation) as InputBorder; + // ignore: avoid_dynamic_calls + final containerColor = inputBorderPainter.blendedColor as Color; + + expect(actualBorder, equals(expectedBorder)); + expect(containerColor, equals(expectedContainerColor)); + } + + late BuildContext buttonContext; + const InputBorder border = InputBorder.none; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(inputDecorationTheme: const InputDecorationThemeData(border: border)), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + buttonContext = context; + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + expect(buttonContext, isNotNull); + + showDateRangePicker( + context: buttonContext, + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: DatePickerEntryMode.input, + ); + await tester.pumpAndSettle(); + + final Finder borderContainers = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), + ); + + // Test the start date text field + testInputDecorator(tester.widget(borderContainers.first), border, Colors.transparent); + + // Test the end date text field + testInputDecorator(tester.widget(borderContainers.last), border, Colors.transparent); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/131989. + testWidgets('Dialog contents do not overflow when resized from landscape to portrait', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + // Initial window size is wide for landscape mode. + tester.view.physicalSize = wideWindowSize; + tester.view.devicePixelRatio = 1.0; + + await preparePicker(tester, (Future<DateTimeRange?> range) async { + // Change window size to narrow for portrait mode. + tester.view.physicalSize = narrowWindowSize; + await tester.pump(); + expect(tester.takeException(), null); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/140311. + testWidgets('Text field stays visible when orientation is portrait and height is reduced', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(720, 1280); + tester.view.devicePixelRatio = 1.0; + initialEntryMode = DatePickerEntryMode.input; + + // Text fields and header are visible by default. + await preparePicker(tester, useMaterial3: true, (Future<DateTimeRange?> range) async { + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.text('Select range'), findsOne); + }); + + // Simulate the portait mode on a device with a small display when the virtual + // keyboard is visible. + tester.view.viewInsets = const FakeViewPadding(bottom: 1000); + await tester.pumpAndSettle(); + + // Text fields are visible and header is hidden + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.text('Select range'), findsNothing); + }); + }); + + testWidgets('DatePickerDialog is state restorable', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + restorationScopeId: 'app', + home: const _RestorableDateRangePickerDialogTestWidget(), + ), + ); + + // The date range picker should be closed. + expect(find.byType(DateRangePickerDialog), findsNothing); + expect(find.text('1/1/2021 to 5/1/2021'), findsOneWidget); + + // Open the date range picker. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(find.byType(DateRangePickerDialog), findsOneWidget); + + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // The date range picker should be open after restoring. + expect(find.byType(DateRangePickerDialog), findsOneWidget); + + // Close the date range picker. + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + // The date range picker should be closed, the text value updated to the + // newly selected date. + expect(find.byType(DateRangePickerDialog), findsNothing); + expect(find.text('1/1/2021 to 5/1/2021'), findsOneWidget); + + // The date range picker should be open after restoring. + await tester.restoreFrom(restorationData); + expect(find.byType(DateRangePickerDialog), findsOneWidget); + + // // Select a different date and close the date range picker. + await tester.tap(find.text('12').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('14').first); + await tester.pumpAndSettle(); + + // Restart after the new selection. It should remain selected. + await tester.restartAndRestore(); + + // Close the date range picker. + await tester.tap(find.text('SAVE')); + await tester.pumpAndSettle(); + + // The date range picker should be closed, the text value updated to the + // newly selected date. + expect(find.byType(DateRangePickerDialog), findsNothing); + expect(find.text('12/1/2021 to 14/1/2021'), findsOneWidget); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + testWidgets('DateRangePickerDialog state restoration - DatePickerEntryMode', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + restorationScopeId: 'app', + home: _RestorableDateRangePickerDialogTestWidget( + datePickerEntryMode: DatePickerEntryMode.calendarOnly, + ), + ), + ); + + // The date range picker should be closed. + expect(find.byType(DateRangePickerDialog), findsNothing); + expect(find.text('1/1/2021 to 5/1/2021'), findsOneWidget); + + // Open the date range picker. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(find.byType(DateRangePickerDialog), findsOneWidget); + + // Only in calendar mode and cannot switch out. + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // The date range picker should be open after restoring. + expect(find.byType(DateRangePickerDialog), findsOneWidget); + // Only in calendar mode and cannot switch out. + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + + // Tap on the barrier. + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + // The date range picker should be closed, the text value should be the same + // as before. + expect(find.byType(DateRangePickerDialog), findsNothing); + expect(find.text('1/1/2021 to 5/1/2021'), findsOneWidget); + + // The date range picker should be open after restoring. + await tester.restoreFrom(restorationData); + expect(find.byType(DateRangePickerDialog), findsOneWidget); + // Only in calendar mode and cannot switch out. + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.edit), findsNothing); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + group('showDateRangePicker avoids overlapping display features', () { + testWidgets('positioning with anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showDateRangePicker( + context: context, + firstDate: DateTime(2018), + lastDate: DateTime(2030), + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(DateRangePickerDialog)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(DateRangePickerDialog)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning with Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showDateRangePicker( + context: context, + firstDate: DateTime(2018), + lastDate: DateTime(2030), + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the right screen + expect(tester.getTopLeft(find.byType(DateRangePickerDialog)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(DateRangePickerDialog)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning with defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + + final BuildContext context = tester.element(find.text('Test')); + showDateRangePicker(context: context, firstDate: DateTime(2018), lastDate: DateTime(2030)); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(DateRangePickerDialog)), Offset.zero); + expect(tester.getBottomRight(find.byType(DateRangePickerDialog)), const Offset(390.0, 600.0)); + }); + }); + + group('Semantics', () { + testWidgets('calendar mode', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + currentDate = DateTime(2016, DateTime.january, 30); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + expect( + tester.getSemantics(find.text('30')), + matchesSemantics( + label: '30, Saturday, January 30, 2016, Today', + hasTapAction: true, + hasFocusAction: true, + hasSelectedState: true, + isFocusable: true, + ), + ); + }); + semantics.dispose(); + }); + }); + + for (final keyboardType in <TextInputType?>[null, TextInputType.emailAddress]) { + testWidgets('DateRangePicker takes keyboardType $keyboardType', (WidgetTester tester) async { + late BuildContext buttonContext; + const InputBorder border = InputBorder.none; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(inputDecorationTheme: const InputDecorationThemeData(border: border)), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + buttonContext = context; + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + expect(buttonContext, isNotNull); + + if (keyboardType == null) { + // If no keyboardType, expect the default. + showDateRangePicker( + context: buttonContext, + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: DatePickerEntryMode.input, + ); + } else { + // If there is a keyboardType, expect it to be passed through. + showDateRangePicker( + context: buttonContext, + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: DatePickerEntryMode.input, + keyboardType: keyboardType, + ); + } + await tester.pumpAndSettle(); + + final DateRangePickerDialog picker = tester.widget(find.byType(DateRangePickerDialog)); + expect(picker.keyboardType, keyboardType ?? TextInputType.datetime); + }); + } + + testWidgets('honors switchToInputEntryModeIcon', (WidgetTester tester) async { + Widget buildApp({bool? useMaterial3, Icon? switchToInputEntryModeIcon}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3 ?? false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Click X'), + onPressed: () { + showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + switchToInputEntryModeIcon: switchToInputEntryModeIcon, + ); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.edit), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(useMaterial3: true)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(switchToInputEntryModeIcon: const Icon(Icons.keyboard))); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.keyboard), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + }); + + testWidgets('honors switchToCalendarEntryModeIcon', (WidgetTester tester) async { + Widget buildApp({bool? useMaterial3, Icon? switchToCalendarEntryModeIcon}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3 ?? false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Click X'), + onPressed: () { + showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + initialEntryMode: DatePickerEntryMode.input, + cancelText: 'CANCEL', + ); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.calendar_today), findsOneWidget); + await tester.tap(find.text('CANCEL')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(useMaterial3: true)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.calendar_today), findsOneWidget); + await tester.tap(find.text('CANCEL')); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildApp(switchToCalendarEntryModeIcon: const Icon(Icons.favorite))); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + await tester.tap(find.text('CANCEL')); + await tester.pumpAndSettle(); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/154393. + testWidgets('DateRangePicker close button shape should be square', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final theme = ThemeData(); + final Finder buttonFinder = find.widgetWithIcon(IconButton, Icons.close); + expect(tester.getSize(buttonFinder), const Size(48.0, 48.0)); + + // Test the close button overlay size is square. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + expect( + buttonFinder, + paints..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 40.0), + color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08), + ), + ); + }, useMaterial3: true); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Default layout (calendar mode)', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Finder helpText = find.text('SELECT RANGE'); + final Finder firstDateHeaderText = find.text('Jan 15'); + final Finder lastDateHeaderText = find.text('Jan 25, 2016'); + final Finder saveText = find.text('SAVE'); + + expect(helpText, findsOneWidget); + expect(firstDateHeaderText, findsOneWidget); + expect(lastDateHeaderText, findsOneWidget); + expect(saveText, findsOneWidget); + + // Test the close button position. + final Offset closeButtonBottomRight = tester.getBottomRight(find.byType(CloseButton)); + final Offset helpTextTopLeft = tester.getTopLeft(helpText); + expect(closeButtonBottomRight.dx, 56.0); + expect(closeButtonBottomRight.dy, helpTextTopLeft.dy - 6.0); + + // Test the save and entry buttons position. + final Offset saveButtonBottomLeft = tester.getBottomLeft(find.byType(TextButton)); + final Offset entryButtonBottomLeft = tester.getBottomLeft( + find.widgetWithIcon(IconButton, Icons.edit), + ); + expect(saveButtonBottomLeft.dx, 800 - 80.0); + expect(saveButtonBottomLeft.dy, helpTextTopLeft.dy - 6.0); + expect(entryButtonBottomLeft.dx, saveButtonBottomLeft.dx - 48.0); + expect(entryButtonBottomLeft.dy, helpTextTopLeft.dy - 6.0); + + // Test help text position. + final Offset helpTextBottomLeft = tester.getBottomLeft(helpText); + expect(helpTextBottomLeft.dx, 72.0); + expect(helpTextBottomLeft.dy, closeButtonBottomRight.dy + 16.0); + + // Test the header position. + final Offset firstDateHeaderTopLeft = tester.getTopLeft(firstDateHeaderText); + final Offset lastDateHeaderTopLeft = tester.getTopLeft(lastDateHeaderText); + expect(firstDateHeaderTopLeft.dx, 72.0); + expect(firstDateHeaderTopLeft.dy, helpTextBottomLeft.dy + 8.0); + final Offset firstDateHeaderTopRight = tester.getTopRight(firstDateHeaderText); + expect(lastDateHeaderTopLeft.dx, firstDateHeaderTopRight.dx + 72.0); + expect(lastDateHeaderTopLeft.dy, helpTextBottomLeft.dy + 8.0); + + // Test the day headers position. + final Offset dayHeadersGridTopLeft = tester.getTopLeft(find.byType(GridView).first); + final Offset firstDateHeaderBottomLeft = tester.getBottomLeft(firstDateHeaderText); + expect(dayHeadersGridTopLeft.dx, (800 - 384) / 2); + expect(dayHeadersGridTopLeft.dy, firstDateHeaderBottomLeft.dy + 16.0); + + // Test the calendar custom scroll view position. + final Offset calendarScrollViewTopLeft = tester.getTopLeft(find.byType(CustomScrollView)); + final Offset dayHeadersGridBottomLeft = tester.getBottomLeft(find.byType(GridView).first); + expect(calendarScrollViewTopLeft.dx, 0.0); + expect(calendarScrollViewTopLeft.dy, dayHeadersGridBottomLeft.dy); + }); + }); + + testWidgets('Default Dialog properties (calendar mode)', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surface); + expect(dialogMaterial.shadowColor, Colors.transparent); + expect(dialogMaterial.surfaceTintColor, Colors.transparent); + expect(dialogMaterial.elevation, 0.0); + expect(dialogMaterial.shape, const RoundedRectangleBorder()); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, EdgeInsets.zero); + }); + }); + + testWidgets('Scaffold and AppBar defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Scaffold scaffold = tester.widget<Scaffold>(find.byType(Scaffold)); + expect(scaffold.backgroundColor, theme.colorScheme.surface); + + final AppBar appBar = tester.widget<AppBar>(find.byType(AppBar)); + final iconTheme = IconThemeData(color: theme.colorScheme.onPrimary); + expect(appBar.iconTheme, iconTheme); + expect(appBar.actionsIconTheme, iconTheme); + expect(appBar.elevation, null); + expect(appBar.scrolledUnderElevation, null); + expect(appBar.backgroundColor, theme.colorScheme.primary); + }); + }); + + group('Input mode', () { + setUp(() { + firstDate = DateTime(2015); + lastDate = DateTime(2017, DateTime.december, 31); + initialDateRange = DateTimeRange( + start: DateTime(2017, DateTime.january, 15), + end: DateTime(2017, DateTime.january, 17), + ); + initialEntryMode = DatePickerEntryMode.input; + }); + + testWidgets('Default Dialog properties (input mode)', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Material dialogMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + + expect(dialogMaterial.color, theme.colorScheme.surface); + expect(dialogMaterial.shadowColor, theme.shadowColor); + expect(dialogMaterial.surfaceTintColor, null); + expect(dialogMaterial.elevation, 24.0); + expect( + dialogMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + expect(dialogMaterial.clipBehavior, Clip.antiAlias); + + final Dialog dialog = tester.widget<Dialog>(find.byType(Dialog)); + expect(dialog.insetPadding, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)); + }); + }); + + testWidgets('Default InputDecoration', (WidgetTester tester) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final InputDecoration startDateDecoration = tester + .widget<TextField>(find.byType(TextField).first) + .decoration!; + expect(startDateDecoration.border, const UnderlineInputBorder()); + expect(startDateDecoration.filled, false); + expect(startDateDecoration.hintText, 'mm/dd/yyyy'); + expect(startDateDecoration.labelText, 'Start Date'); + expect(startDateDecoration.errorText, null); + + final InputDecoration endDateDecoration = tester + .widget<TextField>(find.byType(TextField).last) + .decoration!; + expect(endDateDecoration.border, const UnderlineInputBorder()); + expect(endDateDecoration.filled, false); + expect(endDateDecoration.hintText, 'mm/dd/yyyy'); + expect(endDateDecoration.labelText, 'End Date'); + expect(endDateDecoration.errorText, null); + }); + }); + }); + }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA<GregorianCalendarDelegate>()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA<TestCalendarDelegate>()); + }); + + testWidgets('showDateRangePicker uses gregorian calendar delegate by default', ( + WidgetTester tester, + ) async { + await preparePicker(tester, (Future<DateTimeRange?> range) async { + final Finder helpText = find.text('Select range'); + final Finder firstDateHeaderText = find.text('Jan 15'); + final Finder lastDateHeaderText = find.text('Jan 25, 2016'); + final Finder saveText = find.text('Save'); + + expect(helpText, findsOneWidget); + expect(firstDateHeaderText, findsOneWidget); + expect(lastDateHeaderText, findsOneWidget); + expect(saveText, findsOneWidget); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA<GregorianCalendarDelegate>()); + }, useMaterial3: true); + }); + + testWidgets('showDateRangePicker using custom calendar delegate implementation', ( + WidgetTester tester, + ) async { + await preparePicker( + tester, + (Future<DateTimeRange?> range) async { + final Finder helpText = find.text('Select range'); + final Finder firstDateHeaderText = find.text('Jan 15'); + final Finder lastDateHeaderText = find.text('Jan 25, 2016'); + final Finder saveText = find.text('Save'); + + expect(helpText, findsOneWidget); + expect(firstDateHeaderText, findsOneWidget); + expect(lastDateHeaderText, findsOneWidget); + expect(saveText, findsOneWidget); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA<TestCalendarDelegate>()); + }, + useMaterial3: true, + calendarDelegate: const TestCalendarDelegate(), + ); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + Finder getMonthItem() { + final Finder dayItem = find.descendant( + of: find.byType(ConstrainedBox), + matching: find.text('1'), + ); + return find.ancestor(of: dayItem, matching: find.byType(Column)); + } + + int getDayCount(Finder parent) { + final Finder dayItem = find.descendant( + of: parent, + matching: find.descendant(of: find.byType(InkResponse), matching: find.byType(Text)), + ); + return tester.widgetList(dayItem).length; + } + + Text getMonthYear(Finder parent) { + return tester.widget( + find + .descendant( + of: parent, + matching: find.descendant( + of: find.byType(ConstrainedBox), + matching: find.byType(Text), + ), + ) + .first, + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder monthItem = getMonthItem(); + + final Finder firstMonthItem = monthItem.at(0); + expect(getMonthYear(firstMonthItem).data, 'January 2016'); + expect(getDayCount(firstMonthItem), 28); + + final Finder secondMonthItem = monthItem.at(2); + expect(getMonthYear(secondMonthItem).data, 'February 2016'); + expect(getDayCount(secondMonthItem), 21); + }); + }); + + testWidgets('DateRangePickerDialog does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: DateRangePickerDialog(firstDate: firstDate, lastDate: lastDate), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DateRangePickerDialog)), Size.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177083. + testWidgets('Local InputDecorationTheme is honored', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: InputDecorationTheme( + data: const InputDecorationThemeData(filled: true), + child: DateRangePickerDialog( + firstDate: firstDate, + lastDate: lastDate, + currentDate: DateTime(2016, DateTime.january, 30), + initialEntryMode: DatePickerEntryMode.inputOnly, + ), + ), + ), + ), + ); + + final InputDecoration startDateDecoration = tester + .widget<TextField>(find.byType(TextField).first) + .decoration!; + + expect(startDateDecoration.filled, isTrue); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177441. + testWidgets('DateRangePickerDialog.currentDate is optional', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: InputDecorationTheme( + data: const InputDecorationThemeData(filled: true), + child: DateRangePickerDialog( + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: DatePickerEntryMode.inputOnly, + ), + ), + ), + ), + ); + + expect(tester.takeException(), null); + }); + + testWidgets('DateRangePicker respects DatePickerTheme.dayShape', (WidgetTester tester) async { + const OutlinedBorder customShape = BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + datePickerTheme: const DatePickerThemeData( + dayShape: WidgetStatePropertyAll<OutlinedBorder>(customShape), + ), + ), + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () => showDateRangePicker( + context: context, + firstDate: DateTime(2023), + lastDate: DateTime(2024), + initialDateRange: DateTimeRange( + start: DateTime(2023, 1, 15), + end: DateTime(2023, 1, 20), + ), + ), + child: const Text('Open Picker'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open Picker')); + await tester.pumpAndSettle(); + + final Finder selectedDayText = find.text('15'); + + final Finder dayContainerFinder = find + .ancestor( + of: selectedDayText, + matching: find.byWidgetPredicate((Widget widget) { + return widget is Container && widget.decoration is ShapeDecoration; + }), + ) + .first; + + final Container dayContainer = tester.widget<Container>(dayContainerFinder); + final decoration = dayContainer.decoration! as ShapeDecoration; + + expect(decoration.shape, customShape); + }); +} + +class _RestorableDateRangePickerDialogTestWidget extends StatefulWidget { + const _RestorableDateRangePickerDialogTestWidget({ + this.datePickerEntryMode = DatePickerEntryMode.calendar, + }); + + final DatePickerEntryMode datePickerEntryMode; + + @override + _RestorableDateRangePickerDialogTestWidgetState createState() => + _RestorableDateRangePickerDialogTestWidgetState(); +} + +@pragma('vm:entry-point') +class _RestorableDateRangePickerDialogTestWidgetState + extends State<_RestorableDateRangePickerDialogTestWidget> + with RestorationMixin { + @override + String? get restorationId => 'scaffold_state'; + + final RestorableDateTimeN _startDate = RestorableDateTimeN(DateTime(2021)); + final RestorableDateTimeN _endDate = RestorableDateTimeN(DateTime(2021, 1, 5)); + late final RestorableRouteFuture<DateTimeRange?> _restorableDateRangePickerRouteFuture = + RestorableRouteFuture<DateTimeRange?>( + onComplete: _selectDateRange, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _dateRangePickerRoute, + arguments: <String, dynamic>{'datePickerEntryMode': widget.datePickerEntryMode.index}, + ); + }, + ); + + @override + void dispose() { + _startDate.dispose(); + _endDate.dispose(); + _restorableDateRangePickerRouteFuture.dispose(); + super.dispose(); + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_startDate, 'start_date'); + registerForRestoration(_endDate, 'end_date'); + registerForRestoration(_restorableDateRangePickerRouteFuture, 'date_picker_route_future'); + } + + void _selectDateRange(DateTimeRange? newSelectedDate) { + if (newSelectedDate != null) { + setState(() { + _startDate.value = newSelectedDate.start; + _endDate.value = newSelectedDate.end; + }); + } + } + + @pragma('vm:entry-point') + static Route<DateTimeRange?> _dateRangePickerRoute(BuildContext context, Object? arguments) { + return DialogRoute<DateTimeRange?>( + context: context, + builder: (BuildContext context) { + final args = arguments! as Map<dynamic, dynamic>; + return DateRangePickerDialog( + restorationId: 'date_picker_dialog', + initialEntryMode: DatePickerEntryMode.values[args['datePickerEntryMode'] as int], + firstDate: DateTime(2021), + currentDate: DateTime(2021, 1, 25), + lastDate: DateTime(2022), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final DateTime? startDateTime = _startDate.value; + final DateTime? endDateTime = _endDate.value; + // Example: "25/7/1994" + final startDateTimeString = + '${startDateTime?.day}/${startDateTime?.month}/${startDateTime?.year}'; + final endDateTimeString = '${endDateTime?.day}/${endDateTime?.month}/${endDateTime?.year}'; + return Scaffold( + body: Center( + child: Column( + children: <Widget>[ + OutlinedButton( + onPressed: () { + _restorableDateRangePickerRouteFuture.present(); + }, + child: const Text('X'), + ), + Text('$startDateTimeString to $endDateTimeString'), + ], + ), + ), + ); + } +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/material_ui/test/material/debug_test.dart b/packages/material_ui/test/material/debug_test.dart new file mode 100644 index 000000000000..6477400e453b --- /dev/null +++ b/packages/material_ui/test/material/debug_test.dart @@ -0,0 +1,228 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('debugCheckHasMaterial control test', (WidgetTester tester) async { + await tester.pumpWidget(const Center(child: Chip(label: Text('label')))); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + final error = exception as FlutterError; + expect(error.diagnostics.length, 5); + expect(error.diagnostics[2].level, DiagnosticLevel.hint); + expect( + error.diagnostics[2].toStringDeep(), + equalsIgnoringHashCodes( + 'To introduce a Material widget, you can either directly include\n' + 'one, or use a widget that contains Material itself, such as a\n' + 'Card, Dialog, Drawer, or Scaffold.\n', + ), + ); + expect(error.diagnostics[3], isA<DiagnosticsProperty<Element>>()); + expect(error.diagnostics[4], isA<DiagnosticsBlock>()); + expect( + error.toStringDeep(), + startsWith( + 'FlutterError\n' + ' No Material widget found.\n' + ' Chip widgets require a Material widget ancestor within the\n' + ' closest LookupBoundary.\n' + ' In Material Design, most widgets are conceptually "printed" on a\n' + " sheet of material. In Flutter's material library, that material\n" + ' is represented by the Material widget. It is the Material widget\n' + ' that renders ink splashes, for instance. Because of this, many\n' + ' material library widgets require that there be a Material widget\n' + ' in the tree above them.\n' + ' To introduce a Material widget, you can either directly include\n' + ' one, or use a widget that contains Material itself, such as a\n' + ' Card, Dialog, Drawer, or Scaffold.\n' + ' The specific widget that could not find a Material ancestor was:\n' + ' Chip\n' + ' The ancestors of this widget were:\n' + ' Center\n', + // End of ancestor chain omitted, not relevant for test. + ), + ); + }); + + testWidgets('debugCheckHasMaterialLocalizations control test', (WidgetTester tester) async { + await tester.pumpWidget(const Center(child: BackButton())); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + final error = exception as FlutterError; + expect(error.diagnostics.length, 6); + expect(error.diagnostics[3].level, DiagnosticLevel.hint); + expect( + error.diagnostics[3].toStringDeep(), + equalsIgnoringHashCodes( + 'To introduce a MaterialLocalizations, either use a MaterialApp at\n' + 'the root of your application to include them automatically, or\n' + 'add a Localization widget with a MaterialLocalizations delegate.\n', + ), + ); + expect(error.diagnostics[4], isA<DiagnosticsProperty<Element>>()); + expect(error.diagnostics[5], isA<DiagnosticsBlock>()); + expect( + error.toStringDeep(), + startsWith( + 'FlutterError\n' + ' No MaterialLocalizations found.\n' + ' BackButton widgets require MaterialLocalizations to be provided\n' + ' by a Localizations widget ancestor.\n' + ' The material library uses Localizations to generate messages,\n' + ' labels, and abbreviations.\n' + ' To introduce a MaterialLocalizations, either use a MaterialApp at\n' + ' the root of your application to include them automatically, or\n' + ' add a Localization widget with a MaterialLocalizations delegate.\n' + ' The specific widget that could not find a MaterialLocalizations\n' + ' ancestor was:\n' + ' BackButton\n' + ' The ancestors of this widget were:\n' + ' Center\n', + // End of ancestor chain omitted, not relevant for test. + ), + ); + }); + + testWidgets('debugCheckHasScaffold control test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), + }, + ), + ), + home: Builder( + builder: (BuildContext context) { + showBottomSheet(context: context, builder: (BuildContext context) => Container()); + return Container(); + }, + ), + ), + ); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + final error = exception as FlutterError; + expect(error.diagnostics.length, 5); + expect(error.diagnostics[2], isA<DiagnosticsProperty<Element>>()); + expect(error.diagnostics[3], isA<DiagnosticsBlock>()); + expect(error.diagnostics[4].level, DiagnosticLevel.hint); + expect( + error.diagnostics[4].toStringDeep(), + equalsIgnoringHashCodes( + 'Typically, the Scaffold widget is introduced by the MaterialApp\n' + 'or WidgetsApp widget at the top of your application widget tree.\n', + ), + ); + expect( + error.toStringDeep(), + startsWith( + 'FlutterError\n' + ' No Scaffold widget found.\n' + ' Builder widgets require a Scaffold widget ancestor.\n' + ' The specific widget that could not find a Scaffold ancestor was:\n' + ' Builder\n' + ' The ancestors of this widget were:\n' + ' Semantics\n' + ' Builder\n', + ), + ); + expect( + error.toStringDeep(), + endsWith( + ' [root]\n' + ' Typically, the Scaffold widget is introduced by the MaterialApp\n' + ' or WidgetsApp widget at the top of your application widget tree.\n', + ), + ); + }); + + testWidgets('debugCheckHasScaffoldMessenger control test', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); + final snackBar = SnackBar( + content: const Text('Snack'), + action: SnackBarAction(label: 'Test', onPressed: () {}), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ScaffoldMessenger( + key: scaffoldMessengerKey, + child: Builder( + builder: (BuildContext context) { + return Scaffold(key: scaffoldKey, body: Container()); + }, + ), + ), + ), + ); + final exceptions = <dynamic>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + // ScaffoldMessenger shows SnackBar. + scaffoldMessengerKey.currentState!.showSnackBar(snackBar); + await tester.pumpAndSettle(); + + // Pump widget to rebuild without ScaffoldMessenger + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Scaffold(key: scaffoldKey, body: Container()), + ), + ); + // Tap SnackBarAction to dismiss. + // The SnackBarAction should assert we still have an ancestor + // ScaffoldMessenger in order to dismiss the SnackBar from the + // Scaffold. + await tester.tap(find.text('Test')); + FlutterError.onError = oldHandler; + + expect(exceptions.length, 1); + expect(exceptions.single.runtimeType, FlutterError); + final error = exceptions.first as FlutterError; + expect(error.diagnostics.length, 5); + expect(error.diagnostics[2], isA<DiagnosticsProperty<Element>>()); + expect(error.diagnostics[3], isA<DiagnosticsBlock>()); + expect(error.diagnostics[4].level, DiagnosticLevel.hint); + expect( + error.diagnostics[4].toStringDeep(), + equalsIgnoringHashCodes( + 'Typically, the ScaffoldMessenger widget is introduced by the\n' + 'MaterialApp at the top of your application widget tree.\n', + ), + ); + expect( + error.toStringDeep(), + startsWith( + 'FlutterError\n' + ' No ScaffoldMessenger widget found.\n' + ' SnackBarAction widgets require a ScaffoldMessenger widget\n' + ' ancestor.\n' + ' The specific widget that could not find a ScaffoldMessenger\n' + ' ancestor was:\n' + ' SnackBarAction\n' + ' The ancestors of this widget were:\n' + ' TextButtonTheme\n' + ' Padding\n' + ' Row\n', + ), + ); + expect( + error.toStringDeep(), + endsWith( + ' [root]\n' + ' Typically, the ScaffoldMessenger widget is introduced by the\n' + ' MaterialApp at the top of your application widget tree.\n', + ), + ); + }); +} diff --git a/packages/material_ui/test/material/desktop_text_selection_toolbar_button_test.dart b/packages/material_ui/test/material/desktop_text_selection_toolbar_button_test.dart new file mode 100644 index 000000000000..ff80b5daaa62 --- /dev/null +++ b/packages/material_ui/test/material/desktop_text_selection_toolbar_button_test.dart @@ -0,0 +1,60 @@ +// 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() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('can press', (WidgetTester tester) async { + var pressed = false; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: DesktopTextSelectionToolbarButton( + onPressed: () { + pressed = true; + }, + child: const Text('Tap me'), + ), + ), + ), + ); + + expect(pressed, false); + + await tester.tap(find.byType(DesktopTextSelectionToolbarButton)); + expect(pressed, true); + }); + + testWidgets('passing null to onPressed disables the button', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: DesktopTextSelectionToolbarButton(onPressed: null, child: Text('Cannot tap me')), + ), + ), + ); + + expect(find.byType(TextButton), findsOneWidget); + final TextButton button = tester.widget(find.byType(TextButton)); + expect(button.enabled, isFalse); + }); + + testWidgets('DesktopTextSelectionToolbarButton does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: DesktopTextSelectionToolbarButton(onPressed: null, child: Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DesktopTextSelectionToolbarButton)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/desktop_text_selection_toolbar_test.dart b/packages/material_ui/test/material/desktop_text_selection_toolbar_test.dart new file mode 100644 index 000000000000..750084b855e2 --- /dev/null +++ b/packages/material_ui/test/material/desktop_text_selection_toolbar_test.dart @@ -0,0 +1,46 @@ +// 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() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('positions itself at the anchor', (WidgetTester tester) async { + // An arbitrary point on the screen to position at. + const anchor = Offset(30.0, 40.0); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: DesktopTextSelectionToolbar( + anchor: anchor, + children: <Widget>[ + DesktopTextSelectionToolbarButton(child: const Text('Tap me'), onPressed: () {}), + ], + ), + ), + ), + ); + + expect(tester.getTopLeft(find.byType(DesktopTextSelectionToolbarButton)), anchor); + }); + + testWidgets('DesktopTextSelectionToolbar renders at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: DesktopTextSelectionToolbar( + anchor: const Offset(10, 10), + children: const <Widget>[Text('X')], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DesktopTextSelectionToolbar)).isEmpty, isTrue); + }); +} diff --git a/packages/material_ui/test/material/dialog_test.dart b/packages/material_ui/test/material/dialog_test.dart new file mode 100644 index 000000000000..a831d411fed2 --- /dev/null +++ b/packages/material_ui/test/material/dialog_test.dart @@ -0,0 +1,3526 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +MaterialApp _buildAppWithDialog( + Widget dialog, { + ThemeData? theme, + double textScaleFactor = 1.0, + TraversalEdgeBehavior? traversalEdgeBehavior, +}) { + return MaterialApp( + theme: theme, + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog<void>( + context: context, + traversalEdgeBehavior: traversalEdgeBehavior, + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: dialog, + ); + }, + ); + }, + ), + ); + }, + ), + ), + ); +} + +Material _getMaterialFromDialog(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); +} + +RenderParagraph _getTextRenderObjectFromDialog(WidgetTester tester, String text) { + return tester + .element<StatelessElement>( + find.descendant(of: find.byType(Dialog), matching: find.text(text)), + ) + .renderObject! + as RenderParagraph; +} + +// What was the AlertDialog's ButtonBar when many of these tests were written, +// is now a Padding widget with an OverflowBar child. The Padding widget's size +// and location match the original ButtonBar's size and location. +Finder _findOverflowBar() { + return find.ancestor(of: find.byType(OverflowBar), matching: find.byType(Padding)).first; +} + +const ShapeBorder _defaultM2DialogShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), +); +const ShapeBorder _defaultM3DialogShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28.0)), +); + +void main() { + final material3Theme = ThemeData(brightness: Brightness.dark); + final material2Theme = ThemeData(useMaterial3: false, brightness: Brightness.dark); + + testWidgets('Dialog is scrollable', (WidgetTester tester) async { + var didPressOk = false; + final dialog = AlertDialog( + content: Container(height: 5000.0, width: 300.0, color: Colors.green[500]), + actions: <Widget>[ + TextButton( + onPressed: () { + didPressOk = true; + }, + child: const Text('OK'), + ), + ], + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(didPressOk, false); + await tester.tap(find.text('OK')); + expect(didPressOk, true); + }); + + testWidgets('Dialog background color from AlertDialog', (WidgetTester tester) async { + const Color customColor = Colors.pink; + const dialog = AlertDialog(backgroundColor: customColor, actions: <Widget>[]); + await tester.pumpWidget( + _buildAppWithDialog(dialog, theme: ThemeData(brightness: Brightness.dark)), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.color, customColor); + }); + + testWidgets('Dialog background defaults to ColorScheme.surfaceContainerHigh', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + colorScheme: ThemeData().colorScheme.copyWith( + surface: Colors.orange, + background: Colors.green, + surfaceContainerHigh: Colors.red, + ), + ); + const dialog = Dialog(child: SizedBox(width: 200, height: 200)); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.color, theme.colorScheme.surfaceContainerHigh); + }); + + testWidgets('Material2 - Dialog Defaults', (WidgetTester tester) async { + const dialog = AlertDialog(title: Text('Title'), content: Text('Y'), actions: <Widget>[]); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: material2Theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.color, Colors.grey[800]); + expect(materialWidget.shape, _defaultM2DialogShape); + expect(materialWidget.elevation, 24.0); + + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dy, 360.0); + }); + + testWidgets('Material3 - Dialog Defaults', (WidgetTester tester) async { + const dialog = AlertDialog(title: Text('Title'), content: Text('Y'), actions: <Widget>[]); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: material3Theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material material3Widget = _getMaterialFromDialog(tester); + expect(material3Widget.color, material3Theme.colorScheme.surfaceContainerHigh); + expect(material3Widget.shape, _defaultM3DialogShape); + expect(material3Widget.elevation, 6.0); + }); + + testWidgets('Material2 - Dialog.fullscreen Defaults', (WidgetTester tester) async { + const dialogTextM2 = 'Fullscreen Dialog - M2'; + + await tester.pumpWidget( + _buildAppWithDialog( + theme: material2Theme, + const Dialog.fullscreen(child: Text(dialogTextM2)), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.text(dialogTextM2), findsOneWidget); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.color, Colors.grey[800]); + + // Try to dismiss the fullscreen dialog with the escape key. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text(dialogTextM2), findsNothing); + }); + + testWidgets('Material3 - Dialog.fullscreen Defaults', (WidgetTester tester) async { + const dialogTextM3 = 'Fullscreen Dialog - M3'; + + await tester.pumpWidget( + _buildAppWithDialog( + theme: material3Theme, + const Dialog.fullscreen(child: Text(dialogTextM3)), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.text(dialogTextM3), findsOneWidget); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.color, material3Theme.colorScheme.surface); + + // Try to dismiss the fullscreen dialog with the escape key. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text(dialogTextM3), findsNothing); + }); + + testWidgets('Custom dialog elevation', (WidgetTester tester) async { + const customElevation = 12.0; + const shadowColor = Color(0xFF000001); + const surfaceTintColor = Color(0xFF000002); + const dialog = AlertDialog( + actions: <Widget>[], + elevation: customElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.elevation, customElevation); + expect(materialWidget.shadowColor, shadowColor); + expect(materialWidget.surfaceTintColor, surfaceTintColor); + }); + + testWidgets('Custom Title Text Style', (WidgetTester tester) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog( + title: Text(titleText), + titleTextStyle: titleTextStyle, + actions: <Widget>[], + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObjectFromDialog(tester, titleText); + expect(title.text.style, titleTextStyle); + }); + + testWidgets('Custom Content Text Style', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog( + content: Text(contentText), + contentTextStyle: contentTextStyle, + actions: <Widget>[], + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, contentTextStyle); + }); + + testWidgets('AlertDialog custom clipBehavior', (WidgetTester tester) async { + const dialog = AlertDialog(actions: <Widget>[], clipBehavior: Clip.antiAlias); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.clipBehavior, Clip.antiAlias); + }); + + testWidgets('SimpleDialog custom clipBehavior', (WidgetTester tester) async { + const dialog = SimpleDialog(clipBehavior: Clip.antiAlias, children: <Widget>[]); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.clipBehavior, Clip.antiAlias); + }); + + testWidgets('SimpleDialog Custom Content Text Style', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.pink); + const dialog = SimpleDialog( + contentTextStyle: contentTextStyle, + children: <Widget>[Text(contentText)], + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, contentTextStyle); + }); + + testWidgets('SimpleDialog Custom Content Text Style - DialogTheme', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.orange); + const dialog = SimpleDialog(children: <Widget>[Text(contentText)]); + final theme = ThemeData(dialogTheme: const DialogThemeData(contentTextStyle: contentTextStyle)); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, contentTextStyle); + }); + + testWidgets('Material3 - SimpleDialog Custom Content Text Style - Theme', ( + WidgetTester tester, + ) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.purple); + const dialog = SimpleDialog(children: <Widget>[Text(contentText)]); + final theme = ThemeData(textTheme: const TextTheme(bodyMedium: contentTextStyle)); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style!.color, contentTextStyle.color); + }); + + testWidgets('Material2 - SimpleDialog Custom Content Text Style - Theme', ( + WidgetTester tester, + ) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.teal); + const dialog = SimpleDialog(children: <Widget>[Text(contentText)]); + final theme = ThemeData( + useMaterial3: false, + textTheme: const TextTheme(titleMedium: contentTextStyle), + ); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style!.color, contentTextStyle.color); + }); + + testWidgets('Custom dialog shape', (WidgetTester tester) async { + const customBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ); + const dialog = AlertDialog(actions: <Widget>[], shape: customBorder); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.shape, customBorder); + }); + + testWidgets('Null dialog shape', (WidgetTester tester) async { + final theme = ThemeData(); + const dialog = AlertDialog(actions: <Widget>[]); + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect( + materialWidget.shape, + theme.useMaterial3 ? _defaultM3DialogShape : _defaultM2DialogShape, + ); + }); + + testWidgets('Rectangular dialog shape', (WidgetTester tester) async { + const ShapeBorder customBorder = Border(); + const dialog = AlertDialog(actions: <Widget>[], shape: customBorder); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialFromDialog(tester); + expect(materialWidget.shape, customBorder); + }); + + testWidgets('Custom dialog alignment', (WidgetTester tester) async { + const dialog = AlertDialog(actions: <Widget>[], alignment: Alignment.bottomLeft); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dx, 40.0); + expect(bottomLeft.dy, 576.0); + }); + + testWidgets('Simple dialog control test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + final Future<int?> result = showDialog<int>( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: const Text('Title'), + children: <Widget>[ + SimpleDialogOption( + onPressed: () { + Navigator.pop(context, 42); + }, + child: const Text('First option'), + ), + const SimpleDialogOption(child: Text('Second option')), + ], + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Title'), findsOneWidget); + await tester.tap(find.text('First option')); + + expect(await result, equals(42)); + }); + + testWidgets('Can show dialog using navigator global key', (WidgetTester tester) async { + final navigator = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: const Material(child: Center(child: Text('Go'))), + ), + ); + + final Future<int?> result = showDialog<int>( + context: navigator.currentContext!, + builder: (BuildContext context) { + return SimpleDialog( + title: const Text('Title'), + children: <Widget>[ + SimpleDialogOption( + onPressed: () { + Navigator.pop(context, 42); + }, + child: const Text('First option'), + ), + const SimpleDialogOption(child: Text('Second option')), + ], + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Title'), findsOneWidget); + await tester.tap(find.text('First option')); + + expect(await result, equals(42)); + }); + + testWidgets('Custom padding on SimpleDialogOption', (WidgetTester tester) async { + const customPadding = EdgeInsets.fromLTRB(4, 10, 8, 6); + final dialog = SimpleDialog( + title: const Text('Title'), + children: <Widget>[ + SimpleDialogOption( + onPressed: () {}, + padding: customPadding, + child: const Text('First option'), + ), + ], + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Rect dialogRect = tester.getRect(find.byType(SimpleDialogOption)); + final Rect textRect = tester.getRect(find.text('First option')); + + expect(textRect.left, dialogRect.left + customPadding.left); + expect(textRect.top, dialogRect.top + customPadding.top); + expect(textRect.right, dialogRect.right - customPadding.right); + expect(textRect.bottom, dialogRect.bottom - customPadding.bottom); + }); + + testWidgets('Barrier dismissible', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + showDialog<void>( + context: context, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog1'), + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog1'), findsOneWidget); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog1'), findsNothing); + + showDialog<void>( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog2'), + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog2'), findsOneWidget); + + // Tap on the barrier, which shouldn't do anything this time. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog2'), findsOneWidget); + }); + + testWidgets('Barrier color', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Center(child: Text('Test')))); + final BuildContext context = tester.element(find.text('Test')); + + // Test default barrier color + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Text('Dialog'); + }, + ); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.black54); + + // Dismiss it and test a custom barrier color + await tester.tapAt(const Offset(10.0, 10.0)); + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Text('Dialog'); + }, + barrierColor: Colors.pink, + ); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.pink); + }); + + testWidgets('Dialog hides underlying semantics tree', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const buttonText = 'A button covered by dialog overlay'; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text(buttonText))), + ), + ), + ); + + expect(semantics, includesNodeWith(label: buttonText)); + + final BuildContext context = tester.element(find.text(buttonText)); + + const alertText = 'A button in an overlay alert'; + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const AlertDialog(title: Text(alertText)); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(semantics, includesNodeWith(label: alertText)); + expect(semantics, isNot(includesNodeWith(label: buttonText))); + + semantics.dispose(); + }); + + testWidgets('AlertDialog.actionsPadding defaults', (WidgetTester tester) async { + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ElevatedButton(onPressed: () {}, child: const Text('button'))], + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // The [AlertDialog] is the entire screen, since it also contains the scrim. + // The first [Material] child of [AlertDialog] is the actual dialog + // itself. + final Size dialogSize = tester.getSize( + find.descendant(of: find.byType(AlertDialog), matching: find.byType(Material)).first, + ); + final Size actionsSize = tester.getSize(_findOverflowBar()); + + expect(actionsSize.width, dialogSize.width); + }); + + testWidgets('AlertDialog.actionsPadding surrounds actions with padding', ( + WidgetTester tester, + ) async { + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ElevatedButton(onPressed: () {}, child: const Text('button'))], + // The OverflowBar is inset by the buttonPadding/2 + actionsPadding + buttonPadding: EdgeInsets.zero, + actionsPadding: const EdgeInsets.all(30.0), // custom padding value + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // The [AlertDialog] is the entire screen, since it also contains the scrim. + // The first [Material] child of [AlertDialog] is the actual dialog + // itself. + final Size dialogSize = tester.getSize( + find.descendant(of: find.byType(AlertDialog), matching: find.byType(Material)).first, + ); + final Size actionsSize = tester.getSize(find.byType(OverflowBar)); + + expect(actionsSize.width, dialogSize.width - (30.0 * 2)); + }); + + testWidgets('Material2 - AlertDialog.buttonPadding defaults', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton(key: key1, onPressed: () {}, child: const Text('button 1')), + ElevatedButton(key: key2, onPressed: () {}, child: const Text('button 2')), + ], + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: material2Theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Padding between both buttons + expect( + tester.getBottomLeft(find.byKey(key2)).dx, + tester.getBottomRight(find.byKey(key1)).dx + 8.0, + ); + + // Padding between button and edges of the button bar + // First button + expect( + tester.getTopRight(find.byKey(key1)).dy, + tester.getTopRight(_findOverflowBar()).dy + 8.0, + ); // top + expect( + tester.getBottomRight(find.byKey(key1)).dy, + tester.getBottomRight(_findOverflowBar()).dy - 8.0, + ); // bottom + + // Second button + expect( + tester.getTopRight(find.byKey(key2)).dy, + tester.getTopRight(_findOverflowBar()).dy + 8.0, + ); // top + expect( + tester.getBottomRight(find.byKey(key2)).dy, + tester.getBottomRight(_findOverflowBar()).dy - 8.0, + ); // bottom + expect( + tester.getBottomRight(find.byKey(key2)).dx, + tester.getBottomRight(_findOverflowBar()).dx - 8.0, + ); // right + }); + + testWidgets('Material3 - AlertDialog.buttonPadding defaults', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton(key: key1, onPressed: () {}, child: const Text('button 1')), + ElevatedButton(key: key2, onPressed: () {}, child: const Text('button 2')), + ], + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: material3Theme)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Padding between both buttons + expect( + tester.getBottomLeft(find.byKey(key2)).dx, + tester.getBottomRight(find.byKey(key1)).dx + 8.0, + ); + + // Padding between button and edges of the button bar + // First button + expect( + tester.getTopRight(find.byKey(key1)).dy, + tester.getTopRight(_findOverflowBar()).dy, + ); // top + expect( + tester.getBottomRight(find.byKey(key1)).dy, + tester.getBottomRight(_findOverflowBar()).dy - 24.0, + ); // bottom + + // // Second button + expect( + tester.getTopRight(find.byKey(key2)).dy, + tester.getTopRight(_findOverflowBar()).dy, + ); // top + expect( + tester.getBottomRight(find.byKey(key2)).dy, + tester.getBottomRight(_findOverflowBar()).dy - 24.0, + ); // bottom + expect( + tester.getBottomRight(find.byKey(key2)).dx, + tester.getBottomRight(_findOverflowBar()).dx - 24.0, + ); // right + }); + + testWidgets('AlertDialog.buttonPadding custom values', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton(key: key1, onPressed: () {}, child: const Text('button 1')), + ElevatedButton(key: key2, onPressed: () {}, child: const Text('button 2')), + ], + buttonPadding: const EdgeInsets.only(left: 10.0, right: 20.0), + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: ThemeData(useMaterial3: false))); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Padding between both buttons + expect( + tester.getBottomLeft(find.byKey(key2)).dx, + tester.getBottomRight(find.byKey(key1)).dx + ((10.0 + 20.0) / 2), + ); + + // Padding between button and edges of the button bar + // First button + expect( + tester.getTopRight(find.byKey(key1)).dy, + tester.getTopRight(_findOverflowBar()).dy + ((10.0 + 20.0) / 2), + ); // top + expect( + tester.getBottomRight(find.byKey(key1)).dy, + tester.getBottomRight(_findOverflowBar()).dy - ((10.0 + 20.0) / 2), + ); // bottom + + // Second button + expect( + tester.getTopRight(find.byKey(key2)).dy, + tester.getTopRight(_findOverflowBar()).dy + ((10.0 + 20.0) / 2), + ); // top + expect( + tester.getBottomRight(find.byKey(key2)).dy, + tester.getBottomRight(_findOverflowBar()).dy - ((10.0 + 20.0) / 2), + ); // bottom + expect( + tester.getBottomRight(find.byKey(key2)).dx, + tester.getBottomRight(_findOverflowBar()).dx - ((10.0 + 20.0) / 2), + ); // right + }); + + group('Dialog children padding is correct', () { + final textScaleFactors = <double>[0.5, 1.0, 1.5, 2.0, 3.0]; + final paddingScaleFactors = <double, double>{ + 0.5: 1.0, + 1.0: 1.0, + 1.5: 2.0 / 3.0, + 2.0: 1.0 / 3.0, + 3.0: 1.0 / 3.0, + }; + + final GlobalKey iconKey = GlobalKey(); + final GlobalKey titleKey = GlobalKey(); + final GlobalKey contentKey = GlobalKey(); + final GlobalKey childrenKey = GlobalKey(); + + final Finder dialogFinder = find + .descendant(of: find.byType(Dialog), matching: find.byType(Material)) + .first; + final Finder iconFinder = find.byKey(iconKey); + final Finder titleFinder = find.byKey(titleKey); + final Finder contentFinder = find.byKey(contentKey); + final Finder actionsFinder = _findOverflowBar(); + final Finder childrenFinder = find.byKey(childrenKey); + + Future<void> openDialog( + WidgetTester tester, + Widget dialog, + double textScaleFactor, { + bool isM3 = false, + }) async { + await tester.pumpWidget( + _buildAppWithDialog( + dialog, + textScaleFactor: textScaleFactor, + theme: ThemeData(useMaterial3: isM3), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + } + + void expectLeftEdgePadding( + WidgetTester tester, { + required Finder finder, + required double textScaleFactor, + required double unscaledValue, + }) { + expect( + tester.getTopLeft(dialogFinder).dx, + moreOrLessEquals( + tester.getTopLeft(finder).dx - unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + expect( + tester.getBottomLeft(dialogFinder).dx, + moreOrLessEquals( + tester.getBottomLeft(finder).dx - unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + } + + void expectRightEdgePadding( + WidgetTester tester, { + required Finder finder, + required double textScaleFactor, + required double unscaledValue, + }) { + expect( + tester.getTopRight(dialogFinder).dx, + moreOrLessEquals( + tester.getTopRight(finder).dx + unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + expect( + tester.getBottomRight(dialogFinder).dx, + moreOrLessEquals( + tester.getBottomRight(finder).dx + unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + } + + void expectTopEdgePadding( + WidgetTester tester, { + required Finder finder, + required double textScaleFactor, + required double unscaledValue, + }) { + expect( + tester.getTopLeft(dialogFinder).dy, + moreOrLessEquals( + tester.getTopLeft(finder).dy - unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + expect( + tester.getTopRight(dialogFinder).dy, + moreOrLessEquals( + tester.getTopRight(finder).dy - unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + } + + void expectBottomEdgePadding( + WidgetTester tester, { + required Finder finder, + required double textScaleFactor, + required double unscaledValue, + }) { + expect( + tester.getBottomLeft(dialogFinder).dy, + moreOrLessEquals( + tester.getBottomRight(finder).dy + unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + expect( + tester.getBottomRight(dialogFinder).dy, + moreOrLessEquals( + tester.getBottomRight(finder).dy + unscaledValue * paddingScaleFactors[textScaleFactor]!, + ), + ); + } + + void expectVerticalInnerPadding( + WidgetTester tester, { + required Finder top, + required Finder bottom, + required double value, + }) { + expect(tester.getBottomLeft(top).dy, tester.getTopLeft(bottom).dy - value); + expect(tester.getBottomRight(top).dy, tester.getTopRight(bottom).dy - value); + } + + final Widget icon = Icon(Icons.ac_unit, key: iconKey); + final Widget title = Text('title', key: titleKey); + final Widget content = Text('content', key: contentKey); + final actions = <Widget>[ElevatedButton(onPressed: () {}, child: const Text('button'))]; + final children = <Widget>[ + SimpleDialogOption(key: childrenKey, child: const Text('child'), onPressed: () {}), + ]; + + for (final textScaleFactor in textScaleFactors) { + testWidgets( + 'AlertDialog padding is correct when only icon and actions are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = AlertDialog(icon: icon, actions: actions); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding(tester, top: iconFinder, bottom: actionsFinder, value: 24.0); + expectLeftEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + }, + ); + + testWidgets( + 'AlertDialog padding is correct when only icon, title and actions are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = AlertDialog(icon: icon, title: title, actions: actions); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding(tester, top: iconFinder, bottom: titleFinder, value: 16.0); + expectLeftEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding(tester, top: titleFinder, bottom: actionsFinder, value: 20.0); + expectLeftEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + }, + ); + + for (final isM3 in <bool>[true, false]) { + testWidgets( + 'AlertDialog padding is correct when only icon, content and actions are specified [textScaleFactor]=$textScaleFactor [isM3]=$isM3', + (WidgetTester tester) async { + final dialog = AlertDialog(icon: icon, content: content, actions: actions); + + await openDialog(tester, dialog, textScaleFactor, isM3: isM3); + + expectTopEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: iconFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding( + tester, + top: iconFinder, + bottom: contentFinder, + value: isM3 ? 16.0 : 20.0, + ); + expectLeftEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding( + tester, + top: contentFinder, + bottom: actionsFinder, + value: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + }, + ); + } + + testWidgets( + 'AlertDialog padding is correct when only title and actions are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = AlertDialog(title: title, actions: actions); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding(tester, top: titleFinder, bottom: actionsFinder, value: 20.0); + expectLeftEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + }, + ); + + testWidgets( + 'AlertDialog padding is correct when only content and actions are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = AlertDialog(content: content, actions: actions); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 20.0, + ); + expectLeftEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding( + tester, + top: contentFinder, + bottom: actionsFinder, + value: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + }, + ); + + testWidgets( + 'AlertDialog padding is correct when title, content, and actions are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = AlertDialog(title: title, content: content, actions: actions); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding(tester, top: titleFinder, bottom: contentFinder, value: 20.0); + expectLeftEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: contentFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding( + tester, + top: contentFinder, + bottom: actionsFinder, + value: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: actionsFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + }, + ); + + testWidgets( + 'SimpleDialog padding is correct when only children are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = SimpleDialog(children: children); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 12.0, + ); + expectLeftEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 16.0, + ); + }, + ); + + testWidgets( + 'SimpleDialog padding is correct when title and children are specified [textScaleFactor]=$textScaleFactor', + (WidgetTester tester) async { + final dialog = SimpleDialog(title: title, children: children); + + await openDialog(tester, dialog, textScaleFactor); + + expectTopEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectLeftEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectRightEdgePadding( + tester, + finder: titleFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 24.0, + ); + expectVerticalInnerPadding(tester, top: titleFinder, bottom: childrenFinder, value: 12.0); + expectLeftEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectRightEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 0.0, + ); + expectBottomEdgePadding( + tester, + finder: childrenFinder, + textScaleFactor: textScaleFactor, + unscaledValue: 16.0, + ); + }, + ); + } + }); + + testWidgets('Dialogs can set the vertical direction of overflowing actions', ( + WidgetTester tester, + ) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton( + key: key1, + onPressed: () {}, + child: const Text('Looooooooooooooong button 1'), + ), + ElevatedButton( + key: key2, + onPressed: () {}, + child: const Text('Looooooooooooooong button 2'), + ), + ], + actionsOverflowDirection: VerticalDirection.up, + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Rect buttonOneRect = tester.getRect(find.byKey(key1)); + final Rect buttonTwoRect = tester.getRect(find.byKey(key2)); + // Second [ElevatedButton] should appear above the first. + expect(buttonTwoRect.bottom, lessThanOrEqualTo(buttonOneRect.top)); + }); + + testWidgets('Dialogs have no spacing by default for overflowing actions', ( + WidgetTester tester, + ) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton( + key: key1, + onPressed: () {}, + child: const Text('Looooooooooooooong button 1'), + ), + ElevatedButton( + key: key2, + onPressed: () {}, + child: const Text('Looooooooooooooong button 2'), + ), + ], + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Rect buttonOneRect = tester.getRect(find.byKey(key1)); + final Rect buttonTwoRect = tester.getRect(find.byKey(key2)); + expect(buttonOneRect.bottom, buttonTwoRect.top); + }); + + testWidgets('Dialogs can set the button spacing of overflowing actions', ( + WidgetTester tester, + ) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton( + key: key1, + onPressed: () {}, + child: const Text('Looooooooooooooong button 1'), + ), + ElevatedButton( + key: key2, + onPressed: () {}, + child: const Text('Looooooooooooooong button 2'), + ), + ], + actionsOverflowButtonSpacing: 10.0, + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Rect buttonOneRect = tester.getRect(find.byKey(key1)); + final Rect buttonTwoRect = tester.getRect(find.byKey(key2)); + expect(buttonOneRect.bottom, buttonTwoRect.top - 10.0); + }); + + testWidgets('Dialogs can set the alignment of the OverflowBar', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + + final dialog = AlertDialog( + title: const Text('title'), + content: const Text('content'), + actions: <Widget>[ + ElevatedButton(key: key1, onPressed: () {}, child: const Text('Loooooooooong button 1')), + ElevatedButton( + key: key2, + onPressed: () {}, + child: const Text('Loooooooooooooonger button 2'), + ), + ], + actionsOverflowAlignment: OverflowBarAlignment.center, + ); + + await tester.pumpWidget(_buildAppWithDialog(dialog)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Rect buttonOneRect = tester.getRect(find.byKey(key1)); + final Rect buttonTwoRect = tester.getRect(find.byKey(key2)); + expect(buttonOneRect.center.dx, buttonTwoRect.center.dx); + }); + + testWidgets('Dialogs removes MediaQuery padding and view insets', (WidgetTester tester) async { + late BuildContext outerContext; + late BuildContext routeContext; + late BuildContext dialogContext; + + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.all(50.0), + viewInsets: EdgeInsets.only(left: 25.0, bottom: 75.0), + ), + child: Navigator( + onGenerateRoute: (_) { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + outerContext = context; + return Container(); + }, + ); + }, + ), + ), + ), + ); + + showDialog<void>( + context: outerContext, + barrierDismissible: false, + builder: (BuildContext context) { + routeContext = context; + return Dialog( + child: Builder( + builder: (BuildContext context) { + dialogContext = context; + return const Placeholder(); + }, + ), + ); + }, + ); + + await tester.pump(); + + expect(MediaQuery.of(outerContext).padding, const EdgeInsets.all(50.0)); + expect(MediaQuery.of(routeContext).padding, EdgeInsets.zero); + expect(MediaQuery.of(dialogContext).padding, EdgeInsets.zero); + expect(MediaQuery.of(outerContext).viewInsets, const EdgeInsets.only(left: 25.0, bottom: 75.0)); + expect(MediaQuery.of(routeContext).viewInsets, const EdgeInsets.only(left: 25.0, bottom: 75.0)); + expect(MediaQuery.of(dialogContext).viewInsets, EdgeInsets.zero); + }); + + testWidgets('Dialog widget insets by viewInsets', (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.fromLTRB(10.0, 20.0, 30.0, 40.0)), + child: Dialog(child: Placeholder()), + ), + ); + expect( + tester.getRect(find.byType(Placeholder)), + const Rect.fromLTRB(10.0 + 40.0, 20.0 + 24.0, 800.0 - (40.0 + 30.0), 600.0 - (24.0 + 40.0)), + ); + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: Dialog(child: Placeholder()), + ), + ); + expect( + // no change because this is an animation + tester.getRect(find.byType(Placeholder)), + const Rect.fromLTRB(10.0 + 40.0, 20.0 + 24.0, 800.0 - (40.0 + 30.0), 600.0 - (24.0 + 40.0)), + ); + await tester.pump(const Duration(seconds: 1)); + expect( + // animation finished + tester.getRect(find.byType(Placeholder)), + const Rect.fromLTRB(40.0, 24.0, 800.0 - 40.0, 600.0 - 24.0), + ); + }); + + testWidgets('Dialog insetPadding added to outside of dialog', (WidgetTester tester) async { + // The default testing screen (800, 600) + const screenRect = Rect.fromLTRB(0.0, 0.0, 800.0, 600.0); + + // Test with no padding. + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: Dialog(insetPadding: EdgeInsets.zero, child: Placeholder()), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(Placeholder)), screenRect); + + // Test with an insetPadding. + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: Dialog( + insetPadding: EdgeInsets.fromLTRB(10.0, 20.0, 30.0, 40.0), + child: Placeholder(), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.getRect(find.byType(Placeholder)), + Rect.fromLTRB( + screenRect.left + 10.0, + screenRect.top + 20.0, + screenRect.right - 30.0, + screenRect.bottom - 40.0, + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/78229. + testWidgets('AlertDialog has correct semantics for content in iOS', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const localizations = DefaultMaterialLocalizations(); + + // With the change to defaultTargetPlatform + // (see https://github.com/flutter/flutter/issues/176566), + // the actual platform (not theme.platform) determines the semantics. + // By default: + // On iOS/macOS, no "Alert" label should be added in title. + // On other platforms, an "Alert" label is added. + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const AlertDialog( + title: Text('title'), + content: Column(children: <Widget>[Text('some content'), Text('more content')]), + actions: <Widget>[TextButton(onPressed: null, child: Text('action'))], + ), + ), + ); + + final bool isIOSorMacOS = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + role: SemanticsRole.alertDialog, + children: isIOSorMacOS + ? <TestSemantics>[ + TestSemantics( + id: 5, + label: 'title', + textDirection: TextDirection.ltr, + ), + // The content semantics does not merge into the semantics + // node 4. + TestSemantics( + id: 6, + children: <TestSemantics>[ + TestSemantics( + id: 7, + label: 'some content', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 8, + label: 'more content', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + ], + label: 'action', + textDirection: TextDirection.ltr, + ), + ] + : <TestSemantics>[ + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + label: localizations.alertDialogLabel, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 6, + label: 'title', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics( + id: 8, + label: 'some content', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 9, + label: 'more content', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + id: 10, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + ], + label: 'action', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('AlertDialog widget always contains alert route semantics for android', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const AlertDialog( + title: Text('Title'), + content: Text('Y'), + actions: <Widget>[], + ); + }, + ); + }, + ), + ); + }, + ), + ), + ), + ); + + expect( + semantics, + isNot(includesNodeWith(label: 'Title', flags: <SemanticsFlag>[SemanticsFlag.namesRoute])), + ); + expect( + semantics, + isNot( + includesNodeWith( + label: 'Alert', + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + // It does not use 'Title' as route semantics + expect( + semantics, + isNot(includesNodeWith(label: 'Title', flags: <SemanticsFlag>[SemanticsFlag.namesRoute])), + ); + expect( + semantics, + includesNodeWith( + label: 'Alert', + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ); + + semantics.dispose(); + }); + + testWidgets('SimpleDialog does not introduce additional node', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const SimpleDialog(title: Text('Title'), semanticLabel: 'label'); + }, + ); + }, + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + // A scope route is not focusable in accessibility service. + expect( + semantics, + includesNodeWith( + label: 'label', + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ); + + semantics.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/78229. + testWidgets('SimpleDialog has correct semantics for title in iOS', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const localizations = DefaultMaterialLocalizations(); + + // With the change to defaultTargetPlatform + // (see https://github.com/flutter/flutter/issues/176566), + // the actual platform (not theme.platform) determines the semantics. + // By default: + // On iOS/macOS, no "Alert" label should be added in title. + // On other platforms, an "Alert" label is added. + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const SimpleDialog( + title: Text('title'), + children: <Widget>[ + Text('content'), + TextButton(onPressed: null, child: Text('action')), + ], + ), + ), + ); + + final bool isIOSorMacOS = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + role: SemanticsRole.dialog, + children: isIOSorMacOS + ? <TestSemantics>[ + // Title semantics does not merge into the semantics + // node 4. + TestSemantics( + id: 5, + label: 'title', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 6, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + id: 7, + label: 'content', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 8, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + ], + label: 'action', + textDirection: TextDirection.ltr, + ), + ], + ), + ] + : <TestSemantics>[ + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.scopesRoute, + SemanticsFlag.namesRoute, + ], + label: localizations.dialogLabel, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 6, + label: 'title', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 7, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + id: 8, + label: 'content', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + ], + label: 'action', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Dismissible.confirmDismiss defers to an AlertDialog', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final dismissedItems = <int>[]; + + // Dismiss is confirmed IFF confirmDismiss() returns true. + Future<bool?> confirmDismiss(DismissDirection dismissDirection) async { + return showDialog<bool>( + context: scaffoldKey.currentContext!, + builder: (BuildContext context) { + return AlertDialog( + actions: <Widget>[ + TextButton( + child: const Text('TRUE'), + onPressed: () { + Navigator.pop(context, true); // showDialog() returns true + }, + ), + TextButton( + child: const Text('FALSE'), + onPressed: () { + Navigator.pop(context, false); // showDialog() returns false + }, + ), + ], + ); + }, + ); + } + + Widget buildDismissibleItem(int item, StateSetter setState) { + return Dismissible( + key: ValueKey<int>(item), + confirmDismiss: confirmDismiss, + onDismissed: (DismissDirection direction) { + setState(() { + expect(dismissedItems.contains(item), isFalse); + dismissedItems.add(item); + }); + }, + child: SizedBox(height: 100.0, child: Text(item.toString())), + ); + } + + Widget buildFrame() { + return MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + key: scaffoldKey, + body: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + itemExtent: 100.0, + children: <int>[0, 1, 2, 3, 4] + .where((int i) => !dismissedItems.contains(i)) + .map<Widget>((int item) => buildDismissibleItem(item, setState)) + .toList(), + ), + ), + ); + }, + ), + ); + } + + Future<void> dismissItem(WidgetTester tester, int item) async { + await tester.fling( + find.text(item.toString()), + const Offset(300.0, 0.0), + 1000.0, + ); // fling to the right + await tester.pump(); // start the slide + await tester.pump(const Duration(seconds: 1)); // finish the slide and start shrinking... + await tester.pump(); // first frame of shrinking animation + await tester.pump( + const Duration(seconds: 1), + ); // finish the shrinking and call the callback... + await tester.pump(); // rebuild after the callback removes the entry + } + + // Dismiss item 0 is confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, isEmpty); + await dismissItem(tester, 0); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + await tester.tap(find.text('TRUE')); // AlertDialog action + await tester.pumpAndSettle(); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + expect(dismissedItems, <int>[0]); + expect(find.text('0'), findsNothing); + + // Dismiss item 1 is not confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, <int>[0]); + await dismissItem(tester, 1); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + await tester.tap(find.text('FALSE')); // AlertDialog action + await tester.pumpAndSettle(); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + expect(dismissedItems, <int>[0]); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + + // Dismiss item 1 is not confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, <int>[0]); + await dismissItem(tester, 1); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + expect(find.text('FALSE'), findsOneWidget); + expect(find.text('TRUE'), findsOneWidget); + await tester.tapAt(Offset.zero); // Tap outside of the AlertDialog + await tester.pumpAndSettle(); + expect(dismissedItems, <int>[0]); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + + // Dismiss item 1 is confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, <int>[0]); + await dismissItem(tester, 1); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + await tester.tap(find.text('TRUE')); // AlertDialog action + await tester.pumpAndSettle(); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + expect(dismissedItems, <int>[0, 1]); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/28505. + testWidgets('showDialog only gets Theme from context on the first call', ( + WidgetTester tester, + ) async { + Widget buildFrame(Key builderKey) { + return MaterialApp( + home: Center( + child: Builder( + key: builderKey, + builder: (BuildContext outerContext) { + return ElevatedButton( + onPressed: () { + showDialog<void>( + context: outerContext, + builder: (BuildContext innerContext) { + return const AlertDialog(title: Text('Title')); + }, + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(UniqueKey())); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + // Force the Builder to be recreated (new key) which causes outerContext to + // be deactivated. If showDialog()'s implementation were to refer to + // outerContext again, it would crash. + await tester.pumpWidget(buildFrame(UniqueKey())); + await tester.pump(); + }); + + testWidgets('showDialog safe area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Set up the safe area to be 20 pixels in from each side + data: const MediaQueryData(padding: EdgeInsets.all(20.0)), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + // By default it should honor the safe area + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(20.0, 20.0)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(780.0, 580.0)); + + // Dismiss it and test with useSafeArea off + await tester.tapAt(const Offset(10.0, 10.0)); + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + useSafeArea: false, + ); + await tester.pumpAndSettle(); + // Should take up the whole screen + expect(tester.getTopLeft(find.byType(Placeholder)), Offset.zero); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('showDialog uses root navigator by default', (WidgetTester tester) async { + final rootObserver = DialogObserver(); + final nestedObserver = DialogObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext innerContext) { + return const AlertDialog(title: Text('Title')); + }, + ); + }, + child: const Text('Show Dialog'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.dialogCount, 1); + expect(nestedObserver.dialogCount, 0); + }); + + testWidgets('showDialog uses nested navigator if useRootNavigator is false', ( + WidgetTester tester, + ) async { + final rootObserver = DialogObserver(); + final nestedObserver = DialogObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog<void>( + context: context, + useRootNavigator: false, + builder: (BuildContext innerContext) { + return const AlertDialog(title: Text('Title')); + }, + ); + }, + child: const Text('Show Dialog'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.dialogCount, 0); + expect(nestedObserver.dialogCount, 1); + }); + + testWidgets('showDialog throws a friendly user message when context is not active', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/12467 + await tester.pumpWidget(const MaterialApp(home: Center(child: Text('Test')))); + final BuildContext context = tester.element(find.text('Test')); + + await tester.pumpWidget(const MaterialApp(home: Center())); + + Object? error; + try { + showDialog<void>( + context: context, + builder: (BuildContext innerContext) { + return const AlertDialog(title: Text('Title')); + }, + ); + } catch (exception) { + error = exception; + } + + expect(error, isNotNull); + expect(error, isFlutterError); + if (error is FlutterError) { + final summary = error.diagnostics.first as ErrorSummary; + expect(summary.toString(), 'This BuildContext is no longer valid.'); + } + }); + + group('showDialog avoids overlapping display features', () { + testWidgets('positioning with anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + anchorPoint: const Offset(1000, 0), + ); + await tester.pumpAndSettle(); + + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning with Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // Since this is RTL, it should place the dialog on the right screen + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.0)); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); + }); + + testWidgets('positioning by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Placeholder(); + }, + ); + await tester.pumpAndSettle(); + + // By default it should place the dialog on the left screen + expect(tester.getTopLeft(find.byType(Placeholder)), Offset.zero); + expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(390.0, 600.0)); + }); + }); + + group('AlertDialog.scrollable: ', () { + testWidgets('Title is scrollable', (WidgetTester tester) async { + final Key titleKey = UniqueKey(); + final dialog = AlertDialog( + title: Container(key: titleKey, color: Colors.green, height: 1000), + scrollable: true, + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderBox box = tester.renderObject(find.byKey(titleKey)); + final Offset originalOffset = box.localToGlobal(Offset.zero); + await tester.drag(find.byKey(titleKey), const Offset(0.0, -200.0)); + expect(box.localToGlobal(Offset.zero), equals(originalOffset.translate(0.0, -200.0))); + }); + + testWidgets('Content is scrollable', (WidgetTester tester) async { + final Key contentKey = UniqueKey(); + final dialog = AlertDialog( + content: Container(key: contentKey, color: Colors.orange, height: 1000), + scrollable: true, + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderBox box = tester.renderObject(find.byKey(contentKey)); + final Offset originalOffset = box.localToGlobal(Offset.zero); + await tester.drag(find.byKey(contentKey), const Offset(0.0, -200.0)); + expect(box.localToGlobal(Offset.zero), equals(originalOffset.translate(0.0, -200.0))); + }); + + testWidgets('Title and content are scrollable', (WidgetTester tester) async { + final Key titleKey = UniqueKey(); + final Key contentKey = UniqueKey(); + final dialog = AlertDialog( + title: Container(key: titleKey, color: Colors.green, height: 400), + content: Container(key: contentKey, color: Colors.orange, height: 400), + scrollable: true, + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderBox title = tester.renderObject(find.byKey(titleKey)); + final RenderBox content = tester.renderObject(find.byKey(contentKey)); + final Offset titleOriginalOffset = title.localToGlobal(Offset.zero); + final Offset contentOriginalOffset = content.localToGlobal(Offset.zero); + + // Dragging the title widget should scroll both the title + // and the content widgets. + await tester.drag(find.byKey(titleKey), const Offset(0.0, -200.0)); + expect(title.localToGlobal(Offset.zero), equals(titleOriginalOffset.translate(0.0, -200.0))); + expect( + content.localToGlobal(Offset.zero), + equals(contentOriginalOffset.translate(0.0, -200.0)), + ); + + // Dragging the content widget should scroll both the title + // and the content widgets. + await tester.drag(find.byKey(contentKey), const Offset(0.0, 200.0)); + expect(title.localToGlobal(Offset.zero), equals(titleOriginalOffset)); + expect(content.localToGlobal(Offset.zero), equals(contentOriginalOffset)); + }); + }); + + testWidgets('Dialog with RouteSettings', (WidgetTester tester) async { + late RouteSettings currentRouteSetting; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[ + _ClosureNavigatorObserver( + onDidChange: (Route<dynamic> newRoute) { + currentRouteSetting = newRoute.settings; + }, + ), + ], + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + const exampleSetting = RouteSettings(name: 'simple'); + + final Future<int?> result = showDialog<int>( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: const Text('Title'), + children: <Widget>[ + SimpleDialogOption( + child: const Text('X'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + routeSettings: exampleSetting, + ); + + await tester.pumpAndSettle(); + expect(find.text('Title'), findsOneWidget); + expect(currentRouteSetting, exampleSetting); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(await result, isNull); + await tester.pumpAndSettle(); + expect(currentRouteSetting.name, '/'); + }); + + testWidgets('showDialog - custom barrierLabel', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog<void>( + context: context, + barrierLabel: 'Custom label', + builder: (BuildContext context) { + return const AlertDialog( + title: Text('Title'), + content: Text('Y'), + actions: <Widget>[], + ); + }, + ); + }, + ), + ); + }, + ), + ), + ), + ); + + expect( + semantics, + isNot( + includesNodeWith(label: 'Custom label', flags: <SemanticsFlag>[SemanticsFlag.namesRoute]), + ), + ); + semantics.dispose(); + }); + + testWidgets('DialogRoute is state restorable', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(restorationScopeId: 'app', home: _RestorableDialogTestWidget()), + ); + + expect(find.byType(AlertDialog), findsNothing); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + final TestRestorationData restorationData = await tester.getRestorationData(); + + await tester.restartAndRestore(); + + expect(find.byType(AlertDialog), findsOneWidget); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + + await tester.restoreFrom(restorationData); + expect(find.byType(AlertDialog), findsOneWidget); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 + + testWidgets('AlertDialog.actionsAlignment', (WidgetTester tester) async { + final Key actionKey = UniqueKey(); + + Widget buildFrame(MainAxisAlignment? alignment) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: AlertDialog( + content: const SizedBox(width: 800), + actionsAlignment: alignment, + actions: <Widget>[SizedBox(key: actionKey, width: 20, height: 20)], + buttonPadding: EdgeInsets.zero, + insetPadding: EdgeInsets.zero, + ), + ), + ); + } + + // Default configuration + await tester.pumpWidget(buildFrame(null)); + expect(tester.getTopLeft(find.byType(AlertDialog)).dx, 0); + expect(tester.getTopRight(find.byType(AlertDialog)).dx, 800); + expect(tester.getSize(find.byType(OverflowBar)).width, 800); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, 800 - 20); + expect(tester.getTopRight(find.byKey(actionKey)).dx, 800); + + // All possible alignment values + + await tester.pumpWidget(buildFrame(MainAxisAlignment.start)); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, 0); + expect(tester.getTopRight(find.byKey(actionKey)).dx, 20); + + await tester.pumpWidget(buildFrame(MainAxisAlignment.center)); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, (800 - 20) / 2); + expect(tester.getTopRight(find.byKey(actionKey)).dx, (800 - 20) / 2 + 20); + + await tester.pumpWidget(buildFrame(MainAxisAlignment.end)); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, 800 - 20); + expect(tester.getTopRight(find.byKey(actionKey)).dx, 800); + + await tester.pumpWidget(buildFrame(MainAxisAlignment.spaceBetween)); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, 0); + expect(tester.getTopRight(find.byKey(actionKey)).dx, 20); + + await tester.pumpWidget(buildFrame(MainAxisAlignment.spaceAround)); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, (800 - 20) / 2); + expect(tester.getTopRight(find.byKey(actionKey)).dx, (800 - 20) / 2 + 20); + + await tester.pumpWidget(buildFrame(MainAxisAlignment.spaceEvenly)); + expect(tester.getTopLeft(find.byKey(actionKey)).dx, (800 - 20) / 2); + expect(tester.getTopRight(find.byKey(actionKey)).dx, (800 - 20) / 2 + 20); + }); + + testWidgets('Uses closed loop focus traversal', (WidgetTester tester) async { + final okNode = FocusNode(); + final cancelNode = FocusNode(); + + Future<bool> nextFocus() async { + final result = Actions.invoke(primaryFocus!.context!, const NextFocusIntent())! as bool; + await tester.pump(); + return result; + } + + Future<bool> previousFocus() async { + final result = Actions.invoke(primaryFocus!.context!, const PreviousFocusIntent())! as bool; + await tester.pump(); + return result; + } + + final dialog = AlertDialog( + content: const Text('Test dialog'), + actions: <Widget>[ + TextButton(focusNode: okNode, onPressed: () {}, child: const Text('OK')), + TextButton(focusNode: cancelNode, onPressed: () {}, child: const Text('Cancel')), + ], + ); + await tester.pumpWidget(_buildAppWithDialog(dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Start at OK + okNode.requestFocus(); + await tester.pump(); + expect(okNode.hasFocus, true); + expect(cancelNode.hasFocus, false); + + // OK -> Cancel + expect(await nextFocus(), true); + expect(okNode.hasFocus, false); + expect(cancelNode.hasFocus, true); + + // Cancel -> OK + expect(await nextFocus(), true); + expect(okNode.hasFocus, true); + expect(cancelNode.hasFocus, false); + + // Cancel <- OK + expect(await previousFocus(), true); + expect(okNode.hasFocus, false); + expect(cancelNode.hasFocus, true); + + // OK <- Cancel + expect(await previousFocus(), true); + expect(okNode.hasFocus, true); + expect(cancelNode.hasFocus, false); + + cancelNode.dispose(); + okNode.dispose(); + }); + + testWidgets('Adaptive AlertDialog shows correct widget on each platform', ( + WidgetTester tester, + ) async { + final dialog = AlertDialog.adaptive( + content: Container(height: 5000.0, width: 300.0, color: Colors.green[500]), + actions: <Widget>[TextButton(onPressed: () {}, child: const Text('OK'))], + ); + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: ThemeData(platform: platform))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAlertDialog), findsOneWidget); + + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(_buildAppWithDialog(dialog, theme: ThemeData(platform: platform))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoAlertDialog), findsNothing); + + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + } + }); + + testWidgets('showAdaptiveDialog should not allow dismiss on barrier on iOS by default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + + showDialog<void>( + context: context, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog1'), + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog1'), findsOneWidget); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog1'), findsNothing); + + showAdaptiveDialog<void>( + context: context, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog2'), + ); + }, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog2'), findsOneWidget); + + // Tap on the barrier, which shouldn't do anything this time. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog2'), findsOneWidget); + }); + + testWidgets('Applies AnimationStyle to showAdaptiveDialog', (WidgetTester tester) async { + const animationStyle = AnimationStyle(duration: Duration(seconds: 1), curve: Curves.easeInOut); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + final BuildContext context = tester.element(find.text('Go')); + showAdaptiveDialog<void>( + context: context, + builder: (BuildContext context) { + return Container( + width: 100.0, + height: 100.0, + alignment: Alignment.center, + child: const Text('Dialog1'), + ); + }, + animationStyle: animationStyle, + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog1'), findsOneWidget); + + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Dialog1'), findsNothing); + }); + + testWidgets('Uses open focus traversal when overridden', (WidgetTester tester) async { + final okNode = FocusNode(); + addTearDown(okNode.dispose); + final cancelNode = FocusNode(); + addTearDown(cancelNode.dispose); + + Future<bool> nextFocus() async { + final result = Actions.invoke(primaryFocus!.context!, const NextFocusIntent())! as bool; + await tester.pump(); + return result; + } + + final dialog = AlertDialog( + content: const Text('Test dialog'), + actions: <Widget>[ + TextButton(focusNode: okNode, onPressed: () {}, child: const Text('OK')), + TextButton(focusNode: cancelNode, onPressed: () {}, child: const Text('Cancel')), + ], + ); + await tester.pumpWidget( + _buildAppWithDialog(dialog, traversalEdgeBehavior: TraversalEdgeBehavior.leaveFlutterView), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Start at OK + okNode.requestFocus(); + await tester.pump(); + expect(okNode.hasFocus, true); + expect(cancelNode.hasFocus, false); + + // OK -> Cancel + expect(await nextFocus(), true); + expect(okNode.hasFocus, false); + expect(cancelNode.hasFocus, true); + + // Cancel -> nothing + expect(await nextFocus(), false); + expect(okNode.hasFocus, false); + expect(cancelNode.hasFocus, false); + }); + + testWidgets('Dialog.insetPadding is nullable', (WidgetTester tester) async { + const dialog = Dialog(); + expect(dialog.insetPadding, isNull); + }); + + testWidgets('AlertDialog.insetPadding is nullable', (WidgetTester tester) async { + const alertDialog = AlertDialog(); + expect(alertDialog.insetPadding, isNull); + }); + + testWidgets('SimpleDialog.insetPadding is nullable', (WidgetTester tester) async { + const simpleDialog = SimpleDialog(); + expect(simpleDialog.insetPadding, isNull); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153983. + testWidgets('Can pass a null value to AlertDialog.adaptive clip behavior', ( + WidgetTester tester, + ) async { + for (final clipBehavior in <Clip?>[null, ...Clip.values]) { + AlertDialog.adaptive(clipBehavior: clipBehavior); + } + }); + + testWidgets('Setting DialogRoute.requestFocus to false does not request focus on the dialog', ( + WidgetTester tester, + ) async { + late BuildContext savedContext; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const dialogText = 'Dialog Text'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + savedContext = context; + return TextField(focusNode: focusNode); + }, + ), + ), + ), + ); + await tester.pump(); + + FocusNode? getTextFieldFocusNode() { + return tester + .widget<Focus>(find.descendant(of: find.byType(TextField), matching: find.byType(Focus))) + .focusNode; + } + + // Initially, there is no dialog and the text field has no focus. + expect(find.text(dialogText), findsNothing); + expect(getTextFieldFocusNode()?.hasFocus, false); + + // Request focus on the text field. + focusNode.requestFocus(); + await tester.pump(); + expect(getTextFieldFocusNode()?.hasFocus, true); + + // Bring up dialog. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + DialogRoute<void>( + context: savedContext, + builder: (BuildContext context) => const Text(dialogText), + ), + ); + await tester.pump(); + + // The dialog is showing and the text field has lost focus. + expect(find.text(dialogText), findsOneWidget); + expect(getTextFieldFocusNode()?.hasFocus, false); + + // Dismiss the dialog. + navigator.pop(); + await tester.pump(); + + // The dialog is dismissed and the focus is shifted back to the text field. + expect(find.text(dialogText), findsNothing); + expect(getTextFieldFocusNode()?.hasFocus, true); + + // Bring up dialog again with requestFocus to false. + navigator.push( + ModalBottomSheetRoute<void>( + requestFocus: false, + isScrollControlled: false, + builder: (BuildContext context) => const Text(dialogText), + ), + ); + await tester.pump(); + + // The dialog is showing and the text field still has focus. + expect(find.text(dialogText), findsOneWidget); + expect(getTextFieldFocusNode()?.hasFocus, true); + }); + + testWidgets('requestFocus works correctly in showDialog.', (WidgetTester tester) async { + final navigatorKey = GlobalKey<NavigatorState>(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: Scaffold(body: TextField(focusNode: focusNode)), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + + showDialog<void>( + context: navigatorKey.currentContext!, + requestFocus: true, + builder: (BuildContext context) => const Text('dialog'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('dialog'))).hasFocus, true); + expect(focusNode.hasFocus, false); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + + showDialog<void>( + context: navigatorKey.currentContext!, + requestFocus: false, + builder: (BuildContext context) => const Text('dialog'), + ); + await tester.pumpAndSettle(); + expect(FocusScope.of(tester.element(find.text('dialog'))).hasFocus, false); + expect(focusNode.hasFocus, true); + }); + + testWidgets('Dialog respects the given constraints', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppWithDialog( + const Dialog( + constraints: BoxConstraints(maxWidth: 560), + child: SizedBox(width: 1000, height: 100), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(SizedBox)).width, 560); + }); + + testWidgets('AlertDialog respects the default constraints', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppWithDialog(const AlertDialog(content: SizedBox(), contentPadding: EdgeInsets.zero)), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(SizedBox)).width, 280); + }); + + testWidgets('AlertDialog respects the given constraints', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppWithDialog( + const AlertDialog( + constraints: BoxConstraints(maxWidth: 560), + content: SizedBox(width: 1000, height: 100), + contentPadding: EdgeInsets.zero, + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(SizedBox)).width, 560); + }); + + testWidgets('SimpleDialog respects the default constraints', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppWithDialog( + const SimpleDialog(contentPadding: EdgeInsets.zero, children: <Widget>[SizedBox()]), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(SizedBox)).width, 280); + }); + + testWidgets('SimpleDialog respects the given constraints', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppWithDialog( + const SimpleDialog( + constraints: BoxConstraints(maxWidth: 560), + contentPadding: EdgeInsets.zero, + children: <Widget>[SizedBox(width: 1000, height: 100)], + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(SizedBox)).width, 560); + }); + + testWidgets('test no back gesture on fullscreen dialogs', (WidgetTester tester) async { + // no back button in app bar for RawDialogRoute with full screen dialog set to true + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return TextButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).push( + RawDialogRoute<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return Scaffold( + appBar: AppBar(title: const Text('title')), + body: const Text('body'), + ); + }, + fullscreenDialog: true, + ), + ); + }, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(find.byType(BackButton), findsNothing); + expect(find.byType(CloseButton), findsOneWidget); + }); + + testWidgets('SimpleDialog does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: SimpleDialog(title: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(SimpleDialog)), Size.zero); + }); + + testWidgets('SimpleDialogOption does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SizedBox.shrink(child: SimpleDialogOption(child: Text('X'))), + ), + ), + ), + ); + expect(tester.getSize(find.byType(SimpleDialogOption)), Size.zero); + }); + + testWidgets('Dialog does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Dialog(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(Dialog)).isEmpty, isTrue); + }); + + testWidgets('AlertDialog does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: AlertDialog(content: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(AlertDialog)).isEmpty, isTrue); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177001 + group('Dialog semantics for mismatched platforms', () { + Future<void> pumpDialogWithTheme({ + required WidgetTester tester, + required Widget dialog, + required TargetPlatform themePlatform, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: themePlatform), + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog<void>(context: context, builder: (BuildContext context) => dialog); + }, + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + } + + testWidgets('AlertDialog', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const localizations = DefaultMaterialLocalizations(); + + final bool isIOSorMacOS = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + + // Test with theme.platform = Android on different real platforms. + await pumpDialogWithTheme( + tester: tester, + themePlatform: TargetPlatform.android, + dialog: const AlertDialog(title: Text('Title'), content: Text('Y'), actions: <Widget>[]), + ); + + // Dismiss the first dialog. + Navigator.of(tester.element(find.text('Title'))).pop(); + await tester.pumpAndSettle(); + + // Test with theme.platform = iOS on different real platforms. + await pumpDialogWithTheme( + tester: tester, + themePlatform: TargetPlatform.iOS, + dialog: const AlertDialog(title: Text('Title'), content: Text('Y'), actions: <Widget>[]), + ); + + if (isIOSorMacOS) { + // On iOS/macOS, semantic label should be omitted and namesRoute flag should not exist. + expect( + semantics, + isNot( + includesNodeWith( + label: localizations.alertDialogLabel, + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ), + ); + } else { + // On other platforms, semantic label should be added and namesRoute should exist. + expect( + semantics, + includesNodeWith( + label: localizations.alertDialogLabel, + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ); + } + + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('SimpleDialog', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const localizations = DefaultMaterialLocalizations(); + + final bool isIOSorMacOS = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + + // Test with theme.platform = Android on different real platforms. + await pumpDialogWithTheme( + tester: tester, + themePlatform: TargetPlatform.android, + dialog: const SimpleDialog( + title: Text('Title'), + children: <Widget>[ + Text('content'), + TextButton(onPressed: null, child: Text('action')), + ], + ), + ); + + // Dismiss the first dialog. + Navigator.of(tester.element(find.text('Title'))).pop(); + await tester.pumpAndSettle(); + + // Test with theme.platform = iOS on different real platforms. + await pumpDialogWithTheme( + tester: tester, + themePlatform: TargetPlatform.iOS, + dialog: const SimpleDialog( + title: Text('Title'), + children: <Widget>[ + Text('content'), + TextButton(onPressed: null, child: Text('action')), + ], + ), + ); + + if (isIOSorMacOS) { + // On iOS/macOS, semantic label should be omitted and namesRoute flag should not exist. + expect( + semantics, + isNot( + includesNodeWith( + label: localizations.dialogLabel, + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ), + ); + } else { + // On other platforms, semantic label should be added and namesRoute should exist. + expect( + semantics, + includesNodeWith( + label: localizations.dialogLabel, + flags: <SemanticsFlag>[SemanticsFlag.namesRoute, SemanticsFlag.scopesRoute], + ), + ); + } + + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + }); + + testWidgets('Dialog has hitTestBehavior.opaque to prevent dismissal on empty areas', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) => + const Dialog(child: SizedBox(width: 200, height: 200)), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + + final Semantics routeSemantics = tester.widget<Semantics>( + find.ancestor(of: find.byType(Dialog), matching: find.byType(Semantics)).first, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + semantics.dispose(); + }); + + testWidgets('AlertDialog has hitTestBehavior.opaque via Dialog', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Test Dialog'), + content: const SizedBox(width: 200, height: 100), + actions: <Widget>[ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(Dialog), findsOneWidget); + + // Find the route-level Semantics with hitTestBehavior.opaque + // (wraps the entire dialog content, above the Dialog widget) + final Semantics routeSemantics = tester.widget<Semantics>( + find.ancestor(of: find.byType(Dialog), matching: find.byType(Semantics)).first, + ); + + expect(routeSemantics.properties.hitTestBehavior, SemanticsHitTestBehavior.opaque); + + semantics.dispose(); + }); +} + +@pragma('vm:entry-point') +class _RestorableDialogTestWidget extends StatelessWidget { + const _RestorableDialogTestWidget(); + + @pragma('vm:entry-point') + static Route<Object?> _materialDialogBuilder(BuildContext context, Object? arguments) { + return DialogRoute<void>( + context: context, + builder: (BuildContext context) => const AlertDialog(title: Text('Material Alert!')), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: OutlinedButton( + onPressed: () { + Navigator.of(context).restorablePush(_materialDialogBuilder); + }, + child: const Text('X'), + ), + ), + ); + } +} + +class DialogObserver extends NavigatorObserver { + int dialogCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + dialogCount++; + } + super.didPush(route, previousRoute); + } +} + +class _ClosureNavigatorObserver extends NavigatorObserver { + _ClosureNavigatorObserver({required this.onDidChange}); + + final void Function(Route<dynamic> newRoute) onDidChange; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => onDidChange(route); + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => onDidChange(previousRoute!); + + @override + void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => + onDidChange(previousRoute!); + + @override + void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => onDidChange(newRoute!); +} diff --git a/packages/material_ui/test/material/dialog_theme_test.dart b/packages/material_ui/test/material/dialog_theme_test.dart new file mode 100644 index 000000000000..1f4f624e289c --- /dev/null +++ b/packages/material_ui/test/material/dialog_theme_test.dart @@ -0,0 +1,844 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +MaterialApp _appWithDialog( + WidgetTester tester, + Widget dialog, { + ThemeData? theme, + DialogThemeData? dialogTheme, +}) { + Widget dialogBuilder = Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) { + return RepaintBoundary(key: _painterKey, child: dialog); + }, + ); + }, + ), + ); + }, + ); + + if (dialogTheme != null) { + dialogBuilder = DialogTheme(data: dialogTheme, child: dialogBuilder); + } + + return MaterialApp( + theme: theme, + home: Material(child: dialogBuilder), + ); +} + +final Key _painterKey = UniqueKey(); + +Material _getMaterialAlertDialog(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(AlertDialog), matching: find.byType(Material)), + ); +} + +Material _getMaterialDialog(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); +} + +RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { + return tester.element<StatelessElement>(find.text(text)).renderObject! as RenderParagraph; +} + +RenderParagraph _getIconRenderObject(WidgetTester tester, IconData icon) { + return tester.renderObject<RenderParagraph>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); +} + +void main() { + test('DialogThemeData copyWith, ==, hashCode basics', () { + expect(const DialogThemeData(), const DialogThemeData().copyWith()); + expect(const DialogThemeData().hashCode, const DialogThemeData().copyWith().hashCode); + }); + + test('DialogThemeData lerp special cases', () { + expect(DialogThemeData.lerp(null, null, 0), const DialogThemeData()); + const theme = DialogThemeData(); + expect(identical(DialogThemeData.lerp(theme, theme, 0.5), theme), true); + }); + + test('DialogThemeData defaults', () { + const dialogThemeData = DialogThemeData(); + + expect(dialogThemeData.backgroundColor, null); + expect(dialogThemeData.elevation, null); + expect(dialogThemeData.shadowColor, null); + expect(dialogThemeData.surfaceTintColor, null); + expect(dialogThemeData.shape, null); + expect(dialogThemeData.alignment, null); + expect(dialogThemeData.iconColor, null); + expect(dialogThemeData.titleTextStyle, null); + expect(dialogThemeData.contentTextStyle, null); + expect(dialogThemeData.actionsPadding, null); + expect(dialogThemeData.barrierColor, null); + expect(dialogThemeData.insetPadding, null); + expect(dialogThemeData.clipBehavior, null); + expect(dialogThemeData.constraints, null); + + const dialogTheme = DialogTheme(data: DialogThemeData(), child: SizedBox()); + expect(dialogTheme.backgroundColor, null); + expect(dialogTheme.elevation, null); + expect(dialogTheme.shadowColor, null); + expect(dialogTheme.surfaceTintColor, null); + expect(dialogTheme.shape, null); + expect(dialogTheme.alignment, null); + expect(dialogTheme.iconColor, null); + expect(dialogTheme.titleTextStyle, null); + expect(dialogTheme.contentTextStyle, null); + expect(dialogTheme.actionsPadding, null); + expect(dialogTheme.barrierColor, null); + expect(dialogTheme.insetPadding, null); + expect(dialogTheme.clipBehavior, null); + expect(dialogThemeData.constraints, null); + }); + + testWidgets('Default DialogThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DialogThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('DialogThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DialogThemeData( + backgroundColor: Color(0xff123456), + elevation: 8.0, + shadowColor: Color(0xff000001), + surfaceTintColor: Color(0xff000002), + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20.5))), + alignment: Alignment.bottomLeft, + iconColor: Color(0xff654321), + titleTextStyle: TextStyle(color: Color(0xffffffff)), + contentTextStyle: TextStyle(color: Color(0xff000000)), + actionsPadding: EdgeInsets.all(8.0), + barrierColor: Color(0xff000005), + insetPadding: EdgeInsets.all(20.0), + clipBehavior: Clip.antiAlias, + ).debugFillProperties(builder); + final List<String> description = builder.properties + .where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode n) => n.toString()) + .toList(); + expect(description, <String>[ + 'backgroundColor: ${const Color(0xff123456)}', + 'elevation: 8.0', + 'shadowColor: ${const Color(0xff000001)}', + 'surfaceTintColor: ${const Color(0xff000002)}', + 'shape: BeveledRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(20.5))', + 'alignment: Alignment.bottomLeft', + 'iconColor: ${const Color(0xff654321)}', + 'titleTextStyle: TextStyle(inherit: true, color: ${const Color(0xffffffff)})', + 'contentTextStyle: TextStyle(inherit: true, color: ${const Color(0xff000000)})', + 'actionsPadding: EdgeInsets.all(8.0)', + 'barrierColor: ${const Color(0xff000005)}', + 'insetPadding: EdgeInsets.all(20.0)', + 'clipBehavior: Clip.antiAlias', + ]); + }); + + testWidgets('Local DialogThemeData overrides dialog defaults', (WidgetTester tester) async { + const themeBackgroundColor = Color(0xff123456); + const themeElevation = 8.0; + const themeShadowColor = Color(0xff000001); + const themeSurfaceTintColor = Color(0xff000002); + const themeShape = BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20.5)), + ); + const AlignmentGeometry themeAlignment = Alignment.bottomLeft; + const themeIconColor = Color(0xff654321); + const themeTitleTextStyle = TextStyle(color: Color(0xffffffff)); + const themeContentTextStyle = TextStyle(color: Color(0xff000000)); + const EdgeInsetsGeometry themeActionsPadding = EdgeInsets.all(8.0); + const themeBarrierColor = Color(0xff000005); + const themeInsetPadding = EdgeInsets.all(30.0); + const Clip themeClipBehavior = Clip.antiAlias; + const dialog = AlertDialog( + title: Text('Title'), + content: Text('Content'), + icon: Icon(Icons.search), + actions: <Widget>[Icon(Icons.cancel)], + ); + + const dialogTheme = DialogThemeData( + backgroundColor: themeBackgroundColor, + elevation: themeElevation, + shadowColor: themeShadowColor, + surfaceTintColor: themeSurfaceTintColor, + shape: themeShape, + alignment: themeAlignment, + iconColor: themeIconColor, + titleTextStyle: themeTitleTextStyle, + contentTextStyle: themeContentTextStyle, + actionsPadding: themeActionsPadding, + barrierColor: themeBarrierColor, + insetPadding: themeInsetPadding, + clipBehavior: themeClipBehavior, + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, dialogTheme: dialogTheme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialAlertDialog(tester); + expect(materialWidget.color, themeBackgroundColor); + expect(materialWidget.elevation, themeElevation); + expect(materialWidget.shadowColor, themeShadowColor); + expect(materialWidget.surfaceTintColor, themeSurfaceTintColor); + expect(materialWidget.shape, themeShape); + expect(materialWidget.clipBehavior, Clip.antiAlias); + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dx, 30.0); // 30 is the padding value. + expect(bottomLeft.dy, 570.0); // 600 - 30 + expect(_getIconRenderObject(tester, Icons.search).text.style?.color, themeIconColor); + expect(_getTextRenderObject(tester, 'Title').text.style?.color, themeTitleTextStyle.color); + expect(_getTextRenderObject(tester, 'Content').text.style?.color, themeContentTextStyle.color); + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect(modalBarrier.color, themeBarrierColor); + + final Finder findPadding = find + .ancestor(of: find.byIcon(Icons.cancel), matching: find.byType(Padding)) + .first; + final Padding padding = tester.widget<Padding>(findPadding); + expect(padding.padding, themeActionsPadding); + }); + + testWidgets('Local DialogThemeData overrides global dialogTheme', (WidgetTester tester) async { + const themeBackgroundColor = Color(0xff123456); + const themeElevation = 8.0; + const themeShadowColor = Color(0xff000001); + const themeSurfaceTintColor = Color(0xff000002); + const themeShape = BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20.5)), + ); + const AlignmentGeometry themeAlignment = Alignment.bottomLeft; + const themeIconColor = Color(0xff654321); + const themeTitleTextStyle = TextStyle(color: Color(0xffffffff)); + const themeContentTextStyle = TextStyle(color: Color(0xff000000)); + const EdgeInsetsGeometry themeActionsPadding = EdgeInsets.all(8.0); + const themeBarrierColor = Color(0xff000005); + const themeInsetPadding = EdgeInsets.all(30.0); + const Clip themeClipBehavior = Clip.antiAlias; + + const globalBackgroundColor = Color(0xff654321); + const globalElevation = 7.0; + const globalShadowColor = Color(0xff200001); + const globalSurfaceTintColor = Color(0xff222002); + const globalShape = BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25.5)), + ); + const AlignmentGeometry globalAlignment = Alignment.centerRight; + const globalIconColor = Color(0xff666666); + const globalTitleTextStyle = TextStyle(color: Color(0xff000000)); + const globalContentTextStyle = TextStyle(color: Color(0xffdddddd)); + const EdgeInsetsGeometry globalActionsPadding = EdgeInsets.all(18.0); + const globalBarrierColor = Color(0xff111115); + const globalInsetPadding = EdgeInsets.all(35.0); + const Clip globalClipBehavior = Clip.hardEdge; + const dialog = AlertDialog( + title: Text('Title'), + content: Text('Content'), + icon: Icon(Icons.search), + actions: <Widget>[Icon(Icons.cancel)], + ); + + const dialogTheme = DialogThemeData( + backgroundColor: themeBackgroundColor, + elevation: themeElevation, + shadowColor: themeShadowColor, + surfaceTintColor: themeSurfaceTintColor, + shape: themeShape, + alignment: themeAlignment, + iconColor: themeIconColor, + titleTextStyle: themeTitleTextStyle, + contentTextStyle: themeContentTextStyle, + actionsPadding: themeActionsPadding, + barrierColor: themeBarrierColor, + insetPadding: themeInsetPadding, + clipBehavior: themeClipBehavior, + ); + + const globalDialogTheme = DialogThemeData( + backgroundColor: globalBackgroundColor, + elevation: globalElevation, + shadowColor: globalShadowColor, + surfaceTintColor: globalSurfaceTintColor, + shape: globalShape, + alignment: globalAlignment, + iconColor: globalIconColor, + titleTextStyle: globalTitleTextStyle, + contentTextStyle: globalContentTextStyle, + actionsPadding: globalActionsPadding, + barrierColor: globalBarrierColor, + insetPadding: globalInsetPadding, + clipBehavior: globalClipBehavior, + ); + + await tester.pumpWidget( + _appWithDialog( + tester, + dialog, + dialogTheme: dialogTheme, + theme: ThemeData(dialogTheme: globalDialogTheme), + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialAlertDialog(tester); + expect(materialWidget.color, themeBackgroundColor); + expect(materialWidget.elevation, themeElevation); + expect(materialWidget.shadowColor, themeShadowColor); + expect(materialWidget.surfaceTintColor, themeSurfaceTintColor); + expect(materialWidget.shape, themeShape); + expect(materialWidget.clipBehavior, Clip.antiAlias); + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dx, 30.0); // 30 is the padding value. + expect(bottomLeft.dy, 570.0); // 600 - 30 + expect(_getIconRenderObject(tester, Icons.search).text.style?.color, themeIconColor); + expect(_getTextRenderObject(tester, 'Title').text.style?.color, themeTitleTextStyle.color); + expect(_getTextRenderObject(tester, 'Content').text.style?.color, themeContentTextStyle.color); + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect(modalBarrier.color, themeBarrierColor); + + final Finder findPadding = find + .ancestor(of: find.byIcon(Icons.cancel), matching: find.byType(Padding)) + .first; + final Padding padding = tester.widget<Padding>(findPadding); + expect(padding.padding, themeActionsPadding); + }); + + testWidgets('Dialog background color', (WidgetTester tester) async { + const Color customColor = Colors.pink; + const dialog = AlertDialog(title: Text('Title'), actions: <Widget>[]); + final theme = ThemeData(dialogTheme: const DialogThemeData(backgroundColor: customColor)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialAlertDialog(tester); + expect(materialWidget.color, customColor); + }); + + testWidgets('Custom dialog elevation', (WidgetTester tester) async { + const customElevation = 12.0; + const shadowColor = Color(0xFF000001); + const surfaceTintColor = Color(0xFF000002); + const dialog = AlertDialog(title: Text('Title'), actions: <Widget>[]); + final theme = ThemeData( + dialogTheme: const DialogThemeData( + elevation: customElevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + ), + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialAlertDialog(tester); + expect(materialWidget.elevation, customElevation); + expect(materialWidget.shadowColor, shadowColor); + expect(materialWidget.surfaceTintColor, surfaceTintColor); + }); + + testWidgets('Custom dialog shape', (WidgetTester tester) async { + const customBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ); + const dialog = AlertDialog(title: Text('Title'), actions: <Widget>[]); + final theme = ThemeData(dialogTheme: const DialogThemeData(shape: customBorder)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialAlertDialog(tester); + expect(materialWidget.shape, customBorder); + }); + + testWidgets('Custom dialog alignment', (WidgetTester tester) async { + const dialog = AlertDialog(title: Text('Title'), actions: <Widget>[]); + final theme = ThemeData(dialogTheme: const DialogThemeData(alignment: Alignment.bottomLeft)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dx, 40.0); + expect(bottomLeft.dy, 576.0); + }); + + testWidgets('Material3 - Dialog alignment takes priority over theme', ( + WidgetTester tester, + ) async { + const dialog = AlertDialog( + title: Text('Title'), + actions: <Widget>[], + alignment: Alignment.topRight, + ); + final theme = ThemeData(dialogTheme: const DialogThemeData(alignment: Alignment.bottomLeft)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dx, 480.0); + expect(bottomLeft.dy, 124.0); + }); + + testWidgets('Material2 - Dialog alignment takes priority over theme', ( + WidgetTester tester, + ) async { + const dialog = AlertDialog( + title: Text('Title'), + actions: <Widget>[], + alignment: Alignment.topRight, + ); + final theme = ThemeData( + useMaterial3: false, + dialogTheme: const DialogThemeData(alignment: Alignment.bottomLeft), + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Offset bottomLeft = tester.getBottomLeft( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)), + ); + expect(bottomLeft.dx, 480.0); + expect(bottomLeft.dy, 104.0); + }); + + testWidgets('Material3 - Custom dialog shape matches golden', (WidgetTester tester) async { + const customBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ); + const dialog = AlertDialog(title: Text('Title'), actions: <Widget>[]); + final theme = ThemeData(dialogTheme: const DialogThemeData(shape: customBorder)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('m3_dialog_theme.dialog_with_custom_border.png'), + ); + }); + + testWidgets('Material2 - Custom dialog shape matches golden', (WidgetTester tester) async { + const customBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ); + const dialog = AlertDialog(title: Text('Title'), actions: <Widget>[]); + final theme = ThemeData( + useMaterial3: false, + dialogTheme: const DialogThemeData(shape: customBorder), + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('m2_dialog_theme.dialog_with_custom_border.png'), + ); + }); + + testWidgets('Custom Icon Color - Constructor Param - highest preference', ( + WidgetTester tester, + ) async { + const Color iconColor = Colors.pink, + dialogThemeColor = Colors.green, + iconThemeColor = Colors.yellow; + final theme = ThemeData( + iconTheme: const IconThemeData(color: iconThemeColor), + dialogTheme: const DialogThemeData(iconColor: dialogThemeColor), + ); + const dialog = AlertDialog( + icon: Icon(Icons.ac_unit), + iconColor: iconColor, + actions: <Widget>[], + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // first is Text('X') + final RichText text = tester.widget(find.byType(RichText).last); + expect(text.text.style!.color, iconColor); + }); + + testWidgets('Custom Icon Color - Dialog Theme - preference over Theme', ( + WidgetTester tester, + ) async { + const Color dialogThemeColor = Colors.green, iconThemeColor = Colors.yellow; + final theme = ThemeData( + iconTheme: const IconThemeData(color: iconThemeColor), + dialogTheme: const DialogThemeData(iconColor: dialogThemeColor), + ); + const dialog = AlertDialog(icon: Icon(Icons.ac_unit), actions: <Widget>[]); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // first is Text('X') + final RichText text = tester.widget(find.byType(RichText).last); + expect(text.text.style!.color, dialogThemeColor); + }); + + testWidgets('Material3 - Custom Icon Color - Theme - lowest preference', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + const dialog = AlertDialog(icon: Icon(Icons.ac_unit), actions: <Widget>[]); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // first is Text('X') + final RichText text = tester.widget(find.byType(RichText).last); + expect(text.text.style!.color, theme.colorScheme.secondary); + }); + + testWidgets('Material2 - Custom Icon Color - Theme - lowest preference', ( + WidgetTester tester, + ) async { + const Color iconThemeColor = Colors.yellow; + final theme = ThemeData( + useMaterial3: false, + iconTheme: const IconThemeData(color: iconThemeColor), + ); + const dialog = AlertDialog(icon: Icon(Icons.ac_unit), actions: <Widget>[]); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // first is Text('X') + final RichText text = tester.widget(find.byType(RichText).last); + expect(text.text.style!.color, iconThemeColor); + }); + + testWidgets('Custom Title Text Style - Constructor Param', (WidgetTester tester) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog( + title: Text(titleText), + titleTextStyle: titleTextStyle, + actions: <Widget>[], + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style, titleTextStyle); + }); + + testWidgets('Custom Title Text Style - Dialog Theme', (WidgetTester tester) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog(title: Text(titleText), actions: <Widget>[]); + final theme = ThemeData(dialogTheme: const DialogThemeData(titleTextStyle: titleTextStyle)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style, titleTextStyle); + }); + + testWidgets('Material3 - Custom Title Text Style - Theme', (WidgetTester tester) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog(title: Text(titleText)); + final theme = ThemeData(textTheme: const TextTheme(headlineSmall: titleTextStyle)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style!.color, titleTextStyle.color); + }); + + testWidgets('Material2 - Custom Title Text Style - Theme', (WidgetTester tester) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog(title: Text(titleText)); + final theme = ThemeData( + useMaterial3: false, + textTheme: const TextTheme(titleLarge: titleTextStyle), + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style!.color, titleTextStyle.color); + }); + + testWidgets('Simple Dialog - Custom Title Text Style - Constructor Param', ( + WidgetTester tester, + ) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = SimpleDialog(title: Text(titleText), titleTextStyle: titleTextStyle); + + await tester.pumpWidget(_appWithDialog(tester, dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style, titleTextStyle); + }); + + testWidgets('Simple Dialog - Custom Title Text Style - Dialog Theme', ( + WidgetTester tester, + ) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = SimpleDialog(title: Text(titleText)); + final theme = ThemeData(dialogTheme: const DialogThemeData(titleTextStyle: titleTextStyle)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style, titleTextStyle); + }); + + testWidgets('Simple Dialog - Custom Title Text Style - Theme', (WidgetTester tester) async { + const titleText = 'Title'; + const titleTextStyle = TextStyle(color: Colors.pink); + const dialog = SimpleDialog(title: Text(titleText)); + final theme = ThemeData(textTheme: const TextTheme(titleLarge: titleTextStyle)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph title = _getTextRenderObject(tester, titleText); + expect(title.text.style!.color, titleTextStyle.color); + }); + + testWidgets('Custom Content Text Style - Constructor Param', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog( + content: Text(contentText), + contentTextStyle: contentTextStyle, + actions: <Widget>[], + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObject(tester, contentText); + expect(content.text.style, contentTextStyle); + }); + + testWidgets('Custom Content Text Style - Dialog Theme', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog(content: Text(contentText), actions: <Widget>[]); + final theme = ThemeData(dialogTheme: const DialogThemeData(contentTextStyle: contentTextStyle)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObject(tester, contentText); + expect(content.text.style, contentTextStyle); + }); + + testWidgets('Material3 - Custom Content Text Style - Theme', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog(content: Text(contentText)); + final theme = ThemeData(textTheme: const TextTheme(bodyMedium: contentTextStyle)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObject(tester, contentText); + expect(content.text.style!.color, contentTextStyle.color); + }); + + testWidgets('Material2 - Custom Content Text Style - Theme', (WidgetTester tester) async { + const contentText = 'Content'; + const contentTextStyle = TextStyle(color: Colors.pink); + const dialog = AlertDialog(content: Text(contentText)); + final theme = ThemeData( + useMaterial3: false, + textTheme: const TextTheme(titleMedium: contentTextStyle), + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObject(tester, contentText); + expect(content.text.style!.color, contentTextStyle.color); + }); + + testWidgets('Custom barrierColor - Theme', (WidgetTester tester) async { + const Color barrierColor = Colors.blue; + const dialog = SimpleDialog(); + final theme = ThemeData(dialogTheme: const DialogThemeData(barrierColor: barrierColor)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final ModalBarrier modalBarrier = tester.widget(find.byType(ModalBarrier).last); + expect(modalBarrier.color, barrierColor); + }); + + testWidgets('DialogTheme.insetPadding updates Dialog insetPadding', (WidgetTester tester) async { + // The default testing screen (800, 600) + const screenRect = Rect.fromLTRB(0.0, 0.0, 800.0, 600.0); + const dialogTheme = DialogThemeData(insetPadding: EdgeInsets.fromLTRB(10, 15, 20, 25)); + const dialog = Dialog(child: Placeholder()); + + await tester.pumpWidget( + _appWithDialog(tester, dialog, theme: ThemeData(dialogTheme: dialogTheme)), + ); + await tester.tap(find.text('X')); + await tester.pump(); + + expect( + tester.getRect(find.byType(Placeholder)), + Rect.fromLTRB( + screenRect.left + dialogTheme.insetPadding!.left, + screenRect.top + dialogTheme.insetPadding!.top, + screenRect.right - dialogTheme.insetPadding!.right, + screenRect.bottom - dialogTheme.insetPadding!.bottom, + ), + ); + }); + + testWidgets('DialogTheme.clipBehavior updates the dialogs clip behavior', ( + WidgetTester tester, + ) async { + const dialogTheme = DialogThemeData(clipBehavior: Clip.hardEdge); + const dialog = Dialog(child: Placeholder()); + + await tester.pumpWidget( + _appWithDialog(tester, dialog, theme: ThemeData(dialogTheme: dialogTheme)), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialDialog(tester); + expect(materialWidget.clipBehavior, dialogTheme.clipBehavior); + }); + + testWidgets('Dialog.clipBehavior takes priority over theme', (WidgetTester tester) async { + const dialog = Dialog(clipBehavior: Clip.antiAlias, child: Placeholder()); + final theme = ThemeData(dialogTheme: const DialogThemeData(clipBehavior: Clip.hardEdge)); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialDialog(tester); + expect(materialWidget.clipBehavior, Clip.antiAlias); + }); + + testWidgets('Material2 - Dialog.clipBehavior takes priority over theme', ( + WidgetTester tester, + ) async { + const dialog = Dialog(clipBehavior: Clip.antiAlias, child: Placeholder()); + final theme = ThemeData( + useMaterial3: false, + dialogTheme: const DialogThemeData(clipBehavior: Clip.hardEdge), + ); + + await tester.pumpWidget(_appWithDialog(tester, dialog, theme: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialWidget = _getMaterialDialog(tester); + expect(materialWidget.clipBehavior, Clip.antiAlias); + }); + + testWidgets('DialogThemeData.constraints is respected if Dialog.constraints is null', ( + WidgetTester tester, + ) async { + const themeConstraints = BoxConstraints(maxWidth: 500, maxHeight: 500); + const dialogTheme = DialogThemeData(alignment: Alignment.center, constraints: themeConstraints); + + final dialog = Dialog( + child: SizedBox.expand(child: Container(color: Colors.amber)), + ); + + await tester.pumpWidget( + _appWithDialog(tester, dialog, theme: ThemeData(dialogTheme: dialogTheme)), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Verify that the dialog respects the constraints from the theme + final Finder dialogFinder = find.byType(Container); + final RenderBox renderBox = tester.renderObject(dialogFinder); + + expect(renderBox.constraints.maxWidth, themeConstraints.maxWidth); + expect(renderBox.constraints.maxHeight, themeConstraints.maxHeight); + }); +} diff --git a/packages/material_ui/test/material/divider_test.dart b/packages/material_ui/test/material/divider_test.dart new file mode 100644 index 000000000000..f9bf10c29e46 --- /dev/null +++ b/packages/material_ui/test/material/divider_test.dart @@ -0,0 +1,244 @@ +// 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('Material3 - Divider control test', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Center(child: Divider()))); + final RenderBox box = tester.firstRenderObject(find.byType(Divider)); + expect(box.size.height, 16.0); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, 1.0); + }); + + testWidgets('Material2 - Divider control test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Center(child: Divider()), + ), + ); + final RenderBox box = tester.firstRenderObject(find.byType(Divider)); + expect(box.size.height, 16.0); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, 0.0); + }); + + testWidgets('Divider custom thickness', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Divider(thickness: 5.0)), + ), + ); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, 5.0); + }); + + testWidgets('Divider custom radius', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Divider(radius: BorderRadius.all(Radius.circular(5)))), + ), + ); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.bottomLeft, const Radius.circular(5)); + expect(borderRadius.bottomRight, const Radius.circular(5)); + expect(borderRadius.topLeft, const Radius.circular(5)); + expect(borderRadius.topRight, const Radius.circular(5)); + }); + + testWidgets('Horizontal divider custom indentation', (WidgetTester tester) async { + const customIndent = 10.0; + Rect dividerRect; + Rect lineRect; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Divider(indent: customIndent)), + ), + ); + // The divider line is drawn with a DecoratedBox with a border + dividerRect = tester.getRect(find.byType(Divider)); + lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left + customIndent); + expect(lineRect.right, dividerRect.right); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Divider(endIndent: customIndent)), + ), + ); + dividerRect = tester.getRect(find.byType(Divider)); + lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left); + expect(lineRect.right, dividerRect.right - customIndent); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Divider(indent: customIndent, endIndent: customIndent), + ), + ), + ); + dividerRect = tester.getRect(find.byType(Divider)); + lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left + customIndent); + expect(lineRect.right, dividerRect.right - customIndent); + }); + + testWidgets('Material3 - Vertical Divider Test', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Center(child: VerticalDivider()))); + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + expect(box.size.width, 16.0); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, 1.0); + }); + + testWidgets('Material2 - Vertical Divider Test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Center(child: VerticalDivider()), + ), + ); + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + expect(box.size.width, 16.0); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, 0.0); + }); + + testWidgets('Divider custom thickness', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: VerticalDivider(thickness: 5.0)), + ), + ); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, 5.0); + }); + + testWidgets('Vertical Divider Test 2', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material( + child: SizedBox( + height: 24.0, + child: Row(children: <Widget>[Text('Hey.'), VerticalDivider()]), + ), + ), + ), + ); + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + final RenderBox containerBox = tester.firstRenderObject(find.byType(Container).last); + + expect(box.size.width, 16.0); + expect(containerBox.size.height, 600.0); + expect(find.byType(VerticalDivider), paints..path(strokeWidth: 0.0)); + }); + + testWidgets('Vertical divider custom indentation', (WidgetTester tester) async { + const customIndent = 10.0; + Rect dividerRect; + Rect lineRect; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: VerticalDivider(indent: customIndent)), + ), + ); + // The divider line is drawn with a DecoratedBox with a border + dividerRect = tester.getRect(find.byType(VerticalDivider)); + lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top + customIndent); + expect(lineRect.bottom, dividerRect.bottom); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: VerticalDivider(endIndent: customIndent)), + ), + ); + dividerRect = tester.getRect(find.byType(VerticalDivider)); + lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top); + expect(lineRect.bottom, dividerRect.bottom - customIndent); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: VerticalDivider(indent: customIndent, endIndent: customIndent), + ), + ), + ); + dividerRect = tester.getRect(find.byType(VerticalDivider)); + lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top + customIndent); + expect(lineRect.bottom, dividerRect.bottom - customIndent); + }); + + testWidgets('VerticalDivider custom radius', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: VerticalDivider(radius: BorderRadius.all(Radius.circular(5)))), + ), + ); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.bottomLeft, const Radius.circular(5)); + expect(borderRadius.bottomRight, const Radius.circular(5)); + expect(borderRadius.topLeft, const Radius.circular(5)); + expect(borderRadius.topRight, const Radius.circular(5)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/39533 + testWidgets('createBorderSide does not throw exception with null context', ( + WidgetTester tester, + ) async { + // Passing a null context used to throw an exception but no longer does. + expect(() => Divider.createBorderSide(null), isNot(throwsAssertionError)); + expect(() => Divider.createBorderSide(null), isNot(throwsNoSuchMethodError)); + }); + + testWidgets('Divider does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: Divider())), + ), + ); + expect(tester.getSize(find.byType(Divider)), Size.zero); + }); + + testWidgets('VerticalDivider does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: VerticalDivider())), + ), + ); + expect(tester.getSize(find.byType(VerticalDivider)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/divider_theme_test.dart b/packages/material_ui/test/material/divider_theme_test.dart new file mode 100644 index 000000000000..0c181041bad7 --- /dev/null +++ b/packages/material_ui/test/material/divider_theme_test.dart @@ -0,0 +1,393 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('DividerThemeData copyWith, ==, hashCode basics', () { + expect(const DividerThemeData(), const DividerThemeData().copyWith()); + expect(const DividerThemeData().hashCode, const DividerThemeData().copyWith().hashCode); + }); + + test('DividerThemeData null fields by default', () { + const dividerTheme = DividerThemeData(); + expect(dividerTheme.color, null); + expect(dividerTheme.space, null); + expect(dividerTheme.thickness, null); + expect(dividerTheme.indent, null); + expect(dividerTheme.endIndent, null); + expect(dividerTheme.radius, null); + }); + + testWidgets('Default DividerThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DividerThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('DividerThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DividerThemeData( + color: Color(0xFFFFFFFF), + space: 5.0, + thickness: 4.0, + indent: 3.0, + endIndent: 2.0, + radius: BorderRadius.all(Radius.circular(20)), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'color: ${const Color(0xffffffff)}', + 'space: 5.0', + 'thickness: 4.0', + 'indent: 3.0', + 'endIndent: 2.0', + 'radius: BorderRadius.circular(20.0)', + ]); + }); + + group('Material3 - Horizontal Divider', () { + testWidgets('Passing no DividerThemeData returns defaults', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Divider()), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(Divider)); + expect(box.size.height, 16.0); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, 1.0); + + expect(decoration.border!.bottom.color, theme.colorScheme.outlineVariant); + + final Rect dividerRect = tester.getRect(find.byType(Divider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left); + expect(lineRect.right, dividerRect.right); + }); + + testWidgets('Uses values from DividerThemeData', (WidgetTester tester) async { + final DividerThemeData dividerTheme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(dividerTheme: dividerTheme), + home: const Scaffold(body: Divider()), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(Divider)); + expect(box.size.height, dividerTheme.space); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, dividerTheme.thickness); + expect(decoration.border!.bottom.color, dividerTheme.color); + + final Rect dividerRect = tester.getRect(find.byType(Divider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left + dividerTheme.indent!); + expect(lineRect.right, dividerRect.right - dividerTheme.endIndent!); + + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.topLeft, const Radius.circular(1)); + expect(borderRadius.topRight, const Radius.circular(2)); + expect(borderRadius.bottomLeft, const Radius.circular(3)); + expect(borderRadius.bottomRight, const Radius.circular(4)); + }); + + testWidgets('DividerTheme overrides defaults', (WidgetTester tester) async { + final DividerThemeData dividerTheme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DividerTheme(data: dividerTheme, child: const Divider()), + ), + ), + ); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, dividerTheme.thickness); + expect(decoration.border!.bottom.color, dividerTheme.color); + }); + + testWidgets('Widget properties take priority over theme', (WidgetTester tester) async { + const Color color = Colors.purple; + const height = 10.0; + const thickness = 5.0; + const indent = 8.0; + const endIndent = 9.0; + + final DividerThemeData dividerTheme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(dividerTheme: dividerTheme), + home: const Scaffold( + body: Divider( + color: color, + height: height, + thickness: thickness, + indent: indent, + endIndent: endIndent, + radius: BorderRadiusGeometry.all(Radius.circular(5)), + ), + ), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(Divider)); + expect(box.size.height, height); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, thickness); + expect(decoration.border!.bottom.color, color); + + final Rect dividerRect = tester.getRect(find.byType(Divider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left + indent); + expect(lineRect.right, dividerRect.right - endIndent); + + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.topLeft, const Radius.circular(5)); + expect(borderRadius.topRight, const Radius.circular(5)); + expect(borderRadius.bottomLeft, const Radius.circular(5)); + expect(borderRadius.bottomRight, const Radius.circular(5)); + }); + }); + + group('Material3 - Vertical Divider', () { + testWidgets('Passing no DividerThemeData returns defaults', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: VerticalDivider()), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + expect(box.size.width, 16.0); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, 1.0); + + expect(border.left.color, theme.colorScheme.outlineVariant); + + final Rect dividerRect = tester.getRect(find.byType(VerticalDivider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top); + expect(lineRect.bottom, dividerRect.bottom); + }); + + testWidgets('Uses values from DividerThemeData', (WidgetTester tester) async { + final DividerThemeData dividerTheme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(dividerTheme: dividerTheme), + home: const Scaffold(body: VerticalDivider()), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + expect(box.size.width, dividerTheme.space); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, dividerTheme.thickness); + expect(border.left.color, dividerTheme.color); + + final Rect dividerRect = tester.getRect(find.byType(VerticalDivider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top + dividerTheme.indent!); + expect(lineRect.bottom, dividerRect.bottom - dividerTheme.endIndent!); + + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.topLeft, const Radius.circular(1)); + expect(borderRadius.topRight, const Radius.circular(2)); + expect(borderRadius.bottomLeft, const Radius.circular(3)); + expect(borderRadius.bottomRight, const Radius.circular(4)); + }); + + testWidgets('DividerTheme overrides defaults', (WidgetTester tester) async { + final DividerThemeData dividerTheme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DividerTheme(data: dividerTheme, child: const VerticalDivider()), + ), + ), + ); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, dividerTheme.thickness); + expect(border.left.color, dividerTheme.color); + }); + + testWidgets('Widget properties take priority over theme', (WidgetTester tester) async { + const Color color = Colors.purple; + const width = 10.0; + const thickness = 5.0; + const indent = 8.0; + const endIndent = 9.0; + + final DividerThemeData dividerTheme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(dividerTheme: dividerTheme), + home: const Scaffold( + body: VerticalDivider( + color: color, + width: width, + thickness: thickness, + indent: indent, + endIndent: endIndent, + radius: BorderRadiusGeometry.all(Radius.circular(5)), + ), + ), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + expect(box.size.width, width); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, thickness); + expect(border.left.color, color); + + final Rect dividerRect = tester.getRect(find.byType(VerticalDivider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top + indent); + expect(lineRect.bottom, dividerRect.bottom - endIndent); + + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.topLeft, const Radius.circular(5)); + expect(borderRadius.topRight, const Radius.circular(5)); + expect(borderRadius.bottomLeft, const Radius.circular(5)); + expect(borderRadius.bottomRight, const Radius.circular(5)); + }); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + group('Material2 - Horizontal Divider', () { + testWidgets('Passing no DividerThemeData returns defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(body: Divider()), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(Divider)); + expect(box.size.height, 16.0); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, 0.0); + + final theme = ThemeData(useMaterial3: false); + expect(decoration.border!.bottom.color, theme.dividerColor); + + final Rect dividerRect = tester.getRect(find.byType(Divider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.left, dividerRect.left); + expect(lineRect.right, dividerRect.right); + }); + + testWidgets('DividerTheme overrides defaults', (WidgetTester tester) async { + final DividerThemeData theme = _dividerTheme(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DividerTheme(data: theme, child: const Divider()), + ), + ), + ); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, theme.thickness); + expect(decoration.border!.bottom.color, theme.color); + + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.topLeft, const Radius.circular(1)); + expect(borderRadius.topRight, const Radius.circular(2)); + expect(borderRadius.bottomLeft, const Radius.circular(3)); + expect(borderRadius.bottomRight, const Radius.circular(4)); + }); + }); + + group('Material2 - Vertical Divider', () { + testWidgets('Passing no DividerThemeData returns defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(body: VerticalDivider()), + ), + ); + + final RenderBox box = tester.firstRenderObject(find.byType(VerticalDivider)); + expect(box.size.width, 16.0); + + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.left.width, 0.0); + + final theme = ThemeData(useMaterial3: false); + expect(border.left.color, theme.dividerColor); + + final Rect dividerRect = tester.getRect(find.byType(VerticalDivider)); + final Rect lineRect = tester.getRect(find.byType(DecoratedBox)); + expect(lineRect.top, dividerRect.top); + expect(lineRect.bottom, dividerRect.bottom); + }); + }); + }); +} + +DividerThemeData _dividerTheme() { + return const DividerThemeData( + color: Colors.orange, + space: 12.0, + thickness: 2.0, + indent: 7.0, + endIndent: 5.0, + radius: BorderRadiusGeometry.only( + topLeft: Radius.circular(1), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(3), + bottomRight: Radius.circular(4), + ), + ); +} diff --git a/packages/material_ui/test/material/drawer_button_test.dart b/packages/material_ui/test/material/drawer_button_test.dart new file mode 100644 index 000000000000..41f4ebaf7331 --- /dev/null +++ b/packages/material_ui/test/material/drawer_button_test.dart @@ -0,0 +1,399 @@ +// 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:ui' show PointerDeviceKind; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RendererBinding; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('DrawerButton control test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: DrawerButton(), drawer: Drawer()), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsNothing); + + await tester.tap(find.byType(DrawerButton)); + + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOneWidget); + }); + + testWidgets('DrawerButton onPressed overrides default end drawer open behaviour', ( + WidgetTester tester, + ) async { + var customCallbackWasCalled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: DrawerButton(onPressed: () => customCallbackWasCalled = true)), + drawer: const Drawer(), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(Drawer), findsNothing); // Start off with a closed drawer + expect(customCallbackWasCalled, false); // customCallbackWasCalled should still be false. + await tester.tap(find.byType(DrawerButton)); + + await tester.pumpAndSettle(); + + // Drawer is still closed + expect(find.byType(Drawer), findsNothing); + // The custom callback is called, setting customCallbackWasCalled to true. + expect(customCallbackWasCalled, true); + }); + + testWidgets('DrawerButton icon', (WidgetTester tester) async { + final Key androidKey = UniqueKey(); + final Key iOSKey = UniqueKey(); + final Key linuxKey = UniqueKey(); + final Key macOSKey = UniqueKey(); + final Key windowsKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Column( + children: <Widget>[ + Theme( + data: ThemeData(platform: TargetPlatform.android), + child: DrawerButtonIcon(key: androidKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.iOS), + child: DrawerButtonIcon(key: iOSKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.linux), + child: DrawerButtonIcon(key: linuxKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.macOS), + child: DrawerButtonIcon(key: macOSKey), + ), + Theme( + data: ThemeData(platform: TargetPlatform.windows), + child: DrawerButtonIcon(key: windowsKey), + ), + ], + ), + ), + ); + + final Icon androidIcon = tester.widget( + find.descendant(of: find.byKey(androidKey), matching: find.byType(Icon)), + ); + final Icon iOSIcon = tester.widget( + find.descendant(of: find.byKey(iOSKey), matching: find.byType(Icon)), + ); + final Icon linuxIcon = tester.widget( + find.descendant(of: find.byKey(linuxKey), matching: find.byType(Icon)), + ); + final Icon macOSIcon = tester.widget( + find.descendant(of: find.byKey(macOSKey), matching: find.byType(Icon)), + ); + final Icon windowsIcon = tester.widget( + find.descendant(of: find.byKey(windowsKey), matching: find.byType(Icon)), + ); + + // All icons for drawer are the same + expect(iOSIcon.icon == androidIcon.icon, isTrue); + expect(linuxIcon.icon == androidIcon.icon, isTrue); + expect(macOSIcon.icon == androidIcon.icon, isTrue); + expect(windowsIcon.icon == androidIcon.icon, isTrue); + }); + + testWidgets('DrawerButton color', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: DrawerButton(color: Colors.red)), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(DrawerButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('DrawerButton color with ButtonStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: DrawerButton( + style: ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + ), + ), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(DrawerButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('DrawerButton semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: DrawerButton())), + ), + ); + + await tester.pumpAndSettle(); + + final String? expectedLabel; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expectedLabel = 'Open navigation menu'; + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expectedLabel = null; + } + expect( + tester.getSemantics(find.byType(DrawerButton)), + matchesSemantics( + tooltip: 'Open navigation menu', + label: expectedLabel, + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: defaultTargetPlatform != TargetPlatform.iOS, + isFocusable: true, + ), + ); + handle.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('EndDrawerButton control test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: EndDrawerButton(), endDrawer: Drawer()), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsNothing); + + await tester.tap(find.byType(EndDrawerButton)); + + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsOneWidget); + }); + + testWidgets('EndDrawerButton semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: EndDrawerButton())), + ), + ); + + await tester.pumpAndSettle(); + final String? expectedLabel; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expectedLabel = 'Open navigation menu'; + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expectedLabel = null; + } + expect( + tester.getSemantics(find.byType(EndDrawerButton)), + matchesSemantics( + tooltip: 'Open navigation menu', + label: expectedLabel, + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: defaultTargetPlatform != TargetPlatform.iOS, + isFocusable: true, + ), + ); + handle.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('EndDrawerButton color', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: EndDrawerButton(color: Colors.red)), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(EndDrawerButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('EndDrawerButton color with ButtonStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: EndDrawerButton( + style: ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + ), + ), + ), + ); + + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(EndDrawerButton), matching: find.byType(RichText)), + ); + expect(iconText.text.style!.color, Colors.red); + }); + + testWidgets('EndDrawerButton onPressed overrides default end drawer open behaviour', ( + WidgetTester tester, + ) async { + var customCallbackWasCalled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: EndDrawerButton(onPressed: () => customCallbackWasCalled = true)), + endDrawer: const Drawer(), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(Drawer), findsNothing); // Start off with a closed drawer + expect(customCallbackWasCalled, false); // customCallbackWasCalled should still be false. + await tester.tap(find.byType(EndDrawerButton)); + + await tester.pumpAndSettle(); + + // Drawer is still closed + expect(find.byType(Drawer), findsNothing); + // The custom callback is called, setting customCallbackWasCalled to true. + expect(customCallbackWasCalled, true); + }); + + testWidgets('DrawerButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: DrawerButton()))); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.byType(DrawerButton))); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('EndDrawerButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: EndDrawerButton()))); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.byType(EndDrawerButton))); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('DrawerButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DrawerButton( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(DrawerButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('EndDrawerButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: EndDrawerButton( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(EndDrawerButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('DrawerButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Scaffold(body: DrawerButton())), + ), + ), + ); + expect(tester.getSize(find.byType(DrawerButton)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/drawer_test.dart b/packages/material_ui/test/material/drawer_test.dart new file mode 100644 index 000000000000..9639ab1a9116 --- /dev/null +++ b/packages/material_ui/test/material/drawer_test.dart @@ -0,0 +1,1141 @@ +// 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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Material2 - Drawer control test', (WidgetTester tester) async { + const containerKey = Key('container'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + drawer: Drawer( + child: ListView( + children: <Widget>[ + DrawerHeader( + child: Container(key: containerKey, child: const Text('header')), + ), + const ListTile(leading: Icon(Icons.archive), title: Text('Archive')), + ], + ), + ), + ), + ), + ); + + expect(find.text('Archive'), findsNothing); + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Archive'), findsOneWidget); + + RenderBox box = tester.renderObject(find.byType(DrawerHeader)); + expect(box.size.height, equals(160.0 + 8.0 + 1.0)); // height + bottom margin + bottom edge + + final double drawerWidth = box.size.width; + final double drawerHeight = box.size.height; + + box = tester.renderObject(find.byKey(containerKey)); + expect(box.size.width, equals(drawerWidth - 2 * 16.0)); + expect(box.size.height, equals(drawerHeight - 2 * 16.0)); + + expect(find.text('header'), findsOneWidget); + }); + + testWidgets('Material3 - Drawer control test', (WidgetTester tester) async { + const containerKey = Key('container'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: Drawer( + child: ListView( + children: <Widget>[ + DrawerHeader( + child: Container(key: containerKey, child: const Text('header')), + ), + const ListTile(leading: Icon(Icons.archive), title: Text('Archive')), + ], + ), + ), + ), + ), + ); + + expect(find.text('Archive'), findsNothing); + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Archive'), findsOneWidget); + + RenderBox box = tester.renderObject(find.byType(DrawerHeader)); + expect(box.size.height, equals(160.0 + 8.0 + 1.0)); // height + bottom margin + bottom edge + + final double drawerWidth = box.size.width; + final double drawerHeight = box.size.height; + + box = tester.renderObject(find.byKey(containerKey)); + expect(box.size.width, equals(drawerWidth - 2 * 16.0)); + expect( + box.size.height, + equals(drawerHeight - 2 * 16.0 - 1.0), + ); // Header divider thickness is 1.0 in Material 3. + + expect(find.text('header'), findsOneWidget); + }); + + testWidgets( + 'Drawer dismiss barrier has label', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget(const MaterialApp(home: Scaffold(drawer: Drawer()))); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect( + semantics, + includesNodeWith( + label: const DefaultMaterialLocalizations().modalBarrierDismissLabel, + actions: <SemanticsAction>[SemanticsAction.tap], + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('Drawer dismiss barrier has no label', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget(const MaterialApp(home: Scaffold(drawer: Drawer()))); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect( + semantics, + isNot( + includesNodeWith( + label: const DefaultMaterialLocalizations().modalBarrierDismissLabel, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ), + ); + + semantics.dispose(); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('Scaffold drawerScrimColor', (WidgetTester tester) async { + // The scrim is a ColoredBox within a Semantics node labeled "Dismiss", + // within a DrawerController. Sorry. + Widget getScrim() { + return tester + .widget<Semantics>( + find.descendant( + of: find.byType(DrawerController), + matching: find.byWidgetPredicate((Widget widget) { + return widget is Semantics && widget.properties.label == 'Dismiss'; + }), + ), + ) + .child!; + } + + final scaffoldKey = GlobalKey<ScaffoldState>(); + Widget buildFrame({Color? drawerScrimColor}) { + return MaterialApp( + home: Scaffold( + key: scaffoldKey, + drawerScrimColor: drawerScrimColor, + drawer: Drawer( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.pop(context); + }, // close drawer + ); + }, + ), + ), + ), + ); + } + + Future<void> checkScrim(Color color) async { + scaffoldKey.currentState!.openDrawer(); + await tester.pump(); + var scrim = getScrim() as ColoredBox; + expect(scrim.color, isSameColorAs(color.withValues(alpha: 0))); + + await tester.pumpAndSettle(); + scrim = getScrim() as ColoredBox; + expect(scrim.color, isSameColorAs(color)); + + await tester.tap(find.byType(Drawer)); + await tester.pumpAndSettle(); + expect(find.byType(Drawer), findsNothing); + } + + // Default drawerScrimColor + await tester.pumpWidget(buildFrame()); + await checkScrim(Colors.black54); + + // Specific drawerScrimColor + await tester.pumpWidget(buildFrame(drawerScrimColor: const Color(0xFF323232))); + await checkScrim(const Color(0xFF323232)); + }); + + testWidgets('Open/close drawers by flinging', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + drawer: Drawer(child: Text('start drawer')), + endDrawer: Drawer(child: Text('end drawer')), + ), + ), + ); + + // In the beginning, drawers are closed + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + expect(state.isDrawerOpen, equals(false)); + expect(state.isEndDrawerOpen, equals(false)); + final Size size = tester.getSize(find.byType(Scaffold)); + + // A fling from the left opens the start drawer + await tester.flingFrom(Offset(0, size.height / 2), const Offset(80, 0), 500); + await tester.pumpAndSettle(); + expect(state.isDrawerOpen, equals(true)); + expect(state.isEndDrawerOpen, equals(false)); + + // Now, a fling from the right closes the drawer + await tester.flingFrom(Offset(size.width - 1, size.height / 2), const Offset(-80, 0), 500); + await tester.pumpAndSettle(); + expect(state.isDrawerOpen, equals(false)); + expect(state.isEndDrawerOpen, equals(false)); + + // Another fling from the right opens the end drawer + await tester.flingFrom(Offset(size.width - 1, size.height / 2), const Offset(-80, 0), 500); + await tester.pumpAndSettle(); + expect(state.isDrawerOpen, equals(false)); + expect(state.isEndDrawerOpen, equals(true)); + + // And a fling from the left closes it + await tester.flingFrom(Offset(0, size.height / 2), const Offset(80, 0), 500); + await tester.pumpAndSettle(); + expect(state.isDrawerOpen, equals(false)); + expect(state.isEndDrawerOpen, equals(false)); + }); + + testWidgets('Open/close drawer by dragging', (WidgetTester tester) async { + final draggable = ThemeData(platform: TargetPlatform.android); + await tester.pumpWidget( + MaterialApp( + theme: draggable, + home: const Scaffold(drawer: Drawer()), + ), + ); + + final TestGesture gesture = await tester.createGesture(); + final Finder finder = find.byType(Drawer); + + double drawerPosition() { + expect(finder, findsOneWidget); + final RenderBox renderBox = tester.renderObject(finder); + return renderBox.localToGlobal(Offset.zero).dx; + } + + // Pointer down (drawer is closed). + await gesture.addPointer(); + await gesture.down(const Offset(2, 2)); + await tester.pump(); + expect(finder, findsNothing); + + // Open drawer slightly. + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(drawerPosition(), isNegative); + + // Open drawer more than halfway. + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + expect(drawerPosition(), isNegative); + + // Drawer is fully open. + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + expect(drawerPosition(), 0.0); + + // Drawer is less than halfway closed. + await gesture.moveBy(const Offset(-100.0, 0)); + await tester.pump(); + expect(drawerPosition(), moreOrLessEquals(-100.0)); + + // Drawer is more than halfway closed. + await gesture.moveBy(const Offset(-100.0, 0)); + await tester.pump(); + expect(drawerPosition(), moreOrLessEquals(-200.0)); + + // Drawer is completely closed. + await gesture.moveTo(Offset.zero); + await tester.pump(); + expect(finder, findsNothing); + }); + + testWidgets('Scaffold.drawer - null restorationId ', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold(key: scaffoldKey, drawer: const Text('drawer'), body: Container()), + ), + ); + await tester.pump(); // no effect + expect(find.text('drawer'), findsNothing); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + + await tester.restartAndRestore(); + // Drawer state should not have been saved. + expect(find.text('drawer'), findsNothing); + }); + + testWidgets('Scaffold.endDrawer - null restorationId ', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold(key: scaffoldKey, drawer: const Text('endDrawer'), body: Container()), + ), + ); + await tester.pump(); // no effect + expect(find.text('endDrawer'), findsNothing); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('endDrawer'), findsOneWidget); + + await tester.restartAndRestore(); + // Drawer state should not have been saved. + expect(find.text('endDrawer'), findsNothing); + }); + + testWidgets('Scaffold.drawer state restoration test', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold( + key: scaffoldKey, + restorationId: 'scaffold', + drawer: const Text('drawer'), + body: Container(), + ), + ), + ); + await tester.pump(); // no effect + expect(find.text('drawer'), findsNothing); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + + await tester.restartAndRestore(); + expect(find.text('drawer'), findsOneWidget); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.tapAt(const Offset(750.0, 100.0)); // on the mask + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsNothing); + + await tester.restoreFrom(data); + expect(find.text('drawer'), findsOneWidget); + }); + + testWidgets('Scaffold.endDrawer state restoration test', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold( + key: scaffoldKey, + restorationId: 'scaffold', + endDrawer: const Text('endDrawer'), + body: Container(), + ), + ), + ); + await tester.pump(); // no effect + expect(find.text('endDrawer'), findsNothing); + scaffoldKey.currentState!.openEndDrawer(); + await tester.pumpAndSettle(); + expect(find.text('endDrawer'), findsOneWidget); + + await tester.restartAndRestore(); + expect(find.text('endDrawer'), findsOneWidget); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.tapAt(const Offset(750.0, 100.0)); // on the mask + await tester.pumpAndSettle(); + expect(find.text('endDrawer'), findsNothing); + + await tester.restoreFrom(data); + expect(find.text('endDrawer'), findsOneWidget); + }); + + testWidgets('Both drawer and endDrawer state restoration test', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold( + restorationId: 'scaffold', + key: scaffoldKey, + drawer: const Text('drawer'), + endDrawer: const Text('endDrawer'), + body: Container(), + ), + ), + ); + await tester.pump(); // no effect + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsNothing); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + expect(find.text('endDrawer'), findsNothing); + + await tester.restartAndRestore(); + expect(find.text('drawer'), findsOneWidget); + expect(find.text('endDrawer'), findsNothing); + + TestRestorationData data = await tester.getRestorationData(); + await tester.tapAt(const Offset(750.0, 100.0)); // on the mask + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsNothing); + + await tester.restoreFrom(data); + expect(find.text('drawer'), findsOneWidget); + expect(find.text('endDrawer'), findsNothing); + + await tester.tapAt(const Offset(750.0, 100.0)); // on the mask + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsNothing); + + scaffoldKey.currentState!.openEndDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsOneWidget); + + await tester.restartAndRestore(); + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsOneWidget); + + data = await tester.getRestorationData(); + await tester.tapAt(const Offset(750.0, 100.0)); // on the mask + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsNothing); + + await tester.restoreFrom(data); + expect(find.text('drawer'), findsNothing); + expect(find.text('endDrawer'), findsOneWidget); + }); + + testWidgets('ScaffoldState close drawer', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(key: scaffoldKey, drawer: const Text('Drawer'), body: Container()), + ), + ); + + expect(find.text('Drawer'), findsNothing); + + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('Drawer'), findsOneWidget); + + scaffoldKey.currentState!.closeDrawer(); + await tester.pumpAndSettle(); + expect(find.text('Drawer'), findsNothing); + }); + + testWidgets('ScaffoldState close drawer do not crash if drawer is already closed', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(key: scaffoldKey, drawer: const Text('Drawer'), body: Container()), + ), + ); + + expect(find.text('Drawer'), findsNothing); + + scaffoldKey.currentState!.closeDrawer(); + await tester.pumpAndSettle(); + expect(find.text('Drawer'), findsNothing); + }); + + testWidgets('Disposing drawer does not crash if drawer is open and framework is locked', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/34978 + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1800.0, 2400.0); + + await tester.pumpWidget( + MaterialApp( + home: OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + switch (orientation) { + case Orientation.portrait: + return Scaffold(drawer: const Text('drawer'), body: Container()); + case Orientation.landscape: + return Scaffold(appBar: AppBar(), body: Container()); + } + }, + ), + ), + ); + + expect(find.text('drawer'), findsNothing); + + // Using a global key is a workaround for this issue. + final ScaffoldState portraitScaffoldState = tester.firstState(find.byType(Scaffold)); + portraitScaffoldState.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + + // Change the orientation and cause the drawer controller to be disposed + // while the framework is locked. + tester.view.physicalSize = const Size(2400.0, 1800.0); + await tester.pumpAndSettle(); + expect(find.byType(BackButton), findsNothing); + }); + + testWidgets('Disposing endDrawer does not crash if endDrawer is open and framework is locked', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/34978 + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1800.0, 2400.0); + + await tester.pumpWidget( + MaterialApp( + home: OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + switch (orientation) { + case Orientation.portrait: + return Scaffold(endDrawer: const Text('endDrawer'), body: Container()); + case Orientation.landscape: + return Scaffold(appBar: AppBar(), body: Container()); + } + }, + ), + ), + ); + + expect(find.text('endDrawer'), findsNothing); + + // Using a global key is a workaround for this issue. + final ScaffoldState portraitScaffoldState = tester.firstState(find.byType(Scaffold)); + portraitScaffoldState.openEndDrawer(); + await tester.pumpAndSettle(); + expect(find.text('endDrawer'), findsOneWidget); + + // Change the orientation and cause the drawer controller to be disposed + // while the framework is locked. + tester.view.physicalSize = const Size(2400.0, 1800.0); + await tester.pumpAndSettle(); + expect(find.byType(BackButton), findsNothing); + }); + + testWidgets('ScaffoldState close end drawer', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(key: scaffoldKey, endDrawer: const Text('endDrawer'), body: Container()), + ), + ); + + expect(find.text('endDrawer'), findsNothing); + + scaffoldKey.currentState!.openEndDrawer(); + await tester.pumpAndSettle(); + expect(find.text('endDrawer'), findsOneWidget); + + scaffoldKey.currentState!.closeEndDrawer(); + await tester.pumpAndSettle(); + expect(find.text('endDrawer'), findsNothing); + }); + + testWidgets('Drawer width defaults to Material spec', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(drawer: Drawer()))); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final RenderBox box = tester.renderObject(find.byType(Drawer)); + expect(box.size.width, equals(304.0)); + }); + + testWidgets('Drawer width can be customized by parameter', (WidgetTester tester) async { + const double smallWidth = 200; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(drawer: Drawer(width: smallWidth)), + ), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pumpAndSettle(); + + final RenderBox box = tester.renderObject(find.byType(Drawer)); + expect(box.size.width, equals(smallWidth)); + }); + + testWidgets('Material3 - Drawer default shape (ltr)', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Scaffold(drawer: Drawer(), endDrawer: Drawer()), + ), + ), + ); + + final Finder drawerMaterial = find.descendant( + of: find.byType(Drawer), + matching: find.byType(Material), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + + // Open the drawer. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test the drawer shape. + Material material = tester.widget<Material>(drawerMaterial); + expect( + material.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + bottomRight: Radius.circular(16.0), + ), + ), + ); + + // Close the opened drawer. + await tester.tapAt(const Offset(750, 300)); + await tester.pumpAndSettle(); + + // Open the end drawer. + state.openEndDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test the end drawer shape. + material = tester.widget<Material>(drawerMaterial); + expect( + material.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), + ), + ); + }); + + testWidgets('Material3 - Drawer default shape (rtl)', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold(drawer: Drawer(), endDrawer: Drawer()), + ), + ), + ); + + final Finder drawerMaterial = find.descendant( + of: find.byType(Drawer), + matching: find.byType(Material), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + + // Open the drawer. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test the drawer shape. + Material material = tester.widget<Material>(drawerMaterial); + expect( + material.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), + ), + ); + + // Close the opened drawer. + await tester.tapAt(const Offset(750, 300)); + await tester.pumpAndSettle(); + + // Open the end drawer. + state.openEndDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test the end drawer shape. + material = tester.widget<Material>(drawerMaterial); + expect( + material.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + bottomRight: Radius.circular(16.0), + ), + ), + ); + }); + + testWidgets('Material3 - Drawer clip behavior', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(drawer: Drawer()))); + + final Finder drawerMaterial = find.descendant( + of: find.byType(Drawer), + matching: find.byType(Material), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + + // Open the drawer. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test default clip behavior. + Material material = tester.widget<Material>(drawerMaterial); + expect(material.clipBehavior, Clip.hardEdge); + + state.closeDrawer(); + await tester.pumpAndSettle(); + + // Provide a custom clip behavior. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(drawer: Drawer(clipBehavior: Clip.antiAlias)), + ), + ); + + // Open the drawer again. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Clip behavior is now updated. + material = tester.widget<Material>(drawerMaterial); + expect(material.clipBehavior, Clip.antiAlias); + }); + + testWidgets('Drawer barrier is dismissible by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + appBar: AppBar( + title: Semantics(headingLevel: 1, child: const Text('Drawer Dismissible')), + ), + endDrawer: const Drawer(backgroundColor: Colors.white, width: 300, child: Text('Drawer')), + body: Container( + color: Colors.white, + width: 600, + height: 600, + child: const Center(child: Text('Drawer Dismissible')), + ), + ), + ), + ); + + // Check the flag is set at the Scaffold level. + final Scaffold scaffold = tester.widget<Scaffold>(find.byType(Scaffold)); + expect(scaffold.drawerBarrierDismissible, true); + + // Verify whether the drawer barrier is dimissible by default via the state + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + expect(state.isDrawerBarrierDismissible, true); + + // Open the drawer initially. + state.openEndDrawer(); + + await tester.pumpAndSettle(); + + // Check that the drawer open. + expect(find.byType(Drawer), findsExactly(1)); + + // Close the drawer programmatically. + state.closeEndDrawer(); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsExactly(0)); + + // Open it again, and make sure the drawer is available. + state.openEndDrawer(); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsExactly(1)); + + // Find the ModalBarrier. + final Finder modalBarrierFinder = find.byType(ModalBarrier); + + // Get the RenderBox of the ModalBarrier. + final modalBarrierRenderBox = tester.renderObject(modalBarrierFinder) as RenderBox; + + // Calculate a point to tap outside the Drawer. + // This example taps on the ModalBarrier somewhere outside its boundaries. + const modalBarrierCenter = Offset(400, 300); + final Offset tapPosition = modalBarrierRenderBox.localToGlobal(modalBarrierCenter); + + // Tap on the ModalBarrier. + await tester.tapAt(tapPosition); + await tester.pumpAndSettle(); + + // Make sure the drawer is gone, since the drawerBarrierDismissible flag is set to true by default. + expect(find.byType(Drawer), findsExactly(0)); + }); + + testWidgets('Drawer can be configured as not dismissible', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + drawerBarrierDismissible: false, + appBar: AppBar( + title: Semantics(headingLevel: 1, child: const Text('Drawer Dismissible')), + ), + endDrawer: const Drawer(backgroundColor: Colors.white, width: 300, child: Text('Drawer')), + body: Container( + color: Colors.white, + width: 600, + height: 600, + child: const Center(child: Text('Drawer Dismissible')), + ), + ), + ), + ); + + // Make sure the flag is set to false at the Scaffold level. + final Scaffold scaffold = tester.widget<Scaffold>(find.byType(Scaffold)); + expect(scaffold.drawerBarrierDismissible, false); + + // Verify the drawer barrier is not dimissible by checking the state's getter + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + expect(state.isDrawerBarrierDismissible, false); + + // Open the drawer initially. + state.openEndDrawer(); + + await tester.pumpAndSettle(); + + // Check that the drawer is open. + expect(find.byType(Drawer), findsExactly(1)); + + // Close the drawer programmatically. + state.closeEndDrawer(); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsExactly(0)); + + // Open it again, and make sure the drawer is available. + state.openEndDrawer(); + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), findsExactly(1)); + + // Find the ModalBarrier. + final Finder modalBarrierFinder = find.byType(ModalBarrier); + + // Get the RenderBox of the ModalBarrier. + final modalBarrierRenderBox = tester.renderObject(modalBarrierFinder) as RenderBox; + + // Calculate a point to tap outside the Drawer. + // This example taps on the ModalBarrier somewhere outside its boundaries. + const modalBarrierCenter = Offset(400, 300); + final Offset tapPosition = modalBarrierRenderBox.localToGlobal(modalBarrierCenter); + + // Tap on the ModalBarrier. + await tester.tapAt(tapPosition); + await tester.pumpAndSettle(); + + // Make sure the drawer is still present, and that tapping on the modal barrier + // didn't dismiss it, since the drawerBarrierDismissible property is set to false. + expect(find.byType(Drawer), findsExactly(1)); + }); + + testWidgets('Drawer can be dismissed with the escape key by default', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + // Test with drawerBarrierDismissible: true (default) + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + drawer: const Drawer(child: Text('drawer')), + ), + ), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + expect(state.isDrawerBarrierDismissible, isTrue); + + // Open the drawer. + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + + // Close the drawer with the escape key. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsNothing); + }); + + testWidgets( + 'Drawer cannot be dismissed with the escape key when drawerBarrierDismissible is false', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + drawer: const Drawer(child: Text('drawer')), + drawerBarrierDismissible: false, + ), + ), + ); + + // Verify that the [Scaffold.drawerBarrierDismissible] flag is false + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + expect(state.isDrawerBarrierDismissible, isFalse); + + // Open the drawer. + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + + // Try to close the drawer with the escape key, and verify it does not close. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/177005 + testWidgets('Drawer semantics for mismatched platforms', (WidgetTester tester) async { + const localizations = DefaultMaterialLocalizations(); + + Future<void> pumpDrawerWithTheme(TargetPlatform themePlatform) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: themePlatform), + home: Scaffold( + key: scaffoldKey, + drawer: const Drawer(child: Text('Drawer')), + body: Container(), + ), + ), + ); + + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + // Test label semantics. + final Finder drawerLabelFinder = find.bySemanticsLabel(localizations.drawerLabel); + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + expect(drawerLabelFinder, findsNothing); // Apple platforms don't show drawer label. + } else { + expect(drawerLabelFinder, findsOneWidget); // Non-Apple platforms show drawer label. + } + + // Test barrier semantics. + final semantics = SemanticsTester(tester); + final expectBarrierExcluded = defaultTargetPlatform == TargetPlatform.android; + + if (expectBarrierExcluded) { + expect( + semantics, + isNot( + includesNodeWith( + label: localizations.modalBarrierDismissLabel, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ), + ); + } else { + expect( + semantics, + includesNodeWith( + label: localizations.modalBarrierDismissLabel, + actions: <SemanticsAction>[SemanticsAction.tap], + ), + ); + } + + semantics.dispose(); + } + + // Test with theme.platform = Android on different real platforms. + await pumpDrawerWithTheme(TargetPlatform.android); + + // Test with theme.platform = iOS on different real platforms. + await pumpDrawerWithTheme(TargetPlatform.iOS); + }, variant: TargetPlatformVariant.all()); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Material2 - Drawer default shape', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(drawer: Drawer(), endDrawer: Drawer()), + ), + ); + + final Finder drawerMaterial = find.descendant( + of: find.byType(Drawer), + matching: find.byType(Material), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + + // Open the drawer. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test the drawer shape. + Material material = tester.widget<Material>(drawerMaterial); + expect(material.shape, null); + + // Close the opened drawer. + await tester.tapAt(const Offset(750, 300)); + await tester.pumpAndSettle(); + + // Open the end drawer. + state.openEndDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test the end drawer shape. + material = tester.widget<Material>(drawerMaterial); + expect(material.shape, null); + }); + + testWidgets('Material2 - Drawer clip behavior', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(drawer: Drawer()), + ), + ); + + final Finder drawerMaterial = find.descendant( + of: find.byType(Drawer), + matching: find.byType(Material), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + + // Open the drawer. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Test default clip behavior. + Material material = tester.widget<Material>(drawerMaterial); + expect(material.clipBehavior, Clip.none); + + state.closeDrawer(); + await tester.pumpAndSettle(); + + // Provide a shape and custom clip behavior. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + drawer: Drawer(clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder()), + ), + ), + ); + + // Open the drawer again. + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Clip behavior is now updated. + material = tester.widget<Material>(drawerMaterial); + expect(material.clipBehavior, Clip.hardEdge); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/6537 + testWidgets('Drawer and DrawerHeader do not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Drawer(child: DrawerHeader(child: Text('X'))), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Drawer)), Size.zero); + expect(tester.getSize(find.byType(DrawerHeader)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/drawer_theme_test.dart b/packages/material_ui/test/material/drawer_theme_test.dart new file mode 100644 index 000000000000..3b00fed3db90 --- /dev/null +++ b/packages/material_ui/test/material/drawer_theme_test.dart @@ -0,0 +1,352 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('copyWith, ==, hashCode basics', () { + expect(const DrawerThemeData(), const DrawerThemeData().copyWith()); + expect(const DrawerThemeData().hashCode, const DrawerThemeData().copyWith().hashCode); + }); + + test('DrawerThemeData lerp special cases', () { + expect(DrawerThemeData.lerp(null, null, 0), null); + const data = DrawerThemeData(); + expect(identical(DrawerThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Default debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DrawerThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('Custom debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DrawerThemeData( + backgroundColor: Color(0x00000099), + scrimColor: Color(0x00000098), + elevation: 5.0, + shadowColor: Color(0x00000097), + surfaceTintColor: Color(0x00000096), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + width: 200.0, + clipBehavior: Clip.hardEdge, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'backgroundColor: ${const Color(0x00000099)}', + 'scrimColor: ${const Color(0x00000098)}', + 'elevation: 5.0', + 'shadowColor: ${const Color(0x00000097)}', + 'surfaceTintColor: ${const Color(0x00000096)}', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(2.0))', + 'width: 200.0', + 'clipBehavior: Clip.hardEdge', + ]); + }); + + testWidgets( + 'Material2 - Default values are used when no Drawer or DrawerThemeData properties are specified', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold(key: scaffoldKey, drawer: const Drawer()), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, null); + expect(_drawerMaterial(tester).elevation, 16.0); + expect(_drawerMaterial(tester).shadowColor, theme.shadowColor); + expect(_drawerMaterial(tester).surfaceTintColor, null); + expect(_drawerMaterial(tester).shape, null); + expect(_scrim(tester).color, Colors.black54); + expect(_drawerRenderBox(tester).size.width, 304.0); + expect(_drawerMaterial(tester).clipBehavior, Clip.none); + }, + ); + + testWidgets( + 'Material3 - Default values are used when no Drawer or DrawerThemeData properties are specified', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold(key: scaffoldKey, drawer: const Drawer()), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, theme.colorScheme.surfaceContainerLow); + expect(_drawerMaterial(tester).elevation, 1.0); + expect(_drawerMaterial(tester).shadowColor, Colors.transparent); + expect(_drawerMaterial(tester).surfaceTintColor, Colors.transparent); + expect( + _drawerMaterial(tester).shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal(right: Radius.circular(16.0)), + ), + ); + expect(_scrim(tester).color, Colors.black54); + expect(_drawerRenderBox(tester).size.width, 304.0); + expect(_drawerMaterial(tester).clipBehavior, Clip.hardEdge); + }, + ); + + testWidgets( + 'Material2 - Default values are used when no Drawer or DrawerThemeData properties are specified in end drawer', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold(key: scaffoldKey, endDrawer: const Drawer()), + ), + ); + scaffoldKey.currentState!.openEndDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, null); + expect(_drawerMaterial(tester).elevation, 16.0); + expect(_drawerMaterial(tester).shadowColor, theme.shadowColor); + expect(_drawerMaterial(tester).surfaceTintColor, null); + expect(_drawerMaterial(tester).shape, null); + expect(_scrim(tester).color, Colors.black54); + expect(_drawerRenderBox(tester).size.width, 304.0); + expect(_drawerMaterial(tester).clipBehavior, Clip.none); + }, + ); + + testWidgets( + 'Material3 - Default values are used when no Drawer or DrawerThemeData properties are specified in end drawer', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold(key: scaffoldKey, endDrawer: const Drawer()), + ), + ); + scaffoldKey.currentState!.openEndDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, theme.colorScheme.surfaceContainerLow); + expect(_drawerMaterial(tester).elevation, 1.0); + expect(_drawerMaterial(tester).shadowColor, Colors.transparent); + expect(_drawerMaterial(tester).surfaceTintColor, Colors.transparent); + expect( + _drawerMaterial(tester).shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal(left: Radius.circular(16.0)), + ), + ); + expect(_scrim(tester).color, Colors.black54); + expect(_drawerRenderBox(tester).size.width, 304.0); + expect(_drawerMaterial(tester).clipBehavior, Clip.hardEdge); + }, + ); + + testWidgets('DrawerThemeData values are used when no Drawer properties are specified', ( + WidgetTester tester, + ) async { + const backgroundColor = Color(0x00000001); + const scrimColor = Color(0x00000002); + const elevation = 7.0; + const shadowColor = Color(0x00000003); + const surfaceTintColor = Color(0x00000004); + const shape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))); + const width = 200.0; + const Clip clipBehavior = Clip.antiAlias; + + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + drawerTheme: const DrawerThemeData( + backgroundColor: backgroundColor, + scrimColor: scrimColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + width: width, + clipBehavior: clipBehavior, + ), + ), + home: Scaffold(key: scaffoldKey, drawer: const Drawer()), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, backgroundColor); + expect(_drawerMaterial(tester).elevation, elevation); + expect(_drawerMaterial(tester).shadowColor, shadowColor); + expect(_drawerMaterial(tester).surfaceTintColor, surfaceTintColor); + expect(_drawerMaterial(tester).shape, shape); + expect(_scrim(tester).color, scrimColor); + expect(_drawerRenderBox(tester).size.width, width); + expect(_drawerMaterial(tester).clipBehavior, clipBehavior); + }); + + testWidgets( + 'Drawer values take priority over DrawerThemeData values when both properties are specified', + (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const scrimColor = Color(0x00000002); + const elevation = 7.0; + const shadowColor = Color(0x00000003); + const surfaceTintColor = Color(0x00000004); + const shape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))); + const width = 200.0; + const Clip clipBehavior = Clip.antiAlias; + + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + drawerTheme: const DrawerThemeData( + backgroundColor: Color(0x00000005), + scrimColor: Color(0x00000006), + elevation: 13.0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(29.0))), + width: 400.0, + clipBehavior: Clip.antiAliasWithSaveLayer, + ), + ), + home: Scaffold( + key: scaffoldKey, + drawerScrimColor: scrimColor, + drawer: const Drawer( + backgroundColor: backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + width: width, + clipBehavior: clipBehavior, + ), + ), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, backgroundColor); + expect(_drawerMaterial(tester).elevation, elevation); + expect(_drawerMaterial(tester).shadowColor, shadowColor); + expect(_drawerMaterial(tester).surfaceTintColor, surfaceTintColor); + expect(_drawerMaterial(tester).shape, shape); + expect(_scrim(tester).color, scrimColor); + expect(_drawerRenderBox(tester).size.width, width); + expect(_drawerMaterial(tester).clipBehavior, clipBehavior); + }, + ); + + testWidgets( + 'DrawerTheme values take priority over ThemeData.drawerTheme values when both properties are specified', + (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const scrimColor = Color(0x00000002); + const elevation = 7.0; + const shadowColor = Color(0x00000003); + const surfaceTintColor = Color(0x00000004); + const shape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))); + const width = 200.0; + const Clip clipBehavior = Clip.antiAlias; + + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + drawerTheme: const DrawerThemeData( + backgroundColor: Color(0x00000005), + scrimColor: Color(0x00000006), + elevation: 13.0, + shadowColor: Color(0x00000007), + surfaceTintColor: Color(0x00000007), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(29.0))), + width: 400.0, + clipBehavior: Clip.antiAliasWithSaveLayer, + ), + ), + home: DrawerTheme( + data: const DrawerThemeData( + backgroundColor: backgroundColor, + scrimColor: scrimColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + shape: shape, + width: width, + clipBehavior: clipBehavior, + ), + child: Scaffold(key: scaffoldKey, drawer: const Drawer()), + ), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(_drawerMaterial(tester).color, backgroundColor); + expect(_drawerMaterial(tester).elevation, elevation); + expect(_drawerMaterial(tester).shadowColor, shadowColor); + expect(_drawerMaterial(tester).surfaceTintColor, surfaceTintColor); + expect(_drawerMaterial(tester).shape, shape); + expect(_scrim(tester).color, scrimColor); + expect(_drawerRenderBox(tester).size.width, width); + expect(_drawerMaterial(tester).clipBehavior, clipBehavior); + }, + ); +} + +Material _drawerMaterial(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(Drawer), matching: find.byType(Material)), + ); +} + +// The scrim is a Container within a Semantics node labeled "Dismiss", +// within a DrawerController. +ColoredBox _scrim(WidgetTester tester) { + return tester.widget<ColoredBox>( + find.descendant( + of: find.descendant( + of: find.byType(DrawerController), + matching: find.byWidgetPredicate((Widget widget) { + return widget is Semantics && widget.properties.label == 'Dismiss'; + }), + ), + matching: find.byType(ColoredBox), + ), + ); +} + +// The RenderBox representing the Drawer. +RenderBox _drawerRenderBox(WidgetTester tester) { + return tester.renderObject(find.byType(Drawer)); +} diff --git a/packages/material_ui/test/material/dropdown_form_field_test.dart b/packages/material_ui/test/material/dropdown_form_field_test.dart new file mode 100644 index 000000000000..a5452f503b6e --- /dev/null +++ b/packages/material_ui/test/material/dropdown_form_field_test.dart @@ -0,0 +1,1532 @@ +// 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 PointerDeviceKind; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RendererBinding; +import 'package:flutter_test/flutter_test.dart'; + +const List<String> menuItems = <String>['one', 'two', 'three', 'four']; +void onChanged<T>(T _) {} +Finder _iconRichText(Key iconKey) { + return find.descendant(of: find.byKey(iconKey), matching: find.byType(RichText)); +} + +Widget buildFormFrame({ + Key? buttonKey, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + int elevation = 8, + String? value = 'two', + ValueChanged<String?>? onChanged, + VoidCallback? onTap, + Widget? icon, + Color? iconDisabledColor, + Color? iconEnabledColor, + double iconSize = 24.0, + bool isDense = true, + bool isExpanded = false, + Widget? hint, + Widget? disabledHint, + Widget? underline, + List<String>? items = menuItems, + Alignment alignment = Alignment.center, + TextDirection textDirection = TextDirection.ltr, + AlignmentGeometry buttonAlignment = AlignmentDirectional.centerStart, +}) { + return TestApp( + textDirection: textDirection, + child: Material( + child: Align( + alignment: alignment, + child: RepaintBoundary( + child: DropdownButtonFormField<String>( + key: buttonKey, + autovalidateMode: autovalidateMode, + elevation: elevation, + initialValue: value, + hint: hint, + disabledHint: disabledHint, + onChanged: onChanged, + onTap: onTap, + icon: icon, + iconSize: iconSize, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + isDense: isDense, + isExpanded: isExpanded, + items: items?.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(), + alignment: buttonAlignment, + ), + ), + ), + ), + ); +} + +class _TestAppState extends State<TestApp> { + @override + Widget build(BuildContext context) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: MediaQuery( + data: const MediaQueryData().copyWith(size: widget.mediaSize), + child: Directionality( + textDirection: widget.textDirection, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + assert(settings.name == '/'); + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) => widget.child, + ); + }, + ), + ), + ), + ); + } +} + +class TestApp extends StatefulWidget { + const TestApp({super.key, required this.textDirection, required this.child, this.mediaSize}); + + final TextDirection textDirection; + final Widget child; + final Size? mediaSize; + + @override + State<TestApp> createState() => _TestAppState(); +} + +void verifyPaintedShadow(Finder customPaint, int elevation) { + const originalRectangle = Rect.fromLTRB(0.0, 0.0, 800, 208.0); + + final boxShadows = List<BoxShadow>.generate( + 3, + (int index) => kElevationToShadow[elevation]![index], + ); + final rrects = List<RRect>.generate(3, (int index) { + return RRect.fromRectAndRadius( + originalRectangle.shift(boxShadows[index].offset).inflate(boxShadows[index].spreadRadius), + const Radius.circular(2.0), + ); + }); + + expect( + customPaint, + paints + ..save() + ..rrect(rrect: rrects[0], color: boxShadows[0].color, hasMaskFilter: true) + ..rrect(rrect: rrects[1], color: boxShadows[1].color, hasMaskFilter: true) + ..rrect(rrect: rrects[2], color: boxShadows[2].color, hasMaskFilter: true), + ); +} + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/87102 + testWidgets('label position test - show hint', (WidgetTester tester) async { + int? value; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + hint: const Text('Hint'), + onChanged: (int? newValue) { + value = newValue; + }, + items: const <DropdownMenuItem<int?>>[ + DropdownMenuItem<int?>(value: 1, child: Text('One')), + DropdownMenuItem<int?>(value: 2, child: Text('Two')), + DropdownMenuItem<int?>(value: 3, child: Text('Three')), + ], + ), + ), + ), + ); + + expect(value, null); + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + + // Select a item. + await tester.tap(find.text('Hint'), warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.tap(find.text('One').last); + await tester.pumpAndSettle(); + + expect(value, 1); + final Offset oneValueLabel = tester.getTopLeft(find.text('labelText')); + + // The position of the label does not change. + expect(hintEmptyLabel, oneValueLabel); + }); + + testWidgets('label position test - show disabledHint: disable', (WidgetTester tester) async { + int? value; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + onChanged: null, // this disables the menu and shows the disabledHint. + disabledHint: const Text('disabledHint'), + items: const <DropdownMenuItem<int?>>[ + DropdownMenuItem<int?>(value: 1, child: Text('One')), + DropdownMenuItem<int?>(value: 2, child: Text('Two')), + DropdownMenuItem<int?>(value: 3, child: Text('Three')), + ], + ), + ), + ), + ); + + expect(value, null); // disabledHint shown. + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + expect(hintEmptyLabel, const Offset(0.0, 8.0)); + }); + + testWidgets('label position test - show disabledHint: enable + null item', ( + WidgetTester tester, + ) async { + int? value; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + disabledHint: const Text('disabledHint'), + onChanged: (_) {}, + items: null, + ), + ), + ), + ); + + expect(value, null); // disabledHint shown. + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + expect(hintEmptyLabel, const Offset(0.0, 8.0)); + }); + + testWidgets('label position test - show disabledHint: enable + empty item', ( + WidgetTester tester, + ) async { + int? value; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + disabledHint: const Text('disabledHint'), + onChanged: (_) {}, + items: const <DropdownMenuItem<int?>>[], + ), + ), + ), + ); + + expect(value, null); // disabledHint shown. + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + expect(hintEmptyLabel, const Offset(0.0, 8.0)); + }); + + testWidgets('label position test - show hint: enable + empty item', (WidgetTester tester) async { + int? value; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + hint: const Text('hint'), + onChanged: (_) {}, + items: const <DropdownMenuItem<int?>>[], + ), + ), + ), + ); + + expect(value, null); // hint shown. + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + expect(hintEmptyLabel, const Offset(0.0, 8.0)); + }); + + testWidgets('label position test - no hint shown: enable + no selected + disabledHint', ( + WidgetTester tester, + ) async { + int? value; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + disabledHint: const Text('disabledHint'), + onChanged: (_) {}, + items: const <DropdownMenuItem<int?>>[ + DropdownMenuItem<int?>(value: 1, child: Text('One')), + DropdownMenuItem<int?>(value: 2, child: Text('Two')), + DropdownMenuItem<int?>(value: 3, child: Text('Three')), + ], + ), + ), + ), + ); + + expect(value, null); + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + expect(hintEmptyLabel, const Offset(0.0, 20.0)); + }); + + testWidgets('label position test - show selected item: disabled + hint + disabledHint', ( + WidgetTester tester, + ) async { + const value = 1; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + hint: const Text('hint'), + onChanged: null, // disabled + disabledHint: const Text('disabledHint'), + items: const <DropdownMenuItem<int?>>[ + DropdownMenuItem<int?>(value: 1, child: Text('One')), + DropdownMenuItem<int?>(value: 2, child: Text('Two')), + DropdownMenuItem<int?>(value: 3, child: Text('Three')), + ], + ), + ), + ), + ); + + expect(value, 1); + final Offset hintEmptyLabel = tester.getTopLeft(find.text('labelText')); + expect(hintEmptyLabel, const Offset(0.0, 8.0)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/82910 + testWidgets('null value test', (WidgetTester tester) async { + int? value = 1; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<int?>( + decoration: const InputDecoration(labelText: 'labelText'), + initialValue: value, + onChanged: (int? newValue) { + value = newValue; + }, + items: const <DropdownMenuItem<int?>>[ + DropdownMenuItem<int?>(child: Text('None')), + DropdownMenuItem<int?>(value: 1, child: Text('One')), + DropdownMenuItem<int?>(value: 2, child: Text('Two')), + DropdownMenuItem<int?>(value: 3, child: Text('Three')), + ], + ), + ), + ), + ); + + expect(value, 1); + final Offset nonEmptyLabel = tester.getTopLeft(find.text('labelText')); + + // Switch to `null` value item from value 1. + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.text('None').last); + await tester.pump(); + + expect(value, null); + final Offset nullValueLabel = tester.getTopLeft(find.text('labelText')); + // The position of the label does not change. + expect(nonEmptyLabel, nullValueLabel); + }); + + testWidgets('DropdownButtonFormField with autovalidation test', (WidgetTester tester) async { + String? value = 'one'; + var validateCalled = 0; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: DropdownButtonFormField<String>( + initialValue: value, + hint: const Text('Select Value'), + decoration: const InputDecoration(prefixIcon: Icon(Icons.fastfood)), + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (String? newValue) { + setState(() { + value = newValue; + }); + }, + validator: (String? currentValue) { + validateCalled++; + return currentValue == null ? 'Must select value' : null; + }, + autovalidateMode: AutovalidateMode.always, + ), + ), + ); + }, + ), + ); + + expect(validateCalled, 1); + expect(value, equals('one')); + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + await tester.tap(find.text('three').last); + await tester.pump(); + expect(validateCalled, 2); + await tester.pumpAndSettle(); + expect(value, equals('three')); + }); + + testWidgets('DropdownButtonFormField arrow icon aligns with the edge of button when expanded', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + // There shouldn't be overflow when expanded although list contains longer items. + final items = <String>[ + '1234567890', + 'abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz1234567890', + ]; + + await tester.pumpWidget( + buildFormFrame( + buttonKey: buttonKey, + value: '1234567890', + isExpanded: true, + onChanged: onChanged, + items: items, + ), + ); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + expect(buttonBox.attached, isTrue); + + final RenderBox arrowIcon = tester.renderObject<RenderBox>(find.byIcon(Icons.arrow_drop_down)); + expect(arrowIcon.attached, isTrue); + + // Arrow icon should be aligned with far right of button when expanded + expect( + arrowIcon.localToGlobal(Offset.zero).dx, + buttonBox.size.centerRight(Offset(-arrowIcon.size.width, 0.0)).dx, + ); + }); + + testWidgets('DropdownButtonFormField with isDense:true aligns selected menu item', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + await tester.pumpWidget(buildFormFrame(buttonKey: buttonKey, onChanged: onChanged)); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + expect(buttonBox.attached, isTrue); + + await tester.tap(find.text('two')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + // The selected dropdown item is both in menu we just popped up, and in + // the IndexedStack contained by the dropdown button. Both of them should + // have the same vertical center as the button. + final List<RenderBox> itemBoxes = tester + .renderObjectList<RenderBox>(find.byKey(const ValueKey<String>('two'))) + .toList(); + expect(itemBoxes.length, equals(2)); + + // When isDense is true, the button's height is reduced. The menu items' + // heights are not. + final List<double> itemBoxesHeight = itemBoxes + .map<double>((RenderBox box) => box.size.height) + .toList(); + final double menuItemHeight = itemBoxesHeight.reduce(math.max); + expect(menuItemHeight, greaterThanOrEqualTo(buttonBox.size.height)); + + for (final itemBox in itemBoxes) { + expect(itemBox.attached, isTrue); + final Offset buttonBoxCenter = buttonBox.size.center(buttonBox.localToGlobal(Offset.zero)); + final Offset itemBoxCenter = itemBox.size.center(itemBox.localToGlobal(Offset.zero)); + expect(buttonBoxCenter.dy, equals(itemBoxCenter.dy)); + } + }); + + // Regression test for https://github.com/flutter/flutter/issues/159971. + testWidgets('DropdownButtonFormField does not clip large scale text', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + const value = 'two'; + const scaleFactor = 3.0; + + final List<DropdownMenuItem<String>> dropdownItems = menuItems.map<DropdownMenuItem<String>>(( + String item, + ) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(); + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: Material( + child: Center( + child: DropdownButtonFormField<String>( + key: buttonKey, + initialValue: value, + onChanged: onChanged, + items: dropdownItems, + ), + ), + ), + ); + }, + ), + ), + ); + + final BuildContext context = tester.element(find.byType(DropdownButton<String>)); + final TextStyle style = Theme.of(context).textTheme.titleMedium!; + final double lineHeight = style.fontSize! * style.height!; // 16 * 1.5 = 24 + final double labelHeight = lineHeight * scaleFactor; // 24 * 3.0 = 72 + const decorationVerticalPadding = 16.0; + final RenderBox box = tester.renderObject<RenderBox>(find.byType(DropdownButton<String>)); + expect(box.size.height, labelHeight + decorationVerticalPadding); + }); + + // Regression test for https://github.com/flutter/flutter/issues/159971. + testWidgets('DropdownButtonFormField with custom text style does not clip large scale text', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + const value = 'two'; + const scaleFactor = 3.0; + const double fontSize = 22; + const fontHeight = 1.5; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) => MediaQuery.withClampedTextScaling( + minScaleFactor: scaleFactor, + maxScaleFactor: scaleFactor, + child: Material( + child: Center( + child: DropdownButtonFormField<String>( + key: buttonKey, + initialValue: value, + onChanged: onChanged, + style: const TextStyle(fontSize: fontSize, height: fontHeight), + items: menuItems.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(), + ), + ), + ), + ), + ), + ), + ); + + const double lineHeight = fontSize * fontHeight; // 22 * 1.5 = 33 + const double labelHeight = lineHeight * scaleFactor; // 33 * 3.0 = 99 + const decorationVerticalPadding = 16.0; + final RenderBox box = tester.renderObject<RenderBox>(find.byType(DropdownButton<String>)); + expect(box.size.height, labelHeight + decorationVerticalPadding); + }); + + testWidgets('DropdownButtonFormField.isDense is true by default', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/46844 + final Key buttonKey = UniqueKey(); + const value = 'two'; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: DropdownButtonFormField<String>( + key: buttonKey, + initialValue: value, + onChanged: onChanged, + items: menuItems.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(), + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject<RenderBox>(find.byType(DropdownButton<String>)); + expect(box.size.height, 48.0); + }); + + testWidgets('DropdownButtonFormField - custom text style', (WidgetTester tester) async { + const value = 'foo'; + final itemKey = UniqueKey(); + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButtonFormField<String>( + initialValue: value, + items: <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(key: itemKey, value: 'foo', child: const Text(value)), + ], + onChanged: (_) {}, + style: const TextStyle(color: Colors.amber, fontSize: 20.0), + ), + ), + ), + ); + + final RichText richText = tester.widget<RichText>( + find.descendant(of: find.byKey(itemKey), matching: find.byType(RichText)), + ); + + expect(richText.text.style!.color, Colors.amber); + expect(richText.text.style!.fontSize, 20.0); + }); + + testWidgets( + 'DropdownButtonFormField - disabledHint displays when the items list is empty, when items is null', + (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + } + + // [disabledHint] should display when [items] is null + await tester.pumpWidget(build()); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + + // [disabledHint] should display when [items] is an empty list. + await tester.pumpWidget(build(items: <String>[])); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + }, + ); + + testWidgets('DropdownButtonFormField - hint displays when the items list is ' + 'empty, items is null, and disabledHint is null', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('hint used when disabled'), + ); + } + + // [hint] should display when [items] is null and [disabledHint] is not defined + await tester.pumpWidget(build()); + expect(find.text('hint used when disabled'), findsOneWidget); + + // [hint] should display when [items] is an empty list and [disabledHint] is not defined. + await tester.pumpWidget(build(items: <String>[])); + expect(find.text('hint used when disabled'), findsOneWidget); + }); + + testWidgets('DropdownButtonFormField - disabledHint is null by default', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('hint used when disabled'), + ); + } + + // [hint] should display when [items] is null and [disabledHint] is not defined + await tester.pumpWidget(build()); + expect(find.text('hint used when disabled'), findsOneWidget); + + // [hint] should display when [items] is an empty list and [disabledHint] is not defined. + await tester.pumpWidget(build(items: <String>[])); + expect(find.text('hint used when disabled'), findsOneWidget); + }); + + testWidgets('DropdownButtonFormField - disabledHint is null by default', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('hint used when disabled'), + ); + } + + // [hint] should display when [items] is null and [disabledHint] is not defined + await tester.pumpWidget(build()); + expect(find.text('hint used when disabled'), findsOneWidget); + + // [hint] should display when [items] is an empty list and [disabledHint] is not defined. + await tester.pumpWidget(build(items: <String>[])); + expect(find.text('hint used when disabled'), findsOneWidget); + }); + + testWidgets('DropdownButtonFormField - disabledHint displays when onChanged is null', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items, ValueChanged<String?>? onChanged}) { + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + onChanged: onChanged, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + } + + await tester.pumpWidget(build(items: menuItems)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + }); + + testWidgets('DropdownButtonFormField - disabled hint should be of same size as enabled hint', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFormFrame( + items: items, + buttonKey: buttonKey, + value: null, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + } + + await tester.pumpWidget(build()); + final RenderBox disabledHintBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + + await tester.pumpWidget(build(items: menuItems)); + final RenderBox enabledHintBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + expect( + enabledHintBox.localToGlobal(Offset.zero), + equals(disabledHintBox.localToGlobal(Offset.zero)), + ); + expect(enabledHintBox.size, equals(disabledHintBox.size)); + }); + + testWidgets('DropdownButtonFormField - Custom icon size and colors', (WidgetTester tester) async { + final Key iconKey = UniqueKey(); + final customIcon = Icon(Icons.assessment, key: iconKey); + + await tester.pumpWidget( + buildFormFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + onChanged: onChanged, + ), + ); + + // test for size + final RenderBox icon = tester.renderObject(find.byKey(iconKey)); + expect(icon.size, const Size(30.0, 30.0)); + + // test for enabled color + final RichText enabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(enabledRichText.text.style!.color, Colors.pink); + + // test for disabled color + await tester.pumpWidget( + buildFormFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + items: null, + ), + ); + + final RichText disabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(disabledRichText.text.style!.color, Colors.orange); + }); + + testWidgets('DropdownButtonFormField - default elevation', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + debugDisableShadows = false; + await tester.pumpWidget(buildFormFrame(buttonKey: buttonKey, onChanged: onChanged)); + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + final Finder customPaint = find + .ancestor(of: find.text('one').last, matching: find.byType(CustomPaint)) + .last; + + // Verifying whether or not default elevation(i.e. 8) paints desired shadow + verifyPaintedShadow(customPaint, 8); + debugDisableShadows = true; + }); + + testWidgets('DropdownButtonFormField - custom elevation', (WidgetTester tester) async { + debugDisableShadows = false; + final Key buttonKeyOne = UniqueKey(); + final Key buttonKeyTwo = UniqueKey(); + + await tester.pumpWidget( + buildFormFrame(buttonKey: buttonKeyOne, elevation: 16, onChanged: onChanged), + ); + await tester.tap(find.byKey(buttonKeyOne)); + await tester.pumpAndSettle(); + + final Finder customPaintOne = find + .ancestor(of: find.text('one').last, matching: find.byType(CustomPaint)) + .last; + + verifyPaintedShadow(customPaintOne, 16); + await tester.tap(find.text('one').last); + await tester.pumpWidget( + buildFormFrame(buttonKey: buttonKeyTwo, elevation: 24, onChanged: onChanged), + ); + await tester.tap(find.byKey(buttonKeyTwo)); + await tester.pumpAndSettle(); + + final Finder customPaintTwo = find + .ancestor(of: find.text('one').last, matching: find.byType(CustomPaint)) + .last; + + verifyPaintedShadow(customPaintTwo, 24); + debugDisableShadows = true; + }); + + testWidgets('DropdownButtonFormField does not allow duplicate item values', ( + WidgetTester tester, + ) async { + final List<DropdownMenuItem<String>> itemsWithDuplicateValues = <String>['a', 'b', 'c', 'c'] + .map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }) + .toList(); + + await expectLater( + () => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButtonFormField<String>( + initialValue: 'c', + onChanged: (String? newValue) {}, + items: itemsWithDuplicateValues, + ), + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains("There should be exactly one item with [DropdownButton]'s value"), + ), + ), + ); + }); + + testWidgets('DropdownButtonFormField value should only appear in one menu item', ( + WidgetTester tester, + ) async { + final List<DropdownMenuItem<String>> itemsWithDuplicateValues = <String>['a', 'b', 'c', 'd'] + .map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }) + .toList(); + + await expectLater( + () => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: 'e', + onChanged: (String? newValue) {}, + items: itemsWithDuplicateValues, + ), + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains("There should be exactly one item with [DropdownButton]'s value"), + ), + ), + ); + }); + + testWidgets('DropdownButtonFormField - selectedItemBuilder builds custom buttons', ( + WidgetTester tester, + ) async { + const items = <String>['One', 'Two', 'Three']; + String? selectedItem = items[0]; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + body: DropdownButtonFormField<String>( + initialValue: selectedItem, + onChanged: (String? string) => setState(() => selectedItem = string), + selectedItemBuilder: (BuildContext context) { + var index = 0; + return items.map((String string) { + index += 1; + return Text('$string as an Arabic numeral: $index'); + }).toList(); + }, + items: items.map((String string) { + return DropdownMenuItem<String>(value: string, child: Text(string)); + }).toList(), + ), + ), + ); + }, + ), + ); + + expect(find.text('One as an Arabic numeral: 1'), findsOneWidget); + await tester.tap(find.text('One as an Arabic numeral: 1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + expect(find.text('Two as an Arabic numeral: 2'), findsOneWidget); + }); + + testWidgets('DropdownButton onTap callback is called when defined', (WidgetTester tester) async { + var dropdownButtonTapCounter = 0; + String? value = 'one'; + void onChanged(String? newValue) { + value = newValue; + } + + void onTap() { + dropdownButtonTapCounter += 1; + } + + Widget build() => buildFormFrame(value: value, onChanged: onChanged, onTap: onTap); + await tester.pumpWidget(build()); + + expect(dropdownButtonTapCounter, 0); + + // Tap dropdown button. + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + + expect(value, equals('one')); + expect(dropdownButtonTapCounter, 1); // Should update counter. + + // Tap dropdown menu item. + await tester.tap(find.text('three').last); + await tester.pumpAndSettle(); + + expect(value, equals('three')); + expect(dropdownButtonTapCounter, 1); // Should not change. + + // Tap dropdown button again. + await tester.tap(find.text('three')); + await tester.pumpAndSettle(); + + expect(value, equals('three')); + expect(dropdownButtonTapCounter, 2); // Should update counter. + + // Tap dropdown menu item. + await tester.tap(find.text('two').last); + await tester.pumpAndSettle(); + + expect(value, equals('two')); + expect(dropdownButtonTapCounter, 2); // Should not change. + }); + + testWidgets('DropdownButtonFormField should re-render if initialValue parameter changes', ( + WidgetTester tester, + ) async { + var currentValue = 'two'; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: DropdownButtonFormField<String>( + initialValue: currentValue, + onChanged: onChanged, + items: menuItems.map((String value) { + return DropdownMenuItem<String>( + value: value, + child: Text(value), + onTap: () { + setState(() { + currentValue = value; + }); + }, + ); + }).toList(), + ), + ), + ); + }, + ), + ); + + // Make sure the rendered text value matches the initial state value. + expect(currentValue, equals('two')); + expect(find.text(currentValue), findsOneWidget); + + // Tap the DropdownButtonFormField widget + await tester.tap(find.byType(DropdownButton<String>)); + await tester.pumpAndSettle(); + + // Tap the first dropdown menu item. + await tester.tap(find.text('one').last); + await tester.pumpAndSettle(); + + // Make sure the rendered text value matches the updated state value. + expect(currentValue, equals('one')); + expect(find.text(currentValue), findsOneWidget); + }); + + testWidgets('autovalidateMode is passed to super', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DropdownButtonFormField<String>( + autovalidateMode: AutovalidateMode.always, + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: onChanged, + validator: (String? value) { + validateCalled++; + return null; + }, + ), + ), + ), + ), + ); + + expect(validateCalled, 1); + }); + + testWidgets('DropdownButtonFormField - Custom button alignment', (WidgetTester tester) async { + await tester.pumpWidget( + buildFormFrame( + buttonAlignment: AlignmentDirectional.center, + items: <String>['one'], + value: 'one', + ), + ); + + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byType(IndexedStack)); + final RenderBox selectedItemBox = tester.renderObject(find.text('one')); + + // Should be center-center aligned. + expect( + buttonBox.localToGlobal(Offset(buttonBox.size.width / 2.0, buttonBox.size.height / 2.0)), + selectedItemBox.localToGlobal( + Offset(selectedItemBox.size.width / 2.0, selectedItemBox.size.height / 2.0), + ), + ); + }); + + testWidgets('DropdownButtonFormField onChanged is called when the form is reset', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/123009. + final stateKey = GlobalKey<FormFieldState<String>>(); + final formKey = GlobalKey<FormState>(); + String? value; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Form( + key: formKey, + child: DropdownButtonFormField<String>( + key: stateKey, + initialValue: 'One', + items: <String>['One', 'Two', 'Free', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (String? newValue) { + value = newValue; + }, + ), + ), + ), + ), + ); + + // Initial value is 'One'. + expect(value, isNull); + expect(stateKey.currentState!.value, equals('One')); + + // Select 'Two'. + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Two').last); + await tester.pumpAndSettle(); + expect(value, equals('Two')); + expect(stateKey.currentState!.value, equals('Two')); + + // Should be back to 'One' when the form is reset. + formKey.currentState!.reset(); + expect(value, equals('One')); + expect(stateKey.currentState!.value, equals('One')); + }); + + testWidgets('DropdownButtonFormField with onChanged set to null does not throw on form reset', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/146335. + final stateKey = GlobalKey<FormFieldState<String>>(); + final formKey = GlobalKey<FormState>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Form( + key: formKey, + child: DropdownButtonFormField<String>( + key: stateKey, + initialValue: 'One', + items: <String>['One', 'Two', 'Free', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: null, + ), + ), + ), + ), + ); + + // Reset the form. + formKey.currentState!.reset(); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/106659. + testWidgets('Error visual logic is delegated to InputDecorator', (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Form( + key: formKey, + child: DropdownButtonFormField<String>( + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: onChanged, + validator: (String? v) => 'Required', + onTap: () { + formKey.currentState!.validate(); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(InputDecorator)); + await tester.pump(); + + // Check InputDecorator state because DropdownButtonFormField delegates + // visual logic to the InputDecorator. + final InputDecorator inputDecorator = tester.widget(find.byType(InputDecorator)); + expect(inputDecorator.isFocused, true); + expect(inputDecorator.decoration.errorText, 'Required'); + }); + + // Regression test for https://github.com/flutter/flutter/issues/135292. + testWidgets('Widget returned by errorBuilder is shown', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DropdownButtonFormField<String>( + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: onChanged, + autovalidateMode: AutovalidateMode.always, + validator: (String? v) => 'Required', + errorBuilder: (BuildContext context, String errorText) => Text('**$errorText**'), + ), + ), + ), + ); + + await tester.pump(); + + expect(find.text('**Required**'), findsOneWidget); + }); + + testWidgets('ButtonTheme.alignedDropdown does not affect the field content position', ( + WidgetTester tester, + ) async { + Widget buildFrame({required bool alignedDropdown, required TextDirection textDirection}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: ButtonTheme( + alignedDropdown: alignedDropdown, + child: Material( + child: DropdownButtonFormField<String>( + initialValue: menuItems.first, + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: onChanged, + ), + ), + ), + ), + ); + } + + final Finder findSelectedValue = find.text(menuItems.first).first; + + await tester.pumpWidget(buildFrame(alignedDropdown: false, textDirection: TextDirection.ltr)); + Rect contentRectForUnalignedDropdown = tester.getRect(findSelectedValue); + + // When alignedDropdown is true, the content should be at the same position. + await tester.pumpWidget(buildFrame(alignedDropdown: true, textDirection: TextDirection.ltr)); + expect(tester.getRect(findSelectedValue), contentRectForUnalignedDropdown); + + await tester.pumpWidget(buildFrame(alignedDropdown: false, textDirection: TextDirection.rtl)); + contentRectForUnalignedDropdown = tester.getRect(findSelectedValue); + + // When alignedDropdown is true, the content should be at the same position. + await tester.pumpWidget(buildFrame(alignedDropdown: true, textDirection: TextDirection.rtl)); + expect(tester.getRect(findSelectedValue), contentRectForUnalignedDropdown); + }); + + testWidgets('isValid returns false when forceErrorText is set and changes error display', ( + WidgetTester tester, + ) async { + final fieldKey1 = GlobalKey<FormFieldState<String>>(); + final fieldKey2 = GlobalKey<FormFieldState<String>>(); + const forceErrorText = 'Forcing error.'; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: ListView( + children: <Widget>[ + DropdownButtonFormField<String>( + key: fieldKey1, + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: null, + autovalidateMode: AutovalidateMode.disabled, + ), + DropdownButtonFormField<String>( + key: fieldKey2, + items: menuItems.map((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + forceErrorText: forceErrorText, + onChanged: onChanged, + autovalidateMode: AutovalidateMode.disabled, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + expect(fieldKey1.currentState!.isValid, isTrue); + expect(fieldKey1.currentState!.hasError, isFalse); + expect(fieldKey2.currentState!.isValid, isFalse); + expect(fieldKey2.currentState!.hasError, isTrue); + }); + + testWidgets('forceErrorText overrides InputDecoration.error when both are provided', ( + WidgetTester tester, + ) async { + const forceErrorText = 'Forcing error'; + const decorationErrorText = 'Decoration'; + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: DropdownButtonFormField<String>( + items: menuItems.map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + decoration: const InputDecoration(errorText: decorationErrorText), + forceErrorText: forceErrorText, + onChanged: null, + ), + ), + ), + ), + ), + ), + ), + ); + + expect(find.text(forceErrorText), findsOne); + expect(find.text(decorationErrorText), findsNothing); + }); + + testWidgets('dropdown closes when barrier is tapped by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButtonFormField<String>( + initialValue: 'first', + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(enabled: false, child: Text('disabled')), + DropdownMenuItem<String>(value: 'first', child: Text('first')), + DropdownMenuItem<String>(value: 'second', child: Text('second')), + ], + onChanged: (_) {}, + ), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('first').hitTestable()); + await tester.pumpAndSettle(); + + // Tap on the barrier. + await tester.tapAt(const Offset(400, 400)); + await tester.pumpAndSettle(); + + // The dropdown should be closed, i.e., there should be no widget with 'second' text. + expect(find.text('second'), findsNothing); + }); + + testWidgets('dropdown does not close when barrier dismissible set to false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButtonFormField<String>( + initialValue: 'first', + barrierDismissible: false, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(enabled: false, child: Text('disabled')), + DropdownMenuItem<String>(value: 'first', child: Text('first')), + DropdownMenuItem<String>(value: 'second', child: Text('second')), + ], + onChanged: (_) {}, + ), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('first').hitTestable()); + await tester.pumpAndSettle(); + + // Tap on the barrier. + await tester.tapAt(const Offset(400, 400)); + await tester.pumpAndSettle(); + + // The dropdown should still be open, i.e., there should be one widget with 'second' text. + expect(find.text('second'), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/157074. + testWidgets('DropdownButtonFormField icon is aligned with label text', ( + WidgetTester tester, + ) async { + const labelText = 'Label Text'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField<String>( + decoration: const InputDecoration(labelText: labelText), + items: <String>['One', 'Two'].map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (String? value) {}, + ), + ), + ), + ), + ); + + expect(tester.getCenter(find.text(labelText)).dy, tester.getCenter(find.byType(Icon).last).dy); + }); + + testWidgets('DropdownFormField has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField<String>( + items: <String>['One', 'Two'].map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (String? value) {}, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.byType(DropdownButtonFormField<String>))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('DropdownButtonFormField has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField<String>( + mouseCursor: SystemMouseCursors.cell, + items: <String>['One', 'Two'].map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (String? value) {}, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter(find.byType(DropdownButtonFormField<String>)), + ); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); +} diff --git a/packages/material_ui/test/material/dropdown_menu_form_field_test.dart b/packages/material_ui/test/material/dropdown_menu_form_field_test.dart new file mode 100644 index 000000000000..f0edb286d64a --- /dev/null +++ b/packages/material_ui/test/material/dropdown_menu_form_field_test.dart @@ -0,0 +1,1583 @@ +// 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/src/services/text_formatter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +enum MenuItem { + menuItem0('Item 0'), + menuItem1('Item 1'), + menuItem2('Item 2'), + menuItem3('Item 3'); + + const MenuItem(this.label); + final String label; +} + +void main() { + final menuEntries = <DropdownMenuEntry<MenuItem>>[]; + + for (final MenuItem value in MenuItem.values) { + final entry = DropdownMenuEntry<MenuItem>(value: value, label: value.label); + menuEntries.add(entry); + } + + Finder findMenuItemButton(String label) { + // For each menu items there are two MenuItemButton widgets. + // The last one is the real button item in the menu. + // The first one is not visible, it is part of _DropdownMenuBody + // which is used to compute the dropdown width. + return find.widgetWithText(MenuItemButton, label).last; + } + + Finder findMenuItem(MenuItem menuItem) { + return findMenuItemButton(menuItem.label); + } + + testWidgets('Creates an underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + expect(find.byType(DropdownMenu<MenuItem>), findsOne); + }); + + testWidgets('Passes dropdownMenuEntries to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + final DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.dropdownMenuEntries, menuEntries); + }); + + testWidgets('Dropdown menu can be opened and contains all the items', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + for (final MenuItem item in MenuItem.values) { + expect(findMenuItem(item), findsOne); + } + }); + + testWidgets('Passes enabled to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.enabled, true); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>(enabled: false, dropdownMenuEntries: menuEntries), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.enabled, false); + }); + + testWidgets('Passes width to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.width, null); + + const width = 100.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>(width: width, dropdownMenuEntries: menuEntries), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.width, width); + }); + + testWidgets('Passes menuHeight to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.menuHeight, null); + + const menuHeight = 100.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + menuHeight: menuHeight, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.menuHeight, menuHeight); + }); + + testWidgets('Passes leadingIcon to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.leadingIcon, null); + + const leadingIcon = Icon(Icons.abc); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + leadingIcon: leadingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.leadingIcon, leadingIcon); + }); + + testWidgets('Passes trailingIcon to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.trailingIcon, null); + + const trailingIcon = Icon(Icons.abc); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + trailingIcon: trailingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.trailingIcon, trailingIcon); + }); + + testWidgets('Passes showTrailingIcon to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.showTrailingIcon, true); + + const showTrailingIcon = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + showTrailingIcon: showTrailingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.showTrailingIcon, showTrailingIcon); + }); + + testWidgets('Passes trailingIconFocusNode to underlying DropdownMenu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.trailingIconFocusNode, null); + + final trailingIconFocusNode = FocusNode(); + addTearDown(trailingIconFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + trailingIconFocusNode: trailingIconFocusNode, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.trailingIconFocusNode, trailingIconFocusNode); + }); + + testWidgets('Passes label to underlying InputDecoration', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.label, null); + + const Widget label = Text('Label'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>(label: label, dropdownMenuEntries: menuEntries), + ), + ), + ); + + textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.label, label); + }); + + testWidgets('Passes hintText to underlying InputDecoration', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.hintText, null); + + const hintText = 'Hint'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + hintText: hintText, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.hintText, hintText); + }); + + testWidgets('Passes helperText to underlying InputDecoration', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.helperText, null); + + const helperText = 'Hint'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + helperText: helperText, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.helperText, helperText); + }); + + testWidgets('Passes selectedTrailingIcon to underlying DropdownMenu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.selectedTrailingIcon, null); + + const selectedTrailingIcon = Icon(Icons.abc); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + selectedTrailingIcon: selectedTrailingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.selectedTrailingIcon, selectedTrailingIcon); + }); + + testWidgets('Passes enableFilter to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.enableFilter, false); + + const enableFilter = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + enableFilter: enableFilter, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.enableFilter, enableFilter); + }); + + testWidgets('Passes enableSearch to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.enableSearch, true); + + const enableSearch = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + enableSearch: enableSearch, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.enableSearch, enableSearch); + }); + + testWidgets('Passes keyboardType to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.keyboardType, null); + + const TextInputType keyboardType = TextInputType.datetime; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + keyboardType: keyboardType, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.keyboardType, keyboardType); + }); + + testWidgets('Passes textStyle to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.textStyle, null); + + const textStyle = TextStyle(fontWeight: FontWeight.bold); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + textStyle: textStyle, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.textStyle, textStyle); + }); + + testWidgets('Passes textAlign to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.textAlign, TextAlign.start); + + const TextAlign textAlign = TextAlign.center; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + textAlign: textAlign, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.textAlign, textAlign); + }); + + testWidgets('Passes inputDecorationTheme to underlying DropdownMenu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.inputDecorationTheme, null); + + const inputDecorationTheme = InputDecorationThemeData(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + inputDecorationTheme: inputDecorationTheme, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.inputDecorationTheme, inputDecorationTheme); + }); + + testWidgets('Calls the decoration builder to create the InputDecoration', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + var decorationBuilderCalled = false; + InputDecoration buildDecoration(BuildContext context, MenuController controller) { + decorationBuilderCalled = true; + return const InputDecoration(labelText: 'labelText'); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + decorationBuilder: buildDecoration, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + // The decoration builder should have been called. + expect(decorationBuilderCalled, true); + + // The decoration label is the one provided by the decoration builder. + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.labelText, 'labelText'); + }); + + testWidgets('Passes menuStyle to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.menuStyle, null); + + const menuStyle = MenuStyle(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + menuStyle: menuStyle, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.menuStyle, menuStyle); + }); + + testWidgets('Passes controller to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + final controller = TextEditingController(); + addTearDown(controller.dispose); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.controller, isNotNull); // A default controller is created. + expect(dropdownMenu.controller, isNot(controller)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + controller: controller, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.controller, controller); + }); + + testWidgets('Passes focusNode to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.focusNode, null); + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + focusNode: focusNode, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.focusNode, focusNode); + }); + + testWidgets('Passes requestFocusOnTap to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.requestFocusOnTap, null); + + const requestFocusOnTap = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + requestFocusOnTap: requestFocusOnTap, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.requestFocusOnTap, requestFocusOnTap); + }); + + testWidgets('Passes selectOnly to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.selectOnly, false); + + const selectOnly = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + selectOnly: selectOnly, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.selectOnly, selectOnly); + }); + + testWidgets('Passes expandedInsets to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.expandedInsets, null); + + const EdgeInsetsGeometry expandedInsets = EdgeInsets.zero; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + expandedInsets: expandedInsets, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.expandedInsets, expandedInsets); + }); + + testWidgets('Passes alignmentOffset to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.alignmentOffset, null); + + const Offset alignmentOffset = Offset.zero; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + alignmentOffset: alignmentOffset, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.alignmentOffset, alignmentOffset); + }); + + testWidgets('Passes filterCallback to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + enableFilter: true, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.filterCallback, null); + + List<DropdownMenuEntry<MenuItem>> filterCallback( + List<DropdownMenuEntry<MenuItem>> entries, + String filter, + ) { + return entries; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + enableFilter: true, + filterCallback: filterCallback, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.filterCallback, filterCallback); + }); + + testWidgets('Passes searchCallback to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.searchCallback, null); + + int searchCallback(List<DropdownMenuEntry<MenuItem>> entries, String filter) { + return 0; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + searchCallback: searchCallback, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.searchCallback, searchCallback); + }); + + testWidgets('Passes inputFormatters to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.inputFormatters, null); + + final inputFormatters = <TextInputFormatter>[]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + inputFormatters: inputFormatters, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.inputFormatters, inputFormatters); + }); + + testWidgets('Passes closeBehavior to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.closeBehavior, DropdownMenuCloseBehavior.all); + + const DropdownMenuCloseBehavior closeBehavior = DropdownMenuCloseBehavior.self; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + closeBehavior: closeBehavior, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.closeBehavior, closeBehavior); + }); + + testWidgets('Passes maxLines to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.maxLines, 1); + + const maxLines = 3; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + maxLines: maxLines, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.maxLines, maxLines); + }); + + testWidgets('Passes textInputAction to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.textInputAction, null); + + const TextInputAction textInputAction = TextInputAction.emergencyCall; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + textInputAction: textInputAction, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.textInputAction, textInputAction); + }); + + testWidgets('Passes cursorHeight to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.cursorHeight, null); + + const cursorHeight = 4.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + cursorHeight: cursorHeight, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.cursorHeight, cursorHeight); + }); + + testWidgets('Passes menuController to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.menuController, null); + + final menuController = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + menuController: menuController, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.menuController, menuController); + }); + + testWidgets('Passes restorationId to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu<MenuItem> dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.restorationId, null); + + const restorationId = 'dropdown_menu'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + restorationId: restorationId, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + expect(find.byType(TextField), findsOne); + + dropdownMenu = tester.widget(find.byType(DropdownMenu<MenuItem>)); + expect(dropdownMenu.restorationId, restorationId); + }); + + testWidgets('Field state is correctly updated', (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + }); + + testWidgets('onSaved callback is called when the field is outside a Form', ( + WidgetTester tester, + ) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + MenuItem? savedValue = MenuItem.menuItem0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: savedValue, + onSaved: (MenuItem? newValue) => savedValue = newValue, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem0); + + fieldKey.currentState!.save(); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem1); + }); + + testWidgets('onSaved callback is called when the field is inside a Form', ( + WidgetTester tester, + ) async { + final formKey = GlobalKey<FormState>(); + + MenuItem? savedValue = MenuItem.menuItem0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: DropdownMenuFormField<MenuItem>( + dropdownMenuEntries: menuEntries, + initialSelection: savedValue, + onSaved: (MenuItem? newValue) => savedValue = newValue, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem0); + + formKey.currentState!.save(); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem1); + }); + + testWidgets('Field can be reset', (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + MenuItem? savedValue = MenuItem.menuItem0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: savedValue, + onSaved: (MenuItem? newValue) => savedValue = newValue, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + + fieldKey.currentState!.reset(); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/174578. + testWidgets( + 'Inner text field is cleared on reset when initialSelection is null - Default controller', + (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>(key: fieldKey, dropdownMenuEntries: menuEntries), + ), + ), + ); + + final TextField textField = tester.widget(find.byType(TextField)); + + // Select menuItem1. + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + expect( + textField.controller?.value, + const TextEditingValue(text: 'Item 1', selection: TextSelection.collapsed(offset: 6)), + ); + + // After reset the text field content is cleared. + fieldKey.currentState!.reset(); + await tester.pump(); + + expect(fieldKey.currentState!.value, null); + expect( + textField.controller?.value, + const TextEditingValue(selection: TextSelection.collapsed(offset: 0)), + ); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/174578. + testWidgets( + 'Inner text field is cleared on reset when initialSelection is null - Custom controller', + (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + controller: controller, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + // Custom controller is correctly passed to the inner TextField. + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.controller, controller); + + // Select menuItem1. + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + expect( + textField.controller?.value, + const TextEditingValue(text: 'Item 1', selection: TextSelection.collapsed(offset: 6)), + ); + + // After reset the text field content is cleared. + fieldKey.currentState!.reset(); + await tester.pump(); + + expect(fieldKey.currentState!.value, null); + expect( + controller.value, + const TextEditingValue(selection: TextSelection.collapsed(offset: 0)), + ); + }, + ); + + testWidgets('isValid and hasError results are correct', (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + autovalidateMode: AutovalidateMode.always, + ), + ), + ), + ); + + // No validation error. + expect(fieldKey.currentState!.isValid, true); + expect(fieldKey.currentState!.hasError, false); + + const validationError = 'Required'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + autovalidateMode: AutovalidateMode.always, + validator: (MenuItem? item) => validationError, + ), + ), + ), + ); + + // Validation error. + expect(fieldKey.currentState!.isValid, false); + expect(fieldKey.currentState!.hasError, true); + }); + + testWidgets('Validation result is shown as error text', (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + const validationError = 'Required'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + autovalidateMode: AutovalidateMode.always, + validator: (MenuItem? item) => validationError, + ), + ), + ), + ); + + fieldKey.currentState!.validate(); + await tester.pump(); + + expect(find.text(validationError), findsOneWidget); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.errorText, validationError); + }); + + testWidgets('Validation result is shown as error widget created by errorBuilder', ( + WidgetTester tester, + ) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + const validationError = 'Required'; + final errorKey = UniqueKey(); + var errorBuilderCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + validator: (MenuItem? item) => validationError, + errorBuilder: (context, errorText) { + errorBuilderCalled = true; + return Text(errorText, key: errorKey); + }, + ), + ), + ), + ); + + expect(errorBuilderCalled, false); + expect(find.byKey(errorKey), findsNothing); + + fieldKey.currentState!.validate(); + await tester.pump(); + + expect(errorBuilderCalled, true); + expect(find.byKey(errorKey), findsOneWidget); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.error?.key, errorKey); + }); + + testWidgets('Initial selection is applied', (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + }); + + testWidgets( + 'Initial selection is applied when updated and the field has not been updated in-between', + (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem1, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + }, + ); + + testWidgets( + 'Initial selection is not applied when updated and the field has been updated in-between', + (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + + // Select a different item than the initial one. + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem2)); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem2); + + // Update initial selection. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem1, + ), + ), + ), + ); + + // The value selected by the user is preserved. + expect(fieldKey.currentState!.value, MenuItem.menuItem2); + }, + ); + + testWidgets('Selected value is restorable', (WidgetTester tester) async { + final formFieldState = GlobalKey<FormFieldState<MenuItem>>(); + const restorationId = 'dropdown_menu_form_field'; + + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: formFieldState, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + restorationId: restorationId, + ), + ), + ), + ); + + expect(formFieldState.currentState!.value, MenuItem.menuItem0); + + // Select a different item than the initial one. + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem2)); + await tester.pump(); + + expect(formFieldState.currentState!.value, MenuItem.menuItem2); + + // Needed for restoration data to be updated. + await tester.pump(); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + + expect(formFieldState.currentState!.value, MenuItem.menuItem2); + + formFieldState.currentState!.reset(); + expect(formFieldState.currentState!.value, MenuItem.menuItem0); + + await tester.restoreFrom(data); + await tester.pump(); + + expect(formFieldState.currentState!.value, MenuItem.menuItem2); + }); + + testWidgets('onSelect is called exactly once when a selection is made.', ( + WidgetTester tester, + ) async { + var onSelectedCallCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + onSelected: (MenuItem? value) { + onSelectedCallCount++; + }, + ), + ), + ), + ); + // Select a different item than the initial one. + await tester.tap(find.byType(DropdownMenu<MenuItem>)); + await tester.pump(); + await tester.tap(findMenuItem(MenuItem.menuItem2)); + await tester.pump(); + + expect(onSelectedCallCount, 1); + }); + + testWidgets('onSelect is called exactly once when reset', (WidgetTester tester) async { + var onSelectedCallCount = 0; + final fieldKey = GlobalKey<FormFieldState<MenuItem>>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField<MenuItem>( + key: fieldKey, + dropdownMenuEntries: menuEntries, + onSelected: (MenuItem? value) { + onSelectedCallCount++; + }, + ), + ), + ), + ); + + fieldKey.currentState!.reset(); + await tester.pump(); + expect(onSelectedCallCount, 1); + }); + + testWidgets('DropdownMenuFormField does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: DropdownMenuFormField<MenuItem>(dropdownMenuEntries: menuEntries), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DropdownMenuFormField<MenuItem>)), Size.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/180121. + testWidgets('Allow null entry to clear selection', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + const selectNoneLabel = 'Select none'; + final nullableMenuItems = <DropdownMenuEntry<String?>>[ + const DropdownMenuEntry<String?>(value: null, label: selectNoneLabel), + const DropdownMenuEntry<String?>(value: 'a', label: 'A'), + const DropdownMenuEntry<String?>(value: 'b', label: 'B'), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownMenuFormField<String?>( + controller: controller, + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: nullableMenuItems, + onSelected: (_) { + setState(() {}); + }, + ); + }, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenuFormField<String?>)); + await tester.pump(); + + // Select the 'None' item. + await tester.tap(findMenuItemButton(selectNoneLabel)); + await tester.pumpAndSettle(); + + expect(controller.text, selectNoneLabel); + }); +} diff --git a/packages/material_ui/test/material/dropdown_menu_test.dart b/packages/material_ui/test/material/dropdown_menu_test.dart new file mode 100644 index 000000000000..f0f650864df6 --- /dev/null +++ b/packages/material_ui/test/material/dropdown_menu_test.dart @@ -0,0 +1,5456 @@ +// 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:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + const longText = 'one two three four five six seven eight nine ten eleven twelve'; + final menuChildren = <DropdownMenuEntry<TestMenu>>[]; + final menuChildrenWithIcons = <DropdownMenuEntry<TestMenu>>[]; + const leadingIconToInputPadding = 4.0; + + for (final TestMenu value in TestMenu.values) { + final entry = DropdownMenuEntry<TestMenu>(value: value, label: value.label); + menuChildren.add(entry); + } + + ValueKey<String> leadingIconKey(TestMenu menuEntry) => + ValueKey<String>('leading-${menuEntry.label}'); + ValueKey<String> trailingIconKey(TestMenu menuEntry) => + ValueKey<String>('trailing-${menuEntry.label}'); + + for (final TestMenu value in TestMenu.values) { + final entry = DropdownMenuEntry<TestMenu>( + value: value, + label: value.label, + leadingIcon: Icon(key: leadingIconKey(value), Icons.alarm), + trailingIcon: Icon(key: trailingIconKey(value), Icons.abc), + ); + menuChildrenWithIcons.add(entry); + } + + Widget buildTest<T extends Enum>( + ThemeData themeData, + List<DropdownMenuEntry<T>> entries, { + double? width, + double? menuHeight, + Widget? leadingIcon, + Widget? label, + InputDecorationTheme? decorationTheme, + }) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<T>( + label: label, + leadingIcon: leadingIcon, + width: width, + menuHeight: menuHeight, + dropdownMenuEntries: entries, + inputDecorationTheme: decorationTheme, + ), + ), + ); + } + + Finder findMenuItemButton(String label) { + // For each menu items there are two MenuItemButton widgets. + // The last one is the real button item in the menu. + // The first one is not visible, it is part of _DropdownMenuBody + // which is used to compute the dropdown width. + return find.widgetWithText(MenuItemButton, label).last; + } + + Material getButtonMaterial(WidgetTester tester, String itemLabel) { + return tester.widget<Material>( + find.descendant(of: findMenuItemButton(itemLabel), matching: find.byType(Material)), + ); + } + + bool isItemHighlighted(WidgetTester tester, ThemeData themeData, String itemLabel) { + final Color? color = getButtonMaterial(tester, itemLabel).color; + return color == themeData.colorScheme.onSurface.withOpacity(0.12); + } + + Finder findMenuPanel() { + return find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MenuPanel'); + } + + Finder findMenuMaterial() { + return find.descendant(of: findMenuPanel(), matching: find.byType(Material)).first; + } + + testWidgets('DropdownMenu defaults', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, themeData.textTheme.bodyLarge!.color); + expect(editableText.style.background, themeData.textTheme.bodyLarge!.background); + expect(editableText.style.shadows, themeData.textTheme.bodyLarge!.shadows); + expect(editableText.style.decoration, themeData.textTheme.bodyLarge!.decoration); + expect(editableText.style.locale, themeData.textTheme.bodyLarge!.locale); + expect(editableText.style.wordSpacing, themeData.textTheme.bodyLarge!.wordSpacing); + expect(editableText.style.fontSize, 16.0); + expect(editableText.style.height, 1.5); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.border, const OutlineInputBorder()); + expect(textField.style?.fontSize, 16.0); + expect(textField.style?.height, 1.5); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + Material material = tester.widget<Material>(findMenuMaterial()); + expect(material.color, themeData.colorScheme.surfaceContainer); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 3.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + material = getButtonMaterial(tester, TestMenu.mainMenu0.label); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface); + expect(material.textStyle?.fontSize, 14.0); + expect(material.textStyle?.height, 1.43); + }); + + group('Item style', () { + const focusedBackgroundColor = Color(0xffff0000); + const focusedForegroundColor = Color(0xff00ff00); + const focusedIconColor = Color(0xff0000ff); + const focusedOverlayColor = Color(0xffff00ff); + const defaultBackgroundColor = Color(0xff00ffff); + const defaultForegroundColor = Color(0xff000000); + const defaultIconColor = Color(0xffffffff); + const defaultOverlayColor = Color(0xffffff00); + + final customButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusedBackgroundColor; + } + return defaultBackgroundColor; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusedForegroundColor; + } + return defaultForegroundColor; + }), + iconColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusedIconColor; + } + return defaultIconColor; + }), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusedOverlayColor; + } + return defaultOverlayColor; + }), + ); + + final styledMenuEntries = <DropdownMenuEntry<TestMenu>>[]; + for (final entryWithIcons in menuChildrenWithIcons) { + styledMenuEntries.add( + DropdownMenuEntry<TestMenu>( + value: entryWithIcons.value, + label: entryWithIcons.label, + leadingIcon: entryWithIcons.leadingIcon, + trailingIcon: entryWithIcons.trailingIcon, + style: customButtonStyle, + ), + ); + } + + TextStyle? iconStyle(WidgetTester tester, Key key) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byKey(key), matching: find.byType(RichText)).last, + ); + return iconRichText.text.style; + } + + RenderObject overlayPainter(WidgetTester tester, TestMenu menuItem) { + return tester.renderObject( + find + .descendant( + of: findMenuItemButton(menuItem.label), + matching: find.byElementPredicate( + (Element element) => + element.renderObject.runtimeType.toString() == '_RenderInkFeatures', + ), + ) + .last, + ); + } + + testWidgets('defaults are correct', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: selectedItem, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12)); + expect(selectedButtonMaterial.textStyle?.color, themeData.colorScheme.onSurface); + expect( + iconStyle(tester, leadingIconKey(selectedItem))?.color, + themeData.colorScheme.onSurfaceVariant, + ); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, Colors.transparent); + expect(nonSelectedButtonMaterial.textStyle?.color, themeData.colorScheme.onSurface); + expect( + iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, + themeData.colorScheme.onSurfaceVariant, + ); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(findMenuItemButton(selectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: themeData.colorScheme.onSurface.withOpacity(0.1).withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(findMenuItemButton(nonSelectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: themeData.colorScheme.onSurface.withOpacity(0.08).withAlpha(0)), + ); + }); + + testWidgets('can be overridden at application theme level', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(menuButtonTheme: MenuButtonThemeData(style: customButtonStyle)), + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: selectedItem, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, focusedBackgroundColor); + expect(selectedButtonMaterial.textStyle?.color, focusedForegroundColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, defaultBackgroundColor); + expect(nonSelectedButtonMaterial.textStyle?.color, defaultForegroundColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(findMenuItemButton(selectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(findMenuItemButton(nonSelectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + testWidgets('can be overridden at menu entry level', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: selectedItem, + dropdownMenuEntries: styledMenuEntries, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, focusedBackgroundColor); + expect(selectedButtonMaterial.textStyle?.color, focusedForegroundColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, defaultBackgroundColor); + expect(nonSelectedButtonMaterial.textStyle?.color, defaultForegroundColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(findMenuItemButton(selectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(findMenuItemButton(nonSelectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + testWidgets('defined at menu entry level takes precedence', (WidgetTester tester) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + const luckyColor = Color(0xff777777); + final singleColorButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.all(luckyColor), + foregroundColor: WidgetStateProperty.all(luckyColor), + iconColor: WidgetStateProperty.all(luckyColor), + overlayColor: WidgetStateProperty.all(luckyColor), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(menuButtonTheme: MenuButtonThemeData(style: singleColorButtonStyle)), + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: selectedItem, + dropdownMenuEntries: styledMenuEntries, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, focusedBackgroundColor); + expect(selectedButtonMaterial.textStyle?.color, focusedForegroundColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, defaultBackgroundColor); + expect(nonSelectedButtonMaterial.textStyle?.color, defaultForegroundColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(findMenuItemButton(selectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(findMenuItemButton(nonSelectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + testWidgets('defined at menu entry level and application level are merged', ( + WidgetTester tester, + ) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + const luckyColor = Color(0xff777777); + final partialButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.all(luckyColor), + foregroundColor: WidgetStateProperty.all(luckyColor), + ); + + final partiallyStyledMenuEntries = <DropdownMenuEntry<TestMenu>>[]; + for (final entryWithIcons in menuChildrenWithIcons) { + partiallyStyledMenuEntries.add( + DropdownMenuEntry<TestMenu>( + value: entryWithIcons.value, + label: entryWithIcons.label, + leadingIcon: entryWithIcons.leadingIcon, + trailingIcon: entryWithIcons.trailingIcon, + style: partialButtonStyle, + ), + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(menuButtonTheme: MenuButtonThemeData(style: customButtonStyle)), + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: selectedItem, + dropdownMenuEntries: partiallyStyledMenuEntries, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Material selectedButtonMaterial = getButtonMaterial(tester, selectedItem.label); + expect(selectedButtonMaterial.color, luckyColor); + expect(selectedButtonMaterial.textStyle?.color, luckyColor); + expect(iconStyle(tester, leadingIconKey(selectedItem))?.color, focusedIconColor); + + final Material nonSelectedButtonMaterial = getButtonMaterial(tester, nonSelectedItem.label); + expect(nonSelectedButtonMaterial.color, luckyColor); + expect(nonSelectedButtonMaterial.textStyle?.color, luckyColor); + expect(iconStyle(tester, leadingIconKey(nonSelectedItem))?.color, defaultIconColor); + + // Hover the selected item. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(findMenuItemButton(selectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, selectedItem), + paints..rect(color: focusedOverlayColor.withAlpha(0)), + ); + + // Hover a non-selected item. + await gesture.moveTo(tester.getCenter(findMenuItemButton(nonSelectedItem.label))); + await tester.pump(); + + expect( + overlayPainter(tester, nonSelectedItem), + paints..rect(color: defaultOverlayColor.withAlpha(0)), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177363. + testWidgets('textStyle property is resolved when item is highlighted', ( + WidgetTester tester, + ) async { + const TestMenu selectedItem = TestMenu.mainMenu3; + const TestMenu nonSelectedItem = TestMenu.mainMenu2; + + final customButtonStyle = ButtonStyle( + textStyle: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => TextStyle( + fontWeight: states.contains(WidgetState.focused) ? FontWeight.bold : FontWeight.normal, + ), + ), + ); + + final menuEntries = <DropdownMenuEntry<TestMenu>>[]; + for (final item in menuChildren) { + menuEntries.add( + DropdownMenuEntry<TestMenu>( + value: item.value, + label: item.label, + style: customButtonStyle, + ), + ); + } + + TextStyle? getItemLabelStyle(String label) { + final RenderObject paragraph = tester + .element<StatelessElement>( + find.descendant(of: findMenuItemButton(label), matching: find.text(label)), + ) + .renderObject!; + return (paragraph as RenderParagraph).text.style; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: selectedItem, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + expect(getItemLabelStyle(selectedItem.label)?.fontWeight, FontWeight.bold); + expect(getItemLabelStyle(nonSelectedItem.label)?.fontWeight, FontWeight.normal); + }); + }); + + testWidgets('Inner TextField is disabled when DropdownMenu is disabled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SafeArea( + child: DropdownMenu<TestMenu>(enabled: false, dropdownMenuEntries: menuChildren), + ), + ), + ), + ); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.enabled, false); + final Finder menuMaterial = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Material), + ); + expect(menuMaterial, findsNothing); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + final Finder updatedMenuMaterial = find.ancestor( + of: find.byType(SingleChildScrollView), + matching: find.byType(Material), + ); + expect(updatedMenuMaterial, findsNothing); + }); + + testWidgets('Inner IconButton is disabled when DropdownMenu is disabled', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/149598. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SafeArea( + child: DropdownMenu<TestMenu>(enabled: false, dropdownMenuEntries: menuChildren), + ), + ), + ), + ); + + final IconButton trailingButton = tester.widget( + find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first, + ); + expect(trailingButton.onPressed, null); + }); + + testWidgets( + 'Material2 - The width of the text field should always be the same as the menu view', + (WidgetTester tester) async { + final themeData = ThemeData(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: SafeArea(child: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ), + ); + + final Finder textField = find.byType(TextField); + final Size anchorSize = tester.getSize(textField); + expect(anchorSize, const Size(180.0, 56.0)); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + final Finder menuMaterial = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Material)) + .first; + final Size menuSize = tester.getSize(menuMaterial); + expect(menuSize, const Size(180.0, 304.0)); + + // The text field should have same width as the menu + // when the width property is not null. + await tester.pumpWidget(buildTest(themeData, menuChildren, width: 200.0)); + + final Finder anchor = find.byType(TextField); + final double width = tester.getSize(anchor).width; + expect(width, 200.0); + + await tester.tap(anchor); + await tester.pumpAndSettle(); + + final Finder updatedMenu = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Material)) + .first; + final double updatedMenuWidth = tester.getSize(updatedMenu).width; + expect(updatedMenuWidth, 200.0); + }, + ); + + testWidgets( + 'Material3 - The width of the text field should always be the same as the menu view', + (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: SafeArea(child: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ), + ); + + final Finder textField = find.byType(TextField); + final double anchorWidth = tester.getSize(textField).width; + expect(anchorWidth, closeTo(184.5, 0.1)); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + final Finder menuMaterial = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Material)) + .first; + final double menuWidth = tester.getSize(menuMaterial).width; + expect(menuWidth, closeTo(184.5, 0.1)); + + // The text field should have same width as the menu + // when the width property is not null. + await tester.pumpWidget(buildTest(themeData, menuChildren, width: 200.0)); + + final Finder anchor = find.byType(TextField); + final double width = tester.getSize(anchor).width; + expect(width, 200.0); + + await tester.tap(anchor); + await tester.pumpAndSettle(); + + final Finder updatedMenu = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Material)) + .first; + final double updatedMenuWidth = tester.getSize(updatedMenu).width; + expect(updatedMenuWidth, 200.0); + }, + ); + + testWidgets('The width property can customize the width of the dropdown menu', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final shortMenuItems = <DropdownMenuEntry<ShortMenu>>[]; + + for (final ShortMenu value in ShortMenu.values) { + final entry = DropdownMenuEntry<ShortMenu>(value: value, label: value.label); + shortMenuItems.add(entry); + } + + const customBigWidth = 250.0; + await tester.pumpWidget(buildTest(themeData, shortMenuItems, width: customBigWidth)); + RenderBox box = tester.firstRenderObject(find.byType(DropdownMenu<ShortMenu>)); + expect(box.size.width, customBigWidth); + + await tester.tap(find.byType(DropdownMenu<ShortMenu>)); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(6)); + Size buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, customBigWidth); + + // reset test + await tester.pumpWidget(Container()); + const customSmallWidth = 100.0; + await tester.pumpWidget(buildTest(themeData, shortMenuItems, width: customSmallWidth)); + box = tester.firstRenderObject(find.byType(DropdownMenu<ShortMenu>)); + expect(box.size.width, customSmallWidth); + + await tester.tap(find.byType(DropdownMenu<ShortMenu>)); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(6)); + buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, customSmallWidth); + }); + + testWidgets('The width property update test', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/120567 + final themeData = ThemeData(); + final shortMenuItems = <DropdownMenuEntry<ShortMenu>>[]; + + for (final ShortMenu value in ShortMenu.values) { + final entry = DropdownMenuEntry<ShortMenu>(value: value, label: value.label); + shortMenuItems.add(entry); + } + + var customWidth = 250.0; + await tester.pumpWidget(buildTest(themeData, shortMenuItems, width: customWidth)); + RenderBox box = tester.firstRenderObject(find.byType(DropdownMenu<ShortMenu>)); + expect(box.size.width, customWidth); + + // Update width + customWidth = 400.0; + await tester.pumpWidget(buildTest(themeData, shortMenuItems, width: customWidth)); + box = tester.firstRenderObject(find.byType(DropdownMenu<ShortMenu>)); + expect(box.size.width, customWidth); + }); + + testWidgets('The width is determined by the menu entries', (WidgetTester tester) async { + const double entryLabelWidth = 100; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>( + value: 0, + label: 'Flutter', + labelWidget: SizedBox(width: entryLabelWidth), + ), + ], + ), + ), + ), + ); + + final double width = tester.getSize(find.byType(DropdownMenu<int>)).width; + const menuEntryPadding = 24.0; // See _kDefaultHorizontalPadding. + const decorationStartGap = 4.0; // See _kInputStartGap. + const leadingWidth = 16.0; + const trailingWidth = 56.0; + + expect( + width, + entryLabelWidth + leadingWidth + trailingWidth + menuEntryPadding + decorationStartGap, + ); + }); + + testWidgets('The width is determined by the label when it is longer than menu entries', ( + WidgetTester tester, + ) async { + const double labelWidth = 120; + const double entryLabelWidth = 100; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + label: SizedBox(width: labelWidth), + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>( + value: 0, + label: 'Flutter', + labelWidget: SizedBox(width: entryLabelWidth), + ), + ], + ), + ), + ), + ); + + final double width = tester.getSize(find.byType(DropdownMenu<int>)).width; + const leadingWidth = 16.0; + const trailingWidth = 56.0; + const labelPadding = 8.0; // See RenderEditable.floatingCursorAddedMargin. + + expect(width, labelWidth + labelPadding + leadingWidth + trailingWidth); + }); + + testWidgets('The width of MenuAnchor respects MenuAnchor.expandedInsets', ( + WidgetTester tester, + ) async { + const parentWidth = 500.0; + final shortMenuItems = <DropdownMenuEntry<ShortMenu>>[]; + for (final ShortMenu value in ShortMenu.values) { + final entry = DropdownMenuEntry<ShortMenu>(value: value, label: value.label); + shortMenuItems.add(entry); + } + Widget buildMenuAnchor({EdgeInsets? expandedInsets}) { + return MaterialApp( + home: Scaffold( + body: SizedBox.square( + dimension: parentWidth, + child: DropdownMenu<ShortMenu>( + expandedInsets: expandedInsets, + dropdownMenuEntries: shortMenuItems, + ), + ), + ), + ); + } + + // By default, the width of the text field is determined by the menu children. + await tester.pumpWidget(buildMenuAnchor()); + RenderBox box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, 136.0); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + Size buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, 136.0); + + // If expandedInsets is EdgeInsets.zero, the width should be the same as its parent. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildMenuAnchor(expandedInsets: EdgeInsets.zero)); + box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, parentWidth); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, parentWidth); + + // If expandedInsets is not zero, the width of the text field should be adjusted + // based on the EdgeInsets.left and EdgeInsets.right. The top and bottom values + // will be ignored. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildMenuAnchor(expandedInsets: const EdgeInsets.only(left: 35.0, top: 50.0, right: 20.0)), + ); + box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, parentWidth - 35.0 - 20.0); + final Rect containerRect = tester.getRect(find.byType(SizedBox).first); + final Rect dropdownMenuRect = tester.getRect(find.byType(TextField)); + expect(dropdownMenuRect.top, containerRect.top); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, parentWidth - 35.0 - 20.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/151769 + testWidgets('expandedInsets can use EdgeInsets or EdgeInsetsDirectional', ( + WidgetTester tester, + ) async { + const parentWidth = 500.0; + final shortMenuItems = <DropdownMenuEntry<ShortMenu>>[]; + for (final ShortMenu value in ShortMenu.values) { + final entry = DropdownMenuEntry<ShortMenu>(value: value, label: value.label); + shortMenuItems.add(entry); + } + Widget buildMenuAnchor({EdgeInsetsGeometry? expandedInsets}) { + return MaterialApp( + home: Scaffold( + body: SizedBox.square( + dimension: parentWidth, + child: DropdownMenu<ShortMenu>( + expandedInsets: expandedInsets, + dropdownMenuEntries: shortMenuItems, + ), + ), + ), + ); + } + + // By default, the width of the text field is determined by the menu children. + await tester.pumpWidget(buildMenuAnchor()); + RenderBox box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, 136.0); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + Size buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, 136.0); + + // If expandedInsets is not zero, the width of the text field should be adjusted + // based on the EdgeInsets.left and EdgeInsets.right. The top and bottom values + // will be ignored. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildMenuAnchor(expandedInsets: const EdgeInsets.only(left: 35.0, top: 50.0, right: 20.0)), + ); + box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, parentWidth - 35.0 - 20.0); + Rect containerRect = tester.getRect(find.byType(SizedBox).first); + Rect dropdownMenuRect = tester.getRect(find.byType(TextField)); + expect(dropdownMenuRect.top, containerRect.top); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, parentWidth - 35.0 - 20.0); + + // Regression test for https://github.com/flutter/flutter/issues/151769. + // If expandedInsets is not zero, the width of the text field should be adjusted + // based on the EdgeInsets.end and EdgeInsets.start. The top and bottom values + // will be ignored. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildMenuAnchor( + expandedInsets: const EdgeInsetsDirectional.only(start: 35.0, top: 50.0, end: 20.0), + ), + ); + box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, parentWidth - 35.0 - 20.0); + containerRect = tester.getRect(find.byType(SizedBox).first); + dropdownMenuRect = tester.getRect(find.byType(TextField)); + expect(dropdownMenuRect.top, containerRect.top); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + buttonSize = tester.getSize(findMenuItemButton('I0')); + expect(buttonSize.width, parentWidth - 35.0 - 20.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/172680. + testWidgets('Menu panel width can expand to full-screen width', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + expandedInsets: EdgeInsets.zero, + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Flutter'), + ], + ), + ), + ), + ); + + final double dropdownWidth = tester.getSize(find.byType(DropdownMenu<int>)).width; + expect(dropdownWidth, 800); + + await tester.tap(find.byType(DropdownMenu<int>)); + await tester.pump(); + + final double menuWidth = tester.getSize(findMenuItemButton('Flutter')).width; + expect(dropdownWidth, menuWidth); + }); + + // Regression test for https://github.com/flutter/flutter/issues/176501 + testWidgets('_RenderDropdownMenuBody.computeDryLayout does not access this.constraints', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: _TestDryLayout( + child: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 1, label: 'One'), + DropdownMenuEntry<int>(value: 2, label: 'Two'), + ], + ), + ), + ), + ), + ), + ); + + // The test passes if no exception is thrown during the layout phase. + expect(tester.takeException(), isNull); + expect(find.byType(DropdownMenu<int>), findsOneWidget); + }); + + testWidgets( + 'Material2 - The menuHeight property can be used to show a shorter scrollable menu list instead of the complete list', + (WidgetTester tester) async { + final themeData = ThemeData(useMaterial3: false); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + final Element firstItem = tester.element(findMenuItemButton('Item 0')); + final firstBox = firstItem.renderObject! as RenderBox; + final Offset topLeft = firstBox.localToGlobal(firstBox.size.topLeft(Offset.zero)); + final Element lastItem = tester.element(findMenuItemButton('Item 5')); + final lastBox = lastItem.renderObject! as RenderBox; + final Offset bottomRight = lastBox.localToGlobal(lastBox.size.bottomRight(Offset.zero)); + // height = height of MenuItemButton * 6 = 48 * 6 + expect(bottomRight.dy - topLeft.dy, 288.0); + + final Finder menuView = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Padding)) + .first; + final Size menuViewSize = tester.getSize(menuView); + expect(menuViewSize, const Size(180.0, 304.0)); // 304 = 288 + vertical padding(2 * 8) + + // Constrains the menu height. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildTest(themeData, menuChildren, menuHeight: 100)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + final Finder updatedMenu = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Padding)) + .first; + + final Size updatedMenuSize = tester.getSize(updatedMenu); + expect(updatedMenuSize, const Size(180.0, 100.0)); + }, + ); + + testWidgets( + 'Material3 - The menuHeight property can be used to show a shorter scrollable menu list instead of the complete list', + (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + final Element firstItem = tester.element(findMenuItemButton('Item 0')); + final firstBox = firstItem.renderObject! as RenderBox; + final Offset topLeft = firstBox.localToGlobal(firstBox.size.topLeft(Offset.zero)); + final Element lastItem = tester.element(findMenuItemButton('Item 5')); + final lastBox = lastItem.renderObject! as RenderBox; + final Offset bottomRight = lastBox.localToGlobal(lastBox.size.bottomRight(Offset.zero)); + // height = height of MenuItemButton * 6 = 48 * 6 + expect(bottomRight.dy - topLeft.dy, 288.0); + + final Finder menuView = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Padding)) + .first; + final Size menuViewSize = tester.getSize(menuView); + expect(menuViewSize.height, equals(304.0)); // 304 = 288 + vertical padding(2 * 8) + + // Constrains the menu height. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildTest(themeData, menuChildren, menuHeight: 100)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + final Finder updatedMenu = find + .ancestor(of: find.byType(SingleChildScrollView), matching: find.byType(Padding)) + .first; + + final Size updatedMenuSize = tester.getSize(updatedMenu); + expect(updatedMenuSize.height, equals(100.0)); + }, + ); + + testWidgets('The text in the menu button should be aligned with the text of ' + 'the text field - LTR', (WidgetTester tester) async { + final themeData = ThemeData(); + // Default text field (without leading icon). + await tester.pumpWidget(buildTest(themeData, menuChildren, label: const Text('label'))); + + final Finder label = find.text('label').first; + final Offset labelTopLeft = tester.getTopLeft(label); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder itemText = find.text('Item 0').last; + final Offset itemTextTopLeft = tester.getTopLeft(itemText); + + expect(labelTopLeft.dx, equals(itemTextTopLeft.dx)); + + // Test when the text field has a leading icon. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildTest( + themeData, + menuChildren, + leadingIcon: const Icon(Icons.search), + label: const Text('label'), + ), + ); + + final Finder leadingIcon = find.widgetWithIcon(SizedBox, Icons.search).last; + final double iconWidth = tester.getSize(leadingIcon).width; + final Finder updatedLabel = find.text('label').first; + final Offset updatedLabelTopLeft = tester.getTopLeft(updatedLabel); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder updatedItemText = find.text('Item 0').last; + final Offset updatedItemTextTopLeft = tester.getTopLeft(updatedItemText); + + expect(updatedLabelTopLeft.dx, equals(updatedItemTextTopLeft.dx)); + expect(updatedLabelTopLeft.dx, equals(iconWidth + leadingIconToInputPadding)); + + // Test when then leading icon is a widget with a bigger size. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildTest( + themeData, + menuChildren, + leadingIcon: const SizedBox(width: 75.0, child: Icon(Icons.search)), + label: const Text('label'), + ), + ); + + final Finder largeLeadingIcon = find.widgetWithIcon(SizedBox, Icons.search).last; + final double largeIconWidth = tester.getSize(largeLeadingIcon).width; + final Finder updatedLabel1 = find.text('label').first; + final Offset updatedLabelTopLeft1 = tester.getTopLeft(updatedLabel1); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder updatedItemText1 = find.text('Item 0').last; + final Offset updatedItemTextTopLeft1 = tester.getTopLeft(updatedItemText1); + + expect(updatedLabelTopLeft1.dx, equals(updatedItemTextTopLeft1.dx)); + expect(updatedLabelTopLeft1.dx, equals(largeIconWidth + leadingIconToInputPadding)); + }); + + testWidgets('The text in the menu button should be aligned with the text of ' + 'the text field - RTL', (WidgetTester tester) async { + final themeData = ThemeData(); + // Default text field (without leading icon). + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu<TestMenu>( + label: const Text('label'), + dropdownMenuEntries: menuChildren, + ), + ), + ), + ), + ); + + final Finder label = find.text('label').first; + final Offset labelTopRight = tester.getTopRight(label); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder itemText = find.text('Item 0').last; + final Offset itemTextTopRight = tester.getTopRight(itemText); + + expect(labelTopRight.dx, equals(itemTextTopRight.dx)); + + // Test when the text field has a leading icon. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu<TestMenu>( + leadingIcon: const Icon(Icons.search), + label: const Text('label'), + dropdownMenuEntries: menuChildren, + ), + ), + ), + ), + ); + await tester.pump(); + + final Finder leadingIcon = find.widgetWithIcon(SizedBox, Icons.search).last; + final double iconWidth = tester.getSize(leadingIcon).width; + final Offset dropdownMenuTopRight = tester.getTopRight(find.byType(DropdownMenu<TestMenu>)); + final Finder updatedLabel = find.text('label').first; + final Offset updatedLabelTopRight = tester.getTopRight(updatedLabel); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder updatedItemText = find.text('Item 0').last; + final Offset updatedItemTextTopRight = tester.getTopRight(updatedItemText); + + expect(updatedLabelTopRight.dx, equals(updatedItemTextTopRight.dx)); + expect( + updatedLabelTopRight.dx, + equals(dropdownMenuTopRight.dx - iconWidth - leadingIconToInputPadding), + ); + + // Test when then leading icon is a widget with a bigger size. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu<TestMenu>( + leadingIcon: const SizedBox(width: 75.0, child: Icon(Icons.search)), + label: const Text('label'), + dropdownMenuEntries: menuChildren, + ), + ), + ), + ), + ); + await tester.pump(); + + final Finder largeLeadingIcon = find.widgetWithIcon(SizedBox, Icons.search).last; + final double largeIconWidth = tester.getSize(largeLeadingIcon).width; + final Offset updatedDropdownMenuTopRight = tester.getTopRight( + find.byType(DropdownMenu<TestMenu>), + ); + final Finder updatedLabel1 = find.text('label').first; + final Offset updatedLabelTopRight1 = tester.getTopRight(updatedLabel1); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder updatedItemText1 = find.text('Item 0').last; + final Offset updatedItemTextTopRight1 = tester.getTopRight(updatedItemText1); + + expect(updatedLabelTopRight1.dx, equals(updatedItemTextTopRight1.dx)); + expect( + updatedLabelTopRight1.dx, + equals(updatedDropdownMenuTopRight.dx - largeIconWidth - leadingIconToInputPadding), + ); + }); + + testWidgets('The icon in the menu button should be aligned with the icon of ' + 'the text field - LTR', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Directionality( + textDirection: TextDirection.ltr, + child: DropdownMenu<TestMenu>( + leadingIcon: const Icon(Icons.search), + label: const Text('label'), + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ), + ); + + final Finder dropdownIcon = find + .descendant(of: find.byIcon(Icons.search).first, matching: find.byType(RichText)) + .last; + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder itemLeadingIcon = find.byKey(leadingIconKey(TestMenu.mainMenu0)).last; + + expect(tester.getRect(dropdownIcon).left, tester.getRect(itemLeadingIcon).left); + }); + + testWidgets('The icon in the menu button should be aligned with the icon of ' + 'the text field - RTL', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: DropdownMenu<TestMenu>( + leadingIcon: const Icon(Icons.search), + label: const Text('label'), + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ), + ); + + final Finder dropdownIcon = find + .descendant(of: find.byIcon(Icons.search).first, matching: find.byType(RichText)) + .last; + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + final Finder itemLeadingIcon = find.byKey(leadingIconKey(TestMenu.mainMenu0)).last; + + expect(tester.getRect(dropdownIcon).right, tester.getRect(itemLeadingIcon).right); + }); + + testWidgets('DropdownMenu has default trailing icon button', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first; + expect(iconButton, findsOneWidget); + + await tester.tap(iconButton); + await tester.pump(); + + final Finder menuMaterial = find + .ancestor(of: findMenuItemButton(TestMenu.mainMenu0.label), matching: find.byType(Material)) + .last; + expect(menuMaterial, findsOneWidget); + }); + + testWidgets('Trailing IconButton status test', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget(buildTest(themeData, menuChildren, width: 100.0, menuHeight: 100.0)); + await tester.pump(); + + Finder iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_up); + expect(iconButton, findsNothing); + iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first; + expect(iconButton, findsOneWidget); + + await tester.tap(iconButton); + await tester.pump(); + + iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_up).first; + expect(iconButton, findsOneWidget); + iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down); + expect(iconButton, findsNothing); + + // Tap outside + await tester.tapAt(const Offset(500.0, 500.0)); + await tester.pump(); + + iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_up); + expect(iconButton, findsNothing); + iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first; + expect(iconButton, findsOneWidget); + }); + + testWidgets('Trailing IconButton height respects InputDecorationTheme.suffixIconConstraints', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + + // Default suffix icon constraints. + await tester.pumpWidget(buildTest(themeData, menuChildren)); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first; + expect(tester.getSize(iconButton), const Size(48, 48)); + + // Custom suffix icon constraints. + await tester.pumpWidget( + buildTest( + themeData, + menuChildren, + decorationTheme: const InputDecorationTheme( + suffixIconConstraints: BoxConstraints(minWidth: 66, minHeight: 62), + ), + ), + ); + await tester.pump(); + + expect(tester.getSize(iconButton), const Size(66, 62)); + }); + + testWidgets('InputDecorationTheme.isCollapsed reduces height', (WidgetTester tester) async { + final themeData = ThemeData(); + + // Default height. + await tester.pumpWidget(buildTest(themeData, menuChildren)); + await tester.pump(); + + final Finder textField = find.byType(TextField).first; + expect(tester.getSize(textField).height, 56); + + // Collapsed height. + await tester.pumpWidget( + buildTest( + themeData, + menuChildren, + decorationTheme: const InputDecorationTheme(isCollapsed: true), + ), + ); + await tester.pump(); + + expect(tester.getSize(textField).height, 48); // IconButton min height. + + // Collapsed height with custom suffix icon constraints. + await tester.pumpWidget( + buildTest( + themeData, + menuChildren, + decorationTheme: const InputDecorationTheme( + isCollapsed: true, + suffixIconConstraints: BoxConstraints(maxWidth: 24, maxHeight: 24), + ), + ), + ); + await tester.pump(); + + expect(tester.getSize(textField).height, 24); + }); + + testWidgets('Do not crash when resize window during menu opening', (WidgetTester tester) async { + addTearDown(tester.view.reset); + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownMenu<TestMenu>( + width: MediaQuery.widthOf(context), + dropdownMenuEntries: menuChildren, + ); + }, + ), + ), + ), + ); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first; + expect(iconButton, findsOneWidget); + + await tester.tap(iconButton); + await tester.pump(); + + expect(findMenuItemButton(TestMenu.mainMenu0.label), findsOne); + + // didChangeMetrics + tester.view.physicalSize = const Size(700.0, 700.0); + await tester.pump(); + + // Go without throw. + }); + + testWidgets('DropdownMenu can customize trailing icon button', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + trailingIcon: const Icon(Icons.ac_unit), + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.ac_unit).first; + expect(iconButton, findsOneWidget); + + await tester.tap(iconButton); + await tester.pump(); + + final Finder menuMaterial = find + .ancestor(of: findMenuItemButton(TestMenu.mainMenu0.label), matching: find.byType(Material)) + .last; + expect(menuMaterial, findsOneWidget); + }); + + testWidgets('Down key can highlight the menu item while focused', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + trailingIcon: const Icon(Icons.ac_unit), + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Item 0'), true); + + // Press down key one more time, the highlight should move to the next item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Menu 1'), true); + + // The previous item should not be highlighted. + expect(isItemHighlighted(tester, themeData, 'Item 0'), false); + }); + + testWidgets('Up key can highlight the menu item while focused', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Item 5'), true); + + // Press up key one more time, the highlight should move up to the item 4. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Item 4'), true); + + // The previous item should not be highlighted. + expect(isItemHighlighted(tester, themeData, 'Item 5'), false); + }); + + testWidgets('Left and right keys can move text field selection', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + filterCallback: (List<DropdownMenuEntry<TestMenu>> entries, String filter) { + return entries + .where((DropdownMenuEntry<TestMenu> element) => element.label.contains(filter)) + .toList(); + }, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'example'); + await tester.pump(); + expect(controller.text, 'example'); + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + + // Press left key, the caret should move left. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + + // Press Right key, the caret should move right. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/156712. + testWidgets('Up and down keys can highlight the menu item when expandedInsets is set', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + expandedInsets: EdgeInsets.zero, + requestFocusOnTap: true, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Item 5'), true); + + // Press up key one more time, the highlight should move up to the item 4. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Item 4'), true); + + // The previous item should not be highlighted. + expect(isItemHighlighted(tester, themeData, 'Item 5'), false); + + // Press down key, the highlight should move back to the item 5. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'Item 5'), true); + }); + + // Regression test for https://github.com/flutter/flutter/issues/156712. + testWidgets('Left and right keys can move text field selection when expandedInsets is set', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + expandedInsets: EdgeInsets.zero, + requestFocusOnTap: true, + enableFilter: true, + filterCallback: (List<DropdownMenuEntry<TestMenu>> entries, String filter) { + return entries + .where((DropdownMenuEntry<TestMenu> element) => element.label.contains(filter)) + .toList(); + }, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'example'); + await tester.pump(); + expect(controller.text, 'example'); + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + + // Press left key, the caret should move left. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + + // Press Right key, the caret should move right. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/147253. + testWidgets('Down key and up key can navigate while focused when a label text ' + 'contains another label text', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: DropdownMenu<int>( + requestFocusOnTap: true, + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'ABC'), + DropdownMenuEntry<int>(value: 1, label: 'AB'), + DropdownMenuEntry<int>(value: 2, label: 'ABCD'), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<int>)); + await tester.pump(); + + // Press down key three times, the highlight should move to the next item each time. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'ABC'), true); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'AB'), true); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'ABCD'), true); + + // Press up key two times, the highlight should up each time. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'AB'), true); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'ABC'), true); + }); + + // Regression test for https://github.com/flutter/flutter/issues/151878. + testWidgets('Searching for non matching item does not crash', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + enableFilter: true, + requestFocusOnTap: true, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'Me'); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'Meu'); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154532. + testWidgets('Keyboard navigation does not throw when no entries match the filter', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'No match'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'No match 2'); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/165867. + testWidgets('Keyboard navigation only traverses filtered entries', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + controller: controller, + dropdownMenuEntries: const <DropdownMenuEntry<TestMenu>>[ + DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Good Match 1'), + DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu1, label: 'Bad Match 1'), + DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu2, label: 'Good Match 2'), + DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu3, label: 'Bad Match 2'), + DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu4, label: 'Good Match 3'), + DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu5, label: 'Bad Match 3'), + ], + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + // Filter the entries to only show the ones with 'Good Match'. + await tester.enterText(find.byType(TextField), 'Good Match'); + await tester.pump(); + + // Since the first entry is already highlighted, navigate to the second item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(controller.text, 'Good Match 2'); + + // Navigate to the third item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(controller.text, 'Good Match 3'); + + // Navigate back to the first item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(controller.text, 'Good Match 1'); + }); + + // Regression test for https://github.com/flutter/flutter/issues/147253. + testWidgets('Default search prioritises the current highlight', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + const itemLabel = 'Item 2'; + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + // Highlight the third item by exact search. + await tester.enterText(find.byType(TextField).first, itemLabel); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, itemLabel), true); + + // Search something that matches multiple items. + await tester.enterText(find.byType(TextField).first, 'Item'); + await tester.pump(); + // The third item should still be highlighted. + expect(isItemHighlighted(tester, themeData, itemLabel), true); + }); + + // Regression test for https://github.com/flutter/flutter/issues/152375. + testWidgets('Down key and up key can navigate while focused when a label text contains ' + 'another label text using customized search algorithm', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<int>( + requestFocusOnTap: true, + searchCallback: (List<DropdownMenuEntry<int>> entries, String query) { + if (query.isEmpty) { + return null; + } + final int index = entries.indexWhere( + (DropdownMenuEntry<int> entry) => entry.label.contains(query), + ); + return index != -1 ? index : null; + }, + dropdownMenuEntries: const <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'ABC'), + DropdownMenuEntry<int>(value: 1, label: 'AB'), + DropdownMenuEntry<int>(value: 2, label: 'ABCD'), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<int>)); + await tester.pump(); + + // Press down key three times, the highlight should move to the next item each time. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'ABC'), true); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'AB'), true); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'ABCD'), true); + + // Press up key two times, the highlight should up each time. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'AB'), true); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, 'ABC'), true); + }); + + // Regression test for https://github.com/flutter/flutter/issues/152375. + testWidgets('Searching can highlight entry after keyboard navigation while focused', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + // Open the menu and highlight the first item. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + // Search for the last item. + final String searchedLabel = menuChildren.last.label; + await tester.enterText(find.byType(TextField).first, searchedLabel); + await tester.pump(); + // The corresponding menu entry is highlighted. + expect(isItemHighlighted(tester, themeData, searchedLabel), true); + }); + + testWidgets('The text input should match the label of the menu item ' + 'when pressing down key while focused', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget); + + // Press down key one more time to the next item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Menu 1'), findsOneWidget); + + // Press down to the next item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 2'), findsOneWidget); + }); + + testWidgets('The text input should match the label of the menu item ' + 'when pressing up key while focused', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 5'), findsOneWidget); + + // Press up key one more time to the upper item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 4'), findsOneWidget); + + // Press up to the upper item. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 3'), findsOneWidget); + }); + + testWidgets('Disabled button will be skipped while pressing up/down key while focused', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[ + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu1, label: 'Item 1', enabled: false), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu2, label: 'Item 2', enabled: false), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu3, label: 'Item 3'), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu4, label: 'Item 4'), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu5, label: 'Item 5', enabled: false), + ]; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + dropdownMenuEntries: menuWithDisabledItems, + ), + ), + ), + ); + await tester.pump(); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + // First item is highlighted as it's enabled. + expect(isItemHighlighted(tester, themeData, 'Item 0'), true); + + // Continue to press down key. Item 3 should be highlighted as Menu 1 and Item 2 are both disabled. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(isItemHighlighted(tester, themeData, 'Item 3'), true); + }); + + testWidgets('Searching is enabled by default if initialSelection is non null', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + initialSelection: TestMenu.mainMenu1, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + // Initial selection (Menu 1) button is highlighted. + expect(isItemHighlighted(tester, themeData, 'Menu 1'), true); + }); + + testWidgets('Highlight can move up/down starting from the searching result while focused', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + expect(isItemHighlighted(tester, themeData, 'Menu 1'), true); + + // Press up to the upper item (Item 0). + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget); + expect(isItemHighlighted(tester, themeData, 'Item 0'), true); + + // Continue to move up to the last item (Item 5). + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'Item 5'), findsOneWidget); + expect(isItemHighlighted(tester, themeData, 'Item 5'), true); + }); + + testWidgets('Filtering is disabled by default', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>(requestFocusOnTap: true, dropdownMenuEntries: menuChildren), + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + // One is layout for the _DropdownMenuBody, the other one is the real button item in the menu. + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } + }); + + testWidgets('Enable filtering', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + // 'Menu 1' should be 2, other items should only find one. + if (menu.label == TestMenu.mainMenu1.label) { + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } else { + expect(find.widgetWithText(MenuItemButton, menu.label), findsOneWidget); + } + } + }); + + testWidgets('Enable filtering with custom filter callback that filter text case sensitive', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + filterCallback: (List<DropdownMenuEntry<TestMenu>> entries, String filter) { + return entries + .where((DropdownMenuEntry<TestMenu> element) => element.label.contains(filter)) + .toList(); + }, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'item'); + expect(controller.text, 'item'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + expect(findMenuItemButton(menu.label).hitTestable(), findsNothing); + } + + await tester.enterText(find.byType(TextField).first, 'Item'); + expect(controller.text, 'Item'); + await tester.pumpAndSettle(); + expect(findMenuItemButton('Item 0').hitTestable(), findsOneWidget); + expect(findMenuItemButton('Menu 1').hitTestable(), findsNothing); + expect(findMenuItemButton('Item 2').hitTestable(), findsOneWidget); + expect(findMenuItemButton('Item 3').hitTestable(), findsOneWidget); + expect(findMenuItemButton('Item 4').hitTestable(), findsOneWidget); + expect(findMenuItemButton('Item 5').hitTestable(), findsOneWidget); + }); + + testWidgets( + 'Throw assertion error when enable filtering with custom filter callback and enableFilter set on False', + (WidgetTester tester) async { + final themeData = ThemeData(); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + expect(() { + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + filterCallback: (List<DropdownMenuEntry<TestMenu>> entries, String filter) { + return entries + .where((DropdownMenuEntry<TestMenu> element) => element.label.contains(filter)) + .toList(); + }, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ); + }, throwsAssertionError); + }, + ); + + testWidgets('The controller can access the value in the input field', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ); + }, + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + final Finder item3 = findMenuItemButton('Item 3'); + await tester.tap(item3); + await tester.pumpAndSettle(); + + expect(controller.text, 'Item 3'); + + await tester.enterText(find.byType(TextField).first, 'New Item'); + expect(controller.text, 'New Item'); + }); + + testWidgets('The menu should be closed after text editing is complete', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ), + ); + // Access the MenuAnchor + final MenuAnchor menuAnchor = tester.widget<MenuAnchor>(find.byType(MenuAnchor)); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + expect(menuAnchor.controller!.isOpen, true); + + // Simulate `TextInputAction.done` on textfield + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(menuAnchor.controller!.isOpen, false); + }); + + testWidgets('The onSelected gets called only when a selection is made', ( + WidgetTester tester, + ) async { + var selectionCount = 0; + + final themeData = ThemeData(); + final menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[ + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu1, label: 'Item 1', enabled: false), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu2, label: 'Item 2'), + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu3, label: 'Item 3'), + ]; + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuWithDisabledItems, + controller: controller, + onSelected: (_) { + setState(() { + selectionCount++; + }); + }, + ), + ); + }, + ), + ), + ); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + final bool isMobile = switch (themeData.platform) { + TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => true, + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => false, + }; + var expectedCount = 1; + + // Test onSelected on key press + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + // On mobile platforms, the TextField cannot gain focus by default; the focus is + // on a FocusNode specifically used for keyboard navigation. Therefore, + // LogicalKeyboardKey.enter should be used. + if (isMobile) { + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + } else { + await tester.testTextInput.receiveAction(TextInputAction.done); + } + await tester.pumpAndSettle(); + expect(selectionCount, expectedCount); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + // Disabled item doesn't trigger onSelected callback. + final Finder item1 = findMenuItemButton('Item 1'); + await tester.tap(item1); + await tester.pumpAndSettle(); + + expect(controller.text, 'Item 0'); + expect(selectionCount, expectedCount); + + final Finder item2 = findMenuItemButton('Item 2'); + await tester.tap(item2); + await tester.pumpAndSettle(); + + expect(controller.text, 'Item 2'); + expect(selectionCount, ++expectedCount); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + final Finder item3 = findMenuItemButton('Item 3'); + await tester.tap(item3); + await tester.pumpAndSettle(); + + expect(controller.text, 'Item 3'); + expect(selectionCount, ++expectedCount); + + // On desktop platforms, when typing something in the text field without selecting any of the options, + // the onSelected should not be called. + if (!isMobile) { + await tester.enterText(find.byType(TextField).first, 'New Item'); + expect(controller.text, 'New Item'); + expect(selectionCount, expectedCount); + expect(find.widgetWithText(TextField, 'New Item'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, ''); + expect(selectionCount, expectedCount); + expect(controller.text.isEmpty, true); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('The selectedValue gives an initial text and highlights the according item', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: TestMenu.mainMenu3, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ); + }, + ), + ), + ); + + expect(find.widgetWithText(TextField, 'Item 3'), findsOneWidget); + + // Open the menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + // Validate the item 3 is highlighted. + expect(isItemHighlighted(tester, themeData, 'Item 3'), true); + }); + + testWidgets( + 'When the initial selection matches a menu entry, the text field displays the corresponding value', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: TestMenu.mainMenu3, + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ); + }, + ), + ), + ); + + expect(controller.text, TestMenu.mainMenu3.label); + }, + ); + + testWidgets('Text field is empty when the initial selection does not match any menu entries', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: TestMenu.mainMenu3, + // Use a menu entries which does not contain TestMenu.mainMenu3. + dropdownMenuEntries: menuChildren.getRange(0, 1).toList(), + controller: controller, + ), + ); + }, + ), + ), + ); + + expect(controller.text, isEmpty); + }); + + testWidgets( + 'Text field content is not cleared when the initial selection does not match any menu entries', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Flutter'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: TestMenu.mainMenu3, + // Use a menu entries which does not contain TestMenu.mainMenu3. + dropdownMenuEntries: menuChildren.getRange(0, 1).toList(), + controller: controller, + ), + ); + }, + ), + ), + ); + + expect(controller.text, 'Flutter'); + }, + ); + + testWidgets('The default text input field should not be focused on mobile platforms ' + 'when it is tapped', (WidgetTester tester) async { + final themeData = ThemeData(); + + Widget buildDropdownMenu() => MaterialApp( + theme: themeData, + home: Scaffold( + body: Column(children: <Widget>[DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)]), + ), + ); + + // Test default condition. + await tester.pumpWidget(buildDropdownMenu()); + await tester.pump(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField result = tester.widget<TextField>(textFieldFinder); + expect(result.canRequestFocus, false); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets('The text input field should be focused on desktop platforms ' + 'when it is tapped', (WidgetTester tester) async { + final themeData = ThemeData(); + + Widget buildDropdownMenu() => MaterialApp( + theme: themeData, + home: Scaffold( + body: Column(children: <Widget>[DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)]), + ), + ); + + await tester.pumpWidget(buildDropdownMenu()); + await tester.pump(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField result = tester.widget<TextField>(textFieldFinder); + expect(result.canRequestFocus, true); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('If requestFocusOnTap is true, the text input field can request focus, ' + 'otherwise it cannot request focus', (WidgetTester tester) async { + final themeData = ThemeData(); + + Widget buildDropdownMenu({required bool requestFocusOnTap}) => MaterialApp( + theme: themeData, + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>( + requestFocusOnTap: requestFocusOnTap, + dropdownMenuEntries: menuChildren, + ), + ], + ), + ), + ); + + // Set requestFocusOnTap to true. + await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: true)); + await tester.pump(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField textField = tester.widget<TextField>(textFieldFinder); + expect(textField.canRequestFocus, true); + // Open the dropdown menu. + await tester.tap(textFieldFinder); + await tester.pump(); + // Make a selection. + await tester.tap(findMenuItemButton('Item 0')); + await tester.pump(); + expect(findMenuItemButton('Item 0'), findsOneWidget); + + // Set requestFocusOnTap to false. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: false)); + await tester.pumpAndSettle(); + + final Finder textFieldFinder1 = find.byType(TextField); + final TextField textField1 = tester.widget<TextField>(textFieldFinder1); + expect(textField1.canRequestFocus, false); + // Open the dropdown menu. + await tester.tap(textFieldFinder1); + await tester.pump(); + // Make a selection. + await tester.tap(findMenuItemButton('Item 0')); + await tester.pump(); + expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget); + }, variant: TargetPlatformVariant.all()); + + testWidgets('If requestFocusOnTap is false, the mouse cursor should be clickable when hovered', ( + WidgetTester tester, + ) async { + Widget buildDropdownMenu() => MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>(requestFocusOnTap: false, dropdownMenuEntries: menuChildren), + ], + ), + ), + ); + + await tester.pumpWidget(buildDropdownMenu()); + await tester.pumpAndSettle(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField textField = tester.widget<TextField>(textFieldFinder); + expect(textField.canRequestFocus, false); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.moveTo(tester.getCenter(textFieldFinder)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('If enabled is false, the mouse cursor should be deferred when hovered', ( + WidgetTester tester, + ) async { + Widget buildDropdownMenu({bool enabled = true, bool? requestFocusOnTap}) { + return MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>( + enabled: enabled, + requestFocusOnTap: requestFocusOnTap, + dropdownMenuEntries: menuChildren, + ), + ], + ), + ), + ); + } + + // Check mouse cursor dropdown menu is disabled and requestFocusOnTap is true. + await tester.pumpWidget(buildDropdownMenu(enabled: false, requestFocusOnTap: true)); + await tester.pumpAndSettle(); + + Finder textFieldFinder = find.byType(TextField); + TextField textField = tester.widget<TextField>(textFieldFinder); + expect(textField.canRequestFocus, true); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.moveTo(tester.getCenter(textFieldFinder)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Remove the pointer. + await gesture.removePointer(); + + // Check mouse cursor dropdown menu is disabled and requestFocusOnTap is false. + await tester.pumpWidget(buildDropdownMenu(enabled: false, requestFocusOnTap: false)); + await tester.pumpAndSettle(); + + textFieldFinder = find.byType(TextField); + textField = tester.widget<TextField>(textFieldFinder); + expect(textField.canRequestFocus, false); + + // Add a new pointer. + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(textFieldFinder)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Remove the pointer. + await gesture.removePointer(); + + // Check enabled dropdown menu updates the mouse cursor when hovered. + await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: true)); + await tester.pumpAndSettle(); + + textFieldFinder = find.byType(TextField); + textField = tester.widget<TextField>(textFieldFinder); + expect(textField.canRequestFocus, true); + + // Add a new pointer. + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(textFieldFinder)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + + testWidgets('The menu has the same width as the input field in ListView', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/123631 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + children: <Widget>[DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)], + ), + ), + ), + ); + + final Rect textInput = tester.getRect(find.byType(TextField)); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + final Finder findMenu = find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_MenuPanel'; + }); + final Rect menu = tester.getRect(findMenu); + expect(textInput.width, menu.width); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + children: <Widget>[ + DropdownMenu<TestMenu>(width: 200, dropdownMenuEntries: menuChildren), + ], + ), + ), + ), + ); + + final Rect textInput1 = tester.getRect(find.byType(TextField)); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + final Finder findMenu1 = find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_MenuPanel'; + }); + final Rect menu1 = tester.getRect(findMenu1); + expect(textInput1.width, 200); + expect(menu1.width, 200); + }); + + testWidgets('Semantics does not include hint when input is not empty', ( + WidgetTester tester, + ) async { + const hintText = 'I am hintText'; + TestMenu? selectedValue; + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) => MaterialApp( + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + dropdownMenuEntries: menuChildren, + hintText: hintText, + onSelected: (TestMenu? value) { + setState(() { + selectedValue = value; + }); + }, + controller: controller, + ), + ), + ), + ), + ), + ); + final SemanticsNode node = tester.getSemantics(find.text(hintText)); + + expect(selectedValue?.label, null); + expect(node.label, hintText); + expect(node.value, ''); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + await tester.tap(findMenuItemButton('Item 3')); + await tester.pumpAndSettle(); + expect(selectedValue?.label, 'Item 3'); + expect(node.label, ''); + expect(node.value, 'Item 3'); + }); + + testWidgets('Semantics does not include initial menu buttons', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + dropdownMenuEntries: menuChildren, + onSelected: (TestMenu? value) {}, + controller: controller, + ), + ), + ), + ), + ); + // The menu buttons should not be visible and should not be in the semantics tree. + for (final String label in TestMenu.values.map((TestMenu menu) => menu.label)) { + expect(find.bySemanticsLabel(label), findsNothing); + } + + // Open the menu. + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + + // The menu buttons should be visible and in the semantics tree. + for (final String label in TestMenu.values.map((TestMenu menu) => menu.label)) { + expect(find.bySemanticsLabel(label), findsOneWidget); + } + }); + + testWidgets('helperText is not visible when errorText is not null', (WidgetTester tester) async { + final themeData = ThemeData(); + const helperText = 'I am helperText'; + const errorText = 'I am errorText'; + + Widget buildFrame(bool hasError) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + helperText: helperText, + errorText: hasError ? errorText : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(false)); + expect(find.text(helperText), findsOneWidget); + expect(find.text(errorText), findsNothing); + + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect(find.text(helperText), findsNothing); + expect(find.text(errorText), findsOneWidget); + }); + + testWidgets('DropdownMenu can respect helperText when helperText is not null', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + const helperText = 'I am helperText'; + + Widget buildFrame() { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + helperText: helperText, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + expect(find.text(helperText), findsOneWidget); + }); + + testWidgets('DropdownMenu can respect errorText when errorText is not null', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + const errorText = 'I am errorText'; + + Widget buildFrame() { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren, errorText: errorText), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + expect(find.text(errorText), findsOneWidget); + }); + + testWidgets('Can scroll to the highlighted item', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + menuHeight: 100, // Give a small number so the list can only show 2 or 3 items. + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + expect(find.text('Item 5').hitTestable(), findsNothing); + await tester.enterText(find.byType(TextField), '5'); + await tester.pumpAndSettle(); + // Item 5 should show up. + expect(find.text('Item 5').hitTestable(), findsOneWidget); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/131676. + testWidgets('Material3 - DropdownMenu uses correct text styles', (WidgetTester tester) async { + const inputTextThemeStyle = TextStyle( + fontSize: 18.5, + fontStyle: FontStyle.italic, + wordSpacing: 1.2, + decoration: TextDecoration.lineThrough, + ); + const menuItemTextThemeStyle = TextStyle( + fontSize: 20.5, + fontStyle: FontStyle.italic, + wordSpacing: 2.1, + decoration: TextDecoration.underline, + ); + final themeData = ThemeData( + textTheme: const TextTheme( + bodyLarge: inputTextThemeStyle, + labelLarge: menuItemTextThemeStyle, + ), + ); + await tester.pumpWidget(buildTest(themeData, menuChildren)); + + // Test input text style uses the TextTheme.bodyLarge. + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.fontSize, inputTextThemeStyle.fontSize); + expect(editableText.style.fontStyle, inputTextThemeStyle.fontStyle); + expect(editableText.style.wordSpacing, inputTextThemeStyle.wordSpacing); + expect(editableText.style.decoration, inputTextThemeStyle.decoration); + + // Open the menu. + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + + // Test menu item text style uses the TextTheme.labelLarge. + final Material material = getButtonMaterial(tester, TestMenu.mainMenu0.label); + expect(material.textStyle?.fontSize, menuItemTextThemeStyle.fontSize); + expect(material.textStyle?.fontStyle, menuItemTextThemeStyle.fontStyle); + expect(material.textStyle?.wordSpacing, menuItemTextThemeStyle.wordSpacing); + expect(material.textStyle?.decoration, menuItemTextThemeStyle.decoration); + }); + + testWidgets('DropdownMenuEntries do not overflow when width is specified', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/126882 + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + controller: controller, + width: 100, + dropdownMenuEntries: TestMenu.values.map<DropdownMenuEntry<TestMenu>>((TestMenu item) { + return DropdownMenuEntry<TestMenu>(value: item, label: '${item.label} $longText'); + }).toList(), + ), + ), + ), + ); + + // Opening the width=100 menu should not crash. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + + Finder findMenuItemText(String label) { + final labelText = '$label $longText'; + return find.descendant(of: findMenuItemButton(labelText), matching: find.byType(Text)).last; + } + + // Actual size varies a little on web platforms. + final Matcher closeTo300 = closeTo(300, 0.25); + expect(tester.getSize(findMenuItemText('Item 0')).height, closeTo300); + expect(tester.getSize(findMenuItemText('Menu 1')).height, closeTo300); + expect(tester.getSize(findMenuItemText('Item 2')).height, closeTo300); + expect(tester.getSize(findMenuItemText('Item 3')).height, closeTo300); + + await tester.tap(findMenuItemText('Item 0')); + await tester.pumpAndSettle(); + expect(controller.text, 'Item 0 $longText'); + }); + + testWidgets('DropdownMenuEntry.labelWidget is Text that specifies maxLines 1 or 2', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/126882 + final controller = TextEditingController(); + addTearDown(controller.dispose); + + Widget buildFrame({required int maxLines}) { + return MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + key: ValueKey<int>(maxLines), + controller: controller, + width: 100, + dropdownMenuEntries: TestMenu.values.map<DropdownMenuEntry<TestMenu>>((TestMenu item) { + return DropdownMenuEntry<TestMenu>( + value: item, + label: '${item.label} $longText', + labelWidget: Text('${item.label} $longText', maxLines: maxLines), + ); + }).toList(), + ), + ), + ); + } + + Finder findMenuItemText(String label) { + final labelText = '$label $longText'; + return find.descendant(of: findMenuItemButton(labelText), matching: find.byType(Text)).last; + } + + await tester.pumpWidget(buildFrame(maxLines: 1)); + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + + // Actual size varies a little on web platforms. + final Matcher closeTo20 = closeTo(20, 0.05); + expect(tester.getSize(findMenuItemText('Item 0')).height, closeTo20); + expect(tester.getSize(findMenuItemText('Menu 1')).height, closeTo20); + expect(tester.getSize(findMenuItemText('Item 2')).height, closeTo20); + expect(tester.getSize(findMenuItemText('Item 3')).height, closeTo20); + + // Close the menu + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(controller.text, ''); // nothing selected + + await tester.pumpWidget(buildFrame(maxLines: 2)); + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + + // Actual size varies a little on web platforms. + final Matcher closeTo40 = closeTo(40, 0.05); + expect(tester.getSize(findMenuItemText('Item 0')).height, closeTo40); + expect(tester.getSize(findMenuItemText('Menu 1')).height, closeTo40); + expect(tester.getSize(findMenuItemText('Item 2')).height, closeTo40); + expect(tester.getSize(findMenuItemText('Item 3')).height, closeTo40); + + // Close the menu + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(controller.text, ''); // nothing selected + }); + + // Regression test for https://github.com/flutter/flutter/issues/131350. + testWidgets('DropdownMenuEntry.leadingIcon default layout', (WidgetTester tester) async { + // The DropdownMenu should not get extra padding in DropdownMenuEntry items + // when both text field and DropdownMenuEntry have leading icons. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + leadingIcon: Icon(Icons.search), + hintText: 'Hint', + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0', leadingIcon: Icon(Icons.alarm)), + DropdownMenuEntry<int>(value: 1, label: 'Item 1'), + ], + ), + ), + ), + ); + await tester.tap(find.byType(DropdownMenu<int>)); + await tester.pumpAndSettle(); + + // Check text location in text field. + expect(tester.getTopLeft(find.text('Hint')).dx, 52.0); + + // By default, the text of item 0 should be aligned with the text of the text field. + expect(tester.getTopLeft(find.text('Item 0').last).dx, 52.0); + + // By default, the text of item 1 should be aligned with the text of the text field, + // so there are some extra padding before "Item 1". + expect(tester.getTopLeft(find.text('Item 1').last).dx, 52.0); + }); + + testWidgets('DropdownMenu can have customized search algorithm', (WidgetTester tester) async { + final theme = ThemeData(); + Widget dropdownMenu({SearchCallback<int>? searchCallback}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: DropdownMenu<int>( + requestFocusOnTap: true, + searchCallback: searchCallback, + dropdownMenuEntries: const <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'All'), + DropdownMenuEntry<int>(value: 1, label: 'Unread'), + DropdownMenuEntry<int>(value: 2, label: 'Read'), + ], + ), + ), + ); + } + + void checkExpectedHighlight({String? searchResult, required List<String> otherItems}) { + if (searchResult != null) { + final Finder material = find.descendant( + of: findMenuItemButton(searchResult), + matching: find.byType(Material), + ); + final Material itemMaterial = tester.widget<Material>(material); + expect(itemMaterial.color, theme.colorScheme.onSurface.withOpacity(0.12)); + } + + for (final nonHighlight in otherItems) { + final Finder material = find.descendant( + of: findMenuItemButton(nonHighlight), + matching: find.byType(Material), + ); + final Material itemMaterial = tester.widget<Material>(material); + expect(itemMaterial.color, Colors.transparent); + } + } + + // Test default. + await tester.pumpWidget(dropdownMenu()); + await tester.pump(); + await tester.tap(find.byType(DropdownMenu<int>)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'read'); + await tester.pump(); + checkExpectedHighlight( + searchResult: 'Unread', + otherItems: <String>['All', 'Read'], + ); // Because "Unread" contains "read". + + // Test custom search algorithm. + await tester.pumpWidget(dropdownMenu(searchCallback: (_, _) => 0)); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'read'); + await tester.pump(); + checkExpectedHighlight( + searchResult: 'All', + otherItems: <String>['Unread', 'Read'], + ); // Because the search result should always be index 0. + + // Test custom search algorithm - exact match. + await tester.pumpWidget( + dropdownMenu( + searchCallback: (List<DropdownMenuEntry<int>> entries, String query) { + if (query.isEmpty) { + return null; + } + final int index = entries.indexWhere( + (DropdownMenuEntry<int> entry) => entry.label == query, + ); + + return index != -1 ? index : null; + }, + ), + ); + await tester.pump(); + + await tester.enterText(find.byType(TextField), 'read'); + await tester.pump(); + checkExpectedHighlight( + otherItems: <String>['All', 'Unread', 'Read'], + ); // Because it's case sensitive. + await tester.enterText(find.byType(TextField), 'Read'); + await tester.pump(); + checkExpectedHighlight(searchResult: 'Read', otherItems: <String>['All', 'Unread']); + }); + + testWidgets('onSelected gets called when a selection is made in a nested menu', ( + WidgetTester tester, + ) async { + var selectionCount = 0; + + final themeData = ThemeData(); + final menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[ + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'), + ]; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: MenuAnchor( + menuChildren: <Widget>[ + DropdownMenu<TestMenu>( + dropdownMenuEntries: menuWithDisabledItems, + onSelected: (_) { + setState(() { + selectionCount++; + }); + }, + ), + ], + builder: (BuildContext context, MenuController controller, Widget? widget) { + return IconButton( + icon: const Icon(Icons.smartphone_rounded), + onPressed: () { + controller.open(); + }, + ); + }, + ), + ); + }, + ), + ), + ); + + // Open the first menu + await tester.tap(find.byType(IconButton)); + await tester.pump(); + // Open the dropdown menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Finder item1 = findMenuItemButton('Item 0'); + await tester.tap(item1); + await tester.pumpAndSettle(); + + expect(selectionCount, 1); + }); + + testWidgets( + 'When onSelected is called and menu is closed, no textEditingController exception is thrown', + (WidgetTester tester) async { + var selectionCount = 0; + + final themeData = ThemeData(); + final menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[ + const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'), + ]; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: MenuAnchor( + menuChildren: <Widget>[ + DropdownMenu<TestMenu>( + dropdownMenuEntries: menuWithDisabledItems, + onSelected: (_) { + setState(() { + selectionCount++; + }); + }, + ), + ], + builder: (BuildContext context, MenuController controller, Widget? widget) { + return IconButton( + icon: const Icon(Icons.smartphone_rounded), + onPressed: () { + controller.open(); + }, + ); + }, + ), + ); + }, + ), + ), + ); + + // Open the first menu + await tester.tap(find.byType(IconButton)); + await tester.pump(); + // Open the dropdown menu + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + final Finder item1 = findMenuItemButton('Item 0'); + await tester.tap(item1); + await tester.pumpAndSettle(); + + expect(selectionCount, 1); + expect(tester.takeException(), isNull); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/139871. + testWidgets( + 'setState is not called through addPostFrameCallback after DropdownMenu is unmounted', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView.builder( + itemCount: 500, + itemBuilder: (BuildContext context, int index) { + if (index == 250) { + return DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren); + } else { + return Container(height: 50); + } + }, + ), + ), + ), + ); + + await tester.fling(find.byType(ListView), const Offset(0, -20000), 200000.0); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Menu shows scrollbar when height is limited', (WidgetTester tester) async { + final menuItems = <DropdownMenuEntry<TestMenu>>[ + DropdownMenuEntry<TestMenu>( + value: TestMenu.mainMenu0, + label: 'Item 0', + style: MenuItemButton.styleFrom(minimumSize: const Size.fromHeight(1000)), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuItems)), + ), + ); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + expect(find.byType(Scrollbar), findsOneWidget); + }, variant: TargetPlatformVariant.all()); + + testWidgets('DropdownMenu.focusNode can focus text input field', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: DropdownMenu<String>( + focusNode: focusNode, + dropdownMenuEntries: const <DropdownMenuEntry<String>>[ + DropdownMenuEntry<String>(value: 'Yolk', label: 'Yolk'), + DropdownMenuEntry<String>(value: 'Eggbert', label: 'Eggbert'), + ], + ), + ), + ), + ); + + RenderBox box = tester.renderObject(find.byType(InputDecorator)); + + // Test input border when not focused. + expect(box, paints..rrect(color: theme.colorScheme.outline)); + + focusNode.requestFocus(); + await tester.pump(); + // Advance input decorator animation. + await tester.pump(const Duration(milliseconds: 200)); + + box = tester.renderObject(find.byType(InputDecorator)); + + // Test input border when focused. + expect(box, paints..rrect(color: theme.colorScheme.primary)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/131120. + testWidgets('Focus traversal ignores non visible entries', (WidgetTester tester) async { + final buttonFocusNode = FocusNode(); + final textFieldFocusNode = FocusNode(); + addTearDown(buttonFocusNode.dispose); + addTearDown(textFieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + focusNode: textFieldFocusNode, + ), + ElevatedButton( + focusNode: buttonFocusNode, + onPressed: () {}, + child: const Text('Button'), + ), + ], + ), + ), + ), + ); + + // Move the focus to the dropdown trailing icon. + primaryFocus!.nextFocus(); + await tester.pump(); + final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down)); + expect(Focus.of(iconButton).hasFocus, isTrue); + + // Move the focus to the text field. + primaryFocus!.nextFocus(); + await tester.pump(); + expect(textFieldFocusNode.hasFocus, isTrue); + + // Move the focus to the elevated button. + primaryFocus!.nextFocus(); + await tester.pump(); + expect(buttonFocusNode.hasFocus, isTrue); + }); + + testWidgets('DropdownMenu honors inputFormatters', (WidgetTester tester) async { + var called = 0; + final formatter = TextInputFormatter.withFunction(( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + called += 1; + return newValue; + }); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<String>( + requestFocusOnTap: true, + controller: controller, + dropdownMenuEntries: const <DropdownMenuEntry<String>>[ + DropdownMenuEntry<String>(value: 'Blue', label: 'Blue'), + DropdownMenuEntry<String>(value: 'Green', label: 'Green'), + ], + inputFormatters: <TextInputFormatter>[ + formatter, + FilteringTextInputFormatter.deny(RegExp('[0-9]')), + ], + ), + ), + ), + ); + + final EditableTextState state = tester.firstState(find.byType(EditableText)); + state.updateEditingValue(const TextEditingValue(text: 'Blue')); + expect(called, 1); + expect(controller.text, 'Blue'); + + state.updateEditingValue(const TextEditingValue(text: 'Green')); + expect(called, 2); + expect(controller.text, 'Green'); + + state.updateEditingValue(const TextEditingValue(text: 'Green2')); + expect(called, 3); + expect(controller.text, 'Green'); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/140596. + testWidgets('Long text item does not overflow', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>( + value: 0, + label: 'This is a long text that is multiplied by 4 so it can overflow. ' * 4, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byType(DropdownMenu<int>)); + await tester.pumpAndSettle(); + + // No exception should be thrown. + expect(tester.takeException(), isNull); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/147076. + testWidgets('Text field does not overflow parent', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>( + value: 0, + label: 'This is a long text that is multiplied by 4 so it can overflow. ' * 4, + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(); + final RenderBox box = tester.firstRenderObject(find.byType(TextField)); + expect(box.size.width, 300.0); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/147173. + testWidgets('Text field with large helper text can be selected', (WidgetTester tester) async { + const labelText = 'MenuEntry 1'; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: DropdownMenu<int>( + hintText: 'Hint text', + helperText: 'Menu Helper text', + inputDecorationTheme: InputDecorationTheme( + helperMaxLines: 2, + helperStyle: TextStyle(fontSize: 30), + ), + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: labelText), + ], + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.tapAt(tester.getCenter(find.text('Hint text'))); + await tester.pumpAndSettle(); + // One is layout for the _DropdownMenuBody, the other one is the real button item in the menu. + expect(find.widgetWithText(MenuItemButton, labelText), findsNWidgets(2)); + }); + + testWidgets('DropdownMenu allows customizing text field text align', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Column( + children: <DropdownMenu<int>>[ + DropdownMenu<int>(dropdownMenuEntries: <DropdownMenuEntry<int>>[]), + DropdownMenu<int>( + textAlign: TextAlign.center, + dropdownMenuEntries: <DropdownMenuEntry<int>>[], + ), + ], + ), + ), + ), + ); + + final List<TextField> fields = tester.widgetList<TextField>(find.byType(TextField)).toList(); + + expect(fields[0].textAlign, TextAlign.start); + expect(fields[1].textAlign, TextAlign.center); + }); + + testWidgets('DropdownMenu correctly sets keyboardType on TextField', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SafeArea( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + keyboardType: TextInputType.number, + ), + ), + ), + ), + ); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.number); + }); + + testWidgets('DropdownMenu keyboardType defaults to TextInputType.text', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SafeArea(child: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ), + ); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.text); + }); + + testWidgets('DropdownMenu passes an alignmentOffset to MenuAnchor', (WidgetTester tester) async { + const alignmentOffset = Offset(0, 16); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<String>( + alignmentOffset: alignmentOffset, + dropdownMenuEntries: <DropdownMenuEntry<String>>[ + DropdownMenuEntry<String>(value: '1', label: 'One'), + DropdownMenuEntry<String>(value: '2', label: 'Two'), + ], + ), + ), + ), + ); + + final MenuAnchor menuAnchor = tester.widget<MenuAnchor>(find.byType(MenuAnchor)); + + expect(menuAnchor.alignmentOffset, alignmentOffset); + }); + + testWidgets('DropdownMenu filter is disabled until text input', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + requestFocusOnTap: true, + enableFilter: true, + initialSelection: menuChildren[0].value, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + + // All entries should be available, and two buttons should be found for each entry. + // One is layout for the _DropdownMenuBody, the other one is the real button item in the menu. + for (final TestMenu menu in TestMenu.values) { + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } + + // Text input would enable the filter. + await tester.enterText(find.byType(TextField).first, 'Menu 1'); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + // 'Menu 1' should be 2, other items should only find one. + if (menu.label == TestMenu.mainMenu1.label) { + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } else { + expect(find.widgetWithText(MenuItemButton, menu.label), findsOneWidget); + } + } + + // Selecting an item would disable filter again. + await tester.tap(findMenuItemButton('Menu 1')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + for (final TestMenu menu in TestMenu.values) { + expect(find.widgetWithText(MenuItemButton, menu.label), findsNWidgets(2)); + } + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/151686. + testWidgets('Setting DropdownMenu.requestFocusOnTap to false makes TextField a button', ( + WidgetTester tester, + ) async { + const label = 'Test'; + Widget buildDropdownMenu({bool? requestFocusOnTap}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>( + requestFocusOnTap: requestFocusOnTap, + dropdownMenuEntries: menuChildren, + hintText: label, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: true)); + + expect( + tester.getSemantics(find.byType(TextField)), + matchesSemantics( + hasFocusAction: true, + hasTapAction: true, + isTextField: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + label: 'Test', + textDirection: TextDirection.ltr, + hasExpandedState: true, + ), + ); + + await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: false)); + + expect( + tester.getSemantics(find.byType(TextField)), + kIsWeb + ? matchesSemantics(isButton: true, hasExpandedState: true) + : matchesSemantics( + isButton: true, + hasExpandedState: true, + hasFocusAction: true, + isTextField: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + label: 'Test', + isReadOnly: true, + textDirection: TextDirection.ltr, + ), + ); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/151854. + testWidgets('scrollToHighlight does not scroll parent', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: controller, + children: <Widget>[ + ListView( + shrinkWrap: true, + children: <Widget>[ + DropdownMenu<TestMenu>( + initialSelection: menuChildren.last.value, + dropdownMenuEntries: menuChildren, + ), + ], + ), + const SizedBox(height: 1000.0), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + expect(controller.offset, 0.0); + }); + + testWidgets('DropdownMenu with expandedInsets can be aligned', (WidgetTester tester) async { + Widget buildMenuAnchor({AlignmentGeometry alignment = Alignment.topCenter}) { + return MaterialApp( + home: Scaffold( + body: Row( + children: <Widget>[ + Expanded( + child: Align( + alignment: alignment, + child: DropdownMenu<TestMenu>( + expandedInsets: const EdgeInsets.all(16), + dropdownMenuEntries: menuChildren, + ), + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildMenuAnchor()); + + Offset textFieldPosition = tester.getTopLeft(find.byType(TextField)); + expect(textFieldPosition, equals(const Offset(16.0, 0.0))); + + await tester.pumpWidget(buildMenuAnchor(alignment: Alignment.center)); + + textFieldPosition = tester.getTopLeft(find.byType(TextField)); + expect(textFieldPosition, equals(const Offset(16.0, 272.0))); + + await tester.pumpWidget(buildMenuAnchor(alignment: Alignment.bottomCenter)); + + textFieldPosition = tester.getTopLeft(find.byType(TextField)); + expect(textFieldPosition, equals(const Offset(16.0, 544.0))); + }); + + // Regression test for https://github.com/flutter/flutter/issues/139269. + testWidgets('DropdownMenu.closeBehavior controls menu closing behavior', ( + WidgetTester tester, + ) async { + Widget buildDropdownMenu({ + DropdownMenuCloseBehavior closeBehavior = DropdownMenuCloseBehavior.all, + }) { + return MaterialApp( + home: Scaffold( + body: MenuAnchor( + menuChildren: <Widget>[ + DropdownMenu<TestMenu>( + closeBehavior: closeBehavior, + dropdownMenuEntries: menuChildren, + ), + ], + child: const Text('Open Menu'), + builder: (BuildContext context, MenuController controller, Widget? child) { + return ElevatedButton(onPressed: () => controller.open(), child: child); + }, + ), + ), + ); + } + + // Test closeBehavior set to all. + await tester.pumpWidget(buildDropdownMenu()); + + // Tap the button to open the root anchor. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + // Tap the menu item to open the dropdown menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu<TestMenu>), findsOneWidget); + + MenuAnchor dropdownMenuAnchor = tester.widget<MenuAnchor>(find.byType(MenuAnchor).last); + expect(dropdownMenuAnchor.controller!.isOpen, true); + + // Tap the dropdown menu item. + await tester.tap(findMenuItemButton(TestMenu.mainMenu0.label)); + await tester.pumpAndSettle(); + // All menus should be closed. + expect(find.byType(DropdownMenu<TestMenu>), findsNothing); + expect(find.byType(MenuAnchor), findsOneWidget); + + // Test closeBehavior set to self. + await tester.pumpWidget(buildDropdownMenu(closeBehavior: DropdownMenuCloseBehavior.self)); + + // Tap the button to open the root anchor. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu<TestMenu>), findsOneWidget); + + // Tap the menu item to open the dropdown menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + dropdownMenuAnchor = tester.widget<MenuAnchor>(find.byType(MenuAnchor).last); + expect(dropdownMenuAnchor.controller!.isOpen, true); + + // Tap the menu item to open the dropdown menu. + await tester.tap(findMenuItemButton(TestMenu.mainMenu0.label)); + await tester.pumpAndSettle(); + // Only the dropdown menu should be closed. + expect(dropdownMenuAnchor.controller!.isOpen, false); + + // Test closeBehavior set to none. + await tester.pumpWidget(buildDropdownMenu(closeBehavior: DropdownMenuCloseBehavior.none)); + + // Tap the button to open the root anchor. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu<TestMenu>), findsOneWidget); + + // Tap the menu item to open the dropdown menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + dropdownMenuAnchor = tester.widget<MenuAnchor>(find.byType(MenuAnchor).last); + expect(dropdownMenuAnchor.controller!.isOpen, true); + + // Tap the dropdown menu item. + await tester.tap(findMenuItemButton(TestMenu.mainMenu0.label)); + await tester.pumpAndSettle(); + // None of the menus should be closed. + expect(dropdownMenuAnchor.controller!.isOpen, true); + }); + + group('The menu is attached at the bottom of the TextField', () { + // Define the expected text field bottom instead of querying it using + // tester.getRect because when tight constraints are applied to the + // Dropdown the TextField bounds are expanded while the visible size + // remains 56 pixels. + const textFieldBottom = 56.0; + + testWidgets('when given loose constraints and expandedInsets is set', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + expandedInsets: EdgeInsets.zero, + initialSelection: TestMenu.mainMenu3, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(tester.getRect(findMenuMaterial()).top, textFieldBottom); + }); + + testWidgets('when given tight constraints and expandedInsets is set', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + height: 300, + child: DropdownMenu<TestMenu>( + expandedInsets: EdgeInsets.zero, + initialSelection: TestMenu.mainMenu3, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(tester.getRect(findMenuMaterial()).top, textFieldBottom); + }); + + // Regression test for https://github.com/flutter/flutter/issues/147076. + testWidgets('when given loose constraints and expandedInsets is not set', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + initialSelection: TestMenu.mainMenu3, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(tester.getRect(findMenuMaterial()).top, textFieldBottom); + }); + + // Regression test for https://github.com/flutter/flutter/issues/147076. + testWidgets('when given tight constraints and expandedInsets is not set', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + height: 300, + child: DropdownMenu<TestMenu>( + initialSelection: TestMenu.mainMenu3, + dropdownMenuEntries: menuChildrenWithIcons, + ), + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(tester.getRect(findMenuMaterial()).top, textFieldBottom); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/143505. + testWidgets('Using keyboard navigation to select', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + TestMenu? selectedMenu; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DropdownMenu<TestMenu>( + focusNode: focusNode, + dropdownMenuEntries: menuChildren, + onSelected: (TestMenu? menu) { + selectedMenu = menu; + }, + ), + ), + ), + ), + ); + + // Adding FocusNode to IconButton causes the IconButton to receive focus. + // Thus it does not matter if the TextField has a FocusNode or not. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + // Now the focus is on the icon button. + final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down)); + expect(Focus.of(iconButton).hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(selectedMenu, TestMenu.mainMenu0); + }, variant: TargetPlatformVariant.all()); + + // Regression test for https://github.com/flutter/flutter/issues/143505. + testWidgets( + 'Using keyboard navigation to select and without setting the FocusNode parameter', + (WidgetTester tester) async { + TestMenu? selectedMenu; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + onSelected: (TestMenu? menu) { + selectedMenu = menu; + }, + ), + ), + ), + ), + ); + + // Adding FocusNode to IconButton causes the IconButton to receive focus. + // Thus it does not matter if the TextField has a FocusNode or not. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + // Now the focus is on the icon button. + final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down)); + expect(Focus.of(iconButton).hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(selectedMenu, TestMenu.mainMenu0); + }, + variant: TargetPlatformVariant.all(), + ); + + // Regression test for https://github.com/flutter/flutter/issues/177993. + testWidgets('Pressing ESC key closes the menu when requestFocusOnTap is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + requestFocusOnTap: false, + ), + ), + ), + ), + ); + + // Move focus to the TextField and open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(findMenuPanel(), findsOne); + + // Press ESC to close the menu. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(findMenuPanel(), findsNothing); + }); + + testWidgets('Pressing ESC key closes the menu when requestFocusOnTap is true', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + requestFocusOnTap: true, + ), + ), + ), + ), + ); + + // Move focus to the TextField and open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(findMenuPanel(), findsOne); + + // Press ESC to close the menu. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(findMenuPanel(), findsNothing); + }); + + testWidgets( + 'Pressing ESC key after changing the selected item closes the menu', + (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + initialSelection: menuChildren[2].value, + ), + ), + ), + ), + ); + + // Move focus to the TextField and open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(findMenuPanel(), findsOne); + + // Move the selection. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(isItemHighlighted(tester, themeData, menuChildren[3].label), isTrue); + + // Press ESC to close the menu. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(findMenuPanel(), findsNothing); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('DropdownMenu passes maxLines to TextField', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ); + TextField textField = tester.widget(find.byType(TextField)); + // Default behavior. + expect(textField.maxLines, 1); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren, maxLines: null), + ), + ), + ); + textField = tester.widget(find.byType(TextField)); + expect(textField.maxLines, null); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren, maxLines: 2), + ), + ), + ); + textField = tester.widget(find.byType(TextField)); + expect(textField.maxLines, 2); + }); + + testWidgets('DropdownMenu passes textInputAction to TextField', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ); + TextField textField = tester.widget(find.byType(TextField)); + // Default behavior. + expect(textField.textInputAction, null); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + textInputAction: TextInputAction.next, + ), + ), + ), + ); + textField = tester.widget(find.byType(TextField)); + expect(textField.textInputAction, TextInputAction.next); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162539 + testWidgets( + 'When requestFocusOnTap is true, the TextField should gain focus after being tapped.', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + requestFocusOnTap: true, + ), + ), + ), + ); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + final Element textField = tester.firstElement(find.byType(TextField)); + expect(Focus.of(textField).hasFocus, isTrue); + }, + ); + + testWidgets('items can be constrainted to be smaller than the text field with menuStyle', ( + WidgetTester tester, + ) async { + const longLabel = 'This is a long text that it can overflow.'; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: longLabel), + ], + menuStyle: MenuStyle(maximumSize: WidgetStatePropertyAll<Size>(Size(150.0, 50.0))), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(tester.getSize(findMenuItemButton(longLabel)).width, 150.0); + + // The overwrite of menuStyle is different when a width is provided, + // So it needs to be tested separately. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + width: 200.0, + dropdownMenuEntries: menuChildren, + menuStyle: const MenuStyle( + maximumSize: WidgetStatePropertyAll<Size>(Size(150.0, 50.0)), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(tester.getSize(findMenuItemButton(menuChildren.first.label)).width, 150.0); + + // The overwrite of menuStyle is different when a width is provided but maximumSize is not, + // So it needs to be tested separately. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + width: 200.0, + dropdownMenuEntries: menuChildren, + menuStyle: const MenuStyle(), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(tester.getSize(findMenuItemButton(menuChildren.first.label)).width, 200.0); + }); + + testWidgets( + 'ensure items are constrained to intrinsic size of DropdownMenu (width or anchor) when no maximumSize', + (WidgetTester tester) async { + const shortLabel = 'Male'; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + width: 200, + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: shortLabel), + ], + menuStyle: MenuStyle(), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(tester.getSize(findMenuItemButton(shortLabel)).width, 200); + + // Use expandedInsets to anchor the TextField to the same size as the parent. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: double.infinity, + child: DropdownMenu<int>( + expandedInsets: EdgeInsets.symmetric(horizontal: 20), + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: shortLabel), + ], + menuStyle: MenuStyle(), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + // Default width is 800, so the expected width is 800 - padding (20 + 20). + expect(tester.getSize(findMenuItemButton(shortLabel)).width, 760.0); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/164905. + testWidgets('ensure exclude semantics for trailing button', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0'), + ], + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + if (kIsWeb) + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasExpandedState, + ], + actions: <SemanticsAction>[SemanticsAction.expand], + ) + else + TestSemantics( + id: 5, + inputType: SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isReadOnly, + SemanticsFlag.isButton, + SemanticsFlag.hasExpandedState, + ], + actions: <SemanticsAction>[ + SemanticsAction.focus, + SemanticsAction.expand, + ], + textDirection: TextDirection.ltr, + currentValueLength: 0, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('restorationId is passed to inner TextField', (WidgetTester tester) async { + const restorationId = 'dropdown_menu'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + requestFocusOnTap: true, + restorationId: restorationId, + ), + ), + ), + ); + + expect(find.byType(TextField), findsOne); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.restorationId, restorationId); + }); + + testWidgets( + 'DropdownMenu does not include the default trailing icon when showTrailingIcon is false', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + showTrailingIcon: false, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.arrow_drop_down); + expect(iconButton, findsNothing); + }, + ); + + testWidgets( + 'DropdownMenu does not include the provided trailing icon when showTrailingIcon is false', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + trailingIcon: const Icon(Icons.ac_unit), + showTrailingIcon: false, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + await tester.pump(); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.ac_unit); + expect(iconButton, findsNothing); + }, + ); + + testWidgets('Explicitly provided controllers should not be disposed when switched out.', ( + WidgetTester tester, + ) async { + final controller1 = TextEditingController(); + final controller2 = TextEditingController(); + Future<void> pumpDropdownMenu(TextEditingController? controller) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>(controller: controller, dropdownMenuEntries: menuChildren), + ), + ), + ); + } + + await pumpDropdownMenu(controller1); + await pumpDropdownMenu(controller2); + controller1.dispose(); + controller2.dispose(); + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/169942. + testWidgets( + 'DropdownMenu disabled state applies proper styling to label and selected value text', + (WidgetTester tester) async { + final themeData = ThemeData(); + final Color disabledColor = themeData.colorScheme.onSurface.withOpacity(0.38); + + Widget buildDropdownMenu({required bool isEnabled}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: DropdownMenu<String>( + width: double.infinity, + enabled: isEnabled, + initialSelection: 'One', + label: const Text('Choose number'), + dropdownMenuEntries: const <DropdownMenuEntry<String>>[ + DropdownMenuEntry<String>(value: 'One', label: 'One'), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildDropdownMenu(isEnabled: true)); + + // Find the TextField and its EditableText from DropdownMenu. + final TextField enabledTextField = tester.widget(find.byType(TextField)); + final EditableText enabledEditableText = tester.widget(find.byType(EditableText)); + + // Verify enabled state styling for the TextField. + expect(enabledTextField.enabled, isTrue); + expect(enabledEditableText.style.color, isNot(disabledColor)); + + // Switch to the disabled state by rebuilding the widget. + await tester.pumpWidget(buildDropdownMenu(isEnabled: false)); + + // Find the TextField and its EditableText in disabled state. + final TextField textField = tester.widget(find.byType(TextField)); + final EditableText disabledEditableText = tester.widget(find.byType(EditableText)); + + // Verify disabled state styling for the TextField. + expect(textField.enabled, isFalse); + expect(disabledEditableText.style.color, disabledColor); + + // Verify the selected value text has disabled color. + final EditableText selectedValueText = tester.widget<EditableText>( + find.descendant(of: find.byType(TextField), matching: find.byType(EditableText)), + ); + expect(selectedValueText.style.color, disabledColor); + }, + ); + + testWidgets('DropdownMenu trailingIconFocusNode is created when not provided', ( + WidgetTester tester, + ) async { + final textFieldFocusNode = FocusNode(); + final buttonFocusNode = FocusNode(); + addTearDown(textFieldFocusNode.dispose); + addTearDown(buttonFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + focusNode: textFieldFocusNode, + ), + ElevatedButton( + focusNode: buttonFocusNode, + onPressed: () {}, + child: const Text('Button'), + ), + ], + ), + ), + ), + ); + + primaryFocus!.nextFocus(); + await tester.pump(); + + // Ensure the trailing icon does not have focus. + // If FocusNode is not created then the TextField will have focus. + final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down)); + expect(Focus.of(iconButton).hasFocus, isTrue); + + // Ensure the TextField has focus. + primaryFocus!.nextFocus(); + await tester.pump(); + expect(textFieldFocusNode.hasFocus, isTrue); + + // Ensure the button has focus. + primaryFocus!.nextFocus(); + await tester.pump(); + expect(buttonFocusNode.hasFocus, isTrue); + }); + + testWidgets('DropdownMenu trailingIconFocusNode is used when provided', ( + WidgetTester tester, + ) async { + final textFieldFocusNode = FocusNode(); + final trailingIconFocusNode = FocusNode(); + final buttonFocusNode = FocusNode(); + addTearDown(textFieldFocusNode.dispose); + addTearDown(trailingIconFocusNode.dispose); + addTearDown(buttonFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + focusNode: textFieldFocusNode, + trailingIconFocusNode: trailingIconFocusNode, + ), + ElevatedButton( + focusNode: buttonFocusNode, + onPressed: () {}, + child: const Text('Button'), + ), + ], + ), + ), + ), + ); + + primaryFocus!.nextFocus(); + await tester.pump(); + + // Ensure the trailing icon has focus. + expect(trailingIconFocusNode.hasFocus, isTrue); + + // Ensure the TextField has focus. + primaryFocus!.nextFocus(); + await tester.pump(); + expect(textFieldFocusNode.hasFocus, isTrue); + + // Ensure the button has focus. + primaryFocus!.nextFocus(); + await tester.pump(); + expect(buttonFocusNode.hasFocus, isTrue); + }); + + testWidgets( + 'Throw assertion error when showTrailingIcon is false and trailingIconFocusNode is provided', + (WidgetTester tester) async { + expect(() { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + showTrailingIcon: false, + trailingIconFocusNode: focusNode, + dropdownMenuEntries: menuChildren, + ), + ), + ); + }, throwsAssertionError); + }, + ); + + testWidgets('DropdownMenu can set cursorHeight', (WidgetTester tester) async { + const cursorHeight = 4.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + cursorHeight: cursorHeight, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.cursorHeight, cursorHeight); + }); + + testWidgets('DropdownMenu.scrollPadding is passed through to EditableText', ( + WidgetTester tester, + ) async { + const scrollPadding = EdgeInsets.all(30.0); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + scrollPadding: scrollPadding, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.scrollPadding, scrollPadding); + }); + + testWidgets('DropdownMenu.scrollPadding defaults to EdgeInsets.all(20.0)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.scrollPadding, const EdgeInsets.all(20.0)); + }); + + testWidgets('DropdownMenu accepts a MenuController', (WidgetTester tester) async { + final menuController = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + ), + ), + ), + ); + expect(findMenuItemButton('Item 0').hitTestable(), findsNothing); + menuController.open(); + await tester.pumpAndSettle(); + expect(findMenuItemButton('Item 0').hitTestable(), findsOne); + menuController.close(); + await tester.pumpAndSettle(); + expect(findMenuItemButton('Item 0').hitTestable(), findsNothing); + }); + + group('DropdownMenu.decorationBuilder', () { + const labelText = 'labelText'; + InputDecoration buildDecorationWithSuffixIcon(BuildContext context, MenuController controller) { + return InputDecoration( + labelText: labelText, + suffixIcon: controller.isOpen + ? const Icon(Icons.arrow_drop_up) + : const Icon(Icons.arrow_drop_down), + ); + } + + InputDecoration buildDecoration(BuildContext context, MenuController controller) { + return const InputDecoration(labelText: labelText); + } + + testWidgets('Decoration properties set by decorationBuilder are applied', ( + WidgetTester tester, + ) async { + final menuController = MenuController(); + const decoration = InputDecoration( + labelText: labelText, + helperText: 'helperText', + hintText: 'hintText', + filled: true, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + decorationBuilder: (BuildContext context, MenuController controller) { + return decoration; + }, + ), + ), + ), + ); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + final InputDecoration effectiveDecoration = textField.decoration!; + + expect(effectiveDecoration.labelText, decoration.labelText); + expect(effectiveDecoration.helperText, decoration.helperText); + expect(effectiveDecoration.hintText, decoration.hintText); + expect(effectiveDecoration.filled, decoration.filled); + }); + + testWidgets('Custom decorationBuilder can replace default suffixIcon', ( + WidgetTester tester, + ) async { + final menuController = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + decorationBuilder: buildDecorationWithSuffixIcon, + ), + ), + ), + ); + + expect(find.byIcon(Icons.arrow_drop_down), findsNWidgets(2)); + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets('Custom decorationBuilder is called when the menu opens and closes', ( + WidgetTester tester, + ) async { + final menuController = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + decorationBuilder: buildDecorationWithSuffixIcon, + ), + ), + ), + ); + + expect(find.byIcon(Icons.arrow_drop_down), findsNWidgets(2)); + expect(find.byIcon(Icons.arrow_drop_up), findsNothing); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + // Check that the custom decorationBuilder updated the icon. + expect(find.byIcon(Icons.arrow_drop_down), findsNothing); + expect(find.byIcon(Icons.arrow_drop_up), findsNWidgets(2)); + }); + + testWidgets( + 'Default IconButton is used when decorationBuilder does not set InputDecoration.suffixIcon', + (WidgetTester tester) async { + final menuController = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + decorationBuilder: buildDecoration, + ), + ), + ), + ); + + expect(find.byType(IconButton), findsNWidgets(2)); + }, + ); + + testWidgets('Passing label and decorationBuilder throws', (WidgetTester tester) async { + final menuController = MenuController(); + await expectLater(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + label: const Text('Label'), + decorationBuilder: buildDecoration, + ), + ), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('Passing hintText and decorationBuilder throws', (WidgetTester tester) async { + final menuController = MenuController(); + await expectLater(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + hintText: 'hintText', + decorationBuilder: buildDecoration, + ), + ), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('Passing helperText and decorationBuilder throws', (WidgetTester tester) async { + final menuController = MenuController(); + await expectLater(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + hintText: 'hintText', + decorationBuilder: buildDecoration, + ), + ), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('Passing errorText and decorationBuilder throws', (WidgetTester tester) async { + final menuController = MenuController(); + await expectLater(() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + dropdownMenuEntries: menuChildren, + errorText: 'errorText', + decorationBuilder: buildDecoration, + ), + ), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('Preferred width takes labelText into account', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + decorationBuilder: (BuildContext context, MenuController controller) { + return const InputDecoration(labelText: 'Long label text'); + }, + ), + ), + ), + ); + + final double width = tester.getSize(find.byType(TextField)).width; + expect(width, 327.5); + }); + + testWidgets('Preferred width takes label into account', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + decorationBuilder: (BuildContext context, MenuController controller) { + return const InputDecoration(label: SizedBox(width: 200)); + }, + ), + ), + ), + ); + + final double width = tester.getSize(find.byType(TextField)).width; + expect(width, 280); + }); + }); + + group('DropdownMenu.selectOnly', () { + testWidgets('defaults to false on all platforms', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren)), + ), + ); + + final DropdownMenu<TestMenu> dropdownMenu = tester.firstWidget( + find.byType(DropdownMenu<TestMenu>), + ); + expect(dropdownMenu.selectOnly, false); + }, variant: TargetPlatformVariant.all()); + + testWidgets('when true and requestFocusOnTap is false, makes the text field readOnly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + selectOnly: true, + requestFocusOnTap: false, + ), + ), + ), + ); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.readOnly, true); + }); + + testWidgets('when true and requestFocusOnTap is true, makes the text field readOnly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + selectOnly: true, + requestFocusOnTap: true, + ), + ), + ), + ); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.readOnly, true); + }); + + testWidgets( + 'when true and requestFocusOnTap is false, disables text field interactive selection', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + selectOnly: true, + requestFocusOnTap: false, + ), + ), + ), + ); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.enableInteractiveSelection, false); + }, + ); + + testWidgets( + 'when true and requestFocusOnTap is true, disables text field interactive selection', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + selectOnly: true, + requestFocusOnTap: true, + ), + ), + ), + ); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.enableInteractiveSelection, false); + }, + ); + + testWidgets( + 'when true and requestFocusOnTap is false, does not make the text field focusable', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + selectOnly: true, + requestFocusOnTap: false, + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.focusNode.hasFocus, false); + + // Open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(editableText.focusNode.hasFocus, false); + }, + ); + + testWidgets('when true and requestFocusOnTap is true, makes the text field focusable', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + selectOnly: true, + requestFocusOnTap: true, + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.focusNode.hasFocus, false); + + // Open the menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(editableText.focusNode.hasFocus, true); + }); + + testWidgets('when true and the text field is focused, pressing enter opens the menu', ( + WidgetTester tester, + ) async { + final menuController = MenuController(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenu<TestMenu>( + menuController: menuController, + focusNode: focusNode, + dropdownMenuEntries: menuChildren, + selectOnly: true, + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + + // Focus the dropdownMenu. + expect(editableText.focusNode.hasFocus, false); + focusNode.requestFocus(); + await tester.pump(); + expect(editableText.focusNode.hasFocus, true); + + // Pressing enter opens the menu. + expect(menuController.isOpen, false); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(menuController.isOpen, true); + }); + + testWidgets('when true, the mouse cursor should be SystemMouseCursors.click when hovered', ( + WidgetTester tester, + ) async { + Widget buildDropdownMenu() => MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + DropdownMenu<TestMenu>(selectOnly: true, dropdownMenuEntries: menuChildren), + ], + ), + ), + ); + + await tester.pumpWidget(buildDropdownMenu()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.moveTo(tester.getCenter(find.byType(TextField))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/174609. + testWidgets( + 'DropdownMenu keeps the selected item from filtered list after entries list is updated', + (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownMenu<TestMenu>( + controller: controller, + requestFocusOnTap: true, + enableFilter: true, + // toList() is used here to simulate list update. + dropdownMenuEntries: menuChildren.toList(), + onSelected: (_) { + setState(() {}); + }, + ); + }, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pump(); + + // Filter the entries to only show 'Menu 1'. + await tester.enterText(find.byType(TextField).first, TestMenu.mainMenu1.label); + await tester.pump(); + + // Select the 'Menu 1' item. + await tester.tap(findMenuItemButton(TestMenu.mainMenu1.label)); + await tester.pumpAndSettle(); + + expect(controller.text, TestMenu.mainMenu1.label); + }, + ); + + testWidgets('DropdownMenu does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'I'); + addTearDown(controller.dispose); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + controller: controller, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DropdownMenu<TestMenu>)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + expect(find.byType(MenuItemButton), findsWidgets); + }); + + // The variants to test in the focus handling test. + final focusVariants = ValueVariant<TextInputAction>(TextInputAction.values.toSet()); + + // Regression test for https://github.com/flutter/flutter/issues/177009. + testWidgets('Handles focus correctly when TextInputAction is invoked', ( + WidgetTester tester, + ) async { + Future<void> ensureCorrectFocusHandlingForAction( + TextInputAction textInputAction, { + required bool shouldLoseFocus, + bool shouldFocusNext = false, + bool shouldFocusPrevious = false, + }) async { + final previousFocusNode = FocusNode(); + final textFieldFocusNode = FocusNode(); + final nextFocusNode = FocusNode(); + addTearDown(previousFocusNode.dispose); + addTearDown(textFieldFocusNode.dispose); + addTearDown(nextFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + TextButton( + focusNode: previousFocusNode, + child: const Text('Previous'), + onPressed: () {}, + ), + DropdownMenu<TestMenu>( + dropdownMenuEntries: menuChildren, + focusNode: textFieldFocusNode, + textInputAction: textInputAction, + requestFocusOnTap: true, + showTrailingIcon: false, + ), + TextButton(focusNode: nextFocusNode, child: const Text('Next'), onPressed: () {}), + ], + ), + ), + ), + ); + + expect(textFieldFocusNode.hasFocus, isFalse); + + // Tap on DropdownMenu to request focus on the TextField. + await tester.tap(find.byType(DropdownMenu<TestMenu>)); + await tester.pumpAndSettle(); + expect(textFieldFocusNode.hasFocus, isTrue); + + await tester.testTextInput.receiveAction(textInputAction); + await tester.pumpAndSettle(); + + expect(previousFocusNode.hasFocus, equals(shouldFocusPrevious)); + expect(textFieldFocusNode.hasFocus, equals(!shouldLoseFocus)); + expect(nextFocusNode.hasFocus, equals(shouldFocusNext)); + } + + // The expectations for each of the types of TextInputAction. + const actionShouldLoseFocus = <TextInputAction, bool>{ + TextInputAction.none: false, + TextInputAction.unspecified: false, + TextInputAction.done: true, + TextInputAction.go: true, + TextInputAction.search: true, + TextInputAction.send: true, + TextInputAction.continueAction: false, + TextInputAction.join: false, + TextInputAction.route: false, + TextInputAction.emergencyCall: false, + TextInputAction.newline: true, + TextInputAction.next: true, + TextInputAction.previous: true, + }; + + final TextInputAction textInputAction = focusVariants.currentValue!; + expect(actionShouldLoseFocus.containsKey(textInputAction), isTrue); + + await ensureCorrectFocusHandlingForAction( + textInputAction, + shouldLoseFocus: actionShouldLoseFocus[textInputAction]!, + shouldFocusNext: textInputAction == TextInputAction.next, + shouldFocusPrevious: textInputAction == TextInputAction.previous, + ); + }, variant: focusVariants); + + // Regression test for https://github.com/flutter/flutter/issues/180121. + testWidgets('Allow null entry to clear selection', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + const selectNoneLabel = 'Select none'; + final nullableMenuItems = <DropdownMenuEntry<String?>>[ + const DropdownMenuEntry<String?>(value: null, label: selectNoneLabel), + const DropdownMenuEntry<String?>(value: 'a', label: 'A'), + const DropdownMenuEntry<String?>(value: 'b', label: 'B'), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownMenu<String?>( + controller: controller, + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: nullableMenuItems, + onSelected: (_) { + setState(() {}); + }, + ); + }, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu<String?>)); + await tester.pump(); + + // Select the 'None' item. + await tester.tap(findMenuItemButton(selectNoneLabel)); + await tester.pumpAndSettle(); + + expect(controller.text, selectNoneLabel); + }); +} + +enum TestMenu { + mainMenu0('Item 0'), + mainMenu1('Menu 1'), + mainMenu2('Item 2'), + mainMenu3('Item 3'), + mainMenu4('Item 4'), + mainMenu5('Item 5'); + + const TestMenu(this.label); + final String label; +} + +enum ShortMenu { + item0('I0'), + item1('I1'), + item2('I2'); + + const ShortMenu(this.label); + final String label; +} + +// A helper widget that creates a render object designed to call `getDryLayout` +// on its child during its own `performLayout` phase. This is used to test +// that a child's `computeDryLayout` implementation is valid. +class _TestDryLayout extends SingleChildRenderObjectWidget { + const _TestDryLayout({super.child}); + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderTestDryLayout(); + } +} + +class _RenderTestDryLayout extends RenderProxyBox { + @override + void performLayout() { + if (child == null) { + size = constraints.smallest; + return; + } + + child!.getDryLayout(constraints); + child!.layout(constraints, parentUsesSize: true); + size = child!.size; + } +} diff --git a/packages/material_ui/test/material/dropdown_menu_theme_test.dart b/packages/material_ui/test/material/dropdown_menu_theme_test.dart new file mode 100644 index 000000000000..bbeb7d7586e2 --- /dev/null +++ b/packages/material_ui/test/material/dropdown_menu_theme_test.dart @@ -0,0 +1,445 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Finder findMenuItemButton(String label) { + // For each menu items there are two MenuItemButton widgets. + // The last one is the real button item in the menu. + // The first one is not visible, it is part of _DropdownMenuBody + // which is used to compute the dropdown width. + return find.widgetWithText(MenuItemButton, label).last; + } + + Material getButtonMaterial(WidgetTester tester, String itemLabel) { + return tester.widget<Material>( + find.descendant(of: findMenuItemButton(itemLabel), matching: find.byType(Material)), + ); + } + + Finder findMenuPanel() { + return find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MenuPanel'); + } + + Material getMenuMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findMenuPanel(), matching: find.byType(Material)).first, + ); + } + + test('DropdownMenuThemeData copyWith, ==, hashCode basics', () { + expect(const DropdownMenuThemeData(), const DropdownMenuThemeData().copyWith()); + expect( + const DropdownMenuThemeData().hashCode, + const DropdownMenuThemeData().copyWith().hashCode, + ); + + const custom = DropdownMenuThemeData( + menuStyle: MenuStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.green)), + inputDecorationTheme: InputDecorationTheme(filled: true), + textStyle: TextStyle(fontSize: 25.0), + ); + final DropdownMenuThemeData copy = const DropdownMenuThemeData().copyWith( + menuStyle: custom.menuStyle, + inputDecorationTheme: custom.inputDecorationTheme, + textStyle: custom.textStyle, + ); + expect(copy, custom); + }); + + test('DropdownMenuThemeData lerp special cases', () { + expect(DropdownMenuThemeData.lerp(null, null, 0), const DropdownMenuThemeData()); + const data = DropdownMenuThemeData(); + expect(identical(DropdownMenuThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Default DropdownMenuThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const DropdownMenuThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('With no other configuration, defaults are used', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Scaffold( + body: Center( + child: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0'), + DropdownMenuEntry<int>(value: 1, label: 'Item 1'), + DropdownMenuEntry<int>(value: 2, label: 'Item 2'), + ], + ), + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, themeData.textTheme.labelLarge!.color); + expect(editableText.style.background, themeData.textTheme.labelLarge!.background); + expect(editableText.style.shadows, themeData.textTheme.labelLarge!.shadows); + expect(editableText.style.decoration, themeData.textTheme.labelLarge!.decoration); + expect(editableText.style.locale, themeData.textTheme.labelLarge!.locale); + expect(editableText.style.wordSpacing, themeData.textTheme.labelLarge!.wordSpacing); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.border, const OutlineInputBorder()); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + Material material = getMenuMaterial(tester); + expect(material.color, themeData.colorScheme.surfaceContainer); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 3.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + material = getButtonMaterial(tester, 'Item 0'); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface); + }); + + testWidgets('ThemeData.dropdownMenuTheme overrides defaults', (WidgetTester tester) async { + final theme = ThemeData( + dropdownMenuTheme: DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.orange, + backgroundColor: Colors.indigo, + fontSize: 30.0, + shadows: kElevationToShadow[1], + decoration: TextDecoration.underline, + wordSpacing: 2.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.grey), + shadowColor: MaterialStatePropertyAll<Color>(Colors.brown), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.amberAccent), + elevation: MaterialStatePropertyAll<double>(10.0), + shape: MaterialStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: Colors.lightGreen, + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center( + child: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0'), + DropdownMenuEntry<int>(value: 1, label: 'Item 1'), + DropdownMenuEntry<int>(value: 2, label: 'Item 2'), + ], + ), + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.orange); + expect(editableText.style.backgroundColor, Colors.indigo); + expect(editableText.style.shadows, kElevationToShadow[1]); + expect(editableText.style.decoration, TextDecoration.underline); + expect(editableText.style.wordSpacing, 2.0); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.filled, isTrue); + expect(textField.decoration?.fillColor, Colors.lightGreen); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + Material material = getMenuMaterial(tester); + expect(material.color, Colors.grey); + expect(material.shadowColor, Colors.brown); + expect(material.surfaceTintColor, Colors.amberAccent); + expect(material.elevation, 10.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ); + + material = getButtonMaterial(tester, 'Item 0'); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); + + testWidgets('DropdownMenuTheme overrides ThemeData and defaults', (WidgetTester tester) async { + final global = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.orange, + backgroundColor: Colors.indigo, + fontSize: 30.0, + shadows: kElevationToShadow[1], + decoration: TextDecoration.underline, + wordSpacing: 2.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.grey), + shadowColor: MaterialStatePropertyAll<Color>(Colors.brown), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.amberAccent), + elevation: MaterialStatePropertyAll<double>(10.0), + shape: MaterialStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.lightGreen), + ); + + final dropdownMenuTheme = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.red, + backgroundColor: Colors.orange, + fontSize: 27.0, + shadows: kElevationToShadow[2], + decoration: TextDecoration.lineThrough, + wordSpacing: 5.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.yellow), + shadowColor: MaterialStatePropertyAll<Color>(Colors.green), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.teal), + elevation: MaterialStatePropertyAll<double>(15.0), + shape: MaterialStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.blue), + ); + + final theme = ThemeData(dropdownMenuTheme: global); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: DropdownMenuTheme( + data: dropdownMenuTheme, + child: const Scaffold( + body: Center( + child: DropdownMenu<int>( + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0'), + DropdownMenuEntry<int>(value: 1, label: 'Item 1'), + DropdownMenuEntry<int>(value: 2, label: 'Item 2'), + ], + ), + ), + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.red); + expect(editableText.style.backgroundColor, Colors.orange); + expect(editableText.style.fontSize, 27.0); + expect(editableText.style.shadows, kElevationToShadow[2]); + expect(editableText.style.decoration, TextDecoration.lineThrough); + expect(editableText.style.wordSpacing, 5.0); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.filled, isTrue); + expect(textField.decoration?.fillColor, Colors.blue); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + Material material = getMenuMaterial(tester); + expect(material.color, Colors.yellow); + expect(material.shadowColor, Colors.green); + expect(material.surfaceTintColor, Colors.teal); + expect(material.elevation, 15.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ); + + material = getButtonMaterial(tester, 'Item 0'); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); + + testWidgets('Widget parameters overrides DropdownMenuTheme, ThemeData and defaults', ( + WidgetTester tester, + ) async { + final global = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.orange, + backgroundColor: Colors.indigo, + fontSize: 30.0, + shadows: kElevationToShadow[1], + decoration: TextDecoration.underline, + wordSpacing: 2.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.grey), + shadowColor: MaterialStatePropertyAll<Color>(Colors.brown), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.amberAccent), + elevation: MaterialStatePropertyAll<double>(10.0), + shape: MaterialStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.lightGreen), + ); + + final dropdownMenuTheme = DropdownMenuThemeData( + textStyle: TextStyle( + color: Colors.red, + backgroundColor: Colors.orange, + fontSize: 27.0, + shadows: kElevationToShadow[2], + decoration: TextDecoration.lineThrough, + wordSpacing: 5.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.yellow), + shadowColor: MaterialStatePropertyAll<Color>(Colors.green), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.teal), + elevation: MaterialStatePropertyAll<double>(15.0), + shape: MaterialStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme(filled: true, fillColor: Colors.blue), + ); + + final theme = ThemeData(dropdownMenuTheme: global); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: DropdownMenuTheme( + data: dropdownMenuTheme, + child: Scaffold( + body: Center( + child: DropdownMenu<int>( + textStyle: TextStyle( + color: Colors.pink, + backgroundColor: Colors.cyan, + fontSize: 32.0, + shadows: kElevationToShadow[3], + decoration: TextDecoration.overline, + wordSpacing: 3.0, + ), + menuStyle: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.limeAccent), + shadowColor: MaterialStatePropertyAll<Color>(Colors.deepOrangeAccent), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.lightBlue), + elevation: MaterialStatePropertyAll<double>(21.0), + shape: MaterialStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: Colors.deepPurple, + ), + dropdownMenuEntries: const <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0'), + DropdownMenuEntry<int>(value: 1, label: 'Item 1'), + DropdownMenuEntry<int>(value: 2, label: 'Item 2'), + ], + ), + ), + ), + ), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.pink); + expect(editableText.style.backgroundColor, Colors.cyan); + expect(editableText.style.fontSize, 32.0); + expect(editableText.style.shadows, kElevationToShadow[3]); + expect(editableText.style.decoration, TextDecoration.overline); + expect(editableText.style.wordSpacing, 3.0); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.decoration?.filled, isTrue); + expect(textField.decoration?.fillColor, Colors.deepPurple); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_drop_down).first); + await tester.pump(); + expect(find.byType(MenuAnchor), findsOneWidget); + + Material material = getMenuMaterial(tester); + expect(material.color, Colors.limeAccent); + expect(material.shadowColor, Colors.deepOrangeAccent); + expect(material.surfaceTintColor, Colors.lightBlue); + expect(material.elevation, 21.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))), + ); + + material = getButtonMaterial(tester, 'Item 0'); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); + + testWidgets( + 'DropdownMenuThemeData.menuStyle.disabledColor is being applied when the menu is disabled', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dropdownMenuTheme: const DropdownMenuThemeData(disabledColor: Colors.grey), + ), + home: const Scaffold( + body: Center( + child: DropdownMenu<int>( + enabled: false, + initialSelection: 0, + dropdownMenuEntries: <DropdownMenuEntry<int>>[ + DropdownMenuEntry<int>(value: 0, label: 'Item 0'), + DropdownMenuEntry<int>(value: 1, label: 'Item 1'), + DropdownMenuEntry<int>(value: 2, label: 'Item 2'), + ], + ), + ), + ), + ), + ); + + // make sure the displaying text has grey color + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.grey); + }, + ); +} diff --git a/packages/material_ui/test/material/dropdown_test.dart b/packages/material_ui/test/material/dropdown_test.dart new file mode 100644 index 000000000000..00a6226fffb2 --- /dev/null +++ b/packages/material_ui/test/material/dropdown_test.dart @@ -0,0 +1,4994 @@ +// 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. + +// no-shuffle: +// //TODO(gspencergoog): Remove this tag once this test's state leaks/test +// dependencies have been fixed. +// https://github.com/flutter/flutter/issues/85160 +// Fails with "flutter test --test-randomize-ordering-seed=456" +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set', 'no-shuffle']) +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +const List<String> menuItems = <String>['one', 'two', 'three', 'four']; + +void onChanged<T>(T _) {} + +final Type dropdownButtonType = DropdownButton<String>( + onChanged: (_) {}, + items: const <DropdownMenuItem<String>>[], +).runtimeType; + +Finder _iconRichText(Key iconKey) { + return find.descendant(of: find.byKey(iconKey), matching: find.byType(RichText)); +} + +Widget buildDropdown({ + required bool isFormField, + Key? buttonKey, + String? initialValue = 'two', + ValueChanged<String?>? onChanged, + VoidCallback? onTap, + Widget? icon, + Color? iconDisabledColor, + Color? iconEnabledColor, + double iconSize = 24.0, + bool isDense = false, + bool isExpanded = false, + Widget? hint, + Widget? disabledHint, + Widget? underline, + List<String>? items = menuItems, + List<Widget> Function(BuildContext)? selectedItemBuilder, + double? itemHeight = kMinInteractiveDimension, + double? menuWidth, + AlignmentDirectional alignment = AlignmentDirectional.centerStart, + TextDirection textDirection = TextDirection.ltr, + Size? mediaSize, + FocusNode? focusNode, + bool autofocus = false, + Color? focusColor, + Color? dropdownColor, + double? menuMaxHeight, + EdgeInsetsGeometry? padding, + InputDecoration? decoration, +}) { + final List<DropdownMenuItem<String>>? listItems = items?.map<DropdownMenuItem<String>>(( + String item, + ) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(); + + if (isFormField) { + return Form( + child: DropdownButtonFormField<String>( + key: buttonKey, + initialValue: initialValue, + hint: hint, + disabledHint: disabledHint, + onChanged: onChanged, + onTap: onTap, + icon: icon, + iconSize: iconSize, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + isDense: isDense, + isExpanded: isExpanded, + // No underline attribute + focusNode: focusNode, + autofocus: autofocus, + focusColor: focusColor, + dropdownColor: dropdownColor, + items: listItems, + selectedItemBuilder: selectedItemBuilder, + itemHeight: itemHeight, + alignment: alignment, + menuMaxHeight: menuMaxHeight, + padding: padding, + decoration: decoration, + ), + ); + } + return DropdownButton<String>( + key: buttonKey, + value: initialValue, + hint: hint, + disabledHint: disabledHint, + onChanged: onChanged, + onTap: onTap, + icon: icon, + iconSize: iconSize, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + isDense: isDense, + isExpanded: isExpanded, + underline: underline, + focusNode: focusNode, + autofocus: autofocus, + focusColor: focusColor, + dropdownColor: dropdownColor, + items: listItems, + selectedItemBuilder: selectedItemBuilder, + itemHeight: itemHeight, + menuWidth: menuWidth, + alignment: alignment, + menuMaxHeight: menuMaxHeight, + padding: padding, + ); +} + +Widget buildFrame({ + Key? buttonKey, + String? initialValue = 'two', + ValueChanged<String?>? onChanged, + VoidCallback? onTap, + Widget? icon, + Color? iconDisabledColor, + Color? iconEnabledColor, + double iconSize = 24.0, + bool isDense = false, + bool isExpanded = false, + Widget? hint, + Widget? disabledHint, + Widget? underline, + List<String>? items = menuItems, + List<Widget> Function(BuildContext)? selectedItemBuilder, + double? itemHeight = kMinInteractiveDimension, + double? menuWidth, + AlignmentDirectional alignment = AlignmentDirectional.centerStart, + TextDirection textDirection = TextDirection.ltr, + Size? mediaSize, + FocusNode? focusNode, + bool autofocus = false, + Color? focusColor, + Color? dropdownColor, + bool isFormField = false, + double? menuMaxHeight, + EdgeInsetsGeometry? padding, + Alignment dropdownAlignment = Alignment.center, + bool? useMaterial3, + InputDecoration? decoration, + InputDecorationThemeData? localInputDecorationTheme, +}) { + return Theme( + data: ThemeData(useMaterial3: useMaterial3), + child: TestApp( + textDirection: textDirection, + mediaSize: mediaSize, + child: Material( + child: Align( + alignment: dropdownAlignment, + child: RepaintBoundary( + child: InputDecorationTheme( + data: localInputDecorationTheme, + child: buildDropdown( + isFormField: isFormField, + buttonKey: buttonKey, + initialValue: initialValue, + hint: hint, + disabledHint: disabledHint, + onChanged: onChanged, + onTap: onTap, + icon: icon, + iconSize: iconSize, + iconDisabledColor: iconDisabledColor, + iconEnabledColor: iconEnabledColor, + isDense: isDense, + isExpanded: isExpanded, + underline: underline, + focusNode: focusNode, + autofocus: autofocus, + focusColor: focusColor, + dropdownColor: dropdownColor, + items: items, + selectedItemBuilder: selectedItemBuilder, + itemHeight: itemHeight, + menuWidth: menuWidth, + alignment: alignment, + menuMaxHeight: menuMaxHeight, + padding: padding, + decoration: decoration, + ), + ), + ), + ), + ), + ), + ); +} + +Widget buildDropdownWithHint({ + required AlignmentDirectional alignment, + required bool isExpanded, + bool enableSelectedItemBuilder = false, +}) { + return buildFrame( + useMaterial3: false, + mediaSize: const Size(800, 600), + itemHeight: 100.0, + alignment: alignment, + isExpanded: isExpanded, + selectedItemBuilder: enableSelectedItemBuilder + ? (BuildContext context) { + return menuItems.map<Widget>((String item) { + return ColoredBox(color: const Color(0xff00ff00), child: Text(item)); + }).toList(); + } + : null, + hint: const Text('hint'), + ); +} + +class TestApp extends StatefulWidget { + const TestApp({super.key, required this.textDirection, required this.child, this.mediaSize}); + + final TextDirection textDirection; + final Widget child; + final Size? mediaSize; + + @override + State<TestApp> createState() => _TestAppState(); +} + +class _TestAppState extends State<TestApp> { + @override + Widget build(BuildContext context) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: MediaQuery( + data: MediaQueryData.fromView(View.of(context)).copyWith(size: widget.mediaSize), + child: Directionality( + textDirection: widget.textDirection, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + assert(settings.name == '/'); + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) => widget.child, + ); + }, + ), + ), + ), + ); + } +} + +// When the dropdown's menu is popped up, a RenderParagraph for the selected +// menu's text item will appear both in the dropdown button and in the menu. +// The RenderParagraphs should be aligned, i.e. they should have the same +// size and location. +void checkSelectedItemTextGeometry(WidgetTester tester, String value) { + final List<RenderBox> boxes = tester + .renderObjectList<RenderBox>(find.byKey(ValueKey<String>('${value}Text'))) + .toList(); + expect(boxes.length, equals(2)); + final RenderBox box0 = boxes[0]; + final RenderBox box1 = boxes[1]; + expect(box0.localToGlobal(Offset.zero), equals(box1.localToGlobal(Offset.zero))); + expect(box0.size, equals(box1.size)); +} + +// The dropdown menu isn't readily accessible. To find it we're assuming that it +// contains a ListView and that it's an instance of _DropdownMenu. +Rect getMenuRect(WidgetTester tester) { + late Rect menuRect; + tester.element(find.byType(ListView)).visitAncestorElements((Element element) { + if (element.toString().startsWith('_DropdownMenu')) { + final box = element.findRenderObject()! as RenderBox; + menuRect = box.localToGlobal(Offset.zero) & box.size; + return false; + } + return true; + }); + return menuRect; +} + +Future<void> checkDropdownColor( + WidgetTester tester, { + Color? color, + bool isFormField = false, +}) async { + const text = 'foo'; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: isFormField + ? Form( + child: DropdownButtonFormField<String>( + dropdownColor: color, + initialValue: text, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: text, child: Text(text)), + ], + onChanged: (_) {}, + ), + ) + : DropdownButton<String>( + dropdownColor: color, + value: text, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: text, child: Text(text)), + ], + onChanged: (_) {}, + ), + ), + ), + ); + await tester.tap(find.text(text)); + await tester.pump(); + + expect( + find.ancestor(of: find.text(text).last, matching: find.byType(CustomPaint)).at(2), + paints + ..save() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: color ?? Colors.grey[50], hasMaskFilter: false), + ); +} + +void main() { + testWidgets('Default dropdown golden', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + Widget build() => buildFrame(buttonKey: buttonKey, onChanged: onChanged, useMaterial3: false); + await tester.pumpWidget(build()); + final Finder buttonFinder = find.byKey(buttonKey); + assert(tester.renderObject(buttonFinder).attached); + await expectLater( + find.ancestor(of: buttonFinder, matching: find.byType(RepaintBoundary)).first, + matchesGoldenFile('dropdown_test.default.png'), + ); + }); + + testWidgets('Expanded dropdown golden', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + Widget build() => buildFrame( + buttonKey: buttonKey, + isExpanded: true, + onChanged: onChanged, + useMaterial3: false, + ); + await tester.pumpWidget(build()); + final Finder buttonFinder = find.byKey(buttonKey); + assert(tester.renderObject(buttonFinder).attached); + await expectLater( + find.ancestor(of: buttonFinder, matching: find.byType(RepaintBoundary)).first, + matchesGoldenFile('dropdown_test.expanded.png'), + ); + }); + + testWidgets('Dropdown button control test', (WidgetTester tester) async { + String? value = 'one'; + void didChangeValue(String? newValue) { + value = newValue; + } + + Widget build() => buildFrame(initialValue: value, onChanged: didChangeValue); + + await tester.pumpWidget(build()); + + await tester.tap(find.text('one')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('one')); + + await tester.tap(find.text('three').last); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('three')); + + await tester.tap(find.text('three', skipOffstage: false), warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('three')); + + await tester.pumpWidget(build()); + + await tester.tap(find.text('two').last); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('two')); + }); + + testWidgets('Dropdown button with no app', (WidgetTester tester) async { + String? value = 'one'; + void didChangeValue(String? newValue) { + value = newValue; + } + + Widget build() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromView(tester.view), + child: Navigator( + initialRoute: '/', + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return Material( + child: buildFrame(initialValue: 'one', onChanged: didChangeValue), + ); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(build()); + + await tester.tap(find.text('one')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('one')); + + await tester.tap(find.text('three').last); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('three')); + + await tester.tap(find.text('three', skipOffstage: false), warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('three')); + + await tester.pumpWidget(build()); + + await tester.tap(find.text('two').last); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('two')); + }); + + testWidgets('DropdownButton does not allow duplicate item values', (WidgetTester tester) async { + final List<DropdownMenuItem<String>> itemsWithDuplicateValues = <String>['a', 'b', 'c', 'c'] + .map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }) + .toList(); + + await expectLater( + () => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: 'c', + onChanged: (String? newValue) {}, + items: itemsWithDuplicateValues, + ), + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains("There should be exactly one item with [DropdownButton]'s value"), + ), + ), + ); + }); + + testWidgets('DropdownButton value should only appear in one menu item', ( + WidgetTester tester, + ) async { + final List<DropdownMenuItem<String>> itemsWithDuplicateValues = <String>['a', 'b', 'c', 'd'] + .map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }) + .toList(); + + await expectLater( + () => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: 'e', + onChanged: (String? newValue) {}, + items: itemsWithDuplicateValues, + ), + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + contains("There should be exactly one item with [DropdownButton]'s value"), + ), + ), + ); + }); + + testWidgets('Dropdown form field uses form field state', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + final formKey = GlobalKey<FormState>(); + String? value; + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: Form( + key: formKey, + child: DropdownButtonFormField<String>( + key: buttonKey, + initialValue: value, + hint: const Text('Select Value'), + decoration: const InputDecoration(prefixIcon: Icon(Icons.fastfood)), + items: menuItems.map((String val) { + return DropdownMenuItem<String>(value: val, child: Text(val)); + }).toList(), + validator: (String? v) => v == null ? 'Must select value' : null, + onChanged: (String? newValue) {}, + onSaved: (String? v) { + setState(() { + value = v; + }); + }, + ), + ), + ), + ); + }, + ), + ); + int getIndex() { + final stack = tester.element(find.byType(IndexedStack)).widget as IndexedStack; + return stack.index!; + } + + // Initial value of null displays hint + expect(value, equals(null)); + expect(getIndex(), 4); + await tester.tap(find.text('Select Value', skipOffstage: false), warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.tap(find.text('three').last); + await tester.pumpAndSettle(); + expect(getIndex(), 2); + // Changes only made to FormField state until form saved + expect(value, equals(null)); + final FormState form = formKey.currentState!; + form.save(); + expect(value, equals('three')); + }); + + testWidgets( + 'Dropdown form field only uses initialValue parameter when first built and when reset', + (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<String>>(); + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: DropdownButtonFormField<String>( + key: fieldKey, + initialValue: 'one', + hint: const Text('Select Value'), + items: menuItems.map((String val) { + return DropdownMenuItem<String>(value: val, child: Text(val)); + }).toList(), + onChanged: (String? newValue) { + setState(() { + // Do nothing, just to trigger a rebuild. + }); + }, + ), + ), + ); + }, + ), + ); + expect(fieldKey.currentState!.value, 'one'); + + // Open the dropdown menu. + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('three').last); + await tester.pumpAndSettle(); + + // The value should update to selected, not the initial value. + expect(find.text('three'), findsOneWidget); + expect(fieldKey.currentState!.value, 'three'); + + fieldKey.currentState!.reset(); + await tester.pump(); + + // Reset to the initial value. + expect(find.text('one'), findsOneWidget); + expect(fieldKey.currentState!.value, 'one'); + }, + ); + + testWidgets('Dropdown in ListView', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/12053 + // Positions a DropdownButton at the left and right edges of the screen, + // forcing it to be sized down to the viewport width + const value = 'foo'; + final itemKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + DropdownButton<String>( + value: value, + items: <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(key: itemKey, value: value, child: const Text(value)), + ], + onChanged: (_) {}, + ), + ], + ), + ), + ), + ); + await tester.tap(find.text(value)); + await tester.pump(); + final List<RenderBox> itemBoxes = tester + .renderObjectList<RenderBox>(find.byKey(itemKey)) + .toList(); + expect(itemBoxes[0].localToGlobal(Offset.zero).dx, equals(0.0)); + expect(itemBoxes[1].localToGlobal(Offset.zero).dx, equals(16.0)); + expect(itemBoxes[1].size.width, equals(800.0 - 16.0 * 2)); + }); + + testWidgets('Dropdown menu can position correctly inside a nested navigator', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/66870 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + appBar: AppBar(), + body: Column( + children: <Widget>[ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 200), + child: Navigator( + onGenerateRoute: (RouteSettings s) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Center( + child: DropdownButton<int>( + value: 1, + items: const <DropdownMenuItem<int>>[ + DropdownMenuItem<int>(value: 1, child: Text('First Item')), + DropdownMenuItem<int>(value: 2, child: Text('Second Item')), + ], + onChanged: (_) {}, + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + await tester.tap(find.text('First Item')); + await tester.pump(); + final RenderBox secondItem = tester + .renderObjectList<RenderBox>(find.text('Second Item', skipOffstage: false)) + .toList()[1]; + expect(secondItem.localToGlobal(Offset.zero).dx, equals(150.0)); + expect(secondItem.localToGlobal(Offset.zero).dy, equals(176.0)); + }); + + testWidgets('Dropdown screen edges', (WidgetTester tester) async { + int? value = 4; + final items = <DropdownMenuItem<int>>[ + for (int i = 0; i < 20; ++i) DropdownMenuItem<int>(value: i, child: Text('$i')), + ]; + + void handleChanged(int? newValue) { + value = newValue; + } + + final button = DropdownButton<int>(value: value, onChanged: handleChanged, items: items); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align(alignment: Alignment.topCenter, child: button), + ), + ), + ); + + await tester.tap(find.text('4')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + // We should have two copies of item 5, one in the menu and one in the + // button itself. + expect(tester.elementList(find.text('5', skipOffstage: false)), hasLength(2)); + + expect(value, 4); + await tester.tap(find.byWidget(button, skipOffstage: false), warnIfMissed: false); + expect(value, 4); + // this waits for the route's completer to complete, which calls handleChanged + await tester.idle(); + expect(value, 4); + }); + + for (final TextDirection textDirection in TextDirection.values) { + testWidgets('Dropdown button aligns selected menu item ($textDirection)', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build() => buildFrame( + buttonKey: buttonKey, + textDirection: textDirection, + onChanged: onChanged, + useMaterial3: false, + ); + + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + final Offset buttonOriginBeforeTap = buttonBox.localToGlobal(Offset.zero); + + await tester.tap(find.text('two')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + // Tapping the dropdown button should not cause it to move. + expect(buttonBox.localToGlobal(Offset.zero), equals(buttonOriginBeforeTap)); + + // The selected dropdown item is both in menu we just popped up, and in + // the IndexedStack contained by the dropdown button. Both of them should + // have the same origin and height as the dropdown button. + final List<RenderBox> itemBoxes = tester + .renderObjectList<RenderBox>(find.byKey(const ValueKey<String>('two'))) + .toList(); + expect(itemBoxes.length, equals(2)); + for (final itemBox in itemBoxes) { + assert(itemBox.attached); + switch (textDirection) { + case TextDirection.rtl: + expect( + buttonBox.localToGlobal(buttonBox.size.bottomRight(Offset.zero)), + equals(itemBox.localToGlobal(itemBox.size.bottomRight(Offset.zero))), + ); + case TextDirection.ltr: + expect( + buttonBox.localToGlobal(Offset.zero), + equals(itemBox.localToGlobal(Offset.zero)), + ); + } + expect(buttonBox.size.height, equals(itemBox.size.height)); + } + + // The two RenderParagraph objects, for the 'two' items' Text children, + // should have the same size and location. + checkSelectedItemTextGeometry(tester, 'two'); + + await tester.pumpWidget(Container()); // reset test + }); + } + + testWidgets('Arrow icon aligns with the edge of button when expanded', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build() => buildFrame(buttonKey: buttonKey, isExpanded: true, onChanged: onChanged); + + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + + final RenderBox arrowIcon = tester.renderObject<RenderBox>(find.byIcon(Icons.arrow_drop_down)); + assert(arrowIcon.attached); + + // Arrow icon should be aligned with far right of button when expanded + expect( + arrowIcon.localToGlobal(Offset.zero).dx, + buttonBox.size.centerRight(Offset(-arrowIcon.size.width, 0.0)).dx, + ); + }); + + testWidgets('Dropdown button icon will accept widgets as icons', (WidgetTester tester) async { + final Widget customWidget = Container( + decoration: ShapeDecoration( + shape: CircleBorder(side: BorderSide(width: 5.0, color: Colors.grey.shade700)), + ), + ); + + await tester.pumpWidget(buildFrame(icon: customWidget, onChanged: onChanged)); + + expect(find.byWidget(customWidget), findsOneWidget); + expect(find.byIcon(Icons.arrow_drop_down), findsNothing); + + await tester.pumpWidget(buildFrame(icon: const Icon(Icons.assessment), onChanged: onChanged)); + + expect(find.byIcon(Icons.assessment), findsOneWidget); + expect(find.byIcon(Icons.arrow_drop_down), findsNothing); + }); + + testWidgets('Dropdown button icon should have default size and colors when not defined', ( + WidgetTester tester, + ) async { + final Key iconKey = UniqueKey(); + final customIcon = Icon(Icons.assessment, key: iconKey); + + await tester.pumpWidget(buildFrame(icon: customIcon, onChanged: onChanged)); + + // test for size + final RenderBox icon = tester.renderObject(find.byKey(iconKey)); + expect(icon.size, const Size(24.0, 24.0)); + + // test for enabled color + final RichText enabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(enabledRichText.text.style!.color, Colors.grey.shade700); + + // test for disabled color + await tester.pumpWidget(buildFrame(icon: customIcon)); + + final RichText disabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(disabledRichText.text.style!.color, Colors.grey.shade400); + }); + + testWidgets('Dropdown button icon should have the passed in size and color instead of defaults', ( + WidgetTester tester, + ) async { + final Key iconKey = UniqueKey(); + final customIcon = Icon(Icons.assessment, key: iconKey); + + await tester.pumpWidget( + buildFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + onChanged: onChanged, + ), + ); + + // test for size + final RenderBox icon = tester.renderObject(find.byKey(iconKey)); + expect(icon.size, const Size(30.0, 30.0)); + + // test for enabled color + final RichText enabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(enabledRichText.text.style!.color, Colors.pink); + + // test for disabled color + await tester.pumpWidget( + buildFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + ), + ); + + final RichText disabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(disabledRichText.text.style!.color, Colors.orange); + }); + + testWidgets( + 'Dropdown button should use its own size and color properties over those defined by the theme', + (WidgetTester tester) async { + final Key iconKey = UniqueKey(); + + final customIcon = Icon(Icons.assessment, key: iconKey, size: 40.0, color: Colors.yellow); + + await tester.pumpWidget( + buildFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + onChanged: onChanged, + ), + ); + + // test for size + final RenderBox icon = tester.renderObject(find.byKey(iconKey)); + expect(icon.size, const Size(40.0, 40.0)); + + // test for enabled color + final RichText enabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(enabledRichText.text.style!.color, Colors.yellow); + + // test for disabled color + await tester.pumpWidget( + buildFrame( + icon: customIcon, + iconSize: 30.0, + iconEnabledColor: Colors.pink, + iconDisabledColor: Colors.orange, + ), + ); + + final RichText disabledRichText = tester.widget<RichText>(_iconRichText(iconKey)); + expect(disabledRichText.text.style!.color, Colors.yellow); + }, + ); + + testWidgets('Dropdown button with isDense:true aligns selected menu item', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + + Widget build() => buildFrame(buttonKey: buttonKey, isDense: true, onChanged: onChanged); + + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + + await tester.tap(find.text('two')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + // The selected dropdown item is both in menu we just popped up, and in + // the IndexedStack contained by the dropdown button. Both of them should + // have the same vertical center as the button. + final List<RenderBox> itemBoxes = tester + .renderObjectList<RenderBox>(find.byKey(const ValueKey<String>('two'))) + .toList(); + expect(itemBoxes.length, equals(2)); + + // When isDense is true, the button's height is reduced. The menu items' + // heights are not. + final double menuItemHeight = itemBoxes + .map<double>((RenderBox box) => box.size.height) + .reduce(math.max); + expect(menuItemHeight, greaterThan(buttonBox.size.height)); + + for (final itemBox in itemBoxes) { + assert(itemBox.attached); + final Offset buttonBoxCenter = buttonBox.size.center(buttonBox.localToGlobal(Offset.zero)); + final Offset itemBoxCenter = itemBox.size.center(itemBox.localToGlobal(Offset.zero)); + expect(buttonBoxCenter.dy, equals(itemBoxCenter.dy)); + } + + // The two RenderParagraph objects, for the 'two' items' Text children, + // should have the same size and location. + checkSelectedItemTextGeometry(tester, 'two'); + }); + + testWidgets('Dropdown button can have a text style with no fontSize specified', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/33425 + const value = 'foo'; + final itemKey = UniqueKey(); + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: DropdownButton<String>( + value: value, + items: <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(key: itemKey, value: 'foo', child: const Text(value)), + ], + isDense: true, + onChanged: (_) {}, + style: const TextStyle(color: Colors.blue), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Dropdown menu scrolls to first item in long lists', (WidgetTester tester) async { + // Open the dropdown menu + final Key buttonKey = UniqueKey(); + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + initialValue: null, // nothing selected + items: List<String>.generate(/*length=*/ 100, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + await tester.tap(find.byKey(buttonKey)); + await tester.pump(); + await tester.pumpAndSettle(); // finish the menu animation + + // Find the first item in the scrollable dropdown list + final Finder menuItemFinder = find.byType(Scrollable); + final RenderBox menuItemContainer = tester.renderObject<RenderBox>(menuItemFinder); + final RenderBox firstItem = tester.renderObject<RenderBox>( + find.descendant(of: menuItemFinder, matching: find.byKey(const ValueKey<String>('0'))), + ); + + // List should be scrolled so that the first item is at the top. Menu items + // are offset 8.0 from the top edge of the scrollable menu. + const selectedItemOffset = Offset(0.0, -8.0); + expect( + firstItem.size.topCenter(firstItem.localToGlobal(selectedItemOffset)).dy, + equals(menuItemContainer.size.topCenter(menuItemContainer.localToGlobal(Offset.zero)).dy), + ); + }); + + testWidgets('Dropdown menu aligns selected item with button in long lists', ( + WidgetTester tester, + ) async { + // Open the dropdown menu + final Key buttonKey = UniqueKey(); + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + initialValue: '50', + items: List<String>.generate(/*length=*/ 100, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); // finish the menu animation + + // Find the selected item in the scrollable dropdown list + final RenderBox selectedItem = tester.renderObject<RenderBox>( + find.descendant( + of: find.byType(Scrollable), + matching: find.byKey(const ValueKey<String>('50')), + ), + ); + + // List should be scrolled so that the selected item is in line with the button + expect( + selectedItem.size.center(selectedItem.localToGlobal(Offset.zero)).dy, + equals(buttonBox.size.center(buttonBox.localToGlobal(Offset.zero)).dy), + ); + }); + + testWidgets('Dropdown menu scrolls to last item in long lists', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + initialValue: '99', + items: List<String>.generate(/*length=*/ 100, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + await tester.tap(find.byKey(buttonKey)); + await tester.pump(); + + final ScrollController scrollController = PrimaryScrollController.of( + tester.element(find.byType(ListView)), + ); + // Make sure there is no overscroll + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // Find the selected item in the scrollable dropdown list + final Finder menuItemFinder = find.byType(Scrollable); + final RenderBox menuItemContainer = tester.renderObject<RenderBox>(menuItemFinder); + final RenderBox selectedItem = tester.renderObject<RenderBox>( + find.descendant(of: menuItemFinder, matching: find.byKey(const ValueKey<String>('99'))), + ); + + // kMaterialListPadding.vertical is 8. + const menuPaddingOffset = Offset(0.0, -8.0); + final Offset selectedItemOffset = selectedItem.localToGlobal(Offset.zero); + final Offset menuItemContainerOffset = menuItemContainer.localToGlobal(menuPaddingOffset); + // Selected item should be aligned to the bottom of the dropdown menu. + expect( + selectedItem.size.bottomCenter(selectedItemOffset).dy, + menuItemContainer.size.bottomCenter(menuItemContainerOffset).dy, + ); + }); + + testWidgets('Size of DropdownButton with null value', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + String? value; + + Widget build() => buildFrame(buttonKey: buttonKey, initialValue: value, onChanged: onChanged); + + await tester.pumpWidget(build()); + final RenderBox buttonBoxNullValue = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBoxNullValue.attached); + + value = 'three'; + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + + // A Dropdown button with a null value should be the same size as a + // one with a non-null value. + expect( + buttonBox.localToGlobal(Offset.zero), + equals(buttonBoxNullValue.localToGlobal(Offset.zero)), + ); + expect(buttonBox.size, equals(buttonBoxNullValue.size)); + }); + + testWidgets('Size of DropdownButton with no items', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/26419 + final Key buttonKey = UniqueKey(); + List<String>? items; + + Widget build() => buildFrame(buttonKey: buttonKey, items: items, onChanged: onChanged); + + await tester.pumpWidget(build()); + final RenderBox buttonBoxNullItems = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBoxNullItems.attached); + + items = <String>[]; + await tester.pumpWidget(build()); + final RenderBox buttonBoxEmptyItems = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBoxEmptyItems.attached); + + items = <String>['one', 'two', 'three', 'four']; + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + + // A Dropdown button with a null value should be the same size as a + // one with a non-null value. + expect( + buttonBox.localToGlobal(Offset.zero), + equals(buttonBoxNullItems.localToGlobal(Offset.zero)), + ); + expect(buttonBox.size, equals(buttonBoxNullItems.size)); + }); + + testWidgets('Layout of a DropdownButton with null value', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + String? value; + + void onChanged(String? newValue) { + value = newValue; + } + + Widget build() => buildFrame(buttonKey: buttonKey, initialValue: value, onChanged: onChanged); + + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + + // Show the menu. + await tester.tap(find.byKey(buttonKey)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + // Tap on item 'one', which must appear over the button. + await tester.tap(find.byKey(buttonKey, skipOffstage: false), warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + await tester.pumpWidget(build()); + expect(value, equals('one')); + }); + + testWidgets('Size of DropdownButton with null value and a hint', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + String? value; + + // The hint will define the dropdown's width + Widget build() => + buildFrame(buttonKey: buttonKey, initialValue: value, hint: const Text('onetwothree')); + + await tester.pumpWidget(build()); + expect(find.text('onetwothree'), findsOneWidget); + final RenderBox buttonBoxHintValue = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBoxHintValue.attached); + + value = 'three'; + await tester.pumpWidget(build()); + final RenderBox buttonBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBox.attached); + + // A Dropdown button with a null value and a hint should be the same size as a + // one with a non-null value. + expect( + buttonBox.localToGlobal(Offset.zero), + equals(buttonBoxHintValue.localToGlobal(Offset.zero)), + ); + expect(buttonBox.size, equals(buttonBoxHintValue.size)); + }); + + testWidgets('Dropdown menus must fit within the screen', (WidgetTester tester) async { + // In all of the tests that follow we're assuming that the dropdown menu + // is horizontally aligned with the center of the dropdown button and padded + // on the top, left, and right. + const buttonPadding = EdgeInsets.only(top: 8.0, left: 16.0, right: 24.0); + + Rect getExpandedButtonRect() { + final RenderBox box = tester.renderObject<RenderBox>(find.byType(dropdownButtonType)); + final Rect buttonRect = box.localToGlobal(Offset.zero) & box.size; + return buttonPadding.inflateRect(buttonRect); + } + + late Rect buttonRect; + late Rect menuRect; + + Future<void> popUpAndDown(Widget frame) async { + await tester.pumpWidget(frame); + await tester.tap(find.byType(dropdownButtonType)); + await tester.pumpAndSettle(); + menuRect = getMenuRect(tester); + buttonRect = getExpandedButtonRect(); + await tester.tap(find.byType(dropdownButtonType, skipOffstage: false), warnIfMissed: false); + } + + // Dropdown button is along the top of the app. The top of the menu is + // aligned with the top of the expanded button and shifted horizontally + // so that it fits within the frame. + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.topLeft, + initialValue: menuItems.last, + onChanged: onChanged, + ), + ); + expect(menuRect.topLeft, Offset.zero); + expect(menuRect.topRight, Offset(menuRect.width, 0.0)); + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.topCenter, + initialValue: menuItems.last, + onChanged: onChanged, + ), + ); + expect(menuRect.topLeft, Offset(buttonRect.left, 0.0)); + expect(menuRect.topRight, Offset(buttonRect.right, 0.0)); + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.topRight, + initialValue: menuItems.last, + onChanged: onChanged, + ), + ); + expect(menuRect.topLeft, Offset(800.0 - menuRect.width, 0.0)); + expect(menuRect.topRight, const Offset(800.0, 0.0)); + + // Dropdown button is along the middle of the app. The top of the menu is + // aligned with the top of the expanded button (because the 1st item + // is selected) and shifted horizontally so that it fits within the frame. + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.centerLeft, + initialValue: menuItems.first, + onChanged: onChanged, + ), + ); + expect(menuRect.topLeft, Offset(0.0, buttonRect.top)); + expect(menuRect.topRight, Offset(menuRect.width, buttonRect.top)); + + await popUpAndDown(buildFrame(initialValue: menuItems.first, onChanged: onChanged)); + expect(menuRect.topLeft, buttonRect.topLeft); + expect(menuRect.topRight, buttonRect.topRight); + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.centerRight, + initialValue: menuItems.first, + onChanged: onChanged, + ), + ); + expect(menuRect.topLeft, Offset(800.0 - menuRect.width, buttonRect.top)); + expect(menuRect.topRight, Offset(800.0, buttonRect.top)); + + // Dropdown button is along the bottom of the app. The bottom of the menu is + // aligned with the bottom of the expanded button and shifted horizontally + // so that it fits within the frame. + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.bottomLeft, + initialValue: menuItems.first, + onChanged: onChanged, + ), + ); + expect(menuRect.bottomLeft, const Offset(0.0, 600.0)); + expect(menuRect.bottomRight, Offset(menuRect.width, 600.0)); + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.bottomCenter, + initialValue: menuItems.first, + onChanged: onChanged, + ), + ); + expect(menuRect.bottomLeft, Offset(buttonRect.left, 600.0)); + expect(menuRect.bottomRight, Offset(buttonRect.right, 600.0)); + + await popUpAndDown( + buildFrame( + dropdownAlignment: Alignment.bottomRight, + initialValue: menuItems.first, + onChanged: onChanged, + ), + ); + expect(menuRect.bottomLeft, Offset(800.0 - menuRect.width, 600.0)); + expect(menuRect.bottomRight, const Offset(800.0, 600.0)); + }); + + testWidgets( + 'Dropdown menus are dismissed on screen orientation changes, but not on keyboard hide', + (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(onChanged: onChanged, mediaSize: const Size(800, 600))); + await tester.tap(find.byType(dropdownButtonType)); + await tester.pumpAndSettle(); + expect(find.byType(ListView), findsOneWidget); + + // Show a keyboard (simulate by shortening the height). + await tester.pumpWidget(buildFrame(onChanged: onChanged, mediaSize: const Size(800, 300))); + await tester.pump(); + expect(find.byType(ListView, skipOffstage: false), findsOneWidget); + + // Hide a keyboard again (simulate by increasing the height). + await tester.pumpWidget(buildFrame(onChanged: onChanged, mediaSize: const Size(800, 600))); + await tester.pump(); + expect(find.byType(ListView, skipOffstage: false), findsOneWidget); + + // Rotate the device (simulate by changing the aspect ratio). + await tester.pumpWidget(buildFrame(onChanged: onChanged, mediaSize: const Size(600, 800))); + await tester.pump(); + expect(find.byType(ListView, skipOffstage: false), findsNothing); + }, + ); + + testWidgets('Semantics Tree contains only selected element', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget(buildFrame(onChanged: onChanged)); + + expect(semantics, isNot(includesNodeWith(label: menuItems[0]))); + expect(semantics, includesNodeWith(label: menuItems[1])); + expect(semantics, isNot(includesNodeWith(label: menuItems[2]))); + expect(semantics, isNot(includesNodeWith(label: menuItems[3]))); + + semantics.dispose(); + }); + + testWidgets('Dropdown button includes semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const key = Key('test'); + await tester.pumpWidget( + buildFrame( + buttonKey: key, + initialValue: null, + onChanged: (String? _) {}, + hint: const Text('test'), + ), + ); + + // By default the hint contributes the label. + expect( + tester.getSemantics(find.text('test')), + matchesSemantics( + isButton: true, + hasExpandedState: true, + label: 'test', + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + await tester.pumpWidget( + buildFrame( + buttonKey: key, + initialValue: 'three', + onChanged: onChanged, + hint: const Text('test'), + ), + ); + + // Displays label of select item. + expect( + tester.getSemantics(find.text('three')), + matchesSemantics( + isButton: true, + hasExpandedState: true, + label: 'three', + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('Dropdown button Semantics expanded state should update when menu opens and closes', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/183432 + const key = Key('test'); + await tester.pumpWidget(buildFrame(buttonKey: key, onChanged: onChanged)); + + // Before opening: should have expanded state but not be expanded. + expect( + tester.getSemantics(find.byType(DropdownButton<String>)), + matchesSemantics( + isButton: true, + hasExpandedState: true, + label: 'two', + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + // Open the menu. + await tester.tap(find.text('two')); + await tester.pumpAndSettle(); + + // While the menu is open, BlockSemantics in ModalBarrier blocks the + // button's semantics node (making tester.getSemantics return a stale node). + // Verify the Semantics widget's expanded property directly instead. + final Semantics expandedSemantics = tester.widget<Semantics>( + find.descendant( + of: find.byKey(key), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && widget.properties.expanded != null, + ), + ), + ); + expect(expandedSemantics.properties.expanded, isTrue); + + // Close the menu by selecting an item. + await tester.tap(find.text('one').last); + await tester.pumpAndSettle(); + + // After closing: should have expanded state but not be expanded. + expect( + tester.getSemantics(find.byType(DropdownButton<String>)), + matchesSemantics( + isButton: true, + hasExpandedState: true, + label: 'two', + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + isFocused: true, + ), + ); + }); + + testWidgets('Dropdown menu includes semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const key = Key('test'); + await tester.pumpWidget(buildFrame(buttonKey: key, initialValue: null, onChanged: onChanged)); + await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menu, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: 'Popup menu', + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menuItem, + label: 'one', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocused, + SemanticsFlag.isFocusable, + ], + tags: <SemanticsTag>[ + const SemanticsTag('RenderViewport.twoPane'), + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + TestSemantics( + role: SemanticsRole.menuItem, + label: 'two', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + tags: <SemanticsTag>[ + const SemanticsTag('RenderViewport.twoPane'), + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + TestSemantics( + role: SemanticsRole.menuItem, + label: 'three', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + tags: <SemanticsTag>[ + const SemanticsTag('RenderViewport.twoPane'), + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + TestSemantics( + role: SemanticsRole.menuItem, + label: 'four', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + tags: <SemanticsTag>[ + const SemanticsTag('RenderViewport.twoPane'), + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('disabledHint displays on empty items or onChanged', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items, ValueChanged<String?>? onChanged}) => buildFrame( + items: items, + onChanged: onChanged, + buttonKey: buttonKey, + initialValue: null, + hint: const Text('enabled'), + disabledHint: const Text('disabled'), + ); + + // [disabledHint] should display when [items] is null + await tester.pumpWidget(build(onChanged: onChanged)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + + // [disabledHint] should display when [items] is an empty list. + await tester.pumpWidget(build(items: <String>[], onChanged: onChanged)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + + // [disabledHint] should display when [onChanged] is null + await tester.pumpWidget(build(items: menuItems)); + expect(find.text('enabled'), findsNothing); + expect(find.text('disabled'), findsOneWidget); + final RenderBox disabledHintBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + + // A Dropdown button with a disabled hint should be the same size as a + // one with a regular enabled hint. + await tester.pumpWidget(build(items: menuItems, onChanged: onChanged)); + expect(find.text('disabled'), findsNothing); + expect(find.text('enabled'), findsOneWidget); + final RenderBox enabledHintBox = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + expect( + enabledHintBox.localToGlobal(Offset.zero), + equals(disabledHintBox.localToGlobal(Offset.zero)), + ); + expect(enabledHintBox.size, equals(disabledHintBox.size)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/70177 + testWidgets('disabledHint behavior test', (WidgetTester tester) async { + Widget build({ + List<String>? items, + ValueChanged<String?>? onChanged, + String? value, + Widget? hint, + Widget? disabledHint, + }) => buildFrame( + items: items, + onChanged: onChanged, + initialValue: value, + hint: hint, + disabledHint: disabledHint, + ); + + // The selected value should be displayed when the button is disabled. + await tester.pumpWidget(build(items: menuItems, value: 'two')); + // The dropdown icon and the selected menu item are vertically aligned. + expect(tester.getCenter(find.text('two')).dy, tester.getCenter(find.byType(Icon)).dy); + + // If [value] is null, the button is enabled, hint is displayed. + await tester.pumpWidget( + build( + items: menuItems, + onChanged: onChanged, + hint: const Text('hint'), + disabledHint: const Text('disabledHint'), + ), + ); + expect(tester.getCenter(find.text('hint')).dy, tester.getCenter(find.byType(Icon)).dy); + + // If [value] is null, the button is disabled, [disabledHint] is displayed when [disabledHint] is non-null. + await tester.pumpWidget( + build(items: menuItems, hint: const Text('hint'), disabledHint: const Text('disabledHint')), + ); + expect(tester.getCenter(find.text('disabledHint')).dy, tester.getCenter(find.byType(Icon)).dy); + + // If [value] is null, the button is disabled, [hint] is displayed when [disabledHint] is null. + await tester.pumpWidget(build(items: menuItems, hint: const Text('hint'))); + expect(tester.getCenter(find.text('hint')).dy, tester.getCenter(find.byType(Icon)).dy); + + int? getIndex() { + final stack = tester.element(find.byType(IndexedStack)).widget as IndexedStack; + return stack.index; + } + + // If [value], [hint] and [disabledHint] are null, the button is disabled, nothing displayed. + await tester.pumpWidget(build(items: menuItems)); + expect(getIndex(), null); + + // If [value], [hint] and [disabledHint] are null, the button is enabled, nothing displayed. + await tester.pumpWidget(build(items: menuItems, onChanged: onChanged)); + expect(getIndex(), null); + }); + + testWidgets('DropdownButton selected item color test', (WidgetTester tester) async { + Widget build({ + ValueChanged<String?>? onChanged, + String? value, + Widget? hint, + Widget? disabledHint, + }) { + return MaterialApp( + theme: ThemeData(disabledColor: Colors.pink), + home: Scaffold( + body: Center( + child: Column( + children: <Widget>[ + DropdownButtonFormField<String>( + style: const TextStyle(color: Colors.yellow), + disabledHint: disabledHint, + hint: hint, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: 'one', child: Text('one')), + DropdownMenuItem<String>(value: 'two', child: Text('two')), + ], + initialValue: value, + onChanged: onChanged, + ), + ], + ), + ), + ), + ); + } + + Color textColor(String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style!.color!; + } + + // The selected value should be displayed when the button is enabled. + await tester.pumpWidget(build(onChanged: onChanged, value: 'two')); + // The dropdown icon and the selected menu item are vertically aligned. + expect(tester.getCenter(find.text('two')).dy, tester.getCenter(find.byType(Icon)).dy); + // Selected item has a normal color from [DropdownButtonFormField.style] + // when the button is enabled. + expect(textColor('two'), Colors.yellow); + + // The selected value should be displayed when the button is disabled. + await tester.pumpWidget(build(value: 'two')); + expect(tester.getCenter(find.text('two')).dy, tester.getCenter(find.byType(Icon)).dy); + // Selected item has a disabled color from [theme.disabledColor] + // when the button is disable. + expect(textColor('two'), Colors.pink); + }); + + testWidgets('DropdownButton hint displays when the items list is empty, ' + 'items is null, and disabledHint is null', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFrame( + items: items, + buttonKey: buttonKey, + initialValue: null, + hint: const Text('hint used when disabled'), + ); + } + + // [hint] should display when [items] is null and [disabledHint] is not defined + await tester.pumpWidget(build()); + expect(find.text('hint used when disabled'), findsOneWidget); + + // [hint] should display when [items] is an empty list and [disabledHint] is not defined. + await tester.pumpWidget(build(items: <String>[])); + expect(find.text('hint used when disabled'), findsOneWidget); + }); + + testWidgets('DropdownButton disabledHint is null by default', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + Widget build({List<String>? items}) { + return buildFrame( + items: items, + buttonKey: buttonKey, + initialValue: null, + hint: const Text('hint used when disabled'), + ); + } + + // [hint] should display when [items] is null and [disabledHint] is not defined + await tester.pumpWidget(build()); + expect(find.text('hint used when disabled'), findsOneWidget); + + // [hint] should display when [items] is an empty list and [disabledHint] is not defined. + await tester.pumpWidget(build(items: <String>[])); + expect(find.text('hint used when disabled'), findsOneWidget); + }); + + testWidgets( + 'Size of largest widget is used DropdownButton when selectedItemBuilder is non-null', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + const selectedItem = '25'; + + await tester.pumpWidget( + buildFrame( + // To test the size constraints, the selected item should not be the + // largest item. This validates that the button sizes itself according + // to the largest item regardless of which one is selected. + initialValue: selectedItem, + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + onChanged: (String? newValue) {}, + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, '25'), + ); + // DropdownButton should be the height of the largest item + expect(dropdownButtonRenderBox.size.height, 100); + // DropdownButton should be width of largest item added to the icon size + expect(dropdownButtonRenderBox.size.width, 100 + 24.0); + }, + ); + + testWidgets( + 'Enabled button - Size of largest widget is used DropdownButton when selectedItemBuilder ' + 'is non-null and hint is defined, but smaller than largest selected item widget', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + + await tester.pumpWidget( + buildFrame( + initialValue: null, + // [hint] widget is smaller than largest selected item widget + hint: const SizedBox(height: 50, width: 50, child: Text('hint')), + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + onChanged: (String? newValue) {}, + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, 'hint'), + ); + // DropdownButton should be the height of the largest item + expect(dropdownButtonRenderBox.size.height, 100); + // DropdownButton should be width of largest item added to the icon size + expect(dropdownButtonRenderBox.size.width, 100 + 24.0); + }, + ); + + testWidgets( + 'Enabled button - Size of largest widget is used DropdownButton when selectedItemBuilder ' + 'is non-null and hint is defined, but larger than largest selected item widget', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + const selectedItem = '25'; + + await tester.pumpWidget( + buildFrame( + // To test the size constraints, the selected item should not be the + // largest item. This validates that the button sizes itself according + // to the largest item regardless of which one is selected. + initialValue: selectedItem, + // [hint] widget is larger than largest selected item widget + hint: const SizedBox(height: 125, width: 125, child: Text('hint')), + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + onChanged: (String? newValue) {}, + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, '25'), + ); + // DropdownButton should be the height of the largest item (hint inclusive) + expect(dropdownButtonRenderBox.size.height, 125); + // DropdownButton should be width of largest item (hint inclusive) added to the icon size + expect(dropdownButtonRenderBox.size.width, 125 + 24.0); + }, + ); + + testWidgets( + 'Disabled button - Size of largest widget is used DropdownButton when selectedItemBuilder ' + 'is non-null, and hint is defined, but smaller than largest selected item widget', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + + await tester.pumpWidget( + buildFrame( + initialValue: null, + // [hint] widget is smaller than largest selected item widget + hint: const SizedBox(height: 50, width: 50, child: Text('hint')), + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, 'hint'), + ); + // DropdownButton should be the height of the largest item + expect(dropdownButtonRenderBox.size.height, 100); + // DropdownButton should be width of largest item added to the icon size + expect(dropdownButtonRenderBox.size.width, 100 + 24.0); + }, + ); + + testWidgets( + 'Disabled button - Size of largest widget is used DropdownButton when selectedItemBuilder ' + 'is non-null and hint is defined, but larger than largest selected item widget', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + + await tester.pumpWidget( + buildFrame( + initialValue: null, + // [hint] widget is larger than largest selected item widget + hint: const SizedBox(height: 125, width: 125, child: Text('hint')), + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, '25', skipOffstage: false), + ); + // DropdownButton should be the height of the largest item (hint inclusive) + expect(dropdownButtonRenderBox.size.height, 125); + // DropdownButton should be width of largest item (hint inclusive) added to the icon size + expect(dropdownButtonRenderBox.size.width, 125 + 24.0); + }, + ); + + testWidgets( + 'Disabled button - Size of largest widget is used DropdownButton when selectedItemBuilder ' + 'is non-null, and disabledHint is defined, but smaller than largest selected item widget', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + + await tester.pumpWidget( + buildFrame( + initialValue: null, + // [hint] widget is smaller than largest selected item widget + disabledHint: const SizedBox(height: 50, width: 50, child: Text('hint')), + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, 'hint'), + ); + // DropdownButton should be the height of the largest item + expect(dropdownButtonRenderBox.size.height, 100); + // DropdownButton should be width of largest item added to the icon size + expect(dropdownButtonRenderBox.size.width, 100 + 24.0); + }, + ); + + testWidgets( + 'Disabled button - Size of largest widget is used DropdownButton when selectedItemBuilder ' + 'is non-null and disabledHint is defined, but larger than largest selected item widget', + (WidgetTester tester) async { + final items = <String>['25', '50', '100']; + + await tester.pumpWidget( + buildFrame( + initialValue: null, + // [hint] widget is larger than largest selected item widget + disabledHint: const SizedBox(height: 125, width: 125, child: Text('hint')), + items: items, + itemHeight: null, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + ), + ); + + final RenderBox dropdownButtonRenderBox = tester.renderObject<RenderBox>( + find.widgetWithText(Row, '25', skipOffstage: false), + ); + // DropdownButton should be the height of the largest item (hint inclusive) + expect(dropdownButtonRenderBox.size.height, 125); + // DropdownButton should be width of largest item (hint inclusive) added to the icon size + expect(dropdownButtonRenderBox.size.width, 125 + 24.0); + }, + ); + + testWidgets('Menu width is correct when set', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/133267. + final items = <String>['25', '50', '100']; + const selectedItem = '25'; + + await tester.pumpWidget( + buildFrame( + initialValue: selectedItem, + items: items, + menuWidth: 200, + selectedItemBuilder: (BuildContext context) => [ + for (final item in items) + SizedBox.square( + dimension: double.parse(item), + child: Center(child: Text(item)), + ), + ], + onChanged: (String? newValue) {}, + ), + ); + + await tester.tap(find.text('25')); + await tester.pumpAndSettle(); + + expect(getMenuRect(tester).width, 200); + }); + + testWidgets('Dropdown in middle showing middle item', (WidgetTester tester) async { + final items = List<DropdownMenuItem<int>>.generate( + 100, + (int i) => DropdownMenuItem<int>(value: i, child: Text('$i')), + ); + + final button = DropdownButton<int>(value: 50, onChanged: (int? newValue) {}, items: items); + + double getMenuScroll() { + double scrollPosition; + final ScrollController scrollController = PrimaryScrollController.of( + tester.element(find.byType(ListView)), + ); + scrollPosition = scrollController.position.pixels; + return scrollPosition; + } + + await tester.pumpWidget( + MaterialApp( + home: Material(child: Align(child: button)), + ), + ); + + await tester.tap(find.text('50')); + await tester.pumpAndSettle(); + expect(getMenuScroll(), 2180.0); + }); + + testWidgets('Dropdown in top showing bottom item', (WidgetTester tester) async { + final items = List<DropdownMenuItem<int>>.generate( + 100, + (int i) => DropdownMenuItem<int>(value: i, child: Text('$i')), + ); + + final button = DropdownButton<int>(value: 99, onChanged: (int? newValue) {}, items: items); + + double getMenuScroll() { + double scrollPosition; + final ScrollController scrollController = PrimaryScrollController.of( + tester.element(find.byType(ListView)), + ); + scrollPosition = scrollController.position.pixels; + return scrollPosition; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align(alignment: Alignment.topCenter, child: button), + ), + ), + ); + + await tester.tap(find.text('99')); + await tester.pumpAndSettle(); + expect(getMenuScroll(), 4312.0); + }); + + testWidgets('Dropdown in bottom showing top item', (WidgetTester tester) async { + final items = List<DropdownMenuItem<int>>.generate( + 100, + (int i) => DropdownMenuItem<int>(value: i, child: Text('$i')), + ); + + final button = DropdownButton<int>(value: 0, onChanged: (int? newValue) {}, items: items); + + double getMenuScroll() { + double scrollPosition; + final ScrollController scrollController = PrimaryScrollController.of( + tester.element(find.byType(ListView)), + ); + scrollPosition = scrollController.position.pixels; + return scrollPosition; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align(alignment: Alignment.bottomCenter, child: button), + ), + ), + ); + + await tester.tap(find.text('0')); + await tester.pumpAndSettle(); + expect(getMenuScroll(), 0.0); + }); + + testWidgets('Dropdown in center showing bottom item', (WidgetTester tester) async { + final items = List<DropdownMenuItem<int>>.generate( + 100, + (int i) => DropdownMenuItem<int>(value: i, child: Text('$i')), + ); + + final button = DropdownButton<int>(value: 99, onChanged: (int? newValue) {}, items: items); + + double getMenuScroll() { + double scrollPosition; + final ScrollController scrollController = PrimaryScrollController.of( + tester.element(find.byType(ListView)), + ); + scrollPosition = scrollController.position.pixels; + return scrollPosition; + } + + await tester.pumpWidget( + MaterialApp( + home: Material(child: Align(child: button)), + ), + ); + + await tester.tap(find.text('99')); + await tester.pumpAndSettle(); + expect(getMenuScroll(), 4312.0); + }); + + testWidgets('Dropdown menu respects parent size limits', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/24417 + int? selectedIndex; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: const SizedBox(height: 200), + body: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return SafeArea( + child: Container( + alignment: Alignment.topLeft, + // From material/dropdown.dart (menus are unaligned by default): + // _kUnalignedMenuMargin = EdgeInsetsDirectional.only(start: 16.0, end: 24.0) + // This padding ensures that the entire menu will be visible + padding: const EdgeInsetsDirectional.only(start: 16.0, end: 24.0), + child: DropdownButton<int>( + value: 12, + onChanged: (int? i) { + selectedIndex = i; + }, + items: List<DropdownMenuItem<int>>.generate(100, (int i) { + return DropdownMenuItem<int>(value: i, child: Text('$i')); + }), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('12')); + await tester.pumpAndSettle(); + expect(selectedIndex, null); + + await tester.tap(find.text('13').last); + await tester.pumpAndSettle(); + expect(selectedIndex, 13); + }); + + testWidgets('Dropdown button will accept widgets as its underline', (WidgetTester tester) async { + const decoration = BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFFCCBB00), width: 4.0)), + ); + const defaultDecoration = BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFFBDBDBD), width: 0.0)), + ); + + final Widget customUnderline = Container(height: 4.0, decoration: decoration); + final Key buttonKey = UniqueKey(); + + final Finder decoratedBox = find.descendant( + of: find.byKey(buttonKey), + matching: find.byType(DecoratedBox), + ); + + await tester.pumpWidget( + buildFrame(buttonKey: buttonKey, underline: customUnderline, onChanged: onChanged), + ); + expect(tester.widgetList<DecoratedBox>(decoratedBox).last.decoration, decoration); + + await tester.pumpWidget(buildFrame(buttonKey: buttonKey, onChanged: onChanged)); + expect(tester.widgetList<DecoratedBox>(decoratedBox).last.decoration, defaultDecoration); + }); + + testWidgets('DropdownButton selectedItemBuilder builds custom buttons', ( + WidgetTester tester, + ) async { + const items = <String>['One', 'Two', 'Three']; + String? selectedItem = items[0]; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: selectedItem, + onChanged: (String? string) { + setState(() => selectedItem = string); + }, + selectedItemBuilder: (BuildContext context) { + var index = 0; + return items.map((String string) { + index += 1; + return Text('$string as an Arabic numeral: $index'); + }).toList(); + }, + items: items.map((String string) { + return DropdownMenuItem<String>(value: string, child: Text(string)); + }).toList(), + ), + ), + ); + }, + ), + ); + + expect(find.text('One as an Arabic numeral: 1'), findsOneWidget); + await tester.tap(find.text('One as an Arabic numeral: 1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + expect(find.text('Two as an Arabic numeral: 2'), findsOneWidget); + }); + + testWidgets('DropdownButton uses default color when expanded', (WidgetTester tester) async { + await checkDropdownColor(tester); + }); + + testWidgets('DropdownButton uses dropdownColor when expanded', (WidgetTester tester) async { + await checkDropdownColor(tester, color: const Color.fromRGBO(120, 220, 70, 0.8)); + }); + + testWidgets('DropdownButtonFormField uses dropdownColor when expanded', ( + WidgetTester tester, + ) async { + await checkDropdownColor( + tester, + color: const Color.fromRGBO(120, 220, 70, 0.8), + isFormField: true, + ); + }); + + testWidgets('DropdownButton hint displays properly when selectedItemBuilder is defined', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/42340 + final items = <String>['1', '2', '3']; + String? selectedItem; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + hint: const Text('Please select an item'), + value: selectedItem, + onChanged: (String? string) { + setState(() { + selectedItem = string; + }); + }, + selectedItemBuilder: (BuildContext context) { + return items.map((String item) { + return Text('You have selected: $item'); + }).toList(); + }, + items: items.map((String item) { + return DropdownMenuItem<String>(value: item, child: Text(item)); + }).toList(), + ), + ), + ); + }, + ), + ); + + // Initially shows the hint text + expect(find.text('Please select an item'), findsOneWidget); + await tester.tap(find.text('Please select an item', skipOffstage: false), warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + // Selecting an item should display its corresponding item builder + expect(find.text('You have selected: 1'), findsOneWidget); + }); + + testWidgets('Variable size and oversized menu items', (WidgetTester tester) async { + final itemHeights = <double>[30, 40, 50, 60]; + double? dropdownValue = itemHeights[0]; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<double>( + onChanged: (double? value) { + setState(() { + dropdownValue = value; + }); + }, + value: dropdownValue, + itemHeight: null, + items: itemHeights.map<DropdownMenuItem<double>>((double value) { + return DropdownMenuItem<double>( + key: ValueKey<double>(value), + value: value, + child: Center( + child: Container(width: 100, height: value, color: Colors.blue), + ), + ); + }).toList(), + ); + }, + ), + ), + ), + ); + } + + final Finder dropdownIcon = find.byType(Icon); + final Finder item30 = find.byKey(const ValueKey<double>(30), skipOffstage: false); + final Finder item40 = find.byKey(const ValueKey<double>(40), skipOffstage: false); + final Finder item50 = find.byKey(const ValueKey<double>(50), skipOffstage: false); + final Finder item60 = find.byKey(const ValueKey<double>(60), skipOffstage: false); + + // Only the DropdownButton is visible. It contains the selected item + // and a dropdown arrow icon. + await tester.pumpWidget(buildFrame()); + expect(dropdownIcon, findsOneWidget); + expect(item30, findsOneWidget); + + // All menu items have a minimum height of 48. The centers of the + // dropdown icon and the selected menu item are vertically aligned + // and horizontally adjacent. + expect(tester.getSize(item30), const Size(100, 48)); + expect(tester.getCenter(item30).dy, tester.getCenter(dropdownIcon).dy); + expect(tester.getTopRight(item30).dx, tester.getTopLeft(dropdownIcon).dx); + + // Show the popup menu. + await tester.tap(item30); + await tester.pumpAndSettle(); + + // Each item appears twice, once in the menu and once + // in the dropdown button's IndexedStack. + expect(item30.evaluate().length, 2); + expect(item40.evaluate().length, 2); + expect(item50.evaluate().length, 2); + expect(item60.evaluate().length, 2); + + // Verify that the items have the expected sizes. The width of the items + // that appear in the menu is padded by 16 on the left and right. + expect(tester.getSize(item30.first), const Size(100, 48)); + expect(tester.getSize(item40.first), const Size(100, 48)); + expect(tester.getSize(item50.first), const Size(100, 50)); + expect(tester.getSize(item60.first), const Size(100, 60)); + expect(tester.getSize(item30.last), const Size(132, 48)); + expect(tester.getSize(item40.last), const Size(132, 48)); + expect(tester.getSize(item50.last), const Size(132, 50)); + expect(tester.getSize(item60.last), const Size(132, 60)); + + // The vertical center of the selectedItem (item30) should + // line up with its button counterpart. + expect(tester.getCenter(item30.first).dy, tester.getCenter(item30.last).dy); + + // The menu items should be arranged in a column. + expect(tester.getBottomLeft(item30.last), tester.getTopLeft(item40.last)); + expect(tester.getBottomLeft(item40.last), tester.getTopLeft(item50.last)); + expect(tester.getBottomLeft(item50.last), tester.getTopLeft(item60.last)); + + // Dismiss the menu by selecting item40 and then show the menu again. + await tester.tap(item40.last); + await tester.pumpAndSettle(); + expect(dropdownValue, 40); + await tester.tap(item40.first); + await tester.pumpAndSettle(); + + // The vertical center of the selectedItem (item40) should + // line up with its button counterpart. + expect(tester.getCenter(item40.first).dy, tester.getCenter(item40.last).dy); + }); + + testWidgets('DropdownButton menu items do not resize when its route is popped', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/44877. + const items = <String>['one', 'two', 'three']; + String? item = items[0]; + late double textScale; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + builder: (BuildContext context, Widget? child) { + textScale = MediaQuery.of(context).textScaler.scale(14) / 14; + return MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: child!, + ); + }, + home: Scaffold( + body: DropdownButton<String>( + value: item, + items: items + .map((String item) => DropdownMenuItem<String>(value: item, child: Text(item))) + .toList(), + onChanged: (String? newItem) { + setState(() { + item = newItem; + textScale += 0.1; + }); + }, + ), + ), + ); + }, + ), + ); + + // Verify that the first item is showing. + expect(find.text('one'), findsOneWidget); + + // Select a different item to trigger setState, which updates mediaQuery + // and forces a performLayout on the popped _DropdownRoute. This operation + // should not cause an exception. + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + await tester.tap(find.text('two').last); + await tester.pumpAndSettle(); + expect(find.text('two'), findsOneWidget); + }); + + testWidgets('DropdownButton hint is selected item', (WidgetTester tester) async { + const double hintPaddingOffset = 8; + const itemValues = <String>['item0', 'item1', 'item2', 'item3']; + String? selectedItem = 'item0'; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: ButtonTheme( + alignedDropdown: true, + child: DropdownButtonHideUnderline( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + // The pretzel below is from an actual app. The price + // of limited configurability is keeping this working. + return DropdownButton<String>( + isExpanded: true, + elevation: 2, + hint: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // Stack with a positioned widget is used to override the + // hard coded 16px margin in the dropdown code, so that + // this hint aligns "properly" with the menu. + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: <Widget>[ + PositionedDirectional( + width: constraints.maxWidth + hintPaddingOffset, + start: -hintPaddingOffset, + top: 4.0, + child: Text('-$selectedItem-'), + ), + ], + ); + }, + ), + onChanged: (String? value) { + setState(() { + selectedItem = value; + }); + }, + icon: Container(), + items: itemValues.map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + ); + }, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + expect(tester.getTopLeft(find.text('-item0-')).dx, 8); + + // Show the popup menu. + await tester.tap(find.text('-item0-', skipOffstage: false), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('-item0-')).dx, 8); + }); + + Finder findInputDecoratorBorderPainter() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), + ); + } + + testWidgets('DropdownButton can be focused, and has focusColor', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final buttonKey = UniqueKey(); + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + autofocus: true, + useMaterial3: false, + ), + ); + await tester.pumpAndSettle(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(Material), + paints..rect( + rect: const Rect.fromLTRB(348.0, 276.0, 452.0, 324.0), + color: const Color(0x1f000000), + ), + ); + + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + focusColor: const Color(0xff00ff00), + useMaterial3: false, + ), + ); + await tester.pumpAndSettle(); // Pump a frame for autofocus to take effect. + expect( + find.byType(Material), + paints..rect( + rect: const Rect.fromLTRB(348.0, 276.0, 452.0, 324.0), + color: const Color(0x1f00ff00), + ), + ); + }); + + testWidgets('DropdownButtonFormField can be focused, and has focusColor', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final buttonKey = UniqueKey(); + final focusNode = FocusNode(debugLabel: 'DropdownButtonFormField'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + autofocus: true, + decoration: const InputDecoration(filled: true), + ), + ); + + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + // Default focus Color from InputDecorator defaults. + final ThemeData theme = Theme.of(tester.element(find.byType(InputDecorator))); + expect( + findInputDecoratorBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: theme.colorScheme.surfaceContainerHighest), + ); + + // Focus color from Decoration. + await tester.pumpWidget( + buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + decoration: const InputDecoration(filled: true, focusColor: Color(0xff00ffff)), + ), + ); + + expect( + findInputDecoratorBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: const Color(0xff00ffff)), + ); + + // Focus color from focusColor property. + await tester.pumpWidget( + buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + decoration: const InputDecoration(filled: true, focusColor: Color(0xff00ffff)), + focusColor: const Color(0xff00ff00), + ), + ); + + expect( + findInputDecoratorBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: const Color(0xff00ff00)), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/166642. + testWidgets('DropdownButtonFormField can replace focusNode properly', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final buttonKey = UniqueKey(); + var focusNode = FocusNode(debugLabel: 'DropdownButtonFormField'); + addTearDown(() => focusNode.dispose()); + + Widget buildFormField() => buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + decoration: const InputDecoration(filled: true), + focusColor: const Color(0xff00ff00), + ); + + await tester.pumpWidget(buildFormField()); + final Color defaultBorderColor = Theme.of( + tester.element(find.byType(InputDecorator)), + ).colorScheme.surfaceContainerHighest; + expect( + findInputDecoratorBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: defaultBorderColor), + ); + + // Replace focusNode and request focus. + focusNode.dispose(); + focusNode = FocusNode(debugLabel: 'DropdownButtonFormField'); + focusNode.requestFocus(); + + await tester.pumpWidget(buildFormField()); + await tester.pump(); // Wait for requestFocus to take effect. + expect( + findInputDecoratorBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: const Color(0xff00ff00)), + ); + + // Replace focusNode and request focus. + focusNode.dispose(); + focusNode = FocusNode(debugLabel: 'DropdownButtonFormField'); + focusNode.requestFocus(); + + await tester.pumpWidget(buildFormField()); + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); // Wait for unfocus to take effect. + expect( + findInputDecoratorBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: defaultBorderColor), + ); + }); + + testWidgets('DropdownButtonFormField should properly dispose its internal FocusNode ' + 'when replaced by an external FocusNode', (WidgetTester tester) async { + final buttonKey = UniqueKey(); + FocusNode? focusNode; + addTearDown(() => focusNode?.dispose()); + + Widget buildFormField() => buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + focusNode: focusNode, + ); + + await tester.pumpWidget(buildFormField()); + final FocusNode internalNode = tester + .widget<Focus>( + find + .descendant(of: find.byType(DropdownButton<String>), matching: find.byType(Focus)) + .first, + ) + .focusNode!; + + // Replace internal FocusNode with external FocusNode. + focusNode = FocusNode(debugLabel: 'DropdownButtonFormField'); + await tester.pumpWidget(buildFormField()); + + expect( + internalNode.dispose, + throwsA( + isA<FlutterError>().having( + (FlutterError error) => error.message, + 'message', + startsWith('A FocusNode was used after being disposed.'), + ), + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/147069. + testWidgets('DropdownButtonFormField can be hovered', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final buttonKey = UniqueKey(); + + await tester.pumpWidget( + buildFrame(isFormField: true, buttonKey: buttonKey, onChanged: onChanged), + ); + await tester.pump(); + + // Check inputDecorator.isHovering value because DropdownButtonFormField + // delegates to the InputDecorator which manages hover overlay. + InputDecorator inputDecorator = tester.widget(find.byType(InputDecorator)); + expect(inputDecorator.isHovering, false); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byKey(buttonKey))); + await tester.pump(); + + inputDecorator = tester.widget(find.byType(InputDecorator)); + expect(inputDecorator.isHovering, true); + }); + + // Regression test for https://github.com/flutter/flutter/issues/151460. + testWidgets('DropdownButtonFormField has hover color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final buttonKey = UniqueKey(); + + await tester.pumpWidget( + buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + // Setting InputDecoration.filled to true is required to get overlay showing. + decoration: const InputDecoration(filled: true), + ), + ); + await tester.pump(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byKey(buttonKey))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 15)); // Hover animation. + + // Default hover color. + final ThemeData theme = Theme.of(tester.element(find.byType(InputDecorator))); + expect( + findInputDecoratorBorderPainter(), + paints..rrect( + style: PaintingStyle.fill, + color: Color.alphaBlend(theme.hoverColor, theme.colorScheme.surfaceContainerHighest), + ), + ); + + // Custom hover color. + const hoverColor = Color(0xaa00ff00); + await tester.pumpWidget( + buildFrame( + isFormField: true, + buttonKey: buttonKey, + onChanged: onChanged, + decoration: const InputDecoration(filled: true, hoverColor: hoverColor), + ), + ); + expect( + findInputDecoratorBorderPainter(), + paints..rrect( + style: PaintingStyle.fill, + color: Color.alphaBlend(hoverColor, theme.colorScheme.surfaceContainerHighest), + ), + ); + }); + + testWidgets("DropdownButton won't be focused if not enabled", (WidgetTester tester) async { + final buttonKey = UniqueKey(); + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + focusNode: focusNode, + autofocus: true, + focusColor: const Color(0xff00ff00), + ), + ); + await tester.pump(); // Pump a frame for autofocus to take effect (although it shouldn't). + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + find.byKey(buttonKey), + isNot( + paints..rrect( + rrect: const RRect.fromLTRBXY(0.0, 0.0, 104.0, 48.0, 4.0, 4.0), + color: const Color(0xff00ff00), + ), + ), + ); + }); + + testWidgets('DropdownButton is activated with the enter key', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + String? value = 'one'; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<String>( + focusNode: focusNode, + autofocus: true, + onChanged: (String? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: menuItems.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + expect(value, equals('one')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Focus 'two' + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select 'two'. + await tester.pump(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('two')); + }); + + // Regression test for https://github.com/flutter/flutter/issues/77655. + testWidgets('DropdownButton selecting a null valued item should be selected', ( + WidgetTester tester, + ) async { + final items = <MapEntry<String?, String>>[ + const MapEntry<String?, String>(null, 'None'), + const MapEntry<String?, String>('one', 'One'), + const MapEntry<String?, String>('two', 'Two'), + ]; + String? selectedItem = 'one'; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: selectedItem, + onChanged: (String? string) { + setState(() { + selectedItem = string; + }); + }, + items: items.map((MapEntry<String?, String> item) { + return DropdownMenuItem<String>(value: item.key, child: Text(item.value)); + }).toList(), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.text('None').last); + await tester.pumpAndSettle(); + expect(find.text('None'), findsOneWidget); + }); + + testWidgets('DropdownButton is activated with the space key', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + String? value = 'one'; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<String>( + focusNode: focusNode, + autofocus: true, + onChanged: (String? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: menuItems.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + expect(value, equals('one')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Focus 'two' + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); // Select 'two'. + await tester.pump(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('two')); + }); + + testWidgets('Selected element is focused when dropdown is opened', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + String? value = 'one'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<String>( + focusNode: focusNode, + autofocus: true, + onChanged: (String? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: menuItems.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('Text $item')), + ); + }).toList(), + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu open animation + expect(value, equals('one')); + expect( + Focus.of(tester.element(find.byKey(const ValueKey<String>('one')).last)).hasPrimaryFocus, + isTrue, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Focus 'two' + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select 'two' and close the dropdown. + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu close animation + + expect(value, equals('two')); + + // Now make sure that "two" is focused when we re-open the dropdown. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu open animation + expect(value, equals('two')); + final Element element = tester.element(find.byKey(const ValueKey<String>('two')).last); + final FocusNode node = Focus.of(element); + expect(node.hasFocus, isTrue); + }); + + testWidgets( + 'Selected element is correctly focused with dropdown that more items than fit on the screen', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + int? value = 1; + final hugeMenuItems = List<int>.generate(50, (int index) => index); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<int>( + focusNode: focusNode, + autofocus: true, + onChanged: (int? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: hugeMenuItems.map<DropdownMenuItem<int>>((int item) { + return DropdownMenuItem<int>( + key: ValueKey<int>(item), + value: item, + child: Text(item.toString(), key: ValueKey<String>('Text $item')), + ); + }).toList(), + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu open animation + expect(value, equals(1)); + expect( + Focus.of(tester.element(find.byKey(const ValueKey<int>(1)).last)).hasPrimaryFocus, + isTrue, + ); + + for (var i = 0; i < 41; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Move to the next one. + await tester.pumpAndSettle( + const Duration(milliseconds: 200), + ); // Wait for it to animate the menu. + } + await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select '42' and close the dropdown. + await tester.pumpAndSettle(const Duration(seconds: 1)); // Finish the menu close animation + expect(value, equals(42)); + + // Now make sure that "42" is focused when we re-open the dropdown. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu open animation + expect(value, equals(42)); + final Element element = tester.element(find.byKey(const ValueKey<int>(42)).last); + final FocusNode node = Focus.of(element); + expect(node.hasFocus, isTrue); + }, + ); + + testWidgets("Having a focused element doesn't interrupt scroll when flung by touch", ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + int? value = 1; + final hugeMenuItems = List<int>.generate(100, (int index) => index); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<int>( + focusNode: focusNode, + autofocus: true, + onChanged: (int? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: hugeMenuItems.map<DropdownMenuItem<int>>((int item) { + return DropdownMenuItem<int>( + key: ValueKey<int>(item), + value: item, + child: Text(item.toString(), key: ValueKey<String>('Text $item')), + ); + }).toList(), + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(value, equals(1)); + expect( + Focus.of(tester.element(find.byKey(const ValueKey<int>(1)).last)).hasPrimaryFocus, + isTrue, + ); + + // Move to an item very far down the menu. + for (var i = 0; i < 90; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); // Move to the next one. + await tester.pumpAndSettle(); // Wait for it to animate the menu. + } + expect( + Focus.of(tester.element(find.byKey(const ValueKey<int>(91)).last)).hasPrimaryFocus, + isTrue, + ); + + // Scroll back to the top using touch, and make sure we end up there. + final Finder menu = find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString().startsWith('_DropdownMenu<'); + }); + final Rect menuRect = tester.getRect(menu).shift(tester.getTopLeft(menu)); + for (var i = 0; i < 10; ++i) { + await tester.fling(menu, Offset(0.0, menuRect.height), 10.0); + } + await tester.pumpAndSettle(); + + // Make sure that we made it to the top and something didn't stop the + // scroll. + expect(find.byKey(const ValueKey<int>(1)), findsNWidgets(2)); + expect( + tester.getRect(find.byKey(const ValueKey<int>(1)).last), + equals(const Rect.fromLTRB(372.0, 104.0, 436.0, 152.0)), + ); + + // Scrolling to the top again has removed the one the focus was on from the + // tree, causing it to lose focus. + expect( + Focus.of( + tester.element(find.byKey(const ValueKey<int>(91), skipOffstage: false).last), + ).hasPrimaryFocus, + isFalse, + ); + }); + + testWidgets('DropdownButton onTap callback can request focus', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton')..addListener(() {}); + addTearDown(focusNode.dispose); + int? value = 1; + final hugeMenuItems = List<int>.generate(100, (int index) => index); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<int>( + focusNode: focusNode, + onChanged: (int? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: hugeMenuItems.map<DropdownMenuItem<int>>((int item) { + return DropdownMenuItem<int>( + key: ValueKey<int>(item), + value: item, + child: Text(item.toString()), + ); + }).toList(), + ); + }, + ), + ), + ), + ), + ); + + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isFalse); + + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + + // Close the dropdown menu. + await tester.tapAt(const Offset(1.0, 1.0)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('DropdownButton changes selected item with arrow keys', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'DropdownButton'); + addTearDown(focusNode.dispose); + String? value = 'one'; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<String>( + focusNode: focusNode, + autofocus: true, + onChanged: (String? newValue) { + setState(() { + value = newValue; + }); + }, + value: value, + itemHeight: null, + items: menuItems.map<DropdownMenuItem<String>>((String item) { + return DropdownMenuItem<String>( + key: ValueKey<String>(item), + value: item, + child: Text(item, key: ValueKey<String>('${item}Text')), + ); + }).toList(), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.pump(); // Pump a frame for autofocus to take effect. + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + expect(value, equals('one')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Focus 'two'. + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Focus 'three'. + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Back to 'two'. + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select 'two'. + await tester.pump(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(value, equals('two')); + }); + + testWidgets('DropdownButton onTap callback is called when defined', (WidgetTester tester) async { + var dropdownButtonTapCounter = 0; + String? value = 'one'; + + void onChanged(String? newValue) { + value = newValue; + } + + void onTap() { + dropdownButtonTapCounter += 1; + } + + Widget build() => buildFrame(initialValue: value, onChanged: onChanged, onTap: onTap); + await tester.pumpWidget(build()); + + expect(dropdownButtonTapCounter, 0); + + // Tap dropdown button. + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + + expect(value, equals('one')); + expect(dropdownButtonTapCounter, 1); // Should update counter. + + // Tap dropdown menu item. + await tester.tap(find.text('three').last); + await tester.pumpAndSettle(); + + expect(value, equals('three')); + expect(dropdownButtonTapCounter, 1); // Should not change. + + // Tap dropdown button again. + await tester.tap(find.text('three', skipOffstage: false), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(value, equals('three')); + expect(dropdownButtonTapCounter, 2); // Should update counter. + + // Tap dropdown menu item. + await tester.tap(find.text('two').last); + await tester.pumpAndSettle(); + + expect(value, equals('two')); + expect(dropdownButtonTapCounter, 2); // Should not change. + }); + + testWidgets('DropdownMenuItem onTap callback is called when defined', ( + WidgetTester tester, + ) async { + String? value = 'one'; + final menuItemTapCounters = <int>[0, 0, 0, 0]; + void onChanged(String? newValue) { + value = newValue; + } + + final onTapCallbacks = <VoidCallback>[ + () { + menuItemTapCounters[0] += 1; + }, + () { + menuItemTapCounters[1] += 1; + }, + () { + menuItemTapCounters[2] += 1; + }, + () { + menuItemTapCounters[3] += 1; + }, + ]; + + var currentIndex = -1; + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: RepaintBoundary( + child: DropdownButton<String>( + value: value, + onChanged: onChanged, + items: menuItems.map<DropdownMenuItem<String>>((String item) { + currentIndex += 1; + return DropdownMenuItem<String>( + value: item, + onTap: onTapCallbacks[currentIndex], + child: Text(item), + ); + }).toList(), + ), + ), + ), + ), + ); + + // Tap dropdown button. + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + + expect(value, equals('one')); + // Counters should still be zero. + expect(menuItemTapCounters, <int>[0, 0, 0, 0]); + + // Tap dropdown menu item. + await tester.tap(find.text('three').last); + await tester.pumpAndSettle(); + + // Should update the counter for the third item (second index). + expect(value, equals('three')); + expect(menuItemTapCounters, <int>[0, 0, 1, 0]); + + // Tap dropdown button again. + await tester.tap(find.text('three', skipOffstage: false), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Should not change. + expect(value, equals('three')); + expect(menuItemTapCounters, <int>[0, 0, 1, 0]); + + // Tap dropdown menu item. + await tester.tap(find.text('two').last); + await tester.pumpAndSettle(); + + // Should update the counter for the second item (first index). + expect(value, equals('two')); + expect(menuItemTapCounters, <int>[0, 1, 1, 0]); + + // Tap dropdown button again. + await tester.tap(find.text('two', skipOffstage: false), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Should not change. + expect(value, equals('two')); + expect(menuItemTapCounters, <int>[0, 1, 1, 0]); + + // Tap the already selected menu item + await tester.tap(find.text('two').last); + await tester.pumpAndSettle(); + + // Should update the counter for the second item (first index), even + // though it was already selected. + expect(value, equals('two')); + expect(menuItemTapCounters, <int>[0, 2, 1, 0]); + }); + + testWidgets( + 'Does not crash when option is selected without waiting for opening animation to complete', + (WidgetTester tester) async { + // Regression test for b/171846624. + + final options = <String>['first', 'second', 'third']; + String? value = options.first; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) => DropdownButton<String>( + value: value, + items: options + .map((String s) => DropdownMenuItem<String>(value: s, child: Text(s))) + .toList(), + onChanged: (String? v) { + setState(() { + value = v; + }); + }, + ), + ), + ), + ), + ); + expect(find.text('first').hitTestable(), findsOneWidget); + expect(find.text('second').hitTestable(), findsNothing); + expect(find.text('third').hitTestable(), findsNothing); + + // Open dropdown. + await tester.tap(find.text('first').hitTestable()); + await tester.pump(); + + expect(find.text('third').hitTestable(), findsOneWidget); + expect(find.text('first').hitTestable(), findsOneWidget); + expect(find.text('second').hitTestable(), findsOneWidget); + + // Deliberately not waiting for opening animation to complete! + + // Select an option in dropdown. + await tester.tap(find.text('third').hitTestable()); + await tester.pump(); + expect(find.text('third').hitTestable(), findsOneWidget); + expect(find.text('first').hitTestable(), findsNothing); + expect(find.text('second').hitTestable(), findsNothing); + }, + ); + + testWidgets('Dropdown menu should persistently show a scrollbar if it is scrollable', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildFrame( + initialValue: '0', + // menu is short enough to fit onto the screen. + items: List<String>.generate(/*length=*/ 10, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + await tester.tap(find.text('0')); + await tester.pumpAndSettle(); + + ScrollController scrollController = PrimaryScrollController.of( + tester.element(find.byType(ListView)), + ); + // The scrollbar shouldn't show if the list fits into the screen. + expect(scrollController.position.maxScrollExtent, 0); + expect(find.byType(Scrollbar), isNot(paints..rect())); + + await tester.tap(find.text('0').last); + await tester.pumpAndSettle(); + await tester.pumpWidget( + buildFrame( + initialValue: '0', + // menu is too long to fit onto the screen. + items: List<String>.generate(/*length=*/ 100, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + await tester.tap(find.text('0')); + await tester.pumpAndSettle(); + + scrollController = PrimaryScrollController.of(tester.element(find.byType(ListView))); + // The scrollbar is shown when the list is longer than the height of the screen. + expect(scrollController.position.maxScrollExtent > 0, isTrue); + expect(find.byType(Scrollbar), paints..rect()); + }); + + testWidgets( + "Dropdown menu's maximum height should be influenced by DropdownButton.menuMaxHeight.", + (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + initialValue: '0', + items: List<String>.generate(/*length=*/ 64, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + await tester.tap(find.text('0')); + await tester.pumpAndSettle(); + + final Element element = tester.element(find.byType(ListView)); + double menuHeight = element.size!.height; + // The default maximum height should be one item height from the edge. + // https://material.io/design/components/menus.html#usage + final double mediaHeight = MediaQuery.of(element).size.height; + final double defaultMenuHeight = mediaHeight - (2 * kMinInteractiveDimension); + expect(menuHeight, defaultMenuHeight); + + await tester.tap(find.text('0').last); + await tester.pumpAndSettle(); + + // Set menuMaxHeight which is less than defaultMenuHeight + await tester.pumpWidget( + buildFrame( + initialValue: '0', + items: List<String>.generate(/*length=*/ 64, (int index) => index.toString()), + onChanged: onChanged, + menuMaxHeight: 7 * kMinInteractiveDimension, + ), + ); + await tester.tap(find.text('0')); + await tester.pumpAndSettle(); + + menuHeight = tester.element(find.byType(ListView)).size!.height; + + expect(menuHeight == defaultMenuHeight, isFalse); + expect(menuHeight, kMinInteractiveDimension * 7); + + await tester.tap(find.text('0').last); + await tester.pumpAndSettle(); + + // Set menuMaxHeight which is greater than defaultMenuHeight + await tester.pumpWidget( + buildFrame( + initialValue: '0', + items: List<String>.generate(/*length=*/ 64, (int index) => index.toString()), + onChanged: onChanged, + menuMaxHeight: mediaHeight, + ), + ); + + await tester.tap(find.text('0')); + await tester.pumpAndSettle(); + + menuHeight = tester.element(find.byType(ListView)).size!.height; + expect(menuHeight, defaultMenuHeight); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/89029 + testWidgets('menu position test with `menuMaxHeight`', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + initialValue: '6', + items: List<String>.generate(/*length=*/ 64, (int index) => index.toString()), + onChanged: onChanged, + menuMaxHeight: 2 * kMinInteractiveDimension, + ), + ); + + await tester.tap(find.text('6')); + await tester.pumpAndSettle(); + + final RenderBox menuBox = tester.renderObject(find.byType(ListView)); + final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey)); + // The menu's bottom should align with the drop-button's bottom. + expect( + menuBox.localToGlobal(menuBox.paintBounds.bottomCenter).dy, + buttonBox.localToGlobal(buttonBox.paintBounds.bottomCenter).dy, + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/76614 + testWidgets('Do not crash if used in very short screen', (WidgetTester tester) async { + // The default item height is 48.0 pixels and needs two items padding since + // the menu requires empty space surrounding the menu. Finally, the constraint height + // is 47.0 pixels for the menu rendering. + tester.view.physicalSize = const Size(800.0, 48.0 * 3 - 1.0); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + const value = 'foo'; + final itemKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: DropdownButton<String>( + value: value, + items: <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(key: itemKey, value: value, child: const Text(value)), + ], + onChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text(value)); + await tester.pumpAndSettle(); + + final List<RenderBox> itemBoxes = tester + .renderObjectList<RenderBox>(find.byKey(itemKey)) + .toList(); + expect(itemBoxes[0].localToGlobal(Offset.zero).dx, 364.0); + expect(itemBoxes[0].localToGlobal(Offset.zero).dy, 47.5); + + expect(itemBoxes[1].localToGlobal(Offset.zero).dx, 364.0); + expect(itemBoxes[1].localToGlobal(Offset.zero).dy, 47.5); + + expect( + find.ancestor(of: find.text(value).last, matching: find.byType(CustomPaint)).at(2), + paints + ..save() + ..rrect() + ..rrect() + ..rrect() + // The height of menu is 47.0. + ..rrect( + rrect: const RRect.fromLTRBXY(0.0, 0.0, 112.0, 47.0, 2.0, 2.0), + color: Colors.grey[50], + hasMaskFilter: false, + ), + ); + }); + + testWidgets('Tapping a disabled item should not close DropdownButton', ( + WidgetTester tester, + ) async { + String? value = 'first'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) => DropdownButton<String>( + value: value, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(enabled: false, child: Text('disabled')), + DropdownMenuItem<String>(value: 'first', child: Text('first')), + DropdownMenuItem<String>(value: 'second', child: Text('second')), + ], + onChanged: (String? newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('first').hitTestable()); + await tester.pumpAndSettle(); + + // Tap on a disabled item. + await tester.tap(find.text('disabled').hitTestable()); + await tester.pumpAndSettle(); + + // The dropdown should still be open, i.e., there should be one widget with 'second' text. + expect(find.text('second').hitTestable(), findsOneWidget); + }); + + testWidgets('Disabled item should not be focusable', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: 'enabled', + onChanged: onChanged, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(enabled: false, child: Text('disabled')), + DropdownMenuItem<String>(value: 'enabled', child: Text('enabled')), + ], + ), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('enabled').hitTestable()); + await tester.pumpAndSettle(); + + // The `FocusNode` of [disabledItem] should be `null` as enabled is false. + final Element disabledItem = tester.element(find.text('disabled').hitTestable()); + expect( + Focus.maybeOf(disabledItem), + null, + reason: 'Disabled menu item should not be able to request focus', + ); + }); + + testWidgets('alignment test', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + Widget buildFrame({AlignmentGeometry? buttonAlignment, AlignmentGeometry? menuAlignment}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButton<String>( + key: buttonKey, + alignment: buttonAlignment ?? AlignmentDirectional.centerStart, + value: 'enabled', + onChanged: onChanged, + items: <DropdownMenuItem<String>>[ + DropdownMenuItem<String>( + alignment: buttonAlignment ?? AlignmentDirectional.centerStart, + enabled: false, + child: const Text('disabled'), + ), + DropdownMenuItem<String>( + alignment: buttonAlignment ?? AlignmentDirectional.centerStart, + value: 'enabled', + child: const Text('enabled'), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + final RenderBox buttonBox = tester.renderObject(find.byKey(buttonKey)); + RenderBox selectedItemBox = tester.renderObject(find.text('enabled')); + // Default to center-start aligned. + expect( + buttonBox.localToGlobal(Offset(0.0, buttonBox.size.height / 2.0)), + selectedItemBox.localToGlobal(Offset(0.0, selectedItemBox.size.height / 2.0)), + ); + + await tester.pumpWidget( + buildFrame( + buttonAlignment: AlignmentDirectional.center, + menuAlignment: AlignmentDirectional.center, + ), + ); + + selectedItemBox = tester.renderObject(find.text('enabled')); + // Should be center-center aligned, the icon size is 24.0 pixels. + expect( + buttonBox.localToGlobal( + Offset((buttonBox.size.width - 24.0) / 2.0, buttonBox.size.height / 2.0), + ), + offsetMoreOrLessEquals( + selectedItemBox.localToGlobal( + Offset(selectedItemBox.size.width / 2.0, selectedItemBox.size.height / 2.0), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('enabled').hitTestable()); + await tester.pumpAndSettle(); + + final RenderBox selectedItemBoxInMenu = tester + .renderObjectList<RenderBox>(find.text('enabled')) + .toList()[1]; + final Finder menu = find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString().startsWith('_DropdownMenu<'); + }); + final Rect menuRect = tester.getRect(menu); + final Offset center = selectedItemBoxInMenu.localToGlobal( + Offset(selectedItemBoxInMenu.size.width / 2.0, selectedItemBoxInMenu.size.height / 2.0), + ); + + expect(center.dx, moreOrLessEquals(menuRect.topCenter.dx)); + expect( + center.dy, + moreOrLessEquals( + selectedItemBox + .localToGlobal( + Offset(selectedItemBox.size.width / 2.0, selectedItemBox.size.height / 2.0), + ) + .dy, + ), + ); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + Widget feedbackBoilerplate({bool? enableFeedback}) { + return MaterialApp( + home: Material( + child: DropdownButton<String>( + value: 'One', + enableFeedback: enableFeedback, + underline: Container(height: 2, color: Colors.deepPurpleAccent), + onChanged: (String? value) {}, + items: <String>['One', 'Two'].map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + ), + ), + ); + } + + testWidgets('Dropdown with enabled feedback', (WidgetTester tester) async { + const enableFeedback = true; + + await tester.pumpWidget(feedbackBoilerplate(enableFeedback: enableFeedback)); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(InkWell, 'One').last); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('Dropdown with disabled feedback', (WidgetTester tester) async { + const enableFeedback = false; + + await tester.pumpWidget(feedbackBoilerplate(enableFeedback: enableFeedback)); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(InkWell, 'One').last); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets('Dropdown with enabled feedback by default', (WidgetTester tester) async { + await tester.pumpWidget(feedbackBoilerplate()); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(InkWell, 'Two').last); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + }); + + testWidgets('DropdownMenuItem has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + const menuKey = Key('testDropdownMenuButton'); + const itemKey = Key('testDropdownMenuItem'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DropdownButton<String>( + key: menuKey, + onChanged: (String? value) {}, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>( + key: itemKey, + value: 'testDropdownMenuItem', + child: Text('TestDropdownMenuItem'), + ), + ], + ), + ), + ), + ); + + // Open DropdownButton. + await tester.tap(find.byKey(menuKey)); + await tester.pump(); + + // Find DropdownMenuItem. + final Finder menuItemFinder = find.byKey(itemKey); + final Offset onMenuItem = tester.getCenter(menuItemFinder); + final Offset offMenuItem = tester.getBottomRight(menuItemFinder) + const Offset(1, 1); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + + await gesture.addPointer(location: onMenuItem); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + await gesture.moveTo(offMenuItem); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('DropdownButton changes mouse cursor when hovered as expected', ( + WidgetTester tester, + ) async { + const key = Key('testDropdownButton'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DropdownButton<String>( + key: key, + onChanged: (String? newValue) {}, + items: <String>['One', 'Two', 'Three', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + ), + ), + ), + ); + + final Finder dropdownButtonFinder = find.byKey(key); + final Offset onDropdownButton = tester.getCenter(dropdownButtonFinder); + final Offset offDropdownButton = + tester.getBottomRight(dropdownButtonFinder) + const Offset(1, 1); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + + await gesture.addPointer(location: onDropdownButton); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + await gesture.moveTo(offDropdownButton); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test that mouse cursor doesn't change when button is disabled + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DropdownButton<String>( + key: key, + onChanged: null, + items: <String>['One', 'Two', 'Three', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + ), + ), + ), + ); + + await gesture.moveTo(onDropdownButton); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(offDropdownButton); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('DropdownButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + const menuKey = Key('testDropdownButton'); + const itemKey = Key('testDropdownMenuItem'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DropdownButton<String>( + key: menuKey, + mouseCursor: SystemMouseCursors.cell, + dropdownMenuItemMouseCursor: SystemMouseCursors.grab, + onChanged: (String? newValue) {}, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(key: itemKey, value: 'One', child: Text('One')), + ], + ), + ), + ), + ); + + final Finder dropdownButtonFinder = find.byKey(menuKey); + final Offset onDropdownButton = tester.getCenter(dropdownButtonFinder); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + + await gesture.addPointer(location: onDropdownButton); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('DropdownMenuItem has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + const menuKey = Key('testDropdownButton'); + const itemKey = Key('testDropdownMenuItem'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: DropdownButton<String>( + key: menuKey, + dropdownMenuItemMouseCursor: SystemMouseCursors.grab, + onChanged: (String? newValue) {}, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(key: itemKey, value: 'One', child: Text('One')), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + + // Open DropdownButton. + await tester.tap(find.byKey(menuKey)); + await tester.pumpAndSettle(); + + // Find DropdownMenuItem. + final Finder menuItemFinder = find.byKey(itemKey); + final Offset onMenuItem = tester.getCenter(menuItemFinder); + + await gesture.addPointer(location: onMenuItem); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + }); + + testWidgets( + 'Conflicting scrollbars are not applied by ScrollBehavior to Dropdown', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/83819 + // Open the dropdown menu + final Key buttonKey = UniqueKey(); + await tester.pumpWidget( + buildFrame( + buttonKey: buttonKey, + initialValue: null, // nothing selected + items: List<String>.generate(100, (int index) => index.toString()), + onChanged: onChanged, + ), + ); + await tester.tap(find.byKey(buttonKey)); + await tester.pump(); + await tester.pumpAndSettle(); // finish the menu animation + + // The inherited ScrollBehavior should not apply Scrollbars since they are + // already built in to the widget. For iOS platform, ScrollBar directly returns + // CupertinoScrollbar + expect( + find.byType(CupertinoScrollbar), + debugDefaultTargetPlatformOverride == TargetPlatform.iOS ? findsOneWidget : findsNothing, + ); + expect(find.byType(Scrollbar), findsOneWidget); + expect(find.byType(RawScrollbar), findsNothing); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('borderRadius property works properly', (WidgetTester tester) async { + const radius = 20.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: DropdownButton<String>( + borderRadius: const BorderRadius.all(Radius.circular(radius)), + value: 'One', + items: <String>['One', 'Two', 'Three', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + + expect( + find.ancestor(of: find.text('One').last, matching: find.byType(CustomPaint)).at(2), + paints + ..save() + ..rrect() + ..rrect() + ..rrect() + ..rrect(rrect: const RRect.fromLTRBXY(0.0, 0.0, 144.0, 208.0, radius, radius)), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/88574 + testWidgets("specifying itemHeight affects popup menu items' height", ( + WidgetTester tester, + ) async { + const value = 'One'; + const double itemHeight = 80; + final List<DropdownMenuItem<String>> menuItems = <String>[value, 'Two', 'Free', 'Four'] + .map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }) + .toList(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButton<String>( + value: value, + itemHeight: itemHeight, + onChanged: (_) {}, + items: menuItems, + ), + ), + ), + ), + ); + + await tester.tap(find.text(value)); + await tester.pumpAndSettle(); + + for (final item in menuItems) { + final Iterable<Element> elements = tester.elementList(find.byWidget(item)); + for (final element in elements) { + expect(element.size!.height, itemHeight); + } + } + }); + + // Regression test for https://github.com/flutter/flutter/issues/92438 + testWidgets('Do not throw due to the double precision', (WidgetTester tester) async { + const value = 'One'; + const itemHeight = 77.701; + final List<DropdownMenuItem<String>> menuItems = <String>[value, 'Two', 'Free'] + .map<DropdownMenuItem<String>>((String value) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }) + .toList(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButton<String>( + value: value, + itemHeight: itemHeight, + onChanged: (_) {}, + items: menuItems, + ), + ), + ), + ), + ); + + await tester.tap(find.text(value)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), null); + }); + + testWidgets('BorderRadius property works properly for DropdownButtonFormField', ( + WidgetTester tester, + ) async { + const radius = 20.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField<String>( + borderRadius: const BorderRadius.all(Radius.circular(radius)), + initialValue: 'One', + items: <String>['One', 'Two', 'Three', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + + expect( + find.ancestor(of: find.text('One').last, matching: find.byType(CustomPaint)).at(2), + paints + ..save() + ..rrect() + ..rrect() + ..rrect() + ..rrect(rrect: const RRect.fromLTRBXY(0.0, 0.0, 800.0, 208.0, radius, radius)), + ); + }); + + testWidgets('DropdownButton hint alignment', (WidgetTester tester) async { + const hintText = 'hint'; + + // AlignmentDirectional.centerStart (default) + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.centerStart, isExpanded: false), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 348.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.topStart + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.topStart, isExpanded: false), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 348.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.bottomStart + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.bottomStart, isExpanded: false), + ); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dx, 348.0); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dy, 350.0); + // AlignmentDirectional.center + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.center, isExpanded: false), + ); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dx, 388.0); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dy, 300.0); + // AlignmentDirectional.topEnd + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.topEnd, isExpanded: false), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 428.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.centerEnd + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.centerEnd, isExpanded: false), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 428.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.bottomEnd + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.bottomEnd, isExpanded: false), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 428.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 334.0); + + // DropdownButton with `isExpanded: true` + // AlignmentDirectional.centerStart (default) + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.centerStart, isExpanded: true), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 0.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.topStart + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.topStart, isExpanded: true), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 0.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.bottomStart + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.bottomStart, isExpanded: true), + ); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dx, 0.0); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dy, 350.0); + // AlignmentDirectional.center + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.center, isExpanded: true), + ); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dx, 388.0); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dy, 300.0); + // AlignmentDirectional.topEnd + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.topEnd, isExpanded: true), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 776.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.centerEnd + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.centerEnd, isExpanded: true), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 776.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.bottomEnd + await tester.pumpWidget( + buildDropdownWithHint(alignment: AlignmentDirectional.bottomEnd, isExpanded: true), + ); + expect(tester.getBottomRight(find.text(hintText, skipOffstage: false)).dx, 776.0); + expect(tester.getBottomRight(find.text(hintText, skipOffstage: false)).dy, 350.0); + }); + + testWidgets('DropdownButton hint alignment with selectedItemBuilder', ( + WidgetTester tester, + ) async { + const hintText = 'hint'; + + // AlignmentDirectional.centerStart (default) + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.centerStart, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 348.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.topStart + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.topStart, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 348.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.bottomStart + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.bottomStart, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dx, 348.0); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dy, 350.0); + // AlignmentDirectional.center + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.center, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dx, 388.0); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dy, 300.0); + // AlignmentDirectional.topEnd + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.topEnd, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 428.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.centerEnd + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.centerEnd, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 428.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.bottomEnd + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.bottomEnd, + isExpanded: false, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 428.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 334.0); + + // DropdownButton with `isExpanded: true` + // AlignmentDirectional.centerStart (default) + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.centerStart, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 0.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.topStart + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.topStart, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dx, 0.0); + expect(tester.getTopLeft(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.bottomStart + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.bottomStart, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dx, 0.0); + expect(tester.getBottomLeft(find.text(hintText, skipOffstage: false)).dy, 350.0); + // AlignmentDirectional.center + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.center, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dx, 388.0); + expect(tester.getCenter(find.text(hintText, skipOffstage: false)).dy, 300.0); + // AlignmentDirectional.topEnd + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.topEnd, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 776.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 250.0); + // AlignmentDirectional.centerEnd + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.centerEnd, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dx, 776.0); + expect(tester.getTopRight(find.text(hintText, skipOffstage: false)).dy, 292.0); + // AlignmentDirectional.bottomEnd + await tester.pumpWidget( + buildDropdownWithHint( + alignment: AlignmentDirectional.bottomEnd, + isExpanded: true, + enableSelectedItemBuilder: true, + ), + ); + expect(tester.getBottomRight(find.text(hintText, skipOffstage: false)).dx, 776.0); + expect(tester.getBottomRight(find.text(hintText, skipOffstage: false)).dy, 350.0); + }); + + group('DropdownButtonFormField decoration hintText', () { + const decorationHintText = 'Decoration Hint text'; + const hintText = 'Hint text'; + const disabledHintText = 'Disabled Hint text'; + + testWidgets('is the fallback value for DropdownButtonFormField.hint', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildFrame( + isFormField: true, + onChanged: (String? newValue) {}, + decoration: const InputDecoration(hintText: decorationHintText), + ), + ); + + expect(find.text(decorationHintText, skipOffstage: false), findsOne); + }); + + testWidgets('does not override DropdownButtonFormField.hint', (WidgetTester tester) async { + await tester.pumpWidget( + buildFrame( + hint: const Text(hintText), + isFormField: true, + onChanged: (String? newValue) {}, + decoration: const InputDecoration(hintText: decorationHintText), + ), + ); + + expect(find.text(hintText, skipOffstage: false), findsOne); + expect(find.text(decorationHintText, skipOffstage: false), findsNothing); + }); + + testWidgets('is the fallback value for DropdownButtonFormField.disabledHint', ( + WidgetTester tester, + ) async { + // The Dropdown is disabled because onChanged is not defined. + await tester.pumpWidget( + buildFrame( + isFormField: true, + decoration: const InputDecoration(hintText: decorationHintText), + ), + ); + + expect(find.text(decorationHintText, skipOffstage: false), findsOne); + }); + + testWidgets('does not override DropdownButtonFormField.disabledHint', ( + WidgetTester tester, + ) async { + // The Dropdown is disabled because onChanged is not defined. + await tester.pumpWidget( + buildFrame( + disabledHint: const Text(disabledHintText), + isFormField: true, + decoration: const InputDecoration(hintText: decorationHintText), + ), + ); + + expect(find.text(disabledHintText, skipOffstage: false), findsOne); + expect(find.text(decorationHintText, skipOffstage: false), findsNothing); + }); + + testWidgets('is not used for disabledHint if DropdownButtonFormField.hint is provided', ( + WidgetTester tester, + ) async { + // The Dropdown is disabled because onChanged is not defined. + await tester.pumpWidget( + buildFrame( + hint: const Text(hintText), + isFormField: true, + decoration: const InputDecoration(hintText: decorationHintText), + ), + ); + + expect(find.text(hintText, skipOffstage: false), findsOne); + expect(find.text(decorationHintText, skipOffstage: false), findsNothing); + }); + }); + + testWidgets('BorderRadius property clips dropdown menu', (WidgetTester tester) async { + const radius = 20.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DropdownButtonFormField<String>( + borderRadius: const BorderRadius.all(Radius.circular(radius)), + initialValue: 'One', + items: <String>['One', 'Two', 'Three', 'Four'].map<DropdownMenuItem<String>>(( + String value, + ) { + return DropdownMenuItem<String>(value: value, child: Text(value)); + }).toList(), + onChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + + final RenderClipRRect renderClip = tester.allRenderObjects.whereType<RenderClipRRect>().first; + expect(renderClip.borderRadius, const BorderRadius.all(Radius.circular(radius))); + }); + + testWidgets('Size of DropdownButton with padding', (WidgetTester tester) async { + const double padVertical = 5; + const double padHorizontal = 10; + final Key buttonKey = UniqueKey(); + EdgeInsets? padding; + + Widget build() => buildFrame(buttonKey: buttonKey, onChanged: onChanged, padding: padding); + + await tester.pumpWidget(build()); + final RenderBox buttonBoxNoPadding = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBoxNoPadding.attached); + final noPaddingSize = Size.copy(buttonBoxNoPadding.size); + + padding = const EdgeInsets.symmetric(vertical: padVertical, horizontal: padHorizontal); + await tester.pumpWidget(build()); + final RenderBox buttonBoxPadded = tester.renderObject<RenderBox>(find.byKey(buttonKey)); + assert(buttonBoxPadded.attached); + final paddedSize = Size.copy(buttonBoxPadded.size); + + // dropdowns with padding should be that much larger than with no padding + expect(noPaddingSize.height, equals(paddedSize.height - padVertical * 2)); + expect(noPaddingSize.width, equals(paddedSize.width - padHorizontal * 2)); + }); + + testWidgets('Dropdown closes when barrier is tapped by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: 'first', + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(enabled: false, child: Text('disabled')), + DropdownMenuItem<String>(value: 'first', child: Text('first')), + DropdownMenuItem<String>(value: 'second', child: Text('second')), + ], + onChanged: (_) {}, + ), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('first').hitTestable()); + await tester.pumpAndSettle(); + + // Tap on the barrier. + await tester.tapAt(const Offset(400, 400)); + await tester.pumpAndSettle(); + + // The dropdown should be closed, i.e., there should be no widget with 'second' text. + expect(find.text('second'), findsNothing); + }); + + testWidgets('Dropdown does not close when barrier dismissible set to false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton<String>( + value: 'first', + barrierDismissible: false, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(enabled: false, child: Text('disabled')), + DropdownMenuItem<String>(value: 'first', child: Text('first')), + DropdownMenuItem<String>(value: 'second', child: Text('second')), + ], + onChanged: (_) {}, + ), + ), + ), + ); + + // Open dropdown. + await tester.tap(find.text('first').hitTestable()); + await tester.pumpAndSettle(); + + // Tap on the barrier. + await tester.tapAt(const Offset(400, 400)); + await tester.pumpAndSettle(); + + // The dropdown should still be open, i.e., there should be one widget with 'second' text. + expect(find.text('second'), findsOneWidget); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/70294. + testWidgets( + 'The previous selected item should be highlighted when reopening dropdown on mobile', + (WidgetTester tester) async { + final Color selectedColor = Colors.black.withValues(alpha: 0.12); + var currentValue = 'one'; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(focusColor: selectedColor), + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownButton<String>( + value: currentValue, + items: menuItems + .map( + (String item) => DropdownMenuItem<String>(value: item, child: Text(item)), + ) + .toList(), + onChanged: (String? newValue) { + setState(() { + currentValue = newValue!; + }); + }, + ); + }, + ), + ), + ), + ), + ); + + // Make sure the current value of dropdown is the first one of items list menuItems. + expect(find.text('one'), findsOne); + + // Tap to open the dropdown. + await tester.tap(find.text('one')); + await tester.pumpAndSettle(); + + // Select the second item from the dropdown list. + await tester.tap(find.text('two')); + await tester.pumpAndSettle(); + + // Make sure the current item of dropdown is the second item of items list menuItems. + expect(find.text('two'), findsOneWidget); + + // Tap to reopen the dropdown. + await tester.tap(find.text('two')); + await tester.pumpAndSettle(); + + // Make sure the current selected item is highlighted with selectedColor. + final Ink selectedItemInk = tester.widget<Ink>( + find.ancestor(of: find.text('two'), matching: find.byType(Ink)).first, + ); + final decoration = selectedItemInk.decoration! as BoxDecoration; + expect(decoration.color, selectedColor); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets( + 'DropdownButtonFormField deprecated "value" parameter can still be used to set the initial value', + (WidgetTester tester) async { + final fieldKey = GlobalKey<FormFieldState<String>>(); + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Material( + child: DropdownButtonFormField<String>( + key: fieldKey, + value: 'one', + hint: const Text('Select Value'), + items: menuItems.map((String val) { + return DropdownMenuItem<String>(value: val, child: Text(val)); + }).toList(), + onChanged: (_) {}, + ), + ), + ); + }, + ), + ); + expect(fieldKey.currentState!.value, 'one'); + }, + ); + + testWidgets('DropdownButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: DropdownButton<String>( + value: 'a', + onChanged: (_) {}, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: 'a', child: Text('a')), + ], + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DropdownButton<String>)), Size.zero); + }); + + testWidgets('DropdownButtonFormField does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: DropdownButtonFormField<String>( + onChanged: (_) {}, + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: 'a', child: Text('a')), + ], + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DropdownButtonFormField<String>)), Size.zero); + }); + + testWidgets('DropdownMenuItem does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: DropdownMenuItem<String>(value: 'a', child: Text('a')), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DropdownMenuItem<String>)), Size.zero); + }); + + testWidgets('DropdownButtonFormField can inherit from local InputDecorationThemeData', ( + WidgetTester tester, + ) async { + const labelText = 'Label'; + const Color labelColor = Colors.green; + const decoration = InputDecoration(labelText: labelText); + const decorationTheme = InputDecorationThemeData(labelStyle: TextStyle(color: labelColor)); + + await tester.pumpWidget( + buildFrame( + isFormField: true, + decoration: decoration, + onChanged: (_) {}, + localInputDecorationTheme: decorationTheme, + ), + ); + + final TextStyle labelStyle = DefaultTextStyle.of( + tester.firstElement(find.text(labelText)), + ).style; + expect(labelStyle.color, labelColor); + }); + + testWidgets('DropdownButton selectedItemBuilder length must match items length', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/92773 + final List<DropdownMenuItem<String>> items = <String>['a', 'b'] + .map<DropdownMenuItem<String>>( + (String value) => DropdownMenuItem<String>(value: value, child: Text(value)), + ) + .toList(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: DropdownButtonFormField<String>( + onChanged: (_) {}, + items: items, + selectedItemBuilder: (BuildContext context) { + return <Widget>[const Text('a')]; + }, + ), + ), + ), + ), + ), + ); + + expect( + (tester.takeException() as AssertionError).message, + 'The selectedItemBuilder must return a list of widgets with the same length as the items list.\n' + 'Currently, selectedItemBuilder returns a list of length 1, but items has length 2.', + ); + }); + + testWidgets( + 'DropdownButtonFormField asserts when both errorBuilder and decoration.errorText are provided', + (WidgetTester tester) async { + expect( + () => DropdownButtonFormField<String>( + items: const <DropdownMenuItem<String>>[], + onChanged: (String? value) {}, + decoration: const InputDecoration(errorText: 'Decoration error'), + errorBuilder: (BuildContext context, String errorText) { + return Text(errorText); + }, + ), + throwsAssertionError, + ); + }, + ); +} diff --git a/packages/material_ui/test/material/editable_text_utils.dart b/packages/material_ui/test/material/editable_text_utils.dart new file mode 100644 index 000000000000..bbafd7298d76 --- /dev/null +++ b/packages/material_ui/test/material/editable_text_utils.dart @@ -0,0 +1,147 @@ +// 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/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// On web, the context menu (aka toolbar) is provided by the browser. +const bool isContextMenuProvidedByPlatform = isBrowser; + +/// Returns the [RenderEditable] at the given [index], or the first if not +/// given. +RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) { + final RenderObject root = tester.renderObject(find.byType(EditableText).at(index)); + expect(root, isNotNull); + + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable; +} + +/// Converts a list of local [TextSelectionPoint]s to global coordinates. +List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { + return points.map<TextSelectionPoint>((TextSelectionPoint point) { + return TextSelectionPoint(box.localToGlobal(point.point), point.direction); + }).toList(); +} + +/// Returns the global position of the character at the given [offset] in the +/// [EditableText] found at the given [index]. +Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) { + final RenderEditable renderEditable = findRenderEditable(tester, index: index); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(TextSelection.collapsed(offset: offset)), + renderEditable, + ); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0); +} + +/// Mimic key press events by sending key down and key up events via the [tester]. +Future<void> sendKeys( + WidgetTester tester, + List<LogicalKeyboardKey> keys, { + bool shift = false, + bool wordModifier = false, + bool lineModifier = false, + bool shortcutModifier = false, + required TargetPlatform targetPlatform, +}) async { + final targetPlatformString = targetPlatform.toString(); + final String platform = targetPlatformString + .substring(targetPlatformString.indexOf('.') + 1) + .toLowerCase(); + if (shift) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft, platform: platform); + } + if (shortcutModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (wordModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.altLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (lineModifier) { + await tester.sendKeyDownEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.altLeft, + platform: platform, + ); + } + for (final key in keys) { + await tester.sendKeyEvent(key, platform: platform); + await tester.pump(); + } + if (lineModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.altLeft, + platform: platform, + ); + } + if (wordModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.altLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (shortcutModifier) { + await tester.sendKeyUpEvent( + platform == 'macos' || platform == 'ios' + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.controlLeft, + platform: platform, + ); + } + if (shift) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft, platform: platform); + } + if (shift || wordModifier || lineModifier) { + await tester.pump(); + } +} + +/// A [TextEditingController] that builds a [WidgetSpan] with 100 height for +/// testing overflow behavior. +class OverflowWidgetTextEditingController extends TextEditingController { + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + return TextSpan( + style: style, + children: <InlineSpan>[ + const TextSpan(text: 'Hi'), + WidgetSpan(child: Container(color: const Color(0xffff0000), height: 100.0)), + ], + ); + } +} diff --git a/packages/material_ui/test/material/elevated_button_test.dart b/packages/material_ui/test/material/elevated_button_test.dart new file mode 100644 index 000000000000..59b8a620d65c --- /dev/null +++ b/packages/material_ui/test/material/elevated_button_test.dart @@ -0,0 +1,2697 @@ +// 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 '../widgets/semantics_tester.dart'; + +void main() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + Color textColor(WidgetTester tester, String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style!.color!; + } + + testWidgets('ElevatedButton, ElevatedButton.icon defaults', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + final bool material3 = theme.useMaterial3; + + // Enabled ElevatedButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(Material), + ); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, material3 ? colorScheme.onPrimary : colorScheme.primary); + expect(material.elevation, material3 ? 1 : 2); + expect(material.shadowColor, const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.textStyle!.color, material3 ? colorScheme.primary : 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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(ElevatedButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. + if (!material3) { + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..circle(color: colorScheme.onPrimary.withOpacity(0.24))); + } + + // Only elevation changes when enabled and pressed. + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, material3 ? colorScheme.onPrimary : colorScheme.primary); + expect(material.elevation, material3 ? 1 : 8); + expect(material.shadowColor, const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.textStyle!.color, material3 ? colorScheme.primary : colorScheme.onPrimary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Enabled ElevatedButton.icon + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: ElevatedButton.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<Material>(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, material3 ? colorScheme.onPrimary : colorScheme.primary); + expect(material.elevation, material3 ? 1 : 2); + expect(material.shadowColor, const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.textStyle!.color, material3 ? colorScheme.primary : colorScheme.onPrimary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled ElevatedButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center(child: ElevatedButton(onPressed: null, child: Text('button'))), + ), + ); + + // Finish the elevation animation, final background color change. + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + 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( + 'ElevatedButton.defaultStyle produces a ButtonStyle with appropriate non-null values', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = ElevatedButton(onPressed: () {}, child: const Text('button')); + BuildContext? capturedContext; + // Enabled ElevatedButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.side, isNull, reason: 'side style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }, + ); + + testWidgets( + 'ElevatedButton.defaultStyle with an icon produces a ButtonStyle with appropriate non-null values', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = ElevatedButton.icon( + onPressed: () {}, + icon: const SizedBox(), + label: const Text('button'), + ); + BuildContext? capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.side, isNull, reason: 'side style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }, + ); + + testWidgets('ElevatedButton.icon produces the correct widgets if icon is null', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: ElevatedButton.icon( + key: iconButtonKey, + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.text('label'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: ElevatedButton.icon( + key: iconButtonKey, + onPressed: () {}, + // No icon specified. + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsNothing); + expect(find.text('label'), findsOneWidget); + }); + + testWidgets( + 'Default ElevatedButton meets a11y contrast guidelines', + (WidgetTester tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('ElevatedButton'), + ), + ), + ), + ), + ); + + // 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(ElevatedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + focusNode.dispose(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets('ElevatedButton default overlayColor and elevation resolve pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('ElevatedButton'), + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + double elevation() { + return tester + .widget<PhysicalShape>( + find.descendant(of: find.byType(ElevatedButton), matching: find.byType(PhysicalShape)), + ) + .elevation; + } + + // Hovered. + final Offset center = tester.getCenter(find.byType(ElevatedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(elevation(), 3.0); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect(elevation(), 1.0); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(elevation(), 1.0); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.1))); + + focusNode.dispose(); + }); + + testWidgets('ElevatedButton uses stateful color for text color in different states', ( + WidgetTester tester, + ) async { + const buttonText = 'ElevatedButton'; + final focusNode = FocusNode(); + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButtonTheme( + data: ElevatedButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text(buttonText), + ); + }, + ), + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(textColor(tester, buttonText), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ElevatedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), 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(tester, buttonText), pressedColor); + + focusNode.dispose(); + }); + + testWidgets('ElevatedButton uses stateful color for icon color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButtonTheme( + data: ElevatedButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + iconColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return ElevatedButton.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('ElevatedButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(iconStyle(tester, Icons.add).color, equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, pressedColor); + + focusNode.dispose(); + }); + + testWidgets( + 'ElevatedButton onPressed and onLongPress callbacks are correctly called when non-null', + (WidgetTester tester) async { + bool wasPressed; + Finder elevatedButton; + + Widget buildFrame({VoidCallback? onPressed, VoidCallback? onLongPress}) { + return Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + onPressed: onPressed, + onLongPress: onLongPress, + child: const Text('button'), + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onPressed: () { + wasPressed = true; + }, + ), + ); + elevatedButton = find.byType(ElevatedButton); + expect(tester.widget<ElevatedButton>(elevatedButton).enabled, true); + await tester.tap(elevatedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onLongPress: () { + wasPressed = true; + }, + ), + ); + elevatedButton = find.byType(ElevatedButton); + expect(tester.widget<ElevatedButton>(elevatedButton).enabled, true); + await tester.longPress(elevatedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget(buildFrame()); + elevatedButton = find.byType(ElevatedButton); + expect(tester.widget<ElevatedButton>(elevatedButton).enabled, false); + }, + ); + + testWidgets('ElevatedButton onPressed and onLongPress callbacks are distinctly recognized', ( + WidgetTester tester, + ) async { + var didPressButton = false; + var didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder elevatedButton = find.byType(ElevatedButton); + expect(tester.widget<ElevatedButton>(elevatedButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(elevatedButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(elevatedButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets("ElevatedButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'ElevatedButton Focus'); + final GlobalKey childKey = GlobalKey(); + var hovering = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100, + child: ElevatedButton( + 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.square( + dimension: 100, + child: ElevatedButton( + focusNode: focusNode, + onHover: (bool value) { + hovering = value; + }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + + focusNode.dispose(); + }); + + testWidgets('disabled and hovered ElevatedButton responds to mouse-exit', ( + WidgetTester tester, + ) async { + var onHoverCount = 0; + late bool hover; + + Widget buildFrame({required bool enabled}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: ElevatedButton( + onPressed: enabled ? () {} : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('ElevatedButton'), + ), + ), + ), + ); + } + + 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(ElevatedButton))); + 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 ElevatedButton 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(ElevatedButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the ElevatedButton 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 ElevatedButton 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(ElevatedButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the ElevatedButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set ElevatedButton focus and Can set unFocus.', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'ElevatedButton Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + 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); + + node.dispose(); + }); + + testWidgets('When ElevatedButton disable, Can not set ElevatedButton focus.', ( + WidgetTester tester, + ) async { + final node = FocusNode(debugLabel: 'ElevatedButton Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + + node.dispose(); + }); + + testWidgets('Does ElevatedButton work with hover', (WidgetTester tester) async { + const hoverColor = Color(0xff001122); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.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(ElevatedButton))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..rect(color: hoverColor)); + }); + + testWidgets('Does ElevatedButton work with focus', (WidgetTester tester) async { + const focusColor = Color(0xff001122); + + final focusNode = FocusNode(debugLabel: 'ElevatedButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.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)); + + focusNode.dispose(); + }); + + testWidgets('Does ElevatedButton work with autofocus', (WidgetTester tester) async { + const focusColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.focused) ? focusColor : null; + } + + final focusNode = FocusNode(debugLabel: 'ElevatedButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + autofocus: true, + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(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)); + + focusNode.dispose(); + }); + + testWidgets('Does ElevatedButton contribute semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ElevatedButton( + 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>(Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + 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>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('ElevatedButton size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + const 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>(Size(88, 36)), + ); + + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(useMaterial3: false, materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ElevatedButton( + 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('ElevatedButton has no clip by default', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + onPressed: () { + /* to make sure the button is enabled */ + }, + child: const Text('button'), + ), + ), + ); + + expect(tester.renderObject(find.byType(ElevatedButton)), paintsExactlyCountTimes(#clipPath, 0)); + }); + + testWidgets('ElevatedButton responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + const childKey = Key('test child'); + + Future<void> buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: ElevatedButton( + 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>(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('ElevatedButton.icon responds to applied padding', (WidgetTester tester) async { + const buttonKey = Key('test'); + const 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: ElevatedButton.icon( + key: buttonKey, + style: const ButtonStyle( + padding: MaterialStatePropertyAll<EdgeInsets>(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 tallerWidget = iconRect.height > labelRect.height ? iconRect : labelRect; + expect(paddingRect.top, closeTo(tallerWidget.top - 6.5, .01)); + expect(paddingRect.bottom, closeTo(tallerWidget.bottom + 13.5, .01)); + }); + + group('Default ElevatedButton padding for textScaleFactor, textDirection', () { + const buttonKey = ValueKey<String>('button'); + const labelKey = ValueKey<String>('label'); + const iconKey = ValueKey<String>('icon'); + + const textScaleFactorOptions = <double>[0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const textDirectionOptions = <TextDirection>[TextDirection.ltr, TextDirection.rtl]; + const iconOptions = <Widget?>[null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final paddingWithoutIconStart = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final paddingWithoutIconEnd = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final paddingWithIconStart = <double, double>{ + 0.5: 12, + 1: 12, + 1.25: 11, + 1.5: 10, + 2: 8, + 2.5: 8, + 3: 8, + 4: 8, + }; + final paddingWithIconEnd = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final paddingWithIconGap = <double, double>{ + 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 textScaleFactor in textScaleFactorOptions) { + for (final textDirection in textDirectionOptions) { + for (final icon in iconOptions) { + final String testName = <String>[ + 'ElevatedButton, 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( + useMaterial3: false, + colorScheme: const ColorScheme.light(), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom(minimumSize: const Size(64, 36)), + ), + ), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? ElevatedButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : ElevatedButton.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 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<RenderBox>(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox? iconRenderBox = icon == null + ? null + : tester.renderObject<RenderBox>(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<RenderBox>( + 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<RenderBox>( + 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 theme fontSize changes padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + textTheme: const TextTheme(labelLarge: TextStyle(fontSize: 28.0)), + ), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('text')), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(ElevatedButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 12)); + }); + + testWidgets('Override ElevatedButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 2, + maxScaleFactor: 2, + child: Scaffold( + body: Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('ElevatedButton'), + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(ElevatedButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); + + testWidgets('M3 ElevatedButton has correct padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: ElevatedButton(key: key, onPressed: () {}, child: const Text('ElevatedButton')), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 24)); + }); + + testWidgets('M3 ElevatedButton.icon has correct padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: ElevatedButton.icon( + key: key, + icon: const Icon(Icons.favorite), + onPressed: () {}, + label: const Text('ElevatedButton'), + ), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsetsDirectional.fromSTEB(16.0, 0.0, 24.0, 0.0)); + }); + + testWidgets('Elevated buttons animate elevation before color on disable', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/387 + + const colorScheme = ColorScheme.light(); + final Color backgroundColor = colorScheme.primary; + final Color disabledBackgroundColor = colorScheme.onSurface.withOpacity(0.12); + + Widget buildFrame({required bool enabled}) { + return MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme, useMaterial3: false), + home: Center( + child: ElevatedButton(onPressed: enabled ? () {} : null, child: const Text('button')), + ), + ); + } + + PhysicalShape physicalShape() { + return tester.widget<PhysicalShape>( + find.descendant(of: find.byType(ElevatedButton), matching: find.byType(PhysicalShape)), + ); + } + + // Default elevation is 2, background color is primary. + await tester.pumpWidget(buildFrame(enabled: true)); + expect(physicalShape().elevation, 2); + expect(physicalShape().color, backgroundColor); + + // Disabled elevation animates to 0 over 200ms, THEN the background + // color changes to onSurface.withOpacity(0.12) + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pump(const Duration(milliseconds: 50)); + expect(physicalShape().elevation, lessThan(2)); + expect(physicalShape().color, backgroundColor); + await tester.pump(const Duration(milliseconds: 150)); + expect(physicalShape().elevation, 0); + expect(physicalShape().color, backgroundColor); + await tester.pumpAndSettle(); + expect(physicalShape().elevation, 0); + expect(physicalShape().color, disabledBackgroundColor); + }); + + testWidgets('By default, ElevatedButton shape outline is defined by shape.side', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/69544 + + const borderColor = Color(0xff4caf50); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: const ColorScheme.light(), + textTheme: Typography.englishLike2014, + useMaterial3: false, + ), + home: Center( + child: ElevatedButton( + style: ElevatedButton.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(ElevatedButton), + 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 ElevatedButtons', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + style: ElevatedButton.styleFrom(fixedSize: const Size(100, 100)), + onPressed: () {}, + child: const Text('100x100'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(fixedSize: const Size.fromWidth(200)), + onPressed: () {}, + child: const Text('200xh'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(fixedSize: const Size.fromHeight(200)), + onPressed: () {}, + child: const Text('wx200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(ElevatedButton, '100x100')), const Size(100, 100)); + expect(tester.getSize(find.widgetWithText(ElevatedButton, '200xh')).width, 200); + expect(tester.getSize(find.widgetWithText(ElevatedButton, 'wx200')).height, 200); + }); + + testWidgets('ElevatedButton with NoSplash splashFactory paints nothing', ( + WidgetTester tester, + ) async { + Widget buildFrame({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + style: ElevatedButton.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( + 'ElevatedButton uses InkSparkle only for Android non-web when useMaterial3 is true', + (WidgetTester tester) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(ElevatedButton), 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('ElevatedButton uses InkRipple when useMaterial3 is false', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(ElevatedButton), matching: find.byType(InkWell)), + ); + expect(buttonInkWell.splashFactory, equals(InkRipple.splashFactory)); + }, variant: TargetPlatformVariant.all()); + + testWidgets('ElevatedButton.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: ElevatedButton.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('ElevatedButton.icon icon,label layout', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + final Key iconKey = UniqueKey(); + final Key labelKey = UniqueKey(); + final ButtonStyle style = ElevatedButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.standard, // dx=0, dy=0 + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: ElevatedButton.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('ElevatedButton 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: <Widget>[ + ElevatedButton( + key: key0, + style: ElevatedButton.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'), + ), + ElevatedButton.icon( + key: key1, + style: ElevatedButton.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 ElevatedButton, same as minimumSize == maximumSize', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + style: ElevatedButton.styleFrom(fixedSize: const Size(200, 200)), + onPressed: () {}, + child: const Text('200x200'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(200, 200), + maximumSize: const Size(200, 200), + ), + onPressed: () {}, + child: const Text('200,200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(ElevatedButton, '200x200')), const Size(200, 200)); + expect(tester.getSize(find.widgetWithText(ElevatedButton, '200,200')), const Size(200, 200)); + }); + + testWidgets('ElevatedButton changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ElevatedButton( + style: ElevatedButton.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: ElevatedButton( + style: ElevatedButton.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: ElevatedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ElevatedButton(onPressed: null, child: Text('button')), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('ElevatedButton in SelectionArea changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/104595. + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.grab, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Text))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('Ink Response shape matches Material shape', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/91844 + + Widget buildFrame({BorderSide? side}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + side: side, + shape: const RoundedRectangleBorder( + side: BorderSide(color: Color(0xff0000ff), width: 0), + ), + ), + onPressed: () {}, + child: const Text('ElevatedButton'), + ), + ), + ), + ); + } + + const borderSide = BorderSide(width: 10, color: Color(0xff00ff00)); + await tester.pumpWidget(buildFrame(side: borderSide)); + expect( + tester.widget<InkWell>(find.byType(InkWell)).customBorder, + const RoundedRectangleBorder(side: borderSide), + ); + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect( + tester.widget<InkWell>(find.byType(InkWell)).customBorder, + const RoundedRectangleBorder(side: BorderSide(color: Color(0xff0000ff), width: 0.0)), + ); + }); + + testWidgets('ElevatedButton.styleFrom can be used to set foreground and background colors', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.purple, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(ElevatedButton), matching: find.byType(Material)), + ); + expect(material.color, Colors.purple); + expect(material.textStyle!.color, Colors.white); + }); + + Future<void> testStatesController(Widget? icon, WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: icon == null + ? ElevatedButton( + statesController: controller, + onPressed: () {}, + child: const Text('button'), + ) + : ElevatedButton.icon( + statesController: controller, + onPressed: () {}, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + + expect(controller.value, <WidgetState>{}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(Text)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.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: icon == null + ? ElevatedButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ) + : ElevatedButton.icon( + statesController: controller, + onPressed: null, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.disabled}); + expect(count, 10); // removes pressed and adds disabled - two changes + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 11); + await gesture.removePointer(); + } + + testWidgets('ElevatedButton statesController', (WidgetTester tester) async { + await testStatesController(null, tester); + }); + + testWidgets('ElevatedButton.icon statesController', (WidgetTester tester) async { + await testStatesController(const Icon(Icons.add), tester); + }); + + testWidgets('Disabled ElevatedButton statesController', (WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ElevatedButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 1); + }); + + testWidgets('ElevatedButton backgroundBuilder and foregroundBuilder', ( + WidgetTester tester, + ) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: backgroundColor), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: foregroundColor), + child: child, + ); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget<DecoratedBox>(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget<Text>(find.descendant(of: finder, matching: find.byType(Text))); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + testWidgets( + 'ElevatedButton backgroundBuilder drops button child and foregroundBuilder return value', + (WidgetTester tester) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: backgroundColor)); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }, + ); + + testWidgets('ElevatedButton foregroundBuilder drops button child', (WidgetTester tester) async { + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets( + 'ElevatedButton foreground and background builders are applied to the correct states', + (WidgetTester tester) async { + var foregroundStates = <WidgetState>{}; + var backgroundStates = <WidgetState>{}; + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + style: ButtonStyle( + backgroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const focusedStates = <WidgetState>{WidgetState.focused}; + const focusedHoveredStates = <WidgetState>{WidgetState.focused, WidgetState.hovered}; + const focusedHoveredPressedStates = <WidgetState>{ + WidgetState.focused, + WidgetState.hovered, + WidgetState.pressed, + }; + + bool sameStates(Set<WidgetState> expectedValue, Set<WidgetState> actualValue) { + return expectedValue.difference(actualValue).isEmpty && + actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(ElevatedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + // 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(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }, + ); + + testWidgets('Default ElevatedButton icon alignment', (WidgetTester tester) async { + Widget buildWidget({required TextDirection textDirection}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + // Test default iconAlignment when textDirection is ltr. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.ltr)); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test default iconAlignment when textDirection is rtl. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.rtl)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + }); + + testWidgets('ElevatedButton icon alignment can be customized', (WidgetTester tester) async { + Widget buildWidget({ + required TextDirection textDirection, + required IconAlignment iconAlignment, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + iconAlignment: iconAlignment, + ), + ), + ), + ); + } + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.start), + ); + + Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.end), + ); + + Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 24.0, + ); // 24.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.start), + ); + + buttonTopRight = tester.getTopRight(find.byType(Material).last); + iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.end), + ); + + buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 24.0); // 24.0 - padding between icon and button edge. + }); + + testWidgets('ElevatedButton icon alignment respects ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + home: Center( + child: ElevatedButton.icon( + style: ButtonStyle(iconAlignment: iconAlignment), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('ElevatedButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + iconAlignment: IconAlignment.end, + disabledIconColor: disabledIconColor, + ), + onPressed: enabled ? () {} : null, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets('ElevatedButton icon uses provided foregroundColor over default icon color', ( + WidgetTester tester, + ) async { + const foregroundColor = Color(0xFFFF1234); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom(foregroundColor: foregroundColor), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ), + ); + expect(iconStyle(tester, Icons.add).color, foregroundColor); + }); + + testWidgets('ElevatedButton text and icon respect animation duration', ( + WidgetTester tester, + ) async { + const buttonText = 'Button'; + const IconData buttonIcon = Icons.add; + const hoveredColor = Color(0xFFFF0000); + const idleColor = Color(0xFF000000); + + Widget buildButton({Duration? animationDuration}) { + return MaterialApp( + home: Material( + child: Center( + child: ElevatedButton.icon( + style: ButtonStyle( + animationDuration: animationDuration, + iconColor: const WidgetStateProperty<Color>.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }), + foregroundColor: const WidgetStateProperty<Color>.fromMap( + <WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }, + ), + ), + onPressed: () {}, + icon: const Icon(buttonIcon), + label: const Text(buttonText), + ), + ), + ), + ); + } + + // Test default animation duration. + await tester.pumpWidget(buildButton()); + + expect(textColor(tester, buttonText), idleColor); + expect(iconStyle(tester, buttonIcon).color, idleColor); + + final Offset buttonCenter = tester.getCenter(find.text(buttonText)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + + await gesture.removePointer(); + + // Test custom animation duration. + await tester.pumpWidget(buildButton(animationDuration: const Duration(seconds: 2))); + await tester.pumpAndSettle(); + + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + }); + + testWidgets('ElevatedButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: ElevatedButton(onPressed: () {}, child: const Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ElevatedButton)), Size.zero); + }); + + testWidgets('When an ElevatedButton gains an icon, preserves the same SemanticsNode id', ( + WidgetTester tester, + ) async { + var toggled = false; + const key = Key('button'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Row( + children: <Widget>[ + ElevatedButton.icon( + key: key, + onPressed: () { + setState(() { + toggled = true; + }); + }, + icon: toggled ? const Icon(Icons.favorite) : null, + label: const Text('Button'), + ), + ], + ); + }, + ), + ), + ), + ); + + // Initially, no icons are present. + expect(find.byIcon(Icons.favorite), findsNothing); + + // Find the original ElevatedButton with no icon and get its SemanticsNode. + final Finder elevatedButton = find.bySemanticsLabel('Button'); + expect(elevatedButton, findsOneWidget); + + final SemanticsNode origSemanticsNode = tester.getSemantics(elevatedButton); + + // Tap the button. It should receive an icon now. + await tester.tap(elevatedButton); + await tester.pump(); + + // Now one icon should be present. + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // Check if the semantics has change. + final SemanticsNode semanticsNodeWithIcon = tester.getSemantics(elevatedButton); + + expect(semanticsNodeWithIcon, origSemanticsNode); + }); + + testWidgets('ElevatedButton.icon does not lose focus when icon is nullified', ( + WidgetTester tester, + ) async { + Widget buildButton({required Widget? icon}) { + return MaterialApp( + home: Center( + child: ElevatedButton.icon(onPressed: () {}, icon: icon, label: const Text('button')), + ), + ); + } + + // Build once with an icon. + await tester.pumpWidget(buildButton(icon: const Icon(Icons.abc))); + + FocusNode getButtonFocusNode() { + return Focus.of(tester.element(find.text('button'))); + } + + getButtonFocusNode().requestFocus(); + await tester.pumpAndSettle(); + expect(getButtonFocusNode().hasFocus, true); + + // Rebuild without icon. + await tester.pumpWidget(buildButton(icon: null)); + + // The button should still be focused. + expect(getButtonFocusNode().hasFocus, true); + }); +} diff --git a/packages/material_ui/test/material/elevated_button_theme_test.dart b/packages/material_ui/test/material/elevated_button_theme_test.dart new file mode 100644 index 000000000000..e7ddc9e0c91e --- /dev/null +++ b/packages/material_ui/test/material/elevated_button_theme_test.dart @@ -0,0 +1,481 @@ +// 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() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + test('ElevatedButtonThemeData lerp special cases', () { + expect(ElevatedButtonThemeData.lerp(null, null, 0), null); + const data = ElevatedButtonThemeData(); + expect(identical(ElevatedButtonThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Material3: Passing no ElevatedButtonTheme returns defaults', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, colorScheme.surface); + expect(material.elevation, 1); + expect(material.shadowColor, colorScheme.shadow); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + testWidgets('Material2: Passing no ElevatedButtonTheme returns defaults', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme, useMaterial3: false), + home: Scaffold( + body: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, colorScheme.primary); + expect(material.elevation, 2); + expect(material.shadowColor, const Color(0xff000000)); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + 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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, TextTheme, ElevatedButton style overrides]', () { + const foregroundColor = Color(0xff000001); + const backgroundColor = Color(0xff000002); + const disabledColor = Color(0xff000003); + const shadowColor = Color(0xff000004); + const double elevation = 1; + const textStyle = TextStyle(fontSize: 12.0); + const padding = EdgeInsets.all(3); + const minimumSize = Size(200, 200); + const 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 animationDuration = Duration(milliseconds: 25); + const enableFeedback = false; + const AlignmentGeometry alignment = Alignment.centerLeft; + + final ButtonStyle style = ElevatedButton.styleFrom( + foregroundColor: foregroundColor, + disabledForegroundColor: disabledColor, + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledColor, + 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 ElevatedButton(style: buttonStyle, onPressed: () {}, child: const Text('button')); + }, + ); + return MaterialApp( + theme: ThemeData.from( + useMaterial3: false, + colorScheme: const ColorScheme.light(), + ).copyWith(elevatedButtonTheme: ElevatedButtonThemeData(style: overallStyle)), + home: Scaffold( + body: Center( + // If the ElevatedButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.ElevatedButtonTheme. + child: themeStyle == null + ? child + : ElevatedButtonTheme( + data: ElevatedButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(InkWell), + ); + + const enabled = <WidgetState>{}; + const disabled = <WidgetState>{WidgetState.disabled}; + const hovered = <WidgetState>{WidgetState.hovered}; + const focused = <WidgetState>{WidgetState.focused}; + const pressed = <WidgetState>{WidgetState.pressed}; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget<Material>(findMaterial); + final InkWell inkWell = tester.widget<InkWell>(findInkWell); + expect(material.textStyle!.color, foregroundColor); + expect(material.textStyle!.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect( + WidgetStateProperty.resolveAs<MouseCursor>(inkWell.mouseCursor!, enabled), + enabledMouseCursor, + ); + expect( + WidgetStateProperty.resolveAs<MouseCursor>(inkWell.mouseCursor!, disabled), + disabledMouseCursor, + ); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.1)); + expect(inkWell.overlayColor!.resolve(pressed), foregroundColor.withOpacity(0.1)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(ElevatedButton)), const Size(200, 200)); + final Align align = tester.firstWidget<Align>( + 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('Material3 - ElevatedButton respects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const overriddenColor = Color(0xff000002); + + Widget buildFrame({Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor}) { + return MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme.copyWith(shadow: overallShadowColor)), + home: Scaffold( + body: Center( + child: ElevatedButtonTheme( + data: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom(shadowColor: themeShadowColor), + ), + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.black); //default + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('Material2 - ElevatedButton respects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const overriddenColor = Color(0xff000002); + + Widget buildFrame({Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor}) { + return MaterialApp( + theme: ThemeData.from( + useMaterial3: false, + colorScheme: colorScheme, + ).copyWith(shadowColor: overallShadowColor), + home: Scaffold( + body: Center( + child: ElevatedButtonTheme( + data: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom(shadowColor: themeShadowColor), + ), + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(ElevatedButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.black); //default + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('ElevatedButton.icon respects ElevatedButtonTheme ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + theme: ThemeData( + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle(iconAlignment: iconAlignment), + ), + ), + home: Scaffold( + body: Center( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + await tester.pumpAndSettle(); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets( + 'ElevatedButton icon uses provided ElevatedButtonTheme foregroundColor over default icon color', + (WidgetTester tester) async { + const foregroundColor = Color(0xFFFFA500); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom(foregroundColor: foregroundColor), + ), + ), + home: Material( + child: Center( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ), + ); + + expect(iconStyle(tester, Icons.add).color, foregroundColor); + }, + ); +} diff --git a/packages/material_ui/test/material/elevation_overlay_test.dart b/packages/material_ui/test/material/elevation_overlay_test.dart new file mode 100644 index 000000000000..c5d5affaf684 --- /dev/null +++ b/packages/material_ui/test/material/elevation_overlay_test.dart @@ -0,0 +1,121 @@ +// 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() { + test('applySurfaceTint with null surface tint returns given color', () { + final Color result = ElevationOverlay.applySurfaceTint(const Color(0xff888888), null, 42.0); + + expect(result, equals(const Color(0xFF888888))); + }); + + test('applySurfaceTint with exact elevation levels uses the right opacity overlay', () { + const baseColor = Color(0xff888888); + const surfaceTintColor = Color(0xff44CCFF); + + Color overlayWithOpacity(double opacity) { + return Color.alphaBlend(surfaceTintColor.withOpacity(opacity), baseColor); + } + + // Based on values from the spec: + // https://m3.material.io/styles/color/the-color-system/color-roles + + // Elevation level 0 (0.0) - should have opacity 0.0. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 0.0), + equals(overlayWithOpacity(0.0)), + ); + + // Elevation level 1 (1.0) - should have opacity 0.05. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 1.0), + equals(overlayWithOpacity(0.05)), + ); + + // Elevation level 2 (3.0) - should have opacity 0.08. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 3.0), + equals(overlayWithOpacity(0.08)), + ); + + // Elevation level 3 (6.0) - should have opacity 0.11`. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 6.0), + equals(overlayWithOpacity(0.11)), + ); + + // Elevation level 4 (8.0) - should have opacity 0.12. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 8.0), + equals(overlayWithOpacity(0.12)), + ); + + // Elevation level 5 (12.0) - should have opacity 0.14. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 12.0), + equals(overlayWithOpacity(0.14)), + ); + }); + + test('applySurfaceTint with elevation lower than level 0 should have no overlay', () { + const baseColor = Color(0xff888888); + const surfaceTintColor = Color(0xff44CCFF); + + Color overlayWithOpacity(double opacity) { + return Color.alphaBlend(surfaceTintColor.withOpacity(opacity), baseColor); + } + + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, -42.0), + equals(overlayWithOpacity(0.0)), + ); + }); + + test('applySurfaceTint with elevation higher than level 5 should have no level 5 overlay', () { + const baseColor = Color(0xff888888); + const surfaceTintColor = Color(0xff44CCFF); + + Color overlayWithOpacity(double opacity) { + return Color.alphaBlend(surfaceTintColor.withOpacity(opacity), baseColor); + } + + // Elevation level 5 (12.0) - should have opacity 0.14. + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 42.0), + equals(overlayWithOpacity(0.14)), + ); + }); + + test('applySurfaceTint with elevation between two levels should interpolate the opacity', () { + const baseColor = Color(0xff888888); + const surfaceTintColor = Color(0xff44CCFF); + + Color overlayWithOpacity(double opacity) { + return Color.alphaBlend(surfaceTintColor.withOpacity(opacity), baseColor); + } + + // Elevation between level 4 (8.0) and level 5 (12.0) should be interpolated + // between the opacities 0.12 and 0.14. + + // One third (0.3): (elevation 9.2) -> (opacity 0.126) + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 9.2), + equals(overlayWithOpacity(0.126)), + ); + + // Half way (0.5): (elevation 10.0) -> (opacity 0.13) + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 10.0), + equals(overlayWithOpacity(0.13)), + ); + + // Two thirds (0.6): (elevation 10.4) -> (opacity 0.132) + expect( + ElevationOverlay.applySurfaceTint(baseColor, surfaceTintColor, 10.4), + equals(overlayWithOpacity(0.132)), + ); + }); +} diff --git a/packages/material_ui/test/material/expand_icon_test.dart b/packages/material_ui/test/material/expand_icon_test.dart new file mode 100644 index 000000000000..1064f293ecb2 --- /dev/null +++ b/packages/material_ui/test/material/expand_icon_test.dart @@ -0,0 +1,398 @@ +// 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'; + +Widget wrap({required Widget child, ThemeData? theme}) { + return MaterialApp( + theme: theme, + home: Center(child: Material(child: child)), + ); +} + +void main() { + testWidgets('ExpandIcon test', (WidgetTester tester) async { + var expanded = false; + IconTheme iconTheme; + + // Light mode tests + await tester.pumpWidget( + wrap( + child: ExpandIcon( + onPressed: (bool isExpanded) { + expanded = !expanded; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(expanded, isFalse); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.black54)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + expect(expanded, isTrue); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.black54)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + expect(expanded, isFalse); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.black54)); + + // Dark mode tests + await tester.pumpWidget( + wrap( + child: ExpandIcon( + onPressed: (bool isExpanded) { + expanded = !expanded; + }, + ), + theme: ThemeData(brightness: Brightness.dark), + ), + ); + await tester.pumpAndSettle(); + + expect(expanded, isFalse); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.white60)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + expect(expanded, isTrue); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.white60)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + expect(expanded, isFalse); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.white60)); + }); + + testWidgets('Material2 - ExpandIcon disabled', (WidgetTester tester) async { + IconTheme iconTheme; + // Test light mode. + await tester.pumpWidget( + wrap(theme: ThemeData(useMaterial3: false), child: const ExpandIcon(onPressed: null)), + ); + await tester.pumpAndSettle(); + + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.black38)); + + // Test dark mode. + await tester.pumpWidget( + wrap( + child: const ExpandIcon(onPressed: null), + theme: ThemeData(useMaterial3: false, brightness: Brightness.dark), + ), + ); + await tester.pumpAndSettle(); + + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.white38)); + }); + + testWidgets('Material3 - ExpandIcon disabled', (WidgetTester tester) async { + var theme = ThemeData(); + IconTheme iconTheme; + // Test light mode. + await tester.pumpWidget(wrap(theme: theme, child: const ExpandIcon(onPressed: null))); + await tester.pumpAndSettle(); + + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(theme.colorScheme.onSurface.withOpacity(0.38))); + + theme = ThemeData(brightness: Brightness.dark); + // Test dark mode. + await tester.pumpWidget(wrap(theme: theme, child: const ExpandIcon(onPressed: null))); + await tester.pumpAndSettle(); + + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(theme.colorScheme.onSurface.withOpacity(0.38))); + }); + + testWidgets('ExpandIcon test isExpanded does not trigger callback', (WidgetTester tester) async { + var expanded = false; + + await tester.pumpWidget( + wrap( + child: ExpandIcon( + onPressed: (bool isExpanded) { + expanded = !expanded; + }, + ), + ), + ); + + await tester.pumpWidget( + wrap( + child: ExpandIcon( + isExpanded: true, + onPressed: (bool isExpanded) { + expanded = !expanded; + }, + ), + ), + ); + + expect(expanded, isFalse); + }); + + testWidgets('ExpandIcon is rotated initially if isExpanded is true on first build', ( + WidgetTester tester, + ) async { + var expanded = true; + + await tester.pumpWidget( + wrap( + child: ExpandIcon( + isExpanded: expanded, + onPressed: (bool isExpanded) { + expanded = !isExpanded; + }, + ), + ), + ); + final RotationTransition rotation = tester.firstWidget(find.byType(RotationTransition)); + expect(rotation.turns.value, 0.5); + }); + + testWidgets('ExpandIcon default size is 24', (WidgetTester tester) async { + final expandIcon = ExpandIcon(onPressed: (bool isExpanded) {}); + + await tester.pumpWidget(wrap(child: expandIcon)); + + final ExpandIcon icon = tester.firstWidget(find.byWidget(expandIcon)); + expect(icon.size, 24); + }); + + testWidgets('ExpandIcon has the correct given size', (WidgetTester tester) async { + var expandIcon = ExpandIcon(size: 36, onPressed: (bool isExpanded) {}); + + await tester.pumpWidget(wrap(child: expandIcon)); + + ExpandIcon icon = tester.firstWidget(find.byWidget(expandIcon)); + expect(icon.size, 36); + + expandIcon = ExpandIcon(size: 48, onPressed: (bool isExpanded) {}); + + await tester.pumpWidget(wrap(child: expandIcon)); + + icon = tester.firstWidget(find.byWidget(expandIcon)); + expect(icon.size, 48); + }); + + testWidgets('Material2 - ExpandIcon has correct semantic hints', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + await tester.pumpWidget( + wrap( + theme: ThemeData(useMaterial3: false), + child: ExpandIcon(isExpanded: true, onPressed: (bool _) {}), + ), + ); + + expect( + tester.getSemantics(find.byType(ExpandIcon)), + matchesSemantics( + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + isButton: true, + onTapHint: localizations.expandedIconTapHint, + ), + ); + + await tester.pumpWidget( + wrap( + theme: ThemeData(useMaterial3: false), + child: ExpandIcon(onPressed: (bool _) {}), + ), + ); + + expect( + tester.getSemantics(find.byType(ExpandIcon)), + matchesSemantics( + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + isButton: true, + onTapHint: localizations.collapsedIconTapHint, + ), + ); + handle.dispose(); + }); + + testWidgets('Material3 - ExpandIcon has correct semantic hints', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + + await tester.pumpWidget(wrap(child: ExpandIcon(isExpanded: true, onPressed: (bool _) {}))); + + expect( + tester.getSemantics(find.byType(ExpandIcon)), + matchesSemantics( + onTapHint: localizations.expandedIconTapHint, + children: <Matcher>[ + matchesSemantics( + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + isButton: true, + ), + ], + ), + ); + + await tester.pumpWidget(wrap(child: ExpandIcon(onPressed: (bool _) {}))); + + expect( + tester.getSemantics(find.byType(ExpandIcon)), + matchesSemantics( + onTapHint: localizations.collapsedIconTapHint, + children: <Matcher>[ + matchesSemantics( + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + isButton: true, + ), + ], + ), + ); + + handle.dispose(); + }); + + testWidgets('ExpandIcon uses custom icon color and expanded icon color', ( + WidgetTester tester, + ) async { + var expanded = false; + IconTheme iconTheme; + + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ExpandIcon( + isExpanded: expanded, + onPressed: (bool isExpanded) { + setState(() { + expanded = !isExpanded; + }); + }, + color: Colors.indigo, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.indigo)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.indigo)); + + expanded = false; + + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ExpandIcon( + isExpanded: expanded, + onPressed: (bool isExpanded) { + setState(() { + expanded = !isExpanded; + }); + }, + color: Colors.indigo, + expandedColor: Colors.teal, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.indigo)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.teal)); + + await tester.tap(find.byType(ExpandIcon)); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.indigo)); + }); + + testWidgets('ExpandIcon uses custom disabled icon color', (WidgetTester tester) async { + IconTheme iconTheme; + + await tester.pumpWidget( + wrap(child: const ExpandIcon(onPressed: null, disabledColor: Colors.cyan)), + ); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.cyan)); + + await tester.pumpWidget( + wrap( + child: const ExpandIcon(onPressed: null, color: Colors.indigo, disabledColor: Colors.cyan), + ), + ); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.cyan)); + + await tester.pumpWidget( + wrap(child: const ExpandIcon(isExpanded: true, onPressed: null, disabledColor: Colors.cyan)), + ); + await tester.pumpWidget( + wrap( + child: const ExpandIcon( + isExpanded: true, + onPressed: null, + expandedColor: Colors.teal, + disabledColor: Colors.cyan, + ), + ), + ); + await tester.pumpAndSettle(); + iconTheme = tester.firstWidget(find.byType(IconTheme).last); + expect(iconTheme.data.color, equals(Colors.cyan)); + }); + + testWidgets('Expand icon does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: ExpandIcon(onPressed: (bool value) {})), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ExpandIcon)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/expansion_panel_test.dart b/packages/material_ui/test/material/expansion_panel_test.dart new file mode 100644 index 000000000000..4472acc30f4d --- /dev/null +++ b/packages/material_ui/test/material/expansion_panel_test.dart @@ -0,0 +1,2090 @@ +// 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'; + +class SimpleExpansionPanelListTestWidget extends StatefulWidget { + const SimpleExpansionPanelListTestWidget({ + super.key, + this.firstPanelKey, + this.secondPanelKey, + this.canTapOnHeader = false, + this.expandedHeaderPadding, + this.dividerColor, + this.elevation = 2, + }); + + final Key? firstPanelKey; + final Key? secondPanelKey; + final bool canTapOnHeader; + final Color? dividerColor; + final double elevation; + + /// If null, the default [ExpansionPanelList]'s expanded header padding value is applied via [defaultExpandedHeaderPadding] + final EdgeInsets? expandedHeaderPadding; + + /// Mirrors the default expanded header padding as its source constants are private. + static EdgeInsets defaultExpandedHeaderPadding() { + return const ExpansionPanelList().expandedHeaderPadding; + } + + @override + State<SimpleExpansionPanelListTestWidget> createState() => + _SimpleExpansionPanelListTestWidgetState(); +} + +class _SimpleExpansionPanelListTestWidgetState extends State<SimpleExpansionPanelListTestWidget> { + List<bool> extendedState = <bool>[false, false]; + + @override + Widget build(BuildContext context) { + return ExpansionPanelList( + expandedHeaderPadding: + widget.expandedHeaderPadding ?? + SimpleExpansionPanelListTestWidget.defaultExpandedHeaderPadding(), + expansionCallback: (int index, bool isExpanded) { + setState(() { + extendedState[index] = !extendedState[index]; + }); + }, + dividerColor: widget.dividerColor, + elevation: widget.elevation, + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A', key: widget.firstPanelKey); + }, + body: const SizedBox(height: 100.0), + canTapOnHeader: widget.canTapOnHeader, + isExpanded: extendedState[0], + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C', key: widget.secondPanelKey); + }, + body: const SizedBox(height: 100.0), + canTapOnHeader: widget.canTapOnHeader, + isExpanded: extendedState[1], + ), + ], + ); + } +} + +class ExpansionPanelListSemanticsTest extends StatefulWidget { + const ExpansionPanelListSemanticsTest({super.key, required this.headerKey}); + + final Key headerKey; + + @override + ExpansionPanelListSemanticsTestState createState() => ExpansionPanelListSemanticsTestState(); +} + +class ExpansionPanelListSemanticsTestState extends State<ExpansionPanelListSemanticsTest> { + bool headerTapped = false; + @override + Widget build(BuildContext context) { + return ListView( + children: <Widget>[ + ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return MergeSemantics( + key: widget.headerKey, + child: GestureDetector( + onTap: () => headerTapped = true, + child: const Text.rich(TextSpan(text: 'head1')), + ), + ); + }, + body: const Placeholder(), + ), + ], + ), + ], + ); + } +} + +void main() { + testWidgets('ExpansionPanelList test', (WidgetTester tester) async { + late int capturedIndex; + late bool capturedIsExpanded; + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + capturedIndex = index; + capturedIsExpanded = isExpanded; + }, + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + ), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + RenderBox box = tester.renderObject(find.byType(ExpansionPanelList)); + final double oldHeight = box.size.height; + expect(find.byType(ExpandIcon), findsOneWidget); + await tester.tap(find.byType(ExpandIcon)); + expect(capturedIndex, 0); + expect(capturedIsExpanded, isTrue); + box = tester.renderObject(find.byType(ExpansionPanelList)); + expect(box.size.height, equals(oldHeight)); + + // Now, expand the child panel. + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + capturedIndex = index; + capturedIsExpanded = isExpanded; + }, + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + isExpanded: true, // this is the addition + ), + ], + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + box = tester.renderObject(find.byType(ExpansionPanelList)); + expect(box.size.height - oldHeight, greaterThanOrEqualTo(100.0)); // 100 + some margin + }); + + testWidgets('Material2 - ExpansionPanelList does not merge header when canTapOnHeader is false', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final Key headerKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: ExpansionPanelListSemanticsTest(headerKey: headerKey), + ), + ); + + // Make sure custom gesture detector widget is clickable. + await tester.tap(find.text('head1')); + await tester.pump(); + + final ExpansionPanelListSemanticsTestState state = tester.state( + find.byType(ExpansionPanelListSemanticsTest), + ); + expect(state.headerTapped, true); + + // Check the expansion icon semantics does not merged with header widget. + final Finder expansionIcon = find.descendant( + of: find.ancestor(of: find.byKey(headerKey), matching: find.byType(Row)), + matching: find.byType(ExpandIcon), + ); + expect( + tester.getSemantics(expansionIcon), + matchesSemantics( + label: 'Expand', + isButton: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + + // Check custom header widget semantics is preserved. + final Finder headerWidget = find.descendant( + of: find.byKey(headerKey), + matching: find.byType(RichText), + ); + expect(tester.getSemantics(headerWidget), matchesSemantics(label: 'head1', hasTapAction: true)); + + handle.dispose(); + }); + + testWidgets('Material3 - ExpansionPanelList does not merge header when canTapOnHeader is false', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final Key headerKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp(home: ExpansionPanelListSemanticsTest(headerKey: headerKey)), + ); + + // Make sure custom gesture detector widget is clickable. + await tester.tap(find.text('head1')); + await tester.pump(); + + final ExpansionPanelListSemanticsTestState state = tester.state( + find.byType(ExpansionPanelListSemanticsTest), + ); + expect(state.headerTapped, true); + + // Check the expansion icon semantics does not merged with header widget. + final Finder expansionIcon = find.descendant( + of: find.ancestor(of: find.byKey(headerKey), matching: find.byType(Row)), + matching: find.byType(ExpandIcon), + ); + + expect( + tester.getSemantics(expansionIcon), + matchesSemantics( + label: 'Expand', + children: <Matcher>[ + matchesSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + ), + ], + ), + ); + + // Check custom header widget semantics is preserved. + final Finder headerWidget = find.descendant( + of: find.byKey(headerKey), + matching: find.byType(RichText), + ); + expect(tester.getSemantics(headerWidget), matchesSemantics(label: 'head1', hasTapAction: true)); + + handle.dispose(); + }); + + testWidgets('Multiple Panel List test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: ListView( + children: <ExpansionPanelList>[ + ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + isExpanded: true, + ), + ], + ), + ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + isExpanded: true, + ), + ], + ), + ], + ), + ), + ); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + }); + + testWidgets('Open/close animations', (WidgetTester tester) async { + const kSizeAnimationDuration = Duration(milliseconds: 1000); + // The MaterialGaps animate in using kThemeAnimationDuration (hardcoded), + // which should be less than our test size animation length. So we can assume that they + // appear immediately. Here we just verify that our assumption is true. + expect(kThemeAnimationDuration, lessThan(kSizeAnimationDuration ~/ 2)); + + Widget build(bool a, bool b, bool c) { + return MaterialApp( + home: Column( + children: <Widget>[ + ExpansionPanelList( + animationDuration: kSizeAnimationDuration, + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) => + const Placeholder(fallbackHeight: 12.0), + body: const SizedBox(height: 100.0, child: Placeholder(fallbackHeight: 12.0)), + isExpanded: a, + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) => + const Placeholder(fallbackHeight: 12.0), + body: const SizedBox(height: 100.0, child: Placeholder()), + isExpanded: b, + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) => + const Placeholder(fallbackHeight: 12.0), + body: const SizedBox(height: 100.0, child: Placeholder()), + isExpanded: c, + ), + ], + ), + ], + ), + ); + } + + await tester.pumpWidget(build(false, false, false)); + expect(tester.renderObjectList(find.byType(AnimatedSize)), hasLength(3)); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(1)), + const Rect.fromLTWH(0.0, 97.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(2)), + const Rect.fromLTWH(0.0, 146.0, 800.0, 0.0), + ); + + await tester.pump(const Duration(milliseconds: 200)); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(1)), + const Rect.fromLTWH(0.0, 97.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(2)), + const Rect.fromLTWH(0.0, 146.0, 800.0, 0.0), + ); + + await tester.pumpWidget(build(false, true, false)); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(1)), + const Rect.fromLTWH(0.0, 97.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(2)), + const Rect.fromLTWH(0.0, 146.0, 800.0, 0.0), + ); + + await tester.pump(kSizeAnimationDuration ~/ 2); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + final Rect rect1 = tester.getRect(find.byType(AnimatedSize).at(1)); + expect(rect1.left, 0.0); + expect( + rect1.top, + inExclusiveRange(113.0, 113.0 + 16.0 + 24.0), + ); // 16.0 material gap, plus 12.0 top and bottom margins added to the header + expect(rect1.width, 800.0); + expect(rect1.height, inExclusiveRange(0.0, 100.0)); + final Rect rect2 = tester.getRect(find.byType(AnimatedSize).at(2)); + expect( + rect2, + Rect.fromLTWH(0.0, rect1.bottom + 16.0 + 48.0, 800.0, 0.0), + ); // the 16.0 comes from the MaterialGap being introduced, the 48.0 is the header height. + + await tester.pumpWidget(build(false, false, false)); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect(tester.getRect(find.byType(AnimatedSize).at(1)), rect1); + expect(tester.getRect(find.byType(AnimatedSize).at(2)), rect2); + + await tester.pumpWidget(build(false, false, true)); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect(tester.getRect(find.byType(AnimatedSize).at(1)), rect1); + expect(tester.getRect(find.byType(AnimatedSize).at(2)), rect2); + + // a few no-op pumps to make sure there's nothing fishy going on + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect(tester.getRect(find.byType(AnimatedSize).at(1)), rect1); + expect(tester.getRect(find.byType(AnimatedSize).at(2)), rect2); + + await tester.pumpAndSettle(); + expect( + tester.getRect(find.byType(AnimatedSize).at(0)), + const Rect.fromLTWH(0.0, 48.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(1)), + const Rect.fromLTWH(0.0, 48.0 + 1.0 + 48.0, 800.0, 0.0), + ); + expect( + tester.getRect(find.byType(AnimatedSize).at(2)), + const Rect.fromLTWH(0.0, 48.0 + 1.0 + 48.0 + 16.0 + 16.0 + 48.0 + 16.0, 800.0, 100.0), + ); + }); + + testWidgets('Radio mode has max of one panel open at a time', (WidgetTester tester) async { + final List<ExpansionPanel> demoItemsRadio = <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + value: 2, + ), + ]; + + final expansionListRadio = ExpansionPanelList.radio(children: demoItemsRadio); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionListRadio))); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + RenderBox box = tester.renderObject(find.byType(ExpansionPanelList)); + double oldHeight = box.size.height; + + expect(find.byType(ExpandIcon), findsNWidgets(3)); + + await tester.tap(find.byType(ExpandIcon).at(0)); + + box = tester.renderObject(find.byType(ExpansionPanelList)); + expect(box.size.height, equals(oldHeight)); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + // Now the first panel is open + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + box = tester.renderObject(find.byType(ExpansionPanelList)); + expect(box.size.height - oldHeight, greaterThanOrEqualTo(100.0)); // 100 + some margin + + await tester.tap(find.byType(ExpandIcon).at(1)); + + box = tester.renderObject(find.byType(ExpansionPanelList)); + oldHeight = box.size.height; + + await tester.pump(const Duration(milliseconds: 200)); + + // Now the first panel is closed and the second should be opened + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + expect(box.size.height, greaterThanOrEqualTo(oldHeight)); + + demoItemsRadio.removeAt(0); + + await tester.pumpAndSettle(); + + // Now the first panel should be opened + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + final demoItems = <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + ), + ]; + + final expansionList = ExpansionPanelList(children: demoItems); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionList))); + + // We've reinitialized with a regular expansion panel so they should all be closed again + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + }); + + testWidgets('Radio mode calls expansionCallback once if other panels closed', ( + WidgetTester tester, + ) async { + final List<ExpansionPanel> demoItemsRadio = <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + value: 2, + ), + ]; + + final callbackHistory = <Map<String, dynamic>>[]; + final expansionListRadio = ExpansionPanelList.radio( + expansionCallback: (int index, bool isExpanded) { + callbackHistory.add(<String, dynamic>{'index': index, 'isExpanded': isExpanded}); + }, + children: demoItemsRadio, + ); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionListRadio))); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + // Open one panel + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pumpAndSettle(); + + // Callback is invoked once with appropriate arguments + expect(callbackHistory.length, equals(1)); + expect(callbackHistory.last['index'], equals(1)); + expect(callbackHistory.last['isExpanded'], equals(true)); + + // Close the same panel + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pumpAndSettle(); + + // Callback is invoked once with appropriate arguments + expect(callbackHistory.length, equals(2)); + expect(callbackHistory.last['index'], equals(1)); + expect(callbackHistory.last['isExpanded'], equals(false)); + }); + + testWidgets('Radio mode calls expansionCallback twice if other panel open prior', ( + WidgetTester tester, + ) async { + final List<ExpansionPanel> demoItemsRadio = <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + value: 2, + ), + ]; + + final callbackHistory = <Map<String, dynamic>>[]; + Map<String, dynamic> callbackResults; + + final expansionListRadio = ExpansionPanelList.radio( + expansionCallback: (int index, bool isExpanded) { + callbackHistory.add(<String, dynamic>{'index': index, 'isExpanded': isExpanded}); + }, + children: demoItemsRadio, + ); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionListRadio))); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + // Open one panel + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pumpAndSettle(); + + // Callback is invoked once with appropriate arguments + expect(callbackHistory.length, equals(1)); + callbackResults = callbackHistory[callbackHistory.length - 1]; + expect(callbackResults['index'], equals(1)); + expect(callbackResults['isExpanded'], equals(true)); + + // Close a different panel + await tester.tap(find.byType(ExpandIcon).at(2)); + await tester.pumpAndSettle(); + + // Callback is invoked the first time with correct arguments + expect(callbackHistory.length, equals(3)); + callbackResults = callbackHistory[callbackHistory.length - 2]; + expect(callbackResults['index'], equals(1)); + expect(callbackResults['isExpanded'], equals(false)); + + // Callback is invoked the second time with correct arguments + callbackResults = callbackHistory[callbackHistory.length - 1]; + expect(callbackResults['index'], equals(2)); + expect(callbackResults['isExpanded'], equals(true)); + }); + + testWidgets( + 'ExpansionPanelList.radio callback displays true or false based on the visibility of a list item', + (WidgetTester tester) async { + late int lastExpanded; + var topElementExpanded = false; + var bottomElementExpanded = false; + + final List<ExpansionPanel> demoItemsRadio = <ExpansionPanelRadio>[ + // topElement + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + // bottomElement + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ]; + + final expansionListRadio = ExpansionPanelList.radio( + children: demoItemsRadio, + expansionCallback: (int index, bool isExpanded) { + lastExpanded = index; + if (index == 0) { + topElementExpanded = isExpanded; + bottomElementExpanded = false; + } else { + topElementExpanded = false; + bottomElementExpanded = isExpanded; + } + }, + ); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionListRadio))); + + // Initializes with all panels closed. + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byType(ExpandIcon).at(0)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + // Now the first panel is open. + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + expect(lastExpanded, 0); + expect(topElementExpanded, true); + + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + // Open the other panel and ensure the first is now closed. + expect(lastExpanded, 1); + expect(bottomElementExpanded, true); + expect(topElementExpanded, false); + expect(find.text('D'), findsOneWidget); + expect(find.text('A'), findsOneWidget); + + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + // Close the item that was expanded should now be false. + expect(lastExpanded, 1); + expect(bottomElementExpanded, false); + + // All panels should be closed. + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + }, + ); + + testWidgets('didUpdateWidget accounts for toggling between ExpansionPanelList ' + 'and ExpansionPaneList.radio', (WidgetTester tester) async { + var isRadioList = false; + final panelExpansionState = <bool>[false, false, false]; + + ExpansionPanelList buildRadioExpansionPanelList() { + return ExpansionPanelList.radio( + initialOpenPanelValue: 2, + children: <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + value: 2, + ), + ], + ); + } + + ExpansionPanelList buildExpansionPanelList(StateSetter setState) { + return ExpansionPanelList( + expansionCallback: (int index, _) => setState(() { + panelExpansionState[index] = !panelExpansionState[index]; + }), + children: <ExpansionPanel>[ + ExpansionPanel( + isExpanded: panelExpansionState[0], + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + ), + ExpansionPanel( + isExpanded: panelExpansionState[1], + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + ), + ExpansionPanel( + isExpanded: panelExpansionState[2], + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + ), + ], + ); + } + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: isRadioList + ? buildRadioExpansionPanelList() + : buildExpansionPanelList(setState), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => setState(() { + isRadioList = !isRadioList; + }), + ), + ), + ); + }, + ), + ); + + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + await tester.tap(find.byType(ExpandIcon).at(0)); + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pumpAndSettle(); + + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + // ExpansionPanelList --> ExpansionPanelList.radio + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsNothing); + expect(find.text('F'), findsOneWidget); + + // ExpansionPanelList.radio --> ExpansionPanelList + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + }); + + testWidgets('No duplicate global keys at layout/build time', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/13780 + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + // Wrapping with LayoutBuilder or other widgets that augment + // layout/build order should not create duplicate keys + home: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SingleChildScrollView( + child: ExpansionPanelList.radio( + expansionCallback: (int index, bool isExpanded) { + if (!isExpanded) { + // setState invocation required to trigger + // _ExpansionPanelListState.didUpdateWidget, + // which causes duplicate keys to be + // generated in the regression + setState(() {}); + } + }, + children: <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'F' : 'E'); + }, + body: const SizedBox(height: 100.0), + value: 2, + ), + ], + ), + ); + }, + ), + ); + }, + ), + ); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + expect(find.text('E'), findsOneWidget); + expect(find.text('F'), findsNothing); + + // Open a panel + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pumpAndSettle(); + + final panelExpansionState = <bool>[false, false]; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + home: Scaffold( + // Wrapping with LayoutBuilder or other widgets that augment + // layout/build order should not create duplicate keys + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SingleChildScrollView( + child: ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + // setState invocation required to trigger + // _ExpansionPanelListState.didUpdateWidget, which + // causes duplicate keys to be generated in the + // regression + setState(() { + panelExpansionState[index] = !isExpanded; + }); + }, + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A'); + }, + body: const SizedBox(height: 100.0), + isExpanded: panelExpansionState[0], + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C'); + }, + body: const SizedBox(height: 100.0), + isExpanded: panelExpansionState[1], + ), + ], + ), + ); + }, + ), + ), + ); + }, + ), + ); + + // initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + // open a panel + await tester.tap(find.byType(ExpandIcon).at(1)); + await tester.pumpAndSettle(); + }); + + testWidgets('Material2 - Panel header has semantics, canTapOnHeader = false', ( + WidgetTester tester, + ) async { + const expandedKey = Key('expanded'); + const collapsedKey = Key('collapsed'); + const localizations = DefaultMaterialLocalizations(); + final SemanticsHandle handle = tester.ensureSemantics(); + final demoItems = <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('Expanded', key: expandedKey); + }, + body: const SizedBox(height: 100.0), + isExpanded: true, + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('Collapsed', key: collapsedKey); + }, + body: const SizedBox(height: 100.0), + ), + ]; + + final expansionList = ExpansionPanelList(children: demoItems); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: SingleChildScrollView(child: expansionList), + ), + ); + + // Check the semantics of [ExpandIcon] for expanded panel. + final Finder expandedIcon = find.descendant( + of: find.ancestor(of: find.byKey(expandedKey), matching: find.byType(Row)), + matching: find.byType(ExpandIcon), + ); + expect( + tester.getSemantics(expandedIcon), + matchesSemantics( + label: 'Collapse', + isButton: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + onTapHint: localizations.expandedIconTapHint, + ), + ); + + // Check the semantics of the header widget for expanded panel. + final Finder expandedHeader = find.byKey(expandedKey); + expect(tester.getSemantics(expandedHeader), matchesSemantics(label: 'Expanded')); + + // Check the semantics of [ExpandIcon] for collapsed panel. + final Finder collapsedIcon = find.descendant( + of: find.ancestor(of: find.byKey(collapsedKey), matching: find.byType(Row)), + matching: find.byType(ExpandIcon), + ); + expect( + tester.getSemantics(collapsedIcon), + matchesSemantics( + label: 'Expand', + isButton: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + onTapHint: localizations.collapsedIconTapHint, + ), + ); + + // Check the semantics of the header widget for expanded panel. + final Finder collapsedHeader = find.byKey(collapsedKey); + expect(tester.getSemantics(collapsedHeader), matchesSemantics(label: 'Collapsed')); + + handle.dispose(); + }); + + testWidgets('Material3 - Panel header has semantics, canTapOnHeader = false', ( + WidgetTester tester, + ) async { + const expandedKey = Key('expanded'); + const collapsedKey = Key('collapsed'); + const localizations = DefaultMaterialLocalizations(); + final SemanticsHandle handle = tester.ensureSemantics(); + final demoItems = <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('Expanded', key: expandedKey); + }, + body: const SizedBox(height: 100.0), + isExpanded: true, + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('Collapsed', key: collapsedKey); + }, + body: const SizedBox(height: 100.0), + ), + ]; + + final expansionList = ExpansionPanelList(children: demoItems); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionList))); + + // Check the semantics of [ExpandIcon] for expanded panel. + final Finder expandedIcon = find.descendant( + of: find.ancestor(of: find.byKey(expandedKey), matching: find.byType(Row)), + matching: find.byType(ExpandIcon), + ); + + expect( + tester.getSemantics(expandedIcon), + matchesSemantics( + label: 'Collapse', + onTapHint: localizations.expandedIconTapHint, + children: <Matcher>[ + matchesSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + ), + ], + ), + ); + + // Check the semantics of the header widget for expanded panel. + final Finder expandedHeader = find.byKey(expandedKey); + expect(tester.getSemantics(expandedHeader), matchesSemantics(label: 'Expanded')); + + // Check the semantics of [ExpandIcon] for collapsed panel. + final Finder collapsedIcon = find.descendant( + of: find.ancestor(of: find.byKey(collapsedKey), matching: find.byType(Row)), + matching: find.byType(ExpandIcon), + ); + expect( + tester.getSemantics(collapsedIcon), + matchesSemantics( + label: 'Expand', + onTapHint: localizations.collapsedIconTapHint, + children: <Matcher>[ + matchesSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + ), + ], + ), + ); + + // Check the semantics of the header widget for expanded panel. + final Finder collapsedHeader = find.byKey(collapsedKey); + expect(tester.getSemantics(collapsedHeader), matchesSemantics(label: 'Collapsed')); + + handle.dispose(); + }); + + testWidgets('Panel header has semantics, canTapOnHeader = true', (WidgetTester tester) async { + const expandedKey = Key('expanded'); + const collapsedKey = Key('collapsed'); + final SemanticsHandle handle = tester.ensureSemantics(); + final demoItems = <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('Expanded', key: expandedKey); + }, + canTapOnHeader: true, + body: const SizedBox(height: 100.0), + isExpanded: true, + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('Collapsed', key: collapsedKey); + }, + canTapOnHeader: true, + body: const SizedBox(height: 100.0), + ), + ]; + + final expansionList = ExpansionPanelList(children: demoItems); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionList))); + + expect( + tester.getSemantics(find.byKey(expandedKey)), + matchesSemantics( + label: 'Expanded', + isButton: true, + isEnabled: true, + isFocusable: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + + expect( + tester.getSemantics(find.byKey(collapsedKey)), + matchesSemantics( + label: 'Collapsed', + isButton: true, + isFocusable: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + + handle.dispose(); + }); + + testWidgets('Ensure canTapOnHeader is false by default', (WidgetTester tester) async { + final expansionPanel = ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) => const Text('Demo'), + body: const SizedBox(height: 100.0), + ); + + expect(expansionPanel.canTapOnHeader, isFalse); + }); + + testWidgets('Toggle ExpansionPanelRadio when tapping header and canTapOnHeader is true', ( + WidgetTester tester, + ) async { + const firstPanelKey = Key('firstPanelKey'); + const secondPanelKey = Key('secondPanelKey'); + + final List<ExpansionPanel> demoItemsRadio = <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A', key: firstPanelKey); + }, + body: const SizedBox(height: 100.0), + value: 0, + canTapOnHeader: true, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C', key: secondPanelKey); + }, + body: const SizedBox(height: 100.0), + value: 1, + canTapOnHeader: true, + ), + ]; + + final expansionListRadio = ExpansionPanelList.radio(children: demoItemsRadio); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionListRadio))); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // Now the first panel is open + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(secondPanelKey)); + await tester.pumpAndSettle(); + + // Now the second panel is open + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + }); + + testWidgets('Toggle ExpansionPanel when tapping header and canTapOnHeader is true', ( + WidgetTester tester, + ) async { + const firstPanelKey = Key('firstPanelKey'); + const secondPanelKey = Key('secondPanelKey'); + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget( + firstPanelKey: firstPanelKey, + secondPanelKey: secondPanelKey, + canTapOnHeader: true, + ), + ), + ), + ); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // The first panel is open + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // The first panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(secondPanelKey)); + await tester.pumpAndSettle(); + + // The second panel is open + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsNothing); + expect(find.text('D'), findsOneWidget); + + await tester.tap(find.byKey(secondPanelKey)); + await tester.pumpAndSettle(); + + // The second panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + }); + + testWidgets('Do not toggle ExpansionPanel when tapping header and canTapOnHeader is false', ( + WidgetTester tester, + ) async { + const firstPanelKey = Key('firstPanelKey'); + const secondPanelKey = Key('secondPanelKey'); + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget( + firstPanelKey: firstPanelKey, + secondPanelKey: secondPanelKey, + ), + ), + ), + ); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // The first panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(secondPanelKey)); + await tester.pumpAndSettle(); + + // The second panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + }); + + testWidgets('Do not toggle ExpansionPanelRadio when tapping header and canTapOnHeader is false', ( + WidgetTester tester, + ) async { + const firstPanelKey = Key('firstPanelKey'); + const secondPanelKey = Key('secondPanelKey'); + + final List<ExpansionPanel> demoItemsRadio = <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A', key: firstPanelKey); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C', key: secondPanelKey); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ]; + + final expansionListRadio = ExpansionPanelList.radio(children: demoItemsRadio); + + await tester.pumpWidget(MaterialApp(home: SingleChildScrollView(child: expansionListRadio))); + + // Initializes with all panels closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // The first panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + + await tester.tap(find.byKey(secondPanelKey)); + await tester.pumpAndSettle(); + + // The second panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsNothing); + }); + + testWidgets('Correct default header padding', (WidgetTester tester) async { + const firstPanelKey = Key('firstPanelKey'); + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget( + firstPanelKey: firstPanelKey, + canTapOnHeader: true, + ), + ), + ), + ); + + // The panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + + // No padding applied to closed header + RenderBox box = tester.renderObject( + find.ancestor(of: find.byKey(firstPanelKey), matching: find.byType(AnimatedContainer)).first, + ); + expect(box.size.height, equals(48.0)); // _kPanelHeaderCollapsedHeight + expect(box.size.width, equals(744.0)); + + // Now, expand the child panel. + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // The panel is expanded + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + + // Padding is added to expanded header + box = tester.renderObject( + find.ancestor(of: find.byKey(firstPanelKey), matching: find.byType(AnimatedContainer)).first, + ); + expect( + box.size.height, + equals(80.0), + ); // _kPanelHeaderCollapsedHeight + 24.0 (double default padding) + expect(box.size.width, equals(744.0)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/5848. + testWidgets('The AnimatedContainer and IconButton have the same height of 48px', ( + WidgetTester tester, + ) async { + const firstPanelKey = Key('firstPanelKey'); + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget( + firstPanelKey: firstPanelKey, + canTapOnHeader: true, + ), + ), + ), + ); + + // The panel is closed. + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + + // No padding applied to closed header. + final RenderBox boxOfContainer = tester.renderObject( + find.ancestor(of: find.byKey(firstPanelKey), matching: find.byType(AnimatedContainer)).first, + ); + final RenderBox boxOfIconButton = tester.renderObject(find.byType(IconButton).first); + expect(boxOfContainer.size.height, equals(boxOfIconButton.size.height)); + expect( + boxOfContainer.size.height, + equals(48.0), + ); // Header should have 48px height according to Material 2 Design spec. + }); + + testWidgets("The AnimatedContainer's height is at least kMinInteractiveDimension", ( + WidgetTester tester, + ) async { + const firstPanelKey = Key('firstPanelKey'); + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget( + firstPanelKey: firstPanelKey, + canTapOnHeader: true, + ), + ), + ), + ); + + // The panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + + // No padding applied to closed header + final RenderBox box = tester.renderObject( + find.ancestor(of: find.byKey(firstPanelKey), matching: find.byType(AnimatedContainer)).first, + ); + expect(box.size.height, greaterThanOrEqualTo(kMinInteractiveDimension)); + }); + + testWidgets('Correct custom header padding', (WidgetTester tester) async { + const firstPanelKey = Key('firstPanelKey'); + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget( + firstPanelKey: firstPanelKey, + canTapOnHeader: true, + expandedHeaderPadding: EdgeInsets.symmetric(vertical: 40.0), + ), + ), + ), + ); + + // The panel is closed + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + + // No padding applied to closed header + RenderBox box = tester.renderObject( + find.ancestor(of: find.byKey(firstPanelKey), matching: find.byType(AnimatedContainer)).first, + ); + expect(box.size.height, equals(48.0)); // _kPanelHeaderCollapsedHeight + expect(box.size.width, equals(744.0)); + + // Now, expand the child panel. + await tester.tap(find.byKey(firstPanelKey)); + await tester.pumpAndSettle(); + + // The panel is expanded + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + + // Padding is added to expanded header + box = tester.renderObject( + find.ancestor(of: find.byKey(firstPanelKey), matching: find.byType(AnimatedContainer)).first, + ); + expect(box.size.height, equals(128.0)); // _kPanelHeaderCollapsedHeight + 80.0 (double padding) + expect(box.size.width, equals(744.0)); + }); + + testWidgets('ExpansionPanelList respects dividerColor', (WidgetTester tester) async { + const Color dividerColor = Colors.red; + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget(dividerColor: dividerColor), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox).last); + final decoration = decoratedBox.decoration as BoxDecoration; + + // For the last DecoratedBox, we will have a Border.top with the provided dividerColor. + expect(decoration.border!.top.color, dividerColor); + }); + + testWidgets('ExpansionPanelList.radio respects DividerColor', (WidgetTester tester) async { + const Color dividerColor = Colors.red; + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList.radio( + dividerColor: dividerColor, + children: <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A', key: const Key('firstKey')); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C', key: const Key('secondKey')); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ], + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox).last); + final boxDecoration = decoratedBox.decoration as BoxDecoration; + + // For the last DecoratedBox, we will have a Border.top with the provided dividerColor. + expect(boxDecoration.border!.top.color, dividerColor); + }); + + testWidgets('ExpansionPanelList respects expandIconColor', (WidgetTester tester) async { + const Color expandIconColor = Colors.blue; + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + expandIconColor: expandIconColor, + children: <ExpansionPanel>[ + ExpansionPanel( + canTapOnHeader: true, + body: const SizedBox.shrink(), + headerBuilder: (BuildContext context, bool isExpanded) { + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ), + ); + + final ExpandIcon expandIcon = tester.widget(find.byType(ExpandIcon)); + + expect(expandIcon.color, expandIconColor); + }); + + testWidgets('ExpansionPanelList.radio respects expandIconColor', (WidgetTester tester) async { + const Color expandIconColor = Colors.blue; + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList.radio( + expandIconColor: expandIconColor, + children: <ExpansionPanelRadio>[ + ExpansionPanelRadio( + canTapOnHeader: true, + body: const SizedBox.shrink(), + headerBuilder: (BuildContext context, bool isExpanded) { + return const SizedBox.shrink(); + }, + value: true, + ), + ], + ), + ), + ), + ); + + final ExpandIcon expandIcon = tester.widget(find.byType(ExpandIcon)); + + expect(expandIcon.color, expandIconColor); + }); + + testWidgets('elevation is propagated properly to MergeableMaterial', (WidgetTester tester) async { + const double elevation = 8; + + // Test for ExpansionPanelList. + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: SimpleExpansionPanelListTestWidget(elevation: elevation), + ), + ), + ); + + expect(tester.widget<MergeableMaterial>(find.byType(MergeableMaterial)).elevation, elevation); + + // Test for ExpansionPanelList.radio. + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList.radio( + elevation: elevation, + children: <ExpansionPanelRadio>[ + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'B' : 'A', key: const Key('firstKey')); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + headerBuilder: (BuildContext context, bool isExpanded) { + return Text(isExpanded ? 'D' : 'C', key: const Key('secondKey')); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ], + ), + ), + ), + ); + + expect(tester.widget<MergeableMaterial>(find.byType(MergeableMaterial)).elevation, elevation); + }); + + testWidgets('Using a value non defined value throws assertion error', ( + WidgetTester tester, + ) async { + // It should throw an AssertionError since, 19 is not defined in kElevationToShadow. + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView(child: SimpleExpansionPanelListTestWidget(elevation: 19)), + ), + ); + + final dynamic exception = tester.takeException(); + expect(exception, isAssertionError); + expect( + (exception as AssertionError).toString(), + contains( + 'Invalid value for elevation. See the kElevationToShadow constant for' + ' possible elevation values.', + ), + ); + }); + + testWidgets('ExpansionPanel.panelColor test', (WidgetTester tester) async { + const Color firstPanelColor = Colors.red; + const Color secondPanelColor = Colors.brown; + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) {}, + children: <ExpansionPanel>[ + ExpansionPanel( + backgroundColor: firstPanelColor, + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('A'); + }, + body: const SizedBox(height: 100.0), + ), + ExpansionPanel( + backgroundColor: secondPanelColor, + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('B'); + }, + body: const SizedBox(height: 100.0), + ), + ], + ), + ), + ), + ); + + final MergeableMaterial mergeableMaterial = tester.widget(find.byType(MergeableMaterial)); + + expect((mergeableMaterial.children.first as MaterialSlice).color, firstPanelColor); + expect((mergeableMaterial.children.last as MaterialSlice).color, secondPanelColor); + }); + + testWidgets('ExpansionPanelRadio.backgroundColor test', (WidgetTester tester) async { + const Color firstPanelColor = Colors.red; + const Color secondPanelColor = Colors.brown; + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList.radio( + children: <ExpansionPanelRadio>[ + ExpansionPanelRadio( + backgroundColor: firstPanelColor, + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('A'); + }, + body: const SizedBox(height: 100.0), + value: 0, + ), + ExpansionPanelRadio( + backgroundColor: secondPanelColor, + headerBuilder: (BuildContext context, bool isExpanded) { + return const Text('B'); + }, + body: const SizedBox(height: 100.0), + value: 1, + ), + ], + ), + ), + ), + ); + + final MergeableMaterial mergeableMaterial = tester.widget(find.byType(MergeableMaterial)); + + expect((mergeableMaterial.children.first as MaterialSlice).color, firstPanelColor); + expect((mergeableMaterial.children.last as MaterialSlice).color, secondPanelColor); + }); + + testWidgets('ExpansionPanelList.materialGapSize defaults to 16.0', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + canTapOnHeader: true, + body: const SizedBox.shrink(), + headerBuilder: (BuildContext context, bool isExpanded) { + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ), + ); + + final ExpansionPanelList expansionPanelList = tester.widget(find.byType(ExpansionPanelList)); + expect(expansionPanelList.materialGapSize, 16); + }); + + testWidgets('ExpansionPanelList respects materialGapSize', (WidgetTester tester) async { + Widget buildWidgetForTest({double materialGapSize = 16}) { + return MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + materialGapSize: materialGapSize, + children: <ExpansionPanel>[ + ExpansionPanel( + isExpanded: true, + canTapOnHeader: true, + body: const SizedBox.shrink(), + headerBuilder: (BuildContext context, bool isExpanded) { + return const SizedBox.shrink(); + }, + ), + ExpansionPanel( + canTapOnHeader: true, + body: const SizedBox.shrink(), + headerBuilder: (BuildContext context, bool isExpanded) { + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWidgetForTest(materialGapSize: 0)); + await tester.pumpAndSettle(); + final MergeableMaterial mergeableMaterial = tester.widget(find.byType(MergeableMaterial)); + expect(mergeableMaterial.children.length, 3); + expect(mergeableMaterial.children.whereType<MaterialGap>().length, 1); + expect(mergeableMaterial.children.whereType<MaterialSlice>().length, 2); + for (final MergeableMaterialItem e in mergeableMaterial.children) { + if (e is MaterialGap) { + expect(e.size, 0); + } + } + + await tester.pumpWidget(buildWidgetForTest(materialGapSize: 20)); + await tester.pumpAndSettle(); + final MergeableMaterial mergeableMaterial2 = tester.widget(find.byType(MergeableMaterial)); + expect(mergeableMaterial2.children.length, 3); + expect(mergeableMaterial2.children.whereType<MaterialGap>().length, 1); + expect(mergeableMaterial2.children.whereType<MaterialSlice>().length, 2); + for (final MergeableMaterialItem e in mergeableMaterial2.children) { + if (e is MaterialGap) { + expect(e.size, 20); + } + } + + await tester.pumpWidget(buildWidgetForTest()); + await tester.pumpAndSettle(); + final MergeableMaterial mergeableMaterial3 = tester.widget(find.byType(MergeableMaterial)); + expect(mergeableMaterial3.children.length, 3); + expect(mergeableMaterial3.children.whereType<MaterialGap>().length, 1); + expect(mergeableMaterial3.children.whereType<MaterialSlice>().length, 2); + for (final MergeableMaterialItem e in mergeableMaterial3.children) { + if (e is MaterialGap) { + expect(e.size, 16); + } + } + }); + + testWidgets( + 'Ensure IconButton splashColor and highlightColor are correctly set when canTapOnHeader is false', + (WidgetTester tester) async { + const Color expectedSplashColor = Colors.green; + const Color expectedHighlightColor = Colors.yellow; + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel 1')); + }, + body: const ListTile(title: Text('Content for Panel 1')), + splashColor: expectedSplashColor, + highlightColor: expectedHighlightColor, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Panel 1')); + await tester.pumpAndSettle(); + + final IconButton iconButton = tester.widget(find.byType(IconButton).first); + expect(iconButton.splashColor, expectedSplashColor); + expect(iconButton.highlightColor, expectedHighlightColor); + }, + ); + + testWidgets( + 'Ensure InkWell splashColor and highlightColor are correctly set when canTapOnHeader is true', + (WidgetTester tester) async { + const Color expectedSplashColor = Colors.green; + const Color expectedHighlightColor = Colors.yellow; + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + canTapOnHeader: true, + headerBuilder: (BuildContext context, bool isExpanded) { + return Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.centerLeft, + child: const Text('Panel 1'), + ); + }, + body: const ListTile(title: Text('Content for Panel 1')), + splashColor: expectedSplashColor, + highlightColor: expectedHighlightColor, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final Finder inkWellFinder = find.descendant( + of: find.byType(ExpansionPanelList), + matching: find.byWidgetPredicate( + (Widget widget) => widget is InkWell && widget.onTap != null, + ), + ); + + final InkWell inkWell = tester.widget<InkWell>(inkWellFinder.first); + expect(inkWell.splashColor, expectedSplashColor); + expect(inkWell.highlightColor, expectedHighlightColor); + }, + ); + + testWidgets('ExpandIcon ignores pointer/tap events when canTapOnHeader is true', ( + WidgetTester tester, + ) async { + Widget buildWidget({bool canTapOnHeader = false}) { + return MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: <ExpansionPanel>[ + ExpansionPanel( + canTapOnHeader: canTapOnHeader, + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel')); + }, + body: const ListTile(title: Text('Content for Panel')), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Finder ignorePointerFinder = find + .descendant(of: find.byType(ExpansionPanelList), matching: find.byType(IgnorePointer)) + .first; + + final IgnorePointer ignorePointerFalse = tester.widget(ignorePointerFinder); + expect(ignorePointerFalse.ignoring, isFalse); + + await tester.pumpWidget(buildWidget(canTapOnHeader: true)); + await tester.pumpAndSettle(); + + final IgnorePointer ignorePointerTrue = tester.widget(ignorePointerFinder); + expect(ignorePointerTrue.ignoring, isTrue); + }); +} diff --git a/packages/material_ui/test/material/expansion_tile_test.dart b/packages/material_ui/test/material/expansion_tile_test.dart new file mode 100644 index 000000000000..5c5279da0b59 --- /dev/null +++ b/packages/material_ui/test/material/expansion_tile_test.dart @@ -0,0 +1,2322 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestIcon extends StatefulWidget { + const TestIcon({super.key}); + + @override + TestIconState createState() => TestIconState(); +} + +class TestIconState extends State<TestIcon> { + late IconThemeData iconTheme; + + @override + Widget build(BuildContext context) { + iconTheme = IconTheme.of(context); + return const Icon(Icons.expand_more); + } +} + +class TestText extends StatefulWidget { + const TestText(this.text, {super.key}); + + final String text; + + @override + TestTextState createState() => TestTextState(); +} + +class TestTextState extends State<TestText> { + late TextStyle textStyle; + + @override + Widget build(BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return Text(widget.text); + } +} + +void main() { + const dividerColor = Color(0x1f333333); + const Color foregroundColor = Colors.blueAccent; + const Color unselectedWidgetColor = Colors.black54; + const Color headerColor = Colors.black45; + + Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(ExpansionTile), matching: find.byType(Material)), + ); + } + + testWidgets( + 'ExpansionTile initial state', + (WidgetTester tester) async { + final Key topKey = UniqueKey(); + final Key tileKey = UniqueKey(); + const Key expandedKey = PageStorageKey<String>('expanded'); + const Key collapsedKey = PageStorageKey<String>('collapsed'); + const Key defaultKey = PageStorageKey<String>('default'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(dividerColor: dividerColor), + home: Material( + child: SingleChildScrollView( + child: Column( + children: <Widget>[ + ListTile(title: const Text('Top'), key: topKey), + ExpansionTile( + key: expandedKey, + initiallyExpanded: true, + title: const Text('Expanded'), + backgroundColor: Colors.red, + children: <Widget>[ListTile(key: tileKey, title: const Text('0'))], + ), + ExpansionTile( + key: collapsedKey, + title: const Text('Collapsed'), + children: <Widget>[ListTile(key: tileKey, title: const Text('0'))], + ), + const ExpansionTile( + key: defaultKey, + title: Text('Default'), + children: <Widget>[ListTile(title: Text('0'))], + ), + ], + ), + ), + ), + ), + ); + + double getHeight(Key key) => tester.getSize(find.byKey(key)).height; + DecoratedBox getDecoratedBox(Key key) => tester.firstWidget( + find.descendant(of: find.byKey(key), matching: find.byType(DecoratedBox)), + ); + + expect(getHeight(topKey), getHeight(expandedKey) - getHeight(tileKey) - 2.0); + expect(getHeight(topKey), getHeight(collapsedKey) - 2.0); + expect(getHeight(topKey), getHeight(defaultKey) - 2.0); + + var expandedContainerDecoration = getDecoratedBox(expandedKey).decoration as ShapeDecoration; + expect(expandedContainerDecoration.color, Colors.red); + expect((expandedContainerDecoration.shape as Border).top.color, dividerColor); + expect((expandedContainerDecoration.shape as Border).bottom.color, dividerColor); + + var collapsedContainerDecoration = + getDecoratedBox(collapsedKey).decoration as ShapeDecoration; + expect(collapsedContainerDecoration.color, Colors.transparent); + expect((collapsedContainerDecoration.shape as Border).top.color, Colors.transparent); + expect((collapsedContainerDecoration.shape as Border).bottom.color, Colors.transparent); + + await tester.tap(find.text('Expanded')); + await tester.tap(find.text('Collapsed')); + await tester.tap(find.text('Default')); + + await tester.pump(); + + // Pump to the middle of the animation for expansion. + await tester.pump(const Duration(milliseconds: 100)); + final collapsingContainerDecoration = + getDecoratedBox(collapsedKey).decoration as ShapeDecoration; + expect(collapsingContainerDecoration.color, Colors.transparent); + expect( + (collapsingContainerDecoration.shape as Border).top.color, + isSameColorAs(const Color(0x15222222)), + ); + expect( + (collapsingContainerDecoration.shape as Border).bottom.color, + isSameColorAs(const Color(0x15222222)), + ); + + // Pump all the way to the end now. + await tester.pump(const Duration(seconds: 1)); + + expect(getHeight(topKey), getHeight(expandedKey) - 2.0); + expect(getHeight(topKey), getHeight(collapsedKey) - getHeight(tileKey) - 2.0); + expect(getHeight(topKey), getHeight(defaultKey) - getHeight(tileKey) - 2.0); + + // Expanded should be collapsed now. + expandedContainerDecoration = getDecoratedBox(expandedKey).decoration as ShapeDecoration; + expect(expandedContainerDecoration.color, Colors.transparent); + expect((expandedContainerDecoration.shape as Border).top.color, Colors.transparent); + expect((expandedContainerDecoration.shape as Border).bottom.color, Colors.transparent); + + // Collapsed should be expanded now. + collapsedContainerDecoration = getDecoratedBox(collapsedKey).decoration as ShapeDecoration; + expect(collapsedContainerDecoration.color, Colors.transparent); + expect((collapsedContainerDecoration.shape as Border).top.color, dividerColor); + expect((collapsedContainerDecoration.shape as Border).bottom.color, dividerColor); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'ExpansionTile Theme dependencies', + (WidgetTester tester) async { + final Key expandedTitleKey = UniqueKey(); + final Key collapsedTitleKey = UniqueKey(); + final Key expandedIconKey = UniqueKey(); + final Key collapsedIconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + colorScheme: ColorScheme.fromSwatch().copyWith(primary: foregroundColor), + unselectedWidgetColor: unselectedWidgetColor, + textTheme: const TextTheme(titleMedium: TextStyle(color: headerColor)), + ), + home: Material( + child: SingleChildScrollView( + child: Column( + children: <Widget>[ + const ListTile(title: Text('Top')), + ExpansionTile( + initiallyExpanded: true, + title: TestText('Expanded', key: expandedTitleKey), + backgroundColor: Colors.red, + trailing: TestIcon(key: expandedIconKey), + children: const <Widget>[ListTile(title: Text('0'))], + ), + ExpansionTile( + title: TestText('Collapsed', key: collapsedTitleKey), + trailing: TestIcon(key: collapsedIconKey), + children: const <Widget>[ListTile(title: Text('0'))], + ), + ], + ), + ), + ), + ), + ); + + Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; + Color textColor(Key key) => tester.state<TestTextState>(find.byKey(key)).textStyle.color!; + + expect(textColor(expandedTitleKey), foregroundColor); + expect(textColor(collapsedTitleKey), headerColor); + expect(iconColor(expandedIconKey), foregroundColor); + expect(iconColor(collapsedIconKey), unselectedWidgetColor); + + // Tap both tiles to change their state: collapse and extend respectively + await tester.tap(find.text('Expanded')); + await tester.tap(find.text('Collapsed')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + + expect(textColor(expandedTitleKey), headerColor); + expect(textColor(collapsedTitleKey), foregroundColor); + expect(iconColor(expandedIconKey), unselectedWidgetColor); + expect(iconColor(collapsedIconKey), foregroundColor); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('ExpansionTile subtitle', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpansionTile( + title: Text('Title'), + subtitle: Text('Subtitle'), + children: <Widget>[ListTile(title: Text('0'))], + ), + ), + ), + ); + + expect(find.text('Subtitle'), findsOneWidget); + }); + + testWidgets('ExpansionTile maintainState', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS, dividerColor: dividerColor), + home: const Material( + child: SingleChildScrollView( + child: Column( + children: <Widget>[ + ExpansionTile( + title: Text('Tile 1'), + maintainState: true, + children: <Widget>[Text('Maintaining State')], + ), + ExpansionTile(title: Text('Title 2'), children: <Widget>[Text('Discarding State')]), + ], + ), + ), + ), + ), + ); + + // This text should be offstage while ExpansionTile collapsed + expect(find.text('Maintaining State', skipOffstage: false), findsOneWidget); + expect(find.text('Maintaining State'), findsNothing); + // This text shouldn't be there while ExpansionTile collapsed + expect(find.text('Discarding State'), findsNothing); + }); + + testWidgets('ExpansionTile padding test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + title: Text('Hello'), + tilePadding: EdgeInsets.fromLTRB(8, 12, 4, 10), + ), + ), + ), + ), + ); + + final Rect titleRect = tester.getRect(find.text('Hello')); + final Rect trailingRect = tester.getRect(find.byIcon(Icons.expand_more)); + final Rect listTileRect = tester.getRect(find.byType(ListTile)); + final tallerWidget = titleRect.height > trailingRect.height ? titleRect : trailingRect; + + // Check the positions of title and trailing Widgets, after padding is applied. + expect(listTileRect.left, titleRect.left - 8); + expect(listTileRect.right, trailingRect.right + 4); + + // Calculate the remaining height of ListTile from the default height. + final double remainingHeight = 56 - tallerWidget.height; + expect(listTileRect.top, tallerWidget.top - remainingHeight / 2 - 12); + expect(listTileRect.bottom, tallerWidget.bottom + remainingHeight / 2 + 10); + }); + + testWidgets('ExpansionTile expandedAlignment test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + expandedAlignment: Alignment.centerLeft, + children: <Widget>[ + SizedBox(height: 100, width: 100), + SizedBox(height: 100, width: 80), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + + // The expandedAlignment is used to define the alignment of the Column widget in + // expanded tile, not the alignment of the children inside the Column. + expect(columnRect.left, 0.0); + // The width of the Column is the width of the largest child. The largest width + // being 100.0, the offset of the right edge of Column from X-axis should be 100.0. + expect(columnRect.right, 100.0); + }); + + testWidgets('ExpansionTile expandedAlignment with directional test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + expandedAlignment: AlignmentDirectional.topEnd, + children: <Widget>[ + SizedBox(height: 100, width: 100), + SizedBox(height: 100, width: 80), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + + // The expandedAlignment is used to define the alignment of the Column widget in + // expanded tile, not the alignment of the children inside the Column. + expect(columnRect.left, 0.0); + // The width of the Column is the width of the largest child. The largest width + // being 100.0, the offset of the right edge of Column from X-axis should be 100.0. + expect(columnRect.right, 100.0); + }); + + testWidgets('ExpansionTile expandedCrossAxisAlignment test', (WidgetTester tester) async { + const child0Key = Key('child0'); + const child1Key = Key('child1'); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + // Set the column's alignment to Alignment.centerRight to test CrossAxisAlignment + // of children widgets. This helps distinguish the effect of expandedAlignment + // and expandedCrossAxisAlignment later in the test. + expandedAlignment: Alignment.centerRight, + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + SizedBox(height: 100, width: 100, key: child0Key), + SizedBox(height: 100, width: 80, key: child1Key), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + final Rect child0Rect = tester.getRect(find.byKey(child0Key)); + final Rect child1Rect = tester.getRect(find.byKey(child1Key)); + + // Since expandedAlignment is set to Alignment.centerRight, the column of children + // should be aligned to the center right of the expanded tile. This provides confirmation + // that the expandedCrossAxisAlignment.start is 700.0, where columnRect.left is. + expect(columnRect.right, 800.0); + // The width of the Column is the width of the largest child. The largest width + // being 100.0, the offset of the left edge of Column from X-axis should be 700.0. + expect(columnRect.left, 700.0); + + // Considering the value of expandedCrossAxisAlignment is CrossAxisAlignment.start, + // the offset of the left edge of both the children from X-axis should be 700.0. + expect(child0Rect.left, 700.0); + expect(child1Rect.left, 700.0); + }); + + testWidgets('ExpansionTile expandedCrossAxisAlignment with directional expandedAlignment test', ( + WidgetTester tester, + ) async { + const child0Key = Key('child0'); + const child1Key = Key('child1'); + + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + // Set the column's alignment to AlignmentDirectional.centerStart to test CrossAxisAlignment + // of children widgets. This helps distinguish the effect of expandedAlignment + // and expandedCrossAxisAlignment later in the test. + expandedAlignment: AlignmentDirectional.centerStart, + expandedCrossAxisAlignment: CrossAxisAlignment.end, + children: <Widget>[ + SizedBox(height: 100, width: 100, key: child0Key), + SizedBox(height: 100, width: 80, key: child1Key), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + final Rect child0Rect = tester.getRect(find.byKey(child0Key)); + final Rect child1Rect = tester.getRect(find.byKey(child1Key)); + + // With `textDirection` set to `TextDirection.rtl`, `AlignmentDirectional.centerStart` + // resolves to `Alignment.centerRight`. The column of children should be aligned to the + // center right of the expanded tile. + expect(columnRect.right, 800.0); + // The width of the Column is the width of the largest child. The largest width + // being 100.0, the offset of the left edge of Column from X-axis should be 700.0. + expect(columnRect.left, 700.0); + + // With `textDirection` set to `TextDirection.rtl`, `CrossAxisAlignment.end` aligns children to the left. + // The offset of the left edge of both children from the X-axis should be 700.0. + expect(child0Rect.left, 700.0); + expect(child1Rect.left, 700.0); + }); + + testWidgets('CrossAxisAlignment.baseline is not allowed', (WidgetTester tester) async { + expect( + () { + MaterialApp( + home: Material( + child: ExpansionTile( + initiallyExpanded: true, + title: const Text('title'), + expandedCrossAxisAlignment: CrossAxisAlignment.baseline, + ), + ), + ); + }, + throwsA( + isA<AssertionError>().having( + (AssertionError error) => error.toString(), + '.toString()', + contains( + 'CrossAxisAlignment.baseline is not supported since the expanded' + ' children are aligned in a column, not a row. Try to use another constant.', + ), + ), + ), + ); + }); + + testWidgets('expandedCrossAxisAlignment and expandedAlignment default values', ( + WidgetTester tester, + ) async { + const child1Key = Key('child1'); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + children: <Widget>[ + SizedBox(height: 100, width: 100), + SizedBox(height: 100, width: 80, key: child1Key), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + final Rect child1Rect = tester.getRect(find.byKey(child1Key)); + + // The default viewport size is Size(800, 600). + // By default the value of extendedAlignment is Alignment.center, hence the offset + // of left and right edges from x axis should be equal. + expect(columnRect.left, 800 - columnRect.right); + + // By default the value of extendedCrossAxisAlignment is CrossAxisAlignment.center, hence + // the offset of left and right edges from Column should be equal. + expect(child1Rect.left - columnRect.left, columnRect.right - child1Rect.right); + }); + + testWidgets('childrenPadding default value', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + final Rect paddingRect = tester.getRect(find.byType(Padding).last); + + // By default, the value of childrenPadding is EdgeInsets.zero, hence offset + // of all the edges from x-axis and y-axis should be equal for Padding and Column. + expect(columnRect.top, paddingRect.top); + expect(columnRect.left, paddingRect.left); + expect(columnRect.right, paddingRect.right); + expect(columnRect.bottom, paddingRect.bottom); + }); + + testWidgets('ExpansionTile childrenPadding test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + title: Text('title'), + childrenPadding: EdgeInsets.fromLTRB(10, 8, 12, 4), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ), + ); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + final Rect columnRect = tester.getRect(find.byType(Column).last); + final Rect paddingRect = tester.getRect(find.byType(Padding).last); + + // Check the offset of all the edges from x-axis and y-axis after childrenPadding + // is applied. + expect(columnRect.left, paddingRect.left + 10); + expect(columnRect.top, paddingRect.top + 8); + expect(columnRect.right, paddingRect.right - 12); + expect(columnRect.bottom, paddingRect.bottom - 4); + }); + + testWidgets('ExpansionTile.collapsedBackgroundColor', (WidgetTester tester) async { + const expansionTileKey = Key('expansionTileKey'); + const Color backgroundColor = Colors.red; + const Color collapsedBackgroundColor = Colors.brown; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + key: expansionTileKey, + title: Text('Title'), + backgroundColor: backgroundColor, + collapsedBackgroundColor: collapsedBackgroundColor, + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + var shapeDecoration = + tester + .firstWidget<DecoratedBox>( + find.descendant( + of: find.byKey(expansionTileKey), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + + expect(shapeDecoration.color, collapsedBackgroundColor); + + await tester.tap(find.text('Title')); + await tester.pumpAndSettle(); + + shapeDecoration = + tester + .firstWidget<DecoratedBox>( + find.descendant( + of: find.byKey(expansionTileKey), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as ShapeDecoration; + + expect(shapeDecoration.color, backgroundColor); + }); + + testWidgets('ExpansionTile default iconColor, textColor', (WidgetTester tester) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: ExpansionTile( + title: TestText('title'), + trailing: TestIcon(), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!; + Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!; + + expect(getIconColor(), theme.colorScheme.onSurfaceVariant); + expect(getTextColor(), theme.colorScheme.onSurface); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + expect(getIconColor(), theme.colorScheme.primary); + expect(getTextColor(), theme.colorScheme.onSurface); + }); + + testWidgets('ExpansionTile iconColor, textColor', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/78281 + + const iconColor = Color(0xff00ff00); + const collapsedIconColor = Color(0xff0000ff); + const textColor = Color(0xff00ffff); + const collapsedTextColor = Color(0xffff00ff); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + iconColor: iconColor, + collapsedIconColor: collapsedIconColor, + textColor: textColor, + collapsedTextColor: collapsedTextColor, + title: TestText('title'), + trailing: TestIcon(), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!; + Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!; + + expect(getIconColor(), collapsedIconColor); + expect(getTextColor(), collapsedTextColor); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + expect(getIconColor(), iconColor); + expect(getTextColor(), textColor); + }); + + testWidgets('ExpansionTile shows hover color with opaque backgroundColor', ( + WidgetTester tester, + ) async { + const Color hoverColor = Colors.green; // Green + const Color backgroundColor = Colors.blue; // Blue + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(hoverColor: hoverColor), + home: const Material( + child: ExpansionTile(title: Text('Title'), collapsedBackgroundColor: backgroundColor), + ), + ), + ); + + final Finder materialFinder = find.descendant( + of: find.byType(ExpansionTile), + matching: find.byType(Material), + ); + + // Hover the tile. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.text('Title'))); + await tester.pumpAndSettle(); + + expect( + materialFinder, + paints + ..rect(color: Colors.transparent) + ..rect(color: hoverColor), + ); + }); + + testWidgets('ExpansionTile shows focus color with opaque backgroundColor', ( + WidgetTester tester, + ) async { + const Color focusColor = Colors.pink; // Green + const Color backgroundColor = Colors.amber; // Blue + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(focusColor: focusColor), + home: const Material( + child: ExpansionTile(title: Text('Title'), collapsedBackgroundColor: backgroundColor), + ), + ), + ); + + final Finder materialFinder = find.descendant( + of: find.byType(ExpansionTile), + matching: find.byType(Material), + ); + + // Focus the tile. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect( + materialFinder, + paints + ..rect(color: Colors.transparent) + ..rect(color: focusColor), + ); + }); + + testWidgets('ExpansionTile Border', (WidgetTester tester) async { + const Key expansionTileKey = PageStorageKey<String>('expansionTile'); + + const collapsedShape = Border( + top: BorderSide(color: Colors.blue), + bottom: BorderSide(color: Colors.green), + ); + final shape = Border.all(color: Colors.red); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + key: expansionTileKey, + title: const Text('ExpansionTile'), + collapsedShape: collapsedShape, + shape: shape, + children: const <Widget>[ListTile(title: Text('0'))], + ), + ), + ), + ); + + // When a custom shape is provided, ExpansionTile will use the + // Material widget to draw the shape and background color + // instead of a Container. + Material material = getMaterial(tester); + // ExpansionTile should be collapsed initially. + expect(material.shape, collapsedShape); + expect(material.clipBehavior, Clip.antiAlias); + + await tester.tap(find.text('ExpansionTile')); + await tester.pumpAndSettle(); + + // ExpansionTile should be Expanded now. + material = getMaterial(tester); + expect(material.shape, shape); + expect(material.clipBehavior, Clip.antiAlias); + }); + + testWidgets('ExpansionTile platform controlAffinity test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: ExpansionTile(title: Text('Title'))), + ), + ); + + final ListTile listTile = tester.widget(find.byType(ListTile)); + expect(listTile.leading, isNull); + expect(listTile.trailing.runtimeType, RotationTransition); + }); + + testWidgets('ExpansionTile trailing controlAffinity test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + title: Text('Title'), + controlAffinity: ListTileControlAffinity.trailing, + ), + ), + ), + ); + + final ListTile listTile = tester.widget(find.byType(ListTile)); + expect(listTile.leading, isNull); + expect(listTile.trailing.runtimeType, RotationTransition); + }); + + testWidgets('ExpansionTile leading controlAffinity test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + title: Text('Title'), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ), + ); + + final ListTile listTile = tester.widget(find.byType(ListTile)); + expect(listTile.leading.runtimeType, RotationTransition); + expect(listTile.trailing, isNull); + }); + + testWidgets('ExpansionTile override rotating icon test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + title: Text('Title'), + leading: Icon(Icons.info), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ), + ); + + final ListTile listTile = tester.widget(find.byType(ListTile)); + expect(listTile.leading.runtimeType, Icon); + expect(listTile.trailing, isNull); + }); + + testWidgets('Nested ListTile Semantics', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + ExpansionTile(title: Text('First Expansion Tile'), internalAddSemanticForOnTap: true), + ExpansionTile( + initiallyExpanded: true, + title: Text('Second Expansion Tile'), + internalAddSemanticForOnTap: true, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Focus the first ExpansionTile. + tester.binding.focusManager.primaryFocus?.nextFocus(); + await tester.pumpAndSettle(); + + // The first list tile is focused. + expect( + tester.getSemantics(find.byType(ListTile).first), + matchesSemantics( + isButton: true, + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + isFocused: true, + isFocusable: true, + label: 'First Expansion Tile', + textDirection: TextDirection.ltr, + ), + ); + + // The first list tile is not focused. + expect( + tester.getSemantics(find.byType(ListTile).last), + matchesSemantics( + isButton: true, + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + isFocusable: true, + label: 'Second Expansion Tile', + textDirection: TextDirection.ltr, + ), + ); + handle.dispose(); + }); + + testWidgets( + 'ExpansionTile Semantics announcement', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + title: Text('Title'), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + // There is no semantics announcement without tap action. + expect(tester.takeAnnouncements(), isEmpty); + + // Tap the title to expand ExpansionTile. + await tester.tap(find.text('Title')); + await tester.pumpAndSettle(); + + // The announcement should be the opposite of the current state. + // The ExpansionTile is expanded, so the announcement should be + // "Expanded". + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.collapsedHint), + ); + + // Tap the title to collapse ExpansionTile. + await tester.tap(find.text('Title')); + await tester.pumpAndSettle(); + + // The announcement should be the opposite of the current state. + // The ExpansionTile is collapsed, so the announcement should be + // "Collapsed". + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.expandedHint), + ); + handle.dispose(); + }, + // [intended] iOS: https://github.com/flutter/flutter/issues/122101. + // android: https://github.com/flutter/flutter/issues/165510 + skip: + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android, + ); + + testWidgets('ExpansionTile reports error when SemanticsService.sendAnnouncement fails', ( + WidgetTester tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + final errors = <FlutterErrorDetails>[]; + final void Function(FlutterErrorDetails)? originalOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + final String contextStr = details.context?.toString() ?? ''; + if (contextStr.contains('while sending semantics announcement')) { + errors.add(details); + return; + } + originalOnError?.call(details); + }; + addTearDown(() { + FlutterError.onError = originalOnError; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler( + SystemChannels.accessibility.name, + null, + ); + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler( + SystemChannels.accessibility.name, + (ByteData? message) async { + const codec = StandardMessageCodec(); + final Object? decoded = codec.decodeMessage(message); + if (decoded is Map && decoded['type'] == 'announce') { + final data = ByteData(1); + data.setUint8(0, 255); // Invalid type byte + return data; + } + return null; // Success for other events + }, + ); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + title: Text('Title'), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + await tester.tap(find.text('Title')); + await tester.pump(); + + expect(errors, isNotEmpty); + final bool hasAnnouncementError = errors.any( + (e) => + e.exception.toString().contains('FormatException') && + e.context.toString().contains('while sending semantics announcement'), + ); + expect(hasAnnouncementError, isTrue); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/132264. + testWidgets( + 'ExpansionTile Semantics announcement is delayed on iOS', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + title: Text('Title'), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + // There is no semantics announcement without tap action. + expect(tester.takeAnnouncements(), isEmpty); + + // Tap the title to expand ExpansionTile. + await tester.tap(find.text('Title')); + await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made. + + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.collapsedHint), + ); + + // Tap the title to collapse ExpansionTile. + await tester.tap(find.text('Title')); + await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made. + + expect( + tester.takeAnnouncements().first, + isAccessibilityAnnouncement(localizations.expandedHint), + ); + handle.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('Semantics with the onTapHint is an ancestor of ListTile', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/pull/121624 + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + ExpansionTile(title: Text('First Expansion Tile')), + ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')), + ], + ), + ), + ), + ); + + SemanticsNode semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first, + ); + expect(semantics, isNotNull); + // The onTapHint is passed to semantics properties's hintOverrides. + expect(semantics.hintOverrides, isNotNull); + // The hint should be the opposite of the current state. + // The first ExpansionTile is collapsed, so the hint should be + // "double tap to expand". + expect(semantics.hintOverrides!.onTapHint, localizations.expansionTileCollapsedTapHint); + + semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first, + ); + + expect(semantics, isNotNull); + // The onTapHint is passed to semantics properties's hintOverrides. + expect(semantics.hintOverrides, isNotNull); + // The hint should be the opposite of the current state. + // The second ExpansionTile is expanded, so the hint should be + // "double tap to collapse". + expect(semantics.hintOverrides!.onTapHint, localizations.expansionTileExpandedTapHint); + handle.dispose(); + }); + + testWidgets( + 'Semantics hint for iOS and macOS', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + ExpansionTile(title: Text('First Expansion Tile')), + ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')), + ], + ), + ), + ), + ); + + SemanticsNode semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first, + ); + + expect(semantics, isNotNull); + expect( + semantics.hint, + '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}', + ); + + semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first, + ); + + expect(semantics, isNotNull); + expect( + semantics.hint, + '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}', + ); + handle.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('Collapsed ExpansionTile properties can be updated with setState', ( + WidgetTester tester, + ) async { + const expansionTileKey = Key('expansionTileKey'); + ShapeBorder collapsedShape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + ); + var collapsedTextColor = const Color(0xffffffff); + var collapsedBackgroundColor = const Color(0xffff0000); + var collapsedIconColor = const Color(0xffffffff); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + children: <Widget>[ + ExpansionTile( + key: expansionTileKey, + collapsedShape: collapsedShape, + collapsedTextColor: collapsedTextColor, + collapsedBackgroundColor: collapsedBackgroundColor, + collapsedIconColor: collapsedIconColor, + title: const TestText('title'), + trailing: const TestIcon(), + children: const <Widget>[SizedBox(height: 100, width: 100)], + ), + // This button is used to update the ExpansionTile properties. + FilledButton( + onPressed: () { + setState(() { + collapsedShape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + collapsedTextColor = const Color(0xff000000); + collapsedBackgroundColor = const Color(0xffffff00); + collapsedIconColor = const Color(0xff000000); + }); + }, + child: const Text('Update collapsed properties'), + ), + ], + ); + }, + ), + ), + ), + ); + + // When a custom shape is provided, ExpansionTile will use the + // Material widget to draw the shape and background color + // instead of a Container. + Material material = getMaterial(tester); + + // Test initial ExpansionTile properties. + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.color, const Color(0xffff0000)); + expect(material.clipBehavior, Clip.antiAlias); + expect( + tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color, + const Color(0xffffffff), + ); + expect( + tester.state<TestTextState>(find.byType(TestText)).textStyle.color, + const Color(0xffffffff), + ); + + // Tap the button to update the ExpansionTile properties. + await tester.tap(find.text('Update collapsed properties')); + await tester.pumpAndSettle(); + + material = getMaterial(tester); + + // Test updated ExpansionTile properties. + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))), + ); + expect(material.color, const Color(0xffffff00)); + expect(material.clipBehavior, Clip.antiAlias); + expect( + tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color, + const Color(0xff000000), + ); + expect( + tester.state<TestTextState>(find.byType(TestText)).textStyle.color, + const Color(0xff000000), + ); + }); + + testWidgets('Expanded ExpansionTile properties can be updated with setState', ( + WidgetTester tester, + ) async { + const expansionTileKey = Key('expansionTileKey'); + ShapeBorder shape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ); + var textColor = const Color(0xff00ffff); + var backgroundColor = const Color(0xff0000ff); + var iconColor = const Color(0xff00ffff); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + children: <Widget>[ + ExpansionTile( + key: expansionTileKey, + shape: shape, + textColor: textColor, + backgroundColor: backgroundColor, + iconColor: iconColor, + title: const TestText('title'), + trailing: const TestIcon(), + children: const <Widget>[SizedBox(height: 100, width: 100)], + ), + // This button is used to update the ExpansionTile properties. + FilledButton( + onPressed: () { + setState(() { + shape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ); + textColor = const Color(0xffffffff); + backgroundColor = const Color(0xff123456); + iconColor = const Color(0xffffffff); + }); + }, + child: const Text('Update collapsed properties'), + ), + ], + ); + }, + ), + ), + ), + ); + + // Tap to expand the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // When a custom shape is provided, ExpansionTile will use the + // Material widget to draw the shape and background color + // instead of a Container. + Material material = getMaterial(tester); + + // Test initial ExpansionTile properties. + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ); + expect(material.color, const Color(0xff0000ff)); + expect(material.clipBehavior, Clip.antiAlias); + expect( + tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color, + const Color(0xff00ffff), + ); + expect( + tester.state<TestTextState>(find.byType(TestText)).textStyle.color, + const Color(0xff00ffff), + ); + + // Tap the button to update the ExpansionTile properties. + await tester.tap(find.text('Update collapsed properties')); + await tester.pumpAndSettle(); + + material = getMaterial(tester); + iconColor = tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!; + textColor = tester.state<TestTextState>(find.byType(TestText)).textStyle.color!; + + // Test updated ExpansionTile properties. + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(6))), + ); + expect(material.color, const Color(0xff123456)); + expect(material.clipBehavior, Clip.antiAlias); + expect( + tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color, + const Color(0xffffffff), + ); + expect( + tester.state<TestTextState>(find.byType(TestText)).textStyle.color, + const Color(0xffffffff), + ); + }); + + testWidgets('Override ExpansionTile animation using AnimationStyle', (WidgetTester tester) async { + const expansionTileKey = Key('expansionTileKey'); + + Widget buildExpansionTile({AnimationStyle? animationStyle}) { + return MaterialApp( + home: Material( + child: Center( + child: ExpansionTile( + key: expansionTileKey, + expansionAnimationStyle: animationStyle, + title: const TestText('title'), + children: const <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildExpansionTile()); + + double getHeight(Key key) => tester.getSize(find.byKey(key)).height; + + // Test initial ExpansionTile height. + expect(getHeight(expansionTileKey), 58.0); + + // Test the default expansion animation. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation duration. + await tester.pumpWidget( + buildExpansionTile( + animationStyle: const AnimationStyle(duration: Duration(milliseconds: 800)), + ), + ); + await tester.pumpAndSettle(); + + // Test the overridden animation duration. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump( + const Duration(milliseconds: 200), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation curve. + await tester.pumpWidget( + buildExpansionTile( + animationStyle: const AnimationStyle( + curve: Easing.emphasizedDecelerate, + reverseCurve: Easing.emphasizedAccelerate, + ), + ), + ); + await tester.pumpAndSettle(); + + // Test the overridden animation curve. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(141.2, 0.1)); + + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(153, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Test the overridden reverse (collapse) animation curve. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(98.6, 0.1)); + + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(73.4, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 58.0); + + // Test no animation. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle.noAnimation)); + + // Tap to expand the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pump(); + + expect(getHeight(expansionTileKey), 158.0); + }); + + testWidgets('Material3 - ExpansionTile draws Inkwell splash on top of background color', ( + WidgetTester tester, + ) async { + const expansionTileKey = Key('expansionTileKey'); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + const ShapeBorder collapsedShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + const collapsedBackgroundColor = Color(0xff00ff00); + const backgroundColor = Color(0xffff0000); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0), + child: ExpansionTile( + key: expansionTileKey, + shape: shape, + collapsedBackgroundColor: collapsedBackgroundColor, + backgroundColor: backgroundColor, + collapsedShape: collapsedShape, + title: TestText('title'), + trailing: TestIcon(), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ), + ), + ); + + // Tap and hold the ExpansionTile to trigger ink splash. + final Offset center = tester.getCenter(find.byKey(expansionTileKey)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // Start the splash animation. + await tester.pump(const Duration(milliseconds: 100)); // Splash is underway. + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. Use a golden test instead. + // Check if the ink sparkle is drawn on top of the background color. + await expectLater( + find.byKey(expansionTileKey), + matchesGoldenFile('expansion_tile.ink_splash.drawn_on_top_of_background_color.png'), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Default clipBehavior when a shape is provided', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpansionTile( + title: Text('Title'), + subtitle: Text('Subtitle'), + shape: StadiumBorder(), + children: <Widget>[ListTile(title: Text('0'))], + ), + ), + ), + ); + + expect(getMaterial(tester).clipBehavior, Clip.antiAlias); + }); + + testWidgets('Can override clipBehavior when a shape is provided', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpansionTile( + title: Text('Title'), + subtitle: Text('Subtitle'), + shape: StadiumBorder(), + clipBehavior: Clip.none, + children: <Widget>[ListTile(title: Text('0'))], + ), + ), + ), + ); + + expect(getMaterial(tester).clipBehavior, Clip.none); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('ExpansionTile default iconColor, textColor', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: ExpansionTile( + title: TestText('title'), + trailing: TestIcon(), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + + Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!; + Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!; + + expect(getIconColor(), theme.unselectedWidgetColor); + expect(getTextColor(), theme.textTheme.titleMedium!.color); + + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + expect(getIconColor(), theme.colorScheme.primary); + expect(getTextColor(), theme.colorScheme.primary); + }); + + testWidgets('Material2 - ExpansionTile draws inkwell splash on top of background color', ( + WidgetTester tester, + ) async { + const expansionTileKey = Key('expansionTileKey'); + final theme = ThemeData(useMaterial3: false); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + const ShapeBorder collapsedShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + const collapsedBackgroundColor = Color(0xff00ff00); + const backgroundColor = Color(0xffff0000); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0), + child: ExpansionTile( + key: expansionTileKey, + shape: shape, + collapsedBackgroundColor: collapsedBackgroundColor, + backgroundColor: backgroundColor, + collapsedShape: collapsedShape, + title: TestText('title'), + trailing: TestIcon(), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ), + ), + ); + + // Tap and hold the ExpansionTile to trigger ink splash. + final Offset center = tester.getCenter(find.byKey(expansionTileKey)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // Start the splash animation. + await tester.pump(const Duration(milliseconds: 100)); // Splash is underway. + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + // Check if the ink splash is drawn on top of the background color. + expect( + inkFeatures, + paints + ..path(color: collapsedBackgroundColor) + ..circle(color: theme.splashColor), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + testWidgets('ExpansionTileController isExpanded, expand() and collapse()', ( + WidgetTester tester, + ) async { + final controller = ExpansionTileController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + controller: controller, + title: const Text('Title'), + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + expect(find.text('Child 0'), findsNothing); + expect(controller.isExpanded, isFalse); + controller.expand(); + expect(controller.isExpanded, isTrue); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsOneWidget); + expect(controller.isExpanded, isTrue); + controller.collapse(); + expect(controller.isExpanded, isFalse); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsNothing); + + controller.dispose(); + }); + + testWidgets( + 'Calling ExpansionTileController.expand/collapsed has no effect if it is already expanded/collapsed', + (WidgetTester tester) async { + final controller = ExpansionTileController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + controller: controller, + title: const Text('Title'), + initiallyExpanded: true, + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + expect(find.text('Child 0'), findsOneWidget); + expect(controller.isExpanded, isTrue); + controller.expand(); + expect(controller.isExpanded, isTrue); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + expect(find.text('Child 0'), findsOneWidget); + controller.collapse(); + expect(controller.isExpanded, isFalse); + await tester.pump(); + expect(tester.hasRunningAnimations, isTrue); + await tester.pumpAndSettle(); + expect(controller.isExpanded, isFalse); + expect(find.text('Child 0'), findsNothing); + controller.collapse(); + expect(controller.isExpanded, isFalse); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + + controller.dispose(); + }, + ); + + testWidgets('Call to ExpansionTileController.of()', (WidgetTester tester) async { + final GlobalKey titleKey = GlobalKey(); + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + initiallyExpanded: true, + title: Text('Title', key: titleKey), + children: <Widget>[Text('Child 0', key: childKey)], + ), + ), + ), + ); + + final ExpansionTileController controller1 = ExpansionTileController.of( + childKey.currentContext!, + ); + expect(controller1.isExpanded, isTrue); + + final ExpansionTileController controller2 = ExpansionTileController.of( + titleKey.currentContext!, + ); + expect(controller2.isExpanded, isTrue); + + expect(controller1, controller2); + }); + + testWidgets('Call to ExpansionTile.maybeOf()', (WidgetTester tester) async { + final GlobalKey titleKey = GlobalKey(); + final GlobalKey nonDescendantKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + ExpansionTile( + title: Text('Title', key: titleKey), + children: const <Widget>[Text('Child 0')], + ), + Text('Non descendant', key: nonDescendantKey), + ], + ), + ), + ), + ); + + final ExpansionTileController? controller1 = ExpansionTileController.maybeOf( + titleKey.currentContext!, + ); + expect(controller1, isNotNull); + expect(controller1?.isExpanded, isFalse); + + final ExpansionTileController? controller2 = ExpansionTileController.maybeOf( + nonDescendantKey.currentContext!, + ); + expect(controller2, isNull); + }); + + testWidgets('Check if dense, splashColor, enableFeedback, visualDensity parameter is working', ( + WidgetTester tester, + ) async { + final GlobalKey titleKey = GlobalKey(); + final GlobalKey nonDescendantKey = GlobalKey(); + + const dense = true; + const Color splashColor = Colors.blue; + const enableFeedback = false; + const VisualDensity visualDensity = VisualDensity.compact; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + ExpansionTile( + dense: dense, + splashColor: splashColor, + enableFeedback: enableFeedback, + visualDensity: visualDensity, + title: Text('Title', key: titleKey), + children: const <Widget>[Text('Child 0')], + ), + Text('Non descendant', key: nonDescendantKey), + ], + ), + ), + ), + ); + + final Finder tileFinder = find.byType(ListTile); + final ListTile tileWidget = tester.widget<ListTile>(tileFinder); + expect(tileWidget.dense, dense); + expect(tileWidget.splashColor, splashColor); + expect(tileWidget.enableFeedback, enableFeedback); + expect(tileWidget.visualDensity, visualDensity); + }); + + testWidgets('ExpansionTileController should not toggle if disabled', (WidgetTester tester) async { + final controller = ExpansionTileController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + enabled: false, + controller: controller, + title: const Text('Title'), + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + expect(find.text('Child 0'), findsNothing); + expect(controller.isExpanded, isFalse); + await tester.tap(find.widgetWithText(ExpansionTile, 'Title')); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsNothing); + expect(controller.isExpanded, isFalse); + controller.expand(); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsOneWidget); + expect(controller.isExpanded, isTrue); + await tester.tap(find.widgetWithText(ExpansionTile, 'Title')); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsOneWidget); + expect(controller.isExpanded, isTrue); + + controller.dispose(); + }); + + testWidgets( + 'ExpansionTile does not include the default trailing icon when showTrailingIcon: false (#145268)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + enabled: false, + tilePadding: EdgeInsets.zero, + title: ColoredBox(color: Colors.red, child: Text('Title')), + showTrailingIcon: false, + ), + ), + ), + ); + + final Size materialAppSize = tester.getSize(find.byType(MaterialApp)); + final Size titleSize = tester.getSize( + find.descendant(of: find.byType(ExpansionTile), matching: find.byType(ColoredBox)), + ); + + expect(titleSize.width, materialAppSize.width); + }, + ); + + testWidgets( + 'ExpansionTile with smaller trailing widget allocates at least 32.0 units of space (preserves original behavior) (#145268)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile( + enabled: false, + tilePadding: EdgeInsets.zero, + title: ColoredBox(color: Colors.red, child: Text('Title')), + trailing: SizedBox.shrink(), + ), + ), + ), + ); + + final Size materialAppSize = tester.getSize(find.byType(MaterialApp)); + final Size titleSize = tester.getSize( + find.descendant(of: find.byType(ExpansionTile), matching: find.byType(ColoredBox)), + ); + + expect(titleSize.width, materialAppSize.width - 32.0); + }, + ); + + testWidgets('ExpansionTile uses ListTileTheme controlAffinity', (WidgetTester tester) async { + Widget buildView(ListTileControlAffinity controlAffinity) { + return MaterialApp( + home: ListTileTheme( + data: ListTileThemeData(controlAffinity: controlAffinity), + child: const Material(child: ExpansionTile(title: Text('ExpansionTile'))), + ), + ); + } + + await tester.pumpWidget(buildView(ListTileControlAffinity.leading)); + final Finder leading = find.text('ExpansionTile'); + final Offset offsetLeading = tester.getTopLeft(leading); + expect(offsetLeading, const Offset(56.0, 17.0)); + + await tester.pumpWidget(buildView(ListTileControlAffinity.trailing)); + final Finder trailing = find.text('ExpansionTile'); + final Offset offsetTrailing = tester.getTopLeft(trailing); + expect(offsetTrailing, const Offset(16.0, 17.0)); + + await tester.pumpWidget(buildView(ListTileControlAffinity.platform)); + final Finder platform = find.text('ExpansionTile'); + final Offset offsetPlatform = tester.getTopLeft(platform); + expect(offsetPlatform, const Offset(16.0, 17.0)); + }); + + testWidgets('ExpansionTile can accept a new controller', (WidgetTester tester) async { + final controller1 = ExpansibleController(); + final controller2 = ExpansibleController(); + addTearDown(() { + controller1.dispose(); + controller2.dispose(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + controller: controller1, + title: const Text('Title'), + initiallyExpanded: true, + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + expect(find.text('Child 0'), findsOne); + expect(controller1.isExpanded, isTrue); + controller1.collapse(); + expect(controller1.isExpanded, isFalse); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsNothing); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + controller: controller2, + title: const Text('Title'), + initiallyExpanded: true, + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + expect(find.text('Child 0'), findsNothing); + controller2.expand(); + expect(controller2.isExpanded, isTrue); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsOne); + }); + + testWidgets('ExpansionTile can accept a new controller with a different state', ( + WidgetTester tester, + ) async { + final controller1 = ExpansibleController(); + final controller2 = ExpansibleController(); + addTearDown(() { + controller1.dispose(); + controller2.dispose(); + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + controller: controller1, + title: const Text('Title'), + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + expect(find.text('Child 0'), findsNothing); + expect(controller1.isExpanded, isFalse); + controller1.expand(); + expect(controller1.isExpanded, isTrue); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsOne); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile( + controller: controller2, + title: const Text('Title'), + children: const <Widget>[Text('Child 0')], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + find.text('Child 0'), + findsNothing, + reason: 'The widget should update to the state of the new controller', + ); + controller2.expand(); + expect(controller2.isExpanded, isTrue); + await tester.pumpAndSettle(); + expect(find.text('Child 0'), findsOne); + }); + + // Regression test for https://github.com/flutter/flutter/issues/176566 + testWidgets( + 'ExpansionTile semantics hint uses defaultTargetPlatform for VoiceOver regardless of theme platform', + (WidgetTester tester) async { + // Regression test for VoiceOver accessibility when theme platform differs from device platform. + // When someone sets theme.platform to TargetPlatform.android on an iOS device, + // VoiceOver should still work correctly by using the actual device platform for semantics hints. + + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Column( + children: <Widget>[ + ExpansionTile(title: Text('First Expansion Tile')), + ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')), + ], + ), + ), + ), + ); + + SemanticsNode semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first, + ); + + expect(semantics, isNotNull); + // On iOS/macOS platform, the semantics hint should include expanded/collapsed state guidance + // even theme platform is set to Android. + expect( + semantics.hint, + '${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}', + ); + + semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first, + ); + + expect(semantics, isNotNull); + expect( + semantics.hint, + '${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}', + ); + handle.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + // Regression test for https://github.com/flutter/flutter/issues/173060 + group('Semantics tests for non-iOS/macOS/android platforms', () { + testWidgets( + 'Semantics hint should show current state', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + ExpansionTile(title: Text('First Expansion Tile')), + ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')), + ], + ), + ), + ), + ); + + // Test collapsed tile - should show "Collapsed" hint. + SemanticsNode semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first, + ); + expect(semantics, isNotNull); + expect(semantics.hint, localizations.expandedHint); + + // Test expanded tile - should show "Expanded" hint. + semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first, + ); + expect(semantics, isNotNull); + expect(semantics.hint, localizations.collapsedHint); + + handle.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Semantics hint updates when expansion state changes', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile(title: Text('Test Tile'), children: <Widget>[Text('Child')]), + ), + ), + ); + + // Initially collapsed - should show "Collapsed". + SemanticsNode semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile), matching: find.byType(Semantics)).first, + ); + expect(semantics.hint, localizations.expandedHint); + + // Tap to expand. + await tester.tap(find.text('Test Tile')); + await tester.pumpAndSettle(); + + // Now expanded - should show "Expanded". + semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile), matching: find.byType(Semantics)).first, + ); + expect(semantics.hint, localizations.collapsedHint); + + // Tap to collapse. + await tester.tap(find.text('Test Tile')); + await tester.pumpAndSettle(); + + // Back to collapsed - should show "Collapsed" again. + semantics = tester.getSemantics( + find.ancestor(of: find.byType(ListTile), matching: find.byType(Semantics)).first, + ); + expect(semantics.hint, localizations.expandedHint); + + handle.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + }); + group('Semantics tests for android platform', () { + testWidgets( + 'Semantics liveregion updates when expansion state changes', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + const localizations = DefaultMaterialLocalizations(); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: ExpansionTile(title: Text('Test Tile'), children: <Widget>[Text('Child')]), + ), + ), + ); + + // Initially collapsed - live region label is "Collapsed". + + SemanticsNode liveRegionSemantics = tester.getSemantics( + find.ancestor( + of: find.byType(ListTile), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false), + ), + ), + ); + expect(liveRegionSemantics.label, localizations.expandedHint); + + // Tap to expand. + await tester.tap(find.text('Test Tile')); + await tester.pumpAndSettle(); + + // Now expanded - should show "Expanded". + liveRegionSemantics = tester.getSemantics( + find.ancestor( + of: find.byType(ListTile), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false), + ), + ), + ); + expect(liveRegionSemantics.label, localizations.collapsedHint); + + // Tap to collapse. + await tester.tap(find.text('Test Tile')); + await tester.pumpAndSettle(); + + // Back to collapsed - should show "Collapsed" again. + liveRegionSemantics = tester.getSemantics( + find.ancestor( + of: find.byType(ListTile), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false), + ), + ), + ); + expect(liveRegionSemantics.label, localizations.expandedHint); + + handle.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + }); + + testWidgets('ExpansionTile forwards statesController to ListTile', (tester) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ExpansionTile(title: const Text('Tile'), statesController: controller), + ), + ), + ); + + final ListTile listTile = tester.widget<ListTile>(find.byType(ListTile)); + expect(listTile.statesController, controller); + }); + + testWidgets('ExpansionTile forwards null statesController to ListTile', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: ExpansionTile(title: Text('Tile'))), + ), + ); + + final ListTile listTile = tester.widget<ListTile>(find.byType(ListTile)); + expect(listTile.statesController, isNull); + }); +} diff --git a/packages/material_ui/test/material/expansion_tile_theme_test.dart b/packages/material_ui/test/material/expansion_tile_theme_test.dart new file mode 100644 index 000000000000..e88c6a683169 --- /dev/null +++ b/packages/material_ui/test/material/expansion_tile_theme_test.dart @@ -0,0 +1,448 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestIcon extends StatefulWidget { + const TestIcon({super.key}); + + @override + TestIconState createState() => TestIconState(); +} + +class TestIconState extends State<TestIcon> { + late IconThemeData iconTheme; + + @override + Widget build(BuildContext context) { + iconTheme = IconTheme.of(context); + return const Icon(Icons.expand_more); + } +} + +class TestText extends StatefulWidget { + const TestText(this.text, {super.key}); + + final String text; + + @override + TestTextState createState() => TestTextState(); +} + +class TestTextState extends State<TestText> { + late TextStyle textStyle; + + @override + Widget build(BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return Text(widget.text); + } +} + +void main() { + Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(ExpansionTile), matching: find.byType(Material)), + ); + } + + test('ExpansionTileThemeData copyWith, ==, hashCode basics', () { + expect(const ExpansionTileThemeData(), const ExpansionTileThemeData().copyWith()); + expect( + const ExpansionTileThemeData().hashCode, + const ExpansionTileThemeData().copyWith().hashCode, + ); + }); + + test('ExpansionTileThemeData lerp special cases', () { + expect(ExpansionTileThemeData.lerp(null, null, 0), null); + const data = ExpansionTileThemeData(); + expect(identical(ExpansionTileThemeData.lerp(data, data, 0.5), data), true); + }); + + test('ExpansionTileThemeData defaults', () { + const theme = ExpansionTileThemeData(); + expect(theme.backgroundColor, null); + expect(theme.collapsedBackgroundColor, null); + expect(theme.tilePadding, null); + expect(theme.expandedAlignment, null); + expect(theme.childrenPadding, null); + expect(theme.iconColor, null); + expect(theme.collapsedIconColor, null); + expect(theme.textColor, null); + expect(theme.collapsedTextColor, null); + expect(theme.shape, null); + expect(theme.collapsedShape, null); + expect(theme.clipBehavior, null); + expect(theme.expansionAnimationStyle, null); + }); + + testWidgets('Default ExpansionTileThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TooltipThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ExpansionTileThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ExpansionTileThemeData( + backgroundColor: Color(0xff000000), + collapsedBackgroundColor: Color(0xff6f83fc), + tilePadding: EdgeInsets.all(20.0), + expandedAlignment: Alignment.bottomCenter, + childrenPadding: EdgeInsets.all(10.0), + iconColor: Color(0xffa7c61c), + collapsedIconColor: Color(0xffdd0b1f), + textColor: Color(0xffffffff), + collapsedTextColor: Color(0xff522bab), + shape: Border(), + collapsedShape: Border(), + clipBehavior: Clip.antiAlias, + expansionAnimationStyle: AnimationStyle(curve: Curves.easeInOut), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'backgroundColor: ${const Color(0xff000000)}', + 'collapsedBackgroundColor: ${const Color(0xff6f83fc)}', + 'tilePadding: EdgeInsets.all(20.0)', + 'expandedAlignment: Alignment.bottomCenter', + 'childrenPadding: EdgeInsets.all(10.0)', + 'iconColor: ${const Color(0xffa7c61c)}', + 'collapsedIconColor: ${const Color(0xffdd0b1f)}', + 'textColor: ${const Color(0xffffffff)}', + 'collapsedTextColor: ${const Color(0xff522bab)}', + 'shape: Border.all(BorderSide(width: 0.0, style: none))', + 'collapsedShape: Border.all(BorderSide(width: 0.0, style: none))', + 'clipBehavior: Clip.antiAlias', + 'expansionAnimationStyle: AnimationStyle#983ac(curve: Cubic(0.42, 0.00, 0.58, 1.00))', + ]), + ); + }); + + testWidgets('ExpansionTileTheme - collapsed', (WidgetTester tester) async { + final Key tileKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key iconKey = UniqueKey(); + const Color backgroundColor = Colors.orange; + const Color collapsedBackgroundColor = Colors.red; + const Color iconColor = Colors.green; + const Color collapsedIconColor = Colors.blue; + const Color textColor = Colors.black; + const Color collapsedTextColor = Colors.white; + const ShapeBorder shape = Border( + top: BorderSide(color: Colors.red), + bottom: BorderSide(color: Colors.red), + ); + const ShapeBorder collapsedShape = Border( + top: BorderSide(color: Colors.green), + bottom: BorderSide(color: Colors.green), + ); + const Clip clipBehavior = Clip.antiAlias; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + expansionTileTheme: const ExpansionTileThemeData( + backgroundColor: backgroundColor, + collapsedBackgroundColor: collapsedBackgroundColor, + tilePadding: EdgeInsets.fromLTRB(8, 12, 4, 10), + expandedAlignment: Alignment.centerRight, + childrenPadding: EdgeInsets.all(20.0), + iconColor: iconColor, + collapsedIconColor: collapsedIconColor, + textColor: textColor, + collapsedTextColor: collapsedTextColor, + shape: shape, + collapsedShape: collapsedShape, + clipBehavior: clipBehavior, + ), + ), + home: Material( + child: Center( + child: ExpansionTile( + key: tileKey, + title: TestText('Collapsed Tile', key: titleKey), + trailing: TestIcon(key: iconKey), + children: const <Widget>[Text('Tile 1')], + ), + ), + ), + ), + ); + + // When a custom shape is provided, ExpansionTile will use the + // Material widget to draw the shape and background color + // instead of a Container. + final Material material = getMaterial(tester); + + // ExpansionTile should have Clip.antiAlias as clipBehavior. + expect(material.clipBehavior, clipBehavior); + + // Check the tile's collapsed background color when collapsedBackgroundColor is applied. + expect(material.color, collapsedBackgroundColor); + + final Rect titleRect = tester.getRect(find.text('Collapsed Tile')); + final Rect trailingRect = tester.getRect(find.byIcon(Icons.expand_more)); + final Rect listTileRect = tester.getRect(find.byType(ListTile)); + final tallerWidget = titleRect.height > trailingRect.height ? titleRect : trailingRect; + + // Check the positions of title and trailing Widgets, after padding is applied. + expect(listTileRect.left, titleRect.left - 8); + expect(listTileRect.right, trailingRect.right + 4); + + // Calculate the remaining height of ListTile from the default height. + final double remainingHeight = 56 - tallerWidget.height; + expect(listTileRect.top, tallerWidget.top - remainingHeight / 2 - 12); + expect(listTileRect.bottom, tallerWidget.bottom + remainingHeight / 2 + 10); + + Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!; + Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!; + + // Check the collapsed icon color when iconColor is applied. + expect(getIconColor(), collapsedIconColor); + // Check the collapsed text color when textColor is applied. + expect(getTextColor(), collapsedTextColor); + // Check the collapsed ShapeBorder when shape is applied. + expect(material.shape, collapsedShape); + }); + + testWidgets('ExpansionTileTheme - expanded', (WidgetTester tester) async { + final Key tileKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key iconKey = UniqueKey(); + const Color backgroundColor = Colors.orange; + const Color collapsedBackgroundColor = Colors.red; + const Color iconColor = Colors.green; + const Color collapsedIconColor = Colors.blue; + const Color textColor = Colors.black; + const Color collapsedTextColor = Colors.white; + const ShapeBorder shape = Border( + top: BorderSide(color: Colors.red), + bottom: BorderSide(color: Colors.red), + ); + const ShapeBorder collapsedShape = Border( + top: BorderSide(color: Colors.green), + bottom: BorderSide(color: Colors.green), + ); + const Clip clipBehavior = Clip.none; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + expansionTileTheme: const ExpansionTileThemeData( + backgroundColor: backgroundColor, + collapsedBackgroundColor: collapsedBackgroundColor, + tilePadding: EdgeInsets.fromLTRB(8, 12, 4, 10), + expandedAlignment: Alignment.centerRight, + childrenPadding: EdgeInsets.all(20.0), + iconColor: iconColor, + collapsedIconColor: collapsedIconColor, + textColor: textColor, + collapsedTextColor: collapsedTextColor, + shape: shape, + collapsedShape: collapsedShape, + clipBehavior: clipBehavior, + ), + ), + home: Material( + child: Center( + child: ExpansionTile( + key: tileKey, + initiallyExpanded: true, + title: TestText('Expanded Tile', key: titleKey), + trailing: TestIcon(key: iconKey), + children: const <Widget>[Text('Tile 1')], + ), + ), + ), + ), + ); + + // When a custom shape is provided, ExpansionTile will use the + // Material widget to draw the shape and background color + // instead of a Container. + final Material material = getMaterial(tester); + // Check the tile's background color when backgroundColor is applied. + expect(material.color, backgroundColor); + + final Rect titleRect = tester.getRect(find.text('Expanded Tile')); + final Rect trailingRect = tester.getRect(find.byIcon(Icons.expand_more)); + final Rect listTileRect = tester.getRect(find.byType(ListTile)); + final tallerWidget = titleRect.height > trailingRect.height ? titleRect : trailingRect; + + // Check the positions of title and trailing Widgets, after padding is applied. + expect(listTileRect.left, titleRect.left - 8); + expect(listTileRect.right, trailingRect.right + 4); + + // Calculate the remaining height of ListTile from the default height. + final double remainingHeight = 56 - tallerWidget.height; + expect(listTileRect.top, tallerWidget.top - remainingHeight / 2 - 12); + expect(listTileRect.bottom, tallerWidget.bottom + remainingHeight / 2 + 10); + + Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!; + Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!; + + // Check the expanded icon color when iconColor is applied. + expect(getIconColor(), iconColor); + // Check the expanded text color when textColor is applied. + expect(getTextColor(), textColor); + // Check the expanded ShapeBorder when shape is applied. + expect(material.shape, shape); + // Check the clipBehavior when shape is applied. + expect(material.clipBehavior, clipBehavior); + + // Check the child position when expandedAlignment is applied. + final Rect childRect = tester.getRect(find.text('Tile 1')); + expect(childRect.right, 800 - 20); + expect(childRect.left, 800 - childRect.width - 20); + + // Check the child padding when childrenPadding is applied. + final Rect paddingRect = tester.getRect(find.byType(Padding).last); + expect(childRect.top, paddingRect.top + 20); + expect(childRect.left, paddingRect.left + 20); + expect(childRect.right, paddingRect.right - 20); + expect(childRect.bottom, paddingRect.bottom - 20); + }); + + testWidgets('Override ExpansionTile animation using ExpansionTileThemeData.AnimationStyle', ( + WidgetTester tester, + ) async { + const expansionTileKey = Key('expansionTileKey'); + + Widget buildExpansionTile({AnimationStyle? animationStyle}) { + return MaterialApp( + theme: ThemeData( + expansionTileTheme: ExpansionTileThemeData(expansionAnimationStyle: animationStyle), + ), + home: const Material( + child: Center( + child: ExpansionTile( + key: expansionTileKey, + title: TestText('title'), + children: <Widget>[SizedBox(height: 100, width: 100)], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildExpansionTile()); + + double getHeight(Key key) => tester.getSize(find.byKey(key)).height; + + // Test initial ExpansionTile height. + expect(getHeight(expansionTileKey), 58.0); + + // Test the default expansion animation. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation duration. + await tester.pumpWidget( + buildExpansionTile( + animationStyle: const AnimationStyle(duration: Duration(milliseconds: 800)), + ), + ); + await tester.pumpAndSettle(); + + // Test the overridden animation duration. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(67.4, 0.1)); + + await tester.pump( + const Duration(milliseconds: 200), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(89.6, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pumpAndSettle(); + + // Override the animation curve. + await tester.pumpWidget( + buildExpansionTile(animationStyle: const AnimationStyle(curve: Easing.emphasizedDecelerate)), + ); + await tester.pumpAndSettle(); + + // Test the overridden animation curve. + await tester.tap(find.text('title')); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 1/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(141.2, 0.1)); + + await tester.pump( + const Duration(milliseconds: 50), + ); // Advance the animation by 2/4 of its duration. + + expect(getHeight(expansionTileKey), closeTo(153, 0.1)); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(getHeight(expansionTileKey), 158.0); + + // Tap to collapse the ExpansionTile. + await tester.tap(find.text('title')); + + // Test no animation. + await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle.noAnimation)); + + // Tap to expand the ExpansionTile. + await tester.tap(find.text('title')); + await tester.pump(); + + expect(getHeight(expansionTileKey), 158.0); + }); +} diff --git a/packages/material_ui/test/material/filled_button_test.dart b/packages/material_ui/test/material/filled_button_test.dart new file mode 100644 index 000000000000..91d7e272f312 --- /dev/null +++ b/packages/material_ui/test/material/filled_button_test.dart @@ -0,0 +1,3032 @@ +// 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 '../widgets/semantics_tester.dart'; + +void main() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + Color textColor(WidgetTester tester, String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style!.color!; + } + + testWidgets('FilledButton, FilledButton.icon defaults', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(useMaterial3: false, 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<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(FilledButton)); + final TestGesture gesture = 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<Material>(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('FilledButton.defaultStyle produces a ButtonStyle with appropriate non-null values', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = FilledButton(onPressed: () {}, child: const Text('button')); + BuildContext? capturedContext; + // Enabled FilledButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.side, isNull, reason: 'side style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }); + + testWidgets( + 'FilledButton.defaultStyle with an icon produces a ButtonStyle with appropriate non-null values', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = FilledButton.icon( + onPressed: () {}, + icon: const SizedBox(), + label: const Text('button'), + ); + BuildContext? capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.side, isNull, reason: 'side style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }, + ); + + testWidgets('FilledButton.icon produces the correct widgets if icon is null', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + 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'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.text('label'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton.icon( + key: iconButtonKey, + onPressed: () {}, + // No icon specified. + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsNothing); + expect(find.text('label'), findsOneWidget); + }); + + testWidgets('FilledButton.tonalIcon produces the correct widgets if icon is null', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + 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'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.text('label'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton.tonalIcon( + key: iconButtonKey, + onPressed: () {}, + // No icon specified. + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsNothing); + expect(find.text('label'), findsOneWidget); + }); + + testWidgets('FilledButton.tonal, FilledButton.tonalIcon defaults', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final 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<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(FilledButton)); + final TestGesture gesture = 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<Material>(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'Default FilledButton meets a11y contrast guidelines', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + 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 default overlayColor and elevation resolve pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return FilledButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('FilledButton'), + ); + }, + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + double elevation() { + return tester + .widget<PhysicalShape>( + find.descendant(of: find.byType(FilledButton), matching: find.byType(PhysicalShape)), + ) + .elevation; + } + + // 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(elevation(), 1.0); + expect(overlayColor(), paints..rect(color: theme.colorScheme.onPrimary.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect(elevation(), 0.0); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.onPrimary.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(elevation(), 0.0); + expect(overlayColor(), paints..rect(color: theme.colorScheme.onPrimary.withOpacity(0.1))); + focusNode.dispose(); + }); + + testWidgets('FilledButton.tonal default overlayColor and elevation resolve pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return FilledButton.tonal( + onPressed: () {}, + focusNode: focusNode, + child: const Text('FilledButton'), + ); + }, + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + double elevation() { + return tester + .widget<PhysicalShape>( + find.descendant(of: find.byType(FilledButton), matching: find.byType(PhysicalShape)), + ) + .elevation; + } + + // 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(elevation(), 1.0); + expect( + overlayColor(), + paints..rect(color: theme.colorScheme.onSecondaryContainer.withOpacity(0.08)), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect(elevation(), 0.0); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.onSecondaryContainer.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(elevation(), 0.0); + expect( + overlayColor(), + paints..rect(color: theme.colorScheme.onSecondaryContainer.withOpacity(0.1)), + ); + focusNode.dispose(); + }); + + testWidgets('FilledButton uses stateful color for text color in different states', ( + WidgetTester tester, + ) async { + const buttonText = 'FilledButton'; + final focusNode = FocusNode(); + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FilledButtonTheme( + data: FilledButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return FilledButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text(buttonText), + ); + }, + ), + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(textColor(tester, buttonText), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), 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(tester, buttonText), 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(tester, buttonText), pressedColor); + focusNode.dispose(); + }); + + testWidgets('FilledButton uses stateful color for icon color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FilledButtonTheme( + data: FilledButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + iconColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return FilledButton.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('FilledButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(iconStyle(tester, Icons.add).color, equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, pressedColor); + focusNode.dispose(); + }); + + 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>(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>(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>(filledButton).enabled, false); + }, + ); + + testWidgets('FilledButton onPressed and onLongPress callbacks are distinctly recognized', ( + WidgetTester tester, + ) async { + var didPressButton = false; + var 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>(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(debugLabel: 'FilledButton Focus'); + final GlobalKey childKey = GlobalKey(); + var hovering = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 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.square( + dimension: 100, + child: FilledButton( + focusNode: focusNode, + onHover: (bool value) { + hovering = value; + }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + focusNode.dispose(); + }); + + testWidgets('disabled and hovered FilledButton responds to mouse-exit', ( + WidgetTester tester, + ) async { + var onHoverCount = 0; + late bool hover; + + Widget buildFrame({required bool enabled}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 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 node = FocusNode(debugLabel: 'FilledButton Focus'); + var 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); + node.dispose(); + }); + + testWidgets('When FilledButton disable, Can not set FilledButton focus.', ( + WidgetTester tester, + ) async { + final node = FocusNode(debugLabel: 'FilledButton Focus'); + var 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); + node.dispose(); + }); + + testWidgets('Does FilledButton work with hover', (WidgetTester tester) async { + const hoverColor = Color(0xff001122); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.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 focusColor = Color(0xff001122); + + final focusNode = FocusNode(debugLabel: 'FilledButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.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)); + focusNode.dispose(); + }); + + testWidgets('Does FilledButton work with autofocus', (WidgetTester tester) async { + const focusColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.focused) ? focusColor : null; + } + + final focusNode = FocusNode(debugLabel: 'FilledButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + autofocus: true, + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(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)); + focusNode.dispose(); + }); + + testWidgets('Does FilledButton contribute semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: 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>(Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + 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>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('FilledButton size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + const 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>(Size(88, 36)), + ); + + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(useMaterial3: false, 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('test'); + const childKey = Key('test child'); + + Future<void> buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + 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>(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 buttonKey = Key('test'); + const 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>(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)); + + Matcher closeOnWeb(num value) { + return kIsWeb ? closeTo(value, 1e-2) : equals(value); + } + + // 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, equals(labelRect.right + 10)); + expect(paddingRect.left, equals(iconRect.left - 16)); + // Use the taller widget to check the top and bottom padding. + final tallerWidget = iconRect.height > labelRect.height ? iconRect : labelRect; + expect(paddingRect.top, closeOnWeb(tallerWidget.top - 6.5)); + expect(paddingRect.bottom, closeOnWeb(tallerWidget.bottom + 13.5)); + }); + + group('Default FilledButton padding for textScaleFactor, textDirection', () { + const buttonKey = ValueKey<String>('button'); + const labelKey = ValueKey<String>('label'); + const iconKey = ValueKey<String>('icon'); + + const textScaleFactorOptions = <double>[0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const textDirectionOptions = <TextDirection>[TextDirection.ltr, TextDirection.rtl]; + const iconOptions = <Widget?>[null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final paddingWithoutIconStart = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final paddingWithoutIconEnd = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final paddingWithIconStart = <double, double>{ + 0.5: 12, + 1: 12, + 1.25: 11, + 1.5: 10, + 2: 8, + 2.5: 8, + 3: 8, + 4: 8, + }; + final paddingWithIconEnd = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final paddingWithIconGap = <double, double>{ + 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 textScaleFactor in textScaleFactorOptions) { + for (final textDirection in textDirectionOptions) { + for (final icon in iconOptions) { + final String testName = <String>[ + '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( + useMaterial3: false, + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom(minimumSize: const Size(64, 36)), + ), + ), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: 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 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<RenderBox>(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox? iconRenderBox = icon == null + ? null + : tester.renderObject<RenderBox>(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<RenderBox>( + 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<RenderBox>( + 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.withClampedTextScaling( + minScaleFactor: 2, + maxScaleFactor: 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<Padding>( + find.descendant(of: find.byType(FilledButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); + + testWidgets('Override theme fontSize changes padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + textTheme: const TextTheme(labelLarge: TextStyle(fontSize: 28.0)), + ), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: FilledButton(onPressed: () {}, child: const Text('text')), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(FilledButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 12)); + }); + + testWidgets('M3 FilledButton has correct padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: FilledButton(key: key, onPressed: () {}, child: const Text('FilledButton')), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 24)); + }); + + testWidgets('M3 FilledButton.icon has correct padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: FilledButton.icon( + key: key, + icon: const Icon(Icons.favorite), + onPressed: () {}, + label: const Text('FilledButton'), + ), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsetsDirectional.fromSTEB(16.0, 0.0, 24.0, 0.0)); + }); + + testWidgets('By default, FilledButton shape outline is defined by shape.side', ( + WidgetTester tester, + ) async { + const borderColor = Color(0xff4caf50); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + 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: <Widget>[ + 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 theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + 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(useMaterial3: false), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + FilledButton( + key: key0, + style: FilledButton.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: FilledButton.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: <Widget>[ + 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), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // 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('FilledButton in SelectionArea changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/104595. + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + child: FilledButton( + style: FilledButton.styleFrom( + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.grab, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Text))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + 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(width: 10, color: Color(0xff00ff00)); + await tester.pumpWidget(buildFrame(side: borderSide)); + expect( + tester.widget<InkWell>(find.byType(InkWell)).customBorder, + const RoundedRectangleBorder(side: borderSide), + ); + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect( + tester.widget<InkWell>(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<Material>( + find.descendant(of: find.byType(FilledButton), matching: find.byType(Material)), + ); + expect(material.color, Colors.purple); + expect(material.textStyle!.color, Colors.white); + }); + + Future<void> testStatesController(Widget? icon, WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: icon == null + ? FilledButton( + statesController: controller, + onPressed: () {}, + child: const Text('button'), + ) + : FilledButton.icon( + statesController: controller, + onPressed: () {}, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + + expect(controller.value, <WidgetState>{}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(Text)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.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: icon == null + ? FilledButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ) + : FilledButton.icon( + statesController: controller, + onPressed: null, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.disabled}); + expect(count, 10); // removes pressed and adds disabled - two changes + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 11); + await gesture.removePointer(); + } + + testWidgets('FilledButton statesController', (WidgetTester tester) async { + await testStatesController(null, tester); + }); + + testWidgets('FilledButton.icon statesController', (WidgetTester tester) async { + await testStatesController(const Icon(Icons.add), tester); + }); + + testWidgets('Disabled FilledButton statesController', (WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: FilledButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 1); + }); + + testWidgets('FilledButton backgroundBuilder and foregroundBuilder', (WidgetTester tester) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: backgroundColor), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: foregroundColor), + child: child, + ); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget<DecoratedBox>(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget<Text>(find.descendant(of: finder, matching: find.byType(Text))); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + testWidgets( + 'FilledButton backgroundBuilder drops button child and foregroundBuilder return value', + (WidgetTester tester) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: backgroundColor)); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }, + ); + + testWidgets('FilledButton foregroundBuilder drops button child', (WidgetTester tester) async { + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: FilledButton.styleFrom( + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('FilledButton foreground and background builders are applied to the correct states', ( + WidgetTester tester, + ) async { + var foregroundStates = <WidgetState>{}; + var backgroundStates = <WidgetState>{}; + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FilledButton( + style: ButtonStyle( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const focusedStates = <WidgetState>{WidgetState.focused}; + const focusedHoveredStates = <WidgetState>{WidgetState.focused, WidgetState.hovered}; + const focusedHoveredPressedStates = <WidgetState>{ + WidgetState.focused, + WidgetState.hovered, + WidgetState.pressed, + }; + + bool sameStates(Set<WidgetState> expectedValue, Set<WidgetState> actualValue) { + return expectedValue.difference(actualValue).isEmpty && + actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // 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(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + // 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(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }); + + testWidgets('Default FilledButton icon alignment', (WidgetTester tester) async { + Widget buildWidget({required TextDirection textDirection}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + // Test default iconAlignment when textDirection is ltr. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.ltr)); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test default iconAlignment when textDirection is rtl. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.rtl)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + }); + + testWidgets('FilledButton icon alignment can be customized', (WidgetTester tester) async { + Widget buildWidget({ + required TextDirection textDirection, + required IconAlignment iconAlignment, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + iconAlignment: iconAlignment, + ), + ), + ), + ); + } + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.start), + ); + + Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.end), + ); + + Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 24.0, + ); // 24.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.start), + ); + + buttonTopRight = tester.getTopRight(find.byType(Material).last); + iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.end), + ); + + buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 24.0); // 24.0 - padding between icon and button edge. + }); + + testWidgets('FilledButton icon alignment respects ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + home: Center( + child: FilledButton.icon( + style: ButtonStyle(iconAlignment: iconAlignment), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + testWidgets('FilledButton tonal button icon alignment respects ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + home: Center( + child: FilledButton.tonalIcon( + style: ButtonStyle(iconAlignment: iconAlignment), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + testWidgets('Tonal icon default iconAlignment', (WidgetTester tester) async { + Widget buildWidget({required TextDirection textDirection}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: FilledButton.tonalIcon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + // Test default iconAlignment when textDirection is ltr. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.ltr)); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test default iconAlignment when textDirection is rtl. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.rtl)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + }); + + testWidgets('Tonal icon iconAlignment can be customized', (WidgetTester tester) async { + Widget buildWidget({ + required TextDirection textDirection, + required IconAlignment iconAlignment, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: FilledButton.tonalIcon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + iconAlignment: iconAlignment, + ), + ), + ), + ); + } + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.start), + ); + + Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.end), + ); + + Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 24.0, + ); // 24.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.start), + ); + + buttonTopRight = tester.getTopRight(find.byType(Material).last); + iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.end), + ); + + buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 24.0); // 24.0 - padding between icon and button edge. + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('FilledButton.styleFrom can customize the button icon', (WidgetTester tester) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: FilledButton.icon( + style: FilledButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + iconAlignment: IconAlignment.end, + disabledIconColor: disabledIconColor, + ), + onPressed: enabled ? () {} : null, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets('FilledButton icon uses provided foregroundColor over default icon color', ( + WidgetTester tester, + ) async { + const foregroundColor = Color(0xFFFF1234); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Column( + children: <Widget>[ + FilledButton.icon( + style: FilledButton.styleFrom(foregroundColor: foregroundColor), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + FilledButton.tonalIcon( + style: FilledButton.styleFrom(foregroundColor: foregroundColor), + onPressed: () {}, + icon: const Icon(Icons.mail), + label: const Text('Button'), + ), + ], + ), + ), + ), + ), + ); + expect(iconStyle(tester, Icons.add).color, foregroundColor); + expect(iconStyle(tester, Icons.mail).color, foregroundColor); + }); + + testWidgets('FilledButton text and icon respect animation duration', (WidgetTester tester) async { + const buttonText = 'Button'; + const IconData buttonIcon = Icons.add; + const hoveredColor = Color(0xFFFF0000); + const idleColor = Color(0xFF000000); + + Widget buildButton({Duration? animationDuration}) { + return MaterialApp( + home: Material( + child: Center( + child: FilledButton.icon( + style: ButtonStyle( + animationDuration: animationDuration, + iconColor: const WidgetStateProperty<Color>.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }), + foregroundColor: const WidgetStateProperty<Color>.fromMap( + <WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }, + ), + ), + onPressed: () {}, + icon: const Icon(buttonIcon), + label: const Text(buttonText), + ), + ), + ), + ); + } + + // Test default animation duration. + await tester.pumpWidget(buildButton()); + + expect(textColor(tester, buttonText), idleColor); + expect(iconStyle(tester, buttonIcon).color, idleColor); + + final Offset buttonCenter = tester.getCenter(find.text(buttonText)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + + await gesture.removePointer(); + + // Test custom animation duration. + await tester.pumpWidget(buildButton(animationDuration: const Duration(seconds: 2))); + await tester.pumpAndSettle(); + + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + }); + + testWidgets('FilledButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: FilledButton(onPressed: () {}, child: const Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(FilledButton)), Size.zero); + }); + + testWidgets('When a FilledButton gains an icon, preserves the same SemanticsNode id', ( + WidgetTester tester, + ) async { + var toggled = false; + const key = Key('button'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Row( + children: <Widget>[ + FilledButton.icon( + key: key, + onPressed: () { + setState(() { + toggled = true; + }); + }, + icon: toggled ? const Icon(Icons.favorite) : null, + label: const Text('Button'), + ), + ], + ); + }, + ), + ), + ), + ); + + // Initially, no icons are present. + expect(find.byIcon(Icons.favorite), findsNothing); + + // Find the original FilledButton with no icon and get its SemanticsNode. + final Finder filledButton = find.bySemanticsLabel('Button'); + expect(filledButton, findsOneWidget); + + final SemanticsNode origSemanticsNode = tester.getSemantics(filledButton); + + // Tap the button. It should receive an icon now. + await tester.tap(filledButton); + await tester.pump(); + + // Now one icon should be present. + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // Check if the semantics has change. + final SemanticsNode semanticsNodeWithIcon = tester.getSemantics(filledButton); + + expect(semanticsNodeWithIcon, origSemanticsNode); + }); + + testWidgets('When a filled tonal button gains an icon, preserves the same SemanticsNode id', ( + WidgetTester tester, + ) async { + var toggled = false; + const key = Key('button'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Row( + children: <Widget>[ + FilledButton.tonalIcon( + key: key, + onPressed: () { + setState(() { + toggled = true; + }); + }, + icon: toggled ? const Icon(Icons.favorite) : null, + label: const Text('Button'), + ), + ], + ); + }, + ), + ), + ), + ); + + // Initially, no icons are present. + expect(find.byIcon(Icons.favorite), findsNothing); + + // Find the original button with no icon and get its SemanticsNode. + final Finder filledTonalButton = find.bySemanticsLabel('Button'); + expect(filledTonalButton, findsOneWidget); + + final SemanticsNode origSemanticsNode = tester.getSemantics(filledTonalButton); + + // Tap the button. It should receive an icon now. + await tester.tap(filledTonalButton); + await tester.pump(); + + // Now one icon should be present. + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // Check if the semantics has change. + final SemanticsNode semanticsNodeWithIcon = tester.getSemantics(filledTonalButton); + + expect(semanticsNodeWithIcon, origSemanticsNode); + }); + + testWidgets('FilledButton.icon does not lose focus when icon is nullified', ( + WidgetTester tester, + ) async { + Widget buildButton({required Widget? icon}) { + return MaterialApp( + home: Center( + child: FilledButton.icon(onPressed: () {}, icon: icon, label: const Text('button')), + ), + ); + } + + // Build once with an icon. + await tester.pumpWidget(buildButton(icon: const Icon(Icons.abc))); + + FocusNode getButtonFocusNode() { + return Focus.of(tester.element(find.text('button'))); + } + + getButtonFocusNode().requestFocus(); + await tester.pumpAndSettle(); + expect(getButtonFocusNode().hasFocus, true); + + // Rebuild without icon. + await tester.pumpWidget(buildButton(icon: null)); + + // The button should still be focused. + expect(getButtonFocusNode().hasFocus, true); + }); + + testWidgets('FilledButton.tonalIcon does not lose focus when icon is nullified', ( + WidgetTester tester, + ) async { + Widget buildButton({required Widget? icon}) { + return MaterialApp( + home: Center( + child: FilledButton.tonalIcon(onPressed: () {}, icon: icon, label: const Text('button')), + ), + ); + } + + // Build once with an icon. + await tester.pumpWidget(buildButton(icon: const Icon(Icons.abc))); + + FocusNode getButtonFocusNode() { + return Focus.of(tester.element(find.text('button'))); + } + + getButtonFocusNode().requestFocus(); + await tester.pumpAndSettle(); + expect(getButtonFocusNode().hasFocus, true); + + // Rebuild without icon. + await tester.pumpWidget(buildButton(icon: null)); + + // The button should still be focused. + expect(getButtonFocusNode().hasFocus, true); + }); +} diff --git a/packages/material_ui/test/material/filled_button_theme_test.dart b/packages/material_ui/test/material/filled_button_theme_test.dart new file mode 100644 index 000000000000..2ebea18cd1b3 --- /dev/null +++ b/packages/material_ui/test/material/filled_button_theme_test.dart @@ -0,0 +1,410 @@ +// 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() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + test('FilledButtonThemeData lerp special cases', () { + expect(FilledButtonThemeData.lerp(null, null, 0), null); + const data = FilledButtonThemeData(); + expect(identical(FilledButtonThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Passing no FilledButtonTheme returns defaults', (WidgetTester tester) async { + const 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<Material>(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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, TextTheme, FilledButton style overrides]', () { + const foregroundColor = Color(0xff000001); + const backgroundColor = Color(0xff000002); + const disabledForegroundColor = Color(0xff000003); + const disabledBackgroundColor = Color(0xff000004); + const shadowColor = Color(0xff000005); + const double elevation = 1; + const textStyle = TextStyle(fontSize: 12.0); + const padding = EdgeInsets.all(3); + const minimumSize = Size(200, 200); + const 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 animationDuration = Duration(milliseconds: 25); + const 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 enabled = <WidgetState>{}; + const disabled = <WidgetState>{WidgetState.disabled}; + const hovered = <WidgetState>{WidgetState.hovered}; + const focused = <WidgetState>{WidgetState.focused}; + const pressed = <WidgetState>{WidgetState.pressed}; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget<Material>(findMaterial); + final InkWell inkWell = tester.widget<InkWell>(findInkWell); + expect(material.textStyle!.color, foregroundColor); + expect(material.textStyle!.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect( + WidgetStateProperty.resolveAs<MouseCursor>(inkWell.mouseCursor!, enabled), + enabledMouseCursor, + ); + expect( + WidgetStateProperty.resolveAs<MouseCursor>(inkWell.mouseCursor!, disabled), + disabledMouseCursor, + ); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.1)); + expect(inkWell.overlayColor!.resolve(pressed), foregroundColor.withOpacity(0.1)); + 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<Align>( + 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('FilledButton repsects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const 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<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.black); //default + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('FilledButton.icon respects FilledButtonTheme ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + theme: ThemeData( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle(iconAlignment: iconAlignment), + ), + ), + home: Scaffold( + body: Center( + child: FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + await tester.pumpAndSettle(); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + testWidgets('Filled tonal button icon respects FilledButtonTheme ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + theme: ThemeData( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle(iconAlignment: iconAlignment), + ), + ), + home: Scaffold( + body: Center( + child: FilledButton.tonalIcon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + await tester.pumpAndSettle(); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets( + 'FilledButton icon uses provided FilledButtonTheme foregroundColor over default icon color', + (WidgetTester tester) async { + const foregroundColor = Color(0xFFFFA500); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom(foregroundColor: foregroundColor), + ), + ), + home: Material( + child: Center( + child: Column( + children: <Widget>[ + FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.mail), + label: const Text('Button'), + ), + ], + ), + ), + ), + ), + ); + + expect(iconStyle(tester, Icons.add).color, foregroundColor); + expect(iconStyle(tester, Icons.mail).color, foregroundColor); + }, + ); +} diff --git a/packages/material_ui/test/material/filter_chip_test.dart b/packages/material_ui/test/material/filter_chip_test.dart new file mode 100644 index 000000000000..bd7ca4cdced1 --- /dev/null +++ b/packages/material_ui/test/material/filter_chip_test.dart @@ -0,0 +1,1382 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; + +/// Adds the basic requirements for a Chip. +Widget wrapForChip({ + required Widget child, + TextDirection textDirection = TextDirection.ltr, + TextScaler textScaler = TextScaler.noScaling, + ThemeData? theme, +}) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material(child: child), + ), + ), + ); +} + +Future<void> pumpCheckmarkChip( + WidgetTester tester, { + required Widget chip, + Color? themeColor, + ThemeData? theme, +}) async { + await tester.pumpWidget( + wrapForChip( + theme: theme, + child: Builder( + builder: (BuildContext context) { + final ChipThemeData chipTheme = ChipTheme.of(context); + return ChipTheme( + data: themeColor == null ? chipTheme : chipTheme.copyWith(checkmarkColor: themeColor), + child: chip, + ); + }, + ), + ), + ); +} + +Widget selectedFilterChip({Color? checkmarkColor}) { + return FilterChip( + label: const Text('InputChip'), + selected: true, + showCheckmark: true, + checkmarkColor: checkmarkColor, + onSelected: (bool _) {}, + ); +} + +void expectCheckmarkColor(Finder finder, Color color) { + expect( + finder, + paints + // Physical model path + ..path() + // The first layer that is painted is the selection overlay. We do not care + // how it is painted but it has to be added it to this pattern so that the + // check mark can be checked next. + ..rrect() + // The second layer that is painted is the check mark. + ..path(color: color), + ); +} + +RenderBox getMaterialBox(WidgetTester tester, Finder type) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: type, matching: find.byType(CustomPaint)), + ); +} + +void checkChipMaterialClipBehavior(WidgetTester tester, Clip clipBehavior) { + final Iterable<Material> materials = tester.widgetList<Material>(find.byType(Material)); + // There should be two Material widgets, first Material is from the "_wrapForChip" and + // last Material is from the "RawChip". + expect(materials.length, 2); + // The last Material from `RawChip` should have the clip behavior. + expect(materials.last.clipBehavior, clipBehavior); +} + +Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(FilterChip), matching: find.byType(Material)), + ); +} + +IconThemeData getIconData(WidgetTester tester) { + final IconTheme iconTheme = tester.firstWidget( + find.descendant(of: find.byType(RawChip), matching: find.byType(IconTheme)), + ); + return iconTheme.data; +} + +DefaultTextStyle getLabelStyle(WidgetTester tester, String labelText) { + return tester.widget( + find.ancestor(of: find.text(labelText), matching: find.byType(DefaultTextStyle)).first, + ); +} + +// Finds any container of a tooltip. +Finder findTooltipContainer(String tooltipText) { + return find.ancestor(of: find.text(tooltipText), matching: find.byType(Container)); +} + +void main() { + testWidgets('Material2 - FilterChip defaults', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + const label = 'filter chip'; + + // Test enabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip(onSelected: (bool valueChanged) {}, label: const Text(label)), + ), + ), + ), + ); + + // Test default chip size. + expect(tester.getSize(find.byType(FilterChip)), const Size(178.0, 48.0)); + + // Test default label style. + expect( + getLabelStyle(tester, label).style.color, + theme.textTheme.bodyLarge!.color!.withAlpha(0xde), + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x1f)); + + // Test disabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: FilterChip(onSelected: null, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black38); + + // Test selected enabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: FilterChip( + selected: true, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x3d)); + + // Test selected disabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: FilterChip(selected: true, onSelected: null, label: Text(label)), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.black); + expect(chipMaterial.shape, const StadiumBorder()); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, Colors.black.withAlpha(0x3d)); + }); + + testWidgets('Material3 - FilterChip defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const label = 'filter chip'; + + // Test enabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip(onSelected: (bool valueChanged) {}, label: const Text(label)), + ), + ), + ), + ); + + // Test default chip size. + expect( + tester.getSize(find.byType(FilterChip)), + within(distance: 0.001, from: const Size(189.1, 48.0)), + ); + // Test default label style. + expect( + getLabelStyle(tester, label).style.color!.value, + theme.colorScheme.onSurfaceVariant.value, + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.transparent); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, null); + + // Test disabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: FilterChip(onSelected: null, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, Colors.transparent); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: theme.colorScheme.onSurface.withOpacity(0.12)), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, null); + + // Test selected enabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: FilterChip( + selected: true, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.secondaryContainer); + + // Test selected disabled FilterChip defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: FilterChip(selected: true, onSelected: null, label: Text(label)), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('Material3 - FilterChip.elevated defaults', (WidgetTester tester) async { + final theme = ThemeData(); + const label = 'filter chip'; + + // Test enabled FilterChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip.elevated( + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ), + ); + + // Test default chip size. + expect( + tester.getSize(find.byType(FilterChip)), + within(distance: 0.001, from: const Size(189.1, 48.0)), + ); + // Test default label style. + expect( + getLabelStyle(tester, 'filter chip').style.color!.value, + theme.colorScheme.onSurfaceVariant.value, + ); + + Material chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 1); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + var decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.surfaceContainerLow); + + // Test disabled FilterChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: FilterChip.elevated(onSelected: null, label: Text(label))), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, theme.colorScheme.shadow); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + + // Test selected enabled FilterChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: FilterChip.elevated( + selected: true, + onSelected: (bool valueChanged) {}, + label: const Text(label), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 1); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.secondaryContainer); + + // Test selected disabled FilterChip.elevated defaults. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: FilterChip.elevated(selected: true, onSelected: null, label: Text(label)), + ), + ), + ); + await tester.pumpAndSettle(); + + chipMaterial = getMaterial(tester); + expect(chipMaterial.elevation, 0); + expect(chipMaterial.shadowColor, null); + expect(chipMaterial.surfaceTintColor, Colors.transparent); + expect( + chipMaterial.shape, + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + side: BorderSide(color: Colors.transparent), + ), + ); + + decoration = tester.widget<Ink>(find.byType(Ink)).decoration! as ShapeDecoration; + expect(decoration.color, theme.colorScheme.onSurface.withOpacity(0.12)); + }); + + testWidgets('FilterChip.color resolves material states', (WidgetTester tester) async { + const disabledSelectedColor = Color(0xffffff00); + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + final WidgetStateProperty<Color?> color = WidgetStateProperty.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return disabledSelectedColor; + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return backgroundColor; + }); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: Column( + children: <Widget>[ + FilterChip( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + color: color, + label: const Text('FilterChip'), + ), + FilterChip.elevated( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + color: color, + label: const Text('FilterChip.elevated'), + ), + ], + ), + ); + } + + // Test enabled state. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled FilterChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: backgroundColor), + ); + // Enabled elevated FilterChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: backgroundColor), + ); + + // Test disabled state. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled FilterChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: disabledColor)); + // Disabled elevated FilterChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: disabledColor)); + + // Test enabled & selected state. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected FilterChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: selectedColor)); + // Enabled & selected elevated FilterChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: selectedColor)); + + // Test disabled & selected state. + await tester.pumpWidget(buildApp(enabled: false, selected: true)); + await tester.pumpAndSettle(); + + // Disabled & selected FilterChip should have the provided disabledSelectedColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: disabledSelectedColor), + ); + // Disabled & selected elevated FilterChip should have the + // provided disabledSelectedColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: disabledSelectedColor), + ); + }); + + testWidgets('FilterChip uses provided state color properties', (WidgetTester tester) async { + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: Column( + children: <Widget>[ + FilterChip( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + label: const Text('FilterChip'), + ), + FilterChip.elevated( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + label: const Text('FilterChip.elevated'), + ), + ], + ), + ); + } + + // Test enabled state. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled FilterChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).first), + paints..rrect(color: backgroundColor), + ); + // Enabled elevated FilterChip should have the provided backgroundColor. + expect( + getMaterialBox(tester, find.byType(RawChip).last), + paints..rrect(color: backgroundColor), + ); + + // Test disabled state. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled FilterChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: disabledColor)); + // Disabled elevated FilterChip should have the provided disabledColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: disabledColor)); + + // Test enabled & selected state. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected FilterChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).first), paints..rrect(color: selectedColor)); + // Enabled & selected elevated FilterChip should have the provided selectedColor. + expect(getMaterialBox(tester, find.byType(RawChip).last), paints..rrect(color: selectedColor)); + }); + + testWidgets('FilterChip can be tapped', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: FilterChip(onSelected: (bool valueChanged) {}, label: const Text('filter chip')), + ), + ), + ); + + await tester.tap(find.byType(FilterChip)); + expect(tester.takeException(), null); + }); + + testWidgets( + 'Material2 - Filter chip check mark color is determined by platform brightness when light', + (WidgetTester tester) async { + await pumpCheckmarkChip( + tester, + chip: selectedFilterChip(), + theme: ThemeData(useMaterial3: false), + ); + + expectCheckmarkColor(find.byType(FilterChip), Colors.black.withAlpha(0xde)); + }, + ); + + testWidgets( + 'Material3 - Filter chip check mark color is determined by platform brightness when light', + (WidgetTester tester) async { + final theme = ThemeData(); + await pumpCheckmarkChip(tester, chip: selectedFilterChip(), theme: theme); + + expectCheckmarkColor(find.byType(FilterChip), theme.colorScheme.onSecondaryContainer); + }, + ); + + testWidgets( + 'Material2 - Filter chip check mark color is determined by platform brightness when dark', + (WidgetTester tester) async { + await pumpCheckmarkChip( + tester, + chip: selectedFilterChip(), + theme: ThemeData.dark(useMaterial3: false), + ); + + expectCheckmarkColor(find.byType(FilterChip), Colors.white.withAlpha(0xde)); + }, + ); + + testWidgets( + 'Material3 - Filter chip check mark color is determined by platform brightness when dark', + (WidgetTester tester) async { + final theme = ThemeData(brightness: Brightness.dark); + await pumpCheckmarkChip(tester, chip: selectedFilterChip(), theme: theme); + + expectCheckmarkColor(find.byType(FilterChip), theme.colorScheme.onSecondaryContainer); + }, + ); + + testWidgets('Filter chip check mark color can be set by the chip theme', ( + WidgetTester tester, + ) async { + await pumpCheckmarkChip( + tester, + chip: selectedFilterChip(), + themeColor: const Color(0xff00ff00), + ); + + expectCheckmarkColor(find.byType(FilterChip), const Color(0xff00ff00)); + }); + + testWidgets('Filter chip check mark color can be set by the chip constructor', ( + WidgetTester tester, + ) async { + await pumpCheckmarkChip( + tester, + chip: selectedFilterChip(checkmarkColor: const Color(0xff00ff00)), + ); + + expectCheckmarkColor(find.byType(FilterChip), const Color(0xff00ff00)); + }); + + testWidgets( + 'Filter chip check mark color is set by chip constructor even when a theme color is specified', + (WidgetTester tester) async { + await pumpCheckmarkChip( + tester, + chip: selectedFilterChip(checkmarkColor: const Color(0xffff0000)), + themeColor: const Color(0xff00ff00), + ); + + expectCheckmarkColor(find.byType(FilterChip), const Color(0xffff0000)); + }, + ); + + testWidgets('FilterChip clipBehavior properly passes through to the Material', ( + WidgetTester tester, + ) async { + const label = Text('label'); + await tester.pumpWidget( + wrapForChip( + child: FilterChip(label: label, onSelected: (bool b) {}), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.none); + + await tester.pumpWidget( + wrapForChip( + child: FilterChip(label: label, onSelected: (bool b) {}, clipBehavior: Clip.antiAlias), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.antiAlias); + }); + + testWidgets('Material3 - width should not change with selection', (WidgetTester tester) async { + // Regression tests for: https://github.com/flutter/flutter/issues/110645 + + // For the text "FilterChip" the chip should default to 175 regardless of selection. + const expectedWidth = 175; + + // Unselected + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: FilterChip( + label: const Text('FilterChip'), + showCheckmark: false, + onSelected: (bool _) {}, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(FilterChip)).width, expectedWidth); + + // Selected + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: FilterChip( + label: const Text('FilterChip'), + showCheckmark: false, + selected: true, + onSelected: (bool _) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(FilterChip)).width, expectedWidth); + }); + + testWidgets('FilterChip uses provided iconTheme', (WidgetTester tester) async { + final theme = ThemeData(); + + Widget buildChip({IconThemeData? iconTheme}) { + return MaterialApp( + theme: theme, + home: Material( + child: FilterChip( + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const Text('FilterChip'), + onSelected: (bool _) {}, + ), + ), + ); + } + + // Test default icon theme. + await tester.pumpWidget(buildChip()); + + expect(getIconData(tester).color, theme.colorScheme.primary); + + // Test provided icon theme. + await tester.pumpWidget(buildChip(iconTheme: const IconThemeData(color: Color(0xff00ff00)))); + + expect(getIconData(tester).color, const Color(0xff00ff00)); + }); + + testWidgets('Material3 - FilterChip supports delete button', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip( + onDeleted: () {}, + onSelected: (bool valueChanged) {}, + label: const Text('FilterChip'), + ), + ), + ), + ), + ); + + // Test the chip size with delete button. + expect(find.text('FilterChip'), findsOneWidget); + expect(tester.getSize(find.byType(FilterChip)), const Size(195.0, 48.0)); + + // Test the delete button icon. + expect(tester.getSize(find.byIcon(Icons.clear)), const Size(18.0, 18.0)); + expect(getIconData(tester).color, theme.colorScheme.onSurfaceVariant); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip.elevated( + onDeleted: () {}, + onSelected: (bool valueChanged) {}, + label: const Text('Elevated FilterChip'), + ), + ), + ), + ), + ); + + // Test the elevated chip size with delete button. + expect(find.text('Elevated FilterChip'), findsOneWidget); + expect( + tester.getSize(find.byType(FilterChip)), + within(distance: 0.001, from: const Size(321.9, 48.0)), + ); + + // Test the delete button icon. + expect(tester.getSize(find.byIcon(Icons.clear)), const Size(18.0, 18.0)); + expect(getIconData(tester).color, theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('Material2 - FilterChip supports delete button', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip( + onDeleted: () {}, + onSelected: (bool valueChanged) {}, + label: const Text('FilterChip'), + ), + ), + ), + ), + ); + + // Test the chip size with delete button. + expect(find.text('FilterChip'), findsOneWidget); + expect(tester.getSize(find.byType(FilterChip)), const Size(188.0, 48.0)); + + // Test the delete button icon. + expect(tester.getSize(find.byIcon(Icons.cancel)), const Size(18.0, 18.0)); + expect(getIconData(tester).color, theme.iconTheme.color?.withAlpha(0xde)); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: FilterChip.elevated( + onDeleted: () {}, + onSelected: (bool valueChanged) {}, + label: const Text('Elevated FilterChip'), + ), + ), + ), + ), + ); + + // Test the elevated chip size with delete button. + expect(find.text('Elevated FilterChip'), findsOneWidget); + expect(tester.getSize(find.byType(FilterChip)), const Size(314.0, 48.0)); + + // Test the delete button icon. + expect(tester.getSize(find.byIcon(Icons.cancel)), const Size(18.0, 18.0)); + expect(getIconData(tester).color, theme.iconTheme.color?.withAlpha(0xde)); + }); + + testWidgets('Customize FilterChip delete button', (WidgetTester tester) async { + Widget buildChip({ + Widget? deleteIcon, + Color? deleteIconColor, + String? deleteButtonTooltipMessage, + }) { + return MaterialApp( + home: Material( + child: Center( + child: FilterChip( + deleteIcon: deleteIcon, + deleteIconColor: deleteIconColor, + deleteButtonTooltipMessage: deleteButtonTooltipMessage, + onDeleted: () {}, + onSelected: (bool valueChanged) {}, + label: const Text('FilterChip'), + ), + ), + ), + ); + } + + // Test the custom delete icon. + await tester.pumpWidget(buildChip(deleteIcon: const Icon(Icons.delete))); + + expect(find.byIcon(Icons.clear), findsNothing); + expect(find.byIcon(Icons.delete), findsOneWidget); + + // Test the custom delete icon color. + await tester.pumpWidget( + buildChip(deleteIcon: const Icon(Icons.delete), deleteIconColor: const Color(0xff00ff00)), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.clear), findsNothing); + expect(find.byIcon(Icons.delete), findsOneWidget); + expect(getIconData(tester).color, const Color(0xff00ff00)); + + // Test the custom delete button tooltip message. + await tester.pumpWidget(buildChip(deleteButtonTooltipMessage: 'Delete FilterChip')); + await tester.pumpAndSettle(); + + // Hover over the delete icon of the chip + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byIcon(Icons.clear)), + ); + + await tester.pumpAndSettle(); + + // Verify the tooltip message is set. + expect(find.widgetWithText(Tooltip, 'Delete FilterChip'), findsOneWidget); + + await gesture.up(); + }); + + testWidgets('FilterChip delete button control test', (WidgetTester tester) async { + final feedback = FeedbackTester(); + final deletedButtonStrings = <String>[]; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: FilterChip( + onDeleted: () { + deletedButtonStrings.add('A'); + }, + onSelected: (bool valueChanged) {}, + label: const Text('FilterChip'), + ), + ), + ), + ), + ); + + expect(feedback.clickSoundCount, 0); + + expect(deletedButtonStrings, isEmpty); + await tester.tap(find.byIcon(Icons.clear)); + expect(deletedButtonStrings, equals(<String>['A'])); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + + await tester.tap(find.byIcon(Icons.clear)); + expect(deletedButtonStrings, equals(<String>['A', 'A'])); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + + feedback.dispose(); + }); + + testWidgets('Delete button is visible on disabled FilterChip', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: FilterChip(label: const Text('Label'), onSelected: null, onDeleted: () {}), + ), + ); + + // Delete button should be visible. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('filter_chip.disabled.delete_button.png'), + ); + }); + + testWidgets('Delete button tooltip is not shown on disabled FilterChip', ( + WidgetTester tester, + ) async { + Widget buildChip({bool enabled = true}) { + return wrapForChip( + child: FilterChip( + onSelected: enabled ? (bool value) {} : null, + label: const Text('Label'), + onDeleted: () {}, + ), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildChip()); + + final Offset deleteButtonLocation = tester.getCenter(find.byType(Icon)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(deleteButtonLocation); + await tester.pump(); + + // Delete button tooltip should be visible. + expect(findTooltipContainer('Delete'), findsOneWidget); + + // Test disabled chip. + await tester.pumpWidget(buildChip(enabled: false)); + await tester.pump(); + + // Delete button tooltip should not be visible. + expect(findTooltipContainer('Delete'), findsNothing); + }); + + testWidgets('Material3 - Default FilterChip icon colors', (WidgetTester tester) async { + const IconData flatAvatar = Icons.favorite; + const IconData flatDeleteIcon = Icons.delete; + const IconData elevatedAvatar = Icons.house; + const IconData elevatedDeleteIcon = Icons.clear_all; + final theme = ThemeData(); + + Widget buildChips({required bool selected}) { + return MaterialApp( + theme: theme, + home: Material( + child: Column( + children: <Widget>[ + FilterChip( + avatar: const Icon(flatAvatar), + deleteIcon: const Icon(flatDeleteIcon), + onSelected: (bool valueChanged) {}, + label: const Text('FilterChip'), + selected: selected, + onDeleted: () {}, + ), + FilterChip.elevated( + avatar: const Icon(elevatedAvatar), + deleteIcon: const Icon(elevatedDeleteIcon), + onSelected: (bool valueChanged) {}, + label: const Text('Elevated FilterChip'), + selected: selected, + onDeleted: () {}, + ), + ], + ), + ), + ); + } + + Color getIconColor(WidgetTester tester, IconData icon) { + return tester + .firstWidget<IconTheme>( + find.ancestor(of: find.byIcon(icon), matching: find.byType(IconTheme)), + ) + .data + .color!; + } + + // Test unselected state. + await tester.pumpWidget(buildChips(selected: false)); + // Check the flat chip icon colors. + expect(getIconColor(tester, flatAvatar), theme.colorScheme.primary); + expect(getIconColor(tester, flatDeleteIcon), theme.colorScheme.onSurfaceVariant); + // Check the elevated chip icon colors. + expect(getIconColor(tester, elevatedAvatar), theme.colorScheme.primary); + expect(getIconColor(tester, elevatedDeleteIcon), theme.colorScheme.onSurfaceVariant); + + // Test selected state. + await tester.pumpWidget(buildChips(selected: true)); + // Check the flat chip icon colors. + expect(getIconColor(tester, flatAvatar), theme.colorScheme.onSecondaryContainer); + expect(getIconColor(tester, flatDeleteIcon), theme.colorScheme.onSecondaryContainer); + // Check the elevated chip icon colors. + expect(getIconColor(tester, elevatedAvatar), theme.colorScheme.onSecondaryContainer); + expect(getIconColor(tester, elevatedDeleteIcon), theme.colorScheme.onSecondaryContainer); + }); + + testWidgets('FilterChip avatar layout constraints can be customized', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? avatarBoxConstraints}) { + return wrapForChip( + child: Center( + child: FilterChip( + avatarBoxConstraints: avatarBoxConstraints, + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + onSelected: (bool value) {}, + ), + ), + ); + } + + // Test default avatar layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(FilterChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(FilterChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (labelSize.width / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (labelSize.width / 2) + labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget(buildChip(avatarBoxConstraints: const BoxConstraints.tightForFinite())); + await tester.pump(); + + expect(tester.getSize(find.byType(FilterChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(FilterChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('FilterChip delete icon layout constraints can be customized', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? deleteIconBoxConstraints}) { + return wrapForChip( + child: Center( + child: FilterChip( + deleteIconBoxConstraints: deleteIconBoxConstraints, + onDeleted: () {}, + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + onSelected: (bool value) {}, + ), + ), + ); + } + + // Test default delete icon layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(FilterChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(FilterChip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + Offset chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + final Offset deleteIconCenter = tester.getCenter(find.byIcon(Icons.clear)); + expect(chipTopRight.dx, deleteIconCenter.dx + (labelSize.width / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + Offset labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (labelSize.width / 2) - labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget( + buildChip(deleteIconBoxConstraints: const BoxConstraints.tightForFinite()), + ); + await tester.pump(); + + expect(tester.getSize(find.byType(FilterChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(FilterChip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + expect(chipTopRight.dx, deleteIconCenter.dx + (iconSize / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (iconSize / 2) - labelPadding); + }); + + testWidgets('FilterChip.chipAnimationStyle is passed to RawChip', (WidgetTester tester) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: AnimationStyle.noAnimation, + selectAnimation: const AnimationStyle(duration: Durations.extralong4), + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: FilterChip( + chipAnimationStyle: chipAnimationStyle, + onSelected: (bool value) {}, + label: const Text('FilterChip'), + ), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('Elevated FilterChip.chipAnimationStyle is passed to RawChip', ( + WidgetTester tester, + ) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: AnimationStyle.noAnimation, + selectAnimation: const AnimationStyle(duration: Durations.extralong4), + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: FilterChip.elevated( + chipAnimationStyle: chipAnimationStyle, + onSelected: (bool value) {}, + label: const Text('FilterChip'), + ), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('FilterChip has expected default mouse cursor on hover', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: Center( + child: FilterChip(label: const Text('Chip'), onSelected: (bool value) {}), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('FilterChip mouse cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: FilterChip( + mouseCursor: customCursor, + label: const Text('Chip'), + onSelected: (bool value) {}, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('Mouse cursor resolves in focused/unfocused/disabled states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Chip'); + addTearDown(focusNode.dispose); + + Widget buildChip({required bool enabled}) { + return wrapForChip( + child: Center( + child: FilterChip( + mouseCursor: const WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + WidgetState.focused: SystemMouseCursors.grab, + WidgetState.selected: SystemMouseCursors.click, + WidgetState.any: SystemMouseCursors.basic, + }), + focusNode: focusNode, + label: const Text('Chip'), + onSelected: enabled ? (bool value) {} : null, + ), + ), + ); + } + + // Unfocused case. + await tester.pumpWidget(buildChip(enabled: true)); + final TestGesture gesture1 = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture1.removePointer); + await gesture1.addPointer(location: tester.getCenter(find.text('Chip'))); + await tester.pump(); + await gesture1.moveTo(tester.getCenter(find.text('Chip'))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Focused case. + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Disabled case. + await tester.pumpWidget(buildChip(enabled: false)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); +} diff --git a/packages/material_ui/test/material/flexible_space_bar_collapse_mode_test.dart b/packages/material_ui/test/material/flexible_space_bar_collapse_mode_test.dart new file mode 100644 index 000000000000..765cdce83203 --- /dev/null +++ b/packages/material_ui/test/material/flexible_space_bar_collapse_mode_test.dart @@ -0,0 +1,127 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final Key blockKey = UniqueKey(); +const double expandedAppbarHeight = 250.0; +final Key appbarContainerKey = UniqueKey(); + +void main() { + testWidgets( + 'FlexibleSpaceBar collapse mode none', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: debugDefaultTargetPlatformOverride), + home: Scaffold( + body: CustomScrollView( + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + background: Container(key: appbarContainerKey), + collapseMode: CollapseMode.none, + ), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + + final Finder appbarContainer = find.byKey(appbarContainerKey); + final Offset topBeforeScroll = tester.getTopLeft(appbarContainer); + await slowDrag(tester, blockKey, const Offset(0.0, -100.0)); + final Offset topAfterScroll = tester.getTopLeft(appbarContainer); + + expect(topBeforeScroll.dy, equals(0.0)); + expect(topAfterScroll.dy, equals(0.0)); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.fuchsia}), + ); + + testWidgets( + 'FlexibleSpaceBar collapse mode pin', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: debugDefaultTargetPlatformOverride), + home: Scaffold( + body: CustomScrollView( + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + background: Container(key: appbarContainerKey), + collapseMode: CollapseMode.pin, + ), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + + final Finder appbarContainer = find.byKey(appbarContainerKey); + final Offset topBeforeScroll = tester.getTopLeft(appbarContainer); + await slowDrag(tester, blockKey, const Offset(0.0, -100.0)); + final Offset topAfterScroll = tester.getTopLeft(appbarContainer); + + expect(topBeforeScroll.dy, equals(0.0)); + expect(topAfterScroll.dy, equals(-100.0)); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.fuchsia}), + ); + + testWidgets( + 'FlexibleSpaceBar collapse mode parallax', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: debugDefaultTargetPlatformOverride), + home: Scaffold( + body: CustomScrollView( + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + pinned: true, + flexibleSpace: FlexibleSpaceBar(background: Container(key: appbarContainerKey)), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + + final Finder appbarContainer = find.byKey(appbarContainerKey); + final Offset topBeforeScroll = tester.getTopLeft(appbarContainer); + await slowDrag(tester, blockKey, const Offset(0.0, -100.0)); + final Offset topAfterScroll = tester.getTopLeft(appbarContainer); + + expect(topBeforeScroll.dy, equals(0.0)); + expect(topAfterScroll.dy, lessThan(10.0)); + expect(topAfterScroll.dy, greaterThan(-50.0)); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.fuchsia}), + ); +} + +Future<void> slowDrag(WidgetTester tester, Key widget, Offset offset) async { + final Offset target = tester.getCenter(find.byKey(widget)); + final TestGesture gesture = await tester.startGesture(target); + await gesture.moveBy(offset); + await tester.pump(const Duration(milliseconds: 10)); + await gesture.up(); +} diff --git a/packages/material_ui/test/material/flexible_space_bar_stretch_mode_test.dart b/packages/material_ui/test/material/flexible_space_bar_stretch_mode_test.dart new file mode 100644 index 000000000000..e35637c9d48b --- /dev/null +++ b/packages/material_ui/test/material/flexible_space_bar_stretch_mode_test.dart @@ -0,0 +1,165 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final Key blockKey = UniqueKey(); +const double expandedAppbarHeight = 250.0; +final Key finderKey = UniqueKey(); + +void main() { + testWidgets('FlexibleSpaceBar stretch mode default zoomBackground', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + pinned: true, + stretch: true, + flexibleSpace: FlexibleSpaceBar(background: Container(key: finderKey)), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + + // Scrolling up into the overscroll area causes the appBar to expand in size. + // This overscroll effect enlarges the background in step with the appbar. + final Finder appbarContainer = find.byKey(finderKey); + final Size sizeBeforeScroll = tester.getSize(appbarContainer); + await slowDrag(tester, blockKey, const Offset(0.0, 100.0)); + final Size sizeAfterScroll = tester.getSize(appbarContainer); + + expect(sizeBeforeScroll.height, lessThan(sizeAfterScroll.height)); + }); + + testWidgets('FlexibleSpaceBar stretch mode blurBackground', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + pinned: true, + stretch: true, + flexibleSpace: RepaintBoundary( + child: FlexibleSpaceBar( + stretchModes: const <StretchMode>[StretchMode.blurBackground], + background: Row( + children: <Widget>[ + Expanded(child: Container(color: Colors.red)), + Expanded(child: Container(color: Colors.blue)), + ], + ), + ), + ), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + + // Scrolling up into the overscroll area causes the background to blur. + await slowDrag(tester, blockKey, const Offset(0.0, 100.0)); + await expectLater( + find.byType(FlexibleSpaceBar), + matchesGoldenFile('flexible_space_bar_stretch_mode.blur_background.png'), + ); + }); + + testWidgets('FlexibleSpaceBar stretch mode fadeTitle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + physics: const BouncingScrollPhysics(), + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + pinned: true, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + stretchModes: const <StretchMode>[StretchMode.fadeTitle], + title: Text('Title', key: finderKey), + ), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + await slowDrag(tester, blockKey, const Offset(0.0, 10.0)); + Opacity opacityWidget = tester.widget<Opacity>( + find.ancestor(of: find.text('Title'), matching: find.byType(Opacity)).first, + ); + expect(opacityWidget.opacity.round(), equals(1)); + await slowDrag(tester, blockKey, const Offset(0.0, 100.0)); + opacityWidget = tester.widget<Opacity>( + find.ancestor(of: find.text('Title'), matching: find.byType(Opacity)).first, + ); + expect(opacityWidget.opacity, equals(0.0)); + }); + + testWidgets('FlexibleSpaceBar stretch mode ignored for non-overscroll physics', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + physics: const ClampingScrollPhysics(), + key: blockKey, + slivers: <Widget>[ + SliverAppBar( + expandedHeight: expandedAppbarHeight, + stretch: true, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + stretchModes: const <StretchMode>[StretchMode.blurBackground], + background: Container(key: finderKey), + ), + ), + SliverToBoxAdapter(child: Container(height: 10000.0)), + ], + ), + ), + ), + ); + + final Finder appbarContainer = find.byKey(finderKey); + final Size sizeBeforeScroll = tester.getSize(appbarContainer); + await slowDrag(tester, blockKey, const Offset(0.0, 100.0)); + final Size sizeAfterScroll = tester.getSize(appbarContainer); + + expect(sizeBeforeScroll.height, equals(sizeAfterScroll.height)); + }); +} + +Future<void> slowDrag(WidgetTester tester, Key widget, Offset offset) async { + final Offset target = tester.getCenter(find.byKey(widget)); + final TestGesture gesture = await tester.startGesture(target); + await gesture.moveBy(offset); + await tester.pump(const Duration(milliseconds: 10)); + await gesture.up(); +} diff --git a/packages/material_ui/test/material/flexible_space_bar_test.dart b/packages/material_ui/test/material/flexible_space_bar_test.dart new file mode 100644 index 000000000000..b3bcbff2310e --- /dev/null +++ b/packages/material_ui/test/material/flexible_space_bar_test.dart @@ -0,0 +1,1725 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('FlexibleSpaceBar centers title on iOS', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + appBar: AppBar(flexibleSpace: const FlexibleSpaceBar(title: Text('X'))), + ), + ), + ); + + final Finder title = find.text('X'); + Offset center = tester.getCenter(title); + Size size = tester.getSize(title); + expect(center.dx, lessThan(400.0 - size.width / 2.0)); + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + appBar: AppBar(flexibleSpace: const FlexibleSpaceBar(title: Text('X'))), + ), + ), + ); + + center = tester.getCenter(title); + size = tester.getSize(title); + expect(center.dx, greaterThan(400.0 - size.width / 2.0)); + expect(center.dx, lessThan(400.0 + size.width / 2.0)); + } + }); + + testWidgets('Material3 - FlexibleSpaceBarSettings provides settings to a FlexibleSpaceBar', ( + WidgetTester tester, + ) async { + const minExtent = 100.0; + const initExtent = 200.0; + const maxExtent = 300.0; + const alpha = 0.5; + + final customSettings = + FlexibleSpaceBar.createSettings( + currentExtent: initExtent, + minExtent: minExtent, + maxExtent: maxExtent, + toolbarOpacity: alpha, + child: AppBar( + flexibleSpace: const FlexibleSpaceBar( + title: Text('title'), + background: Text('X2'), + collapseMode: CollapseMode.pin, + ), + ), + ) + as FlexibleSpaceBarSettings; + + const dragTarget = Key('orange box'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + key: dragTarget, + primary: true, + slivers: <Widget>[ + SliverPersistentHeader( + floating: true, + pinned: true, + delegate: TestDelegate(settings: customSettings), + ), + SliverToBoxAdapter(child: Container(height: 1200.0, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final RenderBox clipRect = tester.renderObject(find.byType(ClipRect).at(1)); + final Transform transform = tester.firstWidget( + find.descendant(of: find.byType(FlexibleSpaceBar), matching: find.byType(Transform)), + ); + + // The current (200) is half way between the min (100) and max (300) and the + // lerp values used to calculate the scale are 1 and 1.5, so we check for 1.25. + expect(transform.transform.getMaxScaleOnAxis(), 1.25); + + // The space bar rect always starts fully expanded. + expect(clipRect.size.height, maxExtent); + + final Element actionTextBox = tester.element(find.text('title')); + final textWidget = actionTextBox.widget as Text; + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(actionTextBox); + + final TextStyle effectiveStyle = defaultTextStyle.style.merge(textWidget.style); + expect(effectiveStyle.color?.alpha, 128); // Which is alpha of .5 + + // We drag up to fully collapse the space bar. + await tester.drag(find.byKey(dragTarget), const Offset(0, -400.0)); + await tester.pumpAndSettle(); + + expect(clipRect.size.height, minExtent); + }); + + testWidgets('Material2 - FlexibleSpaceBarSettings provides settings to a FlexibleSpaceBar', ( + WidgetTester tester, + ) async { + const minExtent = 100.0; + const initExtent = 200.0; + const maxExtent = 300.0; + const alpha = 0.5; + + final customSettings = + FlexibleSpaceBar.createSettings( + currentExtent: initExtent, + minExtent: minExtent, + maxExtent: maxExtent, + toolbarOpacity: alpha, + child: AppBar( + flexibleSpace: const FlexibleSpaceBar( + title: Text('title'), + background: Text('X2'), + collapseMode: CollapseMode.pin, + ), + ), + ) + as FlexibleSpaceBarSettings; + + const dragTarget = Key('orange box'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + key: dragTarget, + primary: true, + slivers: <Widget>[ + SliverPersistentHeader( + floating: true, + pinned: true, + delegate: TestDelegate(settings: customSettings), + ), + SliverToBoxAdapter(child: Container(height: 1200.0, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final RenderBox clipRect = tester.renderObject(find.byType(ClipRect).first); + final Transform transform = tester.firstWidget( + find.descendant(of: find.byType(FlexibleSpaceBar), matching: find.byType(Transform)), + ); + + // The current (200) is half way between the min (100) and max (300) and the + // lerp values used to calculate the scale are 1 and 1.5, so we check for 1.25. + expect(transform.transform.getMaxScaleOnAxis(), 1.25); + + // The space bar rect always starts fully expanded. + expect(clipRect.size.height, maxExtent); + + final Element actionTextBox = tester.element(find.text('title')); + final textWidget = actionTextBox.widget as Text; + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(actionTextBox); + + final TextStyle effectiveStyle = defaultTextStyle.style.merge(textWidget.style); + expect(effectiveStyle.color?.alpha, 128); // Which is alpha of .5 + + // We drag up to fully collapse the space bar. + await tester.drag(find.byKey(dragTarget), const Offset(0, -400.0)); + await tester.pumpAndSettle(); + + expect(clipRect.size.height, minExtent); + }); + + testWidgets( + 'FlexibleSpaceBar.background is visible when using height other than kToolbarHeight', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/80451 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + toolbarHeight: 300, + flexibleSpace: const FlexibleSpaceBar( + title: Text('Title'), + background: Text('Background'), + collapseMode: CollapseMode.pin, + ), + ), + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + SliverToBoxAdapter(child: Container(height: 1200.0, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + final dynamic backgroundOpacity = tester.firstWidget( + find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_FlexibleSpaceHeaderOpacity', + ), + ); + // accessing private type member. + // ignore: avoid_dynamic_calls + expect(backgroundOpacity.opacity, 1.0); + }, + ); + + testWidgets('Material3 - Collapsed FlexibleSpaceBar has correct semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + const double expandedHeight = 200; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + pinned: true, + expandedHeight: expandedHeight, + title: Text('Title'), + flexibleSpace: FlexibleSpaceBar(background: Text('Expanded title')), + ), + SliverList.builder( + itemCount: 50, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + var expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 2, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 3, + rect: TestSemantics.fullScreen, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 9, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, expandedHeight), + children: <TestSemantics>[ + TestSemantics( + id: 12, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + children: <TestSemantics>[ + TestSemantics( + id: 13, + rect: const Rect.fromLTRB(0.0, 0.0, 110.0, 28.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + SemanticsFlag.namesRoute, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + id: 10, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + children: <TestSemantics>[ + TestSemantics( + id: 11, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, expandedHeight), + label: 'Expanded title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + id: 14, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + rect: TestSemantics.fullScreen, + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollToOffset, + ], + children: <TestSemantics>[ + TestSemantics( + id: 5, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 0', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 1', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 8, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 3', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true)); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 1'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 2, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 3, + rect: TestSemantics.fullScreen, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 9, + // The app bar is collapsed. + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0), + children: <TestSemantics>[ + TestSemantics( + id: 12, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0), + children: <TestSemantics>[ + TestSemantics( + id: 13, + rect: const Rect.fromLTRB(0.0, 0.0, 110.0, 28.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + SemanticsFlag.namesRoute, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + // The flexible space bar still persists in the + // semantic tree even if it is collapsed. + TestSemantics( + id: 10, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0), + children: <TestSemantics>[ + TestSemantics( + id: 11, + rect: const Rect.fromLTRB(0.0, 36.0, 800.0, 92.0), + label: 'Expanded title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + id: 14, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + rect: TestSemantics.fullScreen, + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollDown, + SemanticsAction.scrollToOffset, + ], + children: <TestSemantics>[ + TestSemantics( + id: 5, + rect: const Rect.fromLTRB(0.0, 150.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 0', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 1', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 56.0, 800.0, 200.0), + label: 'Item 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 8, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 3', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 15, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 16, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 5', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 17, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 6', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true)); + + semantics.dispose(); + }); + + testWidgets('Material2 - Collapsed FlexibleSpaceBar has correct semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + const double expandedHeight = 200; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + pinned: true, + expandedHeight: expandedHeight, + title: Text('Title'), + flexibleSpace: FlexibleSpaceBar(background: Text('Expanded title')), + ), + SliverList.builder( + itemCount: 50, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + var expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 2, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 3, + rect: TestSemantics.fullScreen, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 9, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, expandedHeight), + children: <TestSemantics>[ + TestSemantics( + id: 12, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + children: <TestSemantics>[ + TestSemantics( + id: 13, + rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 20.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + SemanticsFlag.namesRoute, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + id: 10, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + children: <TestSemantics>[ + TestSemantics( + id: 11, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, expandedHeight), + label: 'Expanded title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + id: 14, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + rect: TestSemantics.fullScreen, + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollToOffset, + ], + children: <TestSemantics>[ + TestSemantics( + id: 5, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 0', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 1', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 8, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 3', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true)); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 1'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 2, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 3, + rect: TestSemantics.fullScreen, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 9, + // The app bar is collapsed. + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0), + children: <TestSemantics>[ + TestSemantics( + id: 12, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0), + children: <TestSemantics>[ + TestSemantics( + id: 13, + rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 20.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + SemanticsFlag.namesRoute, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + // The flexible space bar still persists in the + // semantic tree even if it is collapsed. + TestSemantics( + id: 10, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0), + children: <TestSemantics>[ + TestSemantics( + id: 11, + rect: const Rect.fromLTRB(0.0, 36.0, 800.0, 92.0), + label: 'Expanded title', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + id: 14, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + rect: TestSemantics.fullScreen, + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollDown, + SemanticsAction.scrollToOffset, + ], + children: <TestSemantics>[ + TestSemantics( + id: 5, + rect: const Rect.fromLTRB(0.0, 150.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 0', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 1', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 56.0, 800.0, 200.0), + label: 'Item 2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 8, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 3', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 15, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + label: 'Item 4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 16, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 5', + textDirection: TextDirection.ltr, + ), + TestSemantics( + id: 17, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0), + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + label: 'Item 6', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true)); + + semantics.dispose(); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/14227 + testWidgets('Material3 - FlexibleSpaceBar sets width constraints for the title', ( + WidgetTester tester, + ) async { + const titleFontSize = 20.0; + const height = 300.0; + late double width; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + width = MediaQuery.sizeOf(context).width; + return CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + titlePadding: EdgeInsets.zero, + title: Text( + 'X' * 2000, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: titleFontSize, height: 1.0), + ), + centerTitle: false, + ), + ), + ], + ); + }, + ), + ), + ), + ); + + final textWidth = width; + // The title is scaled and transformed to be 1.5 times bigger, when the + // FlexibleSpaceBar is fully expanded, thus we expect the width to be + // 1.5 times smaller than the full width. The height of the text is the same + // as the font size, with 10 dps bottom margin. + expect( + tester.getRect(find.byType(Text)), + rectMoreOrLessEquals( + Rect.fromLTRB(0, height - titleFontSize - 10, textWidth, height), + epsilon: 0.0001, + ), + ); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/14227 + testWidgets('Material2 - FlexibleSpaceBar sets width constraints for the title', ( + WidgetTester tester, + ) async { + const titleFontSize = 20.0; + const height = 300.0; + late double width; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + width = MediaQuery.sizeOf(context).width; + return CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + titlePadding: EdgeInsets.zero, + title: Text( + 'X' * 2000, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: titleFontSize), + ), + centerTitle: false, + ), + ), + ], + ); + }, + ), + ), + ), + ); + + final textWidth = width; + // The title is scaled and transformed to be 1.5 times bigger, when the + // FlexibleSpaceBar is fully expanded, thus we expect the width to be + // 1.5 times smaller than the full width. The height of the text is the same + // as the font size, with 10 dps bottom margin. + expect( + tester.getRect(find.byType(Text)), + rectMoreOrLessEquals( + Rect.fromLTRB(0, height - titleFontSize - 10, textWidth, height), + epsilon: 0.0001, + ), + ); + }); + + testWidgets( + 'Material3 - FlexibleSpaceBar sets constraints for the title - override expandedTitleScale', + (WidgetTester tester) async { + const titleFontSize = 20.0; + const height = 300.0; + const expandedTitleScale = 3.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + expandedTitleScale: expandedTitleScale, + titlePadding: EdgeInsets.zero, + title: Text( + 'X' * 41, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: titleFontSize, height: 1.0), + ), + centerTitle: false, + ), + ), + SliverList.builder( + itemCount: 3, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 0'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + final Finder title = find.byType(Text).first; + final double collapsedWidth = tester.getRect(title).width; + + // We drag down to fully expand the space bar. + await tester.drag(find.text('Item 2'), const Offset(0, 600.0)); + await tester.pumpAndSettle(); + + // The title is shifted by this margin to maintain the position of the + // bottom edge. + const double bottomMargin = titleFontSize * (expandedTitleScale - 1); + + final textWidth = collapsedWidth; + // The title is scaled and transformed to be 3 times bigger, when the + // FlexibleSpaceBar is fully expanded, thus we expect the width to be + // 3 times smaller than the full width. The height of the text is the same + // as the font size, with 40 dps bottom margin to maintain its bottom position. + expect( + tester.getRect(title), + rectMoreOrLessEquals( + Rect.fromLTRB(0, height - titleFontSize - bottomMargin, textWidth, height), + epsilon: 0.0001, + ), + ); + }, + ); + + testWidgets( + 'Material2 - FlexibleSpaceBar sets constraints for the title - override expandedTitleScale', + (WidgetTester tester) async { + const titleFontSize = 20.0; + const height = 300.0; + const expandedTitleScale = 3.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + expandedTitleScale: expandedTitleScale, + titlePadding: EdgeInsets.zero, + title: Text( + 'X' * 41, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: titleFontSize), + ), + centerTitle: false, + ), + ), + SliverList.builder( + itemCount: 3, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 0'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + final Finder title = find.byType(Text).first; + final double collapsedWidth = tester.getRect(title).width; + + // We drag down to fully expand the space bar. + await tester.drag(find.text('Item 2'), const Offset(0, 600.0)); + await tester.pumpAndSettle(); + + // The title is shifted by this margin to maintain the position of the + // bottom edge. + const double bottomMargin = titleFontSize * (expandedTitleScale - 1); + + final textWidth = collapsedWidth; + // The title is scaled and transformed to be 3 times bigger, when the + // FlexibleSpaceBar is fully expanded, thus we expect the width to be + // 3 times smaller than the full width. The height of the text is the same + // as the font size, with 40 dps bottom margin to maintain its bottom position. + expect( + tester.getRect(title), + rectMoreOrLessEquals( + Rect.fromLTRB(0, height - titleFontSize - bottomMargin, textWidth, height), + epsilon: 0.0001, + ), + ); + }, + ); + + testWidgets('Material3 - FlexibleSpaceBar scaled title', (WidgetTester tester) async { + const titleFontSize = 20.0; + const height = 300.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: RepaintBoundary( + child: FlexibleSpaceBar( + title: Text('X', style: TextStyle(fontSize: titleFontSize)), + centerTitle: false, + ), + ), + ), + SliverList.builder( + itemCount: 3, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 0'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + final Finder flexibleSpaceBar = find.ancestor( + of: find.byType(FlexibleSpaceBar), + matching: find.byType(RepaintBoundary).first, + ); + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m3.expanded_title_scale_default.collapsed.png'), + ); + + // We drag down to fully expand the space bar. + await tester.drag(find.text('Item 2'), const Offset(0, 600.0)); + await tester.pumpAndSettle(); + + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m3.expanded_title_scale_default.expanded.png'), + ); + }); + + testWidgets('Material2 - FlexibleSpaceBar scaled title', (WidgetTester tester) async { + const titleFontSize = 20.0; + const height = 300.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: RepaintBoundary( + child: FlexibleSpaceBar( + title: Text('X', style: TextStyle(fontSize: titleFontSize)), + centerTitle: false, + ), + ), + ), + SliverList.builder( + itemCount: 3, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 0'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + final Finder flexibleSpaceBar = find.ancestor( + of: find.byType(FlexibleSpaceBar), + matching: find.byType(RepaintBoundary).first, + ); + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m2.expanded_title_scale_default.collapsed.png'), + ); + + // We drag down to fully expand the space bar. + await tester.drag(find.text('Item 2'), const Offset(0, 600.0)); + await tester.pumpAndSettle(); + + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m2.expanded_title_scale_default.expanded.png'), + ); + }); + + testWidgets('Material3 - FlexibleSpaceBar scaled title - override expandedTitleScale', ( + WidgetTester tester, + ) async { + const titleFontSize = 20.0; + const height = 300.0; + const expandedTitleScale = 3.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: RepaintBoundary( + child: FlexibleSpaceBar( + title: Text('X', style: TextStyle(fontSize: titleFontSize)), + centerTitle: false, + expandedTitleScale: expandedTitleScale, + ), + ), + ), + SliverList.builder( + itemCount: 3, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 0'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + final Finder flexibleSpaceBar = find.ancestor( + of: find.byType(FlexibleSpaceBar), + matching: find.byType(RepaintBoundary).first, + ); + // This should match the default behavior + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m3.expanded_title_scale_default.collapsed.png'), + ); + + // We drag down to fully expand the space bar. + await tester.drag(find.text('Item 2'), const Offset(0, 600.0)); + await tester.pumpAndSettle(); + + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m3.expanded_title_scale_override.expanded.png'), + ); + }); + + testWidgets('Material2 - FlexibleSpaceBar scaled title - override expandedTitleScale', ( + WidgetTester tester, + ) async { + const titleFontSize = 20.0; + const height = 300.0; + const expandedTitleScale = 3.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + expandedHeight: height, + pinned: true, + stretch: true, + flexibleSpace: RepaintBoundary( + child: FlexibleSpaceBar( + title: Text('X', style: TextStyle(fontSize: titleFontSize)), + centerTitle: false, + expandedTitleScale: expandedTitleScale, + ), + ), + ), + SliverList.builder( + itemCount: 3, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 200.0, child: Center(child: Text('Item $index'))); + }, + ), + ], + ), + ), + ), + ); + + // We drag up to fully collapse the space bar. + await tester.drag(find.text('Item 0'), const Offset(0, -600.0)); + await tester.pumpAndSettle(); + + final Finder flexibleSpaceBar = find.ancestor( + of: find.byType(FlexibleSpaceBar), + matching: find.byType(RepaintBoundary).first, + ); + // This should match the default behavior + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m2.expanded_title_scale_default.collapsed.png'), + ); + + // We drag down to fully expand the space bar. + await tester.drag(find.text('Item 2'), const Offset(0, 600.0)); + await tester.pumpAndSettle(); + + await expectLater( + flexibleSpaceBar, + matchesGoldenFile('flexible_space_bar.m2.expanded_title_scale_override.expanded.png'), + ); + }); + + testWidgets('Material3 - FlexibleSpaceBar titlePadding defaults', (WidgetTester tester) async { + Widget buildFrame(TargetPlatform platform, bool? centerTitle) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + appBar: AppBar( + flexibleSpace: FlexibleSpaceBar(centerTitle: centerTitle, title: const Text('X')), + ), + ), + ); + } + + final Finder title = find.text('X'); + final Finder flexibleSpaceBar = find.byType(FlexibleSpaceBar); + Offset getTitleBottomLeft() { + return Offset( + tester.getTopLeft(title).dx, + tester.getBottomRight(flexibleSpaceBar).dy - tester.getBottomRight(title).dy, + ); + } + + await tester.pumpWidget(buildFrame(TargetPlatform.android, null)); + expect(getTitleBottomLeft(), const Offset(72.0, 16.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.android, true)); + expect(getTitleBottomLeft(), const Offset(389.0, 16.0)); + + // Clear the widget tree to avoid animating between Android and iOS. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, null)); + expect(getTitleBottomLeft(), const Offset(389.0, 16.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, false)); + expect(getTitleBottomLeft(), const Offset(72.0, 16.0)); + + // Clear the widget tree to avoid animating between iOS and macOS. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, null)); + expect(getTitleBottomLeft(), const Offset(389.0, 16.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, false)); + expect(getTitleBottomLeft(), const Offset(72.0, 16.0)); + }); + + testWidgets('Material2 - FlexibleSpaceBar titlePadding defaults', (WidgetTester tester) async { + Widget buildFrame(TargetPlatform platform, bool? centerTitle) { + return MaterialApp( + theme: ThemeData(platform: platform, useMaterial3: false), + home: Scaffold( + appBar: AppBar( + flexibleSpace: FlexibleSpaceBar(centerTitle: centerTitle, title: const Text('X')), + ), + ), + ); + } + + final Finder title = find.text('X'); + final Finder flexibleSpaceBar = find.byType(FlexibleSpaceBar); + Offset getTitleBottomLeft() { + return Offset( + tester.getTopLeft(title).dx, + tester.getBottomRight(flexibleSpaceBar).dy - tester.getBottomRight(title).dy, + ); + } + + await tester.pumpWidget(buildFrame(TargetPlatform.android, null)); + expect(getTitleBottomLeft(), const Offset(72.0, 16.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.android, true)); + expect(getTitleBottomLeft(), const Offset(390.0, 16.0)); + + // Clear the widget tree to avoid animating between Android and iOS. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, null)); + expect(getTitleBottomLeft(), const Offset(390.0, 16.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, false)); + expect(getTitleBottomLeft(), const Offset(72.0, 16.0)); + + // Clear the widget tree to avoid animating between iOS and macOS. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, null)); + expect(getTitleBottomLeft(), const Offset(390.0, 16.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, false)); + expect(getTitleBottomLeft(), const Offset(72.0, 16.0)); + }); + + testWidgets('Material3 - FlexibleSpaceBar titlePadding override', (WidgetTester tester) async { + Widget buildFrame(TargetPlatform platform, bool? centerTitle) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + appBar: AppBar( + flexibleSpace: FlexibleSpaceBar( + titlePadding: EdgeInsets.zero, + centerTitle: centerTitle, + title: const Text('X'), + ), + ), + ), + ); + } + + final Finder title = find.text('X'); + final Finder flexibleSpaceBar = find.byType(FlexibleSpaceBar); + Offset getTitleBottomLeft() { + return Offset( + tester.getTopLeft(title).dx, + tester.getBottomRight(flexibleSpaceBar).dy - tester.getBottomRight(title).dy, + ); + } + + await tester.pumpWidget(buildFrame(TargetPlatform.android, null)); + expect(getTitleBottomLeft(), Offset.zero); + + await tester.pumpWidget(buildFrame(TargetPlatform.android, true)); + expect(getTitleBottomLeft(), const Offset(389.0, 0.0)); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, null)); + expect(getTitleBottomLeft(), const Offset(389.0, 0.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, false)); + expect(getTitleBottomLeft(), Offset.zero); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, null)); + expect(getTitleBottomLeft(), const Offset(389.0, 0.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, false)); + expect(getTitleBottomLeft(), Offset.zero); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.windows, null)); + expect(getTitleBottomLeft(), Offset.zero); + + await tester.pumpWidget(buildFrame(TargetPlatform.windows, true)); + expect(getTitleBottomLeft(), const Offset(389.0, 0.0)); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.linux, null)); + expect(getTitleBottomLeft(), Offset.zero); + + await tester.pumpWidget(buildFrame(TargetPlatform.linux, true)); + expect(getTitleBottomLeft(), const Offset(389.0, 0.0)); + }); + + testWidgets('Material2 - FlexibleSpaceBar titlePadding override', (WidgetTester tester) async { + Widget buildFrame(TargetPlatform platform, bool? centerTitle) { + return MaterialApp( + theme: ThemeData(platform: platform, useMaterial3: false), + home: Scaffold( + appBar: AppBar( + flexibleSpace: FlexibleSpaceBar( + titlePadding: EdgeInsets.zero, + centerTitle: centerTitle, + title: const Text('X'), + ), + ), + ), + ); + } + + final Finder title = find.text('X'); + final Finder flexibleSpaceBar = find.byType(FlexibleSpaceBar); + Offset getTitleBottomLeft() { + return Offset( + tester.getTopLeft(title).dx, + tester.getBottomRight(flexibleSpaceBar).dy - tester.getBottomRight(title).dy, + ); + } + + await tester.pumpWidget(buildFrame(TargetPlatform.android, null)); + expect(getTitleBottomLeft(), Offset.zero); + + await tester.pumpWidget(buildFrame(TargetPlatform.android, true)); + expect(getTitleBottomLeft(), const Offset(390.0, 0.0)); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, null)); + expect(getTitleBottomLeft(), const Offset(390.0, 0.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.iOS, false)); + expect(getTitleBottomLeft(), Offset.zero); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, null)); + expect(getTitleBottomLeft(), const Offset(390.0, 0.0)); + + await tester.pumpWidget(buildFrame(TargetPlatform.macOS, false)); + expect(getTitleBottomLeft(), Offset.zero); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.windows, null)); + expect(getTitleBottomLeft(), Offset.zero); + + await tester.pumpWidget(buildFrame(TargetPlatform.windows, true)); + expect(getTitleBottomLeft(), const Offset(390.0, 0.0)); + + // Clear the widget tree to avoid animating between platforms. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildFrame(TargetPlatform.linux, null)); + expect(getTitleBottomLeft(), Offset.zero); + + await tester.pumpWidget(buildFrame(TargetPlatform.linux, true)); + expect(getTitleBottomLeft(), const Offset(390.0, 0.0)); + }); + + testWidgets('FlexibleSpaceBar rebuilds when scrolling.', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: const SubCategoryScreenView(), + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + ), + ); + + expect(RenderRebuildTracker.count, 1); + expect( + tester.layers.lastWhere((Layer element) => element is OpacityLayer), + isA<OpacityLayer>().having((OpacityLayer p0) => p0.alpha, 'alpha', 255), + ); + + // We drag up to fully collapse the space bar. + for (var i = 0; i < 9; i++) { + await tester.drag(find.byKey(SubCategoryScreenView.scrollKey), const Offset(0, -50.0)); + await tester.pumpAndSettle(); + } + + expect( + tester.layers.lastWhere((Layer element) => element is OpacityLayer), + isA<OpacityLayer>().having((OpacityLayer p0) => p0.alpha, 'alpha', lessThan(255)), + ); + + for (var i = 0; i < 11; i++) { + await tester.drag(find.byKey(SubCategoryScreenView.scrollKey), const Offset(0, -50.0)); + await tester.pumpAndSettle(); + } + + expect(RenderRebuildTracker.count, greaterThan(1)); + expect(tester.layers.whereType<OpacityLayer>(), isEmpty); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/132030. + testWidgets('FlexibleSpaceBarSettings.hasLeading provides a gap between leading and title', ( + WidgetTester tester, + ) async { + final customSettings = + FlexibleSpaceBar.createSettings( + currentExtent: 200.0, + hasLeading: true, + child: AppBar( + leading: const Icon(Icons.menu), + flexibleSpace: FlexibleSpaceBar( + title: Text('title ' * 10), + // Set centerTitle to false to create a gap between the leading widget + // and the long title. + centerTitle: false, + ), + ), + ) + as FlexibleSpaceBarSettings; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverPersistentHeader( + floating: true, + pinned: true, + delegate: TestDelegate(settings: customSettings), + ), + SliverToBoxAdapter(child: Container(height: 1200.0, color: Colors.orange[400])), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byType(Text)).dx, closeTo(72.0, 0.01)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/135698. + testWidgets( + '_FlexibleSpaceHeaderOpacity with near zero opacity avoids compositing', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return <Widget>[ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: const SliverAppBar( + pinned: true, + expandedHeight: 200.0, + collapsedHeight: 56.0, + flexibleSpace: FlexibleSpaceBar(background: SizedBox()), + ), + ), + ]; + }, + body: const SingleChildScrollView( + child: Column(children: <Widget>[Placeholder(fallbackHeight: 300.0)]), + ), + ), + ), + ), + ); + + // Drag the scroll view to the top to collapse the sliver app bar. + // Ensure collapsed height - current extent is near zero for the + // FlexibleSpaceBar to avoid compositing. + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -(200.0 - 56.08787892026129)), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.mobile(), + ); + + // This is a regression test for https://github.com/flutter/flutter/issues/138608. + testWidgets('FlexibleSpaceBar centers title with a leading widget', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + leading: Icon(Icons.menu), + flexibleSpace: FlexibleSpaceBar(centerTitle: true, title: Text('X')), + ), + ], + ), + ), + ), + ); + + final Offset appBarCenter = tester.getCenter(find.byType(AppBar)); + final Offset titleCenter = tester.getCenter(find.text('X')); + expect(appBarCenter.dx, titleCenter.dx); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/138296. + testWidgets('Material3 - Default title color', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, // Provide the expected theme data. + home: const Material( + child: CustomScrollView( + slivers: <Widget>[SliverAppBar(flexibleSpace: FlexibleSpaceBar(title: Text('Title')))], + ), + ), + ), + ); + + final DefaultTextStyle textStyle = DefaultTextStyle.of(tester.element(find.text('Title'))); + expect(textStyle.style.color, theme.textTheme.titleLarge!.color); + }); + + testWidgets('FlexibleSpaceBar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold( + appBar: AppBar(flexibleSpace: const FlexibleSpaceBar(title: Text('X'))), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(FlexibleSpaceBar)), Size.zero); + }); +} + +class TestDelegate extends SliverPersistentHeaderDelegate { + const TestDelegate({required this.settings}); + + final FlexibleSpaceBarSettings settings; + + @override + double get maxExtent => settings.maxExtent; + + @override + double get minExtent => settings.minExtent; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return settings; + } + + @override + bool shouldRebuild(TestDelegate oldDelegate) => false; +} + +class RebuildTracker extends SingleChildRenderObjectWidget { + const RebuildTracker({super.key}); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderRebuildTracker(); + } +} + +class RenderRebuildTracker extends RenderProxyBox { + static int count = 0; + + @override + void paint(PaintingContext context, Offset offset) { + count++; + super.paint(context, offset); + } +} + +class SubCategoryScreenView extends StatefulWidget { + const SubCategoryScreenView({super.key}); + + static const Key scrollKey = Key('orange box'); + + @override + State<SubCategoryScreenView> createState() => _SubCategoryScreenViewState(); +} + +class _SubCategoryScreenViewState extends State<SubCategoryScreenView> + with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(centerTitle: true, title: const Text('Test')), + body: CustomScrollView( + key: SubCategoryScreenView.scrollKey, + slivers: <Widget>[ + SliverAppBar( + leading: const SizedBox(), + expandedHeight: MediaQuery.widthOf(context) / 1.7, + collapsedHeight: 0, + toolbarHeight: 0, + titleSpacing: 0, + leadingWidth: 0, + flexibleSpace: const FlexibleSpaceBar( + background: AspectRatio(aspectRatio: 1.7, child: RebuildTracker()), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 12)), + SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), + itemCount: 300, + itemBuilder: (BuildContext context, int index) { + return Card( + color: Colors.amber, + child: Center(child: Text('$index')), + ); + }, + ), + const SliverToBoxAdapter(child: SizedBox(height: 12)), + ], + ), + ); + } +} diff --git a/packages/material_ui/test/material/floating_action_button_location_test.dart b/packages/material_ui/test/material/floating_action_button_location_test.dart new file mode 100644 index 000000000000..2aed96009b3c --- /dev/null +++ b/packages/material_ui/test/material/floating_action_button_location_test.dart @@ -0,0 +1,1962 @@ +// 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'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Basic floating action button locations', () { + testWidgets('still animates motion when the floating action button is null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(_buildFrame(fab: null)); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget( + _buildFrame(fab: null, location: FloatingActionButtonLocation.endFloat), + ); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpWidget( + _buildFrame(fab: null, location: FloatingActionButtonLocation.centerFloat), + ); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + }); + + testWidgets('moves fab from center to end and back', (WidgetTester tester) async { + await tester.pumpWidget(_buildFrame(location: FloatingActionButtonLocation.endFloat)); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(_buildFrame(location: FloatingActionButtonLocation.centerFloat)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(400.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(_buildFrame(location: FloatingActionButtonLocation.endFloat)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('moves to and from custom-defined positions', (WidgetTester tester) async { + await tester.pumpWidget(_buildFrame(location: const _StartTopFloatingActionButtonLocation())); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 56.0)); + + await tester.pumpWidget(_buildFrame(location: FloatingActionButtonLocation.centerFloat)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(400.0, 356.0)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(_buildFrame(location: const _StartTopFloatingActionButtonLocation())); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 56.0)); + expect(tester.binding.transientCallbackCount, 0); + }); + + group('interrupts in-progress animations without jumps', () { + _GeometryListener? geometryListener; + ScaffoldGeometry? geometry; + _GeometryListenerState? listenerState; + Size? previousRect; + Iterable<double>? previousRotations; + + // The maximum amounts we expect the fab width and height to change + // during one step of a transition. + const maxDeltaWidth = 12.5; + const maxDeltaHeight = 12.5; + + // The maximum amounts we expect the fab icon to rotate during one step + // of a transition. + const maxDeltaRotation = 0.09; + + // We'll listen to the Scaffold's geometry for any 'jumps' to detect + // changes in the size and rotation of the fab. + void setupListener(WidgetTester tester) { + // Measure the delta in width and height of the fab, and check that it never grows + // by more than the expected maximum deltas. + void check() { + geometry = listenerState?.cache.value; + final Size? currentRect = geometry?.floatingActionButtonArea?.size; + // Measure the delta in width and height of the rect, and check that + // it never grows by more than a safe amount. + if (previousRect != null && currentRect != null) { + final double deltaWidth = currentRect.width - previousRect!.width; + final double deltaHeight = currentRect.height - previousRect!.height; + expect( + deltaWidth.abs(), + lessThanOrEqualTo(maxDeltaWidth), + reason: + "The Floating Action Button's width should not change " + 'faster than $maxDeltaWidth per animation step.\n' + 'Previous rect: $previousRect, current rect: $currentRect', + ); + expect( + deltaHeight.abs(), + lessThanOrEqualTo(maxDeltaHeight), + reason: + "The Floating Action Button's width should not change " + 'faster than $maxDeltaHeight per animation step.\n' + 'Previous rect: $previousRect, current rect: $currentRect', + ); + } + previousRect = currentRect; + + // Measure the delta in rotation. + // Check that it never grows by more than a safe amount. + // + // There may be multiple transitions all active at + // the same time. We are concerned only with the closest one. + final Iterable<RotationTransition> rotationTransitions = tester.widgetList( + find.byType(RotationTransition), + ); + final Iterable<double> currentRotations = rotationTransitions.map( + (RotationTransition t) => t.turns.value, + ); + + if ((previousRotations?.isNotEmpty ?? false) && + currentRotations.isNotEmpty && + previousRect != null && + currentRect != null) { + final deltas = <double>[]; + for (final currentRotation in currentRotations) { + late double minDelta; + for (final double previousRotation in previousRotations!) { + final double delta = (previousRotation - currentRotation).abs(); + minDelta = delta; + minDelta = min(delta, minDelta); + } + deltas.add(minDelta); + } + + if (deltas.where((double delta) => delta < maxDeltaRotation).isEmpty) { + fail( + "The Floating Action Button's rotation should not change " + 'faster than $maxDeltaRotation per animation step.\n' + 'Detected deltas were: $deltas\n' + 'Previous values: $previousRotations, current values: $currentRotations\n' + 'Previous rect: $previousRect, current rect: $currentRect', + ); + } + } + previousRotations = currentRotations; + } + + listenerState = tester.state(find.byType(_GeometryListener)); + listenerState!.geometryListenable!.addListener(check); + } + + setUp(() { + // We create the geometry listener here, but it can only be set up + // after it is pumped into the widget tree and a tester is + // available. + geometryListener = const _GeometryListener(); + geometry = null; + listenerState = null; + previousRect = null; + previousRotations = null; + }); + + testWidgets('moving the fab to centerFloat', (WidgetTester tester) async { + // Create a scaffold with the fab at endFloat + await tester.pumpWidget( + _buildFrame(location: FloatingActionButtonLocation.endFloat, listener: geometryListener), + ); + setupListener(tester); + + // Move the fab to centerFloat' + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.centerFloat, + listener: geometryListener, + ), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('interrupting motion towards the StartTop location.', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.centerFloat, + listener: geometryListener, + ), + ); + setupListener(tester); + + // Move the fab to the top start after creating the fab. + await tester.pumpWidget( + _buildFrame( + location: const _StartTopFloatingActionButtonLocation(), + listener: geometryListener, + ), + ); + await tester.pump(kFloatingActionButtonSegue ~/ 2); + + // Interrupt motion to move to the end float + await tester.pumpWidget( + _buildFrame(location: FloatingActionButtonLocation.endFloat, listener: geometryListener), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('interrupting entrance to remove the fab.', (WidgetTester tester) async { + await tester.pumpWidget( + _buildFrame( + fab: null, + location: FloatingActionButtonLocation.centerFloat, + listener: geometryListener, + ), + ); + setupListener(tester); + + // Animate the fab in. + await tester.pumpWidget( + _buildFrame(location: FloatingActionButtonLocation.endFloat, listener: geometryListener), + ); + await tester.pump(kFloatingActionButtonSegue ~/ 2); + + // Remove the fab. + await tester.pumpWidget( + _buildFrame( + fab: null, + location: FloatingActionButtonLocation.endFloat, + listener: geometryListener, + ), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('interrupting entrance of a new fab.', (WidgetTester tester) async { + await tester.pumpWidget( + _buildFrame( + fab: null, + location: FloatingActionButtonLocation.endFloat, + listener: geometryListener, + ), + ); + setupListener(tester); + + // Bring in a new fab. + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.centerFloat, + listener: geometryListener, + ), + ); + await tester.pump(kFloatingActionButtonSegue ~/ 2); + + // Interrupt motion to move the fab. + await tester.pumpWidget( + _buildFrame(location: FloatingActionButtonLocation.endFloat, listener: geometryListener), + ); + await tester.pumpAndSettle(); + }); + }); + }); + + testWidgets('Docked floating action button locations', (WidgetTester tester) async { + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.endDocked, + bab: const SizedBox(height: 100.0), + viewInsets: EdgeInsets.zero, + ), + ); + + // Scaffold 800x600, FAB is 56x56, BAB is 800x100, FAB's center is + // at the top of the BAB. + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 500.0)); + + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.centerDocked, + bab: const SizedBox(height: 100.0), + viewInsets: EdgeInsets.zero, + ), + ); + await tester.pumpAndSettle(); + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(400.0, 500.0)); + + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.endDocked, + bab: const SizedBox(height: 100.0), + viewInsets: EdgeInsets.zero, + ), + ); + await tester.pumpAndSettle(); + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 500.0)); + }); + + testWidgets('Docked floating action button locations: no BAB, small BAB', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + _buildFrame(location: FloatingActionButtonLocation.endDocked, viewInsets: EdgeInsets.zero), + ); + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 572.0)); + + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.endDocked, + bab: const SizedBox(height: 16.0), + viewInsets: EdgeInsets.zero, + ), + ); + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 572.0)); + }); + + testWidgets('Contained floating action button locations', (WidgetTester tester) async { + await tester.pumpWidget( + _buildFrame( + location: FloatingActionButtonLocation.endContained, + bab: const SizedBox(height: 100.0), + viewInsets: EdgeInsets.zero, + ), + ); + + // Scaffold 800x600, FAB is 56x56, BAB is 800x100, FAB's center is + // at the top of the BAB. + // Formula: scaffold height - BAB height + FAB height / 2 + BAB top & bottom margins. + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 550.0)); + }); + + testWidgets('Mini-start-top floating action button location', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + floatingActionButton: FloatingActionButton(onPressed: () {}, mini: true), + floatingActionButtonLocation: FloatingActionButtonLocation.miniStartTop, + body: const Column(children: <Widget>[ListTile(leading: CircleAvatar())]), + ), + ), + ); + expect( + tester.getCenter(find.byType(FloatingActionButton)).dx, + tester.getCenter(find.byType(CircleAvatar)).dx, + ); + expect(tester.getCenter(find.byType(FloatingActionButton)).dy, kToolbarHeight); + }); + + testWidgets('Start-top floating action button location LTR', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + floatingActionButton: const FloatingActionButton(onPressed: null), + floatingActionButtonLocation: FloatingActionButtonLocation.startTop, + ), + ), + ); + expect( + tester.getRect(find.byType(FloatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTWH(16.0, 28.0, 56.0, 56.0)), + ); + }); + + testWidgets('End-top floating action button location RTL', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar(), + floatingActionButton: const FloatingActionButton(onPressed: null), + floatingActionButtonLocation: FloatingActionButtonLocation.endTop, + ), + ), + ), + ); + expect( + tester.getRect(find.byType(FloatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTWH(16.0, 28.0, 56.0, 56.0)), + ); + }); + + testWidgets('Start-top floating action button location RTL', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar(), + floatingActionButton: const FloatingActionButton(onPressed: null), + floatingActionButtonLocation: FloatingActionButtonLocation.startTop, + ), + ), + ), + ); + expect( + tester.getRect(find.byType(FloatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTWH(800.0 - 56.0 - 16.0, 28.0, 56.0, 56.0)), + ); + }); + + testWidgets('End-top floating action button location LTR', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + floatingActionButton: const FloatingActionButton(onPressed: null), + floatingActionButtonLocation: FloatingActionButtonLocation.endTop, + ), + ), + ); + expect( + tester.getRect(find.byType(FloatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTWH(800.0 - 56.0 - 16.0, 28.0, 56.0, 56.0)), + ); + }); + + group('New Floating Action Button Locations', () { + testWidgets('startTop', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.startTop)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _topOffsetY), + ); + }); + + testWidgets('centerTop', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.centerTop)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _topOffsetY), + ); + }); + + testWidgets('endTop', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.endTop)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX, _topOffsetY), + ); + }); + + testWidgets('startFloat', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.startFloat)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _floatOffsetY), + ); + }); + + testWidgets('centerFloat', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.centerFloat)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _floatOffsetY), + ); + }); + + testWidgets('endFloat', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.endFloat)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX, _floatOffsetY), + ); + }); + + testWidgets('startDocked', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.startDocked)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _dockedOffsetY), + ); + }); + + testWidgets('centerDocked', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.centerDocked)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _dockedOffsetY), + ); + }); + + testWidgets('endDocked', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.endDocked)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX, _dockedOffsetY), + ); + }); + + testWidgets('endContained', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.endContained)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX, _containedOffsetY), + ); + }); + + testWidgets('miniStartTop', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniStartTop)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniLeftOffsetX, _topOffsetY), + ); + }); + + testWidgets('miniEndTop', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniEndTop)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniRightOffsetX, _topOffsetY), + ); + }); + + testWidgets('miniStartFloat', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniStartFloat)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniLeftOffsetX, _miniFloatOffsetY), + ); + }); + + testWidgets('miniCenterFloat', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniCenterFloat)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _miniFloatOffsetY), + ); + }); + + testWidgets('miniEndFloat', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniEndFloat)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniRightOffsetX, _miniFloatOffsetY), + ); + }); + + testWidgets('miniStartDocked', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniStartDocked)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniLeftOffsetX, _dockedOffsetY), + ); + }); + + testWidgets('miniEndDocked', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.miniEndDocked)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniRightOffsetX, _dockedOffsetY), + ); + }); + + // Test a few RTL cases. + + testWidgets('endTop, RTL', (WidgetTester tester) async { + await tester.pumpWidget( + _singleFabScaffold(FloatingActionButtonLocation.endTop, textDirection: TextDirection.rtl), + ); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _topOffsetY), + ); + }); + + testWidgets('miniStartFloat, RTL', (WidgetTester tester) async { + await tester.pumpWidget( + _singleFabScaffold( + FloatingActionButtonLocation.miniStartFloat, + textDirection: TextDirection.rtl, + ), + ); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_miniRightOffsetX, _miniFloatOffsetY), + ); + }); + }); + + group('Custom Floating Action Button Locations', () { + testWidgets('Almost end float', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(_AlmostEndFloatFabLocation())); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX - 50, _floatOffsetY), + ); + }); + + testWidgets('Almost end float, RTL', (WidgetTester tester) async { + await tester.pumpWidget( + _singleFabScaffold(_AlmostEndFloatFabLocation(), textDirection: TextDirection.rtl), + ); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX + 50, _floatOffsetY), + ); + }); + + testWidgets('Quarter end top', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(_QuarterEndTopFabLocation())); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX * 0.75 + _leftOffsetX * 0.25, _topOffsetY), + ); + }); + + testWidgets('Quarter end top, RTL', (WidgetTester tester) async { + await tester.pumpWidget( + _singleFabScaffold(_QuarterEndTopFabLocation(), textDirection: TextDirection.rtl), + ); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX * 0.75 + _rightOffsetX * 0.25, _topOffsetY), + ); + }); + }); + + group('Moves involving new locations', () { + testWidgets('Moves between new locations and new locations', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.centerTop)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _topOffsetY), + ); + + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.startFloat)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.binding.transientCallbackCount, 0); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _floatOffsetY), + ); + + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.startDocked)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.binding.transientCallbackCount, 0); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _dockedOffsetY), + ); + }); + + testWidgets('Moves between new locations and old locations', (WidgetTester tester) async { + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.endDocked)); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_rightOffsetX, _dockedOffsetY), + ); + + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.startDocked)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.binding.transientCallbackCount, 0); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_leftOffsetX, _dockedOffsetY), + ); + + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.centerFloat)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.binding.transientCallbackCount, 0); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _floatOffsetY), + ); + + await tester.pumpWidget(_singleFabScaffold(FloatingActionButtonLocation.centerTop)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.binding.transientCallbackCount, 0); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + const Offset(_centerOffsetX, _topOffsetY), + ); + }); + + testWidgets('Moves between new locations and old locations with custom animator', ( + WidgetTester tester, + ) async { + final FloatingActionButtonAnimator animator = _LinearMovementFabAnimator(); + const begin = Offset(_centerOffsetX, _topOffsetY); + const end = Offset(_rightOffsetX - 50, _floatOffsetY); + + final Duration animationDuration = kFloatingActionButtonSegue * 2; + + await tester.pumpWidget( + _singleFabScaffold(FloatingActionButtonLocation.centerTop, animator: animator), + ); + + expect(find.byType(FloatingActionButton), findsOneWidget); + + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(_singleFabScaffold(_AlmostEndFloatFabLocation(), animator: animator)); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pump(animationDuration * 0.25); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(begin * 0.75 + end * 0.25), + ); + + await tester.pump(animationDuration * 0.25); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(begin * 0.5 + end * 0.5), + ); + + await tester.pump(animationDuration * 0.25); + + expect( + tester.getCenter(find.byType(FloatingActionButton)), + offsetMoreOrLessEquals(begin * 0.25 + end * 0.75), + ); + + await tester.pumpAndSettle(); + + expect(tester.getCenter(find.byType(FloatingActionButton)), end); + + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('Animator can be updated', (WidgetTester tester) async { + FloatingActionButtonAnimator fabAnimator = FloatingActionButtonAnimator.scaling; + FloatingActionButtonLocation fabLocation = FloatingActionButtonLocation.startFloat; + + final Duration animationDuration = kFloatingActionButtonSegue * 2; + + await tester.pumpWidget(_singleFabScaffold(fabLocation, animator: fabAnimator)); + + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.getCenter(find.byType(FloatingActionButton)).dx, 44.0); + + fabLocation = FloatingActionButtonLocation.endFloat; + await tester.pumpWidget(_singleFabScaffold(fabLocation, animator: fabAnimator)); + + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, lessThan(16.0)); + + await tester.pump(animationDuration * 0.25); + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, greaterThan(16)); + + await tester.pump(animationDuration * 0.25); + expect(tester.getCenter(find.byType(FloatingActionButton)).dx, 756.0); + expect(tester.getTopRight(find.byType(FloatingActionButton)).dx, lessThan(800 - 16)); + + await tester.pump(animationDuration * 0.25); + expect(tester.getTopRight(find.byType(FloatingActionButton)).dx, lessThan(800 - 16)); + + await tester.pump(animationDuration * 0.25); + expect(tester.getTopRight(find.byType(FloatingActionButton)).dx, equals(800 - 16)); + + fabLocation = FloatingActionButtonLocation.startFloat; + fabAnimator = _NoScalingFabAnimator(); + await tester.pumpWidget(_singleFabScaffold(fabLocation, animator: fabAnimator)); + + await tester.pump(animationDuration * 0.25); + expect(tester.getCenter(find.byType(FloatingActionButton)).dx, 756.0); + expect(tester.getTopRight(find.byType(FloatingActionButton)).dx, equals(800 - 16)); + + await tester.pump(animationDuration * 0.25); + expect(tester.getCenter(find.byType(FloatingActionButton)).dx, 44.0); + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, lessThan(16.0)); + }); + }); + + group('Locations account for safe interactive areas', () { + Widget buildTest( + FloatingActionButtonLocation location, + MediaQueryData data, + Key key, { + bool mini = false, + bool appBar = false, + bool bottomNavigationBar = false, + bool bottomSheet = false, + bool resizeToAvoidBottomInset = true, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: data, + child: Scaffold( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + bottomSheet: bottomSheet + ? const SizedBox(height: 100, child: Center(child: Text('BottomSheet'))) + : null, + appBar: appBar ? AppBar(title: const Text('Demo')) : null, + bottomNavigationBar: bottomNavigationBar + ? BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.star), label: '0'), + BottomNavigationBarItem(icon: Icon(Icons.star_border), label: '1'), + ], + ) + : null, + floatingActionButtonLocation: location, + floatingActionButton: Builder( + builder: (BuildContext context) { + return FloatingActionButton( + onPressed: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Snacky!'))); + }, + mini: mini, + key: key, + child: const Text('FAB'), + ); + }, + ), + ), + ), + ); + } + + // Test float locations, for each (6), keyboard presented or not: + // - Default + // - with resizeToAvoidBottomInset: false + // - with BottomNavigationBar + // - with BottomNavigationBar and resizeToAvoidBottomInset: false + // - with BottomNavigationBar & BottomSheet + // - with BottomNavigationBar & BottomSheet, resizeToAvoidBottomInset: false + // - with BottomSheet + // - with BottomSheet and resizeToAvoidBottomInset: false + // - with SnackBar + Future<void> runFloatTests( + WidgetTester tester, + FloatingActionButtonLocation location, { + required Rect defaultRect, + required Rect bottomNavigationBarRect, + required Rect bottomSheetRect, + required Rect snackBarRect, + bool mini = false, + }) async { + const keyboardHeight = 200.0; + const viewPadding = 50.0; + final Key floatingActionButton = UniqueKey(); + const bottomNavHeight = 106.0; + // Default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(bottom: viewPadding)), + floatingActionButton, + mini: mini, + ), + ); + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(defaultRect)); + // Present keyboard and check position, should change + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(defaultRect.translate(0.0, viewPadding - keyboardHeight)), + ); + + // With resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(defaultRect)); + + // BottomNavigationBar default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + floatingActionButton, + bottomNavigationBar: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomNavigationBarRect), + ); + // Present keyboard and check position, FAB position changes + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals( + bottomNavigationBarRect.translate(0.0, -keyboardHeight + bottomNavHeight), + ), + ); + + // BottomNavigationBar with resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomNavigationBarRect), + ); + + // BottomNavigationBar + BottomSheet default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + floatingActionButton, + bottomNavigationBar: true, + bottomSheet: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect.translate(0.0, -bottomNavHeight)), + ); + // Present keyboard and check position, FAB position changes + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + bottomSheet: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect.translate(0.0, -keyboardHeight)), + ); + + // BottomNavigationBar + BottomSheet with resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + bottomSheet: true, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect.translate(0.0, -bottomNavHeight)), + ); + + // BottomSheet default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(bottom: viewPadding)), + floatingActionButton, + bottomSheet: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect), + ); + // Present keyboard and check position, bottomSheet and FAB both resize + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomSheet: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect.translate(0.0, -keyboardHeight)), + ); + + // bottomSheet with resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default bottomSheet position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomSheet: true, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect), + ); + + // SnackBar default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(bottom: viewPadding)), + floatingActionButton, + mini: mini, + ), + ); + await tester.tap(find.byKey(floatingActionButton)); + await tester.pumpAndSettle(); // Show SnackBar + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(snackBarRect)); + + // SnackBar when resized for presented keyboard + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + mini: mini, + ), + ); + await tester.tap(find.byKey(floatingActionButton)); + await tester.pumpAndSettle(); // Show SnackBar + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals( + snackBarRect.translate(0.0, -keyboardHeight + kFloatingActionButtonMargin / 2), + ), + ); + } + + testWidgets('startFloat', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(16.0, 478.0, 72.0, 534.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(16.0, 422.0, 72.0, 478.0); + // Position relative to BottomSheet + const bottomSheetRect = Rect.fromLTRB(16.0, 472.0, 72.0, 528.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(16.0, 478.0, 72.0, 534.0); + await runFloatTests( + tester, + FloatingActionButtonLocation.startFloat, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + ); + }); + + testWidgets('miniStartFloat', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(12.0, 490.0, 60.0, 538.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(12.0, 434.0, 60.0, 482.0); + // Positioned relative to BottomSheet + const bottomSheetRect = Rect.fromLTRB(12.0, 480.0, 60.0, 528.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(12.0, 490.0, 60.0, 538.0); + await runFloatTests( + tester, + FloatingActionButtonLocation.miniStartFloat, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + mini: true, + ); + }); + + testWidgets('centerFloat', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(372.0, 478.0, 428.0, 534.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(372.0, 422.0, 428.0, 478.0); + // Positioned relative to BottomSheet + const bottomSheetRect = Rect.fromLTRB(372.0, 472.0, 428.0, 528.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(372.0, 478.0, 428.0, 534.0); + await runFloatTests( + tester, + FloatingActionButtonLocation.centerFloat, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + ); + }); + + testWidgets('miniCenterFloat', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(376.0, 490.0, 424.0, 538.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(376.0, 434.0, 424.0, 482.0); + // Positioned relative to BottomSheet + const bottomSheetRect = Rect.fromLTRB(376.0, 480.0, 424.0, 528.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(376.0, 490.0, 424.0, 538.0); + await runFloatTests( + tester, + FloatingActionButtonLocation.miniCenterFloat, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + mini: true, + ); + }); + + testWidgets('endFloat', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(728.0, 478.0, 784.0, 534.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(728.0, 422.0, 784.0, 478.0); + // Positioned relative to BottomSheet + const bottomSheetRect = Rect.fromLTRB(728.0, 472.0, 784.0, 528.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(728.0, 478.0, 784.0, 534.0); + await runFloatTests( + tester, + FloatingActionButtonLocation.endFloat, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + ); + }); + + testWidgets('miniEndFloat', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(740.0, 490.0, 788.0, 538.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(740.0, 434.0, 788.0, 482.0); + // Positioned relative to BottomSheet + const bottomSheetRect = Rect.fromLTRB(740.0, 480.0, 788.0, 528.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(740.0, 490.0, 788.0, 538.0); + await runFloatTests( + tester, + FloatingActionButtonLocation.miniEndFloat, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + mini: true, + ); + }); + + // Test docked locations, for each (6), keyboard presented or not. + // If keyboard is presented and resizeToAvoidBottomInset: true, test whether + // the FAB is away from the keyboard(and thus not clipped): + // - Default + // - Default with resizeToAvoidBottomInset: false + // - docked with BottomNavigationBar + // - docked with BottomNavigationBar and resizeToAvoidBottomInset: false + // - docked with BottomNavigationBar & BottomSheet + // - docked with BottomNavigationBar & BottomSheet, resizeToAvoidBottomInset: false + // - with SnackBar + Future<void> runDockedTests( + WidgetTester tester, + FloatingActionButtonLocation location, { + required Rect defaultRect, + required Rect bottomNavigationBarRect, + required Rect bottomSheetRect, + required Rect snackBarRect, + bool mini = false, + }) async { + const keyboardHeight = 200.0; + const viewPadding = 50.0; + const bottomNavHeight = 106.0; + const scaffoldHeight = 600.0; + final Key floatingActionButton = UniqueKey(); + final fabHeight = mini ? 48.0 : 56.0; + // Default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(bottom: viewPadding)), + floatingActionButton, + mini: mini, + ), + ); + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(defaultRect)); + // Present keyboard and check position, should change + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals( + defaultRect.translate(0.0, viewPadding - keyboardHeight - kFloatingActionButtonMargin), + ), + ); + // The FAB should be away from the keyboard + expect( + tester.getRect(find.byKey(floatingActionButton)).bottom, + lessThan(scaffoldHeight - keyboardHeight), + ); + + // With resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(defaultRect)); + + // BottomNavigationBar default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + floatingActionButton, + bottomNavigationBar: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomNavigationBarRect), + ); + // Present keyboard and check position, FAB position changes + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals( + bottomNavigationBarRect.translate( + 0.0, + bottomNavHeight + + fabHeight / 2.0 - + keyboardHeight - + kFloatingActionButtonMargin - + fabHeight, + ), + ), + ); + // The FAB should be away from the keyboard + expect( + tester.getRect(find.byKey(floatingActionButton)).bottom, + lessThan(scaffoldHeight - keyboardHeight), + ); + + // BottomNavigationBar with resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomNavigationBarRect), + ); + + // BottomNavigationBar + BottomSheet default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + floatingActionButton, + bottomNavigationBar: true, + bottomSheet: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect), + ); + // Present keyboard and check position, FAB position changes + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + bottomSheet: true, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect.translate(0.0, -keyboardHeight + bottomNavHeight)), + ); + // The FAB should be away from the keyboard + expect( + tester.getRect(find.byKey(floatingActionButton)).bottom, + lessThan(scaffoldHeight - keyboardHeight), + ); + + // BottomNavigationBar + BottomSheet with resizeToAvoidBottomInset: false + // With keyboard presented, should maintain default position + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + bottomNavigationBar: true, + bottomSheet: true, + resizeToAvoidBottomInset: false, + mini: mini, + ), + ); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(bottomSheetRect), + ); + + // SnackBar default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(bottom: viewPadding)), + floatingActionButton, + mini: mini, + ), + ); + await tester.tap(find.byKey(floatingActionButton)); + await tester.pumpAndSettle(); // Show SnackBar + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(snackBarRect)); + + // SnackBar with BottomNavigationBar + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + padding: EdgeInsets.only(bottom: viewPadding), + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + floatingActionButton, + bottomNavigationBar: true, + mini: mini, + ), + ); + await tester.tap(find.byKey(floatingActionButton)); + await tester.pumpAndSettle(); // Show SnackBar + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(snackBarRect.translate(0.0, -bottomNavHeight)), + ); + + // SnackBar when resized for presented keyboard + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: viewPadding), + viewInsets: EdgeInsets.only(bottom: keyboardHeight), + ), + floatingActionButton, + mini: mini, + ), + ); + await tester.tap(find.byKey(floatingActionButton)); + await tester.pumpAndSettle(); // Show SnackBar + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(snackBarRect.translate(0.0, -keyboardHeight)), + ); + // The FAB should be away from the keyboard + expect( + tester.getRect(find.byKey(floatingActionButton)).bottom, + lessThan(scaffoldHeight - keyboardHeight), + ); + } + + testWidgets('startDocked', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(16.0, 494.0, 72.0, 550.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(16.0, 466.0, 72.0, 522.0); + // Positioned relative to BottomNavigationBar & BottomSheet + const bottomSheetRect = Rect.fromLTRB(16.0, 366.0, 72.0, 422.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(16.0, 486.0, 72.0, 542.0); + await runDockedTests( + tester, + FloatingActionButtonLocation.startDocked, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + ); + }); + + testWidgets('miniStartDocked', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(12.0, 502.0, 60.0, 550.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(12.0, 470.0, 60.0, 518.0); + // Positioned relative to BottomNavigationBar & BottomSheet + const bottomSheetRect = Rect.fromLTRB(12.0, 370.0, 60.0, 418.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(12.0, 494.0, 60.0, 542.0); + await runDockedTests( + tester, + FloatingActionButtonLocation.miniStartDocked, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + mini: true, + ); + }); + + testWidgets('centerDocked', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(372.0, 494.0, 428.0, 550.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(372.0, 466.0, 428.0, 522.0); + // Positioned relative to BottomNavigationBar & BottomSheet + const bottomSheetRect = Rect.fromLTRB(372.0, 366.0, 428.0, 422.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(372.0, 486.0, 428.0, 542.0); + await runDockedTests( + tester, + FloatingActionButtonLocation.centerDocked, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + ); + }); + + testWidgets('miniCenterDocked', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(376.0, 502.0, 424.0, 550.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(376.0, 470.0, 424.0, 518.0); + // Positioned relative to BottomNavigationBar & BottomSheet + const bottomSheetRect = Rect.fromLTRB(376.0, 370.0, 424.0, 418.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(376.0, 494.0, 424.0, 542.0); + await runDockedTests( + tester, + FloatingActionButtonLocation.miniCenterDocked, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + mini: true, + ); + }); + + testWidgets('endDocked', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(728.0, 494.0, 784.0, 550.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(728.0, 466.0, 784.0, 522.0); + // Positioned relative to BottomNavigationBar & BottomSheet + const bottomSheetRect = Rect.fromLTRB(728.0, 366.0, 784.0, 422.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(728.0, 486.0, 784.0, 542.0); + await runDockedTests( + tester, + FloatingActionButtonLocation.endDocked, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + ); + }); + + testWidgets('miniEndDocked', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(740.0, 502.0, 788.0, 550.0); + // Positioned relative to BottomNavigationBar + const bottomNavigationBarRect = Rect.fromLTRB(740.0, 470.0, 788.0, 518.0); + // Positioned relative to BottomNavigationBar & BottomSheet + const bottomSheetRect = Rect.fromLTRB(740.0, 370.0, 788.0, 418.0); + // Positioned relative to SnackBar + const snackBarRect = Rect.fromLTRB(740.0, 494.0, 788.0, 542.0); + await runDockedTests( + tester, + FloatingActionButtonLocation.miniEndDocked, + defaultRect: defaultRect, + bottomNavigationBarRect: bottomNavigationBarRect, + bottomSheetRect: bottomSheetRect, + snackBarRect: snackBarRect, + mini: true, + ); + }); + + // Test top locations, for each (6): + // - Default + // - with an AppBar + Future<void> runTopTests( + WidgetTester tester, + FloatingActionButtonLocation location, { + required Rect defaultRect, + required Rect appBarRect, + bool mini = false, + }) async { + const viewPadding = 50.0; + final Key floatingActionButton = UniqueKey(); + // Default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(top: viewPadding)), + floatingActionButton, + mini: mini, + ), + ); + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(defaultRect)); + + // AppBar default + await tester.pumpWidget( + buildTest( + location, + const MediaQueryData(viewPadding: EdgeInsets.only(top: viewPadding)), + floatingActionButton, + appBar: true, + mini: mini, + ), + ); + expect(tester.getRect(find.byKey(floatingActionButton)), rectMoreOrLessEquals(appBarRect)); + } + + testWidgets('startTop', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(16.0, 50.0, 72.0, 106.0); + // Positioned relative to AppBar + const appBarRect = Rect.fromLTRB(16.0, 28.0, 72.0, 84.0); + await runTopTests( + tester, + FloatingActionButtonLocation.startTop, + defaultRect: defaultRect, + appBarRect: appBarRect, + ); + }); + + testWidgets('miniStartTop', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(12.0, 50.0, 60.0, 98.0); + // Positioned relative to AppBar + const appBarRect = Rect.fromLTRB(12.0, 32.0, 60.0, 80.0); + await runTopTests( + tester, + FloatingActionButtonLocation.miniStartTop, + defaultRect: defaultRect, + appBarRect: appBarRect, + mini: true, + ); + }); + + testWidgets('centerTop', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(372.0, 50.0, 428.0, 106.0); + // Positioned relative to AppBar + const appBarRect = Rect.fromLTRB(372.0, 28.0, 428.0, 84.0); + await runTopTests( + tester, + FloatingActionButtonLocation.centerTop, + defaultRect: defaultRect, + appBarRect: appBarRect, + ); + }); + + testWidgets('miniCenterTop', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(376.0, 50.0, 424.0, 98.0); + // Positioned relative to AppBar + const appBarRect = Rect.fromLTRB(376.0, 32.0, 424.0, 80.0); + await runTopTests( + tester, + FloatingActionButtonLocation.miniCenterTop, + defaultRect: defaultRect, + appBarRect: appBarRect, + mini: true, + ); + }); + + testWidgets('endTop', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(728.0, 50.0, 784.0, 106.0); + // Positioned relative to AppBar + const appBarRect = Rect.fromLTRB(728.0, 28.0, 784.0, 84.0); + await runTopTests( + tester, + FloatingActionButtonLocation.endTop, + defaultRect: defaultRect, + appBarRect: appBarRect, + ); + }); + + testWidgets('miniEndTop', (WidgetTester tester) async { + const defaultRect = Rect.fromLTRB(740.0, 50.0, 788.0, 98.0); + // Positioned relative to AppBar + const appBarRect = Rect.fromLTRB(740.0, 32.0, 788.0, 80.0); + await runTopTests( + tester, + FloatingActionButtonLocation.miniEndTop, + defaultRect: defaultRect, + appBarRect: appBarRect, + mini: true, + ); + }); + }); +} + +class _GeometryListener extends StatefulWidget { + const _GeometryListener(); + + @override + State createState() => _GeometryListenerState(); +} + +class _GeometryListenerState extends State<_GeometryListener> { + @override + Widget build(BuildContext context) { + return CustomPaint(painter: cache); + } + + int numNotifications = 0; + ValueListenable<ScaffoldGeometry>? geometryListenable; + late _GeometryCachePainter cache; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ValueListenable<ScaffoldGeometry> newListenable = Scaffold.geometryOf(context); + if (geometryListenable == newListenable) { + return; + } + + geometryListenable?.removeListener(onGeometryChanged); + geometryListenable = newListenable..addListener(onGeometryChanged); + cache = _GeometryCachePainter(geometryListenable!); + } + + void onGeometryChanged() { + numNotifications += 1; + } +} + +const double _leftOffsetX = 44.0; +const double _centerOffsetX = 400.0; +const double _rightOffsetX = 756.0; +const double _miniLeftOffsetX = _leftOffsetX - kMiniButtonOffsetAdjustment; +const double _miniRightOffsetX = _rightOffsetX + kMiniButtonOffsetAdjustment; + +const double _topOffsetY = 56.0; +const double _floatOffsetY = 500.0; +const double _dockedOffsetY = 544.0; +const double _containedOffsetY = 544.0 + 56.0 / 2; +const double _miniFloatOffsetY = _floatOffsetY + kMiniButtonOffsetAdjustment; + +Widget _singleFabScaffold( + FloatingActionButtonLocation location, { + bool useMaterial3 = false, + FloatingActionButtonAnimator? animator, + bool mini = false, + TextDirection textDirection = TextDirection.ltr, +}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Directionality( + textDirection: textDirection, + child: Scaffold( + appBar: AppBar(title: const Text('FloatingActionButtonLocation Test.')), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), + BottomNavigationBarItem(icon: Icon(Icons.school), label: 'School'), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () {}, + mini: mini, + child: const Icon(Icons.beach_access), + ), + floatingActionButtonLocation: location, + floatingActionButtonAnimator: animator, + ), + ), + ); +} + +// The Scaffold.geometryOf() value is only available at paint time. +// To fetch it for the tests we implement this CustomPainter that just +// caches the ScaffoldGeometry value in its paint method. +class _GeometryCachePainter extends CustomPainter { + _GeometryCachePainter(this.geometryListenable) : super(repaint: geometryListenable); + + final ValueListenable<ScaffoldGeometry> geometryListenable; + + late ScaffoldGeometry value; + @override + void paint(Canvas canvas, Size size) { + value = geometryListenable.value; + } + + @override + bool shouldRepaint(_GeometryCachePainter oldDelegate) { + return true; + } +} + +Widget _buildFrame({ + FloatingActionButton? fab = const FloatingActionButton(onPressed: null, child: Text('1')), + FloatingActionButtonLocation? location, + _GeometryListener? listener, + TextDirection textDirection = TextDirection.ltr, + EdgeInsets viewInsets = const EdgeInsets.only(bottom: 200.0), + Widget? bab, +}) { + return Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(viewInsets: viewInsets), + child: Scaffold( + appBar: AppBar(title: const Text('FabLocation Test')), + floatingActionButtonLocation: location, + floatingActionButton: fab, + bottomNavigationBar: bab, + body: listener, + ), + ), + ), + ); +} + +class _StartTopFloatingActionButtonLocation extends FloatingActionButtonLocation { + const _StartTopFloatingActionButtonLocation(); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + double fabX; + switch (scaffoldGeometry.textDirection) { + case TextDirection.rtl: + final double startPadding = kFloatingActionButtonMargin + scaffoldGeometry.minInsets.right; + fabX = + scaffoldGeometry.scaffoldSize.width - + scaffoldGeometry.floatingActionButtonSize.width - + startPadding; + case TextDirection.ltr: + final double startPadding = kFloatingActionButtonMargin + scaffoldGeometry.minInsets.left; + fabX = startPadding; + } + final double fabY = + scaffoldGeometry.contentTop - (scaffoldGeometry.floatingActionButtonSize.height / 2.0); + return Offset(fabX, fabY); + } +} + +class _AlmostEndFloatFabLocation extends StandardFabLocation with FabEndOffsetX, FabFloatOffsetY { + @override + double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + final directionalAdjustment = scaffoldGeometry.textDirection == TextDirection.ltr + ? -50.0 + : 50.0; + return super.getOffsetX(scaffoldGeometry, adjustment) + directionalAdjustment; + } +} + +class _QuarterEndTopFabLocation extends StandardFabLocation with FabEndOffsetX, FabTopOffsetY { + @override + double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { + return super.getOffsetX(scaffoldGeometry, adjustment) * 0.75 + + (FloatingActionButtonLocation.startFloat as StandardFabLocation).getOffsetX( + scaffoldGeometry, + adjustment, + ) * + 0.25; + } +} + +class _LinearMovementFabAnimator extends FloatingActionButtonAnimator { + @override + Offset getOffset({required Offset begin, required Offset end, required double progress}) { + return Offset.lerp(begin, end, progress)!; + } + + @override + Animation<double> getScaleAnimation({required Animation<double> parent}) { + return const AlwaysStoppedAnimation<double>(1.0); + } + + @override + Animation<double> getRotationAnimation({required Animation<double> parent}) { + return const AlwaysStoppedAnimation<double>(1.0); + } +} + +class _NoScalingFabAnimator extends FloatingActionButtonAnimator { + @override + Offset getOffset({required Offset begin, required Offset end, required double progress}) { + return progress < 0.5 ? begin : end; + } + + @override + Animation<double> getScaleAnimation({required Animation<double> parent}) { + return const AlwaysStoppedAnimation<double>(1.0); + } + + @override + Animation<double> getRotationAnimation({required Animation<double> parent}) { + return const AlwaysStoppedAnimation<double>(1.0); + } +} diff --git a/packages/material_ui/test/material/floating_action_button_test.dart b/packages/material_ui/test/material/floating_action_button_test.dart new file mode 100644 index 000000000000..c9f78d8beedd --- /dev/null +++ b/packages/material_ui/test/material/floating_action_button_test.dart @@ -0,0 +1,1489 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +@TestOn('!chrome') +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + final material3Theme = ThemeData(); + final material2Theme = ThemeData(useMaterial3: false); + + testWidgets('Floating Action Button control test', (WidgetTester tester) async { + var didPressButton = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FloatingActionButton( + onPressed: () { + didPressButton = true; + }, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(didPressButton, isFalse); + await tester.tap(find.byType(Icon)); + expect(didPressButton, isTrue); + }); + + testWidgets('Floating Action Button tooltip', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + tooltip: 'Add', + child: const Icon(Icons.add), + ), + ), + ), + ); + + await tester.tap(find.byType(Icon)); + expect(find.byTooltip('Add'), findsOneWidget); + }); + + // Regression test for: https://github.com/flutter/flutter/pull/21084 + testWidgets('Floating Action Button tooltip (long press button edge)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + tooltip: 'Add', + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(find.text('Add'), findsNothing); + await tester.longPressAt(_rightEdgeOfFab(tester)); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsOneWidget); + }); + + // Regression test for: https://github.com/flutter/flutter/pull/21084 + testWidgets('Floating Action Button tooltip (long press button edge - no child)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}, tooltip: 'Add'), + ), + ), + ); + + expect(find.text('Add'), findsNothing); + await tester.longPressAt(_rightEdgeOfFab(tester)); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsOneWidget); + }); + + testWidgets('Floating Action Button tooltip (no child)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}, tooltip: 'Add'), + ), + ), + ); + + expect(find.text('Add'), findsNothing); + + // Test hover for tooltip. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(FloatingActionButton))); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsOneWidget); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsNothing); + + // Test long press for tooltip. + await tester.longPress(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsOneWidget); + }); + + testWidgets('Floating Action Button tooltip reacts when disabled', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: null, tooltip: 'Add')), + ), + ); + + expect(find.text('Add'), findsNothing); + + // Test hover for tooltip. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + await gesture.moveTo(tester.getCenter(find.byType(FloatingActionButton))); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsOneWidget); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsNothing); + + // Test long press for tooltip. + await tester.longPress(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.text('Add'), findsOneWidget); + }); + + testWidgets('Floating Action Button elevation when highlighted - effect', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: () {})), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + final TestGesture gesture = await tester.press(find.byType(PhysicalShape)); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}, highlightElevation: 20.0), + ), + ), + ); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 20.0); + await gesture.up(); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 20.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + }); + + testWidgets('Floating Action Button elevation when disabled - defaults', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: null)), + ), + ); + + // Disabled elevation defaults to regular default elevation. + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + }); + + testWidgets('Floating Action Button elevation when disabled - override', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null, disabledElevation: 0), + ), + ), + ); + + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 0.0); + }); + + testWidgets('Floating Action Button elevation when disabled - effect', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: null)), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null, disabledElevation: 3.0), + ), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 3.0); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}, disabledElevation: 3.0), + ), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 3.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + }); + + testWidgets('Floating Action Button elevation when disabled while highlighted - effect', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: () {})), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.press(find.byType(PhysicalShape)); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: const Scaffold(floatingActionButton: FloatingActionButton(onPressed: null)), + ), + ); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: () {})), + ), + ); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + }); + + testWidgets('Floating Action Button states elevation', (WidgetTester tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: Scaffold( + body: FloatingActionButton.extended( + label: const Text('tooltip'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ); + + final Finder fabFinder = find.byType(PhysicalShape); + PhysicalShape getFABWidget(Finder finder) => tester.widget<PhysicalShape>(finder); + + // Default, not disabled. + expect(getFABWidget(fabFinder).elevation, 6); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getFABWidget(fabFinder).elevation, 6); + + // Hovered. + final Offset center = tester.getCenter(fabFinder); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getFABWidget(fabFinder).elevation, 8); + + // 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(getFABWidget(fabFinder).elevation, 6); + + focusNode.dispose(); + }); + + testWidgets('FlatActionButton mini size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + final Key key1 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Scaffold( + floatingActionButton: FloatingActionButton(key: key1, mini: true, onPressed: null), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key1)), const Size(48.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Scaffold( + floatingActionButton: FloatingActionButton(key: key2, mini: true, onPressed: null), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key2)), const Size(40.0, 40.0)); + }); + + testWidgets('FloatingActionButton.isExtended', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: const Scaffold(floatingActionButton: FloatingActionButton(onPressed: null)), + ), + ); + + final Finder fabFinder = find.byType(FloatingActionButton); + + FloatingActionButton getFabWidget() { + return tester.widget<FloatingActionButton>(fabFinder); + } + + final Finder materialButtonFinder = find.byType(RawMaterialButton); + + RawMaterialButton getRawMaterialButtonWidget() { + return tester.widget<RawMaterialButton>(materialButtonFinder); + } + + expect(getFabWidget().isExtended, false); + expect( + getRawMaterialButtonWidget().shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: SizedBox(width: 100.0, child: Text('label')), + icon: Icon(Icons.android), + onPressed: null, + ), + ), + ), + ); + + expect(getFabWidget().isExtended, true); + expect( + getRawMaterialButtonWidget().shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + ); + expect(find.text('label'), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); + + // Verify that the widget's height is 56 and that its internal + /// horizontal layout is: 16 icon 8 label 20 + expect(tester.getSize(fabFinder).height, 56.0); + + final double fabLeft = tester.getTopLeft(fabFinder).dx; + final double fabRight = tester.getTopRight(fabFinder).dx; + final double iconLeft = tester.getTopLeft(find.byType(Icon)).dx; + final double iconRight = tester.getTopRight(find.byType(Icon)).dx; + final double labelLeft = tester.getTopLeft(find.text('label')).dx; + final double labelRight = tester.getTopRight(find.text('label')).dx; + expect(iconLeft - fabLeft, 16.0); + expect(labelLeft - iconRight, 8.0); + expect(fabRight - labelRight, 20.0); + + // The overall width of the button is: + // 168 = 16 + 24(icon) + 8 + 100(label) + 20 + expect(tester.getSize(find.byType(Icon)).width, 24.0); + expect(tester.getSize(find.text('label')).width, 100.0); + expect(tester.getSize(fabFinder).width, 168); + }); + + testWidgets('FloatingActionButton.isExtended (without icon)', (WidgetTester tester) async { + final Finder fabFinder = find.byType(FloatingActionButton); + + FloatingActionButton getFabWidget() { + return tester.widget<FloatingActionButton>(fabFinder); + } + + final Finder materialButtonFinder = find.byType(RawMaterialButton); + + RawMaterialButton getRawMaterialButtonWidget() { + return tester.widget<RawMaterialButton>(materialButtonFinder); + } + + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: const Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: SizedBox(width: 100.0, child: Text('label')), + onPressed: null, + ), + ), + ), + ); + + expect(getFabWidget().isExtended, true); + expect( + getRawMaterialButtonWidget().shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + ); + expect(find.text('label'), findsOneWidget); + expect(find.byType(Icon), findsNothing); + + // Verify that the widget's height is 56 and that its internal + /// horizontal layout is: 20 label 20 + expect(tester.getSize(fabFinder).height, 56.0); + + final double fabLeft = tester.getTopLeft(fabFinder).dx; + final double fabRight = tester.getTopRight(fabFinder).dx; + final double labelLeft = tester.getTopLeft(find.text('label')).dx; + final double labelRight = tester.getTopRight(find.text('label')).dx; + expect(labelLeft - fabLeft, 20.0); + expect(fabRight - labelRight, 20.0); + + // The overall width of the button is: + // 140 = 20 + 100(label) + 20 + expect(tester.getSize(find.text('label')).width, 100.0); + expect(tester.getSize(fabFinder).width, 140); + }); + + testWidgets('Floating Action Button heroTag', (WidgetTester tester) async { + late BuildContext theContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + theContext = context; + return const FloatingActionButton(heroTag: 1, onPressed: null); + }, + ), + floatingActionButton: const FloatingActionButton(heroTag: 2, onPressed: null), + ), + ), + ); + Navigator.push( + theContext, + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Placeholder(); + }, + ), + ); + await tester + .pump(); // this would fail if heroTag was the same on both FloatingActionButtons (see below). + }); + + testWidgets('Floating Action Button heroTag - with duplicate', (WidgetTester tester) async { + late BuildContext theContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + theContext = context; + return const FloatingActionButton(onPressed: null); + }, + ), + floatingActionButton: const FloatingActionButton(onPressed: null), + ), + ), + ); + Navigator.push( + theContext, + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Placeholder(); + }, + ), + ); + await tester.pump(); + expect(tester.takeException().toString(), contains('FloatingActionButton')); + }); + + testWidgets('Floating Action Button heroTag - with duplicate', (WidgetTester tester) async { + late BuildContext theContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + theContext = context; + return const FloatingActionButton(heroTag: 'xyzzy', onPressed: null); + }, + ), + floatingActionButton: const FloatingActionButton(heroTag: 'xyzzy', onPressed: null), + ), + ), + ); + Navigator.push( + theContext, + PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return const Placeholder(); + }, + ), + ); + await tester.pump(); + expect(tester.takeException().toString(), contains('xyzzy')); + }); + + testWidgets('Floating Action Button semantics (enabled)', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add, semanticLabel: 'Add'), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + label: 'Add', + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Floating Action Button semantics (disabled)', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FloatingActionButton( + onPressed: null, + child: Icon(Icons.add, semanticLabel: 'Add'), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + label: 'Add', + flags: <SemanticsFlag>[SemanticsFlag.isButton, SemanticsFlag.hasEnabledState], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Tooltip is used as semantics tooltip', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + tooltip: 'Add Photo', + child: const Icon(Icons.add_a_photo), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + tooltip: 'Add Photo', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('extended FAB hero transitions succeed', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/18782 + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: Builder( + builder: (BuildContext context) { + // define context of Navigator.push() + return FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('A long FAB label'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('X'), + onPressed: () {}, + ), + body: Center( + child: ElevatedButton( + child: const Text('POP'), + onPressed: () { + Navigator.pop(context); + }, + ), + ), + ); + }, + ), + ); + }, + ); + }, + ), + body: const Center(child: Text('Hello World')), + ), + ), + ); + + final Finder longFAB = find.text('A long FAB label'); + final Finder shortFAB = find.text('X'); + final Finder helloWorld = find.text('Hello World'); + + expect(longFAB, findsOneWidget); + expect(shortFAB, findsNothing); + expect(helloWorld, findsOneWidget); + + await tester.tap(longFAB); + await tester.pumpAndSettle(); + + expect(shortFAB, findsOneWidget); + expect(longFAB, findsNothing); + + // Trigger a hero transition from shortFAB to longFAB. + await tester.tap(find.text('POP')); + await tester.pumpAndSettle(); + + expect(longFAB, findsOneWidget); + expect(shortFAB, findsNothing); + expect(helloWorld, findsOneWidget); + }); + + // This test prevents https://github.com/flutter/flutter/issues/20483 + testWidgets('Floating Action Button clips ink splash and highlight', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: material3Theme, + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)), + ), + ), + ), + ), + ); + + await tester.press(find.byKey(key)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1000)); + await expectLater(find.byKey(key), matchesGoldenFile('floating_action_button_test.clip.png')); + }); + + testWidgets('Floating Action Button changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: FloatingActionButton.extended( + onPressed: () {}, + mouseCursor: SystemMouseCursors.text, + label: const Text('label'), + icon: const Icon(Icons.android), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(FloatingActionButton))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: FloatingActionButton( + onPressed: () {}, + mouseCursor: SystemMouseCursors.text, + child: const Icon(Icons.add), + ), + ), + ), + ), + ); + + await gesture.moveTo(tester.getCenter(find.byType(FloatingActionButton))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: FloatingActionButton(onPressed: null, child: Icon(Icons.add)), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('Floating Action Button has no clip by default', (WidgetTester tester) async { + final focusNode = FocusNode(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FloatingActionButton( + focusNode: focusNode, + onPressed: () { + /* to make sure the button is enabled */ + }, + ), + ), + ); + + focusNode.unfocus(); + await tester.pump(); + + expect( + tester.renderObject(find.byType(FloatingActionButton)), + paintsExactlyCountTimes(#clipPath, 0), + ); + + focusNode.dispose(); + }); + + testWidgets('Can find FloatingActionButton semantics', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: FloatingActionButton(onPressed: () {}))); + + expect( + tester.getSemantics(find.byType(FloatingActionButton)), + matchesSemantics( + hasTapAction: true, + hasFocusAction: true, + hasEnabledState: true, + isButton: true, + isEnabled: true, + isFocusable: true, + ), + ); + }); + + testWidgets('Foreground color applies to icon on fab', (WidgetTester tester) async { + const foregroundColor = Color(0xcafefeed); + + await tester.pumpWidget( + MaterialApp( + home: FloatingActionButton( + onPressed: () {}, + foregroundColor: foregroundColor, + child: const Icon(Icons.access_alarm), + ), + ), + ); + + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(Icons.access_alarm), matching: find.byType(RichText)), + ); + expect(iconRichText.text.style!.color, foregroundColor); + }); + + testWidgets('FloatingActionButton uses custom splash color', (WidgetTester tester) async { + const splashColor = Color(0xcafefeed); + + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: FloatingActionButton( + onPressed: () {}, + splashColor: splashColor, + child: const Icon(Icons.access_alarm), + ), + ), + ); + + await tester.press(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), paints..circle(color: splashColor)); + }); + + testWidgets('extended FAB does not show label when isExtended is false', ( + WidgetTester tester, + ) async { + const iconKey = Key('icon'); + const labelKey = Key('label'); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FloatingActionButton.extended( + isExtended: false, + label: const Text('', key: labelKey), + icon: const Icon(Icons.add, key: iconKey), + onPressed: () {}, + ), + ), + ); + + // Verify that Icon is present and label is not. + expect(find.byKey(iconKey), findsOneWidget); + expect(find.byKey(labelKey), findsNothing); + }); + + testWidgets('FloatingActionButton.small configures correct size', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton.small( + key: key, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: null, + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key)), const Size(40.0, 40.0)); + }); + + testWidgets('FloatingActionButton.large configures correct size', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(floatingActionButton: FloatingActionButton.large(key: key, onPressed: null)), + ), + ); + + expect(tester.getSize(find.byKey(key)), const Size(96.0, 96.0)); + }); + + testWidgets('FloatingActionButton.extended can customize spacing', (WidgetTester tester) async { + const iconKey = Key('icon'); + const labelKey = Key('label'); + const spacing = 33.0; + const padding = EdgeInsetsDirectional.only(start: 5.0, end: 6.0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: const Text('', key: labelKey), + icon: const Icon(Icons.add, key: iconKey), + extendedIconLabelSpacing: spacing, + extendedPadding: padding, + onPressed: () {}, + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byKey(labelKey)).dx - tester.getTopRight(find.byKey(iconKey)).dx, + spacing, + ); + expect( + tester.getTopLeft(find.byKey(iconKey)).dx - + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + padding.start, + ); + expect( + tester.getTopRight(find.byType(FloatingActionButton)).dx - + tester.getTopRight(find.byKey(labelKey)).dx, + padding.end, + ); + }); + + testWidgets('FloatingActionButton.extended can customize text style', ( + WidgetTester tester, + ) async { + const labelKey = Key('label'); + const style = TextStyle(letterSpacing: 2.0); + + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: const Text('', key: labelKey), + icon: const Icon(Icons.add), + extendedTextStyle: style, + onPressed: () {}, + ), + ), + ), + ); + + final RawMaterialButton rawMaterialButton = tester.widget<RawMaterialButton>( + find.descendant( + of: find.byType(FloatingActionButton), + matching: find.byType(RawMaterialButton), + ), + ); + // The color comes from the default color scheme's onSecondary value. + expect(rawMaterialButton.textStyle, style.copyWith(color: const Color(0xffffffff))); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Floating Action Button elevation when highlighted - effect', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: () {})), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + final TestGesture gesture = await tester.press(find.byType(PhysicalShape)); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0); + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}, highlightElevation: 20.0), + ), + ), + ); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 20.0); + await gesture.up(); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 20.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + }); + + testWidgets('Floating Action Button elevation when disabled while highlighted - effect', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: () {})), + ), + ); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.press(find.byType(PhysicalShape)); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0); + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: const Scaffold(floatingActionButton: FloatingActionButton(onPressed: null)), + ), + ); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 12.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold(floatingActionButton: FloatingActionButton(onPressed: () {})), + ), + ); + await tester.pump(); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget<PhysicalShape>(find.byType(PhysicalShape)).elevation, 6.0); + }); + + testWidgets('Floating Action Button states elevation', (WidgetTester tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold( + body: FloatingActionButton.extended( + label: const Text('tooltip'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ); + + final Finder fabFinder = find.byType(PhysicalShape); + PhysicalShape getFABWidget(Finder finder) => tester.widget<PhysicalShape>(finder); + + // Default, not disabled. + expect(getFABWidget(fabFinder).elevation, 6); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getFABWidget(fabFinder).elevation, 6); + + // Hovered. + final Offset center = tester.getCenter(fabFinder); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getFABWidget(fabFinder).elevation, 8); + + // 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(getFABWidget(fabFinder).elevation, 12); + + focusNode.dispose(); + }); + + testWidgets('FloatingActionButton.isExtended', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: const Scaffold(floatingActionButton: FloatingActionButton(onPressed: null)), + ), + ); + + final Finder fabFinder = find.byType(FloatingActionButton); + + FloatingActionButton getFabWidget() { + return tester.widget<FloatingActionButton>(fabFinder); + } + + final Finder materialButtonFinder = find.byType(RawMaterialButton); + + RawMaterialButton getRawMaterialButtonWidget() { + return tester.widget<RawMaterialButton>(materialButtonFinder); + } + + expect(getFabWidget().isExtended, false); + expect(getRawMaterialButtonWidget().shape, const CircleBorder()); + + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: const Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: SizedBox(width: 100.0, child: Text('label')), + icon: Icon(Icons.android), + onPressed: null, + ), + ), + ), + ); + + expect(getFabWidget().isExtended, true); + expect(getRawMaterialButtonWidget().shape, const StadiumBorder()); + expect(find.text('label'), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); + + // Verify that the widget's height is 48 and that its internal + /// horizontal layout is: 16 icon 8 label 20 + expect(tester.getSize(fabFinder).height, 48.0); + + final double fabLeft = tester.getTopLeft(fabFinder).dx; + final double fabRight = tester.getTopRight(fabFinder).dx; + final double iconLeft = tester.getTopLeft(find.byType(Icon)).dx; + final double iconRight = tester.getTopRight(find.byType(Icon)).dx; + final double labelLeft = tester.getTopLeft(find.text('label')).dx; + final double labelRight = tester.getTopRight(find.text('label')).dx; + expect(iconLeft - fabLeft, 16.0); + expect(labelLeft - iconRight, 8.0); + expect(fabRight - labelRight, 20.0); + + // The overall width of the button is: + // 168 = 16 + 24(icon) + 8 + 100(label) + 20 + expect(tester.getSize(find.byType(Icon)).width, 24.0); + expect(tester.getSize(find.text('label')).width, 100.0); + expect(tester.getSize(fabFinder).width, 168); + }); + + testWidgets('FloatingActionButton.isExtended (without icon)', (WidgetTester tester) async { + final Finder fabFinder = find.byType(FloatingActionButton); + + FloatingActionButton getFabWidget() { + return tester.widget<FloatingActionButton>(fabFinder); + } + + final Finder materialButtonFinder = find.byType(RawMaterialButton); + + RawMaterialButton getRawMaterialButtonWidget() { + return tester.widget<RawMaterialButton>(materialButtonFinder); + } + + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: const Scaffold( + floatingActionButton: FloatingActionButton.extended( + label: SizedBox(width: 100.0, child: Text('label')), + onPressed: null, + ), + ), + ), + ); + + expect(getFabWidget().isExtended, true); + expect(getRawMaterialButtonWidget().shape, const StadiumBorder()); + expect(find.text('label'), findsOneWidget); + expect(find.byType(Icon), findsNothing); + + // Verify that the widget's height is 48 and that its internal + /// horizontal layout is: 20 label 20 + expect(tester.getSize(fabFinder).height, 48.0); + + final double fabLeft = tester.getTopLeft(fabFinder).dx; + final double fabRight = tester.getTopRight(fabFinder).dx; + final double labelLeft = tester.getTopLeft(find.text('label')).dx; + final double labelRight = tester.getTopRight(find.text('label')).dx; + expect(labelLeft - fabLeft, 20.0); + expect(fabRight - labelRight, 20.0); + + // The overall width of the button is: + // 140 = 20 + 100(label) + 20 + expect(tester.getSize(find.text('label')).width, 100.0); + expect(tester.getSize(fabFinder).width, 140); + }); + + // This test prevents https://github.com/flutter/flutter/issues/20483 + testWidgets('Floating Action Button clips ink splash and highlight', ( + WidgetTester tester, + ) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: material2Theme, + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)), + ), + ), + ), + ), + ); + + await tester.press(find.byKey(key)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1000)); + await expectLater( + find.byKey(key), + matchesGoldenFile('floating_action_button_test_m2.clip.png'), + ); + }); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('FloatingActionButton with enabled feedback', (WidgetTester tester) async { + const enableFeedback = true; + + await tester.pumpWidget( + MaterialApp( + home: FloatingActionButton( + onPressed: () {}, + enableFeedback: enableFeedback, + child: const Icon(Icons.access_alarm), + ), + ), + ); + + await tester.tap(find.byType(RawMaterialButton)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('FloatingActionButton with disabled feedback', (WidgetTester tester) async { + const enableFeedback = false; + + await tester.pumpWidget( + MaterialApp( + home: FloatingActionButton( + onPressed: () {}, + enableFeedback: enableFeedback, + child: const Icon(Icons.access_alarm), + ), + ), + ); + + await tester.tap(find.byType(RawMaterialButton)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets('FloatingActionButton with enabled feedback by default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.access_alarm)), + ), + ); + + await tester.tap(find.byType(RawMaterialButton)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('FloatingActionButton with disabled feedback using FloatingActionButtonTheme', ( + WidgetTester tester, + ) async { + const enableFeedbackTheme = false; + final theme = ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + enableFeedback: enableFeedbackTheme, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: theme, + child: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.access_alarm)), + ), + ), + ); + + await tester.tap(find.byType(RawMaterialButton)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets( + 'FloatingActionButton.enableFeedback is overridden by FloatingActionButtonThemeData.enableFeedback', + (WidgetTester tester) async { + const enableFeedbackTheme = false; + const enableFeedback = true; + final theme = ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + enableFeedback: enableFeedbackTheme, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: theme, + child: FloatingActionButton( + enableFeedback: enableFeedback, + onPressed: () {}, + child: const Icon(Icons.access_alarm), + ), + ), + ), + ); + + await tester.tap(find.byType(RawMaterialButton)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }, + ); + }); + + testWidgets('FloatingActionButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: Center( + child: SizedBox.shrink(child: FloatingActionButton(onPressed: () {})), + ), + ), + ), + ); + expect(tester.getSize(find.byType(FloatingActionButton)), Size.zero); + }); +} + +Offset _rightEdgeOfFab(WidgetTester tester) { + final Finder fab = find.byType(FloatingActionButton); + return tester.getRect(fab).centerRight - const Offset(1.0, 0.0); +} diff --git a/packages/material_ui/test/material/floating_action_button_theme_test.dart b/packages/material_ui/test/material/floating_action_button_theme_test.dart new file mode 100644 index 000000000000..bc93700d0d13 --- /dev/null +++ b/packages/material_ui/test/material/floating_action_button_theme_test.dart @@ -0,0 +1,696 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('FloatingActionButtonThemeData copyWith, ==, hashCode basics', () { + expect(const FloatingActionButtonThemeData(), const FloatingActionButtonThemeData().copyWith()); + expect( + const FloatingActionButtonThemeData().hashCode, + const FloatingActionButtonThemeData().copyWith().hashCode, + ); + }); + + test('FloatingActionButtonThemeData lerp special cases', () { + expect(FloatingActionButtonThemeData.lerp(null, null, 0), null); + const data = FloatingActionButtonThemeData(); + expect(identical(FloatingActionButtonThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets( + 'Material3: Default values are used when no FloatingActionButton or FloatingActionButtonThemeData properties are specified', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).fillColor, colorScheme.primaryContainer); + expect(_getRichText(tester).text.style!.color, colorScheme.onPrimaryContainer); + + // These defaults come directly from the [FloatingActionButton]. + expect(_getRawMaterialButton(tester).elevation, 6); + expect(_getRawMaterialButton(tester).highlightElevation, 6); + expect( + _getRawMaterialButton(tester).shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + ); + expect( + _getRawMaterialButton(tester).splashColor, + colorScheme.onPrimaryContainer.withOpacity(0.1), + ); + expect( + _getRawMaterialButton(tester).constraints, + const BoxConstraints.tightFor(width: 56.0, height: 56.0), + ); + expect(_getIconSize(tester).width, 24.0); + expect(_getIconSize(tester).height, 24.0); + }, + ); + + testWidgets( + 'Material2: Default values are used when no FloatingActionButton or FloatingActionButtonThemeData properties are specified', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(useMaterial3: false, colorScheme: colorScheme), + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).fillColor, colorScheme.secondary); + expect(_getRichText(tester).text.style!.color, colorScheme.onSecondary); + + // These defaults come directly from the [FloatingActionButton]. + expect(_getRawMaterialButton(tester).elevation, 6); + expect(_getRawMaterialButton(tester).highlightElevation, 12); + expect(_getRawMaterialButton(tester).shape, const CircleBorder()); + expect(_getRawMaterialButton(tester).splashColor, ThemeData().splashColor); + expect( + _getRawMaterialButton(tester).constraints, + const BoxConstraints.tightFor(width: 56.0, height: 56.0), + ); + expect(_getIconSize(tester).width, 24.0); + expect(_getIconSize(tester).height, 24.0); + }, + ); + + testWidgets( + 'FloatingActionButtonThemeData values are used when no FloatingActionButton properties are specified', + (WidgetTester tester) async { + const backgroundColor = Color(0xBEEFBEEF); + const foregroundColor = Color(0xFACEFACE); + const splashColor = Color(0xCAFEFEED); + const double elevation = 7; + const double disabledElevation = 1; + const double highlightElevation = 13; + const ShapeBorder shape = StadiumBorder(); + const constraints = BoxConstraints.tightFor(width: 100.0, height: 100.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + splashColor: splashColor, + elevation: elevation, + disabledElevation: disabledElevation, + highlightElevation: highlightElevation, + shape: shape, + sizeConstraints: constraints, + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).fillColor, backgroundColor); + expect(_getRichText(tester).text.style!.color, foregroundColor); + expect(_getRawMaterialButton(tester).elevation, elevation); + expect(_getRawMaterialButton(tester).disabledElevation, disabledElevation); + expect(_getRawMaterialButton(tester).highlightElevation, highlightElevation); + expect(_getRawMaterialButton(tester).shape, shape); + expect(_getRawMaterialButton(tester).splashColor, splashColor); + expect(_getRawMaterialButton(tester).constraints, constraints); + }, + ); + + testWidgets( + 'FloatingActionButton values take priority over FloatingActionButtonThemeData values when both properties are specified', + (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const foregroundColor = Color(0x00000002); + const splashColor = Color(0x00000003); + const double elevation = 7; + const double disabledElevation = 1; + const double highlightElevation = 13; + const ShapeBorder shape = StadiumBorder(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: Color(0x00000004), + foregroundColor: Color(0x00000005), + splashColor: Color(0x00000006), + elevation: 23, + disabledElevation: 11, + highlightElevation: 43, + shape: BeveledRectangleBorder(), + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + splashColor: splashColor, + elevation: elevation, + disabledElevation: disabledElevation, + highlightElevation: highlightElevation, + shape: shape, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).fillColor, backgroundColor); + expect(_getRichText(tester).text.style!.color, foregroundColor); + expect(_getRawMaterialButton(tester).elevation, elevation); + expect(_getRawMaterialButton(tester).disabledElevation, disabledElevation); + expect(_getRawMaterialButton(tester).highlightElevation, highlightElevation); + expect(_getRawMaterialButton(tester).shape, shape); + expect(_getRawMaterialButton(tester).splashColor, splashColor); + }, + ); + + testWidgets('Local FloatingActionButtonTheme properties are used', (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const foregroundColor = Color(0x00000002); + const splashColor = Color(0x00000003); + const double elevation = 7; + const double disabledElevation = 1; + const double highlightElevation = 13; + const ShapeBorder shape = StadiumBorder(); + + await tester.pumpWidget( + MaterialApp( + home: FloatingActionButtonTheme( + data: const FloatingActionButtonThemeData( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + splashColor: splashColor, + elevation: elevation, + disabledElevation: disabledElevation, + highlightElevation: highlightElevation, + shape: shape, + ), + child: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).fillColor, backgroundColor); + expect(_getRichText(tester).text.style!.color, foregroundColor); + expect(_getRawMaterialButton(tester).elevation, elevation); + expect(_getRawMaterialButton(tester).disabledElevation, disabledElevation); + expect(_getRawMaterialButton(tester).highlightElevation, highlightElevation); + expect(_getRawMaterialButton(tester).shape, shape); + expect(_getRawMaterialButton(tester).splashColor, splashColor); + }); + + testWidgets( + 'Local FloatingActionButtonTheme takes priority over ThemeData.floatingActionButtonTheme', + (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const foregroundColor = Color(0x00000002); + const splashColor = Color(0x00000003); + const double elevation = 7; + const double disabledElevation = 1; + const double highlightElevation = 13; + const ShapeBorder shape = StadiumBorder(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: Color(0x00000004), + foregroundColor: Color(0x00000005), + splashColor: Color(0x00000006), + elevation: 23, + disabledElevation: 11, + highlightElevation: 43, + shape: BeveledRectangleBorder(), + ), + ), + home: FloatingActionButtonTheme( + data: const FloatingActionButtonThemeData( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + splashColor: splashColor, + elevation: elevation, + disabledElevation: disabledElevation, + highlightElevation: highlightElevation, + shape: shape, + ), + child: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).fillColor, backgroundColor); + expect(_getRichText(tester).text.style!.color, foregroundColor); + expect(_getRawMaterialButton(tester).elevation, elevation); + expect(_getRawMaterialButton(tester).disabledElevation, disabledElevation); + expect(_getRawMaterialButton(tester).highlightElevation, highlightElevation); + expect(_getRawMaterialButton(tester).shape, shape); + expect(_getRawMaterialButton(tester).splashColor, splashColor); + }, + ); + + testWidgets('FloatingActionButton uses a custom shape when specified in the theme', ( + WidgetTester tester, + ) async { + const ShapeBorder customShape = BeveledRectangleBorder(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: () {}, shape: customShape), + ), + ), + ); + + expect(_getRawMaterialButton(tester).shape, customShape); + }); + + testWidgets('FloatingActionButton.small uses custom constraints when specified in the theme', ( + WidgetTester tester, + ) async { + const constraints = BoxConstraints.tightFor(width: 100.0, height: 100.0); + const iconSize = 24.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + smallSizeConstraints: constraints, + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton.small( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).constraints, constraints); + expect(_getIconSize(tester).width, iconSize); + expect(_getIconSize(tester).height, iconSize); + }); + + testWidgets('FloatingActionButton.large uses custom constraints when specified in the theme', ( + WidgetTester tester, + ) async { + const constraints = BoxConstraints.tightFor(width: 100.0, height: 100.0); + const iconSize = 36.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + floatingActionButtonTheme: const FloatingActionButtonThemeData( + largeSizeConstraints: constraints, + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton.large( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).constraints, constraints); + expect(_getIconSize(tester).width, iconSize); + expect(_getIconSize(tester).height, iconSize); + }); + + testWidgets( + 'Material3: FloatingActionButton.extended uses custom properties when specified in the theme', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const iconKey = Key('icon'); + const labelKey = Key('label'); + const constraints = BoxConstraints.tightFor(height: 100.0); + const iconLabelSpacing = 33.0; + const padding = EdgeInsetsDirectional.only(start: 5.0, end: 6.0); + const textStyle = TextStyle(letterSpacing: 2.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: colorScheme, + floatingActionButtonTheme: const FloatingActionButtonThemeData( + extendedSizeConstraints: constraints, + extendedIconLabelSpacing: iconLabelSpacing, + extendedPadding: padding, + extendedTextStyle: textStyle, + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: () {}, + label: const Text('Extended', key: labelKey), + icon: const Icon(Icons.add, key: iconKey), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).constraints, constraints); + expect( + tester.getTopLeft(find.byKey(labelKey)).dx - tester.getTopRight(find.byKey(iconKey)).dx, + iconLabelSpacing, + ); + expect( + tester.getTopLeft(find.byKey(iconKey)).dx - + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + padding.start, + ); + expect( + tester.getTopRight(find.byType(FloatingActionButton)).dx - + tester.getTopRight(find.byKey(labelKey)).dx, + padding.end, + ); + expect( + _getRawMaterialButton(tester).textStyle, + textStyle.copyWith(color: colorScheme.onPrimaryContainer), + ); + }, + ); + + testWidgets( + 'Material2: FloatingActionButton.extended uses custom properties when specified in the theme', + (WidgetTester tester) async { + const iconKey = Key('icon'); + const labelKey = Key('label'); + const constraints = BoxConstraints.tightFor(height: 100.0); + const iconLabelSpacing = 33.0; + const padding = EdgeInsetsDirectional.only(start: 5.0, end: 6.0); + const textStyle = TextStyle(letterSpacing: 2.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + floatingActionButtonTheme: const FloatingActionButtonThemeData( + extendedSizeConstraints: constraints, + extendedIconLabelSpacing: iconLabelSpacing, + extendedPadding: padding, + extendedTextStyle: textStyle, + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: () {}, + label: const Text('Extended', key: labelKey), + icon: const Icon(Icons.add, key: iconKey), + ), + ), + ), + ); + + expect(_getRawMaterialButton(tester).constraints, constraints); + expect( + tester.getTopLeft(find.byKey(labelKey)).dx - tester.getTopRight(find.byKey(iconKey)).dx, + iconLabelSpacing, + ); + expect( + tester.getTopLeft(find.byKey(iconKey)).dx - + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + padding.start, + ); + expect( + tester.getTopRight(find.byType(FloatingActionButton)).dx - + tester.getTopRight(find.byKey(labelKey)).dx, + padding.end, + ); + // The color comes from the default color scheme's onSecondary value. + expect( + _getRawMaterialButton(tester).textStyle, + textStyle.copyWith(color: const Color(0xffffffff)), + ); + }, + ); + + testWidgets( + 'Material3: FloatingActionButton.extended custom properties takes priority over FloatingActionButtonThemeData spacing', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const iconKey = Key('icon'); + const labelKey = Key('label'); + const iconLabelSpacing = 33.0; + const padding = EdgeInsetsDirectional.only(start: 5.0, end: 6.0); + const textStyle = TextStyle(letterSpacing: 2.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: colorScheme, + floatingActionButtonTheme: const FloatingActionButtonThemeData( + extendedIconLabelSpacing: 25.0, + extendedPadding: EdgeInsetsDirectional.only(start: 7.0, end: 8.0), + extendedTextStyle: TextStyle(letterSpacing: 3.0), + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: () {}, + label: const Text('Extended', key: labelKey), + icon: const Icon(Icons.add, key: iconKey), + extendedIconLabelSpacing: iconLabelSpacing, + extendedPadding: padding, + extendedTextStyle: textStyle, + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byKey(labelKey)).dx - tester.getTopRight(find.byKey(iconKey)).dx, + iconLabelSpacing, + ); + expect( + tester.getTopLeft(find.byKey(iconKey)).dx - + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + padding.start, + ); + expect( + tester.getTopRight(find.byType(FloatingActionButton)).dx - + tester.getTopRight(find.byKey(labelKey)).dx, + padding.end, + ); + expect( + _getRawMaterialButton(tester).textStyle, + textStyle.copyWith(color: colorScheme.onPrimaryContainer), + ); + }, + ); + + testWidgets( + 'Material2: FloatingActionButton.extended custom properties takes priority over FloatingActionButtonThemeData spacing', + (WidgetTester tester) async { + const iconKey = Key('icon'); + const labelKey = Key('label'); + const iconLabelSpacing = 33.0; + const padding = EdgeInsetsDirectional.only(start: 5.0, end: 6.0); + const textStyle = TextStyle(letterSpacing: 2.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + floatingActionButtonTheme: const FloatingActionButtonThemeData( + extendedIconLabelSpacing: 25.0, + extendedPadding: EdgeInsetsDirectional.only(start: 7.0, end: 8.0), + extendedTextStyle: TextStyle(letterSpacing: 3.0), + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: () {}, + label: const Text('Extended', key: labelKey), + icon: const Icon(Icons.add, key: iconKey), + extendedIconLabelSpacing: iconLabelSpacing, + extendedPadding: padding, + extendedTextStyle: textStyle, + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.byKey(labelKey)).dx - tester.getTopRight(find.byKey(iconKey)).dx, + iconLabelSpacing, + ); + expect( + tester.getTopLeft(find.byKey(iconKey)).dx - + tester.getTopLeft(find.byType(FloatingActionButton)).dx, + padding.start, + ); + expect( + tester.getTopRight(find.byType(FloatingActionButton)).dx - + tester.getTopRight(find.byKey(labelKey)).dx, + padding.end, + ); + // The color comes from the default color scheme's onSecondary value. + expect( + _getRawMaterialButton(tester).textStyle, + textStyle.copyWith(color: const Color(0xffffffff)), + ); + }, + ); + + testWidgets('default FloatingActionButton debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const FloatingActionButtonThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('Material implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const FloatingActionButtonThemeData( + foregroundColor: Color(0xFEEDFEED), + backgroundColor: Color(0xCAFECAFE), + focusColor: Color(0xFEEDFEE1), + hoverColor: Color(0xFEEDFEE2), + splashColor: Color(0xFEEDFEE3), + elevation: 23, + focusElevation: 9, + hoverElevation: 10, + disabledElevation: 11, + highlightElevation: 43, + shape: BeveledRectangleBorder(), + enableFeedback: true, + iconSize: 42, + sizeConstraints: BoxConstraints.tightFor(width: 100.0, height: 100.0), + smallSizeConstraints: BoxConstraints.tightFor(width: 101.0, height: 101.0), + largeSizeConstraints: BoxConstraints.tightFor(width: 102.0, height: 102.0), + extendedSizeConstraints: BoxConstraints(minHeight: 103.0, maxHeight: 103.0), + extendedIconLabelSpacing: 12, + extendedPadding: EdgeInsetsDirectional.only(start: 7.0, end: 8.0), + extendedTextStyle: TextStyle(letterSpacing: 2.0), + mouseCursor: WidgetStateMouseCursor.clickable, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'foregroundColor: ${const Color(0xfeedfeed)}', + 'backgroundColor: ${const Color(0xcafecafe)}', + 'focusColor: ${const Color(0xfeedfee1)}', + 'hoverColor: ${const Color(0xfeedfee2)}', + 'splashColor: ${const Color(0xfeedfee3)}', + 'elevation: 23.0', + 'focusElevation: 9.0', + 'hoverElevation: 10.0', + 'disabledElevation: 11.0', + 'highlightElevation: 43.0', + 'shape: BeveledRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + 'enableFeedback: true', + 'iconSize: 42.0', + 'sizeConstraints: BoxConstraints(w=100.0, h=100.0)', + 'smallSizeConstraints: BoxConstraints(w=101.0, h=101.0)', + 'largeSizeConstraints: BoxConstraints(w=102.0, h=102.0)', + 'extendedSizeConstraints: BoxConstraints(0.0<=w<=Infinity, h=103.0)', + 'extendedIconLabelSpacing: 12.0', + 'extendedPadding: EdgeInsetsDirectional(7.0, 0.0, 8.0, 0.0)', + 'extendedTextStyle: TextStyle(inherit: true, letterSpacing: 2.0)', + 'mouseCursor: WidgetStateMouseCursor(clickable)', + ]); + }); + + testWidgets( + 'FloatingActionButton.mouseCursor uses FloatingActionButtonThemeData.mouseCursor when specified.', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + floatingActionButtonTheme: FloatingActionButtonThemeData( + mouseCursor: WidgetStateProperty.all(SystemMouseCursors.text), + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(FloatingActionButton))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }, + ); +} + +RawMaterialButton _getRawMaterialButton(WidgetTester tester) { + return tester.widget<RawMaterialButton>( + find.descendant( + of: find.byType(FloatingActionButton), + matching: find.byType(RawMaterialButton), + ), + ); +} + +RichText _getRichText(WidgetTester tester) { + return tester.widget<RichText>( + find.descendant(of: find.byType(FloatingActionButton), matching: find.byType(RichText)), + ); +} + +SizedBox _getIconSize(WidgetTester tester) { + return tester.widget<SizedBox>( + find.descendant( + of: find.descendant(of: find.byType(FloatingActionButton), matching: find.byType(Icon)), + matching: find.byType(SizedBox), + ), + ); +} diff --git a/packages/material_ui/test/material/grid_title_test.dart b/packages/material_ui/test/material/grid_title_test.dart new file mode 100644 index 000000000000..eb15703f11ee --- /dev/null +++ b/packages/material_ui/test/material/grid_title_test.dart @@ -0,0 +1,72 @@ +// 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('GridTile control test', (WidgetTester tester) async { + final Key headerKey = UniqueKey(); + final Key footerKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: GridTile( + header: GridTileBar( + key: headerKey, + leading: const Icon(Icons.thumb_up), + title: const Text('Header'), + subtitle: const Text('Subtitle'), + trailing: const Icon(Icons.thumb_up), + ), + footer: GridTileBar( + key: footerKey, + title: const Text('Footer'), + backgroundColor: Colors.black38, + ), + child: DecoratedBox(decoration: BoxDecoration(color: Colors.green[500])), + ), + ), + ); + + expect(find.text('Header'), findsOneWidget); + expect(find.text('Footer'), findsOneWidget); + + expect( + tester.getBottomLeft(find.byKey(headerKey)).dy, + lessThan(tester.getTopLeft(find.byKey(footerKey)).dy), + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: GridTile(child: Text('Simple')), + ), + ); + + expect(find.text('Simple'), findsOneWidget); + }); + + testWidgets('GridTile does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: GridTile(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(GridTile)), Size.zero); + }); + + testWidgets('GridTileBar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: GridTileBar(title: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(GridTileBar)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/icon_button_test.dart b/packages/material_ui/test/material/icon_button_test.dart new file mode 100644 index 000000000000..73ebcc4ea97a --- /dev/null +++ b/packages/material_ui/test/material/icon_button_test.dart @@ -0,0 +1,3646 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +class MockOnPressedFunction { + int called = 0; + + void handler() { + called++; + } +} + +void main() { + late MockOnPressedFunction mockOnPressedFunction; + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + setUp(() { + mockOnPressedFunction = MockOnPressedFunction(); + }); + + RenderObject getOverlayColor(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + Finder findTooltipContainer(String tooltipText) { + return find.ancestor(of: find.text(tooltipText), matching: find.byType(Container)); + } + + testWidgets('test icon is findable by key', (WidgetTester tester) async { + const key = ValueKey<String>('icon-button'); + await tester.pumpWidget( + wrap( + useMaterial3: true, + child: IconButton(key: key, onPressed: () {}, icon: const Icon(Icons.link)), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets('test default icon buttons are sized up to 48', (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconButton(onPressed: mockOnPressedFunction.handler, icon: const Icon(Icons.link)), + ), + ); + + final RenderBox iconButton = tester.renderObject(find.byType(IconButton)); + expect(iconButton.size, const Size(48.0, 48.0)); + + await tester.tap(find.byType(IconButton)); + expect(mockOnPressedFunction.called, 1); + }); + + testWidgets('test small icons are sized up to 48dp', (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconButton( + iconSize: 10.0, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + ), + ), + ); + + final RenderBox iconButton = tester.renderObject(find.byType(IconButton)); + expect(iconButton.size, const Size(48.0, 48.0)); + }); + + testWidgets('test icons can be small when total size is >48dp', (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconButton( + iconSize: 10.0, + padding: const EdgeInsets.all(30.0), + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + ), + ), + ); + + final RenderBox iconButton = tester.renderObject(find.byType(IconButton)); + expect(iconButton.size, const Size(70.0, 70.0)); + }); + + testWidgets( + 'when both iconSize and IconTheme.of(context).size are null, size falls back to 24.0', + (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconTheme( + data: const IconThemeData(), + child: IconButton( + focusNode: focusNode, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + ), + ), + ), + ); + + final RenderBox icon = tester.renderObject(find.byType(Icon)); + expect(icon.size, const Size(24.0, 24.0)); + + focusNode.dispose(); + }, + ); + + testWidgets('when null, iconSize is overridden by closest IconTheme', ( + WidgetTester tester, + ) async { + RenderBox icon; + final bool material3 = theme.useMaterial3; + + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconTheme( + data: const IconThemeData(size: 10), + child: IconButton(onPressed: mockOnPressedFunction.handler, icon: const Icon(Icons.link)), + ), + ), + ); + + icon = tester.renderObject(find.byType(Icon)); + expect(icon.size, const Size(10.0, 10.0)); + + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: Theme( + data: ThemeData(useMaterial3: material3, iconTheme: const IconThemeData(size: 10)), + child: IconButton(onPressed: mockOnPressedFunction.handler, icon: const Icon(Icons.link)), + ), + ), + ); + + icon = tester.renderObject(find.byType(Icon)); + expect(icon.size, const Size(10.0, 10.0)); + + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: Theme( + data: ThemeData(useMaterial3: material3, iconTheme: const IconThemeData(size: 20)), + child: IconTheme( + data: const IconThemeData(size: 10), + child: IconButton( + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + ), + ), + ), + ), + ); + + icon = tester.renderObject(find.byType(Icon)); + expect(icon.size, const Size(10.0, 10.0)); + + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconTheme( + data: const IconThemeData(size: 20), + child: Theme( + data: ThemeData(useMaterial3: material3, iconTheme: const IconThemeData(size: 10)), + child: IconButton( + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + ), + ), + ), + ), + ); + + icon = tester.renderObject(find.byType(Icon)); + expect(icon.size, const Size(10.0, 10.0)); + }); + + testWidgets('when non-null, iconSize precedes IconTheme.of(context).size', ( + WidgetTester tester, + ) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconTheme( + data: const IconThemeData(size: 30.0), + child: IconButton( + iconSize: 10.0, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + ), + ), + ), + ); + + final RenderBox icon = tester.renderObject(find.byType(Icon)); + expect(icon.size, const Size(10.0, 10.0)); + }); + + testWidgets('Small icons with non-null constraints can be <48dp for M2, but =48dp for M3', ( + WidgetTester tester, + ) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconButton( + iconSize: 10.0, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + constraints: const BoxConstraints(), + ), + ), + ); + + final RenderBox iconButton = tester.renderObject(find.byType(IconButton)); + final RenderBox icon = tester.renderObject(find.byType(Icon)); + + // By default IconButton has a padding of 8.0 on all sides, so both + // width and height are 10.0 + 2 * 8.0 = 26.0 + // M3 IconButton is a subclass of ButtonStyleButton which has a minimum + // Size(48.0, 48.0). + expect(iconButton.size, material3 ? const Size(48.0, 48.0) : const Size(26.0, 26.0)); + expect(icon.size, const Size(10.0, 10.0)); + }); + + testWidgets('Small icons with non-null constraints and custom padding can be <48dp', ( + WidgetTester tester, + ) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: IconButton( + iconSize: 10.0, + padding: const EdgeInsets.all(3.0), + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + constraints: const BoxConstraints(), + ), + ), + ); + + final RenderBox iconButton = tester.renderObject(find.byType(IconButton)); + final RenderBox icon = tester.renderObject(find.byType(Icon)); + + // This IconButton has a padding of 3.0 on all sides, so both + // width and height are 10.0 + 2 * 3.0 = 16.0 + // M3 IconButton is a subclass of ButtonStyleButton which has a minimum + // Size(48.0, 48.0). + expect(iconButton.size, material3 ? const Size(48.0, 48.0) : const Size(16.0, 16.0)); + expect(icon.size, const Size(10.0, 10.0)); + }); + + testWidgets('Small icons comply with VisualDensity requirements', (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; + final themeDataM2 = ThemeData( + useMaterial3: material3, + visualDensity: const VisualDensity(horizontal: 1, vertical: -1), + ); + final themeDataM3 = ThemeData( + useMaterial3: material3, + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + visualDensity: const VisualDensity(horizontal: 1, vertical: -1), + ), + ), + ); + await tester.pumpWidget( + wrap( + useMaterial3: material3, + child: Theme( + data: material3 ? themeDataM3 : themeDataM2, + child: IconButton( + iconSize: 10.0, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link), + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + ), + ), + ), + ); + + final RenderBox iconButton = tester.renderObject(find.byType(IconButton)); + + // VisualDensity(horizontal: 1, vertical: -1) increases the icon's + // width by 4 pixels and decreases its height by 4 pixels, giving + // final width 32.0 + 4.0 = 36.0 and + // final height 32.0 - 4.0 = 28.0 + expect(iconButton.size, material3 ? const Size(52.0, 44.0) : const Size(36.0, 28.0)); + }); + + testWidgets('test default icon buttons are constrained', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: IconButton( + padding: EdgeInsets.zero, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.ac_unit), + iconSize: 80.0, + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(IconButton)); + expect(box.size, const Size(80.0, 80.0)); + }); + + testWidgets('test default icon buttons can be stretched if specified', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + IconButton(onPressed: mockOnPressedFunction.handler, icon: const Icon(Icons.ac_unit)), + ], + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(IconButton)); + expect(box.size, const Size(48.0, 600.0)); + + // Test for Material 3 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Directionality( + textDirection: TextDirection.ltr, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + IconButton(onPressed: mockOnPressedFunction.handler, icon: const Icon(Icons.ac_unit)), + ], + ), + ), + ), + ); + + final RenderBox boxM3 = tester.renderObject(find.byType(IconButton)); + expect(boxM3.size, const Size(48.0, 600.0)); + }); + + testWidgets('test default padding', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: IconButton( + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.ac_unit), + iconSize: 80.0, + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(IconButton)); + expect(box.size, const Size(96.0, 96.0)); + }); + + testWidgets('test default alignment', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: IconButton( + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.ac_unit), + iconSize: 80.0, + ), + ), + ); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + testWidgets('test tooltip', (WidgetTester tester) async { + const tooltipText = 'Test tooltip'; + Widget buildIconButton({String? tooltip}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: IconButton( + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.ac_unit), + tooltip: tooltip, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildIconButton()); + + expect(find.byType(Tooltip), findsNothing); + + // Clear the widget tree. + await tester.pumpWidget(Container(key: UniqueKey())); + + await tester.pumpWidget(buildIconButton(tooltip: tooltipText)); + + expect(find.byType(Tooltip), findsOneWidget); + expect(find.byTooltip(tooltipText), findsOneWidget); + + await tester.tap(find.byTooltip(tooltipText)); + expect(mockOnPressedFunction.called, 1); + + // Hovering over the button should show the tooltip. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(IconButton))); + await tester.pump(); + + expect(findTooltipContainer(tooltipText), findsOneWidget); + }); + + testWidgets('IconButton AppBar size', (WidgetTester tester) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + IconButton( + padding: EdgeInsets.zero, + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.ac_unit), + ), + ], + ), + ), + ), + ); + + final RenderBox barBox = tester.renderObject(find.byType(AppBar)); + final RenderBox iconBox = tester.renderObject(find.byType(IconButton)); + expect(iconBox.size.height, material3 ? 48 : equals(barBox.size.height)); + expect(tester.getCenter(find.byType(IconButton)).dy, 28); + }); + + // This test is very similar to the '...explicit splashColor and highlightColor' test + // in buttons_test.dart. If you change this one, you may want to also change that one. + testWidgets('IconButton with explicit splashColor and highlightColor - M2', ( + WidgetTester tester, + ) async { + const directSplashColor = Color(0xFF00000F); + const directHighlightColor = Color(0xFF0000F0); + + Widget buttonWidget = wrap( + useMaterial3: false, + child: IconButton( + icon: const Icon(Icons.android), + splashColor: directSplashColor, + highlightColor: directHighlightColor, + onPressed: () { + /* enable the button */ + }, + ), + ); + + await tester.pumpWidget(Theme(data: ThemeData(useMaterial3: false), child: buttonWidget)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + expect( + Material.of(tester.element(find.byType(IconButton))), + paints + ..circle(color: directSplashColor) + ..circle(color: directHighlightColor), + ); + + const themeSplashColor1 = Color(0xFF000F00); + const themeHighlightColor1 = Color(0xFF00FF00); + + buttonWidget = wrap( + useMaterial3: false, + child: IconButton( + icon: const Icon(Icons.android), + onPressed: () { + /* enable the button */ + }, + ), + ); + + await tester.pumpWidget( + Theme( + data: ThemeData( + highlightColor: themeHighlightColor1, + splashColor: themeSplashColor1, + useMaterial3: false, + ), + child: buttonWidget, + ), + ); + + expect( + Material.of(tester.element(find.byType(IconButton))), + paints + ..circle(color: themeSplashColor1) + ..circle(color: themeHighlightColor1), + ); + + const themeSplashColor2 = Color(0xFF002200); + const themeHighlightColor2 = Color(0xFF001100); + + await tester.pumpWidget( + Theme( + data: ThemeData( + highlightColor: themeHighlightColor2, + splashColor: themeSplashColor2, + useMaterial3: false, + ), + child: buttonWidget, // same widget, so does not get updated because of us + ), + ); + + expect( + Material.of(tester.element(find.byType(IconButton))), + paints + ..circle(color: themeSplashColor2) + ..circle(color: themeHighlightColor2), + ); + + await gesture.up(); + }); + + testWidgets('IconButton with explicit splash radius - M2', (WidgetTester tester) async { + const splashRadius = 30.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: IconButton( + icon: const Icon(Icons.android), + splashRadius: splashRadius, + onPressed: () { + /* enable the button */ + }, + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // Start gesture. + await tester.pump(const Duration(milliseconds: 1000)); // Wait for splash to be well under way. + + expect( + Material.of(tester.element(find.byType(IconButton))), + paints..circle(radius: splashRadius), + ); + + await gesture.up(); + }); + + testWidgets('IconButton Semantics (enabled) - M2', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + wrap( + useMaterial3: false, + child: IconButton( + onPressed: mockOnPressedFunction.handler, + icon: const Icon(Icons.link, semanticLabel: 'link'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + rect: const Rect.fromLTRB(0.0, 0.0, 48.0, 48.0), + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + label: 'link', + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('IconButton Semantics (disabled) - M2', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + wrap( + useMaterial3: false, + child: const IconButton(onPressed: null, icon: Icon(Icons.link, semanticLabel: 'link')), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + rect: const Rect.fromLTRB(0.0, 0.0, 48.0, 48.0), + flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState, SemanticsFlag.isButton], + label: 'link', + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('IconButton Semantics (selected) - M3', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + wrap( + useMaterial3: true, + child: IconButton( + onPressed: mockOnPressedFunction.handler, + isSelected: true, + icon: const Icon(Icons.link, semanticLabel: 'link'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + ], + label: 'link', + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('IconButton loses focus when disabled.', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'IconButton'); + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: IconButton( + focusNode: focusNode, + autofocus: true, + onPressed: () {}, + icon: const Icon(Icons.link), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: IconButton( + focusNode: focusNode, + autofocus: true, + onPressed: null, + icon: const Icon(Icons.link), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isFalse); + + focusNode.dispose(); + }); + + testWidgets('IconButton keeps focus when disabled in directional navigation mode.', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'IconButton'); + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: MediaQuery( + data: const MediaQueryData(navigationMode: NavigationMode.directional), + child: IconButton( + focusNode: focusNode, + autofocus: true, + onPressed: () {}, + icon: const Icon(Icons.link), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: MediaQuery( + data: const MediaQueryData(navigationMode: NavigationMode.directional), + child: IconButton( + focusNode: focusNode, + autofocus: true, + onPressed: null, + icon: const Icon(Icons.link), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + focusNode.dispose(); + }); + + testWidgets("Disabled IconButton can't be traversed to when disabled.", ( + WidgetTester tester, + ) async { + final focusNode1 = FocusNode(debugLabel: 'IconButton 1'); + final focusNode2 = FocusNode(debugLabel: 'IconButton 2'); + addTearDown(() { + focusNode1.dispose(); + focusNode2.dispose(); + }); + + await tester.pumpWidget( + wrap( + useMaterial3: theme.useMaterial3, + child: Column( + children: <Widget>[ + IconButton( + focusNode: focusNode1, + autofocus: true, + onPressed: () {}, + icon: const Icon(Icons.link), + ), + IconButton(focusNode: focusNode2, onPressed: null, icon: const Icon(Icons.link)), + ], + ), + ), + ); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + expect(focusNode1.nextFocus(), isFalse); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, !kIsWeb); + expect(focusNode2.hasPrimaryFocus, isFalse); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('IconButton with disabled feedback', (WidgetTester tester) async { + final Widget button = Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton(onPressed: () {}, enableFeedback: false, icon: const Icon(Icons.link)), + ), + ); + + await tester.pumpWidget( + theme.useMaterial3 ? MaterialApp(theme: theme, home: button) : Material(child: button), + ); + await tester.tap(find.byType(IconButton), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets('IconButton with enabled feedback', (WidgetTester tester) async { + final Widget button = Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.link)), + ), + ); + + await tester.pumpWidget( + theme.useMaterial3 ? MaterialApp(theme: theme, home: button) : Material(child: button), + ); + await tester.tap(find.byType(IconButton), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('IconButton with enabled feedback by default', (WidgetTester tester) async { + final Widget button = Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.link)), + ), + ); + + await tester.pumpWidget( + theme.useMaterial3 ? MaterialApp(theme: theme, home: button) : Material(child: button), + ); + await tester.tap(find.byType(IconButton), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + }); + + testWidgets('IconButton responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + final bool material3 = theme.useMaterial3; + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: IconButton( + visualDensity: visualDensity, + key: key, + onPressed: () {}, + icon: const Icon(Icons.play_arrow), + ), + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(material3 ? const Size(64, 64) : const Size(60, 60))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + // IconButton is a subclass of ButtonStyleButton in Material 3, so the negative + // visualDensity cannot be applied to horizontal padding. + // The size of the Button with padding is (24 + 8 + 8, 24) -> (40, 24) + // minSize of M3 IconButton is (48 - 12, 48 - 12) -> (36, 36) + // So, the button size in Material 3 is (40, 36) + expect(box.size, equals(material3 ? const Size(40, 36) : const Size(40, 40))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(material3 ? const Size(64, 36) : const Size(60, 40))); + }); + + testWidgets('IconButton.mouseCursor changes cursor on hover', (WidgetTester tester) async { + // Test argument works + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton( + onPressed: () {}, + mouseCursor: SystemMouseCursors.forbidden, + icon: const Icon(Icons.play_arrow), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(IconButton))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + + // Test default is click on web, basic on non-web + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.play_arrow)), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('disabled IconButton has basic mouse cursor', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton( + onPressed: null, // null value indicates IconButton is disabled + icon: Icon(Icons.play_arrow), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(IconButton))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('IconButton.mouseCursor overrides implicit setting of mouse cursor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton( + onPressed: null, + mouseCursor: SystemMouseCursors.none, + icon: Icon(Icons.play_arrow), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(IconButton))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.none, + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton( + onPressed: () {}, + mouseCursor: SystemMouseCursors.none, + icon: const Icon(Icons.play_arrow), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.none, + ); + }); + + testWidgets('IconTheme opacity test', (WidgetTester tester) async { + final theme = ThemeData.from(colorScheme: colorScheme, useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: IconButton(icon: const Icon(Icons.add), color: Colors.purple, onPressed: () {}), + ), + ), + ), + ); + + Color? iconColor() => _iconStyle(tester, Icons.add)?.color; + expect(iconColor(), Colors.purple); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: IconTheme.merge( + data: const IconThemeData(opacity: 0.5), + child: IconButton( + icon: const Icon(Icons.add), + color: Colors.purple, + onPressed: () {}, + ), + ), + ), + ), + ), + ); + + Color? iconColorWithOpacity() => _iconStyle(tester, Icons.add)?.color; + expect(iconColorWithOpacity(), Colors.purple.withOpacity(0.5)); + }); + + testWidgets('IconButton defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.ac_unit)), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center(child: IconButton(onPressed: null, icon: Icon(Icons.ac_unit))), + ), + ); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + }); + + testWidgets('IconButton default overlayColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return IconButton( + onPressed: () {}, + focusNode: focusNode, + icon: const Icon(Icons.add), + ); + }, + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect() + ..rect(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.1)), + ); + + focusNode.dispose(); + }); + + testWidgets('IconButton.fill defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.filled(onPressed: () {}, icon: const Icon(Icons.ac_unit)), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + expect(iconColor(), colorScheme.onPrimary); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center(child: IconButton.filled(onPressed: null, icon: Icon(Icons.ac_unit))), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('IconButton.fill default overlayColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return IconButton.filled( + onPressed: () {}, + focusNode: focusNode, + icon: const Icon(Icons.add), + ); + }, + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onPrimary.withOpacity(0.08)), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect() + ..rect(color: theme.colorScheme.onPrimary.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onPrimary.withOpacity(0.1)), + ); + + focusNode.dispose(); + }); + + testWidgets('Toggleable IconButton.fill defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled selected IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.filled( + isSelected: true, + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + expect(iconColor(), colorScheme.onPrimary); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Enabled unselected IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.filled( + isSelected: false, + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.surfaceVariant); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.primary); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center( + child: IconButton.filled(isSelected: true, onPressed: null, icon: Icon(Icons.ac_unit)), + ), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('IconButton.filledTonal defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled IconButton.tonal + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.filledTonal(onPressed: () {}, icon: const Icon(Icons.ac_unit)), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + expect(iconColor(), colorScheme.onSecondaryContainer); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.secondaryContainer); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.secondaryContainer); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center( + child: IconButton.filledTonal(onPressed: null, icon: Icon(Icons.ac_unit)), + ), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('IconButton.filledTonal default overlayColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return IconButton.filledTonal( + onPressed: () {}, + focusNode: focusNode, + icon: const Icon(Icons.add), + ); + }, + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSecondaryContainer.withOpacity(0.08)), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect() + ..rect(color: theme.colorScheme.onSecondaryContainer.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSecondaryContainer.withOpacity(0.1)), + ); + + focusNode.dispose(); + }); + + testWidgets('Toggleable IconButton.filledTonal defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled selected IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.filledTonal( + isSelected: true, + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + expect(iconColor(), colorScheme.onSecondaryContainer); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.secondaryContainer); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.secondaryContainer); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Enabled unselected IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.filledTonal( + isSelected: false, + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.surfaceVariant); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurfaceVariant); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center( + child: IconButton.filledTonal( + isSelected: true, + onPressed: null, + icon: Icon(Icons.ac_unit), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('IconButton.outlined defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled IconButton.tonal + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.outlined(onPressed: () {}, icon: const Icon(Icons.ac_unit)), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + expect(iconColor(), colorScheme.onSurfaceVariant); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, StadiumBorder(side: BorderSide(color: colorScheme.outline))); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, StadiumBorder(side: BorderSide(color: colorScheme.outline))); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center(child: IconButton.outlined(onPressed: null, icon: Icon(Icons.ac_unit))), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect( + material.shape, + StadiumBorder(side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12))), + ); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('IconButton.outlined default overlayColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return IconButton.outlined( + onPressed: () {}, + focusNode: focusNode, + icon: const Icon(Icons.add), + ); + }, + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect() + ..rect(color: theme.colorScheme.onSurface.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSurfaceVariant.withOpacity(0.08)), + ); + + focusNode.dispose(); + }); + + testWidgets('Toggleable IconButton.outlined defaults - M3', (WidgetTester tester) async { + final themeM3 = ThemeData.from(colorScheme: colorScheme); + + // Enabled selected IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.outlined( + isSelected: true, + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + expect(iconColor(), colorScheme.onInverseSurface); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.inverseSurface); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + expect(tester.getSize(find.byIcon(Icons.ac_unit)), const Size(24.0, 24.0)); + + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.inverseSurface); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + // Enabled unselected IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: Center( + child: IconButton.outlined( + isSelected: false, + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, StadiumBorder(side: BorderSide(color: colorScheme.outline))); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurfaceVariant); + + // Disabled IconButton + await tester.pumpWidget( + MaterialApp( + theme: themeM3, + home: const Center( + child: IconButton.outlined(isSelected: true, onPressed: null, icon: Icon(Icons.ac_unit)), + ), + ), + ); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + 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, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + expect(iconColor(), colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets( + 'Default IconButton meets a11y contrast guidelines - M3', + (WidgetTester tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: IconButton( + onPressed: () {}, + focusNode: focusNode, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ), + ); + + // 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(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // 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. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + await gesture.removePointer(); + + focusNode.dispose(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets('IconButton uses stateful color for icon color in different states - M3', ( + WidgetTester tester, + ) async { + var isSelected = false; + final focusNode = FocusNode(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + const selectedColor = Color(0x00000005); + + Color getIconColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: Center( + child: IconButton( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getIconColor), + ), + isSelected: isSelected, + onPressed: () { + setState(() { + isSelected = !isSelected; + }); + }, + focusNode: focusNode, + icon: const Icon(Icons.ac_unit), + ), + ), + ); + }, + ), + ), + ); + + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + + // Default, not disabled. + expect(iconColor(), equals(defaultColor)); + + // Selected + final Finder button = find.byType(IconButton); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(iconColor(), selectedColor); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + 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); + + focusNode.dispose(); + }); + + testWidgets('Does IconButton contribute semantics - M3', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Theme( + data: ThemeData(), + child: IconButton( + 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>(Size(88, 36)), + ), + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + transform: Matrix4.translationValues(356.0, 276.0, 0.0), + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('IconButton size is configurable by ThemeData.materialTapTargetSize - M3', ( + WidgetTester tester, + ) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize) { + return Theme( + data: ThemeData(materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: IconButton( + style: IconButton.styleFrom(minimumSize: const Size(40, 40)), + icon: const Icon(Icons.ac_unit), + onPressed: () {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded)); + expect(tester.getSize(find.byType(IconButton)), const Size(48.0, 48.0)); + + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap)); + expect(tester.getSize(find.byType(IconButton)), const Size(40.0, 40.0)); + }); + + testWidgets('Override IconButton default padding - M3', (WidgetTester tester) async { + // Use [IconButton]'s padding property to override default value. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: IconButton( + padding: const EdgeInsets.all(20), + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ), + ); + + final Padding paddingWidget1 = tester.widget<Padding>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Padding)), + ); + expect(paddingWidget1.padding, const EdgeInsets.all(20)); + + // Use [IconButton.style]'s padding property to override default value. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: IconButton( + style: IconButton.styleFrom(padding: const EdgeInsets.all(20)), + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ), + ); + + final Padding paddingWidget2 = tester.widget<Padding>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Padding)), + ); + expect(paddingWidget2.padding, const EdgeInsets.all(20)); + + // [IconButton.style]'s padding will override [IconButton]'s padding if both + // values are not null. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: IconButton( + padding: const EdgeInsets.all(15), + style: IconButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + icon: const Icon(Icons.ac_unit), + ), + ), + ), + ), + ); + + final Padding paddingWidget3 = tester.widget<Padding>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Padding)), + ); + expect(paddingWidget3.padding, const EdgeInsets.all(22)); + }); + + testWidgets('Default IconButton is not selectable - M3', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: IconButton(icon: const Icon(Icons.ac_unit), onPressed: () {}), + ), + ); + + final Finder button = find.byType(IconButton); + IconButton buttonWidget() => tester.widget<IconButton>(button); + + Material buttonMaterial() { + return tester.widget<Material>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Material)), + ); + } + + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + + expect(buttonWidget().isSelected, null); + expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant)); + expect(buttonMaterial().color, Colors.transparent); + + await tester.tap( + button, + ); // The non-toggle IconButton should not change appearance after clicking + await tester.pumpAndSettle(); + + expect(buttonWidget().isSelected, null); + expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant)); + expect(buttonMaterial().color, Colors.transparent); + }); + + testWidgets('Icon button is selectable when isSelected is not null - M3', ( + WidgetTester tester, + ) async { + var isSelected = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return IconButton( + isSelected: isSelected, + icon: const Icon(Icons.ac_unit), + onPressed: () { + setState(() { + isSelected = !isSelected; + }); + }, + ); + }, + ), + ), + ); + + final Finder button = find.byType(IconButton); + IconButton buttonWidget() => tester.widget<IconButton>(button); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + + Material buttonMaterial() { + return tester.widget<Material>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Material)), + ); + } + + expect(buttonWidget().isSelected, false); + expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant)); + expect(buttonMaterial().color, Colors.transparent); + + await tester.tap(button); // The toggle IconButton should change appearance after clicking + await tester.pumpAndSettle(); + + expect(buttonWidget().isSelected, true); + expect(iconColor(), equals(const ColorScheme.light().primary)); + expect(buttonMaterial().color, Colors.transparent); + + await tester.tap(button); // The IconButton should be unselected if it's clicked again + await tester.pumpAndSettle(); + + expect(buttonWidget().isSelected, false); + expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant)); + expect(buttonMaterial().color, Colors.transparent); + }); + + testWidgets('The IconButton is in selected status if isSelected is true by default - M3', ( + WidgetTester tester, + ) async { + var isSelected = true; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return IconButton( + isSelected: isSelected, + icon: const Icon(Icons.ac_unit), + onPressed: () { + setState(() { + isSelected = !isSelected; + }); + }, + ); + }, + ), + ), + ); + + final Finder button = find.byType(IconButton); + IconButton buttonWidget() => tester.widget<IconButton>(button); + Color? iconColor() => _iconStyle(tester, Icons.ac_unit)?.color; + + Material buttonMaterial() { + return tester.widget<Material>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Material)), + ); + } + + expect(buttonWidget().isSelected, true); + expect(iconColor(), equals(const ColorScheme.light().primary)); + expect(buttonMaterial().color, Colors.transparent); + + await tester.tap(button); // The IconButton becomes unselected if it's clicked + await tester.pumpAndSettle(); + + expect(buttonWidget().isSelected, false); + expect(iconColor(), equals(const ColorScheme.light().onSurfaceVariant)); + expect(buttonMaterial().color, Colors.transparent); + }); + + testWidgets("The selectedIcon is used if it's not null and the button is clicked", ( + WidgetTester tester, + ) async { + var isSelected = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return IconButton( + isSelected: isSelected, + selectedIcon: const Icon(Icons.account_box), + icon: const Icon(Icons.account_box_outlined), + onPressed: () { + setState(() { + isSelected = !isSelected; + }); + }, + ); + }, + ), + ), + ); + + final Finder button = find.byType(IconButton); + + expect(find.byIcon(Icons.account_box_outlined), findsOneWidget); + expect(find.byIcon(Icons.account_box), findsNothing); + + await tester.tap(button); // The icon becomes to selectedIcon + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.account_box), findsOneWidget); + expect(find.byIcon(Icons.account_box_outlined), findsNothing); + + await tester.tap(button); // The icon becomes the original icon when it's clicked again + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.account_box_outlined), findsOneWidget); + expect(find.byIcon(Icons.account_box), findsNothing); + }); + + testWidgets( + 'The original icon is used for selected and unselected status when selectedIcon is null', + (WidgetTester tester) async { + var isSelected = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return IconButton( + isSelected: isSelected, + icon: const Icon(Icons.account_box), + onPressed: () { + setState(() { + isSelected = !isSelected; + }); + }, + ); + }, + ), + ), + ); + + final Finder button = find.byType(IconButton); + IconButton buttonWidget() => tester.widget<IconButton>(button); + + expect(buttonWidget().isSelected, false); + expect(buttonWidget().selectedIcon, null); + expect(find.byIcon(Icons.account_box), findsOneWidget); + + await tester.tap(button); // The icon becomes the original icon when it's clicked again + await tester.pumpAndSettle(); + + expect(buttonWidget().isSelected, true); + expect(buttonWidget().selectedIcon, null); + expect(find.byIcon(Icons.account_box), findsOneWidget); + }, + ); + + testWidgets('The selectedIcon is used for disabled button if isSelected is true - M3', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: const IconButton( + isSelected: true, + icon: Icon(Icons.account_box), + selectedIcon: Icon(Icons.ac_unit), + onPressed: null, + ), + ), + ); + + final Finder button = find.byType(IconButton); + IconButton buttonWidget() => tester.widget<IconButton>(button); + + expect(buttonWidget().isSelected, true); + expect(find.byIcon(Icons.account_box), findsNothing); + expect(find.byIcon(Icons.ac_unit), findsOneWidget); + }); + + testWidgets('The visualDensity of M3 IconButton can be configured by IconButtonTheme, ' + 'but cannot be configured by ThemeData - M3', (WidgetTester tester) async { + Future<void> buildTest({ + VisualDensity? iconButtonThemeVisualDensity, + VisualDensity? themeVisualDensity, + }) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme).copyWith( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(visualDensity: iconButtonThemeVisualDensity), + ), + visualDensity: themeVisualDensity, + ), + home: Material( + child: Center( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.play_arrow)), + ), + ), + ), + ); + } + + await buildTest(iconButtonThemeVisualDensity: VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(iconButtonThemeVisualDensity: VisualDensity.compact); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(40, 40))); + + await buildTest( + iconButtonThemeVisualDensity: const VisualDensity(horizontal: 3.0, vertical: 3.0), + ); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(64, 64))); + + // ThemeData.visualDensity will be ignored because useMaterial3 is true + await buildTest(themeVisualDensity: VisualDensity.standard); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(themeVisualDensity: VisualDensity.compact); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(themeVisualDensity: const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + }); + + testWidgets('IconButton.styleFrom overlayColor overrides default overlay color', ( + WidgetTester tester, + ) async { + const overlayColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: IconButton( + style: IconButton.styleFrom(overlayColor: overlayColor), + onPressed: () {}, + icon: const Icon(Icons.add), + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.1))); + }); + + testWidgets('IconButton.styleFrom highlight, hover, focus colors overrides overlayColor', ( + WidgetTester tester, + ) async { + const hoverColor = Color(0xff0000f2); + const highlightColor = Color(0xff0000f1); + const focusColor = Color(0xff0000f3); + const overlayColor = Color(0xffff0000); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: IconButton( + style: IconButton.styleFrom( + hoverColor: hoverColor, + highlightColor: highlightColor, + focusColor: focusColor, + overlayColor: overlayColor, + ), + onPressed: () {}, + icon: const Icon(Icons.add), + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: hoverColor)); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: hoverColor) + ..rect(color: highlightColor), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: focusColor)); + }); + + testWidgets('IconButton.styleFrom with transparent overlayColor', (WidgetTester tester) async { + const Color overlayColor = Colors.transparent; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: IconButton( + style: IconButton.styleFrom(overlayColor: overlayColor), + onPressed: () {}, + icon: const Icon(Icons.add), + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor)); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor) + ..rect(color: overlayColor), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/174511. + testWidgets('IconButton.color takes precedence over ambient IconButtonThemeData.iconColor', ( + WidgetTester tester, + ) async { + const iconButtonColor = Color(0xFFFF1234); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + iconButtonTheme: const IconButtonThemeData( + style: ButtonStyle( + iconColor: WidgetStateColor.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.any: Colors.purple, + }), + ), + ), + ), + home: Material( + child: Center( + child: IconButton( + onPressed: () {}, + icon: const Icon(Icons.add, size: 64), + color: iconButtonColor, + ), + ), + ), + ), + ); + expect(_iconStyle(tester, Icons.add)?.color, iconButtonColor); + }); + + group('IconTheme tests in Material 3', () { + testWidgets('IconTheme overrides default values in M3', (WidgetTester tester) async { + // Theme's IconTheme + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(iconTheme: const IconThemeData(color: Colors.red, size: 37)), + home: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ); + + Color? iconColor0() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor0(), Colors.red); + expect(tester.getSize(find.byIcon(Icons.account_box)), equals(const Size(37, 37))); + + // custom IconTheme outside of IconButton + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: IconTheme.merge( + data: const IconThemeData(color: Colors.pink, size: 35), + child: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ), + ); + + Color? iconColor1() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor1(), Colors.pink); + expect(tester.getSize(find.byIcon(Icons.account_box)), equals(const Size(35, 35))); + }); + + testWidgets('Theme IconButtonTheme overrides IconTheme in Material3', ( + WidgetTester tester, + ) async { + // When IconButtonTheme and IconTheme both exist in ThemeData, the IconButtonTheme can override IconTheme. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()).copyWith( + iconTheme: const IconThemeData(color: Colors.red, size: 25), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.green, iconSize: 27), + ), + ), + home: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ); + + Color? iconColor() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor(), Colors.green); + expect(tester.getSize(find.byIcon(Icons.account_box)), equals(const Size(27, 27))); + }); + + testWidgets('Button IconButtonTheme always overrides IconTheme in Material3', ( + WidgetTester tester, + ) async { + // When IconButtonTheme is closer to IconButton, IconButtonTheme overrides IconTheme + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: IconTheme.merge( + data: const IconThemeData(color: Colors.orange, size: 36), + child: IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.blue, iconSize: 35), + ), + child: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ), + ), + ); + + Color? iconColor0() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor0(), Colors.blue); + expect(tester.getSize(find.byIcon(Icons.account_box)), equals(const Size(35, 35))); + + // When IconTheme is closer to IconButton, IconButtonTheme still overrides IconTheme + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: IconTheme.merge( + data: const IconThemeData(color: Colors.blue, size: 35), + child: IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: Colors.orange, iconSize: 36), + ), + child: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + Color? iconColor1() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor1(), Colors.orange); + expect(tester.getSize(find.byIcon(Icons.account_box)), equals(const Size(36, 36))); + }); + + testWidgets('White icon color defined by users shows correctly in Material3', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.dark(), + ).copyWith(iconTheme: const IconThemeData(color: Colors.white)), + home: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ); + + Color? iconColor1() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor1(), Colors.white); + }); + + testWidgets( + 'In light mode, icon color is M3 default color instead of IconTheme.of(context).color, ' + 'if only setting color in IconTheme', + (WidgetTester tester) async { + final ColorScheme darkScheme = const ColorScheme.dark().copyWith( + onSurfaceVariant: const Color(0xffe91e60), + ); + // Brightness.dark + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: darkScheme), + home: Scaffold( + body: IconTheme.merge( + data: const IconThemeData(size: 26), + child: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ), + ), + ); + + Color? iconColor0() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor0(), darkScheme.onSurfaceVariant); // onSurfaceVariant + }, + ); + + testWidgets( + 'In dark mode, icon color is M3 default color instead of IconTheme.of(context).color, ' + 'if only setting color in IconTheme', + (WidgetTester tester) async { + final ColorScheme lightScheme = const ColorScheme.light().copyWith( + onSurfaceVariant: const Color(0xffe91e60), + ); + // Brightness.dark + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: lightScheme), + home: Scaffold( + body: IconTheme.merge( + data: const IconThemeData(size: 26), + child: IconButton(icon: const Icon(Icons.account_box), onPressed: () {}), + ), + ), + ), + ); + + Color? iconColor0() => _iconStyle(tester, Icons.account_box)?.color; + expect(iconColor0(), lightScheme.onSurfaceVariant); // onSurfaceVariant + }, + ); + + testWidgets( + 'black87 icon color defined by users shows correctly in Material3', + (WidgetTester tester) async {}, + ); + + testWidgets("IconButton.styleFrom doesn't throw exception on passing only one cursor", ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/118071. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: IconButton( + style: OutlinedButton.styleFrom(enabledMouseCursor: SystemMouseCursors.text), + onPressed: () {}, + icon: const Icon(Icons.add), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Material3 - IconButton memory leak', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/130708. + Widget buildWidget(bool showIconButton) { + return showIconButton + ? MaterialApp( + home: IconButton(onPressed: () {}, icon: const Icon(Icons.search)), + ) + : const SizedBox(); + } + + await tester.pumpWidget(buildWidget(true)); + await tester.pumpWidget(buildWidget(false)); + + // No exception is thrown. + }); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153544. + testWidgets('Tooltip is drawn when hovering within IconButton area but outside the Icon itself', ( + WidgetTester tester, + ) async { + const tooltipText = 'Test tooltip'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: IconButton( + onPressed: () {}, + icon: const Icon(Icons.favorite), + tooltip: tooltipText, + ), + ), + ), + ), + ); + + // Verify that the tooltip is not shown initially. + expect(findTooltipContainer(tooltipText), findsNothing); + + // Start hovering within IconButton area to show the tooltip. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + + final Offset topLeft = tester.getTopLeft(find.byType(Icon)); + // Move the cursor just outside the Icon. + await gesture.moveTo(Offset(topLeft.dx - 1, topLeft.dy - 1)); + await tester.pump(); + + // Verify that the tooltip is shown. + expect(findTooltipContainer(tooltipText), findsOneWidget); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153544. + testWidgets('Trigger Ink splash when hovering within layout bounds with tooltip', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: ColoredBox( + color: const Color(0xFFFF0000), + child: IconButton( + onPressed: () {}, + icon: const Icon(Icons.favorite), + tooltip: 'Test tooltip', + style: const ButtonStyle( + overlayColor: WidgetStatePropertyAll<Color>(Color(0xFF00FF00)), + padding: WidgetStatePropertyAll<EdgeInsets>(EdgeInsets.all(20)), + ), + ), + ), + ), + ), + ), + ); + + final Offset topLeft = tester.getTopLeft( + find.descendant(of: find.byType(Center), matching: find.byType(ColoredBox)), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(topLeft); + await gesture.down(topLeft); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: const Color(0xFFFF0000)) // ColoredBox. + ..rect(color: const Color(0xFF00FF00)), // IconButton overlay. + ); + }); + + testWidgets('Material3 - IconButton variants hovered & onLongPressed', ( + WidgetTester tester, + ) async { + late bool onHovered; + var onLongPressed = false; + + void onLongPress() { + onLongPressed = true; + } + + void onHover(bool hover) { + onHovered = hover; + } + + // IconButton + await tester.pumpWidget(buildAllVariants(onLongPress: onLongPress, onHover: onHover)); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.favorite); + final Offset iconButtonOffset = tester.getCenter(iconButton); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + + await gesture.moveTo(iconButtonOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants(enabled: false, onLongPress: onLongPress, onHover: onHover), + ); + await gesture.moveTo(iconButtonOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonOffset); + await tester.pump(); + expect(onLongPressed, false); + + await gesture.removePointer(); + + // IconButton.filled + await tester.pumpWidget(buildAllVariants(onLongPress: onLongPress, onHover: onHover)); + + final Finder iconButtonFilled = find.widgetWithIcon(IconButton, Icons.add); + final Offset iconButtonFilledOffset = tester.getCenter(iconButtonFilled); + + await gesture.moveTo(iconButtonFilledOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonFilledOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants(enabled: false, onLongPress: onLongPress, onHover: onHover), + ); + await gesture.moveTo(iconButtonFilledOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonFilledOffset); + await tester.pump(); + expect(onLongPressed, false); + + await gesture.removePointer(); + + // IconButton.filledTonal + await tester.pumpWidget(buildAllVariants(onLongPress: onLongPress, onHover: onHover)); + + final Finder iconButtonFilledTonal = find.widgetWithIcon(IconButton, Icons.add); + final Offset iconButtonFilledTonalOffset = tester.getCenter(iconButtonFilledTonal); + + await gesture.moveTo(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants(enabled: false, onLongPress: onLongPress, onHover: onHover), + ); + await gesture.moveTo(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onLongPressed, false); + + await gesture.removePointer(); + + // IconButton.outlined + await tester.pumpWidget(buildAllVariants(onLongPress: onLongPress, onHover: onHover)); + + final Finder iconButtonOutlined = find.widgetWithIcon(IconButton, Icons.add); + final Offset iconButtonOutlinedOffset = tester.getCenter(iconButtonOutlined); + + await gesture.moveTo(iconButtonOutlinedOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonOutlinedOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants(enabled: false, onLongPress: onLongPress, onHover: onHover), + ); + await gesture.moveTo(iconButtonOutlinedOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonOutlinedOffset); + await tester.pump(); + expect(onLongPressed, false); + }); + + testWidgets('Material2 - IconButton variants hovered & onLongPressed', ( + WidgetTester tester, + ) async { + late bool onHovered; + var onLongPressed = false; + + void onLongPress() { + onLongPressed = true; + } + + void onHover(bool hover) { + onHovered = hover; + } + + // IconButton + await tester.pumpWidget( + buildAllVariants(onLongPress: onLongPress, onHover: onHover, useMaterial3: false), + ); + + final Finder iconButton = find.widgetWithIcon(IconButton, Icons.favorite); + final Offset iconButtonOffset = tester.getCenter(iconButton); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + + await gesture.moveTo(iconButtonOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants( + enabled: false, + onLongPress: onLongPress, + onHover: onHover, + useMaterial3: false, + ), + ); + await gesture.moveTo(iconButtonOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonOffset); + await tester.pump(); + expect(onLongPressed, false); + + await gesture.removePointer(); + + // IconButton.filled + await tester.pumpWidget( + buildAllVariants(onLongPress: onLongPress, onHover: onHover, useMaterial3: false), + ); + + final Finder iconButtonFilled = find.widgetWithIcon(IconButton, Icons.add); + final Offset iconButtonFilledOffset = tester.getCenter(iconButtonFilled); + + await gesture.moveTo(iconButtonFilledOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonFilledOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants( + enabled: false, + onLongPress: onLongPress, + onHover: onHover, + useMaterial3: false, + ), + ); + await gesture.moveTo(iconButtonFilledOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonFilledOffset); + await tester.pump(); + expect(onLongPressed, false); + + await gesture.removePointer(); + + // IconButton.filledTonal + await tester.pumpWidget( + buildAllVariants(onLongPress: onLongPress, onHover: onHover, useMaterial3: false), + ); + + final Finder iconButtonFilledTonal = find.widgetWithIcon(IconButton, Icons.add); + final Offset iconButtonFilledTonalOffset = tester.getCenter(iconButtonFilledTonal); + + await gesture.moveTo(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants( + enabled: false, + onLongPress: onLongPress, + onHover: onHover, + useMaterial3: false, + ), + ); + await gesture.moveTo(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonFilledTonalOffset); + await tester.pump(); + expect(onLongPressed, false); + + await gesture.removePointer(); + + // IconButton.outlined + await tester.pumpWidget( + buildAllVariants(onLongPress: onLongPress, onHover: onHover, useMaterial3: false), + ); + + final Finder iconButtonOutlined = find.widgetWithIcon(IconButton, Icons.add); + final Offset iconButtonOutlinedOffset = tester.getCenter(iconButtonOutlined); + + await gesture.moveTo(iconButtonOutlinedOffset); + await tester.pump(); + expect(onHovered, true); + + await tester.longPressAt(iconButtonOutlinedOffset); + await tester.pump(); + expect(onLongPressed, true); + + onHovered = false; + onLongPressed = false; + + await tester.pumpWidget( + buildAllVariants( + enabled: false, + onLongPress: onLongPress, + onHover: onHover, + useMaterial3: false, + ), + ); + await gesture.moveTo(iconButtonOutlinedOffset); + await tester.pump(); + expect(onHovered, false); + + await tester.longPressAt(iconButtonOutlinedOffset); + await tester.pump(); + expect(onLongPressed, false); + }); + + testWidgets('does not draw focus color when focused by semantics on the web', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/158527. + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + const Color focusColor = Colors.orange; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: IconButton( + focusColor: focusColor, + focusNode: focusNode, + icon: const Icon(Icons.headphones), + onPressed: () {}, + ), + ), + ), + ); + + // Make sure we are in "traditional mode" where the button could potentially draw focus highlights. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional)); + + expect(focusNode.hasFocus, isFalse); + + // Focus on it with semantics. + tester.platformDispatcher.onSemanticsActionEvent!( + SemanticsActionEvent( + type: SemanticsAction.focus, + viewId: tester.view.viewId, + nodeId: tester.semantics.find(find.byIcon(Icons.headphones)).id, + ), + ); + await tester.pumpAndSettle(); + + // Make sure no focus highlight was drawn. + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(focusNode.hasFocus, isTrue); + expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.touch)); + expect(inkFeatures, isNot(paints..rect(color: focusColor))); + + // Check that focus highlight is drawn in traditional mode. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(FocusManager.instance.highlightMode, equals(FocusHighlightMode.traditional)); + expect(inkFeatures, paints..rect(color: focusColor)); + }, skip: !isBrowser); // [intended] tests web-specific behavior. + + testWidgets("IconButton's outline should be behind its child", (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/167431 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + child: IconButton.outlined( + iconSize: 40, + isSelected: false, + onPressed: () {}, + icon: const Badge( + label: Text('Ad', style: TextStyle(fontSize: 18)), + child: Icon(Icons.lightbulb_rounded), + ), + ), + ), + ), + ), + ), + ); + + await expectLater(find.byType(IconButton), matchesGoldenFile('icon_button.badge.outline.png')); + }); + + Future<void> testStatesController(WidgetTester tester, IconButton iconButton) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final MaterialStatesController controller = iconButton.statesController!; + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget(MaterialApp(home: Center(child: iconButton))); + + expect(controller.value, <WidgetState>{}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(Icon)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 8); // adds hovered and pressed - two changes + } + + testWidgets('IconButton statesController', (WidgetTester tester) async { + await testStatesController( + tester, + IconButton( + icon: const Icon(Icons.add), + onPressed: () {}, + statesController: MaterialStatesController(), + ), + ); + }); + + testWidgets('IconButton.filled statesController', (WidgetTester tester) async { + await testStatesController( + tester, + IconButton.filled( + onPressed: () {}, + icon: const Icon(Icons.add), + statesController: MaterialStatesController(), + ), + ); + }); + + testWidgets('IconButton.filledTonal statesController', (WidgetTester tester) async { + await testStatesController( + tester, + IconButton.filledTonal( + onPressed: () {}, + icon: const Icon(Icons.add), + statesController: MaterialStatesController(), + ), + ); + }); + + testWidgets('IconButton.outlined statesController', (WidgetTester tester) async { + await testStatesController( + tester, + IconButton.outlined( + onPressed: () {}, + icon: const Icon(Icons.add), + statesController: MaterialStatesController(), + ), + ); + }); + + testWidgets('IconButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.add)), + ), + ), + ), + ); + expect(tester.getSize(find.byType(IconButton)), Size.zero); + }); +} + +Widget buildAllVariants({ + bool enabled = true, + bool useMaterial3 = true, + void Function(bool)? onHover, + VoidCallback? onLongPress, +}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: <Widget>[ + IconButton( + icon: const Icon(Icons.favorite), + onPressed: enabled ? () {} : null, + onHover: onHover, + onLongPress: onLongPress, + ), + IconButton.filled( + icon: const Icon(Icons.add), + onPressed: enabled ? () {} : null, + onHover: onHover, + onLongPress: onLongPress, + ), + IconButton.filledTonal( + icon: const Icon(Icons.settings), + onPressed: enabled ? () {} : null, + onHover: onHover, + onLongPress: onLongPress, + ), + IconButton.outlined( + icon: const Icon(Icons.home), + onPressed: enabled ? () {} : null, + onHover: onHover, + onLongPress: onLongPress, + ), + ], + ), + ), + ), + ); +} + +Widget wrap({required Widget child, required bool useMaterial3}) { + return useMaterial3 + ? MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ), + ), + ) + : FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Center(child: child)), + ), + ); +} + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} diff --git a/packages/material_ui/test/material/icon_button_theme_test.dart b/packages/material_ui/test/material/icon_button_theme_test.dart new file mode 100644 index 000000000000..32f6b34f85e6 --- /dev/null +++ b/packages/material_ui/test/material/icon_button_theme_test.dart @@ -0,0 +1,338 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + RenderObject getOverlayColor(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + test('IconButtonThemeData lerp special cases', () { + expect(IconButtonThemeData.lerp(null, null, 0), null); + const data = IconButtonThemeData(); + expect(identical(IconButtonThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Passing no IconButtonTheme returns defaults', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: IconButton(onPressed: () {}, icon: const Icon(Icons.ac_unit)), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, IconTheme, IconButton style overrides]', () { + const foregroundColor = Color(0xff000001); + const disabledForegroundColor = Color(0xff000002); + const backgroundColor = Color(0xff000003); + const shadowColor = Color(0xff000004); + const double elevation = 3; + const padding = EdgeInsets.all(3); + const minimumSize = Size(200, 200); + const 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 animationDuration = Duration(milliseconds: 25); + const enableFeedback = false; + const AlignmentGeometry alignment = Alignment.centerLeft; + + final ButtonStyle style = IconButton.styleFrom( + foregroundColor: foregroundColor, + disabledForegroundColor: disabledForegroundColor, + backgroundColor: backgroundColor, + shadowColor: shadowColor, + elevation: elevation, + 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 IconButton(style: buttonStyle, onPressed: () {}, icon: const Icon(Icons.ac_unit)); + }, + ); + return MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(iconButtonTheme: IconButtonThemeData(style: overallStyle)), + home: Scaffold( + body: Center( + // If the IconButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.iconButtonTheme. + child: themeStyle == null + ? child + : IconButtonTheme( + data: IconButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(IconButton), + matching: find.byType(InkWell), + ); + + const enabled = <WidgetState>{}; + const disabled = <WidgetState>{WidgetState.disabled}; + const hovered = <WidgetState>{WidgetState.hovered}; + const focused = <WidgetState>{WidgetState.focused}; + const pressed = <WidgetState>{WidgetState.pressed}; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget<Material>(findMaterial); + final InkWell inkWell = tester.widget<InkWell>(findInkWell); + expect(material.textStyle, null); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect( + WidgetStateProperty.resolveAs<MouseCursor?>(inkWell.mouseCursor, enabled), + enabledMouseCursor, + ); + expect( + WidgetStateProperty.resolveAs<MouseCursor?>(inkWell.mouseCursor, disabled), + disabledMouseCursor, + ); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.1)); + expect(inkWell.overlayColor!.resolve(pressed), foregroundColor.withOpacity(0.1)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(IconButton)), const Size(200, 200)); + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.byIcon(Icons.ac_unit), 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.light(); + const shadowColor = Color(0xff000001); + const 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: IconButtonTheme( + data: IconButtonThemeData(style: IconButton.styleFrom(shadowColor: themeShadowColor)), + child: Builder( + builder: (BuildContext context) { + return IconButton( + style: IconButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + icon: const Icon(Icons.add), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(IconButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.transparent); //default + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.transparent); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('IconButtonTheme IconButton.styleFrom overlayColor overrides default overlay color', ( + WidgetTester tester, + ) async { + const overlayColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: IconButtonTheme( + data: IconButtonThemeData(style: IconButton.styleFrom(overlayColor: overlayColor)), + child: IconButton(onPressed: () {}, icon: const Icon(Icons.add)), + ), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(IconButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.1))); + }); +} diff --git a/packages/material_ui/test/material/icons_test.dart b/packages/material_ui/test/material/icons_test.dart new file mode 100644 index 000000000000..6099ab19c72b --- /dev/null +++ b/packages/material_ui/test/material/icons_test.dart @@ -0,0 +1,178 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart'; + +void main() { + testWidgets('IconData object test', (WidgetTester tester) async { + expect(Icons.account_balance, isNot(equals(Icons.account_box))); + expect(Icons.account_balance.hashCode, isNot(equals(Icons.account_box.hashCode))); + expect(Icons.account_balance, hasOneLineDescription); + }); + + testWidgets('Icons specify the material font', (WidgetTester tester) async { + expect(Icons.clear.fontFamily, 'MaterialIcons'); + expect(Icons.search.fontFamily, 'MaterialIcons'); + }); + + testWidgets('Certain icons (and their variants) match text direction', ( + WidgetTester tester, + ) async { + expect(Icons.arrow_back.matchTextDirection, true); + expect(Icons.arrow_back_rounded.matchTextDirection, true); + expect(Icons.arrow_back_outlined.matchTextDirection, true); + expect(Icons.arrow_back_sharp.matchTextDirection, true); + + expect(Icons.access_time.matchTextDirection, false); + expect(Icons.access_time_rounded.matchTextDirection, false); + expect(Icons.access_time_outlined.matchTextDirection, false); + expect(Icons.access_time_sharp.matchTextDirection, false); + }); + + testWidgets( + 'Adaptive icons are correct on cupertino platforms', + (WidgetTester tester) async { + expect(Icons.adaptive.arrow_back, Icons.arrow_back_ios); + expect(Icons.adaptive.arrow_back_outlined, Icons.arrow_back_ios_outlined); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Adaptive icons are correct on non-cupertino platforms', + (WidgetTester tester) async { + expect(Icons.adaptive.arrow_back, Icons.arrow_back); + expect(Icons.adaptive.arrow_back_outlined, Icons.arrow_back_outlined); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.windows, + TargetPlatform.linux, + }), + ); + + testWidgets('A sample of icons look as expected', (WidgetTester tester) async { + await _loadIconFont(); + + await tester.pumpWidget( + const MaterialApp( + home: IconTheme( + data: IconThemeData(size: 200), + child: Wrap( + children: <Icon>[ + Icon(Icons.ten_k), + Icon(Icons.ac_unit), + Icon(Icons.local_taxi), + Icon(Icons.local_taxi_outlined), + Icon(Icons.local_taxi_rounded), + Icon(Icons.local_taxi_sharp), + Icon(Icons.zoom_out_sharp), + ], + ), + ), + ), + ); + + await expectLater(find.byType(Wrap), matchesGoldenFile('test.icons.sample.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/39998 + + // Regression test for https://github.com/flutter/flutter/issues/95886 + testWidgets('Another sample of icons look as expected', (WidgetTester tester) async { + await _loadIconFont(); + + await tester.pumpWidget( + const MaterialApp( + home: IconTheme( + data: IconThemeData(size: 200), + child: Wrap( + children: <Icon>[ + Icon(Icons.water_drop), + Icon(Icons.water_drop_outlined), + Icon(Icons.water_drop_rounded), + Icon(Icons.water_drop_sharp), + ], + ), + ), + ), + ); + + await expectLater(find.byType(Wrap), matchesGoldenFile('test.icons.sample2.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/39998 + + testWidgets('Another sample of icons look as expected', (WidgetTester tester) async { + await _loadIconFont(); + + await tester.pumpWidget( + const MaterialApp( + home: IconTheme( + data: IconThemeData(size: 200), + child: Wrap( + children: <Icon>[ + Icon(Icons.electric_bolt), + Icon(Icons.electric_bolt_outlined), + Icon(Icons.electric_bolt_rounded), + Icon(Icons.electric_bolt_sharp), + ], + ), + ), + ), + ); + + await expectLater(find.byType(Wrap), matchesGoldenFile('test.icons.sample3.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/39998 + + // Regression test for https://github.com/flutter/flutter/issues/103202. + testWidgets('Another sample of icons look as expected', (WidgetTester tester) async { + await _loadIconFont(); + + await tester.pumpWidget( + const MaterialApp( + home: IconTheme( + data: IconThemeData(size: 200), + child: Wrap( + children: <Icon>[ + Icon(Icons.repeat_on), + Icon(Icons.repeat_on_outlined), + Icon(Icons.repeat_on_rounded), + Icon(Icons.repeat_on_sharp), + ], + ), + ), + ), + ); + + await expectLater(find.byType(Wrap), matchesGoldenFile('test.icons.sample4.png')); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/39998 +} + +// Loads the cached material icon font. +// Only necessary for golden tests. Relies on the tool updating cached assets before +// running tests. +Future<void> _loadIconFont() async { + const FileSystem fs = LocalFileSystem(); + const Platform platform = LocalPlatform(); + final Directory flutterRoot = fs.directory(platform.environment['FLUTTER_ROOT']); + + final File iconFont = flutterRoot.childFile( + fs.path.join('bin', 'cache', 'artifacts', 'material_fonts', 'MaterialIcons-Regular.otf'), + ); + + final bytes = Future<ByteData>.value(iconFont.readAsBytesSync().buffer.asByteData()); + + await (FontLoader('MaterialIcons')..addFont(bytes)).load(); +} diff --git a/packages/material_ui/test/material/inherited_theme_test.dart b/packages/material_ui/test/material/inherited_theme_test.dart new file mode 100644 index 000000000000..23a469a7b60e --- /dev/null +++ b/packages/material_ui/test/material/inherited_theme_test.dart @@ -0,0 +1,680 @@ +// 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('Theme.wrap()', (WidgetTester tester) async { + const primaryColor = Color(0xFF00FF00); + final Key primaryContainerKey = UniqueKey(); + + // Effectively the same as a StatelessWidget subclass. + final Widget primaryBox = Builder( + builder: (BuildContext context) { + return Container(key: primaryContainerKey, color: Theme.of(context).primaryColor); + }, + ); + + late BuildContext navigatorContext; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: Builder( + // Introduce a context so the app's Theme is visible. + builder: (BuildContext context) { + navigatorContext = context; + return Theme( + data: Theme.of(context).copyWith(primaryColor: primaryColor), + child: Builder( + // Introduce a context so the shadow Theme is visible to captureAll(). + builder: (BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + child: const Text('push unwrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // The primaryBox will see the default Theme when built. + builder: (BuildContext _) => primaryBox, + ), + ); + }, + ), + ElevatedButton( + child: const Text('push wrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // Capture the shadow Theme. + builder: (BuildContext _) => + InheritedTheme.captureAll(context, primaryBox), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ); + }, + ), + ), + ); + } + + Color containerColor() { + return tester.widget<Container>(find.byKey(primaryContainerKey)).color!; + } + + await tester.pumpWidget(buildFrame()); + + // Show the route which contains primaryBox which was wrapped with + // InheritedTheme.captureAll(). + await tester.tap(find.text('push wrapped')); + await tester.pumpAndSettle(); // route animation + expect(containerColor(), primaryColor); + + Navigator.of(navigatorContext).pop(); + await tester.pumpAndSettle(); // route animation + + // Show the route which contains primaryBox + await tester.tap(find.text('push unwrapped')); + await tester.pumpAndSettle(); // route animation + expect(containerColor(), isNot(primaryColor)); + }); + + testWidgets('Material2 - PopupMenuTheme.wrap()', (WidgetTester tester) async { + const double menuFontSize = 24; + const menuTextColor = Color(0xFF0000FF); + + Widget buildFrame() { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: PopupMenuTheme( + data: const PopupMenuThemeData( + // The menu route's elevation, shape, and color are defined by the + // current context, so they're not affected by ThemeData.captureAll(). + textStyle: TextStyle(fontSize: menuFontSize, color: menuTextColor), + ), + child: Center( + child: PopupMenuButton<int>( + // The appearance of the menu items' text is defined by the + // PopupMenuTheme defined above. Popup menus use + // InheritedTheme.captureAll() by default. + child: const Text('show popupmenu'), + onSelected: (int result) {}, + itemBuilder: (BuildContext context) { + return const <PopupMenuEntry<int>>[ + PopupMenuItem<int>(value: 1, child: Text('One')), + PopupMenuItem<int>(value: 2, child: Text('Two')), + ]; + }, + ), + ), + ), + ), + ); + } + + TextStyle itemTextStyle(String text) { + return tester + .widget<RichText>(find.descendant(of: find.text(text), matching: find.byType(RichText))) + .text + .style!; + } + + await tester.pumpWidget(buildFrame()); + + await tester.tap(find.text('show popupmenu')); + await tester.pumpAndSettle(); // menu route animation + expect(itemTextStyle('One').fontSize, menuFontSize); + expect(itemTextStyle('One').color, menuTextColor); + expect(itemTextStyle('Two').fontSize, menuFontSize); + expect(itemTextStyle('Two').color, menuTextColor); + + // Dismiss the menu + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); // menu route animation + }); + + testWidgets('Material3 - PopupMenuTheme.wrap()', (WidgetTester tester) async { + const textStyle = TextStyle(fontSize: 24.0, color: Color(0xFF0000FF)); + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: PopupMenuTheme( + data: const PopupMenuThemeData( + // The menu route's elevation, shape, and color are defined by the + // current context, so they're not affected by ThemeData.captureAll(). + labelTextStyle: MaterialStatePropertyAll<TextStyle>(textStyle), + ), + child: Center( + child: PopupMenuButton<int>( + // The appearance of the menu items' text is defined by the + // PopupMenuTheme defined above. Popup menus use + // InheritedTheme.captureAll() by default. + child: const Text('show popupmenu'), + onSelected: (int result) {}, + itemBuilder: (BuildContext context) { + return const <PopupMenuEntry<int>>[ + PopupMenuItem<int>(value: 1, child: Text('One')), + PopupMenuItem<int>(value: 2, child: Text('Two')), + ]; + }, + ), + ), + ), + ), + ); + } + + TextStyle itemTextStyle(String text) { + return tester + .widget<RichText>(find.descendant(of: find.text(text), matching: find.byType(RichText))) + .text + .style!; + } + + await tester.pumpWidget(buildFrame()); + + await tester.tap(find.text('show popupmenu')); + await tester.pumpAndSettle(); // menu route animation + expect(itemTextStyle('One').fontSize, textStyle.fontSize); + expect(itemTextStyle('One').color, textStyle.color); + expect(itemTextStyle('Two').fontSize, textStyle.fontSize); + expect(itemTextStyle('Two').color, textStyle.color); + + // Dismiss the menu + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); // menu route animation + }); + + testWidgets('BannerTheme.wrap()', (WidgetTester tester) async { + const bannerBackgroundColor = Color(0xFF0000FF); + const double bannerFontSize = 48; + const bannerTextColor = Color(0xFF00FF00); + + final Widget banner = MaterialBanner( + content: const Text('hello'), + actions: <Widget>[TextButton(child: const Text('action'), onPressed: () {})], + ); + + late BuildContext navigatorContext; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: MaterialBannerTheme( + data: const MaterialBannerThemeData( + backgroundColor: bannerBackgroundColor, + contentTextStyle: TextStyle(fontSize: bannerFontSize, color: bannerTextColor), + ), + child: Builder( + // Introduce a context so the shadow BannerTheme is visible to captureAll(). + builder: (BuildContext context) { + navigatorContext = context; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + child: const Text('push unwrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // The Banner will see the default BannerTheme when built. + builder: (BuildContext _) => banner, + ), + ); + }, + ), + ElevatedButton( + child: const Text('push wrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // Capture the shadow BannerTheme. + builder: (BuildContext _) => + InheritedTheme.captureAll(context, banner), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + Color bannerColor() { + return tester + .widget<Material>( + find.descendant(of: find.byType(MaterialBanner), matching: find.byType(Material)).first, + ) + .color!; + } + + TextStyle getTextStyle(String text) { + return tester + .widget<RichText>(find.descendant(of: find.text(text), matching: find.byType(RichText))) + .text + .style!; + } + + await tester.pumpWidget(buildFrame()); + + // Show the route which contains the banner. + await tester.tap(find.text('push wrapped')); + await tester.pumpAndSettle(); // route animation + expect(bannerColor(), bannerBackgroundColor); + expect(getTextStyle('hello').fontSize, bannerFontSize); + expect(getTextStyle('hello').color, bannerTextColor); + + Navigator.of(navigatorContext).pop(); + await tester.pumpAndSettle(); // route animation + + await tester.tap(find.text('push unwrapped')); + await tester.pumpAndSettle(); // route animation + expect(bannerColor(), isNot(bannerBackgroundColor)); + expect(getTextStyle('hello').fontSize, isNot(bannerFontSize)); + expect(getTextStyle('hello').color, isNot(bannerTextColor)); + }); + + testWidgets('DividerTheme.wrap()', (WidgetTester tester) async { + const dividerColor = Color(0xFF0000FF); + const double dividerSpace = 13; + const double dividerThickness = 7; + const Widget divider = Center(child: Divider()); + + late BuildContext navigatorContext; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: DividerTheme( + data: const DividerThemeData( + color: dividerColor, + space: dividerSpace, + thickness: dividerThickness, + ), + child: Builder( + // Introduce a context so the shadow DividerTheme is visible to captureAll(). + builder: (BuildContext context) { + navigatorContext = context; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + child: const Text('push unwrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // The Banner will see the default BannerTheme when built. + builder: (BuildContext _) => divider, + ), + ); + }, + ), + ElevatedButton( + child: const Text('push wrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // Capture the shadow BannerTheme. + builder: (BuildContext _) => + InheritedTheme.captureAll(context, divider), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + BorderSide dividerBorder() { + final decoration = + tester + .widget<Container>( + find + .descendant(of: find.byType(Divider), matching: find.byType(Container)) + .first, + ) + .decoration! + as BoxDecoration; + return decoration.border!.bottom; + } + + await tester.pumpWidget(buildFrame()); + + // Show a route which contains a divider. + await tester.tap(find.text('push wrapped')); + await tester.pumpAndSettle(); // route animation + expect(tester.getSize(find.byType(Divider)).height, dividerSpace); + expect(dividerBorder().color, dividerColor); + expect(dividerBorder().width, dividerThickness); + + Navigator.of(navigatorContext).pop(); + await tester.pumpAndSettle(); // route animation + + await tester.tap(find.text('push unwrapped')); + await tester.pumpAndSettle(); // route animation + expect(tester.getSize(find.byType(Divider)).height, isNot(dividerSpace)); + expect(dividerBorder().color, isNot(dividerColor)); + expect(dividerBorder().width, isNot(dividerThickness)); + }); + + testWidgets('ListTileTheme.wrap()', (WidgetTester tester) async { + const tileSelectedColor = Color(0xFF00FF00); + const tileIconColor = Color(0xFF0000FF); + const tileTextColor = Color(0xFFFF0000); + + final Key selectedIconKey = UniqueKey(); + final Key unselectedIconKey = UniqueKey(); + + final Widget listTiles = Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ListTile( + leading: Icon(Icons.computer, key: selectedIconKey), + title: const Text('selected'), + selected: true, + ), + ListTile( + leading: Icon(Icons.add, key: unselectedIconKey), + title: const Text('unselected'), + ), + ], + ), + ), + ); + + late BuildContext navigatorContext; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: ListTileTheme( + selectedColor: tileSelectedColor, + textColor: tileTextColor, + iconColor: tileIconColor, + child: Builder( + // Introduce a context so the shadow ListTileTheme is visible to captureAll(). + builder: (BuildContext context) { + navigatorContext = context; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + child: const Text('push unwrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // The Banner will see the default BannerTheme when built. + builder: (BuildContext _) => listTiles, + ), + ); + }, + ), + ElevatedButton( + child: const Text('push wrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // Capture the shadow BannerTheme. + builder: (BuildContext _) => + InheritedTheme.captureAll(context, listTiles), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + TextStyle getTextStyle(String text) { + return tester + .widget<RichText>(find.descendant(of: find.text(text), matching: find.byType(RichText))) + .text + .style!; + } + + TextStyle getIconStyle(Key key) { + return tester + .widget<RichText>(find.descendant(of: find.byKey(key), matching: find.byType(RichText))) + .text + .style!; + } + + await tester.pumpWidget(buildFrame()); + + // Show a route which contains listTiles. + await tester.tap(find.text('push wrapped')); + await tester.pumpAndSettle(); // route animation + expect(getTextStyle('unselected').color, tileTextColor); + expect(getTextStyle('selected').color, tileSelectedColor); + expect(getIconStyle(selectedIconKey).color, tileSelectedColor); + expect(getIconStyle(unselectedIconKey).color, tileIconColor); + + Navigator.of(navigatorContext).pop(); + await tester.pumpAndSettle(); // route animation + + await tester.tap(find.text('push unwrapped')); + await tester.pumpAndSettle(); // route animation + expect(getTextStyle('unselected').color, isNot(tileTextColor)); + expect(getTextStyle('selected').color, isNot(tileSelectedColor)); + expect(getIconStyle(selectedIconKey).color, isNot(tileSelectedColor)); + expect(getIconStyle(unselectedIconKey).color, isNot(tileIconColor)); + }); + + testWidgets('SliderTheme.wrap()', (WidgetTester tester) async { + const activeTrackColor = Color(0xFF00FF00); + const inactiveTrackColor = Color(0xFF0000FF); + const thumbColor = Color(0xFFFF0000); + + final Widget slider = Scaffold( + body: Center(child: Slider(value: 0.5, onChanged: (double value) {})), + ); + + late BuildContext navigatorContext; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: SliderTheme( + data: const SliderThemeData( + activeTrackColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + thumbColor: thumbColor, + ), + child: Builder( + // Introduce a context so the shadow SliderTheme is visible to captureAll(). + builder: (BuildContext context) { + navigatorContext = context; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + child: const Text('push unwrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // The slider will see the default SliderTheme when built. + builder: (BuildContext _) => slider, + ), + ); + }, + ), + ElevatedButton( + child: const Text('push wrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // Capture the shadow SliderTheme. + builder: (BuildContext _) => + InheritedTheme.captureAll(context, slider), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + // Show a route which contains listTiles. + await tester.tap(find.text('push wrapped')); + await tester.pumpAndSettle(); // route animation + RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(Slider)); + expect( + sliderBox, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor), + ); + expect(sliderBox, paints..circle(color: thumbColor)); + + Navigator.of(navigatorContext).pop(); + await tester.pumpAndSettle(); // route animation + + await tester.tap(find.text('push unwrapped')); + await tester.pumpAndSettle(); // route animation + sliderBox = tester.firstRenderObject<RenderBox>(find.byType(Slider)); + expect( + sliderBox, + isNot( + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor), + ), + ); + expect(sliderBox, isNot(paints..circle(color: thumbColor))); + }); + + testWidgets('ToggleButtonsTheme.wrap()', (WidgetTester tester) async { + const buttonColor = Color(0xFF00FF00); + const selectedButtonColor = Color(0xFFFF0000); + + final Widget toggleButtons = Scaffold( + body: Center( + child: ToggleButtons( + isSelected: const <bool>[true, false], + children: const <Widget>[Text('selected'), Text('unselected')], + onPressed: (int index) {}, + ), + ), + ); + + late BuildContext navigatorContext; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + color: buttonColor, + selectedColor: selectedButtonColor, + ), + child: Builder( + // Introduce a context so the shadow ToggleButtonsTheme is visible to captureAll(). + builder: (BuildContext context) { + navigatorContext = context; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + ElevatedButton( + child: const Text('push unwrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // The slider will see the default ToggleButtonsTheme when built. + builder: (BuildContext _) => toggleButtons, + ), + ); + }, + ), + ElevatedButton( + child: const Text('push wrapped'), + onPressed: () { + Navigator.of(context).push<void>( + MaterialPageRoute<void>( + // Capture the shadow toggleButtons. + builder: (BuildContext _) => + InheritedTheme.captureAll(context, toggleButtons), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + Color getTextColor(String text) { + return tester + .widget<RichText>(find.descendant(of: find.text(text), matching: find.byType(RichText))) + .text + .style! + .color!; + } + + await tester.pumpWidget(buildFrame()); + + // Show a route which contains toggleButtons. + await tester.tap(find.text('push wrapped')); + await tester.pumpAndSettle(); // route animation + expect(getTextColor('selected'), selectedButtonColor); + expect(getTextColor('unselected'), buttonColor); + + Navigator.of(navigatorContext).pop(); + await tester.pumpAndSettle(); // route animation + + await tester.tap(find.text('push unwrapped')); + await tester.pumpAndSettle(); // route animation + expect(getTextColor('selected'), isNot(selectedButtonColor)); + expect(getTextColor('unselected'), isNot(buttonColor)); + }); +} diff --git a/packages/material_ui/test/material/ink_paint_test.dart b/packages/material_ui/test/material/ink_paint_test.dart new file mode 100644 index 000000000000..00e7873dde6c --- /dev/null +++ b/packages/material_ui/test/material/ink_paint_test.dart @@ -0,0 +1,825 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('The Ink widget expands when no dimensions are set', (WidgetTester tester) async { + await tester.pumpWidget(Material(child: Ink())); + expect(find.byType(Ink), findsOneWidget); + expect(tester.getSize(find.byType(Ink)), const Size(800.0, 600.0)); + }); + + testWidgets('The Ink widget fits the specified size', (WidgetTester tester) async { + const height = 150.0; + const width = 200.0; + await tester.pumpWidget( + Material( + child: Center( + // used to constrain to child's size + child: Ink(height: height, width: width), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(Ink), findsOneWidget); + expect(tester.getSize(find.byType(Ink)), const Size(width, height)); + }); + + testWidgets('The Ink widget expands on a unspecified dimension', (WidgetTester tester) async { + const height = 150.0; + await tester.pumpWidget( + Material( + child: Center( + // used to constrain to child's size + child: Ink(height: height), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(Ink), findsOneWidget); + expect(tester.getSize(find.byType(Ink)), const Size(800, height)); + }); + + testWidgets('Material2 - InkWell widget renders an ink splash', (WidgetTester tester) async { + const splashColor = Color(0xAA0000FF); + const borderRadius = BorderRadius.all(Radius.circular(6.0)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: SizedBox( + width: 200.0, + height: 60.0, + child: InkWell(borderRadius: borderRadius, splashColor: splashColor, onTap: () {}), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(InkWell)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect( + box, + paints + ..translate(x: 0.0, y: 0.0) + ..save() + ..translate(x: 300.0, y: 270.0) + ..clipRRect(rrect: RRect.fromLTRBR(0.0, 0.0, 200.0, 60.0, const Radius.circular(6.0))) + ..circle(x: 100.0, y: 30.0, radius: 21.0, color: splashColor) + ..restore(), + ); + + await gesture.up(); + }); + + testWidgets('Material3 - InkWell widget renders an ink splash', (WidgetTester tester) async { + const inkWellKey = Key('InkWell'); + const splashColor = Color(0xAA0000FF); + const borderRadius = BorderRadius.all(Radius.circular(6.0)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 200.0, + height: 60.0, + child: InkWell( + key: inkWellKey, + borderRadius: borderRadius, + splashColor: splashColor, + onTap: () {}, + ), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(InkWell)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + if (kIsWeb) { + expect( + box, + paints + ..save() + ..translate(x: 0.0, y: 0.0) + ..clipRect() + ..save() + ..translate(x: 300.0, y: 270.0) + ..clipRRect(rrect: RRect.fromLTRBR(0.0, 0.0, 200.0, 60.0, const Radius.circular(6.0))) + ..circle() + ..restore(), + ); + } else { + expect( + box, + paints + ..translate(x: 0.0, y: 0.0) + ..save() + ..translate(x: 300.0, y: 270.0) + ..clipRRect(rrect: RRect.fromLTRBR(0.0, 0.0, 200.0, 60.0, const Radius.circular(6.0))) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200, 60)) + ..restore(), + ); + } + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. Use a golden test instead. + await expectLater( + find.byKey(inkWellKey), + matchesGoldenFile('m3_ink_well.renders.ink_splash.png'), + ); + + await gesture.up(); + }); + + testWidgets('The InkWell widget renders an ink ripple', (WidgetTester tester) async { + const highlightColor = Color(0xAAFF0000); + const splashColor = Color(0xB40000FF); + const borderRadius = BorderRadius.all(Radius.circular(6.0)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: InkWell( + borderRadius: borderRadius, + highlightColor: highlightColor, + splashColor: splashColor, + onTap: () {}, + radius: 100.0, + splashFactory: InkRipple.splashFactory, + ), + ), + ), + ), + ), + ); + + final Offset tapDownOffset = tester.getTopLeft(find.byType(InkWell)); + final Offset inkWellCenter = tester.getCenter(find.byType(InkWell)); + //final TestGesture gesture = await tester.startGesture(tapDownOffset); + await tester.tapAt(tapDownOffset); + await tester.pump(); // start gesture + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + + bool offsetsAreClose(Offset a, Offset b) => (a - b).distance < 1.0; + bool radiiAreClose(double a, double b) => (a - b).abs() < 1.0; + + PaintPattern ripplePattern(Offset expectedCenter, double expectedRadius, int expectedAlpha) { + return paints + ..translate(x: 0.0, y: 0.0) + ..translate(x: tapDownOffset.dx, y: tapDownOffset.dy) + ..something((Symbol method, List<dynamic> arguments) { + if (method != #drawCircle) { + return false; + } + final center = arguments[0] as Offset; + final radius = arguments[1] as double; + final paint = arguments[2] as Paint; + if (offsetsAreClose(center, expectedCenter) && + radiiAreClose(radius, expectedRadius) && + paint.color.alpha == expectedAlpha) { + return true; + } + throw ''' + Expected: center == $expectedCenter, radius == $expectedRadius, alpha == $expectedAlpha + Found: center == $center radius == $radius alpha == ${paint.color.alpha}'''; + }); + } + + // Initially the ripple's center is where the tap occurred; + // ripplePattern always add a translation of tapDownOffset. + expect(box, ripplePattern(Offset.zero, 30.0, 0)); + + // The ripple fades in for 75ms. During that time its alpha is eased from + // 0 to the splashColor's alpha value and its center moves towards the + // center of the ink well. + await tester.pump(const Duration(milliseconds: 50)); + expect(box, ripplePattern(const Offset(17.0, 17.0), 56.0, 120)); + + // At 75ms the ripple has fade in: it's alpha matches the splashColor's + // alpha and its center has moved closer to the ink well's center. + await tester.pump(const Duration(milliseconds: 25)); + expect(box, ripplePattern(const Offset(29.0, 29.0), 73.0, 180)); + + // At this point the splash radius has expanded to its limit: 5 past the + // ink well's radius parameter. The splash center has moved to its final + // location at the inkwell's center and the fade-out is about to start. + // The fade-out begins at 225ms = 50ms + 25ms + 150ms. + await tester.pump(const Duration(milliseconds: 150)); + expect(box, ripplePattern(inkWellCenter - tapDownOffset, 105.0, 180)); + + // After another 150ms the fade-out is complete. + await tester.pump(const Duration(milliseconds: 150)); + expect(box, ripplePattern(inkWellCenter - tapDownOffset, 105.0, 0)); + }); + + testWidgets('Material2 - Does the Ink widget render anything', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Ink( + color: Colors.blue, + width: 200.0, + height: 200.0, + child: InkWell(splashColor: Colors.green, onTap: () {}), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(InkWell)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect( + box, + paints + ..rect( + rect: const Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), + color: Color(Colors.blue.value), + ) + ..circle(color: Color(Colors.green.value)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Ink( + color: Colors.red, + width: 200.0, + height: 200.0, + child: InkWell(splashColor: Colors.green, onTap: () {}), + ), + ), + ), + ), + ); + + expect(Material.of(tester.element(find.byType(InkWell))), same(box)); + + expect( + box, + paints + ..rect( + rect: const Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), + color: Color(Colors.red.value), + ) + ..circle(color: Color(Colors.green.value)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: InkWell( + // this is at a different depth in the tree so it's now a new InkWell + splashColor: Colors.green, + onTap: () {}, + ), + ), + ), + ), + ); + + expect(Material.of(tester.element(find.byType(InkWell))), same(box)); + + expect(box, isNot(paints..rect())); + expect(box, isNot(paints..circle())); + + await gesture.up(); + }); + + testWidgets('Material3 - Does the Ink widget render anything', (WidgetTester tester) async { + const inkWellKey = Key('InkWell'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Ink( + color: Colors.blue, + width: 200.0, + height: 200.0, + child: InkWell(key: inkWellKey, splashColor: Colors.green, onTap: () {}), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(InkWell)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect( + box, + paints..rect( + rect: const Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), + color: Color(Colors.blue.value), + ), + ); + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. Use a golden test instead. + await expectLater(find.byKey(inkWellKey), matchesGoldenFile('m3_ink.renders.anything.0.png')); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Ink( + color: Colors.red, + width: 200.0, + height: 200.0, + child: InkWell(key: inkWellKey, splashColor: Colors.green, onTap: () {}), + ), + ), + ), + ), + ); + + expect(Material.of(tester.element(find.byType(InkWell))), same(box)); + + expect( + box, + paints..rect( + rect: const Rect.fromLTRB(300.0, 200.0, 500.0, 400.0), + color: Color(Colors.red.value), + ), + ); + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. Use a golden test instead. + await expectLater(find.byKey(inkWellKey), matchesGoldenFile('m3_ink.renders.anything.1.png')); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: InkWell( + // This is at a different depth in the tree so it's now a new InkWell. + key: inkWellKey, + splashColor: Colors.green, + onTap: () {}, + ), + ), + ), + ), + ); + + expect(Material.of(tester.element(find.byType(InkWell))), same(box)); + + expect(box, isNot(paints..rect())); + expect(box, isNot(paints..rect())); + + await gesture.up(); + }); + + testWidgets('The InkWell widget renders an SelectAction or ActivateAction-induced ink ripple', ( + WidgetTester tester, + ) async { + const highlightColor = Color(0xAAFF0000); + const splashColor = Color(0xB40000FF); + const borderRadius = BorderRadius.all(Radius.circular(6.0)); + + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + Future<void> buildTest(Intent intent) async { + return tester.pumpWidget( + Shortcuts( + shortcuts: <ShortcutActivator, Intent>{ + const SingleActivator(LogicalKeyboardKey.space): intent, + }, + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: InkWell( + borderRadius: borderRadius, + highlightColor: highlightColor, + splashColor: splashColor, + focusNode: focusNode, + onTap: () {}, + radius: 100.0, + splashFactory: InkRipple.splashFactory, + ), + ), + ), + ), + ), + ), + ); + } + + await buildTest(const ActivateIntent()); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final Offset topLeft = tester.getTopLeft(find.byType(InkWell)); + final Offset inkWellCenter = tester.getCenter(find.byType(InkWell)) - topLeft; + + bool offsetsAreClose(Offset a, Offset b) => (a - b).distance < 1.0; + bool radiiAreClose(double a, double b) => (a - b).abs() < 1.0; + + PaintPattern ripplePattern(double expectedRadius, int expectedAlpha) { + return paints + ..translate(x: 0.0, y: 0.0) + ..translate(x: topLeft.dx, y: topLeft.dy) + ..something((Symbol method, List<dynamic> arguments) { + if (method != #drawCircle) { + return false; + } + final center = arguments[0] as Offset; + final radius = arguments[1] as double; + final paint = arguments[2] as Paint; + if (offsetsAreClose(center, inkWellCenter) && + radiiAreClose(radius, expectedRadius) && + paint.color.alpha == expectedAlpha) { + return true; + } + throw ''' + Expected: center == $inkWellCenter, radius == $expectedRadius, alpha == $expectedAlpha + Found: center == $center radius == $radius alpha == ${paint.color.alpha}'''; + }); + } + + await buildTest(const ActivateIntent()); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + + // ripplePattern always add a translation of topLeft. + expect(box, ripplePattern(30.0, 0)); + + // The ripple fades in for 75ms. During that time its alpha is eased from + // 0 to the splashColor's alpha value. + await tester.pump(const Duration(milliseconds: 50)); + expect(box, ripplePattern(56.0, 120)); + + // At 75ms the ripple has faded in: it's alpha matches the splashColor's + // alpha. + await tester.pump(const Duration(milliseconds: 25)); + expect(box, ripplePattern(73.0, 180)); + + // At this point the splash radius has expanded to its limit: 5 past the + // ink well's radius parameter. The fade-out is about to start. + // The fade-out begins at 225ms = 50ms + 25ms + 150ms. + await tester.pump(const Duration(milliseconds: 150)); + expect(box, ripplePattern(105.0, 180)); + + // After another 150ms the fade-out is complete. + await tester.pump(const Duration(milliseconds: 150)); + expect(box, ripplePattern(105.0, 0)); + }); + + testWidgets('Cancel an InkRipple that was disposed when its animation ended', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/14391 + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: InkWell(onTap: () {}, radius: 100.0, splashFactory: InkRipple.splashFactory), + ), + ), + ), + ), + ); + + final Offset tapDownOffset = tester.getTopLeft(find.byType(InkWell)); + await tester.tapAt(tapDownOffset); + await tester.pump(); // start splash + await tester.pump(const Duration(milliseconds: 375)); // _kFadeOutDuration, in_ripple.dart + + final TestGesture gesture = await tester.startGesture(tapDownOffset); + await tester.pump(); // start gesture + await gesture.moveTo(Offset.zero); + await gesture.up(); // generates a tap cancel + await tester.pumpAndSettle(); + }); + + testWidgets('Cancel an InkRipple that was disposed when its animation ended', ( + WidgetTester tester, + ) async { + const highlightColor = Color(0xAAFF0000); + const splashColor = Color(0xB40000FF); + + // Regression test for https://github.com/flutter/flutter/issues/14391 + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: InkWell( + splashColor: splashColor, + highlightColor: highlightColor, + onTap: () {}, + radius: 100.0, + splashFactory: InkRipple.splashFactory, + ), + ), + ), + ), + ), + ); + + final Offset tapDownOffset = tester.getTopLeft(find.byType(InkWell)); + await tester.tapAt(tapDownOffset); + await tester.pump(); // start splash + // No delay here so _fadeInController.value=1.0 (InkRipple.dart) + + // Generate a tap cancel; Will cancel the ink splash before it started + final TestGesture gesture = await tester.startGesture(tapDownOffset); + await tester.pump(); // start gesture + await gesture.moveTo(Offset.zero); + await gesture.up(); // generates a tap cancel + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect( + box, + paints..everything((Symbol method, List<dynamic> arguments) { + if (method != #drawCircle) { + return true; + } + final paint = arguments[2] as Paint; + if (paint.color.alpha == 0) { + return true; + } + throw 'Expected: paint.color.alpha == 0, found: ${paint.color.alpha}'; + }), + ); + }); + + testWidgets('The InkWell widget on OverlayPortal does not throw', (WidgetTester tester) async { + final controller = OverlayPortalController(); + controller.show(); + + late OverlayEntry overlayEntry; + addTearDown( + () => overlayEntry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: SizedBox.square( + dimension: 200, + child: Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + initialEntries: <OverlayEntry>[ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: SizedBox.square( + dimension: 100, + // The material partially overlaps the overlayChild. + // This is to verify that the `overlayChild`'s ink + // features aren't clipped by it. + child: Material( + color: Colors.black, + child: OverlayPortal( + controller: controller, + overlayChildBuilder: (BuildContext context) { + return Positioned( + right: 0, + bottom: 0, + child: InkWell( + splashColor: Colors.red, + onTap: () {}, + child: const SizedBox.square(dimension: 100), + ), + ); + }, + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(InkWell))); + addTearDown(() async { + await gesture.up(); + }); + + await tester.pump(); // start gesture + await tester.pump(const Duration(seconds: 2)); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Material2 - Custom rectCallback renders an ink splash from its center', ( + WidgetTester tester, + ) async { + const splashColor = Color(0xff00ff00); + + Widget buildWidget({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + height: 200.0, + child: InkResponse( + splashColor: splashColor, + containedInkWell: true, + highlightShape: BoxShape.rectangle, + splashFactory: splashFactory, + onTap: () {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Offset center = tester.getCenter(find.byType(SizedBox)); + TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pumpAndSettle(); // Finish rendering ink splash. + + var box = Material.of(tester.element(find.byType(InkResponse))) as RenderBox; + expect(box, paints..circle(x: 50.0, y: 100.0, color: splashColor)); + + await gesture.up(); + + await tester.pumpWidget(buildWidget(splashFactory: _InkRippleFactory())); + await tester.pumpAndSettle(); // Finish rendering ink splash. + + gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pumpAndSettle(); // Finish rendering ink splash. + + box = Material.of(tester.element(find.byType(InkResponse))) as RenderBox; + expect(box, paints..circle(x: 50.0, y: 50.0, color: splashColor)); + }); + + testWidgets('Material3 - Custom rectCallback renders an ink splash from its center', ( + WidgetTester tester, + ) async { + const inkWResponseKey = Key('InkResponse'); + const splashColor = Color(0xff00ff00); + + Widget buildWidget({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + height: 200.0, + child: InkResponse( + key: inkWResponseKey, + splashColor: splashColor, + containedInkWell: true, + highlightShape: BoxShape.rectangle, + splashFactory: splashFactory, + onTap: () {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final Offset center = tester.getCenter(find.byType(SizedBox)); + TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pumpAndSettle(); // Finish rendering ink splash. + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. Use a golden test instead. + await expectLater( + find.byKey(inkWResponseKey), + matchesGoldenFile('m3_ink_response.renders.ink_splash_from_its_center.0.png'), + ); + + await gesture.up(); + + await tester.pumpWidget(buildWidget(splashFactory: _InkRippleFactory())); + await tester.pumpAndSettle(); // Finish rendering ink splash. + + gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pumpAndSettle(); // Finish rendering ink splash. + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. Use a golden test instead. + await expectLater( + find.byKey(inkWResponseKey), + matchesGoldenFile('m3_ink_response.renders.ink_splash_from_its_center.1.png'), + ); + }); + + testWidgets('Ink with isVisible=false does not paint', (WidgetTester tester) async { + const testColor = Color(0xffff1234); + Widget inkWidget({required bool isVisible}) { + return Material( + child: Visibility.maintain( + visible: isVisible, + child: Ink(decoration: const BoxDecoration(color: testColor)), + ), + ); + } + + await tester.pumpWidget(inkWidget(isVisible: true)); + RenderBox box = tester.renderObject(find.byType(Material)); + expect(box, paints..rect(color: testColor)); + + await tester.pumpWidget(inkWidget(isVisible: false)); + box = tester.renderObject(find.byType(Material)); + expect(box, isNot(paints..rect(color: testColor))); + }); +} + +class _InkRippleFactory extends InteractiveInkFeatureFactory { + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + required Offset position, + required Color color, + required TextDirection textDirection, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + }) { + return InkRipple( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: containedInkWell, + rectCallback: () => Offset.zero & const Size(100, 100), + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + textDirection: textDirection, + ); + } +} diff --git a/packages/material_ui/test/material/ink_sparkle_test.dart b/packages/material_ui/test/material/ink_sparkle_test.dart new file mode 100644 index 000000000000..b861afb6975b --- /dev/null +++ b/packages/material_ui/test/material/ink_sparkle_test.dart @@ -0,0 +1,239 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/src/foundation/constants.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'InkSparkle in a Button compiles and does not crash', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + style: ElevatedButton.styleFrom(splashFactory: InkSparkle.splashFactory), + child: const Text('Sparkle!'), + onPressed: () {}, + ), + ), + ), + ), + ); + final Finder buttonFinder = find.text('Sparkle!'); + await tester.tap(buttonFinder); + await tester.pump(); + await tester.pumpAndSettle(); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'InkSparkle default splashFactory paints with drawRect when bounded', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InkWell( + splashFactory: InkSparkle.splashFactory, + child: const Text('Sparkle!'), + onTap: () {}, + ), + ), + ), + ), + ); + final Finder buttonFinder = find.text('Sparkle!'); + await tester.tap(buttonFinder); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final MaterialInkController material = Material.of(tester.element(buttonFinder)); + expect(material, paintsExactlyCountTimes(#drawRect, 1)); + + expect((material as dynamic).debugInkFeatures, hasLength(1)); + + await tester.pumpAndSettle(); + // ink feature is disposed. + expect((material as dynamic).debugInkFeatures, isEmpty); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'InkSparkle default splashFactory paints with drawPaint when unbounded', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InkResponse( + splashFactory: InkSparkle.splashFactory, + child: const Text('Sparkle!'), + onTap: () {}, + ), + ), + ), + ), + ); + final Finder buttonFinder = find.text('Sparkle!'); + await tester.tap(buttonFinder); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final MaterialInkController material = Material.of(tester.element(buttonFinder)); + expect(material, paintsExactlyCountTimes(#drawPaint, 1)); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + ///////////// + // Goldens // + ///////////// + + testWidgets( + 'Material2 - InkSparkle renders with sparkles when top left of button is tapped', + (WidgetTester tester) async { + await _runTest(tester, 'top_left', 0.2); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'Material3 - InkSparkle renders with sparkles when top left of button is tapped', + (WidgetTester tester) async { + await _runM3Test(tester, 'top_left', 0.2); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'Material2 - InkSparkle renders with sparkles when center of button is tapped', + (WidgetTester tester) async { + await _runTest(tester, 'center', 0.5); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'Material3 - InkSparkle renders with sparkles when center of button is tapped', + (WidgetTester tester) async { + await _runM3Test(tester, 'center', 0.5); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'Material2 - InkSparkle renders with sparkles when bottom right of button is tapped', + (WidgetTester tester) async { + await _runTest(tester, 'bottom_right', 0.8); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); + + testWidgets( + 'Material3 - InkSparkle renders with sparkles when bottom right of button is tapped', + (WidgetTester tester) async { + await _runM3Test(tester, 'bottom_right', 0.8); + }, + skip: kIsWeb, // [intended] shaders are not yet supported for web. + ); +} + +Future<void> _runTest(WidgetTester tester, String positionName, double distanceFromTopLeft) async { + final Key repaintKey = UniqueKey(); + final Key buttonKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: repaintKey, + child: ElevatedButton( + key: buttonKey, + style: ElevatedButton.styleFrom( + splashFactory: InkSparkle.constantTurbulenceSeedSplashFactory, + ), + child: const Text('Sparkle!'), + onPressed: () {}, + ), + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + final Finder repaintFinder = find.byKey(repaintKey); + final Offset topLeft = tester.getTopLeft(buttonFinder); + final Offset bottomRight = tester.getBottomRight(buttonFinder); + + await _warmUpShader(tester, buttonFinder); + + final Offset target = topLeft + (bottomRight - topLeft) * distanceFromTopLeft; + await tester.tapAt(target); + for (var i = 0; i <= 5; i++) { + await tester.pump(const Duration(milliseconds: 50)); + await expectLater(repaintFinder, matchesGoldenFile('m2_ink_sparkle.$positionName.$i.png')); + } +} + +Future<void> _runM3Test( + WidgetTester tester, + String positionName, + double distanceFromTopLeft, +) async { + final Key repaintKey = UniqueKey(); + final Key buttonKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: repaintKey, + child: ElevatedButton( + key: buttonKey, + style: ElevatedButton.styleFrom(), + child: const Text('Sparkle!'), + onPressed: () {}, + ), + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + final Finder repaintFinder = find.byKey(repaintKey); + final Offset topLeft = tester.getTopLeft(buttonFinder); + final Offset bottomRight = tester.getBottomRight(buttonFinder); + + await _warmUpShader(tester, buttonFinder); + + final Offset target = topLeft + (bottomRight - topLeft) * distanceFromTopLeft; + await tester.tapAt(target); + for (var i = 0; i <= 5; i++) { + await tester.pump(const Duration(milliseconds: 50)); + await expectLater(repaintFinder, matchesGoldenFile('m3_ink_sparkle.$positionName.$i.png')); + } +} + +// Warm up shader. Compilation is of the order of 10 milliseconds and +// Animation is < 1000 milliseconds. Use 2000 milliseconds as a safety +// net to prevent flakiness. +Future<void> _warmUpShader(WidgetTester tester, Finder buttonFinder) async { + await tester.tap(buttonFinder); + await tester.pumpAndSettle(const Duration(milliseconds: 2000)); +} diff --git a/packages/material_ui/test/material/ink_splash_test.dart b/packages/material_ui/test/material/ink_splash_test.dart new file mode 100644 index 000000000000..72823b770ea4 --- /dev/null +++ b/packages/material_ui/test/material/ink_splash_test.dart @@ -0,0 +1,165 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class Page extends StatefulWidget { + const Page({super.key, required this.title, required this.onDispose}); + + final String title; + + final void Function()? onDispose; + + @override + State<Page> createState() => _PageState(); +} + +class _PageState extends State<Page> { + @override + void dispose() { + widget.onDispose?.call(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: FilledButton(onPressed: () {}, child: Text(widget.title)), + ); + } +} + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/21506. + testWidgets('InkSplash receives textDirection', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Button Border Test')), + body: Center( + child: ElevatedButton(child: const Text('Test'), onPressed: () {}), + ), + ), + ), + ); + await tester.tap(find.text('Test')); + // start ink animation which asserts for a textDirection. + await tester.pumpAndSettle(const Duration(milliseconds: 30)); + expect(tester.takeException(), isNull); + }); + + testWidgets('Material2 - InkWell with NoSplash splashFactory paints nothing', ( + WidgetTester tester, + ) async { + Widget buildFrame({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: Material( + child: InkWell(splashFactory: splashFactory, onTap: () {}, 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(); + } + + // Default splashFactory (from Theme.of().splashFactory), one splash circle drawn. + await tester.pumpWidget(buildFrame()); + { + 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('Material3 - InkWell with NoSplash splashFactory paints nothing', ( + WidgetTester tester, + ) async { + Widget buildFrame({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Material( + child: InkWell(splashFactory: splashFactory, onTap: () {}, child: const Text('test')), + ), + ), + ), + ); + } + + // NoSplash.splashFactory, one rect is drawn for the highlight. + 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(#drawRect, 1)); + await gesture.up(); + await tester.pumpAndSettle(); + } + + // Default splashFactory (from Theme.of().splashFactory), two rects are drawn for the splash and highlight. + await tester.pumpWidget(buildFrame()); + { + 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(#drawRect, (kIsWeb ? 1 : 2))); + await gesture.up(); + await tester.pumpAndSettle(); + } + }); + + // Regression test for https://github.com/flutter/flutter/issues/136441. + testWidgets('PageView item can dispose when widget with NoSplash.splashFactory is tapped', ( + WidgetTester tester, + ) async { + final controller = PageController(); + final disposedPageIndexes = <int>[]; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), + home: Scaffold( + body: PageView.builder( + controller: controller, + itemBuilder: (BuildContext context, int index) { + return Page( + title: 'Page $index', + onDispose: () { + disposedPageIndexes.add(index); + }, + ); + }, + itemCount: 3, + ), + ), + ), + ); + controller.jumpToPage(1); + await tester.pumpAndSettle(); + await tester.tap(find.text('Page 1')); + await tester.pumpAndSettle(); + controller.jumpToPage(0); + await tester.pumpAndSettle(); + expect(disposedPageIndexes, <int>[0, 1]); + controller.dispose(); + }); +} diff --git a/packages/material_ui/test/material/ink_well_test.dart b/packages/material_ui/test/material/ink_well_test.dart new file mode 100644 index 000000000000..af8077e08ec9 --- /dev/null +++ b/packages/material_ui/test/material/ink_well_test.dart @@ -0,0 +1,2586 @@ +// 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' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/src/services/keyboard_key.g.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + RenderObject getInkFeatures(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + testWidgets('InkWell gestures control test', (WidgetTester tester) async { + final log = <String>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onTap: () { + log.add('tap'); + }, + onDoubleTap: () { + log.add('double-tap'); + }, + onLongPress: () { + log.add('long-press'); + }, + onLongPressUp: () { + log.add('long-press-up'); + }, + onTapDown: (TapDownDetails details) { + log.add('tap-down'); + }, + onTapUp: (TapUpDetails details) { + log.add('tap-up'); + }, + onTapCancel: () { + log.add('tap-cancel'); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(InkWell), pointer: 1); + + expect(log, isEmpty); + + await tester.pump(const Duration(seconds: 1)); + + expect(log, equals(<String>['tap-down', 'tap-up', 'tap'])); + log.clear(); + + await tester.tap(find.byType(InkWell), pointer: 2); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byType(InkWell), pointer: 3); + + expect(log, equals(<String>['double-tap'])); + log.clear(); + + await tester.longPress(find.byType(InkWell), pointer: 4); + + expect(log, equals(<String>['tap-down', 'tap-cancel', 'long-press', 'long-press-up'])); + + log.clear(); + TestGesture gesture = await tester.startGesture(tester.getRect(find.byType(InkWell)).center); + await tester.pump(const Duration(milliseconds: 100)); + expect(log, equals(<String>['tap-down'])); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + expect(log, equals(<String>['tap-down', 'tap-up', 'tap'])); + + log.clear(); + gesture = await tester.startGesture(tester.getRect(find.byType(InkWell)).center); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveBy(const Offset(0.0, 200.0)); + await gesture.cancel(); + expect(log, equals(<String>['tap-down', 'tap-cancel'])); + }); + + testWidgets('InkWell only onTapDown enables gestures', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/96030 + var downTapped = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onTapDown: (TapDownDetails details) { + downTapped = true; + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(InkWell)); + expect(downTapped, true); + }); + + testWidgets('InkWell invokes activation actions when expected', (WidgetTester tester) async { + final log = <String>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Shortcuts( + shortcuts: const <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.enter): ButtonActivateIntent(), + }, + child: Material( + child: Center( + child: InkWell( + autofocus: true, + onTap: () { + log.add('tap'); + }, + ), + ), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(log, equals(<String>['tap'])); + log.clear(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(log, equals(<String>['tap'])); + }); + + testWidgets('InkWell onLongPressUp callback is triggered', (WidgetTester tester) async { + var wasCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: InkWell( + onLongPress: () {}, + onLongPressUp: () { + wasCalled = true; + }, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(InkWell))); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(wasCalled, isTrue); + }); + + testWidgets('long-press and tap on disabled should not throw', (WidgetTester tester) async { + await tester.pumpWidget( + const Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center(child: InkWell()), + ), + ), + ); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('ink well changes color on hover', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + hoverColor: const Color(0xff00ff00), + splashColor: const Color(0xffff0000), + focusColor: const Color(0xff0000ff), + highlightColor: const Color(0xf00fffff), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(SizedBox))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + color: const Color(0xff00ff00), + ), + ); + }); + + testWidgets('ink well changes color on hover with overlayColor', (WidgetTester tester) async { + // Same test as 'ink well changes color on hover' except that the + // hover color is specified with the overlayColor parameter. + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.focused)) { + return const Color(0xff0000ff); + } + if (states.contains(WidgetState.pressed)) { + return const Color(0xf00fffff); + } + return const Color(0xffbadbad); // Shouldn't happen. + }), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(SizedBox))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + color: const Color(0xff00ff00), + ), + ); + }); + + testWidgets('ink response changes color on focus', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + focusNode: focusNode, + hoverColor: const Color(0xff00ff00), + splashColor: const Color(0xffff0000), + focusColor: const Color(0xff0000ff), + highlightColor: const Color(0xf00fffff), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRect, 0)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + color: const Color(0xff0000ff), + ), + ); + focusNode.dispose(); + }); + + testWidgets('ink response changes color on focus with overlayColor', (WidgetTester tester) async { + // Same test as 'ink well changes color on focus' except that the + // hover color is specified with the overlayColor parameter. + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + focusNode: focusNode, + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.focused)) { + return const Color(0xff0000ff); + } + if (states.contains(WidgetState.pressed)) { + return const Color(0xf00fffff); + } + return const Color(0xffbadbad); // Shouldn't happen. + }), + highlightColor: const Color(0xf00fffff), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRect, 0)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + color: const Color(0xff0000ff), + ), + ); + focusNode.dispose(); + }); + + testWidgets('ink well changes color on pressed with overlayColor', (WidgetTester tester) async { + const pressedColor = Color(0xffdd00ff); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Container( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 100, + child: InkWell( + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + return const Color(0xffbadbad); // Shouldn't happen. + }), + onTap: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + ); + final RenderObject inkFeatures = getInkFeatures(tester); + expect( + inkFeatures, + paints..rect(rect: const Rect.fromLTRB(0, 0, 100, 100), color: pressedColor.withAlpha(0)), + ); + await tester.pumpAndSettle(); // Let the press highlight animation finish. + expect( + inkFeatures, + paints..rect(rect: const Rect.fromLTRB(0, 0, 100, 100), color: pressedColor), + ); + await gesture.up(); + }); + + group('Ink well overlayColor resolution respects WidgetState.selected', () { + const selectedHoveredColor = Color(0xff00ff00); + const selectedFocusedColor = Color(0xff0000ff); + const selectedPressedColor = Color(0xff00ffff); + const inkRect = Rect.fromLTRB(0, 0, 100, 100); + + Widget boilerplate({FocusNode? focusNode}) { + final statesController = WidgetStatesController(<WidgetState>{WidgetState.selected}); + addTearDown(statesController.dispose); + + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 100, + child: InkWell( + splashFactory: NoSplash.splashFactory, + focusNode: focusNode, + statesController: statesController, + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + if (states.contains(WidgetState.pressed)) { + return selectedPressedColor; + } + if (states.contains(WidgetState.hovered)) { + return selectedHoveredColor; + } + if (states.contains(WidgetState.focused)) { + return selectedFocusedColor; + } + return const Color(0xffbadbad); // Shouldn't happen. + } else { + return Colors.black; + } + }), + onTap: () {}, + ), + ), + ), + ), + ); + } + + testWidgets('when focused', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget(boilerplate(focusNode: focusNode)); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRect, 0)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(inkFeatures, paints..rect(rect: inkRect, color: selectedFocusedColor)); + }); + + testWidgets('when hovered', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(SizedBox))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paints..rect(rect: inkRect, color: selectedHoveredColor)); + }); + + testWidgets('when pressed', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + ); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paints..rect(rect: inkRect, color: selectedPressedColor.withAlpha(0))); + await tester.pumpAndSettle(); // Let the press highlight animation finish. + expect(inkFeatures, paints..rect(rect: inkRect, color: selectedPressedColor)); + await gesture.up(); + }); + }); + + testWidgets('ink response splashColor matches splashColor parameter', ( + WidgetTester tester, + ) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + const splashColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Focus( + focusNode: focusNode, + child: SizedBox.square( + dimension: 100, + child: InkWell( + hoverColor: const Color(0xff00ff00), + splashColor: splashColor, + focusColor: const Color(0xff0000ff), + highlightColor: const Color(0xf00fffff), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + ); + await tester.pump(const Duration(milliseconds: 200)); // unconfirmed splash is well underway + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paints..circle(x: 50, y: 50, color: splashColor)); + await gesture.up(); + focusNode.dispose(); + }); + + testWidgets('ink response splashColor matches resolved overlayColor for WidgetState.pressed', ( + WidgetTester tester, + ) async { + // Same test as 'ink response splashColor matches splashColor + // parameter' except that the splash color is specified with the + // overlayColor parameter. + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + const splashColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Focus( + focusNode: focusNode, + child: SizedBox.square( + dimension: 100, + child: InkWell( + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.focused)) { + return const Color(0xff0000ff); + } + if (states.contains(WidgetState.pressed)) { + return splashColor; + } + return const Color(0xffbadbad); // Shouldn't happen. + }), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + ); + await tester.pump(const Duration(milliseconds: 200)); // unconfirmed splash is well underway + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paints..circle(x: 50, y: 50, color: splashColor)); + await gesture.up(); + focusNode.dispose(); + }); + + testWidgets('ink response uses radius for focus highlight', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkResponse( + focusNode: focusNode, + radius: 20, + focusColor: const Color(0xff0000ff), + onTap: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 0)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..circle(radius: 20, color: const Color(0xff0000ff))); + focusNode.dispose(); + }); + + testWidgets('InkWell uses borderRadius for focus highlight', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + focusNode: focusNode, + borderRadius: const BorderRadius.all(Radius.circular(10)), + focusColor: const Color(0xff0000ff), + onTap: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + + expect( + inkFeatures, + paints..rrect( + rrect: RRect.fromLTRBR(350.0, 250.0, 450.0, 350.0, const Radius.circular(10)), + color: const Color(0xff0000ff), + ), + ); + focusNode.dispose(); + }); + + testWidgets('InkWell uses borderRadius for hover highlight', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: MouseRegion( + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(10)), + hoverColor: const Color(0xff00ff00), + onTap: () {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + // Hover the ink well. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getRect(find.byType(InkWell)).center); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + + expect( + inkFeatures, + paints..rrect( + rrect: RRect.fromLTRBR(350.0, 250.0, 450.0, 350.0, const Radius.circular(10)), + color: const Color(0xff00ff00), + ), + ); + }); + + testWidgets('InkWell customBorder clips for focus highlight', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 100, + child: MouseRegion( + child: InkWell( + focusNode: focusNode, + borderRadius: const BorderRadius.all(Radius.circular(10)), + customBorder: const CircleBorder(), + hoverColor: const Color(0xff00ff00), + onTap: () {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 0)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 1)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + + // Create a rounded rectangle path with a radius that makes it similar to the custom border circle. + const expectedClipRect = Rect.fromLTRB(0, 0, 100, 100); + final expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(50.0))); + // The ink well custom border path should match the rounded rectangle path. + expect( + inkFeatures, + paints..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ), + ); + focusNode.dispose(); + }); + + testWidgets('InkWell customBorder clips for hover highlight', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 100, + child: MouseRegion( + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(10)), + customBorder: const CircleBorder(), + hoverColor: const Color(0xff00ff00), + onTap: () {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 0)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + // Hover the ink well. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getRect(find.byType(InkWell)).center); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 1)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + + // Create a rounded rectangle path with a radius that makes it similar to the custom border circle. + const expectedClipRect = Rect.fromLTRB(0, 0, 100, 100); + final expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(50.0))); + // The ink well custom border path should match the rounded rectangle path. + expect( + inkFeatures, + paints..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ), + ); + }); + + testWidgets('InkResponse radius can be updated', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + Widget boilerplate(double radius) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkResponse( + focusNode: focusNode, + radius: radius, + focusColor: const Color(0xff0000ff), + onTap: () {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(boilerplate(10)); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 1)); + expect(inkFeatures, paints..circle(radius: 10, color: const Color(0xff0000ff))); + + await tester.pumpWidget(boilerplate(20)); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 1)); + expect(inkFeatures, paints..circle(radius: 20, color: const Color(0xff0000ff))); + focusNode.dispose(); + }); + + testWidgets('InkResponse highlightShape can be updated', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + Widget boilerplate(BoxShape shape) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkResponse( + focusNode: focusNode, + highlightShape: shape, + borderRadius: const BorderRadius.all(Radius.circular(10)), + focusColor: const Color(0xff0000ff), + onTap: () {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(boilerplate(BoxShape.circle)); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 0)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 1)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + await tester.pumpWidget(boilerplate(BoxShape.rectangle)); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 0)); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + focusNode.dispose(); + }); + + testWidgets('InkWell borderRadius can be updated', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + Widget boilerplate(BorderRadius borderRadius) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + focusNode: focusNode, + borderRadius: borderRadius, + focusColor: const Color(0xff0000ff), + onTap: () {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(boilerplate(const BorderRadius.all(Radius.circular(10)))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + expect( + inkFeatures, + paints..rrect( + rrect: RRect.fromLTRBR(350.0, 250.0, 450.0, 350.0, const Radius.circular(10)), + color: const Color(0xff0000ff), + ), + ); + + await tester.pumpWidget(boilerplate(const BorderRadius.all(Radius.circular(30)))); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawRRect, 1)); + expect( + inkFeatures, + paints..rrect( + rrect: RRect.fromLTRBR(350.0, 250.0, 450.0, 350.0, const Radius.circular(30)), + color: const Color(0xff0000ff), + ), + ); + focusNode.dispose(); + }); + + testWidgets('InkWell customBorder can be updated', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + Widget boilerplate(BorderRadius borderRadius) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 100, + child: MouseRegion( + child: InkWell( + focusNode: focusNode, + customBorder: RoundedRectangleBorder(borderRadius: borderRadius), + hoverColor: const Color(0xff00ff00), + onTap: () {}, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(boilerplate(const BorderRadius.all(Radius.circular(20)))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 1)); + + const expectedClipRect = Rect.fromLTRB(0, 0, 100, 100); + var expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(20))); + expect( + inkFeatures, + paints..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ), + ); + + await tester.pumpWidget(boilerplate(const BorderRadius.all(Radius.circular(40)))); + await tester.pumpAndSettle(); + expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(40))); + expect( + inkFeatures, + paints..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ), + ); + focusNode.dispose(); + }); + + testWidgets('InkWell splash customBorder can be updated', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/121626. + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + Widget boilerplate(BorderRadius borderRadius) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox.square( + dimension: 100, + child: MouseRegion( + child: InkWell( + focusNode: focusNode, + customBorder: RoundedRectangleBorder(borderRadius: borderRadius), + onTap: () {}, + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(boilerplate(const BorderRadius.all(Radius.circular(20)))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 0)); + + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + ); + await tester.pump(const Duration(milliseconds: 200)); // Unconfirmed splash is well underway. + expect(inkFeatures, paintsExactlyCountTimes(#clipPath, 2)); // Splash and highlight. + + const expectedClipRect = Rect.fromLTRB(0, 0, 100, 100); + var expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(20))); + + // Check that the splash and the highlight are correctly clipped. + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ) + ..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ), + ); + + await tester.pumpWidget(boilerplate(const BorderRadius.all(Radius.circular(40)))); + await tester.pumpAndSettle(); + expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(40))); + + // Check that the splash and the highlight are correctly clipped. + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ) + ..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(20.0), + sampleSize: 100, + ), + ), + ); + + await gesture.up(); + focusNode.dispose(); + }); + + testWidgets("ink response doesn't change color on focus when on touch device", ( + WidgetTester tester, + ) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + focusNode: focusNode, + hoverColor: const Color(0xff00ff00), + splashColor: const Color(0xffff0000), + focusColor: const Color(0xff0000ff), + highlightColor: const Color(0xf00fffff), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRect, 0)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(inkFeatures, paintsExactlyCountTimes(#drawRect, 0)); + focusNode.dispose(); + }); + + testWidgets('InkWell.mouseCursor changes cursor on hover', (WidgetTester tester) async { + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(1, 1)); + + // Test argument works + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: InkWell(mouseCursor: SystemMouseCursors.cell, onTap: () {}), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + + // Test default of InkWell() + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: InkWell(onTap: () {}), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test disabled + await tester.pumpWidget( + const Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion(cursor: SystemMouseCursors.forbidden, child: InkWell()), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test default of InkResponse() + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: InkResponse(onTap: () {}), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test disabled + await tester.pumpWidget( + const Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion(cursor: SystemMouseCursors.forbidden, child: InkResponse()), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('InkResponse containing selectable text changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/104595. + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + child: Material( + child: InkResponse(onTap: () {}, child: const Text('button')), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Text))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('enabled (default)', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: InkWell(onTap: () {}, onLongPress: () {}, onLongPressUp: () {}), + ), + ), + ), + ); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 1); + }); + + testWidgets('disabled', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: InkWell( + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + enableFeedback: false, + ), + ), + ), + ), + ); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + }); + + testWidgets('splashing survives scrolling when keep-alive is enabled', ( + WidgetTester tester, + ) async { + Future<void> runTest(bool keepAlive) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: CompositedTransformFollower( + // forces a layer, which makes the paints easier to separate out + link: LayerLink(), + child: ListView( + addAutomaticKeepAlives: keepAlive, + dragStartBehavior: DragStartBehavior.down, + children: <Widget>[ + SizedBox( + height: 500.0, + child: InkWell(onTap: () {}, child: const Placeholder()), + ), + const SizedBox(height: 500.0), + const SizedBox(height: 500.0), + ], + ), + ), + ), + ), + ); + expect( + tester.renderObject<RenderProxyBox>(find.byType(PhysicalModel)).child, + isNot(paints..circle()), + ); + await tester.tap(find.byType(InkWell)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect( + tester.renderObject<RenderProxyBox>(find.byType(PhysicalModel)).child, + paints..circle(), + ); + await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); + await tester.pump(const Duration(milliseconds: 10)); + await tester.drag(find.byType(ListView), const Offset(0.0, 1000.0)); + await tester.pump(const Duration(milliseconds: 10)); + expect( + tester.renderObject<RenderProxyBox>(find.byType(PhysicalModel)).child, + keepAlive ? (paints..circle()) : isNot(paints..circle()), + ); + } + + await runTest(true); + await runTest(false); + }); + + testWidgets('excludeFromSemantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: InkWell(onTap: () {}, child: const Text('Button')), + ), + ), + ); + expect( + semantics, + includesNodeWith( + label: 'Button', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: InkWell(onTap: () {}, excludeFromSemantics: true, child: const Text('Button')), + ), + ), + ); + expect( + semantics, + isNot( + includesNodeWith( + label: 'Button', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ), + ); + + semantics.dispose(); + }); + + testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: InkWell( + autofocus: true, + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + focusNode: focusNode, + child: Container(key: childKey), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: InkWell( + focusNode: focusNode, + child: Container(key: childKey), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + focusNode.dispose(); + }); + + testWidgets('ink response accepts focus when disabled in directional navigation mode', ( + WidgetTester tester, + ) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + Material( + child: MediaQuery( + data: const MediaQueryData(navigationMode: NavigationMode.directional), + child: Directionality( + textDirection: TextDirection.ltr, + child: InkWell( + autofocus: true, + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + focusNode: focusNode, + child: Container(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + await tester.pumpWidget( + Material( + child: MediaQuery( + data: const MediaQueryData(navigationMode: NavigationMode.directional), + child: Directionality( + textDirection: TextDirection.ltr, + child: InkWell( + focusNode: focusNode, + child: Container(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + focusNode.dispose(); + }); + + testWidgets("ink response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'Ink Focus'); + final GlobalKey childKey = GlobalKey(); + var hovering = false; + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100, + child: InkWell( + autofocus: true, + onTap: () {}, + 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( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100, + child: InkWell( + focusNode: focusNode, + onHover: (bool value) { + hovering = value; + }, + child: SizedBox(key: childKey), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + focusNode.dispose(); + }); + + testWidgets('When ink wells are nested, only the inner one is triggered by tap splash', ( + WidgetTester tester, + ) async { + final GlobalKey middleKey = GlobalKey(); + final GlobalKey innerKey = GlobalKey(); + Widget paddedInkWell({Key? key, Widget? child}) { + return InkWell( + key: key, + onTap: () {}, + child: Padding(padding: const EdgeInsets.all(50), child: child), + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: paddedInkWell( + child: paddedInkWell( + key: middleKey, + child: paddedInkWell(key: innerKey, child: const SizedBox(width: 50, height: 50)), + ), + ), + ), + ), + ), + ), + ); + final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey))); + + // Press + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byKey(innerKey)), + pointer: 1, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Up + await gesture.up(); + await tester.pumpAndSettle(); + expect(material, paintsNothing); + + // Press again + await gesture.down(tester.getCenter(find.byKey(innerKey))); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Cancel + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(material, paintsNothing); + + // Press again + await gesture.down(tester.getCenter(find.byKey(innerKey))); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Use a second pointer to press + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byKey(innerKey)), + pointer: 2, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + await gesture2.up(); + }); + + testWidgets('Reparenting parent should allow both inkwells to show splash afterwards', ( + WidgetTester tester, + ) async { + final GlobalKey middleKey = GlobalKey(); + final GlobalKey innerKey = GlobalKey(); + Widget paddedInkWell({Key? key, Widget? child}) { + return InkWell( + key: key, + onTap: () {}, + child: Padding(padding: const EdgeInsets.all(50), child: child), + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200, + height: 100, + child: Row( + children: <Widget>[ + paddedInkWell( + key: middleKey, + child: paddedInkWell(key: innerKey), + ), + const SizedBox(), + ], + ), + ), + ), + ), + ), + ), + ); + final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey))); + + // Press + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byKey(innerKey)), + pointer: 1, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Reparent parent + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200, + height: 100, + child: Row( + children: <Widget>[ + paddedInkWell(key: innerKey), + paddedInkWell(key: middleKey), + ], + ), + ), + ), + ), + ), + ), + ); + + // Up + await gesture1.up(); + await tester.pumpAndSettle(); + expect(material, paintsNothing); + + // Press the previous parent + await gesture1.down(tester.getCenter(find.byKey(middleKey))); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Use a second pointer to press the previous child + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byKey(innerKey)), + pointer: 2, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 2)); + + // Finish gesture to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Parent inkwell does not block child inkwells from splashes', ( + WidgetTester tester, + ) async { + final GlobalKey middleKey = GlobalKey(); + final GlobalKey innerKey = GlobalKey(); + Widget paddedInkWell({Key? key, Widget? child}) { + return InkWell( + key: key, + onTap: () {}, + child: Padding(padding: const EdgeInsets.all(50), child: child), + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: paddedInkWell( + child: paddedInkWell( + key: middleKey, + child: paddedInkWell(key: innerKey, child: const SizedBox(width: 50, height: 50)), + ), + ), + ), + ), + ), + ), + ); + final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey))); + + // Press middle + final TestGesture gesture1 = await tester.startGesture( + tester.getTopLeft(find.byKey(middleKey)) + const Offset(1, 1), + pointer: 1, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Press inner + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byKey(innerKey)), + pointer: 2, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 2)); + + // Finish gesture to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Parent inkwell can count the number of pressed children to prevent splash', ( + WidgetTester tester, + ) async { + final GlobalKey parentKey = GlobalKey(); + final GlobalKey leftKey = GlobalKey(); + final GlobalKey rightKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + key: parentKey, + onTap: () {}, + child: Center( + child: SizedBox( + width: 100, + height: 50, + child: Row( + children: <Widget>[ + SizedBox.square( + dimension: 50, + child: InkWell(key: leftKey, onTap: () {}), + ), + SizedBox.square( + dimension: 50, + child: InkWell(key: rightKey, onTap: () {}), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + final MaterialInkController material = Material.of(tester.element(find.byKey(leftKey))); + + final Offset parentPosition = tester.getTopLeft(find.byKey(parentKey)) + const Offset(1, 1); + + // Press left child + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byKey(leftKey)), + pointer: 1, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Press right child + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byKey(rightKey)), + pointer: 2, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 2)); + + // Press parent + final TestGesture gesture3 = await tester.startGesture(parentPosition, pointer: 3); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 2)); + await gesture3.up(); + + // Release left child + await gesture1.up(); + await tester.pumpAndSettle(); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Press parent + await gesture3.down(parentPosition); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + await gesture3.up(); + + // Release right child + await gesture2.up(); + await tester.pumpAndSettle(); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + + // Press parent + await gesture3.down(parentPosition); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + await gesture3.up(); + }); + + testWidgets( + 'When ink wells are reparented, the old parent can display splash while the new parent can not', + (WidgetTester tester) async { + final GlobalKey innerKey = GlobalKey(); + final GlobalKey leftKey = GlobalKey(); + final GlobalKey rightKey = GlobalKey(); + + Widget doubleInkWellRow({ + required double leftWidth, + required double rightWidth, + Widget? leftChild, + Widget? rightChild, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: leftWidth + rightWidth, + height: 100, + child: Row( + children: <Widget>[ + SizedBox( + width: leftWidth, + height: 100, + child: InkWell( + key: leftKey, + onTap: () {}, + child: Center( + child: SizedBox(width: leftWidth, height: 50, child: leftChild), + ), + ), + ), + SizedBox( + width: rightWidth, + height: 100, + child: InkWell( + key: rightKey, + onTap: () {}, + child: Center( + child: SizedBox(width: leftWidth, height: 50, child: rightChild), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + doubleInkWellRow( + leftWidth: 110, + rightWidth: 90, + leftChild: InkWell(key: innerKey, onTap: () {}), + ), + ); + final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey))); + + // Press inner + final TestGesture gesture = await tester.startGesture(const Offset(100, 50), pointer: 1); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Switch side + await tester.pumpWidget( + doubleInkWellRow( + leftWidth: 90, + rightWidth: 110, + rightChild: InkWell(key: innerKey, onTap: () {}), + ), + ); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + + // A second pointer presses inner + final TestGesture gesture2 = await tester.startGesture(const Offset(100, 50), pointer: 2); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + await gesture.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + + // Press inner + await gesture.down(const Offset(100, 50)); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Press left + await gesture2.down(const Offset(50, 50)); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 2)); + + await gesture.up(); + await gesture2.up(); + }, + ); + + testWidgets( + "Ink wells's splash starts before tap is confirmed and disappear after tap is canceled", + (WidgetTester tester) async { + final GlobalKey innerKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: GestureDetector( + onHorizontalDragStart: (_) {}, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + onTap: () {}, + child: Center( + child: SizedBox.square( + dimension: 50, + child: InkWell(key: innerKey, onTap: () {}), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey))); + + // Press + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byKey(innerKey)), + pointer: 1, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + + // Scroll upward + await gesture.moveBy(const Offset(0, -100)); + await tester.pumpAndSettle(); + expect(material, paintsNothing); + + // Up + await gesture.up(); + await tester.pumpAndSettle(); + expect(material, paintsNothing); + + // Press again + await gesture.down(tester.getCenter(find.byKey(innerKey))); + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + }, + ); + + testWidgets('disabled and hovered inkwell responds to mouse-exit', (WidgetTester tester) async { + var onHoverCount = 0; + late bool hover; + + Widget buildFrame({required bool enabled}) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + onTap: enabled ? () {} : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + ), + ), + ), + ), + ); + } + + 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(InkWell))); + 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 InkWell 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(InkWell))); + await tester.pumpAndSettle(); + // We no longer see hover events because the InkWell 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 InkWell 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(InkWell)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the InkWell doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('hovered ink well draws a transparent highlight when disabled', ( + WidgetTester tester, + ) async { + Widget buildFrame({required bool enabled}) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + onTap: enabled ? () {} : null, + onHover: (bool value) {}, + hoverColor: const Color(0xff00ff00), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + + // Hover the enabled InkWell. + await gesture.moveTo(tester.getCenter(find.byType(InkWell))); + await tester.pumpAndSettle(); + expect( + find.byType(Material), + paints..rect( + color: const Color(0xff00ff00), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ), + ); + + // Disable the hovered InkWell. + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + expect( + find.byType(Material), + paints..rect( + color: const Color(0x0000ff00), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ), + ); + }); + + testWidgets('Changing InkWell.enabled should not trigger TextButton setState()', ( + WidgetTester tester, + ) async { + Widget buildFrame({required bool enabled}) { + return Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: TextButton(onPressed: enabled ? () {} : null, child: const Text('button')), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: false)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(TextButton))); + await tester.pumpAndSettle(); + + // Rebuilding the button with enabled:true causes InkWell.didUpdateWidget() + // to be called per the change in its enabled flag. If onHover() was called, + // this test would crash. + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + + // Rebuild again, with enabled:false + await gesture.moveBy(const Offset(1, 1)); + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'InkWell does not attach semantics handler for onTap if it was not provided an onTap handler', + (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell(onLongPress: () {}, onLongPressUp: () {}, child: const Text('Foo')), + ), + ), + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('Foo')), + matchesSemantics( + label: 'Foo', + hasLongPressAction: true, + isFocusable: true, + hasFocusAction: true, + textDirection: TextDirection.ltr, + ), + ); + + // Add tap handler and confirm addition to semantic actions. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onLongPress: () {}, + onLongPressUp: () {}, + onTap: () {}, + child: const Text('Foo'), + ), + ), + ), + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('Foo')), + matchesSemantics( + label: 'Foo', + hasTapAction: true, + hasFocusAction: true, + hasLongPressAction: true, + isFocusable: true, + textDirection: TextDirection.ltr, + ), + ); + }, + ); + + testWidgets('InkWell highlight should not survive after [onTapDown, onDoubleTap] sequence', ( + WidgetTester tester, + ) async { + final log = <String>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onTap: () { + log.add('tap'); + }, + onDoubleTap: () { + log.add('double-tap'); + }, + onTapDown: (TapDownDetails details) { + log.add('tap-down'); + }, + onTapCancel: () { + log.add('tap-cancel'); + }, + ), + ), + ), + ), + ); + + final Offset taplocation = tester.getRect(find.byType(InkWell)).center; + + final TestGesture gesture = await tester.startGesture(taplocation); + await tester.pump(const Duration(milliseconds: 100)); + expect(log, equals(<String>['tap-down'])); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byType(InkWell)); + await tester.pump(const Duration(milliseconds: 100)); + expect(log, equals(<String>['tap-down', 'double-tap'])); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawRect, 0)); + }); + + testWidgets( + 'InkWell splash should not survive after [onTapDown, onTapDown, onTapCancel, onDoubleTap] sequence', + (WidgetTester tester) async { + final log = <String>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onTap: () { + log.add('tap'); + }, + onDoubleTap: () { + log.add('double-tap'); + }, + onTapDown: (TapDownDetails details) { + log.add('tap-down'); + }, + onTapCancel: () { + log.add('tap-cancel'); + }, + ), + ), + ), + ), + ); + + final Offset tapLocation = tester.getRect(find.byType(InkWell)).center; + + final TestGesture gesture1 = await tester.startGesture(tapLocation); + await tester.pump(const Duration(milliseconds: 100)); + expect(log, equals(<String>['tap-down'])); + await gesture1.up(); + await tester.pump(const Duration(milliseconds: 100)); + + final TestGesture gesture2 = await tester.startGesture(tapLocation); + await tester.pump(const Duration(milliseconds: 100)); + expect(log, equals(<String>['tap-down', 'tap-down'])); + await gesture2.up(); + await tester.pump(const Duration(milliseconds: 100)); + expect(log, equals(<String>['tap-down', 'tap-down', 'tap-cancel', 'double-tap'])); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 0)); + }, + ); + + testWidgets('InkWell disposes statesController', (WidgetTester tester) async { + var tapCount = 0; + Widget buildFrame(MaterialStatesController? statesController) { + return MaterialApp( + home: Scaffold( + body: Center( + child: InkWell( + statesController: statesController, + onTap: () { + tapCount += 1; + }, + child: const Text('inkwell'), + ), + ), + ), + ); + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + var pressedCount = 0; + controller.addListener(() { + if (controller.value.contains(WidgetState.pressed)) { + pressedCount += 1; + } + }); + + await tester.pumpWidget(buildFrame(controller)); + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + expect(tapCount, 1); + expect(pressedCount, 1); + + await tester.pumpWidget(buildFrame(null)); + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + expect(tapCount, 2); + expect(pressedCount, 1); + + await tester.pumpWidget(buildFrame(controller)); + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + expect(tapCount, 3); + expect(pressedCount, 2); + }); + + testWidgets('ink well overlayColor opacity fades from 0xff when hover ends', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/110266 + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100, + child: InkWell( + overlayColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + return null; + }), + onTap: () {}, + onLongPress: () {}, + onLongPressUp: () {}, + onHover: (bool hover) {}, + ), + ), + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(SizedBox))); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(10, 10)); // fade out the overlay + await tester.pump(); // trigger the fade out animation + final RenderObject inkFeatures = getInkFeatures(tester); + // Fadeout begins with the MaterialStates.hovered overlay color + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + color: const Color(0xff00ff00), + ), + ); + // 50ms fadeout is 50% complete, overlay color alpha goes from 0xff to 0x80 + await tester.pump(const Duration(milliseconds: 25)); + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + color: const Color(0x8000ff00), + ), + ); + }); + + testWidgets('InkWell secondary tap test', (WidgetTester tester) async { + final log = <String>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onSecondaryTap: () { + log.add('secondary-tap'); + }, + onSecondaryTapDown: (TapDownDetails details) { + log.add('secondary-tap-down'); + }, + onSecondaryTapUp: (TapUpDetails details) { + log.add('secondary-tap-up'); + }, + onSecondaryTapCancel: () { + log.add('secondary-tap-cancel'); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(InkWell), pointer: 1, buttons: kSecondaryButton); + + expect(log, equals(<String>['secondary-tap-down', 'secondary-tap-up', 'secondary-tap'])); + log.clear(); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(InkWell)), + pointer: 2, + buttons: kSecondaryButton, + ); + await gesture.moveTo(const Offset(100, 100)); + await gesture.up(); + + expect(log, equals(<String>['secondary-tap-down', 'secondary-tap-cancel'])); + }); + + // Regression test for https://github.com/flutter/flutter/issues/124328. + testWidgets( + 'InkWell secondary tap should not draw a splash when no secondary callbacks are defined', + (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center(child: InkWell(onTap: () {})), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + buttons: kSecondaryButton, + ); + await tester.pump(const Duration(milliseconds: 200)); + + // No splash should be painted. + final RenderObject inkFeatures = getInkFeatures(tester); + expect(inkFeatures, paintsExactlyCountTimes(#drawCircle, 0)); + + await gesture.up(); + }, + ); + + testWidgets('try out hoverDuration property', (WidgetTester tester) async { + final log = <String>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + hoverDuration: const Duration(milliseconds: 1000), + onTap: () { + log.add('tap'); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + + expect(log, equals(<String>['tap'])); + log.clear(); + }); + + testWidgets('InkWell activation action does not end immediately', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/132377. + final controller = MaterialStatesController(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Shortcuts( + shortcuts: const <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.enter): ButtonActivateIntent(), + }, + child: Material( + child: Center( + child: InkWell(autofocus: true, onTap: () {}, statesController: controller), + ), + ), + ), + ), + ); + + // Invoke the InkWell activation action. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + // The InkWell is in pressed state. + await tester.pump(const Duration(milliseconds: 99)); + expect(controller.value.contains(WidgetState.pressed), isTrue); + + await tester.pumpAndSettle(); + expect(controller.value.contains(WidgetState.pressed), isFalse); + + controller.dispose(); + }); + + testWidgets('InkResponse does not crash in zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox.shrink(child: InkResponse())), + ), + ), + ), + ); + expect(tester.getSize(find.byType(InkResponse)), Size.zero); + }); + + testWidgets('InkWell does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center(child: SizedBox.shrink(child: InkWell())), + ), + ), + ); + expect(tester.getSize(find.byType(InkWell)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/input_chip_test.dart b/packages/material_ui/test/material/input_chip_test.dart new file mode 100644 index 000000000000..6e5eaca5d59d --- /dev/null +++ b/packages/material_ui/test/material/input_chip_test.dart @@ -0,0 +1,741 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Adds the basic requirements for a Chip. +Widget wrapForChip({ + required Widget child, + TextDirection textDirection = TextDirection.ltr, + double textScaleFactor = 1.0, + ThemeData? theme, +}) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: textDirection, + child: MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Material(child: child), + ), + ), + ); +} + +Widget selectedInputChip({Color? checkmarkColor, bool enabled = false}) { + return InputChip( + label: const Text('InputChip'), + selected: true, + isEnabled: enabled, + // When [enabled] is true we also need to provide one of the chip + // callbacks, otherwise the chip would have a 'disabled' + // [WidgetState], which is not the intention. + onSelected: enabled ? (_) {} : null, + showCheckmark: true, + checkmarkColor: checkmarkColor, + ); +} + +Future<void> pumpCheckmarkChip( + WidgetTester tester, { + required Widget chip, + Color? themeColor, + ThemeData? theme, +}) async { + await tester.pumpWidget( + wrapForChip( + theme: theme, + child: Builder( + builder: (BuildContext context) { + final ChipThemeData chipTheme = ChipTheme.of(context); + return ChipTheme( + data: themeColor == null ? chipTheme : chipTheme.copyWith(checkmarkColor: themeColor), + child: chip, + ); + }, + ), + ), + ); +} + +void expectCheckmarkColor(Finder finder, Color color) { + expect( + finder, + paints + // Physical model layer path + ..path() + // The first layer that is painted is the selection overlay. We do not care + // how it is painted but it has to be added it to this pattern so that the + // check mark can be checked next. + ..rrect() + // The second layer that is painted is the check mark. + ..path(color: color), + ); +} + +RenderBox getMaterialBox(WidgetTester tester) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byType(InputChip), matching: find.byType(CustomPaint)), + ); +} + +Material getMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(InputChip), matching: find.byType(Material)), + ); +} + +IconThemeData getIconData(WidgetTester tester) { + final IconTheme iconTheme = tester.firstWidget( + find.descendant(of: find.byType(RawChip), matching: find.byType(IconTheme)), + ); + return iconTheme.data; +} + +void checkChipMaterialClipBehavior(WidgetTester tester, Clip clipBehavior) { + final Iterable<Material> materials = tester.widgetList<Material>(find.byType(Material)); + // There should be two Material widgets, first Material is from the "_wrapForChip" and + // last Material is from the "RawChip". + expect(materials.length, 2); + // The last Material from `RawChip` should have the clip behavior. + expect(materials.last.clipBehavior, clipBehavior); +} + +// Finds any container of a tooltip. +Finder findTooltipContainer(String tooltipText) { + return find.ancestor(of: find.text(tooltipText), matching: find.byType(Container)); +} + +void main() { + testWidgets('InputChip.color resolves material states', (WidgetTester tester) async { + const disabledSelectedColor = Color(0xffffff00); + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: InputChip( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + color: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) { + return disabledSelectedColor; + } + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return backgroundColor; + }), + label: const Text('InputChip'), + ), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled chip should have the provided backgroundColor. + expect(getMaterialBox(tester), paints..rrect(color: backgroundColor)); + + // Test disabled chip. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled chip should have the provided disabledColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledColor)); + + // Test enabled & selected chip. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected chip should have the provided selectedColor. + expect(getMaterialBox(tester), paints..rrect(color: selectedColor)); + + // Test disabled & selected chip. + await tester.pumpWidget(buildApp(enabled: false, selected: true)); + await tester.pumpAndSettle(); + + // Disabled & selected chip should have the provided disabledSelectedColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledSelectedColor)); + }); + + testWidgets('InputChip uses provided state color properties', (WidgetTester tester) async { + const disabledColor = Color(0xff00ff00); + const backgroundColor = Color(0xff0000ff); + const selectedColor = Color(0xffff0000); + Widget buildApp({required bool enabled, required bool selected}) { + return wrapForChip( + child: InputChip( + onSelected: enabled ? (bool value) {} : null, + selected: selected, + disabledColor: disabledColor, + backgroundColor: backgroundColor, + selectedColor: selectedColor, + label: const Text('InputChip'), + ), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildApp(enabled: true, selected: false)); + + // Enabled chip should have the provided backgroundColor. + expect(getMaterialBox(tester), paints..rrect(color: backgroundColor)); + + // Test disabled chip. + await tester.pumpWidget(buildApp(enabled: false, selected: false)); + await tester.pumpAndSettle(); + + // Disabled chip should have the provided disabledColor. + expect(getMaterialBox(tester), paints..rrect(color: disabledColor)); + + // Test enabled & selected chip. + await tester.pumpWidget(buildApp(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + // Enabled & selected chip should have the provided selectedColor. + expect(getMaterialBox(tester), paints..rrect(color: selectedColor)); + }); + + testWidgets('InputChip can be tapped', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: InputChip(label: Text('input chip'))), + ), + ); + + await tester.tap(find.byType(InputChip)); + expect(tester.takeException(), null); + }); + + testWidgets('loses focus when disabled', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'InputChip'); + await tester.pumpWidget( + wrapForChip( + child: InputChip( + focusNode: focusNode, + autofocus: true, + shape: const RoundedRectangleBorder(), + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + onPressed: () {}, + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + wrapForChip( + child: InputChip( + focusNode: focusNode, + autofocus: true, + shape: const RoundedRectangleBorder(), + avatar: const CircleAvatar(child: Text('A')), + label: const Text('Chip A'), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isFalse); + + focusNode.dispose(); + }); + + testWidgets('cannot be traversed to when disabled', (WidgetTester tester) async { + final focusNode1 = FocusNode(debugLabel: 'InputChip 1'); + final focusNode2 = FocusNode(debugLabel: 'InputChip 2'); + await tester.pumpWidget( + wrapForChip( + child: FocusScope( + child: Column( + children: <Widget>[ + InputChip( + focusNode: focusNode1, + autofocus: true, + label: const Text('Chip A'), + onPressed: () {}, + ), + InputChip(focusNode: focusNode2, autofocus: true, label: const Text('Chip B')), + ], + ), + ), + ), + ); + await tester.pump(); + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + expect(focusNode1.nextFocus(), isFalse); + + await tester.pump(); + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + focusNode1.dispose(); + focusNode2.dispose(); + }); + + testWidgets( + 'Material2 - Input chip disabled check mark color is determined by platform brightness when light', + (WidgetTester tester) async { + await pumpCheckmarkChip( + tester, + chip: selectedInputChip(), + theme: ThemeData(useMaterial3: false), + ); + + expectCheckmarkColor(find.byType(InputChip), Colors.black.withAlpha(0xde)); + }, + ); + + testWidgets( + 'Material3 - Input chip disabled check mark color is determined by platform brightness when light', + (WidgetTester tester) async { + final theme = ThemeData(); + await pumpCheckmarkChip(tester, chip: selectedInputChip(), theme: theme); + + expectCheckmarkColor(find.byType(InputChip), theme.colorScheme.onSurface); + }, + ); + + testWidgets( + 'Material2 - Input chip disabled check mark color is determined by platform brightness when dark', + (WidgetTester tester) async { + await pumpCheckmarkChip( + tester, + chip: selectedInputChip(), + theme: ThemeData.dark(useMaterial3: false), + ); + + expectCheckmarkColor(find.byType(InputChip), Colors.white.withAlpha(0xde)); + }, + ); + + testWidgets( + 'Material3 - Input chip disabled check mark color is determined by platform brightness when dark', + (WidgetTester tester) async { + final theme = ThemeData.dark(); + await pumpCheckmarkChip(tester, chip: selectedInputChip(), theme: theme); + + expectCheckmarkColor(find.byType(InputChip), theme.colorScheme.onSurface); + }, + ); + + testWidgets('Input chip check mark color can be set by the chip theme', ( + WidgetTester tester, + ) async { + await pumpCheckmarkChip(tester, chip: selectedInputChip(), themeColor: const Color(0xff00ff00)); + + expectCheckmarkColor(find.byType(InputChip), const Color(0xff00ff00)); + }); + + testWidgets('Input chip check mark color can be set by the chip constructor', ( + WidgetTester tester, + ) async { + await pumpCheckmarkChip( + tester, + chip: selectedInputChip(checkmarkColor: const Color(0xff00ff00)), + ); + + expectCheckmarkColor(find.byType(InputChip), const Color(0xff00ff00)); + }); + + testWidgets( + 'Input chip check mark color is set by chip constructor even when a theme color is specified', + (WidgetTester tester) async { + await pumpCheckmarkChip( + tester, + chip: selectedInputChip(checkmarkColor: const Color(0xffff0000)), + themeColor: const Color(0xff00ff00), + ); + + expectCheckmarkColor(find.byType(InputChip), const Color(0xffff0000)); + }, + ); + + testWidgets('InputChip clipBehavior properly passes through to the Material', ( + WidgetTester tester, + ) async { + const label = Text('label'); + await tester.pumpWidget(wrapForChip(child: const InputChip(label: label))); + checkChipMaterialClipBehavior(tester, Clip.none); + + await tester.pumpWidget( + wrapForChip( + child: const InputChip(label: label, clipBehavior: Clip.antiAlias), + ), + ); + checkChipMaterialClipBehavior(tester, Clip.antiAlias); + }); + + testWidgets('Material3 - Input chip has correct selected color when enabled', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await pumpCheckmarkChip(tester, chip: selectedInputChip(enabled: true), theme: theme); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..rrect(color: theme.colorScheme.secondaryContainer)); + }); + + testWidgets('Material3 - Input chip has correct selected color when disabled', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await pumpCheckmarkChip(tester, chip: selectedInputChip(), theme: theme); + + final RenderBox materialBox = getMaterialBox(tester); + expect(materialBox, paints..path(color: theme.colorScheme.onSurface)); + }); + + testWidgets('InputChip uses provided iconTheme', (WidgetTester tester) async { + final theme = ThemeData(); + + Widget buildChip({IconThemeData? iconTheme}) { + return MaterialApp( + theme: theme, + home: Material( + child: InputChip( + iconTheme: iconTheme, + avatar: const Icon(Icons.add), + label: const Text('Test'), + ), + ), + ); + } + + // Test default icon theme. + await tester.pumpWidget(buildChip()); + + expect(getIconData(tester).color, theme.colorScheme.onSurfaceVariant); + + // Test provided icon theme. + await tester.pumpWidget(buildChip(iconTheme: const IconThemeData(color: Color(0xff00ff00)))); + + expect(getIconData(tester).color, const Color(0xff00ff00)); + }); + + testWidgets('Delete button is visible on disabled InputChip', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: InputChip(isEnabled: false, label: const Text('Label'), onDeleted: () {}), + ), + ); + + // Delete button should be visible. + await expectLater( + find.byType(RawChip), + matchesGoldenFile('input_chip.disabled.delete_button.png'), + ); + }); + + testWidgets('Delete button tooltip is not shown on disabled InputChip', ( + WidgetTester tester, + ) async { + Widget buildChip({bool enabled = true}) { + return wrapForChip( + child: InputChip(isEnabled: enabled, label: const Text('Label'), onDeleted: () {}), + ); + } + + // Test enabled chip. + await tester.pumpWidget(buildChip()); + + final Offset deleteButtonLocation = tester.getCenter(find.byType(Icon)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(deleteButtonLocation); + await tester.pump(); + + // Delete button tooltip should be visible. + expect(findTooltipContainer('Delete'), findsOneWidget); + + // Test disabled chip. + await tester.pumpWidget(buildChip(enabled: false)); + await tester.pump(); + + // Delete button tooltip should not be visible. + expect(findTooltipContainer('Delete'), findsNothing); + }); + + testWidgets('InputChip avatar layout constraints can be customized', (WidgetTester tester) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? avatarBoxConstraints}) { + return wrapForChip( + child: Center( + child: InputChip( + avatarBoxConstraints: avatarBoxConstraints, + avatar: const Icon(Icons.favorite), + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default avatar layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(InputChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(InputChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + Offset chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + final Offset avatarCenter = tester.getCenter(find.byIcon(Icons.favorite)); + expect(chipTopLeft.dx, avatarCenter.dx - (labelSize.width / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + Offset labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (labelSize.width / 2) + labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget(buildChip(avatarBoxConstraints: const BoxConstraints.tightForFinite())); + await tester.pump(); + + expect(tester.getSize(find.byType(InputChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(InputChip)).height, equals(118.0)); + + // Calculate the distance between avatar and chip edges. + chipTopLeft = tester.getTopLeft(find.byWidget(getMaterial(tester))); + expect(chipTopLeft.dx, avatarCenter.dx - (iconSize / 2) - padding - border); + expect(chipTopLeft.dy, avatarCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between avatar and label. + labelTopLeft = tester.getTopLeft(find.byType(Container)); + expect(labelTopLeft.dx, avatarCenter.dx + (iconSize / 2) + labelPadding); + }); + + testWidgets('InputChip delete icon layout constraints can be customized', ( + WidgetTester tester, + ) async { + const border = 1.0; + const iconSize = 18.0; + const labelPadding = 8.0; + const padding = 8.0; + const labelSize = Size(100, 100); + + Widget buildChip({BoxConstraints? deleteIconBoxConstraints}) { + return wrapForChip( + child: Center( + child: InputChip( + deleteIconBoxConstraints: deleteIconBoxConstraints, + onDeleted: () {}, + label: Container( + width: labelSize.width, + height: labelSize.width, + color: const Color(0xFFFF0000), + ), + ), + ), + ); + } + + // Test default delete icon layout constraints. + await tester.pumpWidget(buildChip()); + + expect(tester.getSize(find.byType(InputChip)).width, equals(234.0)); + expect(tester.getSize(find.byType(InputChip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + Offset chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + final Offset deleteIconCenter = tester.getCenter(find.byIcon(Icons.clear)); + expect(chipTopRight.dx, deleteIconCenter.dx + (labelSize.width / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + Offset labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (labelSize.width / 2) - labelPadding); + + // Test custom avatar layout constraints. + await tester.pumpWidget( + buildChip(deleteIconBoxConstraints: const BoxConstraints.tightForFinite()), + ); + await tester.pump(); + + expect(tester.getSize(find.byType(InputChip)).width, equals(152.0)); + expect(tester.getSize(find.byType(InputChip)).height, equals(118.0)); + + // Calculate the distance between delete icon and chip edges. + chipTopRight = tester.getTopRight(find.byWidget(getMaterial(tester))); + expect(chipTopRight.dx, deleteIconCenter.dx + (iconSize / 2) + padding + border); + expect(chipTopRight.dy, deleteIconCenter.dy - (labelSize.width / 2) - padding - border); + + // Calculate the distance between delete icon and label. + labelTopRight = tester.getTopRight(find.byType(Container)); + expect(labelTopRight.dx, deleteIconCenter.dx - (iconSize / 2) - labelPadding); + }); + + testWidgets('InputChip.chipAnimationStyle is passed to RawChip', (WidgetTester tester) async { + final chipAnimationStyle = ChipAnimationStyle( + enableAnimation: const AnimationStyle(duration: Durations.short2), + selectAnimation: AnimationStyle.noAnimation, + ); + + await tester.pumpWidget( + wrapForChip( + child: Center( + child: InputChip(chipAnimationStyle: chipAnimationStyle, label: const Text('InputChip')), + ), + ), + ); + + expect(tester.widget<RawChip>(find.byType(RawChip)).chipAnimationStyle, chipAnimationStyle); + }); + + testWidgets('InputChip has expected default mouse cursor on hover', (WidgetTester tester) async { + await tester.pumpWidget( + wrapForChip( + child: Center( + child: InputChip(label: const Text('Chip'), onPressed: () {}), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(10, 10)); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('InputChip mouse cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + wrapForChip( + child: const Center( + child: InputChip(mouseCursor: customCursor, label: Text('Chip')), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('Chip')); + await gesture.moveTo(chip); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('Mouse cursor resolves in focused/unfocused/disabled states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final focusNode = FocusNode(debugLabel: 'Chip'); + addTearDown(focusNode.dispose); + + Widget buildChip({required bool enabled}) { + return wrapForChip( + child: Center( + child: InputChip( + mouseCursor: const WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.disabled: SystemMouseCursors.forbidden, + WidgetState.focused: SystemMouseCursors.grab, + WidgetState.selected: SystemMouseCursors.click, + WidgetState.any: SystemMouseCursors.basic, + }), + focusNode: focusNode, + label: const Text('Chip'), + onSelected: enabled ? (bool value) {} : null, + ), + ), + ); + } + + // Unfocused case. + await tester.pumpWidget(buildChip(enabled: true)); + final TestGesture gesture1 = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture1.removePointer); + await gesture1.addPointer(location: tester.getCenter(find.text('Chip'))); + await tester.pump(); + await gesture1.moveTo(tester.getCenter(find.text('Chip'))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Focused case. + focusNode.requestFocus(); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Disabled case. + await tester.pumpWidget(buildChip(enabled: false)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + }); + + testWidgets('InputChip does not crash at zero area', (WidgetTester tester) async { + Future<void> testChip(Widget chip) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: SizedBox.shrink(child: chip)), + ), + ), + ); + expect(tester.getSize(find.byType(InputChip)), Size.zero); + } + + await testChip(const InputChip(label: Text('X'))); + await testChip( + const InputChip( + label: Text('X'), + avatar: CircleAvatar(child: Text('A')), + ), + ); + await testChip(InputChip(label: const Text('X'), onDeleted: () {})); + }); +} diff --git a/packages/material_ui/test/material/input_date_picker_form_field_test.dart b/packages/material_ui/test/material/input_date_picker_form_field_test.dart new file mode 100644 index 000000000000..ec9c10a46001 --- /dev/null +++ b/packages/material_ui/test/material/input_date_picker_form_field_test.dart @@ -0,0 +1,573 @@ +// 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/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; + +class TestMaterialLocalizations extends DefaultMaterialLocalizations { + @override + String formatCompactDate(DateTime date) { + return '${date.month}/${date.day}/${date.year}'; + } +} + +class TestMaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { + @override + bool isSupported(Locale locale) => true; + + @override + Future<MaterialLocalizations> load(Locale locale) { + return SynchronousFuture<MaterialLocalizations>(TestMaterialLocalizations()); + } + + @override + bool shouldReload(TestMaterialLocalizationsDelegate old) => false; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + + Widget inputDatePickerField({ + Key? key, + DateTime? initialDate, + DateTime? firstDate, + DateTime? lastDate, + ValueChanged<DateTime>? onDateSubmitted, + ValueChanged<DateTime>? onDateSaved, + SelectableDayPredicate? selectableDayPredicate, + String? errorFormatText, + String? errorInvalidText, + String? fieldHintText, + String? fieldLabelText, + bool autofocus = false, + Key? formKey, + ThemeData? theme, + Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates, + bool acceptEmptyDate = false, + FocusNode? focusNode, + }) { + return MaterialApp( + theme: theme ?? ThemeData.from(colorScheme: const ColorScheme.light()), + localizationsDelegates: localizationsDelegates, + home: Material( + child: Form( + key: formKey, + child: InputDatePickerFormField( + key: key, + initialDate: initialDate ?? DateTime(2016, DateTime.january, 15), + firstDate: firstDate ?? DateTime(2001), + lastDate: lastDate ?? DateTime(2031, DateTime.december, 31), + onDateSubmitted: onDateSubmitted, + onDateSaved: onDateSaved, + selectableDayPredicate: selectableDayPredicate, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + fieldHintText: fieldHintText, + fieldLabelText: fieldLabelText, + autofocus: autofocus, + acceptEmptyDate: acceptEmptyDate, + focusNode: focusNode, + ), + ), + ), + ); + } + + TextField textField(WidgetTester tester) { + return tester.widget<TextField>(find.byType(TextField)); + } + + TextEditingController textFieldController(WidgetTester tester) { + return textField(tester).controller!; + } + + double textOpacity(WidgetTester tester, String textValue) { + final FadeTransition opacityWidget = tester.widget<FadeTransition>( + find.ancestor(of: find.text(textValue), matching: find.byType(FadeTransition)).first, + ); + return opacityWidget.opacity.value; + } + + group('InputDatePickerFormField', () { + testWidgets('Initial date is the default', (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + final initialDate = DateTime(2016, DateTime.february, 21); + DateTime? inputDate; + await tester.pumpWidget( + inputDatePickerField( + initialDate: initialDate, + onDateSaved: (DateTime date) => inputDate = date, + formKey: formKey, + ), + ); + expect(textFieldController(tester).value.text, equals('02/21/2016')); + formKey.currentState!.save(); + expect(inputDate, equals(initialDate)); + }); + + testWidgets('Changing initial date is reflected in text value', (WidgetTester tester) async { + final initialDate = DateTime(2016, DateTime.february, 21); + final updatedInitialDate = DateTime(2016, DateTime.february, 23); + await tester.pumpWidget(inputDatePickerField(initialDate: initialDate)); + expect(textFieldController(tester).value.text, equals('02/21/2016')); + + await tester.pumpWidget(inputDatePickerField(initialDate: updatedInitialDate)); + await tester.pumpAndSettle(); + expect(textFieldController(tester).value.text, equals('02/23/2016')); + }); + + testWidgets('Valid date entry', (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + DateTime? inputDate; + await tester.pumpWidget( + inputDatePickerField(onDateSaved: (DateTime date) => inputDate = date, formKey: formKey), + ); + + textFieldController(tester).text = '02/21/2016'; + formKey.currentState!.save(); + expect(inputDate, equals(DateTime(2016, DateTime.february, 21))); + }); + + testWidgets('Invalid text entry shows errorFormat text', (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + DateTime? inputDate; + await tester.pumpWidget( + inputDatePickerField(onDateSaved: (DateTime date) => inputDate = date, formKey: formKey), + ); + // Default errorFormat text + expect(find.text('Invalid format.'), findsNothing); + await tester.enterText(find.byType(TextField), 'foobar'); + expect(formKey.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(inputDate, isNull); + expect(find.text('Invalid format.'), findsOneWidget); + + // Change to a custom errorFormat text + await tester.pumpWidget( + inputDatePickerField( + onDateSaved: (DateTime date) => inputDate = date, + errorFormatText: 'That is not a date.', + formKey: formKey, + ), + ); + expect(formKey.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(find.text('Invalid format.'), findsNothing); + expect(find.text('That is not a date.'), findsOneWidget); + }); + + testWidgets( + 'Valid text entry, but date outside first or last date shows bounds shows errorInvalid text', + (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + DateTime? inputDate; + await tester.pumpWidget( + inputDatePickerField( + firstDate: DateTime(1966, DateTime.february, 21), + lastDate: DateTime(2040, DateTime.february, 23), + onDateSaved: (DateTime date) => inputDate = date, + formKey: formKey, + ), + ); + // Default errorInvalid text + expect(find.text('Out of range.'), findsNothing); + // Before first date + await tester.enterText(find.byType(TextField), '02/21/1950'); + expect(formKey.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(inputDate, isNull); + expect(find.text('Out of range.'), findsOneWidget); + // After last date + await tester.enterText(find.byType(TextField), '02/23/2050'); + expect(formKey.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(inputDate, isNull); + expect(find.text('Out of range.'), findsOneWidget); + + await tester.pumpWidget( + inputDatePickerField( + onDateSaved: (DateTime date) => inputDate = date, + errorInvalidText: 'Not in given range.', + formKey: formKey, + ), + ); + expect(formKey.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(find.text('Out of range.'), findsNothing); + expect(find.text('Not in given range.'), findsOneWidget); + }, + ); + + testWidgets( + 'selectableDatePredicate will be used to show errorInvalid if date is not selectable', + (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + DateTime? inputDate; + await tester.pumpWidget( + inputDatePickerField( + initialDate: DateTime(2016, DateTime.january, 16), + onDateSaved: (DateTime date) => inputDate = date, + selectableDayPredicate: (DateTime date) => date.day.isEven, + formKey: formKey, + ), + ); + // Default errorInvalid text + expect(find.text('Out of range.'), findsNothing); + // Odd day shouldn't be valid + await tester.enterText(find.byType(TextField), '02/21/1966'); + expect(formKey.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(inputDate, isNull); + expect(find.text('Out of range.'), findsOneWidget); + // Even day is valid + await tester.enterText(find.byType(TextField), '02/24/2030'); + expect(formKey.currentState!.validate(), isTrue); + formKey.currentState!.save(); + await tester.pumpAndSettle(); + expect(inputDate, equals(DateTime(2030, DateTime.february, 24))); + expect(find.text('Out of range.'), findsNothing); + }, + ); + + testWidgets('Empty field shows hint text when focused', (WidgetTester tester) async { + await tester.pumpWidget(inputDatePickerField()); + // Focus on it + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Hint text should be invisible + expect(textOpacity(tester, 'mm/dd/yyyy'), equals(0.0)); + textFieldController(tester).clear(); + await tester.pumpAndSettle(); + // Hint text should be visible + expect(textOpacity(tester, 'mm/dd/yyyy'), equals(1.0)); + + // Change to a different hint text + await tester.pumpWidget(inputDatePickerField(fieldHintText: 'Enter some date')); + await tester.pumpAndSettle(); + expect(find.text('mm/dd/yyyy'), findsNothing); + expect(textOpacity(tester, 'Enter some date'), equals(1.0)); + await tester.enterText(find.byType(TextField), 'foobar'); + await tester.pumpAndSettle(); + expect(textOpacity(tester, 'Enter some date'), equals(0.0)); + }); + + testWidgets('Label text', (WidgetTester tester) async { + await tester.pumpWidget(inputDatePickerField()); + // Default label + expect(find.text('Enter Date'), findsOneWidget); + + await tester.pumpWidget(inputDatePickerField(fieldLabelText: 'Give me a date!')); + expect(find.text('Enter Date'), findsNothing); + expect(find.text('Give me a date!'), findsOneWidget); + }); + + testWidgets('Semantics', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget(inputDatePickerField(autofocus: true)); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.byType(EditableText)), + matchesSemantics( + label: 'Enter Date', + isTextField: true, + isFocusable: true, + hasEnabledState: true, + isEnabled: true, + isFocused: true, + value: '01/15/2016', + hasTapAction: true, + hasFocusAction: true, + hasSetTextAction: true, + hasSetSelectionAction: true, + hasCopyAction: true, + hasCutAction: true, + hasPasteAction: true, + hasMoveCursorBackwardByCharacterAction: true, + hasMoveCursorBackwardByWordAction: true, + validationResult: SemanticsValidationResult.valid, + ), + ); + semantics.dispose(); + }); + + testWidgets('ThemeData.inputDecorationTheme is honored', (WidgetTester tester) async { + const InputBorder border = InputBorder.none; + await tester.pumpWidget( + inputDatePickerField( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(inputDecorationTheme: const InputDecorationThemeData(border: border)), + ), + ); + await tester.pumpAndSettle(); + + // Get the border and container color from the painter of the _BorderContainer + // (this was cribbed from input_decorator_test.dart). + final CustomPaint customPaint = tester.widget( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), + ), + ); + final dynamic /*_InputBorderPainter*/ inputBorderPainter = customPaint.foregroundPainter; + // ignore: avoid_dynamic_calls + final dynamic /*_InputBorderTween*/ inputBorderTween = inputBorderPainter.border; + // ignore: avoid_dynamic_calls + final animation = inputBorderPainter.borderAnimation as Animation<double>; + // ignore: avoid_dynamic_calls + final actualBorder = inputBorderTween.evaluate(animation) as InputBorder; + // ignore: avoid_dynamic_calls + final containerColor = inputBorderPainter.blendedColor as Color; + + // Border should match + expect(actualBorder, equals(border)); + + // It shouldn't be filled, so the color should be transparent + expect(containerColor, equals(Colors.transparent)); + }); + + testWidgets('Date text localization', (WidgetTester tester) async { + final Iterable<LocalizationsDelegate<dynamic>> delegates = <LocalizationsDelegate<dynamic>>[ + TestMaterialLocalizationsDelegate(), + DefaultWidgetsLocalizations.delegate, + ]; + await tester.pumpWidget(inputDatePickerField(localizationsDelegates: delegates)); + await tester.enterText(find.byType(TextField), '01/01/2022'); + await tester.pumpAndSettle(); + + // Verify that the widget can be updated to a new value after the + // entered text was transformed by the localization formatter. + await tester.pumpWidget( + inputDatePickerField(initialDate: DateTime(2017), localizationsDelegates: delegates), + ); + }); + + testWidgets( + 'when an empty date is entered and acceptEmptyDate is true, then errorFormatText is not shown', + (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + const errorFormatText = 'That is not a date.'; + await tester.pumpWidget( + inputDatePickerField( + errorFormatText: errorFormatText, + formKey: formKey, + acceptEmptyDate: true, + ), + ); + await tester.enterText(find.byType(TextField), ''); + await tester.pumpAndSettle(); + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText), findsNothing); + }, + ); + + testWidgets( + 'when an empty date is entered and acceptEmptyDate is false, then errorFormatText is shown', + (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + const errorFormatText = 'That is not a date.'; + await tester.pumpWidget( + inputDatePickerField(errorFormatText: errorFormatText, formKey: formKey), + ); + await tester.enterText(find.byType(TextField), ''); + await tester.pumpAndSettle(); + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + expect(find.text(errorFormatText), findsOneWidget); + }, + ); + }); + + testWidgets('FocusNode can request focus', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget(inputDatePickerField(focusNode: focusNode)); + expect((tester.widget(find.byType(TextField)) as TextField).focusNode, focusNode); + expect(focusNode.hasFocus, isFalse); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + ), + ), + ), + ); + + final InputDatePickerFormField inputDatePickerField = tester.widget( + find.byType(InputDatePickerFormField), + ); + expect(inputDatePickerField.calendarDelegate, isA<GregorianCalendarDelegate>()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final InputDatePickerFormField inputDatePickerField = tester.widget( + find.byType(InputDatePickerFormField), + ); + expect(inputDatePickerField.calendarDelegate, isA<TestCalendarDelegate>()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + DateTime? selectedDate; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + onDateSubmitted: (DateTime value) { + selectedDate = value; + }, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder dateInput1 = find.descendant( + of: find.byType(TextField), + matching: find.text('2025..2..26'), + ); + expect(dateInput1, findsOneWidget); + + await tester.tap(dateInput1); + await tester.pumpAndSettle(); + + await tester.enterText(dateInput1, '2025..3..10'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(selectedDate, DateTime(2025, DateTime.march, 10)); + + final Finder dateInput2 = find.descendant( + of: find.byType(TextField), + matching: find.text('2025..3..10'), + ); + expect(dateInput2, findsOneWidget); + + await tester.tap(dateInput2); + await tester.pumpAndSettle(); + + await tester.enterText(dateInput2, '2025..4..21'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(selectedDate, DateTime(2025, DateTime.april, 21)); + }); + }); + + testWidgets('InputDatePickerFormField does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: InputDatePickerFormField(firstDate: DateTime(2020), lastDate: DateTime(2030)), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(InputDatePickerFormField)), Size.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177088. + testWidgets('Local InputDecorationTheme is honored', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: InputDecorationTheme( + data: const InputDecorationThemeData(filled: true), + child: InputDatePickerFormField( + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + ), + ), + ), + ), + ), + ); + + final InputDecoration decoration = tester.widget<TextField>(find.byType(TextField)).decoration!; + expect(decoration.filled, isTrue); + }); +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return '${date.year}..${date.month}..${date.day}'; + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + final List<String> parts = inputString!.split('..'); + if (parts.length != 3) { + return null; + } + final int year = int.tryParse(parts[0]) ?? 0; + final int month = int.tryParse(parts[1]) ?? 0; + final int day = int.tryParse(parts[2]) ?? 0; + return DateTime(year, month, day); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return 'yyyy..mm..dd'; + } +} diff --git a/packages/material_ui/test/material/input_decorator_test.dart b/packages/material_ui/test/material/input_decorator_test.dart new file mode 100644 index 000000000000..6db201346f2f --- /dev/null +++ b/packages/material_ui/test/material/input_decorator_test.dart @@ -0,0 +1,15773 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const Duration kTransitionDuration = Duration(milliseconds: 167); + +const String hintText = 'hint'; +const String inputText = 'text'; +const String labelText = 'label'; +const String errorText = 'error'; +const String helperText = 'helper'; +const String counterText = 'counter'; + +const Key customLabelKey = Key('label'); +const Widget customLabel = Text.rich( + key: customLabelKey, + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), +); + +const String twoLines = 'line1\nline2'; +const String threeLines = 'line1\nline2\nline3'; + +Widget buildInputDecorator({ + InputDecoration decoration = const InputDecoration(), + ThemeData? theme, + InputDecorationThemeData? inputDecorationTheme, + InputDecorationThemeData? localInputDecorationTheme, + IconButtonThemeData? iconButtonTheme, + TextDirection textDirection = TextDirection.ltr, + bool expands = false, + bool isEmpty = false, + bool isFocused = false, + bool isHovering = false, + bool useIntrinsicWidth = false, + TextStyle? baseStyle, + TextAlignVertical? textAlignVertical, + VisualDensity? visualDensity, + Widget child = const Text( + inputText, + // Use a text style compliant with M3 specification (which is bodyLarge for text fields). + style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.w400, letterSpacing: 0.5, height: 1.50), + ), +}) { + Widget widget = InputDecorator( + expands: expands, + decoration: decoration, + isEmpty: isEmpty, + isFocused: isFocused, + isHovering: isHovering, + baseStyle: baseStyle, + textAlignVertical: textAlignVertical, + child: child, + ); + + if (useIntrinsicWidth) { + widget = IntrinsicWidth(child: widget); + } + + if (localInputDecorationTheme != null) { + widget = InputDecorationTheme(data: localInputDecorationTheme, child: widget); + } + + return MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return Theme( + data: (theme ?? Theme.of(context)).copyWith( + inputDecorationTheme: inputDecorationTheme, + iconButtonTheme: iconButtonTheme, + visualDensity: visualDensity, + ), + child: Align( + alignment: Alignment.topLeft, + child: Directionality(textDirection: textDirection, child: widget), + ), + ); + }, + ), + ), + ); +} + +Widget buildInputDecoratorWithFloatingLabel({ + required TextDirection textDirection, + required bool hasIcon, + FloatingLabelAlignment? alignment, + bool borderIsOutline = false, + InputDecorationThemeData? localInputDecorationTheme, +}) { + return buildInputDecorator( + textDirection: textDirection, + localInputDecorationTheme: localInputDecorationTheme, + decoration: InputDecoration( + contentPadding: const EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + floatingLabelAlignment: alignment, + icon: hasIcon ? const Icon(Icons.insert_link) : null, + labelText: labelText, + hintText: hintText, + filled: true, + border: borderIsOutline ? const OutlineInputBorder() : null, + ), + ); +} + +Finder findBorderPainter() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), + ); +} + +double getBorderBottom(WidgetTester tester) { + final RenderBox box = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + return box.size.height; +} + +Finder findLabel() { + return find.descendant( + of: find.byType(MatrixTransition), + matching: find.byWidgetPredicate((Widget w) => w is Text), + ); +} + +Rect getLabelRect(WidgetTester tester) { + return tester.getRect(findLabel()); +} + +Offset getLabelCenter(WidgetTester tester) { + return getLabelRect(tester).center; +} + +TextStyle getLabelStyle(WidgetTester tester) { + return tester + .firstWidget<AnimatedDefaultTextStyle>( + find.ancestor(of: findLabel(), matching: find.byType(AnimatedDefaultTextStyle)), + ) + .style; +} + +Finder findCustomLabel() { + return find.byKey(customLabelKey); +} + +Rect getCustomLabelRect(WidgetTester tester) { + return tester.getRect(findCustomLabel()); +} + +Offset getCustomLabelCenter(WidgetTester tester) { + return getCustomLabelRect(tester).center; +} + +Finder findInputText() { + return find.text(inputText); +} + +Rect getInputRect(WidgetTester tester) { + return tester.getRect(findInputText()); +} + +Offset getInputCenter(WidgetTester tester) { + return getInputRect(tester).center; +} + +Finder findHint() { + return find.text(hintText); +} + +Rect getHintRect(WidgetTester tester) { + return tester.getRect(findHint()); +} + +Offset getHintCenter(WidgetTester tester) { + return getHintRect(tester).center; +} + +double getHintOpacity(WidgetTester tester) { + return getOpacity(tester, hintText); +} + +Finder findHelper() { + return find.text(helperText); +} + +TextStyle getHintStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: findHint(), matching: find.byType(RichText))) + .text + .style!; +} + +Rect getHelperRect(WidgetTester tester) { + return tester.getRect(findHelper()); +} + +TextStyle getHelperStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: findHelper(), matching: find.byType(RichText))) + .text + .style!; +} + +Finder findError() { + return find.text(errorText); +} + +Rect getErrorRect(WidgetTester tester) { + return tester.getRect(findError()); +} + +TextStyle getErrorStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: findError(), matching: find.byType(RichText))) + .text + .style!; +} + +Finder findCounter() { + return find.text(counterText); +} + +Rect getCounterRect(WidgetTester tester) { + return tester.getRect(findCounter()); +} + +TextStyle getCounterStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: findCounter(), matching: find.byType(RichText))) + .text + .style!; +} + +Finder findDecorator() { + return find.byType(InputDecorator); +} + +Rect getDecoratorRect(WidgetTester tester) { + return tester.getRect(findDecorator()); +} + +Offset getDecoratorCenter(WidgetTester tester) { + return getDecoratorRect(tester).center; +} + +Rect getContainerRect(WidgetTester tester) { + final RenderBox box = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + return box.paintBounds; +} + +Animation<double> _getHoverAnimation(WidgetTester tester) { + final CustomPaint customPaint = tester.widget(findBorderPainter()); + final dynamic /*_InputBorderPainter*/ inputBorderPainter = customPaint.foregroundPainter; + // ignore: avoid_dynamic_calls + final animation = inputBorderPainter.hoverAnimation as Animation<double>; + return animation; +} + +InputBorder? getBorder(WidgetTester tester) { + if (!tester.any(findBorderPainter())) { + return null; + } + final CustomPaint customPaint = tester.widget(findBorderPainter()); + final dynamic /*_InputBorderPainter*/ inputBorderPainter = customPaint.foregroundPainter; + // ignore: avoid_dynamic_calls + final dynamic /*_InputBorderTween*/ inputBorderTween = inputBorderPainter.border; + // ignore: avoid_dynamic_calls + final animation = inputBorderPainter.borderAnimation as Animation<double>; + // ignore: avoid_dynamic_calls + final border = inputBorderTween.evaluate(animation) as InputBorder; + return border; +} + +BorderSide? getBorderSide(WidgetTester tester) { + return getBorder(tester)!.borderSide; +} + +BorderRadius? getBorderRadius(WidgetTester tester) { + switch (getBorder(tester)!) { + case UnderlineInputBorder(:final BorderRadius borderRadius): + case OutlineInputBorder(:final BorderRadius borderRadius): + return borderRadius; + } + return null; +} + +double getBorderWeight(WidgetTester tester) => getBorderSide(tester)!.width; + +Color getBorderColor(WidgetTester tester) => getBorderSide(tester)!.color; + +Color getContainerColor(WidgetTester tester) { + final CustomPaint customPaint = tester.widget(findBorderPainter()); + final dynamic /*_InputBorderPainter*/ inputBorderPainter = customPaint.foregroundPainter; + // ignore: avoid_dynamic_calls + return inputBorderPainter.blendedColor as Color; +} + +double getOpacity(WidgetTester tester, String textValue) { + final FadeTransition opacityWidget = tester.widget<FadeTransition>( + find.ancestor(of: find.text(textValue), matching: find.byType(FadeTransition)).first, + ); + return opacityWidget.opacity.value; +} + +TextStyle? getIconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + +RenderObject getOverlayColor(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); +} + +void main() { + // TODO(bleroux): migrate all M2 tests to M3. + // See https://github.com/flutter/flutter/issues/139076 + // Work in progress. + + group('Material3 - InputDecoration container', () { + // Default container height for InputDecorator (filled or outlined) is 56dp on mobile + // whether the label is floating or not. + // This value is taken from https://m3.material.io/components/text-fields/specs. + const containerHeight = 56.0; + + // On desktop, visual density is used to reduce the container height. + // Desktop default density is [VisualDensity.compact] which corresponds to a density value of -2. + // As a rule of thumb, a change of 1 or -1 in density corresponds to 4 logical pixels. + // See https://m3.material.io/foundations/layout/understanding-layout/spacing#a5674a8b-5f38-4a58-8202-5838b082390d. + const double desktopContainerHeight = containerHeight - 2 * 4.0; // 48.0 + + group('for filled text field', () { + group('when field is enabled', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<UnderlineInputBorder>()); + expect( + getBorderRadius(tester), + const BorderRadius.only(topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0)), + ); + }); + + testWidgets('container has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect( + findBorderPainter(), + paints + ..rrect(style: PaintingStyle.fill, color: theme.colorScheme.surfaceContainerHighest), + ); + }); + + testWidgets('active indicator has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onSurfaceVariant); + expect(getBorderWeight(tester), 1.0); + }); + }); + + group('when field is disabled', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<UnderlineInputBorder>()); + expect( + getBorderRadius(tester), + const BorderRadius.only(topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0)), + ); + }); + + testWidgets('container has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect( + findBorderPainter(), + paints..rrect( + style: PaintingStyle.fill, + color: theme.colorScheme.onSurface.withOpacity(0.04), + ), + ); + }); + + testWidgets('active indicator has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onSurface.withOpacity(0.38)); + expect(getBorderWeight(tester), 1.0); + }); + }); + + group('when field is hovered', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<UnderlineInputBorder>()); + expect( + getBorderRadius(tester), + const BorderRadius.only(topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0)), + ); + }); + + testWidgets('container has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(theme.hoverColor, Colors.black.withOpacity(0.04)); + expect( + findBorderPainter(), + paints..rrect( + style: PaintingStyle.fill, + color: Color.alphaBlend(theme.hoverColor, theme.colorScheme.surfaceContainerHighest), + ), + ); + }); + + testWidgets('active indicator has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onSurface); + expect(getBorderWeight(tester), 1.0); + }); + }); + + group('when field is focused', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<UnderlineInputBorder>()); + expect( + getBorderRadius(tester), + const BorderRadius.only(topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0)), + ); + }); + + testWidgets('container has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect( + findBorderPainter(), + paints + ..rrect(style: PaintingStyle.fill, color: theme.colorScheme.surfaceContainerHighest), + ); + }); + + testWidgets('container has correct color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/146573. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color focusColor = theme.colorScheme.surfaceContainerHighest; + final Color hoverColor = theme.hoverColor; + expect( + findBorderPainter(), + paints + ..rrect(style: PaintingStyle.fill, color: Color.alphaBlend(hoverColor, focusColor)), + ); + }); + + testWidgets('active indicator has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.primary); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('active indicator has correct weight and color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/145897. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.primary); + expect(getBorderWeight(tester), 2.0); + }); + }); + + group('when field is in error', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<UnderlineInputBorder>()); + expect( + getBorderRadius(tester), + const BorderRadius.only(topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0)), + ); + }); + + testWidgets('container has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect( + findBorderPainter(), + paints + ..rrect(style: PaintingStyle.fill, color: theme.colorScheme.surfaceContainerHighest), + ); + }); + + testWidgets('active indicator has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.error); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('active indicator has correct weight and color when focused', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.error); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('active indicator has correct weight and color when hovered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onErrorContainer); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('active indicator has correct weight and color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/145897. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.error); + expect(getBorderWeight(tester), 2.0); + }); + }); + + testWidgets('default container height is 48dp on desktop', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, desktopContainerHeight); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'default container height is 48dp on all platforms when visual density is VisualDensity.compact', + (WidgetTester tester) async { + // Visual density configured at the decoration level. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + visualDensity: VisualDensity.compact, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + + // Visual density configured at the input decoration theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.compact, + ), + ), + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + + // Visual density configured at the theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData(visualDensity: VisualDensity.compact), + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'default container height is 56dp on all platforms when visual density if VisualDensity.standard', + (WidgetTester tester) async { + // Visual density configured at the decoration level. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + visualDensity: VisualDensity.standard, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + + // Visual density configured at the input decoration theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.standard, + ), + ), + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + + // Visual density configured at the theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData(visualDensity: VisualDensity.standard), + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('Visual density defined at the decoration level takes precedence', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + visualDensity: VisualDensity.compact, + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.standard, + ), + ), + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + visualDensity: VisualDensity.comfortable, + ), + ), + ); + + expect(getContainerRect(tester).height, 52.0); + }); + + testWidgets('Visual density defined at the input decoration theme level takes precedence', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + visualDensity: VisualDensity.compact, + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.comfortable, + ), + ), + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 52.0); + }); + + testWidgets('Ambient activeIndicatorBorder is used', (WidgetTester tester) async { + const activeIndicatorBorder = BorderSide(color: Colors.amber, width: 2.0); + await tester.pumpWidget( + buildInputDecorator( + inputDecorationTheme: const InputDecorationThemeData( + filled: true, + activeIndicatorBorder: activeIndicatorBorder, + ), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getBorderColor(tester), activeIndicatorBorder.color); + expect(getBorderWeight(tester), activeIndicatorBorder.width); + }); + }); + + group('for outlined text field', () { + group('when field is enabled', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<OutlineInputBorder>()); + expect(getBorderRadius(tester), const BorderRadius.all(Radius.circular(4.0))); + }); + + testWidgets('container is painted correctly', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + // Default outlined text field's container is not filled. + expect(findBorderPainter(), paints..path(style: PaintingStyle.stroke)); + }); + + testWidgets('outline has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.outline); + expect(getBorderWeight(tester), 1.0); + }); + }); + + group('when field is disabled', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<OutlineInputBorder>()); + expect(getBorderRadius(tester), const BorderRadius.all(Radius.circular(4.0))); + }); + + testWidgets('container is painted correctly', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + // Default outlined text field's container is not filled. + expect(findBorderPainter(), paints..path(style: PaintingStyle.stroke)); + }); + + testWidgets('outline has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onSurface.withOpacity(0.12)); + expect(getBorderWeight(tester), 1.0); + }); + }); + + group('when field is hovered', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<OutlineInputBorder>()); + expect(getBorderRadius(tester), const BorderRadius.all(Radius.circular(4.0))); + }); + + testWidgets('container is painted correctly', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + // Default outlined text field's container is not filled. + expect(findBorderPainter(), paints..path(style: PaintingStyle.stroke)); + }); + + testWidgets('outline has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onSurface); + expect(getBorderWeight(tester), 1.0); + }); + }); + + group('when field is focused', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<OutlineInputBorder>()); + expect(getBorderRadius(tester), const BorderRadius.all(Radius.circular(4.0))); + }); + + testWidgets('container is painted correctly', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + // Default outlined text field's container is not filled. + expect(findBorderPainter(), paints..path(style: PaintingStyle.stroke)); + }); + + testWidgets('outline has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.primary); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('outline has correct weight and color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/145897. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.primary); + expect(getBorderWeight(tester), 2.0); + }); + }); + + group('when field is in error', () { + testWidgets('container has correct height and shape', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, containerHeight); + expect(getBorder(tester), isA<OutlineInputBorder>()); + expect(getBorderRadius(tester), const BorderRadius.all(Radius.circular(4.0))); + }); + + testWidgets('container is painted correctly', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + // Default outlined text field's container is not filled. + expect(findBorderPainter(), paints..path(style: PaintingStyle.stroke)); + }); + + testWidgets('outline has correct weight and color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.error); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('outline has correct weight and color when focused', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.error); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('outline has correct weight and color when hovered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.onErrorContainer); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('outline has correct weight and color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/145897. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getBorderColor(tester), theme.colorScheme.error); + expect(getBorderWeight(tester), 2.0); + }); + }); + + testWidgets('default container height is 48dp on desktop', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, desktopContainerHeight); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'default container height is 48dp on all platforms when visual density is VisualDensity.compact', + (WidgetTester tester) async { + // Visual density configured at the decoration level. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + visualDensity: VisualDensity.compact, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + + // Visual density configured at the input decoration theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.compact, + ), + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + + // Visual density configured at the theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData(visualDensity: VisualDensity.compact), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'default container height is 56dp on all platforms when visual density if VisualDensity.standard', + (WidgetTester tester) async { + // Visual density configured at the decoration level. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + visualDensity: VisualDensity.standard, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + + // Visual density configured at the input decoration theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.standard, + ), + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + + // Visual density configured at the theme level. + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData(visualDensity: VisualDensity.standard), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('Visual density defined at the decoration level takes precedence', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + visualDensity: VisualDensity.compact, + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.standard, + ), + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + visualDensity: VisualDensity.comfortable, + ), + ), + ); + + expect(getContainerRect(tester).height, 52.0); + }); + + testWidgets('Visual density defined at the input decoration theme level takes precedence', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + visualDensity: VisualDensity.compact, + inputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity.comfortable, + ), + ), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + expect(getContainerRect(tester).height, 52.0); + }); + }); + + testWidgets('InputDecorator with no input border', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(border: InputBorder.none), + ), + ); + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets('OutlineInputBorder radius carries over when lerping', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/23982 + const key = Key('textField'); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + key: key, + decoration: InputDecoration( + fillColor: Colors.white, + filled: true, + border: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue, width: 2.0), + borderRadius: BorderRadius.zero, + ), + ), + ), + ), + ), + ), + ); + + // TextField has the given border. + expect(getBorderRadius(tester), BorderRadius.zero); + + // Focusing does not change the border. + await tester.tap(find.byKey(key)); + await tester.pump(); + expect(getBorderRadius(tester), BorderRadius.zero); + await tester.pump(const Duration(milliseconds: 100)); + expect(getBorderRadius(tester), BorderRadius.zero); + await tester.pump(kTransitionDuration); + expect(getBorderRadius(tester), BorderRadius.zero); + }); + + testWidgets('OutlineInputBorder async lerp', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/28724 + + final completer = Completer<void>(); + var waitIsOver = false; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return GestureDetector( + onTap: () async { + setState(() { + waitIsOver = true; + }); + await completer.future; + setState(() { + waitIsOver = false; + }); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Test', + enabledBorder: !waitIsOver + ? null + : const OutlineInputBorder(borderSide: BorderSide(color: Colors.blue)), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.byType(StatefulBuilder)); + await tester.pump(kTransitionDuration); + + completer.complete(); + await tester.pump(kTransitionDuration); + }); + + test('InputBorder equality', () { + // OutlineInputBorder's equality is defined by the borderRadius, borderSide, & gapPadding. + const outlineInputBorder = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 32.0, + ); + expect( + outlineInputBorder, + const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.all(Radius.circular(9.0)), + gapPadding: 32.0, + ), + ); + expect(outlineInputBorder, isNot(const OutlineInputBorder())); + expect( + outlineInputBorder, + isNot( + const OutlineInputBorder( + borderSide: BorderSide(color: Colors.red), + borderRadius: BorderRadius.all(Radius.circular(9.0)), + gapPadding: 32.0, + ), + ), + ); + expect( + outlineInputBorder, + isNot( + const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.all(Radius.circular(10.0)), + gapPadding: 32.0, + ), + ), + ); + expect( + outlineInputBorder, + isNot( + const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.all(Radius.circular(9.0)), + gapPadding: 33.0, + ), + ), + ); + + // UnderlineInputBorder's equality is defined by the borderSide and borderRadius. + const underlineInputBorder = UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5.0), + topRight: Radius.circular(5.0), + ), + ); + expect( + underlineInputBorder, + const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5.0), + topRight: Radius.circular(5.0), + ), + ), + ); + expect(underlineInputBorder, isNot(const UnderlineInputBorder())); + expect( + underlineInputBorder, + isNot( + const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.red), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5.0), + topRight: Radius.circular(5.0), + ), + ), + ), + ); + expect( + underlineInputBorder, + isNot( + const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(6.0), + topRight: Radius.circular(6.0), + ), + ), + ), + ); + }); + + test('InputBorder hashCodes', () { + // OutlineInputBorder's hashCode is defined by the borderRadius, borderSide, & gapPadding. + const outlineInputBorder = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 32.0, + ); + expect( + outlineInputBorder.hashCode, + const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 32.0, + ).hashCode, + ); + expect(outlineInputBorder.hashCode, isNot(const OutlineInputBorder().hashCode)); + expect( + outlineInputBorder.hashCode, + isNot( + const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.red), + gapPadding: 32.0, + ).hashCode, + ), + ); + expect( + outlineInputBorder.hashCode, + isNot( + const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 32.0, + ).hashCode, + ), + ); + expect( + outlineInputBorder.hashCode, + isNot( + const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 33.0, + ).hashCode, + ), + ); + + // UnderlineInputBorder's hashCode is defined by the borderSide and borderRadius. + const underlineInputBorder = UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5.0), + topRight: Radius.circular(5.0), + ), + ); + expect( + underlineInputBorder.hashCode, + const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5.0), + topRight: Radius.circular(5.0), + ), + ).hashCode, + ); + expect( + underlineInputBorder.hashCode, + isNot( + const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.red), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(5.0), + topRight: Radius.circular(5.0), + ), + ).hashCode, + ), + ); + expect( + underlineInputBorder.hashCode, + isNot( + const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(6.0), + topRight: Radius.circular(6.0), + ), + ).hashCode, + ), + ); + }); + + testWidgets('OutlineInputBorder borders scale down to fit when large values are passed in', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/34327 + const largerBorderRadius = 200.0; + const smallerBorderRadius = 100.0; + const inputDecoratorHeight = 56.0; + const inputDecoratorWidth = 800.0; + + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + labelText: 'label text', + border: OutlineInputBorder( + borderRadius: BorderRadius.only( + // Intentionally large values that are larger than the InputDecorator. + topLeft: Radius.circular(smallerBorderRadius), + bottomLeft: Radius.circular(smallerBorderRadius), + topRight: Radius.circular(largerBorderRadius), + bottomRight: Radius.circular(largerBorderRadius), + ), + ), + ), + ), + ); + + expect( + findBorderPainter(), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: const BorderRadius.only( + topLeft: Radius.circular(smallerBorderRadius), + bottomLeft: Radius.circular(smallerBorderRadius), + topRight: Radius.circular(largerBorderRadius), + bottomRight: Radius.circular(largerBorderRadius), + ).toRRect(const Rect.fromLTWH(0, 0, inputDecoratorWidth, inputDecoratorHeight)), + ) + ..restore(), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/55317 + + testWidgets('rounded OutlineInputBorder with zero padding just wraps the label', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/82321 + const borderRadius = 30.0; + const labelText = 'label text'; + + const inputDecoratorHeight = 56.0; + const inputDecoratorWidth = 800.0; + + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + labelText: labelText, + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + gapPadding: 0.0, + ), + ), + ), + ); + + expect(find.text(labelText), findsOneWidget); + expect( + findBorderPainter(), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: const BorderRadius.all( + Radius.circular(borderRadius), + ).toRRect(const Rect.fromLTWH(0, 0, inputDecoratorWidth, inputDecoratorHeight)), + ) + ..restore(), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/55317 + + testWidgets('OutlineInputBorder with BorderRadius.zero should draw a rectangular border', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/78855. + const labelText = 'Flutter'; + const inputDecoratorHeight = 56.0; + const inputDecoratorWidth = 800.0; + const borderWidth = 4.0; + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: false, + labelText: labelText, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(width: borderWidth, color: Colors.red), + ), + ), + ), + ); + + expect(find.text(labelText), findsOneWidget); + expect( + findBorderPainter(), + paints + ..save() + ..path( + includes: const <Offset>[ + // Corner points in the middle of the border line should be in the path. + // The path is not filled and borderWidth is 4.0 so Offset(2.0, 2.0) is in the path and Offset(1.0, 1.0) is not. + // See Skia SkPath::contains method. + + // Top-left. + Offset(borderWidth / 2, borderWidth / 2), + // Top-right. + Offset(inputDecoratorWidth - 1 - borderWidth / 2, borderWidth / 2), + // Bottom-left. + Offset(borderWidth / 2, inputDecoratorHeight - 1 - borderWidth / 2), + // Bottom-right. + Offset( + inputDecoratorWidth - 1 - borderWidth / 2, + inputDecoratorHeight - 1 - borderWidth / 2, + ), + ], + excludes: const <Offset>[ + // The path is not filled and borderWidth is 4.0 so the path should not contains the corner points. + // See Skia SkPath::contains method. + + // Top-left. + Offset.zero, + // // Top-right. + Offset(inputDecoratorWidth - 1, 0), + // // Bottom-left. + Offset(0, inputDecoratorHeight - 1), + // // Bottom-right. + Offset(inputDecoratorWidth - 1, inputDecoratorHeight - 1), + ], + ) + ..restore(), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/55317 + + testWidgets('InputDecorator OutlineInputBorder fillColor is clipped by border', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/15742 + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(InputDecorator)); + + expect( + box, + paints + // The border's outer path, a rounded rectangle. + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: const BorderRadius.all( + Radius.circular(12.0), + ).toRRect(const Rect.fromLTWH(0, 0, 800.0, 56.0)), + ) + // Border outline. The rrect is the -center- of the 1.0 stroked outline. + ..rrect( + style: PaintingStyle.stroke, + strokeWidth: 1.0, + rrect: RRect.fromLTRBR(0.5, 0.5, 799.5, 55.5, const Radius.circular(11.5)), + ), + ); + }); + + group('OutlineInputBorder strokeAlign', () { + const borderWidth = 4.0; + const borderRadius = 12.0; + const inputDecoratorWidth = 800.0; + const inputDecoratorHeight = 56.0; + + Future<void> testStrokeAlign({ + required WidgetTester tester, + required double strokeAlign, + required RRect expectedRRect, + }) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFF00FF00), + enabledBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(borderRadius)), + borderSide: BorderSide(width: borderWidth, strokeAlign: strokeAlign), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(InputDecorator)); + + expect( + box, + paints + // Background fill of the InputDecorator. + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: RRect.fromLTRBR( + 0, + 0, + inputDecoratorWidth, + inputDecoratorHeight, + const Radius.circular(borderRadius), + ), + ) + // The stroked OutlineInputBorder. + ..rrect(style: PaintingStyle.stroke, strokeWidth: borderWidth, rrect: expectedRRect), + ); + } + + testWidgets('strokeAlignOutside should draw border outside bounds', ( + WidgetTester tester, + ) async { + await testStrokeAlign( + tester: tester, + strokeAlign: BorderSide.strokeAlignOutside, + expectedRRect: RRect.fromLTRBR( + -borderWidth / 2, + -borderWidth / 2, + inputDecoratorWidth + borderWidth / 2, + inputDecoratorHeight + borderWidth / 2, + const Radius.circular(borderRadius + borderWidth / 2), + ), + ); + }); + + testWidgets('strokeAlignCenter should draw border between bounds', ( + WidgetTester tester, + ) async { + await testStrokeAlign( + tester: tester, + strokeAlign: BorderSide.strokeAlignCenter, + expectedRRect: RRect.fromLTRBR( + 0, + 0, + inputDecoratorWidth, + inputDecoratorHeight, + const Radius.circular(borderRadius), + ), + ); + }); + + testWidgets('strokeAlignInside should draw border inside bounds', ( + WidgetTester tester, + ) async { + await testStrokeAlign( + tester: tester, + strokeAlign: BorderSide.strokeAlignInside, + expectedRRect: RRect.fromLTRBR( + borderWidth / 2, + borderWidth / 2, + inputDecoratorWidth - borderWidth / 2, + inputDecoratorHeight - borderWidth / 2, + const Radius.circular(borderRadius - borderWidth / 2), + ), + ); + }); + }); + + testWidgets('InputDecorator UnderlineInputBorder fillColor is clipped by border', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + border: UnderlineInputBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12.0), + bottomRight: Radius.circular(12.0), + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(InputDecorator)); + + // Fill is the border's outer path, a rounded rectangle. + expect( + box, + paints..drrect( + style: PaintingStyle.fill, + inner: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 800.0, + 47.0, + bottomRight: const Radius.elliptical(12.0, 11.0), + bottomLeft: const Radius.elliptical(12.0, 11.0), + ), + outer: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 800.0, + 48.0, + bottomRight: const Radius.elliptical(12.0, 12.0), + bottomLeft: const Radius.elliptical(12.0, 12.0), + ), + ), + ); + }); + + testWidgets('UnderlineInputBorder clips top border to prevent anti-aliasing glitches', ( + WidgetTester tester, + ) async { + const canvasRect = Rect.fromLTWH(0, 0, 100, 100); + const border = UnderlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))); + expect( + (Canvas canvas) => border.paint(canvas, canvasRect), + paints..drrect( + outer: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 100.0, + 100.0, + bottomRight: const Radius.elliptical(12.0, 12.0), + bottomLeft: const Radius.elliptical(12.0, 12.0), + ), + inner: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 100.0, + 99.0, + bottomRight: const Radius.elliptical(12.0, 11.0), + bottomLeft: const Radius.elliptical(12.0, 11.0), + ), + ), + ); + + const border2 = UnderlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(60.0))); + expect( + (Canvas canvas) => border2.paint(canvas, canvasRect), + paints..drrect( + outer: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 100.0, + 100.0, + bottomRight: const Radius.elliptical(50.0, 50.0), + bottomLeft: const Radius.elliptical(50.0, 50.0), + ), + inner: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 100.0, + 99.0, + bottomRight: const Radius.elliptical(50.0, 49.0), + bottomLeft: const Radius.elliptical(50.0, 49.0), + ), + ), + reason: 'clamp is expected', + ); + }); + + testWidgets('UnderlineInputBorder draws bottom border inside container bounds', ( + WidgetTester tester, + ) async { + const canvasRect = Rect.fromLTWH(0, 0, 100, 100); + const borderWidth = 2.0; + const border = UnderlineInputBorder(borderSide: BorderSide(width: borderWidth)); + expect( + (Canvas canvas) => border.paint(canvas, canvasRect), + paints..line( + p1: Offset(0, canvasRect.height - borderWidth / 2), + p2: Offset(100, canvasRect.height - borderWidth / 2), + strokeWidth: borderWidth, + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/82321. + // Regression test for https://github.com/flutter/flutter/issues/150109. + testWidgets( + 'OutlineBorder starts at the right position when border radius is taller than horizontal content padding', + (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Scaffold( + body: Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: Directionality( + textDirection: textDirection, + child: const RepaintBoundary( + child: InputDecorator( + isFocused: true, + isEmpty: true, + decoration: InputDecoration( + labelText: labelText, + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(30)), + gapPadding: 0.0, + ), + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + RenderBox borderBox = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + // Convert label bottom left offset to border path coordinate system. + final Offset labelBottomLeftLocalToBorder = borderBox.globalToLocal( + getLabelRect(tester).bottomLeft, + ); + + expect( + findBorderPainter(), + paints + ..save() + ..path( + // The label bottom left corner should not be part of the border. + excludes: <Offset>[labelBottomLeftLocalToBorder], + // The points just before the label bottom left corner should be part of the border. + includes: <Offset>[ + labelBottomLeftLocalToBorder + const Offset(-1, 0), + labelBottomLeftLocalToBorder + const Offset(-1, -1), + ], + ) + ..restore(), + ); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + borderBox = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + // Convert label bottom right offset to border path coordinate system. + final Offset labelBottomRightLocalToBorder = borderBox.globalToLocal( + getLabelRect(tester).bottomRight, + ); + + expect( + findBorderPainter(), + paints + ..save() + ..path( + // The label bottom right corner should not be part of the border. + excludes: <Offset>[labelBottomRightLocalToBorder], + // The points just after the label bottom right corner should be part of the border. + includes: <Offset>[ + labelBottomRightLocalToBorder + const Offset(1, 0), + labelBottomRightLocalToBorder + const Offset(1, -1), + ], + ) + ..restore(), + ); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/159942. + testWidgets('OutlineBorder does not overlap with the label at the default radius', ( + WidgetTester tester, + ) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Scaffold( + body: Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: Directionality( + textDirection: textDirection, + child: const RepaintBoundary( + child: InputDecorator( + isFocused: true, + decoration: InputDecoration( + labelText: labelText, + border: OutlineInputBorder(gapPadding: 0.0), + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + Rect labelRect = getLabelRect(tester); + RenderBox borderBox = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + expect( + findBorderPainter(), + paints + ..save() + ..path( + // The points of the label edge should be part of the border. + includes: <Offset>[ + borderBox.globalToLocal(labelRect.centerLeft), + borderBox.globalToLocal(labelRect.centerRight), + ], + // The points inside the label should not be part of the border. + excludes: <Offset>[ + borderBox.globalToLocal(labelRect.centerLeft) + const Offset(1, 0), + borderBox.globalToLocal(labelRect.centerRight) + const Offset(-1, 0), + ], + ) + ..restore(), + ); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + labelRect = getLabelRect(tester); + borderBox = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + expect( + findBorderPainter(), + paints + ..save() + ..path( + // The points of the label edge should be part of the border. + includes: <Offset>[ + borderBox.globalToLocal(labelRect.centerLeft), + borderBox.globalToLocal(labelRect.centerRight), + ], + // The points inside the label should not be part of the border. + excludes: <Offset>[ + borderBox.globalToLocal(labelRect.centerLeft) + const Offset(1, 0), + borderBox.globalToLocal(labelRect.centerRight) + const Offset(-1, 0), + ], + ) + ..restore(), + ); + }); + + testWidgets( + 'OutlineBorder does not draw over label when input decorator is focused and has an icon', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/18111. + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Scaffold( + body: Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: Directionality( + textDirection: textDirection, + child: const RepaintBoundary( + child: InputDecorator( + isFocused: true, + isEmpty: true, + decoration: InputDecoration( + icon: Icon(Icons.insert_link), + labelText: labelText, + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + RenderBox borderBox = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + expect( + findBorderPainter(), + paints + ..save() + ..path( + excludes: <Offset>[ + borderBox.globalToLocal(getLabelRect(tester).centerLeft), + borderBox.globalToLocal(getLabelRect(tester).centerRight), + ], + ) + ..restore(), + ); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + borderBox = InputDecorator.containerOf(tester.element(findBorderPainter()))!; + expect( + findBorderPainter(), + paints + ..save() + ..path( + excludes: <Offset>[ + borderBox.globalToLocal(getLabelRect(tester).centerLeft), + borderBox.globalToLocal(getLabelRect(tester).centerRight), + ], + ) + ..restore(), + ); + }, + ); + }); + + group('Material3 - InputDecoration label', () { + group('for filled text field', () { + testWidgets('label and input horizontal positions are M3 compliant in LTR', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(filled: true, labelText: labelText), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + const double labelAndInputStart = 12.0 + 4.0; // Content left padding + default input gap. + expect(getLabelRect(tester).left, labelAndInputStart); + expect(getInputRect(tester).left, labelAndInputStart); + }); + + testWidgets('label and input horizontal positions are M3 compliant in RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(filled: true, labelText: labelText), + textDirection: TextDirection.rtl, + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + const double labelAndInputStart = 12.0 + 4.0; // Content left padding + default input gap. + expect(getLabelRect(tester).right, 800.0 - labelAndInputStart); + expect(getInputRect(tester).right, 800.0 - labelAndInputStart); + }); + + group('when field is enabled', () { + testWidgets('label text has correct style', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(filled: true, labelText: labelText), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + // Current input decorator implementation forces line height to 1.0, + // this is not compliant with M3 spec. + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith( + color: expectedColor, + height: 1.0, + ); + expect(getLabelStyle(tester), expectedStyle); + }); + }); + + group('when field is disabled', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(filled: true, enabled: false, labelText: labelText), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.onSurface.withOpacity(0.38)); + }); + }); + + group('when field is hovered', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration(filled: true, labelText: labelText), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.onSurfaceVariant); + }); + }); + + group('when field is focused', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration(filled: true, labelText: labelText), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.primary); + }); + + testWidgets('label text has correct color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/146565. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration(filled: true, labelText: labelText), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.primary); + }); + }); + + group('when field is in error', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.error); + }); + + testWidgets('label text has correct color when focused', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.error); + }); + + testWidgets('label text has correct style when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.onErrorContainer); + }); + + testWidgets('label text has correct style when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/146565. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.error); + }); + }); + }); + + group('for outlined text field', () { + testWidgets('label and input horizontal positions are M3 compliant in LTR', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(border: OutlineInputBorder(), labelText: labelText), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + const double labelAndInputStart = 12.0 + 4.0; // Content left padding + default input gap. + expect(getLabelRect(tester).left, labelAndInputStart); + expect(getInputRect(tester).left, labelAndInputStart); + }); + + testWidgets('label and input horizontal positions are M3 compliant in RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(border: OutlineInputBorder(), labelText: labelText), + textDirection: TextDirection.rtl, + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + const double labelAndInputStart = 12.0 + 4.0; // Content left padding + default input gap. + expect(getLabelRect(tester).right, 800 - labelAndInputStart); + expect(getInputRect(tester).right, 800 - labelAndInputStart); + }); + + testWidgets('label and input horizontal positions can be adjusted in LTR', ( + WidgetTester tester, + ) async { + const customGap = 6.0; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(gapPadding: customGap), + labelText: labelText, + ), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + const double labelAndInputStart = 12.0 + customGap; // Content left padding + input gap. + expect(getLabelRect(tester).left, labelAndInputStart); + expect(getInputRect(tester).left, labelAndInputStart); + }); + + testWidgets('label and input horizontal positions can be adjusted in RTL', ( + WidgetTester tester, + ) async { + const customGap = 6.0; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(gapPadding: customGap), + labelText: labelText, + ), + textDirection: TextDirection.rtl, + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + const double labelAndInputStart = 12.0 + customGap; // Content left padding + input gap. + expect(getLabelRect(tester).right, 800.0 - labelAndInputStart); + expect(getInputRect(tester).right, 800.0 - labelAndInputStart); + }); + + group('when field is enabled', () { + testWidgets('label text has correct style', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + // Current input decorator implementation forces line height to 1.0, + // this is not compliant with M3 spec. + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith( + color: expectedColor, + height: 1.0, + ); + expect(getLabelStyle(tester), expectedStyle); + }); + }); + + group('when field is disabled', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.onSurface.withOpacity(0.38)); + }); + }); + + group('when field is hovered', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.onSurfaceVariant); + }); + }); + + group('when field is focused', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.primary); + }); + + testWidgets('label text has correct color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/146565. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.primary); + }); + }); + + group('when field is in error', () { + testWidgets('label text has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.error); + }); + + testWidgets('label text has correct color when focused', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.error); + }); + + testWidgets('label text has correct color when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.onErrorContainer); + }); + + testWidgets('label text has correct color when focused and hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/146565. + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + expect(getLabelStyle(tester).color, theme.colorScheme.error); + }); + }); + }); + + testWidgets( + 'Label and input for non-filled and non-outlined text field have no horizontal padding in LTR', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(labelText: labelText)), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + expect(getLabelRect(tester).left, 0.0); + expect(getInputRect(tester).left, 0.0); + }, + ); + + testWidgets( + 'Label and input for non-filled and non-outlined text field have no horizontal padding in RTL', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(labelText: labelText), + textDirection: TextDirection.rtl, + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + expect(getLabelRect(tester).right, 800.0); + expect(getInputRect(tester).right, 800.0); + }, + ); + + testWidgets('floatingLabelStyle overrides default style', (WidgetTester tester) async { + const floatingLabelStyle = TextStyle(color: Colors.indigo, fontSize: 16.0); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + decoration: const InputDecoration( + labelText: labelText, + floatingLabelStyle: floatingLabelStyle, + ), + ), + ); + + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + expect(getLabelStyle(tester).fontSize, floatingLabelStyle.fontSize); + }); + + testWidgets('floatingLabelStyle defaults to labelStyle', (WidgetTester tester) async { + const labelStyle = TextStyle(color: Colors.amber, fontSize: 16.0); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + decoration: const InputDecoration(labelText: labelText, labelStyle: labelStyle), + ), + ); + + expect(getLabelStyle(tester).color, labelStyle.color); + expect(getLabelStyle(tester).fontSize, labelStyle.fontSize); + }); + + testWidgets('floatingLabelStyle takes precedence over labelStyle', (WidgetTester tester) async { + const labelStyle = TextStyle(color: Colors.amber, fontSize: 16.0); + const floatingLabelStyle = TextStyle(color: Colors.indigo, fontSize: 16.0); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + decoration: const InputDecoration( + labelText: labelText, + labelStyle: labelStyle, + floatingLabelStyle: floatingLabelStyle, + ), + ), + ); + + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + expect(getLabelStyle(tester).fontSize, floatingLabelStyle.fontSize); + }); + + testWidgets('InputDecorationThemeData.labelStyle overrides default style', ( + WidgetTester tester, + ) async { + const labelStyle = TextStyle(color: Colors.amber, fontSize: 16.0); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, // Label appears inline, on top of the input field. + inputDecorationTheme: const InputDecorationThemeData(labelStyle: labelStyle), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getLabelStyle(tester).color, labelStyle.color); + }); + + testWidgets('InputDecorationThemeData.floatingLabelStyle overrides default style', ( + WidgetTester tester, + ) async { + const floatingLabelStyle = TextStyle(color: Colors.indigo, fontSize: 16.0); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + inputDecorationTheme: const InputDecorationThemeData( + floatingLabelStyle: floatingLabelStyle, + ), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + }); + + testWidgets('floatingLabelStyle is always used when FloatingLabelBehavior.always', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/147231. + const labelStyle = TextStyle(color: Colors.amber, fontSize: 16.0); + const floatingLabelStyle = TextStyle(color: Colors.indigo, fontSize: 16.0); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + labelText: labelText, + labelStyle: labelStyle, + floatingLabelStyle: floatingLabelStyle, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + expect(getLabelStyle(tester).fontSize, floatingLabelStyle.fontSize); + + // Focus the input decorator. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration( + labelText: labelText, + labelStyle: labelStyle, + floatingLabelStyle: floatingLabelStyle, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + expect(getLabelStyle(tester).fontSize, floatingLabelStyle.fontSize); + }); + }); + + group('Material3 - InputDecoration labelText layout', () { + testWidgets('The label appears above input', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(labelText: labelText)), + ); + + // Overall height for this InputDecorator is 56dp on mobile: + // 8 - top padding + // 12 - floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - gap between label and input (this is not part of the M3 spec) + // 24 - input text (font size = 16, line height = 1.5) + // 8 - bottom padding + // TODO(bleroux): fix input decorator to not rely on a 4 pixels gap between the label and the input, + // this gap is not compliant with the M3 spec (M3 spec uses line height for this purpose). + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + expect(getLabelRect(tester).top, 8.0); + expect(getLabelRect(tester).bottom, 20.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + }); + + testWidgets('The label appears within the input when there is no text content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator(isEmpty: true, decoration: const InputDecoration(labelText: labelText)), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getLabelRect(tester).top, 20.0); + expect(getLabelRect(tester).bottom, 36.0); + // From the M3 specification, centering the label is right, but setting the line height to 1.0 is not + // compliant (the expected text style is bodyLarge which font size is 16.0 and its line height 1.5). + // TODO(bleroux): fix input decorator to not rely on forcing the label text line height to 1.0. + }); + + testWidgets('When the label appears within the input its padding is correct', ( + WidgetTester tester, + ) async { + // Define a label larger than the available decorator, the label will fill + // all the available space (decorator width minus padding and affixes). + const Widget largeLabel = SizedBox(key: customLabelKey, width: 1000, height: 16); + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(filled: true, label: largeLabel), + ), + ); + + // For filled and/or outlined decoration, the horizontal padding is 16. + const horizontalPadding = 16.0; + expect(getCustomLabelRect(tester).left, horizontalPadding); + expect(getCustomLabelRect(tester).right, 800 - horizontalPadding); + + // Outlined decorator. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), label: largeLabel), + ), + ); + + expect(getCustomLabelRect(tester).left, horizontalPadding); + expect(getCustomLabelRect(tester).right, 800 - horizontalPadding); + + // Rebuild with affixes. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + label: largeLabel, + suffixIcon: Icon(Icons.align_horizontal_left_sharp), + prefixIcon: Icon(Icons.align_horizontal_right_sharp), + ), + ), + ); + + // When suffixIcon and/or prefixIcon are set, the corresponding horizontal + // padding is 52 (48 for the icon + 4 input gap based on M3 spec). + const affixesHorizontalPadding = 52.0; + expect(getCustomLabelRect(tester).left, affixesHorizontalPadding); + expect(getCustomLabelRect(tester).right, 800 - affixesHorizontalPadding); + + // Outlined decorator. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + label: largeLabel, + suffixIcon: Icon(Icons.align_horizontal_left_sharp), + prefixIcon: Icon(Icons.align_horizontal_right_sharp), + ), + ), + ); + + expect(getCustomLabelRect(tester).left, affixesHorizontalPadding); + expect(getCustomLabelRect(tester).right, 800 - affixesHorizontalPadding); + }); + + testWidgets( + 'The label appears above the input when there is no content and floatingLabelBehavior is always', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + labelText: labelText, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + expect(getLabelRect(tester).top, 8.0); + expect(getLabelRect(tester).bottom, 20.0); + }, + ); + + testWidgets( + 'The label appears within the input text when there is content and floatingLabelBehavior is never', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: labelText, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getLabelRect(tester).top, 20.0); + expect(getLabelRect(tester).bottom, 36.0); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/71813. + testWidgets('Ambient theme floatingLabelBehavior is used', (WidgetTester tester) async { + const FloatingLabelBehavior floatingLabelBehavior = FloatingLabelBehavior.never; + await tester.pumpWidget( + buildInputDecorator( + inputDecorationTheme: const InputDecorationThemeData( + floatingLabelBehavior: floatingLabelBehavior, + ), + decoration: const InputDecoration(label: customLabel), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getCustomLabelRect(tester).top, 20.0); + expect(getCustomLabelRect(tester).bottom, 36.0); + }); + + testWidgets('Floating label animation duration and curve', (WidgetTester tester) async { + Future<void> pumpInputDecorator({required bool isFocused}) async { + return tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: isFocused, + decoration: const InputDecoration( + labelText: labelText, + floatingLabelBehavior: FloatingLabelBehavior.auto, + ), + ), + ); + } + + await pumpInputDecorator(isFocused: false); + expect(getLabelRect(tester).top, 20.0); + + // The label animates upwards and scales down. + // The animation duration is 167ms and the curve is fastOutSlowIn. + await pumpInputDecorator(isFocused: true); + await tester.pump(const Duration(milliseconds: 42)); + expect(getLabelRect(tester).top, closeTo(17.09, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getLabelRect(tester).top, closeTo(10.66, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getLabelRect(tester).top, closeTo(8.47, 0.5)); + await tester.pump(const Duration(milliseconds: 41)); + expect(getLabelRect(tester).top, 8.0); + + // If the animation changes direction without first reaching the + // AnimationStatus.completed or AnimationStatus.dismissed status, + // the CurvedAnimation stays on the same curve in the opposite direction. + // The pumpAndSettle is used to prevent this behavior. + await tester.pumpAndSettle(); + + // The label animates downwards and scales up. + // The animation duration is 167ms and the curve is fastOutSlowIn. + await pumpInputDecorator(isFocused: false); + await tester.pump(const Duration(milliseconds: 42)); + expect(getLabelRect(tester).top, closeTo(10.90, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getLabelRect(tester).top, closeTo(17.34, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getLabelRect(tester).top, closeTo(19.69, 0.5)); + await tester.pump(const Duration(milliseconds: 41)); + expect(getLabelRect(tester).top, 20.0); + }); + + testWidgets('InputDecorator withdraws label when not empty or focused', ( + WidgetTester tester, + ) async { + Future<void> pumpDecorator({ + required bool focused, + bool enabled = true, + bool filled = false, + bool empty = true, + bool directional = false, + }) async { + return tester.pumpWidget( + buildInputDecorator( + isEmpty: empty, + isFocused: focused, + decoration: InputDecoration( + labelText: 'Label', + enabled: enabled, + filled: filled, + focusedBorder: const OutlineInputBorder(), + disabledBorder: const OutlineInputBorder(), + border: const OutlineInputBorder(), + ), + ), + ); + } + + await pumpDecorator(focused: false); + await tester.pump(kTransitionDuration); + const labelSize = Size(82.5, 16); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, 20))); + expect(getLabelRect(tester).size, equals(labelSize)); + + await pumpDecorator(focused: false, empty: false); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + await pumpDecorator(focused: true); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + await pumpDecorator(focused: true, empty: false); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + await pumpDecorator(focused: false, enabled: false); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, 20))); + expect(getLabelRect(tester).size, equals(labelSize)); + + await pumpDecorator(focused: false, empty: false, enabled: false); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + // Focused and disabled happens with NavigationMode.directional. + await pumpDecorator(focused: true, enabled: false); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, 20))); + expect(getLabelRect(tester).size, equals(labelSize)); + + await pumpDecorator(focused: true, empty: false, enabled: false); + await tester.pump(kTransitionDuration); + expect(getLabelRect(tester).topLeft, equals(const Offset(16, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + }); + + testWidgets('InputDecorator floating label width scales when focused', ( + WidgetTester tester, + ) async { + final longStringA = String.fromCharCodes(List<int>.generate(200, (_) => 65)); + final longStringB = String.fromCharCodes(List<int>.generate(200, (_) => 66)); + + await tester.pumpWidget( + Center( + child: SizedBox.square( + dimension: 100, + child: buildInputDecorator( + isEmpty: true, + decoration: InputDecoration(labelText: longStringA), + ), + ), + ), + ); + + expect( + find.text(longStringA), + paints..clipRect(rect: const Rect.fromLTWH(0, 0, 100.0, 16.0)), + ); + + await tester.pumpWidget( + Center( + child: SizedBox.square( + dimension: 100, + child: buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: InputDecoration(labelText: longStringB), + ), + ), + ), + ); + + await tester.pump(kTransitionDuration); + + expect( + find.text(longStringB), + paints..something((Symbol methodName, List<dynamic> arguments) { + if (methodName != #clipRect) { + return false; + } + final clipRect = arguments[0] as Rect; + // _kFinalLabelScale = 0.75 + expect( + clipRect, + rectMoreOrLessEquals(const Rect.fromLTWH(0, 0, 100 / 0.75, 16.0), epsilon: 1e-5), + ); + return true; + }), + ); + }, skip: isBrowser); // TODO(yjbanov): https://github.com/flutter/flutter/issues/44020 + + testWidgets('InputDecorator floating label Y coordinate', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/54028 + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + labelText: labelText, + enabledBorder: OutlineInputBorder(borderSide: BorderSide(width: 4)), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + await tester.pump(kTransitionDuration); + + // floatingLabelHeight = 12 (font size 16dps * 0.75 = 12) + // labelY = -floatingLabelHeight/2 + borderWidth/2 + expect(getLabelRect(tester).top, -4.0); + }); + + testWidgets('InputDecorator respects reduced theme visualDensity', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + visualDensity: VisualDensity.compact, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + // Overall height for this InputDecorator is 48dp: + // 4 - top padding (8 minus 4 due to reduced visual density) + // 12 - floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - gap between label and input (this is not part of the M3 spec) + // 24 - input text (font size = 16, line height = 1.5) + // 4 - bottom padding (8 minus 4 due to reduced visual density) + expect(getDecoratorRect(tester).size, const Size(800.0, 48.0)); + + // The decorator is empty, label is not floating and is vertically centered. + expect(getLabelRect(tester).top, 16.0); + expect(getLabelRect(tester).bottom, 32.0); + expect(getHintOpacity(tester), 0.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + + // When the decorator is focused, label moves upwards, hint is visible (opacity 1.0). + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + visualDensity: VisualDensity.compact, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + await tester.pump(kTransitionDuration); + + // The decorator is empty and focused, label and hint are visible. + expect(getDecoratorRect(tester).size, const Size(800.0, 48.0)); + expect(getLabelRect(tester).top, 4.0); + expect(getLabelRect(tester).bottom, 16.0); + expect(getHintRect(tester).top, 20.0); + expect(getHintRect(tester).bottom, 44.0); + expect(getHintOpacity(tester), 1.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 2.0); + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + visualDensity: VisualDensity.compact, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + await tester.pump(kTransitionDuration); + + // The decorator is focused and not empty, label and input are visible. + expect(getDecoratorRect(tester).size, const Size(800.0, 48.0)); + expect(getLabelRect(tester).top, 4.0); + expect(getLabelRect(tester).bottom, 16.0); + expect(getInputRect(tester).top, 20.0); + expect(getInputRect(tester).bottom, 44.0); + expect(getHintOpacity(tester), 0.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('InputDecorator respects increased theme visualDensity', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0), + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + // Overall height for this InputDecorator is 64dp: + // 12 - top padding (8 plus 4 due to increased visual density) + // 12 - floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - gap between label and input (this is not part of the M3 spec) + // 24 - input text (font size = 16, line height = 1.5) + // 12 - bottom padding (8 plus 4 due to increased visual density) + expect(getDecoratorRect(tester).size, const Size(800.0, 64.0)); + + // The decorator is empty, label is not floating and is vertically centered. + expect(getLabelRect(tester).top, 24.0); + expect(getLabelRect(tester).bottom, 40.0); + expect(getHintOpacity(tester), 0.0); + expect(getBorderBottom(tester), 64.0); + expect(getBorderWeight(tester), 1.0); + + // When the decorator is focused, label moves upwards, hint is visible (opacity 1.0). + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0), + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + await tester.pump(kTransitionDuration); + + // The decorator is empty and focused, label and hint are visible. + expect(getDecoratorRect(tester).size, const Size(800.0, 64.0)); + expect(getLabelRect(tester).top, 12.0); + expect(getLabelRect(tester).bottom, 24.0); + expect(getHintRect(tester).top, 28.0); + expect(getHintRect(tester).bottom, 52.0); + expect(getHintOpacity(tester), 1.0); + expect(getBorderBottom(tester), 64.0); + expect(getBorderWeight(tester), 2.0); + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0), + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + await tester.pump(kTransitionDuration); + + // The decorator is focused and not empty, label and input are visible. + expect(getDecoratorRect(tester).size, const Size(800.0, 64.0)); + expect(getLabelRect(tester).top, 12.0); + expect(getLabelRect(tester).bottom, 24.0); + expect(getInputRect(tester).top, 28.0); + expect(getInputRect(tester).bottom, 52.0); + expect(getHintOpacity(tester), 0.0); + expect(getBorderBottom(tester), 64.0); + expect(getBorderWeight(tester), 2.0); + }); + }); + + group('Material3 - InputDecoration label layout', () { + testWidgets('The label appears above input', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(label: customLabel)), + ); + + // Overall height for this InputDecorator is 56dp on mobile: + // 8 - top padding + // 12 - floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - gap between label and input (this is not part of the M3 spec) + // 24 - input text (font size = 16, line height = 1.5) + // 8 - bottom padding + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + expect(getCustomLabelRect(tester).top, 8.0); + expect(getCustomLabelRect(tester).bottom, 20.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + }); + + testWidgets('The label appears within the input when there is no text content', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator(isEmpty: true, decoration: const InputDecoration(label: customLabel)), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getCustomLabelRect(tester).top, 20.0); + expect(getCustomLabelRect(tester).bottom, 36.0); + }); + + testWidgets( + 'The label appears above the input when there is no content and floatingLabelBehavior is always', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + label: customLabel, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // 8 - top padding + // 12 - floating label height (font size = 16 * 0.75, line height is forced to 1.0) + expect(getCustomLabelRect(tester).top, 8.0); + expect(getCustomLabelRect(tester).bottom, 20.0); + }, + ); + + testWidgets( + 'The label appears within the input text when there is content and floatingLabelBehavior is never', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + label: customLabel, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getCustomLabelRect(tester).top, 20.0); + expect(getCustomLabelRect(tester).bottom, 36.0); + }, + ); + + testWidgets('Floating label animation duration and curve', (WidgetTester tester) async { + Future<void> pumpInputDecorator({required bool isFocused}) async { + return tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: isFocused, + decoration: const InputDecoration( + label: customLabel, + floatingLabelBehavior: FloatingLabelBehavior.auto, + ), + ), + ); + } + + await pumpInputDecorator(isFocused: false); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getCustomLabelRect(tester).top, 20.0); + + // The label animates upwards and scales down. + // The animation duration is 167ms and the curve is fastOutSlowIn. + await pumpInputDecorator(isFocused: true); + await tester.pump(const Duration(milliseconds: 42)); + expect(getCustomLabelRect(tester).top, closeTo(17.09, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getCustomLabelRect(tester).top, closeTo(10.66, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getCustomLabelRect(tester).top, closeTo(8.47, 0.5)); + await tester.pump(const Duration(milliseconds: 41)); + expect(getCustomLabelRect(tester).top, 8.0); + + // If the animation changes direction without first reaching the + // AnimationStatus.completed or AnimationStatus.dismissed status, + // the CurvedAnimation stays on the same curve in the opposite direction. + // The pumpAndSettle is used to prevent this behavior. + await tester.pumpAndSettle(); + + // The label animates downwards and scales up. + // The animation duration is 167ms and the curve is fastOutSlowIn. + await pumpInputDecorator(isFocused: false); + await tester.pump(const Duration(milliseconds: 42)); + expect(getCustomLabelRect(tester).top, closeTo(10.90, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getCustomLabelRect(tester).top, closeTo(17.34, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(getCustomLabelRect(tester).top, closeTo(19.69, 0.5)); + await tester.pump(const Duration(milliseconds: 41)); + expect(getCustomLabelRect(tester).top, 20.0); + }); + + testWidgets( + 'InputDecorationThemeData.floatingLabelStyle overrides label widget styles when the widget is a text widget (focused)', + (WidgetTester tester) async { + const style16 = TextStyle(fontSize: 16.0); + final TextStyle floatingLabelStyle = style16.merge(const TextStyle(color: Colors.indigo)); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + inputDecorationTheme: InputDecorationThemeData(floatingLabelStyle: floatingLabelStyle), + decoration: const InputDecoration(label: Text.rich(TextSpan(text: 'label'))), + ), + ); + + // Verify that the styles were passed along. + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + }, + ); + + testWidgets( + 'InputDecorationThemeData.labelStyle overrides label widget styles when the widget is a text widget', + (WidgetTester tester) async { + const styleDefaultSize = TextStyle(fontSize: 16.0); + final TextStyle labelStyle = styleDefaultSize.merge(const TextStyle(color: Colors.purple)); + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, // Label appears inline, on top of the input field. + inputDecorationTheme: InputDecorationThemeData(labelStyle: labelStyle), + decoration: const InputDecoration(label: Text.rich(TextSpan(text: 'label'))), + ), + ); + + // Verify that the styles were passed along. + expect(getLabelStyle(tester).color, labelStyle.color); + }, + ); + }); + + group('Material3 - InputDecoration hint', () { + group('for filled text field without label', () { + // Overall height for this InputDecorator is 48dp on mobile: + // 12 - Top padding + // 24 - Input and hint (font size = 16, line height = 1.5) + // 12 - Bottom padding + group('when field is enabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty and there is no label. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is disabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(enabled: false, filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty and there is no label. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(enabled: false, filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(enabled: false, filled: true, hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is hovered', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + isEmpty: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty and there is no label. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + isEmpty: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is focused', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty and focused. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration(filled: true, hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is in error', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty and there is no label. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + expect(getInputRect(tester).top, 12.0); + expect(getInputRect(tester).bottom, 36.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + }); + + group('for filled text field with label', () { + // Overall height for this InputDecorator is 56dp on mobile: + // 8 - Top padding + // 12 - Floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - Gap between label and input (this is not part of the M3 spec) + // 24 - Input/Hint (font size = 16, line height = 1.5) + // 8 - Bottom padding + group('when field is enabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + + group('when field is disabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + enabled: false, + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + enabled: false, + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + + group('when field is hovered', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + + group('when field is focused', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty and focused. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + ), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is in error', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 24.0); + expect(getInputRect(tester).bottom, 48.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + }); + + group('for outlined text field without label', () { + // Overall height for this InputDecorator is 56dp on mobile: + // 16 - Top padding + // 24 - Input and hint (font size = 16, line height = 1.5) + // 16 - Bottom padding + group('when field is enabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is disabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + enabled: false, + border: OutlineInputBorder(), + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + enabled: false, + border: OutlineInputBorder(), + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + enabled: false, + border: OutlineInputBorder(), + hintText: hintText, + ), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is hovered', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is focused', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration(border: OutlineInputBorder(), hintText: hintText), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is in error', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: hintText, + errorText: errorText, + ), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + }); + + group('for outlined text field with label', () { + // Overall height for this InputDecorator is 56dp on mobile: + // 16 - Top padding + // 24 - Input and hint (font size = 16, line height = 1.5) + // 16 - Bottom padding + group('when field is enabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + + group('when field is disabled', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + enabled: false, + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + enabled: false, + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + + group('when field is hovered', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + + group('when field is focused', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint has correct style when visible', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + ), + ), + ); + + // Hint is visible because decorator is empty. + expect(getHintOpacity(tester), 1.0); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodyLarge!.copyWith(color: expectedColor); + expect(getHintStyle(tester), expectedStyle); + }); + }); + + group('when field is in error', () { + testWidgets('hint and input align vertically when decorator is empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not focused (label is visible). + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hint and input align vertically when decorator is not empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + hintText: hintText, + errorText: errorText, + ), + ), + ); + + expect(getContainerRect(tester).height, 56.0); + expect(getInputRect(tester).top, 16.0); + expect(getInputRect(tester).bottom, 40.0); + expect(getHintRect(tester).top, getInputRect(tester).top); + expect(getHintRect(tester).bottom, getInputRect(tester).bottom); + // Hint is not visible because decorator is not empty. + expect(getHintOpacity(tester), 0.0); + }); + }); + }); + + group('InputDecoration.alignLabelWithHint', () { + testWidgets('positions InputDecoration.labelText vertically aligned with the hint', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + labelText: labelText, + alignLabelWithHint: true, + hintText: hintText, + ), + ), + ); + + // Label and hint should be vertically aligned. + expect(getLabelCenter(tester).dy, getHintCenter(tester).dy); + }); + + testWidgets('positions InputDecoration.label vertically aligned with the hint', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + label: customLabel, + alignLabelWithHint: true, + hintText: hintText, + ), + ), + ); + + // Label and hint should be vertically aligned. + expect(getCustomLabelCenter(tester).dy, getHintCenter(tester).dy); + }); + + group('in non-expanded multiline TextField', () { + testWidgets('positions the label correctly when strut is disabled', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final controller = TextEditingController(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: 8, + decoration: InputDecoration( + labelText: labelText, + alignLabelWithHint: alignLabelWithHint, + hintText: hintText, + ), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ), + ); + } + + // `alignLabelWithHint: false` centers the label vertically in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getDecoratorCenter(tester).dy); + + // Entering text still happens at the top. + await tester.enterText(find.byType(TextField), inputText); + expect(getInputRect(tester).top, 24.0); + controller.clear(); + focusNode.unfocus(); + + // `alignLabelWithHint: true` aligns the label vertically with the hint. + await tester.pumpWidget(buildFrame(true)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getHintCenter(tester).dy); + + // Entering text still happens at the top. + await tester.enterText(find.byType(TextField), inputText); + expect(getInputRect(tester).top, 24.0); + controller.clear(); + focusNode.unfocus(); + }); + + testWidgets('positions the label correctly when strut style is set to default', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final controller = TextEditingController(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: 8, + decoration: InputDecoration( + labelText: labelText, + alignLabelWithHint: alignLabelWithHint, + hintText: hintText, + ), + ), + ), + ), + ), + ); + } + + // `alignLabelWithHint: false` centers the label vertically in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getDecoratorCenter(tester).dy); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), inputText); + expect(getInputRect(tester).top, 24.0); + controller.clear(); + focusNode.unfocus(); + + // `alignLabelWithHint: true` aligns the label vertically with the hint. + await tester.pumpWidget(buildFrame(true)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getHintCenter(tester).dy); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), inputText); + expect(getInputRect(tester).top, 24.0); + controller.clear(); + focusNode.unfocus(); + }); + }); + + group('in expanded multiline TextField', () { + testWidgets('positions the label correctly', (WidgetTester tester) async { + final focusNode = FocusNode(); + final controller = TextEditingController(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: null, + expands: true, + decoration: InputDecoration( + labelText: labelText, + alignLabelWithHint: alignLabelWithHint, + hintText: hintText, + ), + ), + ), + ), + ), + ); + } + + // `alignLabelWithHint: false` centers the label vertically in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getDecoratorCenter(tester).dy); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), inputText); + expect(getInputRect(tester).top, 24.0); + controller.clear(); + focusNode.unfocus(); + + // alignLabelWithHint: true aligns the label vertically with the hint at the top. + await tester.pumpWidget(buildFrame(true)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getHintCenter(tester).dy); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), inputText); + expect(getInputRect(tester).top, 24.0); + controller.clear(); + focusNode.unfocus(); + }); + + testWidgets('positions the label correctly when border is outlined', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final controller = TextEditingController(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: null, + expands: true, + decoration: InputDecoration( + labelText: labelText, + alignLabelWithHint: alignLabelWithHint, + hintText: hintText, + border: const OutlineInputBorder(borderRadius: BorderRadius.zero), + ), + ), + ), + ), + ), + ); + } + + // `alignLabelWithHint: false` centers the label vertically in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pump(kTransitionDuration); + expect(getLabelCenter(tester).dy, getDecoratorCenter(tester).dy); + + // Entering text happens in the center as well. + await tester.enterText(find.byType(InputDecorator), inputText); + expect(getInputCenter(tester).dy, getDecoratorCenter(tester).dy); + controller.clear(); + focusNode.unfocus(); + + // `alignLabelWithHint: true` aligns keeps the label in the center because + // that's where the hint is. + await tester.pumpWidget(buildFrame(true)); + await tester.pump(kTransitionDuration); + + // On M3, hint centering is slightly wrong. + // TODO(bleroux): remove closeTo usage when this is fixed. + expect(getHintCenter(tester).dy, closeTo(getDecoratorCenter(tester).dy, 2.0)); + expect(getLabelCenter(tester).dy, getHintCenter(tester).dy); + + // Entering text still happens in the center. + await tester.enterText(find.byType(InputDecorator), inputText); + expect(getInputCenter(tester).dy, getDecoratorCenter(tester).dy); + controller.clear(); + focusNode.unfocus(); + }); + }); + + group('Horizontal alignment', () { + testWidgets('Label for outlined decoration aligns horizontally with prefixIcon by default', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/113537. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.ac_unit), + labelText: labelText, + border: OutlineInputBorder(), + ), + isFocused: true, + ), + ); + + // Label left padding is 16.0 (12.0 right padding for a decoration with icons + 4.0 extra padding for the floating label) + expect(getLabelRect(tester).left, 16.0); + // Based on M3 spec, the expected horizontal position is 52 (12 padding, 24 icon, 16 gap between icon and input). + // See https://m3.material.io/components/text-fields/specs#1ad2798c-ab41-4f0c-9a97-295ab9b37f33 + // (Note that the diagrams on the spec for outlined text field are wrong but the table for + // outlined text fields and the diagrams for filled text field point to these values). + expect(getInputRect(tester).left, 52.0); + }); + + testWidgets( + 'Label for outlined decoration aligns horizontally with input when alignLabelWithHint is true', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/113537. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.ac_unit), + labelText: labelText, + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + isFocused: true, + ), + ); + + expect(getLabelRect(tester).left, getInputRect(tester).left); + }, + ); + + testWidgets('Label for filled decoration is horizontally aligned with text by default', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/113537. + // See https://github.com/flutter/flutter/pull/115540. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.ac_unit), + labelText: labelText, + filled: true, + ), + isFocused: true, + ), + ); + + // Label and input are horizontally aligned despite `alignLabelWithHint` being false (default value). + // The reason is that `alignLabelWithHint` was initially intended for vertical alignment only. + // See https://github.com/flutter/flutter/pull/24993 which introduced `alignLabelWithHint` parameter. + // See https://github.com/flutter/flutter/pull/115409 which used `alignLabelWithHint` for + // horizontal alignment in outlined text field. + expect(getLabelRect(tester).left, getInputRect(tester).left); + }); + }); + }); + + group('hint opacity animation', () { + testWidgets('default duration', (WidgetTester tester) async { + // Build once without focus. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + // Hint is not visible (opacity 0.0). + expect(getHintOpacity(tester), 0.0); + + // Focus the decorator to trigger the animation. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 9)); + double hintOpacity9ms = getHintOpacity(tester); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + double hintOpacity18ms = getHintOpacity(tester); + expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0)); + + await tester.pump(kTransitionDuration); + // Hint is fully visible (opacity 1.0). + expect(getHintOpacity(tester), 1.0); + + // Unfocus the decorator to trigger the reversed animation. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 9)); + hintOpacity9ms = getHintOpacity(tester); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + hintOpacity18ms = getHintOpacity(tester); + expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms)); + }); + + testWidgets('custom duration', (WidgetTester tester) async { + // Build once without focus. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + labelText: labelText, + hintText: hintText, + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // Hint is not visible (opacity 0.0). + expect(getHintOpacity(tester), 0.0); + + // Focus the decorator to trigger the animation. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration( + labelText: labelText, + hintText: hintText, + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's duration is set to 120ms. + await tester.pump(const Duration(milliseconds: 50)); + double hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + double hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getHintOpacity(tester), 1.0); + + // Unfocus the decorator to trigger the reversed animation. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + labelText: labelText, + hintText: hintText, + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 50)); + hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('duration from theme', (WidgetTester tester) async { + // Build once without focus. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + inputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // Hint is not visible (opacity 0.0). + expect(getHintOpacity(tester), 0.0); + + // Focus the decorator to trigger the animation. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + inputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's duration is set to 120ms. + await tester.pump(const Duration(milliseconds: 50)); + double hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + double hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getHintOpacity(tester), 1.0); + + // Unfocus the decorator to trigger the reversed animation. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + inputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 50)); + hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getHintOpacity(tester), 0.0); + }); + }); + + testWidgets('InputDecorator throws Assertion Error when hint and hintText are provided', ( + WidgetTester tester, + ) async { + expect(() { + buildInputDecorator( + decoration: InputDecoration( + hintText: 'Enter text here', + hint: const Text('Enter text here', style: TextStyle(fontSize: 20.0)), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('InputDecorator shows hint widget', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(hint: Text('hint'))), + ); + expect(find.text('hint'), findsOneWidget); + }); + + testWidgets('hint style overflow works', (WidgetTester tester) async { + final String hintText = 'hint text' * 20; + const hintStyle = TextStyle(fontSize: 14.0, overflow: TextOverflow.fade); + final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle); + + await tester.pumpWidget(buildInputDecorator(decoration: decoration)); + await tester.pump(kTransitionDuration); + + final Finder hintTextFinder = find.text(hintText); + final Text hintTextWidget = tester.widget(hintTextFinder); + expect(hintTextWidget.style!.overflow, decoration.hintStyle!.overflow); + }); + + testWidgets('Widget height collapses from hint height when maintainHintSize is false', ( + WidgetTester tester, + ) async { + final String hintText = 'hint' * 20; + final decoration = InputDecoration( + hintText: hintText, + hintMaxLines: 3, + maintainHintSize: false, + ); + + await tester.pumpWidget(buildInputDecorator(decoration: decoration)); + expect(tester.getSize(find.byType(InputDecorator)).height, 48.0); + }); + + testWidgets('Widget height stays at hint height by default', (WidgetTester tester) async { + final String hintText = 'hint' * 20; + final decoration = InputDecoration(hintMaxLines: 3, hintText: hintText); + + await tester.pumpWidget(buildInputDecorator(decoration: decoration)); + final double hintHeight = tester.getSize(find.text(hintText)).height; + final double inputHeight = tester.getSize(find.byType(InputDecorator)).height; + expect(inputHeight, hintHeight + 16.0); + }); + + testWidgets('hintFadeDuration applies to hint fade-in when maintainHintSize is false', ( + WidgetTester tester, + ) async { + const decoration = InputDecoration( + hintText: hintText, + hintMaxLines: 3, + hintFadeDuration: Duration(milliseconds: 120), + maintainHintSize: false, + ); + + // Build once with empty content. + await tester.pumpWidget(buildInputDecorator(decoration: decoration)); + + // Hint is not exist. + expect(find.text(hintText), findsNothing); + + // Rebuild with empty content. + await tester.pumpWidget(buildInputDecorator(isEmpty: true, decoration: decoration)); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0)); + await tester.pump(const Duration(milliseconds: 20)); + final double hintOpacity120ms = getHintOpacity(tester); + expect(hintOpacity120ms, 1.0); + }); + + testWidgets('hintFadeDuration applies to hint fade-out when maintainHintSize is false', ( + WidgetTester tester, + ) async { + const decoration = InputDecoration( + hintText: hintText, + hintMaxLines: 3, + hintFadeDuration: Duration(milliseconds: 120), + maintainHintSize: false, + ); + + // Build once with empty content. + await tester.pumpWidget(buildInputDecorator(isEmpty: true, decoration: decoration)); + + // Hint is visible (opacity 1.0). + expect(getHintOpacity(tester), 1.0); + + // Rebuild with non-empty content. + await tester.pumpWidget(buildInputDecorator(decoration: decoration)); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); + await tester.pump(const Duration(milliseconds: 20)); + final double hintOpacity120ms = getHintOpacity(tester); + expect(hintOpacity120ms, 0); + await tester.pump(const Duration(milliseconds: 1)); + // The hintText replaced with SizeBox. + expect(find.text(hintText), findsNothing); + }); + + testWidgets('Hint does not change position when maintainHintSize is false - LTR', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator(isEmpty: true, decoration: const InputDecoration(hintText: hintText)), + ); + final double expectedHintLeft = getHintRect(tester).left; + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(hintText: hintText, maintainHintSize: false), + ), + ); + expect(getHintRect(tester).left, expectedHintLeft); + }); + + testWidgets('Hint does not change position when maintainHintSize is false - RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(hintText: hintText), + textDirection: TextDirection.rtl, + ), + ); + final double expectedHintRight = getHintRect(tester).right; + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration(hintText: hintText, maintainHintSize: false), + textDirection: TextDirection.rtl, + ), + ); + expect(getHintRect(tester).right, expectedHintRight); + }); + }); + + group('Material3 - InputDecoration helper/counter/error', () { + // Overall height for InputDecorator (filled or outlined) is 76dp on mobile: + // 8 - top padding + // 12 - floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - gap between label and input + // 24 - input text (font size = 16, line height = 1.5) + // 8 - bottom padding + // 4 - gap above helper/error/counter + // 16 - helper/counter (font size = 12, line height is 1.5) + const topPadding = 8.0; + const floatingLabelHeight = 12.0; + const labelInputGap = 4.0; + const inputHeight = 24.0; + const bottomPadding = 8.0; + const helperGap = 4.0; + const helperHeight = 16.0; + const double containerHeight = + topPadding + floatingLabelHeight + labelInputGap + inputHeight + bottomPadding; // 56.0 + const double fullHeight = containerHeight + helperGap + helperHeight; // 76.0 + const errorHeight = helperHeight; + const hintHeight = inputHeight; + const helperStartPadding = 16.0; + const counterEndPadding = 16.0; + + group('for filled text field', () { + group('when field is enabled', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is disabled', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is hovered', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is focused', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is in error', () { + testWidgets('Error and counter are visible, helper is not visible', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + errorText: errorText, + ), + ), + ); + + expect(findError(), findsOneWidget); + expect(findCounter(), findsOneWidget); + expect(findHelper(), findsNothing); + }); + + testWidgets('Error and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + errorText: errorText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getErrorRect(tester).top, containerHeight + helperGap); + expect(getErrorRect(tester).height, errorHeight); + expect(getErrorRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, errorHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Error and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.error; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getErrorStyle(tester), expectedStyle); + final Color expectedCounterColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedCounterStyle = theme.textTheme.bodySmall!.copyWith( + color: expectedCounterColor, + ); + expect(getCounterStyle(tester), expectedCounterStyle); + }); + }); + }); + + group('for outlined text field', () { + group('when field is enabled', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is disabled', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is hovered', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is focused', () { + testWidgets('Helper and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getHelperRect(tester).top, containerHeight + helperGap); + expect(getHelperRect(tester).height, helperHeight); + expect(getHelperRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, helperHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + }); + + group('when field is in error', () { + testWidgets('Error and counter are visible, helper is not visible', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + errorText: errorText, + ), + ), + ); + + expect(findHelper(), findsNothing); + expect(findError(), findsOneWidget); + expect(findCounter(), findsOneWidget); + }); + + testWidgets('Error and counter are correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + errorText: errorText, + ), + ), + ); + + expect(getDecoratorRect(tester).height, fullHeight); + expect(getBorderBottom(tester), containerHeight); + expect(getErrorRect(tester).top, containerHeight + helperGap); + expect(getErrorRect(tester).height, errorHeight); + expect(getErrorRect(tester).left, helperStartPadding); + expect(getCounterRect(tester).top, containerHeight + helperGap); + expect(getCounterRect(tester).height, errorHeight); + expect(getCounterRect(tester).right, 800 - counterEndPadding); + }); + + testWidgets('Error and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + helperText: helperText, + counterText: counterText, + errorText: errorText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.error; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getErrorStyle(tester), expectedStyle); + final Color expectedCounterColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedCounterStyle = theme.textTheme.bodySmall!.copyWith( + color: expectedCounterColor, + ); + expect(getCounterStyle(tester), expectedCounterStyle); + }); + }); + }); + + group('Multiline error/helper', () { + testWidgets('Error height grows to accommodate error text', (WidgetTester tester) async { + const maxLines = 3; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + errorText: threeLines, + errorMaxLines: maxLines, + filled: true, + ), + ), + ); + + final Rect errorRect = tester.getRect(find.text(threeLines)); + expect(errorRect.height, closeTo(errorHeight * maxLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + errorHeight * maxLines, 0.25), + ); + }); + + testWidgets('Error height is correct when errorMaxLines is restricted', ( + WidgetTester tester, + ) async { + const maxLines = 2; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + errorText: threeLines, + errorMaxLines: maxLines, + filled: true, + ), + ), + ); + + final Rect errorRect = tester.getRect(find.text(threeLines)); + expect(errorRect.height, closeTo(errorHeight * maxLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + errorHeight * maxLines, 0.25), + ); + }); + + testWidgets( + 'Error height is correct when errorMaxLines is bigger than the number of lines in errorText', + (WidgetTester tester) async { + const numberOfLines = 2; + const maxLines = 3; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + errorText: twoLines, + errorMaxLines: maxLines, + filled: true, + ), + ), + ); + + final Rect errorRect = tester.getRect(find.text(twoLines)); + expect(errorRect.height, closeTo(errorHeight * numberOfLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + errorHeight * numberOfLines, 0.25), + ); + }, + ); + + testWidgets('Error height is not limited by default', (WidgetTester tester) async { + const numberOfLines = 3; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + errorText: threeLines, + filled: true, + ), + ), + ); + + final Rect errorRect = tester.getRect(find.text(threeLines)); + expect(errorRect.height, closeTo(errorHeight * numberOfLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + errorHeight * numberOfLines, 0.25), + ); + }); + + testWidgets('InputDecorationThemeData.hintMaxLines behaves as default value', ( + WidgetTester tester, + ) async { + const numberOfLines = 2; + await tester.pumpWidget( + buildInputDecorator( + inputDecorationTheme: const InputDecorationThemeData(hintMaxLines: numberOfLines), + decoration: const InputDecoration(hintText: threeLines), + ), + ); + + final Rect hintRect = tester.getRect(find.text(threeLines)); + expect(hintRect.height, closeTo(hintHeight * numberOfLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(topPadding + hintHeight * numberOfLines + bottomPadding, 0.25), + ); + }); + + testWidgets('InputDecoration hintMaxLines default expands with hintText', ( + WidgetTester tester, + ) async { + const numberOfLines = 3; + await tester.pumpWidget( + buildInputDecorator( + inputDecorationTheme: const InputDecorationThemeData(), + decoration: const InputDecoration(hintText: threeLines), + ), + ); + + final Size hintSize = tester.getSize(find.byType(InputDecorator)); + expect(hintSize.height, topPadding + hintHeight * numberOfLines + bottomPadding); + }); + + testWidgets('Helper height grows to accommodate helper text', (WidgetTester tester) async { + const maxLines = 3; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + helperText: threeLines, + helperMaxLines: maxLines, + filled: true, + ), + ), + ); + + final Rect helperRect = tester.getRect(find.text(threeLines)); + expect(helperRect.height, closeTo(helperHeight * maxLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + helperHeight * maxLines, 0.25), + ); + }); + + testWidgets('Helper height is correct when maxLines is restricted', ( + WidgetTester tester, + ) async { + const maxLines = 2; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + helperText: threeLines, + helperMaxLines: maxLines, + filled: true, + ), + ), + ); + + final Rect helperRect = tester.getRect(find.text(threeLines)); + expect(helperRect.height, closeTo(helperHeight * maxLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + helperHeight * maxLines, 0.25), + ); + }); + + testWidgets( + 'Helper height is correct when helperMaxLines is bigger than the number of lines in helperText', + (WidgetTester tester) async { + const numberOfLines = 2; + const maxLines = 3; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + helperText: twoLines, + helperMaxLines: maxLines, + filled: true, + ), + ), + ); + + final Rect helperRect = tester.getRect(find.text(twoLines)); + expect(helperRect.height, closeTo(helperHeight * numberOfLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + helperHeight * numberOfLines, 0.25), + ); + }, + ); + + testWidgets('Helper height is not limited by default', (WidgetTester tester) async { + const numberOfLines = 3; + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + labelText: 'label', + helperText: threeLines, + filled: true, + ), + ), + ); + + final Rect helperRect = tester.getRect(find.text(threeLines)); + expect(helperRect.height, closeTo(helperHeight * numberOfLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + helperHeight * numberOfLines, 0.25), + ); + }); + }); + + group('Helper widget', () { + testWidgets('InputDecorator shows helper widget', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + helper: Text('helper', style: TextStyle(fontSize: 20.0)), + ), + ), + ); + + expect(find.text('helper'), findsOneWidget); + }); + + testWidgets('InputDecorator throws when helper text and helper widget are provided', ( + WidgetTester tester, + ) async { + expect(() { + buildInputDecorator( + decoration: InputDecoration( + helperText: 'helperText', + helper: const Text('helper', style: TextStyle(fontSize: 20.0)), + ), + ); + }, throwsAssertionError); + }); + }); + + group('Error widget', () { + testWidgets('InputDecorator shows error widget', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + error: Text('error', style: TextStyle(fontSize: 20.0)), + ), + ), + ); + + expect(find.text('error'), findsOneWidget); + }); + + testWidgets('InputDecorator throws when error text and error widget are provided', ( + WidgetTester tester, + ) async { + expect(() { + buildInputDecorator( + decoration: InputDecoration( + errorText: 'errorText', + error: const Text('error', style: TextStyle(fontSize: 20.0)), + ), + ); + }, throwsAssertionError); + }); + + // Regression test for https://github.com/flutter/flutter/issues/174784. + testWidgets('InputDecorator error widget text style defaults to errorStyle', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(error: Text(errorText))), + ); + + expect(findError(), findsOneWidget); + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.error; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getErrorStyle(tester), expectedStyle); + }); + }); + + testWidgets('InputDecorator with counter does not crash when given a 0 size', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/129611 + await tester.pumpWidget( + Center( + child: SizedBox.square( + dimension: 0.0, + child: buildInputDecorator( + decoration: const InputDecoration( + contentPadding: EdgeInsetsDirectional.all(99), + prefixIcon: Focus(child: Icon(Icons.search)), + counter: Text('COUNTER'), + ), + ), + ), + ), + ); + await tester.pump(kTransitionDuration); + + expect(find.byType(InputDecorator), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.text('COUNTER')).size, Size.zero); + }); + }); + + group('Material3 - InputDecoration constraints', () { + testWidgets('No InputDecorator constraints', (WidgetTester tester) async { + await tester.pumpWidget(buildInputDecorator()); + + // Should fill the screen width and be default height. + expect(getDecoratorRect(tester).size, const Size(800, kMinInteractiveDimension)); + }); + + testWidgets('InputDecoratorThemeData constraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + constraints: BoxConstraints(maxWidth: 300, maxHeight: 40), + ), + ), + ), + ); + + // Theme settings should make it 300x40 pixels. + expect(getDecoratorRect(tester).size, const Size(300, 40)); + }); + + testWidgets('InputDecorator constraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + constraints: BoxConstraints(maxWidth: 300, maxHeight: 40), + ), + ), + decoration: const InputDecoration( + constraints: BoxConstraints(maxWidth: 200, maxHeight: 32), + ), + ), + ); + + // InputDecoration.constraints should override the theme. It should be + // only 200x32 pixels. + expect(getDecoratorRect(tester).size, const Size(200, 32)); + }); + }); + + group('Material3 - InputDecoration prefix/suffix', () { + const IconData prefixIcon = Icons.search; + const IconData suffixIcon = Icons.cancel_outlined; + + Finder findPrefixIcon() { + return find.byIcon(prefixIcon); + } + + Rect getPrefixIconRect(WidgetTester tester) { + return tester.getRect(findPrefixIcon()); + } + + Finder findPrefixIconInnerRichText() { + return find.descendant(of: findPrefixIcon(), matching: find.byType(RichText)); + } + + TextStyle getPrefixIconStyle(WidgetTester tester) { + return tester.widget<RichText>(findPrefixIconInnerRichText()).text.style!; + } + + Finder findSuffixIcon() { + return find.byIcon(suffixIcon); + } + + Rect getSuffixIconRect(WidgetTester tester) { + return tester.getRect(findSuffixIcon()); + } + + Finder findSuffixIconInnerRichText() { + return find.descendant(of: findSuffixIcon(), matching: find.byType(RichText)); + } + + TextStyle getSuffixIconStyle(WidgetTester tester) { + return tester.widget<RichText>(findSuffixIconInnerRichText()).text.style!; + } + + group('for filled text field', () { + group('when field is enabled', () { + testWidgets('prefixIcon is correctly positioned - LTR', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon is correctly positioned - RTL', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + textDirection: TextDirection.rtl, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findPrefixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findPrefixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned - LTR', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon is correctly positioned - RTL', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + textDirection: TextDirection.rtl, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findSuffixIconInnerRichText()).left, 12.0); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findSuffixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is disabled', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + prefixIcon: Icon(prefixIcon), + labelText: labelText, + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + enabled: false, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is hovered', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is focused', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is in error', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('prefixIcon has correct color when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.error; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon has correct color when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + errorText: errorText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onErrorContainer; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + }); + + group('for outlined text field', () { + group('when field is enabled', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is disabled', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + prefixIcon: Icon(prefixIcon), + labelText: labelText, + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + enabled: false, + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurface.withOpacity(0.38); + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is hovered', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is focused', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Prefix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + + group('when field is in error', () { + testWidgets('prefixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + // By default, the prefix icon is rendered inside a 48x48 constrained box. + expect(getPrefixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getPrefixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getPrefixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Left padding is 12 per Material 3 spec. + expect(tester.getRect(findPrefixIconInnerRichText()).left, 12.0); + // Check the padding between the prefix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + getInputRect(tester).left - tester.getRect(findPrefixIconInnerRichText()).right, + 16.0, + ); + }); + + testWidgets('prefixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('prefixIcon has correct color when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + prefixIcon: Icon(prefixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findPrefixIcon())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + expect(getPrefixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon is correctly positioned', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + // By default, the suffix icon is rendered inside a 48x48 constrained box. + expect(getSuffixIconRect(tester).size, const Size(48.0, 48.0)); + // The icon size is 24 per Material 3 spec. + expect(getSuffixIconStyle(tester).fontSize, 24.0); + // Suffix icon is vertically centered inside the container. + expect(getSuffixIconRect(tester).center.dy, getContainerRect(tester).center.dy); + // Right padding is 12 per Material 3 spec. + expect( + getDecoratorRect(tester).right - tester.getRect(findSuffixIconInnerRichText()).right, + 12.0, + ); + // Check the padding between the suffix icon and the input. + // The gap between the icon and the input should be 16 based on M3 specification. + expect( + tester.getRect(findSuffixIconInnerRichText()).left - getInputRect(tester).right, + 16.0, + ); + }); + + testWidgets('suffixIcon has correct color', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.error; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + + testWidgets('suffixIcon has correct color when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isHovering: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + errorText: errorText, + suffixIcon: Icon(suffixIcon), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findSuffixIcon())); + final Color expectedColor = theme.colorScheme.onErrorContainer; + expect(getSuffixIconStyle(tester).color, expectedColor); + }); + }); + }); + + testWidgets('InputDecorator iconColor/prefixIconColor/suffixIconColor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField( + decoration: InputDecoration( + icon: Icon(Icons.cabin), + prefixIcon: Icon(Icons.sailing), + suffixIcon: Icon(Icons.close), + iconColor: Colors.amber, + prefixIconColor: Colors.green, + suffixIconColor: Colors.red, + filled: true, + ), + ), + ), + ), + ); + + expect( + tester.widget<IconTheme>(find.widgetWithIcon(IconTheme, Icons.cabin).first).data.color, + Colors.amber, + ); + expect( + tester.widget<IconTheme>(find.widgetWithIcon(IconTheme, Icons.sailing).first).data.color, + Colors.green, + ); + expect( + tester.widget<IconTheme>(find.widgetWithIcon(IconTheme, Icons.close).first).data.color, + Colors.red, + ); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139916. + testWidgets('Prefix ignores pointer when hidden', (WidgetTester tester) async { + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return TextField( + decoration: InputDecoration( + labelText: 'label', + prefix: GestureDetector( + onTap: () { + setState(() { + tapped = true; + }); + }, + child: const Icon(Icons.search), + ), + ), + ); + }, + ), + ), + ), + ); + + expect(tapped, isFalse); + + double prefixOpacity = tester + .widget<AnimatedOpacity>( + find.ancestor(of: find.byType(Icon), matching: find.byType(AnimatedOpacity)), + ) + .opacity; + + // Initially the prefix icon should be hidden. + expect(prefixOpacity, 0.0); + + await tester.tap(find.byType(Icon), warnIfMissed: false); // Not expected to find the target. + await tester.pump(); + + // The suffix icon should ignore pointer events when hidden. + expect(tapped, isFalse); + + // Tap the text field to show the prefix icon. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + prefixOpacity = tester + .widget<AnimatedOpacity>( + find.ancestor(of: find.byType(Icon), matching: find.byType(AnimatedOpacity)), + ) + .opacity; + + // The prefix icon should be visible. + expect(prefixOpacity, 1.0); + + // Tap the prefix icon. + await tester.tap(find.byType(Icon)); + await tester.pump(); + + // The prefix icon should be tapped. + expect(tapped, isTrue); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139916. + testWidgets('Suffix ignores pointer when hidden', (WidgetTester tester) async { + var tapped = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return TextField( + decoration: InputDecoration( + labelText: 'label', + suffix: GestureDetector( + onTap: () { + setState(() { + tapped = true; + }); + }, + child: const Icon(Icons.search), + ), + ), + ); + }, + ), + ), + ), + ); + + expect(tapped, isFalse); + + double suffixOpacity = tester + .widget<AnimatedOpacity>( + find.ancestor(of: find.byType(Icon), matching: find.byType(AnimatedOpacity)), + ) + .opacity; + + // Initially the suffix icon should be hidden. + expect(suffixOpacity, 0.0); + + await tester.tap(find.byType(Icon), warnIfMissed: false); // Not expected to find the target. + await tester.pump(); + + // The suffix icon should ignore pointer events when hidden. + expect(tapped, isFalse); + + // Tap the text field to show the suffix icon. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + suffixOpacity = tester + .widget<AnimatedOpacity>( + find.ancestor(of: find.byType(Icon), matching: find.byType(AnimatedOpacity)), + ) + .opacity; + + // The suffix icon should be visible. + expect(suffixOpacity, 1.0); + + // Tap the suffix icon. + await tester.tap(find.byType(Icon)); + await tester.pump(); + + // The suffix icon should be tapped. + expect(tapped, isTrue); + }); + }); + + group('Material3 - InputDecoration collapsed', () { + // Overall height for a collapsed InputDecorator is 24dp which is the input + // height (font size = 16, line height = 1.5). + const inputHeight = 24.0; + + testWidgets('Decoration height is set to input height on mobile', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration.collapsed(hintText: hintText)), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, inputHeight)); + expect(getInputRect(tester).height, inputHeight); + expect(getInputRect(tester).top, 0.0); + expect(getHintOpacity(tester), 0.0); + + // The hint should appear. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration.collapsed(hintText: hintText), + ), + ); + await tester.pumpAndSettle(); + + expect(getDecoratorRect(tester).size, const Size(800.0, inputHeight)); + expect(getInputRect(tester).height, inputHeight); + expect(getInputRect(tester).top, 0.0); + expect(getHintOpacity(tester), 1.0); + expect(getHintRect(tester).height, inputHeight); + expect(getHintRect(tester).top, 0.0); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets('Decoration height is set to input height on desktop', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/150763. + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration.collapsed(hintText: hintText)), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, inputHeight)); + expect(getInputRect(tester).height, inputHeight); + expect(getInputRect(tester).top, 0.0); + expect(getHintOpacity(tester), 0.0); + + // The hint should appear. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration.collapsed(hintText: hintText), + ), + ); + await tester.pumpAndSettle(); + + expect(getDecoratorRect(tester).size, const Size(800.0, inputHeight)); + expect(getInputRect(tester).height, inputHeight); + expect(getInputRect(tester).top, 0.0); + expect(getHintOpacity(tester), 1.0); + expect(getHintRect(tester).height, inputHeight); + expect(getHintRect(tester).top, 0.0); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('InputDecoration.collapsed defaults to no border', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration.collapsed(hintText: hintText)), + ); + + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets('InputDecoration.collapsed accepts constraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration.collapsed( + hintText: hintText, + constraints: BoxConstraints.tightFor(width: 200.0, height: 32.0), + ), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(200.0, 32.0)); + }); + + testWidgets('InputDecoration.collapsed accepts hintMaxLines', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration.collapsed(hintText: threeLines, hintMaxLines: 2), + ), + ); + + const hintLineHeight = 24.0; // font size = 16 and font height = 1.5. + expect(getDecoratorRect(tester).size, const Size(800.0, 2 * hintLineHeight)); + }); + + testWidgets('InputDecoration.collapsed accepts hintFadeDuration', (WidgetTester tester) async { + // Build once with empty content. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration.collapsed( + hintText: hintText, + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // Hint is visible (opacity 1.0). + expect(getHintOpacity(tester), 1.0); + + // Rebuild with non-empty content. + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration.collapsed( + hintText: hintText, + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getHintOpacity(tester); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getHintOpacity(tester); + expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getHintOpacity(tester), 0.0); + }); + + test('InputDecorationThemeData.isCollapsed is applied', () { + final InputDecoration decoration = const InputDecoration( + hintText: 'Hello, Flutter!', + ).applyDefaults(const InputDecorationThemeData(isCollapsed: true)); + + expect(decoration.isCollapsed, true); + }); + + test('InputDecorationThemeData.isCollapsed defaults to false', () { + final InputDecoration decoration = const InputDecoration( + hintText: 'Hello, Flutter!', + ).applyDefaults(const InputDecorationThemeData()); + + expect(decoration.isCollapsed, false); + }); + + test('InputDecorationThemeData.isCollapsed can be overridden', () { + final InputDecoration decoration = const InputDecoration( + isCollapsed: true, + hintText: 'Hello, Flutter!', + ).applyDefaults(const InputDecorationThemeData()); + + expect(decoration.isCollapsed, true); + }); + }); + + group('Material3 - InputDecoration floatingLabelAlignment', () { + group('LTR with icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + ), + ); + // icon (40) + contentPadding (40) + _kInputExtraPadding + expect(getLabelRect(tester).left, 84.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // icon (40) + contentPadding (40) + _kInputExtraPadding + expect(getLabelRect(tester).left, 84.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + ), + ); + // icon (40) + (decorator (800) - icon (40)) / 2 + expect(getLabelCenter(tester).dx, 420.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // icon (40) + (decorator (800) - icon (40)) / 2 + expect(getLabelCenter(tester).dx, 420.0); + }); + }); + + group('LTR without icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + ), + ); + // contentPadding (40) + _kInputExtraPadding + expect(getLabelRect(tester).left, 44.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // contentPadding (40) + _kInputExtraPadding + expect(getLabelRect(tester).left, 44.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + ), + ); + // decorator (800) / 2 + expect(getLabelCenter(tester).dx, 400.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // decorator (800) / 2 + expect(getLabelCenter(tester).dx, 400.0); + }); + }); + + group('RTL with icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + ), + ); + // decorator (800) - icon (40) - contentPadding (40) - _kInputExtraPadding + expect(getLabelRect(tester).right, 716.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // decorator (800) - icon (40) - contentPadding (40) - _kInputExtraPadding + expect(getLabelRect(tester).right, 716.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + ), + ); + // (decorator (800) - icon (40)) / 2 + expect(getLabelCenter(tester).dx, 380.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // (decorator (800) - icon (40)) / 2 + expect(getLabelCenter(tester).dx, 380.0); + }); + }); + + group('RTL without icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + ), + ); + // decorator (800) - contentPadding (40) - _kInputExtraPadding + expect(getLabelRect(tester).right, 756.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // decorator (800) - contentPadding (40) - _kInputExtraPadding + expect(getLabelRect(tester).right, 756.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + ), + ); + // decorator (800) / 2 + expect(getLabelCenter(tester).dx, 400.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // decorator (800) / 2 + expect(getLabelCenter(tester).dx, 400.0); + }); + }); + }); + + group('Material3 - InputDecoration isDense', () { + // M3 extra horizontal padding. + const kInputExtraPadding = 4.0; + + testWidgets('Dense layout for an outlined decoration', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: labelText, + isDense: true, + ), + ), + ); + + final Rect containerRect = getContainerRect(tester); + final Rect inputRect = getInputRect(tester); + + // Content padding is EdgeInsetsDirectional.fromSTEB(12.0, 16.0, 12.0, 8.0). + // Vertical padding affects the container height not the input vertical + // position as the input is centered. + expect(containerRect.height, 48.0); + expect(inputRect.center.dy, containerRect.center.dy); + expect(inputRect.left, containerRect.left + 12.0 + kInputExtraPadding); + expect(inputRect.right, containerRect.right - 12.0 - kInputExtraPadding); + }); + + testWidgets('Dense layout for a filled and non-outlined decoration', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(filled: true, labelText: labelText, isDense: true), + ), + ); + + final Rect containerRect = getContainerRect(tester); + final Rect inputRect = getInputRect(tester); + final Rect labelRect = getLabelRect(tester); + + // Content padding is EdgeInsetsDirectional.fromSTEB(12.0, 4.0, 12.0, 4.0). + expect(containerRect.height, 48.0); + expect(labelRect.top, containerRect.top + 4.0); + expect(inputRect.bottom, containerRect.bottom - 4.0); + expect(inputRect.left, containerRect.left + 12.0 + kInputExtraPadding); + expect(inputRect.right, containerRect.right - 12.0 - kInputExtraPadding); + }); + + testWidgets('Dense layout for a non-filled and non-outlined decoration', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(labelText: labelText, isDense: true)), + ); + + final Rect containerRect = getContainerRect(tester); + final Rect inputRect = getInputRect(tester); + final Rect labelRect = getLabelRect(tester); + + // Content padding is EdgeInsetsDirectional.fromSTEB(0.0, 4.0, 0.0, 4.0). + expect(containerRect.height, 48.0); + expect(labelRect.top, containerRect.top + 4.0); + expect(inputRect.bottom, containerRect.bottom - 4.0); + expect(inputRect.left, containerRect.left); + expect(inputRect.right, containerRect.right); + }); + + testWidgets('Ambient theme is used', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + inputDecorationTheme: const InputDecorationThemeData(isDense: true), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + }); + }); + + testWidgets('InputDecorator counter text, widget, and null', (WidgetTester tester) async { + Widget buildFrame({ + InputCounterWidgetBuilder? buildCounter, + String? counterText, + Widget? counter, + int? maxLength, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + TextFormField( + buildCounter: buildCounter, + maxLength: maxLength, + decoration: InputDecoration(counterText: counterText, counter: counter), + ), + ], + ), + ), + ), + ); + } + + // When counter, counterText, and buildCounter are null, defaults to showing + // the built-in counter. + int? maxLength = 10; + await tester.pumpWidget(buildFrame(maxLength: maxLength)); + Finder counterFinder = find.byType(Text); + expect(counterFinder, findsOneWidget); + final Text counterWidget = tester.widget(counterFinder); + expect(counterWidget.data, '0/$maxLength'); + + // When counter, counterText, and buildCounter are set, shows the counter + // widget. + final Key counterKey = UniqueKey(); + final Key buildCounterKey = UniqueKey(); + const counterText = 'I show instead of count'; + final Widget counter = Text('hello', key: counterKey); + Widget buildCounter( + BuildContext context, { + required int currentLength, + required int? maxLength, + required bool isFocused, + }) { + return Text('$currentLength of $maxLength', key: buildCounterKey); + } + + await tester.pumpWidget( + buildFrame( + counterText: counterText, + counter: counter, + buildCounter: buildCounter, + maxLength: maxLength, + ), + ); + counterFinder = find.byKey(counterKey); + expect(counterFinder, findsOneWidget); + expect(find.text(counterText), findsNothing); + expect(find.byKey(buildCounterKey), findsNothing); + + // When counter is null but counterText and buildCounter are set, shows the + // counterText. + await tester.pumpWidget( + buildFrame(counterText: counterText, buildCounter: buildCounter, maxLength: maxLength), + ); + expect(find.text(counterText), findsOneWidget); + counterFinder = find.byKey(counterKey); + expect(counterFinder, findsNothing); + expect(find.byKey(buildCounterKey), findsNothing); + + // When counter and counterText are null but buildCounter is set, shows the + // generated widget. + await tester.pumpWidget(buildFrame(buildCounter: buildCounter, maxLength: maxLength)); + expect(find.byKey(buildCounterKey), findsOneWidget); + expect(counterFinder, findsNothing); + expect(find.text(counterText), findsNothing); + + // When counterText is empty string and counter and buildCounter are null, + // shows nothing. + await tester.pumpWidget(buildFrame(counterText: '', maxLength: maxLength)); + expect(find.byType(Text), findsNothing); + + // When no maxLength, can still show a counter + maxLength = null; + await tester.pumpWidget(buildFrame(buildCounter: buildCounter, maxLength: maxLength)); + expect(find.byKey(buildCounterKey), findsOneWidget); + }); + + testWidgets('FloatingLabelAlignment.toString()', (WidgetTester tester) async { + expect(FloatingLabelAlignment.start.toString(), 'FloatingLabelAlignment.start'); + expect(FloatingLabelAlignment.center.toString(), 'FloatingLabelAlignment.center'); + }); + + testWidgets('InputDecorator.toString()', (WidgetTester tester) async { + const Widget child = InputDecorator( + key: Key('key'), + decoration: InputDecoration(), + baseStyle: TextStyle(), + textAlign: TextAlign.center, + child: Placeholder(), + ); + expect( + child.toString(), + "InputDecorator-[<'key'>](decoration: InputDecoration(), baseStyle: TextStyle(<all styles inherited>), isFocused: false, isEmpty: false)", + ); + }); + + testWidgets('InputDecorator.debugDescribeChildren', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration( + icon: Text('icon'), + labelText: 'label', + hintText: 'hint', + prefixText: 'prefix', + suffixText: 'suffix', + prefixIcon: Text('prefixIcon'), + suffixIcon: Text('suffixIcon'), + helperText: 'helper', + counterText: 'counter', + ), + child: const Text('text'), + ), + ); + + // Find the _RenderDecoration render object (which may be wrapped by Semantics) + RenderObject renderer = tester.renderObject(find.byType(InputDecorator)); + // If wrapped by Semantics, walk down to find the actual _RenderDecoration + while (renderer.debugDescribeChildren().length == 1 && + renderer.debugDescribeChildren().first.name == 'child') { + renderer = renderer.debugDescribeChildren().first.value! as RenderObject; + } + final Iterable<String> nodeNames = renderer.debugDescribeChildren().map( + (DiagnosticsNode node) => node.name!, + ); + expect( + nodeNames, + unorderedEquals(<String>[ + 'container', + 'counter', + 'helperError', + 'hint', + 'icon', + 'input', + 'label', + 'prefix', + 'prefixIcon', + 'suffix', + 'suffixIcon', + ]), + ); + + final nodeValues = Set<Object>.of( + renderer.debugDescribeChildren().map<Object>((DiagnosticsNode node) => node.value!), + ); + expect(nodeValues.length, 11); + }); + + testWidgets('InputDecoration.applyDefaults initializes empty field', (WidgetTester tester) async { + const themeStyle = TextStyle(color: Color(0xFF00FFFF)); + const themeColor = Color(0xFF00FF00); + const InputBorder themeInputBorder = OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFF0000FF)), + ); + + final InputDecoration decoration = const InputDecoration().applyDefaults( + const InputDecorationThemeData( + labelStyle: themeStyle, + floatingLabelStyle: themeStyle, + helperStyle: themeStyle, + helperMaxLines: 2, + hintStyle: themeStyle, + errorStyle: themeStyle, + errorMaxLines: 2, + floatingLabelBehavior: FloatingLabelBehavior.never, + floatingLabelAlignment: FloatingLabelAlignment.center, + isDense: true, + contentPadding: EdgeInsets.all(1.0), + iconColor: themeColor, + prefixStyle: themeStyle, + prefixIconColor: themeColor, + prefixIconConstraints: BoxConstraints( + minWidth: 10, + maxWidth: 10, + minHeight: 30, + maxHeight: 30, + ), + suffixStyle: themeStyle, + suffixIconColor: themeColor, + suffixIconConstraints: BoxConstraints( + minWidth: 20, + maxWidth: 20, + minHeight: 40, + maxHeight: 40, + ), + counterStyle: themeStyle, + filled: true, + fillColor: themeColor, + focusColor: themeColor, + hoverColor: themeColor, + errorBorder: themeInputBorder, + focusedBorder: themeInputBorder, + focusedErrorBorder: themeInputBorder, + disabledBorder: themeInputBorder, + enabledBorder: themeInputBorder, + border: InputBorder.none, + alignLabelWithHint: true, + constraints: BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40), + ), + ); + + expect(decoration.labelStyle, themeStyle); + expect(decoration.floatingLabelStyle, themeStyle); + expect(decoration.helperStyle, themeStyle); + expect(decoration.helperMaxLines, 2); + expect(decoration.hintStyle, themeStyle); + expect(decoration.errorStyle, themeStyle); + expect(decoration.errorMaxLines, 2); + expect(decoration.floatingLabelBehavior, FloatingLabelBehavior.never); + expect(decoration.floatingLabelAlignment, FloatingLabelAlignment.center); + expect(decoration.isDense, true); + expect(decoration.contentPadding, const EdgeInsets.all(1.0)); + expect(decoration.iconColor, themeColor); + expect(decoration.prefixStyle, themeStyle); + expect(decoration.prefixIconColor, themeColor); + expect( + decoration.prefixIconConstraints, + const BoxConstraints(minWidth: 10, maxWidth: 10, minHeight: 30, maxHeight: 30), + ); + expect(decoration.suffixStyle, themeStyle); + expect(decoration.suffixIconColor, themeColor); + expect( + decoration.suffixIconConstraints, + const BoxConstraints(minWidth: 20, maxWidth: 20, minHeight: 40, maxHeight: 40), + ); + expect(decoration.counterStyle, themeStyle); + expect(decoration.filled, true); + expect(decoration.fillColor, themeColor); + expect(decoration.focusColor, themeColor); + expect(decoration.hoverColor, themeColor); + expect(decoration.errorBorder, themeInputBorder); + expect(decoration.focusedBorder, themeInputBorder); + expect(decoration.focusedErrorBorder, themeInputBorder); + expect(decoration.disabledBorder, themeInputBorder); + expect(decoration.enabledBorder, themeInputBorder); + expect(decoration.border, InputBorder.none); + expect(decoration.alignLabelWithHint, true); + expect( + decoration.constraints, + const BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40), + ); + }); + + testWidgets('InputDecoration.applyDefaults does not override non-null fields', ( + WidgetTester tester, + ) async { + const themeStyle = TextStyle(color: Color(0xFF00FFFF)); + const themeColor = Color(0xFF00FF00); + const InputBorder themeInputBorder = OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFF0000FF)), + ); + const decorationStyle = TextStyle(color: Color(0xFFFFFF00)); + const decorationColor = Color(0xFF0000FF); + const InputBorder decorationInputBorder = OutlineInputBorder( + borderSide: BorderSide(color: Color(0xFFFF00FF)), + ); + const decorationConstraints = BoxConstraints( + minWidth: 40, + maxWidth: 50, + minHeight: 60, + maxHeight: 70, + ); + + final InputDecoration decoration = + const InputDecoration( + labelStyle: decorationStyle, + floatingLabelStyle: decorationStyle, + helperStyle: decorationStyle, + helperMaxLines: 3, + hintStyle: decorationStyle, + errorStyle: decorationStyle, + errorMaxLines: 3, + floatingLabelBehavior: FloatingLabelBehavior.always, + floatingLabelAlignment: FloatingLabelAlignment.start, + isDense: false, + contentPadding: EdgeInsets.all(4.0), + iconColor: decorationColor, + prefixStyle: decorationStyle, + prefixIconColor: decorationColor, + prefixIconConstraints: decorationConstraints, + suffixStyle: decorationStyle, + suffixIconColor: decorationColor, + suffixIconConstraints: decorationConstraints, + counterStyle: decorationStyle, + filled: false, + fillColor: decorationColor, + focusColor: decorationColor, + hoverColor: decorationColor, + errorBorder: decorationInputBorder, + focusedBorder: decorationInputBorder, + focusedErrorBorder: decorationInputBorder, + disabledBorder: decorationInputBorder, + enabledBorder: decorationInputBorder, + border: OutlineInputBorder(), + alignLabelWithHint: false, + constraints: decorationConstraints, + ).applyDefaults( + const InputDecorationThemeData( + labelStyle: themeStyle, + floatingLabelStyle: themeStyle, + helperStyle: themeStyle, + helperMaxLines: 2, + hintStyle: themeStyle, + errorStyle: themeStyle, + errorMaxLines: 2, + floatingLabelBehavior: FloatingLabelBehavior.never, + floatingLabelAlignment: FloatingLabelAlignment.center, + isDense: true, + contentPadding: EdgeInsets.all(1.0), + iconColor: themeColor, + prefixStyle: themeStyle, + prefixIconColor: themeColor, + suffixStyle: themeStyle, + suffixIconColor: themeColor, + counterStyle: themeStyle, + filled: true, + fillColor: themeColor, + focusColor: themeColor, + hoverColor: themeColor, + errorBorder: themeInputBorder, + focusedBorder: themeInputBorder, + focusedErrorBorder: themeInputBorder, + disabledBorder: themeInputBorder, + enabledBorder: themeInputBorder, + border: InputBorder.none, + alignLabelWithHint: true, + constraints: BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40), + ), + ); + + expect(decoration.labelStyle, decorationStyle); + expect(decoration.floatingLabelStyle, decorationStyle); + expect(decoration.helperStyle, decorationStyle); + expect(decoration.helperMaxLines, 3); + expect(decoration.hintStyle, decorationStyle); + expect(decoration.errorStyle, decorationStyle); + expect(decoration.errorMaxLines, 3); + expect(decoration.floatingLabelBehavior, FloatingLabelBehavior.always); + expect(decoration.floatingLabelAlignment, FloatingLabelAlignment.start); + expect(decoration.isDense, false); + expect(decoration.contentPadding, const EdgeInsets.all(4.0)); + expect(decoration.iconColor, decorationColor); + expect(decoration.prefixStyle, decorationStyle); + expect(decoration.prefixIconColor, decorationColor); + expect(decoration.prefixIconConstraints, decorationConstraints); + expect(decoration.suffixStyle, decorationStyle); + expect(decoration.suffixIconColor, decorationColor); + expect(decoration.suffixIconConstraints, decorationConstraints); + expect(decoration.counterStyle, decorationStyle); + expect(decoration.filled, false); + expect(decoration.fillColor, decorationColor); + expect(decoration.focusColor, decorationColor); + expect(decoration.hoverColor, decorationColor); + expect(decoration.errorBorder, decorationInputBorder); + expect(decoration.focusedBorder, decorationInputBorder); + expect(decoration.focusedErrorBorder, decorationInputBorder); + expect(decoration.disabledBorder, decorationInputBorder); + expect(decoration.enabledBorder, decorationInputBorder); + expect(decoration.border, const OutlineInputBorder()); + expect(decoration.alignLabelWithHint, false); + expect(decoration.constraints, decorationConstraints); + }); + + testWidgets( + 'InputDecoration.applyDefaults accepts only a InputDecorationTheme or a InputDecorationThemeData', + (WidgetTester tester) async { + const InputDecoration().applyDefaults(const InputDecorationTheme()); + expect(tester.takeException(), isNull); + + const InputDecoration().applyDefaults(const InputDecorationThemeData()); + expect(tester.takeException(), isNull); + + expect( + () { + const InputDecoration().applyDefaults(const Object()); + }, + throwsA( + isA<ArgumentError>().having( + (ArgumentError error) => error.message, + 'message', + equals( + 'inputDecorationTheme must be either a InputDecorationThemeData or a InputDecorationTheme', + ), + ), + ), + ); + }, + ); + + testWidgets('InputDecorationThemeData.inputDecoration with WidgetState', ( + WidgetTester tester, + ) async { + final themeStyle = WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + return const TextStyle(color: Colors.green); + }); + + final decorationStyle = WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + return const TextStyle(color: Colors.blue); + }); + + // InputDecorationThemeData arguments define InputDecoration properties. + InputDecoration decoration = const InputDecoration().applyDefaults( + InputDecorationThemeData( + labelStyle: themeStyle, + helperStyle: themeStyle, + hintStyle: themeStyle, + errorStyle: themeStyle, + isDense: true, + contentPadding: const EdgeInsets.all(1.0), + prefixStyle: themeStyle, + suffixStyle: themeStyle, + counterStyle: themeStyle, + filled: true, + fillColor: Colors.red, + focusColor: Colors.blue, + border: InputBorder.none, + alignLabelWithHint: true, + constraints: const BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40), + ), + ); + + expect(decoration.labelStyle, themeStyle); + expect(decoration.helperStyle, themeStyle); + expect(decoration.hintStyle, themeStyle); + expect(decoration.errorStyle, themeStyle); + expect(decoration.isDense, true); + expect(decoration.contentPadding, const EdgeInsets.all(1.0)); + expect(decoration.prefixStyle, themeStyle); + expect(decoration.suffixStyle, themeStyle); + expect(decoration.counterStyle, themeStyle); + expect(decoration.filled, true); + expect(decoration.fillColor, Colors.red); + expect(decoration.border, InputBorder.none); + expect(decoration.alignLabelWithHint, true); + expect( + decoration.constraints, + const BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40), + ); + + // InputDecoration (baseDecoration) defines InputDecoration properties + final border = WidgetStateInputBorder.resolveWith((Set<WidgetState> states) { + return const OutlineInputBorder(); + }); + decoration = + InputDecoration( + labelStyle: decorationStyle, + helperStyle: decorationStyle, + hintStyle: decorationStyle, + errorStyle: decorationStyle, + isDense: false, + contentPadding: const EdgeInsets.all(4.0), + prefixStyle: decorationStyle, + suffixStyle: decorationStyle, + counterStyle: decorationStyle, + filled: false, + fillColor: Colors.blue, + border: border, + alignLabelWithHint: false, + constraints: const BoxConstraints( + minWidth: 10, + maxWidth: 20, + minHeight: 30, + maxHeight: 40, + ), + ).applyDefaults( + InputDecorationThemeData( + labelStyle: themeStyle, + helperStyle: themeStyle, + helperMaxLines: 5, + hintStyle: themeStyle, + errorStyle: themeStyle, + errorMaxLines: 4, + isDense: true, + contentPadding: const EdgeInsets.all(1.0), + prefixStyle: themeStyle, + suffixStyle: themeStyle, + counterStyle: themeStyle, + filled: true, + fillColor: Colors.red, + focusColor: Colors.blue, + border: InputBorder.none, + alignLabelWithHint: true, + constraints: const BoxConstraints( + minWidth: 40, + maxWidth: 30, + minHeight: 20, + maxHeight: 10, + ), + ), + ); + + expect(decoration.labelStyle, decorationStyle); + expect(decoration.helperStyle, decorationStyle); + expect(decoration.helperMaxLines, 5); + expect(decoration.hintStyle, decorationStyle); + expect(decoration.errorStyle, decorationStyle); + expect(decoration.errorMaxLines, 4); + expect(decoration.isDense, false); + expect(decoration.contentPadding, const EdgeInsets.all(4.0)); + expect(decoration.prefixStyle, decorationStyle); + expect(decoration.suffixStyle, decorationStyle); + expect(decoration.counterStyle, decorationStyle); + expect(decoration.filled, false); + expect(decoration.fillColor, Colors.blue); + expect(decoration.border, isA<WidgetStateInputBorder>()); + expect(decoration.alignLabelWithHint, false); + expect( + decoration.constraints, + const BoxConstraints(minWidth: 10, maxWidth: 20, minHeight: 30, maxHeight: 40), + ); + }); + + testWidgets('InputDecoration with WidgetStateInputBorder', (WidgetTester tester) async { + const outlineInputBorder = WidgetStateInputBorder.fromMap(<WidgetStatesConstraint, InputBorder>{ + WidgetState.focused: OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue, width: 4.0), + ), + WidgetState.hovered: OutlineInputBorder( + borderSide: BorderSide(color: Colors.cyan, width: 8.0), + ), + WidgetState.any: OutlineInputBorder(), + }); + + RenderObject getBorder() { + return tester.renderObject( + find.descendant(of: find.byType(TextField), matching: find.byType(CustomPaint)), + ); + } + + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + focusNode: focusNode, + decoration: const InputDecoration(border: outlineInputBorder), + ), + ), + ), + ); + expect(getBorder(), paints..rrect(strokeWidth: 1.0)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(getBorder(), paints..rrect(color: Colors.blue, strokeWidth: 4.0)); + + focusNode.unfocus(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(TextField))); + await tester.pumpAndSettle(); + expect(getBorder(), paints..rrect(color: Colors.cyan, strokeWidth: 8.0)); + + focusNode.dispose(); + }); + + testWidgets('InputDecorator constrained to 0x0', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/17710 + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: UnconstrainedBox( + child: ConstrainedBox( + constraints: BoxConstraints.tight(Size.zero), + child: const InputDecorator( + decoration: InputDecoration(labelText: 'XP', border: OutlineInputBorder()), + ), + ), + ), + ), + ), + ); + }); + + testWidgets('InputDecorationThemeData.toString()', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/19305 + expect( + const InputDecorationThemeData( + contentPadding: EdgeInsetsDirectional.only(start: 5.0), + ).toString(), + contains('contentPadding: EdgeInsetsDirectional(5.0, 0.0, 0.0, 0.0)'), + ); + + // Regression test for https://github.com/flutter/flutter/issues/20374 + expect( + const InputDecorationThemeData(contentPadding: EdgeInsets.only(left: 5.0)).toString(), + contains('contentPadding: EdgeInsets(5.0, 0.0, 0.0, 0.0)'), + ); + + // Verify that the toString() method succeeds. + final debugString = const InputDecorationThemeData( + labelStyle: TextStyle(height: 1.0), + helperStyle: TextStyle(height: 2.0), + helperMaxLines: 5, + hintStyle: TextStyle(height: 3.0), + errorStyle: TextStyle(height: 4.0), + errorMaxLines: 5, + isDense: true, + contentPadding: EdgeInsets.only(right: 6.0), + isCollapsed: true, + prefixStyle: TextStyle(height: 7.0), + suffixStyle: TextStyle(height: 8.0), + counterStyle: TextStyle(height: 9.0), + filled: true, + fillColor: Color(0x00000010), + focusColor: Color(0x00000020), + errorBorder: UnderlineInputBorder(), + focusedBorder: OutlineInputBorder(), + focusedErrorBorder: UnderlineInputBorder(), + disabledBorder: OutlineInputBorder(), + enabledBorder: UnderlineInputBorder(), + border: OutlineInputBorder(), + ).toString(); + + // Spot check + expect(debugString, contains('labelStyle: TextStyle(inherit: true, height: 1.0x)')); + expect(debugString, contains('isDense: true')); + expect(debugString, contains('fillColor: ${const Color(0x00000010)}')); + expect(debugString, contains('focusColor: ${const Color(0x00000020)}')); + expect(debugString, contains('errorBorder: UnderlineInputBorder()')); + expect(debugString, contains('focusedBorder: OutlineInputBorder()')); + }); + + testWidgets('InputDecorationThemeData implements debugFillDescription', ( + WidgetTester tester, + ) async { + final builder = DiagnosticPropertiesBuilder(); + const constraints = BoxConstraints(minWidth: 10, maxWidth: 10, minHeight: 30, maxHeight: 30); + const InputDecorationThemeData( + labelStyle: TextStyle(), + floatingLabelStyle: TextStyle(), + helperStyle: TextStyle(), + helperMaxLines: 6, + hintStyle: TextStyle(), + errorStyle: TextStyle(), + errorMaxLines: 5, + floatingLabelBehavior: FloatingLabelBehavior.never, + floatingLabelAlignment: FloatingLabelAlignment.center, + isDense: true, + contentPadding: EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + isCollapsed: true, + iconColor: Colors.red, + prefixIconColor: Colors.blue, + prefixIconConstraints: constraints, + prefixStyle: TextStyle(), + suffixIconColor: Colors.blue, + suffixIconConstraints: constraints, + suffixStyle: TextStyle(), + counterStyle: TextStyle(), + filled: true, + fillColor: Colors.red, + activeIndicatorBorder: BorderSide(), + outlineBorder: BorderSide(), + focusColor: Colors.blue, + hoverColor: Colors.green, + errorBorder: UnderlineInputBorder(), + focusedBorder: UnderlineInputBorder(), + focusedErrorBorder: UnderlineInputBorder(), + disabledBorder: UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder(), + border: UnderlineInputBorder(), + alignLabelWithHint: true, + constraints: constraints, + ).debugFillProperties(builder); + final List<String> description = builder.properties + .where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode n) => n.toString()) + .toList(); + expect(description, <String>[ + 'labelStyle: TextStyle(<all styles inherited>)', + 'floatingLabelStyle: TextStyle(<all styles inherited>)', + 'helperStyle: TextStyle(<all styles inherited>)', + 'helperMaxLines: 6', + 'hintStyle: TextStyle(<all styles inherited>)', + 'errorStyle: TextStyle(<all styles inherited>)', + 'errorMaxLines: 5', + 'floatingLabelBehavior: FloatingLabelBehavior.never', + 'floatingLabelAlignment: FloatingLabelAlignment.center', + 'isDense: true', + 'contentPadding: EdgeInsetsDirectional(40.0, 12.0, 0.0, 12.0)', + 'isCollapsed: true', + 'iconColor: MaterialColor(primary value: ${const Color(0xfff44336)})', + 'prefixIconColor: MaterialColor(primary value: ${const Color(0xff2196f3)})', + 'prefixIconConstraints: BoxConstraints(w=10.0, h=30.0)', + 'prefixStyle: TextStyle(<all styles inherited>)', + 'suffixIconColor: MaterialColor(primary value: ${const Color(0xff2196f3)})', + 'suffixIconConstraints: BoxConstraints(w=10.0, h=30.0)', + 'suffixStyle: TextStyle(<all styles inherited>)', + 'counterStyle: TextStyle(<all styles inherited>)', + 'filled: true', + 'fillColor: MaterialColor(primary value: ${const Color(0xfff44336)})', + 'activeIndicatorBorder: BorderSide', + 'outlineBorder: BorderSide', + 'focusColor: MaterialColor(primary value: ${const Color(0xff2196f3)})', + 'hoverColor: MaterialColor(primary value: ${const Color(0xff4caf50)})', + 'errorBorder: UnderlineInputBorder()', + 'focusedBorder: UnderlineInputBorder()', + 'focusedErrorBorder: UnderlineInputBorder()', + 'disabledBorder: UnderlineInputBorder()', + 'enabledBorder: UnderlineInputBorder()', + 'border: UnderlineInputBorder()', + 'alignLabelWithHint: true', + 'constraints: BoxConstraints(w=10.0, h=30.0)', + ]); + }); + + testWidgets("InputDecorator label width isn't affected by prefix or suffix", ( + WidgetTester tester, + ) async { + const labelText = 'My Label'; + const prefixText = 'The five boxing wizards jump quickly.'; + const suffixText = 'Suffix'; + + Widget getLabeledInputDecorator(bool showFix) { + return MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return Theme( + data: Theme.of(context), + child: Align( + alignment: Alignment.topLeft, + child: TextField( + decoration: InputDecoration( + icon: const Icon(Icons.assistant), + prefixText: showFix ? prefixText : null, + suffixText: showFix ? suffixText : null, + suffixIcon: const Icon(Icons.threesixty), + labelText: labelText, + ), + ), + ), + ); + }, + ), + ), + ); + } + + // Build with no prefix or suffix. + await tester.pumpWidget(getLabeledInputDecorator(false)); + + // Get the width of the label when there is no prefix/suffix. + expect(find.text(prefixText), findsNothing); + expect(find.text(suffixText), findsNothing); + final double labelWidth = tester.getSize(find.text(labelText)).width; + + // Build with a prefix and suffix. + await tester.pumpWidget(getLabeledInputDecorator(true)); + + // The prefix and suffix exist but aren't visible. They have not affected + // the width of the label. + expect(find.text(prefixText), findsOneWidget); + expect(getOpacity(tester, prefixText), 0.0); + expect(find.text(suffixText), findsOneWidget); + expect(getOpacity(tester, suffixText), 0.0); + expect(tester.getSize(find.text(labelText)).width, labelWidth); + + // Tap to focus. + await tester.tap(find.byType(TextField)); + // TODO(bleroux): investigate why this pumpAndSettle is required. + await tester.pumpAndSettle(); + + // The prefix and suffix are visible, and the label is floating and still + // hasn't had its width affected. + expect(tester.getSize(find.text(labelText)).width, labelWidth); + expect(getOpacity(tester, prefixText), 1.0); + }); + + testWidgets('Prefix and suffix are not visible when decorator is empty', ( + WidgetTester tester, + ) async { + const prefixText = 'Prefix'; + const suffixText = 'Suffix'; + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixText: prefixText, + suffixText: suffixText, + ), + ), + ); + + // Prefix and suffix are hidden. + expect(getOpacity(tester, prefixText), 0.0); + expect(getOpacity(tester, suffixText), 0.0); + }); + + testWidgets( + 'Prefix and suffix are visible when decorator is empty and floating behavior is FloatingBehavior.always', + (WidgetTester tester) async { + const prefixText = 'Prefix'; + const suffixText = 'Suffix'; + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + prefixText: prefixText, + suffixText: suffixText, + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + // Prefix and suffix are visible. + expect(getOpacity(tester, prefixText), 1.0); + expect(getOpacity(tester, suffixText), 1.0); + }, + ); + + testWidgets( + 'OutlineInputBorder and InputDecorator long labels and in Floating, the width should ignore the icon width', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/64427. + const labelText = + 'Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.'; + + Widget getLabeledInputDecorator(FloatingLabelBehavior floatingLabelBehavior) => MaterialApp( + home: MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: TextField( + decoration: InputDecoration( + border: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.greenAccent), + ), + suffixIcon: const Icon(Icons.arrow_drop_down), + floatingLabelBehavior: floatingLabelBehavior, + labelText: labelText, + ), + ), + ), + ), + ), + ); + + await tester.pumpWidget(getLabeledInputDecorator(FloatingLabelBehavior.never)); + + final double labelWidth = getLabelRect(tester).width; + + await tester.pumpWidget(getLabeledInputDecorator(FloatingLabelBehavior.always)); + await tester.pump(kTransitionDuration); + + final double floatedLabelWidth = getLabelRect(tester).width; + + expect(floatedLabelWidth, greaterThan(labelWidth)); + + final Widget target = getLabeledInputDecorator(FloatingLabelBehavior.auto); + await tester.pumpWidget(target); + await tester.pump(kTransitionDuration); + + expect(getLabelRect(tester).width, labelWidth); + + // Click for Focus. + await tester.tap(find.byType(TextField)); + // Default animation duration is 167ms. + await tester.pumpFrames(target, const Duration(milliseconds: 80)); + + expect(getLabelRect(tester).width, greaterThan(labelWidth)); + expect(getLabelRect(tester).width, lessThanOrEqualTo(floatedLabelWidth)); + + await tester.pump(kTransitionDuration); + + expect(getLabelRect(tester).width, floatedLabelWidth); + }, + ); + + testWidgets( + 'given enough space, constrained and unconstrained heights result in the same size widget', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/65572. + final keyUnconstrained = UniqueKey(); + final keyConstrained = UniqueKey(); + + Widget getInputDecorator(VisualDensity visualDensity) { + return MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return Theme( + data: Theme.of(context).copyWith(visualDensity: visualDensity), + child: Center( + child: Row( + children: <Widget>[ + SizedBox(width: 35.0, child: TextField(key: keyUnconstrained)), + SizedBox( + width: 35.0, + // 48 is the height that this TextField would take when + // laid out with no constraints. + height: 48.0, + child: TextField(key: keyConstrained), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(getInputDecorator(VisualDensity.standard)); + final double constrainedHeight = tester.getSize(find.byKey(keyConstrained)).height; + final double unConstrainedHeight = tester.getSize(find.byKey(keyUnconstrained)).height; + expect(constrainedHeight, equals(unConstrainedHeight)); + + await tester.pumpWidget(getInputDecorator(VisualDensity.compact)); + final double constrainedHeightCompact = tester.getSize(find.byKey(keyConstrained)).height; + final double unConstrainedHeightCompact = tester.getSize(find.byKey(keyUnconstrained)).height; + expect(constrainedHeightCompact, equals(unConstrainedHeightCompact)); + }, + ); + + testWidgets('A vertically constrained TextField still positions its text inside of itself', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'A'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox(width: 200, height: 28, child: TextField(controller: controller)), + ), + ), + ), + ); + + final double textFieldTop = tester.getTopLeft(find.byType(TextField)).dy; + final double textFieldBottom = tester.getBottomLeft(find.byType(TextField)).dy; + final double textTop = tester.getTopLeft(find.text('A')).dy; + + // The text is inside the field. + expect(tester.getSize(find.text('A')).height, lessThan(textFieldBottom - textFieldTop)); + expect(textTop, greaterThan(textFieldTop)); + expect(textTop, lessThan(textFieldBottom)); + }); + + testWidgets('Visual density is included in the intrinsic height calculation', ( + WidgetTester tester, + ) async { + final key = UniqueKey(); + final intrinsicHeightKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return Theme( + data: Theme.of(context).copyWith(visualDensity: VisualDensity.compact), + child: Center( + child: Row( + children: <Widget>[ + SizedBox(width: 35.0, child: TextField(key: key)), + SizedBox( + width: 35.0, + child: IntrinsicHeight(child: TextField(key: intrinsicHeightKey)), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + + final double height = tester.getSize(find.byKey(key)).height; + final double intrinsicHeight = tester.getSize(find.byKey(intrinsicHeightKey)).height; + expect(intrinsicHeight, equals(height)); + }); + + testWidgets('Min intrinsic height for TextField with no content padding', ( + WidgetTester tester, + ) async { + // Regression test for: https://github.com/flutter/flutter/issues/75509 + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: IntrinsicHeight( + child: Column( + children: <Widget>[ + TextField( + decoration: InputDecoration( + labelText: 'Label Text', + helperText: 'Helper Text', + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Min intrinsic height for TextField with prefix icon', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + + // Regression test for: https://github.com/flutter/flutter/issues/87403 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + child: IntrinsicHeight( + child: Column( + children: <Widget>[ + TextField( + controller: controller, + maxLines: null, + decoration: const InputDecoration(prefixIcon: Icon(Icons.search)), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Min intrinsic height for TextField with suffix icon', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + + // Regression test for: https://github.com/flutter/flutter/issues/87403 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + child: IntrinsicHeight( + child: Column( + children: <Widget>[ + TextField( + controller: controller, + maxLines: null, + decoration: const InputDecoration(suffixIcon: Icon(Icons.search)), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Min intrinsic height for TextField with prefix', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + + // Regression test for: https://github.com/flutter/flutter/issues/87403 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + child: IntrinsicHeight( + child: Column( + children: <Widget>[ + TextField( + controller: controller, + maxLines: null, + decoration: const InputDecoration(prefix: Text('prefix')), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Min intrinsic height for TextField with suffix', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + + // Regression test for: https://github.com/flutter/flutter/issues/87403 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + child: IntrinsicHeight( + child: Column( + children: <Widget>[ + TextField( + controller: controller, + maxLines: null, + decoration: const InputDecoration(suffix: Text('suffix')), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Min intrinsic height for TextField with icon', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input'); + addTearDown(controller.dispose); + + // Regression test for: https://github.com/flutter/flutter/issues/87403 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100.0, + child: IntrinsicHeight( + child: Column( + children: <Widget>[ + TextField( + controller: controller, + maxLines: null, + decoration: const InputDecoration(icon: Icon(Icons.search)), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Min intrinsic height calculation should not impact the container height', ( + WidgetTester tester, + ) async { + const helperText = '0123456789'; + + Widget buildDecoration({required double width}) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: width, + child: const IntrinsicHeight( + child: InputDecorator( + decoration: InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + helperMaxLines: 2, + ), + ), + ), + ), + ), + ); + } + + // Build once with enough width for the helper to fit in one line. + await tester.pumpWidget(buildDecoration(width: 300)); + final double defaultContainerHeight = getContainerRect(tester).height; + + // Build once with a smaller width that will force the helper text to wrap. + await tester.pumpWidget(buildDecoration(width: 150)); + + // The container height should be the same as before. + expect(getContainerRect(tester).height, defaultContainerHeight); + }); + + group('Intrinsic width', () { + const EdgeInsetsGeometry padding = EdgeInsetsDirectional.only(end: 24, start: 12); + + const decorationWithoutIcons = InputDecoration(contentPadding: padding); + const decorationWithPrefix = InputDecoration( + contentPadding: padding, + prefixIcon: Icon(Icons.search), + ); + const decorationWithSuffix = InputDecoration( + contentPadding: padding, + suffixIcon: Icon(Icons.search), + ); + const decorationWithAffixes = InputDecoration( + contentPadding: padding, + prefixIcon: Icon(Icons.search), + suffixIcon: Icon(Icons.search), + ); + + Future<Size> measureText( + WidgetTester tester, + InputDecoration decoration, + TextDirection direction, + ) async { + await tester.pumpWidget( + buildInputDecorator( + decoration: decoration, + useIntrinsicWidth: true, + textDirection: direction, + ), + ); + return tester.renderObject<RenderBox>(findInputText()).size; + } + + testWidgets('with prefixIcon in LTR', (WidgetTester tester) async { + final Size textSizeWithoutIcon = await measureText( + tester, + decorationWithoutIcons, + TextDirection.ltr, + ); + final Size textSizeWithPrefixIcon = await measureText( + tester, + decorationWithPrefix, + TextDirection.ltr, + ); + + expect(textSizeWithPrefixIcon.width, equals(textSizeWithoutIcon.width)); + }); + + testWidgets('with suffixIcon in LTR', (WidgetTester tester) async { + final Size textSizeWithoutIcon = await measureText( + tester, + decorationWithoutIcons, + TextDirection.ltr, + ); + final Size textSizeWithSuffixIcon = await measureText( + tester, + decorationWithSuffix, + TextDirection.ltr, + ); + + expect(textSizeWithSuffixIcon.width, equals(textSizeWithoutIcon.width)); + }); + + testWidgets('with prefixIcon and suffixIcon in LTR', (WidgetTester tester) async { + final Size textSizeWithoutIcon = await measureText( + tester, + decorationWithoutIcons, + TextDirection.ltr, + ); + final Size textSizeWithIcons = await measureText( + tester, + decorationWithAffixes, + TextDirection.ltr, + ); + + expect(textSizeWithIcons.width, equals(textSizeWithoutIcon.width)); + }); + + testWidgets('with prefixIcon in RTL', (WidgetTester tester) async { + final Size textSizeWithoutIcon = await measureText( + tester, + decorationWithoutIcons, + TextDirection.rtl, + ); + final Size textSizeWithPrefixIcon = await measureText( + tester, + decorationWithPrefix, + TextDirection.rtl, + ); + + expect(textSizeWithPrefixIcon.width, equals(textSizeWithoutIcon.width)); + }); + + testWidgets('with suffixIcon in RTL', (WidgetTester tester) async { + final Size textSizeWithoutIcon = await measureText( + tester, + decorationWithoutIcons, + TextDirection.rtl, + ); + final Size textSizeWithSuffixIcon = await measureText( + tester, + decorationWithSuffix, + TextDirection.rtl, + ); + + expect(textSizeWithSuffixIcon.width, equals(textSizeWithoutIcon.width)); + }); + + testWidgets('with prefixIcon and suffixIcon in RTL', (WidgetTester tester) async { + final Size textSizeWithoutIcon = await measureText( + tester, + decorationWithoutIcons, + TextDirection.rtl, + ); + final Size textSizeWithIcons = await measureText( + tester, + decorationWithAffixes, + TextDirection.rtl, + ); + + expect(textSizeWithIcons.width, equals(textSizeWithoutIcon.width)); + }); + + testWidgets('depends on hint width and content width when decorator is empty', ( + WidgetTester tester, + ) async { + const decorationWithHint = InputDecoration(contentPadding: EdgeInsets.zero, hintText: 'Hint'); + const hintTextWidth = 66.0; + const smallContentWidth = 20.0; + const largeContentWidth = 80.0; + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithHint, + useIntrinsicWidth: true, + isEmpty: true, + child: const SizedBox(width: smallContentWidth), + ), + ); + + // Decorator width depends on the hint because the hint is larger than the content. + expect(getDecoratorRect(tester).width, hintTextWidth); + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithHint, + useIntrinsicWidth: true, + isEmpty: true, + child: const SizedBox(width: largeContentWidth), + ), + ); + + // Decorator width depends on the content because the content is larger than the hint. + expect(getDecoratorRect(tester).width, largeContentWidth); + }); + + // Regression test for https://github.com/flutter/flutter/issues/93337. + testWidgets( + 'depends on content width when decorator is not empty and maintainHintSize is false', + (WidgetTester tester) async { + const decorationWithHint = InputDecoration( + contentPadding: EdgeInsets.zero, + hintText: 'Hint', + maintainHintSize: false, + ); + const contentWidth = 20.0; + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithHint, + useIntrinsicWidth: true, + child: const SizedBox(width: contentWidth), + ), + ); + + // The hint width is ignored even if larger than the content width. + expect(getDecoratorRect(tester).width, contentWidth); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/93337. + testWidgets('depends on hint width when decorator is not empty and maintainHintSize is true', ( + WidgetTester tester, + ) async { + const decorationWithHint = InputDecoration(contentPadding: EdgeInsets.zero, hintText: 'Hint'); + const contentWidth = 20.0; + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithHint, + useIntrinsicWidth: true, + child: const SizedBox(width: contentWidth), + ), + ); + + const hintTextWidth = 66.0; + expect(getDecoratorRect(tester).width, hintTextWidth); + }); + + testWidgets( + 'does not depend on label width when decorator is empty and maintainLabelSize is false', + (WidgetTester tester) async { + const double labelWidth = 30; + const decorationWithLabel = InputDecoration( + contentPadding: EdgeInsets.zero, + label: SizedBox(width: labelWidth), + ); + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithLabel, + useIntrinsicWidth: true, + isEmpty: true, + child: const SizedBox.shrink(), + ), + ); + + // The label width is ignored even if larger than the content width. + expect(getDecoratorRect(tester).width, 0); + }, + ); + + testWidgets('depends on label width when decorator is empty and maintainLabelSize is true', ( + WidgetTester tester, + ) async { + const double labelWidth = 30; + const decorationWithLabel = InputDecoration( + contentPadding: EdgeInsets.zero, + label: SizedBox(width: labelWidth), + maintainLabelSize: true, + ); + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithLabel, + useIntrinsicWidth: true, + isEmpty: true, + child: const SizedBox.shrink(), + ), + ); + + expect(getDecoratorRect(tester).width, labelWidth); + }); + + testWidgets( + 'does not depend on label width when decorator is not empty and maintainLabelSize is false', + (WidgetTester tester) async { + const contentWidth = 20.0; + const double labelWidth = 30; + const decorationWithLabel = InputDecoration( + contentPadding: EdgeInsets.zero, + label: SizedBox(width: labelWidth), + ); + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithLabel, + useIntrinsicWidth: true, + child: const SizedBox(width: contentWidth), + ), + ); + + // The label width is ignored even if larger than the content width. + expect(getDecoratorRect(tester).width, contentWidth); + }, + ); + + testWidgets( + 'depends on label width when decorator is not empty and maintainLabelSize is true', + (WidgetTester tester) async { + const contentWidth = 20.0; + const double labelWidth = 30; + const decorationWithLabel = InputDecoration( + contentPadding: EdgeInsets.zero, + label: SizedBox(width: labelWidth), + maintainLabelSize: true, + ); + + await tester.pumpWidget( + buildInputDecorator( + decoration: decorationWithLabel, + useIntrinsicWidth: true, + child: const SizedBox(width: contentWidth), + ), + ); + + expect(getDecoratorRect(tester).width, labelWidth); + }, + ); + }); + + testWidgets('Ensure the height of labelStyle remains unchanged when TextField is focused', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/141448. + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + focusNode: focusNode, + decoration: const InputDecoration(labelText: 'label'), + ), + ), + ), + ); + final TextStyle beforeStyle = getLabelStyle(tester); + // Focused. + focusNode.requestFocus(); + await tester.pump(kTransitionDuration); + + expect(getLabelStyle(tester).height, beforeStyle.height); + }); + + test('InputDecorationThemeData.copyWith keeps original iconColor', () async { + const original = InputDecorationThemeData(iconColor: Color(0xDEADBEEF)); + expect(original.iconColor, const Color(0xDEADBEEF)); + expect(original.fillColor, isNot(const Color(0xDEADCAFE))); + final InputDecorationThemeData copy1 = original.copyWith(fillColor: const Color(0xDEADCAFE)); + expect(copy1.iconColor, const Color(0xDEADBEEF)); + expect(copy1.fillColor, const Color(0xDEADCAFE)); + final InputDecorationThemeData copy2 = original.copyWith(iconColor: const Color(0xDEADCAFE)); + expect(copy2.iconColor, const Color(0xDEADCAFE)); + expect(copy2.fillColor, isNot(const Color(0xDEADCAFE))); + }); + + test('InputDecorationThemeData copyWith, ==, hashCode basics', () { + expect(const InputDecorationThemeData(), const InputDecorationThemeData().copyWith()); + expect( + const InputDecorationThemeData().hashCode, + const InputDecorationThemeData().copyWith().hashCode, + ); + }); + + test('InputDecorationThemeData copyWith correctly copies and replaces values', () { + const original = InputDecorationThemeData(focusColor: Colors.orange, fillColor: Colors.green); + final InputDecorationThemeData copy = original.copyWith( + focusColor: Colors.yellow, + fillColor: Colors.blue, + ); + + expect(original.focusColor, Colors.orange); + expect(original.fillColor, Colors.green); + expect(copy.focusColor, Colors.yellow); + expect(copy.fillColor, Colors.blue); + }); + + test('InputDecorationThemeData merge', () { + const overrideTheme = InputDecorationThemeData( + labelStyle: TextStyle(color: Color(0x000000f0)), + floatingLabelStyle: TextStyle(color: Color(0x000000f1)), + helperStyle: TextStyle(color: Color(0x000000f2)), + helperMaxLines: 1, + hintStyle: TextStyle(color: Color(0x000000f3)), + errorStyle: TextStyle(color: Color(0x000000f4)), + errorMaxLines: 1, + floatingLabelBehavior: FloatingLabelBehavior.never, + floatingLabelAlignment: FloatingLabelAlignment.center, + isDense: true, + contentPadding: EdgeInsets.all(1.0), + isCollapsed: true, + iconColor: Color(0x000000f5), + prefixStyle: TextStyle(color: Color(0x000000f6)), + prefixIconColor: Color(0x000000f7), + suffixStyle: TextStyle(color: Color(0x000000f8)), + suffixIconColor: Color(0x000000f9), + counterStyle: TextStyle(color: Color(0x00000f10)), + filled: true, + fillColor: Color(0x00000f11), + activeIndicatorBorder: BorderSide(color: Color(0x00000f12)), + outlineBorder: BorderSide(color: Color(0x00000f13)), + focusColor: Color(0x00000f14), + hoverColor: Color(0x00000f15), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: Color(0x00000f16))), + focusedErrorBorder: OutlineInputBorder(borderSide: BorderSide(color: Color(0x00000f17))), + disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Color(0x00000f18))), + enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Color(0x00000f19))), + border: OutlineInputBorder(borderSide: BorderSide(color: Color(0x00000f20))), + alignLabelWithHint: true, + constraints: BoxConstraints(minHeight: 1.0, minWidth: 1.0), + ); + + final InputDecorationThemeData inputDecorationTheme = ThemeData().inputDecorationTheme; + final InputDecorationThemeData merged = inputDecorationTheme.merge(overrideTheme); + + expect(merged.labelStyle, overrideTheme.labelStyle); + expect(merged.floatingLabelStyle, overrideTheme.floatingLabelStyle); + expect(merged.helperStyle, overrideTheme.helperStyle); + expect(merged.helperMaxLines, overrideTheme.helperMaxLines); + expect(merged.hintStyle, overrideTheme.hintStyle); + expect(merged.errorStyle, overrideTheme.errorStyle); + expect(merged.errorMaxLines, overrideTheme.errorMaxLines); + expect(merged.floatingLabelBehavior, isNot(overrideTheme.floatingLabelBehavior)); + expect(merged.floatingLabelAlignment, isNot(overrideTheme.floatingLabelAlignment)); + expect(merged.isDense, isNot(overrideTheme.isDense)); + expect(merged.contentPadding, overrideTheme.contentPadding); + expect(merged.isCollapsed, isNot(overrideTheme.isCollapsed)); + expect(merged.iconColor, overrideTheme.iconColor); + expect(merged.prefixStyle, overrideTheme.prefixStyle); + expect(merged.prefixIconColor, overrideTheme.prefixIconColor); + expect(merged.suffixStyle, overrideTheme.suffixStyle); + expect(merged.suffixIconColor, overrideTheme.suffixIconColor); + expect(merged.counterStyle, overrideTheme.counterStyle); + expect(merged.filled, isNot(overrideTheme.filled)); + expect(merged.fillColor, overrideTheme.fillColor); + expect(merged.activeIndicatorBorder, overrideTheme.activeIndicatorBorder); + expect(merged.outlineBorder, overrideTheme.outlineBorder); + expect(merged.focusColor, overrideTheme.focusColor); + expect(merged.hoverColor, overrideTheme.hoverColor); + expect(merged.errorBorder, overrideTheme.errorBorder); + expect(merged.focusedBorder, overrideTheme.focusedBorder); + expect(merged.focusedErrorBorder, overrideTheme.focusedErrorBorder); + expect(merged.disabledBorder, overrideTheme.disabledBorder); + expect(merged.enabledBorder, overrideTheme.enabledBorder); + expect(merged.border, overrideTheme.border); + expect(merged.alignLabelWithHint, isNot(overrideTheme.alignLabelWithHint)); + expect(merged.constraints, overrideTheme.constraints); + }); + + group('Local InputDecorationTheme overrides default', () { + testWidgets('labelStyle', (WidgetTester tester) async { + const labelStyle = TextStyle(color: Colors.indigo); + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(labelStyle: labelStyle), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getLabelStyle(tester).color, labelStyle.color); + }); + + testWidgets('floatingLabelStyle', (WidgetTester tester) async { + const floatingLabelStyle = TextStyle(color: Colors.indigo); + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, // Label appears floating above input field. + localInputDecorationTheme: const InputDecorationThemeData( + floatingLabelStyle: floatingLabelStyle, + ), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + }); + + testWidgets('helperStyle', (WidgetTester tester) async { + const helperStyle = TextStyle(color: Colors.indigo); + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(helperStyle: helperStyle), + decoration: const InputDecoration(labelText: labelText, helperText: helperText), + ), + ); + + expect(getHelperStyle(tester).color, helperStyle.color); + }); + + testWidgets('helperMaxLines', (WidgetTester tester) async { + const helperMaxLines = 2; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(helperMaxLines: helperMaxLines), + decoration: const InputDecoration(labelText: labelText, helperText: threeLines), + ), + ); + + const helperGap = 4.0; + const helperHeight = 16.0; + const containerHeight = 56.0; + final Rect helperRect = tester.getRect(find.text(threeLines)); + expect(helperRect.height, closeTo(helperHeight * helperMaxLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + helperHeight * helperMaxLines, 0.25), + ); + }); + + testWidgets('hintStyle', (WidgetTester tester) async { + const hintStyle = TextStyle(color: Colors.indigo); + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(hintStyle: hintStyle), + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + expect(getHintStyle(tester).color, hintStyle.color); + }); + + testWidgets('hintFadeDuration', (WidgetTester tester) async { + const hintFadeDuration = Duration(milliseconds: 404); + + // Build once with empty content. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + localInputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: hintFadeDuration, + ), + decoration: const InputDecoration(hintText: hintText), + ), + ); + + // Hint is visible (opacity 1.0). + expect(getHintOpacity(tester), 1.0); + + // Rebuild with non-empty content. + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: hintFadeDuration, + ), + decoration: const InputDecoration(hintText: hintText), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + await tester.pump(const Duration(milliseconds: 200)); + final double hintOpacity200ms = getHintOpacity(tester); + expect(hintOpacity200ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 200)); + final double hintOpacity400ms = getHintOpacity(tester); + expect(hintOpacity400ms, inExclusiveRange(0.0, hintOpacity200ms)); + await tester.pump(const Duration(milliseconds: 4)); + expect(getHintOpacity(tester), 0.0); + }); + + testWidgets('hintMaxLines', (WidgetTester tester) async { + const hintMaxLines = 2; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(hintMaxLines: hintMaxLines), + decoration: const InputDecoration.collapsed(hintText: threeLines), + ), + ); + + const hintLineHeight = 24.0; // Font size = 16 and font height = 1.5. + expect(getDecoratorRect(tester).size, const Size(800.0, hintMaxLines * hintLineHeight)); + }); + + testWidgets('errorStyle', (WidgetTester tester) async { + const errorStyle = TextStyle(color: Colors.indigo); + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + localInputDecorationTheme: const InputDecorationThemeData(errorStyle: errorStyle), + decoration: const InputDecoration(labelText: labelText, errorText: errorText), + ), + ); + + expect(getErrorStyle(tester).color, errorStyle.color); + }); + + testWidgets('errorMaxLines', (WidgetTester tester) async { + const errorMaxLines = 2; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(errorMaxLines: errorMaxLines), + decoration: const InputDecoration( + errorText: threeLines, + labelText: 'label', + filled: true, + ), + ), + ); + + const helperGap = 4.0; + const containerHeight = 56.0; + const errorHeight = 16.0; + + final Rect errorRect = tester.getRect(find.text(threeLines)); + expect(errorRect.height, closeTo(errorHeight * errorMaxLines, 0.25)); + expect( + getDecoratorRect(tester).height, + closeTo(containerHeight + helperGap + errorHeight * errorMaxLines, 0.25), + ); + }); + + testWidgets('floatingLabelBehavior', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + decoration: const InputDecoration(label: customLabel), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, 56.0)); + // Label line height is forced to 1.0 and font size is 16.0, + // the label should be vertically centered (20 pixels above and below). + expect(getCustomLabelRect(tester).top, 20.0); + expect(getCustomLabelRect(tester).bottom, 36.0); + }); + + testWidgets('floatingLabelAlignment', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + localInputDecorationTheme: const InputDecorationThemeData( + floatingLabelAlignment: FloatingLabelAlignment.center, + ), + ), + ); + // icon (40) + (decorator (800) - icon (40)) / 2 + expect(getLabelCenter(tester).dx, 420.0); + }); + + testWidgets('isDense', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(isDense: true), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getContainerRect(tester).height, 48.0); + }); + + testWidgets('contentPadding', (WidgetTester tester) async { + const double start = 11; + const double top = 13; + const double end = 15; + const double bottom = 17; + + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + contentPadding: EdgeInsetsDirectional.only( + start: start, + top: top, + end: end, + bottom: bottom, + ), + ), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + const labelHeight = 16.0; + const inputHeight = 24.0; + expect(getContainerRect(tester).height, top + labelHeight + inputHeight + bottom); + expect(getInputRect(tester).left, start); + expect(getInputRect(tester).right, 800 - end); + }); + + testWidgets('isCollapsed', (WidgetTester tester) async { + // Overall height for a collapsed InputDecorator is 24dp which is the input + // height (font size = 16, line height = 1.5). + const inputHeight = 24.0; + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + localInputDecorationTheme: const InputDecorationThemeData(isCollapsed: true), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getDecoratorRect(tester).size, const Size(800.0, inputHeight)); + }); + + testWidgets('iconColor', (WidgetTester tester) async { + const Color iconColor = Colors.indigo; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(iconColor: iconColor), + decoration: const InputDecoration(icon: Icon(Icons.cabin)), + ), + ); + + expect( + tester.widget<IconTheme>(find.widgetWithIcon(IconTheme, Icons.cabin).first).data.color, + iconColor, + ); + }); + + testWidgets('prefixStyle', (WidgetTester tester) async { + const prefixStyle = TextStyle(color: Colors.indigo); + const prefixLabel = 'prefix'; + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + localInputDecorationTheme: const InputDecorationThemeData(prefixStyle: prefixStyle), + decoration: const InputDecoration(prefix: Text(prefixLabel)), + ), + ); + final TextStyle effectivePrefixStyle = tester + .widget<RichText>( + find.descendant(of: find.text(prefixLabel), matching: find.byType(RichText)), + ) + .text + .style!; + expect(effectivePrefixStyle.color, prefixStyle.color); + }); + + testWidgets('prefixIconColor', (WidgetTester tester) async { + const Color prefixIconColor = Colors.indigo; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + prefixIconColor: prefixIconColor, + ), + decoration: const InputDecoration(prefixIcon: Icon(Icons.cabin)), + ), + ); + + expect( + tester.widget<IconTheme>(find.widgetWithIcon(IconTheme, Icons.cabin).first).data.color, + prefixIconColor, + ); + }); + + testWidgets('prefixIconConstraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + prefixIconConstraints: BoxConstraints(minWidth: 60, minHeight: 60), + ), + decoration: const InputDecoration(prefixIcon: Icon(Icons.cabin)), + ), + ); + + expect(tester.getSize(find.byIcon(Icons.cabin)), const Size(60, 60)); + }); + + testWidgets('suffixStyle', (WidgetTester tester) async { + const suffixStyle = TextStyle(color: Colors.indigo); + const suffixLabel = 'suffix'; + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + localInputDecorationTheme: const InputDecorationThemeData(suffixStyle: suffixStyle), + decoration: const InputDecoration(suffix: Text(suffixLabel)), + ), + ); + final TextStyle effectiveSuffixStyle = tester + .widget<RichText>( + find.descendant(of: find.text(suffixLabel), matching: find.byType(RichText)), + ) + .text + .style!; + expect(effectiveSuffixStyle.color, suffixStyle.color); + }); + + testWidgets('suffixIconColor', (WidgetTester tester) async { + const Color suffixIconColor = Colors.indigo; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + suffixIconColor: suffixIconColor, + ), + decoration: const InputDecoration(suffixIcon: Icon(Icons.cabin)), + ), + ); + + expect( + tester.widget<IconTheme>(find.widgetWithIcon(IconTheme, Icons.cabin).first).data.color, + suffixIconColor, + ); + }); + + testWidgets('suffixIconConstraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + suffixIconConstraints: BoxConstraints(minWidth: 60, minHeight: 60), + ), + decoration: const InputDecoration(suffixIcon: Icon(Icons.cabin)), + ), + ); + + expect(tester.getSize(find.byIcon(Icons.cabin)), const Size(60, 60)); + }); + + testWidgets('counterStyle', (WidgetTester tester) async { + const counterStyle = TextStyle(color: Colors.indigo); + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(counterStyle: counterStyle), + decoration: const InputDecoration(labelText: labelText, counterText: counterText), + ), + ); + + expect(getCounterStyle(tester).color, counterStyle.color); + }); + + testWidgets('filled and fillColor', (WidgetTester tester) async { + const Color fillColor = Colors.indigo; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + filled: true, + fillColor: fillColor, + ), + decoration: const InputDecoration(labelText: labelText, counterText: counterText), + ), + ); + + expect(findBorderPainter(), paints..rrect(style: PaintingStyle.fill, color: fillColor)); + }); + + testWidgets('activeIndicatorBorder', (WidgetTester tester) async { + const activeIndicatorBorder = BorderSide(color: Colors.amber, width: 2.0); + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + filled: true, + activeIndicatorBorder: activeIndicatorBorder, + ), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + expect(getBorderColor(tester), activeIndicatorBorder.color); + expect(getBorderWeight(tester), activeIndicatorBorder.width); + }); + + testWidgets('hoverColor', (WidgetTester tester) async { + const Color hoverColor = Colors.indigo; + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + filled: true, + hoverColor: hoverColor, + ), + isFocused: true, + isHovering: true, + decoration: const InputDecoration(labelText: labelText, helperText: helperText), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color focusColor = theme.colorScheme.surfaceContainerHighest; + expect( + findBorderPainter(), + paints..rrect(style: PaintingStyle.fill, color: Color.alphaBlend(hoverColor, focusColor)), + ); + }); + + testWidgets('errorBorder', (WidgetTester tester) async { + const InputBorder errorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 1.5), + ); + + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(errorBorder: errorBorder), + decoration: const InputDecoration(errorText: 'error'), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), errorBorder); + }); + + testWidgets('focusedErrorBorder', (WidgetTester tester) async { + const InputBorder focusedErrorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 1.5), + ); + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + localInputDecorationTheme: const InputDecorationThemeData( + focusedErrorBorder: focusedErrorBorder, + ), + decoration: const InputDecoration(errorText: 'error'), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), focusedErrorBorder); + }); + + testWidgets('focusedBorder', (WidgetTester tester) async { + const InputBorder focusedBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.green, width: 1.5), + ); + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + localInputDecorationTheme: const InputDecorationThemeData(focusedBorder: focusedBorder), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), focusedBorder); + }); + + testWidgets('enabledBorder', (WidgetTester tester) async { + const InputBorder enabledBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 5.0), + ); + + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(enabledBorder: enabledBorder), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), enabledBorder); + }); + + testWidgets('disabledBorder', (WidgetTester tester) async { + const InputBorder disabledBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 3.0), + ); + + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(disabledBorder: disabledBorder), + decoration: const InputDecoration(enabled: false), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), disabledBorder); + }); + + testWidgets('border', (WidgetTester tester) async { + const borderRadius = BorderRadius.all(Radius.circular(6.0)); + const InputBorder border = OutlineInputBorder(borderRadius: borderRadius); + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(border: border), + ), + ); + + // The real instance of border is created based on the given border. + // The type and the borderRadius should be the same as the given border. + expect(getBorder(tester), isA<OutlineInputBorder>()); + expect(getBorderRadius(tester), borderRadius); + }); + + testWidgets('alignLabelWithHint', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData(alignLabelWithHint: true), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.ac_unit), + labelText: labelText, + border: OutlineInputBorder(), + ), + isFocused: true, + ), + ); + + expect(getLabelRect(tester).left, getInputRect(tester).left); + }); + + testWidgets('constraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + localInputDecorationTheme: const InputDecorationThemeData( + constraints: BoxConstraints(maxWidth: 300, maxHeight: 40), + ), + decoration: const InputDecoration(labelText: labelText), + ), + ); + + // Theme settings should make it 300x40 pixels. + expect(getDecoratorRect(tester).size, const Size(300, 40)); + }); + + testWidgets('visualDensity', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + localInputDecorationTheme: const InputDecorationThemeData( + visualDensity: VisualDensity(horizontal: 2.0, vertical: 2.0), + ), + decoration: const InputDecoration(labelText: labelText, hintText: hintText), + ), + ); + + // Overall height for this InputDecorator is 64dp: + // 12 - top padding (8 plus 4 due to increased visual density) + // 12 - floating label (font size = 16 * 0.75, line height is forced to 1.0) + // 4 - gap between label and input (this is not part of the M3 spec) + // 24 - input text (font size = 16, line height = 1.5) + // 12 - bottom padding (8 plus 4 due to increased visual density) + expect(getDecoratorRect(tester).size.height, 64.0); + }); + }); + + testWidgets('Helper and counter are correctly styled', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration( + filled: true, + labelText: labelText, + helperText: helperText, + counterText: counterText, + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(findDecorator())); + final Color expectedColor = theme.colorScheme.onSurfaceVariant; + final TextStyle expectedStyle = theme.textTheme.bodySmall!.copyWith(color: expectedColor); + expect(getHelperStyle(tester), expectedStyle); + expect(getCounterStyle(tester), expectedStyle); + }); + + testWidgets('Prefix IconButton inherits IconButtonTheme', (WidgetTester tester) async { + const IconData prefixIcon = Icons.person; + const backgroundColor = Color(0xffff0000); + const foregroundColor = Color(0xff00ff00); + const overlayColor = Color(0xff0000ff); + const shadowColor = Color(0xff0ff0ff); + const elevation = 4.0; + const shape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))); + const iconButtonStyle = ButtonStyle( + backgroundColor: MaterialStatePropertyAll<Color>(backgroundColor), + foregroundColor: MaterialStatePropertyAll<Color>(foregroundColor), + overlayColor: MaterialStatePropertyAll<Color>(overlayColor), + shadowColor: MaterialStatePropertyAll<Color>(shadowColor), + elevation: MaterialStatePropertyAll<double>(elevation), + shape: MaterialStatePropertyAll<OutlinedBorder>(shape), + ); + + await tester.pumpWidget( + IconButtonTheme( + data: const IconButtonThemeData(style: iconButtonStyle), + child: buildInputDecorator( + decoration: InputDecoration( + prefixIcon: IconButton(onPressed: () {}, icon: const Icon(prefixIcon)), + ), + ), + ), + ); + + final Finder iconMaterial = find.descendant( + of: find.widgetWithIcon(IconButton, prefixIcon), + matching: find.byType(Material), + ); + final Material material = tester.widget<Material>(iconMaterial); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + + expect(getIconStyle(tester, prefixIcon)?.color, foregroundColor); + + final Offset center = tester.getCenter(find.byIcon(prefixIcon)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor)); + }); + + testWidgets('Suffix IconButton inherits IconButtonTheme', (WidgetTester tester) async { + const IconData suffixIcon = Icons.delete; + const backgroundColor = Color(0xffff0000); + const foregroundColor = Color(0xff00ff00); + const overlayColor = Color(0xff0000ff); + const shadowColor = Color(0xff0ff0ff); + const elevation = 4.0; + const shape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))); + const iconButtonStyle = ButtonStyle( + backgroundColor: MaterialStatePropertyAll<Color>(backgroundColor), + foregroundColor: MaterialStatePropertyAll<Color>(foregroundColor), + overlayColor: MaterialStatePropertyAll<Color>(overlayColor), + shadowColor: MaterialStatePropertyAll<Color>(shadowColor), + elevation: MaterialStatePropertyAll<double>(elevation), + shape: MaterialStatePropertyAll<OutlinedBorder>(shape), + ); + + await tester.pumpWidget( + IconButtonTheme( + data: const IconButtonThemeData(style: iconButtonStyle), + child: buildInputDecorator( + decoration: InputDecoration( + suffixIcon: IconButton(onPressed: () {}, icon: const Icon(suffixIcon)), + ), + ), + ), + ); + + final Finder iconMaterial = find.descendant( + of: find.widgetWithIcon(IconButton, suffixIcon), + matching: find.byType(Material), + ); + final Material material = tester.widget<Material>(iconMaterial); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + + expect(getIconStyle(tester, suffixIcon)?.color, foregroundColor); + + final Offset center = tester.getCenter(find.byIcon(suffixIcon)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor)); + }); + + testWidgets('Prefix IconButton color respects IconButtonTheme foreground color states', ( + WidgetTester tester, + ) async { + const IconData prefixIcon = Icons.person; + const iconErrorColor = Color(0xffff0000); + const iconColor = Color(0xff00ff00); + final iconButtonStyle = ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + if (states.contains(WidgetState.error)) { + return iconErrorColor; + } + return iconColor; + }), + ); + + // Test the prefix IconButton color when there is an error text. + await tester.pumpWidget( + buildInputDecorator( + iconButtonTheme: IconButtonThemeData(style: iconButtonStyle), + decoration: InputDecoration( + errorText: 'error', + prefixIcon: IconButton(onPressed: () {}, icon: const Icon(prefixIcon)), + ), + ), + ); + + expect(getIconStyle(tester, prefixIcon)?.color, iconErrorColor); + + // Test the prefix IconButton color when there is no error text. + await tester.pumpWidget( + buildInputDecorator( + iconButtonTheme: IconButtonThemeData(style: iconButtonStyle), + decoration: InputDecoration( + prefixIcon: IconButton(onPressed: () {}, icon: const Icon(prefixIcon)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getIconStyle(tester, prefixIcon)?.color, iconColor); + }); + + testWidgets('Suffix IconButton color respects IconButtonTheme foreground color states', ( + WidgetTester tester, + ) async { + const IconData suffixIcon = Icons.search; + const iconErrorColor = Color(0xffff0000); + const iconColor = Color(0xff00ff00); + final iconButtonStyle = ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + if (states.contains(WidgetState.error)) { + return iconErrorColor; + } + return iconColor; + }), + ); + + // Test the prefix IconButton color when there is an error text. + await tester.pumpWidget( + buildInputDecorator( + iconButtonTheme: IconButtonThemeData(style: iconButtonStyle), + decoration: InputDecoration( + errorText: 'error', + suffixIcon: IconButton(onPressed: () {}, icon: const Icon(suffixIcon)), + ), + ), + ); + + expect(getIconStyle(tester, suffixIcon)?.color, iconErrorColor); + + // Test the prefix IconButton color when there is no error text. + await tester.pumpWidget( + buildInputDecorator( + iconButtonTheme: IconButtonThemeData(style: iconButtonStyle), + decoration: InputDecoration( + suffixIcon: IconButton(onPressed: () {}, icon: const Icon(suffixIcon)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getIconStyle(tester, suffixIcon)?.color, iconColor); + }); + + group('Custom borders', () { + testWidgets('InputDecoration shows custom focused border', (WidgetTester tester) async { + const InputBorder focusedBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 5.0), + ); + + await tester.pumpWidget( + buildInputDecorator( + isFocused: true, + decoration: const InputDecoration(focusedBorder: focusedBorder), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), focusedBorder); + }); + + testWidgets('InputDecoration shows custom error borders', (WidgetTester tester) async { + const InputBorder errorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 1.5), + ); + const InputBorder focusedErrorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 5.0), + ); + + Future<void> checkBorders({ + bool isFocused = false, + bool enabled = true, + String? errorText, + Widget? error, + required InputBorder expectedBorder, + }) async { + await tester.pumpWidget( + buildInputDecorator( + isFocused: isFocused, + decoration: InputDecoration( + errorText: errorText, + error: error, + enabled: enabled, + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), expectedBorder); + } + + // Test with errorText. + await checkBorders(isFocused: true, errorText: 'error', expectedBorder: focusedErrorBorder); + await checkBorders(errorText: 'error', expectedBorder: errorBorder); + await checkBorders(enabled: false, errorText: 'error', expectedBorder: errorBorder); + + // Test with error widget. + const Widget error = Text('error'); + await checkBorders(isFocused: true, error: error, expectedBorder: focusedErrorBorder); + await checkBorders(error: error, expectedBorder: errorBorder); + await checkBorders(enabled: false, error: error, expectedBorder: errorBorder); + }); + + testWidgets('InputDecoration shows custom enabled border', (WidgetTester tester) async { + const InputBorder enabledBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 5.0), + ); + + await tester.pumpWidget( + buildInputDecorator(decoration: const InputDecoration(enabledBorder: enabledBorder)), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), enabledBorder); + }); + + testWidgets('InputDecoration shows custom disabled border', (WidgetTester tester) async { + const InputBorder disabledBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 3.0), + ); + + await tester.pumpWidget( + buildInputDecorator( + decoration: const InputDecoration(enabled: false, disabledBorder: disabledBorder), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), disabledBorder); + }); + }); + + group('Material2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + Widget buildInputDecoratorM2({ + InputDecoration decoration = const InputDecoration(), + ThemeData? theme, + InputDecorationThemeData? inputDecorationTheme, + TextDirection textDirection = TextDirection.ltr, + bool expands = false, + bool isEmpty = false, + bool isFocused = false, + bool isHovering = false, + bool useIntrinsicWidth = false, + TextStyle? baseStyle, + TextAlignVertical? textAlignVertical, + VisualDensity? visualDensity, + Widget child = const Text('text', style: TextStyle(fontSize: 16.0)), + }) { + Widget widget = InputDecorator( + expands: expands, + decoration: decoration, + isEmpty: isEmpty, + isFocused: isFocused, + isHovering: isHovering, + baseStyle: baseStyle, + textAlignVertical: textAlignVertical, + child: child, + ); + + if (useIntrinsicWidth) { + widget = IntrinsicWidth(child: widget); + } + + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return Theme( + data: (theme ?? Theme.of(context)).copyWith( + inputDecorationTheme: inputDecorationTheme, + visualDensity: visualDensity, + textTheme: const TextTheme(bodyLarge: TextStyle(fontSize: 16.0)), + ), + child: Align( + alignment: Alignment.topLeft, + child: Directionality(textDirection: textDirection, child: widget), + ), + ); + }, + ), + ), + ); + } + + testWidgets('InputDecorator input/label text layout', (WidgetTester tester) async { + // The label appears above the input text + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(labelText: 'label'), + ), + ); + await tester.pumpAndSettle(); + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + + // The label appears within the input when there is no text content + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration(labelText: 'label'), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + + // The label appears above the input text when there is no content and floatingLabelBehavior is always + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + + // The label appears within the input text when there is content and floatingLabelBehavior is never + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + + // isFocused: true increases the border's weight from 1.0 to 2.0 + // but does not change the overall height. + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + isFocused: true, + decoration: const InputDecoration(labelText: 'label'), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + + // isEmpty: true causes the label to be aligned with the input text + await tester.pumpWidget( + buildInputDecoratorM2(isEmpty: true, decoration: const InputDecoration(labelText: 'label')), + ); + + // The label animates downwards from it's initial position + // above the input text. The animation's duration is 167ms. + { + await tester.pump(const Duration(milliseconds: 50)); + final double labelY50ms = tester.getTopLeft(find.text('label')).dy; + expect(labelY50ms, inExclusiveRange(12.0, 20.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double labelY100ms = tester.getTopLeft(find.text('label')).dy; + expect(labelY100ms, inExclusiveRange(labelY50ms, 20.0)); + } + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + + // isFocused: true causes the label to move back up above the input text. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: 'label'), + ), + ); + + // The label animates upwards from it's initial position + // above the input text. The animation's duration is 167ms. + await tester.pump(const Duration(milliseconds: 50)); + final double labelY50ms = tester.getTopLeft(find.text('label')).dy; + expect(labelY50ms, inExclusiveRange(12.0, 28.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double labelY100ms = tester.getTopLeft(find.text('label')).dy; + expect(labelY100ms, inExclusiveRange(12.0, labelY50ms)); + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + + // enabled: false produces a hairline border if filled: false (the default) + // The widget's size and layout is the same as for enabled: true. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration(labelText: 'label', enabled: false), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderWeight(tester), 0.0); + + // enabled: false produces a transparent border if filled: true. + // The widget's size and layout is the same as for enabled: true. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration(labelText: 'label', enabled: false, filled: true), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderColor(tester), Colors.transparent); + + // alignLabelWithHint: true positions the label at the text baseline, + // aligned with the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + labelText: 'label', + alignLabelWithHint: true, + hintText: 'hint', + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect( + tester.getBottomLeft(find.text('label')).dy, + tester.getBottomLeft(find.text('hint')).dy, + ); + }); + + testWidgets('InputDecorator input/label widget layout', (WidgetTester tester) async { + const key = Key('l'); + + // The label appears above the input text. + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.byKey(key)).dy, 12.0); + expect(tester.getBottomLeft(find.byKey(key)).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + + // The label appears within the input when there is no text content. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.byKey(key)).dy, 20.0); + + // The label appears above the input text when there is no content and the + // floatingLabelBehavior is set to always. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.byKey(key)).dy, 12.0); + + // The label appears within the input text when there is content and + // the floatingLabelBehavior is set to never. + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.byKey(key)).dy, 20.0); + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect(tester.getTopLeft(find.byKey(key)).dy, 20.0); + + // isFocused: true increases the border's weight from 1.0 to 2.0 + // but does not change the overall height. + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + isFocused: true, + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.byKey(key)).dy, 12.0); + expect(tester.getBottomLeft(find.byKey(key)).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + + // isEmpty: true causes the label to be aligned with the input text. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + ), + ), + ); + + // The label animates downwards from it's initial position + // above the input text. The animation's duration is 167ms. + await tester.pump(const Duration(milliseconds: 50)); + final double labelY50ms = tester.getTopLeft(find.byKey(key)).dy; + expect(labelY50ms, inExclusiveRange(12.0, 20.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double labelY100ms = tester.getTopLeft(find.byKey(key)).dy; + expect(labelY100ms, inExclusiveRange(labelY50ms, 20.0)); + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.byKey(key)).dy, 20.0); + expect(tester.getBottomLeft(find.byKey(key)).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + + // isFocused: true causes the label to move back up above the input text. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + ), + ), + ); + + // The label animates upwards from it's initial position + // above the input text. The animation's duration is 167ms. + { + await tester.pump(const Duration(milliseconds: 50)); + final double labelY50ms = tester.getTopLeft(find.byKey(key)).dy; + expect(labelY50ms, inExclusiveRange(12.0, 28.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double labelY100ms = tester.getTopLeft(find.byKey(key)).dy; + expect(labelY100ms, inExclusiveRange(12.0, labelY50ms)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.byKey(key)).dy, 12.0); + expect(tester.getBottomLeft(find.byKey(key)).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + + // enabled: false produces a hairline border if filled: false (the default) + // The widget's size and layout is the same as for enabled: true. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + enabled: false, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.byKey(key)).dy, 20.0); + expect(tester.getBottomLeft(find.byKey(key)).dy, 36.0); + expect(getBorderWeight(tester), 0.0); + + // enabled: false produces a transparent border if filled: true. + // The widget's size and layout is the same as for enabled: true. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + enabled: false, + filled: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.byKey(key)).dy, 20.0); + expect(tester.getBottomLeft(find.byKey(key)).dy, 36.0); + expect(getBorderColor(tester), Colors.transparent); + + // alignLabelWithHint: true positions the label at the text baseline, + // aligned with the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + label: Text.rich( + TextSpan( + children: <InlineSpan>[ + TextSpan(text: 'label'), + WidgetSpan( + child: Text('*', style: TextStyle(color: Colors.red)), + ), + ], + ), + key: key, + ), + alignLabelWithHint: true, + hintText: 'hint', + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.byKey(key)).dy, tester.getTopLeft(find.text('hint')).dy); + expect(tester.getBottomLeft(find.byKey(key)).dy, tester.getBottomLeft(find.text('hint')).dy); + }); + + testWidgets('InputDecorator floating label animation duration and curve', ( + WidgetTester tester, + ) async { + Future<void> pumpInputDecorator({required bool isFocused}) async { + return tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: isFocused, + decoration: const InputDecoration( + labelText: 'label', + floatingLabelBehavior: FloatingLabelBehavior.auto, + ), + ), + ); + } + + await pumpInputDecorator(isFocused: false); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + + // The label animates upwards and scales down. + // The animation duration is 167ms and the curve is fastOutSlowIn. + await pumpInputDecorator(isFocused: true); + await tester.pump(const Duration(milliseconds: 42)); + expect(tester.getTopLeft(find.text('label')).dy, closeTo(18.06, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(tester.getTopLeft(find.text('label')).dy, closeTo(13.78, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(tester.getTopLeft(find.text('label')).dy, closeTo(12.31, 0.5)); + await tester.pump(const Duration(milliseconds: 41)); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + + // If the animation changes direction without first reaching the + // AnimationStatus.completed or AnimationStatus.dismissed status, + // the CurvedAnimation stays on the same curve in the opposite direction. + // The pumpAndSettle is used to prevent this behavior. + await tester.pumpAndSettle(); + + // The label animates downwards and scales up. + // The animation duration is 167ms and the curve is fastOutSlowIn. + await pumpInputDecorator(isFocused: false); + await tester.pump(const Duration(milliseconds: 42)); + expect(tester.getTopLeft(find.text('label')).dy, closeTo(13.94, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(tester.getTopLeft(find.text('label')).dy, closeTo(18.22, 0.5)); + await tester.pump(const Duration(milliseconds: 42)); + expect(tester.getTopLeft(find.text('label')).dy, closeTo(19.69, 0.5)); + await tester.pump(const Duration(milliseconds: 41)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + }); + + group('alignLabelWithHint', () { + group('expands false', () { + testWidgets('multiline TextField no-strut', (WidgetTester tester) async { + const text = 'text'; + final focusNode = FocusNode(); + final controller = TextEditingController(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: 8, + decoration: InputDecoration( + labelText: 'label', + alignLabelWithHint: alignLabelWithHint, + hintText: 'hint', + ), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ); + } + + // alignLabelWithHint: false centers the label in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 76.0); + expect(tester.getBottomLeft(find.text('label')).dy, 92.0); + + // Entering text still happens at the top. + await tester.enterText(find.byType(TextField), text); + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + controller.clear(); + focusNode.unfocus(); + + // alignLabelWithHint: true aligns the label with the hint. + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect( + tester.getBottomLeft(find.text('label')).dy, + tester.getBottomLeft(find.text('hint')).dy, + ); + + // Entering text still happens at the top. + await tester.enterText(find.byType(TextField), text); + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + controller.clear(); + focusNode.unfocus(); + }); + + testWidgets('multiline TextField', (WidgetTester tester) async { + const text = 'text'; + final focusNode = FocusNode(); + final controller = TextEditingController(); + addTearDown(() { + focusNode.dispose(); + controller.dispose(); + }); + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: 8, + decoration: InputDecoration( + labelText: 'label', + alignLabelWithHint: alignLabelWithHint, + hintText: 'hint', + ), + ), + ), + ), + ); + } + + // alignLabelWithHint: false centers the label in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 76.0); + expect(tester.getBottomLeft(find.text('label')).dy, 92.0); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), text); + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + controller.clear(); + focusNode.unfocus(); + + // alignLabelWithHint: true aligns the label with the hint. + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect( + tester.getBottomLeft(find.text('label')).dy, + tester.getBottomLeft(find.text('hint')).dy, + ); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), text); + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + controller.clear(); + focusNode.unfocus(); + }); + }); + + group('expands true', () { + testWidgets('multiline TextField', (WidgetTester tester) async { + const text = 'text'; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: null, + expands: true, + decoration: InputDecoration( + labelText: 'label', + alignLabelWithHint: alignLabelWithHint, + hintText: 'hint', + ), + ), + ), + ), + ); + } + + // alignLabelWithHint: false centers the label in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 292.0); + expect(tester.getBottomLeft(find.text('label')).dy, 308.0); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), text); + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + controller.clear(); + focusNode.unfocus(); + + // alignLabelWithHint: true aligns the label with the hint at the top. + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 28.0); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect( + tester.getBottomLeft(find.text('label')).dy, + tester.getBottomLeft(find.text('hint')).dy, + ); + + // Entering text still happens at the top. + await tester.enterText(find.byType(InputDecorator), text); + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + controller.clear(); + focusNode.unfocus(); + }); + + testWidgets('multiline TextField with outline border', (WidgetTester tester) async { + const text = 'text'; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + maxLines: null, + expands: true, + decoration: InputDecoration( + labelText: 'label', + alignLabelWithHint: alignLabelWithHint, + hintText: 'hint', + border: const OutlineInputBorder(borderRadius: BorderRadius.zero), + ), + ), + ), + ), + ); + } + + // alignLabelWithHint: false centers the label in the TextField. + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 292.0); + expect(tester.getBottomLeft(find.text('label')).dy, 308.0); + + // Entering text happens in the center as well. + await tester.enterText(find.byType(InputDecorator), text); + expect(tester.getTopLeft(find.text(text)).dy, 292.0); + controller.clear(); + focusNode.unfocus(); + + // alignLabelWithHint: true aligns keeps the label in the center because + // that's where the hint is. + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 292.0); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect( + tester.getBottomLeft(find.text('label')).dy, + tester.getBottomLeft(find.text('hint')).dy, + ); + + // Entering text still happens in the center. + await tester.enterText(find.byType(InputDecorator), text); + expect(tester.getTopLeft(find.text(text)).dy, 292.0); + controller.clear(); + focusNode.unfocus(); + }); + }); + }); + + // Overall height for this InputDecorator is 40.0dps + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + testWidgets('InputDecorator input/hint layout', (WidgetTester tester) async { + // The hint aligns with the input text + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration(hintText: 'hint'), + ), + ); + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getBottomLeft(find.text('text')).dy, 32.0); + expect(tester.getTopLeft(find.text('hint')).dy, 16.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 32.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + + expect(tester.getSize(find.text('hint')).width, tester.getSize(find.text('text')).width); + }); + + testWidgets('InputDecorator input/label/hint layout', (WidgetTester tester) async { + // Label is visible, hint is not (opacity 0.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // Overall height for this InputDecorator is 56dps. When the + // label is "floating" (empty input or no focus) the layout is: + // + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + // + // When the label is not floating, it's vertically centered. + // + // 20 - top padding + // 16 - label (font size 16dps) + // 20 - bottom padding (empty input text still appears here) + + // The label is not floating so it's vertically centered. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + + // Label moves upwards, hint is visible (opacity 1.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(tester.getTopLeft(find.text('hint')).dy, 28.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 44.0); + expect(getOpacity(tester, 'hint'), 1.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(tester.getTopLeft(find.text('hint')).dy, 28.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 44.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('InputDecorator input/label/hint dense layout', (WidgetTester tester) async { + // Label is visible, hint is not (opacity 0.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration(labelText: 'label', hintText: 'hint', isDense: true), + ), + ); + + // Overall height for this InputDecorator is 48dps. When the + // label is "floating" (empty input or no focus) the layout is: + // + // 8 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 8 - bottom padding + // + // When the label is not floating, it's vertically centered. + // + // 16 - top padding + // 16 - label (font size 16dps) + // 16 - bottom padding (empty input text still appears here) + + // The label is not floating so it's vertically centered. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 16.0); + expect(tester.getBottomLeft(find.text('label')).dy, 32.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + + // Label is visible, hint is not (opacity 0.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint', isDense: true), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 8.0); + expect(tester.getBottomLeft(find.text('label')).dy, 20.0); + expect(getOpacity(tester, 'hint'), 1.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('InputDecorator default hint animation duration', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint is not visible (opacity 0.0). + expect(getOpacity(tester, 'hint'), 0.0); + + // Focus to show the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + expect(getOpacity(tester, 'hint'), 1.0); + } + + // Unfocus to hide the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms)); + await tester.pump(const Duration(milliseconds: 9)); + expect(getOpacity(tester, 'hint'), 0.0); + } + }); + + testWidgets('InputDecorator custom hint animation duration', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint is not visible (opacity 0.0). + expect(getOpacity(tester, 'hint'), 0.0); + + // Focus to show the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's duration is set to 120ms. + { + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getOpacity(tester, 'hint'); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getOpacity(tester, 'hint'); + expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getOpacity(tester, 'hint'), 1.0); + } + + // Unfocus to hide the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + hintFadeDuration: Duration(milliseconds: 120), + ), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getOpacity(tester, 'hint'); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getOpacity(tester, 'hint'); + expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getOpacity(tester, 'hint'), 0.0); + } + }); + + testWidgets('InputDecorator custom hint animation duration from theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + inputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: Duration(milliseconds: 120), + ), + isEmpty: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint is not visible (opacity 0.0). + expect(getOpacity(tester, 'hint'), 0.0); + + // Focus to show the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + inputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: Duration(milliseconds: 120), + ), + isEmpty: true, + isFocused: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's duration is set to 120ms. + { + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getOpacity(tester, 'hint'); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getOpacity(tester, 'hint'); + expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getOpacity(tester, 'hint'), 1.0); + } + + // Unfocus to hide the hint. + await tester.pumpWidget( + buildInputDecoratorM2( + inputDecorationTheme: const InputDecorationThemeData( + hintFadeDuration: Duration(milliseconds: 120), + ), + isEmpty: true, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's duration is set to 160ms. + { + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity50ms = getOpacity(tester, 'hint'); + expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 50)); + final double hintOpacity100ms = getOpacity(tester, 'hint'); + expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); + await tester.pump(const Duration(milliseconds: 50)); + expect(getOpacity(tester, 'hint'), 0.0); + } + }); + + testWidgets('InputDecorator with no input border', (WidgetTester tester) async { + // Label is visible, hint is not (opacity 0.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration(border: InputBorder.none), + ), + ); + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets('InputDecorator error/helper/counter layout', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + counterText: 'counter', + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 76dps. When the label is + // floating the layout is: + // + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + // + // When the label is not floating, it's vertically centered in the space + // above the subtext: + // + // 20 - top padding + // 16 - label (font size 16dps) + // 20 - bottom padding (empty input text still appears here) + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + + // isEmpty: true, the label is not floating + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('helper')), const Offset(12.0, 64.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 64.0)); + + // If errorText is specified then the helperText isn't shown + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + errorText: 'error', + helperText: 'helper', + counterText: 'counter', + filled: true, + ), + ), + ); + await tester.pumpAndSettle(); + + // isEmpty: false, the label _is_ floating + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('error')), const Offset(12.0, 64.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 64.0)); + expect(find.text('helper'), findsNothing); + + // Overall height for this dense layout InputDecorator is 68dps. When the + // label is floating the layout is: + // + // 8 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 8 - bottom padding + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + // + // When the label is not floating, it's vertically centered in the space + // above the subtext: + // + // 16 - top padding + // 16 - label (font size 16dps) + // 16 - bottom padding (empty input text still appears here) + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + // The layout of the error/helper/counter subtext doesn't change for dense layout. + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + isDense: true, + labelText: 'label', + errorText: 'error', + helperText: 'helper', + counterText: 'counter', + filled: true, + ), + ), + ); + await tester.pumpAndSettle(); + + // isEmpty: false, the label _is_ floating + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 68.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 8.0); + expect(tester.getBottomLeft(find.text('label')).dy, 20.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('error')), const Offset(12.0, 56.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 56.0)); + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + isDense: true, + labelText: 'label', + errorText: 'error', + helperText: 'helper', + counterText: 'counter', + filled: true, + ), + ), + ); + await tester.pumpAndSettle(); + + // isEmpty: false, the label is not floating + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 68.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 16.0); + expect(tester.getBottomLeft(find.text('label')).dy, 32.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('error')), const Offset(12.0, 56.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 56.0)); + }); + + testWidgets('InputDecoration errorMaxLines', (WidgetTester tester) async { + const kError1 = 'e0'; + const kError2 = 'e0\ne1'; + const kError3 = 'e0\ne1\ne2'; + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: kError3, + errorMaxLines: 3, + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 100dps: + // + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 36 - error text (3 lines, font size 12dps) + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 100.0)); + expect(tester.getTopLeft(find.text(kError3)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kError3)), const Offset(12.0, 100.0)); + + // Overall height for this InputDecorator is 12 less than the first + // one, 88dps, because errorText only occupies two lines. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: kError2, + errorMaxLines: 3, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 88.0)); + expect(tester.getTopLeft(find.text(kError2)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kError2)), const Offset(12.0, 88.0)); + + // Overall height for this InputDecorator is 24 less than the first + // one, 88dps, because errorText only occupies one line. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: kError1, + errorMaxLines: 3, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text(kError1)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kError1)), const Offset(12.0, 76.0)); + }); + + testWidgets('InputDecoration helperMaxLines', (WidgetTester tester) async { + const kHelper1 = 'e0'; + const kHelper2 = 'e0\ne1'; + const kHelper3 = 'e0\ne1\ne2'; + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: kHelper3, + helperMaxLines: 3, + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 100dps: + // + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 36 - helper text (3 lines, font size 12dps) + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 100.0)); + expect(tester.getTopLeft(find.text(kHelper3)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kHelper3)), const Offset(12.0, 100.0)); + + // Overall height for this InputDecorator is 12 less than the first + // one, 88dps, because helperText only occupies two lines. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: kHelper3, + helperMaxLines: 2, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 88.0)); + expect(tester.getTopLeft(find.text(kHelper3)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kHelper3)), const Offset(12.0, 88.0)); + + // Overall height for this InputDecorator is 12 less than the first + // one, 88dps, because helperText only occupies two lines. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: kHelper2, + helperMaxLines: 3, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 88.0)); + expect(tester.getTopLeft(find.text(kHelper2)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kHelper2)), const Offset(12.0, 88.0)); + + // Overall height for this InputDecorator is 24 less than the first + // one, 88dps, because helperText only occupies one line. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: kHelper1, + helperMaxLines: 3, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text(kHelper1)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kHelper1)), const Offset(12.0, 76.0)); + }); + + testWidgets('InputDecorator shows helper text', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2(decoration: const InputDecoration(helperText: 'helperText')), + ); + + expect(find.text('helperText'), findsOneWidget); + }); + + testWidgets('InputDecorator shows helper widget', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + helper: Text('helper', style: TextStyle(fontSize: 20.0)), + ), + ), + ); + + expect(find.text('helper'), findsOneWidget); + }); + + testWidgets('InputDecorator throws when helper text and helper widget are provided', ( + WidgetTester tester, + ) async { + expect(() { + buildInputDecoratorM2( + decoration: InputDecoration( + helperText: 'helperText', + helper: const Text('helper', style: TextStyle(fontSize: 20.0)), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('InputDecorator shows error text', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2(decoration: const InputDecoration(errorText: 'errorText')), + ); + + expect(find.text('errorText'), findsOneWidget); + }); + + testWidgets('InputDecoration shows error border for errorText and error widget', ( + WidgetTester tester, + ) async { + const InputBorder errorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 1.5), + ); + const InputBorder focusedErrorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 5.0), + ); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration( + errorText: 'error', + // enabled: true (default) + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), focusedErrorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + errorText: 'error', + // enabled: true (default) + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), errorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + errorText: 'error', + enabled: false, + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), errorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration( + error: Text('error'), + // enabled: true (default) + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), focusedErrorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + error: Text('error'), + // enabled: true (default) + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), errorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + error: Text('error'), + enabled: false, + errorBorder: errorBorder, + focusedErrorBorder: focusedErrorBorder, + ), + ), + ); + await tester.pumpAndSettle(); // Border changes are animated. + expect(getBorder(tester), errorBorder); + }); + + testWidgets('InputDecorator shows error widget', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration(error: Text('error', style: TextStyle(fontSize: 20.0))), + ), + ); + + expect(find.text('error'), findsOneWidget); + }); + + testWidgets('InputDecorator throws when error text and error widget are provided', ( + WidgetTester tester, + ) async { + expect(() { + buildInputDecoratorM2( + decoration: InputDecoration( + errorText: 'errorText', + error: const Text('error', style: TextStyle(fontSize: 20.0)), + ), + ); + }, throwsAssertionError); + }); + + testWidgets('InputDecorator prefix/suffix texts', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(prefixText: 'p', suffixText: 's', filled: true), + ), + ); + + // Overall height for this InputDecorator is 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + // + // The prefix and suffix wrap the input text and are left and right justified + // respectively. They should have the same height as the input text (16). + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.text('p')).height, 16.0); + expect(tester.getSize(find.text('s')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getTopLeft(find.text('p')).dy, 16.0); + expect(tester.getTopLeft(find.text('p')).dx, 12.0); + expect(tester.getTopLeft(find.text('s')).dy, 16.0); + expect(tester.getTopRight(find.text('s')).dx, 788.0); + + // layout is a row: [p text s] + expect(tester.getTopLeft(find.text('p')).dx, 12.0); + expect( + tester.getTopRight(find.text('p')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('text')).dx), + ); + expect( + tester.getTopRight(find.text('text')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('s')).dx), + ); + }); + + testWidgets('InputDecorator icon/prefix/suffix', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefixText: 'p', + suffixText: 's', + icon: Icon(Icons.android), + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.text('p')).height, 16.0); + expect(tester.getSize(find.text('s')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getTopLeft(find.text('p')).dy, 16.0); + expect(tester.getTopLeft(find.text('s')).dy, 16.0); + expect(tester.getTopRight(find.text('s')).dx, 788.0); + expect(tester.getSize(find.byType(Icon)).height, 24.0); + + // The 24dps high icon is centered on the 16dps high input line + expect(tester.getTopLeft(find.byType(Icon)).dy, 12.0); + + // layout is a row: [icon, p text s] + expect(tester.getTopLeft(find.byType(Icon)).dx, 0.0); + expect( + tester.getTopRight(find.byType(Icon)).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('p')).dx), + ); + expect( + tester.getTopRight(find.text('p')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('text')).dx), + ); + expect( + tester.getTopRight(find.text('text')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('s')).dx), + ); + }); + + testWidgets('InputDecorator prefix/suffix widgets', (WidgetTester tester) async { + const pKey = Key('p'); + const sKey = Key('s'); + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefix: Padding(key: pKey, padding: EdgeInsets.all(4.0), child: Text('p')), + suffix: Padding(key: sKey, padding: EdgeInsets.all(4.0), child: Text('s')), + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 48dps because + // the prefix and the suffix widget is surrounded with padding: + // 12 - top padding + // 4 - top prefix/suffix padding + // 16 - input text (font size 16dps) + // 4 - bottom prefix/suffix padding + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.byKey(pKey)).height, 24.0); + expect(tester.getSize(find.text('p')).height, 16.0); + expect(tester.getSize(find.byKey(sKey)).height, 24.0); + expect(tester.getSize(find.text('s')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0); + expect(tester.getTopLeft(find.text('p')).dy, 16.0); + expect(tester.getTopLeft(find.byKey(sKey)).dy, 12.0); + expect(tester.getTopLeft(find.text('s')).dy, 16.0); + expect(tester.getTopRight(find.byKey(sKey)).dx, 788.0); + expect(tester.getTopRight(find.text('s')).dx, 784.0); + + // layout is a row: [prefix text suffix] + expect(tester.getTopLeft(find.byKey(pKey)).dx, 12.0); + expect(tester.getTopRight(find.byKey(pKey)).dx, tester.getTopLeft(find.text('text')).dx); + expect( + tester.getTopRight(find.text('text')).dx, + lessThanOrEqualTo(tester.getTopRight(find.byKey(sKey)).dx), + ); + }); + + testWidgets('InputDecorator tall prefix', (WidgetTester tester) async { + const pKey = Key('p'); + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + // Set the fontSize so that everything works out to whole numbers. + child: const Text('text', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Overall height for this InputDecorator is ~127.2dps because + // the prefix is 100dps tall, but it aligns with the input's baseline, + // overlapping the input a bit. + // 12 - top padding + // 100 - total height of prefix + // -15 - input prefix overlap (distance input top to baseline = 20 * 0.75) + // 20 - input text (font size 16dps) + // 0 - bottom prefix/suffix padding + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)).width, 800.0); + expect(tester.getSize(find.byType(InputDecorator)).height, 129.0); + expect(tester.getSize(find.text('text')).height, 20.0); + expect(tester.getSize(find.byKey(pKey)).height, 100.0); + expect(tester.getTopLeft(find.text('text')).dy, 97); // 12 + 100 - 15 + expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0); + + // layout is a row: [prefix text suffix] + expect(tester.getTopLeft(find.byKey(pKey)).dx, 12.0); + expect(tester.getTopRight(find.byKey(pKey)).dx, tester.getTopLeft(find.text('text')).dx); + }); + + testWidgets('InputDecorator tall prefix with border', (WidgetTester tester) async { + const pKey = Key('p'); + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + // Set the fontSize so that everything works out to whole numbers. + child: const Text('text', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Overall height for this InputDecorator is ~127.2dps because + // the prefix is 100dps tall, but it aligns with the input's baseline, + // overlapping the input a bit. + // 24 - top padding + // 100 - total height of prefix + // -15 - input prefix overlap (distance input top to baseline, not exact) + // 20 - input text (font size 16dps) + // 0 - bottom prefix/suffix padding + // 16 - bottom padding + // When a border is present, the input text and prefix/suffix are centered + // within the input. Here, that will be content of height 106, centered + // within an input of height 145. That gives 20 pixels of space on each side + // of the content, so the prefix is positioned at 19, and the text is at + // 20+100-15=105. + + expect(tester.getSize(find.byType(InputDecorator)).width, 800.0); + expect(tester.getSize(find.byType(InputDecorator)).height, 145); + expect(tester.getSize(find.text('text')).height, 20.0); + expect(tester.getSize(find.byKey(pKey)).height, 100.0); + expect(tester.getTopLeft(find.text('text')).dy, 105); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 20.0); + + // layout is a row: [prefix text suffix] + expect(tester.getTopLeft(find.byKey(pKey)).dx, 12.0); + expect(tester.getTopRight(find.byKey(pKey)).dx, tester.getTopLeft(find.text('text')).dx); + }); + + testWidgets('InputDecorator prefixIcon/suffixIcon', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefixIcon: Icon(Icons.pages), + suffixIcon: Icon(Icons.satellite), + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 48dps because the prefix icon's minimum size + // is 48x48 and the rest of the elements only require 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.byIcon(Icons.pages)).height, 48.0); + expect(tester.getSize(find.byIcon(Icons.satellite)).height, 48.0); + expect(tester.getTopLeft(find.text('text')).dy, 12.0); + expect(tester.getTopLeft(find.byIcon(Icons.pages)).dy, 0.0); + expect(tester.getTopLeft(find.byIcon(Icons.satellite)).dy, 0.0); + expect(tester.getTopRight(find.byIcon(Icons.satellite)).dx, 800.0); + + // layout is a row: [icon text icon] + expect(tester.getTopLeft(find.byIcon(Icons.pages)).dx, 0.0); + expect( + tester.getTopRight(find.byIcon(Icons.pages)).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('text')).dx), + ); + expect( + tester.getTopRight(find.text('text')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.byIcon(Icons.satellite)).dx), + ); + }); + + testWidgets('Material2 - InputDecorator suffixIcon color in error state', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: TextField( + decoration: InputDecoration( + suffixIcon: IconButton(icon: const Icon(Icons.close), onPressed: () {}), + errorText: 'Error state', + filled: true, + ), + ), + ), + ), + ); + + final ThemeData theme = Theme.of(tester.element(find.byType(TextField))); + expect(getIconStyle(tester, Icons.close)?.color, theme.colorScheme.error); + }); + + testWidgets('InputDecorator prefixIconConstraints/suffixIconConstraints', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefixIcon: Icon(Icons.pages), + prefixIconConstraints: BoxConstraints(minWidth: 32, minHeight: 32), + suffixIcon: Icon(Icons.satellite), + suffixIconConstraints: BoxConstraints(minWidth: 25, minHeight: 25), + isDense: true, // has to be true to go below 48px height + ), + ), + ); + + // Overall height for this InputDecorator is 32px because the prefix icon + // is now a custom value + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 32.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.byIcon(Icons.pages)).height, 32.0); + expect(tester.getSize(find.byIcon(Icons.satellite)).height, 25.0); + + // (InputDecorator height - Text widget height) / 2 + expect(tester.getTopLeft(find.text('text')).dy, (32.0 - 16.0) / 2); + // prefixIcon should take up the entire height of InputDecorator + expect(tester.getTopLeft(find.byIcon(Icons.pages)).dy, 0.0); + // (InputDecorator height - suffixIcon height) / 2 + expect(tester.getTopLeft(find.byIcon(Icons.satellite)).dy, (32.0 - 25.0) / 2); + expect(tester.getTopRight(find.byIcon(Icons.satellite)).dx, 800.0); + }); + + testWidgets('prefix/suffix icons are centered when smaller than 48 by 48', ( + WidgetTester tester, + ) async { + const prefixKey = Key('prefix'); + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + prefixIcon: Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox(width: 8.0, height: 8.0, key: prefixKey), + ), + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 48dps because the prefix icon's minimum size + // is 48x48 and the rest of the elements only require 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getSize(find.byKey(prefixKey)).height, 16.0); + expect(tester.getTopLeft(find.byKey(prefixKey)).dy, 16.0); + }); + + testWidgets('InputDecorator respects reduced theme visualDensity', (WidgetTester tester) async { + // Label is visible, hint is not (opacity 0.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + visualDensity: VisualDensity.compact, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The label is not floating so it's vertically centered. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 16.0); + expect(tester.getBottomLeft(find.text('label')).dy, 32.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + + // Label moves upwards, hint is visible (opacity 1.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + visualDensity: VisualDensity.compact, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 8.0); + expect(tester.getBottomLeft(find.text('label')).dy, 20.0); + expect(tester.getTopLeft(find.text('hint')).dy, 24.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 40.0); + expect(getOpacity(tester, 'hint'), 1.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 2.0); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + visualDensity: VisualDensity.compact, + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(tester.getTopLeft(find.text('label')).dy, 8.0); + expect(tester.getBottomLeft(find.text('label')).dy, 20.0); + expect(tester.getTopLeft(find.text('hint')).dy, 24.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 40.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('InputDecorator respects increased theme visualDensity', ( + WidgetTester tester, + ) async { + // Label is visible, hint is not (opacity 0.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0), + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The label is not floating so it's vertically centered. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 64.0)); + expect(tester.getTopLeft(find.text('text')).dy, 32.0); + expect(tester.getBottomLeft(find.text('text')).dy, 48.0); + expect(tester.getTopLeft(find.text('label')).dy, 24.0); + expect(tester.getBottomLeft(find.text('label')).dy, 40.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 64.0); + expect(getBorderWeight(tester), 1.0); + + // Label moves upwards, hint is visible (opacity 1.0). + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0), + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 0.0 to 1.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(hintOpacity9ms, 1.0)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 64.0)); + expect(tester.getTopLeft(find.text('text')).dy, 32.0); + expect(tester.getBottomLeft(find.text('text')).dy, 48.0); + expect(tester.getTopLeft(find.text('label')).dy, 16.0); + expect(tester.getBottomLeft(find.text('label')).dy, 28.0); + expect(tester.getTopLeft(find.text('hint')).dy, 32.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 48.0); + expect(getOpacity(tester, 'hint'), 1.0); + expect(getBorderBottom(tester), 64.0); + expect(getBorderWeight(tester), 2.0); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0), + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // The hint's opacity animates from 1.0 to 0.0. + // The animation's default duration is 20ms. + { + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity9ms = getOpacity(tester, 'hint'); + expect(hintOpacity9ms, inExclusiveRange(0.0, 1.0)); + await tester.pump(const Duration(milliseconds: 9)); + final double hintOpacity18ms = getOpacity(tester, 'hint'); + expect(hintOpacity18ms, inExclusiveRange(0.0, hintOpacity9ms)); + } + + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 64.0)); + expect(tester.getTopLeft(find.text('text')).dy, 32.0); + expect(tester.getBottomLeft(find.text('text')).dy, 48.0); + expect(tester.getTopLeft(find.text('label')).dy, 16.0); + expect(tester.getBottomLeft(find.text('label')).dy, 28.0); + expect(tester.getTopLeft(find.text('hint')).dy, 32.0); + expect(tester.getBottomLeft(find.text('hint')).dy, 48.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderBottom(tester), 64.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('prefix/suffix icons increase height of decoration when larger than 48 by 48', ( + WidgetTester tester, + ) async { + const prefixKey = Key('prefix'); + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + prefixIcon: SizedBox(width: 100.0, height: 100.0, key: prefixKey), + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 100dps because the prefix icon's size + // is 100x100 and the rest of the elements only require 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 100.0)); + expect(tester.getSize(find.byKey(prefixKey)).height, 100.0); + expect(tester.getTopLeft(find.byKey(prefixKey)).dy, 0.0); + }); + + group('constraints', () { + testWidgets('No InputDecorator constraints', (WidgetTester tester) async { + await tester.pumpWidget(buildInputDecoratorM2()); + + // Should fill the screen width and be default height + expect(tester.getSize(find.byType(InputDecorator)), const Size(800, 48)); + }); + + testWidgets('InputDecoratorThemeData constraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + constraints: BoxConstraints(maxWidth: 300, maxHeight: 40), + ), + ), + ), + ); + + // Theme settings should make it 300x40 pixels + expect(tester.getSize(find.byType(InputDecorator)), const Size(300, 40)); + }); + + testWidgets('InputDecorator constraints', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + theme: ThemeData( + inputDecorationTheme: const InputDecorationThemeData( + constraints: BoxConstraints(maxWidth: 300, maxHeight: 40), + ), + ), + decoration: const InputDecoration( + constraints: BoxConstraints(maxWidth: 200, maxHeight: 32), + ), + ), + ); + + // InputDecoration.constraints should override the theme. It should be + // only 200x32 pixels + expect(tester.getSize(find.byType(InputDecorator)), const Size(200, 32)); + }); + }); + + group('textAlignVertical position', () { + group('simple case', () { + testWidgets('align top (default)', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, // so we have a tall input where align can vary + decoration: const InputDecoration(filled: true), + textAlignVertical: TextAlignVertical.top, // default when no border + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Same as the default case above. + expect(tester.getTopLeft(find.text(text)).dy, 12.0); + }); + + testWidgets('align center', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration(filled: true), + textAlignVertical: TextAlignVertical.center, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Below the top aligned case. + expect(tester.getTopLeft(find.text(text)).dy, 290.0); + }); + + testWidgets('align bottom', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration(filled: true), + textAlignVertical: TextAlignVertical.bottom, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Below the center aligned case. + expect(tester.getTopLeft(find.text(text)).dy, 568.0); + }); + + testWidgets('align as a double', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration(filled: true), + textAlignVertical: const TextAlignVertical(y: 0.75), + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // In between the center and bottom aligned cases. + expect(tester.getTopLeft(find.text(text)).dy, 498.5); + }); + + testWidgets('works with density and content padding', (WidgetTester tester) async { + const key = Key('child'); + const containerKey = Key('container'); + const totalHeight = 100.0; + const childHeight = 20.0; + const visualDensity = VisualDensity(vertical: VisualDensity.maximumDensity); + const contentPadding = EdgeInsets.only(top: 6, bottom: 14); + + await tester.pumpWidget( + Center( + child: SizedBox( + key: containerKey, + height: totalHeight, + child: buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: contentPadding, + ), + textAlignVertical: TextAlignVertical.center, + visualDensity: visualDensity, + child: const SizedBox(key: key, height: childHeight), + ), + ), + ), + ); + + // Vertical components: contentPadding.vertical, densityOffset.y, child + final double childVerticalSpaceAffordance = + totalHeight - visualDensity.baseSizeAdjustment.dy - contentPadding.vertical; + + // TextAlignVertical.center is specified so `child` needs to be centered + // in the available space. + final double childMargin = (childVerticalSpaceAffordance - childHeight) / 2; + final double childTop = + visualDensity.baseSizeAdjustment.dy / 2.0 + contentPadding.top + childMargin; + + expect( + tester.getTopLeft(find.byKey(key)).dy, + tester.getTopLeft(find.byKey(containerKey)).dy + childTop, + ); + }); + }); + + group('outline border', () { + testWidgets('align top', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, // so we have a tall input where align can vary + decoration: const InputDecoration(filled: true, border: OutlineInputBorder()), + textAlignVertical: TextAlignVertical.top, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Similar to the case without a border, but with a little extra room at + // the top to make room for the border. + expect(tester.getTopLeft(find.text(text)).dy, 24.0); + }); + + testWidgets('align center (default)', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration(filled: true, border: OutlineInputBorder()), + textAlignVertical: TextAlignVertical.center, // default when border + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Below the top aligned case. + expect(tester.getTopLeft(find.text(text)).dy, 290.0); + }); + + testWidgets('align bottom', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration(filled: true, border: OutlineInputBorder()), + textAlignVertical: TextAlignVertical.bottom, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Below the center aligned case. + expect(tester.getTopLeft(find.text(text)).dy, 564.0); + }); + }); + + group('prefix', () { + testWidgets('InputDecorator tall prefix align top', (WidgetTester tester) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: TextAlignVertical.top, // default when no border + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Same as the default case above. + expect(tester.getTopLeft(find.text(text)).dy, 97.0); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0); + }); + + testWidgets('InputDecorator tall prefix align center', (WidgetTester tester) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: TextAlignVertical.center, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Same as the default case above. + expect(tester.getTopLeft(find.text(text)).dy, 97.0); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0); + }); + + testWidgets('InputDecorator tall prefix align bottom', (WidgetTester tester) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: TextAlignVertical.bottom, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Top of the input + 100 prefix height - overlap + expect(tester.getTopLeft(find.text(text)).dy, 97.0); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0); + }); + }); + + group('outline border and prefix', () { + testWidgets('InputDecorator tall prefix align center', (WidgetTester tester) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: TextAlignVertical.center, // default when border + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // In the middle of the expanded InputDecorator. + expect(tester.getTopLeft(find.text(text)).dy, 332.5); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 247.5); + }); + + testWidgets('InputDecorator tall prefix with border align top', ( + WidgetTester tester, + ) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: TextAlignVertical.top, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Above the center example. + expect(tester.getTopLeft(find.text(text)).dy, 109.0); + // The prefix is positioned at the top of the input, so this value is + // the same as the top aligned test without a prefix. + expect(tester.getTopLeft(find.byKey(pKey)).dy, 24.0); + }); + + testWidgets('InputDecorator tall prefix with border align bottom', ( + WidgetTester tester, + ) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: TextAlignVertical.bottom, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Below the center example. + expect(tester.getTopLeft(find.text(text)).dy, 564.0); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 479.0); + }); + + testWidgets('InputDecorator tall prefix with border align double', ( + WidgetTester tester, + ) async { + const pKey = Key('p'); + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefix: SizedBox(key: pKey, height: 100, width: 10), + filled: true, + ), + textAlignVertical: const TextAlignVertical(y: 0.1), + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // Between the top and center examples. + expect(tester.getTopLeft(find.text(text)).dy, 355.65); + expect(tester.getTopLeft(find.byKey(pKey)).dy, 270.65); + }); + }); + + group('label', () { + testWidgets('align top (default)', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, // so we have a tall input where align can vary + decoration: const InputDecoration(labelText: 'label', filled: true), + textAlignVertical: TextAlignVertical.top, // default + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // The label causes the text to start slightly lower than it would + // otherwise. + expect(tester.getTopLeft(find.text(text)).dy, 28.0); + }); + + testWidgets('align center', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, // so we have a tall input where align can vary + decoration: const InputDecoration(labelText: 'label', filled: true), + textAlignVertical: TextAlignVertical.center, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // The label reduces the amount of space available for text, so the + // center is slightly lower. + expect(tester.getTopLeft(find.text(text)).dy, 298.0); + }); + + testWidgets('align bottom', (WidgetTester tester) async { + const text = 'text'; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + expands: true, // so we have a tall input where align can vary + decoration: const InputDecoration(labelText: 'label', filled: true), + textAlignVertical: TextAlignVertical.bottom, + // Set the fontSize so that everything works out to whole numbers. + child: const Text(text, style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + ), + ); + + // The label reduces the amount of space available for text, but the + // bottom line is still in the same place. + expect(tester.getTopLeft(find.text(text)).dy, 568.0); + }); + }); + }); + + group('OutlineInputBorder', () { + group('default alignment', () { + testWidgets('Centers when border', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2(decoration: const InputDecoration(border: OutlineInputBorder())), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 20.0); + expect(tester.getBottomLeft(find.text('text')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('Centers when border and label', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration(labelText: 'label', border: OutlineInputBorder()), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dy, 20.0); + expect(tester.getBottomLeft(find.text('text')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('Centers when border and contentPadding', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 14.0, 8.0, 14.0), + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getBottomLeft(find.text('text')).dy, 32.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('Centers when border and contentPadding and label', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + labelText: 'label', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 14.0, 8.0, 14.0), + ), + ), + ); + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getBottomLeft(find.text('text')).dy, 32.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('Centers when border and lopsided contentPadding and label', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + labelText: 'label', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 104.0, 8.0, 0.0), + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 120.0)); + expect(tester.getTopLeft(find.text('text')).dy, 52.0); + expect(tester.getBottomLeft(find.text('text')).dy, 68.0); + expect(getBorderBottom(tester), 120.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('Label aligns horizontally with text', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.ac_unit), + labelText: 'label', + border: OutlineInputBorder(), + ), + isFocused: true, + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dx, 48.0); + expect(tester.getBottomLeft(find.text('text')).dx, 48.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets( + 'Floating label for filled input decoration is horizontally aligned with text', + (WidgetTester tester) async { + // Regression test added in https://github.com/flutter/flutter/pull/115540. + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.ac_unit), + labelText: 'label', + filled: true, + ), + isFocused: true, + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dx, 48.0); + expect(tester.getBottomLeft(find.text('text')).dx, 48.0); + expect(getBorderWeight(tester), 2.0); + }, + ); + }); + + group('3 point interpolation alignment', () { + testWidgets('top align includes padding', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 24.0, 8.0, 2.0), + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 600.0)); + // Aligned to the top including the 24px padding. + expect(tester.getTopLeft(find.text('text')).dy, 24.0); + expect(tester.getBottomLeft(find.text('text')).dy, 40.0); + expect(getBorderBottom(tester), 600.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('center align ignores padding', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + expands: true, + textAlignVertical: TextAlignVertical.center, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 24.0, 8.0, 2.0), + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 600.0)); + // Baseline is on the center of the 600px high input. + expect(tester.getTopLeft(find.text('text')).dy, 292.0); + expect(tester.getBottomLeft(find.text('text')).dy, 308.0); + expect(getBorderBottom(tester), 600.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('bottom align includes padding', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + expands: true, + textAlignVertical: TextAlignVertical.bottom, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 24.0, 8.0, 2.0), + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 600.0)); + // Includes bottom padding of 2px. + expect(tester.getTopLeft(find.text('text')).dy, 582.0); + expect(tester.getBottomLeft(find.text('text')).dy, 598.0); + expect(getBorderBottom(tester), 600.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('padding exceeds middle keeps top at middle', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.fromLTRB(12.0, 504.0, 8.0, 0.0), + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 600.0)); + // Same position as the center example above. + expect(tester.getTopLeft(find.text('text')).dy, 292.0); + expect(tester.getBottomLeft(find.text('text')).dy, 308.0); + expect(getBorderBottom(tester), 600.0); + expect(getBorderWeight(tester), 1.0); + }); + }); + }); + + testWidgets('counter text has correct right margin - LTR, not dense', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(counterText: 'test', filled: true), + ), + ); + + // Margin for text decoration is 12 when filled + // (dx) - 12 = (text offset)x. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 68.0)); + final double dx = tester.getRect(find.byType(InputDecorator)).right; + expect(tester.getRect(find.text('test')).right, dx - 12.0); + }); + + testWidgets('counter text has correct right margin - RTL, not dense', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + textDirection: TextDirection.rtl, + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(counterText: 'test', filled: true), + ), + ); + + // Margin for text decoration is 12 when filled and top left offset is (0, 0) + // 0 + 12 = 12. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 68.0)); + expect(tester.getRect(find.text('test')).left, 12.0); + }); + + testWidgets('counter text has correct right margin - LTR, dense', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(counterText: 'test', filled: true, isDense: true), + ), + ); + + // Margin for text decoration is 12 when filled + // (dx) - 12 = (text offset)x. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 52.0)); + final double dx = tester.getRect(find.byType(InputDecorator)).right; + expect(tester.getRect(find.text('test')).right, dx - 12.0); + }); + + testWidgets('counter text has correct right margin - RTL, dense', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + textDirection: TextDirection.rtl, + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(counterText: 'test', filled: true, isDense: true), + ), + ); + + // Margin for text decoration is 12 when filled and top left offset is (0, 0) + // 0 + 12 = 12. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 52.0)); + expect(tester.getRect(find.text('test')).left, 12.0); + }); + + testWidgets('InputDecorator error/helper/counter RTL layout', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + textDirection: TextDirection.rtl, + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + counterText: 'counter', + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 76dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 12 - [counter helper/error] (font size 12dps) + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('counter')), const Offset(12.0, 64.0)); + expect(tester.getTopRight(find.text('helper')), const Offset(788.0, 64.0)); + + // If both error and helper are specified, show the error + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + textDirection: TextDirection.rtl, + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: 'error', + counterText: 'counter', + filled: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('counter')), const Offset(12.0, 64.0)); + expect(tester.getTopRight(find.text('error')), const Offset(788.0, 64.0)); + expect(find.text('helper'), findsNothing); + }); + + testWidgets('InputDecorator prefix/suffix RTL', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + textDirection: TextDirection.rtl, + decoration: const InputDecoration(prefixText: 'p', suffixText: 's', filled: true), + ), + ); + + // Overall height for this InputDecorator is 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); // 40 bumped up to minimum. + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.text('p')).height, 16.0); + expect(tester.getSize(find.text('s')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(tester.getTopLeft(find.text('p')).dy, 16.0); + expect(tester.getTopLeft(find.text('s')).dy, 16.0); + + // layout is a row: [s text p] + expect(tester.getTopLeft(find.text('s')).dx, 12.0); + expect( + tester.getTopRight(find.text('s')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('text')).dx), + ); + expect( + tester.getTopRight(find.text('text')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('p')).dx), + ); + }); + + testWidgets('InputDecorator contentPadding RTL layout', (WidgetTester tester) async { + // LTR: content left edge is contentPadding.start: 40.0 + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + contentPadding: EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + labelText: 'label', + hintText: 'hint', + filled: true, + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dx, 40.0); + expect(tester.getTopLeft(find.text('label')).dx, 40.0); + expect(tester.getTopLeft(find.text('hint')).dx, 40.0); + + // RTL: content right edge is 800 - contentPadding.start: 760.0. + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + isFocused: true, // label is floating, still adjusted for contentPadding + textDirection: TextDirection.rtl, + decoration: const InputDecoration( + contentPadding: EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + labelText: 'label', + hintText: 'hint', + filled: true, + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopRight(find.text('text')).dx, 760.0); + expect(tester.getTopRight(find.text('label')).dx, 760.0); + expect(tester.getTopRight(find.text('hint')).dx, 760.0); + }); + + group('inputText width', () { + testWidgets('outline textField', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2(decoration: const InputDecoration(border: OutlineInputBorder())), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dx, 12.0); + expect(tester.getTopRight(find.text('text')).dx, 788.0); + }); + testWidgets('outline textField with prefix and suffix icons', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.visibility), + suffixIcon: Icon(Icons.close), + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dx, 48.0); + expect(tester.getTopRight(find.text('text')).dx, 752.0); + }); + testWidgets('filled textField', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2(decoration: const InputDecoration(filled: true)), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dx, 12.0); + expect(tester.getTopRight(find.text('text')).dx, 788.0); + }); + testWidgets('filled textField with prefix and suffix icons', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + filled: true, + prefixIcon: Icon(Icons.visibility), + suffixIcon: Icon(Icons.close), + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('text')).dx, 48.0); + expect(tester.getTopRight(find.text('text')).dx, 752.0); + }); + }); + + group('floatingLabelAlignment', () { + Widget buildInputDecoratorWithFloatingLabel({ + required TextDirection textDirection, + required bool hasIcon, + required FloatingLabelAlignment alignment, + bool borderIsOutline = false, + }) { + return buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + textDirection: textDirection, + decoration: InputDecoration( + contentPadding: const EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + floatingLabelAlignment: alignment, + icon: hasIcon ? const Icon(Icons.insert_link) : null, + labelText: 'label', + hintText: 'hint', + filled: true, + border: borderIsOutline ? const OutlineInputBorder() : null, + ), + ); + } + + group('LTR with icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + // borderIsOutline: false, (default) + ), + ); + // icon (40) + contentPadding (40) + expect(tester.getTopLeft(find.text('label')).dx, 80.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // icon (40) + contentPadding (40) + expect(tester.getTopLeft(find.text('label')).dx, 80.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + // borderIsOutline: false, (default) + ), + ); + // icon (40) + (decorator (800) - icon (40)) / 2 + expect(tester.getCenter(find.text('label')).dx, 420.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // icon (40) + (decorator (800) - icon (40)) / 2 + expect(tester.getCenter(find.text('label')).dx, 420.0); + }); + }); + + group('LTR without icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + // borderIsOutline: false, (default) + ), + ); + // contentPadding (40) + expect(tester.getTopLeft(find.text('label')).dx, 40.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // contentPadding (40) + expect(tester.getTopLeft(find.text('label')).dx, 40.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + // borderIsOutline: false, (default) + ), + ); + // decorator (800) / 2 + expect(tester.getCenter(find.text('label')).dx, 400.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.ltr, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // decorator (800) / 2 + expect(tester.getCenter(find.text('label')).dx, 400.0); + }); + }); + + group('RTL with icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + // borderIsOutline: false, (default) + ), + ); + // decorator (800) - icon (40) - contentPadding (40) + expect(tester.getTopRight(find.text('label')).dx, 720.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // decorator (800) - icon (40) - contentPadding (40) + expect(tester.getTopRight(find.text('label')).dx, 720.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + // borderIsOutline: false, (default) + ), + ); + // (decorator (800) / icon (40)) / 2 + expect(tester.getCenter(find.text('label')).dx, 380.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: true, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // (decorator (800) / icon (40)) / 2 + expect(tester.getCenter(find.text('label')).dx, 380.0); + }); + }); + + group('RTL without icon aligned', () { + testWidgets('start', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + // borderIsOutline: false, (default) + ), + ); + // decorator (800) - contentPadding (40) + expect(tester.getTopRight(find.text('label')).dx, 760.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.start, + borderIsOutline: true, + ), + ); + // decorator (800) - contentPadding (40) + expect(tester.getTopRight(find.text('label')).dx, 760.0); + }); + + testWidgets('center', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + // borderIsOutline: false, (default) + ), + ); + // decorator (800) / 2 + expect(tester.getCenter(find.text('label')).dx, 400.0); + + await tester.pumpWidget( + buildInputDecoratorWithFloatingLabel( + textDirection: TextDirection.rtl, + hasIcon: false, + alignment: FloatingLabelAlignment.center, + borderIsOutline: true, + ), + ); + // decorator (800) / 2 + expect(tester.getCenter(find.text('label')).dx, 400.0); + }); + }); + }); + + testWidgets('InputDecorator prefix/suffix dense layout', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + isFocused: true, + decoration: const InputDecoration( + isDense: true, + prefixText: 'p', + suffixText: 's', + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 32dps: + // 8 - top padding + // 16 - input text (font size 16dps) + // 8 - bottom padding + // + // The only difference from normal layout for this case is that the + // padding above and below the prefix, input text, suffix, is 8 instead of 12. + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 32.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getSize(find.text('p')).height, 16.0); + expect(tester.getSize(find.text('s')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 8.0); + expect(tester.getTopLeft(find.text('p')).dy, 8.0); + expect(tester.getTopLeft(find.text('p')).dx, 12.0); + expect(tester.getTopLeft(find.text('s')).dy, 8.0); + expect(tester.getTopRight(find.text('s')).dx, 788.0); + + // layout is a row: [p text s] + expect(tester.getTopLeft(find.text('p')).dx, 12.0); + expect( + tester.getTopRight(find.text('p')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('text')).dx), + ); + expect( + tester.getTopRight(find.text('text')).dx, + lessThanOrEqualTo(tester.getTopLeft(find.text('s')).dx), + ); + + expect(getBorderBottom(tester), 32.0); + expect(getBorderWeight(tester), 2.0); + }); + + testWidgets('InputDecorator with empty InputDecoration', (WidgetTester tester) async { + await tester.pumpWidget(buildInputDecoratorM2()); + + // Overall height for this InputDecorator is 40dps: + // 12 - top padding + // 16 - input text (font size 16dps) + // 12 - bottom padding + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); // 40 bumped up to minimum. + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(getBorderBottom(tester), kMinInteractiveDimension); // 40 bumped up to minimum. + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('contentPadding smaller than kMinInteractiveDimension', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/42449 + const verticalPadding = 1.0; + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default), + // isFocused: false (default) + decoration: const InputDecoration( + hintText: 'hint', + contentPadding: EdgeInsets.symmetric(vertical: verticalPadding), + isDense: true, + ), + ), + ); + + // The overall height is 18dps. This is shorter than + // kMinInteractiveDimension, but because isDense is true, the minimum is + // ignored. + // 16 - input text (font size 16dps) + // 2 - total vertical padding + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 18.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 1.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderWeight(tester), 1.0); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default), + // isFocused: false (default) + decoration: const InputDecoration.collapsed( + hintText: 'hint', + // InputDecoration.collapsed does not support contentPadding + ), + ), + ); + + // The overall height is 16dps. This is shorter than + // kMinInteractiveDimension, but because isCollapsed is true, the minimum is + // ignored. There is no padding at all, because isCollapsed doesn't support + // contentPadding. + // 16 - input text (font size 16dps) + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 16.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 0.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderWeight(tester), 1.0); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default), + // isFocused: false (default) + decoration: const InputDecoration( + hintText: 'hint', + contentPadding: EdgeInsets.symmetric(vertical: verticalPadding), + ), + ), + ); + + // The requested overall height is 18dps, however the minimum height is + // kMinInteractiveDimension because neither isDense or isCollapsed are true. + // 16 - input text (font size 16dps) + // 2 - total vertical padding + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 16.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets('InputDecorator.collapsed', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default), + // isFocused: false (default) + decoration: const InputDecoration.collapsed(hintText: 'hint'), + ), + ); + + // Overall height for this InputDecorator is 16dps. There is no minimum + // height when InputDecoration.collapsed is used. + // 16 - input text (font size 16dps) + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 16.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 0.0); + expect(getOpacity(tester, 'hint'), 0.0); + expect(getBorderWeight(tester), 0.0); + + // The hint should appear + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, + decoration: const InputDecoration.collapsed(hintText: 'hint'), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 16.0)); + expect(tester.getSize(find.text('text')).height, 16.0); + expect(tester.getTopLeft(find.text('text')).dy, 0.0); + expect(tester.getSize(find.text('hint')).height, 16.0); + expect(tester.getTopLeft(find.text('hint')).dy, 0.0); + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets('InputDecorator with baseStyle', (WidgetTester tester) async { + // Setting the baseStyle of the InputDecoration and the style of the input + // text child to a smaller font reduces the InputDecoration's vertical size. + const style = TextStyle(fontSize: 10.0); + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + baseStyle: style, + decoration: const InputDecoration(hintText: 'hint', labelText: 'label'), + child: const Text('text', style: style), + ), + ); + + // Overall height for this InputDecorator is 45.5dps. When the label is + // floating the layout is: + // + // 12 - top padding + // 7.5 - floating label (font size 10dps * 0.75 = 7.5) + // 4 - floating label / input text gap + // 10 - input text (font size 10dps) + // 12 - bottom padding + // + // When the label is not floating, it's vertically centered. + // + // 17.75 - top padding + // 10 - label (font size 10dps) + // 17.75 - bottom padding (empty input text still appears here) + + expect( + tester.getSize(find.byType(InputDecorator)), + const Size(800.0, kMinInteractiveDimension), + ); // 45.5 bumped up to minimum. + expect(tester.getSize(find.text('hint')).height, 10.0); + expect(tester.getSize(find.text('label')).height, 10.0); + expect(tester.getSize(find.text('text')).height, 10.0); + expect(tester.getTopLeft(find.text('hint')).dy, 24.75); + expect(tester.getTopLeft(find.text('label')).dy, 19.0); + expect(tester.getTopLeft(find.text('text')).dy, 24.75); + }); + + testWidgets('InputDecorator with empty style overrides', (WidgetTester tester) async { + // Same as not specifying any style overrides + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + helperText: 'helper', + counterText: 'counter', + labelStyle: TextStyle(), + hintStyle: TextStyle(), + errorStyle: TextStyle(), + helperStyle: TextStyle(), + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 76dps. When the label is + // floating the layout is: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + + // Label is floating because isEmpty is false. + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text('text')).dy, 28.0); + expect(tester.getBottomLeft(find.text('text')).dy, 44.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('helper')), const Offset(12.0, 64.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 64.0)); + }); + + testWidgets('InputDecoration outline shape with no border and no floating placeholder', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + isEmpty: true, + decoration: const InputDecoration( + border: OutlineInputBorder(borderSide: BorderSide.none), + floatingLabelBehavior: FloatingLabelBehavior.never, + labelText: 'label', + ), + ), + ); + + // Overall height for this InputDecorator is 56dps. Layout is: + // 20 - top padding + // 16 - label (font size 16dps) + // 20 - bottom padding + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets( + 'InputDecoration outline shape with no border and no floating placeholder not empty', + (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + border: OutlineInputBorder(borderSide: BorderSide.none), + floatingLabelBehavior: FloatingLabelBehavior.never, + labelText: 'label', + ), + ), + ); + + // Overall height for this InputDecorator is 56dps. Layout is: + // 20 - top padding + // 16 - label (font size 16dps) + // 20 - bottom padding + // expect(tester.widget<Text>(find.text('prefix')).style.color, prefixStyle.color); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 0.0); + + // The label should not be seen. + expect(getOpacity(tester, 'label'), 0.0); + }, + ); + + testWidgets('InputDecorationThemeData outline border', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, // label appears, vertically centered + // isFocused: false (default) + inputDecorationTheme: const InputDecorationThemeData(border: OutlineInputBorder()), + decoration: const InputDecoration(labelText: 'label'), + ), + ); + + // Overall height for this InputDecorator is 56dps. Layout is: + // 20 - top padding + // 16 - label (font size 16dps) + // 20 - bottom padding + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('InputDecorationThemeData outline border, dense layout', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, // label appears, vertically centered + // isFocused: false (default) + inputDecorationTheme: const InputDecorationThemeData( + border: OutlineInputBorder(), + isDense: true, + ), + decoration: const InputDecoration(labelText: 'label', hintText: 'hint'), + ), + ); + + // Overall height for this InputDecorator is 56dps. Layout is: + // 16 - top padding + // 16 - label (font size 16dps) + // 16 - bottom padding + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 48.0)); + expect(tester.getTopLeft(find.text('label')).dy, 16.0); + expect(tester.getBottomLeft(find.text('label')).dy, 32.0); + expect(getBorderBottom(tester), 48.0); + expect(getBorderWeight(tester), 1.0); + }); + + testWidgets('InputDecorationThemeData style overrides', (WidgetTester tester) async { + const defaultStyle = TextStyle(fontSize: 16.0); + final TextStyle labelStyle = defaultStyle.merge(const TextStyle(color: Colors.red)); + final TextStyle hintStyle = defaultStyle.merge(const TextStyle(color: Colors.green)); + final TextStyle prefixStyle = defaultStyle.merge(const TextStyle(color: Colors.blue)); + final TextStyle suffixStyle = defaultStyle.merge(const TextStyle(color: Colors.purple)); + + const style12 = TextStyle(fontSize: 12.0); + final TextStyle helperStyle = style12.merge(const TextStyle(color: Colors.orange)); + final TextStyle counterStyle = style12.merge(const TextStyle(color: Colors.orange)); + + // This test also verifies that the default InputDecorator provides a + // "small concession to backwards compatibility" by not padding on + // the left and right. If filled is true or an outline border is + // provided then the horizontal padding is included. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, // label appears, vertically centered + // isFocused: false (default) + inputDecorationTheme: InputDecorationThemeData( + labelStyle: labelStyle, + hintStyle: hintStyle, + prefixStyle: prefixStyle, + suffixStyle: suffixStyle, + helperStyle: helperStyle, + counterStyle: counterStyle, + // filled: false (default) - don't pad by left/right 12dps + ), + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + prefixText: 'prefix', + suffixText: 'suffix', + helperText: 'helper', + counterText: 'counter', + ), + ), + ); + + // Overall height for this InputDecorator is 76dps. Layout is: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - prefix/hint/input/suffix text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + expect(tester.getTopLeft(find.text('helper')), const Offset(0.0, 64.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(800.0, 64.0)); + + // Verify that the styles were passed along + expect(tester.widget<Text>(find.text('hint')).style!.color, hintStyle.color); + expect(tester.widget<Text>(find.text('prefix')).style!.color, prefixStyle.color); + expect(tester.widget<Text>(find.text('suffix')).style!.color, suffixStyle.color); + expect(tester.widget<Text>(find.text('helper')).style!.color, helperStyle.color); + expect(tester.widget<Text>(find.text('counter')).style!.color, counterStyle.color); + expect(getLabelStyle(tester).color, labelStyle.color); + }); + + testWidgets('InputDecorationThemeData style overrides (focused)', (WidgetTester tester) async { + const defaultStyle = TextStyle(fontSize: 16.0); + final TextStyle labelStyle = defaultStyle.merge(const TextStyle(color: Colors.red)); + final TextStyle floatingLabelStyle = defaultStyle.merge( + const TextStyle(color: Colors.indigo), + ); + final TextStyle hintStyle = defaultStyle.merge(const TextStyle(color: Colors.green)); + final TextStyle prefixStyle = defaultStyle.merge(const TextStyle(color: Colors.blue)); + final TextStyle suffixStyle = defaultStyle.merge(const TextStyle(color: Colors.purple)); + + const style12 = TextStyle(fontSize: 12.0); + final TextStyle helperStyle = style12.merge(const TextStyle(color: Colors.orange)); + final TextStyle counterStyle = style12.merge(const TextStyle(color: Colors.orange)); + + // This test also verifies that the default InputDecorator provides a + // "small concession to backwards compatibility" by not padding on + // the left and right. If filled is true or an outline border is + // provided then the horizontal padding is included. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + inputDecorationTheme: InputDecorationThemeData( + labelStyle: labelStyle, + floatingLabelStyle: floatingLabelStyle, + hintStyle: hintStyle, + prefixStyle: prefixStyle, + suffixStyle: suffixStyle, + helperStyle: helperStyle, + counterStyle: counterStyle, + // filled: false (default) - don't pad by left/right 12dps + ), + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + prefixText: 'prefix', + suffixText: 'suffix', + helperText: 'helper', + counterText: 'counter', + ), + ), + ); + + // Overall height for this InputDecorator is 76dps. Layout is: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - prefix/hint/input/suffix text (font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 12 - help/error/counter text (font size 12dps) + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + expect(tester.getTopLeft(find.text('helper')), const Offset(0.0, 64.0)); + expect(tester.getTopRight(find.text('counter')), const Offset(800.0, 64.0)); + + // Verify that the styles were passed along + expect(tester.widget<Text>(find.text('hint')).style!.color, hintStyle.color); + expect(tester.widget<Text>(find.text('prefix')).style!.color, prefixStyle.color); + expect(tester.widget<Text>(find.text('suffix')).style!.color, suffixStyle.color); + expect(tester.widget<Text>(find.text('helper')).style!.color, helperStyle.color); + expect(tester.widget<Text>(find.text('counter')).style!.color, counterStyle.color); + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + }); + + testWidgets('InputDecorator.debugDescribeChildren', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + icon: Text('icon'), + labelText: 'label', + hintText: 'hint', + prefixText: 'prefix', + suffixText: 'suffix', + prefixIcon: Text('prefixIcon'), + suffixIcon: Text('suffixIcon'), + helperText: 'helper', + counterText: 'counter', + ), + child: const Text('text'), + ), + ); + + // Find the _RenderDecoration render object (which may be wrapped by Semantics) + RenderObject renderer = tester.renderObject(find.byType(InputDecorator)); + // If wrapped by Semantics, walk down to find the actual _RenderDecoration + while (renderer.debugDescribeChildren().length == 1 && + renderer.debugDescribeChildren().first.name == 'child') { + renderer = renderer.debugDescribeChildren().first.value! as RenderObject; + } + final Iterable<String> nodeNames = renderer.debugDescribeChildren().map( + (DiagnosticsNode node) => node.name!, + ); + expect( + nodeNames, + unorderedEquals(<String>[ + 'container', + 'counter', + 'helperError', + 'hint', + 'icon', + 'input', + 'label', + 'prefix', + 'prefixIcon', + 'suffix', + 'suffixIcon', + ]), + ); + + final nodeValues = Set<Object>.of( + renderer.debugDescribeChildren().map<Object>((DiagnosticsNode node) => node.value!), + ); + expect(nodeValues.length, 11); + }); + + testWidgets('InputDecorator with empty border and label', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/14165 + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration(labelText: 'label', border: InputBorder.none), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(getBorderWeight(tester), 0.0); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + }); + + testWidgets('InputDecorator OutlineInputBorder fillColor is clipped by border', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/15742 + + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(InputDecorator)); + + expect( + box, + paints + // The border's outer path, a rounded rectangle. + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: const BorderRadius.all( + Radius.circular(12.0), + ).toRRect(const Rect.fromLTWH(0, 0, 800.0, 56.0)), + ) + // Border outline. The rrect is the -center- of the 1.0 stroked outline. + ..rrect( + style: PaintingStyle.stroke, + strokeWidth: 1.0, + rrect: RRect.fromLTRBR(0.5, 0.5, 799.5, 55.5, const Radius.circular(11.5)), + ), + ); + }); + + testWidgets('InputDecorator UnderlineInputBorder fillColor is clipped by border', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + border: UnderlineInputBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12.0), + bottomRight: Radius.circular(12.0), + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(InputDecorator)); + + // Fill is the border's outer path, a rounded rectangle + expect( + box, + paints..drrect( + style: PaintingStyle.fill, + inner: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 800.0, + 47.0, + bottomRight: const Radius.elliptical(12.0, 11.0), + bottomLeft: const Radius.elliptical(12.0, 11.0), + ), + outer: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 800.0, + 48.0, + bottomRight: const Radius.elliptical(12.0, 12.0), + bottomLeft: const Radius.elliptical(12.0, 12.0), + ), + ), + ); + }); + + testWidgets('InputDecorator OutlineBorder focused label with icon', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/82321 + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: Directionality( + textDirection: textDirection, + child: const RepaintBoundary( + child: InputDecorator( + isFocused: true, + isEmpty: true, + decoration: InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + labelText: 'label text', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(30.0)), + gapPadding: 0.0, + ), + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await expectLater( + find.byType(InputDecorator), + matchesGoldenFile('m2_input_decorator.outline_label.ltr.png'), + ); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await expectLater( + find.byType(InputDecorator), + matchesGoldenFile('m2_input_decorator.outline_label.rtl.png'), + ); + }); + + testWidgets('InputDecorator OutlineBorder focused label with icon', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/18111 + + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Container( + padding: const EdgeInsets.all(16.0), + alignment: Alignment.center, + child: Directionality( + textDirection: textDirection, + child: const RepaintBoundary( + child: InputDecorator( + isFocused: true, + isEmpty: true, + decoration: InputDecoration( + icon: Icon(Icons.insert_link), + labelText: 'primaryLink', + hintText: 'Primary link to story', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await expectLater( + find.byType(InputDecorator), + matchesGoldenFile('m2_input_decorator.outline_icon_label.ltr.png'), + ); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await expectLater( + find.byType(InputDecorator), + matchesGoldenFile('m2_input_decorator.outline_icon_label.rtl.png'), + ); + }); + + testWidgets('InputDecorator draws and animates hoverColor', (WidgetTester tester) async { + const fillColor = Color(0x0A000000); + const hoverColor = Color(0xFF00FF00); + const disabledColor = Color(0x05000000); + const enabledBorderColor = Color(0x61000000); + + Future<void> pumpDecorator({ + required bool hovering, + bool enabled = true, + bool filled = true, + }) async { + return tester.pumpWidget( + buildInputDecoratorM2( + isHovering: hovering, + decoration: InputDecoration( + enabled: enabled, + filled: filled, + hoverColor: hoverColor, + disabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: disabledColor), + ), + border: const OutlineInputBorder(borderSide: BorderSide(color: enabledBorderColor)), + ), + ), + ); + } + + // Test filled text field. + await pumpDecorator(hovering: false); + expect(getContainerColor(tester), isSameColorAs(fillColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getContainerColor(tester), isSameColorAs(fillColor)); + + await pumpDecorator(hovering: true); + expect(getContainerColor(tester), isSameColorAs(fillColor)); + await tester.pump(const Duration(milliseconds: 15)); + expect(getContainerColor(tester), isSameColorAs(hoverColor)); + + await pumpDecorator(hovering: false); + expect(getContainerColor(tester), isSameColorAs(hoverColor)); + await tester.pump(const Duration(milliseconds: 15)); + expect(getContainerColor(tester), isSameColorAs(fillColor)); + + // Test that for high refresh rate displays, the color mid-animation is somewhere between + // the fill color and the hover color. + await pumpDecorator(hovering: true); + expect(getContainerColor(tester), isSameColorAs(fillColor)); + await tester.pump(const Duration(milliseconds: 6)); + final Color midHoverColor = Color.lerp( + hoverColor.withAlpha(0), + hoverColor, + _getHoverAnimation(tester).value, + )!; + expect(getContainerColor(tester), isSameColorAs(Color.alphaBlend(midHoverColor, fillColor))); + + await pumpDecorator(hovering: false, enabled: false); + expect(getContainerColor(tester), isSameColorAs(disabledColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getContainerColor(tester), isSameColorAs(disabledColor)); + + await pumpDecorator(hovering: true, enabled: false); + expect(getContainerColor(tester), isSameColorAs(disabledColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getContainerColor(tester), isSameColorAs(disabledColor)); + + // Test outline text field. + const blendedHoverColor = Color(0x74004400); + await pumpDecorator(hovering: false, filled: false); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), isSameColorAs(enabledBorderColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getBorderColor(tester), isSameColorAs(enabledBorderColor)); + + await pumpDecorator(hovering: true, filled: false); + expect(getBorderColor(tester), isSameColorAs(enabledBorderColor)); + await tester.pump(const Duration(milliseconds: 167)); + expect(getBorderColor(tester), isSameColorAs(blendedHoverColor)); + + await pumpDecorator(hovering: false, filled: false); + expect(getBorderColor(tester), isSameColorAs(blendedHoverColor)); + await tester.pump(const Duration(milliseconds: 167)); + expect(getBorderColor(tester), isSameColorAs(enabledBorderColor)); + + await pumpDecorator(hovering: false, filled: false, enabled: false); + expect(getBorderColor(tester), isSameColorAs(enabledBorderColor)); + await tester.pump(const Duration(milliseconds: 167)); + expect(getBorderColor(tester), isSameColorAs(disabledColor)); + + await pumpDecorator(hovering: true, filled: false, enabled: false); + expect(getBorderColor(tester), isSameColorAs(disabledColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getBorderColor(tester), isSameColorAs(disabledColor)); + }); + + testWidgets('InputDecorator draws and animates focusColor', (WidgetTester tester) async { + const focusColor = Color(0xFF0000FF); + const disabledColor = Color(0x05000000); + const enabledBorderColor = Color(0x61000000); + + Future<void> pumpDecorator({ + required bool focused, + bool enabled = true, + bool filled = true, + }) async { + return tester.pumpWidget( + buildInputDecoratorM2( + isFocused: focused, + decoration: InputDecoration( + enabled: enabled, + filled: filled, + focusColor: focusColor, + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: focusColor)), + disabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: disabledColor), + ), + border: const OutlineInputBorder(borderSide: BorderSide(color: enabledBorderColor)), + ), + ), + ); + } + + // Test outline text field default border. + await pumpDecorator(focused: false, filled: false); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), equals(enabledBorderColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getBorderColor(tester), equals(enabledBorderColor)); + + await pumpDecorator(focused: true, filled: false); + expect(getBorderColor(tester), equals(enabledBorderColor)); + await tester.pump(const Duration(milliseconds: 167)); + expect(getBorderColor(tester), equals(focusColor)); + + await pumpDecorator(focused: false, filled: false); + expect(getBorderColor(tester), equals(focusColor)); + await tester.pump(const Duration(milliseconds: 167)); + expect(getBorderColor(tester), equals(enabledBorderColor)); + + await pumpDecorator(focused: false, filled: false, enabled: false); + expect(getBorderColor(tester), equals(enabledBorderColor)); + await tester.pump(const Duration(milliseconds: 167)); + expect(getBorderColor(tester), equals(disabledColor)); + + await pumpDecorator(focused: true, filled: false, enabled: false); + expect(getBorderColor(tester), equals(disabledColor)); + await tester.pump(const Duration(seconds: 10)); + expect(getBorderColor(tester), equals(disabledColor)); + }); + + testWidgets('InputDecorator withdraws label when not empty or focused', ( + WidgetTester tester, + ) async { + Future<void> pumpDecorator({ + required bool focused, + bool enabled = true, + bool filled = false, + bool empty = true, + bool directional = false, + }) async { + return tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: empty, + isFocused: focused, + decoration: InputDecoration( + labelText: 'Label', + enabled: enabled, + filled: filled, + focusedBorder: const OutlineInputBorder(), + disabledBorder: const OutlineInputBorder(), + border: const OutlineInputBorder(), + ), + ), + ); + } + + await pumpDecorator(focused: false); + await tester.pumpAndSettle(); + const labelSize = Size(80, 16); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20))); + expect(getLabelRect(tester).size, equals(labelSize)); + + await pumpDecorator(focused: false, empty: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + await pumpDecorator(focused: true); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + await pumpDecorator(focused: true, empty: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + await pumpDecorator(focused: false, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20))); + expect(getLabelRect(tester).size, equals(labelSize)); + + await pumpDecorator(focused: false, empty: false, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + + // Focused and disabled happens with NavigationMode.directional. + await pumpDecorator(focused: true, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20))); + expect(getLabelRect(tester).size, equals(labelSize)); + + await pumpDecorator(focused: true, empty: false, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -5.5))); + expect(getLabelRect(tester).size, equals(labelSize * 0.75)); + }); + + testWidgets('InputDecoration default border uses colorScheme', (WidgetTester tester) async { + final theme = ThemeData.light(useMaterial3: false); + final Color enabledColor = theme.colorScheme.onSurface.withOpacity(0.38); + final Color disabledColor = theme.disabledColor; + final Color hoverColor = Color.alphaBlend(theme.hoverColor.withOpacity(0.12), enabledColor); + + // Enabled + await tester.pumpWidget(buildInputDecoratorM2(theme: theme)); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), enabledColor); + + // Filled + await tester.pumpWidget( + buildInputDecoratorM2(theme: theme, decoration: const InputDecoration(filled: true)), + ); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), theme.hintColor); + + // Hovering + await tester.pumpWidget(buildInputDecoratorM2(theme: theme, isHovering: true)); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), hoverColor); + + // Focused + await tester.pumpWidget(buildInputDecoratorM2(theme: theme, isFocused: true)); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), theme.colorScheme.primary); + + // Error + await tester.pumpWidget( + buildInputDecoratorM2( + theme: theme, + decoration: const InputDecoration(errorText: 'Nope'), + ), + ); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), theme.colorScheme.error); + + // Disabled + await tester.pumpWidget( + buildInputDecoratorM2(theme: theme, decoration: const InputDecoration(enabled: false)), + ); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), disabledColor); + + // Disabled, filled + await tester.pumpWidget( + buildInputDecoratorM2( + theme: theme, + decoration: const InputDecoration(enabled: false, filled: true), + ), + ); + await tester.pumpAndSettle(); + expect(getBorderColor(tester), Colors.transparent); + }); + + testWidgets('InputDecoration borders', (WidgetTester tester) async { + const InputBorder errorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 1.5), + ); + const InputBorder focusedBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.green, width: 4.0), + ); + const InputBorder focusedErrorBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.teal, width: 5.0), + ); + const InputBorder disabledBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey, width: 0.0), + ); + const InputBorder enabledBorder = OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue, width: 2.5), + ); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + // errorText: null (default) + // enabled: true (default) + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + expect(getBorder(tester), enabledBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration( + // errorText: null (default) + // enabled: true (default) + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + await tester.pumpAndSettle(); // border changes are animated + expect(getBorder(tester), focusedBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration( + errorText: 'error', + // enabled: true (default) + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + await tester.pumpAndSettle(); // border changes are animated + expect(getBorder(tester), focusedErrorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + errorText: 'error', + // enabled: true (default) + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + await tester.pumpAndSettle(); // border changes are animated + expect(getBorder(tester), errorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + errorText: 'error', + enabled: false, + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + await tester.pumpAndSettle(); // border changes are animated + expect(getBorder(tester), errorBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + decoration: const InputDecoration( + enabled: false, + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + await tester.pumpAndSettle(); // border changes are animated + expect(getBorder(tester), disabledBorder); + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration( + // errorText: null (default) + enabled: false, + errorBorder: errorBorder, + focusedBorder: focusedBorder, + focusedErrorBorder: focusedErrorBorder, + disabledBorder: disabledBorder, + enabledBorder: enabledBorder, + ), + ), + ); + await tester.pumpAndSettle(); // border changes are animated + expect(getBorder(tester), disabledBorder); + }); + + testWidgets('OutlineInputBorder borders scale down to fit when large values are passed in', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/34327 + const largerBorderRadius = 200.0; + const smallerBorderRadius = 100.0; + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + const inputDecoratorHeight = 56.0; + const inputDecoratorWidth = 800.0; + + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + labelText: 'label text', + border: OutlineInputBorder( + borderRadius: BorderRadius.only( + // Intentionally large values that are larger than the InputDecorator + topLeft: Radius.circular(smallerBorderRadius), + bottomLeft: Radius.circular(smallerBorderRadius), + topRight: Radius.circular(largerBorderRadius), + bottomRight: Radius.circular(largerBorderRadius), + ), + ), + ), + ), + ); + + expect( + findBorderPainter(), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: const BorderRadius.only( + topLeft: Radius.circular(smallerBorderRadius), + bottomLeft: Radius.circular(smallerBorderRadius), + topRight: Radius.circular(largerBorderRadius), + bottomRight: Radius.circular(largerBorderRadius), + ).toRRect(const Rect.fromLTWH(0, 0, inputDecoratorWidth, inputDecoratorHeight)), + ) + ..restore(), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/55317 + + testWidgets('rounded OutlineInputBorder with zero padding just wraps the label', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/82321 + const borderRadius = 30.0; + const labelText = 'label text'; + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + const inputDecoratorHeight = 56.0; + const inputDecoratorWidth = 800.0; + + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + filled: true, + fillColor: Color(0xFF00FF00), + labelText: labelText, + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + gapPadding: 0.0, + ), + ), + ), + ); + + expect(find.text(labelText), findsOneWidget); + expect( + findBorderPainter(), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: const Color(0xFF00FF00), + rrect: const BorderRadius.all( + Radius.circular(borderRadius), + ).toRRect(const Rect.fromLTWH(0, 0, inputDecoratorWidth, inputDecoratorHeight)), + ) + ..restore(), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/55317 + + testWidgets('OutlineInputBorder with BorderRadius.zero should draw a rectangular border', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/78855 + const labelText = 'Flutter'; + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + const inputDecoratorHeight = 56.0; + const inputDecoratorWidth = 800.0; + const borderWidth = 4.0; + + await tester.pumpWidget( + buildInputDecoratorM2( + isFocused: true, + decoration: const InputDecoration( + filled: false, + labelText: labelText, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(width: borderWidth, color: Colors.red), + ), + ), + ), + ); + + expect(find.text(labelText), findsOneWidget); + expect( + findBorderPainter(), + paints + ..save() + ..path( + includes: const <Offset>[ + // Corner points in the middle of the border line should be in the path. + // The path is not filled and borderWidth is 4.0 so Offset(2.0, 2.0) is in the path and Offset(1.0, 1.0) is not. + // See Skia SkPath::contains method. + + // Top-left + Offset(borderWidth / 2, borderWidth / 2), + // Top-right + Offset(inputDecoratorWidth - 1 - borderWidth / 2, borderWidth / 2), + // Bottom-left + Offset(borderWidth / 2, inputDecoratorHeight - 1 - borderWidth / 2), + // Bottom-right + Offset( + inputDecoratorWidth - 1 - borderWidth / 2, + inputDecoratorHeight - 1 - borderWidth / 2, + ), + ], + excludes: const <Offset>[ + // The path is not filled and borderWidth is 4.0 so the path should not contains the corner points. + // See Skia SkPath::contains method. + + // Top-left + Offset.zero, + // // Top-right + Offset(inputDecoratorWidth - 1, 0), + // // Bottom-left + Offset(0, inputDecoratorHeight - 1), + // // Bottom-right + Offset(inputDecoratorWidth - 1, inputDecoratorHeight - 1), + ], + ) + ..restore(), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/55317 + + testWidgets('uses alphabetic baseline for CJK layout', (WidgetTester tester) async { + await tester.binding.setLocale('zh', 'CN'); + final typography = Typography.material2018(); + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final controller = TextEditingController(); + addTearDown(controller.dispose); + + // The dense theme uses ideographic baselines + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + theme: ThemeData(useMaterial3: false, textTheme: typography.dense), + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + labelText: 'label', + alignLabelWithHint: alignLabelWithHint, + hintText: 'hint', + hintStyle: const TextStyle(fontFamily: 'Cough'), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + + // These numbers should be the values from using alphabetic baselines: + // Ideographic (incorrect) value is 31.299999713897705 + expect(tester.getTopLeft(find.text('hint')).dy, 28.75); + + // Ideographic (incorrect) value is 50.299999713897705 + expect(tester.getBottomLeft(find.text('hint')).dy, isBrowser ? 45.75 : 47.75); + }); + + testWidgets('InputDecorator floating label Y coordinate', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/54028 + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + decoration: const InputDecoration( + labelText: 'label', + enabledBorder: OutlineInputBorder(borderSide: BorderSide(width: 4)), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ); + + await tester.pumpAndSettle(); + + // floatingLabelHeight = 12 (font size 16dps * 0.75 = 12) + // labelY = -floatingLabelHeight/2 + borderWidth/2 + expect(tester.getTopLeft(find.text('label')).dy, -4.0); + }); + + testWidgets('InputDecorator floating label obeys floatingLabelBehavior', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + decoration: const InputDecoration( + labelText: 'label', + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + ); + + // Passing floating behavior never results in a dy offset of 20 + // because the label is not initially floating. + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + }); + + testWidgets('InputDecorator hint is displayed when floatingLabelBehavior is always', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isFocused: false (default) + isEmpty: true, + decoration: const InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, + hintText: 'hint', + labelText: 'label', + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getOpacity(tester, 'hint'), 1.0); + }); + + testWidgets('InputDecorator floating label width scales when focused', ( + WidgetTester tester, + ) async { + final longStringA = String.fromCharCodes(List<int>.generate(200, (_) => 65)); + final longStringB = String.fromCharCodes(List<int>.generate(200, (_) => 66)); + + await tester.pumpWidget( + Center( + child: SizedBox.square( + dimension: 100, + child: buildInputDecoratorM2( + // isFocused: false (default) + isEmpty: true, + decoration: InputDecoration(labelText: longStringA), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + find.text(longStringA), + paints..clipRect(rect: const Rect.fromLTWH(0, 0, 100.0, 16.0)), + ); + + await tester.pumpWidget( + Center( + child: SizedBox.square( + dimension: 100, + child: buildInputDecoratorM2( + isFocused: true, + isEmpty: true, + decoration: InputDecoration(labelText: longStringB), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + find.text(longStringB), + paints..something((Symbol methodName, List<dynamic> arguments) { + if (methodName != #clipRect) { + return false; + } + final clipRect = arguments[0] as Rect; + // _kFinalLabelScale = 0.75 + expect( + clipRect, + rectMoreOrLessEquals(const Rect.fromLTWH(0, 0, 100 / 0.75, 16.0), epsilon: 1e-5), + ); + return true; + }), + ); + }, skip: isBrowser); // TODO(yjbanov): https://github.com/flutter/flutter/issues/44020 + + testWidgets('textAlignVertical can be updated', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/56933 + const hintText = 'hint'; + TextAlignVertical? alignment = TextAlignVertical.top; + late StateSetter setState; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return InputDecorator( + textAlignVertical: alignment, + decoration: const InputDecoration(hintText: hintText), + ); + }, + ), + ), + ); + + final double topPosition = tester.getTopLeft(find.text(hintText)).dy; + + setState(() { + alignment = TextAlignVertical.bottom; + }); + await tester.pump(); + + expect(tester.getTopLeft(find.text(hintText)).dy, greaterThan(topPosition)); + + // Setting textAlignVertical back to null works and reverts to the default. + setState(() { + alignment = null; + }); + await tester.pump(); + + expect(tester.getTopLeft(find.text(hintText)).dy, topPosition); + }); + + testWidgets( + 'InputDecorationThemeData.floatingLabelStyle overrides label widget styles when the widget is a text widget (focused)', + (WidgetTester tester) async { + const style16 = TextStyle(fontSize: 16.0); + final TextStyle floatingLabelStyle = style16.merge(const TextStyle(color: Colors.indigo)); + + // This test also verifies that the default InputDecorator provides a + // "small concession to backwards compatibility" by not padding on + // the left and right. If filled is true or an outline border is + // provided then the horizontal padding is included. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, + isFocused: true, // Label appears floating above input field. + inputDecorationTheme: InputDecorationThemeData( + floatingLabelStyle: floatingLabelStyle, + // filled: false (default) - don't pad by left/right 12dps + ), + decoration: const InputDecoration(label: Text.rich(TextSpan(text: 'label'))), + ), + ); + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 12.0); + expect(tester.getBottomLeft(find.text('label')).dy, 24.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 2.0); + + // Verify that the styles were passed along + expect(getLabelStyle(tester).color, floatingLabelStyle.color); + }, + ); + + testWidgets( + 'InputDecorationThemeData.labelStyle overrides label widget styles when the widget is a text widget', + (WidgetTester tester) async { + const styleDefaultSize = TextStyle(fontSize: 16.0); + final TextStyle labelStyle = styleDefaultSize.merge(const TextStyle(color: Colors.purple)); + + // This test also verifies that the default InputDecorator provides a + // "small concession to backwards compatibility" by not padding on + // the left and right. If filled is true or an outline border is + // provided then the horizontal padding is included. + + await tester.pumpWidget( + buildInputDecoratorM2( + isEmpty: true, // Label appears inline, on top of the input field. + inputDecorationTheme: InputDecorationThemeData( + labelStyle: labelStyle, + // filled: false (default) - don't pad by left/right 12dps + ), + decoration: const InputDecoration(label: Text.rich(TextSpan(text: 'label'))), + ), + ); + + // Overall height for this InputDecorator is 56dps: + // 12 - top padding + // 12 - floating label (font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (font size 16dps) + // 12 - bottom padding + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 1.0); + + // Verify that the styles were passed along + expect(getLabelStyle(tester).color, labelStyle.color); + }, + ); + + testWidgets('hint style overflow works', (WidgetTester tester) async { + final String hintText = 'hint text' * 20; + const hintStyle = TextStyle(fontSize: 14.0, overflow: TextOverflow.fade); + final decoration = InputDecoration(hintText: hintText, hintStyle: hintStyle); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: decoration, + ), + ); + await tester.pumpAndSettle(); + + final Finder hintTextFinder = find.text(hintText); + final Text hintTextWidget = tester.widget(hintTextFinder); + expect(hintTextWidget.style!.overflow, decoration.hintStyle!.overflow); + }); + + testWidgets('prefixIcon in RTL with asymmetric padding', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/129591 + const decoration = InputDecoration( + contentPadding: EdgeInsetsDirectional.only(end: 24), + prefixIcon: Focus(child: Icon(Icons.search)), + ); + + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: decoration, + textDirection: TextDirection.rtl, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(InputDecorator), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); + + final Offset(dx: double decoratorRight) = tester.getTopRight(find.byType(InputDecorator)); + final Offset(dx: double prefixRight) = tester.getTopRight(find.byType(Icon)); + + // The prefix is inside the decorator. + expect(decoratorRight, lessThanOrEqualTo(prefixRight)); + }); + + testWidgets('intrinsic width with prefixIcon/suffixIcon', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/137937 + for (final TextDirection direction in TextDirection.values) { + Future<Size> measureText(InputDecoration decoration) async { + await tester.pumpWidget( + buildInputDecoratorM2( + // isEmpty: false (default) + // isFocused: false (default) + decoration: decoration, + useIntrinsicWidth: true, + textDirection: direction, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('text'), findsOneWidget); + + return tester.renderObject<RenderBox>(find.text('text')).size; + } + + const EdgeInsetsGeometry padding = EdgeInsetsDirectional.only(end: 24, start: 12); + + final Size textSizeWithoutIcons = await measureText( + const InputDecoration(contentPadding: padding), + ); + + final Size textSizeWithPrefixIcon = await measureText( + const InputDecoration( + contentPadding: padding, + prefixIcon: Focus(child: Icon(Icons.search)), + ), + ); + + final Size textSizeWithSuffixIcon = await measureText( + const InputDecoration( + contentPadding: padding, + suffixIcon: Focus(child: Icon(Icons.search)), + ), + ); + + expect( + textSizeWithPrefixIcon.width, + equals(textSizeWithoutIcons.width), + reason: 'text width is different with prefixIcon and $direction', + ); + expect( + textSizeWithSuffixIcon.width, + equals(textSizeWithoutIcons.width), + reason: 'text width is different with prefixIcon and $direction', + ); + } + }); + + testWidgets('InputDecorator with counter does not crash when given a 0 size', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/129611 + const decoration = InputDecoration( + contentPadding: EdgeInsetsDirectional.all(99), + prefixIcon: Focus(child: Icon(Icons.search)), + counter: Text('COUNTER'), + ); + + await tester.pumpWidget( + Center( + child: SizedBox.square( + dimension: 0.0, + child: buildInputDecoratorM2(decoration: decoration), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(InputDecorator), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.text('COUNTER')).size, Size.zero); + }); + }); + + testWidgets('UnderlineInputBorder with BorderStyle.none should not show anything', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/143746 + const decoration = InputDecoration( + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(style: BorderStyle.none), + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + ); + + await tester.pumpWidget(buildInputDecorator(decoration: decoration)); + final RenderBox box = tester.renderObject(find.byType(InputDecorator)); + expect(box, isNot(paints..drrect())); + }); + + testWidgets( + 'InputDecorator _buildError with errorText correctly updates on BuildContext updates', + (WidgetTester tester) async { + final errorTextNotifier = ValueNotifier<String?>('initial error'); + const helperTextValue = 'helper text'; + + addTearDown(errorTextNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ValueListenableBuilder<String?>( + key: const Key('value_listenable_builder_parent'), + valueListenable: errorTextNotifier, + builder: (BuildContext context, String? value, Widget? child) { + return buildInputDecorator( + decoration: InputDecoration(errorText: value, helperText: helperTextValue), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('initial error'), findsOneWidget); + expect(find.text(helperTextValue), findsNothing); + + errorTextNotifier.value = null; + + await tester.pumpAndSettle(); + + expect(find.text('initial error'), findsNothing); + expect(find.text(helperTextValue), findsOneWidget); + }, + ); + + testWidgets('helper text and character counter do not overlap', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/175591. + + // This test verifies that when both helperText and maxLength are specified, + // the helper text and character counter do not overlap. + const longHelperText = + 'This is a very long helper text that should not overlap with the character counter when both are present in the input field'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, // Constrain width to force potential overlap + child: TextFormField( + maxLength: 200, + decoration: const InputDecoration( + helperText: longHelperText, + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Find the helper text and counter widgets. + final Finder helperTextFinder = find.text(longHelperText); + final Finder counterFinder = find.text('0/200'); + + expect(helperTextFinder, findsOneWidget); + expect(counterFinder, findsOneWidget); + + // Get the positions of both widgets. + final Offset helperTextPosition = tester.getTopLeft(helperTextFinder); + final Offset counterPosition = tester.getTopLeft(counterFinder); + final Size helperTextSize = tester.getSize(helperTextFinder); + + // Calculate the right edge of helper text and left edge of counter. + final double helperTextRight = helperTextPosition.dx + helperTextSize.width; + final double counterLeft = counterPosition.dx; + + // Verify that helper text and counter do not overlap. + // The gap should be positive (no overlap) and at least 8.0 pixels. + final double actualGap = counterLeft - helperTextRight; + expect(actualGap, greaterThan(0.0)); // No overlap. + expect(actualGap, 16.0); // As per Material 3 specification. + }); + + // Regression test for https://github.com/flutter/flutter/issues/175993. + testWidgets('helper/error text default padding is correct', (WidgetTester tester) async { + const defaultPadding = 16.0; // From M3 spec. + const longText = 'This is a very long text that should wrap and fill the available width'; + const double inputWidth = 300; + + Future<void> buildDecorator({ + required TextDirection direction, + String? helperText, + String? errorText, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Directionality( + textDirection: direction, + child: SizedBox( + width: inputWidth, + child: InputDecorator( + decoration: InputDecoration( + filled: true, + helperText: helperText, + errorText: errorText, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + final Finder helperFinder = find.text(longText); + final Finder errorFinder = find.text(longText); + + // Helper in LTR. + await buildDecorator(direction: TextDirection.ltr, helperText: longText); + + expect(tester.getTopLeft(helperFinder).dx, defaultPadding); + expect(tester.getTopRight(helperFinder).dx, inputWidth - defaultPadding); + + // Helper in RTL. + await buildDecorator(direction: TextDirection.rtl, helperText: longText); + + expect(tester.getTopLeft(helperFinder).dx, defaultPadding); + expect(tester.getTopRight(helperFinder).dx, inputWidth - defaultPadding); + + // Error in LTR. + await buildDecorator(direction: TextDirection.ltr, errorText: longText); + + expect(tester.getTopLeft(errorFinder).dx, defaultPadding); + expect(tester.getTopRight(errorFinder).dx, inputWidth - defaultPadding); + + // Error in RTL. + await buildDecorator(direction: TextDirection.rtl, errorText: longText); + + expect(tester.getTopLeft(errorFinder).dx, defaultPadding); + expect(tester.getTopRight(errorFinder).dx, inputWidth - defaultPadding); + }); + + testWidgets('InputDecorator does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: InputDecorator(decoration: InputDecoration())), + ), + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), Size.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: InputDecorator(decoration: InputDecoration(border: OutlineInputBorder())), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/list_tile_test.dart b/packages/material_ui/test/material/list_tile_test.dart new file mode 100644 index 000000000000..6b5b70e19ebe --- /dev/null +++ b/packages/material_ui/test/material/list_tile_test.dart @@ -0,0 +1,4893 @@ +// 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'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +class TestIcon extends StatefulWidget { + const TestIcon({super.key}); + + @override + TestIconState createState() => TestIconState(); +} + +class TestIconState extends State<TestIcon> { + late IconThemeData iconTheme; + + @override + Widget build(BuildContext context) { + iconTheme = IconTheme.of(context); + return const Icon(Icons.add); + } +} + +class TestText extends StatefulWidget { + const TestText(this.text, {super.key}); + + final String text; + + @override + TestTextState createState() => TestTextState(); +} + +class TestTextState extends State<TestText> { + late TextStyle textStyle; + + @override + Widget build(BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return Text(widget.text); + } +} + +void main() { + testWidgets('ListTile geometry (LTR)', (WidgetTester tester) async { + // See https://material.io/go/design-lists + + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + late bool hasSubtitle; + + const leftPadding = 10.0; + const rightPadding = 20.0; + Widget buildFrame({ + bool dense = false, + bool isTwoLine = false, + bool isThreeLine = false, + TextScaler textScaler = TextScaler.noScaling, + TextScaler? subtitleScaler, + }) { + hasSubtitle = isTwoLine || isThreeLine; + subtitleScaler ??= textScaler; + return MaterialApp( + home: MediaQuery( + data: MediaQueryData( + padding: const EdgeInsets.only(left: leftPadding, right: rightPadding), + textScaler: textScaler, + ), + child: Material( + child: Center( + child: ListTile( + leading: SizedBox(key: leadingKey, width: 24.0, height: 24.0), + title: const Text('title'), + subtitle: hasSubtitle ? Text('subtitle', textScaler: subtitleScaler) : null, + trailing: SizedBox(key: trailingKey, width: 24.0, height: 24.0), + dense: dense, + isThreeLine: isThreeLine, + ), + ), + ), + ), + ); + } + + void testChildren() { + expect(find.byKey(leadingKey), findsOneWidget); + expect(find.text('title'), findsOneWidget); + if (hasSubtitle) { + expect(find.text('subtitle'), findsOneWidget); + } + expect(find.byKey(trailingKey), findsOneWidget); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double top(String text) => tester.getTopLeft(find.text(text)).dy; + double bottom(String text) => tester.getBottomLeft(find.text(text)).dy; + double height(String text) => tester.getRect(find.text(text)).height; + + double leftKey(Key key) => tester.getTopLeft(find.byKey(key)).dx; + double rightKey(Key key) => tester.getTopRight(find.byKey(key)).dx; + double widthKey(Key key) => tester.getSize(find.byKey(key)).width; + double heightKey(Key key) => tester.getSize(find.byKey(key)).height; + + // ListTiles are contained by a SafeArea defined like this: + // SafeArea(top: false, bottom: false, minimum: contentPadding) + // The default contentPadding is 16.0 on the left and 24.0 on the right. + void testHorizontalGeometry() { + expect(leftKey(leadingKey), math.max(16.0, leftPadding)); + expect(left('title'), 40.0 + math.max(16.0, leftPadding)); + if (hasSubtitle) { + expect(left('subtitle'), 40.0 + math.max(16.0, leftPadding)); + } + expect(left('title'), rightKey(leadingKey) + 16.0); + expect(rightKey(trailingKey), 800.0 - math.max(24.0, rightPadding)); + expect(widthKey(trailingKey), 24.0); + } + + void testVerticalGeometry(double expectedHeight) { + final Rect tileRect = tester.getRect(find.byType(ListTile)); + expect(tileRect.size, Size(800.0, expectedHeight)); + expect(top('title'), greaterThanOrEqualTo(tileRect.top)); + if (hasSubtitle) { + expect(top('subtitle'), greaterThanOrEqualTo(bottom('title'))); + expect(bottom('subtitle'), lessThan(tileRect.bottom)); + } else { + expect(top('title'), equals(tileRect.top + (tileRect.height - height('title')) / 2.0)); + } + expect(heightKey(trailingKey), 24.0); + } + + await tester.pumpWidget(buildFrame()); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(56.0); + + await tester.pumpWidget(buildFrame(isTwoLine: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(72.0); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(88.0); + + await tester.pumpWidget(buildFrame(textScaler: const TextScaler.linear(4.0))); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(112.0); + + await tester.pumpWidget(buildFrame(isTwoLine: true, textScaler: const TextScaler.linear(4.0))); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(192.0); + + // Make sure that the height of a large subtitle is taken into account. + await tester.pumpWidget( + buildFrame( + isTwoLine: true, + textScaler: const TextScaler.linear(0.5), + subtitleScaler: const TextScaler.linear(4.0), + ), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(108.0); + + await tester.pumpWidget( + buildFrame(isThreeLine: true, textScaler: const TextScaler.linear(4.0)), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(192.0); + }); + + testWidgets('ListTile geometry (RTL)', (WidgetTester tester) async { + const leftPadding = 10.0; + const rightPadding = 20.0; + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData( + padding: EdgeInsets.only(left: leftPadding, right: rightPadding), + ), + child: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: ListTile(leading: Text('L'), title: Text('title'), trailing: Text('T')), + ), + ), + ), + ), + ), + ); + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + void testHorizontalGeometry() { + expect(right('L'), 800.0 - math.max(16.0, rightPadding)); + expect(right('title'), 800.0 - 40.0 - math.max(16.0, rightPadding)); + expect(left('T'), math.max(24.0, leftPadding)); + } + + testHorizontalGeometry(); + }); + + testWidgets('ListTile.divideTiles', (WidgetTester tester) async { + final titles = <String>['first', 'second', 'third']; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ListView( + children: ListTile.divideTiles( + context: context, + tiles: titles.map<Widget>((String title) => ListTile(title: Text(title))), + ).toList(), + ); + }, + ), + ), + ), + ); + + expect(find.text('first'), findsOneWidget); + expect(find.text('second'), findsOneWidget); + expect(find.text('third'), findsOneWidget); + }); + + testWidgets('ListTile.divideTiles with empty list', (WidgetTester tester) async { + final Iterable<Widget> output = ListTile.divideTiles(tiles: <Widget>[], color: Colors.grey); + expect(output, isEmpty); + }); + + testWidgets('ListTile.divideTiles with single item list', (WidgetTester tester) async { + final Iterable<Widget> output = ListTile.divideTiles( + tiles: const <Widget>[SizedBox()], + color: Colors.grey, + ); + expect(output.single, isA<SizedBox>()); + }); + + testWidgets('ListTile.divideTiles only runs the generator once', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/78879 + var callCount = 0; + Iterable<Widget> generator() sync* { + callCount += 1; + yield const Text(''); + yield const Text(''); + } + + final List<Widget> output = ListTile.divideTiles( + tiles: generator(), + color: Colors.grey, + ).toList(); + expect(output, hasLength(2)); + expect(callCount, 1); + }); + + testWidgets('ListTile semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Column( + children: <Widget>[ + const ListTile(title: Text('one')), + ListTile(title: const Text('two'), onTap: () {}), + const ListTile(title: Text('three'), selected: true), + const ListTile(title: Text('four'), enabled: false), + ], + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.hasSelectedState, + ], + label: 'one', + ), + TestSemantics.rootChild( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'two', + ), + TestSemantics.rootChild( + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + ], + label: 'three', + ), + TestSemantics.rootChild( + flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState, SemanticsFlag.hasSelectedState], + label: 'four', + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('ListTile contentPadding', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: const ListTile( + contentPadding: EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + top: 30.0, + bottom: 40.0, + ), + leading: Text('L'), + title: Text('title'), + trailing: Text('T'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 126.0)); // 126 = 56 + 30 + 40 + expect(left('L'), 10.0); // contentPadding.start = 10 + expect(right('T'), 780.0); // 800 - contentPadding.end + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 126.0)); // 126 = 56 + 30 + 40 + expect(left('T'), 20.0); // contentPadding.end = 20 + expect(right('L'), 790.0); // 800 - contentPadding.start + }); + + testWidgets('ListTile wide leading Widget', (WidgetTester tester) async { + const Key leadingKey = ValueKey<String>('L'); + + Widget buildFrame(double leadingWidth, TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: SizedBox(key: leadingKey, width: leadingWidth, height: 32.0), + title: const Text('title'), + subtitle: const Text('subtitle'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + // textDirection = LTR + + // Two-line tile's height = 72, leading 24x32 widget is positioned in the center. + await tester.pumpWidget(buildFrame(24.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 20.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 20.0 + 32.0)); + + // Leading widget's width is 20, so default layout: the left edges of the + // title and subtitle are at 40dps, leading widget width is 24dp and 16dp + // is horizontalTitleGap (contentPadding is zero). + expect(left('title'), 40.0); + expect(left('subtitle'), 40.0); + + // If the leading widget is wider than 40 it is separated from the + // title and subtitle by 16. + await tester.pumpWidget(buildFrame(56.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 20.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 20.0 + 32.0)); + expect(left('title'), 72.0); + expect(left('subtitle'), 72.0); + + // Same tests, textDirection = RTL + + await tester.pumpWidget(buildFrame(24.0, TextDirection.rtl)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 20.0)); + expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 24.0, 20.0 + 32.0)); + expect(right('title'), 800.0 - 40.0); + expect(right('subtitle'), 800.0 - 40.0); + + await tester.pumpWidget(buildFrame(56.0, TextDirection.rtl)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 20.0)); + expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 20.0 + 32.0)); + expect(right('title'), 800.0 - 72.0); + expect(right('subtitle'), 800.0 - 72.0); + }); + + testWidgets('ListTile leading and trailing positions', (WidgetTester tester) async { + // This test is based on the redlines at + // https://material.io/design/components/lists.html#specs + + // "ONE"-LINE + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + ), + ], + ), + ), + ), + ); + await tester.pump( + const Duration(seconds: 2), + ); // the text styles are animated when we change dense + // LEFT TOP WIDTH HEIGHT + + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 328.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 144.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 152.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 328.0, 800.0, 56.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 328.0 + 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 328.0 + 16.0, 24.0, 24.0), + ); + + // "TWO"-LINE + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + + const double height = 300; + const avatarTop = 130.0; + const placeholderTop = 138.0; + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, avatarTop, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, placeholderTop, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 24.0, 24.0, 24.0), + ); + + // THREE-LINE + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 8.0, 24.0, 24.0), + ); + + // "ONE-LINE" with Small Leading Widget + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + ), + ], + ), + ), + ), + ); + await tester.pump( + const Duration(seconds: 2), + ); // the text styles are animated when we change dense + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 328.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 158.0, 24.0, 12.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 152.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 328.0, 800.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(2)), + const Rect.fromLTWH(16.0, 328.0 + 22.0, 24.0, 12.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(3)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 328.0 + 16.0, 24.0, 24.0), + ); + }); + + testWidgets('ListTile leading icon height does not exceed ListTile height', ( + WidgetTester tester, + ) async { + // regression test for https://github.com/flutter/flutter/issues/28765 + const oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); + + // One line + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(leading: oversizedWidget, title: Text('A')), + ListTile(leading: oversizedWidget, title: Text('B')), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 0.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 56.0, 24.0, 56.0), + ); + + // Two line + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(leading: oversizedWidget, title: Text('A'), subtitle: Text('A')), + ListTile(leading: oversizedWidget, title: Text('B'), subtitle: Text('B')), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 72.0 + 8.0, 24.0, 56.0), + ); + + // Three line + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 88.0 + 8.0, 24.0, 56.0), + ); + }); + + testWidgets('ListTile trailing icon height does not exceed ListTile height', ( + WidgetTester tester, + ) async { + // regression test for https://github.com/flutter/flutter/issues/28765 + const oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); + + // One line + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(trailing: oversizedWidget, title: Text('A'), dense: false), + ListTile(trailing: oversizedWidget, title: Text('B'), dense: false), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 0.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 56.0, 24.0, 56.0), + ); + + // Two line + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 72.0 + 8.0, 24.0, 56.0), + ); + + // Three line + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 88.0 + 8.0, 24.0, 56.0), + ); + }); + + testWidgets('ListTile only accepts focus when enabled', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + ListTile( + title: Text('A', key: childKey), + dense: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + await tester.pump(); // Let the focus take effect. + + final FocusNode tileNode = Focus.of(childKey.currentContext!); + tileNode.requestFocus(); + await tester.pump(); // Let the focus take effect. + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + + expect(tileNode.hasPrimaryFocus, isTrue); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + ListTile( + title: Text('A', key: childKey), + dense: true, + enabled: false, + onTap: () {}, + ), + ], + ), + ), + ), + ); + + expect(tester.binding.focusManager.primaryFocus, isNot(equals(tileNode))); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + }); + + testWidgets('ListTile can autofocus unless disabled.', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + ListTile( + title: Text('A', key: childKey), + dense: true, + autofocus: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + ListTile( + title: Text('A', key: childKey), + dense: true, + enabled: false, + autofocus: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + }); + + testWidgets('ListTile is focusable and has correct focus color', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'ListTile'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SizedBox( + width: 100, + height: 100, + child: Material( + color: Colors.white, + child: ListTile( + onTap: enabled ? () {} : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(Material), + paints + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero), + color: const Color(0xffffffff), + ) + ..rect(color: const Color(0x00000000)) + ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)), + ); + + // Check when the list tile is disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + find.byType(Material), + paints + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero), + color: const Color(0xffffffff), + ) + ..rect(color: const Color(0x00000000)), + ); + + focusNode.dispose(); + }); + + testWidgets('ListTile can be hovered and has correct hover color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SizedBox( + width: 100, + height: 100, + child: Material( + color: Colors.white, + child: ListTile( + onTap: enabled ? () {} : null, + hoverColor: Colors.orange[500], + autofocus: true, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + find.byType(Material), + paints + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero), + color: const Color(0xffffffff), + ) + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x1f000000), rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(ListTile))); + + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + find.byType(Material), + paints + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero), + color: const Color(0xffffffff), + ) + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x1f000000), rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)) + ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)), + ); + + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + find.byType(Material), + paints + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, Radius.zero), + color: const Color(0xffffffff), + ) + ..rect(color: const Color(0x00000000)) + ..rect( + color: Colors.orange[500]!.withAlpha(0), + rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0), + ), + ); + }); + + testWidgets('ListTile can be splashed and has correct splash color', (WidgetTester tester) async { + final Widget buildApp = MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: SizedBox.square( + dimension: 100, + child: ListTile(onTap: () {}, splashColor: const Color(0xff88ff88)), + ), + ), + ), + ); + + await tester.pumpWidget(buildApp); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(ListTile)).center, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(Material), paints..circle(x: 50, y: 50, color: const Color(0xff88ff88))); + await gesture.up(); + }); + + testWidgets('ListTile can be triggered by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const tileKey = Key('ListTile'); + var tapped = false; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SizedBox( + width: 200, + height: 100, + child: ListTile( + key: tileKey, + onTap: enabled + ? () { + setState(() { + tapped = true; + }); + } + : null, + hoverColor: Colors.orange[500], + autofocus: true, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + }); + + testWidgets('ListTile responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: ListTile( + key: key, + onTap: () {}, + autofocus: true, + visualDensity: visualDensity, + ), + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byKey(key)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 56))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 68))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 44))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 44))); + }); + + testWidgets('ListTile shape is painted correctly', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/63877 + const ShapeBorder rectShape = RoundedRectangleBorder(); + const ShapeBorder stadiumShape = StadiumBorder(); + final Color tileColor = Colors.red.shade500; + + Widget buildListTile(ShapeBorder shape) { + return MaterialApp( + home: Material( + child: Center( + child: ListTile(shape: shape, tileColor: tileColor), + ), + ), + ); + } + + // Test rectangle shape + await tester.pumpWidget(buildListTile(rectShape)); + Rect rect = tester.getRect(find.byType(ListTile)); + + // Check if a rounded rectangle was painted with the correct color and shape + expect(find.byType(Material), paints..rect(color: tileColor, rect: rect)); + + // Test stadium shape + await tester.pumpWidget(buildListTile(stadiumShape)); + rect = tester.getRect(find.byType(ListTile)); + + // Check if a rounded rectangle was painted with the correct color and shape + expect( + find.byType(Material), + paints + ..clipRect() + ..rrect( + color: tileColor, + rrect: RRect.fromRectAndRadius(rect, Radius.circular(rect.shortestSide / 2.0)), + ), + ); + }); + + testWidgets('ListTile changes mouse cursor when hovered', (WidgetTester tester) async { + // Test ListTile() constructor + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ListTile(onTap: () {}, mouseCursor: SystemMouseCursors.text), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(ListTile))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ListTile(onTap: () {}), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ListTile(enabled: false), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Test default cursor when onTap or onLongPress is null + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: MouseRegion(cursor: SystemMouseCursors.forbidden, child: ListTile()), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('ListTile onFocusChange callback', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'ListTile Focus'); + var gotFocus = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTile( + focusNode: node, + onFocusChange: (bool focused) { + gotFocus = focused; + }, + onTap: () {}, + ), + ), + ), + ); + + node.requestFocus(); + await tester.pump(); + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + + node.dispose(); + }); + + testWidgets('ListTile respects tileColor & selectedTileColor', (WidgetTester tester) async { + var isSelected = false; + final Color tileColor = Colors.green.shade500; + final Color selectedTileColor = Colors.red.shade500; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListTile( + selected: isSelected, + selectedTileColor: selectedTileColor, + tileColor: tileColor, + onTap: () { + setState(() => isSelected = !isSelected); + }, + title: const Text('Title'), + ); + }, + ), + ), + ), + ), + ); + + // Initially, when isSelected is false, the ListTile should respect tileColor. + expect(find.byType(Material), paints..rect(color: tileColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + // When isSelected is true, the ListTile should respect selectedTileColor. + expect(find.byType(Material), paints..rect(color: selectedTileColor)); + }); + + testWidgets('ListTile shows Material ripple effects on top of tileColor', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/73616 + final Color tileColor = Colors.red.shade500; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: ListTile(tileColor: tileColor, onTap: () {}, title: const Text('Title')), + ), + ), + ), + ); + + // Before ListTile is tapped, it should be tileColor + expect(find.byType(Material), paints..rect(color: tileColor)); + + // Tap on tile to trigger ink effect and wait for it to be underway. + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(milliseconds: 200)); + + // After tap, the tile could be drawn in tileColor, with the ripple (circle) on top + expect( + find.byType(Material), + paints + ..rect(color: tileColor) + ..circle(), + ); + }); + + testWidgets('ListTile default tile color', (WidgetTester tester) async { + var isSelected = false; + final theme = ThemeData(); + const Color defaultColor = Colors.transparent; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListTile( + selected: isSelected, + onTap: () { + setState(() => isSelected = !isSelected); + }, + title: const Text('Title'), + ); + }, + ), + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: defaultColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect(find.byType(Material), paints..rect(color: defaultColor)); + }); + + testWidgets('Default tile color when ListTile is wrapped with an elevated widget', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117700 + var isSelected = false; + final theme = ThemeData(); + const Color defaultColor = Colors.transparent; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Card( + elevation: 8.0, + child: ListTile( + selected: isSelected, + onTap: () { + setState(() => isSelected = !isSelected); + }, + title: const Text('Title'), + ), + ); + }, + ), + ), + ), + ); + + expect( + find.byType(Material), + paints + ..path(color: const Color(0xff000000)) + ..path(color: const Color(0xfff7f2fa)) + ..save() + ..save(), + ); + expect(find.byType(Material), paints..rect(color: defaultColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect( + find.byType(Material), + paints + ..path(color: const Color(0xff000000)) + ..path(color: const Color(0xfff7f2fa)) + ..save() + ..save(), + ); + expect(find.byType(Material), paints..rect(color: defaultColor)); + }); + + testWidgets('ListTile layout at zero size', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/66636 + const key = Key('key'); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox.shrink( + child: ListTile(key: key, tileColor: Colors.green), + ), + ), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byKey(key)); + expect(renderBox.size.width, equals(0.0)); + expect(renderBox.size.height, equals(0.0)); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('ListTile with disabled feedback', (WidgetTester tester) async { + const enableFeedback = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTile( + title: const Text('Title'), + onTap: () {}, + enableFeedback: enableFeedback, + ), + ), + ), + ); + + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets('ListTile with enabled feedback', (WidgetTester tester) async { + const enableFeedback = true; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTile( + title: const Text('Title'), + onTap: () {}, + enableFeedback: enableFeedback, + ), + ), + ), + ); + + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('ListTile with enabled feedback by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTile(title: const Text('Title'), onTap: () {}), + ), + ), + ); + + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('ListTile with disabled feedback using ListTileTheme', (WidgetTester tester) async { + const enableFeedbackTheme = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTileTheme( + data: const ListTileThemeData(enableFeedback: enableFeedbackTheme), + child: ListTile(title: const Text('Title'), onTap: () {}), + ), + ), + ), + ); + + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + + testWidgets('ListTile.enableFeedback overrides ListTileTheme.enableFeedback', ( + WidgetTester tester, + ) async { + const enableFeedbackTheme = false; + const enableFeedback = true; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTileTheme( + data: const ListTileThemeData(enableFeedback: enableFeedbackTheme), + child: ListTile( + enableFeedback: enableFeedback, + title: const Text('Title'), + onTap: () {}, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(ListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + + testWidgets('ListTile.mouseCursor overrides ListTileTheme.mouseCursor', ( + WidgetTester tester, + ) async { + final Key tileKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTileTheme( + data: const ListTileThemeData(mouseCursor: WidgetStateMouseCursor.clickable), + child: ListTile( + key: tileKey, + mouseCursor: WidgetStateMouseCursor.textable, + title: const Text('Title'), + onTap: () {}, + ), + ), + ), + ), + ); + + final Offset listTile = tester.getCenter(find.byKey(tileKey)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(listTile); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + }); + + testWidgets('ListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeHorizontalTitleGap, + double? widgetHorizontalTitleGap, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + horizontalTitleGap: widgetHorizontalTitleGap, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 40.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 40.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 40.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 760.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 760.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 760.0); + }); + + testWidgets('ListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', ( + WidgetTester tester, + ) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: const ListTile(leading: Text('L'), title: Text('title'), trailing: Text('T')), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(right('title'), 744.0); + }); + + testWidgets('ListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { + Widget buildFrame({double? horizontalTitleGap, VisualDensity? visualDensity}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + visualDensity: visualDensity, + horizontalTitleGap: horizontalTitleGap, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 42.0); + + // Pump another frame of the same widget to ensure the underlying render + // object did not cache the original horizontalTitleGap calculation based on the + // visualDensity + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 42.0); + }); + + testWidgets('ListTile minVerticalPadding = 80.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + minVerticalPadding: widgetMinVerticalPadding, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + }); + + testWidgets('ListTile minLeadingWidth = 60.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinLeadingWidth, + double? widgetMinLeadingWidth, + }) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minLeadingWidth: themeMinLeadingWidth), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + minLeadingWidth: widgetMinLeadingWidth, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 92.0 = 16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0 + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 708.0 = 800.0 - (16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0) + expect(right('title'), 708.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + }); + testWidgets('ListTile minTileHeight', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection, {double? minTileHeight}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile(minTileHeight: minTileHeight), + ), + ), + ), + ); + } + + // Default list tile with height = 56.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + + // Set list tile height = 30.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 30)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 30.0)); + + // Set list tile height = 60.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 60.0)); + }); + + testWidgets('ListTile computeMinIntrinsicHeight respects minTileHeight and padding', ( + WidgetTester tester, + ) async { + Widget buildFrame({double? minTileHeight}) { + return MaterialApp( + theme: ThemeData(listTileTheme: ListTileThemeData(minTileHeight: minTileHeight)), + home: const Material( + type: MaterialType.transparency, + child: Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Column(children: [ListTile(title: Text('item.label'))]), + ), + Expanded( + child: Column(children: [ListTile(title: Text('item.label'))]), + ), + ], + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(minTileHeight: 30)); + expect(tester.takeException(), isNull); + }); + + testWidgets('colors are applied to leading and trailing text widgets', ( + WidgetTester tester, + ) async { + final Key leadingKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + late ThemeData theme; + Widget buildFrame({bool enabled = true, bool selected = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + theme = Theme.of(context); + return ListTile( + enabled: enabled, + selected: selected, + leading: TestText('leading', key: leadingKey), + title: const TestText('title'), + trailing: TestText('trailing', key: trailingKey), + ); + }, + ), + ), + ), + ); + } + + Color textColor(Key key) => tester.state<TestTextState>(find.byKey(key)).textStyle.color!; + + await tester.pumpWidget(buildFrame()); + // Enabled color should be default bodyMedium color. + expect(textColor(leadingKey), theme.textTheme.bodyMedium!.color); + expect(textColor(trailingKey), theme.textTheme.bodyMedium!.color); + + await tester.pumpWidget(buildFrame(selected: true)); + // Wait for text color to animate. + await tester.pumpAndSettle(); + // Selected color should be ThemeData.primaryColor by default. + expect(textColor(leadingKey), theme.primaryColor); + expect(textColor(trailingKey), theme.primaryColor); + + await tester.pumpWidget(buildFrame(enabled: false)); + // Wait for text color to animate. + await tester.pumpAndSettle(); + // Disabled color should be ThemeData.disabledColor by default. + expect(textColor(leadingKey), theme.disabledColor); + expect(textColor(trailingKey), theme.disabledColor); + }); + + testWidgets('selected, enabled ListTile default icon color', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key subtitleKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + Widget buildFrame({required bool selected}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ListTile( + selected: selected, + leading: TestIcon(key: leadingKey), + title: TestIcon(key: titleKey), + subtitle: TestIcon(key: subtitleKey), + trailing: TestIcon(key: trailingKey), + ), + ), + ), + ); + } + + Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; + + await tester.pumpWidget(buildFrame(selected: true)); + expect(iconColor(leadingKey), colorScheme.primary); + expect(iconColor(titleKey), colorScheme.primary); + expect(iconColor(subtitleKey), colorScheme.primary); + expect(iconColor(trailingKey), colorScheme.primary); + + await tester.pumpWidget(buildFrame(selected: false)); + expect(iconColor(leadingKey), colorScheme.onSurfaceVariant); + expect(iconColor(titleKey), colorScheme.onSurfaceVariant); + expect(iconColor(subtitleKey), colorScheme.onSurfaceVariant); + expect(iconColor(trailingKey), colorScheme.onSurfaceVariant); + }); + + testWidgets('ListTile font size', (WidgetTester tester) async { + Widget buildFrame() { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + // ListTile default text sizes. + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 11.0); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 16.0); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 14.0); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 11.0); + }); + + testWidgets('ListTile text color', (WidgetTester tester) async { + Widget buildFrame() { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + final theme = ThemeData(); + + // ListTile default text colors. + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.color, theme.colorScheme.onSurfaceVariant); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, theme.colorScheme.onSurface); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.color, theme.colorScheme.onSurfaceVariant); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.color, theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('Default ListTile debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ListTile().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ListTile implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ListTile( + leading: Text('leading'), + title: Text('title'), + subtitle: Text('trailing'), + trailing: Text('trailing'), + isThreeLine: true, + dense: true, + visualDensity: VisualDensity.standard, + shape: RoundedRectangleBorder(), + style: ListTileStyle.list, + selectedColor: Color(0xff0000ff), + iconColor: Color(0xff00ff00), + textColor: Color(0xffff0000), + titleTextStyle: TextStyle(fontSize: 22), + subtitleTextStyle: TextStyle(fontSize: 18), + leadingAndTrailingTextStyle: TextStyle(fontSize: 16), + contentPadding: EdgeInsets.zero, + enabled: false, + selected: true, + focusColor: Color(0xff00ffff), + hoverColor: Color(0xff0000ff), + autofocus: true, + tileColor: Color(0xffffff00), + selectedTileColor: Color(0xff123456), + enableFeedback: false, + horizontalTitleGap: 4.0, + minVerticalPadding: 2.0, + minLeadingWidth: 6.0, + titleAlignment: ListTileTitleAlignment.bottom, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'isThreeLine: THREE_LINE', + 'dense: true', + 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + 'style: ListTileStyle.list', + 'selectedColor: ${const Color(0xff0000ff)}', + 'iconColor: ${const Color(0xff00ff00)}', + 'textColor: ${const Color(0xffff0000)}', + 'titleTextStyle: TextStyle(inherit: true, size: 22.0)', + 'subtitleTextStyle: TextStyle(inherit: true, size: 18.0)', + 'leadingAndTrailingTextStyle: TextStyle(inherit: true, size: 16.0)', + 'contentPadding: EdgeInsets.zero', + 'enabled: false', + 'selected: true', + 'focusColor: ${const Color(0xff00ffff)}', + 'hoverColor: ${const Color(0xff0000ff)}', + 'autofocus: true', + 'tileColor: ${const Color(0xffffff00)}', + 'selectedTileColor: ${const Color(0xff123456)}', + 'enableFeedback: false', + 'horizontalTitleGap: 4.0', + 'minVerticalPadding: 2.0', + 'minLeadingWidth: 6.0', + 'titleAlignment: ListTileTitleAlignment.bottom', + ]), + ); + }); + + testWidgets('ListTile.textColor respects WidgetStateColor', (WidgetTester tester) async { + var enabled = false; + var selected = false; + const Color defaultColor = Colors.blue; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.red; + + Widget buildFrame() { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + enabled: enabled, + selected: selected, + textColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + }), + title: const TestText('title'), + subtitle: const TestText('subtitle'), + ); + }, + ), + ), + ), + ); + } + + // Test disabled state. + await tester.pumpWidget(buildFrame()); + RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, disabledColor); + + // Test enabled state. + enabled = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, defaultColor); + + // Test selected state. + selected = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, selectedColor); + }); + + testWidgets('ListTile.iconColor respects WidgetStateColor', (WidgetTester tester) async { + var enabled = false; + var selected = false; + const Color defaultColor = Colors.blue; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.red; + final Key leadingKey = UniqueKey(); + + Widget buildFrame() { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + enabled: enabled, + selected: selected, + iconColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + }), + leading: TestIcon(key: leadingKey), + ); + }, + ), + ), + ), + ); + } + + Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; + + // Test disabled state. + await tester.pumpWidget(buildFrame()); + expect(iconColor(leadingKey), disabledColor); + + // Test enabled state. + enabled = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect(iconColor(leadingKey), defaultColor); + + // Test selected state. + selected = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect(iconColor(leadingKey), selectedColor); + }); + + testWidgets( + 'ListTile.iconColor respects iconColor property with icon buttons Material 3 in presence of IconButtonTheme override', + (WidgetTester tester) async { + const Color iconButtonThemeColor = Colors.blue; + const Color listTileIconColor = Colors.green; + const leadingIcon = Icon(Icons.favorite); + const trailingIcon = Icon(Icons.close); + + Widget buildFrame() { + return MaterialApp( + theme: ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: iconButtonThemeColor), + ), + ), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + iconColor: listTileIconColor, + leading: IconButton(icon: leadingIcon, onPressed: () {}), + trailing: IconButton(icon: trailingIcon, onPressed: () {}), + ); + }, + ), + ), + ), + ); + } + + TextStyle? getIconStyle(WidgetTester tester, IconData icon) => tester + .widget<RichText>(find.descendant(of: find.byIcon(icon), matching: find.byType(RichText))) + .text + .style; + + await tester.pumpWidget(buildFrame()); + expect(getIconStyle(tester, leadingIcon.icon!)?.color, listTileIconColor); + expect(getIconStyle(tester, trailingIcon.icon!)?.color, listTileIconColor); + }, + ); + + testWidgets( + 'IconButtonTheme.style.foregroundColor is preserved in ListTile in non-overriding scenario', + (WidgetTester tester) async { + const Color iconButtonThemeColor = Colors.blue; + const leadingIcon = Icon(Icons.favorite); + const trailingIcon = Icon(Icons.close); + + Widget buildFrame() { + return MaterialApp( + theme: ThemeData( + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: iconButtonThemeColor), + ), + ), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + leading: IconButton(icon: leadingIcon, onPressed: () {}), + trailing: IconButton(icon: trailingIcon, onPressed: () {}), + ); + }, + ), + ), + ), + ); + } + + TextStyle? getIconStyle(WidgetTester tester, IconData icon) => tester + .widget<RichText>(find.descendant(of: find.byIcon(icon), matching: find.byType(RichText))) + .text + .style; + + await tester.pumpWidget(buildFrame()); + expect(getIconStyle(tester, leadingIcon.icon!)?.color, iconButtonThemeColor); + expect(getIconStyle(tester, trailingIcon.icon!)?.color, iconButtonThemeColor); + }, + ); + + testWidgets('ListTile respects and combines parent IconButtonThemeData style', ( + WidgetTester tester, + ) async { + const Color customIconColor = Colors.green; + const leadingIcon = Icon(Icons.favorite); + const trailingIcon = Icon(Icons.close); + const WidgetStateProperty<OutlinedBorder> customShape = WidgetStatePropertyAll<OutlinedBorder>( + RoundedRectangleBorder(side: BorderSide()), + ); + // Specially treated in ButtonStyle.merge, thus check these, too. + const WidgetStateProperty<Color> overlayColor = WidgetStatePropertyAll<Color>(Colors.purple); + const WidgetStateProperty<MouseCursor> mouseCursor = WidgetStatePropertyAll<MouseCursor>( + SystemMouseCursors.resizeUpRightDownLeft, + ); + + Widget buildFrame() { + return MaterialApp( + theme: ThemeData( + iconButtonTheme: const IconButtonThemeData( + style: ButtonStyle( + overlayColor: overlayColor, + mouseCursor: mouseCursor, + shape: customShape, + ), + ), + ), + home: Scaffold( + body: Center( + child: ListTile( + iconColor: customIconColor, + leading: IconButton(icon: leadingIcon, onPressed: () {}), + trailing: IconButton(icon: trailingIcon, onPressed: () {}), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + // Verify that the merged theme retains the parent shape and other critical fields. + for (final BuildContext iconButtonContext in find.byType(IconButton).evaluate()) { + final IconButtonThemeData mergedTheme = IconButtonTheme.of(iconButtonContext); + expect(mergedTheme.style?.shape, customShape); + expect(mergedTheme.style?.overlayColor, overlayColor); + expect(mergedTheme.style?.mouseCursor, mouseCursor); + } + + // Verify that ListTile's iconColor overrides the inherited color. + Color? getIconColor(IconData iconData) { + final RichText richText = tester.widget<RichText>( + find.descendant(of: find.byIcon(iconData), matching: find.byType(RichText)), + ); + return richText.text.style?.color; + } + + expect(getIconColor(leadingIcon.icon!), customIconColor); + expect(getIconColor(trailingIcon.icon!), customIconColor); + }); + + testWidgets('ListTile.dense does not throw assertion', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/pull/116908 + + Widget buildFrame({required bool useMaterial3}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return const ListTile(dense: true, title: Text('Title')); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(useMaterial3: false)); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(buildFrame(useMaterial3: true)); + expect(tester.takeException(), isNull); + }); + + testWidgets('titleAlignment position with title widget', (WidgetTester tester) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const leadingHeight = 24.0; + const titleHeight = 50.0; + const trailingHeight = 24.0; + const minVerticalPadding = 10.0; + const double tileHeight = minVerticalPadding * 2 + titleHeight; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + home: Material( + child: Center( + child: ListTile( + titleAlignment: titleAlignment, + minVerticalPadding: minVerticalPadding, + leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), + title: const SizedBox(width: 20.0, height: titleHeight), + trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, the default title alignment is + // [ListTileTitleAlignment.threeLine], which positions the leading and + // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] + // property is false. + await tester.pumpWidget(buildFrame()); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile. + const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // If the tile height is less than 72.0 pixels, the leading widget is placed + // 16.0 pixels below the top of the title widget, and the trailing is centered + // vertically in the tile. + const titlePosition = 16.0; + expect(leadingOffset.dy - tileOffset.dy, titlePosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. + const topPosition = minVerticalPadding; + expect(leadingOffset.dy - tileOffset.dy, topPosition); + expect(trailingOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; + expect(leadingOffset.dy - tileOffset.dy, bottomPosition); + expect(trailingOffset.dy - tileOffset.dy, bottomPosition); + }); + + testWidgets('titleAlignment position with title and subtitle widgets', ( + WidgetTester tester, + ) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const leadingHeight = 24.0; + const titleHeight = 50.0; + const subtitleHeight = 50.0; + const trailingHeight = 24.0; + const minVerticalPadding = 10.0; + const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + home: Material( + child: Center( + child: ListTile( + titleAlignment: titleAlignment, + minVerticalPadding: minVerticalPadding, + leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), + title: const SizedBox(width: 20.0, height: titleHeight), + subtitle: const SizedBox(width: 20.0, height: subtitleHeight), + trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, the default title alignment is + // [ListTileTitleAlignment.threeLine], which positions the leading and + // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] + // property is false. + await tester.pumpWidget(buildFrame()); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile. + const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are positioned 16.0 pixels below the + // top of the title widget. + const titlePosition = 16.0; + expect(leadingOffset.dy - tileOffset.dy, titlePosition); + expect(trailingOffset.dy - tileOffset.dy, titlePosition); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. + const topPosition = minVerticalPadding; + expect(leadingOffset.dy - tileOffset.dy, topPosition); + expect(trailingOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; + expect(leadingOffset.dy - tileOffset.dy, bottomPosition); + expect(trailingOffset.dy - tileOffset.dy, bottomPosition); + }); + + testWidgets("ListTile.isThreeLine updates ListTileTitleAlignment.threeLine's alignment", ( + WidgetTester tester, + ) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const leadingHeight = 24.0; + const titleHeight = 50.0; + const subtitleHeight = 50.0; + const trailingHeight = 24.0; + const minVerticalPadding = 10.0; + const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment, bool isThreeLine = false}) { + return MaterialApp( + home: Material( + child: Center( + child: ListTile( + titleAlignment: titleAlignment, + minVerticalPadding: minVerticalPadding, + leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), + title: const SizedBox(width: 20.0, height: titleHeight), + subtitle: const SizedBox(width: 20.0, height: subtitleHeight), + trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), + isThreeLine: isThreeLine, + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, then title alignment should + // default to [ListTileTitleAlignment.threeLine]. + await tester.pumpWidget(buildFrame()); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // By default, leading and trailing widgets are centered vertically + // in the tile. + const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Set [ListTile.isThreeLine] to true to update the alignment. + await tester.pumpWidget(buildFrame(isThreeLine: true)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // The leading and trailing widgets are placed minVerticalPadding + // to the top of the tile widget. + const topPosition = minVerticalPadding; + expect(leadingOffset.dy - tileOffset.dy, topPosition); + expect(trailingOffset.dy - tileOffset.dy, topPosition); + }); + + group('Leading/Trailing exceeding list tile width throws exception', () { + final exceptions = <Object>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + tearDown(exceptions.clear); + + void onError(FlutterErrorDetails details) => exceptions.add(details.exception); + Widget buildListTile({Widget? leading, Widget? trailing}) { + return MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 100, + child: ListTile(leading: leading, trailing: trailing), + ), + ), + ), + ); + } + + testWidgets('leading', (WidgetTester tester) async { + // Test a leading widget that exceeds the list tile width. + // 16 (content padding) + 61 (leading width) + 24 (content padding) = 101. + // List tile width is 100 as a result, an exception should be thrown. + FlutterError.onError = onError; + await tester.pumpWidget(buildListTile(leading: const SizedBox(width: 61))); + FlutterError.onError = oldHandler; + + final error = exceptions.first as FlutterError; + expect(error.diagnostics.length, 3); + expect( + error.diagnostics[0].toStringDeep(), + 'Leading widget consumes the entire tile width (including\nListTile.contentPadding).\n', + ); + expect( + error.diagnostics[1].toStringDeep(), + 'Either resize the tile width so that the leading widget plus any\n' + 'content padding do not exceed the tile width, or use a sized\n' + 'widget, or consider replacing ListTile with a custom widget.\n', + ); + expect( + error.diagnostics[2].toStringDeep(), + 'See also:\n' + 'https://api.flutter.dev/flutter/material/ListTile-class.html#material.ListTile.4\n', + ); + }); + + testWidgets('trailing', (WidgetTester tester) async { + // Test a trailing widget that exceeds the list tile width. + // 16 (content padding) + 61 (trailing width) + 24 (content padding) = 101. + // List tile width is 100 as a result, an exception should be thrown. + FlutterError.onError = onError; + await tester.pumpWidget(buildListTile(trailing: const SizedBox(width: 61))); + FlutterError.onError = oldHandler; + + final error = exceptions.first as FlutterError; + expect(error.diagnostics.length, 3); + expect( + error.diagnostics[0].toStringDeep(), + 'Trailing widget consumes the entire tile width (including\nListTile.contentPadding).\n', + ); + expect( + error.diagnostics[1].toStringDeep(), + 'Either resize the tile width so that the trailing widget plus any\n' + 'content padding do not exceed the tile width, or use a sized\n' + 'widget, or consider replacing ListTile with a custom widget.\n', + ); + expect( + error.diagnostics[2].toStringDeep(), + 'See also:\n' + 'https://api.flutter.dev/flutter/material/ListTile-class.html#material.ListTile.4\n', + ); + }); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('ListTile geometry (LTR)', (WidgetTester tester) async { + // See https://material.io/go/design-lists + + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + late bool hasSubtitle; + + const leftPadding = 10.0; + const rightPadding = 20.0; + Widget buildFrame({ + bool dense = false, + bool isTwoLine = false, + bool isThreeLine = false, + TextScaler textScaler = TextScaler.noScaling, + TextScaler? subtitleScaler, + }) { + hasSubtitle = isTwoLine || isThreeLine; + subtitleScaler ??= textScaler; + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: MediaQueryData( + padding: const EdgeInsets.only(left: leftPadding, right: rightPadding), + textScaler: textScaler, + ), + child: Material( + child: Center( + child: ListTile( + leading: SizedBox(key: leadingKey, width: 24.0, height: 24.0), + title: const Text('title'), + subtitle: hasSubtitle ? Text('subtitle', textScaler: subtitleScaler) : null, + trailing: SizedBox(key: trailingKey, width: 24.0, height: 24.0), + dense: dense, + isThreeLine: isThreeLine, + ), + ), + ), + ), + ); + } + + void testChildren() { + expect(find.byKey(leadingKey), findsOneWidget); + expect(find.text('title'), findsOneWidget); + if (hasSubtitle) { + expect(find.text('subtitle'), findsOneWidget); + } + expect(find.byKey(trailingKey), findsOneWidget); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double top(String text) => tester.getTopLeft(find.text(text)).dy; + double bottom(String text) => tester.getBottomLeft(find.text(text)).dy; + double height(String text) => tester.getRect(find.text(text)).height; + + double leftKey(Key key) => tester.getTopLeft(find.byKey(key)).dx; + double rightKey(Key key) => tester.getTopRight(find.byKey(key)).dx; + double widthKey(Key key) => tester.getSize(find.byKey(key)).width; + double heightKey(Key key) => tester.getSize(find.byKey(key)).height; + + // ListTiles are contained by a SafeArea defined like this: + // SafeArea(top: false, bottom: false, minimum: contentPadding) + // The default contentPadding is 16.0 on the left and right. + void testHorizontalGeometry() { + expect(leftKey(leadingKey), math.max(16.0, leftPadding)); + expect(left('title'), 56.0 + math.max(16.0, leftPadding)); + if (hasSubtitle) { + expect(left('subtitle'), 56.0 + math.max(16.0, leftPadding)); + } + expect(left('title'), rightKey(leadingKey) + 32.0); + expect(rightKey(trailingKey), 800.0 - math.max(16.0, rightPadding)); + expect(widthKey(trailingKey), 24.0); + } + + void testVerticalGeometry(double expectedHeight) { + final Rect tileRect = tester.getRect(find.byType(ListTile)); + expect(tileRect.size, Size(800.0, expectedHeight)); + expect(top('title'), greaterThanOrEqualTo(tileRect.top)); + if (hasSubtitle) { + expect(top('subtitle'), greaterThanOrEqualTo(bottom('title'))); + expect(bottom('subtitle'), lessThan(tileRect.bottom)); + } else { + expect(top('title'), equals(tileRect.top + (tileRect.height - height('title')) / 2.0)); + } + expect(heightKey(trailingKey), 24.0); + } + + await tester.pumpWidget(buildFrame()); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(56.0); + + await tester.pumpWidget(buildFrame(dense: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(48.0); + + await tester.pumpWidget(buildFrame(isTwoLine: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(72.0); + + await tester.pumpWidget(buildFrame(isTwoLine: true, dense: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(64.0); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(88.0); + + await tester.pumpWidget(buildFrame(isThreeLine: true, dense: true)); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(76.0); + + await tester.pumpWidget(buildFrame(textScaler: const TextScaler.linear(4.0))); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(72.0); + + await tester.pumpWidget(buildFrame(dense: true, textScaler: const TextScaler.linear(4.0))); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(72.0); + + await tester.pumpWidget( + buildFrame(isTwoLine: true, textScaler: const TextScaler.linear(4.0)), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(128.0); + + // Make sure that the height of a large subtitle is taken into account. + await tester.pumpWidget( + buildFrame( + isTwoLine: true, + textScaler: const TextScaler.linear(0.5), + subtitleScaler: const TextScaler.linear(4.0), + ), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(72.0); + + await tester.pumpWidget( + buildFrame(isTwoLine: true, dense: true, textScaler: const TextScaler.linear(4.0)), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(128.0); + + await tester.pumpWidget( + buildFrame(isThreeLine: true, textScaler: const TextScaler.linear(4.0)), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(128.0); + + await tester.pumpWidget( + buildFrame(isThreeLine: true, dense: true, textScaler: const TextScaler.linear(4.0)), + ); + testChildren(); + testHorizontalGeometry(); + testVerticalGeometry(128.0); + }); + + testWidgets('ListTile geometry (RTL)', (WidgetTester tester) async { + const leftPadding = 10.0; + const rightPadding = 20.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const MediaQuery( + data: MediaQueryData( + padding: EdgeInsets.only(left: leftPadding, right: rightPadding), + ), + child: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: ListTile(leading: Text('L'), title: Text('title'), trailing: Text('T')), + ), + ), + ), + ), + ), + ); + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + void testHorizontalGeometry() { + expect(right('L'), 800.0 - math.max(16.0, rightPadding)); + expect(right('title'), 800.0 - 56.0 - math.max(16.0, rightPadding)); + expect(left('T'), math.max(16.0, leftPadding)); + } + + testHorizontalGeometry(); + }); + + testWidgets('ListTile leading and trailing positions', (WidgetTester tester) async { + // This test is based on the redlines at + // https://material.io/design/components/lists.html#specs + + // DENSE "ONE"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + dense: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + dense: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 177.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 177.0, 800.0, 48.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 177.0 + 4.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 177.0 + 12.0, 24.0, 24.0), + ); + + // NON-DENSE "ONE"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + ), + ], + ), + ), + ), + ); + await tester.pump( + const Duration(seconds: 2), + ); // the text styles are animated when we change dense + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 216.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 216.0, 800.0, 56.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 216.0 + 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0), + ); + + // DENSE "TWO"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + dense: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + dense: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 180.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 180.0, 800.0, 64.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 180.0 + 12.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 20.0, 24.0, 24.0), + ); + + // NON-DENSE "TWO"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 180.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 180.0, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 180.0 + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 24.0, 24.0, 24.0), + ); + + // DENSE "THREE"-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + dense: true, + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + dense: true, + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 180.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 180.0, 800.0, 76.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 180.0 + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0), + ); + + // NON-DENSE THREE-LINE + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + isThreeLine: true, + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ); + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 180.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 180.0, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, 180.0 + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 180.0 + 16.0, 24.0, 24.0), + ); + + // "ONE-LINE" with Small Leading Widget + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: SizedBox(height: 12.0, width: 24.0, child: Placeholder()), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + ), + ], + ), + ), + ), + ); + await tester.pump( + const Duration(seconds: 2), + ); // the text styles are animated when we change dense + // LEFT TOP WIDTH HEIGHT + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 216.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 16.0, 24.0, 12.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 16.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, 216.0, 800.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(2)), + const Rect.fromLTWH(16.0, 216.0 + 16.0, 24.0, 12.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(3)), + const Rect.fromLTWH(800.0 - 24.0 - 16.0, 216.0 + 16.0, 24.0, 24.0), + ); + }); + + testWidgets('ListTile leading icon height does not exceed ListTile height', ( + WidgetTester tester, + ) async { + // regression test for https://github.com/flutter/flutter/issues/28765 + const oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); + + // Dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(leading: oversizedWidget, title: Text('A'), dense: true), + ListTile(leading: oversizedWidget, title: Text('B'), dense: true), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 0.0, 24.0, 48.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 48.0, 24.0, 48.0), + ); + + // Non-dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(leading: oversizedWidget, title: Text('A'), dense: false), + ListTile(leading: oversizedWidget, title: Text('B'), dense: false), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 0.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 56.0, 24.0, 56.0), + ); + + // Dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: true, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: true, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 8.0, 24.0, 48.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 64.0 + 8.0, 24.0, 48.0), + ); + + // Non-dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: false, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 8.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 72.0 + 8.0, 24.0, 56.0), + ); + + // Dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: true, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: true, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 16.0, 24.0, 48.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 76.0 + 16.0, 24.0, 48.0), + ); + + // Non-dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: false, + ), + ListTile( + leading: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(16.0, 16.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(16.0, 88.0 + 16.0, 24.0, 56.0), + ); + }); + + testWidgets('ListTile trailing icon height does not exceed ListTile height', ( + WidgetTester tester, + ) async { + // regression test for https://github.com/flutter/flutter/issues/28765 + const oversizedWidget = SizedBox(height: 80.0, width: 24.0, child: Placeholder()); + + // Dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(trailing: oversizedWidget, title: Text('A'), dense: true), + ListTile(trailing: oversizedWidget, title: Text('B'), dense: true), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0, 24.0, 48.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 48.0, 24.0, 48.0), + ); + + // Non-dense One line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile(trailing: oversizedWidget, title: Text('A'), dense: false), + ListTile(trailing: oversizedWidget, title: Text('B'), dense: false), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 0.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 56.0, 24.0, 56.0), + ); + + // Dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: true, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: true, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 48.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 64.0 + 8.0, 24.0, 48.0), + ); + + // Non-dense Two line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + dense: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 8.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 72.0 + 8.0, 24.0, 56.0), + ); + + // Dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: true, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: true, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 48.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 76.0 + 16.0, 24.0, 48.0), + ); + + // Non-dense Three line + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + trailing: oversizedWidget, + title: Text('A'), + subtitle: Text('A'), + isThreeLine: true, + dense: false, + ), + ListTile( + trailing: oversizedWidget, + title: Text('B'), + subtitle: Text('B'), + isThreeLine: true, + dense: false, + ), + ], + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 16.0, 24.0, 56.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 16.0 - 24.0, 88.0 + 16.0, 24.0, 56.0), + ); + }); + + testWidgets('ListTile wide leading Widget', (WidgetTester tester) async { + const Key leadingKey = ValueKey<String>('L'); + + Widget buildFrame(double leadingWidth, TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: SizedBox(key: leadingKey, width: leadingWidth, height: 32.0), + title: const Text('title'), + subtitle: const Text('subtitle'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + // textDirection = LTR + + // Two-line tile's height = 72, leading 24x32 widget is positioned 16.0 pixels from the top. + await tester.pumpWidget(buildFrame(24.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 16.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(24.0, 16.0 + 32.0)); + + // Leading widget's width is 20, so default layout: the left edges of the + // title and subtitle are at 56dps (contentPadding is zero). + expect(left('title'), 56.0); + expect(left('subtitle'), 56.0); + + // If the leading widget is wider than 40 it is separated from the + // title and subtitle by 16. + await tester.pumpWidget(buildFrame(56.0, TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopLeft(find.byKey(leadingKey)), const Offset(0.0, 16.0)); + expect(tester.getBottomRight(find.byKey(leadingKey)), const Offset(56.0, 16.0 + 32.0)); + expect(left('title'), 72.0); + expect(left('subtitle'), 72.0); + + // Same tests, textDirection = RTL + + await tester.pumpWidget(buildFrame(24.0, TextDirection.rtl)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 16.0)); + expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 24.0, 16.0 + 32.0)); + expect(right('title'), 800.0 - 56.0); + expect(right('subtitle'), 800.0 - 56.0); + + await tester.pumpWidget(buildFrame(56.0, TextDirection.rtl)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 72.0)); + expect(tester.getTopRight(find.byKey(leadingKey)), const Offset(800.0, 16.0)); + expect(tester.getBottomLeft(find.byKey(leadingKey)), const Offset(800.0 - 56.0, 16.0 + 32.0)); + expect(right('title'), 800.0 - 72.0); + expect(right('subtitle'), 800.0 - 72.0); + }); + + testWidgets('ListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeHorizontalTitleGap, + double? widgetHorizontalTitleGap, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + horizontalTitleGap: widgetHorizontalTitleGap, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + }); + + testWidgets('ListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', ( + WidgetTester tester, + ) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: const ListTile( + leading: Text('L'), + title: Text('title'), + trailing: Text('T'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(left('title'), 72.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(right('title'), 728.0); + }); + + testWidgets('ListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { + Widget buildFrame({double? horizontalTitleGap, VisualDensity? visualDensity}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + visualDensity: visualDensity, + horizontalTitleGap: horizontalTitleGap, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + + // Pump another frame of the same widget to ensure the underlying render + // object did not cache the original horizontalTitleGap calculation based on the + // visualDensity + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + }); + + testWidgets('ListTile minVerticalPadding = 80.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: ListTile( + minVerticalPadding: widgetMinVerticalPadding, + leading: const Text('L'), + title: const Text('title'), + trailing: const Text('T'), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + }); + + testWidgets('ListTile font size', (WidgetTester tester) async { + Widget buildFrame({ + bool dense = false, + bool enabled = true, + bool selected = false, + ListTileStyle? style, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + dense: dense, + enabled: enabled, + selected: selected, + style: style, + leading: const TestText('leading'), + title: const TestText('title'), + subtitle: const TestText('subtitle'), + trailing: const TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + // ListTile - ListTileStyle.list (default). + await tester.pumpWidget(buildFrame()); + RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 14.0); + RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 16.0); + RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 14.0); + RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 14.0); + + // ListTile - Densed - ListTileStyle.list (default). + await tester.pumpWidget(buildFrame(dense: true)); + await tester.pumpAndSettle(); + leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 14.0); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 13.0); + subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 12.0); + trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 14.0); + + // ListTile - ListTileStyle.drawer. + await tester.pumpWidget(buildFrame(style: ListTileStyle.drawer)); + await tester.pumpAndSettle(); + leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 14.0); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 14.0); + subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 14.0); + trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 14.0); + + // ListTile - Densed - ListTileStyle.drawer. + await tester.pumpWidget(buildFrame(dense: true, style: ListTileStyle.drawer)); + await tester.pumpAndSettle(); + leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, 14.0); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, 13.0); + subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, 12.0); + trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, 14.0); + }); + + testWidgets('ListTile text color', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + Widget buildFrame({ + bool dense = false, + bool enabled = true, + bool selected = false, + ListTileStyle? style, + }) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + dense: dense, + enabled: enabled, + selected: selected, + style: style, + leading: const TestText('leading'), + title: const TestText('title'), + subtitle: const TestText('subtitle'), + trailing: const TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + // ListTile - ListTileStyle.list (default). + await tester.pumpWidget(buildFrame()); + RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.color, theme.textTheme.bodyMedium!.color); + RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, theme.textTheme.titleMedium!.color); + RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.color, theme.textTheme.bodySmall!.color); + RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.color, theme.textTheme.bodyMedium!.color); + + // ListTile - ListTileStyle.drawer. + await tester.pumpWidget(buildFrame(style: ListTileStyle.drawer)); + await tester.pumpAndSettle(); + leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.color, theme.textTheme.bodyMedium!.color); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, theme.textTheme.titleMedium!.color); + subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.color, theme.textTheme.bodySmall!.color); + trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.color, theme.textTheme.bodyMedium!.color); + }); + + testWidgets('selected, enabled ListTile default icon color, light and dark themes', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/pull/77004 + + const lightColorScheme = ColorScheme.light(); + const darkColorScheme = ColorScheme.dark(); + final Key leadingKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key subtitleKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + Widget buildFrame({required Brightness brightness, required bool selected}) { + final theme = brightness == Brightness.light + ? ThemeData.from(colorScheme: const ColorScheme.light(), useMaterial3: false) + : ThemeData.from(colorScheme: const ColorScheme.dark(), useMaterial3: false); + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: ListTile( + selected: selected, + leading: TestIcon(key: leadingKey), + title: TestIcon(key: titleKey), + subtitle: TestIcon(key: subtitleKey), + trailing: TestIcon(key: trailingKey), + ), + ), + ), + ); + } + + Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; + + await tester.pumpWidget(buildFrame(brightness: Brightness.light, selected: true)); + expect(iconColor(leadingKey), lightColorScheme.primary); + expect(iconColor(titleKey), lightColorScheme.primary); + expect(iconColor(subtitleKey), lightColorScheme.primary); + expect(iconColor(trailingKey), lightColorScheme.primary); + + await tester.pumpWidget(buildFrame(brightness: Brightness.light, selected: false)); + expect(iconColor(leadingKey), Colors.black45); + expect(iconColor(titleKey), Colors.black45); + expect(iconColor(subtitleKey), Colors.black45); + expect(iconColor(trailingKey), Colors.black45); + + await tester.pumpWidget(buildFrame(brightness: Brightness.dark, selected: true)); + await tester.pumpAndSettle(); // Animated theme change + expect(iconColor(leadingKey), darkColorScheme.primary); + expect(iconColor(titleKey), darkColorScheme.primary); + expect(iconColor(subtitleKey), darkColorScheme.primary); + expect(iconColor(trailingKey), darkColorScheme.primary); + + // For this configuration, ListTile defers to the default IconTheme. + // The default dark theme's IconTheme has color:white + await tester.pumpWidget(buildFrame(brightness: Brightness.dark, selected: false)); + expect(iconColor(leadingKey), Colors.white); + expect(iconColor(titleKey), Colors.white); + expect(iconColor(subtitleKey), Colors.white); + expect(iconColor(trailingKey), Colors.white); + }); + + testWidgets('ListTile default tile color', (WidgetTester tester) async { + var isSelected = false; + const Color defaultColor = Colors.transparent; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListTile( + selected: isSelected, + onTap: () { + setState(() => isSelected = !isSelected); + }, + title: const Text('Title'), + ); + }, + ), + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: defaultColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect(find.byType(Material), paints..rect(color: defaultColor)); + }); + + testWidgets('titleAlignment position with title widget', (WidgetTester tester) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const leadingHeight = 24.0; + const titleHeight = 50.0; + const trailingHeight = 24.0; + const minVerticalPadding = 10.0; + const double tileHeight = minVerticalPadding * 2 + titleHeight; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: ListTile( + titleAlignment: titleAlignment, + minVerticalPadding: minVerticalPadding, + leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), + title: const SizedBox(width: 20.0, height: titleHeight), + trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is false, the default title alignment is + // [ListTileTitleAlignment.titleHeight], If the tile height is less than + // 72.0 pixels, the leading is placed 16.0 pixels below the top of + // the title widget and the trailing is centered vertically in the tile. + await tester.pumpWidget(buildFrame()); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile. + const titlePosition = 16.0; + const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); + expect(leadingOffset.dy - tileOffset.dy, titlePosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // If the tile height is less than 72.0 pixels, the leading is placed + // 16.0 pixels below the top of the tile widget, and the trailing is + // centered vertically in the tile. + expect(leadingOffset.dy - tileOffset.dy, titlePosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. + const topPosition = minVerticalPadding; + expect(leadingOffset.dy - tileOffset.dy, topPosition); + expect(trailingOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are vertically centered in the tile. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; + expect(leadingOffset.dy - tileOffset.dy, bottomPosition); + expect(trailingOffset.dy - tileOffset.dy, bottomPosition); + }); + + testWidgets('titleAlignment position with title and subtitle widgets', ( + WidgetTester tester, + ) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const leadingHeight = 24.0; + const titleHeight = 50.0; + const subtitleHeight = 50.0; + const trailingHeight = 24.0; + const minVerticalPadding = 10.0; + const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: ListTile( + titleAlignment: titleAlignment, + minVerticalPadding: minVerticalPadding, + leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), + title: const SizedBox(width: 20.0, height: titleHeight), + subtitle: const SizedBox(width: 20.0, height: subtitleHeight), + trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is false, the default title alignment is + // [ListTileTitleAlignment.titleHeight], which positions the leading and + // trailing widgets 16.0 pixels below the top of the tile widget. + await tester.pumpWidget(buildFrame()); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are positioned 16.0 pixels below the + // top of the tile widget. + const titlePosition = 16.0; + expect(leadingOffset.dy - tileOffset.dy, titlePosition); + expect(trailingOffset.dy - tileOffset.dy, titlePosition); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are vertically centered in the tile, + // If the [ListTile.isThreeLine] property is false. + const double centerPosition = (tileHeight / 2) - (leadingHeight / 2); + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are positioned 16.0 pixels below the + // top of the tile widget. + expect(leadingOffset.dy - tileOffset.dy, titlePosition); + expect(trailingOffset.dy - tileOffset.dy, titlePosition); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the tile widget. + const topPosition = minVerticalPadding; + expect(leadingOffset.dy - tileOffset.dy, topPosition); + expect(trailingOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are vertically centered in the tile. + expect(leadingOffset.dy - tileOffset.dy, centerPosition); + expect(trailingOffset.dy - tileOffset.dy, centerPosition); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + const double bottomPosition = tileHeight - minVerticalPadding - leadingHeight; + expect(leadingOffset.dy - tileOffset.dy, bottomPosition); + expect(trailingOffset.dy - tileOffset.dy, bottomPosition); + }); + + testWidgets("ListTile.isThreeLine updates ListTileTitleAlignment.threeLine's alignment", ( + WidgetTester tester, + ) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const leadingHeight = 24.0; + const titleHeight = 50.0; + const subtitleHeight = 50.0; + const trailingHeight = 24.0; + const minVerticalPadding = 10.0; + const double tileHeight = minVerticalPadding * 2 + titleHeight + subtitleHeight; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment, bool isThreeLine = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: ListTile( + titleAlignment: titleAlignment, + minVerticalPadding: minVerticalPadding, + leading: SizedBox(key: leadingKey, width: 24.0, height: leadingHeight), + title: const SizedBox(width: 20.0, height: titleHeight), + subtitle: const SizedBox(width: 20.0, height: subtitleHeight), + trailing: SizedBox(key: trailingKey, width: 24.0, height: trailingHeight), + isThreeLine: isThreeLine, + ), + ), + ), + ); + } + + // Set title alignment to threeLine. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // If title alignment is threeLine and [ListTile.isThreeLine] is false, + // leading and trailing widgets are centered vertically in the tile. + const double leadingTrailingPosition = (tileHeight / 2) - (leadingHeight / 2); + expect(leadingOffset.dy - tileOffset.dy, leadingTrailingPosition); + expect(trailingOffset.dy - tileOffset.dy, leadingTrailingPosition); + + // Set [ListTile.isThreeLine] to true to update the alignment. + await tester.pumpWidget( + buildFrame(titleAlignment: ListTileTitleAlignment.threeLine, isThreeLine: true), + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + + // The leading and trailing widgets are placed minVerticalPadding + // to the top of the tile widget. + expect(leadingOffset.dy - tileOffset.dy, minVerticalPadding); + expect(trailingOffset.dy - tileOffset.dy, minVerticalPadding); + }); + }); + + // Regression test for https://github.com/flutter/flutter/issues/165453 + testWidgets('ListTile isThreeLine', (WidgetTester tester) async { + const double height = 300; + const avatarTop = 130.0; + const placeholderTop = 138.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: themeDataIsThreeLine != null + ? ThemeData(listTileTheme: ListTileThemeData(isThreeLine: themeDataIsThreeLine)) + : null, + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + ListTile( + isThreeLine: isThreeLine, + leading: const CircleAvatar(), + trailing: const SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + isThreeLine: isThreeLine, + leading: const CircleAvatar(), + trailing: const SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: const Text('A'), + subtitle: const Text('A'), + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, avatarTop, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, placeholderTop, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 24.0, 24.0, 24.0), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 8.0, 24.0, 24.0), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('ListTile statesController', (WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ListTile(statesController: controller, onTap: () {}, title: const Text('title')), + ), + ), + ), + ); + + expect(controller.value, <WidgetState>{}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(Text)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.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: Scaffold( + body: Center( + child: ListTile(statesController: controller, title: const Text('title')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.disabled}); + expect(count, 10); // removes pressed and adds disabled - two changes + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 11); + await gesture.removePointer(); + }); + + testWidgets('ListTile does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: ListTile( + title: Text('title'), + leading: Icon(Icons.add), + trailing: Icon(Icons.remove), + subtitle: Text('subTitle'), + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ListTile)), Size.zero); + }); + + testWidgets('ListTile shows warning when a Container with color wraps it', ( + WidgetTester tester, + ) async { + final errorDetails = <FlutterErrorDetails>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + errorDetails.add(details); + }; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Container( + color: Colors.amber, + height: + 200, // This is to remove the lint on Container. Otherwise, linter suggests to use ColoredBox instead. + child: const ListTile(tileColor: Colors.red, title: Text('ListTile')), + ), + ), + ), + ), + ); + + FlutterError.onError = oldHandler; + + expect(errorDetails, isNotEmpty); + final message = errorDetails.first.toString(); + expect(message, contains('ListTile background color or ink splashes may be invisible')); + expect(message, contains('The ListTile is wrapped in a ColoredBox')); + expect(message, contains('ListTile:')); + expect(message, contains('tileColor:')); + expect(message, contains('ColoredBox:')); + expect(message, contains('color:')); + }); + + testWidgets('ListTile throw exception when wrapped in ColoredBox with non-transparent color', ( + WidgetTester tester, + ) async { + final errorDetails = <FlutterErrorDetails>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + errorDetails.add(details); + }; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: ColoredBox( + color: Colors.amber, + child: ListTile(tileColor: Colors.red, title: Text('ListTile')), + ), + ), + ), + ), + ); + + FlutterError.onError = oldHandler; + + expect(errorDetails, isNotEmpty); + final message = errorDetails.first.toString(); + expect(message, contains('ListTile background color or ink splashes may be invisible')); + expect(message, contains('The ListTile is wrapped in a ColoredBox')); + expect(message, contains('ListTile:')); + expect(message, contains('tileColor:')); + expect(message, contains('ColoredBox:')); + expect(message, contains('color:')); + }); + + testWidgets('ListTile throw exception when wrapped in DecoratedBox with non-transparent color', ( + WidgetTester tester, + ) async { + final errorDetails = <FlutterErrorDetails>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + errorDetails.add(details); + }; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.amber), + child: ListTile(tileColor: Colors.red, title: Text('ListTile')), + ), + ), + ), + ), + ); + + FlutterError.onError = oldHandler; + + expect(errorDetails, isNotEmpty); + final message = errorDetails.first.toString(); + expect(message, contains('ListTile background color or ink splashes may be invisible')); + expect(message, contains('The ListTile is wrapped in a DecoratedBox')); + expect(message, contains('ListTile:')); + expect(message, contains('tileColor:')); + expect(message, contains('DecoratedBox:')); + expect(message, contains('bg')); + }); + + testWidgets('ListTile does not throw exception when parent has no/transparent color', ( + WidgetTester tester, + ) async { + final errorDetails = <FlutterErrorDetails>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + errorDetails.add(details); + }; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: ColoredBox( + color: Colors.transparent, + child: ListTile(tileColor: Colors.red, title: Text('Visible ListTile')), + ), + ), + ), + ), + ); + FlutterError.onError = oldHandler; + + expect(errorDetails, isEmpty); + }); +} + +RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject(find.descendant(of: find.byType(ListTile), matching: find.text(text))); +} diff --git a/packages/material_ui/test/material/list_tile_theme_test.dart b/packages/material_ui/test/material/list_tile_theme_test.dart new file mode 100644 index 000000000000..0c2fcf20fb48 --- /dev/null +++ b/packages/material_ui/test/material/list_tile_theme_test.dart @@ -0,0 +1,1316 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestIcon extends StatefulWidget { + const TestIcon({super.key}); + + @override + TestIconState createState() => TestIconState(); +} + +class TestIconState extends State<TestIcon> { + late IconThemeData iconTheme; + + @override + Widget build(BuildContext context) { + iconTheme = IconTheme.of(context); + return const Icon(Icons.add); + } +} + +class TestText extends StatefulWidget { + const TestText(this.text, {super.key}); + + final String text; + + @override + TestTextState createState() => TestTextState(); +} + +class TestTextState extends State<TestText> { + late TextStyle textStyle; + + @override + Widget build(BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return Text(widget.text); + } +} + +void main() { + test('ListTileThemeData copyWith, ==, hashCode, basics', () { + expect(const ListTileThemeData(), const ListTileThemeData().copyWith()); + expect(const ListTileThemeData().hashCode, const ListTileThemeData().copyWith().hashCode); + }); + + test('ListTileThemeData lerp special cases', () { + expect(ListTileThemeData.lerp(null, null, 0), null); + const data = ListTileThemeData(); + expect(identical(ListTileThemeData.lerp(data, data, 0.5), data), true); + }); + + test('ListTileThemeData defaults', () { + const themeData = ListTileThemeData(); + expect(themeData.dense, null); + expect(themeData.shape, null); + expect(themeData.style, null); + expect(themeData.selectedColor, null); + expect(themeData.iconColor, null); + expect(themeData.textColor, null); + expect(themeData.titleTextStyle, null); + expect(themeData.subtitleTextStyle, null); + expect(themeData.leadingAndTrailingTextStyle, null); + expect(themeData.contentPadding, null); + expect(themeData.tileColor, null); + expect(themeData.selectedTileColor, null); + expect(themeData.horizontalTitleGap, null); + expect(themeData.minVerticalPadding, null); + expect(themeData.minLeadingWidth, null); + expect(themeData.minTileHeight, null); + expect(themeData.enableFeedback, null); + expect(themeData.mouseCursor, null); + expect(themeData.visualDensity, null); + expect(themeData.titleAlignment, null); + expect(themeData.isThreeLine, null); + }); + + testWidgets('Default ListTileThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ListTileThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ListTileThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ListTileThemeData( + dense: true, + shape: StadiumBorder(), + style: ListTileStyle.drawer, + selectedColor: Color(0x00000001), + iconColor: Color(0x00000002), + textColor: Color(0x00000003), + titleTextStyle: TextStyle(color: Color(0x00000004)), + subtitleTextStyle: TextStyle(color: Color(0x00000005)), + leadingAndTrailingTextStyle: TextStyle(color: Color(0x00000006)), + contentPadding: EdgeInsets.all(100), + tileColor: Color(0x00000007), + selectedTileColor: Color(0x00000008), + horizontalTitleGap: 200, + minVerticalPadding: 300, + minLeadingWidth: 400, + minTileHeight: 30, + enableFeedback: true, + mouseCursor: WidgetStateMouseCursor.clickable, + visualDensity: VisualDensity.comfortable, + titleAlignment: ListTileTitleAlignment.top, + isThreeLine: true, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'dense: true', + 'shape: StadiumBorder(BorderSide(width: 0.0, style: none))', + 'style: drawer', + 'selectedColor: ${const Color(0x00000001)}', + 'iconColor: ${const Color(0x00000002)}', + 'textColor: ${const Color(0x00000003)}', + 'titleTextStyle: TextStyle(inherit: true, color: ${const Color(0x00000004)})', + 'subtitleTextStyle: TextStyle(inherit: true, color: ${const Color(0x00000005)})', + 'leadingAndTrailingTextStyle: TextStyle(inherit: true, color: ${const Color(0x00000006)})', + 'contentPadding: EdgeInsets.all(100.0)', + 'tileColor: ${const Color(0x00000007)}', + 'selectedTileColor: ${const Color(0x00000008)}', + 'horizontalTitleGap: 200.0', + 'minVerticalPadding: 300.0', + 'minLeadingWidth: 400.0', + 'minTileHeight: 30.0', + 'enableFeedback: true', + 'mouseCursor: WidgetStateMouseCursor(clickable)', + 'visualDensity: VisualDensity#00000(h: -1.0, v: -1.0)(horizontal: -1.0, vertical: -1.0)', + 'titleAlignment: ListTileTitleAlignment.top', + 'isThreeLine: true', + ]), + ); + }); + + testWidgets('ListTileTheme backwards compatibility constructor', (WidgetTester tester) async { + late ListTileThemeData theme; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTileTheme( + dense: true, + shape: const StadiumBorder(), + style: ListTileStyle.drawer, + selectedColor: const Color(0x00000001), + iconColor: const Color(0x00000002), + textColor: const Color(0x00000003), + contentPadding: const EdgeInsets.all(100), + tileColor: const Color(0x00000004), + selectedTileColor: const Color(0x00000005), + horizontalTitleGap: 200, + minVerticalPadding: 300, + minLeadingWidth: 400, + enableFeedback: true, + mouseCursor: WidgetStateMouseCursor.clickable, + child: Center( + child: Builder( + builder: (BuildContext context) { + theme = ListTileTheme.of(context); + return const Placeholder(); + }, + ), + ), + ), + ), + ), + ); + + expect(theme.dense, true); + expect(theme.shape, const StadiumBorder()); + expect(theme.style, ListTileStyle.drawer); + expect(theme.selectedColor, const Color(0x00000001)); + expect(theme.iconColor, const Color(0x00000002)); + expect(theme.textColor, const Color(0x00000003)); + expect(theme.contentPadding, const EdgeInsets.all(100)); + expect(theme.tileColor, const Color(0x00000004)); + expect(theme.selectedTileColor, const Color(0x00000005)); + expect(theme.horizontalTitleGap, 200); + expect(theme.minVerticalPadding, 300); + expect(theme.minLeadingWidth, 400); + expect(theme.enableFeedback, true); + expect(theme.mouseCursor, WidgetStateMouseCursor.clickable); + }); + + testWidgets('ListTileTheme', (WidgetTester tester) async { + final Key listTileKey = UniqueKey(); + final Key titleKey = UniqueKey(); + final Key subtitleKey = UniqueKey(); + final Key leadingKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + late ThemeData theme; + + Widget buildFrame({ + bool enabled = true, + bool dense = false, + bool selected = false, + ShapeBorder? shape, + Color? selectedColor, + Color? iconColor, + Color? textColor, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: ListTileTheme( + data: ListTileThemeData( + dense: dense, + shape: shape, + selectedColor: selectedColor, + iconColor: iconColor, + textColor: textColor, + minVerticalPadding: 25.0, + mouseCursor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.forbidden; + } + + return SystemMouseCursors.click; + }), + visualDensity: VisualDensity.compact, + titleAlignment: ListTileTitleAlignment.bottom, + ), + child: Builder( + builder: (BuildContext context) { + theme = Theme.of(context); + return ListTile( + key: listTileKey, + enabled: enabled, + selected: selected, + leading: TestIcon(key: leadingKey), + trailing: TestIcon(key: trailingKey), + title: TestText('title', key: titleKey), + subtitle: TestText('subtitle', key: subtitleKey), + ); + }, + ), + ), + ), + ), + ); + } + + const green = Color(0xFF00FF00); + const red = Color(0xFFFF0000); + const ShapeBorder roundedShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ); + + Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; + Color textColor(Key key) => tester.state<TestTextState>(find.byKey(key)).textStyle.color!; + ShapeBorder inkWellBorder() => tester + .widget<InkWell>(find.descendant(of: find.byType(ListTile), matching: find.byType(InkWell))) + .customBorder!; + + // A selected ListTile's leading, trailing, and text get the primary color by default + await tester.pumpWidget(buildFrame(selected: true)); + await tester.pump(const Duration(milliseconds: 300)); // DefaultTextStyle changes animate + expect(iconColor(leadingKey), theme.primaryColor); + expect(iconColor(trailingKey), theme.primaryColor); + expect(textColor(titleKey), theme.primaryColor); + expect(textColor(subtitleKey), theme.primaryColor); + + // A selected ListTile's leading, trailing, and text get the ListTileTheme's selectedColor + await tester.pumpWidget(buildFrame(selected: true, selectedColor: green)); + await tester.pump(const Duration(milliseconds: 300)); // DefaultTextStyle changes animate + expect(iconColor(leadingKey), green); + expect(iconColor(trailingKey), green); + expect(textColor(titleKey), green); + expect(textColor(subtitleKey), green); + + // An unselected ListTile's leading and trailing get the ListTileTheme's iconColor + // An unselected ListTile's title texts get the ListTileTheme's textColor + await tester.pumpWidget(buildFrame(iconColor: red, textColor: green)); + await tester.pump(const Duration(milliseconds: 300)); // DefaultTextStyle changes animate + expect(iconColor(leadingKey), red); + expect(iconColor(trailingKey), red); + expect(textColor(titleKey), green); + expect(textColor(subtitleKey), green); + + // If the item is disabled it's rendered with the theme's disabled color. + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pump(const Duration(milliseconds: 300)); // DefaultTextStyle changes animate + expect(iconColor(leadingKey), theme.disabledColor); + expect(iconColor(trailingKey), theme.disabledColor); + expect(textColor(titleKey), theme.disabledColor); + expect(textColor(subtitleKey), theme.disabledColor); + + // If the item is disabled it's rendered with the theme's disabled color. + // Even if it's selected. + await tester.pumpWidget(buildFrame(enabled: false, selected: true)); + await tester.pump(const Duration(milliseconds: 300)); // DefaultTextStyle changes animate + expect(iconColor(leadingKey), theme.disabledColor); + expect(iconColor(trailingKey), theme.disabledColor); + expect(textColor(titleKey), theme.disabledColor); + expect(textColor(subtitleKey), theme.disabledColor); + + // A selected ListTile's InkWell gets the ListTileTheme's shape + await tester.pumpWidget(buildFrame(selected: true, shape: roundedShape)); + expect(inkWellBorder(), roundedShape); + + // Cursor updates when hovering disabled ListTile + await tester.pumpWidget(buildFrame(enabled: false)); + final Offset listTile = tester.getCenter(find.byKey(titleKey)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(listTile); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + + // VisualDensity is respected + final RenderBox box = tester.renderObject(find.byKey(listTileKey)); + expect(box.size, equals(const Size(800, 80.0))); + + // titleAlignment is respected. + final Offset titleOffset = tester.getTopLeft(find.text('title')); + final Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + final Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + expect(leadingOffset.dy - titleOffset.dy, 6); + expect(trailingOffset.dy - titleOffset.dy, 6); + }); + + testWidgets('ListTileTheme colors are applied to leading and trailing text widgets', ( + WidgetTester tester, + ) async { + final Key leadingKey = UniqueKey(); + final Key trailingKey = UniqueKey(); + + const Color selectedColor = Colors.orange; + const Color defaultColor = Colors.black; + + late ThemeData theme; + Widget buildFrame({bool enabled = true, bool selected = false}) { + return MaterialApp( + home: Material( + child: Center( + child: ListTileTheme( + data: const ListTileThemeData(selectedColor: selectedColor, textColor: defaultColor), + child: Builder( + builder: (BuildContext context) { + theme = Theme.of(context); + return ListTile( + enabled: enabled, + selected: selected, + leading: TestText('leading', key: leadingKey), + title: const TestText('title'), + trailing: TestText('trailing', key: trailingKey), + ); + }, + ), + ), + ), + ), + ); + } + + Color textColor(Key key) => tester.state<TestTextState>(find.byKey(key)).textStyle.color!; + + await tester.pumpWidget(buildFrame()); + // Enabled color should use ListTileTheme.textColor. + expect(textColor(leadingKey), defaultColor); + expect(textColor(trailingKey), defaultColor); + + await tester.pumpWidget(buildFrame(selected: true)); + // Wait for text color to animate. + await tester.pumpAndSettle(); + // Selected color should use ListTileTheme.selectedColor. + expect(textColor(leadingKey), selectedColor); + expect(textColor(trailingKey), selectedColor); + + await tester.pumpWidget(buildFrame(enabled: false)); + // Wait for text color to animate. + await tester.pumpAndSettle(); + // Disabled color should be ThemeData.disabledColor. + expect(textColor(leadingKey), theme.disabledColor); + expect(textColor(trailingKey), theme.disabledColor); + }); + + testWidgets( + "Material3 - ListTile respects ListTileTheme's titleTextStyle, subtitleTextStyle & leadingAndTrailingTextStyle", + (WidgetTester tester) async { + const titleTextStyle = TextStyle( + fontSize: 23.0, + color: Color(0xffff0000), + fontStyle: FontStyle.italic, + ); + const subtitleTextStyle = TextStyle( + fontSize: 20.0, + color: Color(0xff00ff00), + fontStyle: FontStyle.italic, + ); + const leadingAndTrailingTextStyle = TextStyle( + fontSize: 18.0, + color: Color(0xff0000ff), + fontStyle: FontStyle.italic, + ); + + final theme = ThemeData( + listTileTheme: const ListTileThemeData( + titleTextStyle: titleTextStyle, + subtitleTextStyle: subtitleTextStyle, + leadingAndTrailingTextStyle: leadingAndTrailingTextStyle, + ), + ); + + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(leading.text.style!.color, leadingAndTrailingTextStyle.color); + expect(leading.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, titleTextStyle.fontSize); + expect(title.text.style!.color, titleTextStyle.color); + expect(title.text.style!.fontStyle, titleTextStyle.fontStyle); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, subtitleTextStyle.fontSize); + expect(subtitle.text.style!.color, subtitleTextStyle.color); + expect(subtitle.text.style!.fontStyle, subtitleTextStyle.fontStyle); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(trailing.text.style!.color, leadingAndTrailingTextStyle.color); + expect(trailing.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + }, + ); + + testWidgets( + "Material2 - ListTile respects ListTileTheme's titleTextStyle, subtitleTextStyle & leadingAndTrailingTextStyle", + (WidgetTester tester) async { + const titleTextStyle = TextStyle( + fontSize: 23.0, + color: Color(0xffff0000), + fontStyle: FontStyle.italic, + ); + const subtitleTextStyle = TextStyle( + fontSize: 20.0, + color: Color(0xff00ff00), + fontStyle: FontStyle.italic, + ); + const leadingAndTrailingTextStyle = TextStyle( + fontSize: 18.0, + color: Color(0xff0000ff), + fontStyle: FontStyle.italic, + ); + + final theme = ThemeData( + useMaterial3: false, + listTileTheme: const ListTileThemeData( + titleTextStyle: titleTextStyle, + subtitleTextStyle: subtitleTextStyle, + leadingAndTrailingTextStyle: leadingAndTrailingTextStyle, + ), + ); + + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(leading.text.style!.color, leadingAndTrailingTextStyle.color); + expect(leading.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, titleTextStyle.fontSize); + expect(title.text.style!.color, titleTextStyle.color); + expect(title.text.style!.fontStyle, titleTextStyle.fontStyle); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, subtitleTextStyle.fontSize); + expect(subtitle.text.style!.color, subtitleTextStyle.color); + expect(subtitle.text.style!.fontStyle, subtitleTextStyle.fontStyle); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(trailing.text.style!.color, leadingAndTrailingTextStyle.color); + expect(trailing.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + }, + ); + + testWidgets( + "Material3 - ListTile's titleTextStyle, subtitleTextStyle & leadingAndTrailingTextStyle are overridden by ListTile properties", + (WidgetTester tester) async { + final theme = ThemeData( + listTileTheme: const ListTileThemeData( + titleTextStyle: TextStyle(fontSize: 20.0), + subtitleTextStyle: TextStyle(fontSize: 17.5), + leadingAndTrailingTextStyle: TextStyle(fontSize: 15.0), + ), + ); + const titleTextStyle = TextStyle( + fontSize: 23.0, + color: Color(0xffff0000), + fontStyle: FontStyle.italic, + ); + const subtitleTextStyle = TextStyle( + fontSize: 20.0, + color: Color(0xff00ff00), + fontStyle: FontStyle.italic, + ); + const leadingAndTrailingTextStyle = TextStyle( + fontSize: 18.0, + color: Color(0xff0000ff), + fontStyle: FontStyle.italic, + ); + + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + titleTextStyle: titleTextStyle, + subtitleTextStyle: subtitleTextStyle, + leadingAndTrailingTextStyle: leadingAndTrailingTextStyle, + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(leading.text.style!.color, leadingAndTrailingTextStyle.color); + expect(leading.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, titleTextStyle.fontSize); + expect(title.text.style!.color, titleTextStyle.color); + expect(title.text.style!.fontStyle, titleTextStyle.fontStyle); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, subtitleTextStyle.fontSize); + expect(subtitle.text.style!.color, subtitleTextStyle.color); + expect(subtitle.text.style!.fontStyle, subtitleTextStyle.fontStyle); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(trailing.text.style!.color, leadingAndTrailingTextStyle.color); + expect(trailing.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + }, + ); + + testWidgets( + "Material2 - ListTile's titleTextStyle, subtitleTextStyle & leadingAndTrailingTextStyle are overridden by ListTile properties", + (WidgetTester tester) async { + final theme = ThemeData( + useMaterial3: false, + listTileTheme: const ListTileThemeData( + titleTextStyle: TextStyle(fontSize: 20.0), + subtitleTextStyle: TextStyle(fontSize: 17.5), + leadingAndTrailingTextStyle: TextStyle(fontSize: 15.0), + ), + ); + const titleTextStyle = TextStyle( + fontSize: 23.0, + color: Color(0xffff0000), + fontStyle: FontStyle.italic, + ); + const subtitleTextStyle = TextStyle( + fontSize: 20.0, + color: Color(0xff00ff00), + fontStyle: FontStyle.italic, + ); + const leadingAndTrailingTextStyle = TextStyle( + fontSize: 18.0, + color: Color(0xff0000ff), + fontStyle: FontStyle.italic, + ); + + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return const ListTile( + titleTextStyle: titleTextStyle, + subtitleTextStyle: subtitleTextStyle, + leadingAndTrailingTextStyle: leadingAndTrailingTextStyle, + leading: TestText('leading'), + title: TestText('title'), + subtitle: TestText('subtitle'), + trailing: TestText('trailing'), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderParagraph leading = _getTextRenderObject(tester, 'leading'); + expect(leading.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(leading.text.style!.color, leadingAndTrailingTextStyle.color); + expect(leading.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + final RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.fontSize, titleTextStyle.fontSize); + expect(title.text.style!.color, titleTextStyle.color); + expect(title.text.style!.fontStyle, titleTextStyle.fontStyle); + final RenderParagraph subtitle = _getTextRenderObject(tester, 'subtitle'); + expect(subtitle.text.style!.fontSize, subtitleTextStyle.fontSize); + expect(subtitle.text.style!.color, subtitleTextStyle.color); + expect(subtitle.text.style!.fontStyle, subtitleTextStyle.fontStyle); + final RenderParagraph trailing = _getTextRenderObject(tester, 'trailing'); + expect(trailing.text.style!.fontSize, leadingAndTrailingTextStyle.fontSize); + expect(trailing.text.style!.color, leadingAndTrailingTextStyle.color); + expect(trailing.text.style!.fontStyle, leadingAndTrailingTextStyle.fontStyle); + }, + ); + + testWidgets("ListTile respects ListTileTheme's tileColor & selectedTileColor", ( + WidgetTester tester, + ) async { + late ListTileThemeData theme; + var isSelected = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTileTheme( + data: ListTileThemeData( + tileColor: Colors.green.shade500, + selectedTileColor: Colors.red.shade500, + ), + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + theme = ListTileTheme.of(context); + return ListTile( + selected: isSelected, + onTap: () { + setState(() => isSelected = !isSelected); + }, + title: const Text('Title'), + ); + }, + ), + ), + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: theme.tileColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect(find.byType(Material), paints..rect(color: theme.selectedTileColor)); + }); + + testWidgets( + "ListTileTheme's tileColor & selectedTileColor are overridden by ListTile properties", + (WidgetTester tester) async { + var isSelected = false; + final Color tileColor = Colors.green.shade500; + final Color selectedTileColor = Colors.red.shade500; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListTileTheme( + data: const ListTileThemeData(selectedTileColor: Colors.green, tileColor: Colors.red), + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListTile( + tileColor: tileColor, + selectedTileColor: selectedTileColor, + selected: isSelected, + onTap: () { + setState(() => isSelected = !isSelected); + }, + title: const Text('Title'), + ); + }, + ), + ), + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: tileColor)); + + // Tap on tile to change isSelected. + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + expect(find.byType(Material), paints..rect(color: selectedTileColor)); + }, + ); + + testWidgets('ListTile uses ListTileTheme shape in a drawer', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/106303 + + final scaffoldKey = GlobalKey<ScaffoldState>(); + const ShapeBorder shapeBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20.0)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(listTileTheme: const ListTileThemeData(shape: shapeBorder)), + home: Scaffold( + key: scaffoldKey, + drawer: const Drawer(child: ListTile()), + body: Container(), + ), + ), + ); + await tester.pumpAndSettle(); + + scaffoldKey.currentState!.openDrawer(); + // Start drawer animation. + await tester.pump(); + + final ShapeBorder? inkWellBorder = tester + .widget<InkWell>(find.descendant(of: find.byType(ListTile), matching: find.byType(InkWell))) + .customBorder; + // Test shape. + expect(inkWellBorder, shapeBorder); + }); + + testWidgets('ListTile respects WidgetStateColor LisTileTheme.textColor', ( + WidgetTester tester, + ) async { + var enabled = false; + var selected = false; + const Color defaultColor = Colors.blue; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.red; + + final theme = ThemeData( + listTileTheme: ListTileThemeData( + textColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + }), + ), + ); + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + enabled: enabled, + selected: selected, + title: const TestText('title'), + subtitle: const TestText('subtitle'), + ); + }, + ), + ), + ), + ); + } + + // Test disabled state. + await tester.pumpWidget(buildFrame()); + RenderParagraph title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, disabledColor); + + // Test enabled state. + enabled = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, defaultColor); + + // Test selected state. + selected = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + title = _getTextRenderObject(tester, 'title'); + expect(title.text.style!.color, selectedColor); + }); + + testWidgets('ListTile respects WidgetStateColor LisTileTheme.iconColor', ( + WidgetTester tester, + ) async { + var enabled = false; + var selected = false; + const Color defaultColor = Colors.blue; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.red; + final Key leadingKey = UniqueKey(); + + final theme = ThemeData( + listTileTheme: ListTileThemeData( + iconColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return defaultColor; + }), + ), + ); + Widget buildFrame() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTile( + enabled: enabled, + selected: selected, + leading: TestIcon(key: leadingKey), + ); + }, + ), + ), + ), + ); + } + + Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!; + + // Test disabled state. + await tester.pumpWidget(buildFrame()); + expect(iconColor(leadingKey), disabledColor); + + // Test enabled state. + enabled = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect(iconColor(leadingKey), defaultColor); + + // Test selected state. + selected = true; + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect(iconColor(leadingKey), selectedColor); + }); + + testWidgets('ListTileThemeData copyWith overrides all properties', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/119734 + + const original = ListTileThemeData( + dense: true, + shape: StadiumBorder(), + style: ListTileStyle.drawer, + selectedColor: Color(0x00000001), + iconColor: Color(0x00000002), + textColor: Color(0x00000003), + titleTextStyle: TextStyle(color: Color(0x00000004)), + subtitleTextStyle: TextStyle(color: Color(0x00000005)), + leadingAndTrailingTextStyle: TextStyle(color: Color(0x00000006)), + contentPadding: EdgeInsets.all(100), + tileColor: Color(0x00000007), + selectedTileColor: Color(0x00000008), + horizontalTitleGap: 200, + minVerticalPadding: 300, + minLeadingWidth: 400, + minTileHeight: 30, + enableFeedback: true, + titleAlignment: ListTileTitleAlignment.bottom, + isThreeLine: true, + ); + + final ListTileThemeData copy = original.copyWith( + dense: false, + shape: const RoundedRectangleBorder(), + style: ListTileStyle.list, + selectedColor: const Color(0x00000009), + iconColor: const Color(0x0000000A), + textColor: const Color(0x0000000B), + titleTextStyle: const TextStyle(color: Color(0x0000000C)), + subtitleTextStyle: const TextStyle(color: Color(0x0000000D)), + leadingAndTrailingTextStyle: const TextStyle(color: Color(0x0000000E)), + contentPadding: const EdgeInsets.all(500), + tileColor: const Color(0x0000000F), + selectedTileColor: const Color(0x00000010), + horizontalTitleGap: 600, + minVerticalPadding: 700, + minLeadingWidth: 800, + minTileHeight: 80, + enableFeedback: false, + titleAlignment: ListTileTitleAlignment.top, + isThreeLine: false, + ); + + expect(copy.dense, false); + expect(copy.shape, const RoundedRectangleBorder()); + expect(copy.style, ListTileStyle.list); + expect(copy.selectedColor, const Color(0x00000009)); + expect(copy.iconColor, const Color(0x0000000A)); + expect(copy.textColor, const Color(0x0000000B)); + expect(copy.titleTextStyle, const TextStyle(color: Color(0x0000000C))); + expect(copy.subtitleTextStyle, const TextStyle(color: Color(0x0000000D))); + expect(copy.leadingAndTrailingTextStyle, const TextStyle(color: Color(0x0000000E))); + expect(copy.contentPadding, const EdgeInsets.all(500)); + expect(copy.tileColor, const Color(0x0000000F)); + expect(copy.selectedTileColor, const Color(0x00000010)); + expect(copy.horizontalTitleGap, 600); + expect(copy.minVerticalPadding, 700); + expect(copy.minLeadingWidth, 800); + expect(copy.minTileHeight, 80); + expect(copy.enableFeedback, false); + expect(copy.titleAlignment, ListTileTitleAlignment.top); + expect(copy.isThreeLine, false); + }); + + testWidgets('ListTileTheme.titleAlignment is overridden by ListTile.titleAlignment', ( + WidgetTester tester, + ) async { + final Key leadingKey = GlobalKey(); + final Key trailingKey = GlobalKey(); + const titleText = '\nHeadline Text\n'; + const subtitleText = '\nSupporting Text\n'; + + Widget buildFrame({ListTileTitleAlignment? alignment}) { + return MaterialApp( + theme: ThemeData( + listTileTheme: const ListTileThemeData(titleAlignment: ListTileTitleAlignment.center), + ), + home: Material( + child: Center( + child: ListTile( + titleAlignment: ListTileTitleAlignment.top, + leading: SizedBox(key: leadingKey, width: 24.0, height: 24.0), + title: const Text(titleText), + subtitle: const Text(subtitleText), + trailing: SizedBox(key: trailingKey, width: 24.0, height: 24.0), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + final Offset leadingOffset = tester.getTopLeft(find.byKey(leadingKey)); + final Offset trailingOffset = tester.getTopRight(find.byKey(trailingKey)); + expect(leadingOffset.dy - tileOffset.dy, 8.0); + expect(trailingOffset.dy - tileOffset.dy, 8.0); + }); + + testWidgets('ListTileTheme.merge supports all properties', (WidgetTester tester) async { + Widget buildFrame() { + return MaterialApp( + theme: ThemeData( + listTileTheme: const ListTileThemeData( + dense: true, + shape: StadiumBorder(), + style: ListTileStyle.drawer, + selectedColor: Color(0x00000001), + iconColor: Color(0x00000002), + textColor: Color(0x00000003), + titleTextStyle: TextStyle(color: Color(0x00000004)), + subtitleTextStyle: TextStyle(color: Color(0x00000005)), + leadingAndTrailingTextStyle: TextStyle(color: Color(0x00000006)), + contentPadding: EdgeInsets.all(100), + tileColor: Color(0x00000007), + selectedTileColor: Color(0x00000008), + horizontalTitleGap: 200, + minVerticalPadding: 300, + minLeadingWidth: 400, + minTileHeight: 30, + enableFeedback: true, + titleAlignment: ListTileTitleAlignment.bottom, + mouseCursor: WidgetStateMouseCursor.textable, + visualDensity: VisualDensity.comfortable, + isThreeLine: true, + ), + ), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ListTileTheme.merge( + dense: false, + shape: const RoundedRectangleBorder(), + style: ListTileStyle.list, + selectedColor: const Color(0x00000009), + iconColor: const Color(0x0000000A), + textColor: const Color(0x0000000B), + titleTextStyle: const TextStyle(color: Color(0x0000000C)), + subtitleTextStyle: const TextStyle(color: Color(0x0000000D)), + leadingAndTrailingTextStyle: const TextStyle(color: Color(0x0000000E)), + contentPadding: const EdgeInsets.all(500), + tileColor: const Color(0x0000000F), + selectedTileColor: const Color(0x00000010), + horizontalTitleGap: 600, + minVerticalPadding: 700, + minLeadingWidth: 800, + minTileHeight: 80, + enableFeedback: false, + titleAlignment: ListTileTitleAlignment.top, + mouseCursor: WidgetStateMouseCursor.clickable, + visualDensity: VisualDensity.compact, + isThreeLine: false, + child: const ListTile(), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final ListTileThemeData theme = ListTileTheme.of(tester.element(find.byType(ListTile))); + expect(theme.dense, false); + expect(theme.shape, const RoundedRectangleBorder()); + expect(theme.style, ListTileStyle.list); + expect(theme.selectedColor, const Color(0x00000009)); + expect(theme.iconColor, const Color(0x0000000A)); + expect(theme.textColor, const Color(0x0000000B)); + expect(theme.titleTextStyle, const TextStyle(color: Color(0x0000000C))); + expect(theme.subtitleTextStyle, const TextStyle(color: Color(0x0000000D))); + expect(theme.leadingAndTrailingTextStyle, const TextStyle(color: Color(0x0000000E))); + expect(theme.contentPadding, const EdgeInsets.all(500)); + expect(theme.tileColor, const Color(0x0000000F)); + expect(theme.selectedTileColor, const Color(0x00000010)); + expect(theme.horizontalTitleGap, 600); + expect(theme.minVerticalPadding, 700); + expect(theme.minLeadingWidth, 800); + expect(theme.minTileHeight, 80); + expect(theme.enableFeedback, false); + expect(theme.titleAlignment, ListTileTitleAlignment.top); + expect(theme.mouseCursor, WidgetStateMouseCursor.clickable); + expect(theme.visualDensity, VisualDensity.compact); + expect(theme.isThreeLine, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/165453 + testWidgets('ListTileThemeData isThreeLine', (WidgetTester tester) async { + const double height = 300; + const avatarTop = 130.0; + const placeholderTop = 138.0; + + Widget buildFrame({bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: isThreeLine != null + ? ThemeData(listTileTheme: ListTileThemeData(isThreeLine: isThreeLine)) + : null, + home: Material( + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, avatarTop, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, placeholderTop, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 24.0, 24.0, 24.0), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 8.0, 24.0, 24.0), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/165453 + testWidgets('ListTileTheme isThreeLine', (WidgetTester tester) async { + const double height = 300; + const avatarTop = 130.0; + const placeholderTop = 138.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(listTileTheme: const ListTileThemeData(isThreeLine: true)), + home: Material( + child: ListTileTheme( + data: const ListTileThemeData(isThreeLine: false), + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, avatarTop, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, placeholderTop, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 16.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 24.0, 24.0, 24.0), + ); + + // THREE-LINE + await tester.pumpWidget( + MaterialApp( + key: UniqueKey(), + home: Material( + child: ListTileTheme( + data: const ListTileThemeData(isThreeLine: true), + child: ListView( + children: const <Widget>[ + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + ), + ListTile( + leading: CircleAvatar(), + trailing: SizedBox(height: 24.0, width: 24.0, child: Placeholder()), + title: Text('A'), + subtitle: Text('A'), + ), + ], + ), + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(ListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(0)), + const Rect.fromLTWH(16.0, 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(0)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, 8.0, 24.0, 24.0), + ); + expect( + tester.getRect(find.byType(ListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(CircleAvatar).at(1)), + const Rect.fromLTWH(16.0, height + 8.0, 40.0, 40.0), + ); + expect( + tester.getRect(find.byType(Placeholder).at(1)), + const Rect.fromLTWH(800.0 - 24.0 - 24.0, height + 8.0, 24.0, 24.0), + ); + }); +} + +RenderParagraph _getTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject(find.descendant(of: find.byType(ListTile), matching: find.text(text))); +} diff --git a/packages/material_ui/test/material/live_text_utils.dart b/packages/material_ui/test/material/live_text_utils.dart new file mode 100644 index 000000000000..1e19a41ba76b --- /dev/null +++ b/packages/material_ui/test/material/live_text_utils.dart @@ -0,0 +1,78 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A mock class to control the return result of Live Text input functions. +class LiveTextInputTester { + /// Creates a [LiveTextInputTester] and installs a mock handler on + /// [SystemChannels.platform]. + LiveTextInputTester() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + _handler, + ); + } + + /// Whether the mock Live Text input is enabled. + bool mockLiveTextInputEnabled = false; + + Future<Object?> _handler(MethodCall methodCall) async { + // Need to set Clipboard.hasStrings method handler because when showing the tool bar, + // the Clipboard.hasStrings will also be invoked. If this isn't handled, + // an exception will be thrown. + if (methodCall.method == 'Clipboard.hasStrings') { + return <String, bool>{'value': true}; + } + if (methodCall.method == 'LiveText.isLiveTextInputAvailable') { + return mockLiveTextInputEnabled; + } + return false; + } + + /// Removes the mock handler from [SystemChannels.platform]. + void dispose() { + assert( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.checkMockMessageHandler( + SystemChannels.platform.name, + _handler, + ), + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + } +} + +/// A function to find the live text button. +/// +/// LiveText button is displayed either using a custom painter, +/// a Text with an empty label, or a Text with the 'Scan text' label. +Finder findLiveTextButton() { + final bool isMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.fuchsia || + defaultTargetPlatform == TargetPlatform.iOS; + if (isMobile) { + return find.byWidgetPredicate((Widget widget) { + return (widget is CustomPaint && + '${widget.painter?.runtimeType}' == '_LiveTextIconPainter') || + (widget is Text && + widget.data == 'Scan text'); // Android and Fuchsia when inside a MaterialApp. + }); + } + if (defaultTargetPlatform == TargetPlatform.macOS) { + return find.ancestor( + of: find.text(''), + matching: find.byType(CupertinoDesktopTextSelectionToolbarButton), + ); + } + return find.byWidgetPredicate((Widget widget) { + return widget is Text && (widget.data == '' || widget.data == 'Scan text'); + }); +} diff --git a/packages/material_ui/test/material/localizations_test.dart b/packages/material_ui/test/material/localizations_test.dart new file mode 100644 index 000000000000..caeecd4a92e0 --- /dev/null +++ b/packages/material_ui/test/material/localizations_test.dart @@ -0,0 +1,227 @@ +// 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('English translations exist for all MaterialLocalizations properties', ( + WidgetTester tester, + ) async { + const MaterialLocalizations localizations = DefaultMaterialLocalizations(); + + expect(localizations.openAppDrawerTooltip, isNotNull); + expect(localizations.backButtonTooltip, isNotNull); + expect(localizations.clearButtonTooltip, isNotNull); + expect(localizations.closeButtonTooltip, isNotNull); + expect(localizations.deleteButtonTooltip, isNotNull); + expect(localizations.moreButtonTooltip, isNotNull); + expect(localizations.nextMonthTooltip, isNotNull); + expect(localizations.previousMonthTooltip, isNotNull); + expect(localizations.nextPageTooltip, isNotNull); + expect(localizations.previousPageTooltip, isNotNull); + expect(localizations.firstPageTooltip, isNotNull); + expect(localizations.lastPageTooltip, isNotNull); + expect(localizations.showMenuTooltip, isNotNull); + expect(localizations.licensesPageTitle, isNotNull); + expect(localizations.rowsPerPageTitle, isNotNull); + expect(localizations.cancelButtonLabel, isNotNull); + expect(localizations.closeButtonLabel, isNotNull); + expect(localizations.continueButtonLabel, isNotNull); + expect(localizations.copyButtonLabel, isNotNull); + expect(localizations.cutButtonLabel, isNotNull); + expect(localizations.scanTextButtonLabel, isNotNull); + expect(localizations.lookUpButtonLabel, isNotNull); + expect(localizations.searchWebButtonLabel, isNotNull); + expect(localizations.shareButtonLabel, isNotNull); + expect(localizations.okButtonLabel, isNotNull); + expect(localizations.pasteButtonLabel, isNotNull); + expect(localizations.selectAllButtonLabel, isNotNull); + expect(localizations.viewLicensesButtonLabel, isNotNull); + expect(localizations.anteMeridiemAbbreviation, isNotNull); + expect(localizations.postMeridiemAbbreviation, isNotNull); + expect(localizations.timePickerHourModeAnnouncement, isNotNull); + expect(localizations.timePickerMinuteModeAnnouncement, isNotNull); + expect(localizations.modalBarrierDismissLabel, isNotNull); + expect(localizations.menuDismissLabel, isNotNull); + expect(localizations.drawerLabel, isNotNull); + expect(localizations.menuBarMenuLabel, isNotNull); + expect(localizations.popupMenuLabel, isNotNull); + expect(localizations.dialogLabel, isNotNull); + expect(localizations.alertDialogLabel, isNotNull); + expect(localizations.searchFieldLabel, isNotNull); + expect(localizations.dateSeparator, isNotNull); + expect(localizations.dateHelpText, isNotNull); + expect(localizations.selectYearSemanticsLabel, isNotNull); + expect(localizations.unspecifiedDate, isNotNull); + expect(localizations.unspecifiedDateRange, isNotNull); + expect(localizations.dateInputLabel, isNotNull); + expect(localizations.dateRangeStartLabel, isNotNull); + expect(localizations.dateRangeEndLabel, isNotNull); + expect(localizations.invalidDateFormatLabel, isNotNull); + expect(localizations.invalidDateRangeLabel, isNotNull); + expect(localizations.dateOutOfRangeLabel, isNotNull); + expect(localizations.saveButtonLabel, isNotNull); + expect(localizations.datePickerHelpText, isNotNull); + expect(localizations.dateRangePickerHelpText, isNotNull); + expect(localizations.calendarModeButtonLabel, isNotNull); + expect(localizations.inputDateModeButtonLabel, isNotNull); + expect(localizations.timePickerDialHelpText, isNotNull); + expect(localizations.timePickerInputHelpText, isNotNull); + expect(localizations.timePickerHourLabel, isNotNull); + expect(localizations.timePickerMinuteLabel, isNotNull); + expect(localizations.invalidTimeLabel, isNotNull); + expect(localizations.dialModeButtonLabel, isNotNull); + expect(localizations.inputTimeModeButtonLabel, isNotNull); + expect(localizations.signedInLabel, isNotNull); + expect(localizations.hideAccountsLabel, isNotNull); + expect(localizations.showAccountsLabel, isNotNull); + expect(localizations.reorderItemToStart, isNotNull); + expect(localizations.reorderItemToEnd, isNotNull); + expect(localizations.reorderItemUp, isNotNull); + expect(localizations.reorderItemDown, isNotNull); + expect(localizations.reorderItemLeft, isNotNull); + expect(localizations.reorderItemRight, isNotNull); + expect(localizations.expandedIconTapHint, isNotNull); + expect(localizations.collapsedIconTapHint, isNotNull); + expect(localizations.expansionTileExpandedHint, isNotNull); + expect(localizations.expansionTileCollapsedHint, isNotNull); + expect(localizations.expansionTileExpandedTapHint, isNotNull); + expect(localizations.expansionTileCollapsedTapHint, isNotNull); + expect(localizations.expandedHint, isNotNull); + expect(localizations.collapsedHint, isNotNull); + expect(localizations.keyboardKeyAlt, isNotNull); + expect(localizations.keyboardKeyAltGraph, isNotNull); + expect(localizations.keyboardKeyBackspace, isNotNull); + expect(localizations.keyboardKeyCapsLock, isNotNull); + expect(localizations.keyboardKeyChannelDown, isNotNull); + expect(localizations.keyboardKeyChannelUp, isNotNull); + expect(localizations.keyboardKeyControl, isNotNull); + expect(localizations.keyboardKeyDelete, isNotNull); + expect(localizations.keyboardKeyEject, isNotNull); + expect(localizations.keyboardKeyEnd, isNotNull); + expect(localizations.keyboardKeyEscape, isNotNull); + expect(localizations.keyboardKeyFn, isNotNull); + expect(localizations.keyboardKeyHome, isNotNull); + expect(localizations.keyboardKeyInsert, isNotNull); + expect(localizations.keyboardKeyMeta, isNotNull); + expect(localizations.keyboardKeyMetaMacOs, isNotNull); + expect(localizations.keyboardKeyMetaWindows, isNotNull); + expect(localizations.keyboardKeyNumLock, isNotNull); + expect(localizations.keyboardKeyNumpad1, isNotNull); + expect(localizations.keyboardKeyNumpad2, isNotNull); + expect(localizations.keyboardKeyNumpad3, isNotNull); + expect(localizations.keyboardKeyNumpad4, isNotNull); + expect(localizations.keyboardKeyNumpad5, isNotNull); + expect(localizations.keyboardKeyNumpad6, isNotNull); + expect(localizations.keyboardKeyNumpad7, isNotNull); + expect(localizations.keyboardKeyNumpad8, isNotNull); + expect(localizations.keyboardKeyNumpad9, isNotNull); + expect(localizations.keyboardKeyNumpad0, isNotNull); + expect(localizations.keyboardKeyNumpadAdd, isNotNull); + expect(localizations.keyboardKeyNumpadComma, isNotNull); + expect(localizations.keyboardKeyNumpadDecimal, isNotNull); + expect(localizations.keyboardKeyNumpadDivide, isNotNull); + expect(localizations.keyboardKeyNumpadEnter, isNotNull); + expect(localizations.keyboardKeyNumpadEqual, isNotNull); + expect(localizations.keyboardKeyNumpadMultiply, isNotNull); + expect(localizations.keyboardKeyNumpadParenLeft, isNotNull); + expect(localizations.keyboardKeyNumpadParenRight, isNotNull); + expect(localizations.keyboardKeyNumpadSubtract, isNotNull); + expect(localizations.keyboardKeyPageDown, isNotNull); + expect(localizations.keyboardKeyPageUp, isNotNull); + expect(localizations.keyboardKeyPower, isNotNull); + expect(localizations.keyboardKeyPowerOff, isNotNull); + expect(localizations.keyboardKeyPrintScreen, isNotNull); + expect(localizations.keyboardKeyScrollLock, isNotNull); + expect(localizations.keyboardKeySelect, isNotNull); + expect(localizations.keyboardKeyShift, isNotNull); + expect(localizations.keyboardKeySpace, isNotNull); + expect(localizations.currentDateLabel, isNotNull); + expect(localizations.scrimLabel, isNotNull); + expect(localizations.bottomSheetLabel, isNotNull); + expect(localizations.selectedDateLabel, isNotNull); + + expect(localizations.scrimOnTapHint('FOO'), contains('FOO')); + + expect(localizations.aboutListTileTitle('FOO'), isNotNull); + expect(localizations.aboutListTileTitle('FOO'), contains('FOO')); + + expect(localizations.selectedRowCountTitle(0), isNotNull); + expect(localizations.selectedRowCountTitle(1), isNotNull); + expect(localizations.selectedRowCountTitle(2), isNotNull); + expect(localizations.selectedRowCountTitle(100), isNotNull); + expect(localizations.selectedRowCountTitle(0).contains(r'$selectedRowCount'), isFalse); + expect(localizations.selectedRowCountTitle(1).contains(r'$selectedRowCount'), isFalse); + expect(localizations.selectedRowCountTitle(2).contains(r'$selectedRowCount'), isFalse); + expect(localizations.selectedRowCountTitle(100).contains(r'$selectedRowCount'), isFalse); + + expect(localizations.pageRowsInfoTitle(1, 10, 100, true), isNotNull); + expect(localizations.pageRowsInfoTitle(1, 10, 100, false), isNotNull); + expect(localizations.pageRowsInfoTitle(1, 10, 100, true).contains(r'$firstRow'), isFalse); + expect(localizations.pageRowsInfoTitle(1, 10, 100, true).contains(r'$lastRow'), isFalse); + expect(localizations.pageRowsInfoTitle(1, 10, 100, true).contains(r'$rowCount'), isFalse); + expect(localizations.pageRowsInfoTitle(1, 10, 100, false).contains(r'$firstRow'), isFalse); + expect(localizations.pageRowsInfoTitle(1, 10, 100, false).contains(r'$lastRow'), isFalse); + expect(localizations.pageRowsInfoTitle(1, 10, 100, false).contains(r'$rowCount'), isFalse); + + expect(localizations.licensesPackageDetailText(0), isNotNull); + expect(localizations.licensesPackageDetailText(1), isNotNull); + expect(localizations.licensesPackageDetailText(2), isNotNull); + expect(localizations.licensesPackageDetailText(100), isNotNull); + expect(localizations.licensesPackageDetailText(1).contains(r'$licensesCount'), isFalse); + expect(localizations.licensesPackageDetailText(2).contains(r'$licensesCount'), isFalse); + expect(localizations.licensesPackageDetailText(100).contains(r'$licensesCount'), isFalse); + }); + + testWidgets('MaterialLocalizations.of throws', (WidgetTester tester) async { + final GlobalKey noLocalizationsAvailable = GlobalKey(); + final GlobalKey localizationsAvailable = GlobalKey(); + + await tester.pumpWidget( + Container( + key: noLocalizationsAvailable, + child: MaterialApp(home: Container(key: localizationsAvailable)), + ), + ); + + expect( + () => MaterialLocalizations.of(noLocalizationsAvailable.currentContext!), + throwsA( + isAssertionError.having( + (AssertionError e) => e.message, + 'message', + contains('No MaterialLocalizations found'), + ), + ), + ); + + expect( + MaterialLocalizations.of(localizationsAvailable.currentContext!), + isA<MaterialLocalizations>(), + ); + }); + + testWidgets("parseCompactDate doesn't throw an exception on invalid text", ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/126397. + final GlobalKey localizations = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material(key: localizations, child: const SizedBox.expand()), + ), + ); + + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( + localizations.currentContext!, + ); + expect(materialLocalizations.parseCompactDate('10/05/2023'), isNotNull); + expect(tester.takeException(), null); + + expect(materialLocalizations.parseCompactDate('10/05/2023666777889'), null); + expect(tester.takeException(), null); + }); +} diff --git a/packages/material_ui/test/material/magnifier_test.dart b/packages/material_ui/test/material/magnifier_test.dart new file mode 100644 index 000000000000..3b1a830a6ce3 --- /dev/null +++ b/packages/material_ui/test/material/magnifier_test.dart @@ -0,0 +1,493 @@ +// 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. + +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final magnifierController = MagnifierController(); + const reasonableTextField = Rect.fromLTRB(50, 100, 200, 100); + final basicOffset = Offset( + Magnifier.kDefaultMagnifierSize.width / 2, + Magnifier.kStandardVerticalFocalPointShift + Magnifier.kDefaultMagnifierSize.height, + ); + + Offset getMagnifierPosition(WidgetTester tester, [bool animated = false]) { + if (animated) { + final AnimatedPositioned animatedPositioned = tester.firstWidget( + find.byType(AnimatedPositioned), + ); + return Offset(animatedPositioned.left ?? 0, animatedPositioned.top ?? 0); + } else { + final Positioned positioned = tester.firstWidget(find.byType(Positioned)); + return Offset(positioned.left ?? 0, positioned.top ?? 0); + } + } + + Future<void> showMagnifier( + BuildContext context, + WidgetTester tester, + ValueNotifier<MagnifierInfo> magnifierInfo, + ) async { + final Future<void> magnifierShown = magnifierController.show( + context: context, + builder: (_) => TextMagnifier(magnifierInfo: magnifierInfo), + ); + + WidgetsBinding.instance.scheduleFrame(); + await tester.pumpAndSettle(); + + // Verify that the magnifier is shown. + await magnifierShown; + } + + tearDown(() { + magnifierController.removeFromOverlay(); + magnifierController.animationController = null; + }); + + group('adaptiveMagnifierControllerBuilder', () { + testWidgets( + 'should return a TextEditingMagnifier on Android', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierPositioner = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierPositioner.dispose); + + final Widget? builtWidget = TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierPositioner, + ); + + expect(builtWidget, isA<TextMagnifier>()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'should return a CupertinoMagnifier on iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierPositioner = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierPositioner.dispose); + + final Widget? builtWidget = TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierPositioner, + ); + + expect(builtWidget, isA<CupertinoTextMagnifier>()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'should return null on all platforms not Android, iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierPositioner = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierPositioner.dispose); + + final Widget? builtWidget = TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierPositioner, + ); + + expect(builtWidget, isNull); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.android}, + ), + ); + }); + + group('magnifier', () { + group('position', () { + testWidgets('should be at gesture position if does not violate any positioning rules', ( + WidgetTester tester, + ) async { + final Key textField = UniqueKey(); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + await tester.pumpWidget( + ColoredBox( + color: const Color.fromARGB(255, 0, 255, 179), + child: MaterialApp( + home: Center( + child: Container( + key: textField, + width: 10, + height: 10, + color: Colors.red, + child: const Placeholder(), + ), + ), + ), + ), + ); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + // Magnifier should be positioned directly over the red square. + final tapPointRenderBox = tester.firstRenderObject(find.byKey(textField)) as RenderBox; + final Rect fakeTextFieldRect = + tapPointRenderBox.localToGlobal(Offset.zero) & tapPointRenderBox.size; + + final magnifierInfo = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: fakeTextFieldRect, + fieldBounds: fakeTextFieldRect, + caretRect: fakeTextFieldRect, + // The tap position is dragBelow units below the text field. + globalGesturePosition: fakeTextFieldRect.center, + ), + ); + addTearDown(magnifierInfo.dispose); + + await showMagnifier(context, tester, magnifierInfo); + + // Should show two red crossed-out squares: the original in the center, + // and one in the magnifier, in the upper half of the image, surrounded + // by a faint offset rounded rectangle shadow. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('magnifier.position.default.png'), + ); + }); + + testWidgets('should never move outside the right bounds of the editing line', ( + WidgetTester tester, + ) async { + const double gestureOutsideLine = 100; + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierPositioner; + addTearDown(() => magnifierPositioner.dispose()); + + await showMagnifier( + context, + tester, + magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + // Inflate these two to make sure we're bounding on the + // current line boundaries, not anything else. + fieldBounds: reasonableTextField.inflate(gestureOutsideLine), + caretRect: reasonableTextField.inflate(gestureOutsideLine), + // The tap position is far out of the right side of the app. + globalGesturePosition: Offset(reasonableTextField.right + gestureOutsideLine, 0), + ), + ), + ); + + // Should be less than the right edge, since we have padding. + expect(getMagnifierPosition(tester).dx, lessThanOrEqualTo(reasonableTextField.right)); + }); + + testWidgets('should never move outside the left bounds of the editing line', ( + WidgetTester tester, + ) async { + const double gestureOutsideLine = 100; + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierPositioner; + addTearDown(() => magnifierPositioner.dispose()); + + await showMagnifier( + context, + tester, + magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + // Inflate these two to make sure we're bounding on the + // current line boundaries, not anything else. + fieldBounds: reasonableTextField.inflate(gestureOutsideLine), + caretRect: reasonableTextField.inflate(gestureOutsideLine), + // The tap position is far out of the left side of the app. + globalGesturePosition: Offset(reasonableTextField.left - gestureOutsideLine, 0), + ), + ), + ); + + expect( + getMagnifierPosition(tester).dx + basicOffset.dx, + greaterThanOrEqualTo(reasonableTextField.left), + ); + }); + + testWidgets('should position vertically at the center of the line', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierPositioner; + addTearDown(() => magnifierPositioner.dispose()); + + await showMagnifier( + context, + tester, + magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ), + ); + + expect(getMagnifierPosition(tester).dy, reasonableTextField.center.dy - basicOffset.dy); + }); + + testWidgets('should reposition vertically if mashed against the ceiling', ( + WidgetTester tester, + ) async { + final topOfScreenTextFieldRect = Rect.fromPoints(Offset.zero, const Offset(200, 0)); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierPositioner; + addTearDown(() => magnifierPositioner.dispose()); + + await showMagnifier( + context, + tester, + magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: topOfScreenTextFieldRect, + fieldBounds: topOfScreenTextFieldRect, + caretRect: topOfScreenTextFieldRect, + globalGesturePosition: topOfScreenTextFieldRect.topCenter, + ), + ), + ); + + expect(getMagnifierPosition(tester).dy, greaterThanOrEqualTo(0)); + }); + }); + + group('focal point', () { + Offset getMagnifierAdditionalFocalPoint(WidgetTester tester) { + final Magnifier magnifier = tester.firstWidget(find.byType(Magnifier)); + return magnifier.additionalFocalPointOffset; + } + + testWidgets('should shift focal point so that the lens sees nothing out of bounds', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierPositioner; + addTearDown(() => magnifierPositioner.dispose()); + + await showMagnifier( + context, + tester, + magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + // Gesture on the far right of the magnifier. + globalGesturePosition: reasonableTextField.topLeft, + ), + ), + ); + + expect(getMagnifierAdditionalFocalPoint(tester).dx, lessThan(reasonableTextField.left)); + }); + + testWidgets('focal point should shift if mashed against the top to always point to text', ( + WidgetTester tester, + ) async { + final topOfScreenTextFieldRect = Rect.fromPoints(Offset.zero, const Offset(200, 0)); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierPositioner; + addTearDown(() => magnifierPositioner.dispose()); + + await showMagnifier( + context, + tester, + magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: topOfScreenTextFieldRect, + fieldBounds: topOfScreenTextFieldRect, + caretRect: topOfScreenTextFieldRect, + globalGesturePosition: topOfScreenTextFieldRect.topCenter, + ), + ), + ); + + expect(getMagnifierAdditionalFocalPoint(tester).dy, lessThan(0)); + }); + }); + + group('animation state', () { + bool getIsAnimated(WidgetTester tester) { + final AnimatedPositioned animatedPositioned = tester.firstWidget( + find.byType(AnimatedPositioned), + ); + return animatedPositioned.duration.compareTo(Duration.zero) != 0; + } + + testWidgets('should not be animated on the initial state', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + late ValueNotifier<MagnifierInfo> magnifierInfo; + addTearDown(() => magnifierInfo.dispose()); + + await showMagnifier( + context, + tester, + magnifierInfo = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ), + ); + + expect(getIsAnimated(tester), false); + }); + + testWidgets('should not be animated on horizontal shifts', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ); + addTearDown(magnifierPositioner.dispose); + + await showMagnifier(context, tester, magnifierPositioner); + + // New position has a horizontal shift. + magnifierPositioner.value = MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center + const Offset(200, 0), + ); + await tester.pumpAndSettle(); + + expect(getIsAnimated(tester), false); + }); + + testWidgets('should be animated on vertical shifts', (WidgetTester tester) async { + const verticalShift = Offset(0, 200); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ); + addTearDown(magnifierPositioner.dispose); + + await showMagnifier(context, tester, magnifierPositioner); + + // New position has a vertical shift. + magnifierPositioner.value = MagnifierInfo( + currentLineBoundaries: reasonableTextField.shift(verticalShift), + fieldBounds: Rect.fromPoints( + reasonableTextField.topLeft, + reasonableTextField.bottomRight + verticalShift, + ), + caretRect: reasonableTextField.shift(verticalShift), + globalGesturePosition: reasonableTextField.center + verticalShift, + ); + + await tester.pump(); + expect(getIsAnimated(tester), true); + }); + + testWidgets('should stop being animated when timer is up', (WidgetTester tester) async { + const verticalShift = Offset(0, 200); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + + final magnifierPositioner = ValueNotifier<MagnifierInfo>( + MagnifierInfo( + currentLineBoundaries: reasonableTextField, + fieldBounds: reasonableTextField, + caretRect: reasonableTextField, + globalGesturePosition: reasonableTextField.center, + ), + ); + addTearDown(magnifierPositioner.dispose); + + await showMagnifier(context, tester, magnifierPositioner); + + // New position has a vertical shift. + magnifierPositioner.value = MagnifierInfo( + currentLineBoundaries: reasonableTextField.shift(verticalShift), + fieldBounds: Rect.fromPoints( + reasonableTextField.topLeft, + reasonableTextField.bottomRight + verticalShift, + ), + caretRect: reasonableTextField.shift(verticalShift), + globalGesturePosition: reasonableTextField.center + verticalShift, + ); + + await tester.pump(); + expect(getIsAnimated(tester), true); + await tester.pump( + TextMagnifier.jumpBetweenLinesAnimationDuration + const Duration(seconds: 2), + ); + expect(getIsAnimated(tester), false); + }); + }); + }); +} diff --git a/packages/material_ui/test/material/material_button_test.dart b/packages/material_ui/test/material/material_button_test.dart new file mode 100644 index 000000000000..f28000d6c263 --- /dev/null +++ b/packages/material_ui/test/material/material_button_test.dart @@ -0,0 +1,974 @@ +// 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' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('MaterialButton defaults', (WidgetTester tester) async { + final Finder rawButtonMaterial = find.descendant( + of: find.byType(MaterialButton), + matching: find.byType(Material), + ); + + // Enabled MaterialButton + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + Material material = tester.widget<Material>(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, null); + expect(material.elevation, 2.0); + expect(material.shadowColor, null); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + ); + expect(material.textStyle!.color, const Color(0xdd000000)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.transparency); + + final Offset center = tester.getCenter(find.byType(MaterialButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Only elevation changes when enabled and pressed. + material = tester.widget<Material>(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, null); + expect(material.elevation, 8.0); + expect(material.shadowColor, null); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + ); + expect(material.textStyle!.color, const Color(0xdd000000)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.transparency); + + // Disabled MaterialButton + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: const Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton(onPressed: null, child: Text('button')), + ), + ), + ); + material = tester.widget<Material>(rawButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, null); + expect(material.elevation, 0.0); + expect(material.shadowColor, null); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + ); + expect(material.textStyle!.color, const Color(0x61000000)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.transparency); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Does MaterialButton work with hover', (WidgetTester tester) async { + const hoverColor = Color(0xff001122); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + hoverColor: hoverColor, + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(MaterialButton))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..rect(color: hoverColor)); + }); + + testWidgets('Does MaterialButton work with focus', (WidgetTester tester) async { + const focusColor = Color(0xff001122); + + final focusNode = FocusNode(debugLabel: 'MaterialButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + focusColor: focusColor, + 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)); + + focusNode.dispose(); + }); + + testWidgets('MaterialButton elevation and colors have proper precedence', ( + WidgetTester tester, + ) async { + const elevation = 10.0; + const focusElevation = 11.0; + const hoverElevation = 12.0; + const highlightElevation = 13.0; + const focusColor = Color(0xff001122); + const hoverColor = Color(0xff112233); + const highlightColor = Color(0xff223344); + + final Finder rawButtonMaterial = find.descendant( + of: find.byType(MaterialButton), + matching: find.byType(Material), + ); + + final focusNode = FocusNode(debugLabel: 'MaterialButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + elevation: elevation, + focusElevation: focusElevation, + hoverElevation: hoverElevation, + highlightElevation: highlightElevation, + focusNode: focusNode, + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + await tester.pumpAndSettle(); + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + // Base elevation + Material material = tester.widget<Material>(rawButtonMaterial); + expect(material.elevation, equals(elevation)); + + // Focus elevation overrides base + focusNode.requestFocus(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(rawButtonMaterial); + RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..rect(color: focusColor)); + expect(focusNode.hasPrimaryFocus, isTrue); + expect(material.elevation, equals(focusElevation)); + + // Hover elevation overrides focus + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(MaterialButton))); + await tester.pumpAndSettle(); + material = tester.widget<Material>(rawButtonMaterial); + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints + ..rect(color: focusColor) + ..rect(color: hoverColor), + ); + expect(material.elevation, equals(hoverElevation)); + await gesture.removePointer(); + gesture = null; + + // Highlight elevation overrides hover + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(MaterialButton)), + ); + addTearDown(gesture2.removePointer); + await tester.pumpAndSettle(); + material = tester.widget<Material>(rawButtonMaterial); + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints + ..rect(color: focusColor) + ..rect(color: highlightColor), + ); + expect(material.elevation, equals(highlightElevation)); + await gesture2.up(); + + focusNode.dispose(); + }); + + testWidgets("MaterialButton's disabledColor takes precedence over its default disabled color.", ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/30012. + + final Finder rawButtonMaterial = find.descendant( + of: find.byType(MaterialButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + disabledColor: Color(0xff00ff00), + onPressed: null, + child: Text('button'), + ), + ), + ); + + final Material material = tester.widget<Material>(rawButtonMaterial); + expect(material.color, const Color(0xff00ff00)); + }); + + testWidgets( + 'Default MaterialButton meets a11y contrast guidelines', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: MaterialButton(child: const Text('MaterialButton'), onPressed: () {}), + ), + ), + ), + ); + + // Default, not disabled. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Highlighted (pressed). + final Offset center = tester.getCenter(find.byType(MaterialButton)); + final TestGesture gesture = await tester.startGesture(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. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets('MaterialButton gets focus when autofocus is set.', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'MaterialButton'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: MaterialButton( + focusNode: focusNode, + onPressed: () {}, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isFalse); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: MaterialButton( + autofocus: true, + focusNode: focusNode, + onPressed: () {}, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + focusNode.dispose(); + }); + + testWidgets( + 'MaterialButton onPressed and onLongPress callbacks are correctly called when non-null', + (WidgetTester tester) async { + bool wasPressed; + Finder materialButton; + + Widget buildFrame({VoidCallback? onPressed, VoidCallback? onLongPress}) { + return Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + onPressed: onPressed, + onLongPress: onLongPress, + child: const Text('button'), + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onPressed: () { + wasPressed = true; + }, + ), + ); + materialButton = find.byType(MaterialButton); + expect(tester.widget<MaterialButton>(materialButton).enabled, true); + await tester.tap(materialButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onLongPress: () { + wasPressed = true; + }, + ), + ); + materialButton = find.byType(MaterialButton); + expect(tester.widget<MaterialButton>(materialButton).enabled, true); + await tester.longPress(materialButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget(buildFrame()); + materialButton = find.byType(MaterialButton); + expect(tester.widget<MaterialButton>(materialButton).enabled, false); + }, + ); + + testWidgets('MaterialButton onPressed and onLongPress callbacks are distinctly recognized', ( + WidgetTester tester, + ) async { + var didPressButton = false; + var didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder materialButton = find.byType(MaterialButton); + expect(tester.widget<MaterialButton>(materialButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(materialButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(materialButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets('MaterialButton changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: MaterialButton(onPressed: () {}, mouseCursor: SystemMouseCursors.text), + ), + ), + ); + + 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 default cursor + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: MaterialButton(onPressed: () {}), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: MaterialButton(onPressed: null), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + // This test is very similar to the '...explicit splashColor and highlightColor' test + // in icon_button_test.dart. If you change this one, you may want to also change that one. + testWidgets('MaterialButton with explicit splashColor and highlightColor', ( + WidgetTester tester, + ) async { + const directSplashColor = Color(0xFF000011); + const directHighlightColor = Color(0xFF000011); + + Widget buttonWidget = Center( + child: MaterialButton( + splashColor: directSplashColor, + highlightColor: directHighlightColor, + onPressed: () { + /* to make sure the button is enabled */ + }, + clipBehavior: Clip.antiAlias, + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + useMaterial3: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: buttonWidget, + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(MaterialButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + // Painter is translated to the center by the Center widget and not + // the Material widget. + const expectedClipRect = Rect.fromLTRB(0.0, 0.0, 88.0, 36.0); + final expectedClipPath = Path() + ..addRRect(RRect.fromRectAndRadius(expectedClipRect, const Radius.circular(2.0))); + expect( + Material.of(tester.element(find.byType(InkWell))), + paints + ..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(10.0), + ), + ) + ..circle(color: directSplashColor) + ..rect(color: directHighlightColor), + ); + + const themeSplashColor1 = Color(0xFF001100); + const themeHighlightColor1 = Color(0xFF001100); + + buttonWidget = Center( + child: MaterialButton( + onPressed: () { + /* to make sure the button is enabled */ + }, + clipBehavior: Clip.antiAlias, + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + useMaterial3: false, + highlightColor: themeHighlightColor1, + splashColor: themeSplashColor1, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: buttonWidget, + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(InkWell))), + paints + ..clipPath( + pathMatcher: coversSameAreaAs( + expectedClipPath, + areaToCompare: expectedClipRect.inflate(10.0), + ), + ) + ..circle(color: themeSplashColor1) + ..rect(color: themeHighlightColor1), + ); + + const themeSplashColor2 = Color(0xFF002200); + const themeHighlightColor2 = Color(0xFF002200); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + useMaterial3: false, + highlightColor: themeHighlightColor2, + splashColor: themeSplashColor2, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: buttonWidget, // same widget, so does not get updated because of us + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(InkWell))), + paints + ..circle(color: themeSplashColor2) + ..rect(color: themeHighlightColor2), + ); + + await gesture.up(); + }); + + testWidgets('MaterialButton has no clip by default', (WidgetTester tester) async { + final GlobalKey buttonKey = GlobalKey(); + final Widget buttonWidget = Center( + child: MaterialButton( + key: buttonKey, + onPressed: () { + /* to make sure the button is enabled */ + }, + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: buttonWidget, + ), + ), + ); + + expect(tester.renderObject(find.byKey(buttonKey)), paintsExactlyCountTimes(#clipPath, 0)); + }); + + testWidgets( + 'Disabled MaterialButton has same semantic size as enabled and exposes disabled semantics', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + const expectedButtonSize = Rect.fromLTRB(0.0, 0.0, 116.0, 48.0); + // Button is in center of screen + final expectedButtonTransform = Matrix4.identity() + ..translate( + TestSemantics.fullScreen.width / 2 - expectedButtonSize.width / 2, + TestSemantics.fullScreen.height / 2 - expectedButtonSize.height / 2, + ); + + // enabled button + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MaterialButton( + child: const Text('Button'), + onPressed: () { + /* to make sure the button is enabled */ + }, + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: expectedButtonSize, + transform: expectedButtonTransform, + label: 'Button', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ), + ); + + // disabled button + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MaterialButton( + onPressed: null, // button is disabled + child: Text('Button'), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: expectedButtonSize, + transform: expectedButtonTransform, + label: 'Button', + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.focus], + ), + ], + ), + ), + ); + + semantics.dispose(); + }, + ); + + testWidgets('MaterialButton minWidth and height parameters', (WidgetTester tester) async { + Widget buildFrame({ + double? minWidth, + double? height, + EdgeInsets padding = EdgeInsets.zero, + Widget? child, + }) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MaterialButton( + padding: padding, + minWidth: minWidth, + height: height, + onPressed: null, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + child: child, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(minWidth: 8.0, height: 24.0)); + expect(tester.getSize(find.byType(MaterialButton)), const Size(8.0, 24.0)); + + await tester.pumpWidget(buildFrame(minWidth: 8.0)); + // Default minHeight constraint is 36, see RawMaterialButton. + expect(tester.getSize(find.byType(MaterialButton)), const Size(8.0, 36.0)); + + await tester.pumpWidget(buildFrame(height: 8.0)); + // Default minWidth constraint is 88, see RawMaterialButton. + expect(tester.getSize(find.byType(MaterialButton)), const Size(88.0, 8.0)); + + await tester.pumpWidget(buildFrame()); + expect(tester.getSize(find.byType(MaterialButton)), const Size(88.0, 36.0)); + + await tester.pumpWidget(buildFrame(padding: const EdgeInsets.all(4.0))); + expect(tester.getSize(find.byType(MaterialButton)), const Size(88.0, 36.0)); + + // Size is defined by the padding. + await tester.pumpWidget( + buildFrame(minWidth: 0.0, height: 0.0, padding: const EdgeInsets.all(4.0)), + ); + expect(tester.getSize(find.byType(MaterialButton)), const Size(8.0, 8.0)); + + // Size is defined by the padded child. + await tester.pumpWidget( + buildFrame( + minWidth: 0.0, + height: 0.0, + padding: const EdgeInsets.all(4.0), + child: const SizedBox(width: 8.0, height: 8.0), + ), + ); + expect(tester.getSize(find.byType(MaterialButton)), const Size(16.0, 16.0)); + + // Size is defined by the minWidth, height constraints. + await tester.pumpWidget( + buildFrame( + minWidth: 18.0, + height: 18.0, + padding: const EdgeInsets.all(4.0), + child: const SizedBox(width: 8.0, height: 8.0), + ), + ); + expect(tester.getSize(find.byType(MaterialButton)), const Size(18.0, 18.0)); + }); + + testWidgets('MaterialButton size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + final Key key1 = UniqueKey(); + await tester.pumpWidget( + Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MaterialButton( + key: key1, + child: const SizedBox(width: 50.0, height: 8.0), + onPressed: () {}, + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key1)), const Size(88.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget( + Theme( + data: ThemeData(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MaterialButton( + key: key2, + child: const SizedBox(width: 50.0, height: 8.0), + onPressed: () {}, + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key2)), const Size(88.0, 36.0)); + }); + + testWidgets('MaterialButton shape overrides ButtonTheme shape', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/29146 + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + onPressed: () {}, + shape: const StadiumBorder(), + child: const Text('button'), + ), + ), + ); + + final Finder rawButtonMaterial = find.descendant( + of: find.byType(MaterialButton), + matching: find.byType(Material), + ); + expect(tester.widget<Material>(rawButtonMaterial).shape, const StadiumBorder()); + }); + + testWidgets('MaterialButton responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + const childKey = Key('test child'); + + Future<void> buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: MaterialButton( + visualDensity: visualDensity, + 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(108, 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(76, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + testWidgets('disabledElevation is passed to RawMaterialButton', (WidgetTester tester) async { + const double disabledElevation = 16; + + final Finder rawMaterialButtonFinder = find.descendant( + of: find.byType(MaterialButton), + matching: find.byType(RawMaterialButton), + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + disabledElevation: disabledElevation, + onPressed: null, // disabled button + child: Text('button'), + ), + ), + ); + + final RawMaterialButton rawMaterialButton = tester.widget(rawMaterialButtonFinder); + expect(rawMaterialButton.disabledElevation, equals(disabledElevation)); + }); + + testWidgets('MaterialButton.disabledElevation defaults to 0.0 when not provided', ( + WidgetTester tester, + ) async { + final Finder rawMaterialButtonFinder = find.descendant( + of: find.byType(MaterialButton), + matching: find.byType(RawMaterialButton), + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MaterialButton( + onPressed: null, // disabled button + child: Text('button'), + ), + ), + ); + + final RawMaterialButton rawMaterialButton = tester.widget(rawMaterialButtonFinder); + expect(rawMaterialButton.disabledElevation, equals(0.0)); + }); +} diff --git a/packages/material_ui/test/material/material_state_mixin_test.dart b/packages/material_ui/test/material/material_state_mixin_test.dart new file mode 100644 index 000000000000..9b9645e454b4 --- /dev/null +++ b/packages/material_ui/test/material/material_state_mixin_test.dart @@ -0,0 +1,161 @@ +// 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:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const Key key = Key('testContainer'); +const Color trueColor = Colors.red; +const Color falseColor = Colors.green; + +/// Mock widget which plays the role of a button -- it can emit notifications +/// that [WidgetState] values are now in or out of play. +class _InnerWidget extends StatefulWidget { + const _InnerWidget({required this.onValueChanged, required this.controller}); + final ValueChanged<bool> onValueChanged; + final StreamController<bool> controller; + + @override + _InnerWidgetState createState() => _InnerWidgetState(); +} + +class _InnerWidgetState extends State<_InnerWidget> { + @override + void initState() { + super.initState(); + widget.controller.stream.listen((bool val) => widget.onValueChanged(val)); + } + + @override + Widget build(BuildContext context) => Container(); +} + +class _MyWidget extends StatefulWidget { + const _MyWidget({required this.controller, required this.evaluator, required this.materialState}); + + /// Wrapper around `MaterialStateMixin.isPressed/isHovered/isFocused/etc`. + final bool Function(_MyWidgetState state) evaluator; + + /// Stream passed down to the child [_InnerWidget] to begin the process. + /// This plays the role of an actual user interaction in the wild, but allows + /// us to engage the system without mocking pointers/hovers etc. + final StreamController<bool> controller; + + /// The value we're watching in the given test. + final WidgetState materialState; + + @override + State createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State<_MyWidget> with MaterialStateMixin { + @override + Widget build(BuildContext context) { + return ColoredBox( + key: key, + color: widget.evaluator(this) ? trueColor : falseColor, + child: _InnerWidget( + onValueChanged: updateMaterialState(widget.materialState), + controller: widget.controller, + ), + ); + } +} + +void main() { + Future<void> verify(WidgetTester tester, Widget widget, StreamController<bool> controller) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget))); + // Set the value to True + controller.sink.add(true); + await tester.pumpAndSettle(); + expect(tester.widget<ColoredBox>(find.byKey(key)).color, trueColor); + + // Set the value to False + controller.sink.add(false); + await tester.pumpAndSettle(); + expect(tester.widget<ColoredBox>(find.byKey(key)).color, falseColor); + } + + testWidgets('WidgetState.pressed is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isPressed, + materialState: WidgetState.pressed, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.focused is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isFocused, + materialState: WidgetState.focused, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.hovered is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isHovered, + materialState: WidgetState.hovered, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.disabled is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isDisabled, + materialState: WidgetState.disabled, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.selected is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isSelected, + materialState: WidgetState.selected, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.scrolledUnder is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isScrolledUnder, + materialState: WidgetState.scrolledUnder, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.dragged is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isDragged, + materialState: WidgetState.dragged, + ); + await verify(tester, widget, controller); + }); + + testWidgets('WidgetState.error is tracked', (WidgetTester tester) async { + final controller = StreamController<bool>(); + final widget = _MyWidget( + controller: controller, + evaluator: (_MyWidgetState state) => state.isErrored, + materialState: WidgetState.error, + ); + await verify(tester, widget, controller); + }); +} diff --git a/packages/material_ui/test/material/material_state_property_test.dart b/packages/material_ui/test/material/material_state_property_test.dart new file mode 100644 index 000000000000..30279229d97a --- /dev/null +++ b/packages/material_ui/test/material/material_state_property_test.dart @@ -0,0 +1,95 @@ +// 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() { + test('MaterialStateProperty.resolveWith()', () { + final MaterialStateProperty<MaterialState> value = + MaterialStateProperty.resolveWith<MaterialState>( + (Set<MaterialState> states) => states.first, + ); + expect(value.resolve(<MaterialState>{MaterialState.hovered}), MaterialState.hovered); + expect(value.resolve(<MaterialState>{MaterialState.focused}), MaterialState.focused); + expect(value.resolve(<MaterialState>{MaterialState.pressed}), MaterialState.pressed); + expect(value.resolve(<MaterialState>{MaterialState.dragged}), MaterialState.dragged); + expect(value.resolve(<MaterialState>{MaterialState.selected}), MaterialState.selected); + expect(value.resolve(<MaterialState>{MaterialState.disabled}), MaterialState.disabled); + expect(value.resolve(<MaterialState>{MaterialState.error}), MaterialState.error); + }); + + test('MaterialStateProperty.all()', () { + final MaterialStateProperty<int> value = MaterialStateProperty.all<int>(123); + expect(value.resolve(<MaterialState>{MaterialState.hovered}), 123); + expect(value.resolve(<MaterialState>{MaterialState.focused}), 123); + expect(value.resolve(<MaterialState>{MaterialState.pressed}), 123); + expect(value.resolve(<MaterialState>{MaterialState.dragged}), 123); + expect(value.resolve(<MaterialState>{MaterialState.selected}), 123); + expect(value.resolve(<MaterialState>{MaterialState.disabled}), 123); + expect(value.resolve(<MaterialState>{MaterialState.error}), 123); + }); + + test('MaterialStatePropertyAll', () { + const value = MaterialStatePropertyAll<int>(123); + expect(value.resolve(<MaterialState>{}), 123); + expect(value.resolve(<MaterialState>{MaterialState.hovered}), 123); + expect(value.resolve(<MaterialState>{MaterialState.focused}), 123); + expect(value.resolve(<MaterialState>{MaterialState.pressed}), 123); + expect(value.resolve(<MaterialState>{MaterialState.dragged}), 123); + expect(value.resolve(<MaterialState>{MaterialState.selected}), 123); + expect(value.resolve(<MaterialState>{MaterialState.disabled}), 123); + expect(value.resolve(<MaterialState>{MaterialState.error}), 123); + }); + + test('toString formats correctly', () { + const MaterialStateProperty<Color?> colorProperty = MaterialStatePropertyAll<Color?>( + Color(0xFFFFFFFF), + ); + expect(colorProperty.toString(), equals('WidgetStatePropertyAll(${const Color(0xffffffff)})')); + + const MaterialStateProperty<double?> doubleProperty = MaterialStatePropertyAll<double?>( + 33 + 1 / 3, + ); + expect(doubleProperty.toString(), equals('WidgetStatePropertyAll(33.3)')); + }); + + test("Can interpolate between two MaterialStateProperty's", () { + const MaterialStateProperty<TextStyle?> textStyle1 = MaterialStatePropertyAll<TextStyle?>( + TextStyle(fontSize: 14.0), + ); + const MaterialStateProperty<TextStyle?> textStyle2 = MaterialStatePropertyAll<TextStyle?>( + TextStyle(fontSize: 20.0), + ); + + // Using `0.0` interpolation value. + TextStyle textStyle = MaterialStateProperty.lerp<TextStyle?>( + textStyle1, + textStyle2, + 0.0, + TextStyle.lerp, + )!.resolve(enabled)!; + expect(textStyle.fontSize, 14.0); + + // Using `0.5` interpolation value. + textStyle = MaterialStateProperty.lerp<TextStyle?>( + textStyle1, + textStyle2, + 0.5, + TextStyle.lerp, + )!.resolve(enabled)!; + expect(textStyle.fontSize, 17.0); + + // Using `1.0` interpolation value. + textStyle = MaterialStateProperty.lerp<TextStyle?>( + textStyle1, + textStyle2, + 1.0, + TextStyle.lerp, + )!.resolve(enabled)!; + expect(textStyle.fontSize, 20.0); + }); +} + +Set<MaterialState> enabled = <MaterialState>{}; diff --git a/packages/material_ui/test/material/material_states_controller_test.dart b/packages/material_ui/test/material/material_states_controller_test.dart new file mode 100644 index 000000000000..dc2367ef1687 --- /dev/null +++ b/packages/material_ui/test/material/material_states_controller_test.dart @@ -0,0 +1,100 @@ +// 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'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +void main() { + test('MaterialStatesController constructor', () { + expect(MaterialStatesController().value, <MaterialState>{}); + expect(MaterialStatesController(<MaterialState>{}).value, <MaterialState>{}); + expect(MaterialStatesController(<MaterialState>{MaterialState.selected}).value, <MaterialState>{ + MaterialState.selected, + }); + }); + + test('MaterialStatesController dispatches memory events', () async { + await expectLater( + await memoryEvents(() => MaterialStatesController().dispose(), MaterialStatesController), + areCreateAndDispose, + ); + }); + + test('MaterialStatesController update, listener', () { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + controller.addListener(valueChanged); + + controller.update(MaterialState.selected, true); + expect(controller.value, <MaterialState>{MaterialState.selected}); + expect(count, 1); + controller.update(MaterialState.selected, true); + expect(controller.value, <MaterialState>{MaterialState.selected}); + expect(count, 1); + + controller.update(MaterialState.hovered, false); + expect(count, 1); + expect(controller.value, <MaterialState>{MaterialState.selected}); + controller.update(MaterialState.selected, false); + expect(count, 2); + expect(controller.value, <MaterialState>{}); + + controller.update(MaterialState.hovered, true); + expect(controller.value, <MaterialState>{MaterialState.hovered}); + expect(count, 3); + controller.update(MaterialState.hovered, true); + expect(controller.value, <MaterialState>{MaterialState.hovered}); + expect(count, 3); + controller.update(MaterialState.pressed, true); + expect(controller.value, <MaterialState>{MaterialState.hovered, MaterialState.pressed}); + expect(count, 4); + controller.update(MaterialState.selected, true); + expect(controller.value, <MaterialState>{ + MaterialState.hovered, + MaterialState.pressed, + MaterialState.selected, + }); + expect(count, 5); + controller.update(MaterialState.selected, false); + expect(controller.value, <MaterialState>{MaterialState.hovered, MaterialState.pressed}); + expect(count, 6); + controller.update(MaterialState.selected, false); + expect(controller.value, <MaterialState>{MaterialState.hovered, MaterialState.pressed}); + expect(count, 6); + controller.update(MaterialState.pressed, false); + expect(controller.value, <MaterialState>{MaterialState.hovered}); + expect(count, 7); + controller.update(MaterialState.hovered, false); + expect(controller.value, <MaterialState>{}); + expect(count, 8); + + controller.removeListener(valueChanged); + controller.update(MaterialState.selected, true); + expect(controller.value, <MaterialState>{MaterialState.selected}); + expect(count, 8); + }); + + test('MaterialStatesController const initial value', () { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(const <MaterialState>{MaterialState.selected}); + controller.addListener(valueChanged); + + controller.update(MaterialState.selected, true); + expect(controller.value, <MaterialState>{MaterialState.selected}); + expect(count, 0); + + controller.update(MaterialState.selected, false); + expect(controller.value, <MaterialState>{}); + expect(count, 1); + }); +} diff --git a/packages/material_ui/test/material/material_test.dart b/packages/material_ui/test/material/material_test.dart new file mode 100644 index 000000000000..75ea3518e194 --- /dev/null +++ b/packages/material_ui/test/material/material_test.dart @@ -0,0 +1,1269 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; +import '../widgets/multi_view_testing.dart'; +import '../widgets/test_border.dart' show TestBorder; + +class NotifyMaterial extends StatelessWidget { + const NotifyMaterial({super.key}); + @override + Widget build(BuildContext context) { + const LayoutChangedNotification().dispatch(context); + return Container(); + } +} + +Widget buildMaterial({ + double elevation = 0.0, + Color shadowColor = const Color(0xFF00FF00), + Color? surfaceTintColor, + Color color = const Color(0xFF0000FF), +}) { + return Center( + child: SizedBox.square( + dimension: 100.0, + child: Material( + color: color, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + elevation: elevation, + shape: const CircleBorder(), + ), + ), + ); +} + +RenderPhysicalShape getModel(WidgetTester tester) { + return tester.renderObject(find.byType(PhysicalShape)); +} + +class PaintRecorder extends CustomPainter { + PaintRecorder(this.log); + + final List<Size> log; + + @override + void paint(Canvas canvas, Size size) { + log.add(size); + final paint = Paint()..color = const Color(0xFF0000FF); + canvas.drawRect(Offset.zero & size, paint); + } + + @override + bool shouldRepaint(PaintRecorder oldDelegate) => false; +} + +class ElevationColor { + const ElevationColor(this.elevation, this.color); + final double elevation; + final Color color; +} + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/81504 + testWidgets('MaterialApp.home nullable and update test', (WidgetTester tester) async { + // _WidgetsAppState._usesNavigator == true + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + + // _WidgetsAppState._usesNavigator == false + await tester.pumpWidget(const MaterialApp()); // Do not crash! + + // _WidgetsAppState._usesNavigator == true + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); // Do not crash! + + expect(tester.takeException(), null); + }); + + testWidgets('default Material debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const Material().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>['type: canvas']); + }); + + testWidgets('Material implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const Material( + color: Color(0xFFFFFFFF), + shadowColor: Color(0xffff0000), + surfaceTintColor: Color(0xff0000ff), + textStyle: TextStyle(color: Color(0xff00ff00)), + borderRadius: BorderRadiusDirectional.all(Radius.circular(10)), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'type: canvas', + 'color: ${const Color(0xffffffff)}', + 'shadowColor: ${const Color(0xffff0000)}', + 'surfaceTintColor: ${const Color(0xff0000ff)}', + 'textStyle.inherit: true', + 'textStyle.color: ${const Color(0xff00ff00)}', + 'borderRadius: BorderRadiusDirectional.circular(10.0)', + ]); + }); + + testWidgets('LayoutChangedNotification test', (WidgetTester tester) async { + await tester.pumpWidget(const Material(child: NotifyMaterial())); + }); + + testWidgets('ListView scroll does not repaint', (WidgetTester tester) async { + final log = <Size>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: <Widget>[ + SizedBox(width: 150.0, height: 150.0, child: CustomPaint(painter: PaintRecorder(log))), + Expanded( + child: Material( + child: Column( + children: <Widget>[ + Expanded( + child: ListView( + children: <Widget>[ + Container(height: 2000.0, color: const Color(0xFF00FF00)), + ], + ), + ), + SizedBox.square( + dimension: 100.0, + child: CustomPaint(painter: PaintRecorder(log)), + ), + ], + ), + ), + ), + ], + ), + ), + ); + + // We paint twice because we have two CustomPaint widgets in the tree above + // to test repainting both inside and outside the Material widget. + expect(log, equals(<Size>[const Size(150.0, 150.0), const Size(100.0, 100.0)])); + log.clear(); + + await tester.drag(find.byType(ListView), const Offset(0.0, -300.0)); + await tester.pump(); + + expect(log, isEmpty); + }); + + testWidgets('Shadow color defaults', (WidgetTester tester) async { + Widget buildWithShadow(Color? shadowColor) { + return Center( + child: SizedBox.square( + dimension: 100.0, + child: Material(shadowColor: shadowColor, elevation: 10, shape: const CircleBorder()), + ), + ); + } + + // Default M2 shadow color + await tester.pumpWidget( + Theme(data: ThemeData(useMaterial3: false), child: buildWithShadow(null)), + ); + await tester.pumpAndSettle(); + expect(getModel(tester).shadowColor, ThemeData().shadowColor); + + // Default M3 shadow color + await tester.pumpWidget(Theme(data: ThemeData(), child: buildWithShadow(null))); + await tester.pumpAndSettle(); + expect(getModel(tester).shadowColor, ThemeData().colorScheme.shadow); + + // Drop shadow can be turned off with a transparent color. + await tester.pumpWidget(Theme(data: ThemeData(), child: buildWithShadow(Colors.transparent))); + await tester.pumpAndSettle(); + expect(getModel(tester).shadowColor, Colors.transparent); + }); + + testWidgets('Shadows animate smoothly', (WidgetTester tester) async { + // This code verifies that the PhysicalModel's elevation animates over + // a kThemeChangeDuration time interval. + + await tester.pumpWidget(buildMaterial()); + final RenderPhysicalShape modelA = getModel(tester); + expect(modelA.elevation, equals(0.0)); + + await tester.pumpWidget(buildMaterial(elevation: 9.0)); + final RenderPhysicalShape modelB = getModel(tester); + expect(modelB.elevation, equals(0.0)); + + await tester.pump(const Duration(milliseconds: 1)); + final RenderPhysicalShape modelC = getModel(tester); + expect(modelC.elevation, moreOrLessEquals(0.0, epsilon: 0.001)); + + await tester.pump(kThemeChangeDuration ~/ 2); + final RenderPhysicalShape modelD = getModel(tester); + expect(modelD.elevation, isNot(moreOrLessEquals(0.0, epsilon: 0.001))); + + await tester.pump(kThemeChangeDuration); + final RenderPhysicalShape modelE = getModel(tester); + expect(modelE.elevation, equals(9.0)); + }); + + testWidgets('Shadow colors animate smoothly', (WidgetTester tester) async { + // This code verifies that the PhysicalModel's shadowColor animates over + // a kThemeChangeDuration time interval. + + await tester.pumpWidget(buildMaterial()); + final RenderPhysicalShape modelA = getModel(tester); + expect(modelA.shadowColor, equals(const Color(0xFF00FF00))); + + await tester.pumpWidget(buildMaterial(shadowColor: const Color(0xFFFF0000))); + final RenderPhysicalShape modelB = getModel(tester); + expect(modelB.shadowColor, equals(const Color(0xFF00FF00))); + + await tester.pump(const Duration(milliseconds: 1)); + final RenderPhysicalShape modelC = getModel(tester); + expect(modelC.shadowColor, within<Color>(distance: 1, from: const Color(0xFF00FF00))); + + await tester.pump(kThemeChangeDuration ~/ 2); + final RenderPhysicalShape modelD = getModel(tester); + expect(modelD.shadowColor, isNot(within<Color>(distance: 1, from: const Color(0xFF00FF00)))); + + await tester.pump(kThemeChangeDuration); + final RenderPhysicalShape modelE = getModel(tester); + expect(modelE.shadowColor, equals(const Color(0xFFFF0000))); + }); + + testWidgets('Transparent material widget does not absorb hit test', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/58665. + var pressed = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: <Widget>[ + ElevatedButton( + onPressed: () { + pressed = true; + }, + child: null, + ), + const Material( + type: MaterialType.transparency, + child: SizedBox(width: 400.0, height: 500.0), + ), + ], + ), + ), + ), + ); + await tester.tap(find.byType(ElevatedButton)); + expect(pressed, isTrue); + }); + + testWidgets('Material uses the dark SystemUIOverlayStyle when the background is light', ( + WidgetTester tester, + ) async { + final lightTheme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + home: const Scaffold(body: Center(child: Text('test'))), + ), + ); + + expect(lightTheme.colorScheme.brightness, Brightness.light); + expect(SystemChrome.latestStyle, SystemUiOverlayStyle.dark); + }); + + testWidgets('Material uses the light SystemUIOverlayStyle when the background is dark', ( + WidgetTester tester, + ) async { + final darkTheme = ThemeData.dark(); + await tester.pumpWidget( + MaterialApp( + theme: darkTheme, + home: const Scaffold(body: Center(child: Text('test'))), + ), + ); + + expect(darkTheme.colorScheme.brightness, Brightness.dark); + expect(SystemChrome.latestStyle, SystemUiOverlayStyle.light); + }); + + group('Surface Tint Overlay', () { + testWidgets( + 'applyElevationOverlayColor does not effect anything with useMaterial3 set to true', + (WidgetTester tester) async { + const surfaceColor = Color(0xFF121212); + await tester.pumpWidget( + Theme( + data: ThemeData( + applyElevationOverlayColor: true, + colorScheme: const ColorScheme.dark().copyWith(surface: surfaceColor), + ), + child: buildMaterial(color: surfaceColor, elevation: 8.0), + ), + ); + final RenderPhysicalShape model = getModel(tester); + expect(model.color, equals(surfaceColor)); + }, + ); + + testWidgets('surfaceTintColor is used to as an overlay to indicate elevation', ( + WidgetTester tester, + ) async { + const baseColor = Color(0xFF121212); + const surfaceTintColor = Color(0xff44CCFF); + + // With no surfaceTintColor specified, it should not apply an overlay + await tester.pumpWidget( + Theme( + data: ThemeData(), + child: buildMaterial(color: baseColor, elevation: 12.0), + ), + ); + await tester.pumpAndSettle(); + final RenderPhysicalShape noTintModel = getModel(tester); + expect(noTintModel.color, equals(baseColor)); + + // With transparent surfaceTintColor, it should not apply an overlay + await tester.pumpWidget( + Theme( + data: ThemeData(), + child: buildMaterial( + color: baseColor, + surfaceTintColor: Colors.transparent, + elevation: 12.0, + ), + ), + ); + await tester.pumpAndSettle(); + final RenderPhysicalShape transparentTintModel = getModel(tester); + expect(transparentTintModel.color, equals(baseColor)); + + // With surfaceTintColor specified, it should not apply an overlay based + // on the elevation. + await tester.pumpWidget( + Theme( + data: ThemeData(), + child: buildMaterial( + color: baseColor, + surfaceTintColor: surfaceTintColor, + elevation: 12.0, + ), + ), + ); + await tester.pumpAndSettle(); + final RenderPhysicalShape tintModel = getModel(tester); + + // Final color should be the base with a tint of 0.14 opacity or 0xff192c33 + expect(tintModel.color, isSameColorAs(const Color(0xff192c33))); + }); + }); // Surface Tint Overlay group + + group('Elevation Overlay M2', () { + // These tests only apply to the Material 2 overlay mechanism. This group + // can be removed after migration to Material 3 is complete. + testWidgets('applyElevationOverlayColor set to false does not change surface color', ( + WidgetTester tester, + ) async { + const surfaceColor = Color(0xFF121212); + await tester.pumpWidget( + Theme( + data: ThemeData( + useMaterial3: false, + applyElevationOverlayColor: false, + colorScheme: const ColorScheme.dark().copyWith(surface: surfaceColor), + ), + child: buildMaterial(color: surfaceColor, elevation: 8.0), + ), + ); + final RenderPhysicalShape model = getModel(tester); + expect(model.color, equals(surfaceColor)); + }); + + testWidgets( + 'applyElevationOverlayColor set to true applies a semi-transparent onSurface color to the surface color', + (WidgetTester tester) async { + const surfaceColor = Color(0xFF121212); + const Color onSurfaceColor = Colors.greenAccent; + + // The colors we should get with a base surface color of 0xFF121212 for + // and a given elevation + const elevationColors = <ElevationColor>[ + ElevationColor(0.0, Color(0xFF121212)), + ElevationColor(1.0, Color(0xFF161D19)), + ElevationColor(2.0, Color(0xFF18211D)), + ElevationColor(3.0, Color(0xFF19241E)), + ElevationColor(4.0, Color(0xFF1A2620)), + ElevationColor(6.0, Color(0xFF1B2922)), + ElevationColor(8.0, Color(0xFF1C2C24)), + ElevationColor(12.0, Color(0xFF1D3027)), + ElevationColor(16.0, Color(0xFF1E3329)), + ElevationColor(24.0, Color(0xFF20362B)), + ]; + + for (final test in elevationColors) { + await tester.pumpWidget( + Theme( + data: ThemeData( + useMaterial3: false, + applyElevationOverlayColor: true, + colorScheme: const ColorScheme.dark().copyWith( + surface: surfaceColor, + onSurface: onSurfaceColor, + ), + ), + child: buildMaterial(color: surfaceColor, elevation: test.elevation), + ), + ); + await tester.pumpAndSettle(); // wait for the elevation animation to finish + final RenderPhysicalShape model = getModel(tester); + expect(model.color, isSameColorAs(test.color)); + } + }, + ); + + testWidgets('overlay will not apply to materials using a non-surface color', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Theme( + data: ThemeData( + useMaterial3: false, + applyElevationOverlayColor: true, + colorScheme: const ColorScheme.dark(), + ), + child: buildMaterial(color: Colors.cyan, elevation: 8.0), + ), + ); + final RenderPhysicalShape model = getModel(tester); + // Shouldn't change, as it is not using a ColorScheme.surface color + expect(model.color, equals(Colors.cyan)); + }); + + testWidgets('overlay will not apply to materials using a light theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Theme( + data: ThemeData( + useMaterial3: false, + applyElevationOverlayColor: true, + colorScheme: const ColorScheme.light(), + ), + child: buildMaterial(color: Colors.cyan, elevation: 8.0), + ), + ); + final RenderPhysicalShape model = getModel(tester); + // Shouldn't change, as it was under a light color scheme. + expect(model.color, equals(Colors.cyan)); + }); + + testWidgets('overlay will apply to materials with a non-opaque surface color', ( + WidgetTester tester, + ) async { + const surfaceColor = Color(0xFF121212); + const surfaceColorWithOverlay = Color(0xC6353535); + + await tester.pumpWidget( + Theme( + data: ThemeData( + useMaterial3: false, + applyElevationOverlayColor: true, + colorScheme: const ColorScheme.dark(), + ), + child: buildMaterial(color: surfaceColor.withOpacity(.75), elevation: 8.0), + ), + ); + + final RenderPhysicalShape model = getModel(tester); + expect(model.color, isSameColorAs(surfaceColorWithOverlay)); + expect(model.color, isNot(isSameColorAs(surfaceColor))); + }); + + testWidgets('Expected overlay color can be computed using colorWithOverlay', ( + WidgetTester tester, + ) async { + const surfaceColor = Color(0xFF123456); + const onSurfaceColor = Color(0xFF654321); + const elevation = 8.0; + + final Color surfaceColorWithOverlay = ElevationOverlay.colorWithOverlay( + surfaceColor, + onSurfaceColor, + elevation, + ); + + await tester.pumpWidget( + Theme( + data: ThemeData( + useMaterial3: false, + applyElevationOverlayColor: true, + colorScheme: const ColorScheme.dark(surface: surfaceColor, onSurface: onSurfaceColor), + ), + child: buildMaterial(color: surfaceColor, elevation: elevation), + ), + ); + + final RenderPhysicalShape model = getModel(tester); + expect(model.color, equals(surfaceColorWithOverlay)); + expect(model.color, isNot(equals(surfaceColor))); + }); + }); // Elevation Overlay M2 group + + group('Transparency clipping', () { + testWidgets('No clip by default', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.transparency, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + final RenderClipPath renderClip = tester.allRenderObjects.whereType<RenderClipPath>().first; + expect(renderClip.clipBehavior, equals(Clip.none)); + }); + + testWidgets('clips to bounding rect by default given Clip.antiAlias', ( + WidgetTester tester, + ) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect(find.byKey(materialKey), clipsWithBoundingRect); + }); + + testWidgets('clips to rounded rect when borderRadius provided given Clip.antiAlias', ( + WidgetTester tester, + ) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.transparency, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + clipBehavior: Clip.antiAlias, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + clipsWithBoundingRRect(borderRadius: const BorderRadius.all(Radius.circular(10.0))), + ); + }); + + testWidgets('clips to shape when provided given Clip.antiAlias', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.transparency, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect(find.byKey(materialKey), clipsWithShapeBorder(shape: const StadiumBorder())); + }); + + testWidgets('supports directional clips', (WidgetTester tester) async { + final logs = <String>[]; + final ShapeBorder shape = TestBorder((String message) { + logs.add(message); + }); + Widget buildMaterial() { + return Material( + type: MaterialType.transparency, + shape: shape, + clipBehavior: Clip.antiAlias, + child: const SizedBox(width: 100.0, height: 100.0), + ); + } + + final Widget material = buildMaterial(); + // verify that a regular clip works as one would expect + logs.add('--0'); + await tester.pumpWidget(material); + // verify that pumping again doesn't recompute the clip + // even though the widget itself is new (the shape doesn't change identity) + logs.add('--1'); + await tester.pumpWidget(buildMaterial()); + // verify that Material passes the TextDirection on to its shape when it's transparent + logs.add('--2'); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, child: material)); + // verify that changing the text direction from LTR to RTL has an effect + // even though the widget itself is identical + logs.add('--3'); + await tester.pumpWidget(Directionality(textDirection: TextDirection.rtl, child: material)); + // verify that pumping again with a text direction has no effect + logs.add('--4'); + await tester.pumpWidget( + Directionality(textDirection: TextDirection.rtl, child: buildMaterial()), + ); + logs.add('--5'); + // verify that changing the text direction and the widget at the same time + // works as expected + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, child: material)); + expect(logs, <String>[ + '--0', + 'getOuterPath Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) null', + 'paint Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) null', + '--1', + '--2', + 'getOuterPath Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) TextDirection.ltr', + 'paint Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) TextDirection.ltr', + '--3', + 'getOuterPath Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) TextDirection.rtl', + 'paint Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) TextDirection.rtl', + '--4', + '--5', + 'getOuterPath Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) TextDirection.ltr', + 'paint Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) TextDirection.ltr', + ]); + }); + }); + + group('PhysicalModels', () { + testWidgets('canvas', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material(key: materialKey, child: const SizedBox(width: 100.0, height: 100.0)), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel( + shape: BoxShape.rectangle, + borderRadius: BorderRadius.zero, + elevation: 0.0, + ), + ); + }); + + testWidgets('canvas with borderRadius and elevation', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + elevation: 1.0, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + elevation: 1.0, + ), + ); + }); + + testWidgets('canvas with shape and elevation', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + shape: const StadiumBorder(), + elevation: 1.0, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalShape(shape: const StadiumBorder(), elevation: 1.0), + ); + }); + + testWidgets('card', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.card, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(2.0)), + elevation: 0.0, + ), + ); + }); + + testWidgets('card with borderRadius and elevation', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.card, + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + elevation: 5.0, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + elevation: 5.0, + ), + ); + }); + + testWidgets('card with shape and elevation', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.card, + shape: const StadiumBorder(), + elevation: 5.0, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalShape(shape: const StadiumBorder(), elevation: 5.0), + ); + }); + + testWidgets('circle', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.circle, + color: const Color(0xFF0000FF), + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel(shape: BoxShape.circle, elevation: 0.0), + ); + }); + + testWidgets('button', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.button, + color: const Color(0xFF0000FF), + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(2.0)), + elevation: 0.0, + ), + ); + }); + + testWidgets('button with elevation and borderRadius', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.button, + color: const Color(0xFF0000FF), + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + elevation: 4.0, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalModel( + shape: BoxShape.rectangle, + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + elevation: 4.0, + ), + ); + }); + + testWidgets('button with elevation and shape', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.button, + color: const Color(0xFF0000FF), + shape: const StadiumBorder(), + elevation: 4.0, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + expect( + find.byKey(materialKey), + rendersOnPhysicalShape(shape: const StadiumBorder(), elevation: 4.0), + ); + }); + }); + + group('Border painting', () { + testWidgets('border is painted on physical layers', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.button, + color: const Color(0xFF0000FF), + shape: const CircleBorder(side: BorderSide(width: 2.0, color: Color(0xFF0000FF))), + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + final RenderBox box = tester.renderObject(find.byKey(materialKey)); + expect(box, paints..circle()); + }); + + testWidgets('border is painted for transparent material', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.transparency, + shape: const CircleBorder(side: BorderSide(width: 2.0, color: Color(0xFF0000FF))), + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + final RenderBox box = tester.renderObject(find.byKey(materialKey)); + expect(box, paints..circle()); + }); + + testWidgets('border is not painted for when border side is none', (WidgetTester tester) async { + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + type: MaterialType.transparency, + shape: const CircleBorder(), + child: const SizedBox(width: 100.0, height: 100.0), + ), + ); + + final RenderBox box = tester.renderObject(find.byKey(materialKey)); + expect(box, isNot(paints..circle())); + }); + + testWidgets('Material2 - border is painted above child by default', ( + WidgetTester tester, + ) async { + final Key painterKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Card( + child: SizedBox( + width: 200, + height: 300, + child: Material( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + side: BorderSide(color: Colors.grey, width: 6), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Column(children: <Widget>[Container(color: Colors.green, height: 150)]), + ), + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byKey(painterKey), + matchesGoldenFile('m2_material.border_paint_above.png'), + ); + }); + + testWidgets('Material3 - border is painted above child by default', ( + WidgetTester tester, + ) async { + final Key painterKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Card( + child: SizedBox( + width: 200, + height: 300, + child: Material( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + side: BorderSide(color: Colors.grey, width: 6), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Column(children: <Widget>[Container(color: Colors.green, height: 150)]), + ), + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byKey(painterKey), + matchesGoldenFile('m3_material.border_paint_above.png'), + ); + }); + + testWidgets('Material2 - border is painted below child when specified', ( + WidgetTester tester, + ) async { + final Key painterKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Card( + child: SizedBox( + width: 200, + height: 300, + child: Material( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + side: BorderSide(color: Colors.grey, width: 6), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + borderOnForeground: false, + child: Column(children: <Widget>[Container(color: Colors.green, height: 150)]), + ), + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byKey(painterKey), + matchesGoldenFile('m2_material.border_paint_below.png'), + ); + }); + + testWidgets('Material3 - border is painted below child when specified', ( + WidgetTester tester, + ) async { + final Key painterKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Card( + child: SizedBox( + width: 200, + height: 300, + child: Material( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + side: BorderSide(color: Colors.grey, width: 6), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + borderOnForeground: false, + child: Column(children: <Widget>[Container(color: Colors.green, height: 150)]), + ), + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byKey(painterKey), + matchesGoldenFile('m3_material.border_paint_below.png'), + ); + }); + }); + + testWidgets('InkFeature skips painting if intermediate node skips', (WidgetTester tester) async { + final GlobalKey sizedBoxKey = GlobalKey(); + final GlobalKey materialKey = GlobalKey(); + await tester.pumpWidget( + Material( + key: materialKey, + child: Offstage(child: SizedBox(key: sizedBoxKey, width: 20, height: 20)), + ), + ); + final MaterialInkController controller = Material.of(sizedBoxKey.currentContext!); + + final tracker = TrackPaintInkFeature( + controller: controller, + referenceBox: sizedBoxKey.currentContext!.findRenderObject()! as RenderBox, + ); + controller.addInkFeature(tracker); + expect(tracker.paintCount, 0); + + final layer1 = ContainerLayer(); + addTearDown(layer1.dispose); + + // Force a repaint. Since it's offstage, the ink feature should not get painted. + materialKey.currentContext!.findRenderObject()!.paint( + PaintingContext(layer1, Rect.largest), + Offset.zero, + ); + expect(tracker.paintCount, 0); + + await tester.pumpWidget( + Material( + key: materialKey, + child: Offstage(offstage: false, child: SizedBox(key: sizedBoxKey, width: 20, height: 20)), + ), + ); + // Gets a paint because the global keys have reused the elements and it is + // now onstage. + expect(tracker.paintCount, 1); + + final layer2 = ContainerLayer(); + addTearDown(layer2.dispose); + + // Force a repaint again. This time, it gets repainted because it is onstage. + materialKey.currentContext!.findRenderObject()!.paint( + PaintingContext(layer2, Rect.largest), + Offset.zero, + ); + expect(tracker.paintCount, 2); + + tracker.dispose(); + }); + + testWidgets('$InkFeature dispatches memory events', (WidgetTester tester) async { + await tester.pumpWidget(const Material(child: SizedBox(width: 20, height: 20))); + + final Element element = tester.element(find.byType(SizedBox)); + final MaterialInkController controller = Material.of(element); + final referenceBox = element.findRenderObject()! as RenderBox; + + await expectLater( + await memoryEvents( + () => _InkFeature(controller: controller, referenceBox: referenceBox).dispose(), + _InkFeature, + ), + areCreateAndDispose, + ); + }); + + group('LookupBoundary', () { + testWidgets('hides Material from Material.maybeOf', (WidgetTester tester) async { + MaterialInkController? material; + + await tester.pumpWidget( + Material( + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + material = Material.maybeOf(context); + return Container(); + }, + ), + ), + ), + ); + + expect(material, isNull); + }); + + testWidgets('hides Material from Material.of', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + Material.of(context); + return Container(); + }, + ), + ), + ), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final error = exception! as FlutterError; + + expect( + error.toStringDeep(), + 'FlutterError\n' + ' Material.of() was called with a context that does not have access\n' + ' to a Material widget.\n' + ' The context provided to Material.of() does have a Material widget\n' + ' ancestor, but it is hidden by a LookupBoundary. This can happen\n' + ' because you are using a widget that looks for a Material\n' + ' ancestor, but no such ancestor exists within the closest\n' + ' LookupBoundary.\n' + ' The context used was:\n' + ' Builder(dirty)\n', + ); + }); + + testWidgets('hides Material from debugCheckHasMaterial', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + debugCheckHasMaterial(context); + return Container(); + }, + ), + ), + ), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final error = exception! as FlutterError; + + expect( + error.toStringDeep(), + startsWith( + 'FlutterError\n' + ' No Material widget found within the closest LookupBoundary.\n' + ' There is an ancestor Material widget, but it is hidden by a\n' + ' LookupBoundary.\n' + ' Builder widgets require a Material widget ancestor within the\n' + ' closest LookupBoundary.\n' + ' In Material Design, most widgets are conceptually "printed" on a\n' + " sheet of material. In Flutter's material library, that material\n" + ' is represented by the Material widget. It is the Material widget\n' + ' that renders ink splashes, for instance. Because of this, many\n' + ' material library widgets require that there be a Material widget\n' + ' in the tree above them.\n' + ' To introduce a Material widget, you can either directly include\n' + ' one, or use a widget that contains Material itself, such as a\n' + ' Card, Dialog, Drawer, or Scaffold.\n' + ' The specific widget that could not find a Material ancestor was:\n' + ' Builder\n' + ' The ancestors of this widget were:\n' + ' LookupBoundary\n', + ), + ); + }); + }); + + testWidgets('Material is not visible from sub-views', (WidgetTester tester) async { + MaterialInkController? outsideView; + MaterialInkController? insideView; + MaterialInkController? outsideViewAnchor; + + await tester.pumpWidget( + Material( + child: Builder( + builder: (BuildContext context) { + outsideViewAnchor = Material.maybeOf(context); + return ViewAnchor( + view: Builder( + builder: (BuildContext context) { + outsideView = Material.maybeOf(context); + return View( + view: FakeView(tester.view), + child: Builder( + builder: (BuildContext context) { + insideView = Material.maybeOf(context); + return const SizedBox(); + }, + ), + ); + }, + ), + child: const SizedBox(), + ); + }, + ), + ), + ); + + expect(outsideViewAnchor, isNotNull); + expect(outsideView, isNull); + expect(insideView, isNull); + }); + + testWidgets('Material does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox(child: Material())), + ), + ); + expect(tester.getSize(find.byType(Material)), Size.zero); + }); +} + +class TrackPaintInkFeature extends InkFeature { + TrackPaintInkFeature({required super.controller, required super.referenceBox}); + + int paintCount = 0; + @override + void paintFeature(Canvas canvas, Matrix4 transform) { + paintCount += 1; + } +} + +class _InkFeature extends InkFeature { + _InkFeature({required super.controller, required super.referenceBox}) { + controller.addInkFeature(this); + } + + @override + void paintFeature(Canvas canvas, Matrix4 transform) {} +} diff --git a/packages/material_ui/test/material/menu_anchor_test.dart b/packages/material_ui/test/material/menu_anchor_test.dart new file mode 100644 index 000000000000..0fa5413a3899 --- /dev/null +++ b/packages/material_ui/test/material/menu_anchor_test.dart @@ -0,0 +1,7128 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker/leak_tracker.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + late MenuController controller; + String? focusedMenu; + final selected = <TestMenu>[]; + final opened = <TestMenu>[]; + final closed = <TestMenu>[]; + final GlobalKey menuItemKey = GlobalKey(); + + void onPressed(TestMenu item) { + selected.add(item); + } + + void onOpen(TestMenu item) { + opened.add(item); + } + + void onClose(TestMenu item) { + closed.add(item); + } + + void handleFocusChange() { + focusedMenu = (primaryFocus?.debugLabel ?? primaryFocus).toString(); + } + + setUp(() { + focusedMenu = null; + selected.clear(); + opened.clear(); + closed.clear(); + controller = MenuController(); + focusedMenu = null; + }); + + Future<void> changeSurfaceSize(WidgetTester tester, Size size) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + } + + void listenForFocusChanges() { + FocusManager.instance.addListener(handleFocusChange); + addTearDown(() => FocusManager.instance.removeListener(handleFocusChange)); + } + + Finder findMenuPanels() { + return find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_MenuPanel'); + } + + List<RenderObject> ancestorRenderTheaters(RenderObject child) { + final results = <RenderObject>[]; + RenderObject? node = child; + while (node != null) { + if (node.runtimeType.toString() == '_RenderTheater') { + results.add(node); + } + final RenderObject? parent = node.parent; + node = parent is RenderObject ? parent : null; + } + return results; + } + + Finder findMenuBarItemLabels() { + return find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_MenuItemLabel', + ); + } + + // Finds the mnemonic associated with the menu item that has the given label. + Finder findMnemonic(String label) { + return find + .descendant( + of: find.ancestor(of: find.text(label), matching: findMenuBarItemLabels()), + matching: find.byType(Text), + ) + .last; + } + + Widget buildTestApp({ + AlignmentGeometry? alignment, + Offset alignmentOffset = Offset.zero, + TextDirection textDirection = TextDirection.ltr, + bool consumesOutsideTap = false, + void Function(TestMenu)? onPressed, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, + }) { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: textDirection, + child: Column( + children: <Widget>[ + GestureDetector( + onTap: () { + onPressed?.call(TestMenu.outsideButton); + }, + child: Text(TestMenu.outsideButton.label), + ), + MenuAnchor( + childFocusNode: focusNode, + controller: controller, + alignmentOffset: alignmentOffset, + consumeOutsideTap: consumesOutsideTap, + style: MenuStyle(alignment: alignment), + onOpen: () { + onOpen?.call(TestMenu.anchorButton); + }, + onClose: () { + onClose?.call(TestMenu.anchorButton); + }, + menuChildren: <Widget>[ + MenuItemButton( + key: menuItemKey, + shortcut: const SingleActivator(LogicalKeyboardKey.keyB, control: true), + onPressed: () { + onPressed?.call(TestMenu.subMenu00); + }, + child: Text(TestMenu.subMenu00.label), + ), + MenuItemButton( + leadingIcon: const Icon(Icons.send), + trailingIcon: const Icon(Icons.mail), + onPressed: () { + onPressed?.call(TestMenu.subMenu01); + }, + child: Text(TestMenu.subMenu01.label), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return ElevatedButton( + focusNode: focusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + onPressed?.call(TestMenu.anchorButton); + }, + child: child, + ); + }, + child: Text(TestMenu.anchorButton.label), + ), + ], + ), + ), + ), + ); + } + + Future<TestGesture> hoverOver(WidgetTester tester, Finder finder) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(finder)); + await tester.pump(); + return gesture; + } + + Material getMenuBarMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findMenuPanels(), matching: find.byType(Material)).first, + ); + } + + RenderObject getOverlayColor(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + testWidgets('Menu responds to density changes', (WidgetTester tester) async { + Widget buildMenu({VisualDensity? visualDensity = VisualDensity.standard}) { + return MaterialApp( + theme: ThemeData(visualDensity: visualDensity, useMaterial3: false), + home: Material( + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildMenu()); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), + ); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), + ); + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), + ); + expect( + tester.getRect(find.widgetWithText(MenuItemButton, TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(257.0, 56.0, 471.0, 104.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(257.0, 48.0, 471.0, 208.0)), + ); + + // Test compact visual density (-2, -2). + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildMenu(visualDensity: VisualDensity.compact)); + await tester.pump(); + + // The original horizontal padding with standard visual density for menu buttons are 12 px, and the total length + // for the menu bar is (655 - 145) = 510. + // There are 4 buttons in the test menu bar, and with compact visual density, + // the padding will reduce by abs(2 * (-2)) = 4. So the total length + // now should reduce by abs(4 * 2 * (-4)) = 32, which would be 510 - 32 = 478, and + // 478 = 639 - 161 + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(161.0, 0.0, 639.0, 40.0)), + ); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(161.0, 0.0, 639.0, 40.0)), + ); + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(161.0, 0.0, 639.0, 40.0)), + ); + expect( + tester.getRect(find.widgetWithText(MenuItemButton, TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(265.0, 48.0, 467.0, 88.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(265.0, 40.0, 467.0, 176.0)), + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildMenu(visualDensity: const VisualDensity(horizontal: 2.0, vertical: 2.0)), + ); + await tester.pump(); + + // Similarly, there are 4 buttons in the test menu bar, and with (2, 2) visual density, + // the padding will increase by abs(2 * 4) = 8. So the total length for buttons + // should increase by abs(4 * 2 * 8) = 64. The horizontal padding for the menu bar + // increases by 2 * 8, so the total width increases to 510 + 64 + 16 = 590, and + // 590 = 695 - 105 + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(105.0, 0.0, 695.0, 56.0)), + ); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(105.0, 0.0, 695.0, 56.0)), + ); + expect( + tester.getRect(find.widgetWithText(MenuItemButton, TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(257.0, 64.0, 491.0, 120.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(249.0, 56.0, 499.0, 240.0)), + ); + }); + + testWidgets('Menu defaults', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + // Menu bar (horizontal menu). + Finder menuMaterial = find + .ancestor(of: find.byType(TextButton), matching: find.byType(Material)) + .first; + + Material material = tester.widget<Material>(menuMaterial); + expect(opened, isEmpty); + expect(material.color, themeData.colorScheme.surfaceContainer); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 3.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + Finder buttonMaterial = find + .descendant(of: find.byType(TextButton), matching: find.byType(Material)) + .first; + material = tester.widget<Material>(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface); + expect(material.textStyle?.fontSize, 14.0); + expect(material.textStyle?.height, 1.43); + + // Vertical menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + menuMaterial = find + .ancestor( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; + + material = tester.widget<Material>(menuMaterial); + expect(opened.last, equals(TestMenu.mainMenu1)); + expect(material.color, themeData.colorScheme.surfaceContainer); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 3.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; + material = tester.widget<Material>(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface); + expect(material.textStyle?.fontSize, 14.0); + expect(material.textStyle?.height, 1.43); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + expect(find.byIcon(Icons.add), findsOneWidget); + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(Icons.add), matching: find.byType(RichText)), + ); + expect(iconRichText.text.style?.color, themeData.colorScheme.onSurfaceVariant); + }); + + testWidgets('Menu defaults - disabled', (WidgetTester tester) async { + final themeData = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + // Menu bar (horizontal menu). + Finder menuMaterial = find + .ancestor( + of: find.widgetWithText(TextButton, TestMenu.mainMenu5.label), + matching: find.byType(Material), + ) + .first; + + Material material = tester.widget<Material>(menuMaterial); + expect(opened, isEmpty); + expect(material.color, themeData.colorScheme.surfaceContainer); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 3.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + Finder buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.mainMenu5.label), + matching: find.byType(Material), + ) + .first; + material = tester.widget<Material>(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface.withOpacity(0.38)); + + // Vertical menu. + await tester.tap(find.text(TestMenu.mainMenu2.label)); + await tester.pump(); + + menuMaterial = find + .ancestor( + of: find.widgetWithText(TextButton, TestMenu.subMenu20.label), + matching: find.byType(Material), + ) + .first; + + material = tester.widget<Material>(menuMaterial); + expect(material.color, themeData.colorScheme.surfaceContainer); + expect(material.shadowColor, themeData.colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.elevation, 3.0); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu20.label), + matching: find.byType(Material), + ) + .first; + material = tester.widget<Material>(buttonMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle?.color, themeData.colorScheme.onSurface.withOpacity(0.38)); + + expect(find.byIcon(Icons.ac_unit), findsOneWidget); + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(Icons.ac_unit), matching: find.byType(RichText)), + ); + expect(iconRichText.text.style?.color, themeData.colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('Menu scrollbar inherits ScrollbarTheme', (WidgetTester tester) async { + const scrollbarTheme = ScrollbarThemeData( + thumbColor: MaterialStatePropertyAll<Color?>(Color(0xffff0000)), + thumbVisibility: MaterialStatePropertyAll<bool?>(true), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scrollbarTheme: scrollbarTheme), + home: Material( + child: MenuBar( + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton( + style: ButtonStyle( + minimumSize: WidgetStateProperty.all<Size>(const Size.fromHeight(1000)), + ), + onPressed: () {}, + child: const Text('Category'), + ), + ], + child: const Text('Main Menu'), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Main Menu')); + await tester.pumpAndSettle(); + + // Test Scrollbar thumb color. + expect(find.byType(Scrollbar).last, paints..rrect(color: const Color(0xffff0000))); + + // Close the menu. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scrollbarTheme: scrollbarTheme), + home: Material( + child: ScrollbarTheme( + data: scrollbarTheme.copyWith( + thumbColor: const MaterialStatePropertyAll<Color?>(Color(0xff00ff00)), + ), + child: MenuBar( + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton( + style: ButtonStyle( + minimumSize: WidgetStateProperty.all<Size>(const Size.fromHeight(1000)), + ), + onPressed: () {}, + child: const Text('Category'), + ), + ], + child: const Text('Main Menu'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Main Menu')); + await tester.pumpAndSettle(); + + // Scrollbar thumb color should be updated. + expect(find.byType(Scrollbar).last, paints..rrect(color: const Color(0xff00ff00))); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Focus is returned to previous focus before invoking onPressed', ( + WidgetTester tester, + ) async { + final buttonFocus = FocusNode(debugLabel: 'Button Focus'); + addTearDown(buttonFocus.dispose); + FocusNode? focusInOnPressed; + + void onMenuSelected(TestMenu item) { + focusInOnPressed = FocusManager.instance.primaryFocus; + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBar( + controller: controller, + children: createTestMenus(onPressed: onMenuSelected), + ), + ElevatedButton( + autofocus: true, + onPressed: () {}, + focusNode: buttonFocus, + child: const Text('Press Me'), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + expect(FocusManager.instance.primaryFocus, equals(buttonFocus)); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subSubMenu110.label)); + await tester.pump(); + + expect(focusInOnPressed, equals(buttonFocus)); + expect(FocusManager.instance.primaryFocus, equals(buttonFocus)); + }); + + group('Menu functions', () { + testWidgets('basic menu structure', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + expect(find.text(TestMenu.mainMenu0.label), findsOneWidget); + expect(find.text(TestMenu.mainMenu1.label), findsOneWidget); + expect(find.text(TestMenu.mainMenu2.label), findsOneWidget); + expect(find.text(TestMenu.subMenu10.label), findsNothing); + expect(find.text(TestMenu.subSubMenu110.label), findsNothing); + expect(opened, isEmpty); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect(find.text(TestMenu.mainMenu0.label), findsOneWidget); + expect(find.text(TestMenu.mainMenu1.label), findsOneWidget); + expect(find.text(TestMenu.mainMenu2.label), findsOneWidget); + expect(find.text(TestMenu.subMenu10.label), findsOneWidget); + expect(find.text(TestMenu.subMenu11.label), findsOneWidget); + expect(find.text(TestMenu.subMenu12.label), findsOneWidget); + expect(find.text(TestMenu.subSubMenu110.label), findsNothing); + expect(find.text(TestMenu.subSubMenu111.label), findsNothing); + expect(find.text(TestMenu.subSubMenu112.label), findsNothing); + expect(opened.last, equals(TestMenu.mainMenu1)); + opened.clear(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(find.text(TestMenu.mainMenu0.label), findsOneWidget); + expect(find.text(TestMenu.mainMenu1.label), findsOneWidget); + expect(find.text(TestMenu.mainMenu2.label), findsOneWidget); + expect(find.text(TestMenu.subMenu10.label), findsOneWidget); + expect(find.text(TestMenu.subMenu11.label), findsOneWidget); + expect(find.text(TestMenu.subMenu12.label), findsOneWidget); + expect(find.text(TestMenu.subSubMenu110.label), findsOneWidget); + expect(find.text(TestMenu.subSubMenu111.label), findsOneWidget); + expect(find.text(TestMenu.subSubMenu112.label), findsOneWidget); + expect(opened.last, equals(TestMenu.subMenu11)); + }); + + testWidgets('geometry', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: MenuBar(children: createTestMenus(onPressed: onPressed)), + ), + ], + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(0, 0, 800, 48))); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect(tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(0, 0, 800, 48))); + expect( + tester.getRect(find.text(TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(124.0, 73.0, 314.0, 87.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(112.0, 48.0, 326.0, 208.0)), + ); + + // Test menu bar size when not expanded. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), + ); + }); + + testWidgets('geometry with RTL direction', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: MenuBar(children: createTestMenus(onPressed: onPressed)), + ), + ], + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(0, 0, 800, 48))); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect(tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(0, 0, 800, 48))); + expect( + tester.getRect(find.text(TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(486.0, 73.0, 676.0, 87.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(474.0, 48.0, 688.0, 208.0)), + ); + + // Close and make sure it goes back where it was. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect(tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(0, 0, 800, 48))); + + // Test menu bar size when not expanded. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), + ); + }); + + testWidgets('menu alignment and offset in LTR', (WidgetTester tester) async { + await tester.pumpWidget(buildTestApp()); + + final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); + + final Finder findMenuScope = find + .ancestor(of: find.byKey(menuItemKey), matching: find.byType(FocusScope)) + .first; + + // Open the menu and make sure things are the right size, in the right place. + await tester.tap(find.text('Press Me')); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(328.0, 62.0, 602.0, 174.0))); + + await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.topStart)); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(328.0, 14.0, 602.0, 126.0))); + + await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.center)); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(400.0, 38.0, 674.0, 150.0))); + + await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.bottomEnd)); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(472.0, 62.0, 746.0, 174.0))); + + await tester.pumpWidget(buildTestApp(alignment: AlignmentDirectional.topStart)); + await tester.pump(); + + final Rect menuRect = tester.getRect(findMenuScope); + await tester.pumpWidget( + buildTestApp( + alignment: AlignmentDirectional.topStart, + alignmentOffset: const Offset(10, 20), + ), + ); + await tester.pump(); + final Rect offsetMenuRect = tester.getRect(findMenuScope); + expect(offsetMenuRect.topLeft - menuRect.topLeft, equals(const Offset(10, 20))); + }); + + testWidgets('menu alignment and offset in RTL', (WidgetTester tester) async { + await tester.pumpWidget(buildTestApp(textDirection: TextDirection.rtl)); + + final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); + + final Finder findMenuScope = find + .ancestor(of: find.text(TestMenu.subMenu00.label), matching: find.byType(FocusScope)) + .first; + + // Open the menu and make sure things are the right size, in the right place. + await tester.tap(find.text('Press Me')); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(198.0, 62.0, 472.0, 174.0))); + + await tester.pumpWidget( + buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.topStart), + ); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(198.0, 14.0, 472.0, 126.0))); + + await tester.pumpWidget( + buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.center), + ); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(126.0, 38.0, 400.0, 150.0))); + + await tester.pumpWidget( + buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.bottomEnd), + ); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(54.0, 62.0, 328.0, 174.0))); + + await tester.pumpWidget( + buildTestApp(textDirection: TextDirection.rtl, alignment: AlignmentDirectional.topStart), + ); + await tester.pump(); + + final Rect menuRect = tester.getRect(findMenuScope); + await tester.pumpWidget( + buildTestApp( + textDirection: TextDirection.rtl, + alignment: AlignmentDirectional.topStart, + alignmentOffset: const Offset(10, 20), + ), + ); + await tester.pump(); + expect( + tester.getRect(findMenuScope).topLeft - menuRect.topLeft, + equals(const Offset(-10, 20)), + ); + }); + + testWidgets('menu position in LTR', (WidgetTester tester) async { + await tester.pumpWidget(buildTestApp(alignmentOffset: const Offset(100, 50))); + + final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); + + final Finder findMenuScope = find + .ancestor(of: find.text(TestMenu.subMenu00.label), matching: find.byType(FocusScope)) + .first; + + // Open the menu and make sure things are the right size, in the right place. + await tester.tap(find.text('Press Me')); + await tester.pump(); + expect( + tester.getRect(findMenuScope), + equals(const Rect.fromLTRB(428.0, 112.0, 702.0, 224.0)), + ); + + // Now move the menu by calling open() again with a local position on the + // anchor. + controller.open(position: const Offset(200, 200)); + await tester.pump(); + expect( + tester.getRect(findMenuScope), + equals(const Rect.fromLTRB(526.0, 214.0, 800.0, 326.0)), + ); + }); + + testWidgets('menu position in RTL', (WidgetTester tester) async { + await tester.pumpWidget( + buildTestApp(alignmentOffset: const Offset(100, 50), textDirection: TextDirection.rtl), + ); + + final Rect buttonRect = tester.getRect(find.byType(ElevatedButton)); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); + expect(buttonRect, equals(const Rect.fromLTRB(328.0, 14.0, 472.0, 62.0))); + + final Finder findMenuScope = find + .ancestor(of: find.text(TestMenu.subMenu00.label), matching: find.byType(FocusScope)) + .first; + + // Open the menu and make sure things are the right size, in the right place. + await tester.tap(find.text('Press Me')); + await tester.pump(); + expect(tester.getRect(findMenuScope), equals(const Rect.fromLTRB(98.0, 112.0, 372.0, 224.0))); + + // Now move the menu by calling open() again with a local position on the + // anchor. + controller.open(position: const Offset(400, 200)); + await tester.pump(); + expect( + tester.getRect(findMenuScope), + equals(const Rect.fromLTRB(526.0, 214.0, 800.0, 326.0)), + ); + }); + + testWidgets('works with Padding around menu and overlay', (WidgetTester tester) async { + await tester.pumpWidget( + Padding( + padding: const EdgeInsets.all(10.0), + child: MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Column( + children: <Widget>[ + Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: <Widget>[ + Expanded( + child: MenuBar(children: createTestMenus(onPressed: onPressed)), + ), + ], + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(22.0, 22.0, 778.0, 70.0)), + ); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(22.0, 22.0, 778.0, 70.0)), + ); + expect( + tester.getRect(find.text(TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(146.0, 95.0, 336.0, 109.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(134.0, 70.0, 348.0, 230.0)), + ); + + // Close and make sure it goes back where it was. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(22.0, 22.0, 778.0, 70.0)), + ); + }); + + testWidgets('works with Padding around menu and overlay with RTL direction', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Padding( + padding: const EdgeInsets.all(10.0), + child: MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: Column( + children: <Widget>[ + Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: <Widget>[ + Expanded( + child: MenuBar(children: createTestMenus(onPressed: onPressed)), + ), + ], + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(22.0, 22.0, 778.0, 70.0)), + ); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(22.0, 22.0, 778.0, 70.0)), + ); + expect( + tester.getRect(find.text(TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(464.0, 95.0, 654.0, 109.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(452.0, 70.0, 666.0, 230.0)), + ); + + // Close and make sure it goes back where it was. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(22.0, 22.0, 778.0, 70.0)), + ); + }); + + testWidgets('visual attributes can be set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: MenuBar( + style: MenuStyle( + elevation: WidgetStateProperty.all<double?>(10), + backgroundColor: const MaterialStatePropertyAll<Color>(Colors.red), + ), + children: createTestMenus(onPressed: onPressed), + ), + ), + ], + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + expect(tester.getRect(findMenuPanels()), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 48.0))); + final Material material = getMenuBarMaterial(tester); + expect(material.elevation, equals(10)); + expect(material.color, equals(Colors.red)); + }); + + testWidgets('MenuAnchor clip behavior', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MenuAnchor( + menuChildren: const <Widget>[MenuItemButton(child: Text('Button 1'))], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + controller.open(); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Tap me')); + await tester.pump(); + + // Test default clip behavior. + expect(getMenuBarMaterial(tester).clipBehavior, equals(Clip.hardEdge)); + + // Close the menu. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MenuAnchor( + clipBehavior: Clip.antiAlias, + menuChildren: const <Widget>[MenuItemButton(child: Text('Button 1'))], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + controller.open(); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('Tap me')); + await tester.pump(); + + // Test custom clip behavior. + expect(getMenuBarMaterial(tester).clipBehavior, equals(Clip.antiAlias)); + }); + + testWidgets('open and close works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect(opened, equals(<TestMenu>[TestMenu.mainMenu1])); + expect(closed, isEmpty); + opened.clear(); + closed.clear(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.subMenu11])); + expect(closed, isEmpty); + opened.clear(); + closed.clear(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, equals(<TestMenu>[TestMenu.subMenu11])); + opened.clear(); + closed.clear(); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.mainMenu0])); + expect(closed, equals(<TestMenu>[TestMenu.mainMenu1])); + }); + + testWidgets('Menus close and consume tap when open and tapped outside', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildTestApp( + consumesOutsideTap: true, + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + ), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + // Doesn't consume tap when the menu is closed. + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + expect(selected, equals(<TestMenu>[TestMenu.outsideButton])); + selected.clear(); + + await tester.tap(find.text(TestMenu.anchorButton.label)); + await tester.pump(); + expect(opened, equals(<TestMenu>[TestMenu.anchorButton])); + expect(closed, isEmpty); + expect(selected, equals(<TestMenu>[TestMenu.anchorButton])); + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, equals(<TestMenu>[TestMenu.anchorButton])); + // When the menu is open, don't expect the outside button to be selected: + // it's supposed to consume the key down. + expect(selected, isEmpty); + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgets("Menus close and don't consume tap when open and tapped outside", ( + WidgetTester tester, + ) async { + await tester.pumpWidget(buildTestApp(onPressed: onPressed, onOpen: onOpen, onClose: onClose)); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + // Doesn't consume tap when the menu is closed. + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + expect(selected, equals(<TestMenu>[TestMenu.outsideButton])); + selected.clear(); + + await tester.tap(find.text(TestMenu.anchorButton.label)); + await tester.pump(); + expect(opened, equals(<TestMenu>[TestMenu.anchorButton])); + expect(closed, isEmpty); + expect(selected, equals(<TestMenu>[TestMenu.anchorButton])); + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(TestMenu.outsideButton.label)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, equals(<TestMenu>[TestMenu.anchorButton])); + // Because consumesOutsideTap is false, this is expected to receive its + // tap. + expect(selected, equals(<TestMenu>[TestMenu.outsideButton])); + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgets('select works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11])); + opened.clear(); + await tester.tap(find.text(TestMenu.subSubMenu110.label)); + await tester.pump(); + + expect(selected, equals(<TestMenu>[TestMenu.subSubMenu110])); + + // Selecting a non-submenu item should close all the menus. + expect(opened, isEmpty); + expect(find.text(TestMenu.subSubMenu110.label), findsNothing); + expect(find.text(TestMenu.subMenu11.label), findsNothing); + }); + + testWidgets('diagnostics', (WidgetTester tester) async { + const item = MenuItemButton( + shortcut: SingleActivator(LogicalKeyboardKey.keyA), + child: Text('label2'), + ); + final menuBar = MenuBar( + controller: controller, + style: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.red), + elevation: MaterialStatePropertyAll<double?>(10.0), + ), + children: const <Widget>[item], + ); + + await tester.pumpWidget(MaterialApp(home: Material(child: menuBar))); + await tester.pump(); + + final builder = DiagnosticPropertiesBuilder(); + menuBar.debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description.join('\n'), + equalsIgnoringHashCodes( + 'style: MenuStyle#00000(backgroundColor: WidgetStatePropertyAll(MaterialColor(primary value: ${const Color(0xfff44336)})), elevation: WidgetStatePropertyAll(10.0))\n' + 'clipBehavior: Clip.none', + ), + ); + }); + testWidgets('menus can be traversed multiple times', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/150334 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuItemButton( + autofocus: true, + onPressed: () {}, + child: const Text('External Focus'), + ), + MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("External Focus"))')); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + }); + + testWidgets('keyboard tab traversal works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pumpAndSettle(); + + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + opened.clear(); + closed.clear(); + + // Test closing a menu with enter. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(opened, isEmpty); + expect(closed, <TestMenu>[TestMenu.mainMenu0]); + }); + + testWidgets('keyboard directional traversal works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + listenForFocusChanges(); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Open the next submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + + // Go back, close the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Move up, should close the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + // Move down, should reopen the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Open the next submenu again. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 111"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 112"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))')); + + // Since this is a leaf off of a vertical menu, moving left should + // return to this menu's parent button. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Moving left while in a first-level submenu should focus the + // previous top-level menubar anchor. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + // Pressing arrowup from a top-level menubar anchor should focus the last + // item in that anchor's submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + await tester.pump(); + + // Enter the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + + // Move to next top-level menu button. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + }); + + testWidgets('keyboard directional traversal works in RTL mode', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ), + ); + + listenForFocusChanges(); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Open the next submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + + // Go back, close the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Move up, should close the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + // Move down, should reopen the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Open the next submenu again. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 111"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 112"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))')); + + // Since this is a leaf off of a vertical menu, moving right should + // return to this menu's parent button. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Moving left while in a first-level submenu should focus the + // previous top-level menubar anchor. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + // Pressing arrowup from a top-level menubar anchor should focus the last + // item in that anchor's submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + // Enter the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + + // Move to next top-level menu button. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + }); + + testWidgets('MenuAnchor tab traversal works', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/144381 + final buttonFocusNode = FocusNode(debugLabel: TestMenu.anchorButton.label); + addTearDown(buttonFocusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuAnchor( + childFocusNode: buttonFocusNode, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('start')), + ...createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Text(TestMenu.anchorButton.label), + ); + }, + ), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + + // Directional traversal doesn't work until a menu item is focused. + // To start focusing, hover over the first menu item. + await hoverOver(tester, find.text('start')); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("start"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("start"))')); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + opened.clear(); + closed.clear(); + + // Test closing a menu with enter. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(opened, isEmpty); + expect(closed, <TestMenu>[TestMenu.mainMenu0]); + }); + + testWidgets('MenuAnchor LTR directional traversal works', (WidgetTester tester) async { + final buttonFocusNode = FocusNode(debugLabel: TestMenu.anchorButton.label); + addTearDown(buttonFocusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuAnchor( + childFocusNode: buttonFocusNode, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('start')), + ...createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Open'), + ); + }, + ), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + expect(find.text('start'), findsOneWidget); + + // Directional traversal doesn't work until a menu item is focused. + // To start focusing, hover over the first menu item. + await hoverOver(tester, find.text('start')); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("start"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsOne); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 00"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 01"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))')); + + // We're at the deepest menu on a LTR menu, so arrow right should not change focus. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))')); + + // Arrow left should move focus to the parent anchor. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsNothing); + + // We're at the root menu, so arrow left should not change focus and + // should not open the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsNothing); + + // Open the submenu again. + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsOne); + + // Close all menus. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + expect(find.byType(MenuItemButton), findsNothing); + }); + + testWidgets('MenuAnchor RTL directional traversal works', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/119532 + final buttonFocusNode = FocusNode(debugLabel: TestMenu.anchorButton.label); + addTearDown(buttonFocusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Column( + children: <Widget>[ + MenuAnchor( + childFocusNode: buttonFocusNode, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('start')), + ...createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + focusNode: buttonFocusNode, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Open'), + ); + }, + ), + ], + ), + ), + ), + ), + ); + + listenForFocusChanges(); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + expect(find.text('start'), findsOneWidget); + + // Directional traversal doesn't work until a menu item is focused. + // To start focusing, hover over the first menu item. + await hoverOver(tester, find.text('start')); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("start"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsOne); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 00"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 01"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))')); + + // We're at the deepest menu on a RTL menu, so arrow left should not change focus. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))')); + + // Arrow right should move focus to the parent anchor. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsNothing); + + // We're at the root menu, so arrow right should not change focus and + // should not open the submenu. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsNothing); + + // Open the submenu again. + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + expect(find.text('Sub Menu 00'), findsOne); + + // Close all menus. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(focusedMenu, equals(TestMenu.anchorButton.label)); + expect(find.byType(MenuItemButton), findsNothing); + }); + + testWidgets('hover traversal works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + listenForFocusChanges(); + + // Hovering when the menu is not yet open does nothing. + await hoverOver(tester, find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + expect(focusedMenu, isNull); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + // Hovering when the menu is already open does nothing. + await hoverOver(tester, find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))')); + + // Hovering over the other main menu items opens them now. + await hoverOver(tester, find.text(TestMenu.mainMenu2.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + + await hoverOver(tester, find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + // Hovering over the menu items focuses them. + await hoverOver(tester, find.text(TestMenu.subMenu10.label)); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + await hoverOver(tester, find.text(TestMenu.subMenu11.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + await hoverOver(tester, find.text(TestMenu.subSubMenu110.label)); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))')); + }); + + testWidgets('hover traversal invalidates directional focus scope data', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/150910. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + listenForFocusChanges(); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await hoverOver(tester, find.text(TestMenu.subMenu12.label)); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + // Move pointer to disabled menu. + await hoverOver(tester, find.text(TestMenu.mainMenu5.label)); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))')); + + await hoverOver(tester, find.text(TestMenu.subMenu12.label)); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))')); + }); + + testWidgets('hoverOpenDelay delays when a SubmenuButton opens', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SubmenuButton( + hoverOpenDelay: const Duration(milliseconds: 500), + menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Item 0'))], + child: const Text('Submenu'), + ), + ), + ), + ); + + // Hover over the submenu button + await hoverOver(tester, find.text('Submenu')); + await tester.pump(const Duration(milliseconds: 499)); + + expect(find.text('Item 0'), findsNothing); + + await tester.pump(const Duration(milliseconds: 1)); + + // Menu should now be open + expect(find.text('Item 0'), findsOneWidget); + }); + + testWidgets('hoverOpenDelay can be cancelled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + const Text('Outside'), + SubmenuButton( + hoverOpenDelay: const Duration(milliseconds: 500), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + ], + child: const Text('Submenu'), + ), + ], + ), + ), + ), + ); + + // Hover over the submenu button + await hoverOver(tester, find.text('Submenu')); + await tester.pump(const Duration(milliseconds: 499)); + + expect(find.text('Item 0'), findsNothing); + + // Move hover away before the delay completes + await hoverOver(tester, find.text('Outside')); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.text('Item 0'), findsNothing); + }); + + testWidgets('hoverOpenDelay restarts after cancellation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + const Text('Outside'), + SubmenuButton( + hoverOpenDelay: const Duration(milliseconds: 500), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + ], + child: const Text('Submenu'), + ), + ], + ), + ), + ), + ); + + await hoverOver(tester, find.text('Submenu')); + await tester.pump(const Duration(milliseconds: 499)); + + expect(find.text('Item 0'), findsNothing); + + await hoverOver(tester, find.text('Outside')); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.text('Item 0'), findsNothing); + + await hoverOver(tester, find.text('Submenu')); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Item 0'), findsOneWidget); + }); + + testWidgets( + 'Throws if non-zero hoverOpenDelay is applied to MenuBar items', + experimentalLeakTesting: LeakTesting.settings + .withIgnoredAll(), // leaking by design because of exception + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + children: <Widget>[ + SubmenuButton( + hoverOpenDelay: const Duration(milliseconds: 1), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + ], + child: const Text('Submenu'), + ), + ], + ), + ), + ), + ); + + // Should throw because MenuBar items cannot have a hoverOpenDelay + expect( + tester.takeException(), + isA<FlutterError>().having( + (FlutterError e) => e.message, + 'message', + contains( + 'A non-zero hoverOpenDelay was used in a top-level SubmenuButton situated in a MenuBar', + ), + ), + ); + }, + ); + + testWidgets('scrolling does not trigger hover traversal', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/150911. + final GlobalKey scrolledMenuItemKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + style: const MenuStyle(fixedSize: WidgetStatePropertyAll<Size>(Size.fromHeight(200))), + controller: controller, + menuChildren: <Widget>[ + for (int i = 0; i < 20; i++) + MenuItemButton( + key: i == 15 ? scrolledMenuItemKey : null, + onPressed: () {}, + child: Text('Item $i'), + ), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + controller.open(); + await tester.pumpAndSettle(); + + await hoverOver(tester, find.text('Item 1')); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))')); + + // Scroll the menu while the pointer is over a menu item. The focus should + // not change. + tester.renderObject(find.text('Item 15')).showOnScreen(); + await tester.pumpAndSettle(); + expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))')); + + // Traverse with the keyboard to test that the menu scrolls without hover + // focus affecting the focused menu. + for (var i = 2; i < 20; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Item $i"))')); + } + }); + + testWidgets('menus close on ancestor scroll', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SingleChildScrollView( + controller: scrollController, + child: Container( + height: 1000, + alignment: Alignment.center, + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(opened, isNotEmpty); + expect(closed, isEmpty); + opened.clear(); + + scrollController.jumpTo(1000); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isNotEmpty); + }); + + testWidgets('menus do not close on root menu internal scroll', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/122168. + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + var rootOpened = false; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + menuButtonTheme: MenuButtonThemeData( + // Increase menu items height to make root menu scrollable. + style: TextButton.styleFrom(minimumSize: const Size.fromHeight(200)), + ), + ), + home: Material( + child: SingleChildScrollView( + controller: scrollController, + child: Container( + height: 1000, + alignment: Alignment.topLeft, + child: MenuAnchor( + controller: controller, + alignmentOffset: const Offset(0, 10), + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton.tonal( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Show menu'), + ); + }, + onOpen: () { + rootOpened = true; + }, + onClose: () { + rootOpened = false; + }, + menuChildren: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + includeExtraGroups: true, + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show menu')); + await tester.pump(); + expect(rootOpened, true); + + // Hover the first item. + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding( + pointer.hover(tester.getCenter(find.text(TestMenu.mainMenu0.label))), + ); + await tester.pump(); + expect(opened, isNotEmpty); + + // Menus do not close on internal scroll. + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0))); + await tester.pump(); + expect(rootOpened, true); + expect(closed, isEmpty); + + // Menus close on external scroll. + scrollController.jumpTo(1000); + await tester.pump(); + expect(rootOpened, false); + expect(closed, isNotEmpty); + }); + + testWidgets('menus close on view size change', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final mediaQueryData = MediaQueryData.fromView(tester.view); + + Widget build(Size size) { + return MaterialApp( + home: Material( + child: MediaQuery( + data: mediaQueryData.copyWith(size: size), + child: SingleChildScrollView( + controller: scrollController, + child: Container( + height: 1000, + alignment: Alignment.center, + child: MenuBar( + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(build(mediaQueryData.size)); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(opened, isNotEmpty); + expect(closed, isEmpty); + opened.clear(); + + const smallSize = Size(200, 200); + await changeSurfaceSize(tester, smallSize); + + await tester.pumpWidget(build(smallSize)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isNotEmpty); + }); + + // Regression test for + // https://github.com/flutter/flutter/issues/119532#issuecomment-2274705565. + testWidgets('Shortcuts of MenuAnchor do not rely on WidgetsApp.shortcuts', ( + WidgetTester tester, + ) async { + // MenuAnchor used to rely on WidgetsApp.shortcuts for menu navigation, + // which is a problem for Web because the Web uses a special set of + // default shortcuts that define arrow keys as scrolling instead of + // traversing, and therefore arrow keys won't enter submenus when the + // focus is on MenuAnchor. + // + // This test verifies that `MenuAnchor`'s shortcuts continues to work even + // when `WidgetsApp.shortcuts` contains nothing. + + final childNode = FocusNode(debugLabel: 'Dropdown Inkwell'); + addTearDown(childNode.dispose); + + await tester.pumpWidget( + MaterialApp( + // Clear WidgetsApp.shortcuts to make sure MenuAnchor doesn't rely on + // it. + shortcuts: const <ShortcutActivator, Intent>{}, + home: Scaffold( + body: MenuAnchor( + childFocusNode: childNode, + menuChildren: List<Widget>.generate( + 3, + (int i) => MenuItemButton(child: Text('Submenu item $i'), onPressed: () {}), + ), + builder: (BuildContext context, MenuController controller, Widget? child) { + return InkWell( + focusNode: childNode, + onTap: controller.open, + child: const Text('Main button'), + ); + }, + ), + ), + ), + ); + + listenForFocusChanges(); + + // Open the drop down menu and focus on the MenuAnchor. + await tester.tap(find.text('Main button')); + await tester.pumpAndSettle(); + expect(find.text('Submenu item 0'), findsOneWidget); + + // Press arrowDown, and the first submenu button should be focused. + // This is the critical part. It used to not work on Web. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Submenu item 0"))')); + + // Press arrowDown, and the second submenu button should be focused. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusedMenu, equals('MenuItemButton(Text("Submenu item 1"))')); + }); + }); + + group('Accelerators', () { + const apple = <TargetPlatform>{TargetPlatform.macOS, TargetPlatform.iOS}; + final Set<TargetPlatform> nonApple = TargetPlatform.values.toSet().difference(apple); + + test('Accelerator markers are stripped properly', () { + const expected = <String, String>{ + 'Plain String': 'Plain String', + '&Simple Accelerator': 'Simple Accelerator', + '&Multiple &Accelerators': 'Multiple Accelerators', + 'Whitespace & Accelerators': 'Whitespace Accelerators', + '&Quoted && Ampersand': 'Quoted & Ampersand', + 'Ampersand at End &': 'Ampersand at End ', + '&&Multiple Ampersands &&& &&&A &&&&B &&&&': '&Multiple Ampersands & &A &&B &&', + 'Bohrium 𨨏 Code point U+28A0F': 'Bohrium 𨨏 Code point U+28A0F', + }; + const expectedIndices = <int>[-1, 0, 0, -1, 0, -1, 24, -1]; + const expectedHasAccelerator = <bool>[false, true, true, false, true, false, true, false]; + var acceleratorIndex = -1; + var count = 0; + for (final String key in expected.keys) { + expect( + MenuAcceleratorLabel.stripAcceleratorMarkers( + key, + setIndex: (int index) { + acceleratorIndex = index; + }, + ), + equals(expected[key]), + reason: "'$key' label doesn't match ${expected[key]}", + ); + expect( + acceleratorIndex, + equals(expectedIndices[count]), + reason: "'$key' index doesn't match ${expectedIndices[count]}", + ); + expect( + MenuAcceleratorLabel(key).hasAccelerator, + equals(expectedHasAccelerator[count]), + reason: "'$key' hasAccelerator isn't ${expectedHasAccelerator[count]}", + ); + count += 1; + } + }); + + testWidgets('can invoke menu items', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + key: UniqueKey(), + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + accelerators: true, + ), + ), + ), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm'); + await tester.pump(); + // Makes sure that identical accelerators in parent menu items don't + // shadow the ones in the children. + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.mainMenu0])); + expect(closed, equals(<TestMenu>[TestMenu.mainMenu0])); + expect(selected, equals(<TestMenu>[TestMenu.subMenu00])); + // Selecting a non-submenu item should close all the menus. + expect(find.text(TestMenu.subMenu00.label), findsNothing); + opened.clear(); + closed.clear(); + selected.clear(); + + // Invoking several levels deep. + await tester.sendKeyDownEvent(LogicalKeyboardKey.altRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'e'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altRight); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11])); + expect(closed, equals(<TestMenu>[TestMenu.subMenu11, TestMenu.mainMenu1])); + expect(selected, equals(<TestMenu>[TestMenu.subSubMenu111])); + opened.clear(); + closed.clear(); + selected.clear(); + }, variant: TargetPlatformVariant(nonApple)); + + testWidgets('can combine with regular keyboard navigation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + key: UniqueKey(), + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + accelerators: true, + ), + ), + ), + ), + ); + + // Combining accelerators and regular keyboard navigation works. + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'e'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1'); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11])); + expect(closed, equals(<TestMenu>[TestMenu.subMenu11, TestMenu.mainMenu1])); + expect(selected, equals(<TestMenu>[TestMenu.subSubMenu110])); + }, variant: TargetPlatformVariant(nonApple)); + + testWidgets('can combine with mouse', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + key: UniqueKey(), + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + accelerators: true, + ), + ), + ), + ), + ); + + // Combining accelerators and regular keyboard navigation works. + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'e'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '1'); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.tap(find.text(TestMenu.subSubMenu112.label)); + await tester.pump(); + + expect(opened, equals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11])); + expect(closed, equals(<TestMenu>[TestMenu.subMenu11, TestMenu.mainMenu1])); + expect(selected, equals(<TestMenu>[TestMenu.subSubMenu112])); + }, variant: TargetPlatformVariant(nonApple)); + + testWidgets("disabled items don't respond to accelerators", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + key: UniqueKey(), + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + accelerators: true, + ), + ), + ), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: '5'); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isEmpty); + expect(selected, isEmpty); + // Selecting a non-submenu item should close all the menus. + expect(find.text(TestMenu.subMenu00.label), findsNothing); + }, variant: TargetPlatformVariant(nonApple)); + + testWidgets("Apple platforms don't react to accelerators", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + key: UniqueKey(), + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + accelerators: true, + ), + ), + ), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'm'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isEmpty); + expect(selected, isEmpty); + + // Or with the option key equivalents. + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'µ'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyM, character: 'µ'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isEmpty); + expect(selected, isEmpty); + }, variant: const TargetPlatformVariant(apple)); + }); + + group('MenuController', () { + testWidgets('Moving a controller to a new instance works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar(key: UniqueKey(), controller: controller, children: createTestMenus()), + ), + ), + ); + + // Open a menu initially. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + // Now pump a new menu with a different UniqueKey to dispose of the opened + // menu's node, but keep the existing controller. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + key: UniqueKey(), + controller: controller, + children: createTestMenus(includeExtraGroups: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('closing via controller works', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus( + onPressed: onPressed, + onOpen: onOpen, + onClose: onClose, + shortcuts: <TestMenu, MenuSerializableShortcut>{ + TestMenu.subSubMenu110: const SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + ), + }, + ), + ), + ), + ), + ); + + // Open a menu initially. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + expect(opened, unorderedEquals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11])); + opened.clear(); + closed.clear(); + + // Close menus using the controller. + controller.close(); + await tester.pump(); + + // The menu should go away, + expect(closed, unorderedEquals(<TestMenu>[TestMenu.mainMenu1, TestMenu.subMenu11])); + expect(opened, isEmpty); + }); + + // Regression test for https://github.com/flutter/flutter/issues/176374. + testWidgets('internal controller is created when the controller is null', ( + WidgetTester tester, + ) async { + MenuController? testController; + + await tester.pumpWidget( + MaterialApp( + home: MenuAnchor( + controller: controller, + menuChildren: const <Widget>[], + builder: (BuildContext context, MenuController controller, Widget? child) { + testController = controller; + return const Text('Anchor'); + }, + ), + ), + ); + + expect(testController, equals(controller)); + + await tester.pumpWidget( + MaterialApp( + home: MenuAnchor( + menuChildren: const <Widget>[], + builder: (BuildContext context, MenuController controller, Widget? child) { + testController = controller; + return const Text('Anchor'); + }, + ), + ), + ); + + expect(testController, isNotNull); + expect(testController, isNot(controller)); + }); + }); + + group('MenuItemButton', () { + testWidgets('Shortcut mnemonics are displayed', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus( + shortcuts: <TestMenu, MenuSerializableShortcut>{ + TestMenu.subSubMenu110: const SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + ), + TestMenu.subSubMenu111: const SingleActivator( + LogicalKeyboardKey.keyB, + shift: true, + ), + TestMenu.subSubMenu112: const SingleActivator(LogicalKeyboardKey.keyC, alt: true), + TestMenu.subSubMenu113: const SingleActivator( + LogicalKeyboardKey.keyD, + meta: true, + ), + }, + ), + ), + ), + ), + ); + + // Open a menu initially. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + Text mnemonic0 = tester.widget(findMnemonic(TestMenu.subSubMenu110.label)); + Text mnemonic1 = tester.widget(findMnemonic(TestMenu.subSubMenu111.label)); + Text mnemonic2 = tester.widget(findMnemonic(TestMenu.subSubMenu112.label)); + Text mnemonic3 = tester.widget(findMnemonic(TestMenu.subSubMenu113.label)); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + expect(mnemonic0.data, equals('Ctrl+A')); + expect(mnemonic1.data, equals('Shift+B')); + expect(mnemonic2.data, equals('Alt+C')); + expect(mnemonic3.data, equals('Meta+D')); + case TargetPlatform.windows: + expect(mnemonic0.data, equals('Ctrl+A')); + expect(mnemonic1.data, equals('Shift+B')); + expect(mnemonic2.data, equals('Alt+C')); + expect(mnemonic3.data, equals('Win+D')); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(mnemonic0.data, equals('⌃ A')); + expect(mnemonic1.data, equals('⇧ B')); + expect(mnemonic2.data, equals('⌥ C')); + expect(mnemonic3.data, equals('⌘ D')); + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus( + includeExtraGroups: true, + shortcuts: <TestMenu, MenuSerializableShortcut>{ + TestMenu.subSubMenu110: const SingleActivator(LogicalKeyboardKey.arrowRight), + TestMenu.subSubMenu111: const SingleActivator(LogicalKeyboardKey.arrowLeft), + TestMenu.subSubMenu112: const SingleActivator(LogicalKeyboardKey.arrowUp), + TestMenu.subSubMenu113: const SingleActivator(LogicalKeyboardKey.arrowDown), + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + mnemonic0 = tester.widget(findMnemonic(TestMenu.subSubMenu110.label)); + expect(mnemonic0.data, equals('→')); + mnemonic1 = tester.widget(findMnemonic(TestMenu.subSubMenu111.label)); + expect(mnemonic1.data, equals('←')); + mnemonic2 = tester.widget(findMnemonic(TestMenu.subSubMenu112.label)); + expect(mnemonic2.data, equals('↑')); + mnemonic3 = tester.widget(findMnemonic(TestMenu.subSubMenu113.label)); + expect(mnemonic3.data, equals('↓')); + + // Try some weirder ones. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus( + shortcuts: <TestMenu, MenuSerializableShortcut>{ + TestMenu.subSubMenu110: const SingleActivator(LogicalKeyboardKey.escape), + TestMenu.subSubMenu111: const SingleActivator(LogicalKeyboardKey.fn), + TestMenu.subSubMenu112: const SingleActivator(LogicalKeyboardKey.enter), + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + mnemonic0 = tester.widget(findMnemonic(TestMenu.subSubMenu110.label)); + expect(mnemonic0.data, equals('Esc')); + mnemonic1 = tester.widget(findMnemonic(TestMenu.subSubMenu111.label)); + expect(mnemonic1.data, equals('Fn')); + mnemonic2 = tester.widget(findMnemonic(TestMenu.subSubMenu112.label)); + expect(mnemonic2.data, equals('↵')); + }, variant: TargetPlatformVariant.all()); + + // Regression test for https://github.com/flutter/flutter/issues/145040. + testWidgets('CharacterActivator shortcut mnemonics include modifiers', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus( + shortcuts: <TestMenu, MenuSerializableShortcut>{ + TestMenu.subSubMenu110: const CharacterActivator('A', control: true), + TestMenu.subSubMenu111: const CharacterActivator('B', alt: true), + TestMenu.subSubMenu112: const CharacterActivator('C', meta: true), + }, + ), + ), + ), + ), + ); + + // Open a menu initially. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + final Text mnemonic0 = tester.widget(findMnemonic(TestMenu.subSubMenu110.label)); + final Text mnemonic1 = tester.widget(findMnemonic(TestMenu.subSubMenu111.label)); + final Text mnemonic2 = tester.widget(findMnemonic(TestMenu.subSubMenu112.label)); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + expect(mnemonic0.data, equals('Ctrl+A')); + expect(mnemonic1.data, equals('Alt+B')); + expect(mnemonic2.data, equals('Meta+C')); + case TargetPlatform.windows: + expect(mnemonic0.data, equals('Ctrl+A')); + expect(mnemonic1.data, equals('Alt+B')); + expect(mnemonic2.data, equals('Win+C')); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(mnemonic0.data, equals('⌃ A')); + expect(mnemonic1.data, equals('⌥ B')); + expect(mnemonic2.data, equals('⌘ C')); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('leadingIcon is used when set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton( + leadingIcon: const Text('leadingIcon'), + child: Text(TestMenu.subMenu00.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(find.text('leadingIcon'), findsOneWidget); + }); + + testWidgets('autofocus is used when set and widget is enabled', (WidgetTester tester) async { + listenForFocusChanges(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuAnchor( + controller: controller, + menuChildren: <Widget>[ + MenuItemButton( + autofocus: true, + // Required for clickability. + onPressed: () {}, + child: Text(TestMenu.mainMenu0.label), + ), + MenuItemButton(onPressed: () {}, child: Text(TestMenu.mainMenu1.label)), + ], + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + expect(controller.isOpen, equals(true)); + expect(focusedMenu, equals('MenuItemButton(Text("${TestMenu.mainMenu0.label}"))')); + }); + + testWidgets('trailingIcon is used when set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton( + trailingIcon: const Text('trailingIcon'), + child: Text(TestMenu.subMenu00.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(find.text('trailingIcon'), findsOneWidget); + }); + + testWidgets('SubmenuButton uses supplied controller', (WidgetTester tester) async { + final submenuController = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + controller: submenuController, + menuChildren: <Widget>[MenuItemButton(child: Text(TestMenu.subMenu00.label))], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + + submenuController.open(); + await tester.pump(); + expect(find.text(TestMenu.subMenu00.label), findsOneWidget); + + submenuController.close(); + await tester.pump(); + expect(find.text(TestMenu.subMenu00.label), findsNothing); + + // Now remove the controller and try to control it. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[MenuItemButton(child: Text(TestMenu.subMenu00.label))], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + + await expectLater(() => submenuController.open(), throwsAssertionError); + await tester.pump(); + expect(find.text(TestMenu.subMenu00.label), findsNothing); + }); + + testWidgets('diagnostics', (WidgetTester tester) async { + final style = ButtonStyle( + shape: WidgetStateProperty.all<OutlinedBorder?>(const StadiumBorder()), + elevation: WidgetStateProperty.all<double?>(10.0), + backgroundColor: const MaterialStatePropertyAll<Color>(Colors.red), + ); + final menuStyle = MenuStyle( + shape: WidgetStateProperty.all<OutlinedBorder?>(const RoundedRectangleBorder()), + elevation: WidgetStateProperty.all<double?>(20.0), + backgroundColor: const MaterialStatePropertyAll<Color>(Colors.green), + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + style: style, + menuStyle: menuStyle, + menuChildren: <Widget>[ + MenuItemButton(style: style, child: Text(TestMenu.subMenu00.label)), + ], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + final SubmenuButton submenu = tester.widget(find.byType(SubmenuButton)); + final builder = DiagnosticPropertiesBuilder(); + submenu.debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'focusNode: null', + 'menuStyle: MenuStyle#00000(backgroundColor: WidgetStatePropertyAll(MaterialColor(primary value: ${const Color(0xff4caf50)})), elevation: WidgetStatePropertyAll(20.0), shape: WidgetStatePropertyAll(RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)))', + 'alignmentOffset: null', + 'clipBehavior: hardEdge', + ]), + ); + }); + + testWidgets('MenuItemButton respects closeOnActivate property', (WidgetTester tester) async { + final controller = MenuController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MenuAnchor( + controller: controller, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Button 1')), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + controller.open(); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(1)); + + // Taps the MenuItemButton which should close the menu. + await tester.tap(find.text('Button 1')); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(0)); + + await tester.pumpAndSettle(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MenuAnchor( + controller: controller, + menuChildren: <Widget>[ + MenuItemButton( + closeOnActivate: false, + onPressed: () {}, + child: const Text('Button 1'), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + controller.open(); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(1)); + + // Taps the MenuItemButton which shouldn't close the menu. + await tester.tap(find.text('Button 1')); + await tester.pump(); + expect(find.byType(MenuItemButton), findsNWidgets(1)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/129439. + testWidgets('MenuItemButton does not overflow when child is long', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: MenuItemButton( + overflowAxis: Axis.vertical, + onPressed: () {}, + child: const Text('MenuItem Button does not overflow when child is long'), + ), + ), + ), + ), + ); + + // No exception should be thrown. + expect(tester.takeException(), isNull); + }); + + testWidgets('MenuItemButton layout is updated by overflowAxis', (WidgetTester tester) async { + Widget buildMenuButton({required Axis overflowAxis, bool constrainedLayout = false}) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: constrainedLayout ? 200 : null, + child: MenuItemButton( + overflowAxis: overflowAxis, + onPressed: () {}, + child: const Text('This is a very long text that will wrap to the multiple lines.'), + ), + ), + ), + ); + } + + // Test a long MenuItemButton in an unconstrained layout with vertical overflow axis. + await tester.pumpWidget(buildMenuButton(overflowAxis: Axis.vertical)); + expect(tester.getSize(find.byType(MenuItemButton)), const Size(800.0, 48.0)); + + // Test a long MenuItemButton in an unconstrained layout with horizontal overflow axis. + await tester.pumpWidget(buildMenuButton(overflowAxis: Axis.horizontal)); + expect(tester.getSize(find.byType(MenuItemButton)), const Size(800.0, 48.0)); + + // Test a long MenuItemButton in a constrained layout with vertical overflow axis. + await tester.pumpWidget( + buildMenuButton(overflowAxis: Axis.vertical, constrainedLayout: true), + ); + expect(tester.getSize(find.byType(MenuItemButton)), const Size(200.0, 120.0)); + + // Test a long MenuItemButton in a constrained layout with horizontal overflow axis. + await tester.pumpWidget( + buildMenuButton(overflowAxis: Axis.horizontal, constrainedLayout: true), + ); + expect(tester.getSize(find.byType(MenuItemButton)), const Size(200.0, 48.0)); + // This should throw an error. + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + }); + + testWidgets('MenuItemButton.styleFrom overlayColor overrides default overlay color', ( + WidgetTester tester, + ) async { + const overlayColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MenuItemButton( + style: MenuItemButton.styleFrom(overlayColor: overlayColor), + onPressed: () {}, + child: const Text('MenuItem'), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(MenuItemButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/147479. + testWidgets('MenuItemButton can build when its child is null', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: SizedBox(width: 200, child: MenuItemButton())), + ), + ); + + expect(tester.takeException(), isNull); + }); + }); + + group('Layout', () { + List<Rect> collectMenuItemRects() { + final menuRects = <Rect>[]; + final List<Element> candidates = find.byType(SubmenuButton).evaluate().toList(); + for (final candidate in candidates) { + final box = candidate.renderObject! as RenderBox; + final Offset topLeft = box.localToGlobal(box.size.topLeft(Offset.zero)); + final Offset bottomRight = box.localToGlobal(box.size.bottomRight(Offset.zero)); + menuRects.add(Rect.fromPoints(topLeft, bottomRight)); + } + return menuRects; + } + + List<Rect> collectSubmenuRects() { + final menuRects = <Rect>[]; + final List<Element> candidates = findMenuPanels().evaluate().toList(); + for (final candidate in candidates) { + final box = candidate.renderObject! as RenderBox; + final Offset topLeft = box.localToGlobal(box.size.topLeft(Offset.zero)); + final Offset bottomRight = box.localToGlobal(box.size.bottomRight(Offset.zero)); + menuRects.add(Rect.fromPoints(topLeft, bottomRight)); + } + return menuRects; + } + + testWidgets('unconstrained menus show up in the right place in LTR', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(800, 600)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: MenuBar(children: createTestMenus(onPressed: onPressed)), + ), + ], + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(find.byType(MenuItemButton), findsNWidgets(6)); + expect(find.byType(SubmenuButton), findsNWidgets(5)); + expect( + collectMenuItemRects(), + equals(const <Rect>[ + Rect.fromLTRB(4.0, 0.0, 112.0, 48.0), + Rect.fromLTRB(112.0, 0.0, 220.0, 48.0), + Rect.fromLTRB(112.0, 104.0, 326.0, 152.0), + Rect.fromLTRB(220.0, 0.0, 328.0, 48.0), + Rect.fromLTRB(328.0, 0.0, 506.0, 48.0), + ]), + ); + }); + + testWidgets('unconstrained menus show up in the right place in RTL', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(800, 600)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Column( + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: MenuBar(children: createTestMenus(onPressed: onPressed)), + ), + ], + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(find.byType(MenuItemButton), findsNWidgets(6)); + expect(find.byType(SubmenuButton), findsNWidgets(5)); + expect( + collectMenuItemRects(), + equals(const <Rect>[ + Rect.fromLTRB(688.0, 0.0, 796.0, 48.0), + Rect.fromLTRB(580.0, 0.0, 688.0, 48.0), + Rect.fromLTRB(474.0, 104.0, 688.0, 152.0), + Rect.fromLTRB(472.0, 0.0, 580.0, 48.0), + Rect.fromLTRB(294.0, 0.0, 472.0, 48.0), + ]), + ); + }); + + testWidgets('constrained menus show up in the right place in LTR', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(300, 300)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(find.byType(MenuItemButton), findsNWidgets(6)); + expect(find.byType(SubmenuButton), findsNWidgets(5)); + expect( + collectMenuItemRects(), + equals(const <Rect>[ + Rect.fromLTRB(4.0, 0.0, 112.0, 48.0), + Rect.fromLTRB(112.0, 0.0, 220.0, 48.0), + Rect.fromLTRB(86.0, 104.0, 300.0, 152.0), + Rect.fromLTRB(220.0, 0.0, 328.0, 48.0), + Rect.fromLTRB(328.0, 0.0, 506.0, 48.0), + ]), + ); + }); + + testWidgets('tapping MenuItemButton with null focus node', (WidgetTester tester) async { + FocusNode? buttonFocusNode = FocusNode(); + + // Build our app and trigger a frame. + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuAnchor( + menuChildren: <Widget>[ + MenuItemButton( + focusNode: buttonFocusNode, + closeOnActivate: false, + child: const Text('Set focus to null'), + onPressed: () { + setState(() { + buttonFocusNode?.dispose(); + buttonFocusNode = null; + }); + }, + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ); + }, + ), + ), + ); + + await tester.tap(find.text('OPEN MENU')); + await tester.pump(); + + expect(find.text('Set focus to null'), findsOneWidget); + + await tester.tap(find.text('Set focus to null')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('constrained menus show up in the right place in RTL', (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(300, 300)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + + expect(find.byType(MenuItemButton), findsNWidgets(6)); + expect(find.byType(SubmenuButton), findsNWidgets(5)); + expect( + collectMenuItemRects(), + equals(const <Rect>[ + Rect.fromLTRB(188.0, 0.0, 296.0, 48.0), + Rect.fromLTRB(80.0, 0.0, 188.0, 48.0), + Rect.fromLTRB(0.0, 104.0, 214.0, 152.0), + Rect.fromLTRB(-28.0, 0.0, 80.0, 48.0), + Rect.fromLTRB(-206.0, 0.0, -28.0, 48.0), + ]), + ); + }); + + testWidgets('constrained menus show up in the right place with offset in LTR', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(800, 600)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MenuAnchor( + menuChildren: const <Widget>[ + SubmenuButton( + alignmentOffset: Offset(10, 0), + menuChildren: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + SubmenuButton( + alignmentOffset: Offset(10, 0), + menuChildren: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[], + child: Text('SubMenuButton4'), + ), + ], + child: Text('SubMenuButton3'), + ), + ], + child: Text('SubMenuButton2'), + ), + ], + child: Text('SubMenuButton1'), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + await tester.tap(find.text('SubMenuButton1')); + await tester.pump(); + await tester.tap(find.text('SubMenuButton2')); + await tester.pump(); + await tester.tap(find.text('SubMenuButton3')); + await tester.pump(); + + expect(find.byType(SubmenuButton), findsNWidgets(4)); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(0.0, 48.0, 256.0, 112.0), + Rect.fromLTRB(266.0, 48.0, 522.0, 112.0), + Rect.fromLTRB(522.0, 48.0, 778.0, 112.0), + Rect.fromLTRB(256.0, 48.0, 512.0, 112.0), + ]), + ); + }); + + testWidgets('constrained menus show up in the right place with offset in RTL', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(800, 600)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.rtl, + child: Align( + alignment: Alignment.topRight, + child: MenuAnchor( + menuChildren: const <Widget>[ + SubmenuButton( + alignmentOffset: Offset(10, 0), + menuChildren: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + SubmenuButton( + alignmentOffset: Offset(10, 0), + menuChildren: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[], + child: Text('SubMenuButton4'), + ), + ], + child: Text('SubMenuButton3'), + ), + ], + child: Text('SubMenuButton2'), + ), + ], + child: Text('SubMenuButton1'), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + await tester.tap(find.text('SubMenuButton1')); + await tester.pump(); + await tester.tap(find.text('SubMenuButton2')); + await tester.pump(); + await tester.tap(find.text('SubMenuButton3')); + await tester.pump(); + + expect(find.byType(SubmenuButton), findsNWidgets(4)); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(544.0, 48.0, 800.0, 112.0), + Rect.fromLTRB(278.0, 48.0, 534.0, 112.0), + Rect.fromLTRB(22.0, 48.0, 278.0, 112.0), + Rect.fromLTRB(288.0, 48.0, 544.0, 112.0), + ]), + ); + }); + + testWidgets('vertically constrained menus are positioned above the anchor by default', ( + WidgetTester tester, + ) async { + await changeSurfaceSize(tester, const Size(800, 600)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.bottomLeft, + child: MenuAnchor( + menuChildren: const <Widget>[MenuItemButton(child: Text('Button1'))], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + }, + ), + ), + ); + + await tester.pump(); + await tester.tap(find.text('Tap me')); + await tester.pump(); + + expect(find.byType(MenuItemButton), findsNWidgets(1)); + // Test the default offset (0, 0) vertical position. + expect(collectSubmenuRects(), equals(const <Rect>[Rect.fromLTRB(0.0, 488.0, 122.0, 552.0)])); + }); + + testWidgets( + 'vertically constrained menus are positioned above the anchor with the provided offset', + (WidgetTester tester) async { + await changeSurfaceSize(tester, const Size(800, 600)); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.bottomLeft, + child: MenuAnchor( + alignmentOffset: const Offset(0, 50), + menuChildren: const <Widget>[MenuItemButton(child: Text('Button1'))], + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + }, + ), + ), + ); + + await tester.pump(); + await tester.tap(find.text('Tap me')); + await tester.pump(); + + expect(find.byType(MenuItemButton), findsNWidgets(1)); + // Test the offset (0, 50) vertical position. + expect( + collectSubmenuRects(), + equals(const <Rect>[Rect.fromLTRB(0.0, 438.0, 122.0, 502.0)]), + ); + }, + ); + + Future<void> buildDensityPaddingApp( + WidgetTester tester, { + required TextDirection textDirection, + VisualDensity visualDensity = VisualDensity.standard, + EdgeInsetsGeometry? menuPadding, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(useMaterial3: false).copyWith(visualDensity: visualDensity), + home: Directionality( + textDirection: textDirection, + child: Material( + child: Column( + children: <Widget>[ + MenuBar( + style: menuPadding != null + ? MenuStyle( + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(menuPadding), + ) + : null, + children: createTestMenus(onPressed: onPressed), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + await tester.tap(find.text(TestMenu.subMenu11.label)); + await tester.pump(); + } + + testWidgets('submenus account for density in LTR', (WidgetTester tester) async { + await buildDensityPaddingApp(tester, textDirection: TextDirection.ltr); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(145.0, 0.0, 655.0, 48.0), + Rect.fromLTRB(257.0, 48.0, 471.0, 208.0), + Rect.fromLTRB(471.0, 96.0, 719.0, 304.0), + ]), + ); + }); + + testWidgets('submenus account for menu density in RTL', (WidgetTester tester) async { + await buildDensityPaddingApp(tester, textDirection: TextDirection.rtl); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(145.0, 0.0, 655.0, 48.0), + Rect.fromLTRB(329.0, 48.0, 543.0, 208.0), + Rect.fromLTRB(81.0, 96.0, 329.0, 304.0), + ]), + ); + }); + + testWidgets('submenus account for compact menu density in LTR', (WidgetTester tester) async { + await buildDensityPaddingApp( + tester, + visualDensity: VisualDensity.compact, + textDirection: TextDirection.ltr, + ); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(161.0, 0.0, 639.0, 40.0), + Rect.fromLTRB(265.0, 40.0, 467.0, 176.0), + Rect.fromLTRB(467.0, 80.0, 707.0, 256.0), + ]), + ); + }); + + testWidgets('submenus account for compact menu density in RTL', (WidgetTester tester) async { + await buildDensityPaddingApp( + tester, + visualDensity: VisualDensity.compact, + textDirection: TextDirection.rtl, + ); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(161.0, 0.0, 639.0, 40.0), + Rect.fromLTRB(333.0, 40.0, 535.0, 176.0), + Rect.fromLTRB(93.0, 80.0, 333.0, 256.0), + ]), + ); + }); + + testWidgets('submenus account for padding in LTR', (WidgetTester tester) async { + await buildDensityPaddingApp( + tester, + menuPadding: const EdgeInsetsDirectional.only(start: 10, end: 11, top: 12, bottom: 13), + textDirection: TextDirection.ltr, + ); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(138.5, 0.0, 661.5, 73.0), + Rect.fromLTRB(256.5, 60.0, 470.5, 220.0), + Rect.fromLTRB(470.5, 108.0, 718.5, 316.0), + ]), + ); + }); + + testWidgets('submenus account for padding in RTL', (WidgetTester tester) async { + await buildDensityPaddingApp( + tester, + menuPadding: const EdgeInsetsDirectional.only(start: 10, end: 11, top: 12, bottom: 13), + textDirection: TextDirection.rtl, + ); + expect( + collectSubmenuRects(), + equals(const <Rect>[ + Rect.fromLTRB(138.5, 0.0, 661.5, 73.0), + Rect.fromLTRB(329.5, 60.0, 543.5, 220.0), + Rect.fromLTRB(81.5, 108.0, 329.5, 316.0), + ]), + ); + }); + + testWidgets('Menu follows content position when a LayerLink is provided', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + final contentKey = UniqueKey(); + + Widget boilerplate(double bottomInsets) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: bottomInsets)), + child: Scaffold( + body: Center( + child: MenuAnchor( + controller: controller, + layerLink: LayerLink(), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Button 1')), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return SizedBox(key: contentKey, width: 100, height: 100); + }, + ), + ), + ), + ), + ); + } + + // Build once without bottom insets and open the menu. + await tester.pumpWidget(boilerplate(0.0)); + controller.open(); + await tester.pump(); + + // Menu vertical position is just under the content. + expect(tester.getRect(findMenuPanels()).top, tester.getRect(find.byKey(contentKey)).bottom); + + // Simulate the keyboard opening resizing the view. + await tester.pumpWidget(boilerplate(100.0)); + await tester.pump(); + + // Menu vertical position is just under the content. + expect(tester.getRect(findMenuPanels()).top, tester.getRect(find.byKey(contentKey)).bottom); + }); + + testWidgets('menu is positioned to avoid the software keyboard', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/142921 + // The menu should not extend into the area occupied by the software keyboard. + const screenSize = Size(600, 800); + await changeSurfaceSize(tester, screenSize); + const keyboardHeight = 200.0; + final controller = MenuController(); + + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(viewInsets: const EdgeInsets.only(bottom: keyboardHeight)), + child: child!, + ); + }, + home: Material( + child: Stack( + children: <Widget>[ + // Position anchor just above the keyboard, so menu would extend + // into keyboard area if opened below. + Positioned( + left: 100, + bottom: keyboardHeight + 100, // 100px above keyboard + child: MenuAnchor( + controller: controller, + menuChildren: List<Widget>.generate(5, (index) { + return MenuItemButton(onPressed: () {}, child: Text('Item $index')); + }), + builder: (BuildContext context, MenuController controller, Widget? child) { + return FilledButton( + onPressed: controller.open, + child: const Text('Open Menu'), + ); + }, + ), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pumpAndSettle(); + + final Rect menuRect = tester.getRect(findMenuPanels()); + // Menu should not extend into the keyboard area (bottom 200px of screen). + expect( + menuRect.bottom, + lessThanOrEqualTo(screenSize.height - keyboardHeight), + reason: + 'Menu bottom (${menuRect.bottom}) should not extend into keyboard area (below ${screenSize.height - keyboardHeight})', + ); + }); + + testWidgets( + 'Menu is correctly offset when a LayerLink is provided and alignmentOffset is set', + (WidgetTester tester) async { + final controller = MenuController(); + final contentKey = UniqueKey(); + const horizontalOffset = 16.0; + const verticalOffset = 20.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: MenuAnchor( + controller: controller, + layerLink: LayerLink(), + alignmentOffset: const Offset(horizontalOffset, verticalOffset), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Button 1')), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return SizedBox(key: contentKey, width: 100, height: 100); + }, + ), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + expect( + tester.getRect(findMenuPanels()).top, + tester.getRect(find.byKey(contentKey)).bottom + verticalOffset, + ); + expect( + tester.getRect(findMenuPanels()).left, + tester.getRect(find.byKey(contentKey)).left + horizontalOffset, + ); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/171608 + testWidgets('Menu vertical padding should not be reduced with compact visual density', ( + WidgetTester tester, + ) async { + // Helper function to get menu padding by measuring first/last items. + (double, double) getMenuPadding() { + // Find any menu items that are available. + final Finder menuItems = find.byType(SubmenuButton); + if (menuItems.evaluate().length < 2) { + return (0.0, 0.0); + } + + final Rect firstItem = tester.getRect(menuItems.first); + final Rect lastItem = tester.getRect(menuItems.last); + final Rect menuPanel = tester.getRect(find.byType(Material).last); + + final double topPadding = firstItem.top - menuPanel.top; + final double bottomPadding = menuPanel.bottom - lastItem.bottom; + return (topPadding, bottomPadding); + } + + Future<void> buildSimpleMenuAnchor( + TextDirection textDirection, { + VisualDensity visualDensity = VisualDensity.standard, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(visualDensity: visualDensity), + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: MenuAnchor( + style: const MenuStyle( + padding: WidgetStatePropertyAll<EdgeInsets>( + EdgeInsets.symmetric(vertical: 12, horizontal: 4), + ), + ), + menuChildren: const <Widget>[ + DecoratedBox( + decoration: BoxDecoration(color: Colors.blue), + child: Text('Text 1'), + ), + DecoratedBox( + decoration: BoxDecoration(color: Colors.blue), + child: Text('Text 2'), + ), + ], + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('OPEN MENU'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('OPEN MENU')); + await tester.pump(); + } + + // Pump widget with standard visual density. + await buildSimpleMenuAnchor(TextDirection.ltr); + + final (double topStandard, double bottomStandard) = getMenuPadding(); + + // Pump widget with compact visual density. + await buildSimpleMenuAnchor(TextDirection.ltr, visualDensity: VisualDensity.compact); + + final (double topCompact, double bottomCompact) = getMenuPadding(); + + // Compare standard vs compact padding. + expect( + topCompact, + equals(topStandard), + reason: + 'Compact visual density should not change top padding. ' + 'Standard: $topStandard, Compact: $topCompact', + ); + + expect( + bottomCompact, + equals(bottomStandard), + reason: + 'Compact visual density should not change bottom padding. ' + 'Standard: $bottomStandard, Compact: $bottomCompact', + ); + }); + + group('LocalizedShortcutLabeler', () { + testWidgets('getShortcutLabel returns the right labels', (WidgetTester tester) async { + String expectedMeta; + String expectedCtrl; + String expectedAlt; + String expectedSeparator; + String expectedShift; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expectedCtrl = 'Ctrl'; + expectedMeta = defaultTargetPlatform == TargetPlatform.windows ? 'Win' : 'Meta'; + expectedAlt = 'Alt'; + expectedShift = 'Shift'; + expectedSeparator = '+'; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expectedCtrl = '⌃'; + expectedMeta = '⌘'; + expectedAlt = '⌥'; + expectedShift = '⇧'; + expectedSeparator = ' '; + } + + const allModifiers = SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + meta: true, + shift: true, + alt: true, + ); + late String allExpected; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + allExpected = <String>[ + expectedAlt, + expectedCtrl, + expectedMeta, + expectedShift, + 'A', + ].join(expectedSeparator); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + allExpected = <String>[ + expectedCtrl, + expectedAlt, + expectedShift, + expectedMeta, + 'A', + ].join(expectedSeparator); + } + const charShortcuts = CharacterActivator('ñ'); + const charExpected = 'ñ'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton(shortcut: allModifiers, child: Text(TestMenu.subMenu10.label)), + MenuItemButton( + shortcut: charShortcuts, + child: Text(TestMenu.subMenu11.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(find.text(allExpected), findsOneWidget); + expect(find.text(charExpected), findsOneWidget); + }, variant: TargetPlatformVariant.all()); + }); + + group('CheckboxMenuButton', () { + testWidgets('tapping toggles checkbox', (WidgetTester tester) async { + bool? checkBoxValue; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuBar( + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + CheckboxMenuButton( + value: checkBoxValue, + onChanged: (bool? value) { + setState(() { + checkBoxValue = value; + }); + }, + tristate: true, + child: const Text('checkbox'), + ), + ], + child: const Text('submenu'), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + + expect(tester.widget<CheckboxMenuButton>(find.byType(CheckboxMenuButton)).value, null); + + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); + }); + + group('RadioMenuButton', () { + testWidgets('tapping toggles radio button', (WidgetTester tester) async { + int? radioValue; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuBar( + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + RadioMenuButton<int>( + value: 0, + groupValue: radioValue, + onChanged: (int? value) { + setState(() { + radioValue = value; + }); + }, + toggleable: true, + child: const Text('radio 0'), + ), + RadioMenuButton<int>( + value: 1, + groupValue: radioValue, + onChanged: (int? value) { + setState(() { + radioValue = value; + }); + }, + toggleable: true, + child: const Text('radio 1'), + ), + ], + child: const Text('submenu'), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + + expect( + tester.widget<RadioMenuButton<int>>(find.byType(RadioMenuButton<int>).first).groupValue, + null, + ); + + await tester.tap(find.byType(RadioMenuButton<int>).first); + await tester.pumpAndSettle(); + expect(radioValue, 0); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(RadioMenuButton<int>).first); + await tester.pumpAndSettle(); + expect(radioValue, null); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(RadioMenuButton<int>).last); + await tester.pumpAndSettle(); + expect(radioValue, 1); + }); + }); + + group('Semantics', () { + testWidgets('MenuItemButton has platform-adaptive button semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MenuItemButton( + style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ); + + // On web, menu items should have SemanticsFlag.isButton. + // On other platforms, they should NOT have the isButton flag. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + 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>[ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('MenuItemButton semantics respects label', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: MenuItemButton( + semanticsLabel: 'TestWidget', + shortcut: const SingleActivator(LogicalKeyboardKey.comma), + style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ); + + expect(find.bySemanticsLabel('TestWidget'), findsOneWidget); + semantics.dispose(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('SubmenuButton has platform-adaptive button semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SubmenuButton( + onHover: (bool value) {}, + style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + menuChildren: const <Widget>[], + child: const Text('ABC'), + ), + ), + ), + ); + + // On web, submenu buttons should have SemanticsFlag.isButton. + // On other platforms, they should NOT have the isButton flag. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + children: <TestSemantics>[ + TestSemantics( + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasExpandedState, + ], + label: 'ABC', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SubmenuButton expanded/collapsed state', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SubmenuButton( + style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + menuChildren: <Widget>[ + MenuItemButton( + style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)), + child: const Text('Item 0'), + onPressed: () {}, + ), + ], + child: const Text('ABC'), + ), + ), + ), + ); + + // Test expanded state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics( + id: 8, + children: <TestSemantics>[ + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasImplicitScrolling, + ], + children: <TestSemantics>[ + TestSemantics( + id: 10, + label: 'Item 0', + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + id: 5, + label: 'ABC', + flags: <SemanticsFlag>[ + SemanticsFlag.isFocused, + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + SemanticsFlag.isExpanded, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + // Test collapsed state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + + expect(find.byType(MenuItemButton), findsNothing); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 5, + label: 'ABC', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Animated SubmenuButton expanded/collapsed state', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SubmenuButton( + animated: true, + style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + menuChildren: <Widget>[ + MenuItemButton( + style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)), + child: const Text('Item 0'), + onPressed: () {}, + ), + ], + child: const Text('ABC'), + ), + ), + ), + ); + + // Test expanded state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics( + id: 8, + children: <TestSemantics>[ + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasImplicitScrolling, + ], + children: <TestSemantics>[ + TestSemantics( + id: 10, + label: 'Item 0', + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + id: 5, + label: 'ABC', + flags: <SemanticsFlag>[ + SemanticsFlag.isFocused, + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + SemanticsFlag.isExpanded, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + // Test collapsed state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + + expect(find.byType(MenuItemButton), findsNothing); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 5, + label: 'ABC', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. + + // This is a regression test for https://github.com/flutter/flutter/issues/131676. + testWidgets('Material3 - Menu uses correct text styles', (WidgetTester tester) async { + const menuTextStyle = TextStyle( + fontSize: 18.5, + fontStyle: FontStyle.italic, + wordSpacing: 1.2, + decoration: TextDecoration.lineThrough, + ); + final themeData = ThemeData(textTheme: const TextTheme(labelLarge: menuTextStyle)); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + // Test menu button text style uses the TextTheme.labelLarge. + Finder buttonMaterial = find + .descendant(of: find.byType(TextButton), matching: find.byType(Material)) + .first; + Material material = tester.widget<Material>(buttonMaterial); + expect(material.textStyle?.fontSize, menuTextStyle.fontSize); + expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); + expect(material.textStyle?.wordSpacing, menuTextStyle.wordSpacing); + expect(material.textStyle?.decoration, menuTextStyle.decoration); + + // Open the menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + // Test menu item text style uses the TextTheme.labelLarge. + buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; + material = tester.widget<Material>(buttonMaterial); + expect(material.textStyle?.fontSize, menuTextStyle.fontSize); + expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); + expect(material.textStyle?.wordSpacing, menuTextStyle.wordSpacing); + expect(material.textStyle?.decoration, menuTextStyle.decoration); + }); + + testWidgets('SubmenuButton.onFocusChange is respected', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var onFocusChangeCalled = 0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SubmenuButton( + focusNode: focusNode, + onFocusChange: (bool value) { + setState(() { + onFocusChangeCalled += 1; + }); + }, + menuChildren: const <Widget>[MenuItemButton(child: Text('item 0'))], + child: const Text('Submenu 0'), + ); + }, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + expect(onFocusChangeCalled, 1); + + focusNode.unfocus(); + await tester.pump(); + expect(focusNode.hasFocus, false); + expect(onFocusChangeCalled, 2); + }); + + testWidgets('Horizontal _MenuPanel wraps children with IntrinsicWidth', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + children: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Menu Item'))], + ), + ), + ), + ); + + // Horizontal _MenuPanel wraps children with IntrinsicWidth to ensure MenuItemButton + // with vertical overflow axis is as wide as the widest child. + final Finder intrinsicWidthFinder = find.ancestor( + of: find.byType(MenuItemButton), + matching: find.byType(IntrinsicWidth), + ); + expect(intrinsicWidthFinder, findsOneWidget); + }); + + testWidgets('SubmenuButton.styleFrom overlayColor overrides default overlay color', ( + WidgetTester tester, + ) async { + const overlayColor = Color(0xffff00ff); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SubmenuButton( + style: SubmenuButton.styleFrom(overlayColor: overlayColor), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('MenuItemButton')), + ], + child: const Text('Submenu'), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(SubmenuButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + }); + + testWidgets( + 'Garbage collector destroys child _MenuAnchorState after parent is closed', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/149584 + await tester.pumpWidget( + MaterialApp( + home: MenuAnchor( + controller: controller, + menuChildren: const <Widget>[ + SubmenuButton(menuChildren: <Widget>[], child: Text('')), + ], + ), + ), + ); + + controller.open(); + await tester.pump(); + + final state = WeakReference<State>( + tester.firstState<State<SubmenuButton>>(find.byType(SubmenuButton)), + ); + expect(state.target, isNotNull); + + controller.close(); + await tester.pump(); + + controller.open(); + await tester.pump(); + + controller.close(); + await tester.pump(); + + // Garbage collect. 1 should be enough, but 3 prevents flaky tests. + await tester.runAsync<void>(() async { + await forceGC(fullGcCycles: 3); + }); + + expect(state.target, isNull); + }, + // Skipped on Web: [intended] ForceGC does not work in web and in release mode. See https://api.flutter.dev/flutter/package-leak_tracker_leak_tracker/forceGC.html + // Skipped for everyone else: forceGC is flaky, see https://github.com/flutter/flutter/issues/154858 + skip: true, + ); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('MenuItemButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: MenuItemButton( + style: MenuItemButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + ), + onPressed: enabled ? () {} : null, + trailingIcon: const Icon(Icons.add), + child: const Text('Button'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('SubmenuButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: SubmenuButton( + style: SubmenuButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + ), + trailingIcon: const Icon(Icons.add), + menuChildren: <Widget>[if (enabled) const Text('Item')], + child: const Text('SubmenuButton'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + }); + + // Regression test for https://github.com/flutter/flutter/issues/155034. + testWidgets('Content is shown in the root overlay when useRootOverlay is true', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + final overlayKey = UniqueKey(); + final menuItemKey = UniqueKey(); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + Widget boilerplate() { + return MaterialApp( + home: Overlay( + key: overlayKey, + initialEntries: <OverlayEntry>[ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: MenuAnchor( + useRootOverlay: true, + controller: controller, + menuChildren: <Widget>[ + MenuItemButton( + key: menuItemKey, + onPressed: () {}, + child: const Text('Item 1'), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + await tester.pumpWidget(boilerplate()); + expect(find.byKey(menuItemKey), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + expect(find.byKey(menuItemKey), findsOne); + + // Expect two overlays: the root overlay created by MaterialApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Iterable<Overlay> overlays = tester.widgetList<Overlay>(find.byType(Overlay)); + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + final Overlay rootOverlay = overlays.firstWhere( + (Overlay overlay) => overlay != nonRootOverlay, + ); + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect( + ancestorRenderTheaters(tester.renderObject(find.byKey(menuItemKey))).single, + tester.renderObject(find.byWidget(rootOverlay)), + ); + }); + + testWidgets('Content is shown in the nearest ancestor overlay when useRootOverlay is false', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + final overlayKey = UniqueKey(); + final menuItemKey = UniqueKey(); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + Widget boilerplate() { + return MaterialApp( + home: Overlay( + key: overlayKey, + initialEntries: <OverlayEntry>[ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: MenuAnchor( + controller: controller, + menuChildren: <Widget>[ + MenuItemButton( + key: menuItemKey, + onPressed: () {}, + child: const Text('Item 1'), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + await tester.pumpWidget(boilerplate()); + expect(find.byKey(menuItemKey), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + expect(find.byKey(menuItemKey), findsOne); + + // Expect two overlays: the root overlay created by MaterialApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect( + ancestorRenderTheaters(tester.renderObject(find.byKey(menuItemKey))).first, + tester.renderObject(find.byWidget(nonRootOverlay)), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/156572. + testWidgets('Unattached MenuController does not throw when calling close', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + controller.close(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Unattached MenuController returns false when calling isOpen', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + expect(controller.isOpen, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/157606. + testWidgets('MenuAnchor updates isOpen state correctly', (WidgetTester tester) async { + var isOpen = false; + var openCount = 0; + var closeCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: MenuAnchor( + menuChildren: const <Widget>[MenuItemButton(child: Text('menu item'))], + builder: (BuildContext context, MenuController controller, Widget? child) { + isOpen = controller.isOpen; + return FilledButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Text(isOpen ? 'close' : 'open'), + ); + }, + onOpen: () => openCount++, + onClose: () => closeCount++, + ), + ), + ), + ), + ); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 0); + expect(closeCount, 0); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(find.text('close'), findsOneWidget); + expect(isOpen, true); + expect(openCount, 1); + expect(closeCount, 0); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 1); + expect(closeCount, 1); + }); + + testWidgets('SubmenuButton.submenuIcon updates default arrow icon', ( + WidgetTester tester, + ) async { + const IconData disabledIcon = Icons.close; + const IconData hoveredIcon = Icons.bolt; + const IconData focusedIcon = Icons.favorite; + const IconData defaultIcon = Icons.add; + final WidgetStateProperty<Widget?> submenuIcon = WidgetStateProperty.resolveWith<Widget?>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.disabled)) { + return const Icon(disabledIcon); + } + if (states.contains(WidgetState.hovered)) { + return const Icon(hoveredIcon); + } + if (states.contains(WidgetState.focused)) { + return const Icon(focusedIcon); + } + return const Icon(defaultIcon); + }); + + Widget buildMenu({WidgetStateProperty<Widget?>? icon, bool enabled = true}) { + return MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + SubmenuButton( + submenuIcon: icon, + menuChildren: enabled + ? <Widget>[MenuItemButton(child: Text(TestMenu.mainMenu0.label))] + : <Widget>[], + child: Text(TestMenu.subSubMenu110.label), + ), + ], + child: Text(TestMenu.subMenu00.label), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildMenu()); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_right), findsOneWidget); + + controller.close(); + await tester.pump(); + + await tester.pumpWidget(buildMenu(icon: submenuIcon)); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + expect(find.byIcon(defaultIcon), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.byIcon(focusedIcon), findsOneWidget); + + controller.close(); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + await hoverOver(tester, find.text(TestMenu.subSubMenu110.label)); + await tester.pump(); + expect(find.byIcon(hoveredIcon), findsOneWidget); + + controller.close(); + await tester.pump(); + + await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false)); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + expect(find.byIcon(disabledIcon), findsOneWidget); + }); + }); + + group('Mouse cursors', () { + testWidgets('SubmenuButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Test menu button item')), + ], + child: const Text('Main Menu'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.byType(SubmenuButton))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('MenuItemButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Test menu item')), + ], + child: const Text('File'), + ), + ], + ), + ), + ), + ); + + // Open SubmenuButton. + await tester.tap(find.text('File')); + await tester.pumpAndSettle(); + + final Finder menuItemFinder = find.byType(MenuItemButton); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + // Move to MenuItemButton. + await gesture.moveTo(tester.getCenter(menuItemFinder)); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('CheckboxMenuButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + bool? value = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CheckboxMenuButton( + value: value, + onChanged: (bool? newValue) { + value = newValue; + }, + child: const Text('Checkbox'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.byType(CheckboxMenuButton))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('RadioMenuButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + int? groupValue = 0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RadioMenuButton<int>( + value: 1, + groupValue: groupValue, + onChanged: (int? newValue) { + groupValue = newValue; + }, + child: const Text('Radio'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(1000, 1000)); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(tester.getCenter(find.byType(RadioMenuButton<int>))); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('MenuItemButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuItemButton( + onPressed: () {}, + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + child: const Text('Menu Item'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(MenuItemButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('CheckboxMenuButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CheckboxMenuButton( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + value: true, + onChanged: (bool? value) {}, + child: const Text('Menu Item'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(CheckboxMenuButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('RadioMenuButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RadioMenuButton<bool>( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + value: false, + onChanged: (bool? value) {}, + groupValue: null, + child: const Text('Menu Item'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(RadioMenuButton<bool>))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + + testWidgets('SubmenuButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + children: <Widget>[ + SubmenuButton( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.cell), + ), + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Test menu item')), + ], + child: const Text('File'), + ), + ], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(SubmenuButton))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.cell, + ); + }); + }); + + group('Animations', () { + testWidgets('Animations can be disabled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Item 0'))], + ), + ), + ), + ); + + // When animations are enabled, this will return the opacity of the + // FadeTransition wrapping the MenuItemButton. + // + // When animations are disabled, this will return the opacity of the + // FadeTransition wrapping the MenuAnchor's surface. + double getOpacity(String text) { + return tester + .firstWidget<FadeTransition>( + find.ancestor( + of: find.widgetWithText(MenuItemButton, text), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; + } + + // Open the menu and verify animations are enabled. + controller.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(getOpacity('Item 0'), closeTo(0.2, 0.05)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Item 0'))], + ), + ), + ), + ); + + expect(getOpacity('Item 0'), equals(1.0)); + }); + + testWidgets('MenuAnchor children can be changed', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Item 0'))], + ), + ), + ), + ); + + // When animations are enabled, this will return the opacity of the + // FadeTransition wrapping the MenuItemButton. + // + // When animations are disabled, this will return the opacity of the + // FadeTransition wrapping the MenuAnchor's surface. + double getOpacity(String text) { + return tester + .firstWidget<FadeTransition>( + find.ancestor( + of: find.widgetWithText(MenuItemButton, text), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; + } + + controller.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(getOpacity('Item 0'), closeTo(0.2, 0.05)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Item 1'))], + ), + ), + ), + ); + + expect(getOpacity('Item 1'), closeTo(0.2, 0.05)); + }); + + testWidgets('Menu panel fades in', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + MenuItemButton(onPressed: () {}, child: const Text('Item 1')), + MenuItemButton(onPressed: () {}, child: const Text('Item 2')), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + double getOpacity() { + return tester + .firstWidget<FadeTransition>( + find.ancestor(of: findMenuPanels(), matching: find.byType(FadeTransition)), + ) + .opacity + .value; + } + + // Opacity values at different millisecond offsets during the 50 ms fade-in animation. + const animationOpacities = <int, double>{0: 0.0, 10: 0.2, 20: 0.4, 30: 0.6, 40: 0.8, 50: 1.0}; + + for (final int ms in animationOpacities.keys) { + expect(getOpacity(), closeTo(animationOpacities[ms]!, 0.05), reason: 'at t=$ms'); + await tester.pump(const Duration(milliseconds: 10)); + } + }); + + testWidgets('Menu panel fades out', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + MenuItemButton(onPressed: () {}, child: const Text('Item 1')), + MenuItemButton(onPressed: () {}, child: const Text('Item 2')), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + controller.close(); + await tester.pump(); + + double getOpacity() { + return tester + .firstWidget<FadeTransition>( + find.ancestor(of: findMenuPanels(), matching: find.byType(FadeTransition)), + ) + .opacity + .value; + } + + // Opacity values at different millisecond offsets during the 50 ms fade-in animation. + const animationOpacities = <int, double>{110: 1.0, 120: 0.8, 130: 0.6, 140: 0.4, 150: 0.2}; + + expect(getOpacity(), closeTo(1.0, 0.05), reason: 'at t=0'); + + await tester.pump(const Duration(milliseconds: 100)); + + for (final int ms in animationOpacities.keys) { + expect(getOpacity(), closeTo(animationOpacities[ms]!, 0.05), reason: 'at t=$ms'); + await tester.pump(const Duration(milliseconds: 10)); + } + }); + + testWidgets('Menu panel height eases out while opening', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + style: const MenuStyle( + padding: WidgetStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.zero), + ), + controller: controller, + animated: true, + // Different platforms have different default menu item heights. + // To make the test consistent across platforms, use a fixed + // height. + menuChildren: const <Widget>[SizedBox(height: 160)], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + const animatedHeights = <int, double>{ + 0: 0.0, + 100: 60.5, + 200: 125.5, + 300: 148, + 400: 157.5, + 500: 160, + }; + + final Finder panel = find.descendant( + of: find.byType(MenuAnchor), + matching: find.byType(FocusScope), + ); + for (final int key in animatedHeights.keys) { + final double height = tester.getSize(panel).height; + expect(height, closeTo(animatedHeights[key]!, 1), reason: 'at t=$key'); + await tester.pump(const Duration(milliseconds: 100)); + } + }); + + testWidgets('Menu panel height eases in while closing', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + style: const MenuStyle( + padding: WidgetStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.zero), + ), + controller: controller, + animated: true, + // Different platforms have different default menu item heights. + // To make the test consistent across platforms, use a fixed + // height. + menuChildren: const <Widget>[SizedBox(height: 160)], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pumpAndSettle(); + controller.close(); + await tester.pump(); + + const animatedHeights = <int, double>{ + 100: 129, + 110: 120, + 120: 110, + 130: 97, + 140: 80, + 150: 0.0, + }; + + double getHeight() { + final Finder panel = find.descendant( + of: find.byType(MenuAnchor), + matching: find.byType(FocusScope), + ); + return tester.getSize(panel).height; + } + + expect(getHeight(), 160, reason: 'at t=0'); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(getHeight(), closeTo(153, 2.0), reason: 'at t=50'); + + await tester.pump(const Duration(milliseconds: 50)); + + for (final int ms in animatedHeights.keys) { + expect(getHeight(), closeTo(animatedHeights[ms]!, 2.0), reason: 'at t=$ms'); + await tester.pump(const Duration(milliseconds: 10)); + } + }); + + testWidgets('Item fade-in animation staggers over 500 ms', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + MenuItemButton(onPressed: () {}, child: const Text('Item 1')), + MenuItemButton(onPressed: () {}, child: const Text('Item 2')), + MenuItemButton(onPressed: () {}, child: const Text('Item 3')), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + // Get opacity values at the start of animation + double getOpacity(String text) { + return tester + .firstWidget<FadeTransition>( + find.ancestor( + of: find.widgetWithText(MenuItemButton, text), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; + } + + // Opacity values at different millisecond offsets during the 500ms opening animation. + const values = <int, List<double>>{ + 100: <double>[0.400, 0.0667, 0.00, 0.00], + 200: <double>[0.800, 0.467, 0.133, 0.00], + 300: <double>[1.00, 0.867, 0.533, 0.200], + 400: <double>[1.00, 1.00, 0.933, 0.600], + 500: <double>[1.00, 1.00, 1.00, 1.00], + }; + + for (final int time in values.keys) { + await tester.pump(const Duration(milliseconds: 100)); + final List<double> expected = values[time]!; + expect(getOpacity('Item 0'), closeTo(expected[0], 0.05), reason: 'at t=$time'); + expect(getOpacity('Item 1'), closeTo(expected[1], 0.05), reason: 'at t=$time'); + expect(getOpacity('Item 2'), closeTo(expected[2], 0.05), reason: 'at t=$time'); + expect(getOpacity('Item 3'), closeTo(expected[3], 0.05), reason: 'at t=$time'); + } + }); + + testWidgets('Item fade-out animation staggers over 150 ms with 50 ms delay', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Open Menu'), + ); + }, + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + MenuItemButton(onPressed: () {}, child: const Text('Item 1')), + MenuItemButton(onPressed: () {}, child: const Text('Item 2')), + MenuItemButton(onPressed: () {}, child: const Text('Item 3')), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 501)); + controller.close(); + await tester.pump(); + + // Get opacity values at the start of animation + double getOpacity(String text) { + final Finder fadeTransition = find.widgetWithText(FadeTransition, text); + return tester.firstWidget<FadeTransition>(fadeTransition).opacity.value; + } + + // Opacity values at different millisecond offsets during the 150ms close animation. + const menuItemOpacities = <int, List<double>>{ + 0: <double>[1.00, 1.00, 1.00, 1.00], + 25: <double>[1.00, 1.00, 1.00, 1.00], + 50: <double>[1.00, 1.00, 1.00, 1.00], + 75: <double>[1.00, 1.00, 0.833, 0.500], + 100: <double>[1.00, 0.667, 0.333, 0.00], + 125: <double>[0.500, 0.167, 0.00, 0.00], + 150: <double>[0.00, 0.00, 0.00, 0.00], + }; + + for (final int time in menuItemOpacities.keys) { + final List<double> expected = menuItemOpacities[time]!; + expect(getOpacity('Item 0'), closeTo(expected[0], 0.05), reason: 'at t=$time'); + expect(getOpacity('Item 1'), closeTo(expected[1], 0.05), reason: 'at t=$time'); + expect(getOpacity('Item 2'), closeTo(expected[2], 0.05), reason: 'at t=$time'); + expect(getOpacity('Item 3'), closeTo(expected[3], 0.05), reason: 'at t=$time'); + await tester.pump(const Duration(milliseconds: 25)); + } + }); + + testWidgets('MenuAnchor calls onAnimationStatusChanged with correct values', ( + WidgetTester tester, + ) async { + AnimationStatus? animationStatus; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + onAnimationStatusChanged: (AnimationStatus status) { + animationStatus = status; + }, + menuChildren: <Widget>[MenuItemButton(onPressed: () {}, child: const Text('Item 0'))], + ), + ), + ), + ); + + // Open the menu + controller.open(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.forward)); + + await tester.pump(const Duration(milliseconds: 200)); + + expect(animationStatus, equals(AnimationStatus.forward)); + + controller.close(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.reverse)); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(animationStatus, equals(AnimationStatus.reverse)); + + controller.open(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.forward)); + + await tester.pumpAndSettle(); + + expect(animationStatus, equals(AnimationStatus.completed)); + + controller.close(); + + await tester.pumpAndSettle(); + + expect(animationStatus, equals(AnimationStatus.dismissed)); + }); + + testWidgets( + 'MenuAnchor without animations calls onAnimationStatusChanged with correct values', + (WidgetTester tester) async { + AnimationStatus? animationStatus; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + onAnimationStatusChanged: (AnimationStatus status) { + animationStatus = status; + }, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + ], + ), + ), + ), + ); + + // Open the menu + controller.open(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.completed)); + + controller.close(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.dismissed)); + + controller.open(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.completed)); + }, + ); + + testWidgets('SubmenuButton onAnimationStatusChanged is invoked with correct values', ( + WidgetTester tester, + ) async { + AnimationStatus? animationStatus; + final submenuController = MenuController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + SubmenuButton( + controller: submenuController, + animated: true, + onAnimationStatusChanged: (AnimationStatus status) { + animationStatus = status; + }, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + ], + child: const Text('Submenu'), + ), + ], + ), + ), + ), + ); + + // Open the menu + controller.open(); + await tester.pump(); + submenuController.open(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.forward)); + + await tester.pump(const Duration(milliseconds: 200)); + + expect(animationStatus, equals(AnimationStatus.forward)); + + submenuController.close(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.reverse)); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(animationStatus, equals(AnimationStatus.reverse)); + + submenuController.open(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.forward)); + + await tester.pumpAndSettle(); + + expect(animationStatus, equals(AnimationStatus.completed)); + + submenuController.close(); + + await tester.pumpAndSettle(); + + expect(animationStatus, equals(AnimationStatus.dismissed)); + + submenuController.open(); + await tester.pumpAndSettle(); + + expect(animationStatus, equals(AnimationStatus.completed)); + + controller.close(); + await tester.pump(); + + expect(animationStatus, equals(AnimationStatus.reverse)); + + await tester.pump(const Duration(milliseconds: 50)); + + submenuController.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 25)); + + // Reopen request should be ignored since the parent menu is closing. + expect(animationStatus, equals(AnimationStatus.reverse)); + + await tester.pump(); + await tester.pumpAndSettle(); + + expect(animationStatus, equals(AnimationStatus.dismissed)); + }); + + testWidgets('Menu items can be pressed during opening animation', (WidgetTester tester) async { + var pressCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: () { + pressCount += 1; + }, + child: const Text('Item 0'), + ), + MenuItemButton( + onPressed: () { + pressCount += 1; + }, + child: const Text('Item 1'), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.text('Item 0')); + await tester.pump(); + + expect(pressCount, 1); + + controller.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.text('Item 1')); + await tester.pump(); + + expect(pressCount, 2); + }); + + testWidgets('Pointer is blocked while menu is closing', (WidgetTester tester) async { + var itemPressed = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: () { + itemPressed = true; + }, + child: const Text('Item 0'), + ), + ], + ), + ), + ), + ); + + // Open and wait for animation to complete + controller.open(); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + + // Start closing + controller.close(); + await tester.pump(); + + // Try to tap the item while closing + await tester.tap(find.text('Item 0'), warnIfMissed: false); + await tester.pump(); + + // Item should not have been pressed + expect(itemPressed, false); + }); + + testWidgets('Menu items can be focused during opening animation', (WidgetTester tester) async { + final item0FocusNode = FocusNode(); + final item1FocusNode = FocusNode(); + addTearDown(item0FocusNode.dispose); + addTearDown(item1FocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + focusNode: item0FocusNode, + onPressed: () {}, + child: const Text('Item 0'), + ), + MenuItemButton( + focusNode: item1FocusNode, + onPressed: () {}, + child: const Text('Item 1'), + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + item0FocusNode.requestFocus(); + await tester.pump(); + + expect(item0FocusNode.hasFocus, true); + + item1FocusNode.requestFocus(); + await tester.pump(); + + expect(item1FocusNode.hasFocus, true); + }); + + testWidgets('Focus returns to parent scope during closing animation', ( + WidgetTester tester, + ) async { + final submenuFocusNode = FocusNode(); + final outsideFocusNode = FocusNode(); + addTearDown(submenuFocusNode.dispose); + addTearDown(outsideFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 400, minHeight: 400), + child: Column( + children: <Widget>[ + TextButton( + onPressed: () {}, + focusNode: outsideFocusNode, + child: const Text('Outside'), + ), + MenuAnchor( + controller: controller, + animated: true, + builder: (BuildContext context, MenuController controller, Widget? child) { + return TextButton( + autofocus: true, + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Text('Open Menu'), + ); + }, + menuChildren: <Widget>[ + MenuItemButton( + autofocus: true, + onPressed: () {}, + child: const Text('Item 0'), + ), + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + focusNode: submenuFocusNode, + onPressed: () {}, + child: const Text('Nested'), + ), + ], + child: const Text('Submenu'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + + outsideFocusNode.requestFocus(); + await tester.pump(); + + expect(outsideFocusNode.hasPrimaryFocus, true); + + await tester.tap(find.text('Open Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + await hoverOver(tester, find.text('Submenu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Nested'), findsOneWidget); + + submenuFocusNode.requestFocus(); + await tester.pump(); + + expect(submenuFocusNode.hasPrimaryFocus, true); + + controller.close(); + await tester.pump(); + + expect(outsideFocusNode.hasPrimaryFocus, true); + + await tester.pump(const Duration(milliseconds: 50)); + + expect(outsideFocusNode.hasPrimaryFocus, true); + + await tester.pumpAndSettle(); + + expect(outsideFocusNode.hasPrimaryFocus, true); + }); + + testWidgets('Submenu items can be traversed during opening animation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Sub Item 0')), + MenuItemButton(onPressed: () {}, child: const Text('Sub Item 1')), + ], + child: const Text('Submenu'), + ), + ], + child: const Text('Menu'), + ), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + await tester.tap(find.text('Menu')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(focusedMenu, equals('SubmenuButton(Text("Menu"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(focusedMenu, equals('MenuItemButton(Text("Item 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(focusedMenu, equals('SubmenuButton(Text("Submenu"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Sub Item 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Sub Item 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(focusedMenu, equals('SubmenuButton(Text("Submenu"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Sub Item 0"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Item 0"))')); + }); + + testWidgets('Hover traversal works during opening animation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Item 0')), + MenuItemButton(onPressed: () {}, child: const Text('Item 1')), + MenuItemButton(onPressed: () {}, child: const Text('Item 2')), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + controller.open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await hoverOver(tester, find.text('Item 0')); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Item 0"))')); + + await tester.pump(const Duration(milliseconds: 100)); + await hoverOver(tester, find.text('Item 1')); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Item 1"))')); + + await tester.pump(const Duration(milliseconds: 100)); + await hoverOver(tester, find.text('Item 2')); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Item 2"))')); + + await tester.pump(const Duration(milliseconds: 200)); + await hoverOver(tester, find.text('Item 0')); + await tester.pump(); + + expect(focusedMenu, equals('MenuItemButton(Text("Item 0"))')); + }); + + testWidgets('MenuBar items can be traversed during submenu opening animation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Menu 0 Item')), + ], + child: const Text('Menu 0'), + ), + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Menu 1 Item')), + ], + child: const Text('Menu 1'), + ), + SubmenuButton( + animated: true, + menuChildren: <Widget>[ + MenuItemButton(onPressed: () {}, child: const Text('Menu 2 Item')), + ], + child: const Text('Menu 2'), + ), + ], + ), + ), + ), + ); + + listenForFocusChanges(); + + await tester.tap(find.text('Menu 0')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))')); + + await tester.pump(const Duration(milliseconds: 20)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))')); + }); + + testWidgets('Focus state remains consistent throughout opening animation', ( + WidgetTester tester, + ) async { + final itemFocusNode = FocusNode(); + addTearDown(itemFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + TextButton(onPressed: () {}, autofocus: true, child: const Text('Outside')), + MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + MenuItemButton( + focusNode: itemFocusNode, + onPressed: () {}, + child: const Text('Item 0'), + ), + MenuItemButton(autofocus: true, onPressed: () {}, child: const Text('Item 1')), + ], + ), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + itemFocusNode.requestFocus(); + await tester.pump(); + + expect(itemFocusNode.hasPrimaryFocus, true); + + await tester.pump(const Duration(milliseconds: 200)); + + expect(itemFocusNode.hasPrimaryFocus, true); + + await tester.pumpAndSettle(); + + expect(itemFocusNode.hasPrimaryFocus, true); + }); + + testWidgets('Scrollbar thumb is not shown while animated menu is opening', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + controller: controller, + animated: true, + menuChildren: <Widget>[ + for (int i = 0; i < 20; i++) + MenuItemButton(onPressed: () {}, child: Text('Item $i')), + ], + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + expect(tester.widget<Scrollbar>(find.byType(Scrollbar)).thumbVisibility, isFalse); + + // Progress the animation to the middle. + await tester.pump(const Duration(milliseconds: 250)); + + expect(tester.widget<Scrollbar>(find.byType(Scrollbar)).thumbVisibility, isFalse); + + await tester.pumpAndSettle(); + + expect(tester.widget<Scrollbar>(find.byType(Scrollbar)).thumbVisibility, isTrue); + }); + + // Regression test for https://github.com/flutter/flutter/issues/182929. + testWidgets('Positioned menus always begin animating at the target position', ( + WidgetTester tester, + ) async { + final controller = MenuController(); + const targetPosition = Offset(400.0, 400.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuAnchor( + style: const MenuStyle( + padding: WidgetStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.zero), + ), + controller: controller, + animated: true, + menuChildren: const <Widget>[SizedBox(height: 160)], + ), + ), + ), + ); + + controller.open(position: targetPosition); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2)); + + final Finder panel = find.descendant( + of: find.byType(MenuAnchor), + matching: find.byType(FocusScope), + ); + expect(tester.getTopLeft(panel), targetPosition); + + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(panel), targetPosition); + }); + }); + + testWidgets('Menu panel default reserved padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MenuAnchor( + controller: controller, + menuChildren: const <Widget>[SizedBox(width: 800, height: 24)], + builder: (BuildContext context, MenuController controller, Widget? child) { + return const SizedBox(width: 800, height: 24); + }, + ), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + const defaultReservedPadding = 8.0; // See _kMenuViewPadding. + expect(tester.getRect(findMenuPanels()).width, 800.0 - defaultReservedPadding * 2); + }); + + testWidgets('Menu panel accepts custom reserved padding', (WidgetTester tester) async { + const EdgeInsetsGeometry reservedPadding = EdgeInsets.symmetric(horizontal: 13.0); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: MenuAnchor( + controller: controller, + reservedPadding: reservedPadding, + menuChildren: const <Widget>[SizedBox(width: 800, height: 24)], + builder: (BuildContext context, MenuController controller, Widget? child) { + return const SizedBox(width: 800, height: 24); + }, + ), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + expect(tester.getRect(findMenuPanels()).width, 800.0 - reservedPadding.horizontal); + }); + + testWidgets('MenuAcceleratorLabel does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: MenuAcceleratorLabel('X'))), + ), + ); + expect(tester.getSize(find.byType(MenuAcceleratorLabel)), Size.zero); + }); + + testWidgets('Layout updates when reserved padding changes', (WidgetTester tester) async { + const EdgeInsetsGeometry reservedPadding = EdgeInsets.symmetric(horizontal: 13.0); + + await tester.pumpWidget( + MaterialApp( + home: MenuAnchor( + controller: controller, + menuChildren: const <Widget>[SizedBox(width: 800, height: 24)], + ), + ), + ); + + controller.open(position: Offset.zero); + await tester.pump(); + + await tester.pumpWidget( + MaterialApp( + home: MenuAnchor( + controller: controller, + reservedPadding: reservedPadding, + menuChildren: const <Widget>[SizedBox(width: 800, height: 24)], + ), + ), + ); + + expect(tester.getRect(findMenuPanels()).width, 800.0 - reservedPadding.horizontal); + }); + + testWidgets('SubmenuButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: SubmenuButton(menuChildren: <Widget>[], child: null)), + ), + ), + ); + expect(tester.getSize(find.byType(SubmenuButton)), Size.zero); + }); + + testWidgets('MenuBar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: MenuBar(children: <Widget>[Text('X')])), + ), + ), + ); + expect(tester.getSize(find.byType(MenuBar)), Size.zero); + }); + + testWidgets('MenuItemButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: MenuItemButton())), + ), + ); + expect(tester.getSize(find.byType(MenuItemButton)), Size.zero); + }); + + testWidgets('RadioMenuButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: RadioMenuButton<bool>( + value: true, + groupValue: true, + onChanged: (bool? value) {}, + child: null, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(RadioMenuButton<bool>)), Size.zero); + }); + + testWidgets('CheckboxMenuButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: CheckboxMenuButton( + value: true, + onChanged: (bool? value) {}, + child: const Text('X'), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CheckboxMenuButton)), Size.zero); + }); + + testWidgets('MenuAnchor does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final menuController = MenuController(); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: MenuAnchor(menuChildren: const <Widget>[Text('X')], controller: menuController), + ), + ), + ), + ); + expect(tester.getSize(find.byType(MenuAnchor)), Size.zero); + menuController.open(); + await tester.pump(); + expect(find.text('X'), findsOne); + }); +} + +List<Widget> createTestMenus({ + void Function(TestMenu)? onPressed, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, + Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{}, + bool includeExtraGroups = false, + bool accelerators = false, +}) { + Widget submenuButton(TestMenu menu, {required List<Widget> menuChildren}) { + return SubmenuButton( + onOpen: onOpen != null ? () => onOpen(menu) : null, + onClose: onClose != null ? () => onClose(menu) : null, + menuChildren: menuChildren, + child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label), + ); + } + + Widget menuItemButton( + TestMenu menu, { + bool enabled = true, + Widget? leadingIcon, + Widget? trailingIcon, + Key? key, + }) { + return MenuItemButton( + key: key, + onPressed: enabled && onPressed != null ? () => onPressed(menu) : null, + shortcut: shortcuts[menu], + leadingIcon: leadingIcon, + trailingIcon: trailingIcon, + child: accelerators ? MenuAcceleratorLabel(menu.acceleratorLabel) : Text(menu.label), + ); + } + + final result = <Widget>[ + submenuButton( + TestMenu.mainMenu0, + menuChildren: <Widget>[ + menuItemButton(TestMenu.subMenu00, leadingIcon: const Icon(Icons.add)), + menuItemButton(TestMenu.subMenu01), + menuItemButton(TestMenu.subMenu02), + ], + ), + submenuButton( + TestMenu.mainMenu1, + menuChildren: <Widget>[ + menuItemButton(TestMenu.subMenu10), + submenuButton( + TestMenu.subMenu11, + menuChildren: <Widget>[ + menuItemButton(TestMenu.subSubMenu110, key: UniqueKey()), + menuItemButton(TestMenu.subSubMenu111), + menuItemButton(TestMenu.subSubMenu112), + menuItemButton(TestMenu.subSubMenu113), + ], + ), + menuItemButton(TestMenu.subMenu12), + ], + ), + submenuButton( + TestMenu.mainMenu2, + menuChildren: <Widget>[ + menuItemButton(TestMenu.subMenu20, leadingIcon: const Icon(Icons.ac_unit), enabled: false), + ], + ), + if (includeExtraGroups) + submenuButton( + TestMenu.mainMenu3, + menuChildren: <Widget>[menuItemButton(TestMenu.subMenu30, enabled: false)], + ), + if (includeExtraGroups) + submenuButton( + TestMenu.mainMenu4, + menuChildren: <Widget>[ + menuItemButton(TestMenu.subMenu40, enabled: false), + menuItemButton(TestMenu.subMenu41, enabled: false), + menuItemButton(TestMenu.subMenu42, enabled: false), + ], + ), + submenuButton(TestMenu.mainMenu5, menuChildren: const <Widget>[]), + ]; + return result; +} + +enum TestMenu { + mainMenu0('&Menu 0'), + mainMenu1('M&enu &1'), + mainMenu2('Me&nu 2'), + mainMenu3('Men&u 3'), + mainMenu4('Menu &4'), + mainMenu5('Menu &5 && &6 &'), + subMenu00('Sub &Menu 0&0'), + subMenu01('Sub Menu 0&1'), + subMenu02('Sub Menu 0&2'), + subMenu10('Sub Menu 1&0'), + subMenu11('Sub Menu 1&1'), + subMenu12('Sub Menu 1&2'), + subMenu20('Sub Menu 2&0'), + subMenu30('Sub Menu 3&0'), + subMenu40('Sub Menu 4&0'), + subMenu41('Sub Menu 4&1'), + subMenu42('Sub Menu 4&2'), + subSubMenu110('Sub Sub Menu 11&0'), + subSubMenu111('Sub Sub Menu 11&1'), + subSubMenu112('Sub Sub Menu 11&2'), + subSubMenu113('Sub Sub Menu 11&3'), + anchorButton('Press Me'), + outsideButton('Outside'); + + const TestMenu(this.acceleratorLabel); + final String acceleratorLabel; + // Strip the accelerator markers. + String get label => MenuAcceleratorLabel.stripAcceleratorMarkers(acceleratorLabel); +} diff --git a/packages/material_ui/test/material/menu_bar_theme_test.dart b/packages/material_ui/test/material/menu_bar_theme_test.dart new file mode 100644 index 000000000000..c22f1e768ca0 --- /dev/null +++ b/packages/material_ui/test/material/menu_bar_theme_test.dart @@ -0,0 +1,343 @@ +// 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() { + void onPressed(TestMenu item) {} + + Finder findMenuPanels(Axis orientation) { + return find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_MenuPanel' && + (widget as dynamic).orientation == orientation; + }); + } + + Finder findMenuBarPanel() { + return findMenuPanels(Axis.horizontal); + } + + Finder findSubmenuPanel() { + return findMenuPanels(Axis.vertical); + } + + Finder findSubMenuItem() { + return find.descendant(of: findSubmenuPanel().last, matching: find.byType(MenuItemButton)); + } + + Material getMenuBarPanelMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findMenuBarPanel(), matching: find.byType(Material)).first, + ); + } + + Material getSubmenuPanelMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findSubmenuPanel(), matching: find.byType(Material)).first, + ); + } + + DefaultTextStyle getLabelStyle(WidgetTester tester, String labelText) { + return tester.widget<DefaultTextStyle>( + find.ancestor(of: find.text(labelText), matching: find.byType(DefaultTextStyle)).first, + ); + } + + test('MenuBarThemeData lerp special cases', () { + expect(MenuBarThemeData.lerp(null, null, 0), null); + const data = MenuBarThemeData(); + expect(identical(MenuBarThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('theme is honored', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return MenuTheme( + data: const MenuThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.green), + elevation: MaterialStatePropertyAll<double?>(20.0), + ), + ), + child: MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.red), + elevation: MaterialStatePropertyAll<double?>(15.0), + shape: MaterialStatePropertyAll<OutlinedBorder?>(StadiumBorder()), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.all(10.0), + ), + ), + ), + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + }, + ), + ), + ), + ); + + // Open a test menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect( + tester.getRect(findMenuBarPanel().first), + equals(const Rect.fromLTRB(228.0, 0.0, 572.0, 68.0)), + ); + final Material menuBarMaterial = getMenuBarPanelMaterial(tester); + expect(menuBarMaterial.elevation, equals(15)); + expect(menuBarMaterial.color, equals(Colors.red)); + + final Material subMenuMaterial = getSubmenuPanelMaterial(tester); + expect( + tester.getRect(findSubmenuPanel()), + equals(const Rect.fromLTRB(346.0, 58.0, 560.0, 218.0)), + ); + expect(subMenuMaterial.elevation, equals(20)); + expect(subMenuMaterial.color, equals(Colors.green)); + }); + + testWidgets('Constructor parameters override theme parameters', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return MenuTheme( + data: const MenuThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.green), + elevation: MaterialStatePropertyAll<double?>(20.0), + ), + ), + child: MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.red), + elevation: MaterialStatePropertyAll<double?>(15.0), + shape: MaterialStatePropertyAll<OutlinedBorder?>(StadiumBorder()), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.all(10.0), + ), + ), + ), + child: Column( + children: <Widget>[ + MenuBar( + style: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.blue), + elevation: MaterialStatePropertyAll<double?>(10.0), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.all(12.0), + ), + ), + children: createTestMenus( + onPressed: onPressed, + menuBackground: Colors.cyan, + menuElevation: 18.0, + menuPadding: const EdgeInsetsDirectional.all(14.0), + menuShape: const BeveledRectangleBorder(), + itemBackground: Colors.amber, + itemForeground: Colors.grey, + itemOverlay: Colors.blueGrey, + itemPadding: const EdgeInsetsDirectional.all(11.0), + itemShape: const StadiumBorder(), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + }, + ), + ), + ), + ); + + // Open a test menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(findMenuBarPanel().first), + equals(const Rect.fromLTRB(226.0, 0.0, 574.0, 72.0)), + ); + final Material menuBarMaterial = getMenuBarPanelMaterial(tester); + expect(menuBarMaterial.elevation, equals(10.0)); + expect(menuBarMaterial.color, equals(Colors.blue)); + + final Material subMenuMaterial = getSubmenuPanelMaterial(tester); + expect( + tester.getRect(findSubmenuPanel()), + equals(const Rect.fromLTRB(332.0, 60.0, 574.0, 232.0)), + ); + expect(subMenuMaterial.elevation, equals(18)); + expect(subMenuMaterial.color, equals(Colors.cyan)); + expect(subMenuMaterial.shape, equals(const BeveledRectangleBorder())); + + final Finder menuItem = findSubMenuItem(); + expect(tester.getRect(menuItem.first), equals(const Rect.fromLTRB(346.0, 74.0, 560.0, 122.0))); + final Material menuItemMaterial = tester.widget<Material>( + find.ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)).first, + ); + expect(menuItemMaterial.color, equals(Colors.amber)); + expect(menuItemMaterial.elevation, equals(0.0)); + expect(menuItemMaterial.shape, equals(const StadiumBorder())); + expect(getLabelStyle(tester, TestMenu.subMenu10.label).style.color, equals(Colors.grey)); + final ButtonStyle? textButtonStyle = tester + .widget<TextButton>( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(TextButton)) + .first, + ) + .style; + expect( + textButtonStyle?.overlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + equals(Colors.blueGrey), + ); + }); +} + +List<Widget> createTestMenus({ + void Function(TestMenu)? onPressed, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, + Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{}, + bool includeStandard = false, + Color? itemOverlay, + Color? itemBackground, + Color? itemForeground, + EdgeInsetsDirectional? itemPadding, + Color? menuBackground, + EdgeInsetsDirectional? menuPadding, + OutlinedBorder? menuShape, + double? menuElevation, + OutlinedBorder? itemShape, +}) { + final menuStyle = MenuStyle( + padding: menuPadding != null ? MaterialStatePropertyAll<EdgeInsetsGeometry>(menuPadding) : null, + backgroundColor: menuBackground != null + ? MaterialStatePropertyAll<Color>(menuBackground) + : null, + elevation: menuElevation != null ? MaterialStatePropertyAll<double>(menuElevation) : null, + shape: menuShape != null ? MaterialStatePropertyAll<OutlinedBorder>(menuShape) : null, + ); + final itemStyle = ButtonStyle( + padding: itemPadding != null ? MaterialStatePropertyAll<EdgeInsetsGeometry>(itemPadding) : null, + shape: itemShape != null ? MaterialStatePropertyAll<OutlinedBorder>(itemShape) : null, + foregroundColor: itemForeground != null + ? MaterialStatePropertyAll<Color>(itemForeground) + : null, + backgroundColor: itemBackground != null + ? MaterialStatePropertyAll<Color>(itemBackground) + : null, + overlayColor: itemOverlay != null ? MaterialStatePropertyAll<Color>(itemOverlay) : null, + ); + final result = <Widget>[ + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu0) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu0) : null, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu00) : null, + shortcut: shortcuts[TestMenu.subMenu00], + child: Text(TestMenu.subMenu00.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu1) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu1) : null, + menuStyle: menuStyle, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu10) : null, + shortcut: shortcuts[TestMenu.subMenu10], + style: itemStyle, + child: Text(TestMenu.subMenu10.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.subMenu11) : null, + onClose: onClose != null ? () => onClose(TestMenu.subMenu11) : null, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu110) : null, + shortcut: shortcuts[TestMenu.subSubMenu110], + child: Text(TestMenu.subSubMenu110.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu111) : null, + shortcut: shortcuts[TestMenu.subSubMenu111], + child: Text(TestMenu.subSubMenu111.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu112) : null, + shortcut: shortcuts[TestMenu.subSubMenu112], + child: Text(TestMenu.subSubMenu112.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu113) : null, + shortcut: shortcuts[TestMenu.subSubMenu113], + child: Text(TestMenu.subSubMenu113.label), + ), + ], + child: Text(TestMenu.subMenu11.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu12) : null, + shortcut: shortcuts[TestMenu.subMenu12], + child: Text(TestMenu.subMenu12.label), + ), + ], + child: Text(TestMenu.mainMenu1.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu2) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu2) : null, + menuChildren: <Widget>[ + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu20], + // Always disabled. + child: Text(TestMenu.subMenu20.label), + ), + ], + child: Text(TestMenu.mainMenu2.label), + ), + ]; + return result; +} + +enum TestMenu { + mainMenu0('Menu 0'), + mainMenu1('Menu 1'), + mainMenu2('Menu 2'), + subMenu00('Sub Menu 00'), + subMenu10('Sub Menu 10'), + subMenu11('Sub Menu 11'), + subMenu12('Sub Menu 12'), + subMenu20('Sub Menu 20'), + subSubMenu110('Sub Sub Menu 110'), + subSubMenu111('Sub Sub Menu 111'), + subSubMenu112('Sub Sub Menu 112'), + subSubMenu113('Sub Sub Menu 113'); + + const TestMenu(this.label); + final String label; +} diff --git a/packages/material_ui/test/material/menu_button_theme_test.dart b/packages/material_ui/test/material/menu_button_theme_test.dart new file mode 100644 index 000000000000..feec54de8719 --- /dev/null +++ b/packages/material_ui/test/material/menu_button_theme_test.dart @@ -0,0 +1,14 @@ +// 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() { + test('MenuButtonThemeData lerp special cases', () { + expect(MenuButtonThemeData.lerp(null, null, 0), null); + const data = MenuButtonThemeData(); + expect(identical(MenuButtonThemeData.lerp(data, data, 0.5), data), true); + }); +} diff --git a/packages/material_ui/test/material/menu_style_test.dart b/packages/material_ui/test/material/menu_style_test.dart new file mode 100644 index 000000000000..28992d4a7dfd --- /dev/null +++ b/packages/material_ui/test/material/menu_style_test.dart @@ -0,0 +1,460 @@ +// 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() { + Finder findMenuPanels() { + return find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_MenuPanel'); + } + + Material getMenuBarMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findMenuPanels(), matching: find.byType(Material)).first, + ); + } + + Padding getMenuBarPadding(WidgetTester tester) { + return tester.widget<Padding>( + find.descendant(of: findMenuPanels(), matching: find.byType(Padding)).first, + ); + } + + Material getMenuMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findMenuPanels().at(1), matching: find.byType(Material)).first, + ); + } + + Padding getMenuPadding(WidgetTester tester) { + return tester.widget<Padding>( + find.descendant(of: findMenuPanels().at(1), matching: find.byType(Padding)).first, + ); + } + + group('MenuStyle', () { + test('MenuStyle lerp special cases', () { + expect(MenuStyle.lerp(null, null, 0), null); + const data = MenuStyle(); + expect(identical(MenuStyle.lerp(data, data, 0.5), data), true); + }); + + testWidgets('fixedSize affects geometry', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle(fixedSize: MaterialStatePropertyAll<Size>(Size(600, 60))), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle(fixedSize: MaterialStatePropertyAll<Size>(Size(100, 100))), + ), + child: MenuBar(children: createTestMenus(onPressed: (TestMenu menu) {})), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + // MenuBarTheme affects MenuBar. + expect( + tester.getRect(findMenuPanels().first), + equals(const Rect.fromLTRB(100.0, 0.0, 700.0, 60.0)), + ); + expect(tester.getRect(findMenuPanels().first).size, equals(const Size(600.0, 60.0))); + + // MenuTheme affects menus. + expect( + tester.getRect(findMenuPanels().at(1)), + equals(const Rect.fromLTRB(104.0, 54.0, 204.0, 154.0)), + ); + expect(tester.getRect(findMenuPanels().at(1)).size, equals(const Size(100.0, 100.0))); + }); + + testWidgets('maximumSize affects geometry', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle(maximumSize: MaterialStatePropertyAll<Size>(Size(250, 40))), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle(maximumSize: MaterialStatePropertyAll<Size>(Size(100, 100))), + ), + child: MenuBar(children: createTestMenus(onPressed: (TestMenu menu) {})), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + // MenuBarTheme affects MenuBar. + expect( + tester.getRect(findMenuPanels().first), + equals(const Rect.fromLTRB(275.0, 0.0, 525.0, 40.0)), + ); + expect(tester.getRect(findMenuPanels().first).size, equals(const Size(250.0, 40.0))); + + // MenuTheme affects menus. + expect( + tester.getRect(findMenuPanels().at(1)), + equals(const Rect.fromLTRB(279.0, 44.0, 379.0, 144.0)), + ); + expect(tester.getRect(findMenuPanels().at(1)).size, equals(const Size(100.0, 100.0))); + }); + + testWidgets('minimumSize affects geometry', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle(minimumSize: MaterialStatePropertyAll<Size>(Size(400, 60))), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle(minimumSize: MaterialStatePropertyAll<Size>(Size(300, 300))), + ), + child: MenuBar(children: createTestMenus(onPressed: (TestMenu menu) {})), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + // MenuBarTheme affects MenuBar. + expect( + tester.getRect(findMenuPanels().first), + equals(const Rect.fromLTRB(200.0, 0.0, 600.0, 60.0)), + ); + expect(tester.getRect(findMenuPanels().first).size, equals(const Size(400.0, 60.0))); + + // MenuTheme affects menus. + expect( + tester.getRect(findMenuPanels().at(1)), + equals(const Rect.fromLTRB(204.0, 54.0, 504.0, 354.0)), + ); + expect(tester.getRect(findMenuPanels().at(1)).size, equals(const Size(300.0, 300.0))); + }); + + testWidgets('Material parameters are honored', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.red), + shadowColor: MaterialStatePropertyAll<Color>(Colors.green), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.blue), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.all(10)), + elevation: MaterialStatePropertyAll<double>(10), + side: MaterialStatePropertyAll<BorderSide>( + BorderSide(color: Colors.redAccent), + ), + shape: MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()), + ), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color>(Colors.cyan), + shadowColor: MaterialStatePropertyAll<Color>(Colors.purple), + surfaceTintColor: MaterialStatePropertyAll<Color>(Colors.yellow), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(EdgeInsets.all(20)), + elevation: MaterialStatePropertyAll<double>(20), + side: MaterialStatePropertyAll<BorderSide>( + BorderSide(color: Colors.cyanAccent), + ), + shape: MaterialStatePropertyAll<OutlinedBorder>(StarBorder()), + ), + ), + child: MenuBar(children: createTestMenus(onPressed: (TestMenu menu) {})), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + + // Have to open a menu initially to start things going. + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + final Material menuBarMaterial = getMenuBarMaterial(tester); + final Padding menuBarPadding = getMenuBarPadding(tester); + final Material panelMaterial = getMenuMaterial(tester); + final Padding panelPadding = getMenuPadding(tester); + + // MenuBarTheme affects MenuBar. + expect(menuBarMaterial.color, equals(Colors.red)); + expect(menuBarMaterial.shadowColor, equals(Colors.green)); + expect(menuBarMaterial.surfaceTintColor, equals(Colors.blue)); + expect( + menuBarMaterial.shape, + equals(const StadiumBorder(side: BorderSide(color: Colors.redAccent))), + ); + expect(menuBarPadding.padding, equals(const EdgeInsets.all(10))); + + // MenuBarTheme affects menus. + expect(panelMaterial.color, equals(Colors.cyan)); + expect(panelMaterial.shadowColor, equals(Colors.purple)); + expect(panelMaterial.surfaceTintColor, equals(Colors.yellow)); + expect( + panelMaterial.shape, + equals(const StarBorder(side: BorderSide(color: Colors.cyanAccent))), + ); + expect(panelPadding.padding, equals(const EdgeInsets.all(20))); + }); + + testWidgets('visual density', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Column( + children: <Widget>[ + MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle(visualDensity: VisualDensity(horizontal: 1.5, vertical: -1.5)), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle( + visualDensity: VisualDensity(horizontal: 0.5, vertical: -0.5), + ), + ), + child: MenuBar(children: createTestMenus(onPressed: (TestMenu menu) {})), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ), + ); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(228.0, 0.0, 572.0, 48.0)), + ); + + // Open and make sure things are the right size. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(228.0, 0.0, 572.0, 48.0)), + ); + expect( + tester.getRect(find.text(TestMenu.subMenu10.label)), + equals(const Rect.fromLTRB(372.0, 70.0, 565.0, 84.0)), + ); + expect( + tester.getRect( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)) + .at(1), + ), + equals(const Rect.fromLTRB(352.0, 48.0, 585.0, 190.0)), + ); + }); + }); +} + +List<Widget> createTestMenus({ + void Function(TestMenu)? onPressed, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, + Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{}, + bool includeStandard = false, + bool includeExtraGroups = false, +}) { + final result = <Widget>[ + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu0) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu0) : null, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu00) : null, + shortcut: shortcuts[TestMenu.subMenu00], + child: Text(TestMenu.subMenu00.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu01) : null, + shortcut: shortcuts[TestMenu.subMenu01], + child: Text(TestMenu.subMenu01.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu02) : null, + shortcut: shortcuts[TestMenu.subMenu02], + child: Text(TestMenu.subMenu02.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu1) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu1) : null, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu10) : null, + shortcut: shortcuts[TestMenu.subMenu10], + child: Text(TestMenu.subMenu10.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.subMenu11) : null, + onClose: onClose != null ? () => onClose(TestMenu.subMenu11) : null, + menuChildren: <Widget>[ + MenuItemButton( + key: UniqueKey(), + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu110) : null, + shortcut: shortcuts[TestMenu.subSubMenu110], + child: Text(TestMenu.subSubMenu110.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu111) : null, + shortcut: shortcuts[TestMenu.subSubMenu111], + child: Text(TestMenu.subSubMenu111.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu112) : null, + shortcut: shortcuts[TestMenu.subSubMenu112], + child: Text(TestMenu.subSubMenu112.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu113) : null, + shortcut: shortcuts[TestMenu.subSubMenu113], + child: Text(TestMenu.subSubMenu113.label), + ), + ], + child: Text(TestMenu.subMenu11.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu12) : null, + shortcut: shortcuts[TestMenu.subMenu12], + child: Text(TestMenu.subMenu12.label), + ), + ], + child: Text(TestMenu.mainMenu1.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu2) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu2) : null, + menuChildren: <Widget>[ + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu20], + child: Text(TestMenu.subMenu20.label), + ), + ], + child: Text(TestMenu.mainMenu2.label), + ), + if (includeExtraGroups) + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu3) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu3) : null, + menuChildren: <Widget>[ + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu30], + // Always disabled. + child: Text(TestMenu.subMenu30.label), + ), + ], + child: Text(TestMenu.mainMenu3.label), + ), + if (includeExtraGroups) + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu4) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu4) : null, + menuChildren: <Widget>[ + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu40], + // Always disabled. + child: Text(TestMenu.subMenu40.label), + ), + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu41], + // Always disabled. + child: Text(TestMenu.subMenu41.label), + ), + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu42], + // Always disabled. + child: Text(TestMenu.subMenu42.label), + ), + ], + child: Text(TestMenu.mainMenu4.label), + ), + ]; + return result; +} + +enum TestMenu { + mainMenu0('Menu 0'), + mainMenu1('Menu 1'), + mainMenu2('Menu 2'), + mainMenu3('Menu 3'), + mainMenu4('Menu 4'), + subMenu00('Sub Menu 00'), + subMenu01('Sub Menu 01'), + subMenu02('Sub Menu 02'), + subMenu10('Sub Menu 10'), + subMenu11('Sub Menu 11'), + subMenu12('Sub Menu 12'), + subMenu20('Sub Menu 20'), + subMenu30('Sub Menu 30'), + subMenu40('Sub Menu 40'), + subMenu41('Sub Menu 41'), + subMenu42('Sub Menu 42'), + subSubMenu110('Sub Sub Menu 110'), + subSubMenu111('Sub Sub Menu 111'), + subSubMenu112('Sub Sub Menu 112'), + subSubMenu113('Sub Sub Menu 113'); + + const TestMenu(this.label); + final String label; +} diff --git a/packages/material_ui/test/material/menu_theme_test.dart b/packages/material_ui/test/material/menu_theme_test.dart new file mode 100644 index 000000000000..a8e053c23acf --- /dev/null +++ b/packages/material_ui/test/material/menu_theme_test.dart @@ -0,0 +1,474 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + void onPressed(TestMenu item) {} + + Finder findMenuPanels(Axis orientation) { + return find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_MenuPanel' && + (widget as dynamic).orientation == orientation; + }); + } + + Finder findMenuBarPanel() { + return findMenuPanels(Axis.horizontal); + } + + Finder findSubmenuPanel() { + return findMenuPanels(Axis.vertical); + } + + Finder findSubMenuItem() { + return find.descendant(of: findSubmenuPanel().last, matching: find.byType(MenuItemButton)); + } + + Material getMenuBarPanelMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findMenuBarPanel(), matching: find.byType(Material)).first, + ); + } + + Material getSubmenuPanelMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findSubmenuPanel(), matching: find.byType(Material)).first, + ); + } + + DefaultTextStyle getLabelStyle(WidgetTester tester, String labelText) { + return tester.widget<DefaultTextStyle>( + find.ancestor(of: find.text(labelText), matching: find.byType(DefaultTextStyle)).first, + ); + } + + Future<TestGesture> hoverOver(WidgetTester tester, Finder finder) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(finder)); + await tester.pumpAndSettle(); + return gesture; + } + + test('MenuThemeData defaults', () { + const menuThemeData = MenuThemeData(); + expect(menuThemeData.style, isNull); + expect(menuThemeData.submenuIcon, isNull); + }); + + testWidgets('Default MenuThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const MenuThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('MenuThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const MenuThemeData( + style: MenuStyle(backgroundColor: WidgetStatePropertyAll<Color?>(Color(0xfffffff1))), + submenuIcon: WidgetStatePropertyAll<Widget?>(Icon(Icons.add)), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'style: MenuStyle#c6d29(backgroundColor: WidgetStatePropertyAll(Color(alpha: 1.0000, red: 1.0000, green: 1.0000, blue: 0.9451, colorSpace: ColorSpace.sRGB)))', + 'submenuIcon: WidgetStatePropertyAll(Icon(IconData(U+0E047)))', + ]), + ); + }); + + test('MenuThemeData lerp special cases', () { + expect(MenuThemeData.lerp(null, null, 0), null); + const data = MenuThemeData(); + expect(identical(MenuThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('theme is honored', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.green), + elevation: MaterialStatePropertyAll<double?>(20.0), + ), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.red), + elevation: MaterialStatePropertyAll<double?>(15.0), + shape: MaterialStatePropertyAll<OutlinedBorder?>(StadiumBorder()), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.all(10.0), + ), + ), + ), + child: Column( + children: <Widget>[ + MenuBar(children: createTestMenus(onPressed: onPressed)), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + }, + ), + ), + ), + ); + + // Open a test menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + expect( + tester.getRect(findMenuBarPanel().first), + equals(const Rect.fromLTRB(234.0, 0.0, 566.0, 48.0)), + ); + final Material menuBarMaterial = getMenuBarPanelMaterial(tester); + expect(menuBarMaterial.elevation, equals(20)); + expect(menuBarMaterial.color, equals(Colors.green)); + + final Material subMenuMaterial = getSubmenuPanelMaterial(tester); + expect( + tester.getRect(findSubmenuPanel()), + equals(const Rect.fromLTRB(336.0, 48.0, 570.0, 212.0)), + ); + expect(subMenuMaterial.elevation, equals(15)); + expect(subMenuMaterial.color, equals(Colors.red)); + }); + + testWidgets('Constructor parameters override theme parameters', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return MenuBarTheme( + data: const MenuBarThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.green), + elevation: MaterialStatePropertyAll<double?>(20.0), + ), + ), + child: MenuTheme( + data: const MenuThemeData( + style: MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.red), + elevation: MaterialStatePropertyAll<double?>(15.0), + shape: MaterialStatePropertyAll<OutlinedBorder?>(StadiumBorder()), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.all(10.0), + ), + ), + ), + child: Column( + children: <Widget>[ + MenuBar( + style: const MenuStyle( + backgroundColor: MaterialStatePropertyAll<Color?>(Colors.blue), + elevation: MaterialStatePropertyAll<double?>(10.0), + padding: MaterialStatePropertyAll<EdgeInsetsGeometry>( + EdgeInsetsDirectional.all(12.0), + ), + ), + children: createTestMenus( + onPressed: onPressed, + menuBackground: Colors.cyan, + menuElevation: 18.0, + menuPadding: const EdgeInsetsDirectional.all(14.0), + menuShape: const BeveledRectangleBorder(), + itemBackground: Colors.amber, + itemForeground: Colors.grey, + itemOverlay: Colors.blueGrey, + itemPadding: const EdgeInsetsDirectional.all(11.0), + itemShape: const StadiumBorder(), + ), + ), + const Expanded(child: Placeholder()), + ], + ), + ), + ); + }, + ), + ), + ), + ); + + // Open a test menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + expect( + tester.getRect(findMenuBarPanel().first), + equals(const Rect.fromLTRB(226.0, 0.0, 574.0, 72.0)), + ); + final Material menuBarMaterial = getMenuBarPanelMaterial(tester); + expect(menuBarMaterial.elevation, equals(10.0)); + expect(menuBarMaterial.color, equals(Colors.blue)); + + final Material subMenuMaterial = getSubmenuPanelMaterial(tester); + expect( + tester.getRect(findSubmenuPanel()), + equals(const Rect.fromLTRB(332.0, 60.0, 574.0, 232.0)), + ); + expect(subMenuMaterial.elevation, equals(18)); + expect(subMenuMaterial.color, equals(Colors.cyan)); + expect(subMenuMaterial.shape, equals(const BeveledRectangleBorder())); + + final Finder menuItem = findSubMenuItem(); + expect(tester.getRect(menuItem.first), equals(const Rect.fromLTRB(346.0, 74.0, 560.0, 122.0))); + final Material menuItemMaterial = tester.widget<Material>( + find.ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(Material)).first, + ); + expect(menuItemMaterial.color, equals(Colors.amber)); + expect(menuItemMaterial.elevation, equals(0.0)); + expect(menuItemMaterial.shape, equals(const StadiumBorder())); + expect(getLabelStyle(tester, TestMenu.subMenu10.label).style.color, equals(Colors.grey)); + final ButtonStyle? textButtonStyle = tester + .widget<TextButton>( + find + .ancestor(of: find.text(TestMenu.subMenu10.label), matching: find.byType(TextButton)) + .first, + ) + .style; + expect( + textButtonStyle?.overlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + equals(Colors.blueGrey), + ); + }); + + testWidgets('SubmenuButton.submenuIcon updates default arrow icon', (WidgetTester tester) async { + final controller = MenuController(); + const IconData disabledIcon = Icons.close_fullscreen; + const IconData hoveredIcon = Icons.ac_unit; + const IconData focusedIcon = Icons.zoom_out; + const IconData defaultIcon = Icons.minimize; + final WidgetStateProperty<Widget?> submenuIcon = WidgetStateProperty.resolveWith<Widget?>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.disabled)) { + return const Icon(disabledIcon); + } + if (states.contains(WidgetState.hovered)) { + return const Icon(hoveredIcon); + } + if (states.contains(WidgetState.focused)) { + return const Icon(focusedIcon); + } + return const Icon(defaultIcon); + }); + + Widget buildMenu({WidgetStateProperty<Widget?>? icon, bool enabled = true}) { + return MaterialApp( + theme: ThemeData(menuTheme: MenuThemeData(submenuIcon: icon)), + home: Material( + child: MenuBar( + controller: controller, + children: <Widget>[ + SubmenuButton( + menuChildren: <Widget>[ + SubmenuButton( + menuChildren: enabled + ? <Widget>[MenuItemButton(child: Text(TestMenu.mainMenu0.label))] + : <Widget>[], + child: Text(TestMenu.subSubMenu110.label), + ), + ], + child: Text(TestMenu.subMenu00.label), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildMenu()); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + + expect(find.byIcon(Icons.arrow_right), findsOneWidget); + + controller.close(); + await tester.pump(); + + await tester.pumpWidget(buildMenu(icon: submenuIcon)); + await tester.pumpAndSettle(); + + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + expect(find.byIcon(defaultIcon), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.byIcon(focusedIcon), findsOneWidget); + + controller.close(); + await tester.pump(); + + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + await hoverOver(tester, find.text(TestMenu.subSubMenu110.label)); + await tester.pump(); + expect(find.byIcon(hoveredIcon), findsOneWidget); + + controller.close(); + await tester.pump(); + + await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false)); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + expect(find.byIcon(disabledIcon), findsOneWidget); + }); +} + +List<Widget> createTestMenus({ + void Function(TestMenu)? onPressed, + void Function(TestMenu)? onOpen, + void Function(TestMenu)? onClose, + Map<TestMenu, MenuSerializableShortcut> shortcuts = const <TestMenu, MenuSerializableShortcut>{}, + bool includeStandard = false, + Color? itemOverlay, + Color? itemBackground, + Color? itemForeground, + EdgeInsetsDirectional? itemPadding, + Color? menuBackground, + EdgeInsetsDirectional? menuPadding, + OutlinedBorder? menuShape, + double? menuElevation, + OutlinedBorder? itemShape, +}) { + final menuStyle = MenuStyle( + padding: menuPadding != null ? MaterialStatePropertyAll<EdgeInsetsGeometry>(menuPadding) : null, + backgroundColor: menuBackground != null + ? MaterialStatePropertyAll<Color>(menuBackground) + : null, + elevation: menuElevation != null ? MaterialStatePropertyAll<double>(menuElevation) : null, + shape: menuShape != null ? MaterialStatePropertyAll<OutlinedBorder>(menuShape) : null, + ); + final itemStyle = ButtonStyle( + padding: itemPadding != null ? MaterialStatePropertyAll<EdgeInsetsGeometry>(itemPadding) : null, + shape: itemShape != null ? MaterialStatePropertyAll<OutlinedBorder>(itemShape) : null, + foregroundColor: itemForeground != null + ? MaterialStatePropertyAll<Color>(itemForeground) + : null, + backgroundColor: itemBackground != null + ? MaterialStatePropertyAll<Color>(itemBackground) + : null, + overlayColor: itemOverlay != null ? MaterialStatePropertyAll<Color>(itemOverlay) : null, + ); + final result = <Widget>[ + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu0) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu0) : null, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu00) : null, + shortcut: shortcuts[TestMenu.subMenu00], + child: Text(TestMenu.subMenu00.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu1) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu1) : null, + menuStyle: menuStyle, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu10) : null, + shortcut: shortcuts[TestMenu.subMenu10], + style: itemStyle, + child: Text(TestMenu.subMenu10.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.subMenu11) : null, + onClose: onClose != null ? () => onClose(TestMenu.subMenu11) : null, + menuChildren: <Widget>[ + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu110) : null, + shortcut: shortcuts[TestMenu.subSubMenu110], + child: Text(TestMenu.subSubMenu110.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu111) : null, + shortcut: shortcuts[TestMenu.subSubMenu111], + child: Text(TestMenu.subSubMenu111.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu112) : null, + shortcut: shortcuts[TestMenu.subSubMenu112], + child: Text(TestMenu.subSubMenu112.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subSubMenu113) : null, + shortcut: shortcuts[TestMenu.subSubMenu113], + child: Text(TestMenu.subSubMenu113.label), + ), + ], + child: Text(TestMenu.subMenu11.label), + ), + MenuItemButton( + onPressed: onPressed != null ? () => onPressed(TestMenu.subMenu12) : null, + shortcut: shortcuts[TestMenu.subMenu12], + child: Text(TestMenu.subMenu12.label), + ), + ], + child: Text(TestMenu.mainMenu1.label), + ), + SubmenuButton( + onOpen: onOpen != null ? () => onOpen(TestMenu.mainMenu2) : null, + onClose: onClose != null ? () => onClose(TestMenu.mainMenu2) : null, + menuChildren: <Widget>[ + MenuItemButton( + // Always disabled. + shortcut: shortcuts[TestMenu.subMenu20], + // Always disabled. + child: Text(TestMenu.subMenu20.label), + ), + ], + child: Text(TestMenu.mainMenu2.label), + ), + ]; + return result; +} + +enum TestMenu { + mainMenu0('Menu 0'), + mainMenu1('Menu 1'), + mainMenu2('Menu 2'), + subMenu00('Sub Menu 00'), + subMenu10('Sub Menu 10'), + subMenu11('Sub Menu 11'), + subMenu12('Sub Menu 12'), + subMenu20('Sub Menu 20'), + subSubMenu110('Sub Sub Menu 110'), + subSubMenu111('Sub Sub Menu 111'), + subSubMenu112('Sub Sub Menu 112'), + subSubMenu113('Sub Sub Menu 113'); + + const TestMenu(this.label); + final String label; +} diff --git a/packages/material_ui/test/material/mergeable_material_test.dart b/packages/material_ui/test/material/mergeable_material_test.dart new file mode 100644 index 000000000000..1277d22998c7 --- /dev/null +++ b/packages/material_ui/test/material/mergeable_material_test.dart @@ -0,0 +1,1117 @@ +// 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'; + +enum RadiusType { Sharp, Shifting, Round } + +void matches(BorderRadius? borderRadius, RadiusType top, RadiusType bottom) { + final Radius cardRadius = kMaterialEdges[MaterialType.card]!.topLeft; + + switch (top) { + case RadiusType.Sharp: + expect(borderRadius?.topLeft, equals(Radius.zero)); + expect(borderRadius?.topRight, equals(Radius.zero)); + case RadiusType.Shifting: + expect(borderRadius?.topLeft.x, greaterThan(0.0)); + expect(borderRadius?.topLeft.x, lessThan(cardRadius.x)); + expect(borderRadius?.topLeft.y, greaterThan(0.0)); + expect(borderRadius?.topLeft.y, lessThan(cardRadius.y)); + expect(borderRadius?.topRight.x, greaterThan(0.0)); + expect(borderRadius?.topRight.x, lessThan(cardRadius.x)); + expect(borderRadius?.topRight.y, greaterThan(0.0)); + expect(borderRadius?.topRight.y, lessThan(cardRadius.y)); + case RadiusType.Round: + expect(borderRadius?.topLeft, equals(cardRadius)); + expect(borderRadius?.topRight, equals(cardRadius)); + } + + switch (bottom) { + case RadiusType.Sharp: + expect(borderRadius?.bottomLeft, equals(Radius.zero)); + expect(borderRadius?.bottomRight, equals(Radius.zero)); + case RadiusType.Shifting: + expect(borderRadius?.bottomLeft.x, greaterThan(0.0)); + expect(borderRadius?.bottomLeft.x, lessThan(cardRadius.x)); + expect(borderRadius?.bottomLeft.y, greaterThan(0.0)); + expect(borderRadius?.bottomLeft.y, lessThan(cardRadius.y)); + expect(borderRadius?.bottomRight.x, greaterThan(0.0)); + expect(borderRadius?.bottomRight.x, lessThan(cardRadius.x)); + expect(borderRadius?.bottomRight.y, greaterThan(0.0)); + expect(borderRadius?.bottomRight.y, lessThan(cardRadius.y)); + case RadiusType.Round: + expect(borderRadius?.bottomLeft, equals(cardRadius)); + expect(borderRadius?.bottomRight, equals(cardRadius)); + } +} + +// Returns the border radius decoration of an item within a MergeableMaterial. +// This depends on the exact structure of objects built by the Material and +// MergeableMaterial widgets. +BorderRadius? getBorderRadius(WidgetTester tester, int index) { + final List<Element> containers = tester.elementList(find.byType(Container)).toList(); + + final container = containers[index].widget as Container; + final boxDecoration = container.decoration as BoxDecoration?; + + return boxDecoration!.borderRadius as BorderRadius?; +} + +void main() { + testWidgets('MergeableMaterial empty', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: MergeableMaterial())), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(0)); + }); + + testWidgets('MergeableMaterial update slice', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(100.0)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 200.0), + ), + ], + ), + ), + ), + ), + ); + + box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200.0)); + }); + + testWidgets('MergeableMaterial swap slices', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200.0)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200.0)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(200.0)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + }); + + testWidgets('MergeableMaterial paints shadows', (WidgetTester tester) async { + debugDisableShadows = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RRect rrect = kMaterialEdges[MaterialType.card]!.toRRect( + const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0), + ); + expect( + find.byType(MergeableMaterial), + paints + ..shadow(elevation: 2.0) + ..rrect(rrect: rrect, color: Colors.white, hasMaskFilter: false), + ); + debugDisableShadows = true; + }); + + testWidgets('MergeableMaterial skips shadow for zero elevation', (WidgetTester tester) async { + debugDisableShadows = false; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + elevation: 0, + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(MergeableMaterial), isNot(paints..shadow(elevation: 0.0))); + debugDisableShadows = true; + }); + + testWidgets('MergeableMaterial merge gap', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + }); + + testWidgets('MergeableMaterial separate slices', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + }); + + testWidgets('MergeableMaterial separate merge separate', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + }); + + testWidgets('MergeableMaterial insert slice', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + expect(box.size.height, equals(300)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Sharp); + matches(getBorderRadius(tester, 2), RadiusType.Sharp, RadiusType.Round); + }); + + testWidgets('MergeableMaterial remove slice', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(300)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Sharp); + matches(getBorderRadius(tester, 2), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + }); + + testWidgets('MergeableMaterial insert chunk', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('y')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Shifting); + matches(getBorderRadius(tester, 2), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 2), RadiusType.Round, RadiusType.Round); + }); + + testWidgets('MergeableMaterial remove chunk', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('y')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 2), RadiusType.Round, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(200)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Sharp); + matches(getBorderRadius(tester, 1), RadiusType.Sharp, RadiusType.Round); + }); + + testWidgets('MergeableMaterial replace gap with chunk', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('y')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('z')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Shifting); + matches(getBorderRadius(tester, 2), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 2), RadiusType.Round, RadiusType.Round); + }); + + testWidgets('MergeableMaterial replace chunk with gap', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('y')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 2), RadiusType.Round, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('z')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(332)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + }); + + testWidgets('MergeableMaterial insert and separate slice', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(MergeableMaterial)); + expect(box.size.height, equals(100)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, lessThan(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Shifting); + matches(getBorderRadius(tester, 1), RadiusType.Shifting, RadiusType.Round); + + await tester.pump(const Duration(milliseconds: 100)); + expect(box.size.height, equals(216)); + + matches(getBorderRadius(tester, 0), RadiusType.Round, RadiusType.Round); + matches(getBorderRadius(tester, 1), RadiusType.Round, RadiusType.Round); + }); + + bool isDivider(BoxDecoration decoration, bool top, bool bottom) { + const side = BorderSide(color: Color(0x1F000000), width: 0.5); + + return decoration == + BoxDecoration( + border: Border( + top: top ? side : BorderSide.none, + bottom: bottom ? side : BorderSide.none, + ), + ); + } + + testWidgets('MergeableMaterial dividers', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + hasDividers: true, + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('D'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + List<Widget> animatedContainers = tester.widgetList(find.byType(AnimatedContainer)).toList(); + var boxes = <BoxDecoration>[]; + for (final container in animatedContainers) { + boxes.add((container as AnimatedContainer).decoration! as BoxDecoration); + } + + var offset = 0; + + expect(isDivider(boxes[offset], false, true), isTrue); + expect(isDivider(boxes[offset + 1], true, true), isTrue); + expect(isDivider(boxes[offset + 2], true, true), isTrue); + expect(isDivider(boxes[offset + 3], true, false), isTrue); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + hasDividers: true, + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialGap(key: ValueKey<String>('x')), + MaterialSlice( + key: ValueKey<String>('C'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('D'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + // Wait for dividers to shrink. + await tester.pump(const Duration(milliseconds: 200)); + + animatedContainers = tester.widgetList(find.byType(AnimatedContainer)).toList(); + boxes = <BoxDecoration>[]; + + for (final container in animatedContainers) { + boxes.add((container as AnimatedContainer).decoration! as BoxDecoration); + } + + offset = 0; + + expect(isDivider(boxes[offset], false, true), isTrue); + expect(isDivider(boxes[offset + 1], true, false), isTrue); + expect(isDivider(boxes[offset + 2], false, true), isTrue); + expect(isDivider(boxes[offset + 3], true, false), isTrue); + }); + + testWidgets('MergeableMaterial respects dividerColor', (WidgetTester tester) async { + const Color dividerColor = Colors.red; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + hasDividers: true, + dividerColor: dividerColor, + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + child: SizedBox(width: 100.0, height: 100.0), + ), + MaterialSlice( + key: ValueKey<String>('B'), + child: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox).last); + final decoration = decoratedBox.decoration as BoxDecoration; + // Since we are getting the last DecoratedBox, it will have a Border.top. + expect(decoration.border!.top.color, dividerColor); + }); + + testWidgets('MergeableMaterial respects MaterialSlice.color', (WidgetTester tester) async { + const Color themeCardColor = Colors.red; + const Color materialSliceColor = Colors.green; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(cardColor: themeCardColor), + home: const Scaffold( + body: SingleChildScrollView( + child: MergeableMaterial( + children: <MergeableMaterialItem>[ + MaterialSlice( + key: ValueKey<String>('A'), + color: materialSliceColor, + child: SizedBox(height: 100, width: 100), + ), + MaterialGap(key: ValueKey<String>('B')), + MaterialSlice(key: ValueKey<String>('C'), child: SizedBox(height: 100, width: 100)), + ], + ), + ), + ), + ), + ); + + var boxDecoration = + tester.widget<Container>(find.byType(Container).first).decoration! as BoxDecoration; + expect(boxDecoration.color, materialSliceColor); + + boxDecoration = + tester.widget<Container>(find.byType(Container).last).decoration! as BoxDecoration; + expect(boxDecoration.color, themeCardColor); + }); +} diff --git a/packages/material_ui/test/material/navigation_bar_test.dart b/packages/material_ui/test/material/navigation_bar_test.dart new file mode 100644 index 000000000000..f5645569335e --- /dev/null +++ b/packages/material_ui/test/material/navigation_bar_test.dart @@ -0,0 +1,1816 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Navigation bar updates destinations when tapped', (WidgetTester tester) async { + var mutatedIndex = -1; + final Widget widget = _buildWidget( + NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) { + mutatedIndex = i; + }, + ), + ); + + await tester.pumpWidget(widget); + + expect(find.text('AC'), findsOneWidget); + expect(find.text('Alarm'), findsOneWidget); + + await tester.tap(find.text('Alarm')); + expect(mutatedIndex, 1); + + await tester.tap(find.text('AC')); + expect(mutatedIndex, 0); + }); + + testWidgets('NavigationBar can update background color', (WidgetTester tester) async { + const Color color = Colors.yellow; + + await tester.pumpWidget( + _buildWidget( + NavigationBar( + backgroundColor: color, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + expect(_getMaterial(tester).color, equals(color)); + }); + + testWidgets('NavigationBar can update elevation', (WidgetTester tester) async { + const elevation = 42.0; + + await tester.pumpWidget( + _buildWidget( + NavigationBar( + elevation: elevation, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + expect(_getMaterial(tester).elevation, equals(elevation)); + }); + + testWidgets('NavigationBar adds bottom padding to height', (WidgetTester tester) async { + const bottomPadding = 40.0; + + await tester.pumpWidget( + _buildWidget( + NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + final double defaultSize = tester.getSize(find.byType(NavigationBar)).height; + expect(defaultSize, 80); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: bottomPadding)), + child: NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + + final double expectedHeight = defaultSize + bottomPadding; + expect(tester.getSize(find.byType(NavigationBar)).height, expectedHeight); + }); + + testWidgets('NavigationBar respects the notch/system navigation bar in landscape mode', ( + WidgetTester tester, + ) async { + const safeAreaPadding = 40.0; + Widget navigationBar() { + return NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination( + key: Key('Center'), + icon: Icon(Icons.center_focus_strong), + label: 'Center', + ), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ); + } + + await tester.pumpWidget(_buildWidget(navigationBar())); + final double defaultWidth = tester.getSize(find.byType(NavigationBar)).width; + final Finder defaultCenterItem = find.byKey(const Key('Center')); + final Offset center = tester.getCenter(defaultCenterItem); + expect(center.dx, defaultWidth / 2); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(left: safeAreaPadding)), + child: navigationBar(), + ), + ), + ); + + // The position of center item of navigation bar should indicate whether + // the safe area is sufficiently respected, when safe area is on the left side. + // e.g. Android device with system navigation bar in landscape mode. + final Finder leftPaddedCenterItem = find.byKey(const Key('Center')); + final Offset leftPaddedCenter = tester.getCenter(leftPaddedCenterItem); + expect( + leftPaddedCenter.dx, + closeTo((defaultWidth + safeAreaPadding) / 2.0, precisionErrorTolerance), + ); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(right: safeAreaPadding)), + child: navigationBar(), + ), + ), + ); + + // The position of center item of navigation bar should indicate whether + // the safe area is sufficiently respected, when safe area is on the right side. + // e.g. Android device with system navigation bar in landscape mode. + final Finder rightPaddedCenterItem = find.byKey(const Key('Center')); + final Offset rightPaddedCenter = tester.getCenter(rightPaddedCenterItem); + expect( + rightPaddedCenter.dx, + closeTo((defaultWidth - safeAreaPadding) / 2, precisionErrorTolerance), + ); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.fromLTRB(safeAreaPadding, 0, safeAreaPadding, safeAreaPadding), + ), + child: navigationBar(), + ), + ), + ); + + // The position of center item of navigation bar should indicate whether + // the safe area is sufficiently respected, when safe areas are on both sides. + // e.g. iOS device with both sides of round corner. + final Finder paddedCenterItem = find.byKey(const Key('Center')); + final Offset paddedCenter = tester.getCenter(paddedCenterItem); + expect(paddedCenter.dx, closeTo(defaultWidth / 2, precisionErrorTolerance)); + }); + + testWidgets('Material2 - NavigationBar uses proper defaults when no parameters are given', ( + WidgetTester tester, + ) async { + // M2 settings that were hand coded. + await tester.pumpWidget( + _buildWidget( + NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + useMaterial3: false, + ), + ); + + expect(_getMaterial(tester).color, const Color(0xffeaeaea)); + expect(_getMaterial(tester).surfaceTintColor, null); + expect(_getMaterial(tester).elevation, 0); + expect(tester.getSize(find.byType(NavigationBar)).height, 80); + expect(_getIndicatorDecoration(tester)?.color, const Color(0x3d2196f3)); + expect( + _getIndicatorDecoration(tester)?.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))), + ); + }); + + testWidgets('Material3 - NavigationBar uses proper defaults when no parameters are given', ( + WidgetTester tester, + ) async { + // M3 settings from the token database. + final theme = ThemeData(); + await tester.pumpWidget( + _buildWidget( + NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + useMaterial3: theme.useMaterial3, + ), + ); + + expect(_getMaterial(tester).color, theme.colorScheme.surfaceContainer); + expect(_getMaterial(tester).surfaceTintColor, Colors.transparent); + expect(_getMaterial(tester).elevation, 3); + expect(tester.getSize(find.byType(NavigationBar)).height, 80); + expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); + expect(_getIndicatorDecoration(tester)?.shape, const StadiumBorder()); + }); + + testWidgets('Material2 - NavigationBar shows tooltips with text scaling', ( + WidgetTester tester, + ) async { + const label = 'A'; + + Widget buildApp({required TextScaler textScaler}) { + return MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const <NavigationDestination>[ + NavigationDestination( + label: label, + icon: Icon(Icons.ac_unit), + tooltip: label, + ), + NavigationDestination(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: TextScaler.noScaling)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(find.text(label), findsNWidgets(2)); + + // The default size of a tooltip with the text A. + const defaultTooltipSize = Size(14.0, 14.0); + expect(tester.getSize(find.text(label).last), defaultTooltipSize); + // The duration is needed to ensure the tooltip disappears. + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(4.0))); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect( + tester.getSize(find.text(label).last), + Size(defaultTooltipSize.width * 4, defaultTooltipSize.height * 4), + ); + }); + + testWidgets('Material3 - NavigationBar shows tooltips with text scaling', ( + WidgetTester tester, + ) async { + const label = 'A'; + + Widget buildApp({required TextScaler textScaler}) { + return MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: MaterialApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const <NavigationDestination>[ + NavigationDestination( + label: label, + icon: Icon(Icons.ac_unit), + tooltip: label, + ), + NavigationDestination(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: TextScaler.noScaling)); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + expect(find.text(label), findsNWidgets(2)); + + expect(tester.getSize(find.text(label).last), const Size(14.25, 20.0)); + // The duration is needed to ensure the tooltip disappears. + await tester.pumpAndSettle(const Duration(seconds: 2)); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(4.0))); + expect(find.text(label), findsOneWidget); + await tester.longPress(find.text(label)); + + expect(tester.getSize(find.text(label).last), const Size(56.25, 80.0)); + }); + + testWidgets('Material3 - NavigationBar label can scale and has maxScaleFactor', ( + WidgetTester tester, + ) async { + const label = 'A'; + + Widget buildApp({required TextScaler textScaler}) { + return MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: MaterialApp( + home: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const <NavigationDestination>[ + NavigationDestination(label: label, icon: Icon(Icons.ac_unit)), + NavigationDestination(label: 'B', icon: Icon(Icons.battery_alert)), + ], + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(textScaler: TextScaler.noScaling)); + expect(find.text(label), findsOneWidget); + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(12.5, 16.0)), true); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(1.1))); + await tester.pumpAndSettle(); + + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(13.7, 18.0)), true); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(1.3))); + + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(16.1, 21.0)), true); + + await tester.pumpWidget(buildApp(textScaler: const TextScaler.linear(4))); + expect(_sizeAlmostEqual(tester.getSize(find.text(label)), const Size(16.1, 21.0)), true); + }); + + testWidgets('Custom tooltips in NavigationBarDestination', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBar( + destinations: const <NavigationDestination>[ + NavigationDestination(label: 'A', tooltip: 'A tooltip', icon: Icon(Icons.ac_unit)), + NavigationDestination(label: 'B', icon: Icon(Icons.battery_alert)), + NavigationDestination(label: 'C', icon: Icon(Icons.cake), tooltip: ''), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsOneWidget); + await tester.longPress(find.text('A')); + expect(find.byTooltip('A tooltip'), findsOneWidget); + + expect(find.text('B'), findsOneWidget); + await tester.longPress(find.text('B')); + expect(find.byTooltip('B'), findsOneWidget); + + expect(find.text('C'), findsOneWidget); + await tester.longPress(find.text('C')); + expect(find.byTooltip('C'), findsNothing); + }); + + testWidgets('Navigation bar semantics', (WidgetTester tester) async { + Widget widget({int selectedIndex = 0}) { + return _buildWidget( + NavigationBar( + selectedIndex: selectedIndex, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + NavigationDestination(icon: Icon(Icons.abc), label: 'ABC'), + ], + ), + ); + } + + await tester.pumpWidget(widget()); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC${kIsWeb ? '' : '\nTab 1 of 3'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + isButton: true, + hasSelectedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm${kIsWeb ? '' : '\nTab 2 of 3'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isButton: true, + hasSelectedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('ABC')), + matchesSemantics( + label: 'ABC${kIsWeb ? '' : '\nTab 3 of 3'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isButton: true, + hasSelectedState: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + + await tester.pumpWidget(widget(selectedIndex: 1)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC${kIsWeb ? '' : '\nTab 1 of 3'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm${kIsWeb ? '' : '\nTab 2 of 3'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('ABC')), + matchesSemantics( + label: 'ABC${kIsWeb ? '' : '\nTab 3 of 3'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }); + testWidgets('Navigation bar disabled semantics', (WidgetTester tester) async { + Widget widget({int selectedIndex = 0}) { + return _buildWidget( + NavigationBar( + selectedIndex: selectedIndex, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC', enabled: false), + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'Another'), + ], + ), + ); + } + + await tester.pumpWidget(widget()); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC${kIsWeb ? '' : '\nTab 1 of 2'}', + textDirection: TextDirection.ltr, + isSelected: true, + hasSelectedState: true, + hasEnabledState: true, + isButton: true, + ), + ); + }); + + testWidgets('Navigation bar semantics with some labels hidden', (WidgetTester tester) async { + Widget widget({int selectedIndex = 0}) { + return _buildWidget( + NavigationBar( + labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected, + selectedIndex: selectedIndex, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ); + } + + await tester.pumpWidget(widget()); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC${kIsWeb ? '' : '\nTab 1 of 2'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm${kIsWeb ? '' : '\nTab 2 of 2'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + + await tester.pumpWidget(widget(selectedIndex: 1)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC${kIsWeb ? '' : '\nTab 1 of 2'}', + textDirection: TextDirection.ltr, + isFocusable: true, + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm${kIsWeb ? '' : '\nTab 2 of 2'}', + textDirection: TextDirection.ltr, + isFocusable: true, + hasEnabledState: true, + hasSelectedState: true, + isEnabled: true, + isSelected: true, + isButton: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }); + + testWidgets('Navigation bar does not grow with text scale factor', (WidgetTester tester) async { + const animationMilliseconds = 800; + + Widget widget({TextScaler textScaler = TextScaler.noScaling}) { + return _buildWidget( + MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: NavigationBar( + animationDuration: const Duration(milliseconds: animationMilliseconds), + destinations: const <NavigationDestination>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ); + } + + await tester.pumpWidget(widget()); + final double initialHeight = tester.getSize(find.byType(NavigationBar)).height; + + await tester.pumpWidget(widget(textScaler: const TextScaler.linear(2))); + final double newHeight = tester.getSize(find.byType(NavigationBar)).height; + + expect(newHeight, equals(initialHeight)); + }); + + testWidgets('Material3 - Navigation indicator renders ripple', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/116751. + var selectedIndex = 0; + + Widget buildWidget({NavigationDestinationLabelBehavior? labelBehavior}) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: Center( + child: NavigationBar( + selectedIndex: selectedIndex, + labelBehavior: labelBehavior, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.access_alarm))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + var indicatorCenter = const Offset(600, 30); + const includedIndicatorSize = Size(64, 32); + const excludedIndicatorSize = Size(74, 40); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysShow` (default). + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysHide`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.alwaysHide), + ); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.access_alarm))); + await tester.pumpAndSettle(); + + indicatorCenter = const Offset(600, 40); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.onlyShowSelected`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected), + ); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.access_alarm))); + await tester.pumpAndSettle(); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + + // Make sure ripple is shifted when selectedIndex changes. + selectedIndex = 1; + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected), + ); + await tester.pumpAndSettle(); + indicatorCenter = const Offset(600, 30); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + }); + + testWidgets('Material3 - Navigation indicator ripple golden test', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117420. + + Widget buildWidget({NavigationDestinationLabelBehavior? labelBehavior}) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: Center( + child: NavigationBar( + labelBehavior: labelBehavior, + destinations: const <Widget>[ + NavigationDestination(icon: SizedBox(), label: 'AC'), + NavigationDestination(icon: SizedBox(), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysShow` (default). + await expectLater(find.byType(NavigationBar), matchesGoldenFile('indicator_alwaysShow_m3.png')); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysHide`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.alwaysHide), + ); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + await expectLater(find.byType(NavigationBar), matchesGoldenFile('indicator_alwaysHide_m3.png')); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.onlyShowSelected`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected), + ); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).first)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_onlyShowSelected_selected_m3.png'), + ); + + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_onlyShowSelected_unselected_m3.png'), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/169249. + testWidgets('Material3 - Navigation indicator moves to selected item', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + var index = 0; + + Widget buildNavigationBar({Color? indicatorColor, ShapeBorder? indicatorShape}) { + return MaterialApp( + theme: theme, + home: Scaffold( + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + selectedIndex: index, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + + // Move the selection to the second destination. + index = 1; + await tester.pumpWidget(buildNavigationBar()); + await tester.pumpAndSettle(); + + // The navigation indicator should be on the second item. + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('m3.navigation_bar.indicator.ink.position.png'), + ); + }); + + testWidgets('Navigation indicator scale transform', (WidgetTester tester) async { + var selectedIndex = 0; + + Widget buildNavigationBar() { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: Center( + child: NavigationBar( + selectedIndex: selectedIndex, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + await tester.pumpAndSettle(); + final Finder transformFinder = find + .descendant(of: find.byType(NavigationIndicator), matching: find.byType(Transform)) + .last; + Matrix4 transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], 0.0); + + selectedIndex = 1; + await tester.pumpWidget(buildNavigationBar()); + await tester.pump(const Duration(milliseconds: 100)); + transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], closeTo(0.7805849514007568, precisionErrorTolerance)); + + await tester.pump(const Duration(milliseconds: 100)); + transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], closeTo(0.9473570239543915, precisionErrorTolerance)); + + await tester.pumpAndSettle(); + transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], 1.0); + }); + + testWidgets('Material3 - Navigation destination updates indicator color and shape', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + const color = Color(0xff0000ff); + const ShapeBorder shape = RoundedRectangleBorder(); + + Widget buildNavigationBar({Color? indicatorColor, ShapeBorder? indicatorShape}) { + return MaterialApp( + theme: theme, + home: Scaffold( + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + + // Test default indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); + expect(_getIndicatorDecoration(tester)?.shape, const StadiumBorder()); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + // Test default indicator color and shape with ripple. + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('m3.navigation_bar.default.indicator.inkwell.shape.png'), + ); + + await tester.pumpWidget(buildNavigationBar(indicatorColor: color, indicatorShape: shape)); + + // Test custom indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, color); + expect(_getIndicatorDecoration(tester)?.shape, shape); + + // Test custom indicator color and shape with ripple. + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('m3.navigation_bar.custom.indicator.inkwell.shape.png'), + ); + }); + + testWidgets('Destinations respect their disabled state', (WidgetTester tester) async { + var selectedIndex = 0; + + await tester.pumpWidget( + _buildWidget( + NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + NavigationDestination(icon: Icon(Icons.bookmark), label: 'Bookmark', enabled: false), + ], + onDestinationSelected: (int i) => selectedIndex = i, + selectedIndex: selectedIndex, + ), + ), + ); + + await tester.tap(find.text('AC')); + expect(selectedIndex, 0); + + await tester.tap(find.text('Alarm')); + expect(selectedIndex, 1); + + await tester.tap(find.text('Bookmark')); + expect(selectedIndex, 1); + }); + + testWidgets('NavigationBar respects overlayColor in active/pressed/hovered states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoverColor = Color(0xff0000ff); + const focusColor = Color(0xff00ffff); + const pressedColor = Color(0xffff00ff); + final WidgetStateProperty<Color?> overlayColor = WidgetStateProperty.resolveWith<Color>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusColor; + } + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + return Colors.transparent; + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + overlayColor: overlayColor, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // Test hovered state. + expect( + inkFeatures, + kIsWeb + ? (paints + ..rrect() + ..rrect() + ..circle(color: hoverColor)) + : (paints..circle(color: hoverColor)), + ); + + await gesture.down(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + // Test pressed state. + expect( + inkFeatures, + kIsWeb + ? (paints + ..circle() + ..circle() + ..circle(color: pressedColor)) + : (paints + ..circle() + ..circle(color: pressedColor)), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Press tab to focus the navigation bar. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Test focused state. + expect( + inkFeatures, + kIsWeb + ? (paints + ..circle() + ..circle(color: focusColor)) + : (paints + ..circle() + ..circle(color: focusColor)), + ); + }); + + testWidgets('NavigationBar.labelPadding overrides NavigationDestination.label padding', ( + WidgetTester tester, + ) async { + const EdgeInsetsGeometry labelPadding = EdgeInsets.all(8); + Widget buildNavigationBar({EdgeInsetsGeometry? labelPadding}) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBar( + labelPadding: labelPadding, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + expect(_getLabelPadding(tester, 'Home'), const EdgeInsets.only(top: 4)); + expect(_getLabelPadding(tester, 'Settings'), const EdgeInsets.only(top: 4)); + + await tester.pumpWidget(buildNavigationBar(labelPadding: labelPadding)); + expect(_getLabelPadding(tester, 'Home'), labelPadding); + expect(_getLabelPadding(tester, 'Settings'), labelPadding); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Material2 - Navigation destination updates indicator color and shape', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + const color = Color(0xff0000ff); + const ShapeBorder shape = RoundedRectangleBorder(); + + Widget buildNavigationBar({Color? indicatorColor, ShapeBorder? indicatorShape}) { + return MaterialApp( + theme: theme, + home: Scaffold( + bottomNavigationBar: NavigationBar( + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + + // Test default indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondary.withOpacity(0.24)); + expect( + _getIndicatorDecoration(tester)?.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))), + ); + + await tester.pumpWidget(buildNavigationBar(indicatorColor: color, indicatorShape: shape)); + + // Test custom indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, color); + expect(_getIndicatorDecoration(tester)?.shape, shape); + }); + + testWidgets('Material2 - Navigation indicator renders ripple', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/116751. + var selectedIndex = 0; + + Widget buildWidget({NavigationDestinationLabelBehavior? labelBehavior}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: Center( + child: NavigationBar( + selectedIndex: selectedIndex, + labelBehavior: labelBehavior, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.access_alarm))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + var indicatorCenter = const Offset(600, 33); + const includedIndicatorSize = Size(64, 32); + const excludedIndicatorSize = Size(74, 40); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysShow` (default). + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysHide`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.alwaysHide), + ); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.access_alarm))); + await tester.pumpAndSettle(); + + indicatorCenter = const Offset(600, 40); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.onlyShowSelected`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected), + ); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.access_alarm))); + await tester.pumpAndSettle(); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + + // Make sure ripple is shifted when selectedIndex changes. + selectedIndex = 1; + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected), + ); + await tester.pumpAndSettle(); + indicatorCenter = const Offset(600, 33); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (includedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (includedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (includedIndicatorSize.height / 2)), + ], + excludes: <Offset>[ + // Left center. + Offset(indicatorCenter.dx - (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Top center. + Offset(indicatorCenter.dx, indicatorCenter.dy - (excludedIndicatorSize.height / 2)), + // Right center. + Offset(indicatorCenter.dx + (excludedIndicatorSize.width / 2), indicatorCenter.dy), + // Bottom center. + Offset(indicatorCenter.dx, indicatorCenter.dy + (excludedIndicatorSize.height / 2)), + ], + ), + ) + ..circle( + x: indicatorCenter.dx, + y: indicatorCenter.dy, + radius: 35.0, + color: const Color(0x0a000000), + ), + ); + }); + + testWidgets('Material2 - Navigation indicator ripple golden test', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117420. + + Widget buildWidget({NavigationDestinationLabelBehavior? labelBehavior}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + bottomNavigationBar: Center( + child: NavigationBar( + labelBehavior: labelBehavior, + destinations: const <Widget>[ + NavigationDestination(icon: SizedBox(), label: 'AC'), + NavigationDestination(icon: SizedBox(), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysShow` (default). + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_alwaysShow_m2.png'), + ); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.alwaysHide`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.alwaysHide), + ); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_alwaysHide_m2.png'), + ); + + // Test ripple when NavigationBar is using `NavigationDestinationLabelBehavior.onlyShowSelected`. + await tester.pumpWidget( + buildWidget(labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected), + ); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).first)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_onlyShowSelected_selected_m2.png'), + ); + + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_onlyShowSelected_unselected_m2.png'), + ); + }); + + testWidgets('Destination icon does not rebuild when tapped', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/122811. + + Widget buildNavigationBar() { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + var selectedIndex = 0; + return NavigationBar( + selectedIndex: selectedIndex, + destinations: const <Widget>[ + NavigationDestination( + icon: IconWithRandomColor(icon: Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: IconWithRandomColor(icon: Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) { + setState(() { + selectedIndex = i; + }); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + Icon icon = tester.widget<Icon>(find.byType(Icon).last); + final Color initialColor = icon.color!; + + // Trigger a rebuild. + await tester.tap(find.text('Alarm')); + await tester.pumpAndSettle(); + + // Icon color should be the same as before the rebuild. + icon = tester.widget<Icon>(find.byType(Icon).last); + expect(icon.color, initialColor); + }); + }); + + testWidgets('NavigationBar.labelPadding overrides NavigationDestination.label padding', ( + WidgetTester tester, + ) async { + const selectedText = 'Home'; + const unselectedText = 'Settings'; + const EdgeInsetsGeometry labelPadding = EdgeInsets.all(8); + Widget buildNavigationBar({EdgeInsetsGeometry? labelPadding}) { + return MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBar( + labelPadding: labelPadding, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.home), label: selectedText), + NavigationDestination(icon: Icon(Icons.settings), label: unselectedText), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + expect(_getLabelPadding(tester, selectedText), const EdgeInsets.only(top: 4)); + expect(_getLabelPadding(tester, unselectedText), const EdgeInsets.only(top: 4)); + + await tester.pumpWidget(buildNavigationBar(labelPadding: labelPadding)); + expect(_getLabelPadding(tester, selectedText), labelPadding); + expect(_getLabelPadding(tester, unselectedText), labelPadding); + }); + + testWidgets('NavigationBar.labelTextStyle overrides NavigationDestination.label text style', ( + WidgetTester tester, + ) async { + const selectedText = 'Home'; + const unselectedText = 'Settings'; + const disabledText = 'Bookmark'; + final theme = ThemeData(); + Widget buildNavigationBar({WidgetStateProperty<TextStyle?>? labelTextStyle}) { + return MaterialApp( + theme: theme, + home: Scaffold( + bottomNavigationBar: NavigationBar( + labelTextStyle: labelTextStyle, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.home), label: selectedText), + NavigationDestination(icon: Icon(Icons.settings), label: unselectedText), + NavigationDestination( + enabled: false, + icon: Icon(Icons.bookmark), + label: disabledText, + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildNavigationBar()); + + // Test selected label text style. + expect(_getLabelStyle(tester, selectedText).fontSize, equals(12.0)); + expect(_getLabelStyle(tester, selectedText).color, equals(theme.colorScheme.onSurface)); + + // Test unselected label text style. + expect(_getLabelStyle(tester, unselectedText).fontSize, equals(12.0)); + expect( + _getLabelStyle(tester, unselectedText).color, + equals(theme.colorScheme.onSurfaceVariant), + ); + + // Test disabled label text style. + expect(_getLabelStyle(tester, disabledText).fontSize, equals(12.0)); + expect( + _getLabelStyle(tester, disabledText).color, + equals(theme.colorScheme.onSurfaceVariant.withOpacity(0.38)), + ); + + const selectedTextStyle = TextStyle(fontSize: 15, color: Color(0xFF00FF00)); + const unselectedTextStyle = TextStyle(fontSize: 15, color: Color(0xFF0000FF)); + const disabledTextStyle = TextStyle(fontSize: 16, color: Color(0xFFFF0000)); + await tester.pumpWidget( + buildNavigationBar( + labelTextStyle: + const WidgetStateProperty<TextStyle?>.fromMap(<WidgetStatesConstraint, TextStyle?>{ + WidgetState.disabled: disabledTextStyle, + WidgetState.selected: selectedTextStyle, + WidgetState.any: unselectedTextStyle, + }), + ), + ); + + // Test selected label text style. + expect(_getLabelStyle(tester, selectedText).fontSize, equals(selectedTextStyle.fontSize)); + expect(_getLabelStyle(tester, selectedText).color, equals(selectedTextStyle.color)); + + // Test unselected label text style. + expect(_getLabelStyle(tester, unselectedText).fontSize, equals(unselectedTextStyle.fontSize)); + expect(_getLabelStyle(tester, unselectedText).color, equals(unselectedTextStyle.color)); + + // Test disabled label text style. + expect(_getLabelStyle(tester, disabledText).fontSize, equals(disabledTextStyle.fontSize)); + expect(_getLabelStyle(tester, disabledText).color, equals(disabledTextStyle.color)); + }); + + testWidgets('NavigationBar.maintainBottomViewPadding can consume bottom MediaQuery.padding', ( + WidgetTester tester, + ) async { + const double bottomPadding = 40; + const TextDirection textDirection = TextDirection.ltr; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: textDirection, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: bottomPadding)), + child: Scaffold( + bottomNavigationBar: NavigationBar( + maintainBottomViewPadding: true, + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + ), + ), + ), + ), + ), + ); + + final double safeAreaBottomPadding = tester + .widget<Padding>(find.byType(Padding).first) + .padding + .resolve(textDirection) + .bottom; + expect(safeAreaBottomPadding, equals(0)); + }); + + testWidgets('NavigationBar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.add), label: 'X'), + NavigationDestination(icon: Icon(Icons.abc), label: 'Y'), + ], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(NavigationBar)), Size.zero); + }); +} + +Widget _buildWidget(Widget child, {bool? useMaterial3}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Scaffold(bottomNavigationBar: Center(child: child)), + ); +} + +Material _getMaterial(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(NavigationBar), matching: find.byType(Material)), + ); +} + +ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) { + return tester + .firstWidget<Ink>( + find.descendant(of: find.byType(FadeTransition), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; +} + +class IconWithRandomColor extends StatelessWidget { + const IconWithRandomColor({super.key, required this.icon}); + + final IconData icon; + + @override + Widget build(BuildContext context) { + final Color randomColor = Color( + (Random().nextDouble() * 0xFFFFFF).toInt(), + ).withValues(alpha: 1.0); + return Icon(icon, color: randomColor); + } +} + +bool _sizeAlmostEqual(Size a, Size b, {double maxDiff = 0.05}) { + return (a.width - b.width).abs() <= maxDiff && (a.height - b.height).abs() <= maxDiff; +} + +EdgeInsetsGeometry _getLabelPadding(WidgetTester tester, String text) { + return tester + .widget<Padding>(find.ancestor(of: find.text(text), matching: find.byType(Padding)).first) + .padding; +} + +TextStyle _getLabelStyle(WidgetTester tester, String text) { + return tester + .widget<RichText>(find.descendant(of: find.text(text), matching: find.byType(RichText))) + .text + .style!; +} diff --git a/packages/material_ui/test/material/navigation_bar_theme_test.dart b/packages/material_ui/test/material/navigation_bar_theme_test.dart new file mode 100644 index 000000000000..80244ea1abc8 --- /dev/null +++ b/packages/material_ui/test/material/navigation_bar_theme_test.dart @@ -0,0 +1,432 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('copyWith, ==, hashCode basics', () { + expect(const NavigationBarThemeData(), const NavigationBarThemeData().copyWith()); + expect( + const NavigationBarThemeData().hashCode, + const NavigationBarThemeData().copyWith().hashCode, + ); + }); + + test('NavigationBarThemeData lerp special cases', () { + expect(NavigationBarThemeData.lerp(null, null, 0), null); + const data = NavigationBarThemeData(); + expect(identical(NavigationBarThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Default debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const NavigationBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('NavigationBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const NavigationBarThemeData( + height: 200.0, + backgroundColor: Color(0x00000099), + elevation: 20.0, + shadowColor: Color(0x00000098), + surfaceTintColor: Color(0x00000097), + indicatorColor: Color(0x00000096), + indicatorShape: CircleBorder(), + labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)), + iconTheme: MaterialStatePropertyAll<IconThemeData>(IconThemeData(color: Color(0x00000097))), + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + overlayColor: MaterialStatePropertyAll<Color>(Color(0x00000095)), + labelPadding: EdgeInsets.all(8), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'height: 200.0', + 'backgroundColor: Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.6000, colorSpace: ColorSpace.sRGB)', + 'elevation: 20.0', + 'shadowColor: Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.5961, colorSpace: ColorSpace.sRGB)', + 'surfaceTintColor: Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.5922, colorSpace: ColorSpace.sRGB)', + 'indicatorColor: Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.5882, colorSpace: ColorSpace.sRGB)', + 'indicatorShape: CircleBorder(BorderSide(width: 0.0, style: none))', + 'labelTextStyle: WidgetStatePropertyAll(TextStyle(inherit: true, size: 7.0))', + 'iconTheme: WidgetStatePropertyAll(IconThemeData#fd5c3(color: Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.5922, colorSpace: ColorSpace.sRGB)))', + 'labelBehavior: NavigationDestinationLabelBehavior.alwaysHide', + 'overlayColor: WidgetStatePropertyAll(Color(alpha: 0.0000, red: 0.0000, green: 0.0000, blue: 0.5843, colorSpace: ColorSpace.sRGB))', + 'labelPadding: EdgeInsets.all(8.0)', + ]), + ); + }); + + testWidgets( + 'NavigationBarThemeData values are used when no NavigationBar properties are specified', + (WidgetTester tester) async { + const height = 200.0; + const backgroundColor = Color(0x00000001); + const elevation = 42.0; + const indicatorColor = Color(0x00000002); + const ShapeBorder indicatorShape = CircleBorder(); + const selectedIconSize = 25.0; + const unselectedIconSize = 23.0; + const selectedIconColor = Color(0x00000003); + const unselectedIconColor = Color(0x00000004); + const selectedIconOpacity = 0.99; + const unselectedIconOpacity = 0.98; + const selectedLabelFontSize = 13.0; + const unselectedLabelFontSize = 11.0; + const NavigationDestinationLabelBehavior labelBehavior = + NavigationDestinationLabelBehavior.alwaysShow; + const EdgeInsetsGeometry labelPadding = EdgeInsets.all(8); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBarTheme( + data: NavigationBarThemeData( + height: height, + backgroundColor: backgroundColor, + elevation: elevation, + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + iconTheme: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData( + size: selectedIconSize, + color: selectedIconColor, + opacity: selectedIconOpacity, + ); + } + return const IconThemeData( + size: unselectedIconSize, + color: unselectedIconColor, + opacity: unselectedIconOpacity, + ); + }), + labelTextStyle: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const TextStyle(fontSize: selectedLabelFontSize); + } + return const TextStyle(fontSize: unselectedLabelFontSize); + }), + labelBehavior: labelBehavior, + labelPadding: labelPadding, + ), + child: NavigationBar(destinations: _destinations()), + ), + ), + ), + ); + + expect(_barHeight(tester), height); + expect(_barMaterial(tester).color, backgroundColor); + expect(_barMaterial(tester).elevation, elevation); + expect(_indicator(tester)?.color, indicatorColor); + expect(_indicator(tester)?.shape, indicatorShape); + expect(_selectedIconTheme(tester).size, selectedIconSize); + expect(_selectedIconTheme(tester).color, selectedIconColor); + expect(_selectedIconTheme(tester).opacity, selectedIconOpacity); + expect(_unselectedIconTheme(tester).size, unselectedIconSize); + expect(_unselectedIconTheme(tester).color, unselectedIconColor); + expect(_unselectedIconTheme(tester).opacity, unselectedIconOpacity); + expect(_selectedLabelStyle(tester).fontSize, selectedLabelFontSize); + expect(_unselectedLabelStyle(tester).fontSize, unselectedLabelFontSize); + expect(_labelBehavior(tester), labelBehavior); + expect(_getLabelPadding(tester, 'Abc'), labelPadding); + expect(_getLabelPadding(tester, 'Def'), labelPadding); + }, + ); + + testWidgets( + 'NavigationBar values take priority over NavigationBarThemeData values when both properties are specified', + (WidgetTester tester) async { + const height = 200.0; + const backgroundColor = Color(0x00000001); + const elevation = 42.0; + const NavigationDestinationLabelBehavior labelBehavior = + NavigationDestinationLabelBehavior.alwaysShow; + const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric(horizontal: 16.0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: NavigationBarTheme( + data: const NavigationBarThemeData( + height: 100.0, + elevation: 18.0, + backgroundColor: Color(0x00000099), + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + labelPadding: EdgeInsets.all(8), + ), + child: NavigationBar( + height: height, + elevation: elevation, + backgroundColor: backgroundColor, + labelBehavior: labelBehavior, + labelPadding: labelPadding, + destinations: _destinations(), + ), + ), + ), + ), + ); + + expect(_barHeight(tester), height); + expect(_barMaterial(tester).color, backgroundColor); + expect(_barMaterial(tester).elevation, elevation); + expect(_labelBehavior(tester), labelBehavior); + expect(_getLabelPadding(tester, 'Abc'), labelPadding); + expect(_getLabelPadding(tester, 'Def'), labelPadding); + }, + ); + + testWidgets('Custom label style renders ink ripple properly', (WidgetTester tester) async { + Widget buildWidget({NavigationDestinationLabelBehavior? labelBehavior}) { + return MaterialApp( + theme: ThemeData( + navigationBarTheme: const NavigationBarThemeData( + labelTextStyle: MaterialStatePropertyAll<TextStyle>( + TextStyle(fontSize: 25, color: Color(0xff0000ff)), + ), + ), + ), + home: Scaffold( + bottomNavigationBar: Center( + child: NavigationBar( + labelBehavior: labelBehavior, + destinations: const <Widget>[ + NavigationDestination(icon: SizedBox(), label: 'AC'), + NavigationDestination(icon: SizedBox(), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationDestination).last)); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(NavigationBar), + matchesGoldenFile('indicator_custom_label_style.png'), + ); + }); + + testWidgets( + 'NavigationBar respects NavigationBarTheme.overlayColor in active/pressed/hovered states', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoverColor = Color(0xff0000ff); + const focusColor = Color(0xff00ffff); + const pressedColor = Color(0xffff00ff); + final WidgetStateProperty<Color?> overlayColor = WidgetStateProperty.resolveWith<Color>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusColor; + } + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + return Colors.transparent; + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(navigationBarTheme: NavigationBarThemeData(overlayColor: overlayColor)), + home: Scaffold( + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + destinations: const <Widget>[ + NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'), + NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'), + ], + onDestinationSelected: (int i) {}, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // Test hovered state. + expect( + inkFeatures, + kIsWeb + ? (paints + ..rrect() + ..rrect() + ..circle(color: hoverColor)) + : (paints..circle(color: hoverColor)), + ); + + await gesture.down(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + // Test pressed state. + expect( + inkFeatures, + kIsWeb + ? (paints + ..circle() + ..circle() + ..circle(color: pressedColor)) + : (paints + ..circle() + ..circle(color: pressedColor)), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Press tab to focus the navigation bar. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Test focused state. + expect( + inkFeatures, + kIsWeb + ? (paints + ..circle() + ..circle(color: focusColor)) + : (paints + ..circle() + ..circle(color: focusColor)), + ); + }, + ); +} + +List<NavigationDestination> _destinations() { + return const <NavigationDestination>[ + NavigationDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: 'Abc', + ), + NavigationDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: 'Def', + ), + ]; +} + +double _barHeight(WidgetTester tester) { + return tester.getRect(find.byType(NavigationBar)).height; +} + +Material _barMaterial(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(NavigationBar), matching: find.byType(Material)), + ); +} + +ShapeDecoration? _indicator(WidgetTester tester) { + return tester + .firstWidget<Ink>( + find.descendant(of: find.byType(FadeTransition), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; +} + +IconThemeData _selectedIconTheme(WidgetTester tester) { + return _iconTheme(tester, Icons.favorite); +} + +IconThemeData _unselectedIconTheme(WidgetTester tester) { + return _iconTheme(tester, Icons.star_border); +} + +IconThemeData _iconTheme(WidgetTester tester, IconData icon) { + return tester + .firstWidget<IconTheme>( + find.ancestor(of: find.byIcon(icon), matching: find.byType(IconTheme)), + ) + .data; +} + +TextStyle _selectedLabelStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: find.text('Abc'), matching: find.byType(RichText))) + .text + .style!; +} + +TextStyle _unselectedLabelStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: find.text('Def'), matching: find.byType(RichText))) + .text + .style!; +} + +NavigationDestinationLabelBehavior _labelBehavior(WidgetTester tester) { + if (_opacityAboveLabel('Abc').evaluate().isNotEmpty && + _opacityAboveLabel('Def').evaluate().isNotEmpty) { + return _labelOpacity(tester, 'Abc') == 1 + ? NavigationDestinationLabelBehavior.onlyShowSelected + : NavigationDestinationLabelBehavior.alwaysHide; + } else { + return NavigationDestinationLabelBehavior.alwaysShow; + } +} + +Finder _opacityAboveLabel(String text) { + return find.ancestor(of: find.text(text), matching: find.byType(Opacity)); +} + +// Only valid when labelBehavior != alwaysShow. +double _labelOpacity(WidgetTester tester, String text) { + final Opacity opacityWidget = tester.widget<Opacity>( + find.ancestor(of: find.text(text), matching: find.byType(Opacity)), + ); + return opacityWidget.opacity; +} + +EdgeInsetsGeometry _getLabelPadding(WidgetTester tester, String text) { + return tester + .widget<Padding>(find.ancestor(of: find.text(text), matching: find.byType(Padding)).first) + .padding; +} diff --git a/packages/material_ui/test/material/navigation_drawer_test.dart b/packages/material_ui/test/material/navigation_drawer_test.dart new file mode 100644 index 000000000000..8b90468dddef --- /dev/null +++ b/packages/material_ui/test/material/navigation_drawer_test.dart @@ -0,0 +1,643 @@ +// 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('Navigation drawer updates destinations when tapped', (WidgetTester tester) async { + var mutatedIndex = -1; + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + widgetSetup(tester, 3000, viewHeight: 3000); + final Widget widget = _buildWidget( + scaffoldKey, + NavigationDrawer( + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + NavigationDrawerDestination( + icon: Icon(Icons.ac_unit, color: theme.iconTheme.color), + label: Text('AC', style: theme.textTheme.bodySmall), + ), + NavigationDrawerDestination( + icon: Icon(Icons.access_alarm, color: theme.iconTheme.color), + label: Text('Alarm', style: theme.textTheme.bodySmall), + ), + ], + onDestinationSelected: (int i) { + mutatedIndex = i; + }, + ), + ); + + await tester.pumpWidget(widget); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(); + + expect(find.text('Headline'), findsOneWidget); + expect(find.text('AC'), findsOneWidget); + expect(find.text('Alarm'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); // animation done + + await tester.tap(find.text('Alarm')); + expect(mutatedIndex, 1); + + await tester.tap(find.text('AC')); + expect(mutatedIndex, 0); + }); + + testWidgets('NavigationDrawer can update background color', (WidgetTester tester) async { + const Color color = Colors.yellow; + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + backgroundColor: color, + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + NavigationDrawerDestination( + icon: Icon(Icons.ac_unit, color: theme.iconTheme.color), + label: Text('AC', style: theme.textTheme.bodySmall), + ), + NavigationDrawerDestination( + icon: Icon(Icons.access_alarm, color: theme.iconTheme.color), + label: Text('Alarm', style: theme.textTheme.bodySmall), + ), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); // animation done + + expect(_getMaterial(tester).color, equals(color)); + }); + + testWidgets('NavigationDestinationDrawer background color is customizable', ( + WidgetTester tester, + ) async { + const Color color = Colors.yellow; + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + NavigationDrawerDestination( + icon: Icon(Icons.ac_unit, color: theme.iconTheme.color), + label: Text('AC', style: theme.textTheme.bodySmall), + ), + NavigationDrawerDestination( + icon: Icon(Icons.access_alarm, color: theme.iconTheme.color), + label: Text('Alarm', style: theme.textTheme.bodySmall), + backgroundColor: color, + ), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + + Finder findDestinationInk(String label) { + return find.descendant( + of: find.ancestor(of: find.text(label), matching: find.byType(NavigationDrawerDestination)), + matching: find.byType(Ink), + ); + } + + scaffoldKey.currentState!.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // Animation done. + + // Destination with no custom background color. + await tester.tap(find.text('AC')); + await tester.pump(); + + // When no background color is set, only the non-visible indicator Ink is expected. + expect(findDestinationInk('AC'), findsOne); + + // Destination with a custom background color. + await tester.tap(find.byIcon(Icons.access_alarm)); + await tester.pump(); + + // A Material is added with the custom color. + expect(findDestinationInk('Alarm'), findsNWidgets(2)); + final destinationDecoration = + tester.firstWidget<Ink>(findDestinationInk('Alarm')).decoration! as BoxDecoration; + expect(destinationDecoration.color, color); + }); + + testWidgets('NavigationDrawer can update elevation', (WidgetTester tester) async { + const elevation = 42.0; + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + final drawer = NavigationDrawer( + elevation: elevation, + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + NavigationDrawerDestination( + icon: Icon(Icons.ac_unit, color: theme.iconTheme.color), + label: Text('AC', style: theme.textTheme.bodySmall), + ), + NavigationDrawerDestination( + icon: Icon(Icons.access_alarm, color: theme.iconTheme.color), + label: Text('Alarm', style: theme.textTheme.bodySmall), + ), + ], + ); + + await tester.pumpWidget(_buildWidget(scaffoldKey, drawer)); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + expect(_getMaterial(tester).elevation, equals(elevation)); + }); + + testWidgets('NavigationDrawer uses proper defaults when no parameters are given', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + const NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + const NavigationDrawerDestination(icon: Icon(Icons.access_alarm), label: Text('Alarm')), + ], + onDestinationSelected: (int i) {}, + ), + useMaterial3: theme.useMaterial3, + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + // Test drawer Material. + expect(_getMaterial(tester).color, theme.colorScheme.surfaceContainerLow); + expect(_getMaterial(tester).surfaceTintColor, Colors.transparent); + expect(_getMaterial(tester).shadowColor, Colors.transparent); + expect(_getMaterial(tester).elevation, 1); + // Test indicator decoration. + expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); + expect(_getIndicatorDecoration(tester)?.shape, const StadiumBorder()); + // Test selected and unselected icon colors. + expect(_iconStyle(tester, Icons.ac_unit)?.color, theme.colorScheme.onSecondaryContainer); + expect(_iconStyle(tester, Icons.access_alarm)?.color, theme.colorScheme.onSurfaceVariant); + // Test selected and unselected label colors. + expect(_labelStyle(tester, 'AC')?.color, theme.colorScheme.onSecondaryContainer); + expect(_labelStyle(tester, 'Alarm')?.color, theme.colorScheme.onSurfaceVariant); + // Test that the icon and label are the correct size. + RenderBox iconBox = tester.renderObject(find.byIcon(Icons.ac_unit)); + expect(iconBox.size, const Size(24.0, 24.0)); + iconBox = tester.renderObject(find.byIcon(Icons.access_alarm)); + expect(iconBox.size, const Size(24.0, 24.0)); + }); + + testWidgets('Navigation drawer is scrollable', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + widgetSetup(tester, 500, viewHeight: 300); + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + children: <Widget>[ + for (int i = 0; i < 100; i++) + NavigationDrawerDestination(icon: const Icon(Icons.ac_unit), label: Text('Label$i')), + ], + onDestinationSelected: (int i) {}, + ), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Label0'), findsOneWidget); + expect(find.text('Label1'), findsOneWidget); + expect(find.text('Label2'), findsOneWidget); + expect(find.text('Label3'), findsOneWidget); + expect(find.text('Label4'), findsOneWidget); + expect(find.text('Label5'), findsOneWidget); + expect(find.text('Label6'), findsNothing); + expect(find.text('Label7'), findsNothing); + expect(find.text('Label8'), findsNothing); + + await tester.dragFrom(const Offset(0, 200), const Offset(0.0, -200)); + await tester.pump(); + + expect(find.text('Label0'), findsNothing); + expect(find.text('Label1'), findsNothing); + expect(find.text('Label2'), findsNothing); + expect(find.text('Label3'), findsOneWidget); + expect(find.text('Label4'), findsOneWidget); + expect(find.text('Label5'), findsOneWidget); + expect(find.text('Label6'), findsOneWidget); + expect(find.text('Label7'), findsOneWidget); + expect(find.text('Label8'), findsOneWidget); + expect(find.text('Label9'), findsNothing); + expect(find.text('Label10'), findsNothing); + }); + + testWidgets('Safe Area test', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const double viewHeight = 300; + widgetSetup(tester, 500, viewHeight: viewHeight); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(20.0)), + child: MaterialApp( + useInheritedMediaQuery: true, + home: Scaffold( + key: scaffoldKey, + drawer: NavigationDrawer( + children: <Widget>[ + for (int i = 0; i < 10; i++) + NavigationDrawerDestination( + icon: const Icon(Icons.ac_unit), + label: Text('Label$i'), + ), + ], + onDestinationSelected: (int i) {}, + ), + body: Container(), + ), + ), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Safe area padding on the top and sides. + expect( + tester.getTopLeft(find.widgetWithText(NavigationDrawerDestination, 'Label0')), + const Offset(20.0, 20.0), + ); + + // No Safe area padding at the bottom. + expect( + tester.getBottomRight(find.widgetWithText(NavigationDrawerDestination, 'Label4')).dy, + viewHeight, + ); + }); + + testWidgets('Navigation drawer semantics', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + Widget widget({int selectedIndex = 0}) { + return _buildWidget( + scaffoldKey, + NavigationDrawer( + selectedIndex: selectedIndex, + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + NavigationDrawerDestination( + icon: Icon(Icons.ac_unit, color: theme.iconTheme.color), + label: Text('AC', style: theme.textTheme.bodySmall), + ), + NavigationDrawerDestination( + icon: Icon(Icons.access_alarm, color: theme.iconTheme.color), + label: Text('Alarm', style: theme.textTheme.bodySmall), + ), + ], + ), + ); + } + + await tester.pumpWidget(widget()); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + + await tester.pumpWidget(widget(selectedIndex: 1)); + + expect( + tester.getSemantics(find.text('AC')), + matchesSemantics( + label: 'AC\nTab 1 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + expect( + tester.getSemantics(find.text('Alarm')), + matchesSemantics( + label: 'Alarm\nTab 2 of 2', + textDirection: TextDirection.ltr, + isFocusable: true, + isSelected: true, + hasSelectedState: true, + hasTapAction: true, + hasFocusAction: true, + ), + ); + }); + + testWidgets('Navigation destination updates indicator color and shape', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + final theme = ThemeData(); + const color = Color(0xff0000ff); + const ShapeBorder shape = RoundedRectangleBorder(); + + Widget buildNavigationDrawer({Color? indicatorColor, ShapeBorder? indicatorShape}) { + return MaterialApp( + theme: theme, + home: Scaffold( + key: scaffoldKey, + drawer: NavigationDrawer( + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + children: <Widget>[ + Text('Headline', style: theme.textTheme.bodyLarge), + const NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + const NavigationDrawerDestination( + icon: Icon(Icons.access_alarm), + label: Text('Alarm'), + ), + ], + onDestinationSelected: (int i) {}, + ), + body: Container(), + ), + ); + } + + await tester.pumpWidget(buildNavigationDrawer()); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + // Test default indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); + expect(_getIndicatorDecoration(tester)?.shape, const StadiumBorder()); + // Test that InkWell for hover, focus and pressed use default shape. + expect(_getInkWell(tester)?.customBorder, const StadiumBorder()); + + await tester.pumpWidget(buildNavigationDrawer(indicatorColor: color, indicatorShape: shape)); + + // Test custom indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, color); + expect(_getIndicatorDecoration(tester)?.shape, shape); + // Test that InkWell for hover, focus and pressed use custom shape. + expect(_getInkWell(tester)?.customBorder, shape); + }); + + testWidgets('NavigationDrawer.tilePadding defaults to EdgeInsets.symmetric(horizontal: 12.0)', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + widgetSetup(tester, 3000, viewHeight: 3000); + final Widget widget = _buildWidget( + scaffoldKey, + NavigationDrawer( + children: const <Widget>[ + NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + ], + onDestinationSelected: (int i) {}, + ), + ); + + await tester.pumpWidget(widget); + scaffoldKey.currentState?.openDrawer(); + await tester.pump(); + final NavigationDrawer drawer = tester.widget(find.byType(NavigationDrawer)); + expect(drawer.tilePadding, const EdgeInsets.symmetric(horizontal: 12.0)); + }); + + testWidgets('Destinations respect their disabled state', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + var selectedIndex = 0; + + widgetSetup(tester, 800); + + final Widget widget = _buildWidget( + scaffoldKey, + NavigationDrawer( + children: const <Widget>[ + NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + NavigationDrawerDestination(icon: Icon(Icons.access_alarm), label: Text('Alarm')), + NavigationDrawerDestination( + icon: Icon(Icons.accessible), + label: Text('Accessible'), + enabled: false, + ), + ], + onDestinationSelected: (int i) { + selectedIndex = i; + }, + ), + ); + + await tester.pumpWidget(widget); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(); + + expect(find.text('AC'), findsOneWidget); + expect(find.text('Alarm'), findsOneWidget); + expect(find.text('Accessible'), findsOneWidget); + + await tester.pump(const Duration(seconds: 1)); + + expect(selectedIndex, 0); + + await tester.tap(find.text('Alarm')); + expect(selectedIndex, 1); + + await tester.tap(find.text('Accessible')); + expect(selectedIndex, 1); + + await tester.pumpAndSettle(); + }); + + testWidgets('NavigationDrawer can display header and footer', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + widgetSetup(tester, 3000, viewHeight: 3000); + final Widget widget = _buildWidget( + scaffoldKey, + NavigationDrawer( + header: const DrawerHeader( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: <Widget>[FlutterLogo(), Text('Header')], + ), + ), + ), + footer: ListTile( + leading: const FlutterLogo(), + title: const Text('Footer'), + trailing: const Icon(Icons.settings), + onTap: () {}, + ), + children: <Widget>[ + for (int i = 0; i < 10; i++) + NavigationDrawerDestination(icon: const Icon(Icons.home), label: Text('Item $i')), + ], + ), + ); + + await tester.pumpWidget(widget); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(DrawerHeader), findsOneWidget); + expect(find.text('Header'), findsOneWidget); + expect(find.byType(FlutterLogo), findsNWidgets(2)); + expect(find.byType(ListTile), findsOneWidget); + expect(find.text('Footer'), findsOneWidget); + expect(find.byIcon(Icons.settings), findsOneWidget); + }); + + testWidgets('NavigationDrawer does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: NavigationDrawer( + children: <Widget>[ + NavigationDrawerDestination(icon: Icon(Icons.inbox), label: Text('Inbox')), + ], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(NavigationDrawer)), Size.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/180233 + testWidgets( + 'NavigationDrawer ink effects are bounded within scrollable area when footer is present', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + widgetSetup(tester, 800, viewHeight: 400); + + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + footer: const Padding(padding: EdgeInsets.all(16.0), child: Text('Footer')), + children: <Widget>[ + for (int i = 0; i < 10; i++) + NavigationDrawerDestination(icon: const Icon(Icons.home), label: Text('Item $i')), + ], + ), + ), + ); + + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + final Finder footerFinder = find + .ancestor(of: find.text('Footer'), matching: find.byType(Padding)) + .first; + expect(footerFinder, findsOneWidget); + final Rect footerRect = tester.getRect(footerFinder); + + final Finder inkWellFinder = find.descendant( + of: find.byType(NavigationDrawerDestination).first, + matching: find.byType(InkWell), + ); + expect(inkWellFinder, findsOneWidget); + + final inkMaterial = Material.of(tester.element(inkWellFinder)) as RenderBox; + + final Offset inkMaterialTopLeft = inkMaterial.localToGlobal(Offset.zero); + final Offset inkMaterialBottomRight = inkMaterial.localToGlobal( + Offset(inkMaterial.size.width, inkMaterial.size.height), + ); + final inkMaterialRect = Rect.fromPoints(inkMaterialTopLeft, inkMaterialBottomRight); + + expect(inkMaterialRect.bottom, equals(footerRect.top)); + }, + ); +} + +Widget _buildWidget(GlobalKey<ScaffoldState> scaffoldKey, Widget child, {bool? useMaterial3}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Scaffold(key: scaffoldKey, drawer: child, body: Container()), + ); +} + +Material _getMaterial(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(NavigationDrawer), matching: find.byType(Material)), + ); +} + +InkWell? _getInkWell(WidgetTester tester) { + return tester.firstWidget<InkWell>( + find.descendant(of: find.byType(NavigationDrawer), matching: find.byType(InkWell)), + ); +} + +ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) { + return tester + .firstWidget<Ink>( + find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; +} + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + +TextStyle? _labelStyle(WidgetTester tester, String label) { + final RichText labelRichText = tester.widget<RichText>( + find.descendant(of: find.text(label), matching: find.byType(RichText)), + ); + return labelRichText.text.style; +} + +void widgetSetup(WidgetTester tester, double viewWidth, {double viewHeight = 1000}) { + tester.view.devicePixelRatio = 2; + final double dpi = tester.view.devicePixelRatio; + tester.view.physicalSize = Size(viewWidth * dpi, viewHeight * dpi); + addTearDown(tester.view.reset); +} diff --git a/packages/material_ui/test/material/navigation_drawer_theme_test.dart b/packages/material_ui/test/material/navigation_drawer_theme_test.dart new file mode 100644 index 000000000000..e74e89e04dda --- /dev/null +++ b/packages/material_ui/test/material/navigation_drawer_theme_test.dart @@ -0,0 +1,305 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('NavigationDrawerThemeData copyWith, ==, hashCode, basics', () { + expect(const NavigationDrawerThemeData(), const NavigationDrawerThemeData().copyWith()); + expect( + const NavigationDrawerThemeData().hashCode, + const NavigationDrawerThemeData().copyWith().hashCode, + ); + }); + + test('NavigationDrawerThemeData lerp special cases', () { + expect(NavigationDrawerThemeData.lerp(null, null, 0), null); + const data = NavigationDrawerThemeData(); + expect(identical(NavigationDrawerThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Default debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const NavigationDrawerThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('NavigationDrawerThemeData implements debugFillProperties', ( + WidgetTester tester, + ) async { + final builder = DiagnosticPropertiesBuilder(); + const NavigationDrawerThemeData( + tileHeight: 50, + backgroundColor: Color(0x00000099), + elevation: 5.0, + shadowColor: Color(0x00000098), + surfaceTintColor: Color(0x00000097), + indicatorColor: Color(0x00000096), + indicatorShape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + indicatorSize: Size(10, 10), + labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)), + iconTheme: MaterialStatePropertyAll<IconThemeData>(IconThemeData(color: Color(0x00000095))), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'tileHeight: 50.0', + 'backgroundColor: ${const Color(0x00000099)}', + 'elevation: 5.0', + 'shadowColor: ${const Color(0x00000098)}', + 'surfaceTintColor: ${const Color(0x00000097)}', + 'indicatorColor: ${const Color(0x00000096)}', + 'indicatorShape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(2.0))', + 'indicatorSize: Size(10.0, 10.0)', + 'labelTextStyle: WidgetStatePropertyAll(TextStyle(inherit: true, size: 7.0))', + 'iconTheme: WidgetStatePropertyAll(IconThemeData#00000(color: ${const Color(0x00000095)}))', + ]), + ); + }); + + testWidgets( + 'NavigationDrawerThemeData values are used when no NavigationDrawer properties are specified', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const navigationDrawerTheme = NavigationDrawerThemeData( + backgroundColor: Color(0x00000001), + elevation: 7.0, + shadowColor: Color(0x00000002), + surfaceTintColor: Color(0x00000003), + indicatorColor: Color(0x00000004), + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.only(topRight: Radius.circular(16.0)), + ), + labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)), + iconTheme: MaterialStatePropertyAll<IconThemeData>(IconThemeData(color: Color(0x00000005))), + ); + + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + children: const <Widget>[ + Text('Headline'), + NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + NavigationDrawerDestination(icon: Icon(Icons.access_alarm), label: Text('Alarm')), + ], + onDestinationSelected: (int i) {}, + ), + theme: ThemeData(navigationDrawerTheme: navigationDrawerTheme), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + // Test drawer Material. + expect(_getMaterial(tester).color, navigationDrawerTheme.backgroundColor); + expect(_getMaterial(tester).surfaceTintColor, navigationDrawerTheme.surfaceTintColor); + expect(_getMaterial(tester).shadowColor, navigationDrawerTheme.shadowColor); + expect(_getMaterial(tester).elevation, 7); + // Test indicator decoration. + expect(_getIndicatorDecoration(tester)?.color, navigationDrawerTheme.indicatorColor); + expect(_getIndicatorDecoration(tester)?.shape, navigationDrawerTheme.indicatorShape); + // Test icon. + expect( + _iconStyle(tester, Icons.ac_unit)?.color, + navigationDrawerTheme.iconTheme?.resolve(<WidgetState>{})?.color, + ); + expect( + _iconStyle(tester, Icons.access_alarm)?.color, + navigationDrawerTheme.iconTheme?.resolve(<WidgetState>{})?.color, + ); + // Test label. + expect( + _labelStyle(tester, 'AC'), + navigationDrawerTheme.labelTextStyle?.resolve(<WidgetState>{}), + ); + expect( + _labelStyle(tester, 'Alarm'), + navigationDrawerTheme.labelTextStyle?.resolve(<WidgetState>{}), + ); + }, + ); + + testWidgets( + 'NavigationDrawer values take priority over NavigationDrawerThemeData values when both properties are specified', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const navigationDrawerTheme = NavigationDrawerThemeData( + backgroundColor: Color(0x00000001), + elevation: 7.0, + shadowColor: Color(0x00000002), + surfaceTintColor: Color(0x00000003), + indicatorColor: Color(0x00000004), + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.only(topRight: Radius.circular(16.0)), + ), + labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)), + iconTheme: MaterialStatePropertyAll<IconThemeData>(IconThemeData(color: Color(0x00000005))), + ); + const backgroundColor = Color(0x00000009); + const elevation = 14.0; + const shadowColor = Color(0x00000008); + const surfaceTintColor = Color(0x00000007); + const indicatorShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(32.0)), + ); + const indicatorColor = Color(0x00000006); + + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawer( + backgroundColor: backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + indicatorShape: indicatorShape, + indicatorColor: indicatorColor, + children: const <Widget>[ + Text('Headline'), + NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + NavigationDrawerDestination(icon: Icon(Icons.access_alarm), label: Text('Alarm')), + ], + onDestinationSelected: (int i) {}, + ), + theme: ThemeData(navigationDrawerTheme: navigationDrawerTheme), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + // Test drawer Material. + expect(_getMaterial(tester).color, backgroundColor); + expect(_getMaterial(tester).surfaceTintColor, surfaceTintColor); + expect(_getMaterial(tester).shadowColor, shadowColor); + expect(_getMaterial(tester).elevation, elevation); + // Test indicator decoration. + expect(_getIndicatorDecoration(tester)?.color, indicatorColor); + expect(_getIndicatorDecoration(tester)?.shape, indicatorShape); + }, + ); + + testWidgets('Local NavigationDrawerTheme takes priority over ThemeData.navigationDrawerTheme', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const backgroundColor = Color(0x00000009); + const elevation = 7.0; + const shadowColor = Color(0x00000008); + const surfaceTintColor = Color(0x00000007); + const iconColor = Color(0x00000006); + const labelStyle = TextStyle(fontSize: 7.0); + const ShapeBorder indicatorShape = CircleBorder(); + const indicatorColor = Color(0x00000005); + + await tester.pumpWidget( + _buildWidget( + scaffoldKey, + NavigationDrawerTheme( + data: const NavigationDrawerThemeData( + backgroundColor: backgroundColor, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + indicatorShape: indicatorShape, + indicatorColor: indicatorColor, + labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)), + iconTheme: MaterialStatePropertyAll<IconThemeData>(IconThemeData(color: iconColor)), + ), + child: NavigationDrawer( + children: const <Widget>[ + Text('Headline'), + NavigationDrawerDestination(icon: Icon(Icons.ac_unit), label: Text('AC')), + NavigationDrawerDestination(icon: Icon(Icons.access_alarm), label: Text('Alarm')), + ], + onDestinationSelected: (int i) {}, + ), + ), + theme: ThemeData( + navigationDrawerTheme: const NavigationDrawerThemeData( + backgroundColor: Color(0x00000001), + elevation: 7.0, + shadowColor: Color(0x00000002), + surfaceTintColor: Color(0x00000003), + indicatorColor: Color(0x00000004), + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.only(topRight: Radius.circular(16.0)), + ), + labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)), + iconTheme: MaterialStatePropertyAll<IconThemeData>( + IconThemeData(color: Color(0x00000005)), + ), + ), + ), + ), + ); + scaffoldKey.currentState!.openDrawer(); + await tester.pump(const Duration(seconds: 1)); + + // Test drawer Material. + expect(_getMaterial(tester).color, backgroundColor); + expect(_getMaterial(tester).surfaceTintColor, surfaceTintColor); + expect(_getMaterial(tester).shadowColor, shadowColor); + expect(_getMaterial(tester).elevation, elevation); + // Test indicator decoration. + expect(_getIndicatorDecoration(tester)?.color, indicatorColor); + expect(_getIndicatorDecoration(tester)?.shape, indicatorShape); + // Test icon. + expect(_iconStyle(tester, Icons.ac_unit)?.color, iconColor); + expect(_iconStyle(tester, Icons.access_alarm)?.color, iconColor); + // Test label. + expect(_labelStyle(tester, 'AC'), labelStyle); + expect(_labelStyle(tester, 'Alarm'), labelStyle); + }); +} + +Widget _buildWidget(GlobalKey<ScaffoldState> scaffoldKey, Widget child, {ThemeData? theme}) { + return MaterialApp( + theme: theme, + home: Scaffold(key: scaffoldKey, drawer: child, body: Container()), + ); +} + +Material _getMaterial(WidgetTester tester) { + return tester.firstWidget<Material>( + find.descendant(of: find.byType(NavigationDrawer), matching: find.byType(Material)), + ); +} + +ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) { + return tester + .firstWidget<Ink>( + find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; +} + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + return tester + .widget<RichText>(find.descendant(of: find.byIcon(icon), matching: find.byType(RichText))) + .text + .style; +} + +TextStyle? _labelStyle(WidgetTester tester, String label) { + return tester + .widget<RichText>(find.descendant(of: find.text(label), matching: find.byType(RichText))) + .text + .style; +} diff --git a/packages/material_ui/test/material/navigation_rail_test.dart b/packages/material_ui/test/material/navigation_rail_test.dart new file mode 100644 index 000000000000..c96cd46aa258 --- /dev/null +++ b/packages/material_ui/test/material/navigation_rail_test.dart @@ -0,0 +1,6401 @@ +// 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 '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Custom selected and unselected textStyles are honored', (WidgetTester tester) async { + const selectedTextStyle = TextStyle(fontWeight: FontWeight.w300, fontSize: 17.0); + const unselectedTextStyle = TextStyle(fontWeight: FontWeight.w800, fontSize: 11.0); + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + selectedLabelTextStyle: selectedTextStyle, + unselectedLabelTextStyle: unselectedTextStyle, + ), + ); + + final TextStyle actualSelectedTextStyle = tester + .renderObject<RenderParagraph>(find.text('Abc')) + .text + .style!; + final TextStyle actualUnselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text('Def')) + .text + .style!; + expect(actualSelectedTextStyle.fontSize, equals(selectedTextStyle.fontSize)); + expect(actualSelectedTextStyle.fontWeight, equals(selectedTextStyle.fontWeight)); + expect(actualUnselectedTextStyle.fontSize, equals(actualUnselectedTextStyle.fontSize)); + expect(actualUnselectedTextStyle.fontWeight, equals(actualUnselectedTextStyle.fontWeight)); + }); + + testWidgets('Custom selected and unselected iconThemes are honored', (WidgetTester tester) async { + const selectedIconTheme = IconThemeData(size: 36, color: Color(0x00000001)); + const unselectedIconTheme = IconThemeData(size: 18, color: Color(0x00000002)); + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + selectedIconTheme: selectedIconTheme, + unselectedIconTheme: unselectedIconTheme, + ), + ); + + final TextStyle actualSelectedIconTheme = _iconStyle(tester, Icons.favorite); + final TextStyle actualUnselectedIconTheme = _iconStyle(tester, Icons.bookmark_border); + expect(actualSelectedIconTheme.color, equals(selectedIconTheme.color)); + expect(actualSelectedIconTheme.fontSize, equals(selectedIconTheme.size)); + expect(actualUnselectedIconTheme.color, equals(unselectedIconTheme.color)); + expect(actualUnselectedIconTheme.fontSize, equals(unselectedIconTheme.size)); + }); + + testWidgets('No selected destination when selectedIndex is null', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail(selectedIndex: null, destinations: _destinations()), + ); + + final Iterable<Semantics> semantics = tester.widgetList<Semantics>(find.byType(Semantics)); + expect(semantics.where((Semantics s) => s.properties.selected ?? false), isEmpty); + }); + + testWidgets('backgroundColor can be changed', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + expect( + _railMaterial(tester).color, + equals(const Color(0xfffef7ff)), + ); // default surface color in M3 colorScheme + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + backgroundColor: Colors.green, + ), + ); + + expect(_railMaterial(tester).color, equals(Colors.green)); + }); + + testWidgets('elevation can be changed', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + expect(_railMaterial(tester).elevation, equals(0)); + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + elevation: 7, + ), + ); + + expect(_railMaterial(tester).elevation, equals(7)); + }); + + testWidgets('Renders at the correct default width - [labelType]=none (default)', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 80.0); + }); + + testWidgets('Renders at the correct default width - [labelType]=selected', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + labelType: NavigationRailLabelType.selected, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 80.0); + }); + + testWidgets('Renders at the correct default width - [labelType]=all', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + labelType: NavigationRailLabelType.all, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 80.0); + }); + + testWidgets('Leading and trailing spacing is correct with 0~2 destinations', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Padding at after the leading widget. + const spacerPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationSpacing = 12.0; + // Height of the leading and trailing widgets. + const fabHeight = 56.0; + + late StateSetter stateSetter; + var destinations = const <NavigationRailDestination>[]; + Widget? leadingWidget; + Widget? trailingWidget; + + const leadingWidgetKey = Key('leadingWidget'); + const trailingWidgetKey = Key('trailingWidget'); + + void matchExpect(RenderBox renderBox, double nextDestinationY) { + expect( + renderBox.localToGlobal(Offset.zero), + Offset( + (destinationWidth - renderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - renderBox.size.height) / 2.0, + ), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + destinations: destinations, + selectedIndex: null, + leading: leadingWidget, + trailing: trailingWidget, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + // empty destinations and leading widget + stateSetter(() { + destinations = const <NavigationRailDestination>[]; + leadingWidget = FloatingActionButton(key: leadingWidgetKey, onPressed: () {}); + trailingWidget = null; + }); + await tester.pumpAndSettle(); + RenderBox leadingWidgetRenderBox = tester.renderObject<RenderBox>(find.byKey(leadingWidgetKey)); + expect(leadingWidgetRenderBox.localToGlobal(Offset.zero), const Offset(0.0, topPadding)); + + // one destination and leading widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + ]; + }); + await tester.pumpAndSettle(); + var nextDestinationY = topPadding; + leadingWidgetRenderBox = tester.renderObject<RenderBox>(find.byKey(leadingWidgetKey)); + expect( + leadingWidgetRenderBox.localToGlobal(Offset.zero), + Offset((destinationWidth - leadingWidgetRenderBox.size.width) / 2.0, nextDestinationY), + ); + + nextDestinationY += fabHeight + spacerPadding + destinationSpacing / 2; + RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite_border); + matchExpect(firstIconRenderBox, nextDestinationY); + + // two destinations and leading widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ]; + }); + await tester.pumpAndSettle(); + nextDestinationY = topPadding; + leadingWidgetRenderBox = tester.renderObject<RenderBox>(find.byKey(leadingWidgetKey)); + expect( + leadingWidgetRenderBox.localToGlobal(Offset.zero), + Offset((destinationWidth - leadingWidgetRenderBox.size.width) / 2.0, nextDestinationY), + ); + + nextDestinationY += fabHeight + spacerPadding + destinationSpacing / 2; + firstIconRenderBox = _iconRenderBox(tester, Icons.favorite_border); + matchExpect(firstIconRenderBox, nextDestinationY); + + nextDestinationY += destinationHeight + destinationSpacing; + RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + matchExpect(secondIconRenderBox, nextDestinationY); + + // empty destinations and trailing widget + stateSetter(() { + destinations = const <NavigationRailDestination>[]; + leadingWidget = null; + trailingWidget = FloatingActionButton(key: trailingWidgetKey, onPressed: () {}); + }); + await tester.pumpAndSettle(); + RenderBox trailingWidgetRenderBox = tester.renderObject<RenderBox>( + find.byKey(trailingWidgetKey), + ); + expect(trailingWidgetRenderBox.localToGlobal(Offset.zero), const Offset(0.0, topPadding)); + + // one destination and trailing widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + ]; + }); + await tester.pumpAndSettle(); + nextDestinationY = topPadding + destinationSpacing / 2; + firstIconRenderBox = _iconRenderBox(tester, Icons.favorite_border); + matchExpect(firstIconRenderBox, nextDestinationY); + + nextDestinationY += destinationHeight + destinationSpacing / 2; + trailingWidgetRenderBox = tester.renderObject<RenderBox>(find.byKey(trailingWidgetKey)); + expect( + trailingWidgetRenderBox.localToGlobal(Offset.zero), + Offset((destinationWidth - trailingWidgetRenderBox.size.width) / 2.0, nextDestinationY), + ); + + // two destinations and trailing widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ]; + }); + await tester.pumpAndSettle(); + nextDestinationY = topPadding + destinationSpacing / 2; + firstIconRenderBox = _iconRenderBox(tester, Icons.favorite_border); + matchExpect(firstIconRenderBox, nextDestinationY); + + nextDestinationY += destinationHeight + destinationSpacing; + secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + matchExpect(secondIconRenderBox, nextDestinationY); + + nextDestinationY += destinationHeight + destinationSpacing / 2.0; + trailingWidgetRenderBox = tester.renderObject<RenderBox>(find.byKey(trailingWidgetKey)); + expect( + trailingWidgetRenderBox.localToGlobal(Offset.zero), + Offset((destinationWidth - trailingWidgetRenderBox.size.width) / 2.0, nextDestinationY), + ); + }); + + testWidgets('Change destinations and selectedIndex', (WidgetTester tester) async { + late StateSetter stateSetter; + int? selectedIndex; + var destinations = const <NavigationRailDestination>[]; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail(selectedIndex: selectedIndex, destinations: destinations), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 0); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, isNull); + + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ]; + }); + + await tester.pumpAndSettle(); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 2); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, isNull); + + stateSetter(() { + selectedIndex = 0; + }); + + await tester.pumpAndSettle(); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 2); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, 0); + + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + ]; + }); + + await tester.pumpAndSettle(); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 1); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, 0); + }); + + testWidgets('Renders wider for a destination with a long label - [labelType]=all', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + labelType: NavigationRailLabelType.all, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ], + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + // Total padding is 16 (8 on each side). + expect(renderBox.size.width, _labelRenderBox(tester, 'Longer Label').size.width + 16.0); + }); + + testWidgets('Renders only icons - [labelType]=none (default)', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.bookmark_border), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsOneWidget); + expect(find.byIcon(Icons.hotel), findsOneWidget); + + // When there are no labels, a 0 opacity label is still shown for semantics. + expect(_labelOpacity(tester, 'Abc'), 0); + expect(_labelOpacity(tester, 'Def'), 0); + expect(_labelOpacity(tester, 'Ghi'), 0); + expect(_labelOpacity(tester, 'Jkl'), 0); + }); + + testWidgets('Renders icons and labels - [labelType]=all', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.bookmark_border), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsOneWidget); + expect(find.byIcon(Icons.hotel), findsOneWidget); + + expect(find.text('Abc'), findsOneWidget); + expect(find.text('Def'), findsOneWidget); + expect(find.text('Ghi'), findsOneWidget); + expect(find.text('Jkl'), findsOneWidget); + + // When displaying all labels, there is no opacity. + expect(_opacityAboveLabel('Abc'), findsNothing); + expect(_opacityAboveLabel('Def'), findsNothing); + expect(_opacityAboveLabel('Ghi'), findsNothing); + expect(_opacityAboveLabel('Jkl'), findsNothing); + }); + + testWidgets('Renders icons and selected label - [labelType]=selected', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.bookmark_border), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsOneWidget); + expect(find.byIcon(Icons.hotel), findsOneWidget); + + // Only the selected label is visible. + expect(_labelOpacity(tester, 'Abc'), 1); + expect(_labelOpacity(tester, 'Def'), 0); + expect(_labelOpacity(tester, 'Ghi'), 0); + expect(_labelOpacity(tester, 'Jkl'), 0); + }); + + testWidgets( + 'Destination spacing is correct - [labelType]=none (default), [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationPadding / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct - [labelType]=none (default), [textScaleFactor]=3.0', + (WidgetTester tester) async { + // Since the rail is icon only, its destinations should not be affected by + // textScaleFactor. + + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 3.0, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationPadding / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct - [labelType]=none (default), [textScaleFactor]=0.75', + (WidgetTester tester) async { + // Since the rail is icon only, its destinations should not be affected by + // textScaleFactor. + + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 0.75, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationPadding / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct - [labelType]=selected, [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between the indicator and label. + const destinationLabelSpacing = 4.0; + // Height of the label. + const labelHeight = 16.0; + // Height of a destination with both icon and label. + const double destinationHeightWithLabel = + destinationHeight + destinationLabelSpacing + labelHeight; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination is topPadding below the rail top. + var nextDestinationY = topPadding; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + destinationHeight + destinationLabelSpacing, + ), + ), + ); + + // The second destination is below the first with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is below the second with some spacing. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is below the third with some spacing. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets('Destination spacing is correct - [labelType]=selected, [textScaleFactor]=3.0', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 125.5; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between the indicator and label. + const destinationLabelSpacing = 4.0; + // Height of the label. + const double labelHeight = 16.0 * 3.0; + // Height of a destination with both icon and label. + const double destinationHeightWithLabel = + destinationHeight + destinationLabelSpacing + labelHeight; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination topPadding below the rail top. + var nextDestinationY = topPadding; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + destinationHeight + destinationLabelSpacing, + ), + ), + ); + + // The second destination is below the first with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is below the second with some spacing. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is below the third with some spacing. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Destination spacing is correct - [labelType]=selected, [textScaleFactor]=0.75', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between the indicator and label. + const destinationLabelSpacing = 4.0; + // Height of the label. + const double labelHeight = 16.0 * 0.75; + // Height of a destination with both icon and label. + const double destinationHeightWithLabel = + destinationHeight + destinationLabelSpacing + labelHeight; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 0.75, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination topPadding below the rail top. + var nextDestinationY = topPadding; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + destinationHeight + destinationLabelSpacing, + ), + ), + ); + + // The second destination is below the first with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is below the second with some spacing. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is below the third with some spacing. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Destination spacing is correct - [labelType]=all, [textScaleFactor]=1.0 (default)', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between the indicator and label. + const destinationLabelSpacing = 4.0; + // Height of the label. + const labelHeight = 16.0; + // Height of a destination with both icon and label. + const double destinationHeightWithLabel = + destinationHeight + destinationLabelSpacing + labelHeight; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination topPadding below the rail top. + var nextDestinationY = topPadding; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + destinationHeight + destinationLabelSpacing, + ), + ), + ); + + // The second destination is below the first with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is below the second with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is below the third with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Destination spacing is correct - [labelType]=all, [textScaleFactor]=3.0', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 125.5; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between the indicator and label. + const destinationLabelSpacing = 4.0; + // Height of the label. + const double labelHeight = 16.0 * 3.0; + // Height of a destination with both icon and label. + const double destinationHeightWithLabel = + destinationHeight + destinationLabelSpacing + labelHeight; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination topPadding below the rail top. + var nextDestinationY = topPadding; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + destinationHeight + destinationLabelSpacing, + ), + ), + ); + + // The second destination is below the first with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is below the second with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is below the third with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Destination spacing is correct - [labelType]=all, [textScaleFactor]=0.75', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between the indicator and label. + const destinationLabelSpacing = 4.0; + // Height of the label. + const double labelHeight = 16.0 * 0.75; + // Height of a destination with both icon and label. + const double destinationHeightWithLabel = + destinationHeight + destinationLabelSpacing + labelHeight; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 0.75, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination topPadding below the rail top. + var nextDestinationY = topPadding; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + destinationHeight + destinationLabelSpacing, + ), + ), + ); + + // The second destination is below the first with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is below the second with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is below the third with some spacing. + nextDestinationY += destinationHeightWithLabel + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets( + 'Destination spacing is correct for a compact rail - [preferredWidth]=56, [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const compactWidth = 56.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + minWidth: 56.0, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 56.0); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationSpacing / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is row below the first destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is a row below the second destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is a row below the third destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct for a compact rail - [preferredWidth]=56, [textScaleFactor]=3.0', + (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const compactWidth = 56.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + minWidth: 56.0, + destinations: _destinations(), + ), + ); + + // Since the rail is icon only, its preferred width should not be affected + // by textScaleFactor. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, compactWidth); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationSpacing / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is row below the first destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is a row below the second destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is a row below the third destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct for a compact rail - [preferredWidth]=56, [textScaleFactor]=0.75', + (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const compactWidth = 56.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationSpacing = 12.0; + + await _pumpNavigationRail( + tester, + textScaleFactor: 0.75, + navigationRail: NavigationRail( + selectedIndex: 0, + minWidth: 56.0, + destinations: _destinations(), + ), + ); + + // Since the rail is icon only, its preferred width should not be affected + // by textScaleFactor. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, compactWidth); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationSpacing / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is row below the first destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is a row below the second destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is a row below the third destination. + nextDestinationY += destinationHeight + destinationSpacing; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (compactWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets('Group alignment works - [groupAlignment]=-1.0 (default)', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationPadding / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Group alignment works - [groupAlignment]=0.0', (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + groupAlignment: 0.0, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination below the rail top by some padding with an offset for the alignment. + double nextDestinationY = topPadding + destinationPadding / 2 + 208; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Group alignment works - [groupAlignment]=1.0', (WidgetTester tester) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + groupAlignment: 1.0, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, destinationWidth); + + // The first destination below the rail top by some padding with an offset for the alignment. + double nextDestinationY = topPadding + destinationPadding / 2 + 416; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Leading and trailing appear in the correct places', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + leading: FloatingActionButton(onPressed: () {}), + trailing: FloatingActionButton(onPressed: () {}), + destinations: _destinations(), + ), + ); + + final RenderBox leading = tester.renderObject<RenderBox>( + find.byType(FloatingActionButton).at(0), + ); + final RenderBox trailing = tester.renderObject<RenderBox>( + find.byType(FloatingActionButton).at(1), + ); + expect(leading.localToGlobal(Offset.zero), Offset((80 - leading.size.width) / 2, 8.0)); + expect(trailing.localToGlobal(Offset.zero), Offset((80 - trailing.size.width) / 2, 248.0)); + }); + + testWidgets('Extended rail animates the width and labels appear - [textDirection]=LTR', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, destinationWidth); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(rail.size.width, equals(168.0)); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(256.0)); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationPadding / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + destinationWidth, + nextDestinationY + (destinationHeight - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + destinationWidth, + nextDestinationY + (destinationHeight - secondLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + destinationWidth, + nextDestinationY + (destinationHeight - thirdLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (destinationWidth - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + destinationWidth, + nextDestinationY + (destinationHeight - fourthLabelRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Extended rail animates the width and labels appear - [textDirection]=RTL', ( + WidgetTester tester, + ) async { + // Padding at the top of the rail. + const topPadding = 8.0; + // Width of a destination. + const destinationWidth = 80.0; + // Height of a destination indicator with icon. + const destinationHeight = 32.0; + // Space between destinations. + const destinationPadding = 12.0; + + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: Row( + textDirection: TextDirection.rtl, + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(destinationWidth)); + expect(rail.localToGlobal(Offset.zero), equals(const Offset(720.0, 0.0))); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(rail.size.width, equals(168.0)); + expect(rail.localToGlobal(Offset.zero), equals(const Offset(632.0, 0.0))); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(256.0)); + expect(rail.localToGlobal(Offset.zero), equals(const Offset(544.0, 0.0))); + + // The first destination below the rail top by some padding. + double nextDestinationY = topPadding + destinationPadding / 2; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (destinationWidth + firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - destinationWidth - firstLabelRenderBox.size.width, + nextDestinationY + (destinationHeight - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is one height below the first destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (destinationWidth + secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - destinationWidth - secondLabelRenderBox.size.width, + nextDestinationY + (destinationHeight - secondLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is one height below the second destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (destinationWidth + thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - destinationWidth - thirdLabelRenderBox.size.width, + nextDestinationY + (destinationHeight - thirdLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is one height below the third destination. + nextDestinationY += destinationHeight + destinationPadding; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800 - (destinationWidth + fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (destinationHeight - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - destinationWidth - fourthLabelRenderBox.size.width, + nextDestinationY + (destinationHeight - fourthLabelRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Extended rail gets wider with longer labels are larger text scale', ( + WidgetTester tester, + ) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ], + extended: extended, + ), + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(80.0)); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(rail.size.width, equals(303.0)); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(526.0)); + }); + + testWidgets('Extended rail final width can be changed', (WidgetTester tester) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + minExtendedWidth: 300, + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(80.0)); + + stateSetter(() { + extended = true; + }); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(300.0)); + }); + + /// Regression test for https://github.com/flutter/flutter/issues/65657 + testWidgets('Extended rail transition does not jump from the beginning', ( + WidgetTester tester, + ) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ], + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final Finder rail = find.byType(NavigationRail); + + // Before starting the animation, the rail has a width of 80. + expect(tester.getSize(rail).width, 80.0); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + // Create very close to 0, but non-zero, animation value. + await tester.pump(const Duration(milliseconds: 1)); + // Expect that it has started to extend. + expect(tester.getSize(rail).width, greaterThan(80.0)); + // Expect that it has only extended by a small amount, or that the first + // frame does not jump. This helps verify that it is a smooth animation. + expect(tester.getSize(rail).width, closeTo(80.0, 1.0)); + }); + + testWidgets('Extended rail animation can be consumed', (WidgetTester tester) async { + var extended = false; + late Animation<double> animation; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + leading: Builder( + builder: (BuildContext context) { + animation = NavigationRail.extendedAnimation(context); + return FloatingActionButton(onPressed: () {}); + }, + ), + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + expect(animation.isDismissed, isTrue); + + stateSetter(() { + extended = true; + }); + await tester.pumpAndSettle(); + + expect(animation.isCompleted, isTrue); + }); + + testWidgets('onDestinationSelected is called', (WidgetTester tester) async { + late int selectedIndex; + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + onDestinationSelected: (int index) { + selectedIndex = index; + }, + labelType: NavigationRailLabelType.all, + ), + ); + + await tester.tap(find.text('Def')); + expect(selectedIndex, 1); + + await tester.tap(find.text('Ghi')); + expect(selectedIndex, 2); + + // Wait for any pending shader compilation. + await tester.pumpAndSettle(); + }); + + testWidgets('onDestinationSelected is not called if null', (WidgetTester tester) async { + const selectedIndex = 0; + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: selectedIndex, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + await tester.tap(find.text('Def')); + expect(selectedIndex, 0); + + // Wait for any pending shader compilation. + await tester.pumpAndSettle(); + }); + + testWidgets('Changing destinations animate when [labelType]=selected', ( + WidgetTester tester, + ) async { + var selectedIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + destinations: _destinations(), + selectedIndex: selectedIndex, + labelType: NavigationRailLabelType.selected, + onDestinationSelected: (int index) { + setState(() { + selectedIndex = index; + }); + }, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + // Tap the second destination. + await tester.tap(find.byIcon(Icons.bookmark_border)); + expect(selectedIndex, 1); + + // The second destination animates in. + expect(_labelOpacity(tester, 'Def'), equals(0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(_labelOpacity(tester, 'Def'), equals(0.5)); + await tester.pumpAndSettle(); + expect(_labelOpacity(tester, 'Def'), equals(1.0)); + + // Tap the third destination. + await tester.tap(find.byIcon(Icons.star_border)); + expect(selectedIndex, 2); + + // The second destination animates out quickly and the third destination + // animates in. + expect(_labelOpacity(tester, 'Ghi'), equals(0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 25)); + expect(_labelOpacity(tester, 'Def'), equals(0.5)); + expect(_labelOpacity(tester, 'Ghi'), equals(0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 25)); + expect(_labelOpacity(tester, 'Def'), equals(0.0)); + expect(_labelOpacity(tester, 'Ghi'), equals(0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(_labelOpacity(tester, 'Ghi'), equals(0.5)); + await tester.pumpAndSettle(); + expect(_labelOpacity(tester, 'Ghi'), equals(1.0)); + }); + + testWidgets('Changing destinations animate for selectedIndex=null', (WidgetTester tester) async { + int? selectedIndex = 0; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + destinations: _destinations(), + selectedIndex: selectedIndex, + labelType: NavigationRailLabelType.selected, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + // Unset the selected index. + stateSetter(() { + selectedIndex = null; + }); + + // The first destination animates out. + expect(_labelOpacity(tester, 'Abc'), equals(1.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 25)); + expect(_labelOpacity(tester, 'Abc'), equals(0.5)); + await tester.pumpAndSettle(); + expect(_labelOpacity(tester, 'Abc'), equals(0.0)); + + // Set the selected index to the first destination. + stateSetter(() { + selectedIndex = 0; + }); + + // The first destination animates in. + expect(_labelOpacity(tester, 'Abc'), equals(0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(_labelOpacity(tester, 'Abc'), equals(0.5)); + await tester.pumpAndSettle(); + expect(_labelOpacity(tester, 'Abc'), equals(1.0)); + }); + + testWidgets('Changing destinations animate when selectedIndex=null during transition', ( + WidgetTester tester, + ) async { + int? selectedIndex = 0; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + destinations: _destinations(), + selectedIndex: selectedIndex, + labelType: NavigationRailLabelType.selected, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + stateSetter(() { + selectedIndex = 1; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 175)); + + // Interrupt while animating from index 0 to 1. + stateSetter(() { + selectedIndex = null; + }); + + expect(_labelOpacity(tester, 'Abc'), equals(0)); + expect(_labelOpacity(tester, 'Def'), equals(1)); + + await tester.pump(); + // Create very close to 0, but non-zero, animation value. + await tester.pump(const Duration(milliseconds: 1)); + // Ensure the opacity is animated back towards 0. + expect(_labelOpacity(tester, 'Def'), lessThan(0.5)); + expect(_labelOpacity(tester, 'Def'), closeTo(0.5, 0.03)); + + await tester.pumpAndSettle(); + expect(_labelOpacity(tester, 'Abc'), equals(0.0)); + expect(_labelOpacity(tester, 'Def'), equals(0.0)); + }); + + testWidgets('Semantics - labelType=[none]', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await _pumpLocalizedTestRail(tester, labelType: NavigationRailLabelType.none); + + expect( + semantics, + hasSemantics(_expectedSemantics(), ignoreId: true, ignoreTransform: true, ignoreRect: true), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics - labelType=[selected]', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await _pumpLocalizedTestRail(tester, labelType: NavigationRailLabelType.selected); + + expect( + semantics, + hasSemantics(_expectedSemantics(), ignoreId: true, ignoreTransform: true, ignoreRect: true), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics - labelType=[all]', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await _pumpLocalizedTestRail(tester, labelType: NavigationRailLabelType.all); + + expect( + semantics, + hasSemantics(_expectedSemantics(), ignoreId: true, ignoreTransform: true, ignoreRect: true), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics - extended', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await _pumpLocalizedTestRail(tester, extended: true); + + expect( + semantics, + hasSemantics(_expectedSemantics(), ignoreId: true, ignoreTransform: true, ignoreRect: true), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics - scrollable', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await _pumpLocalizedTestRail(tester, scrollable: true); + + expect( + semantics, + hasSemantics( + _expectedSemantics(scrollable: true), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('NavigationRailDestination padding properly applied - NavigationRailLabelType.all', ( + WidgetTester tester, + ) async { + const defaultPadding = EdgeInsets.symmetric(horizontal: 8.0); + const secondItemPadding = EdgeInsets.symmetric(vertical: 30.0); + const thirdItemPadding = EdgeInsets.symmetric(horizontal: 10.0); + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + labelType: NavigationRailLabelType.all, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + padding: secondItemPadding, + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + padding: thirdItemPadding, + ), + ], + ), + ); + + final Iterable<Widget> indicatorInkWells = tester.allWidgets.where( + (Widget object) => object.runtimeType.toString() == '_IndicatorInkWell', + ); + final Padding firstItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(0).runtimeType, 'Abc'), + matching: find.widgetWithText(Padding, 'Abc'), + ), + ); + final Padding secondItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(1).runtimeType, 'Def'), + matching: find.widgetWithText(Padding, 'Def'), + ), + ); + final Padding thirdItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(2).runtimeType, 'Ghi'), + matching: find.widgetWithText(Padding, 'Ghi'), + ), + ); + + expect(firstItem.padding, defaultPadding); + expect(secondItem.padding, secondItemPadding); + expect(thirdItem.padding, thirdItemPadding); + }); + + testWidgets( + 'NavigationRailDestination padding properly applied - NavigationRailLabelType.selected', + (WidgetTester tester) async { + const defaultPadding = EdgeInsets.symmetric(horizontal: 8.0); + const secondItemPadding = EdgeInsets.symmetric(vertical: 30.0); + const thirdItemPadding = EdgeInsets.symmetric(horizontal: 10.0); + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + labelType: NavigationRailLabelType.selected, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + padding: secondItemPadding, + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + padding: thirdItemPadding, + ), + ], + ), + ); + + final Iterable<Widget> indicatorInkWells = tester.allWidgets.where( + (Widget object) => object.runtimeType.toString() == '_IndicatorInkWell', + ); + final Padding firstItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(0).runtimeType, 'Abc'), + matching: find.widgetWithText(Padding, 'Abc'), + ), + ); + final Padding secondItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(1).runtimeType, 'Def'), + matching: find.widgetWithText(Padding, 'Def'), + ), + ); + final Padding thirdItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(2).runtimeType, 'Ghi'), + matching: find.widgetWithText(Padding, 'Ghi'), + ), + ); + + expect(firstItem.padding, defaultPadding); + expect(secondItem.padding, secondItemPadding); + expect(thirdItem.padding, thirdItemPadding); + }, + ); + + testWidgets('NavigationRailDestination padding properly applied - NavigationRailLabelType.none', ( + WidgetTester tester, + ) async { + const EdgeInsets defaultPadding = EdgeInsets.zero; + const secondItemPadding = EdgeInsets.symmetric(vertical: 30.0); + const thirdItemPadding = EdgeInsets.symmetric(horizontal: 10.0); + + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + labelType: NavigationRailLabelType.none, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + padding: secondItemPadding, + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + padding: thirdItemPadding, + ), + ], + ), + ); + + final Iterable<Widget> indicatorInkWells = tester.allWidgets.where( + (Widget object) => object.runtimeType.toString() == '_IndicatorInkWell', + ); + final Padding firstItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(0).runtimeType, 'Abc'), + matching: find.widgetWithText(Padding, 'Abc'), + ), + ); + final Padding secondItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(1).runtimeType, 'Def'), + matching: find.widgetWithText(Padding, 'Def'), + ), + ); + final Padding thirdItem = tester.widget<Padding>( + find.descendant( + of: find.widgetWithText(indicatorInkWells.elementAt(2).runtimeType, 'Ghi'), + matching: find.widgetWithText(Padding, 'Ghi'), + ), + ); + + expect(firstItem.padding, defaultPadding); + expect(secondItem.padding, secondItemPadding); + expect(thirdItem.padding, thirdItemPadding); + }); + + testWidgets( + 'NavigationRailDestination adds indicator by default when ThemeData.useMaterial3 is true', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + labelType: NavigationRailLabelType.selected, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + expect(find.byType(NavigationIndicator), findsWidgets); + }, + ); + + testWidgets('NavigationRailDestination adds indicator when useIndicator is true', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + useIndicator: true, + labelType: NavigationRailLabelType.selected, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + expect(find.byType(NavigationIndicator), findsWidgets); + }); + + testWidgets('NavigationRailDestination does not add indicator when useIndicator is false', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + useIndicator: false, + labelType: NavigationRailLabelType.selected, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + expect(find.byType(NavigationIndicator), findsNothing); + }); + + testWidgets('NavigationRailDestination adds an oval indicator when no labels are present', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + useIndicator: true, + labelType: NavigationRailLabelType.none, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + final NavigationIndicator indicator = tester.widget<NavigationIndicator>( + find.byType(NavigationIndicator).first, + ); + + expect(indicator.width, 56); + expect(indicator.height, 32); + }); + + testWidgets('NavigationRailDestination adds an oval indicator when selected labels are present', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + useIndicator: true, + labelType: NavigationRailLabelType.selected, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + final NavigationIndicator indicator = tester.widget<NavigationIndicator>( + find.byType(NavigationIndicator).first, + ); + + expect(indicator.width, 56); + expect(indicator.height, 32); + }); + + testWidgets('NavigationRailDestination adds an oval indicator when all labels are present', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + useIndicator: true, + labelType: NavigationRailLabelType.all, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + final NavigationIndicator indicator = tester.widget<NavigationIndicator>( + find.byType(NavigationIndicator).first, + ); + + expect(indicator.width, 56); + expect(indicator.height, 32); + }); + + testWidgets('NavigationRailDestination has center aligned indicator - [labelType]=none', ( + WidgetTester tester, + ) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/97753 + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + labelType: NavigationRailLabelType.none, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Stack( + children: <Widget>[ + Icon(Icons.umbrella), + Positioned( + top: 0, + right: 0, + child: Text('Text', style: TextStyle(fontSize: 10, color: Colors.red)), + ), + ], + ), + label: Text('Abc'), + ), + NavigationRailDestination(icon: Icon(Icons.umbrella), label: Text('Def')), + NavigationRailDestination(icon: Icon(Icons.bookmark_border), label: Text('Ghi')), + ], + ), + ); + // Indicator with Stack widget + final RenderBox firstIndicator = tester.renderObject(find.byType(Icon).first); + expect(firstIndicator.localToGlobal(Offset.zero).dx, 28.0); + // Indicator without Stack widget + final RenderBox lastIndicator = tester.renderObject(find.byType(Icon).last); + expect(lastIndicator.localToGlobal(Offset.zero).dx, 28.0); + }); + + testWidgets('NavigationRail respects the notch/system navigation bar in landscape mode', ( + WidgetTester tester, + ) async { + const safeAreaPadding = 40.0; + NavigationRail navigationRail() { + return NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + ); + } + + await tester.pumpWidget(_buildWidget(navigationRail())); + final double defaultWidth = tester.getSize(find.byType(NavigationRail)).width; + expect(defaultWidth, 80); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(left: safeAreaPadding)), + child: navigationRail(), + ), + ), + ); + final double updatedWidth = tester.getSize(find.byType(NavigationRail)).width; + expect(updatedWidth, defaultWidth + safeAreaPadding); + + // test width when text direction is RTL. + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(right: safeAreaPadding)), + child: navigationRail(), + ), + isRTL: true, + ), + ); + final double updatedWidthRTL = tester.getSize(find.byType(NavigationRail)).width; + expect(updatedWidthRTL, defaultWidth + safeAreaPadding); + }); + + testWidgets('NavigationRail indicator renders ripple', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 1, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + labelType: NavigationRailLabelType.all, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + const indicatorRect = Rect.fromLTRB(12.0, 0.0, 68.0, 32.0); + const includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + ..rrect( + rrect: RRect.fromLTRBR(12.0, 0.0, 68.0, 32.0, const Radius.circular(16)), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator renders ripple - extended', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117126 + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 1, + extended: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + labelType: NavigationRailLabelType.none, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + const indicatorRect = Rect.fromLTRB(12.0, 6.0, 68.0, 38.0); + const includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + ..rrect( + rrect: RRect.fromLTRBR(12.0, 6.0, 68.0, 38.0, const Radius.circular(16)), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator renders properly when padding is applied', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117126 + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 1, + extended: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + padding: EdgeInsets.all(10), + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + padding: EdgeInsets.all(18), + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + labelType: NavigationRailLabelType.none, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + const indicatorRect = Rect.fromLTRB(22.0, 16.0, 78.0, 48.0); + const includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + ..rrect( + rrect: RRect.fromLTRBR(30.0, 24.0, 86.0, 56.0, const Radius.circular(16)), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('Indicator renders properly when NavigationRai.minWidth < default minWidth', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117126 + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + minWidth: 50, + selectedIndex: 1, + extended: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + labelType: NavigationRailLabelType.none, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + const indicatorRect = Rect.fromLTRB(-3.0, 6.0, 53.0, 38.0); + const includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + ..rrect( + rrect: RRect.fromLTRBR(0.0, 6.0, 50.0, 38.0, const Radius.circular(16)), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator renders properly with custom padding and minWidth', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/117126 + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + minWidth: 300, + selectedIndex: 1, + extended: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + padding: EdgeInsets.all(10), + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + padding: EdgeInsets.all(18), + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + labelType: NavigationRailLabelType.none, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + const indicatorRect = Rect.fromLTRB(132.0, 16.0, 188.0, 48.0); + const includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + ..rrect( + rrect: RRect.fromLTRBR(140.0, 24.0, 196.0, 56.0, const Radius.circular(16)), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator renders properly with long labels', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/128005. + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 1, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('ABC'), + ), + ], + labelType: NavigationRailLabelType.all, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // Default values from M3 specification. + const railMinWidth = 80.0; + const indicatorHeight = 32.0; + const destinationWidth = 72.0; + const destinationHorizontalPadding = 8.0; + const double indicatorWidth = destinationWidth - 2 * destinationHorizontalPadding; // 56.0 + + // The navigation rail width is larger than default because of the first destination long label. + final double railWidth = tester.getSize(find.byType(NavigationRail)).width; + + // Expected indicator position. + final double indicatorLeft = (railWidth - indicatorWidth) / 2; + final double indicatorRight = (railWidth + indicatorWidth) / 2; + final indicatorRect = Rect.fromLTRB(indicatorLeft, 0.0, indicatorRight, indicatorHeight); + final includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + const double indicatorHorizontalPadding = (railMinWidth - indicatorWidth) / 2; // 12.0 + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + ..rrect( + rrect: RRect.fromLTRBR( + indicatorHorizontalPadding, + 0.0, + indicatorHorizontalPadding + indicatorWidth, + indicatorHeight, + const Radius.circular(16), + ), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator renders properly with large icon', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/133799. + const double iconSize = 50; + await _pumpNavigationRail( + tester, + navigationRailTheme: const NavigationRailThemeData( + selectedIconTheme: IconThemeData(size: iconSize), + unselectedIconTheme: IconThemeData(size: iconSize), + ), + navigationRail: NavigationRail( + selectedIndex: 1, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('ABC'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('DEF'), + ), + ], + labelType: NavigationRailLabelType.all, + ), + ); + + // Hover the first destination. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // Default values from M3 specification. + const railMinWidth = 80.0; + const indicatorHeight = 32.0; + const destinationWidth = 72.0; + const destinationHorizontalPadding = 8.0; + const double indicatorWidth = destinationWidth - 2 * destinationHorizontalPadding; // 56.0 + + // The navigation rail width is the default one because labels are short. + final double railWidth = tester.getSize(find.byType(NavigationRail)).width; + expect(railWidth, railMinWidth); + + // Expected indicator position. + final double indicatorLeft = (railWidth - indicatorWidth) / 2; + final double indicatorRight = (railWidth + indicatorWidth) / 2; + const double indicatorTop = (iconSize - indicatorHeight) / 2; + const double indicatorBottom = (iconSize + indicatorHeight) / 2; + final indicatorRect = Rect.fromLTRB( + indicatorLeft, + indicatorTop, + indicatorRight, + indicatorBottom, + ); + final includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + // Icon height is greater than indicator height so the indicator has a vertical offset. + const double secondIndicatorVerticalOffset = (iconSize - indicatorHeight) / 2; + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + // Hover highlight for the hovered destination (the one with 'favorite' icon). + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + // Indicator for the selected destination (the one with 'bookmark' icon). + ..rrect( + rrect: RRect.fromLTRBR( + indicatorLeft, + secondIndicatorVerticalOffset, + indicatorRight, + secondIndicatorVerticalOffset + indicatorHeight, + const Radius.circular(16), + ), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator renders properly when text direction is rtl', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/134361. + await tester.pumpWidget( + _buildWidget( + NavigationRail( + selectedIndex: 1, + extended: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('ABC'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('DEF'), + ), + ], + ), + isRTL: true, + ), + ); + + // Hover the first destination. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // Default values from M3 specification. + const railMinExtendedWidth = 256.0; + const indicatorHeight = 32.0; + const destinationWidth = 72.0; + const destinationHorizontalPadding = 8.0; + const double indicatorWidth = destinationWidth - 2 * destinationHorizontalPadding; // 56.0 + const verticalDestinationSpacingM3 = 12.0; + + // The navigation rail width is the default one because labels are short. + final double railWidth = tester.getSize(find.byType(NavigationRail)).width; + expect(railWidth, railMinExtendedWidth); + + // Expected indicator position. + final double indicatorLeft = railWidth - (destinationWidth - destinationHorizontalPadding / 2); + final double indicatorRight = indicatorLeft + indicatorWidth; + final indicatorRect = Rect.fromLTRB( + indicatorLeft, + verticalDestinationSpacingM3 / 2, + indicatorRight, + verticalDestinationSpacingM3 / 2 + indicatorHeight, + ); + final includedRect = indicatorRect; + final Rect excludedRect = includedRect.inflate(10); + + // Compute the vertical position for the selected destination (the one with 'bookmark' icon). + const double secondIndicatorVerticalOffset = verticalDestinationSpacingM3 / 2; + + expect( + inkFeatures, + paints + ..clipPath( + pathMatcher: isPathThat( + includes: <Offset>[ + includedRect.centerLeft, + includedRect.topCenter, + includedRect.centerRight, + includedRect.bottomCenter, + ], + excludes: <Offset>[ + excludedRect.centerLeft, + excludedRect.topCenter, + excludedRect.centerRight, + excludedRect.bottomCenter, + ], + ), + ) + // Hover highlight for the hovered destination (the one with 'favorite' icon). + ..rect(rect: indicatorRect, color: const Color(0x0a6750a4)) + // Indicator for the selected destination (the one with 'bookmark' icon). + ..rrect( + rrect: RRect.fromLTRBR( + indicatorLeft, + secondIndicatorVerticalOffset, + indicatorRight, + secondIndicatorVerticalOffset + indicatorHeight, + const Radius.circular(16), + ), + color: const Color(0xffe8def8), + ), + ); + }); + + testWidgets('NavigationRail indicator scale transform', (WidgetTester tester) async { + var selectedIndex = 0; + Future<void> buildWidget() async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: selectedIndex, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + labelType: NavigationRailLabelType.all, + ), + ); + } + + await buildWidget(); + await tester.pumpAndSettle(); + final Finder transformFinder = find + .descendant(of: find.byType(NavigationIndicator), matching: find.byType(Transform)) + .last; + Matrix4 transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], 0.0); + + selectedIndex = 1; + await buildWidget(); + await tester.pump(const Duration(milliseconds: 100)); + transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], closeTo(0.9705023956298828, precisionErrorTolerance)); + + await tester.pump(const Duration(milliseconds: 100)); + transform = tester.widget<Transform>(transformFinder).transform; + expect(transform.getColumn(0)[0], 1.0); + }); + + testWidgets('Navigation destination updates indicator color and shape', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + const color = Color(0xff0000ff); + const ShapeBorder shape = RoundedRectangleBorder(); + + Widget buildNavigationRail({Color? indicatorColor, ShapeBorder? indicatorShape}) { + return MaterialApp( + theme: theme, + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + useIndicator: true, + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + selectedIndex: 0, + destinations: _destinations(), + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildNavigationRail()); + + // Test default indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); + expect(_getIndicatorDecoration(tester)?.shape, const StadiumBorder()); + + await tester.pumpWidget(buildNavigationRail(indicatorColor: color, indicatorShape: shape)); + + // Test custom indicator color and shape. + expect(_getIndicatorDecoration(tester)?.color, color); + expect(_getIndicatorDecoration(tester)?.shape, shape); + }); + + testWidgets("Destination's respect their disabled state", (WidgetTester tester) async { + late int selectedIndex; + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Bcd'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Cde'), + disabled: true, + ), + ], + onDestinationSelected: (int index) { + selectedIndex = index; + }, + labelType: NavigationRailLabelType.all, + ), + ); + + await tester.tap(find.text('Abc')); + expect(selectedIndex, 0); + + await tester.tap(find.text('Bcd')); + expect(selectedIndex, 1); + + await tester.tap(find.text('Cde')); + expect(selectedIndex, 1); + + // Wait for any pending shader compilation. + await tester.pumpAndSettle(); + }); + + testWidgets("Destination's label with the right opacity while disabled", ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Bcd'), + disabled: true, + ), + ], + onDestinationSelected: (int index) {}, + labelType: NavigationRailLabelType.all, + ), + ); + + await tester.pumpAndSettle(); + + double? defaultTextStyleOpacity(String text) { + return tester + .widget<DefaultTextStyle>( + find.ancestor(of: find.text(text), matching: find.byType(DefaultTextStyle)).first, + ) + .style + .color + ?.opacity; + } + + final double? abcLabelOpacity = defaultTextStyleOpacity('Abc'); + final double? bcdLabelOpacity = defaultTextStyleOpacity('Bcd'); + + expect(abcLabelOpacity, 1.0); + expect(bcdLabelOpacity, closeTo(0.38, 0.01)); + }); + + testWidgets('NavigationRail indicator inkwell can be transparent', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/135866. + final theme = ThemeData( + colorScheme: const ColorScheme.light().copyWith(primary: Colors.transparent), + // Material 3 defaults to InkSparkle which is not testable using paints. + splashFactory: InkSplash.splashFactory, + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 1, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('ABC'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('DEF'), + ), + ], + labelType: NavigationRailLabelType.all, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byIcon(Icons.favorite_border))); + await tester.pumpAndSettle(); + RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + expect(inkFeatures, paints..rect(color: Colors.transparent)); + + await gesture.down(tester.getCenter(find.byIcon(Icons.favorite_border))); + 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. + + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..circle(color: Colors.transparent)); + }); + + testWidgets('Navigation rail can have expanded widgets inside', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination(icon: Icon(Icons.favorite_border), label: Text('Abc')), + NavigationRailDestination(icon: Icon(Icons.bookmark_border), label: Text('Bcd')), + ], + trailing: const Expanded(child: Icon(Icons.search)), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('NavigationRail labels shall not overflow if longer texts provided - extended', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/110901. + // The navigation rail has a narrow width constraint. The text should wrap. + const normalLabel = 'Abc'; + const longLabel = 'Very long bookmark text for navigation destination'; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Row( + children: <Widget>[ + SizedBox( + width: 140.0, + child: NavigationRail( + selectedIndex: 1, + extended: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text(normalLabel), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text(longLabel), + ), + ], + ), + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + expect(find.byType(NavigationRail), findsOneWidget); + expect(find.text(normalLabel), findsOneWidget); + expect(find.text(longLabel), findsOneWidget); + + // If the widget manages to layout without throwing an overflow exception, + // the test passes. + expect(tester.takeException(), isNull); + }); + + testWidgets('NavigationRail can scroll in low height', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + // Set Screen height with 300 + data: MediaQuery.of(context).copyWith(size: const Size(800, 300)), + child: Scaffold( + body: Row( + children: <Widget>[ + // Set NavigationRail height with 100 + SizedBox( + height: 100, + child: NavigationRail( + selectedIndex: 0, + scrollable: true, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ), + const Expanded(child: Text('body')), + ], + ), + ), + ); + }, + ), + ), + ); + + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + scrollable.position.jumpTo(500.0); + expect(scrollable.position.pixels, equals(500.0)); + }); + + testWidgets( + 'NavigationRail leading widget is at top and trailing widget is at last destination (defaults)', + (WidgetTester tester) async { + const leadingKey = Key('leading'); + const trailingKey = Key('trailing'); + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Row( + children: <Widget>[ + SizedBox( + width: 140.0, + child: NavigationRail( + selectedIndex: 0, + extended: true, + groupAlignment: 0.0, + leading: const SizedBox(key: leadingKey, height: 50), + trailing: const SizedBox(key: trailingKey, height: 50), + destinations: const <NavigationRailDestination>[ + NavigationRailDestination(icon: Icon(Icons.favorite), label: Text('Abc')), + NavigationRailDestination(icon: Icon(Icons.bookmark), label: Text('Def')), + NavigationRailDestination(icon: Icon(Icons.star), label: Text('Ghi')), + ], + ), + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final Rect leadingRect = tester.getRect(find.byKey(leadingKey)); + final Rect trailingRect = tester.getRect(find.byKey(trailingKey)); + final Rect firstDestRect = tester.getRect( + find.ancestor(of: find.byIcon(Icons.favorite), matching: find.byType(Semantics)).first, + ); + final Rect lastDestRect = tester.getRect( + find.ancestor(of: find.byIcon(Icons.star), matching: find.byType(Semantics)).first, + ); + + expect(leadingRect.top, 8); + expect(firstDestRect.top - leadingRect.bottom, greaterThan(100)); + expect(trailingRect.top - lastDestRect.bottom, 0); + expect(trailingRect.bottom, lessThan(500)); + }, + ); + + testWidgets('NavigationRail leadingAtTop set to false and trailingAtBottom to true', ( + WidgetTester tester, + ) async { + const leadingKey = Key('leading'); + const trailingKey = Key('trailing'); + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Row( + children: <Widget>[ + SizedBox( + width: 140.0, + child: NavigationRail( + selectedIndex: 0, + extended: true, + groupAlignment: 0.0, + leadingAtTop: false, + trailingAtBottom: true, + leading: const SizedBox(key: leadingKey, height: 50), + trailing: const SizedBox(key: trailingKey, height: 50), + destinations: const <NavigationRailDestination>[ + NavigationRailDestination(icon: Icon(Icons.favorite), label: Text('Abc')), + NavigationRailDestination(icon: Icon(Icons.bookmark), label: Text('Def')), + NavigationRailDestination(icon: Icon(Icons.star), label: Text('Ghi')), + ], + ), + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final Rect leadingRect = tester.getRect(find.byKey(leadingKey)); + final Rect trailingRect = tester.getRect(find.byKey(trailingKey)); + final Rect firstDestRect = tester.getRect( + find.ancestor(of: find.byIcon(Icons.favorite), matching: find.byType(Semantics)).first, + ); + final Rect lastDestRect = tester.getRect( + find.ancestor(of: find.byIcon(Icons.star), matching: find.byType(Semantics)).first, + ); + + expect(leadingRect.top, greaterThan(100)); + expect(firstDestRect.top - leadingRect.bottom, 8); + expect(trailingRect.top - lastDestRect.bottom, greaterThan(100)); + expect(trailingRect.bottom, 600); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Renders at the correct default width - [labelType]=none (default)', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + }); + + testWidgets('Renders at the correct default width - [labelType]=selected', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + labelType: NavigationRailLabelType.selected, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + }); + + testWidgets('Renders at the correct default width - [labelType]=all', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + labelType: NavigationRailLabelType.all, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + }); + + testWidgets('Leading and trailing spacing is correct with 0~2 destinations', ( + WidgetTester tester, + ) async { + late StateSetter stateSetter; + var destinations = const <NavigationRailDestination>[]; + Widget? leadingWidget; + Widget? trailingWidget; + + const leadingWidgetKey = Key('leadingWidget'); + const trailingWidgetKey = Key('trailingWidget'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + destinations: destinations, + selectedIndex: null, + leading: leadingWidget, + trailing: trailingWidget, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + // empty destinations and leading widget + stateSetter(() { + destinations = const <NavigationRailDestination>[]; + leadingWidget = FloatingActionButton(key: leadingWidgetKey, onPressed: () {}); + trailingWidget = null; + }); + await tester.pumpAndSettle(); + expect( + tester.renderObject<RenderBox>(find.byKey(leadingWidgetKey)).localToGlobal(Offset.zero), + const Offset(0, 8.0), + ); + + // one destination and leading widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + ]; + }); + await tester.pumpAndSettle(); + expect( + _iconRenderBox(tester, Icons.favorite_border).localToGlobal(Offset.zero), + const Offset(24.0, 96.0), + ); + expect(_labelRenderBox(tester, 'Abc').localToGlobal(Offset.zero), const Offset(0.0, 72.0)); + expect( + tester.renderObject<RenderBox>(find.byKey(leadingWidgetKey)).localToGlobal(Offset.zero), + const Offset(8.0, 8.0), + ); + + // two destinations and leading widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ]; + }); + await tester.pumpAndSettle(); + expect( + _iconRenderBox(tester, Icons.favorite_border).localToGlobal(Offset.zero), + const Offset(24.0, 96.0), + ); + expect(_labelRenderBox(tester, 'Abc').localToGlobal(Offset.zero), const Offset(0.0, 72.0)); + expect( + _iconRenderBox(tester, Icons.bookmark_border).localToGlobal(Offset.zero), + const Offset(24.0, 168.0), + ); + expect( + _labelRenderBox(tester, 'Longer Label').localToGlobal(Offset.zero), + const Offset(0.0, 144.0), + ); + expect( + tester.renderObject<RenderBox>(find.byKey(leadingWidgetKey)).localToGlobal(Offset.zero), + const Offset(8.0, 8.0), + ); + + // empty destinations and trailing widget + stateSetter(() { + destinations = const <NavigationRailDestination>[]; + leadingWidget = null; + trailingWidget = FloatingActionButton(key: trailingWidgetKey, onPressed: () {}); + }); + await tester.pumpAndSettle(); + expect( + tester.renderObject<RenderBox>(find.byKey(trailingWidgetKey)).localToGlobal(Offset.zero), + const Offset(0, 8.0), + ); + + // one destination and trailing widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + ]; + }); + await tester.pumpAndSettle(); + expect( + _iconRenderBox(tester, Icons.favorite_border).localToGlobal(Offset.zero), + const Offset(24.0, 32.0), + ); + expect(_labelRenderBox(tester, 'Abc').localToGlobal(Offset.zero), const Offset(0.0, 8.0)); + expect( + tester.renderObject<RenderBox>(find.byKey(trailingWidgetKey)).localToGlobal(Offset.zero), + const Offset(8.0, 80.0), + ); + + // two destinations and trailing widget + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ]; + }); + await tester.pumpAndSettle(); + expect( + _iconRenderBox(tester, Icons.favorite_border).localToGlobal(Offset.zero), + const Offset(24.0, 32.0), + ); + expect(_labelRenderBox(tester, 'Abc').localToGlobal(Offset.zero), const Offset(0.0, 8.0)); + expect( + _iconRenderBox(tester, Icons.bookmark_border).localToGlobal(Offset.zero), + const Offset(24.0, 104.0), + ); + expect( + _labelRenderBox(tester, 'Longer Label').localToGlobal(Offset.zero), + const Offset(0.0, 80.0), + ); + expect( + tester.renderObject<RenderBox>(find.byKey(trailingWidgetKey)).localToGlobal(Offset.zero), + const Offset(8.0, 152.0), + ); + }); + + testWidgets('Change destinations and selectedIndex', (WidgetTester tester) async { + late StateSetter stateSetter; + int? selectedIndex; + var destinations = const <NavigationRailDestination>[]; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail(selectedIndex: selectedIndex, destinations: destinations), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 0); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, isNull); + + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ]; + }); + + await tester.pumpAndSettle(); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 2); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, isNull); + + stateSetter(() { + selectedIndex = 0; + }); + + await tester.pumpAndSettle(); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 2); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, 0); + + stateSetter(() { + destinations = const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + ]; + }); + + await tester.pumpAndSettle(); + + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).destinations.length, 1); + expect(tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex, 0); + }); + + testWidgets( + 'Destination spacing is correct - [labelType]=none (default), [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct - [labelType]=none (default), [textScaleFactor]=3.0', + (WidgetTester tester) async { + // Since the rail is icon only, its destinations should not be affected by + // textScaleFactor. + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 3.0, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct - [labelType]=none (default), [textScaleFactor]=0.75', + (WidgetTester tester) async { + // Since the rail is icon only, its destinations should not be affected by + // textScaleFactor. + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 0.75, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct - [labelType]=selected, [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + + (72.0 - firstIconRenderBox.size.height - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + + (72.0 + firstIconRenderBox.size.height - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets('Destination spacing is correct - [labelType]=selected, [textScaleFactor]=3.0', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + // The rail and destinations sizes grow to fit the larger text labels. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 142.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - firstIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + + // The first label sits right below the first icon. + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (142.0 - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + firstIconRenderBox.size.height, + ), + ), + ); + + nextDestinationY += + 16.0 + firstIconRenderBox.size.height + firstLabelRenderBox.size.height + 16.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - secondIconRenderBox.size.width) / 2.0, nextDestinationY + 24.0)), + ); + + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - thirdIconRenderBox.size.width) / 2.0, nextDestinationY + 24.0)), + ); + + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - fourthIconRenderBox.size.width) / 2.0, nextDestinationY + 24.0)), + ); + }); + + testWidgets('Destination spacing is correct - [labelType]=selected, [textScaleFactor]=0.75', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 0.75, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.selected, + ), + ); + + // A smaller textScaleFactor will not reduce the default width of the rail + // since there is a minWidth. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + + (72.0 - firstIconRenderBox.size.height - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + + (72.0 + firstIconRenderBox.size.height - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets( + 'Destination spacing is correct - [labelType]=all, [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - firstIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + firstIconRenderBox.size.height, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - secondIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + secondIconRenderBox.size.height, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - thirdIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + thirdIconRenderBox.size.height, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - fourthIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + fourthIconRenderBox.size.height, + ), + ), + ); + }, + ); + + testWidgets('Destination spacing is correct - [labelType]=all, [textScaleFactor]=3.0', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + // The rail and destinations sizes grow to fit the larger text labels. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 142.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - firstIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (142.0 - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + firstIconRenderBox.size.height, + ), + ), + ); + + nextDestinationY += + 16.0 + firstIconRenderBox.size.height + firstLabelRenderBox.size.height + 16.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - secondIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (142.0 - secondLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + secondIconRenderBox.size.height, + ), + ), + ); + + nextDestinationY += + 16.0 + secondIconRenderBox.size.height + secondLabelRenderBox.size.height + 16.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - thirdIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (142.0 - thirdLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + thirdIconRenderBox.size.height, + ), + ), + ); + + nextDestinationY += + 16.0 + thirdIconRenderBox.size.height + thirdLabelRenderBox.size.height + 16.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((142.0 - fourthIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (142.0 - fourthLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + fourthIconRenderBox.size.height, + ), + ), + ); + }); + + testWidgets('Destination spacing is correct - [labelType]=all, [textScaleFactor]=0.75', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 0.75, + navigationRail: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + labelType: NavigationRailLabelType.all, + ), + ); + + // A smaller textScaleFactor will not reduce the default size of the rail. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 72.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - firstIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + firstIconRenderBox.size.height, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - secondIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + secondIconRenderBox.size.height, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - thirdIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + thirdIconRenderBox.size.height, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals(Offset((72.0 - fourthIconRenderBox.size.width) / 2.0, nextDestinationY + 16.0)), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthLabelRenderBox.size.width) / 2.0, + nextDestinationY + 16.0 + fourthIconRenderBox.size.height, + ), + ), + ); + }); + + testWidgets( + 'Destination spacing is correct for a compact rail - [preferredWidth]=56, [textScaleFactor]=1.0 (default)', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + minWidth: 56.0, + destinations: _destinations(), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 56.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 56 below the first destination. + nextDestinationY += 56.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 56 below the second destination. + nextDestinationY += 56.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 56 below the third destination. + nextDestinationY += 56.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct for a compact rail - [preferredWidth]=56, [textScaleFactor]=3.0', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + minWidth: 56.0, + destinations: _destinations(), + ), + ); + + // Since the rail is icon only, its preferred width should not be affected + // by textScaleFactor. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 56.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 56 below the first destination. + nextDestinationY += 56.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 56 below the second destination. + nextDestinationY += 56.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 56 below the third destination. + nextDestinationY += 56.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets( + 'Destination spacing is correct for a compact rail - [preferredWidth]=56, [textScaleFactor]=0.75', + (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + textScaleFactor: 3.0, + navigationRail: NavigationRail( + selectedIndex: 0, + minWidth: 56.0, + destinations: _destinations(), + ), + ); + + // Since the rail is icon only, its preferred width should not be affected + // by textScaleFactor. + final RenderBox renderBox = tester.renderObject(find.byType(NavigationRail)); + expect(renderBox.size.width, 56.0); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 56 below the first destination. + nextDestinationY += 56.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 56 below the second destination. + nextDestinationY += 56.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 56 below the third destination. + nextDestinationY += 56.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (56.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (56.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }, + ); + + testWidgets('Group alignment works - [groupAlignment]=-1.0 (default)', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Group alignment works - [groupAlignment]=0.0', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + groupAlignment: 0.0, + destinations: _destinations(), + ), + ); + + var nextDestinationY = 160.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Group alignment works - [groupAlignment]=1.0', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + groupAlignment: 1.0, + destinations: _destinations(), + ), + ); + + var nextDestinationY = 312.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Leading and trailing appear in the correct places', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + selectedIndex: 0, + leading: FloatingActionButton(onPressed: () {}), + trailing: FloatingActionButton(onPressed: () {}), + destinations: _destinations(), + ), + ); + + final RenderBox leading = tester.renderObject<RenderBox>( + find.byType(FloatingActionButton).at(0), + ); + final RenderBox trailing = tester.renderObject<RenderBox>( + find.byType(FloatingActionButton).at(1), + ); + expect(leading.localToGlobal(Offset.zero), Offset((72 - leading.size.width) / 2.0, 8.0)); + expect(trailing.localToGlobal(Offset.zero), Offset((72 - trailing.size.width) / 2.0, 360.0)); + }); + + testWidgets('Extended rail animates the width and labels appear - [textDirection]=LTR', ( + WidgetTester tester, + ) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(72.0)); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(rail.size.width, equals(164.0)); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(256.0)); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals(Offset(72.0, nextDestinationY + (72.0 - firstLabelRenderBox.size.height) / 2.0)), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals(Offset(72.0, nextDestinationY + (72.0 - secondLabelRenderBox.size.height) / 2.0)), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals(Offset(72.0, nextDestinationY + (72.0 - thirdLabelRenderBox.size.height) / 2.0)), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + (72.0 - fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals(Offset(72.0, nextDestinationY + (72.0 - fourthLabelRenderBox.size.height) / 2.0)), + ); + }); + + testWidgets('Extended rail animates the width and labels appear - [textDirection]=RTL', ( + WidgetTester tester, + ) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: Row( + textDirection: TextDirection.rtl, + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(72.0)); + expect(rail.localToGlobal(Offset.zero), equals(const Offset(728.0, 0.0))); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(rail.size.width, equals(164.0)); + expect(rail.localToGlobal(Offset.zero), equals(const Offset(636.0, 0.0))); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(256.0)); + expect(rail.localToGlobal(Offset.zero), equals(const Offset(544.0, 0.0))); + + // The first destination is 8 from the top because of the default vertical + // padding at the to of the rail. + var nextDestinationY = 8.0; + final RenderBox firstIconRenderBox = _iconRenderBox(tester, Icons.favorite); + final RenderBox firstLabelRenderBox = _labelRenderBox(tester, 'Abc'); + expect( + firstIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (72.0 + firstIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - firstIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + firstLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - 72.0 - firstLabelRenderBox.size.width, + nextDestinationY + (72.0 - firstLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The second destination is 72 below the first destination. + nextDestinationY += 72.0; + final RenderBox secondIconRenderBox = _iconRenderBox(tester, Icons.bookmark_border); + final RenderBox secondLabelRenderBox = _labelRenderBox(tester, 'Def'); + expect( + secondIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (72.0 + secondIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - secondIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + secondLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - 72.0 - secondLabelRenderBox.size.width, + nextDestinationY + (72.0 - secondLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The third destination is 72 below the second destination. + nextDestinationY += 72.0; + final RenderBox thirdIconRenderBox = _iconRenderBox(tester, Icons.star_border); + final RenderBox thirdLabelRenderBox = _labelRenderBox(tester, 'Ghi'); + expect( + thirdIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (72.0 + thirdIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - thirdIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + thirdLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - 72.0 - thirdLabelRenderBox.size.width, + nextDestinationY + (72.0 - thirdLabelRenderBox.size.height) / 2.0, + ), + ), + ); + + // The fourth destination is 72 below the third destination. + nextDestinationY += 72.0; + final RenderBox fourthIconRenderBox = _iconRenderBox(tester, Icons.hotel); + final RenderBox fourthLabelRenderBox = _labelRenderBox(tester, 'Jkl'); + expect( + fourthIconRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - (72.0 + fourthIconRenderBox.size.width) / 2.0, + nextDestinationY + (72.0 - fourthIconRenderBox.size.height) / 2.0, + ), + ), + ); + expect( + fourthLabelRenderBox.localToGlobal(Offset.zero), + equals( + Offset( + 800.0 - 72.0 - fourthLabelRenderBox.size.width, + nextDestinationY + (72.0 - fourthLabelRenderBox.size.height) / 2.0, + ), + ), + ); + }); + + testWidgets('Extended rail gets wider with longer labels are larger text scale', ( + WidgetTester tester, + ) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ], + extended: extended, + ), + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(72.0)); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(rail.size.width, equals(328.0)); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(584.0)); + }); + + testWidgets('Extended rail final width can be changed', (WidgetTester tester) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + minExtendedWidth: 300, + destinations: _destinations(), + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final RenderBox rail = tester.firstRenderObject<RenderBox>(find.byType(NavigationRail)); + + expect(rail.size.width, equals(72.0)); + + stateSetter(() { + extended = true; + }); + + await tester.pumpAndSettle(); + expect(rail.size.width, equals(300.0)); + }); + + /// Regression test for https://github.com/flutter/flutter/issues/65657 + testWidgets('Extended rail transition does not jump from the beginning', ( + WidgetTester tester, + ) async { + var extended = false; + late StateSetter stateSetter; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Longer Label'), + ), + ], + extended: extended, + ), + const Expanded(child: Text('body')), + ], + ), + ); + }, + ), + ), + ); + + final Finder rail = find.byType(NavigationRail); + + // Before starting the animation, the rail has a width of 72. + expect(tester.getSize(rail).width, 72.0); + + stateSetter(() { + extended = true; + }); + + await tester.pump(); + // Create very close to 0, but non-zero, animation value. + await tester.pump(const Duration(milliseconds: 1)); + // Expect that it has started to extend. + expect(tester.getSize(rail).width, greaterThan(72.0)); + // Expect that it has only extended by a small amount, or that the first + // frame does not jump. This helps verify that it is a smooth animation. + expect(tester.getSize(rail).width, closeTo(72.0, 1.0)); + }); + + testWidgets('NavigationRailDestination adds circular indicator when no labels are present', ( + WidgetTester tester, + ) async { + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + useIndicator: true, + labelType: NavigationRailLabelType.none, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + ], + ), + ); + + final NavigationIndicator indicator = tester.widget<NavigationIndicator>( + find.byType(NavigationIndicator).first, + ); + + expect(indicator.width, 56); + expect(indicator.height, 56); + }); + + testWidgets('NavigationRailDestination has center aligned indicator - [labelType]=none', ( + WidgetTester tester, + ) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/97753 + await _pumpNavigationRail( + tester, + useMaterial3: false, + navigationRail: NavigationRail( + labelType: NavigationRailLabelType.none, + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Stack( + children: <Widget>[ + Icon(Icons.umbrella), + Positioned( + top: 0, + right: 0, + child: Text('Text', style: TextStyle(fontSize: 10, color: Colors.red)), + ), + ], + ), + label: Text('Abc'), + ), + NavigationRailDestination(icon: Icon(Icons.umbrella), label: Text('Def')), + NavigationRailDestination(icon: Icon(Icons.bookmark_border), label: Text('Ghi')), + ], + ), + ); + // Indicator with Stack widget + final RenderBox firstIndicator = tester.renderObject(find.byType(Icon).first); + expect(firstIndicator.localToGlobal(Offset.zero).dx, 24.0); + // Indicator without Stack widget + final RenderBox lastIndicator = tester.renderObject(find.byType(Icon).last); + expect(lastIndicator.localToGlobal(Offset.zero).dx, 24.0); + }); + + testWidgets('NavigationRail respects the notch/system navigation bar in landscape mode', ( + WidgetTester tester, + ) async { + const safeAreaPadding = 40.0; + NavigationRail navigationRail() { + return NavigationRail( + selectedIndex: 0, + destinations: const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + ], + ); + } + + await tester.pumpWidget(_buildWidget(navigationRail(), useMaterial3: false)); + final double defaultWidth = tester.getSize(find.byType(NavigationRail)).width; + expect(defaultWidth, 72); + + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(left: safeAreaPadding)), + child: navigationRail(), + ), + useMaterial3: false, + ), + ); + final double updatedWidth = tester.getSize(find.byType(NavigationRail)).width; + expect(updatedWidth, defaultWidth + safeAreaPadding); + + // test width when text direction is RTL. + await tester.pumpWidget( + _buildWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(right: safeAreaPadding)), + child: navigationRail(), + ), + useMaterial3: false, + isRTL: true, + ), + ); + final double updatedWidthRTL = tester.getSize(find.byType(NavigationRail)).width; + expect(updatedWidthRTL, defaultWidth + safeAreaPadding); + }); + }); // End Material 2 group + + testWidgets('NavigationRail does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: NavigationRail( + destinations: const <NavigationRailDestination>[ + NavigationRailDestination(icon: Icon(Icons.abc), label: Text('X')), + ], + selectedIndex: 0, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(NavigationRail)), Size.zero); + }); + + testWidgets('NavigationRail respects mainAxisAlignment', (WidgetTester tester) async { + await _pumpNavigationRail( + tester, + navigationRail: NavigationRail( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + selectedIndex: 0, + destinations: _destinations(), + ), + ); + + // Find the exact column by checking both alignment and size + final Finder columnFinder = find.byWidgetPredicate( + (Widget widget) => + widget is Column && + widget.mainAxisAlignment == MainAxisAlignment.spaceEvenly && + widget.mainAxisSize == MainAxisSize.max, + ); + + expect(columnFinder, findsOneWidget); + }); +} + +TestSemantics _expectedSemantics({bool scrollable = false}) { + var destinations = <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Abc\nTab 1 of 4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Def\nTab 2 of 4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Ghi\nTab 3 of 4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Jkl\nTab 4 of 4', + textDirection: TextDirection.ltr, + ), + ]; + + if (scrollable) { + destinations = <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: destinations, + ), + ]; + } + + return TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics(children: destinations), + TestSemantics(label: 'body', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); +} + +List<NavigationRailDestination> _destinations() { + return const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.bookmark_border), + selectedIcon: Icon(Icons.bookmark), + label: Text('Def'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Ghi'), + ), + NavigationRailDestination( + icon: Icon(Icons.hotel), + selectedIcon: Icon(Icons.home), + label: Text('Jkl'), + ), + ]; +} + +Future<void> _pumpNavigationRail( + WidgetTester tester, { + double textScaleFactor = 1.0, + required NavigationRail navigationRail, + bool useMaterial3 = true, + NavigationRailThemeData? navigationRailTheme, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3, navigationRailTheme: navigationRailTheme), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Scaffold( + body: Row( + children: <Widget>[ + navigationRail, + const Expanded(child: Text('body')), + ], + ), + ), + ); + }, + ), + ), + ); +} + +Future<void> _pumpLocalizedTestRail( + WidgetTester tester, { + NavigationRailLabelType? labelType, + bool extended = false, + bool scrollable = false, +}) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: MaterialApp( + home: Scaffold( + body: Row( + children: <Widget>[ + NavigationRail( + selectedIndex: 0, + extended: extended, + destinations: _destinations(), + labelType: labelType, + scrollable: scrollable, + ), + const Expanded(child: Text('body')), + ], + ), + ), + ), + ), + ); +} + +RenderBox _iconRenderBox(WidgetTester tester, IconData iconData) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: find.byIcon(iconData), matching: find.byType(RichText)), + ); +} + +RenderBox _labelRenderBox(WidgetTester tester, String text) { + return tester.firstRenderObject<RenderBox>( + find.descendant(of: find.text(text), matching: find.byType(RichText)), + ); +} + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + return tester + .widget<RichText>(find.descendant(of: find.byIcon(icon), matching: find.byType(RichText))) + .text + .style!; +} + +Finder _opacityAboveLabel(String text) { + return find.ancestor(of: find.text(text), matching: find.byType(Opacity)); +} + +// Only valid when labelType != all. +double? _labelOpacity(WidgetTester tester, String text) { + // We search for both Visibility and FadeTransition since in some + // cases opacity is animated, in other it's not. + final Iterable<Visibility> visibilityWidgets = tester.widgetList<Visibility>( + find.ancestor(of: find.text(text), matching: find.byType(Visibility)), + ); + if (visibilityWidgets.isNotEmpty) { + return visibilityWidgets.single.visible ? 1.0 : 0.0; + } + + final FadeTransition fadeTransitionWidget = tester.widget<FadeTransition>( + find + .ancestor(of: find.text(text), matching: find.byType(FadeTransition)) + .first, // first because there's also a FadeTransition from the MaterialPageRoute, which is up the tree + ); + return fadeTransitionWidget.opacity.value; +} + +Material _railMaterial(WidgetTester tester) { + // The first material is for the rail, and the rest are for the destinations. + return tester.firstWidget<Material>( + find.descendant(of: find.byType(NavigationRail), matching: find.byType(Material)), + ); +} + +Widget _buildWidget(Widget child, {bool useMaterial3 = true, bool isRTL = false}) { + return MaterialApp( + theme: ThemeData(useMaterial3: useMaterial3), + home: Directionality( + textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr, + child: Scaffold( + body: Row( + children: <Widget>[ + child, + const Expanded(child: Text('body')), + ], + ), + ), + ), + ); +} + +ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) { + return tester + .firstWidget<Ink>( + find.descendant(of: find.byType(FadeTransition), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; +} diff --git a/packages/material_ui/test/material/navigation_rail_theme_test.dart b/packages/material_ui/test/material/navigation_rail_theme_test.dart new file mode 100644 index 000000000000..a4009b139a17 --- /dev/null +++ b/packages/material_ui/test/material/navigation_rail_theme_test.dart @@ -0,0 +1,400 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('copyWith, ==, hashCode basics', () { + expect(const NavigationRailThemeData(), const NavigationRailThemeData().copyWith()); + expect( + const NavigationRailThemeData().hashCode, + const NavigationRailThemeData().copyWith().hashCode, + ); + }); + + testWidgets( + 'Material3 - Default values are used when no NavigationRail or NavigationRailThemeData properties are specified', + (WidgetTester tester) async { + final theme = ThemeData(); + // Material 3 defaults + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold(body: NavigationRail(selectedIndex: 0, destinations: _destinations())), + ), + ); + + expect(_railMaterial(tester).color, theme.colorScheme.surface); + expect(_railMaterial(tester).elevation, 0); + expect(_destinationSize(tester).width, 80.0); + expect(_selectedIconTheme(tester).size, 24.0); + expect(_selectedIconTheme(tester).color, theme.colorScheme.onSecondaryContainer); + expect(_selectedIconTheme(tester).opacity, null); + expect(_unselectedIconTheme(tester).size, 24.0); + expect(_unselectedIconTheme(tester).color, theme.colorScheme.onSurfaceVariant); + expect(_unselectedIconTheme(tester).opacity, null); + expect(_selectedLabelStyle(tester).fontSize, 14.0); + expect(_unselectedLabelStyle(tester).fontSize, 14.0); + expect(_destinationsAlign(tester).alignment, Alignment.topCenter); + expect(_labelType(tester), NavigationRailLabelType.none); + expect(find.byType(NavigationIndicator), findsWidgets); + expect(_indicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); + expect(_indicatorDecoration(tester)?.shape, const StadiumBorder()); + final inkResponse = + tester.allWidgets.firstWhere( + (Widget object) => object.runtimeType.toString() == '_IndicatorInkWell', + ) + as InkResponse; + expect(inkResponse.customBorder, const StadiumBorder()); + }, + ); + + testWidgets( + 'Material2 - Default values are used when no NavigationRail or NavigationRailThemeData properties are specified', + (WidgetTester tester) async { + // This test can be removed when `useMaterial3` is deprecated. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light().copyWith(useMaterial3: false), + home: Scaffold(body: NavigationRail(selectedIndex: 0, destinations: _destinations())), + ), + ); + + expect(_railMaterial(tester).color, ThemeData().colorScheme.surface); + expect(_railMaterial(tester).elevation, 0); + expect(_destinationSize(tester).width, 72.0); + expect(_selectedIconTheme(tester).size, 24.0); + expect(_selectedIconTheme(tester).color, ThemeData().colorScheme.primary); + expect(_selectedIconTheme(tester).opacity, 1.0); + expect(_unselectedIconTheme(tester).size, 24.0); + expect(_unselectedIconTheme(tester).color, ThemeData().colorScheme.onSurface); + expect(_unselectedIconTheme(tester).opacity, 0.64); + expect(_selectedLabelStyle(tester).fontSize, 14.0); + expect(_unselectedLabelStyle(tester).fontSize, 14.0); + expect(_destinationsAlign(tester).alignment, Alignment.topCenter); + expect(_labelType(tester), NavigationRailLabelType.none); + expect(find.byType(NavigationIndicator), findsNothing); + }, + ); + + testWidgets( + 'NavigationRailThemeData values are used when no NavigationRail properties are specified', + (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const elevation = 7.0; + const selectedIconSize = 25.0; + const unselectedIconSize = 23.0; + const selectedIconColor = Color(0x00000002); + const unselectedIconColor = Color(0x00000003); + const selectedIconOpacity = 0.99; + const unselectedIconOpacity = 0.98; + const selectedLabelFontSize = 13.0; + const unselectedLabelFontSize = 11.0; + const groupAlignment = 0.0; + const NavigationRailLabelType labelType = NavigationRailLabelType.all; + const useIndicator = true; + const indicatorColor = Color(0x00000004); + const ShapeBorder indicatorShape = RoundedRectangleBorder(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NavigationRailTheme( + data: const NavigationRailThemeData( + backgroundColor: backgroundColor, + elevation: elevation, + selectedIconTheme: IconThemeData( + size: selectedIconSize, + color: selectedIconColor, + opacity: selectedIconOpacity, + ), + unselectedIconTheme: IconThemeData( + size: unselectedIconSize, + color: unselectedIconColor, + opacity: unselectedIconOpacity, + ), + selectedLabelTextStyle: TextStyle(fontSize: selectedLabelFontSize), + unselectedLabelTextStyle: TextStyle(fontSize: unselectedLabelFontSize), + groupAlignment: groupAlignment, + labelType: labelType, + useIndicator: useIndicator, + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + ), + child: NavigationRail(selectedIndex: 0, destinations: _destinations()), + ), + ), + ), + ); + + expect(_railMaterial(tester).color, backgroundColor); + expect(_railMaterial(tester).elevation, elevation); + expect(_selectedIconTheme(tester).size, selectedIconSize); + expect(_selectedIconTheme(tester).color, selectedIconColor); + expect(_selectedIconTheme(tester).opacity, selectedIconOpacity); + expect(_unselectedIconTheme(tester).size, unselectedIconSize); + expect(_unselectedIconTheme(tester).color, unselectedIconColor); + expect(_unselectedIconTheme(tester).opacity, unselectedIconOpacity); + expect(_selectedLabelStyle(tester).fontSize, selectedLabelFontSize); + expect(_unselectedLabelStyle(tester).fontSize, unselectedLabelFontSize); + expect(_destinationsAlign(tester).alignment, Alignment.center); + expect(_labelType(tester), labelType); + expect(find.byType(NavigationIndicator), findsWidgets); + expect(_indicatorDecoration(tester)?.color, indicatorColor); + expect(_indicatorDecoration(tester)?.shape, indicatorShape); + }, + ); + + testWidgets( + 'NavigationRail values take priority over NavigationRailThemeData values when both properties are specified', + (WidgetTester tester) async { + const backgroundColor = Color(0x00000001); + const elevation = 7.0; + const selectedIconSize = 25.0; + const unselectedIconSize = 23.0; + const selectedIconColor = Color(0x00000002); + const unselectedIconColor = Color(0x00000003); + const selectedIconOpacity = 0.99; + const unselectedIconOpacity = 0.98; + const selectedLabelFontSize = 13.0; + const unselectedLabelFontSize = 11.0; + const groupAlignment = 0.0; + const NavigationRailLabelType labelType = NavigationRailLabelType.all; + const useIndicator = true; + const indicatorColor = Color(0x00000004); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NavigationRailTheme( + data: const NavigationRailThemeData( + backgroundColor: Color(0x00000099), + elevation: 5, + selectedIconTheme: IconThemeData( + size: 31.0, + color: Color(0x00000098), + opacity: 0.81, + ), + unselectedIconTheme: IconThemeData( + size: 37.0, + color: Color(0x00000097), + opacity: 0.82, + ), + selectedLabelTextStyle: TextStyle(fontSize: 9.0), + unselectedLabelTextStyle: TextStyle(fontSize: 7.0), + groupAlignment: 1.0, + labelType: NavigationRailLabelType.selected, + useIndicator: false, + indicatorColor: Color(0x00000096), + ), + child: NavigationRail( + selectedIndex: 0, + destinations: _destinations(), + backgroundColor: backgroundColor, + elevation: elevation, + selectedIconTheme: const IconThemeData( + size: selectedIconSize, + color: selectedIconColor, + opacity: selectedIconOpacity, + ), + unselectedIconTheme: const IconThemeData( + size: unselectedIconSize, + color: unselectedIconColor, + opacity: unselectedIconOpacity, + ), + selectedLabelTextStyle: const TextStyle(fontSize: selectedLabelFontSize), + unselectedLabelTextStyle: const TextStyle(fontSize: unselectedLabelFontSize), + groupAlignment: groupAlignment, + labelType: labelType, + useIndicator: useIndicator, + indicatorColor: indicatorColor, + ), + ), + ), + ), + ); + + expect(_railMaterial(tester).color, backgroundColor); + expect(_railMaterial(tester).elevation, elevation); + expect(_selectedIconTheme(tester).size, selectedIconSize); + expect(_selectedIconTheme(tester).color, selectedIconColor); + expect(_selectedIconTheme(tester).opacity, selectedIconOpacity); + expect(_unselectedIconTheme(tester).size, unselectedIconSize); + expect(_unselectedIconTheme(tester).color, unselectedIconColor); + expect(_unselectedIconTheme(tester).opacity, unselectedIconOpacity); + expect(_selectedLabelStyle(tester).fontSize, selectedLabelFontSize); + expect(_unselectedLabelStyle(tester).fontSize, unselectedLabelFontSize); + expect(_destinationsAlign(tester).alignment, Alignment.center); + expect(_labelType(tester), labelType); + expect(find.byType(NavigationIndicator), findsWidgets); + expect(_indicatorDecoration(tester)?.color, indicatorColor); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/118618. + testWidgets('NavigationRailThemeData lerps correctly with null iconThemes', ( + WidgetTester tester, + ) async { + final NavigationRailThemeData lerp = NavigationRailThemeData.lerp( + const NavigationRailThemeData(), + const NavigationRailThemeData(), + 0.5, + )!; + + expect(lerp.selectedIconTheme, isNull); + expect(lerp.unselectedIconTheme, isNull); + }); + + testWidgets('Default debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const NavigationRailThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('Custom debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const NavigationRailThemeData( + backgroundColor: Color(0x00000099), + elevation: 5, + selectedIconTheme: IconThemeData(color: Color(0x00000098)), + unselectedIconTheme: IconThemeData(color: Color(0x00000097)), + selectedLabelTextStyle: TextStyle(fontSize: 9.0), + unselectedLabelTextStyle: TextStyle(fontSize: 7.0), + groupAlignment: 1.0, + labelType: NavigationRailLabelType.selected, + useIndicator: true, + indicatorColor: Color(0x00000096), + indicatorShape: CircleBorder(), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'backgroundColor: ${const Color(0x00000099)}'); + expect(description[1], 'elevation: 5.0'); + expect(description[2], 'unselectedLabelTextStyle: TextStyle(inherit: true, size: 7.0)'); + expect(description[3], 'selectedLabelTextStyle: TextStyle(inherit: true, size: 9.0)'); + + // Ignore instance address for IconThemeData. + expect(description[4].contains('unselectedIconTheme: IconThemeData'), isTrue); + expect(description[4].contains('(color: ${const Color(0x00000097)})'), isTrue); + expect(description[5].contains('selectedIconTheme: IconThemeData'), isTrue); + expect(description[5].contains('(color: ${const Color(0x00000098)})'), isTrue); + + expect(description[6], 'groupAlignment: 1.0'); + expect(description[7], 'labelType: NavigationRailLabelType.selected'); + expect(description[8], 'useIndicator: true'); + expect(description[9], 'indicatorColor: ${const Color(0x00000096)}'); + expect(description[10], 'indicatorShape: CircleBorder(BorderSide(width: 0.0, style: none))'); + }); +} + +List<NavigationRailDestination> _destinations() { + return const <NavigationRailDestination>[ + NavigationRailDestination( + icon: Icon(Icons.favorite_border), + selectedIcon: Icon(Icons.favorite), + label: Text('Abc'), + ), + NavigationRailDestination( + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + label: Text('Def'), + ), + ]; +} + +Material _railMaterial(WidgetTester tester) { + // The first material is for the rail, and the rest are for the destinations. + return tester.firstWidget<Material>( + find.descendant(of: find.byType(NavigationRail), matching: find.byType(Material)), + ); +} + +ShapeDecoration? _indicatorDecoration(WidgetTester tester) { + return tester + .firstWidget<Ink>( + find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Ink)), + ) + .decoration + as ShapeDecoration?; +} + +IconThemeData _selectedIconTheme(WidgetTester tester) { + return _iconTheme(tester, Icons.favorite); +} + +IconThemeData _unselectedIconTheme(WidgetTester tester) { + return _iconTheme(tester, Icons.star_border); +} + +IconThemeData _iconTheme(WidgetTester tester, IconData icon) { + // The first IconTheme is the one added by the navigation rail. + return tester + .firstWidget<IconTheme>( + find.ancestor(of: find.byIcon(icon), matching: find.byType(IconTheme)), + ) + .data; +} + +TextStyle _selectedLabelStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: find.text('Abc'), matching: find.byType(RichText))) + .text + .style!; +} + +TextStyle _unselectedLabelStyle(WidgetTester tester) { + return tester + .widget<RichText>(find.descendant(of: find.text('Def'), matching: find.byType(RichText))) + .text + .style!; +} + +Size _destinationSize(WidgetTester tester) { + return tester.getSize( + find.ancestor(of: find.byIcon(Icons.favorite), matching: find.byType(Material)).first, + ); +} + +Align _destinationsAlign(WidgetTester tester) { + // The first Flexible widget is the one within the main Column for the rail + // content. + return tester.firstWidget<Align>( + find.descendant(of: find.byType(Flexible), matching: find.byType(Align)), + ); +} + +NavigationRailLabelType _labelType(WidgetTester tester) { + if (_visibilityAboveLabel('Abc').evaluate().isNotEmpty && + _visibilityAboveLabel('Def').evaluate().isNotEmpty) { + return _labelVisibility(tester, 'Abc') + ? NavigationRailLabelType.selected + : NavigationRailLabelType.none; + } else { + return NavigationRailLabelType.all; + } +} + +Finder _visibilityAboveLabel(String text) { + return find.ancestor(of: find.text(text), matching: find.byType(Visibility)); +} + +// Only valid when labelType != all. +bool _labelVisibility(WidgetTester tester, String text) { + final Visibility visibilityWidget = tester.widget<Visibility>( + find.ancestor(of: find.text(text), matching: find.byType(Visibility)), + ); + return visibilityWidget.visible; +} diff --git a/packages/material_ui/test/material/outlined_button_test.dart b/packages/material_ui/test/material/outlined_button_test.dart new file mode 100644 index 000000000000..9c40d876cbe6 --- /dev/null +++ b/packages/material_ui/test/material/outlined_button_test.dart @@ -0,0 +1,3080 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +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 '../widgets/semantics_tester.dart'; + +void main() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + Color textColor(WidgetTester tester, String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style!.color!; + } + + testWidgets('OutlinedButton, OutlinedButton.icon defaults', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + final bool material3 = theme.useMaterial3; + + // Enabled OutlinedButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + + expect( + material.shape, + material3 + ? StadiumBorder(side: BorderSide(color: colorScheme.outline)) + : RoundedRectangleBorder( + side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12)), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ); + + expect(material.textStyle!.color, colorScheme.primary); + 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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. + if (!material3) { + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..circle(color: colorScheme.primary.withOpacity(0.12))); + } + + await gesture.up(); + await tester.pumpAndSettle(); + // No change vs enabled and not pressed. + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + + expect( + material.shape, + material3 + ? StadiumBorder(side: BorderSide(color: colorScheme.outline)) + : RoundedRectangleBorder( + side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12)), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ); + + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Enabled OutlinedButton.icon + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OutlinedButton.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<Material>(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + + expect( + material.shape, + material3 + ? StadiumBorder(side: BorderSide(color: colorScheme.outline)) + : RoundedRectangleBorder( + side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12)), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ); + + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled OutlinedButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center(child: OutlinedButton(onPressed: null, child: Text('button'))), + ), + ); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + + expect( + material.shape, + material3 + ? StadiumBorder(side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12))) + : RoundedRectangleBorder( + side: BorderSide(color: colorScheme.onSurface.withOpacity(0.12)), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ); + + 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( + 'OutlinedButton.defaultStyle produces a ButtonStyle with appropriate non-null values', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = OutlinedButton(onPressed: () {}, child: const Text('button')); + BuildContext? capturedContext; + // Enabled OutlinedButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + expect(style.side, isNotNull, reason: 'side style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }, + ); + + testWidgets( + 'OutlinedButton.defaultStyle with an icon produces a ButtonStyle with appropriate non-null values', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = OutlinedButton.icon( + onPressed: () {}, + icon: const SizedBox(), + label: const Text('button'), + ); + BuildContext? capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + expect(style.side, isNotNull, reason: 'side style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }, + ); + + testWidgets('OutlinedButton.icon produces the correct widgets if icon is null', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OutlinedButton.icon( + key: iconButtonKey, + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.text('label'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OutlinedButton.icon( + key: iconButtonKey, + onPressed: () {}, + // No icon specified. + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsNothing); + expect(find.text('label'), findsOneWidget); + }); + + testWidgets('OutlinedButton default overlayColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return OutlinedButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('OutlinedButton'), + ); + }, + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.1))); + + focusNode.dispose(); + }); + + testWidgets('Does OutlinedButton work with hover', (WidgetTester tester) async { + const hoverColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.hovered) ? hoverColor : null; + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(getOverlayColor), + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(OutlinedButton))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..rect(color: hoverColor)); + }); + + testWidgets('Does OutlinedButton work with focus', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + const focusColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.focused) ? focusColor : null; + } + + final focusNode = FocusNode(debugLabel: 'OutlinedButton Node'); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: OutlinedButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(getOverlayColor), + ), + 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)); + + final Finder buttonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + + expect(material.shape, StadiumBorder(side: BorderSide(color: colors.primary))); + + focusNode.dispose(); + }); + + testWidgets('Does OutlinedButton work with autofocus', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + const focusColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.focused) ? focusColor : null; + } + + final focusNode = FocusNode(debugLabel: 'OutlinedButton Node'); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: OutlinedButton( + autofocus: true, + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(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)); + + final Finder buttonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + + expect(material.shape, StadiumBorder(side: BorderSide(color: colors.primary))); + focusNode.dispose(); + }); + + testWidgets( + 'Default OutlinedButton meets a11y contrast guidelines', + (WidgetTester tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: OutlinedButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('OutlinedButton'), + ), + ), + ), + ), + ); + + // Default, not disabled. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // 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. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.up(); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + focusNode.dispose(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets( + 'OutlinedButton with colored theme meets a11y contrast guidelines', + (WidgetTester tester) async { + final focusNode = FocusNode(); + + Color getTextColor(Set<WidgetState> states) { + final interactiveStates = <WidgetState>{ + WidgetState.pressed, + WidgetState.hovered, + WidgetState.focused, + }; + if (states.any(interactiveStates.contains)) { + return Colors.blue[900]!; + } + return Colors.blue[800]!; + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: ColorScheme.fromSwatch()), + home: Scaffold( + backgroundColor: Colors.white, + body: Center( + child: OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return OutlinedButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('OutlinedButton'), + ); + }, + ), + ), + ), + ), + ), + ); + + // 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(OutlinedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // 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. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + focusNode.dispose(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets('OutlinedButton uses stateful color for text color in different states', ( + WidgetTester tester, + ) async { + const buttonText = 'OutlinedButton'; + final focusNode = FocusNode(); + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text(buttonText), + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(textColor(tester, buttonText), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), 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(tester, buttonText), pressedColor); + + focusNode.dispose(); + }); + + testWidgets('OutlinedButton uses stateful color for icon color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getIconColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton.icon( + key: buttonKey, + style: ButtonStyle(iconColor: WidgetStateProperty.resolveWith<Color>(getIconColor)), + icon: const Icon(Icons.add), + label: const Text('OutlinedButton'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(iconStyle(tester, Icons.add).color, equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, pressedColor); + + focusNode.dispose(); + }); + + testWidgets('OutlinedButton uses stateful color for border color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + BorderSide getBorderSide(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return const BorderSide(color: pressedColor); + } + if (states.contains(WidgetState.hovered)) { + return const BorderSide(color: hoverColor); + } + if (states.contains(WidgetState.focused)) { + return const BorderSide(color: focusedColor); + } + return const BorderSide(color: defaultColor); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton( + style: ButtonStyle( + side: WidgetStateProperty.resolveWith<BorderSide>(getBorderSide), + // Test assumes a rounded rect for the shape + shape: ButtonStyleButton.allOrNull( + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ), + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('OutlinedButton'), + ), + ), + ), + ), + ); + + final Finder outlinedButton = find.byType(OutlinedButton); + + // Default, not disabled. + expect(outlinedButton, paints..drrect(color: defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(outlinedButton, paints..drrect(color: focusedColor)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(outlinedButton, paints..drrect(color: hoverColor)); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect(outlinedButton, paints..drrect(color: pressedColor)); + + focusNode.dispose(); + }); + + testWidgets( + 'OutlinedButton onPressed and onLongPress callbacks are correctly called when non-null', + (WidgetTester tester) async { + bool wasPressed; + Finder outlinedButton; + + Widget buildFrame({VoidCallback? onPressed, VoidCallback? onLongPress}) { + return Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + onPressed: onPressed, + onLongPress: onLongPress, + child: const Text('button'), + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onPressed: () { + wasPressed = true; + }, + ), + ); + outlinedButton = find.byType(OutlinedButton); + expect(tester.widget<OutlinedButton>(outlinedButton).enabled, true); + await tester.tap(outlinedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onLongPress: () { + wasPressed = true; + }, + ), + ); + outlinedButton = find.byType(OutlinedButton); + expect(tester.widget<OutlinedButton>(outlinedButton).enabled, true); + await tester.longPress(outlinedButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget(buildFrame()); + outlinedButton = find.byType(OutlinedButton); + expect(tester.widget<OutlinedButton>(outlinedButton).enabled, false); + }, + ); + + testWidgets("OutlinedButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'OutlinedButton Focus'); + final GlobalKey childKey = GlobalKey(); + var hovering = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100.0, + child: OutlinedButton( + 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.square( + dimension: 100, + child: OutlinedButton( + focusNode: focusNode, + onHover: (bool value) { + hovering = value; + }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + + focusNode.dispose(); + }); + + testWidgets('disabled and hovered OutlinedButton responds to mouse-exit', ( + WidgetTester tester, + ) async { + var onHoverCount = 0; + late bool hover; + + Widget buildFrame({required bool enabled}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: OutlinedButton( + onPressed: enabled ? () {} : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('OutlinedButton'), + ), + ), + ), + ); + } + + 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(OutlinedButton))); + 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 OutlinedButton 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(OutlinedButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the OutlinedButton 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 OutlinedButton 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(OutlinedButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the OutlinedButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set OutlinedButton focus and Can set unFocus.', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'OutlinedButton Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + 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); + + node.dispose(); + }); + + testWidgets('When OutlinedButton disable, Can not set OutlinedButton focus.', ( + WidgetTester tester, + ) async { + final node = FocusNode(debugLabel: 'OutlinedButton Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + + node.dispose(); + }); + + testWidgets("Outline button doesn't crash if disabled during a gesture", ( + WidgetTester tester, + ) async { + Widget buildFrame(VoidCallback? onPressed) { + return Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData(), + child: Center( + child: OutlinedButton(onPressed: onPressed, child: const Text('button')), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(() {})); + await tester.press(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + await tester.pumpWidget(buildFrame(null)); + await tester.pumpAndSettle(); + }); + + testWidgets('OutlinedButton shape and border component overrides', (WidgetTester tester) async { + const fillColor = Color(0xFF00FF00); + const disabledBorderSide = BorderSide(color: Color(0xFFFF0000), width: 3); + const enabledBorderSide = BorderSide(color: Color(0xFFFF00FF), width: 4); + const pressedBorderSide = BorderSide(color: Color(0xFF0000FF), width: 5); + + Widget buildFrame({VoidCallback? onPressed}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + textTheme: Typography.englishLike2014, + ), + child: Container( + alignment: Alignment.topLeft, + child: OutlinedButton( + style: + OutlinedButton.styleFrom( + shape: const RoundedRectangleBorder(), + // default border radius is 0 + backgroundColor: fillColor, + minimumSize: const Size(64, 36), + ).copyWith( + side: WidgetStateProperty.resolveWith<BorderSide>((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledBorderSide; + } + if (states.contains(WidgetState.pressed)) { + return pressedBorderSide; + } + return enabledBorderSide; + }), + ), + clipBehavior: Clip.antiAlias, + onPressed: onPressed, + child: const Text('button'), + ), + ), + ), + ); + } + + final Finder outlinedButton = find.byType(OutlinedButton); + + BorderSide getBorderSide() { + final border = + tester + .widget<Material>( + find.descendant(of: outlinedButton, matching: find.byType(Material)), + ) + .shape! + as OutlinedBorder; + return border.side; + } + + // Pump a button with a null onPressed callback to make it disabled. + await tester.pumpWidget(buildFrame()); + + // Expect that the button is disabled and painted with the disabled border color. + expect(tester.widget<OutlinedButton>(outlinedButton).enabled, false); + expect(getBorderSide(), disabledBorderSide); + + // Pump a new button with a no-op onPressed callback to make it enabled. + await tester.pumpWidget(buildFrame(onPressed: () {})); + + // Wait for the border color to change from disabled to enabled. + await tester.pumpAndSettle(); + expect(getBorderSide(), enabledBorderSide); + + final Offset center = tester.getCenter(outlinedButton); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + + // Wait for the border's color to change to pressed + await tester.pump(const Duration(milliseconds: 200)); + expect(getBorderSide(), pressedBorderSide); + + // Tap gesture completes, button returns to its initial configuration. + await gesture.up(); + await tester.pumpAndSettle(); + expect(getBorderSide(), enabledBorderSide); + }); + + testWidgets('OutlinedButton has no clip by default', (WidgetTester tester) async { + final GlobalKey buttonKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: OutlinedButton(key: buttonKey, onPressed: () {}, child: const Text('ABC')), + ), + ), + ); + + expect(tester.renderObject(find.byKey(buttonKey)), paintsExactlyCountTimes(#clipPath, 0)); + }); + + testWidgets('OutlinedButton contributes semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: OutlinedButton( + style: const 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>(Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + 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>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('When an OutlinedButton gains an icon, preserves the same SemanticsNode id', ( + WidgetTester tester, + ) async { + var toggled = false; + + const key = Key('button'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Row( + children: <Widget>[ + OutlinedButton.icon( + key: key, + onPressed: () { + setState(() { + toggled = true; + }); + }, + icon: toggled ? const Icon(Icons.favorite) : null, + label: const Text('Button'), + ), + ], + ); + }, + ), + ), + ), + ); + + // Initially, no icons are present. + expect(find.byIcon(Icons.favorite), findsNothing); + + // Find the original OutlinedButton with no icon and get its SemanticsNode. + final Finder outlinedButton = find.bySemanticsLabel('Button'); + expect(outlinedButton, findsOneWidget); + + final SemanticsNode origSemanticsNode = tester.getSemantics(outlinedButton); + + // Tap the button. It should receive an icon now. + await tester.tap(outlinedButton); + await tester.pump(); + + // Now one icon should be present. + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // Check if the semantics has change. + final SemanticsNode semanticsNodeWithIcon = tester.getSemantics(outlinedButton); + + expect(semanticsNodeWithIcon, origSemanticsNode); + }); + + testWidgets('OutlinedButton scales textScaleFactor', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Center( + child: OutlinedButton( + style: const 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>(Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(OutlinedButton)), equals(const Size(88.0, 48.0))); + expect(tester.getSize(find.byType(Text)), equals(const Size(42.0, 14.0))); + + // textScaleFactor expands text, but not button. + await tester.pumpWidget( + Theme( + // Force Material 2 typography. + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery.withClampedTextScaling( + minScaleFactor: 1.25, + maxScaleFactor: 1.25, + child: Center( + child: OutlinedButton( + style: const 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>(Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(OutlinedButton)), equals(const Size(88.0, 48.0))); + expect(tester.getSize(find.byType(Text)), const Size(52.5, 18.0)); + + // Set text scale large enough to expand text and button. + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('ABC')), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(OutlinedButton)), const Size(134.0, 48.0)); + expect(tester.getSize(find.byType(Text)), const Size(126.0, 42.0)); + }); + + testWidgets('OutlinedButton onPressed and onLongPress callbacks are distinctly recognized', ( + WidgetTester tester, + ) async { + var didPressButton = false; + var didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder outlinedButton = find.byType(OutlinedButton); + expect(tester.widget<OutlinedButton>(outlinedButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(outlinedButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(outlinedButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets('OutlinedButton responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + const childKey = Key('test child'); + + Future<void> buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: OutlinedButton( + style: ButtonStyle( + visualDensity: visualDensity, + minimumSize: ButtonStyleButton.allOrNull(const Size(64, 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))); + }); + + group('Default OutlinedButton padding for textScaleFactor, textDirection', () { + const buttonKey = ValueKey<String>('button'); + const labelKey = ValueKey<String>('label'); + const iconKey = ValueKey<String>('icon'); + + const textScaleFactorOptions = <double>[0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const textDirectionOptions = <TextDirection>[TextDirection.ltr, TextDirection.rtl]; + const iconOptions = <Widget?>[null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final paddingVertical = <double, double>{ + 0.5: 0, + 1: 0, + 1.25: 0, + 1.5: 0, + 2: 0, + 2.5: 0, + 3: 0, + 4: 0, + }; + final paddingWithIconGap = <double, double>{ + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + final paddingHorizontal = <double, double>{ + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 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 textScaleFactor in textScaleFactorOptions) { + for (final textDirection in textDirectionOptions) { + for (final icon in iconOptions) { + final String testName = <String>[ + 'OutlinedButton, 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( + useMaterial3: false, + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(minimumSize: const Size(64, 36)), + ), + ), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? OutlinedButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : OutlinedButton.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 paddingWidget = paddingElement.widget as Padding; + + // Compute expected padding, and check. + + final double expectedPaddingTop = paddingVertical[textScaleFactor]!; + final double expectedPaddingBottom = paddingVertical[textScaleFactor]!; + final double expectedPaddingStart = paddingHorizontal[textScaleFactor]!; + final expectedPaddingEnd = expectedPaddingStart; + + final EdgeInsets expectedPadding = EdgeInsetsDirectional.fromSTEB( + expectedPaddingStart, + expectedPaddingTop, + expectedPaddingEnd, + expectedPaddingBottom, + ).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<RenderBox>(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox? iconRenderBox = icon == null + ? null + : tester.renderObject<RenderBox>(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<RenderBox>( + 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<RenderBox>( + 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 OutlinedButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 2, + maxScaleFactor: 2, + child: Scaffold( + body: Center( + child: OutlinedButton( + style: OutlinedButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('OutlinedButton'), + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(OutlinedButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); + + testWidgets('Override theme fontSize changes padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + textTheme: const TextTheme(labelLarge: TextStyle(fontSize: 28.0)), + ), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('text')), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(OutlinedButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 12)); + }); + + testWidgets('M3 OutlinedButton has correct padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: OutlinedButton(key: key, onPressed: () {}, child: const Text('OutlinedButton')), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 24)); + }); + + testWidgets('M3 OutlinedButton.icon has correct padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: OutlinedButton.icon( + key: key, + icon: const Icon(Icons.favorite), + onPressed: () {}, + label: const Text('OutlinedButton'), + ), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsetsDirectional.fromSTEB(16.0, 0.0, 24.0, 0.0)); + }); + + testWidgets('Fixed size OutlinedButtons', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + OutlinedButton( + style: OutlinedButton.styleFrom(fixedSize: const Size(100, 100)), + onPressed: () {}, + child: const Text('100x100'), + ), + OutlinedButton( + style: OutlinedButton.styleFrom(fixedSize: const Size.fromWidth(200)), + onPressed: () {}, + child: const Text('200xh'), + ), + OutlinedButton( + style: OutlinedButton.styleFrom(fixedSize: const Size.fromHeight(200)), + onPressed: () {}, + child: const Text('wx200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(OutlinedButton, '100x100')), const Size(100, 100)); + expect(tester.getSize(find.widgetWithText(OutlinedButton, '200xh')).width, 200); + expect(tester.getSize(find.widgetWithText(OutlinedButton, 'wx200')).height, 200); + }); + + testWidgets('OutlinedButton with NoSplash splashFactory paints nothing', ( + WidgetTester tester, + ) async { + Widget buildFrame({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton( + style: OutlinedButton.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( + 'OutlinedButton uses InkSparkle only for Android non-web when useMaterial3 is true', + (WidgetTester tester) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(OutlinedButton), 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('OutlinedButton uses InkRipple when useMaterial3 is false', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(OutlinedButton), matching: find.byType(InkWell)), + ); + expect(buttonInkWell.splashFactory, equals(InkRipple.splashFactory)); + }, variant: TargetPlatformVariant.all()); + + testWidgets('OutlinedButton.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: OutlinedButton.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('OutlinedButton.icon icon,label layout', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + final Key iconKey = UniqueKey(); + final Key labelKey = UniqueKey(); + final ButtonStyle style = OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.standard, // dx=0, dy=0 + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: OutlinedButton.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('OutlinedButton maximumSize', (WidgetTester tester) async { + final Key key0 = UniqueKey(); + final Key key1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + OutlinedButton( + key: key0, + style: OutlinedButton.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'), + ), + OutlinedButton.icon( + key: key1, + style: OutlinedButton.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 OutlinedButton, same as minimumSize == maximumSize', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + OutlinedButton( + style: OutlinedButton.styleFrom(fixedSize: const Size(200, 200)), + onPressed: () {}, + child: const Text('200x200'), + ), + OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(200, 200), + maximumSize: const Size(200, 200), + ), + onPressed: () {}, + child: const Text('200,200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(OutlinedButton, '200x200')), const Size(200, 200)); + expect(tester.getSize(find.widgetWithText(OutlinedButton, '200,200')), const Size(200, 200)); + }); + + testWidgets('OutlinedButton changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: OutlinedButton( + style: OutlinedButton.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: OutlinedButton( + style: OutlinedButton.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: OutlinedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: OutlinedButton(onPressed: null, child: Text('button')), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('OutlinedButton in SelectionArea changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/104595. + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.grab, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Text))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('OutlinedButton.styleFrom can be used to set foreground and background colors', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.purple, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(OutlinedButton), matching: find.byType(Material)), + ); + expect(material.color, Colors.purple); + expect(material.textStyle!.color, Colors.white); + }); + + Future<void> testStatesController(Widget? icon, WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: icon == null + ? OutlinedButton( + statesController: controller, + onPressed: () {}, + child: const Text('button'), + ) + : OutlinedButton.icon( + statesController: controller, + onPressed: () {}, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + + expect(controller.value, <WidgetState>{}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(Text)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.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: icon == null + ? OutlinedButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ) + : OutlinedButton.icon( + statesController: controller, + onPressed: null, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.disabled}); + expect(count, 10); // removes pressed and adds disabled - two changes + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 11); + await gesture.removePointer(); + } + + testWidgets('OutlinedButton statesController', (WidgetTester tester) async { + await testStatesController(null, tester); + }); + + testWidgets('OutlinedButton.icon statesController', (WidgetTester tester) async { + await testStatesController(const Icon(Icons.add), tester); + }); + + testWidgets('Disabled OutlinedButton statesController', (WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: OutlinedButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 1); + }); + + testWidgets("OutlinedButton.styleFrom doesn't throw exception on passing only one cursor", ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/118071. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom(enabledMouseCursor: SystemMouseCursors.text), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('OutlinedButton backgroundBuilder and foregroundBuilder', ( + WidgetTester tester, + ) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: backgroundColor), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: foregroundColor), + child: child, + ); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget<DecoratedBox>(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget<Text>(find.descendant(of: finder, matching: find.byType(Text))); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + testWidgets( + 'OutlinedButton backgroundBuilder drops button child and foregroundBuilder return value', + (WidgetTester tester) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: backgroundColor)); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }, + ); + + testWidgets('OutlinedButton foregroundBuilder drops button child', (WidgetTester tester) async { + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets( + 'OutlinedButton foreground and background builders are applied to the correct states', + (WidgetTester tester) async { + var foregroundStates = <WidgetState>{}; + var backgroundStates = <WidgetState>{}; + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: OutlinedButton( + style: ButtonStyle( + backgroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: + (BuildContext context, Set<WidgetState> states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const focusedStates = <WidgetState>{WidgetState.focused}; + const focusedHoveredStates = <WidgetState>{WidgetState.focused, WidgetState.hovered}; + const focusedHoveredPressedStates = <WidgetState>{ + WidgetState.focused, + WidgetState.hovered, + WidgetState.pressed, + }; + + bool sameStates(Set<WidgetState> expectedValue, Set<WidgetState> actualValue) { + return expectedValue.difference(actualValue).isEmpty && + actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(OutlinedButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + // 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(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }, + ); + + testWidgets('OutlinedButton styleFrom backgroundColor special case', (WidgetTester tester) async { + // Regression test for an internal Google issue: b/323399158 + + const backgroundColor = Color(0xFF000022); + + Widget buildFrame({VoidCallback? onPressed}) { + return Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton( + style: OutlinedButton.styleFrom(backgroundColor: backgroundColor), + onPressed: () {}, + child: const Text('button'), + ), + ); + } + + await tester.pumpWidget(buildFrame(onPressed: () {})); // enabled + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(OutlinedButton), matching: find.byType(Material)), + ); + expect(material.color, backgroundColor); + + await tester.pumpWidget(buildFrame()); // onPressed: null - disabled + expect(material.color, backgroundColor); + }); + + testWidgets('Default OutlinedButton icon alignment', (WidgetTester tester) async { + Widget buildWidget({required TextDirection textDirection}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + // Test default iconAlignment when textDirection is ltr. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.ltr)); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test default iconAlignment when textDirection is rtl. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.rtl)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + }); + + testWidgets('OutlinedButton icon alignment can be customized', (WidgetTester tester) async { + Widget buildWidget({ + required TextDirection textDirection, + required IconAlignment iconAlignment, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + iconAlignment: iconAlignment, + ), + ), + ), + ); + } + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.start), + ); + + Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.end), + ); + + Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 24.0, + ); // 24.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.start), + ); + + buttonTopRight = tester.getTopRight(find.byType(Material).last); + iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.end), + ); + + buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 24.0); // 24.0 - padding between icon and button edge. + }); + + testWidgets('OutlinedButton icon alignment respects ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + home: Center( + child: OutlinedButton.icon( + style: ButtonStyle(iconAlignment: iconAlignment), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + testWidgets("OutlinedButton.icon response doesn't hover when disabled", ( + WidgetTester tester, + ) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'OutlinedButton.icon Focus'); + final GlobalKey childKey = GlobalKey(); + var hovering = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100, + child: OutlinedButton.icon( + autofocus: true, + onPressed: () {}, + onLongPress: () {}, + onHover: (bool value) { + hovering = value; + }, + focusNode: focusNode, + label: SizedBox(key: childKey), + icon: const Icon(Icons.add), + ), + ), + ), + ); + 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.square( + dimension: 100, + child: OutlinedButton.icon( + focusNode: focusNode, + onHover: (bool value) { + hovering = value; + }, + onPressed: null, + label: SizedBox(key: childKey), + icon: const Icon(Icons.add), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + focusNode.dispose(); + }); + + testWidgets('Disabled and hovered OutlinedButton.icon responds to mouse-exit', ( + WidgetTester tester, + ) async { + var onHoverCount = 0; + late bool hover; + const key = Key('OutlinedButton.icon'); + Widget buildFrame({required bool enabled}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: OutlinedButton.icon( + key: key, + onPressed: enabled ? () {} : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + label: const Text('OutlinedButton'), + icon: const Icon(Icons.add), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + + await gesture.moveTo(tester.getCenter(find.byKey(key))); + 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 OutlinedButton 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.byKey(key))); + await tester.pumpAndSettle(); + // We no longer see hover events because the OutlinedButton 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 OutlinedButton 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.byKey(key)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the OutlinedButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('OutlinedButton.icon can be focused/unfocused', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'OutlinedButton.icon Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton.icon( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: () {}, + label: const SizedBox(), + icon: const Icon(Icons.add), + ), + ), + ); + + node.requestFocus(); + await tester.pump(); + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + node.unfocus(); + await tester.pump(); + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + node.dispose(); + }); + + testWidgets('Disabled OutlinedButton.icon cannot receive focus', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'OutlinedButton.icon Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: OutlinedButton.icon( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + label: const SizedBox(), + icon: const Icon(Icons.add), + ), + ), + ); + + node.requestFocus(); + await tester.pump(); + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + node.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('OutlinedButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + iconAlignment: IconAlignment.end, + disabledIconColor: disabledIconColor, + ), + onPressed: enabled ? () {} : null, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets('OutlinedButton icon uses provided foregroundColor over default icon color', ( + WidgetTester tester, + ) async { + const foregroundColor = Color(0xFFFF1234); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(foregroundColor: foregroundColor), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ), + ); + expect(iconStyle(tester, Icons.add).color, foregroundColor); + }); + + testWidgets('OutlinedButton text and icon respect animation duration', ( + WidgetTester tester, + ) async { + const buttonText = 'Button'; + const IconData buttonIcon = Icons.add; + const hoveredColor = Color(0xFFFF0000); + const idleColor = Color(0xFF000000); + + Widget buildButton({Duration? animationDuration}) { + return MaterialApp( + home: Material( + child: Center( + child: OutlinedButton.icon( + style: ButtonStyle( + animationDuration: animationDuration, + iconColor: const WidgetStateProperty<Color>.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }), + foregroundColor: const WidgetStateProperty<Color>.fromMap( + <WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }, + ), + ), + onPressed: () {}, + icon: const Icon(buttonIcon), + label: const Text(buttonText), + ), + ), + ), + ); + } + + // Test default animation duration. + await tester.pumpWidget(buildButton()); + + expect(textColor(tester, buttonText), idleColor); + expect(iconStyle(tester, buttonIcon).color, idleColor); + + final Offset buttonCenter = tester.getCenter(find.text(buttonText)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + + await gesture.removePointer(); + + // Test custom animation duration. + await tester.pumpWidget(buildButton(animationDuration: const Duration(seconds: 2))); + await tester.pumpAndSettle(); + + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + }); + + testWidgets("OutlinedButton's outline should be behind its child", (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/167431 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + child: OutlinedButton( + style: OutlinedButton.styleFrom(backgroundColor: Colors.transparent), + onPressed: () {}, + child: const Badge( + backgroundColor: Colors.green, + label: Text('Ad', style: TextStyle(fontSize: 18, color: Colors.red)), + child: Icon(Icons.lightbulb_rounded), + ), + ), + ), + ), + ), + ), + ); + + await expectLater( + find.byType(OutlinedButton), + matchesGoldenFile('outlined_button.badge.outline.png'), + ); + }); + + testWidgets('OutlinedButton.icon does not lose focus when icon is nullified', ( + WidgetTester tester, + ) async { + Widget buildButton({required Widget? icon}) { + return MaterialApp( + home: Center( + child: OutlinedButton.icon(onPressed: () {}, icon: icon, label: const Text('button')), + ), + ); + } + + // Build once with an icon. + await tester.pumpWidget(buildButton(icon: const Icon(Icons.abc))); + + FocusNode getButtonFocusNode() { + return Focus.of(tester.element(find.text('button'))); + } + + getButtonFocusNode().requestFocus(); + await tester.pumpAndSettle(); + expect(getButtonFocusNode().hasFocus, true); + + // Rebuild without icon. + await tester.pumpWidget(buildButton(icon: null)); + + // The button should still be focused. + expect(getButtonFocusNode().hasFocus, true); + }); + + testWidgets('OutlinedButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: OutlinedButton(onPressed: () {}, child: const Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(OutlinedButton)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/outlined_button_theme_test.dart b/packages/material_ui/test/material/outlined_button_theme_test.dart new file mode 100644 index 000000000000..65dd849108b8 --- /dev/null +++ b/packages/material_ui/test/material/outlined_button_theme_test.dart @@ -0,0 +1,485 @@ +// 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() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + test('OutlinedButtonThemeData lerp special cases', () { + expect(OutlinedButtonThemeData.lerp(null, null, 0), null); + const data = OutlinedButtonThemeData(); + expect(identical(OutlinedButtonThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Material3: Passing no OutlinedButtonTheme returns defaults', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + + expect(material.shape, isInstanceOf<StadiumBorder>()); + final materialShape = material.shape! as StadiumBorder; + expect(materialShape.side, BorderSide(color: colorScheme.outline)); + + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + testWidgets('Material2: Passing no OutlinedButtonTheme returns defaults', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(useMaterial3: false, colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: OutlinedButton(onPressed: () {}, child: const Text('button')), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.black); + + expect(material.shape, isInstanceOf<RoundedRectangleBorder>()); + final materialShape = material.shape! as RoundedRectangleBorder; + expect(materialShape.side, BorderSide(color: colorScheme.onSurface.withOpacity(0.12))); + expect(materialShape.borderRadius, const BorderRadius.all(Radius.circular(4.0))); + + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, TextTheme, OutlinedButton style overrides]', () { + const foregroundColor = Color(0xff000001); + const backgroundColor = Color(0xff000002); + const disabledColor = Color(0xff000003); + const shadowColor = Color(0xff000004); + const double elevation = 3; + const textStyle = TextStyle(fontSize: 12.0); + const padding = EdgeInsets.all(3); + const minimumSize = Size(200, 200); + const 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 animationDuration = Duration(milliseconds: 25); + const enableFeedback = false; + const AlignmentGeometry alignment = Alignment.centerLeft; + + final ButtonStyle style = OutlinedButton.styleFrom( + foregroundColor: foregroundColor, + disabledForegroundColor: disabledColor, + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledColor, + 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 OutlinedButton(style: buttonStyle, onPressed: () {}, child: const Text('button')); + }, + ); + return MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(outlinedButtonTheme: OutlinedButtonThemeData(style: overallStyle)), + home: Scaffold( + body: Center( + // If the OutlinedButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.outlinedButtonTheme. + child: themeStyle == null + ? child + : OutlinedButtonTheme( + data: OutlinedButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(InkWell), + ); + + const enabled = <WidgetState>{}; + const disabled = <WidgetState>{WidgetState.disabled}; + const hovered = <WidgetState>{WidgetState.hovered}; + const focused = <WidgetState>{WidgetState.focused}; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget<Material>(findMaterial); + final InkWell inkWell = tester.widget<InkWell>(findInkWell); + expect(material.textStyle!.color, foregroundColor); + expect(material.textStyle!.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect( + WidgetStateProperty.resolveAs<MouseCursor?>(inkWell.mouseCursor, enabled), + enabledMouseCursor, + ); + expect( + WidgetStateProperty.resolveAs<MouseCursor?>(inkWell.mouseCursor, disabled), + disabledMouseCursor, + ); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.1)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(OutlinedButton)), const Size(200, 200)); + final Align align = tester.firstWidget<Align>( + 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('Material3 - OutlinedButton repsects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const overriddenColor = Color(0xff000002); + + Widget buildFrame({Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor}) { + return MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme.copyWith(shadow: overallShadowColor)), + home: Scaffold( + body: Center( + child: OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(shadowColor: themeShadowColor), + ), + child: Builder( + builder: (BuildContext context) { + return OutlinedButton( + style: OutlinedButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.transparent); + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.transparent); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('Material2 - OutlinedButton repsects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const overriddenColor = Color(0xff000002); + + Widget buildFrame({Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor}) { + return MaterialApp( + theme: ThemeData.from( + useMaterial3: false, + colorScheme: colorScheme, + ).copyWith(shadowColor: overallShadowColor), + home: Scaffold( + body: Center( + child: OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(shadowColor: themeShadowColor), + ), + child: Builder( + builder: (BuildContext context) { + return OutlinedButton( + style: OutlinedButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(OutlinedButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.black); //default + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets( + 'OutlinedButton.icon alignment respects OutlinedButtonTheme ButtonStyle.iconAlignment', + (WidgetTester tester) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + theme: ThemeData( + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle(iconAlignment: iconAlignment), + ), + ), + home: Scaffold( + body: Center( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + await tester.pumpAndSettle(); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 24.0); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets( + 'OutlinedButton icon uses provided OutlinedButtonTheme foregroundColor over default icon color', + (WidgetTester tester) async { + const foregroundColor = Color(0xFFFFA500); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(foregroundColor: foregroundColor), + ), + ), + home: Material( + child: Center( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ), + ); + + expect(iconStyle(tester, Icons.add).color, foregroundColor); + }, + ); +} diff --git a/packages/material_ui/test/material/page_selector_test.dart b/packages/material_ui/test/material/page_selector_test.dart new file mode 100644 index 000000000000..c6902c10ac25 --- /dev/null +++ b/packages/material_ui/test/material/page_selector_test.dart @@ -0,0 +1,417 @@ +// 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'; + +const Color kSelectedColor = Color(0xFF00FF00); +const Color kUnselectedColor = Colors.transparent; + +Widget buildFrame( + TabController tabController, { + Color? color, + Color? selectedColor, + double indicatorSize = 12.0, + BorderStyle? borderStyle, +}) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData(colorScheme: const ColorScheme.light().copyWith(secondary: kSelectedColor)), + child: SizedBox.expand( + child: Center( + child: SizedBox.square( + dimension: 400.0, + child: Column( + children: <Widget>[ + TabPageSelector( + controller: tabController, + color: color, + selectedColor: selectedColor, + indicatorSize: indicatorSize, + borderStyle: borderStyle, + ), + Flexible( + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); +} + +List<Color> indicatorColors(WidgetTester tester) { + final Iterable<TabPageSelectorIndicator> indicators = tester.widgetList( + find.descendant( + of: find.byType(TabPageSelector), + matching: find.byType(TabPageSelectorIndicator), + ), + ); + return indicators + .map<Color>((TabPageSelectorIndicator indicator) => indicator.backgroundColor) + .toList(); +} + +void main() { + testWidgets('PageSelector responds correctly to setting the TabController index', ( + WidgetTester tester, + ) async { + final tabController = TabController(vsync: const TestVSync(), length: 3); + addTearDown(tabController.dispose); + await tester.pumpWidget(buildFrame(tabController)); + + expect(tabController.index, 0); + expect(indicatorColors(tester), const <Color>[ + kSelectedColor, + kUnselectedColor, + kUnselectedColor, + ]); + + tabController.index = 1; + await tester.pump(); + expect(tabController.index, 1); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + tabController.index = 2; + await tester.pump(); + expect(tabController.index, 2); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kUnselectedColor, + kSelectedColor, + ]); + }); + + testWidgets('PageSelector responds correctly to TabController.animateTo()', ( + WidgetTester tester, + ) async { + final tabController = TabController(vsync: const TestVSync(), length: 3); + addTearDown(tabController.dispose); + await tester.pumpWidget(buildFrame(tabController)); + + expect(tabController.index, 0); + expect(indicatorColors(tester), const <Color>[ + kSelectedColor, + kUnselectedColor, + kUnselectedColor, + ]); + + tabController.animateTo(1, duration: const Duration(milliseconds: 200)); + await tester.pump(); + // Verify that indicator 0's color is becoming increasingly transparent, + // and indicator 1's color is becoming increasingly opaque during the + // 200ms animation. Indicator 2 remains transparent throughout. + await tester.pump(const Duration(milliseconds: 10)); + List<Color> colors = indicatorColors(tester); + expect(colors[0].alpha, greaterThan(colors[1].alpha)); + expect(colors[2], kUnselectedColor); + await tester.pump(const Duration(milliseconds: 175)); + colors = indicatorColors(tester); + expect(colors[0].alpha, lessThan(colors[1].alpha)); + expect(colors[2], kUnselectedColor); + await tester.pumpAndSettle(); + expect(tabController.index, 1); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + tabController.animateTo(2, duration: const Duration(milliseconds: 200)); + await tester.pump(); + // Same animation test as above for indicators 1 and 2. + await tester.pump(const Duration(milliseconds: 10)); + colors = indicatorColors(tester); + expect(colors[1].alpha, greaterThan(colors[2].alpha)); + expect(colors[0], kUnselectedColor); + await tester.pump(const Duration(milliseconds: 175)); + colors = indicatorColors(tester); + expect(colors[1].alpha, lessThan(colors[2].alpha)); + expect(colors[0], kUnselectedColor); + await tester.pumpAndSettle(); + expect(tabController.index, 2); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kUnselectedColor, + kSelectedColor, + ]); + }); + + testWidgets('PageSelector responds correctly to TabBarView drags', (WidgetTester tester) async { + final tabController = TabController(vsync: const TestVSync(), initialIndex: 1, length: 3); + addTearDown(tabController.dispose); + await tester.pumpWidget(buildFrame(tabController)); + + expect(tabController.index, 1); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0)); + + // Drag to the left moving the selection towards indicator 2. Indicator 2's + // opacity should increase and Indicator 1's opacity should decrease. + await gesture.moveBy(const Offset(-100.0, 0.0)); + await tester.pumpAndSettle(); + List<Color> colors = indicatorColors(tester); + expect(colors[1].alpha, greaterThan(colors[2].alpha)); + expect(colors[0], kUnselectedColor); + + // Drag back to where we started. + await gesture.moveBy(const Offset(100.0, 0.0)); + await tester.pumpAndSettle(); + colors = indicatorColors(tester); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + // Drag to the left moving the selection towards indicator 0. Indicator 0's + // opacity should increase and Indicator 1's opacity should decrease. + await gesture.moveBy(const Offset(100.0, 0.0)); + await tester.pumpAndSettle(); + colors = indicatorColors(tester); + expect(colors[1].alpha, greaterThan(colors[0].alpha)); + expect(colors[2], kUnselectedColor); + + // Drag back to where we started. + await gesture.moveBy(const Offset(-100.0, 0.0)); + await tester.pumpAndSettle(); + colors = indicatorColors(tester); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + // Completing the gesture doesn't change anything + await gesture.up(); + await tester.pumpAndSettle(); + colors = indicatorColors(tester); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + // Fling to the left, selects indicator 2 + await tester.fling(find.byType(TabBarView), const Offset(-100.0, 0.0), 1000.0); + await tester.pumpAndSettle(); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kUnselectedColor, + kSelectedColor, + ]); + + // Fling to the right, selects indicator 1 + await tester.fling(find.byType(TabBarView), const Offset(100.0, 0.0), 1000.0); + await tester.pumpAndSettle(); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + }); + + testWidgets('PageSelector indicatorColors', (WidgetTester tester) async { + const kRed = Color(0xFFFF0000); + const kBlue = Color(0xFF0000FF); + + final tabController = TabController(vsync: const TestVSync(), initialIndex: 1, length: 3); + addTearDown(tabController.dispose); + await tester.pumpWidget(buildFrame(tabController, color: kRed, selectedColor: kBlue)); + + expect(tabController.index, 1); + expect(indicatorColors(tester), const <Color>[kRed, kBlue, kRed]); + + tabController.index = 0; + await tester.pumpAndSettle(); + expect(indicatorColors(tester), const <Color>[kBlue, kRed, kRed]); + }); + + testWidgets('PageSelector indicatorSize', (WidgetTester tester) async { + final tabController = TabController(vsync: const TestVSync(), initialIndex: 1, length: 3); + addTearDown(tabController.dispose); + await tester.pumpWidget(buildFrame(tabController, indicatorSize: 16.0)); + + final Iterable<Element> indicatorElements = find + .descendant( + of: find.byType(TabPageSelector), + matching: find.byType(TabPageSelectorIndicator), + ) + .evaluate(); + + // Indicators get an 8 pixel margin, 16 + 8 = 24. + for (final indicatorElement in indicatorElements) { + expect(indicatorElement.size, const Size(24.0, 24.0)); + } + + expect(tester.getSize(find.byType(TabPageSelector)).height, 24.0); + }); + + testWidgets('PageSelector circle border', (WidgetTester tester) async { + final tabController = TabController(vsync: const TestVSync(), initialIndex: 1, length: 3); + addTearDown(tabController.dispose); + + Iterable<TabPageSelectorIndicator> indicators; + + // Default border + await tester.pumpWidget(buildFrame(tabController)); + indicators = tester.widgetList( + find.descendant( + of: find.byType(TabPageSelector), + matching: find.byType(TabPageSelectorIndicator), + ), + ); + for (final indicator in indicators) { + expect(indicator.borderStyle, BorderStyle.solid); + } + + // No border + await tester.pumpWidget(buildFrame(tabController, borderStyle: BorderStyle.none)); + indicators = tester.widgetList( + find.descendant( + of: find.byType(TabPageSelector), + matching: find.byType(TabPageSelectorIndicator), + ), + ); + for (final indicator in indicators) { + expect(indicator.borderStyle, BorderStyle.none); + } + + // Solid border + await tester.pumpWidget(buildFrame(tabController, borderStyle: BorderStyle.solid)); + indicators = tester.widgetList( + find.descendant( + of: find.byType(TabPageSelector), + matching: find.byType(TabPageSelectorIndicator), + ), + ); + for (final indicator in indicators) { + expect(indicator.borderStyle, BorderStyle.solid); + } + }); + + testWidgets( + 'PageSelector responds correctly to TabController.animateTo() from the default tab controller', + (WidgetTester tester) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + colorScheme: const ColorScheme.light().copyWith(secondary: kSelectedColor), + ), + child: const SizedBox.expand( + child: Center( + child: SizedBox.square( + dimension: 400.0, + child: DefaultTabController( + length: 3, + child: Column( + children: <Widget>[ + TabPageSelector(), + Flexible( + child: TabBarView( + children: <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + + final TabController tabController = DefaultTabController.of( + tester.element(find.byType(TabPageSelector)), + ); + + expect(tabController.index, 0); + expect(indicatorColors(tester), const <Color>[ + kSelectedColor, + kUnselectedColor, + kUnselectedColor, + ]); + + tabController.animateTo(1, duration: const Duration(milliseconds: 200)); + await tester.pump(); + // Verify that indicator 0's color is becoming increasingly transparent, + // and indicator 1's color is becoming increasingly opaque during the + // 200ms animation. Indicator 2 remains transparent throughout. + await tester.pump(const Duration(milliseconds: 10)); + List<Color> colors = indicatorColors(tester); + expect(colors[0].alpha, greaterThan(colors[1].alpha)); + expect(colors[2], kUnselectedColor); + await tester.pump(const Duration(milliseconds: 175)); + colors = indicatorColors(tester); + expect(colors[0].alpha, lessThan(colors[1].alpha)); + expect(colors[2], kUnselectedColor); + await tester.pumpAndSettle(); + expect(tabController.index, 1); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kSelectedColor, + kUnselectedColor, + ]); + + tabController.animateTo(2, duration: const Duration(milliseconds: 200)); + await tester.pump(); + // Same animation test as above for indicators 1 and 2. + await tester.pump(const Duration(milliseconds: 10)); + colors = indicatorColors(tester); + expect(colors[1].alpha, greaterThan(colors[2].alpha)); + expect(colors[0], kUnselectedColor); + await tester.pump(const Duration(milliseconds: 175)); + colors = indicatorColors(tester); + expect(colors[1].alpha, lessThan(colors[2].alpha)); + expect(colors[0], kUnselectedColor); + await tester.pumpAndSettle(); + expect(tabController.index, 2); + expect(indicatorColors(tester), const <Color>[ + kUnselectedColor, + kUnselectedColor, + kSelectedColor, + ]); + }, + ); +} diff --git a/packages/material_ui/test/material/page_test.dart b/packages/material_ui/test/material/page_test.dart new file mode 100644 index 000000000000..a0e9d8803065 --- /dev/null +++ b/packages/material_ui/test/material/page_test.dart @@ -0,0 +1,1594 @@ +// 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. + +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart' show CupertinoPageRoute; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'test page transition (CupertinoPageTransition)', + (WidgetTester tester) async { + final Key page2Key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Page 1')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return Material(key: page2Key, child: const Text('Page 2')); + }, + }, + ), + ); + + final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + final RenderDecoratedBox box = tester + .element(find.byKey(page2Key)) + .findAncestorRenderObjectOfType<RenderDecoratedBox>()!; + + // Page 1 is moving to the left. + expect(widget1TransientTopLeft.dx < widget1InitialTopLeft.dx, true); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy == widget1InitialTopLeft.dy, true); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy == widget2TopLeft.dy, true); + // Page 2 is coming in from the right. + expect(widget2TopLeft.dx > widget1InitialTopLeft.dx, true); + // As explained in _CupertinoEdgeShadowPainter.paint the shadow is drawn + // as a bunch of rects. The rects are covering an area to the left of + // where the page 2 box is and a width of 5% of the page 2 box width. + // `paints` tests relative to the painter's given canvas + // rather than relative to the screen so assert that the shadow starts at + // offset.dx = 0. + final PaintPattern paintsShadow = paints; + for (var i = 0; i < 0.05 * 800; i += 1) { + paintsShadow.rect(rect: Rect.fromLTWH(-i.toDouble() - 1.0, 0.0, 1.0, 600)); + } + expect(box, paintsShadow); + + await tester.pumpAndSettle(); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 is coming back from the left. + expect(widget1TransientTopLeft.dx < widget1InitialTopLeft.dx, true); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy == widget1InitialTopLeft.dy, true); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy == widget2TopLeft.dy, true); + // Page 2 is leaving towards the right. + expect(widget2TopLeft.dx > widget1InitialTopLeft.dx, true); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + + // Page 1 is back where it started. + expect(widget1InitialTopLeft == widget1TransientTopLeft, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'test page transition (_ZoomPageTransition) without rasterization', + (WidgetTester tester) async { + Iterable<Layer> findLayers(Finder of) { + return tester.layerListOf( + find.ancestor(of: of, matching: find.byType(SnapshotWidget)).first, + ); + } + + OpacityLayer findForwardFadeTransition(Finder of) { + return findLayers(of).whereType<OpacityLayer>().first; + } + + TransformLayer findForwardScaleTransition(Finder of) { + return findLayers(of).whereType<TransformLayer>().first; + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + allowSnapshotting: false, + builder: (BuildContext context) { + if (settings.name == '/') { + return const Material(child: Text('Page 1')); + } + return const Material(child: Text('Page 2')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + TransformLayer widget1Scale = findForwardScaleTransition(find.text('Page 1')); + TransformLayer widget2Scale = findForwardScaleTransition(find.text('Page 2')); + OpacityLayer widget2Opacity = findForwardFadeTransition(find.text('Page 2')); + + double getScale(TransformLayer layer) { + return layer.transform!.storage[0]; + } + + // Page 1 is enlarging, starts from 1.0. + expect(getScale(widget1Scale), greaterThan(1.0)); + // Page 2 is enlarging from the value less than 1.0. + expect(getScale(widget2Scale), lessThan(1.0)); + // Page 2 is becoming none transparent. + expect(widget2Opacity.alpha, lessThan(255)); + + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(const Duration(milliseconds: 1)); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + widget1Scale = findForwardScaleTransition(find.text('Page 1')); + widget2Scale = findForwardScaleTransition(find.text('Page 2')); + widget2Opacity = findForwardFadeTransition(find.text('Page 2')); + + // Page 1 is narrowing down, but still larger than 1.0. + expect(getScale(widget1Scale), greaterThan(1.0)); + // Page 2 is smaller than 1.0. + expect(getScale(widget2Scale), lessThan(1.0)); + // Page 2 is becoming transparent. + expect(widget2Opacity.alpha, lessThan(255)); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Material2 - test page transition (_ZoomPageTransition) with rasterization re-rasterizes when view insets change', + (WidgetTester tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1000, 1000); + tester.view.viewInsets = FakeViewPadding.zero; + + // Intentionally use nested scaffolds to simulate the view insets being + // consumed. + final Key key = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: MaterialApp( + theme: ThemeData( + useMaterial3: false, + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Scaffold( + body: Scaffold(body: Material(child: SizedBox.shrink())), + ); + }, + ); + }, + ), + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + await expectLater(find.byKey(key), matchesGoldenFile('m2_zoom_page_transition.small.png')); + + // Change the view insets. + tester.view.viewInsets = const FakeViewPadding(bottom: 500); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + await expectLater(find.byKey(key), matchesGoldenFile('m2_zoom_page_transition.big.png')); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] rasterization is not used on the web. + ); + + testWidgets( + 'Material3 - test page transition (_ZoomPageTransition) with rasterization re-rasterizes when view insets change', + (WidgetTester tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1000, 1000); + tester.view.viewInsets = FakeViewPadding.zero; + + // Intentionally use nested scaffolds to simulate the view insets being + // consumed. + final Key key = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: ZoomPageTransitionsBuilder(), + }, + ), + ), + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Scaffold( + body: Scaffold(body: Material(child: SizedBox.shrink())), + ); + }, + ); + }, + ), + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + await expectLater(find.byKey(key), matchesGoldenFile('m3_zoom_page_transition.small.png')); + + // Change the view insets. + tester.view.viewInsets = const FakeViewPadding(bottom: 500); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + await expectLater(find.byKey(key), matchesGoldenFile('m3_zoom_page_transition.big.png')); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] rasterization is not used on the web. + ); + + testWidgets( + 'test page transition (_ZoomPageTransition) with rasterization disables snapshotting for enter route', + (WidgetTester tester) async { + Iterable<Layer> findLayers(Finder of) { + return tester.layerListOf( + find.ancestor(of: of, matching: find.byType(SnapshotWidget)).first, + ); + } + + bool isTransitioningWithoutSnapshotting(Finder of) { + // When snapshotting is off, the OpacityLayer and TransformLayer will be + // applied directly. + final Iterable<Layer> layers = findLayers(of); + return layers.whereType<OpacityLayer>().length == 1 && + layers.whereType<TransformLayer>().length == 1; + } + + bool isSnapshotted(Finder of) { + final Iterable<Layer> layers = findLayers(of); + // The scrim and the snapshot image are the only two layers. + return layers.length == 2 && + layers.whereType<OffsetLayer>().length == 1 && + layers.whereType<PictureLayer>().length == 1; + } + + await tester.pumpWidget( + MaterialApp( + routes: <String, WidgetBuilder>{ + '/1': (_) => const Material(child: Text('Page 1')), + '/2': (_) => const Material(child: Text('Page 2')), + }, + initialRoute: '/1', + builder: (BuildContext context, Widget? child) { + final ThemeData themeData = Theme.of(context); + return Theme( + data: themeData.copyWith( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + ...themeData.pageTransitionsTheme.builders, + TargetPlatform.android: const ZoomPageTransitionsBuilder( + allowEnterRouteSnapshotting: false, + ), + }, + ), + ), + child: Builder(builder: (_) => child!), + ); + }, + ), + ); + + final Finder page1Finder = find.text('Page 1'); + final Finder page2Finder = find.text('Page 2'); + + // Page 1 on top. + expect(isSnapshotted(page1Finder), isFalse); + + // Transitioning from page 1 to page 2. + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/2'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(isSnapshotted(page1Finder), isTrue); + expect(isTransitioningWithoutSnapshotting(page2Finder), isTrue); + + // Page 2 on top. + await tester.pumpAndSettle(); + expect(isSnapshotted(page2Finder), isFalse); + + // Transitioning back from page 2 to page 1. + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(isTransitioningWithoutSnapshotting(page1Finder), isTrue); + expect(isSnapshotted(page2Finder), isTrue); + + // Page 1 on top. + await tester.pumpAndSettle(); + expect(isSnapshotted(page1Finder), isFalse); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] rasterization is not used on the web. + ); + testWidgets( + 'test fullscreen dialog transition', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: Text('Page 1')))); + + final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Material(child: Text('Page 2')); + }, + fullscreenDialog: true, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 doesn't move. + expect(widget1TransientTopLeft == widget1InitialTopLeft, true); + // Fullscreen dialogs transitions vertically only. + expect(widget1InitialTopLeft.dx == widget2TopLeft.dx, true); + // Page 2 is coming in from the bottom. + expect(widget2TopLeft.dy > widget1InitialTopLeft.dy, true); + + await tester.pumpAndSettle(); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 doesn't move. + expect(widget1TransientTopLeft == widget1InitialTopLeft, true); + // Fullscreen dialogs transitions vertically only. + expect(widget1InitialTopLeft.dx == widget2TopLeft.dx, true); + // Page 2 is leaving towards the bottom. + expect(widget2TopLeft.dy > widget1InitialTopLeft.dy, true); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + + // Page 1 is back where it started. + expect(widget1InitialTopLeft == widget1TransientTopLeft, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('test no back gesture on Android', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: const Scaffold(body: Text('Page 1')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Scaffold(body: Text('Page 2')); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from left edge to invoke the gesture. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); + await gesture.moveBy(const Offset(400.0, 0.0)); + await tester.pump(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Page 2 didn't move. + expect(tester.getTopLeft(find.text('Page 2')), Offset.zero); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets( + 'test back gesture', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: const Scaffold(body: Text('Page 1')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Scaffold(body: Text('Page 2')); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from left edge to invoke the gesture. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); + await gesture.moveBy(const Offset(400.0, 0.0)); + await tester.pump(); + + // Page 1 is now visible. + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), isOnstage); + + // The route widget position needs to track the finger position very exactly. + expect(tester.getTopLeft(find.text('Page 2')), const Offset(400.0, 0.0)); + + await gesture.moveBy(const Offset(-200.0, 0.0)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Page 2')), const Offset(200.0, 0.0)); + + await gesture.moveBy(const Offset(-100.0, 200.0)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Page 2')), const Offset(100.0, 0.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('back gesture while OS changes', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('PUSH'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('HELLO'), + }; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + routes: routes, + ), + ); + await tester.tap(find.text('PUSH')); + expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); + expect(find.text('PUSH'), findsNothing); + expect(find.text('HELLO'), findsOneWidget); + final Offset helloPosition1 = tester.getCenter(find.text('HELLO')); + final TestGesture gesture = await tester.startGesture(const Offset(2.5, 300.0)); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveBy(const Offset(100.0, 0.0)); + expect(find.text('PUSH'), findsNothing); + expect(find.text('HELLO'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 20)); + expect(find.text('PUSH'), findsOneWidget); + expect(find.text('HELLO'), findsOneWidget); + final Offset helloPosition2 = tester.getCenter(find.text('HELLO')); + expect(helloPosition1.dx, lessThan(helloPosition2.dx)); + expect(helloPosition1.dy, helloPosition2.dy); + expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.iOS); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + routes: routes, + ), + ); + // Now we have to let the theme animation run through. + // This takes three frames (including the first one above): + // 1. Start the Theme animation. It's at t=0 so everything else is identical. + // 2. Start any animations that are informed by the Theme, for example, the + // DefaultTextStyle, on the first frame that the theme is not at t=0. In + // this case, it's at t=1.0 of the theme animation, so this is also the + // frame in which the theme animation ends. + // 3. End all the other animations. + expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); + expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.android); + final Offset helloPosition3 = tester.getCenter(find.text('HELLO')); + expect(helloPosition3, helloPosition2); + expect(find.text('PUSH'), findsOneWidget); + expect(find.text('HELLO'), findsOneWidget); + await gesture.moveBy(const Offset(100.0, 0.0)); + await tester.pump(const Duration(milliseconds: 20)); + expect(find.text('PUSH'), findsOneWidget); + expect(find.text('HELLO'), findsOneWidget); + final Offset helloPosition4 = tester.getCenter(find.text('HELLO')); + expect(helloPosition3.dx, lessThan(helloPosition4.dx)); + expect(helloPosition3.dy, helloPosition4.dy); + await gesture.moveBy(const Offset(500.0, 0.0)); + await gesture.up(); + expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 3); + expect(find.text('PUSH'), findsOneWidget); + expect(find.text('HELLO'), findsNothing); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.macOS), + routes: routes, + ), + ); + await tester.tap(find.text('PUSH')); + expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); + expect(find.text('PUSH'), findsNothing); + expect(find.text('HELLO'), findsOneWidget); + final Offset helloPosition5 = tester.getCenter(find.text('HELLO')); + await gesture.down(const Offset(2.5, 300.0)); + await tester.pump(const Duration(milliseconds: 20)); + await gesture.moveBy(const Offset(100.0, 0.0)); + expect(find.text('PUSH'), findsNothing); + expect(find.text('HELLO'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 20)); + expect(find.text('PUSH'), findsOneWidget); + expect(find.text('HELLO'), findsOneWidget); + final Offset helloPosition6 = tester.getCenter(find.text('HELLO')); + expect(helloPosition5.dx, lessThan(helloPosition6.dx)); + expect(helloPosition5.dy, helloPosition6.dy); + expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.macOS); + }); + + testWidgets( + 'test no back gesture on fullscreen dialogs', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Text('Page 1')))); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Scaffold(body: Text('Page 2')); + }, + fullscreenDialog: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Drag from left edge to invoke the gesture. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); + await gesture.moveBy(const Offset(400.0, 0.0)); + await tester.pump(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Page 2 didn't move. + expect(tester.getTopLeft(find.text('Page 2')), Offset.zero); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'test fullscreen routes do not transition previous route', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + initialRoute: '/', + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/') { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return Scaffold( + appBar: AppBar(title: const Text('Page 1')), + body: Container(), + ); + }, + ); + } + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Page 2')), + body: Container(), + ); + }, + fullscreenDialog: true, + ); + }, + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + final double pageTitleDX = tester.getTopLeft(find.text('Page 1')).dx; + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Second page transition has started. + expect(find.text('Page 2'), findsOneWidget); + + // First page has not moved. + expect(tester.getTopLeft(find.text('Page 1')).dx, equals(pageTitleDX)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'test adaptable transitions switch during execution', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.android, + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), + }, + ), + ), + home: const Material(child: Text('Page 1')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Material(child: Text('Page 2')); + }, + }, + ), + ); + + final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + final Size widget2Size = tester.getSize(find.text('Page 2')); + + // Android transition is vertical only. + expect(widget1InitialTopLeft.dx == widget2TopLeft.dx, true); + // Page 1 is above page 2 mid-transition. + expect(widget1InitialTopLeft.dy < widget2TopLeft.dy, true); + // Animation begins from the top of the page. + expect(widget2TopLeft.dy < widget2Size.height, true); + + await tester.pump(const Duration(milliseconds: 300)); + + // Page 2 covers page 1. + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + // Re-pump the same app but with iOS instead of Android. + await tester.pumpWidget( + MaterialApp( + home: const Material(child: Text('Page 1')), + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Material(child: Text('Page 2')); + }, + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + widget2TopLeft = tester.getTopLeft(find.text('Page 2')); + + // Page 1 is coming back from the left. + expect(widget1TransientTopLeft.dx < widget1InitialTopLeft.dx, true); + // Page 1 isn't moving vertically. + expect(widget1TransientTopLeft.dy == widget1InitialTopLeft.dy, true); + // iOS transition is horizontal only. + expect(widget1InitialTopLeft.dy == widget2TopLeft.dy, true); + // Page 2 is leaving towards the right. + expect(widget2TopLeft.dx > widget1InitialTopLeft.dx, true); + + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + + widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); + + // Page 1 is back where it started. + expect(widget1InitialTopLeft == widget1TransientTopLeft, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'test edge swipe then drop back at starting point works', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + // Bring it exactly back such that there's nothing to animate when releasing. + await gesture.moveBy(const Offset(-300, 0)); + await gesture.up(); + await tester.pump(); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'test edge swipe then drop back at ending point works', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + final pageNumber = settings.name == '/' ? '1' : '2'; + return Center(child: Text('Page $pageNumber')); + }, + ); + }, + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 2'), isOnstage); + + final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); + // The width of the page. + await gesture.moveBy(const Offset(800, 0)); + await gesture.up(); + await tester.pump(); + + expect(find.text('Page 1'), isOnstage); + expect(find.text('Page 2'), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Back swipe dismiss interrupted by route push', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/28728 + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Scaffold(body: Center(child: Text('route'))); + }, + ), + ); + }, + child: const Text('push'), + ), + ), + ), + ), + ); + + // Check the basic iOS back-swipe dismiss transition. Dragging the pushed + // route halfway across the screen will trigger the iOS dismiss animation. + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + + TestGesture gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(400, 0)); + await gesture.up(); + await tester.pump(); + expect( + // The 'route' route has been dragged to the right, halfway across the screen. + tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))), + const Offset(400, 0), + ); + expect( + // The 'push' route is sliding in from the left. + tester.getTopLeft(find.ancestor(of: find.text('push'), matching: find.byType(Scaffold))).dx, + lessThan(0), + ); + await tester.pumpAndSettle(); + expect(find.text('push'), findsOneWidget); + expect( + tester.getTopLeft(find.ancestor(of: find.text('push'), matching: find.byType(Scaffold))), + Offset.zero, + ); + expect(find.text('route'), findsNothing); + + // Run the dismiss animation 60%, which exposes the route "push" button, + // and then press the button. A drag dropped animation is 400ms when dropped + // exactly halfway. It follows a curve that is very steep initially. + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + + gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(400, 0)); // Drag halfway. + await gesture.up(); + await tester.pump(); // Trigger the dropped snapping animation. + expect( + tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))), + const Offset(400, 0), + ); + // Let the dismissing snapping animation go 60%. + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester + .getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))) + .dx, + moreOrLessEquals(794, epsilon: 1), + ); + + // Use the navigator to push a route instead of tapping the 'push' button. + // The topmost route (the one that's animating away), ignores input while + // the pop is underway because route.navigator.userGestureInProgress. + Navigator.push<void>( + scaffoldKey.currentContext!, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Scaffold(body: Center(child: Text('route'))); + }, + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'During back swipe the route ignores input', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/39989 + + final GlobalKey homeScaffoldKey = GlobalKey(); + final GlobalKey pageScaffoldKey = GlobalKey(); + var homeTapCount = 0; + var pageTapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: homeScaffoldKey, + body: GestureDetector( + onTap: () { + homeTapCount += 1; + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(homeScaffoldKey)); + expect(homeTapCount, 1); + expect(pageTapCount, 0); + + Navigator.push<void>( + homeScaffoldKey.currentContext!, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + key: pageScaffoldKey, + appBar: AppBar(title: const Text('Page')), + body: Padding( + padding: const EdgeInsets.all(16), + child: GestureDetector( + onTap: () { + pageTapCount += 1; + }, + ), + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.byKey(pageScaffoldKey)); + expect(homeTapCount, 1); + expect(pageTapCount, 1); + + // Start the basic iOS back-swipe dismiss transition. Drag the pushed + // "page" route halfway across the screen. The underlying "home" will + // start sliding in from the left. + + final TestGesture gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(400, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(400, 0)); + expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); + + // Tapping on the "page" route doesn't trigger the GestureDetector because + // it's being dragged. + await tester.tap(find.byKey(pageScaffoldKey), warnIfMissed: false); + expect(homeTapCount, 1); + expect(pageTapCount, 1); + + // Tapping the "page" route's back button doesn't do anything either. + await tester.tap(find.byTooltip('Back'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(400, 0)); + expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'After a pop caused by a back-swipe, input reaches the exposed route', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/41024 + + final GlobalKey homeScaffoldKey = GlobalKey(); + final GlobalKey pageScaffoldKey = GlobalKey(); + var homeTapCount = 0; + var pageTapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: homeScaffoldKey, + body: GestureDetector( + onTap: () { + homeTapCount += 1; + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(homeScaffoldKey)); + expect(homeTapCount, 1); + expect(pageTapCount, 0); + + final ValueNotifier<bool> notifier = Navigator.of( + homeScaffoldKey.currentContext!, + ).userGestureInProgressNotifier; + expect(notifier.value, false); + + Navigator.push<void>( + homeScaffoldKey.currentContext!, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + key: pageScaffoldKey, + appBar: AppBar(title: const Text('Page')), + body: Padding( + padding: const EdgeInsets.all(16), + child: GestureDetector( + onTap: () { + pageTapCount += 1; + }, + ), + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.byKey(pageScaffoldKey)); + expect(homeTapCount, 1); + expect(pageTapCount, 1); + + // Trigger the basic iOS back-swipe dismiss transition. Drag the pushed + // "page" route more than halfway across the screen and then release it. + + final TestGesture gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(500, 0)); + await tester.pump(); + expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(500, 0)); + expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); + expect(notifier.value, true); + await gesture.up(); + await tester.pumpAndSettle(); + expect(notifier.value, false); + expect(find.byKey(pageScaffoldKey), findsNothing); + + // The back-swipe dismiss pop transition has finished and input on the + // home page still works. + await tester.tap(find.byKey(homeScaffoldKey)); + expect(homeTapCount, 2); + expect(pageTapCount, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'A MaterialPageRoute should slide out with CupertinoPageTransition when a compatible PageRoute is pushed on top of it', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/44864. + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Scaffold(appBar: AppBar(title: const Text('Title'))), + ), + ); + + final Offset titleInitialTopLeft = tester.getTopLeft(find.text('Title')); + + tester + .state<NavigatorState>(find.byType(Navigator)) + .push<void>( + CupertinoPageRoute<void>(builder: (BuildContext context) => const Placeholder()), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + final Offset titleTransientTopLeft = tester.getTopLeft(find.text('Title')); + + // Title of the first route slides to the left. + expect(titleInitialTopLeft.dy, equals(titleTransientTopLeft.dy)); + expect(titleInitialTopLeft.dx, greaterThan(titleTransientTopLeft.dx)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('MaterialPage works', (WidgetTester tester) async { + final LocalKey pageKey = UniqueKey(); + final detector = TransitionDetector(); + var myPages = <Page<void>>[MaterialPage<void>(key: pageKey, child: const Text('first'))]; + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test should never execute this. + return true; + }, + transitionDelegate: detector, + ), + ); + + expect(detector.hasTransition, isFalse); + expect(find.text('first'), findsOneWidget); + + myPages = <Page<void>>[MaterialPage<void>(key: pageKey, child: const Text('second'))]; + + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test should never execute this. + return true; + }, + transitionDelegate: detector, + ), + ); + // There should be no transition because the page has the same key. + expect(detector.hasTransition, isFalse); + // The content does update. + expect(find.text('first'), findsNothing); + expect(find.text('second'), findsOneWidget); + }); + + testWidgets('MaterialPage can toggle MaintainState', (WidgetTester tester) async { + final LocalKey pageKeyOne = UniqueKey(); + final LocalKey pageKeyTwo = UniqueKey(); + final detector = TransitionDetector(); + var myPages = <Page<void>>[ + MaterialPage<void>(key: pageKeyOne, maintainState: false, child: const Text('first')), + MaterialPage<void>(key: pageKeyTwo, child: const Text('second')), + ]; + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test should never execute this. + return true; + }, + transitionDelegate: detector, + ), + ); + + expect(detector.hasTransition, isFalse); + // Page one does not maintain state. + expect(find.text('first', skipOffstage: false), findsNothing); + expect(find.text('second'), findsOneWidget); + + myPages = <Page<void>>[ + MaterialPage<void>(key: pageKeyOne, child: const Text('first')), + MaterialPage<void>(key: pageKeyTwo, child: const Text('second')), + ]; + + await tester.pumpWidget( + buildNavigator( + view: tester.view, + pages: myPages, + onPopPage: (Route<dynamic> route, dynamic result) { + assert(false); // The test should never execute this. + return true; + }, + transitionDelegate: detector, + ), + ); + // There should be no transition because the page has the same key. + expect(detector.hasTransition, isFalse); + // Page one sets the maintain state to be true, its widget tree should be + // built. + expect(find.text('first', skipOffstage: false), findsOneWidget); + expect(find.text('second'), findsOneWidget); + }); + + testWidgets('MaterialPage does not lose its state when transitioning out', ( + WidgetTester tester, + ) async { + final navigator = GlobalKey<NavigatorState>(); + await tester.pumpWidget(KeepsStateTestWidget(navigatorKey: navigator)); + expect(find.text('subpage'), findsOneWidget); + expect(find.text('home'), findsNothing); + + navigator.currentState!.pop(); + await tester.pump(); + + expect(find.text('subpage'), findsOneWidget); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('MaterialPage restores its state', (WidgetTester tester) async { + await tester.pumpWidget( + RootRestorationScope( + restorationId: 'root', + child: TestDependencies( + child: Navigator( + onPopPage: (Route<dynamic> route, dynamic result) { + return false; + }, + pages: const <Page<Object?>>[ + MaterialPage<void>( + restorationId: 'p1', + child: TestRestorableWidget(restorationId: 'p1'), + ), + ], + restorationScopeId: 'nav', + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) { + return TestRestorableWidget(restorationId: settings.name!); + }, + ); + }, + ), + ), + ), + ); + + expect(find.text('p1'), findsOneWidget); + expect(find.text('count: 0'), findsOneWidget); + + await tester.tap(find.text('increment')); + await tester.pump(); + expect(find.text('count: 1'), findsOneWidget); + + tester.state<NavigatorState>(find.byType(Navigator)).restorablePushNamed('p2'); + await tester.pumpAndSettle(); + + expect(find.text('p1'), findsNothing); + expect(find.text('p2'), findsOneWidget); + + await tester.tap(find.text('increment')); + await tester.pump(); + await tester.tap(find.text('increment')); + await tester.pump(); + expect(find.text('count: 2'), findsOneWidget); + + await tester.restartAndRestore(); + + expect(find.text('p2'), findsOneWidget); + expect(find.text('count: 2'), findsOneWidget); + + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); + + expect(find.text('p1'), findsOneWidget); + expect(find.text('count: 1'), findsOneWidget); + }); + + testWidgets('MaterialPageRoute can be dismissed with escape keyboard shortcut', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/132138. + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.push<void>( + scaffoldKey.currentContext!, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Scaffold(body: Center(child: Text('route'))); + }, + barrierDismissible: true, + ), + ); + }, + child: const Text('push'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('route'), findsOneWidget); + expect(find.text('push'), findsNothing); + + // Try to dismiss the route with the escape key. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('route'), findsNothing); + }); + + testWidgets( + 'Setting MaterialPageRoute.requestFocus to false does not request focus on the page', + (WidgetTester tester) async { + late BuildContext savedContext; + const pageTwoText = 'Page Two'; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + savedContext = context; + return Container(); + }, + ), + ), + ); + await tester.pump(); + + // Check page two is not on the screen. + expect(find.text(pageTwoText), findsNothing); + + // Navigate to page two with text. + final NavigatorState navigator = Navigator.of(savedContext); + navigator.push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return const Text(pageTwoText); + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // The page two is showing and the text widget has focus. + Element textOnPageTwo = tester.element(find.text(pageTwoText)); + FocusScopeNode focusScopeNode = FocusScope.of(textOnPageTwo); + expect(focusScopeNode.hasFocus, isTrue); + + // Navigate back to page one. + navigator.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Navigate to page two again with requestFocus set to false. + navigator.push( + MaterialPageRoute<void>( + requestFocus: false, + builder: (BuildContext context) { + return const Text(pageTwoText); + }, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // The page two is showing and the text widget is not focused. + textOnPageTwo = tester.element(find.text(pageTwoText)); + focusScopeNode = FocusScope.of(textOnPageTwo); + expect(focusScopeNode.hasFocus, isFalse); + }, + ); +} + +class TransitionDetector extends DefaultTransitionDelegate<void> { + bool hasTransition = false; + @override + Iterable<RouteTransitionRecord> resolve({ + required List<RouteTransitionRecord> newPageRouteHistory, + required Map<RouteTransitionRecord?, RouteTransitionRecord> locationToExitingPageRoute, + required Map<RouteTransitionRecord?, List<RouteTransitionRecord>> pageRouteToPagelessRoutes, + }) { + hasTransition = true; + return super.resolve( + newPageRouteHistory: newPageRouteHistory, + locationToExitingPageRoute: locationToExitingPageRoute, + pageRouteToPagelessRoutes: pageRouteToPagelessRoutes, + ); + } +} + +Widget buildNavigator({ + required List<Page<dynamic>> pages, + required PopPageCallback onPopPage, + required ui.FlutterView view, + GlobalKey<NavigatorState>? key, + TransitionDelegate<dynamic>? transitionDelegate, +}) { + return MediaQuery( + data: MediaQueryData.fromView(view), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + key: key, + pages: pages, + onPopPage: onPopPage, + transitionDelegate: transitionDelegate ?? const DefaultTransitionDelegate<dynamic>(), + ), + ), + ), + ); +} + +class KeepsStateTestWidget extends StatefulWidget { + const KeepsStateTestWidget({super.key, this.navigatorKey}); + + final Key? navigatorKey; + + @override + State<KeepsStateTestWidget> createState() => _KeepsStateTestWidgetState(); +} + +class _KeepsStateTestWidgetState extends State<KeepsStateTestWidget> { + String? _subpage = 'subpage'; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Navigator( + key: widget.navigatorKey, + pages: <Page<void>>[ + const MaterialPage<void>(child: Text('home')), + if (_subpage != null) MaterialPage<void>(child: Text(_subpage!)), + ], + onPopPage: (Route<dynamic> route, dynamic result) { + if (!route.didPop(result)) { + return false; + } + setState(() { + _subpage = null; + }); + return true; + }, + ), + ); + } +} + +class TestRestorableWidget extends StatefulWidget { + const TestRestorableWidget({super.key, required this.restorationId}); + + final String restorationId; + + @override + State<StatefulWidget> createState() => _TestRestorableWidgetState(); +} + +class _TestRestorableWidgetState extends State<TestRestorableWidget> with RestorationMixin { + @override + String? get restorationId => widget.restorationId; + + final RestorableInt counter = RestorableInt(0); + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(counter, 'counter'); + } + + @override + Widget build(BuildContext context) { + return Column( + children: <Widget>[ + Text(widget.restorationId), + Text('count: ${counter.value}'), + ElevatedButton( + onPressed: () { + setState(() { + counter.value++; + }); + }, + child: const Text('increment'), + ), + ], + ); + } + + @override + void dispose() { + counter.dispose(); + super.dispose(); + } +} + +class TestDependencies extends StatelessWidget { + const TestDependencies({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery(data: MediaQueryData.fromView(View.of(context)), child: child), + ); + } +} diff --git a/packages/material_ui/test/material/page_transitions_theme_test.dart b/packages/material_ui/test/material/page_transitions_theme_test.dart new file mode 100644 index 000000000000..2b7b284834d0 --- /dev/null +++ b/packages/material_ui/test/material/page_transitions_theme_test.dart @@ -0,0 +1,1540 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Default PageTransitionsTheme platform', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Text('home'))); + final PageTransitionsTheme theme = Theme.of( + tester.element(find.text('home')), + ).pageTransitionsTheme; + expect(theme.builders, isNotNull); + for (final TargetPlatform platform in TargetPlatform.values) { + switch (platform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + theme.builders[platform], + isNotNull, + reason: 'theme builder for $platform is null', + ); + case TargetPlatform.fuchsia: + expect( + theme.builders[platform], + isNull, + reason: 'theme builder for $platform is not null', + ); + } + } + }); + + testWidgets( + 'Default PageTransitionsTheme builds a CupertinoPageTransition', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + expect( + Theme.of(tester.element(find.text('push'))).platform, + debugDefaultTargetPlatformOverride, + ); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Default PageTransitionsTheme builds a _FadeForwardsPageTransition for android', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + Finder findFadeForwardsPageTransition() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeForwardsPageTransition', + ), + ); + } + + expect( + Theme.of(tester.element(find.text('push'))).platform, + debugDefaultTargetPlatformOverride, + ); + expect(findFadeForwardsPageTransition(), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(findFadeForwardsPageTransition(), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Default background color when FadeForwardsPageTransitionBuilder is used', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + }, + ), + colorScheme: ThemeData().colorScheme.copyWith(surface: Colors.pink), + ), + routes: routes, + ), + ); + + Finder findFadeForwardsPageTransition() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeForwardsPageTransition', + ), + ); + } + + expect(findFadeForwardsPageTransition(), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pump(const Duration(milliseconds: 400)); + + final Finder coloredBoxFinder = find.byType(ColoredBox).last; + expect(coloredBoxFinder, findsOneWidget); + final ColoredBox coloredBox = tester.widget<ColoredBox>(coloredBoxFinder); + expect(coloredBox.color, Colors.pink); + + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(findFadeForwardsPageTransition(), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Override background color in FadeForwardsPageTransitionBuilder', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeForwardsPageTransitionsBuilder( + backgroundColor: Colors.lightGreen, + ), + }, + ), + colorScheme: ThemeData().colorScheme.copyWith(surface: Colors.pink), + ), + routes: routes, + ), + ); + + Finder findFadeForwardsPageTransition() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeForwardsPageTransition', + ), + ); + } + + expect(findFadeForwardsPageTransition(), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pump(const Duration(milliseconds: 400)); + + final Finder coloredBoxFinder = find.byType(ColoredBox).last; + expect(coloredBoxFinder, findsOneWidget); + final ColoredBox coloredBox = tester.widget<ColoredBox>(coloredBoxFinder); + expect(coloredBox.color, Colors.lightGreen); + + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(findFadeForwardsPageTransition(), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + group('FadeForwardsPageTransitionsBuilder transitions', () { + testWidgets( + 'opacity fades out during forward secondary animation', + (WidgetTester tester) async { + final controller = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + addTearDown(controller.dispose); + final Animation<double> animation = Tween<double>(begin: 1, end: 0).animate(controller); + final Animation<double> secondaryAnimation = Tween<double>( + begin: 0, + end: 1, + ).animate(controller); + + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + return const FadeForwardsPageTransitionsBuilder().delegatedTransition!( + context, + animation, + secondaryAnimation, + false, + const SizedBox(), + )!; + }, + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.byType(SizedBox)) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + + // Since secondary animation is forward, transition will be reverse between duration 0 to 0.25. + controller.value = 0.0; + await tester.pump(); + expect(renderOpacity?.opacity.value, 1.0); + + controller.value = 0.25; + await tester.pump(); + expect(renderOpacity?.opacity.value, 0.0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'opacity fades in during reverse secondary animaation', + (WidgetTester tester) async { + final controller = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + addTearDown(controller.dispose); + final Animation<double> animation = Tween<double>(begin: 0, end: 1).animate(controller); + final Animation<double> secondaryAnimation = Tween<double>( + begin: 1, + end: 0, + ).animate(controller); + + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + return const FadeForwardsPageTransitionsBuilder().delegatedTransition!( + context, + animation, + secondaryAnimation, + false, + const SizedBox(), + )!; + }, + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester + .element(find.byType(SizedBox)) + .findAncestorRenderObjectOfType<RenderAnimatedOpacity>(); + + // Since secondary animation is reverse, transition will be forward between duration 0.75 to 1.0. + controller.value = 0.75; + await tester.pump(); + expect(renderOpacity?.opacity.value, 0.0); + + controller.value = 1.0; + await tester.pump(); + expect(renderOpacity?.opacity.value, 1.0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'FadeForwardsPageTransitionBuilder does not use ColoredBox for non-opaque routes', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeForwardsPageTransitionsBuilder( + backgroundColor: Colors.lightGreen, + ), + }, + ), + ), + home: Builder( + builder: (BuildContext context) { + return Material( + child: TextButton( + onPressed: () { + Navigator.push( + context, + PageRouteBuilder<void>( + opaque: false, + pageBuilder: (_, _, _) { + return Material( + child: TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>(builder: (_) => const Text('page b')), + ); + }, + child: const Text('push b'), + ), + ); + }, + ), + ); + }, + child: const Text('push a'), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('push a')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('push b')); + await tester.pump(const Duration(milliseconds: 400)); + + void findColoredBox() { + expect( + find.byWidgetPredicate((Widget w) => w is ColoredBox && w.color == Colors.lightGreen), + findsNothing, + ); + } + + // Check that ColoredBox is not used for non-opaque route. + findColoredBox(); + + await tester.pumpAndSettle(); + + Navigator.pop(tester.element(find.text('page b'))); + + await tester.pumpAndSettle(const Duration(milliseconds: 400)); + + // Check that ColoredBox is not used for non-opaque route + findColoredBox(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + }); + + testWidgets( + 'FadeForwardsPageTransitionBuilder default duration is 800ms', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + }, + ), + ), + routes: routes, + ), + ); + + Finder findFadeForwardsPageTransition() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeForwardsPageTransition', + ), + ); + } + + expect(findFadeForwardsPageTransition(), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pump(const Duration(milliseconds: 799)); + expect(find.text('page b'), findsNothing); + ColoredBox coloredBox = tester.widget(find.byType(ColoredBox).last); + expect( + coloredBox.color, + isNot(Colors.transparent), + ); // Color is not transparent during animation. + + await tester.pump(const Duration(milliseconds: 801)); + expect(find.text('page b'), findsOneWidget); + coloredBox = tester.widget(find.byType(ColoredBox).last); + expect(coloredBox.color, Colors.transparent); // Color is transparent during animation. + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'CupertinoPageTransitionsBuilder default duration is 500ms', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + }, + ), + ), + routes: routes, + ), + ); + + expect(find.byType(CupertinoPageTransition), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 499)); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(const Duration(milliseconds: 10)); + expect(tester.hasRunningAnimations, isFalse); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Animation duration changes accordingly when page transition builder changes', + (WidgetTester tester) async { + Widget buildApp(PageTransitionsBuilder pageTransitionBuilder) { + return MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: pageTransitionBuilder, + }, + ), + ), + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => Material( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + TextButton( + child: const Text('pop'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + const Text('page b'), + ], + ), + ), + }, + ); + } + + await tester.pumpWidget(buildApp(const FadeForwardsPageTransitionsBuilder())); + + Finder findFadeForwardsPageTransition() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeForwardsPageTransition', + ), + ); + } + + expect(findFadeForwardsPageTransition(), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pump(const Duration(milliseconds: 799)); + expect(find.text('page b'), findsNothing); + ColoredBox coloredBox = tester.widget(find.byType(ColoredBox).last); + expect( + coloredBox.color, + isNot(Colors.transparent), + ); // The color is not transparent during animation. + + await tester.pump(const Duration(milliseconds: 801)); + expect(find.text('page b'), findsOneWidget); + coloredBox = tester.widget(find.byType(ColoredBox).last); + expect(coloredBox.color, Colors.transparent); // The color is transparent during animation. + + await tester.pumpWidget(buildApp(const FadeUpwardsPageTransitionsBuilder())); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeUpwardsPageTransition', + ), + ), + findsOneWidget, + ); + await tester.tap(find.text('pop')); + await tester.pump(const Duration(milliseconds: 299)); + expect(find.text('page b'), findsOneWidget); + expect( + find.byType(ColoredBox), + findsNothing, + ); // ColoredBox doesn't exist in FadeUpwardsPageTransition. + + await tester.pump(const Duration(milliseconds: 301)); + expect(find.text('page b'), findsNothing); + expect(find.text('push'), findsOneWidget); // The first page + expect(find.byType(ColoredBox), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'PageTransitionsTheme override builds a CupertinoPageTransition on android', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: CupertinoPageTransitionsBuilder(), + }, + ), + ), + routes: routes, + ), + ); + + expect( + Theme.of(tester.element(find.text('push'))).platform, + debugDefaultTargetPlatformOverride, + ); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'CupertinoPageTransition on android does not block gestures on backswipe', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: CupertinoPageTransitionsBuilder(), + }, + ), + ), + routes: routes, + ), + ); + + expect( + Theme.of(tester.element(find.text('push'))).platform, + debugDefaultTargetPlatformOverride, + ); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(find.byType(CupertinoPageTransition), findsOneWidget); + + await tester.pumpAndSettle(const Duration(minutes: 1)); + + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); + await gesture.moveBy(const Offset(400.0, 0.0)); + await gesture.up(); + await tester.pump(); + + await tester.pumpAndSettle(const Duration(minutes: 1)); + + expect(find.text('push'), findsOneWidget); + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'PageTransitionsTheme override builds a _FadeUpwardsTransition', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: + FadeUpwardsPageTransitionsBuilder(), // creates a _FadeUpwardsTransition + }, + ), + ), + routes: routes, + ), + ); + + Finder findFadeUpwardsPageTransition() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeUpwardsPageTransition', + ), + ); + } + + expect( + Theme.of(tester.element(find.text('push'))).platform, + debugDefaultTargetPlatformOverride, + ); + expect(findFadeUpwardsPageTransition(), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('page b'), findsOneWidget); + expect(findFadeUpwardsPageTransition(), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + Widget boilerplate({ + required bool themeAllowSnapshotting, + bool secondRouteAllowSnapshotting = true, + }) { + return MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: ZoomPageTransitionsBuilder( + allowSnapshotting: themeAllowSnapshotting, + ), + }, + ), + ), + onGenerateRoute: (RouteSettings settings) { + if (settings.name == '/') { + return MaterialPageRoute<Widget>(builder: (_) => const Material(child: Text('Page 1'))); + } + return MaterialPageRoute<Widget>( + builder: (_) => const Material(child: Text('Page 2')), + allowSnapshotting: secondRouteAllowSnapshotting, + ); + }, + ); + } + + bool isTransitioningWithSnapshotting(WidgetTester tester, Finder of) { + final Iterable<Layer> layers = tester.layerListOf( + find.ancestor(of: of, matching: find.byType(SnapshotWidget)).first, + ); + final hasOneOpacityLayer = layers.whereType<OpacityLayer>().length == 1; + final hasOneTransformLayer = layers.whereType<TransformLayer>().length == 1; + // When snapshotting is on, the OpacityLayer and TransformLayer will not be + // applied directly. + return !(hasOneOpacityLayer && hasOneTransformLayer); + } + + testWidgets( + 'ZoomPageTransitionsBuilder default route snapshotting behavior', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(themeAllowSnapshotting: true)); + + final Finder page1 = find.text('Page 1'); + final Finder page2 = find.text('Page 2'); + + // Transitioning from page 1 to page 2. + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/2'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Exiting route should be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page1), isTrue); + + // Entering route should be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page2), isTrue); + + await tester.pumpAndSettle(); + + // Transitioning back from page 2 to page 1. + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Exiting route should be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page2), isTrue); + + // Entering route should be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page1), isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] rasterization is not used on the web. + ); + + testWidgets( + 'ZoomPageTransitionsBuilder.allowSnapshotting can disable route snapshotting', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(themeAllowSnapshotting: false)); + + final Finder page1 = find.text('Page 1'); + final Finder page2 = find.text('Page 2'); + + // Transitioning from page 1 to page 2. + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/2'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Exiting route should not be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page1), isFalse); + + // Entering route should not be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page2), isFalse); + + await tester.pumpAndSettle(); + + // Transitioning back from page 2 to page 1. + tester.state<NavigatorState>(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Exiting route should not be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page2), isFalse); + + // Entering route should not be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page1), isFalse); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] rasterization is not used on the web. + ); + + testWidgets( + 'Setting PageRoute.allowSnapshotting to false overrides ZoomPageTransitionsBuilder.allowSnapshotting = true', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate(themeAllowSnapshotting: true, secondRouteAllowSnapshotting: false), + ); + + final Finder page1 = find.text('Page 1'); + final Finder page2 = find.text('Page 2'); + + // Transitioning from page 1 to page 2. + tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/2'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // First route should be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page1), isTrue); + + // Second route should not be snapshotted. + expect(isTransitioningWithSnapshotting(tester, page2), isFalse); + + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] rasterization is not used on the web. + ); + + testWidgets( + '_ZoomPageTransition only causes child widget built once', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/58345 + + var builtCount = 0; + + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + builtCount++; // Increase [builtCount] each time the widget build + return TextButton( + child: const Text('pop'), + onPressed: () { + Navigator.pop(context); + }, + ); + }, + ), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: + ZoomPageTransitionsBuilder(), // creates a _ZoomPageTransition + }, + ), + ), + routes: routes, + ), + ); + + // No matter push or pop was called, the child widget should built only once. + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(builtCount, 1); + + await tester.tap(find.text('pop')); + await tester.pumpAndSettle(); + expect(builtCount, 1); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'predictive back gestures pop the route on all platforms regardless of whether their transition handles predictive back', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + + // Start a system pop gesture. + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + + // Drag the system back gesture far enough to commit. + final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'x': 100.0, + 'y': 300.0, + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + // Shows both pages while doing the "peek" predicitve back transition. + expect(find.text('push'), findsOneWidget); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + // Does no transition yet; still shows page b only. + expect(find.text('push'), findsNothing); + } + expect(find.text('page b'), findsOneWidget); + + // Commit the system back gesture. + final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('commitBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('predictive back is the default on Android', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + }; + await tester.pumpWidget(MaterialApp(routes: routes)); + + final ThemeData themeData = Theme.of(tester.element(find.text('push'))); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect( + themeData.pageTransitionsTheme.builders[defaultTargetPlatform], + isA<PredictiveBackPageTransitionsBuilder>(), + ); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + expect( + themeData.pageTransitionsTheme.builders[defaultTargetPlatform], + isNot(isA<PredictiveBackPageTransitionsBuilder>()), + ); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('predictive back falls back to FadeForwardsPageTransition', ( + WidgetTester tester, + ) async { + Finder findPredictiveBackPageTransition() { + return find.descendant( + of: find.byType(PrimaryScrollController), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_PredictiveBackSharedElementPageTransition', + ), + ); + } + + Finder findFallbackPageTransition() { + return find.descendant( + of: find.byType(PrimaryScrollController), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_FadeForwardsPageTransition', + ), + ); + } + + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + routes: routes, + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.iOS: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.macOS: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.windows: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.linux: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.fuchsia: PredictiveBackPageTransitionsBuilder(), + }, + ), + ), + ), + ); + + final ThemeData themeData = Theme.of(tester.element(find.text('push'))); + expect( + themeData.pageTransitionsTheme.builders[defaultTargetPlatform], + isA<PredictiveBackPageTransitionsBuilder>(), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + + // Only Android sends system back gestures. + if (defaultTargetPlatform == TargetPlatform.android) { + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(findPredictiveBackPageTransition(), findsOneWidget); + expect(findFallbackPageTransition(), findsNothing); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + expect(findPredictiveBackPageTransition(), findsNothing); + expect(findFallbackPageTransition(), findsOneWidget); + } + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + + // Drag the system back gesture far enough to commit. + if (defaultTargetPlatform == TargetPlatform.android) { + final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'x': 100.0, + 'y': 300.0, + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('push'), findsOneWidget); + } else { + expect(find.text('push'), findsNothing); + } + + expect(find.text('page b'), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(findPredictiveBackPageTransition(), findsNWidgets(2)); + expect(findFallbackPageTransition(), findsNothing); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + expect(findPredictiveBackPageTransition(), findsNothing); + expect(findFallbackPageTransition(), findsOneWidget); + } + + if (defaultTargetPlatform == TargetPlatform.android) { + // Commit the system back gesture on Android. + final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('commitBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage, + (ByteData? _) {}, + ); + } else { + // On other platforms, send a one-off system pop. + final ByteData popMessage = const JSONMethodCodec().encodeMethodCall( + const MethodCall('popRoute'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + popMessage, + (ByteData? _) {}, + ); + } + await tester.pump(); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsOneWidget); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(findPredictiveBackPageTransition(), findsNWidgets(2)); + expect(findFallbackPageTransition(), findsNothing); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + expect(findPredictiveBackPageTransition(), findsNothing); + expect(findFallbackPageTransition(), findsNWidgets(2)); + } + + await tester.pumpAndSettle(); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + expect(findPredictiveBackPageTransition(), findsNothing); + expect(findFallbackPageTransition(), findsOneWidget); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + expect(findPredictiveBackPageTransition(), findsNothing); + expect(findFallbackPageTransition(), findsOneWidget); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'ZoomPageTransitionsBuilder uses theme color during transition effects', + (WidgetTester tester) async { + // Color that is being tested for presence. + const themeTestSurfaceColor = Color.fromARGB(255, 195, 255, 0); + + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: Scaffold( + appBar: AppBar(title: const Text('Home Page')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/scaffolded'); + }, + child: const Text('Route with scaffold!'), + ), + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/not-scaffolded'); + }, + child: const Text('Route with NO scaffold!'), + ), + ], + ), + ), + ), + ), + '/scaffolded': (BuildContext context) => Material( + child: Scaffold( + appBar: AppBar(title: const Text('Scaffolded Page')), + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Back to home route...'), + ), + ), + ), + ), + '/not-scaffolded': (BuildContext context) => Material( + child: Center( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Back to home route...'), + ), + ), + ), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + surface: themeTestSurfaceColor, + ), + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + // Force all platforms to use ZoomPageTransitionsBuilder to test each one. + for (final TargetPlatform platform in TargetPlatform.values) + platform: const ZoomPageTransitionsBuilder(), + }, + ), + ), + routes: routes, + ), + ); + + // Go to scaffolded page. + await tester.tap(find.text('Route with scaffold!')); + + // Pump till animation is half-way through. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 75)); + + // Verify that the render box is painting the right color for scaffolded pages. + final RenderBox scaffoldedRenderBox = tester.firstRenderObject<RenderBox>( + find.byType(MaterialApp), + ); + // Expect the color to be at exactly 12.2% opacity at this time. + expect(scaffoldedRenderBox, paints..rect(color: themeTestSurfaceColor.withOpacity(0.122))); + + await tester.pumpAndSettle(); + + // Go back home and then go to non-scaffolded page. + await tester.tap(find.text('Back to home route...')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Route with NO scaffold!')); + + // Pump till animation is half-way through. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); + + // Verify that the render box is painting the right color for non-scaffolded pages. + final RenderBox nonScaffoldedRenderBox = tester.firstRenderObject<RenderBox>( + find.byType(MaterialApp), + ); + // Expect the color to be at exactly 59.6% opacity at this time. + expect(nonScaffoldedRenderBox, paints..rect(color: themeTestSurfaceColor.withOpacity(0.596))); + + await tester.pumpAndSettle(); + + // Verify that the transition successfully completed. + expect(find.text('Back to home route...'), findsOneWidget); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'ZoomPageTransitionsBuilder uses developer-provided color during transition effects if provided', + (WidgetTester tester) async { + // Color that is being tested for presence. + const Color testSurfaceColor = Colors.red; + + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: Scaffold( + appBar: AppBar(title: const Text('Home Page')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/scaffolded'); + }, + child: const Text('Route with scaffold!'), + ), + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/not-scaffolded'); + }, + child: const Text('Route with NO scaffold!'), + ), + ], + ), + ), + ), + ), + '/scaffolded': (BuildContext context) => Material( + child: Scaffold( + appBar: AppBar(title: const Text('Scaffolded Page')), + body: Center( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Back to home route...'), + ), + ), + ), + ), + '/not-scaffolded': (BuildContext context) => Material( + child: Center( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Back to home route...'), + ), + ), + ), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, surface: Colors.blue), + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + // Force all platforms to use ZoomPageTransitionsBuilder to test each one. + for (final TargetPlatform platform in TargetPlatform.values) + platform: const ZoomPageTransitionsBuilder(backgroundColor: testSurfaceColor), + }, + ), + ), + routes: routes, + ), + ); + + // Go to scaffolded page. + await tester.tap(find.text('Route with scaffold!')); + + // Pump till animation is half-way through. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 75)); + + // Verify that the render box is painting the right color for scaffolded pages. + final RenderBox scaffoldedRenderBox = tester.firstRenderObject<RenderBox>( + find.byType(MaterialApp), + ); + // Expect the color to be at exactly 12.2% opacity at this time. + expect(scaffoldedRenderBox, paints..rect(color: testSurfaceColor.withOpacity(0.122))); + + await tester.pumpAndSettle(); + + // Go back home and then go to non-scaffolded page. + await tester.tap(find.text('Back to home route...')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Route with NO scaffold!')); + + // Pump till animation is half-way through. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); + + // Verify that the render box is painting the right color for non-scaffolded pages. + final RenderBox nonScaffoldedRenderBox = tester.firstRenderObject<RenderBox>( + find.byType(MaterialApp), + ); + // Expect the color to be at exactly 59.6% opacity at this time. + expect(nonScaffoldedRenderBox, paints..rect(color: testSurfaceColor.withOpacity(0.596))); + + await tester.pumpAndSettle(); + + // Verify that the transition successfully completed. + expect(find.text('Back to home route...'), findsOneWidget); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'Can interact with incoming route during FadeForwards back navigation', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => Material( + child: TextButton( + child: const Text('go back'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: const PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + }, + ), + colorScheme: ThemeData().colorScheme.copyWith(surface: Colors.pink), + ), + routes: routes, + ), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('go back'), findsNothing); + + // Go to the second route. The duration of the FadeForwardsPageTransition + // is 800ms. + await tester.tap(find.text('push')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 801)); + + expect(find.text('push'), findsNothing); + expect(find.text('go back'), findsOneWidget); + + // Tap to go back to the first route. + await tester.tap(find.text('go back')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('push'), findsOneWidget); + expect(find.text('go back'), findsOneWidget); + + // In the middle of the transition, tap to go back to the second route. + await tester.tap(find.text('push')); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 401)); + + expect(find.text('push'), findsOneWidget); + expect(find.text('go back'), findsOneWidget); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('push'), findsNothing); + expect(find.text('go back'), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/packages/material_ui/test/material/paginated_data_table_test.dart b/packages/material_ui/test/material/paginated_data_table_test.dart new file mode 100644 index 000000000000..24921d35a54a --- /dev/null +++ b/packages/material_ui/test/material/paginated_data_table_test.dart @@ -0,0 +1,1537 @@ +// 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/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'data_table_test_utils.dart'; + +class TestDataSource extends DataTableSource { + TestDataSource({this.allowSelection = false}); + + final bool allowSelection; + + int get generation => _generation; + int _generation = 0; + set generation(int value) { + if (_generation == value) { + return; + } + _generation = value; + notifyListeners(); + } + + final Set<int> _selectedRows = <int>{}; + + void _handleSelected(int index, bool? selected) { + if (selected ?? false) { + _selectedRows.add(index); + } else { + _selectedRows.remove(index); + } + notifyListeners(); + } + + @override + DataRow getRow(int index) { + final Dessert dessert = kDesserts[index % kDesserts.length]; + final int page = index ~/ kDesserts.length; + return DataRow.byIndex( + index: index, + selected: _selectedRows.contains(index), + cells: <DataCell>[ + DataCell(Text('${dessert.name} ($page)')), + DataCell(Text('${dessert.calories}')), + DataCell(Text('$generation')), + ], + onSelectChanged: allowSelection ? (bool? selected) => _handleSelected(index, selected) : null, + ); + } + + @override + int get rowCount => 50 * kDesserts.length; + + @override + bool get isRowCountApproximate => false; + + @override + int get selectedRowCount => _selectedRows.length; +} + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + late TestDataSource source; + setUp(() => source = TestDataSource()); + tearDown(() => source.dispose()); + + testWidgets('PaginatedDataTable paging', (WidgetTester tester) async { + final log = <String>[]; + + await tester.pumpWidget( + MaterialApp( + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + showFirstLastButtons: true, + availableRowsPerPage: const <int>[2, 4, 8, 16], + onRowsPerPageChanged: (int? rowsPerPage) { + log.add('rows-per-page-changed: $rowsPerPage'); + }, + onPageChanged: (int rowIndex) { + log.add('page-changed: $rowIndex'); + }, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ); + + await tester.tap(find.byTooltip('Next page')); + + expect(log, <String>['page-changed: 2']); + log.clear(); + + await tester.pump(); + + expect(find.text('Frozen yogurt (0)'), findsNothing); + expect(find.text('Eclair (0)'), findsOneWidget); + expect(find.text('Gingerbread (0)'), findsNothing); + + await tester.tap(find.byIcon(Icons.chevron_left)); + + expect(log, <String>['page-changed: 0']); + log.clear(); + + await tester.pump(); + + expect(find.text('Frozen yogurt (0)'), findsOneWidget); + expect(find.text('Eclair (0)'), findsNothing); + expect(find.text('Gingerbread (0)'), findsNothing); + + final Finder lastPageButton = find.ancestor( + of: find.byTooltip('Last page'), + matching: find.byWidgetPredicate((Widget widget) => widget is IconButton), + ); + + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNotNull); + + await tester.tap(lastPageButton); + + expect(log, <String>['page-changed: 498']); + log.clear(); + + await tester.pump(); + + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNull); + + expect(find.text('Frozen yogurt (0)'), findsNothing); + expect(find.text('Donut (49)'), findsOneWidget); + expect(find.text('KitKat (49)'), findsOneWidget); + + final Finder firstPageButton = find.ancestor( + of: find.byTooltip('First page'), + matching: find.byWidgetPredicate((Widget widget) => widget is IconButton), + ); + + expect(tester.widget<IconButton>(firstPageButton).onPressed, isNotNull); + + await tester.tap(firstPageButton); + + expect(log, <String>['page-changed: 0']); + log.clear(); + + await tester.pump(); + + expect(tester.widget<IconButton>(firstPageButton).onPressed, isNull); + + expect(find.text('Frozen yogurt (0)'), findsOneWidget); + expect(find.text('Eclair (0)'), findsNothing); + expect(find.text('Gingerbread (0)'), findsNothing); + + await tester.tap(find.byIcon(Icons.chevron_left)); + + expect(log, isEmpty); + + await tester.tap(find.text('2')); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + await tester.tap(find.text('8').last); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + expect(log, <String>['rows-per-page-changed: 8']); + log.clear(); + }); + + testWidgets('PaginatedDataTable footer page number', (WidgetTester tester) async { + var rowsPerPage = 2; + + Widget buildTable(TestDataSource source, int rowsPerPage) { + return PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: rowsPerPage, + showFirstLastButtons: true, + availableRowsPerPage: const <int>[2, 3, 4, 5, 7, 8], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ); + } + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + + expect(find.text('1–2 of 500'), findsOneWidget); + + await tester.tap(find.byTooltip('Next page')); + await tester.pump(); + + expect(find.text('3–4 of 500'), findsOneWidget); + + final Finder lastPageButton = find.ancestor( + of: find.byTooltip('Last page'), + matching: find.byWidgetPredicate((Widget widget) => widget is IconButton), + ); + + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNotNull); + + await tester.tap(lastPageButton); + await tester.pump(); + + expect(find.text('499–500 of 500'), findsOneWidget); + + final PaginatedDataTableState state = tester.state(find.byType(PaginatedDataTable)); + + state.pageTo(1); + rowsPerPage = 3; + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + + expect(find.textContaining('1–3 of 500'), findsOneWidget); + + await tester.tap(find.byTooltip('Next page')); + await tester.pump(); + + expect(find.text('4–6 of 500'), findsOneWidget); + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNotNull); + + await tester.tap(lastPageButton); + await tester.pump(); + + expect(find.text('499–500 of 500'), findsOneWidget); + + state.pageTo(1); + rowsPerPage = 4; + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + + expect(find.textContaining('1–4 of 500'), findsOneWidget); + + await tester.tap(find.byTooltip('Next page')); + await tester.pump(); + + expect(find.text('5–8 of 500'), findsOneWidget); + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNotNull); + + await tester.tap(lastPageButton); + await tester.pump(); + + expect(find.text('497–500 of 500'), findsOneWidget); + + state.pageTo(1); + rowsPerPage = 5; + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + + expect(find.textContaining('1–5 of 500'), findsOneWidget); + + await tester.tap(find.byTooltip('Next page')); + await tester.pump(); + + expect(find.text('6–10 of 500'), findsOneWidget); + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNotNull); + + await tester.tap(lastPageButton); + await tester.pump(); + + expect(find.text('496–500 of 500'), findsOneWidget); + + state.pageTo(1); + rowsPerPage = 8; + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + + expect(find.textContaining('1–8 of 500'), findsOneWidget); + + await tester.tap(find.byTooltip('Next page')); + await tester.pump(); + + expect(find.text('9–16 of 500'), findsOneWidget); + expect(tester.widget<IconButton>(lastPageButton).onPressed, isNotNull); + + await tester.tap(lastPageButton); + await tester.pump(); + + expect(find.text('497–500 of 500'), findsOneWidget); + }); + + testWidgets('PaginatedDataTable Last Page Empty Space', (WidgetTester tester) async { + final source = TestDataSource(); + var rowsPerPage = 3; + final int rowCount = source.rowCount; + addTearDown(source.dispose); + + Widget buildTable(TestDataSource source, int rowsPerPage) { + return PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: rowsPerPage, + showFirstLastButtons: true, + dataRowHeight: 46, + availableRowsPerPage: const <int>[3, 6, 7, 8, 9], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + showEmptyRows: false, + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Center(child: buildTable(source, rowsPerPage))), + ), + ); + + expect( + find.byWidgetPredicate((Widget widget) => widget is SizedBox && widget.height == 0), + findsOneWidget, + ); + await tester.tap(find.byIcon(Icons.skip_next)); + await tester.pump(); + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SizedBox && widget.height == (rowsPerPage - (rowCount % rowsPerPage)) * 46.0, + ), + findsOneWidget, + ); + + rowsPerPage = 6; + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + + await tester.tap(find.byIcon(Icons.skip_previous)); + await tester.pump(); + expect( + find.byWidgetPredicate((Widget widget) => widget is SizedBox && widget.height == 0), + findsOneWidget, + ); + await tester.tap(find.byIcon(Icons.skip_next)); + await tester.pump(); + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SizedBox && widget.height == (rowsPerPage - (rowCount % rowsPerPage)) * 46.0, + ), + findsOneWidget, + ); + + rowsPerPage = 7; + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + await tester.tap(find.byIcon(Icons.skip_previous)); + await tester.pump(); + + expect( + find.byWidgetPredicate((Widget widget) => widget is SizedBox && widget.height == 0), + findsOneWidget, + ); + await tester.tap(find.byIcon(Icons.skip_next)); + await tester.pump(); + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SizedBox && widget.height == (rowsPerPage - (rowCount % rowsPerPage)) * 46.0, + ), + findsOneWidget, + ); + + rowsPerPage = 8; + + await tester.pumpWidget(MaterialApp(home: buildTable(source, rowsPerPage))); + await tester.tap(find.byIcon(Icons.skip_previous)); + await tester.pump(); + + expect( + find.byWidgetPredicate((Widget widget) => widget is SizedBox && widget.height == 0), + findsOneWidget, + ); + await tester.tap(find.byIcon(Icons.skip_next)); + await tester.pump(); + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SizedBox && widget.height == (rowsPerPage - (rowCount % rowsPerPage)) * 46.0, + ), + findsOneWidget, + ); + }); + + testWidgets('PaginatedDataTable control test', (WidgetTester tester) async { + var source = TestDataSource()..generation = 42; + addTearDown(source.dispose); + + final log = <String>[]; + + Widget buildTable(TestDataSource source) { + return PaginatedDataTable( + header: const Text('Test table'), + source: source, + onPageChanged: (int rowIndex) { + log.add('page-changed: $rowIndex'); + }, + columns: <DataColumn>[ + const DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn( + label: const Text('Calories'), + tooltip: 'Calories', + numeric: true, + onSort: (int columnIndex, bool ascending) { + log.add('column-sort: $columnIndex $ascending'); + }, + ), + const DataColumn(label: Text('Generation'), tooltip: 'Generation'), + ], + actions: <Widget>[ + IconButton( + icon: const Icon(Icons.adjust), + onPressed: () { + log.add('action: adjust'); + }, + ), + ], + ); + } + + await tester.pumpWidget(MaterialApp(home: buildTable(source))); + + // the column overflows because we're forcing it to 600 pixels high + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + // ignore: avoid_dynamic_calls + expect(exception.diagnostics.first.level, DiagnosticLevel.summary); + // ignore: avoid_dynamic_calls + expect(exception.diagnostics.first.toString(), startsWith('A RenderFlex overflowed by ')); + + expect(find.text('Gingerbread (0)'), findsOneWidget); + expect(find.text('Gingerbread (1)'), findsNothing); + expect(find.text('42'), findsNWidgets(10)); + + source.generation = 43; + await tester.pump(); + + expect(find.text('42'), findsNothing); + expect(find.text('43'), findsNWidgets(10)); + + source = TestDataSource()..generation = 15; + addTearDown(source.dispose); + + await tester.pumpWidget(MaterialApp(home: buildTable(source))); + + expect(find.text('42'), findsNothing); + expect(find.text('43'), findsNothing); + expect(find.text('15'), findsNWidgets(10)); + + final PaginatedDataTableState state = tester.state(find.byType(PaginatedDataTable)); + + expect(log, isEmpty); + state.pageTo(23); + expect(log, <String>['page-changed: 20']); + log.clear(); + + await tester.pump(); + + expect(find.text('Gingerbread (0)'), findsNothing); + expect(find.text('Gingerbread (1)'), findsNothing); + expect(find.text('Gingerbread (2)'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.adjust)); + expect(log, <String>['action: adjust']); + log.clear(); + }); + + testWidgets('PaginatedDataTable text alignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: PaginatedDataTable( + header: const Text('HEADER'), + source: source, + rowsPerPage: 8, + availableRowsPerPage: const <int>[8, 9], + onRowsPerPageChanged: (int? rowsPerPage) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('COL1')), + DataColumn(label: Text('COL2')), + DataColumn(label: Text('COL3')), + ], + ), + ), + ); + expect(find.text('Rows per page:'), findsOneWidget); + expect(find.text('8'), findsOneWidget); + expect( + tester.getTopRight(find.text('8')).dx, + tester.getTopRight(find.text('Rows per page:')).dx + 40.0, + ); // per spec + }); + + testWidgets('PaginatedDataTable with and without header and actions', ( + WidgetTester tester, + ) async { + await binding.setSurfaceSize(const Size(800, 800)); + const headerText = 'HEADER'; + final actions = <Widget>[IconButton(onPressed: () {}, icon: const Icon(Icons.add))]; + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + + Widget buildTable({String? header, List<Widget>? actions}) => MaterialApp( + home: PaginatedDataTable( + header: header != null ? Text(header) : null, + actions: actions, + source: source, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ); + + await tester.pumpWidget(buildTable(header: headerText)); + expect(find.text(headerText), findsOneWidget); + expect(find.byIcon(Icons.add), findsNothing); + + await tester.pumpWidget(buildTable(header: headerText, actions: actions)); + expect(find.text(headerText), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + + await tester.pumpWidget(buildTable()); + expect(find.text(headerText), findsNothing); + expect(find.byIcon(Icons.add), findsNothing); + + expect(() => buildTable(actions: actions), throwsAssertionError); + + await binding.setSurfaceSize(null); + }); + + testWidgets('PaginatedDataTable with large text', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: 20.0, + maxScaleFactor: 20.0, + child: PaginatedDataTable( + header: const Text('HEADER'), + source: source, + rowsPerPage: 501, + availableRowsPerPage: const <int>[501], + onRowsPerPageChanged: (int? rowsPerPage) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('COL1')), + DataColumn(label: Text('COL2')), + DataColumn(label: Text('COL3')), + ], + ), + ), + ), + ); + // the column overflows because we're forcing it to 600 pixels high + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + // ignore: avoid_dynamic_calls + expect(exception.diagnostics.first.level, DiagnosticLevel.summary); + // ignore: avoid_dynamic_calls + expect(exception.diagnostics.first.toString(), contains('A RenderFlex overflowed by')); + + expect(find.text('Rows per page:'), findsOneWidget); + // Test that we will show some options in the drop down even if the lowest option is bigger than the source: + assert(501 > source.rowCount); + expect(find.text('501'), findsOneWidget); + // Test that it fits: + expect( + tester.getTopRight(find.text('501')).dx, + greaterThanOrEqualTo(tester.getTopRight(find.text('Rows per page:')).dx + 40.0), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/43433 + + testWidgets('PaginatedDataTable footer scrolls', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 100.0, + child: PaginatedDataTable( + header: const Text('HEADER'), + source: source, + rowsPerPage: 5, + dragStartBehavior: DragStartBehavior.down, + availableRowsPerPage: const <int>[5], + onRowsPerPageChanged: (int? rowsPerPage) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('COL1')), + DataColumn(label: Text('COL2')), + DataColumn(label: Text('COL3')), + ], + ), + ), + ), + ), + ); + expect(find.text('Rows per page:'), findsOneWidget); + expect(tester.getTopLeft(find.text('Rows per page:')).dx, lessThan(0.0)); // off screen + await tester.dragFrom( + Offset(50.0, tester.getTopLeft(find.text('Rows per page:')).dy), + const Offset(1000.0, 0.0), + ); + await tester.pumpAndSettle(); + expect(find.text('Rows per page:'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Rows per page:')).dx, + 18.0, + ); // 14 padding in the footer row, 4 padding from the card + }); + + testWidgets('PaginatedDataTable custom row height', (WidgetTester tester) async { + Widget buildCustomHeightPaginatedTable({ + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + double headingRowHeight = 56.0, + }) { + return PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4, 8, 16], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + dataRowHeight: dataRowHeight, + dataRowMinHeight: dataRowMinHeight, + dataRowMaxHeight: dataRowMaxHeight, + headingRowHeight: headingRowHeight, + ); + } + + // DEFAULT VALUES + await tester.pumpWidget( + MaterialApp( + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4, 8, 16], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ); + expect( + tester.renderObject<RenderBox>(find.widgetWithText(Container, 'Name').first).size.height, + 56.0, + ); // This is the header row height + expect( + tester + .renderObject<RenderBox>(find.widgetWithText(Container, 'Frozen yogurt (0)').first) + .size + .height, + 48.0, + ); // This is the data row height + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomHeightPaginatedTable(headingRowHeight: 48.0))), + ); + expect( + tester.renderObject<RenderBox>(find.widgetWithText(Container, 'Name').first).size.height, + 48.0, + ); + + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomHeightPaginatedTable(headingRowHeight: 64.0))), + ); + expect( + tester.renderObject<RenderBox>(find.widgetWithText(Container, 'Name').first).size.height, + 64.0, + ); + + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomHeightPaginatedTable(dataRowHeight: 30.0))), + ); + expect( + tester + .renderObject<RenderBox>(find.widgetWithText(Container, 'Frozen yogurt (0)').first) + .size + .height, + 30.0, + ); + + await tester.pumpWidget( + MaterialApp(home: Material(child: buildCustomHeightPaginatedTable(dataRowHeight: 56.0))), + ); + expect( + tester + .renderObject<RenderBox>(find.widgetWithText(Container, 'Frozen yogurt (0)').first) + .size + .height, + 56.0, + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: buildCustomHeightPaginatedTable(dataRowMinHeight: 51.0, dataRowMaxHeight: 51.0), + ), + ), + ); + expect( + tester + .renderObject<RenderBox>(find.widgetWithText(Container, 'Frozen yogurt (0)').first) + .size + .height, + 51.0, + ); + }); + + testWidgets('PaginatedDataTable custom horizontal padding - checkbox', ( + WidgetTester tester, + ) async { + const defaultHorizontalMargin = 24.0; + const defaultColumnSpacing = 56.0; + const customHorizontalMargin = 10.0; + const customColumnSpacing = 15.0; + + const double width = 400; + const double height = 400; + + final Size originalSize = binding.renderView.size; + + // Ensure the containing Card is small enough that we don't expand too + // much, resulting in our custom margin being ignored. + await binding.setSurfaceSize(const Size(width, height)); + + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + Finder cellContent; + Finder checkbox; + Finder padding; + + await tester.pumpWidget( + MaterialApp( + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + onSelectAll: (bool? value) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ); + + // default checkbox padding + checkbox = find.byType(Checkbox).first; + padding = find.ancestor(of: checkbox, matching: find.byType(Padding)).first; + expect(tester.getRect(checkbox).left - tester.getRect(padding).left, defaultHorizontalMargin); + expect( + tester.getRect(padding).right - tester.getRect(checkbox).right, + defaultHorizontalMargin / 2, + ); + + // default first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt (0)').first; + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt (0)', + ); // DataTable wraps its DataCells in an Align widget + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultHorizontalMargin / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default middle column padding + padding = find.widgetWithText(Padding, '159').first; + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default last column padding + padding = find.widgetWithText(Padding, '0').first; + cellContent = find.widgetWithText(Align, '0').first; + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultHorizontalMargin, + ); + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + onSelectAll: (bool? value) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + horizontalMargin: customHorizontalMargin, + columnSpacing: customColumnSpacing, + ), + ), + ), + ); + + // custom checkbox padding + checkbox = find.byType(Checkbox).first; + padding = find.ancestor(of: checkbox, matching: find.byType(Padding)).first; + expect(tester.getRect(checkbox).left - tester.getRect(padding).left, customHorizontalMargin); + expect( + tester.getRect(padding).right - tester.getRect(checkbox).right, + customHorizontalMargin / 2, + ); + + // custom first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt (0)').first; + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt (0)', + ); // DataTable wraps its DataCells in an Align widget + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customHorizontalMargin / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom middle column padding + padding = find.widgetWithText(Padding, '159').first; + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom last column padding + padding = find.widgetWithText(Padding, '0').first; + cellContent = find.widgetWithText(Align, '0').first; + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customHorizontalMargin, + ); + + // Reset the surface size. + await binding.setSurfaceSize(originalSize); + }); + + testWidgets('PaginatedDataTable custom horizontal padding - no checkbox', ( + WidgetTester tester, + ) async { + const defaultHorizontalMargin = 24.0; + const defaultColumnSpacing = 56.0; + const customHorizontalMargin = 10.0; + const customColumnSpacing = 15.0; + Finder cellContent; + Finder padding; + + await tester.pumpWidget( + MaterialApp( + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4, 8, 16], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ); + + // default first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt (0)').first; + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt (0)', + ); // DataTable wraps its DataCells in an Align widget + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultHorizontalMargin, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default middle column padding + padding = find.widgetWithText(Padding, '159').first; + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultColumnSpacing / 2, + ); + + // default last column padding + padding = find.widgetWithText(Padding, '0').first; + cellContent = find.widgetWithText(Align, '0').first; + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + defaultColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + defaultHorizontalMargin, + ); + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4, 8, 16], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + horizontalMargin: customHorizontalMargin, + columnSpacing: customColumnSpacing, + ), + ), + ), + ); + + // custom first column padding + padding = find.widgetWithText(Padding, 'Frozen yogurt (0)').first; + cellContent = find.widgetWithText(Align, 'Frozen yogurt (0)'); + expect(tester.getRect(cellContent).left - tester.getRect(padding).left, customHorizontalMargin); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom middle column padding + padding = find.widgetWithText(Padding, '159').first; + cellContent = find.widgetWithText(Align, '159'); + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customColumnSpacing / 2, + ); + + // custom last column padding + padding = find.widgetWithText(Padding, '0').first; + cellContent = find.widgetWithText(Align, '0').first; + expect( + tester.getRect(cellContent).left - tester.getRect(padding).left, + customColumnSpacing / 2, + ); + expect( + tester.getRect(padding).right - tester.getRect(cellContent).right, + customHorizontalMargin, + ); + }); + + testWidgets('PaginatedDataTable table fills Card width', (WidgetTester tester) async { + // 800 is wide enough to ensure that all of the columns fit in the + // Card. The test makes sure that the DataTable is exactly as wide + // as the Card, minus the Card's margin. + const double originalWidth = 800; + const double expandedWidth = 1600; + const double height = 400; + + // By default, the margin of a Card is 4 in all directions, so + // the size of the DataTable (inside the Card) is horizontally + // reduced by 4 * 2; the left and right margins. + const double cardMargin = 8; + + final Size originalSize = binding.renderView.size; + + Widget buildWidget() => MaterialApp( + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4, 8, 16], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ); + + await binding.setSurfaceSize(const Size(originalWidth, height)); + await tester.pumpWidget(buildWidget()); + + double cardWidth = tester.renderObject<RenderBox>(find.byType(Card).first).size.width; + + // Widths should be equal before we resize... + expect( + tester.renderObject<RenderBox>(find.byType(DataTable).first).size.width, + moreOrLessEquals(cardWidth - cardMargin), + ); + + await binding.setSurfaceSize(const Size(expandedWidth, height)); + await tester.pumpWidget(buildWidget()); + + cardWidth = tester.renderObject<RenderBox>(find.byType(Card).first).size.width; + + // ... and should still be equal after the resize. + expect( + tester.renderObject<RenderBox>(find.byType(DataTable).first).size.width, + moreOrLessEquals(cardWidth - cardMargin), + ); + + // Double check to ensure we actually resized the surface properly. + expect(cardWidth, moreOrLessEquals(expandedWidth)); + + // Reset the surface size. + await binding.setSurfaceSize(originalSize); + }); + + testWidgets('PaginatedDataTable with optional column checkbox', (WidgetTester tester) async { + await binding.setSurfaceSize(const Size(800, 800)); + addTearDown(() => binding.setSurfaceSize(null)); + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + + Widget buildTable(bool checkbox) => MaterialApp( + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + showCheckboxColumn: checkbox, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ); + + await tester.pumpWidget(buildTable(true)); + expect(find.byType(Checkbox), findsNWidgets(11)); + + await tester.pumpWidget(buildTable(false)); + expect(find.byType(Checkbox), findsNothing); + }); + + testWidgets('Table should not use decoration from DataTableTheme', (WidgetTester tester) async { + final Size originalSize = binding.renderView.size; + await binding.setSurfaceSize(const Size(800, 800)); + + Widget buildTable() { + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + + return MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData(decoration: BoxDecoration(color: Colors.white)), + ), + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ); + } + + await tester.pumpWidget(buildTable()); + final Finder tableContainerFinder = find + .ancestor(of: find.byType(Table), matching: find.byType(Container)) + .first; + expect(tester.widget<Container>(tableContainerFinder).decoration, const BoxDecoration()); + + // Reset the surface size. + await binding.setSurfaceSize(originalSize); + }); + + testWidgets('dataRowMinHeight & dataRowMaxHeight if not set will use DataTableTheme', ( + WidgetTester tester, + ) async { + addTearDown(() => binding.setSurfaceSize(null)); + await binding.setSurfaceSize(const Size(800, 800)); + + const minMaxDataRowHeight = 30.0; + + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + dataTableTheme: const DataTableThemeData( + dataRowMinHeight: minMaxDataRowHeight, + dataRowMaxHeight: minMaxDataRowHeight, + ), + ), + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ); + + final Container rowContainer = tester.widget<Container>( + find.descendant(of: find.byType(Table), matching: find.byType(Container)).last, + ); + expect(rowContainer.constraints?.minHeight, minMaxDataRowHeight); + expect(rowContainer.constraints?.maxHeight, minMaxDataRowHeight); + }); + + testWidgets('PaginatedDataTable custom checkboxHorizontalMargin properly applied', ( + WidgetTester tester, + ) async { + const customCheckboxHorizontalMargin = 15.0; + const customHorizontalMargin = 10.0; + + const double width = 400; + const double height = 400; + + final Size originalSize = binding.renderView.size; + + // Ensure the containing Card is small enough that we don't expand too + // much, resulting in our custom margin being ignored. + await binding.setSurfaceSize(const Size(width, height)); + + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + + Finder cellContent; + Finder checkbox; + Finder padding; + + // CUSTOM VALUES + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PaginatedDataTable( + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + availableRowsPerPage: const <int>[2, 4], + onRowsPerPageChanged: (int? rowsPerPage) {}, + onPageChanged: (int rowIndex) {}, + onSelectAll: (bool? value) {}, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + horizontalMargin: customHorizontalMargin, + checkboxHorizontalMargin: customCheckboxHorizontalMargin, + ), + ), + ), + ); + + // Custom checkbox padding. + checkbox = find.byType(Checkbox).first; + padding = find.ancestor(of: checkbox, matching: find.byType(Padding)).first; + expect( + tester.getRect(checkbox).left - tester.getRect(padding).left, + customCheckboxHorizontalMargin, + ); + expect( + tester.getRect(padding).right - tester.getRect(checkbox).right, + customCheckboxHorizontalMargin, + ); + + // Custom first column padding. + padding = find.widgetWithText(Padding, 'Frozen yogurt (0)').first; + cellContent = find.widgetWithText( + Align, + 'Frozen yogurt (0)', + ); // DataTable wraps its DataCells in an Align widget. + expect(tester.getRect(cellContent).left - tester.getRect(padding).left, customHorizontalMargin); + + // Reset the surface size. + await binding.setSurfaceSize(originalSize); + }); + + testWidgets('Items selected text uses secondary color', (WidgetTester tester) async { + const selectedTextColor = Color(0xff00ddff); + final ColorScheme colors = const ColorScheme.light().copyWith(secondary: selectedTextColor); + final theme = ThemeData.from(colorScheme: colors); + + final source = TestDataSource(allowSelection: true); + addTearDown(source.dispose); + + Widget buildTable() { + return MaterialApp( + theme: theme, + home: PaginatedDataTable( + header: const Text('Test table'), + source: source, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ); + } + + await binding.setSurfaceSize(const Size(800, 800)); + await tester.pumpWidget(buildTable()); + expect(find.text('Test table'), findsOneWidget); + + // Select a row with yogurt + await tester.tap(find.text('Frozen yogurt (0)')); + await tester.pumpAndSettle(); + + // The header should be replace with a selected text item + expect(find.text('Test table'), findsNothing); + expect(find.text('1 item selected'), findsOneWidget); + + // The color of the selected text item should be the colorScheme.secondary + final TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text('1 item selected')) + .text + .style!; + expect(selectedTextStyle.color, equals(selectedTextColor)); + + await binding.setSurfaceSize(null); + }); + + testWidgets('PaginatedDataTable arrowHeadColor set properly', (WidgetTester tester) async { + await binding.setSurfaceSize(const Size(800, 800)); + addTearDown(() => binding.setSurfaceSize(null)); + const arrowHeadColor = Color(0xFFE53935); + + await tester.pumpWidget( + MaterialApp( + home: PaginatedDataTable( + arrowHeadColor: arrowHeadColor, + showFirstLastButtons: true, + header: const Text('Test table'), + source: source, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ); + + final Iterable<IconButton> icons = tester.widgetList(find.byType(IconButton)); + + expect(icons.elementAt(0).color, arrowHeadColor); + expect(icons.elementAt(1).color, arrowHeadColor); + expect(icons.elementAt(2).color, arrowHeadColor); + expect(icons.elementAt(3).color, arrowHeadColor); + }); + + testWidgets('OverflowBar header left alignment', (WidgetTester tester) async { + // Test an old special case that tried to align the first child of a ButtonBar + // and the left edge of a Text header widget. Still possible with OverflowBar + // albeit without any special case in the implementation's build method. + Widget buildFrame(Widget header) { + return MaterialApp( + home: PaginatedDataTable( + header: header, + rowsPerPage: 2, + source: source, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ); + } + + await tester.pumpWidget(buildFrame(const Text('HEADER'))); + final double headerX = tester.getTopLeft(find.text('HEADER')).dx; + final Widget overflowBar = OverflowBar( + children: <Widget>[ElevatedButton(onPressed: () {}, child: const Text('BUTTON'))], + ); + await tester.pumpWidget(buildFrame(overflowBar)); + expect(headerX, tester.getTopLeft(find.byType(ElevatedButton)).dx); + }); + + testWidgets('PaginatedDataTable can be scrolled using ScrollController', ( + WidgetTester tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + Widget buildTable(TestDataSource source) { + return Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 100, + child: PaginatedDataTable( + controller: scrollController, + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + columns: const <DataColumn>[ + DataColumn(label: Text('Name'), tooltip: 'Name'), + DataColumn(label: Text('Calories'), tooltip: 'Calories', numeric: true), + DataColumn(label: Text('Generation'), tooltip: 'Generation'), + ], + ), + ), + ); + } + + await tester.pumpWidget(MaterialApp(home: buildTable(source))); + + // DataTable uses provided ScrollController + final Scrollable bodyScrollView = tester.widget(find.byType(Scrollable).first); + expect(bodyScrollView.controller, scrollController); + + expect(scrollController.offset, 0.0); + scrollController.jumpTo(50.0); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 50.0); + }); + + testWidgets('PaginatedDataTable uses PrimaryScrollController when primary ', ( + WidgetTester tester, + ) async { + final primaryScrollController = ScrollController(); + addTearDown(primaryScrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: PrimaryScrollController( + controller: primaryScrollController, + child: PaginatedDataTable( + primary: true, + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ), + ); + + // DataTable uses primaryScrollController + final Scrollable bodyScrollView = tester.widget(find.byType(Scrollable).first); + expect(bodyScrollView.controller, primaryScrollController); + + // Footer does not use primaryScrollController + final Scrollable footerScrollView = tester.widget(find.byType(Scrollable).last); + expect(footerScrollView.controller, null); + }); + + testWidgets('PaginatedDataTable custom heading row color', (WidgetTester tester) async { + const WidgetStateProperty<Color> headingRowColor = MaterialStatePropertyAll<Color>( + Color(0xffFF0000), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PaginatedDataTable( + primary: true, + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + headingRowColor: headingRowColor, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ), + ); + + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children[0]; + final tableRowBoxDecoration = tableRow.decoration! as BoxDecoration; + expect(tableRowBoxDecoration.color, headingRowColor.resolve(<WidgetState>{})); + }); + + testWidgets('PaginatedDataTable respects custom dividerThickness', (WidgetTester tester) async { + const dividerThickness = 2.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PaginatedDataTable( + primary: true, + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + dividerThickness: dividerThickness, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ), + ); + + expect(find.byType(Table), findsOneWidget); + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children[0]; + final tableRowBoxDecoration = tableRow.decoration as BoxDecoration?; + expect(tableRowBoxDecoration, isNotNull); + expect(tableRowBoxDecoration?.border, isA<Border>()); + + final border = tableRowBoxDecoration?.border as Border?; + expect(border?.bottom.width, dividerThickness); + }); + + testWidgets('PaginatedDataTable respects default dividerThickness', (WidgetTester tester) async { + const defaultDividerThickness = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PaginatedDataTable( + primary: true, + header: const Text('Test table'), + source: source, + rowsPerPage: 2, + columns: const <DataColumn>[ + DataColumn(label: Text('Name')), + DataColumn(label: Text('Calories'), numeric: true), + DataColumn(label: Text('Generation')), + ], + ), + ), + ), + ); + + expect(find.byType(Table), findsOneWidget); + final Table table = tester.widget(find.byType(Table)); + final TableRow tableRow = table.children[0]; + final tableRowBoxDecoration = tableRow.decoration as BoxDecoration?; + expect(tableRowBoxDecoration, isNotNull); + expect(tableRowBoxDecoration?.border, isA<Border>()); + + final border = tableRowBoxDecoration?.border as Border?; + expect(border?.bottom.width, defaultDividerThickness); + }); + + testWidgets('PaginatedDataTable does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: PaginatedDataTable( + columns: const <DataColumn>[ + DataColumn(label: Text('X')), + DataColumn(label: Text('Y')), + DataColumn(label: Text('Z')), + ], + source: source, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(PaginatedDataTable)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/persistent_bottom_sheet_test.dart b/packages/material_ui/test/material/persistent_bottom_sheet_test.dart new file mode 100644 index 000000000000..4b1d3b7740e4 --- /dev/null +++ b/packages/material_ui/test/material/persistent_bottom_sheet_test.dart @@ -0,0 +1,784 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Pumps and ensures that the BottomSheet animates non-linearly. + Future<void> checkNonLinearAnimation(WidgetTester tester) async { + final Offset firstPosition = tester.getCenter(find.text('One')); + await tester.pump(const Duration(milliseconds: 30)); + final Offset secondPosition = tester.getCenter(find.text('One')); + await tester.pump(const Duration(milliseconds: 30)); + final Offset thirdPosition = tester.getCenter(find.text('One')); + + final double dyDelta1 = secondPosition.dy - firstPosition.dy; + final double dyDelta2 = thirdPosition.dy - secondPosition.dy; + + // If the animation were linear, these two values would be the same. + expect(dyDelta1, isNot(moreOrLessEquals(dyDelta2, epsilon: 0.1))); + } + + testWidgets('Persistent draggableScrollableSheet localHistoryEntries test', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/110123 + Widget buildFrame(Widget? bottomSheet) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(), + body: const Center(child: Text('body')), + bottomSheet: bottomSheet, + floatingActionButton: const FloatingActionButton(onPressed: null, child: Text('fab')), + ), + ); + } + + final Widget draggableScrollableSheet = DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.3, + minChildSize: 0.3, + builder: (_, ScrollController controller) { + return ListView.builder( + itemExtent: 50.0, + itemCount: 50, + itemBuilder: (_, int index) => Text('Item $index'), + controller: controller, + ); + }, + ); + + await tester.pumpWidget(buildFrame(draggableScrollableSheet)); + await tester.pumpAndSettle(); + + expect(find.byType(BackButton).hitTestable(), findsNothing); + + await tester.drag(find.text('Item 2'), const Offset(0, -200.0)); + await tester.pumpAndSettle(); + // We've started to drag up, we should have a back button now for a11y + expect(find.byType(BackButton).hitTestable(), findsOneWidget); + + await tester.fling(find.text('Item 2'), const Offset(0, 200.0), 2000.0); + await tester.pumpAndSettle(); + // BackButton should be hidden + expect(find.byType(BackButton).hitTestable(), findsNothing); + + // Show the back button again + await tester.drag(find.text('Item 2'), const Offset(0, -200.0)); + await tester.pumpAndSettle(); + expect(find.byType(BackButton).hitTestable(), findsOneWidget); + + // Remove the draggableScrollableSheet should hide the back button + await tester.pumpWidget(buildFrame(null)); + expect(find.byType(BackButton).hitTestable(), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/83668 + testWidgets('Scaffold.bottomSheet update test', (WidgetTester tester) async { + Widget buildFrame(Widget? bottomSheet) { + return MaterialApp( + home: Scaffold(body: const Placeholder(), bottomSheet: bottomSheet), + ); + } + + await tester.pumpWidget(buildFrame(const Text('I love Flutter!'))); + await tester.pumpWidget(buildFrame(null)); + + // The disappearing animation has not yet been completed. + await tester.pumpWidget(buildFrame(const Text('I love Flutter!'))); + }); + + testWidgets( + 'Verify that a BottomSheet can be rebuilt with ScaffoldFeatureController.setState()', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + var buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + final PersistentBottomSheetController bottomSheet = scaffoldKey.currentState!.showBottomSheet( + (_) { + return Builder( + builder: (BuildContext context) { + buildCount += 1; + return Container(height: 200.0); + }, + ); + }, + ); + + await tester.pump(); + expect(buildCount, equals(1)); + bottomSheet.setState!(() {}); + await tester.pump(); + expect(buildCount, equals(2)); + }, + ); + + testWidgets('Verify that a persistent BottomSheet cannot be dismissed', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: const Center(child: Text('body')), + bottomSheet: DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return ListView( + controller: controller, + shrinkWrap: true, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + + await tester.drag(find.text('Two'), const Offset(0.0, 400.0)); + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + }); + + testWidgets('Verify that a scrollable BottomSheet can be dismissed', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return ListView( + shrinkWrap: true, + primary: false, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }); + + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + + await tester.drag(find.text('Two'), const Offset(0.0, 400.0)); + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsNothing); + }); + + testWidgets( + 'Verify DraggableScrollableSheet.shouldCloseOnMinExtent == false prevents dismissal', + (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return DraggableScrollableSheet( + expand: false, + shouldCloseOnMinExtent: false, + builder: (_, ScrollController controller) { + return ListView( + controller: controller, + shrinkWrap: true, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }, + ); + }); + + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + + await tester.drag(find.text('Two'), const Offset(0.0, 400.0)); + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + }, + ); + + testWidgets('Verify that a BottomSheet animates non-linearly', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return ListView( + shrinkWrap: true, + primary: false, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }); + await tester.pump(); + await checkNonLinearAnimation(tester); + + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + + await tester.drag(find.text('Two'), const Offset(0.0, 200.0)); + await checkNonLinearAnimation(tester); + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsNothing); + }); + + testWidgets('Verify that a scrollControlled BottomSheet can be dismissed', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return ListView( + shrinkWrap: true, + controller: controller, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }, + ); + }); + + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + + await tester.drag(find.text('Two'), const Offset(0.0, 400.0)); + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsNothing); + }); + + testWidgets('Verify that a persistent BottomSheet can fling up and hide the fab', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + body: const Center(child: Text('body')), + bottomSheet: DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return ListView.builder( + itemExtent: 50.0, + itemCount: 50, + itemBuilder: (_, int index) => Text('Item $index'), + controller: controller, + ); + }, + ), + floatingActionButton: const FloatingActionButton(onPressed: null, child: Text('fab')), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 22'), findsNothing); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(FloatingActionButton).hitTestable(), findsOneWidget); + expect(find.byType(BackButton).hitTestable(), findsNothing); + + await tester.drag(find.text('Item 2'), const Offset(0, -20.0)); + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 22'), findsNothing); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(FloatingActionButton).hitTestable(), findsOneWidget); + + await tester.fling(find.text('Item 2'), const Offset(0.0, -600.0), 2000.0); + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 22'), findsOneWidget); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(FloatingActionButton).hitTestable(), findsNothing); + }); + + testWidgets('Verify that a back button resets a persistent BottomSheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + body: const Center(child: Text('body')), + bottomSheet: DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return ListView.builder( + itemExtent: 50.0, + itemCount: 50, + itemBuilder: (_, int index) => Text('Item $index'), + controller: controller, + ); + }, + ), + floatingActionButton: const FloatingActionButton(onPressed: null, child: Text('fab')), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 22'), findsNothing); + expect(find.byType(BackButton).hitTestable(), findsNothing); + + await tester.drag(find.text('Item 2'), const Offset(0, -20.0)); + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 22'), findsNothing); + // We've started to drag up, we should have a back button now for a11y + expect(find.byType(BackButton).hitTestable(), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(BackButton).hitTestable(), findsNothing); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 22'), findsNothing); + + await tester.fling(find.text('Item 2'), const Offset(0.0, -600.0), 2000.0); + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 22'), findsOneWidget); + expect(find.byType(BackButton).hitTestable(), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(BackButton).hitTestable(), findsNothing); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Item 22'), findsNothing); + }); + + testWidgets('Verify that a scrollable BottomSheet hides the fab when scrolled up', ( + WidgetTester tester, + ) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + floatingActionButton: const FloatingActionButton(onPressed: null, child: Text('fab')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet((BuildContext context) { + return DraggableScrollableSheet( + expand: false, + builder: (_, ScrollController controller) { + return ListView( + controller: controller, + shrinkWrap: true, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }, + ); + }); + + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + expect(find.byType(FloatingActionButton).hitTestable(), findsOneWidget); + + await tester.drag(find.text('Two'), const Offset(0.0, -600.0)); + await tester.pumpAndSettle(); + + expect(find.text('Two'), findsOneWidget); + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(FloatingActionButton).hitTestable(), findsNothing); + }); + + testWidgets('showBottomSheet()', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Placeholder(key: key)), + ), + ); + + var buildCount = 0; + showBottomSheet( + context: key.currentContext!, + builder: (BuildContext context) { + return Builder( + builder: (BuildContext context) { + buildCount += 1; + return Container(height: 200.0); + }, + ); + }, + ); + await tester.pump(); + expect(buildCount, equals(1)); + }); + + testWidgets('Scaffold removes top MediaQuery padding', (WidgetTester tester) async { + late BuildContext scaffoldContext; + late BuildContext bottomSheetContext; + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(50.0)), + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Builder( + builder: (BuildContext context) { + scaffoldContext = context; + return Container(); + }, + ), + ), + ), + ), + ); + + await tester.pump(); + + showBottomSheet( + context: scaffoldContext, + builder: (BuildContext context) { + bottomSheetContext = context; + return Container(); + }, + ); + + await tester.pump(); + + expect( + MediaQuery.of(bottomSheetContext).padding, + const EdgeInsets.only(bottom: 50.0, left: 50.0, right: 50.0), + ); + }); + + testWidgets('Scaffold.bottomSheet', (WidgetTester tester) async { + final Key bottomSheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: const Placeholder(), + bottomSheet: Container( + key: bottomSheetKey, + alignment: Alignment.center, + height: 200.0, + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('showModalBottomSheet'), + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) => const Text('modal bottom sheet'), + ); + }, + ); + }, + ), + ), + ), + ), + ); + + expect(find.text('showModalBottomSheet'), findsOneWidget); + expect(tester.getSize(find.byKey(bottomSheetKey)), const Size(800.0, 200.0)); + expect(tester.getTopLeft(find.byKey(bottomSheetKey)), const Offset(0.0, 400.0)); + + // Show the modal bottomSheet + await tester.tap(find.text('showModalBottomSheet')); + await tester.pumpAndSettle(); + expect(find.text('modal bottom sheet'), findsOneWidget); + + // Dismiss the modal bottomSheet by tapping above the sheet + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + expect(find.text('modal bottom sheet'), findsNothing); + expect(find.text('showModalBottomSheet'), findsOneWidget); + + // Remove the persistent bottomSheet + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Placeholder()))); + await tester.pumpAndSettle(); + expect(find.text('showModalBottomSheet'), findsNothing); + expect(find.byKey(bottomSheetKey), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/71435 + testWidgets('Scaffold.bottomSheet should be updated without creating a new RO' + ' when the new widget has the same key and type.', (WidgetTester tester) async { + Widget buildFrame(String text) { + return MaterialApp( + home: Scaffold(body: const Placeholder(), bottomSheet: Text(text)), + ); + } + + await tester.pumpWidget(buildFrame('I love Flutter!')); + final RenderParagraph renderBeforeUpdate = tester.renderObject(find.text('I love Flutter!')); + + await tester.pumpWidget(buildFrame('Flutter is the best!')); + await tester.pumpAndSettle(); + final RenderParagraph renderAfterUpdate = tester.renderObject( + find.text('Flutter is the best!'), + ); + + expect(renderBeforeUpdate, renderAfterUpdate); + }); + + testWidgets('Verify that visual properties are passed through', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + const Color color = Colors.pink; + const elevation = 9.0; + const ShapeBorder shape = BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ); + const Clip clipBehavior = Clip.antiAlias; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + scaffoldKey.currentState!.showBottomSheet( + (BuildContext context) { + return ListView( + shrinkWrap: true, + primary: false, + children: const <Widget>[ + SizedBox(height: 100.0, child: Text('One')), + SizedBox(height: 100.0, child: Text('Two')), + SizedBox(height: 100.0, child: Text('Three')), + ], + ); + }, + backgroundColor: color, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + ); + + await tester.pumpAndSettle(); + + final BottomSheet bottomSheet = tester.widget(find.byType(BottomSheet)); + expect(bottomSheet.backgroundColor, color); + expect(bottomSheet.elevation, elevation); + expect(bottomSheet.shape, shape); + expect(bottomSheet.clipBehavior, clipBehavior); + }); + + testWidgets('PersistentBottomSheetController.close dismisses the bottom sheet', ( + WidgetTester tester, + ) async { + final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + final PersistentBottomSheetController bottomSheet = scaffoldKey.currentState!.showBottomSheet(( + _, + ) { + return Builder( + builder: (BuildContext context) { + return Container(height: 200.0); + }, + ); + }); + + await tester.pump(); + expect(find.byType(BottomSheet), findsOneWidget); + + bottomSheet.close(); + await tester.pump(); + expect(find.byType(BottomSheet), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/6451 + testWidgets( + 'Check back gesture with a persistent bottom sheet showing', + (WidgetTester tester) async { + final GlobalKey<ScaffoldState> containerKey1 = GlobalKey(); + final GlobalKey<PersistentBottomSheetTestState> containerKey2 = GlobalKey(); + final routes = <String, WidgetBuilder>{ + '/': (_) => Scaffold(key: containerKey1, body: const Text('Home')), + '/sheet': (_) => PersistentBottomSheetTest(key: containerKey2), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + Navigator.pushNamed(containerKey1.currentContext!, '/sheet'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Home'), findsNothing); + expect(find.text('Sheet'), isOnstage); + + // Drag from left edge to invoke the gesture. We should go back. + TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); + await gesture.moveBy(const Offset(500.0, 0.0)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + Navigator.pushNamed(containerKey1.currentContext!, '/sheet'); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Home'), findsNothing); + expect(find.text('Sheet'), isOnstage); + + // Show the bottom sheet. + final PersistentBottomSheetTestState sheet = containerKey2.currentState!; + sheet.showBottomSheet(); + + await tester.pump(const Duration(seconds: 1)); + + // Drag from left edge to invoke the gesture. Nothing should happen. + gesture = await tester.startGesture(const Offset(5.0, 100.0)); + await gesture.moveBy(const Offset(500.0, 0.0)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Home'), findsNothing); + expect(find.text('Sheet'), isOnstage); + + // Sheet did not call setState (since the gesture did nothing). + expect(sheet.setStateCalled, isFalse); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); +} + +class PersistentBottomSheetTest extends StatefulWidget { + const PersistentBottomSheetTest({super.key}); + + @override + PersistentBottomSheetTestState createState() => PersistentBottomSheetTestState(); +} + +class PersistentBottomSheetTestState extends State<PersistentBottomSheetTest> { + final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); + + bool setStateCalled = false; + + void showBottomSheet() { + _scaffoldKey.currentState! + .showBottomSheet((BuildContext context) { + return const Text('bottomSheet'); + }) + .closed + .whenComplete(() { + setState(() { + setStateCalled = true; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold(key: _scaffoldKey, body: const Text('Sheet')); + } +} diff --git a/packages/material_ui/test/material/popup_menu_test.dart b/packages/material_ui/test/material/popup_menu_test.dart new file mode 100644 index 000000000000..5cbf90970768 --- /dev/null +++ b/packages/material_ui/test/material/popup_menu_test.dart @@ -0,0 +1,5134 @@ +// 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:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('Navigator.push works within a PopupMenuButton', (WidgetTester tester) async { + final Key targetKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + routes: <String, WidgetBuilder>{ + '/next': (BuildContext context) { + return const Text('Next'); + }, + }, + home: Material( + child: Center( + child: Builder( + key: targetKey, + builder: (BuildContext context) { + return PopupMenuButton<int>( + onSelected: (int value) { + Navigator.pushNamed(context, '/next'); + }, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('One')), + ]; + }, + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the menu animation + + expect(find.text('One'), findsOneWidget); + expect(find.text('Next'), findsNothing); + + await tester.tap(find.text('One')); + await tester.pump(); // return the future + await tester.pump(); // start the navigation + await tester.pump(const Duration(seconds: 1)); // end the navigation + + expect(find.text('One'), findsNothing); + expect(find.text('Next'), findsOneWidget); + }); + + testWidgets('PopupMenuButton calls onOpened callback when the menu is opened', ( + WidgetTester tester, + ) async { + var opens = 0; + late BuildContext popupContext; + final Key noItemsKey = UniqueKey(); + final Key noCallbackKey = UniqueKey(); + final Key withCallbackKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: noItemsKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[]; + }, + onOpened: () => opens++, + ), + PopupMenuButton<int>( + key: noCallbackKey, + itemBuilder: (BuildContext context) { + popupContext = context; + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + ), + PopupMenuButton<int>( + key: withCallbackKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me, too!')), + ]; + }, + onOpened: () => opens++, + ), + ], + ), + ), + ), + ); + + // Make sure callback is not called when the menu is not shown + await tester.tap(find.byKey(noItemsKey)); + await tester.pump(); + expect(opens, equals(0)); + + // Make sure everything works if no callback is provided + await tester.tap(find.byKey(noCallbackKey)); + await tester.pump(); + expect(opens, equals(0)); + + // Close the opened menu + Navigator.of(popupContext).pop(); + await tester.pump(); + + // Make sure callback is called when the button is tapped + await tester.tap(find.byKey(withCallbackKey)); + await tester.pump(); + expect(opens, equals(1)); + }); + + testWidgets('PopupMenuButton calls onCanceled callback when an item is not selected', ( + WidgetTester tester, + ) async { + var cancels = 0; + late BuildContext popupContext; + final Key noCallbackKey = UniqueKey(); + final Key withCallbackKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: noCallbackKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + ), + PopupMenuButton<int>( + key: withCallbackKey, + onCanceled: () => cancels++, + itemBuilder: (BuildContext context) { + popupContext = context; + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me, too!')), + ]; + }, + ), + ], + ), + ), + ), + ); + + // Make sure everything works if no callback is provided + await tester.tap(find.byKey(noCallbackKey)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.tapAt(Offset.zero); + await tester.pump(); + expect(cancels, equals(0)); + + // Make sure callback is called when a non-selection tap occurs + await tester.tap(find.byKey(withCallbackKey)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.tapAt(Offset.zero); + await tester.pump(); + expect(cancels, equals(1)); + + // Make sure callback is called when back navigation occurs + await tester.tap(find.byKey(withCallbackKey)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + Navigator.of(popupContext).pop(); + await tester.pump(); + expect(cancels, equals(2)); + }); + + testWidgets( + 'Disabled PopupMenuButton will not call itemBuilder, onOpened, onSelected or onCanceled', + (WidgetTester tester) async { + final GlobalKey popupButtonKey = GlobalKey(); + var itemBuilderCalled = false; + var onOpenedCalled = false; + var onSelectedCalled = false; + var onCanceledCalled = false; + + Widget buildApp({bool directional = false}) { + return MaterialApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(navigationMode: NavigationMode.directional), + child: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + enabled: false, + child: Text('Tap Me', key: popupButtonKey), + itemBuilder: (BuildContext context) { + itemBuilderCalled = true; + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + onOpened: () => onOpenedCalled = true, + onSelected: (int selected) => onSelectedCalled = true, + onCanceled: () => onCanceledCalled = true, + ), + ], + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Try to bring up the popup menu and select the first item from it + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isFalse); + expect(onOpenedCalled, isFalse); + expect(onSelectedCalled, isFalse); + + // Try to bring up the popup menu and tap outside it to cancel the menu + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isFalse); + expect(onOpenedCalled, isFalse); + expect(onCanceledCalled, isFalse); + + // Test again, with directional navigation mode and after focusing the button. + await tester.pumpWidget(buildApp(directional: true)); + + // Try to bring up the popup menu and select the first item from it + Focus.of(popupButtonKey.currentContext!).requestFocus(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isFalse); + expect(onOpenedCalled, isFalse); + expect(onSelectedCalled, isFalse); + + // Try to bring up the popup menu and tap outside it to cancel the menu + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isFalse); + expect(onOpenedCalled, isFalse); + expect(onCanceledCalled, isFalse); + }, + ); + + testWidgets('disabled PopupMenuButton is not focusable', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + final GlobalKey childKey = GlobalKey(); + var itemBuilderCalled = false; + var onOpenedCalled = false; + var onSelectedCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: popupButtonKey, + enabled: false, + child: Container(key: childKey), + itemBuilder: (BuildContext context) { + itemBuilderCalled = true; + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + onOpened: () => onOpenedCalled = true, + onSelected: (int selected) => onSelectedCalled = true, + ), + ], + ), + ), + ), + ); + Focus.of(childKey.currentContext!).requestFocus(); + await tester.pump(); + + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + expect(itemBuilderCalled, isFalse); + expect(onOpenedCalled, isFalse); + expect(onSelectedCalled, isFalse); + }); + + testWidgets('Disabled PopupMenuButton is focusable with directional navigation', ( + WidgetTester tester, + ) async { + final Key popupButtonKey = UniqueKey(); + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(navigationMode: NavigationMode.directional), + child: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: popupButtonKey, + enabled: false, + child: Container(key: childKey), + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + onSelected: (int selected) {}, + ), + ], + ), + ), + ); + }, + ), + ), + ); + Focus.of(childKey.currentContext!).requestFocus(); + await tester.pump(); + + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + }); + + testWidgets('PopupMenuItem onTap callback is called when defined', (WidgetTester tester) async { + final menuItemTapCounters = <int>[0, 0]; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: RepaintBoundary( + child: PopupMenuButton<void>( + child: const Text('Actions'), + itemBuilder: (BuildContext context) => <PopupMenuItem<void>>[ + PopupMenuItem<void>( + child: const Text('First option'), + onTap: () { + menuItemTapCounters[0] += 1; + }, + ), + PopupMenuItem<void>( + child: const Text('Second option'), + onTap: () { + menuItemTapCounters[1] += 1; + }, + ), + const PopupMenuItem<void>(child: Text('Option without onTap')), + ], + ), + ), + ), + ), + ); + + // Tap the first time + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('First option')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[1, 0]); + + // Tap the item again + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('First option')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[2, 0]); + + // Tap a different item + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Second option')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[2, 1]); + + // Tap an item without onTap + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Option without onTap')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[2, 1]); + }); + + testWidgets('PopupMenuItem can have both onTap and value', (WidgetTester tester) async { + final menuItemTapCounters = <int>[0, 0]; + String? selected; + + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: RepaintBoundary( + child: PopupMenuButton<String>( + child: const Text('Actions'), + onSelected: (String value) { + selected = value; + }, + itemBuilder: (BuildContext context) => <PopupMenuItem<String>>[ + PopupMenuItem<String>( + value: 'first', + child: const Text('First option'), + onTap: () { + menuItemTapCounters[0] += 1; + }, + ), + PopupMenuItem<String>( + value: 'second', + child: const Text('Second option'), + onTap: () { + menuItemTapCounters[1] += 1; + }, + ), + const PopupMenuItem<String>(value: 'third', child: Text('Option without onTap')), + ], + ), + ), + ), + ), + ); + + // Tap the first item + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('First option')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[1, 0]); + expect(selected, 'first'); + + // Tap the item again + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('First option')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[2, 0]); + expect(selected, 'first'); + + // Tap a different item + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Second option')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[2, 1]); + expect(selected, 'second'); + + // Tap an item without onTap + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Option without onTap')); + await tester.pumpAndSettle(); + expect(menuItemTapCounters, <int>[2, 1]); + expect(selected, 'third'); + }); + + testWidgets('PopupMenuItem is only focusable when enabled', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + final GlobalKey childKey = GlobalKey(); + var itemBuilderCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: popupButtonKey, + itemBuilder: (BuildContext context) { + itemBuilderCalled = true; + return <PopupMenuEntry<int>>[ + PopupMenuItem<int>(value: 1, child: Text('Tap me please!', key: childKey)), + ]; + }, + ), + ], + ), + ), + ), + ); + + // Open the popup to build and show the menu contents. + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + final FocusNode childNode = Focus.of(childKey.currentContext!); + // Now that the contents are shown, request focus on the child text. + childNode.requestFocus(); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isTrue); + + // Make sure that the focus went where we expected it to. + expect(childNode.hasPrimaryFocus, isTrue); + itemBuilderCalled = false; + + // Close the popup. + await tester.tap(find.byKey(popupButtonKey), warnIfMissed: false); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: popupButtonKey, + itemBuilder: (BuildContext context) { + itemBuilderCalled = true; + return <PopupMenuEntry<int>>[ + PopupMenuItem<int>( + enabled: false, + value: 1, + child: Text('Tap me please!', key: childKey), + ), + ]; + }, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Open the popup again to rebuild the contents with enabled == false. + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + expect(itemBuilderCalled, isTrue); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + }); + + testWidgets('PopupMenuButton is horizontal on iOS', (WidgetTester tester) async { + Widget build(TargetPlatform platform) { + debugDefaultTargetPlatformOverride = platform; + return MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('One')), + ]; + }, + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(build(TargetPlatform.android)); + + expect(find.byIcon(Icons.more_vert), findsOneWidget); + expect(find.byIcon(Icons.more_horiz), findsNothing); + + await tester.pumpWidget(build(TargetPlatform.iOS)); + await tester.pumpAndSettle(); // Run theme change animation. + + expect(find.byIcon(Icons.more_vert), findsNothing); + expect(find.byIcon(Icons.more_horiz), findsOneWidget); + + await tester.pumpWidget(build(TargetPlatform.macOS)); + await tester.pumpAndSettle(); // Run theme change animation. + + expect(find.byIcon(Icons.more_vert), findsNothing); + expect(find.byIcon(Icons.more_horiz), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + + group('PopupMenuButton with Icon', () { + // Helper function to create simple and valid popup menus. + List<PopupMenuItem<int>> simplePopupMenuItemBuilder(BuildContext context) { + return <PopupMenuItem<int>>[const PopupMenuItem<int>(value: 1, child: Text('1'))]; + } + + testWidgets('PopupMenuButton fails when given both child and icon', ( + WidgetTester tester, + ) async { + expect(() { + PopupMenuButton<int>( + icon: const Icon(Icons.view_carousel), + itemBuilder: simplePopupMenuItemBuilder, + child: const Text('heyo'), + ); + }, throwsAssertionError); + }); + + testWidgets('PopupMenuButton creates IconButton when given an icon', ( + WidgetTester tester, + ) async { + final button = PopupMenuButton<int>( + icon: const Icon(Icons.view_carousel), + itemBuilder: simplePopupMenuItemBuilder, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(appBar: AppBar(actions: <Widget>[button])), + ), + ); + + expect(find.byType(IconButton), findsOneWidget); + expect(find.byIcon(Icons.view_carousel), findsOneWidget); + }); + }); + + testWidgets('PopupMenu positioning', (WidgetTester tester) async { + final Widget testButton = PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('AAA')), + const PopupMenuItem<int>(value: 2, child: Text('BBB')), + const PopupMenuItem<int>(value: 3, child: Text('CCC')), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ); + + bool popupMenu(Widget widget) => widget.runtimeType.toString() == '_PopupMenu<int?>'; + + Future<void> openMenu(TextDirection textDirection, Alignment alignment) async { + return TestAsyncUtils.guard<void>(() async { + await tester.pumpWidget(Container()); // reset in case we had a menu up already + await tester.pumpWidget( + TestApp( + textDirection: textDirection, + child: Align(alignment: alignment, child: testButton), + ), + ); + await tester.tap(find.text('XXX')); + await tester.pump(); + }); + } + + Future<void> testPositioningDown( + WidgetTester tester, + TextDirection textDirection, + Alignment alignment, + TextDirection growthDirection, + Rect startRect, + ) { + return TestAsyncUtils.guard<void>(() async { + await openMenu(textDirection, alignment); + Rect rect = tester.getRect(find.byWidgetPredicate(popupMenu)); + expect(rect, startRect); + var doneVertically = false; + var doneHorizontally = false; + do { + await tester.pump(const Duration(milliseconds: 20)); + final Rect newRect = tester.getRect(find.byWidgetPredicate(popupMenu)); + expect(newRect.top, rect.top); + if (doneVertically) { + expect(newRect.bottom, rect.bottom); + } else { + if (newRect.bottom == rect.bottom) { + doneVertically = true; + } else { + expect(newRect.bottom, greaterThan(rect.bottom)); + } + } + switch (growthDirection) { + case TextDirection.rtl: + expect(newRect.right, rect.right); + if (doneHorizontally) { + expect(newRect.left, rect.left); + } else { + if (newRect.left == rect.left) { + doneHorizontally = true; + } else { + expect(newRect.left, lessThan(rect.left)); + } + } + case TextDirection.ltr: + expect(newRect.left, rect.left); + if (doneHorizontally) { + expect(newRect.right, rect.right); + } else { + if (newRect.right == rect.right) { + doneHorizontally = true; + } else { + expect(newRect.right, greaterThan(rect.right)); + } + } + } + rect = newRect; + } while (tester.binding.hasScheduledFrame); + }); + } + + Future<void> testPositioningDownThenUp( + WidgetTester tester, + TextDirection textDirection, + Alignment alignment, + TextDirection growthDirection, + Rect startRect, + ) { + return TestAsyncUtils.guard<void>(() async { + await openMenu(textDirection, alignment); + Rect rect = tester.getRect(find.byWidgetPredicate(popupMenu)); + expect(rect, startRect); + var verticalStage = 0; // 0=down, 1=up, 2=done + var doneHorizontally = false; + do { + await tester.pump(const Duration(milliseconds: 20)); + final Rect newRect = tester.getRect(find.byWidgetPredicate(popupMenu)); + switch (verticalStage) { + case 0: + if (newRect.top < rect.top) { + verticalStage = 1; + expect(newRect.bottom, greaterThanOrEqualTo(rect.bottom)); + break; + } + expect(newRect.top, rect.top); + expect(newRect.bottom, greaterThan(rect.bottom)); + case 1: + if (newRect.top == rect.top) { + verticalStage = 2; + expect(newRect.bottom, rect.bottom); + break; + } + expect(newRect.top, lessThan(rect.top)); + expect(newRect.bottom, rect.bottom); + case 2: + expect(newRect.bottom, rect.bottom); + expect(newRect.top, rect.top); + default: + assert(false); + } + switch (growthDirection) { + case TextDirection.rtl: + expect(newRect.right, rect.right); + if (doneHorizontally) { + expect(newRect.left, rect.left); + } else { + if (newRect.left == rect.left) { + doneHorizontally = true; + } else { + expect(newRect.left, lessThan(rect.left)); + } + } + case TextDirection.ltr: + expect(newRect.left, rect.left); + if (doneHorizontally) { + expect(newRect.right, rect.right); + } else { + if (newRect.right == rect.right) { + doneHorizontally = true; + } else { + expect(newRect.right, greaterThan(rect.right)); + } + } + } + rect = newRect; + } while (tester.binding.hasScheduledFrame); + }); + } + + await testPositioningDown( + tester, + TextDirection.ltr, + Alignment.topRight, + TextDirection.rtl, + const Rect.fromLTWH(792.0, 8.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.rtl, + Alignment.topRight, + TextDirection.rtl, + const Rect.fromLTWH(792.0, 8.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.ltr, + Alignment.topLeft, + TextDirection.ltr, + const Rect.fromLTWH(8.0, 8.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.rtl, + Alignment.topLeft, + TextDirection.ltr, + const Rect.fromLTWH(8.0, 8.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.ltr, + Alignment.topCenter, + TextDirection.ltr, + const Rect.fromLTWH(350.0, 8.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.rtl, + Alignment.topCenter, + TextDirection.rtl, + const Rect.fromLTWH(450.0, 8.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.ltr, + Alignment.centerRight, + TextDirection.rtl, + const Rect.fromLTWH(792.0, 250.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.rtl, + Alignment.centerRight, + TextDirection.rtl, + const Rect.fromLTWH(792.0, 250.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.ltr, + Alignment.centerLeft, + TextDirection.ltr, + const Rect.fromLTWH(8.0, 250.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.rtl, + Alignment.centerLeft, + TextDirection.ltr, + const Rect.fromLTWH(8.0, 250.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.ltr, + Alignment.center, + TextDirection.ltr, + const Rect.fromLTWH(350.0, 250.0, 0.0, 0.0), + ); + await testPositioningDown( + tester, + TextDirection.rtl, + Alignment.center, + TextDirection.rtl, + const Rect.fromLTWH(450.0, 250.0, 0.0, 0.0), + ); + await testPositioningDownThenUp( + tester, + TextDirection.ltr, + Alignment.bottomRight, + TextDirection.rtl, + const Rect.fromLTWH(792.0, 500.0, 0.0, 0.0), + ); + await testPositioningDownThenUp( + tester, + TextDirection.rtl, + Alignment.bottomRight, + TextDirection.rtl, + const Rect.fromLTWH(792.0, 500.0, 0.0, 0.0), + ); + await testPositioningDownThenUp( + tester, + TextDirection.ltr, + Alignment.bottomLeft, + TextDirection.ltr, + const Rect.fromLTWH(8.0, 500.0, 0.0, 0.0), + ); + await testPositioningDownThenUp( + tester, + TextDirection.rtl, + Alignment.bottomLeft, + TextDirection.ltr, + const Rect.fromLTWH(8.0, 500.0, 0.0, 0.0), + ); + await testPositioningDownThenUp( + tester, + TextDirection.ltr, + Alignment.bottomCenter, + TextDirection.ltr, + const Rect.fromLTWH(350.0, 500.0, 0.0, 0.0), + ); + await testPositioningDownThenUp( + tester, + TextDirection.rtl, + Alignment.bottomCenter, + TextDirection.rtl, + const Rect.fromLTWH(450.0, 500.0, 0.0, 0.0), + ); + }); + + testWidgets('PopupMenu positioning inside nested Overlay', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Example')), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (_) => Center( + child: PopupMenuButton<int>( + key: buttonKey, + itemBuilder: (_) => <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Item 1')), + const PopupMenuItem<int>(value: 2, child: Text('Item 2')), + ], + child: const Text('Show Menu'), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + final Finder popupFinder = find.bySemanticsLabel('Popup menu'); + await tester.tap(buttonFinder); + await tester.pumpAndSettle(); + + final Offset buttonTopLeft = tester.getTopLeft(buttonFinder); + expect(tester.getTopLeft(popupFinder), buttonTopLeft); + }); + + testWidgets('PopupMenu positioning inside nested Navigator', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Example')), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: PopupMenuButton<int>( + key: buttonKey, + itemBuilder: (_) => <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Item 1')), + const PopupMenuItem<int>(value: 2, child: Text('Item 2')), + ], + child: const Text('Show Menu'), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + final Finder popupFinder = find.bySemanticsLabel('Popup menu'); + await tester.tap(buttonFinder); + await tester.pumpAndSettle(); + + final Offset buttonTopLeft = tester.getTopLeft(buttonFinder); + expect(tester.getTopLeft(popupFinder), buttonTopLeft); + }); + + testWidgets('PopupMenu positioning inside nested Navigator when useRootNavigator', ( + WidgetTester tester, + ) async { + final Key buttonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Example')), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: PopupMenuButton<int>( + key: buttonKey, + useRootNavigator: true, + itemBuilder: (_) => <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Item 1')), + const PopupMenuItem<int>(value: 2, child: Text('Item 2')), + ], + child: const Text('Show Menu'), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + final Finder popupFinder = find.bySemanticsLabel('Popup menu'); + await tester.tap(buttonFinder); + await tester.pumpAndSettle(); + + final Offset buttonTopLeft = tester.getTopLeft(buttonFinder); + expect(tester.getTopLeft(popupFinder), buttonTopLeft); + }); + + testWidgets('Popup menu with RouteSettings', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + const popupRoute = RouteSettings(name: '/popup'); + late RouteSettings currentRouteSetting; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[ + _ClosureNavigatorObserver( + onDidChange: (Route<dynamic> newRoute) { + currentRouteSetting = newRoute.settings; + }, + ), + ], + home: Scaffold( + body: PopupMenuButton<int>( + key: buttonKey, + routeSettings: popupRoute, + itemBuilder: (_) => <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Item 1')), + const PopupMenuItem<int>(value: 2, child: Text('Item 2')), + ], + child: const Text('Show Menu'), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + await tester.tap(buttonFinder); + await tester.pumpAndSettle(); + + expect(currentRouteSetting, popupRoute); + }); + + testWidgets('PopupMenu positioning around display features', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + // A 20-pixel wide vertical display feature, similar to a foldable + // with a visible hinge. Splits the display into two "virtual screens" + // and the popup menu should never overlap the display feature. + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.cutout, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Scaffold( + body: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return Padding( + // Position the button in the top-right of the first "virtual screen" + padding: const EdgeInsets.only(right: 390.0), + child: Align( + alignment: Alignment.topRight, + child: PopupMenuButton<int>( + key: buttonKey, + itemBuilder: (_) => <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Item 1')), + const PopupMenuItem<int>(value: 2, child: Text('Item 2')), + ], + child: const Text('Show Menu'), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.byKey(buttonKey); + final Finder popupFinder = find.bySemanticsLabel('Popup menu'); + await tester.tap(buttonFinder); + await tester.pumpAndSettle(); + + // Since the display feature splits the display into 2 sub-screens, popup + // menu should be positioned to fit in the first virtual screen, where the + // originating button is. + // The 8 pixels is [_kMenuScreenPadding]. + expect(tester.getTopRight(popupFinder), const Offset(390 - 8, 8)); + }); + + testWidgets('PopupMenu removes MediaQuery padding', (WidgetTester tester) async { + late BuildContext popupContext; + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(50.0)), + child: Material( + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + popupContext = context; + return <PopupMenuItem<int>>[ + PopupMenuItem<int>( + value: 1, + child: Builder( + builder: (BuildContext context) { + popupContext = context; + return const Text('AAA'); + }, + ), + ), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ), + ), + ), + ), + ); + + await tester.tap(find.text('XXX')); + + await tester.pump(); + + expect(MediaQuery.of(popupContext).padding, EdgeInsets.zero); + }); + + testWidgets('Popup Menu Offset Test', (WidgetTester tester) async { + PopupMenuButton<int> buildMenuButton({Offset offset = Offset.zero}) { + return PopupMenuButton<int>( + offset: offset, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + PopupMenuItem<int>( + value: 1, + child: Builder( + builder: (BuildContext context) { + return const Text('AAA'); + }, + ), + ), + ]; + }, + ); + } + + // Popup a menu without any offset. + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Material(child: buildMenuButton())), + ), + ); + + // Popup the menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + // Initial state, the menu start at Offset(8.0, 8.0), the 8 pixels is edge padding when offset.dx < 8.0. + expect( + tester.getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PopupMenu<int?>'), + ), + const Offset(8.0, 8.0), + ); + + // Collapse the menu. + await tester.tap(find.byType(IconButton), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Popup a new menu with Offset(50.0, 50.0). + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material(child: buildMenuButton(offset: const Offset(50.0, 50.0))), + ), + ), + ); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + // This time the menu should start at Offset(50.0, 50.0), the padding only added when offset.dx < 8.0. + expect( + tester.getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PopupMenu<int?>'), + ), + const Offset(50.0, 50.0), + ); + }); + + testWidgets('Opened PopupMenu has correct semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('1')), + const PopupMenuItem<int>(value: 2, child: Text('2')), + const PopupMenuItem<int>(value: 3, child: Text('3')), + const PopupMenuItem<int>(value: 4, child: Text('4')), + const PopupMenuItem<int>(value: 5, child: Text('5')), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ), + ), + ), + ); + await tester.tap(find.text('XXX')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menu, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: 'Popup menu', + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '1', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '3', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '5', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss menu', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('PopupMenuButton Semantics expanded state updates when menu opens and closes', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/183432 + const key = Key('test'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PopupMenuButton<int>( + key: key, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Item 1')), + const PopupMenuItem<int>(value: 2, child: Text('Item 2')), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ), + ), + ), + ); + + // Before opening: should have expanded state but not be expanded. + expect( + tester.getSemantics(find.byType(PopupMenuButton<int>)), + matchesSemantics( + hasExpandedState: true, + label: 'XXX', + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + + // Open the menu. + await tester.tap(find.text('XXX')); + await tester.pumpAndSettle(); + + // While the menu is open, BlockSemantics in ModalBarrier blocks the + // button's semantics node (making tester.getSemantics return a stale node). + // Verify the Semantics widget's expanded property directly instead. + final Semantics expandedSemantics = tester.widget<Semantics>( + find.descendant( + of: find.byKey(key), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && widget.properties.expanded != null, + ), + ), + ); + expect(expandedSemantics.properties.expanded, isTrue); + + // Close the menu by selecting an item. + await tester.tap(find.text('Item 1').last); + await tester.pumpAndSettle(); + + // After closing: should have expanded state but not be expanded. + expect( + tester.getSemantics(find.byType(PopupMenuButton<int>)), + matchesSemantics( + hasExpandedState: true, + label: 'XXX', + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + }); + + testWidgets('PopupMenuItem merges the semantics of its descendants', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + PopupMenuItem<int>( + value: 1, + child: Row( + children: <Widget>[ + Semantics(child: const Text('test1')), + Semantics(child: const Text('test2')), + ], + ), + ), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ), + ), + ), + ); + await tester.tap(find.text('XXX')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menu, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: 'Popup menu', + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'test1\ntest2', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss menu', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Disabled PopupMenuItem has correct semantics', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/45044. + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('1')), + const PopupMenuItem<int>(value: 2, enabled: false, child: Text('2')), + const PopupMenuItem<int>(value: 3, child: Text('3')), + const PopupMenuItem<int>(value: 4, child: Text('4')), + const PopupMenuItem<int>(value: 5, child: Text('5')), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ), + ), + ), + ); + await tester.tap(find.text('XXX')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menu, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: 'Popup menu', + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '1', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + ], + actions: <SemanticsAction>[], + label: '2', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '3', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + role: SemanticsRole.menuItem, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: '5', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss menu', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('CheckedPopupMenuItem has correct semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const CheckedPopupMenuItem<int>( + value: 1, + checked: true, + child: Text('Checked Item'), + ), + const CheckedPopupMenuItem<int>(value: 2, child: Text('Unchecked Item')), + ]; + }, + child: const SizedBox(height: 100.0, width: 100.0, child: Text('XXX')), + ), + ), + ), + ); + await tester.tap(find.text('XXX')); + await tester.pumpAndSettle(); + + // Verify that CheckedPopupMenuItem uses SemanticsRole.menuItemCheckbox + final Iterable<SemanticsNode> allNodes = semantics.nodesWith(); + final List<SemanticsNode> menuItemNodes = allNodes + .where( + (SemanticsNode node) => node.getSemanticsData().role == SemanticsRole.menuItemCheckbox, + ) + .toList(); + expect(menuItemNodes, hasLength(2)); + + // Verify that the checked item has the correct properties + final SemanticsNode checkedNode = menuItemNodes.firstWhere( + (SemanticsNode node) => node.getSemanticsData().hasFlag(SemanticsFlag.isChecked), + ); + expect(checkedNode.getSemanticsData().role, SemanticsRole.menuItemCheckbox); + expect(checkedNode.getSemanticsData().hasFlag(SemanticsFlag.isButton), isTrue); + expect(checkedNode.getSemanticsData().hasFlag(SemanticsFlag.hasCheckedState), isTrue); + + // Verify that the unchecked item has the correct properties + final SemanticsNode uncheckedNode = menuItemNodes.firstWhere( + (SemanticsNode node) => !node.getSemanticsData().hasFlag(SemanticsFlag.isChecked), + ); + expect(uncheckedNode.getSemanticsData().role, SemanticsRole.menuItemCheckbox); + expect(uncheckedNode.getSemanticsData().hasFlag(SemanticsFlag.isButton), isTrue); + expect(uncheckedNode.getSemanticsData().hasFlag(SemanticsFlag.hasCheckedState), isTrue); + expect(uncheckedNode.getSemanticsData().hasFlag(SemanticsFlag.isChecked), isFalse); + + semantics.dispose(); + }); + + testWidgets('PopupMenuButton PopupMenuDivider', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/27072 + + late String selectedValue; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + onSelected: (String result) { + selectedValue = result; + }, + initialValue: '1', + child: const Text('Menu Button'), + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: '1', child: Text('1')), + const PopupMenuDivider(), + const PopupMenuItem<String>(value: '2', child: Text('2')), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Menu Button')); + await tester.pumpAndSettle(); + expect(find.text('1'), findsOneWidget); + expect(find.byType(PopupMenuDivider), findsOneWidget); + expect(find.text('2'), findsOneWidget); + + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(selectedValue, '1'); + + await tester.tap(find.text('Menu Button')); + await tester.pumpAndSettle(); + expect(find.text('1'), findsOneWidget); + expect(find.byType(PopupMenuDivider), findsOneWidget); + expect(find.text('2'), findsOneWidget); + + await tester.tap(find.text('2')); + await tester.pumpAndSettle(); + expect(selectedValue, '2'); + }); + + testWidgets('PopupMenuItem child height is a minimum, child is vertically centered', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + final Type menuItemType = const PopupMenuItem<String>(child: Text('item')).runtimeType; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + // This menu item's height will be 48 because the default minimum height + // is 48 and the height of the text is less than 48. + const PopupMenuItem<String>(value: '0', child: Text('Item 0')), + // This menu item's height parameter specifies its minimum height. The + // overall height of the menu item will be 50 because the child's + // height 40, is less than 50. + const PopupMenuItem<String>( + height: 50, + value: '1', + child: SizedBox(height: 40, child: Text('Item 1')), + ), + // This menu item's height parameter specifies its minimum height, so the + // overall height of the menu item will be 75. + const PopupMenuItem<String>( + height: 75, + value: '2', + child: SizedBox(child: Text('Item 2')), + ), + // This menu item's height will be 100. + const PopupMenuItem<String>( + value: '3', + child: SizedBox(height: 100, child: Text('Item 3')), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // The menu items and their InkWells should have the expected vertical size + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, 48); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, 50); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 2')).height, 75); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 3')).height, 100); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 0')).height, 48); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 1')).height, 50); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 2')).height, 75); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 3')).height, 100); + + // Menu item children which whose height is less than the PopupMenuItem + // are vertically centered. + expect( + tester.getRect(find.widgetWithText(menuItemType, 'Item 0')).center.dy, + tester.getRect(find.text('Item 0')).center.dy, + ); + expect( + tester.getRect(find.widgetWithText(menuItemType, 'Item 2')).center.dy, + tester.getRect(find.text('Item 2')).center.dy, + ); + }); + + testWidgets('Material3 - PopupMenuItem default padding', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: '0', enabled: false, child: Text('Item 0')), + const PopupMenuItem<String>(value: '1', child: Text('Item 1')), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + EdgeInsetsGeometry paddingFor(String text) { + return tester.widget<Padding>(find.widgetWithText(Padding, 'Item 0').first).padding; + } + + expect(paddingFor('Item 0'), const EdgeInsets.symmetric(horizontal: 12.0)); + expect(paddingFor('Item 1'), const EdgeInsets.symmetric(horizontal: 12.0)); + }); + + testWidgets('PopupMenu default padding', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: '0', enabled: false, child: Text('Item 0')), + const PopupMenuItem<String>(value: '1', child: Text('Item 1')), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pump(const Duration(milliseconds: 300)); + + // Check popup menu padding. + final SingleChildScrollView popupMenu = tester.widget<SingleChildScrollView>( + find.byType(SingleChildScrollView), + ); + expect(popupMenu.padding, const EdgeInsets.symmetric(vertical: 8.0)); + }); + + testWidgets('Material2 - PopupMenuItem default padding', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: '0', enabled: false, child: Text('Item 0')), + const PopupMenuItem<String>(value: '1', child: Text('Item 1')), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + EdgeInsetsGeometry paddingFor(String text) { + return tester.widget<Padding>(find.widgetWithText(Padding, 'Item 0').first).padding; + } + + expect(paddingFor('Item 0'), const EdgeInsets.symmetric(horizontal: 16.0)); + expect(paddingFor('Item 1'), const EdgeInsets.symmetric(horizontal: 16.0)); + }); + + testWidgets('Material2 - PopupMenuItem default padding', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: '0', enabled: false, child: Text('Item 0')), + const PopupMenuItem<String>(value: '1', child: Text('Item 1')), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pump(const Duration(milliseconds: 300)); + + // Check popup menu padding. + final SingleChildScrollView popupMenu = tester.widget<SingleChildScrollView>( + find.byType(SingleChildScrollView), + ); + expect(popupMenu.padding, const EdgeInsets.symmetric(vertical: 8.0)); + }); + + testWidgets('PopupMenuItem custom padding', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + final Type menuItemType = const PopupMenuItem<String>(child: Text('item')).runtimeType; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>( + padding: EdgeInsets.zero, + value: '0', + child: Text('Item 0'), + ), + const PopupMenuItem<String>( + padding: EdgeInsets.zero, + height: 0, + value: '0', + child: Text('Item 1'), + ), + const PopupMenuItem<String>( + padding: EdgeInsets.all(20), + value: '0', + child: Text('Item 2'), + ), + const PopupMenuItem<String>( + padding: EdgeInsets.all(20), + height: 100, + value: '0', + child: Text('Item 3'), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // The menu items and their InkWells should have the expected vertical size + // given the interactions between heights and padding. + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, + 48, + ); // Minimum interactive height (48) + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, + 16, + ); // Height of text (16) + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 2')).height, + 56, + ); // Padding (20.0 + 20.0) + Height of text (16) = 56 + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 3')).height, + 100, + ); // Height value of 100, since child (16) + padding (40) < 100 + + EdgeInsetsGeometry paddingFor(String text) { + final ConstrainedBox widget = tester.widget<ConstrainedBox>( + find.ancestor( + of: find.text(text), + matching: find.byWidgetPredicate( + (Widget widget) => widget is ConstrainedBox && widget.child is Padding, + ), + ), + ); + return (widget.child! as Padding).padding; + } + + expect(paddingFor('Item 0'), EdgeInsets.zero); + expect(paddingFor('Item 1'), EdgeInsets.zero); + expect(paddingFor('Item 2'), const EdgeInsets.all(20)); + expect(paddingFor('Item 3'), const EdgeInsets.all(20)); + }); + + testWidgets('CheckedPopupMenuItem child height is a minimum, child is vertically centered', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + final Type menuItemType = const CheckedPopupMenuItem<String>(child: Text('item')).runtimeType; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + // This menu item's height will be 56.0 because the default minimum height + // is 48, but the contents of PopupMenuItem are 56.0 tall. + const CheckedPopupMenuItem<String>( + checked: true, + value: '0', + child: Text('Item 0'), + ), + // This menu item's height parameter specifies its minimum height. The + // overall height of the menu item will be 60 because the child's + // height 56, is less than 60. + const CheckedPopupMenuItem<String>( + checked: true, + height: 60, + value: '1', + child: SizedBox(height: 40, child: Text('Item 1')), + ), + // This menu item's height parameter specifies its minimum height, so the + // overall height of the menu item will be 75. + const CheckedPopupMenuItem<String>( + checked: true, + height: 75, + value: '2', + child: SizedBox(child: Text('Item 2')), + ), + // This menu item's height will be 100. + const CheckedPopupMenuItem<String>( + checked: true, + height: 100, + value: '3', + child: SizedBox(child: Text('Item 3')), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // The menu items and their InkWells should have the expected vertical size + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, 56); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, 60); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 2')).height, 75); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 3')).height, 100); + // We evaluate the InkWell at the first index because that is the ListTile's + // InkWell, which wins in the gesture arena over the child's InkWell and + // is the one of interest. + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 0').at(1)).height, 56); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 1').at(1)).height, 60); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 2').at(1)).height, 75); + expect(tester.getSize(find.widgetWithText(InkWell, 'Item 3').at(1)).height, 100); + + // Menu item children which whose height is less than the PopupMenuItem + // are vertically centered. + expect( + tester.getRect(find.widgetWithText(menuItemType, 'Item 0')).center.dy, + tester.getRect(find.text('Item 0')).center.dy, + ); + expect( + tester.getRect(find.widgetWithText(menuItemType, 'Item 2')).center.dy, + tester.getRect(find.text('Item 2')).center.dy, + ); + }); + + testWidgets('CheckedPopupMenuItem custom padding', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + final Type menuItemType = const CheckedPopupMenuItem<String>(child: Text('item')).runtimeType; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const CheckedPopupMenuItem<String>( + padding: EdgeInsets.zero, + value: '0', + child: Text('Item 0'), + ), + const CheckedPopupMenuItem<String>( + padding: EdgeInsets.zero, + height: 0, + value: '0', + child: Text('Item 1'), + ), + const CheckedPopupMenuItem<String>( + padding: EdgeInsets.all(20), + value: '0', + child: Text('Item 2'), + ), + const CheckedPopupMenuItem<String>( + padding: EdgeInsets.all(20), + height: 100, + value: '0', + child: Text('Item 3'), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // The menu items and their InkWells should have the expected vertical size + // given the interactions between heights and padding. + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, + 56, + ); // Minimum ListTile height (56) + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, + 56, + ); // Minimum ListTile height (56) + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 2')).height, + 96, + ); // Padding (20.0 + 20.0) + Height of ListTile (56) = 96 + expect( + tester.getSize(find.widgetWithText(menuItemType, 'Item 3')).height, + 100, + ); // Height value of 100, since child (56) + padding (40) < 100 + + EdgeInsetsGeometry paddingFor(String text) { + final ConstrainedBox widget = tester.widget<ConstrainedBox>( + find.ancestor( + of: find.text(text), + matching: find.byWidgetPredicate( + (Widget widget) => widget is ConstrainedBox && widget.child is Padding, + ), + ), + ); + return (widget.child! as Padding).padding; + } + + expect(paddingFor('Item 0'), EdgeInsets.zero); + expect(paddingFor('Item 1'), EdgeInsets.zero); + expect(paddingFor('Item 2'), const EdgeInsets.all(20)); + expect(paddingFor('Item 3'), const EdgeInsets.all(20)); + }); + + testWidgets('Update PopupMenuItem layout while the menu is visible', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + final Type menuItemType = const PopupMenuItem<String>(child: Text('item')).runtimeType; + + Widget buildFrame({TextDirection textDirection = TextDirection.ltr, double fontSize = 24}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + builder: (BuildContext context, Widget? child) { + return Directionality( + textDirection: textDirection, + child: PopupMenuTheme( + data: PopupMenuTheme.of(context).copyWith( + textStyle: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: fontSize), + ), + child: child!, + ), + ); + }, + home: Scaffold( + body: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: '0', child: Text('Item 0')), + const PopupMenuItem<String>(value: '1', child: Text('Item 1')), + ]; + }, + ), + ), + ); + } + + // Show the menu + await tester.pumpWidget(buildFrame()); + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // The menu items should have their default heights and horizontal alignment. + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, 48); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, 48); + expect(tester.getTopLeft(find.text('Item 0')).dx, 24); + expect(tester.getTopLeft(find.text('Item 1')).dx, 24); + + // While the menu is up, change its font size to 64 (default is 16). + await tester.pumpWidget(buildFrame(fontSize: 64)); + await tester.pumpAndSettle(); // Theme changes are animated. + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, 128); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, 128); + expect(tester.getSize(find.text('Item 0')).height, 128); + expect(tester.getSize(find.text('Item 1')).height, 128); + expect(tester.getTopLeft(find.text('Item 0')).dx, 24); + expect(tester.getTopLeft(find.text('Item 1')).dx, 24); + + // While the menu is up, change the textDirection to rtl. Now menu items + // will be aligned right. + await tester.pumpWidget(buildFrame(textDirection: TextDirection.rtl)); + await tester.pumpAndSettle(); // Theme changes are animated. + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, 48); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 1')).height, 48); + expect(tester.getTopLeft(find.text('Item 0')).dx, 72); + expect(tester.getTopLeft(find.text('Item 1')).dx, 72); + }); + + test("PopupMenuButton's child and icon properties cannot be simultaneously defined", () { + expect(() { + PopupMenuButton<int>( + itemBuilder: (BuildContext context) => <PopupMenuItem<int>>[], + icon: const Icon(Icons.error), + child: Container(), + ); + }, throwsAssertionError); + }); + + testWidgets('PopupMenuButton default tooltip', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + // Default Tooltip should be present when [PopupMenuButton.icon] + // and [PopupMenuButton.child] are undefined. + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + ), + // Default Tooltip should be present when + // [PopupMenuButton.child] is defined. + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + child: const Text('Test text'), + ), + // Default Tooltip should be present when + // [PopupMenuButton.icon] is defined. + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + icon: const Icon(Icons.check), + ), + ], + ), + ), + ), + ); + + // The default tooltip is defined as [MaterialLocalizations.showMenuTooltip] + // and it is used when no tooltip is provided. + expect(find.byType(Tooltip), findsNWidgets(3)); + expect(find.byTooltip(const DefaultMaterialLocalizations().showMenuTooltip), findsNWidgets(3)); + }); + + testWidgets('PopupMenuButton custom tooltip', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + // Tooltip should work when [PopupMenuButton.icon] + // and [PopupMenuButton.child] are undefined. + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + tooltip: 'Test tooltip', + ), + // Tooltip should work when + // [PopupMenuButton.child] is defined. + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + tooltip: 'Test tooltip', + child: const Text('Test text'), + ), + // Tooltip should work when + // [PopupMenuButton.icon] is defined. + PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + tooltip: 'Test tooltip', + icon: const Icon(Icons.check), + ), + ], + ), + ), + ), + ); + + expect(find.byType(Tooltip), findsNWidgets(3)); + expect(find.byTooltip('Test tooltip'), findsNWidgets(3)); + }); + + testWidgets('Allow Widget for PopupMenuButton.icon', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + tooltip: 'Test tooltip', + icon: const Text('PopupMenuButton icon'), + ), + ), + ), + ); + + expect(find.text('PopupMenuButton icon'), findsOneWidget); + }); + + testWidgets('showMenu uses nested navigator by default', (WidgetTester tester) async { + final rootObserver = MenuObserver(); + final nestedObserver = MenuObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showMenu<int>( + context: context, + position: RelativeRect.fill, + items: <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('1')), + ], + ); + }, + child: const Text('Show Menu'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.menuCount, 0); + expect(nestedObserver.menuCount, 1); + }); + + testWidgets('showMenu uses root navigator if useRootNavigator is true', ( + WidgetTester tester, + ) async { + final rootObserver = MenuObserver(); + final nestedObserver = MenuObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showMenu<int>( + context: context, + useRootNavigator: true, + position: RelativeRect.fill, + items: <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('1')), + ], + ); + }, + child: const Text('Show Menu'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.menuCount, 1); + expect(nestedObserver.menuCount, 0); + }); + + testWidgets('PopupMenuButton calling showButtonMenu manually', (WidgetTester tester) async { + final GlobalKey<PopupMenuButtonState<int>> globalKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<int>( + key: globalKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Tap me please!')), + ]; + }, + ), + ], + ), + ), + ), + ); + + expect(find.text('Tap me please!'), findsNothing); + + globalKey.currentState!.showButtonMenu(); + // The PopupMenuItem will appear after an animation, hence, + // we have to first wait for the tester to settle. + await tester.pumpAndSettle(); + + expect(find.text('Tap me please!'), findsOneWidget); + }); + + testWidgets('PopupMenuButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + const Key key = ValueKey<int>(1); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: key, + itemBuilder: (_) => const <PopupMenuEntry<String>>[ + PopupMenuItem<String>(value: 'a', child: Text('A')), + PopupMenuItem<String>(value: 'b', child: Text('B')), + ], + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byKey(key))); + addTearDown(gesture.removePointer); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('PopupMenuItem changes mouse cursor when hovered', (WidgetTester tester) async { + const Key key = ValueKey<int>(1); + // Test PopupMenuItem() constructor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + // The [SemanticsRole.menu] is added here to make sure + // [PopupMenuItem]'s parent role is menu. + child: Semantics( + role: SemanticsRole.menu, + child: PopupMenuItem<int>( + key: key, + mouseCursor: SystemMouseCursors.text, + value: 1, + child: Container(), + ), + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byKey(key))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + // The [SemanticsRole.menu] is added here to make sure + // [PopupMenuItem]'s parent role is menu. + child: Semantics( + role: SemanticsRole.menu, + child: PopupMenuItem<int>(key: key, value: 1, child: Container()), + ), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + // The [SemanticsRole.menu] is added here to make sure + // [PopupMenuItem]'s parent role is menu. + child: Semantics( + role: SemanticsRole.menu, + child: PopupMenuItem<int>(key: key, value: 1, enabled: false, child: Container()), + ), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('CheckedPopupMenuItem changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + const Key key = ValueKey<int>(1); + // Test CheckedPopupMenuItem() constructor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + // The [SemanticsRole.menu] is added here to make sure + // [CheckedPopupMenuItem]'s parent role is menu. + child: Semantics( + role: SemanticsRole.menu, + child: CheckedPopupMenuItem<int>( + key: key, + mouseCursor: SystemMouseCursors.text, + value: 1, + child: Container(), + ), + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byKey(key))); + addTearDown(gesture.removePointer); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + // The [SemanticsRole.menu] is added here to make sure + // [CheckedPopupMenuItem]'s parent role is menu. + child: Semantics( + role: SemanticsRole.menu, + child: CheckedPopupMenuItem<int>(key: key, value: 1, child: Container()), + ), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + // The [SemanticsRole.menu] is added here to make sure + // [CheckedPopupMenuItem]'s parent role is menu. + child: Semantics( + role: SemanticsRole.menu, + child: CheckedPopupMenuItem<int>( + key: key, + value: 1, + enabled: false, + child: Container(), + ), + ), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('PopupMenu in AppBar does not overlap with the status bar', ( + WidgetTester tester, + ) async { + const choices = <PopupMenuItem<int>>[ + PopupMenuItem<int>(value: 1, child: Text('Item 1')), + PopupMenuItem<int>(value: 2, child: Text('Item 2')), + PopupMenuItem<int>(value: 3, child: Text('Item 3')), + ]; + + const statusBarHeight = 24.0; + final PopupMenuItem<int> firstItem = choices[0]; + int selectedValue = choices[0].value!; + + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: statusBarHeight), + ), // status bar + child: child!, + ); + }, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + appBar: AppBar( + title: const Text('PopupMenu Test'), + actions: <Widget>[ + PopupMenuButton<int>( + onSelected: (int result) { + setState(() { + selectedValue = result; + }); + }, + initialValue: selectedValue, + itemBuilder: (BuildContext context) { + return choices; + }, + ), + ], + ), + ); + }, + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + // Tap third item. + await tester.tap(find.text('Item 3')); + await tester.pumpAndSettle(); + + // Open popupMenu again. + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + // Check whether the first item is not overlapping with status bar. + expect(tester.getTopLeft(find.byWidget(firstItem)).dy, greaterThan(statusBarHeight)); + }); + + testWidgets('Vertically long PopupMenu does not overlap with the status bar and bottom notch', ( + WidgetTester tester, + ) async { + const double windowPaddingTop = 44; + const double windowPaddingBottom = 34; + + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: windowPaddingTop, bottom: windowPaddingBottom), + ), + child: child!, + ); + }, + home: Scaffold( + appBar: AppBar(title: const Text('PopupMenu Test')), + body: PopupMenuButton<int>( + child: const Text('Show Menu'), + itemBuilder: (BuildContext context) => Iterable<PopupMenuItem<int>>.generate( + 20, + (int i) => PopupMenuItem<int>(value: i, child: Text('Item $i')), + ).toList(), + ), + ), + ), + ); + + await tester.tap(find.text('Show Menu')); + await tester.pumpAndSettle(); + + final Offset topRightOfMenu = tester.getTopRight(find.byType(SingleChildScrollView)); + final Offset bottomRightOfMenu = tester.getBottomRight(find.byType(SingleChildScrollView)); + + expect(topRightOfMenu.dy, windowPaddingTop + 8.0); + expect(bottomRightOfMenu.dy, 600.0 - windowPaddingBottom - 8.0); // Screen height is 600. + }); + + testWidgets('PopupMenu position test when have unsafe area', (WidgetTester tester) async { + final GlobalKey buttonKey = GlobalKey(); + + Widget buildFrame(double width, double height) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 32.0, bottom: 32.0)), + child: child!, + ); + }, + home: Scaffold( + appBar: AppBar( + title: const Text('PopupMenu Test'), + actions: <Widget>[ + PopupMenuButton<int>( + child: SizedBox( + key: buttonKey, + height: height, + width: width, + child: const ColoredBox(color: Colors.pink), + ), + itemBuilder: (BuildContext context) => <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('-1-')), + const PopupMenuItem<int>(value: 2, child: Text('-2-')), + ], + ), + ], + ), + body: Container(), + ), + ); + } + + await tester.pumpWidget(buildFrame(20.0, 20.0)); + + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + final Offset button = tester.getTopRight(find.byKey(buttonKey)); + expect(button, const Offset(800.0, 32.0)); // The topPadding is 32.0. + + final Offset popupMenu = tester.getTopRight(find.byType(SingleChildScrollView)); + + // The menu should be positioned directly next to the top of the button. + // The 8.0 pixels is [_kMenuScreenPadding]. + expect(popupMenu, Offset(button.dx - 8.0, button.dy + 8.0)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/82874 + testWidgets('PopupMenu position test when have unsafe area - left/right padding', ( + WidgetTester tester, + ) async { + final GlobalKey buttonKey = GlobalKey(); + const padding = EdgeInsets.only(left: 300.0, top: 32.0, right: 310.0, bottom: 64.0); + EdgeInsets? mediaQueryPadding; + + Widget buildFrame(double width, double height) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: const MediaQueryData(padding: padding), + child: child!, + ); + }, + home: Scaffold( + appBar: AppBar( + title: const Text('PopupMenu Test'), + actions: <Widget>[ + PopupMenuButton<int>( + child: SizedBox( + key: buttonKey, + height: height, + width: width, + child: const ColoredBox(color: Colors.pink), + ), + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + PopupMenuItem<int>( + value: 1, + child: Builder( + builder: (BuildContext context) { + mediaQueryPadding = MediaQuery.paddingOf(context); + return Text('-1-' * 500); // A long long text string. + }, + ), + ), + const PopupMenuItem<int>(value: 2, child: Text('-2-')), + ]; + }, + ), + ], + ), + body: const SizedBox.shrink(), + ), + ); + } + + await tester.pumpWidget(buildFrame(20.0, 20.0)); + + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + final Offset button = tester.getTopRight(find.byKey(buttonKey)); + expect(button, Offset(800.0 - padding.right, padding.top)); // The topPadding is 32.0. + + final Offset popupMenuTopRight = tester.getTopRight(find.byType(SingleChildScrollView)); + + // The menu should be positioned directly next to the top of the button. + // The 8.0 pixels is [_kMenuScreenPadding]. + expect(popupMenuTopRight, Offset(800.0 - padding.right - 8.0, padding.top + 8.0)); + + final Offset popupMenuTopLeft = tester.getTopLeft(find.byType(SingleChildScrollView)); + expect(popupMenuTopLeft, Offset(padding.left + 8.0, padding.top + 8.0)); + + final Offset popupMenuBottomLeft = tester.getBottomLeft(find.byType(SingleChildScrollView)); + expect(popupMenuBottomLeft, Offset(padding.left + 8.0, 600.0 - padding.bottom - 8.0)); + + // The `MediaQueryData.padding` should be removed. + expect(mediaQueryPadding, EdgeInsets.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/163477 + testWidgets("PopupMenu's overlay can be rebuilt even when the button is unmounted", ( + WidgetTester tester, + ) async { + final GlobalKey buttonKey = GlobalKey(); + + late StateSetter setState; + var showButton = true; + + Widget widget({required Size viewSize}) { + return Center( + child: SizedBox( + width: viewSize.width, + height: viewSize.height, + child: MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter innerSetState) { + setState = innerSetState; + return showButton + ? PopupMenuButton<int>( + key: buttonKey, + popUpAnimationStyle: const AnimationStyle( + reverseDuration: Duration(milliseconds: 400), + ), + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<int>>[ + PopupMenuItem<int>( + value: 1, + child: const Text('ACTION'), + onTap: () {}, + ), + ]; + }, + ) + : Container(); + }, + ), + ), + ), + ), + ); + } + + // Pump a button + await tester.pumpWidget(widget(viewSize: const Size(500, 500))); + + // Tap the button to show the menu + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + expect(find.text('ACTION'), findsOne); + expect(find.byKey(buttonKey), findsOne); + + // Hide the button. The menu still shows since it's placed on a separate route. + setState(() { + showButton = false; + }); + await tester.pump(); + expect(find.text('ACTION'), findsOne); + expect(find.byKey(buttonKey), findsNothing); + + // Resize the view, causing the menu to rebuild. Before the fix, this + // rebuild would lead to a crash, because it relies on context of the button, + // which has been unmounted. + await tester.pumpWidget(widget(viewSize: const Size(300, 300))); + + expect(tester.takeException(), isNull); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + Widget buildFrame({bool? widgetEnableFeedback, bool? themeEnableFeedback}) { + return MaterialApp( + home: Scaffold( + body: PopupMenuTheme( + data: PopupMenuThemeData(enableFeedback: themeEnableFeedback), + child: PopupMenuButton<int>( + enableFeedback: widgetEnableFeedback, + child: const Text('Show Menu'), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[const PopupMenuItem<int>(value: 1, child: Text('One'))]; + }, + ), + ), + ), + ); + } + + testWidgets('PopupMenuButton enableFeedback works properly', (WidgetTester tester) async { + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + // PopupMenuButton with enabled feedback. + await tester.pumpWidget(buildFrame(widgetEnableFeedback: true)); + await tester.tap(find.text('Show Menu')); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + + await tester.pumpWidget(Container()); + + // PopupMenuButton with disabled feedback. + await tester.pumpWidget(buildFrame(widgetEnableFeedback: false)); + await tester.tap(find.text('Show Menu')); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + + await tester.pumpWidget(Container()); + + // PopupMenuButton with enabled feedback by default. + await tester.pumpWidget(buildFrame()); + await tester.tap(find.text('Show Menu')); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 0); + + await tester.pumpWidget(Container()); + + // PopupMenu with disabled feedback using PopupMenuButtonTheme. + await tester.pumpWidget(buildFrame(themeEnableFeedback: false)); + await tester.tap(find.text('Show Menu')); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 0); + + await tester.pumpWidget(Container()); + + // PopupMenu enableFeedback property overrides PopupMenuButtonTheme. + await tester.pumpWidget(buildFrame(widgetEnableFeedback: false, themeEnableFeedback: true)); + await tester.tap(find.text('Show Menu')); + await tester.pumpAndSettle(); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 0); + }); + }); + + testWidgets('Can customize PopupMenuButton icon', (WidgetTester tester) async { + const iconColor = Color(0xffffff00); + const iconSize = 29.5; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + iconColor: iconColor, + iconSize: iconSize, + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('child')), + ], + ), + ), + ), + ), + ); + + expect(_iconStyle(tester, Icons.adaptive.more)?.color, iconColor); + expect(tester.getSize(find.byIcon(Icons.adaptive.more)), const Size(iconSize, iconSize)); + }); + + testWidgets('does not crash in small overlay', (WidgetTester tester) async { + final GlobalKey navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + OutlinedButton( + onPressed: () { + showMenu<void>( + context: navigator.currentContext!, + position: RelativeRect.fill, + items: const <PopupMenuItem<void>>[PopupMenuItem<void>(child: Text('foo'))], + ); + }, + child: const Text('press'), + ), + SizedBox.square( + dimension: 10, + child: Navigator( + key: navigator, + onGenerateRoute: (RouteSettings settings) => MaterialPageRoute<void>( + builder: (BuildContext context) => Container(color: Colors.red), + ), + ), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('press')); + await tester.pumpAndSettle(); + expect(find.text('foo'), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/80869 + testWidgets('The menu position test in the scrollable widget', (WidgetTester tester) async { + final GlobalKey buttonKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Column( + children: <Widget>[ + const SizedBox(height: 100), + PopupMenuButton<int>( + child: SizedBox( + key: buttonKey, + height: 10.0, + width: 10.0, + child: const ColoredBox(color: Colors.pink), + ), + itemBuilder: (BuildContext context) => <PopupMenuEntry<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('-1-')), + const PopupMenuItem<int>(value: 2, child: Text('-2-')), + ], + ), + const SizedBox(height: 600), + ], + ), + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + Offset button = tester.getTopLeft(find.byKey(buttonKey)); + expect(button, const Offset(0.0, 100.0)); + + Offset popupMenu = tester.getTopLeft(find.byType(SingleChildScrollView).last); + // The menu should be positioned directly next to the top of the button. + // The 8.0 pixels is [_kMenuScreenPadding]. + expect(popupMenu, const Offset(8.0, 100.0)); + + // Close the menu. + await tester.tap(find.byKey(buttonKey), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Scroll a little bit. + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -50.0)); + + button = tester.getTopLeft(find.byKey(buttonKey)); + expect(button, const Offset(0.0, 50.0)); + + // Open the menu again. + await tester.tap(find.byKey(buttonKey)); + await tester.pumpAndSettle(); + + popupMenu = tester.getTopLeft(find.byType(SingleChildScrollView).last); + // The menu should be positioned directly next to the top of the button. + // The 8.0 pixels is [_kMenuScreenPadding]. + expect(popupMenu, const Offset(8.0, 50.0)); + }); + + testWidgets('PopupMenuButton custom splash radius', (WidgetTester tester) async { + Future<void> buildFrameWithoutChild({double? splashRadius}) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + splashRadius: splashRadius, + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('child')), + ], + ), + ), + ), + ), + ); + } + + Future<void> buildFrameWithChild({double? splashRadius}) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + splashRadius: splashRadius, + child: const Text('An item'), + itemBuilder: (_) => <PopupMenuEntry<String>>[const PopupMenuDivider()], + ), + ), + ), + ), + ); + } + + await buildFrameWithoutChild(); + expect( + tester.widget<InkResponse>(find.byType(InkResponse)).radius, + Material.defaultSplashRadius, + ); + await buildFrameWithChild(); + expect(tester.widget<InkWell>(find.byType(InkWell)).radius, null); + + const double testSplashRadius = 50; + + await buildFrameWithoutChild(splashRadius: testSplashRadius); + expect(tester.widget<InkResponse>(find.byType(InkResponse)).radius, testSplashRadius); + await buildFrameWithChild(splashRadius: testSplashRadius); + expect(tester.widget<InkWell>(find.byType(InkWell)).radius, testSplashRadius); + }); + + testWidgets('Can override menu size constraints', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + final Type menuItemType = const PopupMenuItem<String>(child: Text('item')).runtimeType; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + constraints: const BoxConstraints(minWidth: 500), + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('Item 0')), + ], + ), + ), + ), + ), + ); + + // Show the menu + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).height, 48); + expect(tester.getSize(find.widgetWithText(menuItemType, 'Item 0')).width, 500); + }); + + testWidgets('Can change menu position and offset', (WidgetTester tester) async { + PopupMenuButton<int> buildMenuButton({required PopupMenuPosition position}) { + return PopupMenuButton<int>( + position: position, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + PopupMenuItem<int>( + value: 1, + child: Builder( + builder: (BuildContext context) { + return const Text('AAA'); + }, + ), + ), + ]; + }, + ); + } + + // Popup menu with `MenuPosition.over (default) with default offset`. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material(child: buildMenuButton(position: PopupMenuPosition.over)), + ), + ), + ); + + // Open the popup menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PopupMenu<int?>'), + ), + const Offset(8.0, 8.0), + ); + + // Close the popup menu. + await tester.tapAt(Offset.zero); + await tester.pump(); + + // Popup menu with `MenuPosition.under`(custom) with default offset`. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material(child: buildMenuButton(position: PopupMenuPosition.under)), + ), + ), + ); + + // Open the popup menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PopupMenu<int?>'), + ), + const Offset(8.0, 40.0), + ); + + // Close the popup menu. + await tester.tapAt(Offset.zero); + await tester.pump(); + + // Popup menu with `MenuPosition.over (default) with custom offset`. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material( + child: PopupMenuButton<int>( + offset: const Offset(0.0, 50), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + PopupMenuItem<int>( + value: 1, + child: Builder( + builder: (BuildContext context) { + return const Text('AAA'); + }, + ), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Open the popup menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PopupMenu<int?>'), + ), + const Offset(8.0, 50.0), + ); + + // Close the popup menu. + await tester.tapAt(Offset.zero); + await tester.pump(); + + // Popup menu with `MenuPosition.under (custom) with custom offset`. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material( + child: PopupMenuButton<int>( + offset: const Offset(0.0, 50), + position: PopupMenuPosition.under, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + PopupMenuItem<int>( + value: 1, + child: Builder( + builder: (BuildContext context) { + return const Text('AAA'); + }, + ), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Open the popup menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_PopupMenu<int?>'), + ), + const Offset(8.0, 90.0), + ); + }); + + testWidgets("PopupMenuButton icon inherits IconTheme's size", (WidgetTester tester) async { + Widget buildPopupMenu({double? themeIconSize, double? iconSize}) { + return MaterialApp( + theme: ThemeData(iconTheme: IconThemeData(size: themeIconSize)), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + iconSize: iconSize, + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('Item 0')), + ], + ), + ), + ), + ); + } + + // Popup menu with default icon size. + await tester.pumpWidget(buildPopupMenu()); + // Default PopupMenuButton icon size is 24.0. + expect(tester.getSize(find.byIcon(Icons.more_vert)), const Size(24.0, 24.0)); + + // Popup menu with custom theme icon size. + await tester.pumpWidget(buildPopupMenu(themeIconSize: 30.0)); + await tester.pumpAndSettle(); + // PopupMenuButton icon inherits IconTheme's size. + expect(tester.getSize(find.byIcon(Icons.more_vert)), const Size(30.0, 30.0)); + + // Popup menu with custom icon size. + await tester.pumpWidget(buildPopupMenu(themeIconSize: 30.0, iconSize: 50.0)); + await tester.pumpAndSettle(); + // PopupMenuButton icon size overrides IconTheme's size. + expect(tester.getSize(find.byIcon(Icons.more_vert)), const Size(50.0, 50.0)); + }); + + testWidgets('Popup menu clip behavior', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/107215 + final Key popupButtonKey = UniqueKey(); + const radius = 20.0; + + Widget buildPopupMenu({required Clip clipBehavior}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupButtonKey, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(radius)), + ), + clipBehavior: clipBehavior, + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('Item 0')), + ], + ), + ), + ), + ); + } + + // Popup menu with default ClipBehavior. + await tester.pumpWidget(buildPopupMenu(clipBehavior: Clip.none)); + + // Open the popup to build and show the menu contents. + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + Material material = tester.widget<Material>(find.byType(Material).last); + expect(material.clipBehavior, Clip.none); + + // Close the popup menu. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // Popup menu with custom ClipBehavior. + await tester.pumpWidget(buildPopupMenu(clipBehavior: Clip.hardEdge)); + + // Open the popup to build and show the menu contents. + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(find.byType(Material).last); + expect(material.clipBehavior, Clip.hardEdge); + }); + + testWidgets('Uses closed loop focus traversal', (WidgetTester tester) async { + FocusNode nodeA() => Focus.of(find.text('A').evaluate().single); + FocusNode nodeB() => Focus.of(find.text('B').evaluate().single); + + final GlobalKey popupButtonKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupButtonKey, + itemBuilder: (_) => const <PopupMenuEntry<String>>[ + PopupMenuItem<String>(value: 'a', child: Text('A')), + PopupMenuItem<String>(value: 'b', child: Text('B')), + ], + ), + ), + ), + ), + ); + + // Open the popup to build and show the menu contents. + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + Future<bool> nextFocus() async { + final result = Actions.invoke(primaryFocus!.context!, const NextFocusIntent())! as bool; + await tester.pump(); + return result; + } + + Future<bool> previousFocus() async { + final result = Actions.invoke(primaryFocus!.context!, const PreviousFocusIntent())! as bool; + await tester.pump(); + return result; + } + + // Start at A + nodeA().requestFocus(); + await tester.pump(); + expect(nodeA().hasFocus, true); + expect(nodeB().hasFocus, false); + + // A -> B + expect(await nextFocus(), true); + expect(nodeA().hasFocus, false); + expect(nodeB().hasFocus, true); + + // B -> A (wrap around) + expect(await nextFocus(), true); + expect(nodeA().hasFocus, true); + expect(nodeB().hasFocus, false); + + // B <- A + expect(await previousFocus(), true); + expect(nodeA().hasFocus, false); + expect(nodeB().hasFocus, true); + + // A <- B (wrap around) + expect(await previousFocus(), true); + expect(nodeA().hasFocus, true); + expect(nodeB().hasFocus, false); + }); + + testWidgets('Popup menu scrollbar inherits ScrollbarTheme', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + const scrollbarTheme = ScrollbarThemeData( + thumbColor: MaterialStatePropertyAll<Color?>(Color(0xffff0000)), + thumbVisibility: MaterialStatePropertyAll<bool?>(true), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scrollbarTheme: scrollbarTheme), + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<void>( + key: popupButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[ + const PopupMenuItem<void>(height: 1000, child: Text('Example')), + ]; + }, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byType(Scrollbar), findsOneWidget); + // Test Scrollbar thumb color. + expect(find.byType(Scrollbar), paints..rrect(color: const Color(0xffff0000))); + + // Close the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Test local ScrollbarTheme overrides global ScrollbarTheme. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scrollbarTheme: scrollbarTheme), + home: Material( + child: Column( + children: <Widget>[ + ScrollbarTheme( + data: scrollbarTheme.copyWith( + thumbColor: const MaterialStatePropertyAll<Color?>(Color(0xff0000ff)), + ), + child: PopupMenuButton<void>( + key: popupButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[ + const PopupMenuItem<void>(height: 1000, child: Text('Example')), + ]; + }, + ), + ), + ], + ), + ), + ), + ); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byType(Scrollbar), findsOneWidget); + // Scrollbar thumb color should be updated. + expect(find.byType(Scrollbar), paints..rrect(color: const Color(0xff0000ff))); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Popup menu with RouteSettings', (WidgetTester tester) async { + late RouteSettings currentRouteSetting; + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[ + _ClosureNavigatorObserver( + onDidChange: (Route<dynamic> newRoute) { + currentRouteSetting = newRoute.settings; + }, + ), + ], + home: const Material( + child: Center(child: ElevatedButton(onPressed: null, child: Text('Go'))), + ), + ), + ); + + final BuildContext context = tester.element(find.text('Go')); + const exampleSetting = RouteSettings(name: 'simple'); + + showMenu<void>( + context: context, + position: RelativeRect.fill, + items: const <PopupMenuItem<void>>[PopupMenuItem<void>(child: Text('foo'))], + routeSettings: exampleSetting, + ); + + await tester.pumpAndSettle(); + expect(find.text('foo'), findsOneWidget); + expect(currentRouteSetting, exampleSetting); + + await tester.tap(find.text('foo')); + await tester.pumpAndSettle(); + expect(currentRouteSetting.name, '/'); + }); + + testWidgets('Popup menu is positioned under the child', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + final Key childKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<void>( + key: popupButtonKey, + position: PopupMenuPosition.under, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[const PopupMenuItem<void>(child: Text('Example'))]; + }, + child: SizedBox(key: childKey, height: 50, width: 50), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + final Offset childBottomLeft = tester.getBottomLeft(find.byKey(childKey)); + final Offset menuTopLeft = tester.getTopLeft(find.bySemanticsLabel('Popup menu')); + expect(childBottomLeft, menuTopLeft); + }); + + testWidgets('PopupMenuItem onTap should be calling after Navigator.pop', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<int>( + itemBuilder: (BuildContext context) => <PopupMenuItem<int>>[ + PopupMenuItem<int>( + onTap: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) { + return const SizedBox( + height: 200.0, + child: Center(child: Text('ModalBottomSheet')), + ); + }, + ); + }, + value: 10, + child: const Text('ACTION'), + ), + ], + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<int>)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('ACTION')); + await tester.pumpAndSettle(); + + // Verify that the ModalBottomSheet is displayed + final Finder modalBottomSheet = find.text('ModalBottomSheet'); + expect(modalBottomSheet, findsOneWidget); + }); + + testWidgets('Material3 - CheckedPopupMenuItem.labelTextStyle uses correct text style', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + var theme = ThemeData(); + + Widget buildMenu() { + return MaterialApp( + theme: theme, + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<void>( + key: popupMenuButtonKey, + itemBuilder: (BuildContext context) => <PopupMenuItem<void>>[ + const CheckedPopupMenuItem<void>(child: Text('Item 1')), + const CheckedPopupMenuItem<int>(checked: true, child: Text('Item 2')), + ], + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildMenu()); + + // Show the menu + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // Test default text style. + expect(_labelStyle(tester, 'Item 1')!.fontSize, 14.0); + expect(_labelStyle(tester, 'Item 1')!.color, theme.colorScheme.onSurface); + + // Close the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Test custom text theme. + const customTextStyle = TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ); + theme = theme.copyWith(textTheme: const TextTheme(labelLarge: customTextStyle)); + await tester.pumpWidget(buildMenu()); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // Test custom text theme. + expect(_labelStyle(tester, 'Item 1')!.fontSize, customTextStyle.fontSize); + expect(_labelStyle(tester, 'Item 1')!.fontWeight, customTextStyle.fontWeight); + expect(_labelStyle(tester, 'Item 1')!.fontStyle, customTextStyle.fontStyle); + }); + + testWidgets('CheckedPopupMenuItem.labelTextStyle resolve material states', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + final WidgetStateProperty<TextStyle?> labelTextStyle = WidgetStateProperty.resolveWith(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.selected)) { + return const TextStyle(color: Colors.red, fontSize: 24.0); + } + + return const TextStyle(color: Colors.amber, fontSize: 20.0); + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<void>( + key: popupMenuButtonKey, + itemBuilder: (BuildContext context) => <PopupMenuItem<void>>[ + CheckedPopupMenuItem<void>( + labelTextStyle: labelTextStyle, + child: const Text('Item 1'), + ), + CheckedPopupMenuItem<int>( + checked: true, + labelTextStyle: labelTextStyle, + child: const Text('Item 2'), + ), + ], + ), + ], + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + expect(_labelStyle(tester, 'Item 1'), labelTextStyle.resolve(<WidgetState>{})); + expect( + _labelStyle(tester, 'Item 2'), + labelTextStyle.resolve(<WidgetState>{WidgetState.selected}), + ); + }); + + testWidgets('CheckedPopupMenuItem overrides redundant ListTile.contentPadding', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const CheckedPopupMenuItem<String>(value: '0', child: Text('Item 0')), + const CheckedPopupMenuItem<String>( + value: '1', + checked: true, + child: Text('Item 1'), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + SafeArea getItemSafeArea(String label) { + return tester.widget<SafeArea>( + find.ancestor(of: find.text(label), matching: find.byType(SafeArea)), + ); + } + + expect(getItemSafeArea('Item 0').minimum, EdgeInsets.zero); + expect(getItemSafeArea('Item 1').minimum, EdgeInsets.zero); + }); + + testWidgets('PopupMenuItem overrides redundant ListTile.contentPadding', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + const PopupMenuItem<String>( + value: '0', + enabled: false, + child: ListTile(title: Text('Item 0')), + ), + const PopupMenuItem<String>( + value: '1', + child: ListTile(title: Text('Item 1')), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + SafeArea getItemSafeArea(String label) { + return tester.widget<SafeArea>( + find.ancestor(of: find.text(label), matching: find.byType(SafeArea)), + ); + } + + expect(getItemSafeArea('Item 0').minimum, EdgeInsets.zero); + expect(getItemSafeArea('Item 1').minimum, EdgeInsets.zero); + }); + + testWidgets('Material3 - PopupMenuItem overrides ListTile.titleTextStyle', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + var theme = ThemeData(); + + Widget buildMenu() { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + // Popup menu item with a Text widget. + const PopupMenuItem<String>(value: '0', child: Text('Item 0')), + // Popup menu item with a ListTile widget. + const PopupMenuItem<String>( + value: '1', + child: ListTile(title: Text('Item 1')), + ), + ]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildMenu()); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // Test popup menu item with a Text widget. + expect(_labelStyle(tester, 'Item 0')!.fontSize, 14.0); + expect(_labelStyle(tester, 'Item 0')!.color, theme.colorScheme.onSurface); + + // Test popup menu item with a ListTile widget. + expect(_labelStyle(tester, 'Item 1')!.fontSize, 14.0); + expect(_labelStyle(tester, 'Item 1')!.color, theme.colorScheme.onSurface); + + // Close the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Test custom text theme. + const customTextStyle = TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ); + theme = theme.copyWith(textTheme: const TextTheme(labelLarge: customTextStyle)); + await tester.pumpWidget(buildMenu()); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // Test popup menu item with a Text widget with custom text theme. + expect(_labelStyle(tester, 'Item 0')!.fontSize, customTextStyle.fontSize); + expect(_labelStyle(tester, 'Item 0')!.fontWeight, customTextStyle.fontWeight); + expect(_labelStyle(tester, 'Item 0')!.fontStyle, customTextStyle.fontStyle); + + // Test popup menu item with a ListTile widget with custom text theme. + expect(_labelStyle(tester, 'Item 1')!.fontSize, customTextStyle.fontSize); + expect(_labelStyle(tester, 'Item 1')!.fontWeight, customTextStyle.fontWeight); + expect(_labelStyle(tester, 'Item 1')!.fontStyle, customTextStyle.fontStyle); + }); + + testWidgets('Material2 - PopupMenuItem overrides ListTile.titleTextStyle', ( + WidgetTester tester, + ) async { + final Key popupMenuButtonKey = UniqueKey(); + var theme = ThemeData(useMaterial3: false); + + Widget buildMenu() { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + key: popupMenuButtonKey, + child: const Text('button'), + onSelected: (String result) {}, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<String>>[ + // Popup menu item with a Text widget. + const PopupMenuItem<String>(value: '0', child: Text('Item 0')), + // Popup menu item with a ListTile widget. + const PopupMenuItem<String>( + value: '1', + child: ListTile(title: Text('Item 1')), + ), + ]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildMenu()); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // Test popup menu item with a Text widget. + expect(_labelStyle(tester, 'Item 0')!.fontSize, 16.0); + expect(_labelStyle(tester, 'Item 0')!.color, theme.textTheme.titleMedium!.color); + + // Test popup menu item with a ListTile widget. + expect(_labelStyle(tester, 'Item 1')!.fontSize, 16.0); + expect(_labelStyle(tester, 'Item 1')!.color, theme.textTheme.titleMedium!.color); + + // Close the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Test custom text theme. + const customTextStyle = TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ); + theme = theme.copyWith(textTheme: const TextTheme(titleMedium: customTextStyle)); + await tester.pumpWidget(buildMenu()); + + // Show the menu. + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pumpAndSettle(); + + // Test popup menu item with a Text widget with custom text style. + expect(_labelStyle(tester, 'Item 0')!.fontSize, customTextStyle.fontSize); + expect(_labelStyle(tester, 'Item 0')!.fontWeight, customTextStyle.fontWeight); + expect(_labelStyle(tester, 'Item 0')!.fontStyle, customTextStyle.fontStyle); + + // Test popup menu item with a ListTile widget with custom text style. + expect(_labelStyle(tester, 'Item 1')!.fontSize, customTextStyle.fontSize); + expect(_labelStyle(tester, 'Item 1')!.fontWeight, customTextStyle.fontWeight); + expect(_labelStyle(tester, 'Item 1')!.fontStyle, customTextStyle.fontStyle); + }); + + testWidgets('CheckedPopupMenuItem.onTap callback is called when defined', ( + WidgetTester tester, + ) async { + var count = 0; + await tester.pumpWidget( + TestApp( + textDirection: TextDirection.ltr, + child: Material( + child: RepaintBoundary( + child: PopupMenuButton<String>( + child: const Text('button'), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + CheckedPopupMenuItem<String>( + onTap: () { + count += 1; + }, + value: 'item1', + child: const Text('Item with onTap'), + ), + const CheckedPopupMenuItem<String>( + value: 'item2', + child: Text('Item without onTap'), + ), + ]; + }, + ), + ), + ), + ), + ); + + // Tap a checked menu item with onTap. + await tester.tap(find.text('button')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(CheckedPopupMenuItem<String>, 'Item with onTap')); + await tester.pumpAndSettle(); + expect(count, 1); + + // Tap a checked menu item without onTap. + await tester.tap(find.text('button')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(CheckedPopupMenuItem<String>, 'Item without onTap')); + await tester.pumpAndSettle(); + expect(count, 1); + }); + + testWidgets('PopupMenuButton uses root navigator if useRootNavigator is true', ( + WidgetTester tester, + ) async { + final rootObserver = MenuObserver(); + final nestedObserver = MenuObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return Material( + child: PopupMenuButton<String>( + useRootNavigator: true, + child: const Text('button'), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + const CheckedPopupMenuItem<String>(value: 'item1', child: Text('item 1')), + const CheckedPopupMenuItem<String>(value: 'item2', child: Text('item 2')), + ]; + }, + ), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('button')); + + expect(rootObserver.menuCount, 1); + expect(nestedObserver.menuCount, 0); + }); + + testWidgets('PopupMenuButton does not use root navigator if useRootNavigator is false', ( + WidgetTester tester, + ) async { + final rootObserver = MenuObserver(); + final nestedObserver = MenuObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return Material( + child: PopupMenuButton<String>( + child: const Text('button'), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + const CheckedPopupMenuItem<String>(value: 'item1', child: Text('item 1')), + const CheckedPopupMenuItem<String>(value: 'item2', child: Text('item 2')), + ]; + }, + ), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.text('button')); + + expect(rootObserver.menuCount, 0); + expect(nestedObserver.menuCount, 1); + }); + + testWidgets('Override Popup Menu animation using AnimationStyle', (WidgetTester tester) async { + final Key targetKey = UniqueKey(); + + Widget buildPopupMenu({AnimationStyle? animationStyle}) { + return MaterialApp( + home: Material( + child: Center( + child: PopupMenuButton<int>( + key: targetKey, + popUpAnimationStyle: animationStyle, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('One')), + const PopupMenuItem<int>(value: 2, child: Text('Two')), + const PopupMenuItem<int>(value: 3, child: Text('Three')), + ]; + }, + ), + ), + ), + ); + } + + // Test default animation. + await tester.pumpWidget(buildPopupMenu()); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance the animation by 1/3 of its duration. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(112.0, 80.0)), + ); + + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance the animation by 2/3 of its duration. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(112.0, 160.0)), + ); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(112.0, 160.0)), + ); + + // Tap outside to dismiss the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Override the animation duration. + await tester.pumpWidget( + buildPopupMenu(animationStyle: const AnimationStyle(duration: Duration.zero)), + ); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); // Advance the animation by 1 millisecond. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(112.0, 160.0)), + ); + + // Tap outside to dismiss the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Override the animation curve. + await tester.pumpWidget( + buildPopupMenu(animationStyle: const AnimationStyle(curve: Easing.emphasizedAccelerate)), + ); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance the animation by 1/3 of its duration. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(32.4, 15.4)), + ); + + await tester.pump( + const Duration(milliseconds: 100), + ); // Advance the animation by 2/3 of its duration. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(112.0, 72.2)), + ); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect( + tester.getSize(find.byType(Material).last), + within(distance: 0.1, from: const Size(112.0, 160.0)), + ); + }); + + testWidgets('PopupMenuButton scrolls initial value/selected value to visible', ( + WidgetTester tester, + ) async { + const length = 50; + const int selectedValue = length - 1; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return List<PopupMenuEntry<int>>.generate(length, (int index) { + return PopupMenuItem<int>(value: index, child: Text('item #$index')); + }); + }, + popUpAnimationStyle: AnimationStyle.noAnimation, + initialValue: selectedValue, + child: const Text('click here'), + ), + ), + ), + ), + ); + await tester.tap(find.text('click here')); + await tester.pump(); + + // Set up finder and verify basic widget structure. + final Finder item49 = find.text('item #49'); + expect(item49, findsOneWidget); + + // The initially selected menu item should be positioned on screen. + final RenderBox initialItem = tester.renderObject<RenderBox>(item49); + final Rect initialItemBounds = initialItem.localToGlobal(Offset.zero) & initialItem.size; + final Size windowSize = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(initialItemBounds.bottomRight.dy, lessThanOrEqualTo(windowSize.height)); + + // Select item 20. + final Finder item20 = find.text('item #20'); + await tester.scrollUntilVisible(item20, 500); + expect(item20, findsOneWidget); + await tester.tap(item20); + await tester.pump(); + + // Open menu again. + await tester.tap(find.text('click here')); + await tester.pump(); + expect(item20, findsOneWidget); + + // The selected menu item should be positioned on screen. + final RenderBox selectedItem = tester.renderObject<RenderBox>(item20); + final Rect selectedItemBounds = selectedItem.localToGlobal(Offset.zero) & selectedItem.size; + expect(selectedItemBounds.bottomRight.dy, lessThanOrEqualTo(windowSize.height)); + }); + + testWidgets('PopupMenuButton properly positions a constrained-size popup', ( + WidgetTester tester, + ) async { + final Size windowSize = tester.view.physicalSize / tester.view.devicePixelRatio; + const length = 50; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(50), + child: Align( + alignment: Alignment.bottomCenter, + child: PopupMenuButton<int>( + itemBuilder: (BuildContext context) { + return List<PopupMenuEntry<int>>.generate(length, (int index) { + return PopupMenuItem<int>(value: index, child: Text('item #$index')); + }); + }, + constraints: BoxConstraints(maxHeight: windowSize.height / 3), + popUpAnimationStyle: AnimationStyle.noAnimation, + initialValue: length - 1, + child: const Text('click here'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('click here')); + await tester.pump(); + + // Set up finders and verify basic widget structure + final Finder findButton = find.byType(PopupMenuButton<int>); + final Finder findLastItem = find.text('item #49'); + final Finder findListBody = find.byType(ListBody); + final Finder findListViewport = find.ancestor( + of: findListBody, + matching: find.byType(SingleChildScrollView), + ); + expect(findButton, findsOne); + expect(findLastItem, findsOne); + expect(findListBody, findsOne); + expect(findListViewport, findsOne); + + // The button and the list viewport should overlap + final RenderBox button = tester.renderObject<RenderBox>(findButton); + final Rect buttonBounds = button.localToGlobal(Offset.zero) & button.size; + final RenderBox listViewport = tester.renderObject<RenderBox>(findListViewport); + final Rect listViewportBounds = listViewport.localToGlobal(Offset.zero) & listViewport.size; + expect(listViewportBounds.topLeft.dy, lessThanOrEqualTo(windowSize.height)); + expect(listViewportBounds.bottomRight.dy, lessThanOrEqualTo(windowSize.height)); + expect(listViewportBounds, overlaps(buttonBounds)); + }); + + testWidgets('PopupMenuButton honors style', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PopupMenuButton<int>( + style: const ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[const PopupMenuItem<int>(value: 1, child: Text('One'))]; + }, + ), + ), + ), + ); + final RichText iconText = tester.firstWidget( + find.descendant(of: find.byType(PopupMenuButton<int>), matching: find.byType(RichText)), + ); + expect(iconText.text.style?.color, Colors.red); + }); + + testWidgets("Popup menu child's InkWell borderRadius", (WidgetTester tester) async { + const borderRadius = BorderRadius.all(Radius.circular(20)); + + Widget buildPopupMenu({required BorderRadius? borderRadius}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + borderRadius: borderRadius, + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('Item 0')), + ], + child: const Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[Text('Pop up menu'), Icon(Icons.arrow_drop_down)], + ), + ), + ), + ), + ); + } + + // Popup menu with default null borderRadius. + await tester.pumpWidget(buildPopupMenu(borderRadius: null)); + await tester.pumpAndSettle(); + + InkWell inkWell = tester.widget<InkWell>(find.byType(InkWell)); + expect(inkWell.borderRadius, isNull); + + // Popup menu with fixed borderRadius. + await tester.pumpWidget(buildPopupMenu(borderRadius: borderRadius)); + await tester.pumpAndSettle(); + + inkWell = tester.widget<InkWell>(find.byType(InkWell)); + expect(inkWell.borderRadius, borderRadius); + }); + + testWidgets('PopupMenuButton respects materialTapTargetSize', (WidgetTester tester) async { + const buttonSize = 10.0; + + Widget buildPopupMenu({required MaterialTapTargetSize tapTargetSize}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + style: ButtonStyle(tapTargetSize: tapTargetSize), + itemBuilder: (_) => <PopupMenuEntry<String>>[ + const PopupMenuItem<String>(value: 'value', child: Text('Item 0')), + ], + child: const SizedBox(height: buttonSize, width: buttonSize), + ), + ), + ), + ); + } + + // Popup menu with MaterialTapTargetSize.padded. + await tester.pumpWidget(buildPopupMenu(tapTargetSize: MaterialTapTargetSize.padded)); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(InkWell)), const Size(48.0, 48.0)); + + // Popup menu with MaterialTapTargetSize.shrinkWrap. + await tester.pumpWidget(buildPopupMenu(tapTargetSize: MaterialTapTargetSize.shrinkWrap)); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(InkWell)), const Size(buttonSize, buttonSize)); + }); + + testWidgets( + 'If requestFocus is false, the original focus should be preserved upon menu appearance.', + (WidgetTester tester) async { + final fieldFocusNode = FocusNode(); + addTearDown(fieldFocusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + TextField(focusNode: fieldFocusNode, autofocus: true), + PopupMenuButton<int>( + style: const ButtonStyle(iconColor: MaterialStatePropertyAll<Color>(Colors.red)), + itemBuilder: (BuildContext context) { + return <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('One')), + ]; + }, + requestFocus: false, + child: const Text('click here'), + ), + ], + ), + ), + ), + ); + expect(fieldFocusNode.hasFocus, isTrue); + await tester.tap(find.text('click here')); + await tester.pump(); + expect(fieldFocusNode.hasFocus, isTrue); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/152475 + testWidgets('PopupMenuButton updates position on orientation change', ( + WidgetTester tester, + ) async { + const initialSize = Size(400, 800); + const newSize = Size(1024, 768); + + await tester.binding.setSurfaceSize(initialSize); + + final GlobalKey buttonKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<int>( + key: buttonKey, + itemBuilder: (BuildContext context) => <PopupMenuItem<int>>[ + const PopupMenuItem<int>(value: 1, child: Text('Option 1')), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<int>)); + await tester.pumpAndSettle(); + + final Rect initialButtonRect = tester.getRect(find.byKey(buttonKey)); + final Rect initialMenuRect = tester.getRect(find.text('Option 1')); + + await tester.binding.setSurfaceSize(newSize); + await tester.pumpAndSettle(); + + final Rect newButtonRect = tester.getRect(find.byKey(buttonKey)); + final Rect newMenuRect = tester.getRect(find.text('Option 1')); + + expect(newButtonRect, isNot(equals(initialButtonRect))); + + expect(newMenuRect, isNot(equals(initialMenuRect))); + + expect( + newMenuRect.topLeft - newButtonRect.topLeft, + initialMenuRect.topLeft - initialButtonRect.topLeft, + ); + + await tester.binding.setSurfaceSize(null); + }); + + testWidgets('PopupMenuDivider custom thickness', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + onSelected: (String result) {}, + child: const Text('Menu Button'), + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + const PopupMenuDivider(thickness: 5.0), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pump(); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.width, 5.0); + }); + + testWidgets('PopupMenuDivider custom indent', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + onSelected: (String result) {}, + child: const Text('Menu Button'), + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + const PopupMenuDivider(indent: 5.0), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pump(); + final Container container = tester.widget(find.byType(Container)); + final margin = container.margin! as EdgeInsetsDirectional; + expect(margin.start, 5.0); + }); + + testWidgets('PopupMenuDivider custom color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + onSelected: (String result) {}, + child: const Text('Menu Button'), + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + const PopupMenuDivider(color: Colors.deepOrange), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pump(); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.border!.bottom.color, Colors.deepOrange); + }); + + testWidgets('PopupMenuDivider custom end indent', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + onSelected: (String result) {}, + child: const Text('Menu Button'), + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + const PopupMenuDivider(endIndent: 5.0), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pump(); + final Container container = tester.widget(find.byType(Container)); + final margin = container.margin! as EdgeInsetsDirectional; + expect(margin.end, 5.0); + }); + + testWidgets('PopupMenuDivider custom radius', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopupMenuButton<String>( + onSelected: (String result) {}, + child: const Text('Menu Button'), + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + const PopupMenuDivider(radius: BorderRadius.all(Radius.circular(5))), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pump(); + final Container container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + final borderRadius = decoration.borderRadius! as BorderRadius; + expect(borderRadius.bottomLeft, const Radius.circular(5)); + expect(borderRadius.bottomRight, const Radius.circular(5)); + expect(borderRadius.topLeft, const Radius.circular(5)); + expect(borderRadius.topRight, const Radius.circular(5)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/171422. + testWidgets('PopupMenuButton should not crash when being hidden immediately', ( + WidgetTester tester, + ) async { + var showPopupMenu = true; + + Widget buildWidget() { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + if (showPopupMenu) + PopupMenuButton<String>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + const PopupMenuItem<String>(value: 'add', child: Text('Add')), + const PopupMenuItem<String>(value: 'hide', child: Text('Hide')), + const PopupMenuItem<String>(value: 'delete', child: Text('Delete')), + ]; + }, + onSelected: (String value) { + if (value == 'hide') { + showPopupMenu = false; + } + }, + ), + ], + ), + body: Text( + 'PopupMenuButton:${showPopupMenu ? 'PopupMenu is showing' : 'PopupMenu is hidden'}', + ), + ), + ); + } + + await tester.pumpWidget(buildWidget()); + + // Find the PopupMenuButton. + final Finder popupMenuButtonFinder = find.byType(PopupMenuButton<String>); + expect(popupMenuButtonFinder, findsOneWidget); + + // Tap on PopupMenuButton. + await tester.tap(popupMenuButtonFinder); + await tester.pumpAndSettle(); + + // Tap on "Hide" menu item. + await tester.tap(find.text('Hide')); + + // Rebuild the widget tree with the PopupMenuButton removed to trigger the bug. + await tester.pumpWidget(buildWidget()); + + // Verify no exception is thrown at a brief moment when the PopupMenuButton is hidden. + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/43824. + testWidgets('PopupMenu updates when PopupMenuTheme in Theme changes', ( + WidgetTester tester, + ) async { + Widget buildApp(PopupMenuThemeData popupMenuTheme) { + return MaterialApp( + theme: ThemeData(popupMenuTheme: popupMenuTheme), + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<String>( + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + const PopupMenuItem<String>(value: '0', child: Text('Item 0')), + const PopupMenuItem<String>(value: '1', child: Text('Item 1')), + ]; + }, + ), + ], + ), + ), + ); + } + + void checkPopupMenu(PopupMenuThemeData popupMenuTheme) { + final Material material = tester.widget(find.byType(Material).last); + expect(material.elevation, popupMenuTheme.elevation); + expect(material.color, popupMenuTheme.color); + expect(material.shadowColor, popupMenuTheme.shadowColor); + expect(material.surfaceTintColor, popupMenuTheme.surfaceTintColor); + expect(material.shape, popupMenuTheme.shape); + + final SingleChildScrollView scrollView = tester.widget(find.byType(SingleChildScrollView)); + expect(scrollView.padding, popupMenuTheme.menuPadding); + } + + const popupMenuTheme1 = PopupMenuThemeData( + elevation: 10, + color: Colors.red, + shadowColor: Colors.black, + surfaceTintColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + menuPadding: EdgeInsets.all(10), + ); + const popupMenuTheme2 = PopupMenuThemeData( + elevation: 20, + color: Colors.blue, + shadowColor: Colors.white, + surfaceTintColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))), + menuPadding: EdgeInsets.all(20), + ); + + // Show the menu with the first theme. + await tester.pumpWidget(buildApp(popupMenuTheme1)); + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pumpAndSettle(); + + checkPopupMenu(popupMenuTheme1); + + // Rebuild with the second theme. + await tester.pumpWidget(buildApp(popupMenuTheme2)); + await tester.pumpAndSettle(); + + checkPopupMenu(popupMenuTheme2); + }); + + testWidgets('CheckedPopupMenuItem does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: CheckedPopupMenuItem<String>(child: Text('X'))), + ), + ), + ), + ); + expect(tester.getSize(find.byType(CheckedPopupMenuItem<String>)), Size.zero); + }); + + testWidgets('PopupMenuItem does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: PopupMenuItem<String>(child: Text('X'))), + ), + ), + ), + ); + expect(tester.getSize(find.byType(PopupMenuItem<String>)), Size.zero); + }); + + testWidgets('PopupMenuButton does not crash at zero area', (WidgetTester tester) async { + // This test case only verifies the layout of the button itself, not the + // overlay, because there doesn't seem to be a way to open the menu at zero + // area. Though, this should be sufficient since the overlay has been verified + // by similar tests for MenuAnchor and PopupMenuItem. + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: PopupMenuButton<String>( + itemBuilder: (BuildContext context) => <PopupMenuItem<String>>[ + const PopupMenuItem<String>(value: 'X', child: Text('X')), + ], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(PopupMenuButton<String>)), Size.zero); + }); + + testWidgets('PopupMenuDivider does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: PopupMenuDivider())), + ), + ); + expect(tester.getSize(find.byType(PopupMenuDivider)), Size.zero); + }); + + // Regression test for https://github.com/flutter/flutter/issues/177003 + testWidgets('PopupMenu semantics for mismatched platforms', (WidgetTester tester) async { + Future<void> pumpPopupMenuWithTheme(TargetPlatform themePlatform) async { + const localizations = DefaultMaterialLocalizations(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: themePlatform), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return OutlinedButton( + onPressed: () { + showMenu<void>( + context: context, + position: RelativeRect.fill, + items: const <PopupMenuItem<void>>[PopupMenuItem<void>(child: Text('foo'))], + ); + }, + child: const Text('open'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final Finder popupFinder = find.bySemanticsLabel(localizations.popupMenuLabel); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(popupFinder, findsNothing); // Apple platforms don't show label. + case _: + expect(popupFinder, findsOneWidget); // Non-Apple platforms show label. + } + } + + // Test with theme.platform = Android on different real platforms. + await pumpPopupMenuWithTheme(TargetPlatform.android); + + // Dismiss the first popup. + Navigator.of(tester.element(find.text('foo'))).pop(); + await tester.pumpAndSettle(); + + // Test with theme.platform = iOS on different real platforms. + await pumpPopupMenuWithTheme(TargetPlatform.iOS); + }, variant: TargetPlatformVariant.all()); +} + +Matcher overlaps(Rect other) => OverlapsMatcher(other); + +class OverlapsMatcher extends Matcher { + OverlapsMatcher(this.other); + + final Rect other; + + @override + Description describe(Description description) { + return description.add('<Rect that overlaps with $other>'); + } + + @override + bool matches(Object? item, Map<dynamic, dynamic> matchState) => + item is Rect && item.overlaps(other); + + @override + Description describeMismatch( + dynamic item, + Description mismatchDescription, + Map<dynamic, dynamic> matchState, + bool verbose, + ) { + return mismatchDescription.add('does not overlap'); + } +} + +class TestApp extends StatelessWidget { + const TestApp({super.key, required this.textDirection, this.child}); + + final TextDirection textDirection; + final Widget? child; + + @override + Widget build(BuildContext context) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: MediaQuery( + data: MediaQueryData.fromView(View.of(context)), + child: Directionality( + textDirection: textDirection, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + assert(settings.name == '/'); + return MaterialPageRoute<void>( + settings: settings, + builder: (BuildContext context) => Material(child: child), + ); + }, + ), + ), + ), + ); + } +} + +class MenuObserver extends NavigatorObserver { + int menuCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route.toString().contains('_PopupMenuRoute')) { + menuCount++; + } + super.didPush(route, previousRoute); + } +} + +class _ClosureNavigatorObserver extends NavigatorObserver { + _ClosureNavigatorObserver({required this.onDidChange}); + + final void Function(Route<dynamic> newRoute) onDidChange; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => onDidChange(route); + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => onDidChange(previousRoute!); + + @override + void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => + onDidChange(previousRoute!); + + @override + void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => onDidChange(newRoute!); +} + +TextStyle? _labelStyle(WidgetTester tester, String label) { + return tester + .widget<RichText>(find.descendant(of: find.text(label), matching: find.byType(RichText))) + .text + .style; +} + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + return tester + .widget<RichText>(find.descendant(of: find.byIcon(icon), matching: find.byType(RichText))) + .text + .style; +} diff --git a/packages/material_ui/test/material/popup_menu_theme_test.dart b/packages/material_ui/test/material/popup_menu_theme_test.dart new file mode 100644 index 000000000000..8bd6ea107395 --- /dev/null +++ b/packages/material_ui/test/material/popup_menu_theme_test.dart @@ -0,0 +1,767 @@ +// 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' show kIsWeb; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +PopupMenuThemeData _popupMenuThemeM2() { + return PopupMenuThemeData( + color: Colors.orange, + shape: const BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + elevation: 12.0, + textStyle: const TextStyle(color: Color(0xffffffff), textBaseline: TextBaseline.alphabetic), + mouseCursor: WidgetStateProperty.resolveWith<MouseCursor?>((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.contextMenu; + } + return SystemMouseCursors.alias; + }), + ); +} + +PopupMenuThemeData _popupMenuThemeM3() { + return PopupMenuThemeData( + color: Colors.orange, + shape: const BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + menuPadding: const EdgeInsets.symmetric(vertical: 9.0), + elevation: 12.0, + shadowColor: const Color(0xff00ff00), + surfaceTintColor: const Color(0xff00ff00), + labelTextStyle: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const TextStyle(color: Color(0xfff99ff0), fontSize: 12.0); + } + return const TextStyle(color: Color(0xfff12099), fontSize: 17.0); + }), + mouseCursor: WidgetStateProperty.resolveWith<MouseCursor?>((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return SystemMouseCursors.contextMenu; + } + return SystemMouseCursors.alias; + }), + iconColor: const Color(0xfff12099), + iconSize: 17.0, + ); +} + +void main() { + test('PopupMenuThemeData copyWith, ==, hashCode basics', () { + expect(const PopupMenuThemeData(), const PopupMenuThemeData().copyWith()); + expect(const PopupMenuThemeData().hashCode, const PopupMenuThemeData().copyWith().hashCode); + }); + + test('PopupMenuThemeData lerp special cases', () { + expect(PopupMenuThemeData.lerp(null, null, 0), null); + const data = PopupMenuThemeData(); + expect(identical(PopupMenuThemeData.lerp(data, data, 0.5), data), true); + }); + + test('PopupMenuThemeData null fields by default', () { + const popupMenuTheme = PopupMenuThemeData(); + expect(popupMenuTheme.color, null); + expect(popupMenuTheme.shape, null); + expect(popupMenuTheme.menuPadding, null); + expect(popupMenuTheme.elevation, null); + expect(popupMenuTheme.shadowColor, null); + expect(popupMenuTheme.surfaceTintColor, null); + expect(popupMenuTheme.textStyle, null); + expect(popupMenuTheme.labelTextStyle, null); + expect(popupMenuTheme.enableFeedback, null); + expect(popupMenuTheme.mouseCursor, null); + }); + + testWidgets('Default PopupMenuThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const PopupMenuThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('PopupMenuThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + PopupMenuThemeData( + color: const Color(0xfffffff1), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + menuPadding: const EdgeInsets.symmetric(vertical: 12.0), + elevation: 2.0, + shadowColor: const Color(0xfffffff2), + surfaceTintColor: const Color(0xfffffff3), + textStyle: const TextStyle(color: Color(0xfffffff4)), + labelTextStyle: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const TextStyle(color: Color(0xfffffff5), fontSize: 12.0); + } + return const TextStyle(color: Color(0xfffffff6), fontSize: 17.0); + }), + enableFeedback: false, + mouseCursor: WidgetStateMouseCursor.clickable, + position: PopupMenuPosition.over, + iconColor: const Color(0xfffffff8), + iconSize: 31.0, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'color: ${const Color(0xfffffff1)}', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(2.0))', + 'menuPadding: EdgeInsets(0.0, 12.0, 0.0, 12.0)', + 'elevation: 2.0', + 'shadowColor: ${const Color(0xfffffff2)}', + 'surfaceTintColor: ${const Color(0xfffffff3)}', + 'text style: TextStyle(inherit: true, color: ${const Color(0xfffffff4)})', + "labelTextStyle: Instance of '_WidgetStatePropertyWith<TextStyle?>'", + 'enableFeedback: false', + 'mouseCursor: WidgetStateMouseCursor(clickable)', + 'position: over', + 'iconColor: ${const Color(0xfffffff8)}', + 'iconSize: 31.0', + ]); + }); + + testWidgets('Passing no PopupMenuThemeData returns defaults', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + final Key popupButtonApp = UniqueKey(); + final Key enabledPopupItemKey = UniqueKey(); + final Key disabledPopupItemKey = UniqueKey(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + key: popupButtonApp, + home: Material( + child: Column( + children: <Widget>[ + Padding( + // The padding makes sure the menu has enough space around it to + // get properly aligned when displayed (`_kMenuScreenPadding`). + padding: const EdgeInsets.all(8.0), + child: PopupMenuButton<void>( + key: popupButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[ + PopupMenuItem<void>( + key: enabledPopupItemKey, + child: const Text('Enabled PopupMenuItem'), + ), + const PopupMenuDivider(), + PopupMenuItem<void>( + key: disabledPopupItemKey, + enabled: false, + child: const Text('Disabled PopupMenuItem'), + ), + const CheckedPopupMenuItem<void>(child: Text('Unchecked item')), + const CheckedPopupMenuItem<void>(checked: true, child: Text('Checked item')), + ]; + }, + ), + ), + ], + ), + ), + ), + ); + + // Test default button icon color. + expect(_iconStyle(tester, Icons.adaptive.more)?.color, theme.iconTheme.color); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + /// The last Material widget under popupButtonApp is the [PopupMenuButton] + /// specified above, so by finding the last descendent of popupButtonApp + /// that is of type Material, this code retrieves the built + /// [PopupMenuButton]. + final Material button = tester.widget<Material>( + find.descendant(of: find.byKey(popupButtonApp), matching: find.byType(Material)).last, + ); + expect(button.color, theme.colorScheme.surfaceContainer); + expect(button.shadowColor, theme.colorScheme.shadow); + expect(button.surfaceTintColor, Colors.transparent); + expect( + button.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + expect(button.elevation, 3.0); + + /// The last DefaultTextStyle widget under popupItemKey is the + /// [PopupMenuItem] specified above, so by finding the last descendent of + /// popupItemKey that is of type DefaultTextStyle, this code retrieves the + /// built [PopupMenuItem]. + DefaultTextStyle popupMenuItemLabel = tester.widget<DefaultTextStyle>( + find + .descendant(of: find.byKey(enabledPopupItemKey), matching: find.byType(DefaultTextStyle)) + .last, + ); + expect(popupMenuItemLabel.style.fontFamily, 'Roboto'); + expect(popupMenuItemLabel.style.color, theme.colorScheme.onSurface); + + /// Test disabled text color + popupMenuItemLabel = tester.widget<DefaultTextStyle>( + find + .descendant(of: find.byKey(disabledPopupItemKey), matching: find.byType(DefaultTextStyle)) + .last, + ); + expect(popupMenuItemLabel.style.color, theme.colorScheme.onSurface.withOpacity(0.38)); + + final Offset topLeftButton = tester.getTopLeft(find.byType(PopupMenuButton<void>)); + final Offset topLeftMenu = tester.getTopLeft(find.byWidget(button)); + expect(topLeftMenu, topLeftButton); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(disabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.down(tester.getCenter(find.byKey(enabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test unchecked CheckedPopupMenuItem label. + ListTile listTile = tester.widget<ListTile>(find.byType(ListTile).first); + expect(listTile.titleTextStyle?.color, theme.colorScheme.onSurface); + + // Test checked CheckedPopupMenuItem label. + listTile = tester.widget<ListTile>(find.byType(ListTile).last); + expect(listTile.titleTextStyle?.color, theme.colorScheme.onSurface); + + // Check popup menu padding. + final SingleChildScrollView popupMenu = tester.widget<SingleChildScrollView>( + find.byType(SingleChildScrollView), + ); + expect(popupMenu.padding, const EdgeInsets.symmetric(vertical: 8.0)); + }); + + testWidgets('Popup menu uses values from PopupMenuThemeData', (WidgetTester tester) async { + final PopupMenuThemeData popupMenuTheme = _popupMenuThemeM3(); + final Key popupButtonKey = UniqueKey(); + final Key popupButtonApp = UniqueKey(); + final Key enabledPopupItemKey = UniqueKey(); + final Key disabledPopupItemKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(popupMenuTheme: popupMenuTheme), + key: popupButtonApp, + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<void>( + // The padding is used in the positioning of the menu when the + // position is `PopupMenuPosition.under`. Setting it to zero makes + // it easier to test. + padding: EdgeInsets.zero, + key: popupButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<Object>>[ + PopupMenuItem<Object>( + key: disabledPopupItemKey, + enabled: false, + child: const Text('disabled'), + ), + const PopupMenuDivider(), + PopupMenuItem<Object>( + key: enabledPopupItemKey, + onTap: () {}, + child: const Text('enabled'), + ), + const CheckedPopupMenuItem<Object>(child: Text('Unchecked item')), + const CheckedPopupMenuItem<Object>(checked: true, child: Text('Checked item')), + ]; + }, + ), + ], + ), + ), + ), + ); + + expect(_iconStyle(tester, Icons.adaptive.more)?.color, popupMenuTheme.iconColor); + expect( + tester.getSize(find.byIcon(Icons.adaptive.more)), + Size(popupMenuTheme.iconSize!, popupMenuTheme.iconSize!), + ); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + /// The last Material widget under popupButtonApp is the [PopupMenuButton] + /// specified above, so by finding the last descendent of popupButtonApp + /// that is of type Material, this code retrieves the built + /// [PopupMenuButton]. + final Material button = tester.widget<Material>( + find.descendant(of: find.byKey(popupButtonApp), matching: find.byType(Material)).last, + ); + expect(button.color, Colors.orange); + expect(button.surfaceTintColor, const Color(0xff00ff00)); + expect(button.shadowColor, const Color(0xff00ff00)); + expect( + button.shape, + const BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ); + expect(button.elevation, 12.0); + + DefaultTextStyle popupMenuItemLabel = tester.widget<DefaultTextStyle>( + find + .descendant(of: find.byKey(enabledPopupItemKey), matching: find.byType(DefaultTextStyle)) + .last, + ); + expect(popupMenuItemLabel.style, popupMenuTheme.labelTextStyle?.resolve(enabled)); + + /// Test disabled text color + popupMenuItemLabel = tester.widget<DefaultTextStyle>( + find + .descendant(of: find.byKey(disabledPopupItemKey), matching: find.byType(DefaultTextStyle)) + .last, + ); + expect(popupMenuItemLabel.style, popupMenuTheme.labelTextStyle?.resolve(disabled)); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(disabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + popupMenuTheme.mouseCursor?.resolve(disabled), + ); + await gesture.down(tester.getCenter(find.byKey(enabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + popupMenuTheme.mouseCursor?.resolve(enabled), + ); + + // Test unchecked CheckedPopupMenuItem label. + ListTile listTile = tester.widget<ListTile>(find.byType(ListTile).first); + expect(listTile.titleTextStyle, popupMenuTheme.labelTextStyle?.resolve(enabled)); + + // Test checked CheckedPopupMenuItem label. + listTile = tester.widget<ListTile>(find.byType(ListTile).last); + expect(listTile.titleTextStyle, popupMenuTheme.labelTextStyle?.resolve(enabled)); + + // Check popup menu padding. + final SingleChildScrollView popupMenu = tester.widget<SingleChildScrollView>( + find.byType(SingleChildScrollView), + ); + expect(popupMenu.padding, popupMenuTheme.menuPadding); + }); + + testWidgets('Popup menu widget properties take priority over theme', (WidgetTester tester) async { + final PopupMenuThemeData popupMenuTheme = _popupMenuThemeM3(); + final Key popupButtonKey = UniqueKey(); + final Key popupButtonApp = UniqueKey(); + final Key popupItemKey = UniqueKey(); + + const color = Color(0xfff11fff); + const surfaceTintColor = Color(0xfff12fff); + const shadowColor = Color(0xfff13fff); + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + const EdgeInsets menuPadding = EdgeInsets.zero; + const elevation = 7.0; + const textStyle = TextStyle(color: Color(0xfff14fff), fontSize: 19.0); + const MouseCursor cursor = SystemMouseCursors.forbidden; + const iconColor = Color(0xfff15fff); + const iconSize = 21.5; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(popupMenuTheme: popupMenuTheme), + key: popupButtonApp, + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<void>( + key: popupButtonKey, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + color: color, + shape: shape, + menuPadding: menuPadding, + iconColor: iconColor, + iconSize: iconSize, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[ + PopupMenuItem<void>( + key: popupItemKey, + labelTextStyle: WidgetStateProperty.all<TextStyle>(textStyle), + mouseCursor: cursor, + child: const Text('Example'), + ), + CheckedPopupMenuItem<void>( + checked: true, + labelTextStyle: WidgetStateProperty.all<TextStyle>(textStyle), + child: const Text('Checked item'), + ), + ]; + }, + ), + ], + ), + ), + ), + ); + + expect(_iconStyle(tester, Icons.adaptive.more)?.color, iconColor); + expect(tester.getSize(find.byIcon(Icons.adaptive.more)), const Size(iconSize, iconSize)); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + /// The last Material widget under popupButtonApp is the [PopupMenuButton] + /// specified above, so by finding the last descendent of popupButtonApp + /// that is of type Material, this code retrieves the built + /// [PopupMenuButton]. + final Material button = tester.widget<Material>( + find.descendant(of: find.byKey(popupButtonApp), matching: find.byType(Material)).last, + ); + expect(button.color, color); + expect(button.shape, shape); + expect(button.elevation, elevation); + expect(button.shadowColor, shadowColor); + expect(button.surfaceTintColor, surfaceTintColor); + + /// The last DefaultTextStyle widget under popupItemKey is the + /// [PopupMenuItem] specified above, so by finding the last descendent of + /// popupItemKey that is of type DefaultTextStyle, this code retrieves the + /// built [PopupMenuItem]. + final DefaultTextStyle text = tester.widget<DefaultTextStyle>( + find.descendant(of: find.byKey(popupItemKey), matching: find.byType(DefaultTextStyle)).last, + ); + expect(text.style, textStyle); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(popupItemKey))); + await tester.pumpAndSettle(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), cursor); + + // Test CheckedPopupMenuItem label. + final ListTile listTile = tester.widget<ListTile>(find.byType(ListTile).first); + expect(listTile.titleTextStyle, textStyle); + + // Check popup menu padding. + final SingleChildScrollView popupMenu = tester.widget<SingleChildScrollView>( + find.byType(SingleChildScrollView), + ); + expect(popupMenu.padding, EdgeInsets.zero); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Passing no PopupMenuThemeData returns defaults', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + final Key popupButtonApp = UniqueKey(); + final Key enabledPopupItemKey = UniqueKey(); + final Key disabledPopupItemKey = UniqueKey(); + final theme = ThemeData(useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + key: popupButtonApp, + home: Material( + child: Column( + children: <Widget>[ + Padding( + // The padding makes sure the menu has enough space around it to + // get properly aligned when displayed (`_kMenuScreenPadding`). + padding: const EdgeInsets.all(8.0), + child: PopupMenuButton<void>( + key: popupButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[ + PopupMenuItem<void>( + key: enabledPopupItemKey, + child: const Text('Enabled PopupMenuItem'), + ), + const PopupMenuDivider(), + PopupMenuItem<void>( + key: disabledPopupItemKey, + enabled: false, + child: const Text('Disabled PopupMenuItem'), + ), + ]; + }, + ), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + /// The last Material widget under popupButtonApp is the [PopupMenuButton] + /// specified above, so by finding the last descendent of popupButtonApp + /// that is of type Material, this code retrieves the built + /// [PopupMenuButton]. + final Material button = tester.widget<Material>( + find.descendant(of: find.byKey(popupButtonApp), matching: find.byType(Material)).last, + ); + expect(button.color, null); + expect(button.shape, null); + expect(button.elevation, 8.0); + + /// The last DefaultTextStyle widget under popupItemKey is the + /// [PopupMenuItem] specified above, so by finding the last descendent of + /// popupItemKey that is of type DefaultTextStyle, this code retrieves the + /// built [PopupMenuItem]. + final DefaultTextStyle enabledText = tester.widget<DefaultTextStyle>( + find + .descendant( + of: find.byKey(enabledPopupItemKey), + matching: find.byType(DefaultTextStyle), + ) + .last, + ); + expect(enabledText.style.fontFamily, 'Roboto'); + expect(enabledText.style.color, const Color(0xdd000000)); + + /// Test disabled text color + final DefaultTextStyle disabledText = tester.widget<DefaultTextStyle>( + find + .descendant( + of: find.byKey(disabledPopupItemKey), + matching: find.byType(DefaultTextStyle), + ) + .last, + ); + expect(disabledText.style.color, theme.disabledColor); + + final Offset topLeftButton = tester.getTopLeft(find.byType(PopupMenuButton<void>)); + final Offset topLeftMenu = tester.getTopLeft(find.byWidget(button)); + expect(topLeftMenu, topLeftButton); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(disabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.down(tester.getCenter(find.byKey(enabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Check popup menu padding. + final SingleChildScrollView popupMenu = tester.widget<SingleChildScrollView>( + find.byType(SingleChildScrollView), + ); + expect(popupMenu.padding, const EdgeInsets.symmetric(vertical: 8.0)); + }); + + testWidgets('Popup menu uses values from PopupMenuThemeData', (WidgetTester tester) async { + final PopupMenuThemeData popupMenuTheme = _popupMenuThemeM2(); + final Key popupButtonKey = UniqueKey(); + final Key popupButtonApp = UniqueKey(); + final Key enabledPopupItemKey = UniqueKey(); + final Key disabledPopupItemKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(popupMenuTheme: popupMenuTheme, useMaterial3: false), + key: popupButtonApp, + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<void>( + // The padding is used in the positioning of the menu when the + // position is `PopupMenuPosition.under`. Setting it to zero makes + // it easier to test. + padding: EdgeInsets.zero, + key: popupButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<Object>>[ + PopupMenuItem<Object>( + key: disabledPopupItemKey, + enabled: false, + child: const Text('disabled'), + ), + const PopupMenuDivider(), + PopupMenuItem<Object>( + key: enabledPopupItemKey, + onTap: () {}, + child: const Text('enabled'), + ), + ]; + }, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + /// The last Material widget under popupButtonApp is the [PopupMenuButton] + /// specified above, so by finding the last descendent of popupButtonApp + /// that is of type Material, this code retrieves the built + /// [PopupMenuButton]. + final Material button = tester.widget<Material>( + find.descendant(of: find.byKey(popupButtonApp), matching: find.byType(Material)).last, + ); + expect(button.color, popupMenuTheme.color); + expect(button.shape, popupMenuTheme.shape); + expect(button.elevation, popupMenuTheme.elevation); + + /// The last DefaultTextStyle widget under popupItemKey is the + /// [PopupMenuItem] specified above, so by finding the last descendent of + /// popupItemKey that is of type DefaultTextStyle, this code retrieves the + /// built [PopupMenuItem]. + final DefaultTextStyle text = tester.widget<DefaultTextStyle>( + find + .descendant( + of: find.byKey(enabledPopupItemKey), + matching: find.byType(DefaultTextStyle), + ) + .last, + ); + expect(text.style, popupMenuTheme.textStyle); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(disabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + popupMenuTheme.mouseCursor?.resolve(disabled), + ); + await gesture.down(tester.getCenter(find.byKey(enabledPopupItemKey))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + popupMenuTheme.mouseCursor?.resolve(enabled), + ); + }); + + testWidgets('Popup menu widget properties take priority over theme', ( + WidgetTester tester, + ) async { + final PopupMenuThemeData popupMenuTheme = _popupMenuThemeM2(); + final Key popupButtonKey = UniqueKey(); + final Key popupButtonApp = UniqueKey(); + final Key popupItemKey = UniqueKey(); + + const Color color = Colors.purple; + const Color surfaceTintColor = Colors.amber; + const Color shadowColor = Colors.green; + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + const elevation = 7.0; + const textStyle = TextStyle(color: Color(0xffffffef), fontSize: 19.0); + const MouseCursor cursor = SystemMouseCursors.forbidden; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(popupMenuTheme: popupMenuTheme), + key: popupButtonApp, + home: Material( + child: Column( + children: <Widget>[ + PopupMenuButton<void>( + key: popupButtonKey, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + color: color, + shape: shape, + itemBuilder: (BuildContext context) { + return <PopupMenuEntry<void>>[ + PopupMenuItem<void>( + key: popupItemKey, + labelTextStyle: WidgetStateProperty.all<TextStyle>(textStyle), + mouseCursor: cursor, + child: const Text('Example'), + ), + ]; + }, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + + /// The last Material widget under popupButtonApp is the [PopupMenuButton] + /// specified above, so by finding the last descendent of popupButtonApp + /// that is of type Material, this code retrieves the built + /// [PopupMenuButton]. + final Material button = tester.widget<Material>( + find.descendant(of: find.byKey(popupButtonApp), matching: find.byType(Material)).last, + ); + expect(button.color, color); + expect(button.shape, shape); + expect(button.elevation, elevation); + expect(button.shadowColor, shadowColor); + expect(button.surfaceTintColor, surfaceTintColor); + + /// The last DefaultTextStyle widget under popupItemKey is the + /// [PopupMenuItem] specified above, so by finding the last descendent of + /// popupItemKey that is of type DefaultTextStyle, this code retrieves the + /// built [PopupMenuItem]. + final DefaultTextStyle text = tester.widget<DefaultTextStyle>( + find.descendant(of: find.byKey(popupItemKey), matching: find.byType(DefaultTextStyle)).last, + ); + expect(text.style, textStyle); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(popupItemKey))); + await tester.pumpAndSettle(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), cursor); + }); + }); +} + +Set<WidgetState> enabled = <WidgetState>{}; +Set<WidgetState> disabled = <WidgetState>{WidgetState.disabled}; + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + return tester + .widget<RichText>(find.descendant(of: find.byIcon(icon), matching: find.byType(RichText))) + .text + .style; +} diff --git a/packages/material_ui/test/material/predictive_back_page_transitions_builder_test.dart b/packages/material_ui/test/material/predictive_back_page_transitions_builder_test.dart new file mode 100644 index 000000000000..c50e369a4630 --- /dev/null +++ b/packages/material_ui/test/material/predictive_back_page_transitions_builder_test.dart @@ -0,0 +1,809 @@ +// 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/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + for (final pageTransitionsBuilder in <PageTransitionsBuilder>[ + const PredictiveBackPageTransitionsBuilder(), + const PredictiveBackFullscreenPageTransitionsBuilder(), + ]) { + testWidgets( + 'PredictiveBackPageTransitionsBuilder supports predictive back on Android', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + routes: routes, + ), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + // Only Android supports backGesture channel methods. Other platforms will + // do nothing. + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + + // Start a system pop gesture, which will switch to using + // _PredictiveBackSharedElementPageTransition for the page transition. + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + final Offset startPageBOffset = tester.getTopLeft(find.text('page b')); + expect(startPageBOffset.dx, 0.0); + + // Drag the system back gesture far enough to commit. + final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'x': 100.0, + 'y': 300.0, + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + final Offset updatePageBOffset = tester.getTopLeft(find.text('page b')); + expect(updatePageBOffset.dx, greaterThan(startPageBOffset.dx)); + + // Commit the system back gesture. + final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('commitBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'PredictiveBackPageTransitionsBuilder supports canceling a predictive back gesture', + (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + routes: routes, + ), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + // Only Android supports backGesture channel methods. Other platforms will + // do nothing. + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + + // Start a system pop gesture, which will switch to using + // _PredictiveBackSharedElementPageTransition for the page transition. + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + final Offset startPageBOffset = tester.getTopLeft(find.text('page b')); + expect(startPageBOffset.dx, 0.0); + + // Drag the system back gesture. + final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'touchOffset': <double>[100.0, 300.0], + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + final Offset updatePageBOffset = tester.getTopLeft(find.text('page b')); + expect(updatePageBOffset.dx, greaterThan(startPageBOffset.dx)); + + // Cancel the system back gesture. + final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('cancelBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'if there are multiple PredictiveBackPageTransitionBuilder observers, only one gets called for a given back gesture', + (WidgetTester tester) async { + var includingNestedNavigator = false; + late StateSetter setState; + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + const Text('page b'), + StatefulBuilder( + builder: (BuildContext context, StateSetter localSetState) { + setState = localSetState; + if (!includingNestedNavigator) { + return const SizedBox.shrink(); + } + return Navigator( + initialRoute: 'b/nested', + onGenerateRoute: (RouteSettings settings) { + WidgetBuilder builder; + switch (settings.name) { + case 'b/nested': + builder = (BuildContext context) => Material( + child: Theme( + data: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + child: const Column( + children: <Widget>[Text('Nested route inside of page b')], + ), + ), + ); + default: + throw Exception('Invalid route: ${settings.name}'); + } + return MaterialPageRoute<void>(builder: builder, settings: settings); + }, + ); + }, + ), + ], + ), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + routes: routes, + ), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + expect(find.text('Nested route inside of page b'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('page b'), findsOneWidget); + expect(find.text('Nested route inside of page b'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + // Only Android supports backGesture channel methods. Other platforms will + // do nothing. + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + + // Start a system pop gesture, which will switch to using + // _PredictiveBackSharedElementPageTransition for the page transition. + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + final Offset startPageBOffset = tester.getTopLeft(find.text('page b')); + expect(startPageBOffset.dx, 0.0); + + // Drag the system back gesture. + final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'touchOffset': <double>[100.0, 300.0], + 'progress': 0.3, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + final Offset updatePageBOffset = tester.getTopLeft(find.text('page b')); + expect(updatePageBOffset.dx, greaterThan(startPageBOffset.dx)); + + // In the middle of the system back gesture here, add a nested Navigator + // that includes a new predictive back gesture observer. + setState(() { + includingNestedNavigator = true; + }); + await tester.pumpAndSettle(); + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsOneWidget); + expect(find.text('Nested route inside of page b'), findsOneWidget); + + // Send another drag gesture, and ensure that the original observer still + // gets it. + final ByteData updateMessage2 = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'touchOffset': <double>[110.0, 300.0], + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage2, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + // Despite using a PredictiveBackPageTransitions, the new route has not + // received a start event, so it is still using the fallback transition. + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + final Offset update2PageBOffset = tester.getTopLeft(find.text('page b')); + expect(update2PageBOffset.dx, greaterThan(updatePageBOffset.dx)); + + // Commit the system back gesture, and the original observer is able to + // handle the back without interference. + final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('commitBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + expect(find.text('Nested route inside of page b'), findsNothing); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('two back gestures back to back dismiss two routes', (WidgetTester tester) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push b'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => Material( + child: TextButton( + child: const Text('push c'), + onPressed: () { + Navigator.of(context).pushNamed('/c'); + }, + ), + ), + '/c': (BuildContext context) => const Text('page c'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + routes: routes, + ), + ); + + expect(find.text('push b'), findsOneWidget); + expect(find.text('push c'), findsNothing); + expect(find.text('page c'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + await tester.tap(find.text('push b')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('push c'), findsOneWidget); + expect(find.text('page c'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + await tester.tap(find.text('push c')); + await tester.pumpAndSettle(); + + expect(find.text('push'), findsNothing); + expect(find.text('push c'), findsNothing); + expect(find.text('page c'), findsOneWidget); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + // Only Android supports backGesture channel methods. Other platforms will + // do nothing. + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + + // Start a system pop gesture, which will switch to using + // _PredictiveBackSharedElementPageTransition for the page transition. + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + // Drag the system back gesture far enough to commit. + final ByteData updateMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'x': 100.0, + 'y': 300.0, + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + // Commit the system back gesture. + final ByteData commitMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('commitBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + // The predictive back page transitions still exist because the outgoing + // animation has not yet finished. + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + expect(find.text('push'), findsNothing); + expect(find.text('push c'), findsOneWidget); + expect(find.text('page c'), findsOneWidget); + + // Start another system pop gesture, before the first has finished + // animating out. + final ByteData startMessage2 = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage2, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(2)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + // Drag the system back gesture far enough to commit. + final ByteData updateMessage2 = const StandardMethodCodec().encodeMethodCall( + const MethodCall('updateBackGestureProgress', <String, dynamic>{ + 'x': 100.0, + 'y': 300.0, + 'progress': 0.35, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + updateMessage2, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNWidgets(3)); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + + // Commit the system back gesture. + final ByteData commitMessage2 = const StandardMethodCodec().encodeMethodCall( + const MethodCall('commitBackGesture'), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + commitMessage2, + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + expect(find.text('push b'), findsOneWidget); + expect(find.text('push c'), findsNothing); + expect(find.text('page c'), findsNothing); + }, variant: TargetPlatformVariant.all()); + + testWidgets('PredictiveBackPageTransitionsBuilder uses display corner radii when available', ( + WidgetTester tester, + ) async { + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + const displayCornerRadii = BorderRadius.all(Radius.circular(33.3)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).applyDisplayCornerRadii(displayCornerRadii), + child: child!, + ); + }, + routes: routes, + ), + ); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(find.byType(ClipRRect), findsNothing); + + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(find.byType(ClipRRect), findsNothing); + + // Start a system pop gesture, which will switch to using + // _PredictiveBackSharedElementPageTransition for the page transition. + final ByteData startMessage = const StandardMethodCodec().encodeMethodCall( + const MethodCall('startBackGesture', <String, dynamic>{ + 'touchOffset': <double>[5.0, 300.0], + 'progress': 0.0, + 'swipeEdge': 0, // left + }), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/backgesture', + startMessage, + (ByteData? _) {}, + ); + await tester.pump(); + + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsOneWidget); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsNothing); + expect( + find.byWidgetPredicate( + (Widget widget) => widget is ClipRRect && widget.borderRadius == displayCornerRadii, + ), + findsOneWidget, + ); + }); + } + + testWidgets('PredictiveBackPageTransitionsBuilder uses fallbackColor', ( + WidgetTester tester, + ) async { + const PageTransitionsBuilder pageTransitionsBuilder = PredictiveBackPageTransitionsBuilder( + fallbackColor: Colors.black, + ); + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + routes: routes, + ), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + await tester.tap(find.text('push')); + await tester.pump(const Duration(milliseconds: 400)); + + final Finder coloredBoxFinder = find.byType(ColoredBox).last; + expect(coloredBoxFinder, findsOneWidget); + final ColoredBox coloredBox = tester.widget<ColoredBox>(coloredBoxFinder); + expect(coloredBox.color, Colors.black); + + await tester.pumpAndSettle(); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'PredictiveBackFullscreenPageTransitionsBuilder uses fallbackColor', + (WidgetTester tester) async { + const PageTransitionsBuilder pageTransitionsBuilder = + PredictiveBackFullscreenPageTransitionsBuilder(fallbackColor: Colors.black); + final routes = <String, WidgetBuilder>{ + '/': (BuildContext context) => Material( + child: TextButton( + child: const Text('push'), + onPressed: () { + Navigator.of(context).pushNamed('/b'); + }, + ), + ), + '/b': (BuildContext context) => const Text('page b'), + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + pageTransitionsTheme: PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: pageTransitionsBuilder, + }, + ), + ), + routes: routes, + ), + ); + + expect(find.text('push'), findsOneWidget); + expect(find.text('page b'), findsNothing); + expect(_findPredictiveBackPageTransition(pageTransitionsBuilder), findsNothing); + expect(_findFallbackPageTransition(pageTransitionsBuilder), findsOneWidget); + + // Pump till animation is half-way through. + await tester.tap(find.text('push')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); + + // Verify that the render box is painting the right color. + final RenderBox nonScaffoldedRenderBox = tester.firstRenderObject<RenderBox>( + find.byType(MaterialApp), + ); + // Expect the color to be at exactly 59.6% opacity at this time. + expect(nonScaffoldedRenderBox, paints..rect(color: Colors.black.withOpacity(0.596))); + + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant.all(), + ); +} + +String _getTransitionsString(PageTransitionsBuilder pageTransitionsBuilder) { + return switch (pageTransitionsBuilder) { + PredictiveBackPageTransitionsBuilder() => '_PredictiveBackSharedElementPageTransition', + PredictiveBackFullscreenPageTransitionsBuilder() => '_PredictiveBackFullscreenPageTransition', + _ => throw UnsupportedError('Unsupported subclass of PageTransitionsBuilder'), + }; +} + +Finder _findPredictiveBackPageTransition(PageTransitionsBuilder pageTransitionsBuilder) { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == _getTransitionsString(pageTransitionsBuilder), + ), + ); +} + +Finder _findFallbackPageTransition(PageTransitionsBuilder pageTransitionsBuilder) { + final String fallback = switch (pageTransitionsBuilder) { + final PredictiveBackPageTransitionsBuilder _ => '_FadeForwardsPageTransition', + final PredictiveBackFullscreenPageTransitionsBuilder _ => '_ZoomPageTransition', + _ => throw TypeError(), + }; + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == fallback), + ); +} diff --git a/packages/material_ui/test/material/progress_indicator_test.dart b/packages/material_ui/test/material/progress_indicator_test.dart new file mode 100644 index 000000000000..1ceab65d9c48 --- /dev/null +++ b/packages/material_ui/test/material/progress_indicator_test.dart @@ -0,0 +1,2149 @@ +// 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. + +// no-shuffle: +// //TODO(gspencergoog): Remove this tag once this test's state leaks/test +// dependencies have been fixed. +// https://github.com/flutter/flutter/issues/85160 +// Fails with "flutter test --test-randomize-ordering-seed=456" +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set', 'no-shuffle']) +library; + +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final theme = ThemeData(); + + // The "can be constructed" tests that follow are primarily to ensure that any + // animations started by the progress indicators are stopped at dispose() time. + + testWidgets( + 'LinearProgressIndicator(value: 0.0) can be constructed and has empty semantics by default', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.0)), + ), + ), + ), + ); + + expect(tester.getSemantics(find.byType(LinearProgressIndicator)), matchesSemantics()); + handle.dispose(); + }, + ); + + testWidgets( + 'LinearProgressIndicator(value: null) can be constructed and has empty semantics by default', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.rtl, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator())), + ), + ), + ); + + expect(tester.getSemantics(find.byType(LinearProgressIndicator)), matchesSemantics()); + handle.dispose(); + }, + ); + + testWidgets('LinearProgressIndicator custom minHeight', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(value: 0.25, minHeight: 2.0), + ), + ), + ), + ), + ); + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 2.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 2.0)), + ); + + // Same test, but using the theme + await tester.pumpWidget( + Theme( + data: theme.copyWith( + progressIndicatorTheme: const ProgressIndicatorThemeData(linearMinHeight: 2.0), + ), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.25))), + ), + ), + ); + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 2.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 2.0)), + ); + }); + + testWidgets('LinearProgressIndicator paint (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.25))), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0)), + ); + + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('LinearProgressIndicator paint (RTL)', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.rtl, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.25))), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(150.0, 0.0, 200.0, 4.0)), + ); + + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('LinearProgressIndicator indeterminate (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator())), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 300)); + final double animationValue = const Interval( + 0.0, + 750.0 / 1800.0, + curve: Cubic(0.2, 0.0, 0.8, 1.0), + ).transform(300.0 / 1800.0); + + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: Rect.fromLTRB(animationValue * 200.0, 0.0, 200.0, 4.0)) // Track + ..rect(rect: Rect.fromLTRB(0.0, 0.0, animationValue * 200.0, 4.0)), // Active indicator + ); + + expect(tester.binding.transientCallbackCount, 1); + }); + + testWidgets('LinearProgressIndicator paint (RTL)', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.rtl, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator())), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 300)); + final double animationValue = const Interval( + 0.0, + 750.0 / 1800.0, + curve: Cubic(0.2, 0.0, 0.8, 1.0), + ).transform(300.0 / 1800.0); + + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: Rect.fromLTRB(0.0, 0.0, 200.0 - animationValue * 200.0, 4.0)) + ..rect(rect: Rect.fromLTRB(200.0 - animationValue * 200.0, 0.0, 200.0, 4.0)), + ); + + expect(tester.binding.transientCallbackCount, 1); + }); + + testWidgets('LinearProgressIndicator with colors', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.25))), + ), + ); + + // Defaults. + expect( + find.byType(LinearProgressIndicator), + paints + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0), + color: theme.colorScheme.secondaryContainer, + ) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0), color: theme.colorScheme.primary), + ); + + // With valueColor & color provided + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator( + value: 0.25, + backgroundColor: Colors.black, + color: Colors.blue, + valueColor: AlwaysStoppedAnimation<Color>(Colors.white), + ), + ), + ), + ), + ), + ); + + // Should use valueColor + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0), color: Colors.white), + ); + + // With just color provided + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator( + value: 0.25, + backgroundColor: Colors.black, + color: Colors.white12, + ), + ), + ), + ), + ), + ); + + // Should use color + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0), color: Colors.white12), + ); + + // With no color provided + const primaryColor = Color(0xff008800); + await tester.pumpWidget( + Theme( + data: theme.copyWith(colorScheme: ColorScheme.fromSwatch().copyWith(primary: primaryColor)), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(value: 0.25, backgroundColor: Colors.black), + ), + ), + ), + ), + ); + + // Should use the theme's primary color + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0), color: primaryColor), + ); + + // With ProgressIndicatorTheme colors + const indicatorColor = Color(0xff0000ff); + await tester.pumpWidget( + Theme( + data: theme.copyWith( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: indicatorColor, + linearTrackColor: Colors.black, + ), + ), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.25))), + ), + ), + ); + + // Should use the progress indicator theme colors + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0), color: indicatorColor), + ); + }); + + testWidgets('LinearProgressIndicator with animation with null colors', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator( + value: 0.25, + valueColor: AlwaysStoppedAnimation<Color?>(null), + backgroundColor: Colors.black, + ), + ), + ), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 50.0, 4.0)), + ); + }); + + testWidgets( + 'CircularProgressIndicator(value: 0.0) can be constructed and has value semantics by default', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: CircularProgressIndicator(value: 0.0)), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(CircularProgressIndicator)), + matchesSemantics( + value: '0', + textDirection: TextDirection.ltr, + minValue: '0', + maxValue: '100', + ), + ); + handle.dispose(); + }, + ); + + testWidgets( + 'CircularProgressIndicator(value: null) can be constructed and has empty semantics by default', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + Theme( + data: theme, + child: const Center(child: CircularProgressIndicator()), + ), + ); + + expect(tester.getSemantics(find.byType(CircularProgressIndicator)), matchesSemantics()); + handle.dispose(); + }, + ); + + testWidgets('LinearProgressIndicator causes a repaint when it changes', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: ListView(children: const <Widget>[LinearProgressIndicator(value: 0.0)]), + ), + ), + ); + final List<Layer> layers1 = tester.layers; + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: ListView(children: const <Widget>[LinearProgressIndicator(value: 0.5)]), + ), + ), + ); + final List<Layer> layers2 = tester.layers; + expect(layers1, isNot(equals(layers2))); + }); + + testWidgets('CircularProgressIndicator stroke width', (WidgetTester tester) async { + await tester.pumpWidget(Theme(data: theme, child: const CircularProgressIndicator())); + + expect(find.byType(CircularProgressIndicator), paints..arc(strokeWidth: 4.0)); + + await tester.pumpWidget( + Theme(data: theme, child: const CircularProgressIndicator(strokeWidth: 16.0)), + ); + + expect(find.byType(CircularProgressIndicator), paints..arc(strokeWidth: 16.0)); + }); + + testWidgets('CircularProgressIndicator.adaptive stroke width', (WidgetTester tester) async { + await tester.pumpWidget(const CircularProgressIndicator.adaptive()); + + // The default strokeWidth is 4.0 + expect(find.byType(CircularProgressIndicator), paints..arc(strokeWidth: 4.0)); + + final ThemeData themeData = theme.copyWith( + progressIndicatorTheme: const ProgressIndicatorThemeData(strokeWidth: 10.0), + ); + await tester.pumpWidget( + Theme(data: themeData, child: const CircularProgressIndicator.adaptive()), + ); + + // Get the theme’s strokeWidth. + expect(find.byType(CircularProgressIndicator), paints..arc(strokeWidth: 10.0)); + + await tester.pumpWidget( + Theme(data: themeData, child: const CircularProgressIndicator.adaptive(strokeWidth: 16.0)), + ); + + // The strokeWidth parameter should override the theme’s strokeWidth. + expect(find.byType(CircularProgressIndicator), paints..arc(strokeWidth: 16.0)); + }); + + testWidgets('CircularProgressIndicator strokeAlign', (WidgetTester tester) async { + await tester.pumpWidget(Theme(data: theme, child: const CircularProgressIndicator())); + expect( + find.byType(CircularProgressIndicator), + paints..arc(rect: Offset.zero & const Size(800.0, 600.0)), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const CircularProgressIndicator( + strokeAlign: CircularProgressIndicator.strokeAlignInside, + ), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints..arc(rect: const Offset(2.0, 2.0) & const Size(796.0, 596.0)), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const CircularProgressIndicator( + strokeAlign: CircularProgressIndicator.strokeAlignOutside, + ), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints..arc(rect: const Offset(-2.0, -2.0) & const Size(804.0, 604.0)), + ); + + // Unbounded alignment. + await tester.pumpWidget( + Theme(data: theme, child: const CircularProgressIndicator(strokeAlign: 2.0)), + ); + expect( + find.byType(CircularProgressIndicator), + paints..arc(rect: const Offset(-4.0, -4.0) & const Size(808.0, 608.0)), + ); + }); + + testWidgets('CircularProgressIndicator with strokeCap', (WidgetTester tester) async { + await tester.pumpWidget(const CircularProgressIndicator()); + expect( + find.byType(CircularProgressIndicator), + paints..arc(strokeCap: StrokeCap.square), + reason: 'Default indeterminate strokeCap is StrokeCap.square.', + ); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: CircularProgressIndicator(value: 0.5), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints..arc(strokeCap: StrokeCap.butt), + reason: 'Default determinate strokeCap is StrokeCap.butt.', + ); + + await tester.pumpWidget(const CircularProgressIndicator(strokeCap: StrokeCap.butt)); + expect( + find.byType(CircularProgressIndicator), + paints..arc(strokeCap: StrokeCap.butt), + reason: 'strokeCap can be set to StrokeCap.butt, and will not be overridden.', + ); + + await tester.pumpWidget(const CircularProgressIndicator(strokeCap: StrokeCap.round)); + expect(find.byType(CircularProgressIndicator), paints..arc(strokeCap: StrokeCap.round)); + }); + + testWidgets('LinearProgressIndicator with indicatorBorderRadius', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100.0, + height: 4.0, + child: LinearProgressIndicator( + value: 0.25, + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + ), + ), + ), + ), + ); + expect( + find.byType(LinearProgressIndicator), + paints + ..rrect(rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 4.0, const Radius.circular(10.0))) + ..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(0.0, 0.0, 25.0, 4.0), + const Radius.circular(10.0), + ), + ), + ); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('LinearProgressIndicator reflects controller value', (WidgetTester tester) async { + final controller = AnimationController(vsync: tester, value: 0.5); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox(width: 200, child: LinearProgressIndicator(controller: controller)), + ), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints..rect(rect: const Rect.fromLTRB(127.79541015625, 0.0, 200.0, 4.0)), + ); + }); + + testWidgets('CircularProgressIndicator paint colors', (WidgetTester tester) async { + const green = Color(0xFF00FF00); + const blue = Color(0xFF0000FF); + const red = Color(0xFFFF0000); + + // With valueColor & color provided + await tester.pumpWidget( + Theme( + data: theme, + child: const CircularProgressIndicator( + color: red, + valueColor: AlwaysStoppedAnimation<Color>(blue), + ), + ), + ); + expect(find.byType(CircularProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(CircularProgressIndicator), paints..arc(color: blue)); + + // With just color provided + await tester.pumpWidget( + Theme( + data: theme, + child: const CircularProgressIndicator(color: red), + ), + ); + expect(find.byType(CircularProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(CircularProgressIndicator), paints..arc(color: red)); + + // With no color provided + await tester.pumpWidget( + Theme( + data: theme.copyWith(colorScheme: ColorScheme.fromSwatch().copyWith(primary: green)), + child: const CircularProgressIndicator(), + ), + ); + expect(find.byType(CircularProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(CircularProgressIndicator), paints..arc(color: green)); + + // With background + await tester.pumpWidget( + Theme( + data: theme, + child: const CircularProgressIndicator(backgroundColor: green, color: blue), + ), + ); + expect(find.byType(CircularProgressIndicator), paintsExactlyCountTimes(#drawArc, 2)); + expect( + find.byType(CircularProgressIndicator), + paints + ..arc(color: green) + ..arc(color: blue), + ); + + // With ProgressIndicatorTheme + await tester.pumpWidget( + Theme( + data: theme.copyWith( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: green, + circularTrackColor: blue, + ), + ), + child: const CircularProgressIndicator(), + ), + ); + expect(find.byType(CircularProgressIndicator), paintsExactlyCountTimes(#drawArc, 2)); + expect( + find.byType(CircularProgressIndicator), + paints + ..arc(color: blue) + ..arc(color: green), + ); + }); + + testWidgets('RefreshProgressIndicator paint colors', (WidgetTester tester) async { + const green = Color(0xFF00FF00); + const blue = Color(0xFF0000FF); + const red = Color(0xFFFF0000); + + // With valueColor & color provided + await tester.pumpWidget( + Theme( + data: theme, + child: const RefreshProgressIndicator( + color: red, + valueColor: AlwaysStoppedAnimation<Color>(blue), + ), + ), + ); + expect(find.byType(RefreshProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(RefreshProgressIndicator), paints..arc(color: blue)); + + // With just color provided + await tester.pumpWidget( + Theme( + data: theme, + child: const RefreshProgressIndicator(color: red), + ), + ); + expect(find.byType(RefreshProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(RefreshProgressIndicator), paints..arc(color: red)); + + // With no color provided + await tester.pumpWidget( + Theme( + data: theme.copyWith(colorScheme: ColorScheme.fromSwatch().copyWith(primary: green)), + child: const RefreshProgressIndicator(), + ), + ); + expect(find.byType(RefreshProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(RefreshProgressIndicator), paints..arc(color: green)); + + // With background + await tester.pumpWidget( + Theme( + data: theme, + child: const RefreshProgressIndicator(color: blue, backgroundColor: green), + ), + ); + expect(find.byType(RefreshProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(RefreshProgressIndicator), paints..arc(color: blue)); + final Material backgroundMaterial = tester.widget( + find.descendant(of: find.byType(RefreshProgressIndicator), matching: find.byType(Material)), + ); + expect(backgroundMaterial.type, MaterialType.circle); + expect(backgroundMaterial.color, green); + + // With ProgressIndicatorTheme + await tester.pumpWidget( + Theme( + data: theme.copyWith( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: green, + refreshBackgroundColor: blue, + ), + ), + child: const RefreshProgressIndicator(), + ), + ); + expect(find.byType(RefreshProgressIndicator), paintsExactlyCountTimes(#drawArc, 1)); + expect(find.byType(RefreshProgressIndicator), paints..arc(color: green)); + final Material themeBackgroundMaterial = tester.widget( + find.descendant(of: find.byType(RefreshProgressIndicator), matching: find.byType(Material)), + ); + expect(themeBackgroundMaterial.type, MaterialType.circle); + expect(themeBackgroundMaterial.color, blue); + }); + + testWidgets('RefreshProgressIndicator with a round indicator', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshProgressIndicator()); + expect( + find.byType(RefreshProgressIndicator), + paints..arc(strokeCap: StrokeCap.square), + reason: 'Default indeterminate strokeCap is StrokeCap.square', + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: RefreshProgressIndicator(strokeCap: StrokeCap.round), + ), + ), + ), + ), + ); + expect(find.byType(RefreshProgressIndicator), paints..arc(strokeCap: StrokeCap.round)); + }); + + testWidgets( + 'Indeterminate RefreshProgressIndicator keeps spinning until end of time (approximate)', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/13782 + + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox(width: 200.0, child: RefreshProgressIndicator())), + ), + ), + ); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(const Duration(seconds: 5)); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(const Duration(milliseconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(const Duration(days: 9999)); + expect(tester.hasRunningAnimations, isTrue); + }, + ); + + testWidgets('Material2 - RefreshProgressIndicator uses expected animation', ( + WidgetTester tester, + ) async { + final animationSheet = AnimationSheetBuilder(frameSize: const Size(50, 50)); + addTearDown(animationSheet.dispose); + + await tester.pumpFrames( + animationSheet.record( + Theme(data: ThemeData(useMaterial3: false), child: const _RefreshProgressIndicatorGolden()), + ), + const Duration(seconds: 3), + ); + + await expectLater( + animationSheet.collate(20), + matchesGoldenFile('m2_material.refresh_progress_indicator.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Material3 - RefreshProgressIndicator uses expected animation', ( + WidgetTester tester, + ) async { + final animationSheet = AnimationSheetBuilder(frameSize: const Size(50, 50)); + addTearDown(animationSheet.dispose); + + await tester.pumpFrames( + animationSheet.record( + Theme(data: ThemeData(), child: const _RefreshProgressIndicatorGolden()), + ), + const Duration(seconds: 3), + ); + + await expectLater( + animationSheet.collate(20), + matchesGoldenFile('m3_material.refresh_progress_indicator.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Determinate CircularProgressIndicator stops the animator', ( + WidgetTester tester, + ) async { + double? progressValue; + late StateSetter setState; + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CircularProgressIndicator(value: progressValue); + }, + ), + ), + ), + ), + ); + expect(tester.hasRunningAnimations, isTrue); + + setState(() { + progressValue = 1.0; + }); + await tester.pump(const Duration(milliseconds: 1)); + expect(tester.hasRunningAnimations, isFalse); + + setState(() { + progressValue = null; + }); + await tester.pump(const Duration(milliseconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('LinearProgressIndicator with height 12.0', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100.0, + height: 12.0, + child: LinearProgressIndicator(value: 0.25), + ), + ), + ), + ), + ); + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 12.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 25.0, 12.0)), + ); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('LinearProgressIndicator with a height less than the minimum', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(width: 100.0, height: 3.0, child: LinearProgressIndicator(value: 0.25)), + ), + ), + ), + ); + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 3.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 25.0, 3.0)), + ); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('LinearProgressIndicator with default height', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: theme, + child: const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(width: 100.0, height: 4.0, child: LinearProgressIndicator(value: 0.25)), + ), + ), + ), + ); + expect( + find.byType(LinearProgressIndicator), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 4.0)) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 25.0, 4.0)), + ); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('LinearProgressIndicator can be made accessible', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey key = GlobalKey(); + const label = 'Label'; + const value = '25'; + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: LinearProgressIndicator( + key: key, + value: 0.25, + semanticsLabel: label, + semanticsValue: value, + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(key)), + matchesSemantics(textDirection: TextDirection.ltr, label: label, value: value), + ); + + handle.dispose(); + }); + + testWidgets('LinearProgressIndicator that is determinate gets default a11y value', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey key = GlobalKey(); + const label = 'Label'; + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: LinearProgressIndicator(key: key, value: 0.25, semanticsLabel: label), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(key)), + matchesSemantics(textDirection: TextDirection.ltr, label: label, value: '25'), + ); + + handle.dispose(); + }); + + testWidgets( + 'LinearProgressIndicator that is determinate does not default a11y value when label is null', + (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: LinearProgressIndicator(key: key, value: 0.25), + ), + ), + ); + + expect(tester.getSemantics(find.byKey(key)), matchesSemantics()); + + handle.dispose(); + }, + ); + + testWidgets('LinearProgressIndicator that is indeterminate does not default a11y value', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey key = GlobalKey(); + const label = 'Progress'; + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: LinearProgressIndicator(key: key, value: 0.25, semanticsLabel: label), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(key)), + matchesSemantics(textDirection: TextDirection.ltr, label: label), + ); + + handle.dispose(); + }); + + testWidgets('CircularProgressIndicator can be made accessible', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey key = GlobalKey(); + const label = 'Label'; + const value = '25'; + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: CircularProgressIndicator( + key: key, + value: 0.25, + semanticsLabel: label, + semanticsValue: value, + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(key)), + matchesSemantics(textDirection: TextDirection.ltr, label: label, value: value), + ); + + handle.dispose(); + }); + + testWidgets('RefreshProgressIndicator can be made accessible', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final GlobalKey key = GlobalKey(); + const label = 'Label'; + const value = '25'; + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: RefreshProgressIndicator(key: key, semanticsLabel: label, semanticsValue: value), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(key)), + matchesSemantics(textDirection: TextDirection.ltr, label: label, value: value), + ); + + handle.dispose(); + }); + + testWidgets('Material2 - Indeterminate CircularProgressIndicator uses expected animation', ( + WidgetTester tester, + ) async { + final animationSheet = AnimationSheetBuilder(frameSize: const Size(40, 40)); + addTearDown(animationSheet.dispose); + + await tester.pumpFrames( + animationSheet.record( + Theme( + data: ThemeData(useMaterial3: false), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Padding(padding: EdgeInsets.all(4), child: CircularProgressIndicator()), + ), + ), + ), + const Duration(seconds: 2), + ); + + await expectLater( + animationSheet.collate(20), + matchesGoldenFile('m2_material.circular_progress_indicator.indeterminate.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Material3 - Indeterminate CircularProgressIndicator uses expected animation', ( + WidgetTester tester, + ) async { + final animationSheet = AnimationSheetBuilder(frameSize: const Size(40, 40)); + addTearDown(animationSheet.dispose); + + await tester.pumpFrames( + animationSheet.record( + Theme( + data: ThemeData(), + child: const Directionality( + textDirection: TextDirection.ltr, + child: Padding(padding: EdgeInsets.all(4), child: CircularProgressIndicator()), + ), + ), + ), + const Duration(seconds: 2), + ); + + await expectLater( + animationSheet.collate(20), + matchesGoldenFile('m3_material.circular_progress_indicator.indeterminate.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets( + 'Adaptive CircularProgressIndicator displays CupertinoActivityIndicator in iOS', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold(body: Material(child: CircularProgressIndicator.adaptive())), + ), + ); + + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Adaptive CircularProgressIndicator displays CupertinoActivityIndicator in iOS/macOS', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold( + body: Material(child: CircularProgressIndicator.adaptive(value: 0.5)), + ), + ), + ); + + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + final double actualProgress = tester + .widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)) + .progress; + expect(actualProgress, 0.5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Adaptive CircularProgressIndicator can use backgroundColor to change tick color for iOS', + (WidgetTester tester) async { + const color = Color(0xFF5D3FD3); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold( + body: Material(child: CircularProgressIndicator.adaptive(backgroundColor: color)), + ), + ), + ); + + expect( + find.byType(CupertinoActivityIndicator), + paints..rrect( + rrect: const RRect.fromLTRBXY(-1, -10 / 3, 1, -10, 1, 1), + // The value of 47 comes from the alpha that is applied to the first + // tick. + color: color.withAlpha(47), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Adaptive CircularProgressIndicator does not display CupertinoActivityIndicator in non-iOS', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Material(child: CircularProgressIndicator.adaptive())), + ), + ); + + expect(find.byType(CupertinoActivityIndicator), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.windows, + TargetPlatform.linux, + }), + ); + + testWidgets('ProgressIndicatorTheme.wrap() always creates a new ProgressIndicatorTheme', ( + WidgetTester tester, + ) async { + late BuildContext builderContext; + + const themeData = ProgressIndicatorThemeData( + color: Color(0xFFFF0000), + linearTrackColor: Color(0xFF00FF00), + ); + + final progressTheme = ProgressIndicatorTheme( + data: themeData, + child: Builder( + builder: (BuildContext context) { + builderContext = context; + return const LinearProgressIndicator(value: 0.5); + }, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Theme(data: theme, child: progressTheme), + ), + ); + final Widget wrappedTheme = progressTheme.wrap(builderContext, Container()); + + // Make sure the returned widget is a new ProgressIndicatorTheme instance + // with the same theme data as the original. + expect(wrappedTheme, isNot(equals(progressTheme))); + expect(wrappedTheme, isInstanceOf<ProgressIndicatorTheme>()); + expect((wrappedTheme as ProgressIndicatorTheme).data, themeData); + }); + + testWidgets('Material3 - Default size of CircularProgressIndicator', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: Material(child: CircularProgressIndicator())), + ), + ); + + expect(tester.getSize(find.byType(CircularProgressIndicator)), const Size(36, 36)); + }); + + testWidgets('Material3 - Default size of CircularProgressIndicator when year2023 is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: Material(child: CircularProgressIndicator(year2023: false))), + ), + ); + + expect(tester.getSize(find.byType(CircularProgressIndicator)), const Size(48, 48)); + }); + + testWidgets('RefreshProgressIndicator using fields correctly', (WidgetTester tester) async { + Future<void> pumpIndicator(RefreshProgressIndicator indicator) { + return tester.pumpWidget(Theme(data: theme, child: indicator)); + } + + // With default values. + await pumpIndicator(const RefreshProgressIndicator()); + Material material = tester.widget( + find.descendant(of: find.byType(RefreshProgressIndicator), matching: find.byType(Material)), + ); + Padding padding = tester.widget( + find + .descendant(of: find.byType(RefreshProgressIndicator), matching: find.byType(Padding)) + .first, + ); + Padding innerPadding = tester.widget( + find + .descendant( + of: find.descendant( + of: find.byType(RefreshProgressIndicator), + matching: find.byType(Material), + ), + matching: find.byType(Padding), + ) + .last, + ); + expect(material.elevation, 2.0); + expect(padding.padding, const EdgeInsets.all(4.0)); + expect(innerPadding.padding, const EdgeInsets.all(12.0)); + + // With values provided. + const testElevation = 1.0; + const EdgeInsetsGeometry testIndicatorMargin = EdgeInsets.all(6.0); + const EdgeInsetsGeometry testIndicatorPadding = EdgeInsets.all(10.0); + await pumpIndicator( + const RefreshProgressIndicator( + elevation: testElevation, + indicatorMargin: testIndicatorMargin, + indicatorPadding: testIndicatorPadding, + ), + ); + material = tester.widget( + find.descendant(of: find.byType(RefreshProgressIndicator), matching: find.byType(Material)), + ); + padding = tester.widget( + find + .descendant(of: find.byType(RefreshProgressIndicator), matching: find.byType(Padding)) + .first, + ); + innerPadding = tester.widget( + find + .descendant( + of: find.descendant( + of: find.byType(RefreshProgressIndicator), + matching: find.byType(Material), + ), + matching: find.byType(Padding), + ) + .last, + ); + expect(material.elevation, testElevation); + expect(padding.padding, testIndicatorMargin); + expect(innerPadding.padding, testIndicatorPadding); + }); + + testWidgets('LinearProgressIndicator default stop indicator when year2023 is false', ( + WidgetTester tester, + ) async { + Widget buildIndicator({required TextDirection textDirection}) { + return Directionality( + textDirection: textDirection, + child: const Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(year2023: false, value: 0.5), + ), + ), + ); + } + + await tester.pumpWidget(buildIndicator(textDirection: TextDirection.ltr)); + expect( + find.byType(LinearProgressIndicator), + paints..circle(x: 198.0, y: 2.0, radius: 2.0, color: theme.colorScheme.primary), + ); + + await tester.pumpWidget(buildIndicator(textDirection: TextDirection.rtl)); + expect( + find.byType(LinearProgressIndicator), + paints..circle(x: 2.0, y: 2.0, radius: 2.0, color: theme.colorScheme.primary), + ); + }); + + testWidgets( + 'Determinate LinearProgressIndicator when year2023 is false', + (WidgetTester tester) async { + Future<ui.Image> getGoldenImage({ + required TextDirection textDirection, + double? trackGap, + }) async { + final value = ValueNotifier<double>(0); + addTearDown(value.dispose); + + final animationSheet = AnimationSheetBuilder(frameSize: const Size(250, 30)); + addTearDown(animationSheet.dispose); + + final Widget target = Material( + child: Directionality( + textDirection: textDirection, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: Center( + child: ValueListenableBuilder<double>( + valueListenable: value, + builder: (BuildContext context, double value, _) { + return LinearProgressIndicator( + year2023: false, + value: value, + trackGap: trackGap, + ); + }, + ), + ), + ), + ), + ); + + for (var i = 0; i <= 50; i++) { + value.value = i * 0.02; + await tester.pumpWidget(animationSheet.record(target)); + } + + return animationSheet.collate(3); + } + + await expectLater( + await getGoldenImage(textDirection: TextDirection.ltr), + matchesGoldenFile('m3_linear_progress_indicator.determinate.ltr.png'), + ); + + await expectLater( + await getGoldenImage(textDirection: TextDirection.rtl), + matchesGoldenFile('m3_linear_progress_indicator.determinate.rtl.png'), + ); + + await expectLater( + await getGoldenImage(textDirection: TextDirection.ltr, trackGap: 20), + matchesGoldenFile('m3_linear_progress_indicator.determinate.ltr.custom_track_gap.png'), + ); + + await expectLater( + await getGoldenImage(textDirection: TextDirection.rtl, trackGap: 20), + matchesGoldenFile('m3_linear_progress_indicator.determinate.rtl.custom_track_gap.png'), + ); + }, + skip: isBrowser, // [intended] https://github.com/flutter/flutter/issues/56001 + ); + + testWidgets( + 'Indeterminate LinearProgressIndicator when year2023 is false', + (WidgetTester tester) async { + Future<ui.Image> getGoldenImage({ + required TextDirection textDirection, + double? trackGap, + }) async { + final animationSheet = AnimationSheetBuilder(frameSize: const Size(250, 30)); + addTearDown(animationSheet.dispose); + + final Widget target = Material( + child: Directionality( + textDirection: textDirection, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: Center(child: LinearProgressIndicator(year2023: false, trackGap: trackGap)), + ), + ), + ); + + await tester.pumpFrames(animationSheet.record(target), const Duration(milliseconds: 1800)); + + return animationSheet.collate(3); + } + + await expectLater( + await getGoldenImage(textDirection: TextDirection.ltr), + matchesGoldenFile('m3_linear_progress_indicator.indeterminate.ltr.png'), + ); + + await expectLater( + await getGoldenImage(textDirection: TextDirection.rtl), + matchesGoldenFile('m3_linear_progress_indicator.indeterminate.rtl.png'), + ); + + await expectLater( + await getGoldenImage(textDirection: TextDirection.ltr, trackGap: 20), + matchesGoldenFile('m3_linear_progress_indicator.indeterminate.ltr.custom_track_gap.png'), + ); + + await expectLater( + await getGoldenImage(textDirection: TextDirection.rtl, trackGap: 20), + matchesGoldenFile('m3_linear_progress_indicator.indeterminate.rtl.custom_track_gap.png'), + ); + }, + skip: isBrowser, // [intended] https://github.com/flutter/flutter/issues/56001 + ); + + testWidgets('Indeterminate LinearProgressIndicator does not paint stop indicator', ( + WidgetTester tester, + ) async { + Widget buildIndicator({double? value}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(year2023: false, value: value), + ), + ), + ); + } + + // Determinate LinearProgressIndicator paints stop indicator. + await tester.pumpWidget(buildIndicator(value: 0.5)); + expect( + find.byType(LinearProgressIndicator), + // Stop indicator. + paints..circle(x: 198.0, y: 2.0, radius: 2.0, color: theme.colorScheme.primary), + ); + + // Indeterminate LinearProgressIndicator does not paint stop indicator. + await tester.pumpWidget(buildIndicator()); + expect( + find.byType(LinearProgressIndicator), + // Stop indicator. + isNot(paints..circle(x: 198.0, y: 2.0, radius: 2.0, color: theme.colorScheme.primary)), + ); + }); + + testWidgets('Can customise LinearProgressIndicator stop indicator when year2023 is false', ( + WidgetTester tester, + ) async { + const stopIndicatorColor = Color(0XFF00FF00); + const stopIndicatorRadius = 5.0; + Widget buildIndicator({Color? stopIndicatorColor, double? stopIndicatorRadius}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator( + year2023: false, + stopIndicatorColor: stopIndicatorColor, + stopIndicatorRadius: stopIndicatorRadius, + minHeight: 20.0, + value: 0.5, + ), + ), + ), + ); + } + + // Test customized stop indicator. + await tester.pumpWidget( + buildIndicator( + stopIndicatorColor: stopIndicatorColor, + stopIndicatorRadius: stopIndicatorRadius, + ), + ); + expect( + find.byType(LinearProgressIndicator), + // Stop indicator. + paints..circle(x: 190.0, y: 10.0, radius: stopIndicatorRadius, color: stopIndicatorColor), + ); + + // Remove stop indicator. + await tester.pumpWidget(buildIndicator(stopIndicatorRadius: 0)); + expect( + find.byType(LinearProgressIndicator), + // Stop indicator. + isNot(paints..circle(color: stopIndicatorColor)), + ); + + // Test stop indicator with transparent color. + await tester.pumpWidget(buildIndicator(stopIndicatorColor: const Color(0x00000000))); + expect( + find.byType(LinearProgressIndicator), + // Stop indicator. + paints..circle(color: const Color(0x00000000)), + ); + }); + + testWidgets('Stop indicator size cannot be larger than the progress indicator', ( + WidgetTester tester, + ) async { + Widget buildIndicator({double? stopIndicatorRadius, double? minHeight}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator( + year2023: false, + stopIndicatorRadius: stopIndicatorRadius, + minHeight: minHeight, + value: 0.5, + ), + ), + ), + ); + } + + // Test stop indicator radius equals to minHeight. + await tester.pumpWidget(buildIndicator(stopIndicatorRadius: 10.0, minHeight: 20.0)); + expect( + find.byType(LinearProgressIndicator), + paints..circle(x: 190.0, y: 10.0, radius: 10.0, color: theme.colorScheme.primary), + ); + + // Test stop indicator radius larger than minHeight. + await tester.pumpWidget(buildIndicator(stopIndicatorRadius: 30.0, minHeight: 20.0)); + expect( + find.byType(LinearProgressIndicator), + // Stop indicator radius is clamped to minHeight. + paints..circle(x: 190.0, y: 10.0, radius: 10.0, color: theme.colorScheme.primary), + ); + }); + + testWidgets('LinearProgressIndicator default track gap when year2023 is false', ( + WidgetTester tester, + ) async { + const defaultTrackGap = 4.0; + Widget buildIndicator({required TextDirection textDirection}) { + return Directionality( + textDirection: textDirection, + child: const Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(year2023: false, value: 0.5), + ), + ), + ); + } + + // Test default track gap in LTR. + await tester.pumpWidget(buildIndicator(textDirection: TextDirection.ltr)); + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 100.0 + defaultTrackGap, + 0.0, + 200.0, + 4.0, + const Radius.circular(2.0), + ), + color: theme.colorScheme.secondaryContainer, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 4.0, const Radius.circular(2.0)), + color: theme.colorScheme.primary, + ), + ); + + // Test default track gap in RTL. + await tester.pumpWidget(buildIndicator(textDirection: TextDirection.rtl)); + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 100.0 - defaultTrackGap, + 4.0, + const Radius.circular(2.0), + ), + color: theme.colorScheme.secondaryContainer, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(100.0, 0.0, 200.0, 4.0, const Radius.circular(2.0)), + color: theme.colorScheme.primary, + ), + ); + }); + + testWidgets('Can customise LinearProgressIndicator track gap when year2023 is false', ( + WidgetTester tester, + ) async { + const customTrackGap = 12.0; + const noTrackGap = 0.0; + Widget buildIndicator({double? trackGap}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(year2023: false, trackGap: trackGap, value: 0.5), + ), + ), + ); + } + + // Test customized track gap. + await tester.pumpWidget(buildIndicator(trackGap: customTrackGap)); + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 100.0 + customTrackGap, + 0.0, + 200.0, + 4.0, + const Radius.circular(2.0), + ), + color: theme.colorScheme.secondaryContainer, + ) + // Stop indicator. + ..circle() + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 4.0, const Radius.circular(2.0)), + color: theme.colorScheme.primary, + ), + ); + + // Remove track gap. + await tester.pumpWidget(buildIndicator(trackGap: noTrackGap)); + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 200.0, 4.0, const Radius.circular(2.0)), + color: theme.colorScheme.secondaryContainer, + ) + // Stop indicator. + ..circle() + // Active indicator. + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 4.0, const Radius.circular(2.0)), + color: theme.colorScheme.primary, + ), + ); + }); + + testWidgets('Default determinate CircularProgressIndicator when year2023 is false', ( + WidgetTester tester, + ) async { + const EdgeInsetsGeometry padding = EdgeInsets.all(4.0); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center(child: CircularProgressIndicator(year2023: false, value: 0.5)), + ), + ); + + final Size indicatorBoxSize = tester.getSize( + find.descendant( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ConstrainedBox), + ), + ); + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + equals( + Size( + indicatorBoxSize.width + padding.horizontal, + indicatorBoxSize.height + padding.vertical, + ), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints + // Track. + ..arc( + rect: const Rect.fromLTRB(2.0, 2.0, 38.0, 38.0), + color: theme.colorScheme.secondaryContainer, + strokeWidth: 4.0, + strokeCap: StrokeCap.round, + style: PaintingStyle.stroke, + ) + // Active indicator. + ..arc( + rect: const Rect.fromLTRB(2.0, 2.0, 38.0, 38.0), + color: theme.colorScheme.primary, + strokeWidth: 4.0, + strokeCap: StrokeCap.round, + style: PaintingStyle.stroke, + ), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_determinate_year2023_false.png'), + ); + }); + + testWidgets('Default indeterminate CircularProgressIndicator when year2023 is false', ( + WidgetTester tester, + ) async { + const EdgeInsetsGeometry padding = EdgeInsets.all(4.0); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center(child: CircularProgressIndicator(year2023: false)), + ), + ); + + // Advance the animation. + await tester.pump(const Duration(milliseconds: 200)); + + final Size indicatorBoxSize = tester.getSize( + find.descendant( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ConstrainedBox), + ), + ); + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + equals( + Size( + indicatorBoxSize.width + padding.horizontal, + indicatorBoxSize.height + padding.vertical, + ), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints + // Active indicator. + ..arc( + rect: const Rect.fromLTRB(2.0, 2.0, 38.0, 38.0), + color: theme.colorScheme.primary, + strokeWidth: 4.0, + strokeCap: StrokeCap.round, + style: PaintingStyle.stroke, + ), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_indeterminate_year2023_false.png'), + ); + }); + + testWidgets('CircularProgressIndicator track gap can be adjusted when year2023 is false', ( + WidgetTester tester, + ) async { + Widget buildIndicator({double? trackGap}) { + return MaterialApp( + home: Center( + child: CircularProgressIndicator(year2023: false, trackGap: trackGap, value: 0.5), + ), + ); + } + + await tester.pumpWidget(buildIndicator()); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_default_track_gap_year2023_false.png'), + ); + + await tester.pumpWidget(buildIndicator(trackGap: 12.0)); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_custom_track_gap_year2023_false.png'), + ); + + await tester.pumpWidget(buildIndicator(trackGap: 0.0)); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_no_track_gap_year2023_false.png'), + ); + }); + + testWidgets('Can override CircularProgressIndicator stroke cap when year2023 is false', ( + WidgetTester tester, + ) async { + const StrokeCap strokeCap = StrokeCap.square; + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: CircularProgressIndicator(year2023: false, strokeCap: strokeCap, value: 0.5), + ), + ), + ); + + expect( + find.byType(CircularProgressIndicator), + paints + // Track. + ..arc(strokeCap: strokeCap) + // Active indicator. + ..arc(strokeCap: strokeCap), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_custom_stroke_cap_year2023_false.png'), + ); + }); + + testWidgets('CircularProgressIndicator.constraints can override default size', ( + WidgetTester tester, + ) async { + const size = Size(64, 64); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: CircularProgressIndicator( + constraints: BoxConstraints(minWidth: size.width, minHeight: size.height), + value: 0.5, + ), + ), + ), + ); + + expect(tester.getSize(find.byType(CircularProgressIndicator)), equals(size)); + }); + + testWidgets('CircularProgressIndicator padding can be customized', (WidgetTester tester) async { + const EdgeInsetsGeometry padding = EdgeInsets.all(12.0); + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: CircularProgressIndicator(padding: padding, year2023: false, value: 0.5), + ), + ), + ); + + final Size indicatorBoxSize = tester.getSize( + find.descendant( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ConstrainedBox), + ), + ); + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + equals( + Size( + indicatorBoxSize.width + padding.horizontal, + indicatorBoxSize.height + padding.vertical, + ), + ), + ); + }); + + testWidgets('CircularProgressIndicator reflects controller value', (WidgetTester tester) async { + final controller = AnimationController(vsync: tester, value: 0.5); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox.square( + dimension: 200.0, + child: AnimatedBuilder( + animation: controller, + builder: (BuildContext context, Widget? child) { + return CircularProgressIndicator(controller: controller); + }, + ), + ), + ), + ), + ), + ); + + expect( + find.byType(CircularProgressIndicator), + paints..arc(startAngle: 1.5707963267948966, sweepAngle: 0.001), + ); + }); + + testWidgets('RefreshProgressIndicator does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: RefreshProgressIndicator())), + ), + ); + expect(tester.getSize(find.byType(RefreshProgressIndicator)), Size.zero); + }); + + testWidgets('CircularProgressIndicator does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: CircularProgressIndicator())), + ), + ); + expect(tester.getSize(find.byType(CircularProgressIndicator)), Size.zero); + }); + + testWidgets('LinearProgressIndicator does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: LinearProgressIndicator())), + ), + ); + expect(tester.getSize(find.byType(LinearProgressIndicator)), Size.zero); + }); + + testWidgets('LinearProgressIndicator clamps value', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // Test value > 1.0 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: LinearProgressIndicator(value: 1.5))), + ); + + expect( + tester.getSemantics(find.byType(LinearProgressIndicator)), + matchesSemantics(value: '100', textDirection: TextDirection.ltr), + ); + + // Test value < 0.0 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: LinearProgressIndicator(value: -0.5))), + ); + + expect( + tester.getSemantics(find.byType(LinearProgressIndicator)), + matchesSemantics(value: '0', textDirection: TextDirection.ltr), + ); + + handle.dispose(); + }); + + testWidgets('CircularProgressIndicator clamps value', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // Test value > 1.0 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: CircularProgressIndicator(value: 1.5))), + ); + + expect( + tester.getSemantics(find.byType(CircularProgressIndicator)), + matchesSemantics(value: '100', textDirection: TextDirection.ltr), + ); + + // Test value < 0.0 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: CircularProgressIndicator(value: -0.5))), + ); + + expect( + tester.getSemantics(find.byType(CircularProgressIndicator)), + matchesSemantics(value: '0', textDirection: TextDirection.ltr), + ); + + handle.dispose(); + }); + + testWidgets('RefreshProgressIndicator clamps value', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // Test value > 1.0 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: RefreshProgressIndicator(value: 1.5))), + ); + + expect( + tester.getSemantics(find.byType(RefreshProgressIndicator)), + matchesSemantics(value: '100', textDirection: TextDirection.ltr), + ); + + // Test value < 0.0 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: RefreshProgressIndicator(value: -0.5))), + ); + + expect( + tester.getSemantics(find.byType(RefreshProgressIndicator)), + matchesSemantics(value: '0', textDirection: TextDirection.ltr), + ); + + handle.dispose(); + }); + + testWidgets('CircularProgressIndicator with non-numeric semanticsValue does not crash', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: CircularProgressIndicator(value: 0.5, semanticsValue: '50%')), + ), + ); + + expect( + tester.getSemantics(find.byType(CircularProgressIndicator)), + matchesSemantics(value: '50%', textDirection: TextDirection.ltr), + ); + + handle.dispose(); + }); + + testWidgets('LinearProgressIndicator with non-numeric semanticsValue does not crash', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: LinearProgressIndicator(value: 0.5, semanticsValue: '50%')), + ), + ); + + expect( + tester.getSemantics(find.byType(LinearProgressIndicator)), + matchesSemantics(value: '50%', textDirection: TextDirection.ltr), + ); + + handle.dispose(); + }); +} + +class _RefreshProgressIndicatorGolden extends StatefulWidget { + const _RefreshProgressIndicatorGolden(); + + @override + _RefreshProgressIndicatorGoldenState createState() => _RefreshProgressIndicatorGoldenState(); +} + +class _RefreshProgressIndicatorGoldenState extends State<_RefreshProgressIndicatorGolden> + with SingleTickerProviderStateMixin { + late final AnimationController controller = + AnimationController(vsync: this, duration: const Duration(seconds: 1)) + ..forward() + ..addListener(() { + setState(() {}); + }) + ..addStatusListener((AnimationStatus status) { + if (status.isCompleted) { + indeterminate = true; + } + }); + + bool indeterminate = false; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: RefreshProgressIndicator(value: indeterminate ? null : controller.value), + ), + ); + } +} diff --git a/packages/material_ui/test/material/progress_indicator_theme_test.dart b/packages/material_ui/test/material/progress_indicator_theme_test.dart new file mode 100644 index 000000000000..aaf43c2efcd3 --- /dev/null +++ b/packages/material_ui/test/material/progress_indicator_theme_test.dart @@ -0,0 +1,708 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ProgressIndicatorThemeData copyWith, ==, hashCode, basics', () { + expect(const ProgressIndicatorThemeData(), const ProgressIndicatorThemeData().copyWith()); + expect( + const ProgressIndicatorThemeData().hashCode, + const ProgressIndicatorThemeData().copyWith().hashCode, + ); + }); + + test('ProgressIndicatorThemeData lerp special cases', () { + expect(ProgressIndicatorThemeData.lerp(null, null, 0), null); + const data = ProgressIndicatorThemeData(); + expect(identical(ProgressIndicatorThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('ProgressIndicatorThemeData implements debugFillProperties', ( + WidgetTester tester, + ) async { + final builder = DiagnosticPropertiesBuilder(); + const ProgressIndicatorThemeData( + color: Color(0XFF0000F1), + linearTrackColor: Color(0XFF0000F2), + linearMinHeight: 25.0, + circularTrackColor: Color(0XFF0000F3), + refreshBackgroundColor: Color(0XFF0000F4), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + stopIndicatorColor: Color(0XFF0000F5), + stopIndicatorRadius: 10.0, + strokeWidth: 8.0, + strokeAlign: BorderSide.strokeAlignOutside, + strokeCap: StrokeCap.butt, + constraints: BoxConstraints.tightFor(width: 80.0, height: 80.0), + trackGap: 16.0, + circularTrackPadding: EdgeInsets.all(12.0), + year2023: false, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'color: Color(alpha: 1.0000, red: 0.0000, green: 0.0000, blue: 0.9451, colorSpace: ColorSpace.sRGB)', + 'linearTrackColor: Color(alpha: 1.0000, red: 0.0000, green: 0.0000, blue: 0.9490, colorSpace: ColorSpace.sRGB)', + 'linearMinHeight: 25.0', + 'circularTrackColor: Color(alpha: 1.0000, red: 0.0000, green: 0.0000, blue: 0.9529, colorSpace: ColorSpace.sRGB)', + 'refreshBackgroundColor: Color(alpha: 1.0000, red: 0.0000, green: 0.0000, blue: 0.9569, colorSpace: ColorSpace.sRGB)', + 'borderRadius: BorderRadius.circular(8.0)', + 'stopIndicatorColor: Color(alpha: 1.0000, red: 0.0000, green: 0.0000, blue: 0.9608, colorSpace: ColorSpace.sRGB)', + 'stopIndicatorRadius: 10.0', + 'strokeWidth: 8.0', + 'strokeAlign: 1.0', + 'strokeCap: StrokeCap.butt', + 'constraints: BoxConstraints(w=80.0, h=80.0)', + 'trackGap: 16.0', + 'circularTrackPadding: EdgeInsets.all(12.0)', + 'year2023: false', + ]), + ); + }); + + testWidgets('Can theme LinearProgressIndicator using ProgressIndicatorTheme', ( + WidgetTester tester, + ) async { + const color = Color(0XFF00FF00); + const linearTrackColor = Color(0XFFFF0000); + const linearMinHeight = 25.0; + const borderRadius = 8.0; + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: color, + linearTrackColor: linearTrackColor, + linearMinHeight: linearMinHeight, + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center(child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.5))), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 200.0, + linearMinHeight, + const Radius.circular(borderRadius), + ), + color: linearTrackColor, + ) + // Active indicator. + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 100.0, + linearMinHeight, + const Radius.circular(borderRadius), + ), + color: color, + ), + ); + }); + + testWidgets('Can theme LinearProgressIndicator when year2023 to false', ( + WidgetTester tester, + ) async { + const color = Color(0XFF00FF00); + const linearTrackColor = Color(0XFFFF0000); + const linearMinHeight = 25.0; + const borderRadius = 8.0; + const stopIndicatorColor = Color(0XFF0000FF); + const stopIndicatorRadius = 10.0; + const trackGap = 16.0; + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: color, + linearTrackColor: linearTrackColor, + linearMinHeight: linearMinHeight, + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + stopIndicatorColor: stopIndicatorColor, + stopIndicatorRadius: stopIndicatorRadius, + trackGap: trackGap, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center( + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(year2023: false, value: 0.5), + ), + ), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 100.0 + trackGap, + 0.0, + 200.0, + linearMinHeight, + const Radius.circular(borderRadius), + ), + color: linearTrackColor, + ) + // Stop indicator. + ..circle(x: 187.5, y: 12.5, radius: stopIndicatorRadius, color: stopIndicatorColor) + // Active indicator. + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 100.0, + linearMinHeight, + const Radius.circular(borderRadius), + ), + color: color, + ), + ); + }); + + testWidgets( + 'Local ProgressIndicatorTheme takes precedence over inherited ProgressIndicatorTheme', + (WidgetTester tester) async { + const color = Color(0XFFFF00FF); + const linearTrackColor = Color(0XFF00FFFF); + const linearMinHeight = 20.0; + const borderRadius = 6.0; + const stopIndicatorColor = Color(0XFFFFFF00); + const stopIndicatorRadius = 8.0; + const trackGap = 12.0; + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: Color(0XFF00FF00), + linearTrackColor: Color(0XFFFF0000), + linearMinHeight: 25.0, + borderRadius: BorderRadius.all(Radius.circular(8.0)), + stopIndicatorColor: Color(0XFF0000FF), + stopIndicatorRadius: 10.0, + trackGap: 16.0, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center( + child: ProgressIndicatorTheme( + data: ProgressIndicatorThemeData( + color: color, + linearTrackColor: linearTrackColor, + linearMinHeight: linearMinHeight, + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + stopIndicatorColor: stopIndicatorColor, + stopIndicatorRadius: stopIndicatorRadius, + trackGap: trackGap, + ), + child: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.5)), + ), + ), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 200.0, + linearMinHeight, + const Radius.circular(borderRadius), + ), + color: linearTrackColor, + ) + // Active indicator. + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 100.0, + linearMinHeight, + const Radius.circular(borderRadius), + ), + color: color, + ), + ); + }, + ); + + testWidgets('Can theme CircularProgressIndicator using ProgressIndicatorTheme', ( + WidgetTester tester, + ) async { + const color = Color(0XFFFF0000); + const circularTrackColor = Color(0XFF0000FF); + const strokeWidth = 8.0; + const double strokeAlign = BorderSide.strokeAlignOutside; + const StrokeCap strokeCap = StrokeCap.butt; + const constraints = BoxConstraints.tightFor(width: 80.0, height: 80.0); + const padding = EdgeInsets.all(14.0); + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: color, + circularTrackColor: circularTrackColor, + strokeWidth: strokeWidth, + strokeAlign: strokeAlign, + strokeCap: strokeCap, + constraints: constraints, + circularTrackPadding: padding, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Center(child: CircularProgressIndicator(value: 0.5))), + ), + ); + + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + equals( + Size(constraints.maxWidth + padding.horizontal, constraints.maxHeight + padding.vertical), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints + // Track. + ..arc(color: circularTrackColor, strokeWidth: strokeWidth, strokeCap: strokeCap) + // Active indicator. + ..arc(color: color, strokeWidth: strokeWidth, strokeCap: strokeCap), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_theme.png'), + ); + }); + + testWidgets('Can theme CircularProgressIndicator when year2023 to false', ( + WidgetTester tester, + ) async { + const color = Color(0XFFFF0000); + const circularTrackColor = Color(0XFF0000FF); + const strokeWidth = 8.0; + const double strokeAlign = BorderSide.strokeAlignOutside; + const StrokeCap strokeCap = StrokeCap.butt; + const constraints = BoxConstraints.tightFor(width: 80.0, height: 80.0); + const trackGap = 12.0; + const padding = EdgeInsets.all(18.0); + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: color, + circularTrackColor: circularTrackColor, + strokeWidth: strokeWidth, + strokeAlign: strokeAlign, + strokeCap: strokeCap, + constraints: constraints, + trackGap: trackGap, + circularTrackPadding: padding, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center(child: CircularProgressIndicator(year2023: false, value: 0.5)), + ), + ), + ); + + final Size indicatorBoxSize = tester.getSize( + find.descendant( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ConstrainedBox), + ), + ); + expect(indicatorBoxSize, constraints.biggest); + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + equals( + Size( + indicatorBoxSize.width + padding.horizontal, + indicatorBoxSize.height + padding.vertical, + ), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints + // Track. + ..arc(color: circularTrackColor, strokeWidth: strokeWidth, strokeCap: strokeCap) + // Active indicator. + ..arc(color: color, strokeWidth: strokeWidth, strokeCap: strokeCap), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_theme_year2023_false.png'), + ); + }); + + testWidgets( + 'CircularProgressIndicator.year2023 set to false and provided circularTrackColor does not throw exception', + (WidgetTester tester) async { + const circularTrackColor = Color(0XFF0000FF); + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData( + circularTrackColor: circularTrackColor, + year2023: false, + ), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Center(child: CircularProgressIndicator()), + ), + ); + + expect(tester.takeException(), null); + }, + ); + + testWidgets( + 'Opt into 2024 CircularProgressIndicator appearance with ProgressIndicatorThemeData.year2023', + (WidgetTester tester) async { + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false), + ); + const EdgeInsetsGeometry padding = EdgeInsets.all(4.0); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: Center(child: CircularProgressIndicator(value: 0.5))), + ), + ); + + final Size indicatorBoxSize = tester.getSize( + find.descendant( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ConstrainedBox), + ), + ); + expect( + tester.getSize(find.byType(CircularProgressIndicator)), + equals( + Size( + indicatorBoxSize.width + padding.horizontal, + indicatorBoxSize.height + padding.vertical, + ), + ), + ); + expect( + find.byType(CircularProgressIndicator), + paints + // Track. + ..arc( + rect: const Rect.fromLTRB(2.0, 2.0, 38.0, 38.0), + color: theme.colorScheme.secondaryContainer, + strokeWidth: 4.0, + strokeCap: StrokeCap.round, + style: PaintingStyle.stroke, + ) + // Active indicator. + ..arc( + rect: const Rect.fromLTRB(2.0, 2.0, 38.0, 38.0), + color: theme.colorScheme.primary, + strokeWidth: 4.0, + strokeCap: StrokeCap.round, + style: PaintingStyle.stroke, + ), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_theme_opt_into_2024.png'), + ); + }, + ); + + testWidgets('CircularProgressIndicator.year2023 overrides ProgressIndicatorThemeData.year2023', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Center(child: CircularProgressIndicator(year2023: true, value: 0.5)), + ), + ), + ); + + final Size indicatorBoxSize = tester.getSize( + find.descendant( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ConstrainedBox), + ), + ); + expect(tester.getSize(find.byType(CircularProgressIndicator)), equals(indicatorBoxSize)); + expect( + find.byType(CircularProgressIndicator), + paints + // Active indicator. + ..arc( + rect: const Rect.fromLTRB(-0.0, -0.0, 36.0, 36.0), + color: theme.colorScheme.primary, + strokeWidth: 4.0, + style: PaintingStyle.stroke, + ), + ); + await expectLater( + find.byType(CircularProgressIndicator), + matchesGoldenFile('circular_progress_indicator_theme_opt_into_2024_override.png'), + ); + }); + + testWidgets( + 'Opt into 2024 LinearProgressIndicator appearance with ProgressIndicatorThemeData.year2023', + (WidgetTester tester) async { + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false), + ); + const defaultTrackGap = 4.0; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: SizedBox(width: 200.0, child: LinearProgressIndicator(value: 0.5)), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rrect( + rrect: RRect.fromLTRBR( + 100.0 + defaultTrackGap, + 0.0, + 200.0, + 4.0, + const Radius.circular(2.0), + ), + color: theme.colorScheme.secondaryContainer, + ) + // Stop indicator. + ..circle(x: 198.0, y: 2.0, radius: 2.0, color: theme.colorScheme.primary) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(0.0, 0.0, 100.0, 4.0, const Radius.circular(2.0)), + color: theme.colorScheme.primary, + ), + ); + }, + ); + + testWidgets('LinearProgressIndicator.year2023 overrides ProgressIndicatorThemeData.year2023', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: SizedBox(width: 200.0, child: LinearProgressIndicator(year2023: true, value: 0.5)), + ), + ), + ); + + expect( + find.byType(LinearProgressIndicator), + paints + // Track. + ..rect( + rect: const Rect.fromLTRB(0.0, 0.0, 200.0, 4.0), + color: theme.colorScheme.secondaryContainer, + ) + // Active track. + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 100.0, 4.0), color: theme.colorScheme.primary), + ); + }); + + testWidgets('LinearProgressIndicator reflects the value of the theme controller', ( + WidgetTester tester, + ) async { + Widget buildApp({ + AnimationController? widgetController, + AnimationController? indicatorThemeController, + AnimationController? globalThemeController, + }) { + return MaterialApp( + home: Material( + child: Center( + child: Theme( + data: ThemeData( + progressIndicatorTheme: ProgressIndicatorThemeData( + controller: globalThemeController, + ), + ), + child: ProgressIndicatorTheme( + data: ProgressIndicatorThemeData(controller: indicatorThemeController), + child: SizedBox( + width: 200.0, + child: LinearProgressIndicator(controller: widgetController), + ), + ), + ), + ), + ), + ); + } + + void expectProgressAt({required double left, required double right}) { + final PaintPattern expectedPaints = paints; + if (right < 200) { + // Right track + expectedPaints.rect(rect: Rect.fromLTRB(right, 0.0, 200, 4.0)); + } + expectedPaints.rect(rect: Rect.fromLTRB(left, 0.0, right, 4.0)); + if (left > 0) { + // Left track + expectedPaints.rect(rect: Rect.fromLTRB(0, 0.0, left, 4.0)); + } + expect(find.byType(LinearProgressIndicator), expectedPaints); + } + + await tester.pumpWidget(buildApp()); + await tester.pump(const Duration(milliseconds: 500)); + expectProgressAt(left: 16.028758883476257, right: 141.07513427734375); + + final globalThemeController = AnimationController(vsync: tester, value: 0.1); + addTearDown(globalThemeController.dispose); + await tester.pumpWidget(buildApp(globalThemeController: globalThemeController)); + expectProgressAt(left: 0.0, right: 37.14974820613861); + + final indicatorThemeController = AnimationController(vsync: tester, value: 0.5); + addTearDown(indicatorThemeController.dispose); + await tester.pumpWidget( + buildApp( + globalThemeController: globalThemeController, + indicatorThemeController: indicatorThemeController, + ), + ); + expectProgressAt(left: 127.79541015625, right: 200.0); + + final widgetController = AnimationController(vsync: tester, value: 0.8); + addTearDown(widgetController.dispose); + await tester.pumpWidget( + buildApp( + globalThemeController: globalThemeController, + indicatorThemeController: indicatorThemeController, + widgetController: widgetController, + ), + ); + expectProgressAt(left: 98.24226796627045, right: 181.18448555469513); + }); + + testWidgets('CircularProgressIndicator reflects the value of the theme controller', ( + WidgetTester tester, + ) async { + Widget buildApp({ + AnimationController? widgetController, + AnimationController? indicatorThemeController, + AnimationController? globalThemeController, + }) { + return MaterialApp( + home: Material( + child: Center( + child: Theme( + data: ThemeData( + progressIndicatorTheme: ProgressIndicatorThemeData( + color: Colors.black, + linearTrackColor: Colors.green, + controller: globalThemeController, + ), + ), + child: ProgressIndicatorTheme( + data: ProgressIndicatorThemeData(controller: indicatorThemeController), + child: SizedBox( + width: 200.0, + child: CircularProgressIndicator(controller: widgetController), + ), + ), + ), + ), + ), + ); + } + + void expectProgressAt({required double start, required double sweep}) { + expect( + find.byType(CircularProgressIndicator), + paints..arc(startAngle: start, sweepAngle: sweep), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pump(const Duration(milliseconds: 500)); + expectProgressAt(start: 0.43225767465697107, sweep: 4.52182126629162); + + final globalThemeController = AnimationController(vsync: tester, value: 0.1); + addTearDown(globalThemeController.dispose); + await tester.pumpWidget(buildApp(globalThemeController: globalThemeController)); + expectProgressAt(start: 0.628318530718057, sweep: 2.8904563625380906); + + final indicatorThemeController = AnimationController(vsync: tester, value: 0.5); + addTearDown(indicatorThemeController.dispose); + await tester.pumpWidget( + buildApp( + globalThemeController: globalThemeController, + indicatorThemeController: indicatorThemeController, + ), + ); + expectProgressAt(start: 1.5707963267948966, sweep: 0.001); + + final widgetController = AnimationController(vsync: tester, value: 0.8); + addTearDown(widgetController.dispose); + await tester.pumpWidget( + buildApp( + globalThemeController: globalThemeController, + indicatorThemeController: indicatorThemeController, + widgetController: widgetController, + ), + ); + expectProgressAt(start: 2.520489337828999, sweep: 4.076855234710353); + }); +} diff --git a/packages/material_ui/test/material/radio_list_tile_test.dart b/packages/material_ui/test/material/radio_list_tile_test.dart new file mode 100644 index 000000000000..95defe619221 --- /dev/null +++ b/packages/material_ui/test/material/radio_list_tile_test.dart @@ -0,0 +1,2674 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +Widget wrap({Widget? child}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: child), + ), + ); +} + +void main() { + testWidgets('RadioListTile should initialize according to groupValue', ( + WidgetTester tester, + ) async { + final values = <int>[0, 1, 2]; + int? selectedValue; + // Constructor parameters are required for [RadioListTile], but they are + // irrelevant when searching with [find.byType]. + final Type radioListTileType = const RadioListTile<int>(value: 0, groupValue: 0).runtimeType; + + List<RadioListTile<int>> generatedRadioListTiles; + List<RadioListTile<int>> findTiles() => find + .byType(radioListTileType) + .evaluate() + .map<Widget>((Element element) => element.widget) + .cast<RadioListTile<int>>() + .toList(); + + Widget buildFrame() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: ListView.builder( + itemCount: values.length, + itemBuilder: (BuildContext context, int index) => RadioListTile<int>( + onChanged: (int? value) { + setState(() { + selectedValue = value; + }); + }, + value: values[index], + groupValue: selectedValue, + title: Text(values[index].toString()), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildFrame()); + generatedRadioListTiles = findTiles(); + + expect(generatedRadioListTiles[0].checked, equals(false)); + expect(generatedRadioListTiles[1].checked, equals(false)); + expect(generatedRadioListTiles[2].checked, equals(false)); + + selectedValue = 1; + + await tester.pumpWidget(buildFrame()); + generatedRadioListTiles = findTiles(); + + expect(generatedRadioListTiles[0].checked, equals(false)); + expect(generatedRadioListTiles[1].checked, equals(true)); + expect(generatedRadioListTiles[2].checked, equals(false)); + }); + + testWidgets('RadioListTile forwards statesController to ListTile', (WidgetTester tester) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + value: 1, + groupValue: 1, + onChanged: (_) {}, + statesController: controller, + ), + ), + ); + + final ListTile tile = tester.widget<ListTile>(find.byType(ListTile)); + + expect(tile.statesController, same(controller)); + }); + + testWidgets('RadioListTile simple control test', (WidgetTester tester) async { + final Key key = UniqueKey(); + final Key titleKey = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + title: Text('Title', key: titleKey), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + log.clear(); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + activeColor: Colors.green[500], + title: Text('Title', key: titleKey), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + key: key, + value: 1, + groupValue: 2, + title: Text('Title', key: titleKey), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + title: Text('Title', key: titleKey), + ), + ), + ); + + await tester.tap(find.byKey(titleKey)); + + expect(log, equals(<int>[1])); + }); + + testWidgets('RadioListTile control tests', (WidgetTester tester) async { + final values = <int>[0, 1, 2]; + int? selectedValue; + // Constructor parameters are required for [Radio], but they are irrelevant + // when searching with [find.byType]. + final Type radioType = const Radio<int>(value: 0, groupValue: 0).runtimeType; + final log = <dynamic>[]; + + Widget buildFrame() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: ListView.builder( + itemCount: values.length, + itemBuilder: (BuildContext context, int index) => RadioListTile<int>( + onChanged: (int? value) { + log.add(value); + setState(() { + selectedValue = value; + }); + }, + value: values[index], + groupValue: selectedValue, + title: Text(values[index].toString()), + ), + ), + ); + }, + ), + ); + } + + // Tests for tapping between [Radio] and [ListTile] + await tester.pumpWidget(buildFrame()); + await tester.tap(find.text('1')); + log.add('-'); + await tester.tap(find.byType(radioType).at(2)); + expect(log, equals(<dynamic>[1, '-', 2])); + log.add('-'); + await tester.tap(find.text('1')); + + log.clear(); + selectedValue = null; + + // Tests for tapping across [Radio]s exclusively + await tester.pumpWidget(buildFrame()); + await tester.tap(find.byType(radioType).at(1)); + log.add('-'); + await tester.tap(find.byType(radioType).at(2)); + expect(log, equals(<dynamic>[1, '-', 2])); + + log.clear(); + selectedValue = null; + + // Tests for tapping across [ListTile]s exclusively + await tester.pumpWidget(buildFrame()); + await tester.tap(find.text('1')); + log.add('-'); + await tester.tap(find.text('2')); + expect(log, equals(<dynamic>[1, '-', 2])); + }); + + testWidgets('Selected RadioListTile should not trigger onChanged', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/30311 + final values = <int>[0, 1, 2]; + int? selectedValue; + // Constructor parameters are required for [Radio], but they are irrelevant + // when searching with [find.byType]. + final Type radioType = const Radio<int>(value: 0, groupValue: 0).runtimeType; + final log = <dynamic>[]; + + Widget buildFrame() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: ListView.builder( + itemCount: values.length, + itemBuilder: (BuildContext context, int index) => RadioListTile<int>( + onChanged: (int? value) { + log.add(value); + setState(() { + selectedValue = value; + }); + }, + value: values[index], + groupValue: selectedValue, + title: Text(values[index].toString()), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.tap(find.text('0')); + await tester.pump(); + expect(log, equals(<int>[0])); + + await tester.tap(find.text('0')); + await tester.pump(); + expect(log, equals(<int>[0])); + + await tester.tap(find.byType(radioType).at(0)); + await tester.pump(); + expect(log, equals(<int>[0])); + }); + + testWidgets('Selected RadioListTile should trigger onChanged when toggleable', ( + WidgetTester tester, + ) async { + final values = <int>[0, 1, 2]; + int? selectedValue; + // Constructor parameters are required for [Radio], but they are irrelevant + // when searching with [find.byType]. + final Type radioType = const Radio<int>(value: 0, groupValue: 0).runtimeType; + final log = <dynamic>[]; + + Widget buildFrame() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: ListView.builder( + itemCount: values.length, + itemBuilder: (BuildContext context, int index) { + return RadioListTile<int>( + onChanged: (int? value) { + log.add(value); + setState(() { + selectedValue = value; + }); + }, + toggleable: true, + value: values[index], + groupValue: selectedValue, + title: Text(values[index].toString()), + ); + }, + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.tap(find.text('0')); + await tester.pump(); + expect(log, equals(<int>[0])); + + await tester.tap(find.text('0')); + await tester.pump(); + expect(log, equals(<int?>[0, null])); + + await tester.tap(find.byType(radioType).at(0)); + await tester.pump(); + expect(log, equals(<int?>[0, null, 0])); + }); + + testWidgets('RadioListTile can be toggled when toggleable is set', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + Material( + child: Center( + child: Radio<int>( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + toggleable: true, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + log.clear(); + + await tester.pumpWidget( + Material( + child: Center( + child: Radio<int>( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + toggleable: true, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int?>[null])); + log.clear(); + + await tester.pumpWidget( + Material( + child: Center( + child: Radio<int>(key: key, value: 1, onChanged: log.add, toggleable: true), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + }); + + testWidgets('RadioListTile semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + value: 1, + groupValue: 2, + onChanged: (int? i) {}, + title: const Text('Title'), + internalAddSemanticForOnTap: true, + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + value: 2, + groupValue: 2, + onChanged: (int? i) {}, + title: const Text('Title'), + internalAddSemanticForOnTap: true, + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + wrap( + child: const RadioListTile<int>( + value: 1, + groupValue: 2, + title: Text('Title'), + internalAddSemanticForOnTap: true, + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ], + actions: <SemanticsAction>[SemanticsAction.focus], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + wrap( + child: const RadioListTile<int>( + value: 2, + groupValue: 2, + title: Text('Title'), + internalAddSemanticForOnTap: true, + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasSelectedState, + ], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('RadioListTile has semantic events', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + dynamic semanticEvent; + int? radioValue = 2; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + key: key, + value: 1, + groupValue: radioValue, + onChanged: (int? i) { + radioValue = i; + }, + title: const Text('Title'), + ), + ), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + final RenderObject object = tester.firstRenderObject(find.byKey(key)); + + expect(radioValue, 1); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semantics.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('RadioListTile can autofocus unless disabled.', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + value: 1, + groupValue: 2, + onChanged: (_) {}, + title: Text('Title', key: childKey), + autofocus: true, + ), + ), + ); + + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + wrap( + child: RadioListTile<int>( + value: 1, + groupValue: 2, + title: Text('Title', key: childKey), + autofocus: true, + ), + ), + ); + + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + }); + + testWidgets('RadioListTile contentPadding test', (WidgetTester tester) async { + final Type radioType = const Radio<bool>(groupValue: true, value: true).runtimeType; + + await tester.pumpWidget( + wrap( + child: Center( + child: RadioListTile<bool>( + groupValue: true, + value: true, + title: const Text('Title'), + onChanged: (_) {}, + contentPadding: const EdgeInsets.fromLTRB(8, 10, 15, 20), + ), + ), + ), + ); + + final Rect paddingRect = tester.getRect(find.byType(SafeArea)); + final Rect radioRect = tester.getRect(find.byType(radioType)); + final Rect titleRect = tester.getRect(find.text('Title')); + + // Get the taller Rect of the Radio and Text widgets + final tallerRect = radioRect.height > titleRect.height ? radioRect : titleRect; + + // Get the extra height between the tallerRect and ListTile height + final double extraHeight = 56 - tallerRect.height; + + // Check for correct top and bottom padding + expect(paddingRect.top, tallerRect.top - extraHeight / 2 - 10); //top padding + expect(paddingRect.bottom, tallerRect.bottom + extraHeight / 2 + 20); //bottom padding + + // Check for correct left and right padding + expect(paddingRect.left, radioRect.left - 8); //left padding + expect(paddingRect.right, titleRect.right + 15); //right padding + }); + + testWidgets('RadioListTile respects shape', (WidgetTester tester) async { + const ShapeBorder shapeBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal(right: Radius.circular(100)), + ); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: RadioListTile<bool>( + value: true, + groupValue: true, + title: Text('Title'), + shape: shapeBorder, + ), + ), + ), + ); + + expect(tester.widget<InkWell>(find.byType(InkWell)).customBorder, shapeBorder); + }); + + testWidgets('RadioListTile respects tileColor', (WidgetTester tester) async { + final Color tileColor = Colors.red.shade500; + + await tester.pumpWidget( + wrap( + child: Center( + child: RadioListTile<bool>( + value: false, + groupValue: true, + title: const Text('Title'), + tileColor: tileColor, + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: tileColor)); + }); + + testWidgets('RadioListTile respects selectedTileColor', (WidgetTester tester) async { + final Color selectedTileColor = Colors.green.shade500; + + await tester.pumpWidget( + wrap( + child: Center( + child: RadioListTile<bool>( + value: false, + groupValue: true, + title: const Text('Title'), + selected: true, + selectedTileColor: selectedTileColor, + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: selectedTileColor)); + }); + + testWidgets('RadioListTile selected item text Color', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/76906 + + const activeColor = Color(0xff00ff00); + + Widget buildFrame({Color? activeColor, Color? fillColor}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.selected) ? fillColor : null; + }), + ), + ), + home: Scaffold( + body: Center( + child: RadioListTile<bool>( + activeColor: activeColor, + selected: true, + title: const Text('title'), + value: false, + groupValue: true, + onChanged: (bool? newValue) {}, + ), + ), + ), + ); + } + + Color? textColor(String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style?.color; + } + + await tester.pumpWidget(buildFrame(fillColor: activeColor)); + expect(textColor('title'), activeColor); + + await tester.pumpWidget(buildFrame(activeColor: activeColor)); + expect(textColor('title'), activeColor); + }); + + testWidgets('RadioListTile respects visualDensity', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + wrap( + child: Center( + child: RadioListTile<bool>( + key: key, + value: false, + groupValue: true, + onChanged: (bool? value) {}, + autofocus: true, + visualDensity: visualDensity, + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byKey(key)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 56))); + }); + + testWidgets('RadioListTile respects focusNode', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + wrap( + child: Center( + child: RadioListTile<bool>( + value: false, + groupValue: true, + title: Text('A', key: childKey), + onChanged: (bool? value) {}, + ), + ), + ), + ); + + await tester.pump(); + final FocusNode tileNode = Focus.of(childKey.currentContext!); + tileNode.requestFocus(); + await tester.pump(); // Let the focus take effect. + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + expect(tileNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('RadioListTile onFocusChange callback', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'RadioListTile onFocusChange'); + addTearDown(node.dispose); + + var gotFocus = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RadioListTile<bool>( + value: true, + focusNode: node, + onFocusChange: (bool focused) { + gotFocus = focused; + }, + onChanged: (bool? value) {}, + groupValue: true, + ), + ), + ), + ); + + 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('Radio changes mouse cursor when hovered', (WidgetTester tester) async { + // Test Radio() constructor + await tester.pumpWidget( + wrap( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RadioListTile<int>( + mouseCursor: SystemMouseCursors.text, + value: 1, + onChanged: (int? v) {}, + groupValue: 2, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Radio<int>))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + wrap( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RadioListTile<int>(value: 1, onChanged: (int? v) {}, groupValue: 2), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + wrap( + child: const MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RadioListTile<int>(value: 1, groupValue: 2), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('RadioListTile respects fillColor in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledFillColor = Color(0xFF000001); + const activeDisabledFillColor = Color(0xFF000002); + const inactiveEnabledFillColor = Color(0xFF000003); + const inactiveDisabledFillColor = Color(0xFF000004); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledFillColor; + } + return inactiveDisabledFillColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledFillColor; + } + return inactiveEnabledFillColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + Widget buildApp({required bool enabled}) { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RadioListTile<int>( + value: 0, + fillColor: fillColor, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + groupValue: groupValue, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Selected and enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: activeEnabledFillColor) + ..circle(color: activeEnabledFillColor), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: inactiveEnabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: activeDisabledFillColor) + ..circle(color: activeDisabledFillColor), + ); + + // Check when the radio is unselected and disabled. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: inactiveDisabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets('RadioListTile respects fillColor in hovered state', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredFillColor = Color(0xFF000001); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredFillColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + Widget buildApp() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RadioListTile<int>( + value: 0, + fillColor: fillColor, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + groupValue: groupValue, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio<int>))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle() + ..circle(color: Colors.transparent) + ..circle(color: hoveredFillColor), + ); + }); + + testWidgets('Material3 - RadioListTile respects hoverColor', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + final Color? hoverColor = Colors.orange[500]; + final theme = ThemeData(); + Widget buildApp({bool enabled = true}) { + return wrap( + child: MaterialApp( + theme: theme, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RadioListTile<int>( + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: hoverColor, + groupValue: groupValue, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..circle(color: theme.colorScheme.primary), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Radio<int>))); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: hoverColor) + ..circle(color: Colors.transparent), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)), + ); + }); + + testWidgets('Material3 - RadioListTile respects overlayColor in active/pressed/hovered states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const fillColor = Color(0xFF000000); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const hoverColor = Color(0xFF000005); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + } + + Widget buildRadio({bool active = false, bool useOverlay = true}) { + return MaterialApp( + home: Material( + child: RadioListTile<bool>( + value: active, + groupValue: true, + onChanged: (_) {}, + fillColor: const MaterialStatePropertyAll<Color>(fillColor), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(useOverlay: false)); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x66bcbcbc)) + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: 20.0), + reason: 'Default inactive pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio(active: true, useOverlay: false)); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x66bcbcbc)) + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: 20.0), + reason: 'Default active pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio()); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x66bcbcbc)) + ..circle(color: inactivePressedOverlayColor, radius: 20.0), + reason: 'Inactive pressed Radio should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildRadio(active: true)); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x66bcbcbc)) + ..circle(color: activePressedOverlayColor, radius: 20.0), + reason: 'Active pressed Radio should have overlay color: $activePressedOverlayColor', + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio<bool>))); + await tester.pumpAndSettle(); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..rect(color: const Color(0x00000000)) + ..rect(color: const Color(0x0a000000)) + ..circle(color: hoverOverlayColor, radius: 20.0), + reason: 'Hovered Radio should use overlay color $hoverOverlayColor over $hoverColor', + ); + }); + + testWidgets('RadioListTile respects splashRadius', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 30; + Widget buildApp() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RadioListTile<int>( + value: 0, + onChanged: (_) {}, + hoverColor: Colors.orange[500], + groupValue: 0, + splashRadius: splashRadius, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio<int>))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byWidgetPredicate((Widget widget) => widget is Radio<int>))), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('Radio respects materialTapTargetSize', (WidgetTester tester) async { + await tester.pumpWidget( + wrap( + child: RadioListTile<bool>(groupValue: true, value: true, onChanged: (bool? newValue) {}), + ), + ); + + // default test + expect(tester.getSize(find.byType(Radio<bool>)), const Size(40.0, 40.0)); + + await tester.pumpWidget( + wrap( + child: RadioListTile<bool>( + materialTapTargetSize: MaterialTapTargetSize.padded, + groupValue: true, + value: true, + onChanged: (bool? newValue) {}, + ), + ), + ); + + expect(tester.getSize(find.byType(Radio<bool>)), const Size(48.0, 48.0)); + }); + + testWidgets('RadioListTile.control widget should not request focus on traversal', ( + WidgetTester tester, + ) async { + final GlobalKey firstChildKey = GlobalKey(); + final GlobalKey secondChildKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + RadioListTile<bool>( + value: true, + groupValue: true, + onChanged: (bool? value) {}, + title: Text('Hey', key: firstChildKey), + ), + RadioListTile<bool>( + value: true, + groupValue: true, + onChanged: (bool? value) {}, + title: Text('There', key: secondChildKey), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + Focus.of(firstChildKey.currentContext!).requestFocus(); + await tester.pump(); + expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isTrue); + Focus.of(firstChildKey.currentContext!).nextFocus(); + await tester.pump(); + expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse); + expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue); + }); + + testWidgets('RadioListTile.adaptive shows the correct radio platform widget', ( + WidgetTester tester, + ) async { + Widget buildApp(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Material( + child: Center( + child: RadioListTile<int>.adaptive(value: 1, groupValue: 2, onChanged: (_) {}), + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoRadio<int>), findsOneWidget); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoRadio<int>), findsNothing); + } + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('RadioListTile respects enableFeedback', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest(bool enableFeedback) async { + return tester.pumpWidget( + wrap( + child: Center( + child: RadioListTile<bool>( + key: key, + value: false, + groupValue: true, + selected: true, + onChanged: (bool? value) {}, + enableFeedback: enableFeedback, + ), + ), + ), + ); + } + + await buildTest(false); + await tester.tap(find.byKey(key)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await buildTest(true); + await tester.tap(find.byKey(key)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets( + 'Material2 - RadioListTile respects overlayColor in active/pressed/hovered states', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const fillColor = Color(0xFF000000); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const hoverColor = Color(0xFF000005); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + } + + Widget buildRadio({bool active = false, bool useOverlay = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: RadioListTile<bool>( + value: active, + groupValue: true, + onChanged: (_) {}, + fillColor: const MaterialStatePropertyAll<Color>(fillColor), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(useOverlay: false)); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle() + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: 20), + reason: 'Default inactive pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio(active: true, useOverlay: false)); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle() + ..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: 20), + reason: 'Default active pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio()); + await tester.press(find.byType(Radio<bool>)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle() + ..circle(color: inactivePressedOverlayColor, radius: 20), + reason: 'Inactive pressed Radio should have overlay color: $inactivePressedOverlayColor', + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio<bool>))); + await tester.pumpAndSettle(); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints..circle(color: hoverOverlayColor, radius: 20), + reason: 'Hovered Radio should use overlay color $hoverOverlayColor over $hoverColor', + ); + }, + ); + + testWidgets('Material2 - RadioListTile respects hoverColor', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + final Color? hoverColor = Colors.orange[500]; + Widget buildApp({bool enabled = true}) { + return wrap( + child: MaterialApp( + theme: ThemeData(useMaterial3: false), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RadioListTile<int>( + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: hoverColor, + groupValue: groupValue, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: const Color(0xff2196f3)) + ..circle(color: const Color(0xff2196f3)), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Radio<int>))); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: hoverColor) + ..circle(color: Colors.transparent), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: Colors.transparent) + ..circle(color: const Color(0x61000000)) + ..circle(color: const Color(0x61000000)), + ); + }); + }); + + testWidgets('RadioListTile uses ListTileTheme controlAffinity', (WidgetTester tester) async { + Widget buildListTile(ListTileControlAffinity controlAffinity) { + return MaterialApp( + home: Material( + child: ListTileTheme( + data: ListTileThemeData(controlAffinity: controlAffinity), + child: RadioListTile<double>( + value: 0.5, + groupValue: 1.0, + title: const Text('RadioListTile'), + onChanged: (double? value) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildListTile(ListTileControlAffinity.leading)); + final Finder leading = find.text('RadioListTile'); + final Offset offsetLeading = tester.getTopLeft(leading); + expect(offsetLeading, const Offset(72.0, 16.0)); + + await tester.pumpWidget(buildListTile(ListTileControlAffinity.trailing)); + final Finder trailing = find.text('RadioListTile'); + final Offset offsetTrailing = tester.getTopLeft(trailing); + expect(offsetTrailing, const Offset(16.0, 16.0)); + + await tester.pumpWidget(buildListTile(ListTileControlAffinity.platform)); + final Finder platform = find.text('RadioListTile'); + final Offset offsetPlatform = tester.getTopLeft(platform); + expect(offsetPlatform, const Offset(72.0, 16.0)); + }); + + testWidgets('RadioListTile renders with default scale', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: RadioListTile<bool>(value: false, groupValue: false)), + ), + ); + + final Finder transformFinder = find.ancestor( + of: find.byType(Radio<bool>), + matching: find.byType(Transform), + ); + + expect(transformFinder, findsNothing); + }); + + testWidgets('RadioListTile respects radioScaleFactor', (WidgetTester tester) async { + const scale = 1.4; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: RadioListTile<bool>(value: false, groupValue: false, radioScaleFactor: scale), + ), + ), + ); + + final Transform widget = tester.widget( + find.ancestor(of: find.byType(Radio<bool>), matching: find.byType(Transform)), + ); + + expect(widget.transform.getMaxScaleOnAxis(), scale); + }); + + testWidgets('RadioListTile isThreeLine', (WidgetTester tester) async { + const double height = 300; + const size = 40.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: themeDataIsThreeLine != null + ? ThemeData(listTileTheme: ListTileThemeData(isThreeLine: themeDataIsThreeLine)) + : null, + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + RadioListTile<int>( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + value: 0, + groupValue: 1, + ), + RadioListTile<int>( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A'), + value: 0, + groupValue: 2, + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(RadioListTile<int>).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(0)), + const Rect.fromLTWH(16.0, 130.0, size, size), + ); + expect( + tester.getRect(find.byType(RadioListTile<int>).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(1)), + const Rect.fromLTWH(16.0, height + 16, size, size), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(RadioListTile<int>).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(0)), + const Rect.fromLTWH(16.0, 8.0, size, size), + ); + expect( + tester.getRect(find.byType(RadioListTile<int>).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(1)), + const Rect.fromLTWH(16.0, height + 8.0, size, size), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('RadioListTile.adaptive isThreeLine', (WidgetTester tester) async { + const double height = 300; + const size = 18.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: ThemeData( + platform: TargetPlatform.iOS, + listTileTheme: themeDataIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeDataIsThreeLine) + : null, + ), + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + RadioListTile<int>.adaptive( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + value: 0, + groupValue: 1, + ), + RadioListTile<int>.adaptive( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A'), + value: 0, + groupValue: 2, + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(RadioListTile<int>).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(0)), + const Rect.fromLTWH(16.0, 141.0, size, size), + ); + expect( + tester.getRect(find.byType(RadioListTile<int>).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(1)), + const Rect.fromLTWH(16.0, height + 27.0, size, size), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(RadioListTile<int>).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(0)), + const Rect.fromLTWH(16.0, 8.0, size, size), + ); + expect( + tester.getRect(find.byType(RadioListTile<int>).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(Radio<int>).at(1)), + const Rect.fromLTWH(16.0, height + 8.0, size, size), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('titleAlignment position with title widget', (WidgetTester tester) async { + const secondaryKey = Key('secondary'); + const titleHeight = 50.0; + const secondaryHeight = 24.0; + // The default vertical padding for material 3 is 8.0. + const minVerticalPadding = 8.0; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + home: Material( + child: Center( + child: RadioListTile<bool>( + titleAlignment: titleAlignment, + controlAffinity: ListTileControlAffinity.leading, + value: true, + groupValue: true, + onChanged: (bool? newValue) {}, + title: const SizedBox(width: 20.0, height: titleHeight), + secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight), + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, the default title alignment is + // [ListTileTitleAlignment.threeLine], which positions the leading and + // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] + // property is false. + await tester.pumpWidget(buildFrame()); + final double radioHeight = tester.getSize(find.byType(Radio<bool>)).height; + final double tileHeight = tester.getSize(find.byType(ListTile)).height; + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == null; + }), + findsOne, + ); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + final double centerPositionRadio = (tileHeight / 2) - (radioHeight / 2); + final double centerPositionSecondary = (tileHeight / 2) - (secondaryHeight / 2); + expect(radioOffset.dy - tileOffset.dy, centerPositionRadio); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(radioOffset.dy - tileOffset.dy, centerPositionRadio); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + expect(radioOffset.dy - tileOffset.dy, (tileHeight - radioHeight) / 2); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. The default for material 3 is 8.0. + const topPosition = minVerticalPadding; + expect(radioOffset.dy - tileOffset.dy, topPosition); + expect(secondaryOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + expect(radioOffset.dy - tileOffset.dy, centerPositionRadio); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionSecondary); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + final double bottomPositionRadio = tileHeight - minVerticalPadding - radioHeight; + final double bottomPositionSecondary = tileHeight - minVerticalPadding - secondaryHeight; + expect(radioOffset.dy - tileOffset.dy, bottomPositionRadio); + expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary); + }); + + testWidgets('titleAlignment position with title and subtitle widgets', ( + WidgetTester tester, + ) async { + const secondaryKey = Key('secondary'); + const titleHeight = 50.0; + const subtitleHeight = 50.0; + const secondaryHeight = 24.0; + const verticalPadding = 8.0; + + Widget buildFrame({ListTileTitleAlignment? titleAlignment}) { + return MaterialApp( + home: Material( + child: Center( + child: RadioListTile<bool>( + titleAlignment: titleAlignment, + controlAffinity: ListTileControlAffinity.leading, + title: const SizedBox(width: 20.0, height: titleHeight), + subtitle: const SizedBox(width: 20.0, height: subtitleHeight), + secondary: const SizedBox(key: secondaryKey, width: 24.0, height: secondaryHeight), + value: true, + groupValue: true, + onChanged: (bool? newValue) {}, + ), + ), + ), + ); + } + + // If [ThemeData.useMaterial3] is true, the default title alignment is + // [ListTileTitleAlignment.threeLine], which positions the leading and + // trailing widgets center vertically in the tile if the [ListTile.isThreeLine] + // property is false. + await tester.pumpWidget(buildFrame()); + final double tileHeight = tester.getSize(find.byType(ListTile)).height; + final double radioHeight = tester.getSize(find.byType(Radio<bool>)).height; + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == null; + }), + findsOne, + ); + Offset tileOffset = tester.getTopLeft(find.byType(ListTile)); + Offset radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + Offset secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + final double centerPositionOffsetRadio = (tileHeight / 2) - (radioHeight / 2); + final double centerPositionOffsetSecondary = (tileHeight / 2) - (secondaryHeight / 2); + expect(radioOffset.dy - tileOffset.dy, centerPositionOffsetRadio); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary); + + // Test [ListTileTitleAlignment.threeLine] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.threeLine)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.threeLine; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile, + // If the [ListTile.isThreeLine] property is false. + expect(radioOffset.dy - tileOffset.dy, centerPositionOffsetRadio); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary); + + // Test [ListTileTitleAlignment.titleHeight] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.titleHeight)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.titleHeight; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are positioned 16.0 pixels below the + // top of the title widget. + const titlePosition = 16.0; + expect(radioOffset.dy - tileOffset.dy, titlePosition); + expect(secondaryOffset.dy - tileOffset.dy, titlePosition); + + // Test [ListTileTitleAlignment.top] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.top)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.top; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding below + // the top of the title widget. + const topPosition = verticalPadding; + expect(radioOffset.dy - tileOffset.dy, topPosition); + expect(secondaryOffset.dy - tileOffset.dy, topPosition); + + // Test [ListTileTitleAlignment.center] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.center)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.center; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are centered vertically in the tile. + expect(radioOffset.dy - tileOffset.dy, centerPositionOffsetRadio); + expect(secondaryOffset.dy - tileOffset.dy, centerPositionOffsetSecondary); + + // Test [ListTileTitleAlignment.bottom] alignment. + await tester.pumpWidget(buildFrame(titleAlignment: ListTileTitleAlignment.bottom)); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ListTile && widget.titleAlignment == ListTileTitleAlignment.bottom; + }), + findsOne, + ); + tileOffset = tester.getTopLeft(find.byType(ListTile)); + radioOffset = tester.getTopLeft(find.byType(Radio<bool>)); + secondaryOffset = tester.getTopRight(find.byKey(secondaryKey)); + + // Leading and trailing widgets are placed minVerticalPadding above + // the bottom of the subtitle widget. + final double bottomPositionRadio = tileHeight - verticalPadding - radioHeight; + final double bottomPositionSecondary = tileHeight - verticalPadding - secondaryHeight; + expect(radioOffset.dy - tileOffset.dy, bottomPositionRadio); + expect(secondaryOffset.dy - tileOffset.dy, bottomPositionSecondary); + }); + + testWidgets('RadioListTile respects radioBackgroundColor in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledBackgroundColor = Color(0xFF000001); + const activeDisabledBackgroundColor = Color(0xFF000002); + const inactiveEnabledBackgroundColor = Color(0xFF000003); + const inactiveDisabledBackgroundColor = Color(0xFF000004); + + Color getBackgroundColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledBackgroundColor; + } + return inactiveDisabledBackgroundColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledBackgroundColor; + } + return inactiveEnabledBackgroundColor; + } + + final WidgetStateProperty<Color> backgroundColor = WidgetStateColor.resolveWith( + getBackgroundColor, + ); + + int? groupValue = 0; + Widget buildApp({required bool enabled}) { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: RadioListTile<int>( + value: 0, + radioBackgroundColor: backgroundColor, + enabled: enabled, + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Selected and enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: activeEnabledBackgroundColor), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: inactiveEnabledBackgroundColor), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: activeDisabledBackgroundColor), + ); + + // Check when the radio is unselected and disabled. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle(color: inactiveDisabledBackgroundColor), + ); + }); + + testWidgets('RadioListTile respects radioBackgroundColor in hovered state', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredBackgroundColor = Color(0xFF000001); + + final theme = ThemeData(); + + Color getBackgroundColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredBackgroundColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> backgroundColor = WidgetStateColor.resolveWith( + getBackgroundColor, + ); + + const groupValue = 0; + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) {}, + child: RadioListTile<int>(value: 0, radioBackgroundColor: backgroundColor), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Radio<int>))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<int>))), + paints + ..rect() + ..circle() + ..circle(color: hoveredBackgroundColor), + ); + }); + + testWidgets('radioSide is passed to the Radio', (WidgetTester tester) async { + const side = BorderSide(color: Colors.red, width: 3.0); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: RadioListTile<bool>(value: true, radioSide: side)), + ), + ), + ); + + final Radio<bool> radio = tester.widget(find.byType(Radio<bool>)); + expect(radio.side, side); + }); + + testWidgets('radioInnerRadius is passed to the Radio', (WidgetTester tester) async { + final WidgetStateProperty<double?> innerRadius = WidgetStateProperty.all(6); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: RadioListTile<bool>(value: true, radioInnerRadius: innerRadius)), + ), + ), + ); + + final Radio<bool> radio = tester.widget(find.byType(Radio<bool>)); + expect(radio.innerRadius, innerRadius); + }); + + testWidgets('RadioListTile does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center(child: SizedBox.shrink(child: RadioListTile<bool>(value: true))), + ), + ), + ); + expect(tester.getSize(find.byType(RadioListTile<bool>)), Size.zero); + }); + + testWidgets('RadioListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeHorizontalTitleGap, + double? widgetHorizontalTitleGap, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), + child: Container( + alignment: Alignment.topLeft, + child: RadioListTile<int>( + controlAffinity: ListTileControlAffinity.leading, + horizontalTitleGap: widgetHorizontalTitleGap, + value: 1, + title: const Text('title'), + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 56.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 744.0); + }); + + testWidgets( + 'RadioListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', + (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: const RadioListTile<int>( + controlAffinity: ListTileControlAffinity.leading, + value: 1, + title: Text('title'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(left('title'), 72.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(right('title'), 728.0); + }, + ); + + testWidgets('RadioListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { + Widget buildFrame({double? horizontalTitleGap, VisualDensity? visualDensity}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: RadioListTile<int>( + controlAffinity: ListTileControlAffinity.leading, + visualDensity: visualDensity, + horizontalTitleGap: horizontalTitleGap, + value: 1, + title: const Text('title'), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + + // Pump another frame of the same widget to ensure the underlying render + // object did not cache the original horizontalTitleGap calculation based on the + // visualDensity + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 58.0); + }); + + testWidgets('RadioListTile minVerticalPadding = 80.0 Material 3', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: RadioListTile<int>( + minVerticalPadding: widgetMinVerticalPadding, + value: 1, + title: const Text('title'), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 24(Title) = 184 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 24(Title) = 184 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + }); + + testWidgets('RadioListTile minVerticalPadding = 80.0 Material 2', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: RadioListTile<int>( + minVerticalPadding: widgetMinVerticalPadding, + value: 1, + title: const Text('title'), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + }); + + testWidgets('RadioListTile minLeadingWidth = 60.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinLeadingWidth, + double? widgetMinLeadingWidth, + }) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minLeadingWidth: themeMinLeadingWidth), + child: Container( + alignment: Alignment.topLeft, + child: RadioListTile<int>( + controlAffinity: ListTileControlAffinity.leading, + minLeadingWidth: widgetMinLeadingWidth, + value: 1, + title: const Text('title'), + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 92.0 = 16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0 + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 708.0 = 800.0 - (16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0) + expect(right('title'), 708.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + }); + + testWidgets('RadioListTile minTileHeight', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection, {double? minTileHeight}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: RadioListTile<int>(value: 1, minTileHeight: minTileHeight), + ), + ), + ), + ); + } + + // Default list tile with height = 56.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + + // Set list tile height = 30.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 30)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 30.0)); + + // Set list tile height = 60.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 60.0)); + }); +} diff --git a/packages/material_ui/test/material/radio_test.dart b/packages/material_ui/test/material/radio_test.dart new file mode 100644 index 000000000000..267cb36a9959 --- /dev/null +++ b/packages/material_ui/test/material/radio_test.dart @@ -0,0 +1,2782 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/src/gestures/constants.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + final theme = ThemeData(); + + testWidgets('Radio control test', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio<int>(key: key, value: 1, groupValue: 2, onChanged: log.add), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + log.clear(); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio<int>( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + activeColor: Colors.green[500], + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center(child: Radio<int>(key: key, value: 1, groupValue: 2)), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + }); + + testWidgets('Radio disabled', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio<int>( + key: key, + value: 1, + groupValue: 2, + enabled: false, + onChanged: log.add, + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[])); + }); + + testWidgets('Radio can be toggled when toggleable is set', (WidgetTester tester) async { + final Key key = UniqueKey(); + final log = <int?>[]; + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio<int>( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + toggleable: true, + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + log.clear(); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio<int>( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + toggleable: true, + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int?>[null])); + log.clear(); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio<int>(key: key, value: 1, onChanged: log.add, toggleable: true), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals(<int>[1])); + }); + + testWidgets('Radio size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + final Key key1 = UniqueKey(); + await tester.pumpWidget( + Theme( + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Radio<bool>( + key: key1, + groupValue: true, + value: true, + onChanged: (bool? newValue) {}, + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key1)), const Size(48.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget( + Theme( + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Radio<bool>( + key: key2, + groupValue: true, + value: true, + onChanged: (bool? newValue) {}, + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key2)), const Size(40.0, 40.0)); + }); + + testWidgets('Radio selected semantics - platform adaptive', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material(child: Radio<int>(value: 1, groupValue: 1, onChanged: (int? i) {})), + ), + ); + final bool isCupertino = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + expect( + semantics, + includesNodeWith( + flags: <SemanticsFlag>[ + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isChecked, + if (isCupertino) SemanticsFlag.hasSelectedState, + if (isCupertino) SemanticsFlag.isSelected, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform != TargetPlatform.iOS) SemanticsAction.focus, + ], + ), + ); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Radio semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material(child: Radio<int>(value: 1, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material(child: Radio<int>(value: 2, groupValue: 2, onChanged: (int? i) {})), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Radio<int>(value: 1, groupValue: 2)), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.isFocusable, // This flag is delayed by 1 frame. + ], + actions: <SemanticsAction>[SemanticsAction.focus], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pump(); + + // Now the isFocusable should be gone. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: const Material(child: Radio<int>(value: 2, groupValue: 2)), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('has semantic events', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + dynamic semanticEvent; + int? radioValue = 2; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Radio<int>( + key: key, + value: 1, + groupValue: radioValue, + onChanged: (int? i) { + radioValue = i; + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + final RenderObject object = tester.firstRenderObject(find.byKey(key)); + + expect(radioValue, 1); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semantics.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('Material2 - Radio ink ripple is displayed correctly', (WidgetTester tester) async { + final Key painterKey = UniqueKey(); + const radioKey = Key('radio'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Center( + child: Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 1, + groupValue: 1, + onChanged: (int? value) {}, + ), + ), + ), + ), + ), + ), + ); + + await tester.press(find.byKey(radioKey)); + await tester.pumpAndSettle(); + await expectLater(find.byKey(painterKey), matchesGoldenFile('m2_radio.ink_ripple.png')); + }); + + testWidgets('Material3 - Radio ink ripple is displayed correctly', (WidgetTester tester) async { + final Key painterKey = UniqueKey(); + const radioKey = Key('radio'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RepaintBoundary( + key: painterKey, + child: Center( + child: Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 1, + groupValue: 1, + onChanged: (int? value) {}, + ), + ), + ), + ), + ), + ), + ); + + await tester.press(find.byKey(radioKey)); + await tester.pumpAndSettle(); + await expectLater(find.byKey(painterKey), matchesGoldenFile('m3_radio.ink_ripple.png')); + }); + + testWidgets('Radio with splash radius set', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 30; + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + value: 0, + onChanged: (int? newValue) {}, + focusColor: Colors.orange[500], + autofocus: true, + groupValue: 0, + splashRadius: splashRadius, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byWidgetPredicate((Widget widget) => widget is Radio<int>))), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('Material2 - Radio is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + const radioKey = Key('radio'); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.orange[500]) + ..circle(color: Colors.transparent) + ..circle(color: const Color(0xff2196f3)) + ..circle(color: const Color(0xff2196f3)), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.orange[500]) + ..circle(color: Colors.transparent) + ..circle(color: const Color(0x8a000000), style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: const Color(0x61000000)) + ..circle(color: const Color(0x61000000)), + ); + focusNode.dispose(); + }); + + testWidgets('Material3 - Radio is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + const radioKey = Key('radio'); + final theme = ThemeData(); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.orange[500]) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..circle(color: theme.colorScheme.primary), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect() + ..circle(color: Colors.orange[500]) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)), + ); + focusNode.dispose(); + }); + + testWidgets('Material2 - Radio can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + const radioKey = Key('radio'); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: const Color(0xff2196f3)) + ..circle(color: const Color(0xff2196f3)), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.orange[500]) + ..circle(color: Colors.transparent) + ..circle(color: const Color(0x8a000000), style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: const Color(0x61000000)) + ..circle(color: const Color(0x61000000)), + ); + }); + + testWidgets('Material3 - Radio can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 0; + const radioKey = Key('radio'); + final theme = ThemeData(); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..circle(color: theme.colorScheme.primary), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp()); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.orange[500]) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)), + ); + }); + + testWidgets('Radio can be controlled by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + int? groupValue = 1; + const radioKey0 = Key('radio0'); + const radioKey1 = Key('radio1'); + const radioKey2 = Key('radio2'); + final focusNode2 = FocusNode(debugLabel: 'radio2'); + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 200, + height: 100, + color: Colors.white, + child: Row( + children: <Widget>[ + Radio<int>( + key: radioKey0, + value: 0, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + groupValue: groupValue, + autofocus: true, + ), + Radio<int>( + key: radioKey1, + value: 1, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + groupValue: groupValue, + ), + Radio<int>( + key: radioKey2, + value: 2, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + groupValue: groupValue, + focusNode: focusNode2, + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + // On web, radios don't respond to the enter key. + expect(groupValue, kIsWeb ? equals(1) : equals(0)); + + focusNode2.requestFocus(); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(groupValue, equals(2)); + + focusNode2.dispose(); + }); + + testWidgets('Radio responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Radio<int>( + visualDensity: visualDensity, + key: key, + onChanged: (int? value) {}, + value: 0, + groupValue: 0, + ), + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byKey(key)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(48, 48))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(60, 60))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(36, 36))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(60, 36))); + }); + + testWidgets('Radio changes mouse cursor when hovered', (WidgetTester tester) async { + const Key key = ValueKey<int>(1); + // Test Radio() constructor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Radio<int>( + key: key, + mouseCursor: SystemMouseCursors.text, + value: 1, + onChanged: (int? v) {}, + groupValue: 2, + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byKey(key))); + addTearDown(gesture.removePointer); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Radio<int>(value: 1, onChanged: (int? v) {}, groupValue: 2), + ), + ), + ), + ), + ), + ); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Radio<int>(value: 1, groupValue: 2), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('Radio button fill color resolves in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledFillColor = Color(0xFF000001); + const activeDisabledFillColor = Color(0xFF000002); + const inactiveEnabledFillColor = Color(0xFF000003); + const inactiveDisabledFillColor = Color(0xFF000004); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledFillColor; + } + return inactiveDisabledFillColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledFillColor; + } + return inactiveEnabledFillColor; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + const radioKey = Key('radio'); + Widget buildApp({required bool enabled}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + key: radioKey, + value: 0, + fillColor: fillColor, + onChanged: enabled + ? (int? newValue) { + setState(() { + groupValue = newValue; + }); + } + : null, + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Selected and enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: activeEnabledFillColor) + ..circle(color: activeEnabledFillColor), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: inactiveEnabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: activeDisabledFillColor) + ..circle(color: activeDisabledFillColor), + ); + + // Check when the radio is unselected and disabled. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: inactiveDisabledFillColor, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + }); + + testWidgets('Material2 - Radio fill color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredFillColor = Color(0xFF000001); + const focusedFillColor = Color(0xFF000002); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredFillColor; + } + if (states.contains(WidgetState.focused)) { + return focusedFillColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + const radioKey = Key('radio'); + final theme = ThemeData(useMaterial3: false); + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + autofocus: true, + focusNode: focusNode, + key: radioKey, + value: 0, + fillColor: fillColor, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.black12) + ..circle(color: Colors.transparent) + ..circle(color: focusedFillColor), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: theme.hoverColor) + ..circle(color: Colors.transparent) + ..circle(color: hoveredFillColor), + ); + + focusNode.dispose(); + }); + + testWidgets('Material3 - Radio fill color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredFillColor = Color(0xFF000001); + const focusedFillColor = Color(0xFF000002); + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredFillColor; + } + if (states.contains(WidgetState.focused)) { + return focusedFillColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> fillColor = WidgetStateColor.resolveWith(getFillColor); + + int? groupValue = 0; + const radioKey = Key('radio'); + final theme = ThemeData(); + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: Radio<int>( + autofocus: true, + focusNode: focusNode, + key: radioKey, + value: 0, + fillColor: fillColor, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + groupValue: groupValue, + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect() + ..circle(color: theme.colorScheme.primary.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: focusedFillColor), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: theme.colorScheme.primary.withOpacity(0.08)) + ..circle(color: Colors.transparent) + ..circle(color: hoveredFillColor), + ); + + focusNode.dispose(); + }); + + testWidgets('Radio overlay color resolves in active/pressed/focused/hovered states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const fillColor = Color(0xFF000000); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const focusOverlayColor = Color(0xFF000004); + const hoverColor = Color(0xFF000005); + const focusColor = Color(0xFF000006); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Finder findRadio() { + return find.byWidgetPredicate((Widget widget) => widget is Radio<bool>); + } + + MaterialInkController? getRadioMaterial(WidgetTester tester) { + return Material.of(tester.element(findRadio())); + } + + Widget buildRadio({bool active = false, bool focused = false, bool useOverlay = true}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Radio<bool>( + focusNode: focusNode, + autofocus: focused, + value: active, + groupValue: true, + onChanged: (_) {}, + fillColor: const MaterialStatePropertyAll<Color>(fillColor), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + focusColor: focusColor, + splashRadius: splashRadius, + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(useOverlay: false)); + await tester.press(findRadio()); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default inactive pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio(active: true, useOverlay: false)); + await tester.press(findRadio()); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints..circle(color: fillColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default active pressed Radio should have overlay color from fillColor', + ); + + await tester.pumpWidget(buildRadio()); + await tester.press(findRadio()); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Radio should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildRadio(active: true)); + await tester.press(findRadio()); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Radio should have overlay color: $activePressedOverlayColor', + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio(focused: true)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + getRadioMaterial(tester), + paints..circle(color: focusOverlayColor, radius: splashRadius), + reason: 'Focused Radio should use overlay color $focusOverlayColor over $focusColor', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(findRadio())); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints..circle(color: hoverOverlayColor, radius: splashRadius), + reason: 'Hovered Radio should use overlay color $hoverOverlayColor over $hoverColor', + ); + + focusNode.dispose(); + }); + + testWidgets('Do not crash when widget disappears while pointer is down', ( + WidgetTester tester, + ) async { + final Key key = UniqueKey(); + + Widget buildRadio(bool show) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: show + ? Radio<bool>(key: key, value: true, groupValue: false, onChanged: (_) {}) + : Container(), + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(true)); + final Offset center = tester.getCenter(find.byKey(key)); + // Put a pointer down on the screen. + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); + // While the pointer is down, the widget disappears. + await tester.pumpWidget(buildRadio(false)); + expect(find.byKey(key), findsNothing); + // Release pointer after widget disappeared. + await gesture.up(); + }); + + testWidgets('disabled radio shows tooltip', (WidgetTester tester) async { + const longPressTooltip = 'long press tooltip'; + const tapTooltip = 'tap tooltip'; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: Tooltip( + message: longPressTooltip, + child: Radio<bool>(value: true, groupValue: false), + ), + ), + ), + ); + + // Default tooltip shows up after long pressed. + final Finder tooltip0 = find.byType(Tooltip); + expect(find.text(longPressTooltip), findsNothing); + + await tester.tap(tooltip0); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(longPressTooltip), findsNothing); + + final TestGesture gestureLongPress = await tester.startGesture(tester.getCenter(tooltip0)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gestureLongPress.up(); + await tester.pump(); + + expect(find.text(longPressTooltip), findsOneWidget); + + // Tooltip shows up after tapping when set triggerMode to TooltipTriggerMode.tap. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: tapTooltip, + child: Radio<bool>(value: true, groupValue: false), + ), + ), + ), + ); + + await tester.pump(const Duration(days: 1)); + await tester.pumpAndSettle(); + expect(find.text(tapTooltip), findsNothing); + expect(find.text(longPressTooltip), findsNothing); + + final Finder tooltip1 = find.byType(Tooltip); + await tester.tap(tooltip1); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(tapTooltip), findsOneWidget); + }); + + testWidgets('Material2 - Radio button default colors', (WidgetTester tester) async { + Widget buildRadio({bool enabled = true, bool selected = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Radio<bool>(value: true, groupValue: true, onChanged: enabled ? (_) {} : null), + ), + ); + } + + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: Colors.transparent) + ..circle(color: const Color(0xFF2196F3)) // Outer circle - primary value + ..circle(color: const Color(0xFF2196F3)) + ..restore(), // Inner circle - primary value + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio(selected: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..save() + ..circle(color: Colors.transparent) + ..circle(color: const Color(0xFF2196F3)) + ..restore(), + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio(enabled: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: Colors.transparent) + ..circle(color: Colors.black38), + ); + }); + + testWidgets('Material3 - Radio button default colors', (WidgetTester tester) async { + final theme = ThemeData(); + Widget buildRadio({bool enabled = true, bool selected = true}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Radio<bool>(value: true, groupValue: true, onChanged: enabled ? (_) {} : null), + ), + ); + } + + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) // Outer circle - primary value + ..circle(color: theme.colorScheme.primary) + ..restore(), // Inner circle - primary value + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio(selected: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..save() + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..restore(), + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildRadio(enabled: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.38)), + ); + }); + + testWidgets('Material2 - Radio button default overlay colors in hover/focus/press states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + final theme = ThemeData(useMaterial3: false); + final ColorScheme colors = theme.colorScheme; + Widget buildRadio({bool enabled = true, bool focused = false, bool selected = true}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Radio<bool>( + focusNode: focusNode, + autofocus: focused, + value: true, + groupValue: selected, + onChanged: enabled ? (_) {} : null, + ), + ), + ); + } + + // default selected radio + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: Colors.transparent) + ..circle(color: colors.secondary), + ); + + // selected radio in pressed state + await tester.pumpWidget(buildRadio()); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(Radio<bool>)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: colors.secondary.withAlpha(0x1F)) + ..circle(color: Colors.transparent) + ..circle(color: colors.secondary), + ); + + // unselected radio in pressed state + await tester.pumpWidget(buildRadio(selected: false)); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(Radio<bool>)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: theme.unselectedWidgetColor.withAlpha(0x1F)) + ..circle(color: Colors.transparent) + ..circle(color: theme.unselectedWidgetColor), + ); + + // selected radio in focused state + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildRadio(focused: true)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: theme.focusColor) + ..circle(color: Colors.transparent) + ..circle(color: colors.secondary), + ); + + // unselected radio in focused state + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildRadio(focused: true, selected: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: theme.focusColor) + ..circle(color: Colors.transparent) + ..circle(color: theme.unselectedWidgetColor), + ); + + // selected radio in hovered state + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildRadio()); + final TestGesture gesture3 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture3.addPointer(); + await gesture3.moveTo(tester.getCenter(find.byType(Radio<bool>))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: theme.hoverColor) + ..circle(color: Colors.transparent) + ..circle(color: colors.secondary), + ); + + focusNode.dispose(); + + // Finish gesture to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Material3 - Radio button default overlay colors in hover/focus/press states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + Widget buildRadio({bool enabled = true, bool focused = false, bool selected = true}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Radio<bool>( + focusNode: focusNode, + autofocus: focused, + value: true, + groupValue: selected, + onChanged: enabled ? (_) {} : null, + ), + ), + ); + } + + // default selected radio + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: Colors.transparent) + ..circle(color: colors.primary.withOpacity(1)), + ); + + // selected radio in pressed state + await tester.pumpWidget(buildRadio()); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.byType(Radio<bool>)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: colors.onSurface.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: colors.primary.withOpacity(1)), + ); + + // unselected radio in pressed state + await tester.pumpWidget(buildRadio(selected: false)); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(Radio<bool>)), + ); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: colors.primary.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: colors.onSurfaceVariant.withOpacity(1)), + ); + + // selected radio in focused state + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildRadio(focused: true)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: colors.primary.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: colors.primary.withOpacity(1)), + ); + + // unselected radio in focused state + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildRadio(focused: true, selected: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: colors.onSurface.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: colors.onSurface.withOpacity(1)), + ); + + // selected radio in hovered state + await tester.pumpWidget(Container()); // reset test + await tester.pumpWidget(buildRadio()); + final TestGesture gesture3 = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture3.addPointer(); + await gesture3.moveTo(tester.getCenter(find.byType(Radio<bool>))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Radio<bool>))), + paints + ..circle(color: colors.primary.withOpacity(0.08)) + ..circle(color: Colors.transparent) + ..circle(color: colors.primary.withOpacity(1)), + ); + + focusNode.dispose(); + + // Finish gesture to release resources. + await gesture1.up(); + await gesture2.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Radio.adaptive shows the correct platform widget', (WidgetTester tester) async { + Widget buildApp(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Material( + child: Center(child: Radio<int>.adaptive(value: 1, groupValue: 2, onChanged: (_) {})), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoRadio<int>), findsOneWidget); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(buildApp(platform)); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoRadio<int>), findsNothing); + } + }); + + testWidgets('Material2 - Radio default overlayColor and fillColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(useMaterial3: false); + + Finder findRadio() { + return find.byWidgetPredicate((Widget widget) => widget is Radio<bool>); + } + + MaterialInkController? getRadioMaterial(WidgetTester tester) { + return Material.of(tester.element(findRadio())); + } + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Radio<bool>(focusNode: focusNode, value: true, groupValue: true, onChanged: (_) {}), + ), + ), + ); + + // Hover + final Offset center = tester.getCenter(find.byType(Radio<bool>)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints + ..circle(color: theme.hoverColor) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.secondary), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints + ..circle(color: theme.colorScheme.secondary.withAlpha(kRadialReactionAlpha)) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.secondary), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints + ..circle(color: theme.focusColor) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.secondary), + ); + focusNode.dispose(); + }); + + testWidgets('Material3 - Radio default overlayColor and fillColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + + Finder findRadio() { + return find.byWidgetPredicate((Widget widget) => widget is Radio<bool>); + } + + MaterialInkController? getRadioMaterial(WidgetTester tester) { + return Material.of(tester.element(findRadio())); + } + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Radio<bool>(focusNode: focusNode, value: true, groupValue: true, onChanged: (_) {}), + ), + ), + ); + + // Hover + final Offset center = tester.getCenter(find.byType(Radio<bool>)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints + ..circle(color: theme.colorScheme.primary.withOpacity(0.08)) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary), + ); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints + ..circle(color: theme.colorScheme.onSurface.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect( + getRadioMaterial(tester), + paints + ..circle(color: theme.colorScheme.primary.withOpacity(0.1)) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary), + ); + focusNode.dispose(); + }); + + testWidgets('Radio button background color resolves in enabled/disabled states', ( + WidgetTester tester, + ) async { + const activeEnabledBackgroundColor = Color(0xFF000001); + const activeDisabledBackgroundColor = Color(0xFF000002); + const inactiveEnabledBackgroundColor = Color(0xFF000003); + const inactiveDisabledBackgroundColor = Color(0xFF000004); + + Color getBackgroundColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledBackgroundColor; + } + return inactiveDisabledBackgroundColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledBackgroundColor; + } + return inactiveEnabledBackgroundColor; + } + + final WidgetStateProperty<Color> backgroundColor = WidgetStateColor.resolveWith( + getBackgroundColor, + ); + + int? groupValue = 0; + const radioKey = Key('radio'); + Widget buildApp({required bool enabled}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Radio<int>( + key: radioKey, + value: 0, + backgroundColor: backgroundColor, + enabled: enabled, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Selected and enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: activeEnabledBackgroundColor), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: inactiveEnabledBackgroundColor), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: activeDisabledBackgroundColor), + ); + + // Check when the radio is unselected and disabled. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: inactiveDisabledBackgroundColor), + ); + }); + + testWidgets('Radio background color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredBackgroundColor = Color(0xFF000001); + const focusedBackgroundColor = Color(0xFF000002); + + Color getBackgroundColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredBackgroundColor; + } + if (states.contains(WidgetState.focused)) { + return focusedBackgroundColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> backgroundColor = WidgetStateColor.resolveWith( + getBackgroundColor, + ); + + int? groupValue = 0; + const radioKey = Key('radio'); + final theme = ThemeData(); + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Radio<int>( + autofocus: true, + focusNode: focusNode, + key: radioKey, + value: 0, + backgroundColor: backgroundColor, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect() + ..circle(color: theme.colorScheme.primary.withValues(alpha: 0.1)) + ..circle(color: focusedBackgroundColor), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: theme.colorScheme.primary.withValues(alpha: 0.08)) + ..circle(color: hoveredBackgroundColor), + ); + + focusNode.dispose(); + }); + + testWidgets('Radio button side resolves in enabled/disabled states', (WidgetTester tester) async { + const activeEnabledSide = BorderSide(color: Color(0xFF000001)); + const activeDisabledSide = BorderSide(color: Color(0xFF000002), width: 2); + const inactiveEnabledSide = BorderSide(color: Color(0xFF000003), width: 3); + const inactiveDisabledSide = BorderSide(color: Color(0xFF000004), width: 4); + + BorderSide getSide(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledSide; + } + return inactiveDisabledSide; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledSide; + } + return inactiveEnabledSide; + } + + final side = WidgetStateBorderSide.resolveWith(getSide); + + int? groupValue = 0; + const radioKey = Key('radio'); + Widget buildApp({required bool enabled}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Radio<int>(key: radioKey, value: 0, side: side, enabled: enabled), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Selected and enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: activeEnabledSide.color, strokeWidth: activeEnabledSide.width), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: inactiveEnabledSide.color, strokeWidth: inactiveEnabledSide.width), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: activeDisabledSide.color, strokeWidth: activeDisabledSide.width), + ); + + // Check when the radio is unselected and disabled. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: inactiveDisabledSide.color, strokeWidth: inactiveDisabledSide.width), + ); + }); + + testWidgets('Radio background color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredSide = BorderSide(color: Color(0xFF000001)); + const focusedSide = BorderSide(color: Color(0xFF000002), width: 2); + + BorderSide? getSide(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredSide; + } + if (states.contains(WidgetState.focused)) { + return focusedSide; + } + return null; + } + + final side = WidgetStateBorderSide.resolveWith(getSide); + + int? groupValue = 0; + const radioKey = Key('radio'); + final theme = ThemeData(); + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Radio<int>( + autofocus: true, + focusNode: focusNode, + key: radioKey, + value: 0, + side: side, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect() + ..circle(color: theme.colorScheme.primary.withValues(alpha: 0.1)) + ..circle(color: Colors.transparent) + ..circle(color: focusedSide.color, strokeWidth: focusedSide.width), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: theme.colorScheme.primary.withValues(alpha: 0.08)) + ..circle(color: Colors.transparent) + ..circle(color: hoveredSide.color, strokeWidth: hoveredSide.width), + ); + + focusNode.dispose(); + }); + + testWidgets('Radio button inner radius resolves in enabled/disabled states', ( + WidgetTester tester, + ) async { + const double enabledInnerRadius = 1; + const double disabledInnerRadius = 2; + + double getInnerRadius(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabledInnerRadius; + } + return enabledInnerRadius; + } + + final WidgetStateProperty<double> innerRadius = WidgetStateProperty.resolveWith(getInnerRadius); + + const value = 0; + const radioKey = Key('radio'); + Widget buildApp({required bool enabled}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup<int>( + groupValue: value, + onChanged: (int? newValue) {}, + child: Radio<int>( + key: radioKey, + value: value, + innerRadius: innerRadius, + enabled: enabled, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..circle(radius: enabledInnerRadius, color: theme.colorScheme.primary), + ); + + // Disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.onSurface.withAlpha(97)) + ..circle(radius: disabledInnerRadius, color: theme.colorScheme.onSurface.withAlpha(97)), + ); + }); + + testWidgets('Radio inner radius resolves in hovered/focused states', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double hoveredInnerRadius = 1; + const double focusedInnerRadius = 2; + double? getInnerRadius(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredInnerRadius; + } + if (states.contains(WidgetState.focused)) { + return focusedInnerRadius; + } + return null; + } + + final WidgetStateProperty<double?> innerRadius = WidgetStateProperty.resolveWith( + getInnerRadius, + ); + + const value = 0; + const radioKey = Key('radio'); + final theme = ThemeData(); + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup<int>( + groupValue: value, + onChanged: (int? newValue) {}, + child: Radio<int>( + autofocus: true, + focusNode: focusNode, + key: radioKey, + value: value, + innerRadius: innerRadius, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect() + ..circle(color: theme.colorScheme.primary.withValues(alpha: 0.1)) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..circle(radius: focusedInnerRadius, color: theme.colorScheme.primary), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: theme.colorScheme.primary.withValues(alpha: 0.08)) + ..circle(color: Colors.transparent) + ..circle(color: theme.colorScheme.primary) + ..circle(radius: hoveredInnerRadius, color: theme.colorScheme.primary), + ); + + focusNode.dispose(); + }); + + // Regression tests for https://github.com/flutter/flutter/issues/170422 + group('Radio accessibility announcements on various platforms', () { + testWidgets('Unselected radio should be vocalized via hint on iOS/macOS platform', ( + WidgetTester tester, + ) async { + const WidgetsLocalizations localizations = DefaultWidgetsLocalizations(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: RadioGroup<int>( + groupValue: 2, + onChanged: (int? value) {}, + child: const Radio<int>(value: 1), + ), + ), + ), + ); + final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last); + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + expect(semanticNode.hint, localizations.radioButtonUnselectedLabel); + } else { + expect(semanticNode.hint, anyOf(isNull, isEmpty)); + } + }); + + testWidgets('Selected radio should be vocalized via the selected flag on all platforms', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: RadioGroup<int>( + groupValue: 1, + onChanged: (int? value) {}, + child: const Radio<int>(value: 1), + ), + ), + ), + ); + + final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last); + // Radio semantics should not have hint. + expect(semanticNode.hint, anyOf(isNull, isEmpty)); + }); + }); + + testWidgets('Radio does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center(child: SizedBox.shrink(child: Radio<bool>(value: true))), + ), + ), + ); + expect(tester.getSize(find.byType(Radio<bool>)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/radio_theme_test.dart b/packages/material_ui/test/material/radio_theme_test.dart new file mode 100644 index 000000000000..dc45e85fb271 --- /dev/null +++ b/packages/material_ui/test/material/radio_theme_test.dart @@ -0,0 +1,577 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('RadioThemeData copyWith, ==, hashCode basics', () { + expect(const RadioThemeData(), const RadioThemeData().copyWith()); + expect(const RadioThemeData().hashCode, const RadioThemeData().copyWith().hashCode); + }); + + test('RadioThemeData lerp special cases', () { + expect(RadioThemeData.lerp(null, null, 0), const RadioThemeData()); + const data = RadioThemeData(); + expect(identical(RadioThemeData.lerp(data, data, 0.5), data), true); + }); + + test('RadioThemeData defaults', () { + const themeData = RadioThemeData(); + expect(themeData.mouseCursor, null); + expect(themeData.fillColor, null); + expect(themeData.overlayColor, null); + expect(themeData.splashRadius, null); + expect(themeData.materialTapTargetSize, null); + expect(themeData.visualDensity, null); + expect(themeData.backgroundColor, null); + expect(themeData.side, null); + expect(themeData.innerRadius, null); + + const theme = RadioTheme(data: RadioThemeData(), child: SizedBox()); + expect(theme.data.mouseCursor, null); + expect(theme.data.fillColor, null); + expect(theme.data.overlayColor, null); + expect(theme.data.splashRadius, null); + expect(theme.data.materialTapTargetSize, null); + expect(theme.data.visualDensity, null); + expect(theme.data.backgroundColor, null); + expect(theme.data.side, null); + expect(theme.data.innerRadius, null); + }); + + testWidgets('Default RadioThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const RadioThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, const <String>[]); + }); + + testWidgets('RadioThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const RadioThemeData( + mouseCursor: WidgetStatePropertyAll<MouseCursor>(SystemMouseCursors.click), + fillColor: WidgetStatePropertyAll<Color>(Color(0xfffffff0)), + overlayColor: WidgetStatePropertyAll<Color>(Color(0xfffffff1)), + splashRadius: 1.0, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.standard, + backgroundColor: WidgetStatePropertyAll<Color>(Color(0xfffffff2)), + side: BorderSide(color: Color(0xfffffff3), width: 2), + innerRadius: WidgetStatePropertyAll<double>(5.0), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'mouseCursor: WidgetStatePropertyAll(SystemMouseCursor(click))', + 'fillColor: WidgetStatePropertyAll(${const Color(0xfffffff0)})', + 'overlayColor: WidgetStatePropertyAll(${const Color(0xfffffff1)})', + 'splashRadius: 1.0', + 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap', + 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', + 'backgroundColor: WidgetStatePropertyAll(${const Color(0xfffffff2)})', + 'side: BorderSide(color: ${const Color(0xfffffff3)}, width: 2.0)', + 'innerRadius: WidgetStatePropertyAll(5.0)', + ]), + ); + }); + + testWidgets('Radio is themeable', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const MouseCursor mouseCursor = SystemMouseCursors.text; + const defaultFillColor = Color(0xfffffff0); + const selectedFillColor = Color(0xfffffff1); + const focusOverlayColor = Color(0xfffffff2); + const hoverOverlayColor = Color(0xfffffff3); + const defaultBackgroundColor = Color(0xfffffff4); + const selectedBackgroundColor = Color(0xfffffff5); + const splashRadius = 1.0; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const visualDensity = VisualDensity(horizontal: 1, vertical: 1); + const innerRadius = 5.0; + + Widget buildRadio({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + mouseCursor: const WidgetStatePropertyAll<MouseCursor>(mouseCursor), + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedFillColor; + } + return defaultFillColor; + }), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + }), + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize, + visualDensity: visualDensity, + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedBackgroundColor; + } + return defaultBackgroundColor; + }), + innerRadius: const WidgetStatePropertyAll<double>(innerRadius), + ), + ), + home: Scaffold( + body: Radio<int>( + onChanged: (int? int) {}, + value: selected ? 1 : 0, + groupValue: 1, + autofocus: autofocus, + ), + ), + ); + } + + // Radio. + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: defaultBackgroundColor) + ..circle(color: defaultFillColor), + ); + // Size from MaterialTapTargetSize.shrinkWrap with added VisualDensity. + expect(tester.getSize(_findRadio()), const Size(40.0, 40.0) + visualDensity.baseSizeAdjustment); + + // Selected radio. + await tester.pumpWidget(buildRadio(selected: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: selectedBackgroundColor) + ..circle(color: selectedFillColor) + ..circle(color: selectedFillColor, radius: innerRadius), + ); + + // Radio with hover. + await tester.pumpWidget(buildRadio()); + await _pointGestureToRadio(tester); + await tester.pumpAndSettle(); + expect(_getRadioMaterial(tester), paints..circle(color: hoverOverlayColor)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Radio with focus. + await tester.pumpWidget(buildRadio(autofocus: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints..circle(color: focusOverlayColor, radius: splashRadius), + ); + }); + + testWidgets('Radio side is themeable', (WidgetTester tester) async { + const defaultSide = BorderSide(color: Color(0xfffffff0), width: 2.0); + const selectedSide = BorderSide(color: Color(0xfffffff1), width: 3.0); + + Widget buildRadio({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedSide; + } + return defaultSide; + }), + ), + ), + home: Scaffold( + body: Radio<int>( + onChanged: (int? int) {}, + value: selected ? 1 : 0, + groupValue: 1, + autofocus: autofocus, + ), + ), + ); + } + + // Radio. + await tester.pumpWidget(buildRadio()); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: defaultSide.color, strokeWidth: defaultSide.width), + ); + + // Selected radio. + await tester.pumpWidget(buildRadio(selected: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: selectedSide.color, strokeWidth: selectedSide.width), + ); + }); + + testWidgets('Radio properties are taken over the theme values', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const MouseCursor themeMouseCursor = SystemMouseCursors.click; + const themeDefaultFillColor = Color(0xfffffff0); + const themeSelectedFillColor = Color(0xfffffff1); + const themeFocusOverlayColor = Color(0xfffffff2); + const themeHoverOverlayColor = Color(0xfffffff3); + const themeDefaultBackgroundColor = Color(0xfffffff4); + const themeSelectedBackgroundColor = Color(0xfffffff5); + const themeSplashRadius = 1.0; + const MaterialTapTargetSize themeMaterialTapTargetSize = MaterialTapTargetSize.padded; + const VisualDensity themeVisualDensity = VisualDensity.standard; + const themeInnerRadius = 5.0; + + const MouseCursor mouseCursor = SystemMouseCursors.text; + const defaultFillColor = Color(0xeffffff0); + const selectedFillColor = Color(0xeffffff1); + const focusColor = Color(0xeffffff2); + const hoverColor = Color(0xeffffff3); + const defaultBackgroundColor = Color(0xeffffff4); + const selectedBackgroundColor = Color(0xeffffff5); + const splashRadius = 2.0; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const visualDensity = VisualDensity(horizontal: 1, vertical: 1); + const innerRadius = 6.0; + + Widget buildRadio({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + mouseCursor: const WidgetStatePropertyAll<MouseCursor>(themeMouseCursor), + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedFillColor; + } + return themeDefaultFillColor; + }), + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return themeFocusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return themeHoverOverlayColor; + } + return null; + }), + splashRadius: themeSplashRadius, + materialTapTargetSize: themeMaterialTapTargetSize, + visualDensity: themeVisualDensity, + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedBackgroundColor; + } + return themeDefaultBackgroundColor; + }), + innerRadius: const WidgetStatePropertyAll<double>(themeInnerRadius), + ), + ), + home: Scaffold( + body: Radio<int>( + onChanged: (int? int) {}, + value: selected ? 0 : 1, + groupValue: 0, + autofocus: autofocus, + mouseCursor: mouseCursor, + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedFillColor; + } + return defaultFillColor; + }), + focusColor: focusColor, + hoverColor: hoverColor, + splashRadius: splashRadius, + materialTapTargetSize: materialTapTargetSize, + visualDensity: visualDensity, + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedBackgroundColor; + } + return defaultBackgroundColor; + }), + innerRadius: const WidgetStatePropertyAll<double>(innerRadius), + ), + ), + ); + } + + // Radio. + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: defaultBackgroundColor) + ..circle(color: defaultFillColor), + ); + // Size from MaterialTapTargetSize.shrinkWrap with added VisualDensity. + expect(tester.getSize(_findRadio()), const Size(40.0, 40.0) + visualDensity.baseSizeAdjustment); + + // Selected radio. + await tester.pumpWidget(buildRadio(selected: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: selectedBackgroundColor) + ..circle(color: selectedFillColor) + ..circle(color: selectedFillColor, radius: innerRadius), + ); + + // Radio with hover. + await tester.pumpWidget(buildRadio()); + await _pointGestureToRadio(tester); + await tester.pumpAndSettle(); + expect(_getRadioMaterial(tester), paints..circle(color: hoverColor)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Radio with focus. + await tester.pumpWidget(buildRadio(autofocus: true)); + await tester.pumpAndSettle(); + expect(_getRadioMaterial(tester), paints..circle(color: focusColor, radius: splashRadius)); + }); + + testWidgets('Radio side property is taken over the theme values', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const themeDefaultSide = BorderSide(color: Color(0xfffffff0), width: 2.0); + const themeSelectedSide = BorderSide(color: Color(0xfffffff1), width: 3.0); + + const defaultSide = BorderSide(color: Color(0xeffffff2), width: 4.0); + const selectedSide = BorderSide(color: Color(0xeffffff3), width: 5.0); + + Widget buildRadio({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedSide; + } + return themeDefaultSide; + }), + ), + ), + home: Scaffold( + body: Radio<int>( + onChanged: (int? int) {}, + value: selected ? 0 : 1, + groupValue: 0, + autofocus: autofocus, + side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedSide; + } + return defaultSide; + }), + ), + ), + ); + } + + // Radio. + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: defaultSide.color, strokeWidth: defaultSide.width), + ); + + // Selected radio. + await tester.pumpWidget(buildRadio(selected: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: selectedSide.color, strokeWidth: selectedSide.width), + ); + }); + + testWidgets('Radio activeColor property is taken over the theme', (WidgetTester tester) async { + const themeDefaultFillColor = Color(0xfffffff0); + const themeSelectedFillColor = Color(0xfffffff1); + + const selectedFillColor = Color(0xfffffff1); + + Widget buildRadio({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedFillColor; + } + return themeDefaultFillColor; + }), + ), + ), + home: Scaffold( + body: Radio<int>( + onChanged: (int? int) {}, + value: selected ? 0 : 1, + groupValue: 0, + autofocus: autofocus, + activeColor: selectedFillColor, + ), + ), + ); + } + + // Radio. + await tester.pumpWidget(buildRadio()); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: themeDefaultFillColor), + ); + + // Selected radio. + await tester.pumpWidget(buildRadio(selected: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: selectedFillColor), + ); + }); + + testWidgets('Radio theme overlay color resolves in active/pressed states', ( + WidgetTester tester, + ) async { + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Widget buildRadio({required bool active}) { + return MaterialApp( + theme: ThemeData( + radioTheme: RadioThemeData( + overlayColor: WidgetStateProperty.resolveWith(getOverlayColor), + splashRadius: splashRadius, + ), + ), + home: Scaffold( + body: Radio<int>(value: active ? 1 : 0, groupValue: 1, onChanged: (_) {}), + ), + ); + } + + await tester.pumpWidget(buildRadio(active: false)); + await tester.press(_findRadio()); + await tester.pumpAndSettle(); + + expect( + _getRadioMaterial(tester), + paints..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Radio should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildRadio(active: true)); + await tester.press(_findRadio()); + await tester.pumpAndSettle(); + + expect( + _getRadioMaterial(tester), + paints..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Radio should have overlay color: $activePressedOverlayColor', + ); + }); + + testWidgets('Local RadioTheme can override global RadioTheme', (WidgetTester tester) async { + const globalThemeFillColor = Color(0xfffffff1); + const localThemeFillColor = Color(0xffff0000); + + Widget buildRadio({required bool active}) { + return MaterialApp( + theme: ThemeData( + radioTheme: const RadioThemeData( + fillColor: MaterialStatePropertyAll<Color>(globalThemeFillColor), + ), + ), + home: Scaffold( + body: RadioTheme( + data: const RadioThemeData( + fillColor: MaterialStatePropertyAll<Color>(localThemeFillColor), + ), + child: Radio<int>(value: active ? 1 : 0, groupValue: 1, onChanged: (_) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildRadio(active: true)); + await tester.pumpAndSettle(); + expect( + _getRadioMaterial(tester), + paints + ..circle(color: Colors.transparent) + ..circle(color: localThemeFillColor), + ); + }); +} + +Finder _findRadio() { + return find.byWidgetPredicate((Widget widget) => widget is Radio<int>); +} + +Future<void> _pointGestureToRadio(WidgetTester tester) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(_findRadio())); +} + +MaterialInkController? _getRadioMaterial(WidgetTester tester) { + return Material.of(tester.element(_findRadio())); +} diff --git a/packages/material_ui/test/material/range_slider_test.dart b/packages/material_ui/test/material/range_slider_test.dart new file mode 100644 index 000000000000..ff98d565fb50 --- /dev/null +++ b/packages/material_ui/test/material/range_slider_test.dart @@ -0,0 +1,4113 @@ +// 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/services.dart'; +import 'package:flutter/src/physics/utils.dart' show nearEqual; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/105833 + testWidgets('Drag gesture uses provided gesture settings', (WidgetTester tester) async { + var values = const RangeValues(0.1, 0.5); + var dragStarted = false; + final Key sliderKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: (DragStartDetails details) { + dragStarted = true; + }, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 20)), + child: RangeSlider( + key: sliderKey, + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + expect(values, const RangeValues(0.1, 0.5)); + expect(dragStarted, true); + + dragStarted = false; + + await drag.up(); + await tester.pumpAndSettle(); + + drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + var sliderEnd = false; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: (DragStartDetails details) { + dragStarted = true; + }, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 10)), + child: RangeSlider( + key: sliderKey, + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeEnd: (RangeValues newValues) { + sliderEnd = true; + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + // More than touch slop. + await drag.moveBy(const Offset(12.0, 0)); + + await drag.up(); + await tester.pumpAndSettle(); + + expect(sliderEnd, true); + expect(dragStarted, false); + }); + + testWidgets('Range Slider can move when tapped (continuous LTR)', (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.8); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // The closest thumb is selected when tapping between the thumbs outside the touch + // boundaries + expect(values, equals(const RangeValues(0.3, 0.8))); + // taps at 0.5 + await tester.tap(find.byType(RangeSlider)); + await tester.pump(); + expect(values, equals(const RangeValues(0.5, 0.8))); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // The start thumb is selected when tapping the left inactive track. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.1; + await tester.tapAt(leftTarget); + expect(values.start, moreOrLessEquals(0.1, epsilon: 0.01)); + expect(values.end, equals(0.8)); + + // The end thumb is selected when tapping the right inactive track. + await tester.pump(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.9; + await tester.tapAt(rightTarget); + expect(values.start, moreOrLessEquals(0.1, epsilon: 0.01)); + expect(values.end, moreOrLessEquals(0.9, epsilon: 0.01)); + }); + + testWidgets('Range Slider can move when tapped (continuous RTL)', (WidgetTester tester) async { + var values = const RangeValues(0.3, 1.0); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // The closest thumb is selected when tapping between the thumbs outside the touch + // boundaries + expect(values, equals(const RangeValues(0.3, 1.0))); + // taps at 0.5 + await tester.tap(find.byType(RangeSlider)); + await tester.pump(); + expect(values, equals(const RangeValues(0.5, 1.0))); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // The end thumb is selected when tapping the left inactive track. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.1; + await tester.tapAt(leftTarget); + expect(values.start, 0.5); + expect(values.end, moreOrLessEquals(0.9, epsilon: 0.01)); + + // The start thumb is selected when tapping the right inactive track. + await tester.pump(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.9; + await tester.tapAt(rightTarget); + expect(values.start, moreOrLessEquals(0.1, epsilon: 0.01)); + expect(values.end, moreOrLessEquals(0.9, epsilon: 0.01)); + }); + + testWidgets('Range Slider can move when tapped (discrete LTR)', (WidgetTester tester) async { + var values = const RangeValues(30, 80); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100.0, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // The closest thumb is selected when tapping between the thumbs outside the touch + // boundaries + expect(values, equals(const RangeValues(30, 80))); + // taps at 0.5 + await tester.tap(find.byType(RangeSlider)); + await tester.pumpAndSettle(); + expect(values, equals(const RangeValues(50, 80))); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // The start thumb is selected when tapping the left inactive track. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.1; + await tester.tapAt(leftTarget); + await tester.pumpAndSettle(); + expect(values.start.round(), equals(10)); + expect(values.end.round(), equals(80)); + + // The end thumb is selected when tapping the right inactive track. + await tester.pump(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.9; + await tester.tapAt(rightTarget); + await tester.pumpAndSettle(); + expect(values.start.round(), equals(10)); + expect(values.end.round(), equals(90)); + }); + + testWidgets('Range Slider can move when tapped (discrete RTL)', (WidgetTester tester) async { + var values = const RangeValues(30, 80); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // The closest thumb is selected when tapping between the thumbs outside the touch + // boundaries + expect(values, equals(const RangeValues(30, 80))); + // taps at 0.5 + await tester.tap(find.byType(RangeSlider)); + await tester.pumpAndSettle(); + expect(values, equals(const RangeValues(50, 80))); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // The start thumb is selected when tapping the left inactive track. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.1; + await tester.tapAt(leftTarget); + await tester.pumpAndSettle(); + expect(values.start.round(), equals(50)); + expect(values.end.round(), equals(90)); + + // The end thumb is selected when tapping the right inactive track. + await tester.pump(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.9; + await tester.tapAt(rightTarget); + await tester.pumpAndSettle(); + expect(values.start.round(), equals(10)); + expect(values.end.round(), equals(90)); + }); + + testWidgets('Range Slider thumbs can be dragged to the min and max (continuous LTR)', ( + WidgetTester tester, + ) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // Drag the start thumb to the min. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, topLeft + (bottomRight - topLeft) * -0.4); + expect(values.start, equals(0)); + + // Drag the end thumb to the max. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, topLeft + (bottomRight - topLeft) * 0.4); + expect(values.end, equals(1)); + }); + + testWidgets('Range Slider thumbs can be dragged to the min and max (continuous RTL)', ( + WidgetTester tester, + ) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // Drag the end thumb to the max. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, topLeft + (bottomRight - topLeft) * -0.4); + expect(values.end, equals(1)); + + // Drag the start thumb to the min. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, topLeft + (bottomRight - topLeft) * 0.4); + expect(values.start, equals(0)); + }); + + testWidgets('Range Slider thumbs can be dragged to the min and max (discrete LTR)', ( + WidgetTester tester, + ) async { + var values = const RangeValues(30, 70); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // Drag the start thumb to the min. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, topLeft + (bottomRight - topLeft) * -0.4); + expect(values.start, equals(0)); + + // Drag the end thumb to the max. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, topLeft + (bottomRight - topLeft) * 0.4); + expect(values.end, equals(100)); + }); + + testWidgets('Range Slider thumbs can be dragged to the min and max (discrete RTL)', ( + WidgetTester tester, + ) async { + var values = const RangeValues(30, 70); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // Drag the end thumb to the max. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, topLeft + (bottomRight - topLeft) * -0.4); + expect(values.end, equals(100)); + + // Drag the start thumb to the min. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, topLeft + (bottomRight - topLeft) * 0.4); + expect(values.start, equals(0)); + }); + + testWidgets('minThumbSeparation has same width as surrounding box, values still bounded (ltr)', ( + WidgetTester tester, + ) async { + const boundingBoxSize = 200.0; + var values = const RangeValues(0.0, 1.0); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: SizedBox( + width: boundingBoxSize, + child: SliderTheme( + data: SliderTheme.of(context).copyWith(minThumbSeparation: boundingBoxSize), + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await tester.drag(find.byType(RangeSlider), Offset.zero); + await tester.pumpAndSettle(); + + expect(values.start, inInclusiveRange(0.0, 1.0)); + expect(values.end, inInclusiveRange(0.0, 1.0)); + }); + + testWidgets('minThumbSeparation has same width as surrounding box, values still bounded (rtl)', ( + WidgetTester tester, + ) async { + const boundingBoxSize = 200.0; + var values = const RangeValues(0.0, 1.0); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: SizedBox( + width: boundingBoxSize, + child: SliderTheme( + data: SliderTheme.of(context).copyWith(minThumbSeparation: boundingBoxSize), + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await tester.drag(find.byType(RangeSlider), Offset.zero); + await tester.pumpAndSettle(); + + expect(values.start, inInclusiveRange(0.0, 1.0)); + expect(values.end, inInclusiveRange(0.0, 1.0)); + }); + + testWidgets( + 'Range Slider thumbs can be dragged together and the start thumb can be dragged apart (continuous LTR)', + (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the start thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the end thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the start thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, -(bottomRight - topLeft) * 0.3); + expect(values.start, moreOrLessEquals(0.2, epsilon: 0.05)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the start thumb can be dragged apart (continuous RTL)', + (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the end thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the start thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the start thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, (bottomRight - topLeft) * 0.3); + expect(values.start, moreOrLessEquals(0.2, epsilon: 0.05)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the start thumb can be dragged apart (discrete LTR)', + (WidgetTester tester) async { + var values = const RangeValues(30, 70); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the start thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.start, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the end thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.end, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the start thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, -(bottomRight - topLeft) * 0.3); + expect(values.start, moreOrLessEquals(20, epsilon: 0.01)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the start thumb can be dragged apart (discrete RTL)', + (WidgetTester tester) async { + var values = const RangeValues(30, 70); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the end thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.end, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the start thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.start, moreOrLessEquals(50, epsilon: 0.01)); + expect(values.end, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the start thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, (bottomRight - topLeft) * 0.3); + expect(values.start, moreOrLessEquals(20, epsilon: 0.01)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the end thumb can be dragged apart (continuous LTR)', + (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the start thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the end thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the end thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, (bottomRight - topLeft) * 0.3); + expect(values.end, moreOrLessEquals(0.8, epsilon: 0.05)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the end thumb can be dragged apart (continuous RTL)', + (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the end thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the start thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.05)); + + // Drag the end thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, -(bottomRight - topLeft) * 0.3); + expect(values.end, moreOrLessEquals(0.8, epsilon: 0.05)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the end thumb can be dragged apart (discrete LTR)', + (WidgetTester tester) async { + var values = const RangeValues(30, 70); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the start thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.start, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the end thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.end, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the end thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, (bottomRight - topLeft) * 0.3); + expect(values.end, moreOrLessEquals(80, epsilon: 0.01)); + }, + ); + + testWidgets( + 'Range Slider thumbs can be dragged together and the end thumb can be dragged apart (discrete RTL)', + (WidgetTester tester) async { + var values = const RangeValues(30, 70); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the end thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + expect(values.end, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the start thumb towards the center. + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.start, moreOrLessEquals(50, epsilon: 0.01)); + + // Drag the end thumb apart. + await tester.pumpAndSettle(); + await tester.dragFrom(middle, -(bottomRight - topLeft) * 0.3); + expect(values.end, moreOrLessEquals(80, epsilon: 0.01)); + }, + ); + + testWidgets( + 'Range Slider onChangeEnd and onChangeStart are called on an interaction initiated by tap', + (WidgetTester tester) async { + var values = const RangeValues(30, 70); + RangeValues? startValues; + RangeValues? endValues; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) { + startValues = newValues; + }, + onChangeEnd: (RangeValues newValues) { + endValues = newValues; + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // Drag the start thumb towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + expect(startValues, null); + expect(endValues, null); + await tester.dragFrom(leftTarget, (bottomRight - topLeft) * 0.2); + expect(startValues!.start, moreOrLessEquals(30, epsilon: 1)); + expect(startValues!.end, moreOrLessEquals(70, epsilon: 1)); + expect(values.start, moreOrLessEquals(50, epsilon: 1)); + expect(values.end, moreOrLessEquals(70, epsilon: 1)); + expect(endValues!.start, moreOrLessEquals(50, epsilon: 1)); + expect(endValues!.end, moreOrLessEquals(70, epsilon: 1)); + }, + ); + + testWidgets( + 'Range Slider onChangeEnd and onChangeStart are called on an interaction initiated by drag', + (WidgetTester tester) async { + var values = const RangeValues(30, 70); + late RangeValues startValues; + late RangeValues endValues; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) { + startValues = newValues; + }, + onChangeEnd: (RangeValues newValues) { + endValues = newValues; + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + + // Drag the thumbs together. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, (bottomRight - topLeft) * 0.2); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, (bottomRight - topLeft) * -0.2); + await tester.pumpAndSettle(); + expect(values.start, moreOrLessEquals(50, epsilon: 1)); + expect(values.end, moreOrLessEquals(51, epsilon: 1)); + + // Drag the end thumb to the right. + final Offset middleTarget = topLeft + (bottomRight - topLeft) * 0.5; + await tester.dragFrom(middleTarget, (bottomRight - topLeft) * 0.4); + await tester.pumpAndSettle(); + expect(startValues.start, moreOrLessEquals(50, epsilon: 1)); + expect(startValues.end, moreOrLessEquals(51, epsilon: 1)); + expect(endValues.start, moreOrLessEquals(50, epsilon: 1)); + expect(endValues.end, moreOrLessEquals(90, epsilon: 1)); + }, + ); + + ThemeData buildTheme() { + return ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + disabledThumbColor: Color(0xff000001), + disabledActiveTickMarkColor: Color(0xff000002), + disabledActiveTrackColor: Color(0xff000003), + disabledInactiveTickMarkColor: Color(0xff000004), + disabledInactiveTrackColor: Color(0xff000005), + activeTrackColor: Color(0xff000006), + activeTickMarkColor: Color(0xff000007), + inactiveTrackColor: Color(0xff000008), + inactiveTickMarkColor: Color(0xff000009), + overlayColor: Color(0xff000010), + thumbColor: Color(0xff000011), + valueIndicatorColor: Color(0xff000012), + ), + ); + } + + Widget buildThemedApp({ + required ThemeData theme, + Color? activeColor, + Color? inactiveColor, + int? divisions, + bool enabled = true, + }) { + var values = const RangeValues(0.5, 0.75); + final ValueChanged<RangeValues>? onChanged = !enabled + ? null + : (RangeValues newValues) { + values = newValues; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes for a default enabled slider', + (WidgetTester tester) async { + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(buildThemedApp(theme: theme)); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + // Check default theme for enabled widget. + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.activeTrackColor), + ); + expect( + sliderBox, + paints + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.thumbColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes when setting the active color', + (WidgetTester tester) async { + const activeColor = Color(0xcafefeed); + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(buildThemedApp(theme: theme, activeColor: activeColor)); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: activeColor), + ); + expect( + sliderBox, + paints + ..circle(color: activeColor) + ..circle(color: activeColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes when setting the inactive color', + (WidgetTester tester) async { + const inactiveColor = Color(0xdeadbeef); + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(buildThemedApp(theme: theme, inactiveColor: inactiveColor)); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: inactiveColor) + ..rrect(color: inactiveColor) + ..rrect(color: sliderTheme.activeTrackColor), + ); + expect( + sliderBox, + paints + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.thumbColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes with active and inactive colors', + (WidgetTester tester) async { + const activeColor = Color(0xcafefeed); + const inactiveColor = Color(0xdeadbeef); + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + buildThemedApp(theme: theme, activeColor: activeColor, inactiveColor: inactiveColor), + ); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: inactiveColor) + ..rrect(color: inactiveColor) + ..rrect(color: activeColor), + ); + expect( + sliderBox, + paints + ..circle(color: activeColor) + ..circle(color: activeColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes for a discrete slider', + (WidgetTester tester) async { + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(buildThemedApp(theme: theme, divisions: 3)); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.activeTrackColor), + ); + expect( + sliderBox, + paints + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.thumbColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes for a discrete slider with active and inactive colors', + (WidgetTester tester) async { + const activeColor = Color(0xcafefeed); + const inactiveColor = Color(0xdeadbeef); + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + buildThemedApp( + theme: theme, + activeColor: activeColor, + inactiveColor: inactiveColor, + divisions: 3, + ), + ); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: inactiveColor) + ..rrect(color: inactiveColor) + ..rrect(color: activeColor), + ); + expect( + sliderBox, + paints + ..circle(color: activeColor) + ..circle(color: activeColor) + ..circle(color: inactiveColor) + ..circle(color: activeColor) + ..circle(color: activeColor) + ..circle(color: activeColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes for a default disabled slider', + (WidgetTester tester) async { + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(buildThemedApp(theme: theme, enabled: false)); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rrect(color: sliderTheme.disabledActiveTrackColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.inactiveTrackColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes for a disabled slider with active and inactive colors', + (WidgetTester tester) async { + const activeColor = Color(0xcafefeed); + const inactiveColor = Color(0xdeadbeef); + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + buildThemedApp( + theme: theme, + activeColor: activeColor, + inactiveColor: inactiveColor, + enabled: false, + ), + ); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rrect(color: sliderTheme.disabledActiveTrackColor), + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.inactiveTrackColor))); + }, + ); + + testWidgets( + 'Range Slider uses the right theme colors for the right shapes when the value indicators are showing', + (WidgetTester tester) async { + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + var values = const RangeValues(0.5, 0.75); + + Widget buildApp({ + Color? activeColor, + Color? inactiveColor, + int? divisions, + bool enabled = true, + }) { + final ValueChanged<RangeValues>? onChanged = !enabled + ? null + : (RangeValues newValues) { + values = newValues; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels( + values.start.toStringAsFixed(2), + values.end.toStringAsFixed(2), + ), + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(divisions: 3)); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset topRight = tester.getTopRight(find.byType(RangeSlider)).translate(-24, 0); + final TestGesture gesture = await tester.startGesture(topRight); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(values.end, equals(1)); + expect( + valueIndicatorBox, + paints + ..path(color: Colors.black) // shadow + ..path(color: Colors.black) // shadow + ..path(color: sliderTheme.valueIndicatorColor) + ..paragraph(), + ); + await gesture.up(); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'Range Slider removes value indicator from overlay if Slider gets disposed without value indicator animation completing.', + (WidgetTester tester) async { + var values = const RangeValues(0.5, 0.75); + const fillColor = Color(0xf55f5f5f); + + Widget buildApp({ + Color? activeColor, + Color? inactiveColor, + int? divisions, + bool enabled = true, + }) { + void onChanged(RangeValues newValues) { + values = newValues; + } + + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + // The builder is used to pass the context from the MaterialApp widget + // to the [Navigator]. This context is required in order for the + // Navigator to work. + body: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + RangeSlider( + values: values, + labels: RangeLabels( + values.start.toStringAsFixed(2), + values.end.toStringAsFixed(2), + ), + divisions: divisions, + onChanged: onChanged, + ), + ElevatedButton( + child: const Text('Next'), + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Inner page'), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp(divisions: 5)); + + final RenderObject valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + final Offset topRight = tester.getTopRight(find.byType(RangeSlider)).translate(-24, 0); + final TestGesture gesture = await tester.startGesture(topRight); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + // Represents the raised button wth next text. + ..path(color: Colors.black) + ..paragraph() + // Represents the range slider. + ..path(color: fillColor) + ..paragraph() + ..path(color: fillColor) + ..paragraph(), + ); + + // Represents the Raised Button and Range Slider. + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 6)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawParagraph, 3)); + + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + + expect(find.byType(RangeSlider), findsNothing); + expect( + valueIndicatorBox, + isNot( + paints + ..path(color: fillColor) + ..paragraph() + ..path(color: fillColor) + ..paragraph(), + ), + ); + + // Represents the raised button with inner page text. + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawParagraph, 1)); + + // Don't stop holding the value indicator. + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Range Slider top thumb gets stroked when overlapping', (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + final theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + thumbColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.03)); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.03)); + await tester.pumpAndSettle(); + + expect( + sliderBox, + paints + ..circle(color: sliderTheme.overlayColor) + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.overlappingShapeStrokeColor) + ..circle(color: sliderTheme.thumbColor), + ); + }); + + testWidgets('Range Slider top value indicator gets stroked when overlapping', ( + WidgetTester tester, + ) async { + var values = const RangeValues(0.3, 0.7); + + final theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + valueIndicatorColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + showValueIndicator: ShowValueIndicator.always, + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels( + values.start.toStringAsFixed(2), + values.end.toStringAsFixed(2), + ), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + await tester.pumpAndSettle(); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.03)); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.03)); + final TestGesture gesture = await tester.startGesture(middle); + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: Colors.black) // shadow + ..path(color: Colors.black) // shadow + ..path(color: sliderTheme.valueIndicatorColor) + ..paragraph(), + ); + + await gesture.up(); + }); + + testWidgets( + 'Range Slider top value indicator gets stroked when overlapping with large text scale', + (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + final theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + valueIndicatorColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + showValueIndicator: ShowValueIndicator.always, + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels( + values.start.toStringAsFixed(2), + values.end.toStringAsFixed(2), + ), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + await tester.pumpAndSettle(); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.03)); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.03)); + final TestGesture gesture = await tester.startGesture(middle); + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: Colors.black) // shadow + ..path(color: Colors.black) // shadow + ..path(color: sliderTheme.valueIndicatorColor) + ..paragraph(), + ); + + await gesture.up(); + }, + ); + + testWidgets('Range Slider thumb gets stroked when overlapping', (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + final theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + valueIndicatorColor: Color(0xff000001), + showValueIndicator: ShowValueIndicator.onlyForContinuous, + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels( + values.start.toStringAsFixed(2), + values.end.toStringAsFixed(2), + ), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + await tester.pumpAndSettle(); + expect(values.start, moreOrLessEquals(0.5, epsilon: 0.03)); + expect(values.end, moreOrLessEquals(0.5, epsilon: 0.03)); + final TestGesture gesture = await tester.startGesture(middle); + await tester.pumpAndSettle(); + + /// The first circle is the thumb, the second one is the overlapping shape + /// circle, and the last one is the second thumb. + expect( + find.byType(RangeSlider), + paints + ..circle() + ..circle(color: sliderTheme.overlappingShapeStrokeColor) + ..circle(), + ); + + await gesture.up(); + + expect( + find.byType(RangeSlider), + paints + ..circle() + ..circle(color: sliderTheme.overlappingShapeStrokeColor) + ..circle(), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/101868 + testWidgets('RangeSlider.label info should not write to semantic node', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: RangeSlider( + values: const RangeValues(10.0, 12.0), + max: 100.0, + onChanged: (RangeValues v) {}, + labels: const RangeLabels('Begin', 'End'), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.byType(RangeSlider)), + matchesSemantics( + scopesRoute: true, + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '10%', + decreasedValue: '5%', + label: '', + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '12%', + increasedValue: '17%', + decreasedValue: '12%', + label: '', + ), + ], + ), + ], + ), + ], + ), + ); + }); + + testWidgets('Range Slider Semantics - ltr', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: RangeSlider( + values: const RangeValues(10.0, 30.0), + max: 100.0, + onChanged: (RangeValues v) {}, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final SemanticsNode semanticsNode = tester.getSemantics(find.byType(RangeSlider)); + expect( + semanticsNode, + matchesSemantics( + scopesRoute: true, + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + ), + ], + ), + ], + ), + ], + ), + ); + + // TODO(tahatesser): This is a workaround for matching + // the semantics node rects by avoiding floating point errors. + // https://github.com/flutter/flutter/issues/115079 + // Get semantics node rects. + final rects = <Rect>[]; + semanticsNode.visitChildren((SemanticsNode node) { + node.visitChildren((SemanticsNode node) { + node.visitChildren((SemanticsNode node) { + // Round rect values to avoid floating point errors. + rects.add( + Rect.fromLTRB( + node.rect.left.roundToDouble(), + node.rect.top.roundToDouble(), + node.rect.right.roundToDouble(), + node.rect.bottom.roundToDouble(), + ), + ); + return true; + }); + return true; + }); + return true; + }); + // Test that the semantics node rect sizes are correct. + expect(rects, <Rect>[ + const Rect.fromLTRB(75.0, 276.0, 123.0, 324.0), + const Rect.fromLTRB(226.0, 276.0, 274.0, 324.0), + ]); + }); + + testWidgets('Range Slider Semantics - rtl', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: RangeSlider( + values: const RangeValues(10.0, 30.0), + max: 100.0, + onChanged: (RangeValues v) {}, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final SemanticsNode semanticsNode = tester.getSemantics(find.byType(RangeSlider)); + expect( + semanticsNode, + matchesSemantics( + scopesRoute: true, + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + ), + ], + ), + ], + ), + ], + ), + ); + + // TODO(tahatesser): This is a workaround for matching + // the semantics node rects by avoiding floating point errors. + // https://github.com/flutter/flutter/issues/115079 + // Get semantics node rects. + final rects = <Rect>[]; + semanticsNode.visitChildren((SemanticsNode node) { + node.visitChildren((SemanticsNode node) { + node.visitChildren((SemanticsNode node) { + // Round rect values to avoid floating point errors. + rects.add( + Rect.fromLTRB( + node.rect.left.roundToDouble(), + node.rect.top.roundToDouble(), + node.rect.right.roundToDouble(), + node.rect.bottom.roundToDouble(), + ), + ); + return true; + }); + return true; + }); + return true; + }); + // Test that the semantics node rect sizes are correct. + expect(rects, <Rect>[ + const Rect.fromLTRB(526.0, 276.0, 574.0, 324.0), + const Rect.fromLTRB(677.0, 276.0, 725.0, 324.0), + ]); + }); + + testWidgets('Range Slider implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + RangeSlider( + activeColor: Colors.blue, + divisions: 4, + inactiveColor: Colors.grey, + labels: const RangeLabels('lowerValue', 'upperValue'), + max: 100.0, + onChanged: null, + values: const RangeValues(25.0, 75.0), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'valueStart: 25.0', + 'valueEnd: 75.0', + 'disabled', + 'min: 0.0', + 'max: 100.0', + 'divisions: 4', + 'labelStart: "lowerValue"', + 'labelEnd: "upperValue"', + 'activeColor: MaterialColor(primary value: ${const Color(0xff2196f3)})', + 'inactiveColor: MaterialColor(primary value: ${const Color(0xff9e9e9e)})', + ]); + }); + + testWidgets( + 'Range Slider can be painted in a narrower constraint when track shape is RoundedRectRange', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox( + height: 10.0, + width: 0.0, + child: RangeSlider(values: const RangeValues(0.25, 0.5), onChanged: null), + ), + ), + ), + ), + ), + ); + + final RenderObject renderObject = tester.allRenderObjects + .where( + (RenderObject renderObject) => + renderObject.runtimeType.toString() == '_RenderRangeSlider', + ) + .first; + + expect( + renderObject, + paints + // left inactive track RRect + ..rrect( + rrect: RRect.fromLTRBAndCorners( + -24.0, + 3.0, + -12.0, + 7.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // right inactive track RRect + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 0.0, + 3.0, + 24.0, + 7.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // active track RRect + ..rrect(rrect: RRect.fromLTRBR(-14.0, 2.0, 2.0, 8.0, const Radius.circular(2.0))) + // thumbs + ..circle(x: -12.0, y: 5.0, radius: 10.0) + ..circle(x: 0.0, y: 5.0, radius: 10.0), + ); + }, + ); + + testWidgets( + 'Range Slider can be painted in a narrower constraint when track shape is Rectangular', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + sliderTheme: const SliderThemeData(rangeTrackShape: RectangularRangeSliderTrackShape()), + ), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox( + height: 10.0, + width: 0.0, + child: RangeSlider(values: const RangeValues(0.25, 0.5), onChanged: null), + ), + ), + ), + ), + ), + ); + + final RenderObject renderObject = tester.allRenderObjects + .where( + (RenderObject renderObject) => + renderObject.runtimeType.toString() == '_RenderRangeSlider', + ) + .first; + + //There should no gap between the inactive track and active track. + expect( + renderObject, + paints + // left inactive track RRect + ..rect(rect: const Rect.fromLTRB(-24.0, 3.0, -12.0, 7.0)) + // active track RRect + ..rect(rect: const Rect.fromLTRB(-12.0, 3.0, 0.0, 7.0)) + // right inactive track RRect + ..rect(rect: const Rect.fromLTRB(0.0, 3.0, 24.0, 7.0)) + // thumbs + ..circle(x: -12.0, y: 5.0, radius: 10.0) + ..circle(x: 0.0, y: 5.0, radius: 10.0), + ); + }, + ); + + testWidgets('Update the divisions and values at the same time for RangeSlider', ( + WidgetTester tester, + ) async { + // Regress test for https://github.com/flutter/flutter/issues/65943 + Widget buildFrame(double maxValue) { + return MaterialApp( + home: Material( + child: Center( + child: RangeSlider( + values: const RangeValues(5, 8), + max: maxValue, + divisions: maxValue.toInt(), + onChanged: (RangeValues newValue) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(10)); + + final RenderObject renderObject = tester.allRenderObjects + .where( + (RenderObject renderObject) => + renderObject.runtimeType.toString() == '_RenderRangeSlider', + ) + .first; + + // Update the divisions from 10 to 15, the thumbs should be paint at the correct position. + await tester.pumpWidget(buildFrame(15)); + await tester.pumpAndSettle(); // Finish the animation. + + late RRect activeTrackRRect; + expect( + renderObject, + paints + ..rrect() + ..rrect() + ..something((Symbol method, List<dynamic> arguments) { + if (method != #drawRRect) { + return false; + } + activeTrackRRect = arguments[0] as RRect; + return true; + }), + ); + + const padding = 4.0; + // The 1st thumb should at one-third(5 / 15) of the Slider. + // The 2nd thumb should at (8 / 15) of the Slider. + // The left of the active track shape is the position of the 1st thumb. + // The right of the active track shape is the position of the 2nd thumb. + // 24.0 is the default margin, (800.0 - 24.0 - 24.0 - padding) is the slider's width. + // Where the padding value equals to the track height. + expect( + nearEqual(activeTrackRRect.left, (800.0 - 24.0 - 24.0 - padding) * (5 / 15) + 24.0, 0.01), + true, + ); + expect( + nearEqual( + activeTrackRRect.right, + (800.0 - 24.0 - 24.0 - padding) * (8 / 15) + 24.0 + padding, + 0.01, + ), + true, + ); + }); + + testWidgets('RangeSlider changes mouse cursor when hovered', (WidgetTester tester) async { + const values = RangeValues(50, 70); + + // Test default cursor. + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RangeSlider(values: values, max: 100.0, onChanged: (RangeValues values) {}), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(RangeSlider))); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test custom cursor. + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RangeSlider( + values: values, + max: 100.0, + mouseCursor: const MaterialStatePropertyAll<MouseCursor?>( + SystemMouseCursors.text, + ), + onChanged: (RangeValues values) {}, + ), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + + testWidgets('RangeSlider WidgetStateMouseCursor resolves correctly', (WidgetTester tester) async { + var values = const RangeValues(20, 75); + const MouseCursor systemDefaultCursor = SystemMouseCursors.basic; + const MouseCursor disabledCursor = SystemMouseCursors.forbidden; + const MouseCursor draggedCursor = SystemMouseCursors.move; + const MouseCursor hoveredCursor = SystemMouseCursors.grab; + + Widget buildFrame({required bool enabled}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RangeSlider( + mouseCursor: WidgetStateProperty.resolveWith<MouseCursor?>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.disabled)) { + return disabledCursor; + } + if (states.contains(WidgetState.dragged)) { + return draggedCursor; + } + if (states.contains(WidgetState.hovered)) { + return hoveredCursor; + } + return SystemMouseCursors.click; + }), + values: values, + max: 100.0, + onChanged: enabled + ? (RangeValues newValues) { + setState(() { + values = newValues; + }); + } + : null, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + + // System default. + await gesture.addPointer(location: Offset.zero); + await tester.pumpWidget(buildFrame(enabled: false)); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), systemDefaultCursor); + + // Disabled. + await gesture.moveTo(tester.getCenter(find.byType(RangeSlider))); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), disabledCursor); + + // Hovered. + await tester.pumpWidget(buildFrame(enabled: true)); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), hoveredCursor); + + // Dragged. + await gesture.down(tester.getCenter(find.byType(RangeSlider))); + await gesture.moveBy(const Offset(20.0, 0.0)); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), draggedCursor); + + // Hovered. + await gesture.up(); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), hoveredCursor); + + // System default. + await gesture.moveTo(Offset.zero); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), systemDefaultCursor); + }); + + testWidgets('RangeSlider can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var values = const RangeValues(50, 70); + final theme = ThemeData(); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100.0, + onChanged: enabled + ? (RangeValues newValues) { + setState(() { + values = newValues; + }); + } + : null, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // RangeSlider does not have overlay when enabled and not hovered. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(RangeSlider))); + + // RangeSlider has overlay when enabled and hovered. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.12)), + ); + + // RangeSlider does not have an overlay when disabled and hovered. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + }); + + testWidgets('RangeSlider can be focused using keyboard focus', (WidgetTester tester) async { + var values = const RangeValues(20, 80); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RangeSlider( + values: values, + max: 100, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) {}, + onChangeEnd: (RangeValues newValues) {}, + ), + ); + }, + ), + ), + ), + ), + ); + // Focus on the start thumb + final Finder rangeSliderFinder = find.byType(RangeSlider); + expect(rangeSliderFinder, findsOneWidget); + final startFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + final endFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + + startFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, startFocusNode); + + // Tab to focus on the end thumb + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, endFocusNode); + }); + + testWidgets('Keyboard focus also changes semantics focus', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: RangeSlider( + values: const RangeValues(10.0, 30.0), + max: 100.0, + onChanged: (RangeValues v) {}, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final startFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + final endFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + // Focus on the start thumb + startFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, startFocusNode); + + final SemanticsNode semanticsNode = tester.getSemantics(find.byType(RangeSlider)); + expect( + semanticsNode, + matchesSemantics( + scopesRoute: true, + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + isFocused: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + ), + ], + ), + ], + ), + ], + ), + ); + + // Tab to focus on the end thumb + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, endFocusNode); + + expect( + semanticsNode, + matchesSemantics( + scopesRoute: true, + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + children: <Matcher>[ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + isFocused: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + ), + ], + ), + ], + ), + ], + ), + ); + }); + + testWidgets('RangeSlider is draggable and has correct dragged color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var values = const RangeValues(50, 70); + final theme = ThemeData(); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100.0, + onChanged: enabled + ? (RangeValues newValues) { + setState(() { + values = newValues; + }); + } + : null, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // RangeSlider does not have overlay when enabled and not dragged. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + + // Start dragging. + final TestGesture drag = await tester.startGesture(tester.getCenter(find.byType(RangeSlider))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + // RangeSlider has overlay when enabled and dragged. + expect( + Material.of(tester.element(find.byType(RangeSlider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.12)), + ); + }); + + testWidgets('RangeSlider overlayColor supports hovered and dragged states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var values = const RangeValues(50, 70); + const hoverColor = Color(0xffff0000); + const draggedColor = Color(0xff0000ff); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100.0, + overlayColor: WidgetStateProperty.resolveWith<Color?>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.dragged)) { + return draggedColor; + } + + return null; + }), + onChanged: enabled + ? (RangeValues newValues) { + setState(() { + values = newValues; + }); + } + : null, + onChangeStart: enabled ? (RangeValues newValues) {} : null, + onChangeEnd: enabled ? (RangeValues newValues) {} : null, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // RangeSlider does not have overlay when enabled and not hovered. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: hoverColor)), + ); + + // Hover on the range slider but outside the thumb. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getTopLeft(find.byType(RangeSlider))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: hoverColor)), + ); + + // Hover on the thumb. + await gesture.moveTo(tester.getCenter(find.byType(RangeSlider))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + paints..circle(color: hoverColor), + ); + + // Hover on the slider but outside the thumb. + await gesture.moveTo(tester.getBottomRight(find.byType(RangeSlider))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: hoverColor)), + ); + + // Reset range slider values. + values = const RangeValues(50, 70); + + // RangeSlider does not have overlay when enabled and not dragged. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: draggedColor)), + ); + + // Start dragging. + final TestGesture drag = await tester.startGesture(tester.getCenter(find.byType(RangeSlider))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop. + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + // RangeSlider has overlay when enabled and dragged. + expect( + Material.of(tester.element(find.byType(RangeSlider))), + paints..circle(color: draggedColor), + ); + + // Stop dragging. + await drag.up(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: draggedColor)), + ); + }); + + testWidgets('RangeSlider onChangeStart and onChangeEnd fire once', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/128433 + + var startFired = 0; + var endFired = 0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: GestureDetector( + onHorizontalDragUpdate: (_) {}, + child: RangeSlider( + values: const RangeValues(40, 80), + max: 100, + onChanged: (RangeValues newValue) {}, + onChangeStart: (RangeValues value) { + startFired += 1; + }, + onChangeEnd: (RangeValues value) { + endFired += 1; + }, + ), + ), + ), + ), + ), + ), + ); + + await tester.timedDragFrom( + tester.getTopLeft(find.byType(RangeSlider)), + const Offset(100.0, 0.0), + const Duration(milliseconds: 500), + ); + + expect(startFired, equals(1)); + expect(endFired, equals(1)); + }); + + testWidgets('RangeSlider in a ListView does not throw an exception', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/126648 + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: ListView( + children: <Widget>[ + const SizedBox(height: 600, child: Placeholder()), + RangeSlider( + values: const RangeValues(40, 80), + max: 100, + onChanged: (RangeValues newValue) {}, + ), + ], + ), + ), + ), + ), + ); + + // No exception should be thrown. + expect(tester.takeException(), null); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/141953. + testWidgets('Semantic nodes do not throw an error after clearSemantics', ( + WidgetTester tester, + ) async { + var semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MaterialApp( + home: Scaffold( + body: RangeSlider( + values: const RangeValues(40, 80), + max: 100, + onChanged: (RangeValues newValue) {}, + ), + ), + ), + ), + ); + + // Dispose the semantics to trigger clearSemantics. + semantics.dispose(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + // Initialize the semantics again. + semantics = SemanticsTester(tester); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + semantics.dispose(); + }, semanticsEnabled: false); + + testWidgets('Value indicator appears when it should', (WidgetTester tester) async { + final baseTheme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + SliderThemeData theme = baseTheme.sliderTheme.copyWith(valueIndicatorColor: Colors.red); + var value = const RangeValues(1, 5); + Widget buildApp({required SliderThemeData sliderTheme, int? divisions, bool enabled = true}) { + final ValueChanged<RangeValues>? onChanged = enabled ? (RangeValues d) => value = d : null; + return MaterialApp( + home: Material( + child: Center( + child: Theme( + data: baseTheme, + child: SliderTheme( + data: sliderTheme, + child: RangeSlider( + values: value, + max: 10, + labels: RangeLabels(value.start.toString(), value.end.toString()), + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + Future<void> expectValueIndicator({ + required bool isVisible, + required SliderThemeData theme, + int? divisions, + bool enabled = true, + bool dragged = true, + }) async { + // Discrete enabled widget. + await tester.pumpWidget(buildApp(sliderTheme: theme, divisions: divisions, enabled: enabled)); + final Offset center = tester.getCenter(find.byType(RangeSlider)); + TestGesture? gesture; + if (dragged) { + gesture = await tester.startGesture(center); + } + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + // _RenderValueIndicator is the last render object in the tree. + final RenderObject valueIndicatorBox = tester.allRenderObjects.last; + expect( + valueIndicatorBox, + isVisible + ? (paints + ..path(color: theme.valueIndicatorColor) + ..paragraph()) + : isNot( + paints + ..path(color: theme.valueIndicatorColor) + ..paragraph(), + ), + ); + if (dragged) { + await gesture!.up(); + } + } + + // Default (showValueIndicator set to onlyForDiscrete). + await expectValueIndicator(isVisible: true, theme: theme, divisions: 10); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, enabled: false); + await expectValueIndicator(isVisible: false, theme: theme); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, dragged: false); + await expectValueIndicator( + isVisible: false, + theme: theme, + divisions: 3, + enabled: false, + dragged: false, + ); + await expectValueIndicator(isVisible: false, theme: theme, dragged: false); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false, dragged: false); + + // With showValueIndicator set to onlyForContinuous. + theme = theme.copyWith(showValueIndicator: ShowValueIndicator.onlyForContinuous); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, enabled: false); + await expectValueIndicator(isVisible: true, theme: theme); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, dragged: false); + await expectValueIndicator( + isVisible: false, + theme: theme, + divisions: 3, + enabled: false, + dragged: false, + ); + await expectValueIndicator(isVisible: false, theme: theme, dragged: false); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false, dragged: false); + + // discrete enabled widget with showValueIndicator set to onDrag. + theme = theme.copyWith(showValueIndicator: ShowValueIndicator.onDrag); + await expectValueIndicator(isVisible: true, theme: theme, divisions: 10); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, enabled: false); + await expectValueIndicator(isVisible: true, theme: theme); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, dragged: false); + await expectValueIndicator( + isVisible: false, + theme: theme, + divisions: 3, + enabled: false, + dragged: false, + ); + await expectValueIndicator(isVisible: false, theme: theme, dragged: false); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false, dragged: false); + + // discrete enabled widget with showValueIndicator set to never. + theme = theme.copyWith(showValueIndicator: ShowValueIndicator.never); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, enabled: false); + await expectValueIndicator(isVisible: false, theme: theme); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false); + await expectValueIndicator(isVisible: false, theme: theme, divisions: 10, dragged: false); + await expectValueIndicator( + isVisible: false, + theme: theme, + divisions: 3, + enabled: false, + dragged: false, + ); + await expectValueIndicator(isVisible: false, theme: theme, dragged: false); + await expectValueIndicator(isVisible: false, theme: theme, enabled: false, dragged: false); + + // discrete enabled widget with showValueIndicator set to alwaysVisible. + theme = theme.copyWith(showValueIndicator: ShowValueIndicator.alwaysVisible); + await expectValueIndicator(isVisible: true, theme: theme, divisions: 3); + await expectValueIndicator(isVisible: true, theme: theme, divisions: 3, enabled: false); + await expectValueIndicator(isVisible: true, theme: theme); + await expectValueIndicator(isVisible: true, theme: theme, enabled: false); + await expectValueIndicator(isVisible: true, theme: theme, divisions: 3, dragged: false); + await expectValueIndicator( + isVisible: true, + theme: theme, + divisions: 3, + enabled: false, + dragged: false, + ); + await expectValueIndicator(isVisible: true, theme: theme, dragged: false); + await expectValueIndicator(isVisible: true, theme: theme, enabled: false, dragged: false); + }); + + testWidgets('RangeSlider overlay appears correctly for specific thumb interactions', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var values = const RangeValues(50, 70); + const hoverColor = Color(0xffff0000); + const dragColor = Color(0xff0000ff); + + Widget buildApp() { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: RangeSlider( + values: values, + max: 100.0, + overlayColor: WidgetStateProperty.resolveWith<Color?>(( + Set<WidgetState> states, + ) { + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.dragged)) { + return dragColor; + } + + return null; + }), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) {}, + onChangeEnd: (RangeValues newValues) {}, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + // Initial state - no overlay. + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: dragColor)), + ); + + // Drag start thumb to left. + final Offset topThumbLocation = tester.getCenter(find.byType(RangeSlider)); + final TestGesture dragStartThumb = await tester.startGesture(topThumbLocation); + await tester.pump(kPressTimeout); + await dragStartThumb.moveBy(const Offset(-20.0, 0)); + await tester.pumpAndSettle(); + + // Verify overlay is visible and shadow is visible on single thumb. + expect( + Material.of(tester.element(find.byType(RangeSlider))), + paints + ..circle(color: dragColor) + ..path(color: Colors.black, style: PaintingStyle.stroke, strokeWidth: 2.0) + ..path(color: Colors.black, style: PaintingStyle.stroke, strokeWidth: 12.0), + ); + + // Move back and release. + await dragStartThumb.moveBy(const Offset(20.0, 0)); + await dragStartThumb.up(); + await tester.pumpAndSettle(); + + // Verify overlay and shadow disappears + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot( + paints + ..circle(color: dragColor) + ..path(color: Colors.black, style: PaintingStyle.stroke, strokeWidth: 2.0) + ..path(color: Colors.black, style: PaintingStyle.stroke, strokeWidth: 2.0), + ), + ); + + // Drag end thumb and return to original position. + final Offset bottomThumbLocation = tester + .getCenter(find.byType(RangeSlider)) + .translate(220.0, 0.0); + final TestGesture dragEndThumb = await tester.startGesture(bottomThumbLocation); + await tester.pump(kPressTimeout); + await dragEndThumb.moveBy(const Offset(20.0, 0)); + await tester.pump(kPressTimeout); + await dragEndThumb.moveBy(const Offset(-20.0, 0)); + await dragEndThumb.up(); + await tester.pumpAndSettle(); + + // Verify overlay disappears. + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: dragColor)), + ); + + // Hover on start thumb. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(topThumbLocation); + await tester.pumpAndSettle(); + + // Verify overlay appears only for start thumb and no shadow is visible. + expect( + Material.of(tester.element(find.byType(RangeSlider))), + paints + ..circle(color: hoverColor) + ..path(color: Colors.black, style: PaintingStyle.stroke, strokeWidth: 2.0) + ..path(color: Colors.black, style: PaintingStyle.stroke, strokeWidth: 2.0), + ); + + final RenderObject renderObject = tester.renderObject(find.byType(RangeSlider)); + // 2 thumbs, 1 overlay for hover, and 1 overlay for focus. + expect(renderObject, paintsExactlyCountTimes(#drawCircle, 4)); + + // Move away from thumb + await gesture.moveTo(tester.getTopRight(find.byType(RangeSlider))); + await tester.pumpAndSettle(); + + // Verify overlay disappears + expect( + Material.of(tester.element(find.byType(RangeSlider))), + isNot(paints..circle(color: hoverColor)), + ); + }); + + testWidgets('RangeSlider.padding can override the default RangeSlider padding', ( + WidgetTester tester, + ) async { + Widget buildRangeSlider({EdgeInsetsGeometry? padding}) { + return MaterialApp( + home: Material( + child: Center( + child: IntrinsicHeight( + child: RangeSlider( + padding: padding, + values: const RangeValues(0, 1.0), + onChanged: (RangeValues values) {}, + ), + ), + ), + ), + ); + } + + RenderBox sliderRenderBox() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderRangeSlider', + ) + as RenderBox; + } + + // Test RangeSlider height and tracks spacing with zero padding. + await tester.pumpWidget(buildRangeSlider(padding: EdgeInsets.zero)); + await tester.pumpAndSettle(); + + // The height equals to the default thumb height. + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(RangeSlider), + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 8.0, + 10.0, + 12.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 790.0, + 8.0, + 790.0, + 12.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(8.0, 7.0, 792.0, 13.0, const Radius.circular(2.0))), + ); + + // Test RangeSlider height and tracks spacing with directional padding. + const double startPadding = 100; + const double endPadding = 20; + await tester.pumpWidget( + buildRangeSlider( + padding: const EdgeInsetsDirectional.only(start: startPadding, end: endPadding), + ), + ); + await tester.pumpAndSettle(); + + expect(sliderRenderBox().size, const Size(800 - startPadding - endPadding, 20)); + expect( + find.byType(RangeSlider), + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 8.0, + 10.0, + 12.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 670.0, + 8.0, + 670.0, + 12.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(8.0, 7.0, 672.0, 13.0, const Radius.circular(2.0))), + ); + + // Test RangeSlider height and tracks spacing with top and bottom padding. + const double topPadding = 100; + const double bottomPadding = 20; + const double trackHeight = 20; + await tester.pumpWidget( + buildRangeSlider( + padding: const EdgeInsetsDirectional.only(top: topPadding, bottom: bottomPadding), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(RangeSlider)), + const Size(800, topPadding + trackHeight + bottomPadding), + ); + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(RangeSlider), + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 8.0, + 10.0, + 12.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 790.0, + 8.0, + 790.0, + 12.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(8.0, 7.0, 792.0, 13.0, const Radius.circular(2.0))), + ); + }); + + // Regression test for hhttps://github.com/flutter/flutter/issues/161805 + testWidgets('Discrete RangeSlider does not apply thumb padding in a non-rounded track shape', ( + WidgetTester tester, + ) async { + // The default track left and right padding. + const sliderPadding = 24.0; + final theme = ThemeData( + sliderTheme: const SliderThemeData( + // Thumb padding is applied based on the track height. + trackHeight: 100, + rangeTrackShape: RectangularRangeSliderTrackShape(), + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: SizedBox( + width: 300, + child: RangeSlider( + values: const RangeValues(0, 100), + max: 100, + divisions: 100, + onChanged: (RangeValues value) {}, + ), + ), + ), + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(RangeSlider))); + + expect( + material, + paints + // Start thumb. + ..circle(x: sliderPadding, y: 300.0, color: theme.colorScheme.primary) + // End thumb. + ..circle(x: 800.0 - sliderPadding, y: 300.0, color: theme.colorScheme.primary), + ); + }); + + testWidgets('Default RangeSlider when year2023 is false', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + final Color activeTrackColor = colorScheme.primary; + final Color inactiveTrackColor = colorScheme.secondaryContainer; + final Color disabledActiveTrackColor = colorScheme.onSurface.withOpacity(0.38); + final Color disabledInactiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color activeTickMarkColor = colorScheme.onPrimary; + final Color inactiveTickMarkColor = colorScheme.onSecondaryContainer; + final Color disabledActiveTickMarkColor = colorScheme.onInverseSurface; + final Color disabledInactiveTickMarkColor = colorScheme.onSurface; + final Color thumbColor = colorScheme.primary; + final Color disabledThumbColor = colorScheme.onSurface.withOpacity(0.38); + final Color valueIndicatorColor = colorScheme.inverseSurface; + var values = const RangeValues(25.0, 75.0); + Widget buildApp({int? divisions, bool enabled = true}) { + final ValueChanged<RangeValues>? onChanged = !enabled + ? null + : (RangeValues newValues) { + values = newValues; + }; + return MaterialApp( + home: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + year2023: false, + values: values, + max: 100, + labels: RangeLabels(values.start.round().toString(), values.end.round().toString()), + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(RangeSlider))); + + // Test default track shape. + const trackOuterCornerRadius = Radius.circular(8.0); + const trackInnerCornerRadius = Radius.circular(2.0); + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 206.0, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + color: inactiveTrackColor, + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 594.0, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + color: inactiveTrackColor, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(218.0, 292.0, 582.0, 308.0, trackInnerCornerRadius), + color: activeTrackColor, + ), + ); + + // Test default colors for enabled slider. + expect( + material, + paints + ..circle() + ..circle() + ..rrect(color: thumbColor) + ..rrect(color: thumbColor), + ); + expect( + material, + isNot( + paints + ..circle() + ..circle() + ..rrect(color: disabledThumbColor) + ..rrect(color: disabledThumbColor), + ), + ); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + + // Test defaults colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 4)); + expect( + material, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor) + ..circle(color: inactiveTickMarkColor) + ..circle(color: activeTickMarkColor) + ..circle(color: inactiveTickMarkColor), + ); + expect(material, isNot(paints..rrect(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + + // Test defaults colors for disabled slider. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + material, + paints + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledThumbColor) + ..rrect(color: disabledThumbColor), + ); + expect( + material, + isNot( + paints + ..rrect(color: thumbColor) + ..rrect(color: thumbColor), + ), + ); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + + // Test defaults colors for disabled discrete slider. + await tester.pumpWidget(buildApp(divisions: 4, enabled: false)); + expect( + material, + paints + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledActiveTrackColor) + ..circle(color: disabledInactiveTickMarkColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..rrect(color: disabledThumbColor) + ..rrect(color: disabledThumbColor), + ); + expect( + material, + isNot( + paints + ..rrect(color: thumbColor) + ..rrect(color: thumbColor), + ), + ); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + + await tester.pumpWidget(buildApp(divisions: 4)); + await tester.pumpAndSettle(); + + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(topLeft); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..rrect(color: valueIndicatorColor), + ); + await gesture.up(); + }); + + testWidgets('RangeSlider value indicator text when year2023 is false', ( + WidgetTester tester, + ) async { + const values = RangeValues(25.0, 75.0); + final log = <InlineSpan>[]; + final loggingValueIndicatorShape = LoggingRangeSliderValueIndicatorShape(log); + final theme = ThemeData( + sliderTheme: SliderThemeData(rangeValueIndicatorShape: loggingValueIndicatorShape), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: RangeSlider( + year2023: false, + values: values, + max: 100, + labels: RangeLabels(values.start.round().toString(), values.end.round().toString()), + divisions: 4, + onChanged: (RangeValues value) {}, + ), + ), + ), + ), + ); + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(topLeft); + await tester.pumpAndSettle(); + + expect(log.last.toPlainText(), '25'); + expect(log.last.style!.fontSize, 14.0); + expect(log.last.style!.color, theme.colorScheme.onInverseSurface); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('RangeSlider supports DropRangeSliderValueIndicatorShape', ( + WidgetTester tester, + ) async { + const values = RangeValues(25.0, 75.0); + const valueIndicatorColor = Color(0XFFFF0000); + final theme = ThemeData( + sliderTheme: const SliderThemeData( + rangeValueIndicatorShape: DropRangeSliderValueIndicatorShape(), + valueIndicatorColor: valueIndicatorColor, + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: RangeSlider( + year2023: false, + values: values, + max: 100, + labels: RangeLabels(values.start.round().toString(), values.end.round().toString()), + divisions: 4, + onChanged: (RangeValues value) {}, + ), + ), + ), + ), + ); + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(topLeft); + await tester.pumpAndSettle(); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect(valueIndicatorBox, paints..path(color: valueIndicatorColor)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Value indicator appears on tap', (WidgetTester tester) async { + final ThemeData theme = buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + const discreteValues = RangeValues(20, 40); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: RangeSlider( + labels: RangeLabels( + discreteValues.start.round().toString(), + discreteValues.end.round().toString(), + ), + values: discreteValues, + divisions: 5, + max: 100, + onChanged: (RangeValues values) {}, + ), + ), + ), + ); + await tester.tap(find.byType(RangeSlider)); + await tester.pumpAndSettle(); + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..path(color: Colors.black) // shadow + ..path(color: Colors.black) // shadow + ..path(color: sliderTheme.valueIndicatorColor) + ..paragraph(), + ); + }); + + testWidgets('RangeSlider does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: RangeSlider(values: const RangeValues(0, 1), onChanged: (_) {}), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(RangeSlider)), Size.zero); + }); + + testWidgets('RangeSlider taps should set focus on start/end thumbs', (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) {}, + onChangeEnd: (RangeValues newValues) {}, + ); + }, + ), + ), + ), + ), + ); + + // Initial state: root focus scope has focus + final FocusNode initialFocus = FocusManager.instance.primaryFocus!; + expect(initialFocus, isNotNull); + + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)); + + // Tap near the start thumb (0.3) + final Offset startThumbPos = topLeft + (bottomRight - topLeft) * 0.3; + await tester.tapAt(startThumbPos); + await tester.pump(); + + // Verify focus changed to start thumb + final startFocusNode = + (tester.state(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + expect(startFocusNode.hasFocus, isTrue, reason: 'Start thumb should have focus after tap'); + expect(FocusManager.instance.primaryFocus, equals(startFocusNode)); + + // Reset focus + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + + // Tap near the end thumb (0.7) + final Offset endThumbPos = topLeft + (bottomRight - topLeft) * 0.7; + await tester.tapAt(endThumbPos); + await tester.pump(); + + // Verify focus changed to end thumb + final endFocusNode = + (tester.state(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + expect(endFocusNode.hasFocus, isTrue, reason: 'End thumb should have focus after tap'); + expect(FocusManager.instance.primaryFocus, equals(endFocusNode)); + }); + + testWidgets('RangeSlider drag should set focus on start/end thumbs', (WidgetTester tester) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ); + }, + ), + ), + ), + ), + ); + + // Initial state + final FocusNode initialFocus = FocusManager.instance.primaryFocus!; + expect(initialFocus, isNotNull); + + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)); + + // Drag start thumb + final Offset startThumbPos = topLeft + (bottomRight - topLeft) * 0.3; + final TestGesture gesture = await tester.startGesture(startThumbPos); + await tester.pump(); + + // Verify focus on start drag + final startFocusNode = + (tester.state(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + expect(startFocusNode.hasFocus, isTrue, reason: 'Start thumb should have focus on drag start'); + expect(FocusManager.instance.primaryFocus, equals(startFocusNode)); + + await gesture.moveBy(const Offset(10, 0)); + await gesture.up(); + await tester.pump(); + + // Reset focus + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + + // Drag end thumb + final Offset endThumbPos = topLeft + (bottomRight - topLeft) * 0.7; + final TestGesture endGesture = await tester.startGesture(endThumbPos); + await tester.pump(); + + // Verify focus on end drag + final endFocusNode = + (tester.state(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + expect(endFocusNode.hasFocus, isTrue, reason: 'End thumb should have focus on drag start'); + expect(FocusManager.instance.primaryFocus, equals(endFocusNode)); + + await endGesture.moveBy(const Offset(-10, 0)); + await endGesture.up(); + await tester.pump(); + }); + + testWidgets('RangeSlider tap start thumb then tab should focus end thumb', ( + WidgetTester tester, + ) async { + var values = const RangeValues(0.3, 0.7); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ); + }, + ), + ), + ), + ), + ); + + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)); + + // Tap near the start thumb (0.3) + final Offset startThumbPos = topLeft + (bottomRight - topLeft) * 0.3; + await tester.tapAt(startThumbPos); + await tester.pump(); + + // Verify start thumb has focus + final startFocusNode = + (tester.state(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + expect(startFocusNode.hasFocus, isTrue, reason: 'Start thumb should have focus after tap'); + expect(FocusManager.instance.primaryFocus, equals(startFocusNode)); + + // Press Tab + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + // Verify end thumb has focus + final endFocusNode = + (tester.state(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + expect(endFocusNode.hasFocus, isTrue, reason: 'End thumb should have focus after tab'); + expect(FocusManager.instance.primaryFocus, equals(endFocusNode)); + }); +} + +// A value indicator shape to log labelPainter text. +class LoggingRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { + LoggingRangeSliderValueIndicatorShape(this.logLabel); + + final List<InlineSpan> logLabel; + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + required TextPainter labelPainter, + required double textScaleFactor, + }) { + return const Size(10.0, 10.0); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + bool? isDiscrete, + bool? isOnTop, + required TextPainter labelPainter, + double? textScaleFactor, + Size? sizeWithOverflow, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + double? value, + Thumb? thumb, + }) { + logLabel.add(labelPainter.text!); + } +} diff --git a/packages/material_ui/test/material/raw_material_button_test.dart b/packages/material_ui/test/material/raw_material_button_test.dart new file mode 100644 index 000000000000..5166123ddcb8 --- /dev/null +++ b/packages/material_ui/test/material/raw_material_button_test.dart @@ -0,0 +1,666 @@ +// 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/src/services/keyboard_key.g.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('RawMaterialButton responds when tapped', (WidgetTester tester) async { + var pressed = false; + const splashColor = Color(0xff00ff00); + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RawMaterialButton( + splashColor: splashColor, + onPressed: () { + pressed = true; + }, + child: const Text('BUTTON'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('BUTTON')); + await tester.pump(const Duration(milliseconds: 10)); + + final splash = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect(splash, paints..circle(color: splashColor)); + + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + }); + + testWidgets('RawMaterialButton responds to shortcut when activated', (WidgetTester tester) async { + var pressed = false; + final focusNode = FocusNode(debugLabel: 'Test Button'); + const splashColor = Color(0xff00ff00); + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Shortcuts( + shortcuts: const <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }, + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RawMaterialButton( + splashColor: splashColor, + focusNode: focusNode, + onPressed: () { + pressed = true; + }, + child: const Text('BUTTON'), + ), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + // Web doesn't react to enter, just space. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 10)); + + if (!kIsWeb) { + final splash = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect(splash, paints..circle(color: splashColor)); + } + + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + + pressed = false; + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + + pressed = false; + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(const Duration(milliseconds: 10)); + + final splash = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect(splash, paints..circle(color: splashColor)); + + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + + pressed = false; + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + focusNode.dispose(); + }); + + testWidgets('materialTapTargetSize.padded expands hit test area', (WidgetTester tester) async { + var pressed = 0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RawMaterialButton( + onPressed: () { + pressed++; + }, + constraints: BoxConstraints.tight(const Size(10.0, 10.0)), + materialTapTargetSize: MaterialTapTargetSize.padded, + child: const Text('+'), + ), + ), + ); + + await tester.tapAt(const Offset(40.0, 400.0)); + + expect(pressed, 1); + }); + + testWidgets('materialTapTargetSize.padded expands semantics area', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RawMaterialButton( + onPressed: () {}, + constraints: BoxConstraints.tight(const Size(10.0, 10.0)), + materialTapTargetSize: MaterialTapTargetSize.padded, + child: const Text('+'), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: '+', + textDirection: TextDirection.ltr, + rect: const Rect.fromLTRB(0.0, 0.0, 48.0, 48.0), + children: <TestSemantics>[], + ), + ], + ), + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Ink splash from center tap originates in correct location', ( + WidgetTester tester, + ) async { + const highlightColor = Color(0xAAFF0000); + const splashColor = Color(0xAA0000FF); + const fillColor = Color(0xFFEF5350); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.padded, + onPressed: () {}, + fillColor: fillColor, + highlightColor: highlightColor, + splashColor: splashColor, + child: const SizedBox(), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(InkWell)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + // centered in material button. + expect(box, paints..circle(x: 44.0, y: 18.0, color: splashColor)); + await gesture.up(); + }); + + testWidgets('Ink splash from tap above material originates in correct location', ( + WidgetTester tester, + ) async { + const highlightColor = Color(0xAAFF0000); + const splashColor = Color(0xAA0000FF); + const fillColor = Color(0xFFEF5350); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.padded, + onPressed: () {}, + fillColor: fillColor, + highlightColor: highlightColor, + splashColor: splashColor, + child: const SizedBox(), + ), + ), + ), + ), + ); + + final Offset top = tester.getRect(find.byType(InkWell)).topCenter; + final TestGesture gesture = await tester.startGesture(top); + await tester.pump(); // start gesture + await tester.pump(const Duration(milliseconds: 200)); // wait for splash to be well under way + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + // paints above material + expect(box, paints..circle(x: 44.0, y: 0.0, color: splashColor)); + await gesture.up(); + }); + + testWidgets('off-center child is hit testable', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Column( + children: <Widget>[ + RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.padded, + onPressed: () {}, + child: const SizedBox.square( + dimension: 400.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: <Widget>[SizedBox(height: 50.0, width: 400.0, child: Text('Material'))], + ), + ), + ), + ], + ), + ), + ); + expect(find.text('Material').hitTestable(), findsOneWidget); + }); + + testWidgets('smaller child is hit testable', (WidgetTester tester) async { + const key = Key('test'); + await tester.pumpWidget( + MaterialApp( + home: Column( + children: <Widget>[ + RawMaterialButton( + materialTapTargetSize: MaterialTapTargetSize.padded, + onPressed: () {}, + child: SizedBox( + key: key, + width: 8.0, + height: 8.0, + child: Container(color: const Color(0xFFAABBCC)), + ), + ), + ], + ), + ), + ); + expect(find.byKey(key).hitTestable(), findsOneWidget); + }); + + testWidgets('RawMaterialButton can be expanded by parent constraints', ( + WidgetTester tester, + ) async { + const key = Key('test'); + await tester.pumpWidget( + MaterialApp( + home: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + RawMaterialButton(key: key, onPressed: () {}, child: const SizedBox()), + ], + ), + ), + ); + + expect(tester.getSize(find.byKey(key)), const Size(800.0, 48.0)); + }); + + testWidgets('RawMaterialButton handles focus', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Button Focus'); + const key = Key('test'); + const focusColor = Color(0xff00ff00); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RawMaterialButton( + key: key, + focusNode: focusNode, + focusColor: focusColor, + onPressed: () {}, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ); + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + expect(box, isNot(paints..rect(color: focusColor))); + + focusNode.requestFocus(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(box, paints..rect(color: focusColor)); + focusNode.dispose(); + }); + + testWidgets('RawMaterialButton loses focus when disabled.', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'RawMaterialButton'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RawMaterialButton( + autofocus: true, + focusNode: focusNode, + onPressed: () {}, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RawMaterialButton( + focusNode: focusNode, + onPressed: null, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isFalse); + focusNode.dispose(); + }); + + testWidgets("Disabled RawMaterialButton can't be traversed to.", (WidgetTester tester) async { + final focusNode1 = FocusNode(debugLabel: '$RawMaterialButton 1'); + final focusNode2 = FocusNode(debugLabel: '$RawMaterialButton 2'); + + await tester.pumpWidget( + MaterialApp( + home: FocusScope( + child: Center( + child: Column( + children: <Widget>[ + RawMaterialButton( + autofocus: true, + focusNode: focusNode1, + onPressed: () {}, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + RawMaterialButton( + autofocus: true, + focusNode: focusNode2, + onPressed: null, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + expect(focusNode1.nextFocus(), isFalse); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + focusNode1.dispose(); + focusNode2.dispose(); + }); + + testWidgets('RawMaterialButton handles hover', (WidgetTester tester) async { + const key = Key('test'); + const hoverColor = Color(0xff00ff00); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RawMaterialButton( + key: key, + hoverColor: hoverColor, + hoverElevation: 10.5, + onPressed: () {}, + child: Container(width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ); + final box = Material.of(tester.element(find.byType(InkWell))) as RenderBox; + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + expect(box, isNot(paints..rect(color: hoverColor))); + + await gesture.moveTo(tester.getCenter(find.byType(RawMaterialButton))); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(box, paints..rect(color: hoverColor)); + }); + + testWidgets( + 'RawMaterialButton onPressed and onLongPress callbacks are correctly called when non-null', + (WidgetTester tester) async { + bool wasPressed; + Finder rawMaterialButton; + + Widget buildFrame({VoidCallback? onPressed, VoidCallback? onLongPress}) { + return Directionality( + textDirection: TextDirection.ltr, + child: RawMaterialButton( + onPressed: onPressed, + onLongPress: onLongPress, + child: const Text('button'), + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onPressed: () { + wasPressed = true; + }, + ), + ); + rawMaterialButton = find.byType(RawMaterialButton); + expect(tester.widget<RawMaterialButton>(rawMaterialButton).enabled, true); + await tester.tap(rawMaterialButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onLongPress: () { + wasPressed = true; + }, + ), + ); + rawMaterialButton = find.byType(RawMaterialButton); + expect(tester.widget<RawMaterialButton>(rawMaterialButton).enabled, true); + await tester.longPress(rawMaterialButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget(buildFrame()); + rawMaterialButton = find.byType(RawMaterialButton); + expect(tester.widget<RawMaterialButton>(rawMaterialButton).enabled, false); + }, + ); + + testWidgets('RawMaterialButton onPressed and onLongPress callbacks are distinctly recognized', ( + WidgetTester tester, + ) async { + var didPressButton = false; + var didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RawMaterialButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder rawMaterialButton = find.byType(RawMaterialButton); + expect(tester.widget<RawMaterialButton>(rawMaterialButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(rawMaterialButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(rawMaterialButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets('RawMaterialButton responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + const childKey = Key('test child'); + + Future<void> buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: RawMaterialButton( + visualDensity: visualDensity, + 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(100, 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(124, 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(100, 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(100, 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(76, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + testWidgets('RawMaterialButton changes mouse cursor as expected when hovered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RawMaterialButton(onPressed: () {}, mouseCursor: SystemMouseCursors.text), + ), + ), + ); + + 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 default cursor + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RawMaterialButton(onPressed: () {}), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: RawMaterialButton(onPressed: null), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); +} diff --git a/packages/material_ui/test/material/refresh_indicator_test.dart b/packages/material_ui/test/material/refresh_indicator_test.dart new file mode 100644 index 000000000000..2d716529ce8d --- /dev/null +++ b/packages/material_ui/test/material/refresh_indicator_test.dart @@ -0,0 +1,1273 @@ +// 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:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +bool refreshCalled = false; + +Future<void> refresh() { + refreshCalled = true; + return Future<void>.value(); +} + +Future<void> holdRefresh() { + refreshCalled = true; + return Completer<void>().future; +} + +void main() { + testWidgets('RefreshIndicator', (WidgetTester tester) async { + refreshCalled = false; + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + expect( + tester.getSemantics(find.byType(RefreshProgressIndicator)), + matchesSemantics(label: 'Refresh'), + ); + + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, true); + handle.dispose(); + }); + + testWidgets('Refresh Indicator - nested', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + notificationPredicate: (ScrollNotification notification) => notification.depth == 1, + onRefresh: refresh, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: 600.0, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ), + ), + ); + + await tester.fling(find.text('A'), const Offset(300.0, 0.0), 1000.0); // horizontal fling + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, false); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); // vertical fling + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, true); + }); + + testWidgets('RefreshIndicator - reverse', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + reverse: true, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 600.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, true); + }); + + testWidgets('RefreshIndicator - top - position', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: holdRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, lessThan(300.0)); + }); + + testWidgets('RefreshIndicator - reverse - position', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: holdRefresh, + child: ListView( + reverse: true, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 600.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, lessThan(300.0)); + }); + + testWidgets('RefreshIndicator - no movement', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + // this fling is horizontal, not up or down + await tester.fling(find.text('X'), const Offset(1.0, 0.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, false); + }); + + testWidgets('RefreshIndicator - not enough', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 50.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, false); + }); + + testWidgets('RefreshIndicator - just enough', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 200.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, true); + }); + + testWidgets('RefreshIndicator - drag back not far enough to cancel', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 1000), + ], + ), + ), + ), + ); + + final Offset startLocation = tester.getCenter( + find.text('X'), + warnIfMissed: true, + callee: 'drag', + ); + final testPointer = TestPointer(); + await tester.sendEventToBinding(testPointer.down(startLocation)); + await tester.sendEventToBinding(testPointer.move(startLocation + const Offset(0.0, 175))); + await tester.pump(); + await tester.sendEventToBinding(testPointer.move(startLocation + const Offset(0.0, 150))); + await tester.pump(); + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, true); + }); + + testWidgets('RefreshIndicator - drag back far enough to cancel', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 1000), + ], + ), + ), + ), + ); + + final Offset startLocation = tester.getCenter( + find.text('X'), + warnIfMissed: true, + callee: 'drag', + ); + final testPointer = TestPointer(); + await tester.sendEventToBinding(testPointer.down(startLocation)); + await tester.sendEventToBinding(testPointer.move(startLocation + const Offset(0.0, 175))); + await tester.pump(); + await tester.sendEventToBinding(testPointer.move(startLocation + const Offset(0.0, 149))); + await tester.pump(); + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, false); + }); + + testWidgets('RefreshIndicator - show - slow', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: holdRefresh, // this one never returns + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + var completed = false; + tester.state<RefreshIndicatorState>(find.byType(RefreshIndicator)).show().then<void>(( + void value, + ) { + completed = true; + }); + await tester.pump(); + expect(completed, false); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, true); + expect(completed, false); + completed = false; + refreshCalled = false; + tester.state<RefreshIndicatorState>(find.byType(RefreshIndicator)).show().then<void>(( + void value, + ) { + completed = true; + }); + await tester.pump(); + expect(completed, false); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, false); + }); + + testWidgets('RefreshIndicator - show - fast', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + var completed = false; + tester.state<RefreshIndicatorState>(find.byType(RefreshIndicator)).show().then<void>(( + void value, + ) { + completed = true; + }); + await tester.pump(); + expect(completed, false); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, true); + expect(completed, true); + completed = false; + refreshCalled = false; + tester.state<RefreshIndicatorState>(find.byType(RefreshIndicator)).show().then<void>(( + void value, + ) { + completed = true; + }); + await tester.pump(); + expect(completed, false); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, true); + expect(completed, true); + }); + + testWidgets('RefreshIndicator - show - fast - twice', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[SizedBox(height: 200.0, child: Text('X'))], + ), + ), + ), + ); + + var completed1 = false; + tester.state<RefreshIndicatorState>(find.byType(RefreshIndicator)).show().then<void>(( + void value, + ) { + completed1 = true; + }); + var completed2 = false; + tester.state<RefreshIndicatorState>(find.byType(RefreshIndicator)).show().then<void>(( + void value, + ) { + completed2 = true; + }); + await tester.pump(); + expect(completed1, false); + expect(completed2, false); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(refreshCalled, true); + expect(completed1, true); + expect(completed2, true); + }); + + testWidgets( + 'Refresh starts while scroll view moves back to 0.0 after overscroll', + (WidgetTester tester) async { + refreshCalled = false; + double lastScrollOffset; + final controller = ScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + controller: controller, + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await tester.fling(find.text('A'), const Offset(0.0, 1500.0), 10000.0); + } else { + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + } + await tester.pump(const Duration(milliseconds: 100)); + expect(lastScrollOffset = controller.offset, lessThan(0.0)); + expect(refreshCalled, isFalse); + + await tester.pump(const Duration(milliseconds: 400)); + expect(controller.offset, greaterThan(lastScrollOffset)); + expect(controller.offset, lessThan(0.0)); + expect(refreshCalled, isTrue); + + controller.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('RefreshIndicator does not force child to relayout', (WidgetTester tester) async { + var layoutCount = 0; + + Widget layoutCallback(BuildContext context, BoxConstraints constraints) { + layoutCount++; + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: LayoutBuilder(builder: layoutCallback), + ), + ), + ); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); // trigger refresh + await tester.pump(); + + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + + expect(layoutCount, 1); + }); + + testWidgets('RefreshIndicator responds to strokeWidth', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: () async {}, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + // Check for the default value + expect( + tester.widget<RefreshIndicator>(find.byType(RefreshIndicator)).strokeWidth, + RefreshProgressIndicator.defaultStrokeWidth, + ); + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: () async {}, + strokeWidth: 4.0, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + expect(tester.widget<RefreshIndicator>(find.byType(RefreshIndicator)).strokeWidth, 4.0); + }); + + testWidgets('RefreshIndicator responds to edgeOffset', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: () async {}, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + //By default the value of edgeOffset is 0.0 + expect(tester.widget<RefreshIndicator>(find.byType(RefreshIndicator)).edgeOffset, 0.0); + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: () async {}, + edgeOffset: kToolbarHeight, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + expect( + tester.widget<RefreshIndicator>(find.byType(RefreshIndicator)).edgeOffset, + kToolbarHeight, + ); + }); + + testWidgets('RefreshIndicator appears at edgeOffset', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + edgeOffset: kToolbarHeight, + displacement: kToolbarHeight, + onRefresh: () async { + await Future<void>.delayed(const Duration(seconds: 1), () {}); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + await tester.fling(find.byType(ListView), const Offset(0.0, 2.0 * kToolbarHeight), 1000.0); + await tester.pump(const Duration(seconds: 2)); + + expect( + tester.getTopLeft(find.byType(RefreshProgressIndicator)).dy, + greaterThanOrEqualTo(2.0 * kToolbarHeight), + ); + }); + + testWidgets( + 'Top RefreshIndicator(anywhere mode) should be shown when dragging from non-zero scroll position', + (WidgetTester tester) async { + refreshCalled = false; + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ), + ), + ); + + scrollController.jumpTo(50.0); + + await tester.fling(find.text('X'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, lessThan(300.0)); + + scrollController.dispose(); + }, + ); + + testWidgets( + 'Reverse RefreshIndicator(anywhere mode) should be shown when dragging from non-zero scroll position', + (WidgetTester tester) async { + refreshCalled = false; + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + reverse: true, + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ), + ), + ); + + scrollController.jumpTo(50.0); + + await tester.fling(find.text('X'), const Offset(0.0, 600.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, lessThan(300.0)); + + scrollController.dispose(); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/71936 + testWidgets( + 'RefreshIndicator(anywhere mode) should not be shown when overscroll occurs due to inertia', + (WidgetTester tester) async { + refreshCalled = false; + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 2000.0, child: Text('Y')), + ], + ), + ), + ), + ); + + scrollController.jumpTo(100.0); + + // Release finger before reach the edge. + await tester.fling(find.text('X'), const Offset(0.0, 99.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(find.byType(RefreshProgressIndicator), findsNothing); + + scrollController.dispose(); + }, + ); + + testWidgets( + 'Top RefreshIndicator(onEdge mode) should not be shown when dragging from non-zero scroll position', + (WidgetTester tester) async { + refreshCalled = false; + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: holdRefresh, + child: ListView( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ), + ), + ); + + scrollController.jumpTo(50.0); + + await tester.fling(find.text('X'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(find.byType(RefreshProgressIndicator), findsNothing); + + scrollController.dispose(); + }, + ); + + testWidgets( + 'Reverse RefreshIndicator(onEdge mode) should be shown when dragging from non-zero scroll position', + (WidgetTester tester) async { + refreshCalled = false; + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: holdRefresh, + child: ListView( + reverse: true, + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ), + ), + ); + + scrollController.jumpTo(50.0); + + await tester.fling(find.text('X'), const Offset(0.0, -300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(find.byType(RefreshProgressIndicator), findsNothing); + + scrollController.dispose(); + }, + ); + + testWidgets('ScrollController.jumpTo should not trigger the refresh indicator', ( + WidgetTester tester, + ) async { + refreshCalled = false; + final scrollController = ScrollController(initialScrollOffset: 500.0); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 800.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ), + ), + ); + + scrollController.jumpTo(0.0); + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + + expect(refreshCalled, false); + + scrollController.dispose(); + }); + + testWidgets('RefreshIndicator.adaptive', (WidgetTester tester) async { + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: RefreshIndicator.adaptive( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildFrame(platform)); + await tester.pumpAndSettle(); // Finish the theme change animation. + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + expect(find.byType(RefreshProgressIndicator), findsNothing); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(buildFrame(platform)); + await tester.pumpAndSettle(); // Finish the theme change animation. + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + expect( + tester.getSemantics(find.byType(RefreshProgressIndicator)), + matchesSemantics(label: 'Refresh'), + ); + expect(find.byType(CupertinoActivityIndicator), findsNothing); + } + }); + + testWidgets('RefreshIndicator color defaults to ColorScheme.primary', ( + WidgetTester tester, + ) async { + const primaryColor = Color(0xff4caf50); + final theme = ThemeData.from( + colorScheme: const ColorScheme.light().copyWith(primary: primaryColor), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + return RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + reverse: true, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ); + }, + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 600.0), 1000.0); + await tester.pump(); + expect( + tester + .widget<RefreshProgressIndicator>(find.byType(RefreshProgressIndicator)) + .valueColor! + .value, + primaryColor, + ); + }); + + testWidgets('RefreshIndicator.color can be updated at runtime', (WidgetTester tester) async { + refreshCalled = false; + Color refreshIndicatorColor = Colors.green; + const Color red = Colors.red; + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + color: refreshIndicatorColor, + child: ListView( + reverse: true, + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[ + SizedBox(height: 200.0, child: Text('X')), + SizedBox(height: 800.0, child: Text('Y')), + ], + ), + ); + }, + ), + ), + ); + + await tester.fling(find.text('X'), const Offset(0.0, 600.0), 1000.0); + await tester.pump(); + expect( + tester + .widget<RefreshProgressIndicator>(find.byType(RefreshProgressIndicator)) + .valueColor! + .value, + refreshIndicatorColor.withOpacity(1.0), + ); + + setState(() { + refreshIndicatorColor = red; + }); + + await tester.pump(); + expect( + tester + .widget<RefreshProgressIndicator>(find.byType(RefreshProgressIndicator)) + .valueColor! + .value, + red.withOpacity(1.0), + ); + }); + + testWidgets('RefreshIndicator - reverse - BouncingScrollPhysics', (WidgetTester tester) async { + refreshCalled = false; + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + reverse: true, + physics: const BouncingScrollPhysics(), + children: <Widget>[ + for (int i = 0; i < 4; i++) SizedBox(height: 200.0, child: Text('X - $i')), + ], + ), + ), + ), + ); + + // Scroll to top + await tester.fling(find.text('X - 0'), const Offset(0.0, 800.0), 1000.0); + await tester.pumpAndSettle(); + + // Fling down to show refresh indicator + await tester.fling(find.text('X - 3'), const Offset(0.0, 250.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, true); + }); + + testWidgets('RefreshIndicator disallows indicator - glow', (WidgetTester tester) async { + refreshCalled = false; + var glowAccepted = true; + ScrollNotification? lastNotification; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: RefreshIndicator( + onRefresh: refresh, + child: Builder( + builder: (BuildContext context) { + return NotificationListener<ScrollNotification>( + onNotification: (ScrollNotification notification) { + if (notification is OverscrollNotification && + lastNotification is! OverscrollNotification) { + final confirmationNotification = OverscrollIndicatorNotification(leading: true); + confirmationNotification.dispatch(context); + glowAccepted = confirmationNotification.accepted; + } + lastNotification = notification; + return false; + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ); + }, + ), + ), + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsNothing); + expect(find.byType(GlowingOverscrollIndicator), findsOneWidget); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, true); + expect(glowAccepted, false); + }); + + testWidgets('RefreshIndicator disallows indicator - stretch', (WidgetTester tester) async { + refreshCalled = false; + var stretchAccepted = true; + ScrollNotification? lastNotification; + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: Builder( + builder: (BuildContext context) { + return NotificationListener<ScrollNotification>( + onNotification: (ScrollNotification notification) { + if (notification is OverscrollNotification && + lastNotification is! OverscrollNotification) { + final confirmationNotification = OverscrollIndicatorNotification(leading: true); + confirmationNotification.dispatch(context); + stretchAccepted = confirmationNotification.accepted; + } + lastNotification = notification; + return false; + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ); + }, + ), + ), + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator hide animation + expect(refreshCalled, true); + expect(stretchAccepted, false); + }); + + group('RefreshIndicator.noSpinner', () { + testWidgets('onStatusChange and onRefresh Trigger', (WidgetTester tester) async { + refreshCalled = false; + var modeSnap = false; + var modeDrag = false; + var modeArmed = false; + var modeDone = false; + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator.noSpinner( + onStatusChange: (RefreshIndicatorStatus? mode) { + if (mode == RefreshIndicatorStatus.armed) { + modeArmed = true; + } + if (mode == RefreshIndicatorStatus.drag) { + modeDrag = true; + } + if (mode == RefreshIndicatorStatus.snap) { + modeSnap = true; + } + if (mode == RefreshIndicatorStatus.done) { + modeDone = true; + } + }, + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map<Widget>((String item) { + return SizedBox(height: 200.0, child: Text(item)); + }).toList(), + ), + ), + ), + ); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + // Finish the scroll animation. + await tester.pump(const Duration(seconds: 1)); + + // Finish the indicator settle animation. + await tester.pump(const Duration(seconds: 1)); + + // Finish the indicator hide animation. + await tester.pump(const Duration(seconds: 1)); + + expect(refreshCalled, true); + expect(modeSnap, true); + expect(modeDrag, true); + expect(modeArmed, true); + expect(modeDone, true); + }); + }); + + testWidgets('RefreshIndicator manipulates value color opacity correctly', ( + WidgetTester tester, + ) async { + final colors = <Color>[ + Colors.black, + Colors.black54, + Colors.white, + Colors.white54, + Colors.transparent, + ]; + const positions = <double>[50.0, 100.0, 150.0]; + + Future<void> testColor(Color color) async { + final positionController = AnimationController(vsync: const TestVSync()); + addTearDown(positionController.dispose); + // Correspond to [_setupColorTween]. + final Animation<Color?> valueColorAnimation = positionController.drive( + ColorTween(begin: color.withAlpha(0), end: color.withAlpha(color.alpha)).chain( + CurveTween( + // Correspond to [_kDragSizeFactorLimit]. + curve: const Interval(0.0, 1.0 / 1.5), + ), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + color: color, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[Text('X')], + ), + ), + ), + ); + + RefreshProgressIndicator getIndicator() { + return tester.widget<RefreshProgressIndicator>(find.byType(RefreshProgressIndicator)); + } + + // Correspond to [_kDragContainerExtentPercentage]. + final double maxPosition = + tester.view.physicalSize.height / tester.view.devicePixelRatio * 0.25; + for (final position in positions) { + await tester.fling(find.text('X'), Offset(0.0, position), 1.0); + await tester.pump(); + positionController.value = position / maxPosition; + expect(getIndicator().valueColor!.value!.alpha, valueColorAnimation.value!.alpha); + // Wait until the fling finishes before starting the next fling. + await tester.pumpAndSettle(); + } + } + + for (final color in colors) { + await testColor(color); + } + }); + + testWidgets('RefreshIndicator passes the default elevation through correctly', ( + WidgetTester tester, + ) async { + final positionController = AnimationController(vsync: const TestVSync()); + addTearDown(positionController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[Text('X')], + ), + ), + ), + ); + + final double maxPosition = + tester.view.physicalSize.height / tester.view.devicePixelRatio * 0.25; + const position = 50.0; + await tester.fling(find.text('X'), const Offset(0.0, position), 1.0); + await tester.pump(); + positionController.value = position / maxPosition; + expect( + tester.widget<RefreshProgressIndicator>(find.byType(RefreshProgressIndicator)).elevation, + 2.0, + ); + }); + + testWidgets('RefreshIndicator passes custom elevation values through correctly', ( + WidgetTester tester, + ) async { + for (final elevation in <double>[0.0, 2.0]) { + final positionController = AnimationController(vsync: const TestVSync()); + addTearDown(positionController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + elevation: elevation, + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Widget>[Text('X')], + ), + ), + ), + ); + + final double maxPosition = + tester.view.physicalSize.height / tester.view.devicePixelRatio * 0.25; + const position = 50.0; + await tester.fling(find.text('X'), const Offset(0.0, position), 1.0); + await tester.pump(); + positionController.value = position / maxPosition; + expect( + tester.widget<RefreshProgressIndicator>(find.byType(RefreshProgressIndicator)).elevation, + elevation, + ); + await tester.pumpAndSettle(); + } + }); + + testWidgets('RefreshIndicator does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const <Text>[Text('X')], + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(RefreshIndicator)), Size.zero); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Center))); + await gesture.moveBy(const Offset(0.0, 20.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/material_ui/test/material/reorderable_list_test.dart b/packages/material_ui/test/material/reorderable_list_test.dart new file mode 100644 index 000000000000..4ecce4bfb609 --- /dev/null +++ b/packages/material_ui/test/material/reorderable_list_test.dart @@ -0,0 +1,2803 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('$ReorderableListView', () { + const itemHeight = 48.0; + const originalListItems = <String>['Item 1', 'Item 2', 'Item 3', 'Item 4']; + late List<String> listItems; + + void onReorderItem(int oldIndex, int newIndex) { + final String element = listItems.removeAt(oldIndex); + listItems.insert(newIndex, element); + } + + Widget listItemToWidget(String listItem) { + return SizedBox( + key: Key(listItem), + height: itemHeight, + width: itemHeight, + child: Text(listItem), + ); + } + + Widget build({ + Widget? header, + Widget? footer, + Axis scrollDirection = Axis.vertical, + bool reverse = false, + EdgeInsets padding = EdgeInsets.zero, + TextDirection textDirection = TextDirection.ltr, + TargetPlatform? platform, + }) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Directionality( + textDirection: textDirection, + child: SizedBox.square( + dimension: itemHeight * 10, + child: ReorderableListView( + header: header, + footer: footer, + scrollDirection: scrollDirection, + onReorderItem: onReorderItem, + reverse: reverse, + padding: padding, + children: listItems.map<Widget>(listItemToWidget).toList(), + ), + ), + ), + ); + } + + setUp(() { + // Copy the original list into listItems. + listItems = originalListItems.toList(); + }); + + group('in vertical mode', () { + testWidgets('reorder is not triggered when children length is less or equals to 1', ( + WidgetTester tester, + ) async { + var onReorderWasCalled = false; + final List<String> currentListItems = listItems.take(1).toList(); + final reorderableListView = ReorderableListView( + header: const Text('Header'), + onReorderItem: (_, _) => onReorderWasCalled = true, + children: currentListItems.map<Widget>(listItemToWidget).toList(), + ); + final List<String> currentOriginalListItems = originalListItems.take(1).toList(); + await tester.pumpWidget( + MaterialApp( + home: SizedBox(height: itemHeight * 10, child: reorderableListView), + ), + ); + expect(currentListItems, orderedEquals(currentOriginalListItems)); + final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + expect(currentListItems, orderedEquals(currentOriginalListItems)); + await drag.moveTo(tester.getBottomLeft(find.text('Item 1')) * 2); + expect(currentListItems, orderedEquals(currentOriginalListItems)); + await drag.up(); + expect(onReorderWasCalled, false); + expect(currentListItems, orderedEquals(<String>['Item 1'])); + }); + + testWidgets('reorders its contents only when a drag finishes', (WidgetTester tester) async { + await tester.pumpWidget(build()); + expect(listItems, orderedEquals(originalListItems)); + final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + expect(listItems, orderedEquals(originalListItems)); + await drag.moveTo(tester.getCenter(find.text('Item 4'))); + expect(listItems, orderedEquals(originalListItems)); + await drag.up(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 1', 'Item 4'])); + }); + + testWidgets('allows reordering from the very top to the very bottom', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(build()); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 1')), + tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), + ); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + }); + + testWidgets('allows reordering from the very bottom to the very top', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(build()); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 4')), + tester.getCenter(find.text('Item 1')), + ); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); + }); + + testWidgets('allows reordering inside the middle of the widget', (WidgetTester tester) async { + await tester.pumpWidget(build()); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 3')), + tester.getCenter(find.text('Item 2')), + ); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4'])); + }); + + testWidgets('properly reorders with a header', (WidgetTester tester) async { + await tester.pumpWidget(build(header: const Text('Header Text'))); + expect(find.text('Header Text'), findsOneWidget); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 1')), + tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), + ); + await tester.pumpAndSettle(); + expect(find.text('Header Text'), findsOneWidget); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + }); + + testWidgets('properly reorders with a footer', (WidgetTester tester) async { + await tester.pumpWidget(build(footer: const Text('Footer Text'))); + expect(find.text('Footer Text'), findsOneWidget); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 1')), + tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), + ); + await tester.pumpAndSettle(); + expect(find.text('Footer Text'), findsOneWidget); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + }); + + testWidgets('properly determines the vertical drop area extents', ( + WidgetTester tester, + ) async { + final Widget reorderableListView = ReorderableListView( + onReorderItem: (_, _) {}, + children: const <Widget>[ + SizedBox(key: Key('Normal item'), height: itemHeight, child: Text('Normal item')), + SizedBox(key: Key('Tall item'), height: itemHeight * 2, child: Text('Tall item')), + SizedBox(key: Key('Last item'), height: itemHeight, child: Text('Last item')), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: SizedBox(height: itemHeight * 10, child: reorderableListView), + ), + ); + + double getListHeight() { + final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList)); + return listScrollView.geometry!.maxPaintExtent; + } + + const double kDraggingListHeight = 4 * itemHeight; + // Drag a normal text item + expect(getListHeight(), kDraggingListHeight); + TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + expect(getListHeight(), kDraggingListHeight); + + // Move it + await drag.moveTo(tester.getCenter(find.text('Last item'))); + await tester.pumpAndSettle(); + expect(getListHeight(), kDraggingListHeight); + + // Drop it + await drag.up(); + await tester.pumpAndSettle(); + expect(getListHeight(), kDraggingListHeight); + + // Drag a tall item + drag = await tester.startGesture(tester.getCenter(find.text('Tall item'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + expect(getListHeight(), kDraggingListHeight); + // Move it + await drag.moveTo(tester.getCenter(find.text('Last item'))); + await tester.pumpAndSettle(); + expect(getListHeight(), kDraggingListHeight); + + // Drop it + await drag.up(); + await tester.pumpAndSettle(); + expect(getListHeight(), kDraggingListHeight); + }); + + testWidgets('Vertical drag in progress golden image', (WidgetTester tester) async { + debugDisableShadows = false; + final Widget reorderableListView = ReorderableListView( + children: <Widget>[ + Container( + key: const Key('pink'), + width: double.infinity, + height: itemHeight, + color: Colors.pink, + ), + Container( + key: const Key('blue'), + width: double.infinity, + height: itemHeight, + color: Colors.blue, + ), + Container( + key: const Key('green'), + width: double.infinity, + height: itemHeight, + color: Colors.green, + ), + ], + onReorderItem: (_, _) {}, + ); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Container( + color: Colors.white, + height: itemHeight * 3, + // Wrap in an overlay so that the golden image includes the dragged item. + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + // Wrap the list in padding to test that the positioning + // is correct when the origin of the overlay is different + // from the list. + return Padding(padding: const EdgeInsets.all(24), child: reorderableListView); + }, + ), + ], + ), + ), + ), + ); + + // Start dragging the second item. + final TestGesture drag = await tester.startGesture( + tester.getCenter(find.byKey(const Key('blue'))), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + + // Drag it up to be partially over the top item. + await drag.moveBy(const Offset(0, -itemHeight / 3)); + await tester.pumpAndSettle(); + + // Should be an image of the second item overlapping the bottom of the + // first with a gap between the first and third and a drop shadow on + // the dragged item. + await expectLater( + find.byType(Overlay).last, + matchesGoldenFile('reorderable_list_test.vertical.drop_area.png'), + ); + debugDisableShadows = true; + }); + + testWidgets('Preserves children states when the list parent changes the order', ( + WidgetTester tester, + ) async { + _StatefulState findState(Key key) { + return find + .byElementPredicate( + (Element element) => element.findAncestorWidgetOfExactType<_Stateful>()?.key == key, + ) + .evaluate() + .first + .findAncestorStateOfType<_StatefulState>()!; + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView( + children: <Widget>[ + _Stateful(key: const Key('A')), + _Stateful(key: const Key('B')), + _Stateful(key: const Key('C')), + ], + onReorderItem: (_, _) {}, + ), + ), + ); + await tester.tap(find.byKey(const Key('A'))); + await tester.pumpAndSettle(); + // Only the 'A' widget should be checked. + expect(findState(const Key('A')).checked, true); + expect(findState(const Key('B')).checked, false); + expect(findState(const Key('C')).checked, false); + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView( + children: <Widget>[ + _Stateful(key: const Key('B')), + _Stateful(key: const Key('C')), + _Stateful(key: const Key('A')), + ], + onReorderItem: (_, _) {}, + ), + ), + ); + // Only the 'A' widget should be checked. + expect(findState(const Key('B')).checked, false); + expect(findState(const Key('C')).checked, false); + expect(findState(const Key('A')).checked, true); + }); + + testWidgets('Preserves children states when rebuilt', (WidgetTester tester) async { + const firstBox = Key('key'); + Widget build() { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100.0, + child: ReorderableListView( + children: const <Widget>[SizedBox(key: firstBox, width: 10, height: 10)], + onReorderItem: (_, _) {}, + ), + ), + ), + ); + } + + // When the widget is rebuilt, the state of child should be consistent. + await tester.pumpWidget(build()); + final Element e0 = tester.element(find.byKey(firstBox)); + await tester.pumpWidget(build()); + final Element e1 = tester.element(find.byKey(firstBox)); + expect(e0, equals(e1)); + }); + + testWidgets('Uses the PrimaryScrollController when available', (WidgetTester tester) async { + final primary = ScrollController(); + addTearDown(primary.dispose); + final Widget reorderableList = ReorderableListView( + children: const <Widget>[ + SizedBox(width: 100.0, height: 100.0, key: Key('C'), child: Text('C')), + SizedBox(width: 100.0, height: 100.0, key: Key('B'), child: Text('B')), + SizedBox(width: 100.0, height: 100.0, key: Key('A'), child: Text('A')), + ], + onReorderItem: (_, _) {}, + ); + + Widget buildWithScrollController(ScrollController controller) { + return MaterialApp( + home: PrimaryScrollController( + controller: controller, + child: SizedBox(height: 100.0, width: 100.0, child: reorderableList), + ), + ); + } + + await tester.pumpWidget(buildWithScrollController(primary)); + Scrollable scrollView = tester.widget(find.byType(Scrollable)); + expect(scrollView.controller, primary); + + // Now try changing the primary scroll controller and checking that the scroll view gets updated. + final primary2 = ScrollController(); + addTearDown(primary2.dispose); + + await tester.pumpWidget(buildWithScrollController(primary2)); + scrollView = tester.widget(find.byType(Scrollable)); + expect(scrollView.controller, primary2); + }); + + testWidgets('Test custom ScrollController behavior when set', (WidgetTester tester) async { + const firstBox = Key('C'); + const secondBox = Key('B'); + const thirdBox = Key('A'); + final customController = ScrollController(); + addTearDown(customController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 150, + child: ReorderableListView( + scrollController: customController, + onReorderItem: (_, _) {}, + children: const <Widget>[ + SizedBox(width: 100.0, height: 100.0, key: firstBox, child: Text('C')), + SizedBox(width: 100.0, height: 100.0, key: secondBox, child: Text('B')), + SizedBox(width: 100.0, height: 100.0, key: thirdBox, child: Text('A')), + ], + ), + ), + ), + ), + ); + + // Check initial scroll offset of first list item relative to + // the offset of the list view. + customController.animateTo( + 40.0, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + Offset listViewTopLeft = tester.getTopLeft(find.byType(ReorderableListView)); + Offset firstBoxTopLeft = tester.getTopLeft(find.byKey(firstBox)); + expect(firstBoxTopLeft.dy, listViewTopLeft.dy - 40.0); + + // Drag the UI to see if the scroll controller updates accordingly + await tester.drag(find.text('B'), const Offset(0.0, -100.0)); + listViewTopLeft = tester.getTopLeft(find.byType(ReorderableListView)); + firstBoxTopLeft = tester.getTopLeft(find.byKey(firstBox)); + // Initial scroll controller offset: 40.0 + // Drag UI by 100.0 upwards vertically + // First 20.0 px always ignored, so scroll offset is only + // shifted by 80.0. + // Final offset: 40.0 + 80.0 = 120.0 + // The total distance available to scroll is 300.0 - 150.0 = 150.0, or + // height of the ReorderableListView minus height of the SizedBox. Since + // The final offset is less than this, it's not limited. + expect(customController.offset, 120.0); + }); + + testWidgets('ReorderableList auto scrolling is fast enough', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/121603. + final controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReorderableListView.builder( + scrollController: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('data', key: ValueKey<int>(index)); + }, + onReorderItem: (_, _) {}, + ), + ), + ), + ); + + // Start gesture on first item. + final TestGesture drag = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey<int>(0))), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + final Offset bottomRight = tester.getBottomRight(find.byType(ReorderableListView)); + // Drag enough for move to start. + await drag.moveTo(Offset(bottomRight.dx / 2, bottomRight.dy)); + await tester.pump(); + // Use a fixed value to make sure the default velocity scalar is bigger + // than a certain amount. + const kMinimumAllowedAutoScrollDistancePer5ms = 1.7; + + await tester.pump(const Duration(milliseconds: 5)); + expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms)); + await tester.pump(const Duration(milliseconds: 5)); + expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms * 2)); + await tester.pump(const Duration(milliseconds: 5)); + expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms * 3)); + await tester.pump(const Duration(milliseconds: 5)); + expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms * 4)); + }); + + testWidgets('Still builds when no PrimaryScrollController is available', ( + WidgetTester tester, + ) async { + final Widget reorderableList = ReorderableListView( + children: const <Widget>[ + SizedBox(width: 100.0, height: 100.0, key: Key('C'), child: Text('C')), + SizedBox(width: 100.0, height: 100.0, key: Key('B'), child: Text('B')), + SizedBox(width: 100.0, height: 100.0, key: Key('A'), child: Text('A')), + ], + onReorderItem: (_, _) {}, + ); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + final Widget overlay = Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry(builder: (BuildContext context) => reorderableList), + ], + ); + final Widget boilerplate = Localizations( + locale: const Locale('en'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: SizedBox.square( + dimension: 100.0, + child: Directionality(textDirection: TextDirection.ltr, child: overlay), + ), + ); + await expectLater(() => tester.pumpWidget(boilerplate), returnsNormally); + }); + + group('Accessibility (a11y/Semantics)', () { + Map<CustomSemanticsAction, VoidCallback> getSemanticsActions(int index) { + final semantics = + find + .ancestor( + of: find.byKey(Key(listItems[index])), + matching: find.byType(Semantics), + ) + .evaluate() + .first + .widget + as Semantics; + return semantics.properties.customSemanticsActions!; + } + + const moveToStart = CustomSemanticsAction(label: 'Move to the start'); + const moveToEnd = CustomSemanticsAction(label: 'Move to the end'); + const moveUp = CustomSemanticsAction(label: 'Move up'); + const moveDown = CustomSemanticsAction(label: 'Move down'); + + testWidgets('Provides the correct accessibility actions in LTR and RTL modes', ( + WidgetTester tester, + ) async { + // The a11y actions for a vertical list are the same in LTR and RTL modes. + final SemanticsHandle handle = tester.ensureSemantics(); + for (final TextDirection direction in TextDirection.values) { + await tester.pumpWidget(build()); + + // The first item can be moved down or to the end. + final Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = + getSemanticsActions(0); + expect( + firstSemanticsActions.length, + 2, + reason: 'The first list item should have 2 custom actions with $direction.', + ); + expect( + firstSemanticsActions.containsKey(moveToStart), + false, + reason: 'The first item cannot `Move to the start` with $direction.', + ); + expect( + firstSemanticsActions.containsKey(moveUp), + false, + reason: 'The first item cannot `Move up` with $direction.', + ); + expect( + firstSemanticsActions.containsKey(moveDown), + true, + reason: 'The first item should be able to `Move down` with $direction.', + ); + expect( + firstSemanticsActions.containsKey(moveToEnd), + true, + reason: 'The first item should be able to `Move to the end` with $direction.', + ); + + // Items in the middle can be moved to the start, end, up or down. + for (var i = 1; i < listItems.length - 1; i += 1) { + final Map<CustomSemanticsAction, VoidCallback> ithSemanticsActions = + getSemanticsActions(i); + expect( + ithSemanticsActions.length, + 4, + reason: 'List item $i should have 4 custom actions with $direction.', + ); + expect( + ithSemanticsActions.containsKey(moveToStart), + true, + reason: 'List item $i should be able to `Move to the start` with $direction.', + ); + expect( + ithSemanticsActions.containsKey(moveUp), + true, + reason: 'List item $i should be able to `Move up` with $direction.', + ); + expect( + ithSemanticsActions.containsKey(moveDown), + true, + reason: 'List item $i should be able to `Move down` with $direction.', + ); + expect( + ithSemanticsActions.containsKey(moveToEnd), + true, + reason: 'List item $i should be able to `Move to the end` with $direction.', + ); + } + + // The last item can be moved up or to the start. + final Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = + getSemanticsActions(listItems.length - 1); + expect( + lastSemanticsActions.length, + 2, + reason: 'The last list item should have 2 custom actions with $direction.', + ); + expect( + lastSemanticsActions.containsKey(moveToStart), + true, + reason: 'The last item should be able to `Move to the start` with $direction.', + ); + expect( + lastSemanticsActions.containsKey(moveUp), + true, + reason: 'The last item should be able to `Move up` with $direction.', + ); + expect( + lastSemanticsActions.containsKey(moveDown), + false, + reason: 'The last item cannot `Move down` with $direction.', + ); + expect( + lastSemanticsActions.containsKey(moveToEnd), + false, + reason: 'The last item cannot `Move to the end` with $direction.', + ); + } + handle.dispose(); + }); + + testWidgets('First item accessibility (a11y) actions work', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to end: move Item 1 to the end of the list. + await tester.pumpWidget(build()); + Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); + firstSemanticsActions[moveToEnd]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + + // Test out move after: move Item 2 (the current first item) one space down. + await tester.pumpWidget(build()); + firstSemanticsActions = getSemanticsActions(0); + firstSemanticsActions[moveDown]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1'])); + + handle.dispose(); + }); + + testWidgets('Middle item accessibility (a11y) actions work', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to end: move Item 2 to the end of the list. + await tester.pumpWidget(build()); + Map<CustomSemanticsAction, VoidCallback> middleSemanticsActions = getSemanticsActions(1); + middleSemanticsActions[moveToEnd]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); + + // Test out move after: move Item 3 (the current second item) one space down. + await tester.pumpWidget(build()); + middleSemanticsActions = getSemanticsActions(1); + middleSemanticsActions[moveDown]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 4', 'Item 3', 'Item 2'])); + + // Test out move after: move Item 3 (the current third item) one space up. + await tester.pumpWidget(build()); + middleSemanticsActions = getSemanticsActions(2); + middleSemanticsActions[moveUp]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); + + // Test out move to start: move Item 4 (the current third item) to the start of the list. + await tester.pumpWidget(build()); + middleSemanticsActions = getSemanticsActions(2); + middleSemanticsActions[moveToStart]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); + + handle.dispose(); + }); + + testWidgets('Last item accessibility (a11y) actions work', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to start: move Item 4 to the start of the list. + await tester.pumpWidget(build()); + Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions( + listItems.length - 1, + ); + lastSemanticsActions[moveToStart]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); + + // Test out move up: move Item 3 (the current last item) one space up. + await tester.pumpWidget(build()); + lastSemanticsActions = getSemanticsActions(listItems.length - 1); + lastSemanticsActions[moveUp]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); + + handle.dispose(); + }); + + testWidgets("Doesn't hide accessibility when a child declares its own semantics", ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final Widget reorderableListView = ReorderableListView( + onReorderItem: (_, _) {}, + children: <Widget>[ + const SizedBox( + key: Key('List tile 1'), + height: itemHeight, + child: Text('List tile 1'), + ), + SizedBox( + key: const Key('Switch tile'), + height: itemHeight, + child: Material( + child: SwitchListTile( + title: const Text('Switch tile'), + value: true, + onChanged: (bool? newValue) {}, + internalAddSemanticForOnTap: true, + ), + ), + ), + const SizedBox( + key: Key('List tile 2'), + height: itemHeight, + child: Text('List tile 2'), + ), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: SizedBox(height: itemHeight * 10, child: reorderableListView), + ), + ); + + // Get the switch tile's semantics: + final SemanticsNode semanticsNode = tester.getSemantics( + find.byKey(const Key('Switch tile')), + ); + + // Check for ReorderableListView custom semantics actions. + expect( + semanticsNode, + matchesSemantics( + customActions: const <CustomSemanticsAction>[ + CustomSemanticsAction(label: 'Move up'), + CustomSemanticsAction(label: 'Move down'), + CustomSemanticsAction(label: 'Move to the end'), + CustomSemanticsAction(label: 'Move to the start'), + ], + ), + ); + + // Check for properties of SwitchTile semantics. + late SemanticsNode child; + semanticsNode.visitChildren((SemanticsNode node) { + child = node; + return false; + }); + expect( + child, + matchesSemantics( + isButton: true, + hasToggledState: true, + isToggled: true, + isEnabled: true, + isFocusable: true, + hasEnabledState: true, + hasSelectedState: true, + label: 'Switch tile', + hasTapAction: true, + hasFocusAction: true, + ), + ); + handle.dispose(); + }); + }); + }); + + group('in horizontal mode', () { + testWidgets('reorder is not triggered when children length is less or equals to 1', ( + WidgetTester tester, + ) async { + var onReorderWasCalled = false; + final List<String> currentListItems = listItems.take(1).toList(); + final reorderableListView = ReorderableListView( + header: const Text('Header'), + scrollDirection: Axis.horizontal, + onReorderItem: (_, _) => onReorderWasCalled = true, + children: currentListItems.map<Widget>(listItemToWidget).toList(), + ); + final List<String> currentOriginalListItems = originalListItems.take(1).toList(); + await tester.pumpWidget( + MaterialApp( + home: SizedBox(height: itemHeight * 10, child: reorderableListView), + ), + ); + expect(currentListItems, orderedEquals(currentOriginalListItems)); + final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + expect(currentListItems, orderedEquals(currentOriginalListItems)); + await drag.moveTo(tester.getBottomLeft(find.text('Item 1')) * 2); + expect(currentListItems, orderedEquals(currentOriginalListItems)); + await drag.up(); + expect(onReorderWasCalled, false); + expect(currentListItems, orderedEquals(<String>['Item 1'])); + }); + + testWidgets('allows reordering from the very top to the very bottom', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 1')), + tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), + ); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + }); + + testWidgets('allows reordering from the very bottom to the very top', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 4')), + tester.getCenter(find.text('Item 1')), + ); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); + }); + + testWidgets('allows reordering inside the middle of the widget', (WidgetTester tester) async { + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 3')), + tester.getCenter(find.text('Item 2')), + ); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4'])); + }); + + testWidgets('properly reorders with a header', (WidgetTester tester) async { + await tester.pumpWidget( + build(header: const Text('Header Text'), scrollDirection: Axis.horizontal), + ); + expect(find.text('Header Text'), findsOneWidget); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 1')), + tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), + ); + await tester.pumpAndSettle(); + expect(find.text('Header Text'), findsOneWidget); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + await tester.pumpWidget( + build(header: const Text('Header Text'), scrollDirection: Axis.horizontal), + ); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 4')), + tester.getCenter(find.text('Item 3')), + ); + await tester.pumpAndSettle(); + expect(find.text('Header Text'), findsOneWidget); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1'])); + }); + + testWidgets('properly reorders with a footer', (WidgetTester tester) async { + await tester.pumpWidget( + build(footer: const Text('Footer Text'), scrollDirection: Axis.horizontal), + ); + expect(find.text('Footer Text'), findsOneWidget); + expect(listItems, orderedEquals(originalListItems)); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 1')), + tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), + ); + await tester.pumpAndSettle(); + expect(find.text('Footer Text'), findsOneWidget); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + await tester.pumpWidget( + build(footer: const Text('Footer Text'), scrollDirection: Axis.horizontal), + ); + await longPressDrag( + tester, + tester.getCenter(find.text('Item 4')), + tester.getCenter(find.text('Item 3')), + ); + await tester.pumpAndSettle(); + expect(find.text('Footer Text'), findsOneWidget); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1'])); + }); + + testWidgets('properly determines the horizontal drop area extents', ( + WidgetTester tester, + ) async { + final Widget reorderableListView = ReorderableListView( + scrollDirection: Axis.horizontal, + onReorderItem: (_, _) {}, + children: const <Widget>[ + SizedBox(key: Key('Normal item'), width: itemHeight, child: Text('Normal item')), + SizedBox(key: Key('Tall item'), width: itemHeight * 2, child: Text('Tall item')), + SizedBox(key: Key('Last item'), width: itemHeight, child: Text('Last item')), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: SizedBox(width: itemHeight * 10, child: reorderableListView), + ), + ); + + double getListWidth() { + final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList)); + return listScrollView.geometry!.maxPaintExtent; + } + + const double kDraggingListWidth = 4 * itemHeight; + // Drag a normal text item + expect(getListWidth(), kDraggingListWidth); + TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + expect(getListWidth(), kDraggingListWidth); + + // Move it + await drag.moveTo(tester.getCenter(find.text('Last item'))); + await tester.pumpAndSettle(); + expect(getListWidth(), kDraggingListWidth); + + // Drop it + await drag.up(); + await tester.pumpAndSettle(); + expect(getListWidth(), kDraggingListWidth); + + // Drag a tall item + drag = await tester.startGesture(tester.getCenter(find.text('Tall item'))); + await tester.pump(kLongPressTimeout + kPressTimeout); + await tester.pumpAndSettle(); + expect(getListWidth(), kDraggingListWidth); + // Move it + await drag.moveTo(tester.getCenter(find.text('Last item'))); + await tester.pumpAndSettle(); + expect(getListWidth(), kDraggingListWidth); + + // Drop it + await drag.up(); + await tester.pumpAndSettle(); + expect(getListWidth(), kDraggingListWidth); + }); + + testWidgets('Horizontal drag in progress golden image', (WidgetTester tester) async { + debugDisableShadows = false; + final Widget reorderableListView = ReorderableListView( + scrollDirection: Axis.horizontal, + onReorderItem: (_, _) {}, + children: <Widget>[ + Container( + key: const Key('pink'), + height: double.infinity, + width: itemHeight, + color: Colors.pink, + ), + Container( + key: const Key('blue'), + height: double.infinity, + width: itemHeight, + color: Colors.blue, + ), + Container( + key: const Key('green'), + height: double.infinity, + width: itemHeight, + color: Colors.green, + ), + ], + ); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Container( + color: Colors.white, + width: itemHeight * 3, + // Wrap in an overlay so that the golden image includes the dragged item. + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + // Wrap the list in padding to test that the positioning + // is correct when the origin of the overlay is different + // from the list. + return Padding(padding: const EdgeInsets.all(24), child: reorderableListView); + }, + ), + ], + ), + ), + ), + ); + + // Start dragging the second item. + final TestGesture drag = await tester.startGesture( + tester.getCenter(find.byKey(const Key('blue'))), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + + // Drag it left to be partially over the first item. + await drag.moveBy(const Offset(-itemHeight / 3, 0)); + await tester.pumpAndSettle(); + + // Should be an image of the second item overlapping the right of the + // first with a gap between the first and third and a drop shadow on + // the dragged item. + await expectLater( + find.byType(Overlay).last, + matchesGoldenFile('reorderable_list_test.horizontal.drop_area.png'), + ); + debugDisableShadows = true; + }); + + testWidgets('Preserves children states when the list parent changes the order', ( + WidgetTester tester, + ) async { + _StatefulState findState(Key key) { + return find + .byElementPredicate( + (Element element) => element.findAncestorWidgetOfExactType<_Stateful>()?.key == key, + ) + .evaluate() + .first + .findAncestorStateOfType<_StatefulState>()!; + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView( + onReorderItem: (_, _) {}, + scrollDirection: Axis.horizontal, + children: <Widget>[ + _Stateful(key: const Key('A')), + _Stateful(key: const Key('B')), + _Stateful(key: const Key('C')), + ], + ), + ), + ); + await tester.tap(find.byKey(const Key('A'))); + await tester.pumpAndSettle(); + // Only the 'A' widget should be checked. + expect(findState(const Key('A')).checked, true); + expect(findState(const Key('B')).checked, false); + expect(findState(const Key('C')).checked, false); + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView( + onReorderItem: (_, _) {}, + scrollDirection: Axis.horizontal, + children: <Widget>[ + _Stateful(key: const Key('B')), + _Stateful(key: const Key('C')), + _Stateful(key: const Key('A')), + ], + ), + ), + ); + // Only the 'A' widget should be checked. + expect(findState(const Key('B')).checked, false); + expect(findState(const Key('C')).checked, false); + expect(findState(const Key('A')).checked, true); + }); + + testWidgets('Preserves children states when rebuilt', (WidgetTester tester) async { + const firstBox = Key('key'); + Widget build() { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100.0, + child: ReorderableListView( + scrollDirection: Axis.horizontal, + children: const <Widget>[SizedBox(key: firstBox, width: 10, height: 10)], + onReorderItem: (_, _) {}, + ), + ), + ), + ); + } + + // When the widget is rebuilt, the state of child should be consistent. + await tester.pumpWidget(build()); + final Element e0 = tester.element(find.byKey(firstBox)); + await tester.pumpWidget(build()); + final Element e1 = tester.element(find.byKey(firstBox)); + expect(e0, equals(e1)); + }); + + group('Accessibility (a11y/Semantics)', () { + Map<CustomSemanticsAction, VoidCallback> getSemanticsActions(int index) { + final semantics = + find + .ancestor( + of: find.byKey(Key(listItems[index])), + matching: find.byType(Semantics), + ) + .evaluate() + .first + .widget + as Semantics; + return semantics.properties.customSemanticsActions!; + } + + const moveToStart = CustomSemanticsAction(label: 'Move to the start'); + const moveToEnd = CustomSemanticsAction(label: 'Move to the end'); + const moveLeft = CustomSemanticsAction(label: 'Move left'); + const moveRight = CustomSemanticsAction(label: 'Move right'); + + testWidgets('Provides the correct accessibility actions in LTR mode', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + + // The first item can be moved right or to the end. + final Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = + getSemanticsActions(0); + expect( + firstSemanticsActions.length, + 2, + reason: 'The first list item should have 2 custom actions.', + ); + expect( + firstSemanticsActions.containsKey(moveToStart), + false, + reason: 'The first item cannot `Move to the start`.', + ); + expect( + firstSemanticsActions.containsKey(moveLeft), + false, + reason: 'The first item cannot `Move left`.', + ); + expect( + firstSemanticsActions.containsKey(moveRight), + true, + reason: 'The first item should be able to `Move right`.', + ); + expect( + firstSemanticsActions.containsKey(moveToEnd), + true, + reason: 'The first item should be able to `Move to the end`.', + ); + + // Items in the middle can be moved to the start, end, left or right. + for (var i = 1; i < listItems.length - 1; i += 1) { + final Map<CustomSemanticsAction, VoidCallback> ithSemanticsActions = + getSemanticsActions(i); + expect( + ithSemanticsActions.length, + 4, + reason: 'List item $i should have 4 custom actions.', + ); + expect( + ithSemanticsActions.containsKey(moveToStart), + true, + reason: 'List item $i should be able to `Move to the start`.', + ); + expect( + ithSemanticsActions.containsKey(moveLeft), + true, + reason: 'List item $i should be able to `Move left`.', + ); + expect( + ithSemanticsActions.containsKey(moveRight), + true, + reason: 'List item $i should be able to `Move right`.', + ); + expect( + ithSemanticsActions.containsKey(moveToEnd), + true, + reason: 'List item $i should be able to `Move to the end`.', + ); + } + + // The last item can be moved left or to the start. + final Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions( + listItems.length - 1, + ); + expect( + lastSemanticsActions.length, + 2, + reason: 'The last list item should have 2 custom actions.', + ); + expect( + lastSemanticsActions.containsKey(moveToStart), + true, + reason: 'The last item should be able to `Move to the start`.', + ); + expect( + lastSemanticsActions.containsKey(moveLeft), + true, + reason: 'The last item should be able to `Move left`.', + ); + expect( + lastSemanticsActions.containsKey(moveRight), + false, + reason: 'The last item cannot `Move right`.', + ); + expect( + lastSemanticsActions.containsKey(moveToEnd), + false, + reason: 'The last item cannot `Move to the end`.', + ); + handle.dispose(); + }); + + testWidgets('Provides the correct accessibility actions in Right-To-Left directionality', ( + WidgetTester tester, + ) async { + // In RTL mode, the right is the start and the left is the end. + // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + + // The first item can be moved right or to the end. + final Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = + getSemanticsActions(0); + expect( + firstSemanticsActions.length, + 2, + reason: 'The first list item should have 2 custom actions.', + ); + expect( + firstSemanticsActions.containsKey(moveToStart), + false, + reason: 'The first item cannot `Move to the start`.', + ); + expect( + firstSemanticsActions.containsKey(moveRight), + false, + reason: 'The first item cannot `Move right`.', + ); + expect( + firstSemanticsActions.containsKey(moveLeft), + true, + reason: 'The first item should be able to `Move left`.', + ); + expect( + firstSemanticsActions.containsKey(moveToEnd), + true, + reason: 'The first item should be able to `Move to the end`.', + ); + + // Items in the middle can be moved to the start, end, left or right. + for (var i = 1; i < listItems.length - 1; i += 1) { + final Map<CustomSemanticsAction, VoidCallback> ithSemanticsActions = + getSemanticsActions(i); + expect( + ithSemanticsActions.length, + 4, + reason: 'List item $i should have 4 custom actions.', + ); + expect( + ithSemanticsActions.containsKey(moveToStart), + true, + reason: 'List item $i should be able to `Move to the start`.', + ); + expect( + ithSemanticsActions.containsKey(moveRight), + true, + reason: 'List item $i should be able to `Move right`.', + ); + expect( + ithSemanticsActions.containsKey(moveLeft), + true, + reason: 'List item $i should be able to `Move left`.', + ); + expect( + ithSemanticsActions.containsKey(moveToEnd), + true, + reason: 'List item $i should be able to `Move to the end`.', + ); + } + + // The last item can be moved left or to the start. + final Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions( + listItems.length - 1, + ); + expect( + lastSemanticsActions.length, + 2, + reason: 'The last list item should have 2 custom actions.', + ); + expect( + lastSemanticsActions.containsKey(moveToStart), + true, + reason: 'The last item should be able to `Move to the start`.', + ); + expect( + lastSemanticsActions.containsKey(moveRight), + true, + reason: 'The last item should be able to `Move right`.', + ); + expect( + lastSemanticsActions.containsKey(moveLeft), + false, + reason: 'The last item cannot `Move left`.', + ); + expect( + lastSemanticsActions.containsKey(moveToEnd), + false, + reason: 'The last item cannot `Move to the end`.', + ); + handle.dispose(); + }); + + testWidgets('First item accessibility (a11y) actions work in LTR mode', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to end: move Item 1 to the end of the list. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); + firstSemanticsActions[moveToEnd]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + + // Test out move after: move Item 2 (the current first item) one space to the right. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + firstSemanticsActions = getSemanticsActions(0); + firstSemanticsActions[moveRight]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1'])); + + handle.dispose(); + }); + + testWidgets('First item accessibility (a11y) actions work in Right-To-Left directionality', ( + WidgetTester tester, + ) async { + // In RTL mode, the right is the start and the left is the end. + // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to end: move Item 1 to the end of the list. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); + firstSemanticsActions[moveToEnd]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); + + // Test out move after: move Item 2 (the current first item) one space to the left. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + firstSemanticsActions = getSemanticsActions(0); + firstSemanticsActions[moveLeft]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1'])); + + handle.dispose(); + }); + + testWidgets('Middle item accessibility (a11y) actions work in LTR mode', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to end: move Item 2 to the end of the list. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + Map<CustomSemanticsAction, VoidCallback> middleSemanticsActions = getSemanticsActions(1); + middleSemanticsActions[moveToEnd]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); + + // Test out move after: move Item 3 (the current second item) one space to the right. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + middleSemanticsActions = getSemanticsActions(1); + middleSemanticsActions[moveRight]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 4', 'Item 3', 'Item 2'])); + + // Test out move after: move Item 3 (the current third item) one space to the left. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + middleSemanticsActions = getSemanticsActions(2); + middleSemanticsActions[moveLeft]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); + + // Test out move to start: move Item 4 (the current third item) to the start of the list. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + middleSemanticsActions = getSemanticsActions(2); + middleSemanticsActions[moveToStart]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); + + handle.dispose(); + }); + + testWidgets('Middle item accessibility (a11y) actions work in Right-To-Left directionality', ( + WidgetTester tester, + ) async { + // In RTL mode, the right is the start and the left is the end. + // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to end: move Item 2 to the end of the list. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + Map<CustomSemanticsAction, VoidCallback> middleSemanticsActions = getSemanticsActions(1); + middleSemanticsActions[moveToEnd]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); + + // Test out move after: move Item 3 (the current second item) one space to the left. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + middleSemanticsActions = getSemanticsActions(1); + middleSemanticsActions[moveLeft]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 4', 'Item 3', 'Item 2'])); + + // Test out move after: move Item 3 (the current third item) one space to the right. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + middleSemanticsActions = getSemanticsActions(2); + middleSemanticsActions[moveRight]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); + + // Test out move to start: move Item 4 (the current third item) to the start of the list. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + middleSemanticsActions = getSemanticsActions(2); + middleSemanticsActions[moveToStart]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); + + handle.dispose(); + }); + + testWidgets('Last item accessibility (a11y) actions work in LTR mode', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to start: move Item 4 to the start of the list. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions( + listItems.length - 1, + ); + lastSemanticsActions[moveToStart]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); + + // Test out move before: move Item 3 (the current last item) one space to the left. + await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); + lastSemanticsActions = getSemanticsActions(listItems.length - 1); + lastSemanticsActions[moveLeft]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); + + handle.dispose(); + }); + + testWidgets('Last item accessibility (a11y) actions work in Right-To-Left directionality', ( + WidgetTester tester, + ) async { + // In RTL mode, the right is the start and the left is the end. + // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. + final SemanticsHandle handle = tester.ensureSemantics(); + expect(listItems, orderedEquals(originalListItems)); + + // Test out move to start: move Item 4 to the start of the list. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions( + listItems.length - 1, + ); + lastSemanticsActions[moveToStart]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); + + // Test out move before: move Item 3 (the current last item) one space to the right. + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl), + ); + lastSemanticsActions = getSemanticsActions(listItems.length - 1); + lastSemanticsActions[moveRight]!(); + await tester.pumpAndSettle(); + expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); + + handle.dispose(); + }); + }); + }); + + testWidgets('ReorderableListView.builder asserts on negative childCount', ( + WidgetTester tester, + ) async { + expect( + () => ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return const SizedBox(); + }, + itemCount: -1, + onReorderItem: (_, _) {}, + ), + throwsAssertionError, + ); + }); + + testWidgets('ReorderableListView.builder only creates the children it needs', ( + WidgetTester tester, + ) async { + final itemsCreated = <int>{}; + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + itemsCreated.add(index); + return Text(index.toString(), key: ValueKey<int>(index)); + }, + itemCount: 1000, + onReorderItem: (_, _) {}, + ), + ), + ); + + // Should have only created the first 18 items. + expect(itemsCreated, <int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17}); + }); + + group('Padding', () { + testWidgets('Padding with no header & footer', (WidgetTester tester) async { + const padding = EdgeInsets.fromLTRB(10, 20, 30, 40); + + // Vertical + await tester.pumpWidget(build(padding: padding)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 20, 770, 68), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(10, 164, 770, 212), + ); + + // Horizontal + await tester.pumpWidget(build(padding: padding, scrollDirection: Axis.horizontal)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 20, 58, 560), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(154, 20, 202, 560), + ); + }); + + testWidgets('Padding with header or footer', (WidgetTester tester) async { + const padding = EdgeInsets.fromLTRB(10, 20, 30, 40); + const headerKey = Key('Header'); + const footerKey = Key('Footer'); + const Widget verticalHeader = SizedBox(key: headerKey, height: 10); + const Widget horizontalHeader = SizedBox(key: headerKey, width: 10); + const Widget verticalFooter = SizedBox(key: footerKey, height: 10); + const Widget horizontalFooter = SizedBox(key: footerKey, width: 10); + + // Vertical Header + await tester.pumpWidget(build(padding: padding, header: verticalHeader)); + expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(10, 20, 770, 30)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 30, 770, 78), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(10, 174, 770, 222), + ); + + // Vertical Footer + await tester.pumpWidget(build(padding: padding, footer: verticalFooter)); + expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(10, 212, 770, 222)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 20, 770, 68), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(10, 164, 770, 212), + ); + + // Vertical Header, reversed + await tester.pumpWidget(build(padding: padding, header: verticalHeader, reverse: true)); + expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(10, 550, 770, 560)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 502, 770, 550), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(10, 358, 770, 406), + ); + + // Vertical Footer, reversed + await tester.pumpWidget(build(padding: padding, footer: verticalFooter, reverse: true)); + expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(10, 358, 770, 368)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 512, 770, 560), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(10, 368, 770, 416), + ); + + // Horizontal Header + await tester.pumpWidget( + build(padding: padding, header: horizontalHeader, scrollDirection: Axis.horizontal), + ); + expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(10, 20, 20, 560)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(20, 20, 68, 560), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(164, 20, 212, 560), + ); + + // // Horizontal Footer + await tester.pumpWidget( + build(padding: padding, footer: horizontalFooter, scrollDirection: Axis.horizontal), + ); + expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(202, 20, 212, 560)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(10, 20, 58, 560), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(154, 20, 202, 560), + ); + + // Horizontal Header, reversed + await tester.pumpWidget( + build( + padding: padding, + header: horizontalHeader, + scrollDirection: Axis.horizontal, + reverse: true, + ), + ); + expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(760, 20, 770, 560)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(712, 20, 760, 560), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(568, 20, 616, 560), + ); + + // // Horizontal Footer, reversed + await tester.pumpWidget( + build( + padding: padding, + footer: horizontalFooter, + scrollDirection: Axis.horizontal, + reverse: true, + ), + ); + expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(568, 20, 578, 560)); + expect( + tester.getRect(find.byKey(const Key('Item 1'))), + const Rect.fromLTRB(722, 20, 770, 560), + ); + expect( + tester.getRect(find.byKey(const Key('Item 4'))), + const Rect.fromLTRB(578, 20, 626, 560), + ); + }); + }); + + testWidgets('ReorderableListView can be reversed', (WidgetTester tester) async { + final Widget reorderableListView = ReorderableListView( + reverse: true, + onReorderItem: (_, _) {}, + children: const <Widget>[ + SizedBox(key: Key('A'), child: Text('A')), + SizedBox(key: Key('B'), child: Text('B')), + SizedBox(key: Key('C'), child: Text('C')), + ], + ); + await tester.pumpWidget(MaterialApp(home: reorderableListView)); + expect(tester.getCenter(find.text('A')).dy, greaterThan(tester.getCenter(find.text('B')).dy)); + }); + + testWidgets( + 'ReorderableListView in Flexible with one item does not assert when dragged to edge', + (WidgetTester tester) async { + final items = <String>['Item 1']; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + Flexible( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ReorderableListView( + onReorder: (int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final String item = items.removeAt(oldIndex); + items.insert(newIndex, item); + }); + }, + children: <Widget>[ + ListTile( + key: const ValueKey<String>('Item 1'), + title: Text(items.first), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final Offset startLocation = tester.getCenter(find.byKey(const ValueKey<String>('Item 1'))); + final TestGesture gesture = await tester.startGesture(startLocation); + await tester.pump(); + await gesture.moveTo(tester.getBottomRight(find.byType(Scaffold)) - const Offset(10, 10)); + await tester.pump(const Duration(seconds: 1)); + + expect(tester.takeException(), isNull); + + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Animation test when placing an item in place', (WidgetTester tester) async { + const testItemKey = Key('Test item'); + final Widget reorderableListView = ReorderableListView( + onReorderItem: (_, _) {}, + children: const <Widget>[ + SizedBox(key: Key('First item'), height: itemHeight, child: Text('First item')), + SizedBox(key: testItemKey, height: itemHeight, child: Text('Test item')), + SizedBox(key: Key('Last item'), height: itemHeight, child: Text('Last item')), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: SizedBox(height: itemHeight * 10, child: reorderableListView), + ), + ); + + Offset getTestItemPosition() { + final RenderBox testItem = tester.renderObject<RenderBox>(find.byKey(testItemKey)); + return testItem.localToGlobal(Offset.zero); + } + + // Before pick it up. + final Offset startPosition = getTestItemPosition(); + + // Pick it up. + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byKey(testItemKey)), + ); + await tester.pump(kLongPressTimeout + kPressTimeout); + expect(getTestItemPosition(), startPosition); + + // Put it down. + await gesture.up(); + await tester.pump(); + expect(getTestItemPosition(), startPosition); + + // After put it down. + await tester.pumpAndSettle(); + expect(getTestItemPosition(), startPosition); + }); + // TODO(djshuckerow): figure out how to write a test for scrolling the list. + + testWidgets( + 'ReorderableListView on desktop platforms should have drag handles', + (WidgetTester tester) async { + await tester.pumpWidget(build()); + // All four items should have drag handles and not delayed listeners. + expect(find.byIcon(Icons.drag_handle), findsNWidgets(4)); + expect(find.byType(ReorderableDelayedDragStartListener), findsNothing); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'ReorderableListView on mobile platforms should not have drag handles', + (WidgetTester tester) async { + await tester.pumpWidget(build()); + // All four items should have delayed listeners and not drag handles. + expect(find.byType(ReorderableDelayedDragStartListener), findsNWidgets(4)); + expect(find.byIcon(Icons.drag_handle), findsNothing); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets('Vertical list renders drag handle in correct position', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(build(platform: TargetPlatform.macOS)); + final Finder listView = find.byType(ReorderableListView); + final Finder item1 = find.byKey(const Key('Item 1')); + final Finder dragHandle = find.byIcon(Icons.drag_handle).first; + + // Should be centered vertically within the item and 8 pixels from the right edge of the list. + expect(tester.getCenter(dragHandle).dy, tester.getCenter(item1).dy); + expect(tester.getTopRight(dragHandle).dx, tester.getSize(listView).width - 8); + }); + + testWidgets('Horizontal list renders drag handle in correct position', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + build(scrollDirection: Axis.horizontal, platform: TargetPlatform.macOS), + ); + final Finder listView = find.byType(ReorderableListView); + final Finder item1 = find.byKey(const Key('Item 1')); + final Finder dragHandle = find.byIcon(Icons.drag_handle).first; + + // Should be centered horizontally within the item and 8 pixels from the bottom of the list. + expect(tester.getCenter(dragHandle).dx, tester.getCenter(item1).dx); + expect(tester.getBottomRight(dragHandle).dy, tester.getSize(listView).height - 8); + }); + }); + + testWidgets( + 'ReorderableListView, can deal with the dragged item getting unmounted and rebuilt during drag', + (WidgetTester tester) async { + // See https://github.com/flutter/flutter/issues/74840 for more details. + final items = List<int>.generate(100, (int index) => index); + + void handleReorder(int fromIndex, int toIndex) { + items.insert(toIndex, items.removeAt(fromIndex)); + } + + // The list is 800x600, 8 items, each item is 800x100 with + // an "item $index" text widget at the item's origin. Drags are initiated by + // a simple press on the text widget. + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(items[index]), + height: 100, + child: ReorderableDragStartListener( + index: index, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[Text('item ${items[index]}')], + ), + ), + ); + }, + buildDefaultDragHandles: false, + itemCount: items.length, + onReorderItem: handleReorder, + ), + ), + ); + + // Drag item 0 downwards and force an auto scroll off the end of the list + // far enough that item zeros original entry in the list is unmounted. + final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + // Off the bottom of the screen, which should autoscroll until we hit the + // end of the list + await drag.moveBy(const Offset(0, 700)); + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + // Ensure we made it to the bottom (only 4 should be showing as there should + // be a gap at the end for the drop area of the dragged item. + for (final i in <int>[95, 96, 97, 98, 99]) { + expect(find.text('item $i'), findsOneWidget); + } + + // Drag back to off the top of the list, which should autoscroll until + // we hit the beginning of the list. This should cause the first item's + // entry to be rebuilt. However, the contents should not be in both places. + await drag.moveBy(const Offset(0, -1400)); + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + // Release back at the top so item 0 should drop where it was + await drag.up(); + await tester.pumpAndSettle(); + + // Should not have changed anything + for (final i in <int>[0, 1, 2, 3, 4, 5]) { + expect(find.text('item $i'), findsOneWidget); + } + expect(items.take(8), orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7])); + }, + ); + + testWidgets('ReorderableListView calls onReorderStart and onReorderEnd correctly', ( + WidgetTester tester, + ) async { + final items = List<int>.generate(8, (int index) => index); + int? startIndex, endIndex; + final Finder item0 = find.textContaining('item 0'); + + void handleReorder(int fromIndex, int toIndex) { + items.insert(toIndex, items.removeAt(fromIndex)); + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(items[index]), + height: 100, + child: ReorderableDragStartListener( + index: index, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[Text('item ${items[index]}')], + ), + ), + ); + }, + buildDefaultDragHandles: false, + itemCount: items.length, + onReorderItem: handleReorder, + onReorderStart: (int index) { + startIndex = index; + }, + onReorderEnd: (int index) { + endIndex = index; + }, + ), + ), + ); + + TestGesture drag = await tester.startGesture(tester.getCenter(item0)); + await tester.pump(kPressTimeout); + // Drag enough for move to start. + await drag.moveBy(const Offset(0, 20)); + + expect(startIndex, equals(0)); + expect(endIndex, isNull); + + // Move item0 from index 0 to index 3 + await drag.moveBy(const Offset(0, 300)); + await tester.pumpAndSettle(); + await drag.up(); + await tester.pumpAndSettle(); + + expect(endIndex, equals(3)); + + startIndex = null; + endIndex = null; + + drag = await tester.startGesture(tester.getCenter(item0)); + await tester.pump(kPressTimeout); + // Drag enough for move to start. + await drag.moveBy(const Offset(0, 20)); + + expect(startIndex, equals(2)); + expect(endIndex, isNull); + + // Move item0 from index 2 to index 0 + await drag.moveBy(const Offset(0, -200)); + await tester.pumpAndSettle(); + await drag.up(); + await tester.pumpAndSettle(); + + expect(endIndex, equals(0)); + }); + + testWidgets('ReorderableListView calls old onReorder callback correctly', ( + WidgetTester tester, + ) async { + const itemCount = 5; + var onReorderCallCount = 0; + final children = <Widget>[ + for (int index = 0; index < itemCount; index++) + SizedBox( + key: ValueKey<int>(index), + height: 100, + child: ReorderableDragStartListener(index: index, child: Text('item $index')), + ), + ]; + + void handleReorder(int fromIndex, int toIndex) { + onReorderCallCount += 1; + + if (fromIndex < toIndex) { + toIndex -= 1; + } + + children.insert(toIndex, children.removeAt(fromIndex)); + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView(onReorder: handleReorder, children: children), + ), + ); + + // Start gesture on the first item. + final TestGesture dragDown = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move down the first item. + await dragDown.moveBy(const Offset(0, 50)); + await tester.pump(); + await dragDown.up(); + await tester.pumpAndSettle(); + + expect(onReorderCallCount, 1); + + final dragDownItems = <int>[ + for (final Widget child in children) + if (child.key case final ValueKey<int> key) key.value, + ]; + + expect(dragDownItems, orderedEquals(<int>[1, 0, 2, 3, 4])); + + // Now do the reverse. + final TestGesture dragUp = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move up the first item. + await dragUp.moveBy(const Offset(0, -50)); + await tester.pump(); + await dragUp.up(); + await tester.pumpAndSettle(); + + final dragUpItems = <int>[ + for (final Widget child in children) + if (child.key case final ValueKey<int> key) key.value, + ]; + + expect(onReorderCallCount, 2); + expect(dragUpItems, orderedEquals(<int>[0, 1, 2, 3, 4])); + }); + + testWidgets('ReorderableListView calls onReorderItem callback correctly', ( + WidgetTester tester, + ) async { + const itemCount = 5; + final children = <Widget>[ + for (int index = 0; index < itemCount; index++) + SizedBox( + key: ValueKey<int>(index), + height: 100, + child: ReorderableDragStartListener(index: index, child: Text('item $index')), + ), + ]; + + void handleReorderItem(int fromIndex, int toIndex) { + children.insert(toIndex, children.removeAt(fromIndex)); + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView(onReorderItem: handleReorderItem, children: children), + ), + ); + + // Start gesture on the first item. + final TestGesture dragDown = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move down the first item. + await dragDown.moveBy(const Offset(0, 50)); + await tester.pump(); + await dragDown.up(); + await tester.pumpAndSettle(); + + final dragDownItems = <int>[ + for (final Widget child in children) + if (child.key case final ValueKey<int> key) key.value, + ]; + + expect(dragDownItems, orderedEquals(<int>[1, 0, 2, 3, 4])); + + // Now do the reverse. + final TestGesture dragUp = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move up the first item. + await dragUp.moveBy(const Offset(0, -50)); + await tester.pump(); + await dragUp.up(); + await tester.pumpAndSettle(); + + final dragUpItems = <int>[ + for (final Widget child in children) + if (child.key case final ValueKey<int> key) key.value, + ]; + + expect(dragUpItems, orderedEquals(<int>[0, 1, 2, 3, 4])); + }); + + testWidgets('ReorderableListView asserts if neither onReorder and onReorderItem are provided', ( + WidgetTester tester, + ) async { + expect(() => ReorderableListView(children: const <Widget>[]), throwsAssertionError); + }); + + testWidgets('ReorderableListView asserts if both onReorder and onReorderItem are provided', ( + WidgetTester tester, + ) async { + expect( + () => ReorderableListView( + onReorder: (_, _) {}, + onReorderItem: (_, _) {}, + children: const <Widget>[], + ), + throwsAssertionError, + ); + }); + + testWidgets('ReorderableListView.builder calls old onReorder callback correctly', ( + WidgetTester tester, + ) async { + const itemCount = 5; + var onReorderCallCount = 0; + final items = List<int>.generate(itemCount, (int index) => index); + + void handleReorder(int fromIndex, int toIndex) { + onReorderCallCount += 1; + + if (fromIndex < toIndex) { + toIndex -= 1; + } + + items.insert(toIndex, items.removeAt(fromIndex)); + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView.builder( + itemCount: items.length, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(items[index]), + height: 100, + child: ReorderableDragStartListener( + index: index, + child: Text('item ${items[index]}'), + ), + ); + }, + onReorder: handleReorder, + ), + ), + ); + + // Start gesture on the first item. + final TestGesture dragDown = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move down the first item. + await dragDown.moveBy(const Offset(0, 50)); + await tester.pump(); + await dragDown.up(); + await tester.pumpAndSettle(); + + expect(onReorderCallCount, 1); + expect(items, orderedEquals(<int>[1, 0, 2, 3, 4])); + + // Now do the reverse. + final TestGesture dragUp = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move up the first item. + await dragUp.moveBy(const Offset(0, -50)); + await tester.pump(); + await dragUp.up(); + await tester.pumpAndSettle(); + + expect(onReorderCallCount, 2); + expect(items, orderedEquals(<int>[0, 1, 2, 3, 4])); + }); + + testWidgets('ReorderableListView.builder calls onReorderItem callback correctly', ( + WidgetTester tester, + ) async { + const itemCount = 5; + final items = List<int>.generate(itemCount, (int index) => index); + + void handleReorderItem(int fromIndex, int toIndex) { + items.insert(toIndex, items.removeAt(fromIndex)); + } + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView.builder( + itemCount: items.length, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(items[index]), + height: 100, + child: ReorderableDragStartListener( + index: index, + child: Text('item ${items[index]}'), + ), + ); + }, + onReorderItem: handleReorderItem, + ), + ), + ); + + // Start gesture on the first item. + final TestGesture dragDown = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move down the first item. + await dragDown.moveBy(const Offset(0, 50)); + await tester.pump(); + await dragDown.up(); + await tester.pumpAndSettle(); + + expect(items, orderedEquals(<int>[1, 0, 2, 3, 4])); + + // Now do the reverse. + final TestGesture dragUp = await tester.startGesture(tester.getCenter(find.text('item 0'))); + await tester.pump(kPressTimeout); + + // Drag enough to move up the first item. + await dragUp.moveBy(const Offset(0, -50)); + await tester.pump(); + await dragUp.up(); + await tester.pumpAndSettle(); + + expect(items, orderedEquals(<int>[0, 1, 2, 3, 4])); + }); + + testWidgets( + 'ReorderableListView.builder asserts if neither onReorder and onReorderItem are provided', + (WidgetTester tester) async { + expect( + () => ReorderableListView.builder(itemBuilder: (_, _) => const SizedBox(), itemCount: 0), + throwsAssertionError, + ); + }, + ); + + testWidgets( + 'ReorderableListView.builder asserts if both onReorder and onReorderItem are provided', + (WidgetTester tester) async { + expect( + () => ReorderableListView.builder( + itemBuilder: (_, _) => const SizedBox(), + itemCount: 0, + onReorder: (_, _) {}, + onReorderItem: (_, _) {}, + ), + throwsAssertionError, + ); + }, + ); + + testWidgets('ReorderableListView throws an error when key is not passed to its children', ( + WidgetTester tester, + ) async { + final Widget reorderableListView = ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return SizedBox(child: Text('Item $index')); + }, + itemCount: 3, + onReorderItem: (_, _) {}, + ); + await tester.pumpWidget(MaterialApp(home: reorderableListView)); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + expect(exception.toString(), contains('Every item of ReorderableListView must have a key.')); + }); + + testWidgets('Throws an error if no overlay present', (WidgetTester tester) async { + final Widget reorderableList = ReorderableListView( + children: const <Widget>[ + SizedBox(width: 100.0, height: 100.0, key: Key('C'), child: Text('C')), + SizedBox(width: 100.0, height: 100.0, key: Key('B'), child: Text('B')), + SizedBox(width: 100.0, height: 100.0, key: Key('A'), child: Text('A')), + ], + onReorderItem: (_, _) {}, + ); + final Widget boilerplate = Localizations( + locale: const Locale('en'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: SizedBox.square( + dimension: 100.0, + child: Directionality(textDirection: TextDirection.ltr, child: reorderableList), + ), + ); + await tester.pumpWidget(boilerplate); + + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + expect(exception.toString(), contains('No Overlay widget found')); + expect( + exception.toString(), + contains('ReorderableListView widgets require an Overlay widget ancestor'), + ); + }); + + testWidgets('ReorderableListView asserts on both non-null itemExtent and prototypeItem', ( + WidgetTester tester, + ) async { + expect( + () => ReorderableListView( + itemExtent: 30, + prototypeItem: const SizedBox(), + onReorderItem: (_, _) {}, + children: const <Widget>[], + ), + throwsAssertionError, + ); + }); + + testWidgets('ReorderableListView.builder asserts on both non-null itemExtent and prototypeItem', ( + WidgetTester tester, + ) async { + final numbers = <int>[0, 1, 2]; + expect( + () => ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(numbers[index]), + height: 20 + numbers[index] * 10, + child: ReorderableDragStartListener( + index: index, + child: Text(numbers[index].toString()), + ), + ); + }, + itemCount: numbers.length, + itemExtent: 30, + prototypeItem: const SizedBox(), + onReorderItem: (_, _) {}, + ), + throwsAssertionError, + ); + }); + + testWidgets('if itemExtent is non-null, children have same extent in the scroll direction', ( + WidgetTester tester, + ) async { + final numbers = <int>[0, 1, 2]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(numbers[index]), + // children with different heights + height: 20 + numbers[index] * 10, + child: ReorderableDragStartListener( + index: index, + child: Text(numbers[index].toString()), + ), + ); + }, + itemCount: numbers.length, + itemExtent: 30, + onReorderItem: (_, _) {}, + ); + }, + ), + ), + ), + ); + + final double item0Height = tester.getSize(find.text('0').hitTestable()).height; + final double item1Height = tester.getSize(find.text('1').hitTestable()).height; + final double item2Height = tester.getSize(find.text('2').hitTestable()).height; + + expect(item0Height, 30.0); + expect(item1Height, 30.0); + expect(item2Height, 30.0); + }); + + testWidgets('if prototypeItem is non-null, children have same extent in the scroll direction', ( + WidgetTester tester, + ) async { + final numbers = <int>[0, 1, 2]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return SizedBox( + key: ValueKey<int>(numbers[index]), + // children with different heights + height: 20 + numbers[index] * 10, + child: ReorderableDragStartListener( + index: index, + child: Text(numbers[index].toString()), + ), + ); + }, + itemCount: numbers.length, + prototypeItem: const SizedBox(height: 30, child: Text('3')), + onReorderItem: (_, _) {}, + ); + }, + ), + ), + ), + ); + + final double item0Height = tester.getSize(find.text('0').hitTestable()).height; + final double item1Height = tester.getSize(find.text('1').hitTestable()).height; + final double item2Height = tester.getSize(find.text('2').hitTestable()).height; + + expect(item0Height, 30.0); + expect(item1Height, 30.0); + expect(item2Height, 30.0); + }); + + testWidgets('ReorderableListView auto scrolls speed is configurable', ( + WidgetTester tester, + ) async { + Future<void> pumpFor({ + required Duration duration, + Duration interval = const Duration(milliseconds: 50), + }) async { + await tester.pump(); + + int times = (duration.inMilliseconds / interval.inMilliseconds).ceil(); + while (times > 0) { + await tester.pump(interval + const Duration(milliseconds: 1)); + await tester.idle(); + times--; + } + } + + Future<double> pumpListAndDrag({required double autoScrollerVelocityScalar}) async { + final items = List<int>.generate(10, (int index) => index); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return Container( + key: ValueKey<int>(items[index]), + height: 100, + color: items[index].isOdd ? Colors.red : Colors.green, + child: ReorderableDragStartListener( + index: index, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[Text('item ${items[index]}'), const Icon(Icons.drag_handle)], + ), + ), + ); + }, + itemCount: items.length, + onReorderItem: (_, _) {}, + scrollController: scrollController, + autoScrollerVelocityScalar: autoScrollerVelocityScalar, + ), + ), + ); + + expect(scrollController.offset, 0); + + final Finder item = find.text('item 0'); + final TestGesture drag = await tester.startGesture(tester.getCenter(item)); + + // Drag just enough to touch the edge but not surpass it, so the + // auto scroller is not yet triggered + await drag.moveBy(const Offset(0, 500)); + await pumpFor(duration: const Duration(milliseconds: 200)); + + expect(scrollController.offset, 0); + + // Now drag a little bit more so the auto scroller triggers + await drag.moveBy(const Offset(0, 50)); + await pumpFor( + duration: const Duration(milliseconds: 600), + interval: Duration(milliseconds: (1000 / autoScrollerVelocityScalar).round()), + ); + await drag.up(); + + return scrollController.offset; + } + + const double fastVelocityScalar = 20; + final double offsetForFastScroller = await pumpListAndDrag( + autoScrollerVelocityScalar: fastVelocityScalar, + ); + + // Reset widget tree + await tester.pumpWidget(const SizedBox()); + + const double slowVelocityScalar = 5; + final double offsetForSlowScroller = await pumpListAndDrag( + autoScrollerVelocityScalar: slowVelocityScalar, + ); + + expect(offsetForFastScroller / offsetForSlowScroller, fastVelocityScalar / slowVelocityScalar); + }); + + testWidgets('DragBoundary defines the boundary for ReorderableListView.', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container( + margin: const EdgeInsets.only(top: 100), + height: 300, + child: DragBoundary( + child: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return ReorderableDragStartListener( + key: ValueKey<int>(index), + index: index, + child: Text('$index'), + ); + }, + itemCount: 5, + onReorderItem: (_, _) {}, + ), + ), + ), + ), + ), + ); + TestGesture drag = await tester.startGesture(tester.getCenter(find.text('0'))); + await tester.pump(kLongPressTimeout); + await drag.moveBy(const Offset(0, -400)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('0')), const Offset(0, 100)); + await drag.up(); + await tester.pumpAndSettle(); + + drag = await tester.startGesture(tester.getCenter(find.text('0'))); + await tester.pump(kLongPressTimeout); + await drag.moveBy(const Offset(0, 800)); + await tester.pumpAndSettle(); + expect(tester.getBottomLeft(find.text('0')), const Offset(0, 400)); + await drag.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Mouse cursor behavior on the drag handle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReorderableListView.builder( + itemBuilder: (BuildContext context, int index) { + return ReorderableDragStartListener( + key: ValueKey<int>(index), + index: index, + child: Text('$index'), + ); + }, + itemCount: 5, + onReorderItem: (_, _) {}, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byIcon(Icons.drag_handle).first)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + await gesture.down(tester.getCenter(find.byIcon(Icons.drag_handle).first)); + await tester.pump(kLongPressTimeout); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grabbing, + ); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'Mouse cursor behavior on the drag handle can be provided', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReorderableListView.builder( + mouseCursor: + const WidgetStateMouseCursor.fromMap(<WidgetStatesConstraint, MouseCursor>{ + WidgetState.dragged: SystemMouseCursors.copy, + WidgetState.any: SystemMouseCursors.resizeColumn, + }), + itemBuilder: (BuildContext context, int index) { + return ReorderableDragStartListener( + key: ValueKey<int>(index), + index: index, + child: Text('$index'), + ); + }, + itemCount: 5, + onReorderItem: (_, _) {}, + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byIcon(Icons.drag_handle).first)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.resizeColumn, + ); + await gesture.down(tester.getCenter(find.byIcon(Icons.drag_handle).first)); + await tester.pump(kLongPressTimeout); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.copy, + ); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.resizeColumn, + ); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('ReorderableListView does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: ReorderableListView( + children: const <Widget>[ + Text(key: Key('x'), 'X'), + Text(key: Key('y'), 'Y'), + ], + onReorderItem: (_, _) {}, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ReorderableListView)), Size.zero); + }); +} + +Future<void> longPressDrag(WidgetTester tester, Offset start, Offset end) async { + final TestGesture drag = await tester.startGesture(start); + await tester.pump(kLongPressTimeout + kPressTimeout); + await drag.moveTo(end); + await tester.pump(kPressTimeout); + await drag.up(); +} + +class _Stateful extends StatefulWidget { + // Ignoring the preference for const constructors because we want to test with regular non-const instances. + // ignore:prefer_const_constructors_in_immutables + _Stateful({super.key}); + + @override + State<StatefulWidget> createState() => _StatefulState(); +} + +class _StatefulState extends State<_Stateful> { + bool? checked = false; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 48.0, + child: Material( + child: Checkbox(value: checked, onChanged: (bool? newValue) => checked = newValue), + ), + ); + } +} diff --git a/packages/material_ui/test/material/scaffold_test.dart b/packages/material_ui/test/material/scaffold_test.dart new file mode 100644 index 000000000000..829794a8fe91 --- /dev/null +++ b/packages/material_ui/test/material/scaffold_test.dart @@ -0,0 +1,3945 @@ +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +import '../widgets/semantics_tester.dart'; + +// From bottom_sheet.dart. +const Duration _bottomSheetExitDuration = Duration(milliseconds: 200); + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/103741 + testWidgets('extendBodyBehindAppBar change should not cause the body widget lose state', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + Widget buildFrame({required bool extendBodyBehindAppBar}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + extendBodyBehindAppBar: extendBodyBehindAppBar, + resizeToAvoidBottomInset: false, + body: SingleChildScrollView( + controller: controller, + child: const FlutterLogo(size: 1107), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: true)); + expect(controller.position.pixels, 0.0); + + controller.jumpTo(100.0); + await tester.pump(); + expect(controller.position.pixels, 100.0); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: false)); + expect(controller.position.pixels, 100.0); + }); + + testWidgets('keyboardDismissBehavior.OnDrag with drawer tests', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + drawer: Container(), + body: Column( + children: <Widget>[ + const TextField(), + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Container(height: 1000), + ), + ), + ], + ), + ), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + final Finder finder = find.byType(TextField).first; + await tester.tap(finder); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.drag(find.byType(SingleChildScrollView).first, const Offset(0.0, -40.0)); + await tester.pumpAndSettle(); + + expect(tester.testTextInput.isVisible, isFalse); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Scaffold drawer callback test', (WidgetTester tester) async { + var isDrawerOpen = false; + var isEndDrawerOpen = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: Container(color: Colors.blue), + onDrawerChanged: (bool isOpen) { + isDrawerOpen = isOpen; + }, + endDrawer: Container(color: Colors.green), + onEndDrawerChanged: (bool isOpen) { + isEndDrawerOpen = isOpen; + }, + body: Container(), + ), + ), + ); + + final ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + + scaffoldState.openDrawer(); + await tester.pumpAndSettle(); + expect(isDrawerOpen, true); + scaffoldState.openEndDrawer(); + await tester.pumpAndSettle(); + expect(isDrawerOpen, false); + + scaffoldState.openEndDrawer(); + await tester.pumpAndSettle(); + expect(isEndDrawerOpen, true); + scaffoldState.openDrawer(); + await tester.pumpAndSettle(); + expect(isEndDrawerOpen, false); + }); + + testWidgets('Scaffold drawer callback test - only call when changed', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/87914 + var onDrawerChangedCalled = false; + var onEndDrawerChangedCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: Container(color: Colors.blue), + onDrawerChanged: (bool isOpen) { + onDrawerChangedCalled = true; + }, + endDrawer: Container(color: Colors.green), + onEndDrawerChanged: (bool isOpen) { + onEndDrawerChangedCalled = true; + }, + body: Container(), + ), + ), + ); + + await tester.flingFrom(Offset.zero, const Offset(10.0, 0.0), 10.0); + expect(onDrawerChangedCalled, false); + + await tester.pumpAndSettle(); + + final double width = tester.getSize(find.byType(MaterialApp)).width; + await tester.flingFrom(Offset(width - 1, 0.0), const Offset(-10.0, 0.0), 10.0); + await tester.pumpAndSettle(); + expect(onEndDrawerChangedCalled, false); + }); + + testWidgets('ListView dismiss keyboard onDrag and keep dismissed on drawer opened test', ( + WidgetTester tester, + ) async { + final list = List<int>.generate(50, (int i) => i); + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Scaffold( + key: scaffoldKey, + drawer: Container(), + body: Column( + children: <Widget>[ + const TextField(), + Expanded( + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: list.map((int i) { + return Container(height: 50); + }).toList(), + ), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + final Finder finder = find.byType(EditableText).first; + await tester.tap(finder); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.drag(find.byType(ListView).first, const Offset(0.0, -40.0)); + await tester.pumpAndSettle(); + + expect(tester.testTextInput.isVisible, isFalse); + scaffoldKey.currentState!.openDrawer(); + await tester.pumpAndSettle(); + + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Scaffold control test', (WidgetTester tester) async { + final Key bodyKey = UniqueKey(); + Widget boilerplate(Widget child) { + return Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality(textDirection: TextDirection.ltr, child: child), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Container(key: bodyKey), + ), + ), + ); + RenderBox bodyBox = tester.renderObject(find.byKey(bodyKey)); + expect(bodyBox.size, equals(const Size(800.0, 544.0))); + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100.0)), + child: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Container(key: bodyKey), + ), + ), + ), + ); + + bodyBox = tester.renderObject(find.byKey(bodyKey)); + expect(bodyBox.size, equals(const Size(800.0, 444.0))); + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100.0)), + child: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Container(key: bodyKey), + resizeToAvoidBottomInset: false, + ), + ), + ), + ); + + bodyBox = tester.renderObject(find.byKey(bodyKey)); + expect(bodyBox.size, equals(const Size(800.0, 544.0))); + }); + + testWidgets('Scaffold large bottom padding test', (WidgetTester tester) async { + final Key bodyKey = UniqueKey(); + + Widget boilerplate(Widget child) { + return Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality(textDirection: TextDirection.ltr, child: child), + ); + } + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 700.0)), + child: Scaffold(body: Container(key: bodyKey)), + ), + ), + ); + + final RenderBox bodyBox = tester.renderObject(find.byKey(bodyKey)); + expect(bodyBox.size, equals(const Size(800.0, 0.0))); + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 500.0)), + child: Scaffold(body: Container(key: bodyKey)), + ), + ), + ); + + expect(bodyBox.size, equals(const Size(800.0, 100.0))); + + await tester.pumpWidget( + boilerplate( + MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 580.0)), + child: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Container(key: bodyKey), + ), + ), + ), + ); + + expect(bodyBox.size, equals(const Size(800.0, 0.0))); + }); + + testWidgets('Floating action entrance/exit animation', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + key: Key('one'), + onPressed: null, + child: Text('1'), + ), + ), + ), + ); + + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + key: Key('two'), + onPressed: null, + child: Text('2'), + ), + ), + ), + ); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + await tester.pumpWidget(Container()); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + key: Key('one'), + onPressed: null, + child: Text('1'), + ), + ), + ), + ); + + expect(tester.binding.transientCallbackCount, greaterThan(0)); + }); + + testWidgets('Floating action button shrinks when bottom sheet becomes dominant', ( + WidgetTester tester, + ) async { + final draggableController = DraggableScrollableController(); + addTearDown(draggableController.dispose); + const kBottomSheetDominatesPercentage = 0.3; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: const FloatingActionButton( + key: Key('one'), + onPressed: null, + child: Text('1'), + ), + bottomSheet: DraggableScrollableSheet( + expand: false, + controller: draggableController, + builder: (BuildContext context, ScrollController scrollController) { + return SingleChildScrollView(controller: scrollController, child: const SizedBox()); + }, + ), + ), + ), + ); + + double getScale() => + tester.firstWidget<ScaleTransition>(find.byType(ScaleTransition)).scale.value; + + for (double i = 0, extent = i / 10; i <= 10; i++, extent = i / 10) { + draggableController.jumpTo(extent); + + final double extentRemaining = 1.0 - extent; + if (extentRemaining < kBottomSheetDominatesPercentage) { + final double visValue = extentRemaining * kBottomSheetDominatesPercentage * 10; + // since FAB uses easeIn curve, we're testing this by using the fact that + // easeIn curve is always less than or equal to x=y curve. + expect(getScale(), lessThanOrEqualTo(visValue)); + } else { + expect(getScale(), equals(1.0)); + } + } + }); + + testWidgets('Scaffold shows scrim when bottom sheet becomes dominant', ( + WidgetTester tester, + ) async { + final draggableController = DraggableScrollableController(); + addTearDown(draggableController.dispose); + const kBottomSheetDominatesPercentage = 0.3; + const kMinBottomSheetScrimOpacity = 0.1; + const kMaxBottomSheetScrimOpacity = 0.6; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomSheet: DraggableScrollableSheet( + expand: false, + controller: draggableController, + builder: (BuildContext context, ScrollController scrollController) { + return SingleChildScrollView(controller: scrollController, child: const SizedBox()); + }, + ), + ), + ), + ); + + Finder findModalBarrier() => + find.descendant(of: find.byType(Scaffold), matching: find.byType(ModalBarrier)); + double getOpacity() => tester.firstWidget<ModalBarrier>(findModalBarrier()).color!.opacity; + double getExpectedOpacity(double visValue) => + math.max(kMinBottomSheetScrimOpacity, kMaxBottomSheetScrimOpacity - visValue); + + for (double i = 0, extent = i / 10; i <= 10; i++, extent = i / 10) { + draggableController.jumpTo(extent); + await tester.pump(); + + final double extentRemaining = 1.0 - extent; + if (extentRemaining < kBottomSheetDominatesPercentage) { + final double visValue = extentRemaining * kBottomSheetDominatesPercentage * 10; + + expect(findModalBarrier(), findsOneWidget); + expect(getOpacity(), moreOrLessEquals(getExpectedOpacity(visValue), epsilon: 0.02)); + } else { + expect(findModalBarrier(), findsNothing); + } + } + }); + + testWidgets('Scaffold uses bottomSheetScrimBuilder if defined', (WidgetTester tester) async { + final draggableController = DraggableScrollableController(); + addTearDown(draggableController.dispose); + const kBottomSheetDominatesPercentage = 0.3; + + const scrimKey = Key('scrim'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomSheetScrimBuilder: (BuildContext context, Animation<double> animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return ColoredBox(key: scrimKey, color: Colors.black.withOpacity(animation.value)); + }, + ); + }, + bottomSheet: DraggableScrollableSheet( + expand: false, + controller: draggableController, + builder: (BuildContext context, ScrollController scrollController) { + return SingleChildScrollView(controller: scrollController, child: const SizedBox()); + }, + ), + ), + ), + ); + Finder findScrim() => find.byKey(scrimKey); + Finder findModalBarrier() => + find.descendant(of: find.byType(Scaffold), matching: find.byType(ModalBarrier)); + double getOpacity() => tester.firstWidget<ColoredBox>(findScrim()).color.opacity; + + for (double i = 0, extent = i / 10; i <= 10; i++, extent = i / 10) { + draggableController.jumpTo(extent); + await tester.pump(); + + final double extentRemaining = 1.0 - extent; + if (extentRemaining < kBottomSheetDominatesPercentage) { + final double animationValue = 1 - extentRemaining / kBottomSheetDominatesPercentage; + + expect(findModalBarrier(), findsNothing); + expect(findScrim(), findsOneWidget); + expect(getOpacity(), moreOrLessEquals(animationValue, epsilon: 0.02)); + } else { + expect(findScrim(), findsNothing); + } + } + }); + + testWidgets('Floating action button directionality', (WidgetTester tester) async { + Widget build(TextDirection textDirection) { + return Directionality( + textDirection: textDirection, + child: const MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: 200.0)), + child: Scaffold( + floatingActionButton: FloatingActionButton(onPressed: null, child: Text('1')), + ), + ), + ); + } + + await tester.pumpWidget(build(TextDirection.ltr)); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(756.0, 356.0)); + + await tester.pumpWidget(build(TextDirection.rtl)); + expect(tester.binding.transientCallbackCount, 0); + + expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 356.0)); + }); + + testWidgets('Floating Action Button bottom padding not consumed by viewInsets', ( + WidgetTester tester, + ) async { + final Widget child = Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Container(), + floatingActionButton: const Placeholder(), + ), + ); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(viewPadding: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ); + final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); + expect( + tester.getBottomLeft(find.byType(Placeholder)).dy, + moreOrLessEquals(600.0 - 20.0 - kFloatingActionButtonMargin), + ); + + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: child, + ), + ); + final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); + expect(initialPoint, finalPoint); + }); + + testWidgets('viewPadding change should trigger _ScaffoldLayout re-layout', ( + WidgetTester tester, + ) async { + Widget buildFrame(EdgeInsets viewPadding) { + return MediaQuery( + data: MediaQueryData(viewPadding: viewPadding), + child: Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Container(), + floatingActionButton: const Placeholder(), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(const EdgeInsets.only(bottom: 300))); + + final RenderBox renderBox = tester.renderObject<RenderBox>(find.byType(CustomMultiChildLayout)); + expect(renderBox.debugNeedsLayout, false); + + await tester.pumpWidget( + buildFrame(const EdgeInsets.only(bottom: 400)), + phase: EnginePhase.build, + ); + + expect(renderBox.debugNeedsLayout, true); + }); + + testWidgets('Drawer scrolling', (WidgetTester tester) async { + final Key drawerKey = UniqueKey(); + const appBarHeight = 256.0; + + final scrollOffset = ScrollController(); + addTearDown(scrollOffset.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: Drawer( + key: drawerKey, + child: ListView( + dragStartBehavior: DragStartBehavior.down, + controller: scrollOffset, + children: List<Widget>.generate( + 10, + (int index) => SizedBox(height: 100.0, child: Text('D$index')), + ), + ), + ), + body: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + pinned: true, + expandedHeight: appBarHeight, + title: Text('Title'), + flexibleSpace: FlexibleSpaceBar(title: Text('Title')), + ), + SliverPadding( + padding: const EdgeInsets.only(top: appBarHeight), + sliver: SliverList.builder( + itemCount: 10, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 100.0, child: Text('B$index')); + }, + ), + ), + ], + ), + ), + ), + ); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(scrollOffset.offset, 0.0); + + const scrollDelta = 80.0; + await tester.drag(find.byKey(drawerKey), const Offset(0.0, -scrollDelta)); + await tester.pump(); + + expect(scrollOffset.offset, scrollDelta); + + final RenderBox renderBox = tester.renderObject(find.byType(AppBar)); + expect(renderBox.size.height, equals(appBarHeight)); + }); + + Widget buildStatusBarTestApp() { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: Scaffold( + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar(title: Text('Title')), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 100.0, child: Text('$index')); + }, + ), + ], + ), + ), + ), + ); + } + + testWidgets( + 'No status bar when primary is false', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: debugDefaultTargetPlatformOverride), + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: Scaffold( + primary: false, + body: CustomScrollView( + primary: true, + slivers: <Widget>[ + const SliverAppBar(title: Text('Title')), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 100.0, child: Text('$index')); + }, + ), + ], + ), + ), + ), + ), + ); + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + scrollable.position.jumpTo(500.0); + expect(scrollable.position.pixels, equals(500.0)); + await tester.tapAt(const Offset(100.0, 10.0)); + await tester.pumpAndSettle(); + expect(scrollable.position.pixels, equals(500.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + // Regression test for https://github.com/flutter/flutter/issues/175062 + testWidgets( + 'Top of Scaffold is not blocked when primary is false', + (WidgetTester tester) async { + var receivedTap = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: debugDefaultTargetPlatformOverride), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(top: kToolbarHeight), // status bar + ), + child: Scaffold( + appBar: AppBar( + primary: false, + title: GestureDetector( + onTap: () { + receivedTap = true; + }, + child: const Text('Title'), + ), + ), + primary: false, + body: const Text('Scaffold'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Title')); + await tester.pumpAndSettle(); + expect(receivedTap, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Tapping the status bar scrolls to top with ease out curve animation', + (WidgetTester tester) async { + const duration = 1000; + final stops = <double>[0.842, 0.959, 0.993, 1.0]; + const double scrollOffset = 1000; + + await tester.pumpWidget(buildStatusBarTestApp()); + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + scrollable.position.jumpTo(scrollOffset); + + tester.simulateStatusBarTap(); + await tester.pump(Duration.zero); + expect(scrollable.position.pixels, equals(scrollOffset)); + + for (var i = 0; i < stops.length; i++) { + await tester.pump(Duration(milliseconds: duration ~/ stops.length)); + // Scroll pixel position is very long double, compare with floored int + // pixel position + expect( + scrollable.position.pixels.toInt(), + equals((scrollOffset * (1 - stops[i])).toInt()), + reason: 'stop $i', + ); + } + + // Finally stops at the top. + expect(scrollable.position.pixels, equals(0.0)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'status bar tap only scrolls the foregrounded primary controller', + (WidgetTester tester) async { + final app = MaterialApp( + initialRoute: 'a', + onGenerateInitialRoutes: (initialRoute) { + return [ + MaterialPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + MaterialPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + ]; + }, + onGenerateRoute: (_) => throw UnimplementedError(), + ); + await tester.pumpWidget(app); + + final Iterable<ScrollableState> scrollables = tester.stateList<ScrollableState>( + find.descendant( + of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false), + matching: find.byType(Scrollable, skipOffstage: false), + skipOffstage: false, + ), + ); + + final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList(); + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 1000); + + tester.simulateStatusBarTap(); + await tester.pumpAndSettle(); + + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('Bottom sheet cannot overlap app bar', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showBottomSheet((BuildContext context) { + return Container(key: sheetKey, color: Colors.blue[500]); + }); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(seconds: 1)); + + final RenderBox appBarBox = tester.renderObject(find.byType(AppBar)); + final RenderBox sheetBox = tester.renderObject(find.byKey(sheetKey)); + + final Offset appBarBottomRight = appBarBox.localToGlobal( + appBarBox.size.bottomRight(Offset.zero), + ); + final Offset sheetTopRight = sheetBox.localToGlobal(sheetBox.size.topRight(Offset.zero)); + + expect(appBarBottomRight, equals(sheetTopRight)); + }); + + testWidgets('BottomSheet bottom padding is not consumed by viewInsets', ( + WidgetTester tester, + ) async { + final Widget child = Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Container(), + bottomSheet: const Placeholder(), + ), + ); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ); + final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: child, + ), + ); + final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); + expect(initialPoint, finalPoint); + }); + + testWidgets('Persistent bottom buttons are persistent', (WidgetTester tester) async { + var didPressButton = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Container(color: Colors.amber[500], height: 5000.0, child: const Text('body')), + ), + persistentFooterButtons: <Widget>[ + TextButton( + onPressed: () { + didPressButton = true; + }, + child: const Text('X'), + ), + ], + ), + ), + ); + + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -1000.0)); + expect(didPressButton, isFalse); + await tester.tap(find.text('X')); + expect(didPressButton, isTrue); + }); + + testWidgets('Persistent bottom buttons alignment', (WidgetTester tester) async { + Widget buildApp(AlignmentDirectional persistentAlignment) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Container(color: Colors.amber[500], height: 5000.0, child: const Text('body')), + ), + persistentFooterAlignment: persistentAlignment, + persistentFooterButtons: <Widget>[TextButton(onPressed: () {}, child: const Text('X'))], + ), + ); + } + + await tester.pumpWidget(buildApp(AlignmentDirectional.centerEnd)); + Finder footerButton = find.byType(TextButton); + expect(tester.getTopRight(footerButton).dx, 800.0 - 8.0); + + await tester.pumpWidget(buildApp(AlignmentDirectional.center)); + footerButton = find.byType(TextButton); + expect(tester.getCenter(footerButton).dx, 800.0 / 2); + + await tester.pumpWidget(buildApp(AlignmentDirectional.centerStart)); + footerButton = find.byType(TextButton); + expect(tester.getTopLeft(footerButton).dx, 8.0); + }); + + testWidgets('Persistent bottom buttons apply media padding', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(10.0, 20.0, 30.0, 40.0)), + child: Scaffold( + body: SingleChildScrollView( + child: Container(color: Colors.amber[500], height: 5000.0, child: const Text('body')), + ), + persistentFooterButtons: const <Widget>[Placeholder()], + ), + ), + ), + ); + + final Finder buttonsBar = find + .ancestor(of: find.byType(OverflowBar), matching: find.byType(Padding)) + .first; + expect(tester.getBottomLeft(buttonsBar), const Offset(10.0, 560.0)); + expect(tester.getBottomRight(buttonsBar), const Offset(770.0, 560.0)); + }); + + testWidgets('persistentFooterButtons with bottomNavigationBar apply SafeArea properly', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/pull/92039 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: const MediaQueryData( + // Representing a navigational notch at the bottom of the screen + viewPadding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 40.0), + ), + child: Scaffold( + body: SingleChildScrollView( + child: Container(color: Colors.amber[500], height: 5000.0, child: const Text('body')), + ), + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), + BottomNavigationBarItem(icon: Icon(Icons.business), label: 'Business'), + BottomNavigationBarItem(icon: Icon(Icons.school), label: 'School'), + ], + ), + persistentFooterButtons: const <Widget>[Placeholder()], + ), + ), + ), + ); + + final Finder buttonsBar = find + .ancestor(of: find.byType(OverflowBar), matching: find.byType(Padding)) + .first; + // The SafeArea of the persistentFooterButtons should not pad below them + // since they are stacked on top of the bottomNavigationBar. The + // bottomNavigationBar will handle the padding instead. + // 488 represents the height of the persistentFooterButtons, with the bottom + // of the screen being 600. If the 40 pixels of bottom padding were being + // errantly applied, the buttons would be higher (448). + expect(tester.getTopLeft(buttonsBar), const Offset(0.0, 488.0)); + }); + + testWidgets('Persistent bottom buttons bottom padding is not consumed by viewInsets', ( + WidgetTester tester, + ) async { + final Widget child = Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Container(), + persistentFooterButtons: const <Widget>[Placeholder()], + ), + ); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ); + final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: child, + ), + ); + final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); + expect(initialPoint, finalPoint); + }); + + testWidgets('Persistent bottom buttons can apply decoration', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(10.0, 20.0, 30.0, 40.0)), + child: Scaffold( + body: SingleChildScrollView( + child: Container(color: Colors.amber[500], height: 5000.0, child: const Text('body')), + ), + persistentFooterDecoration: const BoxDecoration( + border: Border(top: BorderSide(color: Colors.red)), + ), + persistentFooterButtons: const <Widget>[Placeholder()], + ), + ), + ), + ); + + final Finder persistentFooter = find + .ancestor(of: find.byType(OverflowBar), matching: find.byType(Container)) + .first; + final Decoration decoration = tester.widget<Container>(persistentFooter).decoration!; + + expect(decoration, isA<BoxDecoration>()); + expect((decoration as BoxDecoration).border!.top.color, Colors.red); + }); + + group('back arrow', () { + Future<void> expectBackIcon(WidgetTester tester, IconData expectedIcon) async { + final GlobalKey rootKey = GlobalKey(); + final routes = <String, WidgetBuilder>{ + '/': (_) => Container(key: rootKey, child: const Text('Home')), + '/scaffold': (_) => Scaffold(appBar: AppBar(), body: const Text('Scaffold')), + }; + await tester.pumpWidget(MaterialApp(routes: routes)); + + Navigator.pushNamed(rootKey.currentContext!, '/scaffold'); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final Icon icon = tester.widget(find.byType(Icon)); + expect(icon.icon, expectedIcon); + } + + testWidgets( + 'Back arrow uses correct default', + (WidgetTester tester) async { + await expectBackIcon(tester, Icons.arrow_back); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'Back arrow uses correct default', + (WidgetTester tester) async { + await expectBackIcon(tester, kIsWeb ? Icons.arrow_back : Icons.arrow_back_ios_new_rounded); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + }); + + group('close button', () { + Future<void> expectCloseIcon( + WidgetTester tester, + PageRoute<void> Function() routeBuilder, + String type, + ) async { + const IconData expectedIcon = Icons.close; + await tester.pumpWidget( + MaterialApp( + home: Scaffold(appBar: AppBar(), body: const Text('Page 1')), + ), + ); + + tester.state<NavigatorState>(find.byType(Navigator)).push(routeBuilder()); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final Icon icon = tester.widget(find.byType(Icon)); + expect(icon.icon, expectedIcon, reason: "didn't find close icon for $type"); + expect( + find.byKey(StandardComponentType.closeButton.key), + findsOneWidget, + reason: "didn't find close button for $type", + ); + } + + PageRoute<void> materialRouteBuilder() { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold(appBar: AppBar(), body: const Text('Page 2')); + }, + fullscreenDialog: true, + ); + } + + PageRoute<void> pageRouteBuilder() { + return PageRouteBuilder<void>( + pageBuilder: + ( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return Scaffold(appBar: AppBar(), body: const Text('Page 2')); + }, + fullscreenDialog: true, + ); + } + + PageRoute<void> customPageRouteBuilder() { + return _CustomPageRoute<void>( + builder: (BuildContext context) { + return Scaffold(appBar: AppBar(), body: const Text('Page 2')); + }, + fullscreenDialog: true, + ); + } + + testWidgets('Close button shows correctly', (WidgetTester tester) async { + await expectCloseIcon(tester, materialRouteBuilder, 'materialRouteBuilder'); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Close button shows correctly with PageRouteBuilder', (WidgetTester tester) async { + await expectCloseIcon(tester, pageRouteBuilder, 'pageRouteBuilder'); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Close button shows correctly with custom page route', (WidgetTester tester) async { + await expectCloseIcon(tester, customPageRouteBuilder, 'customPageRouteBuilder'); + }, variant: TargetPlatformVariant.all()); + }); + + group('body size', () { + testWidgets('body size with container', (WidgetTester tester) async { + final Key testKey = UniqueKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scaffold(body: Container(key: testKey)), + ), + ), + ); + expect(tester.element(find.byKey(testKey)).size, const Size(800.0, 600.0)); + expect( + tester.renderObject<RenderBox>(find.byKey(testKey)).localToGlobal(Offset.zero), + Offset.zero, + ); + }); + + testWidgets('body size with sized container', (WidgetTester tester) async { + final Key testKey = UniqueKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scaffold(body: Container(key: testKey, height: 100.0)), + ), + ), + ); + expect(tester.element(find.byKey(testKey)).size, const Size(800.0, 100.0)); + expect( + tester.renderObject<RenderBox>(find.byKey(testKey)).localToGlobal(Offset.zero), + Offset.zero, + ); + }); + + testWidgets('body size with centered container', (WidgetTester tester) async { + final Key testKey = UniqueKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scaffold( + body: Center(child: Container(key: testKey)), + ), + ), + ), + ); + expect(tester.element(find.byKey(testKey)).size, const Size(800.0, 600.0)); + expect( + tester.renderObject<RenderBox>(find.byKey(testKey)).localToGlobal(Offset.zero), + Offset.zero, + ); + }); + + testWidgets('body size with button', (WidgetTester tester) async { + final Key testKey = UniqueKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scaffold( + body: TextButton(key: testKey, onPressed: () {}, child: const Text('')), + ), + ), + ), + ); + expect(tester.element(find.byKey(testKey)).size, const Size(64.0, 48.0)); + expect( + tester.renderObject<RenderBox>(find.byKey(testKey)).localToGlobal(Offset.zero), + Offset.zero, + ); + }); + + testWidgets('body size with extendBody', (WidgetTester tester) async { + final Key bodyKey = UniqueKey(); + late double mediaQueryBottom; + + Widget buildFrame({ + required bool extendBody, + bool? resizeToAvoidBottomInset, + double viewInsetBottom = 0.0, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: viewInsetBottom)), + child: Scaffold( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + extendBody: extendBody, + body: Builder( + builder: (BuildContext context) { + mediaQueryBottom = MediaQuery.paddingOf(context).bottom; + return Container(key: bodyKey); + }, + ), + bottomNavigationBar: const BottomAppBar(child: SizedBox(height: 48.0)), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(extendBody: true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(mediaQueryBottom, 48.0); + + await tester.pumpWidget(buildFrame(extendBody: false)); + expect( + tester.getSize(find.byKey(bodyKey)), + const Size(800.0, 552.0), + ); // 552 = 600 - 48 (BAB height) + expect(mediaQueryBottom, 0.0); + + // If resizeToAvoidBottomInsets is false, same results as if it was unspecified (null). + await tester.pumpWidget( + buildFrame(extendBody: true, resizeToAvoidBottomInset: false, viewInsetBottom: 100.0), + ); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(mediaQueryBottom, 48.0); + + await tester.pumpWidget( + buildFrame(extendBody: false, resizeToAvoidBottomInset: false, viewInsetBottom: 100.0), + ); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 552.0)); + expect(mediaQueryBottom, 0.0); + + // If resizeToAvoidBottomInsets is true and viewInsets.bottom is > the bottom + // navigation bar's height then the body always resizes and the MediaQuery + // isn't adjusted. This case corresponds to the keyboard appearing. + await tester.pumpWidget( + buildFrame(extendBody: true, resizeToAvoidBottomInset: true, viewInsetBottom: 100.0), + ); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + expect(mediaQueryBottom, 0.0); + + await tester.pumpWidget( + buildFrame(extendBody: false, resizeToAvoidBottomInset: true, viewInsetBottom: 100.0), + ); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + expect(mediaQueryBottom, 0.0); + }); + + testWidgets('body size with extendBodyBehindAppBar', (WidgetTester tester) async { + final Key appBarKey = UniqueKey(); + final Key bodyKey = UniqueKey(); + + const double appBarHeight = 100; + const double windowPaddingTop = 24; + late bool fixedHeightAppBar; + late double mediaQueryTop; + + Widget buildFrame({required bool extendBodyBehindAppBar, required bool hasAppBar}) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: windowPaddingTop)), + child: Builder( + builder: (BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: extendBodyBehindAppBar, + appBar: !hasAppBar + ? null + : PreferredSize( + key: appBarKey, + preferredSize: const Size.fromHeight(appBarHeight), + child: Container( + constraints: BoxConstraints( + minHeight: appBarHeight, + maxHeight: fixedHeightAppBar ? appBarHeight : double.infinity, + ), + ), + ), + body: Builder( + builder: (BuildContext context) { + mediaQueryTop = MediaQuery.paddingOf(context).top; + return Container(key: bodyKey); + }, + ), + ); + }, + ), + ), + ); + } + + fixedHeightAppBar = false; + + // When an appbar is provided, the Scaffold's body is built within a + // MediaQuery with padding.top = 0, and the appBar's maxHeight is + // constrained to its preferredSize.height + the original MediaQuery + // padding.top. When extendBodyBehindAppBar is true, an additional + // inner MediaQuery is added around the Scaffold's body with padding.top + // equal to the overall height of the appBar. See _BodyBuilder in + // material/scaffold.dart. + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: true, hasAppBar: true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect( + tester.getSize(find.byKey(appBarKey)), + const Size(800.0, appBarHeight + windowPaddingTop), + ); + expect(mediaQueryTop, appBarHeight + windowPaddingTop); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: true, hasAppBar: false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(find.byKey(appBarKey), findsNothing); + expect(mediaQueryTop, windowPaddingTop); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: false, hasAppBar: true)); + expect( + tester.getSize(find.byKey(bodyKey)), + const Size(800.0, 600.0 - appBarHeight - windowPaddingTop), + ); + expect( + tester.getSize(find.byKey(appBarKey)), + const Size(800.0, appBarHeight + windowPaddingTop), + ); + expect(mediaQueryTop, 0.0); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: false, hasAppBar: false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(find.byKey(appBarKey), findsNothing); + expect(mediaQueryTop, windowPaddingTop); + + fixedHeightAppBar = true; + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: true, hasAppBar: true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(tester.getSize(find.byKey(appBarKey)), const Size(800.0, appBarHeight)); + expect(mediaQueryTop, appBarHeight); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: true, hasAppBar: false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(find.byKey(appBarKey), findsNothing); + expect(mediaQueryTop, windowPaddingTop); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: false, hasAppBar: true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0 - appBarHeight)); + expect(tester.getSize(find.byKey(appBarKey)), const Size(800.0, appBarHeight)); + expect(mediaQueryTop, 0.0); + + await tester.pumpWidget(buildFrame(extendBodyBehindAppBar: false, hasAppBar: false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(find.byKey(appBarKey), findsNothing); + expect(mediaQueryTop, windowPaddingTop); + }); + }); + + testWidgets('Open drawer hides underlying semantics tree', (WidgetTester tester) async { + const bodyLabel = 'I am the body'; + const persistentFooterButtonLabel = 'a button on the bottom'; + const bottomNavigationBarLabel = 'a bar in an app'; + const floatingActionButtonLabel = 'I float in space'; + const drawerLabel = 'I am the reason for this test'; + + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Text(bodyLabel), + persistentFooterButtons: <Widget>[Text(persistentFooterButtonLabel)], + bottomNavigationBar: Text(bottomNavigationBarLabel), + floatingActionButton: Text(floatingActionButtonLabel), + drawer: Drawer(child: Text(drawerLabel)), + ), + ), + ); + + expect(semantics, includesNodeWith(label: bodyLabel)); + expect(semantics, includesNodeWith(label: persistentFooterButtonLabel)); + expect(semantics, includesNodeWith(label: bottomNavigationBarLabel)); + expect(semantics, includesNodeWith(label: floatingActionButtonLabel)); + expect(semantics, isNot(includesNodeWith(label: drawerLabel))); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(semantics, isNot(includesNodeWith(label: bodyLabel))); + expect(semantics, isNot(includesNodeWith(label: persistentFooterButtonLabel))); + expect(semantics, isNot(includesNodeWith(label: bottomNavigationBarLabel))); + expect(semantics, isNot(includesNodeWith(label: floatingActionButtonLabel))); + expect(semantics, includesNodeWith(label: drawerLabel)); + + semantics.dispose(); + }); + + testWidgets('Scaffold and extreme window padding', (WidgetTester tester) async { + final Key appBar = UniqueKey(); + final Key body = UniqueKey(); + final Key floatingActionButton = UniqueKey(); + final Key persistentFooterButton = UniqueKey(); + final Key drawer = UniqueKey(); + final Key bottomNavigationBar = UniqueKey(); + final Key insideAppBar = UniqueKey(); + final Key insideBody = UniqueKey(); + final Key insideFloatingActionButton = UniqueKey(); + final Key insidePersistentFooterButton = UniqueKey(); + final Key insideDrawer = UniqueKey(); + final Key insideBottomNavigationBar = UniqueKey(); + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 20.0, top: 30.0, right: 50.0, bottom: 60.0), + viewInsets: EdgeInsets.only(bottom: 200.0), + ), + child: Scaffold( + drawerDragStartBehavior: DragStartBehavior.down, + appBar: PreferredSize( + preferredSize: const Size(11.0, 13.0), + child: Container( + key: appBar, + child: SafeArea(child: Placeholder(key: insideAppBar)), + ), + ), + body: Container( + key: body, + child: SafeArea(child: Placeholder(key: insideBody)), + ), + floatingActionButton: SizedBox( + key: floatingActionButton, + width: 77.0, + height: 77.0, + child: SafeArea(child: Placeholder(key: insideFloatingActionButton)), + ), + persistentFooterButtons: <Widget>[ + SizedBox( + key: persistentFooterButton, + width: 100.0, + height: 90.0, + child: SafeArea(child: Placeholder(key: insidePersistentFooterButton)), + ), + ], + drawer: SizedBox( + key: drawer, + width: 204.0, + child: SafeArea(child: Placeholder(key: insideDrawer)), + ), + bottomNavigationBar: SizedBox( + key: bottomNavigationBar, + height: 85.0, + child: SafeArea(child: Placeholder(key: insideBottomNavigationBar)), + ), + ), + ), + ), + ), + ); + // open drawer + await tester.flingFrom(const Offset(795.0, 5.0), const Offset(-200.0, 0.0), 10.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(tester.getRect(find.byKey(appBar)), const Rect.fromLTRB(0.0, 0.0, 800.0, 43.0)); + expect(tester.getRect(find.byKey(body)), const Rect.fromLTRB(0.0, 43.0, 800.0, 400.0)); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTRB(36.0, 307.0, 113.0, 384.0)), + ); + expect( + tester.getRect(find.byKey(persistentFooterButton)), + const Rect.fromLTRB(28.0, 417.0, 128.0, 507.0), + ); // Includes 8px each top/bottom padding. + expect(tester.getRect(find.byKey(drawer)), const Rect.fromLTRB(596.0, 0.0, 800.0, 600.0)); + expect( + tester.getRect(find.byKey(bottomNavigationBar)), + const Rect.fromLTRB(0.0, 515.0, 800.0, 600.0), + ); + expect(tester.getRect(find.byKey(insideAppBar)), const Rect.fromLTRB(20.0, 30.0, 750.0, 43.0)); + expect(tester.getRect(find.byKey(insideBody)), const Rect.fromLTRB(20.0, 43.0, 750.0, 400.0)); + expect( + tester.getRect(find.byKey(insideFloatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTRB(36.0, 307.0, 113.0, 384.0)), + ); + expect( + tester.getRect(find.byKey(insidePersistentFooterButton)), + const Rect.fromLTRB(28.0, 417.0, 128.0, 507.0), + ); + expect( + tester.getRect(find.byKey(insideDrawer)), + const Rect.fromLTRB(596.0, 30.0, 750.0, 540.0), + ); + expect( + tester.getRect(find.byKey(insideBottomNavigationBar)), + const Rect.fromLTRB(20.0, 515.0, 750.0, 540.0), + ); + }); + + testWidgets('Scaffold and extreme window padding - persistent footer buttons only', ( + WidgetTester tester, + ) async { + final Key appBar = UniqueKey(); + final Key body = UniqueKey(); + final Key floatingActionButton = UniqueKey(); + final Key persistentFooterButton = UniqueKey(); + final Key drawer = UniqueKey(); + final Key insideAppBar = UniqueKey(); + final Key insideBody = UniqueKey(); + final Key insideFloatingActionButton = UniqueKey(); + final Key insidePersistentFooterButton = UniqueKey(); + final Key insideDrawer = UniqueKey(); + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 20.0, top: 30.0, right: 50.0, bottom: 60.0), + viewInsets: EdgeInsets.only(bottom: 200.0), + ), + child: Scaffold( + appBar: PreferredSize( + preferredSize: const Size(11.0, 13.0), + child: Container( + key: appBar, + child: SafeArea(child: Placeholder(key: insideAppBar)), + ), + ), + body: Container( + key: body, + child: SafeArea(child: Placeholder(key: insideBody)), + ), + floatingActionButton: SizedBox( + key: floatingActionButton, + width: 77.0, + height: 77.0, + child: SafeArea(child: Placeholder(key: insideFloatingActionButton)), + ), + persistentFooterButtons: <Widget>[ + SizedBox( + key: persistentFooterButton, + width: 100.0, + height: 90.0, + child: SafeArea(child: Placeholder(key: insidePersistentFooterButton)), + ), + ], + drawer: SizedBox( + key: drawer, + width: 204.0, + child: SafeArea(child: Placeholder(key: insideDrawer)), + ), + ), + ), + ), + ), + ); + // open drawer + await tester.flingFrom(const Offset(795.0, 5.0), const Offset(-200.0, 0.0), 10.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(tester.getRect(find.byKey(appBar)), const Rect.fromLTRB(0.0, 0.0, 800.0, 43.0)); + expect(tester.getRect(find.byKey(body)), const Rect.fromLTRB(0.0, 43.0, 800.0, 400.0)); + expect( + tester.getRect(find.byKey(floatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTRB(36.0, 307.0, 113.0, 384.0)), + ); + expect( + tester.getRect(find.byKey(persistentFooterButton)), + const Rect.fromLTRB(28.0, 442.0, 128.0, 532.0), + ); // Includes 8px each top/bottom padding. + expect(tester.getRect(find.byKey(drawer)), const Rect.fromLTRB(596.0, 0.0, 800.0, 600.0)); + expect(tester.getRect(find.byKey(insideAppBar)), const Rect.fromLTRB(20.0, 30.0, 750.0, 43.0)); + expect(tester.getRect(find.byKey(insideBody)), const Rect.fromLTRB(20.0, 43.0, 750.0, 400.0)); + expect( + tester.getRect(find.byKey(insideFloatingActionButton)), + rectMoreOrLessEquals(const Rect.fromLTRB(36.0, 307.0, 113.0, 384.0)), + ); + expect( + tester.getRect(find.byKey(insidePersistentFooterButton)), + const Rect.fromLTRB(28.0, 442.0, 128.0, 532.0), + ); + expect( + tester.getRect(find.byKey(insideDrawer)), + const Rect.fromLTRB(596.0, 30.0, 750.0, 540.0), + ); + }); + + group('ScaffoldGeometry', () { + testWidgets('bottomNavigationBar', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + bottomNavigationBar: ConstrainedBox( + key: key, + constraints: const BoxConstraints.expand(height: 80.0), + child: const _GeometryListener(), + ), + ), + ), + ); + + final RenderBox navigationBox = tester.renderObject(find.byKey(key)); + final RenderBox appBox = tester.renderObject(find.byType(MaterialApp)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); + final ScaffoldGeometry geometry = listenerState.cache.value; + + expect(geometry.bottomNavigationBarTop, appBox.size.height - navigationBox.size.height); + }); + + testWidgets('no bottomNavigationBar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints.expand(height: 80.0), + child: const _GeometryListener(), + ), + ), + ), + ); + + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); + final ScaffoldGeometry geometry = listenerState.cache.value; + + expect(geometry.bottomNavigationBarTop, null); + }); + + testWidgets('Scaffold BottomNavigationBar bottom padding is not consumed by viewInsets.', ( + WidgetTester tester, + ) async { + Widget boilerplate(Widget child) { + return Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality(textDirection: TextDirection.ltr, child: child), + ); + } + + final Widget child = boilerplate( + Scaffold( + resizeToAvoidBottomInset: false, + body: const Placeholder(), + bottomNavigationBar: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.add), label: 'test'), + BottomNavigationBarItem(icon: Icon(Icons.add), label: 'test'), + ], + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ); + final Offset initialPoint = tester.getCenter(find.byType(Placeholder)); + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.only(bottom: 20), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: child, + ), + ); + final Offset finalPoint = tester.getCenter(find.byType(Placeholder)); + expect(initialPoint, finalPoint); + }); + + testWidgets('floatingActionButton', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + floatingActionButton: FloatingActionButton( + key: key, + child: const _GeometryListener(), + onPressed: () {}, + ), + ), + ), + ); + + final RenderBox floatingActionButtonBox = tester.renderObject(find.byKey(key)); + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); + final ScaffoldGeometry geometry = listenerState.cache.value; + + final Rect fabRect = + floatingActionButtonBox.localToGlobal(Offset.zero) & floatingActionButtonBox.size; + + expect(geometry.floatingActionButtonArea, fabRect); + }); + + testWidgets('no floatingActionButton', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints.expand(height: 80.0), + child: const _GeometryListener(), + ), + ), + ), + ); + + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); + final ScaffoldGeometry geometry = listenerState.cache.value; + + expect(geometry.floatingActionButtonArea, null); + }); + + testWidgets('floatingActionButton entrance/exit animation', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints.expand(height: 80.0), + child: const _GeometryListener(), + ), + ), + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + floatingActionButton: FloatingActionButton( + key: key, + child: const _GeometryListener(), + onPressed: () {}, + ), + ), + ), + ); + + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); + await tester.pump(const Duration(milliseconds: 50)); + + ScaffoldGeometry geometry = listenerState.cache.value; + final Rect transitioningFabRect = geometry.floatingActionButtonArea!; + + final double transitioningRotation = tester + .widget<RotationTransition>(find.byType(RotationTransition)) + .turns + .value; + + await tester.pump(const Duration(seconds: 3)); + geometry = listenerState.cache.value; + final RenderBox floatingActionButtonBox = tester.renderObject(find.byKey(key)); + final Rect fabRect = + floatingActionButtonBox.localToGlobal(Offset.zero) & floatingActionButtonBox.size; + + final double completedRotation = tester + .widget<RotationTransition>(find.byType(RotationTransition)) + .turns + .value; + + expect(transitioningRotation, lessThan(1.0)); + + expect(completedRotation, equals(1.0)); + + expect(geometry.floatingActionButtonArea, fabRect); + + expect(geometry.floatingActionButtonArea!.center, transitioningFabRect.center); + + expect(geometry.floatingActionButtonArea!.width, greaterThan(transitioningFabRect.width)); + + expect(geometry.floatingActionButtonArea!.height, greaterThan(transitioningFabRect.height)); + }); + + testWidgets('change notifications', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + var numNotificationsAtLastFrame = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints.expand(height: 80.0), + child: const _GeometryListener(), + ), + ), + ), + ); + + final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); + + expect(listenerState.numNotifications, greaterThan(numNotificationsAtLastFrame)); + numNotificationsAtLastFrame = listenerState.numNotifications; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + floatingActionButton: FloatingActionButton( + key: key, + child: const _GeometryListener(), + onPressed: () {}, + ), + ), + ), + ); + + expect(listenerState.numNotifications, greaterThan(numNotificationsAtLastFrame)); + numNotificationsAtLastFrame = listenerState.numNotifications; + + await tester.pump(const Duration(milliseconds: 50)); + + expect(listenerState.numNotifications, greaterThan(numNotificationsAtLastFrame)); + numNotificationsAtLastFrame = listenerState.numNotifications; + + await tester.pump(const Duration(seconds: 3)); + + expect(listenerState.numNotifications, greaterThan(numNotificationsAtLastFrame)); + numNotificationsAtLastFrame = listenerState.numNotifications; + }); + + testWidgets('Simultaneous drawers on either side', (WidgetTester tester) async { + const bodyLabel = 'I am the body'; + const drawerLabel = 'I am the label on start side'; + const endDrawerLabel = 'I am the label on end side'; + + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Text(bodyLabel), + drawer: Drawer(child: Text(drawerLabel)), + endDrawer: Drawer(child: Text(endDrawerLabel)), + ), + ), + ); + + expect(semantics, includesNodeWith(label: bodyLabel)); + expect(semantics, isNot(includesNodeWith(label: drawerLabel))); + expect(semantics, isNot(includesNodeWith(label: endDrawerLabel))); + + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(semantics, isNot(includesNodeWith(label: bodyLabel))); + expect(semantics, includesNodeWith(label: drawerLabel)); + + state.openEndDrawer(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(semantics, isNot(includesNodeWith(label: bodyLabel))); + expect(semantics, includesNodeWith(label: endDrawerLabel)); + + semantics.dispose(); + }); + + testWidgets('Drawer state query correctly', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SafeArea( + left: false, + right: false, + bottom: false, + child: Scaffold( + endDrawer: const Drawer(child: Text('endDrawer')), + drawer: const Drawer(child: Text('drawer')), + body: const Text('scaffold body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ), + ); + + final ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + + final Finder drawerOpenButton = find.byType(IconButton).first; + final Finder endDrawerOpenButton = find.byType(IconButton).last; + + await tester.tap(drawerOpenButton); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + await tester.tap(endDrawerOpenButton, warnIfMissed: false); // hits the modal barrier + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + await tester.tap(endDrawerOpenButton); + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, true); + await tester.tap(drawerOpenButton, warnIfMissed: false); // hits the modal barrier + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, false); + + scaffoldState.openDrawer(); + expect(scaffoldState.isDrawerOpen, true); + await tester.tap(endDrawerOpenButton, warnIfMissed: false); // hits the modal barrier + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + scaffoldState.openEndDrawer(); + expect(scaffoldState.isEndDrawerOpen, true); + + scaffoldState.openDrawer(); + expect(scaffoldState.isDrawerOpen, true); + }); + + testWidgets('Dual Drawer Opening', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SafeArea( + left: false, + right: false, + bottom: false, + child: Scaffold( + endDrawer: const Drawer(child: Text('endDrawer')), + drawer: const Drawer(child: Text('drawer')), + body: const Text('scaffold body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ), + ); + + // Open Drawer, tap on end drawer, which closes the drawer, but does + // not open the drawer. + await tester.tap(find.byType(IconButton).first); + await tester.pumpAndSettle(); + await tester.tap(find.byType(IconButton).last, warnIfMissed: false); // hits the modal barrier + await tester.pumpAndSettle(); + + expect(find.text('endDrawer'), findsNothing); + expect(find.text('drawer'), findsNothing); + + // Tapping the first opens the first drawer + await tester.tap(find.byType(IconButton).first); + await tester.pumpAndSettle(); + + expect(find.text('endDrawer'), findsNothing); + expect(find.text('drawer'), findsOneWidget); + + // Tapping on the end drawer and then on the drawer should close the + // drawer and then reopen it. + await tester.tap(find.byType(IconButton).last, warnIfMissed: false); // hits the modal barrier + await tester.pumpAndSettle(); + await tester.tap(find.byType(IconButton).first); + await tester.pumpAndSettle(); + + expect(find.text('endDrawer'), findsNothing); + expect(find.text('drawer'), findsOneWidget); + }); + + testWidgets('Drawer opens correctly with padding from MediaQuery (LTR)', ( + WidgetTester tester, + ) async { + const simulatedNotchSize = 40.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + + ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + await tester.dragFrom(const Offset(simulatedNotchSize + 15.0, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(simulatedNotchSize, 0, 0, 0)), + child: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ), + ); + scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + await tester.dragFrom(const Offset(simulatedNotchSize + 15.0, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + }); + + testWidgets('Drawer opens correctly with padding from MediaQuery (RTL)', ( + WidgetTester tester, + ) async { + const simulatedNotchSize = 40.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + + final double scaffoldWidth = tester.renderObject<RenderBox>(find.byType(Scaffold)).size.width; + ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + await tester.dragFrom( + Offset(scaffoldWidth - simulatedNotchSize - 15.0, 100), + const Offset(-300, 0), + ); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(0, 0, simulatedNotchSize, 0)), + child: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ), + ), + ); + scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + await tester.dragFrom( + Offset(scaffoldWidth - simulatedNotchSize - 15.0, 100), + const Offset(-300, 0), + ); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + }); + }); + + testWidgets('Drawer opens correctly with custom edgeDragWidth', (WidgetTester tester) async { + // The default edge drag width is 20.0. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + await tester.dragFrom(const Offset(35, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + drawerEdgeDragWidth: 40.0, + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + await tester.dragFrom(const Offset(35, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + }); + + testWidgets( + 'Drawer does not open with a drag gesture when it is disabled on mobile', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + // Test that we can open the drawer with a drag gesture when + // `Scaffold.drawerEnableDragGesture` is true. + await tester.dragFrom(const Offset(0, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + + await tester.dragFrom(const Offset(300, 100), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + drawerEnableOpenDragGesture: false, + body: const Text('Scaffold body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + // Test that we cannot open the drawer with a drag gesture when + // `Scaffold.drawerEnableDragGesture` is false. + await tester.dragFrom(const Offset(0, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + // Test that we can close drawer with a drag gesture when + // `Scaffold.drawerEnableDragGesture` is false. + final Finder drawerOpenButton = find.byType(IconButton).first; + await tester.tap(drawerOpenButton); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + + await tester.dragFrom(const Offset(300, 100), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets('Drawer does not open with a drag gesture on desktop', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + final ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isDrawerOpen, false); + + // Test that we cannot open the drawer with a drag gesture. + await tester.dragFrom(const Offset(0, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + + // Test that we can open the drawer with a tap gesture on drawer icon button. + final Finder drawerOpenButton = find.byType(IconButton).first; + await tester.tap(drawerOpenButton); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + + // Test that we cannot close the drawer with a drag gesture. + await tester.dragFrom(const Offset(300, 100), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, true); + + // Test that we can close the drawer with a tap gesture in the body. + await tester.tapAt(const Offset(500, 300)); + await tester.pumpAndSettle(); + expect(scaffoldState.isDrawerOpen, false); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('End drawer does not open with a drag gesture when it is disabled', ( + WidgetTester tester, + ) async { + late double screenWidth; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + screenWidth = MediaQuery.sizeOf(context).width; + return Scaffold( + endDrawer: const Drawer(child: Text('Drawer')), + body: const Text('Scaffold Body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ); + }, + ), + ), + ); + ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isEndDrawerOpen, false); + + // Test that we can open the end drawer with a drag gesture when + // `Scaffold.endDrawerEnableDragGesture` is true. + await tester.dragFrom(Offset(screenWidth - 1, 100), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, true); + + await tester.dragFrom(Offset(screenWidth - 300, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, false); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + endDrawer: const Drawer(child: Text('Drawer')), + endDrawerEnableOpenDragGesture: false, + body: const Text('Scaffold body'), + appBar: AppBar(centerTitle: true, title: const Text('Title')), + ), + ), + ); + scaffoldState = tester.state(find.byType(Scaffold)); + expect(scaffoldState.isEndDrawerOpen, false); + + // Test that we cannot open the end drawer with a drag gesture when + // `Scaffold.endDrawerEnableDragGesture` is false. + await tester.dragFrom(Offset(screenWidth - 1, 100), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, false); + + // Test that we can close the end drawer a with drag gesture when + // `Scaffold.endDrawerEnableDragGesture` is false. + final Finder endDrawerOpenButton = find.byType(IconButton).first; + await tester.tap(endDrawerOpenButton); + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, true); + + await tester.dragFrom(Offset(screenWidth - 300, 100), const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(scaffoldState.isEndDrawerOpen, false); + }); + + testWidgets('Nested scaffold body insets', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/20295 + final Key bodyKey = UniqueKey(); + + Widget buildFrame(bool? innerResizeToAvoidBottomInset, bool? outerResizeToAvoidBottomInset) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(viewInsets: EdgeInsets.only(bottom: 100.0)), + child: Builder( + builder: (BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: outerResizeToAvoidBottomInset, + body: Builder( + builder: (BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: innerResizeToAvoidBottomInset, + body: Container(key: bodyKey), + ); + }, + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(true, true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + + await tester.pumpWidget(buildFrame(false, true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + + await tester.pumpWidget(buildFrame(true, false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + + // This is the only case where the body is not bottom inset. + await tester.pumpWidget(buildFrame(false, false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + + await tester.pumpWidget(buildFrame(null, null)); // resizeToAvoidBottomInset default is true + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + + await tester.pumpWidget(buildFrame(null, false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + + await tester.pumpWidget(buildFrame(false, null)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + }); + + group('FlutterError control test', () { + testWidgets('showBottomSheet() while Scaffold has bottom sheet', (WidgetTester tester) async { + final key = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: key, + body: Center(child: Container()), + bottomSheet: const Text('Bottom sheet'), + ), + ), + ); + late FlutterError error; + try { + key.currentState!.showBottomSheet((BuildContext context) { + final ThemeData themeData = Theme.of(context); + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: themeData.disabledColor)), + ), + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Text( + 'This is a Material persistent bottom sheet. Drag downwards to dismiss it.', + textAlign: TextAlign.center, + style: TextStyle(color: themeData.colorScheme.secondary, fontSize: 24.0), + ), + ), + ); + }); + } on FlutterError catch (e) { + error = e; + } finally { + expect(error, isNotNull); + expect( + error.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' Scaffold.bottomSheet cannot be specified while a bottom sheet\n' + ' displayed with showBottomSheet() is still visible.\n' + ' Rebuild the Scaffold with a null bottomSheet before calling\n' + ' showBottomSheet().\n', + ), + ); + } + }); + + testWidgets( + 'didUpdate bottomSheet while a previous bottom sheet is still displayed', + experimentalLeakTesting: LeakTesting.settings + .withIgnoredAll(), // leaking by design because of exception + (WidgetTester tester) async { + final key = GlobalKey<ScaffoldState>(); + const buttonKey = Key('button'); + final errors = <FlutterErrorDetails>[]; + FlutterError.onError = (FlutterErrorDetails error) => errors.add(error); + var state = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + key: key, + body: Container(), + floatingActionButton: FloatingActionButton( + key: buttonKey, + onPressed: () { + state += 1; + setState(() {}); + }, + ), + bottomSheet: state == 0 ? null : const SizedBox(), + ); + }, + ), + ), + ); + key.currentState!.showBottomSheet((_) => Container()); + await tester.tap(find.byKey(buttonKey)); + await tester.pump(); + expect(errors, isNotEmpty); + expect(errors.first.exception, isFlutterError); + final error = errors.first.exception as FlutterError; + expect(error.diagnostics.length, 2); + expect(error.diagnostics.last.level, DiagnosticLevel.hint); + expect( + error.diagnostics.last.toStringDeep(), + 'Use the PersistentBottomSheetController returned by\n' + 'showBottomSheet() to close the old bottom sheet before creating a\n' + 'Scaffold with a (non null) bottomSheet.\n', + ); + expect( + error.toStringDeep(), + 'FlutterError\n' + ' Scaffold.bottomSheet cannot be specified while a bottom sheet\n' + ' displayed with showBottomSheet() is still visible.\n' + ' Use the PersistentBottomSheetController returned by\n' + ' showBottomSheet() to close the old bottom sheet before creating a\n' + ' Scaffold with a (non null) bottomSheet.\n', + ); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Call to Scaffold.of() without context', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + Scaffold.of(context).showBottomSheet((BuildContext context) { + return Container(); + }); + return Container(); + }, + ), + ), + ); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + final error = exception as FlutterError; + expect(error.diagnostics.length, 5); + expect(error.diagnostics[2].level, DiagnosticLevel.hint); + expect( + error.diagnostics[2].toStringDeep(), + equalsIgnoringHashCodes( + 'There are several ways to avoid this problem. The simplest is to\n' + 'use a Builder to get a context that is "under" the Scaffold. For\n' + 'an example of this, please see the documentation for\n' + 'Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html\n', + ), + ); + expect(error.diagnostics[3].level, DiagnosticLevel.hint); + expect( + error.diagnostics[3].toStringDeep(), + equalsIgnoringHashCodes( + 'A more efficient solution is to split your build function into\n' + 'several widgets. This introduces a new context from which you can\n' + 'obtain the Scaffold. In this solution, you would have an outer\n' + 'widget that creates the Scaffold populated by instances of your\n' + 'new inner widgets, and then in these inner widgets you would use\n' + 'Scaffold.of().\n' + 'A less elegant but more expedient solution is assign a GlobalKey\n' + 'to the Scaffold, then use the key.currentState property to obtain\n' + 'the ScaffoldState rather than using the Scaffold.of() function.\n', + ), + ); + expect(error.diagnostics[4], isA<DiagnosticsProperty<Element>>()); + expect( + error.toStringDeep(), + 'FlutterError\n' + ' Scaffold.of() called with a context that does not contain a\n' + ' Scaffold.\n' + ' No Scaffold ancestor could be found starting from the context\n' + ' that was passed to Scaffold.of(). This usually happens when the\n' + ' context provided is from the same StatefulWidget as that whose\n' + ' build function actually creates the Scaffold widget being sought.\n' + ' There are several ways to avoid this problem. The simplest is to\n' + ' use a Builder to get a context that is "under" the Scaffold. For\n' + ' an example of this, please see the documentation for\n' + ' Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html\n' + ' A more efficient solution is to split your build function into\n' + ' several widgets. This introduces a new context from which you can\n' + ' obtain the Scaffold. In this solution, you would have an outer\n' + ' widget that creates the Scaffold populated by instances of your\n' + ' new inner widgets, and then in these inner widgets you would use\n' + ' Scaffold.of().\n' + ' A less elegant but more expedient solution is assign a GlobalKey\n' + ' to the Scaffold, then use the key.currentState property to obtain\n' + ' the ScaffoldState rather than using the Scaffold.of() function.\n' + ' The context used was:\n' + ' Builder\n', + ); + await tester.pumpAndSettle(); + }); + + testWidgets('Call to Scaffold.geometryOf() without context', (WidgetTester tester) async { + ValueListenable<ScaffoldGeometry>? geometry; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + geometry = Scaffold.geometryOf(context); + return Container(); + }, + ), + ), + ); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + expect(geometry, isNull); + final error = exception as FlutterError; + expect(error.diagnostics.length, 5); + expect(error.diagnostics[2].level, DiagnosticLevel.hint); + expect( + error.diagnostics[2].toStringDeep(), + equalsIgnoringHashCodes( + 'There are several ways to avoid this problem. The simplest is to\n' + 'use a Builder to get a context that is "under" the Scaffold. For\n' + 'an example of this, please see the documentation for\n' + 'Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html\n', + ), + ); + expect(error.diagnostics[3].level, DiagnosticLevel.hint); + expect( + error.diagnostics[3].toStringDeep(), + equalsIgnoringHashCodes( + 'A more efficient solution is to split your build function into\n' + 'several widgets. This introduces a new context from which you can\n' + 'obtain the Scaffold. In this solution, you would have an outer\n' + 'widget that creates the Scaffold populated by instances of your\n' + 'new inner widgets, and then in these inner widgets you would use\n' + 'Scaffold.geometryOf().\n', + ), + ); + expect(error.diagnostics[4], isA<DiagnosticsProperty<Element>>()); + expect( + error.toStringDeep(), + 'FlutterError\n' + ' Scaffold.geometryOf() called with a context that does not contain\n' + ' a Scaffold.\n' + ' This usually happens when the context provided is from the same\n' + ' StatefulWidget as that whose build function actually creates the\n' + ' Scaffold widget being sought.\n' + ' There are several ways to avoid this problem. The simplest is to\n' + ' use a Builder to get a context that is "under" the Scaffold. For\n' + ' an example of this, please see the documentation for\n' + ' Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html\n' + ' A more efficient solution is to split your build function into\n' + ' several widgets. This introduces a new context from which you can\n' + ' obtain the Scaffold. In this solution, you would have an outer\n' + ' widget that creates the Scaffold populated by instances of your\n' + ' new inner widgets, and then in these inner widgets you would use\n' + ' Scaffold.geometryOf().\n' + ' The context used was:\n' + ' Builder\n', + ); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'FloatingActionButton always keeps the same position regardless of extendBodyBehindAppBar', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endTop, + ), + ), + ); + final Offset defaultOffset = tester.getCenter(find.byType(FloatingActionButton)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(), + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endTop, + extendBodyBehindAppBar: true, + ), + ), + ); + final Offset extendedBodyOffset = tester.getCenter(find.byType(FloatingActionButton)); + + expect(defaultOffset.dy, extendedBodyOffset.dy); + }, + ); + }); + + testWidgets('ScaffoldMessenger.maybeOf can return null if not found', ( + WidgetTester tester, + ) async { + ScaffoldMessengerState? scaffoldMessenger; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + scaffoldMessenger = ScaffoldMessenger.maybeOf(context); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + await tester.pump(); + expect(scaffoldMessenger, isNull); + }); + + testWidgets('ScaffoldMessenger.of will assert if not found', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + + final exceptions = <dynamic>[]; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + exceptions.add(details.exception); + }; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(tapTarget)); + FlutterError.onError = oldHandler; + + expect(exceptions.length, 1); + expect(exceptions.single.runtimeType, FlutterError); + final error = exceptions.first as FlutterError; + expect(error.diagnostics.length, 5); + expect(error.diagnostics[2], isA<DiagnosticsProperty<Element>>()); + expect(error.diagnostics[3], isA<DiagnosticsBlock>()); + expect(error.diagnostics[4].level, DiagnosticLevel.hint); + expect( + error.diagnostics[4].toStringDeep(), + equalsIgnoringHashCodes( + 'Typically, the ScaffoldMessenger widget is introduced by the\n' + 'MaterialApp at the top of your application widget tree.\n', + ), + ); + expect( + error.toStringDeep(), + startsWith( + 'FlutterError\n' + ' No ScaffoldMessenger widget found.\n' + ' Builder widgets require a ScaffoldMessenger widget ancestor.\n' + ' The specific widget that could not find a ScaffoldMessenger\n' + ' ancestor was:\n' + ' Builder\n' + ' The ancestors of this widget were:\n', + ), + ); + expect( + error.toStringDeep(), + endsWith( + ' [root]\n' + ' Typically, the ScaffoldMessenger widget is introduced by the\n' + ' MaterialApp at the top of your application widget tree.\n', + ), + ); + }); + + testWidgets('ScaffoldMessenger checks for nesting when a new Scaffold is registered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/77251 + const snackBarContent = 'SnackBar Content'; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) => Scaffold( + body: Scaffold( + body: TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold( + body: Column( + children: <Widget>[ + TextButton( + onPressed: () { + const snackBar = SnackBar( + content: Text(snackBarContent), + behavior: SnackBarBehavior.floating, + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, + child: const Text('Show SnackBar'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Pop route'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push route'), + ), + ), + ), + ), + ), + ); + + expect(find.text(snackBarContent), findsNothing); + await tester.tap(find.text('Push route')); + await tester.pumpAndSettle(); + expect(find.text(snackBarContent), findsNothing); + expect(find.text('Pop route'), findsOneWidget); + + // Show SnackBar on second page + await tester.tap(find.text('Show SnackBar')); + await tester.pump(); + expect(find.text(snackBarContent), findsOneWidget); + // Pop the second page, the SnackBar completes a hero animation to the next route. + // If we have not handled the nested Scaffolds properly, this will throw an + // exception as duplicate SnackBars on the first route would have a common hero tag. + await tester.tap(find.text('Pop route')); + await tester.pump(); + // There are SnackBars two during the execution of the hero animation. + expect(find.text(snackBarContent), findsNWidgets(2)); + await tester.pumpAndSettle(); + expect(find.text(snackBarContent), findsOneWidget); + // Allow the SnackBar to animate out + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + expect(find.text(snackBarContent), findsNothing); + }); + + testWidgets('Drawer can be dismissed with escape keyboard shortcut', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/106131 + var isDrawerOpen = false; + var isEndDrawerOpen = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + drawer: Container(color: Colors.blue), + onDrawerChanged: (bool isOpen) { + isDrawerOpen = isOpen; + }, + endDrawer: Container(color: Colors.green), + onEndDrawerChanged: (bool isOpen) { + isEndDrawerOpen = isOpen; + }, + body: Container(), + ), + ), + ); + + final ScaffoldState scaffoldState = tester.state(find.byType(Scaffold)); + + scaffoldState.openDrawer(); + await tester.pumpAndSettle(); + expect(isDrawerOpen, true); + expect(isEndDrawerOpen, false); + + // Try to dismiss the drawer with the shortcut key + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(isDrawerOpen, false); + expect(isEndDrawerOpen, false); + + scaffoldState.openEndDrawer(); + await tester.pumpAndSettle(); + expect(isDrawerOpen, false); + expect(isEndDrawerOpen, true); + + // Try to dismiss the drawer with the shortcut key + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(isDrawerOpen, false); + expect(isEndDrawerOpen, false); + }); + + testWidgets( + 'ScaffoldMessenger showSnackBar throws an intuitive error message if called during build', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('SnackBar'))); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + final error = tester.takeException() as FlutterError; + final summary = error.diagnostics.first as ErrorSummary; + expect(summary.toString(), 'The showSnackBar() method cannot be called during build.'); + }, + ); + + testWidgets('Persistent BottomSheet is not dismissible via a11y means', ( + WidgetTester tester, + ) async { + final Key bottomSheetKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomSheet: Container( + key: bottomSheetKey, + height: 44, + color: Colors.blue, + child: const Text('BottomSheet'), + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(bottomSheetKey)), + // Having the redundant argument value makes the intent of the test clear. + // ignore: avoid_redundant_argument_values + matchesSemantics(label: 'BottomSheet', hasDismissAction: false), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/117004 + testWidgets('can rebuild and remove bottomSheet at the same time', (WidgetTester tester) async { + var themeIsLight = true; + bool? defaultBottomSheet = true; + final GlobalKey bottomSheetKey1 = GlobalKey(); + final GlobalKey bottomSheetKey2 = GlobalKey(); + late StateSetter setState; + + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return MaterialApp( + theme: themeIsLight ? ThemeData() : ThemeData.dark(), + home: Scaffold( + bottomSheet: defaultBottomSheet == null + ? null + : defaultBottomSheet! + ? Container( + key: bottomSheetKey1, + width: double.infinity, + height: 100, + color: Colors.blue, + child: const Text('BottomSheet'), + ) + : Container( + key: bottomSheetKey2, + width: double.infinity, + height: 100, + color: Colors.red, + child: const Text('BottomSheet'), + ), + body: const Placeholder(), + ), + ); + }, + ), + ); + + expect(find.byKey(bottomSheetKey1), findsOneWidget); + expect(find.byKey(bottomSheetKey2), findsNothing); + + // Change to the other bottomSheet. + setState(() { + defaultBottomSheet = false; + }); + expect(find.byKey(bottomSheetKey1), findsOneWidget); + expect(find.byKey(bottomSheetKey2), findsNothing); + await tester.pumpAndSettle(); + expect(find.byKey(bottomSheetKey1), findsNothing); + expect(find.byKey(bottomSheetKey2), findsOneWidget); + + // Set bottomSheet to null, which starts its exit animation. + setState(() { + defaultBottomSheet = null; + }); + expect(find.byKey(bottomSheetKey1), findsNothing); + expect(find.byKey(bottomSheetKey2), findsOneWidget); + + // While the bottomSheet is on the way out, change the theme to cause it to + // rebuild. + setState(() { + themeIsLight = false; + }); + expect(find.byKey(bottomSheetKey1), findsNothing); + expect(find.byKey(bottomSheetKey2), findsOneWidget); + + // The most recent bottomSheet remains on screen during the exit animation. + await tester.pump(_bottomSheetExitDuration); + expect(find.byKey(bottomSheetKey1), findsNothing); + expect(find.byKey(bottomSheetKey2), findsOneWidget); + + // After animating out, the bottomSheet is gone. + await tester.pumpAndSettle(); + expect(find.byKey(bottomSheetKey1), findsNothing); + expect(find.byKey(bottomSheetKey2), findsNothing); + + expect(tester.takeException(), isNull); + }); + + testWidgets('showBottomSheet removes scrim when draggable sheet is dismissed', ( + WidgetTester tester, + ) async { + final draggableController = DraggableScrollableController(); + addTearDown(draggableController.dispose); + final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey(); + PersistentBottomSheetController? sheetController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + ), + ); + + sheetController = scaffoldKey.currentState!.showBottomSheet((_) { + return DraggableScrollableSheet( + expand: false, + controller: draggableController, + builder: (BuildContext context, ScrollController scrollController) { + return SingleChildScrollView(controller: scrollController, child: const Placeholder()); + }, + ); + }); + + Finder findModalBarrier() => + find.descendant(of: find.byType(Scaffold), matching: find.byType(ModalBarrier)); + + await tester.pump(); + expect(find.byType(BottomSheet), findsOneWidget); + + // The scrim is not present yet. + expect(findModalBarrier(), findsNothing); + + // Expand the sheet to 80% of parent height to show the scrim. + draggableController.jumpTo(0.8); + await tester.pump(); + expect(findModalBarrier(), findsOneWidget); + + // Dismiss the sheet. + sheetController.close(); + await tester.pumpAndSettle(); + + // The scrim should be gone. + expect(findModalBarrier(), findsNothing); + }); + + testWidgets("Closing bottom sheet & removing FAB at the same time doesn't throw assertion", ( + WidgetTester tester, + ) async { + final Key bottomSheetKey = UniqueKey(); + PersistentBottomSheetController? controller; + var show = true; + + await tester.pumpWidget( + StatefulBuilder( + builder: (_, StateSetter setState) => MaterialApp( + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) => ElevatedButton( + onPressed: () { + if (controller == null) { + controller = showBottomSheet( + context: context, + builder: (_) => Container(key: bottomSheetKey, height: 200), + ); + } else { + controller!.close(); + controller = null; + } + }, + child: const Text('BottomSheet'), + ), + ), + ), + floatingActionButton: show + ? FloatingActionButton(onPressed: () => setState(() => show = false)) + : null, + ), + ), + ), + ); + + // Show bottom sheet. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Bottom sheet and FAB are visible. + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byKey(bottomSheetKey), findsOneWidget); + + // Close bottom sheet while removing FAB. + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); // start animation + await tester.tap(find.byType(ElevatedButton)); + // Let the animation finish. + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Bottom sheet and FAB are gone. + expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byKey(bottomSheetKey), findsNothing); + + // No exception is thrown. + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/115924. + testWidgets('Default ScaffoldMessenger can access ambient theme', (WidgetTester tester) async { + final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); + + final colorScheme = ColorScheme.fromSeed(seedColor: Colors.deepPurple); + final customTheme = ThemeData( + colorScheme: colorScheme, + visualDensity: VisualDensity.comfortable, + ); + + await tester.pumpWidget( + MaterialApp( + scaffoldMessengerKey: scaffoldMessengerKey, + theme: customTheme, + home: const SizedBox.shrink(), + ), + ); + + final ThemeData messengerTheme = Theme.of(scaffoldMessengerKey.currentContext!); + expect(messengerTheme.colorScheme, colorScheme); + expect(messengerTheme.visualDensity, VisualDensity.comfortable); + }); + + testWidgets('ScaffoldMessenger showSnackBar default animation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('I am a snack bar.'), showCloseIcon: true), + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + ), + ); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + }); + + testWidgets('ScaffoldMessenger showSnackBar animation can be customized', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('I am a snack bar.'), showCloseIcon: true), + snackBarAnimationStyle: const AnimationStyle( + duration: Duration(milliseconds: 1200), + reverseDuration: Duration(milliseconds: 600), + ), + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + ), + ); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(602.6, 0.1)); + + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 600)); // Advance the animation by 600ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ns. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + }); + + testWidgets('Updated snackBarAnimationStyle updates snack bar animation', ( + WidgetTester tester, + ) async { + Widget buildSnackBar(AnimationStyle snackBarAnimationStyle) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('I am a snack bar.'), showCloseIcon: true), + snackBarAnimationStyle: snackBarAnimationStyle, + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + ); + } + + // Test custom animation style. + await tester.pumpWidget( + buildSnackBar( + const AnimationStyle( + duration: Duration(milliseconds: 800), + reverseDuration: Duration(milliseconds: 400), + ), + ), + ); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // Advance the animation by 400ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 400)); // Advance the animation by 400ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // Advance the animation by 400ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + + // Test no animation style. + await tester.pumpWidget(buildSnackBar(AnimationStyle.noAnimation)); + await tester.pumpAndSettle(); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + // The SnackBar is dismissed. + expect(find.text('I am a snack bar.'), findsNothing); + }); + + testWidgets('snackBarAnimationStyle with only reverseDuration uses default forward duration', ( + WidgetTester tester, + ) async { + Widget buildSnackBar(AnimationStyle snackBarAnimationStyle) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('I am a snack bar.'), showCloseIcon: true), + snackBarAnimationStyle: snackBarAnimationStyle, + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + ); + } + + // Test custom animation style with only reverseDuration. + await tester.pumpWidget( + buildSnackBar(const AnimationStyle(reverseDuration: Duration(milliseconds: 400))), + ); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + // Advance the animation by 1/2 of the reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + // Advance the animation by 1/2 of the reverse duration. + await tester.pump(const Duration(milliseconds: 200)); // Advance the animation by 200ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + }); + + testWidgets('Scaffold showBottomSheet default animation', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + // Test default bottom sheet animation. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showBottomSheet((BuildContext context) { + return SizedBox.expand( + child: ColoredBox( + key: sheetKey, + color: Theme.of(context).colorScheme.primary, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ), + ); + }); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + // Tap the 'X' to show the bottom sheet. + await tester.tap(find.text('X')); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1)); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + // Advance the animation by 1/2 of the default reverse duration. + await tester.pump(const Duration(milliseconds: 100)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1)); + + // Advance the animation by 1/2 of the default reverse duration. + await tester.pump(const Duration(milliseconds: 100)); + + // The bottom sheet is dismissed. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0)); + }); + + testWidgets('Scaffold showBottomSheet animation can be customized', (WidgetTester tester) async { + final Key sheetKey = UniqueKey(); + + Widget buildWidget({AnimationStyle? sheetAnimationStyle}) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showBottomSheet(sheetAnimationStyle: sheetAnimationStyle, ( + BuildContext context, + ) { + return SizedBox.expand( + child: ColoredBox( + key: sheetKey, + color: Theme.of(context).colorScheme.primary, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Close'), + ), + ), + ); + }); + }, + child: const Text('X'), + ); + }, + ), + ), + ); + } + + // Test custom animation style. + await tester.pumpWidget( + buildWidget( + sheetAnimationStyle: const AnimationStyle( + duration: Duration(milliseconds: 800), + reverseDuration: Duration(milliseconds: 400), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 400)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1)); + + // Advance the animation by 1/2 of the custom forward duration. + await tester.pump(const Duration(milliseconds: 400)); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + // Advance the animation by 1/2 of the custom reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The bottom sheet is partially visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, closeTo(134.6, 0.1)); + + // Advance the animation by 1/2 of the custom reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The bottom sheet is dismissed. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(600.0)); + + // Test no animation style. + await tester.pumpWidget(buildWidget(sheetAnimationStyle: AnimationStyle.noAnimation)); + await tester.pumpAndSettle(); + await tester.tap(find.text('X')); + await tester.pump(); + + // The bottom sheet is fully visible. + expect(tester.getTopLeft(find.byKey(sheetKey)).dy, equals(0.0)); + + // Dismiss the bottom sheet. + await tester.tap(find.widgetWithText(FilledButton, 'Close')); + await tester.pump(); + + // The bottom sheet is dismissed. + expect(find.byKey(sheetKey), findsNothing); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/145585. + testWidgets('FAB default entrance and exit animations', (WidgetTester tester) async { + var showFab = false; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: ElevatedButton( + onPressed: () { + setState(() { + showFab = !showFab; + }); + }, + child: const Text('Toggle FAB'), + ), + floatingActionButton: !showFab + ? null + : FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)), + ); + }, + ), + ), + ); + + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + + // Tap the button to show the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is partially animated in. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, closeTo(743.8, 0.1)); + + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is fully animated in. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the button to hide the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is partially animated out. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, closeTo(747.1, 0.1)); + + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is fully animated out. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(756.0)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 50ms. + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/145585. + testWidgets('FAB default entrance and exit animations can be disabled', ( + WidgetTester tester, + ) async { + var showFab = false; + FloatingActionButtonLocation fabLocation = FloatingActionButtonLocation.endFloat; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + // Disable FAB animations. + floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, + floatingActionButtonLocation: fabLocation, + body: Column( + children: <Widget>[ + ElevatedButton( + onPressed: () { + setState(() { + showFab = !showFab; + }); + }, + child: const Text('Toggle FAB'), + ), + ElevatedButton( + onPressed: () { + setState(() { + fabLocation = FloatingActionButtonLocation.centerFloat; + }); + }, + child: const Text('Update FAB Location'), + ), + ], + ), + floatingActionButton: !showFab + ? null + : FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)), + ); + }, + ), + ), + ); + + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + + // Tap the button to show the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is visible. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the button to hide the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + + // Tap the button to show the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is visible. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the update location button. + await tester.tap(find.widgetWithText(ElevatedButton, 'Update FAB Location')); + await tester.pump(); + + // FAB is visible at the new location. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(372.0)); + + // Tap the button to hide the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + }); + + testWidgets('Scaffold FAB animates without rebuilding', (WidgetTester tester) async { + bool? dirty; + bool? checkDirty() { + final Finder finder = find.descendant( + of: find.byType(ScrollNotificationObserver), + matching: find.ancestor( + of: find.byType(CustomMultiChildLayout), + matching: find.byWidgetPredicate((widget) { + return widget is AnimatedBuilder || widget is Builder; + }), + ), + ); + return finder.tryEvaluate() ? tester.element(finder).dirty : null; + } + + final fabLocation = ValueNotifier<FloatingActionButtonLocation>(.centerDocked); + const builderKey = Key('Builder'); + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + key: builderKey, + valueListenable: fabLocation, + builder: (context, location, child) { + dirty = checkDirty(); + + return Scaffold( + body: const SizedBox.expand(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + floatingActionButtonLocation: location, + ); + }, + ), + ), + ); + + // Switch the FAB location to trigger the animation. + fabLocation.value = .endFloat; + await tester.pump(Durations.short1); + + // Trigger a rebuild to update the "dirty" value. + tester.element(find.byKey(builderKey)).markNeedsBuild(); + await tester.pump(Durations.short1); + + // The element that builds the Scaffold's CustomMultiChildLayout should not be dirty. + expect(dirty, isFalse); + + fabLocation.dispose(); + }); + + testWidgets('Scaffold background color defaults to ColorScheme.surface', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + colorScheme: ThemeData().colorScheme.copyWith( + surface: Colors.orange, + background: Colors.green, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: SizedBox.expand()), + ), + ); + + final Material scaffoldMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Scaffold), matching: find.byType(Material).first), + ); + expect(scaffoldMaterial.color, theme.colorScheme.surface); + }); + + testWidgets( + 'Body height remains Scaffold height when keyboard is smaller than bottomNavigationBar and extendBody is true', + (WidgetTester tester) async { + final Key bodyKey = UniqueKey(); + Widget buildFrame({double keyboardHeight = 0}) { + return MaterialApp( + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(viewInsets: EdgeInsets.only(bottom: keyboardHeight)), + child: Scaffold( + extendBody: true, + body: SizedBox.expand(key: bodyKey), + bottomNavigationBar: const SizedBox(height: 100), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(buildFrame()); + expect(tester.getSize(find.byKey(bodyKey)).height, 600); + + await tester.pumpWidget(buildFrame(keyboardHeight: 100)); + expect(tester.getSize(find.byKey(bodyKey)).height, 600); + + await tester.pumpWidget(buildFrame(keyboardHeight: 200)); + expect(tester.getSize(find.byKey(bodyKey)).height, 400); + }, + ); + + // This is a regression test for https://github.com/flutter/flutter/issues/172866. + testWidgets('BottomAppBar with noAnimation FAB does not throw null error', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, + body: const SizedBox(), + bottomNavigationBar: const BottomAppBar(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsOneWidget); + + expect(find.byType(BottomAppBar), findsOneWidget); + }); + + testWidgets('Scaffold does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: Scaffold(appBar: AppBar(), body: const SizedBox.shrink()), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Scaffold)), Size.zero); + }); +} + +class _GeometryListener extends StatefulWidget { + const _GeometryListener(); + + @override + _GeometryListenerState createState() => _GeometryListenerState(); +} + +class _GeometryListenerState extends State<_GeometryListener> { + @override + Widget build(BuildContext context) { + return CustomPaint(painter: cache); + } + + int numNotifications = 0; + ValueListenable<ScaffoldGeometry>? geometryListenable; + late _GeometryCachePainter cache; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final ValueListenable<ScaffoldGeometry> newListenable = Scaffold.geometryOf(context); + if (geometryListenable == newListenable) { + return; + } + + geometryListenable?.removeListener(onGeometryChanged); + geometryListenable = newListenable..addListener(onGeometryChanged); + cache = _GeometryCachePainter(geometryListenable!); + } + + void onGeometryChanged() { + numNotifications += 1; + } +} + +// The Scaffold.geometryOf() value is only available at paint time. +// To fetch it for the tests we implement this CustomPainter that just +// caches the ScaffoldGeometry value in its paint method. +class _GeometryCachePainter extends CustomPainter { + _GeometryCachePainter(this.geometryListenable) : super(repaint: geometryListenable); + + final ValueListenable<ScaffoldGeometry> geometryListenable; + + late ScaffoldGeometry value; + @override + void paint(Canvas canvas, Size size) { + value = geometryListenable.value; + } + + @override + bool shouldRepaint(_GeometryCachePainter oldDelegate) { + return true; + } +} + +class _CustomPageRoute<T> extends PageRoute<T> { + _CustomPageRoute({ + required this.builder, + RouteSettings super.settings = const RouteSettings(), + this.maintainState = true, + super.fullscreenDialog, + }); + + final WidgetBuilder builder; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + final bool maintainState; + + @override + Widget buildPage( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + ) { + return builder(context); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation<double> animation, + Animation<double> secondaryAnimation, + Widget child, + ) { + return child; + } +} + +class _ScaffoldWithPrimaryScrollView extends StatefulWidget { + @override + State<StatefulWidget> createState() => _ScaffoldWithPrimaryScrollViewState(); +} + +class _ScaffoldWithPrimaryScrollViewState extends State<_ScaffoldWithPrimaryScrollView> { + final ScrollController controller = ScrollController(initialScrollOffset: 1000); + @override + Widget build(BuildContext context) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: PrimaryScrollController( + controller: controller, + child: const Scaffold( + body: SingleChildScrollView(primary: true, child: SizedBox(height: 2000)), + ), + ), + ); + } +} diff --git a/packages/material_ui/test/material/scrollbar_paint_test.dart b/packages/material_ui/test/material/scrollbar_paint_test.dart new file mode 100644 index 000000000000..17122fccce19 --- /dev/null +++ b/packages/material_ui/test/material/scrollbar_paint_test.dart @@ -0,0 +1,138 @@ +// 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'; + +const Color _kAndroidThumbIdleColor = Color(0xffbcbcbc); + +Widget _buildSingleChildScrollViewWithScrollbar({ + TextDirection textDirection = TextDirection.ltr, + EdgeInsets padding = EdgeInsets.zero, + Widget? child, +}) { + return Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(padding: padding), + child: Scrollbar(child: SingleChildScrollView(child: child)), + ), + ); +} + +void main() { + testWidgets('Viewport basic test (LTR)', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSingleChildScrollViewWithScrollbar( + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ); + expect(find.byType(Scrollbar), isNot(paints..rect())); + await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), color: const Color(0x00000000)) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect(rect: const Rect.fromLTRB(796.0, 1.5, 800.0, 91.5), color: _kAndroidThumbIdleColor), + ); + }); + + testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSingleChildScrollViewWithScrollbar( + textDirection: TextDirection.rtl, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ); + expect(find.byType(Scrollbar), isNot(paints..rect())); + await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 4.0, 600.0), color: const Color(0x00000000)) + ..line( + p1: const Offset(4.0, 0.0), + p2: const Offset(4.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect(rect: const Rect.fromLTRB(0.0, 1.5, 4.0, 91.5), color: _kAndroidThumbIdleColor), + ); + }); + + testWidgets('works with MaterialApp and Scaffold', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(0, 20, 0, 34)), + child: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Scrollbar( + child: ListView(children: const <Widget>[SizedBox(width: 4000, height: 4000)]), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView))); + // On Android it should not overscroll. + await gesture.moveBy(const Offset(0, 100)); + // Trigger fade in animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 490.0), color: const Color(0x00000000)) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 490.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTWH( + 796.0, + 0.0, + 4.0, + (600.0 - 56 - 34 - 20) / 4000 * (600 - 56 - 34 - 20), + ), + color: _kAndroidThumbIdleColor, + ), + ); + }); + + testWidgets("should not paint when there isn't enough space", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.fromLTRB(0, 20, 0, 34)), + child: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Scrollbar( + child: ListView(children: const <Widget>[SizedBox(width: 40, height: 40)]), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView))); + // On Android it should not overscroll. + await gesture.moveBy(const Offset(0, 100)); + // Trigger fade in animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(Scrollbar), isNot(paints..rect())); + }); +} diff --git a/packages/material_ui/test/material/scrollbar_test.dart b/packages/material_ui/test/material/scrollbar_test.dart new file mode 100644 index 000000000000..0f9627ae1ceb --- /dev/null +++ b/packages/material_ui/test/material/scrollbar_test.dart @@ -0,0 +1,1865 @@ +// 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. + +// TODO(gspencergoog): Remove this tag once this test's state leaks/test +// dependencies have been fixed. +// https://github.com/flutter/flutter/issues/85160 +// Fails with "flutter test --test-randomize-ordering-seed=20230313" +@Tags(<String>['no-shuffle']) +library; + +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); +const Color _kAndroidThumbIdleColor = Color(0xffbcbcbc); +const Rect _kAndroidTrackDimensions = Rect.fromLTRB(796.0, 0.0, 800.0, 600.0); +const Radius _kDefaultThumbRadius = Radius.circular(8.0); +const Color _kDefaultIdleThumbColor = Color(0x1a000000); +const Offset _kTrackBorderPoint1 = Offset(796.0, 0.0); +const Offset _kTrackBorderPoint2 = Offset(796.0, 600.0); + +Rect getStartingThumbRect({required bool isAndroid}) { + return isAndroid + // On Android the thumb is slightly different. The thumb is only 4 pixels wide, + // and has no margin along the side of the viewport. + ? const Rect.fromLTRB(796.0, 0.0, 800.0, 90.0) + // The Material Design thumb is 8 pixels wide, with a 2 + // pixel margin to the right edge of the viewport. + : const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0); +} + +class TestCanvas implements Canvas { + final List<Invocation> invocations = <Invocation>[]; + + @override + void noSuchMethod(Invocation invocation) { + invocations.add(invocation); + } +} + +Widget _buildBoilerplate({ + TextDirection textDirection = TextDirection.ltr, + EdgeInsets padding = EdgeInsets.zero, + required Widget child, +}) { + return Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(padding: padding), + child: ScrollConfiguration(behavior: const NoScrollbarBehavior(), child: child), + ), + ); +} + +class NoScrollbarBehavior extends MaterialScrollBehavior { + const NoScrollbarBehavior(); + + @override + Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) => child; +} + +void main() { + testWidgets('MaterialScrollBehavior flings on different platforms', (WidgetTester tester) async { + double getScrollOffset(WidgetTester tester, {bool last = true}) { + Finder viewportFinder = find.byType(Viewport); + if (last) { + viewportFinder = viewportFinder.last; + } + final RenderViewport viewport = tester.renderObject(viewportFinder); + return viewport.offset.pixels; + } + + void resetScrollOffset(WidgetTester tester) { + final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); + final position = viewport.offset as ScrollPosition; + position.jumpTo(0.0); + } + + Future<void> pumpTest( + WidgetTester tester, + TargetPlatform? platform, { + bool scrollable = true, + bool reverse = false, + Set<LogicalKeyboardKey>? axisModifier, + Axis scrollDirection = Axis.vertical, + ScrollController? controller, + bool enableMouseDrag = true, + }) async { + await tester.pumpWidget( + MaterialApp( + scrollBehavior: const NoScrollbarBehavior().copyWith( + dragDevices: enableMouseDrag + ? <ui.PointerDeviceKind>{...ui.PointerDeviceKind.values} + : null, + pointerAxisModifiers: axisModifier, + ), + theme: ThemeData(platform: platform), + home: CustomScrollView( + controller: controller, + reverse: reverse, + scrollDirection: scrollDirection, + physics: scrollable ? null : const NeverScrollableScrollPhysics(), + slivers: <Widget>[ + SliverToBoxAdapter( + child: SizedBox( + height: scrollDirection == Axis.vertical ? 2000.0 : null, + width: scrollDirection == Axis.horizontal ? 2000.0 : null, + ), + ), + ], + ), + ), + ); + await tester.pump(const Duration(seconds: 5)); // to let the theme animate + } + + const dragOffset = 200.0; + + await pumpTest(tester, TargetPlatform.android); + await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); + expect(getScrollOffset(tester), dragOffset); + await tester.pump(); // trigger fling + expect(getScrollOffset(tester), dragOffset); + await tester.pump(const Duration(seconds: 5)); + final double androidResult = getScrollOffset(tester); + + resetScrollOffset(tester); + + await pumpTest(tester, TargetPlatform.iOS); + await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); + // Scroll starts ease into the scroll on iOS. + expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); + await tester.pump(); // trigger fling + expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); + await tester.pump(const Duration(seconds: 5)); + final double iOSResult = getScrollOffset(tester); + + resetScrollOffset(tester); + + await pumpTest(tester, TargetPlatform.macOS); + await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); + // Scroll starts ease into the scroll on iOS. + expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); + await tester.pump(); // trigger fling + expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); + await tester.pump(const Duration(seconds: 5)); + final double macOSResult = getScrollOffset(tester); + + // Android is slipperier than MacOS, so the scroll offset should be greater. + expect(androidResult, greaterThan(macOSResult)); + // iOS is slipperier than Android, so the scroll offset should be greater. + expect(iOSResult, greaterThan(androidResult)); + // iOS is slipperier than MacOS, so the scroll offset should be greater. + expect(iOSResult, greaterThan(macOSResult)); + }); + + testWidgets("Scrollbar doesn't show when tapping list", (WidgetTester tester) async { + await tester.pumpWidget( + _buildBoilerplate( + child: Center( + child: Container( + decoration: BoxDecoration(border: Border.all(color: const Color(0xFFFFFF00))), + height: 200.0, + width: 300.0, + child: Scrollbar( + child: ListView( + children: const <Widget>[ + SizedBox(height: 40.0, child: Text('0')), + SizedBox(height: 40.0, child: Text('1')), + SizedBox(height: 40.0, child: Text('2')), + SizedBox(height: 40.0, child: Text('3')), + SizedBox(height: 40.0, child: Text('4')), + SizedBox(height: 40.0, child: Text('5')), + SizedBox(height: 40.0, child: Text('6')), + SizedBox(height: 40.0, child: Text('7')), + ], + ), + ), + ), + ), + ), + ); + + SchedulerBinding.instance.debugAssertNoTransientCallbacks( + 'Building a list with a scrollbar triggered an animation.', + ); + await tester.tap(find.byType(ListView)); + SchedulerBinding.instance.debugAssertNoTransientCallbacks( + 'Tapping a block with a scrollbar triggered an animation.', + ); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.drag(find.byType(ListView), const Offset(0.0, -10.0)); + expect(SchedulerBinding.instance.transientCallbackCount, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + }); + + testWidgets('ScrollbarPainter does not divide by zero', (WidgetTester tester) async { + await tester.pumpWidget( + _buildBoilerplate( + child: SizedBox( + height: 200.0, + width: 300.0, + child: Scrollbar( + child: ListView(children: const <Widget>[SizedBox(height: 40.0, child: Text('0'))]), + ), + ), + ), + ); + + final CustomPaint custom = tester.widget( + find.descendant(of: find.byType(Scrollbar), matching: find.byType(CustomPaint)).first, + ); + final scrollPainter = custom.foregroundPainter as ScrollbarPainter?; + // Dragging makes the scrollbar first appear. + await tester.drag(find.text('0'), const Offset(0.0, -10.0)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + + final ScrollMetrics metrics = FixedScrollMetrics( + minScrollExtent: 0.0, + maxScrollExtent: 0.0, + pixels: 0.0, + viewportDimension: 100.0, + axisDirection: AxisDirection.down, + devicePixelRatio: tester.view.devicePixelRatio, + ); + scrollPainter!.update(metrics, AxisDirection.down); + + final canvas = TestCanvas(); + scrollPainter.paint(canvas, const Size(10.0, 100.0)); + + // Scrollbar is not supposed to draw anything if there isn't enough content. + expect(canvas.invocations.isEmpty, isTrue); + }); + + testWidgets( + 'When thumbVisibility is true, must pass a controller or find PrimaryScrollController', + (WidgetTester tester) async { + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: const Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + }, + ); + + testWidgets( + 'When thumbVisibility is true, must pass a controller that is attached to a scroll view or find PrimaryScrollController', + (WidgetTester tester) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + thumbVisibility: true, + controller: controller, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + + controller.dispose(); + }, + ); + + testWidgets('On first render with thumbVisibility: true, the thumb shows', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + thumbVisibility: true, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), paints..rect()); + + controller.dispose(); + }); + + testWidgets( + 'On first render with thumbVisibility: true, the thumb shows with PrimaryScrollController', + (WidgetTester tester) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: PrimaryScrollController( + controller: controller, + child: Builder( + builder: (BuildContext context) { + return const Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), paints..rect()); + + controller.dispose(); + }, + ); + + testWidgets( + 'When thumbVisibility is true, must pass a controller or find PrimaryScrollController', + (WidgetTester tester) async { + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: const Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + }, + ); + + testWidgets( + 'When thumbVisibility is true, must pass a controller that is attached to a scroll view or find PrimaryScrollController', + (WidgetTester tester) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + thumbVisibility: true, + controller: controller, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + final exception = tester.takeException() as AssertionError; + expect(exception, isAssertionError); + + controller.dispose(); + }, + ); + + testWidgets('On first render with thumbVisibility: true, the thumb shows', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + thumbVisibility: true, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), paints..rect()); + + controller.dispose(); + }); + + testWidgets( + 'On first render with thumbVisibility: true, the thumb shows with PrimaryScrollController', + (WidgetTester tester) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: PrimaryScrollController( + controller: controller, + child: Builder( + builder: (BuildContext context) { + return const Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), paints..rect()); + + controller.dispose(); + }, + ); + + testWidgets('On first render with thumbVisibility: false, the thumb is hidden', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + thumbVisibility: false, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), isNot(paints..rect())); + + controller.dispose(); + }); + + testWidgets( + 'With thumbVisibility: true, fling a scroll. While it is still scrolling, set thumbVisibility: false. The thumb should not fade out until the scrolling stops.', + (WidgetTester tester) async { + final controller = ScrollController(); + var thumbVisibility = true; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + ), + body: Scrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + // Scrollbar is not showing after scroll finishes + expect(find.byType(Scrollbar), isNot(paints..rect())); + + controller.dispose(); + }, + ); + + testWidgets( + 'With thumbVisibility: false, set thumbVisibility: true. The thumb should be always shown directly', + (WidgetTester tester) async { + final controller = ScrollController(); + var thumbVisibility = false; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + ), + body: Scrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), isNot(paints..rect())); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + // Scrollbar is not showing after scroll finishes + expect(find.byType(Scrollbar), paints..rect()); + + controller.dispose(); + }, + ); + + testWidgets( + 'With thumbVisibility: false, fling a scroll. While it is still scrolling, set thumbVisibility: true. The thumb should not fade even after the scrolling stops', + (WidgetTester tester) async { + final controller = ScrollController(); + var thumbVisibility = false; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + ), + body: Scrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), isNot(paints..rect())); + await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + expect(find.byType(Scrollbar), paints..rect()); + + // Wait for the timer delay to expire. + await tester.pump(const Duration(milliseconds: 600)); // _kScrollbarTimeToFade + await tester.pumpAndSettle(); + // Scrollbar thumb is showing after scroll finishes and timer ends. + expect(find.byType(Scrollbar), paints..rect()); + + controller.dispose(); + }, + ); + + testWidgets( + 'Toggling thumbVisibility while not scrolling fades the thumb in/out. This works even when you have never scrolled at all yet', + (WidgetTester tester) async { + final controller = ScrollController(); + var thumbVisibility = true; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + thumbVisibility = !thumbVisibility; + }); + }, + ), + body: Scrollbar( + thumbVisibility: thumbVisibility, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + final Finder materialScrollbar = find.byType(Scrollbar); + expect(materialScrollbar, paints..rect()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(materialScrollbar, isNot(paints..rect())); + + controller.dispose(); + }, + ); + + testWidgets('Scrollbar respects thickness and radius', (WidgetTester tester) async { + final controller = ScrollController(); + Widget viewWithScroll({Radius? radius}) { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + controller: controller, + thickness: 20, + radius: radius, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 1600.0, height: 1200.0), + ), + ), + ), + ); + } + + // Scroll a bit to cause the scrollbar thumb to be shown; + // undo the scroll to put the thumb back at the top. + await tester.pumpWidget(viewWithScroll()); + const scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await scrollGesture.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await scrollGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pump(); + await scrollGesture.up(); + await tester.pump(); + + // Long press on the scrollbar thumb and expect it to grow + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(780.0, 0.0, 800.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(780.0, 0.0), + p2: const Offset(780.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(780.0, 0.0, 800.0, 300.0), color: _kAndroidThumbIdleColor), + ); + await tester.pumpWidget(viewWithScroll(radius: const Radius.circular(10))); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(780, 0.0, 800.0, 300.0), + const Radius.circular(10), + ), + ), + ); + + await tester.pumpAndSettle(); + + controller.dispose(); + }); + + testWidgets('Tapping the track area pages the Scroll View', (WidgetTester tester) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Scrollbar( + interactive: true, + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 1000.0, height: 1000.0), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 360.0), color: _kAndroidThumbIdleColor), + ); + + // Tap on the track area below the thumb. + await tester.tapAt(const Offset(796.0, 550.0)); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 400.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 240.0, 800.0, 600.0), + color: _kAndroidThumbIdleColor, + ), + ); + + // Tap on the track area above the thumb. + await tester.tapAt(const Offset(796.0, 50.0)); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 360.0), color: _kAndroidThumbIdleColor), + ); + + scrollController.dispose(); + }); + + testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scrollbar( + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(const Offset(0.0, -20.0)); + await tester.pump(); + // Scrollbar fully showing + await tester.pump(const Duration(milliseconds: 500)); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 3.0, 800.0, 93.0), color: _kAndroidThumbIdleColor), + ); + + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 3)); + // Still there. + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 3.0, 800.0, 93.0), color: _kAndroidThumbIdleColor), + ); + + await gesture.up(); + await tester.pump(_kScrollbarTimeToFade); + await tester.pump(_kScrollbarFadeDuration * 0.5); + + // Opacity going down now. + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 3.0, 800.0, 93.0), color: const Color(0xc6bcbcbc)), + ); + }); + + testWidgets('Scrollbar thumb can be dragged', (WidgetTester tester) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + interactive: true, + thumbVisibility: true, + controller: scrollController, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: getStartingThumbRect(isAndroid: true), color: _kAndroidThumbIdleColor), + ); + + // Drag the thumb down to scroll down. + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: getStartingThumbRect(isAndroid: true), + // Drag color + color: const Color(0x99000000), + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe pointer of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0), + color: _kAndroidThumbIdleColor, + ), + ); + + scrollController.dispose(); + }); + + testWidgets( + 'Scrollbar thumb color completes a hover animation', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: ScrollbarThemeData(thumbVisibility: WidgetStateProperty.all(true)), + ), + home: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Hover animation is not triggered by tap gestures', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: ScrollbarThemeData( + thumbVisibility: WidgetStateProperty.all(true), + trackVisibility: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + ), + ), + home: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + await tester.tapAt(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + // Tapping triggers a hover enter event. In this case, the Scrollbar should + // be unchanged since it ignores hover events that aren't from a mouse. + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + // Now trigger hover with a mouse. + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pump(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(784.0, 0.0), + p2: const Offset(784.0, 600.0), + strokeWidth: 1.0, + color: _kDefaultIdleThumbColor, + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(786.0, 0.0, 798.0, 90.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'ScrollbarThemeData.thickness replaces hoverThickness', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: ScrollbarThemeData( + thumbVisibility: WidgetStateProperty.resolveWith((Set<WidgetState> states) => true), + trackVisibility: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + return states.contains(WidgetState.hovered); + }), + thickness: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return 40.0; + } + // Default thickness + return 8.0; + }), + ), + ), + home: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pump(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(756.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(756.0, 0.0), + p2: const Offset(756.0, 600.0), + strokeWidth: 1.0, + color: _kDefaultIdleThumbColor, + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(758.0, 0.0, 798.0, 90.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'ScrollbarThemeData.trackVisibility', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: ScrollbarThemeData( + thumbVisibility: WidgetStateProperty.all(true), + trackVisibility: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + ), + ), + home: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pump(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(784.0, 0.0), + p2: const Offset(784.0, 600.0), + strokeWidth: 1.0, + color: _kDefaultIdleThumbColor, + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(786.0, 0.0, 798.0, 90.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Scrollbar trackVisibility on hovered', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: ScrollbarThemeData( + thumbVisibility: WidgetStateProperty.all(true), + trackVisibility: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + ), + ), + home: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pump(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(784.0, 0.0), + p2: const Offset(784.0, 600.0), + strokeWidth: 1.0, + color: _kDefaultIdleThumbColor, + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(786.0, 0.0, 798.0, 90.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + }), + ); + + testWidgets('Adaptive scrollbar', (WidgetTester tester) async { + Widget viewWithScroll(TargetPlatform platform) { + return _buildBoilerplate( + child: Theme( + data: ThemeData(platform: platform), + child: const Scrollbar( + child: SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll(TargetPlatform.android)); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0)); + await tester.pump(); + // Scrollbar fully showing + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.pumpWidget(viewWithScroll(TargetPlatform.iOS)); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(const Offset(0.0, -10.0)); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(Scrollbar), paints..rrect()); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'Scrollbar passes controller to CupertinoScrollbar', + (WidgetTester tester) async { + final controller = ScrollController(); + Widget viewWithScroll(TargetPlatform? platform) { + return _buildBoilerplate( + child: Theme( + data: ThemeData(platform: platform), + child: Scrollbar( + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll(debugDefaultTargetPlatformOverride)); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(SingleChildScrollView)), + ); + await gesture.moveBy(const Offset(0.0, -10.0)); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + final CupertinoScrollbar scrollbar = tester.widget<CupertinoScrollbar>( + find.byType(CupertinoScrollbar), + ); + expect(scrollbar.controller, isNotNull); + + controller.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + "Scrollbar doesn't show when scroll the inner scrollable widget", + (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(); + final GlobalKey key2 = GlobalKey(); + final GlobalKey outerKey = GlobalKey(); + final GlobalKey innerKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: Scrollbar( + key: key2, + child: SingleChildScrollView( + key: outerKey, + child: SizedBox( + height: 1000.0, + width: double.infinity, + child: Column( + children: <Widget>[ + Scrollbar( + key: key1, + child: SizedBox( + height: 300.0, + width: double.infinity, + child: SingleChildScrollView( + key: innerKey, + child: const SizedBox( + key: Key('Inner scrollable'), + height: 1000.0, + width: double.infinity, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + // Drag the inner scrollable widget. + await tester.drag(find.byKey(innerKey), const Offset(0.0, -25.0)); + await tester.pump(); + // Scrollbar fully showing. + await tester.pump(const Duration(milliseconds: 500)); + + expect( + tester.renderObject(find.byKey(key2)), + paintsExactlyCountTimes(#drawRect, 2), // Each bar will call [drawRect] twice. + ); + + expect(tester.renderObject(find.byKey(key1)), paintsExactlyCountTimes(#drawRect, 2)); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'Scrollbar dragging can be disabled', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + interactive: false, + thumbVisibility: true, + controller: scrollController, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(788.0, 0.0, 800.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(788.0, 0.0), + p2: const Offset(788.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + getStartingThumbRect(isAndroid: false), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + // Try to drag the thumb down. + const scrollAmount = 10.0; + final TestGesture dragScrollbarThumbGesture = await tester.startGesture( + const Offset(797.0, 45.0), + ); + await tester.pumpAndSettle(); + await dragScrollbarThumbGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarThumbGesture.up(); + await tester.pumpAndSettle(); + // Dragging on the thumb does not change the offset. + expect(scrollController.offset, 0.0); + + // Drag in the track area to validate pass through to scrollable. + final TestGesture dragPassThroughTrack = await tester.startGesture( + const Offset(797.0, 250.0), + ); + await dragPassThroughTrack.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pumpAndSettle(); + await dragPassThroughTrack.up(); + await tester.pumpAndSettle(); + // The scroll view received the drag. + expect(scrollController.offset, scrollAmount); + + // Tap on the track to validate the scroll view will not page. + await tester.tapAt(const Offset(797.0, 200.0)); + await tester.pumpAndSettle(); + // The offset should not have changed. + expect(scrollController.offset, scrollAmount); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.fuchsia}), + ); + + testWidgets('Scrollbar dragging is disabled by default on Android', (WidgetTester tester) async { + var tapCount = 0; + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + dragStartBehavior: DragStartBehavior.down, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + tapCount += 1; + }, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: getStartingThumbRect(isAndroid: true), color: _kAndroidThumbIdleColor), + ); + + // Try to drag the thumb down. + const scrollAmount = 50.0; + await tester.dragFrom( + const Offset(797.0, 45.0), + const Offset(0.0, scrollAmount), + touchSlopY: 0.0, + ); + await tester.pumpAndSettle(); + // Dragging on the thumb does not change the offset. + expect(scrollController.offset, 0.0); + expect(tapCount, 0); + + // Try to drag up in the thumb area to validate pass through to scrollable. + await tester.dragFrom(const Offset(797.0, 45.0), const Offset(0.0, -scrollAmount)); + await tester.pumpAndSettle(); + // The scroll view received the drag. + expect(scrollController.offset, scrollAmount); + expect(tapCount, 0); + + // Drag in the track area to validate pass through to scrollable. + await tester.dragFrom( + const Offset(797.0, 45.0), + const Offset(0.0, -scrollAmount), + touchSlopY: 0.0, + ); + await tester.pumpAndSettle(); + // The scroll view received the drag. + expect(scrollController.offset, scrollAmount * 2); + expect(tapCount, 0); + + // Tap on the thumb to validate the scroll view receives a click. + await tester.tapAt(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + expect(tapCount, 1); + + // Tap on the track to validate the scroll view will not page and receives a click. + await tester.tapAt(const Offset(797.0, 400.0)); + await tester.pumpAndSettle(); + // The offset should not have changed. + expect(scrollController.offset, scrollAmount * 2); + expect(tapCount, 2); + + scrollController.dispose(); + }); + + testWidgets('Simultaneous dragging and pointer scrolling does not cause a crash', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/70105 + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + interactive: true, + thumbVisibility: true, + controller: scrollController, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: getStartingThumbRect(isAndroid: true), color: _kAndroidThumbIdleColor), + ); + + // Drag the thumb down to scroll down. + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect(rect: _kAndroidTrackDimensions, color: Colors.transparent) + ..line( + p1: _kTrackBorderPoint1, + p2: _kTrackBorderPoint2, + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: getStartingThumbRect(isAndroid: true), + // Drag color + color: const Color(0x99000000), + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(10.0)); + final double previousOffset = scrollController.offset; + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0), + color: const Color(0x99000000), + ), + ); + + // Execute a pointer scroll while dragging (drag gesture has not come up yet) + final pointer = TestPointer(1, ui.PointerDeviceKind.mouse); + pointer.hover(const Offset(798.0, 15.0)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0))); + await tester.pumpAndSettle(); + + if (!kIsWeb) { + // Scrolling while holding the drag on the scrollbar and still hovered over + // the scrollbar should not have changed the scroll offset. + expect(pointer.location, const Offset(798.0, 15.0)); + expect(scrollController.offset, previousOffset); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0), + color: const Color(0x99000000), + ), + ); + } else { + expect(pointer.location, const Offset(798.0, 15.0)); + expect(scrollController.offset, previousOffset + 20.0); + } + + // Drag is still being held, move pointer to be hovering over another area + // of the scrollable (not over the scrollbar) and execute another pointer scroll + pointer.hover(tester.getCenter(find.byType(SingleChildScrollView))); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0))); + await tester.pumpAndSettle(); + // Scrolling while holding the drag on the scrollbar changed the offset + expect(pointer.location, const Offset(400.0, 300.0)); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 90.0), color: const Color(0x99000000)), + ); + + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 90.0), color: const Color(0xffbcbcbc)), + ); + + scrollController.dispose(); + }); + + testWidgets( + 'Scrollbar.thumbVisibility triggers assertion when multiple ScrollPositions are attached.', + (WidgetTester tester) async { + Widget getTabContent({ScrollController? scrollController}) { + return Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: ListView.builder( + controller: scrollController, + itemCount: 200, + itemBuilder: (BuildContext context, int index) => const Text('Test'), + ), + ); + } + + Widget buildApp({required String id, ScrollController? scrollController}) { + return MaterialApp( + key: ValueKey<String>(id), + home: DefaultTabController( + length: 2, + child: Scaffold( + body: TabBarView( + children: <Widget>[ + getTabContent(scrollController: scrollController), + getTabContent(scrollController: scrollController), + ], + ), + ), + ), + ); + } + + // Asserts when using the PrimaryScrollController. + await tester.pumpWidget(buildApp(id: 'PrimaryScrollController')); + + // Swipe to the second tab, resulting in two attached ScrollPositions during + // the transition. + await tester.drag(find.text('Test').first, const Offset(-100.0, 0.0)); + await tester.pump(); + + var error = tester.takeException() as FlutterError; + expect( + error.message, + ''' +The PrimaryScrollController is attached to more than one ScrollPosition. +The Scrollbar requires a single ScrollPosition in order to be painted. +When Scrollbar.thumbVisibility is true, the associated ScrollController must only have one ScrollPosition attached. +If a ScrollController has not been provided, the PrimaryScrollController is used by default on mobile platforms for ScrollViews with an Axis.vertical scroll direction. +More than one ScrollView may have tried to use the PrimaryScrollController of the current context. ScrollView.primary can override this behavior.''', + ); + + // Asserts when using the ScrollController provided by the user. + final scrollController = ScrollController(); + await tester.pumpWidget( + buildApp(id: 'Provided ScrollController', scrollController: scrollController), + ); + + // Swipe to the second tab, resulting in two attached ScrollPositions during + // the transition. + await tester.drag(find.text('Test').first, const Offset(-100.0, 0.0)); + await tester.pump(); + error = tester.takeException() as FlutterError; + expect(error.message, ''' +The provided ScrollController is attached to more than one ScrollPosition. +The Scrollbar requires a single ScrollPosition in order to be painted. +When Scrollbar.thumbVisibility is true, the associated ScrollController must only have one ScrollPosition attached. +The provided ScrollController cannot be shared by multiple ScrollView widgets.'''); + + scrollController.dispose(); + }, + ); + + testWidgets('Scrollbar scrollOrientation works correctly', (WidgetTester tester) async { + final scrollController = ScrollController(); + + Widget buildScrollWithOrientation(ScrollbarOrientation orientation) { + return _buildBoilerplate( + child: Theme( + data: ThemeData(platform: TargetPlatform.android), + child: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + interactive: true, + thumbVisibility: true, + scrollbarOrientation: orientation, + controller: scrollController, + child: const SingleChildScrollView(child: SizedBox(width: 4000.0, height: 4000.0)), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildScrollWithOrientation(ScrollbarOrientation.left)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 4.0, 600.0), color: Colors.transparent) + ..line( + p1: const Offset(4.0, 0.0), + p2: const Offset(4.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect(rect: const Rect.fromLTRB(0.0, 0.0, 4.0, 90.0), color: _kAndroidThumbIdleColor), + ); + + scrollController.dispose(); + }); + + testWidgets('Scrollbar does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: Scrollbar(child: SingleChildScrollView())), + ), + ), + ); + expect(tester.getSize(find.byType(Scrollbar)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/scrollbar_theme_test.dart b/packages/material_ui/test/material/scrollbar_theme_test.dart new file mode 100644 index 000000000000..aab90c80e74d --- /dev/null +++ b/packages/material_ui/test/material/scrollbar_theme_test.dart @@ -0,0 +1,828 @@ +// 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:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// The const represents the starting position of the scrollbar thumb for +// the below tests. The thumb is 90 pixels long, and 8 pixels wide, with a 2 +// pixel margin to the right edge of the viewport. +const Rect _kMaterialDesignInitialThumbRect = Rect.fromLTRB(790.0, 0.0, 798.0, 90.0); +const Radius _kDefaultThumbRadius = Radius.circular(8.0); +const Color _kDefaultIdleThumbColor = Color(0x1a000000); +const Color _kDefaultDragThumbColor = Color(0x99000000); + +void main() { + test('ScrollbarThemeData copyWith, ==, hashCode basics', () { + expect(const ScrollbarThemeData(), const ScrollbarThemeData().copyWith()); + expect(const ScrollbarThemeData().hashCode, const ScrollbarThemeData().copyWith().hashCode); + }); + + test('ScrollbarThemeData lerp special cases', () { + expect(ScrollbarThemeData.lerp(null, null, 0), const ScrollbarThemeData()); + const data = ScrollbarThemeData(); + expect(identical(ScrollbarThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets( + 'Passing no ScrollbarTheme returns defaults', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: ScrollbarThemeData( + trackVisibility: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + ), + ), + home: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + color: _kDefaultIdleThumbColor, + ), + ); + + // Drag scrollbar behavior + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + // Drag color + color: _kDefaultDragThumbColor, + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // Hover scrollbar behavior + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(784.0, 0.0), + p2: const Offset(784.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x1a000000), + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(786.0, 10.0, 798.0, 100.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'Scrollbar uses values from ScrollbarTheme', + (WidgetTester tester) async { + final ScrollbarThemeData scrollbarTheme = _scrollbarTheme(); + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scrollbarTheme: scrollbarTheme), + home: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(785.0, 10.0, 795.0, 97.0), + const Radius.circular(6.0), + ), + color: const Color(0xff4caf50), + ), + ); + + // Drag scrollbar behavior + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(785.0, 10.0, 795.0, 97.0), + const Radius.circular(6.0), + ), + // Drag color + color: const Color(0xfff44336), + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // Hover scrollbar behavior + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 15.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(770.0, 0.0, 800.0, 600.0), + color: const Color(0xff000000), + ) + ..line( + p1: const Offset(770.0, 00.0), + p2: const Offset(770.0, 600.0), + strokeWidth: 1.0, + color: const Color(0xffffeb3b), + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(775.0, 20.0, 795.0, 107.0), + const Radius.circular(6.0), + ), + // Hover color + color: const Color(0xff2196f3), + ), + ); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); + + testWidgets('Scrollbar uses values from ScrollbarTheme if exists instead of values from Theme', ( + WidgetTester tester, + ) async { + final ScrollbarThemeData scrollbarTheme = _scrollbarTheme(); + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scrollbarTheme: scrollbarTheme), + home: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: ScrollbarTheme( + data: _scrollbarTheme().copyWith( + thumbColor: WidgetStateProperty.all(const Color(0xFF000000)), + ), + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(785.0, 10.0, 795.0, 97.0), + const Radius.circular(6.0), + ), + color: const Color(0xFF000000), + ), + ); + + scrollController.dispose(); + }); + + testWidgets( + 'ScrollbarTheme can disable gestures', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: const ScrollbarThemeData(interactive: false), + ), + home: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + color: _kDefaultIdleThumbColor, + ), + ); + + // Try to drag scrollbar. + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + // Expect no change + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + color: _kDefaultIdleThumbColor, + ), + ); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.fuchsia}), + ); + + testWidgets( + 'Scrollbar.interactive takes priority over ScrollbarTheme', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + scrollbarTheme: const ScrollbarThemeData(interactive: false), + ), + home: Scrollbar( + interactive: true, + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + color: _kDefaultIdleThumbColor, + ), + ); + + // Drag scrollbar. + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + // Gestures handled by Scrollbar. + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), + _kDefaultThumbRadius, + ), + color: _kDefaultIdleThumbColor, + ), + ); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.fuchsia}), + ); + + testWidgets( + 'Scrollbar widget properties take priority over theme', + (WidgetTester tester) async { + const thickness = 4.0; + const edgeMargin = 2.0; + const radius = Radius.circular(3.0); + final scrollController = ScrollController(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: const ColorScheme.light(), + scrollbarTheme: ScrollbarThemeData( + trackVisibility: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + ), + ), + home: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: Scrollbar( + thickness: thickness, + thumbVisibility: true, + radius: radius, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(800 - thickness - edgeMargin, 0.0, 798.0, 90.0), + const Radius.circular(3.0), + ), + color: _kDefaultIdleThumbColor, + ), + ); + + // Drag scrollbar behavior. + const scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(800 - thickness - edgeMargin, 0.0, 798.0, 90.0), + const Radius.circular(3.0), + ), + // Drag color + color: _kDefaultDragThumbColor, + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // Hover scrollbar behavior. + final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(792.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(792.0, 0.0), + p2: const Offset(792.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x1a000000), + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(800 - thickness - edgeMargin, 10.0, 798.0, 100.0), + const Radius.circular(3.0), + ), + // Hover color + color: const Color(0x80000000), + ), + ); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'ThemeData colorScheme is used when no ScrollbarTheme is set', + (WidgetTester tester) async { + (ScrollController, Widget) buildFrame(ThemeData appTheme) { + final scrollController = ScrollController(); + final ThemeData theme = appTheme.copyWith( + scrollbarTheme: ScrollbarThemeData( + trackVisibility: WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + ), + ); + return ( + scrollController, + MaterialApp( + theme: theme, + home: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + } + + // Scrollbar defaults for light themes: + // - coloring based on ColorScheme.onSurface + final (ScrollController controller1, Widget frame1) = buildFrame( + ThemeData(colorScheme: const ColorScheme.light()), + ); + await tester.pumpWidget(frame1); + await tester.pumpAndSettle(); + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + color: _kDefaultIdleThumbColor, + ), + ); + + // Drag scrollbar behavior + const scrollAmount = 10.0; + TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius(_kMaterialDesignInitialThumbRect, _kDefaultThumbRadius), + // Drag color + color: _kDefaultDragThumbColor, + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // Hover scrollbar behavior + final TestGesture hoverGesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); + await hoverGesture.addPointer(); + addTearDown(hoverGesture.removePointer); + await hoverGesture.moveTo(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), + color: const Color(0x08000000), + ) + ..line( + p1: const Offset(784.0, 0.0), + p2: const Offset(784.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x1a000000), + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(786.0, 10.0, 798.0, 100.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0x80000000), + ), + ); + + await hoverGesture.moveTo(Offset.zero); + + // Scrollbar defaults for dark themes: + // - coloring slightly different based on ColorScheme.onSurface + final (ScrollController controller2, Widget frame2) = buildFrame( + ThemeData(colorScheme: const ColorScheme.dark()), + ); + await tester.pumpWidget(frame2); + await tester.pumpAndSettle(); // Theme change animation + + // Idle scrollbar behavior + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), + _kDefaultThumbRadius, + ), + color: const Color(0x4dffffff), + ), + ); + + // Drag scrollbar behavior + dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), + _kDefaultThumbRadius, + ), + // Drag color + color: const Color(0xbfffffff), + ), + ); + + await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // Hover scrollbar behavior + await hoverGesture.moveTo(const Offset(794.0, 5.0)); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), + color: const Color(0x0dffffff), + ) + ..line( + p1: const Offset(784.0, 0.0), + p2: const Offset(784.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x40ffffff), + ) + ..rrect( + rrect: RRect.fromRectAndRadius( + // Scrollbar thumb is larger + const Rect.fromLTRB(786.0, 20.0, 798.0, 110.0), + _kDefaultThumbRadius, + ), + // Hover color + color: const Color(0xa6ffffff), + ), + ); + + controller1.dispose(); + controller2.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'ScrollbarThemeData.trackVisibility test', + (WidgetTester tester) async { + final scrollController = ScrollController(); + bool? getTrackVisibility(Set<WidgetState> states) { + return true; + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false).copyWith( + scrollbarTheme: _scrollbarTheme( + trackVisibility: WidgetStateProperty.resolveWith(getTrackVisibility), + ), + ), + home: ScrollConfiguration( + behavior: const NoScrollbarBehavior(), + child: Scrollbar( + thumbVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byType(Scrollbar), + paints + ..rect(color: const Color(0x08000000)) + ..line(strokeWidth: 1.0, color: const Color(0x1a000000)) + ..rrect(color: const Color(0xff4caf50)), + ); + + scrollController.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.fuchsia, + }), + ); + + testWidgets('Default ScrollbarTheme debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ScrollbarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ScrollbarTheme implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + ScrollbarThemeData( + thickness: WidgetStateProperty.resolveWith(_getThickness), + thumbVisibility: WidgetStateProperty.resolveWith(_getThumbVisibility), + radius: const Radius.circular(3.0), + thumbColor: WidgetStateProperty.resolveWith(_getThumbColor), + trackColor: WidgetStateProperty.resolveWith(_getTrackColor), + trackBorderColor: WidgetStateProperty.resolveWith(_getTrackBorderColor), + crossAxisMargin: 3.0, + mainAxisMargin: 6.0, + minThumbLength: 120.0, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + "thumbVisibility: Instance of '_WidgetStatePropertyWith<bool?>'", + "thickness: Instance of '_WidgetStatePropertyWith<double?>'", + 'radius: Radius.circular(3.0)', + "thumbColor: Instance of '_WidgetStatePropertyWith<Color?>'", + "trackColor: Instance of '_WidgetStatePropertyWith<Color?>'", + "trackBorderColor: Instance of '_WidgetStatePropertyWith<Color?>'", + 'crossAxisMargin: 3.0', + 'mainAxisMargin: 6.0', + 'minThumbLength: 120.0', + ]); + + // On the web, Dart doubles and ints are backed by the same kind of object because + // JavaScript does not support integers. So, the Dart double "4.0" is identical + // to "4", which results in the web evaluating to the value "4" regardless of which + // one is used. This results in a difference for doubles in debugFillProperties between + // the web and the rest of Flutter's target platforms. + }, skip: kIsWeb); // [intended] +} + +class NoScrollbarBehavior extends ScrollBehavior { + const NoScrollbarBehavior(); + + @override + Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) => child; +} + +ScrollbarThemeData _scrollbarTheme({ + WidgetStateProperty<double?>? thickness, + WidgetStateProperty<bool?>? trackVisibility, + WidgetStateProperty<bool?>? thumbVisibility, + Radius radius = const Radius.circular(6.0), + WidgetStateProperty<Color?>? thumbColor, + WidgetStateProperty<Color?>? trackColor, + WidgetStateProperty<Color?>? trackBorderColor, + double crossAxisMargin = 5.0, + double mainAxisMargin = 10.0, + double minThumbLength = 50.0, +}) { + return ScrollbarThemeData( + thickness: thickness ?? WidgetStateProperty.resolveWith(_getThickness), + trackVisibility: + trackVisibility ?? + WidgetStateProperty.resolveWith( + (Set<WidgetState> states) => states.contains(WidgetState.hovered), + ), + thumbVisibility: thumbVisibility, + radius: radius, + thumbColor: thumbColor ?? WidgetStateProperty.resolveWith(_getThumbColor), + trackColor: trackColor ?? WidgetStateProperty.resolveWith(_getTrackColor), + trackBorderColor: trackBorderColor ?? WidgetStateProperty.resolveWith(_getTrackBorderColor), + crossAxisMargin: crossAxisMargin, + mainAxisMargin: mainAxisMargin, + minThumbLength: minThumbLength, + ); +} + +double? _getThickness(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return 20.0; + } + return 10.0; +} + +bool? _getThumbVisibility(Set<WidgetState> states) => true; + +Color? _getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.dragged)) { + return Colors.red; + } + if (states.contains(WidgetState.hovered)) { + return Colors.blue; + } + return Colors.green; +} + +Color? _getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return Colors.black; + } + return null; +} + +Color? _getTrackBorderColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return Colors.yellow; + } + return null; +} diff --git a/packages/material_ui/test/material/search_anchor_test.dart b/packages/material_ui/test/material/search_anchor_test.dart new file mode 100644 index 000000000000..119d4583dfa9 --- /dev/null +++ b/packages/material_ui/test/material/search_anchor_test.dart @@ -0,0 +1,4484 @@ +// 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:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + // Returns the RenderEditable at the given index, or the first if not given. + RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) { + final RenderObject root = tester.renderObject(find.byType(EditableText).at(index)); + expect(root, isNotNull); + + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable; + } + + List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { + return points.map<TextSelectionPoint>((TextSelectionPoint point) { + return TextSelectionPoint(box.localToGlobal(point.point), point.direction); + }).toList(); + } + + Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) { + final RenderEditable renderEditable = findRenderEditable(tester, index: index); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(TextSelection.collapsed(offset: offset)), + renderEditable, + ); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(kIsWeb ? 1.0 : 0.0, -2.0); + } + + testWidgets('SearchBar defaults', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: SearchBar(hintText: 'hint text')), + ), + ); + + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(searchBarMaterial); + await checkSearchBarDefaults(tester, colorScheme, material); + }); + + testWidgets('SearchBar respects controller property', (WidgetTester tester) async { + const defaultText = 'default text'; + final controller = TextEditingController(text: defaultText); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchBar(controller: controller)), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + const updatedText = 'updated text'; + await tester.enterText(find.byType(SearchBar), updatedText); + expect(controller.value.text, updatedText); + expect(find.text(defaultText), findsNothing); + expect(find.text(updatedText), findsOneWidget); + }); + + testWidgets('SearchBar respects focusNode property', (WidgetTester tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchBar(focusNode: node)), + ), + ); + + expect(node.hasFocus, false); + + node.requestFocus(); + await tester.pump(); + expect(node.hasFocus, true); + + node.unfocus(); + await tester.pump(); + expect(node.hasFocus, false); + }); + + testWidgets('SearchBar focusNode is hot swappable', (WidgetTester tester) async { + final node1 = FocusNode(); + addTearDown(node1.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchBar(focusNode: node1)), + ), + ); + + expect(node1.hasFocus, isFalse); + + node1.requestFocus(); + await tester.pump(); + expect(node1.hasFocus, isTrue); + + node1.unfocus(); + await tester.pump(); + expect(node1.hasFocus, isFalse); + + final node2 = FocusNode(); + addTearDown(node2.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchBar(focusNode: node2)), + ), + ); + + expect(node1.hasFocus, isFalse); + expect(node2.hasFocus, isFalse); + + node2.requestFocus(); + await tester.pump(); + expect(node1.hasFocus, isFalse); + expect(node2.hasFocus, isTrue); + + node2.unfocus(); + await tester.pump(); + expect(node1.hasFocus, isFalse); + expect(node2.hasFocus, isFalse); + + await tester.pumpWidget(const MaterialApp(home: Material(child: SearchBar()))); + + expect(node1.hasFocus, isFalse); + expect(node2.hasFocus, isFalse); + + await tester.tap(find.byType(SearchBar)); + await tester.pump(); + expect(node1.hasFocus, isFalse); + expect(node2.hasFocus, isFalse); + }); + + testWidgets('SearchBar has correct default layout and padding LTR', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SearchBar( + leading: IconButton(icon: const Icon(Icons.search), onPressed: () {}), + trailing: <Widget>[IconButton(icon: const Icon(Icons.menu), onPressed: () {})], + ), + ), + ), + ); + + final Rect barRect = tester.getRect(find.byType(SearchBar)); + expect(barRect.size, const Size(800.0, 56.0)); + expect(barRect, equals(const Rect.fromLTRB(0.0, 272.0, 800.0, 328.0))); + + final Rect leadingIcon = tester.getRect(find.widgetWithIcon(IconButton, Icons.search)); + // Default left padding is 8.0, and icon button has 8.0 padding, so in total the padding between + // the edge of the bar and the icon of the button is 16.0, which matches the spec. + expect(leadingIcon.left, equals(barRect.left + 8.0)); + + final Rect textField = tester.getRect(find.byType(TextField)); + expect(textField.left, equals(leadingIcon.right + 8.0)); + + final Rect trailingIcon = tester.getRect(find.widgetWithIcon(IconButton, Icons.menu)); + expect(trailingIcon.left, equals(textField.right + 8.0)); + expect(trailingIcon.right, equals(barRect.right - 8.0)); + }); + + testWidgets('SearchBar has correct default layout and padding - RTL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: SearchBar( + leading: IconButton(icon: const Icon(Icons.search), onPressed: () {}), + trailing: <Widget>[IconButton(icon: const Icon(Icons.menu), onPressed: () {})], + ), + ), + ), + ), + ); + + final Rect barRect = tester.getRect(find.byType(SearchBar)); + expect(barRect.size, const Size(800.0, 56.0)); + expect(barRect, equals(const Rect.fromLTRB(0.0, 272.0, 800.0, 328.0))); + + // The default padding is set to 8.0 so the distance between the icon of the button + // and the edge of the bar is 16.0, which matches the spec. + final Rect leadingIcon = tester.getRect(find.widgetWithIcon(IconButton, Icons.search)); + expect(leadingIcon.right, equals(barRect.right - 8.0)); + + final Rect textField = tester.getRect(find.byType(TextField)); + expect(textField.right, equals(leadingIcon.left - 8.0)); + + final Rect trailingIcon = tester.getRect(find.widgetWithIcon(IconButton, Icons.menu)); + expect(trailingIcon.right, equals(textField.left - 8.0)); + expect(trailingIcon.left, equals(barRect.left + 8.0)); + }); + + testWidgets('SearchBar respects hintText property', (WidgetTester tester) async { + const hintText = 'hint text'; + await tester.pumpWidget( + const MaterialApp( + home: Material(child: SearchBar(hintText: hintText)), + ), + ); + + expect(find.text(hintText), findsOneWidget); + }); + + testWidgets('SearchBar respects leading property', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchBar( + leading: IconButton(icon: const Icon(Icons.search), onPressed: () {}), + ), + ), + ), + ); + + expect(find.widgetWithIcon(IconButton, Icons.search), findsOneWidget); + final Color? iconColor = _iconStyle(tester, Icons.search)?.color; + expect(iconColor, colorScheme.onSurface); // Default icon color. + }); + + testWidgets('SearchBar respects trailing property', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchBar( + trailing: <Widget>[IconButton(icon: const Icon(Icons.menu), onPressed: () {})], + ), + ), + ), + ); + + expect(find.widgetWithIcon(IconButton, Icons.menu), findsOneWidget); + final Color? iconColor = _iconStyle(tester, Icons.menu)?.color; + expect(iconColor, colorScheme.onSurfaceVariant); // Default icon color. + }); + + testWidgets('SearchBar respects onTap property', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: SearchBar( + onTap: () { + setState(() { + tapCount++; + }); + }, + ), + ); + }, + ), + ), + ); + expect(tapCount, 0); + await tester.tap(find.byType(SearchBar)); + expect(tapCount, 1); + await tester.tap(find.byType(SearchBar)); + expect(tapCount, 2); + }); + + testWidgets('SearchBar respects onChanged property', (WidgetTester tester) async { + var changeCount = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: SearchBar( + onChanged: (_) { + setState(() { + changeCount++; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(changeCount, 0); + await tester.enterText(find.byType(SearchBar), 'a'); + expect(changeCount, 1); + await tester.enterText(find.byType(SearchBar), 'b'); + expect(changeCount, 2); + }); + + testWidgets('SearchBar respects onSubmitted property', (WidgetTester tester) async { + var submittedQuery = ''; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchBar( + onSubmitted: (String text) { + submittedQuery = text; + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(SearchBar), 'query'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + expect(submittedQuery, equals('query')); + }); + + testWidgets('SearchBar respects constraints property', (WidgetTester tester) async { + const constraints = BoxConstraints(maxWidth: 350.0, minHeight: 80); + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Material(child: SearchBar(constraints: constraints)), + ), + ), + ); + + final Rect barRect = tester.getRect(find.byType(SearchBar)); + expect(barRect.size, const Size(350.0, 80.0)); + }); + + testWidgets('SearchBar respects elevation property', (WidgetTester tester) async { + const pressedElevation = 0.0; + const hoveredElevation = 1.0; + const focusedElevation = 2.0; + const defaultElevation = 3.0; + double getElevation(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedElevation; + } + if (states.contains(WidgetState.hovered)) { + return hoveredElevation; + } + if (states.contains(WidgetState.focused)) { + return focusedElevation; + } + return defaultElevation; + } + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar(elevation: WidgetStateProperty.resolveWith<double>(getElevation)), + ), + ), + ), + ); + + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + Material material = tester.widget<Material>(searchBarMaterial); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.elevation, hoveredElevation); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(searchBarMaterial); + expect(material.elevation, pressedElevation); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.elevation, focusedElevation); + }); + + testWidgets('SearchBar respects backgroundColor property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar(backgroundColor: WidgetStateProperty.resolveWith<Color>(_getColor)), + ), + ), + ), + ); + + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + Material material = tester.widget<Material>(searchBarMaterial); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.color, hoveredColor); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(searchBarMaterial); + expect(material.color, pressedColor); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.color, focusedColor); + }); + + testWidgets('SearchBar respects shadowColor property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar(shadowColor: WidgetStateProperty.resolveWith<Color>(_getColor)), + ), + ), + ), + ); + + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + Material material = tester.widget<Material>(searchBarMaterial); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.shadowColor, hoveredColor); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(searchBarMaterial); + expect(material.shadowColor, pressedColor); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.shadowColor, focusedColor); + }); + + testWidgets('SearchBar respects surfaceTintColor property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar(surfaceTintColor: WidgetStateProperty.resolveWith<Color>(_getColor)), + ), + ), + ), + ); + + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + Material material = tester.widget<Material>(searchBarMaterial); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.surfaceTintColor, hoveredColor); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(searchBarMaterial); + expect(material.surfaceTintColor, pressedColor); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.surfaceTintColor, focusedColor); + }); + + testWidgets('SearchBar respects overlayColor property', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar( + focusNode: focusNode, + overlayColor: WidgetStateProperty.resolveWith<Color>(_getColor), + ), + ), + ), + ), + ); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: hoveredColor.withOpacity(1.0))); + + // On pressed. + await tester.pumpAndSettle(); + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints + ..rect() + ..rect(color: pressedColor.withOpacity(1.0)), + ); + + // On focused. + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints + ..rect() + ..rect(color: focusedColor.withOpacity(1.0)), + ); + }); + + testWidgets('SearchBar respects side and shape properties', (WidgetTester tester) async { + const pressedSide = BorderSide(width: 2.0); + const hoveredSide = BorderSide(width: 3.0); + const focusedSide = BorderSide(width: 4.0); + const defaultSide = BorderSide(width: 5.0); + + const OutlinedBorder pressedShape = RoundedRectangleBorder(); + const OutlinedBorder hoveredShape = ContinuousRectangleBorder(); + const OutlinedBorder focusedShape = CircleBorder(); + const OutlinedBorder defaultShape = StadiumBorder(); + BorderSide getSide(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedSide; + } + if (states.contains(WidgetState.hovered)) { + return hoveredSide; + } + if (states.contains(WidgetState.focused)) { + return focusedSide; + } + return defaultSide; + } + + OutlinedBorder getShape(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedShape; + } + if (states.contains(WidgetState.hovered)) { + return hoveredShape; + } + if (states.contains(WidgetState.focused)) { + return focusedShape; + } + return defaultShape; + } + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar( + side: WidgetStateProperty.resolveWith<BorderSide>(getSide), + shape: WidgetStateProperty.resolveWith<OutlinedBorder>(getShape), + ), + ), + ), + ), + ); + + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + Material material = tester.widget<Material>(searchBarMaterial); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.shape, hoveredShape.copyWith(side: hoveredSide)); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + + material = tester.widget<Material>(searchBarMaterial); + expect(material.shape, pressedShape.copyWith(side: pressedSide)); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + material = tester.widget<Material>(searchBarMaterial); + expect(material.shape, focusedShape.copyWith(side: focusedSide)); + }); + + testWidgets('SearchBar respects padding property', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Material( + child: SearchBar( + leading: Icon(Icons.search), + padding: MaterialStatePropertyAll<EdgeInsets>(EdgeInsets.all(16.0)), + trailing: <Widget>[Icon(Icons.menu)], + ), + ), + ), + ), + ); + + final Rect barRect = tester.getRect(find.byType(SearchBar)); + final Rect leadingRect = tester.getRect(find.byIcon(Icons.search)); + final Rect textFieldRect = tester.getRect(find.byType(TextField)); + final Rect trailingRect = tester.getRect(find.byIcon(Icons.menu)); + + expect(barRect.left, leadingRect.left - 16.0); + expect(leadingRect.right, textFieldRect.left - 16.0); + expect(textFieldRect.right, trailingRect.left - 16.0); + expect(trailingRect.right, barRect.right - 16.0); + }); + + testWidgets('SearchBar respects hintStyle property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar( + hintText: 'hint text', + hintStyle: WidgetStateProperty.resolveWith<TextStyle?>(_getTextStyle), + ), + ), + ), + ), + ); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + Text helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, hoveredColor); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, pressedColor); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, focusedColor); + }); + + testWidgets('SearchBar respects textStyle property', (WidgetTester tester) async { + final controller = TextEditingController(text: 'input text'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar( + controller: controller, + textStyle: WidgetStateProperty.resolveWith<TextStyle?>(_getTextStyle), + ), + ), + ), + ), + ); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + EditableText inputText = tester.widget(find.text('input text')); + expect(inputText.style.color, hoveredColor); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + inputText = tester.widget(find.text('input text')); + expect(inputText.style.color, pressedColor); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + inputText = tester.widget(find.text('input text')); + expect(inputText.style.color, focusedColor); + }); + + testWidgets('SearchBar respects textCapitalization property', (WidgetTester tester) async { + Widget buildSearchBar(TextCapitalization textCapitalization) { + return MaterialApp( + home: Center( + child: Material(child: SearchBar(textCapitalization: textCapitalization)), + ), + ); + } + + await tester.pumpWidget(buildSearchBar(TextCapitalization.characters)); + await tester.pump(); + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.characters); + + await tester.pumpWidget(buildSearchBar(TextCapitalization.sentences)); + await tester.pump(); + textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.sentences); + + await tester.pumpWidget(buildSearchBar(TextCapitalization.words)); + await tester.pump(); + textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.words); + + await tester.pumpWidget(buildSearchBar(TextCapitalization.none)); + await tester.pump(); + textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.none); + }); + + testWidgets('SearchAnchor respects textCapitalization property', (WidgetTester tester) async { + Widget buildSearchAnchor(TextCapitalization textCapitalization) { + return MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + textCapitalization: textCapitalization, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.ac_unit), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor(TextCapitalization.characters)); + await tester.pump(); + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.characters); + await tester.tap(find.backButton()); + await tester.pump(); + + await tester.pumpWidget(buildSearchAnchor(TextCapitalization.none)); + await tester.pump(); + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.none); + }); + + testWidgets('SearchAnchor respects viewOnChanged and viewOnSubmitted properties', ( + WidgetTester tester, + ) async { + final controller = SearchController(); + addTearDown(controller.dispose); + var onChangedCalled = 0; + var onSubmittedCalled = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Material( + child: SearchAnchor( + searchController: controller, + viewOnChanged: (String value) { + setState(() { + onChangedCalled = onChangedCalled + 1; + }); + }, + viewOnSubmitted: (String value) { + setState(() { + onSubmittedCalled = onSubmittedCalled + 1; + }); + controller.closeView(value); + }, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + onTap: () { + if (!controller.isOpen) { + controller.openView(); + } + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.byType(SearchBar)); // Open search view. + await tester.pumpAndSettle(); + expect(controller.isOpen, true); + + final Finder barOnView = find.descendant( + of: findViewContent(), + matching: find.byType(TextField), + ); + await tester.enterText(barOnView, 'a'); + expect(onChangedCalled, 1); + await tester.enterText(barOnView, 'abc'); + expect(onChangedCalled, 2); + + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(onSubmittedCalled, 1); + expect(controller.isOpen, false); + }); + + testWidgets('SearchAnchor.bar respects textCapitalization property', (WidgetTester tester) async { + Widget buildSearchAnchor(TextCapitalization textCapitalization) { + return MaterialApp( + home: Center( + child: Material( + child: SearchAnchor.bar( + textCapitalization: textCapitalization, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor(TextCapitalization.characters)); + await tester.pump(); + await tester.tap(find.byType(SearchBar)); // Open search view. + await tester.pumpAndSettle(); + final Finder textFieldFinder = find.descendant( + of: findViewContent(), + matching: find.byType(TextField), + ); + final TextField textFieldInView = tester.widget<TextField>(textFieldFinder); + expect(textFieldInView.textCapitalization, TextCapitalization.characters); + // Close search view. + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.textCapitalization, TextCapitalization.characters); + }); + + testWidgets('SearchAnchor.bar respects onChanged and onSubmitted properties', ( + WidgetTester tester, + ) async { + final controller = SearchController(); + addTearDown(controller.dispose); + var onChangedCalled = 0; + var onSubmittedCalled = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Material( + child: SearchAnchor.bar( + searchController: controller, + onSubmitted: (String value) { + setState(() { + onSubmittedCalled = onSubmittedCalled + 1; + }); + controller.closeView(value); + }, + onChanged: (String value) { + setState(() { + onChangedCalled = onChangedCalled + 1; + }); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.byType(SearchBar)); // Open search view. + await tester.pumpAndSettle(); + expect(controller.isOpen, true); + + final Finder barOnView = find.descendant( + of: findViewContent(), + matching: find.byType(TextField), + ); + await tester.enterText(barOnView, 'a'); + expect(onChangedCalled, 1); + await tester.enterText(barOnView, 'abc'); + expect(onChangedCalled, 2); + + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(onSubmittedCalled, 1); + expect(controller.isOpen, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/178719. + testWidgets('SearchAnchor.bar anchor loses focus after view closes', (WidgetTester tester) async { + final controller = SearchController(); + addTearDown(controller.dispose); + var onSubmittedCalled = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Material( + child: SearchAnchor.bar( + searchController: controller, + onSubmitted: (String value) { + setState(() { + onSubmittedCalled++; + }); + controller.closeView(value); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + }, + ), + ), + ); + + // Tap to open the search view. + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + expect(controller.isOpen, true); + + // Find the anchor SearchBar's TextField. + final Finder anchorTextField = find.descendant( + of: find.byType(SearchBar).first, + matching: find.byType(TextField), + ); + + // Find the view SearchBar's TextField. + final Finder viewTextField = find.descendant( + of: findViewContent(), + matching: find.byType(TextField), + ); + + // View SearchBar should have focus. + expect(tester.widget<TextField>(viewTextField).focusNode?.hasFocus, isTrue); + + // Close the view (use receiveAction because sendKeyEvent + // doesn't trigger onSubmitted in tests - the text input action needs to + // be sent directly to properly simulate the IME behavior). + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(onSubmittedCalled, 1); + expect(controller.isOpen, false); + + // After search view is closed, anchor SearchBar should NOT have focus. + expect(tester.widget<TextField>(anchorTextField).focusNode?.hasFocus, isFalse); + + // Simulate the IME behavior via input action again . + // It should not trigger onSubmitted because field is unfocused + // and the text input connection is closed. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(onSubmittedCalled, 1); // Still 1, not incremented. + }); + + testWidgets('hintStyle can override textStyle for hintText', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchBar( + hintText: 'hint text', + hintStyle: WidgetStateProperty.resolveWith<TextStyle?>(_getTextStyle), + textStyle: const MaterialStatePropertyAll<TextStyle>(TextStyle(color: Colors.pink)), + ), + ), + ), + ), + ); + + // On hovered. + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await tester.pump(); + Text helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, hoveredColor); + + // On pressed. + await gesture.down(tester.getCenter(find.byType(SearchBar))); + await tester.pumpAndSettle(); + helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, pressedColor); + + // On focused. + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); + helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, focusedColor); + }); + + // Regression test for https://github.com/flutter/flutter/issues/127092. + testWidgets('The text is still centered when SearchBar text field is smaller than 48', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Material(child: SearchBar(constraints: BoxConstraints.tightFor(height: 35.0))), + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'input text'); + final Finder textContent = find.text('input text'); + final double textCenterY = tester.getCenter(textContent).dy; + final Finder searchBar = find.byType(SearchBar); + final double searchBarCenterY = tester.getCenter(searchBar).dy; + expect(textCenterY, searchBarCenterY); + }); + + testWidgets('The search view defaults', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final Material material = getSearchViewMaterial(tester); + expect(material.elevation, 6.0); + expect(material.color, colorScheme.surfaceContainerHigh); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.clipBehavior, Clip.antiAlias); + + final Finder findDivider = find.byType(Divider); + final Container dividerContainer = tester.widget<Container>( + find.descendant(of: findDivider, matching: find.byType(Container)).first, + ); + final decoration = dividerContainer.decoration! as BoxDecoration; + expect(decoration.border!.bottom.color, colorScheme.outline); + + // Default search view has a leading back button on the start of the header. + expect(find.backButton(), findsOneWidget); + + final Text helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, colorScheme.onSurfaceVariant); + expect(helperText.style?.fontSize, 16.0); + expect(helperText.style?.fontFamily, 'Roboto'); + expect(helperText.style?.fontWeight, FontWeight.w400); + + const input = 'entered text'; + await tester.enterText(find.byType(SearchBar), input); + final EditableText inputText = tester.widget(find.text(input)); + expect(inputText.style.color, colorScheme.onSurface); + expect(inputText.style.fontSize, 16.0); + expect(inputText.style.fontFamily, 'Roboto'); + expect(inputText.style.fontWeight, FontWeight.w400); + }); + + testWidgets('The search view default size on different platforms', (WidgetTester tester) async { + // The search view should be is full-screen on mobile platforms, + // and have a size of (360, 2/3 screen height) on other platforms + Widget buildSearchAnchor(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: SafeArea( + child: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ), + ); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.iOS, + TargetPlatform.android, + TargetPlatform.fuchsia, + ]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(platform)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final Size size = tester.getSize( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(size.width, 800.0); + expect(size.height, 600.0); + } + + for (final platform in <TargetPlatform>[TargetPlatform.linux, TargetPlatform.windows]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(platform)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final Size size = tester.getSize( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(size.width, 360.0); + expect(size.height, 400.0); + } + }); + + testWidgets('SearchAnchor respects isFullScreen property', (WidgetTester tester) async { + Widget buildSearchAnchor(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: SafeArea( + child: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + isFullScreen: true, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.linux, TargetPlatform.windows]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(platform)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final Size size = tester.getSize( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(size.width, 800.0); + expect(size.height, 600.0); + } + }); + + testWidgets('SearchAnchor respects controller property', (WidgetTester tester) async { + const defaultText = 'initial text'; + final controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + const updatedText = 'updated text'; + await tester.enterText(find.byType(SearchBar), updatedText); + expect(controller.value.text, updatedText); + expect(find.text(defaultText), findsNothing); + expect(find.text(updatedText), findsOneWidget); + }); + + testWidgets('SearchAnchor attaches and detaches controllers property', ( + WidgetTester tester, + ) async { + Widget builder(BuildContext context, SearchController controller) { + return const Icon(Icons.search); + } + + List<Widget> suggestionsBuilder(BuildContext context, SearchController controller) { + return const <Widget>[]; + } + + final controller1 = SearchController(); + addTearDown(controller1.dispose); + + expect(controller1.isAttached, isFalse); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + searchController: controller1, + builder: builder, + suggestionsBuilder: suggestionsBuilder, + ), + ), + ), + ); + + expect(controller1.isAttached, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor(builder: builder, suggestionsBuilder: suggestionsBuilder), + ), + ), + ); + + expect(controller1.isAttached, isFalse); + + final controller2 = SearchController(); + addTearDown(controller2.dispose); + + expect(controller2.isAttached, isFalse); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + searchController: controller2, + builder: builder, + suggestionsBuilder: suggestionsBuilder, + ), + ), + ), + ); + + expect(controller1.isAttached, isFalse); + expect(controller2.isAttached, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor(builder: builder, suggestionsBuilder: suggestionsBuilder), + ), + ), + ); + + expect(controller1.isAttached, isFalse); + expect(controller2.isAttached, isFalse); + }); + + testWidgets('SearchAnchor respects viewBuilder property', (WidgetTester tester) async { + Widget buildAnchor({ViewBuilder? viewBuilder}) { + return MaterialApp( + home: Material( + child: SearchAnchor( + viewBuilder: viewBuilder, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + // Default is a ListView. + expect(find.byType(ListView), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAnchor( + viewBuilder: (Iterable<Widget> suggestions) => + GridView.count(crossAxisCount: 5, children: suggestions.toList()), + ), + ); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.byType(ListView), findsNothing); + expect(find.byType(GridView), findsOneWidget); + }); + + testWidgets('SearchAnchor.bar respects viewBuilder property', (WidgetTester tester) async { + Widget buildAnchor({ViewBuilder? viewBuilder}) { + return MaterialApp( + home: Material( + child: SearchAnchor.bar( + viewBuilder: viewBuilder, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + // Default is a ListView. + expect(find.byType(ListView), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAnchor( + viewBuilder: (Iterable<Widget> suggestions) => + GridView.count(crossAxisCount: 5, children: suggestions.toList()), + ), + ); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + expect(find.byType(ListView), findsNothing); + expect(find.byType(GridView), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewLeading property', (WidgetTester tester) async { + Widget buildAnchor({Widget? viewLeading}) { + return MaterialApp( + home: Material( + child: SearchAnchor( + viewLeading: viewLeading, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + // Default is a icon button with arrow_back. + expect(find.backButton(), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAnchor(viewLeading: const Icon(Icons.history))); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.backButton(), findsNothing); + expect(find.byIcon(Icons.history), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewTrailing property', (WidgetTester tester) async { + Widget buildAnchor({Iterable<Widget>? viewTrailing}) { + return MaterialApp( + home: Material( + child: SearchAnchor( + viewTrailing: viewTrailing, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + // Default is a icon button with close icon when input is not empty. + await tester.enterText(findTextField(), 'a'); + await tester.pump(); + expect(find.widgetWithIcon(IconButton, Icons.close), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAnchor(viewTrailing: <Widget>[const Icon(Icons.history)])); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.close), findsNothing); + expect(find.byIcon(Icons.history), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewHintText property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.text('hint text'), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewBackgroundColor property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + viewBackgroundColor: Colors.purple, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).color, Colors.purple); + }); + + testWidgets('SearchAnchor respects viewElevation property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + viewElevation: 3.0, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).elevation, 3.0); + }); + + testWidgets('SearchAnchor respects viewSurfaceTint property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + viewSurfaceTintColor: Colors.purple, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).surfaceTintColor, Colors.purple); + }); + + testWidgets('SearchAnchor respects viewSide property', (WidgetTester tester) async { + const side = BorderSide(color: Colors.purple, width: 5.0); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + viewSide: side, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect( + getSearchViewMaterial(tester).shape, + const RoundedRectangleBorder( + side: side, + borderRadius: BorderRadius.all(Radius.circular(28.0)), + ), + ); + }); + + testWidgets('SearchAnchor respects viewShape property', (WidgetTester tester) async { + const side = BorderSide(color: Colors.purple, width: 5.0); + const OutlinedBorder shape = StadiumBorder(side: side); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + viewShape: shape, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).shape, shape); + }); + + testWidgets('SearchAnchor respects headerTextStyle property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + headerTextStyle: theme.textTheme.bodyLarge?.copyWith(color: Colors.red), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(SearchBar), 'input text'); + await tester.pumpAndSettle(); + + final EditableText inputText = tester.widget(find.text('input text')); + expect(inputText.style.color, Colors.red); + }); + + testWidgets('SearchAnchor respects headerHintStyle property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + viewHintText: 'hint text', + headerHintStyle: theme.textTheme.bodyLarge?.copyWith(color: Colors.orange), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Text inputText = tester.widget(find.text('hint text')); + expect(inputText.style?.color, Colors.orange); + }); + + testWidgets('SearchAnchor respects viewPadding property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + viewPadding: const EdgeInsets.all(16.0), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Padding padding = tester.widget<Padding>( + find.descendant(of: findViewContent(), matching: find.byType(Padding)).first, + ); + expect(padding.padding, const EdgeInsets.all(16.0)); + }); + + testWidgets('SearchAnchor ignores viewPadding property if full screen', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: true, + viewPadding: const EdgeInsets.all(16.0), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Padding padding = tester.widget<Padding>( + find.descendant(of: findViewContent(), matching: find.byType(Padding)).first, + ); + expect(padding.padding, EdgeInsets.zero); + }); + + testWidgets('SearchAnchor respects shrinkWrap property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + shrinkWrap: true, + viewConstraints: const BoxConstraints(), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<Widget>.generate( + controller.text.length, + (int index) => ListTile(title: Text('Item $index')), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Finder findDivider = find.descendant( + of: findViewContent(), + matching: find.byType(Divider), + ); + + // Divider should not be shown if there are no suggestions + expect(findDivider, findsNothing); + + final Finder findMaterial = find + .descendant(of: findViewContent(), matching: find.byType(Material)) + .first; + final Rect materialRectWithoutSuggestions = tester.getRect(findMaterial); + expect(materialRectWithoutSuggestions, equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0))); + + await tester.enterText(find.byType(SearchBar), 'a'); + await tester.pumpAndSettle(); + + expect(findDivider, findsOneWidget); + + final Rect materialRectWithSuggestions = tester.getRect(findMaterial); + expect(materialRectWithSuggestions, equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 113.0))); + }); + + testWidgets('SearchAnchor respects dividerColor property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + dividerColor: Colors.red, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Finder findDivider = find.byType(Divider); + final Container dividerContainer = tester.widget<Container>( + find.descendant(of: findDivider, matching: find.byType(Container)).first, + ); + final decoration = dividerContainer.decoration! as BoxDecoration; + expect(decoration.border!.bottom.color, Colors.red); + }); + + testWidgets('SearchAnchor respects viewConstraints property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + isFullScreen: false, + viewConstraints: BoxConstraints.tight(const Size(280.0, 390.0)), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Size size = tester.getSize( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(size.width, 280.0); + expect(size.height, 390.0); + }); + + testWidgets('SearchAnchor respects viewBarPadding property', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + viewBarPadding: const EdgeInsets.symmetric(horizontal: 16.0), + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Finder findSearchBar = find + .descendant(of: findViewContent(), matching: find.byType(SearchBar)) + .first; + final Padding padding = tester.widget<Padding>( + find.descendant(of: findSearchBar, matching: find.byType(Padding)).first, + ); + expect(padding.padding, const EdgeInsets.symmetric(horizontal: 16.0)); + }); + + testWidgets('SearchAnchor respects builder property - LTR', (WidgetTester tester) async { + Widget buildAnchor({required SearchAnchorChildBuilder builder}) { + return MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + isFullScreen: false, + builder: builder, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget( + buildAnchor( + builder: (BuildContext context, SearchController controller) => const Icon(Icons.search), + ), + ); + final Rect anchorRect = tester.getRect(find.byIcon(Icons.search)); + expect(anchorRect.size, const Size(24.0, 24.0)); + expect(anchorRect, equals(const Rect.fromLTRB(388.0, 0.0, 412.0, 24.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect, equals(const Rect.fromLTRB(388.0, 0.0, 748.0, 400.0))); + + // Search view top left should be the same as the anchor top left + expect(searchViewRect.topLeft, anchorRect.topLeft); + }); + + testWidgets('SearchAnchor respects builder property - RTL', (WidgetTester tester) async { + Widget buildAnchor({required SearchAnchorChildBuilder builder}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + isFullScreen: false, + builder: builder, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + buildAnchor( + builder: (BuildContext context, SearchController controller) => const Icon(Icons.search), + ), + ); + final Rect anchorRect = tester.getRect(find.byIcon(Icons.search)); + expect(anchorRect.size, const Size(24.0, 24.0)); + expect(anchorRect, equals(const Rect.fromLTRB(388.0, 0.0, 412.0, 24.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect, equals(const Rect.fromLTRB(52.0, 0.0, 412.0, 400.0))); + + // Search view top right should be the same as the anchor top right + expect(searchViewRect.topRight, anchorRect.topRight); + }); + + testWidgets('SearchAnchor respects suggestionsBuilder property', (WidgetTester tester) async { + final controller = SearchController(); + addTearDown(controller.dispose); + const suggestion = 'suggestion text'; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[ + ListTile( + title: const Text(suggestion), + onTap: () { + setState(() { + controller.closeView(suggestion); + }); + }, + ), + ]; + }, + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Finder listTile = find.widgetWithText(ListTile, suggestion); + expect(listTile, findsOneWidget); + await tester.tap(listTile); + await tester.pumpAndSettle(); + + expect(controller.isOpen, false); + expect(controller.value.text, suggestion); + }); + + testWidgets('SearchAnchor should update suggestions on changes to search controller', ( + WidgetTester tester, + ) async { + final controller = SearchController(); + const suggestions = <String>['foo', 'far', 'bim']; + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + final String searchText = controller.text.toLowerCase(); + if (searchText.isEmpty) { + return const <Widget>[Center(child: Text('No Search'))]; + } + final Iterable<String> filterSuggestions = suggestions.where( + (String suggestion) => suggestion.toLowerCase().contains(searchText), + ); + return filterSuggestions.map((String suggestion) { + return ListTile( + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(Icons.call_missed), + onPressed: () { + controller.text = suggestion; + }, + ), + onTap: () { + controller.closeView(suggestion); + }, + ); + }).toList(); + }, + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Finder listTile1 = find.widgetWithText(ListTile, 'foo'); + final Finder listTile2 = find.widgetWithText(ListTile, 'far'); + final Finder listTile3 = find.widgetWithText(ListTile, 'bim'); + final Finder textWidget = find.widgetWithText(Center, 'No Search'); + final Finder iconInListTile1 = find.descendant( + of: listTile1, + matching: find.byIcon(Icons.call_missed), + ); + + expect(textWidget, findsOneWidget); + expect(listTile1, findsNothing); + expect(listTile2, findsNothing); + expect(listTile3, findsNothing); + + await tester.enterText(find.byType(SearchBar), 'f'); + await tester.pumpAndSettle(); + expect(textWidget, findsNothing); + expect(listTile1, findsOneWidget); + expect(listTile2, findsOneWidget); + expect(listTile3, findsNothing); + + await tester.tap(iconInListTile1); + await tester.pumpAndSettle(); + expect(controller.value.text, 'foo'); + expect(textWidget, findsNothing); + expect(listTile1, findsOneWidget); + expect(listTile2, findsNothing); + expect(listTile3, findsNothing); + + await tester.tap(listTile1); + await tester.pumpAndSettle(); + expect(controller.isOpen, false); + expect(controller.value.text, 'foo'); + expect(textWidget, findsNothing); + expect(listTile1, findsNothing); + expect(listTile2, findsNothing); + expect(listTile3, findsNothing); + }); + + testWidgets('SearchAnchor suggestionsBuilder property could be async', ( + WidgetTester tester, + ) async { + final controller = SearchController(); + addTearDown(controller.dispose); + const suggestion = 'suggestion text'; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) async { + return <Widget>[ + ListTile( + title: const Text(suggestion), + onTap: () { + setState(() { + controller.closeView(suggestion); + }); + }, + ), + ]; + }, + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Finder text = find.text(suggestion); + expect(text, findsOneWidget); + await tester.tap(text); + await tester.pumpAndSettle(); + + expect(controller.isOpen, false); + expect(controller.value.text, suggestion); + }); + + testWidgets('SearchAnchor.bar has a default search bar as the anchor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor.bar( + isFullScreen: false, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + expect(find.byType(SearchBar), findsOneWidget); + final Rect anchorRect = tester.getRect(find.byType(SearchBar)); + expect(anchorRect.size, const Size(800.0, 56.0)); + expect(anchorRect, equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect, equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 400.0))); + + // Search view has same width with the default anchor(search bar). + expect(searchViewRect.width, anchorRect.width); + }); + + testWidgets('SearchController can open/close view', (WidgetTester tester) async { + final controller = SearchController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + isFullScreen: false, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[ + ListTile( + title: const Text('item 0'), + onTap: () { + controller.closeView('item 0'); + }, + ), + ]; + }, + ), + ), + ), + ); + + expect(controller.isOpen, false); + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + + expect(controller.isOpen, true); + await tester.tap(find.widgetWithText(ListTile, 'item 0')); + await tester.pumpAndSettle(); + expect(controller.isOpen, false); + controller.openView(); + expect(controller.isOpen, true); + }); + + testWidgets('Search view does not go off the screen - LTR', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Align( + // Put the search anchor on the bottom-right corner of the screen to test + // if the search view goes off the window. + alignment: Alignment.bottomRight, + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + final Finder findIconButton = find.widgetWithIcon(IconButton, Icons.search); + final Rect iconButton = tester.getRect(findIconButton); + // Icon button has a size of (48.0, 48.0) and the screen size is (800.0, 600.0). + expect(iconButton, equals(const Rect.fromLTRB(752.0, 552.0, 800.0, 600.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect, equals(const Rect.fromLTRB(440.0, 200.0, 800.0, 600.0))); + }); + + testWidgets('Search view does not go off the screen - RTL', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Align( + // Put the search anchor on the bottom-left corner of the screen to test + // if the search view goes off the window when the text direction is right-to-left. + alignment: Alignment.bottomLeft, + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ), + ); + + final Finder findIconButton = find.widgetWithIcon(IconButton, Icons.search); + final Rect iconButton = tester.getRect(findIconButton); + expect(iconButton, equals(const Rect.fromLTRB(0.0, 552.0, 48.0, 600.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect, equals(const Rect.fromLTRB(0.0, 200.0, 360.0, 600.0))); + }); + + testWidgets('Search view becomes smaller if the window size is smaller than the view size', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(200.0, 200.0); + tester.view.devicePixelRatio = 1.0; + + Widget buildSearchAnchor({TextDirection textDirection = TextDirection.ltr}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return Align( + alignment: Alignment.bottomRight, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + // Test LTR text direction. + await tester.pumpWidget(buildSearchAnchor()); + + final Finder findIconButton = find.widgetWithIcon(IconButton, Icons.search); + final Rect iconButton = tester.getRect(findIconButton); + // The icon button size is (48.0, 48.0), and the screen size is (200.0, 200.0) + expect(iconButton, equals(const Rect.fromLTRB(152.0, 152.0, 200.0, 200.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect, equals(const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0))); + + // Test RTL text direction. + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(textDirection: TextDirection.rtl)); + + final Finder findIconButtonRTL = find.widgetWithIcon(IconButton, Icons.search); + final Rect iconButtonRTL = tester.getRect(findIconButtonRTL); + // The icon button size is (48.0, 48.0), and the screen size is (200.0, 200.0) + expect(iconButtonRTL, equals(const Rect.fromLTRB(152.0, 152.0, 200.0, 200.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRectRTL = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRectRTL, equals(const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0))); + }); + + testWidgets('Docked search view route is popped if the window size changes', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(500.0, 600.0); + tester.view.devicePixelRatio = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return Align( + alignment: Alignment.bottomRight, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + // Open the search view + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + expect(find.backButton(), findsOneWidget); + + // Change window size + tester.view.physicalSize = const Size(250.0, 200.0); + tester.view.devicePixelRatio = 1.0; + await tester.pumpAndSettle(); + expect(find.backButton(), findsNothing); + }); + + testWidgets('Full-screen search view route should stay if the window size changes', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(500.0, 600.0); + tester.view.devicePixelRatio = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: true, + builder: (BuildContext context, SearchController controller) { + return Align( + alignment: Alignment.bottomRight, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + // Open a full-screen search view + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + expect(find.backButton(), findsOneWidget); + + // Change window size + tester.view.physicalSize = const Size(250.0, 200.0); + tester.view.devicePixelRatio = 1.0; + await tester.pumpAndSettle(); + expect(find.backButton(), findsOneWidget); + }); + + testWidgets('Search view route does not throw exception during pop animation', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/126590. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<Widget>.generate(5, (int index) { + final item = 'item $index'; + return ListTile( + leading: const Icon(Icons.history), + title: Text(item), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ); + }); + }, + ), + ), + ), + ), + ); + + // Open search view + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + // Pop search view route + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + + // No exception. + }); + + testWidgets('Docked search should position itself correctly based on closest navigator', ( + WidgetTester tester, + ) async { + const rootSpacing = 100.0; + + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return Scaffold( + body: Padding(padding: const EdgeInsets.all(rootSpacing), child: child), + ); + }, + home: Material( + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect.topLeft, equals(const Offset(rootSpacing, rootSpacing))); + }); + + testWidgets('Docked search view with nested navigator does not go off the screen', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400.0, 400.0); + tester.view.devicePixelRatio = 1.0; + + const rootSpacing = 100.0; + + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return Scaffold( + body: Padding(padding: const EdgeInsets.all(rootSpacing), child: child), + ); + }, + home: Material( + child: Align( + alignment: Alignment.bottomRight, + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(searchViewRect.bottomRight, equals(const Offset(300.0, 300.0))); + }); + + // Regression tests for https://github.com/flutter/flutter/issues/128332 + group('SearchAnchor text selection', () { + testWidgets('can right-click to select word', (WidgetTester tester) async { + const defaultText = 'initial text'; + final controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 4) + const Offset(0.0, -9.0), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); + + testWidgets('can click to set position', (WidgetTester tester) async { + const defaultText = 'initial text'; + final controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await gesture.down(textOffsetToPosition(tester, 2) + const Offset(0.0, -9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.value.selection, const TextSelection.collapsed(offset: 2)); + + await gesture.down(textOffsetToPosition(tester, 9, index: 1) + const Offset(0.0, -9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, const TextSelection.collapsed(offset: 9)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('can double-click to select word', (WidgetTester tester) async { + const defaultText = 'initial text'; + final controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await _pointGestureToSearchBar(tester); + final Offset targetPosition = textOffsetToPosition(tester, 4) + const Offset(0.0, -9.0); + await gesture.down(targetPosition); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final Offset targetPositionAfterViewOpened = + textOffsetToPosition(tester, 4, index: 1) + const Offset(0.0, -9.0); + await gesture.down(targetPositionAfterViewOpened); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pump(); + + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.value.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('can triple-click to select field', (WidgetTester tester) async { + const defaultText = 'initial text'; + final controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await _pointGestureToSearchBar(tester); + final Offset targetPosition = textOffsetToPosition(tester, 4) + const Offset(0.0, -9.0); + await gesture.down(targetPosition); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final Offset targetPositionAfterViewOpened = + textOffsetToPosition(tester, 4, index: 1) + const Offset(0.0, -9.0); + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, const TextSelection(baseOffset: 0, extentOffset: 12)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.desktop()); + }); + + // Regression tests for https://github.com/flutter/flutter/issues/126623 + group('Overall InputDecorationThemeData does not impact SearchBar and SearchView', () { + const inputDecorationTheme = InputDecorationThemeData( + focusColor: Colors.green, + hoverColor: Colors.blue, + outlineBorder: BorderSide(color: Colors.pink, width: 10), + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 20), + hintStyle: TextStyle(color: Colors.purpleAccent), + fillColor: Colors.tealAccent, + filled: true, + isCollapsed: true, + border: OutlineInputBorder(), + focusedBorder: UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder(), + errorBorder: UnderlineInputBorder(), + focusedErrorBorder: UnderlineInputBorder(), + disabledBorder: UnderlineInputBorder(), + constraints: BoxConstraints(maxWidth: 300), + ); + final theme = ThemeData(inputDecorationTheme: inputDecorationTheme); + + void checkDecorationInSearchBar(WidgetTester tester) { + final Finder textField = findTextField(); + final InputDecoration? decoration = tester.widget<TextField>(textField).decoration; + + expect(decoration?.border, InputBorder.none); + expect(decoration?.focusedBorder, InputBorder.none); + expect(decoration?.enabledBorder, InputBorder.none); + expect(decoration?.errorBorder, null); + expect(decoration?.focusedErrorBorder, null); + expect(decoration?.disabledBorder, null); + expect(decoration?.constraints, null); + expect(decoration?.isCollapsed, false); + expect(decoration?.filled, false); + expect(decoration?.fillColor, null); + expect(decoration?.focusColor, null); + expect(decoration?.hoverColor, null); + expect(decoration?.contentPadding, EdgeInsets.zero); + expect(decoration?.hintStyle?.color, theme.colorScheme.onSurfaceVariant); + } + + testWidgets('Overall InputDecorationThemeData does not override text field style' + ' in SearchBar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: Material(child: SearchBar(hintText: 'hint text')), + ), + ), + ); + + // Check input decoration in `SearchBar` + checkDecorationInSearchBar(tester); + + // Check search bar defaults. + final Finder searchBarMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(searchBarMaterial); + await checkSearchBarDefaults(tester, theme.colorScheme, material); + }); + + testWidgets('Overall InputDecorationThemeData does not override text field style' + ' in the search view route', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + // Check input decoration in `SearchBar` + checkDecorationInSearchBar(tester); + + // Check search bar defaults in search view route. + final Finder searchBarMaterial = find + .descendant( + of: find.descendant(of: findViewContent(), matching: find.byType(SearchBar)), + matching: find.byType(Material), + ) + .first; + + final Material material = tester.widget<Material>(searchBarMaterial); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + final Text hintText = tester.widget(find.text('hint text')); + expect(hintText.style?.color, theme.colorScheme.onSurfaceVariant); + + const input = 'entered text'; + await tester.enterText(find.byType(SearchBar), input); + final EditableText inputText = tester.widget(find.text(input)); + expect(inputText.style.color, theme.colorScheme.onSurface); + }); + }); + + testWidgets('SearchAnchor view respects theme brightness', (WidgetTester tester) async { + Widget buildSearchAnchor(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Center( + child: Material( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.ac_unit), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + var theme = ThemeData(brightness: Brightness.light); + await tester.pumpWidget(buildSearchAnchor(theme)); + + // Open the search view. + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + + // Test the search view background color. + Material material = getSearchViewMaterial(tester); + expect(material.color, theme.colorScheme.surfaceContainerHigh); + + // Change the theme brightness. + theme = ThemeData(brightness: Brightness.dark); + await tester.pumpWidget(buildSearchAnchor(theme)); + await tester.pumpAndSettle(); + + // Test the search view background color. + material = getSearchViewMaterial(tester); + expect(material.color, theme.colorScheme.surfaceContainerHigh); + }); + + testWidgets('Search view widgets can inherit local themes', (WidgetTester tester) async { + final globalTheme = ThemeData(colorSchemeSeed: Colors.red); + final localTheme = ThemeData( + colorSchemeSeed: Colors.green, + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom(backgroundColor: const Color(0xffffff00)), + ), + cardTheme: const CardThemeData(color: Color(0xff00ffff)), + ); + Widget buildSearchAnchor() { + return MaterialApp( + theme: globalTheme, + home: Center( + child: Builder( + builder: (BuildContext context) { + return Theme( + data: localTheme, + child: Material( + child: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[ + Card( + child: ListTile(onTap: () {}, title: const Text('Item 1')), + ), + ]; + }, + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor()); + + // Open the search view. + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + + // Test the search view background color. + final Material searchViewMaterial = getSearchViewMaterial(tester); + expect(searchViewMaterial.color, localTheme.colorScheme.surfaceContainerHigh); + + // Test the search view icons background color. + final Material iconButtonMaterial = tester.widget<Material>( + find.descendant(of: find.byType(IconButton), matching: find.byType(Material)).first, + ); + expect(find.byWidget(iconButtonMaterial), findsOneWidget); + expect( + iconButtonMaterial.color, + localTheme.iconButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{}), + ); + + // Test the suggestion card color. + final Material suggestionMaterial = tester.widget<Material>( + find.descendant(of: find.byType(Card), matching: find.byType(Material)).first, + ); + expect(suggestionMaterial.color, localTheme.cardTheme.color); + }); + + testWidgets('SearchBar respects keyboardType property', (WidgetTester tester) async { + Widget buildSearchBar(TextInputType keyboardType) { + return MaterialApp( + home: Center( + child: Material(child: SearchBar(keyboardType: keyboardType)), + ), + ); + } + + await tester.pumpWidget(buildSearchBar(TextInputType.number)); + await tester.pump(); + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.number); + + await tester.pumpWidget(buildSearchBar(TextInputType.phone)); + await tester.pump(); + textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.phone); + }); + + testWidgets('SearchAnchor respects keyboardType property', (WidgetTester tester) async { + Widget buildSearchAnchor(TextInputType keyboardType) { + return MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + keyboardType: keyboardType, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.ac_unit), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor(TextInputType.number)); + await tester.pump(); + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.number); + await tester.tap(find.backButton()); + await tester.pump(); + + await tester.pumpWidget(buildSearchAnchor(TextInputType.phone)); + await tester.pump(); + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.phone); + }); + + testWidgets('SearchAnchor.bar respects keyboardType property', (WidgetTester tester) async { + Widget buildSearchAnchor(TextInputType keyboardType) { + return MaterialApp( + home: Center( + child: Material( + child: SearchAnchor.bar( + keyboardType: keyboardType, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor(TextInputType.number)); + await tester.pump(); + await tester.tap(find.byType(SearchBar)); // Open search view. + await tester.pumpAndSettle(); + final Finder textFieldFinder = find.descendant( + of: findViewContent(), + matching: find.byType(TextField), + ); + final TextField textFieldInView = tester.widget<TextField>(textFieldFinder); + expect(textFieldInView.keyboardType, TextInputType.number); + // Close search view. + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.keyboardType, TextInputType.number); + }); + + testWidgets('SearchBar respects textInputAction property', (WidgetTester tester) async { + Widget buildSearchBar(TextInputAction textInputAction) { + return MaterialApp( + home: Center( + child: Material(child: SearchBar(textInputAction: textInputAction)), + ), + ); + } + + await tester.pumpWidget(buildSearchBar(TextInputAction.previous)); + await tester.pump(); + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.textInputAction, TextInputAction.previous); + + await tester.pumpWidget(buildSearchBar(TextInputAction.send)); + await tester.pump(); + textField = tester.widget(find.byType(TextField)); + expect(textField.textInputAction, TextInputAction.send); + }); + + testWidgets('SearchAnchor respects textInputAction property', (WidgetTester tester) async { + Widget buildSearchAnchor(TextInputAction textInputAction) { + return MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + textInputAction: textInputAction, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.ac_unit), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor(TextInputAction.previous)); + await tester.pump(); + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.textInputAction, TextInputAction.previous); + await tester.tap(find.backButton()); + await tester.pump(); + + await tester.pumpWidget(buildSearchAnchor(TextInputAction.send)); + await tester.pump(); + await tester.tap(find.widgetWithIcon(IconButton, Icons.ac_unit)); + await tester.pumpAndSettle(); + textField = tester.widget(find.byType(TextField)); + expect(textField.textInputAction, TextInputAction.send); + }); + + testWidgets('SearchAnchor.bar respects textInputAction property', (WidgetTester tester) async { + Widget buildSearchAnchor(TextInputAction textInputAction) { + return MaterialApp( + home: Center( + child: Material( + child: SearchAnchor.bar( + textInputAction: textInputAction, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSearchAnchor(TextInputAction.previous)); + await tester.pump(); + await tester.tap(find.byType(SearchBar)); // Open search view. + await tester.pumpAndSettle(); + final Finder textFieldFinder = find.descendant( + of: findViewContent(), + matching: find.byType(TextField), + ); + final TextField textFieldInView = tester.widget<TextField>(textFieldFinder); + expect(textFieldInView.textInputAction, TextInputAction.previous); + // Close search view. + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.textInputAction, TextInputAction.previous); + }); + + testWidgets('Block entering text on disabled widget', (WidgetTester tester) async { + const initValue = 'init'; + final controller = TextEditingController(text: initValue); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: SearchBar(controller: controller, enabled: false)), + ), + ), + ); + + const testValue = 'abcdefghi'; + await tester.enterText(find.byType(SearchBar), testValue); + expect(controller.value.text, initValue); + }); + + testWidgets('Block entering text on disabled widget with SearchAnchor.bar', ( + WidgetTester tester, + ) async { + const initValue = 'init'; + final controller = TextEditingController(text: initValue); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + enabled: false, + ), + ), + ), + ), + ); + + const testValue = 'abcdefghi'; + await tester.enterText(find.byType(SearchBar), testValue); + expect(controller.value.text, initValue); + }); + + testWidgets('Disabled SearchBar semantics node still contains value and inputType', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final controller = TextEditingController(text: 'text'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: SearchBar(controller: controller, enabled: false)), + ), + ), + ); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[], + value: 'text', + inputType: SemanticsInputType.search, + ), + ); + semantics.dispose(); + }); + + testWidgets('SearchBar semantics node has search input type', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: SearchBar())), + ), + ); + + expect(semantics, includesNodeWith(inputType: SemanticsInputType.search)); + semantics.dispose(); + }); + + testWidgets('Check SearchBar opacity when disabled', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: SearchBar(enabled: false))), + ), + ); + + final Finder searchBarFinder = find.byType(SearchBar); + expect(searchBarFinder, findsOneWidget); + final Finder opacityFinder = find.descendant( + of: searchBarFinder, + matching: find.byType(Opacity), + ); + expect(opacityFinder, findsOneWidget); + final Opacity opacityWidget = tester.widget<Opacity>(opacityFinder); + expect(opacityWidget.opacity, 0.38); + }); + + testWidgets('Check SearchAnchor opacity when disabled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + enabled: false, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + final Finder searchBarFinder = find.byType(SearchAnchor); + expect(searchBarFinder, findsOneWidget); + final Finder opacityFinder = find.descendant( + of: searchBarFinder, + matching: find.byType(AnimatedOpacity), + ); + expect(opacityFinder, findsOneWidget); + final AnimatedOpacity opacityWidget = tester.widget<AnimatedOpacity>(opacityFinder); + expect(opacityWidget.opacity, 0.38); + }); + + testWidgets('SearchAnchor tap failed when disabled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + enabled: false, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + + final Finder searchBarFinder = find.byType(SearchAnchor); + expect(searchBarFinder, findsOneWidget); + expect(searchBarFinder.hitTestable().tryEvaluate(), false); + }); + + testWidgets('SearchAnchor respects headerHeight', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + isFullScreen: true, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + headerHeight: 32, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + final Finder findHeader = find.descendant( + of: findViewContent(), + matching: find.byType(SearchBar), + ); + expect(tester.getSize(findHeader).height, 32); + }); + + testWidgets('SearchAnchor.bar respects viewHeaderHeight', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchAnchor.bar( + isFullScreen: true, + viewHeaderHeight: 32, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byType(SearchBar)); // Open search view. + await tester.pumpAndSettle(); + final Finder findHeader = find.descendant( + of: findViewContent(), + matching: find.byType(SearchBar), + ); + final RenderBox box = tester.renderObject(findHeader); + expect(box.size.height, 32); + }); + + testWidgets( + 'Tapping outside searchbar should unfocus the searchbar on mobile', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return SearchBar( + controller: controller, + onTap: () { + controller.openView(); + }, + onTapOutside: (PointerDownEvent event) { + focusNode.unfocus(); + }, + onChanged: (_) { + controller.openView(); + }, + autoFocus: true, + focusNode: focusNode, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<ListTile>.generate(5, (int index) { + final item = 'item $index'; + return ListTile(title: Text(item)); + }); + }, + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.tapAt(const Offset(50, 50)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isFalse); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets('The default clear button only shows when text input is not empty ' + 'on the search view', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + + expect(find.widgetWithIcon(IconButton, Icons.close), findsNothing); + await tester.enterText(findTextField(), 'a'); + await tester.pump(); + expect(find.widgetWithIcon(IconButton, Icons.close), findsOneWidget); + await tester.enterText(findTextField(), ''); + await tester.pump(); + expect(find.widgetWithIcon(IconButton, Icons.close), findsNothing); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139880. + testWidgets('suggestionsBuilder with Future is not called twice on layout resize', ( + WidgetTester tester, + ) async { + var suggestionsLoadingCount = 0; + + Future<List<String>> createListData() async { + return List<String>.generate(1000, (int index) { + return 'Hello World - $index'; + }); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[ + FutureBuilder<List<String>>( + future: createListData(), + builder: (BuildContext context, AsyncSnapshot<List<String>> snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + final List<String>? result = snapshot.data; + if (result == null) { + return const LinearProgressIndicator(); + } + suggestionsLoadingCount++; + return SingleChildScrollView( + child: Column( + children: result.map((String text) { + return ListTile(title: Text(text)); + }).toList(), + ), + ); + }, + ), + ]; + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + + // Simulate the keyboard opening resizing the view. + tester.view.viewInsets = const FakeViewPadding(bottom: 500.0); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + expect(suggestionsLoadingCount, 1); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139880. + testWidgets('suggestionsBuilder is not called when the search value does not change', ( + WidgetTester tester, + ) async { + var suggestionsBuilderCalledCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + suggestionsBuilderCalledCount++; + return <Widget>[]; + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + + // Simulate the keyboard opening resizing the view. + tester.view.viewInsets = const FakeViewPadding(bottom: 500.0); + addTearDown(tester.view.reset); + // Show the keyboard. + await tester.showKeyboard(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(suggestionsBuilderCalledCount, 2); + + // Remove the viewInset, as if the keyboard were hidden. + tester.view.resetViewInsets(); + // Hide the keyboard. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(suggestionsBuilderCalledCount, 2); + }); + + testWidgets('Suggestions gets refreshed after long API call', (WidgetTester tester) async { + Timer? debounceTimer; + const apiCallDuration = Duration(seconds: 1); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) async { + final completer = Completer<List<String>>(); + debounceTimer?.cancel(); + debounceTimer = Timer(apiCallDuration, () { + completer.complete(List<String>.generate(10, (int index) => 'Item - $index')); + }); + final List<String> options = await completer.future; + + final suggestions = List<Widget>.generate(options.length, (int index) { + final String item = options[index]; + return ListTile(title: Text(item)); + }); + return suggestions; + }, + ), + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + + // Simulate the keyboard opening resizing the view. + tester.view.viewInsets = const FakeViewPadding(bottom: 500.0); + addTearDown(tester.view.reset); + + // Show the keyboard. + await tester.showKeyboard(find.byType(TextField)); + await tester.pumpAndSettle(apiCallDuration); + + expect(find.text('Item - 1'), findsOneWidget); + }); + + testWidgets('SearchBar.scrollPadding is passed through to EditableText', ( + WidgetTester tester, + ) async { + const EdgeInsets scrollPadding = EdgeInsets.zero; + await tester.pumpWidget( + const MaterialApp( + home: Material(child: SearchBar(scrollPadding: scrollPadding)), + ), + ); + + expect(find.byType(EditableText), findsOneWidget); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.scrollPadding, scrollPadding); + }); + + testWidgets('SearchAnchor.bar.scrollPadding is passed through to EditableText', ( + WidgetTester tester, + ) async { + const EdgeInsets scrollPadding = EdgeInsets.zero; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + scrollPadding: scrollPadding, + ), + ), + ), + ); + + expect(find.byType(EditableText), findsOneWidget); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.scrollPadding, scrollPadding); + }); + + group('contextMenuBuilder', () { + setUp(() async { + if (!kIsWeb) { + return; + } + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.contextMenu, + (MethodCall call) { + // Just complete successfully, so that BrowserContextMenu thinks that + // the engine successfully received its call. + return Future<void>.value(); + }, + ); + await BrowserContextMenu.disableContextMenu(); + }); + + tearDown(() async { + if (!kIsWeb) { + return; + } + await BrowserContextMenu.enableContextMenu(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.contextMenu, + null, + ); + }); + + testWidgets('SearchAnchor.bar.contextMenuBuilder is passed through to EditableText', ( + WidgetTester tester, + ) async { + Widget contextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + return const Placeholder(); + } + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + contextMenuBuilder: contextMenuBuilder, + ), + ), + ), + ); + + expect(find.byType(EditableText), findsOneWidget); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.contextMenuBuilder, contextMenuBuilder); + + expect(find.byType(Placeholder), findsNothing); + + await tester.tap(find.byType(SearchBar), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + + expect(find.byType(Placeholder), findsOneWidget); + }); + + testWidgets( + 'iOS uses the system context menu by default if supported', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + final controller = TextEditingController(text: 'one two three'); + addTearDown(controller.dispose); + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ), + ); + + // No context menu shown. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + }); + + testWidgets('SearchAnchor does not dispose external SearchController', ( + WidgetTester tester, + ) async { + final controller = SearchController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return IconButton( + onPressed: () async { + controller.openView(); + }, + icon: const Icon(Icons.search), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await tester.pumpWidget(const MaterialApp(home: Material(child: Text('disposed')))); + expect(tester.takeException(), isNull); + ChangeNotifier.debugAssertNotDisposed(controller); + }); + + testWidgets('SearchAnchor gracefully closes its search view when disposed', ( + WidgetTester tester, + ) async { + var disposed = false; + late StateSetter setState; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + if (disposed) { + return const Text('disposed'); + } + return SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + onPressed: () async { + controller.openView(); + }, + icon: const Icon(Icons.search), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[const Text('suggestion')]; + }, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + setState(() { + disposed = true; + }); + await tester.pump(); + // The search menu starts to close but is not disposed yet. + final EditableText editableText = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableText.controller; + ChangeNotifier.debugAssertNotDisposed(controller); + + await tester.pumpAndSettle(); + // The search menu and the internal search controller are now disposed. + expect(tester.takeException(), isNull); + expect(find.byType(TextField), findsNothing); + FlutterError? error; + try { + ChangeNotifier.debugAssertNotDisposed(controller); + } on FlutterError catch (e) { + error = e; + } + expect(error, isNotNull); + expect(error, isFlutterError); + expect( + error!.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' A SearchController was used after being disposed.\n' + ' Once you have called dispose() on a SearchController, it can no\n' + ' longer be used.\n', + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/155180. + testWidgets('disposing SearchAnchor during search view exit animation does not crash', ( + WidgetTester tester, + ) async { + final key = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: key, + home: Material( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return IconButton( + onPressed: () async { + controller.openView(); + }, + icon: const Icon(Icons.search), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[const Text('suggestion')]; + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + key.currentState!.pop(); + await tester.pump(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: key, + home: const Material(child: Text('disposed')), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('SearchAnchor viewOnClose function test', (WidgetTester tester) async { + var name = 'silva'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + viewOnClose: () { + name = 'Pedro'; + }, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<Widget>.generate(5, (int index) { + final item = 'item $index'; + return ListTile( + leading: const Icon(Icons.history), + title: Text(item), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ); + }); + }, + ), + ), + ), + ), + ); + + // Open search view + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + expect(name, 'silva'); + + // Pop search view route + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + expect(name, 'Pedro'); + + // No exception. + }); + + testWidgets('SearchAnchor.bar viewOnClose function test', (WidgetTester tester) async { + var name = 'silva'; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + onClose: () { + name = 'Pedro'; + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[ + ListTile( + title: const Text('item 0'), + onTap: () { + controller.closeView('item 0'); + }, + ), + ]; + }, + ), + ), + ), + ); + + // Open search view + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + expect(name, 'silva'); + + // Pop search view route + await tester.tap(find.backButton()); + await tester.pumpAndSettle(); + expect(name, 'Pedro'); + + // No exception. + }); + + testWidgets('SearchAnchor viewOnOpen is called when the search view is opened', ( + WidgetTester tester, + ) async { + var searchViewState = 'Idle'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + viewOnClose: () { + searchViewState = 'Closed'; + }, + viewOnOpen: () { + searchViewState = 'Opened'; + }, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<Widget>.generate(5, (int index) { + final item = 'item $index'; + return ListTile( + leading: const Icon(Icons.history), + title: Text(item), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ); + }); + }, + ), + ), + ), + ), + ); + + expect(find.byIcon(Icons.search), findsOneWidget); + // Open search view. + await tester.tap(find.byIcon(Icons.search)); + await tester.pump(); + expect(searchViewState, 'Opened'); + + // Pop search view route. + await tester.tap(find.backButton()); + await tester.pump(); + expect(searchViewState, 'Closed'); + }); + + testWidgets('SearchAnchor.bar onOpen is called when the search view is opened', ( + WidgetTester tester, + ) async { + var searchViewState = 'Idle'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor.bar( + onClose: () { + searchViewState = 'Closed'; + }, + onOpen: () { + searchViewState = 'Opened'; + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<Widget>.generate(5, (int index) { + final item = 'item $index'; + return ListTile( + leading: const Icon(Icons.history), + title: Text(item), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ); + }); + }, + ), + ), + ), + ), + ); + + expect(find.byIcon(Icons.search), findsOneWidget); + // Open search view. + await tester.tap(find.byIcon(Icons.search)); + await tester.pump(); + expect(searchViewState, 'Opened'); + + // Pop search view route. + await tester.tap(find.backButton()); + await tester.pump(); + expect(searchViewState, 'Closed'); + }); + + testWidgets( + 'The last element of the suggestion list should be visible when scrolling to the end of list', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List<Widget>.generate(30, (int index) { + return ListTile( + titleAlignment: ListTileTitleAlignment.center, + title: Text('Item $index'), + ); + }); + }, + ), + ), + ); + + // Open search view. + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + const fakeKeyboardHeight = 500.0; + final double physicalBottomPadding = fakeKeyboardHeight * tester.view.devicePixelRatio; + + // Simulate the keyboard opening resizing the view. + tester.view.viewInsets = FakeViewPadding(bottom: physicalBottomPadding); + addTearDown(tester.view.reset); + + // Scroll down to the end of the list. + expect(find.byType(ListView), findsOne); + await tester.fling(find.byType(ListView), const Offset(0, -5000), 5000); + await tester.pumpAndSettle(); + + // The last item should not be hidden by the keyboard. + final double lastItemBottom = tester.getRect(find.text('Item 29')).bottom; + final double fakeKeyboardTop = + tester.getSize(find.byType(MaterialApp)).height - fakeKeyboardHeight; + expect(lastItemBottom, lessThanOrEqualTo(fakeKeyboardTop)); + }, + ); + + testWidgets( + 'readOnly disallows SystemContextMenu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170521. + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + final controller = TextEditingController(text: 'abcdefghijklmnopqr'); + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + controller.dispose(); + }); + + var readOnly = true; + late StateSetter setState; + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return SearchBar(controller: controller, readOnly: readOnly); + }, + ), + ), + ), + ), + ); + + final Duration waitDuration = SelectionOverlay.fadeDuration > kDoubleTapTimeout + ? SelectionOverlay.fadeDuration + : kDoubleTapTimeout; + + // Double tap to select the text. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // No error as in https://github.com/flutter/flutter/issues/170521. + + // The Flutter-drawn context menu is shown. The SystemContextMenu is not + // shown because readOnly is true. + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + + // Turn off readOnly and hide the context menu. + setState(() { + readOnly = false; + }); + await tester.tap(find.text('Copy')); + await tester.pump(waitDuration); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to show the context menu again. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // Now iOS is showing the SystemContextMenu while others continue to show + // the Flutter-drawn context menu. + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + expect(find.byType(SystemContextMenu), findsOneWidget); + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + } + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets('smartDashesType and smartQuotesType are properly forwarded to inner text fields', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + ), + ), + ), + ); + + // Check anchor's text field. + final TextField anchorTextField = tester.widget(find.byType(TextField)); + expect(anchorTextField.smartDashesType, SmartDashesType.disabled); + expect(anchorTextField.smartQuotesType, SmartQuotesType.disabled); + + // Open view and check view's text field. + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + + final Finder viewTextFieldFinder = find.descendant( + of: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_ViewContent', + ), + matching: find.byType(TextField), + ); + expect(viewTextFieldFinder, findsOneWidget); + final TextField viewTextField = tester.widget(viewTextFieldFinder); + expect(viewTextField.smartDashesType, SmartDashesType.disabled); + expect(viewTextField.smartQuotesType, SmartQuotesType.disabled); + }); + + testWidgets('SearchAnchor does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = SearchController(); + addTearDown(controller.dispose); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SearchAnchor( + searchController: controller, + builder: (_, _) => const Text('X'), + suggestionsBuilder: (_, _) => <Widget>[const Text('Y')], + ), + ), + ), + ); + expect(tester.getSize(find.byType(SearchAnchor)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + expect(find.byType(Text), findsWidgets); + }); + + testWidgets('SearchBar does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'X'); + addTearDown(controller.dispose); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Center(child: SearchBar(controller: controller)), + ), + ); + expect(tester.getSize(find.byType(SearchBar)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + expect(find.text('X'), findsOne); + }); +} + +Future<void> checkSearchBarDefaults( + WidgetTester tester, + ColorScheme colorScheme, + Material material, +) async { + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.surfaceContainerHigh); + expect(material.elevation, 6.0); + expect(material.shadowColor, colorScheme.shadow); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + + final Text helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, colorScheme.onSurfaceVariant); + expect(helperText.style?.fontSize, 16.0); + expect(helperText.style?.fontFamily, 'Roboto'); + expect(helperText.style?.fontWeight, FontWeight.w400); + + const input = 'entered text'; + await tester.enterText(find.byType(SearchBar), input); + final EditableText inputText = tester.widget(find.text(input)); + expect(inputText.style.color, colorScheme.onSurface); + expect(inputText.style.fontSize, 16.0); + expect(helperText.style?.fontFamily, 'Roboto'); + expect(inputText.style.fontWeight, FontWeight.w400); +} + +Finder findTextField() { + return find.descendant(of: find.byType(SearchBar), matching: find.byType(TextField)); +} + +TextStyle? _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + +const Color pressedColor = Colors.red; +const Color hoveredColor = Colors.orange; +const Color focusedColor = Colors.yellow; +const Color defaultColor = Colors.green; + +Color _getColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoveredColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; +} + +final ThemeData theme = ThemeData(); +final TextStyle? pressedStyle = theme.textTheme.bodyLarge?.copyWith(color: pressedColor); +final TextStyle? hoveredStyle = theme.textTheme.bodyLarge?.copyWith(color: hoveredColor); +final TextStyle? focusedStyle = theme.textTheme.bodyLarge?.copyWith(color: focusedColor); + +TextStyle? _getTextStyle(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedStyle; + } + if (states.contains(WidgetState.hovered)) { + return hoveredStyle; + } + if (states.contains(WidgetState.focused)) { + return focusedStyle; + } + return null; +} + +Future<TestGesture> _pointGestureToSearchBar(WidgetTester tester) async { + final Offset center = tester.getCenter(find.byType(SearchBar)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + + // On hovered. + await gesture.addPointer(); + await gesture.moveTo(center); + return gesture; +} + +Finder findViewContent() { + return find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_ViewContent'; + }); +} + +Material getSearchViewMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findViewContent(), matching: find.byType(Material)).first, + ); +} diff --git a/packages/material_ui/test/material/search_bar_theme_test.dart b/packages/material_ui/test/material/search_bar_theme_test.dart new file mode 100644 index 000000000000..ba7c9215281d --- /dev/null +++ b/packages/material_ui/test/material/search_bar_theme_test.dart @@ -0,0 +1,321 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SearchBarThemeData copyWith, ==, hashCode basics', () { + expect(const SearchBarThemeData(), const SearchBarThemeData().copyWith()); + expect(const SearchBarThemeData().hashCode, const SearchBarThemeData().copyWith().hashCode); + }); + + test('SearchBarThemeData lerp special cases', () { + expect(SearchBarThemeData.lerp(null, null, 0), null); + const data = SearchBarThemeData(); + expect(identical(SearchBarThemeData.lerp(data, data, 0.5), data), true); + }); + + test('SearchBarThemeData defaults', () { + const themeData = SearchBarThemeData(); + expect(themeData.elevation, null); + expect(themeData.backgroundColor, null); + expect(themeData.shadowColor, null); + expect(themeData.surfaceTintColor, null); + expect(themeData.overlayColor, null); + expect(themeData.side, null); + expect(themeData.shape, null); + expect(themeData.padding, null); + expect(themeData.textStyle, null); + expect(themeData.hintStyle, null); + expect(themeData.constraints, null); + expect(themeData.textCapitalization, null); + + const theme = SearchBarTheme(data: SearchBarThemeData(), child: SizedBox()); + expect(theme.data.elevation, null); + expect(theme.data.backgroundColor, null); + expect(theme.data.shadowColor, null); + expect(theme.data.surfaceTintColor, null); + expect(theme.data.overlayColor, null); + expect(theme.data.side, null); + expect(theme.data.shape, null); + expect(theme.data.padding, null); + expect(theme.data.textStyle, null); + expect(theme.data.hintStyle, null); + expect(theme.data.constraints, null); + expect(theme.data.textCapitalization, null); + }); + + testWidgets('Default SearchBarThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SearchBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('SearchBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SearchBarThemeData( + elevation: MaterialStatePropertyAll<double>(3.0), + backgroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff1)), + shadowColor: MaterialStatePropertyAll<Color>(Color(0xfffffff2)), + surfaceTintColor: MaterialStatePropertyAll<Color>(Color(0xfffffff3)), + overlayColor: MaterialStatePropertyAll<Color>(Color(0xfffffff4)), + side: MaterialStatePropertyAll<BorderSide>(BorderSide(width: 2.0, color: Color(0xfffffff5))), + shape: MaterialStatePropertyAll<OutlinedBorder>(StadiumBorder()), + padding: MaterialStatePropertyAll<EdgeInsets>(EdgeInsets.all(16.0)), + textStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 24.0)), + hintStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 16.0)), + constraints: BoxConstraints(minWidth: 350, maxWidth: 850), + textCapitalization: TextCapitalization.characters, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'elevation: WidgetStatePropertyAll(3.0)'); + expect(description[1], 'backgroundColor: WidgetStatePropertyAll(${const Color(0xfffffff1)})'); + expect(description[2], 'shadowColor: WidgetStatePropertyAll(${const Color(0xfffffff2)})'); + expect(description[3], 'surfaceTintColor: WidgetStatePropertyAll(${const Color(0xfffffff3)})'); + expect(description[4], 'overlayColor: WidgetStatePropertyAll(${const Color(0xfffffff4)})'); + expect( + description[5], + 'side: WidgetStatePropertyAll(BorderSide(color: ${const Color(0xfffffff5)}, width: 2.0))', + ); + expect( + description[6], + 'shape: WidgetStatePropertyAll(StadiumBorder(BorderSide(width: 0.0, style: none)))', + ); + expect(description[7], 'padding: WidgetStatePropertyAll(EdgeInsets.all(16.0))'); + expect( + description[8], + 'textStyle: WidgetStatePropertyAll(TextStyle(inherit: true, size: 24.0))', + ); + expect( + description[9], + 'hintStyle: WidgetStatePropertyAll(TextStyle(inherit: true, size: 16.0))', + ); + expect(description[10], 'constraints: BoxConstraints(350.0<=w<=850.0, 0.0<=h<=Infinity)'); + expect(description[11], 'textCapitalization: TextCapitalization.characters'); + }); + + group('[Theme, SearchBarTheme, SearchBar properties overrides]', () { + const elevationValue = 5.0; + const backgroundColorValue = Color(0xff000001); + const shadowColorValue = Color(0xff000001); + const surfaceTintColorValue = Color(0xff000001); + const overlayColorValue = Color(0xff000001); + const sideValue = BorderSide(color: Color(0xff000004), width: 2.0); + const OutlinedBorder shapeValue = RoundedRectangleBorder( + side: sideValue, + borderRadius: BorderRadius.all(Radius.circular(2.0)), + ); + const paddingValue = EdgeInsets.symmetric(horizontal: 16.0); + const textStyleValue = TextStyle(color: Color(0xff000005), fontSize: 20.0); + const hintStyleValue = TextStyle(color: Color(0xff000006), fontSize: 18.0); + + const WidgetStateProperty<double?> elevation = MaterialStatePropertyAll<double>(elevationValue); + const WidgetStateProperty<Color?> backgroundColor = MaterialStatePropertyAll<Color>( + backgroundColorValue, + ); + const WidgetStateProperty<Color?> shadowColor = MaterialStatePropertyAll<Color>( + shadowColorValue, + ); + const WidgetStateProperty<Color?> surfaceTintColor = MaterialStatePropertyAll<Color>( + surfaceTintColorValue, + ); + const WidgetStateProperty<Color?> overlayColor = MaterialStatePropertyAll<Color>( + overlayColorValue, + ); + const WidgetStateProperty<BorderSide?> side = MaterialStatePropertyAll<BorderSide>(sideValue); + const WidgetStateProperty<OutlinedBorder?> shape = MaterialStatePropertyAll<OutlinedBorder>( + shapeValue, + ); + const WidgetStateProperty<EdgeInsetsGeometry?> padding = MaterialStatePropertyAll<EdgeInsets>( + paddingValue, + ); + const WidgetStateProperty<TextStyle?> textStyle = MaterialStatePropertyAll<TextStyle>( + textStyleValue, + ); + const WidgetStateProperty<TextStyle?> hintStyle = MaterialStatePropertyAll<TextStyle>( + hintStyleValue, + ); + const constraints = BoxConstraints(minWidth: 250.0, maxWidth: 300.0, minHeight: 80.0); + const TextCapitalization textCapitalization = TextCapitalization.words; + + const searchBarTheme = SearchBarThemeData( + elevation: elevation, + backgroundColor: backgroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + overlayColor: overlayColor, + side: side, + shape: shape, + padding: padding, + textStyle: textStyle, + hintStyle: hintStyle, + constraints: constraints, + textCapitalization: textCapitalization, + ); + + Widget buildFrame({ + bool useSearchBarProperties = false, + SearchBarThemeData? searchBarThemeData, + SearchBarThemeData? overallTheme, + }) { + final Widget child = Builder( + builder: (BuildContext context) { + if (!useSearchBarProperties) { + return const SearchBar( + hintText: 'hint', + leading: Icon(Icons.search), + trailing: <Widget>[Icon(Icons.menu)], + ); + } + return const SearchBar( + hintText: 'hint', + leading: Icon(Icons.search), + trailing: <Widget>[Icon(Icons.menu)], + elevation: elevation, + backgroundColor: backgroundColor, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + overlayColor: overlayColor, + side: side, + shape: shape, + padding: padding, + textStyle: textStyle, + hintStyle: hintStyle, + constraints: constraints, + textCapitalization: textCapitalization, + ); + }, + ); + return MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(searchBarTheme: overallTheme), + home: Scaffold( + body: Center( + // If the SearchBarThemeData widget is present, it's used + // instead of the Theme's ThemeData.searchBarTheme. + child: searchBarThemeData == null + ? child + : SearchBarTheme(data: searchBarThemeData, child: child), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(SearchBar), + matching: find.byType(InkWell), + ); + + const hovered = <WidgetState>{WidgetState.hovered}; + const focused = <WidgetState>{WidgetState.focused}; + const pressed = <WidgetState>{WidgetState.pressed}; + + Future<void> checkSearchBar(WidgetTester tester) async { + final Material material = tester.widget<Material>(findMaterial); + final InkWell inkWell = tester.widget<InkWell>(findInkWell); + expect(material.elevation, elevationValue); + expect(material.color, backgroundColorValue); + expect(material.shadowColor, shadowColorValue); + expect(material.surfaceTintColor, surfaceTintColorValue); + expect(material.shape, shapeValue); + expect(inkWell.overlayColor!.resolve(hovered), overlayColor.resolve(hovered)); + expect(inkWell.overlayColor!.resolve(focused), overlayColor.resolve(focused)); + expect(inkWell.overlayColor!.resolve(pressed), overlayColor.resolve(pressed)); + expect(inkWell.customBorder, shapeValue); + + expect(tester.getSize(find.byType(SearchBar)), const Size(300.0, 80.0)); + + final Text hintText = tester.widget(find.text('hint')); + expect(hintText.style?.color, hintStyleValue.color); + expect(hintText.style?.fontSize, hintStyleValue.fontSize); + + await tester.enterText(find.byType(TextField), 'input'); + final EditableText inputText = tester.widget(find.text('input')); + expect(inputText.style.color, textStyleValue.color); + expect(inputText.style.fontSize, textStyleValue.fontSize); + expect(inputText.textCapitalization, textCapitalization); + + final Rect barRect = tester.getRect(find.byType(SearchBar)); + final Rect leadingRect = tester.getRect(find.byIcon(Icons.search)); + final Rect textFieldRect = tester.getRect(find.byType(TextField)); + final Rect trailingRect = tester.getRect(find.byIcon(Icons.menu)); + + expect(barRect.left, leadingRect.left - 16.0); + expect(leadingRect.right, textFieldRect.left - 16.0); + expect(textFieldRect.right, trailingRect.left - 16.0); + expect(trailingRect.right, barRect.right - 16.0); + } + + testWidgets('SearchBar properties overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(useSearchBarProperties: true)); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchBar(tester); + }); + + testWidgets('SearchBar theme data overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(searchBarThemeData: searchBarTheme)); + await tester.pumpAndSettle(); + await checkSearchBar(tester); + }); + + testWidgets('Overall Theme SearchBar theme overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallTheme: searchBarTheme)); + await tester.pumpAndSettle(); + await checkSearchBar(tester); + }); + + // Same as the previous tests with empty SearchBarThemeData's instead of null. + + testWidgets('SearchBar properties overrides defaults, empty theme and overall theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildFrame( + useSearchBarProperties: true, + searchBarThemeData: const SearchBarThemeData(), + overallTheme: const SearchBarThemeData(), + ), + ); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchBar(tester); + }); + + testWidgets('SearchBar theme overrides defaults and overall theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildFrame(searchBarThemeData: searchBarTheme, overallTheme: const SearchBarThemeData()), + ); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchBar(tester); + }); + + testWidgets('Overall Theme SearchBar theme overrides defaults and null theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(buildFrame(overallTheme: searchBarTheme)); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchBar(tester); + }); + }); +} diff --git a/packages/material_ui/test/material/search_test.dart b/packages/material_ui/test/material/search_test.dart new file mode 100644 index 000000000000..245ecc44a1ee --- /dev/null +++ b/packages/material_ui/test/material/search_test.dart @@ -0,0 +1,1572 @@ +// 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:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + + setUp(() async { + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + testWidgets('Changing query moves cursor to the end of query', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + delegate.query = 'Foo'; + + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + + expect( + textField.controller!.selection, + TextSelection(baseOffset: delegate.query.length, extentOffset: delegate.query.length), + ); + + delegate.query = ''; + expect(textField.controller!.selection, const TextSelection.collapsed(offset: 0)); + }); + + testWidgets('Can open and close search', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + final selectedResults = <String>[]; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + // We are on the homepage + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + + // Open search + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(selectedResults, hasLength(0)); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isTrue); + + // Close search + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + expect(selectedResults, <String>['Result']); + }); + + testWidgets('Can close search with system back button to return null', ( + WidgetTester tester, + ) async { + // regression test for https://github.com/flutter/flutter/issues/18145 + + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + final selectedResults = <String?>[]; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + // We are on the homepage + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + + // Open search + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Bottom'), findsOneWidget); + + // Simulate system back button + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/navigation', + message, + (_) {}, + ); + await tester.pumpAndSettle(); + + expect(selectedResults, <String?>[null]); + + // We are on the homepage again + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + + // Open search again + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + }); + + testWidgets('Can close search with escape button and return null', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + final selectedResults = <String?>[]; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + // We are on the homepage. + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + + // Open search. + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Bottom'), findsOneWidget); + + // Simulate escape button. + await simulateKeyDownEvent(LogicalKeyboardKey.escape, platform: 'windows'); + await tester.pumpAndSettle(); + + expect(selectedResults, <String?>[null]); + + // We are on the homepage again. + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + + // Open search again. + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + }); + + testWidgets('Hint text color overridden', (WidgetTester tester) async { + const searchHintText = 'Enter search terms'; + final delegate = _TestSearchDelegate(searchHint: searchHintText); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final Text hintText = tester.widget(find.text(searchHintText)); + expect(hintText.style!.color, _TestSearchDelegate.hintTextColor); + }); + + testWidgets('Requests suggestions', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(delegate.query, ''); + expect(delegate.queriesForSuggestions.last, ''); + expect(delegate.queriesForResults, hasLength(0)); + + // Type W o w into search field + delegate.queriesForSuggestions.clear(); + await tester.enterText(find.byType(TextField), 'W'); + await tester.pumpAndSettle(); + expect(delegate.query, 'W'); + await tester.enterText(find.byType(TextField), 'Wo'); + await tester.pumpAndSettle(); + expect(delegate.query, 'Wo'); + await tester.enterText(find.byType(TextField), 'Wow'); + await tester.pumpAndSettle(); + expect(delegate.query, 'Wow'); + + expect(delegate.queriesForSuggestions, <String>['W', 'Wo', 'Wow']); + expect(delegate.queriesForResults, hasLength(0)); + }); + + testWidgets('Shows Results and closes search', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + final selectedResults = <String>[]; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Wow'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Suggestions')); + await tester.pumpAndSettle(); + + // We are on the results page for Wow + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Results'), findsOneWidget); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isFalse); + expect(delegate.queriesForResults, <String>['Wow']); + + // Close search + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Results'), findsNothing); + expect(selectedResults, <String>['Result']); + }); + + testWidgets('Can switch between results and suggestions', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + // Showing suggestions + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Results'), findsNothing); + + // Typing query Wow + delegate.queriesForSuggestions.clear(); + await tester.enterText(find.byType(TextField), 'Wow'); + await tester.pumpAndSettle(); + + expect(delegate.query, 'Wow'); + expect(delegate.queriesForSuggestions, <String>['Wow']); + expect(delegate.queriesForResults, hasLength(0)); + + await tester.tap(find.text('Suggestions')); + await tester.pumpAndSettle(); + + // Showing Results + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Results'), findsOneWidget); + + expect(delegate.query, 'Wow'); + expect(delegate.queriesForSuggestions, <String>['Wow']); + expect(delegate.queriesForResults, <String>['Wow']); + + TextField textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isFalse); + + // Tapping search field to go back to suggestions + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isTrue); + + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Results'), findsNothing); + expect(delegate.queriesForSuggestions, <String>['Wow', 'Wow']); + expect(delegate.queriesForResults, <String>['Wow']); + + await tester.enterText(find.byType(TextField), 'Foo'); + await tester.pumpAndSettle(); + + expect(delegate.query, 'Foo'); + expect(delegate.queriesForSuggestions, <String>['Wow', 'Wow', 'Foo']); + expect(delegate.queriesForResults, <String>['Wow']); + + // Go to results again + await tester.tap(find.text('Suggestions')); + await tester.pumpAndSettle(); + + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Results'), findsOneWidget); + + expect(delegate.query, 'Foo'); + expect(delegate.queriesForSuggestions, <String>['Wow', 'Wow', 'Foo']); + expect(delegate.queriesForResults, <String>['Wow', 'Foo']); + + textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isFalse); + }); + + testWidgets('Fresh search always starts with empty query', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(delegate.query, ''); + + delegate.query = 'Foo'; + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(delegate.query, ''); + }); + + testWidgets('Initial queries are honored', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + expect(delegate.query, ''); + + await tester.pumpWidget( + TestHomePage(delegate: delegate, passInInitialQuery: true, initialQuery: 'Foo'), + ); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(delegate.query, 'Foo'); + }); + + testWidgets('Initial query null re-used previous query', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + delegate.query = 'Foo'; + + await tester.pumpWidget(TestHomePage(delegate: delegate, passInInitialQuery: true)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(delegate.query, 'Foo'); + }); + + testWidgets('Changing query shows up in search field', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate, passInInitialQuery: true)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + delegate.query = 'Foo'; + + expect(find.text('Foo'), findsOneWidget); + expect(find.text('Bar'), findsNothing); + + delegate.query = 'Bar'; + + expect(find.text('Foo'), findsNothing); + expect(find.text('Bar'), findsOneWidget); + }); + + testWidgets('transitionAnimation runs while search fades in/out', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate, passInInitialQuery: true)); + + // runs while search fades in + expect(delegate.transitionAnimation.status, AnimationStatus.dismissed); + await tester.tap(find.byTooltip('Search')); + expect(delegate.transitionAnimation.status, AnimationStatus.forward); + await tester.pumpAndSettle(); + expect(delegate.transitionAnimation.status, AnimationStatus.completed); + + // does not run while switching to results + await tester.tap(find.text('Suggestions')); + expect(delegate.transitionAnimation.status, AnimationStatus.completed); + await tester.pumpAndSettle(); + expect(delegate.transitionAnimation.status, AnimationStatus.completed); + + // runs while search fades out + await tester.tap(find.byTooltip('Back')); + expect(delegate.transitionAnimation.status, AnimationStatus.reverse); + await tester.pumpAndSettle(); + expect(delegate.transitionAnimation.status, AnimationStatus.dismissed); + }); + + testWidgets('Closing nested search returns to search', (WidgetTester tester) async { + final nestedSearchResults = <String?>[]; + final nestedSearchDelegate = _TestSearchDelegate( + suggestions: 'Nested Suggestions', + result: 'Nested Result', + ); + addTearDown(nestedSearchDelegate.dispose); + + final selectedResults = <String>[]; + final delegate = _TestSearchDelegate( + actions: <Widget>[ + Builder( + builder: (BuildContext context) { + return IconButton( + tooltip: 'Nested Search', + icon: const Icon(Icons.search), + onPressed: () async { + final String? result = await showSearch( + context: context, + delegate: nestedSearchDelegate, + ); + nestedSearchResults.add(result); + }, + ); + }, + ), + ], + ); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + expect(find.text('HomeBody'), findsOneWidget); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Nested Suggestions'), findsNothing); + + await tester.tap(find.byTooltip('Nested Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Nested Suggestions'), findsOneWidget); + + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(nestedSearchResults, <String>['Nested Result']); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Nested Suggestions'), findsNothing); + + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Nested Suggestions'), findsNothing); + expect(selectedResults, <String>['Result']); + }); + + testWidgets('Closing search with nested search shown goes back to underlying route', ( + WidgetTester tester, + ) async { + late _TestSearchDelegate delegate; + addTearDown(() => delegate.dispose()); + final nestedSearchResults = <String?>[]; + final nestedSearchDelegate = _TestSearchDelegate( + suggestions: 'Nested Suggestions', + result: 'Nested Result', + actions: <Widget>[ + Builder( + builder: (BuildContext context) { + return IconButton( + tooltip: 'Close Search', + icon: const Icon(Icons.close), + onPressed: () async { + delegate.close(context, 'Result Foo'); + }, + ); + }, + ), + ], + ); + addTearDown(nestedSearchDelegate.dispose); + + final selectedResults = <String>[]; + delegate = _TestSearchDelegate( + actions: <Widget>[ + Builder( + builder: (BuildContext context) { + return IconButton( + tooltip: 'Nested Search', + icon: const Icon(Icons.search), + onPressed: () async { + final String? result = await showSearch( + context: context, + delegate: nestedSearchDelegate, + ); + nestedSearchResults.add(result); + }, + ); + }, + ), + ], + ); + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + expect(find.text('HomeBody'), findsOneWidget); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(find.text('Nested Suggestions'), findsNothing); + + await tester.tap(find.byTooltip('Nested Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Nested Suggestions'), findsOneWidget); + + await tester.tap(find.byTooltip('Close Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('Nested Suggestions'), findsNothing); + expect(nestedSearchResults, <String?>[null]); + expect(selectedResults, <String>['Result Foo']); + }); + + testWidgets('Custom searchFieldLabel value', (WidgetTester tester) async { + const searchHint = 'custom search hint'; + final String defaultSearchHint = const DefaultMaterialLocalizations().searchFieldLabel; + + final delegate = _TestSearchDelegate(searchHint: searchHint); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text(searchHint), findsOneWidget); + expect(find.text(defaultSearchHint), findsNothing); + }); + + testWidgets('Default searchFieldLabel is used when it is set to null', ( + WidgetTester tester, + ) async { + final String searchHint = const DefaultMaterialLocalizations().searchFieldLabel; + + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text(searchHint), findsOneWidget); + }); + + testWidgets('Custom searchFieldStyle value', (WidgetTester tester) async { + const searchHintText = 'Enter search terms'; + const searchFieldStyle = TextStyle(color: Colors.red, fontSize: 3); + + final delegate = _TestSearchDelegate( + searchHint: searchHintText, + searchFieldStyle: searchFieldStyle, + ); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final Text hintText = tester.widget(find.text(searchHintText)); + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + + expect(hintText.style?.color, delegate.searchFieldStyle?.color); + expect(hintText.style?.fontSize, delegate.searchFieldStyle?.fontSize); + expect(textField.style?.color, delegate.searchFieldStyle?.color); + expect(textField.style?.fontSize, delegate.searchFieldStyle?.fontSize); + }); + + testWidgets('Default autocorrect and enableSuggestions value', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + + expect(textField.autocorrect, isTrue); + expect(textField.enableSuggestions, isTrue); + }); + + testWidgets('Custom autocorrect value', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(autocorrect: false); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + + expect(textField.autocorrect, isFalse); + }); + + testWidgets('Custom enableSuggestions value', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(enableSuggestions: false); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + + expect(textField.enableSuggestions, isFalse); + }); + + testWidgets('keyboard show search button by default', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + await tester.showKeyboard(find.byType(TextField)); + + expect(tester.testTextInput.setClientArgs!['inputAction'], TextInputAction.search.toString()); + }); + + testWidgets('Custom textInputAction results in keyboard with corresponding button', ( + WidgetTester tester, + ) async { + final delegate = _TestSearchDelegate(textInputAction: TextInputAction.done); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField)); + expect(tester.testTextInput.setClientArgs!['inputAction'], TextInputAction.done.toString()); + }); + + testWidgets('Custom flexibleSpace value', (WidgetTester tester) async { + const Widget flexibleSpace = Text('custom flexibleSpace'); + final delegate = _TestSearchDelegate(flexibleSpace: flexibleSpace); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.byWidget(flexibleSpace), findsOneWidget); + }); + + group('contributes semantics with custom flexibleSpace', () { + const Widget flexibleSpace = Text('FlexibleSpace'); + + TestSemantics buildExpected({required String routeName}) { + final bool isDesktop = + debugDefaultTargetPlatformOverride == TargetPlatform.macOS || + debugDefaultTargetPlatformOverride == TargetPlatform.windows || + debugDefaultTargetPlatformOverride == TargetPlatform.linux; + final bool isCupertino = + debugDefaultTargetPlatformOverride == TargetPlatform.iOS || + debugDefaultTargetPlatformOverride == TargetPlatform.macOS; + final textField = kIsWeb + ? TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + if (!isCupertino) SemanticsFlag.namesRoute, + ], + children: <TestSemantics>[ + TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + if (isDesktop) SemanticsAction.didGainAccessibilityFocus, + if (isDesktop) SemanticsAction.didLoseAccessibilityFocus, + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + label: 'Search', + currentValueLength: 0, + inputType: SemanticsInputType.search, + textDirection: TextDirection.ltr, + textSelection: const TextSelection(baseOffset: 0, extentOffset: 0), + ), + ], + ) + : TestSemantics( + id: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + SemanticsFlag.isFocusable, + SemanticsFlag.isHeader, + if (!isCupertino) SemanticsFlag.namesRoute, + ], + actions: <SemanticsAction>[ + if (isDesktop) SemanticsAction.didGainAccessibilityFocus, + if (isDesktop) SemanticsAction.didLoseAccessibilityFocus, + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + label: 'Search', + currentValueLength: 0, + inputType: SemanticsInputType.search, + textDirection: TextDirection.ltr, + textSelection: const TextSelection(baseOffset: 0, extentOffset: 0), + ); + + return TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: routeName, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 6, + children: <TestSemantics>[ + TestSemantics( + id: 8, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform != TargetPlatform.iOS) + SemanticsAction.focus, + ], + tooltip: 'Back', + textDirection: TextDirection.ltr, + ), + textField, + TestSemantics( + id: 10, + label: 'Bottom', + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics( + id: 11, + label: 'FlexibleSpace', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform != TargetPlatform.iOS) SemanticsAction.focus, + ], + label: 'Suggestions', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ); + } + + testWidgets('includes routeName on Android', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final delegate = _TestSearchDelegate(flexibleSpace: flexibleSpace); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + buildExpected(routeName: 'Search'), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets( + 'does not include routeName', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final delegate = _TestSearchDelegate(flexibleSpace: flexibleSpace); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + buildExpected(routeName: ''), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + }); + + group('contributes semantics', () { + TestSemantics buildExpected({required String routeName}) { + final bool isDesktop = + debugDefaultTargetPlatformOverride == TargetPlatform.macOS || + debugDefaultTargetPlatformOverride == TargetPlatform.windows || + debugDefaultTargetPlatformOverride == TargetPlatform.linux; + final bool isCupertino = + debugDefaultTargetPlatformOverride == TargetPlatform.iOS || + debugDefaultTargetPlatformOverride == TargetPlatform.macOS; + final textField = kIsWeb + ? TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isHeader, + if (!isCupertino) SemanticsFlag.namesRoute, + ], + children: <TestSemantics>[ + TestSemantics( + id: 11, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + if (isDesktop) SemanticsAction.didGainAccessibilityFocus, + if (isDesktop) SemanticsAction.didLoseAccessibilityFocus, + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + label: 'Search', + inputType: SemanticsInputType.search, + currentValueLength: 0, + textDirection: TextDirection.ltr, + textSelection: const TextSelection(baseOffset: 0, extentOffset: 0), + ), + ], + ) + : TestSemantics( + id: 11, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + SemanticsFlag.isFocusable, + SemanticsFlag.isHeader, + if (!isCupertino) SemanticsFlag.namesRoute, + ], + actions: <SemanticsAction>[ + if (isDesktop) SemanticsAction.didGainAccessibilityFocus, + if (isDesktop) SemanticsAction.didLoseAccessibilityFocus, + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + label: 'Search', + inputType: SemanticsInputType.search, + currentValueLength: 0, + textDirection: TextDirection.ltr, + textSelection: const TextSelection(baseOffset: 0, extentOffset: 0), + ); + return TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 7, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], + label: routeName, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 9, + children: <TestSemantics>[ + TestSemantics( + id: 10, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform != TargetPlatform.iOS) + SemanticsAction.focus, + ], + tooltip: 'Back', + textDirection: TextDirection.ltr, + ), + textField, + TestSemantics(id: 14, label: 'Bottom', textDirection: TextDirection.ltr), + ], + ), + TestSemantics( + id: 8, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform != TargetPlatform.iOS) SemanticsAction.focus, + ], + label: 'Suggestions', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ); + } + + testWidgets('includes routeName on Android', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + buildExpected(routeName: 'Search'), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets( + 'does not include routeName', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + buildExpected(routeName: ''), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + }); + + testWidgets('Custom searchFieldDecorationTheme value', (WidgetTester tester) async { + const searchFieldDecorationTheme = InputDecorationTheme( + hintStyle: TextStyle(color: _TestSearchDelegate.hintTextColor), + ); + final delegate = _TestSearchDelegate(searchFieldDecorationTheme: searchFieldDecorationTheme); + addTearDown(() => delegate.dispose()); + + await tester.pumpWidget(TestHomePage(delegate: delegate)); + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final ThemeData textFieldTheme = Theme.of(tester.element(find.byType(TextField))); + expect(textFieldTheme.inputDecorationTheme, searchFieldDecorationTheme.data); + }); + + // Regression test for: https://github.com/flutter/flutter/issues/66781 + testWidgets('text in search bar contrasts background (light mode)', (WidgetTester tester) async { + final themeData = ThemeData(useMaterial3: false); + final delegate = _TestSearchDelegate(defaultAppBarTheme: true); + addTearDown(() => delegate.dispose()); + const query = 'search query'; + + await tester.pumpWidget( + TestHomePage( + delegate: delegate, + passInInitialQuery: true, + initialQuery: query, + themeData: themeData, + ), + ); + + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final Material appBarBackground = tester + .widgetList<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ) + .first; + expect(appBarBackground.color, Colors.white); + + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + expect(textField.style!.color, themeData.textTheme.bodyLarge!.color); + expect(textField.style!.color, isNot(equals(Colors.white))); + }); + + // Regression test for: https://github.com/flutter/flutter/issues/66781 + testWidgets('text in search bar contrasts background (dark mode)', (WidgetTester tester) async { + final themeData = ThemeData.dark(useMaterial3: false); + final delegate = _TestSearchDelegate(defaultAppBarTheme: true); + addTearDown(() => delegate.dispose()); + const query = 'search query'; + + await tester.pumpWidget( + TestHomePage( + delegate: delegate, + passInInitialQuery: true, + initialQuery: query, + themeData: themeData, + ), + ); + + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final Material appBarBackground = tester + .widgetList<Material>( + find.descendant(of: find.byType(AppBar), matching: find.byType(Material)), + ) + .first; + expect(appBarBackground.color, themeData.primaryColor); + + final TextField textField = tester.widget<TextField>(find.byType(TextField)); + expect(textField.style!.color, themeData.textTheme.bodyLarge!.color); + expect(textField.style!.color, isNot(equals(themeData.primaryColor))); + }); + + // Regression test for: https://github.com/flutter/flutter/issues/78144 + testWidgets('`Leading`, `Actions` and `FlexibleSpace` nullable test', ( + WidgetTester tester, + ) async { + // The search delegate page is displayed with no issues + // even with a null return values for [buildLeading], [buildActions] and [flexibleSpace]. + final delegate = _TestEmptySearchDelegate(); + addTearDown(delegate.dispose); + final selectedResults = <String>[]; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + // We are on the homepage. + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + + // Open the search page. + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsNothing); + expect(find.text('HomeTitle'), findsNothing); + expect(find.text('Suggestions'), findsOneWidget); + expect(selectedResults, hasLength(0)); + + final TextField textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode!.hasFocus, isTrue); + + // Close the search page. + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + + expect(find.text('HomeBody'), findsOneWidget); + expect(find.text('HomeTitle'), findsOneWidget); + expect(find.text('Suggestions'), findsNothing); + expect(selectedResults, <String>['Result']); + }); + + testWidgets('Leading width size is 16', (WidgetTester tester) async { + final delegate = _TestSearchDelegate(); + final selectedResults = <String>[]; + delegate.leadingWidth = 16; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + // Open the search page with check leading width smaller than 16. + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(16, 16)); + expect(find.text('Suggestions'), findsOneWidget); + final Finder appBarFinder = find.byType(AppBar); + final AppBar appBar = tester.widget<AppBar>(appBarFinder); + expect(appBar.leadingWidth, 16); + await tester.tapAt(const Offset(8, 16)); + await tester.pumpAndSettle(); + expect(find.text('Suggestions'), findsNothing); + expect(find.text('HomeBody'), findsOneWidget); + }); + + testWidgets('showSearch with useRootNavigator', (WidgetTester tester) async { + final rootObserver = _MyNavigatorObserver(); + final localObserver = _MyNavigatorObserver(); + + final delegate = _TestEmptySearchDelegate(); + addTearDown(delegate.dispose); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[localObserver], + onGenerateRoute: (RouteSettings settings) { + if (settings.name == 'nested') { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + TextButton( + onPressed: () async { + await showSearch( + context: context, + delegate: delegate, + useRootNavigator: true, + ); + }, + child: const Text('showSearchRootNavigator'), + ), + TextButton( + onPressed: () async { + await showSearch(context: context, delegate: delegate); + }, + child: const Text('showSearchLocalNavigator'), + ), + ], + ), + settings: settings, + ); + } + throw UnimplementedError(); + }, + initialRoute: 'nested', + ), + ), + ); + + expect(rootObserver.pushCount, 0); + expect(localObserver.pushCount, 0); + + // showSearch normal and back. + await tester.tap(find.text('showSearchLocalNavigator')); + await tester.pumpAndSettle(); + final Finder backButtonFinder = find.byType(BackButton); + expect(backButtonFinder, findsWidgets); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(rootObserver.pushCount, 0); + expect(localObserver.pushCount, 1); + + // showSearch with rootNavigator. + await tester.tap(find.text('showSearchRootNavigator')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + + // showSearch without back button. + delegate.automaticallyImplyLeading = false; + await tester.tap(find.text('showSearchRootNavigator')); + await tester.pumpAndSettle(); + final Finder appBarFinder = find.byType(AppBar); + final AppBar appBar = tester.widget<AppBar>(appBarFinder); + expect(appBar.automaticallyImplyLeading, false); + expect(find.byTooltip('Back'), findsNothing); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(rootObserver.pushCount, 2); + expect(localObserver.pushCount, 1); + }); + + testWidgets('Query text field shows toolbar initially', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/95588 + + final delegate = _TestSearchDelegate(); + addTearDown(() => delegate.dispose()); + final selectedResults = <String>[]; + + await tester.pumpWidget(TestHomePage(delegate: delegate, results: selectedResults)); + + // Open search. + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField textField = tester.widget<TextField>(textFieldFinder); + expect(textField.controller!.text.length, 0); + + await mockClipboard.handleMethodCall( + const MethodCall('Clipboard.setData', <String, dynamic>{'text': 'pasteablestring'}), + ); + + // Long press shows toolbar. + await tester.longPress(textFieldFinder); + await tester.pump(); + expect(find.text('Paste'), findsOneWidget); + + await tester.tap(find.text('Paste')); + await tester.pump(); + expect(textField.controller!.text.length, 15); + }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('showSearch with maintainState on the route', (WidgetTester tester) async { + final navigationObserver = _MyNavigatorObserver(); + + final delegate = _TestEmptySearchDelegate(); + addTearDown(delegate.dispose); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[navigationObserver], + home: Builder( + builder: (BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + TextButton( + onPressed: () async { + await showSearch(context: context, delegate: delegate); + }, + child: const Text('showSearch'), + ), + TextButton( + onPressed: () async { + await showSearch(context: context, delegate: delegate, maintainState: true); + }, + child: const Text('showSearchWithMaintainState'), + ), + ], + ), + ), + ), + ); + + expect(navigationObserver.pushCount, 0); + expect(navigationObserver.maintainState, false); + + // showSearch normal and back. + await tester.tap(find.text('showSearch')); + await tester.pumpAndSettle(); + final Finder backButtonFinder = find.byType(BackButton); + expect(backButtonFinder, findsWidgets); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(navigationObserver.pushCount, 1); + expect(navigationObserver.maintainState, false); + + // showSearch with maintainState. + await tester.tap(find.text('showSearchWithMaintainState')); + await tester.pumpAndSettle(); + expect(backButtonFinder, findsWidgets); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(navigationObserver.pushCount, 2); + expect(navigationObserver.maintainState, true); + }); +} + +class TestHomePage extends StatelessWidget { + const TestHomePage({ + super.key, + this.results, + required this.delegate, + this.passInInitialQuery = false, + this.initialQuery, + this.themeData, + }); + + final List<String?>? results; + final SearchDelegate<String> delegate; + final bool passInInitialQuery; + final ThemeData? themeData; + final String? initialQuery; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: themeData, + home: Builder( + builder: (BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('HomeTitle'), + actions: <Widget>[ + IconButton( + tooltip: 'Search', + icon: const Icon(Icons.search), + onPressed: () async { + String? selectedResult; + if (passInInitialQuery) { + selectedResult = await showSearch<String>( + context: context, + delegate: delegate, + query: initialQuery, + ); + } else { + selectedResult = await showSearch<String>( + context: context, + delegate: delegate, + ); + } + results?.add(selectedResult); + }, + ), + ], + ), + body: const Text('HomeBody'), + ); + }, + ), + ); + } +} + +class _TestSearchDelegate extends SearchDelegate<String> { + _TestSearchDelegate({ + this.suggestions = 'Suggestions', + this.result = 'Result', + this.actions = const <Widget>[], + this.flexibleSpace, + this.defaultAppBarTheme = false, + super.searchFieldDecorationTheme, + super.searchFieldStyle, + String? searchHint, + super.textInputAction, + super.autocorrect, + super.enableSuggestions, + }) : super(searchFieldLabel: searchHint); + + final bool defaultAppBarTheme; + final String suggestions; + final String result; + final List<Widget> actions; + final Widget? flexibleSpace; + static const Color hintTextColor = Colors.green; + + @override + ThemeData appBarTheme(BuildContext context) { + if (defaultAppBarTheme) { + return super.appBarTheme(context); + } + final ThemeData theme = Theme.of(context); + return theme.copyWith( + inputDecorationTheme: + searchFieldDecorationTheme ?? + InputDecorationThemeData( + hintStyle: searchFieldStyle ?? const TextStyle(color: hintTextColor), + ), + ); + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + tooltip: 'Back', + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, result); + }, + ); + } + + final List<String> queriesForSuggestions = <String>[]; + final List<String> queriesForResults = <String>[]; + + @override + Widget buildSuggestions(BuildContext context) { + queriesForSuggestions.add(query); + return ElevatedButton( + onPressed: () { + showResults(context); + }, + child: Text(suggestions), + ); + } + + @override + Widget buildResults(BuildContext context) { + queriesForResults.add(query); + return const Text('Results'); + } + + @override + List<Widget> buildActions(BuildContext context) { + return actions; + } + + @override + Widget? buildFlexibleSpace(BuildContext context) { + return flexibleSpace; + } + + @override + PreferredSizeWidget buildBottom(BuildContext context) { + return const PreferredSize(preferredSize: Size.fromHeight(56.0), child: Text('Bottom')); + } +} + +class _TestEmptySearchDelegate extends SearchDelegate<String> { + @override + Widget? buildLeading(BuildContext context) => null; + + @override + List<Widget>? buildActions(BuildContext context) => null; + + @override + Widget buildSuggestions(BuildContext context) { + return ElevatedButton( + onPressed: () { + showResults(context); + }, + child: const Text('Suggestions'), + ); + } + + @override + Widget buildResults(BuildContext context) { + return const Text('Results'); + } + + @override + PreferredSizeWidget buildBottom(BuildContext context) { + return PreferredSize( + preferredSize: const Size.fromHeight(56.0), + child: IconButton( + tooltip: 'Close', + icon: const Icon(Icons.arrow_back), + onPressed: () { + close(context, 'Result'); + }, + ), + ); + } +} + +class _MyNavigatorObserver extends NavigatorObserver { + bool maintainState = false; + int pushCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + // don't count the root route + if (<String>['nested', '/'].contains(route.settings.name)) { + return; + } + if (route is PageRoute) { + maintainState = route.maintainState; + } + pushCount++; + } +} diff --git a/packages/material_ui/test/material/search_view_theme_test.dart b/packages/material_ui/test/material/search_view_theme_test.dart new file mode 100644 index 000000000000..540f4c4c5213 --- /dev/null +++ b/packages/material_ui/test/material/search_view_theme_test.dart @@ -0,0 +1,283 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SearchViewThemeData copyWith, ==, hashCode basics', () { + expect(const SearchViewThemeData(), const SearchViewThemeData().copyWith()); + expect(const SearchViewThemeData().hashCode, const SearchViewThemeData().copyWith().hashCode); + }); + + test('SearchViewThemeData lerp special cases', () { + expect(SearchViewThemeData.lerp(null, null, 0), null); + const data = SearchViewThemeData(); + expect(identical(SearchViewThemeData.lerp(data, data, 0.5), data), true); + }); + + test('SearchViewThemeData defaults', () { + const themeData = SearchViewThemeData(); + expect(themeData.backgroundColor, null); + expect(themeData.elevation, null); + expect(themeData.surfaceTintColor, null); + expect(themeData.constraints, null); + expect(themeData.side, null); + expect(themeData.shape, null); + expect(themeData.headerHeight, null); + expect(themeData.headerTextStyle, null); + expect(themeData.headerHintStyle, null); + expect(themeData.padding, null); + expect(themeData.barPadding, null); + expect(themeData.shrinkWrap, null); + expect(themeData.dividerColor, null); + + const theme = SearchViewTheme(data: SearchViewThemeData(), child: SizedBox()); + expect(theme.data.backgroundColor, null); + expect(theme.data.elevation, null); + expect(theme.data.surfaceTintColor, null); + expect(theme.data.constraints, null); + expect(theme.data.side, null); + expect(theme.data.shape, null); + expect(theme.data.headerHeight, null); + expect(theme.data.headerTextStyle, null); + expect(theme.data.headerHintStyle, null); + expect(themeData.padding, null); + expect(themeData.barPadding, null); + expect(themeData.shrinkWrap, null); + expect(theme.data.dividerColor, null); + }); + + testWidgets('Default SearchViewThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SearchViewThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('SearchViewThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SearchViewThemeData( + backgroundColor: Color(0xfffffff1), + elevation: 3.5, + surfaceTintColor: Color(0xfffffff3), + side: BorderSide(width: 2.5, color: Color(0xfffffff5)), + shape: RoundedRectangleBorder(), + headerHeight: 35.5, + headerTextStyle: TextStyle(fontSize: 24.0), + headerHintStyle: TextStyle(fontSize: 16.0), + constraints: BoxConstraints(minWidth: 350, minHeight: 240), + padding: EdgeInsets.only(bottom: 32.0), + barPadding: EdgeInsets.zero, + shrinkWrap: true, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'backgroundColor: ${const Color(0xfffffff1)}'); + expect(description[1], 'elevation: 3.5'); + expect(description[2], 'surfaceTintColor: ${const Color(0xfffffff3)}'); + expect(description[3], 'side: BorderSide(color: ${const Color(0xfffffff5)}, width: 2.5)'); + expect( + description[4], + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)', + ); + expect(description[5], 'headerHeight: 35.5'); + expect(description[6], 'headerTextStyle: TextStyle(inherit: true, size: 24.0)'); + expect(description[7], 'headerHintStyle: TextStyle(inherit: true, size: 16.0)'); + expect(description[8], 'constraints: BoxConstraints(350.0<=w<=Infinity, 240.0<=h<=Infinity)'); + expect(description[9], 'padding: EdgeInsets(0.0, 0.0, 0.0, 32.0)'); + expect(description[10], 'barPadding: EdgeInsets.zero'); + expect(description[11], 'shrinkWrap: true'); + }); + + group('[Theme, SearchViewTheme, SearchView properties overrides]', () { + const backgroundColor = Color(0xff000001); + const elevation = 5.0; + const surfaceTintColor = Color(0xff000002); + const side = BorderSide(color: Color(0xff000003), width: 2.0); + const OutlinedBorder shape = RoundedRectangleBorder( + side: side, + borderRadius: BorderRadius.all(Radius.circular(20.0)), + ); + const headerHeight = 45.0; + const headerTextStyle = TextStyle(color: Color(0xff000004), fontSize: 20.0); + const headerHintStyle = TextStyle(color: Color(0xff000005), fontSize: 18.0); + const constraints = BoxConstraints(minWidth: 250.0, maxWidth: 300.0, minHeight: 450.0); + + const searchViewTheme = SearchViewThemeData( + backgroundColor: backgroundColor, + elevation: elevation, + surfaceTintColor: surfaceTintColor, + side: side, + shape: shape, + headerHeight: headerHeight, + headerTextStyle: headerTextStyle, + headerHintStyle: headerHintStyle, + constraints: constraints, + ); + + Widget buildFrame({ + bool useSearchViewProperties = false, + SearchViewThemeData? searchViewThemeData, + SearchViewThemeData? overallTheme, + }) { + final Widget child = Builder( + builder: (BuildContext context) { + if (!useSearchViewProperties) { + return SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + isFullScreen: false, + ); + } + return SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return <Widget>[]; + }, + isFullScreen: false, + viewElevation: elevation, + viewBackgroundColor: backgroundColor, + viewSurfaceTintColor: surfaceTintColor, + viewSide: side, + viewShape: shape, + headerHeight: headerHeight, + headerTextStyle: headerTextStyle, + headerHintStyle: headerHintStyle, + viewConstraints: constraints, + ); + }, + ); + return MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(searchViewTheme: overallTheme), + home: Scaffold( + body: Center( + // If the SearchViewThemeData widget is present, it's used + // instead of the Theme's ThemeData.searchViewTheme. + child: searchViewThemeData == null + ? child + : SearchViewTheme(data: searchViewThemeData, child: child), + ), + ), + ); + } + + Finder findViewContent() { + return find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_ViewContent'; + }); + } + + Material getSearchViewMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: findViewContent(), matching: find.byType(Material)).first, + ); + } + + Future<void> checkSearchView(WidgetTester tester) async { + final Material material = getSearchViewMaterial(tester); + expect(material.elevation, elevation); + expect(material.color, backgroundColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shape, shape); + + final Size size = tester.getSize( + find.descendant(of: findViewContent(), matching: find.byType(ConstrainedBox)).first, + ); + expect(size.width, 250.0); + expect(size.height, 450.0); + + final Text hintText = tester.widget(find.text('hint text')); + expect(hintText.style?.color, headerHintStyle.color); + expect(hintText.style?.fontSize, headerHintStyle.fontSize); + + final RenderBox box = tester.renderObject( + find.descendant(of: findViewContent(), matching: find.byType(SearchBar)), + ); + expect(box.size.height, headerHeight); + await tester.enterText(find.byType(TextField), 'input'); + final EditableText inputText = tester.widget(find.text('input')); + expect(inputText.style.color, headerTextStyle.color); + expect(inputText.style.fontSize, headerTextStyle.fontSize); + } + + testWidgets('SearchView properties overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(useSearchViewProperties: true)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchView(tester); + }); + + testWidgets('SearchView theme data overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(searchViewThemeData: searchViewTheme)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await checkSearchView(tester); + }); + + testWidgets('Overall Theme SearchView theme overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallTheme: searchViewTheme)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await checkSearchView(tester); + }); + + // Same as the previous tests with empty SearchViewThemeData's instead of null. + + testWidgets('SearchView properties overrides defaults, empty theme and overall theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildFrame( + useSearchViewProperties: true, + searchViewThemeData: const SearchViewThemeData(), + overallTheme: const SearchViewThemeData(), + ), + ); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchView(tester); + }); + + testWidgets('SearchView theme overrides defaults and overall theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + buildFrame(searchViewThemeData: searchViewTheme, overallTheme: const SearchViewThemeData()), + ); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchView(tester); + }); + + testWidgets('Overall Theme SearchView theme overrides defaults and null theme', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(buildFrame(overallTheme: searchViewTheme)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + await checkSearchView(tester); + }); + }); +} diff --git a/packages/material_ui/test/material/segmented_button_test.dart b/packages/material_ui/test/material/segmented_button_test.dart new file mode 100644 index 000000000000..a49ddf49f26b --- /dev/null +++ b/packages/material_ui/test/material/segmented_button_test.dart @@ -0,0 +1,1640 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + RenderObject getOverlayColor(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + Widget boilerplate({required Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); + } + + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + testWidgets('SegmentsButton when compositing does not crash', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/135747 + // If the render object holds on to a stale canvas reference, this will + // throw an exception. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>( + value: 0, + label: Opacity(opacity: 0.5, child: Text('option')), + icon: Opacity(opacity: 0.5, child: Icon(Icons.add)), + ), + ], + selected: const <int>{0}, + ), + ), + ), + ); + + expect(find.byType(SegmentedButton<int>), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('SegmentedButton releases state controllers for deleted segments', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final Key key = UniqueKey(); + + Widget buildApp(Widget button) { + return MaterialApp( + theme: theme, + home: Scaffold(body: Center(child: button)), + ); + } + + await tester.pumpWidget( + buildApp( + SegmentedButton<int>( + key: key, + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ], + selected: const <int>{2}, + ), + ), + ); + + await tester.pumpWidget( + buildApp( + SegmentedButton<int>( + key: key, + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3')), + ], + selected: const <int>{2}, + ), + ), + ); + + final SegmentedButtonState<int> state = tester.state(find.byType(SegmentedButton<int>)); + expect(state.statesControllers, hasLength(2)); + expect(state.statesControllers.keys.first.value, 2); + expect(state.statesControllers.keys.last.value, 3); + }); + + testWidgets('SegmentedButton is built with Material of type MaterialType.transparency', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ); + + // Expect SegmentedButton to be built with type MaterialType.transparency. + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder parentMaterial = find.ancestor(of: parent, matching: find.byType(Material)).first; + final Material material = tester.widget<Material>(parentMaterial); + expect(material.type, MaterialType.transparency); + }); + + testWidgets('SegmentedButton supports exclusive choice by default', (WidgetTester tester) async { + var callbackCount = 0; + var selectedSegment = 2; + + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3')), + ], + selected: <int>{selected}, + onSelectionChanged: (Set<int> selected) { + assert(selected.length == 1); + selectedSegment = selected.first; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selectedSegment)); + expect(selectedSegment, 2); + expect(callbackCount, 0); + + // Tap on segment 1. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selectedSegment, 1); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(1)); + + // Tap on segment 1 again should do nothing. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selectedSegment, 1); + + // Tap on segment 3. + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(callbackCount, 2); + expect(selectedSegment, 3); + }); + + testWidgets('SegmentedButton supports multiple selected segments', (WidgetTester tester) async { + var callbackCount = 0; + var selection = <int>{1}; + + Widget frameWithSelection(Set<int> selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + multiSelectionEnabled: true, + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3')), + ], + selected: selected, + onSelectionChanged: (Set<int> selected) { + selection = selected; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selection)); + expect(selection, <int>{1}); + expect(callbackCount, 0); + + // Tap on segment 2. + await tester.tap(find.text('2')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selection, <int>{1, 2}); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(<int>{1, 2})); + await tester.pumpAndSettle(); + + // Tap on segment 1 again should remove it from selection. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 2); + expect(selection, <int>{2}); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(<int>{2})); + await tester.pumpAndSettle(); + + // Tap on segment 3. + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(callbackCount, 3); + expect(selection, <int>{2, 3}); + }); + + // Regression test for https://github.com/flutter/flutter/issues/161922. + testWidgets('Focused segment does not lose focus when its selection state changes', ( + WidgetTester tester, + ) async { + var callbackCount = 0; + var selection = <int>{1}; + + Widget frameWithSelection(Set<int> selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + multiSelectionEnabled: true, + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ], + selected: selected, + onSelectionChanged: (Set<int> selected) { + selection = selected; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selection)); + expect(selection, <int>{1}); + expect(callbackCount, 0); + + // Select segment 2. + await tester.pumpWidget(frameWithSelection(<int>{1, 2})); + await tester.pumpAndSettle(); + + FocusNode getSegment2FocusNode() { + return Focus.of(tester.element(find.text('2'))); + } + + // Set focus on segment 2. + getSegment2FocusNode().requestFocus(); + await tester.pumpAndSettle(); + expect(getSegment2FocusNode().hasFocus, true); + + // Unselect segment 2. + await tester.pumpWidget(frameWithSelection(<int>{1})); + await tester.pumpAndSettle(); + + // The button should still be focused. + expect(getSegment2FocusNode().hasFocus, true); + }); + + testWidgets('SegmentedButton allows for empty selection', (WidgetTester tester) async { + var callbackCount = 0; + int? selectedSegment = 1; + + Widget frameWithSelection(int? selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + emptySelectionAllowed: true, + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3')), + ], + selected: <int>{?selected}, + onSelectionChanged: (Set<int> selected) { + selectedSegment = selected.isEmpty ? null : selected.first; + callbackCount += 1; + }, + ), + ), + ); + } + + await tester.pumpWidget(frameWithSelection(selectedSegment)); + expect(selectedSegment, 1); + expect(callbackCount, 0); + + // Tap on segment 1 should deselect it and make the selection empty. + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + expect(callbackCount, 1); + expect(selectedSegment, null); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(null)); + + // Tap on segment 2 should select it. + await tester.tap(find.text('2')); + await tester.pumpAndSettle(); + expect(callbackCount, 2); + expect(selectedSegment, 2); + + // Update the selection in the widget + await tester.pumpWidget(frameWithSelection(2)); + + // Tap on segment 3. + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + expect(callbackCount, 3); + expect(selectedSegment, 3); + }); + + testWidgets('SegmentedButton shows checkboxes for selected segments', ( + WidgetTester tester, + ) async { + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3')), + ], + selected: <int>{selected}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ); + } + + Finder textHasIcon(String text, IconData icon) { + return find.descendant(of: find.widgetWithText(Row, text), matching: find.byIcon(icon)); + } + + await tester.pumpWidget(frameWithSelection(1)); + expect(textHasIcon('1', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(2)); + expect(textHasIcon('2', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(2)); + expect(textHasIcon('2', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets( + 'SegmentedButton shows selected checkboxes in place of icon if it has a label as well', + (WidgetTester tester) async { + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, icon: Icon(Icons.add), label: Text('1')), + ButtonSegment<int>(value: 2, icon: Icon(Icons.add_a_photo), label: Text('2')), + ButtonSegment<int>(value: 3, icon: Icon(Icons.add_alarm), label: Text('3')), + ], + selected: <int>{selected}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ); + } + + Finder textHasIcon(String text, IconData icon) { + return find.descendant(of: find.widgetWithText(Row, text), matching: find.byIcon(icon)); + } + + await tester.pumpWidget(frameWithSelection(1)); + expect(textHasIcon('1', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.add), findsNothing); + expect(textHasIcon('2', Icons.add_a_photo), findsOneWidget); + expect(textHasIcon('3', Icons.add_alarm), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(2)); + expect(textHasIcon('1', Icons.add), findsOneWidget); + expect(textHasIcon('2', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.add_a_photo), findsNothing); + expect(textHasIcon('3', Icons.add_alarm), findsOneWidget); + + await tester.pumpWidget(frameWithSelection(3)); + expect(textHasIcon('1', Icons.add), findsOneWidget); + expect(textHasIcon('2', Icons.add_a_photo), findsOneWidget); + expect(textHasIcon('3', Icons.check), findsOneWidget); + expect(find.byIcon(Icons.add_alarm), findsNothing); + }, + ); + + testWidgets('SegmentedButton shows selected checkboxes next to icon if there is no label', ( + WidgetTester tester, + ) async { + Widget frameWithSelection(int selected) { + return Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, icon: Icon(Icons.add)), + ButtonSegment<int>(value: 2, icon: Icon(Icons.add_a_photo)), + ButtonSegment<int>(value: 3, icon: Icon(Icons.add_alarm)), + ], + selected: <int>{selected}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ); + } + + Finder rowWithIcons(IconData icon1, IconData icon2) { + return find.descendant(of: find.widgetWithIcon(Row, icon1), matching: find.byIcon(icon2)); + } + + await tester.pumpWidget(frameWithSelection(1)); + expect(rowWithIcons(Icons.add, Icons.check), findsOneWidget); + expect(rowWithIcons(Icons.add_a_photo, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_alarm, Icons.check), findsNothing); + + await tester.pumpWidget(frameWithSelection(2)); + expect(rowWithIcons(Icons.add, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_a_photo, Icons.check), findsOneWidget); + expect(rowWithIcons(Icons.add_alarm, Icons.check), findsNothing); + + await tester.pumpWidget(frameWithSelection(3)); + expect(rowWithIcons(Icons.add, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_a_photo, Icons.check), findsNothing); + expect(rowWithIcons(Icons.add_alarm, Icons.check), findsOneWidget); + }); + + testWidgets('SegmentedButtons have correct semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + // First is an unselected, enabled button. + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + label: '1', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + + // Second is a selected, enabled button. + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + label: '2', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + + // Third is an unselected, disabled button. + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isInMutuallyExclusiveGroup, + ], + label: '3', + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Multi-select SegmentedButtons have correct semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{1, 3}, + onSelectionChanged: (Set<int> selected) {}, + multiSelectionEnabled: true, + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + // First is selected, enabled button. + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + ], + label: '1', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + + // Second is an unselected, enabled button. + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + label: '2', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + + // Third is a selected, disabled button. + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isSelected, + SemanticsFlag.hasSelectedState, + ], + label: '3', + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/146987 + testWidgets('SegmentedButton announce state on all platforms', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ); + + // Verify that the selected segments/buttons use 'selected' semantic property. + // This ensures iOS VoiceOver announces 'selected' state. + + final Iterable<SemanticsNode> allNodes = semantics.nodesWith(); + + // Verify that the selected state flags are existing. + final Iterable<SemanticsNode> selectedNodes = allNodes.where( + (SemanticsNode node) => + node.hasFlag(SemanticsFlag.hasSelectedState) && node.hasFlag(SemanticsFlag.isSelected), + ); + + expect(selectedNodes.isNotEmpty, isTrue); + + final Iterable<SemanticsNode> unselectedNodes = allNodes.where( + (SemanticsNode node) => + node.hasFlag(SemanticsFlag.hasSelectedState) && !node.hasFlag(SemanticsFlag.isSelected), + ); + + expect(unselectedNodes.isNotEmpty, isTrue); + + // Verify that there is one selected segment and one unselected segment. + expect(selectedNodes.length, equals(1)); + expect(unselectedNodes.length, equals(1)); + + // Ensure that the 'checked' flags are NOT used to prevent duplication issue + // on Android. + // On Android, TalkBack reader announces both 'checked' and 'selected' states. + // This verifies `checked` state is not read with Android TalkBack. + for (final node in allNodes) { + expect(node.hasFlag(SemanticsFlag.hasCheckedState), isFalse); + expect(node.hasFlag(SemanticsFlag.isChecked), isFalse); + } + + semantics.dispose(); + }); + + testWidgets('SegmentedButton default overlayColor and foregroundColor resolve pressed state', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ], + selected: const <int>{1}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton).last, matching: find.byType(Material)), + ); + + // Hovered. + final Offset center = tester.getCenter(find.text('2')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08)), + ); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect() + ..rect(color: theme.colorScheme.onSurface.withOpacity(0.1)), + ); + expect(material.textStyle?.color, theme.colorScheme.onSurface); + }); + + testWidgets('SegmentedButton has no tooltips by default', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ); + + expect(find.byType(Tooltip), findsNothing); + }); + + testWidgets('SegmentedButton has correct tooltips', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2'), tooltip: 't2'), + ButtonSegment<int>(value: 3, label: Text('3'), tooltip: 't3', enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ); + + expect(find.byType(Tooltip), findsNWidgets(2)); + expect(find.byTooltip('t2'), findsOneWidget); + expect(find.byTooltip('t3'), findsOneWidget); + }); + + testWidgets('SegmentedButton.styleFrom is applied to the SegmentedButton', ( + WidgetTester tester, + ) async { + const foregroundColor = Color(0xfffffff0); + const backgroundColor = Color(0xfffffff1); + const selectedBackgroundColor = Color(0xfffffff2); + const selectedForegroundColor = Color(0xfffffff3); + const disabledBackgroundColor = Color(0xfffffff4); + const disabledForegroundColor = Color(0xfffffff5); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + + final ButtonStyle styleFromStyle = SegmentedButton.styleFrom( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + selectedForegroundColor: selectedForegroundColor, + selectedBackgroundColor: selectedBackgroundColor, + disabledForegroundColor: disabledForegroundColor, + disabledBackgroundColor: disabledBackgroundColor, + shadowColor: const Color(0xfffffff6), + surfaceTintColor: const Color(0xfffffff7), + elevation: 1, + textStyle: const TextStyle(color: Color(0xfffffff8)), + padding: const EdgeInsets.all(2), + side: const BorderSide(color: Color(0xfffffff9)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(3))), + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + animationDuration: const Duration(milliseconds: 100), + enableFeedback: true, + alignment: Alignment.center, + splashFactory: NoSplash.splashFactory, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + style: styleFromStyle, + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + selectedIcon: const Icon(Icons.alarm), + ), + ), + ), + ), + ); + + // Test provided button style is applied to the enabled button segment. + ButtonStyle? buttonStyle = tester.widget<TextButton>(find.byType(TextButton).first).style; + expect(buttonStyle?.foregroundColor?.resolve(enabled), foregroundColor); + expect(buttonStyle?.backgroundColor?.resolve(enabled), backgroundColor); + expect(buttonStyle?.overlayColor, styleFromStyle.overlayColor); + expect(buttonStyle?.surfaceTintColor, styleFromStyle.surfaceTintColor); + expect(buttonStyle?.elevation, styleFromStyle.elevation); + expect(buttonStyle?.textStyle, styleFromStyle.textStyle); + expect(buttonStyle?.padding, styleFromStyle.padding); + expect(buttonStyle?.mouseCursor?.resolve(enabled), enabledMouseCursor); + expect(buttonStyle?.visualDensity, styleFromStyle.visualDensity); + expect(buttonStyle?.tapTargetSize, styleFromStyle.tapTargetSize); + expect(buttonStyle?.animationDuration, styleFromStyle.animationDuration); + expect(buttonStyle?.enableFeedback, styleFromStyle.enableFeedback); + expect(buttonStyle?.alignment, styleFromStyle.alignment); + expect(buttonStyle?.splashFactory, styleFromStyle.splashFactory); + + // Test provided button style is applied selected button segment. + buttonStyle = tester.widget<TextButton>(find.byType(TextButton).at(1)).style; + expect(buttonStyle?.foregroundColor?.resolve(selected), selectedForegroundColor); + expect(buttonStyle?.backgroundColor?.resolve(selected), selectedBackgroundColor); + expect(buttonStyle?.mouseCursor?.resolve(enabled), enabledMouseCursor); + + // Test provided button style is applied disabled button segment. + buttonStyle = tester.widget<TextButton>(find.byType(TextButton).last).style; + expect(buttonStyle?.foregroundColor?.resolve(disabled), disabledForegroundColor); + expect(buttonStyle?.backgroundColor?.resolve(disabled), disabledBackgroundColor); + expect(buttonStyle?.mouseCursor?.resolve(disabled), disabledMouseCursor); + + // Test provided button style is applied to the segmented button material. + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(SegmentedButton<int>), matching: find.byType(Material)).first, + ); + expect(material.elevation, styleFromStyle.elevation?.resolve(enabled)); + expect(material.shadowColor, styleFromStyle.shadowColor?.resolve(enabled)); + expect(material.surfaceTintColor, styleFromStyle.surfaceTintColor?.resolve(enabled)); + + // Test provided button style border is applied to the segmented button border. + expect( + find.byType(SegmentedButton<int>), + paints..line(color: styleFromStyle.side?.resolve(enabled)?.color), + ); + + // Test foreground color is applied to the overlay color. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.down(tester.getCenter(find.text('1'))); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: foregroundColor.withOpacity(0.08))); + }); + + testWidgets('Disabled SegmentedButton has correct states when rebuilding', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + children: <Widget>[ + SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('foo')), + ], + selected: const <int>{0}, + ), + ElevatedButton( + onPressed: () => setState(() {}), + child: const Text('Trigger rebuild'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + final states = <WidgetState>{WidgetState.selected, WidgetState.disabled}; + // Check the initial states. + SegmentedButtonState<int> state = tester.state(find.byType(SegmentedButton<int>)); + expect(state.statesControllers.values.first.value, states); + // Trigger a rebuild. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + // Check the states after the rebuild. + state = tester.state(find.byType(SegmentedButton<int>)); + expect(state.statesControllers.values.first.value, states); + }); + + testWidgets('Min button hit target height is 48.0 and min (painted) button height is 40 ' + 'by default with standard density and MaterialTapTargetSize.padded', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Column( + children: <Widget>[ + SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>( + value: 0, + label: Text('Day'), + icon: Icon(Icons.calendar_view_day), + ), + ButtonSegment<int>( + value: 1, + label: Text('Week'), + icon: Icon(Icons.calendar_view_week), + ), + ButtonSegment<int>( + value: 2, + label: Text('Month'), + icon: Icon(Icons.calendar_view_month), + ), + ButtonSegment<int>( + value: 3, + label: Text('Year'), + icon: Icon(Icons.calendar_today), + ), + ], + selected: const <int>{0}, + onSelectionChanged: (Set<int> value) {}, + ), + ], + ), + ), + ), + ), + ); + + expect(theme.visualDensity, VisualDensity.standard); + expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded); + + final Finder button = find.byType(SegmentedButton<int>); + expect(tester.getSize(button).height, 48.0); + expect( + find.byType(SegmentedButton<int>), + paints..rrect( + style: PaintingStyle.stroke, + strokeWidth: 1.0, + // Button border height is button.bottom(43.5) - button.top(4.5) + stoke width(1) = 40. + rrect: RRect.fromLTRBR(0.5, 4.5, 497.5, 43.5, const Radius.circular(19.5)), + ), + ); + }); + + testWidgets( + 'SegmentedButton expands to fill the available width when expandedInsets is not null', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('Segment 1')), + ButtonSegment<int>(value: 2, label: Text('Segment 2')), + ], + selected: const <int>{1}, + expandedInsets: EdgeInsets.zero, + ), + ), + ), + ), + ); + + // Get the width of the SegmentedButton. + final RenderBox box = tester.renderObject(find.byType(SegmentedButton<int>)); + final double segmentedButtonWidth = box.size.width; + + // Get the width of the parent widget. + final double screenWidth = tester.getSize(find.byType(Scaffold)).width; + + // The width of the SegmentedButton must be equal to the width of the parent widget. + expect(segmentedButtonWidth, equals(screenWidth)); + }, + ); + + testWidgets('SegmentedButton does not expand when expandedInsets is null', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('Segment 1')), + ButtonSegment<int>(value: 2, label: Text('Segment 2')), + ], + selected: const <int>{1}, + ), + ), + ), + ), + ); + + // Get the width of the SegmentedButton. + final RenderBox box = tester.renderObject(find.byType(SegmentedButton<int>)); + final double segmentedButtonWidth = box.size.width; + + // Get the width of the parent widget. + final double screenWidth = tester.getSize(find.byType(Scaffold)).width; + + // The width of the SegmentedButton must be less than the width of the parent widget. + expect(segmentedButtonWidth, lessThan(screenWidth)); + }); + + testWidgets('SegmentedButton.styleFrom overlayColor overrides default overlay color', ( + WidgetTester tester, + ) async { + const overlayColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + style: IconButton.styleFrom(overlayColor: overlayColor), + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option 1')), + ButtonSegment<int>(value: 1, label: Text('Option 2')), + ], + onSelectionChanged: (Set<int> selected) {}, + selected: const <int>{1}, + ), + ), + ), + ), + ); + + // Hovered selected segment, + Offset center = tester.getCenter(find.text('Option 1')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Hovered unselected segment, + center = tester.getCenter(find.text('Option 2')); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted unselected segment (pressed). + center = tester.getCenter(find.text('Option 1')); + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Highlighted selected segment (pressed) + center = tester.getCenter(find.text('Option 2')); + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused unselected segment. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.1))); + + // Focused selected segment. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.1))); + }); + + testWidgets('SegmentedButton.styleFrom with transparent overlayColor', ( + WidgetTester tester, + ) async { + const Color overlayColor = Colors.transparent; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + style: IconButton.styleFrom(overlayColor: overlayColor), + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option')), + ], + onSelectionChanged: (Set<int> selected) {}, + selected: const <int>{0}, + ), + ), + ), + ), + ); + + // Hovered, + final Offset center = tester.getCenter(find.text('Option')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor)); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor) + ..rect(color: overlayColor), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/144990. + testWidgets('SegmentedButton clips border path when drawing segments', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option 1')), + ButtonSegment<int>(value: 1, label: Text('Option 2')), + ], + onSelectionChanged: (Set<int> selected) {}, + selected: const <int>{0}, + ), + ), + ), + ), + ); + + expect( + find.byType(SegmentedButton<int>), + paints + ..save() + ..clipPath() // Clip the border. + ..path(color: const Color(0xffe8def8)) // Draw segment 0. + ..save() + ..clipPath() // Clip the border. + ..path(color: const Color(0x00000000)), // Draw segment 1. + ); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/144990. + testWidgets('SegmentedButton dividers matches border rect size', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option 1')), + ButtonSegment<int>(value: 1, label: Text('Option 2')), + ], + onSelectionChanged: (Set<int> selected) {}, + selected: const <int>{0}, + ), + ), + ), + ), + ); + + const tapTargetSize = 48.0; + expect( + find.byType(SegmentedButton<int>), + paints..line( + p1: const Offset(166.8000030517578, 4.0), + p2: const Offset(166.8000030517578, tapTargetSize - 4.0), + ), + ); + }); + + testWidgets('SegmentedButton vertical aligned children', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option 0')), + ButtonSegment<int>(value: 1, label: Text('Option 1')), + ButtonSegment<int>(value: 2, label: Text('Option 2')), + ButtonSegment<int>(value: 3, label: Text('Option 3')), + ], + onSelectionChanged: (Set<int> selected) {}, + selected: const <int>{-1}, // Prevent any of ButtonSegment to be selected + direction: Axis.vertical, + ), + ), + ), + ), + ); + + Rect? previewsChildRect; + for (var i = 0; i <= 3; i++) { + final Rect currentChildRect = tester.getRect(find.widgetWithText(TextButton, 'Option $i')); + if (previewsChildRect != null) { + expect(currentChildRect.left, previewsChildRect.left); + expect(currentChildRect.right, previewsChildRect.right); + expect(currentChildRect.top, previewsChildRect.top + previewsChildRect.height); + } + previewsChildRect = currentChildRect; + } + }); + + testWidgets('SegmentedButton vertical aligned golden image', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option 0')), + ButtonSegment<int>(value: 1, label: Text('Option 1')), + ], + selected: const <int>{0}, // Prevent any of ButtonSegment to be selected + direction: Axis.vertical, + ), + ), + ), + ), + ), + ); + + await expectLater(find.byKey(key), matchesGoldenFile('segmented_button_test_vertical.png')); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('SegmentedButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: SegmentedButton<int>( + style: SegmentedButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + ), + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Add'), icon: Icon(Icons.add)), + ButtonSegment<int>(value: 1, label: Text('Subtract'), icon: Icon(Icons.remove)), + ], + showSelectedIcon: false, + onSelectionChanged: enabled ? (Set<int> selected) {} : null, + selected: const <int>{0}, + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + }); + + testWidgets('SegmentedButton border sides respect states', (WidgetTester tester) async { + const disabledColor = Color(0XFF999999); + const hoveredColor = Color(0XFF0000FF); + const focusedColor = Color(0XFF00FF00); + const selectedColor = Color(0XFF001234); + const hoveredSelectedColor = Color(0XFF32CD32); + const focusedSelectedColor = Color(0XFF0000CD); + const enabledColor = Color(0XFFFF0000); + + Widget buildButton({ + bool enabled = true, + WidgetStateProperty<BorderSide?>? side, + Set<int> selected = const <int>{}, + }) { + return MaterialApp( + home: Material( + child: Center( + child: SegmentedButton<int>( + style: ButtonStyle(side: side), + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Add'), icon: Icon(Icons.add)), + ButtonSegment<int>(value: 1, label: Text('Subtract'), icon: Icon(Icons.remove)), + ButtonSegment<int>( + value: 2, + label: Text('Multiply'), + icon: Icon(Icons.multiple_stop), + ), + ], + showSelectedIcon: false, + onSelectionChanged: enabled ? (Set<int> selected) {} : null, + selected: selected, + emptySelectionAllowed: true, + ), + ), + ), + ); + } + + await tester.pumpWidget( + buildButton( + side: const WidgetStateProperty<BorderSide?>.fromMap(<WidgetStatesConstraint, BorderSide?>{ + WidgetState.hovered: BorderSide(color: hoveredColor), + WidgetState.focused: BorderSide(color: focusedColor), + WidgetState.any: BorderSide(color: enabledColor), + }), + ), + ); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: enabledColor)); + + // Hovered. + Offset buttonLocation = tester.getCenter(find.text('Add')); + TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(buttonLocation); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: hoveredColor)); + + await gesture.removePointer(); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: focusedColor)); + + await tester.pumpWidget( + buildButton( + side: WidgetStateProperty<BorderSide?>.fromMap(<WidgetStatesConstraint, BorderSide?>{ + WidgetState.hovered & WidgetState.selected: const BorderSide(color: hoveredSelectedColor), + WidgetState.focused & WidgetState.selected: const BorderSide(color: focusedSelectedColor), + WidgetState.hovered: const BorderSide(color: hoveredColor), + WidgetState.focused: const BorderSide(color: focusedColor), + WidgetState.any: const BorderSide(color: enabledColor), + }), + selected: <int>{1}, + ), + ); + await tester.pumpAndSettle(); + + // Hovered. + buttonLocation = tester.getCenter(find.text('Add')); + gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(buttonLocation); + addTearDown(gesture.removePointer); + await tester.pumpAndSettle(); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: hoveredSelectedColor)); + + await gesture.removePointer(); + await tester.pumpAndSettle(); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: focusedSelectedColor)); + + await tester.pumpWidget( + buildButton( + enabled: false, + side: const WidgetStateProperty<BorderSide?>.fromMap(<WidgetStatesConstraint, BorderSide?>{ + WidgetState.disabled: BorderSide(color: disabledColor), + WidgetState.any: BorderSide(color: enabledColor), + }), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: disabledColor)); + + await tester.pumpWidget( + buildButton( + side: const WidgetStateProperty<BorderSide?>.fromMap(<WidgetStatesConstraint, BorderSide?>{ + WidgetState.selected: BorderSide(color: selectedColor), + WidgetState.any: BorderSide(color: enabledColor), + }), + selected: <int>{1}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SegmentedButton<int>), paints..rrect(color: selectedColor)); + }); + + testWidgets('SegmentedButton border sides respect disabled state', (WidgetTester tester) async { + const disabledColor = Color(0XFF999999); + const enabledColor = Color(0XFFFF0000); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SegmentedButton<int>( + style: const ButtonStyle( + side: + WidgetStateProperty<BorderSide?>.fromMap(<WidgetStatesConstraint, BorderSide?>{ + WidgetState.disabled: BorderSide(color: disabledColor), + WidgetState.any: BorderSide(color: enabledColor), + }), + ), + // First segment is enabled, second is disabled. + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('0')), + ButtonSegment<int>(value: 1, label: Text('1'), enabled: false), + ], + selected: const <int>{0}, + onSelectionChanged: (Set<int> newSelection) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byType(SegmentedButton<int>), + paints + // First segment has an enabled border. + ..rrect(color: enabledColor) + // Second segment has a disabled border. + ..rrect(color: disabledColor), + ); + }); + + testWidgets('SegmentedButton has expected default mouse cursor on hover', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('0')), + ButtonSegment<int>(value: 1, label: Text('1')), + ], + selected: const <int>{0}, + onSelectionChanged: (Set<int> newSelection) {}, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(10, 10)); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.text('0')); + await gesture.moveTo(chip); + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets('SegmentedButton has expected mouse cursor when explicitly configured', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SegmentedButton<int>( + style: ButtonStyle( + mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.grab), + ), + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('0')), + ButtonSegment<int>(value: 1, label: Text('1')), + ], + selected: const <int>{0}, + onSelectionChanged: (Set<int> newSelection) {}, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.byType(SegmentedButton<int>))); + addTearDown(gesture.removePointer); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + }); + + testWidgets('SegmentedButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: SegmentedButton<String>( + segments: const <ButtonSegment<String>>[ + ButtonSegment<String>(value: 'X', label: Text('X')), + ], + selected: const <String>{'X'}, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(SegmentedButton<String>)), Size.zero); + }); + + testWidgets('SegmentedButton should expand to fill the full width', (WidgetTester tester) async { + tester.view + ..physicalSize = const Size(800, 1200) + ..devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + const double screenWidth = 800; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: double.infinity, + child: SegmentedButton<String>( + direction: Axis.vertical, + segments: const [ + ButtonSegment(value: 'All', label: Text('All')), + ButtonSegment(value: 'Top free', label: Text('Top free')), + ButtonSegment(value: 'Top paid', label: Text('Top paid')), + ], + selected: const {'All'}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final RenderBox segmentedBox = tester.renderObject(find.byType(SegmentedButton<String>)); + + expect(segmentedBox.size.width, screenWidth); + + final Finder segmentMaterials = find.descendant( + of: find.byType(SegmentedButton<String>), + matching: find.byType(Material), + ); + + for (final Element element in segmentMaterials.evaluate()) { + final segmentBox = element.renderObject! as RenderBox; + + expect(segmentBox.size.width, screenWidth); + } + }); +} + +Set<WidgetState> enabled = const <WidgetState>{}; +Set<WidgetState> disabled = const <WidgetState>{WidgetState.disabled}; +Set<WidgetState> selected = const <WidgetState>{WidgetState.selected}; diff --git a/packages/material_ui/test/material/segmented_button_theme_test.dart b/packages/material_ui/test/material/segmented_button_theme_test.dart new file mode 100644 index 000000000000..cc062da3e441 --- /dev/null +++ b/packages/material_ui/test/material/segmented_button_theme_test.dart @@ -0,0 +1,577 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + RenderObject getOverlayColor(WidgetTester tester) { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + test('SegmentedButtonThemeData copyWith, ==, hashCode basics', () { + expect(const SegmentedButtonThemeData(), const SegmentedButtonThemeData().copyWith()); + expect( + const SegmentedButtonThemeData().hashCode, + const SegmentedButtonThemeData().copyWith().hashCode, + ); + + const custom = SegmentedButtonThemeData( + style: ButtonStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.green)), + selectedIcon: Icon(Icons.error), + ); + final SegmentedButtonThemeData copy = const SegmentedButtonThemeData().copyWith( + style: custom.style, + selectedIcon: custom.selectedIcon, + ); + expect(copy, custom); + }); + + test('SegmentedButtonThemeData lerp special cases', () { + expect(SegmentedButtonThemeData.lerp(null, null, 0), const SegmentedButtonThemeData()); + const theme = SegmentedButtonThemeData(); + expect(identical(SegmentedButtonThemeData.lerp(theme, theme, 0.5), theme), true); + }); + + testWidgets('Default SegmentedButtonThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SegmentedButtonThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('With no other configuration, defaults are used', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.check)); + final Material material = tester.widget<Material>(parent); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, theme.colorScheme.onSurface); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.check)); + final Material material = tester.widget<Material>(parent); + expect(material.color, theme.colorScheme.secondaryContainer); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, theme.colorScheme.onSecondaryContainer); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.check)); + final Material material = tester.widget<Material>(parent); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, theme.colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets('ThemeData.segmentedButtonTheme overrides defaults', (WidgetTester tester) async { + final theme = ThemeData( + segmentedButtonTheme: SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + if (states.contains(WidgetState.selected)) { + return Colors.purple; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.yellow; + } + if (states.contains(WidgetState.selected)) { + return Colors.brown; + } else { + return Colors.cyan; + } + }), + ), + selectedIcon: const Icon(Icons.error), + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.error)); + final Material material = tester.widget<Material>(parent); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.cyan); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.error)); + final Material material = tester.widget<Material>(parent); + expect(material.color, Colors.purple); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.brown); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.error)); + final Material material = tester.widget<Material>(parent); + expect(material.color, Colors.blue); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.yellow); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets('SegmentedButtonTheme overrides ThemeData and defaults', (WidgetTester tester) async { + final global = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + if (states.contains(WidgetState.selected)) { + return Colors.purple; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.yellow; + } + if (states.contains(WidgetState.selected)) { + return Colors.brown; + } else { + return Colors.cyan; + } + }), + ), + selectedIcon: const Icon(Icons.error), + ); + final segmentedTheme = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.lightBlue; + } + if (states.contains(WidgetState.selected)) { + return Colors.lightGreen; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.lime; + } + if (states.contains(WidgetState.selected)) { + return Colors.amber; + } else { + return Colors.deepPurple; + } + }), + ), + selectedIcon: const Icon(Icons.plus_one), + ); + final theme = ThemeData(segmentedButtonTheme: global); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: SegmentedButtonTheme( + data: segmentedTheme, + child: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + ), + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant( + of: parent, + matching: find.byIcon(Icons.plus_one), + ); + final Material material = tester.widget<Material>(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.deepPurple); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant( + of: parent, + matching: find.byIcon(Icons.plus_one), + ); + final Material material = tester.widget<Material>(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.lightGreen); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.amber); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant( + of: parent, + matching: find.byIcon(Icons.plus_one), + ); + final Material material = tester.widget<Material>(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.lightBlue); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.lime); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets('Widget parameters overrides SegmentedTheme, ThemeData and defaults', ( + WidgetTester tester, + ) async { + final global = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + if (states.contains(WidgetState.selected)) { + return Colors.purple; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.yellow; + } + if (states.contains(WidgetState.selected)) { + return Colors.brown; + } else { + return Colors.cyan; + } + }), + ), + selectedIcon: const Icon(Icons.error), + ); + final segmentedTheme = SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.lightBlue; + } + if (states.contains(WidgetState.selected)) { + return Colors.lightGreen; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.lime; + } + if (states.contains(WidgetState.selected)) { + return Colors.amber; + } else { + return Colors.deepPurple; + } + }), + ), + selectedIcon: const Icon(Icons.plus_one), + ); + final theme = ThemeData(segmentedButtonTheme: global); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: SegmentedButtonTheme( + data: segmentedTheme, + child: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 1, label: Text('1')), + ButtonSegment<int>(value: 2, label: Text('2')), + ButtonSegment<int>(value: 3, label: Text('3'), enabled: false), + ], + selected: const <int>{2}, + onSelectionChanged: (Set<int> selected) {}, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.black12; + } + if (states.contains(WidgetState.selected)) { + return Colors.grey; + } + return null; + }), + foregroundColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.amberAccent; + } + if (states.contains(WidgetState.selected)) { + return Colors.deepOrange; + } else { + return Colors.deepPurpleAccent; + } + }), + ), + selectedIcon: const Icon(Icons.alarm), + ), + ), + ), + ), + ), + ); + + // Test first segment, should be enabled + { + final Finder text = find.text('1'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.alarm)); + final Material material = tester.widget<Material>(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.deepPurpleAccent); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + + // Test second segment, should be enabled and selected + { + final Finder text = find.text('2'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.alarm)); + final Material material = tester.widget<Material>(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.grey); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.deepOrange); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsOneWidget); + } + + // Test last segment, should be disabled + { + final Finder text = find.text('3'); + final Finder parent = find.ancestor(of: text, matching: find.byType(Material)).first; + final Finder selectedIcon = find.descendant(of: parent, matching: find.byIcon(Icons.alarm)); + final Material material = tester.widget<Material>(parent); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.black12); + expect(material.shape, const RoundedRectangleBorder()); + expect(material.textStyle!.color, Colors.amberAccent); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(selectedIcon, findsNothing); + } + }); + + testWidgets( + 'SegmentedButtonTheme SegmentedButton.styleFrom overlayColor overrides default overlay color', + (WidgetTester tester) async { + const overlayColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + segmentedButtonTheme: SegmentedButtonThemeData( + style: SegmentedButton.styleFrom(overlayColor: overlayColor), + ), + ), + home: Scaffold( + body: Center( + child: SegmentedButton<int>( + segments: const <ButtonSegment<int>>[ + ButtonSegment<int>(value: 0, label: Text('Option 1')), + ButtonSegment<int>(value: 1, label: Text('Option 2')), + ], + onSelectionChanged: (Set<int> selected) {}, + selected: const <int>{1}, + ), + ), + ), + ), + ); + + // Hovered selected segment, + Offset center = tester.getCenter(find.text('Option 1')); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Hovered unselected segment, + center = tester.getCenter(find.text('Option 2')); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted unselected segment (pressed). + center = tester.getCenter(find.text('Option 1')); + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Highlighted selected segment (pressed) + center = tester.getCenter(find.text('Option 2')); + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + // Remove pressed and hovered states, + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused unselected segment. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.1))); + + // Focused selected segment. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.1))); + }, + ); +} diff --git a/packages/material_ui/test/material/selectable_text_test.dart b/packages/material_ui/test/material/selectable_text_test.dart new file mode 100644 index 000000000000..2eb29e198bea --- /dev/null +++ b/packages/material_ui/test/material/selectable_text_test.dart @@ -0,0 +1,5738 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +@TestOn('!chrome') +library; + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, SemanticsInputType; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +import '../widgets/clipboard_utils.dart'; +import '../widgets/semantics_tester.dart'; +import 'editable_text_utils.dart' show textOffsetToPosition; + +class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { + @override + bool isSupported(Locale locale) => true; + + @override + Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale); + + @override + bool shouldReload(MaterialLocalizationsDelegate old) => false; +} + +class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> { + @override + bool isSupported(Locale locale) => true; + + @override + Future<WidgetsLocalizations> load(Locale locale) => DefaultWidgetsLocalizations.load(locale); + + @override + bool shouldReload(WidgetsLocalizationsDelegate old) => false; +} + +Widget overlay({Widget? child}) { + final entry = OverlayEntry( + builder: (BuildContext context) { + return Center(child: Material(child: child)); + }, + ); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + return overlayWithEntry(entry); +} + +Widget overlayWithEntry(OverlayEntry entry) { + return Theme( + data: ThemeData(useMaterial3: false), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: <LocalizationsDelegate<dynamic>>[ + WidgetsLocalizationsDelegate(), + MaterialLocalizationsDelegate(), + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Overlay(initialEntries: <OverlayEntry>[entry]), + ), + ), + ), + ); +} + +Widget boilerplate({Widget? child}) { + return Theme( + data: ThemeData(useMaterial3: false), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: <LocalizationsDelegate<dynamic>>[ + WidgetsLocalizationsDelegate(), + MaterialLocalizationsDelegate(), + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: Material(child: child)), + ), + ), + ), + ); +} + +Future<void> skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + + const kThreeLines = + 'First line of text is\n' + 'Second line goes until\n' + 'Third line of stuff'; + const kMoreThanFourLines = + '$kThreeLines\n' + "Fourth line won't display and ends at"; + + // Returns the first RenderEditable. + RenderEditable findRenderEditable(WidgetTester tester) { + final RenderObject root = tester.renderObject(find.byType(EditableText)); + expect(root, isNotNull); + + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + expect(renderEditable, isNotNull); + return renderEditable; + } + + // Check that the Cupertino text selection toolbar is the expected one on iOS and macOS. + // TODO(bleroux): try to merge this into text_selection_toolbar_utils.dart + // (for instance by adding a 'readOnly' flag). + void expectCupertinoSelectionToolbar() { + // This function is valid only for tests running on Apple platforms. + expect( + defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS, + isTrue, + ); + + if (defaultTargetPlatform == TargetPlatform.iOS) { + expect(find.byType(CupertinoButton), findsNWidgets(4)); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Look Up'), findsOneWidget); + expect(find.text('Search Web'), findsOneWidget); + expect(find.text('Share...'), findsOneWidget); + } else { + expect(find.byType(CupertinoButton), findsNWidgets(1)); + expect(find.text('Copy'), findsOneWidget); + } + } + + // Check that the Material text selection toolbar is the expected one. + // TODO(bleroux): Try to merge this into text_selection_toolbar_utils.dart + // (for instance by adding a 'readOnly' flag). + void expectMaterialSelectionToolbar() { + if (defaultTargetPlatform == TargetPlatform.android) { + expect(find.byType(TextButton), findsNWidgets(3)); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + } else { + expect(find.byType(TextButton), findsNWidgets(2)); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + } + } + + List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) { + return points.map<TextSelectionPoint>((TextSelectionPoint point) { + return TextSelectionPoint(box.localToGlobal(point.point), point.direction); + }).toList(); + } + + setUp(() async { + debugResetSemanticsIdCounter(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + Widget selectableTextBuilder({String text = '', int? maxLines = 1, int? minLines}) { + return boilerplate( + child: SelectableText( + text, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: maxLines, + minLines: minLines, + ), + ); + } + + testWidgets( + 'throw if no Overlay widget exists above', + experimentalLeakTesting: LeakTesting.settings + .withIgnoredAll(), // leaking by design because of exception + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: Material(child: SelectableText('I love Flutter!'))), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(SelectableText)); + final TestGesture gesture = await tester.startGesture( + textFieldStart, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final error = tester.takeException() as FlutterError; + expect(error.message, contains('EditableText widgets require an Overlay widget ancestor')); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(tester.takeException(), isNotNull); // side effect exception + }, + ); + + testWidgets('Do not crash when remove SelectableText during handle drag', ( + WidgetTester tester, + ) async { + // Regression test https://github.com/flutter/flutter/issues/108242 + var isShow = true; + late StateSetter setter; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + setter = setState; + if (isShow) { + return const SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the left handle to the left. + final Offset handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 1); + final Offset newHandlePos1 = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + + // Unmount the SelectableText during handle drag. + setter(() { + isShow = false; + }); + await tester.pump(); + + await gesture.moveTo(newHandlePos1); + await tester.pump(); // Do not crash here. + + await gesture.up(); + await tester.pump(); + }); + + testWidgets('has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(child: const SelectableText('selectable text'))); + + final SelectableText selectableText = tester.firstWidget(find.byType(SelectableText)); + expect(selectableText.showCursor, false); + expect(selectableText.autofocus, false); + expect(selectableText.dragStartBehavior, DragStartBehavior.start); + expect(selectableText.cursorWidth, 2.0); + expect(selectableText.cursorHeight, isNull); + expect(selectableText.enableInteractiveSelection, true); + }); + + testWidgets('Rich selectable text has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText.rich( + TextSpan( + text: 'First line!', + style: TextStyle(fontSize: 14, fontFamily: 'Roboto'), + children: <TextSpan>[ + TextSpan( + text: 'Second line!\n', + style: TextStyle(fontSize: 30, fontFamily: 'Roboto'), + ), + TextSpan( + text: 'Third line!\n', + style: TextStyle(fontSize: 14, fontFamily: 'Roboto'), + ), + ], + ), + ), + ), + ), + ); + + final SelectableText selectableText = tester.firstWidget(find.byType(SelectableText)); + expect(selectableText.showCursor, false); + expect(selectableText.autofocus, false); + expect(selectableText.dragStartBehavior, DragStartBehavior.start); + expect(selectableText.cursorWidth, 2.0); + expect(selectableText.cursorHeight, isNull); + expect(selectableText.enableInteractiveSelection, true); + }); + + testWidgets('Rich selectable text supports WidgetSpan', (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: SelectableText.rich( + TextSpan( + text: 'First line!', + style: TextStyle(fontSize: 14, fontFamily: 'Roboto'), + children: <InlineSpan>[ + WidgetSpan( + child: SizedBox( + width: 120, + height: 50, + child: Card(child: Center(child: Text('Hello World!'))), + ), + ), + TextSpan( + text: 'Third line!\n', + style: TextStyle(fontSize: 14, fontFamily: 'Roboto'), + ), + ], + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('no text keyboard when widget is focused', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('selectable text'))); + await tester.tap(find.byType(SelectableText)); + await tester.idle(); + expect(tester.testTextInput.hasAnyClients, false); + }); + + testWidgets('uses DefaultSelectionStyle for selection and cursor colors if provided', ( + WidgetTester tester, + ) async { + const Color selectionColor = Colors.orange; + const Color cursorColor = Colors.red; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: DefaultSelectionStyle( + selectionColor: selectionColor, + cursorColor: cursorColor, + child: SelectableText('text'), + ), + ), + ), + ); + await tester.pump(); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.widget.selectionColor, selectionColor); + expect(state.widget.cursorColor, cursorColor); + }); + + testWidgets('Selectable Text can have custom selection color', (WidgetTester tester) async { + const Color selectionColor = Colors.orange; + const Color defaultSelectionColor = Colors.red; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: DefaultSelectionStyle( + selectionColor: defaultSelectionColor, + child: SelectableText('text', selectionColor: selectionColor), + ), + ), + ), + ); + await tester.pump(); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.widget.selectionColor, selectionColor); + }); + + testWidgets('Selectable Text has adaptive size', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(child: const SelectableText('s'))); + + RenderBox findSelectableTextBox() => tester.renderObject(find.byType(SelectableText)); + + final RenderBox textBox = findSelectableTextBox(); + expect(textBox.size, const Size(17.0, 14.0)); + + await tester.pumpWidget(boilerplate(child: const SelectableText('very very long'))); + + final RenderBox longtextBox = findSelectableTextBox(); + expect(longtextBox.size, const Size(199.0, 14.0)); + }); + + testWidgets('can scale with textScaleFactor', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(child: const SelectableText('selectable text'))); + + final RenderBox renderBox = tester.renderObject(find.byType(SelectableText)); + expect(renderBox.size.height, 14.0); + + await tester.pumpWidget( + boilerplate(child: const SelectableText('selectable text', textScaleFactor: 1.9)), + ); + + final RenderBox scaledBox = tester.renderObject(find.byType(SelectableText)); + expect(scaledBox.size.height, 27.0); + }); + + testWidgets('can switch between textWidthBasis', (WidgetTester tester) async { + RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); + const text = 'I can face roll keyboardkeyboardaszzaaaaszzaaaaszzaaaaszzaaaa'; + await tester.pumpWidget( + boilerplate(child: const SelectableText(text, textWidthBasis: TextWidthBasis.parent)), + ); + RenderBox textBox = findTextBox(); + expect(textBox.size, const Size(800.0, 28.0)); + + await tester.pumpWidget( + boilerplate(child: const SelectableText(text, textWidthBasis: TextWidthBasis.longestLine)), + ); + textBox = findTextBox(); + expect(textBox.size, const Size(633.0, 28.0)); + }); + + testWidgets('can switch between textHeightBehavior', (WidgetTester tester) async { + const text = 'selectable text'; + const textHeightBehavior = TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ); + await tester.pumpWidget(boilerplate(child: const SelectableText(text))); + expect(findRenderEditable(tester).textHeightBehavior, isNull); + + await tester.pumpWidget( + boilerplate(child: const SelectableText(text, textHeightBehavior: textHeightBehavior)), + ); + expect(findRenderEditable(tester).textHeightBehavior, textHeightBehavior); + }); + + testWidgets('Cursor blinks when showCursor is true', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('some text', showCursor: true))); + await tester.tap(find.byType(SelectableText)); + await tester.idle(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + + // Check that the cursor visibility toggles after each blink interval. + final bool initialShowCursor = editableText.cursorCurrentlyVisible; + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval ~/ 10); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + }); + + testWidgets('selectable text selection toolbar renders correctly inside opacity', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: Opacity(opacity: 0.5, child: SelectableText('selectable text')), + ), + ), + ), + ), + ); + + // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap); + + expect(state.showToolbar(), true); + + // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible. + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Select all'), findsOneWidget); + }); + + testWidgets('Caret position is updated on tap', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('abc def ghi'))); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + + // Tap to reposition the caret. + const tapIndex = 4; + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(editableText.controller.selection.baseOffset, tapIndex); + expect(editableText.controller.selection.extentOffset, tapIndex); + }); + + testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async { + await tester.pumpWidget( + overlay(child: const SelectableText('abc def ghi', enableInteractiveSelection: false)), + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + + // Tap would ordinarily reposition the caret. + const tapIndex = 4; + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + }); + + testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { + await tester.pumpWidget( + overlay(child: const SelectableText('abc def ghi', enableInteractiveSelection: false)), + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + expect(editableText.controller.selection.isCollapsed, true); + expect(editableText.controller.selection.baseOffset, -1); + expect(editableText.controller.selection.extentOffset, -1); + }); + + testWidgets('Can long press to select', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('abc def ghi'))); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + + expect(editableText.controller.selection.isCollapsed, true); + + // Long press the 'e' to select 'def'. + const tapIndex = 5; + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.longPressAt(ePos); + await tester.pump(); + + // 'def' is selected. + expect(editableText.controller.selection.baseOffset, 4); + expect(editableText.controller.selection.extentOffset, 7); + + // Tapping elsewhere immediately collapses and moves the cursor. + await tester.tapAt(textOffsetToPosition(tester, 9)); + await tester.pump(); + + expect(editableText.controller.selection.isCollapsed, true); + expect(editableText.controller.selection.baseOffset, 9); + }); + + testWidgets("Slight movements in longpress don't hide/show handles", (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('abc def ghi'))); + // Long press the 'e' to select 'def', but don't release the gesture. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + // Handles are shown. + final Finder fadeFinder = find.byType(FadeTransition); + expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar + FadeTransition handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + + // Move the gesture very slightly. + await gesture.moveBy(const Offset(1.0, 1.0)); + await tester.pump(SelectionOverlay.fadeDuration * 0.5); + handle = tester.widget(fadeFinder.at(0)); + + // The handle should still be fully opaque. + expect(handle.opacity.value, equals(1.0)); + }); + + testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('abc def ghi'))); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + + // Long press the 'e' using a mouse device. + const eIndex = 5; + final Offset ePos = textOffsetToPosition(tester, eIndex); + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The cursor is placed just like a regular tap. + expect(editableText.controller.selection.baseOffset, eIndex); + expect(editableText.controller.selection.extentOffset, eIndex); + }); + + testWidgets('selectable text basic', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('selectable'))); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + // Selectable text cannot open keyboard. + await tester.showKeyboard(find.byType(SelectableText)); + expect(tester.testTextInput.hasAnyClients, false); + await skipPastScrollingAnimation(tester); + + expect(editableTextWidget.controller.selection.isCollapsed, true); + + await tester.tap(find.byType(SelectableText)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + // Collapse selection should not paint. + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + // Long press on the 't' character of text 'selectable' to show context menu. + const dIndex = 5; + final Offset dPos = textOffsetToPosition(tester, dIndex); + await tester.longPressAt(dPos); + await tester.pump(); + + // Context menu should not have paste and cut. + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + }); + + testWidgets('selectable text can disable toolbar options', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + 'a selectable text', + toolbarOptions: ToolbarOptions(selectAll: true), + ), + ), + ); + const dIndex = 5; + final Offset dPos = textOffsetToPosition(tester, dIndex); + await tester.longPressAt(dPos); + await tester.pump(); + // Context menu should not have copy. + expect(find.text('Copy'), findsNothing); + expect(find.text('Select all'), findsOneWidget); + }); + + testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi', dragStartBehavior: DragStartBehavior.down), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 8); + }); + + testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(fontSize: 10.0), + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + var selectionChangedCount = 0; + + controller.addListener(() { + selectionChangedCount++; + }); + + final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'. + final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'. + final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'. + + // Drag from 'c' to 'g'. + final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, isNonZero); + selectionChangedCount = 0; + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 8); + + // Tiny movement shouldn't cause text selection to change. + await gesture.moveTo(gPos + const Offset(4.0, 0.0)); + await tester.pumpAndSettle(); + expect(selectionChangedCount, 0); + + // Now a text selection change will occur after a significant movement. + await gesture.moveTo(hPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, 1); + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 9); + }); + + testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi', dragStartBehavior: DragStartBehavior.down), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 5); + }); + + testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi', dragStartBehavior: DragStartBehavior.down), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 8); + }); + + testWidgets('Can drag handles to change selection', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi', dragStartBehavior: DragStartBehavior.down), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, 11); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Drag the left handle 2 letters to the left. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + }); + + testWidgets('Dragging handles calls onSelectionChanged', (WidgetTester tester) async { + TextSelection? newSelection; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + dragStartBehavior: DragStartBehavior.down, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + expect(newSelection, isNull); + newSelection = selection; + }, + ), + ), + ), + ); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + expect(newSelection!.baseOffset, 4); + expect(newSelection!.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(newSelection!), + renderEditable, + ); + expect(endpoints.length, 2); + newSelection = null; + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 9); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(newSelection!.baseOffset, 4); + expect(newSelection!.extentOffset, 9); + }); + + testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SelectableText('abc def ghi', dragStartBehavior: DragStartBehavior.down), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); // Position before 'e'. + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle until there's only 1 char selected. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0); + Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'. + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 5); + + newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'. + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + // The selection doesn't move beyond the left handle. There's always at + // least 1 char selected. + expect(controller.selection.extentOffset, 5); + }); + + testWidgets('Can use selection toolbar', (WidgetTester tester) async { + const testValue = 'abc def ghi'; + await tester.pumpWidget(const MaterialApp(home: Material(child: SelectableText(testValue)))); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + // Tapping on the part of the handle's GestureDetector where it overlaps + // with the text itself does not show the menu, so add a small vertical + // offset to tap below the text. + await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // Select all should select all the text. + await tester.tap(find.text('Select all')); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, testValue.length); + + // Copy should reset the selection. + await tester.tap(find.text('Copy')); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + }); + + testWidgets('Selectable height with maxLine', (WidgetTester tester) async { + await tester.pumpWidget(selectableTextBuilder()); + + RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); + + final RenderBox textBox = findTextBox(); + final Size emptyInputSize = textBox.size; + + await tester.pumpWidget(selectableTextBuilder(text: 'No wrapping here.')); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, emptyInputSize.height); + + // Even when entering multiline text, SelectableText doesn't grow. It's a single + // line input. + await tester.pumpWidget(selectableTextBuilder(text: kThreeLines)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, emptyInputSize.height); + + // maxLines: 3 makes the SelectableText 3 lines tall. + await tester.pumpWidget(selectableTextBuilder(maxLines: 3)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, greaterThan(emptyInputSize.height)); + + final Size threeLineInputSize = textBox.size; + + // Filling with 3 lines of text stays the same size. + await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: 3)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, threeLineInputSize.height); + + // An extra line won't increase the size because we max at 3. + await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 3)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, threeLineInputSize.height); + + // But now it will... but it will max at four. + await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: 4)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, greaterThan(threeLineInputSize.height)); + + final Size fourLineInputSize = textBox.size; + + // Now it won't max out until the end. + await tester.pumpWidget(selectableTextBuilder(maxLines: null)); + expect(findTextBox(), equals(textBox)); + expect(textBox.size, equals(emptyInputSize)); + await tester.pumpWidget(selectableTextBuilder(text: kThreeLines, maxLines: null)); + expect(textBox.size.height, equals(threeLineInputSize.height)); + await tester.pumpWidget(selectableTextBuilder(text: kMoreThanFourLines, maxLines: null)); + expect(textBox.size.height, greaterThan(fourLineInputSize.height)); + }); + + testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async { + const testValue = kThreeLines; + await tester.pumpWidget( + overlay( + child: const SelectableText( + testValue, + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 3, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + + // Check that the text spans multiple lines. + final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); + final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); + final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); + final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst')); + + expect(firstPos.dx, 24.5); + expect(secondPos.dx, 24.5); + expect(thirdPos.dx, 24.5); + expect(middleStringPos.dx, 58.5); + expect(firstPos.dx, secondPos.dx); + expect(firstPos.dx, thirdPos.dx); + expect(firstPos.dy, lessThan(secondPos.dy)); + expect(secondPos.dy, lessThan(thirdPos.dy)); + + // Long press the 'n' in 'until' to select the word. + final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until') + 1); + TestGesture gesture = await tester.startGesture(untilPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + expect(controller.selection.baseOffset, 39); + expect(controller.selection.extentOffset, 44); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle to the third line, just after 'Third'. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + // The distance below the y value returned by textOffsetToPosition required + // to register a full vertical line drag. + const downLineOffset = Offset(0.0, 3.0); + Offset newHandlePos = + textOffsetToPosition(tester, testValue.indexOf('Third') + 5) + downLineOffset; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 39, extentOffset: 50)); + + // Drag the left handle to the first line, just after 'First'. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 50); + await tester.tap(find.text('Copy')); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + }); + + testWidgets('Can scroll multiline input', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SelectableText( + kMoreThanFourLines, + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText)); + final TextEditingController controller = editableTextWidget.controller; + RenderBox findInputBox() => tester.renderObject(find.byType(SelectableText)); + final RenderBox inputBox = findInputBox(); + + // Check that the last line of text is not displayed. + final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(firstPos.dx, 0.0); + expect(fourthPos.dx, 0.0); + expect(firstPos.dx, fourthPos.dx); + expect(firstPos.dy, lessThan(fourthPos.dy)); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), + isTrue, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), + isFalse, + ); + + TestGesture gesture = await tester.startGesture(firstPos, pointer: 7); + await tester.pump(); + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pump(const Duration(seconds: 1)); + // Wait and drag again to trigger https://github.com/flutter/flutter/issues/6329 + // (No idea why this is necessary, but the bug wouldn't repro without it.) + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Now the first line is scrolled up, and the fourth line is visible. + Offset newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + Offset newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + + expect(newFirstPos.dy, lessThan(firstPos.dy)); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), + isFalse, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), + isTrue, + ); + + // Now try scrolling by dragging the selection handle. + // Long press the middle of the word "won't" in the fourth line. + final Offset selectedWordPos = textOffsetToPosition( + tester, + kMoreThanFourLines.indexOf('Fourth line') + 14, + ); + + gesture = await tester.startGesture(selectedWordPos, pointer: 7); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(controller.selection.base.offset, 77); + expect(controller.selection.extent.offset, 82); + // Sanity check for the word selected is the intended one. + expect( + controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset), + "won't", + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the left handle to the first line, just after 'First'. + final Offset handlePos = endpoints[0].point + const Offset(-1, 1); + final Offset newHandlePos = textOffsetToPosition( + tester, + kMoreThanFourLines.indexOf('First') + 5, + ); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveTo(newHandlePos + const Offset(0.0, -10.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + // The text should have scrolled up with the handle to keep the active + // cursor visible, back to its original position. + newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(newFirstPos.dy, firstPos.dy); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), + isTrue, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), + isFalse, + ); + }); + + testWidgets('ScrollBehavior can be overridden', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const SelectableText( + kMoreThanFourLines, + dragStartBehavior: DragStartBehavior.down, + style: TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + ), + ), + ); + expect(tester.widget<EditableText>(find.byType(EditableText)).scrollBehavior, isNull); + expect(tester.widget<Scrollable>(find.byType(Scrollable)).scrollBehavior, isNotNull); + + final behavior = const ScrollBehavior()..copyWith(scrollbars: false); + await tester.pumpWidget( + boilerplate( + child: SelectableText( + kMoreThanFourLines, + dragStartBehavior: DragStartBehavior.down, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + scrollBehavior: behavior, + ), + ), + ); + expect(tester.widget<EditableText>(find.byType(EditableText)).scrollBehavior, equals(behavior)); + expect(tester.widget<Scrollable>(find.byType(Scrollable)).scrollBehavior, equals(behavior)); + }); + + testWidgets('minLines cannot be greater than maxLines', (WidgetTester tester) async { + expect( + () async { + await tester.pumpWidget( + overlay( + child: SizedBox(width: 300.0, child: SelectableText('abcd', minLines: 4, maxLines: 3)), + ), + ); + }, + throwsA( + isA<AssertionError>().having( + (AssertionError error) => error.toString(), + '.toString()', + contains("minLines can't be greater than maxLines"), + ), + ), + ); + }); + + testWidgets('Selectable height with minLine', (WidgetTester tester) async { + await tester.pumpWidget(selectableTextBuilder()); + + RenderBox findTextBox() => tester.renderObject(find.byType(SelectableText)); + + final RenderBox textBox = findTextBox(); + final Size emptyInputSize = textBox.size; + + // Even if the text is a one liner, minimum height of SelectableText will determined by minLines. + await tester.pumpWidget( + selectableTextBuilder(text: 'No wrapping here.', minLines: 2, maxLines: 3), + ); + expect(findTextBox(), equals(textBox)); + expect(textBox.size.height, emptyInputSize.height * 2); + }); + + testWidgets('Can align to center', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SizedBox( + width: 300.0, + child: SelectableText('abcd', textAlign: TextAlign.center), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + final Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + + expect(topLeft.dx, equals(399.0)); + }); + + testWidgets('Can align to center within center', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SizedBox( + width: 300.0, + child: Center(child: SelectableText('abcd', textAlign: TextAlign.center)), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + final Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + + expect(topLeft.dx, equals(399.0)); + }); + + testWidgets('Tapping outside SelectableText clears the selection', (WidgetTester tester) async { + Future<void> setAppLifecycleState(AppLifecycleState state) async { + final ByteData? message = const StringCodec().encodeMessage(state.toString()); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/lifecycle', + message, + (_) {}, + ); + } + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: Column( + children: <Widget>[ + SelectableText('first selectable text'), + SelectableText('second selectable text'), + ], + ), + ), + ), + ), + ); + // Setting the app lifecycle state to AppLifecycleState.resumed to simulate + // an applications default running mode, i.e. the application window is focused. + await setAppLifecycleState(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + // First tap on the first SelectableText sets the cursor. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + + final EditableText editableTextWidgetFirst = tester.widget(find.byType(EditableText).first); + final TextEditingController controllerA = editableTextWidgetFirst.controller; + final EditableText editableTextWidgetSecond = tester.widget(find.byType(EditableText).last); + final TextEditingController controllerB = editableTextWidgetSecond.controller; + + expect(controllerA.selection, const TextSelection.collapsed(offset: 5)); + expect(controllerB.selection, TextRange.empty); + + // Tapping on the second SelectableText sets the cursor on it, and clears the selection from + // the first SelectableText. + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText).last); + await tester.tapAt(selectableTextStart); + await tester.pumpAndSettle(); + expect(controllerA.selection, TextRange.empty); + expect(controllerB.selection, const TextSelection.collapsed(offset: 0)); + + // Setting the app lifecycle state to AppLifecycleState.inactive to simulate + // a lose of window focus. Selection should remain the same. + await setAppLifecycleState(AppLifecycleState.inactive); + await tester.pumpAndSettle(); + expect(controllerA.selection, TextRange.empty); + expect(controllerB.selection, const TextSelection.collapsed(offset: 0)); + }); + + testWidgets('Selectable text is skipped during focus traversal', (WidgetTester tester) async { + final firstFieldFocus = FocusNode(); + addTearDown(firstFieldFocus.dispose); + final lastFieldFocus = FocusNode(); + addTearDown(lastFieldFocus.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Column( + children: <Widget>[ + TextField(focusNode: firstFieldFocus, autofocus: true), + const SelectableText('some text'), + TextField(focusNode: lastFieldFocus), + ], + ), + ), + ), + ), + ); + + await tester.pump(); + + expect(firstFieldFocus.hasFocus, isTrue); + expect(lastFieldFocus.hasFocus, isFalse); + + firstFieldFocus.nextFocus(); + await tester.pump(); + + // Expecting focus to skip straight to the second field. + expect(firstFieldFocus.hasFocus, isFalse); + expect(lastFieldFocus.hasFocus, isTrue); + }); + + testWidgets('Selectable text identifies as text field in semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: SelectableText('some text'))), + ), + ); + + expect( + semantics, + includesNodeWith( + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isReadOnly, + SemanticsFlag.isMultiline, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets('Selectable text rich text with spell out in semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText.rich(TextSpan(text: 'some text', spellOut: true))), + ), + ), + ); + + expect( + semantics, + includesNodeWith( + attributedValue: AttributedString( + 'some text', + attributes: <StringAttribute>[ + SpellOutStringAttribute(range: const TextRange(start: 0, end: 9)), + ], + ), + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isReadOnly, + SemanticsFlag.isMultiline, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets('Selectable text rich text with locale in semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText.rich(TextSpan(text: 'some text', locale: Locale('es', 'MX'))), + ), + ), + ), + ); + + expect( + semantics, + includesNodeWith( + attributedValue: AttributedString( + 'some text', + attributes: <StringAttribute>[ + LocaleStringAttribute( + range: const TextRange(start: 0, end: 9), + locale: const Locale('es', 'MX'), + ), + ], + ), + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isReadOnly, + SemanticsFlag.isMultiline, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets('Selectable rich text with gesture recognizer has correct semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final recognizer = TapGestureRecognizer(); + addTearDown(recognizer.dispose); + + await tester.pumpWidget( + overlay( + child: SelectableText.rich( + TextSpan( + children: <TextSpan>[ + const TextSpan(text: 'text'), + TextSpan(text: 'link', recognizer: recognizer..onTap = () {}), + ], + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.longPress], + inputType: ui.SemanticsInputType.text, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics(label: 'text', textDirection: TextDirection.ltr), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isLink], + actions: <SemanticsAction>[SemanticsAction.tap], + label: 'link', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + group('Keyboard Tests', () { + late TextEditingController controller; + + Future<void> setupWidget(WidgetTester tester, String text) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + child: SelectableText(text, maxLines: 3), + ), + ), + ), + ); + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + controller = editableTextWidget.controller; + } + + testWidgets('Shift test 1', (WidgetTester tester) async { + await setupWidget(tester, 'a big house'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + expect(controller.selection.extentOffset - controller.selection.baseOffset, -1); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Shift test 2', (WidgetTester tester) async { + await setupWidget(tester, 'abcdefghi'); + + controller.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Control Shift test', (WidgetTester tester) async { + await setupWidget(tester, 'their big house'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, -5); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Down and up test', (WidgetTester tester) async { + await setupWidget(tester, 'a big house'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, -11); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Down and up test 2', (WidgetTester tester) async { + await setupWidget(tester, 'a big house\njumped over a mouse\nOne more line yay'); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 32); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, -5); + }, variant: KeySimulatorTransitModeVariant.all()); + }); + + testWidgets('Copy test', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + var clipboardContent = ''; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'Clipboard.setData') { + clipboardContent = (methodCall.arguments as Map<String, dynamic>)['text'] as String; + } else if (methodCall.method == 'Clipboard.getData') { + return <String, dynamic>{'text': clipboardContent}; + } + return null; + }); + const testValue = 'a big house\njumped over a mouse'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + child: const SelectableText(testValue, maxLines: 3), + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + // Select the first 5 characters. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + // Copy them. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); + await tester.pumpAndSettle(); + + expect(clipboardContent, 'a big'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Select all test', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + const testValue = 'a big house\njumped over a mouse'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + child: const SelectableText(testValue, maxLines: 3), + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + + // Select All. + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 31); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('keyboard selection should call onSelectionChanged', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + TextSelection? newSelection; + const testValue = 'a big house\njumped over a mouse'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: RawKeyboardListener( + focusNode: focusNode, + child: SelectableText( + testValue, + maxLines: 3, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + expect(newSelection, isNull); + newSelection = selection; + }, + ), + ), + ), + ), + ); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.byType(SelectableText)); + await tester.pumpAndSettle(); + expect(newSelection!.baseOffset, 31); + expect(newSelection!.extentOffset, 31); + newSelection = null; + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + // Select the first 5 characters. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(newSelection!.baseOffset, 0); + expect(newSelection!.extentOffset, i + 1); + newSelection = null; + } + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Changing positions of selectable text', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final events = <KeyEvent>[]; + + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + onKeyEvent: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + SelectableText('a big house', key: key1, maxLines: 3), + SelectableText('another big house', key: key2, maxLines: 3), + ], + ), + ), + ), + ), + ); + + EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + TextEditingController c1 = editableTextWidget.controller; + + await tester.tap(find.byType(EditableText).first); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + onKeyEvent: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + SelectableText('another big house', key: key2, maxLines: 3), + SelectableText('a big house', key: key1, maxLines: 3), + ], + ), + ), + ), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + editableTextWidget = tester.widget(find.byType(EditableText).last); + c1 = editableTextWidget.controller; + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -10); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Changing focus test', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final events = <KeyEvent>[]; + + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + onKeyEvent: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + SelectableText('a big house', key: key1, maxLines: 3), + SelectableText('another big house', key: key2, maxLines: 3), + ], + ), + ), + ), + ), + ); + + final EditableText editableTextWidget1 = tester.widget(find.byType(EditableText).first); + final TextEditingController c1 = editableTextWidget1.controller; + + final EditableText editableTextWidget2 = tester.widget(find.byType(EditableText).last); + final TextEditingController c2 = editableTextWidget2.controller; + + await tester.tap(find.byType(SelectableText).first); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); + expect(c2.selection.extentOffset - c2.selection.baseOffset, 0); + + await tester.tap(find.byType(SelectableText).last); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); + expect(c2.selection.extentOffset - c2.selection.baseOffset, -5); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Caret works when maxLines is null', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const SelectableText('x'))); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection.baseOffset, -1); + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is + + // Confirm that the selection was updated. + expect(controller.selection.baseOffset, 0); + }); + + testWidgets('SelectableText baseline alignment no-strut', (WidgetTester tester) async { + final Key keyA = UniqueKey(); + final Key keyB = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + Expanded( + child: SelectableText( + 'A', + key: keyA, + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 10.0), + strutStyle: StrutStyle.disabled, + ), + ), + const Text('abc', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + Expanded( + child: SelectableText( + 'B', + key: keyB, + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 30.0), + strutStyle: StrutStyle.disabled, + ), + ), + ], + ), + ), + ); + + // The test font extends 0.25 * fontSize below the baseline. + // So the three row elements line up like this: + // + // A abc B + // ------------- baseline + // 2.5 5 7.5 space below the baseline = 0.25 * fontSize + // ------------- rowBottomY + + final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; + expect(tester.getBottomLeft(find.byKey(keyA)).dy, rowBottomY - 5.0); + expect(tester.getBottomLeft(find.text('abc')).dy, rowBottomY - 2.5); + expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); + }); + + testWidgets('SelectableText baseline alignment', (WidgetTester tester) async { + final Key keyA = UniqueKey(); + final Key keyB = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + Expanded( + child: SelectableText( + 'A', + key: keyA, + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 10.0), + ), + ), + const Text('abc', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0)), + Expanded( + child: SelectableText( + 'B', + key: keyB, + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 30.0), + ), + ), + ], + ), + ), + ); + + // The test font extends 0.25 * fontSize below the baseline. + // So the three row elements line up like this: + // + // A abc B + // ------------- baseline + // 2.5 5 7.5 space below the baseline = 0.25 * fontSize + // ------------- rowBottomY + + final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; + expect(tester.getBottomLeft(find.byKey(keyA)).dy, rowBottomY - 5.0); + expect(tester.getBottomLeft(find.text('abc')).dy, rowBottomY - 2.5); + expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); + }); + + testWidgets('SelectableText semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget(overlay(child: SelectableText('Guten Tag', key: key))); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics.rootChild( + textDirection: TextDirection.ltr, + value: 'Guten Tag', + actions: <SemanticsAction>[SemanticsAction.longPress], + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isReadOnly, + SemanticsFlag.isMultiline, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + controller.selection = const TextSelection.collapsed(offset: 9); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + value: 'Guten Tag', + textSelection: const TextSelection.collapsed(offset: 9), + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.selection = const TextSelection.collapsed(offset: 4); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 4), + value: 'Guten Tag', + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 0), + value: 'Guten Tag', + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SelectableText semantics, with semanticsLabel', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: SelectableText( + 'Guten Tag', + semanticsLabel: 'German greeting for good day', + key: key, + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.longPress], + label: 'German greeting for good day', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('SelectableText semantics, enableInteractiveSelection = false', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay(child: SelectableText('Guten Tag', key: key, enableInteractiveSelection: false)), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Guten Tag', + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + // Absent the following because enableInteractiveSelection: false + // SemanticsAction.moveCursorBackwardByCharacter, + // SemanticsAction.moveCursorBackwardByWord, + // SemanticsAction.setSelection, + // SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + // SelectableText act like a text widget when enableInteractiveSelection + // is false. It will not respond to any pointer event. + // SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SelectableText semantics for selections', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final Key key = UniqueKey(); + + await tester.pumpWidget(overlay(child: SelectableText('Hello', key: key))); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Hello', + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[SemanticsAction.longPress], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + // Focus the selectable text + await tester.tap(find.byKey(key)); + await tester.pump(); + + controller.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Hello', + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Hello', + textSelection: const TextSelection(baseOffset: 5, extentOffset: 3), + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + SemanticsAction.copy, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('semantic nodes of offscreen recognizers are marked hidden', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/100395. + final semantics = SemanticsTester(tester); + const textStyle = TextStyle(fontSize: 200); + const onScreenText = 'onscreen\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n'; + const offScreenText = 'off screen'; + final controller = ScrollController(); + addTearDown(controller.dispose); + final recognizer = TapGestureRecognizer(); + addTearDown(recognizer.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + controller: controller, + child: SelectableText.rich( + TextSpan( + children: <TextSpan>[ + const TextSpan(text: onScreenText), + TextSpan(text: offScreenText, recognizer: recognizer..onTap = () {}), + ], + style: textStyle, + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ); + + final expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollToOffset, + ], + children: <TestSemantics>[ + TestSemantics( + actions: <SemanticsAction>[SemanticsAction.longPress], + inputType: ui.SemanticsInputType.text, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'onscreen\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isHidden, + SemanticsFlag.isLink, + ], + actions: <SemanticsAction>[SemanticsAction.tap], + label: 'off screen', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true), + ); + + // Test shows on screen. + expect(controller.offset, 0.0); + tester.binding.pipelineOwner.semanticsOwner!.performAction(8, SemanticsAction.showOnScreen); + await tester.pumpAndSettle(); + expect(controller.offset != 0.0, isTrue); + + semantics.dispose(); + }); + + testWidgets('SelectableText change selection with semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final Key key = UniqueKey(); + + await tester.pumpWidget(overlay(child: SelectableText('Hello', key: key))); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Focus the selectable text. + await tester.tap(find.byKey(key)); + await tester.pump(); + + controller.selection = const TextSelection(baseOffset: 5, extentOffset: 5); + await tester.pump(); + + const inputFieldId = 2; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + value: 'Hello', + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + // Move cursor back once. + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ + 'base': 4, + 'extent': 4, + }); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + + // Move cursor to front. + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ + 'base': 0, + 'extent': 0, + }); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + + // Select all. + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ + 'base': 0, + 'extent': 5, + }); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + value: 'Hello', + textSelection: const TextSelection(baseOffset: 0, extentOffset: 5), + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.copy, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Can activate SelectableText with explicit controller via semantics', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/17801 + + const testValue = 'Hello'; + + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final Key key = UniqueKey(); + + await tester.pumpWidget(overlay(child: SelectableText(testValue, key: key))); + + const inputFieldId = 2; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + ], + actions: <SemanticsAction>[SemanticsAction.longPress], + value: testValue, + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semanticsOwner.performAction(inputFieldId, SemanticsAction.longPress); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isReadOnly, + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.isMultiline, + SemanticsFlag.isFocused, + ], + actions: <SemanticsAction>[ + SemanticsAction.longPress, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + ], + value: testValue, + textDirection: TextDirection.ltr, + textSelection: const TextSelection( + baseOffset: testValue.length, + extentOffset: testValue.length, + ), + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + overlay( + child: SelectableText( + 'something', + onTap: () { + tapCount += 1; + }, + ), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(SelectableText)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(SelectableText)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(SelectableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + testWidgets('SelectableText style is merged with default text style', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/23994 + final defaultStyle = TextStyle(color: Colors.blue[500]); + Widget buildFrame(TextStyle style) { + return MaterialApp( + home: Material( + child: DefaultTextStyle( + style: defaultStyle, + child: Center(child: SelectableText('something', style: style)), + ), + ), + ); + } + + // Empty TextStyle is overridden by theme. + await tester.pumpWidget(buildFrame(const TextStyle())); + EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, defaultStyle.color); + expect(editableText.style.background, defaultStyle.background); + expect(editableText.style.shadows, defaultStyle.shadows); + expect(editableText.style.decoration, defaultStyle.decoration); + expect(editableText.style.locale, defaultStyle.locale); + expect(editableText.style.wordSpacing, defaultStyle.wordSpacing); + + // Properties set on TextStyle override theme. + const Color setColor = Colors.red; + await tester.pumpWidget(buildFrame(const TextStyle(color: setColor))); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, setColor); + + // inherit: false causes nothing to be merged in from theme. + await tester.pumpWidget( + buildFrame( + const TextStyle(fontSize: 24.0, textBaseline: TextBaseline.alphabetic, inherit: false), + ), + ); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, isNull); + }); + + testWidgets('style enforces required fields', (WidgetTester tester) async { + Widget buildFrame(TextStyle style) { + return MaterialApp( + home: Material(child: SelectableText('something', style: style)), + ); + } + + await tester.pumpWidget( + buildFrame( + const TextStyle(inherit: false, fontSize: 12.0, textBaseline: TextBaseline.alphabetic), + ), + ); + expect(tester.takeException(), isNull); + + // With inherit not set to false, will pickup required fields from theme. + await tester.pumpWidget(buildFrame(const TextStyle(fontSize: 12.0))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(buildFrame(const TextStyle(inherit: false, fontSize: 12.0))); + expect(tester.takeException(), isNotNull); + }); + + testWidgets( + 'tap moves cursor to the edge of the word it tapped', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + // We moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + // But don't trigger the toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'tap moves cursor to the position tapped (Android)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), + ); + + // But don't trigger the toolbar. + expect(find.byType(TextButton), findsNothing); + }, + variant: TargetPlatformVariant.all(excluding: const <TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'two slow taps do not trigger a word selection on iOS', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Plain collapsed selection. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'two slow taps do not trigger a word selection', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Plain collapsed selection. + expect( + controller.selection, + const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), + ); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + // On iOS, this moves the cursor to the closest word edge. + // On macOS, this moves the cursor to the tapped position. + expect( + controller.selection, + TextSelection.collapsed( + offset: defaultTargetPlatform == TargetPlatform.iOS ? 12 : 11, + affinity: TextAffinity.upstream, + ), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor and shows toolbar (Android)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + expectMaterialSelectionToolbar(); + }, + ); + + testWidgets('double tap on top of cursor also selects word (Android)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure'))), + ), + ); + + // Tap to put the cursor after the "w". + const index = 3; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection.collapsed(offset: index)); + + // Double tap on the same location. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + + // First tap doesn't change the selection. + expect(controller.selection, const TextSelection.collapsed(offset: index)); + + // Second tap selects the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + expectMaterialSelectionToolbar(); + }); + + testWidgets( + 'double tap hold selects word', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(150.0, 5.0), + ); + // Hold the press. + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + expectCupertinoSelectionToolbar(); + + await gesture.up(); + await tester.pump(); + + // Still selected. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + // The toolbar is still showing. + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap selects word with semantics label', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: SelectableText.rich( + TextSpan(text: 'Atwater Peel Sherbrooke Bonaventure', semanticsLabel: ''), + ), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(220.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(selectableTextStart + const Offset(220.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'tap after a double tap select is not affected (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(100.0, 5.0)); + await tester.pump(); + + // Plain collapsed selection at the edge of first word. In iOS 12, the + // first tap after a double tap ends up putting the cursor at where + // you tapped instead of the edge like every other single tap. This is + // likely a bug in iOS 12 and not present in other versions. + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'tap after a double tap select is not affected (macOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(selectableTextStart + const Offset(100.0, 5.0)); + await tester.pump(); + + // Collapse selection. + expect(controller.selection, const TextSelection.collapsed(offset: 7)); + + // No toolbar. + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + + testWidgets( + 'long press selects word and shows toolbar (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // The long pressed word is selected. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('long press selects word and shows toolbar (Android)', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure'))), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + expectMaterialSelectionToolbar(); + }); + + testWidgets( + 'long press selects word and shows custom toolbar (Cupertino)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure', + selectionControls: cupertinoTextSelectionControls, + ), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // The long pressed word is selected. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // Toolbar shows one button (copy). + expect(find.byType(CupertinoButton), findsNWidgets(1)); + expect(find.text('Copy'), findsOneWidget); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'long press selects word and shows custom toolbar (Material)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure', + selectionControls: materialTextSelectionControls, + ), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // Collapsed toolbar shows 2 buttons: copy, select all + expect(find.byType(TextButton), findsNWidgets(2)); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('textSelectionControls is passed to EditableText', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Scaffold( + body: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure', + selectionControls: materialTextSelectionControls, + ), + ), + ), + ), + ); + + final EditableText widget = tester.widget(find.byType(EditableText)); + expect(widget.selectionControls, equals(materialTextSelectionControls)); + }); + + testWidgets( + 'PageView beats SelectableText drag gestures (iOS)', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/130198. + final pageController = PageController(); + addTearDown(pageController.dispose); + const testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PageView( + controller: pageController, + children: const <Widget>[ + Center(child: SelectableText(testValue)), + SizedBox(height: 200.0, child: Center(child: Text('Page 2'))), + ], + ), + ), + ), + ); + + await skipPastScrollingAnimation(tester); + + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset pPos = textOffsetToPosition(tester, testValue.indexOf('p')); + + // A double tap + drag should take precedence over parent drags. + final TestGesture gesture = await tester.startGesture(gPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await gesture.down(gPos); + await tester.pumpAndSettle(); + await gesture.moveTo(pPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + final TextEditingValue currentValue = tester + .state<EditableTextState>(find.byType(EditableText)) + .textEditingValue; + expect( + currentValue.selection, + TextSelection(baseOffset: testValue.indexOf('g'), extentOffset: testValue.indexOf('p') + 3), + ); + + expect(pageController.page, isNotNull); + expect(pageController.page, 0.0); + // A horizontal drag directly on the SelectableText should move the page + // view to the next page. + final Rect selectableTextRect = tester.getRect(find.byType(SelectableText)); + await tester.dragFrom( + selectableTextRect.centerRight - const Offset(0.1, 0.0), + const Offset(-500.0, 0.0), + ); + await tester.pumpAndSettle(); + expect(pageController.page, isNotNull); + expect(pageController.page, 1.0); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'long press tap cannot initiate a double tap on macOS', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + // Hide the toolbar so it doesn't interfere with taps on the text. + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + editableTextState.hideToolbar(); + await tester.pumpAndSettle(); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Move the cursor to the tapped position. + expect( + controller.selection, + const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream), + ); + + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + + testWidgets( + 'long press tap cannot initiate a double tap on iOS', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + // Hide the toolbar so it doesn't interfere with taps on the text. + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + editableTextState.hideToolbar(); + await tester.pumpAndSettle(); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Move the cursor to the edge of the same word and toggle the toolbar. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + expect(find.byType(CupertinoButton), findsNWidgets(4)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'long press drag extends the selection to the word under the drag and shows toolbar on lift on non-Apple platforms', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 18)); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // Long press selects the word at the long presses position. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + // Cursor move doesn't trigger a toolbar initially. + expect(find.byType(TextButton), findsNothing); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 35)); + // Still no toolbar. + expect(find.byType(TextButton), findsNothing); + + // The selection is moved on a backwards drag. + await gesture.moveBy(const Offset(-200, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 8)); + // Still no toolbar. + expect(find.byType(TextButton), findsNothing); + + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 0)); + // Still no toolbar. + expect(find.byType(TextButton), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 0)); + + expectMaterialSelectionToolbar(); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'long press drag extends the selection to the word under the drag and shows toolbar on lift (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 18)); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // The long pressed word is selected. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + // Word select doesn't trigger a toolbar initially. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 35)); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + // The selection is moved with a backwards drag. + await gesture.moveBy(const Offset(-200, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 8)); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + // The selection is moved with a backwards drag. + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 0)); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 0)); + // The toolbar now shows up. + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'long press drag moves the cursor under the drag and shows toolbar on lift (macOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(50.0, 5.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // The long pressed word is selected. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + // Cursor move doesn't trigger a toolbar initially. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 8)); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 12)); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 12)); + // The toolbar now shows up. + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + ); + + testWidgets( + 'long press drag can edge scroll when inside a scrollable', + (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/129590. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 300.0, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges ' * 2, + maxLines: 1, + ), + ), + ), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(200.0, 0.0), + ); + await tester.pump(kLongPressTimeout); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + + await gesture.moveBy(const Offset(100, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + // Keep moving out. + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 35)); + await gesture.moveBy(const Offset(1600, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 134)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 134)); + // The toolbar shows up. + if (defaultTargetPlatform == TargetPlatform.iOS) { + expectCupertinoSelectionToolbar(); + } else { + expectMaterialSelectionToolbar(); + } + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.isNotEmpty, isTrue); + expect(endpoints.length, 2); + expect(endpoints[0].point.dx, isNegative); + expect(endpoints[1].point.dx, isPositive); + }, + // TODO(Renzo-Olivares): Add in TargetPlatform.android in the line below when + // we fix edge scrolling in a Scrollable https://github.com/flutter/flutter/issues/64059. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Desktop mouse drag can edge scroll when inside a horizontal scrollable', + (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/129590. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SizedBox( + width: 300.0, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges ' * 2, + maxLines: 1, + ), + ), + ), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(200.0, 0.0), + ); + await tester.pump(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + await gesture.moveBy(const Offset(100, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 14, extentOffset: 21)); + // Keep moving out. + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 14, extentOffset: 28)); + await gesture.moveBy(const Offset(1600, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 14, extentOffset: 134)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 14, extentOffset: 134)); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.isNotEmpty, isTrue); + expect(endpoints.length, 2); + expect(endpoints[0].point.dx, isNegative); + expect(endpoints[1].point.dx, isPositive); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'long press drag can edge scroll', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SelectableText( + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges ' * 2, + maxLines: 1, + ), + ), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(300, 5), + ); + await tester.pump(kLongPressTimeout); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + + await gesture.moveBy(const Offset(300, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 45)); + // Keep moving out. + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 66)); + await gesture.moveBy(const Offset(400, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 102)); + + await gesture.moveBy(const Offset(700, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 134)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 134)); + // The toolbar shows up. + if (defaultTargetPlatform == TargetPlatform.iOS) { + expectCupertinoSelectionToolbar(); + } else { + expectMaterialSelectionToolbar(); + } + + // Find the selection handle fade transition after the start handle has been + // hidden because it is out of view. + final List<FadeTransition> transitionsAfter = find + .descendant( + of: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay', + ), + matching: find.byType(FadeTransition), + ) + .evaluate() + .map((Element e) => e.widget) + .cast<FadeTransition>() + .toList(); + + expect(transitionsAfter.length, 2); + + final FadeTransition startHandleAfter = transitionsAfter[0]; + final FadeTransition endHandleAfter = transitionsAfter[1]; + + expect(startHandleAfter.opacity.value, 0.0); + expect(endHandleAfter.opacity.value, 1.0); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + + testWidgets( + 'long tap still selects after a double tap select (iOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor to the beginning of the second word. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.longPressAt(selectableTextStart + const Offset(100.0, 5.0)); + await tester.pump(); + + // Selected the "word" where the tap happened, which is the first space. + // Because the "word" is a whitespace, the selection will shift to the + // previous "word" that is not a whitespace. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'long tap still selects after a double tap select (macOS)', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moves the cursor to the tapped position. + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.longPressAt(selectableTextStart + const Offset(100.0, 5.0)); + await tester.pump(); + + // Selected the "word" where the tap happened, which is the first space. + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 8)); + + // The toolbar shows up. + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + ); + //convert + testWidgets( + 'double tap after a long tap is not affected', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + // Hide the toolbar so it doesn't interfere with taps on the text. + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + editableTextState.hideToolbar(); + await tester.pumpAndSettle(); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // First tap moved the cursor. + expect( + controller.selection, + TextSelection.collapsed( + offset: defaultTargetPlatform == TargetPlatform.iOS ? 12 : 11, + affinity: TextAffinity.upstream, + ), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(); + + // Double tap selection. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap chains work', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ), + ); + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + expect( + controller.selection, + TextSelection.collapsed( + offset: defaultTargetPlatform == TargetPlatform.iOS ? 7 : 4, + affinity: TextAffinity.upstream, + ), + ); + await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoSelectionToolbar(); + + // Double tap selecting the same word somewhere else is fine. + await tester.pumpAndSettle(kDoubleTapTimeout); + await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + + // First tap moved the cursor and toggled the toolbar. + expect(find.byType(CupertinoButton), findsNothing); + expect( + controller.selection, + defaultTargetPlatform == TargetPlatform.iOS + ? const TextSelection(baseOffset: 0, extentOffset: 7) + : const TextSelection.collapsed(offset: 1, affinity: TextAffinity.upstream), + ); + await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // Second tap toggled the toolbar, and on macOS also selects the word at the tapped position. + // On iOS the selection remains the same. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoSelectionToolbar(); + + // Hide the toolbar so it doesn't interfere with taps on the text. + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + editableTextState.hideToolbar(); + await tester.pumpAndSettle(); + + await tester.pumpAndSettle(kDoubleTapTimeout); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + TextSelection.collapsed( + offset: defaultTargetPlatform == TargetPlatform.iOS ? 12 : 11, + affinity: TextAffinity.upstream, + ), + ); + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('force press does not select a word on (android)', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ); + + final Offset offset = tester.getTopLeft(find.byType(SelectableText)) + const Offset(150.0, 5.0); + + final int pointerValue = tester.nextPointer; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: offset + const Offset(150.0, 5.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We don't want this gesture to select any word on Android. + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + + await gesture.up(); + await tester.pump(); + expect(find.byType(TextButton), findsNothing); + }); + + testWidgets( + 'force press selects word', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final int pointerValue = tester.nextPointer; + final Offset offset = selectableTextStart + const Offset(150.0, 5.0); + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: selectableTextStart + const Offset(150.0, 5.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // We expect the force press to select a word at the given location. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + await gesture.up(); + await tester.pump(); + expectCupertinoSelectionToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'tap on non-force-press-supported devices work', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: SelectableText('Atwater Peel Sherbrooke Bonaventure')), + ), + ); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + final int pointerValue = tester.nextPointer; + final Offset offset = selectableTextStart + const Offset(150.0, 5.0); + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + // iPhone 6 and below report 0 across the board. + pressure: 0, + pressureMax: 0, + pressureMin: 0, + ), + ); + + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: selectableTextStart + const Offset(150.0, 5.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + await gesture.up(); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + + // The event should fallback to a normal tap and move the cursor. + // Single taps selects the edge of the word. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + + await tester.pump(); + // Single taps shouldn't trigger the toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + // TODO(gspencergoog): Add in TargetPlatform.macOS in the line below when we + // figure out what global state is leaking. + // https://github.com/flutter/flutter/issues/43445 + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('default SelectableText debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + const SelectableText('something').debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>['data: something']); + }); + + testWidgets('SelectableText implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + // Not checking controller, inputFormatters, focusNode. + const SelectableText( + 'something', + style: TextStyle(color: Color(0xff00ff00)), + textAlign: TextAlign.end, + textDirection: TextDirection.ltr, + textScaler: TextScaler.noScaling, + autofocus: true, + showCursor: true, + minLines: 2, + maxLines: 10, + cursorWidth: 1.0, + cursorHeight: 1.0, + cursorRadius: Radius.zero, + cursorColor: Color(0xff00ff00), + scrollPhysics: ClampingScrollPhysics(), + scrollBehavior: ScrollBehavior(), + semanticsLabel: 'something else', + enableInteractiveSelection: false, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'data: something', + 'semanticsLabel: something else', + 'style: TextStyle(inherit: true, color: ${const Color(0xff00ff00)})', + 'autofocus: true', + 'showCursor: true', + 'minLines: 2', + 'maxLines: 10', + 'textAlign: end', + 'textDirection: ltr', + 'textScaler: no scaling', + 'cursorWidth: 1.0', + 'cursorHeight: 1.0', + 'cursorRadius: Radius.circular(0.0)', + 'cursorColor: ${const Color(0xff00ff00)}', + 'selection disabled', + 'scrollPhysics: ClampingScrollPhysics', + 'scrollBehavior: ScrollBehavior', + ]); + }); + + testWidgets('strut basic single line', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material(child: Center(child: SelectableText('something'))), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // This is the height of the decoration (24) plus the metrics from the default + // TextStyle of the theme (16). + const Size(129.0, 14.0), + ); + }); + + testWidgets('strut TextStyle increases height', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center(child: SelectableText('something', style: TextStyle(fontSize: 20))), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // Strut should inherit the TextStyle.fontSize by default and produce the + // same height as if it were disabled. + const Size(183.0, 20.0), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: SelectableText( + 'something', + style: TextStyle(fontSize: 20), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // The height here should match the previous version with strut enabled. + const Size(183.0, 20.0), + ); + }); + + testWidgets('strut basic multi line', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material(child: Center(child: SelectableText('something', maxLines: 6))), + ), + ); + + expect(tester.getSize(find.byType(SelectableText)), const Size(129.0, 84.0)); + }); + + testWidgets('strut no force small strut', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 6, + strutStyle: StrutStyle( + // The small strut is overtaken by the larger + // TextStyle fontSize. + fontSize: 5, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // When the strut's height is smaller than TextStyle's and forceStrutHeight + // is disabled, then the TextStyle takes precedence. Should be the same height + // as 'strut basic multi line'. + const Size(129.0, 84.0), + ); + }); + + testWidgets('strut no force large strut', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: SelectableText('something', maxLines: 6, strutStyle: StrutStyle(fontSize: 25)), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // When the strut's height is larger than TextStyle's and forceStrutHeight + // is disabled, then the StrutStyle takes precedence. + const Size(129.0, 150.0), + ); + }); + + testWidgets('strut height override', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 3, + strutStyle: StrutStyle(fontSize: 8, forceStrutHeight: true), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // The smaller font size of strut make the field shorter than normal. + const Size(129.0, 24.0), + ); + }); + + testWidgets('strut forces field taller', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: SelectableText( + 'something', + maxLines: 3, + style: TextStyle(fontSize: 10), + strutStyle: StrutStyle(fontSize: 18, forceStrutHeight: true), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(SelectableText)), + // When the strut fontSize is larger than a provided TextStyle, the + // strut's height takes precedence. + const Size(93.0, 54.0), + ); + }); + + testWidgets('Caret center position', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SizedBox( + width: 300.0, + child: SelectableText('abcd', textAlign: TextAlign.center), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft, + ); + expect(topLeft.dx, equals(427)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft, + ); + expect(topLeft.dx, equals(413)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + expect(topLeft.dx, equals(399)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft, + ); + expect(topLeft.dx, equals(385)); + }); + + testWidgets('Caret indexes into trailing whitespace center align', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SizedBox( + width: 300.0, + child: SelectableText('abcd ', textAlign: TextAlign.center), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 7)).topLeft, + ); + expect(topLeft.dx, equals(469)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 8)).topLeft, + ); + expect(topLeft.dx, equals(483)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft, + ); + expect(topLeft.dx, equals(427)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft, + ); + expect(topLeft.dx, equals(413)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + expect(topLeft.dx, equals(399)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft, + ); + expect(topLeft.dx, equals(385)); + }); + + testWidgets('selection handles are rendered and not faded away', (WidgetTester tester) async { + const testText = 'lorem ipsum'; + await tester.pumpWidget(const MaterialApp(home: Material(child: SelectableText(testText)))); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List<FadeTransition> transitions = find + .descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), + matching: find.byType(FadeTransition), + ) + .evaluate() + .map((Element e) => e.widget) + .cast<FadeTransition>() + .toList(); + expect(transitions.length, 2); + final FadeTransition left = transitions[0]; + final FadeTransition right = transitions[1]; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }); + + testWidgets( + 'selection handles are rendered and not faded away', + (WidgetTester tester) async { + const testText = 'lorem ipsum'; + + await tester.pumpWidget(const MaterialApp(home: Material(child: SelectableText(testText)))); + + final RenderEditable renderEditable = tester + .state<EditableTextState>(find.byType(EditableText)) + .renderEditable; + + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List<Widget> transitions = find + .byType(FadeTransition) + .evaluate() + .map((Element e) => e.widget) + .toList(); + expect(transitions.length, 2); + final left = transitions[0] as FadeTransition; + final right = transitions[1] as FadeTransition; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('Long press shows handles and toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Long press at 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.longPressAt(ePos); + await tester.pumpAndSettle(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect(editableText.selectionOverlay!.toolbarIsVisible, isTrue); + }); + + testWidgets('Double tap shows handles and toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Double tap at 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(ePos); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect(editableText.selectionOverlay!.toolbarIsVisible, isTrue); + }); + + testWidgets('Mouse tap does not show handles nor toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Long press to trigger the selectable text. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture( + ePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('Mouse long press does not show handles nor toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Long press to trigger the selectable text. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture( + ePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('Mouse double tap does not show handles nor toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Double tap to trigger the selectable text. + final Offset selectableTextPos = tester.getCenter(find.byType(SelectableText)); + final TestGesture gesture = await tester.startGesture( + selectableTextPos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(); + await gesture.down(selectableTextPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('text span with tap gesture recognizer works in selectable rich text', ( + WidgetTester tester, + ) async { + var spyTaps = 0; + final spyRecognizer = TapGestureRecognizer() + ..onTap = () { + spyTaps += 1; + }; + addTearDown(spyRecognizer.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SelectableText.rich( + TextSpan( + children: <TextSpan>[ + const TextSpan(text: 'Atwater '), + TextSpan(text: 'Peel', recognizer: spyRecognizer), + const TextSpan(text: ' Sherbrooke Bonaventure'), + ], + ), + ), + ), + ), + ), + ); + expect(spyTaps, 0); + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + expect(spyTaps, 1); + + // Waits for a while to avoid double taps. + await tester.pump(const Duration(seconds: 1)); + + // Starts a long press. + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(150.0, 5.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + + final TextEditingController controller = editableTextWidget.controller; + // Long press still triggers selection. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + // Long press does not trigger gesture recognizer. + expect(spyTaps, 1); + }); + + testWidgets('text span with long press gesture recognizer works in selectable rich text', ( + WidgetTester tester, + ) async { + var spyLongPress = 0; + final spyRecognizer = LongPressGestureRecognizer() + ..onLongPress = () { + spyLongPress += 1; + }; + addTearDown(spyRecognizer.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SelectableText.rich( + TextSpan( + children: <TextSpan>[ + const TextSpan(text: 'Atwater '), + TextSpan(text: 'Peel', recognizer: spyRecognizer), + const TextSpan(text: ' Sherbrooke Bonaventure'), + ], + ), + ), + ), + ), + ), + ); + expect(spyLongPress, 0); + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText)); + + await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); + expect(spyLongPress, 0); + + // Waits for a while to avoid double taps. + await tester.pump(const Duration(seconds: 1)); + + // Starts a long press. + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(150.0, 5.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(); + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + + final TextEditingController controller = editableTextWidget.controller; + // Long press does not trigger selection if there is text span with long + // press recognizer. + expect( + controller.selection, + const TextSelection(baseOffset: 11, extentOffset: 11, affinity: TextAffinity.upstream), + ); + // Long press triggers gesture recognizer. + expect(spyLongPress, 1); + }); + + testWidgets('SelectableText changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: SelectableText('test'))), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('test'))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + + testWidgets( + 'The handles show after pressing Select All', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Long press at 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.longPressAt(ePos); + await tester.pumpAndSettle(); + + expect(find.text('Select all'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect(editableText.selectionOverlay!.toolbarIsVisible, isTrue); + + await tester.tap(find.text('Select all')); + await tester.pump(); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'The Select All calls on selection changed', + (WidgetTester tester) async { + TextSelection? newSelection; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + expect(newSelection, isNull); + newSelection = selection; + }, + ), + ), + ), + ); + + // Long press at 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.longPressAt(ePos); + await tester.pumpAndSettle(); + + expect(newSelection!.baseOffset, 4); + expect(newSelection!.extentOffset, 7); + newSelection = null; + + await tester.tap(find.text('Select all')); + await tester.pump(); + expect(newSelection!.baseOffset, 0); + expect(newSelection!.extentOffset, 11); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'The Select All calls on selection changed with a mouse on windows and linux', + (WidgetTester tester) async { + const string = 'abc def ghi'; + TextSelection? newSelection; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SelectableText( + string, + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + expect(newSelection, isNull); + newSelection = selection; + }, + ), + ), + ), + ); + + // Right-click on the 'e' in 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + final TestGesture gesture = await tester.startGesture( + ePos, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(newSelection!.isCollapsed, isTrue); + expect(newSelection!.baseOffset, 5); + newSelection = null; + + await tester.tap(find.text('Select all')); + await tester.pump(); + expect(newSelection!.baseOffset, 0); + expect(newSelection!.extentOffset, 11); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.windows, + TargetPlatform.linux, + }), + ); + + testWidgets('Does not show handles when updated from the web engine', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Material(child: SelectableText('abc def ghi'))), + ); + + // Interact with the selectable text to establish the input connection. + final Offset topLeft = tester.getTopLeft(find.byType(EditableText)); + final TestGesture gesture = await tester.startGesture( + topLeft + const Offset(0.0, 5.0), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pumpAndSettle(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + expect(state.currentTextEditingValue.selection, const TextSelection.collapsed(offset: 0)); + + if (kIsWeb) { + tester.testTextInput.updateEditingValue( + const TextEditingValue(selection: TextSelection(baseOffset: 2, extentOffset: 7)), + ); + // Wait for all the `setState` calls to be flushed. + await tester.pumpAndSettle(); + expect( + state.currentTextEditingValue.selection, + const TextSelection(baseOffset: 2, extentOffset: 7), + ); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + } + }); + + testWidgets('onSelectionChanged is called when selection changes', (WidgetTester tester) async { + var onSelectionChangedCallCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SelectableText( + 'abc def ghi', + onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { + onSelectionChangedCallCount += 1; + }, + ), + ), + ), + ); + + // Long press to select 'abc'. + final Offset aLocation = textOffsetToPosition(tester, 1); + await tester.longPressAt(aLocation); + await tester.pump(); + expect(onSelectionChangedCallCount, equals(1)); + // Long press to select 'def'. + await tester.longPressAt(textOffsetToPosition(tester, 5)); + await tester.pump(); + expect(onSelectionChangedCallCount, equals(2)); + // Tap on 'Select all' option to select the whole text. + await tester.tap(find.text('Select all')); + expect(onSelectionChangedCallCount, equals(3)); + }); + + testWidgets( + 'selecting a space selects the previous word on mobile', + (WidgetTester tester) async { + TextSelection? selection; + + await tester.pumpWidget( + MaterialApp( + home: SelectableText( + ' blah blah', + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? cause) { + selection = newSelection; + }, + ), + ), + ); + + expect(selection, isNull); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(selection, isNotNull); + expect(selection!.baseOffset, 10); + expect(selection!.extentOffset, 10); + + // Long press on the second space and the previous word is selected. + await tester.longPressAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 1); + expect(selection!.extentOffset, 5); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(selection, isNotNull); + expect(selection!.baseOffset, 10); + expect(selection!.extentOffset, 10); + + // Long press on the first space and the space is selected because there is + // no previous word. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 0); + expect(selection!.extentOffset, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + + testWidgets( + 'selecting a space selects the space on non-mobile platforms', + (WidgetTester tester) async { + TextSelection? selection; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SelectableText( + ' blah blah', + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? cause) { + selection = newSelection; + }, + ), + ), + ), + ), + ); + + expect(selection, isNull); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(selection, isNotNull); + expect(selection!.baseOffset, 10); + expect(selection!.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 5); + expect(selection!.extentOffset, 6); + + // Tap at the beginning of the text to hide the toolbar, then at the end to + // move the cursor to the end. On some platforms, the context menu would + // otherwise block a tap on the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(selection, isNotNull); + expect(selection!.baseOffset, 10); + expect(selection!.extentOffset, 10); + + // Double tapping the first space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 0); + expect(selection!.extentOffset, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'double tapping a space selects the previous word on mobile', + (WidgetTester tester) async { + TextSelection? selection; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: SelectableText( + ' blah blah \n blah', + onSelectionChanged: (TextSelection newSelection, SelectionChangedCause? cause) { + selection = newSelection; + }, + ), + ), + ), + ), + ); + + expect(selection, isNull); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(selection, isNotNull); + expect(selection!.baseOffset, 19); + expect(selection!.extentOffset, 19); + + // Double tapping the second space selects the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 1); + expect(selection!.extentOffset, 5); + + // Double tapping does the same thing for the first space. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 0); + expect(selection!.extentOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(selection, isNotNull); + expect(selection!.baseOffset, 19); + expect(selection!.extentOffset, 19); + + // Double tapping the last space selects all previous contiguous spaces on + // both lines and the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(selection, isNotNull); + expect(selection!.baseOffset, 6); + expect(selection!.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + + testWidgets('text selection style 1', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material( + child: Center( + child: Column( + children: <Widget>[ + SelectableText.rich( + TextSpan( + children: <TextSpan>[ + TextSpan(text: 'Atwater Peel ', style: TextStyle(fontSize: 30.0)), + TextSpan(text: 'Sherbrooke Bonaventure ', style: TextStyle(fontSize: 15.0)), + TextSpan(text: 'hi wassup!', style: TextStyle(fontSize: 10.0)), + ], + ), + key: Key('field0'), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingTop, + selectionWidthStyle: ui.BoxWidthStyle.max, + ), + ], + ), + ), + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 46); + await tester.pump(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('selectable_text_golden.TextSelectionStyle.1.png'), + ); + }); + + testWidgets('text selection style 2', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material( + child: Center( + child: Column( + children: <Widget>[ + SelectableText.rich( + TextSpan( + children: <TextSpan>[ + TextSpan(text: 'Atwater Peel ', style: TextStyle(fontSize: 30.0)), + TextSpan(text: 'Sherbrooke Bonaventure ', style: TextStyle(fontSize: 15.0)), + TextSpan(text: 'hi wassup!', style: TextStyle(fontSize: 10.0)), + ], + ), + key: Key('field0'), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingBottom, + ), + ], + ), + ), + ), + ), + ); + + final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first); + final TextEditingController controller = editableTextWidget.controller; + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 46); + await tester.pump(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('selectable_text_golden.TextSelectionStyle.2.png'), + ); + }); + + testWidgets('keeps alive when has focus', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: DefaultTabController( + length: 2, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return <Widget>[ + SliverToBoxAdapter( + child: Container( + height: 200, + color: Colors.black12, + child: const Center(child: Text('Sliver 1')), + ), + ), + const SliverToBoxAdapter( + child: Center( + child: TabBar( + labelColor: Colors.black, + tabs: <Tab>[ + Tab(text: 'Sliver Tab 1'), + Tab(text: 'Sliver Tab 2'), + ], + ), + ), + ), + ]; + }, + body: const TabBarView( + children: <Widget>[ + Padding(padding: EdgeInsets.only(top: 100.0), child: Text('Regular Text')), + Padding( + padding: EdgeInsets.only(top: 100.0), + child: SelectableText('Selectable Text'), + ), + ], + ), + ), + ), + ), + ), + ); + + // Without any selection, the offscreen widget is disposed and can't be + // found, for both Text and SelectableText. + expect(find.text('Regular Text', skipOffstage: false), findsOneWidget); + expect(find.byType(SelectableText, skipOffstage: false), findsNothing); + + await tester.tap(find.text('Sliver Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Regular Text', skipOffstage: false), findsNothing); + expect(find.byType(SelectableText, skipOffstage: false), findsOneWidget); + + await tester.tap(find.text('Sliver Tab 1')); + await tester.pumpAndSettle(); + expect(find.text('Regular Text', skipOffstage: false), findsOneWidget); + expect(find.byType(SelectableText, skipOffstage: false), findsNothing); + + // Switch back to tab 2 and select some text in SelectableText. + await tester.tap(find.text('Sliver Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('Regular Text', skipOffstage: false), findsNothing); + expect(find.byType(SelectableText, skipOffstage: false), findsOneWidget); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.controller.selection.isValid, isFalse); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(editableText.controller.selection.isValid, isTrue); + expect(editableText.controller.selection.baseOffset, 0); + expect(editableText.controller.selection.extentOffset, 'Selectable'.length); + + // Switch back to tab 1. The SelectableText remains because it is preserving + // its selection. + await tester.tap(find.text('Sliver Tab 1')); + await tester.pumpAndSettle(); + expect(find.text('Regular Text', skipOffstage: false), findsOneWidget); + expect(find.byType(SelectableText, skipOffstage: false), findsOneWidget); + }); + + group('magnifier', () { + late ValueNotifier<MagnifierInfo> magnifierInfo; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + testWidgets('Can drag handles to show, unshow, and update magnifier', ( + WidgetTester tester, + ) async { + const testValue = 'abc def ghi'; + final selectableText = SelectableText( + testValue, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + (_, MagnifierController controller, ValueNotifier<MagnifierInfo> localMagnifierInfo) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ); + + await tester.pumpWidget(overlay(child: selectableText)); + + await skipPastScrollingAnimation(tester); + + // Double tap the 'e' to select 'def'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + + final selection = TextSelection( + baseOffset: testValue.indexOf('d'), + extentOffset: testValue.indexOf('f'), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + + // Drag the right handle 2 letters to the right. + final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + + Offset? firstDragGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2)); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }); + }); + + testWidgets('SelectableText text span style is merged with default text style', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/71389 + + const textStyle = TextStyle(color: Color(0xff00ff00), fontSize: 12.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const SelectableText.rich(TextSpan(text: 'Abcd', style: textStyle)), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.fontSize, textStyle.fontSize); + }); + + testWidgets('SelectableText text span style is merged with default text style', ( + WidgetTester tester, + ) async { + TextSelection? selection; + var count = 0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: SelectableText( + 'I love Flutter!', + onSelectionChanged: (TextSelection s, _) { + selection = s; + count++; + }, + ), + ), + ); + + expect(selection, null); + expect(count, 0); + + // Tap to put the cursor before the "F". + const index = 7; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 500)); + expect(selection, const TextSelection.collapsed(offset: index)); + expect(count, 1); // The `onSelectionChanged` will be triggered one time. + + // Tap on the same location again. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + expect(selection, const TextSelection.collapsed(offset: index)); + expect(count, 1); // The `onSelectionChanged` will not be triggered. + }); + + testWidgets("Off-screen selected text doesn't throw exception", (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/123527 + + TextSelection? selection; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + Expanded( + child: ListView.builder( + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return SelectableText( + 'I love Flutter! $index', + onSelectionChanged: (TextSelection s, _) { + selection = s; + }, + ); + }, + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Pop'), + ), + ], + ); + }, + ), + ), + ), + ); + + expect(selection, null); + + final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText).first); + final TestGesture gesture = await tester.startGesture( + selectableTextStart + const Offset(50, 5), + ); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + + expect(selection, isNotNull); + + await tester.drag(find.byType(ListView), const Offset(0.0, -3000.0)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pop')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'SelectableText respects MediaQueryData.lineHeightScaleFactorOverride, MediaQueryData.letterSpacingOverride, and MediaQueryData.wordSpacingOverride', + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData( + lineHeightScaleFactorOverride: 2.0, + letterSpacingOverride: 2.0, + wordSpacingOverride: 2.0, + ), + child: SelectableText('hello world', strutStyle: StrutStyle(height: 0.9)), + ), + ), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + final TextStyle? resultTextStyle = renderEditable.text?.style; + expect(resultTextStyle?.height, 2.0); + expect(resultTextStyle?.letterSpacing, 2.0); + expect(resultTextStyle?.wordSpacing, 2.0); + expect(renderEditable.strutStyle?.height, 2.0); + }, + ); + + group('context menu', () { + // Regression test for https://github.com/flutter/flutter/issues/169001. + testWidgets( + 'iOS does not use the system context menu by default even when supported', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: const MaterialApp(home: Material(child: SelectableText('one two three'))), + ), + ); + + // No context menu shown. + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + }); + + testWidgets('SelectableText does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: SizedBox.shrink(child: SelectableText('XYZ'))), + ), + ); + expect(tester.getSize(find.byType(SelectableText)), Size.zero); + + // Manually set a selection to trigger the code path that was crashing. + final EditableTextState state = tester.state(find.byType(EditableText)); + state.updateEditingValue( + const TextEditingValue(text: 'XYZ', selection: TextSelection(baseOffset: 0, extentOffset: 3)), + ); + await tester.pump(); + }); +} diff --git a/packages/material_ui/test/material/selection_area_test.dart b/packages/material_ui/test/material/selection_area_test.dart new file mode 100644 index 000000000000..908552b8e847 --- /dev/null +++ b/packages/material_ui/test/material/selection_area_test.dart @@ -0,0 +1,640 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/process_text_utils.dart'; + +Offset textOffsetToPosition(RenderParagraph paragraph, int offset) { + const caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); + final Offset localOffset = paragraph.getOffsetForCaret(TextPosition(offset: offset), caret); + return paragraph.localToGlobal(localOffset); +} + +void main() { + testWidgets('SelectionArea uses correct selection controls', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: SelectionArea(child: Text('abc')))); + final SelectableRegion region = tester.widget<SelectableRegion>(find.byType(SelectableRegion)); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + expect(region.selectionControls, materialTextSelectionHandleControls); + case TargetPlatform.iOS: + expect(region.selectionControls, cupertinoTextSelectionHandleControls); + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(region.selectionControls, desktopTextSelectionHandleControls); + case TargetPlatform.macOS: + expect(region.selectionControls, cupertinoDesktopTextSelectionHandleControls); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('Does not crash when long pressing on padding after dragging', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/123378 + await tester.pumpWidget( + const MaterialApp( + color: Color(0xFF2196F3), + title: 'Demo', + home: Scaffold( + body: SelectionArea( + child: Padding(padding: EdgeInsets.all(100.0), child: Text('Hello World')), + ), + ), + ), + ); + final TestGesture dragging = await tester.startGesture(const Offset(10, 10)); + addTearDown(dragging.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await dragging.moveTo(const Offset(90, 90)); + await dragging.up(); + + final TestGesture longpress = await tester.startGesture(const Offset(20, 20)); + addTearDown(longpress.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await longpress.up(); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/111370 + testWidgets( + 'Handle is correctly transformed when the text is inside of a FittedBox ', + (WidgetTester tester) async { + final Key textKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + color: const Color(0xFF2196F3), + home: Scaffold( + body: SelectionArea( + child: SizedBox( + height: 100, + child: FittedBox( + fit: BoxFit.fill, + child: Text('test', key: textKey), + ), + ), + ), + ), + ), + ); + + final TestGesture longpress = await tester.startGesture(tester.getCenter(find.byType(Text))); + addTearDown(longpress.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await longpress.up(); + + // Text box is scaled by 5. + final RenderBox textBox = tester.firstRenderObject(find.byKey(textKey)); + expect(textBox.size.height, 20.0); + final Offset textPoint = textBox.localToGlobal(const Offset(0, 20)); + expect(textPoint, equals(const Offset(0, 100))); + + // Find handles and verify their sizes. + expect(find.byType(Overlay), findsOneWidget); + expect( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + findsNWidgets(2), + ); + final Iterable<RenderBox> handles = tester.renderObjectList( + find.descendant(of: find.byType(Overlay), matching: find.byType(CustomPaint)), + ); + + // The handle height is determined by the formula: + // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap . + // The text line height will be the value of the fontSize. + // The constant _kSelectionHandleRadius has the value of 6. + // The constant _kSelectionHandleOverlap has the value of 1.5. + // The handle height before scaling is 20.0 + 6 * 2 - 1.5 = 30.5. + + final double handleHeightBeforeScaling = handles.first.size.height; + expect(handleHeightBeforeScaling, 30.5); + + final Offset handleHeightAfterScaling = + handles.first.localToGlobal(const Offset(0, 30.5)) - + handles.first.localToGlobal(Offset.zero); + + // The handle height after scaling is 30.5 * 5 = 152.5 + expect(handleHeightAfterScaling, equals(const Offset(0.0, 152.5))); + }, + skip: isBrowser, // [intended] + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'builds the default context menu by default on Android and iOS web', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectionArea(focusNode: focusNode, child: const Text('How are you?')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Show the toolbar by longpressing. + final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph1, 6), + ); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + // `are` is selected. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + }, + // TODO(Renzo-Olivares): Remove this test when the web context menu + // for Android and iOS is re-enabled. + // See: https://github.com/flutter/flutter/issues/177123. + // [intended] Android and iOS use the flutter rendered menu on the web. + skip: + !kIsWeb || + !<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }.contains(defaultTargetPlatform), + ); + + testWidgets( + 'builds the default context menu by default', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectionArea(focusNode: focusNode, child: const Text('How are you?')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Show the toolbar by longpressing. + final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph1, 6), + ); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + // `are` is selected. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets( + 'builds a custom context menu if provided', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + focusNode: focusNode, + contextMenuBuilder: + (BuildContext context, SelectableRegionState selectableRegionState) { + return Placeholder(key: key); + }, + child: const Text('How are you?'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byKey(key), findsNothing); + + // Show the toolbar by longpressing. + final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph1, 6), + ); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + // `are` is selected. + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byKey(key), findsOneWidget); + }, + skip: kIsWeb, // [intended] + ); + + testWidgets( + 'Text processing actions are added to the toolbar', + (WidgetTester tester) async { + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectionArea(focusNode: focusNode, child: const Text('How are you?')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph, 6), + ); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + // `are` is selected. + expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The toolbar is visible. + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // The text processing actions are visible on Android only. + final areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android; + expect(find.text(fakeAction1Label), areTextActionsSupported ? findsOneWidget : findsNothing); + expect(find.text(fakeAction2Label), areTextActionsSupported ? findsOneWidget : findsNothing); + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] + ); + + testWidgets('onSelectionChange is called when the selection changes', ( + WidgetTester tester, + ) async { + SelectedContent? content; + + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + child: const Text('How are you'), + onSelectionChanged: (SelectedContent? selectedContent) => content = selectedContent, + ), + ), + ); + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('How are you'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph, 4), + kind: PointerDeviceKind.mouse, + ); + expect(content, isNull); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(paragraph, 7)); + await gesture.up(); + await tester.pump(); + expect(content, isNotNull); + expect(content!.plainText, 'are'); + + // Backwards selection. + await gesture.down(textOffsetToPosition(paragraph, 3)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(content, isNotNull); + expect(content!.plainText, ''); + + await gesture.down(textOffsetToPosition(paragraph, 3)); + await tester.pump(); + await gesture.moveTo(textOffsetToPosition(paragraph, 0)); + await gesture.up(); + await tester.pump(); + expect(content, isNotNull); + expect(content!.plainText, 'How'); + }); + + testWidgets( + 'stopping drag of end handle will show the toolbar', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + // Regression test for https://github.com/flutter/flutter/issues/119314 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 64), + child: Column( + children: <Widget>[ + const Text('How are you?'), + SelectionArea(focusNode: focusNode, child: const Text('Good, and you?')), + const Text('Fine, thank you.'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph2, 7), + ); // at the 'a' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + final List<TextBox> boxes = paragraph2.getBoxesForSelection(paragraph2.selections[0]); + expect(boxes.length, 1); + await tester.pumpAndSettle(); + // There is a selection now. + // We check the presence of the copy button to make sure the selection toolbar + // is showing. + expect(find.text('Copy'), findsOneWidget); + + // This is the position of the selection handle displayed at the end. + final Offset handlePos = paragraph2.localToGlobal(boxes[0].toRect().bottomRight); + await gesture.down(handlePos); + await gesture.moveTo( + textOffsetToPosition(paragraph2, 11) + Offset(0, paragraph2.size.height / 2), + ); + await tester.pump(); + + await gesture.up(); + await tester.pump(); + + // After lifting the finger up, the selection toolbar should be showing again. + expect(find.text('Copy'), findsOneWidget); + }, + variant: TargetPlatformVariant.mobile(), + skip: kIsWeb, // [intended] + ); + + testWidgets( + 'Can only drag one selection handle at a time on iOS', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 64), + child: Center( + child: SelectionArea( + focusNode: focusNode, + child: const Text('one two three four five'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('one two three four five'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph, 11), + ); // at the 'e'. + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + final List<TextBox> boxes = paragraph.getBoxesForSelection(paragraph.selections[0]); + expect(boxes.length, 1); + await tester.pumpAndSettle(); + // There is a selection now. + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 13)); + + // This is the position of the selection handle displayed at the end. + final Offset endHandlePos = paragraph.localToGlobal(boxes[0].toRect().bottomRight); + await gesture.down(endHandlePos); + await gesture.moveTo( + textOffsetToPosition(paragraph, 22) + Offset(0, paragraph.size.height / 2), + ); + await tester.pumpAndSettle(); + + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 22)); + + // Attempt to move the start handle while still touching the end handle. + final Offset startHandlePos = paragraph.localToGlobal(boxes[0].toRect().bottomLeft); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos); + addTearDown(startHandleGesture.removePointer); + await tester.pump(); + await startHandleGesture.moveTo( + textOffsetToPosition(paragraph, 0) + Offset(0, paragraph.size.height / 2), + ); + await tester.pump(); + await gesture.up(); + await startHandleGesture.up(); + await tester.pumpAndSettle(); + // Selection should not change when dragging start handle. + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 22)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + skip: kIsWeb, // [intended] + ); + + testWidgets( + 'Can only drag one selection handle at a time on Android web', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 64), + child: Center( + child: SelectionArea( + focusNode: focusNode, + child: const Text('one two three four five'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('one two three four five'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph, 11), + ); // at the 'e'. + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + final List<TextBox> boxes = paragraph.getBoxesForSelection(paragraph.selections[0]); + expect(boxes.length, 1); + await tester.pumpAndSettle(); + // There is a selection now. + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 13)); + + // This is the position of the selection handle displayed at the end. + final Offset endHandlePos = paragraph.localToGlobal(boxes[0].toRect().bottomRight); + await gesture.down(endHandlePos); + await gesture.moveTo( + textOffsetToPosition(paragraph, 22) + Offset(0, paragraph.size.height / 2), + ); + await tester.pumpAndSettle(); + + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 22)); + + // Attempt to move the start handle while still touching the end handle. + final Offset startHandlePos = paragraph.localToGlobal(boxes[0].toRect().bottomLeft); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos); + addTearDown(startHandleGesture.removePointer); + await tester.pump(); + await startHandleGesture.moveTo( + textOffsetToPosition(paragraph, 0) + Offset(0, paragraph.size.height / 2), + ); + await tester.pump(); + await gesture.up(); + await startHandleGesture.up(); + await tester.pumpAndSettle(); + // Selection should not change when dragging start handle. + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 22)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: !kIsWeb, // [intended] on native both selection handles can be dragged at a time. + ); + + testWidgets( + 'Can drag both selection handles at a time on Android', + (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 64), + child: Center( + child: SelectionArea( + focusNode: focusNode, + child: const Text('one two three four five'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>( + find.descendant(of: find.text('one two three four five'), matching: find.byType(RichText)), + ); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(paragraph, 11), + ); // at the 'e'. + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pumpAndSettle(); + final List<TextBox> boxes = paragraph.getBoxesForSelection(paragraph.selections[0]); + expect(boxes.length, 1); + await tester.pumpAndSettle(); + // There is a selection now. + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 13)); + + // This is the position of the selection handle displayed at the end. + final Offset endHandlePos = paragraph.localToGlobal(boxes[0].toRect().bottomRight); + await gesture.down(endHandlePos); + await gesture.moveTo( + textOffsetToPosition(paragraph, 22) + Offset(0, paragraph.size.height / 2), + ); + await tester.pumpAndSettle(); + + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 8, extentOffset: 22)); + + // Attempt to move the start handle while still touching the end handle. + final Offset startHandlePos = paragraph.localToGlobal(boxes[0].toRect().bottomLeft); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos); + addTearDown(startHandleGesture.removePointer); + await tester.pump(); + await startHandleGesture.moveTo( + textOffsetToPosition(paragraph, 0) + Offset(0, paragraph.size.height / 2), + ); + await tester.pump(); + await gesture.up(); + await startHandleGesture.up(); + await tester.pumpAndSettle(); + // Selection changes when dragging start handle. + expect(paragraph.selections.length, 1); + expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 22)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + skip: kIsWeb, // [intended] on web only one selection handle can be dragged at a time. + ); + + testWidgets('SelectionArea does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: SelectionArea(child: Text('X'))), + ), + ), + ); + expect(tester.getSize(find.byType(SelectionArea)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/shaped_input_border_test.dart b/packages/material_ui/test/material/shaped_input_border_test.dart new file mode 100644 index 000000000000..2fd6d041fc1e --- /dev/null +++ b/packages/material_ui/test/material/shaped_input_border_test.dart @@ -0,0 +1,201 @@ +// 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:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('ShapedInputBorder constructor', () { + const inputBorder = ShapedInputBorder(shape: RoundedRectangleBorder()); + expect(inputBorder.borderSide, const BorderSide()); + expect(inputBorder.shape, const RoundedRectangleBorder()); + expect(inputBorder.gapPadding, 4.0); + expect(inputBorder.isOutline, true); + }); + + test('ShapedInputBorder with RoundedSuperellipseBorder', () { + const inputBorder = ShapedInputBorder( + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + expect(inputBorder.borderSide, const BorderSide()); + expect( + inputBorder.shape, + const RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + expect(inputBorder.gapPadding, 4.0); + expect(inputBorder.isOutline, true); + }); + + test('ShapedInputBorder copyWith, ==, hashCode', () { + const inputBorder = ShapedInputBorder(shape: RoundedRectangleBorder()); + final ShapedInputBorder copy = inputBorder.copyWith(); + expect(inputBorder, copy); + expect(inputBorder.hashCode, copy.hashCode); + + final ShapedInputBorder copy2 = inputBorder.copyWith( + borderSide: const BorderSide(color: Colors.blue), + shape: const StadiumBorder(), + gapPadding: 8.0, + ); + expect(copy2.borderSide, const BorderSide(color: Colors.blue)); + expect(copy2.shape, const StadiumBorder()); + expect(copy2.gapPadding, 8.0); + expect(inputBorder == copy2, false); + }); + + test('ShapedInputBorder scale', () { + const inputBorder = ShapedInputBorder( + borderSide: BorderSide(width: 2.0), + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + ); + final ShapedInputBorder scaled = inputBorder.scale(2.0); + expect(scaled.borderSide.width, 4.0); + expect( + scaled.shape, + const RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(20.0))), + ); + expect(scaled.gapPadding, 8.0); + }); + + test('ShapedInputBorder lerp', () { + const inputBorder1 = ShapedInputBorder( + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + gapPadding: 2.0, + ); + const inputBorder2 = ShapedInputBorder( + borderSide: BorderSide(width: 3.0), + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + gapPadding: 6.0, + ); + + final lerped = ShapeBorder.lerp(inputBorder1, inputBorder2, 0.5)! as ShapedInputBorder; + expect(lerped.borderSide.width, 2.0); + expect( + lerped.shape, + const RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ); + expect( + lerped.gapPadding, + 2.0, + ); // gapPadding is not interpolated, it takes the value from the source + }); + + test('ShapedInputBorder dimensions', () { + const inputBorder = ShapedInputBorder( + borderSide: BorderSide(width: 4.0), + shape: RoundedRectangleBorder(), + ); + expect(inputBorder.dimensions, const EdgeInsets.all(4.0)); + }); + + testWidgets('ShapedInputBorder paint with RoundedSuperellipseBorder', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + decoration: InputDecoration( + border: ShapedInputBorder( + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + borderSide: BorderSide(color: Colors.blue, width: 2.0), + ), + labelText: 'Test Label', + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('ShapedInputBorder paint with gap', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + decoration: InputDecoration( + border: ShapedInputBorder( + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + borderSide: BorderSide(color: Colors.blue, width: 2.0), + ), + labelText: 'Test Label', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('ShapedInputBorder paint with StadiumBorder', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + decoration: InputDecoration( + border: ShapedInputBorder( + shape: StadiumBorder(), + borderSide: BorderSide(color: Colors.blue, width: 2.0), + ), + labelText: 'Test Label', + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + test('ShapedInputBorder getInnerPath', () { + const inputBorder = ShapedInputBorder( + borderSide: BorderSide(width: 4.0), + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + final Path path = inputBorder.getInnerPath(const Rect.fromLTRB(10.0, 20.0, 30.0, 40.0)); + expect(path, isNotNull); + }); + + test('ShapedInputBorder getOuterPath', () { + const inputBorder = ShapedInputBorder( + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + final Path path = inputBorder.getOuterPath(const Rect.fromLTRB(10.0, 20.0, 30.0, 40.0)); + expect(path, isNotNull); + }); + + test('ShapedInputBorder paintInterior', () { + const inputBorder = ShapedInputBorder( + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(12.0))), + ); + expect(inputBorder.preferPaintInterior, true); + + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint(); + + inputBorder.paintInterior(canvas, const Rect.fromLTRB(10.0, 20.0, 30.0, 40.0), paint); + + recorder.endRecording(); + }); +} diff --git a/packages/material_ui/test/material/slider_test.dart b/packages/material_ui/test/material/slider_test.dart new file mode 100644 index 000000000000..fa10864ef76c --- /dev/null +++ b/packages/material_ui/test/material/slider_test.dart @@ -0,0 +1,5727 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/src/physics/utils.dart' show nearEqual; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +/// A [RoundedRectSliderTrackShape] that logs its paint. +class LoggingRoundedRectSliderTrackShape extends RoundedRectSliderTrackShape { + LoggingRoundedRectSliderTrackShape({this.secondaryOffsetLog}); + final List<Offset?>? secondaryOffsetLog; + + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = true, + bool isDiscrete = false, + }) { + return super.getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 2, + }) { + secondaryOffsetLog?.add(secondaryOffset); + super.paint( + context, + offset, + parentBox: parentBox, + sliderTheme: sliderTheme, + enableAnimation: enableAnimation, + textDirection: textDirection, + thumbCenter: thumbCenter, + secondaryOffset: secondaryOffset, + isDiscrete: isDiscrete, + isEnabled: isEnabled, + additionalActiveTrackHeight: additionalActiveTrackHeight, + ); + } +} + +// A thumb shape that also logs its repaint center. +class LoggingThumbShape extends SliderComponentShape { + LoggingThumbShape(this.log); + + final List<Offset> log; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(10.0, 10.0); + } + + @override + void paint( + PaintingContext context, + Offset thumbCenter, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + log.add(thumbCenter); + final thumbPaint = Paint()..color = Colors.red; + context.canvas.drawCircle(thumbCenter, 5.0, thumbPaint); + } +} + +// A value indicator shape to log labelPainter text. +class LoggingValueIndicatorShape extends SliderComponentShape { + LoggingValueIndicatorShape(this.logLabel); + + final List<InlineSpan> logLabel; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(10.0, 10.0); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + logLabel.add(labelPainter.text!); + } +} + +class TallSliderTickMarkShape extends SliderTickMarkShape { + @override + Size getPreferredSize({required SliderThemeData sliderTheme, required bool isEnabled}) { + return const Size(10.0, 200.0); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required Offset thumbCenter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required bool isEnabled, + required TextDirection textDirection, + }) { + final paint = Paint()..color = Colors.red; + context.canvas.drawRect(Rect.fromLTWH(offset.dx, offset.dy, 10.0, 20.0), paint); + } +} + +class _StateDependentMouseCursor extends WidgetStateMouseCursor { + const _StateDependentMouseCursor({ + this.disabled = SystemMouseCursors.none, + this.focused = SystemMouseCursors.none, + this.dragged = SystemMouseCursors.none, + this.hovered = SystemMouseCursors.none, + this.regular = SystemMouseCursors.none, + }); + + final MouseCursor disabled; + final MouseCursor focused; + final MouseCursor hovered; + final MouseCursor dragged; + final MouseCursor regular; + + @override + MouseCursor resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return disabled; + } + if (states.contains(WidgetState.focused)) { + return focused; + } + if (states.contains(WidgetState.dragged)) { + return dragged; + } + if (states.contains(WidgetState.hovered)) { + return hovered; + } + return regular; + } + + @override + String get debugDescription => '_StateDependentMouseCursor'; +} + +void main() { + testWidgets('The initial value should respect the discrete value', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.20; + final log = <Offset>[]; + final loggingThumb = LoggingThumbShape(log); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of( + context, + ).copyWith(thumbShape: loggingThumb); + return Material( + child: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.20)); + expect(log.length, 1); + expect(log[0], const Offset(213.0, 300.0)); + }); + + testWidgets('Slider can move when tapped (LTR)', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + double? startValue; + double? endValue; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeStart: (double value) { + startValue = value; + }, + onChangeEnd: (double value) { + endValue = value; + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + expect(startValue, equals(0.0)); + expect(endValue, equals(0.5)); + startValue = null; + endValue = null; + await tester.pump(); // No animation should start. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + final Offset bottomRight = tester.getBottomRight(find.byKey(sliderKey)); + + final Offset target = topLeft + (bottomRight - topLeft) / 4.0; + await tester.tapAt(target); + expect(value, moreOrLessEquals(0.25, epsilon: 0.05)); + expect(startValue, equals(0.5)); + expect(endValue, moreOrLessEquals(0.25, epsilon: 0.05)); + await tester.pump(); // No animation should start. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider can move when tapped (RTL)', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + await tester.pump(); // No animation should start. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + + final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey)); + final Offset bottomRight = tester.getBottomRight(find.byKey(sliderKey)); + + final Offset target = topLeft + (bottomRight - topLeft) / 4.0; + await tester.tapAt(target); + expect(value, moreOrLessEquals(0.75, epsilon: 0.05)); + await tester.pump(); // No animation should start. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets("Slider doesn't send duplicate change events if tapped on the same value", ( + WidgetTester tester, + ) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + late double startValue; + late double endValue; + var updates = 0; + var startValueUpdates = 0; + var endValueUpdates = 0; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + updates++; + value = newValue; + }); + }, + onChangeStart: (double value) { + startValueUpdates++; + startValue = value; + }, + onChangeEnd: (double value) { + endValueUpdates++; + endValue = value; + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + expect(startValue, equals(0.0)); + expect(endValue, equals(0.5)); + await tester.pump(); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + await tester.pump(); + expect(updates, equals(1)); + expect(startValueUpdates, equals(2)); + expect(endValueUpdates, equals(2)); + }); + + testWidgets('Value indicator shows for a bit after being tapped', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + await tester.pump(const Duration(milliseconds: 100)); + // Starts with the position animation and value indicator + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + await tester.pump(const Duration(milliseconds: 100)); + // Value indicator is longer than position. + expect(SchedulerBinding.instance.transientCallbackCount, equals(1)); + await tester.pump(const Duration(milliseconds: 100)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + await tester.pump(const Duration(milliseconds: 100)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + await tester.pump(const Duration(milliseconds: 100)); + // Shown for long enough, value indicator is animated closed. + expect(SchedulerBinding.instance.transientCallbackCount, equals(1)); + await tester.pump(const Duration(milliseconds: 101)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Discrete Slider repaints and animates when dragged', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + final log = <Offset>[]; + final loggingThumb = LoggingThumbShape(log); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of( + context, + ).copyWith(thumbShape: loggingThumb); + return Material( + child: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final expectedLog = <Offset>[ + const Offset(26.0, 300.0), + const Offset(26.0, 300.0), + const Offset(400.0, 300.0), + ]; + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(value, equals(0.5)); + expect(log.length, 3); + expect(log, orderedEquals(expectedLog)); + await gesture.moveBy(const Offset(-500.0, 0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(value, equals(0.0)); + expect(log.length, 5); + expect(log.last.dx, moreOrLessEquals(386.6, epsilon: 0.1)); + // With no more gesture or value changes, the thumb position should still + // be redrawn in the animated position. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(value, equals(0.0)); + expect(log.length, 7); + expect(log.last.dx, moreOrLessEquals(344.8, epsilon: 0.1)); + // Final position. + await tester.pump(const Duration(milliseconds: 80)); + expectedLog.add(const Offset(26.0, 300.0)); + expect(value, equals(0.0)); + expect(log.length, 8); + expect(log.last.dx, moreOrLessEquals(26.0, epsilon: 0.1)); + await gesture.up(); + }); + + testWidgets("Slider doesn't send duplicate change events if tapped on the same value", ( + WidgetTester tester, + ) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + var updates = 0; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + updates++; + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + await tester.pump(); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(0.5)); + await tester.pump(); + expect(updates, equals(1)); + }); + + testWidgets('Discrete Slider repaints when dragged', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + final log = <Offset>[]; + final loggingThumb = LoggingThumbShape(log); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of( + context, + ).copyWith(thumbShape: loggingThumb); + return Material( + child: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final expectedLog = <Offset>[ + const Offset(26.0, 300.0), + const Offset(26.0, 300.0), + const Offset(400.0, 300.0), + ]; + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(value, equals(0.5)); + expect(log.length, 3); + expect(log, orderedEquals(expectedLog)); + await gesture.moveBy(const Offset(-500.0, 0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(value, equals(0.0)); + expect(log.length, 5); + expect(log.last.dx, moreOrLessEquals(386.6, epsilon: 0.1)); + // With no more gesture or value changes, the thumb position should still + // be redrawn in the animated position. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(value, equals(0.0)); + expect(log.length, 7); + expect(log.last.dx, moreOrLessEquals(344.8, epsilon: 0.1)); + // Final position. + await tester.pump(const Duration(milliseconds: 80)); + expectedLog.add(const Offset(26.0, 300.0)); + expect(value, equals(0.0)); + expect(log.length, 8); + expect(log.last.dx, moreOrLessEquals(26.0, epsilon: 0.1)); + await gesture.up(); + }); + + testWidgets('Slider take on discrete values', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: SizedBox( + width: 144.0 + 2 * 16.0, // _kPreferredTotalWidth + child: Slider( + key: sliderKey, + max: 100.0, + divisions: 10, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, equals(0.0)); + await tester.tap(find.byKey(sliderKey)); + expect(value, equals(50.0)); + await tester.drag(find.byKey(sliderKey), const Offset(5.0, 0.0)); + expect(value, equals(50.0)); + await tester.drag(find.byKey(sliderKey), const Offset(40.0, 0.0)); + expect(value, equals(80.0)); + + await tester.pump(); // Starts animation. + expect(SchedulerBinding.instance.transientCallbackCount, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 200)); + // Animation complete. + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + }); + + testWidgets('Slider can be given zero values', (WidgetTester tester) async { + final log = <double>[]; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Slider( + value: 0.0, + onChanged: (double newValue) { + log.add(newValue); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(Slider)); + expect(log, <double>[0.5]); + log.clear(); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Slider( + value: 0.0, + max: 0.0, + onChanged: (double newValue) { + log.add(newValue); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(Slider)); + expect(log, <double>[]); + log.clear(); + }); + + testWidgets('Slider can tap in vertical scroller', (WidgetTester tester) async { + var value = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: ListView( + children: <Widget>[ + Slider( + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + Container(height: 2000.0), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(Slider)); + expect(value, equals(0.5)); + }); + + testWidgets('Slider drags immediately (LTR)', (WidgetTester tester) async { + var value = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Slider( + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + + expect(value, equals(0.5)); + + await gesture.moveBy(const Offset(1.0, 0.0)); + + expect(value, greaterThan(0.5)); + + await gesture.up(); + }); + + testWidgets('Slider drags immediately (RTL)', (WidgetTester tester) async { + var value = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Slider( + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + + expect(value, equals(0.5)); + + await gesture.moveBy(const Offset(1.0, 0.0)); + + expect(value, lessThan(0.5)); + + await gesture.up(); + }); + + testWidgets('Slider onChangeStart and onChangeEnd fire once', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/28115 + + var startFired = 0; + var endFired = 0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: GestureDetector( + onHorizontalDragUpdate: (_) {}, + child: Slider( + value: 0.0, + onChanged: (double newValue) {}, + onChangeStart: (double value) { + startFired += 1; + }, + onChangeEnd: (double value) { + endFired += 1; + }, + ), + ), + ), + ), + ), + ), + ); + + await tester.timedDrag( + find.byType(Slider), + const Offset(20.0, 0.0), + const Duration(milliseconds: 100), + ); + + expect(startFired, equals(1)); + expect(endFired, equals(1)); + }); + + testWidgets('Slider sizing', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Center(child: Slider(value: 0.5, onChanged: null))), + ), + ), + ); + expect(tester.renderObject<RenderBox>(find.byType(Slider)).size, const Size(800.0, 600.0)); + + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center(child: IntrinsicWidth(child: Slider(value: 0.5, onChanged: null))), + ), + ), + ), + ); + expect( + tester.renderObject<RenderBox>(find.byType(Slider)).size, + const Size(144.0 + 2.0 * 24.0, 600.0), + ); + + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Slider(value: 0.5, onChanged: null), + ), + ), + ), + ), + ), + ); + expect( + tester.renderObject<RenderBox>(find.byType(Slider)).size, + const Size(144.0 + 2.0 * 24.0, 48.0), + ); + }); + + testWidgets('Slider respects textScaleFactor', (WidgetTester tester) async { + debugDisableShadows = false; + try { + final Key sliderKey = UniqueKey(); + var value = 0.0; + + Widget buildSlider({ + required double textScaleFactor, + bool isDiscrete = true, + ShowValueIndicator show = ShowValueIndicator.onlyForDiscrete, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScaleFactor)), + child: Material( + child: Theme( + data: Theme.of(context).copyWith( + sliderTheme: Theme.of( + context, + ).sliderTheme.copyWith(showValueIndicator: show), + ), + child: Center( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Slider( + key: sliderKey, + max: 100.0, + divisions: isDiscrete ? 10 : null, + label: '${value.round()}', + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(textScaleFactor: 1.0)); + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect( + tester.renderObject(find.byType(Overlay)), + paints + ..path( + includes: const <Offset>[ + Offset.zero, + Offset(0.0, -8.0), + Offset(-276.0, -16.0), + Offset(-216.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ) + ..paragraph(), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildSlider(textScaleFactor: 2.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect( + tester.renderObject(find.byType(Overlay)), + paints + ..path( + includes: const <Offset>[ + Offset.zero, + Offset(0.0, -8.0), + Offset(-304.0, -16.0), + Offset(-216.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ) + ..paragraph(), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Check continuous + await tester.pumpWidget( + buildSlider( + textScaleFactor: 1.0, + isDiscrete: false, + show: ShowValueIndicator.onlyForContinuous, + ), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect( + tester.renderObject(find.byType(Overlay)), + paints + ..path( + includes: const <Offset>[ + Offset.zero, + Offset(0.0, -8.0), + Offset(-276.0, -16.0), + Offset(-216.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ) + ..paragraph(), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + buildSlider( + textScaleFactor: 2.0, + isDiscrete: false, + show: ShowValueIndicator.onlyForContinuous, + ), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect( + tester.renderObject(find.byType(Overlay)), + paints + ..path( + includes: const <Offset>[ + Offset.zero, + Offset(0.0, -8.0), + Offset(-276.0, -16.0), + Offset(-216.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ) + ..paragraph(), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Slider value indicator respects bold text', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + final log = <InlineSpan>[]; + final loggingValueIndicatorShape = LoggingValueIndicatorShape(log); + + Widget buildSlider({bool boldText = false}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData(boldText: boldText), + child: Material( + child: Theme( + data: Theme.of(context).copyWith( + sliderTheme: Theme.of(context).sliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: loggingValueIndicatorShape, + ), + ), + child: Center( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Slider( + key: sliderKey, + max: 100.0, + divisions: 4, + label: '${value.round()}', + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } + + // Normal text + await tester.pumpWidget(buildSlider()); + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(log.last.toPlainText(), '50'); + expect(log.last.style!.fontWeight, FontWeight.w500); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Bold text + await tester.pumpWidget(buildSlider(boldText: true)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(log.last.toPlainText(), '50'); + expect(log.last.style!.fontWeight, FontWeight.w700); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Tick marks are skipped when they are too dense', (WidgetTester tester) async { + Widget buildSlider({required int divisions}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Slider( + max: 100.0, + divisions: divisions, + value: 0.25, + onChanged: (double newValue) {}, + ), + ), + ), + ), + ); + } + + // Pump a slider with a reasonable amount of divisions to verify that the + // tick marks are drawn when the number of tick marks is not too dense. + await tester.pumpWidget(buildSlider(divisions: 4)); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // 5 tick marks and a thumb. + expect(material, paintsExactlyCountTimes(#drawCircle, 6)); + + // 200 divisions will produce a tick interval off less than 6, + // which would be too dense to draw. + await tester.pumpWidget(buildSlider(divisions: 200)); + + // No tick marks are drawn because they are too dense, but the thumb is + // still drawn. + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + }); + + testWidgets('Slider has correct animations when reparented', (WidgetTester tester) async { + final Key sliderKey = GlobalKey(debugLabel: 'A'); + var value = 0.0; + + Widget buildSlider(int parents) { + Widget createParents(int parents, StateSetter setState) { + Widget slider = Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ); + + for (var i = 0; i < parents; ++i) { + slider = Column(children: <Widget>[slider]); + } + return slider; + } + + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material(child: createParents(parents, setState)); + }, + ), + ), + ); + } + + Future<void> testReparenting(bool reparent) async { + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + final Offset center = tester.getCenter(find.byType(Slider)); + // Move to 0.0. + TestGesture gesture = await tester.startGesture(Offset.zero); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + expect( + material, + paints + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 26.0, y: 24.0, radius: 10.0), + ); + + gesture = await tester.startGesture(center); + await tester.pump(); + // Wait for animations to start. + await tester.pump(const Duration(milliseconds: 25)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + expect( + material, + paints + ..circle(x: 112.7431640625, y: 24.0, radius: 5.687664985656738) + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 112.7431640625, y: 24.0, radius: 10.0), + ); + + // Reparenting in the middle of an animation should do nothing. + if (reparent) { + await tester.pumpWidget(buildSlider(2)); + } + + // Move a little further in the animations. + await tester.pump(const Duration(milliseconds: 10)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + expect( + material, + paints + ..circle(x: 191.130521774292, y: 24.0, radius: 12.0) + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 191.130521774292, y: 24.0, radius: 10.0), + ); + // Wait for animations to finish. + await tester.pumpAndSettle(); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + expect( + material, + paints + ..circle(x: 400.0, y: 24.0, radius: 24.0) + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 10.0), + ); + await gesture.up(); + await tester.pumpAndSettle(); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + expect( + material, + paints + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 10.0), + ); + } + + await tester.pumpWidget(buildSlider(1)); + // Do it once without reparenting in the middle of an animation + await testReparenting(false); + // Now do it again with reparenting in the middle of an animation. + await testReparenting(true); + }); + + testWidgets( + 'Slider Semantics', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: (double v) {})), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.focus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + // Disable slider + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: null)), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pump(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + }), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. + ); + + testWidgets( + 'Slider Semantics', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 100.0, max: 200.0, onChanged: (double v) {})), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.focus, + ], + value: '50%', + increasedValue: '60%', + decreasedValue: '40%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + // Disable slider + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: null)), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics(id: 9), + TestSemantics( + id: 8, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + value: '50%', + increasedValue: '60%', + decreasedValue: '40%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. + ); + + testWidgets( + 'Slider Semantics', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: (double v) {})), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.didGainAccessibilityFocus, + SemanticsAction.focus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + // Disable slider + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: null)), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.didGainAccessibilityFocus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pump(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 7, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.didGainAccessibilityFocus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.windows}), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. + ); + + testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Slider( + value: 40.0, + max: 200.0, + divisions: 10, + semanticFormatterCallback: (double value) => value.round().toString(), + onChanged: (double v) {}, + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.focus, + ], + value: '40', + increasedValue: '60', + decreasedValue: '20', + textDirection: TextDirection.ltr, + ), + TestSemantics(id: 6), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + semantics.dispose(); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. + + // Regression test for https://github.com/flutter/flutter/issues/101868 + testWidgets('Slider.label info should not write to semantic node', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + const label = 'Bingo'; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Slider( + value: 40.0, + max: 200.0, + divisions: 10, + semanticFormatterCallback: (double value) => value.round().toString(), + onChanged: (double v) {}, + label: label, + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.focus, + ], + label: 'Bingo', + value: '40', + increasedValue: '60', + decreasedValue: '20', + textDirection: TextDirection.ltr, + ), + TestSemantics(id: 6), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + semantics.dispose(); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. + + testWidgets('Material3 - Slider is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Slider'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + var value = 0.5; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Check that the overlay shows when focused. + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + + // Check that the overlay does not show when unfocused and disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.1))), + ); + }); + + testWidgets('Slider has correct focus color from overlayColor property', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Slider'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + overlayColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return Colors.purple[500]!; + } + + return Colors.transparent; + }), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Check that the overlay shows when focused. + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: Colors.purple[500]), + ); + + // Check that the overlay does not show when focused and disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.purple[500])), + ); + }); + + testWidgets('Slider can be hovered and has correct hover color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + var value = 0.5; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not hovered. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.orange[500])), + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + + // Slider has overlay when enabled and hovered. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.08)), + ); + + // Slider still shows correct hovered color after pressing/dragging + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0.0, 100.0)); + await tester.pumpAndSettle(); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.08)), + ); + + // Slider does not have an overlay when disabled and hovered. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.orange[500])), + ); + }); + + testWidgets('Slider has correct hovered color from overlayColor property', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + overlayColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return Colors.cyan[500]!; + } + + return Colors.transparent; + }), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not hovered. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.cyan[500])), + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + + // Slider has overlay when enabled and hovered. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: Colors.cyan[500]), + ); + + // Slider does not have an overlay when disabled and hovered. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.cyan[500])), + ); + }); + + testWidgets('Material3 - Slider is draggable and has correct dragged color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + final theme = ThemeData(); + final Key sliderKey = UniqueKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + key: sliderKey, + value: value, + focusNode: focusNode, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not dragged. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.1))), + ); + + // Start dragging. + final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + // Slider has overlay when enabled and dragged. + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + + await drag.up(); + await tester.pumpAndSettle(); + + // Slider without focus doesn't have overlay when enabled and dragged. + expect(focusNode.hasFocus, false); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.1))), + ); + + // Slider has overlay when enabled, dragged and focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(focusNode.hasFocus, true); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + }); + + testWidgets('Slider has correct dragged color from overlayColor property', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + final Key sliderKey = UniqueKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + key: sliderKey, + value: value, + focusNode: focusNode, + overlayColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.dragged)) { + return Colors.lime[500]!; + } + + return Colors.transparent; + }), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not dragged. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.lime[500])), + ); + + // Start dragging. + final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + // Slider has overlay when enabled and dragged. + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: Colors.lime[500]), + ); + + await drag.up(); + await tester.pumpAndSettle(); + + // Slider without focus doesn't have overlay when enabled and dragged. + expect(focusNode.hasFocus, false); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.lime[500])), + ); + }); + + testWidgets('OverlayColor property is correctly applied when activeColor is also provided', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Slider'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + const activeColor = Color(0xffff0000); + const overlayColor = Color(0xff0000ff); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + activeColor: activeColor, + overlayColor: const MaterialStatePropertyAll<Color?>(overlayColor), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + // Check that thumb color is using active color. + expect(material, paints..circle(color: activeColor)); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Check that the overlay shows when focused. + expect(focusNode.hasPrimaryFocus, isTrue); + expect(Material.of(tester.element(find.byType(Slider))), paints..circle(color: overlayColor)); + + // Check that the overlay does not show when focused and disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + }); + + testWidgets( + 'Slider can be incremented and decremented by keyboard shortcuts - LTR', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var startValue = 0.0; + var currentValue = 0.5; + var endValue = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: currentValue, + onChangeStart: (double newValue) { + setState(() { + startValue = newValue; + }); + }, + onChanged: (double newValue) { + setState(() { + currentValue = newValue; + }); + }, + onChangeEnd: (double newValue) { + setState(() { + endValue = newValue; + }); + }, + autofocus: true, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.55); + expect(endValue, 0.55); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValue, 0.55); + expect(currentValue, 0.5); + expect(endValue, 0.5); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.55); + expect(endValue, 0.55); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValue, 0.55); + expect(currentValue, 0.5); + expect(endValue, 0.5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Slider can be incremented and decremented by keyboard shortcuts - LTR', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var startValue = 0.0; + var currentValue = 0.5; + var endValue = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: currentValue, + onChangeStart: (double newValue) { + setState(() { + startValue = newValue; + }); + }, + onChanged: (double newValue) { + setState(() { + currentValue = newValue; + }); + }, + onChangeEnd: (double newValue) { + setState(() { + endValue = newValue; + }); + }, + autofocus: true, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.6); + expect(endValue, 0.6); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValue, 0.6); + expect(currentValue, 0.5); + expect(endValue, 0.5); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.6); + expect(endValue, 0.6); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValue, 0.6); + expect(currentValue, 0.5); + expect(endValue, 0.5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Slider can be incremented and decremented by keyboard shortcuts - RTL', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var startValue = 0.0; + var currentValue = 0.5; + var endValue = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.rtl, + child: Slider( + value: currentValue, + onChangeStart: (double newValue) { + setState(() { + startValue = newValue; + }); + }, + onChanged: (double newValue) { + setState(() { + currentValue = newValue; + }); + }, + onChangeEnd: (double newValue) { + setState(() { + endValue = newValue; + }); + }, + autofocus: true, + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.45); + expect(endValue, 0.45); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValue, 0.45); + expect(currentValue, 0.5); + expect(endValue, 0.5); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.55); + expect(endValue, 0.55); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValue, 0.55); + expect(currentValue, 0.5); + expect(endValue, 0.5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Slider can be incremented and decremented by keyboard shortcuts - RTL', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var startValue = 0.0; + var currentValue = 0.5; + var endValue = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.rtl, + child: Slider( + value: currentValue, + onChangeStart: (double newValue) { + setState(() { + startValue = newValue; + }); + }, + onChanged: (double newValue) { + setState(() { + currentValue = newValue; + }); + }, + onChangeEnd: (double newValue) { + setState(() { + endValue = newValue; + }); + }, + autofocus: true, + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.4); + expect(endValue, 0.4); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValue, 0.4); + expect(currentValue, 0.5); + expect(endValue, 0.5); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValue, 0.5); + expect(currentValue, 0.6); + expect(endValue, 0.6); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValue, 0.6); + expect(currentValue, 0.5); + expect(endValue, 0.5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('In directional nav, Slider can be navigated out of by using up and down arrows', ( + WidgetTester tester, + ) async { + const shortcuts = <ShortcutActivator, Intent>{ + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent( + TraversalDirection.left, + ), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent( + TraversalDirection.right, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent( + TraversalDirection.down, + ), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + }; + + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var topSliderValue = 0.5; + var bottomSliderValue = 0.5; + await tester.pumpWidget( + MaterialApp( + home: Shortcuts( + shortcuts: shortcuts, + child: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: const MediaQueryData(navigationMode: NavigationMode.directional), + child: Column( + children: <Widget>[ + Slider( + value: topSliderValue, + onChanged: (double newValue) { + setState(() { + topSliderValue = newValue; + }); + }, + autofocus: true, + ), + Slider( + value: bottomSliderValue, + onChanged: (double newValue) { + setState(() { + bottomSliderValue = newValue; + }); + }, + ), + ], + ), + ); + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The top slider is auto-focused and can be adjusted with left and right arrow keys. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.55, reason: 'focused top Slider increased after first arrowRight'); + expect( + bottomSliderValue, + 0.5, + reason: 'unfocused bottom Slider unaffected by first arrowRight', + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.5, reason: 'focused top Slider decreased after first arrowLeft'); + expect(bottomSliderValue, 0.5, reason: 'unfocused bottom Slider unaffected by first arrowLeft'); + + // Pressing the down-arrow key moves focus down to the bottom slider + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.5, reason: 'arrowDown unfocuses top Slider, does not alter its value'); + expect( + bottomSliderValue, + 0.5, + reason: 'arrowDown focuses bottom Slider, does not alter its value', + ); + + // The bottom slider is now focused and can be adjusted with left and right arrow keys. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.5, reason: 'unfocused top Slider unaffected by second arrowRight'); + expect(bottomSliderValue, 0.55, reason: 'focused bottom Slider increased by second arrowRight'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.5, reason: 'unfocused top Slider unaffected by second arrowLeft'); + expect(bottomSliderValue, 0.5, reason: 'focused bottom Slider decreased by second arrowLeft'); + + // Pressing the up-arrow key moves focus back up to the top slider + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.5, reason: 'arrowUp focuses top Slider, does not alter its value'); + expect( + bottomSliderValue, + 0.5, + reason: 'arrowUp unfocuses bottom Slider, does not alter its value', + ); + + // The top slider is now focused again and can be adjusted with left and right arrow keys. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.55, reason: 'focused top Slider increased after third arrowRight'); + expect( + bottomSliderValue, + 0.5, + reason: 'unfocused bottom Slider unaffected by third arrowRight', + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(topSliderValue, 0.5, reason: 'focused top Slider decreased after third arrowRight'); + expect( + bottomSliderValue, + 0.5, + reason: 'unfocused bottom Slider unaffected by third arrowRight', + ); + }); + + testWidgets( + 'Slider gains keyboard focus when it gains semantics focus on Windows', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Slider(value: 0.5, onChanged: (double _) {}, focusNode: focusNode), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + children: <TestSemantics>[ + TestSemantics(id: 6), + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: <SemanticsAction>[ + SemanticsAction.focus, + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.didGainAccessibilityFocus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(5, SemanticsAction.didGainAccessibilityFocus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.windows}), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. + ); + + // Regression test for https://github.com/flutter/flutter/issues/180767 + group('Value indicator appears and disappears when it should:', () { + final baseTheme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData baseSliderTheme = baseTheme.sliderTheme.copyWith( + valueIndicatorColor: Colors.red, + valueIndicatorShape: const _FixedSizeCircle(), + ); + var value = 0.45; + Widget buildApp({required SliderThemeData sliderTheme, int? divisions, bool enabled = true}) { + final ValueChanged<double>? onChanged = enabled ? (double d) => value = d : null; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: baseTheme, + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: value, + label: '$value', + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ), + ), + ), + ); + } + + Future<void> expectValueIndicator( + WidgetTester tester, { + required bool visibleWhenDragged, + required bool visibleWhenReleased, + required SliderThemeData theme, + int? divisions, + bool enabled = true, + }) async { + void expectIndicatorVisible(bool isVisible) { + // _RenderValueIndicator is the last render object in the tree. + final RenderObject valueIndicatorBox = tester.allRenderObjects.last; + expect( + valueIndicatorBox, + isVisible + ? (paints + ..circle(color: theme.valueIndicatorColor) + ..paragraph()) + : isNot( + paints + ..circle(color: theme.valueIndicatorColor) + ..paragraph(), + ), + ); + } + + await tester.pumpWidget(buildApp(sliderTheme: theme, divisions: divisions, enabled: enabled)); + expectIndicatorVisible(visibleWhenReleased); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + expectIndicatorVisible(visibleWhenDragged); + + await gesture.up(); + await tester.pumpAndSettle(); + expectIndicatorVisible(visibleWhenReleased); + + // Reset state to avoid state leak. + await tester.pumpWidget(Container()); + } + + testWidgets('showValueIndicator set to onlyForDiscrete', (WidgetTester tester) async { + // The default value is onlyForDiscrete. No modification is needed. + final SliderThemeData sliderTheme = baseSliderTheme.copyWith(); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + visibleWhenDragged: true, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + }); + + testWidgets('showValueIndicator set to onlyForContinuous', (WidgetTester tester) async { + final SliderThemeData sliderTheme = baseSliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.onlyForContinuous, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + visibleWhenDragged: true, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + }); + + testWidgets('showValueIndicator set to onDrag', (WidgetTester tester) async { + final SliderThemeData sliderTheme = baseSliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.onDrag, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + visibleWhenDragged: true, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + visibleWhenDragged: true, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + }); + + testWidgets('showValueIndicator set to never', (WidgetTester tester) async { + final SliderThemeData sliderTheme = baseSliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.never, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + }); + + testWidgets('showValueIndicator set to alwaysVisible', (WidgetTester tester) async { + final SliderThemeData sliderTheme = baseSliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.alwaysVisible, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + visibleWhenDragged: true, + visibleWhenReleased: true, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + divisions: 3, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + visibleWhenDragged: true, + visibleWhenReleased: true, + ); + await expectValueIndicator( + tester, + theme: sliderTheme, + enabled: false, + visibleWhenDragged: false, + visibleWhenReleased: false, + ); + }); + }); + + testWidgets("Slider doesn't start any animations after dispose", (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pumpAndSettle(); + expect(value, equals(0.5)); + await gesture.moveBy(const Offset(-500.0, 0.0)); + await tester.pumpAndSettle(); + // Change the tree to dispose the original widget. + await tester.pumpWidget(Container()); + expect(await tester.pumpAndSettle(), equals(1)); + await gesture.up(); + }); + + testWidgets( + 'Slider removes value indicator from overlay if Slider gets disposed without value indicator animation completing.', + (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + const fillColor = Color(0xf55f5f5f); + var value = 0.0; + + Widget buildApp({int? divisions, bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + // The builder is used to pass the context from the MaterialApp widget + // to the [Navigator]. This context is required in order for the + // Navigator to work. + builder: (BuildContext context) { + return Column( + children: <Widget>[ + Slider( + key: sliderKey, + max: 100.0, + divisions: divisions, + label: '${value.round()}', + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + ElevatedButton( + child: const Text('Next'), + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Inner page'), + onPressed: () { + Navigator.of(context).pop(); + }, + ); + }, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp(divisions: 3)); + + final RenderObject valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + final Offset topRight = tester.getTopRight(find.byType(Slider)).translate(-24, 0); + final TestGesture gesture = await tester.startGesture(topRight); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect(find.byType(Slider), isNotNull); + expect( + valueIndicatorBox, + paints + // Represents the raised button with text, next. + ..path(color: Colors.black) + ..paragraph() + // Represents the Slider. + ..path(color: fillColor) + ..paragraph(), + ); + + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 4)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawParagraph, 2)); + + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + + expect(find.byType(Slider), findsNothing); + expect( + valueIndicatorBox, + isNot( + paints + ..path(color: fillColor) + ..paragraph(), + ), + ); + + // Represents the ElevatedButton with inner Text, inner page. + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawParagraph, 1)); + + // Don't stop holding the value indicator. + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Slider.adaptive', (WidgetTester tester) async { + var value = 0.5; + + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Slider.adaptive( + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + value = 0.5; + await tester.pumpWidget(buildFrame(platform)); + expect(find.byType(Slider), findsOneWidget); + expect(find.byType(CupertinoSlider), findsOneWidget); + + expect(value, 0.5, reason: 'on ${platform.name}'); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CupertinoSlider)), + ); + // Drag to the right end of the track. + await gesture.moveBy(const Offset(600.0, 0.0)); + expect(value, 1.0, reason: 'on ${platform.name}'); + await gesture.up(); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + value = 0.5; + await tester.pumpWidget(buildFrame(platform)); + await tester.pumpAndSettle(); // Finish the theme change animation. + expect(find.byType(Slider), findsOneWidget); + expect(find.byType(CupertinoSlider), findsNothing); + + expect(value, 0.5, reason: 'on ${platform.name}'); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Slider))); + // Drag to the right end of the track. + await gesture.moveBy(const Offset(600.0, 0.0)); + expect(value, 1.0, reason: 'on ${platform.name}'); + await gesture.up(); + } + }); + + testWidgets('Slider respects height from theme', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + var value = 0.0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of( + context, + ).copyWith(tickMarkShape: TallSliderTickMarkShape()); + return Material( + child: Center( + child: IntrinsicHeight( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox renderObject = tester.renderObject<RenderBox>(find.byType(Slider)); + expect(renderObject.size.height, 200); + }); + + testWidgets('Slider changes mouse cursor when hovered', (WidgetTester tester) async { + // Test Slider() constructor + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Slider( + mouseCursor: SystemMouseCursors.text, + value: 0.5, + onChanged: (double newValue) {}, + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Slider))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test Slider.adaptive() constructor + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Slider.adaptive( + mouseCursor: SystemMouseCursors.text, + value: 0.5, + onChanged: (double newValue) {}, + ), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Slider(value: 0.5, onChanged: (double newValue) {}), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('Slider WidgetStateMouseCursor resolves correctly', (WidgetTester tester) async { + const MouseCursor systemDefaultCursor = SystemMouseCursors.basic; + const MouseCursor regularCursor = SystemMouseCursors.click; + const MouseCursor disabledCursor = SystemMouseCursors.forbidden; + const MouseCursor focusedCursor = SystemMouseCursors.precise; + const MouseCursor hoveredCursor = SystemMouseCursors.grab; + const MouseCursor draggedCursor = SystemMouseCursors.move; + + Widget buildFrame({required bool enabled}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + Center( + child: Slider( + mouseCursor: const _StateDependentMouseCursor( + disabled: disabledCursor, + focused: focusedCursor, + hovered: hoveredCursor, + dragged: draggedCursor, + regular: regularCursor, + ), + value: 0.5, + onChanged: enabled ? (double newValue) {} : null, + ), + ), + ], + ), + ), + ), + ); + } + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + addTearDown(gesture.removePointer); + + // System default. + await gesture.addPointer(location: Offset.zero); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), systemDefaultCursor); + + // Disabled. + await tester.pumpWidget(buildFrame(enabled: false)); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), disabledCursor); + + // Regular. + await tester.pumpWidget(buildFrame(enabled: true)); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), regularCursor); + + // Hovered. + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), hoveredCursor); + + // Dragged. + await gesture.down(tester.getCenter(find.byType(Slider))); + await gesture.moveBy(const Offset(20.0, 0.0)); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), draggedCursor); + + // Hovered. + await gesture.up(); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), hoveredCursor); + + // Focused. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), focusedCursor); + + // System default. + await gesture.moveTo(Offset.zero); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), systemDefaultCursor); + }); + + testWidgets('Slider implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + const Slider( + activeColor: Colors.blue, + divisions: 10, + inactiveColor: Colors.grey, + secondaryActiveColor: Colors.blueGrey, + label: 'Set a value', + max: 100.0, + onChanged: null, + value: 50.0, + secondaryTrackValue: 75.0, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'value: 50.0', + 'secondaryTrackValue: 75.0', + 'disabled', + 'min: 0.0', + 'max: 100.0', + 'divisions: 10', + 'label: "Set a value"', + 'activeColor: MaterialColor(primary value: ${const Color(0xff2196f3)})', + 'inactiveColor: MaterialColor(primary value: ${const Color(0xff9e9e9e)})', + 'secondaryActiveColor: MaterialColor(primary value: ${const Color(0xff607d8b)})', + ]); + }); + + testWidgets('Slider track paints correctly when the shape is rectangular', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + sliderTheme: const SliderThemeData(trackShape: RectangularSliderTrackShape()), + ), + home: const Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Center(child: Slider(value: 0.5, onChanged: null))), + ), + ), + ); + + final RenderObject renderObject = tester.allRenderObjects + .where((RenderObject e) => e.runtimeType.toString() == '_RenderSlider') + .first; + + // The active track rect should start at 24.0 pixels, + // and there should not have a gap between active and inactive track. + expect( + renderObject, + paints + ..rect(rect: const Rect.fromLTRB(24.0, 298.0, 400.0, 302.0)) // active track Rect. + ..rect(rect: const Rect.fromLTRB(400.0, 298.0, 776.0, 302.0)), // inactive track Rect. + ); + }); + + testWidgets('SliderTheme change should trigger re-layout', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/118955 + var sliderValue = 0.0; + Widget buildFrame(ThemeMode themeMode) { + return MaterialApp( + themeMode: themeMode, + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox.square( + dimension: 10.0, + child: Slider( + value: sliderValue, + label: 'label', + onChanged: (double value) => sliderValue = value, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(ThemeMode.light)); + + final RenderObject renderObject = tester.allRenderObjects + .where((RenderObject e) => e.runtimeType.toString() == '_RenderSlider') + .first; + expect(renderObject.debugNeedsLayout, false); + + await tester.pumpWidget(buildFrame(ThemeMode.dark)); + await tester.pump( + const Duration(milliseconds: 100), // to let the theme animate + EnginePhase.build, + ); + + expect(renderObject.debugNeedsLayout, true); + + // Pump the rest of the frames to complete the test. + await tester.pumpAndSettle(); + }); + + testWidgets('Slider can be painted in a narrower constraint', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox.square(dimension: 10.0, child: Slider(value: 0.5, onChanged: null)), + ), + ), + ), + ), + ); + + final RenderObject renderObject = tester.allRenderObjects + .where((RenderObject e) => e.runtimeType.toString() == '_RenderSlider') + .first; + + expect( + renderObject, + paints + // Inactive track RRect. + ..rrect(rrect: RRect.fromLTRBR(3.0, 3.0, 24.0, 7.0, const Radius.circular(2.0))) + // Active track RRect. + ..rrect(rrect: RRect.fromLTRBR(-14.0, 2.0, 7.0, 8.0, const Radius.circular(3.0))) + // Thumb. + ..circle(x: 5.0, y: 5.0, radius: 10.0), + ); + }); + + testWidgets('Update the divisions and value at the same time for Slider', ( + WidgetTester tester, + ) async { + // Regress test for https://github.com/flutter/flutter/issues/65943 + Widget buildFrame(double maxValue) { + return MaterialApp( + home: Material( + child: Center( + child: Slider.adaptive( + value: 5, + max: maxValue, + divisions: maxValue.toInt(), + onChanged: (double newValue) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(10)); + + final RenderObject renderObject = tester.allRenderObjects + .where((RenderObject e) => e.runtimeType.toString() == '_RenderSlider') + .first; + + // Update the divisions from 10 to 15, the thumb should be paint at the correct position. + await tester.pumpWidget(buildFrame(15)); + await tester.pumpAndSettle(); // Finish the animation. + + late RRect activeTrackRRect; + expect( + renderObject, + paints + ..rrect() + ..something((Symbol method, List<dynamic> arguments) { + if (method != #drawRRect) { + return false; + } + activeTrackRRect = arguments[0] as RRect; + return true; + }), + ); + + const padding = 4.0; + // The thumb should at one-third(5 / 15) of the Slider. + // The right of the active track shape is the position of the thumb. + // 24.0 is the default margin, (800.0 - 24.0 - 24.0) is the slider's width. + expect( + nearEqual( + activeTrackRRect.right, + (800.0 - 24.0 - 24.0 + (padding / 2)) * (5 / 15) + 24.0 + padding / 2, + 0.01, + ), + true, + ); + }); + + testWidgets('Slider paints thumbColor', (WidgetTester tester) async { + const color = Color(0xffffc107); + + final Widget sliderAdaptive = MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material( + child: Slider(value: 0, onChanged: (double newValue) {}, thumbColor: color), + ), + ); + + await tester.pumpWidget(sliderAdaptive); + await tester.pumpAndSettle(); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect(material, paints..circle(color: color)); + }); + + testWidgets('Slider.adaptive paints thumbColor on Android', (WidgetTester tester) async { + const color = Color(0xffffc107); + + final Widget sliderAdaptive = MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Material( + child: Slider.adaptive(value: 0, onChanged: (double newValue) {}, thumbColor: color), + ), + ); + + await tester.pumpWidget(sliderAdaptive); + await tester.pumpAndSettle(); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect(material, paints..circle(color: color)); + }); + + testWidgets('If thumbColor is null, it defaults to CupertinoColors.white', ( + WidgetTester tester, + ) async { + final Widget sliderAdaptive = MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material(child: Slider.adaptive(value: 0, onChanged: (double newValue) {})), + ); + + await tester.pumpWidget(sliderAdaptive); + await tester.pumpAndSettle(); + + final MaterialInkController material = Material.of( + tester.element(find.byType(CupertinoSlider)), + ); + expect( + material, + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: CupertinoColors.white), + ); + }); + + testWidgets('Slider.adaptive passes thumbColor to CupertinoSlider', (WidgetTester tester) async { + const color = Color(0xffffc107); + + final Widget sliderAdaptive = MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material( + child: Slider.adaptive(value: 0, onChanged: (double newValue) {}, thumbColor: color), + ), + ); + + await tester.pumpWidget(sliderAdaptive); + await tester.pumpAndSettle(); + + final MaterialInkController material = Material.of( + tester.element(find.byType(CupertinoSlider)), + ); + expect( + material, + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: color), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/103566 + testWidgets('Drag gesture uses provided gesture settings', (WidgetTester tester) async { + var value = 0.5; + var dragStarted = false; + final Key sliderKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: (DragStartDetails details) { + dragStarted = true; + }, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 20)), + child: Slider( + value: value, + key: sliderKey, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + expect(value, 0.5); + expect(dragStarted, true); + + dragStarted = false; + + await drag.up(); + await tester.pumpAndSettle(); + + drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + var sliderEnd = false; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onHorizontalDragStart: (DragStartDetails details) { + dragStarted = true; + }, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(gestureSettings: const DeviceGestureSettings(touchSlop: 10)), + child: Slider( + value: value, + key: sliderKey, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double endValue) { + sliderEnd = true; + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + // More than touch slop. + await drag.moveBy(const Offset(12.0, 0)); + + await drag.up(); + await tester.pumpAndSettle(); + + expect(sliderEnd, true); + expect(dragStarted, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/139281 + testWidgets('Slider does not request focus when the value is changed', ( + WidgetTester tester, + ) async { + var value = 0.5; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ); + }, + ), + ), + ), + ), + ); + // Initially, the slider does not have focus whe enabled and not tapped. + await tester.pumpAndSettle(); + expect(value, equals(0.5)); + // Get FocusNode from the state of the slider to include auto-generated FocusNode. + // ignore: invalid_assignment + final FocusNode focusNode = (tester.firstState(find.byType(Slider)) as dynamic).focusNode; + // The slider does not have focus. + expect(focusNode.hasFocus, false); + final Offset sliderCenter = tester.getCenter(find.byType(Slider)); + final tapLocation = Offset(sliderCenter.dx + 50, sliderCenter.dy); + // Tap on the slider to change the value. + final TestGesture gesture = await tester.createGesture(); + await gesture.addPointer(); + await gesture.down(tapLocation); + await gesture.up(); + await tester.pumpAndSettle(); + expect(value, isNot(equals(0.5))); + // The slider does not have focus after the value is changed. + expect(focusNode.hasFocus, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/139281 + testWidgets('Overlay remains when Slider thumb is interacted', (WidgetTester tester) async { + var value = 0.5; + const overlayColor = Color(0xffff0000); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + overlayColor: const MaterialStatePropertyAll<Color?>(overlayColor), + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ); + }, + ), + ), + ), + ), + ); + // Slider does not have overlay when enabled and not tapped. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + final Offset sliderCenter = tester.getCenter(find.byType(Slider)); + // Tap and hold down on the thumb to keep it active. + final TestGesture gesture = await tester.createGesture(); + await gesture.addPointer(); + await gesture.down(sliderCenter); + await tester.pumpAndSettle(); + expect(Material.of(tester.element(find.byType(Slider))), paints..circle(color: overlayColor)); + // Hover on the slider but outside the thumb. + await gesture.moveTo(tester.getTopLeft(find.byType(Slider))); + await tester.pumpAndSettle(); + expect(Material.of(tester.element(find.byType(Slider))), paints..circle(color: overlayColor)); + // Tap up on the slider. + await gesture.up(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + }); + + testWidgets( + 'Overlay appear only when hovered on the thumb on desktop', + (WidgetTester tester) async { + var value = 0.5; + const overlayColor = Color(0xffff0000); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + overlayColor: const MaterialStatePropertyAll<Color?>(overlayColor), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not hovered. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + + // Hover on the slider but outside the thumb. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getTopLeft(find.byType(Slider))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + + // Hover on the thumb. + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + await tester.pumpAndSettle(); + expect(Material.of(tester.element(find.byType(Slider))), paints..circle(color: overlayColor)); + + // Hover on the slider but outside the thumb. + await gesture.moveTo(tester.getBottomRight(find.byType(Slider))); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('Overlay remains when Slider is in focus on desktop', (WidgetTester tester) async { + var value = 0.5; + const overlayColor = Color(0xffff0000); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + focusNode: focusNode, + overlayColor: const MaterialStatePropertyAll<Color?>(overlayColor), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not tapped. + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + + final Offset sliderCenter = tester.getCenter(find.byType(Slider)); + var tapLocation = Offset(sliderCenter.dx + 50, sliderCenter.dy); + + // Tap somewhere to bring overlay. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.down(tapLocation); + await gesture.up(); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + expect(Material.of(tester.element(find.byType(Slider))), paints..circle(color: overlayColor)); + + tapLocation = Offset(sliderCenter.dx - 50, sliderCenter.dy); + await gesture.down(tapLocation); + await gesture.up(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + // Overlay is removed when adjusted with a tap. + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: overlayColor)), + ); + }, variant: TargetPlatformVariant.desktop()); + + // Regression test for https://github.com/flutter/flutter/issues/123313, which only occurs on desktop platforms. + testWidgets( + 'Value indicator disappears after adjusting the slider on desktop', + (WidgetTester tester) async { + final theme = ThemeData(); + const currentValue = 0.5; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider( + value: currentValue, + divisions: 5, + label: currentValue.toStringAsFixed(1), + onChanged: (_) {}, + ), + ), + ), + ), + ); + + // Slider does not show value indicator initially. + await tester.pumpAndSettle(); + RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + isNot( + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ), + ); + + final Offset sliderCenter = tester.getCenter(find.byType(Slider)); + final tapLocation = Offset(sliderCenter.dx + 50, sliderCenter.dy); + + // Tap the slider by mouse to bring up the value indicator. + await tester.tapAt(tapLocation, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + + // Value indicator is visible. + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ); + + // Wait for the value indicator to disappear. + await tester.pumpAndSettle(const Duration(seconds: 2)); + + // Value indicator is no longer visible. + expect( + valueIndicatorBox, + isNot( + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ), + ); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'Value indicator remains when Slider is in focus on desktop', + (WidgetTester tester) async { + var value = 0.5; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData( + sliderTheme: const SliderThemeData(showValueIndicator: ShowValueIndicator.always), + ), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + focusNode: focusNode, + divisions: 5, + label: value.toStringAsFixed(1), + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not show value indicator without focus. + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + isNot( + paints + ..path(color: const Color(0xff000000)) + ..paragraph(), + ), + ); + + final Offset sliderCenter = tester.getCenter(find.byType(Slider)); + final tapLocation = Offset(sliderCenter.dx + 50, sliderCenter.dy); + + // Tap somewhere to bring value indicator. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.down(tapLocation); + await gesture.up(); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..path(color: const Color(0xff000000)) + ..paragraph(), + ); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + expect( + valueIndicatorBox, + isNot( + paints + ..path(color: const Color(0xff000000)) + ..paragraph(), + ), + ); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('showValueIndicator takes priority over theme', (WidgetTester tester) async { + Widget buildApp({ + required ShowValueIndicator? themeShowValueIndicator, + required ShowValueIndicator? sliderShowValueIndicator, + }) { + return MaterialApp( + home: Material( + child: Center( + child: SliderTheme( + data: SliderThemeData( + valueIndicatorColor: Colors.red, + showValueIndicator: themeShowValueIndicator, + ), + child: Slider( + value: 0.5, + label: '0.5', + onChanged: (double newValue) {}, + showValueIndicator: sliderShowValueIndicator, + ), + ), + ), + ), + ); + } + + void checkValueIndicator({required bool isVisible}) { + // _RenderValueIndicator is the last render object in the tree. + final RenderObject valueIndicatorBox = tester.allRenderObjects.last; + final PaintPattern matcher = paints + ..path(color: Colors.red) + ..paragraph(); + expect(valueIndicatorBox, isVisible ? matcher : isNot(matcher)); + } + + await tester.pumpWidget( + buildApp(themeShowValueIndicator: ShowValueIndicator.never, sliderShowValueIndicator: null), + ); + checkValueIndicator(isVisible: false); + + await tester.pumpWidget( + buildApp( + themeShowValueIndicator: ShowValueIndicator.never, + sliderShowValueIndicator: ShowValueIndicator.alwaysVisible, + ), + ); + checkValueIndicator(isVisible: true); + + await tester.pumpWidget( + buildApp( + themeShowValueIndicator: ShowValueIndicator.alwaysVisible, + sliderShowValueIndicator: ShowValueIndicator.never, + ), + ); + checkValueIndicator(isVisible: false); + }); + + testWidgets('Event on Slider should perform no-op if already unmounted', ( + WidgetTester tester, + ) async { + // Test covering crashing found in Google internal issue b/192329942. + var value = 0.0; + final shouldShowSliderListenable = ValueNotifier<bool>(true); + addTearDown(shouldShowSliderListenable.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: ValueListenableBuilder<bool>( + valueListenable: shouldShowSliderListenable, + builder: (BuildContext context, bool shouldShowSlider, _) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + // Note: it is important that `onTap` is non-null so + // [GestureDetector] will register tap events. + onTap: () {}, + child: shouldShowSlider + ? Slider( + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ) + : const SizedBox.expand(), + ); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Move Slider. + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(Slider)).center, + ); + await gesture.moveBy(const Offset(1.0, 0.0)); + await tester.pumpAndSettle(); + + // Hide Slider. Slider will dispose and unmount. + shouldShowSliderListenable.value = false; + await tester.pumpAndSettle(); + + // Move Slider after unmounted. + await gesture.moveBy(const Offset(1.0, 0.0)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), null); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Slider can be hovered and has correct hover color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(useMaterial3: false); + var value = 0.5; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not hovered. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + + // Start hovering. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + + // Slider has overlay when enabled and hovered. + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.12)), + ); + + // Slider does not have an overlay when disabled and hovered. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + }); + + testWidgets('Material2 - Slider is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Slider'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(useMaterial3: false); + var value = 0.5; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Check that the overlay shows when focused. + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.12)), + ); + + // Check that the overlay does not show when unfocused and disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + }); + + testWidgets('Material2 - Slider is draggable and has correct dragged color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + final theme = ThemeData(useMaterial3: false); + final Key sliderKey = UniqueKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + key: sliderKey, + value: value, + focusNode: focusNode, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not have overlay when enabled and not dragged. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + + // Start dragging. + final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(sliderKey))); + await tester.pump(kPressTimeout); + + // Less than configured touch slop, more than default touch slop + await drag.moveBy(const Offset(19.0, 0)); + await tester.pump(); + + // Slider has overlay when enabled and dragged. + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.12)), + ); + + await drag.up(); + await tester.pumpAndSettle(); + + // Slider without focus doesn't have overlay when enabled and dragged. + expect(focusNode.hasFocus, false); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: theme.colorScheme.primary.withOpacity(0.12))), + ); + }); + }); + + group('Slider.allowedInteraction', () { + testWidgets('SliderInteraction.tapOnly', (WidgetTester tester) async { + var value = 1.0; + final Key sliderKey = UniqueKey(); + // (slider's left padding (overlayRadius), windowHeight / 2) + const startOfTheSliderTrack = Offset(24, 300); + const centerOfTheSlideTrack = Offset(400, 300); + final logs = <String>[]; + + Widget buildWidget() => MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext _, StateSetter setState) { + return Slider( + value: value, + key: sliderKey, + allowedInteraction: SliderInteraction.tapOnly, + onChangeStart: (double newValue) { + logs.add('onChangeStart'); + }, + onChanged: (double newValue) { + logs.add('onChanged'); + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double newValue) { + logs.add('onChangeEnd'); + }, + ); + }, + ), + ), + ), + ); + + // allow tap only + await tester.pumpWidget(buildWidget()); + + expect(logs, isEmpty); + + // test tap + final TestGesture gesture = await tester.startGesture(centerOfTheSlideTrack); + await tester.pump(); + // changes from 1.0 -> 0.5 + expect(value, 0.5); + expect(logs, <String>['onChangeStart', 'onChanged']); + + // test slide + await gesture.moveTo(startOfTheSliderTrack); + await tester.pump(); + // has no effect, remains 0.5 + expect(value, 0.5); + expect(logs, <String>['onChangeStart', 'onChanged']); + + await gesture.up(); + await tester.pump(); + expect(logs, <String>['onChangeStart', 'onChanged', 'onChangeEnd']); + }); + + testWidgets('SliderInteraction.tapAndSlide (default)', (WidgetTester tester) async { + var value = 1.0; + final Key sliderKey = UniqueKey(); + // (slider's left padding (overlayRadius), windowHeight / 2) + const startOfTheSliderTrack = Offset(24, 300); + const centerOfTheSlideTrack = Offset(400, 300); + const endOfTheSliderTrack = Offset(800 - 24, 300); + final logs = <String>[]; + + Widget buildWidget() => MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext _, StateSetter setState) { + return Slider( + value: value, + key: sliderKey, + onChangeStart: (double newValue) { + logs.add('onChangeStart'); + }, + onChanged: (double newValue) { + logs.add('onChanged'); + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double newValue) { + logs.add('onChangeEnd'); + }, + ); + }, + ), + ), + ), + ); + + await tester.pumpWidget(buildWidget()); + + expect(logs, isEmpty); + + // Test tap. + final TestGesture gesture = await tester.startGesture(centerOfTheSlideTrack); + await tester.pump(); + // changes from 1.0 -> 0.5 + expect(value, 0.5); + expect(logs, <String>['onChangeStart', 'onChanged']); + + // test slide + await gesture.moveTo(startOfTheSliderTrack); + await tester.pump(); + // changes from 0.5 -> 0.0 + expect(value, 0.0); + await gesture.moveTo(endOfTheSliderTrack); + await tester.pump(); + // changes from 0.0 -> 1.0 + expect(value, 1.0); + expect(logs, <String>['onChangeStart', 'onChanged', 'onChanged', 'onChanged']); + + await gesture.up(); + await tester.pump(); + + expect(logs, <String>['onChangeStart', 'onChanged', 'onChanged', 'onChanged', 'onChangeEnd']); + }); + + testWidgets('SliderInteraction.slideOnly', (WidgetTester tester) async { + const double overlayRadius = 23; + const Color overlayColor = Colors.red; + var value = 1.0; + final Key sliderKey = UniqueKey(); + // (slider's left padding (overlayRadius), windowHeight / 2) + const startOfTheSliderTrack = Offset(overlayRadius, 300); + const centerOfTheSliderTrack = Offset(400, 300); + const endOfTheSliderTrack = Offset(800 - overlayRadius, 300); + final xPosThumb = Tween<double>(begin: startOfTheSliderTrack.dx, end: endOfTheSliderTrack.dx); + final logs = <String>[]; + + Widget buildApp() { + return MaterialApp( + theme: ThemeData( + sliderTheme: const SliderThemeData( + overlayColor: overlayColor, + overlayShape: RoundSliderOverlayShape(overlayRadius: overlayRadius), + ), + ), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext _, StateSetter setState) { + return Slider( + value: value, + key: sliderKey, + allowedInteraction: SliderInteraction.slideOnly, + onChangeStart: (double newValue) { + logs.add('onChangeStart'); + }, + onChanged: (double newValue) { + logs.add('onChanged'); + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double newValue) { + logs.add('onChangeEnd'); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final Element sliderContext = tester.element(find.byType(Slider)); + final MaterialInkController material = Material.of(sliderContext); + + expect(logs, isEmpty); + + // Test tap. + final TestGesture gesture = await tester.startGesture(centerOfTheSliderTrack); + // Start animation. + await tester.pump(); + // Go to mid-animation frame. + await tester.pump(kRadialReactionDuration * 0.5); + expect(material, paints..circle(color: overlayColor, x: xPosThumb.transform(value))); + // We have a non-linear asymmetric curve, so just verify the radius is not full. + expect( + material, + isNot( + paints..circle(color: overlayColor, radius: overlayRadius, x: xPosThumb.transform(value)), + ), + ); + + // Finish animation. + await tester.pumpAndSettle(); + // Overlay drawn. + expect( + material, + paints..circle(color: overlayColor, radius: overlayRadius, x: xPosThumb.transform(value)), + ); + // Has no other effect as tap is disabled, remains 1.0. + expect(value, 1.0); + expect(logs, <String>['onChangeStart']); + + // Test slide. + await gesture.moveTo(startOfTheSliderTrack); + await tester.pump(); + // Changes from 1.0 -> 0.5. + expect(value, 0.5); + // Overlay still there. + expect( + material, + paints..circle(color: overlayColor, radius: overlayRadius, x: xPosThumb.transform(value)), + ); + await gesture.moveTo(endOfTheSliderTrack); + await tester.pump(); + // Changes from 0.0 -> 1.0. + expect(value, 1.0); + expect(logs, <String>['onChangeStart', 'onChanged', 'onChanged']); + // Overlay still there. + expect( + material, + paints..circle(color: overlayColor, radius: overlayRadius, x: xPosThumb.transform(value)), + ); + + await gesture.up(); + + // Start release animation. + await tester.pump(); + // Go to mid-animation frame. + await tester.pump(kRadialReactionDuration * 0.5); + expect(material, paints..circle(color: overlayColor, x: xPosThumb.transform(value))); + // Verify the radius is not full. + expect( + material, + isNot( + paints..circle(color: overlayColor, radius: overlayRadius, x: xPosThumb.transform(value)), + ), + ); + + await tester.pumpAndSettle(); + // No overlay drawn. + expect(material, isNot(paints..circle(color: overlayColor, radius: overlayRadius))); + + expect(logs, <String>['onChangeStart', 'onChanged', 'onChanged', 'onChangeEnd']); + }); + + testWidgets('SliderInteraction.slideThumb', (WidgetTester tester) async { + var value = 1.0; + final Key sliderKey = UniqueKey(); + // (slider's left padding (overlayRadius), windowHeight / 2) + const startOfTheSliderTrack = Offset(24, 300); + const centerOfTheSliderTrack = Offset(400, 300); + const endOfTheSliderTrack = Offset(800 - 24, 300); + final logs = <String>[]; + + Widget buildApp() { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext _, StateSetter setState) { + return Slider( + value: value, + key: sliderKey, + allowedInteraction: SliderInteraction.slideThumb, + onChangeStart: (double newValue) { + logs.add('onChangeStart'); + }, + onChanged: (double newValue) { + logs.add('onChanged'); + setState(() { + value = newValue; + }); + }, + onChangeEnd: (double newValue) { + logs.add('onChangeEnd'); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + expect(logs, isEmpty); + + // test tap + final TestGesture gesture = await tester.startGesture(centerOfTheSliderTrack); + await tester.pump(); + // has no effect, remains 1.0 + expect(value, 1.0); + expect(logs, isEmpty); + + // test slide + await gesture.moveTo(startOfTheSliderTrack); + await tester.pump(); + // has no effect, remains 1.0 + expect(value, 1.0); + expect(logs, isEmpty); + + // test slide thumb + await gesture.up(); + await gesture.down(endOfTheSliderTrack); // where the thumb is + await tester.pump(); + // has no effect, remains 1.0 + expect(value, 1.0); + expect(logs, <String>['onChangeStart']); + + await gesture.moveTo(centerOfTheSliderTrack); + await tester.pump(); + // changes from 1.0 -> 0.5 + expect(value, 0.5); + expect(logs, <String>['onChangeStart', 'onChanged']); + + // test tap inside overlay but not on thumb, then slide + await gesture.up(); + // default overlay radius is 12, so 10 is inside the overlay + await gesture.down(centerOfTheSliderTrack.translate(-10, 0)); + await tester.pump(); + // changes from 1.0 -> 0.5 + expect(value, 0.5); + expect(logs, <String>['onChangeStart', 'onChanged', 'onChangeEnd', 'onChangeStart']); + + await gesture.moveTo(endOfTheSliderTrack.translate(-10, 0)); + await tester.pump(); + // changes from 0.5 -> 1.0 + expect(value, 1.0); + expect(logs, <String>[ + 'onChangeStart', + 'onChanged', + 'onChangeEnd', + 'onChangeStart', + 'onChanged', + ]); + + await gesture.up(); + await tester.pump(); + + expect(logs, <String>[ + 'onChangeStart', + 'onChanged', + 'onChangeEnd', + 'onChangeStart', + 'onChanged', + 'onChangeEnd', + ]); + }); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/143524. + testWidgets('Discrete Slider.onChanged is called only once', (WidgetTester tester) async { + var onChangeCallbackCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Slider( + max: 5, + divisions: 5, + value: 0, + onChanged: (double newValue) { + onChangeCallbackCount++; + }, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getTopLeft(find.byType(Slider))); + await tester.pump(kLongPressTimeout); + await gesture.moveBy(const Offset(160.0, 0.0)); + await gesture.moveBy(const Offset(1.0, 0.0)); + await gesture.moveBy(const Offset(1.0, 0.0)); + expect(onChangeCallbackCount, 1); + }); + + testWidgets('Skip drawing ValueIndicator shape when label painter text is null', ( + WidgetTester tester, + ) async { + double sliderValue = 10; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, void Function(void Function()) setState) { + return Material( + child: Slider( + value: sliderValue, + max: 100, + label: sliderValue > 50 ? null : sliderValue.toString(), + divisions: 10, + onChanged: (double value) { + setState(() { + sliderValue = value; + }); + }, + ), + ); + }, + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Calculate a specific position on the Slider. + final Rect sliderRect = tester.getRect(find.byType(Slider)); + final tapPositionLeft = Offset(sliderRect.left + sliderRect.width * 0.25, sliderRect.center.dy); + final tapPositionRight = Offset( + sliderRect.left + sliderRect.width * 0.75, + sliderRect.center.dy, + ); + + // Tap on the 25% position of the Slider. + await tester.tapAt(tapPositionLeft); + await tester.pumpAndSettle(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); + + // Tap on the 75% position of the Slider. + await tester.tapAt(tapPositionRight); + await tester.pumpAndSettle(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); + }); + + testWidgets('Slider value indicator is shown when using arrow keys', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + var startValue = 0.0; + var currentValue = 0.5; + var endValue = 0.0; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: currentValue, + divisions: 5, + label: currentValue.toStringAsFixed(1), + onChangeStart: (double newValue) { + setState(() { + startValue = newValue; + }); + }, + onChanged: (double newValue) { + setState(() { + currentValue = newValue; + }); + }, + onChangeEnd: (double newValue) { + setState(() { + endValue = newValue; + }); + }, + autofocus: true, + ); + }, + ), + ), + ), + ), + ); + + // Slider shows value indicator initially on focus. + await tester.pumpAndSettle(); + RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ); + + // Right arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValue, 0.6); + expect(currentValue.toStringAsFixed(1), '0.8'); + expect(endValue.toStringAsFixed(1), '0.8'); + + // Value indicator is visible. + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ); + + // Left arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValue, 0.8); + expect(currentValue.toStringAsFixed(1), '0.6'); + expect(endValue.toStringAsFixed(1), '0.6'); + + // Value indicator is still visible. + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ); + + // Up arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValue, 0.6); + expect(currentValue.toStringAsFixed(1), '0.8'); + expect(endValue.toStringAsFixed(1), '0.8'); + + // Value indicator is still visible. + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ); + + // Down arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValue, 0.8); + expect(currentValue.toStringAsFixed(1), '0.6'); + expect(endValue.toStringAsFixed(1), '0.6'); + + // Value indicator is still visible. + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..path(color: theme.colorScheme.primary), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Value indicator label is shown when focused', (WidgetTester tester) async { + var value = 0.5; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget buildApp() { + return MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + focusNode: focusNode, + divisions: 5, + label: value.toStringAsFixed(1), + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Slider does not show value indicator without focus. + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, false); + RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + isNot( + paints + ..path(color: const Color(0xff000000)) + ..paragraph(), + ), + ); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, true); + + // Slider shows value indicator when focused. + valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..path(color: const Color(0xff000000)) + ..paragraph(), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Slider.padding can override the default Slider padding', ( + WidgetTester tester, + ) async { + Widget buildSlider({EdgeInsetsGeometry? padding}) { + return MaterialApp( + home: Material( + child: Center( + child: IntrinsicHeight( + child: Slider(padding: padding, value: 0.5, onChanged: (double value) {}), + ), + ), + ), + ); + } + + RenderBox sliderRenderBox() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderSlider', + ) + as RenderBox; + } + + // Test Slider height and tracks spacing with zero padding. + await tester.pumpWidget(buildSlider(padding: EdgeInsets.zero)); + await tester.pumpAndSettle(); + + // The height equals to the default thumb height. + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(398.0, 8.0, 800.0, 12.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(0.0, 7.0, 402.0, 13.0, const Radius.circular(3.0))), + ); + + // Test Slider height and tracks spacing with directional padding. + const double startPadding = 100; + const double endPadding = 20; + await tester.pumpWidget( + buildSlider( + padding: const EdgeInsetsDirectional.only(start: startPadding, end: endPadding), + ), + ); + await tester.pumpAndSettle(); + + expect(sliderRenderBox().size, const Size(800 - startPadding - endPadding, 20)); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(338.0, 8.0, 680.0, 12.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(0.0, 7.0, 342.0, 13.0, const Radius.circular(3.0))), + ); + + // Test Slider height and tracks spacing with top and bottom padding. + const double topPadding = 100; + const double bottomPadding = 20; + const double trackHeight = 20; + await tester.pumpWidget( + buildSlider( + padding: const EdgeInsetsDirectional.only(top: topPadding, bottom: bottomPadding), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(Slider)), + const Size(800, topPadding + trackHeight + bottomPadding), + ); + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(398.0, 8.0, 800.0, 12.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(0.0, 7.0, 402.0, 13.0, const Radius.circular(3.0))), + ); + }); + + testWidgets('Default Slider when year2023 is false', (WidgetTester tester) async { + debugDisableShadows = false; + try { + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + final Color activeTrackColor = colorScheme.primary; + final Color inactiveTrackColor = colorScheme.secondaryContainer; + final Color secondaryActiveTrackColor = colorScheme.primary.withOpacity(0.54); + final Color disabledActiveTrackColor = colorScheme.onSurface.withOpacity(0.38); + final Color disabledInactiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color disabledSecondaryActiveTrackColor = colorScheme.onSurface.withOpacity(0.38); + final Color activeTickMarkColor = colorScheme.onPrimary; + final Color inactiveTickMarkColor = colorScheme.onSecondaryContainer; + final Color disabledActiveTickMarkColor = colorScheme.onInverseSurface; + final Color disabledInactiveTickMarkColor = colorScheme.onSurface; + final Color thumbColor = colorScheme.primary; + final Color disabledThumbColor = colorScheme.onSurface.withOpacity(0.38); + final Color valueIndicatorColor = colorScheme.inverseSurface; + var value = 0.45; + Widget buildApp({int? divisions, bool enabled = true}) { + final ValueChanged<double>? onChanged = !enabled + ? null + : (double d) { + value = d; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: theme, + child: Slider( + year2023: false, + value: value, + secondaryTrackValue: 0.75, + label: '$value', + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track shape. + const trackOuterCornerRadius = Radius.circular(8.0); + const trackInnerCornerRadius = Radius.circular(2.0); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 356.4, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + color: activeTrackColor, + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 368.4, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + color: inactiveTrackColor, + ), + ); + + // Test default colors for enabled slider. + expect( + material, + paints + ..circle() + ..rrect(color: thumbColor), + ); + expect( + material, + isNot( + paints + ..circle() + ..circle(color: disabledThumbColor), + ), + ); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + + // Test defaults colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 3)); + expect( + material, + paints + ..rrect(color: activeTrackColor) + ..rrect(color: inactiveTrackColor) + ..rrect(color: secondaryActiveTrackColor) + ..circle(color: activeTickMarkColor) + ..circle(color: activeTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ..circle(color: inactiveTickMarkColor), + ); + expect(material, isNot(paints..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + + // Test defaults colors for disabled slider. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + material, + paints + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledSecondaryActiveTrackColor), + ); + expect( + material, + paints + ..circle() + ..rrect(color: disabledThumbColor), + ); + expect( + material, + isNot( + paints + ..circle() + ..rrect(color: thumbColor), + ), + ); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + + // Test defaults colors for disabled discrete slider. + await tester.pumpWidget(buildApp(divisions: 3, enabled: false)); + expect( + material, + paints + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledSecondaryActiveTrackColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..rrect(color: disabledThumbColor), + ); + expect( + material, + isNot( + paints + ..circle() + ..rrect(color: thumbColor), + ), + ); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + + await tester.pumpWidget(buildApp(divisions: 3)); + await tester.pumpAndSettle(); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..rrect(color: valueIndicatorColor), + ); + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Slider value indicator text when year2023 is false', (WidgetTester tester) async { + const double value = 50; + final log = <InlineSpan>[]; + final loggingValueIndicatorShape = LoggingValueIndicatorShape(log); + final theme = ThemeData( + sliderTheme: SliderThemeData(valueIndicatorShape: loggingValueIndicatorShape), + ); + + Widget buildSlider() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider( + year2023: false, + max: 100.0, + divisions: 4, + label: '${value.round()}', + value: value, + onChanged: (double newValue) {}, + ), + ), + ), + ); + } + + // Normal text + await tester.pumpWidget(buildSlider()); + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(log.last.toPlainText(), '50'); + expect(log.last.style!.fontSize, 14.0); + expect(log.last.style!.color, theme.colorScheme.onInverseSurface); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Can update renderObject when secondaryTrackValue is updated', ( + WidgetTester tester, + ) async { + final log = <Offset?>[]; + final loggingTrackShape = LoggingRoundedRectSliderTrackShape(secondaryOffsetLog: log); + final theme = ThemeData(sliderTheme: SliderThemeData(trackShape: loggingTrackShape)); + Widget buildSlider(double? secondaryTrackValue) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider( + value: 0, + secondaryTrackValue: secondaryTrackValue, + onChanged: (double value) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(null)); + await tester.pumpAndSettle(); + expect(log.last, isNull); + + await tester.pumpWidget(buildSlider(0.2)); + await tester.pumpAndSettle(); + expect(log.last, const Offset(174.4, 300.0)); + + await tester.pumpWidget(buildSlider(0.5)); + await tester.pumpAndSettle(); + expect(log.last, const Offset(400.0, 300.0)); + }); + + // Regression test for hhttps://github.com/flutter/flutter/issues/161805 + testWidgets('Discrete Slider does not apply thumb padding in a non-rounded track shape', ( + WidgetTester tester, + ) async { + // The default track left and right padding. + const sliderPadding = 24.0; + final theme = ThemeData( + sliderTheme: const SliderThemeData( + // Thumb padding is applied based on the track height. + trackHeight: 100, + trackShape: RectangularSliderTrackShape(), + ), + ); + + Widget buildSlider({required double value}) { + return MaterialApp( + theme: theme, + home: Material( + child: SizedBox( + width: 300, + child: Slider(value: value, max: 100, divisions: 100, onChanged: (double value) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(value: 0)); + + MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect(material, paints..circle(x: sliderPadding, y: 300.0, color: theme.colorScheme.primary)); + + await tester.pumpWidget(buildSlider(value: 100)); + await tester.pumpAndSettle(); + + material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints..circle(x: 800.0 - sliderPadding, y: 300.0, color: theme.colorScheme.primary), + ); + }); + + testWidgets('Slider does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: Slider(value: 1, onChanged: (_) {})), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Slider)), Size.zero); + }); +} + +// A slider value indicator that's a circle with a fixed size and +// does not animate at all. +// +// This allows test cases to verify whether a `Slider` removes the value +// indicator painter after the animation is dismissed (a more strict requirement +// than "painting nothing"). The default value indicator shape is not suitable +// for this job since it does not paint anything when animation is dismissed. +class _FixedSizeCircle extends SliderComponentShape { + const _FixedSizeCircle(); + + static const circleDiameter = 40.0; + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter? labelPainter, + double? textScaleFactor, + }) => const Size.square(circleDiameter); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation<double> activationAnimation, + required Animation<double> enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + final paint = Paint() + ..color = sliderTheme.valueIndicatorColor ?? Colors.purple + ..style = PaintingStyle.fill; + + canvas.drawCircle(center, circleDiameter / 2, paint); + labelPainter.paint(canvas, center); + } +} diff --git a/packages/material_ui/test/material/slider_theme_test.dart b/packages/material_ui/test/material/slider_theme_test.dart new file mode 100644 index 000000000000..c42647da305e --- /dev/null +++ b/packages/material_ui/test/material/slider_theme_test.dart @@ -0,0 +1,3719 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SliderThemeData copyWith, ==, hashCode basics', () { + expect(const SliderThemeData(), const SliderThemeData().copyWith()); + expect(const SliderThemeData().hashCode, const SliderThemeData().copyWith().hashCode); + }); + + test('SliderThemeData lerp special cases', () { + const data = SliderThemeData(); + expect(identical(SliderThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Default SliderThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SliderThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('SliderThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SliderThemeData( + trackHeight: 7.0, + activeTrackColor: Color(0xFF000001), + inactiveTrackColor: Color(0xFF000002), + secondaryActiveTrackColor: Color(0xFF000003), + disabledActiveTrackColor: Color(0xFF000004), + disabledInactiveTrackColor: Color(0xFF000005), + disabledSecondaryActiveTrackColor: Color(0xFF000006), + activeTickMarkColor: Color(0xFF000007), + inactiveTickMarkColor: Color(0xFF000008), + disabledActiveTickMarkColor: Color(0xFF000009), + disabledInactiveTickMarkColor: Color(0xFF000010), + thumbColor: Color(0xFF000011), + overlappingShapeStrokeColor: Color(0xFF000012), + disabledThumbColor: Color(0xFF000013), + overlayColor: Color(0xFF000014), + valueIndicatorColor: Color(0xFF000015), + valueIndicatorStrokeColor: Color(0xFF000015), + overlayShape: RoundSliderOverlayShape(), + tickMarkShape: RoundSliderTickMarkShape(), + thumbShape: RoundSliderThumbShape(), + trackShape: RoundedRectSliderTrackShape(), + valueIndicatorShape: PaddleSliderValueIndicatorShape(), + rangeTickMarkShape: RoundRangeSliderTickMarkShape(), + rangeThumbShape: RoundRangeSliderThumbShape(), + rangeTrackShape: RoundedRectRangeSliderTrackShape(), + rangeValueIndicatorShape: PaddleRangeSliderValueIndicatorShape(), + showValueIndicator: ShowValueIndicator.always, + valueIndicatorTextStyle: TextStyle(color: Colors.black), + mouseCursor: WidgetStateMouseCursor.clickable, + allowedInteraction: SliderInteraction.tapOnly, + padding: EdgeInsets.all(1.0), + thumbSize: WidgetStatePropertyAll<Size>(Size(20, 20)), + trackGap: 10.0, + year2023: false, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'trackHeight: 7.0', + 'activeTrackColor: ${const Color(0xff000001)}', + 'inactiveTrackColor: ${const Color(0xff000002)}', + 'secondaryActiveTrackColor: ${const Color(0xff000003)}', + 'disabledActiveTrackColor: ${const Color(0xff000004)}', + 'disabledInactiveTrackColor: ${const Color(0xff000005)}', + 'disabledSecondaryActiveTrackColor: ${const Color(0xff000006)}', + 'activeTickMarkColor: ${const Color(0xff000007)}', + 'inactiveTickMarkColor: ${const Color(0xff000008)}', + 'disabledActiveTickMarkColor: ${const Color(0xff000009)}', + 'disabledInactiveTickMarkColor: ${const Color(0xff000010)}', + 'thumbColor: ${const Color(0xff000011)}', + 'overlappingShapeStrokeColor: ${const Color(0xff000012)}', + 'disabledThumbColor: ${const Color(0xff000013)}', + 'overlayColor: ${const Color(0xff000014)}', + 'valueIndicatorColor: ${const Color(0xff000015)}', + 'valueIndicatorStrokeColor: ${const Color(0xff000015)}', + "overlayShape: Instance of 'RoundSliderOverlayShape'", + "tickMarkShape: Instance of 'RoundSliderTickMarkShape'", + "thumbShape: Instance of 'RoundSliderThumbShape'", + "trackShape: Instance of 'RoundedRectSliderTrackShape'", + "valueIndicatorShape: Instance of 'PaddleSliderValueIndicatorShape'", + "rangeTickMarkShape: Instance of 'RoundRangeSliderTickMarkShape'", + "rangeThumbShape: Instance of 'RoundRangeSliderThumbShape'", + "rangeTrackShape: Instance of 'RoundedRectRangeSliderTrackShape'", + "rangeValueIndicatorShape: Instance of 'PaddleRangeSliderValueIndicatorShape'", + 'showValueIndicator: always', + 'valueIndicatorTextStyle: TextStyle(inherit: true, color: ${const Color(0xff000000)})', + 'mouseCursor: WidgetStateMouseCursor(clickable)', + 'allowedInteraction: tapOnly', + 'padding: EdgeInsets.all(1.0)', + 'thumbSize: WidgetStatePropertyAll(Size(20.0, 20.0))', + 'trackGap: 10.0', + 'year2023: false', + ]); + }); + + testWidgets('Slider defaults', (WidgetTester tester) async { + debugDisableShadows = false; + final theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + const trackHeight = 4.0; + final activeTrackColor = Color(colorScheme.primary.value); + final Color inactiveTrackColor = colorScheme.surfaceContainerHighest; + final Color secondaryActiveTrackColor = colorScheme.primary.withOpacity(0.54); + final Color disabledActiveTrackColor = colorScheme.onSurface.withOpacity(0.38); + final Color disabledInactiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color disabledSecondaryActiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color shadowColor = colorScheme.shadow; + final thumbColor = Color(colorScheme.primary.value); + final Color disabledThumbColor = Color.alphaBlend( + colorScheme.onSurface.withOpacity(0.38), + colorScheme.surface, + ); + final Color activeTickMarkColor = colorScheme.onPrimary.withOpacity(0.38); + final Color inactiveTickMarkColor = colorScheme.onSurfaceVariant.withOpacity(0.38); + final Color disabledActiveTickMarkColor = colorScheme.onSurface.withOpacity(0.38); + final Color disabledInactiveTickMarkColor = colorScheme.onSurface.withOpacity(0.38); + + try { + var value = 0.45; + Widget buildApp({int? divisions, bool enabled = true}) { + final ValueChanged<double>? onChanged = !enabled + ? null + : (double d) { + value = d; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: theme, + child: Slider( + value: value, + secondaryTrackValue: 0.75, + label: '$value', + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track height. + const radius = Radius.circular(trackHeight / 2); + const activatedRadius = Radius.circular((trackHeight + 2) / 2); + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBR(360.4, 298.0, 776.0, 302.0, radius), + color: inactiveTrackColor, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(24.0, 297.0, 364.4, 303.0, activatedRadius), + color: activeTrackColor, + ), + ); + + // Test default colors for enabled slider. + expect( + material, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor) + ..rrect(color: secondaryActiveTrackColor), + ); + expect(material, paints..shadow(color: shadowColor)); + expect(material, paints..circle(color: thumbColor)); + expect(material, isNot(paints..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + expect(material, isNot(paints..circle(color: activeTickMarkColor))); + expect(material, isNot(paints..circle(color: inactiveTickMarkColor))); + + // Test defaults colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 3)); + expect( + material, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor) + ..rrect(color: secondaryActiveTrackColor), + ); + expect( + material, + paints + ..circle(color: activeTickMarkColor) + ..circle(color: activeTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: thumbColor), + ); + expect(material, isNot(paints..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + + // Test defaults colors for disabled slider. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + material, + paints + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledSecondaryActiveTrackColor), + ); + expect( + material, + paints + ..shadow(color: shadowColor) + ..circle(color: disabledThumbColor), + ); + expect(material, isNot(paints..circle(color: thumbColor))); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + + // Test defaults colors for disabled discrete slider. + await tester.pumpWidget(buildApp(divisions: 3, enabled: false)); + expect( + material, + paints + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..shadow(color: shadowColor) + ..circle(color: disabledThumbColor), + ); + expect(material, isNot(paints..circle(color: thumbColor))); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Slider uses the right theme colors for the right components', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + const customColor1 = Color(0xcafefeed); + const customColor2 = Color(0xdeadbeef); + const customColor3 = Color(0xdecaface); + final theme = ThemeData( + useMaterial3: false, + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + disabledThumbColor: Color(0xff000001), + disabledActiveTickMarkColor: Color(0xff000002), + disabledActiveTrackColor: Color(0xff000003), + disabledInactiveTickMarkColor: Color(0xff000004), + disabledInactiveTrackColor: Color(0xff000005), + activeTrackColor: Color(0xff000006), + activeTickMarkColor: Color(0xff000007), + inactiveTrackColor: Color(0xff000008), + inactiveTickMarkColor: Color(0xff000009), + overlayColor: Color(0xff000010), + thumbColor: Color(0xff000011), + valueIndicatorColor: Color(0xff000012), + disabledSecondaryActiveTrackColor: Color(0xff000013), + secondaryActiveTrackColor: Color(0xff000014), + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + var value = 0.45; + Widget buildApp({ + Color? activeColor, + Color? inactiveColor, + Color? secondaryActiveColor, + int? divisions, + bool enabled = true, + }) { + final ValueChanged<double>? onChanged = !enabled + ? null + : (double d) { + value = d; + }; + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: theme, + child: Slider( + value: value, + secondaryTrackValue: 0.75, + label: '$value', + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + secondaryActiveColor: secondaryActiveColor, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Check default theme for enabled widget. + expect( + material, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.activeTrackColor) + ..rrect(color: sliderTheme.secondaryActiveTrackColor), + ); + expect(material, paints..shadow(color: const Color(0xff000000))); + expect(material, paints..circle(color: sliderTheme.thumbColor)); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + expect(material, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(material, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + + // Test setting only the activeColor. + await tester.pumpWidget(buildApp(activeColor: customColor1)); + expect( + material, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: customColor1) + ..rrect(color: sliderTheme.secondaryActiveTrackColor), + ); + expect(material, paints..shadow(color: Colors.black)); + expect(material, paints..circle(color: customColor1)); + expect(material, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + + // Test setting only the inactiveColor. + await tester.pumpWidget(buildApp(inactiveColor: customColor1)); + expect( + material, + paints + ..rrect(color: customColor1) + ..rrect(color: sliderTheme.activeTrackColor) + ..rrect(color: sliderTheme.secondaryActiveTrackColor), + ); + expect(material, paints..shadow(color: Colors.black)); + expect(material, paints..circle(color: sliderTheme.thumbColor)); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + + // Test setting only the secondaryActiveColor. + await tester.pumpWidget(buildApp(secondaryActiveColor: customColor1)); + expect( + material, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.activeTrackColor) + ..rrect(color: customColor1), + ); + expect(material, paints..shadow(color: Colors.black)); + expect(material, paints..circle(color: sliderTheme.thumbColor)); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + + // Test setting both activeColor, inactiveColor, and secondaryActiveColor. + await tester.pumpWidget( + buildApp( + activeColor: customColor1, + inactiveColor: customColor2, + secondaryActiveColor: customColor3, + ), + ); + expect( + material, + paints + ..rrect(color: customColor2) + ..rrect(color: customColor1) + ..rrect(color: customColor3), + ); + expect(material, paints..shadow(color: Colors.black)); + expect(material, paints..circle(color: customColor1)); + expect(material, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + + // Test colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 3)); + expect( + material, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rrect(color: sliderTheme.activeTrackColor) + ..rrect(color: sliderTheme.secondaryActiveTrackColor), + ); + expect( + material, + paints + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: sliderTheme.thumbColor), + ); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + + // Test colors for discrete slider with inactiveColor and activeColor set. + await tester.pumpWidget( + buildApp( + activeColor: customColor1, + inactiveColor: customColor2, + secondaryActiveColor: customColor3, + divisions: 3, + ), + ); + expect( + material, + paints + ..rrect(color: customColor2) + ..rrect(color: customColor1) + ..rrect(color: customColor3), + ); + expect( + material, + paints + ..circle(color: customColor2) + ..circle(color: customColor2) + ..circle(color: customColor1) + ..circle(color: customColor1) + ..shadow(color: Colors.black) + ..circle(color: customColor1), + ); + expect(material, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(material, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor))); + expect(material, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(material, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + + // Test default theme for disabled widget. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + material, + paints + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rrect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor), + ); + expect( + material, + paints + ..shadow(color: Colors.black) + ..circle(color: sliderTheme.disabledThumbColor), + ); + expect(material, isNot(paints..circle(color: sliderTheme.thumbColor))); + // These 2 colors are too close to distinguish. + // expect(material, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.secondaryActiveTrackColor))); + + // Test default theme for disabled discrete widget. + await tester.pumpWidget(buildApp(divisions: 3, enabled: false)); + expect( + material, + paints + ..circle(color: sliderTheme.disabledActiveTickMarkColor) + ..circle(color: sliderTheme.disabledActiveTickMarkColor) + ..circle(color: sliderTheme.disabledInactiveTickMarkColor) + ..circle(color: sliderTheme.disabledInactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: sliderTheme.disabledThumbColor), + ); + expect(material, isNot(paints..circle(color: sliderTheme.thumbColor))); + // These 2 colors are too close to distinguish. + // expect(material, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.secondaryActiveTrackColor))); + expect(material, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(material, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + + // Test setting the activeColor, inactiveColor and secondaryActiveColor for disabled widget. + await tester.pumpWidget( + buildApp( + activeColor: customColor1, + inactiveColor: customColor2, + secondaryActiveColor: customColor3, + enabled: false, + ), + ); + expect( + material, + paints + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rrect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledSecondaryActiveTrackColor), + ); + expect(material, paints..circle(color: sliderTheme.disabledThumbColor)); + expect(material, isNot(paints..circle(color: sliderTheme.thumbColor))); + // These colors are too close to distinguish. + // expect(material, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: sliderTheme.secondaryActiveTrackColor))); + + // Test that the default value indicator has the right colors. + await tester.pumpWidget(buildApp(divisions: 3)); + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(value, equals(2.0 / 3.0)); + expect( + valueIndicatorBox, + paints + ..path(color: sliderTheme.valueIndicatorColor) + ..paragraph(), + ); + await gesture.up(); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + // Testing the custom colors are used for the indicator. + await tester.pumpWidget( + buildApp(divisions: 3, activeColor: customColor1, inactiveColor: customColor2), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(value, equals(2.0 / 3.0)); + expect( + valueIndicatorBox, + paints + ..rrect(color: const Color(0xfffafafa)) + ..rrect(color: customColor2) // Inactive track + ..rrect(color: customColor1) // Active track + ..circle(color: customColor1.withOpacity(0.12)) // overlay + ..circle(color: customColor2) // 1st tick mark + ..circle(color: customColor2) // 2nd tick mark + ..circle(color: customColor2) // 3rd tick mark + ..circle(color: customColor1) // 4th tick mark + ..shadow(color: Colors.black) + ..circle(color: customColor1) // thumb + ..path(color: sliderTheme.valueIndicatorColor), // indicator + ); + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Slider parameters overrides theme properties', (WidgetTester tester) async { + debugDisableShadows = false; + const activeTrackColor = Color(0xffff0001); + const inactiveTrackColor = Color(0xffff0002); + const secondaryActiveTrackColor = Color(0xffff0003); + const thumbColor = Color(0xffff0004); + + final theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + activeTrackColor: Color(0xff000001), + inactiveTickMarkColor: Color(0xff000002), + secondaryActiveTrackColor: Color(0xff000003), + thumbColor: Color(0xff000004), + ), + ); + try { + const value = 0.45; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Slider( + activeColor: activeTrackColor, + inactiveColor: inactiveTrackColor, + secondaryActiveColor: secondaryActiveTrackColor, + thumbColor: thumbColor, + value: value, + secondaryTrackValue: 0.75, + label: '$value', + onChanged: (double value) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test Slider parameters. + expect( + material, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor) + ..rrect(color: secondaryActiveTrackColor), + ); + expect(material, paints..circle(color: thumbColor)); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Slider uses ThemeData slider theme if present', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.red); + final SliderThemeData sliderTheme = theme.sliderTheme; + final SliderThemeData customTheme = sliderTheme.copyWith( + activeTrackColor: Colors.purple, + inactiveTrackColor: Colors.purple.withAlpha(0x3d), + secondaryActiveTrackColor: Colors.purple.withAlpha(0x8a), + ); + + await tester.pumpWidget( + _buildApp(sliderTheme, value: 0.5, secondaryTrackValue: 0.75, enabled: false), + ); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect( + material, + paints + ..rrect(color: customTheme.disabledActiveTrackColor) + ..rrect(color: customTheme.disabledInactiveTrackColor) + ..rrect(color: customTheme.disabledSecondaryActiveTrackColor), + ); + }); + + testWidgets('Slider overrides ThemeData theme if SliderTheme present', ( + WidgetTester tester, + ) async { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.red); + final SliderThemeData sliderTheme = theme.sliderTheme; + final SliderThemeData customTheme = sliderTheme.copyWith( + activeTrackColor: Colors.purple, + inactiveTrackColor: Colors.purple.withAlpha(0x3d), + secondaryActiveTrackColor: Colors.purple.withAlpha(0x8a), + ); + + await tester.pumpWidget( + _buildApp(sliderTheme, value: 0.5, secondaryTrackValue: 0.75, enabled: false), + ); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect( + material, + paints + ..rrect(color: customTheme.disabledActiveTrackColor) + ..rrect(color: customTheme.disabledInactiveTrackColor) + ..rrect(color: customTheme.disabledSecondaryActiveTrackColor), + ); + }); + + testWidgets('SliderThemeData generates correct opacities for fromPrimaryColors', ( + WidgetTester tester, + ) async { + const customColor1 = Color(0xcafefeed); + const customColor2 = Color(0xdeadbeef); + const customColor3 = Color(0xdecaface); + const customColor4 = Color(0xfeedcafe); + + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: customColor1, + primaryColorDark: customColor2, + primaryColorLight: customColor3, + valueIndicatorTextStyle: ThemeData.fallback().textTheme.bodyLarge!.copyWith( + color: customColor4, + ), + ); + + expect(sliderTheme.activeTrackColor, equals(customColor1.withAlpha(0xff))); + expect(sliderTheme.inactiveTrackColor, equals(customColor1.withAlpha(0x3d))); + expect(sliderTheme.secondaryActiveTrackColor, equals(customColor1.withAlpha(0x8a))); + expect(sliderTheme.disabledActiveTrackColor, equals(customColor2.withAlpha(0x52))); + expect(sliderTheme.disabledInactiveTrackColor, equals(customColor2.withAlpha(0x1f))); + expect(sliderTheme.disabledSecondaryActiveTrackColor, equals(customColor2.withAlpha(0x1f))); + expect(sliderTheme.activeTickMarkColor, equals(customColor3.withAlpha(0x8a))); + expect(sliderTheme.inactiveTickMarkColor, equals(customColor1.withAlpha(0x8a))); + expect(sliderTheme.disabledActiveTickMarkColor, equals(customColor3.withAlpha(0x1f))); + expect(sliderTheme.disabledInactiveTickMarkColor, equals(customColor2.withAlpha(0x1f))); + expect(sliderTheme.thumbColor, equals(customColor1.withAlpha(0xff))); + expect(sliderTheme.disabledThumbColor, equals(customColor2.withAlpha(0x52))); + expect(sliderTheme.overlayColor, equals(customColor1.withAlpha(0x1f))); + expect(sliderTheme.valueIndicatorColor, equals(customColor1.withAlpha(0xff))); + expect(sliderTheme.valueIndicatorStrokeColor, equals(customColor1.withAlpha(0xff))); + expect(sliderTheme.valueIndicatorTextStyle!.color, equals(customColor4)); + }); + + testWidgets('SliderThemeData generates correct shapes for fromPrimaryColors', ( + WidgetTester tester, + ) async { + const customColor1 = Color(0xcafefeed); + const customColor2 = Color(0xdeadbeef); + const customColor3 = Color(0xdecaface); + const customColor4 = Color(0xfeedcafe); + + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: customColor1, + primaryColorDark: customColor2, + primaryColorLight: customColor3, + valueIndicatorTextStyle: ThemeData.fallback().textTheme.bodyLarge!.copyWith( + color: customColor4, + ), + ); + + expect(sliderTheme.overlayShape, const RoundSliderOverlayShape()); + expect(sliderTheme.tickMarkShape, const RoundSliderTickMarkShape()); + expect(sliderTheme.thumbShape, const RoundSliderThumbShape()); + expect(sliderTheme.trackShape, const RoundedRectSliderTrackShape()); + expect(sliderTheme.valueIndicatorShape, const PaddleSliderValueIndicatorShape()); + expect(sliderTheme.rangeTickMarkShape, const RoundRangeSliderTickMarkShape()); + expect(sliderTheme.rangeThumbShape, const RoundRangeSliderThumbShape()); + expect(sliderTheme.rangeTrackShape, const RoundedRectRangeSliderTrackShape()); + expect(sliderTheme.rangeValueIndicatorShape, const PaddleRangeSliderValueIndicatorShape()); + }); + + testWidgets('SliderThemeData lerps correctly', (WidgetTester tester) async { + final SliderThemeData sliderThemeBlack = SliderThemeData.fromPrimaryColors( + primaryColor: Colors.black, + primaryColorDark: Colors.black, + primaryColorLight: Colors.black, + valueIndicatorTextStyle: ThemeData.fallback().textTheme.bodyLarge!.copyWith( + color: Colors.black, + ), + ).copyWith(trackHeight: 2.0); + final SliderThemeData sliderThemeWhite = SliderThemeData.fromPrimaryColors( + primaryColor: Colors.white, + primaryColorDark: Colors.white, + primaryColorLight: Colors.white, + valueIndicatorTextStyle: ThemeData.fallback().textTheme.bodyLarge!.copyWith( + color: Colors.white, + ), + ).copyWith(trackHeight: 6.0); + final SliderThemeData lerp = SliderThemeData.lerp(sliderThemeBlack, sliderThemeWhite, 0.5); + const middleGrey = Color(0xff7f7f7f); + + expect(lerp.trackHeight, equals(4.0)); + expect(lerp.activeTrackColor, isSameColorAs(middleGrey.withAlpha(0xff))); + expect(lerp.inactiveTrackColor, isSameColorAs(middleGrey.withAlpha(0x3d))); + expect(lerp.secondaryActiveTrackColor, isSameColorAs(middleGrey.withAlpha(0x8a))); + expect(lerp.disabledActiveTrackColor, isSameColorAs(middleGrey.withAlpha(0x52))); + expect(lerp.disabledInactiveTrackColor, isSameColorAs(middleGrey.withAlpha(0x1f))); + expect(lerp.disabledSecondaryActiveTrackColor, isSameColorAs(middleGrey.withAlpha(0x1f))); + expect(lerp.activeTickMarkColor, isSameColorAs(middleGrey.withAlpha(0x8a))); + expect(lerp.inactiveTickMarkColor, isSameColorAs(middleGrey.withAlpha(0x8a))); + expect(lerp.disabledActiveTickMarkColor, isSameColorAs(middleGrey.withAlpha(0x1f))); + expect(lerp.disabledInactiveTickMarkColor, isSameColorAs(middleGrey.withAlpha(0x1f))); + expect(lerp.thumbColor, isSameColorAs(middleGrey.withAlpha(0xff))); + expect(lerp.disabledThumbColor, isSameColorAs(middleGrey.withAlpha(0x52))); + expect(lerp.overlayColor, isSameColorAs(middleGrey.withAlpha(0x1f))); + expect(lerp.valueIndicatorColor, isSameColorAs(middleGrey.withAlpha(0xff))); + expect(lerp.valueIndicatorStrokeColor, isSameColorAs(middleGrey.withAlpha(0xff))); + expect(lerp.valueIndicatorTextStyle!.color, isSameColorAs(middleGrey.withAlpha(0xff))); + }); + + testWidgets('Default slider track draws correctly', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, secondaryTrackValue: 0.5)); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + const radius = Radius.circular(2); + const activatedRadius = Radius.circular(3); + + // The enabled slider thumb has track segments that extend to and from + // the center of the thumb. + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBR(210.0, 298.0, 776.0, 302.0, radius), + color: sliderTheme.inactiveTrackColor, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(24.0, 297.0, 214.0, 303.0, activatedRadius), + color: sliderTheme.activeTrackColor, + ) + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 212.0, + 298.0, + 400.0, + 302.0, + topRight: radius, + bottomRight: radius, + ), + color: sliderTheme.secondaryActiveTrackColor, + ), + ); + + await tester.pumpWidget( + _buildApp(sliderTheme, value: 0.25, secondaryTrackValue: 0.5, enabled: false), + ); + await tester.pumpAndSettle(); // wait for disable animation + + // The disabled slider thumb is the same size as the enabled thumb. + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBR(210.0, 298.0, 776.0, 302.0, radius), + color: sliderTheme.disabledInactiveTrackColor, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(24.0, 297.0, 214.0, 303.0, activatedRadius), + color: sliderTheme.disabledActiveTrackColor, + ) + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 212.0, + 298.0, + 400.0, + 302.0, + topRight: radius, + bottomRight: radius, + ), + color: sliderTheme.disabledSecondaryActiveTrackColor, + ), + ); + }); + + testWidgets('Default slider overlay draws correctly', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // With no touch, paints only the thumb. + expect( + material, + paints..circle(color: sliderTheme.thumbColor, x: 212.0, y: 300.0, radius: 10.0), + ); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for overlay animation to finish. + await tester.pumpAndSettle(); + + // After touch, paints thumb and overlay. + expect( + material, + paints + ..circle(color: sliderTheme.overlayColor, x: 212.0, y: 300.0, radius: 24.0) + ..circle(color: sliderTheme.thumbColor, x: 212.0, y: 300.0, radius: 10.0), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + // After the gesture is up and complete, it again paints only the thumb. + expect( + material, + paints..circle(color: sliderTheme.thumbColor, x: 212.0, y: 300.0, radius: 10.0), + ); + }); + + testWidgets('Slider can use theme overlay with material states', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + overlayColor: WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return Colors.brown[500]!; + } + + return Colors.transparent; + }), + ); + final focusNode = FocusNode(debugLabel: 'Slider'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = 0.5; + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(sliderTheme: sliderTheme), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Slider( + value: value, + onChanged: enabled + ? (double newValue) { + setState(() { + value = newValue; + }); + } + : null, + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // Check that the overlay shows when focused. + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Slider))), + paints..circle(color: Colors.brown[500]), + ); + + // Check that the overlay does not show when focused and disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Slider))), + isNot(paints..circle(color: Colors.brown[500])), + ); + }); + + testWidgets('Default slider ticker and thumb shape draw correctly', (WidgetTester tester) async { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45)); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect(material, paints..circle(color: sliderTheme.thumbColor, radius: 10.0)); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + + expect(material, paints..circle(color: sliderTheme.disabledThumbColor, radius: 10.0)); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45, divisions: 3)); + await tester.pumpAndSettle(); // wait for enable animation + + expect( + material, + paints + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.thumbColor, radius: 10.0), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45, divisions: 3, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + + expect( + material, + paints + ..circle(color: sliderTheme.disabledActiveTickMarkColor) + ..circle(color: sliderTheme.disabledInactiveTickMarkColor) + ..circle(color: sliderTheme.disabledInactiveTickMarkColor) + ..circle(color: sliderTheme.disabledInactiveTickMarkColor) + ..circle(color: sliderTheme.disabledThumbColor, radius: 10.0), + ); + }); + + testWidgets('Default paddle slider value indicator shape draws correctly', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + final theme = ThemeData( + useMaterial3: false, + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + ); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + thumbColor: Colors.red.shade500, + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), + ); + Widget buildApp( + String value, { + double sliderValue = 0.5, + TextScaler textScaler = TextScaler.noScaling, + }) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material( + child: Row( + children: <Widget>[ + Expanded( + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: sliderValue, + label: value, + divisions: 3, + onChanged: (double d) {}, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp('1')); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(15.9, -40.0), + const Offset(-15.9, -40.0), + ], + excludes: <Offset>[const Offset(16.1, -40.0), const Offset(-16.1, -40.0)], + ), + ); + + await gesture.up(); + + // Test that it expands with a larger label. + await tester.pumpWidget(buildApp('1000')); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(35.9, -40.0), + const Offset(-35.9, -40.0), + ], + excludes: <Offset>[const Offset(36.1, -40.0), const Offset(-36.1, -40.0)], + ), + ); + await gesture.up(); + + // Test that it avoids the left edge of the screen. + await tester.pumpWidget(buildApp('1000000', sliderValue: 0.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(92.0, -40.0), + const Offset(-16.0, -40.0), + ], + excludes: <Offset>[const Offset(98.1, -40.0), const Offset(-20.1, -40.0)], + ), + ); + await gesture.up(); + + // Test that it avoids the right edge of the screen. + await tester.pumpWidget(buildApp('1000000', sliderValue: 1.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(16.0, -40.0), + const Offset(-92.0, -40.0), + ], + excludes: <Offset>[const Offset(20.1, -40.0), const Offset(-98.1, -40.0)], + ), + ); + await gesture.up(); + + // Test that the neck stretches when the text scale gets smaller. + await tester.pumpWidget( + buildApp('1000000', sliderValue: 0.0, textScaler: const TextScaler.linear(0.5)), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -49.0), + const Offset(68.0, -49.0), + const Offset(-24.0, -49.0), + ], + excludes: <Offset>[ + const Offset(98.0, -32.0), // inside full size, outside small + const Offset(-40.0, -32.0), // inside full size, outside small + const Offset(90.1, -49.0), + const Offset(-40.1, -49.0), + ], + ), + ); + await gesture.up(); + + // Test that the neck shrinks when the text scale gets larger. + await tester.pumpWidget( + buildApp('1000000', sliderValue: 0.0, textScaler: const TextScaler.linear(2.5)), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -38.8), + const Offset(92.0, -38.8), + const Offset(8.0, -23.0), // Inside large, outside scale=1.0 + const Offset(-2.0, -23.0), // Inside large, outside scale=1.0 + ], + excludes: <Offset>[const Offset(98.5, -38.8), const Offset(-16.1, -38.8)], + ), + ); + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Default paddle slider value indicator shape draws correctly', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + final theme = ThemeData( + useMaterial3: false, + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + ); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + thumbColor: Colors.red.shade500, + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), + ); + Widget buildApp( + String value, { + double sliderValue = 0.5, + TextScaler textScaler = TextScaler.noScaling, + }) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material( + child: Row( + children: <Widget>[ + Expanded( + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: sliderValue, + label: value, + divisions: 3, + onChanged: (double d) {}, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp('1')); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(15.9, -40.0), + const Offset(-15.9, -40.0), + ], + excludes: <Offset>[const Offset(16.1, -40.0), const Offset(-16.1, -40.0)], + ), + ); + + await gesture.up(); + + // Test that it expands with a larger label. + await tester.pumpWidget(buildApp('1000')); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(35.9, -40.0), + const Offset(-35.9, -40.0), + ], + excludes: <Offset>[const Offset(36.1, -40.0), const Offset(-36.1, -40.0)], + ), + ); + await gesture.up(); + + // Test that it avoids the left edge of the screen. + await tester.pumpWidget(buildApp('1000000', sliderValue: 0.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(92.0, -40.0), + const Offset(-16.0, -40.0), + ], + excludes: <Offset>[const Offset(98.1, -40.0), const Offset(-20.1, -40.0)], + ), + ); + await gesture.up(); + + // Test that it avoids the right edge of the screen. + await tester.pumpWidget(buildApp('1000000', sliderValue: 1.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -40.0), + const Offset(16.0, -40.0), + const Offset(-92.0, -40.0), + ], + excludes: <Offset>[const Offset(20.1, -40.0), const Offset(-98.1, -40.0)], + ), + ); + await gesture.up(); + + // Test that the neck stretches when the text scale gets smaller. + await tester.pumpWidget( + buildApp('1000000', sliderValue: 0.0, textScaler: const TextScaler.linear(0.5)), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -49.0), + const Offset(68.0, -49.0), + const Offset(-24.0, -49.0), + ], + excludes: <Offset>[ + const Offset(98.0, -32.0), // inside full size, outside small + const Offset(-40.0, -32.0), // inside full size, outside small + const Offset(90.1, -49.0), + const Offset(-40.1, -49.0), + ], + ), + ); + await gesture.up(); + + // Test that the neck shrinks when the text scale gets larger. + await tester.pumpWidget( + buildApp('1000000', sliderValue: 0.0, textScaler: const TextScaler.linear(2.5)), + ); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints..path( + color: sliderTheme.valueIndicatorColor, + includes: <Offset>[ + const Offset(0.0, -38.8), + const Offset(92.0, -38.8), + const Offset(8.0, -23.0), // Inside large, outside scale=1.0 + const Offset(-2.0, -23.0), // Inside large, outside scale=1.0 + ], + excludes: <Offset>[const Offset(98.5, -38.8), const Offset(-16.1, -38.8)], + ), + ); + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('The slider track height can be overridden', (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith(trackHeight: 16); + const radius = Radius.circular(8); + const activatedRadius = Radius.circular(9); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Top and bottom are centerY (300) + and - trackRadius (8). + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBR(204.0, 292.0, 776.0, 308.0, radius), + color: sliderTheme.inactiveTrackColor, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(24.0, 291.0, 220.0, 309.0, activatedRadius), + color: sliderTheme.activeTrackColor, + ), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + + // The disabled thumb is smaller so the active track has to paint longer to + // get to the edge. + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBR(204.0, 292.0, 776.0, 308.0, radius), + color: sliderTheme.disabledInactiveTrackColor, + ) + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(24.0, 291.0, 220.0, 309.0, activatedRadius), + color: sliderTheme.disabledActiveTrackColor, + ), + ); + }); + + testWidgets('The default slider thumb shape sizes can be overridden', ( + WidgetTester tester, + ) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7, disabledThumbRadius: 11), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect(material, paints..circle(x: 212, y: 300, radius: 7, color: sliderTheme.thumbColor)); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + + expect( + material, + paints..circle(x: 212, y: 300, radius: 11, color: sliderTheme.disabledThumbColor), + ); + }); + + testWidgets( + 'The default slider thumb shape disabled size can be inferred from the enabled size', + (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect(material, paints..circle(x: 212, y: 300, radius: 9, color: sliderTheme.thumbColor)); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + expect( + material, + paints..circle(x: 212, y: 300, radius: 9, color: sliderTheme.disabledThumbColor), + ); + }, + ); + + testWidgets('The default slider tick mark shape size can be overridden', ( + WidgetTester tester, + ) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 5), + activeTickMarkColor: const Color(0xfadedead), + inactiveTickMarkColor: const Color(0xfadebeef), + disabledActiveTickMarkColor: const Color(0xfadecafe), + disabledInactiveTickMarkColor: const Color(0xfadeface), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2)); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect( + material, + paints + ..circle(x: 26, y: 300, radius: 5, color: sliderTheme.activeTickMarkColor) + ..circle(x: 400, y: 300, radius: 5, color: sliderTheme.activeTickMarkColor) + ..circle(x: 774, y: 300, radius: 5, color: sliderTheme.inactiveTickMarkColor), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2, enabled: false)); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle(x: 26, y: 300, radius: 5, color: sliderTheme.disabledActiveTickMarkColor) + ..circle(x: 400, y: 300, radius: 5, color: sliderTheme.disabledActiveTickMarkColor) + ..circle(x: 774, y: 300, radius: 5, color: sliderTheme.disabledInactiveTickMarkColor), + ); + }); + + testWidgets('The default slider overlay shape size can be overridden', ( + WidgetTester tester, + ) async { + const double uniqueOverlayRadius = 23; + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + overlayShape: const RoundSliderOverlayShape(overlayRadius: uniqueOverlayRadius), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5)); + // Tap center and wait for animation. + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints..circle( + x: center.dx, + y: center.dy, + radius: uniqueOverlayRadius, + color: sliderTheme.overlayColor, + ), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/74503 + testWidgets( + 'The slider track layout correctly when the overlay size is smaller than the thumb size', + (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + overlayShape: SliderComponentShape.noOverlay, + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5)); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // The track rectangle begins at 10 pixels from the left of the screen and ends 10 pixels from the right + // (790 pixels from the left). The main check here it that the track itself should be centered on + // the 800 pixel-wide screen. + expect( + material, + paints + // Inactive track RRect. Ends 10 pixels from right of screen. + ..rrect(rrect: RRect.fromLTRBR(398.0, 298.0, 790.0, 302.0, const Radius.circular(2.0))) + // Active track RRect. Starts 10 pixels from left of screen. + ..rrect(rrect: RRect.fromLTRBR(10.0, 297.0, 402.0, 303.0, const Radius.circular(3.0))) + // The thumb. + ..circle(x: 400.0, y: 300.0, radius: 10.0), + ); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/125467 + testWidgets( + 'The RangeSlider track layout correctly when the overlay size is smaller than the thumb size', + (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + overlayShape: SliderComponentShape.noOverlay, + ); + + await tester.pumpWidget(_buildRangeApp(sliderTheme, values: const RangeValues(0.0, 1.0))); + + final MaterialInkController material = Material.of(tester.element(find.byType(RangeSlider))); + + // The track rectangle begins at 10 pixels from the left of the screen and ends 10 pixels from the right + // (790 pixels from the left). The main check here it that the track itself should be centered on + // the 800 pixel-wide screen. + expect( + material, + paints + // active track RRect. Starts 10 pixels from left of screen. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 298.0, + 10.0, + 302.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // inactive track RRect. Ends 10 pixels from right of screen. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 790.0, + 298.0, + 790.0, + 302.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // active track RRect Start 10 pixels from left screen. + ..rrect(rrect: RRect.fromLTRBR(8.0, 297.0, 792.0, 303.0, const Radius.circular(2.0))) + // The thumb Left. + ..circle(x: 10.0, y: 300.0, radius: 10.0) + // The thumb Right. + ..circle(x: 790.0, y: 300.0, radius: 10.0), + ); + }, + ); + + // Only the thumb, overlay, and tick mark have special shortcuts to provide + // no-op or empty shapes. + // + // The track can also be skipped by providing 0 height. + // + // The value indicator can be skipped by passing the appropriate + // [ShowValueIndicator]. + testWidgets('The slider can skip all of its component painting', (WidgetTester tester) async { + // Pump a slider with all shapes skipped. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.never, + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect(material, paintsExactlyCountTimes(#drawRect, 0)); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + expect(material, paintsExactlyCountTimes(#drawPath, 0)); + }); + + testWidgets('The slider can skip all component painting except the track', ( + WidgetTester tester, + ) async { + // Pump a slider with just a track. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.never, + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Only 2 track segments. + expect(material, paintsExactlyCountTimes(#drawRRect, 2)); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + expect(material, paintsExactlyCountTimes(#drawPath, 0)); + }); + + testWidgets('The slider can skip all component painting except the tick marks', ( + WidgetTester tester, + ) async { + // Pump a slider with just tick marks. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + showValueIndicator: ShowValueIndicator.never, + // When the track is hidden to 0 height, a tick mark radius + // must be provided to get a non-zero radius. + tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 1), + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Only 5 tick marks. + expect(material, paintsExactlyCountTimes(#drawRect, 0)); + expect(material, paintsExactlyCountTimes(#drawCircle, 5)); + expect(material, paintsExactlyCountTimes(#drawPath, 0)); + }); + + testWidgets('The slider can skip all component painting except the thumb', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + // Pump a slider with just a thumb. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.never, + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Only 1 thumb. + expect(material, paintsExactlyCountTimes(#drawRect, 0)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + expect(material, paintsExactlyCountTimes(#drawPath, 0)); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('The slider can skip all component painting except the overlay', ( + WidgetTester tester, + ) async { + // Pump a slider with just an overlay. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.never, + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Tap the center of the track and wait for animations to finish. + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Only 1 overlay. + expect(material, paintsExactlyCountTimes(#drawRect, 0)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + expect(material, paintsExactlyCountTimes(#drawPath, 0)); + + await gesture.up(); + }); + + testWidgets('The slider can skip all component painting except the value indicator', ( + WidgetTester tester, + ) async { + // Pump a slider with just a value indicator. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.always, + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Tap the center of the track and wait for animations to finish. + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + // Only 1 value indicator. + expect(material, paintsExactlyCountTimes(#drawRect, 0)); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); + + await gesture.up(); + }); + + testWidgets('PaddleSliderValueIndicatorShape skips all painting at zero scale', ( + WidgetTester tester, + ) async { + // Pump a slider with just a value indicator. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), + ), + value: 0.5, + divisions: 4, + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + + // Nothing to paint at scale 0. + await tester.pump(); + expect(material, paintsExactlyCountTimes(#drawRect, 0)); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for the value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); + + await gesture.up(); + }); + + testWidgets('Default slider value indicator shape skips all painting at zero scale', ( + WidgetTester tester, + ) async { + // Pump a slider with just a value indicator. + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.onDrag, + ), + value: 0.5, + divisions: 4, + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + + // Nothing to paint at scale 0. + await tester.pump(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for the value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); + + await gesture.up(); + }); + + testWidgets('Default paddle range slider value indicator shape draws correctly', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + thumbColor: Colors.red.shade500, + showValueIndicator: ShowValueIndicator.always, + rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + ); + + await tester.pumpWidget(_buildRangeApp(sliderTheme)); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + // physical model + ..rrect() + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 298.0, + 24.0, + 302.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 298.0, + 776.0, + 302.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + ..rrect(rrect: RRect.fromLTRBR(22.0, 297.0, 26.0, 303.0, const Radius.circular(2.0))) + ..circle(x: 24.0, y: 300.0) + ..shadow(elevation: 1.0) + ..circle(x: 24.0, y: 300.0) + ..shadow(elevation: 6.0) + ..circle(x: 24.0, y: 300.0), + ); + + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets( + 'Default paddle range slider value indicator shape draws correctly with debugDisableShadows', + (WidgetTester tester) async { + debugDisableShadows = true; + final theme = ThemeData(platform: TargetPlatform.android, primarySwatch: Colors.blue); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + thumbColor: Colors.red.shade500, + showValueIndicator: ShowValueIndicator.always, + rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + ); + + await tester.pumpWidget(_buildRangeApp(sliderTheme)); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + // physical model + ..rrect() + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 298.0, + 24.0, + 302.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 298.0, + 776.0, + 302.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + ..rrect(rrect: RRect.fromLTRBR(22.0, 297.0, 26.0, 303.0, const Radius.circular(2))) + ..circle(x: 24.0, y: 300.0) + ..path(strokeWidth: 1.0 * 2.0, color: Colors.black) + ..circle(x: 24.0, y: 300.0) + ..path(strokeWidth: 6.0 * 2.0, color: Colors.black) + ..circle(x: 24.0, y: 300.0), + ); + + await gesture.up(); + }, + ); + + testWidgets('PaddleRangeSliderValueIndicatorShape skips all painting at zero scale', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + // Pump a slider with just a value indicator. + await tester.pumpWidget( + _buildRangeApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + ), + values: const RangeValues(0, 0.5), + divisions: 4, + ), + ); + + // final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider)); + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + + // No value indicator path to paint at scale 0. + await tester.pump(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for each value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); + + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Default range indicator shape skips all painting at zero scale', ( + WidgetTester tester, + ) async { + debugDisableShadows = false; + try { + // Pump a slider with just a value indicator. + await tester.pumpWidget( + _buildRangeApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.always, + ), + values: const RangeValues(0, 0.5), + divisions: 4, + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + + // No value indicator path to paint at scale 0. + await tester.pump(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for each value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); + + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets( + 'activeTrackRadius is taken into account when painting the border of the active track', + (WidgetTester tester) async { + await tester.pumpWidget( + _buildApp( + value: 0.5, + ThemeData().sliderTheme.copyWith( + trackShape: const RoundedRectSliderTrackShapeWithCustomAdditionalActiveTrackHeight( + additionalActiveTrackHeight: 10.0, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(398.0, 298.0, 776.0, 302.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(24.0, 293.0, 402.0, 307.0, const Radius.circular(7.0))), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('The mouse cursor is themeable', (WidgetTester tester) async { + await tester.pumpWidget( + _buildApp( + ThemeData().sliderTheme.copyWith( + mouseCursor: const MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.text), + ), + ), + ); + + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + + testWidgets('SliderTheme.allowedInteraction is themeable', (WidgetTester tester) async { + var value = 0.0; + + Widget buildApp({ + bool isAllowedInteractionInThemeNull = false, + bool isAllowedInteractionInSliderNull = false, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SliderTheme( + data: ThemeData().sliderTheme.copyWith( + allowedInteraction: isAllowedInteractionInThemeNull + ? null + : SliderInteraction.slideOnly, + ), + child: StatefulBuilder( + builder: (_, void Function(void Function()) setState) { + return Slider( + value: value, + allowedInteraction: isAllowedInteractionInSliderNull + ? null + : SliderInteraction.tapOnly, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ); + }, + ), + ), + ), + ), + ); + } + + final TestGesture gesture = await tester.createGesture(); + + // when theme and parameter are specified, parameter is used [tapOnly]. + await tester.pumpWidget(buildApp()); + // tap is allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + expect(value, equals(0.5)); // changes + await gesture.up(); + // slide isn't allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + expect(value, equals(0.0)); // no change + await gesture.up(); + + // when only parameter is specified, parameter is used [tapOnly]. + await tester.pumpWidget(buildApp(isAllowedInteractionInThemeNull: true)); + // tap is allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + expect(value, equals(0.5)); // changes + await gesture.up(); + // slide isn't allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + expect(value, equals(0.0)); // no change + await gesture.up(); + + // when theme is specified but parameter is null, theme is used [slideOnly]. + await tester.pumpWidget(buildApp(isAllowedInteractionInSliderNull: true)); + // tap isn't allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + expect(value, equals(0.0)); // no change + await gesture.up(); + // slide isn't allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + expect(value, greaterThan(0.0)); // changes + await gesture.up(); + + // when both theme and parameter are null, default is used [tapAndSlide]. + await tester.pumpWidget( + buildApp(isAllowedInteractionInSliderNull: true, isAllowedInteractionInThemeNull: true), + ); + // tap is allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + expect(value, equals(0.5)); + await gesture.up(); + // slide is allowed. + value = 0.0; + await gesture.down(tester.getCenter(find.byType(Slider))); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + expect(value, greaterThan(0.0)); // changes + await gesture.up(); + }); + + testWidgets('Default value indicator color', (WidgetTester tester) async { + debugDisableShadows = false; + try { + final theme = ThemeData(platform: TargetPlatform.android); + Widget buildApp( + String value, { + double sliderValue = 0.5, + TextScaler textScaler = TextScaler.noScaling, + }) { + return MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material( + child: Row( + children: <Widget>[ + Expanded( + child: Slider( + value: sliderValue, + label: value, + divisions: 3, + onChanged: (double d) {}, + ), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp('1')); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect(color: const Color(0xfffef7ff)) + ..rrect(color: const Color(0xffe6e0e9)) + ..rrect(color: const Color(0xff6750a4)) + ..path(color: Color(theme.colorScheme.primary.value)), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets( + 'RectangularSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor', + (WidgetTester tester) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: RectangularSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + ), + ); + + const value = 0.5; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider(value: value, label: '$value', onChanged: (double newValue) {}), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(Slider)); + await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + }, + ); + + testWidgets('PaddleSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: PaddleSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + ), + ); + + const value = 0.5; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider(value: value, label: '$value', onChanged: (double newValue) {}), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(Slider)); + await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + }); + + testWidgets('DropSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: DropSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + ), + ); + + const value = 0.5; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider(value: value, label: '$value', onChanged: (double newValue) {}), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(Slider)); + await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + }); + + testWidgets( + 'RectangularRangeSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor', + (WidgetTester tester) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + rangeValueIndicatorShape: RectangularRangeSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + ), + ); + + var values = const RangeValues(0, 0.5); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toString(), values.end.toString()), + onChanged: (RangeValues val) { + values = val; + }, + ), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor) + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + + await gesture.up(); + }, + ); + + testWidgets( + 'RectangularRangeSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor on overlapping indicator', + (WidgetTester tester) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + rangeValueIndicatorShape: RectangularRangeSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + overlappingShapeStrokeColor: Color(0xff000003), + ), + ); + + var values = const RangeValues(0.0, 0.0); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toString(), values.end.toString()), + onChanged: (RangeValues val) { + values = val; + }, + ), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor) + ..path(color: theme.sliderTheme.overlappingShapeStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + + await gesture.up(); + }, + ); + + testWidgets( + 'PaddleRangeSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor', + (WidgetTester tester) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + rangeValueIndicatorShape: PaddleRangeSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + ), + ); + + var values = const RangeValues(0, 0.5); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toString(), values.end.toString()), + onChanged: (RangeValues val) { + values = val; + }, + ), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor) + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + + await gesture.up(); + }, + ); + + testWidgets( + 'PaddleRangeSliderValueIndicatorShape supports SliderTheme.valueIndicatorStrokeColor on overlapping indicator', + (WidgetTester tester) async { + final theme = ThemeData( + sliderTheme: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + rangeValueIndicatorShape: PaddleRangeSliderValueIndicatorShape(), + valueIndicatorColor: Color(0xff000001), + valueIndicatorStrokeColor: Color(0xff000002), + overlappingShapeStrokeColor: Color(0xff000003), + ), + ); + + var values = const RangeValues(0, 0); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toString(), values.end.toString()), + onChanged: (RangeValues val) { + values = val; + }, + ), + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.colorScheme.shadow) // shadow + ..path(color: theme.sliderTheme.valueIndicatorStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor) + ..path(color: theme.sliderTheme.overlappingShapeStrokeColor) + ..path(color: theme.sliderTheme.valueIndicatorColor), + ); + + await gesture.up(); + }, + ); + + group('RoundedRectSliderTrackShape', () { + testWidgets( + 'Only draw active track if thumb center is higher than trackRect.left and track radius', + (WidgetTester tester) async { + const sliderTheme = SliderThemeData(trackShape: RoundedRectSliderTrackShape()); + await tester.pumpWidget(_buildApp(sliderTheme)); + + MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(22.0, 298.0, 776.0, 302.0, const Radius.circular(2.0))), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.025)); + + material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(40.8, 298.0, 776.0, 302.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(24.0, 297.0, 44.8, 303.0, const Radius.circular(3.0))), + ); + }, + ); + + testWidgets( + 'Only draw inactive track if thumb center is lower than trackRect.right and track radius', + (WidgetTester tester) async { + const sliderTheme = SliderThemeData(trackShape: RoundedRectSliderTrackShape()); + await tester.pumpWidget(_buildApp(sliderTheme, value: 1.0)); + + MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints + // Active track. + ..rrect(rrect: RRect.fromLTRBR(24.0, 297.0, 778.0, 303.0, const Radius.circular(3.0))), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.975)); + + material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(755.2, 298.0, 776.0, 302.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(24.0, 297.0, 759.2, 303.0, const Radius.circular(3.0))), + ); + }, + ); + }); + + testWidgets('Track shape isRounded defaults', (WidgetTester tester) async { + expect(const RectangularSliderTrackShape().isRounded, isFalse); + expect(const RoundedRectSliderTrackShape().isRounded, isTrue); + expect(const RectangularRangeSliderTrackShape().isRounded, isFalse); + expect(const RoundedRectRangeSliderTrackShape().isRounded, isTrue); + }); + + testWidgets('SliderThemeData.padding can override the default Slider padding', ( + WidgetTester tester, + ) async { + Widget buildSlider({EdgeInsetsGeometry? padding}) { + return MaterialApp( + theme: ThemeData(sliderTheme: SliderThemeData(padding: padding)), + home: Material( + child: Center( + child: IntrinsicHeight(child: Slider(value: 0.5, onChanged: (double value) {})), + ), + ), + ); + } + + RenderBox sliderRenderBox() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderSlider', + ) + as RenderBox; + } + + // Test Slider height and tracks spacing with zero padding. + await tester.pumpWidget(buildSlider(padding: EdgeInsets.zero)); + await tester.pumpAndSettle(); + + // The height equals to the default thumb height. + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(398.0, 8.0, 800.0, 12.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(0.0, 7.0, 402.0, 13.0, const Radius.circular(3.0))), + ); + + // Test Slider height and tracks spacing with directional padding. + const double startPadding = 100; + const double endPadding = 20; + await tester.pumpWidget( + buildSlider( + padding: const EdgeInsetsDirectional.only(start: startPadding, end: endPadding), + ), + ); + await tester.pumpAndSettle(); + + expect(sliderRenderBox().size, const Size(800 - startPadding - endPadding, 20)); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(338.0, 8.0, 680.0, 12.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(0.0, 7.0, 342.0, 13.0, const Radius.circular(3.0))), + ); + + // Test Slider height and tracks spacing with top and bottom padding. + const double topPadding = 100; + const double bottomPadding = 20; + const double trackHeight = 20; + await tester.pumpWidget( + buildSlider( + padding: const EdgeInsetsDirectional.only(top: topPadding, bottom: bottomPadding), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(Slider)), + const Size(800, topPadding + trackHeight + bottomPadding), + ); + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(Slider), + paints + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(398.0, 8.0, 800.0, 12.0, const Radius.circular(2.0))) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(0.0, 7.0, 402.0, 13.0, const Radius.circular(3.0))), + ); + }); + + testWidgets('SliderThemeData.padding can override the default RangeSlider padding', ( + WidgetTester tester, + ) async { + Widget buildRangeSlider({EdgeInsetsGeometry? padding}) { + return MaterialApp( + theme: ThemeData(sliderTheme: SliderThemeData(padding: padding)), + home: Material( + child: Center( + child: IntrinsicHeight( + child: RangeSlider( + values: const RangeValues(0, 1.0), + onChanged: (RangeValues values) {}, + ), + ), + ), + ), + ); + } + + RenderBox sliderRenderBox() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderRangeSlider', + ) + as RenderBox; + } + + // Test RangeSlider height and tracks spacing with zero padding. + await tester.pumpWidget(buildRangeSlider(padding: EdgeInsets.zero)); + await tester.pumpAndSettle(); + + // The height equals to the default thumb height. + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(RangeSlider), + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 8.0, + 10.0, + 12.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 790.0, + 8.0, + 790.0, + 12.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(8.0, 7.0, 792.0, 13.0, const Radius.circular(2.0))), + ); + + // Test RangeSlider height and tracks spacing with directional padding. + const double startPadding = 100; + const double endPadding = 20; + await tester.pumpWidget( + buildRangeSlider( + padding: const EdgeInsetsDirectional.only(start: startPadding, end: endPadding), + ), + ); + await tester.pumpAndSettle(); + + expect(sliderRenderBox().size, const Size(800 - startPadding - endPadding, 20)); + expect( + find.byType(RangeSlider), + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 8.0, + 10.0, + 12.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 670.0, + 8.0, + 670.0, + 12.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(8.0, 7.0, 672.0, 13.0, const Radius.circular(2.0))), + ); + + // Test RangeSlider height and tracks spacing with top and bottom padding. + const double topPadding = 100; + const double bottomPadding = 20; + const double trackHeight = 20; + await tester.pumpWidget( + buildRangeSlider( + padding: const EdgeInsetsDirectional.only(top: topPadding, bottom: bottomPadding), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(RangeSlider)), + const Size(800, topPadding + trackHeight + bottomPadding), + ); + expect(sliderRenderBox().size, const Size(800, 20)); + expect( + find.byType(RangeSlider), + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 10.0, + 8.0, + 10.0, + 12.0, + topLeft: const Radius.circular(2.0), + bottomLeft: const Radius.circular(2.0), + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 790.0, + 8.0, + 790.0, + 12.0, + topRight: const Radius.circular(2.0), + bottomRight: const Radius.circular(2.0), + ), + ) + // Active track. + ..rrect(rrect: RRect.fromLTRBR(8.0, 7.0, 792.0, 13.0, const Radius.circular(2.0))), + ); + }); + + testWidgets('Can customize Slider track gap when year2023 is false', (WidgetTester tester) async { + Widget buildSlider({double? trackGap}) { + return MaterialApp( + theme: ThemeData(sliderTheme: SliderThemeData(trackGap: trackGap)), + home: Material( + child: Center(child: Slider(year2023: false, value: 0.5, onChanged: (double value) {})), + ), + ); + } + + await tester.pumpWidget(buildSlider(trackGap: 0)); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track shape. + const trackOuterCornerRadius = Radius.circular(8.0); + const trackInnerCornerRadius = Radius.circular(2.0); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 400.0, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 400.0, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + ), + ); + + await tester.pumpWidget(buildSlider(trackGap: 10)); + await tester.pumpAndSettle(); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 390.0, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 410.0, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + ), + ); + }); + + testWidgets('Can customize RangeSlider track gap when year2023 is false', ( + WidgetTester tester, + ) async { + Widget buildRangeSlider({double? trackGap}) { + return MaterialApp( + theme: ThemeData(sliderTheme: SliderThemeData(trackGap: trackGap)), + home: Material( + child: Center( + child: RangeSlider( + year2023: false, + values: const RangeValues(25, 75), + max: 100, + onChanged: (RangeValues value) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildRangeSlider(trackGap: 0)); + + final MaterialInkController material = Material.of(tester.element(find.byType(RangeSlider))); + + // Test default track shape. + const trackOuterCornerRadius = Radius.circular(8.0); + const trackInnerCornerRadius = Radius.circular(2.0); + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 212.0, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 588.0, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + ) + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(212.0, 292.0, 588.0, 308.0, trackInnerCornerRadius)), + ); + + await tester.pumpWidget(buildRangeSlider(trackGap: 10)); + await tester.pumpAndSettle(); + expect( + material, + paints + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 202.0, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + ) + // Inactive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 598.0, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + ) + // Inactive track. + ..rrect(rrect: RRect.fromLTRBR(222.0, 292.0, 578.0, 308.0, trackInnerCornerRadius)), + ); + }); + + testWidgets('Can customize Slider thumb size when year2023 is false', ( + WidgetTester tester, + ) async { + Widget buildSlider({WidgetStateProperty<Size?>? thumbSize}) { + return MaterialApp( + theme: ThemeData(sliderTheme: SliderThemeData(thumbSize: thumbSize)), + home: Material( + child: Center(child: Slider(year2023: false, value: 0.5, onChanged: (double value) {})), + ), + ); + } + + await tester.pumpWidget( + buildSlider(thumbSize: const WidgetStatePropertyAll<Size>(Size(20, 20))), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints + ..circle() + ..rrect(rrect: RRect.fromLTRBR(390.0, 290.0, 410.0, 310.0, const Radius.circular(10.0))), + ); + + await tester.pumpWidget( + buildSlider( + thumbSize: const WidgetStateProperty<Size?>.fromMap(<WidgetStatesConstraint, Size>{ + WidgetState.pressed: Size(20, 20), + WidgetState.any: Size(10, 10), + }), + ), + ); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle() + ..rrect(rrect: RRect.fromLTRBR(395.0, 295.0, 405.0, 305.0, const Radius.circular(5.0))), + ); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle() + ..rrect(rrect: RRect.fromLTRBR(390.0, 295.0, 410.0, 305.0, const Radius.circular(5.0))), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Can customize RangeSlider thumbs size when year2023 is false', ( + WidgetTester tester, + ) async { + Widget buildRangeSlider({WidgetStateProperty<Size?>? thumbSize}) { + return MaterialApp( + theme: ThemeData(sliderTheme: SliderThemeData(thumbSize: thumbSize)), + home: Material( + child: Center( + child: RangeSlider( + year2023: false, + values: const RangeValues(25, 75), + max: 100, + onChanged: (RangeValues value) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget( + buildRangeSlider(thumbSize: const WidgetStatePropertyAll<Size>(Size(20, 20))), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(RangeSlider))); + expect( + material, + paints + ..circle() + ..rrect(rrect: RRect.fromLTRBR(202.0, 290.0, 222.0, 310.0, const Radius.circular(10.0))) + ..rrect(rrect: RRect.fromLTRBR(578.0, 290.0, 598.0, 310.0, const Radius.circular(10.0))), + ); + + await tester.pumpWidget( + buildRangeSlider( + thumbSize: const WidgetStateProperty<Size?>.fromMap(<WidgetStatesConstraint, Size>{ + WidgetState.pressed: Size(20, 20), + WidgetState.any: Size(10, 10), + }), + ), + ); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle() + ..rrect(rrect: RRect.fromLTRBR(207.0, 295.0, 217.0, 305.0, const Radius.circular(5.0))) + ..rrect(rrect: RRect.fromLTRBR(583.0, 295.0, 593.0, 305.0, const Radius.circular(5.0))), + ); + + final Offset topRight = tester.getTopRight(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(topRight); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle() + ..rrect(rrect: RRect.fromLTRBR(207.0, 295.0, 217.0, 305.0, const Radius.circular(5.0))) + ..rrect(rrect: RRect.fromLTRBR(583.0, 295.0, 593.0, 305.0, const Radius.circular(5.0))), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('Opt into 2024 Slider appearance with SliderThemeData.year2023', ( + WidgetTester tester, + ) async { + final theme = ThemeData(sliderTheme: const SliderThemeData(year2023: false)); + final ColorScheme colorScheme = theme.colorScheme; + final Color activeTrackColor = colorScheme.primary; + final Color inactiveTrackColor = colorScheme.secondaryContainer; + const value = 0.45; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider(value: value, onChanged: (double value) {}), + ), + ), + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track shape. + const trackOuterCornerRadius = Radius.circular(8.0); + const trackInnerCornerRadius = Radius.circular(2.0); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, + 292.0, + 356.4, + 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornerRadius, + bottomRight: trackInnerCornerRadius, + bottomLeft: trackOuterCornerRadius, + ), + color: activeTrackColor, + ) + // Inctive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 368.4, + 292.0, + 776.0, + 308.0, + topLeft: trackInnerCornerRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornerRadius, + ), + color: inactiveTrackColor, + ), + ); + }); + + testWidgets('Slider.year2023 overrides SliderThemeData.year2023', (WidgetTester tester) async { + final theme = ThemeData(sliderTheme: const SliderThemeData(year2023: false)); + final ColorScheme colorScheme = theme.colorScheme; + final Color activeTrackColor = colorScheme.primary; + final Color inactiveTrackColor = colorScheme.surfaceContainerHighest; + const value = 0.45; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Theme( + data: theme, + child: Slider(year2023: true, value: value, onChanged: (double value) {}), + ), + ), + ), + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track shape. + const activeTrackCornerRadius = Radius.circular(3.0); + const inactiveTrackCornerRadius = Radius.circular(2.0); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBR(360.4, 298.0, 776.0, 302.0, inactiveTrackCornerRadius), + color: inactiveTrackColor, + ) + // Inctive track. + ..rrect( + rrect: RRect.fromLTRBR(24.0, 297.0, 364.4, 303.0, activeTrackCornerRadius), + color: activeTrackColor, + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/161210 + testWidgets( + 'Slider with transparent track colors and custom track height can reach extreme ends', + (WidgetTester tester) async { + const sliderPadding = 24.0; + final theme = ThemeData( + sliderTheme: const SliderThemeData( + trackHeight: 100, + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + ), + ); + + Widget buildSlider({required double value}) { + return MaterialApp( + theme: theme, + home: Material( + child: SizedBox( + width: 300, + child: Slider(value: value, onChanged: (double value) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(value: 0)); + + MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + expect( + material, + paints..circle(x: sliderPadding, y: 300.0, color: theme.colorScheme.primary), + ); + + await tester.pumpWidget(buildSlider(value: 1)); + + material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints..circle(x: 800.0 - sliderPadding, y: 300.0, color: theme.colorScheme.primary), + ); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/161210 + testWidgets( + 'RangeSlider with transparent track colors and custom track height can reach extreme ends', + (WidgetTester tester) async { + const sliderPadding = 24.0; + final theme = ThemeData( + sliderTheme: const SliderThemeData( + trackHeight: 100, + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: SizedBox( + width: 300, + child: RangeSlider( + values: const RangeValues(0, 1), + onChanged: (RangeValues values) {}, + ), + ), + ), + ), + ); + + final MaterialInkController material = Material.of(tester.element(find.byType(RangeSlider))); + + expect( + material, + paints + ..circle(x: sliderPadding, y: 300.0, color: theme.colorScheme.primary) + ..circle(x: 800.0 - sliderPadding, y: 300.0, color: theme.colorScheme.primary), + ); + }, + ); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Slider defaults', (WidgetTester tester) async { + debugDisableShadows = false; + final theme = ThemeData(useMaterial3: false); + const trackHeight = 4.0; + final ColorScheme colorScheme = theme.colorScheme; + final activeTrackColor = Color(colorScheme.primary.value); + final Color inactiveTrackColor = colorScheme.primary.withOpacity(0.24); + final Color secondaryActiveTrackColor = colorScheme.primary.withOpacity(0.54); + final Color disabledActiveTrackColor = colorScheme.onSurface.withOpacity(0.32); + final Color disabledInactiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color disabledSecondaryActiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color shadowColor = colorScheme.shadow; + final thumbColor = Color(colorScheme.primary.value); + final Color disabledThumbColor = Color.alphaBlend( + colorScheme.onSurface.withOpacity(.38), + colorScheme.surface, + ); + final Color activeTickMarkColor = colorScheme.onPrimary.withOpacity(0.54); + final Color inactiveTickMarkColor = colorScheme.primary.withOpacity(0.54); + final Color disabledActiveTickMarkColor = colorScheme.onPrimary.withOpacity(0.12); + final Color disabledInactiveTickMarkColor = colorScheme.onSurface.withOpacity(0.12); + final Color valueIndicatorColor = Color.alphaBlend( + colorScheme.onSurface.withOpacity(0.60), + colorScheme.surface.withOpacity(0.90), + ); + + try { + var value = 0.45; + Widget buildApp({int? divisions, bool enabled = true}) { + final ValueChanged<double>? onChanged = !enabled + ? null + : (double d) { + value = d; + }; + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider( + value: value, + secondaryTrackValue: 0.75, + label: '$value', + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + // Test default track height. + const radius = Radius.circular(trackHeight / 2); + const activatedRadius = Radius.circular((trackHeight + 2) / 2); + expect( + material, + paints + ..rrect( + rrect: RRect.fromLTRBR(360.4, 298.0, 776.0, 302.0, radius), + color: inactiveTrackColor, + ) + ..rrect( + rrect: RRect.fromLTRBR(24.0, 297.0, 364.4, 303.0, activatedRadius), + color: activeTrackColor, + ), + ); + + // Test default colors for enabled slider. + expect( + material, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor) + ..rrect(color: secondaryActiveTrackColor), + ); + expect(material, paints..shadow(color: shadowColor)); + expect(material, paints..circle(color: thumbColor)); + expect(material, isNot(paints..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + expect(material, isNot(paints..circle(color: activeTickMarkColor))); + expect(material, isNot(paints..circle(color: inactiveTickMarkColor))); + + // Test defaults colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 3)); + expect( + material, + paints + ..rrect(color: inactiveTrackColor) + ..rrect(color: activeTrackColor) + ..rrect(color: secondaryActiveTrackColor), + ); + expect( + material, + paints + ..circle(color: activeTickMarkColor) + ..circle(color: activeTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: thumbColor), + ); + expect(material, isNot(paints..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + + // Test defaults colors for disabled slider. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + material, + paints + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledSecondaryActiveTrackColor), + ); + expect( + material, + paints + ..shadow(color: Colors.black) + ..circle(color: disabledThumbColor), + ); + expect(material, isNot(paints..circle(color: thumbColor))); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + + // Test defaults colors for disabled discrete slider. + await tester.pumpWidget(buildApp(divisions: 3, enabled: false)); + expect( + material, + paints + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: disabledThumbColor), + ); + expect(material, isNot(paints..circle(color: thumbColor))); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + expect(material, isNot(paints..circle(color: activeTickMarkColor))); + expect(material, isNot(paints..circle(color: inactiveTickMarkColor))); + + // Test the default color for value indicator. + await tester.pumpWidget(buildApp(divisions: 3)); + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(value, equals(2.0 / 3.0)); + expect( + valueIndicatorBox, + paints + ..path(color: valueIndicatorColor) + ..paragraph(), + ); + await gesture.up(); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Default value indicator color', (WidgetTester tester) async { + debugDisableShadows = false; + try { + final theme = ThemeData(useMaterial3: false, platform: TargetPlatform.android); + Widget buildApp( + String value, { + double sliderValue = 0.5, + TextScaler textScaler = TextScaler.noScaling, + }) { + return MaterialApp( + theme: theme, + home: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Material( + child: Row( + children: <Widget>[ + Expanded( + child: Slider( + value: sliderValue, + label: value, + divisions: 3, + onChanged: (double d) {}, + ), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp('1')); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect(color: const Color(0xfffafafa)) + ..rrect(color: const Color(0x3d2196f3)) + ..rrect(color: const Color(0xff2196f3)) + // Test that the value indicator text is painted with the correct color. + ..path(color: const Color(0xf55f5f5f)), + ); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + } finally { + debugDisableShadows = true; + } + }); + }); +} + +class RoundedRectSliderTrackShapeWithCustomAdditionalActiveTrackHeight + extends RoundedRectSliderTrackShape { + const RoundedRectSliderTrackShapeWithCustomAdditionalActiveTrackHeight({ + required this.additionalActiveTrackHeight, + }); + final double additionalActiveTrackHeight; + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation<double> enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 2.0, + }) { + super.paint( + context, + offset, + parentBox: parentBox, + sliderTheme: sliderTheme, + enableAnimation: enableAnimation, + textDirection: textDirection, + thumbCenter: thumbCenter, + secondaryOffset: secondaryOffset, + additionalActiveTrackHeight: this.additionalActiveTrackHeight, + ); + } +} + +Widget _buildApp( + SliderThemeData sliderTheme, { + double value = 0.0, + double? secondaryTrackValue, + bool enabled = true, + int? divisions, + FocusNode? focusNode, +}) { + final ValueChanged<double>? onChanged = enabled ? (double d) => value = d : null; + return MaterialApp( + home: Scaffold( + body: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: value, + secondaryTrackValue: secondaryTrackValue, + label: '$value', + onChanged: onChanged, + divisions: divisions, + focusNode: focusNode, + ), + ), + ), + ), + ); +} + +Widget _buildRangeApp( + SliderThemeData sliderTheme, { + RangeValues values = const RangeValues(0, 0), + bool enabled = true, + int? divisions, +}) { + final ValueChanged<RangeValues>? onChanged = enabled ? (RangeValues d) => values = d : null; + return MaterialApp( + home: Scaffold( + body: Center( + child: SliderTheme( + data: sliderTheme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toString(), values.end.toString()), + onChanged: onChanged, + divisions: divisions, + ), + ), + ), + ), + ); +} diff --git a/packages/material_ui/test/material/sliver_app_bar_test.dart b/packages/material_ui/test/material/sliver_app_bar_test.dart new file mode 100644 index 000000000000..a669aa2ee4fd --- /dev/null +++ b/packages/material_ui/test/material/sliver_app_bar_test.dart @@ -0,0 +1,987 @@ +// 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:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void _verifySliverGeometry({ + required GlobalKey key, + required bool visible, + required double paintExtent, +}) { + final target = key.currentContext!.findRenderObject()! as RenderSliver; + final SliverGeometry geometry = target.geometry!; + expect(geometry.visible, visible); + expect(geometry.paintExtent, paintExtent); +} + +void main() { + Future<void> slowDrag(WidgetTester tester, Key widget, Offset offset) async { + final Offset target = tester.getCenter(find.byKey(widget)); + final TestGesture gesture = await tester.startGesture(target); + await gesture.moveBy(offset); + await tester.pump(const Duration(milliseconds: 10)); + await gesture.up(); + } + + testWidgets('SliverAppBar - floating and pinned - correct elevation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + bottom: PreferredSize(preferredSize: Size.fromHeight(28), child: Text('Bottom')), + backgroundColor: Colors.green, + floating: true, + primary: false, + automaticallyImplyLeading: false, + ), + SliverToBoxAdapter(child: Container(color: Colors.yellow, height: 50.0)), + SliverToBoxAdapter(child: Container(color: Colors.red, height: 50.0)), + ], + ), + ), + ), + ), + ); + + final RenderPhysicalModel renderObject = tester.renderObject<RenderPhysicalModel>( + find.byType(PhysicalModel), + ); + expect(renderObject, isNotNull); + expect(renderObject.elevation, 0.0); + }); + + testWidgets('SliverAppBar - floating and pinned - correct semantics', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'us'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CustomScrollView( + slivers: <Widget>[ + const SliverAppBar( + title: Text('Hello'), + pinned: true, + floating: true, + expandedHeight: 200.0, + ), + SliverFixedExtentList( + itemExtent: 100.0, + delegate: SliverChildBuilderDelegate((BuildContext _, int index) { + return Container( + height: 100.0, + color: index.isEven ? Colors.red : Colors.yellow, + child: Text('Tile $index'), + ); + }), + ), + ], + ), + ), + ), + ), + ); + + final semantics = SemanticsTester(tester); + + var expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Hello', + flags: <SemanticsFlag>[SemanticsFlag.isHeader, SemanticsFlag.namesRoute], + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollToOffset, + ], + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + scrollIndex: 0, + children: <TestSemantics>[ + TestSemantics(label: 'Tile 0', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 1', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 2', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 3', textDirection: TextDirection.ltr), + TestSemantics( + label: 'Tile 4', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics( + label: 'Tile 5', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics( + label: 'Tile 6', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true), + ); + + await tester.fling(find.text('Tile 2'), const Offset(0, -600), 2000); + await tester.pumpAndSettle(); + + expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + label: 'Hello', + flags: <SemanticsFlag>[SemanticsFlag.isHeader, SemanticsFlag.namesRoute], + textDirection: TextDirection.ltr, + ), + ], + ), + TestSemantics( + actions: <SemanticsAction>[ + SemanticsAction.scrollUp, + SemanticsAction.scrollDown, + SemanticsAction.scrollToOffset, + ], + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + scrollIndex: 11, + children: <TestSemantics>[ + TestSemantics( + label: 'Tile 7', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics( + label: 'Tile 8', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics( + label: 'Tile 9', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics( + label: 'Tile 10', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics(label: 'Tile 11', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 12', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 13', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 14', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 15', textDirection: TextDirection.ltr), + TestSemantics(label: 'Tile 16', textDirection: TextDirection.ltr), + TestSemantics( + label: 'Tile 17', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + TestSemantics( + label: 'Tile 18', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[SemanticsFlag.isHidden], + ), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true), + ); + semantics.dispose(); + }); + + testWidgets('SliverAppBar - floating and pinned - second app bar stacks below', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: CustomScrollView( + controller: controller, + slivers: <Widget>[ + const SliverAppBar( + floating: true, + pinned: true, + expandedHeight: 200.0, + title: Text('A'), + ), + const SliverAppBar(primary: false, pinned: true, title: Text('B')), + SliverList.list( + children: const <Widget>[ + Text('C'), + Text('D'), + SizedBox(height: 500.0), + Text('E'), + SizedBox(height: 500.0), + ], + ), + ], + ), + ), + ); + const textPositionInAppBar = Offset(16.0, 18.0); + expect(tester.getTopLeft(find.text('A')), textPositionInAppBar); + // top app bar is 200.0 high at this point + expect(tester.getTopLeft(find.text('B')), const Offset(0.0, 200.0) + textPositionInAppBar); + // second app bar is 56.0 high + expect( + tester.getTopLeft(find.text('C')), + const Offset(0.0, 200.0 + 56.0), + ); // height of both appbars + final Size cSize = tester.getSize(find.text('C')); + controller.jumpTo(200.0 - 56.0); + await tester.pump(); + expect(tester.getTopLeft(find.text('A')), textPositionInAppBar); + // top app bar is now only 56.0 high, same as second + expect(tester.getTopLeft(find.text('B')), const Offset(0.0, 56.0) + textPositionInAppBar); + expect( + tester.getTopLeft(find.text('C')), + const Offset(0.0, 56.0 * 2.0), + ); // height of both collapsed appbars + expect(find.text('E'), findsNothing); + controller.jumpTo(600.0); + await tester.pump(); + expect(tester.getTopLeft(find.text('A')), textPositionInAppBar); // app bar is pinned at top + expect( + tester.getTopLeft(find.text('B')), + const Offset(0.0, 56.0) + textPositionInAppBar, + ); // second one too + expect(find.text('C'), findsNothing); // contents are scrolled off though + expect(find.text('D'), findsNothing); + // we have scrolled 600.0 pixels + // initial position of E was 200 + 56 + cSize.height + cSize.height + 500 + // we've scrolled that up by 600.0, meaning it's at that minus 600 now: + expect( + tester.getTopLeft(find.text('E')), + Offset(0.0, 200.0 + 56.0 + cSize.height * 2.0 + 500.0 - 600.0), + ); + }); + + testWidgets( + 'SliverAppBar does not crash when there is less than minExtent remainingPaintExtent', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/21887. + final controller = ScrollController(); + addTearDown(controller.dispose); + const availableHeight = 50.0; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Container( + height: availableHeight, + color: Colors.green, + child: CustomScrollView( + controller: controller, + slivers: <Widget>[ + const SliverAppBar(pinned: true, floating: true, expandedHeight: 120.0), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 100.0, child: Text('Tile $index')); + }, + ), + ], + ), + ), + ), + ), + ); + final RenderSliverFloatingPinnedPersistentHeader render = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(render.minExtent, greaterThan(availableHeight)); // Precondition + expect(render.geometry!.scrollExtent, 120.0); + expect(render.geometry!.paintExtent, availableHeight); + expect(render.geometry!.layoutExtent, availableHeight); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.geometry!.scrollExtent, 120.0); + expect(render.geometry!.paintExtent, availableHeight); + expect(render.geometry!.layoutExtent, 0.0); + }, + ); + + testWidgets('Pinned and floating SliverAppBar sticks to top the content is scroll down', ( + WidgetTester tester, + ) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Container( + height: 300, + color: Colors.green, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(pinned: true, floating: true, expandedHeight: 100.0), + SliverToBoxAdapter( + child: Container(key: anchor, color: Colors.red, height: 100), + ), + SliverToBoxAdapter(child: Container(height: 600, color: Colors.green)), + ], + ), + ), + ), + ), + ); + final RenderSliverFloatingPinnedPersistentHeader render = tester.renderObject( + find.byType(SliverAppBar), + ); + + const double scrollDistance = 40; + final TestGesture gesture = await tester.press(find.byKey(anchor)); + await gesture.moveBy(const Offset(0, scrollDistance)); + await tester.pump(); + + expect(render.geometry!.paintOrigin, -scrollDistance); + }); + + testWidgets('Floating SliverAppBar sticks to top the content is scroll down', ( + WidgetTester tester, + ) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Container( + height: 300, + color: Colors.green, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(floating: true, expandedHeight: 100.0), + SliverToBoxAdapter( + child: Container(key: anchor, color: Colors.red, height: 100), + ), + SliverToBoxAdapter(child: Container(height: 600, color: Colors.green)), + ], + ), + ), + ), + ), + ); + final RenderSliverFloatingPersistentHeader render = tester.renderObject( + find.byType(SliverAppBar), + ); + + const double scrollDistance = 40; + final TestGesture gesture = await tester.press(find.byKey(anchor)); + await gesture.moveBy(const Offset(0, scrollDistance)); + await tester.pump(); + + expect(render.geometry!.paintOrigin, -scrollDistance); + }); + + testWidgets('Pinned SliverAppBar sticks to top the content is scroll down', ( + WidgetTester tester, + ) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Container( + height: 300, + color: Colors.green, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(pinned: true, expandedHeight: 100.0), + SliverToBoxAdapter( + child: Container(key: anchor, color: Colors.red, height: 100), + ), + SliverToBoxAdapter(child: Container(height: 600, color: Colors.green)), + ], + ), + ), + ), + ), + ); + final RenderSliverPinnedPersistentHeader render = tester.renderObject( + find.byType(SliverAppBar), + ); + + const double scrollDistance = 40; + final TestGesture gesture = await tester.press(find.byKey(anchor)); + await gesture.moveBy(const Offset(0, scrollDistance)); + await tester.pump(); + + expect(render.geometry!.paintOrigin, -scrollDistance); + }); + + testWidgets('Pointer scrolled floating and SliverAppBar', (WidgetTester tester) async { + final GlobalKey appBarKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar(key: appBarKey, floating: true, title: const Text('Test Title')), + SliverFixedExtentList( + itemExtent: 50.0, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item $index'), + childCount: 30, + ), + ), + ], + ), + ), + ); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, visible: true, paintExtent: 56.0); + + // Pointer scroll the app bar away, we will scroll back less to validate the + // app bar floats back in. + final Offset point1 = tester.getCenter(find.text('Item 5')); + final testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + testPointer.hover(point1); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); + await tester.pump(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + _verifySliverGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + + // Scroll back to float in appbar + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, paintExtent: 50.0, visible: true); + + // Float the rest of the way in. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -250.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, paintExtent: 56.0, visible: true); + }); + + testWidgets('Pointer scrolled floating and snapping SliverAppBar', (WidgetTester tester) async { + final GlobalKey appBarKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + key: appBarKey, + floating: true, + snap: true, + title: const Text('Test Title'), + ), + SliverFixedExtentList( + itemExtent: 50.0, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item $index'), + childCount: 30, + ), + ), + ], + ), + ), + ); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, visible: true, paintExtent: 56.0); + + // Pointer scroll the app bar away, we will scroll back less to validate the + // app bar floats back in and then snaps to full size. + final Offset point1 = tester.getCenter(find.text('Item 5')); + final testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + testPointer.hover(point1); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); + await tester.pump(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + _verifySliverGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + + // Scroll back to float in appbar + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, paintExtent: 30.0, visible: true); + await tester.pumpAndSettle(); + // The snap animation should have completed and the app bar should be + // fully expanded. + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, paintExtent: 56.0, visible: true); + + // Float back out a bit and trigger snap close animation. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 50.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0); + _verifySliverGeometry(key: appBarKey, paintExtent: 6.0, visible: true); + await tester.pumpAndSettle(); + // The snap animation should have completed and the app bar should no + // longer be visible. + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + _verifySliverGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + }); + + group('SliverAppBar - Stretch', () { + testWidgets('fills overscroll', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + final RenderSliverScrollingPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(200.0)); + }); + + testWidgets('fills overscroll after reverse direction input - scrolling header', ( + WidgetTester tester, + ) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(title: Text('Test'), stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + final RenderSliverScrollingPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + expect(tester.getCenter(find.text('Test')).dy, 28.0); + // First scroll the header away + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(anchor))); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pump(const Duration(milliseconds: 10)); + expect(header.child!.size.height, equals(56.0)); + expect(tester.getCenter(find.text('Test', skipOffstage: false)).dy, -28.0); + // With the same gesture, scroll back and into overscroll + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pump(const Duration(milliseconds: 10)); + // Header should stretch in overscroll + expect(header.child!.size.height, equals(200.0)); + expect(tester.getCenter(find.text('Test')).dy, 28.0); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('fills overscroll after reverse direction input - floating header', ( + WidgetTester tester, + ) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar( + title: Text('Test'), + stretch: true, + floating: true, + expandedHeight: 100.0, + ), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + final RenderSliverFloatingPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + expect(tester.getCenter(find.text('Test')).dy, 28.0); + // First scroll the header away + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(anchor))); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pump(const Duration(milliseconds: 10)); + expect(header.child!.size.height, equals(56.0)); + expect(tester.getCenter(find.text('Test', skipOffstage: false)).dy, -28.0); + // With the same gesture, scroll back and into overscroll + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pump(const Duration(milliseconds: 10)); + // Header should stretch in overscroll + expect(header.child!.size.height, equals(200.0)); + expect(tester.getCenter(find.text('Test')).dy, 28.0); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('does not stretch without overscroll physics', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + final RenderSliverScrollingPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100.0)); + expect(header.child!.size.height, equals(100.0)); + }); + + testWidgets('default trigger offset', (WidgetTester tester) async { + var didTrigger = false; + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + SliverAppBar( + stretch: true, + expandedHeight: 100.0, + onStretchTrigger: () async { + didTrigger = true; + }, + ), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + await slowDrag(tester, anchor, const Offset(0.0, 50.0)); + expect(didTrigger, isFalse); + await tester.pumpAndSettle(); + await slowDrag(tester, anchor, const Offset(0.0, 150.0)); + expect(didTrigger, isTrue); + }); + + testWidgets('custom trigger offset', (WidgetTester tester) async { + var didTrigger = false; + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + SliverAppBar( + stretch: true, + expandedHeight: 100.0, + stretchTriggerOffset: 150.0, + onStretchTrigger: () async { + didTrigger = true; + }, + ), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + await slowDrag(tester, anchor, const Offset(0.0, 100.0)); + await tester.pumpAndSettle(); + expect(didTrigger, isFalse); + await slowDrag(tester, anchor, const Offset(0.0, 300.0)); + expect(didTrigger, isTrue); + }); + + testWidgets('stretch callback not triggered without overscroll physics', ( + WidgetTester tester, + ) async { + var didTrigger = false; + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: <Widget>[ + SliverAppBar( + stretch: true, + expandedHeight: 100.0, + stretchTriggerOffset: 150.0, + onStretchTrigger: () async { + didTrigger = true; + }, + ), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + + await slowDrag(tester, anchor, const Offset(0.0, 100.0)); + await tester.pumpAndSettle(); + expect(didTrigger, isFalse); + await slowDrag(tester, anchor, const Offset(0.0, 300.0)); + expect(didTrigger, isFalse); + }); + + testWidgets('asserts reasonable trigger offset', (WidgetTester tester) async { + expect(() { + return MaterialApp( + home: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: <Widget>[ + SliverAppBar(stretch: true, expandedHeight: 100.0, stretchTriggerOffset: -150.0), + SliverToBoxAdapter(child: Container(height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ); + }, throwsAssertionError); + }); + }); + + group('SliverAppBar - Stretch, Pinned', () { + testWidgets('fills overscroll', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(pinned: true, stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + final RenderSliverPinnedPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(200.0)); + }); + + testWidgets('does not stretch without overscroll physics', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(pinned: true, stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + final RenderSliverPinnedPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(100.0)); + }); + }); + + group('SliverAppBar - Stretch, Floating', () { + testWidgets('fills overscroll', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(floating: true, stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + final RenderSliverFloatingPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(200.0)); + }); + + testWidgets('does not fill overscroll without proper physics', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar(floating: true, stretch: true, expandedHeight: 100.0), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + final RenderSliverFloatingPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(100.0)); + }); + }); + + group('SliverAppBar - Stretch, Floating, Pinned', () { + testWidgets('fills overscroll', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar( + floating: true, + pinned: true, + stretch: true, + expandedHeight: 100.0, + ), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + final RenderSliverFloatingPinnedPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(200.0)); + }); + + testWidgets('does not fill overscroll without proper physics', (WidgetTester tester) async { + const anchor = Key('drag'); + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: <Widget>[ + const SliverAppBar( + pinned: true, + floating: true, + stretch: true, + expandedHeight: 100.0, + ), + SliverToBoxAdapter(child: Container(key: anchor, height: 800)), + SliverToBoxAdapter(child: Container(height: 800)), + ], + ), + ), + ); + final RenderSliverFloatingPinnedPersistentHeader header = tester.renderObject( + find.byType(SliverAppBar), + ); + expect(header.child!.size.height, equals(100.0)); + await slowDrag(tester, anchor, const Offset(0.0, 100)); + expect(header.child!.size.height, equals(100.0)); + }); + }); +} diff --git a/packages/material_ui/test/material/sliver_appbar_opacity_test.dart b/packages/material_ui/test/material/sliver_appbar_opacity_test.dart new file mode 100644 index 000000000000..8db392ea3944 --- /dev/null +++ b/packages/material_ui/test/material/sliver_appbar_opacity_test.dart @@ -0,0 +1,276 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('!pinned && !floating && !bottom ==> fade opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: false, floating: false, bottom: false, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 0.0); + }); + + testWidgets('a11y mode ===> 1.0 opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: _TestWidget(pinned: false, floating: false, bottom: false, controller: controller), + ), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(100.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 1.0); + }); + + testWidgets('turn on/off a11y mode to change opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + addTearDown(tester.platformDispatcher.clearAllTestValues); + addTearDown(tester.view.reset); + + tester.platformDispatcher + ..textScaleFactorTestValue = 123 + ..platformBrightnessTestValue = Brightness.dark + ..accessibilityFeaturesTestValue = const FakeAccessibilityFeatures(); + + await tester.pumpWidget( + _TestWidget(pinned: false, floating: false, bottom: false, controller: controller), + ); + + // AccessibleNavigation is off + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + controller.jumpTo(100.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity < 1.0, true); + + // Turn on accessibleNavigation + tester.platformDispatcher.accessibilityFeaturesTestValue = const FakeAccessibilityFeatures( + accessibleNavigation: true, + ); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 1.0); + + // Turn off accessibleNavigation + tester.platformDispatcher.accessibilityFeaturesTestValue = const FakeAccessibilityFeatures(); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity < 1.0, true); + }); + testWidgets('!pinned && !floating && bottom ==> fade opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: false, floating: false, bottom: true, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 0.0); + }); + + testWidgets('!pinned && floating && !bottom ==> fade opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: false, floating: true, bottom: false, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 0.0); + }); + + testWidgets('!pinned && floating && bottom ==> fade opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: false, floating: true, bottom: true, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 0.0); + }); + + testWidgets('pinned && !floating && !bottom ==> 1.0 opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: true, floating: false, bottom: false, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 1.0); + }); + + testWidgets('pinned && !floating && bottom ==> 1.0 opacity', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: true, floating: false, bottom: true, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 1.0); + }); + + testWidgets('pinned && floating && !bottom ==> 1.0 opacity', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/25000. + + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: true, floating: true, bottom: false, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 1.0); + }); + + testWidgets('pinned && floating && bottom && extraToolbarHeight == 0.0 ==> fade opacity', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/25993. + + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget(pinned: true, floating: true, bottom: true, controller: controller), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 0.0); + }); + + testWidgets('pinned && floating && bottom && extraToolbarHeight != 0.0 ==> 1.0 opacity', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _TestWidget( + pinned: true, + floating: true, + bottom: true, + collapsedHeight: 100.0, + controller: controller, + ), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(200.0); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 1.0); + }); + + testWidgets('!pinned && !floating && !bottom && extraToolbarHeight != 0.0 ==> fade opacity', ( + WidgetTester tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + const collapsedHeight = 100.0; + await tester.pumpWidget( + _TestWidget( + pinned: false, + floating: false, + bottom: false, + controller: controller, + collapsedHeight: collapsedHeight, + ), + ); + + final RenderParagraph render = tester.renderObject(find.text('Hallo Welt!!1')); + expect(render.text.style!.color!.opacity, 1.0); + + controller.jumpTo(collapsedHeight); + await tester.pumpAndSettle(); + expect(render.text.style!.color!.opacity, 0.0); + }); +} + +class _TestWidget extends StatelessWidget { + const _TestWidget({ + required this.pinned, + required this.floating, + required this.bottom, + this.controller, + this.collapsedHeight, + }); + + final bool pinned; + final bool floating; + final bool bottom; + final ScrollController? controller; + final double? collapsedHeight; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: CustomScrollView( + controller: controller, + slivers: <Widget>[ + SliverAppBar( + pinned: pinned, + floating: floating, + expandedHeight: 120.0, + collapsedHeight: collapsedHeight, + title: const Text('Hallo Welt!!1'), + bottom: !bottom + ? null + : PreferredSize(preferredSize: const Size.fromHeight(35.0), child: Container()), + ), + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return SizedBox(height: 100.0, child: Text('Tile $index')); + }, + ), + ], + ), + ); + } +} diff --git a/packages/material_ui/test/material/snack_bar_test.dart b/packages/material_ui/test/material/snack_bar_test.dart new file mode 100644 index 000000000000..60a985e83e2d --- /dev/null +++ b/packages/material_ui/test/material/snack_bar_test.dart @@ -0,0 +1,4500 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SnackBar control test', (WidgetTester tester) async { + const helloSnackBar = 'Hello SnackBar'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text(helloSnackBar), duration: Duration(seconds: 2)), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + expect(find.text(helloSnackBar), findsNothing); + await tester.tap(find.byKey(tapTarget)); + expect(find.text(helloSnackBar), findsNothing); + await tester.pump(); // schedule animation + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 3.00s // timer triggers to dismiss snackbar, reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text(helloSnackBar), findsOneWidget); // frame 0 of dismiss animation + await tester.pump( + const Duration(milliseconds: 750), + ); // 3.75s // last frame of animation, snackbar removed from build + expect(find.text(helloSnackBar), findsNothing); + }); + + testWidgets('SnackBar twice test', (WidgetTester tester) async { + var snackBarCount = 0; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + snackBarCount += 1; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('bar$snackBarCount'), + duration: const Duration(seconds: 2), + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.tap(find.byKey(tapTarget)); // queue bar1 + await tester.tap(find.byKey(tapTarget)); // queue bar2 + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // schedule animation for bar1 + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // begin animation + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 3.00s // timer triggers to dismiss snackbar, reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 3.75s // last frame of animation, snackbar removed from build, new snack bar put in its place + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 4.50s // animation last frame; two second timer starts here + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 5.25s + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 6.00s + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 6.75s // timer triggers to dismiss snackbar, reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 7.50s // last frame of animation, snackbar removed from build, new snack bar put in its place + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + }); + + testWidgets('SnackBar cancel test', (WidgetTester tester) async { + var snackBarCount = 0; + const tapTarget = Key('tap-target'); + late int time; + late ScaffoldFeatureController<SnackBar, SnackBarClosedReason> lastController; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + snackBarCount += 1; + lastController = ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('bar$snackBarCount'), + duration: Duration(seconds: time), + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + time = 1000; + await tester.tap(find.byKey(tapTarget)); // queue bar1 + final firstController = lastController; + time = 2; + await tester.tap(find.byKey(tapTarget)); // queue bar2 + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // schedule animation for bar1 + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // begin animation + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(const Duration(milliseconds: 10000)); // 12.25s + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + + firstController.close(); // snackbar is manually dismissed + + await tester.pump( + const Duration(milliseconds: 750), + ); // 13.00s // reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 13.75s // last frame of animation, snackbar removed from build, new snack bar put in its place + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 14.50s // animation last frame; two second timer starts here + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 15.25s + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 16.00s + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 16.75s // timer triggers to dismiss snackbar, reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 17.50s // last frame of animation, snackbar removed from build, new snack bar put in its place + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + }); + + testWidgets('SnackBar dismiss test', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + late DismissDirection dismissDirection; + late double width; + var snackBarCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + width = MediaQuery.sizeOf(context).width; + + return GestureDetector( + key: tapTarget, + onTap: () { + snackBarCount += 1; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('bar$snackBarCount'), + duration: const Duration(seconds: 2), + dismissDirection: dismissDirection, + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + await _testSnackBarDismiss( + tester: tester, + tapTarget: tapTarget, + scaffoldWidth: width, + onDismissDirectionChange: (DismissDirection dir) => dismissDirection = dir, + onDragGestureChange: () => snackBarCount = 0, + ); + }); + + testWidgets('SnackBar dismissDirection can be customised from SnackBarThemeData', ( + WidgetTester tester, + ) async { + const tapTarget = Key('tap-target'); + late double width; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(dismissDirection: DismissDirection.startToEnd), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + width = MediaQuery.sizeOf(context).width; + + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('swipe ltr'), duration: Duration(seconds: 2)), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + expect(find.text('swipe ltr'), findsNothing); + await tester.tap(find.byKey(tapTarget)); + expect(find.text('swipe ltr'), findsNothing); + await tester.pump(); // schedule animation for snack bar + expect(find.text('swipe ltr'), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text('swipe ltr'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); + await tester.drag(find.text('swipe ltr'), Offset(width, 0.0)); + await tester.pump(); // snack bar dismissed + expect(find.text('swipe ltr'), findsNothing); + }); + + testWidgets('dismissDirection from SnackBar should be preferred over SnackBarThemeData', ( + WidgetTester tester, + ) async { + const tapTarget = Key('tap-target'); + late double width; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(dismissDirection: DismissDirection.startToEnd), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + width = MediaQuery.sizeOf(context).width; + + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('swipe rtl'), + duration: Duration(seconds: 2), + dismissDirection: DismissDirection.endToStart, + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + expect(find.text('swipe rtl'), findsNothing); + await tester.tap(find.byKey(tapTarget)); + expect(find.text('swipe rtl'), findsNothing); + await tester.pump(); // schedule animation for snack bar + expect(find.text('swipe rtl'), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text('swipe rtl'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); + await tester.drag(find.text('swipe rtl'), Offset(-width, 0.0)); + await tester.pump(); // snack bar dismissed + expect(find.text('swipe rtl'), findsNothing); + }); + + testWidgets('SnackBar cannot be tapped twice', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + label: 'ACTION', + onPressed: () { + ++tapCount; + }, + ), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + expect(tapCount, equals(0)); + await tester.tap(find.text('ACTION')); + expect(tapCount, equals(1)); + await tester.tap(find.text('ACTION')); + expect(tapCount, equals(1)); + await tester.pump(); + await tester.tap(find.text('ACTION')); + expect(tapCount, equals(1)); + }); + + testWidgets('Material2 - Light theme SnackBar has dark background', (WidgetTester tester) async { + final lightTheme = ThemeData.light(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final RenderPhysicalModel renderModel = tester.renderObject( + find.widgetWithText(Material, 'I am a snack bar.').first, + ); + // There is a somewhat complicated background color calculation based + // off of the surface color. For the default light theme it + // should be this value. + expect(renderModel.color, isSameColorAs(const Color(0xFF333333))); + }); + + testWidgets('Material3 - Light theme SnackBar has dark background', (WidgetTester tester) async { + final lightTheme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder material = find.widgetWithText(Material, 'I am a snack bar.').first; + final RenderPhysicalModel renderModel = tester.renderObject(material); + + expect(renderModel.color, equals(lightTheme.colorScheme.inverseSurface)); + }); + + testWidgets('Dark theme SnackBar has light background', (WidgetTester tester) async { + final darkTheme = ThemeData.dark(); + await tester.pumpWidget( + MaterialApp( + theme: darkTheme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final RenderPhysicalModel renderModel = tester.renderObject( + find.widgetWithText(Material, 'I am a snack bar.').first, + ); + expect(renderModel.color, equals(darkTheme.colorScheme.onSurface)); + }); + + testWidgets('Material2 - Dark theme SnackBar has primary text buttons', ( + WidgetTester tester, + ) async { + final darkTheme = ThemeData.dark(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: darkTheme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final TextStyle buttonTextStyle = tester + .widget<RichText>(find.descendant(of: find.text('ACTION'), matching: find.byType(RichText))) + .text + .style!; + expect(buttonTextStyle.color, equals(darkTheme.colorScheme.primary)); + }); + + testWidgets('Material3 - Dark theme SnackBar has primary text buttons', ( + WidgetTester tester, + ) async { + final darkTheme = ThemeData.dark(); + await tester.pumpWidget( + MaterialApp( + theme: darkTheme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final TextStyle buttonTextStyle = tester + .widget<RichText>(find.descendant(of: find.text('ACTION'), matching: find.byType(RichText))) + .text + .style!; + expect(buttonTextStyle.color, equals(darkTheme.colorScheme.inversePrimary)); + }); + + testWidgets('SnackBar should inherit theme data from its ancestor', (WidgetTester tester) async { + final theme = ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.green, brightness: Brightness.dark), + ); + ThemeData? themeBeforeSnackBar; + ThemeData? themeAfterSnackBar; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + themeBeforeSnackBar = Theme.of(context); + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Builder( + builder: (BuildContext context) { + themeAfterSnackBar = Theme.of(context); + return const Text('I am a snack bar.'); + }, + ), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final ThemeData comparedTheme = themeBeforeSnackBar!.copyWith( + colorScheme: themeAfterSnackBar!.colorScheme, + ); // Fields replaced by SnackBar. + expect(comparedTheme, themeAfterSnackBar); + }); + + testWidgets('Snackbar margin can be customized', (WidgetTester tester) async { + const padding = 20.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + margin: EdgeInsets.all(padding), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, padding); + expect(snackBarBottomLeft.dy, 600 - padding); // Device height is 600. + expect(snackBarBottomRight.dx, 800 - padding); // Device width is 800. + }); + + testWidgets('SnackbarBehavior.floating is positioned within safe area', ( + WidgetTester tester, + ) async { + const viewPadding = 50.0; + const floatingSnackBarDefaultBottomMargin = 10.0; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + // Simulate non-safe area. + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // Start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + expect( + snackBarBottomLeft.dy, + // Device height is 600. + 600 - viewPadding - floatingSnackBarDefaultBottomMargin, + ); + }); + + testWidgets('Snackbar padding can be customized', (WidgetTester tester) async { + const padding = 20.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + padding: EdgeInsets.all(padding), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder textFinder = find.text('I am a snack bar.'); + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset textBottomLeft = tester.getBottomLeft(textFinder); + final Offset textTopRight = tester.getTopRight(textFinder); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarTopRight = tester.getTopRight(materialFinder); + expect(textBottomLeft.dx - snackBarBottomLeft.dx, padding); + expect(snackBarTopRight.dx - textTopRight.dx, padding); + expect(snackBarBottomLeft.dy - textBottomLeft.dy, padding); + expect(textTopRight.dy - snackBarTopRight.dy, padding); + }); + + testWidgets('Snackbar width can be customized', (WidgetTester tester) async { + const width = 200.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + width: width, + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, (800 - width) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + width) / 2); // Device width is 800. + }); + + testWidgets('Snackbar width can be customized from ThemeData', (WidgetTester tester) async { + const width = 200.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(width: width, behavior: SnackBarBehavior.floating), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Feeling snackish'))); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, (800 - width) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + width) / 2); // Device width is 800. + }); + + testWidgets('Snackbar width customization takes preference of widget over theme', ( + WidgetTester tester, + ) async { + const themeWidth = 200.0; + const widgetWidth = 400.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData( + width: themeWidth, + behavior: SnackBarBehavior.floating, + ), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Feeling super snackish'), width: widgetWidth), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, (800 - widgetWidth) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + widgetWidth) / 2); // Device width is 800. + }); + + testWidgets('Material2 - Snackbar labels can be colored as MaterialColor', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + textColor: Colors.lightBlue, + disabledTextColor: Colors.red, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Element actionTextBox = tester.element(find.text('ACTION')); + expect(actionTextBox.widget, isA<Text>()); + final Text(:TextStyle? style) = actionTextBox.widget as Text; + + final TextStyle defaultStyle = DefaultTextStyle.of(actionTextBox).style; + expect(defaultStyle.merge(style).color, Colors.lightBlue); + }); + + testWidgets('Material3 - Snackbar labels can be colored as MaterialColor', ( + WidgetTester tester, + ) async { + const MaterialColor usedColor = Colors.teal; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + textColor: usedColor, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Element actionTextButton = tester.element(find.widgetWithText(TextButton, 'ACTION')); + final Widget textButton = actionTextButton.widget; + if (textButton is TextButton) { + final ButtonStyle buttonStyle = textButton.style!; + if (buttonStyle.foregroundColor is WidgetStateColor) { + // Same color when resolved + expect(buttonStyle.foregroundColor!.resolve(<WidgetState>{}), usedColor); + } else { + expect(false, true); + } + } else { + expect(false, true); + } + }); + + testWidgets('Snackbar labels can be colored as WidgetStateColor (Material 3)', ( + WidgetTester tester, + ) async { + const usedColor = _TestMaterialStateColor(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + textColor: usedColor, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Element actionTextButton = tester.element(find.widgetWithText(TextButton, 'ACTION')); + final Widget textButton = actionTextButton.widget; + if (textButton is TextButton) { + final ButtonStyle buttonStyle = textButton.style!; + if (buttonStyle.foregroundColor is WidgetStateColor) { + // Exactly the same object + expect(buttonStyle.foregroundColor, usedColor); + } else { + expect(false, true); + } + } else { + expect(false, true); + } + }); + + testWidgets('Material2 - SnackBar button text alignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 24.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 17.0 + 40.0); // margin + bottom padding + expect(actionTextBottomLeft.dx - textBottomRight.dx, 24.0 + 12.0); // action padding + margin + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 24.0 + 12.0 + 30.0, + ); // action (padding + margin) + right padding + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 17.0 + 40.0, + ); // margin + bottom padding + }); + + testWidgets('Material3 - SnackBar button text alignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 24.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 14.0 + 40.0); // margin + bottom padding + expect(actionTextBottomLeft.dx - textBottomRight.dx, 24.0 + 12.0); // action padding + margin + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 24.0 + 12.0 + 30.0, + ); // action (padding + margin) + right padding + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 14.0 + 40.0, + ); // margin + bottom padding + }); + + testWidgets( + 'Material2 - Custom padding between SnackBar and its contents when set to SnackBarBehavior.fixed', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.favorite), label: 'Animutation'), + BottomNavigationBarItem(icon: Icon(Icons.block), label: 'Zombo.com'), + ], + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 24.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 17.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 24.0 + 12.0); // action padding + margin + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 24.0 + 12.0 + 30.0, + ); // action (padding + margin) + right padding + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 17.0, + ); // margin (with no bottom padding) + }, + ); + + testWidgets( + 'Material3 - Custom padding between SnackBar and its contents when set to SnackBarBehavior.fixed', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.favorite), label: 'Animutation'), + BottomNavigationBarItem(icon: Icon(Icons.block), label: 'Zombo.com'), + ], + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 24.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 14.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 24.0 + 12.0); // action padding + margin + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 24.0 + 12.0 + 30.0, + ); // action (padding + margin) + right padding + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 14.0, + ); // margin (with no bottom padding) + }, + ); + + testWidgets('SnackBar should push FloatingActionButton above', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + + // Get the Rect of the FAB to compare after the SnackBar appears. + final Rect originalFabRect = tester.getRect(find.byType(FloatingActionButton)); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Rect fabRect = tester.getRect(find.byType(FloatingActionButton)); + + // FAB should shift upwards after SnackBar appears. + expect(fabRect.center.dy, lessThan(originalFabRect.center.dy)); + + final Offset snackBarTopRight = tester.getTopRight(find.byType(SnackBar)); + + // FAB's surrounding padding is set to [kFloatingActionButtonMargin] in floating_action_button_location.dart by default. + const defaultFabPadding = 16; + + // FAB should be positioned above the SnackBar by the default padding. + expect(fabRect.bottomRight.dy, snackBarTopRight.dy - defaultFabPadding); + }); + + testWidgets('Material2 - Floating SnackBar button text alignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 31.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 27.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 16.0 + 8.0); // action padding + margin + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 31.0 + 30.0 + 8.0, + ); // margin + right (padding + margin) + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 27.0, + ); // margin (with no bottom padding) + }); + + testWidgets('Material3 - Floating SnackBar button text alignment', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 31.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 24.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 16.0 + 8.0); // action padding + margin + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 31.0 + 30.0 + 8.0, + ); // margin + right (padding + margin) + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 24.0, + ); // margin (with no bottom padding) + }); + + testWidgets( + 'Material2 - Custom padding between SnackBar and its contents when set to SnackBarBehavior.floating', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.favorite), label: 'Animutation'), + BottomNavigationBarItem(icon: Icon(Icons.block), label: 'Zombo.com'), + ], + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 31.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 27.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 16.0 + 8.0); // action (margin + padding) + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 31.0 + 30.0 + 8.0, + ); // margin + right (padding + margin) + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 27.0, + ); // margin (with no bottom padding) + }, + ); + + testWidgets( + 'Material3 - Custom padding between SnackBar and its contents when set to SnackBarBehavior.floating', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const <BottomNavigationBarItem>[ + BottomNavigationBarItem(icon: Icon(Icons.favorite), label: 'Animutation'), + BottomNavigationBarItem(icon: Icon(Icons.block), label: 'Zombo.com'), + ], + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 31.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 24.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 16.0 + 8.0); // action (margin + padding) + expect( + snackBarBottomRight.dx - actionTextBottomRight.dx, + 31.0 + 30.0 + 8.0, + ); // margin + right (padding + margin) + expect( + snackBarBottomRight.dy - actionTextBottomRight.dy, + 24.0, + ); // margin (with no bottom padding) + }, + ); + + testWidgets('SnackBarClosedReason', (WidgetTester tester) async { + final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); + var actionPressed = false; + SnackBarClosedReason? closedReason; + + await tester.pumpWidget( + MaterialApp( + scaffoldMessengerKey: scaffoldMessengerKey, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: const Text('snack'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + label: 'ACTION', + onPressed: () { + actionPressed = true; + }, + ), + ), + ) + .closed + .then<void>((SnackBarClosedReason reason) { + closedReason = reason; + }); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + // Pop up the snack bar and then press its action button. + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + expect(actionPressed, isFalse); + await tester.tap(find.text('ACTION')); + expect(actionPressed, isTrue); + // Closed reason is only set when the animation is complete. + await tester.pump(const Duration(milliseconds: 250)); + expect(closedReason, isNull); + // Wait for animation to complete. + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(closedReason, equals(SnackBarClosedReason.action)); + + // Pop up the snack bar and then swipe downwards to dismiss it. + await tester.tap(find.text('X')); + await tester.pump(const Duration(milliseconds: 750)); + await tester.pump(const Duration(milliseconds: 750)); + await tester.drag(find.text('snack'), const Offset(0.0, 50.0)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(closedReason, equals(SnackBarClosedReason.swipe)); + + // Pop up the snack bar and then remove it. + await tester.tap(find.text('X')); + await tester.pump(const Duration(milliseconds: 750)); + scaffoldMessengerKey.currentState!.removeCurrentSnackBar(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(closedReason, equals(SnackBarClosedReason.remove)); + + // Pop up the snack bar and then hide it. + await tester.tap(find.text('X')); + await tester.pump(const Duration(milliseconds: 750)); + scaffoldMessengerKey.currentState!.hideCurrentSnackBar(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(closedReason, equals(SnackBarClosedReason.hide)); + + // Remove action to test SnackBarClosedReason.timeout because Snackbar with + // action doesn't timeout. + await tester.pumpWidget( + MaterialApp( + scaffoldMessengerKey: scaffoldMessengerKey, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar(content: Text('snack'), duration: Duration(seconds: 2)), + ) + .closed + .then<void>((SnackBarClosedReason reason) { + closedReason = reason; + }); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + // Pop up the snack bar and then let it time out. + await tester.tap(find.text('X')); + await tester.pump(const Duration(milliseconds: 750)); + await tester.pump(const Duration(milliseconds: 750)); + await tester.pump(const Duration(milliseconds: 1500)); + await tester.pump(); // begin animation + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(closedReason, equals(SnackBarClosedReason.timeout)); + }); + + testWidgets('accessible navigation behavior with action', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: ScaffoldMessenger( + child: Builder( + builder: (BuildContext context) { + return Scaffold( + key: scaffoldKey, + body: GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('snack'), + duration: const Duration(seconds: 1), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pump(); + // Find action immediately + expect(find.text('ACTION'), findsOneWidget); + // Snackbar doesn't close + await tester.pump(const Duration(seconds: 10)); + expect(find.text('ACTION'), findsOneWidget); + await tester.tap(find.text('ACTION')); + await tester.pump(); + // Snackbar closes immediately + expect(find.text('ACTION'), findsNothing); + }); + + testWidgets('contributes dismiss semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final scaffoldKey = GlobalKey<ScaffoldState>(); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: ScaffoldMessenger( + child: Builder( + builder: (BuildContext context) { + return Scaffold( + key: scaffoldKey, + body: GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('snack'), + duration: const Duration(seconds: 1), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.text('snack')), + matchesSemantics( + isLiveRegion: true, + hasDismissAction: true, + hasScrollDownAction: true, + hasScrollUpAction: true, + label: 'snack', + textDirection: TextDirection.ltr, + ), + ); + handle.dispose(); + }); + + testWidgets('SnackBar default display duration test', (WidgetTester tester) async { + const helloSnackBar = 'Hello SnackBar'; + const tapTarget = Key('tap-target'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text(helloSnackBar))); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + expect(find.text(helloSnackBar), findsNothing); + await tester.tap(find.byKey(tapTarget)); + expect(find.text(helloSnackBar), findsNothing); + await tester.pump(); // schedule animation + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; four second timer starts here + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 3.00s + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 3.75s + expect(find.text(helloSnackBar), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 1000), + ); // 4.75s // timer triggers to dismiss snackbar, reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text(helloSnackBar), findsOneWidget); // frame 0 of dismiss animation + await tester.pump( + const Duration(milliseconds: 750), + ); // 5.50s // last frame of animation, snackbar removed from build + expect(find.text(helloSnackBar), findsNothing); + }); + + testWidgets('SnackBar handles updates to accessibleNavigation', (WidgetTester tester) async { + Future<void> boilerplate({required bool accessibleNavigation}) { + return tester.pumpWidget( + MediaQuery( + data: MediaQueryData(accessibleNavigation: accessibleNavigation), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('test'), + action: SnackBarAction(label: 'foo', onPressed: () {}), + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + } + + await boilerplate(accessibleNavigation: false); + expect(find.text('test'), findsNothing); + await tester.tap(find.text('X')); + await tester.pump(); // schedule animation + expect(find.text('test'), findsOneWidget); + await tester.pump(); // begin animation + await tester.pump(const Duration(milliseconds: 4750)); // 4.75s + expect(find.text('test'), findsOneWidget); + + // Enabled accessible navigation + await boilerplate(accessibleNavigation: true); + + await tester.pump(const Duration(milliseconds: 4000)); // 8.75s + await tester.pump(); + expect(find.text('test'), findsOneWidget); + + // disable accessible navigation + await boilerplate(accessibleNavigation: false); + await tester.pumpAndSettle(const Duration(milliseconds: 5750)); + expect(find.text('test'), findsOneWidget); + + await tester.tap(find.text('foo')); + await tester.pumpAndSettle(); + expect(find.text('test'), findsNothing); + }); + + testWidgets('Snackbar calls onVisible once', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + var called = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('hello'), + duration: const Duration(seconds: 1), + onVisible: () { + called += 1; + }, + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(tapTarget)); + await tester.pump(); // start animation + await tester.pumpAndSettle(); + + expect(find.text('hello'), findsOneWidget); + expect(called, 1); + }); + + testWidgets('Snackbar does not call onVisible when it is queued', (WidgetTester tester) async { + const tapTarget = Key('tap-target'); + var called = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('hello'), + duration: const Duration(seconds: 1), + onVisible: () { + called += 1; + }, + ), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('hello 2'), + duration: const Duration(seconds: 1), + onVisible: () { + called += 1; + }, + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byKey(tapTarget)); + await tester.pump(); // start animation + await tester.pumpAndSettle(); + + expect(find.text('hello'), findsOneWidget); + expect(called, 1); + }); + + group('SnackBar position', () { + for (final SnackBarBehavior behavior in SnackBarBehavior.values) { + final snackBar = SnackBar(content: const Text('SnackBar text'), behavior: behavior); + + testWidgets('$behavior should align SnackBar with the bottom of Scaffold ' + 'when Scaffold has no other elements', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: Container()))); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar(snackBar); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + final Offset scaffoldBottomRight = tester.getBottomRight(find.byType(Scaffold)); + + expect(snackBarBottomRight, equals(scaffoldBottomRight)); + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset scaffoldBottomLeft = tester.getBottomLeft(find.byType(Scaffold)); + + expect(snackBarBottomLeft, equals(scaffoldBottomLeft)); + }); + + testWidgets('$behavior should align SnackBar with the top of BottomNavigationBar ' + 'when Scaffold has no FloatingActionButton', (WidgetTester tester) async { + final boxKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + bottomNavigationBar: SizedBox(key: boxKey, width: 800, height: 60), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar(snackBar); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + final Offset bottomNavigationBarTopRight = tester.getTopRight(find.byKey(boxKey)); + + expect(snackBarBottomRight, equals(bottomNavigationBarTopRight)); + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset bottomNavigationBarTopLeft = tester.getTopLeft(find.byKey(boxKey)); + + expect(snackBarBottomLeft, equals(bottomNavigationBarTopLeft)); + }); + } + + testWidgets('Padding of ${SnackBarBehavior.fixed} is not consumed by viewInsets', ( + WidgetTester tester, + ) async { + final Widget child = MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.fixed, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ); + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 20.0)), + child: child, + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Show snackbar + final Offset initialBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset initialBottomRight = tester.getBottomRight(find.byType(SnackBar)); + // Consume bottom padding - as if by the keyboard opening + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + viewPadding: EdgeInsets.all(20), + viewInsets: EdgeInsets.all(100), + ), + child: child, + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset finalBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset finalBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(initialBottomLeft, finalBottomLeft); + expect(initialBottomRight, finalBottomRight); + }); + + testWidgets('${SnackBarBehavior.fixed} should align SnackBar with the bottom of Scaffold ' + 'when Scaffold has a FloatingActionButton', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar(content: Text('Snackbar text'), behavior: SnackBarBehavior.fixed), + ); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + final Offset scaffoldBottomRight = tester.getBottomRight(find.byType(Scaffold)); + + expect(snackBarBottomRight, equals(scaffoldBottomRight)); + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset scaffoldBottomLeft = tester.getBottomLeft(find.byType(Scaffold)); + + expect(snackBarBottomLeft, equals(scaffoldBottomLeft)); + }); + + testWidgets( + '${SnackBarBehavior.floating} should align SnackBar with the top of FloatingActionButton when Scaffold has a FloatingActionButton', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset floatingActionButtonTopLeft = tester.getTopLeft( + find.byType(FloatingActionButton), + ); + + // Since padding between the SnackBar and the FAB is created by the SnackBar, + // the bottom offset of the SnackBar should be equal to the top offset of the FAB + expect(snackBarBottomLeft.dy, floatingActionButtonTopLeft.dy); + }, + ); + + testWidgets( + '${SnackBarBehavior.floating} should not align SnackBar with the top of FloatingActionButton ' + 'when Scaffold has a FloatingActionButton and floatingActionButtonLocation is set to a top position', + (WidgetTester tester) async { + Future<void> pumpApp({required FloatingActionButtonLocation fabLocation}) async { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + floatingActionButtonLocation: fabLocation, + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + } + + const topLocations = <FloatingActionButtonLocation>[ + FloatingActionButtonLocation.startTop, + FloatingActionButtonLocation.centerTop, + FloatingActionButtonLocation.endTop, + FloatingActionButtonLocation.miniStartTop, + FloatingActionButtonLocation.miniCenterTop, + FloatingActionButtonLocation.miniEndTop, + ]; + + for (final location in topLocations) { + await pumpApp(fabLocation: location); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + + expect(snackBarBottomLeft.dy, 600); // Device height is 600. + } + }, + ); + + testWidgets( + '${SnackBarBehavior.floating} should align SnackBar with the top of FloatingActionButton ' + 'when Scaffold has a FloatingActionButton and floatingActionButtonLocation is not set to a top position', + (WidgetTester tester) async { + Future<void> pumpApp({required FloatingActionButtonLocation fabLocation}) async { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + floatingActionButtonLocation: fabLocation, + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + } + + const nonTopLocations = <FloatingActionButtonLocation>[ + FloatingActionButtonLocation.startDocked, + FloatingActionButtonLocation.startFloat, + FloatingActionButtonLocation.centerDocked, + FloatingActionButtonLocation.centerFloat, + FloatingActionButtonLocation.endContained, + FloatingActionButtonLocation.endDocked, + FloatingActionButtonLocation.endFloat, + FloatingActionButtonLocation.miniStartDocked, + FloatingActionButtonLocation.miniStartFloat, + FloatingActionButtonLocation.miniCenterDocked, + FloatingActionButtonLocation.miniCenterFloat, + FloatingActionButtonLocation.miniEndDocked, + FloatingActionButtonLocation.miniEndFloat, + // Regression test related to https://github.com/flutter/flutter/pull/131303. + _CustomFloatingActionButtonLocation(), + ]; + + for (final location in nonTopLocations) { + await pumpApp(fabLocation: location); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset floatingActionButtonTopLeft = tester.getTopLeft( + find.byType(FloatingActionButton), + ); + + // Since padding between the SnackBar and the FAB is created by the SnackBar, + // the bottom offset of the SnackBar should be equal to the top offset of the FAB + expect(snackBarBottomLeft.dy, floatingActionButtonTopLeft.dy); + } + }, + ); + + testWidgets( + '${SnackBarBehavior.fixed} should align SnackBar with the top of BottomNavigationBar ' + 'when Scaffold has a BottomNavigationBar and FloatingActionButton', + (WidgetTester tester) async { + final boxKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + bottomNavigationBar: SizedBox(key: boxKey, width: 800, height: 60), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar(content: Text('SnackBar text'), behavior: SnackBarBehavior.fixed), + ); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + final Offset bottomNavigationBarTopRight = tester.getTopRight(find.byKey(boxKey)); + + expect(snackBarBottomRight, equals(bottomNavigationBarTopRight)); + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset bottomNavigationBarTopLeft = tester.getTopLeft(find.byKey(boxKey)); + + expect(snackBarBottomLeft, equals(bottomNavigationBarTopLeft)); + }, + ); + + testWidgets( + '${SnackBarBehavior.floating} should align SnackBar with the top of FloatingActionButton ' + 'when Scaffold has BottomNavigationBar and FloatingActionButton', + (WidgetTester tester) async { + final boxKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + bottomNavigationBar: SizedBox(key: boxKey, width: 800, height: 60), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar(content: Text('SnackBar text'), behavior: SnackBarBehavior.floating), + ); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + final Offset fabTopRight = tester.getTopRight(find.byType(FloatingActionButton)); + + expect(snackBarBottomRight.dy, equals(fabTopRight.dy)); + }, + ); + + testWidgets( + '${SnackBarBehavior.floating} should align SnackBar with the top of BottomNavigationBar ' + 'when Scaffold has both BottomNavigationBar and FloatingActionButton and ' + 'BottomNavigationBar.top is higher than FloatingActionButton.top', + (WidgetTester tester) async { + final boxKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container(), + bottomNavigationBar: SizedBox(key: boxKey, width: 800, height: 200), + floatingActionButton: FloatingActionButton(onPressed: () {}), + floatingActionButtonLocation: FloatingActionButtonLocation.endContained, + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar(content: Text('SnackBar text'), behavior: SnackBarBehavior.floating), + ); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + final Offset fabTopRight = tester.getTopRight(find.byType(FloatingActionButton)); + final Offset navBarTopRight = tester.getTopRight(find.byKey(boxKey)); + + // Test the top of the navigation bar is higher than the top of the floating action button. + expect(fabTopRight.dy, greaterThan(navBarTopRight.dy)); + + expect(snackBarBottomRight.dy, equals(navBarTopRight.dy)); + }, + ); + + Future<void> openFloatingSnackBar(WidgetTester tester) async { + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar(content: Text('SnackBar text'), behavior: SnackBarBehavior.floating), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + } + + const offScreenMessage = + 'Floating SnackBar presented off screen.\n' + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n' + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.'; + + testWidgets( + 'Snackbar with SnackBarBehavior.floating will assert when offset too high by a large Scaffold.floatingActionButton', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + Future<void> boilerplate({required double? fabHeight}) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold(floatingActionButton: Container(height: fabHeight)), + ), + ); + } + + // Run once with a visible SnackBar to compute the empty space above SnackBar. + const double mediumFabHeight = 100; + await boilerplate(fabHeight: mediumFabHeight); + await openFloatingSnackBar(tester); + expect(tester.takeException(), isNull); + final double spaceAboveSnackBar = tester.getTopLeft(find.byType(SnackBar)).dy; + + // Run with the Snackbar fully off screen. + await boilerplate(fabHeight: spaceAboveSnackBar + mediumFabHeight * 2); + await openFloatingSnackBar(tester); + var exception = tester.takeException() as AssertionError; + expect(exception.message, offScreenMessage); + + // Run with the Snackbar partially off screen. + await boilerplate(fabHeight: spaceAboveSnackBar + mediumFabHeight + 10); + await openFloatingSnackBar(tester); + exception = tester.takeException() as AssertionError; + expect(exception.message, offScreenMessage); + + // Run with the Snackbar fully visible right on the top of the screen. + await boilerplate(fabHeight: spaceAboveSnackBar + mediumFabHeight); + await openFloatingSnackBar(tester); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Material2 - Snackbar with SnackBarBehavior.floating will assert when offset too high by a large Scaffold.persistentFooterButtons', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(persistentFooterButtons: <Widget>[SizedBox(height: 1000)]), + ), + ); + + await openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final exception = tester.takeException() as AssertionError; + expect(exception.message, offScreenMessage); + }, + ); + + testWidgets( + 'Material3 - Snackbar with SnackBarBehavior.floating will assert when offset too high by a large Scaffold.persistentFooterButtons', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(persistentFooterButtons: <Widget>[SizedBox(height: 1000)]), + ), + ); + + final FlutterExceptionHandler? handler = FlutterError.onError; + final errorMessages = <String>[]; + FlutterError.onError = (FlutterErrorDetails details) { + errorMessages.add(details.exceptionAsString()); + }; + addTearDown(() => FlutterError.onError = handler); + + await openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + expect(errorMessages.contains(offScreenMessage), isTrue); + }, + ); + + testWidgets( + 'Material2 - Snackbar with SnackBarBehavior.floating will assert when offset too high by a large Scaffold.bottomNavigationBar', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold(bottomNavigationBar: SizedBox(height: 1000)), + ), + ); + + await openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + final exception = tester.takeException() as AssertionError; + expect(exception.message, offScreenMessage); + }, + ); + + testWidgets( + 'Material3 - Snackbar with SnackBarBehavior.floating will assert when offset too high by a large Scaffold.bottomNavigationBar', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + const MaterialApp(home: Scaffold(bottomNavigationBar: SizedBox(height: 1000))), + ); + + final FlutterExceptionHandler? handler = FlutterError.onError; + final errorMessages = <String>[]; + FlutterError.onError = (FlutterErrorDetails details) { + errorMessages.add(details.exceptionAsString()); + }; + addTearDown(() => FlutterError.onError = handler); + + await openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + expect(errorMessages.contains(offScreenMessage), isTrue); + }, + ); + + testWidgets('SnackBar has correct end padding when it contains an action with fixed behavior', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Some content'), + behavior: SnackBarBehavior.fixed, + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Offset snackBarTopRight = tester.getTopRight(find.byType(SnackBar)); + final Offset actionTopRight = tester.getTopRight(find.byType(SnackBarAction)); + + expect(snackBarTopRight.dx - actionTopRight.dx, 12.0); + }); + + testWidgets( + 'SnackBar has correct end padding when it contains an action with floating behavior', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Some content'), + behavior: SnackBarBehavior.floating, + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + final Offset snackBarTopRight = tester.getTopRight(find.byType(SnackBar)); + final Offset actionTopRight = tester.getTopRight(find.byType(SnackBarAction)); + + expect( + snackBarTopRight.dx - actionTopRight.dx, + 8.0 + 15.0, + ); // button margin + horizontal scaffold outside margin + }, + ); + + testWidgets( + 'Material3 - Floating snackbar with custom width is centered when text direction is rtl', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/140125. + const customWidth = 400.0; + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + width: customWidth, + content: Text('Feeling super snackish'), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // Start animation. + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, (800 - customWidth) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + customWidth) / 2); // Device width is 800. + }, + ); + + testWidgets( + 'Material2 - Floating snackbar with custom width is centered when text direction is rtl', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/147838. + const customWidth = 400.0; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + width: customWidth, + content: Text('Feeling super snackish'), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // Start animation. + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, (800 - customWidth) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + customWidth) / 2); // Device width is 800. + }, + ); + }); + + testWidgets('SnackBars hero across transitions when using ScaffoldMessenger', ( + WidgetTester tester, + ) async { + const snackBarText = 'hello snackbar'; + const firstHeader = 'home'; + const secondHeader = 'second'; + const snackTarget = Key('snack-target'); + const transitionTarget = Key('transition-target'); + + Widget buildApp() { + return MaterialApp( + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text(firstHeader)), + body: Center( + child: ElevatedButton( + key: transitionTarget, + child: const Text('PUSH'), + onPressed: () { + Navigator.of(context).pushNamed('/second'); + }, + ), + ), + floatingActionButton: FloatingActionButton( + key: snackTarget, + onPressed: () async { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text(snackBarText))); + }, + child: const Text('X'), + ), + ); + }, + '/second': (BuildContext context) => + Scaffold(appBar: AppBar(title: const Text(secondHeader))), + }, + ); + } + + await tester.pumpWidget(buildApp()); + + expect(find.text(snackBarText), findsNothing); + expect(find.text(firstHeader), findsOneWidget); + expect(find.text(secondHeader), findsNothing); + + // Present SnackBar + await tester.tap(find.byKey(snackTarget)); + await tester.pump(); // schedule animation + expect(find.text(snackBarText), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text(snackBarText), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); + expect(find.text(snackBarText), findsOneWidget); + // Push new route + await tester.tap(find.byKey(transitionTarget)); + await tester.pump(); + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(firstHeader), findsOneWidget); + expect(find.text(secondHeader, skipOffstage: false), findsOneWidget); + await tester.pump(); + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(firstHeader), findsOneWidget); + expect(find.text(secondHeader), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1500)); + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(firstHeader), findsNothing); + expect(find.text(secondHeader), findsOneWidget); + }); + + testWidgets('Should have only one SnackBar during back swipe navigation', ( + WidgetTester tester, + ) async { + const snackBarText = 'hello snackbar'; + const snackTarget = Key('snack-target'); + const transitionTarget = Key('transition-target'); + + Widget buildApp() { + final pageTransitionTheme = PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + for (final TargetPlatform platform in TargetPlatform.values) + platform: const CupertinoPageTransitionsBuilder(), + }, + ); + return MaterialApp( + theme: ThemeData(pageTransitionsTheme: pageTransitionTheme), + initialRoute: '/', + routes: <String, WidgetBuilder>{ + '/': (BuildContext context) { + return Scaffold( + body: Center( + child: ElevatedButton( + key: transitionTarget, + child: const Text('PUSH'), + onPressed: () { + Navigator.of(context).pushNamed('/second'); + }, + ), + ), + ); + }, + '/second': (BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + key: snackTarget, + onPressed: () async { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text(snackBarText))); + }, + child: const Text('X'), + ), + ); + }, + }, + ); + } + + await tester.pumpWidget(buildApp()); + + // Transition to second page. + await tester.tap(find.byKey(transitionTarget)); + await tester.pumpAndSettle(); + + // Present SnackBar + await tester.tap(find.byKey(snackTarget)); + await tester.pump(); // schedule animation + expect(find.text(snackBarText), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text(snackBarText), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); + expect(find.text(snackBarText), findsOneWidget); + + // Start the gesture at the edge of the screen. + final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0)); + // Trigger the swipe. + await gesture.moveBy(const Offset(100.0, 0.0)); + + // Back gestures should trigger and draw the hero transition in the very same + // frame (since the "from" route has already moved to reveal the "to" route). + await tester.pump(); + + // We should have only one SnackBar displayed on the screen. + expect(find.text(snackBarText), findsOneWidget); + }); + + testWidgets('Material2 - SnackBars should be shown above the bottomSheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('I love Flutter!'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.goldenTest.workWithBottomSheet.png'), + ); + }); + + testWidgets('Material3 - SnackBars should be shown above the bottomSheet', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('I love Flutter!'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.goldenTest.workWithBottomSheet.png'), + ); + }); + + testWidgets('ScaffoldMessenger does not duplicate a SnackBar when presenting a MaterialBanner.', ( + WidgetTester tester, + ) async { + const materialBannerTapTarget = Key('materialbanner-tap-target'); + const snackBarTapTarget = Key('snackbar-tap-target'); + const snackBarText = 'SnackBar'; + const materialBannerText = 'MaterialBanner'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + GestureDetector( + key: snackBarTapTarget, + onTap: () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text(snackBarText))); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ), + GestureDetector( + key: materialBannerTapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: const Text(materialBannerText), + actions: <Widget>[ + TextButton( + child: const Text('DISMISS'), + onPressed: () => + ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.tap(find.byKey(snackBarTapTarget)); + await tester.tap(find.byKey(materialBannerTapTarget)); + await tester.pumpAndSettle(); + + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(materialBannerText), findsOneWidget); + }); + + testWidgets( + 'Material2 - ScaffoldMessenger presents SnackBars to only the root Scaffold when Scaffolds are nested.', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + body: const Scaffold(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state<ScaffoldMessengerState>( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('ScaffoldMessenger'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + // The FloatingActionButton helps us identify which Scaffold has the + // SnackBar here. Since the outer Scaffold contains a FAB, the SnackBar + // should be above it. If the inner Scaffold had the SnackBar, it would be + // overlapping the FAB. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.scaffold.nested.png'), + ); + final Offset snackBarTopRight = tester.getTopRight(find.byType(SnackBar)); + expect(snackBarTopRight.dy, 465.0); + }, + ); + + testWidgets( + 'Material3 - ScaffoldMessenger presents SnackBars to only the root Scaffold when Scaffolds are nested.', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + body: const Scaffold(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state<ScaffoldMessengerState>( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('ScaffoldMessenger'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + // The FloatingActionButton helps us identify which Scaffold has the + // SnackBar here. Since the outer Scaffold contains a FAB, the SnackBar + // should be above it. If the inner Scaffold had the SnackBar, it would be + // overlapping the FAB. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.scaffold.nested.png'), + ); + final Offset snackBarTopRight = tester.getTopRight(find.byType(SnackBar)); + expect(snackBarTopRight.dy, 465.0); + }, + ); + + testWidgets('ScaffoldMessengerState clearSnackBars works as expected', ( + WidgetTester tester, + ) async { + final snackBars = <String>['Hello Snackbar', 'Hi Snackbar', 'Bye Snackbar']; + var snackBarCounter = 0; + const tapTarget = Key('tap-target'); + final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: ScaffoldMessenger( + key: scaffoldMessengerKey, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(snackBars[snackBarCounter++]), + duration: const Duration(seconds: 2), + ), + ); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox(height: 100.0, width: 100.0), + ); + }, + ), + ), + ), + ), + ); + expect(find.text(snackBars[0]), findsNothing); + expect(find.text(snackBars[1]), findsNothing); + expect(find.text(snackBars[2]), findsNothing); + await tester.tap(find.byKey(tapTarget)); + await tester.tap(find.byKey(tapTarget)); + await tester.tap(find.byKey(tapTarget)); + expect(find.text(snackBars[0]), findsNothing); + expect(find.text(snackBars[1]), findsNothing); + expect(find.text(snackBars[2]), findsNothing); + await tester.pump(); // schedule animation + expect(find.text(snackBars[0]), findsOneWidget); + scaffoldMessengerKey.currentState!.clearSnackBars(); + expect(find.text(snackBars[0]), findsOneWidget); + await tester.pump(const Duration(seconds: 2)); + expect(find.text(snackBars[0]), findsNothing); + expect(find.text(snackBars[1]), findsNothing); + expect(find.text(snackBars[2]), findsNothing); + }); + + Widget doBuildApp({ + required SnackBarBehavior? behavior, + EdgeInsetsGeometry? margin, + double? width, + double? actionOverflowThreshold, + }) { + return MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton(child: const Icon(Icons.send), onPressed: () {}), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: behavior, + margin: margin, + width: width, + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + actionOverflowThreshold: actionOverflowThreshold, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ); + } + + testWidgets('Setting SnackBarBehavior.fixed will still assert for margin', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/84935 + await tester.pumpWidget( + doBuildApp(behavior: SnackBarBehavior.fixed, margin: const EdgeInsets.all(8.0)), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + final exception = tester.takeException() as AssertionError; + expect( + exception.message, + 'Margin can only be used with floating behavior. SnackBarBehavior.fixed ' + 'was set in the SnackBar constructor.', + ); + }); + + testWidgets('Default SnackBarBehavior will still assert for margin', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84935 + await tester.pumpWidget(doBuildApp(behavior: null, margin: const EdgeInsets.all(8.0))); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + final exception = tester.takeException() as AssertionError; + expect( + exception.message, + 'Margin can only be used with floating behavior. SnackBarBehavior.fixed ' + 'was set by default.', + ); + }); + + testWidgets('Setting SnackBarBehavior.fixed will still assert for width', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/84935 + await tester.pumpWidget(doBuildApp(behavior: SnackBarBehavior.fixed, width: 5.0)); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + final exception = tester.takeException() as AssertionError; + expect( + exception.message, + 'Width can only be used with floating behavior. SnackBarBehavior.fixed ' + 'was set in the SnackBar constructor.', + ); + }); + + testWidgets('Default SnackBarBehavior will still assert for width', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84935 + await tester.pumpWidget(doBuildApp(behavior: null, width: 5.0)); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + final exception = tester.takeException() as AssertionError; + expect( + exception.message, + 'Width can only be used with floating behavior. SnackBarBehavior.fixed ' + 'was set by default.', + ); + }); + + for (final overflowThreshold in <double>[-1.0, -.0001, 1.000001, 5]) { + testWidgets('SnackBar will assert for actionOverflowThreshold outside of 0-1 range', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + doBuildApp(actionOverflowThreshold: overflowThreshold, behavior: SnackBarBehavior.fixed), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final exception = tester.takeException() as AssertionError; + expect(exception.message, 'Action overflow threshold must be between 0 and 1 inclusive'); + }); + } + + testWidgets('Material2 - Snackbar by default clips BackdropFilter', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/98205 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + body: const Scaffold(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state<ScaffoldMessengerState>( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + backgroundColor: Colors.transparent, + content: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), + child: const Text('I am a snack bar.'), + ), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.fixed, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('I am a snack bar.')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.goldenTest.backdropFilter.png'), + ); + }); + + testWidgets('Material3 - Snackbar by default clips BackdropFilter', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/98205 + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + body: const Scaffold(), + floatingActionButton: FloatingActionButton(onPressed: () {}), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state<ScaffoldMessengerState>( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + backgroundColor: Colors.transparent, + content: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), + child: const Text('I am a snack bar.'), + ), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.fixed, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('I am a snack bar.')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.goldenTest.backdropFilter.png'), + ); + }); + + testWidgets('Floating snackbar can display optional icon', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('Feeling snackish'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('snack_bar.goldenTest.floatingWithActionWithIcon.png'), + ); + }); + + testWidgets('SnackBar has tooltip for Close Button', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/143793 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('Snackbar with close button'), + duration: const Duration(days: 365), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate in. + + expect( + find.byTooltip(MaterialLocalizations.of(scaffoldMessengerState.context).closeButtonLabel), + findsOneWidget, + ); + }); + + testWidgets('Material2 - Fixed width snackbar can display optional icon', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('Go get a snack'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.goldenTest.fixedWithActionWithIcon.png'), + ); + }); + + testWidgets('Material3 - Fixed width snackbar can display optional icon', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('Go get a snack'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.goldenTest.fixedWithActionWithIcon.png'), + ); + }); + + testWidgets('Material2 - Fixed snackbar can display optional icon without action', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text('I wonder if there are snacks nearby?'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.fixed, + showCloseIcon: true, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.goldenTest.fixedWithIcon.png'), + ); + }); + + testWidgets('Material3 - Fixed snackbar can display optional icon without action', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text('I wonder if there are snacks nearby?'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.fixed, + showCloseIcon: true, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.goldenTest.fixedWithIcon.png'), + ); + }); + + testWidgets('Material2 - Floating width snackbar can display optional icon without action', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text('Must go get a snack!'), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.goldenTest.floatingWithIcon.png'), + ); + }); + + testWidgets('Material3 - Floating width snackbar can display optional icon without action', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text('Must go get a snack!'), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.goldenTest.floatingWithIcon.png'), + ); + }); + + testWidgets('Material2 - Floating multi-line snackbar with icon is aligned correctly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text( + 'This is a really long snackbar message. So long, it spans across more than one line!', + ), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m2_snack_bar.goldenTest.multiLineWithIcon.png'), + ); + }); + + testWidgets('Material3 - Floating multi-line snackbar with icon is aligned correctly', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text( + 'This is a really long snackbar message. So long, it spans across more than one line!', + ), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('m3_snack_bar.goldenTest.multiLineWithIcon.png'), + ); + }); + + testWidgets( + 'Material2 - Floating multi-line snackbar with icon and actionOverflowThreshold=1 is aligned correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: const Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text( + 'This is a really long snackbar message. So long, it spans across more than one line!', + ), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + actionOverflowThreshold: 1, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate in. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + 'm2_snack_bar.goldenTest.multiLineWithIconWithZeroActionOverflowThreshold.png', + ), + ); + }, + ); + + testWidgets( + 'Material3 - Floating multi-line snackbar with icon and actionOverflowThreshold=1 is aligned correctly', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + home: Scaffold( + bottomSheet: SizedBox(width: 200, height: 50, child: ColoredBox(color: Colors.pink)), + ), + ), + ); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text( + 'This is a really long snackbar message. So long, it spans across more than one line!', + ), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + actionOverflowThreshold: 1, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate in. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + 'm3_snack_bar.goldenTest.multiLineWithIconWithZeroActionOverflowThreshold.png', + ), + ); + }, + ); + + testWidgets('ScaffoldMessenger will alert for snackbars that cannot be presented', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/103004 + await tester.pumpWidget(const MaterialApp(home: Center())); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state<ScaffoldMessengerState>( + find.byType(ScaffoldMessenger), + ); + expect( + () { + scaffoldMessengerState.showSnackBar(const SnackBar(content: Text('SnackBar'))); + }, + throwsA( + isA<AssertionError>().having( + (AssertionError error) => error.toString(), + 'description', + contains( + 'ScaffoldMessenger.showSnackBar was called, but there are currently ' + 'no descendant Scaffolds to present to.', + ), + ), + ), + ); + }); + + testWidgets('SnackBarAction backgroundColor works as a Color', (WidgetTester tester) async { + const Color backgroundColor = Colors.blue; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + backgroundColor: backgroundColor, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('Tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Tap')); + await tester.pumpAndSettle(); + + final Material materialBeforeDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialBeforeDismissed.color, backgroundColor); + + await tester.tap(find.text('ACTION')); + await tester.pump(); + + final Material materialAfterDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialAfterDismissed.color, Colors.transparent); + }); + + testWidgets('SnackBarAction backgroundColor works as a WidgetStateColor', ( + WidgetTester tester, + ) async { + final backgroundColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + return Colors.purple; + }); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + backgroundColor: backgroundColor, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('Tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Tap')); + await tester.pumpAndSettle(); + + final Material materialBeforeDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialBeforeDismissed.color, Colors.purple); + + await tester.tap(find.text('ACTION')); + await tester.pump(); + + final Material materialAfterDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialAfterDismissed.color, Colors.blue); + }); + + testWidgets('SnackBarAction disabledBackgroundColor works as expected', ( + WidgetTester tester, + ) async { + const Color backgroundColor = Colors.blue; + const Color disabledBackgroundColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('Tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Tap')); + await tester.pumpAndSettle(); + + final Material materialBeforeDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialBeforeDismissed.color, backgroundColor); + + await tester.tap(find.text('ACTION')); + await tester.pump(); + + final Material materialAfterDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialAfterDismissed.color, disabledBackgroundColor); + }); + + testWidgets( + 'SnackBarAction asserts when backgroundColor is a WidgetStateColor and disabledBackgroundColor is also provided', + (WidgetTester tester) async { + final Color backgroundColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + return Colors.purple; + }); + const Color disabledBackgroundColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + label: 'ACTION', + onPressed: () {}, + ), + ), + ); + }, + child: const Text('Tap'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Tap')); + await tester.pumpAndSettle(); + + expect( + tester.takeException(), + isAssertionError.having( + (AssertionError e) => e.toString(), + 'description', + contains( + 'disabledBackgroundColor must not be provided when background color is a WidgetStateColor', + ), + ), + ); + }, + ); + + testWidgets('SnackBar material applies SnackBar.clipBehavior', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: Container()))); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state( + find.byType(ScaffoldMessenger), + ); + scaffoldMessengerState.showSnackBar(const SnackBar(content: Text('I am a snack bar.'))); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + Material material = tester.widget<Material>( + find.descendant(of: find.byType(SnackBar), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.hardEdge); + + scaffoldMessengerState.hideCurrentSnackBar(); // Hide the SnackBar. + + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + scaffoldMessengerState.showSnackBar( + const SnackBar(content: Text('I am a snack bar.'), clipBehavior: Clip.antiAlias), + ); + + await tester.pumpAndSettle(); // Have the SnackBar fully animate in. + + material = tester.widget<Material>( + find.descendant(of: find.byType(SnackBar), matching: find.byType(Material)), + ); + + expect(material.clipBehavior, Clip.antiAlias); + }); + + testWidgets('Tap on button behind snack bar defined by width', (WidgetTester tester) async { + tester.view.physicalSize = const Size.square(200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + const buttonText = 'Show snackbar'; + const snackbarContent = 'Snackbar'; + const buttonText2 = 'Try press me'; + + final completer = Completer<void>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + width: 100, + content: Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ), + ElevatedButton( + onPressed: () { + completer.complete(); + }, + child: const Text(buttonText2), + ), + ], + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text(buttonText)); + await tester.pumpAndSettle(); + + expect(find.text(snackbarContent), findsOneWidget); + await tester.tapAt(tester.getTopLeft(find.text(buttonText2))); + expect(find.text(snackbarContent), findsOneWidget); + + expect(completer.isCompleted, true); + }); + + testWidgets('Tap on button behind snack bar defined by margin', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/78537. + tester.view.physicalSize = const Size.square(200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + const buttonText = 'Show snackbar'; + const snackbarContent = 'Snackbar'; + const buttonText2 = 'Try press me'; + + final completer = Completer<void>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(left: 100), + content: Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ), + ElevatedButton( + onPressed: () { + completer.complete(); + }, + child: const Text(buttonText2), + ), + ], + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text(buttonText)); + await tester.pumpAndSettle(); + + expect(find.text(snackbarContent), findsOneWidget); + await tester.tapAt(tester.getTopLeft(find.text(buttonText2))); + expect(find.text(snackbarContent), findsOneWidget); + + expect(completer.isCompleted, true); + }); + + testWidgets("Can't tap on button behind snack bar defined by margin and HitTestBehavior.opaque", ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/78537. + tester.view.physicalSize = const Size.square(200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + const buttonText = 'Show snackbar'; + const snackbarContent = 'Snackbar'; + const buttonText2 = 'Try press me'; + + final completer = Completer<void>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + hitTestBehavior: HitTestBehavior.opaque, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(left: 100), + content: Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ), + ElevatedButton( + onPressed: () { + completer.complete(); + }, + child: const Text(buttonText2), + ), + ], + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text(buttonText)); + await tester.pumpAndSettle(); + + expect(find.text(snackbarContent), findsOneWidget); + await tester.tapAt(tester.getTopLeft(find.text(buttonText2))); + expect(find.text(snackbarContent), findsOneWidget); + + expect(completer.isCompleted, false); + }); + + testWidgets('Action text button uses correct overlay color', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final ButtonStyle? actionButtonStyle = tester + .widget<TextButton>(find.widgetWithText(TextButton, 'ACTION')) + .style; + expect( + actionButtonStyle?.overlayColor?.resolve(<WidgetState>{WidgetState.hovered}), + theme.colorScheme.inversePrimary.withOpacity(0.08), + ); + }); + + testWidgets( + 'Can interact with widgets behind SnackBar when insetPadding is set in SnackBarThemeData', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/148566. + tester.view.physicalSize = const Size.square(200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + const buttonText = 'Show snackbar'; + const snackbarContent = 'Snackbar'; + const buttonText2 = 'Try press me'; + + final completer = Completer<void>(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(insetPadding: EdgeInsets.only(left: 100)), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ), + ElevatedButton( + onPressed: () { + completer.complete(); + }, + child: const Text(buttonText2), + ), + ], + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text(buttonText)); + await tester.pumpAndSettle(); + + expect(find.text(snackbarContent), findsOneWidget); + await tester.tapAt(tester.getTopLeft(find.text(buttonText2))); + expect(find.text(snackbarContent), findsOneWidget); + + expect(completer.isCompleted, true); + }, + ); + + testWidgets('Setting persist to true prevents timeout', (WidgetTester tester) async { + const buttonText = 'Show snackbar'; + const snackbarContent = 'Snackbar'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 1), + persist: true, + showCloseIcon: true, + content: Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text(buttonText)); + await tester.pump(const Duration(milliseconds: 750)); + // The snackbar shows up before the timeout. + expect(find.text(snackbarContent), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1500)); + await tester.pumpAndSettle(); + // The snackbar is still there after the timeout. + expect(find.text(snackbarContent), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + // The snackbar is dismissed. + expect(find.text(snackbarContent), findsNothing); + }); + + testWidgets('Setting persist to false so snackbar auto dismisses', (WidgetTester tester) async { + const buttonText = 'Show'; + const snackbarContent = 'SnackbarContent'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 1), + persist: false, + showCloseIcon: true, + content: Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 750)); + // The snackbar shows up before the timeout. + expect(find.text(snackbarContent), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1500)); + await tester.pumpAndSettle(); + // The snackbar auto dismisses after the timeout. + expect(find.text(snackbarContent), findsNothing); + }); + + testWidgets('Setting persist to false overrides accessibleNavigation', ( + WidgetTester tester, + ) async { + const buttonText = 'Show'; + const snackbarContent = 'SnackbarContent'; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 1), + persist: false, + action: SnackBarAction(label: 'Action', onPressed: () {}), + content: const Text(snackbarContent), + ), + ); + }, + child: const Text(buttonText), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text(snackbarContent), findsOneWidget); + + await tester.pump(const Duration(seconds: 10)); + await tester.pumpAndSettle(); + // The snackbar auto dismisses after the timeout. + expect(find.text(snackbarContent), findsNothing); + }); + + testWidgets('SnackBarAction does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: SnackBarAction(label: 'X', onPressed: () {}), + ), + ), + ), + ); + expect(tester.getSize(find.byType(SnackBarAction)), Size.zero); + }); +} + +/// Start test for "SnackBar dismiss test". +Future<void> _testSnackBarDismiss({ + required WidgetTester tester, + required Key tapTarget, + required double scaffoldWidth, + required ValueChanged<DismissDirection> onDismissDirectionChange, + required VoidCallback onDragGestureChange, +}) async { + final Map<DismissDirection, List<Offset>> dragGestures = _getDragGesturesOfDismissDirections( + scaffoldWidth, + ); + + for (final DismissDirection key in dragGestures.keys) { + onDismissDirectionChange(key); + + for (final Offset dragGesture in dragGestures[key]!) { + onDragGestureChange(); + + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.tap(find.byKey(tapTarget)); // queue bar1 + await tester.tap(find.byKey(tapTarget)); // queue bar2 + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // schedule animation for bar1 + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // begin animation + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + await tester.drag(find.text('bar1'), dragGesture); + await tester.pump(); // bar1 dismissed, bar2 begins animating + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + await tester.drag(find.text('bar2'), dragGesture); + await tester.pump(); // bar2 dismissed + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + } + } +} + +/// Create drag gestures for DismissDirections. +Map<DismissDirection, List<Offset>> _getDragGesturesOfDismissDirections(double scaffoldWidth) { + final dragGestures = <DismissDirection, List<Offset>>{}; + + for (final DismissDirection val in DismissDirection.values) { + switch (val) { + case DismissDirection.down: + dragGestures[val] = <Offset>[const Offset(0.0, 50.0)]; // drag to bottom gesture + case DismissDirection.up: + dragGestures[val] = <Offset>[const Offset(0.0, -50.0)]; // drag to top gesture + case DismissDirection.vertical: + dragGestures[val] = <Offset>[ + const Offset(0.0, 50.0), // drag to bottom gesture + const Offset(0.0, -50.0), // drag to top gesture + ]; + case DismissDirection.startToEnd: + dragGestures[val] = <Offset>[Offset(scaffoldWidth, 0.0)]; // drag to right gesture + case DismissDirection.endToStart: + dragGestures[val] = <Offset>[Offset(-scaffoldWidth, 0.0)]; // drag to left gesture + case DismissDirection.horizontal: + dragGestures[val] = <Offset>[ + Offset(scaffoldWidth, 0.0), // drag to right gesture + Offset(-scaffoldWidth, 0.0), // drag to left gesture + ]; + case DismissDirection.none: + break; + } + } + + return dragGestures; +} + +class _TestMaterialStateColor extends WidgetStateColor { + const _TestMaterialStateColor() : super(_colorRed); + + static const int _colorRed = 0xFFF44336; + static const int _colorBlue = 0xFF2196F3; + + @override + Color resolve(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return const Color(_colorBlue); + } + + return const Color(_colorRed); + } +} + +class _CustomFloatingActionButtonLocation extends StandardFabLocation + with FabEndOffsetX, FabFloatOffsetY { + const _CustomFloatingActionButtonLocation(); +} diff --git a/packages/material_ui/test/material/snack_bar_theme_test.dart b/packages/material_ui/test/material/snack_bar_theme_test.dart new file mode 100644 index 000000000000..94be74c83c26 --- /dev/null +++ b/packages/material_ui/test/material/snack_bar_theme_test.dart @@ -0,0 +1,861 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SnackBarThemeData copyWith, ==, hashCode basics', () { + expect(const SnackBarThemeData(), const SnackBarThemeData().copyWith()); + expect(const SnackBarThemeData().hashCode, const SnackBarThemeData().copyWith().hashCode); + }); + + test('SnackBarThemeData lerp special cases', () { + expect(SnackBarThemeData.lerp(null, null, 0), const SnackBarThemeData()); + const data = SnackBarThemeData(); + expect(identical(SnackBarThemeData.lerp(data, data, 0.5), data), true); + }); + + test('SnackBarThemeData null fields by default', () { + const snackBarTheme = SnackBarThemeData(); + expect(snackBarTheme.backgroundColor, null); + expect(snackBarTheme.actionTextColor, null); + expect(snackBarTheme.disabledActionTextColor, null); + expect(snackBarTheme.contentTextStyle, null); + expect(snackBarTheme.elevation, null); + expect(snackBarTheme.shape, null); + expect(snackBarTheme.behavior, null); + expect(snackBarTheme.width, null); + expect(snackBarTheme.insetPadding, null); + expect(snackBarTheme.showCloseIcon, null); + expect(snackBarTheme.closeIconColor, null); + expect(snackBarTheme.actionOverflowThreshold, null); + }); + + test('SnackBarTheme throws assertion if width is provided with fixed behaviour', () { + expect( + () => SnackBarThemeData(behavior: SnackBarBehavior.fixed, width: 300.0), + throwsAssertionError, + ); + }); + + testWidgets('Default SnackBarThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SnackBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('SnackBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SnackBarThemeData( + backgroundColor: Color(0xFFFFFFFF), + actionTextColor: Color(0xFF0000AA), + disabledActionTextColor: Color(0xFF00AA00), + contentTextStyle: TextStyle(color: Color(0xFF123456)), + elevation: 2.0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), + behavior: SnackBarBehavior.floating, + width: 400.0, + insetPadding: EdgeInsets.all(10.0), + showCloseIcon: false, + closeIconColor: Color(0xFF0000AA), + actionOverflowThreshold: 0.5, + dismissDirection: DismissDirection.down, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'backgroundColor: ${const Color(0xffffffff)}', + 'actionTextColor: ${const Color(0xff0000aa)}', + 'disabledActionTextColor: ${const Color(0xff00aa00)}', + 'contentTextStyle: TextStyle(inherit: true, color: ${const Color(0xff123456)})', + 'elevation: 2.0', + 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(2.0))', + 'behavior: SnackBarBehavior.floating', + 'width: 400.0', + 'insetPadding: EdgeInsets.all(10.0)', + 'showCloseIcon: false', + 'closeIconColor: ${const Color(0xff0000aa)}', + 'actionOverflowThreshold: 0.5', + 'dismissDirection: DismissDirection.down', + ]); + }); + + testWidgets('Material2 - Passing no SnackBarThemeData returns defaults', ( + WidgetTester tester, + ) async { + const text = 'I am a snack bar.'; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text(text), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material material = _getSnackBarMaterial(tester); + final RenderParagraph content = _getSnackBarTextRenderObject(tester, text); + + expect(content.text.style, Typography.material2018().white.titleMedium); + expect(material.color, isSameColorAs(const Color(0xFF333333))); + expect(material.elevation, 6.0); + expect(material.shape, null); + }); + + testWidgets('Material3 - Passing no SnackBarThemeData returns defaults', ( + WidgetTester tester, + ) async { + const text = 'I am a snack bar.'; + final theme = ThemeData(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text(text), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material material = _getSnackBarMaterial(tester); + final RenderParagraph content = _getSnackBarTextRenderObject(tester, text); + + expect( + content.text.style, + Typography.material2021().englishLike.bodyMedium + ?.merge(Typography.material2021().black.bodyMedium) + .copyWith( + color: theme.colorScheme.onInverseSurface, + decorationColor: theme.colorScheme.onSurface, + ), + ); + expect(material.color, theme.colorScheme.inverseSurface); + expect(material.elevation, 6.0); + expect(material.shape, null); + }); + + testWidgets('SnackBar uses values from SnackBarThemeData', (WidgetTester tester) async { + const text = 'I am a snack bar.'; + const action = 'ACTION'; + final SnackBarThemeData snackBarTheme = _snackBarTheme(showCloseIcon: true); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(snackBarTheme: snackBarTheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text(text), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: action, onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material material = _getSnackBarMaterial(tester); + final RenderParagraph button = _getSnackBarActionTextRenderObject(tester, action); + final RenderParagraph content = _getSnackBarTextRenderObject(tester, text); + final Icon icon = _getSnackBarIcon(tester); + + expect(content.text.style, snackBarTheme.contentTextStyle); + expect(material.color, snackBarTheme.backgroundColor); + expect(material.elevation, snackBarTheme.elevation); + expect(material.shape, snackBarTheme.shape); + expect(button.text.style!.color, snackBarTheme.actionTextColor); + expect(icon.icon, Icons.close); + }); + + testWidgets('SnackBar widget properties take priority over theme', (WidgetTester tester) async { + const Color backgroundColor = Colors.purple; + const Color textColor = Colors.pink; + const elevation = 7.0; + const action = 'ACTION'; + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + const snackBarWidth = 400.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(snackBarTheme: _snackBarTheme(showCloseIcon: true)), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + width: snackBarWidth, + elevation: elevation, + shape: shape, + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(textColor: textColor, label: action, onPressed: () {}), + showCloseIcon: false, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Finder materialFinder = _getSnackBarMaterialFinder(tester); + final Material material = _getSnackBarMaterial(tester); + final RenderParagraph button = _getSnackBarActionTextRenderObject(tester, action); + + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(button.text.style!.color, textColor); + expect(_getSnackBarIconFinder(tester), findsNothing); + // Assert width. + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder.first); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder.first); + expect(snackBarBottomLeft.dx, (800 - snackBarWidth) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + snackBarWidth) / 2); // Device width is 800. + }); + + testWidgets('Local SnackBarTheme properties are used', (WidgetTester tester) async { + const Color backgroundColor = Colors.purple; + const Color textColor = Colors.pink; + const elevation = 7.0; + const action = 'ACTION'; + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + const snackBarWidth = 400.0; + + await tester.pumpWidget( + MaterialApp( + home: SnackBarTheme( + data: const SnackBarThemeData( + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + width: snackBarWidth, + elevation: elevation, + shape: shape, + showCloseIcon: false, + actionTextColor: textColor, + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: action, onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Finder materialFinder = _getSnackBarMaterialFinder(tester); + final Material material = _getSnackBarMaterial(tester); + final RenderParagraph button = _getSnackBarActionTextRenderObject(tester, action); + + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(button.text.style!.color, textColor); + expect(_getSnackBarIconFinder(tester), findsNothing); + // Assert width. + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder.first); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder.first); + expect(snackBarBottomLeft.dx, (800 - snackBarWidth) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + snackBarWidth) / 2); // Device width is 800. + }); + + testWidgets('SnackBar widget properties take priority over local theme', ( + WidgetTester tester, + ) async { + const Color backgroundColor = Colors.purple; + const Color textColor = Colors.pink; + const elevation = 7.0; + const action = 'ACTION'; + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + const snackBarWidth = 400.0; + + await tester.pumpWidget( + MaterialApp( + home: SnackBarTheme( + data: _snackBarTheme(showCloseIcon: true), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + width: snackBarWidth, + elevation: elevation, + shape: shape, + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + textColor: textColor, + label: action, + onPressed: () {}, + ), + showCloseIcon: false, + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Finder materialFinder = _getSnackBarMaterialFinder(tester); + final Material material = _getSnackBarMaterial(tester); + final RenderParagraph button = _getSnackBarActionTextRenderObject(tester, action); + + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(button.text.style!.color, textColor); + expect(_getSnackBarIconFinder(tester), findsNothing); + // Assert width. + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder.first); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder.first); + expect(snackBarBottomLeft.dx, (800 - snackBarWidth) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + snackBarWidth) / 2); // Device width is 800. + }); + + testWidgets('SnackBarAction uses actionBackgroundColor', (WidgetTester tester) async { + final actionBackgroundColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + return Colors.purple; + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: _createSnackBarTheme(actionBackgroundColor: actionBackgroundColor), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialBeforeDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialBeforeDismissed.color, Colors.purple); + + await tester.tap(find.text('ACTION')); + await tester.pump(); + + final Material materialAfterDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialAfterDismissed.color, Colors.blue); + }); + + testWidgets('SnackBarAction backgroundColor overrides SnackBarThemeData actionBackgroundColor', ( + WidgetTester tester, + ) async { + final snackBarActionBackgroundColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.amber; + } + return Colors.cyan; + }); + + final actionBackgroundColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + return Colors.purple; + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: _createSnackBarTheme(actionBackgroundColor: actionBackgroundColor), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + action: SnackBarAction( + label: 'ACTION', + backgroundColor: snackBarActionBackgroundColor, + onPressed: () {}, + ), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final Material materialBeforeDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialBeforeDismissed.color, Colors.cyan); + + await tester.tap(find.text('ACTION')); + await tester.pump(); + + final Material materialAfterDismissed = tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, 'ACTION'), + matching: find.byType(Material), + ), + ); + expect(materialAfterDismissed.color, Colors.amber); + }); + + testWidgets( + 'SnackBarThemeData asserts when actionBackgroundColor is a WidgetStateColor and disabledActionBackgroundColor is also provided', + (WidgetTester tester) async { + final actionBackgroundColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return Colors.blue; + } + return Colors.purple; + }); + + expect( + () => tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: _createSnackBarTheme( + actionBackgroundColor: actionBackgroundColor, + disabledActionBackgroundColor: Colors.amber, + ), + ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ), + throwsA( + isA<AssertionError>().having( + (AssertionError e) => e.toString(), + 'description', + contains( + 'disabledBackgroundColor must not be provided when background color is a WidgetStateColor', + ), + ), + ), + ); + }, + ); + + testWidgets('SnackBar theme behavior is correct for floating', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderBox snackBarBox = tester.firstRenderObject(find.byType(SnackBar)); + final RenderBox floatingActionButtonBox = tester.firstRenderObject( + find.byType(FloatingActionButton), + ); + + final Offset snackBarBottomCenter = snackBarBox.localToGlobal( + snackBarBox.size.bottomCenter(Offset.zero), + ); + final Offset floatingActionButtonTopCenter = floatingActionButtonBox.localToGlobal( + floatingActionButtonBox.size.topCenter(Offset.zero), + ); + + // Since padding and margin is handled inside snackBarBox, + // the bottom offset of snackbar should equal with top offset of FAB + expect(snackBarBottomCenter.dy == floatingActionButtonTopCenter.dy, true); + }); + + testWidgets('SnackBar theme behavior is correct for fixed', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.fixed)), + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ), + ); + + final RenderBox floatingActionButtonOriginBox = tester.firstRenderObject( + find.byType(FloatingActionButton), + ); + final Offset floatingActionButtonOriginBottomCenter = floatingActionButtonOriginBox + .localToGlobal(floatingActionButtonOriginBox.size.bottomCenter(Offset.zero)); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + final RenderBox snackBarBox = tester.firstRenderObject(find.byType(SnackBar)); + final RenderBox floatingActionButtonBox = tester.firstRenderObject( + find.byType(FloatingActionButton), + ); + + final Offset snackBarTopCenter = snackBarBox.localToGlobal( + snackBarBox.size.topCenter(Offset.zero), + ); + final Offset floatingActionButtonBottomCenter = floatingActionButtonBox.localToGlobal( + floatingActionButtonBox.size.bottomCenter(Offset.zero), + ); + + expect(floatingActionButtonOriginBottomCenter.dy > floatingActionButtonBottomCenter.dy, true); + expect(snackBarTopCenter.dy > floatingActionButtonBottomCenter.dy, true); + }); + + Widget buildApp({ + required SnackBarBehavior themedBehavior, + EdgeInsetsGeometry? margin, + double? width, + double? themedActionOverflowThreshold, + }) { + return MaterialApp( + theme: ThemeData( + snackBarTheme: SnackBarThemeData( + behavior: themedBehavior, + actionOverflowThreshold: themedActionOverflowThreshold, + ), + ), + home: Scaffold( + floatingActionButton: FloatingActionButton(child: const Icon(Icons.send), onPressed: () {}), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + margin: margin, + width: width, + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + ), + ); + }, + child: const Text('X'), + ); + }, + ), + ), + ); + } + + testWidgets('SnackBar theme behavior will assert properly for margin use', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/84935 + // SnackBarBehavior.floating set in theme does not assert with margin + await tester.pumpWidget( + buildApp(themedBehavior: SnackBarBehavior.floating, margin: const EdgeInsets.all(8.0)), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + var exception = tester.takeException() as AssertionError?; + expect(exception, isNull); + + // SnackBarBehavior.fixed set in theme will still assert with margin + await tester.pumpWidget( + buildApp(themedBehavior: SnackBarBehavior.fixed, margin: const EdgeInsets.all(8.0)), + ); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + exception = tester.takeException() as AssertionError; + expect( + exception.message, + 'Margin can only be used with floating behavior. SnackBarBehavior.fixed ' + 'was set by the inherited SnackBarThemeData.', + ); + }); + + for (final overflowThreshold in <double>[-1.0, -.0001, 1.000001, 5]) { + test('SnackBar theme will assert for actionOverflowThreshold outside of 0-1 range', () { + expect( + () => SnackBarThemeData(actionOverflowThreshold: overflowThreshold), + throwsAssertionError, + ); + }); + } + + testWidgets('SnackBar theme behavior will assert properly for width use', ( + WidgetTester tester, + ) async { + // SnackBarBehavior.floating set in theme does not assert with width + await tester.pumpWidget(buildApp(themedBehavior: SnackBarBehavior.floating, width: 5.0)); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + var exception = tester.takeException() as AssertionError?; + expect(exception, isNull); + + // SnackBarBehavior.fixed set in theme will still assert with width + await tester.pumpWidget(buildApp(themedBehavior: SnackBarBehavior.fixed, width: 5.0)); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + exception = tester.takeException() as AssertionError; + expect( + exception.message, + 'Width can only be used with floating behavior. SnackBarBehavior.fixed ' + 'was set by the inherited SnackBarThemeData.', + ); + }); +} + +SnackBarThemeData _snackBarTheme({bool? showCloseIcon}) { + return SnackBarThemeData( + backgroundColor: Colors.orange, + actionTextColor: Colors.green, + contentTextStyle: const TextStyle(color: Colors.blue), + elevation: 12.0, + showCloseIcon: showCloseIcon, + shape: const BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ); +} + +SnackBarThemeData _createSnackBarTheme({ + Color? backgroundColor, + Color? actionTextColor, + Color? disabledActionTextColor, + TextStyle? contentTextStyle, + double? elevation, + ShapeBorder? shape, + SnackBarBehavior? behavior, + Color? actionBackgroundColor, + Color? disabledActionBackgroundColor, + DismissDirection? dismissDirection, +}) { + return SnackBarThemeData( + backgroundColor: backgroundColor, + actionTextColor: actionTextColor, + disabledActionTextColor: disabledActionTextColor, + contentTextStyle: contentTextStyle, + elevation: elevation, + shape: shape, + behavior: behavior, + actionBackgroundColor: actionBackgroundColor, + disabledActionBackgroundColor: disabledActionBackgroundColor, + dismissDirection: dismissDirection, + ); +} + +Material _getSnackBarMaterial(WidgetTester tester) { + return tester.widget<Material>(_getSnackBarMaterialFinder(tester).first); +} + +Finder _getSnackBarMaterialFinder(WidgetTester tester) { + return find.descendant(of: find.byType(SnackBar), matching: find.byType(Material)); +} + +RenderParagraph _getSnackBarActionTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject( + find.descendant(of: find.byType(TextButton), matching: find.text(text)), + ); +} + +Icon _getSnackBarIcon(WidgetTester tester) { + return tester.widget<Icon>(_getSnackBarIconFinder(tester)); +} + +Finder _getSnackBarIconFinder(WidgetTester tester) { + return find.descendant(of: find.byType(SnackBar), matching: find.byIcon(Icons.close)); +} + +RenderParagraph _getSnackBarTextRenderObject(WidgetTester tester, String text) { + return tester.renderObject(find.descendant(of: find.byType(SnackBar), matching: find.text(text))); +} diff --git a/packages/material_ui/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart b/packages/material_ui/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart new file mode 100644 index 000000000000..c4f12079048b --- /dev/null +++ b/packages/material_ui/test/material/spell_check_suggestions_toolbar_layout_delegate_test.dart @@ -0,0 +1,49 @@ +// 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('positions itself at anchorAbove if it fits and shifts up when not', ( + WidgetTester tester, + ) async { + late StateSetter setState; + const double toolbarOverlap = 100; + const double height = 500; + var anchorY = 200.0; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return CustomSingleChildLayout( + delegate: SpellCheckSuggestionsToolbarLayoutDelegate(anchor: Offset(50.0, anchorY)), + child: Container(width: 200.0, height: height, color: const Color(0xffff0000)), + ); + }, + ), + ), + ), + ); + + // When the toolbar doesn't fit below anchor, it positions itself such that + // it can just fit. + double toolbarY = tester.getTopLeft(find.byType(Container)).dy; + // Total height available is 600. + expect(toolbarY, equals(toolbarOverlap)); + + // When it does fit below anchor, it positions itself there. + setState(() { + anchorY = anchorY - toolbarOverlap; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(find.byType(Container)).dy; + expect(toolbarY, equals(anchorY)); + }); +} diff --git a/packages/material_ui/test/material/spell_check_suggestions_toolbar_test.dart b/packages/material_ui/test/material/spell_check_suggestions_toolbar_test.dart new file mode 100644 index 000000000000..3e4f00b09e2d --- /dev/null +++ b/packages/material_ui/test/material/spell_check_suggestions_toolbar_test.dart @@ -0,0 +1,163 @@ +// 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/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Vertical position at which to anchor the toolbar for testing. +const double _kAnchor = 200; +// Amount for toolbar to overlap bottom padding for testing. +const double _kTestToolbarOverlap = 10; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Builds test button items for each of the suggestions provided. + List<ContextMenuButtonItem> buildSuggestionButtons(List<String> suggestions) { + return <ContextMenuButtonItem>[ + for (final String suggestion in suggestions) + ContextMenuButtonItem(onPressed: () {}, label: suggestion), + ContextMenuButtonItem(onPressed: () {}, type: ContextMenuButtonType.delete, label: 'DELETE'), + ]; + } + + /// Finds the container of the [SpellCheckSuggestionsToolbar] so that + /// the position of the toolbar itself may be determined. + Finder findSpellCheckSuggestionsToolbar() { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_SpellCheckSuggestionsToolbarContainer', + ), + ); + } + + testWidgets('positions toolbar below anchor when it fits above bottom view padding', ( + WidgetTester tester, + ) async { + // We expect the toolbar to be positioned right below the anchor with padding accounted for. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellCheckSuggestionsToolbar( + anchor: const Offset(0.0, _kAnchor), + buttonItems: buildSuggestionButtons(<String>['hello', 'yellow', 'yell']), + ), + ), + ), + ); + + final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy; + expect(toolbarY, equals(_kAnchor)); + }); + + testWidgets( + 're-positions toolbar higher below anchor when it does not fit above bottom view padding', + (WidgetTester tester) async { + // We expect the toolbar to be positioned _kTestToolbarOverlap pixels above the anchor. + const double expectedToolbarY = _kAnchor - _kTestToolbarOverlap; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellCheckSuggestionsToolbar( + anchor: const Offset(0.0, _kAnchor - _kTestToolbarOverlap), + buttonItems: buildSuggestionButtons(<String>['hello', 'yellow', 'yell']), + ), + ), + ), + ); + + final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy; + expect(toolbarY, equals(expectedToolbarY)); + }, + ); + + testWidgets( + 'more than three suggestions throws an error', + (WidgetTester tester) async { + Future<void> pumpToolbar(List<String> suggestions) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SpellCheckSuggestionsToolbar( + anchor: const Offset(0.0, _kAnchor - _kTestToolbarOverlap), + buttonItems: buildSuggestionButtons(suggestions), + ), + ), + ), + ); + } + + await pumpToolbar(<String>['hello', 'yellow', 'yell']); + expect(() async { + await pumpToolbar(<String>['hello', 'yellow', 'yell', 'yeller']); + }, throwsAssertionError); + }, + skip: kIsWeb, // [intended] + ); + + test('buildSuggestionButtons only considers the first three suggestions', () { + final editableTextState = _FakeEditableTextState( + suggestions: <String>['hello', 'yellow', 'yell', 'yeller'], + ); + final List<ContextMenuButtonItem>? buttonItems = SpellCheckSuggestionsToolbar.buildButtonItems( + editableTextState, + ); + expect(buttonItems, isNotNull); + final Iterable<String?> labels = buttonItems!.map((ContextMenuButtonItem buttonItem) { + return buttonItem.label; + }); + expect(labels, hasLength(4)); + expect(labels, contains('hello')); + expect(labels, contains('yellow')); + expect(labels, contains('yell')); + expect(labels, contains(null)); // For the delete button. + expect(labels, isNot(contains('yeller'))); + }); + + test('buildButtonItems builds only a delete button when no suggestions', () { + final editableTextState = _FakeEditableTextState(); + final List<ContextMenuButtonItem>? buttonItems = SpellCheckSuggestionsToolbar.buildButtonItems( + editableTextState, + ); + + expect(buttonItems, hasLength(1)); + expect(buttonItems!.first.type, ContextMenuButtonType.delete); + }); + + testWidgets('SpellCheckSuggestionsToolbar does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: SpellCheckSuggestionsToolbar( + anchor: const Offset(1, 1), + buttonItems: buildSuggestionButtons(<String>['X', 'Y']), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(SpellCheckSuggestionsToolbar)), Size.zero); + }); +} + +class _FakeEditableTextState extends EditableTextState { + _FakeEditableTextState({this.suggestions}); + + final List<String>? suggestions; + + @override + TextEditingValue get currentTextEditingValue => TextEditingValue.empty; + + @override + SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) { + return SuggestionSpan(const TextRange(start: 0, end: 0), suggestions ?? <String>[]); + } +} diff --git a/packages/material_ui/test/material/stepper_test.dart b/packages/material_ui/test/material/stepper_test.dart new file mode 100644 index 000000000000..30321e99074a --- /dev/null +++ b/packages/material_ui/test/material/stepper_test.dart @@ -0,0 +1,1998 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Material3 has sentence case labels', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + onStepTapped: (int i) {}, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ); + expect(find.text('Continue'), findsWidgets); + expect(find.text('Cancel'), findsWidgets); + }); + + testWidgets('Stepper tap callback test', (WidgetTester tester) async { + var index = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + onStepTapped: (int i) { + index = i; + }, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ); + await tester.tap(find.text('Step 2')); + expect(index, 1); + }); + + testWidgets('Stepper expansion test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: Stepper( + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 200.0, height: 200.0)), + ], + ), + ), + ), + ), + ); + + RenderBox box = tester.renderObject(find.byType(Stepper)); + expect(box.size.height, 332.0); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: Stepper( + currentStep: 1, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 200.0, height: 200.0)), + ], + ), + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(Stepper)); + expect(box.size.height, greaterThan(332.0)); + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(Stepper)); + expect(box.size.height, 432.0); + }); + + testWidgets('Stepper horizontal size test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ), + ); + + final RenderBox box = tester.renderObject(find.byType(Stepper)); + expect(box.size.height, 600.0); + }); + + testWidgets('Stepper visibility test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('Step 1'), content: Text('A')), + Step(title: Text('Step 2'), content: Text('B')), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsNothing); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + currentStep: 1, + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('Step 1'), content: Text('A')), + Step(title: Text('Step 2'), content: Text('B')), + ], + ), + ), + ), + ); + + expect(find.text('A'), findsNothing); + expect(find.text('B'), findsOneWidget); + }); + + testWidgets('Material2 - Stepper button test', (WidgetTester tester) async { + var continuePressed = false; + var cancelPressed = false; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Stepper( + type: StepperType.horizontal, + onStepContinue: () { + continuePressed = true; + }, + onStepCancel: () { + cancelPressed = true; + }, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 200.0, height: 200.0)), + ], + ), + ), + ), + ); + + await tester.tap(find.text('CONTINUE')); + await tester.tap(find.text('CANCEL')); + + expect(continuePressed, isTrue); + expect(cancelPressed, isTrue); + }); + + testWidgets('Material3 - Stepper button test', (WidgetTester tester) async { + var continuePressed = false; + var cancelPressed = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + onStepContinue: () { + continuePressed = true; + }, + onStepCancel: () { + cancelPressed = true; + }, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 200.0, height: 200.0)), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Continue')); + await tester.tap(find.text('Cancel')); + + expect(continuePressed, isTrue); + expect(cancelPressed, isTrue); + }); + + testWidgets('Stepper disabled step test', (WidgetTester tester) async { + var index = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + onStepTapped: (int i) { + index = i; + }, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + Step( + title: Text('Step 2'), + state: StepState.disabled, + content: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('Step 2')); + expect(index, 0); + }); + + testWidgets('Stepper scroll test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 300.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 100.0, height: 300.0)), + Step(title: Text('Step 3'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ); + + final ScrollableState scrollableState = tester.firstState(find.byType(Scrollable)); + expect(scrollableState.position.pixels, 0.0); + + await tester.tap(find.text('Step 3')); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + currentStep: 2, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 300.0)), + Step(title: Text('Step 2'), content: SizedBox(width: 100.0, height: 300.0)), + Step(title: Text('Step 3'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + expect(scrollableState.position.pixels, greaterThan(0.0)); + }); + + testWidgets('Stepper index test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: Stepper( + steps: const <Step>[ + Step( + title: Text('A'), + state: StepState.complete, + content: SizedBox(width: 100.0, height: 100.0), + ), + Step(title: Text('B'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ), + ); + + expect(find.text('1'), findsNothing); + expect(find.text('2'), findsOneWidget); + }); + + testWidgets('Stepper custom controls test', (WidgetTester tester) async { + var continuePressed = false; + void setContinue() { + continuePressed = true; + } + + var canceledPressed = false; + void setCanceled() { + canceledPressed = true; + } + + Widget builder(BuildContext context, ControlsDetails details) { + return Container( + margin: const EdgeInsets.only(top: 16.0), + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 48.0), + child: Row( + children: <Widget>[ + TextButton(onPressed: details.onStepContinue, child: const Text('Let us continue!')), + Container( + margin: const EdgeInsetsDirectional.only(start: 8.0), + child: TextButton( + onPressed: details.onStepCancel, + child: const Text('Cancel This!'), + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: Stepper( + controlsBuilder: builder, + onStepCancel: setCanceled, + onStepContinue: setContinue, + steps: const <Step>[ + Step( + title: Text('A'), + state: StepState.complete, + content: SizedBox(width: 100.0, height: 100.0), + ), + Step(title: Text('B'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ), + ); + + // 2 because stepper creates a set of controls for each step + expect(find.text('Let us continue!'), findsNWidgets(2)); + expect(find.text('Cancel This!'), findsNWidgets(2)); + + await tester.tap(find.text('Cancel This!').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Let us continue!').first); + await tester.pumpAndSettle(); + + expect(canceledPressed, isTrue); + expect(continuePressed, isTrue); + }); + + testWidgets('Stepper custom indexed controls test', (WidgetTester tester) async { + var currentStep = 0; + void setContinue() { + currentStep += 1; + } + + void setCanceled() { + currentStep -= 1; + } + + Widget builder(BuildContext context, ControlsDetails details) { + // For the purposes of testing, only render something for the active + // step. + if (!details.isActive) { + return Container(); + } + + return Container( + margin: const EdgeInsets.only(top: 16.0), + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 48.0), + child: Row( + children: <Widget>[ + TextButton( + onPressed: details.onStepContinue, + child: Text('Continue to ${details.stepIndex + 1}'), + ), + Container( + margin: const EdgeInsetsDirectional.only(start: 8.0), + child: TextButton( + onPressed: details.onStepCancel, + child: Text('Return to ${details.stepIndex - 1}'), + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Stepper( + currentStep: currentStep, + controlsBuilder: builder, + onStepCancel: () => setState(setCanceled), + onStepContinue: () => setState(setContinue), + steps: const <Step>[ + Step( + title: Text('A'), + state: StepState.complete, + content: SizedBox(width: 100.0, height: 100.0), + ), + Step(title: Text('C'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ); + }, + ), + ), + ), + ), + ); + + // Never mind that there is no Step -1 or Step 2 -- actual build method + // implementations would make those checks. + expect(find.text('Return to -1'), findsNWidgets(1)); + expect(find.text('Continue to 1'), findsNWidgets(1)); + expect(find.text('Return to 0'), findsNWidgets(0)); + expect(find.text('Continue to 2'), findsNWidgets(0)); + + await tester.tap(find.text('Continue to 1').first); + await tester.pumpAndSettle(); + + // Never mind that there is no Step -1 or Step 2 -- actual build method + // implementations would make those checks. + expect(find.text('Return to -1'), findsNWidgets(0)); + expect(find.text('Continue to 1'), findsNWidgets(0)); + expect(find.text('Return to 0'), findsNWidgets(1)); + expect(find.text('Continue to 2'), findsNWidgets(1)); + }); + + testWidgets('Stepper error test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Material( + child: Stepper( + steps: const <Step>[ + Step( + title: Text('A'), + state: StepState.error, + content: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('!'), findsOneWidget); + }); + + testWidgets('Nested stepper error test', (WidgetTester tester) async { + late FlutterErrorDetails errorDetails; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + errorDetails = details; + }; + try { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: <Step>[ + Step( + title: const Text('Step 2'), + content: Stepper( + steps: const <Step>[ + Step(title: Text('Nested step 1'), content: Text('A')), + Step(title: Text('Nested step 2'), content: Text('A')), + ], + ), + ), + const Step(title: Text('Step 1'), content: Text('A')), + ], + ), + ), + ), + ); + } finally { + FlutterError.onError = oldHandler; + } + + expect(errorDetails.stack, isNotNull); + // Check the ErrorDetails without the stack trace + final fullErrorMessage = errorDetails.toString(); + final List<String> lines = fullErrorMessage.split('\n'); + // The lines in the middle of the error message contain the stack trace + // which will change depending on where the test is run. + final String errorMessage = lines.takeWhile((String line) => line != '').join('\n'); + expect(errorMessage.length, lessThan(fullErrorMessage.length)); + expect( + errorMessage, + startsWith( + '══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞════════════════════════\n' + 'The following assertion was thrown building Stepper(', + ), + ); + // The description string of the stepper looks slightly different depending + // on the platform and is omitted here. + expect( + errorMessage, + endsWith( + '):\n' + 'Steppers must not be nested.\n' + 'The material specification advises that one should avoid\n' + 'embedding steppers within steppers.\n' + 'https://material.io/archive/guidelines/components/steppers.html#steppers-usage', + ), + ); + }); + + ///https://github.com/flutter/flutter/issues/16920 + testWidgets('Stepper icons size test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + steps: const <Step>[ + Step( + title: Text('A'), + state: StepState.editing, + content: SizedBox(width: 100.0, height: 100.0), + ), + Step( + title: Text('B'), + state: StepState.complete, + content: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ); + + RenderBox renderObject = tester.renderObject(find.byIcon(Icons.edit)); + expect(renderObject.size, equals(const Size.square(18.0))); + + renderObject = tester.renderObject(find.byIcon(Icons.check)); + expect(renderObject.size, equals(const Size.square(18.0))); + }); + + testWidgets('Stepper physics scroll error test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + Stepper( + steps: const <Step>[ + Step(title: Text('Step 1'), content: Text('Text 1')), + Step(title: Text('Step 2'), content: Text('Text 2')), + Step(title: Text('Step 3'), content: Text('Text 3')), + Step(title: Text('Step 4'), content: Text('Text 4')), + Step(title: Text('Step 5'), content: Text('Text 5')), + Step(title: Text('Step 6'), content: Text('Text 6')), + Step(title: Text('Step 7'), content: Text('Text 7')), + Step(title: Text('Step 8'), content: Text('Text 8')), + Step(title: Text('Step 9'), content: Text('Text 9')), + Step(title: Text('Step 10'), content: Text('Text 10')), + ], + ), + const Text('Text After Stepper'), + ], + ), + ), + ), + ); + + await tester.fling(find.byType(Stepper), const Offset(0.0, -100.0), 1000.0); + await tester.pumpAndSettle(); + + expect(find.text('Text After Stepper'), findsNothing); + }); + + testWidgets("Vertical Stepper can't be focused when disabled.", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + steps: const <Step>[ + Step(title: Text('Step 0'), state: StepState.disabled, content: Text('Text 0')), + ], + ), + ), + ), + ); + await tester.pump(); + + final FocusNode disabledNode = Focus.of(tester.element(find.text('Step 0')), scopeOk: true); + disabledNode.requestFocus(); + await tester.pump(); + expect(disabledNode.hasPrimaryFocus, isFalse); + }); + + testWidgets("Horizontal Stepper can't be focused when disabled.", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('Step 0'), state: StepState.disabled, content: Text('Text 0')), + ], + ), + ), + ), + ); + await tester.pump(); + + final FocusNode disabledNode = Focus.of(tester.element(find.text('Step 0')), scopeOk: true); + disabledNode.requestFocus(); + await tester.pump(); + expect(disabledNode.hasPrimaryFocus, isFalse); + }); + + testWidgets('Stepper header title should not overflow', (WidgetTester tester) async { + const longText = 'A long long long long long long long long long long long long text'; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + Stepper( + steps: const <Step>[Step(title: Text(longText), content: Text('Text content'))], + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Stepper header subtitle should not overflow', (WidgetTester tester) async { + const longText = 'A long long long long long long long long long long long long text'; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + Stepper( + steps: const <Step>[ + Step( + title: Text('Regular title'), + subtitle: Text(longText), + content: Text('Text content'), + ), + ], + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Material2 - Stepper enabled button styles', (WidgetTester tester) async { + Widget buildFrame(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Material( + child: Stepper( + type: StepperType.horizontal, + onStepCancel: () {}, + onStepContinue: () {}, + steps: const <Step>[ + Step(title: Text('step1'), content: SizedBox(width: 100, height: 100)), + ], + ), + ), + ); + } + + Material buttonMaterial(String label) { + return tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, label), + matching: find.byType(Material), + ), + ); + } + + const OutlinedBorder buttonShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(2)), + ); + + final themeLight = ThemeData(useMaterial3: false); + await tester.pumpWidget(buildFrame(themeLight)); + + const continueStr = 'CONTINUE'; + const cancelStr = 'CANCEL'; + const continueButtonRect = Rect.fromLTRB(24.0, 212.0, 168.0, 260.0); + const cancelButtonRect = Rect.fromLTRB(176.0, 212.0, 292.0, 260.0); + expect(buttonMaterial(continueStr).color!.value, 0xff2196f3); + expect(buttonMaterial(continueStr).textStyle!.color!.value, 0xffffffff); + expect(buttonMaterial(continueStr).shape, buttonShape); + expect(tester.getRect(find.widgetWithText(TextButton, continueStr)), continueButtonRect); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect(buttonMaterial(cancelStr).textStyle!.color!.value, 0x8a000000); + expect(buttonMaterial(cancelStr).shape, buttonShape); + expect(tester.getRect(find.widgetWithText(TextButton, cancelStr)), cancelButtonRect); + + final themeDark = ThemeData.dark(useMaterial3: false); + await tester.pumpWidget(buildFrame(themeDark)); + await tester.pumpAndSettle(); // Complete the theme animation. + + expect(buttonMaterial(continueStr).color!.value, 0); + expect(buttonMaterial(continueStr).textStyle!.color!.value, 0xffffffff); + expect(buttonMaterial(continueStr).shape, buttonShape); + expect(tester.getRect(find.widgetWithText(TextButton, continueStr)), continueButtonRect); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect(buttonMaterial(cancelStr).textStyle!.color!.value, 0xb3ffffff); + expect(buttonMaterial(cancelStr).shape, buttonShape); + expect(tester.getRect(find.widgetWithText(TextButton, cancelStr)), cancelButtonRect); + }); + + testWidgets('Material3 - Stepper enabled button styles', (WidgetTester tester) async { + Widget buildFrame(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Material( + child: Stepper( + type: StepperType.horizontal, + onStepCancel: () {}, + onStepContinue: () {}, + steps: const <Step>[ + Step(title: Text('step1'), content: SizedBox(width: 100, height: 100)), + ], + ), + ), + ); + } + + Material buttonMaterial(String label) { + return tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, label), + matching: find.byType(Material), + ), + ); + } + + const OutlinedBorder buttonShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(2)), + ); + + final themeLight = ThemeData(); + await tester.pumpWidget(buildFrame(themeLight)); + + const continueStr = 'Continue'; + const cancelStr = 'Cancel'; + const continueButtonRect = Rect.fromLTRB(24.0, 212.0, 168.8, 260.0); + const cancelButtonRect = Rect.fromLTRB(176.8, 212.0, 293.4, 260.0); + expect(buttonMaterial(continueStr).color!.value, themeLight.colorScheme.primary.value); + expect(buttonMaterial(continueStr).textStyle!.color!.value, 0xffffffff); + expect(buttonMaterial(continueStr).shape, buttonShape); + expect( + tester.getRect(find.widgetWithText(TextButton, continueStr)), + rectMoreOrLessEquals(continueButtonRect, epsilon: 0.001), + ); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect(buttonMaterial(cancelStr).textStyle!.color!.value, 0x8a000000); + expect(buttonMaterial(cancelStr).shape, buttonShape); + expect( + tester.getRect(find.widgetWithText(TextButton, cancelStr)), + rectMoreOrLessEquals(cancelButtonRect, epsilon: 0.001), + ); + + final themeDark = ThemeData.dark(); + await tester.pumpWidget(buildFrame(themeDark)); + await tester.pumpAndSettle(); // Complete the theme animation. + + expect(buttonMaterial(continueStr).color!.value, 0); + expect( + buttonMaterial(continueStr).textStyle!.color!.value, + themeDark.colorScheme.onSurface.value, + ); + expect(buttonMaterial(continueStr).shape, buttonShape); + expect( + tester.getRect(find.widgetWithText(TextButton, continueStr)), + rectMoreOrLessEquals(continueButtonRect, epsilon: 0.001), + ); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect(buttonMaterial(cancelStr).textStyle!.color!.value, 0xb3ffffff); + expect(buttonMaterial(cancelStr).shape, buttonShape); + expect( + tester.getRect(find.widgetWithText(TextButton, cancelStr)), + rectMoreOrLessEquals(cancelButtonRect, epsilon: 0.001), + ); + }); + + testWidgets('Material2 - Stepper disabled button styles', (WidgetTester tester) async { + Widget buildFrame(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('step1'), content: SizedBox(width: 100, height: 100)), + ], + ), + ), + ); + } + + Material buttonMaterial(String label) { + return tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, label), + matching: find.byType(Material), + ), + ); + } + + final themeLight = ThemeData(useMaterial3: false); + await tester.pumpWidget(buildFrame(themeLight)); + + const continueStr = 'CONTINUE'; + const cancelStr = 'CANCEL'; + expect(buttonMaterial(continueStr).color!.value, 0); + expect(buttonMaterial(continueStr).textStyle!.color!.value, 0x61000000); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect(buttonMaterial(cancelStr).textStyle!.color!.value, 0x61000000); + + final themeDark = ThemeData.dark(useMaterial3: false); + await tester.pumpWidget(buildFrame(themeDark)); + await tester.pumpAndSettle(); // Complete the theme animation. + + expect(buttonMaterial(continueStr).color!.value, 0); + expect(buttonMaterial(continueStr).textStyle!.color!.value, 0x61ffffff); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect(buttonMaterial(cancelStr).textStyle!.color!.value, 0x61ffffff); + }); + + testWidgets('Material3 - Stepper disabled button styles', (WidgetTester tester) async { + Widget buildFrame(ThemeData theme) { + return MaterialApp( + theme: theme, + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('step1'), content: SizedBox(width: 100, height: 100)), + ], + ), + ), + ); + } + + Material buttonMaterial(String label) { + return tester.widget<Material>( + find.descendant( + of: find.widgetWithText(TextButton, label), + matching: find.byType(Material), + ), + ); + } + + final themeLight = ThemeData(); + final ColorScheme colorsLight = themeLight.colorScheme; + await tester.pumpWidget(buildFrame(themeLight)); + + const continueStr = 'Continue'; + const cancelStr = 'Cancel'; + expect(buttonMaterial(continueStr).color!.value, 0); + expect( + buttonMaterial(continueStr).textStyle!.color!.value, + colorsLight.onSurface.withOpacity(0.38).value, + ); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect( + buttonMaterial(cancelStr).textStyle!.color!.value, + colorsLight.onSurface.withOpacity(0.38).value, + ); + + final themeDark = ThemeData.dark(); + final ColorScheme colorsDark = themeDark.colorScheme; + await tester.pumpWidget(buildFrame(themeDark)); + await tester.pumpAndSettle(); // Complete the theme animation. + + expect(buttonMaterial(continueStr).color!.value, 0); + expect( + buttonMaterial(continueStr).textStyle!.color!.value, + colorsDark.onSurface.withOpacity(0.38).value, + ); + + expect(buttonMaterial(cancelStr).color!.value, 0); + expect( + buttonMaterial(cancelStr).textStyle!.color!.value, + colorsDark.onSurface.withOpacity(0.38).value, + ); + }); + + testWidgets('Vertical and Horizontal Stepper physics test', (WidgetTester tester) async { + const ScrollPhysics physics = NeverScrollableScrollPhysics(); + + for (final StepperType type in StepperType.values) { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + physics: physics, + type: type, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ); + + final ListView listView = tester.widget<ListView>( + find.descendant(of: find.byType(Stepper), matching: find.byType(ListView)), + ); + expect(listView.physics, physics); + } + }); + + testWidgets('ScrollController is passed to the stepper listview', (WidgetTester tester) async { + final controller = ScrollController(); + addTearDown(() => controller.dispose()); + for (final StepperType type in StepperType.values) { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + controller: controller, + type: type, + steps: const <Step>[ + Step(title: Text('Step 1'), content: SizedBox(width: 100.0, height: 100.0)), + ], + ), + ), + ), + ); + + final ListView listView = tester.widget<ListView>( + find.descendant(of: find.byType(Stepper), matching: find.byType(ListView)), + ); + expect(listView.controller, controller); + } + }); + + testWidgets('Stepper horizontal size test', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/77732 + Widget buildFrame({bool isActive = true, Brightness? brightness}) { + return MaterialApp( + theme: brightness == Brightness.dark ? ThemeData.dark() : ThemeData(), + home: Scaffold( + body: Center( + child: Stepper( + type: StepperType.horizontal, + steps: <Step>[ + Step(title: const Text('step'), content: const Text('content'), isActive: isActive), + ], + ), + ), + ), + ); + } + + Color? circleFillColor() { + final Finder container = find.widgetWithText(AnimatedContainer, '1'); + return (tester.widget<AnimatedContainer>(container).decoration as BoxDecoration?)?.color; + } + + // Light theme + final ColorScheme light = ThemeData().colorScheme; + await tester.pumpWidget(buildFrame(brightness: Brightness.light)); + expect(circleFillColor(), light.primary); + await tester.pumpWidget(buildFrame(isActive: false, brightness: Brightness.light)); + await tester.pumpAndSettle(); + expect(circleFillColor(), light.onSurface.withOpacity(0.38)); + + // Dark theme + final ColorScheme dark = ThemeData.dark().colorScheme; + await tester.pumpWidget(buildFrame(brightness: Brightness.dark)); + await tester.pumpAndSettle(); + expect(circleFillColor(), dark.secondary); + await tester.pumpWidget(buildFrame(isActive: false, brightness: Brightness.dark)); + await tester.pumpAndSettle(); + expect(circleFillColor(), dark.background); + }); + + testWidgets('Stepper custom elevation', (WidgetTester tester) async { + const elevation = 4.0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 200, + height: 75, + child: Stepper( + type: StepperType.horizontal, + elevation: elevation, + steps: const <Step>[ + Step(title: Text('Regular title'), content: Text('Text content')), + ], + ), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.descendant(of: find.byType(Stepper), matching: find.byType(Material)), + ); + + expect(material.elevation, elevation); + }); + + testWidgets('Stepper with default elevation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 200, + height: 75, + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[ + Step(title: Text('Regular title'), content: Text('Text content')), + ], + ), + ), + ), + ), + ); + + final Material material = tester.firstWidget<Material>( + find.descendant(of: find.byType(Stepper), matching: find.byType(Material)), + ); + + expect(material.elevation, 2.0); + }); + + testWidgets('Stepper horizontal preserves state', (WidgetTester tester) async { + const Color untappedColor = Colors.blue; + const Color tappedColor = Colors.red; + var index = 0; + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: Center( + // Must break this out into its own widget purely to be able to call `setState()` + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Stepper( + onStepTapped: (int i) => setState(() => index = i), + currentStep: index, + type: StepperType.horizontal, + steps: const <Step>[ + Step( + title: Text('Step 1'), + content: _TappableColorWidget( + key: Key('tappable-color'), + tappedColor: tappedColor, + untappedColor: untappedColor, + ), + ), + Step(title: Text('Step 2'), content: Text('Step 2 Content')), + ], + ); + }, + ), + ), + ), + ); + } + + final Widget widget = buildFrame(); + await tester.pumpWidget(widget); + + // Set up a getter to examine the MacGuffin's color + Color getColor() => tester + .widget<ColoredBox>( + find.descendant( + of: find.byKey(const Key('tappable-color')), + matching: find.byType(ColoredBox), + ), + ) + .color; + + // We are on step 1 + expect(find.text('Step 2 Content'), findsNothing); + expect(getColor(), untappedColor); + + await tester.tap(find.byKey(const Key('tap-me'))); + await tester.pumpAndSettle(); + expect(getColor(), tappedColor); + + // Now flip to step 2 + await tester.tap(find.text('Step 2')); + await tester.pumpAndSettle(); + + // Confirm that we did in fact flip to step 2 + expect(find.text('Step 2 Content'), findsOneWidget); + + // Now go back to step 1 + await tester.tap(find.text('Step 1')); + await tester.pumpAndSettle(); + + // Confirm that we flipped back to step 1 + expect(find.text('Step 2 Content'), findsNothing); + + // The color should still be `tappedColor` + expect(getColor(), tappedColor); + }); + testWidgets('Stepper custom margin', (WidgetTester tester) async { + const EdgeInsetsGeometry margin = EdgeInsetsDirectional.only(bottom: 20, top: 20); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SizedBox( + width: 200, + height: 75, + child: Stepper( + margin: margin, + steps: const <Step>[ + Step(title: Text('Regular title'), content: Text('Text content')), + ], + ), + ), + ), + ), + ); + + final Stepper material = tester.firstWidget<Stepper>( + find.descendant(of: find.byType(Material), matching: find.byType(Stepper)), + ); + + expect(material.margin, equals(margin)); + }); + + testWidgets('Stepper with Alternative Label', (WidgetTester tester) async { + var index = 0; + late TextStyle bodyLargeStyle; + late TextStyle bodyMediumStyle; + late TextStyle bodySmallStyle; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + bodyLargeStyle = Theme.of(context).textTheme.bodyLarge!; + bodyMediumStyle = Theme.of(context).textTheme.bodyMedium!; + bodySmallStyle = Theme.of(context).textTheme.bodySmall!; + return Stepper( + type: StepperType.horizontal, + currentStep: index, + onStepTapped: (int i) { + setState(() { + index = i; + }); + }, + steps: <Step>[ + Step( + title: const Text('Title 1'), + content: const Text('Content 1'), + label: Text('Label 1', style: Theme.of(context).textTheme.bodySmall), + ), + Step( + title: const Text('Title 2'), + content: const Text('Content 2'), + label: Text('Label 2', style: Theme.of(context).textTheme.bodyLarge), + ), + Step( + title: const Text('Title 3'), + content: const Text('Content 3'), + label: Text('Label 3', style: Theme.of(context).textTheme.bodyMedium), + ), + ], + ); + }, + ), + ), + ), + ); + + // Check Styles of Label Text Widgets before tapping steps + final Text label1TextWidget = tester.widget<Text>(find.text('Label 1')); + final Text label3TextWidget = tester.widget<Text>(find.text('Label 3')); + + expect(bodySmallStyle, label1TextWidget.style); + expect(bodyMediumStyle, label3TextWidget.style); + + late Text selectedLabelTextWidget; + late Text nextLabelTextWidget; + + // Tap to Step1 Label then, `index` become 0 + await tester.tap(find.text('Label 1')); + expect(index, 0); + + // Check Styles of Selected Label Text Widgets and Another Label Text Widget + selectedLabelTextWidget = tester.widget<Text>(find.text('Label ${index + 1}')); + expect(bodySmallStyle, selectedLabelTextWidget.style); + nextLabelTextWidget = tester.widget<Text>(find.text('Label ${index + 2}')); + expect(bodyLargeStyle, nextLabelTextWidget.style); + + // Tap to Step2 Label then, `index` become 1 + await tester.tap(find.text('Label 2')); + expect(index, 1); + + // Check Styles of Selected Label Text Widgets and Another Label Text Widget + selectedLabelTextWidget = tester.widget<Text>(find.text('Label ${index + 1}')); + expect(bodyLargeStyle, selectedLabelTextWidget.style); + + nextLabelTextWidget = tester.widget<Text>(find.text('Label ${index + 2}')); + expect(bodyMediumStyle, nextLabelTextWidget.style); + }); + + testWidgets('Stepper Connector Style', (WidgetTester tester) async { + const Color selectedColor = Colors.black; + const Color disabledColor = Colors.white; + var index = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Stepper( + type: StepperType.horizontal, + connectorColor: WidgetStateProperty.resolveWith<Color>( + (Set<WidgetState> states) => + states.contains(WidgetState.selected) ? selectedColor : disabledColor, + ), + onStepTapped: (int i) => setState(() => index = i), + currentStep: index, + steps: <Step>[ + Step( + isActive: index >= 0, + title: const Text('step1'), + content: const Text('step1 content'), + ), + Step( + isActive: index >= 1, + title: const Text('step2'), + content: const Text('step2 content'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + Color? circleColor(String circleText) => + (tester + .widget<AnimatedContainer>(find.widgetWithText(AnimatedContainer, circleText)) + .decoration + as BoxDecoration?) + ?.color; + + Color lineColor() { + return tester + .widget<ColoredBox>( + find.descendant(of: find.byType(Stepper), matching: find.byType(ColoredBox)), + ) + .color; + } + + // Step 1 + // check if I'm in step 1 + expect(find.text('step1 content'), findsOneWidget); + expect(find.text('step2 content'), findsNothing); + + expect(circleColor('1'), selectedColor); + expect(circleColor('2'), disabledColor); + // in two steps case there will be single line + expect(lineColor(), selectedColor); + + // now hitting step two + await tester.tap(find.text('step2')); + await tester.pumpAndSettle(); + + // check if I'm in step 1 + expect(find.text('step1 content'), findsNothing); + expect(find.text('step2 content'), findsOneWidget); + + expect(circleColor('1'), selectedColor); + expect(circleColor('2'), selectedColor); + + expect(lineColor(), selectedColor); + }); + + testWidgets('Stepper stepIconBuilder test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + stepIconBuilder: (int index, StepState state) { + if (state == StepState.complete) { + return const FlutterLogo(size: 18); + } + return null; + }, + steps: const <Step>[ + Step( + title: Text('A'), + state: StepState.complete, + content: SizedBox(width: 100.0, height: 100.0), + ), + Step( + title: Text('B'), + state: StepState.editing, + content: SizedBox(width: 100.0, height: 100.0), + ), + Step( + title: Text('C'), + state: StepState.error, + content: SizedBox(width: 100.0, height: 100.0), + ), + ], + ), + ), + ), + ); + + /// Finds the overridden widget for StepState.complete + expect(find.byType(FlutterLogo), findsOneWidget); + + /// StepState.editing and StepState.error should have a default icon + expect(find.byIcon(Icons.edit), findsOneWidget); + expect(find.text('!'), findsOneWidget); + }); + + testWidgets('StepperProperties test', (WidgetTester tester) async { + const Widget widget = SizedBox.shrink(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + stepIconHeight: 24, + stepIconWidth: 24, + stepIconMargin: const EdgeInsets.all(8), + steps: List<Step>.generate(3, (int index) { + return Step(title: Text('Step $index'), content: widget); + }), + ), + ), + ), + ); + + final Finder stepperFinder = find.byType(Stepper); + final Stepper stepper = tester.widget<Stepper>(stepperFinder); + + expect(stepper.stepIconHeight, 24); + expect(stepper.stepIconWidth, 24); + expect(stepper.stepIconMargin, const EdgeInsets.all(8)); + }); + + testWidgets('StepStyle test', (WidgetTester tester) async { + final stepStyle = StepStyle( + color: Colors.white, + errorColor: Colors.orange, + connectorColor: Colors.red, + connectorThickness: 2, + border: Border.all(), + gradient: const LinearGradient(colors: <Color>[Colors.red, Colors.blue]), + indexStyle: const TextStyle(color: Colors.black), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + steps: <Step>[ + Step( + title: const Text('Regular title'), + content: const Text('Text content'), + stepStyle: stepStyle, + ), + ], + ), + ), + ), + ); + + final Finder stepperFinder = find.byType(Stepper); + final Stepper stepper = tester.widget<Stepper>(stepperFinder); + final StepStyle? style = stepper.steps.first.stepStyle; + + expect(style?.color, stepStyle.color); + expect(style?.errorColor, stepStyle.errorColor); + expect(style?.connectorColor, stepStyle.connectorColor); + expect(style?.connectorThickness, stepStyle.connectorThickness); + expect(style?.border, stepStyle.border); + expect(style?.gradient, stepStyle.gradient); + expect(style?.indexStyle, stepStyle.indexStyle); + + //copyWith + final StepStyle newStyle = stepStyle.copyWith( + color: Colors.black, + errorColor: Colors.red, + connectorColor: Colors.blue, + connectorThickness: 3, + border: Border.all(), + gradient: const LinearGradient(colors: <Color>[Colors.red, Colors.blue]), + indexStyle: const TextStyle(color: Colors.black), + ); + + expect(newStyle.color, Colors.black); + expect(newStyle.errorColor, Colors.red); + expect(newStyle.connectorColor, Colors.blue); + expect(newStyle.connectorThickness, 3); + expect(newStyle.border, stepStyle.border); + expect(newStyle.gradient, stepStyle.gradient); + expect(newStyle.indexStyle, stepStyle.indexStyle); + + //merge + final StepStyle mergedStyle = stepStyle.merge(newStyle); + + expect(mergedStyle.color, Colors.black); + expect(mergedStyle.errorColor, Colors.red); + expect(mergedStyle.connectorColor, Colors.blue); + expect(mergedStyle.connectorThickness, 3); + expect(mergedStyle.border, stepStyle.border); + expect(mergedStyle.gradient, stepStyle.gradient); + expect(mergedStyle.indexStyle, stepStyle.indexStyle); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/144376. + testWidgets('Vertical Stepper does not draw connector on the last step', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Stepper( + currentStep: 1, + steps: const <Step>[ + Step(title: Text('step1'), content: Text('step1 content')), + Step(title: Text('step2'), content: Text('step2 content')), + ], + ), + ), + ), + ), + ); + + final lastConnector = + tester + .widget<Center>( + find.descendant( + of: find.byType(PositionedDirectional), + matching: find.byType(Center).last, + ), + ) + .child! + as SizedBox; + + expect(lastConnector.width, equals(0.0)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/66007. + testWidgets('Default Stepper clipBehavior', (WidgetTester tester) async { + Widget buildStepper({required StepperType type}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Stepper( + type: type, + steps: const <Step>[ + Step(title: Text('step1'), content: Text('step1 content')), + Step(title: Text('step2'), content: Text('step2 content')), + ], + ), + ), + ), + ); + } + + ClipRect getContentClipRect() { + return tester.widget<ClipRect>( + find.ancestor(of: find.text('step1 content'), matching: find.byType(ClipRect)).first, + ); + } + + // Test vertical stepper with default clipBehavior. + await tester.pumpWidget(buildStepper(type: StepperType.vertical)); + + expect(getContentClipRect().clipBehavior, equals(Clip.none)); + + // Test horizontal stepper with default clipBehavior. + await tester.pumpWidget(buildStepper(type: StepperType.horizontal)); + + expect(getContentClipRect().clipBehavior, equals(Clip.none)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/66007. + testWidgets('Stepper steps can be clipped', (WidgetTester tester) async { + Widget buildStepper({required StepperType type, required Clip clipBehavior}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Stepper( + clipBehavior: clipBehavior, + type: type, + steps: const <Step>[ + Step(title: Text('step1'), content: Text('step1 content')), + Step(title: Text('step2'), content: Text('step2 content')), + ], + ), + ), + ), + ); + } + + ClipRect getContentClipRect() { + return tester.widget<ClipRect>( + find.ancestor(of: find.text('step1 content'), matching: find.byType(ClipRect)).first, + ); + } + + // Test vertical stepper with clipBehavior set to Clip.hardEdge. + await tester.pumpWidget(buildStepper(type: StepperType.vertical, clipBehavior: Clip.hardEdge)); + + expect(getContentClipRect().clipBehavior, equals(Clip.hardEdge)); + + // Test horizontal stepper with clipBehavior set to Clip.hardEdge. + await tester.pumpWidget( + buildStepper(type: StepperType.horizontal, clipBehavior: Clip.hardEdge), + ); + + expect(getContentClipRect().clipBehavior, equals(Clip.hardEdge)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/160156. + testWidgets('Vertical stepper border displays correctly', (WidgetTester tester) async { + var index = 0; + const connectorColor = Color(0xff00ffff); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Stepper( + currentStep: index, + connectorColor: const WidgetStatePropertyAll<Color>(connectorColor), + onStepTapped: (int value) { + setState(() { + index = value; + }); + }, + steps: const <Step>[ + Step(title: Text('step1'), content: Text('step1 content')), + Step(title: Text('step2'), content: Text('step2 content')), + ], + ); + }, + ), + ), + ), + ), + ); + + final Finder findConnector = find.descendant( + of: find.byType(Stepper), + matching: find.descendant( + of: find.byType(PositionedDirectional), + matching: find.byElementPredicate((BuildContext context) { + if (context case BuildContext( + widget: ColoredBox(color: connectorColor), + size: Size(width: 1.0, height: > 0), + )) { + return true; + } + return false; + }), + ), + ); + + void verifyConnector() { + expect(findConnector, findsOneWidget); + final RenderBox renderBox = tester.renderObject(findConnector); + expect(renderBox, paints..rect(color: connectorColor)); + } + + verifyConnector(); + + final Finder findStep2 = find.text('step2'); + await tester.tap(findStep2); + + const checkCount = 5; + final duration = Duration( + microseconds: kThemeAnimationDuration.inMicroseconds ~/ (checkCount + 1), + ); + + for (var i = 0; i < checkCount; i++) { + await tester.pump(duration); + verifyConnector(); + } + }); + + testWidgets('Vertical stepper active step has fully colored connector line', ( + WidgetTester tester, + ) async { + const activeColor = Color(0xFF2196F3); + const inactiveColor = Color(0xFF9E9E9E); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Stepper( + controlsBuilder: (_, _) => const SizedBox.shrink(), + connectorThickness: 3, + connectorColor: MaterialStateProperty.resolveWith<Color>( + (Set<WidgetState> states) => + states.contains(WidgetState.selected) ? activeColor : inactiveColor, + ), + steps: const <Step>[ + Step(title: Text('step1'), content: Text('step1 content'), isActive: true), + Step(title: Text('step2'), content: Text('step2 content')), + ], + ), + ), + ), + ), + ); + + final Finder connectorLines = find.byWidgetPredicate( + (Widget widget) => + widget is ColoredBox && + widget.child is SizedBox && + (widget.child! as SizedBox).width == 3.0, + ); + + expect(connectorLines, findsWidgets); + + final List<ColoredBox> lineWidgets = tester.widgetList<ColoredBox>(connectorLines).toList(); + final List<Color> colors = lineWidgets.map((ColoredBox box) => box.color).toList(); + // Both top and bottom box should be colored. + expect(colors.where((Color c) => c == activeColor).length, equals(2)); + expect(colors.first, equals(activeColor)); + expect(colors.last, equals(activeColor)); + }); + + testWidgets('Stepper does not crash at zero area', (WidgetTester tester) async { + for (final StepperType type in StepperType.values) { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: Stepper( + type: type, + steps: const <Step>[ + Step(title: Text('X'), content: Text('X')), + Step(title: Text('Y'), content: Text('Y')), + ], + ), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Stepper)), Size.zero); + } + }); + + testWidgets('Stepper custom headerPadding for vertical stepper', (WidgetTester tester) async { + // Default header padding is 24.0 horizontal, 0.0 vertical. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Rect defaultTitleRect = tester.getRect(find.text('Step 1')); + + // Custom padding: horizontal 12.0 (reduces left by 12.0), vertical 8.0 (increases top by 8.0). + const EdgeInsetsGeometry customPadding = EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + headerPadding: customPadding, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Rect customTitleRect = tester.getRect(find.text('Step 1')); + + expect(customTitleRect.left, lessThan(defaultTitleRect.left)); + expect(defaultTitleRect.left - customTitleRect.left, moreOrLessEquals(12.0)); + expect(defaultTitleRect.top, equals(24.0)); + expect(customTitleRect.top, greaterThan(defaultTitleRect.top)); + expect(customTitleRect.top, equals(32.0)); + expect(customTitleRect.top - defaultTitleRect.top, moreOrLessEquals(8.0)); + }); + + testWidgets('Stepper custom headerPadding for horizontal stepper', (WidgetTester tester) async { + // Default header padding is 24.0 horizontal, 0.0 vertical. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Rect defaultTitleRect = tester.getRect(find.text('Step 1')); + + // Custom padding: horizontal 16.0 (reduces left by 8.0 from default 24.0), + // vertical 8.0 (increases top by 8.0). + const EdgeInsetsGeometry customPadding = EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + headerPadding: customPadding, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Rect customTitleRect = tester.getRect(find.text('Step 1')); + + expect(customTitleRect.left, lessThan(defaultTitleRect.left)); + expect(defaultTitleRect.left - customTitleRect.left, moreOrLessEquals(8.0)); + expect(defaultTitleRect.top, equals(24.0)); + expect(customTitleRect.top, greaterThan(defaultTitleRect.top)); + expect(customTitleRect.top, equals(32.0)); + expect(customTitleRect.top - defaultTitleRect.top, moreOrLessEquals(8.0)); + }); + + testWidgets('Stepper custom contentPadding for horizontal stepper', (WidgetTester tester) async { + const EdgeInsetsGeometry customPadding = EdgeInsets.all(16.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + contentPadding: customPadding, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper)); + expect(stepper.contentPadding, customPadding); + + // The content is in a ListView, verify its padding + final ListView listView = tester.widget<ListView>( + find.descendant(of: find.byType(Stepper), matching: find.byType(ListView)), + ); + expect(listView.padding, customPadding); + }); + + testWidgets('Stepper custom contentPadding for vertical stepper', (WidgetTester tester) async { + const EdgeInsetsGeometry customPadding = EdgeInsetsDirectional.only( + start: 48.0, + end: 16.0, + bottom: 16.0, + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + contentPadding: customPadding, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + // Find the Padding widget that wraps the step content and verify the effective padding. + const expected = EdgeInsets.only(left: 48.0, right: 16.0, bottom: 16.0); + + final Iterable<Padding> paddings = tester.widgetList<Padding>( + find.ancestor(of: find.text('Content 1'), matching: find.byType(Padding)), + ); + + expect(paddings.any((Padding p) => p.padding.resolve(TextDirection.ltr) == expected), isTrue); + }); + + testWidgets('Stepper default contentPadding for horizontal stepper', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + type: StepperType.horizontal, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper)); + expect(stepper.contentPadding, isNull); + + // Verify the default padding is applied to ListView + final ListView listView = tester.widget<ListView>( + find.descendant(of: find.byType(Stepper), matching: find.byType(ListView)), + ); + expect(listView.padding, const EdgeInsets.all(24.0)); + }); + + testWidgets('Stepper vertical contentPadding includes stepIconMargin start value', ( + WidgetTester tester, + ) async { + const customIconMargin = EdgeInsets.only(left: 16.0, right: 8.0); + const customContentPadding = EdgeInsetsDirectional.only(start: 40.0, end: 20.0, bottom: 16.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + stepIconMargin: customIconMargin, + contentPadding: customContentPadding, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + // Find the Padding widget that wraps the step content + final Finder contentPadding = find.ancestor( + of: find.text('Content 1'), + matching: find.byType(Padding), + ); + + final Padding paddingWidget = tester.widget<Padding>(contentPadding.first); + final EdgeInsetsGeometry resolvedPadding = paddingWidget.padding; + + // The effective padding should be customContentPadding + stepIconMargin.left + // start: 40.0 + 16.0 = 56.0 + const expectedPadding = EdgeInsets.only( + left: 56.0, // 40.0 + 16.0 (marginLeft) + right: 20.0, + bottom: 16.0, + ); + + expect(resolvedPadding.resolve(TextDirection.ltr), expectedPadding); + }); + + testWidgets('Stepper vertical default contentPadding includes stepIconMargin start value', ( + WidgetTester tester, + ) async { + const customIconMargin = EdgeInsets.only(left: 10.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + stepIconMargin: customIconMargin, + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + // Find the Padding widget that wraps the step content + final Finder contentPadding = find.ancestor( + of: find.text('Content 1'), + matching: find.byType(Padding), + ); + + final Padding paddingWidget = tester.widget<Padding>(contentPadding.first); + final EdgeInsetsGeometry resolvedPadding = paddingWidget.padding; + + // Default padding is start: 60.0, end: 24.0, bottom: 24.0 + // Plus stepIconMargin.left: 10.0 + // Expected start: 60.0 + 10.0 = 70.0 + const expectedPadding = EdgeInsets.only( + left: 70.0, // 60.0 + 10.0 (marginLeft) + right: 24.0, + bottom: 24.0, + ); + + expect(resolvedPadding.resolve(TextDirection.ltr), expectedPadding); + }); + + testWidgets('Stepper vertical contentPadding without stepIconMargin uses default', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Stepper( + steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + // Find the Padding widget that wraps the step content + final Finder contentPadding = find.ancestor( + of: find.text('Content 1'), + matching: find.byType(Padding), + ); + + final Padding paddingWidget = tester.widget<Padding>(contentPadding.first); + final EdgeInsetsGeometry resolvedPadding = paddingWidget.padding; + + // Default padding without stepIconMargin + const expectedPadding = EdgeInsets.only(left: 60.0, right: 24.0, bottom: 24.0); + + expect(resolvedPadding.resolve(TextDirection.ltr), expectedPadding); + }); +} + +class _TappableColorWidget extends StatefulWidget { + const _TappableColorWidget({required this.tappedColor, required this.untappedColor, super.key}); + + final Color tappedColor; + final Color untappedColor; + + @override + State<StatefulWidget> createState() => _TappableColorWidgetState(); +} + +class _TappableColorWidgetState extends State<_TappableColorWidget> { + Color? color; + + @override + void initState() { + super.initState(); + color = widget.untappedColor; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + color = widget.tappedColor; + }); + }, + child: Container(key: const Key('tap-me'), height: 50, width: 50, color: color), + ); + } +} diff --git a/packages/material_ui/test/material/switch_list_tile_test.dart b/packages/material_ui/test/material/switch_list_tile_test.dart new file mode 100644 index 000000000000..3396921d41f0 --- /dev/null +++ b/packages/material_ui/test/material/switch_list_tile_test.dart @@ -0,0 +1,2574 @@ +// 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/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +Widget wrap({required Widget child}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: child), + ), + ); +} + +void main() { + testWidgets('SwitchListTile control test', (WidgetTester tester) async { + final log = <dynamic>[]; + await tester.pumpWidget( + wrap( + child: SwitchListTile( + value: true, + onChanged: (bool value) { + log.add(value); + }, + title: const Text('Hello'), + ), + ), + ); + await tester.tap(find.text('Hello')); + log.add('-'); + await tester.tap(find.byType(Switch)); + expect(log, equals(<dynamic>[false, '-', false])); + }); + + testWidgets('SwitchListTile semantics test', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + wrap( + child: Column( + children: <Widget>[ + SwitchListTile( + value: true, + onChanged: (bool value) {}, + title: const Text('AAA'), + secondary: const Text('aaa'), + internalAddSemanticForOnTap: true, + ), + CheckboxListTile( + value: true, + onChanged: (bool? value) {}, + title: const Text('BBB'), + secondary: const Text('bbb'), + internalAddSemanticForOnTap: true, + ), + RadioListTile<bool>( + value: true, + groupValue: false, + onChanged: (bool? value) {}, + title: const Text('CCC'), + secondary: const Text('ccc'), + internalAddSemanticForOnTap: true, + ), + ], + ), + ), + ); + + // This test verifies that the label and the control get merged. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasToggledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isToggled, + SemanticsFlag.hasSelectedState, + ], + actions: SemanticsAction.tap.index | SemanticsAction.focus.index, + label: 'aaa\nAAA', + ), + TestSemantics.rootChild( + id: 3, + rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + transform: Matrix4.translationValues(0.0, 56.0, 0.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isChecked, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ], + actions: SemanticsAction.tap.index | SemanticsAction.focus.index, + label: 'bbb\nBBB', + ), + TestSemantics.rootChild( + id: 5, + rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + transform: Matrix4.translationValues(0.0, 112.0, 0.0), + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasSelectedState, + ], + actions: SemanticsAction.tap.index | SemanticsAction.focus.index, + label: 'CCC\nccc', + ), + ], + ), + ), + ); + + semantics.dispose(); + }); + + testWidgets('Material2 - SwitchListTile has the right colors', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(8.0)), + child: Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: SwitchListTile( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + activeColor: Colors.red[500], + activeTrackColor: Colors.green[500], + inactiveThumbColor: Colors.yellow[500], + inactiveTrackColor: Colors.blue[500], + ), + ); + }, + ), + ), + ), + ), + ); + + expect( + find.byType(Switch), + paints + ..rrect(color: Colors.blue[500]) + ..rrect() + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.yellow[500]), + ); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Colors.green[500]) + ..rrect() + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.red[500]), + ); + }); + + testWidgets('Material3 - SwitchListTile has the right colors', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.all(8.0)), + child: Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: SwitchListTile( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + activeColor: Colors.red[500], + activeTrackColor: Colors.green[500], + inactiveThumbColor: Colors.yellow[500], + inactiveTrackColor: Colors.blue[500], + ), + ); + }, + ), + ), + ), + ), + ); + + expect( + find.byType(Switch), + paints + ..rrect(color: Colors.blue[500]) + ..rrect() + ..rrect(color: Colors.yellow[500]), + ); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Colors.green[500]) + ..rrect() + ..rrect(color: Colors.red[500]), + ); + }); + + testWidgets('SwitchListTile.adaptive only uses material switch', (WidgetTester tester) async { + var value = false; + + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: SwitchListTile.adaptive( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.iOS, + TargetPlatform.macOS, + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + value = false; + await tester.pumpWidget(buildFrame(platform)); + expect(find.byType(CupertinoSwitch), findsNothing); + expect(find.byType(Switch), findsOneWidget); + expect(value, isFalse, reason: 'on ${platform.name}'); + + await tester.tap(find.byType(SwitchListTile)); + expect(value, isTrue, reason: 'on ${platform.name}'); + } + }); + + testWidgets('SwitchListTile contentPadding', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + contentPadding: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + top: 30.0, + bottom: 40.0, + ), + secondary: const Text('L'), + title: const Text('title'), + value: true, + onChanged: (bool selected) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getTopLeft(find.text('L')).dx, 10.0); // contentPadding.start = 10 + expect(tester.getTopRight(find.byType(Switch)).dx, 780.0); // 800 - contentPadding.end + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getTopLeft(find.byType(Switch)).dx, 20.0); // contentPadding.end = 20 + expect(tester.getTopRight(find.text('L')).dx, 790.0); // 800 - contentPadding.start + }); + + testWidgets('SwitchListTile forwards statesController to ListTile', (WidgetTester tester) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + SwitchListTile( + value: true, + onChanged: (_) {}, + title: const Text('Switch'), + statesController: controller, + ), + ], + ), + ), + ), + ); + + final ListTile listTile = tester.widget<ListTile>(find.byType(ListTile)); + + expect(listTile.statesController, same(controller)); + }); + + testWidgets('SwitchListTile can autofocus unless disabled.', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + SwitchListTile( + value: true, + onChanged: (_) {}, + title: Text('A', key: childKey), + autofocus: true, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + SwitchListTile( + value: true, + onChanged: null, + title: Text('A', key: childKey), + autofocus: true, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isFalse); + }); + + testWidgets('SwitchListTile controlAffinity test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SwitchListTile( + value: true, + onChanged: null, + secondary: Icon(Icons.info), + title: Text('Title'), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ), + ); + + final ListTile listTile = tester.widget(find.byType(ListTile)); + // When controlAffinity is ListTileControlAffinity.leading, the position of + // Switch is at leading edge and SwitchListTile.secondary at trailing edge. + + // Find the ExcludeFocus widget within the ListTile's leading + final ExcludeFocus excludeFocusWidget = tester.widget( + find.byWidgetPredicate( + (Widget widget) => listTile.leading == widget && widget is ExcludeFocus, + ), + ); + + // Assert that the ExcludeFocus widget is not null + expect(excludeFocusWidget, isNotNull); + + // Assert that the child of ExcludeFocus is Switch + expect(excludeFocusWidget.child.runtimeType, Switch); + + // Assert that the trailing is Icon + expect(listTile.trailing.runtimeType, Icon); + }); + + testWidgets('SwitchListTile controlAffinity default value test', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SwitchListTile( + value: true, + onChanged: null, + secondary: Icon(Icons.info), + title: Text('Title'), + ), + ), + ), + ); + + final ListTile listTile = tester.widget(find.byType(ListTile)); + // By default, value of controlAffinity is ListTileControlAffinity.platform, + // where the position of SwitchListTile.secondary is at leading edge and Switch + // at trailing edge. This also covers test for ListTileControlAffinity.trailing. + + // Find the ExcludeFocus widget within the ListTile's trailing + final ExcludeFocus excludeFocusWidget = tester.widget( + find.byWidgetPredicate( + (Widget widget) => listTile.trailing == widget && widget is ExcludeFocus, + ), + ); + + // Assert that the ExcludeFocus widget is not null + expect(excludeFocusWidget, isNotNull); + + // Assert that the child of ExcludeFocus is Switch + expect(excludeFocusWidget.child.runtimeType, Switch); + + // Assert that the leading is Icon + expect(listTile.leading.runtimeType, Icon); + }); + + testWidgets('SwitchListTile respects shape', (WidgetTester tester) async { + const ShapeBorder shapeBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal(right: Radius.circular(100)), + ); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: SwitchListTile( + value: true, + onChanged: null, + title: Text('Title'), + shape: shapeBorder, + ), + ), + ), + ); + + expect(tester.widget<InkWell>(find.byType(InkWell)).customBorder, shapeBorder); + }); + + testWidgets('SwitchListTile respects tileColor', (WidgetTester tester) async { + final Color tileColor = Colors.red.shade500; + + await tester.pumpWidget( + wrap( + child: Center( + child: SwitchListTile( + value: false, + onChanged: null, + title: const Text('Title'), + tileColor: tileColor, + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: tileColor)); + }); + + testWidgets('SwitchListTile respects selectedTileColor', (WidgetTester tester) async { + final Color selectedTileColor = Colors.green.shade500; + + await tester.pumpWidget( + wrap( + child: Center( + child: SwitchListTile( + value: false, + onChanged: null, + title: const Text('Title'), + selected: true, + selectedTileColor: selectedTileColor, + ), + ), + ), + ); + + expect(find.byType(Material), paints..rect(color: selectedTileColor)); + }); + + testWidgets('SwitchListTile selected item text Color', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/76909 + + const activeColor = Color(0xff00ff00); + + Widget buildFrame({Color? activeColor, Color? thumbColor}) { + return MaterialApp( + theme: ThemeData( + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) { + return states.contains(WidgetState.selected) ? thumbColor : null; + }), + ), + ), + home: Scaffold( + body: Center( + child: SwitchListTile( + activeColor: activeColor, + selected: true, + title: const Text('title'), + value: true, + onChanged: (bool? value) {}, + ), + ), + ), + ); + } + + Color? textColor(String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style?.color; + } + + await tester.pumpWidget(buildFrame(activeColor: activeColor)); + expect(textColor('title'), activeColor); + + await tester.pumpWidget(buildFrame(activeColor: activeColor)); + expect(textColor('title'), activeColor); + }); + + testWidgets('SwitchListTile respects visualDensity', (WidgetTester tester) async { + const key = Key('test'); + Future<void> buildTest(VisualDensity visualDensity) async { + return tester.pumpWidget( + wrap( + child: Center( + child: SwitchListTile( + key: key, + value: false, + onChanged: (bool? value) {}, + autofocus: true, + visualDensity: visualDensity, + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byKey(key)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(800, 56))); + }); + + testWidgets('SwitchListTile respects focusNode', (WidgetTester tester) async { + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + wrap( + child: Center( + child: SwitchListTile( + value: false, + title: Text('A', key: childKey), + onChanged: (bool? value) {}, + ), + ), + ), + ); + + await tester.pump(); + final FocusNode tileNode = Focus.of(childKey.currentContext!); + tileNode.requestFocus(); + await tester.pump(); // Let the focus take effect. + expect(Focus.of(childKey.currentContext!).hasPrimaryFocus, isTrue); + expect(tileNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('SwitchListTile onFocusChange callback', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'SwitchListTile onFocusChange'); + var gotFocus = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SwitchListTile( + value: true, + focusNode: node, + onFocusChange: (bool focused) { + gotFocus = focused; + }, + onChanged: (bool value) {}, + ), + ), + ), + ); + + node.requestFocus(); + await tester.pump(); + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + node.dispose(); + }); + + testWidgets('SwitchListTile.adaptive onFocusChange Callback', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'SwitchListTile.adaptive onFocusChange'); + var gotFocus = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SwitchListTile.adaptive( + value: true, + focusNode: node, + onFocusChange: (bool focused) { + gotFocus = focused; + }, + onChanged: (bool value) {}, + ), + ), + ), + ); + + node.requestFocus(); + await tester.pump(); + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + node.dispose(); + }); + + group('feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('SwitchListTile respects enableFeedback', (WidgetTester tester) async { + Future<void> buildTest(bool enableFeedback) async { + return tester.pumpWidget( + wrap( + child: Center( + child: SwitchListTile( + value: false, + onChanged: (bool? value) {}, + enableFeedback: enableFeedback, + ), + ), + ), + ); + } + + await buildTest(false); + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await buildTest(true); + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + }); + }); + + testWidgets('SwitchListTile respects hoverColor', (WidgetTester tester) async { + const key = Key('test'); + await tester.pumpWidget( + wrap( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SizedBox( + width: 500, + height: 100, + child: Material( + color: Colors.white, + child: SwitchListTile( + value: false, + key: key, + hoverColor: Colors.orange[500], + title: const Text('A'), + onChanged: (bool? value) {}, + ), + ), + ); + }, + ), + ), + ), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byKey(key))); + + await tester.pump(); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(key))), + paints + ..rect() + ..rect(color: Colors.orange[500], rect: const Rect.fromLTRB(0.0, 0.0, 500.0, 100.0)), + ); + }); + + testWidgets('Material2 - SwitchListTile respects thumbColor in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledThumbColor = Color(0xFF000001); + const activeDisabledThumbColor = Color(0xFF000002); + const inactiveEnabledThumbColor = Color(0xFF000003); + const inactiveDisabledThumbColor = Color(0xFF000004); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledThumbColor; + } + return inactiveDisabledThumbColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledThumbColor; + } + return inactiveEnabledThumbColor; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitchListTile({required bool enabled, required bool selected}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + value: selected, + thumbColor: thumbColor, + onChanged: enabled ? (_) {} : null, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: inactiveDisabledThumbColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: activeDisabledThumbColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: inactiveEnabledThumbColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: activeEnabledThumbColor), + ); + }); + + testWidgets('Material3 - SwitchListTile respects thumbColor in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledThumbColor = Color(0xFF000001); + const activeDisabledThumbColor = Color(0xFF000002); + const inactiveEnabledThumbColor = Color(0xFF000003); + const inactiveDisabledThumbColor = Color(0xFF000004); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledThumbColor; + } + return inactiveDisabledThumbColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledThumbColor; + } + return inactiveEnabledThumbColor; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitchListTile({required bool enabled, required bool selected}) { + return MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + value: selected, + thumbColor: thumbColor, + onChanged: enabled ? (_) {} : null, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(color: inactiveDisabledThumbColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(color: activeDisabledThumbColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(color: inactiveEnabledThumbColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(color: activeEnabledThumbColor), + ); + }); + + testWidgets('Material2 - SwitchListTile respects thumbColor in hovered/pressed states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredThumbColor = Color(0xFF4caf50); + const pressedThumbColor = Color(0xFFF44336); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedThumbColor; + } + if (states.contains(WidgetState.hovered)) { + return hoveredThumbColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitchListTile() { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile(value: false, thumbColor: thumbColor, onChanged: (_) {}); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: hoveredThumbColor), + ); + + // On pressed state + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: pressedThumbColor), + ); + }); + + testWidgets('Material3 - SwitchListTile respects thumbColor in hovered/pressed states', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredThumbColor = Color(0xFF4caf50); + const pressedThumbColor = Color(0xFFF44336); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedThumbColor; + } + if (states.contains(WidgetState.hovered)) { + return hoveredThumbColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitchListTile() { + return MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile(value: false, thumbColor: thumbColor, onChanged: (_) {}); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(color: hoveredThumbColor), + ); + + // On pressed state + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(color: pressedThumbColor), + ); + }); + + testWidgets('SwitchListTile respects trackColor in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledTrackColor = Color(0xFF000001); + const activeDisabledTrackColor = Color(0xFF000002); + const inactiveEnabledTrackColor = Color(0xFF000003); + const inactiveDisabledTrackColor = Color(0xFF000004); + + Color getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackColor; + } + return inactiveDisabledTrackColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackColor; + } + return inactiveEnabledTrackColor; + } + + final WidgetStateProperty<Color> trackColor = WidgetStateColor.resolveWith(getTrackColor); + + Widget buildSwitchListTile({required bool enabled, required bool selected}) { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + value: selected, + trackColor: trackColor, + onChanged: enabled ? (_) {} : null, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: inactiveDisabledTrackColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: activeDisabledTrackColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: inactiveEnabledTrackColor), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: activeEnabledTrackColor), + ); + }); + + testWidgets('SwitchListTile respects trackColor in hovered states', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackColor = Color(0xFF4caf50); + + Color getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> trackColor = WidgetStateColor.resolveWith(getTrackColor); + + Widget buildSwitchListTile() { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile(value: false, trackColor: trackColor, onChanged: (_) {}); + }, + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: hoveredTrackColor), + ); + }); + + testWidgets('SwitchListTile respects thumbIcon - M3', (WidgetTester tester) async { + const activeIcon = Icon(Icons.check); + const inactiveIcon = Icon(Icons.close); + + WidgetStateProperty<Icon?> thumbIcon(Icon? activeIcon, Icon? inactiveIcon) { + return WidgetStateProperty.resolveWith<Icon?>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeIcon; + } + return inactiveIcon; + }); + } + + Widget buildSwitchListTile({ + required bool enabled, + required bool active, + Icon? activeIcon, + Icon? inactiveIcon, + }) { + return MaterialApp( + home: wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + thumbIcon: thumbIcon(activeIcon, inactiveIcon), + value: active, + onChanged: enabled ? (_) {} : null, + ); + }, + ), + ), + ); + } + + // active icon shows when switch is on. + await tester.pumpWidget( + buildSwitchListTile(enabled: true, active: true, activeIcon: activeIcon), + ); + await tester.pumpAndSettle(); + final Switch switchWidget0 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget0.thumbIcon?.resolve(<WidgetState>{WidgetState.selected}), activeIcon); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..paragraph(offset: const Offset(32.0, 12.0)), + ); + + // inactive icon shows when switch is off. + await tester.pumpWidget( + buildSwitchListTile(enabled: true, active: false, inactiveIcon: inactiveIcon), + ); + await tester.pumpAndSettle(); + final Switch switchWidget1 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget1.thumbIcon?.resolve(<WidgetState>{}), inactiveIcon); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..paragraph(offset: const Offset(12.0, 12.0)), + ); + + // active icon doesn't show when switch is off. + await tester.pumpWidget( + buildSwitchListTile(enabled: true, active: false, activeIcon: activeIcon), + ); + await tester.pumpAndSettle(); + final Switch switchWidget2 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget2.thumbIcon?.resolve(<WidgetState>{WidgetState.selected}), activeIcon); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(), + ); + + // inactive icon doesn't show when switch is on. + await tester.pumpWidget( + buildSwitchListTile(enabled: true, active: true, inactiveIcon: inactiveIcon), + ); + await tester.pumpAndSettle(); + final Switch switchWidget3 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget3.thumbIcon?.resolve(<WidgetState>{}), inactiveIcon); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..restore(), + ); + + // without icon + await tester.pumpWidget(buildSwitchListTile(enabled: true, active: false)); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..restore(), + ); + }); + + testWidgets('Material2 - SwitchListTile respects materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(MaterialTapTargetSize materialTapTargetSize) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + materialTapTargetSize: materialTapTargetSize, + value: false, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.padded)); + final Switch switchWidget = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget.materialTapTargetSize, MaterialTapTargetSize.padded); + expect(tester.getSize(find.byType(Switch)), const Size(59.0, 48.0)); + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.shrinkWrap)); + final Switch switchWidget1 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget1.materialTapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + }); + + testWidgets('Material3 - SwitchListTile respects materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(MaterialTapTargetSize materialTapTargetSize) { + return MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + materialTapTargetSize: materialTapTargetSize, + value: false, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.padded)); + final Switch switchWidget = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget.materialTapTargetSize, MaterialTapTargetSize.padded); + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 48.0)); + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.shrinkWrap)); + final Switch switchWidget1 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget1.materialTapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 40.0)); + }); + + testWidgets('Material2 - SwitchListTile.adaptive respects applyCupertinoTheme', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(bool applyCupertinoTheme, TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(useMaterial3: false, platform: platform), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile.adaptive( + applyCupertinoTheme: applyCupertinoTheme, + value: true, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildSwitchListTile(true, platform)); + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsOneWidget); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: const Color(0xFF2196F3)), + ); + + await tester.pumpWidget(buildSwitchListTile(false, platform)); + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsOneWidget); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: const Color(0xFF34C759)), + ); + } + }); + + testWidgets('Material3 - SwitchListTile.adaptive respects applyCupertinoTheme', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(bool applyCupertinoTheme, TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile.adaptive( + applyCupertinoTheme: applyCupertinoTheme, + value: true, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildSwitchListTile(true, platform)); + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsOneWidget); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: const Color(0xFF6750A4)), + ); + + await tester.pumpWidget(buildSwitchListTile(false, platform)); + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsOneWidget); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect(color: const Color(0xFF34C759)), + ); + } + }); + + testWidgets('Material2 - SwitchListTile respects materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(MaterialTapTargetSize materialTapTargetSize) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + materialTapTargetSize: materialTapTargetSize, + value: false, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.padded)); + final Switch switchWidget = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget.materialTapTargetSize, MaterialTapTargetSize.padded); + expect(tester.getSize(find.byType(Switch)), const Size(59.0, 48.0)); + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.shrinkWrap)); + final Switch switchWidget1 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget1.materialTapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + }); + + testWidgets('Material3 - SwitchListTile respects materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(MaterialTapTargetSize materialTapTargetSize) { + return MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + materialTapTargetSize: materialTapTargetSize, + value: false, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.padded)); + final Switch switchWidget = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget.materialTapTargetSize, MaterialTapTargetSize.padded); + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 48.0)); + + await tester.pumpWidget(buildSwitchListTile(MaterialTapTargetSize.shrinkWrap)); + final Switch switchWidget1 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget1.materialTapTargetSize, MaterialTapTargetSize.shrinkWrap); + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 40.0)); + }); + + testWidgets('SwitchListTile passes the value of dragStartBehavior to Switch', ( + WidgetTester tester, + ) async { + Widget buildSwitchListTile(DragStartBehavior dragStartBehavior) { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + dragStartBehavior: dragStartBehavior, + value: false, + onChanged: (_) {}, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(DragStartBehavior.start)); + final Switch switchWidget = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget.dragStartBehavior, DragStartBehavior.start); + + await tester.pumpWidget(buildSwitchListTile(DragStartBehavior.down)); + final Switch switchWidget1 = tester.widget<Switch>(find.byType(Switch)); + expect(switchWidget1.dragStartBehavior, DragStartBehavior.down); + }); + + testWidgets('Switch on SwitchListTile changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + // Test SwitchListTile.adaptive() constructor + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile.adaptive( + mouseCursor: SystemMouseCursors.text, + value: false, + onChanged: (_) {}, + ); + }, + ), + ), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Switch))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test SwitchListTile() constructor + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + mouseCursor: SystemMouseCursors.forbidden, + value: false, + onChanged: (_) {}, + ); + }, + ), + ), + ); + + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.forbidden, + ); + + // Test default cursor + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile(value: false, onChanged: (_) {}); + }, + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return const SwitchListTile(value: false, onChanged: null); + }, + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('Switch with splash radius set', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 35; + await tester.pumpWidget( + wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile(splashRadius: splashRadius, value: false, onChanged: (_) {}); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpAndSettle(); + expect(Material.of(tester.element(find.byType(Switch))), paints..circle(radius: splashRadius)); + }); + + testWidgets( + 'The overlay color for the thumb of the switch resolves in active/pressed/hovered states', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const activeThumbColor = Color(0xFF000000); + const inactiveThumbColor = Color(0xFF000010); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const hoverColor = Color(0xFF000005); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + } + + Widget buildSwitch({bool active = false, bool focused = false, bool useOverlay = true}) { + return MaterialApp( + home: Scaffold( + body: SwitchListTile( + value: active, + onChanged: (_) {}, + thumbColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeThumbColor; + } + return inactiveThumbColor; + }), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + ), + ), + ); + } + + // test inactive Switch, and overlayColor is set to null. + await tester.pumpWidget(buildSwitch(useOverlay: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: inactiveThumbColor.withAlpha(kRadialReactionAlpha)), + reason: 'Default inactive pressed Switch should have overlay color from thumbColor', + ); + + // test active Switch, and overlayColor is set to null. + await tester.pumpWidget(buildSwitch(active: true, useOverlay: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: activeThumbColor.withAlpha(kRadialReactionAlpha)), + reason: 'Default active pressed Switch should have overlay color from thumbColor', + ); + + // test inactive Switch with an overlayColor + await tester.pumpWidget(buildSwitch()); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: inactivePressedOverlayColor), + reason: 'Inactive pressed Switch should have overlay color: $inactivePressedOverlayColor', + ); + + // test active Switch with an overlayColor + await tester.pumpWidget(buildSwitch(active: true)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: activePressedOverlayColor), + reason: 'Active pressed Switch should have overlay color: $activePressedOverlayColor', + ); + + await tester.pumpWidget(buildSwitch(focused: true)); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: hoverOverlayColor), + reason: 'Hovered Switch should use overlay color $hoverOverlayColor over $hoverColor', + ); + }, + ); + + testWidgets('SwitchListTile respects trackOutlineColor in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledTrackOutlineColor = Color(0xFF000001); + const activeDisabledTrackOutlineColor = Color(0xFF000002); + const inactiveEnabledTrackOutlineColor = Color(0xFF000003); + const inactiveDisabledTrackOutlineColor = Color(0xFF000004); + + Color getOutlineColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackOutlineColor; + } + return inactiveDisabledTrackOutlineColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackOutlineColor; + } + return inactiveEnabledTrackOutlineColor; + } + + final WidgetStateProperty<Color> trackOutlineColor = WidgetStateColor.resolveWith( + getOutlineColor, + ); + + Widget buildSwitchListTile({required bool enabled, required bool selected}) { + return wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + value: selected, + trackOutlineColor: trackOutlineColor, + onChanged: enabled ? (_) {} : null, + ); + }, + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: inactiveDisabledTrackOutlineColor, style: PaintingStyle.stroke), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: false, selected: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: activeDisabledTrackOutlineColor, style: PaintingStyle.stroke), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: inactiveEnabledTrackOutlineColor, style: PaintingStyle.stroke), + ); + + await tester.pumpWidget(buildSwitchListTile(enabled: true, selected: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: activeEnabledTrackOutlineColor, style: PaintingStyle.stroke), + ); + }); + + testWidgets('SwitchListTile respects trackOutlineColor in hovered state', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackColor = Color(0xFF4caf50); + + Color getTrackOutlineColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> outlineColor = WidgetStateColor.resolveWith( + getTrackOutlineColor, + ); + + Widget buildSwitchListTile() { + return MaterialApp( + theme: ThemeData(), + home: wrap( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SwitchListTile( + value: false, + trackOutlineColor: outlineColor, + onChanged: (_) {}, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSwitchListTile()); + await tester.pumpAndSettle(); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect(color: hoveredTrackColor, style: PaintingStyle.stroke), + ); + }); + + testWidgets('SwitchListTile.control widget should not request focus on traversal', ( + WidgetTester tester, + ) async { + final GlobalKey firstChildKey = GlobalKey(); + final GlobalKey secondChildKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + SwitchListTile( + value: true, + onChanged: (bool? value) {}, + title: Text('Hey', key: firstChildKey), + ), + SwitchListTile( + value: true, + onChanged: (bool? value) {}, + title: Text('There', key: secondChildKey), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + Focus.of(firstChildKey.currentContext!).requestFocus(); + await tester.pump(); + expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isTrue); + Focus.of(firstChildKey.currentContext!).nextFocus(); + await tester.pump(); + expect(Focus.of(firstChildKey.currentContext!).hasPrimaryFocus, isFalse); + expect(Focus.of(secondChildKey.currentContext!).hasPrimaryFocus, isTrue); + }); + + testWidgets('SwitchListTile uses ListTileTheme controlAffinity', (WidgetTester tester) async { + Widget buildView(ListTileControlAffinity controlAffinity) { + return MaterialApp( + home: Material( + child: ListTileTheme( + data: ListTileThemeData(controlAffinity: controlAffinity), + child: SwitchListTile( + value: true, + title: const Text('SwitchListTile'), + onChanged: (bool value) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildView(ListTileControlAffinity.leading)); + final Finder leading = find.text('SwitchListTile'); + final Offset offsetLeading = tester.getTopLeft(leading); + expect(offsetLeading, const Offset(92.0, 16.0)); + + await tester.pumpWidget(buildView(ListTileControlAffinity.trailing)); + final Finder trailing = find.text('SwitchListTile'); + final Offset offsetTrailing = tester.getTopLeft(trailing); + expect(offsetTrailing, const Offset(16.0, 16.0)); + + await tester.pumpWidget(buildView(ListTileControlAffinity.platform)); + final Finder platform = find.text('SwitchListTile'); + final Offset offsetPlatform = tester.getTopLeft(platform); + expect(offsetPlatform, const Offset(16.0, 16.0)); + }); + + testWidgets('SwitchListTile isThreeLine', (WidgetTester tester) async { + const double height = 300; + const switchTop = 130.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: themeDataIsThreeLine != null + ? ThemeData(listTileTheme: ListTileThemeData(isThreeLine: themeDataIsThreeLine)) + : null, + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + SwitchListTile( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + value: false, + onChanged: null, + ), + SwitchListTile( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A'), + value: false, + onChanged: null, + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(SwitchListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Switch).at(0)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, switchTop, 60.0, 40.0), + ); + expect( + tester.getRect(find.byType(SwitchListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(Switch).at(1)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, height + 16, 60.0, 40.0), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(SwitchListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Switch).at(0)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, 8.0, 60.0, 40.0), + ); + expect( + tester.getRect(find.byType(SwitchListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(Switch).at(1)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, height + 8.0, 60.0, 40.0), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('SwitchListTile.adaptive isThreeLine', (WidgetTester tester) async { + const double height = 300; + const switchTop = 130.0; + + Widget buildFrame({bool? themeDataIsThreeLine, bool? themeIsThreeLine, bool? isThreeLine}) { + return MaterialApp( + key: UniqueKey(), + theme: ThemeData( + platform: TargetPlatform.iOS, + listTileTheme: themeDataIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeDataIsThreeLine) + : null, + ), + home: Material( + child: ListTileTheme( + data: themeIsThreeLine != null + ? ListTileThemeData(isThreeLine: themeIsThreeLine) + : null, + child: ListView( + children: <Widget>[ + SwitchListTile.adaptive( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), + value: false, + onChanged: null, + ), + SwitchListTile.adaptive( + isThreeLine: isThreeLine, + title: const Text('A'), + subtitle: const Text('A'), + value: false, + onChanged: null, + ), + ], + ), + ), + ), + ); + } + + void expectTwoLine() { + expect( + tester.getRect(find.byType(SwitchListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Switch).at(0)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, switchTop, 60.0, 40.0), + ); + expect( + tester.getRect(find.byType(SwitchListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 72.0), + ); + expect( + tester.getRect(find.byType(Switch).at(1)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, height + 16, 60.0, 40.0), + ); + } + + void expectThreeLine() { + expect( + tester.getRect(find.byType(SwitchListTile).at(0)), + const Rect.fromLTWH(0.0, 0.0, 800.0, height), + ); + expect( + tester.getRect(find.byType(Switch).at(0)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, 8.0, 60.0, 40.0), + ); + expect( + tester.getRect(find.byType(SwitchListTile).at(1)), + const Rect.fromLTWH(0.0, height, 800.0, 88.0), + ); + expect( + tester.getRect(find.byType(Switch).at(1)), + const Rect.fromLTWH(800.0 - 60.0 - 24.0, height + 8.0, 60.0, 40.0), + ); + } + + await tester.pumpWidget(buildFrame()); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: true, isThreeLine: false)); + expectTwoLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: true, themeIsThreeLine: true, isThreeLine: false), + ); + expectTwoLine(); + + await tester.pumpWidget(buildFrame(themeIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget(buildFrame(themeDataIsThreeLine: false, isThreeLine: true)); + expectThreeLine(); + + await tester.pumpWidget( + buildFrame(themeDataIsThreeLine: false, themeIsThreeLine: false, isThreeLine: true), + ); + expectThreeLine(); + }); + + testWidgets('Material3 - SwitchListTile activeThumbColor', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: SwitchListTile( + value: true, + selected: true, + onChanged: (_) {}, + activeColor: Colors.red[500], + activeThumbColor: Colors.yellow[500], + activeTrackColor: Colors.green[500], + title: const Text('title'), + ), + ); + }, + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Colors.green[500]) + ..rrect() + ..rrect(color: Colors.yellow[500]), + ); + final RenderParagraph title = tester.renderObject( + find.descendant(of: find.byType(ListTile), matching: find.text('title')), + ); + expect(title.text.style!.color, Colors.yellow[500]); + }); + + testWidgets('Material3 - SwitchListTile.adaptive activeThumbColor', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: SwitchListTile.adaptive( + value: true, + selected: true, + onChanged: (_) {}, + activeColor: Colors.red[500], + activeThumbColor: Colors.yellow[500], + activeTrackColor: Colors.green[500], + title: const Text('title'), + ), + ); + }, + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Colors.green[500]) + ..rrect() + ..rrect(color: Colors.yellow[500]), + ); + final RenderParagraph title = tester.renderObject( + find.descendant(of: find.byType(ListTile), matching: find.text('title')), + ); + expect(title.text.style!.color, Colors.yellow[500]); + }); + + testWidgets('SwitchListTile does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: SwitchListTile(value: true, onChanged: (_) {})), + ), + ), + ), + ); + expect(tester.getSize(find.byType(SwitchListTile)), Size.zero); + }); + + testWidgets('SwitchListTile horizontalTitleGap = 0.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeHorizontalTitleGap, + double? widgetHorizontalTitleGap, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(horizontalTitleGap: themeHorizontalTitleGap), + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + controlAffinity: ListTileControlAffinity.leading, + horizontalTitleGap: widgetHorizontalTitleGap, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 76.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 76.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 76.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 724.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 0)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 724.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeHorizontalTitleGap: 10, widgetHorizontalTitleGap: 0), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 724.0); + }); + + testWidgets( + 'SwitchListTile horizontalTitleGap = (default) && ListTile minLeadingWidth = (default)', + (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + controlAffinity: ListTileControlAffinity.leading, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // horizontalTitleGap: ListTileDefaultValue.horizontalTitleGap (16.0) + expect(right('title'), 708.0); + }, + ); + + testWidgets('SwitchListTile horizontalTitleGap with visualDensity', (WidgetTester tester) async { + Widget buildFrame({double? horizontalTitleGap, VisualDensity? visualDensity}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + controlAffinity: ListTileControlAffinity.leading, + visualDensity: visualDensity, + horizontalTitleGap: horizontalTitleGap, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 78.0); + + // Pump another frame of the same widget to ensure the underlying render + // object did not cache the original horizontalTitleGap calculation based on the + // visualDensity + await tester.pumpWidget( + buildFrame( + horizontalTitleGap: 10.0, + visualDensity: const VisualDensity(horizontal: VisualDensity.minimumDensity), + ), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 78.0); + }); + + testWidgets('SwitchListTile minVerticalPadding = 80.0 Material 3', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + minVerticalPadding: widgetMinVerticalPadding, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 24(Title) = 184 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 24(Title) = 184 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 184.0)); + }); + + testWidgets('SwitchListTile minVerticalPadding = 80.0 Material 2', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinVerticalPadding, + double? widgetMinVerticalPadding, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minVerticalPadding: themeMinVerticalPadding), + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + minVerticalPadding: widgetMinVerticalPadding, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinVerticalPadding: 80)); + // 80 + 80 + 16(Title) = 176 + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinVerticalPadding: 80)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinVerticalPadding: 0, widgetMinVerticalPadding: 80), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 176.0)); + }); + + testWidgets('SwitchListTile minLeadingWidth = 60.0', (WidgetTester tester) async { + Widget buildFrame( + TextDirection textDirection, { + double? themeMinLeadingWidth, + double? widgetMinLeadingWidth, + }) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: ListTileTheme( + data: ListTileThemeData(minLeadingWidth: themeMinLeadingWidth), + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile( + controlAffinity: ListTileControlAffinity.leading, + minLeadingWidth: widgetMinLeadingWidth, + value: true, + title: const Text('title'), + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + } + + double left(String text) => tester.getTopLeft(find.text(text)).dx; + double right(String text) => tester.getTopRight(find.text(text)).dx; + + await tester.pumpWidget(buildFrame(TextDirection.ltr, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 92.0 = 16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0 + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget( + buildFrame(TextDirection.ltr, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(left('title'), 92.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, widgetMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + // 708.0 = 800.0 - (16.0(Default contentPadding) + 16.0(Default horizontalTitleGap) + 60.0) + expect(right('title'), 708.0); + + await tester.pumpWidget(buildFrame(TextDirection.rtl, themeMinLeadingWidth: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + + await tester.pumpWidget( + buildFrame(TextDirection.rtl, themeMinLeadingWidth: 0, widgetMinLeadingWidth: 60), + ); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + expect(right('title'), 708.0); + }); + + testWidgets('SwitchListTile minTileHeight', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection, {double? minTileHeight}) { + return MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: textDirection, + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: SwitchListTile(value: true, minTileHeight: minTileHeight, onChanged: (_) {}), + ), + ), + ), + ); + } + + // Default list tile with height = 56.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 56.0)); + + // Set list tile height = 30.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 30)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 30.0)); + + // Set list tile height = 60.0 + await tester.pumpWidget(buildFrame(TextDirection.ltr, minTileHeight: 60)); + expect(tester.getSize(find.byType(ListTile)), const Size(800.0, 60.0)); + }); +} diff --git a/packages/material_ui/test/material/switch_test.dart b/packages/material_ui/test/material/switch_test.dart new file mode 100644 index 000000000000..4f1a922f8ce7 --- /dev/null +++ b/packages/material_ui/test/material/switch_test.dart @@ -0,0 +1,4529 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + final theme = ThemeData(); + + testWidgets('Switch can toggle on tap', (WidgetTester tester) async { + final Key switchKey = UniqueKey(); + var value = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + key: switchKey, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ); + }, + ), + ), + ); + + expect(value, isFalse); + await tester.tap(find.byKey(switchKey)); + expect(value, isTrue); + }); + + testWidgets('Switch size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + Theme( + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.padded), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (bool newValue) {}, + ), + ), + ), + ), + ), + ); + + // switch width = trackWidth - 2 * trackRadius + _kSwitchMinSize + // M2 width = 33 - 2 * 7 + 40 + // M3 width = 52 - 2 * 16 + 40 + expect( + tester.getSize(find.byType(Switch)), + material3 ? const Size(60.0, 48.0) : const Size(59.0, 48.0), + ); + + await tester.pumpWidget( + Theme( + data: theme.copyWith(materialTapTargetSize: MaterialTapTargetSize.shrinkWrap), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (bool newValue) {}, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(Switch)), + material3 ? const Size(60.0, 40.0) : const Size(59.0, 40.0), + ); + }); + + testWidgets('Material2 - Switch does not get distorted upon changing constraints with parent', ( + WidgetTester tester, + ) async { + const double maxWidth = 300; + const double maxHeight = 100; + + const boundaryKey = ValueKey<String>('switch container'); + + Widget buildSwitch({required double width, required double height}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: maxWidth, + height: maxHeight, + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: width, + height: height, + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (_) {}, + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(width: maxWidth, height: maxHeight)); + await expectLater(find.byKey(boundaryKey), matchesGoldenFile('m2_switch_test.big.on.png')); + + await tester.pumpWidget(buildSwitch(width: 20, height: 10)); + await expectLater(find.byKey(boundaryKey), matchesGoldenFile('m2_switch_test.small.on.png')); + }); + + testWidgets('Material3 - Switch does not get distorted upon changing constraints with parent', ( + WidgetTester tester, + ) async { + const double maxWidth = 300; + const double maxHeight = 100; + + const boundaryKey = ValueKey<String>('switch container'); + + Widget buildSwitch({required double width, required double height}) { + return MaterialApp( + home: Scaffold( + body: Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: maxWidth, + height: maxHeight, + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: width, + height: height, + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (_) {}, + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(width: maxWidth, height: maxHeight)); + await expectLater(find.byKey(boundaryKey), matchesGoldenFile('m3_switch_test.big.on.png')); + + await tester.pumpWidget(buildSwitch(width: 20, height: 10)); + await expectLater(find.byKey(boundaryKey), matchesGoldenFile('m3_switch_test.small.on.png')); + }); + + testWidgets('Switch can drag (LTR)', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, isFalse); + + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + + expect(value, isFalse); + + await tester.drag(find.byType(Switch), const Offset(30.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(Switch), const Offset(30.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + + expect(value, isFalse); + }); + + testWidgets('Switch can drag with dragStartBehavior', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, isFalse); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + expect(value, isFalse); + + await tester.drag(find.byType(Switch), const Offset(30.0, 0.0)); + expect(value, isTrue); + await tester.pump(); + await tester.drag(find.byType(Switch), const Offset(30.0, 0.0)); + expect(value, isTrue); + await tester.pump(); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + expect(value, isFalse); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + final Rect switchRect = tester.getRect(find.byType(Switch)); + + TestGesture gesture = await tester.startGesture(switchRect.center); + // We have to execute the drag in two frames because the first update will + // just set the start position. + await gesture.moveBy(const Offset(20.0, 0.0)); + await gesture.moveBy(const Offset(20.0, 0.0)); + expect(value, isFalse); + await gesture.up(); + expect(value, isTrue); + await tester.pump(); + + gesture = await tester.startGesture(switchRect.center); + await gesture.moveBy(const Offset(20.0, 0.0)); + await gesture.moveBy(const Offset(20.0, 0.0)); + expect(value, isTrue); + await gesture.up(); + expect(value, isTrue); + await tester.pump(); + + gesture = await tester.startGesture(switchRect.center); + await gesture.moveBy(const Offset(-20.0, 0.0)); + await gesture.moveBy(const Offset(-20.0, 0.0)); + expect(value, isTrue); + await gesture.up(); + expect(value, isFalse); + }); + + testWidgets('Switch can drag (RTL)', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + await tester.drag(find.byType(Switch), const Offset(30.0, 0.0)); + + expect(value, isFalse); + + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + + expect(value, isTrue); + + await tester.pump(); + await tester.drag(find.byType(Switch), const Offset(30.0, 0.0)); + + expect(value, isFalse); + }); + + testWidgets('Material2 - Switch has default colors when enabled', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x52000000), // Black with 32% opacity + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.grey.shade50), + reason: 'Inactive enabled switch should match these colors', + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x802196f3), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Material3 - Switch has default colors when enabled', (WidgetTester tester) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.outline, + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..rrect(color: colors.outline), // thumb color + reason: 'Inactive enabled switch should match these colors', + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: colors.onPrimary), // thumb color + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Switch.adaptive(Cupertino) has default colors when enabled', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch.adaptive( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.outline, + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..rrect(color: colors.outline), // thumb color + reason: 'Inactive enabled switch should match these colors', + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: colors.onPrimary), // thumb color + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Material2 - Switch has default colors when disabled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Directionality( + textDirection: TextDirection.rtl, + child: Material(child: Center(child: Switch(value: false, onChanged: null))), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.black12, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.grey.shade400), + reason: 'Inactive disabled switch should match these colors', + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Directionality( + textDirection: TextDirection.rtl, + child: Material(child: Center(child: Switch(value: true, onChanged: null))), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.black12, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.grey.shade400), + reason: 'Active disabled switch should match these colors', + ); + }); + + testWidgets('Material3 - Inactive Switch has default colors when disabled', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Directionality( + textDirection: TextDirection.rtl, + child: Material(child: Center(child: Switch(value: false, onChanged: null))), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..rrect( + color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface), + ), // thumb color + reason: 'Inactive disabled switch should match these colors', + ); + }); + + testWidgets('Material3 - Active Switch has default colors when disabled', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: const Directionality( + textDirection: TextDirection.rtl, + child: Material(child: Center(child: Switch(value: true, onChanged: null))), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..save() + ..rrect( + style: PaintingStyle.fill, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: colors.surface), // thumb color + reason: 'Active disabled switch should match these colors', + ); + }); + + testWidgets('Material2 - Switch default overlayColor resolves hovered/focused state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + Finder findSwitch() { + return find.byWidgetPredicate((Widget widget) => widget is Switch); + } + + MaterialInkController? getSwitchMaterial(WidgetTester tester) { + return Material.of(tester.element(findSwitch())); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Switch(focusNode: focusNode, value: true, onChanged: (_) {}), + ), + ), + ); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(getSwitchMaterial(tester), paints..circle(color: theme.focusColor)); + + // On both hovered and focused, the overlay color should show hovered overlay color. + final Offset center = tester.getCenter(find.byType(Switch)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(getSwitchMaterial(tester), paints..circle(color: theme.hoverColor)); + + focusNode.dispose(); + }); + + testWidgets('Material3 - Switch default overlayColor resolves hovered/focused state', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + Finder findSwitch() { + return find.byWidgetPredicate((Widget widget) => widget is Switch); + } + + MaterialInkController? getSwitchMaterial(WidgetTester tester) { + return Material.of(tester.element(findSwitch())); + } + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Switch(focusNode: focusNode, value: true, onChanged: (_) {}), + ), + ), + ); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect( + getSwitchMaterial(tester), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + + // On both hovered and focused, the overlay color should show hovered overlay color. + final Offset center = tester.getCenter(find.byType(Switch)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect( + getSwitchMaterial(tester), + paints..circle(color: theme.colorScheme.primary.withOpacity(0.08)), + ); + + focusNode.dispose(); + }); + + testWidgets('Material2 - Switch can be set color', (WidgetTester tester) async { + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + activeColor: Colors.red[500], + activeTrackColor: Colors.green[500], + inactiveThumbColor: Colors.yellow[500], + inactiveTrackColor: Colors.blue[500], + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.blue[500], + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.yellow[500]), + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.green[500], + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: Colors.red[500]), + ); + }); + + testWidgets('Material3 - Switch can be set color', (WidgetTester tester) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + activeColor: Colors.red[500], + activeTrackColor: Colors.green[500], + inactiveThumbColor: Colors.yellow[500], + inactiveTrackColor: Colors.blue[500], + ), + ), + ); + }, + ), + ), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: Colors.blue[500], + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect(style: PaintingStyle.stroke, color: colors.outline) + ..rrect(color: Colors.yellow[500]), // thumb color + ); + await tester.drag(find.byType(Switch), const Offset(-30.0, 0.0)); + await tester.pump(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: Colors.green[500], + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: Colors.red[500]), // thumb color + ); + }); + + testWidgets('Drag ends after animation completes', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/17773 + + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.moveBy(Offset(switchRect.width, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(value, isTrue); + expect(tester.hasRunningAnimations, false); + }); + + testWidgets('can veto switch dragging result', (WidgetTester tester) async { + var value = false; + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: value, + onChanged: (bool newValue) { + setState(() { + value = value || newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + // Move a little to the right, not past the middle. + TestGesture gesture = await tester.startGesture(tester.getRect(find.byType(Switch)).center); + await gesture.moveBy(const Offset(kTouchSlop + 0.1, 0.0)); + await tester.pump(); + await gesture.moveBy(const Offset(-kTouchSlop + 5.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isFalse); + final ToggleableStateMixin state = tester.state<ToggleableStateMixin>( + find.descendant( + of: find.byType(Switch), + matching: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_MaterialSwitch', + ), + ), + ); + expect(state.position.value, lessThan(0.5)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isFalse); + expect(state.position.value, 0); + + // Move past the middle. + gesture = await tester.startGesture(tester.getRect(find.byType(Switch)).center); + await gesture.moveBy(const Offset(kTouchSlop + 0.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(state.position.value, greaterThan(0.5)); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(state.position.value, 1.0); + + // Now move back to the left, the revert animation should play. + gesture = await tester.startGesture(tester.getRect(find.byType(Switch)).center); + await gesture.moveBy(const Offset(-kTouchSlop - 0.1, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(value, isTrue); + expect(state.position.value, lessThan(0.5)); + + await tester.pump(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(state.position.value, 1.0); + }); + + testWidgets('switch has semantic events', (WidgetTester tester) async { + dynamic semanticEvent; + var value = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + await tester.tap(find.byType(Switch)); + final RenderObject object = tester.firstRenderObject(find.byType(Switch)); + + expect(value, true); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('switch sends semantic events from parent if fully merged', ( + WidgetTester tester, + ) async { + dynamic semanticEvent; + var value = false; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvent = message; + }, + ); + final semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + void onChanged(bool newValue) { + setState(() { + value = newValue; + }); + } + + return Material( + child: MergeSemantics( + child: ListTile( + leading: const Text('test'), + onTap: () { + onChanged(!value); + }, + trailing: Switch(value: value, onChanged: onChanged), + ), + ), + ); + }, + ), + ), + ); + await tester.tap(find.byType(MergeSemantics)); + final RenderObject object = tester.firstRenderObject(find.byType(MergeSemantics)); + + expect(value, true); + expect(semanticEvent, <String, dynamic>{ + 'type': 'tap', + 'nodeId': object.debugSemantics!.id, + 'data': <String, dynamic>{}, + }); + expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true); + + semanticsTester.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('Switch.adaptive', (WidgetTester tester) async { + var value = false; + const activeTrackColor = Color(0xffff1200); + const inactiveTrackColor = Color(0xffff12ff); + const thumbColor = Color(0xffffff00); + const focusColor = Color(0xff00ff00); + + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch.adaptive( + value: value, + activeColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + thumbColor: const MaterialStatePropertyAll<Color?>(thumbColor), + focusColor: focusColor, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + value = false; + await tester.pumpWidget(buildFrame(platform)); + expect(find.byType(Switch), findsOneWidget, reason: 'on ${platform.name}'); + expect(find.byType(CupertinoSwitch), findsNothing); + + final Switch adaptiveSwitch = tester.widget(find.byType(Switch)); + expect(adaptiveSwitch.activeColor, activeTrackColor, reason: 'on ${platform.name}'); + expect(adaptiveSwitch.inactiveTrackColor, inactiveTrackColor, reason: 'on ${platform.name}'); + expect( + adaptiveSwitch.thumbColor?.resolve(<WidgetState>{}), + thumbColor, + reason: 'on ${platform.name}', + ); + expect(adaptiveSwitch.focusColor, focusColor, reason: 'on ${platform.name}'); + + expect(value, isFalse, reason: 'on ${platform.name}'); + await tester.tap(find.byType(Switch)); + expect(value, isTrue, reason: 'on ${platform.name}'); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + value = false; + await tester.pumpWidget(buildFrame(platform)); + await tester.pumpAndSettle(); // Finish the theme change animation. + expect(find.byType(CupertinoSwitch), findsNothing); + expect(value, isFalse, reason: 'on ${platform.name}'); + await tester.tap(find.byType(Switch)); + expect(value, isTrue, reason: 'on ${platform.name}'); + } + }); + + testWidgets('Switch.adaptive default mouse cursor(Cupertino)', (WidgetTester tester) async { + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(buildAdaptiveSwitch(platform: platform, value: false)); + final Size switchSize = tester.getSize(find.byType(Switch)); + expect(switchSize, const Size(60.0, 48.0)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Switch))); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + await tester.pumpWidget(buildAdaptiveSwitch(platform: platform)); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test disabled switch. + await tester.pumpWidget( + buildAdaptiveSwitch(platform: platform, enabled: false, value: false), + ); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.removePointer(location: tester.getCenter(find.byType(Switch))); + await tester.pump(); + } + }); + + testWidgets('Switch.adaptive default thumb/track color and size(Cupertino)', ( + WidgetTester tester, + ) async { + const Color thumbColor = Colors.white; + const inactiveTrackColor = Color.fromARGB(40, 120, 120, 128); // Default inactive track color. + const activeTrackColor = Color.fromARGB(255, 52, 199, 89); // Default active track color. + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + // Switches have same sizes on both platform but they are more compact on macOS. + final trackRRect = platform == TargetPlatform.iOS + ? RRect.fromLTRBR(4.5, 8.5, 55.5, 39.5, const Radius.circular(15.5)) + : RRect.fromLTRBR(4.5, 4.5, 55.5, 35.5, const Radius.circular(15.5)); + final inactiveThumbRRect = platform == TargetPlatform.iOS + ? RRect.fromLTRBR(6.0, 10.0, 34.0, 38.0, const Radius.circular(14.0)) + : RRect.fromLTRBR(6.0, 6.0, 34.0, 34.0, const Radius.circular(14.0)); + final activeThumbRRect = platform == TargetPlatform.iOS + ? RRect.fromLTRBR(26.0, 10.0, 54.0, 38.0, const Radius.circular(14.0)) + : RRect.fromLTRBR(26.0, 6.0, 54.0, 34.0, const Radius.circular(14.0)); + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAdaptiveSwitch(platform: platform, value: false)); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: inactiveTrackColor, + rrect: trackRRect, + ) // Default cupertino inactive track color + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: thumbColor, rrect: inactiveThumbRRect), + reason: 'Inactive enabled switch should have default track and thumb color', + ); + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity)).opacity, 1.0); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAdaptiveSwitch(platform: platform)); + await tester.pump(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: activeTrackColor, + rrect: trackRRect, + ) // Default cupertino active track color + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: thumbColor, rrect: activeThumbRRect), + reason: 'Active enabled switch should have default track and thumb color', + ); + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity)).opacity, 1.0); + + // Test disabled switch. + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch(platform: platform, enabled: false, value: false), + ); + await tester.pump(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: inactiveTrackColor, + rrect: trackRRect, + ) // Default cupertino inactive track color + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: thumbColor, rrect: inactiveThumbRRect), + reason: 'Inactive disabled switch should have default track and thumb color', + ); + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity)).opacity, 0.5); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAdaptiveSwitch(platform: platform, enabled: false)); + await tester.pump(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: activeTrackColor, + rrect: trackRRect, + ) // Default cupertino active track color + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: thumbColor, rrect: activeThumbRRect), + reason: 'Active disabled switch should have default track and thumb color', + ); + expect(find.byType(Opacity), findsOneWidget); + expect(tester.widget<Opacity>(find.byType(Opacity)).opacity, 0.5); + } + }); + + testWidgets('Default Switch.adaptive are not affected by ' + 'ThemeData.switchThemeData on iOS/macOS', (WidgetTester tester) async { + const Color defaultThumbColor = Colors.white; + const defaultInactiveTrackColor = Color.fromARGB(40, 120, 120, 128); + const defaultActiveTrackColor = Color.fromARGB(255, 52, 199, 89); + const Color updatedThumbColor = Colors.red; + const Color updatedTrackColor = Colors.green; + const overallSwitchTheme = SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(updatedThumbColor), + trackColor: MaterialStatePropertyAll<Color>(updatedTrackColor), + ); + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch(platform: platform, overallSwitchThemeData: overallSwitchTheme), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: defaultActiveTrackColor) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: defaultThumbColor), + reason: 'Active enabled switch should still have default track and thumb color', + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: platform, + value: false, + overallSwitchThemeData: overallSwitchTheme, + ), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: defaultInactiveTrackColor) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: defaultThumbColor), + reason: 'Inactive enabled switch should have default track and thumb color', + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: platform, + enabled: false, + value: false, + overallSwitchThemeData: overallSwitchTheme, + ), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: defaultInactiveTrackColor) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: defaultThumbColor), + reason: 'Inactive disabled switch should have default track and thumb color', + ); + } + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: TargetPlatform.android, + overallSwitchThemeData: overallSwitchTheme, + ), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Color(updatedTrackColor.value)) + ..rrect() + ..rrect(color: Color(updatedThumbColor.value)), + reason: 'Switch.adaptive is affected by SwitchTheme on other platforms', + ); + }); + + testWidgets('Default Switch.adaptive are not affected by ' + 'SwitchThemeData on iOS/macOS', (WidgetTester tester) async { + const Color defaultThumbColor = Colors.white; + const defaultInactiveTrackColor = Color.fromARGB(40, 120, 120, 128); + const defaultActiveTrackColor = Color.fromARGB(255, 52, 199, 89); + const Color updatedThumbColor = Colors.red; + const Color updatedTrackColor = Colors.green; + const switchTheme = SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(updatedThumbColor), + trackColor: MaterialStatePropertyAll<Color>(updatedTrackColor), + ); + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch(platform: platform, switchThemeData: switchTheme), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: defaultActiveTrackColor) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: defaultThumbColor), + reason: 'Active enabled switch should still have default track and thumb color', + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch(platform: platform, value: false, switchThemeData: switchTheme), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: defaultInactiveTrackColor) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: defaultThumbColor), + reason: 'Inactive enabled switch should have default track and thumb color', + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: platform, + enabled: false, + value: false, + switchThemeData: switchTheme, + ), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: defaultInactiveTrackColor) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) // Thumb border color(only cupertino) + ..rrect(color: defaultThumbColor), + reason: 'Inactive disabled switch should have default track and thumb color', + ); + } + + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch(platform: TargetPlatform.android, switchThemeData: switchTheme), + ); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Color(updatedTrackColor.value)) + ..rrect() + ..rrect(color: Color(updatedThumbColor.value)), + reason: 'Switch.adaptive is affected by SwitchTheme on other platforms', + ); + }); + + testWidgets('Override default adaptive SwitchThemeData on iOS/macOS', ( + WidgetTester tester, + ) async { + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: platform, + switchThemeData: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(Colors.yellow), + trackColor: MaterialStatePropertyAll<Color>(Colors.brown), + ), + switchThemeAdaptation: const _SwitchThemeAdaptation(), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Color(Colors.deepPurple.value)) + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: Color(Colors.lightGreen.value)), + ); + } + + // Other platforms should not be affected by the adaptive switch theme. + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: platform, + switchThemeData: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(Colors.yellow), + trackColor: MaterialStatePropertyAll<Color>(Colors.brown), + ), + switchThemeAdaptation: const _SwitchThemeAdaptation(), + ), + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: Color(Colors.brown.value)) + ..rrect() + ..rrect(color: Color(Colors.yellow.value)), + ); + } + }); + + testWidgets('Switch.adaptive default focus color(Cupertino)', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final node = FocusNode(); + addTearDown(node.dispose); + await tester.pumpWidget( + buildAdaptiveSwitch(platform: TargetPlatform.macOS, autofocus: true, focusNode: node), + ); + await tester.pumpAndSettle(); + expect(node.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: const Color(0xff34c759)) // Track color + ..rrect() + ..rrect( + color: const Color(0xcc6ef28f), + strokeWidth: 3.5, + style: PaintingStyle.stroke, + ) // Focused outline + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: const Color(0xffffffff)), // Thumb color + ); + + await tester.pumpWidget( + buildAdaptiveSwitch( + platform: TargetPlatform.macOS, + autofocus: true, + focusNode: node, + focusColor: Colors.red, + ), + ); + await tester.pumpAndSettle(); + expect(node.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: const Color(0xff34c759)) // Track color + ..rrect() + ..rrect( + color: Color(Colors.red.value), + strokeWidth: 3.5, + style: PaintingStyle.stroke, + ) // Focused outline + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: const Color(0xffffffff)), // Thumb color + ); + }); + + testWidgets('Material2 - Switch is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x802196f3), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..circle(color: Colors.orange[500]) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), + ); + + // Check the false value. + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x52000000), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..circle(color: Colors.orange[500]) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xfffafafa)), + ); + + // Check what happens when disabled. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x1f000000), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xffbdbdbd)), + ); + + focusNode.dispose(); + }); + + testWidgets('Material3 - Switch is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + focusNode: focusNode, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + // active, enabled switch + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.orange[500]), + ); + + // Check the false value: inactive enabled switch + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.outline, + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.orange[500]), + ); + + // Check what happens when disabled: inactive disabled switch. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..rrect(color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface)), + ); + + focusNode.dispose(); + }); + + testWidgets('Switch with splash radius set', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const double splashRadius = 30; + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Switch( + value: true, + onChanged: (bool newValue) {}, + focusColor: Colors.orange[500], + autofocus: true, + splashRadius: splashRadius, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..circle(color: Colors.orange[500], radius: splashRadius), + ); + }); + + testWidgets('Material2 - Switch can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x802196f3), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x802196f3), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..circle(color: Colors.orange[500]) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xff2196f3)), + ); + + // Check what happens when disabled. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x1f000000), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: const Color(0xffbdbdbd)), + ); + }); + + testWidgets('Material3 - Switch can be hovered and has correct hover color', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + hoverColor: Colors.orange[500], + ); + }, + ), + ), + ), + ); + } + + // active enabled switch + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: colors.onPrimary), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: Colors.orange[500]), + ); + + // Check what happens for disabled active switch + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: colors.surface.withOpacity(1.0)), + ); + }); + + testWidgets('Switch can be toggled by keyboard shortcuts', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: Colors.orange[500], + autofocus: true, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + // On web, switches don't respond to the enter key. + expect(value, kIsWeb ? isTrue : isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(value, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(value, isTrue); + }); + + testWidgets('Switch changes mouse cursor when hovered', (WidgetTester tester) async { + // Test Switch.adaptive() constructor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Switch.adaptive( + mouseCursor: SystemMouseCursors.text, + value: true, + onChanged: (_) {}, + ), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Switch))); + addTearDown(gesture.removePointer); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test Switch() constructor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Switch(mouseCursor: SystemMouseCursors.text, value: true, onChanged: (_) {}), + ), + ), + ), + ), + ), + ); + + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Switch(value: true, onChanged: (_) {}), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Switch(value: true, onChanged: null), + ), + ), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('Material switch should not recreate its render object when disabled', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/61247. + var value = true; + var enabled = true; + late StateSetter stateSetter; + await tester.pumpWidget( + Theme( + data: theme, + child: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + stateSetter = setState; + return Material( + child: Center( + child: Switch( + value: value, + onChanged: !enabled + ? null + : (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + + final ToggleableStateMixin oldSwitchState = tester.state( + find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_MaterialSwitch'), + ); + + stateSetter(() { + value = false; + }); + await tester.pump(); + // Disable the switch when the implicit animation begins. + stateSetter(() { + enabled = false; + }); + await tester.pump(); + + final ToggleableStateMixin updatedSwitchState = tester.state( + find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_MaterialSwitch'), + ); + + expect(updatedSwitchState.isInteractive, false); + expect(updatedSwitchState, oldSwitchState); + expect(updatedSwitchState.position.isCompleted, false); + expect(updatedSwitchState.position.isDismissed, false); + }); + + testWidgets('Material2 - Switch thumb color resolves in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledThumbColor = Color(0xFF000001); + const activeDisabledThumbColor = Color(0xFF000002); + const inactiveEnabledThumbColor = Color(0xFF000003); + const inactiveDisabledThumbColor = Color(0xFF000004); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledThumbColor; + } + return inactiveDisabledThumbColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledThumbColor; + } + return inactiveEnabledThumbColor; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + thumbColor: thumbColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.black12, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: inactiveDisabledThumbColor), + reason: 'Inactive disabled switch should default track and custom thumb color', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.black12, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: activeDisabledThumbColor), + reason: 'Active disabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x52000000), // Black with 32% opacity, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: inactiveEnabledThumbColor), + reason: 'Inactive enabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.black12, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: inactiveDisabledThumbColor), + reason: 'Inactive disabled switch should match these colors', + ); + }); + + testWidgets('Material3 - Switch thumb color resolves in active/enabled states', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + const activeEnabledThumbColor = Color(0xFF000001); + const activeDisabledThumbColor = Color(0xFF000002); + const inactiveEnabledThumbColor = Color(0xFF000003); + const inactiveDisabledThumbColor = Color(0xFF000004); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledThumbColor; + } + return inactiveDisabledThumbColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledThumbColor; + } + return inactiveEnabledThumbColor; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + thumbColor: thumbColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect( + style: PaintingStyle.stroke, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(5.0, 9.0, 55.0, 39.0, const Radius.circular(16.0)), + ) + ..rrect(color: inactiveDisabledThumbColor), + reason: 'Inactive disabled switch should default track and custom thumb color', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: activeDisabledThumbColor), + reason: 'Active disabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.surfaceContainerHighest, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: inactiveEnabledThumbColor), + reason: 'Inactive enabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: activeEnabledThumbColor), + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Material2 - Switch thumb color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredThumbColor = Color(0xFF000001); + const focusedThumbColor = Color(0xFF000002); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredThumbColor; + } + if (states.contains(WidgetState.focused)) { + return focusedThumbColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitch() { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + thumbColor: thumbColor, + onChanged: (_) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x802196f3), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..circle() // Radial reaction + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: focusedThumbColor), + reason: 'Inactive disabled switch should default track and custom thumb color', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: const Color(0x802196f3), + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..circle() + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: hoveredThumbColor), + reason: 'Inactive disabled switch should default track and custom thumb color', + ); + + focusNode.dispose(); + }); + + testWidgets('Material3 - Switch thumb color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredThumbColor = Color(0xFF000001); + const focusedThumbColor = Color(0xFF000002); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredThumbColor; + } + if (states.contains(WidgetState.focused)) { + return focusedThumbColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitch() { + return MaterialApp( + theme: themeData, + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + thumbColor: thumbColor, + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.primary.withOpacity(0.1)) + ..rrect(color: focusedThumbColor), + reason: 'active enabled switch should default track and custom thumb color', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: colors.primary, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..circle(color: colors.primary.withOpacity(0.08)) + ..rrect(color: hoveredThumbColor), + reason: 'active enabled switch should default track and custom thumb color', + ); + + focusNode.dispose(); + }); + + testWidgets('Material2 - Track color resolves in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledTrackColor = Color(0xFF000001); + const activeDisabledTrackColor = Color(0xFF000002); + const inactiveEnabledTrackColor = Color(0xFF000003); + const inactiveDisabledTrackColor = Color(0xFF000004); + + Color getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackColor; + } + return inactiveDisabledTrackColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackColor; + } + return inactiveEnabledTrackColor; + } + + final WidgetStateProperty<Color> trackColor = WidgetStateColor.resolveWith(getTrackColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Switch( + trackColor: trackColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: inactiveDisabledTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ), + reason: 'Inactive disabled switch track should use this value', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: activeDisabledTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ), + reason: 'Active disabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: inactiveEnabledTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ), + reason: 'Inactive enabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: inactiveDisabledTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ), + reason: 'Inactive disabled switch should match these colors', + ); + }); + + testWidgets('Material3 - Track color resolves in active/enabled states', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + const activeEnabledTrackColor = Color(0xFF000001); + const activeDisabledTrackColor = Color(0xFF000002); + const inactiveEnabledTrackColor = Color(0xFF000003); + const inactiveDisabledTrackColor = Color(0xFF000004); + + Color getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackColor; + } + return inactiveDisabledTrackColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackColor; + } + return inactiveEnabledTrackColor; + } + + final WidgetStateProperty<Color> trackColor = WidgetStateColor.resolveWith(getTrackColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + trackColor: trackColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: inactiveDisabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Inactive disabled switch track should use this value', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: activeDisabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active disabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: inactiveEnabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Inactive enabled switch should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: activeEnabledTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active enabled switch should match these colors', + ); + }); + + testWidgets('Material2 - Switch track color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackColor = Color(0xFF000001); + const focusedTrackColor = Color(0xFF000002); + + Color getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackColor; + } + if (states.contains(WidgetState.focused)) { + return focusedTrackColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> trackColor = WidgetStateColor.resolveWith(getTrackColor); + + Widget buildSwitch() { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + trackColor: trackColor, + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: focusedTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ), + reason: 'Inactive enabled switch should match these colors', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: hoveredTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ), + reason: 'Inactive enabled switch should match these colors', + ); + + focusNode.dispose(); + }); + + testWidgets('Material3 - Switch track color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final themeData = ThemeData(); + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackColor = Color(0xFF000001); + const focusedTrackColor = Color(0xFF000002); + + Color getTrackColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackColor; + } + if (states.contains(WidgetState.focused)) { + return focusedTrackColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> trackColor = WidgetStateColor.resolveWith(getTrackColor); + + Widget buildSwitch() { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + trackColor: trackColor, + onChanged: (_) {}, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: focusedTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active enabled switch should match these colors', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints..rrect( + color: hoveredTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ), + reason: 'Active enabled switch should match these colors', + ); + + focusNode.dispose(); + }); + + testWidgets('Material2 - Switch thumb color is blended against surface color', ( + WidgetTester tester, + ) async { + final Color activeDisabledThumbColor = Colors.blue.withOpacity(.60); + final theme = ThemeData.light(useMaterial3: false); + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return activeDisabledThumbColor; + } + return Colors.black; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Directionality( + textDirection: TextDirection.rtl, + child: Theme( + data: theme, + child: Material( + child: Center( + child: Switch( + thumbColor: thumbColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + + final Color expectedThumbColor = Color.alphaBlend( + activeDisabledThumbColor, + theme.colorScheme.surface, + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: Colors.black12, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: expectedThumbColor), + reason: 'Active disabled thumb color should be blended on top of surface color', + ); + }); + + testWidgets('Material3 - Switch thumb color is blended against surface color', ( + WidgetTester tester, + ) async { + final Color activeDisabledThumbColor = Colors.blue.withOpacity(.60); + final theme = ThemeData(); + final ColorScheme colors = theme.colorScheme; + + Color getThumbColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return activeDisabledThumbColor; + } + return Colors.black; + } + + final WidgetStateProperty<Color> thumbColor = WidgetStateColor.resolveWith(getThumbColor); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Directionality( + textDirection: TextDirection.rtl, + child: Theme( + data: theme, + child: Material( + child: Center( + child: Switch( + thumbColor: thumbColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + + final Color expectedThumbColor = Color.alphaBlend( + activeDisabledThumbColor, + theme.colorScheme.surface, + ); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.onSurface.withOpacity(0.12), + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: expectedThumbColor), + reason: 'Active disabled thumb color should be blended on top of surface color', + ); + }); + + testWidgets('Switch overlay color resolves in active/pressed/focused/hovered states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const activeThumbColor = Color(0xFF000000); + const inactiveThumbColor = Color(0xFF000010); + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + const hoverOverlayColor = Color(0xFF000003); + const focusOverlayColor = Color(0xFF000004); + const hoverColor = Color(0xFF000005); + const focusColor = Color(0xFF000006); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + + Widget buildSwitch({bool active = false, bool focused = false, bool useOverlay = true}) { + return MaterialApp( + theme: theme, + home: Scaffold( + body: Switch( + focusNode: focusNode, + autofocus: focused, + value: active, + onChanged: (_) {}, + thumbColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeThumbColor; + } + return inactiveThumbColor; + }), + overlayColor: useOverlay ? WidgetStateProperty.resolveWith(getOverlayColor) : null, + hoverColor: hoverColor, + focusColor: focusColor, + splashRadius: splashRadius, + ), + ), + ); + } + + // test inactive Switch, and overlayColor is set to null. + await tester.pumpWidget(buildSwitch(useOverlay: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: inactiveThumbColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default inactive pressed Switch should have overlay color from thumbColor', + ); + + // test active Switch, and overlayColor is set to null. + await tester.pumpWidget(buildSwitch(active: true, useOverlay: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: activeThumbColor.withAlpha(kRadialReactionAlpha), radius: splashRadius), + reason: 'Default active pressed Switch should have overlay color from thumbColor', + ); + + // test inactive Switch with an overlayColor + await tester.pumpWidget(buildSwitch()); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Switch should have overlay color: $inactivePressedOverlayColor', + ); + + // test active Switch with an overlayColor + await tester.pumpWidget(buildSwitch(active: true)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Switch should have overlay color: $activePressedOverlayColor', + ); + + await tester.pumpWidget(buildSwitch(focused: true)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: focusOverlayColor, radius: splashRadius), + reason: 'Focused Switch should use overlay color $focusOverlayColor over $focusColor', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..circle(color: hoverOverlayColor, radius: splashRadius), + reason: 'Hovered Switch should use overlay color $hoverOverlayColor over $hoverColor', + ); + + focusNode.dispose(); + }); + + testWidgets('Do not crash when widget disappears while pointer is down', ( + WidgetTester tester, + ) async { + Widget buildSwitch(bool show) { + return MaterialApp( + theme: theme, + home: Material( + child: Center(child: show ? Switch(value: true, onChanged: (_) {}) : Container()), + ), + ); + } + + await tester.pumpWidget(buildSwitch(true)); + final Offset center = tester.getCenter(find.byType(Switch)); + // Put a pointer down on the screen. + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); + // While the pointer is down, the widget disappears. + await tester.pumpWidget(buildSwitch(false)); + expect(find.byType(Switch), findsNothing); + // Release pointer after widget disappeared. + await gesture.up(); + }); + + testWidgets('disabled switch shows tooltip', (WidgetTester tester) async { + const longPressTooltip = 'long press tooltip'; + const tapTooltip = 'tap tooltip'; + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Tooltip(message: longPressTooltip, child: Switch(onChanged: null, value: true)), + ), + ), + ); + + // Default tooltip shows up after long pressed. + final Finder tooltip0 = find.byType(Tooltip); + expect(find.text(longPressTooltip), findsNothing); + + await tester.tap(tooltip0); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(longPressTooltip), findsNothing); + + final TestGesture gestureLongPress = await tester.startGesture(tester.getCenter(tooltip0)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gestureLongPress.up(); + await tester.pump(); + + expect(find.text(longPressTooltip), findsOneWidget); + + // Tooltip shows up after tapping when set triggerMode to TooltipTriggerMode.tap. + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: tapTooltip, + child: Switch(onChanged: null, value: true), + ), + ), + ), + ); + + await tester.pump(const Duration(days: 1)); + await tester.pumpAndSettle(); + expect(find.text(tapTooltip), findsNothing); + expect(find.text(longPressTooltip), findsNothing); + + final Finder tooltip1 = find.byType(Tooltip); + await tester.tap(tooltip1); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text(tapTooltip), findsOneWidget); + }); + + group('with image', () { + late ui.Image image; + + setUp(() async { + image = await createTestImage(width: 100, height: 100); + }); + + testWidgets('thumb image shows up', (WidgetTester tester) async { + imageCache.clear(); + final provider1 = _TestImageProvider(); + final provider2 = _TestImageProvider(); + + expect(provider1.loadCallCount, 0); + expect(provider2.loadCallCount, 0); + + var value1 = true; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Switch( + activeThumbImage: provider1, + inactiveThumbImage: provider2, + value: value1, + onChanged: (bool val) { + setState(() { + value1 = val; + }); + }, + ), + ); + }, + ), + ), + ); + + expect(provider1.loadCallCount, 1); + expect(provider2.loadCallCount, 0); + expect(imageCache.liveImageCount, 1); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(provider1.loadCallCount, 1); + expect(provider2.loadCallCount, 1); + expect(imageCache.liveImageCount, 2); + }); + + testWidgets('do not crash when imageProvider completes after Switch is disposed', ( + WidgetTester tester, + ) async { + final imageProvider = DelayedImageProvider(image); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Switch(value: true, onChanged: null, inactiveThumbImage: imageProvider), + ), + ), + ), + ); + + expect(find.byType(Switch), findsOneWidget); + + // Dispose the switch by taking down the tree. + await tester.pumpWidget(Container()); + expect(find.byType(Switch), findsNothing); + + imageProvider.complete(); + expect(tester.takeException(), isNull); + }); + + testWidgets('do not crash when previous imageProvider completes after Switch is disposed', ( + WidgetTester tester, + ) async { + final imageProvider1 = DelayedImageProvider(image); + final imageProvider2 = DelayedImageProvider(image); + + Future<void> buildSwitch(ImageProvider imageProvider) { + return tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Switch(value: true, onChanged: null, inactiveThumbImage: imageProvider), + ), + ), + ), + ); + } + + await buildSwitch(imageProvider1); + expect(find.byType(Switch), findsOneWidget); + // Replace the ImageProvider. + await buildSwitch(imageProvider2); + expect(find.byType(Switch), findsOneWidget); + + // Dispose the switch by taking down the tree. + await tester.pumpWidget(Container()); + expect(find.byType(Switch), findsNothing); + + // Completing the replaced ImageProvider shouldn't crash. + imageProvider1.complete(); + expect(tester.takeException(), isNull); + + imageProvider2.complete(); + expect(tester.takeException(), isNull); + }); + }); + + group('Switch M3 only tests', () { + testWidgets('M3 Switch has a 300-millisecond animation in total', (WidgetTester tester) async { + final theme = ThemeData(); + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // M2 animation duration + expect(tester.hasRunningAnimations, true); + await tester.pump(const Duration(milliseconds: 101)); + expect(tester.hasRunningAnimations, false); + }); + + testWidgets('M3 Switch has a stadium shape in the middle of the track', ( + WidgetTester tester, + ) async { + final theme = ThemeData(colorSchemeSeed: Colors.deepPurple); + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // After 33 milliseconds, the switch thumb moves to the middle + // and has a stadium shape with a size of (34x22). + await tester.pump(const Duration(milliseconds: 33)); + expect(tester.hasRunningAnimations, true); + + await expectLater(find.byType(Switch), matchesGoldenFile('switch_test.m3.transition.png')); + }); + + testWidgets('M3 Switch thumb bounces in the end of the animation', (WidgetTester tester) async { + final theme = ThemeData(); + var value = false; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + value: value, + onChanged: (bool newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ); + }, + ), + ), + ), + ); + expect(value, isFalse); + + final Rect switchRect = tester.getRect(find.byType(Switch)); + final TestGesture gesture = await tester.startGesture(switchRect.centerLeft); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // The value on y axis is greater than 1 when t > 0.375 + // 300 * 0.375 = 112.5 + await tester.pump(const Duration(milliseconds: 113)); + final ToggleableStateMixin state = tester.state<ToggleableStateMixin>( + find.descendant( + of: find.byType(Switch), + matching: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_MaterialSwitch', + ), + ), + ); + expect(tester.hasRunningAnimations, true); + expect(state.position.value, greaterThan(1)); + }); + + testWidgets('Switch thumb shows correct pressed color - M3', (WidgetTester tester) async { + final themeData = ThemeData(); + final ColorScheme colors = themeData.colorScheme; + Widget buildApp({bool enabled = true, bool value = true}) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Switch( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.primary, // track color + style: PaintingStyle.fill, + ) + ..rrect( + color: Colors.transparent, // track outline color + style: PaintingStyle.stroke, + ) + ..rrect( + color: colors.primaryContainer, + rrect: RRect.fromLTRBR(26.0, 10.0, 54.0, 38.0, const Radius.circular(14.0)), + ), + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp(value: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.surfaceContainerHighest, // track color + style: PaintingStyle.fill, + ) + ..rrect( + color: colors.outline, // track outline color + style: PaintingStyle.stroke, + ) + ..rrect(color: colors.onSurfaceVariant), + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp(enabled: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.onSurface.withOpacity(0.12), // track color + style: PaintingStyle.fill, + ) + ..rrect( + color: Colors.transparent, // track outline color + style: PaintingStyle.stroke, + ) + ..rrect(color: colors.surface.withOpacity(1.0)), + ); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildApp(enabled: false, value: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: colors.surfaceContainerHighest.withOpacity(0.12), // track color + style: PaintingStyle.fill, + ) + ..rrect( + color: colors.onSurface.withOpacity(0.12), // track outline color + style: PaintingStyle.stroke, + ) + ..rrect(color: Color.alphaBlend(colors.onSurface.withOpacity(0.38), colors.surface)), + ); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets('Track outline color resolves in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledTrackOutlineColor = Color(0xFF000001); + const activeDisabledTrackOutlineColor = Color(0xFF000002); + const inactiveEnabledTrackOutlineColor = Color(0xFF000003); + const inactiveDisabledTrackOutlineColor = Color(0xFF000004); + + Color getTrackOutlineColor(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackOutlineColor; + } + return inactiveDisabledTrackOutlineColor; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackOutlineColor; + } + return inactiveEnabledTrackOutlineColor; + } + + final WidgetStateProperty<Color> trackOutlineColor = WidgetStateColor.resolveWith( + getTrackOutlineColor, + ); + + Widget buildSwitch({required bool enabled, required bool active}) { + return Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + trackOutlineColor: trackOutlineColor, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: inactiveDisabledTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Inactive disabled switch track outline should use this value', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: activeDisabledTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Active disabled switch track outline should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: inactiveEnabledTrackOutlineColor), + reason: 'Inactive enabled switch track outline should match these colors', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: activeEnabledTrackOutlineColor), + reason: 'Active enabled switch track outline should match these colors', + ); + }); + + testWidgets('Switch track outline color resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackOutlineColor = Color(0xFF000001); + const focusedTrackOutlineColor = Color(0xFF000002); + + Color getTrackOutlineColor(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackOutlineColor; + } + if (states.contains(WidgetState.focused)) { + return focusedTrackOutlineColor; + } + return Colors.transparent; + } + + final WidgetStateProperty<Color> trackOutlineColor = WidgetStateColor.resolveWith( + getTrackOutlineColor, + ); + + Widget buildSwitch() { + return Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + trackOutlineColor: trackOutlineColor, + onChanged: (_) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: focusedTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline should match this color', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(color: hoveredTrackOutlineColor, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline should match this color', + ); + + focusNode.dispose(); + }); + + testWidgets('Track outline width resolves in active/enabled states', ( + WidgetTester tester, + ) async { + const activeEnabledTrackOutlineWidth = 1.0; + const activeDisabledTrackOutlineWidth = 2.0; + const inactiveEnabledTrackOutlineWidth = 3.0; + const inactiveDisabledTrackOutlineWidth = 4.0; + + double getTrackOutlineWidth(Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + if (states.contains(WidgetState.selected)) { + return activeDisabledTrackOutlineWidth; + } + return inactiveDisabledTrackOutlineWidth; + } + if (states.contains(WidgetState.selected)) { + return activeEnabledTrackOutlineWidth; + } + return inactiveEnabledTrackOutlineWidth; + } + + final WidgetStateProperty<double> trackOutlineWidth = WidgetStateProperty.resolveWith( + getTrackOutlineWidth, + ); + + Widget buildSwitch({required bool enabled, required bool active}) { + return MaterialApp( + home: Material( + child: Center( + child: Switch( + trackOutlineWidth: trackOutlineWidth, + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(enabled: false, active: false)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: inactiveDisabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Inactive disabled switch track outline width should be 4.0', + ); + + await tester.pumpWidget(buildSwitch(enabled: false, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: activeDisabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active disabled switch track outline width should be 2.0', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: inactiveEnabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Inactive enabled switch track outline width should be 3.0', + ); + + await tester.pumpWidget(buildSwitch(enabled: true, active: true)); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: activeEnabledTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline width should be 1.0', + ); + }); + + testWidgets('Switch track outline width resolves in hovered/focused states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const hoveredTrackOutlineWidth = 4.0; + const focusedTrackOutlineWidth = 6.0; + + double getTrackOutlineWidth(Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoveredTrackOutlineWidth; + } + if (states.contains(WidgetState.focused)) { + return focusedTrackOutlineWidth; + } + return 8.0; + } + + final WidgetStateProperty<double> trackOutlineWidth = WidgetStateProperty.resolveWith( + getTrackOutlineWidth, + ); + + Widget buildSwitch() { + return MaterialApp( + home: Material( + child: Center( + child: Switch( + focusNode: focusNode, + autofocus: true, + value: true, + trackOutlineWidth: trackOutlineWidth, + onChanged: (_) {}, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: focusedTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline width should be 6.0', + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill) + ..rrect(strokeWidth: hoveredTrackOutlineWidth, style: PaintingStyle.stroke), + reason: 'Active enabled switch track outline width should be 4.0', + ); + + focusNode.dispose(); + }); + + testWidgets('Switch can set icon - M3', (WidgetTester tester) async { + final themeData = ThemeData( + colorSchemeSeed: const Color(0xff6750a4), + brightness: Brightness.light, + ); + + WidgetStateProperty<Icon?> thumbIcon(Icon? activeIcon, Icon? inactiveIcon) { + return WidgetStateProperty.resolveWith<Icon?>((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return activeIcon; + } + return inactiveIcon; + }); + } + + Widget buildSwitch({ + required bool enabled, + required bool active, + Icon? activeIcon, + Icon? inactiveIcon, + }) { + return Theme( + data: themeData, + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Switch( + thumbIcon: thumbIcon(activeIcon, inactiveIcon), + value: active, + onChanged: enabled ? (_) {} : null, + ), + ), + ), + ), + ); + } + + // active icon shows when switch is on. + await tester.pumpWidget( + buildSwitch(enabled: true, active: true, activeIcon: const Icon(Icons.close)), + ); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..paragraph(offset: const Offset(32.0, 16.0)), + ); + + // inactive icon shows when switch is off. + await tester.pumpWidget( + buildSwitch(enabled: true, active: false, inactiveIcon: const Icon(Icons.close)), + ); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..paragraph(offset: const Offset(12.0, 16.0)), + ); + + // active icon doesn't show when switch is off. + await tester.pumpWidget( + buildSwitch(enabled: true, active: false, activeIcon: const Icon(Icons.check)), + ); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect(), + ); + + // inactive icon doesn't show when switch is on. + await tester.pumpWidget( + buildSwitch(enabled: true, active: true, inactiveIcon: const Icon(Icons.check)), + ); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..restore(), + ); + + // without icon + await tester.pumpWidget(buildSwitch(enabled: true, active: false)); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect() + ..rrect() + ..rrect() + ..restore(), + ); + }); + }); + + testWidgets('Switch.adaptive(Cupertino) is focusable and has correct focus color', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(debugLabel: 'Switch.adaptive'); + addTearDown(focusNode.dispose); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var value = true; + const focusColor = Color(0xffff0000); + + Widget buildApp({bool enabled = true}) { + return MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Switch.adaptive( + value: value, + onChanged: enabled + ? (bool newValue) { + setState(() { + value = newValue; + }); + } + : null, + focusColor: focusColor, + focusNode: focusNode, + autofocus: true, + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(Switch), + paints + ..rrect(color: const Color(0xff34c759)) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: focusColor) + ..clipRRect() + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: const Color(0xffffffff)), + ); + + // Check the false value. + value = false; + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + find.byType(Switch), + paints + ..rrect(color: const Color(0x28787880)) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: focusColor) + ..clipRRect() + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: const Color(0xffffffff)), + ); + + // Check what happens when disabled. + value = false; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isFalse); + expect( + find.byType(Switch), + paints + ..rrect(color: const Color(0x28787880)) + ..clipRRect() + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: const Color(0xffffffff)), + ); + }); + + testWidgets('Switch.onFocusChange callback', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Switch'); + var focused = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Switch( + value: true, + focusNode: focusNode, + onFocusChange: (bool value) { + focused = value; + }, + onChanged: (bool newValue) {}, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focused, isTrue); + expect(focusNode.hasFocus, isTrue); + + focusNode.unfocus(); + await tester.pump(); + expect(focused, isFalse); + expect(focusNode.hasFocus, isFalse); + + focusNode.dispose(); + }); + + testWidgets('Switch.padding is respected', (WidgetTester tester) async { + Widget buildSwitch({EdgeInsets? padding}) { + return MaterialApp( + home: Material( + child: Center( + child: Switch(padding: padding, value: true, onChanged: (_) {}), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 48.0)); + + await tester.pumpWidget(buildSwitch(padding: EdgeInsets.zero)); + + expect(tester.getSize(find.byType(Switch)), const Size(52.0, 48.0)); + + await tester.pumpWidget(buildSwitch(padding: const EdgeInsets.all(4.0))); + + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 56.0)); + }); + + testWidgets('Material2 - Switch activeThumbColor', (WidgetTester tester) async { + const activeColor = Color(0xffff0000); + const activeThumbColor = Color(0xff00ff00); + const activeTrackColor = Color(0xff0000ff); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (_) {}, + activeColor: activeColor, + activeThumbColor: activeThumbColor, + activeTrackColor: activeTrackColor, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(tester.widget<Switch>(find.byType(Switch)).activeThumbColor, activeThumbColor); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + color: activeTrackColor, + rrect: RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)), + ) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: activeThumbColor), + ); + }); + + testWidgets('Material3 - Switch activeThumbColor', (WidgetTester tester) async { + const activeColor = Color(0xffff0000); + const activeThumbColor = Color(0xff00ff00); + const activeTrackColor = Color(0xff0000ff); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (_) {}, + activeColor: activeColor, + activeThumbColor: activeThumbColor, + activeTrackColor: activeTrackColor, + ), + ), + ); + }, + ), + ), + ), + ); + + expect(tester.widget<Switch>(find.byType(Switch)).activeThumbColor, activeThumbColor); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect( + style: PaintingStyle.fill, + color: activeTrackColor, + rrect: RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)), + ) + ..rrect() + ..rrect(color: activeThumbColor), + ); + }); + + testWidgets('Material2 - Switch.adaptive activeThumbColor', (WidgetTester tester) async { + const activeColor = Color(0xffff0000); + const activeThumbColor = Color(0xff00ff00); + const activeTrackColor = Color(0xff0000ff); + + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + key: UniqueKey(), + theme: ThemeData(platform: platform, useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch.adaptive( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (_) {}, + activeColor: activeColor, + activeThumbColor: activeThumbColor, + activeTrackColor: activeTrackColor, + ), + ), + ); + }, + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + final trackRRect = platform == TargetPlatform.iOS + ? RRect.fromLTRBR(4.0, 8.5, 55.0, 39.5, const Radius.circular(15.5)) + : RRect.fromLTRBR(4.0, 4.5, 55.0, 35.5, const Radius.circular(15.5)); + await tester.pumpWidget(buildFrame(platform)); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: activeTrackColor, rrect: trackRRect) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: activeThumbColor), + ); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + final trackRRect = platform == TargetPlatform.fuchsia || platform == TargetPlatform.android + ? RRect.fromLTRBR(13.0, 17.0, 46.0, 31.0, const Radius.circular(7.0)) + : RRect.fromLTRBR(13.0, 13.0, 46.0, 27.0, const Radius.circular(7.0)); + await tester.pumpWidget(buildFrame(platform)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: activeTrackColor, rrect: trackRRect) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x33000000)) + ..rrect(color: const Color(0x24000000)) + ..rrect(color: const Color(0x1f000000)) + ..rrect(color: activeThumbColor), + ); + } + }); + + testWidgets('Material3 - Switch.adaptive activeThumbColor', (WidgetTester tester) async { + const activeColor = Color(0xffff0000); + const activeThumbColor = Color(0xff00ff00); + const activeTrackColor = Color(0xff0000ff); + + Widget buildFrame(TargetPlatform platform) { + return MaterialApp( + key: UniqueKey(), + theme: ThemeData(platform: platform), + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: Switch.adaptive( + dragStartBehavior: DragStartBehavior.down, + value: true, + onChanged: (_) {}, + activeColor: activeColor, + activeThumbColor: activeThumbColor, + activeTrackColor: activeTrackColor, + ), + ), + ); + }, + ), + ), + ); + } + + for (final platform in <TargetPlatform>[TargetPlatform.iOS, TargetPlatform.macOS]) { + final trackRRect = platform == TargetPlatform.iOS + ? RRect.fromLTRBR(4.5, 8.5, 55.5, 39.5, const Radius.circular(15.5)) + : RRect.fromLTRBR(4.5, 4.5, 55.5, 35.5, const Radius.circular(15.5)); + await tester.pumpWidget(buildFrame(platform)); + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(color: activeTrackColor, rrect: trackRRect) + ..rrect(color: const Color(0x00000000)) + ..rrect(color: const Color(0x26000000)) + ..rrect(color: const Color(0x0f000000)) + ..rrect(color: const Color(0x0a000000)) + ..rrect(color: activeThumbColor), + ); + } + + for (final platform in <TargetPlatform>[ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + ]) { + final trackRRect = platform == TargetPlatform.fuchsia || platform == TargetPlatform.android + ? RRect.fromLTRBR(4.0, 8.0, 56.0, 40.0, const Radius.circular(16.0)) + : RRect.fromLTRBR(4.0, 4.0, 56.0, 36.0, const Radius.circular(16.0)); + + await tester.pumpWidget(buildFrame(platform)); + + expect( + Material.of(tester.element(find.byType(Switch))), + paints + ..rrect(style: PaintingStyle.fill, color: activeTrackColor, rrect: trackRRect) + ..rrect() + ..rrect(color: activeThumbColor), + ); + } + }); +} + +class DelayedImageProvider extends ImageProvider<DelayedImageProvider> { + DelayedImageProvider(this.image); + + final ui.Image image; + + final Completer<ImageInfo> _completer = Completer<ImageInfo>(); + + @override + Future<DelayedImageProvider> obtainKey(ImageConfiguration configuration) { + return SynchronousFuture<DelayedImageProvider>(this); + } + + @override + ImageStreamCompleter loadImage(DelayedImageProvider key, ImageDecoderCallback decode) { + return OneFrameImageStreamCompleter(_completer.future); + } + + void complete() { + _completer.complete(ImageInfo(image: image)); + } + + @override + String toString() => '${describeIdentity(this)}()'; +} + +class _TestImageProvider extends ImageProvider<Object> { + _TestImageProvider({ImageStreamCompleter? streamCompleter}) { + _streamCompleter = streamCompleter ?? OneFrameImageStreamCompleter(_completer.future); + } + + final Completer<ImageInfo> _completer = Completer<ImageInfo>(); + late ImageStreamCompleter _streamCompleter; + + bool get loadCalled => _loadCallCount > 0; + int get loadCallCount => _loadCallCount; + int _loadCallCount = 0; + + @override + Future<Object> obtainKey(ImageConfiguration configuration) { + return SynchronousFuture<_TestImageProvider>(this); + } + + @override + void resolveStreamForKey( + ImageConfiguration configuration, + ImageStream stream, + Object key, + ImageErrorListener handleError, + ) { + super.resolveStreamForKey(configuration, stream, key, handleError); + } + + @override + ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) { + _loadCallCount += 1; + return _streamCompleter; + } + + void complete(ui.Image image) { + _completer.complete(ImageInfo(image: image)); + } + + void fail(Object exception, StackTrace? stackTrace) { + _completer.completeError(exception, stackTrace); + } + + @override + String toString() => '${describeIdentity(this)}()'; +} + +Widget buildAdaptiveSwitch({ + required TargetPlatform platform, + bool enabled = true, + bool value = true, + bool autofocus = false, + FocusNode? focusNode, + Color? focusColor, + SwitchThemeData? overallSwitchThemeData, + SwitchThemeData? switchThemeData, + Adaptation<SwitchThemeData>? switchThemeAdaptation, +}) { + final Widget adaptiveSwitch = Switch.adaptive( + focusNode: focusNode, + autofocus: autofocus, + focusColor: focusColor, + value: value, + onChanged: enabled ? (_) {} : null, + ); + + return MaterialApp( + theme: ThemeData( + platform: platform, + switchTheme: overallSwitchThemeData, + adaptations: switchThemeAdaptation == null + ? null + : <Adaptation<Object>>[switchThemeAdaptation], + ), + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Material( + child: Center( + child: switchThemeData == null + ? adaptiveSwitch + : SwitchTheme(data: switchThemeData, child: adaptiveSwitch), + ), + ); + }, + ), + ); +} + +class _SwitchThemeAdaptation extends Adaptation<SwitchThemeData> { + const _SwitchThemeAdaptation(); + + @override + SwitchThemeData adapt(ThemeData theme, SwitchThemeData defaultValue) { + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + case TargetPlatform.linux: + return defaultValue; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(Colors.lightGreen), + trackColor: MaterialStatePropertyAll<Color>(Colors.deepPurple), + ); + } + } +} diff --git a/packages/material_ui/test/material/switch_theme_test.dart b/packages/material_ui/test/material/switch_theme_test.dart new file mode 100644 index 000000000000..76590144b098 --- /dev/null +++ b/packages/material_ui/test/material/switch_theme_test.dart @@ -0,0 +1,1092 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SwitchThemeData copyWith, ==, hashCode basics', () { + expect(const SwitchThemeData(), const SwitchThemeData().copyWith()); + expect(const SwitchThemeData().hashCode, const SwitchThemeData().copyWith().hashCode); + }); + + test('SwitchThemeData lerp special cases', () { + const data = SwitchThemeData(); + expect(identical(SwitchThemeData.lerp(data, data, 0.5), data), true); + }); + + test('SwitchThemeData defaults', () { + const themeData = SwitchThemeData(); + expect(themeData.thumbColor, null); + expect(themeData.trackColor, null); + expect(themeData.trackOutlineColor, null); + expect(themeData.trackOutlineWidth, null); + expect(themeData.mouseCursor, null); + expect(themeData.materialTapTargetSize, null); + expect(themeData.overlayColor, null); + expect(themeData.splashRadius, null); + expect(themeData.thumbIcon, null); + expect(themeData.padding, null); + + const theme = SwitchTheme(data: SwitchThemeData(), child: SizedBox()); + expect(theme.data.thumbColor, null); + expect(theme.data.trackColor, null); + expect(theme.data.trackOutlineColor, null); + expect(theme.data.trackOutlineWidth, null); + expect(theme.data.mouseCursor, null); + expect(theme.data.materialTapTargetSize, null); + expect(theme.data.overlayColor, null); + expect(theme.data.splashRadius, null); + expect(theme.data.thumbIcon, null); + expect(theme.data.padding, null); + }); + + testWidgets('Default SwitchThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SwitchThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('SwitchThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(Color(0xfffffff0)), + trackColor: MaterialStatePropertyAll<Color>(Color(0xfffffff1)), + trackOutlineColor: MaterialStatePropertyAll<Color>(Color(0xfffffff3)), + trackOutlineWidth: MaterialStatePropertyAll<double>(6.0), + mouseCursor: MaterialStatePropertyAll<MouseCursor>(SystemMouseCursors.click), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + overlayColor: MaterialStatePropertyAll<Color>(Color(0xfffffff2)), + splashRadius: 1.0, + thumbIcon: MaterialStatePropertyAll<Icon>(Icon(IconData(123))), + padding: EdgeInsets.all(4.0), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'thumbColor: WidgetStatePropertyAll(${const Color(0xfffffff0)})'); + expect(description[1], 'trackColor: WidgetStatePropertyAll(${const Color(0xfffffff1)})'); + expect(description[2], 'trackOutlineColor: WidgetStatePropertyAll(${const Color(0xfffffff3)})'); + expect(description[3], 'trackOutlineWidth: WidgetStatePropertyAll(6.0)'); + expect(description[4], 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap'); + expect(description[5], 'mouseCursor: WidgetStatePropertyAll(SystemMouseCursor(click))'); + expect(description[6], 'overlayColor: WidgetStatePropertyAll(${const Color(0xfffffff2)})'); + expect(description[7], 'splashRadius: 1.0'); + expect(description[8], 'thumbIcon: WidgetStatePropertyAll(Icon(IconData(U+0007B)))'); + expect(description[9], 'padding: EdgeInsets.all(4.0)'); + }); + + testWidgets('Material2 - Switch is themeable', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const defaultThumbColor = Color(0xfffffff0); + const selectedThumbColor = Color(0xfffffff1); + const defaultTrackColor = Color(0xfffffff2); + const selectedTrackColor = Color(0xfffffff3); + const defaultTrackOutlineColor = Color(0xfffffff4); + const selectedTrackOutlineColor = Color(0xfffffff5); + const defaultTrackOutlineWidth = 3.0; + const selectedTrackOutlineWidth = 6.0; + const MouseCursor mouseCursor = SystemMouseCursors.text; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const focusOverlayColor = Color(0xfffffff4); + const hoverOverlayColor = Color(0xfffffff5); + const splashRadius = 1.0; + const icon1 = Icon(Icons.check); + const icon2 = Icon(Icons.close); + + final themeData = ThemeData( + useMaterial3: false, + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedThumbColor; + } + return defaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackColor; + } + return defaultTrackColor; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackOutlineColor; + } + return defaultTrackOutlineColor; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackOutlineWidth; + } + return defaultTrackOutlineWidth; + }), + mouseCursor: const MaterialStatePropertyAll<MouseCursor>(mouseCursor), + materialTapTargetSize: materialTapTargetSize, + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + }), + splashRadius: splashRadius, + thumbIcon: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return icon1; + } + return icon2; + }), + ), + ); + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch( + dragStartBehavior: DragStartBehavior.down, + value: selected, + onChanged: (bool value) {}, + autofocus: autofocus, + ), + ), + ); + } + + // Switch. + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: defaultTrackColor) + ..rrect(color: defaultTrackOutlineColor, strokeWidth: defaultTrackOutlineWidth) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: defaultThumbColor), + ); + // Size from MaterialTapTargetSize.shrinkWrap. + expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + + // Selected switch. + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: selectedTrackColor) + ..rrect(color: selectedTrackOutlineColor, strokeWidth: selectedTrackOutlineWidth) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: selectedThumbColor), + ); + + // Switch with hover. + await tester.pumpWidget(buildSwitch()); + await _pointGestureToSwitch(tester); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(_getSwitchMaterial(tester), paints..circle(color: hoverOverlayColor)); + + // Switch with focus. + await tester.pumpWidget(buildSwitch(autofocus: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints..circle(color: focusOverlayColor, radius: splashRadius), + ); + }); + + testWidgets('Material3 - Switch is themeable', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const defaultThumbColor = Color(0xfffffff0); + const selectedThumbColor = Color(0xfffffff1); + const defaultTrackColor = Color(0xfffffff2); + const selectedTrackColor = Color(0xfffffff3); + const defaultTrackOutlineColor = Color(0xfffffff4); + const selectedTrackOutlineColor = Color(0xfffffff5); + const defaultTrackOutlineWidth = 3.0; + const selectedTrackOutlineWidth = 6.0; + const MouseCursor mouseCursor = SystemMouseCursors.text; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const focusOverlayColor = Color(0xfffffff4); + const hoverOverlayColor = Color(0xfffffff5); + const splashRadius = 1.0; + const icon1 = Icon(Icons.check); + const icon2 = Icon(Icons.close); + + final themeData = ThemeData( + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedThumbColor; + } + return defaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackColor; + } + return defaultTrackColor; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackOutlineColor; + } + return defaultTrackOutlineColor; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackOutlineWidth; + } + return defaultTrackOutlineWidth; + }), + mouseCursor: const MaterialStatePropertyAll<MouseCursor>(mouseCursor), + materialTapTargetSize: materialTapTargetSize, + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return focusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverOverlayColor; + } + return null; + }), + splashRadius: splashRadius, + thumbIcon: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return icon1; + } + return icon2; + }), + ), + ); + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch( + dragStartBehavior: DragStartBehavior.down, + value: selected, + onChanged: (bool value) {}, + autofocus: autofocus, + ), + ), + ); + } + + // Switch. + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: defaultTrackColor) + ..rrect(color: defaultTrackOutlineColor, strokeWidth: defaultTrackOutlineWidth) + ..rrect(color: defaultThumbColor) + ..paragraph(), + ); + // Size from MaterialTapTargetSize.shrinkWrap. + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 40.0)); + + // Selected switch. + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: selectedTrackColor) + ..rrect(color: selectedTrackOutlineColor, strokeWidth: selectedTrackOutlineWidth) + ..rrect(color: selectedThumbColor) + ..paragraph(), + ); + + // Switch with hover. + await tester.pumpWidget(buildSwitch()); + await _pointGestureToSwitch(tester); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(_getSwitchMaterial(tester), paints..circle(color: hoverOverlayColor)); + + // Switch with focus. + await tester.pumpWidget(buildSwitch(autofocus: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints..circle(color: focusOverlayColor, radius: splashRadius), + ); + }); + + testWidgets('Material2 - Switch properties are taken over the theme values', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const themeDefaultThumbColor = Color(0xfffffff0); + const themeSelectedThumbColor = Color(0xfffffff1); + const themeDefaultTrackColor = Color(0xfffffff2); + const themeSelectedTrackColor = Color(0xfffffff3); + const themeDefaultOutlineColor = Color(0xfffffff6); + const themeSelectedOutlineColor = Color(0xfffffff7); + const themeDefaultOutlineWidth = 5.0; + const themeSelectedOutlineWidth = 7.0; + const MouseCursor themeMouseCursor = SystemMouseCursors.click; + const MaterialTapTargetSize themeMaterialTapTargetSize = MaterialTapTargetSize.padded; + const themeFocusOverlayColor = Color(0xfffffff4); + const themeHoverOverlayColor = Color(0xfffffff5); + const themeSplashRadius = 1.0; + + const defaultThumbColor = Color(0xffffff0f); + const selectedThumbColor = Color(0xffffff1f); + const defaultTrackColor = Color(0xffffff2f); + const selectedTrackColor = Color(0xffffff3f); + const defaultOutlineColor = Color(0xffffff6f); + const selectedOutlineColor = Color(0xffffff7f); + const defaultOutlineWidth = 6.0; + const selectedOutlineWidth = 8.0; + const MouseCursor mouseCursor = SystemMouseCursors.text; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const focusColor = Color(0xffffff4f); + const hoverColor = Color(0xffffff5f); + const splashRadius = 2.0; + + final themeData = ThemeData( + useMaterial3: false, + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedThumbColor; + } + return themeDefaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedTrackColor; + } + return themeDefaultTrackColor; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedOutlineColor; + } + return themeDefaultOutlineColor; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedOutlineWidth; + } + return themeDefaultOutlineWidth; + }), + mouseCursor: const MaterialStatePropertyAll<MouseCursor>(themeMouseCursor), + materialTapTargetSize: themeMaterialTapTargetSize, + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return themeFocusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return themeHoverOverlayColor; + } + return null; + }), + splashRadius: themeSplashRadius, + thumbIcon: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return null; + } + return null; + }), + ), + ); + + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch( + value: selected, + onChanged: (bool value) {}, + autofocus: autofocus, + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedThumbColor; + } + return defaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackColor; + } + return defaultTrackColor; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedOutlineColor; + } + return defaultOutlineColor; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedOutlineWidth; + } + return defaultOutlineWidth; + }), + mouseCursor: mouseCursor, + materialTapTargetSize: materialTapTargetSize, + focusColor: focusColor, + hoverColor: hoverColor, + splashRadius: splashRadius, + thumbIcon: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const Icon(Icons.add); + } + return const Icon(Icons.access_alarm); + }), + ), + ), + ); + } + + // Switch. + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: defaultTrackColor) + ..rrect(color: defaultOutlineColor, strokeWidth: defaultOutlineWidth) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: defaultThumbColor), + ); + // Size from MaterialTapTargetSize.shrinkWrap. + expect(tester.getSize(find.byType(Switch)), const Size(59.0, 40.0)); + + // Selected switch. + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: selectedTrackColor) + ..rrect(color: selectedOutlineColor, strokeWidth: selectedOutlineWidth) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: selectedThumbColor), + ); + + // Switch with hover. + await tester.pumpWidget(buildSwitch()); + await _pointGestureToSwitch(tester); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(_getSwitchMaterial(tester), paints..circle(color: hoverColor)); + + // Switch with focus. + await tester.pumpWidget(buildSwitch(autofocus: true)); + await tester.pumpAndSettle(); + expect(_getSwitchMaterial(tester), paints..circle(color: focusColor, radius: splashRadius)); + }); + + testWidgets('Material3 - Switch properties are taken over the theme values', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const themeDefaultThumbColor = Color(0xfffffff0); + const themeSelectedThumbColor = Color(0xfffffff1); + const themeDefaultTrackColor = Color(0xfffffff2); + const themeSelectedTrackColor = Color(0xfffffff3); + const themeDefaultOutlineColor = Color(0xfffffff6); + const themeSelectedOutlineColor = Color(0xfffffff7); + const themeDefaultOutlineWidth = 5.0; + const themeSelectedOutlineWidth = 7.0; + const MouseCursor themeMouseCursor = SystemMouseCursors.click; + const MaterialTapTargetSize themeMaterialTapTargetSize = MaterialTapTargetSize.padded; + const themeFocusOverlayColor = Color(0xfffffff4); + const themeHoverOverlayColor = Color(0xfffffff5); + const themeSplashRadius = 1.0; + + const defaultThumbColor = Color(0xffffff0f); + const selectedThumbColor = Color(0xffffff1f); + const defaultTrackColor = Color(0xffffff2f); + const selectedTrackColor = Color(0xffffff3f); + const defaultOutlineColor = Color(0xffffff6f); + const selectedOutlineColor = Color(0xffffff7f); + const defaultOutlineWidth = 6.0; + const selectedOutlineWidth = 8.0; + const MouseCursor mouseCursor = SystemMouseCursors.text; + const MaterialTapTargetSize materialTapTargetSize = MaterialTapTargetSize.shrinkWrap; + const focusColor = Color(0xffffff4f); + const hoverColor = Color(0xffffff5f); + const splashRadius = 2.0; + + final themeData = ThemeData( + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedThumbColor; + } + return themeDefaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedTrackColor; + } + return themeDefaultTrackColor; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedOutlineColor; + } + return themeDefaultOutlineColor; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedOutlineWidth; + } + return themeDefaultOutlineWidth; + }), + mouseCursor: const MaterialStatePropertyAll<MouseCursor>(themeMouseCursor), + materialTapTargetSize: themeMaterialTapTargetSize, + overlayColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.focused)) { + return themeFocusOverlayColor; + } + if (states.contains(WidgetState.hovered)) { + return themeHoverOverlayColor; + } + return null; + }), + splashRadius: themeSplashRadius, + thumbIcon: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return null; + } + return null; + }), + ), + ); + + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch( + value: selected, + onChanged: (bool value) {}, + autofocus: autofocus, + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedThumbColor; + } + return defaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedTrackColor; + } + return defaultTrackColor; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedOutlineColor; + } + return defaultOutlineColor; + }), + trackOutlineWidth: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedOutlineWidth; + } + return defaultOutlineWidth; + }), + mouseCursor: mouseCursor, + materialTapTargetSize: materialTapTargetSize, + focusColor: focusColor, + hoverColor: hoverColor, + splashRadius: splashRadius, + thumbIcon: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return const Icon(Icons.add); + } + return const Icon(Icons.access_alarm); + }), + ), + ), + ); + } + + // Switch. + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: defaultTrackColor) + ..rrect(color: defaultOutlineColor, strokeWidth: defaultOutlineWidth) + ..rrect(color: defaultThumbColor) + ..paragraph(offset: const Offset(12, 12)), + ); + // Size from MaterialTapTargetSize.shrinkWrap. + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 40.0)); + + // Selected switch. + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: selectedTrackColor) + ..rrect(color: selectedOutlineColor, strokeWidth: selectedOutlineWidth) + ..rrect(color: selectedThumbColor), + ); + + // Switch with hover. + await tester.pumpWidget(buildSwitch()); + await _pointGestureToSwitch(tester); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(_getSwitchMaterial(tester), paints..circle(color: hoverColor)); + + // Switch with focus. + await tester.pumpWidget(buildSwitch(autofocus: true)); + await tester.pumpAndSettle(); + expect(_getSwitchMaterial(tester), paints..circle(color: focusColor, radius: splashRadius)); + }); + + testWidgets('Material2 - Switch active and inactive properties are taken over the theme values', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const themeDefaultThumbColor = Color(0xfffffff0); + const themeSelectedThumbColor = Color(0xfffffff1); + const themeDefaultTrackColor = Color(0xfffffff2); + const themeSelectedTrackColor = Color(0xfffffff3); + + const defaultThumbColor = Color(0xffffff0f); + const selectedThumbColor = Color(0xffffff1f); + const defaultTrackColor = Color(0xffffff2f); + const selectedTrackColor = Color(0xffffff3f); + + final themeData = ThemeData( + useMaterial3: false, + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedThumbColor; + } + return themeDefaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedTrackColor; + } + return themeDefaultTrackColor; + }), + ), + ); + + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch( + value: selected, + onChanged: (bool value) {}, + autofocus: autofocus, + activeColor: selectedThumbColor, + inactiveThumbColor: defaultThumbColor, + activeTrackColor: selectedTrackColor, + inactiveTrackColor: defaultTrackColor, + ), + ), + ); + } + + // Unselected switch. + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: defaultTrackColor) + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: defaultThumbColor), + ); + + // Selected switch. + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: selectedTrackColor) + ..rrect() + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: selectedThumbColor), + ); + }); + + testWidgets('Material3 - Switch active and inactive properties are taken over the theme values', ( + WidgetTester tester, + ) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + + const themeDefaultThumbColor = Color(0xfffffff0); + const themeSelectedThumbColor = Color(0xfffffff1); + const themeDefaultTrackColor = Color(0xfffffff2); + const themeSelectedTrackColor = Color(0xfffffff3); + + const defaultThumbColor = Color(0xffffff0f); + const selectedThumbColor = Color(0xffffff1f); + const defaultTrackColor = Color(0xffffff2f); + const selectedTrackColor = Color(0xffffff3f); + + final themeData = ThemeData( + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedThumbColor; + } + return themeDefaultThumbColor; + }), + trackColor: WidgetStateProperty.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return themeSelectedTrackColor; + } + return themeDefaultTrackColor; + }), + ), + ); + + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch( + value: selected, + onChanged: (bool value) {}, + autofocus: autofocus, + activeColor: selectedThumbColor, + inactiveThumbColor: defaultThumbColor, + activeTrackColor: selectedTrackColor, + inactiveTrackColor: defaultTrackColor, + ), + ), + ); + } + + // Unselected switch. + await tester.pumpWidget(buildSwitch()); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: defaultTrackColor) + ..rrect(color: themeData.colorScheme.outline) + ..rrect(color: defaultThumbColor), + ); + + // Selected switch. + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: selectedTrackColor) + ..rrect() + ..rrect(color: selectedThumbColor), + ); + }); + + testWidgets('Material2 - Switch theme overlay color resolves in active/pressed states', ( + WidgetTester tester, + ) async { + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + final themeData = ThemeData( + useMaterial3: false, + switchTheme: SwitchThemeData( + overlayColor: WidgetStateProperty.resolveWith(getOverlayColor), + splashRadius: splashRadius, + ), + ); + + Widget buildSwitch({required bool active}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch(value: active, onChanged: (_) {}), + ), + ); + } + + await tester.pumpWidget(buildSwitch(active: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + _getSwitchMaterial(tester), + paints + ..rrect() + ..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Switch should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildSwitch(active: true)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + _getSwitchMaterial(tester), + paints + ..rrect() + ..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Switch should have overlay color: $activePressedOverlayColor', + ); + }); + + testWidgets('Material3 - Switch theme overlay color resolves in active/pressed states', ( + WidgetTester tester, + ) async { + const activePressedOverlayColor = Color(0xFF000001); + const inactivePressedOverlayColor = Color(0xFF000002); + + Color? getOverlayColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.selected)) { + return activePressedOverlayColor; + } + return inactivePressedOverlayColor; + } + return null; + } + + const splashRadius = 24.0; + final themeData = ThemeData( + switchTheme: SwitchThemeData( + overlayColor: WidgetStateProperty.resolveWith(getOverlayColor), + splashRadius: splashRadius, + ), + ); + + Widget buildSwitch({required bool active}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: Switch(value: active, onChanged: (_) {}), + ), + ); + } + + await tester.pumpWidget(buildSwitch(active: false)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + _getSwitchMaterial(tester), + (paints + ..rrect() + ..rrect()) + ..circle(color: inactivePressedOverlayColor, radius: splashRadius), + reason: 'Inactive pressed Switch should have overlay color: $inactivePressedOverlayColor', + ); + + await tester.pumpWidget(buildSwitch(active: true)); + await tester.press(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect( + _getSwitchMaterial(tester), + paints + ..rrect() + ..circle(color: activePressedOverlayColor, radius: splashRadius), + reason: 'Active pressed Switch should have overlay color: $activePressedOverlayColor', + ); + }); + + testWidgets('Material2 - Local SwitchTheme can override global SwitchTheme', ( + WidgetTester tester, + ) async { + const globalThemeThumbColor = Color(0xfffffff1); + const globalThemeTrackColor = Color(0xfffffff2); + const globalThemeOutlineColor = Color(0xfffffff3); + const globalThemeOutlineWidth = 6.0; + const localThemeThumbColor = Color(0xffff0000); + const localThemeTrackColor = Color(0xffff0000); + const localThemeOutlineColor = Color(0xffff0000); + const localThemeOutlineWidth = 4.0; + + final themeData = ThemeData( + useMaterial3: false, + switchTheme: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(globalThemeThumbColor), + trackColor: MaterialStatePropertyAll<Color>(globalThemeTrackColor), + trackOutlineColor: MaterialStatePropertyAll<Color>(globalThemeOutlineColor), + trackOutlineWidth: MaterialStatePropertyAll<double>(globalThemeOutlineWidth), + ), + ); + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: SwitchTheme( + data: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(localThemeThumbColor), + trackColor: MaterialStatePropertyAll<Color>(localThemeTrackColor), + trackOutlineColor: MaterialStatePropertyAll<Color>(localThemeOutlineColor), + trackOutlineWidth: MaterialStatePropertyAll<double>(localThemeOutlineWidth), + ), + child: Switch(value: selected, onChanged: (bool value) {}, autofocus: autofocus), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: localThemeTrackColor) + ..rrect(color: localThemeOutlineColor, strokeWidth: localThemeOutlineWidth) + ..rrect() + ..rrect() + ..rrect() + ..rrect(color: localThemeThumbColor), + ); + }); + + testWidgets('Material3 - Local SwitchTheme can override global SwitchTheme', ( + WidgetTester tester, + ) async { + const globalThemeThumbColor = Color(0xfffffff1); + const globalThemeTrackColor = Color(0xfffffff2); + const globalThemeOutlineColor = Color(0xfffffff3); + const globalThemeOutlineWidth = 6.0; + const localThemeThumbColor = Color(0xffff0000); + const localThemeTrackColor = Color(0xffff0000); + const localThemeOutlineColor = Color(0xffff0000); + const localThemeOutlineWidth = 4.0; + + final themeData = ThemeData( + switchTheme: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(globalThemeThumbColor), + trackColor: MaterialStatePropertyAll<Color>(globalThemeTrackColor), + trackOutlineColor: MaterialStatePropertyAll<Color>(globalThemeOutlineColor), + trackOutlineWidth: MaterialStatePropertyAll<double>(globalThemeOutlineWidth), + ), + ); + Widget buildSwitch({bool selected = false, bool autofocus = false}) { + return MaterialApp( + theme: themeData, + home: Scaffold( + body: SwitchTheme( + data: const SwitchThemeData( + thumbColor: MaterialStatePropertyAll<Color>(localThemeThumbColor), + trackColor: MaterialStatePropertyAll<Color>(localThemeTrackColor), + trackOutlineColor: MaterialStatePropertyAll<Color>(localThemeOutlineColor), + trackOutlineWidth: MaterialStatePropertyAll<double>(localThemeOutlineWidth), + ), + child: Switch(value: selected, onChanged: (bool value) {}, autofocus: autofocus), + ), + ), + ); + } + + await tester.pumpWidget(buildSwitch(selected: true)); + await tester.pumpAndSettle(); + expect( + _getSwitchMaterial(tester), + paints + ..rrect(color: localThemeTrackColor) + ..rrect(color: localThemeOutlineColor, strokeWidth: localThemeOutlineWidth) + ..rrect(color: localThemeThumbColor), + ); + }); + + testWidgets('SwitchTheme padding is respected', (WidgetTester tester) async { + Widget buildSwitch({EdgeInsets? padding}) { + return MaterialApp( + theme: ThemeData(switchTheme: SwitchThemeData(padding: padding)), + home: Scaffold( + body: Center(child: Switch(value: true, onChanged: (_) {})), + ), + ); + } + + await tester.pumpWidget(buildSwitch()); + + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 48.0)); + + await tester.pumpWidget(buildSwitch(padding: EdgeInsets.zero)); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(Switch)), const Size(52.0, 48.0)); + + await tester.pumpWidget(buildSwitch(padding: const EdgeInsets.all(4.0))); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(Switch)), const Size(60.0, 56.0)); + }); +} + +Future<void> _pointGestureToSwitch(WidgetTester tester) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Switch))); +} + +MaterialInkController? _getSwitchMaterial(WidgetTester tester) { + return Material.of(tester.element(find.byType(Switch))); +} diff --git a/packages/material_ui/test/material/tab_bar_theme_test.dart b/packages/material_ui/test/material/tab_bar_theme_test.dart new file mode 100644 index 000000000000..7da75ff44d10 --- /dev/null +++ b/packages/material_ui/test/material/tab_bar_theme_test.dart @@ -0,0 +1,1857 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'tabs_utils.dart'; + +const String _tab1Text = 'tab 1'; +const String _tab2Text = 'tab 2'; +const String _tab3Text = 'tab 3'; + +final Key _painterKey = UniqueKey(); + +const List<Tab> _tabs = <Tab>[ + Tab(text: _tab1Text, icon: Icon(Icons.looks_one)), + Tab(text: _tab2Text, icon: Icon(Icons.looks_two)), + Tab(text: _tab3Text, icon: Icon(Icons.looks_3)), +]; + +final List<SizedBox> _sizedTabs = <SizedBox>[ + SizedBox(key: UniqueKey(), width: 100.0, height: 50.0), + SizedBox(key: UniqueKey(), width: 100.0, height: 50.0), +]; + +Widget buildTabBar({ + TabBarThemeData? localTabBarTheme, + TabBarThemeData? tabBarTheme, + bool secondaryTabBar = false, + List<Widget> tabs = _tabs, + bool isScrollable = false, + bool useMaterial3 = false, + String? fontFamily, + TextStyle? labelStyle, + TextStyle? unselectedLabelStyle, +}) { + final controller = TabController(length: tabs.length, vsync: const TestVSync()); + addTearDown(controller.dispose); + + Widget tabBar = secondaryTabBar + ? TabBar.secondary( + tabs: tabs, + isScrollable: isScrollable, + controller: controller, + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ) + : TabBar( + tabs: tabs, + isScrollable: isScrollable, + controller: controller, + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ); + + if (localTabBarTheme != null) { + tabBar = TabBarTheme(data: localTabBarTheme, child: tabBar); + } + + return MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme, useMaterial3: useMaterial3, fontFamily: fontFamily), + home: Scaffold( + body: RepaintBoundary(key: _painterKey, child: tabBar), + ), + ); +} + +RenderParagraph _getIcon(WidgetTester tester, IconData icon) { + return tester.renderObject<RenderParagraph>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); +} + +RenderParagraph _getText(WidgetTester tester, String text) { + return tester.renderObject<RenderParagraph>(find.text(text)); +} + +void main() { + test('TabBarThemeData copyWith, ==, hashCode, defaults', () { + expect(const TabBarThemeData(), const TabBarThemeData().copyWith()); + expect(const TabBarThemeData().hashCode, const TabBarThemeData().copyWith().hashCode); + + expect(const TabBarThemeData().indicator, null); + expect(const TabBarThemeData().indicatorColor, null); + expect(const TabBarThemeData().indicatorSize, null); + expect(const TabBarThemeData().dividerColor, null); + expect(const TabBarThemeData().dividerHeight, null); + expect(const TabBarThemeData().labelColor, null); + expect(const TabBarThemeData().labelPadding, null); + expect(const TabBarThemeData().labelStyle, null); + expect(const TabBarThemeData().unselectedLabelColor, null); + expect(const TabBarThemeData().unselectedLabelStyle, null); + expect(const TabBarThemeData().overlayColor, null); + expect(const TabBarThemeData().splashFactory, null); + expect(const TabBarThemeData().mouseCursor, null); + expect(const TabBarThemeData().tabAlignment, null); + expect(const TabBarThemeData().textScaler, null); + expect(const TabBarThemeData().indicatorAnimation, null); + }); + + test('TabBarThemeData lerp special cases', () { + const theme = TabBarThemeData(); + expect(identical(TabBarThemeData.lerp(theme, theme, 0.5), theme), true); + }); + + testWidgets('Default TabBarThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TabBarThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('TabBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TabBarThemeData( + indicator: BoxDecoration(color: Color(0xFF00FF00)), + indicatorColor: Colors.red, + indicatorSize: TabBarIndicatorSize.label, + dividerColor: Color(0xff000001), + dividerHeight: 20.5, + labelColor: Color(0xff000002), + labelPadding: EdgeInsets.all(20.0), + labelStyle: TextStyle(color: Colors.amber), + unselectedLabelColor: Color(0xff654321), + unselectedLabelStyle: TextStyle(color: Colors.blue), + overlayColor: WidgetStatePropertyAll<Color>(Colors.yellow), + mouseCursor: WidgetStatePropertyAll<MouseCursor>(SystemMouseCursors.contextMenu), + tabAlignment: TabAlignment.center, + textScaler: TextScaler.noScaling, + indicatorAnimation: TabIndicatorAnimation.elastic, + ).debugFillProperties(builder); + final List<String> description = builder.properties + .where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode n) => n.toString()) + .toList(); + expect(description, <String>[ + 'indicator: BoxDecoration(color: ${const Color(0xff00ff00)})', + 'indicatorColor: MaterialColor(primary value: ${const Color(0xfff44336)})', + 'indicatorSize: TabBarIndicatorSize.label', + 'dividerColor: ${const Color(0xff000001)}', + 'dividerHeight: 20.5', + 'labelColor: ${const Color(0xff000002)}', + 'labelPadding: EdgeInsets.all(20.0)', + 'labelStyle: TextStyle(inherit: true, color: MaterialColor(primary value: ${const Color(0xffffc107)}))', + 'unselectedLabelColor: ${const Color(0xff654321)}', + 'unselectedLabelStyle: TextStyle(inherit: true, color: MaterialColor(primary value: ${const Color(0xff2196f3)}))', + 'overlayColor: WidgetStatePropertyAll(MaterialColor(primary value: ${const Color(0xffffeb3b)}))', + 'mouseCursor: WidgetStatePropertyAll(SystemMouseCursor(contextMenu))', + 'tabAlignment: TabAlignment.center', + 'textScaler: no scaling', + 'indicatorAnimation: TabIndicatorAnimation.elastic', + ]); + }); + + testWidgets('Local TabBarTheme overrides defaults', (WidgetTester tester) async { + const Color indicatorColor = Colors.green; + const dividerColor = Color(0xff000001); + const dividerHeight = 20.5; + const labelColor = Color(0xff000002); + const labelStyle = TextStyle(fontSize: 32.0); + const unselectedLabelColor = Color(0xff654321); + const unselectedLabelStyle = TextStyle(fontWeight: FontWeight.bold); + + const tabBarTheme = TabBarThemeData( + indicatorColor: indicatorColor, + dividerColor: dividerColor, + dividerHeight: dividerHeight, + labelColor: labelColor, + labelStyle: labelStyle, + unselectedLabelColor: unselectedLabelColor, + unselectedLabelStyle: unselectedLabelStyle, + ); + + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(useMaterial3: true, localTabBarTheme: tabBarTheme)); + + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.color, labelColor); + expect(selectedLabel.text.style!.fontSize, 32.0); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.color, unselectedLabelColor); + expect(unselectedLabel.text.style!.fontWeight, FontWeight.bold); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect( + tabBarBox, + paints + ..line(color: dividerColor, strokeWidth: dividerHeight) + ..rrect(color: indicatorColor), + ); + }); + + testWidgets('Tab bar defaults (primary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(useMaterial3: true)); + + final theme = ThemeData(); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(theme.colorScheme.primary)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(theme.colorScheme.onSurfaceVariant)); + + // Test default labelPadding. + await tester.pumpWidget(buildTabBar(tabs: _sizedTabs, isScrollable: true)); + + const indicatorWeight = 2.0; + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + const tabStartOffset = 52.0; + + // Verify tabOne coordinates. + expect(tabOneRect.left, equals(kTabLabelPadding.left + tabStartOffset)); + expect(tabOneRect.top, equals(kTabLabelPadding.top)); + expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabTwo coordinates. + final double tabTwoRight = + tabStartOffset + + kTabLabelPadding.horizontal + + tabOneRect.width + + kTabLabelPadding.left + + tabTwoRect.width; + expect(tabTwoRect.right, tabTwoRight); + expect(tabTwoRect.top, equals(kTabLabelPadding.top)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo. + expect( + tabOneRect.right, + equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right), + ); + + // Test default indicator & divider color. + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect( + tabBarBox, + paints + ..line(color: theme.colorScheme.outlineVariant, strokeWidth: 1.0) + ..rrect(color: theme.colorScheme.primary), + ); + }); + + testWidgets('Tab bar defaults (secondary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(secondaryTabBar: true, useMaterial3: true)); + + final theme = ThemeData(); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(theme.colorScheme.onSurface)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(theme.colorScheme.onSurfaceVariant)); + + // Test default labelPadding. + await tester.pumpWidget( + buildTabBar(secondaryTabBar: true, tabs: _sizedTabs, isScrollable: true, useMaterial3: true), + ); + + const indicatorWeight = 2.0; + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + const tabStartOffset = 52.0; + + // Verify tabOne coordinates. + expect(tabOneRect.left, equals(kTabLabelPadding.left + tabStartOffset)); + expect(tabOneRect.top, equals(kTabLabelPadding.top)); + expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabTwo coordinates. + final double tabTwoRight = + tabStartOffset + + kTabLabelPadding.horizontal + + tabOneRect.width + + kTabLabelPadding.left + + tabTwoRect.width; + expect(tabTwoRect.right, tabTwoRight); + expect(tabTwoRect.top, equals(kTabLabelPadding.top)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo. + expect( + tabOneRect.right, + equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right), + ); + + // Test default indicator & divider color. + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect( + tabBarBox, + paints + ..line(color: theme.colorScheme.outlineVariant, strokeWidth: 1.0) + ..line(color: theme.colorScheme.primary), + ); + }); + + testWidgets('Tab bar theme overrides label color (selected)', (WidgetTester tester) async { + const Color labelColor = Colors.black; + const tabBarTheme = TabBarThemeData(labelColor: labelColor); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final RenderParagraph tabLabel = _getText(tester, _tab1Text); + expect(tabLabel.text.style!.color, equals(labelColor)); + final RenderParagraph tabIcon = _getIcon(tester, Icons.looks_one); + expect(tabIcon.text.style!.color, equals(labelColor)); + }); + + testWidgets('Tab bar theme overrides label padding', (WidgetTester tester) async { + const topPadding = 10.0; + const bottomPadding = 7.0; + const rightPadding = 13.0; + const leftPadding = 16.0; + const indicatorWeight = 2.0; // default value + + const EdgeInsetsGeometry labelPadding = EdgeInsets.fromLTRB( + leftPadding, + topPadding, + rightPadding, + bottomPadding, + ); + + const tabBarTheme = TabBarThemeData(labelPadding: labelPadding); + + await tester.pumpWidget( + buildTabBar(tabBarTheme: tabBarTheme, tabs: _sizedTabs, isScrollable: true), + ); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // verify coordinates of tabOne + expect(tabOneRect.left, equals(leftPadding)); + expect(tabOneRect.top, equals(topPadding)); + expect(tabOneRect.bottom, equals(tabBar.bottom - bottomPadding - indicatorWeight)); + + // verify coordinates of tabTwo + expect(tabTwoRect.right, equals(tabBar.width - rightPadding)); + expect(tabTwoRect.top, equals(topPadding)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - bottomPadding - indicatorWeight)); + + // verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo + expect(tabOneRect.right, equals(tabTwoRect.left - leftPadding - rightPadding)); + }); + + testWidgets('Tab bar theme overrides label styles', (WidgetTester tester) async { + const labelStyle = TextStyle(fontFamily: 'foobar'); + const unselectedLabelStyle = TextStyle(fontFamily: 'baz'); + const tabBarTheme = TabBarThemeData( + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(labelStyle.fontFamily)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); + }); + + testWidgets('Tab bar theme with just label style specified', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/28784 + const labelStyle = TextStyle(fontFamily: 'foobar'); + const tabBarTheme = TabBarThemeData(labelStyle: labelStyle); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(labelStyle.fontFamily)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + }); + + testWidgets('Tab bar label styles override theme label styles', (WidgetTester tester) async { + const labelStyle = TextStyle(fontFamily: '1'); + const unselectedLabelStyle = TextStyle(fontFamily: '2'); + const themeLabelStyle = TextStyle(fontFamily: '3'); + const themeUnselectedLabelStyle = TextStyle(fontFamily: '4'); + const tabBarTheme = TabBarThemeData( + labelStyle: themeLabelStyle, + unselectedLabelStyle: themeUnselectedLabelStyle, + ); + final controller = TabController(length: _tabs.length, vsync: const TestVSync()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme), + home: Scaffold( + body: TabBar( + tabs: _tabs, + controller: controller, + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ), + ), + ), + ); + + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(labelStyle.fontFamily)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); + }); + + testWidgets('Material2 - Tab bar label padding overrides theme label padding', ( + WidgetTester tester, + ) async { + const verticalPadding = 10.0; + const horizontalPadding = 10.0; + const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ); + + const verticalThemePadding = 20.0; + const horizontalThemePadding = 20.0; + const EdgeInsetsGeometry themeLabelPadding = EdgeInsets.symmetric( + vertical: verticalThemePadding, + horizontal: horizontalThemePadding, + ); + + const indicatorWeight = 2.0; // default value + + const tabBarTheme = TabBarThemeData(labelPadding: themeLabelPadding); + + final controller = TabController(length: _sizedTabs.length, vsync: const TestVSync()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme, useMaterial3: false), + home: Scaffold( + body: RepaintBoundary( + key: _painterKey, + child: TabBar( + tabs: _sizedTabs, + isScrollable: true, + controller: controller, + labelPadding: labelPadding, + ), + ), + ), + ), + ); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // verify coordinates of tabOne + expect(tabOneRect.left, equals(horizontalPadding)); + expect(tabOneRect.top, equals(verticalPadding)); + expect(tabOneRect.bottom, equals(tabBar.bottom - verticalPadding - indicatorWeight)); + + // verify coordinates of tabTwo + expect(tabTwoRect.right, equals(tabBar.width - horizontalPadding)); + expect(tabTwoRect.top, equals(verticalPadding)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - verticalPadding - indicatorWeight)); + + // verify tabOne and tabTwo are separated by 2x horizontalPadding + expect(tabOneRect.right, equals(tabTwoRect.left - (2 * horizontalPadding))); + }); + + testWidgets('Material3 - Tab bar label padding overrides theme label padding', ( + WidgetTester tester, + ) async { + const tabStartOffset = 52.0; + const verticalPadding = 10.0; + const horizontalPadding = 10.0; + const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ); + + const verticalThemePadding = 20.0; + const horizontalThemePadding = 20.0; + const EdgeInsetsGeometry themeLabelPadding = EdgeInsets.symmetric( + vertical: verticalThemePadding, + horizontal: horizontalThemePadding, + ); + + const indicatorWeight = 2.0; // default value + + const tabBarTheme = TabBarThemeData(labelPadding: themeLabelPadding); + + final controller = TabController(length: _sizedTabs.length, vsync: const TestVSync()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme), + home: Scaffold( + body: RepaintBoundary( + key: _painterKey, + child: TabBar( + tabs: _sizedTabs, + isScrollable: true, + controller: controller, + labelPadding: labelPadding, + ), + ), + ), + ), + ); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // verify coordinates of tabOne + expect(tabOneRect.left, equals(horizontalPadding + tabStartOffset)); + expect(tabOneRect.top, equals(verticalPadding)); + expect(tabOneRect.bottom, equals(tabBar.bottom - verticalPadding - indicatorWeight)); + + // verify coordinates of tabTwo + expect( + tabTwoRect.right, + equals( + tabStartOffset + + horizontalThemePadding + + tabOneRect.width + + tabTwoRect.width + + (horizontalThemePadding / 2), + ), + ); + expect(tabTwoRect.top, equals(verticalPadding)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - verticalPadding - indicatorWeight)); + + // verify tabOne and tabTwo are separated by 2x horizontalPadding + expect(tabOneRect.right, equals(tabTwoRect.left - (2 * horizontalPadding))); + }); + + testWidgets('Tab bar theme overrides label color (unselected)', (WidgetTester tester) async { + const Color unselectedLabelColor = Colors.black; + const tabBarTheme = TabBarThemeData(unselectedLabelColor: unselectedLabelColor); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final RenderParagraph textRenderObject = tester.renderObject<RenderParagraph>( + find.text(_tab2Text), + ); + expect(textRenderObject.text.style!.color, equals(unselectedLabelColor)); + final RenderParagraph iconRenderObject = _getIcon(tester, Icons.looks_two); + expect(iconRenderObject.text.style!.color, equals(unselectedLabelColor)); + }); + + testWidgets('Tab bar default tab indicator size (primary)', (WidgetTester tester) async { + await tester.pumpWidget(buildTabBar(useMaterial3: true, isScrollable: true)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar.default.tab_indicator_size.png'), + ); + }); + + testWidgets('Tab bar default tab indicator size (secondary)', (WidgetTester tester) async { + await tester.pumpWidget(buildTabBar(useMaterial3: true, isScrollable: true)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar_secondary.default.tab_indicator_size.png'), + ); + }); + + testWidgets('Tab bar theme overrides tab indicator size (tab)', (WidgetTester tester) async { + const tabBarTheme = TabBarThemeData(indicatorSize: TabBarIndicatorSize.tab); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar_theme.tab_indicator_size_tab.png'), + ); + }); + + testWidgets('Tab bar theme overrides tab indicator size (label)', (WidgetTester tester) async { + const tabBarTheme = TabBarThemeData(indicatorSize: TabBarIndicatorSize.label); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar_theme.tab_indicator_size_label.png'), + ); + }); + + testWidgets('Tab bar theme overrides tab mouse cursor', (WidgetTester tester) async { + const tabBarTheme = TabBarThemeData(mouseCursor: WidgetStateMouseCursor.textable); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final Offset tabBar = tester.getCenter( + find.ancestor(of: find.text('tab 1'), matching: find.byType(TabBar)), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tabBar); + await tester.pumpAndSettle(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + }); + + testWidgets('Tab bar theme - custom tab indicator', (WidgetTester tester) async { + final tabBarTheme = TabBarThemeData(indicator: BoxDecoration(border: Border.all())); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar_theme.custom_tab_indicator.png'), + ); + }); + + testWidgets('Tab bar theme - beveled rect indicator', (WidgetTester tester) async { + const tabBarTheme = TabBarThemeData( + indicator: ShapeDecoration( + shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20.0))), + color: Colors.black, + ), + ); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar_theme.beveled_rect_indicator.png'), + ); + }); + + testWidgets('TabAlignment.fill from TabBarTheme only supports non-scrollable tab bar', ( + WidgetTester tester, + ) async { + const tabBarTheme = TabBarThemeData(tabAlignment: TabAlignment.fill); + + // Test TabAlignment.fill from TabBarTheme with non-scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.fill from TabBarTheme with scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme, isScrollable: true)); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets( + 'TabAlignment.start & TabAlignment.startOffset from TabBarTheme only supports scrollable tab bar', + (WidgetTester tester) async { + var tabBarTheme = const TabBarThemeData(tabAlignment: TabAlignment.start); + + // Test TabAlignment.start from TabBarTheme with scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme, isScrollable: true)); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.start from TabBarTheme with non-scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + expect(tester.takeException(), isAssertionError); + + tabBarTheme = const TabBarThemeData(tabAlignment: TabAlignment.startOffset); + + // Test TabAlignment.startOffset from TabBarTheme with scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme, isScrollable: true)); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.startOffset from TabBarTheme with non-scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + expect(tester.takeException(), isAssertionError); + }, + ); + + testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (primary)', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + tabBarTheme: const TabBarThemeData(indicatorSize: TabBarIndicatorSize.tab), + ); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final controller = TabController(vsync: const TestVSync(), length: tabs.length); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const indicatorWeight = 2.0; + const double indicatorY = 48 - (indicatorWeight / 2.0); + const double indicatorLeft = indicatorWeight / 2.0; + const double indicatorRight = 200.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints + // Divider. + ..line(color: theme.colorScheme.outlineVariant, strokeWidth: 1.0) + // Tab indicator. + ..line( + color: theme.colorScheme.primary, + strokeWidth: indicatorWeight, + p1: const Offset(indicatorLeft, indicatorY), + p2: const Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (secondary)', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + tabBarTheme: const TabBarThemeData(indicatorSize: TabBarIndicatorSize.label), + ); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final controller = TabController(vsync: const TestVSync(), length: tabs.length); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Container( + alignment: Alignment.topLeft, + child: TabBar.secondary(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const indicatorWeight = 2.0; + const double indicatorY = 48 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints + // Divider. + ..line(color: theme.colorScheme.outlineVariant, strokeWidth: 1.0) + // Tab indicator + ..line( + color: theme.colorScheme.primary, + strokeWidth: indicatorWeight, + p1: const Offset(65.75, indicatorY), + p2: const Offset(134.25, indicatorY), + ), + ); + }); + + testWidgets('TabBar divider can use TabBarTheme.dividerColor & TabBarTheme.dividerHeight', ( + WidgetTester tester, + ) async { + const dividerColor = Color(0xff00ff00); + const dividerHeight = 10.0; + + final controller = TabController(length: 3, vsync: const TestVSync()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarThemeData( + dividerColor: dividerColor, + dividerHeight: dividerHeight, + ), + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: controller, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + // Test divider color. + expect(tabBarBox, paints..line(color: dividerColor, strokeWidth: dividerHeight)); + }); + + testWidgets('dividerColor & dividerHeight overrides TabBarTheme.dividerColor', ( + WidgetTester tester, + ) async { + const dividerColor = Color(0xff0000ff); + const dividerHeight = 8.0; + + final controller = TabController(length: 3, vsync: const TestVSync()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarThemeData(dividerColor: Colors.pink, dividerHeight: 5.0), + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + dividerColor: dividerColor, + dividerHeight: dividerHeight, + controller: controller, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + // Test divider color. + expect(tabBarBox, paints..line(color: dividerColor, strokeWidth: dividerHeight)); + }); + + testWidgets('TabBar respects TabBarTheme.tabAlignment', (WidgetTester tester) async { + final controller1 = TabController(length: 2, vsync: const TestVSync()); + addTearDown(controller1.dispose); + + // Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(tabAlignment: TabAlignment.center)), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: controller1, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + const availableWidth = 800.0; + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + double tabOneLeft = (availableWidth / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = (availableWidth / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + + final controller2 = TabController(length: 2, vsync: const TestVSync()); + addTearDown(controller2.dispose); + + // Test scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(tabAlignment: TabAlignment.start)), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + isScrollable: true, + controller: controller2, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabBar.tabAlignment overrides TabBarTheme.tabAlignment', ( + WidgetTester tester, + ) async { + final controller1 = TabController(length: 2, vsync: const TestVSync()); + addTearDown(controller1.dispose); + + /// Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(tabAlignment: TabAlignment.fill)), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabAlignment: TabAlignment.center, + controller: controller1, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + const availableWidth = 800.0; + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + double tabOneLeft = (availableWidth / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = (availableWidth / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + + final controller2 = TabController(length: 2, vsync: const TestVSync()); + addTearDown(controller2.dispose); + + /// Test scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(tabAlignment: TabAlignment.center)), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + controller: controller2, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets( + 'TabBar labels use colors from TabBarTheme.labelStyle & TabBarTheme.unselectedLabelStyle', + (WidgetTester tester) async { + const labelStyle = TextStyle(color: Color(0xff0000ff), fontStyle: FontStyle.italic); + const unselectedLabelStyle = TextStyle(color: Color(0x950000ff), fontStyle: FontStyle.italic); + const tabBarTheme = TabBarThemeData( + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ); + + // Test tab bar with TabBarTheme labelStyle & unselectedLabelStyle. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + final IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + final TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab1Text)) + .text + .style!; + final TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab2Text)) + .text + .style!; + + // Selected tab should use labelStyle color. + expect(selectedTabIcon.color, labelStyle.color); + expect(selectedTextStyle.color, labelStyle.color); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use unselectedLabelStyle color. + expect(unselectedTabIcon.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + }, + ); + + testWidgets( + "TabBarTheme's labelColor & unselectedLabelColor override labelStyle & unselectedLabelStyle colors", + (WidgetTester tester) async { + const labelColor = Color(0xfff00000); + const unselectedLabelColor = Color(0x95ff0000); + const labelStyle = TextStyle(color: Color(0xff0000ff), fontStyle: FontStyle.italic); + const unselectedLabelStyle = TextStyle(color: Color(0x950000ff), fontStyle: FontStyle.italic); + var tabBarTheme = const TabBarThemeData( + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ); + + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + // Test tab bar with TabBarTheme labelStyle & unselectedLabelStyle. + await tester.pumpWidget(buildTabBar()); + + IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab1Text)) + .text + .style!; + TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab2Text)) + .text + .style!; + + // Selected tab should use the labelStyle color. + expect(selectedTabIcon.color, labelStyle.color); + expect(selectedTextStyle.color, labelStyle.color); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use the unselectedLabelStyle color. + expect(unselectedTabIcon.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + + // Update the TabBarTheme with labelColor & unselectedLabelColor. + tabBarTheme = const TabBarThemeData( + labelColor: labelColor, + unselectedLabelColor: unselectedLabelColor, + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + ); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + await tester.pumpAndSettle(); + + selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + selectedTextStyle = tester.renderObject<RenderParagraph>(find.text(_tab1Text)).text.style!; + unselectedTextStyle = tester.renderObject<RenderParagraph>(find.text(_tab2Text)).text.style!; + + // Selected tab should use the labelColor. + expect(selectedTabIcon.color, labelColor); + expect(selectedTextStyle.color, labelColor); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use the unselectedLabelColor. + expect(unselectedTabIcon.color, unselectedLabelColor); + expect(unselectedTextStyle.color, unselectedLabelColor); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + }, + ); + + testWidgets( + "TabBarTheme's labelColor & unselectedLabelColor override TabBar.labelStyle & TabBar.unselectedLabelStyle colors", + (WidgetTester tester) async { + const labelColor = Color(0xfff00000); + const unselectedLabelColor = Color(0x95ff0000); + const labelStyle = TextStyle(color: Color(0xff0000ff), fontStyle: FontStyle.italic); + const unselectedLabelStyle = TextStyle(color: Color(0x950000ff), fontStyle: FontStyle.italic); + + Widget buildTabBar({TabBarThemeData? tabBarTheme}) { + return MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme), + home: const Material( + child: DefaultTabController( + length: 2, + child: TabBar( + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + tabs: <Widget>[ + Tab(text: _tab1Text), + Tab(text: _tab2Text), + ], + ), + ), + ), + ); + } + + // Test tab bar with [TabBar.labelStyle] & [TabBar.unselectedLabelStyle]. + await tester.pumpWidget(buildTabBar()); + + IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab1Text)) + .text + .style!; + TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab2Text)) + .text + .style!; + + // Selected tab should use the [TabBar.labelStyle] color. + expect(selectedTabIcon.color, labelStyle.color); + expect(selectedTextStyle.color, labelStyle.color); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use the [TabBar.unselectedLabelStyle] color. + expect(unselectedTabIcon.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + + // Add TabBarTheme with labelColor & unselectedLabelColor. + await tester.pumpWidget( + buildTabBar( + tabBarTheme: const TabBarThemeData( + labelColor: labelColor, + unselectedLabelColor: unselectedLabelColor, + ), + ), + ); + await tester.pumpAndSettle(); + + selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + selectedTextStyle = tester.renderObject<RenderParagraph>(find.text(_tab1Text)).text.style!; + unselectedTextStyle = tester.renderObject<RenderParagraph>(find.text(_tab2Text)).text.style!; + + // Selected tab should use the [TabBarTheme.labelColor]. + expect(selectedTabIcon.color, labelColor); + expect(selectedTextStyle.color, labelColor); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use the [TabBarTheme.unselectedLabelColor]. + expect(unselectedTabIcon.color, unselectedLabelColor); + expect(unselectedTextStyle.color, unselectedLabelColor); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + }, + ); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Tab bar defaults (primary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar()); + + final theme = ThemeData(useMaterial3: false); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(Colors.white)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + + // Test default labelPadding. + await tester.pumpWidget(buildTabBar(tabs: _sizedTabs, isScrollable: true)); + + const indicatorWeight = 2.0; + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // Verify tabOne coordinates. + expect(tabOneRect.left, equals(kTabLabelPadding.left)); + expect(tabOneRect.top, equals(kTabLabelPadding.top)); + expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabTwo coordinates. + expect(tabTwoRect.right, equals(tabBar.width - kTabLabelPadding.right)); + expect(tabTwoRect.top, equals(kTabLabelPadding.top)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo. + expect( + tabOneRect.right, + equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right), + ); + + // Test default indicator color. + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox, paints..line(color: theme.indicatorColor)); + }); + + testWidgets('Tab bar defaults (secondary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(secondaryTabBar: true)); + + final theme = ThemeData(useMaterial3: false); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(Colors.white)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + + // Test default labelPadding. + await tester.pumpWidget(buildTabBar(tabs: _sizedTabs, isScrollable: true)); + + const indicatorWeight = 2.0; + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // Verify tabOne coordinates. + expect(tabOneRect.left, equals(kTabLabelPadding.left)); + expect(tabOneRect.top, equals(kTabLabelPadding.top)); + expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabTwo coordinates. + expect(tabTwoRect.right, equals(tabBar.width - kTabLabelPadding.right)); + expect(tabTwoRect.top, equals(kTabLabelPadding.top)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo. + expect( + tabOneRect.right, + equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right), + ); + + // Test default indicator color. + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox, paints..line(color: theme.indicatorColor)); + }); + + testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { + await tester.pumpWidget(buildTabBar()); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar.m2.default.tab_indicator_size.png'), + ); + }); + + testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (primary)', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + tabBarTheme: const TabBarThemeData(indicatorSize: TabBarIndicatorSize.tab), + useMaterial3: false, + ); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final controller = TabController(vsync: const TestVSync(), length: tabs.length); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const indicatorWeight = 2.0; + const double indicatorY = 48 - (indicatorWeight / 2.0); + const double indicatorLeft = indicatorWeight / 2.0; + const double indicatorRight = 200.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints + // Tab indicator + ..line( + color: theme.indicatorColor, + strokeWidth: indicatorWeight, + p1: const Offset(indicatorLeft, indicatorY), + p2: const Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (secondary)', ( + WidgetTester tester, + ) async { + final theme = ThemeData( + tabBarTheme: const TabBarThemeData(indicatorSize: TabBarIndicatorSize.label), + useMaterial3: false, + ); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final controller = TabController(vsync: const TestVSync(), length: tabs.length); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Container( + alignment: Alignment.topLeft, + child: TabBar.secondary(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const indicatorWeight = 2.0; + const double indicatorY = 48 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints + // Tab indicator + ..line( + color: theme.indicatorColor, + strokeWidth: indicatorWeight, + p1: const Offset(66.0, indicatorY), + p2: const Offset(134.0, indicatorY), + ), + ); + }); + + testWidgets('TabBar respects TabBarTheme.tabAlignment', (WidgetTester tester) async { + final controller = TabController(length: 2, vsync: const TestVSync()); + addTearDown(controller.dispose); + + // Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarThemeData(tabAlignment: TabAlignment.center), + useMaterial3: false, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: controller, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Rect tabOneRect = tester.getRect(find.byType(Tab).first); + final Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + final double tabOneLeft = (800 / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + final double tabTwoRight = (800 / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabBar.tabAlignment overrides TabBarTheme.tabAlignment', ( + WidgetTester tester, + ) async { + final controller = TabController(length: 2, vsync: const TestVSync()); + addTearDown(controller.dispose); + + // Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarThemeData(tabAlignment: TabAlignment.fill), + useMaterial3: false, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabAlignment: TabAlignment.center, + controller: controller, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Rect tabOneRect = tester.getRect(find.byType(Tab).first); + final Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + final double tabOneLeft = (800 / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + final double tabTwoRight = (800 / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + }); + + testWidgets('Material3 - TabBar indicator respects TabBarTheme.indicatorColor', ( + WidgetTester tester, + ) async { + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final controller = TabController(vsync: const TestVSync(), length: tabs.length); + addTearDown(controller.dispose); + + const tabBarThemeIndicatorColor = Color(0xffff0000); + + Widget buildTabBar({required ThemeData theme}) { + return MaterialApp( + theme: theme, + home: Material( + child: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar(theme: ThemeData())); + + RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox, paints..rrect(color: ThemeData().colorScheme.primary)); + + await tester.pumpWidget( + buildTabBar( + theme: ThemeData( + tabBarTheme: const TabBarThemeData(indicatorColor: tabBarThemeIndicatorColor), + ), + ), + ); + await tester.pumpAndSettle(); + + tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox, paints..rrect(color: tabBarThemeIndicatorColor)); + }); + + testWidgets('Material2 - TabBar indicator respects TabBarTheme.indicatorColor', ( + WidgetTester tester, + ) async { + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final controller = TabController(vsync: const TestVSync(), length: tabs.length); + addTearDown(controller.dispose); + + const tabBarThemeIndicatorColor = Color(0xffffff00); + + Widget buildTabBar({Color? tabBarThemeIndicatorColor}) { + return MaterialApp( + theme: ThemeData( + tabBarTheme: TabBarThemeData(indicatorColor: tabBarThemeIndicatorColor), + useMaterial3: false, + ), + home: Material( + child: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar(tabBarThemeIndicatorColor: tabBarThemeIndicatorColor)); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox, paints..line(color: tabBarThemeIndicatorColor)); + }); + + testWidgets('TabBarTheme.labelColor resolves material states', (WidgetTester tester) async { + const selectedColor = Color(0xff00ff00); + const unselectedColor = Color(0xffff0000); + final labelColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return unselectedColor; + }); + + final tabBarTheme = TabBarThemeData(labelColor: labelColor); + + // Test labelColor correctly resolves material states. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + final IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + final IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + final TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab1Text)) + .text + .style!; + final TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab2Text)) + .text + .style!; + + expect(selectedTabIcon.color, selectedColor); + expect(unselectedTabIcon.color, unselectedColor); + expect(selectedTextStyle.color, selectedColor); + expect(unselectedTextStyle.color, unselectedColor); + }); + + testWidgets( + 'TabBarTheme.labelColor & TabBarTheme.unselectedLabelColor override material state TabBarTheme.labelColor', + (WidgetTester tester) async { + const selectedStateColor = Color(0xff00ff00); + const unselectedStateColor = Color(0xffff0000); + final labelColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedStateColor; + } + return unselectedStateColor; + }); + const selectedColor = Color(0xff00ffff); + const unselectedColor = Color(0xffff12ff); + + var tabBarTheme = TabBarThemeData(labelColor: labelColor); + + // Test material state label color. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab1Text)) + .text + .style!; + TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(_tab2Text)) + .text + .style!; + + expect(selectedTabIcon.color, selectedStateColor); + expect(unselectedTabIcon.color, unselectedStateColor); + expect(selectedTextStyle.color, selectedStateColor); + expect(unselectedTextStyle.color, unselectedStateColor); + + // Test labelColor & unselectedLabelColor override material state labelColor. + tabBarTheme = const TabBarThemeData( + labelColor: selectedColor, + unselectedLabelColor: unselectedColor, + ); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + await tester.pumpAndSettle(); + + selectedTabIcon = IconTheme.of(tester.element(find.text(_tab1Text))); + unselectedTabIcon = IconTheme.of(tester.element(find.text(_tab2Text))); + selectedTextStyle = tester.renderObject<RenderParagraph>(find.text(_tab1Text)).text.style!; + unselectedTextStyle = tester.renderObject<RenderParagraph>(find.text(_tab2Text)).text.style!; + + expect(selectedTabIcon.color, selectedColor); + expect(unselectedTabIcon.color, unselectedColor); + expect(selectedTextStyle.color, selectedColor); + expect(unselectedTextStyle.color, unselectedColor); + }, + ); + + testWidgets( + 'TabBarTheme.textScaler overrides tab label text scale, textScaleFactor = noScaling, 1.75, 2.0', + (WidgetTester tester) async { + final tabs = <String>['Tab 1', 'Tab 2']; + + Widget buildTabs({TextScaler? textScaler}) { + return MaterialApp( + theme: ThemeData(tabBarTheme: TabBarThemeData(textScaler: textScaler)), + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(3.0)), + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + bottom: TabBar(tabs: tabs.map((String tab) => Tab(text: tab)).toList()), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTabs(textScaler: TextScaler.noScaling)); + + Size labelSize = tester.getSize(find.text('Tab 1')); + expect(labelSize, equals(const Size(70.5, 20.0))); + + await tester.pumpWidget(buildTabs(textScaler: const TextScaler.linear(1.75))); + await tester.pumpAndSettle(); + + labelSize = tester.getSize(find.text('Tab 1')); + expect(labelSize, equals(const Size(123.0, 35.0))); + + await tester.pumpWidget(buildTabs(textScaler: const TextScaler.linear(2.0))); + await tester.pumpAndSettle(); + + labelSize = tester.getSize(find.text('Tab 1')); + expect(labelSize, equals(const Size(140.5, 40.0))); + }, + ); + + testWidgets('TabBarTheme indicatorAnimation can customize tab indicator animation', ( + WidgetTester tester, + ) async { + const indicatorWidth = 50.0; + final tabs = List<Widget>.generate(4, (int index) { + return Tab( + key: ValueKey<int>(index), + child: const SizedBox(width: indicatorWidth), + ); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTab({TabIndicatorAnimation? indicatorAnimation}) { + return MaterialApp( + theme: ThemeData(tabBarTheme: TabBarThemeData(indicatorAnimation: indicatorAnimation)), + home: Material( + child: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ); + } + + // Test tab indicator animation with TabIndicatorAnimation.linear. + await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.linear)); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + + // Idle at tab 0. + expect( + tabBarBox, + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + 75.0, + 45.0, + 125.0, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + // Start moving tab indicator. + controller.offset = 0.2; + await tester.pump(); + + expect( + tabBarBox, + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + 115.0, + 45.0, + 165.0, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + // Reset tab controller offset. + controller.offset = 0.0; + + // Test tab indicator animation with TabIndicatorAnimation.elastic. + await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic)); + await tester.pumpAndSettle(); + + // Idle at tab 0. + const currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + const fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + var toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.0), + ), + ); + + // Start moving tab indicator. + controller.offset = 0.2; + await tester.pump(); + toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2), + ), + ); + }); + + testWidgets( + 'Tab bar inherits fontFamily from theme when labelStyle and unselectedLabelStyle are specified', + (WidgetTester tester) async { + const fontFamily = 'TestFont'; + await tester.pumpWidget(buildTabBar(fontFamily: fontFamily)); + + RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(fontFamily)); + RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(fontFamily)); + + await tester.pumpWidget( + buildTabBar( + fontFamily: fontFamily, + localTabBarTheme: const TabBarThemeData( + labelStyle: TextStyle(), + unselectedLabelStyle: TextStyle(), + ), + ), + ); + + selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(fontFamily)); + unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(fontFamily)); + + await tester.pumpWidget( + buildTabBar( + fontFamily: fontFamily, + labelStyle: const TextStyle(), + unselectedLabelStyle: const TextStyle(), + ), + ); + + selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(fontFamily)); + unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(fontFamily)); + + // if inherit is false, the fontFamily should not be applied + await tester.pumpWidget( + buildTabBar( + fontFamily: fontFamily, + localTabBarTheme: const TabBarThemeData( + labelStyle: TextStyle(inherit: false), + unselectedLabelStyle: TextStyle(inherit: false), + ), + ), + ); + + selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, isNull); + unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, isNull); + + await tester.pumpWidget( + buildTabBar( + fontFamily: fontFamily, + labelStyle: const TextStyle(inherit: false), + unselectedLabelStyle: const TextStyle(inherit: false), + ), + ); + + selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, isNull); + unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, isNull); + }, + ); + + testWidgets('TabBar inherits splashBorderRadius from theme', (WidgetTester tester) async { + const hoverColor = Color(0xfff44336); + const double radius = 20; + await tester.pumpWidget( + Material( + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: const DefaultTabController( + length: 1, + child: TabBarTheme( + data: TabBarThemeData(splashBorderRadius: BorderRadius.all(Radius.circular(radius))), + child: TabBar( + overlayColor: WidgetStateMapper<Color>(<WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoverColor, + WidgetState.any: Colors.black54, + }), + tabs: <Widget>[Tab(child: Text(''))], + ), + ), + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.moveTo(tester.getCenter(find.byType(Tab))); + await tester.pumpAndSettle(); + final Finder findInkWell = find.byType(InkWell); + + final BuildContext context = tester.element(findInkWell); + expect( + Material.of(context), + paints..rrect( + color: hoverColor, + rrect: RRect.fromRectAndRadius(tester.getRect(findInkWell), const Radius.circular(radius)), + ), + ); + await gesture.removePointer(); + }); +} diff --git a/packages/material_ui/test/material/tab_controller_test.dart b/packages/material_ui/test/material/tab_controller_test.dart new file mode 100644 index 000000000000..fcd93237d0ad --- /dev/null +++ b/packages/material_ui/test/material/tab_controller_test.dart @@ -0,0 +1,21 @@ +// 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'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +void main() { + testWidgets('$TabController dispatches creation in constructor.', ( + WidgetTester widgetTester, + ) async { + await expectLater( + await memoryEvents( + () async => TabController(length: 1, vsync: const TestVSync()).dispose(), + TabController, + ), + areCreateAndDispose, + ); + }); +} diff --git a/packages/material_ui/test/material/tabbed_scrollview_warp_test.dart b/packages/material_ui/test/material/tabbed_scrollview_warp_test.dart new file mode 100644 index 000000000000..ee6ca36c65d6 --- /dev/null +++ b/packages/material_ui/test/material/tabbed_scrollview_warp_test.dart @@ -0,0 +1,81 @@ +// 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'; + +// This is a regression test for https://github.com/flutter/flutter/issues/10549 +// which was failing because _SliverPersistentHeaderElement.visitChildren() +// didn't check child != null before visiting its child. + +class MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { + @override + double get minExtent => 50.0; + + @override + double get maxExtent => 150.0; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => + const Placeholder(color: Colors.teal); + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => false; +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State<MyHomePage> createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin { + static const int tabCount = 3; + late TabController tabController; + + @override + void initState() { + super.initState(); + tabController = TabController(length: tabCount, vsync: this); + } + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: tabController, + tabs: List<Widget>.generate(tabCount, (int index) => Tab(text: 'Tab $index')).toList(), + ), + ), + body: TabBarView( + controller: tabController, + children: List<Widget>.generate(tabCount, (int index) { + return CustomScrollView( + // The bug only occurs when this key is included + key: ValueKey<String>('Page $index'), + slivers: <Widget>[SliverPersistentHeader(delegate: MySliverPersistentHeaderDelegate())], + ); + }).toList(), + ), + ); + } +} + +void main() { + testWidgets('Tabbed CustomScrollViews, warp from tab 1 to 3', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: MyHomePage())); + + // should not crash. + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + }); +} diff --git a/packages/material_ui/test/material/tabs_test.dart b/packages/material_ui/test/material/tabs_test.dart new file mode 100644 index 000000000000..e5af34b9d00e --- /dev/null +++ b/packages/material_ui/test/material/tabs_test.dart @@ -0,0 +1,9797 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; +import 'tabs_utils.dart'; + +Widget boilerplate({ + Widget? child, + TextDirection textDirection = TextDirection.ltr, + ThemeData? theme, + TabBarThemeData? tabBarTheme, + bool? useMaterial3, +}) { + return Theme( + data: theme ?? ThemeData(useMaterial3: useMaterial3, tabBarTheme: tabBarTheme), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const <LocalizationsDelegate<dynamic>>[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: Directionality( + textDirection: textDirection, + child: Material(child: child), + ), + ), + ); +} + +Widget buildFrame({ + Key? tabBarKey, + bool secondaryTabBar = false, + required List<String> tabs, + required String value, + bool isScrollable = false, + Color? indicatorColor, + Duration? animationDuration, + EdgeInsetsGeometry? padding, + TextDirection textDirection = TextDirection.ltr, + TabAlignment? tabAlignment, + TabBarThemeData? tabBarTheme, + Decoration? indicator, + bool? useMaterial3, +}) { + if (secondaryTabBar) { + return boilerplate( + useMaterial3: useMaterial3, + tabBarTheme: tabBarTheme, + textDirection: textDirection, + child: DefaultTabController( + animationDuration: animationDuration, + initialIndex: tabs.indexOf(value), + length: tabs.length, + child: TabBar.secondary( + key: tabBarKey, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + isScrollable: isScrollable, + indicatorColor: indicatorColor, + padding: padding, + tabAlignment: tabAlignment, + ), + ), + ); + } + + return boilerplate( + useMaterial3: useMaterial3, + tabBarTheme: tabBarTheme, + textDirection: textDirection, + child: DefaultTabController( + animationDuration: animationDuration, + initialIndex: tabs.indexOf(value), + length: tabs.length, + child: TabBar( + key: tabBarKey, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + isScrollable: isScrollable, + indicatorColor: indicatorColor, + padding: padding, + tabAlignment: tabAlignment, + indicator: indicator, + ), + ), + ); +} + +Widget buildLeftRightApp({ + required List<String> tabs, + required String value, + bool automaticIndicatorColorAdjustment = true, + ThemeData? themeData, +}) { + return MaterialApp( + theme: themeData ?? ThemeData(platform: TargetPlatform.android), + home: DefaultTabController( + initialIndex: tabs.indexOf(value), + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + automaticIndicatorColorAdjustment: automaticIndicatorColorAdjustment, + ), + ), + body: const TabBarView( + children: <Widget>[ + Center(child: Text('LEFT CHILD')), + Center(child: Text('RIGHT CHILD')), + ], + ), + ), + ), + ); +} + +void main() { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + testWidgets('indicatorPadding update test', (WidgetTester tester) async { + // Regressing test for https://github.com/flutter/flutter/issues/108102 + const tab = Tab(text: 'A'); + const indicatorPadding = EdgeInsets.only(left: 7.0, right: 7.0); + + await tester.pumpWidget( + boilerplate( + child: const DefaultTabController( + length: 1, + child: TabBar(tabs: <Tab>[tab], indicatorPadding: indicatorPadding), + ), + ), + ); + + // Change the indicatorPadding + await tester.pumpWidget( + boilerplate( + child: DefaultTabController( + length: 1, + child: TabBar( + tabs: const <Tab>[tab], + indicatorPadding: indicatorPadding + const EdgeInsets.all(7.0), + ), + ), + ), + duration: Duration.zero, + phase: EnginePhase.build, + ); + + expect(tester.renderObject(find.byType(CustomPaint).last).debugNeedsPaint, true); + }); + + testWidgets('tab semantics role test', (WidgetTester tester) async { + // Regressing test for https://github.com/flutter/flutter/issues/169175 + // Creates an image semantics node with zero size. + await tester.pumpWidget( + boilerplate( + child: DefaultTabController( + length: 1, + child: TabBar( + tabs: <Widget>[Tab(icon: Semantics(image: true, child: const SizedBox.shrink()))], + ), + ), + ), + ); + expect(find.byType(Tab), findsOneWidget); + }); + + testWidgets('Tab sizing - icon', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Material(child: Tab(icon: SizedBox(width: 10.0, height: 10.0))), + ), + ), + ); + expect(tester.getSize(find.byType(Tab)), const Size(10.0, 46.0)); + }); + + testWidgets('Tab sizing - child', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Material(child: Tab(child: SizedBox(width: 10.0, height: 10.0))), + ), + ), + ); + expect(tester.getSize(find.byType(Tab)), const Size(10.0, 46.0)); + }); + + testWidgets('Tab sizing - text', (WidgetTester tester) async { + final theme = ThemeData(fontFamily: 'FlutterTest'); + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: Material(child: Tab(text: 'x')), + ), + ), + ); + expect( + tester.renderObject<RenderParagraph>(find.byType(RichText)).text.style!.fontFamily, + 'FlutterTest', + ); + expect( + tester.getSize(find.byType(Tab)), + material3 ? const Size(14.25, 46.0) : const Size(14.0, 46.0), + ); + }); + + testWidgets('Tab sizing - icon and text', (WidgetTester tester) async { + final theme = ThemeData(fontFamily: 'FlutterTest'); + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: Material( + child: Tab(icon: SizedBox(width: 10.0, height: 10.0), text: 'x'), + ), + ), + ), + ); + expect( + tester.renderObject<RenderParagraph>(find.byType(RichText)).text.style!.fontFamily, + 'FlutterTest', + ); + expect( + tester.getSize(find.byType(Tab)), + material3 ? const Size(14.25, 72.0) : const Size(14.0, 72.0), + ); + }); + + testWidgets('Tab sizing - icon, iconMargin and text', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(fontFamily: 'FlutterTest'), + home: const Center( + child: Material( + child: Tab( + icon: SizedBox(width: 10.0, height: 10.0), + iconMargin: EdgeInsets.symmetric(horizontal: 100.0), + text: 'x', + ), + ), + ), + ), + ); + expect( + tester.renderObject<RenderParagraph>(find.byType(RichText)).text.style!.fontFamily, + 'FlutterTest', + ); + expect(tester.getSize(find.byType(Tab)), const Size(210.0, 72.0)); + }); + + testWidgets('Tab sizing - icon and child', (WidgetTester tester) async { + final theme = ThemeData(fontFamily: 'FlutterTest'); + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: Material( + child: Tab(icon: SizedBox(width: 10.0, height: 10.0), child: Text('x')), + ), + ), + ), + ); + expect( + tester.renderObject<RenderParagraph>(find.byType(RichText)).text.style!.fontFamily, + 'FlutterTest', + ); + expect( + tester.getSize(find.byType(Tab)), + material3 ? const Size(14.25, 72.0) : const Size(14.0, 72.0), + ); + }); + + testWidgets('Material2 - Default Tab iconMargin', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material( + child: Tab(icon: Icon(Icons.house), text: 'x'), + ), + ), + ); + + double getIconMargin() { + final Rect iconRect = tester.getRect(find.byIcon(Icons.house)); + final Rect labelRect = tester.getRect(find.text('x')); + return labelRect.top - iconRect.bottom; + } + + expect(getIconMargin(), equals(10)); + }); + + testWidgets('Material3 - Default Tab iconMargin', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Tab(icon: Icon(Icons.house), text: 'x'), + ), + ), + ); + + double getIconMargin() { + final Rect iconRect = tester.getRect(find.byIcon(Icons.house)); + final Rect labelRect = tester.getRect(find.text('x')); + return labelRect.top - iconRect.bottom; + } + + expect(getIconMargin(), equals(2)); + }); + + testWidgets('Tab color - normal', (WidgetTester tester) async { + final theme = ThemeData(fontFamily: 'FlutterTest'); + final bool material3 = theme.useMaterial3; + final Widget tabBar = TabBar( + tabs: const <Widget>[SizedBox.shrink()], + controller: createTabController(length: 1, vsync: tester), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material(child: tabBar), + ), + ); + expect( + find.byType(TabBar), + paints..line(color: material3 ? theme.colorScheme.outlineVariant : Colors.blue[500]), + ); + }); + + testWidgets('Tab color - match', (WidgetTester tester) async { + final theme = ThemeData(); + final bool material3 = theme.useMaterial3; + final Widget tabBar = TabBar( + tabs: const <Widget>[SizedBox.shrink()], + controller: createTabController(length: 1, vsync: tester), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material(color: const Color(0xff2196f3), child: tabBar), + ), + ); + expect( + find.byType(TabBar), + paints..line(color: material3 ? theme.colorScheme.outlineVariant : Colors.white), + ); + }); + + testWidgets('Tab color - transparency', (WidgetTester tester) async { + final theme = ThemeData(); + final bool material3 = theme.useMaterial3; + final Widget tabBar = TabBar( + tabs: const <Widget>[SizedBox.shrink()], + controller: createTabController(length: 1, vsync: tester), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material(type: MaterialType.transparency, child: tabBar), + ), + ); + expect( + find.byType(TabBar), + paints..line(color: material3 ? theme.colorScheme.outlineVariant : Colors.blue[500]), + ); + }); + + testWidgets('TabBar default selected/unselected label style (primary)', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B', 'C']; + + const selectedValue = 'A'; + const unselectedValue = 'C'; + await tester.pumpWidget( + buildFrame(tabs: tabs, value: selectedValue, useMaterial3: theme.useMaterial3), + ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label text style. + final RenderParagraph selectedLabel = getTabText(tester, selectedValue); + expect(selectedLabel.text.style!.fontFamily, 'Roboto'); + expect(selectedLabel.text.style!.fontSize, 14.0); + expect(selectedLabel.text.style!.color, theme.colorScheme.primary); + + // Test unselected label text style. + final RenderParagraph unselectedLabel = getTabText(tester, unselectedValue); + expect(unselectedLabel.text.style!.fontFamily, 'Roboto'); + expect(unselectedLabel.text.style!.fontSize, 14.0); + expect(unselectedLabel.text.style!.color, theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('TabBar default selected/unselected label style (secondary)', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B', 'C']; + + const selectedValue = 'A'; + const unselectedValue = 'C'; + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: selectedValue, + secondaryTabBar: true, + useMaterial3: theme.useMaterial3, + ), + ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label text style. + final RenderParagraph selectedLabel = getTabText(tester, selectedValue); + expect(selectedLabel.text.style!.fontFamily, 'Roboto'); + expect(selectedLabel.text.style!.fontSize, 14.0); + expect(selectedLabel.text.style!.color, theme.colorScheme.onSurface); + + // Test unselected label text style. + final RenderParagraph unselectedLabel = getTabText(tester, unselectedValue); + expect(unselectedLabel.text.style!.fontFamily, 'Roboto'); + expect(unselectedLabel.text.style!.fontSize, 14.0); + expect(unselectedLabel.text.style!.color, theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('TabBar default tab indicator (primary)', (WidgetTester tester) async { + final theme = ThemeData(); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + const indicatorWeightLabel = 3.0; + const indicatorWeightTab = 2.0; + + Widget buildTab({TabBarIndicatorSize? indicatorSize}) { + return MaterialApp( + home: boilerplate( + theme: theme, + child: Container( + alignment: Alignment.topLeft, + child: TabBar(indicatorSize: indicatorSize, controller: controller, tabs: tabs), + ), + ), + ); + } + + // Test default tab indicator (TabBarIndicatorSize.label). + await tester.pumpWidget(buildTab()); + + RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + // Check tab indicator size and color. + final rrect = RRect.fromLTRBAndCorners( + 64.75, + tabBarBox.size.height - indicatorWeightLabel, + 135.25, + tabBarBox.size.height, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ); + expect(tabBarBox, paints..rrect(color: theme.colorScheme.primary, rrect: rrect)); + + // Test default tab indicator (TabBarIndicatorSize.tab). + await tester.pumpWidget(buildTab(indicatorSize: TabBarIndicatorSize.tab)); + await tester.pumpAndSettle(); + + tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const double indicatorY = 48 - (indicatorWeightTab / 2.0); + const double indicatorLeft = indicatorWeightTab / 2.0; + const double indicatorRight = 200.0 - (indicatorWeightTab / 2.0); + + // Check tab indicator size and color. + expect( + tabBarBox, + paints + // Divider. + ..line(color: theme.colorScheme.outlineVariant) + // Tab indicator. + ..line( + color: theme.colorScheme.primary, + strokeWidth: indicatorWeightTab, + p1: const Offset(indicatorLeft, indicatorY), + p2: const Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar default tab indicator (secondary)', (WidgetTester tester) async { + final theme = ThemeData(); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + const indicatorWeight = 2.0; + + // Test default tab indicator. + await tester.pumpWidget( + MaterialApp( + home: boilerplate( + theme: theme, + child: Container( + alignment: Alignment.topLeft, + child: TabBar.secondary(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const double indicatorY = 48 - (indicatorWeight / 2.0); + const double indicatorLeft = indicatorWeight / 2.0; + const double indicatorRight = 200.0 - (indicatorWeight / 2.0); + + // Check tab indicator size and color. + expect( + tabBarBox, + paints + // Divider. + ..line(color: theme.colorScheme.outlineVariant) + // Tab indicator. + ..line( + color: theme.colorScheme.primary, + strokeWidth: indicatorWeight, + p1: const Offset(indicatorLeft, indicatorY), + p2: const Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar default overlay (primary)', (WidgetTester tester) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B']; + + const selectedValue = 'A'; + const unselectedValue = 'B'; + await tester.pumpWidget( + buildFrame(tabs: tabs, value: selectedValue, useMaterial3: theme.useMaterial3), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + + await gesture.down(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + await gesture.up(); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text(unselectedValue))); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); + + await gesture.moveTo(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + + await gesture.down(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('TabBar default overlay (secondary)', (WidgetTester tester) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B']; + + const selectedValue = 'A'; + const unselectedValue = 'B'; + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: selectedValue, + secondaryTabBar: true, + useMaterial3: theme.useMaterial3, + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); + + await gesture.down(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.onSurface.withOpacity(0.1)), + ); + await gesture.up(); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text(unselectedValue))); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); + + await gesture.down(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.onSurface.withOpacity(0.1)), + ); + }); + + testWidgets('TabBar tap selects tab', (WidgetTester tester) async { + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C')); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + final TabController controller = DefaultTabController.of(tester.element(find.text('A'))); + expect(controller, isNotNull); + expect(controller.index, 2); + expect(controller.previousIndex, 2); + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C')); + await tester.tap(find.text('B')); + await tester.pump(); + expect(controller.indexIsChanging, true); + await tester.pump(const Duration(seconds: 1)); // finish the animation + expect(controller.index, 1); + expect(controller.previousIndex, 2); + expect(controller.indexIsChanging, false); + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C')); + await tester.tap(find.text('C')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.index, 2); + expect(controller.previousIndex, 1); + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C')); + await tester.tap(find.text('A')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.index, 0); + expect(controller.previousIndex, 2); + }); + + testWidgets('Scrollable TabBar tap selects tab', (WidgetTester tester) async { + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', isScrollable: true)); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + final TabController controller = DefaultTabController.of(tester.element(find.text('A'))); + expect(controller.index, 2); + expect(controller.previousIndex, 2); + + await tester.tap(find.text('C')); + await tester.pumpAndSettle(); + expect(controller.index, 2); + + await tester.tap(find.text('B')); + await tester.pumpAndSettle(); + expect(controller.index, 1); + + await tester.tap(find.text('A')); + await tester.pumpAndSettle(); + expect(controller.index, 0); + }); + + testWidgets('Material2 - Scrollable TabBar tap centers selected tab', ( + WidgetTester tester, + ) async { + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + const tabBarKey = Key('TabBar'); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + useMaterial3: false, + ), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the right of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, greaterThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(400.0, epsilon: 1.0)); + }); + + testWidgets('Material3 - Scrollable TabBar tap centers selected tab', ( + WidgetTester tester, + ) async { + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + const tabBarKey = Key('TabBar'); + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'AAAAAA', isScrollable: true, tabBarKey: tabBarKey), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the right of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, greaterThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(452.0, epsilon: 1.0)); + }); + + testWidgets('Material2 - Scrollable TabBar, with padding, tap centers selected tab', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/112776 + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + const tabBarKey = Key('TabBar'); + const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + useMaterial3: false, + ), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the right of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, greaterThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(400.0, epsilon: 1.0)); + }); + + testWidgets('Material3 - Scrollable TabBar, with padding, tap centers selected tab', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/112776 + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + const tabBarKey = Key('TabBar'); + const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + ), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the right of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, greaterThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(452.0, epsilon: 1.0)); + }); + + testWidgets( + 'Material2 - Scrollable TabBar, with padding and TextDirection.rtl, tap centers selected tab', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/112776 + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + const tabBarKey = Key('TabBar'); + const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + textDirection: TextDirection.rtl, + useMaterial3: false, + ), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the left of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, lessThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(400.0, epsilon: 1.0)); + }, + ); + + testWidgets( + 'Material3 - Scrollable TabBar, with padding and TextDirection.rtl, tap centers selected tab', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/112776 + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + const tabBarKey = Key('TabBar'); + const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + textDirection: TextDirection.rtl, + ), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the left of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, lessThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(348.0, epsilon: 1.0)); + }, + ); + + testWidgets('Material2 - TabBar can be scrolled independent of the selection', ( + WidgetTester tester, + ) async { + final tabs = <String>[ + 'AAAA', + 'BBBB', + 'CCCC', + 'DDDD', + 'EEEE', + 'FFFF', + 'GGGG', + 'HHHH', + 'IIII', + 'JJJJ', + 'KKKK', + 'LLLL', + ]; + const tabBarKey = Key('TabBar'); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'AAAA', + isScrollable: true, + tabBarKey: tabBarKey, + useMaterial3: false, + ), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + // Fling-scroll the TabBar to the left + expect(tester.getCenter(find.text('HHHH')).dx, lessThan(700.0)); + await tester.fling(find.byKey(tabBarKey), const Offset(-200.0, 0.0), 10000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(tester.getCenter(find.text('HHHH')).dx, lessThan(500.0)); + + // Scrolling the TabBar doesn't change the selection + expect(controller.index, 0); + }); + + testWidgets('Material3 - TabBar can be scrolled independent of the selection', ( + WidgetTester tester, + ) async { + final tabs = <String>[ + 'AAAA', + 'BBBB', + 'CCCC', + 'DDDD', + 'EEEE', + 'FFFF', + 'GGGG', + 'HHHH', + 'IIII', + 'JJJJ', + 'KKKK', + 'LLLL', + ]; + const tabBarKey = Key('TabBar'); + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'AAAA', isScrollable: true, tabBarKey: tabBarKey), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + // Fling-scroll the TabBar to the left + expect(tester.getCenter(find.text('HHHH')).dx, lessThan(720.0)); + await tester.fling(find.byKey(tabBarKey), const Offset(-200.0, 0.0), 10000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(tester.getCenter(find.text('HHHH')).dx, lessThan(500.0)); + + // Scrolling the TabBar doesn't change the selection + expect(controller.index, 0); + }); + + testWidgets('TabBarView maintains state', (WidgetTester tester) async { + final tabs = <String>['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE']; + String value = tabs[0]; + + Widget builder() { + return boilerplate( + child: DefaultTabController( + initialIndex: tabs.indexOf(value), + length: tabs.length, + child: TabBarView( + children: tabs.map<Widget>((String name) { + return TabStateMarker(child: Text(name)); + }).toList(), + ), + ), + ); + } + + TabStateMarkerState findStateMarkerState(String name) { + return tester.state(find.widgetWithText(TabStateMarker, name, skipOffstage: false)); + } + + await tester.pumpWidget(builder()); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + + TestGesture gesture = await tester.startGesture(tester.getCenter(find.text(tabs[0]))); + await gesture.moveBy(const Offset(-600.0, 0.0)); + await tester.pump(); + expect(value, equals(tabs[0])); + findStateMarkerState(tabs[1]).marker = 'marked'; + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + value = tabs[controller.index]; + expect(value, equals(tabs[1])); + await tester.pumpWidget(builder()); + expect(findStateMarkerState(tabs[1]).marker, equals('marked')); + + // Move to the third tab. + + gesture = await tester.startGesture(tester.getCenter(find.text(tabs[1]))); + await gesture.moveBy(const Offset(-600.0, 0.0)); + await gesture.up(); + await tester.pump(); + expect(findStateMarkerState(tabs[1]).marker, equals('marked')); + await tester.pump(const Duration(seconds: 1)); + value = tabs[controller.index]; + expect(value, equals(tabs[2])); + await tester.pumpWidget(builder()); + + // The state is now gone. + + expect(find.text(tabs[1]), findsNothing); + + // Move back to the second tab. + + gesture = await tester.startGesture(tester.getCenter(find.text(tabs[2]))); + await gesture.moveBy(const Offset(600.0, 0.0)); + await tester.pump(); + final TabStateMarkerState markerState = findStateMarkerState(tabs[1]); + expect(markerState.marker, isNull); + markerState.marker = 'marked'; + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + value = tabs[controller.index]; + expect(value, equals(tabs[1])); + await tester.pumpWidget(builder()); + expect(findStateMarkerState(tabs[1]).marker, equals('marked')); + }); + + testWidgets('TabBar left/right fling', (WidgetTester tester) async { + final tabs = <String>['LEFT', 'RIGHT']; + + await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT')); + expect(find.text('LEFT'), findsOneWidget); + expect(find.text('RIGHT'), findsOneWidget); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + + final TabController controller = DefaultTabController.of(tester.element(find.text('LEFT'))); + expect(controller.index, 0); + + // Fling to the left, switch from the 'LEFT' tab to the 'RIGHT' + Offset flingStart = tester.getCenter(find.text('LEFT CHILD')); + await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), 10000.0); + await tester.pumpAndSettle(); + expect(controller.index, 1); + expect(find.text('LEFT CHILD'), findsNothing); + expect(find.text('RIGHT CHILD'), findsOneWidget); + + // Fling to the right, switch back to the 'LEFT' tab + flingStart = tester.getCenter(find.text('RIGHT CHILD')); + await tester.flingFrom(flingStart, const Offset(200.0, 0.0), 10000.0); + await tester.pumpAndSettle(); + expect(controller.index, 0); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + }); + + testWidgets('TabBar left/right fling reverse (1)', (WidgetTester tester) async { + final tabs = <String>['LEFT', 'RIGHT']; + + await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT')); + expect(find.text('LEFT'), findsOneWidget); + expect(find.text('RIGHT'), findsOneWidget); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + + final TabController controller = DefaultTabController.of(tester.element(find.text('LEFT'))); + expect(controller.index, 0); + + final Offset flingStart = tester.getCenter(find.text('LEFT CHILD')); + await tester.flingFrom(flingStart, const Offset(200.0, 0.0), 10000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(controller.index, 0); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + }); + + testWidgets('TabBar left/right fling reverse (2)', (WidgetTester tester) async { + final tabs = <String>['LEFT', 'RIGHT']; + + await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT')); + expect(find.text('LEFT'), findsOneWidget); + expect(find.text('RIGHT'), findsOneWidget); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + + final TabController controller = DefaultTabController.of(tester.element(find.text('LEFT'))); + expect(controller.index, 0); + + final Offset flingStart = tester.getCenter(find.text('LEFT CHILD')); + await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), 10000.0); + await tester.pump(); + // this is similar to a test above, but that one does many more pumps + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(controller.index, 1); + expect(find.text('LEFT CHILD'), findsNothing); + expect(find.text('RIGHT CHILD'), findsOneWidget); + }); + + // A regression test for https://github.com/flutter/flutter/issues/5095 + testWidgets('TabBar left/right fling reverse (2)', (WidgetTester tester) async { + final tabs = <String>['LEFT', 'RIGHT']; + + await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT')); + expect(find.text('LEFT'), findsOneWidget); + expect(find.text('RIGHT'), findsOneWidget); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + + final TabController controller = DefaultTabController.of(tester.element(find.text('LEFT'))); + expect(controller.index, 0); + + final Offset flingStart = tester.getCenter(find.text('LEFT CHILD')); + final TestGesture gesture = await tester.startGesture(flingStart); + for (var index = 0; index > 50; index += 1) { + await gesture.moveBy(const Offset(-10.0, 0.0)); + await tester.pump(const Duration(milliseconds: 1)); + } + // End the fling by reversing direction. This should cause not cause + // a change to the selected tab, everything should just settle back to + // where it started. + for (var index = 0; index > 50; index += 1) { + await gesture.moveBy(const Offset(10.0, 0.0)); + await tester.pump(const Duration(milliseconds: 1)); + } + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(controller.index, 0); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + }); + + // A regression test for https://github.com/flutter/flutter/pull/88878. + testWidgets('TabController notifies the index to change when left flinging', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B', 'C']; + late TabController tabController; + + Widget buildTabControllerFrame(BuildContext context, TabController controller) { + tabController = controller; + return MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + controller: controller, + children: const <Widget>[ + Center(child: Text('CHILD A')), + Center(child: Text('CHILD B')), + Center(child: Text('CHILD C')), + ], + ), + ), + ); + } + + await tester.pumpWidget( + TabControllerFrame( + builder: buildTabControllerFrame, + length: tabs.length, + initialIndex: tabs.indexOf('C'), + ), + ); + expect(tabController.index, tabs.indexOf('C')); + + tabController.addListener(() { + final int indexOfB = tabs.indexOf('B'); + expect(tabController.index, indexOfB); + }); + final Offset flingStart = tester.getCenter(find.text('CHILD C')); + await tester.flingFrom(flingStart, const Offset(600, 0.0), 10000.0); + await tester.pumpAndSettle(); + }); + + // A regression test for https://github.com/flutter/flutter/issues/7133 + testWidgets('TabBar fling velocity', (WidgetTester tester) async { + final tabs = <String>[ + 'AAAAAA', + 'BBBBBB', + 'CCCCCC', + 'DDDDDD', + 'EEEEEE', + 'FFFFFF', + 'GGGGGG', + 'HHHHHH', + 'IIIIII', + 'JJJJJJ', + 'KKKKKK', + 'LLLLLL', + ]; + var index = 0; + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300.0, + height: 200.0, + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + isScrollable: true, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + children: tabs.map<Widget>((String name) => Text('${index++}')).toList(), + ), + ), + ), + ), + ), + ), + ); + + // After a small slow fling to the left, we expect the second item to still be visible. + await tester.fling(find.text('AAAAAA'), const Offset(-25.0, 0.0), 100.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + final RenderBox box = tester.renderObject(find.text('BBBBBB')); + expect(box.localToGlobal(Offset.zero).dx, greaterThan(0.0)); + }); + + testWidgets('TabController change notification', (WidgetTester tester) async { + final tabs = <String>['LEFT', 'RIGHT']; + + await tester.pumpWidget(buildLeftRightApp(tabs: tabs, value: 'LEFT')); + final TabController controller = DefaultTabController.of(tester.element(find.text('LEFT'))); + + expect(controller, isNotNull); + expect(controller.index, 0); + + late String value; + controller.addListener(() { + value = tabs[controller.index]; + }); + + await tester.tap(find.text('RIGHT')); + await tester.pumpAndSettle(); + expect(value, 'RIGHT'); + + await tester.tap(find.text('LEFT')); + await tester.pumpAndSettle(); + expect(value, 'LEFT'); + + final Offset leftFlingStart = tester.getCenter(find.text('LEFT CHILD')); + await tester.flingFrom(leftFlingStart, const Offset(-200.0, 0.0), 10000.0); + await tester.pumpAndSettle(); + expect(value, 'RIGHT'); + + final Offset rightFlingStart = tester.getCenter(find.text('RIGHT CHILD')); + await tester.flingFrom(rightFlingStart, const Offset(200.0, 0.0), 10000.0); + await tester.pumpAndSettle(); + expect(value, 'LEFT'); + }); + + testWidgets('Explicit TabController', (WidgetTester tester) async { + final tabs = <String>['LEFT', 'RIGHT']; + late TabController tabController; + + Widget buildTabControllerFrame(BuildContext context, TabController controller) { + tabController = controller; + return MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + controller: controller, + children: const <Widget>[ + Center(child: Text('LEFT CHILD')), + Center(child: Text('RIGHT CHILD')), + ], + ), + ), + ); + } + + await tester.pumpWidget( + TabControllerFrame(builder: buildTabControllerFrame, length: tabs.length, initialIndex: 1), + ); + + expect(find.text('LEFT'), findsOneWidget); + expect(find.text('RIGHT'), findsOneWidget); + expect(find.text('LEFT CHILD'), findsNothing); + expect(find.text('RIGHT CHILD'), findsOneWidget); + expect(tabController.index, 1); + expect(tabController.previousIndex, 1); + expect(tabController.indexIsChanging, false); + expect(tabController.animation!.value, 1.0); + expect(tabController.animation!.status, AnimationStatus.forward); + + tabController.index = 0; + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('LEFT CHILD'), findsOneWidget); + expect(find.text('RIGHT CHILD'), findsNothing); + + tabController.index = 1; + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('LEFT CHILD'), findsNothing); + expect(find.text('RIGHT CHILD'), findsOneWidget); + }); + + testWidgets('TabController listener resets index', (WidgetTester tester) async { + // This is a regression test for the scenario brought up here + // https://github.com/flutter/flutter/pull/7387#pullrequestreview-15630946 + + final tabs = <String>['A', 'B', 'C']; + late TabController tabController; + + Widget buildTabControllerFrame(BuildContext context, TabController controller) { + tabController = controller; + return MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + controller: controller, + children: const <Widget>[ + Center(child: Text('CHILD A')), + Center(child: Text('CHILD B')), + Center(child: Text('CHILD C')), + ], + ), + ), + ); + } + + await tester.pumpWidget( + TabControllerFrame(builder: buildTabControllerFrame, length: tabs.length), + ); + + tabController.animation!.addListener(() { + if (tabController.animation!.status == AnimationStatus.forward) { + tabController.index = 2; + } + expect(tabController.indexIsChanging, true); + }); + + expect(tabController.index, 0); + expect(tabController.indexIsChanging, false); + + tabController.animateTo(1, duration: const Duration(milliseconds: 200), curve: Curves.linear); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(tabController.index, 2); + expect(tabController.indexIsChanging, false); + }); + + testWidgets('TabBar unselectedLabelColor control test', (WidgetTester tester) async { + final TabController controller = createTabController(vsync: const TestVSync(), length: 2); + + late Color firstColor; + late Color secondColor; + + await tester.pumpWidget( + boilerplate( + child: TabBar( + controller: controller, + labelColor: Colors.green[500], + unselectedLabelColor: Colors.blue[500], + tabs: <Widget>[ + Builder( + builder: (BuildContext context) { + firstColor = IconTheme.of(context).color!; + return const Text('First'); + }, + ), + Builder( + builder: (BuildContext context) { + secondColor = IconTheme.of(context).color!; + return const Text('Second'); + }, + ), + ], + ), + ), + ); + + expect(firstColor, equals(Colors.green[500])); + expect(secondColor, equals(Colors.blue[500])); + }); + + testWidgets('TabBarView page left and right test', (WidgetTester tester) async { + final TabController controller = createTabController(vsync: const TestVSync(), length: 2); + + await tester.pumpWidget( + boilerplate( + child: TabBarView( + controller: controller, + children: const <Widget>[Text('First'), Text('Second')], + ), + ), + ); + + expect(controller.index, equals(0)); + + TestGesture gesture = await tester.startGesture(const Offset(100.0, 100.0)); + expect(controller.index, equals(0)); + + // Drag to the left and right, by less than the TabBarView's width. + // The selected index (controller.index) should not change. + await gesture.moveBy(const Offset(-100.0, 0.0)); + await gesture.moveBy(const Offset(100.0, 0.0)); + expect(controller.index, equals(0)); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsNothing); + + // Drag more than the TabBarView's width to the right. This forces + // the selected index to change to 1. + await gesture.moveBy(const Offset(-500.0, 0.0)); + await gesture.up(); + await tester.pump(); // start the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(controller.index, equals(1)); + expect(find.text('First'), findsNothing); + expect(find.text('Second'), findsOneWidget); + + gesture = await tester.startGesture(const Offset(100.0, 100.0)); + expect(controller.index, equals(1)); + + // Drag to the left and right, by less than the TabBarView's width. + // The selected index (controller.index) should not change. + await gesture.moveBy(const Offset(-100.0, 0.0)); + await gesture.moveBy(const Offset(100.0, 0.0)); + expect(controller.index, equals(1)); + expect(find.text('First'), findsNothing); + expect(find.text('Second'), findsOneWidget); + + // Drag more than the TabBarView's width to the left. This forces + // the selected index to change back to 0. + await gesture.moveBy(const Offset(500.0, 0.0)); + await gesture.up(); + await tester.pump(); // start the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(controller.index, equals(0)); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsNothing); + }); + + testWidgets('TabBar animationDuration sets indicator animation duration', ( + WidgetTester tester, + ) async { + const animationDuration = Duration(milliseconds: 100); + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'B', animationDuration: animationDuration), + ); + final TabController controller = DefaultTabController.of(tester.element(find.text('A'))); + + await tester.tap(find.text('A')); + await tester.pump(); + expect(controller.indexIsChanging, true); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(animationDuration); + expect(controller.index, 0); + expect(controller.previousIndex, 1); + expect(controller.indexIsChanging, false); + + //Test when index diff is greater than 1 + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'B', animationDuration: animationDuration), + ); + await tester.tap(find.text('C')); + await tester.pump(); + expect(controller.indexIsChanging, true); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(animationDuration); + expect(controller.index, 2); + expect(controller.previousIndex, 0); + expect(controller.indexIsChanging, false); + }); + + testWidgets('ensureVisible does not move TabViews', (WidgetTester tester) async { + final controller = TabController(length: 3, vsync: const TestVSync()); + addTearDown(controller.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TabBarView( + controller: controller, + children: List<ListView>.generate(3, (int pageIndex) { + return ListView( + key: Key('list_$pageIndex'), + children: List<Widget>.generate(100, (int listIndex) { + return Row( + children: <Widget>[ + Container( + key: Key('${pageIndex}_${listIndex}_0'), + color: Colors.red, + width: 200, + height: 10, + ), + Container( + key: Key('${pageIndex}_${listIndex}_1'), + color: Colors.blue, + width: 200, + height: 10, + ), + Container( + key: Key('${pageIndex}_${listIndex}_2'), + color: Colors.green, + width: 200, + height: 10, + ), + ], + ); + }), + ); + }), + ), + ), + ); + + final Finder targetMidRightPage0 = find.byKey(const Key('0_25_2')); + final Finder targetMidRightPage1 = find.byKey(const Key('1_25_2')); + final Finder targetMidLeftPage1 = find.byKey(const Key('1_25_0')); + + expect(find.byKey(const Key('list_0')), findsOneWidget); + expect(find.byKey(const Key('list_1')), findsNothing); + expect(targetMidRightPage0, findsOneWidget); + expect(targetMidRightPage1, findsNothing); + expect(targetMidLeftPage1, findsNothing); + + await tester.ensureVisible(targetMidRightPage0); + await tester.pumpAndSettle(); + expect(targetMidRightPage0, findsOneWidget); + expect(targetMidRightPage1, findsNothing); + expect(targetMidLeftPage1, findsNothing); + + controller.index = 1; + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('list_0')), findsNothing); + expect(find.byKey(const Key('list_1')), findsOneWidget); + await tester.ensureVisible(targetMidRightPage1); + await tester.pumpAndSettle(); + + expect(targetMidRightPage0, findsNothing); + expect(targetMidRightPage1, findsOneWidget); + expect(targetMidLeftPage1, findsOneWidget); + + await tester.ensureVisible(targetMidLeftPage1); + await tester.pumpAndSettle(); + + expect(targetMidRightPage0, findsNothing); + expect(targetMidRightPage1, findsOneWidget); + expect(targetMidLeftPage1, findsOneWidget); + }); + + testWidgets('TabBarView controller sets animation duration', (WidgetTester tester) async { + const animationDuration = Duration(milliseconds: 100); + final tabs = <String>['A', 'B', 'C']; + + final TabController tabController = createTabController( + vsync: const TestVSync(), + initialIndex: 1, + length: tabs.length, + animationDuration: animationDuration, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 400); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 400); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(animationDuration); + expect(position.pixels, 800); + }); + + testWidgets('TabBarView animation can be interrupted', (WidgetTester tester) async { + const animationDuration = Duration(seconds: 2); + final tabs = <String>['A', 'B', 'C']; + + final TabController tabController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: animationDuration, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget<PageView>(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + expect(position.pixels, 0.0); + + await tester.tap(find.text('C')); + await tester.pump(const Duration(milliseconds: 10)); // TODO(bleroux): find why this is needed. + + // Runs the animation for half of the animation duration. + await tester.pump(const Duration(seconds: 1)); + + // The position should be between page 1 and page 2. + expect(position.pixels, greaterThan(400.0)); + expect(position.pixels, lessThan(800.0)); + + // Switch to another tab before the end of the animation. + await tester.tap(find.text('A')); + await tester.pump(const Duration(milliseconds: 10)); // TODO(bleroux): find why this is needed. + await tester.pump(animationDuration); + expect(position.pixels, 0.0); + + await tester.pumpAndSettle(); // Finish the animation. + }); + + testWidgets('TabBarView viewportFraction sets PageView viewport fraction', ( + WidgetTester tester, + ) async { + const animationDuration = Duration(milliseconds: 100); + final tabs = <String>['A', 'B', 'C']; + + final TabController tabController = createTabController( + vsync: const TestVSync(), + initialIndex: 1, + length: tabs.length, + animationDuration: animationDuration, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + viewportFraction: 0.8, + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + + // The TabView was initialized with viewportFraction as 0.8 + // So it's expected the PageView inside would obtain the same viewportFraction + expect(pageController.viewportFraction, 0.8); + }); + + testWidgets('TabBarView viewportFraction is 1 by default', (WidgetTester tester) async { + const animationDuration = Duration(milliseconds: 100); + final tabs = <String>['A', 'B', 'C']; + + final TabController tabController = createTabController( + vsync: const TestVSync(), + initialIndex: 1, + length: tabs.length, + animationDuration: animationDuration, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + + // The TabView was initialized with default viewportFraction + // So it's expected the PageView inside would obtain the value 1 + expect(pageController.viewportFraction, 1); + }); + + testWidgets('TabBarView viewportFraction can be updated', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/135557. + final tabs = <String>['A', 'B', 'C']; + TabController? controller; + + Widget buildFrame(double viewportFraction) { + controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: 1, + ); + return boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: controller, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + viewportFraction: viewportFraction, + controller: controller, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ); + } + + await tester.pumpWidget(buildFrame(0.8)); + PageView pageView = tester.widget(find.byType(PageView)); + PageController pageController = pageView.controller!; + expect(pageController.viewportFraction, 0.8); + + // Rebuild with a different viewport fraction. + await tester.pumpWidget(buildFrame(0.5)); + pageView = tester.widget(find.byType(PageView)); + pageController = pageView.controller!; + expect(pageController.viewportFraction, 0.5); + }); + + testWidgets('TabBarView has clipBehavior Clip.hardEdge by default', (WidgetTester tester) async { + final tabs = <Widget>[const Text('First'), const Text('Second')]; + + Widget builder() { + return boilerplate( + child: DefaultTabController( + length: tabs.length, + child: TabBarView(children: tabs), + ), + ); + } + + await tester.pumpWidget(builder()); + final TabBarView tabBarView = tester.widget(find.byType(TabBarView)); + expect(tabBarView.clipBehavior, Clip.hardEdge); + }); + + testWidgets('TabBarView sets clipBehavior correctly', (WidgetTester tester) async { + final tabs = <Widget>[const Text('First'), const Text('Second')]; + + Widget builder() { + return boilerplate( + child: DefaultTabController( + length: tabs.length, + child: TabBarView(clipBehavior: Clip.none, children: tabs), + ), + ); + } + + await tester.pumpWidget(builder()); + final PageView pageView = tester.widget(find.byType(PageView)); + expect(pageView.clipBehavior, Clip.none); + }); + + testWidgets('TabBar tap skips indicator animation when disabled in controller', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B']; + + const indicatorColor = Color(0xFFFF0000); + await tester.pumpWidget( + buildFrame( + useMaterial3: false, + tabs: tabs, + value: 'A', + indicatorColor: indicatorColor, + animationDuration: Duration.zero, + ), + ); + + final RenderBox box = tester.renderObject(find.byType(TabBar)); + final canvas = TabIndicatorRecordingCanvas(indicatorColor); + final context = TestRecordingPaintingContext(canvas); + + box.paint(context, Offset.zero); + final Rect indicatorRect0 = canvas.indicatorRect; + expect(indicatorRect0.left, 0.0); + expect(indicatorRect0.width, 400.0); + expect(indicatorRect0.height, 2.0); + + await tester.tap(find.text('B')); + await tester.pump(); + box.paint(context, Offset.zero); + final Rect indicatorRect2 = canvas.indicatorRect; + expect(indicatorRect2.left, 400.0); + expect(indicatorRect2.width, 400.0); + expect(indicatorRect2.height, 2.0); + }); + + testWidgets('TabBar tap changes index instantly when animation is disabled in controller', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: Duration.zero)); + final TabController controller = DefaultTabController.of(tester.element(find.text('A'))); + + await tester.tap(find.text('A')); + await tester.pump(); + expect(controller.index, 0); + expect(controller.previousIndex, 1); + expect(controller.indexIsChanging, false); + + //Test when index diff is greater than 1 + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', animationDuration: Duration.zero)); + await tester.tap(find.text('C')); + await tester.pump(); + expect(controller.index, 2); + expect(controller.previousIndex, 0); + expect(controller.indexIsChanging, false); + }); + + testWidgets('Scrollable TabBar does not have overscroll indicator', (WidgetTester tester) async { + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'A', isScrollable: true)); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + }); + + testWidgets('TabBar should not throw when animation is disabled in controller', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/102600 + final tabs = <String>['A']; + + Widget buildWithTabBarView() { + return boilerplate( + child: DefaultTabController( + animationDuration: Duration.zero, + length: tabs.length, + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + isScrollable: true, + ), + Flexible( + child: TabBarView( + children: List<Widget>.generate(tabs.length, (int index) => Text('Tab $index')), + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildWithTabBarView()); + TabController controller = DefaultTabController.of(tester.element(find.text('A'))); + expect(controller.index, 0); + + tabs.add('B'); + await tester.pumpWidget(buildWithTabBarView()); + tabs.add('C'); + await tester.pumpWidget(buildWithTabBarView()); + await tester.tap(find.text('C')); + await tester.pumpAndSettle(); + controller = DefaultTabController.of(tester.element(find.text('A'))); + expect(controller.index, 2); + + expect(tester.takeException(), isNull); + }); + + testWidgets('TabBarView skips animation when disabled in controller', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B', 'C']; + final TabController tabController = createTabController( + vsync: const TestVSync(), + initialIndex: 1, + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 400); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 800); + }); + + testWidgets('TabBarView skips animation when disabled in controller - skip tabs', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B', 'C']; + final TabController tabController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 0); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 800); + }); + + testWidgets('TabBarView skips animation when disabled in controller - skip tabs twice', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/110970 + final tabs = <String>['A', 'B', 'C']; + final TabController tabController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 0); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 800); + + await tester.tap(find.text('A')); + await tester.pump(); + expect(position.pixels, 0); + }); + + testWidgets( + 'TabBarView skips animation when disabled in controller - skip tabs followed by single tab navigation', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/110970 + final tabs = <String>['A', 'B', 'C']; + final TabController tabController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 0); + await tester.tap(find.text('C')); + await tester.pump(); + expect(position.pixels, 800); + + await tester.tap(find.text('B')); + await tester.pump(); + expect(position.pixels, 400); + + await tester.tap(find.text('A')); + await tester.pump(); + expect(position.pixels, 0); + }, + ); + + testWidgets('TabBarView skips animation when disabled in controller - two tabs', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B']; + final TabController tabController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + animationDuration: Duration.zero, + ); + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + ], + ), + ), + ], + ), + ), + ); + + expect(tabController.index, 0); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + expect(position.pixels, 0); + await tester.tap(find.text('B')); + await tester.pump(); + expect(position.pixels, 400); + }); + + testWidgets('TabBar tap animates the selection indicator', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/7479 + + final tabs = <String>['A', 'B']; + + const indicatorColor = Color(0xFFFF0000); + await tester.pumpWidget( + buildFrame(useMaterial3: false, tabs: tabs, value: 'A', indicatorColor: indicatorColor), + ); + + final RenderBox box = tester.renderObject(find.byType(TabBar)); + final canvas = TabIndicatorRecordingCanvas(indicatorColor); + final context = TestRecordingPaintingContext(canvas); + + box.paint(context, Offset.zero); + final Rect indicatorRect0 = canvas.indicatorRect; + expect(indicatorRect0.left, 0.0); + expect(indicatorRect0.width, 400.0); + expect(indicatorRect0.height, 2.0); + + await tester.tap(find.text('B')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + box.paint(context, Offset.zero); + final Rect indicatorRect1 = canvas.indicatorRect; + expect(indicatorRect1.left, greaterThan(indicatorRect0.left)); + expect(indicatorRect1.right, lessThan(800.0)); + expect(indicatorRect1.height, 2.0); + + await tester.pump(const Duration(milliseconds: 300)); + box.paint(context, Offset.zero); + final Rect indicatorRect2 = canvas.indicatorRect; + expect(indicatorRect2.left, 400.0); + expect(indicatorRect2.width, 400.0); + expect(indicatorRect2.height, 2.0); + }); + + testWidgets('TabBarView child disposed during animation', (WidgetTester tester) async { + // This is a regression test for this patch: + // https://github.com/flutter/flutter/pull/9015 + + final TabController controller = createTabController(vsync: const TestVSync(), length: 2); + + Widget buildFrame() { + return boilerplate( + child: TabBar( + key: UniqueKey(), + controller: controller, + tabs: const <Widget>[Text('A'), Text('B')], + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + // The original TabBar will be disposed. The controller should no + // longer have any listeners from the original TabBar. + await tester.pumpWidget(buildFrame()); + + controller.index = 1; + await tester.pump(const Duration(milliseconds: 300)); + }); + + group('TabBarView children updated', () { + Widget buildFrameWithMarker(List<String> log, String marker) { + return MaterialApp( + home: DefaultTabController( + animationDuration: const Duration(seconds: 1), + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab(text: 'A'), + Tab(text: 'B'), + Tab(text: 'C'), + ], + ), + title: const Text('Tabs Test'), + ), + body: TabBarView( + children: <Widget>[ + TabBody(index: 0, log: log, marker: marker), + TabBody(index: 1, log: log, marker: marker), + TabBody(index: 2, log: log, marker: marker), + ], + ), + ), + ), + ); + } + + testWidgets('TabBarView children can be updated during animation to an adjacent tab', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/107399 + final log = <String>[]; + + const initialMarker = 'before'; + await tester.pumpWidget(buildFrameWithMarker(log, initialMarker)); + expect(log, <String>['init: 0']); + expect(find.text('0-$initialMarker'), findsOneWidget); + + // Select the second tab and wait until the transition starts + await tester.tap(find.text('B')); + await tester.pump(const Duration(milliseconds: 100)); + + // Check that both TabBody's are instantiated while the transition is animating + await tester.pump(const Duration(milliseconds: 400)); + expect(log, <String>['init: 0', 'init: 1']); + + // Update the TabBody's states while the transition is animating + const updatedMarker = 'after'; + await tester.pumpWidget(buildFrameWithMarker(log, updatedMarker)); + + // Wait until the transition ends + await tester.pumpAndSettle(); + + // The TabBody state of the second TabBar should have been updated + expect(find.text('1-$initialMarker'), findsNothing); + expect(find.text('1-$updatedMarker'), findsOneWidget); + }); + + testWidgets('TabBarView children can be updated during animation to a non adjacent tab', ( + WidgetTester tester, + ) async { + final log = <String>[]; + + const initialMarker = 'before'; + await tester.pumpWidget(buildFrameWithMarker(log, initialMarker)); + expect(log, <String>['init: 0']); + expect(find.text('0-$initialMarker'), findsOneWidget); + + // Select the third tab and wait until the transition starts + await tester.tap(find.text('C')); + await tester.pump(const Duration(milliseconds: 100)); + + // Check that both TabBody's are instantiated while the transition is animating + await tester.pump(const Duration(milliseconds: 400)); + expect(log, <String>['init: 0', 'init: 2']); + + // Update the TabBody's states while the transition is animating + const updatedMarker = 'after'; + await tester.pumpWidget(buildFrameWithMarker(log, updatedMarker)); + + // Wait until the transition ends + await tester.pumpAndSettle(); + + // The TabBody state of the third TabBar should have been updated + expect(find.text('2-$initialMarker'), findsNothing); + expect(find.text('2-$updatedMarker'), findsOneWidget); + }); + }); + + testWidgets('TabBarView scrolls end close to a new page', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/9375 + + final TabController tabController = createTabController( + vsync: const TestVSync(), + initialIndex: 1, + length: 3, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.expand( + child: Center( + child: SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ), + ), + ), + ); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + + expect(position.pixels, 400.0); + + // Not close enough to switch to page 2 + pageController.jumpTo(500.0); + expect(tabController.index, 1); + + // Close enough to switch to page 2 + pageController.jumpTo(700.0); + expect(tabController.index, 2); + + // Same behavior going left: not left enough to get to page 0 + pageController.jumpTo(300.0); + expect(tabController.index, 1); + + // Left enough to get to page 0 + pageController.jumpTo(100.0); + expect(tabController.index, 0); + }); + + testWidgets('On going TabBarView animation can be interrupted by a new animation', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/132293. + + final tabs = <String>['A', 'B', 'C']; + final TabController tabController = createTabController( + length: tabs.length, + vsync: const TestVSync(), + ); + + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + controller: tabController, + ), + SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ], + ), + ), + ); + + // First page is visible. + expect(tabController.index, 0); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Animate to the second page. + tabController.animateTo(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Animate back to the first page before the previous animation ends. + tabController.animateTo(0); + await tester.pumpAndSettle(); + + // First page should be visible. + expect(tabController.index, 0); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + }); + + testWidgets('Can switch to non-neighboring tab in nested TabBarView without crashing', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/18756 + final TabController mainTabController = createTabController( + length: 4, + vsync: const TestVSync(), + ); + final TabController nestedTabController = createTabController( + length: 2, + vsync: const TestVSync(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Exception for Nested Tabs'), + bottom: TabBar( + controller: mainTabController, + tabs: const <Widget>[ + Tab(icon: Icon(Icons.add), text: 'A'), + Tab(icon: Icon(Icons.add), text: 'B'), + Tab(icon: Icon(Icons.add), text: 'C'), + Tab(icon: Icon(Icons.add), text: 'D'), + ], + ), + ), + body: TabBarView( + controller: mainTabController, + children: <Widget>[ + Container(color: Colors.red), + ColoredBox( + color: Colors.blue, + child: Column( + children: <Widget>[ + TabBar( + controller: nestedTabController, + tabs: const <Tab>[ + Tab(text: 'Yellow'), + Tab(text: 'Grey'), + ], + ), + Expanded( + child: TabBarView( + controller: nestedTabController, + children: <Widget>[ + Container(color: Colors.yellow), + Container(color: Colors.grey), + ], + ), + ), + ], + ), + ), + Container(color: Colors.green), + Container(color: Colors.indigo), + ], + ), + ), + ), + ); + + // expect first tab to be selected + expect(mainTabController.index, 0); + + // tap on third tab + await tester.tap(find.text('C')); + await tester.pumpAndSettle(); + + // expect third tab to be selected without exceptions + expect(mainTabController.index, 2); + }); + + testWidgets('TabBarView can warp when child is kept alive and contains ink', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/57662. + final TabController controller = createTabController(vsync: const TestVSync(), length: 3); + + await tester.pumpWidget( + boilerplate( + child: TabBarView( + controller: controller, + children: const <Widget>[ + Text('Page 1'), + Text('Page 2'), + TabKeepAliveInk(title: 'Page 3'), + ], + ), + ), + ); + + expect(controller.index, equals(0)); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + controller.index = 2; + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsNothing); + expect(find.text('Page 3'), findsOneWidget); + + controller.index = 0; + await tester.pumpAndSettle(); + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + expect(tester.takeException(), isNull); + }); + + testWidgets('TabBarView scrolls end close to a new page with custom physics', ( + WidgetTester tester, + ) async { + final TabController tabController = createTabController( + vsync: const TestVSync(), + initialIndex: 1, + length: 3, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.expand( + child: Center( + child: SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + physics: const TabBarTestScrollPhysics(), + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ), + ), + ), + ); + + expect(tabController.index, 1); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + // The TabBarView's page width is 400, so page 0 is at scroll offset 0.0, + // page 1 is at 400.0, page 2 is at 800.0. + + expect(position.pixels, 400.0); + + // Not close enough to switch to page 2 + pageController.jumpTo(500.0); + expect(tabController.index, 1); + + // Close enough to switch to page 2 + pageController.jumpTo(700.0); + expect(tabController.index, 2); + + // Same behavior going left: not left enough to get to page 0 + pageController.jumpTo(300.0); + expect(tabController.index, 1); + + // Left enough to get to page 0 + pageController.jumpTo(100.0); + expect(tabController.index, 0); + }); + + testWidgets('TabBar accepts custom physics', (WidgetTester tester) async { + final tabs = List<Tab>.generate(20, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: controller, + tabs: tabs, + physics: const TabBarTestScrollPhysics(), + ), + ), + ); + + final TabBar tabBar = tester.widget(find.byType(TabBar)); + final double position = tabBar.physics!.applyPhysicsToUserOffset(TabMockScrollMetrics(), 10); + + expect(position, equals(20)); + }); + + testWidgets('TabBar accepts external TabBarScrollController', (WidgetTester tester) async { + final tabs = List<Tab>.generate(6, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController tabBarController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + final tabScrollController = TabBarScrollController(); + addTearDown(tabScrollController.dispose); + + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: tabBarController, + scrollController: tabScrollController, + tabs: tabs, + ), + ), + ); + + final ScrollableState scrollableState = tester.state<ScrollableState>(find.byType(Scrollable)); + + tabScrollController.jumpTo(50); + await tester.pump(); + + expect(scrollableState.position.pixels, 50); + }); + + testWidgets('TabBar attaches state to internal TabBarScrollController in didUpdateWidget', ( + WidgetTester tester, + ) async { + final tabs = List<Tab>.generate(6, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController tabBarController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + final tabScrollController = TabBarScrollController(); + addTearDown(tabScrollController.dispose); + + // Attach to the external controller. + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: tabBarController, + scrollController: tabScrollController, + tabs: tabs, + ), + ), + ); + + final double oldPixels = tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels; + + // Trigger didUpdateWidget for the change to the internal controller. + await tester.pumpWidget( + boilerplate( + child: TabBar(isScrollable: true, controller: tabBarController, tabs: tabs), + ), + ); + + // Creating the new scroll position should not throw. + expect(tester.takeException(), isNull); + final ScrollableState scrollableState = tester.state<ScrollableState>(find.byType(Scrollable)); + + // The pixels of the new position should be the same as before the update. + expect(scrollableState.position.pixels, oldPixels); + expect(tabScrollController.debugCheckHasTabBarState, throwsAssertionError); + }); + + testWidgets('TabBar attaches state to external TabBarScrollController in didUpdateWidget', ( + WidgetTester tester, + ) async { + final tabs = List<Tab>.generate(6, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController tabBarController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + final tabScrollController = TabBarScrollController(); + addTearDown(tabScrollController.dispose); + + // Attach to the internal controller. + await tester.pumpWidget( + boilerplate( + child: TabBar(isScrollable: true, controller: tabBarController, tabs: tabs), + ), + ); + + final double oldPixels = tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels; + + // Trigger didUpdateWidget for the change to the external controller. + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: tabBarController, + scrollController: tabScrollController, + tabs: tabs, + ), + ), + ); + + // Creating the new scroll position should not throw. + expect(tester.takeException(), isNull); + final ScrollableState scrollableState = tester.state<ScrollableState>(find.byType(Scrollable)); + + // The pixels of the new position should be the same as before the update. + expect(scrollableState.position.pixels, oldPixels); + + // This should not throw, since the tab bar is attached to the external controller. + expect(tabScrollController.debugCheckHasTabBarState(), isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'TabBar correctly detaches old external TabBarScrollController when switched to a new one', + (WidgetTester tester) async { + final tabs = <Tab>[for (int i = 0; i < 10; i++) Tab(text: 'Tab $i')]; + + final tabController = TabController(length: tabs.length, vsync: const TestVSync()); + addTearDown(tabController.dispose); + + final controllerA = TabBarScrollController(); + final controllerB = TabBarScrollController(); + addTearDown(controllerA.dispose); + addTearDown(controllerB.dispose); + + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: tabController, + scrollController: controllerA, + tabs: tabs, + ), + ), + ); + + expect(controllerA.debugCheckHasTabBarState(), isTrue); + expect(() => controllerB.debugCheckHasTabBarState(), throwsAssertionError); + + // Switch to controllerB + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: tabController, + scrollController: controllerB, + tabs: tabs, + ), + ), + ); + + expect(controllerB.debugCheckHasTabBarState(), isTrue); + expect(() => controllerA.debugCheckHasTabBarState(), throwsAssertionError); + }, + ); + + testWidgets('TabBar correctly detaches external TabBarScrollController when disposed', ( + WidgetTester tester, + ) async { + final tabs = <Tab>[for (int i = 0; i < 10; i++) Tab(text: 'Tab $i')]; + + final tabController = TabController(length: tabs.length, vsync: const TestVSync()); + addTearDown(tabController.dispose); + final controller = TabBarScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + boilerplate( + child: TabBar( + isScrollable: true, + controller: tabController, + scrollController: controller, + tabs: tabs, + ), + ), + ); + + expect(controller.debugCheckHasTabBarState(), isTrue); + + // Dispose the TabBar by pumping a different widget + await tester.pumpWidget(boilerplate(child: const SizedBox.shrink())); + + expect(() => controller.debugCheckHasTabBarState(), throwsAssertionError); + }); + + // Regression test for https://github.com/flutter/flutter/issues/124608 + testWidgets('TabBar can be wrapped with RawScrollbar', (WidgetTester tester) async { + final tabs = List<Tab>.generate(6, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController tabBarController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + final tabScrollController = TabBarScrollController(); + addTearDown(tabScrollController.dispose); + + await tester.pumpWidget( + boilerplate( + child: RawScrollbar( + controller: tabScrollController, + child: TabBar( + isScrollable: true, + controller: tabBarController, + scrollController: tabScrollController, + tabs: tabs, + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + // Regression test for https://github.com/flutter/flutter/issues/124608 + testWidgets('TabBar can show scrollbar on hover', (WidgetTester tester) async { + final tabs = List<Tab>.generate(6, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController tabBarController = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + final tabScrollController = TabBarScrollController(); + addTearDown(tabScrollController.dispose); + + await tester.pumpWidget( + boilerplate( + child: RawScrollbar( + controller: tabScrollController, + child: TabBar( + isScrollable: true, + controller: tabBarController, + scrollController: tabScrollController, + tabs: tabs, + ), + ), + ), + ); + + final Finder tab1 = find.text('TAB #1'); + expect(tab1, findsOneWidget); + + // Hover over the tab bar and verify that the scrollbar is shown. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(tab1)); + await tester.pumpAndSettle(); + + expect(find.byType(RawScrollbar), paints..rect()); + expect(tester.takeException(), isNull); + }); + + testWidgets('Scrollable TabBar with a non-zero TabController initialIndex', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/9374 + + final tabs = List<Tab>.generate(20, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.length - 1, + ); + + await tester.pumpWidget( + boilerplate( + child: TabBar(isScrollable: true, controller: controller, tabs: tabs), + ), + ); + + // The initialIndex tab should be visible and right justified + expect(find.text('TAB #19'), findsOneWidget); + + // Tabs have a minimum width of 72.0 and 'TAB #19' is wider than + // that. Tabs are padded horizontally with kTabLabelPadding. + final double tabRight = 800.0 - kTabLabelPadding.right; + + expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB #19')).dx, moreOrLessEquals(tabRight)); + }); + + testWidgets('Indicator elastic animation', (WidgetTester tester) async { + const indicatorWidth = 50.0; + final tabs = List<Widget>.generate(4, (int index) { + return Tab( + key: ValueKey<int>(index), + child: const SizedBox(width: indicatorWidth), + ); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + MaterialApp( + home: boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + const fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + var toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.0), + ), + ); + + controller.offset = 0.2; + await tester.pump(); + toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2), + ), + ); + + controller.offset = 0.5; + await tester.pump(); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.5), + ), + ); + + controller.offset = 1; + await tester.pump(); + // When the animation is completed, no stretch is applied. + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 1.0), + ), + ); + }); + + testWidgets('TabBar with indicatorWeight, indicatorPadding (LTR)', (WidgetTester tester) async { + const indicatorColor = Color(0xFF00FF00); + const indicatorWeight = 8.0; + const padLeft = 8.0; + const padRight = 4.0; + + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorWeight: indicatorWeight, + indicatorColor: indicatorColor, + indicatorPadding: const EdgeInsets.only(left: padLeft, right: padRight), + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 54.0); // 54 = _kTabHeight(46) + indicatorWeight(8.0) + + const double indicatorY = 54.0 - indicatorWeight / 2.0; + double indicatorLeft = padLeft + indicatorWeight / 2.0; + double indicatorRight = 200.0 - (padRight + indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + color: indicatorColor, + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + // Select tab 3 + controller.index = 3; + await tester.pumpAndSettle(); + + indicatorLeft = 600.0 + padLeft + indicatorWeight / 2.0; + indicatorRight = 800.0 - (padRight + indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + color: indicatorColor, + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar with indicatorWeight, indicatorPadding (RTL)', (WidgetTester tester) async { + const indicatorColor = Color(0xFF00FF00); + const indicatorWeight = 8.0; + const padLeft = 8.0; + const padRight = 4.0; + + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorWeight: indicatorWeight, + indicatorColor: indicatorColor, + indicatorPadding: const EdgeInsets.only(left: padLeft, right: padRight), + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 54.0); // 54 = _kTabHeight(46) + indicatorWeight(8.0) + expect(tabBarBox.size.width, 800.0); + + const double indicatorY = 54.0 - indicatorWeight / 2.0; + double indicatorLeft = 600.0 + padLeft + indicatorWeight / 2.0; + double indicatorRight = 800.0 - padRight - indicatorWeight / 2.0; + + expect( + tabBarBox, + paints..line( + color: indicatorColor, + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + // Select tab 3 + controller.index = 3; + await tester.pumpAndSettle(); + + indicatorLeft = padLeft + indicatorWeight / 2.0; + indicatorRight = 200.0 - padRight - indicatorWeight / 2.0; + + expect( + tabBarBox, + paints..line( + color: indicatorColor, + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar changes indicator attributes', (WidgetTester tester) async { + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + var indicatorColor = const Color(0xFF00FF00); + var indicatorWeight = 8.0; + var padLeft = 8.0; + var padRight = 4.0; + + Widget buildFrame() { + return boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorWeight: indicatorWeight, + indicatorColor: indicatorColor, + indicatorPadding: EdgeInsets.only(left: padLeft, right: padRight), + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 54.0); // 54 = _kTabHeight(46) + indicatorWeight(8.0) + + double indicatorY = 54.0 - indicatorWeight / 2.0; + double indicatorLeft = padLeft + indicatorWeight / 2.0; + double indicatorRight = 200.0 - (padRight + indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + color: indicatorColor, + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + indicatorColor = const Color(0xFF0000FF); + indicatorWeight = 4.0; + padLeft = 4.0; + padRight = 8.0; + + await tester.pumpWidget(buildFrame()); + + expect(tabBarBox.size.height, 50.0); // 54 = _kTabHeight(46) + indicatorWeight(4.0) + + indicatorY = 50.0 - indicatorWeight / 2.0; + indicatorLeft = padLeft + indicatorWeight / 2.0; + indicatorRight = 200.0 - (padRight + indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + color: indicatorColor, + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar with directional indicatorPadding (LTR)', (WidgetTester tester) async { + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + const indicatorWeight = 2.0; // the default + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorPadding: const EdgeInsetsDirectional.only(start: 100.0), + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = kTabLabelPadding.left; + double tabRight = tabLeft + 130.0; + double tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + // Tab 0 selected, indicator padding resolves to left: 100.0 + const double indicatorLeft = 100.0 + indicatorWeight / 2.0; + final double indicatorRight = 130.0 + kTabLabelPadding.horizontal - indicatorWeight / 2.0; + final double indicatorY = tabBottom + indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar with directional indicatorPadding (RTL)', (WidgetTester tester) async { + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + const indicatorWeight = 2.0; // the default + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorPadding: const EdgeInsetsDirectional.only(start: 100.0), + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab2 width = 150, height = 50 + double tabLeft = kTabLabelPadding.left; + double tabRight = tabLeft + 150.0; + double tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0; + double tabBottom = tabTop + 50.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab0 width = 130, height = 30 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 130.0; + tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0; + tabBottom = tabTop + 30.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab 0 selected, indicator padding resolves to right: 100.0 + final double indicatorLeft = tabLeft - kTabLabelPadding.left + indicatorWeight / 2.0; + final double indicatorRight = tabRight + kTabLabelPadding.left - indicatorWeight / 2.0 - 100.0; + const double indicatorY = 50.0 + indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar with custom indicator and indicatorPadding(LTR)', ( + WidgetTester tester, + ) async { + const indicatorColor = Color(0xFF00FF00); + const padTop = 10.0; + const padBottom = 12.0; + const padLeft = 8.0; + const padRight = 4.0; + const Decoration indicator = BoxDecoration(color: indicatorColor); + + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicator: indicator, + indicatorPadding: const EdgeInsets.fromLTRB(padLeft, padTop, padRight, padBottom), + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + // 48 = _kTabHeight(46) + indicatorWeight(2.0) ~default + + const double indicatorBottom = 48.0 - padBottom; + const indicatorTop = padTop; + var indicatorLeft = padLeft; + double indicatorRight = 200.0 - padRight; + + expect( + tabBarBox, + paints..rect( + rect: Rect.fromLTRB(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom), + color: indicatorColor, + ), + ); + + // Select tab 3 + controller.index = 3; + await tester.pumpAndSettle(); + + indicatorLeft = 600.0 + padLeft; + indicatorRight = 800.0 - padRight; + + expect( + tabBarBox, + paints..rect( + rect: Rect.fromLTRB(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom), + color: indicatorColor, + ), + ); + }); + + testWidgets('TabBar with custom indicator and indicatorPadding (RTL)', ( + WidgetTester tester, + ) async { + const indicatorColor = Color(0xFF00FF00); + const padTop = 10.0; + const padBottom = 12.0; + const padLeft = 8.0; + const padRight = 4.0; + const Decoration indicator = BoxDecoration(color: indicatorColor); + + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicator: indicator, + indicatorPadding: const EdgeInsets.fromLTRB(padLeft, padTop, padRight, padBottom), + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + // 48 = _kTabHeight(46) + indicatorWeight(2.0) ~default + expect(tabBarBox.size.width, 800.0); + const double indicatorBottom = 48.0 - padBottom; + const indicatorTop = padTop; + double indicatorLeft = 600.0 + padLeft; + double indicatorRight = 800.0 - padRight; + + expect( + tabBarBox, + paints..rect( + rect: Rect.fromLTRB(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom), + color: indicatorColor, + ), + ); + + // Select tab 3 + controller.index = 3; + await tester.pumpAndSettle(); + + indicatorLeft = padLeft; + indicatorRight = 200.0 - padRight; + + expect( + tabBarBox, + paints..rect( + rect: Rect.fromLTRB(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom), + color: indicatorColor, + ), + ); + }); + + testWidgets('TabBar with custom indicator - directional indicatorPadding (LTR)', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + const indicatorColor = Color(0xFF00FF00); + const padTop = 10.0; + const padBottom = 12.0; + const padStart = 8.0; + const padEnd = 4.0; + const Decoration indicator = BoxDecoration(color: indicatorColor); + const indicatorWeight = 2.0; // the default + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicator: indicator, + indicatorPadding: const EdgeInsetsDirectional.fromSTEB( + padStart, + padTop, + padEnd, + padBottom, + ), + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = kTabLabelPadding.left; + double tabRight = tabLeft + 130.0; + double tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + // Tab 0 selected, indicator padding resolves to left: 8.0, right: 4.0 + const indicatorLeft = padStart; + final double indicatorRight = 130.0 + kTabLabelPadding.horizontal - padEnd; + const indicatorTop = padTop; + const double indicatorBottom = tabBarHeight - padBottom; + expect( + tabBarBox, + paints..rect( + rect: Rect.fromLTRB(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom), + color: indicatorColor, + ), + ); + }); + + testWidgets('TabBar with custom indicator - directional indicatorPadding (RTL)', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + const indicatorColor = Color(0xFF00FF00); + const padTop = 10.0; + const padBottom = 12.0; + const padStart = 8.0; + const padEnd = 4.0; + const Decoration indicator = BoxDecoration(color: indicatorColor); + const indicatorWeight = 2.0; // the default + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicator: indicator, + indicatorPadding: const EdgeInsetsDirectional.fromSTEB( + padStart, + padTop, + padEnd, + padBottom, + ), + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab2 width = 150, height = 50 + double tabLeft = kTabLabelPadding.left; + double tabRight = tabLeft + 150.0; + double tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0; + double tabBottom = tabTop + 50.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab0 width = 130, height = 30 + tabLeft = tabRight + kTabLabelPadding.right + kTabLabelPadding.left; + tabRight = tabLeft + 130.0; + tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0; + tabBottom = tabTop + 30.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab 0 selected, indicator padding resolves to left: 4.0, right: 8.0 + final double indicatorLeft = tabLeft - kTabLabelPadding.left + padEnd; + final double indicatorRight = tabRight + kTabLabelPadding.left - padStart; + const indicatorTop = padTop; + const double indicatorBottom = tabBarHeight - padBottom; + + expect( + tabBarBox, + paints..rect( + rect: Rect.fromLTRB(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom), + color: indicatorColor, + ), + ); + }); + + testWidgets('TabBar with padding isScrollable: false', (WidgetTester tester) async { + const indicatorWeight = 2.0; // default indicator weight + const padding = EdgeInsets.only(left: 3.0, top: 7.0, right: 5.0, bottom: 3.0); + + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: double.infinity, height: 30.0), + SizedBox(key: UniqueKey(), width: double.infinity, height: 40.0), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + padding: padding, + labelPadding: EdgeInsets.zero, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + final double tabBarHeight = + 40.0 + indicatorWeight + padding.top + padding.bottom; // 40 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + final double tabSize = (tabBarBox.size.width - padding.horizontal) / 2.0; + + // Tab0 height = 30 + double tabLeft = padding.left; + double tabRight = tabLeft + tabSize; + double tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 height = 40 + tabLeft = tabRight; + tabRight = tabLeft + tabSize; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + tabRight += padding.right; + expect(tabBarBox.size.width, tabRight); + }); + + testWidgets('Material3 - TabBar with padding isScrollable: true', (WidgetTester tester) async { + const indicatorWeight = 2.0; // default indicator weight + const padding = EdgeInsets.only(left: 3.0, top: 7.0, right: 5.0, bottom: 3.0); + const tabStartOffset = 52.0; + + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + padding: padding, + labelPadding: EdgeInsets.zero, + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + final double tabBarHeight = + 50.0 + indicatorWeight + padding.top + padding.bottom; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = padding.left + tabStartOffset; + double tabRight = tabLeft + 130.0; + double tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + tabRight += padding.right; + expect( + tabBarBox.size.width, + tabRight + 320.0, + ); // Right tab + remaining space of the stretched tab bar. + }); + + testWidgets('TabBar with labelPadding', (WidgetTester tester) async { + const indicatorWeight = 2.0; // default indicator weight + const labelPadding = EdgeInsets.only(left: 3.0, right: 7.0); + const indicatorPadding = labelPadding; + + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + labelPadding: labelPadding, + indicatorPadding: labelPadding, + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = labelPadding.left; + double tabRight = tabLeft + 130.0; + double tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight + labelPadding.right + labelPadding.left; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight + labelPadding.right + labelPadding.left; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + // Tab 0 selected, indicatorPadding == labelPadding + final double indicatorLeft = indicatorPadding.left + indicatorWeight / 2.0; + final double indicatorRight = + 130.0 + labelPadding.horizontal - indicatorPadding.right - indicatorWeight / 2.0; + final double indicatorY = tabBottom + indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar with labelPadding(TabBarIndicatorSize.label)', (WidgetTester tester) async { + const indicatorWeight = 2.0; // default indicator weight + const labelPadding = EdgeInsets.only(left: 7.0, right: 4.0); + const indicatorPadding = EdgeInsets.only(left: 3.0, right: 7.0); + + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + labelPadding: labelPadding, + indicatorPadding: indicatorPadding, + isScrollable: true, + controller: controller, + indicatorSize: TabBarIndicatorSize.label, + tabs: tabs, + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 50.0 + indicatorWeight; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = labelPadding.left; + double tabRight = tabLeft + 130.0; + double tabTop = (tabBarHeight - indicatorWeight - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight + labelPadding.right + labelPadding.left; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight + labelPadding.right + labelPadding.left; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + // Tab 0 selected + final double indicatorLeft = indicatorPadding.left + labelPadding.left + indicatorWeight / 2.0; + final double indicatorRight = + labelPadding.left + 130.0 - indicatorPadding.right - indicatorWeight / 2.0; + final double indicatorY = tabBottom + indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('Overflowing RTL tab bar', (WidgetTester tester) async { + final tabs = List<Widget>.filled( + 100, + // For convenience padded width of each tab will equal 100: + // 68 + kTabLabelPadding.horizontal(32) + SizedBox(key: UniqueKey(), width: 68.0, height: 40.0), + ); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + const indicatorWeight = 2.0; // the default + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar(isScrollable: true, controller: controller, tabs: tabs), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + const double tabBarHeight = 40.0 + indicatorWeight; // 40 = tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab 0 out of 100 selected + double indicatorLeft = 99.0 * 100.0 + indicatorWeight / 2.0; + double indicatorRight = 100.0 * 100.0 - indicatorWeight / 2.0; + const double indicatorY = 40.0 + indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + controller.animateTo( + tabs.length - 1, + duration: const Duration(seconds: 1), + curve: Curves.linear, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + // In RTL, the elastic tab animation expands the width of the tab with a negative offset + // when jumping from the first tab to the last tab in a scrollable tab bar. + p1: const Offset(4951.0, indicatorY), + p2: const Offset(5049.0, indicatorY), + ), + ); + + await tester.pump(const Duration(milliseconds: 501)); + + // Tab 99 out of 100 selected, appears on the far left because RTL. + indicatorLeft = indicatorWeight / 2.0; + indicatorRight = 100.0 - indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('Tab indicator animation test', (WidgetTester tester) async { + const indicatorWeight = 8.0; + + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar(indicatorWeight: indicatorWeight, controller: controller, tabs: tabs), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + + // Initial indicator position. + const double indicatorY = 54.0 - indicatorWeight / 2.0; + double indicatorLeft = indicatorWeight / 2.0; + double indicatorRight = 200.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + // Select tab 1. + controller.animateTo(1, duration: const Duration(milliseconds: 1000), curve: Curves.linear); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + indicatorLeft = 100.0 + indicatorWeight / 2.0; + indicatorRight = 300.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + // Select tab 2 when animation is running. + controller.animateTo(2, duration: const Duration(milliseconds: 1000), curve: Curves.linear); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + indicatorLeft = 250.0 + indicatorWeight / 2.0; + indicatorRight = 450.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + // Final indicator position. + await tester.pumpAndSettle(); + indicatorLeft = 400.0 + indicatorWeight / 2.0; + indicatorRight = 600.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('correct semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + final tabs = List<Tab>.generate(2, (int index) { + return Tab(text: 'TAB #$index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Semantics( + container: true, + child: TabBar(isScrollable: true, controller: controller, tabs: tabs), + ), + ), + ); + + final expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 2, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 3, + rect: TestSemantics.fullScreen, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + id: 4, + rect: const Rect.fromLTRB(0.0, 0.0, 232.0, 600.0), + role: SemanticsRole.tabBar, + children: <TestSemantics>[ + TestSemantics( + id: 5, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + ], + label: 'TAB #0${kIsWeb ? '' : '\nTab 1 of 2'}', + rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), + role: SemanticsRole.tab, + transform: Matrix4.translationValues(0.0, 276.0, 0.0), + ), + TestSemantics( + id: 6, + flags: <SemanticsFlag>[ + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'TAB #1${kIsWeb ? '' : '\nTab 2 of 2'}', + rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), + role: SemanticsRole.tab, + transform: Matrix4.translationValues(116.0, 276.0, 0.0), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); + + testWidgets('correct scrolling semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + final tabs = List<Tab>.generate(20, (int index) { + return Tab(text: 'This is a very wide tab #$index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + child: Semantics( + container: true, + child: TabBar(isScrollable: true, controller: controller, tabs: tabs), + ), + ), + ); + + const tab0title = 'This is a very wide tab #0${kIsWeb ? '' : '\nTab 1 of 20'}'; + const tab10title = 'This is a very wide tab #10${kIsWeb ? '' : '\nTab 11 of 20'}'; + + const hiddenFlags = <SemanticsFlag>[ + SemanticsFlag.isHidden, + SemanticsFlag.isFocusable, + SemanticsFlag.hasSelectedState, + ]; + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.scrollLeft, SemanticsAction.scrollToOffset], + ), + ); + expect(semantics, includesNodeWith(label: tab0title)); + expect(semantics, includesNodeWith(label: tab10title, flags: hiddenFlags)); + + controller.index = 10; + await tester.pumpAndSettle(); + + expect(semantics, includesNodeWith(label: tab0title, flags: hiddenFlags)); + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[ + SemanticsAction.scrollLeft, + SemanticsAction.scrollRight, + SemanticsAction.scrollToOffset, + ], + ), + ); + expect(semantics, includesNodeWith(label: tab10title)); + + controller.index = 19; + await tester.pumpAndSettle(); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.scrollRight, SemanticsAction.scrollToOffset], + ), + ); + + controller.index = 0; + await tester.pumpAndSettle(); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.scrollLeft, SemanticsAction.scrollToOffset], + ), + ); + expect(semantics, includesNodeWith(label: tab0title)); + expect(semantics, includesNodeWith(label: tab10title, flags: hiddenFlags)); + + semantics.dispose(); + }); + + testWidgets('TabBar etc with zero tabs', (WidgetTester tester) async { + final TabController controller = createTabController(vsync: const TestVSync(), length: 0); + + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar(controller: controller, tabs: const <Widget>[]), + Flexible( + child: TabBarView(controller: controller, children: const <Widget>[]), + ), + ], + ), + ), + ); + + expect(controller.index, 0); + expect(tester.getSize(find.byType(TabBar)), const Size(800.0, 48.0)); + expect(tester.getSize(find.byType(TabBarView)), const Size(800.0, 600.0 - 48.0)); + + // A fling in the TabBar or TabBarView, shouldn't do anything. + + await tester.fling(find.byType(TabBar), const Offset(-100.0, 0.0), 5000.0, warnIfMissed: false); + await tester.pumpAndSettle(); + + await tester.fling(find.byType(TabBarView), const Offset(100.0, 0.0), 5000.0); + await tester.pumpAndSettle(); + + expect(controller.index, 0); + }); + + testWidgets('TabBar etc with one tab', (WidgetTester tester) async { + final TabController controller = createTabController(vsync: const TestVSync(), length: 1); + + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + controller: controller, + tabs: const <Widget>[Tab(text: 'TAB')], + ), + Flexible( + child: TabBarView(controller: controller, children: const <Widget>[Text('PAGE')]), + ), + ], + ), + ), + ); + + expect(controller.index, 0); + expect(find.text('TAB'), findsOneWidget); + expect(find.text('PAGE'), findsOneWidget); + expect(tester.getSize(find.byType(TabBar)), const Size(800.0, 48.0)); + expect(tester.getSize(find.byType(TabBarView)), const Size(800.0, 600.0 - 48.0)); + + // The one tab should be center vis the app's width (800). + final double tabLeft = tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx; + final double tabRight = tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx; + expect(tabLeft + (tabRight - tabLeft) / 2.0, 400.0); + + // A fling in the TabBar or TabBarView, shouldn't move the tab. + + await tester.fling(find.byType(TabBar), const Offset(-100.0, 0.0), 5000.0); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, tabLeft); + expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, tabRight); + await tester.pumpAndSettle(); + + await tester.fling(find.byType(TabBarView), const Offset(100.0, 0.0), 5000.0); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.getTopLeft(find.widgetWithText(Tab, 'TAB')).dx, tabLeft); + expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB')).dx, tabRight); + await tester.pumpAndSettle(); + + expect(controller.index, 0); + expect(find.text('TAB'), findsOneWidget); + expect(find.text('PAGE'), findsOneWidget); + }); + + testWidgets('can tap on indicator at very bottom of TabBar to switch tabs', ( + WidgetTester tester, + ) async { + final TabController controller = createTabController(vsync: const TestVSync(), length: 2); + + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + controller: controller, + indicatorWeight: 30.0, + tabs: const <Widget>[ + Tab(text: 'TAB1'), + Tab(text: 'TAB2'), + ], + ), + Flexible( + child: TabBarView( + controller: controller, + children: const <Widget>[Text('PAGE1'), Text('PAGE2')], + ), + ), + ], + ), + ), + ); + + expect(controller.index, 0); + + final Offset bottomRight = tester.getBottomRight(find.byType(TabBar)) - const Offset(1.0, 1.0); + final TestGesture gesture = await tester.startGesture(bottomRight); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.index, 1); + }); + + testWidgets('can override semantics of tabs', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + final tabs = List<Tab>.generate(2, (int index) { + return Tab( + child: Semantics( + label: 'Semantics override $index', + child: ExcludeSemantics(child: Text('TAB #$index')), + ), + ); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: Semantics( + container: true, + child: TabBar(isScrollable: true, controller: controller, tabs: tabs), + ), + ), + ); + + final expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 2, + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + id: 3, + rect: TestSemantics.fullScreen, + flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], + children: <TestSemantics>[ + TestSemantics( + id: 4, + rect: const Rect.fromLTRB(0.0, 0.0, 232.0, 600.0), + role: SemanticsRole.tabBar, + children: <TestSemantics>[ + TestSemantics( + id: 5, + flags: <SemanticsFlag>[ + SemanticsFlag.hasSelectedState, + SemanticsFlag.isSelected, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Semantics override 0${kIsWeb ? '' : '\nTab 1 of 2'}', + rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), + role: SemanticsRole.tab, + transform: Matrix4.translationValues(0.0, 276.0, 0.0), + ), + TestSemantics( + id: 6, + flags: <SemanticsFlag>[ + SemanticsFlag.hasSelectedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + label: 'Semantics override 1${kIsWeb ? '' : '\nTab 2 of 2'}', + rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), + role: SemanticsRole.tab, + transform: Matrix4.translationValues(116.0, 276.0, 0.0), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); + + testWidgets('can be notified of TabBar onTap behavior', (WidgetTester tester) async { + var tabIndex = -1; + + Widget buildFrame({required TabController controller, required List<String> tabs}) { + return boilerplate( + child: TabBar( + controller: controller, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + onTap: (int index) { + tabIndex = index; + }, + ), + ); + } + + final tabs = <String>['A', 'B', 'C']; + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + initialIndex: tabs.indexOf('C'), + ); + + await tester.pumpWidget(buildFrame(tabs: tabs, controller: controller)); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + expect(controller, isNotNull); + expect(controller.index, 2); + expect(tabIndex, -1); // no tap so far so tabIndex should reflect that + + // Verify whether the [onTap] notification works when the [TabBar] animates. + + await tester.pumpWidget(buildFrame(tabs: tabs, controller: controller)); + await tester.tap(find.text('B')); + await tester.pump(); + expect(controller.indexIsChanging, true); + await tester.pumpAndSettle(); + expect(controller.index, 1); + expect(controller.previousIndex, 2); + expect(controller.indexIsChanging, false); + expect(tabIndex, controller.index); + + tabIndex = -1; + + await tester.pumpWidget(buildFrame(tabs: tabs, controller: controller)); + await tester.tap(find.text('C')); + await tester.pump(); + await tester.pumpAndSettle(); + expect(controller.index, 2); + expect(controller.previousIndex, 1); + expect(tabIndex, controller.index); + + tabIndex = -1; + + await tester.pumpWidget(buildFrame(tabs: tabs, controller: controller)); + await tester.tap(find.text('A')); + await tester.pump(); + await tester.pumpAndSettle(); + expect(controller.index, 0); + expect(controller.previousIndex, 2); + expect(tabIndex, controller.index); + + tabIndex = -1; + + // Verify whether [onTap] is called even when the [TabController] does + // not change. + + final int currentControllerIndex = controller.index; + await tester.pumpWidget(buildFrame(tabs: tabs, controller: controller)); + await tester.tap(find.text('A')); + await tester.pump(); + await tester.pumpAndSettle(); + expect(controller.index, currentControllerIndex); // controller has not changed + expect(tabIndex, 0); + }); + + test('illegal constructor combinations', () { + expect(() => Tab(icon: nonconst(null)), throwsAssertionError); + expect(() => Tab(icon: Container(), text: 'foo', child: Container()), throwsAssertionError); + expect(() => Tab(text: 'foo', child: Container()), throwsAssertionError); + }); + + test('Tab throws clear error when both text and child are set', () { + // Wrap in a closure so the assertion is checked at runtime + expect( + () { + Tab(text: 'Hi', child: const Text('World')); // no const + }, + throwsA( + const TypeMatcher<AssertionError>().having( + (AssertionError error) => error.message, + 'message', + contains('Provide either text or child, not both, when creating a Tab.'), + ), + ), + ); + }); + + testWidgets('Tabs changes mouse cursor when a tab is hovered', (WidgetTester tester) async { + final tabs = <String>['A', 'B']; + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: tabs.length, + child: Scaffold( + body: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TabBar( + mouseCursor: SystemMouseCursors.text, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Tab).first)); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: tabs.length, + child: Scaffold( + body: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TabBar(tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList()), + ), + ), + ), + ), + ); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('TabController changes', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/14812 + + Widget buildFrame(TabController controller) { + return boilerplate( + useMaterial3: false, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + controller: controller, + tabs: const <Tab>[ + Tab(text: 'LEFT'), + Tab(text: 'RIGHT'), + ], + ), + ), + ); + } + + final TabController controller1 = createTabController(vsync: const TestVSync(), length: 2); + + final TabController controller2 = createTabController(vsync: const TestVSync(), length: 2); + + await tester.pumpWidget(buildFrame(controller1)); + await tester.pumpWidget(buildFrame(controller2)); + expect(controller1.index, 0); + expect(controller2.index, 0); + + const indicatorWeight = 2.0; + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); // 48 = _kTabHeight(46) + indicatorWeight(2.0) + + const double indicatorY = 48.0 - indicatorWeight / 2.0; + double indicatorLeft = indicatorWeight / 2.0; + double indicatorRight = 400.0 - indicatorWeight / 2.0; // 400 = screen_width / 2 + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + + await tester.tap(find.text('RIGHT')); + await tester.pumpAndSettle(); + expect(controller1.index, 0); + expect(controller2.index, 1); + + // Verify that the TabBar's _IndicatorPainter is now listening to + // tabController2. + + indicatorLeft = 400.0 + indicatorWeight / 2.0; + indicatorRight = 800.0 - indicatorWeight / 2.0; + expect( + tabBarBox, + paints..line( + strokeWidth: indicatorWeight, + p1: Offset(indicatorLeft, indicatorY), + p2: Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabController changes while flinging', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/34744 + + Widget buildFrame(TabController controller) { + return MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: <Tab>[ + const Tab(text: 'A'), + const Tab(text: 'B'), + if (controller.length == 3) const Tab(text: 'C'), + ], + ), + ), + body: TabBarView( + controller: controller, + children: <Widget>[ + const Center(child: Text('CHILD A')), + const Center(child: Text('CHILD B')), + if (controller.length == 3) const Center(child: Text('CHILD C')), + ], + ), + ), + ); + } + + final TabController controller1 = createTabController(vsync: const TestVSync(), length: 2); + + final TabController controller2 = createTabController(vsync: const TestVSync(), length: 3); + + expect(controller1.index, 0); + expect(controller2.index, 0); + + await tester.pumpWidget(buildFrame(controller1)); + final Offset flingStart = tester.getCenter(find.text('CHILD A')); + await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), 10000.0); + await tester.pump(const Duration(milliseconds: 10)); // start the fling animation + + await tester.pump(const Duration(milliseconds: 10)); + + await tester.pumpWidget(buildFrame(controller2)); // replace controller + await tester.flingFrom(flingStart, const Offset(-200.0, 0.0), 10000.0); + await tester.pumpAndSettle(); // finish the fling animation + + expect(controller1.index, 0); + expect(controller2.index, 1); + }); + + testWidgets('TabController changes with different initialIndex', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/115917 + const lastTabKey = Key('Last Tab'); + TabController? controller; + + Widget buildFrame(int length) { + controller = createTabController( + vsync: const TestVSync(), + length: length, + initialIndex: length - 1, + ); + return boilerplate( + child: TabBar( + labelPadding: EdgeInsets.zero, + controller: controller, + isScrollable: true, + tabs: List<Widget>.generate(length, (int index) { + return SizedBox( + width: 100, + child: Tab(key: index == length - 1 ? lastTabKey : null, text: 'Tab $index'), + ); + }), + ), + ); + } + + await tester.pumpWidget(buildFrame(10)); + expect(controller!.index, 9); + expect(tester.getCenter(find.byKey(lastTabKey)).dx, equals(750.0)); + + // Rebuild with a new controller with more tabs and last tab selected. + // Last tab should be visible and on the right of the window. + await tester.pumpWidget(buildFrame(15)); + expect(controller!.index, 14); + expect(tester.getCenter(find.byKey(lastTabKey)).dx, equals(750.0)); + }); + + testWidgets('DefaultTabController changes does not recreate PageController', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/134253. + Widget buildFrame(int length) { + return boilerplate( + child: DefaultTabController( + length: length, + initialIndex: length - 1, + child: TabBarView( + physics: const TabBarTestScrollPhysics(), + children: List<Widget>.generate(length, (int index) { + return Center(child: Text('Page $index')); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(15)); + PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController1 = pageView.controller!; + TabController tabController = DefaultTabController.of(tester.element(find.text('Page 14'))); + expect(tabController.index, 14); + expect(pageController1.page, 14); + + // Rebuild with a new default tab controller with more tabs. + await tester.pumpWidget(buildFrame(10)); + pageView = tester.widget(find.byType(PageView)); + final PageController pageController2 = pageView.controller!; + tabController = DefaultTabController.of(tester.element(find.text('Page 9'))); + expect(tabController.index, 9); + expect(pageController2.page, 9); + + expect(pageController1, equals(pageController2)); + }); + + testWidgets( + 'Do not throw when switching between a scrollable TabBar and a non-scrollable TabBar', + (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/120649 + final TabController controller1 = createTabController(vsync: const TestVSync(), length: 2); + final TabController controller2 = createTabController(vsync: const TestVSync(), length: 2); + + Widget buildFrame(TabController controller, bool isScrollable) { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + controller: controller, + isScrollable: isScrollable, + tabs: const <Tab>[ + Tab(text: 'LEFT'), + Tab(text: 'RIGHT'), + ], + ), + ), + ); + } + + // Show both controllers once. + await tester.pumpWidget(buildFrame(controller1, false)); + await tester.pumpWidget(buildFrame(controller2, true)); + + // Switch back to the first controller. + await tester.pumpWidget(buildFrame(controller1, false)); + expect(tester.takeException(), null); + + // Switch back to the second controller. + await tester.pumpWidget(buildFrame(controller2, true)); + expect(tester.takeException(), null); + }, + ); + + testWidgets('Default tab indicator color is white in M2 and surfaceVariant in M3', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/15958 + final tabs = <String>['LEFT', 'RIGHT']; + final theme = ThemeData(platform: TargetPlatform.android); + final bool material3 = theme.useMaterial3; + await tester.pumpWidget(buildLeftRightApp(themeData: theme, tabs: tabs, value: 'LEFT')); + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect( + tabBarBox, + paints..line(color: material3 ? theme.colorScheme.outlineVariant : Colors.white), + ); + }); + + testWidgets( + 'Tab indicator color should not be adjusted when disable [automaticIndicatorColorAdjustment]', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/68077 + final tabs = <String>['LEFT', 'RIGHT']; + final theme = ThemeData(platform: TargetPlatform.android); + final bool material3 = theme.useMaterial3; + await tester.pumpWidget( + buildLeftRightApp( + themeData: theme, + tabs: tabs, + value: 'LEFT', + automaticIndicatorColorAdjustment: false, + ), + ); + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect( + tabBarBox, + paints..line(color: material3 ? theme.colorScheme.outlineVariant : const Color(0xff2196f3)), + ); + }, + ); + + group('Tab feedback', () { + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('Tab feedback is enabled (default)', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const DefaultTabController( + length: 1, + child: TabBar(tabs: <Tab>[Tab(text: 'A')]), + ), + ), + ); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 0); + }); + + testWidgets('Tab feedback is disabled', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: const DefaultTabController( + length: 1, + child: TabBar(tabs: <Tab>[Tab(text: 'A')], enableFeedback: false), + ), + ), + ); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + }); + + group('Tab overlayColor affects ink response', () { + testWidgets("Tab's ink well changes color on hover with Tab overlayColor", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + child: DefaultTabController( + length: 1, + child: TabBar( + tabs: const <Tab>[Tab(text: 'A')], + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.pressed)) { + return const Color(0xf00fffff); + } + return const Color(0xffbadbad); // Shouldn't happen. + }), + ), + ), + ), + ); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Tab))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + inkFeatures, + paints..rect( + rect: const Rect.fromLTRB(0.0, 276.0, 800.0, 324.0), + color: const Color(0xff00ff00), + ), + ); + }); + + testWidgets( + "Tab's ink response splashColor matches resolved Tab overlayColor for WidgetState.pressed", + (WidgetTester tester) async { + const splashColor = Color(0xf00fffff); + await tester.pumpWidget( + boilerplate( + useMaterial3: false, + child: DefaultTabController( + length: 1, + child: TabBar( + tabs: const <Tab>[Tab(text: 'A')], + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return const Color(0xff00ff00); + } + if (states.contains(WidgetState.pressed)) { + return splashColor; + } + return const Color(0xffbadbad); // Shouldn't happen. + }), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + ); + await tester.pump(const Duration(milliseconds: 200)); // unconfirmed splash is well underway + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..circle(x: 400, y: 24, color: splashColor)); + await gesture.up(); + }, + ); + }); + + testWidgets('Skipping tabs with global key does not crash', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/24660 + final tabs = <String>['Tab1', 'Tab2', 'Tab3', 'Tab4']; + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300.0, + height: 200.0, + child: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + controller: controller, + children: <Widget>[ + Text('1', key: GlobalKey()), + Text('2', key: GlobalKey()), + Text('3', key: GlobalKey()), + Text('4', key: GlobalKey()), + ], + ), + ), + ), + ), + ), + ); + expect(find.text('1'), findsOneWidget); + expect(find.text('4'), findsNothing); + await tester.tap(find.text('Tab4')); + await tester.pumpAndSettle(); + expect(controller.index, 3); + expect(find.text('4'), findsOneWidget); + expect(find.text('1'), findsNothing); + }); + + testWidgets('Skipping tabs with a KeepAlive child works', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/11895 + final tabs = <String>['Tab1', 'Tab2', 'Tab3', 'Tab4', 'Tab5']; + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300.0, + height: 200.0, + child: Scaffold( + appBar: AppBar( + title: const Text('tabs'), + bottom: TabBar( + controller: controller, + tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + controller: controller, + children: <Widget>[ + TabAlwaysKeepAliveWidget(key: UniqueKey()), + const Text('2'), + const Text('3'), + const Text('4'), + const Text('5'), + ], + ), + ), + ), + ), + ), + ); + expect(find.text(TabAlwaysKeepAliveWidget.text), findsOneWidget); + expect(find.text('4'), findsNothing); + await tester.tap(find.text('Tab4')); + await tester.pumpAndSettle(); + await tester.pump(); + expect(controller.index, 3); + expect(find.text(TabAlwaysKeepAliveWidget.text, skipOffstage: false), findsOneWidget); + expect(find.text('4'), findsOneWidget); + }); + + testWidgets( + 'tabbar does not scroll when viewport dimensions initially change from zero to non-zero', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/10531. + + const tabs = <Widget>[Tab(text: 'NEW MEXICO'), Tab(text: 'GABBA'), Tab(text: 'HEY')]; + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTestWidget({double? width, double? height}) { + return MaterialApp( + home: Center( + child: SizedBox( + height: height, + width: width, + child: Scaffold( + appBar: AppBar( + title: const Text('AppBarBug'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(30.0), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Align( + alignment: FractionalOffset.center, + child: TabBar(controller: controller, isScrollable: true, tabs: tabs), + ), + ), + ), + ), + body: const Center(child: Text('Hello World')), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTestWidget(width: 0.0, height: 0.0)); + + await tester.pumpWidget(buildTestWidget(width: 300.0, height: 400.0)); + + expect(tester.hasRunningAnimations, isFalse); + expect(await tester.pumpAndSettle(), 1); // no more frames are scheduled. + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/20292. + testWidgets('Number of tabs can be updated dynamically', (WidgetTester tester) async { + final threeTabs = <String>['A', 'B', 'C']; + final twoTabs = <String>['A', 'B']; + final oneTab = <String>['A']; + final Key key = UniqueKey(); + Widget buildTabs(List<String> tabs) { + return boilerplate( + child: DefaultTabController( + key: key, + length: tabs.length, + child: TabBar(tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList()), + ), + ); + } + + TabController getController() => DefaultTabController.of(tester.element(find.text('A'))); + + await tester.pumpWidget(buildTabs(threeTabs)); + await tester.tap(find.text('B')); + await tester.pump(); + TabController controller = getController(); + expect(controller.previousIndex, 0); + expect(controller.index, 1); + expect(controller.length, 3); + + await tester.pumpWidget(buildTabs(twoTabs)); + controller = getController(); + expect(controller.previousIndex, 0); + expect(controller.index, 1); + expect(controller.length, 2); + + await tester.pumpWidget(buildTabs(oneTab)); + controller = getController(); + expect(controller.previousIndex, 1); + expect(controller.index, 0); + expect(controller.length, 1); + + await tester.pumpWidget(buildTabs(twoTabs)); + controller = getController(); + expect(controller.previousIndex, 1); + expect(controller.index, 0); + expect(controller.length, 2); + }); + + // Regression test for https://github.com/flutter/flutter/issues/15008. + testWidgets('TabBar with one tab has correct color', (WidgetTester tester) async { + const tab = Tab(text: 'A'); + const selectedTabColor = Color(0x00000001); + const unselectedTabColor = Color(0x00000002); + + await tester.pumpWidget( + boilerplate( + child: const DefaultTabController( + length: 1, + child: TabBar( + tabs: <Tab>[tab], + labelColor: selectedTabColor, + unselectedLabelColor: unselectedTabColor, + ), + ), + ), + ); + + final IconThemeData iconTheme = IconTheme.of(tester.element(find.text('A'))); + expect(iconTheme.color, equals(selectedTabColor)); + }); + + testWidgets('TabBar.labelColor resolves material states', (WidgetTester tester) async { + const tab1 = 'Tab 1'; + const tab2 = 'Tab 2'; + + const selectedColor = Color(0xff00ff00); + const unselectedColor = Color(0xffff0000); + final labelColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedColor; + } + return unselectedColor; + }); + + // Test labelColor correctly resolves material states. + await tester.pumpWidget( + boilerplate( + child: DefaultTabController( + length: 2, + child: TabBar(labelColor: labelColor, tabs: const <Widget>[Text(tab1), Text(tab2)]), + ), + ), + ); + + final IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(tab1))); + final IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(tab2))); + final TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab1)) + .text + .style!; + final TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab2)) + .text + .style!; + + expect(selectedTabIcon.color, selectedColor); + expect(unselectedTabIcon.color, unselectedColor); + expect(selectedTextStyle.color, selectedColor); + expect(unselectedTextStyle.color, unselectedColor); + }); + + testWidgets('labelColor & unselectedLabelColor override material state labelColor', ( + WidgetTester tester, + ) async { + const tab1 = 'Tab 1'; + const tab2 = 'Tab 2'; + + const selectedStateColor = Color(0xff00ff00); + const unselectedStateColor = Color(0xffff0000); + final labelColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedStateColor; + } + return unselectedStateColor; + }); + const selectedColor = Color(0xff00ffff); + const unselectedColor = Color(0xffff12ff); + + Widget buildTabBar({bool stateColor = true}) { + return boilerplate( + child: DefaultTabController( + length: 2, + child: TabBar( + labelColor: stateColor ? labelColor : selectedColor, + unselectedLabelColor: stateColor ? null : unselectedColor, + tabs: const <Widget>[Text(tab1), Text(tab2)], + ), + ), + ); + } + + // Test material state label color. + await tester.pumpWidget(buildTabBar()); + + IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(tab1))); + IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(tab2))); + TextStyle selectedTextStyle = tester.renderObject<RenderParagraph>(find.text(tab1)).text.style!; + TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab2)) + .text + .style!; + + expect(selectedTabIcon.color, selectedStateColor); + expect(unselectedTabIcon.color, unselectedStateColor); + expect(selectedTextStyle.color, selectedStateColor); + expect(unselectedTextStyle.color, unselectedStateColor); + + // Test labelColor & unselectedLabelColor override material state labelColor. + await tester.pumpWidget(buildTabBar(stateColor: false)); + + selectedTabIcon = IconTheme.of(tester.element(find.text(tab1))); + unselectedTabIcon = IconTheme.of(tester.element(find.text(tab2))); + selectedTextStyle = tester.renderObject<RenderParagraph>(find.text(tab1)).text.style!; + unselectedTextStyle = tester.renderObject<RenderParagraph>(find.text(tab2)).text.style!; + + expect(selectedTabIcon.color, selectedColor); + expect(unselectedTabIcon.color, unselectedColor); + expect(selectedTextStyle.color, selectedColor); + expect(unselectedTextStyle.color, unselectedColor); + }); + + testWidgets('Replacing the tabController after disposing the old one', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/32428 + var controller = TabController(vsync: const TestVSync(), length: 2); + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: controller, + tabs: List<Widget>.generate( + controller.length, + (int index) => Tab(text: 'Tab$index'), + ), + ), + actions: <Widget>[ + TextButton( + child: const Text('Change TabController length'), + onPressed: () { + setState(() { + controller.dispose(); + controller = createTabController(vsync: const TestVSync(), length: 3); + }); + }, + ), + ], + ), + body: TabBarView( + controller: controller, + children: List<Widget>.generate( + controller.length, + (int index) => Center(child: Text('Tab $index')), + ), + ), + ); + }, + ), + ), + ); + + expect(controller.index, 0); + expect(controller.length, 2); + expect(find.text('Tab0'), findsOneWidget); + expect(find.text('Tab1'), findsOneWidget); + expect(find.text('Tab2'), findsNothing); + + await tester.tap(find.text('Change TabController length')); + await tester.pumpAndSettle(); + expect(controller.index, 0); + expect(controller.length, 3); + expect(find.text('Tab0'), findsOneWidget); + expect(find.text('Tab1'), findsOneWidget); + expect(find.text('Tab2'), findsOneWidget); + }); + + testWidgets('DefaultTabController should allow for a length of zero', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/20292. + var tabTextContent = <String>[]; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DefaultTabController( + length: tabTextContent.length, + child: Scaffold( + appBar: AppBar( + title: const Text('Default TabBar Preview'), + bottom: tabTextContent.isNotEmpty + ? TabBar( + isScrollable: true, + tabs: tabTextContent + .map((String textContent) => Tab(text: textContent)) + .toList(), + ) + : null, + ), + body: tabTextContent.isNotEmpty + ? TabBarView( + children: tabTextContent + .map((String textContent) => Tab(text: "$textContent's view")) + .toList(), + ) + : const Center(child: Text('No tabs')), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + IconButton( + key: const Key('Add tab'), + icon: const Icon(Icons.add), + onPressed: () { + setState(() { + tabTextContent = List<String>.of(tabTextContent) + ..add('Tab ${tabTextContent.length + 1}'); + }); + }, + ), + IconButton( + key: const Key('Delete tab'), + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + tabTextContent = List<String>.of(tabTextContent)..removeLast(); + }); + }, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + + // Initializes with zero tabs properly + expect(find.text('No tabs'), findsOneWidget); + await tester.tap(find.byKey(const Key('Add tab'))); + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text("Tab 1's view"), findsOneWidget); + + // Dynamically updates to zero tabs properly + await tester.tap(find.byKey(const Key('Delete tab'))); + await tester.pumpAndSettle(); + expect(find.text('No tabs'), findsOneWidget); + }); + + testWidgets('DefaultTabController should allow dynamic length of tabs', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/94504. + final tabTitles = <String>[]; + + void onTabAdd(StateSetter setState) { + setState(() { + tabTitles.add('Tab ${tabTitles.length + 1}'); + }); + } + + void onTabRemove(StateSetter setState) { + setState(() { + tabTitles.removeLast(); + }); + } + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DefaultTabController( + length: tabTitles.length, + child: Scaffold( + appBar: AppBar( + actions: <Widget>[ + TextButton( + key: const Key('Add tab'), + child: const Text('Add tab'), + onPressed: () => onTabAdd(setState), + ), + TextButton( + key: const Key('Remove tab'), + child: const Text('Remove tab'), + onPressed: () => onTabRemove(setState), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40.0), + child: Expanded( + child: TabBar( + tabs: tabTitles.map((String title) => Tab(text: title)).toList(), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + expect(find.text('Tab 1'), findsNothing); + expect(find.text('Tab 2'), findsNothing); + + await tester.tap(find.byKey(const Key('Add tab'))); // +1 + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsNothing); + + await tester.tap(find.byKey(const Key('Add tab'))); // +2 + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsOneWidget); + + await tester.tap(find.byKey(const Key('Remove tab'))); // -2 + await tester.tap(find.byKey(const Key('Remove tab'))); // -1 + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsNothing); + expect(find.text('Tab 2'), findsNothing); + }); + + testWidgets('TabBar - updating to and from zero tabs', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/68962. + final tabTitles = <String>[]; + TabController tabController = createTabController( + length: tabTitles.length, + vsync: const TestVSync(), + ); + + void onTabAdd(StateSetter setState) { + setState(() { + tabTitles.add('Tab ${tabTitles.length + 1}'); + tabController = createTabController(length: tabTitles.length, vsync: const TestVSync()); + }); + } + + void onTabRemove(StateSetter setState) { + setState(() { + tabTitles.removeLast(); + tabController = createTabController(length: tabTitles.length, vsync: const TestVSync()); + }); + } + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + appBar: AppBar( + actions: <Widget>[ + TextButton( + key: const Key('Add tab'), + child: const Text('Add tab'), + onPressed: () => onTabAdd(setState), + ), + TextButton( + key: const Key('Remove tab'), + child: const Text('Remove tab'), + onPressed: () => onTabRemove(setState), + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40.0), + child: Expanded( + child: TabBar( + controller: tabController, + tabs: tabTitles.map((String title) => Tab(text: title)).toList(), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + expect(find.text('Tab 1'), findsNothing); + expect(find.text('Add tab'), findsOneWidget); + await tester.tap(find.byKey(const Key('Add tab'))); + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + + await tester.tap(find.byKey(const Key('Remove tab'))); + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsNothing); + }); + + testWidgets( + 'TabBar expands vertically to accommodate the Icon and child Text() pair the same amount it would expand for Icon and text pair.', + (WidgetTester tester) async { + const tabListWithText = <Widget>[Tab(icon: Icon(Icons.notifications), text: 'Test')]; + const tabListWithTextChild = <Widget>[ + Tab(icon: Icon(Icons.notifications), child: Text('Test')), + ]; + + const tabBarWithText = TabBar(tabs: tabListWithText); + const tabBarWithTextChild = TabBar(tabs: tabListWithTextChild); + + expect(tabBarWithText.preferredSize, tabBarWithTextChild.preferredSize); + }, + ); + + testWidgets( + 'Setting TabController index should make TabBar indicator immediately pop into the position', + (WidgetTester tester) async { + const tabs = <Tab>[Tab(text: 'A'), Tab(text: 'B'), Tab(text: 'C')]; + const indicatorColor = Color(0xFFFF0000); + late TabController tabController; + + Widget buildTabControllerFrame(BuildContext context, TabController controller) { + tabController = controller; + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + appBar: AppBar( + bottom: TabBar(controller: controller, tabs: tabs, indicatorColor: indicatorColor), + ), + body: TabBarView( + controller: controller, + children: tabs.map((Tab tab) { + return Center(child: Text(tab.text!)); + }).toList(), + ), + ), + ); + } + + await tester.pumpWidget( + TabControllerFrame(builder: buildTabControllerFrame, length: tabs.length), + ); + + final RenderBox box = tester.renderObject(find.byType(TabBar)); + final canvas = TabIndicatorRecordingCanvas(indicatorColor); + final context = TestRecordingPaintingContext(canvas); + + box.paint(context, Offset.zero); + double expectedIndicatorLeft = canvas.indicatorRect.left; + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + void pageControllerListener() { + // Whenever TabBarView scrolls due to changing TabController's index, + // check if indicator stays idle in its expectedIndicatorLeft + box.paint(context, Offset.zero); + expect(canvas.indicatorRect.left, expectedIndicatorLeft); + } + + // Moving from index 0 to 2 (distanced tabs) + tabController.index = 2; + box.paint(context, Offset.zero); + expectedIndicatorLeft = canvas.indicatorRect.left; + pageController.addListener(pageControllerListener); + await tester.pumpAndSettle(); + + // Moving from index 2 to 1 (neighboring tabs) + tabController.index = 1; + box.paint(context, Offset.zero); + expectedIndicatorLeft = canvas.indicatorRect.left; + await tester.pumpAndSettle(); + pageController.removeListener(pageControllerListener); + }, + ); + + testWidgets( + 'Setting BouncingScrollPhysics on TabBarView does not include ClampingScrollPhysics', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/57708 + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 10, + child: Scaffold( + body: TabBarView( + physics: const BouncingScrollPhysics(), + children: List<Widget>.generate(10, (int i) => Center(child: Text('index $i'))), + ), + ), + ), + ), + ); + + final PageView pageView = tester.widget<PageView>(find.byType(PageView)); + expect(pageView.physics.toString().contains('ClampingScrollPhysics'), isFalse); + }, + ); + + testWidgets('TabController.offset changes reflect labelColor', (WidgetTester tester) async { + final TabController controller = createTabController(vsync: const TestVSync(), length: 2); + + late Color firstColor; + late Color secondColor; + + Widget buildTabBar({bool stateColor = false}) { + final Color labelColor = stateColor + ? WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return Colors.white; + } else { + // this is a third color to also test if unselectedLabelColor + // is ignored when labelColor is WidgetStateColor + return Colors.transparent; + } + }) + : Colors.white; + + return boilerplate( + child: TabBar( + controller: controller, + labelColor: labelColor, + unselectedLabelColor: Colors.black, + tabs: <Widget>[ + Builder( + builder: (BuildContext context) { + firstColor = DefaultTextStyle.of(context).style.color!; + return const Text('First'); + }, + ), + Builder( + builder: (BuildContext context) { + secondColor = DefaultTextStyle.of(context).style.color!; + return const Text('Second'); + }, + ), + ], + ), + ); + } + + Future<void> testLabelColor({ + required Color selectedColor, + required Color unselectedColor, + }) async { + expect(firstColor, equals(selectedColor)); + expect(secondColor, equals(unselectedColor)); + + controller.offset = 0.6; + await tester.pump(); + + expect(firstColor, equals(Color.lerp(selectedColor, unselectedColor, 0.6))); + expect(secondColor, equals(Color.lerp(unselectedColor, selectedColor, 0.6))); + + controller.index = 1; + await tester.pump(); + + expect(firstColor, equals(unselectedColor)); + expect(secondColor, equals(selectedColor)); + + controller.offset = 0.6; + await tester.pump(); + + expect(firstColor, equals(unselectedColor)); + expect(secondColor, equals(selectedColor)); + + controller.offset = -0.6; + await tester.pump(); + + expect(firstColor, equals(Color.lerp(selectedColor, unselectedColor, 0.4))); + expect(secondColor, equals(Color.lerp(unselectedColor, selectedColor, 0.4))); + } + + await tester.pumpWidget(buildTabBar()); + await testLabelColor(selectedColor: Colors.white, unselectedColor: Colors.black); + + // reset + controller.index = 0; + await tester.pump(); + + await tester.pumpWidget(buildTabBar(stateColor: true)); + await testLabelColor(selectedColor: Colors.white, unselectedColor: Colors.transparent); + }); + + testWidgets('No crash on dispose', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab(icon: Icon(Icons.directions_car)), + Tab(icon: Icon(Icons.directions_transit)), + Tab(icon: Icon(Icons.directions_bike)), + ], + ), + title: const Text('Tabs Demo'), + ), + body: const TabBarView( + children: <Widget>[ + Icon(Icons.directions_car), + Icon(Icons.directions_transit), + Icon(Icons.directions_bike), + ], + ), + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.directions_bike)); + // No crash on dispose. + expect(tester.takeException(), isNull); + }); + + testWidgets( + "TabController's animation value should be in sync with TabBarView's scroll value when user interrupts ballistic scroll", + (WidgetTester tester) async { + final TabController tabController = createTabController(vsync: const TestVSync(), length: 3); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.expand( + child: Center( + child: SizedBox.square( + dimension: 400.0, + child: TabBarView( + controller: tabController, + children: const <Widget>[ + Center(child: Text('0')), + Center(child: Text('1')), + Center(child: Text('2')), + ], + ), + ), + ), + ), + ), + ); + + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + final ScrollPosition position = pageController.position; + + expect(tabController.index, 0); + expect(position.pixels, 0.0); + + pageController.jumpTo(300.0); + await tester.pump(); + expect(tabController.animation!.value, pageController.page); + + // Touch TabBarView while ballistic scrolling is happening and + // check if tabController's animation value properly follows page value. + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(PageView)), + ); + await tester.pump(); + expect(tabController.animation!.value, pageController.page); + + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('Does not instantiate intermediate tabs during animation', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/14316. + final log = <String>[]; + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 5, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab(text: 'car'), + Tab(text: 'transit'), + Tab(text: 'bike'), + Tab(text: 'boat'), + Tab(text: 'bus'), + ], + ), + title: const Text('Tabs Test'), + ), + body: TabBarView( + children: <Widget>[ + TabBody(index: 0, log: log), + TabBody(index: 1, log: log), + TabBody(index: 2, log: log), + TabBody(index: 3, log: log), + TabBody(index: 4, log: log), + ], + ), + ), + ), + ), + ); + + expect(find.text('0'), findsOneWidget); + expect(find.text('3'), findsNothing); + expect(log, <String>['init: 0']); + + await tester.tap(find.text('boat')); + await tester.pumpAndSettle(); + + expect(find.text('0'), findsNothing); + expect(find.text('3'), findsOneWidget); + + // No other tab got instantiated during the animation. + expect(log, <String>['init: 0', 'init: 3', 'dispose: 0']); + }); + + testWidgets( + "TabController's animation value should be updated when TabController's index >= tabs's length", + (WidgetTester tester) async { + // This is a regression test for the issue brought up here + // https://github.com/flutter/flutter/issues/79226 + + final tabs = <String>['A', 'B', 'C']; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + bottom: TabBar(tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList()), + actions: <Widget>[ + TextButton( + child: const Text('Remove Last Tab'), + onPressed: () { + setState(() { + tabs.removeLast(); + }); + }, + ), + ], + ), + body: TabBarView( + children: tabs + .map<Widget>((String tab) => Tab(text: 'Tab child $tab')) + .toList(), + ), + ), + ); + }, + ), + ), + ); + + TabController getController() => DefaultTabController.of(tester.element(find.text('B'))); + TabController controller = getController(); + + controller.animateTo(2, duration: const Duration(milliseconds: 200), curve: Curves.linear); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + controller = getController(); + expect(controller.index, 2); + expect(controller.animation!.value, 2); + + await tester.tap(find.text('Remove Last Tab')); + await tester.pumpAndSettle(); + + controller = getController(); + expect(controller.index, 1); + expect(controller.animation!.value, 1); + }, + ); + + testWidgets('Tab preferredSize gives correct value', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Row( + children: <Tab>[ + Tab(icon: Icon(Icons.message)), + Tab(text: 'Two'), + Tab(text: 'Three', icon: Icon(Icons.chat)), + ], + ), + ), + ), + ); + + final Tab firstTab = tester.widget(find.widgetWithIcon(Tab, Icons.message)); + final Tab secondTab = tester.widget(find.widgetWithText(Tab, 'Two')); + final Tab thirdTab = tester.widget(find.widgetWithText(Tab, 'Three')); + + expect(firstTab.preferredSize, const Size.fromHeight(46.0)); + expect(secondTab.preferredSize, const Size.fromHeight(46.0)); + expect(thirdTab.preferredSize, const Size.fromHeight(72.0)); + }); + + testWidgets( + 'TabBar preferredSize gives correct value when there are both icon and text in tabs', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 5, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab(text: 'car'), + Tab(text: 'transit'), + Tab(text: 'bike'), + Tab(text: 'boat', icon: Icon(Icons.message)), + Tab(text: 'bus'), + ], + ), + title: const Text('Tabs Test'), + ), + ), + ), + ), + ); + + final TabBar tabBar = tester.widget(find.widgetWithText(TabBar, 'car')); + + expect(tabBar.preferredSize, const Size.fromHeight(74.0)); + }, + ); + + testWidgets('TabBar preferredSize gives correct value when there is only icon or text in tabs', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 5, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab(text: 'car'), + Tab(icon: Icon(Icons.message)), + Tab(text: 'bike'), + Tab(icon: Icon(Icons.chat)), + Tab(text: 'bus'), + ], + ), + title: const Text('Tabs Test'), + ), + ), + ), + ), + ); + + final TabBar tabBar = tester.widget(find.widgetWithText(TabBar, 'car')); + + expect(tabBar.preferredSize, const Size.fromHeight(48.0)); + }); + + testWidgets('Tabs are given uniform padding in case of few tabs having both text and icon', ( + WidgetTester tester, + ) async { + const EdgeInsetsGeometry expectedPaddingAdjusted = EdgeInsets.symmetric( + vertical: 13.0, + horizontal: 16.0, + ); + const EdgeInsetsGeometry expectedPaddingDefault = EdgeInsets.symmetric(horizontal: 16.0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: createTabController(length: 3, vsync: const TestVSync()), + tabs: const <Widget>[ + Tab(text: 'Tab 1', icon: Icon(Icons.plus_one)), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Padding tabOne = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 1').first); + final Padding tabTwo = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 2').first); + final Padding tabThree = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 3').first); + + expect(tabOne.padding, expectedPaddingDefault); + expect(tabTwo.padding, expectedPaddingAdjusted); + expect(tabThree.padding, expectedPaddingAdjusted); + }); + + testWidgets('Tabs are given uniform padding when labelPadding is given', ( + WidgetTester tester, + ) async { + const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0); + const EdgeInsetsGeometry expectedPaddingAdjusted = EdgeInsets.symmetric( + vertical: 23.0, + horizontal: 20.0, + ); + const EdgeInsetsGeometry expectedPaddingDefault = EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 20.0, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + labelPadding: labelPadding, + controller: createTabController(length: 3, vsync: const TestVSync()), + tabs: const <Widget>[ + Tab(text: 'Tab 1', icon: Icon(Icons.plus_one)), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Padding tabOne = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 1').first); + final Padding tabTwo = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 2').first); + final Padding tabThree = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 3').first); + + expect(tabOne.padding, expectedPaddingDefault); + expect(tabTwo.padding, expectedPaddingAdjusted); + expect(tabThree.padding, expectedPaddingAdjusted); + }); + + testWidgets('Tabs are given uniform padding TabBarTheme.labelPadding is given', ( + WidgetTester tester, + ) async { + const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric(vertical: 15.0, horizontal: 20); + const EdgeInsetsGeometry expectedPaddingAdjusted = EdgeInsets.symmetric( + vertical: 28.0, + horizontal: 20.0, + ); + const EdgeInsetsGeometry expectedPaddingDefault = EdgeInsets.symmetric( + vertical: 15.0, + horizontal: 20.0, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(labelPadding: labelPadding)), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: createTabController(length: 3, vsync: const TestVSync()), + tabs: const <Widget>[ + Tab(text: 'Tab 1', icon: Icon(Icons.plus_one)), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Padding tabOne = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 1').first); + final Padding tabTwo = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 2').first); + final Padding tabThree = tester.widget<Padding>(find.widgetWithText(Padding, 'Tab 3').first); + + expect(tabOne.padding, expectedPaddingDefault); + expect(tabTwo.padding, expectedPaddingAdjusted); + expect(tabThree.padding, expectedPaddingAdjusted); + }); + + testWidgets('Change tab bar height', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: DefaultTabController( + length: 4, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab( + icon: Icon(Icons.check, size: 40), + height: 85, + child: Text('1 - OK', style: TextStyle(fontSize: 25)), + ), // icon and child + Tab(height: 85, child: Text('2 - OK', style: TextStyle(fontSize: 25))), // child + Tab(icon: Icon(Icons.done, size: 40), height: 85), // icon + Tab(text: '4 - OK', height: 85), // text + ], + ), + ), + ), + ), + ), + ); + final Tab firstTab = tester.widget(find.widgetWithIcon(Tab, Icons.check)); + final Tab secTab = tester.widget(find.widgetWithText(Tab, '2 - OK')); + final Tab thirdTab = tester.widget(find.widgetWithIcon(Tab, Icons.done)); + final Tab fourthTab = tester.widget(find.widgetWithText(Tab, '4 - OK')); + expect(firstTab.preferredSize.height, 85); + expect(firstTab.height, 85); + expect(secTab.height, 85); + expect(thirdTab.height, 85); + expect(fourthTab.height, 85); + }); + + testWidgets('Change tab bar height 2', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 1, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: <Widget>[ + Tab( + icon: Icon(Icons.check, size: 40), + text: '1 - OK', + height: 85, + ), // icon and text + ], + ), + ), + ), + ), + ), + ); + final Tab firstTab = tester.widget(find.widgetWithIcon(Tab, Icons.check)); + expect(firstTab.height, 85); + }); + + testWidgets('Test semantics of TabPageSelector', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + final TabController controller = createTabController(vsync: const TestVSync(), length: 2); + + await tester.pumpWidget( + boilerplate( + child: Column( + children: <Widget>[ + TabBar( + controller: controller, + indicatorWeight: 30.0, + tabs: const <Widget>[ + Tab(text: 'TAB1'), + Tab(text: 'TAB2'), + ], + ), + Flexible( + child: TabBarView( + controller: controller, + children: const <Widget>[Text('PAGE1'), Text('PAGE2')], + ), + ), + Expanded(child: TabPageSelector(controller: controller)), + ], + ), + ), + ); + + final expectedSemantics = TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + role: SemanticsRole.tabBar, + children: <TestSemantics>[ + TestSemantics( + label: 'TAB1${kIsWeb ? '' : '\nTab 1 of 2'}', + flags: <SemanticsFlag>[ + SemanticsFlag.isFocusable, + SemanticsFlag.isSelected, + SemanticsFlag.hasSelectedState, + ], + rect: TestSemantics.fullScreen, + actions: 1 | SemanticsAction.focus.index, + role: SemanticsRole.tab, + ), + TestSemantics( + label: 'TAB2${kIsWeb ? '' : '\nTab 2 of 2'}', + flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState], + rect: TestSemantics.fullScreen, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + role: SemanticsRole.tab, + ), + ], + ), + TestSemantics( + rect: TestSemantics.fullScreen, + children: <TestSemantics>[ + TestSemantics( + rect: TestSemantics.fullScreen, + actions: <SemanticsAction>[SemanticsAction.scrollLeft], + children: <TestSemantics>[ + TestSemantics( + rect: TestSemantics.fullScreen, + label: 'PAGE1', + role: SemanticsRole.tabPanel, + ), + ], + ), + ], + ), + TestSemantics(label: 'Tab 1 of 2', textDirection: TextDirection.ltr), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreRect: true, ignoreTransform: true, ignoreId: true), + ); + + semantics.dispose(); + }); + + testWidgets( + 'Change the TabController should make both TabBar and TabBarView return to the initial index.', + (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/93237 + + Widget buildFrame(TabController controller, {required bool showLast}) { + return boilerplate( + child: Column( + children: <Widget>[ + TabBar( + controller: controller, + tabs: <Tab>[ + const Tab(text: 'one'), + const Tab(text: 'two'), + if (showLast) const Tab(text: 'three'), + ], + ), + Flexible( + child: TabBarView( + controller: controller, + children: <Widget>[ + const Text('PAGE1'), + const Text('PAGE2'), + if (showLast) const Text('PAGE3'), + ], + ), + ), + ], + ), + ); + } + + final TabController controller1 = createTabController(vsync: const TestVSync(), length: 3); + + final TabController controller2 = createTabController(vsync: const TestVSync(), length: 2); + + final TabController controller3 = createTabController(vsync: const TestVSync(), length: 3); + + await tester.pumpWidget(buildFrame(controller1, showLast: true)); + final PageView pageView = tester.widget(find.byType(PageView)); + final PageController pageController = pageView.controller!; + await tester.tap(find.text('three')); + await tester.pumpAndSettle(); + expect(controller1.index, 2); + expect(pageController.page, 2); + + // Change TabController from 3 items to 2. + await tester.pumpWidget(buildFrame(controller2, showLast: false)); + await tester.pumpAndSettle(); + expect(controller2.index, 0); + expect(pageController.page, 0); + + // Change TabController from 2 items to 3. + await tester.pumpWidget(buildFrame(controller3, showLast: true)); + await tester.pumpAndSettle(); + expect(controller3.index, 0); + expect(pageController.page, 0); + + await tester.tap(find.text('three')); + await tester.pumpAndSettle(); + + expect(controller3.index, 2); + expect(pageController.page, 2); + }, + ); + + testWidgets('Do not crash when the new TabController.index is longer than the old length.', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/97441 + + Widget buildFrame(TabController controller, {required bool showLast}) { + return boilerplate( + child: Column( + children: <Widget>[ + TabBar( + controller: controller, + tabs: <Tab>[ + const Tab(text: 'one'), + const Tab(text: 'two'), + if (showLast) const Tab(text: 'three'), + ], + ), + Flexible( + child: TabBarView( + controller: controller, + children: <Widget>[ + const Text('PAGE1'), + const Text('PAGE2'), + if (showLast) const Text('PAGE3'), + ], + ), + ), + ], + ), + ); + } + + final TabController controller1 = createTabController(vsync: const TestVSync(), length: 3); + + final TabController controller2 = createTabController(vsync: const TestVSync(), length: 2); + + await tester.pumpWidget(buildFrame(controller1, showLast: true)); + PageView pageView = tester.widget(find.byType(PageView)); + PageController pageController = pageView.controller!; + await tester.tap(find.text('three')); + await tester.pumpAndSettle(); + expect(controller1.index, 2); + expect(pageController.page, 2); + + // Change TabController from controller1 to controller2. + await tester.pumpWidget(buildFrame(controller2, showLast: false)); + await tester.pumpAndSettle(); + pageView = tester.widget(find.byType(PageView)); + pageController = pageView.controller!; + expect(controller2.index, 0); + expect(pageController.page, 0); + + // Change TabController back to 'controller1' whose index is 2. + await tester.pumpWidget(buildFrame(controller1, showLast: true)); + await tester.pumpAndSettle(); + pageView = tester.widget(find.byType(PageView)); + pageController = pageView.controller!; + expect(controller1.index, 2); + expect(pageController.page, 2); + }); + + testWidgets('TabBar InkWell splashFactory and overlayColor', (WidgetTester tester) async { + const InteractiveInkFeatureFactory splashFactory = NoSplash.splashFactory; + final WidgetStateProperty<Color?> overlayColor = WidgetStateProperty.resolveWith<Color?>( + (Set<WidgetState> states) => Colors.transparent, + ); + + // TabBarTheme splashFactory and overlayColor + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: TabBarThemeData(splashFactory: splashFactory, overlayColor: overlayColor), + ), + home: DefaultTabController( + length: 1, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabs: <Widget>[Container(width: 100, height: 100, color: Colors.green)], + ), + ), + ), + ), + ), + ); + + expect(tester.widget<InkWell>(find.byType(InkWell)).splashFactory, splashFactory); + expect(tester.widget<InkWell>(find.byType(InkWell)).overlayColor, overlayColor); + + // TabBar splashFactory and overlayColor + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 1, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + splashFactory: splashFactory, + overlayColor: overlayColor, + tabs: <Widget>[Container(width: 100, height: 100, color: Colors.green)], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); // theme animation + expect(tester.widget<InkWell>(find.byType(InkWell)).splashFactory, splashFactory); + expect(tester.widget<InkWell>(find.byType(InkWell)).overlayColor, overlayColor); + }); + + testWidgets('splashBorderRadius is passed to InkWell.borderRadius', (WidgetTester tester) async { + const hoverColor = Color(0xfff44336); + const double radius = 20; + await tester.pumpWidget( + boilerplate( + child: DefaultTabController( + length: 1, + child: TabBar( + overlayColor: WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) { + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + return Colors.black54; + }), + splashBorderRadius: const BorderRadius.all(Radius.circular(radius)), + tabs: const <Widget>[Tab(child: Text(''))], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.moveTo(tester.getCenter(find.byType(Tab))); + await tester.pumpAndSettle(); + final RenderObject object = tester.allRenderObjects.firstWhere( + (RenderObject element) => element.runtimeType.toString() == '_RenderInkFeatures', + ); + expect( + object, + paints..rrect( + color: hoverColor, + rrect: RRect.fromRectAndRadius( + tester.getRect(find.byType(InkWell)), + const Radius.circular(radius), + ), + ), + ); + await gesture.removePointer(); + }); + + testWidgets('No crash if TabBar build called before didUpdateWidget with SliverAppBar', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/154484. + final tabs = <String>[]; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DefaultTabController( + length: tabs.length, + child: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverAppBar( + actions: <Widget>[ + TextButton( + child: const Text('Add Tab'), + onPressed: () { + setState(() { + tabs.add('Tab ${tabs.length + 1}'); + }); + }, + ), + ], + bottom: TabBar(tabs: tabs.map((String tab) => Tab(text: tab)).toList()), + ), + ], + ), + ), + ); + }, + ), + ), + ); + + // Initializes with zero tabs. + expect(find.text('Tab 1'), findsNothing); + expect(find.text('Tab 2'), findsNothing); + + // No crash after tabs added. + await tester.tap(find.text('Add Tab')); + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Tab 2'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Do not crash if the controller and TabBarView are updated at different phases(build and layout) of the same frame', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/104994. + var tabTextContent = <String>[]; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DefaultTabController( + length: tabTextContent.length, + child: Scaffold( + appBar: AppBar( + title: const Text('Default TabBar Preview'), + bottom: tabTextContent.isNotEmpty + ? TabBar( + isScrollable: true, + tabs: tabTextContent + .map((String textContent) => Tab(text: textContent)) + .toList(), + ) + : null, + ), + body: LayoutBuilder( + builder: (_, _) { + return tabTextContent.isNotEmpty + ? TabBarView( + children: tabTextContent + .map((String textContent) => Tab(text: "$textContent's view")) + .toList(), + ) + : const Center(child: Text('No tabs')); + }, + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + IconButton( + key: const Key('Add tab'), + icon: const Icon(Icons.add), + onPressed: () { + setState(() { + tabTextContent = List<String>.of(tabTextContent) + ..add('Tab ${tabTextContent.length + 1}'); + }); + }, + ), + IconButton( + key: const Key('Delete tab'), + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + tabTextContent = List<String>.of(tabTextContent)..removeLast(); + }); + }, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + + // Initializes with zero tabs properly + expect(find.text('No tabs'), findsOneWidget); + await tester.tap(find.byKey(const Key('Add tab'))); + await tester.pumpAndSettle(); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text("Tab 1's view"), findsOneWidget); + + // Dynamically updates to zero tabs properly + await tester.tap(find.byKey(const Key('Delete tab'))); + await tester.pumpAndSettle(); + expect(find.text('No tabs'), findsOneWidget); + }, + ); + + testWidgets("Throw if the controller's length mismatch the tabs count", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabs: <Widget>[Container(width: 100, height: 100, color: Colors.green)], + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets("Throw if the controller's length mismatch the TabBarView‘s children count", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 1, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabs: <Widget>[Container(width: 100, height: 100, color: Colors.green)], + ), + ), + body: const TabBarView( + children: <Widget>[ + Icon(Icons.directions_car), + Icon(Icons.directions_transit), + Icon(Icons.directions_bike), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('Tab has correct selected/unselected hover color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'C', useMaterial3: theme.useMaterial3)); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, isNot(paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08)))); + expect(inkFeatures, isNot(paints..rect(color: theme.colorScheme.primary.withOpacity(0.08)))); + + // Start hovering unselected tab. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Tab).first)); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); + + // Start hovering selected tab. + await gesture.moveTo(tester.getCenter(find.byType(Tab).last)); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + }); + + testWidgets('Tab has correct selected/unselected focus color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final theme = ThemeData(); + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget( + MaterialApp( + home: buildFrame(tabs: tabs, value: 'B', useMaterial3: theme.useMaterial3), + ), + ); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, isNot(paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.1)))); + expect(inkFeatures, isNot(paints..rect(color: theme.colorScheme.primary.withOpacity(0.1)))); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(tester.binding.focusManager.primaryFocus?.hasPrimaryFocus, isTrue); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.1))); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(tester.binding.focusManager.primaryFocus?.hasPrimaryFocus, isTrue); + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.1))); + }); + + testWidgets('Tab has correct selected/unselected pressed color', (WidgetTester tester) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B', 'C']; + + await tester.pumpWidget( + MaterialApp( + home: buildFrame(tabs: tabs, value: 'B', useMaterial3: theme.useMaterial3), + ), + ); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, isNot(paints..rect(color: theme.colorScheme.primary.withOpacity(0.1)))); + + // Press unselected tab. + TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pumpAndSettle(); // Let the press highlight animation finish. + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.1))); + + // Release pressed gesture. + await gesture.up(); + await tester.pumpAndSettle(); + + // Press selected tab. + gesture = await tester.startGesture(tester.getCenter(find.text('B'))); + await tester.pumpAndSettle(); // Let the press highlight animation finish. + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.1))); + }); + + testWidgets('Material3 - Default TabAlignment', (WidgetTester tester) async { + final tabs = <String>['A', 'B']; + const tabStartOffset = 52.0; + + // Test default TabAlignment when isScrollable is false. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B')); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should fill the width of the TabBar. + double tabOneLeft = ((tabBar.width / 2) - tabOneRect.width) / 2; + expect(tabOneRect.left, moreOrLessEquals(tabOneLeft)); + double tabTwoRight = tabBar.width - ((tabBar.width / 2) - tabTwoRect.width) / 2; + expect(tabTwoRect.right, moreOrLessEquals(tabTwoRight)); + + // Test default TabAlignment when isScrollable is true. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', isScrollable: true)); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar. + tabOneLeft = kTabLabelPadding.left + tabStartOffset; + expect(tabOneRect.left, moreOrLessEquals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + + tabStartOffset + + tabOneRect.width + + kTabLabelPadding.left + + tabTwoRect.width; + expect(tabTwoRect.right, moreOrLessEquals(tabTwoRight)); + }); + + testWidgets('TabAlignment.fill only supports non-scrollable tab bar', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B']; + + // Test TabAlignment.fill with non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.fill), + ), + ); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.fill with scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + tabAlignment: TabAlignment.fill, + isScrollable: true, + ), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('TabAlignment.start & TabAlignment.startOffset only supports scrollable tab bar', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final tabs = <String>['A', 'B']; + + // Test TabAlignment.start with scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + tabAlignment: TabAlignment.start, + isScrollable: true, + ), + ), + ); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.start with non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.start), + ), + ); + + expect(tester.takeException(), isAssertionError); + + // Test TabAlignment.startOffset with scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + tabAlignment: TabAlignment.startOffset, + isScrollable: true, + ), + ), + ); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.startOffset with non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.startOffset), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('Material3 - TabAlignment updates tabs alignment (non-scrollable TabBar)', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B']; + + // Test TabAlignment.fill (default) when isScrollable is false. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B')); + + const availableWidth = 800.0; + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // By defaults tabs should fill the width of the TabBar. + double tabOneLeft = ((availableWidth / 2) - tabOneRect.width) / 2; + expect(tabOneRect.left, moreOrLessEquals(tabOneLeft)); + double tabTwoRight = availableWidth - ((availableWidth / 2) - tabTwoRect.width) / 2; + expect(tabTwoRect.right, moreOrLessEquals(tabTwoRight)); + + // Test TabAlignment.center when isScrollable is false. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.center)); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should not fill the width of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, moreOrLessEquals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, moreOrLessEquals(tabTwoRight)); + }); + + testWidgets('Material3 - TabAlignment updates tabs alignment (scrollable TabBar)', ( + WidgetTester tester, + ) async { + final tabs = <String>['A', 'B']; + const tabStartOffset = 52.0; + + // Test TabAlignment.startOffset (default) when isScrollable is true. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', isScrollable: true)); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // By default tabs should be aligned to the start of the TabBar with + // an horizontal offset of 52.0 pixels. + double tabOneLeft = kTabLabelPadding.left + tabStartOffset; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = + tabStartOffset + + kTabLabelPadding.horizontal + + tabOneRect.width + + kTabLabelPadding.left + + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.start when isScrollable is true. + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'B', isScrollable: true, tabAlignment: TabAlignment.start), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.center when isScrollable is true. + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'B', isScrollable: true, tabAlignment: TabAlignment.center), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be centered in the TabBar. + tabOneLeft = (tabBar.width / 2) - tabOneRect.width - kTabLabelPadding.right; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = (tabBar.width / 2) + tabTwoRect.width + kTabLabelPadding.left; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.startOffset when isScrollable is true. + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.startOffset, + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar with an + // horizontal offset of 52.0 pixels. + tabOneLeft = kTabLabelPadding.left + tabStartOffset; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = + tabStartOffset + + kTabLabelPadding.horizontal + + tabOneRect.width + + kTabLabelPadding.left + + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets( + 'Material3 - TabAlignment.start & TabAlignment.startOffset respects TextDirection.rtl', + (WidgetTester tester) async { + final tabs = <String>['A', 'B']; + const tabStartOffset = 52.0; + + // Test TabAlignment.startOffset (default) when isScrollable is true. + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'B', isScrollable: true, textDirection: TextDirection.rtl), + ); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar with an + // horizontal offset of 52.0 pixels. + double tabOneRight = tabBar.width - kTabLabelPadding.right - tabStartOffset; + expect(tabOneRect.right, equals(tabOneRight)); + double tabTwoLeft = + tabBar.width - + tabStartOffset - + kTabLabelPadding.horizontal - + tabOneRect.width - + kTabLabelPadding.right - + tabTwoRect.width; + expect(tabTwoRect.left, equals(tabTwoLeft)); + + // Test TabAlignment.start when isScrollable is true. + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.start, + textDirection: TextDirection.rtl, + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar. + tabOneRight = tabBar.width - kTabLabelPadding.right; + expect(tabOneRect.right, equals(tabOneRight)); + tabTwoLeft = + tabBar.width - + kTabLabelPadding.horizontal - + tabOneRect.width - + kTabLabelPadding.left - + tabTwoRect.width; + expect(tabTwoRect.left, equals(tabTwoLeft)); + + // Test TabAlignment.startOffset when isScrollable is true. + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.startOffset, + textDirection: TextDirection.rtl, + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar with an + // horizontal offset of 52.0 pixels. + tabOneRight = tabBar.width - kTabLabelPadding.right - tabStartOffset; + expect(tabOneRect.right, equals(tabOneRight)); + tabTwoLeft = + tabBar.width - + tabStartOffset - + kTabLabelPadding.horizontal - + tabOneRect.width - + kTabLabelPadding.right - + tabTwoRect.width; + expect(tabTwoRect.left, equals(tabTwoLeft)); + }, + ); + + testWidgets('Material3 - TabBar inherits the dividerColor of TabBarTheme', ( + WidgetTester tester, + ) async { + const Color dividerColor = Colors.yellow; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(dividerColor: dividerColor)), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: createTabController(length: 3, vsync: const TestVSync()), + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + // Test painter's divider color. + final CustomPaint paint = tester.widget<CustomPaint>(find.byType(CustomPaint).last); + expect((paint.painter as dynamic).dividerColor, dividerColor); + }); + + // This is a regression test for https://github.com/flutter/flutter/pull/125974#discussion_r1239089151. + testWidgets('Divider can be constrained', (WidgetTester tester) async { + const Color dividerColor = Colors.yellow; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: const TabBarThemeData(dividerColor: dividerColor)), + home: Scaffold( + body: DefaultTabController( + length: 2, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: ColoredBox( + color: Colors.grey[200]!, + child: const TabBar.secondary( + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: <Widget>[ + Tab(text: 'Test 1'), + Tab(text: 'Test 2'), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + // Test tab bar width. + expect(tester.getSize(find.byType(TabBar)).width, 360); + // Test divider width. + expect(tester.getSize(find.byType(CustomPaint).at(1)).width, 360); + }); + + testWidgets('TabBar labels use colors from labelStyle & unselectedLabelStyle', ( + WidgetTester tester, + ) async { + const tab1 = 'Tab 1'; + const tab2 = 'Tab 2'; + + const labelStyle = TextStyle(color: Color(0xff0000ff), fontStyle: FontStyle.italic); + const unselectedLabelStyle = TextStyle(color: Color(0x950000ff), fontStyle: FontStyle.italic); + + // Test tab bar with labelStyle & unselectedLabelStyle. + await tester.pumpWidget( + boilerplate( + child: const DefaultTabController( + length: 2, + child: TabBar( + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + tabs: <Widget>[ + Tab(text: tab1), + Tab(text: tab2), + ], + ), + ), + ), + ); + + final IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(tab1))); + final IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(tab2))); + final TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab1)) + .text + .style!; + final TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab2)) + .text + .style!; + + // Selected tab should use the labelStyle color. + expect(selectedTabIcon.color, labelStyle.color); + expect(selectedTextStyle.color, labelStyle.color); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use the unselectedLabelStyle color. + expect(unselectedTabIcon.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + }); + + testWidgets( + 'labelColor & unselectedLabelColor override labelStyle & unselectedLabelStyle colors', + (WidgetTester tester) async { + const tab1 = 'Tab 1'; + const tab2 = 'Tab 2'; + + const labelColor = Color(0xfff00000); + const unselectedLabelColor = Color(0x95ff0000); + const labelStyle = TextStyle(color: Color(0xff0000ff), fontStyle: FontStyle.italic); + const unselectedLabelStyle = TextStyle(color: Color(0x950000ff), fontStyle: FontStyle.italic); + + Widget buildTabBar({Color? labelColor, Color? unselectedLabelColor}) { + return boilerplate( + child: DefaultTabController( + length: 2, + child: TabBar( + labelColor: labelColor, + unselectedLabelColor: unselectedLabelColor, + labelStyle: labelStyle, + unselectedLabelStyle: unselectedLabelStyle, + tabs: const <Widget>[ + Tab(text: tab1), + Tab(text: tab2), + ], + ), + ), + ); + } + + // Test tab bar with labelStyle & unselectedLabelStyle. + await tester.pumpWidget(buildTabBar()); + + IconThemeData selectedTabIcon = IconTheme.of(tester.element(find.text(tab1))); + IconThemeData unselectedTabIcon = IconTheme.of(tester.element(find.text(tab2))); + TextStyle selectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab1)) + .text + .style!; + TextStyle unselectedTextStyle = tester + .renderObject<RenderParagraph>(find.text(tab2)) + .text + .style!; + + // Selected tab should use labelStyle color. + expect(selectedTabIcon.color, labelStyle.color); + expect(selectedTextStyle.color, labelStyle.color); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use unselectedLabelStyle color. + expect(unselectedTabIcon.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.color, unselectedLabelStyle.color); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + + // Update tab bar with labelColor & unselectedLabelColor. + await tester.pumpWidget( + buildTabBar(labelColor: labelColor, unselectedLabelColor: unselectedLabelColor), + ); + await tester.pumpAndSettle(); + + selectedTabIcon = IconTheme.of(tester.element(find.text(tab1))); + unselectedTabIcon = IconTheme.of(tester.element(find.text(tab2))); + selectedTextStyle = tester.renderObject<RenderParagraph>(find.text(tab1)).text.style!; + unselectedTextStyle = tester.renderObject<RenderParagraph>(find.text(tab2)).text.style!; + + // Selected tab should use the labelColor. + expect(selectedTabIcon.color, labelColor); + expect(selectedTextStyle.color, labelColor); + expect(selectedTextStyle.fontStyle, labelStyle.fontStyle); + // Unselected tab should use the unselectedLabelColor. + expect(unselectedTabIcon.color, unselectedLabelColor); + expect(unselectedTextStyle.color, unselectedLabelColor); + expect(unselectedTextStyle.fontStyle, unselectedLabelStyle.fontStyle); + }, + ); + + // This is a regression test for https://github.com/flutter/flutter/issues/140338. + testWidgets('Material3 - Scrollable TabBar without a divider does not expand to full width', ( + WidgetTester tester, + ) async { + Widget buildTabBar({Color? dividerColor, double? dividerHeight, TabAlignment? tabAlignment}) { + return boilerplate( + child: Center( + child: DefaultTabController( + length: 3, + child: TabBar( + dividerColor: dividerColor, + dividerHeight: dividerHeight, + tabAlignment: tabAlignment, + isScrollable: true, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ); + } + + // Test default tab bar width when there is a divider and tabAlignment + // is set to startOffset. + await tester.pumpWidget(buildTabBar(tabAlignment: TabAlignment.start)); + expect(tester.getSize(find.byType(TabBar)).width, 800.0); + + // Test default tab bar width when there is a divider and tabAlignment + // is set to start. + await tester.pumpWidget(buildTabBar(tabAlignment: TabAlignment.startOffset)); + expect(tester.getSize(find.byType(TabBar)).width, 800.0); + + // Test default tab bar width when there is a divider and tabAlignment + // tabAlignment is set to center. + await tester.pumpWidget(buildTabBar(tabAlignment: TabAlignment.center)); + expect(tester.getSize(find.byType(TabBar)).width, 800.0); + + // Test default tab bar width when the divider height is set to 0.0 + // and tabAlignment is set to startOffset. + await tester.pumpWidget( + buildTabBar(dividerHeight: 0.0, tabAlignment: TabAlignment.startOffset), + ); + expect(tester.getSize(find.byType(TabBar)).width, 359.5); + + // Test default tab bar width when the divider height is set to 0.0 + // and tabAlignment is set to start. + await tester.pumpWidget(buildTabBar(dividerHeight: 0.0, tabAlignment: TabAlignment.start)); + expect(tester.getSize(find.byType(TabBar)).width, 307.5); + + // Test default tab bar width when the divider height is set to 0.0 + // and tabAlignment is set to center. + await tester.pumpWidget(buildTabBar(dividerHeight: 0.0, tabAlignment: TabAlignment.center)); + expect(tester.getSize(find.byType(TabBar)).width, 307.5); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('TabBar default selected/unselected text style', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + final tabs = <String>['A', 'B', 'C']; + + const selectedValue = 'A'; + const unSelectedValue = 'C'; + await tester.pumpWidget(buildFrame(useMaterial3: false, tabs: tabs, value: selectedValue)); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label text style. + expect( + tester.renderObject<RenderParagraph>(find.text(selectedValue)).text.style!.fontFamily, + 'Roboto', + ); + expect( + tester.renderObject<RenderParagraph>(find.text(selectedValue)).text.style!.fontSize, + 14.0, + ); + expect( + tester.renderObject<RenderParagraph>(find.text(selectedValue)).text.style!.color, + theme.primaryTextTheme.bodyLarge!.color, + ); + + // Test unselected label text style. + expect( + tester.renderObject<RenderParagraph>(find.text(unSelectedValue)).text.style!.fontFamily, + 'Roboto', + ); + expect( + tester.renderObject<RenderParagraph>(find.text(unSelectedValue)).text.style!.fontSize, + 14.0, + ); + expect( + tester.renderObject<RenderParagraph>(find.text(unSelectedValue)).text.style!.color, + theme.primaryTextTheme.bodyLarge!.color!.withAlpha(0xB2), // 70% alpha, + ); + }); + + testWidgets('TabBar default unselectedLabelColor inherits labelColor with 70% opacity', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/pull/116273 + final tabs = <String>['A', 'B', 'C']; + + const selectedValue = 'A'; + const unSelectedValue = 'C'; + const labelColor = Color(0xff0000ff); + await tester.pumpWidget( + buildFrame( + tabs: tabs, + value: selectedValue, + useMaterial3: false, + tabBarTheme: const TabBarThemeData(labelColor: labelColor), + ), + ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label color. + expect( + tester.renderObject<RenderParagraph>(find.text(selectedValue)).text.style!.color, + labelColor, + ); + + // Test unselected label color. + expect( + tester.renderObject<RenderParagraph>(find.text(unSelectedValue)).text.style!.color, + labelColor.withAlpha(0xB2), // 70% alpha, + ); + }); + + testWidgets('Material2 - Default TabAlignment', (WidgetTester tester) async { + final tabs = <String>['A', 'B']; + + // Test default TabAlignment when isScrollable is false. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', useMaterial3: false)); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should fill the width of the TabBar. + double tabOneLeft = ((tabBar.width / 2) - tabOneRect.width) / 2; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = tabBar.width - ((tabBar.width / 2) - tabTwoRect.width) / 2; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test default TabAlignment when isScrollable is true. + await tester.pumpWidget( + buildFrame(tabs: tabs, value: 'B', isScrollable: true, useMaterial3: false), + ); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabBar default tab indicator (primary)', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + MaterialApp( + home: boilerplate( + useMaterial3: theme.useMaterial3, + child: Container( + alignment: Alignment.topLeft, + child: TabBar(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const indicatorWeight = 2.0; + const double indicatorY = 48 - (indicatorWeight / 2.0); + const double indicatorLeft = indicatorWeight / 2.0; + const double indicatorRight = 200.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + color: theme.indicatorColor, + strokeWidth: indicatorWeight, + p1: const Offset(indicatorLeft, indicatorY), + p2: const Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('TabBar default tab indicator (secondary)', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + final tabs = List<Widget>.generate(4, (int index) { + return Tab(text: 'Tab $index'); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + MaterialApp( + home: boilerplate( + useMaterial3: theme.useMaterial3, + child: Container( + alignment: Alignment.topLeft, + child: TabBar.secondary(controller: controller, tabs: tabs), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + expect(tabBarBox.size.height, 48.0); + + const indicatorWeight = 2.0; + const double indicatorY = 48 - (indicatorWeight / 2.0); + const double indicatorLeft = indicatorWeight / 2.0; + const double indicatorRight = 200.0 - (indicatorWeight / 2.0); + + expect( + tabBarBox, + paints..line( + color: theme.indicatorColor, + strokeWidth: indicatorWeight, + p1: const Offset(indicatorLeft, indicatorY), + p2: const Offset(indicatorRight, indicatorY), + ), + ); + }); + + testWidgets('Material2 - TabBar with padding isScrollable: true', (WidgetTester tester) async { + const indicatorWeight = 2.0; // default indicator weight + const padding = EdgeInsets.only(left: 3.0, top: 7.0, right: 5.0, bottom: 3.0); + + final tabs = <Widget>[ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + padding: padding, + labelPadding: EdgeInsets.zero, + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + useMaterial3: false, + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + final double tabBarHeight = + 50.0 + indicatorWeight + padding.top + padding.bottom; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = padding.left; + double tabRight = tabLeft + 130.0; + double tabTop = + (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + var tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + tabRight += padding.right; + expect(tabBarBox.size.width, tabRight); + }); + + testWidgets('Material2 - TabAlignment updates tabs alignment (non-scrollable TabBar)', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + final tabs = <String>['A', 'B']; + + // Test TabAlignment.fill (default) when isScrollable is false. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B'), + ), + ); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // By default tabs should fill the width of the TabBar. + double tabOneLeft = ((tabBar.width / 2) - tabOneRect.width) / 2; + expect(tabOneRect.left, moreOrLessEquals(tabOneLeft)); + double tabTwoRight = tabBar.width - ((tabBar.width / 2) - tabTwoRect.width) / 2; + expect(tabTwoRect.right, moreOrLessEquals(tabTwoRight)); + + // Test TabAlignment.center when isScrollable is false. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.center), + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should not fill the width of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, moreOrLessEquals(tabOneLeft)); + tabTwoRight = + kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, moreOrLessEquals(tabTwoRight)); + }); + }); + + testWidgets('does not crash if switching to a newly added tab', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/144087. + Widget buildTabs(int tabCount) { + return boilerplate( + child: DefaultTabController( + length: tabCount, + child: Scaffold( + appBar: AppBar( + title: const Text('Flutter Demo Click Counter'), + bottom: TabBar( + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: List<Widget>.generate(tabCount, (int i) => Tab(text: 'Tab $i')), + ), + ), + body: TabBarView(children: List<Widget>.generate(tabCount, (int i) => Text('View $i'))), + ), + ), + ); + } + + await tester.pumpWidget(buildTabs(1)); + expect(tester.widgetList(find.byType(Tab)), hasLength(1)); + + await tester.pumpWidget(buildTabs(2)); + expect(tester.widgetList(find.byType(Tab)), hasLength(2)); + + await tester.pumpWidget(buildTabs(3)); + expect(tester.widgetList(find.byType(Tab)), hasLength(3)); + + expect(find.text('View 0'), findsOneWidget); + expect(find.text('View 2'), findsNothing); + await tester.tap(find.text('Tab 2')); + await tester.pumpAndSettle(); + expect(find.text('View 0'), findsNothing); + expect(find.text('View 2'), findsOneWidget); + }); + + testWidgets('Tab indicator painter image configuration', (WidgetTester tester) async { + final tabs = <String>['A', 'B']; + final decoration = TestIndicatorDecoration(); + + Widget buildTabs({TextDirection textDirection = TextDirection.ltr, double ratio = 1.0}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(devicePixelRatio: ratio), + child: Directionality( + textDirection: textDirection, + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + indicator: decoration, + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTabs()); + + ImageConfiguration config = decoration.painters.last.lastConfiguration!; + expect(config.size?.width, closeTo(14.1, 0.1)); + expect(config.size?.height, equals(48.0)); + expect(config.textDirection, TextDirection.ltr); + expect(config.devicePixelRatio, 1.0); + + await tester.pumpWidget(buildTabs(textDirection: TextDirection.rtl, ratio: 2.33)); + + config = decoration.painters.last.lastConfiguration!; + expect(config.size?.width, closeTo(14.1, 0.1)); + expect(config.size?.height, equals(48.0)); + expect(config.textDirection, TextDirection.rtl); + expect(config.devicePixelRatio, 2.33); + }); + + testWidgets( + 'TabBar.textScaler overrides tab label text scale, textScaleFactor = noScaling, 1.75, 2.0', + (WidgetTester tester) async { + final tabs = <String>['Tab 1', 'Tab 2']; + + Widget buildTabs({TextScaler? textScaler}) { + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(3.0)), + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + textScaler: textScaler, + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTabs(textScaler: TextScaler.noScaling)); + + Size labelSize = tester.getSize(find.text('Tab 1')); + expect(labelSize, equals(const Size(70.5, 20.0))); + + await tester.pumpWidget(buildTabs(textScaler: const TextScaler.linear(1.75))); + + labelSize = tester.getSize(find.text('Tab 1')); + expect(labelSize, equals(const Size(123.0, 35.0))); + + await tester.pumpWidget(buildTabs(textScaler: const TextScaler.linear(2.0))); + + labelSize = tester.getSize(find.text('Tab 1')); + expect(labelSize, equals(const Size(140.5, 40.0))); + }, + ); + + // This is a regression test for https://github.com/flutter/flutter/issues/150000. + testWidgets('Scrollable TabBar does not jitter in the middle position', ( + WidgetTester tester, + ) async { + final tabs = List<String>.generate(20, (int index) => 'Tab $index'); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: tabs.length, + initialIndex: 10, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + isScrollable: true, + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + children: <Widget>[ + for (int i = 0; i < tabs.length; i++) Center(child: Text('Page $i')), + ], + ), + ), + ), + ), + ); + + final SingleChildScrollView scrollable = tester.widget(find.byType(SingleChildScrollView)); + expect(find.text('Page 10'), findsOneWidget); + expect(find.text('Page 11'), findsNothing); + expect(scrollable.controller!.position.pixels, closeTo(683.2, 0.1)); + + // Drag the TabBarView to the left. + await tester.drag(find.byType(TabBarView), const Offset(-800, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Page 10'), findsNothing); + expect(find.text('Page 11'), findsOneWidget); + expect(scrollable.controller!.position.pixels, closeTo(799.8, 0.1)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/150000. + testWidgets('Scrollable TabBar does not jitter when the tab bar reaches the start', ( + WidgetTester tester, + ) async { + final tabs = List<String>.generate(20, (int index) => 'Tab $index'); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: tabs.length, + initialIndex: 4, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + isScrollable: true, + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + ), + ), + body: TabBarView( + children: <Widget>[ + for (int i = 0; i < tabs.length; i++) Center(child: Text('Page $i')), + ], + ), + ), + ), + ), + ); + + final SingleChildScrollView scrollable = tester.widget(find.byType(SingleChildScrollView)); + + expect(find.text('Page 4'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + expect(scrollable.controller!.position.pixels, closeTo(61.25, 0.1)); + + // Drag the TabBarView to the right. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Page 4'))); + await gesture.moveBy(const Offset(600.0, 0.0)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Page 4'), findsOneWidget); + expect(find.text('Page 3'), findsOneWidget); + expect(scrollable.controller!.position.pixels, closeTo(0.2, 0.1)); + + await tester.pumpAndSettle(); + expect(find.text('Page 4'), findsNothing); + expect(find.text('Page 3'), findsOneWidget); + expect(scrollable.controller!.position.pixels, equals(0.0)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/150316. + testWidgets('Scrollable TabBar with transparent divider expands to full width', ( + WidgetTester tester, + ) async { + Widget buildTabBar({Color? dividerColor, TabAlignment? tabAlignment}) { + return boilerplate( + child: Center( + child: DefaultTabController( + length: 3, + child: TabBar( + dividerColor: dividerColor, + tabAlignment: tabAlignment, + isScrollable: true, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ); + } + + // Test default tab bar width when the divider color is set to transparent + // and tabAlignment is set to startOffset. + await tester.pumpWidget( + buildTabBar(dividerColor: Colors.transparent, tabAlignment: TabAlignment.startOffset), + ); + expect(tester.getSize(find.byType(TabBar)).width, 800.0); + + // Test default tab bar width when the divider color is set to transparent + // and tabAlignment is set to start. + await tester.pumpWidget( + buildTabBar(dividerColor: Colors.transparent, tabAlignment: TabAlignment.start), + ); + expect(tester.getSize(find.byType(TabBar)).width, 800.0); + + // Test default tab bar width when the divider color is set to transparent + // and tabAlignment is set to center. + await tester.pumpWidget( + buildTabBar(dividerColor: Colors.transparent, tabAlignment: TabAlignment.center), + ); + expect(tester.getSize(find.byType(TabBar)).width, 800.0); + }); + + testWidgets('TabBar.indicatorAnimation can customize tab indicator animation', ( + WidgetTester tester, + ) async { + const indicatorWidth = 50.0; + final tabs = List<Widget>.generate(4, (int index) { + return Tab( + key: ValueKey<int>(index), + child: const SizedBox(width: indicatorWidth), + ); + }); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTab({TabIndicatorAnimation? indicatorAnimation}) { + return MaterialApp( + home: boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: indicatorAnimation, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } + + // Test tab indicator animation with TabIndicatorAnimation.linear. + await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.linear)); + + final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar)); + + // Idle at tab 0. + expect( + tabBarBox, + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + 75.0, + 45.0, + 125.0, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + // Start moving tab indicator. + controller.offset = 0.2; + await tester.pump(); + + expect( + tabBarBox, + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + 115.0, + 45.0, + 165.0, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + // Reset tab controller offset. + controller.offset = 0.0; + + // Test tab indicator animation with TabIndicatorAnimation.elastic. + await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic)); + await tester.pumpAndSettle(); + + // Idle at tab 0. + const currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + const fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + var toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.0), + ), + ); + + controller.offset = 0.2; + await tester.pump(); + toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2), + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/155518. + testWidgets('Tabs icon respects ambient icon theme', (WidgetTester tester) async { + final theme = ThemeData(iconTheme: const IconThemeData(color: Color(0xffff0000), size: 38.0)); + const IconData selectedIcon = Icons.ac_unit; + const IconData unselectedIcon = Icons.access_alarm; + await tester.pumpWidget( + boilerplate( + theme: theme, + child: const DefaultTabController( + length: 2, + child: TabBar( + tabs: <Widget>[ + Tab(icon: Icon(selectedIcon), text: 'Tab 1'), + Tab(icon: Icon(unselectedIcon), text: 'Tab 2'), + ], + ), + ), + ), + ); + + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + // The iconTheme color isn't applied to the selected icon. + expect(iconStyle(tester, selectedIcon).color, equals(theme.colorScheme.primary)); + // The iconTheme color is applied to the unselected icon. + expect(iconStyle(tester, unselectedIcon).color, equals(theme.iconTheme.color)); + + // Both selected and unselected icons should have the iconTheme size. + expect( + tester.getSize(find.byIcon(selectedIcon)), + Size(theme.iconTheme.size!, theme.iconTheme.size!), + ); + expect( + tester.getSize(find.byIcon(unselectedIcon)), + Size(theme.iconTheme.size!, theme.iconTheme.size!), + ); + }); + + testWidgets('Elastic Tab animation does not overflow target tab - LTR', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 92.50662931979836; + var indicatorRight = 241.31938023664574; + final Rect labelRect = tester.getRect(find.byType(Tab).at(2)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorRight, lessThan(labelRect.right)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 192.50227846755732; + indicatorRight = 282.61484607849377; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorRight, lessThan(labelRect.right)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + }); + + testWidgets('Elastic Tab animation does not overflow target tab - RTL', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 558.6806197633543; + var indicatorRight = 707.4933706802017; + final Rect labelRect = tester.getRect(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRect.left)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 517.3851539215062; + indicatorRight = 607.497721532442; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRect.left)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + }); + + testWidgets('Linear Tab animation does not overflow target tab - LTR', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.linear, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 131.26358723640442; + var indicatorRight = 199.26358723640442; + final Rect labelRect = tester.getRect(find.byType(Tab).at(2)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorRight, lessThan(labelRect.right)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 201.00625545158982; + indicatorRight = 269.0062554515898; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorRight, lessThan(labelRect.right)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + }); + + testWidgets('Linear Tab animation does not overflow target tab - RTL', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.linear, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 600.7364127635956; + var indicatorRight = 668.7364127635956; + final Rect labelRect = tester.getRect(find.byType(Tab).at(2)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRect.left)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 530.9937445484102; + indicatorRight = 598.9937445484102; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRect.left)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + }); + + testWidgets('Elastic Tab animation does not overflow target tab in a scrollable tab bar - LTR', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + isScrollable: true, + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 159.14390228994424; + var indicatorRight = 791.2121709715643; + Offset labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorRight, lessThan(labelRectRight.dx)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 346.2357603195887; + indicatorRight = 976.195212100479; + labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, lessThan(labelRectRight.dx)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 390.1999969482422; + indicatorRight = 982.4000091552734; + labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, lessThan(labelRectRight.dx)); + }); + + testWidgets('Elastic Tab animation does not overflow target tab in a scrollable tab bar - RTL', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + isScrollable: true, + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 1495.1878305543146; + var indicatorRight = 2127.2560992359345; + Offset labelRectLeft = tester.getBottomLeft(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRectLeft.dx)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 1310.2047894254; + indicatorRight = 1940.1642412062902; + labelRectLeft = tester.getBottomLeft(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRectLeft.dx)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 1303.9999923706055; + indicatorRight = 1896.2000045776367; + labelRectLeft = tester.getBottomLeft(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRectLeft.dx)); + }); + + testWidgets('Linear Tab animation does not overflow target tab in a scrollable tab bar - LTR', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + isScrollable: true, + indicatorAnimation: TabIndicatorAnimation.linear, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 159.9711660555031; + var indicatorRight = 453.47531034110943; + Offset labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorRight, lessThan(labelRectRight.dx)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 349.4619934677845; + indicatorRight = 888.8090538538061; + labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, lessThan(labelRectRight.dx)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 390.1999969482422; + indicatorRight = 982.4000091552734; + labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, lessThan(labelRectRight.dx)); + }); + + testWidgets('Linear Tab animation does not overflow target tab in a scrollable tab bar - RTL', ( + WidgetTester tester, + ) async { + final tabs = <Widget>[ + const Tab(text: 'Short'), + const Tab(text: 'A Bit Longer Text'), + const Tab(text: 'An Extremely Long Tab Label That Overflows'), + const Tab(text: 'Tiny'), + const Tab(text: 'Moderate Length'), + const Tab(text: 'Just Right'), + const Tab(text: 'Supercalifragilisticexpialidocious'), + const Tab(text: 'Longer Than Usual'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + isScrollable: true, + indicatorAnimation: TabIndicatorAnimation.linear, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + var indicatorLeft = 1832.9246911847695; + var indicatorRight = 2126.428835470376; + Offset labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRectRight.dx)); + + await tester.pump(const Duration(milliseconds: 100)); + + indicatorLeft = 1397.590947672073; + indicatorRight = 1936.9380080580945; + labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRectRight.dx)); + + // Let the animation complete. + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 1303.9999923706055; + indicatorRight = 1896.2000045776367; + labelRectRight = tester.getBottomRight(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + expect(indicatorLeft, greaterThan(labelRectRight.dx)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/160631 + testWidgets('Elastic Tab animation when skipping tabs', (WidgetTester tester) async { + final tabs = List<Widget>.generate(10, (int index) => Tab(text: 'Tab $index')); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + var indicatorLeft = 157.20182277404584; + var indicatorRight = 222.89187686279502; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.pumpAndSettle(); + + Rect labelRect = tester.getRect(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.tap(find.byType(Tab).last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 670.2063797091604; + indicatorRight = 780.1215690197826; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.pumpAndSettle(); + + labelRect = tester.getRect(find.byType(Tab).last); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.tap(find.byType(Tab).at(1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 100.43249254881991; + indicatorRight = 219.19270890381662; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.pumpAndSettle(); + labelRect = tester.getRect(find.byType(Tab).at(1)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162098 + testWidgets('Linear Tab animation when skipping tabs', (WidgetTester tester) async { + final tabs = List<Widget>.generate(10, (int index) => Tab(text: 'Tab $index')); + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget buildTabBar() { + return boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.linear, + controller: controller, + tabs: tabs, + ), + ), + ); + } + + await tester.pumpWidget(buildTabBar()); + + await tester.tap(find.byType(Tab).at(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + var indicatorLeft = 164.00500436127186; + var indicatorRight = 212.00500436127186; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.pumpAndSettle(); + + Rect labelRect = tester.getRect(find.byType(Tab).at(2)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.tap(find.byType(Tab).last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 694.0175152644515; + indicatorRight = 742.0175152644515; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.pumpAndSettle(); + + labelRect = tester.getRect(find.byType(Tab).last); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.tap(find.byType(Tab).at(1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + indicatorLeft = 143.97998255491257; + indicatorRight = 191.97998255491257; + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + indicatorLeft, + 45.0, + indicatorRight, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + + await tester.pumpAndSettle(); + labelRect = tester.getRect(find.byType(Tab).at(1)); + expect( + find.byType(TabBar), + paints..rrect( + rrect: RRect.fromLTRBAndCorners( + labelRect.left, + 45.0, + labelRect.right, + 48.0, + topLeft: const Radius.circular(3.0), + topRight: const Radius.circular(3.0), + ), + ), + ); + }); + + testWidgets('onHover is triggered when mouse pointer is over a tab', (WidgetTester tester) async { + final hoverEvents = <({bool hover, int index})>[]; + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + onHover: (bool value, int index) { + hoverEvents.add((hover: value, index: index)); + }, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + body: const TabBarView( + children: <Widget>[Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(hoverEvents.isEmpty, isTrue); + + // Hover over the first tab. + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('Tab 1'))); + await tester.pump(); + + // Hover entered first tab. + expect(hoverEvents, <({bool hover, int index})>[(hover: true, index: 0)]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 2'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 3'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.byType(TabBarView))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + (hover: false, index: 2), // Third tab hover exit + ]); + + hoverEvents.clear(); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar.secondary( + onHover: (bool value, int index) { + hoverEvents.add((hover: value, index: index)); + }, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + body: const TabBarView( + children: <Widget>[Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(hoverEvents.isEmpty, isTrue); + + // Hover over the first tab. + await gesture.moveTo(tester.getCenter(find.text('Tab 1'))); + await tester.pump(); + + // Hover enters first tab. + expect(hoverEvents, <({bool hover, int index})>[(hover: true, index: 0)]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 2'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.text('Tab 3'))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + ]); + + await gesture.moveTo(tester.getCenter(find.byType(TabBarView))); + await tester.pump(); + + expect(hoverEvents, <({bool hover, int index})>[ + (hover: true, index: 0), // First tab hover enter + (hover: false, index: 0), // First tab hover exit + (hover: true, index: 1), // Second tab hover enter + (hover: false, index: 1), // Second tab hover exit + (hover: true, index: 2), // Third tab hover enter + (hover: false, index: 2), // Third tab hover exit + ]); + }); + + testWidgets('onFocusChange is triggered when tabs gain and lose focus', ( + WidgetTester tester, + ) async { + final focusEvents = <({bool focus, int index})>[]; + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + onFocusChange: (bool value, int index) { + focusEvents.add((focus: value, index: index)); + }, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + body: const TabBarView( + children: <Widget>[Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(focusEvents.isEmpty, isTrue); + + // Focus on the first tab. + Element tabElement = tester.element(find.text('Tab 1')); + FocusNode node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + // Focus gained at first tab. + expect(focusEvents, <({bool focus, int index})>[(focus: true, index: 0)]); + + tabElement = tester.element(find.text('Tab 2')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + ]); + + tabElement = tester.element(find.text('Tab 3')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + expect(node.hasFocus, isTrue); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + ]); + + node.unfocus(); + await tester.pump(); + + expect(node.hasFocus, isFalse); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + (focus: false, index: 2), // Third tab loses focus + ]); + + focusEvents.clear(); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar.secondary( + onFocusChange: (bool value, int index) { + focusEvents.add((focus: value, index: index)); + }, + tabs: const <Widget>[ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + body: const TabBarView( + children: <Widget>[Text('Tab 1 View'), Text('Tab 2 View'), Text('Tab 3 View')], + ), + ), + ), + ), + ); + + expect(focusEvents.isEmpty, isTrue); + + // Focus on the first tab. + tabElement = tester.element(find.text('Tab 1')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + // Focus gained at first tab. + expect(focusEvents, <({bool focus, int index})>[(focus: true, index: 0)]); + + tabElement = tester.element(find.text('Tab 2')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + ]); + + tabElement = tester.element(find.text('Tab 3')); + node = Focus.of(tabElement); + node.requestFocus(); + await tester.pump(); + expect(node.hasFocus, isTrue); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + ]); + + node.unfocus(); + await tester.pump(); + + expect(node.hasFocus, isFalse); + expect(focusEvents, <({bool focus, int index})>[ + (focus: true, index: 0), // First tab gains focus + (focus: false, index: 0), // First tab loses focus + (focus: true, index: 1), // Second tab gains focus + (focus: false, index: 1), // Second tab loses focus + (focus: true, index: 2), // Third tab gains focus + (focus: false, index: 2), // Third tab loses focus + ]); + }); + + // Regression test for https://github.com/flutter/flutter/issues/141269. + testWidgets('Ink features are painted on inner Material', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DefaultTabController( + length: 10, + child: TabBar( + isScrollable: true, + tabs: <Widget>[for (int i = 1; i <= 10; i++) Tab(text: 'Tab $i')], + ), + ), + ), + ), + ), + ); + + expect(find.byType(Material), findsNWidgets(2)); + + // Material outside the TabBar. + final MaterialInkController outerMaterial = Material.of(tester.element(find.byType(TabBar))); + // Material directly wrapping the TabBar. + final MaterialInkController innerMaterial = Material.of( + tester.firstElement( + find.descendant(of: find.byType(TabBar), matching: find.byType(Semantics)), + ), + ); + + expect(outerMaterial, isNot(same(innerMaterial))); + expect((outerMaterial as dynamic).debugInkFeatures, isNull); + expect((innerMaterial as dynamic).debugInkFeatures, isNull); + + // Hover over the first tab to trigger the ink highlight. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: tester.getCenter(find.text('Tab 1'))); + addTearDown(gesture.removePointer); + await tester.pump(); + + // Only the inner Material should have ink features. + expect((outerMaterial as dynamic).debugInkFeatures, isNull); + expect((innerMaterial as dynamic).debugInkFeatures, hasLength(1)); + }); + + testWidgets('Tab can have children with other semantics roles', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: DefaultTabController( + length: 1, + child: TabBar( + isScrollable: true, + tabs: <Widget>[ + Tab( + child: Semantics(role: SemanticsRole.listItem, child: const Text('A')), + ), + ], + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('TabPageSelector does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TabController(length: 2, vsync: tester); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Center(child: TabPageSelector(controller: controller)), + ), + ); + expect(tester.getSize(find.byType(TabPageSelector)), Size.zero); + controller.animateTo(1); + await tester.pump(); + await tester.pumpAndSettle(); + }); + + testWidgets('TabBarView does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TabController(length: 2, vsync: tester); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: TabBarView(controller: controller, children: const <Widget>[Text('X'), Text('Y')]), + ), + ), + ); + expect(tester.getSize(find.byType(TabBarView)), Size.zero); + controller.animateTo(1); + await tester.pump(); + await tester.pumpAndSettle(); + }); + + testWidgets('TabPageSelectorIndicator does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: TabPageSelectorIndicator( + backgroundColor: Colors.red, + borderColor: Colors.blue, + size: 1, + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(TabPageSelectorIndicator)), Size.zero); + }); + + testWidgets('DefaultTabController does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink(child: DefaultTabController(length: 2, child: Scaffold())), + ), + ), + ); + expect(tester.getSize(find.byType(DefaultTabController)), Size.zero); + }); + + testWidgets('Tab does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink(child: Tab(child: Text('X'))), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Tab)), Size.zero); + }); + + testWidgets('TabBar does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TabController(length: 2, vsync: tester); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: TabBar(controller: controller, tabs: const <Widget>[Text('X'), Text('Y')]), + ), + ), + ); + expect(tester.getSize(find.byType(TabBar)), Size.zero); + controller.animateTo(1); + await tester.pump(); + await tester.pumpAndSettle(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/59143. + testWidgets('TabBar indicator image should be rendered at initialIndex for the first time', ( + WidgetTester tester, + ) async { + // TabBar indicators with asynchronously loaded images (e.g. from network) + // should trigger a repaint when the image finishes loading, even on the initial tab. + final decoration = TabBarAsyncImageIndicatorDecoration(); + + await tester.pumpWidget( + MaterialApp( + home: DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + indicator: decoration, + tabs: const <Widget>[ + Tab(text: 'One'), + Tab(text: 'Two'), + Tab(text: 'Three'), + ], + ), + ), + body: const TabBarView( + children: <Widget>[ + Center(child: Text('Page One')), + Center(child: Text('Page Two')), + Center(child: Text('Page Three')), + ], + ), + ), + ), + ), + ); + + // Initial paint - indicator should be painted once. + expect(decoration.paintCount, 1); + + // Pump with duration to allow the event queue (async image load simulation) to complete. + // Future.delayed(Duration.zero) schedules in the event queue, so we need to advance time. + await tester.pump(const Duration(milliseconds: 1)); + + // After async image loads, the indicator should be repainted. + // This verifies that the markNeedsPaint callback properly triggers a repaint. + expect( + decoration.paintCount, + greaterThan(1), + reason: 'Indicator should be repainted after async image loads', + ); + + // Verify the indicator repaints when switching tabs. + final int initialPaintCount = decoration.paintCount; + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + + expect( + decoration.paintCount, + greaterThan(initialPaintCount), + reason: 'Indicator should repaint when switching tabs', + ); + }); +} diff --git a/packages/material_ui/test/material/tabs_utils.dart b/packages/material_ui/test/material/tabs_utils.dart new file mode 100644 index 000000000000..1c978d86c2e3 --- /dev/null +++ b/packages/material_ui/test/material/tabs_utils.dart @@ -0,0 +1,340 @@ +// 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'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// This returns render paragraph of the Tab label text. +RenderParagraph getTabText(WidgetTester tester, String text) { + return tester.renderObject<RenderParagraph>( + find.descendant( + of: find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_TabStyle'), + matching: find.text(text), + ), + ); +} + +// This creates and returns a TabController. +TabController createTabController({ + required int length, + required TickerProvider vsync, + int initialIndex = 0, + Duration? animationDuration, +}) { + final result = TabController( + length: length, + vsync: vsync, + initialIndex: initialIndex, + animationDuration: animationDuration, + ); + addTearDown(result.dispose); + return result; +} + +// This widget is used to test widget state in the tabs_test.dart file. +class TabStateMarker extends StatefulWidget { + const TabStateMarker({super.key, this.child}); + + final Widget? child; + + @override + TabStateMarkerState createState() => TabStateMarkerState(); +} + +class TabStateMarkerState extends State<TabStateMarker> { + String? marker; + + @override + Widget build(BuildContext context) { + return widget.child ?? Container(); + } +} + +// Tab controller builder for TabControllerFrame widget. +typedef TabControllerFrameBuilder = Widget Function(BuildContext context, TabController controller); + +// This widget creates a TabController and passes it to the builder. +class TabControllerFrame extends StatefulWidget { + const TabControllerFrame({ + super.key, + required this.length, + this.initialIndex = 0, + required this.builder, + }); + + final int length; + final int initialIndex; + final TabControllerFrameBuilder builder; + + @override + TabControllerFrameState createState() => TabControllerFrameState(); +} + +class TabControllerFrameState extends State<TabControllerFrame> + with SingleTickerProviderStateMixin { + late TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController( + vsync: this, + length: widget.length, + initialIndex: widget.initialIndex, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _controller); + } +} + +// Test utility class to test tab indicator drawing. +class TabIndicatorRecordingCanvas extends TestRecordingCanvas { + TabIndicatorRecordingCanvas(this.indicatorColor); + + final Color indicatorColor; + late Rect indicatorRect; + + @override + void drawLine(Offset p1, Offset p2, Paint paint) { + // Assuming that the indicatorWeight is 2.0, the default. + const indicatorWeight = 2.0; + if (paint.color == indicatorColor) { + indicatorRect = Rect.fromPoints(p1, p2).inflate(indicatorWeight / 2.0); + } + } +} + +// This creates a Fake implementation of ScrollMetrics. +class TabMockScrollMetrics extends Fake implements ScrollMetrics {} + +class TabBarTestScrollPhysics extends ScrollPhysics { + const TabBarTestScrollPhysics({super.parent}); + + @override + TabBarTestScrollPhysics applyTo(ScrollPhysics? ancestor) { + return TabBarTestScrollPhysics(parent: buildParent(ancestor)); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + return offset == 10 ? 20 : offset; + } + + static final SpringDescription _kDefaultSpring = SpringDescription.withDampingRatio( + mass: 0.5, + stiffness: 500.0, + ratio: 1.1, + ); + + @override + SpringDescription get spring => _kDefaultSpring; +} + +// This widget is used to log the lifecycle of the TabBarView children. +class TabBody extends StatefulWidget { + const TabBody({super.key, required this.index, required this.log, this.marker = ''}); + + final int index; + final List<String> log; + final String marker; + + @override + State<TabBody> createState() => TabBodyState(); +} + +class TabBodyState extends State<TabBody> { + @override + void initState() { + widget.log.add('init: ${widget.index}'); + super.initState(); + } + + @override + void didUpdateWidget(TabBody oldWidget) { + super.didUpdateWidget(oldWidget); + // To keep the logging straight, widgets must not change their index. + assert(oldWidget.index == widget.index); + } + + @override + void dispose() { + widget.log.add('dispose: ${widget.index}'); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: widget.marker.isEmpty + ? Text('${widget.index}') + : Text('${widget.index}-${widget.marker}'), + ); + } +} + +// This widget is used to test the lifecycle of the TabBarView children with Ink widget. +class TabKeepAliveInk extends StatefulWidget { + const TabKeepAliveInk({super.key, required this.title}); + + final String title; + + @override + State<StatefulWidget> createState() => _TabKeepAliveInkState(); +} + +class _TabKeepAliveInkState extends State<TabKeepAliveInk> with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return Ink(child: Text(widget.title)); + } +} + +// This widget is used to test the lifecycle of the TabBarView children. +class TabAlwaysKeepAliveWidget extends StatefulWidget { + const TabAlwaysKeepAliveWidget({super.key}); + + static String text = 'AlwaysKeepAlive'; + + @override + State<TabAlwaysKeepAliveWidget> createState() => _TabAlwaysKeepAliveWidgetState(); +} + +class _TabAlwaysKeepAliveWidgetState extends State<TabAlwaysKeepAliveWidget> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return Text(TabAlwaysKeepAliveWidget.text); + } +} + +// This decoration is used to test the indicator decoration image configuration. +class TestIndicatorDecoration extends Decoration { + final List<TestIndicatorBoxPainter> painters = <TestIndicatorBoxPainter>[]; + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + final painter = TestIndicatorBoxPainter(); + painters.add(painter); + return painter; + } +} + +class TestIndicatorBoxPainter extends BoxPainter { + ImageConfiguration? lastConfiguration; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + lastConfiguration = configuration; + } +} + +// Ease out sine (decelerating). +double _decelerateInterpolation(double fraction) { + return math.sin((fraction * math.pi) / 2.0); +} + +// Ease in sine (accelerating). +double _accelerateInterpolation(double fraction) { + return 1.0 - math.cos((fraction * math.pi) / 2.0); +} + +// Returns Tab indicator RRect with elastic animation. +RRect tabIndicatorRRectElasticAnimation( + RenderBox tabBarBox, + Rect currentRect, + Rect fromRect, + Rect toRect, + double progress, +) { + const indicatorWeight = 3.0; + final double leftFraction = _accelerateInterpolation(progress); + final double rightFraction = _decelerateInterpolation(progress); + + return RRect.fromLTRBAndCorners( + lerpDouble(fromRect.left, toRect.left, leftFraction)!, + tabBarBox.size.height - indicatorWeight, + lerpDouble(fromRect.right, toRect.right, rightFraction)!, + tabBarBox.size.height, + topLeft: const Radius.circular(indicatorWeight), + topRight: const Radius.circular(indicatorWeight), + ); +} + +// This decoration is used to test async image loading in indicator. +class TabBarAsyncImageIndicatorDecoration extends Decoration { + TabBarAsyncImageIndicatorDecoration() : _paintCounter = _TabBarPaintCounter(); + + final _TabBarPaintCounter _paintCounter; + + /// The number of times the indicator has been painted. + int get paintCount => _paintCounter.count; + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return TabBarAsyncImageIndicatorBoxPainter( + onChanged: onChanged, + onPaint: _paintCounter.increment, + ); + } +} + +// Helper class to track paint counts. +class _TabBarPaintCounter { + int count = 0; + + void increment() { + count++; + } +} + +// Box painter that simulates async image loading for testing TabBar indicators. +class TabBarAsyncImageIndicatorBoxPainter extends BoxPainter { + TabBarAsyncImageIndicatorBoxPainter({VoidCallback? onChanged, this.onPaint}) : super(onChanged); + + final VoidCallback? onPaint; + bool _imagePainted = false; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + onPaint?.call(); + + // Simulate async image loading on first paint. + if (!_imagePainted && onChanged != null) { + _imagePainted = true; + Future.delayed(Duration.zero, () { + onChanged!(); + }); + } + + // Paint a simple rectangle to indicate the indicator was painted. + final paint = Paint() + ..color = Colors.blue + ..style = PaintingStyle.fill; + canvas.drawRect( + Rect.fromLTWH(offset.dx, configuration.size!.height - 2.0, configuration.size!.width, 2.0), + paint, + ); + } +} diff --git a/packages/material_ui/test/material/text_button_test.dart b/packages/material_ui/test/material/text_button_test.dart new file mode 100644 index 000000000000..cf75c1b076fd --- /dev/null +++ b/packages/material_ui/test/material/text_button_test.dart @@ -0,0 +1,2687 @@ +// 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 '../widgets/semantics_tester.dart'; + +void main() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + Color textColor(WidgetTester tester, String text) { + return tester.renderObject<RenderParagraph>(find.text(text)).text.style!.color!; + } + + testWidgets('TextButton, TextButton.icon defaults', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + final bool material3 = theme.useMaterial3; + + // Enabled TextButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.textStyle!.color, colorScheme.primary); + 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<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + // Material 3 uses the InkSparkle which uses a shader, so we can't capture + // the effect with paint methods. + if (!material3) { + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..circle(color: colorScheme.primary.withOpacity(0.12))); + } + + await gesture.up(); + await tester.pumpAndSettle(); + material = tester.widget<Material>(buttonMaterial); + // No change vs enabled and not pressed. + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Enabled TextButton.icon + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton.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<Material>(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled TextButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center(child: TextButton(onPressed: null, child: Text('button'))), + ), + ); + + material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, false); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, material3 ? Colors.transparent : const Color(0xff000000)); + expect( + material.shape, + material3 + ? const StadiumBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))), + ); + 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('TextButton.defaultStyle produces a ButtonStyle with appropriate non-null values', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = TextButton(onPressed: () {}, child: const Text('button')); + BuildContext? capturedContext; + // Enabled TextButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.side, isNull, reason: 'side style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }); + + testWidgets( + 'TextButton.defaultStyle with an icon produces a ButtonStyle with appropriate non-null values', + (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + + final button = TextButton.icon( + onPressed: () {}, + icon: const SizedBox(), + label: const Text('button'), + ); + BuildContext? capturedContext; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return button; + }, + ), + ), + ), + ); + final ButtonStyle style = button.defaultStyleOf(capturedContext!); + + // Properties that must be non-null. + expect(style.textStyle, isNotNull, reason: 'textStyle style'); + expect(style.backgroundColor, isNotNull, reason: 'backgroundColor style'); + expect(style.foregroundColor, isNotNull, reason: 'foregroundColor style'); + expect(style.overlayColor, isNotNull, reason: 'overlayColor style'); + expect(style.shadowColor, isNotNull, reason: 'shadowColor style'); + expect(style.surfaceTintColor, isNotNull, reason: 'surfaceTintColor style'); + expect(style.elevation, isNotNull, reason: 'elevation style'); + expect(style.padding, isNotNull, reason: 'padding style'); + expect(style.minimumSize, isNotNull, reason: 'minimumSize style'); + expect(style.maximumSize, isNotNull, reason: 'maximumSize style'); + expect(style.iconColor, isNotNull, reason: 'iconColor style'); + expect(style.iconSize, isNotNull, reason: 'iconSize style'); + expect(style.shape, isNotNull, reason: 'shape style'); + expect(style.mouseCursor, isNotNull, reason: 'mouseCursor style'); + expect(style.visualDensity, isNotNull, reason: 'visualDensity style'); + expect(style.tapTargetSize, isNotNull, reason: 'tapTargetSize style'); + expect(style.animationDuration, isNotNull, reason: 'animationDuration style'); + expect(style.enableFeedback, isNotNull, reason: 'enableFeedback style'); + expect(style.alignment, isNotNull, reason: 'alignment style'); + expect(style.splashFactory, isNotNull, reason: 'splashFactory style'); + + // Properties that are expected to be null. + expect(style.fixedSize, isNull, reason: 'fixedSize style'); + expect(style.side, isNull, reason: 'side style'); + expect(style.backgroundBuilder, isNull, reason: 'backgroundBuilder style'); + expect(style.foregroundBuilder, isNull, reason: 'foregroundBuilder style'); + }, + ); + + testWidgets('TextButton.icon produces the correct widgets when icon is null', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton.icon( + key: iconButtonKey, + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.text('label'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton.icon( + key: iconButtonKey, + onPressed: () {}, + // No icon specified. + label: const Text('label'), + ), + ), + ), + ); + + expect(find.byIcon(Icons.add), findsNothing); + expect(find.text('label'), findsOneWidget); + }); + + testWidgets( + 'Default TextButton meets a11y contrast guidelines', + (WidgetTester tester) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: TextButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('TextButton'), + ), + ), + ), + ), + ); + + // Default, not disabled. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // 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. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + await gesture.removePointer(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + focusNode.dispose(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets( + 'TextButton with colored theme meets a11y contrast guidelines', + (WidgetTester tester) async { + final focusNode = FocusNode(); + + Color getTextColor(Set<WidgetState> states) { + final interactiveStates = <WidgetState>{ + WidgetState.pressed, + WidgetState.hovered, + WidgetState.focused, + }; + if (states.any(interactiveStates.contains)) { + return Colors.blue[900]!; + } + return Colors.blue[800]!; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: TextButtonTheme( + data: TextButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('TextButton'), + ); + }, + ), + ), + ), + ), + ), + ); + + // 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(TextButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // 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. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + focusNode.dispose(); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + testWidgets('TextButton default overlayColor resolves pressed state', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('TextButton'), + ); + }, + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + // Hovered. + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + overlayColor(), + paints + ..rect() + ..rect(color: theme.colorScheme.primary.withOpacity(0.1)), + ); + // Remove pressed and hovered states + await gesture.up(); + await tester.pumpAndSettle(); + await gesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.1))); + + focusNode.dispose(); + }); + + testWidgets('TextButton uses stateful color for text color in different states', ( + WidgetTester tester, + ) async { + const buttonText = 'TextButton'; + final focusNode = FocusNode(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: TextButton( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text(buttonText), + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(textColor(tester, buttonText), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(tester, buttonText), 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(tester, buttonText), pressedColor); + + focusNode.dispose(); + }); + + testWidgets('TextButton uses stateful color for icon color in different states', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const pressedColor = Color(0x00000001); + const hoverColor = Color(0x00000002); + const focusedColor = Color(0x00000003); + const defaultColor = Color(0x00000004); + + Color getTextColor(Set<WidgetState> states) { + if (states.contains(WidgetState.pressed)) { + return pressedColor; + } + if (states.contains(WidgetState.hovered)) { + return hoverColor; + } + if (states.contains(WidgetState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: TextButton.icon( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + iconColor: WidgetStateProperty.resolveWith<Color>(getTextColor), + ), + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('TextButton'), + onPressed: () {}, + focusNode: focusNode, + ), + ), + ), + ), + ); + + // Default, not disabled. + expect(iconStyle(tester, Icons.add).color, equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, 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(iconStyle(tester, Icons.add).color, pressedColor); + + focusNode.dispose(); + }); + + testWidgets('TextButton has no clip by default', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + child: Container(), + onPressed: () { + /* to make sure the button is enabled */ + }, + ), + ), + ); + + expect(tester.renderObject(find.byType(TextButton)), paintsExactlyCountTimes(#clipPath, 0)); + }); + + testWidgets('Does TextButton work with hover', (WidgetTester tester) async { + const hoverColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.hovered) ? hoverColor : null; + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(getOverlayColor), + ), + child: Container(), + onPressed: () { + /* to make sure the button is enabled */ + }, + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(TextButton))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + expect(inkFeatures, paints..rect(color: hoverColor)); + }); + + testWidgets('Does TextButton work with focus', (WidgetTester tester) async { + const focusColor = Color(0xff001122); + + Color? getOverlayColor(Set<WidgetState> states) { + return states.contains(WidgetState.focused) ? focusColor : null; + } + + final focusNode = FocusNode(debugLabel: 'TextButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith<Color?>(getOverlayColor), + ), + focusNode: focusNode, + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + WidgetsBinding.instance.focusManager.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)); + + focusNode.dispose(); + }); + + testWidgets('Does TextButton contribute semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: TextButton( + 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>(Size(88, 36)), + ), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + 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>[ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Does TextButton scale with font scale changes', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Center( + child: TextButton(onPressed: () {}, child: const Text('ABC')), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(TextButton)), equals(const Size(64.0, 48.0))); + expect(tester.getSize(find.byType(Text)), equals(const Size(42.0, 14.0))); + + // textScaleFactor expands text, but not button. + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery.withClampedTextScaling( + minScaleFactor: 1.25, + maxScaleFactor: 1.25, + child: Center( + child: TextButton(onPressed: () {}, child: const Text('ABC')), + ), + ), + ), + ), + ); + + const textButtonSize = Size(68.5, 48.0); + const textSize = Size(52.5, 18.0); + expect(tester.getSize(find.byType(TextButton)), textButtonSize); + expect(tester.getSize(find.byType(Text)), textSize); + + // Set text scale large enough to expand text and button. + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery.withClampedTextScaling( + minScaleFactor: 3.0, + maxScaleFactor: 3.0, + child: Center( + child: TextButton(onPressed: () {}, child: const Text('ABC')), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(TextButton)), const Size(134.0, 48.0)); + expect(tester.getSize(find.byType(Text)), const Size(126.0, 42.0)); + }); + + testWidgets('TextButton size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(useMaterial3: false, materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: TextButton( + key: key, + style: TextButton.styleFrom(minimumSize: const Size(64, 36)), + 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(66.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(66.0, 36.0)); + }); + + testWidgets('TextButton onPressed and onLongPress callbacks are correctly called when non-null', ( + WidgetTester tester, + ) async { + bool wasPressed; + Finder textButton; + + Widget buildFrame({VoidCallback? onPressed, VoidCallback? onLongPress}) { + return Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + onPressed: onPressed, + onLongPress: onLongPress, + child: const Text('button'), + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onPressed: () { + wasPressed = true; + }, + ), + ); + textButton = find.byType(TextButton); + expect(tester.widget<TextButton>(textButton).enabled, true); + await tester.tap(textButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame( + onLongPress: () { + wasPressed = true; + }, + ), + ); + textButton = find.byType(TextButton); + expect(tester.widget<TextButton>(textButton).enabled, true); + await tester.longPress(textButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget(buildFrame()); + textButton = find.byType(TextButton); + expect(tester.widget<TextButton>(textButton).enabled, false); + }); + + testWidgets('TextButton onPressed and onLongPress callbacks are distinctly recognized', ( + WidgetTester tester, + ) async { + var didPressButton = false; + var didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder textButton = find.byType(TextButton); + expect(tester.widget<TextButton>(textButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(textButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(textButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets("TextButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final focusNode = FocusNode(debugLabel: 'TextButton Focus'); + final GlobalKey childKey = GlobalKey(); + var hovering = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.square( + dimension: 100.0, + child: TextButton( + 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.square( + dimension: 100.0, + child: TextButton( + focusNode: focusNode, + onHover: (bool value) { + hovering = value; + }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + + focusNode.dispose(); + }); + + testWidgets('disabled and hovered TextButton responds to mouse-exit', ( + WidgetTester tester, + ) async { + var onHoverCount = 0; + late bool hover; + + Widget buildFrame({required bool enabled}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.square( + dimension: 100.0, + child: TextButton( + onPressed: enabled ? () {} : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('TextButton'), + ), + ), + ), + ); + } + + 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(TextButton))); + 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 TextButton 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(TextButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the TextButton 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 TextButton 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(TextButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the TextButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set TextButton focus and Can set unFocus.', (WidgetTester tester) async { + final node = FocusNode(debugLabel: 'TextButton Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + 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); + + node.dispose(); + }); + + testWidgets('When TextButton disable, Can not set TextButton focus.', ( + WidgetTester tester, + ) async { + final node = FocusNode(debugLabel: 'TextButton Focus'); + var gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + + node.dispose(); + }); + + testWidgets('TextButton responds to density changes.', (WidgetTester tester) async { + const key = Key('test'); + const childKey = Key('test child'); + + Future<void> buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: TextButton( + style: ButtonStyle(visualDensity: visualDensity), + 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(116, 116))); + 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(140, 140))); + 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(116, 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(72, 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(96, 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(72, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + group('Default TextButton padding for textScaleFactor, textDirection', () { + const buttonKey = ValueKey<String>('button'); + const labelKey = ValueKey<String>('label'); + const iconKey = ValueKey<String>('icon'); + + const textScaleFactorOptions = <double>[0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const textDirectionOptions = <TextDirection>[TextDirection.ltr, TextDirection.rtl]; + const iconOptions = <Widget?>[null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final paddingVertical = <double, double>{ + 0.5: 8, + 1: 8, + 1.25: 6, + 1.5: 4, + 2: 0, + 2.5: 0, + 3: 0, + 4: 0, + }; + final paddingWithIconGap = <double, double>{ + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + final textPaddingWithoutIconHorizontal = <double, double>{ + 0.5: 8, + 1: 8, + 1.25: 8, + 1.5: 8, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final textPaddingWithIconHorizontal = <double, double>{ + 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 textScaleFactor in textScaleFactorOptions) { + for (final textDirection in textDirectionOptions) { + for (final icon in iconOptions) { + final String testName = <String>[ + 'TextButton, 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( + useMaterial3: false, + colorScheme: const ColorScheme.light(), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(minimumSize: const Size(64, 36)), + ), + ), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: textScaleFactor, + maxScaleFactor: textScaleFactor, + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? TextButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : TextButton.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 paddingWidget = paddingElement.widget as Padding; + + // Compute expected padding, and check. + + final double expectedPaddingTop = paddingVertical[textScaleFactor]!; + final double expectedPaddingBottom = paddingVertical[textScaleFactor]!; + + final double expectedPaddingStart = icon != null + ? textPaddingWithIconHorizontal[textScaleFactor]! + : textPaddingWithoutIconHorizontal[textScaleFactor]!; + final expectedPaddingEnd = expectedPaddingStart; + + final EdgeInsets expectedPadding = EdgeInsetsDirectional.fromSTEB( + expectedPaddingStart, + expectedPaddingTop, + expectedPaddingEnd, + expectedPaddingBottom, + ).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<RenderBox>(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox? iconRenderBox = icon == null + ? null + : tester.renderObject<RenderBox>(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<RenderBox>( + 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<RenderBox>( + 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 TextButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery.withClampedTextScaling( + minScaleFactor: 2, + maxScaleFactor: 2, + child: Scaffold( + body: Center( + child: TextButton( + style: TextButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('TextButton'), + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); + + testWidgets('Override theme fontSize changes padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + textTheme: const TextTheme(labelLarge: TextStyle(fontSize: 28.0)), + ), + home: Builder( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: TextButton(onPressed: () {}, child: const Text('text')), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 8)); + }); + + testWidgets('M3 TextButton has correct default padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: TextButton(key: key, onPressed: () {}, child: const Text('TextButton')), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsets.symmetric(horizontal: 12, vertical: 8)); + }); + + testWidgets('M3 TextButton.icon has correct default padding', (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: TextButton.icon( + key: key, + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('TextButton'), + ), + ), + ), + ), + ); + + final Padding paddingWidget = tester.widget<Padding>( + find.descendant(of: find.byKey(key), matching: find.byType(Padding)), + ); + expect(paddingWidget.padding, const EdgeInsetsDirectional.fromSTEB(12, 8, 16, 8)); + }); + + testWidgets('Fixed size TextButtons', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + TextButton( + style: TextButton.styleFrom(fixedSize: const Size(100, 100)), + onPressed: () {}, + child: const Text('100x100'), + ), + TextButton( + style: TextButton.styleFrom(fixedSize: const Size.fromWidth(200)), + onPressed: () {}, + child: const Text('200xh'), + ), + TextButton( + style: TextButton.styleFrom(fixedSize: const Size.fromHeight(200)), + onPressed: () {}, + child: const Text('wx200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(TextButton, '100x100')), const Size(100, 100)); + expect(tester.getSize(find.widgetWithText(TextButton, '200xh')).width, 200); + expect(tester.getSize(find.widgetWithText(TextButton, 'wx200')).height, 200); + }); + + testWidgets('TextButton with NoSplash splashFactory paints nothing', (WidgetTester tester) async { + Widget buildFrame({InteractiveInkFeatureFactory? splashFactory}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: TextButton( + style: TextButton.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( + 'TextButton uses InkSparkle only for Android non-web when useMaterial3 is true', + (WidgetTester tester) async { + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(TextButton), 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('TextButton uses InkRipple when useMaterial3 is false', (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget<InkWell>( + find.descendant(of: find.byType(TextButton), matching: find.byType(InkWell)), + ); + expect(buttonInkWell.splashFactory, equals(InkRipple.splashFactory)); + }, variant: TargetPlatformVariant.all()); + + testWidgets('TextButton.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: TextButton.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('TextButton.icon icon,label layout', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + final Key iconKey = UniqueKey(); + final Key labelKey = UniqueKey(); + final ButtonStyle style = TextButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.standard, // dx=0, dy=0 + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: TextButton.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('TextButton maximumSize', (WidgetTester tester) async { + final Key key0 = UniqueKey(); + final Key key1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + TextButton( + 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'), + ), + TextButton.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, 128.0)); + expect(tester.getSize(find.byKey(key1)), const Size(104.0, 128.0)); + }); + + testWidgets('Fixed size TextButton, same as minimumSize == maximumSize', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + TextButton( + style: TextButton.styleFrom(fixedSize: const Size(200, 200)), + onPressed: () {}, + child: const Text('200x200'), + ), + TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(200, 200), + maximumSize: const Size(200, 200), + ), + onPressed: () {}, + child: const Text('200,200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(TextButton, '200x200')), const Size(200, 200)); + expect(tester.getSize(find.widgetWithText(TextButton, '200,200')), const Size(200, 200)); + }); + + testWidgets('TextButton changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextButton( + style: TextButton.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: TextButton( + style: TextButton.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: TextButton(onPressed: () {}, child: const Text('button')), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextButton(onPressed: null, child: Text('button')), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('TextButton in SelectionArea changes mouse cursor when hovered', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/104595. + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + child: TextButton( + style: TextButton.styleFrom( + enabledMouseCursor: SystemMouseCursors.click, + disabledMouseCursor: SystemMouseCursors.grab, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.byType(Text))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.click, + ); + }); + + testWidgets('TextButton.styleFrom can be used to set foreground and background colors', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.purple, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, Colors.purple); + expect(material.textStyle!.color, Colors.white); + }); + + Future<void> testStatesController(Widget? icon, WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: icon == null + ? TextButton( + statesController: controller, + onPressed: () {}, + child: const Text('button'), + ) + : TextButton.icon( + statesController: controller, + onPressed: () {}, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + + expect(controller.value, <WidgetState>{}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(Text)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{WidgetState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, <WidgetState>{}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.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: icon == null + ? TextButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ) + : TextButton.icon( + statesController: controller, + onPressed: null, + icon: icon, + label: const Text('button'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.hovered, WidgetState.disabled}); + expect(count, 10); // removes pressed and adds disabled - two changes + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 11); + await gesture.removePointer(); + } + + testWidgets('TextButton statesController', (WidgetTester tester) async { + await testStatesController(null, tester); + }); + + testWidgets('TextButton.icon statesController', (WidgetTester tester) async { + await testStatesController(const Icon(Icons.add), tester); + }); + + testWidgets('Disabled TextButton statesController', (WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: TextButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 1); + }); + + testWidgets('icon color can be different from the text color', (WidgetTester tester) async { + final Key iconButtonKey = UniqueKey(); + const colorScheme = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: colorScheme); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton.icon( + key: iconButtonKey, + style: TextButton.styleFrom(iconColor: Colors.red), + icon: const Icon(Icons.add), + onPressed: () {}, + label: const Text('button'), + ), + ), + ), + ); + + Finder buttonMaterial = find.descendant( + of: find.byKey(iconButtonKey), + matching: find.byType(Material), + ); + + Material material = tester.widget<Material>(buttonMaterial); + expect(material.textStyle!.color, colorScheme.primary); + + expect(iconStyle(tester, Icons.add).color, equals(Colors.red)); + + // disabled button + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: TextButton.icon( + key: iconButtonKey, + style: TextButton.styleFrom(iconColor: Colors.red, disabledIconColor: Colors.blue), + icon: const Icon(Icons.add), + onPressed: null, + label: const Text('button'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + buttonMaterial = find.descendant( + of: find.byKey(iconButtonKey), + matching: find.byType(Material), + ); + + material = tester.widget<Material>(buttonMaterial); + expect(material.textStyle!.color, colorScheme.onSurface.withOpacity(0.38)); + expect(iconStyle(tester, Icons.add).color, equals(Colors.blue)); + }); + + testWidgets("TextButton.styleFrom doesn't throw exception on passing only one cursor", ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/118071. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom(enabledMouseCursor: SystemMouseCursors.text), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('TextButton backgroundBuilder and foregroundBuilder', (WidgetTester tester) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: backgroundColor), + child: child, + ); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return DecoratedBox( + decoration: const BoxDecoration(color: foregroundColor), + child: child, + ); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + BoxDecoration boxDecorationOf(Finder finder) { + return tester.widget<DecoratedBox>(finder).decoration as BoxDecoration; + } + + final Finder decorations = find.descendant( + of: find.byType(TextButton), + matching: find.byType(DecoratedBox), + ); + + expect(boxDecorationOf(decorations.at(0)).color, backgroundColor); + expect(boxDecorationOf(decorations.at(1)).color, foregroundColor); + + Text textChildOf(Finder finder) { + return tester.widget<Text>(find.descendant(of: finder, matching: find.byType(Text))); + } + + expect(textChildOf(decorations.at(0)).data, 'button'); + expect(textChildOf(decorations.at(1)).data, 'button'); + }); + + testWidgets( + 'TextButton backgroundBuilder drops button child and foregroundBuilder return value', + (WidgetTester tester) async { + const backgroundColor = Color(0xFF000011); + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: backgroundColor)); + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder background = find.descendant( + of: find.byType(TextButton), + matching: find.byType(DecoratedBox), + ); + + expect(background, findsOneWidget); + expect(find.text('button'), findsNothing); + }, + ); + + testWidgets('TextButton foregroundBuilder drops button child', (WidgetTester tester) async { + const foregroundColor = Color(0xFF000022); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom( + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + return const DecoratedBox(decoration: BoxDecoration(color: foregroundColor)); + }, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ); + + final Finder foreground = find.descendant( + of: find.byType(TextButton), + matching: find.byType(DecoratedBox), + ); + + expect(foreground, findsOneWidget); + expect(find.text('button'), findsNothing); + }); + + testWidgets('TextButton foreground and background builders are applied to the correct states', ( + WidgetTester tester, + ) async { + var foregroundStates = <WidgetState>{}; + var backgroundStates = <WidgetState>{}; + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: TextButton( + style: ButtonStyle( + backgroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + backgroundStates = states; + return child!; + }, + foregroundBuilder: (BuildContext context, Set<WidgetState> states, Widget? child) { + foregroundStates = states; + return child!; + }, + ), + onPressed: () {}, + focusNode: focusNode, + child: const Text('button'), + ), + ), + ), + ), + ); + + // Default. + expect(backgroundStates.isEmpty, isTrue); + expect(foregroundStates.isEmpty, isTrue); + + const focusedStates = <WidgetState>{WidgetState.focused}; + const focusedHoveredStates = <WidgetState>{WidgetState.focused, WidgetState.hovered}; + const focusedHoveredPressedStates = <WidgetState>{ + WidgetState.focused, + WidgetState.hovered, + WidgetState.pressed, + }; + + bool sameStates(Set<WidgetState> expectedValue, Set<WidgetState> actualValue) { + return expectedValue.difference(actualValue).isEmpty && + actualValue.difference(expectedValue).isEmpty; + } + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(sameStates(focusedStates, backgroundStates), isTrue); + expect(sameStates(focusedStates, foregroundStates), isTrue); + + // Hovered. + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(sameStates(focusedHoveredStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredStates, foregroundStates), isTrue); + + // 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(sameStates(focusedHoveredPressedStates, backgroundStates), isTrue); + expect(sameStates(focusedHoveredPressedStates, foregroundStates), isTrue); + + focusNode.dispose(); + }); + + testWidgets('TextButton styleFrom backgroundColor special case', (WidgetTester tester) async { + // Regression test for an internal Google issue: b/323399158 + + const backgroundColor = Color(0xFF000022); + + Widget buildFrame({VoidCallback? onPressed}) { + return Directionality( + textDirection: TextDirection.ltr, + child: TextButton( + style: TextButton.styleFrom(backgroundColor: backgroundColor), + onPressed: () {}, + child: const Text('button'), + ), + ); + } + + await tester.pumpWidget(buildFrame(onPressed: () {})); // enabled + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, backgroundColor); + + await tester.pumpWidget(buildFrame()); // onPressed: null - disabled + expect(material.color, backgroundColor); + }); + + testWidgets('Default TextButton icon alignment', (WidgetTester tester) async { + Widget buildWidget({required TextDirection textDirection}) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + // Test default iconAlignment when textDirection is ltr. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.ltr)); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 12.0); // 12.0 - padding between icon and button edge. + + // Test default iconAlignment when textDirection is rtl. + await tester.pumpWidget(buildWidget(textDirection: TextDirection.rtl)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 12.0, + ); // 12.0 - padding between icon and button edge. + }); + + testWidgets('TextButton icon alignment can be customized', (WidgetTester tester) async { + Widget buildWidget({ + required TextDirection textDirection, + required IconAlignment iconAlignment, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Center( + child: TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + iconAlignment: iconAlignment, + ), + ), + ), + ); + } + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.start), + ); + + Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 12.0); // 12.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is ltr. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.ltr, iconAlignment: IconAlignment.end), + ); + + Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 16.0, + ); // 16.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.start), + ); + + buttonTopRight = tester.getTopRight(find.byType(Material).last); + iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + // The icon is aligned to the right of the button. + expect( + buttonTopRight.dx, + iconTopRight.dx + 12.0, + ); // 12.0 - padding between icon and button edge. + + // Test iconAlignment when textDirection is rtl. + await tester.pumpWidget( + buildWidget(textDirection: TextDirection.rtl, iconAlignment: IconAlignment.end), + ); + + buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + // The icon is aligned to the left of the button. + expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. + }); + + testWidgets('TextButton icon alignment respects ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + home: Center( + child: TextButton.icon( + style: ButtonStyle(iconAlignment: iconAlignment), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 12.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 16.0); + }); + + testWidgets('treats a hovering stylus like a mouse', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final theme = ThemeData(); + var hasBeenHovered = false; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () {}, + onHover: (bool entered) { + hasBeenHovered = true; + }, + focusNode: focusNode, + child: const Text('TextButton'), + ); + }, + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere( + (RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures', + ); + } + + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus); + await gesture.addPointer(); + await tester.pumpAndSettle(); + + expect(hasBeenHovered, isFalse); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + expect(hasBeenHovered, isTrue); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('TextButton.styleFrom can customize the button icon', (WidgetTester tester) async { + const iconColor = Color(0xFFF000FF); + const iconSize = 32.0; + const disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: TextButton.icon( + style: TextButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + iconAlignment: IconAlignment.end, + disabledIconColor: disabledIconColor, + ), + onPressed: enabled ? () {} : null, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + await tester.pumpAndSettle(); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + expect(buttonTopRight.dx, iconTopRight.dx + 16.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets('TextButton icon uses provided foregroundColor over default icon color', ( + WidgetTester tester, + ) async { + const foregroundColor = Color(0xFFFF1234); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextButton.icon( + style: TextButton.styleFrom(foregroundColor: foregroundColor), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ), + ); + expect(iconStyle(tester, Icons.add).color, foregroundColor); + }); + + testWidgets('TextButton text and icon respect animation duration', (WidgetTester tester) async { + const buttonText = 'Button'; + const IconData buttonIcon = Icons.add; + const hoveredColor = Color(0xFFFF0000); + const idleColor = Color(0xFF000000); + + Widget buildButton({Duration? animationDuration}) { + return MaterialApp( + home: Material( + child: Center( + child: TextButton.icon( + style: ButtonStyle( + animationDuration: animationDuration, + iconColor: const WidgetStateProperty<Color>.fromMap(<WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }), + foregroundColor: const WidgetStateProperty<Color>.fromMap( + <WidgetStatesConstraint, Color>{ + WidgetState.hovered: hoveredColor, + WidgetState.any: idleColor, + }, + ), + ), + onPressed: () {}, + icon: const Icon(buttonIcon), + label: const Text(buttonText), + ), + ), + ), + ); + } + + // Test default animation duration. + await tester.pumpWidget(buildButton()); + + expect(textColor(tester, buttonText), idleColor); + expect(iconStyle(tester, buttonIcon).color, idleColor); + + final Offset buttonCenter = tester.getCenter(find.text(buttonText)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + + await gesture.removePointer(); + + // Test custom animation duration. + await tester.pumpWidget(buildButton(animationDuration: const Duration(seconds: 2))); + await tester.pumpAndSettle(); + + await gesture.moveTo(buttonCenter); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor.withValues(red: 0.5)); + expect(iconStyle(tester, buttonIcon).color, hoveredColor.withValues(red: 0.5)); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(textColor(tester, buttonText), hoveredColor); + expect(iconStyle(tester, buttonIcon).color, hoveredColor); + }); + + // Regression test for https://github.com/flutter/flutter/issues/173944. + testWidgets('TextButton.icon does not lose focus when icon is nullified', ( + WidgetTester tester, + ) async { + Widget buildTextButton({required Widget? icon}) { + return MaterialApp( + home: Center( + child: TextButton.icon(onPressed: () {}, icon: icon, label: const Text('button')), + ), + ); + } + + // Build once with an icon. + await tester.pumpWidget(buildTextButton(icon: const Icon(Icons.abc))); + + FocusNode getButtonFocusNode() { + return Focus.of(tester.element(find.text('button'))); + } + + getButtonFocusNode().requestFocus(); + await tester.pumpAndSettle(); + expect(getButtonFocusNode().hasFocus, true); + + // Rebuild without icon. + await tester.pumpWidget(buildTextButton(icon: null)); + + // The button should still be focused. + expect(getButtonFocusNode().hasFocus, true); + }); + + testWidgets('TextButton does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox.shrink( + child: TextButton(onPressed: () {}, child: const Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(TextButton)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/text_button_theme_test.dart b/packages/material_ui/test/material/text_button_theme_test.dart new file mode 100644 index 000000000000..27e0901b7734 --- /dev/null +++ b/packages/material_ui/test/material/text_button_theme_test.dart @@ -0,0 +1,489 @@ +// 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() { + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + test('TextButtonTheme lerp special cases', () { + expect(TextButtonThemeData.lerp(null, null, 0), null); + const data = TextButtonThemeData(); + expect(identical(TextButtonThemeData.lerp(data, data, 0.5), data), true); + }); + + testWidgets('Material3: Passing no TextButtonTheme returns defaults', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: TextButton(onPressed: () {}, child: const Text('button')), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, Colors.transparent); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + testWidgets('Material2: Passing no TextButtonTheme returns defaults', ( + WidgetTester tester, + ) async { + const colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(useMaterial3: false, colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: TextButton(onPressed: () {}, child: const Text('button')), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget<Material>(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, Colors.transparent); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect( + material.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + expect(material.textStyle!.color, colorScheme.primary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, TextTheme, TextButton style overrides]', () { + const foregroundColor = Color(0xff000001); + const backgroundColor = Color(0xff000002); + const disabledColor = Color(0xff000003); + const shadowColor = Color(0xff000004); + const double elevation = 3; + const textStyle = TextStyle(fontSize: 12.0); + const padding = EdgeInsets.all(3); + const minimumSize = Size(200, 200); + const 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 animationDuration = Duration(milliseconds: 25); + const enableFeedback = false; + const AlignmentGeometry alignment = Alignment.centerLeft; + + final Key backgroundKey = UniqueKey(); + final Key foregroundKey = UniqueKey(); + Widget backgroundBuilder(BuildContext context, Set<WidgetState> states, Widget? child) { + return KeyedSubtree(key: backgroundKey, child: child!); + } + + Widget foregroundBuilder(BuildContext context, Set<WidgetState> states, Widget? child) { + return KeyedSubtree(key: foregroundKey, child: child!); + } + + final ButtonStyle style = TextButton.styleFrom( + foregroundColor: foregroundColor, + disabledForegroundColor: disabledColor, + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledColor, + 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, + backgroundBuilder: backgroundBuilder, + foregroundBuilder: foregroundBuilder, + ); + + Widget buildFrame({ + ButtonStyle? buttonStyle, + ButtonStyle? themeStyle, + ButtonStyle? overallStyle, + }) { + final Widget child = Builder( + builder: (BuildContext context) { + return TextButton(style: buttonStyle, onPressed: () {}, child: const Text('button')); + }, + ); + return MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), + ).copyWith(textButtonTheme: TextButtonThemeData(style: overallStyle)), + home: Scaffold( + body: Center( + // If the TextButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.textButtonTheme. + child: themeStyle == null + ? child + : TextButtonTheme( + data: TextButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(TextButton), + matching: find.byType(InkWell), + ); + + const enabled = <WidgetState>{}; + const disabled = <WidgetState>{WidgetState.disabled}; + const hovered = <WidgetState>{WidgetState.hovered}; + const focused = <WidgetState>{WidgetState.focused}; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget<Material>(findMaterial); + final InkWell inkWell = tester.widget<InkWell>(findInkWell); + expect(material.textStyle!.color, foregroundColor); + expect(material.textStyle!.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect( + WidgetStateProperty.resolveAs<MouseCursor?>(inkWell.mouseCursor, enabled), + enabledMouseCursor, + ); + expect( + WidgetStateProperty.resolveAs<MouseCursor?>(inkWell.mouseCursor, disabled), + disabledMouseCursor, + ); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.1)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(TextButton)), const Size(200, 200)); + final Align align = tester.firstWidget<Align>( + find.ancestor(of: find.text('button'), matching: find.byType(Align)), + ); + expect(align.alignment, alignment); + expect( + find.descendant(of: findMaterial, matching: find.byKey(backgroundKey)), + findsOneWidget, + ); + expect(find.descendant(of: findInkWell, matching: find.byKey(foregroundKey)), findsOneWidget); + } + + 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('Material3 - TextButton repsects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const overriddenColor = Color(0xff000002); + + Widget buildFrame({Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor}) { + return MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme.copyWith(shadow: overallShadowColor)), + home: Scaffold( + body: Center( + child: TextButtonTheme( + data: TextButtonThemeData(style: TextButton.styleFrom(shadowColor: themeShadowColor)), + child: Builder( + builder: (BuildContext context) { + return TextButton( + style: TextButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.transparent); + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.transparent); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('Material2 - TextButton repsects Theme shadowColor', (WidgetTester tester) async { + const colorScheme = ColorScheme.light(); + const shadowColor = Color(0xff000001); + const overriddenColor = Color(0xff000002); + + Widget buildFrame({Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor}) { + return MaterialApp( + theme: ThemeData.from( + useMaterial3: false, + colorScheme: colorScheme, + ).copyWith(shadowColor: overallShadowColor), + home: Scaffold( + body: Center( + child: TextButtonTheme( + data: TextButtonThemeData(style: TextButton.styleFrom(shadowColor: themeShadowColor)), + child: Builder( + builder: (BuildContext context) { + return TextButton( + style: TextButton.styleFrom(shadowColor: shadowColor), + onPressed: () {}, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, Colors.black); //default + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget( + buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor), + ); + await tester.pumpAndSettle(); // theme animation + material = tester.widget<Material>(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); + + testWidgets('TextButton.icon respects TextButtonTheme ButtonStyle.iconAlignment', ( + WidgetTester tester, + ) async { + Widget buildButton({IconAlignment? iconAlignment}) { + return MaterialApp( + theme: ThemeData( + textButtonTheme: TextButtonThemeData(style: ButtonStyle(iconAlignment: iconAlignment)), + ), + home: Scaffold( + body: Center( + child: TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('button'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildButton()); + + final Offset buttonTopLeft = tester.getTopLeft(find.byType(Material).last); + final Offset iconTopLeft = tester.getTopLeft(find.byIcon(Icons.add)); + + expect(buttonTopLeft.dx, iconTopLeft.dx - 12.0); + + await tester.pumpWidget(buildButton(iconAlignment: IconAlignment.end)); + await tester.pumpAndSettle(); + + final Offset buttonTopRight = tester.getTopRight(find.byType(Material).last); + final Offset iconTopRight = tester.getTopRight(find.byIcon(Icons.add)); + + expect(buttonTopRight.dx, iconTopRight.dx + 16.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162839. + testWidgets( + 'TextButton icon uses provided TextButtonThemeData foregroundColor over default icon color', + (WidgetTester tester) async { + const foregroundColor = Color(0xFFFFA500); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: foregroundColor), + ), + ), + home: Material( + child: Center( + child: TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text('Button'), + ), + ), + ), + ), + ); + + expect(iconStyle(tester, Icons.add).color, foregroundColor); + }, + ); +} diff --git a/packages/material_ui/test/material/text_field_cursor_test.dart b/packages/material_ui/test/material/text_field_cursor_test.dart new file mode 100644 index 000000000000..d742dbb71cd8 --- /dev/null +++ b/packages/material_ui/test/material/text_field_cursor_test.dart @@ -0,0 +1,136 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +@TestOn('!chrome') +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'Cursor animates on iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: TextField()))); + + final Finder textFinder = find.byType(TextField); + await tester.tap(textFinder); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorColor!.opacity, 1.0); + + var walltimeMicrosecond = 0; + var lastVerifiedOpacity = 1.0; + + Future<void> verifyKeyFrame({required double opacity, required int at}) async { + const delta = 1; + assert(at - delta > walltimeMicrosecond); + await tester.pump(Duration(microseconds: at - delta - walltimeMicrosecond)); + + // Instead of verifying the opacity at each key frame, this function + // verifies the opacity immediately *before* each key frame to avoid + // fp precision issues. + expect( + renderEditable.cursorColor!.opacity, + closeTo(lastVerifiedOpacity, 0.01), + reason: 'opacity at ${at - delta} microseconds', + ); + + walltimeMicrosecond = at - delta; + lastVerifiedOpacity = opacity; + } + + await verifyKeyFrame(opacity: 1.0, at: 500000); + await verifyKeyFrame(opacity: 0.75, at: 537500); + await verifyKeyFrame(opacity: 0.5, at: 575000); + await verifyKeyFrame(opacity: 0.25, at: 612500); + await verifyKeyFrame(opacity: 0.0, at: 650000); + await verifyKeyFrame(opacity: 0.0, at: 850000); + await verifyKeyFrame(opacity: 0.25, at: 887500); + await verifyKeyFrame(opacity: 0.5, at: 925000); + await verifyKeyFrame(opacity: 0.75, at: 962500); + await verifyKeyFrame(opacity: 1.0, at: 1000000); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Cursor does not animate on non-iOS platforms', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: TextField(maxLines: 3)))); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + // Wait for the current animation to finish. If the cursor never stops its + // blinking animation the test will timeout. + await tester.pumpAndSettle(); + + for (var i = 0; i < 40; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.hasRunningAnimations, false); + } + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('Cursor does not animate on Android', (WidgetTester tester) async { + final defaultCursorColor = Color(ThemeData.fallback().colorScheme.primary.value); + final Widget widget = MaterialApp( + home: Material(child: TextField(maxLines: 3, cursorColor: defaultCursorColor)), + ); + await tester.pumpWidget(widget); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + await tester.pump(); + expect(renderEditable.cursorColor!.alpha, 255); + expect(renderEditable, paints..rect(color: defaultCursorColor)); + + // Android cursor goes from exactly on to exactly off on the 500ms dot. + await tester.pump(const Duration(milliseconds: 499)); + expect(renderEditable.cursorColor!.alpha, 255); + expect(renderEditable, paints..rect(color: defaultCursorColor)); + + await tester.pump(const Duration(milliseconds: 1)); + expect(renderEditable.cursorColor!.alpha, 0); + // Don't try to draw the cursor. + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 500)); + expect(renderEditable.cursorColor!.alpha, 255); + expect(renderEditable, paints..rect(color: defaultCursorColor)); + + await tester.pump(const Duration(milliseconds: 500)); + expect(renderEditable.cursorColor!.alpha, 0); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + }); + + testWidgets( + 'Cursor radius is 2.0 on Apple platforms', + (WidgetTester tester) async { + const Widget widget = MaterialApp(home: Material(child: TextField(maxLines: 3))); + await tester.pumpWidget(widget); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); +} diff --git a/packages/material_ui/test/material/text_field_focus_test.dart b/packages/material_ui/test/material/text_field_focus_test.dart new file mode 100644 index 000000000000..b29392bbde58 --- /dev/null +++ b/packages/material_ui/test/material/text_field_focus_test.dart @@ -0,0 +1,560 @@ +// 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:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Regression test for https://github.com/flutter/flutter/issues/87099 + testWidgets('TextField.autofocus should skip the element that never layout', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Navigator( + pages: const <Page<void>>[_APage(), _BPage()], + onPopPage: (Route<dynamic> route, dynamic result) { + return false; + }, + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('Dialog interaction', (WidgetTester tester) async { + expect(tester.testTextInput.isVisible, isFalse); + + final focusNode = FocusNode(debugLabel: 'Editable Text Node'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode, autofocus: true)), + ), + ), + ); + + expect(tester.testTextInput.isVisible, isTrue); + expect(focusNode.hasPrimaryFocus, isTrue); + + final BuildContext context = tester.element(find.byType(TextField)); + + showDialog<void>( + context: context, + builder: (BuildContext context) => const SimpleDialog(title: Text('Dialog')), + ); + + await tester.pump(); + + expect(tester.testTextInput.isVisible, isFalse); + + Navigator.of(tester.element(find.text('Dialog'))).pop(); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.pumpWidget(Container()); + + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Request focus shows keyboard', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode)), + ), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + + FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode); + await tester.idle(); + + expect(tester.testTextInput.isVisible, isTrue); + + await tester.pumpWidget(Container()); + + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Autofocus shows keyboard', (WidgetTester tester) async { + expect(tester.testTextInput.isVisible, isFalse); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(autofocus: true))), + ), + ); + + expect(tester.testTextInput.isVisible, isTrue); + + await tester.pumpWidget(Container()); + + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Tap shows keyboard', (WidgetTester tester) async { + expect(tester.testTextInput.isVisible, isFalse); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField())), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byType(TextField)); + await tester.idle(); + + expect(tester.testTextInput.isVisible, isTrue); + // Prevent the gesture recognizer from recognizing the next tap as a + // double-tap. + await tester.pump(const Duration(seconds: 1)); + + tester.testTextInput.hide(); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.connectionClosed(); + + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byType(TextField)); + await tester.idle(); + + expect(tester.testTextInput.isVisible, isTrue); + + await tester.pumpWidget(Container()); + + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Focus triggers keep-alive', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + TextField(focusNode: focusNode), + Container(height: 1000.0), + ], + ), + ), + ), + ); + + expect(find.byType(TextField), findsOneWidget); + expect(tester.testTextInput.isVisible, isFalse); + + FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode); + await tester.pump(); + expect(find.byType(TextField), findsOneWidget); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.drag(find.byType(TextField), const Offset(0.0, -1000.0)); + await tester.pump(); + expect(find.byType(TextField, skipOffstage: false), findsOneWidget); + expect(tester.testTextInput.isVisible, isTrue); + + focusNode.unfocus(); + await tester.pump(); + + expect(find.byType(TextField), findsNothing); + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Focus keep-alive works with GlobalKey reparenting', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + Widget makeTest(String? prefix) { + return MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + TextField( + focusNode: focusNode, + decoration: InputDecoration(prefixText: prefix), + ), + Container(height: 1000.0), + ], + ), + ), + ); + } + + await tester.pumpWidget(makeTest(null)); + FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode); + await tester.pump(); + expect(find.byType(TextField), findsOneWidget); + await tester.drag(find.byType(TextField), const Offset(0.0, -1000.0)); + await tester.pump(); + expect(find.byType(TextField, skipOffstage: false), findsOneWidget); + await tester.pumpWidget(makeTest('test')); + await tester.pump(); // in case the AutomaticKeepAlive widget thinks it needs a cleanup frame + expect(find.byType(TextField, skipOffstage: false), findsOneWidget); + }); + + testWidgets('TextField with decoration:null', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/16880 + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(decoration: null))), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + await tester.tap(find.byType(TextField)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + }); + + testWidgets('Sibling FocusScopes', (WidgetTester tester) async { + expect(tester.testTextInput.isVisible, isFalse); + + final focusScopeNode0 = FocusScopeNode(); + addTearDown(focusScopeNode0.dispose); + final focusScopeNode1 = FocusScopeNode(); + addTearDown(focusScopeNode1.dispose); + + final Key textField0 = UniqueKey(); + final Key textField1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + FocusScope( + node: focusScopeNode0, + child: Builder(builder: (BuildContext context) => TextField(key: textField0)), + ), + FocusScope( + node: focusScopeNode1, + child: Builder(builder: (BuildContext context) => TextField(key: textField1)), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byKey(textField0)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.hide(); + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byKey(textField1)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.tap(find.byKey(textField0)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.tap(find.byKey(textField1)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.hide(); + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byKey(textField0)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.pumpWidget(Container()); + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets('Sibling Navigators', (WidgetTester tester) async { + expect(tester.testTextInput.isVisible, isFalse); + + final Key textField0 = UniqueKey(); + final Key textField1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Column( + children: <Widget>[ + Expanded( + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return TextField(key: textField0); + }, + settings: settings, + ); + }, + ), + ), + Expanded( + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return TextField(key: textField1); + }, + settings: settings, + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byKey(textField0)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.hide(); + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byKey(textField1)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.tap(find.byKey(textField0)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.tap(find.byKey(textField1)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + tester.testTextInput.hide(); + expect(tester.testTextInput.isVisible, isFalse); + + await tester.tap(find.byKey(textField0)); + await tester.idle(); + expect(tester.testTextInput.isVisible, isTrue); + + await tester.pumpWidget(Container()); + expect(tester.testTextInput.isVisible, isFalse); + }); + + testWidgets( + 'A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop', + (WidgetTester tester) async { + final focusNodeA = FocusNode(); + addTearDown(focusNodeA.dispose); + final focusNodeB = FocusNode(); + addTearDown(focusNodeB.dispose); + + final Key key = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + TextField(focusNode: focusNodeA), + Container(key: key, height: 200), + TextField(focusNode: focusNodeB), + ], + ), + ), + ), + ); + + final TestGesture down1 = await tester.startGesture( + tester.getCenter(find.byType(TextField).first), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.pumpAndSettle(); + await down1.up(); + await down1.removePointer(); + + expect(focusNodeA.hasFocus, true); + expect(focusNodeB.hasFocus, false); + + // Click on the container to not hit either text field. + final TestGesture down2 = await tester.startGesture( + tester.getCenter(find.byKey(key)), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.pumpAndSettle(); + await down2.up(); + await down2.removePointer(); + + expect(focusNodeA.hasFocus, false); + expect(focusNodeB.hasFocus, false); + + // Second text field can still gain focus. + + final TestGesture down3 = await tester.startGesture( + tester.getCenter(find.byType(TextField).last), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.pumpAndSettle(); + await down3.up(); + await down3.removePointer(); + + expect(focusNodeA.hasFocus, false); + expect(focusNodeB.hasFocus, true); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'A Focused text-field will not lose focus when clicking on its decoration', + (WidgetTester tester) async { + final focusNodeA = FocusNode(); + addTearDown(focusNodeA.dispose); + final Key iconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + TextField( + focusNode: focusNodeA, + decoration: InputDecoration(icon: Icon(Icons.copy_all, key: iconKey)), + ), + ], + ), + ), + ), + ); + + final TestGesture down1 = await tester.startGesture( + tester.getCenter(find.byType(TextField).first), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await down1.removePointer(); + + expect(focusNodeA.hasFocus, true); + + // Click on the icon which has a different RO than the text field's focus node context + final TestGesture down2 = await tester.startGesture( + tester.getCenter(find.byKey(iconKey)), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.pumpAndSettle(); + await down2.up(); + await down2.removePointer(); + + expect(focusNodeA.hasFocus, true); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop after tab navigation', + (WidgetTester tester) async { + final focusNodeA = FocusNode(debugLabel: 'A'); + addTearDown(focusNodeA.dispose); + final focusNodeB = FocusNode(debugLabel: 'B'); + addTearDown(focusNodeB.dispose); + + final Key key = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + const TextField(), + const TextField(), + TextField(focusNode: focusNodeA), + Container(key: key, height: 200), + TextField(focusNode: focusNodeB), + ], + ), + ), + ), + ); + // Tab over to the 3rd text field. + for (var i = 0; i < 3; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + } + + Future<void> click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + await gesture.removePointer(); + } + + expect(focusNodeA.hasFocus, true); + expect(focusNodeB.hasFocus, false); + + // Click on the container to not hit either text field. + await click(find.byKey(key)); + await tester.pump(); + + expect(focusNodeA.hasFocus, false); + expect(focusNodeB.hasFocus, false); + + // Second text field can still gain focus. + + await click(find.byType(TextField).last); + await tester.pump(); + + expect(focusNodeA.hasFocus, false); + expect(focusNodeB.hasFocus, true); + }, + variant: TargetPlatformVariant.desktop(), + ); +} + +class _APage extends Page<void> { + const _APage(); + + @override + Route<void> createRoute(BuildContext context) => PageRouteBuilder<void>( + settings: this, + pageBuilder: (_, _, _) => const TextField(autofocus: true), + ); +} + +class _BPage extends Page<void> { + const _BPage(); + + @override + Route<void> createRoute(BuildContext context) => + PageRouteBuilder<void>(settings: this, pageBuilder: (_, _, _) => const Text('B')); +} diff --git a/packages/material_ui/test/material/text_field_helper_text_test.dart b/packages/material_ui/test/material/text_field_helper_text_test.dart new file mode 100644 index 000000000000..5f73ffc945d7 --- /dev/null +++ b/packages/material_ui/test/material/text_field_helper_text_test.dart @@ -0,0 +1,29 @@ +// 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('TextField works correctly when changing helperText', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField(decoration: InputDecoration(helperText: 'Awesome')), + ), + ), + ); + expect(find.text('Awesome'), findsNWidgets(1)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Awesome'), findsNWidgets(1)); + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField(decoration: InputDecoration(errorText: 'Awesome')), + ), + ), + ); + expect(find.text('Awesome'), findsNWidgets(2)); + }); +} diff --git a/packages/material_ui/test/material/text_field_restoration_test.dart b/packages/material_ui/test/material/text_field_restoration_test.dart new file mode 100644 index 000000000000..91d40c5b2ea9 --- /dev/null +++ b/packages/material_ui/test/material/text_field_restoration_test.dart @@ -0,0 +1,108 @@ +// 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'; + +const String text = 'Hello World! How are you? Life is good!'; +const String alternativeText = 'Everything is awesome!!'; + +void main() { + testWidgets('TextField restoration', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(restorationScopeId: 'app', home: TestWidget())); + + await restoreAndVerify(tester); + }); + + testWidgets('TextField restoration with external controller', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp(restorationScopeId: 'root', home: TestWidget(useExternal: true)), + ); + + await restoreAndVerify(tester); + }); +} + +Future<void> restoreAndVerify(WidgetTester tester) async { + expect(find.text(text), findsNothing); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0); + + await tester.enterText(find.byType(TextField), text); + await skipPastScrollingAnimation(tester); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 0); + + await tester.drag(find.byType(Scrollable), const Offset(0, -80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); + + await tester.restartAndRestore(); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); + + final TestRestorationData data = await tester.getRestorationData(); + + await tester.enterText(find.byType(TextField), alternativeText); + await skipPastScrollingAnimation(tester); + await tester.drag(find.byType(Scrollable), const Offset(0, 80)); + await skipPastScrollingAnimation(tester); + + expect(find.text(text), findsNothing); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, isNot(60)); + + await tester.restoreFrom(data); + + expect(find.text(text), findsOneWidget); + expect(tester.state<ScrollableState>(find.byType(Scrollable)).position.pixels, 60); +} + +class TestWidget extends StatefulWidget { + const TestWidget({super.key, this.useExternal = false}); + + final bool useExternal; + + @override + TestWidgetState createState() => TestWidgetState(); +} + +class TestWidgetState extends State<TestWidget> with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + + @override + String get restorationId => 'widget'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'controller'); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Align( + child: SizedBox( + width: 50, + child: TextField( + restorationId: 'text', + maxLines: 3, + controller: widget.useExternal ? controller.value : null, + ), + ), + ), + ); + } +} + +Future<void> skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} diff --git a/packages/material_ui/test/material/text_field_splash_test.dart b/packages/material_ui/test/material/text_field_splash_test.dart new file mode 100644 index 000000000000..1211ad9d5c45 --- /dev/null +++ b/packages/material_ui/test/material/text_field_splash_test.dart @@ -0,0 +1,170 @@ +// 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/gestures.dart' show kPressTimeout; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +bool confirmCalled = false; +bool cancelCalled = false; + +class TestInkSplash extends InkSplash { + TestInkSplash({ + required super.controller, + required super.referenceBox, + super.position, + required super.color, + super.containedInkWell, + super.rectCallback, + super.borderRadius, + super.customBorder, + super.radius, + super.onRemoved, + required super.textDirection, + }); + + @override + void confirm() { + confirmCalled = true; + super.confirm(); + } + + @override + void cancel() { + cancelCalled = true; + super.cancel(); + } +} + +class TestInkSplashFactory extends InteractiveInkFeatureFactory { + const TestInkSplashFactory(); + + @override + InteractiveInkFeature create({ + required MaterialInkController controller, + required RenderBox referenceBox, + Offset? position, + required Color color, + bool containedInkWell = false, + RectCallback? rectCallback, + BorderRadius? borderRadius, + ShapeBorder? customBorder, + double? radius, + VoidCallback? onRemoved, + required TextDirection textDirection, + }) { + return TestInkSplash( + controller: controller, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: containedInkWell, + rectCallback: rectCallback, + borderRadius: borderRadius, + customBorder: customBorder, + radius: radius, + onRemoved: onRemoved, + textDirection: textDirection, + ); + } +} + +void main() { + setUp(() { + confirmCalled = false; + cancelCalled = false; + }); + + testWidgets('Tapping should never cause a splash', (WidgetTester tester) async { + final Key textField1 = UniqueKey(); + final Key textField2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(splashFactory: const TestInkSplashFactory()), + child: Material( + child: Container( + alignment: Alignment.topLeft, + child: Column( + children: <Widget>[ + TextField( + key: textField1, + decoration: const InputDecoration(labelText: 'label'), + ), + TextField( + key: textField2, + decoration: const InputDecoration(labelText: 'label'), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(textField1)); + await tester.pumpAndSettle(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + + await tester.tap(find.byKey(textField1)); + await tester.pumpAndSettle(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + + await tester.tap(find.byKey(textField2)); + await tester.pumpAndSettle(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + + await tester.tapAt(tester.getTopLeft(find.byKey(textField1))); + await tester.pumpAndSettle(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + + await tester.tap(find.byKey(textField2)); + await tester.pumpAndSettle(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + }); + + testWidgets('Splash should never be created or canceled', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(splashFactory: const TestInkSplashFactory()), + child: Material( + child: ListView( + children: <Widget>[ + const TextField(decoration: InputDecoration(labelText: 'label1')), + const TextField(decoration: InputDecoration(labelText: 'label2')), + Container(height: 1000.0, color: const Color(0xFF00FF00)), + ], + ), + ), + ), + ), + ); + + // If there were a splash, this would cancel the splash. + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('label1'))); + + await tester.pump(kPressTimeout); + + await gesture1.moveTo(const Offset(400.0, 300.0)); + await gesture1.up(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + + // Pointer is dragged upwards causing a scroll, splash would be canceled. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('label2'))); + await tester.pump(kPressTimeout); + await gesture2.moveBy(const Offset(0.0, -200.0)); + await gesture2.up(); + expect(confirmCalled, isFalse); + expect(cancelCalled, isFalse); + }); +} diff --git a/packages/material_ui/test/material/text_field_test.dart b/packages/material_ui/test/material/text_field_test.dart new file mode 100644 index 000000000000..472a38cbf5c9 --- /dev/null +++ b/packages/material_ui/test/material/text_field_test.dart @@ -0,0 +1,19581 @@ +// 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. + +// reduced-test-set: +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +// no-shuffle: +// TODO(122950): Remove this tag once this test's state leaks/test +// dependencies have been fixed. +// https://github.com/flutter/flutter/issues/122950 +// Fails with "flutter test --test-randomize-ordering-seed=20230318" +@Tags(<String>['reduced-test-set', 'no-shuffle']) +library; + +import 'dart:math' as math; +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, SemanticsInputType; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import '../widgets/feedback_tester.dart'; +import '../widgets/process_text_utils.dart'; +import '../widgets/semantics_tester.dart'; +import '../widgets/text_selection_toolbar_utils.dart'; +import 'editable_text_utils.dart'; +import 'live_text_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + + const kThreeLines = + 'First line of text is\n' + 'Second line goes until\n' + 'Third line of stuff'; + const kMoreThanFourLines = + '$kThreeLines\n' + "Fourth line won't display and ends at"; + // Gap between caret and edge of input, defined in editable.dart. + const kCaretGap = 1; + + setUp(() async { + debugResetSemanticsIdCounter(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + final Key textFieldKey = UniqueKey(); + Widget textFieldBuilder({int? maxLines = 1, int? minLines}) { + return boilerplate( + child: TextField( + key: textFieldKey, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: maxLines, + minLines: minLines, + decoration: const InputDecoration(hintText: 'Placeholder'), + ), + ); + } + + testWidgets('Live Text button shows and hides correctly when LiveTextStatus changes', ( + WidgetTester tester, + ) async { + final liveTextInputTester = LiveTextInputTester(); + addTearDown(liveTextInputTester.dispose); + final TextEditingController controller = _textEditingController(); + const Key key = ValueKey<String>('TextField'); + final FocusNode focusNode = _focusNode(); + final Widget app = MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Scaffold( + body: Center( + child: TextField(key: key, controller: controller, focusNode: focusNode), + ), + ), + ); + + liveTextInputTester.mockLiveTextInputEnabled = true; + await tester.pumpWidget(app); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + await tester.pumpAndSettle(); + expect(findLiveTextButton(), kIsWeb ? findsNothing : findsOneWidget); + + liveTextInputTester.mockLiveTextInputEnabled = false; + await tester.longPress(textFinder); + await tester.pumpAndSettle(); + expect(findLiveTextButton(), findsNothing); + }); + + testWidgets( + 'text field selection toolbar should hide when the user starts typing', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: TextField(decoration: InputDecoration(hintText: 'Placeholder')), + ), + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField)); + + const testValue = 'A B C'; + tester.testTextInput.updateEditingValue(const TextEditingValue(text: testValue)); + await tester.pump(); + + // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. + // (This is true even if we provide selection parameter to the TextEditingValue above.) + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap); + + expect(state.showToolbar(), true); + + // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible. + await tester.pumpAndSettle(); + + // Sanity check that the toolbar widget exists. + expect(find.text('Paste'), findsOneWidget); + + const newValue = 'A B C D'; + tester.testTextInput.updateEditingValue(const TextEditingValue(text: newValue)); + await tester.pump(); + + expect(state.selectionOverlay!.toolbarIsVisible, isFalse); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('Composing change does not hide selection handle caret', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/108673 + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'I Love Flutter!'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + // Handle not shown. + expect(controller.selection.isCollapsed, true); + final Finder fadeFinder = find.byType(FadeTransition); + FadeTransition handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(0.0)); + + // Tap on the text field to show the handle. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(fadeFinder, findsNWidgets(1)); + handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + final RenderObject handleRenderObjectBegin = tester.renderObject(fadeFinder.at(0)); + + expect( + controller.value, + const TextEditingValue( + text: 'I Love Flutter!', + selection: TextSelection.collapsed(offset: 15, affinity: TextAffinity.upstream), + ), + ); + + // Simulate text composing change. + tester.testTextInput.updateEditingValue( + controller.value.copyWith(composing: const TextRange(start: 7, end: 15)), + ); + await skipPastScrollingAnimation(tester); + + expect( + controller.value, + const TextEditingValue( + text: 'I Love Flutter!', + selection: TextSelection.collapsed(offset: 15, affinity: TextAffinity.upstream), + composing: TextRange(start: 7, end: 15), + ), + ); + + // Handle still shown. + expect(controller.selection.isCollapsed, true); + handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + + // Simulate text composing and affinity change. + tester.testTextInput.updateEditingValue( + controller.value.copyWith( + selection: controller.value.selection.copyWith(affinity: TextAffinity.downstream), + composing: const TextRange(start: 8, end: 15), + ), + ); + await skipPastScrollingAnimation(tester); + + expect( + controller.value, + const TextEditingValue( + text: 'I Love Flutter!', + selection: TextSelection.collapsed(offset: 15, affinity: TextAffinity.upstream), + composing: TextRange(start: 8, end: 15), + ), + ); + + // Handle still shown. + expect(controller.selection.isCollapsed, true); + handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + + final RenderObject handleRenderObjectEnd = tester.renderObject(fadeFinder.at(0)); + // The RenderObject sub-tree should not be unmounted. + expect(identical(handleRenderObjectBegin, handleRenderObjectEnd), true); + }); + + testWidgets( + 'can use the desktop cut/copy/paste buttons on Mac', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoCupertinoToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expectNoCupertinoToolbar(); + + // Paste it at the end. + await gesture.down(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Paste')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2blah1'); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Cut the first word. + await gesture.down(midBlah1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + await tester.tap(find.text('Cut')); + await tester.pumpAndSettle(); + expect(controller.text, ' blah2blah1'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0)); + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'can use the desktop cut/copy/paste buttons on Windows and Linux', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoCupertinoToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 2)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + + // Double tap to select the first word, then right click to show the menu. + final Offset startBlah1 = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(startBlah1, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.down(startBlah1); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expectNoCupertinoToolbar(); + + // Paste it at the end. + gesture = await tester.startGesture( + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + gesture = await tester.startGesture( + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Paste')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2blah1'); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Cut the first word. + gesture = await tester.startGesture(midBlah1, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.down(startBlah1); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + gesture = await tester.startGesture( + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Cut')); + await tester.pumpAndSettle(); + expect(controller.text, ' blah2blah1'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0)); + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.windows, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Look Up shows up on iOS only', + (WidgetTester tester) async { + String? lastLookUp; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'LookUp.invoke') { + expect(methodCall.arguments, isA<String>()); + lastLookUp = methodCall.arguments as String; + } + return null; + }, + ); + + final TextEditingController controller = _textEditingController(text: 'Test '); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final isTargetPlatformiOS = defaultTargetPlatform == TargetPlatform.iOS; + + // Long press to put the cursor after the "s". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(find.text('Look Up'), isTargetPlatformiOS ? findsOneWidget : findsNothing); + + if (isTargetPlatformiOS) { + await tester.tap(find.text('Look Up')); + expect(lastLookUp, 'Test'); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Search Web shows up on iOS only', + (WidgetTester tester) async { + String? lastSearch; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'SearchWeb.invoke') { + expect(methodCall.arguments, isA<String>()); + lastSearch = methodCall.arguments as String; + } + return null; + }, + ); + + final TextEditingController controller = _textEditingController(text: 'Test '); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final isTargetPlatformiOS = defaultTargetPlatform == TargetPlatform.iOS; + + // Long press to put the cursor after the "s". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(find.text('Search Web'), isTargetPlatformiOS ? findsOneWidget : findsNothing); + + if (isTargetPlatformiOS) { + await tester.tap(find.text('Search Web')); + expect(lastSearch, 'Test'); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Share shows up on iOS and Android', + (WidgetTester tester) async { + String? lastShare; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'Share.invoke') { + expect(methodCall.arguments, isA<String>()); + lastShare = methodCall.arguments as String; + } + return null; + }, + ); + + final TextEditingController controller = _textEditingController(text: 'Test '); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final isTargetPlatformiOS = defaultTargetPlatform == TargetPlatform.iOS; + + // Long press to put the cursor after the "s". + const index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + + if (isTargetPlatformiOS) { + expect(find.text('Share...'), findsOneWidget); + await tester.tap(find.text('Share...')); + } else { + expect(find.text('Share'), findsOneWidget); + await tester.tap(find.text('Share')); + } + expect(lastShare, 'Test'); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('uses DefaultSelectionStyle for selection and cursor colors if provided', ( + WidgetTester tester, + ) async { + const Color selectionColor = Colors.orange; + const Color cursorColor = Colors.red; + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: DefaultSelectionStyle( + selectionColor: selectionColor, + cursorColor: cursorColor, + child: TextField(autofocus: true), + ), + ), + ), + ); + await tester.pump(); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.widget.selectionColor, selectionColor); + expect(state.widget.cursorColor, cursorColor); + }); + + testWidgets( + 'Use error cursor color when an InputDecoration with an errorText or error widget is provided', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField( + autofocus: true, + decoration: InputDecoration( + error: Text('error'), + errorStyle: TextStyle(color: Colors.teal), + ), + ), + ), + ), + ); + await tester.pump(); + EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.widget.cursorColor, Colors.teal); + + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField( + autofocus: true, + decoration: InputDecoration( + errorText: 'error', + errorStyle: TextStyle(color: Colors.teal), + ), + ), + ), + ), + ); + await tester.pump(); + state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.widget.cursorColor, Colors.teal); + }, + ); + + testWidgets('suffix has correct semantics', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/169499. + final suffix = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Scaffold( + body: Center( + child: TextField( + autofocus: true, + decoration: InputDecoration( + suffix: Semantics( + key: suffix, + identifier: 'myId', + container: true, + child: const SizedBox(width: 50, height: 50, child: Text('suffix')), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); // Wait for autofocus and suffix animation. + expect(find.text('suffix'), findsOneWidget); + expect( + tester.semantics.find(find.byKey(suffix)), + isSemantics(label: 'suffix', identifier: 'myId', rect: const Rect.fromLTWH(0, 0, 50, 50)), + ); + }); + + testWidgets('sets cursorOpacityAnimates on EditableText correctly', (WidgetTester tester) async { + // True + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: TextField(autofocus: true, cursorOpacityAnimates: true)), + ), + ); + await tester.pump(); + EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.cursorOpacityAnimates, true); + + // False + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: TextField(autofocus: true, cursorOpacityAnimates: false)), + ), + ); + await tester.pump(); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.cursorOpacityAnimates, false); + }); + + testWidgets( + 'Activates the text field when receives semantics focus on desktops', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final FocusNode focusNode = _focusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(focusNode: focusNode)), + ), + ); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.didGainAccessibilityFocus, + SemanticsAction.didLoseAccessibilityFocus, + ], + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.didGainAccessibilityFocus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + + semanticsOwner.performAction(4, SemanticsAction.didLoseAccessibilityFocus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + semantics.dispose(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + }), + ); + + testWidgets('TextField passes onEditingComplete to EditableText', (WidgetTester tester) async { + void onEditingComplete() {} + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(onEditingComplete: onEditingComplete)), + ), + ); + + final Finder editableTextFinder = find.byType(EditableText); + expect(editableTextFinder, findsOneWidget); + + final EditableText editableTextWidget = tester.widget(editableTextFinder); + expect(editableTextWidget.onEditingComplete, onEditingComplete); + }); + + // Regression test for https://github.com/flutter/flutter/issues/127597. + testWidgets( + 'The second TextField is clicked, triggers the onTapOutside callback of the previous TextField', + (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey keyC = GlobalKey(); + var outsideClickA = false; + var outsideClickB = false; + var outsideClickC = false; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: <Widget>[ + const Text('Outside'), + Material( + child: TextField( + key: keyA, + groupId: 'Group A', + onTapOutside: (PointerDownEvent event) { + outsideClickA = true; + }, + ), + ), + Material( + child: TextField( + key: keyB, + groupId: 'Group B', + onTapOutside: (PointerDownEvent event) { + outsideClickB = true; + }, + ), + ), + Material( + child: TextField( + key: keyC, + groupId: 'Group C', + onTapOutside: (PointerDownEvent event) { + outsideClickC = true; + }, + ), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + + Future<void> click(Finder finder) async { + await tester.tap(finder); + await tester.enterText(finder, 'Hello'); + await tester.pump(); + } + + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyA)); + await tester.showKeyboard(find.byKey(keyA)); + await tester.idle(); + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyB)); + expect(outsideClickA, true); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyC)); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, false); + + await tester.tap(find.text('Outside')); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, true); + }, + ); + + testWidgets('TextField has consistent size', (WidgetTester tester) async { + final Key textFieldKey = UniqueKey(); + String? textFieldValue; + + await tester.pumpWidget( + overlay( + child: TextField( + key: textFieldKey, + decoration: const InputDecoration(hintText: 'Placeholder'), + onChanged: (String value) { + textFieldValue = value; + }, + ), + ), + ); + + RenderBox findTextFieldBox() => tester.renderObject(find.byKey(textFieldKey)); + + final RenderBox inputBox = findTextFieldBox(); + final Size emptyInputSize = inputBox.size; + + Future<void> checkText(String testValue) async { + return TestAsyncUtils.guard(() async { + expect(textFieldValue, isNull); + await tester.enterText(find.byType(TextField), testValue); + // Check that the onChanged event handler fired. + expect(textFieldValue, equals(testValue)); + textFieldValue = null; + await skipPastScrollingAnimation(tester); + }); + } + + await checkText(' '); + expect(findTextFieldBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + + await checkText('Test'); + expect(findTextFieldBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + }); + + testWidgets('Cursor blinks', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const TextField(decoration: InputDecoration(hintText: 'Placeholder')), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + + // Check that the cursor visibility toggles after each blink interval. + Future<void> checkCursorToggle() async { + final bool initialShowCursor = editableText.cursorCurrentlyVisible; + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval ~/ 10); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor)); + await tester.pump(editableText.cursorBlinkInterval); + expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor)); + } + + await checkCursorToggle(); + await tester.showKeyboard(find.byType(TextField)); + + // Try the test again with a nonempty EditableText. + tester.testTextInput.updateEditingValue( + const TextEditingValue(text: 'X', selection: TextSelection.collapsed(offset: 1)), + ); + await tester.idle(); + expect(tester.state(find.byType(EditableText)), editableText); + await checkCursorToggle(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/78918. + testWidgets('RenderEditable sets correct text editing value', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'how are you'); + final icon = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + controller: controller, + decoration: InputDecoration( + suffixIcon: IconButton( + key: icon, + icon: const Icon(Icons.cancel), + onPressed: () => controller.clear(), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(icon)); + await tester.pump(); + expect(controller.text, ''); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + }); + + testWidgets( + 'Cursor radius is 2.0', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: TextField()))); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + expect(renderEditable.cursorRadius, const Radius.circular(2.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('cursor has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const TextField())); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.cursorWidth, 2.0); + expect(textField.cursorHeight, null); + expect(textField.cursorRadius, null); + }); + + testWidgets('cursor has expected radius value', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const TextField(cursorRadius: Radius.circular(3.0)))); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.cursorWidth, 2.0); + expect(textField.cursorRadius, const Radius.circular(3.0)); + }); + + testWidgets('clipBehavior has expected defaults', (WidgetTester tester) async { + await tester.pumpWidget(overlay(child: const TextField())); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.clipBehavior, Clip.hardEdge); + }); + + testWidgets('Overflow clipBehavior none golden', (WidgetTester tester) async { + final controller = OverflowWidgetTextEditingController(); + addTearDown(controller.dispose); + final Widget widget = Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: RepaintBoundary( + key: const ValueKey<int>(1), + child: SizedBox.square( + dimension: 200, + child: Center( + child: SizedBox( + // Make sure the input field is not high enough for the WidgetSpan. + height: 50, + child: TextField(controller: controller, clipBehavior: Clip.none), + ), + ), + ), + ), + ), + ); + await tester.pumpWidget(widget); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.clipBehavior, Clip.none); + + final EditableText editableText = tester.firstWidget(find.byType(EditableText)); + expect(editableText.clipBehavior, Clip.none); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('overflow_clipbehavior_none.material.0.png'), + ); + }); + + testWidgets('Material cursor android golden', (WidgetTester tester) async { + final Widget widget = Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: const RepaintBoundary( + key: ValueKey<int>(1), + child: TextField( + cursorColor: Colors.blue, + cursorWidth: 15, + cursorRadius: Radius.circular(3.0), + ), + ), + ), + ); + await tester.pumpWidget(widget); + + const testValue = 'A short phrase'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + await tester.tapAt(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_cursor_test.material.0.png'), + ); + }); + + testWidgets( + 'Material cursor golden', + (WidgetTester tester) async { + final Widget widget = Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: const RepaintBoundary( + key: ValueKey<int>(1), + child: TextField( + cursorColor: Colors.blue, + cursorWidth: 15, + cursorRadius: Radius.circular(3.0), + ), + ), + ), + ); + await tester.pumpWidget(widget); + + const testValue = 'A short phrase'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + await tester.tapAt(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile( + 'text_field_cursor_test_${debugDefaultTargetPlatformOverride!.name.toLowerCase()}.material.1.png', + ), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'TextInputFormatter gets correct selection value', + (WidgetTester tester) async { + late TextEditingValue actualOldValue; + late TextEditingValue actualNewValue; + void callBack(TextEditingValue oldValue, TextEditingValue newValue) { + actualOldValue = oldValue; + actualNewValue = newValue; + } + + final FocusNode focusNode = _focusNode(); + final TextEditingController controller = _textEditingController(text: '123'); + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: controller, + focusNode: focusNode, + inputFormatters: <TextInputFormatter>[TestFormatter(callBack)], + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + expect( + actualOldValue, + const TextEditingValue( + text: '123', + selection: TextSelection.collapsed(offset: 3, affinity: TextAffinity.upstream), + ), + ); + expect( + actualNewValue, + const TextEditingValue(text: '12', selection: TextSelection.collapsed(offset: 2)), + ); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + ); + + testWidgets( + 'text field selection toolbar renders correctly inside opacity', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: Opacity( + opacity: 0.5, + child: TextField(decoration: InputDecoration(hintText: 'Placeholder')), + ), + ), + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField)); + + const testValue = 'A B C'; + tester.testTextInput.updateEditingValue(const TextEditingValue(text: testValue)); + await tester.pump(); + + // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. + // (This is true even if we provide selection parameter to the TextEditingValue above.) + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap); + + expect(state.showToolbar(), true); + + // This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible. + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 1)); + + // Sanity check that the toolbar widget exists. + expect(find.text('Paste'), findsOneWidget); + + await expectLater( + // The toolbar exists in the Overlay above the MaterialApp. + find.byType(Overlay), + matchesGoldenFile('text_field_opacity_test.0.png'), + ); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'text field toolbar options correctly changes options', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + toolbarOptions: const ToolbarOptions(copy: true), + ), + ), + ), + ), + ); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(textOffsetToPosition(tester, 3)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pump(); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // Selected text shows 'Copy', and not 'Paste', 'Cut', 'Select All'. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('text selection style 1', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure\nhi\nwasssup!', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: RepaintBoundary( + child: Container( + width: 650.0, + height: 600.0, + decoration: const BoxDecoration(color: Color(0xff00ff00)), + child: Column( + children: <Widget>[ + TextField( + key: const Key('field0'), + controller: controller, + style: const TextStyle(height: 4, color: Colors.black45), + toolbarOptions: const ToolbarOptions(copy: true, selectAll: true), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingTop, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 2.0)); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + await tester.tapAt(textfieldStart + const Offset(100.0, 107.0)); + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('text_field_golden.TextSelectionStyle.1.png'), + ); + }); + + testWidgets('text selection style 2', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure\nhi\nwasssup!', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: RepaintBoundary( + child: Container( + width: 650.0, + height: 600.0, + decoration: const BoxDecoration(color: Color(0xff00ff00)), + child: Column( + children: <Widget>[ + TextField( + key: const Key('field0'), + controller: controller, + style: const TextStyle(height: 4, color: Colors.black45), + toolbarOptions: const ToolbarOptions(copy: true, selectAll: true), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingBottom, + selectionWidthStyle: ui.BoxWidthStyle.tight, + maxLines: 3, + ), + ], + ), + ), + ), + ), + ), + ), + ); + final EditableTextState editableTextState = tester.state(find.byType(EditableText)); + + // Double tap to select the first word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 7); + + // Select all text. Use the toolbar if possible. iOS only shows the toolbar + // when the selection is collapsed. + if (isContextMenuProvidedByPlatform || defaultTargetPlatform == TargetPlatform.iOS) { + controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length); + expect(controller.selection.extentOffset, controller.text.length); + } else { + await tester.tap(find.text('Select all')); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, controller.text.length); + } + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('text_field_golden.TextSelectionStyle.2.png'), + ); + // Text selection styles are not fully supported on web. + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/93723 + + testWidgets( + 'text field toolbar options correctly changes options', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + toolbarOptions: const ToolbarOptions(copy: true), + ), + ), + ), + ), + ); + + final Offset pos = textOffsetToPosition(tester, 9); // Index of 'P|eel' + + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tapAt(pos); + await tester.pump(); + + // Selected text shows 'Copy', and not 'Paste', 'Cut', 'Select all'. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select all'), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('cursor layout has correct width', (WidgetTester tester) async { + final controller = TextEditingController.fromValue( + const TextEditingValue(selection: TextSelection.collapsed(offset: 0)), + ); + addTearDown(controller.dispose); + final FocusNode focusNode = _focusNode(); + EditableText.debugDeterministicCursor = true; + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: RepaintBoundary( + child: TextField(cursorWidth: 15.0, controller: controller, focusNode: focusNode), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await expectLater( + find.byType(TextField), + matchesGoldenFile('text_field_cursor_width_test.0.png'), + ); + EditableText.debugDeterministicCursor = false; + }); + + testWidgets('cursor layout has correct radius', (WidgetTester tester) async { + final controller = TextEditingController.fromValue( + const TextEditingValue(selection: TextSelection.collapsed(offset: 0)), + ); + addTearDown(controller.dispose); + final FocusNode focusNode = _focusNode(); + EditableText.debugDeterministicCursor = true; + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: RepaintBoundary( + child: TextField( + cursorWidth: 15.0, + cursorRadius: const Radius.circular(3.0), + controller: controller, + focusNode: focusNode, + ), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await expectLater( + find.byType(TextField), + matchesGoldenFile('text_field_cursor_width_test.1.png'), + ); + EditableText.debugDeterministicCursor = false; + }); + + testWidgets('cursor layout has correct height', (WidgetTester tester) async { + final controller = TextEditingController.fromValue( + const TextEditingValue(selection: TextSelection.collapsed(offset: 0)), + ); + addTearDown(controller.dispose); + final FocusNode focusNode = _focusNode(); + + EditableText.debugDeterministicCursor = true; + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: RepaintBoundary( + child: TextField( + cursorWidth: 15.0, + cursorHeight: 30.0, + controller: controller, + focusNode: focusNode, + ), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await expectLater( + find.byType(TextField), + matchesGoldenFile('text_field_cursor_width_test.2.png'), + ); + EditableText.debugDeterministicCursor = false; + }); + + testWidgets('Overflowing a line with spaces stops the cursor at the end', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: TextField(key: textFieldKey, controller: controller, maxLines: null), + ), + ), + ); + expect(controller.selection.baseOffset, -1); + expect(controller.selection.extentOffset, -1); + + const testValueOneLine = 'enough text to be exactly at the end of the line.'; + await tester.enterText(find.byType(TextField), testValueOneLine); + await skipPastScrollingAnimation(tester); + + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); + + RenderBox inputBox = findInputBox(); + final Size oneLineInputSize = inputBox.size; + + await tester.tapAt(textOffsetToPosition(tester, testValueOneLine.length)); + await tester.pump(); + + const testValueTwoLines = 'enough text to overflow the first line and go to the second'; + await tester.enterText(find.byType(TextField), testValueTwoLines); + await skipPastScrollingAnimation(tester); + + expect(inputBox, findInputBox()); + inputBox = findInputBox(); + expect(inputBox.size.height, greaterThan(oneLineInputSize.height)); + final Size twoLineInputSize = inputBox.size; + + // Enter a string with the same number of characters as testValueTwoLines, + // but where the overflowing part is all spaces. Assert that it only renders + // on one line. + const testValueSpaces = '$testValueOneLine '; + expect(testValueSpaces.length, testValueTwoLines.length); + await tester.enterText(find.byType(TextField), testValueSpaces); + await skipPastScrollingAnimation(tester); + + expect(inputBox, findInputBox()); + inputBox = findInputBox(); + expect(inputBox.size.height, oneLineInputSize.height); + + // Swapping the final space for a letter causes it to wrap to 2 lines. + const testValueSpacesOverflow = '$testValueOneLine a'; + expect(testValueSpacesOverflow.length, testValueTwoLines.length); + await tester.enterText(find.byType(TextField), testValueSpacesOverflow); + await skipPastScrollingAnimation(tester); + + expect(inputBox, findInputBox()); + inputBox = findInputBox(); + expect(inputBox.size.height, twoLineInputSize.height); + + // Positioning the cursor at the end of a line overflowing with spaces puts + // it inside the input still. + await tester.enterText(find.byType(TextField), testValueSpaces); + await skipPastScrollingAnimation(tester); + await tester.tapAt(textOffsetToPosition(tester, testValueSpaces.length)); + await tester.pump(); + + final double inputWidth = findRenderEditable(tester).size.width; + final Offset cursorOffsetSpaces = findRenderEditable( + tester, + ).getLocalRectForCaret(const TextPosition(offset: testValueSpaces.length)).bottomRight; + + expect(cursorOffsetSpaces.dx, inputWidth - kCaretGap); + }); + + testWidgets('Overflowing a line with spaces stops the cursor at the end (rtl direction)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + overlay(child: const TextField(textDirection: TextDirection.rtl, maxLines: null)), + ); + + const testValueOneLine = 'enough text to be exactly at the end of the line.'; + const testValueSpaces = '$testValueOneLine '; + + // Positioning the cursor at the end of a line overflowing with spaces puts + // it inside the input still. + await tester.enterText(find.byType(TextField), testValueSpaces); + await skipPastScrollingAnimation(tester); + await tester.tapAt(textOffsetToPosition(tester, testValueSpaces.length)); + await tester.pump(); + + final Offset cursorOffsetSpaces = findRenderEditable( + tester, + ).getLocalRectForCaret(const TextPosition(offset: testValueSpaces.length)).topLeft; + + expect(cursorOffsetSpaces.dx >= 0, isTrue); + }); + + testWidgets( + 'mobile obscureText control test', + (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const TextField( + obscureText: true, + decoration: InputDecoration(hintText: 'Placeholder'), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + + const testValue = 'ABC'; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue, + selection: TextSelection.collapsed(offset: testValue.length), + ), + ); + + await tester.pump(); + + // Enter a character into the obscured field and verify that the character + // is temporarily shown to the user and then changed to a bullet. + const newChar = 'X'; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue + newChar, + selection: TextSelection.collapsed(offset: testValue.length + 1), + ), + ); + + await tester.pump(); + + String editText = (findRenderEditable(tester).text! as TextSpan).text!; + expect(editText.substring(editText.length - 1), newChar); + + await tester.pump(const Duration(seconds: 2)); + + editText = (findRenderEditable(tester).text! as TextSpan).text!; + expect(editText.substring(editText.length - 1), '\u2022'); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'desktop obscureText control test', + (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const TextField( + obscureText: true, + decoration: InputDecoration(hintText: 'Placeholder'), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + + const testValue = 'ABC'; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue, + selection: TextSelection.collapsed(offset: testValue.length), + ), + ); + + await tester.pump(); + + // Enter a character into the obscured field and verify that the character + // isn't shown to the user. + const newChar = 'X'; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue + newChar, + selection: TextSelection.collapsed(offset: testValue.length + 1), + ), + ); + + await tester.pump(); + + final String editText = (findRenderEditable(tester).text! as TextSpan).text!; + expect(editText.substring(editText.length - 1), '\u2022'); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('Caret position is updated on tap', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + expect(controller.selection.baseOffset, -1); + expect(controller.selection.extentOffset, -1); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap to reposition the caret. + final int tapIndex = testValue.indexOf('e'); + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, tapIndex); + expect(controller.selection.extentOffset, tapIndex); + }); + + testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + overlay(child: TextField(controller: controller, enableInteractiveSelection: false)), + ); + expect(controller.selection.baseOffset, -1); + expect(controller.selection.extentOffset, -1); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap would ordinarily reposition the caret. + final int tapIndex = testValue.indexOf('e'); + final Offset ePos = textOffsetToPosition(tester, tapIndex); + await tester.tapAt(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, testValue.length); + expect(controller.selection.isCollapsed, isTrue); + }); + + testWidgets('Can long press to select', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + await tester.longPressAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + + // 'def' is selected. + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('f') + 1); + + // Tapping elsewhere immediately collapses and moves the cursor. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('h'))); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('h')); + }); + + testWidgets("Slight movements in longpress don't hide/show handles", (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + // Long press the 'e' to select 'def', but don't release the gesture. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + + // Handles are shown + final Finder fadeFinder = find.byType(FadeTransition); + expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar + FadeTransition handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + + // Move the gesture very slightly + await gesture.moveBy(const Offset(1.0, 1.0)); + await tester.pump(SelectionOverlay.fadeDuration * 0.5); + handle = tester.widget(fadeFinder.at(0)); + + // The handle should still be fully opaque. + expect(handle.opacity.value, equals(1.0)); + }); + + testWidgets( + 'Long pressing a field with selection 0,0 shows the selection menu', + (WidgetTester tester) async { + late final TextEditingController controller; + addTearDown(() => controller.dispose()); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller = TextEditingController.fromValue( + const TextEditingValue(selection: TextSelection(baseOffset: 0, extentOffset: 0)), + ), + ), + ), + ); + + expect(find.text('Paste'), findsNothing); + final Offset emptyPos = textOffsetToPosition(tester, 0); + await tester.longPressAt(emptyPos, pointer: 7); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('infinite multi-line text hint text is not ellipsized by default', ( + WidgetTester tester, + ) async { + const kLongString = + 'Enter your email Enter your email Enter your ' + 'email Enter your email Enter your email Enter ' + 'your email Enter your email'; + const double defaultLineHeight = 24; + await tester.pumpWidget( + overlay( + child: const TextField( + maxLines: null, + decoration: InputDecoration(labelText: 'Email', hintText: kLongString), + ), + ), + ); + final Text hintText = tester.widget<Text>(find.text(kLongString)); + expect(hintText.overflow, isNull); + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.text(kLongString)); + expect(paragraph.size.height > defaultLineHeight * 2, isTrue); + }); + + testWidgets('non-infinite multi-line hint text is ellipsized by default', ( + WidgetTester tester, + ) async { + const kLongString = + 'Enter your email Enter your email Enter your ' + 'email Enter your email Enter your email Enter ' + 'your email Enter your email'; + const double defaultLineHeight = 24; + await tester.pumpWidget( + overlay( + child: const TextField( + maxLines: 2, + decoration: InputDecoration(labelText: 'Email', hintText: kLongString), + ), + ), + ); + final Text hintText = tester.widget<Text>(find.text(kLongString)); + expect(hintText.overflow, TextOverflow.ellipsis); + final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.text(kLongString)); + expect(paragraph.size.height < defaultLineHeight * 2 + precisionErrorTolerance, isTrue); + }); + + testWidgets('Entering text hides selection handle caret', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abcdefghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + // Handle not shown. + expect(controller.selection.isCollapsed, true); + final Finder fadeFinder = find.byType(FadeTransition); + FadeTransition handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(0.0)); + + // Tap on the text field to show the handle. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(fadeFinder, findsNWidgets(1)); + handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(1.0)); + + // Enter more text. + const testValueAddition = 'jklmni'; + await tester.enterText(find.byType(TextField), testValueAddition); + expect(controller.value.text, testValueAddition); + await skipPastScrollingAnimation(tester); + + // Handle not shown. + expect(controller.selection.isCollapsed, true); + handle = tester.widget(fadeFinder.at(0)); + expect(handle.opacity.value, equals(0.0)); + }); + + testWidgets('multiple text fields with prefix and suffix have correct semantics order.', ( + WidgetTester tester, + ) async { + final TextEditingController controller1 = _textEditingController(text: 'abc'); + final TextEditingController controller2 = _textEditingController(text: 'def'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + TextField( + decoration: const InputDecoration(prefixText: 'prefix1', suffixText: 'suffix1'), + enabled: false, + controller: controller1, + ), + TextField( + decoration: const InputDecoration(prefixText: 'prefix2', suffixText: 'suffix2'), + enabled: false, + controller: controller2, + ), + ], + ), + ), + ), + ); + final List<String> orders = tester.semantics + .simulatedAccessibilityTraversal(startNode: find.semantics.byLabel('prefix1')) + .map((SemanticsNode node) => node.label + node.value) + .toList(); + expect(orders, <String>['prefix1', 'abc', 'suffix1', 'prefix2', 'def', 'suffix2']); + }); + + testWidgets('selection handles are excluded from the semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abcdefghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + // Tap on the text field to show the handle. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + // The semantics should only have the text field. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.paste, + SemanticsAction.setText, + SemanticsAction.moveCursorBackwardByWord, + ], + value: 'abcdefghi', + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 9), + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreId: true, + ignoreTransform: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + // Long press the 'e' using a mouse device. + final int eIndex = testValue.indexOf('e'); + final Offset ePos = textOffsetToPosition(tester, eIndex); + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The cursor is placed just like a regular tap. + expect(controller.selection.baseOffset, eIndex); + expect(controller.selection.extentOffset, eIndex); + }); + + testWidgets('Read only text field basic', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'readonly'); + + await tester.pumpWidget(overlay(child: TextField(controller: controller, readOnly: true))); + // Read only text field cannot open keyboard. + await tester.showKeyboard(find.byType(TextField)); + // On web, we always create a client connection to the engine. + expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + // On web, we always create a client connection to the engine. + expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse); + final EditableTextState editableText = tester.state(find.byType(EditableText)); + // Collapse selection should not paint. + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + // Long press on the 'd' character of text 'readOnly' to show context menu. + const dIndex = 3; + final Offset dPos = textOffsetToPosition(tester, dIndex); + await tester.longPressAt(dPos); + await tester.pumpAndSettle(); + + // Context menu should not have paste and cut. + expect(find.text('Copy'), isContextMenuProvidedByPlatform ? findsNothing : findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Cut'), findsNothing); + }); + + testWidgets( + 'does not paint toolbar when no options available', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: TextField(readOnly: true)))); + + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tap(find.byType(TextField)); + // Wait for context menu to be built. + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoTextSelectionToolbar), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'text field build empty toolbar when no options available', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Material(child: TextField(readOnly: true)))); + + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tap(find.byType(TextField)); + // Wait for context menu to be built. + await tester.pumpAndSettle(); + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + final SizedBox sizedBox = tester.widget( + find.descendant( + of: find.byType(AdaptiveTextSelectionToolbar), + matching: find.byType(SizedBox), + ), + ); + expect(sizedBox.width, 0.0); + expect(sizedBox.height, 0.0); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('Swapping controllers should update selection', (WidgetTester tester) async { + TextEditingController controller = _textEditingController(text: 'readonly'); + final entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material(child: TextField(controller: controller, readOnly: true)), + ); + }, + ); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget(overlayWithEntry(entry)); + const dIndex = 3; + final Offset dPos = textOffsetToPosition(tester, dIndex); + await tester.longPressAt(dPos); + await tester.pumpAndSettle(); + final EditableTextState state = tester.state(find.byType(EditableText)); + TextSelection currentOverlaySelection = state.selectionOverlay!.value.selection; + expect(currentOverlaySelection.baseOffset, 0); + expect(currentOverlaySelection.extentOffset, 8); + + // Update selection from [0 to 8] to [1 to 7]. + controller = TextEditingController.fromValue( + controller.value.copyWith(selection: const TextSelection(baseOffset: 1, extentOffset: 7)), + ); + addTearDown(controller.dispose); + + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + + await tester.pump(); + currentOverlaySelection = state.selectionOverlay!.value.selection; + expect(currentOverlaySelection.baseOffset, 1); + expect(currentOverlaySelection.extentOffset, 7); + }); + + testWidgets('Read only text should not compose', (WidgetTester tester) async { + final controller = TextEditingController.fromValue( + const TextEditingValue( + text: 'readonly', + composing: TextRange(start: 0, end: 8), // Simulate text composing. + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget(overlay(child: TextField(controller: controller, readOnly: true))); + + final RenderEditable renderEditable = findRenderEditable(tester); + // There should be no composing. + expect(renderEditable.text, TextSpan(text: 'readonly', style: renderEditable.text!.style)); + }); + + testWidgets( + 'Dynamically switching between read only and not read only should hide or show collapse cursor', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'readonly'); + var readOnly = true; + final entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField(controller: controller, readOnly: readOnly), + ), + ); + }, + ); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget(overlayWithEntry(entry)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + // Collapse selection should not paint. + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + + readOnly = false; + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + await tester.pumpAndSettle(); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + + readOnly = true; + entry.markNeedsBuild(); + await tester.pumpAndSettle(); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }, + ); + + testWidgets('Dynamically switching to read only should close input connection', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(text: 'readonly'); + var readOnly = false; + final entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField(controller: controller, readOnly: readOnly), + ), + ); + }, + ); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget(overlayWithEntry(entry)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, true); + + readOnly = true; + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + await tester.pump(); + // On web, we always have a client connection to the engine. + expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse); + }); + + testWidgets('Dynamically switching to non read only should open input connection', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(text: 'readonly'); + var readOnly = true; + final entry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Material( + child: TextField(controller: controller, readOnly: readOnly), + ), + ); + }, + ); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget(overlayWithEntry(entry)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + // On web, we always have a client connection to the engine. + expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse); + + readOnly = false; + // Mark entry to be dirty in order to trigger overlay update. + entry.markNeedsBuild(); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, true); + }); + + testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + overlay(child: TextField(controller: controller, enableInteractiveSelection: false)), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + await tester.longPressAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.length); + }); + + testWidgets('Selection updates on tap down (Desktop platforms)', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 5); + + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + await gesture.down(gPos); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // This should do nothing. The selection is set on tap down on desktop platforms. + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('Selection updates on tap up (Mobile platforms)', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, 5); + final Offset gPos = textOffsetToPosition(tester, 8); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + await gesture.down(gPos); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 5); + + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + final TestGesture touchGesture = await tester.startGesture(ePos); + await touchGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + // On iOS a tap to select, selects the word edge instead of the exact tap position. + expect(controller.selection.baseOffset, isTargetPlatformApple ? 7 : 5); + expect(controller.selection.extentOffset, isTargetPlatformApple ? 7 : 5); + + // Selection should stay the same since it is set on tap up for mobile platforms. + await touchGesture.down(gPos); + await tester.pump(); + expect(controller.selection.baseOffset, isTargetPlatformApple ? 7 : 5); + expect(controller.selection.extentOffset, isTargetPlatformApple ? 7 : 5); + + await touchGesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets( + 'Can select text with a mouse when wrapped in a GestureDetector with tap/double tap callbacks', + (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/129161. + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: GestureDetector( + onTap: () {}, + onDoubleTap: () {}, + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + // This is to allow the GestureArena to decide a winner between TapGestureRecognizer, + // DoubleTapGestureRecognizer, and BaseTapAndDragGestureRecognizer. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + await gesture.down(ePos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('g')); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('g')); + }); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|g'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Cursor should not move on a quick touch drag when touch does not begin on previous selection (iOS)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // The position we tap during a drag start is not on the collapsed selection, + // so the cursor should not move. + await gesture.down(textOffsetToPosition(tester, 7)); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS) - multiline', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + const testValue = 'abc\ndef\nghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and move to '|i'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS) - PageView', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/142624. + final TextEditingController controller = _textEditingController(); + final pageController = PageController(); + addTearDown(pageController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PageView( + controller: pageController, + children: <Widget>[ + Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + const SizedBox(height: 200.0, child: Center(child: Text('Page 2'))), + ], + ), + ), + ), + ); + + const testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and attempt move + // to '|g'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + + expect(pageController.page, isNotNull); + expect(pageController.page, 0.0); + // A horizontal drag directly on the TextField, but not on the current + // collapsed selection should move the page view to the next page. + final Rect textFieldRect = tester.getRect(find.byType(TextField)); + await tester.dragFrom( + textFieldRect.centerRight - const Offset(0.1, 0.0), + const Offset(-500.0, 0.0), + ); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + expect(pageController.page, isNotNull); + expect(pageController.page, 1.0); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS) - TextField in Dismissible', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/124421. + final TextEditingController controller = _textEditingController(); + var dismissed = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + Dismissible( + key: UniqueKey(), + onDismissed: (DismissDirection? direction) { + dismissed = true; + }, + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ], + ), + ), + ), + ); + + const testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and attempt move + // to '|g'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + + expect(dismissed, false); + // A horizontal drag directly on the TextField, but not on the current + // collapsed selection should allow for the Dismissible to be dismissed. + await tester.dragFrom( + tester.getRect(find.byType(TextField)).centerRight - const Offset(0.1, 0.0), + const Offset(-400.0, 0.0), + ); + await tester.pumpAndSettle(); + expect(dismissed, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging, when tap is on collapsed selection (iOS) - ListView', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/122519 + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ], + ), + ), + ), + ); + + const testValue = 'abc\ndef\nghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and attempt move + // to '|g'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Can move cursor when dragging (Android)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + // Tap on text field to gain focus, and set selection to '|e'. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|d', and move to '|g'. + await gesture.down(textOffsetToPosition(tester, testValue.indexOf('d'))); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'Can move cursor when dragging (Android) - multiline', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + const testValue = 'abc\ndef\nghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + // Tap on text field to gain focus, and set selection to '|a'. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('a')); + + // Here we tap on '|c', and move down to '|g'. + await gesture.down(textOffsetToPosition(tester, testValue.indexOf('c'))); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'Can move cursor when dragging (Android) - ListView', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/122519 + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: <Widget>[ + TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ], + ), + ), + ), + ); + + const testValue = 'abc\ndef\nghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset cPos = textOffsetToPosition(tester, testValue.indexOf('c')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + // Tap on text field to gain focus, and set selection to '|c'. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(cPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('c')); + + // Here we tap on '|a', and attempt move to '|g'. The cursor will not move + // because the `VerticalDragGestureRecognizer` in the scrollable will beat + // the `TapAndHorizontalDragGestureRecognizer` in the TextField. This is + // because moving from `|a` to `|g` is a completely vertical movement. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('c')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // Here we tap on '|c', and move to '|g'. Unlike our previous attempt to + // drag to `|g`, this works because moving from `|c` to `|g` includes a + // horizontal movement so the `TapAndHorizontalDragGestureRecognizer` + // in TextField can beat the `VerticalDragGestureRecognizer` in the scrollable. + await gesture.down(cPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { + var selectionChangedCount = 0; + const testValue = 'abc def ghi'; + final TextEditingController controller = _textEditingController(text: testValue); + + controller.addListener(() { + selectionChangedCount++; + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'. + final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'. + final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'. + + // Drag from 'c' to 'g'. + final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, isNonZero); + selectionChangedCount = 0; + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 8); + + // Tiny movement shouldn't cause text selection to change. + await gesture.moveTo(gPos + const Offset(2.0, 0.0)); + await tester.pumpAndSettle(); + expect(selectionChangedCount, 0); + + // Now a text selection change will occur after a significant movement. + await gesture.moveTo(hPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(selectionChangedCount, 1); + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 9); + }); + + testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('g')); + expect(controller.selection.extentOffset, testValue.indexOf('e')); + }); + + testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('g')); + }); + + testWidgets( + 'Can drag handles to change selection on Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Double tap the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + // The first tap. + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // The second tap. + await gesture.down(ePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, testValue.length); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Drag the left handle 2 letters to the left. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 2); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + // On Apple platforms, dragging the base handle makes it the extent. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection.baseOffset, 11); + expect(controller.selection.extentOffset, 2); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 11); + } + + // Drag the left handle 2 letters to the left again. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // The left handle was already the extent, and it remains so. + expect(controller.selection.baseOffset, 11); + expect(controller.selection.extentOffset, 0); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Can drag handles to change selection on non-Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, testValue.length); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Drag the left handle 2 letters to the left. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 2); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + // On Apple platforms, dragging the base handle makes it the extent. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection.baseOffset, 11); + expect(controller.selection.extentOffset, 2); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 11); + } + + // Drag the left handle 2 letters to the left again. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // The left handle was already the extent, and it remains so. + expect(controller.selection.baseOffset, 11); + expect(controller.selection.extentOffset, 0); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + } + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'assertion error is not thrown when attempting to drag both selection handles', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/168578. + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + overlay( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to 'g'. + final Offset endHandlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, 9); // Position of 'g'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 9); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point + const Offset(1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await tester.pump(); + + // Drag the end handle to the end of the text after releasing the start handle. + newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await endHandleGesture.up(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Can only drag one selection handle at a time on iOS', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + overlay( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 7); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to the end of the text. + final Offset endHandlePos = endpoints[1].point; + Offset newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point; + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await endHandleGesture.up(); + await tester.pump(); + + // The start handle does not cause the selection to change. + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'Can only drag one selection handle at a time on Android web', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + overlay( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to the end of the text. + final Offset endHandlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point + const Offset(1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await endHandleGesture.up(); + await tester.pump(); + + // Moving the start handle does not change the selection. + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + }, + skip: !kIsWeb, // [intended] on web only one selection handle can be dragged at a time. + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Can drag both selection handles at a time on Android', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + overlay( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(fontSize: 10.0), + ), + ), + ), + ); + + // Double tap on 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 5); + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the end handle to the end of the text. + final Offset endHandlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, 11); // Position of 'i'. + final TestGesture endHandleGesture = await tester.startGesture(endHandlePos, pointer: 7); + await tester.pump(); + await endHandleGesture.moveTo(newHandlePos); + await tester.pump(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Attempt to drag the start handle to the start of the text. + final Offset startHandlePos = endpoints[0].point + const Offset(1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + final TestGesture startHandleGesture = await tester.startGesture(startHandlePos, pointer: 8); + await tester.pump(); + await startHandleGesture.moveTo(newHandlePos); + await tester.pump(); + await startHandleGesture.up(); + await endHandleGesture.up(); + await tester.pump(); + + // Moving the start handle changes the selection. + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + }, + skip: kIsWeb, // [intended] on web only one selection handle can be dragged at a time. + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets('Can drag the left handle while the right handle remains off-screen', ( + WidgetTester tester, + ) async { + // Text is longer than textfield width. + const testValue = 'aaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final TextEditingController controller = _textEditingController(text: testValue); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + scrollController: scrollController, + ), + ), + ), + ), + ); + + // Double tap 'b' to show handles. + final Offset bPos = textOffsetToPosition(tester, testValue.indexOf('b')); + await tester.tapAt(bPos); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(bPos); + await tester.pumpAndSettle(); + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 28); + expect(selection.extentOffset, testValue.length); + + // Move to the left edge. + scrollController.jumpTo(0); + await tester.pumpAndSettle(); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Left handle should appear between textfield's left and right position. + final Offset textFieldLeftPosition = tester.getTopLeft(find.byType(TextField)); + expect(endpoints[0].point.dx - textFieldLeftPosition.dx, isPositive); + final Offset textFieldRightPosition = tester.getTopRight(find.byType(TextField)); + expect(textFieldRightPosition.dx - endpoints[0].point.dx, isPositive); + // Right handle should remain off-screen. + expect(endpoints[1].point.dx - textFieldRightPosition.dx, isPositive); + + // Drag the left handle to the right by 25 offset. + const toOffset = 25; + final double beforeScrollOffset = scrollController.offset; + final Offset handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, toOffset); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // On Apple platforms, dragging the base handle makes it the extent. + expect(controller.selection.baseOffset, testValue.length); + expect(controller.selection.extentOffset, toOffset); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, toOffset); + expect(controller.selection.extentOffset, testValue.length); + } + + // The scroll area of text field should not move. + expect(scrollController.offset, beforeScrollOffset); + }); + + testWidgets('Can drag the right handle while the left handle remains off-screen', ( + WidgetTester tester, + ) async { + // Text is longer than textfield width. + const testValue = 'aaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final TextEditingController controller = _textEditingController(text: testValue); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + scrollController: scrollController, + ), + ), + ), + ), + ); + + // Double tap 'a' to show handles. + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + await tester.tapAt(aPos); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(aPos); + await tester.pumpAndSettle(); + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 0); + expect(selection.extentOffset, 27); + + // Move to the right edge. + scrollController.jumpTo(800); + await tester.pumpAndSettle(); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Right handle should appear between textfield's left and right position. + final Offset textFieldLeftPosition = tester.getTopLeft(find.byType(TextField)); + expect(endpoints[1].point.dx - textFieldLeftPosition.dx, isPositive); + final Offset textFieldRightPosition = tester.getTopRight(find.byType(TextField)); + expect(textFieldRightPosition.dx - endpoints[1].point.dx, isPositive); + // Left handle should remain off-screen. + expect(endpoints[0].point.dx, isNegative); + + // Drag the right handle to the left by 50 offset. + const toOffset = 50; + final double beforeScrollOffset = scrollController.offset; + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, toOffset); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, toOffset); + + // The scroll area of text field should not move. + expect(scrollController.offset, beforeScrollOffset); + }); + + testWidgets('Drag handles trigger feedback', (WidgetTester tester) async { + final feedback = FeedbackTester(); + addTearDown(feedback.dispose); + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + overlay( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(feedback.hapticCount, 0); + await skipPastScrollingAnimation(tester); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + expect(feedback.hapticCount, 1); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // Use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, testValue.length); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + expect(feedback.hapticCount, 2); + }); + + testWidgets('Dragging a collapsed handle should trigger feedback.', (WidgetTester tester) async { + final feedback = FeedbackTester(); + addTearDown(feedback.dispose); + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + overlay( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(feedback.hapticCount, 0); + await skipPastScrollingAnimation(tester); + + // Tap the 'e' to bring up a collapsed handle. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 5); + expect(selection.extentOffset, 5); + expect(feedback.hapticCount, 0); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 1); + + // Drag the right handle 3 letters to the right. + // Use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[0].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, testValue.indexOf('g')); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + expect(feedback.hapticCount, 1); + }); + + testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, 5); // Position before 'e'. + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle until there's only 1 char selected. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point + const Offset(4.0, 0.0); + Offset newHandlePos = textOffsetToPosition(tester, 5); // Position before 'e'. + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 5); + + newHandlePos = textOffsetToPosition(tester, 2); // Position before 'c'. + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + // The selection doesn't move beyond the left handle. There's always at + // least 1 char selected. + expect(controller.selection.extentOffset, 5); + }); + + testWidgets( + 'Dragging between multiple lines keeps the contact point at the same place on the handle on Android', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + // 11 first line, 19 second line, 17 third line = length 49 + text: 'a big house\njumped over a mouse\nOne more line yay', + ); + + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: 3, + minLines: 3, + ), + ), + ); + + // Double tap to select 'over'. + final Offset pos = textOffsetToPosition(tester, controller.text.indexOf('v')); + // The first tap. + TestGesture gesture = await tester.startGesture(pos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // The second tap. + await gesture.down(pos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final TextSelection selection = controller.selection; + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 23)); + + final RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 4 letters to the right. + // The adjustment moves the tap from the text position to the handle. + const endHandleAdjustment = Offset(1.0, 6.0); + Offset handlePos = endpoints[1].point + endHandleAdjustment; + Offset newHandlePos = textOffsetToPosition(tester, 27) + endHandleAdjustment; + await tester.pump(); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the right handle 1 line down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + final toNextLine = Offset(0.0, findRenderEditable(tester).preferredLineHeight + 3.0); + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 47)); + + // Drag the right handle back up 1 line. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + newHandlePos = handlePos - toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the left handle 4 letters to the left. + // The adjustment moves the tap from the text position to the handle. + const startHandleAdjustment = Offset(-1.0, 6.0); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = textOffsetToPosition(tester, 15) + startHandleAdjustment; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 15, extentOffset: 27)); + + // Drag the left handle 1 line up. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = handlePos - toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 3, extentOffset: 27)); + + // Drag the left handle 1 line back down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 15, extentOffset: 27)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'Dragging between multiple lines keeps the contact point at the same place on the handle on iOS', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + // 11 first line, 19 second line, 17 third line = length 49 + text: 'a big house\njumped over a mouse\nOne more line yay', + ); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: 3, + minLines: 3, + ), + ), + ), + ); + + // Double tap to select 'over'. + final Offset pos = textOffsetToPosition(tester, controller.text.indexOf('v')); + // The first tap. + TestGesture gesture = await tester.startGesture(pos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // The second tap. + await gesture.down(pos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final TextSelection selection = controller.selection; + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 23)); + + final RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 4 letters to the right. + // The adjustment moves the tap from the text position to the handle. + const endHandleAdjustment = Offset(1.0, 6.0); + Offset handlePos = endpoints[1].point + endHandleAdjustment; + Offset newHandlePos = textOffsetToPosition(tester, 27) + endHandleAdjustment; + await tester.pump(); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the right handle 1 line down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final toNextLine = Offset(0.0, lineHeight + 3.0); + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 47)); + + // Drag the right handle back up 1 line. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[1].point + endHandleAdjustment; + newHandlePos = handlePos - toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 27)); + + // Drag the left handle 4 letters to the left. + // The adjustment moves the tap from the text position to the handle. + final startHandleAdjustment = Offset(-1.0, -lineHeight + 6.0); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = textOffsetToPosition(tester, 15) + startHandleAdjustment; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + // On Apple platforms, dragging the base handle makes it the extent. + expect(controller.selection, const TextSelection(baseOffset: 27, extentOffset: 15)); + + // Drag the left handle 1 line up. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + // Move handle a sufficient global distance so it can be considered a drag + // by the selection handle's [PanGestureRecognizer]. + newHandlePos = handlePos - (toNextLine * 2); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 27, extentOffset: 3)); + + // Drag the left handle 1 line back down. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + startHandleAdjustment; + newHandlePos = handlePos + toNextLine; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + // Move handle up a small amount before dragging it down so the total global + // distance travelled can be accepted by the selection handle's [PanGestureRecognizer] as a drag. + // This way it can declare itself the winner before the [TapAndDragGestureRecognizer] that + // is on the selection overlay. + await gesture.moveTo(handlePos - toNextLine); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 27, extentOffset: 15)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + "dragging caret within a word doesn't affect composing region", + (WidgetTester tester) async { + const testValue = 'abc def ghi'; + final controller = TextEditingController.fromValue( + const TextEditingValue( + text: testValue, + selection: TextSelection(baseOffset: 4, extentOffset: 4, affinity: TextAffinity.upstream), + composing: TextRange(start: 4, end: 7), + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + overlay( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ); + + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 4); + expect(controller.value.composing.start, 4); + expect(controller.value.composing.end, 7); + + // Tap the caret to show the handle. + final Offset ePos = textOffsetToPosition(tester, 4); + await tester.tapAt(ePos); + await tester.pumpAndSettle(); + + final TextSelection selection = controller.selection; + expect(controller.selection.isCollapsed, true); + expect(selection.baseOffset, 4); + expect(controller.value.composing.start, 4); + expect(controller.value.composing.end, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 1); + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[0].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, 7); + final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + expect(controller.value.composing.start, 4); + expect(controller.value.composing.end, 7); + }, + skip: kIsWeb, // [intended] text selection is handled by the browser + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Can use selection toolbar', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + // Tapping on the part of the handle's GestureDetector where it overlaps + // with the text itself does not show the menu, so add a small vertical + // offset to tap below the text. + await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + // Select all should select all the text. + await tester.tap(find.text('Select all')); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, testValue.length); + + // Copy should reset the selection. + await tester.tap(find.text('Copy')); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + + // Tap again to bring back the menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + // Allow time for handle to appear and double tap to time out. + await tester.pump(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('e')); + renderEditable = findRenderEditable(tester); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('e')); + + // Paste right before the 'e'. + await tester.tap(find.text('Paste')); + await tester.pump(); + expect(controller.text, 'abc d${testValue}ef ghi'); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + // Show the selection menu at the given index into the text by tapping to + // place the cursor and then tapping on the handle. + Future<void> showSelectionMenuAt( + WidgetTester tester, + TextEditingController controller, + int index, + ) async { + await tester.tapAt(tester.getCenter(find.byType(EditableText))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + expect(find.text('Select all'), findsNothing); + + // Tap the selection handle to bring up the "paste / select all" menu for + // the last line of text. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + // Tapping on the part of the handle's GestureDetector where it overlaps + // with the text itself does not show the menu, so add a small vertical + // offset to tap below the text. + await tester.tapAt(endpoints[0].point + const Offset(1.0, 13.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + } + + testWidgets( + 'Check the toolbar appears below the TextField when there is not enough space above the TextField to show it', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/29808 + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(30.0), + child: TextField(controller: controller), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + await showSelectionMenuAt(tester, controller, testValue.indexOf('e')); + + // Verify the selection toolbar position is below the text. + Offset toolbarTopLeft = tester.getTopLeft(find.text('Select all')); + Offset textFieldTopLeft = tester.getTopLeft(find.byType(TextField)); + expect(textFieldTopLeft.dy, lessThan(toolbarTopLeft.dy)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(150.0), + child: TextField(controller: controller), + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + await showSelectionMenuAt(tester, controller, testValue.indexOf('e')); + + // Verify the selection toolbar position + toolbarTopLeft = tester.getTopLeft(find.text('Select all')); + textFieldTopLeft = tester.getTopLeft(find.byType(TextField)); + expect(toolbarTopLeft.dy, lessThan(textFieldTopLeft.dy)); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'the toolbar adjusts its position above/below when bottom inset changes', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 48.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + IntrinsicHeight( + child: TextField(controller: controller, expands: true, maxLines: null), + ), + const SizedBox(height: 325.0), + ], + ), + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + await showSelectionMenuAt(tester, controller, testValue.indexOf('e')); + + // Verify the selection toolbar position is above the text. + expect(find.text('Select all'), findsOneWidget); + Offset toolbarTopLeft = tester.getTopLeft(find.text('Select all')); + Offset textFieldTopLeft = tester.getTopLeft(find.byType(TextField)); + expect(toolbarTopLeft.dy, lessThan(textFieldTopLeft.dy)); + + // Add a viewInset tall enough to push the field to the top, where there + // is no room to display the toolbar above. This is similar to when the + // keyboard is shown. + tester.view.viewInsets = const FakeViewPadding(bottom: 500.0); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + // Verify the selection toolbar position is below the text. + toolbarTopLeft = tester.getTopLeft(find.text('Select all')); + textFieldTopLeft = tester.getTopLeft(find.byType(TextField)); + expect(toolbarTopLeft.dy, greaterThan(textFieldTopLeft.dy)); + + // Remove the viewInset, as if the keyboard were hidden. + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + + // Verify the selection toolbar position is below the text. + toolbarTopLeft = tester.getTopLeft(find.text('Select all')); + textFieldTopLeft = tester.getTopLeft(find.byType(TextField)); + expect(toolbarTopLeft.dy, lessThan(textFieldTopLeft.dy)); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Toolbar appears in the right places in multiline inputs', + (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/36749 + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(30.0), + child: TextField(controller: controller, minLines: 6, maxLines: 6), + ), + ), + ), + ); + + expect(find.text('Select all'), findsNothing); + const testValue = 'abc\ndef\nghi\njkl\nmno\npqr'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Show the selection menu on the first line and verify the selection + // toolbar position is below the first line. + await showSelectionMenuAt(tester, controller, testValue.indexOf('c')); + expect(find.text('Select all'), findsOneWidget); + final Offset firstLineToolbarTopLeft = tester.getTopLeft(find.text('Select all')); + final Offset firstLineTopLeft = textOffsetToPosition(tester, testValue.indexOf('a')); + expect(firstLineTopLeft.dy, lessThan(firstLineToolbarTopLeft.dy)); + + // Show the selection menu on the second to last line and verify the + // selection toolbar position is above that line and above the first + // line's toolbar. + await showSelectionMenuAt(tester, controller, testValue.indexOf('o')); + expect(find.text('Select all'), findsOneWidget); + final Offset penultimateLineToolbarTopLeft = tester.getTopLeft(find.text('Select all')); + final Offset penultimateLineTopLeft = textOffsetToPosition(tester, testValue.indexOf('p')); + expect(penultimateLineToolbarTopLeft.dy, lessThan(penultimateLineTopLeft.dy)); + expect(penultimateLineToolbarTopLeft.dy, lessThan(firstLineToolbarTopLeft.dy)); + + // Show the selection menu on the last line and verify the selection + // toolbar position is above that line and below the position of the + // second to last line's toolbar. + await showSelectionMenuAt(tester, controller, testValue.indexOf('r')); + expect(find.text('Select all'), findsOneWidget); + final Offset lastLineToolbarTopLeft = tester.getTopLeft(find.text('Select all')); + final Offset lastLineTopLeft = textOffsetToPosition(tester, testValue.indexOf('p')); + expect(lastLineToolbarTopLeft.dy, lessThan(lastLineTopLeft.dy)); + expect(lastLineToolbarTopLeft.dy, greaterThan(penultimateLineToolbarTopLeft.dy)); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Selection toolbar fades in', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller))); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(); + // Allow time for the handle to appear and for a double tap to time out. + await tester.pump(const Duration(milliseconds: 600)); + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + // Pump an extra frame to allow the selection menu to read the clipboard. + await tester.pump(); + await tester.pump(); + + // Toolbar should fade in. Starting at 0% opacity. + expect(find.text('Select all'), findsOneWidget); + final Element target = tester.element(find.text('Select all')); + final FadeTransition opacity = target.findAncestorWidgetOfExactType<FadeTransition>()!; + expect(opacity.opacity.value, equals(0.0)); + + // Still fading in. + await tester.pump(const Duration(milliseconds: 50)); + final FadeTransition opacity2 = target.findAncestorWidgetOfExactType<FadeTransition>()!; + expect(opacity, same(opacity2)); + expect(opacity.opacity.value, greaterThan(0.0)); + expect(opacity.opacity.value, lessThan(1.0)); + + // End the test here to ensure the animation is properly disposed of. + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('An obscured TextField is selectable by default', (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/32845 + + final TextEditingController controller = _textEditingController(); + Widget buildFrame(bool obscureText) { + return overlay( + child: TextField(controller: controller, obscureText: obscureText), + ); + } + + // Obscure text and don't enable or disable selection. + await tester.pumpWidget(buildFrame(true)); + await tester.enterText(find.byType(TextField), 'abcdefghi'); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + + // Long press does select text. + final Offset ePos = textOffsetToPosition(tester, 1); + await tester.longPressAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + }); + + testWidgets('An obscured TextField is not selectable when disabled', (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/32845 + + final TextEditingController controller = _textEditingController(); + Widget buildFrame(bool obscureText, bool enableInteractiveSelection) { + return overlay( + child: TextField( + controller: controller, + obscureText: obscureText, + enableInteractiveSelection: enableInteractiveSelection, + ), + ); + } + + // Explicitly disabled selection on obscured text. + await tester.pumpWidget(buildFrame(true, false)); + await tester.enterText(find.byType(TextField), 'abcdefghi'); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + + // Long press doesn't select text. + final Offset ePos2 = textOffsetToPosition(tester, 1); + await tester.longPressAt(ePos2, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + }); + + testWidgets('An obscured TextField is not selectable when read-only', ( + WidgetTester tester, + ) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/32845 + + final TextEditingController controller = _textEditingController(); + Widget buildFrame(bool obscureText, bool readOnly) { + return overlay( + child: TextField(controller: controller, obscureText: obscureText, readOnly: readOnly), + ); + } + + // Explicitly disabled selection on obscured text that is read-only. + await tester.pumpWidget(buildFrame(true, true)); + await tester.enterText(find.byType(TextField), 'abcdefghi'); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + + // Long press doesn't select text. + final Offset ePos2 = textOffsetToPosition(tester, 1); + await tester.longPressAt(ePos2, pointer: 7); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + }); + + testWidgets('An obscured TextField is selected as one word', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller, obscureText: true))); + await tester.enterText(find.byType(TextField), 'abcde fghi'); + await skipPastScrollingAnimation(tester); + + // Long press does select text. + final Offset bPos = textOffsetToPosition(tester, 1); + await tester.longPressAt(bPos, pointer: 7); + await tester.pumpAndSettle(); + final TextSelection selection = controller.selection; + expect(selection.isCollapsed, false); + expect(selection.baseOffset, 0); + expect(selection.extentOffset, 10); + }); + + testWidgets( + 'An obscured TextField has correct default context menu', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller, obscureText: true))); + await tester.enterText(find.byType(TextField), 'abcde fghi'); + await skipPastScrollingAnimation(tester); + + // Long press to select text. + final Offset bPos = textOffsetToPosition(tester, 1); + await tester.longPressAt(bPos, pointer: 7); + await tester.pumpAndSettle(); + + // Should only have paste option when whole obscure text is selected. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select all'), findsNothing); + + // Long press at the end + final Offset iPos = textOffsetToPosition(tester, 10); + final Offset slightRight = iPos + const Offset(30.0, 0.0); + await tester.longPressAt(slightRight, pointer: 7); + await tester.pumpAndSettle(); + + // Should have paste and select all options when collapse. + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'create selection overlay if none exists when toggleToolbar is called', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/111660 + final Widget testWidget = MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Test'), + actions: <Widget>[ + PopupMenuButton<String>( + itemBuilder: (BuildContext context) { + return <String>{'About'}.map((String value) { + return PopupMenuItem<String>(value: value, child: Text(value)); + }).toList(); + }, + ), + ], + ), + body: const TextField(), + ), + ); + + await tester.pumpWidget(testWidget); + + // Tap on TextField. + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture(textFieldStart); + await tester.pump(const Duration(milliseconds: 300)); + await gesture.up(); + await tester.pumpAndSettle(); + + // Tap on 3 dot menu. + await tester.tap(find.byType(PopupMenuButton<String>)); + await tester.pumpAndSettle(); + + // Tap on TextField. + await gesture.down(textFieldStart); + await tester.pump(const Duration(milliseconds: 300)); + await gesture.up(); + await tester.pumpAndSettle(); + + // Tap on TextField again. + await tester.tapAt(textFieldStart); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('TextField height with minLines unset', (WidgetTester tester) async { + await tester.pumpWidget(textFieldBuilder()); + + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); + + final RenderBox inputBox = findInputBox(); + final Size emptyInputSize = inputBox.size; + + await tester.enterText(find.byType(TextField), 'No wrapping here.'); + await tester.pumpWidget(textFieldBuilder()); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + + // Even when entering multiline text, TextField doesn't grow. It's a single + // line input. + await tester.enterText(find.byType(TextField), kThreeLines); + await tester.pumpWidget(textFieldBuilder()); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + + // maxLines: 3 makes the TextField 3 lines tall + await tester.enterText(find.byType(TextField), ''); + await tester.pumpWidget(textFieldBuilder(maxLines: 3)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size.height, greaterThan(emptyInputSize.height)); + expect(inputBox.size.width, emptyInputSize.width); + + final Size threeLineInputSize = inputBox.size; + + // Filling with 3 lines of text stays the same size + await tester.enterText(find.byType(TextField), kThreeLines); + await tester.pumpWidget(textFieldBuilder(maxLines: 3)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, threeLineInputSize); + + // An extra line won't increase the size because we max at 3. + await tester.enterText(find.byType(TextField), kMoreThanFourLines); + await tester.pumpWidget(textFieldBuilder(maxLines: 3)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, threeLineInputSize); + + // But now it will... but it will max at four + await tester.enterText(find.byType(TextField), kMoreThanFourLines); + await tester.pumpWidget(textFieldBuilder(maxLines: 4)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size.height, greaterThan(threeLineInputSize.height)); + expect(inputBox.size.width, threeLineInputSize.width); + + final Size fourLineInputSize = inputBox.size; + + // Now it won't max out until the end + await tester.enterText(find.byType(TextField), ''); + await tester.pumpWidget(textFieldBuilder(maxLines: null)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + await tester.enterText(find.byType(TextField), kThreeLines); + await tester.pump(); + expect(inputBox.size, equals(threeLineInputSize)); + await tester.enterText(find.byType(TextField), kMoreThanFourLines); + await tester.pump(); + expect(inputBox.size.height, greaterThan(fourLineInputSize.height)); + expect(inputBox.size.width, fourLineInputSize.width); + }); + + testWidgets('TextField height with minLines and maxLines', (WidgetTester tester) async { + await tester.pumpWidget(textFieldBuilder()); + + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); + + final RenderBox inputBox = findInputBox(); + final Size emptyInputSize = inputBox.size; + + await tester.enterText(find.byType(TextField), 'No wrapping here.'); + await tester.pumpWidget(textFieldBuilder()); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, equals(emptyInputSize)); + + // min and max set to same value locks height to value. + await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: 3)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size.height, greaterThan(emptyInputSize.height)); + expect(inputBox.size.width, emptyInputSize.width); + + final Size threeLineInputSize = inputBox.size; + + // maxLines: null with minLines set grows beyond minLines + await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: null)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, threeLineInputSize); + await tester.enterText(find.byType(TextField), kMoreThanFourLines); + await tester.pump(); + expect(inputBox.size.height, greaterThan(threeLineInputSize.height)); + expect(inputBox.size.width, threeLineInputSize.width); + + // With minLines and maxLines set, input will expand through the range + await tester.enterText(find.byType(TextField), ''); + await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: 4)); + expect(findInputBox(), equals(inputBox)); + expect(inputBox.size, equals(threeLineInputSize)); + await tester.enterText(find.byType(TextField), kMoreThanFourLines); + await tester.pump(); + expect(inputBox.size.height, greaterThan(threeLineInputSize.height)); + expect(inputBox.size.width, threeLineInputSize.width); + + // minLines can't be greater than maxLines. + expect(() async { + await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: 2)); + }, throwsAssertionError); + + // maxLines defaults to 1 and can't be less than minLines + expect(() async { + await tester.pumpWidget(textFieldBuilder(minLines: 3)); + }, throwsAssertionError); + }); + + testWidgets('Multiline text when wrapped in Expanded', (WidgetTester tester) async { + Widget expandedTextFieldBuilder({int? maxLines = 1, int? minLines, bool expands = false}) { + return boilerplate( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + Expanded( + child: TextField( + key: textFieldKey, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: maxLines, + minLines: minLines, + expands: expands, + decoration: const InputDecoration(hintText: 'Placeholder'), + ), + ), + ], + ), + ); + } + + await tester.pumpWidget(expandedTextFieldBuilder()); + + RenderBox findBorder() { + return tester.renderObject( + find.descendant( + of: find.byType(InputDecorator), + matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + ), + ); + } + + final RenderBox border = findBorder(); + + // Without expanded: true and maxLines: null, the TextField does not expand + // to fill its parent when wrapped in an Expanded widget. + final Size unexpandedInputSize = border.size; + + // It does expand to fill its parent when expands: true, maxLines: null, and + // it's wrapped in an Expanded widget. + await tester.pumpWidget(expandedTextFieldBuilder(expands: true, maxLines: null)); + expect(border.size.height, greaterThan(unexpandedInputSize.height)); + expect(border.size.width, unexpandedInputSize.width); + + // min/maxLines that is not null and expands: true contradict each other. + expect(() async { + await tester.pumpWidget(expandedTextFieldBuilder(expands: true, maxLines: 4)); + }, throwsAssertionError); + expect(() async { + await tester.pumpWidget(expandedTextFieldBuilder(expands: true, minLines: 1, maxLines: null)); + }, throwsAssertionError); + }); + + // Regression test for https://github.com/flutter/flutter/pull/29093 + testWidgets('Multiline text when wrapped in IntrinsicHeight', (WidgetTester tester) async { + final Key intrinsicHeightKey = UniqueKey(); + Widget intrinsicTextFieldBuilder(bool wrapInIntrinsic) { + final textField = TextFormField( + key: textFieldKey, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: null, + decoration: const InputDecoration(counterText: 'I am counter'), + ); + final Widget widget = wrapInIntrinsic + ? IntrinsicHeight(key: intrinsicHeightKey, child: textField) + : textField; + return boilerplate( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[widget]), + ); + } + + await tester.pumpWidget(intrinsicTextFieldBuilder(false)); + expect(find.byKey(intrinsicHeightKey), findsNothing); + + RenderBox findEditableText() => tester.renderObject(find.byType(EditableText)); + RenderBox editableText = findEditableText(); + final Size unwrappedEditableTextSize = editableText.size; + + // Wrapping in IntrinsicHeight should not affect the height of the input + await tester.pumpWidget(intrinsicTextFieldBuilder(true)); + editableText = findEditableText(); + expect(editableText.size.height, unwrappedEditableTextSize.height); + expect(editableText.size.width, unwrappedEditableTextSize.width); + }); + + // Regression test for https://github.com/flutter/flutter/pull/29093 + testWidgets('errorText empty string', (WidgetTester tester) async { + Widget textFormFieldBuilder(String? errorText) { + return boilerplate( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + TextFormField( + key: textFieldKey, + maxLength: 3, + maxLengthEnforcement: MaxLengthEnforcement.none, + decoration: InputDecoration(counterText: '', errorText: errorText), + ), + ], + ), + ); + } + + await tester.pumpWidget(textFormFieldBuilder(null)); + + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); + final RenderBox inputBox = findInputBox(); + final Size errorNullInputSize = inputBox.size; + + // Setting errorText causes the input's height to increase to accommodate it + await tester.pumpWidget(textFormFieldBuilder('im errorText')); + expect(inputBox, findInputBox()); + expect(inputBox.size.height, greaterThan(errorNullInputSize.height)); + expect(inputBox.size.width, errorNullInputSize.width); + final Size errorInputSize = inputBox.size; + + // Setting errorText to an empty string causes the input's height to + // increase to accommodate it, even though it's not displayed. + // This may or may not be ideal behavior, but it is legacy behavior and + // there are visual tests that rely on it (see Github issue referenced at + // the top of this test). A counterText of empty string does not affect + // input height, however. + await tester.pumpWidget(textFormFieldBuilder('')); + expect(inputBox, findInputBox()); + expect(inputBox.size.height, errorInputSize.height); + expect(inputBox.size.width, errorNullInputSize.width); + }); + + testWidgets('Growable TextField when content height exceeds parent', (WidgetTester tester) async { + const height = 200.0; + const padding = 24.0; + + Widget containedTextFieldBuilder({ + Widget? counter, + String? helperText, + String? labelText, + Widget? prefix, + }) { + return boilerplate( + theme: ThemeData(useMaterial3: false), + child: SizedBox( + height: height, + child: TextField( + key: textFieldKey, + maxLines: null, + decoration: InputDecoration( + counter: counter, + helperText: helperText, + labelText: labelText, + prefix: prefix, + ), + ), + ), + ); + } + + await tester.pumpWidget(containedTextFieldBuilder()); + RenderBox findEditableText() => tester.renderObject(find.byType(EditableText)); + + final RenderBox inputBox = findEditableText(); + + // With no decoration and when overflowing with content, the EditableText + // takes up the full height minus the padding, so the input fits perfectly + // inside the parent. + await tester.enterText(find.byType(TextField), 'a\n' * 11); + await tester.pump(); + expect(findEditableText(), equals(inputBox)); + expect(inputBox.size.height, height - padding); + + // Adding a counter causes the EditableText to shrink to fit the counter + // inside the parent as well. + const counterHeight = 40.0; + const subtextGap = 8.0; + const double counterSpace = counterHeight + subtextGap; + await tester.pumpWidget(containedTextFieldBuilder(counter: Container(height: counterHeight))); + expect(findEditableText(), equals(inputBox)); + expect(inputBox.size.height, height - padding - counterSpace); + + // Including helperText causes the EditableText to shrink to fit the text + // inside the parent as well. + await tester.pumpWidget(containedTextFieldBuilder(helperText: 'I am helperText')); + expect(findEditableText(), equals(inputBox)); + const helperTextSpace = 12.0; + expect(inputBox.size.height, height - padding - helperTextSpace - subtextGap); + + // When both helperText and counter are present, EditableText shrinks by the + // height of the taller of the two in order to fit both within the parent. + await tester.pumpWidget( + containedTextFieldBuilder( + counter: Container(height: counterHeight), + helperText: 'I am helperText', + ), + ); + expect(findEditableText(), equals(inputBox)); + expect(inputBox.size.height, height - padding - counterSpace); + + // When a label is present, EditableText shrinks to fit it at the top so + // that the bottom of the input still lines up perfectly with the parent. + await tester.pumpWidget(containedTextFieldBuilder(labelText: 'I am labelText')); + const labelSpace = 16.0; + expect(findEditableText(), equals(inputBox)); + expect(inputBox.size.height, height - padding - labelSpace); + + // When decoration is present on the top and bottom, EditableText shrinks to + // fit both inside the parent independently. + await tester.pumpWidget( + containedTextFieldBuilder( + counter: Container(height: counterHeight), + labelText: 'I am labelText', + ), + ); + expect(findEditableText(), equals(inputBox)); + expect(inputBox.size.height, height - padding - counterSpace - labelSpace); + + // When a prefix or suffix is present in an input that's full of content, + // it is ignored and allowed to expand beyond the top of the input. Other + // top and bottom decoration is still respected. + await tester.pumpWidget( + containedTextFieldBuilder( + counter: Container(height: counterHeight), + labelText: 'I am labelText', + prefix: const SizedBox(width: 10, height: 60), + ), + ); + expect(findEditableText(), equals(inputBox)); + expect(inputBox.size.height, height - padding - labelSpace - counterSpace); + }); + + testWidgets('Multiline hint text will wrap up to maxLines', (WidgetTester tester) async { + final Key textFieldKey = UniqueKey(); + + Widget builder(int? maxLines, final String hintMsg) { + return boilerplate( + child: TextField( + key: textFieldKey, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: maxLines, + decoration: InputDecoration(hintText: hintMsg), + ), + ); + } + + const hintPlaceholder = 'Placeholder'; + const multipleLineText = + "Here's a text, which is more than one line, to demonstrate the multiple line hint text"; + await tester.pumpWidget(builder(null, hintPlaceholder)); + + RenderBox findHintText(String hint) => tester.renderObject(find.text(hint)); + + final RenderBox hintTextBox = findHintText(hintPlaceholder); + final Size oneLineHintSize = hintTextBox.size; + + await tester.pumpWidget(builder(null, hintPlaceholder)); + expect(findHintText(hintPlaceholder), equals(hintTextBox)); + expect(hintTextBox.size, equals(oneLineHintSize)); + + const maxLines = 3; + await tester.pumpWidget(builder(maxLines, multipleLineText)); + final Text hintTextWidget = tester.widget(find.text(multipleLineText)); + expect(hintTextWidget.maxLines, equals(maxLines)); + expect(findHintText(multipleLineText).size.width, greaterThanOrEqualTo(oneLineHintSize.width)); + expect( + findHintText(multipleLineText).size.height, + greaterThanOrEqualTo(oneLineHintSize.height), + ); + }); + + testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 3, + ), + ), + ), + ); + + const testValue = kThreeLines; + const cutValue = 'First line of stuff'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Check that the text spans multiple lines. + final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); + final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); + final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); + final Offset middleStringPos = textOffsetToPosition(tester, testValue.indexOf('irst')); + expect(firstPos.dx, lessThan(middleStringPos.dx)); + expect(firstPos.dx, secondPos.dx); + expect(firstPos.dx, thirdPos.dx); + expect(firstPos.dy, lessThan(secondPos.dy)); + expect(secondPos.dy, lessThan(thirdPos.dy)); + + // Long press the 'n' in 'until' to select the word. + final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until') + 1); + TestGesture gesture = await tester.startGesture(untilPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + expect(controller.selection, const TextSelection(baseOffset: 39, extentOffset: 44)); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle to the third line, just after 'Third'. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + // The distance below the y value returned by textOffsetToPosition required + // to register a full vertical line drag. + const downLineOffset = Offset(0.0, 3.0); + Offset newHandlePos = + textOffsetToPosition(tester, testValue.indexOf('Third') + 5) + downLineOffset; + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection, const TextSelection(baseOffset: 39, extentOffset: 50)); + + // Drag the left handle to the first line, just after 'First'. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, testValue.indexOf('First') + 5); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 50); + + if (!isContextMenuProvidedByPlatform) { + await tester.tap(find.text('Cut')); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.text, cutValue); + } + }); + + testWidgets('Can scroll multiline input', (WidgetTester tester) async { + final Key textFieldKey = UniqueKey(); + final TextEditingController controller = _textEditingController(text: kMoreThanFourLines); + + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + key: textFieldKey, + controller: controller, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + ), + ), + ); + + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); + final RenderBox inputBox = findInputBox(); + + // Check that the last line of text is not displayed. + final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(firstPos.dx, fourthPos.dx); + expect(firstPos.dy, lessThan(fourthPos.dy)); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), + isTrue, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), + isFalse, + ); + + TestGesture gesture = await tester.startGesture(firstPos, pointer: 7); + await tester.pump(); + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pump(const Duration(seconds: 1)); + // Wait and drag again to trigger https://github.com/flutter/flutter/issues/6329 + // (No idea why this is necessary, but the bug wouldn't repro without it.) + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // Now the first line is scrolled up, and the fourth line is visible. + Offset newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + Offset newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + + expect(newFirstPos.dy, lessThan(firstPos.dy)); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), + isFalse, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), + isTrue, + ); + + // Now try scrolling by dragging the selection handle. + // Long press the middle of the word "won't" in the fourth line. + final Offset selectedWordPos = textOffsetToPosition( + tester, + kMoreThanFourLines.indexOf('Fourth line') + 14, + ); + + gesture = await tester.startGesture(selectedWordPos, pointer: 7); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(controller.selection.base.offset, 77); + expect(controller.selection.extent.offset, 82); + // Sanity check for the word selected is the intended one. + expect( + controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset), + "won't", + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the left handle to the first line, just after 'First'. + final Offset handlePos = endpoints[0].point + const Offset(-1, 1); + final Offset newHandlePos = textOffsetToPosition( + tester, + kMoreThanFourLines.indexOf('First') + 5, + ); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveTo(newHandlePos + const Offset(0.0, -10.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + // The text should have scrolled up with the handle to keep the active + // cursor visible, back to its original position. + newFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + newFourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(newFirstPos.dy, firstPos.dy); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFirstPos)), + isTrue, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(newFourthPos)), + isFalse, + ); + }); + + testWidgets('TextField smoke test', (WidgetTester tester) async { + late String textFieldValue; + + await tester.pumpWidget( + overlay( + child: TextField( + decoration: null, + onChanged: (String value) { + textFieldValue = value; + }, + ), + ), + ); + + Future<void> checkText(String testValue) { + return TestAsyncUtils.guard(() async { + await tester.enterText(find.byType(TextField), testValue); + + // Check that the onChanged event handler fired. + expect(textFieldValue, equals(testValue)); + + await tester.pump(); + }); + } + + await checkText('Hello World'); + }); + + testWidgets('TextField with global key', (WidgetTester tester) async { + final GlobalKey textFieldKey = GlobalKey(debugLabel: 'textFieldKey'); + late String textFieldValue; + + await tester.pumpWidget( + overlay( + child: TextField( + key: textFieldKey, + decoration: const InputDecoration(hintText: 'Placeholder'), + onChanged: (String value) { + textFieldValue = value; + }, + ), + ), + ); + + Future<void> checkText(String testValue) async { + return TestAsyncUtils.guard(() async { + await tester.enterText(find.byType(TextField), testValue); + + // Check that the onChanged event handler fired. + expect(textFieldValue, equals(testValue)); + + await tester.pump(); + }); + } + + await checkText('Hello World'); + }); + + testWidgets('TextField errorText trumps helperText', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: const TextField( + decoration: InputDecoration(errorText: 'error text', helperText: 'helper text'), + ), + ), + ), + ); + expect(find.text('helper text'), findsNothing); + expect(find.text('error text'), findsOneWidget); + }); + + testWidgets('TextField with default helperStyle', (WidgetTester tester) async { + final themeData = ThemeData(hintColor: Colors.blue[500], useMaterial3: false); + await tester.pumpWidget( + overlay( + child: Theme( + data: themeData, + child: const TextField(decoration: InputDecoration(helperText: 'helper text')), + ), + ), + ); + final Text helperText = tester.widget(find.text('helper text')); + expect(helperText.style!.color, themeData.hintColor); + expect(helperText.style!.fontSize, Typography.englishLike2014.bodySmall!.fontSize); + }); + + testWidgets('TextField with specified helperStyle', (WidgetTester tester) async { + final style = TextStyle(inherit: false, color: Colors.pink[500], fontSize: 10.0); + + await tester.pumpWidget( + overlay( + child: TextField( + decoration: InputDecoration(helperText: 'helper text', helperStyle: style), + ), + ), + ); + final Text helperText = tester.widget(find.text('helper text')); + expect(helperText.style, style); + }); + + testWidgets('TextField with default hintStyle', (WidgetTester tester) async { + final style = TextStyle(color: Colors.pink[500], fontSize: 10.0); + final themeData = ThemeData(); + + await tester.pumpWidget( + overlay( + child: Theme( + data: themeData, + child: TextField( + decoration: const InputDecoration(hintText: 'Placeholder'), + style: style, + ), + ), + ), + ); + + final Text hintText = tester.widget(find.text('Placeholder')); + expect(hintText.style!.color, themeData.colorScheme.onSurfaceVariant); + expect(hintText.style!.fontSize, style.fontSize); + }); + + testWidgets('Material2 - TextField with default hintStyle', (WidgetTester tester) async { + final style = TextStyle(color: Colors.pink[500], fontSize: 10.0); + final themeData = ThemeData(useMaterial3: false, hintColor: Colors.blue[500]); + + await tester.pumpWidget( + overlay( + child: Theme( + data: themeData, + child: TextField( + decoration: const InputDecoration(hintText: 'Placeholder'), + style: style, + ), + ), + ), + ); + + final Text hintText = tester.widget(find.text('Placeholder')); + expect(hintText.style!.color, themeData.hintColor); + expect(hintText.style!.fontSize, style.fontSize); + }); + + testWidgets('TextField with specified hintStyle', (WidgetTester tester) async { + final hintStyle = TextStyle(inherit: false, color: Colors.pink[500], fontSize: 10.0); + + await tester.pumpWidget( + overlay( + child: TextField( + decoration: InputDecoration(hintText: 'Placeholder', hintStyle: hintStyle), + ), + ), + ); + + final Text hintText = tester.widget(find.text('Placeholder')); + expect(hintText.style, hintStyle); + }); + + testWidgets('TextField with specified prefixStyle', (WidgetTester tester) async { + final prefixStyle = TextStyle(inherit: false, color: Colors.pink[500], fontSize: 10.0); + + await tester.pumpWidget( + overlay( + child: TextField( + decoration: InputDecoration(prefixText: 'Prefix:', prefixStyle: prefixStyle), + ), + ), + ); + + final Text prefixText = tester.widget(find.text('Prefix:')); + expect(prefixText.style, prefixStyle); + }); + + testWidgets('TextField prefix and suffix create a sibling node', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + overlay( + child: TextField( + controller: _textEditingController(text: 'some text'), + decoration: const InputDecoration(prefixText: 'Prefix', suffixText: 'Suffix'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics(id: 2, textDirection: TextDirection.ltr, label: 'Prefix'), + TestSemantics( + textDirection: TextDirection.ltr, + value: 'some text', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + TestSemantics(id: 3, textDirection: TextDirection.ltr, label: 'Suffix'), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('TextField prefix icon and suffix icon create a sibling node', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + overlay( + child: TextField( + controller: _textEditingController(text: 'some text'), + decoration: const InputDecoration(prefixIcon: Text('Prefix'), suffixIcon: Text('Suffix')), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + value: 'some text', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + TestSemantics(textDirection: TextDirection.ltr, label: 'Prefix'), + TestSemantics(textDirection: TextDirection.ltr, label: 'Suffix'), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('TextField with specified suffixStyle', (WidgetTester tester) async { + final suffixStyle = TextStyle(color: Colors.pink[500], fontSize: 10.0); + + await tester.pumpWidget( + overlay( + child: TextField( + decoration: InputDecoration(suffixText: '.com', suffixStyle: suffixStyle), + ), + ), + ); + + final Text suffixText = tester.widget(find.text('.com')); + expect(suffixText.style, suffixStyle); + }); + + testWidgets('TextField prefix and suffix appear correctly with no hint or label', ( + WidgetTester tester, + ) async { + final Key secondKey = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Column( + children: <Widget>[ + const TextField(decoration: InputDecoration(labelText: 'First')), + TextField( + key: secondKey, + decoration: const InputDecoration(prefixText: 'Prefix', suffixText: 'Suffix'), + ), + ], + ), + ), + ); + + expect(find.text('Prefix'), findsOneWidget); + expect(find.text('Suffix'), findsOneWidget); + + // Focus the Input. The prefix should still display. + await tester.tap(find.byKey(secondKey)); + await tester.pump(); + + expect(find.text('Prefix'), findsOneWidget); + expect(find.text('Suffix'), findsOneWidget); + + // Enter some text, and the prefix should still display. + await tester.enterText(find.byKey(secondKey), 'Hi'); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Prefix'), findsOneWidget); + expect(find.text('Suffix'), findsOneWidget); + }); + + testWidgets('TextField prefix and suffix appear correctly with hint text', ( + WidgetTester tester, + ) async { + final hintStyle = TextStyle(inherit: false, color: Colors.pink[500], fontSize: 10.0); + final Key secondKey = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Column( + children: <Widget>[ + const TextField(decoration: InputDecoration(labelText: 'First')), + TextField( + key: secondKey, + decoration: InputDecoration( + hintText: 'Hint', + hintStyle: hintStyle, + prefixText: 'Prefix', + suffixText: 'Suffix', + ), + ), + ], + ), + ), + ); + + // Neither the prefix or the suffix should initially be visible, only the hint. + expect(getOpacity(tester, find.text('Prefix')), 0.0); + expect(getOpacity(tester, find.text('Suffix')), 0.0); + expect(getOpacity(tester, find.text('Hint')), 1.0); + + await tester.tap(find.byKey(secondKey)); + await tester.pumpAndSettle(); + + // Focus the Input. The hint, prefix, and suffix should appear + expect(getOpacity(tester, find.text('Prefix')), 1.0); + expect(getOpacity(tester, find.text('Suffix')), 1.0); + expect(getOpacity(tester, find.text('Hint')), 1.0); + + // Enter some text, and the hint should disappear and the prefix and suffix + // should continue to be visible + await tester.enterText(find.byKey(secondKey), 'Hi'); + await tester.pumpAndSettle(); + + expect(getOpacity(tester, find.text('Prefix')), 1.0); + expect(getOpacity(tester, find.text('Suffix')), 1.0); + expect(getOpacity(tester, find.text('Hint')), 0.0); + + // Check and make sure that the right styles were applied. + final Text prefixText = tester.widget(find.text('Prefix')); + expect(prefixText.style, hintStyle); + final Text suffixText = tester.widget(find.text('Suffix')); + expect(suffixText.style, hintStyle); + }); + + testWidgets('TextField prefix and suffix appear correctly with label text', ( + WidgetTester tester, + ) async { + final prefixStyle = TextStyle(color: Colors.pink[500], fontSize: 10.0); + final suffixStyle = TextStyle(color: Colors.green[500], fontSize: 12.0); + final Key secondKey = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Column( + children: <Widget>[ + const TextField(decoration: InputDecoration(labelText: 'First')), + TextField( + key: secondKey, + decoration: InputDecoration( + labelText: 'Label', + prefixText: 'Prefix', + prefixStyle: prefixStyle, + suffixText: 'Suffix', + suffixStyle: suffixStyle, + ), + ), + ], + ), + ), + ); + + // Not focused. The prefix and suffix should not appear, but the label should. + expect(getOpacity(tester, find.text('Prefix')), 0.0); + expect(getOpacity(tester, find.text('Suffix')), 0.0); + expect(find.text('Label'), findsOneWidget); + + // Focus the input. The label, prefix, and suffix should appear. + await tester.tap(find.byKey(secondKey)); + await tester.pumpAndSettle(); + + expect(getOpacity(tester, find.text('Prefix')), 1.0); + expect(getOpacity(tester, find.text('Suffix')), 1.0); + expect(find.text('Label'), findsOneWidget); + + // Enter some text. The label, prefix, and suffix should remain visible. + await tester.enterText(find.byKey(secondKey), 'Hi'); + await tester.pumpAndSettle(); + + expect(getOpacity(tester, find.text('Prefix')), 1.0); + expect(getOpacity(tester, find.text('Suffix')), 1.0); + expect(find.text('Label'), findsOneWidget); + + // Check and make sure that the right styles were applied. + final Text prefixText = tester.widget(find.text('Prefix')); + expect(prefixText.style, prefixStyle); + final Text suffixText = tester.widget(find.text('Suffix')); + expect(suffixText.style, suffixStyle); + }); + + testWidgets('TextField label text animates', (WidgetTester tester) async { + final Key secondKey = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: Column( + children: <Widget>[ + const TextField(decoration: InputDecoration(labelText: 'First')), + TextField( + key: secondKey, + decoration: const InputDecoration(labelText: 'Second'), + ), + ], + ), + ), + ); + + Offset pos = tester.getTopLeft(find.text('Second')); + + // Focus the Input. The label should start animating upwards. + await tester.tap(find.byKey(secondKey)); + await tester.idle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + Offset newPos = tester.getTopLeft(find.text('Second')); + expect(newPos.dy, lessThan(pos.dy)); + + // Label should still be sliding upward. + await tester.pump(const Duration(milliseconds: 50)); + pos = newPos; + newPos = tester.getTopLeft(find.text('Second')); + expect(newPos.dy, lessThan(pos.dy)); + }); + + testWidgets('Icon is separated from input/label by 16+12', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const TextField( + decoration: InputDecoration(icon: Icon(Icons.phone), labelText: 'label', filled: true), + ), + ), + ); + final double iconRight = tester.getTopRight(find.byType(Icon)).dx; + // There's a 16 pixels gap between the right edge of the icon and the text field's + // container, and, per https://material.io/go/design-text-fields#text-fields-layout, + // 16 pixels more padding between the left edge of the container and the left edge + // of the input and label. + expect(iconRight + 16.0 + 16.0, equals(tester.getTopLeft(find.text('label')).dx)); + expect(iconRight + 16.0 + 16.0, equals(tester.getTopLeft(find.byType(EditableText)).dx)); + }); + + testWidgets('Collapsed hint text placement', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: const TextField( + decoration: InputDecoration.collapsed(hintText: 'hint'), + strutStyle: StrutStyle.disabled, + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.text('hint')), + equals(tester.getTopLeft(find.byType(EditableText))), + ); + }); + + testWidgets('Can align to center', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SizedBox( + width: 300.0, + child: TextField(textAlign: TextAlign.center, decoration: null), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + assert(editable.size.width == 300); + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 0)).topLeft, + ); + + // The overlay() function centers its child within a 800x600 view. + // Default cursorWidth is 2.0, test viewWidth is 800 + // Centered cursor topLeft.dx: 399 == viewWidth/2 - cursorWidth/2 + expect(topLeft.dx, equals(399.0)); + + await tester.enterText(find.byType(TextField), 'abcd'); + await tester.pump(); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + + // TextPosition(offset: 2) - center of 'abcd' + expect(topLeft.dx, equals(399.0)); + }); + + testWidgets('Can align to center within center', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: const SizedBox( + width: 300.0, + child: Center(child: TextField(textAlign: TextAlign.center, decoration: null)), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 0)).topLeft, + ); + + // The overlay() function centers its child within a 800x600 view. + // Default cursorWidth is 2.0, test viewWidth is 800 + // Centered cursor topLeft.dx: 399 == viewWidth/2 - cursorWidth/2 + expect(topLeft.dx, equals(399.0)); + + await tester.enterText(find.byType(TextField), 'abcd'); + await tester.pump(); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + + // TextPosition(offset: 2) - center of 'abcd' + expect(topLeft.dx, equals(399.0)); + }); + + testWidgets('Controller can update server', (WidgetTester tester) async { + final TextEditingController controller1 = _textEditingController(text: 'Initial Text'); + final TextEditingController controller2 = _textEditingController(text: 'More Text'); + + TextEditingController? currentController; + late StateSetter setState; + + await tester.pumpWidget( + overlay( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextField(controller: currentController); + }, + ), + ), + ); + expect(tester.testTextInput.editingState, isNull); + + // Initial state with null controller. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.editingState!['text'], isEmpty); + + // Update the controller from null to controller1. + setState(() { + currentController = controller1; + }); + await tester.pump(); + expect(tester.testTextInput.editingState!['text'], equals('Initial Text')); + + // Verify that updates to controller1 are handled. + controller1.text = 'Updated Text'; + await tester.idle(); + expect(tester.testTextInput.editingState!['text'], equals('Updated Text')); + + // Verify that switching from controller1 to controller2 is handled. + setState(() { + currentController = controller2; + }); + await tester.pump(); + expect(tester.testTextInput.editingState!['text'], equals('More Text')); + + // Verify that updates to controller1 are ignored. + controller1.text = 'Ignored Text'; + await tester.idle(); + expect(tester.testTextInput.editingState!['text'], equals('More Text')); + + // Verify that updates to controller text are handled. + controller2.text = 'Additional Text'; + await tester.idle(); + expect(tester.testTextInput.editingState!['text'], equals('Additional Text')); + + // Verify that updates to controller selection are handled. + controller2.selection = const TextSelection(baseOffset: 0, extentOffset: 5); + await tester.idle(); + expect(tester.testTextInput.editingState!['selectionBase'], equals(0)); + expect(tester.testTextInput.editingState!['selectionExtent'], equals(5)); + + // Verify that calling clear() clears the text. + controller2.clear(); + await tester.idle(); + expect(tester.testTextInput.editingState!['text'], equals('')); + + // Verify that switching from controller2 to null preserves current text. + controller2.text = 'The Final Cut'; + await tester.idle(); + expect(tester.testTextInput.editingState!['text'], equals('The Final Cut')); + setState(() { + currentController = null; + }); + await tester.pump(); + expect(tester.testTextInput.editingState!['text'], equals('The Final Cut')); + + // Verify that changes to controller2 are ignored. + controller2.text = 'Goodbye Cruel World'; + expect(tester.testTextInput.editingState!['text'], equals('The Final Cut')); + }); + + testWidgets('Cannot enter new lines onto single line TextField', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, decoration: null)), + ); + + await tester.enterText(find.byType(TextField), 'abc\ndef'); + + expect(textController.text, 'abcdef'); + }); + + testWidgets('Injected formatters are chained', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + decoration: null, + inputFormatters: <TextInputFormatter>[ + FilteringTextInputFormatter.deny(RegExp(r'[a-z]'), replacementString: '#'), + ], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a一b二c三\nd四e五f六'); + // The default single line formatter replaces \n with empty string. + expect(textController.text, '#一#二#三#四#五#六'); + }); + + testWidgets('Injected formatters are chained (deprecated names)', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + decoration: null, + inputFormatters: <TextInputFormatter>[ + FilteringTextInputFormatter.deny(RegExp(r'[a-z]'), replacementString: '#'), + ], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a一b二c三\nd四e五f六'); + // The default single line formatter replaces \n with empty string. + expect(textController.text, '#一#二#三#四#五#六'); + }); + + testWidgets('Chained formatters are in sequence', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + decoration: null, + maxLines: 2, + inputFormatters: <TextInputFormatter>[ + FilteringTextInputFormatter.deny(RegExp(r'[a-z]'), replacementString: '12\n'), + FilteringTextInputFormatter.allow(RegExp(r'\n[0-9]')), + ], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a1b2c3'); + // The first formatter turns it into + // 12\n112\n212\n3 + // The second formatter turns it into + // \n1\n2\n3 + // Multiline is allowed since maxLine != 1. + expect(textController.text, '\n1\n2\n3'); + }); + + testWidgets('Chained formatters are in sequence (deprecated names)', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + decoration: null, + maxLines: 2, + inputFormatters: <TextInputFormatter>[ + FilteringTextInputFormatter.deny(RegExp(r'[a-z]'), replacementString: '12\n'), + FilteringTextInputFormatter.allow(RegExp(r'\n[0-9]')), + ], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a1b2c3'); + // The first formatter turns it into + // 12\n112\n212\n3 + // The second formatter turns it into + // \n1\n2\n3 + // Multiline is allowed since maxLine != 1. + expect(textController.text, '\n1\n2\n3'); + }); + + testWidgets( + 'Pasted values are formatted', + (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: textController, + decoration: null, + inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a1b\n2c3'); + expect(textController.text, '123'); + await skipPastScrollingAnimation(tester); + + await tester.tapAt(textOffsetToPosition(tester, '123'.indexOf('2'))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(textController.selection), + renderEditable, + ); + await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + await Clipboard.setData(const ClipboardData(text: '一4二\n5三6')); + await tester.tap(find.text('Paste')); + await tester.pump(); + // Puts 456 before the 2 in 123. + expect(textController.text, '145623'); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Pasted values are formatted (deprecated names)', + (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: textController, + decoration: null, + inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a1b\n2c3'); + expect(textController.text, '123'); + await skipPastScrollingAnimation(tester); + + await tester.tapAt(textOffsetToPosition(tester, '123'.indexOf('2'))); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(textController.selection), + renderEditable, + ); + await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is zero + + await Clipboard.setData(const ClipboardData(text: '一4二\n5三6')); + await tester.tap(find.text('Paste')); + await tester.pump(); + // Puts 456 before the 2 in 123. + expect(textController.text, '145623'); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('Do not add LengthLimiting formatter to the user supplied list', ( + WidgetTester tester, + ) async { + final formatters = <TextInputFormatter>[]; + + await tester.pumpWidget( + overlay(child: TextField(decoration: null, maxLength: 5, inputFormatters: formatters)), + ); + + expect(formatters.isEmpty, isTrue); + }); + + testWidgets('Text field scrolls the caret into view', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: SizedBox(width: 100.0, child: TextField(controller: controller)), + ), + ), + ); + + final String longText = 'a' * 20; + await tester.enterText(find.byType(TextField), longText); + await skipPastScrollingAnimation(tester); + + ScrollableState scrollableState = tester.firstState(find.byType(Scrollable)); + expect(scrollableState.position.pixels, equals(0.0)); + + // Move the caret to the end of the text and check that the text field + // scrolls to make the caret visible. + scrollableState = tester.firstState(find.byType(Scrollable)); + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + editableTextState.userUpdateTextEditingValue( + editableTextState.textEditingValue.copyWith( + selection: TextSelection.collapsed(offset: longText.length), + ), + null, + ); + + await tester.pump(); // TODO(ianh): Figure out why this extra pump is needed. + await skipPastScrollingAnimation(tester); + + scrollableState = tester.firstState(find.byType(Scrollable)); + // For a horizontal input, scrolls to the exact position of the caret. + expect(scrollableState.position.pixels, equals(222.0)); + }); + + testWidgets('Multiline text field scrolls the caret into view', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller, maxLines: 6))); + + const tallText = 'a\nb\nc\nd\ne\nf\ng'; // One line over max + await tester.enterText(find.byType(TextField), tallText); + await skipPastScrollingAnimation(tester); + + ScrollableState scrollableState = tester.firstState(find.byType(Scrollable)); + expect(scrollableState.position.pixels, equals(0.0)); + + // Move the caret to the end of the text and check that the text field + // scrolls to make the caret visible. + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + editableTextState.userUpdateTextEditingValue( + editableTextState.textEditingValue.copyWith( + selection: const TextSelection.collapsed(offset: tallText.length), + ), + null, + ); + await tester.pump(); + await skipPastScrollingAnimation(tester); + + // Should have scrolled down exactly one line height (7 lines of text in 6 + // line text field). + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + scrollableState = tester.firstState(find.byType(Scrollable)); + expect(scrollableState.position.pixels, moreOrLessEquals(lineHeight, epsilon: 0.1)); + }); + + testWidgets('haptic feedback', (WidgetTester tester) async { + final feedback = FeedbackTester(); + addTearDown(feedback.dispose); + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + overlay( + child: SizedBox(width: 100.0, child: TextField(controller: controller)), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(TextField)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 1); + }); + + testWidgets('Text field drops selection color when losing focus', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/103341. + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + final TextEditingController controller1 = _textEditingController(); + const Color selectionColor = Colors.orange; + const Color cursorColor = Colors.red; + + await tester.pumpWidget( + overlay( + child: DefaultSelectionStyle( + selectionColor: selectionColor, + cursorColor: cursorColor, + child: Column( + children: <Widget>[ + TextField(key: key1, controller: controller1), + TextField(key: key2), + ], + ), + ), + ), + ); + + const selection = TextSelection(baseOffset: 0, extentOffset: 4); + final EditableTextState state1 = tester.state<EditableTextState>( + find.byType(EditableText).first, + ); + final EditableTextState state2 = tester.state<EditableTextState>( + find.byType(EditableText).last, + ); + + await tester.tap(find.byKey(key1)); + await tester.enterText(find.byKey(key1), 'abcd'); + await tester.pump(); + + await tester.tap(find.byKey(key2)); + await tester.enterText(find.byKey(key2), 'dcba'); + await tester.pump(); + + // Focus and selection is active on first TextField, so the second TextFields + // selectionColor should be dropped. + await tester.tap(find.byKey(key1)); + controller1.selection = const TextSelection(baseOffset: 0, extentOffset: 4); + await tester.pump(); + expect(controller1.selection, selection); + expect(state1.widget.selectionColor, selectionColor); + expect(state2.widget.selectionColor, null); + + // Focus and selection is active on second TextField, so the first TextFields + // selectionColor should be dropped. + await tester.tap(find.byKey(key2)); + await tester.pump(); + expect(state1.widget.selectionColor, null); + expect(state2.widget.selectionColor, selectionColor); + }); + + testWidgets('Selection is consistent with text length', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + controller.text = 'abcde'; + controller.selection = const TextSelection.collapsed(offset: 5); + + controller.text = ''; + expect(controller.selection.start, lessThanOrEqualTo(0)); + expect(controller.selection.end, lessThanOrEqualTo(0)); + + late FlutterError error; + try { + controller.selection = const TextSelection.collapsed(offset: 10); + } on FlutterError catch (e) { + error = e; + } finally { + expect(error.diagnostics.length, 1); + expect( + error.toStringDeep(), + equalsIgnoringHashCodes( + 'FlutterError\n' + ' invalid text selection: TextSelection.collapsed(offset: 10,\n' + ' affinity: TextAffinity.downstream, isDirectional: false)\n', + ), + ); + } + }); + + // Regression test for https://github.com/flutter/flutter/issues/35848 + testWidgets('Clearing text field with suffixIcon does not cause text selection exception', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(text: 'Prefilled text.'); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: controller, + decoration: InputDecoration( + suffixIcon: IconButton(icon: const Icon(Icons.close), onPressed: controller.clear), + ), + ), + ), + ); + + await tester.tap(find.byType(IconButton)); + expect(controller.text, ''); + }); + + testWidgets('maxLength limits input.', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + expect(textController.text, '0123456789'); + }); + + testWidgets('maxLength limits input with surrogate pairs.', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + const surrogatePair = '😆'; + await tester.enterText(find.byType(TextField), '${surrogatePair}0123456789101112'); + expect(textController.text, '${surrogatePair}012345678'); + }); + + testWidgets('maxLength limits input with grapheme clusters.', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + const graphemeCluster = '👨‍👩‍👦'; + await tester.enterText(find.byType(TextField), '${graphemeCluster}0123456789101112'); + expect(textController.text, '${graphemeCluster}012345678'); + }); + + testWidgets('maxLength limits input in the center of a maxed-out field.', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/37420. + final TextEditingController textController = _textEditingController(); + const testValue = '0123456789'; + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + // Max out the character limit in the field. + await tester.enterText(find.byType(TextField), testValue); + expect(textController.text, testValue); + + // Entering more characters at the end does nothing. + await tester.enterText(find.byType(TextField), '${testValue}9999999'); + expect(textController.text, testValue); + + // Entering text in the middle of the field also does nothing. + await tester.enterText(find.byType(TextField), '0123455555555556789'); + expect(textController.text, testValue); + }); + + testWidgets( + 'maxLength limits input in the center of a maxed-out field, with collapsed selection', + (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + const testValue = '0123456789'; + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + // Max out the character limit in the field. + await tester.showKeyboard(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue(text: testValue, selection: TextSelection.collapsed(offset: 10)), + ); + await tester.pump(); + expect(textController.text, testValue); + + // Entering more characters at the end does nothing. + await tester.showKeyboard(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '${testValue}9999999', + selection: TextSelection.collapsed(offset: 10 + 7), + ), + ); + await tester.pump(); + + expect(textController.text, testValue); + + // Entering text in the middle of the field also does nothing. + // Entering more characters at the end does nothing. + await tester.showKeyboard(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '0123455555555556789', + selection: TextSelection.collapsed(offset: 19), + ), + ); + await tester.pump(); + + expect(textController.text, testValue); + }, + ); + + testWidgets( + 'maxLength limits input in the center of a maxed-out field, with non-collapsed selection', + (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + const testValue = '0123456789'; + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + ), + ), + ); + + // Max out the character limit in the field. + await tester.showKeyboard(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue, + selection: TextSelection(baseOffset: 8, extentOffset: 10), + ), + ); + await tester.pump(); + expect(textController.text, testValue); + + // Entering more characters at the end does nothing. + await tester.showKeyboard(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '01234569999999', + selection: TextSelection.collapsed(offset: 14), + ), + ); + await tester.pump(); + + expect(textController.text, '0123456999'); + }, + ); + + testWidgets('maxLength limits input length even if decoration is null.', ( + WidgetTester tester, + ) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, decoration: null, maxLength: 10)), + ); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + expect(textController.text, '0123456789'); + }); + + testWidgets('maxLength still works with other formatters', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + maxLength: 10, + inputFormatters: <TextInputFormatter>[ + FilteringTextInputFormatter.deny(RegExp(r'[a-z]'), replacementString: '#'), + ], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a一b二c三\nd四e五f六'); + // The default single line formatter replaces \n with empty string. + expect(textController.text, '#一#二#三#四#五'); + }); + + testWidgets('maxLength still works with other formatters (deprecated names)', ( + WidgetTester tester, + ) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + maxLength: 10, + inputFormatters: <TextInputFormatter>[ + FilteringTextInputFormatter.deny(RegExp(r'[a-z]'), replacementString: '#'), + ], + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'a一b二c三\nd四e五f六'); + // The default single line formatter replaces \n with empty string. + expect(textController.text, '#一#二#三#四#五'); + }); + + testWidgets("maxLength isn't enforced when maxLengthEnforcement.none.", ( + WidgetTester tester, + ) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate( + child: TextField( + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.none, + ), + ), + ); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + expect(textController.text, '0123456789101112'); + }); + + testWidgets('maxLength shows warning when maxLengthEnforcement.none.', ( + WidgetTester tester, + ) async { + final TextEditingController textController = _textEditingController(); + const testStyle = TextStyle(color: Colors.deepPurpleAccent); + + await tester.pumpWidget( + boilerplate( + child: TextField( + decoration: const InputDecoration(errorStyle: testStyle), + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.none, + ), + ), + ); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + await tester.pump(); + + expect(textController.text, '0123456789101112'); + expect(find.text('16/10'), findsOneWidget); + Text counterTextWidget = tester.widget(find.text('16/10')); + expect(counterTextWidget.style!.color, equals(Colors.deepPurpleAccent)); + + await tester.enterText(find.byType(TextField), '0123456789'); + await tester.pump(); + + expect(textController.text, '0123456789'); + expect(find.text('10/10'), findsOneWidget); + counterTextWidget = tester.widget(find.text('10/10')); + expect(counterTextWidget.style!.color, isNot(equals(Colors.deepPurpleAccent))); + }); + + testWidgets('maxLength shows warning in Material 3', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + final theme = ThemeData.from( + colorScheme: const ColorScheme.light().copyWith(error: Colors.deepPurpleAccent), + ); + await tester.pumpWidget( + boilerplate( + theme: theme, + child: TextField( + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.none, + ), + ), + ); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + await tester.pump(); + + expect(textController.text, '0123456789101112'); + expect(find.text('16/10'), findsOneWidget); + Text counterTextWidget = tester.widget(find.text('16/10')); + expect(counterTextWidget.style!.color, equals(Colors.deepPurpleAccent)); + + await tester.enterText(find.byType(TextField), '0123456789'); + await tester.pump(); + + expect(textController.text, '0123456789'); + expect(find.text('10/10'), findsOneWidget); + counterTextWidget = tester.widget(find.text('10/10')); + expect(counterTextWidget.style!.color, isNot(equals(Colors.deepPurpleAccent))); + }); + + testWidgets('maxLength shows warning when maxLengthEnforcement.none with surrogate pairs.', ( + WidgetTester tester, + ) async { + final TextEditingController textController = _textEditingController(); + const testStyle = TextStyle(color: Colors.deepPurpleAccent); + + await tester.pumpWidget( + boilerplate( + child: TextField( + decoration: const InputDecoration(errorStyle: testStyle), + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.none, + ), + ), + ); + + await tester.enterText(find.byType(TextField), '😆012345678910111'); + await tester.pump(); + + expect(textController.text, '😆012345678910111'); + expect(find.text('16/10'), findsOneWidget); + Text counterTextWidget = tester.widget(find.text('16/10')); + expect(counterTextWidget.style!.color, equals(Colors.deepPurpleAccent)); + + await tester.enterText(find.byType(TextField), '😆012345678'); + await tester.pump(); + + expect(textController.text, '😆012345678'); + expect(find.text('10/10'), findsOneWidget); + counterTextWidget = tester.widget(find.text('10/10')); + expect(counterTextWidget.style!.color, isNot(equals(Colors.deepPurpleAccent))); + }); + + testWidgets('maxLength shows warning when maxLengthEnforcement.none with grapheme clusters.', ( + WidgetTester tester, + ) async { + final TextEditingController textController = _textEditingController(); + const testStyle = TextStyle(color: Colors.deepPurpleAccent); + + await tester.pumpWidget( + boilerplate( + child: TextField( + decoration: const InputDecoration(errorStyle: testStyle), + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.none, + ), + ), + ); + + await tester.enterText(find.byType(TextField), '👨‍👩‍👦012345678910111'); + await tester.pump(); + + expect(textController.text, '👨‍👩‍👦012345678910111'); + expect(find.text('16/10'), findsOneWidget); + Text counterTextWidget = tester.widget(find.text('16/10')); + expect(counterTextWidget.style!.color, equals(Colors.deepPurpleAccent)); + + await tester.enterText(find.byType(TextField), '👨‍👩‍👦012345678'); + await tester.pump(); + + expect(textController.text, '👨‍👩‍👦012345678'); + expect(find.text('10/10'), findsOneWidget); + counterTextWidget = tester.widget(find.text('10/10')); + expect(counterTextWidget.style!.color, isNot(equals(Colors.deepPurpleAccent))); + }); + + testWidgets('maxLength limits input with surrogate pairs.', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + const surrogatePair = '😆'; + await tester.enterText(find.byType(TextField), '${surrogatePair}0123456789101112'); + expect(textController.text, '${surrogatePair}012345678'); + }); + + testWidgets('maxLength limits input with grapheme clusters.', (WidgetTester tester) async { + final TextEditingController textController = _textEditingController(); + + await tester.pumpWidget( + boilerplate(child: TextField(controller: textController, maxLength: 10)), + ); + + const graphemeCluster = '👨‍👩‍👦'; + await tester.enterText(find.byType(TextField), '${graphemeCluster}0123456789101112'); + expect(textController.text, '${graphemeCluster}012345678'); + }); + + testWidgets('setting maxLength shows counter', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10))), + ), + ); + + expect(find.text('0/10'), findsOneWidget); + + await tester.enterText(find.byType(TextField), '01234'); + await tester.pump(); + + expect(find.text('5/10'), findsOneWidget); + }); + + testWidgets('maxLength counter measures surrogate pairs as one character', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10))), + ), + ); + + expect(find.text('0/10'), findsOneWidget); + + const surrogatePair = '😆'; + await tester.enterText(find.byType(TextField), surrogatePair); + await tester.pump(); + + expect(find.text('1/10'), findsOneWidget); + }); + + testWidgets('maxLength counter measures grapheme clusters as one character', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10))), + ), + ); + + expect(find.text('0/10'), findsOneWidget); + + const familyEmoji = '👨‍👩‍👦'; + await tester.enterText(find.byType(TextField), familyEmoji); + await tester.pump(); + + expect(find.text('1/10'), findsOneWidget); + }); + + testWidgets('setting maxLength to TextField.noMaxLength shows only entered length', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center(child: TextField(maxLength: TextField.noMaxLength)), + ), + ), + ); + + expect(find.text('0'), findsOneWidget); + + await tester.enterText(find.byType(TextField), '01234'); + await tester.pump(); + + expect(find.text('5'), findsOneWidget); + }); + + testWidgets('passing a buildCounter shows returned widget', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + buildCounter: + ( + BuildContext context, { + required int currentLength, + int? maxLength, + required bool isFocused, + }) { + return Text('$currentLength of $maxLength'); + }, + maxLength: 10, + ), + ), + ), + ), + ); + + expect(find.text('0 of 10'), findsOneWidget); + + await tester.enterText(find.byType(TextField), '01234'); + await tester.pump(); + + expect(find.text('5 of 10'), findsOneWidget); + }); + + testWidgets('TextField identifies as text field in semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10))), + ), + ); + + expect( + semantics, + includesNodeWith( + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets('Can scroll multiline input when disabled', (WidgetTester tester) async { + final Key textFieldKey = UniqueKey(); + final TextEditingController controller = _textEditingController(text: kMoreThanFourLines); + + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + key: textFieldKey, + controller: controller, + ignorePointers: false, + enabled: false, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + ), + ), + ); + + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); + final RenderBox inputBox = findInputBox(); + + // Check that the last line of text is not displayed. + final Offset firstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + final Offset fourthPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('Fourth')); + expect(firstPos.dx, fourthPos.dx); + expect(firstPos.dy, lessThan(fourthPos.dy)); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(firstPos)), + isTrue, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(fourthPos)), + isFalse, + ); + + final TestGesture gesture = await tester.startGesture(firstPos, pointer: 7); + await tester.pump(); + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0.0, -1000.0)); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Now the first line is scrolled up, and the fourth line is visible. + final Offset finalFirstPos = textOffsetToPosition(tester, kMoreThanFourLines.indexOf('First')); + final Offset finalFourthPos = textOffsetToPosition( + tester, + kMoreThanFourLines.indexOf('Fourth'), + ); + + expect(finalFirstPos.dy, lessThan(firstPos.dy)); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(finalFirstPos)), + isFalse, + ); + expect( + inputBox.hitTest(BoxHitTestResult(), position: inputBox.globalToLocal(finalFourthPos)), + isTrue, + ); + }); + + testWidgets('Disabled text field does not have tap action', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10, enabled: false))), + ), + ); + + expect( + semantics, + isNot( + includesNodeWith(actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus]), + ), + ); + semantics.dispose(); + }); + + testWidgets('Disabled text field semantics node still contains value', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: _textEditingController(text: 'text'), + maxLength: 10, + enabled: false, + ), + ), + ), + ), + ); + + expect(semantics, includesNodeWith(actions: <SemanticsAction>[], value: 'text')); + semantics.dispose(); + }); + + testWidgets('Readonly text field does not have tap action', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10, readOnly: true))), + ), + ); + + expect( + semantics, + isNot( + includesNodeWith(actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus]), + ), + ); + + semantics.dispose(); + }); + + testWidgets('Disabled text field hides helper and counter', (WidgetTester tester) async { + const helperText = 'helper text'; + const counterText = 'counter text'; + const errorText = 'error text'; + Widget buildFrame(bool enabled, bool hasError) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: TextField( + decoration: InputDecoration( + labelText: 'label text', + helperText: helperText, + counterText: counterText, + errorText: hasError ? errorText : null, + enabled: enabled, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(true, false)); + Text helperWidget = tester.widget(find.text(helperText)); + Text counterWidget = tester.widget(find.text(counterText)); + expect(helperWidget.style!.color, isNot(equals(Colors.transparent))); + expect(counterWidget.style!.color, isNot(equals(Colors.transparent))); + await tester.pumpWidget(buildFrame(true, true)); + counterWidget = tester.widget(find.text(counterText)); + Text errorWidget = tester.widget(find.text(errorText)); + expect(helperWidget.style!.color, isNot(equals(Colors.transparent))); + expect(errorWidget.style!.color, isNot(equals(Colors.transparent))); + + // When enabled is false, the helper/error and counter are not visible. + await tester.pumpWidget(buildFrame(false, false)); + helperWidget = tester.widget(find.text(helperText)); + counterWidget = tester.widget(find.text(counterText)); + expect(helperWidget.style!.color, equals(Colors.transparent)); + expect(counterWidget.style!.color, equals(Colors.transparent)); + await tester.pumpWidget(buildFrame(false, true)); + errorWidget = tester.widget(find.text(errorText)); + counterWidget = tester.widget(find.text(counterText)); + expect(counterWidget.style!.color, equals(Colors.transparent)); + expect(errorWidget.style!.color, equals(Colors.transparent)); + }); + + testWidgets('Disabled text field has default M2 disabled text style for the input text', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller, enabled: false)), + ), + ), + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect( + editableText.style.color, + Colors.black38, + ); // Colors.black38 is the default disabled color for ThemeData.light(). + }); + + testWidgets('Disabled text field has default M3 disabled text style for the input text', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + + final theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Center(child: TextField(controller: controller, enabled: false)), + ), + ), + ); + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, theme.textTheme.bodyLarge!.color!.withOpacity(0.38)); + }); + + testWidgets('Enabled TextField statesController', (WidgetTester tester) async { + final textEditingController = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + addTearDown(textEditingController.dispose); + + var count = 0; + void valueChanged() { + count += 1; + } + + final statesController = MaterialStatesController(); + addTearDown(statesController.dispose); + + statesController.addListener(valueChanged); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(statesController: statesController, controller: textEditingController), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + final Offset center = tester.getCenter(find.byType(EditableText).first); + await gesture.moveTo(center); + await tester.pump(); + expect(statesController.value, <WidgetState>{WidgetState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pump(); + expect(statesController.value, <WidgetState>{}); + expect(count, 2); + + await gesture.down(center); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(statesController.value, <WidgetState>{WidgetState.hovered, WidgetState.focused}); + expect(count, 4); // adds hovered and pressed - two changes. + + await gesture.moveTo(Offset.zero); + await tester.pump(); + expect(statesController.value, <WidgetState>{WidgetState.focused}); + expect(count, 5); + + await gesture.down(Offset.zero); + await tester.pump(); + expect(statesController.value, <WidgetState>{}); + expect(count, 6); + await gesture.up(); + await tester.pump(); + + await gesture.down(center); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(statesController.value, <WidgetState>{WidgetState.hovered, WidgetState.focused}); + expect(count, 8); // adds hovered and pressed - two changes. + + // If the text field is rebuilt disabled, then the focused state is + // removed. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + statesController: statesController, + controller: textEditingController, + enabled: false, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(statesController.value, <WidgetState>{WidgetState.hovered, WidgetState.disabled}); + expect(count, 10); // removes focused and adds disabled - two changes. + + await gesture.moveTo(Offset.zero); + await tester.pump(); + expect(statesController.value, <WidgetState>{WidgetState.disabled}); + expect(count, 11); + + // If the text field is rebuilt enabled and in an error state, then the error + // state is added. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + statesController: statesController, + controller: textEditingController, + decoration: const InputDecoration(errorText: 'error'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(statesController.value, <WidgetState>{WidgetState.error}); + expect(count, 13); // removes disabled and adds error - two changes. + + // If the text field is rebuilt without an error, then the error + // state is removed. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(statesController: statesController, controller: textEditingController), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(statesController.value, <WidgetState>{}); + expect(count, 14); + }); + + testWidgets('Disabled TextField statesController', (WidgetTester tester) async { + var count = 0; + void valueChanged() { + count += 1; + } + + final controller = MaterialStatesController(); + addTearDown(controller.dispose); + controller.addListener(valueChanged); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(statesController: controller, enabled: false)), + ), + ), + ); + expect(controller.value, <WidgetState>{WidgetState.disabled}); + expect(count, 1); + }); + + testWidgets('Provided style correctly resolves for material states', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + + final theme = ThemeData(); + + Widget buildFrame(bool enabled) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: TextField( + controller: controller, + enabled: enabled, + style: WidgetStateTextStyle.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.disabled)) { + return const TextStyle(color: Colors.red); + } + return const TextStyle(color: Colors.blue); + }), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(false)); + EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.red); + await tester.pumpWidget(buildFrame(true)); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, Colors.blue); + }); + + testWidgets('currentValueLength/maxValueLength are in the tree', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLength: 10)), + ), + ), + ); + + expect( + semantics, + includesNodeWith( + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + maxValueLength: 10, + ), + ); + + await tester.showKeyboard(find.byType(TextField)); + const testValue = '123'; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue, + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 0, end: testValue.length), + ), + ); + await tester.pump(); + + expect( + semantics, + includesNodeWith( + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + maxValueLength: 10, + currentValueLength: 3, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Read only TextField identifies as read only text field in semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField(maxLength: 10, readOnly: true))), + ), + ); + + expect( + semantics, + includesNodeWith( + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isReadOnly, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets("Disabled TextField can't be traversed to.", (WidgetTester tester) async { + final focusNode1 = FocusNode(debugLabel: 'TextField 1'); + addTearDown(focusNode1.dispose); + final focusNode2 = FocusNode(debugLabel: 'TextField 2'); + addTearDown(focusNode2.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: FocusScope( + child: Center( + child: Column( + children: <Widget>[ + TextField(focusNode: focusNode1, autofocus: true, maxLength: 10, enabled: true), + TextField(focusNode: focusNode2, maxLength: 10, enabled: false), + ], + ), + ), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + + expect(focusNode1.nextFocus(), isFalse); + await tester.pump(); + + expect(focusNode1.hasPrimaryFocus, isTrue); + expect(focusNode2.hasPrimaryFocus, isFalse); + }); + + group( + 'Keyboard Tests', + () { + late TextEditingController controller; + + setUp(() { + controller = _textEditingController(); + }); + + Future<void> setupWidget(WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + child: TextField(controller: controller, maxLines: 3), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('Shift test 1', (WidgetTester tester) async { + await setupWidget(tester); + const testValue = 'a big house'; + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + // Need to wait for selection to catch up. + await tester.pump(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.extentOffset - controller.selection.baseOffset, -1); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Shift test 2', (WidgetTester tester) async { + await setupWidget(tester); + + const testValue = 'abcdefghi'; + await tester.showKeyboard(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testValue, + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 0, end: testValue.length), + ), + ); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(controller.selection.extentOffset - controller.selection.baseOffset, 1); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Control Shift test', (WidgetTester tester) async { + await setupWidget(tester); + const testValue = 'their big house'; + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 5); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Down and up test', (WidgetTester tester) async { + await setupWidget(tester); + const testValue = 'a big house'; + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + // Need to wait for selection to catch up. + await tester.pump(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, -11); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Down and up test 2', (WidgetTester tester) async { + await setupWidget(tester); + const testValue = 'a big house\njumped over a mouse\nOne more line yay'; // 11 \n 19 + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 32); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 12); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, 0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + + expect(controller.selection.extentOffset - controller.selection.baseOffset, -5); + }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Read only keyboard selection test', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'readonly'); + await tester.pumpWidget(overlay(child: TextField(controller: controller, readOnly: true))); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + expect(controller.selection.extentOffset - controller.selection.baseOffset, -1); + }, variant: KeySimulatorTransitModeVariant.all()); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + ); + + testWidgets( + 'Copy paste test', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final TextEditingController controller = _textEditingController(); + final textField = TextField(controller: controller, maxLines: 3); + + var clipboardContent = ''; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'Clipboard.setData') { + // ignore: avoid_dynamic_calls + clipboardContent = methodCall.arguments['text'] as String; + } else if (methodCall.method == 'Clipboard.getData') { + return <String, dynamic>{'text': clipboardContent}; + } + return null; + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener(focusNode: focusNode, child: textField), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + const testValue = 'a big house\njumped over a mouse'; // 11 \n 19 + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Select the first 5 characters + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + // Copy them + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); + await tester.pumpAndSettle(); + + expect(clipboardContent, 'a big'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + + // Paste them + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); + await tester.pumpAndSettle(); + + const expected = 'a biga big house\njumped over a mouse'; + expect( + find.text(expected), + findsOneWidget, + reason: 'Because text contains ${controller.text}', + ); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + // Regression test for https://github.com/flutter/flutter/issues/78219 + testWidgets( + 'Paste does not crash after calling TextController.text setter', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final TextEditingController controller = _textEditingController(); + final textField = TextField(controller: controller, obscureText: true); + + const clipboardContent = 'I love Flutter!'; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'Clipboard.getData') { + return <String, dynamic>{'text': clipboardContent}; + } + return null; + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener(focusNode: focusNode, child: textField), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Clear the text. + controller.text = ''; + + // Paste clipboardContent to the text field. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); + await tester.pumpAndSettle(); + + // Clipboard content is correctly pasted. + expect(find.text(clipboardContent), findsOneWidget); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + testWidgets( + 'Cut test', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final TextEditingController controller = _textEditingController(); + final textField = TextField(controller: controller, maxLines: 3); + var clipboardContent = ''; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + if (methodCall.method == 'Clipboard.setData') { + // ignore: avoid_dynamic_calls + clipboardContent = methodCall.arguments['text'] as String; + } else if (methodCall.method == 'Clipboard.getData') { + return <String, dynamic>{'text': clipboardContent}; + } + return null; + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener(focusNode: focusNode, child: textField), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + const testValue = 'a big house\njumped over a mouse'; // 11 \n 19 + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Select the first 5 characters + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); + } + + // Cut them + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); + await tester.sendKeyEvent(LogicalKeyboardKey.keyX); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); + await tester.pumpAndSettle(); + + expect(clipboardContent, 'a big'); + + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + } + + // Paste them + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlRight); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlRight); + await tester.pumpAndSettle(); + + const expected = ' housa bige\njumped over a mouse'; + expect(find.text(expected), findsOneWidget); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + testWidgets( + 'Select all test', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final TextEditingController controller = _textEditingController(); + final textField = TextField(controller: controller, maxLines: 3); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener(focusNode: focusNode, child: textField), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + const testValue = 'a big house\njumped over a mouse'; // 11 \n 19 + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Select All + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pumpAndSettle(); + + // Delete them + await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + + const expected = ''; + expect(find.text(expected), findsOneWidget); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + testWidgets( + 'Delete test', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final TextEditingController controller = _textEditingController(); + final textField = TextField(controller: controller, maxLines: 3); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener(focusNode: focusNode, child: textField), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + const testValue = 'a big house\njumped over a mouse'; // 11 \n 19 + await tester.enterText(find.byType(TextField), testValue); + + await tester.idle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Delete + for (var i = 0; i < 6; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + } + + const expected = 'house\njumped over a mouse'; + expect(find.text(expected), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + + const expected2 = ''; + expect(find.text(expected2), findsOneWidget); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + testWidgets( + 'Changing positions of text fields', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final events = <KeyEvent>[]; + + final TextEditingController c1 = _textEditingController(); + final TextEditingController c2 = _textEditingController(); + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + onKeyEvent: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + TextField(key: key1, controller: c1, maxLines: 3), + TextField(key: key2, controller: c2, maxLines: 3), + ], + ), + ), + ), + ), + ); + + const testValue = 'a big house'; + await tester.enterText(find.byType(TextField).first, testValue); + + await tester.idle(); + // Need to wait for selection to catch up. + await tester.pump(); + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + onKeyEvent: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + TextField(key: key2, controller: c2, maxLines: 3), + TextField(key: key1, controller: c1, maxLines: 3), + ], + ), + ), + ), + ), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -10); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + testWidgets( + 'Changing focus test', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + final events = <KeyEvent>[]; + + final TextEditingController c1 = _textEditingController(); + final TextEditingController c2 = _textEditingController(); + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: KeyboardListener( + focusNode: focusNode, + onKeyEvent: events.add, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: <Widget>[ + TextField(key: key1, controller: c1, maxLines: 3), + TextField(key: key2, controller: c2, maxLines: 3), + ], + ), + ), + ), + ), + ); + + const testValue = 'a big house'; + await tester.enterText(find.byType(TextField).first, testValue); + await tester.idle(); + await tester.pump(); + + await tester.idle(); + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); + expect(c2.selection.extentOffset - c2.selection.baseOffset, 0); + + await tester.enterText(find.byType(TextField).last, testValue); + await tester.idle(); + await tester.pump(); + + await tester.idle(); + await tester.tap(find.byType(TextField).last); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + for (var i = 0; i < 5; i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + expect(c1.selection.extentOffset - c1.selection.baseOffset, -5); + expect(c2.selection.extentOffset - c2.selection.baseOffset, -5); + }, + // [intended] only applies to platforms where we handle key events. + skip: areKeyEventsHandledByPlatform, + variant: KeySimulatorTransitModeVariant.all(), + ); + + testWidgets('Caret works when maxLines is null', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget(overlay(child: TextField(controller: controller, maxLines: null))); + + const testValue = 'x'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.length); + + // Tap the selection handle to bring up the "paste / select all" menu. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(); + await tester.pump( + const Duration(milliseconds: 200), + ); // skip past the frame where the opacity is + + // Confirm that the selection was updated. + expect(controller.selection.baseOffset, 0); + }); + + testWidgets('TextField baseline alignment no-strut', (WidgetTester tester) async { + final TextEditingController controllerA = _textEditingController(text: 'A'); + final TextEditingController controllerB = _textEditingController(text: 'B'); + final Key keyA = UniqueKey(); + final Key keyB = UniqueKey(); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + Expanded( + child: TextField( + key: keyA, + decoration: null, + controller: controllerA, + // The point size of the font must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 12.0), + strutStyle: StrutStyle.disabled, + ), + ), + const Text( + 'abc', + // The point size of the font must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + style: TextStyle(fontFamily: 'FlutterTest', fontSize: 24.0), + ), + Expanded( + child: TextField( + key: keyB, + decoration: null, + controller: controllerB, + // The point size of the font must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 36.0), + strutStyle: StrutStyle.disabled, + ), + ), + ], + ), + ), + ), + ); + + // The test font extends 0.25 * fontSize below the baseline. + // So the three row elements line up like this: + // + // A abc B + // --------- baseline + // 3 6 9 space below the baseline = 0.25 * fontSize + // --------- rowBottomY + + final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; + expect(tester.getBottomLeft(find.byKey(keyA)).dy, rowBottomY - 6.0); + expect(tester.getBottomLeft(find.text('abc')).dy, rowBottomY - 3.0); + expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); + }); + + testWidgets('TextField baseline alignment', (WidgetTester tester) async { + final TextEditingController controllerA = _textEditingController(text: 'A'); + final TextEditingController controllerB = _textEditingController(text: 'B'); + final Key keyA = UniqueKey(); + final Key keyB = UniqueKey(); + + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: overlay( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + Expanded( + child: TextField( + key: keyA, + decoration: null, + controller: controllerA, + // The point size of the font must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 12.0), + ), + ), + const Text( + 'abc', + // The point size of the font must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + style: TextStyle(fontFamily: 'FlutterTest', fontSize: 24.0), + ), + Expanded( + child: TextField( + key: keyB, + decoration: null, + controller: controllerB, + // The point size of the font must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + style: const TextStyle(fontFamily: 'FlutterTest', fontSize: 36.0), + ), + ), + ], + ), + ), + ), + ); + + // The test font extends 0.25 * fontSize below the baseline. + // So the three row elements line up like this: + // + // A abc B + // --------- baseline + // 3 6 9 space below the baseline = 0.25 * fontSize + // --------- rowBottomY + + final double rowBottomY = tester.getBottomLeft(find.byType(Row)).dy; + // The values here should match the version with strut disabled ('TextField baseline alignment no-strut') + expect(tester.getBottomLeft(find.byKey(keyA)).dy, rowBottomY - 6.0); + expect(tester.getBottomLeft(find.text('abc')).dy, rowBottomY - 3.0); + expect(tester.getBottomLeft(find.byKey(keyB)).dy, rowBottomY); + }); + + testWidgets( + 'TextField semantics include label when unfocused and label/hint when focused if input is empty', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration(hintText: 'hint', labelText: 'label'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + + expect(node.label, 'label'); + expect(node.value, ''); + + // Focus text field. + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(node.label, 'label'); + expect(node.value, ''); + semantics.dispose(); + }, + ); + + testWidgets( + 'TextField semantics always include label and not hint when input value is not empty', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(text: 'value'); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration(hintText: 'hint', labelText: 'label'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + + expect(node.label, 'label'); + expect(node.value, 'value'); + + // Focus text field. + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(node.label, 'label'); + expect(node.value, 'value'); + semantics.dispose(); + }, + ); + + testWidgets('TextField semantics always include label when no hint is given', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(text: 'value'); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration(labelText: 'label'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + + expect(node.label, 'label'); + expect(node.value, 'value'); + + // Focus text field. + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(node.label, 'label'); + expect(node.value, 'value'); + semantics.dispose(); + }); + + testWidgets('TextField semantics only include hint when it is visible', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(text: 'value'); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration(hintText: 'hint'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + + expect(node.label, ''); + expect(node.value, 'value'); + + // Focus text field. + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(node.label, ''); + expect(node.value, 'value'); + + // Clear the Text. + await tester.enterText(find.byType(TextField), ''); + await tester.pumpAndSettle(); + + expect(node.value, ''); + expect(node.label, 'hint'); + + semantics.dispose(); + }); + + testWidgets('TextField semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField(key: key, controller: controller), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.text = 'Guten Tag'; + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + value: 'Guten Tag', + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + value: 'Guten Tag', + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + textSelection: const TextSelection.collapsed(offset: 9), + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.selection = const TextSelection.collapsed(offset: 4); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 4), + value: 'Guten Tag', + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.text = 'Schönen Feierabend'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + textDirection: TextDirection.ltr, + textSelection: const TextSelection.collapsed(offset: 0), + value: 'Schönen Feierabend', + inputType: ui.SemanticsInputType.text, + currentValueLength: 18, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + // Regressing test for https://github.com/flutter/flutter/issues/99763 + testWidgets('Update textField semantics when obscureText changes', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget(_ObscureTextTestWidget(controller: controller)); + + controller.text = 'Hello'; + await tester.pump(); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + value: 'Hello', + inputType: ui.SemanticsInputType.text, + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isObscured, + ], + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect( + semantics, + includesNodeWith( + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + value: 'Hello', + inputType: ui.SemanticsInputType.text, + ), + ); + + semantics.dispose(); + }); + + testWidgets('TextField semantics, enableInteractiveSelection = false', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField(key: key, controller: controller, enableInteractiveSelection: false), + ), + ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics.rootChild( + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.setText, + // Absent the following because enableInteractiveSelection: false + // SemanticsAction.moveCursorBackwardByCharacter, + // SemanticsAction.moveCursorBackwardByWord, + // SemanticsAction.setSelection, + // SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('TextField semantics for selections', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController()..text = 'Hello'; + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField(key: key, controller: controller), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Hello', + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + // Focus the text field + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Hello', + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.selection = const TextSelection(baseOffset: 5, extentOffset: 3); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + value: 'Hello', + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textSelection: const TextSelection(baseOffset: 5, extentOffset: 3), + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.moveCursorForwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + SemanticsAction.cut, + SemanticsAction.copy, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('TextField change selection with semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final TextEditingController controller = _textEditingController()..text = 'Hello'; + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField(key: key, controller: controller), + ), + ); + + // Focus the text field + await tester.tap(find.byKey(key)); + await tester.pump(); + + const inputFieldId = 2; + + expect( + controller.selection, + const TextSelection.collapsed(offset: 5, affinity: TextAffinity.upstream), + ); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + value: 'Hello', + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textSelection: const TextSelection.collapsed(offset: 5), + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + // move cursor back once + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ + 'base': 4, + 'extent': 4, + }); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + + // move cursor to front + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ + 'base': 0, + 'extent': 0, + }); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + + // select all + semanticsOwner.performAction(inputFieldId, SemanticsAction.setSelection, <dynamic, dynamic>{ + 'base': 0, + 'extent': 5, + }); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + value: 'Hello', + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textSelection: const TextSelection(baseOffset: 0, extentOffset: 5), + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + SemanticsAction.cut, + SemanticsAction.copy, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Can activate TextField with explicit controller via semantics ', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/17801 + + const textInTextField = 'Hello'; + + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final TextEditingController controller = _textEditingController()..text = textInTextField; + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField(key: key, controller: controller), + ), + ); + + const inputFieldId = 2; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + value: textInTextField, + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semanticsOwner.performAction(inputFieldId, SemanticsAction.tap); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + value: textInTextField, + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textDirection: TextDirection.ltr, + textSelection: const TextSelection( + baseOffset: textInTextField.length, + extentOffset: textInTextField.length, + ), + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('When clipboard empty, no semantics paste option', (WidgetTester tester) async { + const textInTextField = 'Hello'; + + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final TextEditingController controller = _textEditingController()..text = textInTextField; + final Key key = UniqueKey(); + + // Clear the clipboard. + await Clipboard.setData(const ClipboardData(text: '')); + + await tester.pumpWidget( + overlay( + child: TextField(key: key, controller: controller), + ), + ); + + const inputFieldId = 2; + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + value: textInTextField, + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semanticsOwner.performAction(inputFieldId, SemanticsAction.tap); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: inputFieldId, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.moveCursorBackwardByWord, + SemanticsAction.setSelection, + SemanticsAction.setText, + // No paste option. + ], + value: textInTextField, + inputType: ui.SemanticsInputType.text, + currentValueLength: 5, + textDirection: TextDirection.ltr, + textSelection: const TextSelection( + baseOffset: textInTextField.length, + extentOffset: textInTextField.length, + ), + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + + // On web, we don't check for pasteability because that triggers a + // permission dialog in the browser. + // https://github.com/flutter/flutter/pull/57139#issuecomment-629048058 + }, skip: isBrowser); // [intended] see above. + + testWidgets('TextField throws when not descended from a Material widget', ( + WidgetTester tester, + ) async { + const Widget textField = TextField(); + await tester.pumpWidget(textField); + final dynamic exception = tester.takeException(); + expect(exception, isFlutterError); + expect(exception.toString(), startsWith('No Material widget found.')); + }); + + testWidgets('TextField loses focus when disabled', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'TextField Focus Node'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + boilerplate(child: TextField(focusNode: focusNode, autofocus: true, enabled: true)), + ); + expect(focusNode.hasFocus, isTrue); + + await tester.pumpWidget( + boilerplate(child: TextField(focusNode: focusNode, autofocus: true, enabled: false)), + ); + expect(focusNode.hasFocus, isFalse); + + await tester.pumpWidget( + boilerplate( + child: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(navigationMode: NavigationMode.directional), + child: TextField(focusNode: focusNode, autofocus: true, enabled: true), + ); + }, + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + + await tester.pumpWidget( + boilerplate( + child: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(navigationMode: NavigationMode.directional), + child: TextField(focusNode: focusNode, autofocus: true, enabled: false), + ); + }, + ), + ), + ); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('TextField displays text with text direction', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material(child: TextField(textDirection: TextDirection.rtl)), + ), + ); + + RenderEditable editable = findRenderEditable(tester); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + await tester.pumpAndSettle(); + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 10)).topLeft, + ); + + expect(topLeft.dx, equals(701)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material(child: TextField(textDirection: TextDirection.ltr)), + ), + ); + + editable = findRenderEditable(tester); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + await tester.pumpAndSettle(); + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 10)).topLeft, + ); + + expect(topLeft.dx, equals(160.0)); + }); + + testWidgets('TextField semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + maxLength: 10, + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + helperText: 'helper', + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + label: 'label', + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + maxValueLength: 10, + currentValueLength: 0, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + children: <TestSemantics>[ + TestSemantics(label: 'helper', textDirection: TextDirection.ltr), + TestSemantics( + label: '10 characters remaining', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics.rootChild( + label: 'label', + textDirection: TextDirection.ltr, + textSelection: const TextSelection(baseOffset: 0, extentOffset: 0), + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + maxValueLength: 10, + actions: <SemanticsAction>[ + SemanticsAction.tap, + SemanticsAction.focus, + SemanticsAction.setSelection, + SemanticsAction.setText, + SemanticsAction.paste, + ], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocused, + ], + children: <TestSemantics>[ + TestSemantics(id: 2, label: 'helper', textDirection: TextDirection.ltr), + TestSemantics( + label: '10 characters remaining', + flags: <SemanticsFlag>[SemanticsFlag.isLiveRegion], + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + controller.text = 'hello'; + await tester.pump(); + semantics.dispose(); + }); + + testWidgets('InputDecoration counterText can have a semanticCounterText', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + helperText: 'helper', + counterText: '0/10', + semanticCounterText: '0 out of 10', + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + label: 'label', + textDirection: TextDirection.ltr, + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + children: <TestSemantics>[ + TestSemantics(label: 'helper', textDirection: TextDirection.ltr), + TestSemantics(label: '0 out of 10', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + for (final supportsAnnounce in <bool>[true, false]) { + testWidgets('InputDecoration errorText semantics (supportsAnnounce=$supportsAnnounce)', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: MediaQuery( + data: MediaQueryData(supportsAnnounce: supportsAnnounce), + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration( + labelText: 'label', + hintText: 'hint', + errorText: 'oh no!', + ), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics.rootChild( + label: 'label', + hint: 'oh no!', + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + children: <TestSemantics>[ + TestSemantics( + label: 'oh no!', + textDirection: TextDirection.ltr, + flags: <SemanticsFlag>[if (!supportsAnnounce) SemanticsFlag.isLiveRegion], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + } + + testWidgets('TextField hintText is not duplicated in semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration(labelText: 'Search', hintText: 'Search Google Pay'), + ), + ), + ), + ), + ); + + // Focus the text field so the hint Text widget becomes visible + // and merges into the semantics tree. + await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + + final SemanticsNode node = tester.getSemantics(find.byType(EditableText)); + expect(node.label, contains('Search Google Pay')); + expect(node.hint, isEmpty); + + semantics.dispose(); + }); + + testWidgets('TextField passes errorText to semantics hint', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + overlay( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration(labelText: 'Email', errorText: 'Email is required'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + expect(node.hint, 'Email is required'); + + semantics.dispose(); + }); + + // When both errorText and hintText are present, they should end up in + // separate semantics properties: hintText in label (via Text widget merge), + // errorText in hint (via explicit Semantics wrapper). + testWidgets('TextField errorText and hintText do not concatenate in semantics', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final TextEditingController controller = _textEditingController(); + final Key key = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + child: TextField( + key: key, + controller: controller, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'Enter your email', + errorText: 'Email is required', + ), + ), + ), + ), + ), + ); + + // Focus the text field so the hint Text widget becomes visible. + await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + + final SemanticsNode node = tester.getSemantics(find.byType(EditableText)); + // hintText merges into label via the Text widget's markAsMergeUp. + expect(node.label, contains('Enter your email')); + // errorText is in the hint property via the explicit Semantics wrapper. + expect(node.hint, 'Email is required'); + + semantics.dispose(); + }); + + testWidgets('floating label does not overlap with value at large textScaleFactors', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(text: 'Just some text'); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(4.0)), + child: Center( + child: TextField( + decoration: const InputDecoration( + labelText: 'Label', + border: UnderlineInputBorder(), + ), + controller: controller, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + final Rect labelRect = tester.getRect(find.text('Label')); + final Rect fieldRect = tester.getRect(find.text('Just some text')); + expect(labelRect.bottom, lessThanOrEqualTo(fieldRect.top)); + }); + + testWidgets('TextField scrolls into view but does not bounce (SingleChildScrollView)', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/20485 + + final Key textField1 = UniqueKey(); + final Key textField2 = UniqueKey(); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + double? minOffset; + double? maxOffset; + + scrollController.addListener(() { + final double offset = scrollController.offset; + minOffset = math.min(minOffset ?? offset, offset); + maxOffset = math.max(maxOffset ?? offset, offset); + }); + + Widget buildFrame(Axis scrollDirection) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + controller: scrollController, + child: Column( + children: <Widget>[ + SizedBox( + // visible when scrollOffset is 0.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField1, scrollPadding: const EdgeInsets.all(200.0)), + ), + const SizedBox( + height: 600.0, // Same size as the frame. Initially + width: 800.0, // textField2 is not visible + ), + SizedBox( + // visible when scrollOffset is 200.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField2, scrollPadding: const EdgeInsets.all(200.0)), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Axis.vertical)); + await tester.enterText(find.byKey(textField1), '1'); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 200.0); + + minOffset = null; + maxOffset = null; + + await tester.pumpWidget(buildFrame(Axis.horizontal)); + await tester.enterText(find.byKey(textField1), '1'); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 200.0); + }); + + testWidgets('TextField scrolls into view but does not bounce (ListView)', ( + WidgetTester tester, + ) async { + // This is a regression test for https://github.com/flutter/flutter/issues/20485 + + final Key textField1 = UniqueKey(); + final Key textField2 = UniqueKey(); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + double? minOffset; + double? maxOffset; + + scrollController.addListener(() { + final double offset = scrollController.offset; + minOffset = math.min(minOffset ?? offset, offset); + maxOffset = math.max(maxOffset ?? offset, offset); + }); + + Widget buildFrame(Axis scrollDirection) { + return MaterialApp( + home: Scaffold( + body: SafeArea( + child: ListView( + physics: const BouncingScrollPhysics(), + controller: scrollController, + children: <Widget>[ + SizedBox( + // visible when scrollOffset is 0.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField1, scrollPadding: const EdgeInsets.all(200.0)), + ), + const SizedBox( + height: 450.0, // 50.0 smaller than the overall frame so that both + width: 650.0, // textfields are always partially visible. + ), + SizedBox( + // visible when scrollOffset = 50.0 + height: 100.0, + width: 100.0, + child: TextField(key: textField2, scrollPadding: const EdgeInsets.all(200.0)), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Axis.vertical)); + await tester.enterText(find.byKey(textField1), '1'); // textfield1 is visible + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 50.0); + + minOffset = null; + maxOffset = null; + + await tester.pumpWidget(buildFrame(Axis.horizontal)); + await tester.enterText(find.byKey(textField1), '1'); // textfield1 is visible + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField2), '2'); //scroll textField2 into view + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(textField1), '3'); //scroll textField1 back into view + await tester.pumpAndSettle(); + + expect(minOffset, 0.0); + expect(maxOffset, 50.0); + }); + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + overlay( + child: TextField( + onTap: () { + tapCount += 1; + }, + ), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(TextField)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + testWidgets('onTap is not called, field is disabled', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + overlay( + child: TextField( + enabled: false, + onTap: () { + tapCount += 1; + }, + ), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(TextField)); + await tester.tap(find.byType(TextField)); + await tester.tap(find.byType(TextField)); + expect(tapCount, 0); + }); + + testWidgets('Includes cursor for TextField', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/issues/24612 + + Widget buildFrame({ + double? stepWidth, + required double cursorWidth, + required TextAlign textAlign, + }) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + IntrinsicWidth( + stepWidth: stepWidth, + child: TextField(textAlign: textAlign, cursorWidth: cursorWidth), + ), + ], + ), + ), + ), + ); + } + + // A cursor of default size doesn't cause the TextField to increase its + // width. + const text = '1234'; + double? stepWidth = 80.0; + await tester.pumpWidget( + buildFrame(stepWidth: 80.0, cursorWidth: 2.0, textAlign: TextAlign.left), + ); + await tester.enterText(find.byType(TextField), text); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(TextField)).width, stepWidth); + + // A wide cursor is counted in the width of the text and causes the + // TextField to increase to twice the stepWidth. + await tester.pumpWidget( + buildFrame(stepWidth: stepWidth, cursorWidth: 18.0, textAlign: TextAlign.left), + ); + await tester.enterText(find.byType(TextField), text); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(TextField)).width, 2 * stepWidth); + + // A null stepWidth causes the TextField to perfectly wrap the text plus + // the cursor regardless of alignment. + stepWidth = null; + const WIDTH_OF_CHAR = 16.0; + const CARET_GAP = 1.0; + await tester.pumpWidget( + buildFrame(stepWidth: stepWidth, cursorWidth: 18.0, textAlign: TextAlign.left), + ); + await tester.enterText(find.byType(TextField), text); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byType(TextField)).width, + WIDTH_OF_CHAR * text.length + 18.0 + CARET_GAP, + ); + await tester.pumpWidget( + buildFrame(stepWidth: stepWidth, cursorWidth: 18.0, textAlign: TextAlign.right), + ); + await tester.enterText(find.byType(TextField), text); + await tester.pumpAndSettle(); + expect( + tester.getSize(find.byType(TextField)).width, + WIDTH_OF_CHAR * text.length + 18.0 + CARET_GAP, + ); + }); + + testWidgets('TextField style is merged with theme', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/23994 + + final themeData = ThemeData( + useMaterial3: false, + textTheme: TextTheme(titleMedium: TextStyle(color: Colors.blue[500])), + ); + + Widget buildFrame(TextStyle style) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center(child: TextField(style: style)), + ), + ); + } + + // Empty TextStyle is overridden by theme + await tester.pumpWidget(buildFrame(const TextStyle())); + EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, themeData.textTheme.titleMedium!.color); + expect(editableText.style.background, themeData.textTheme.titleMedium!.background); + expect(editableText.style.shadows, themeData.textTheme.titleMedium!.shadows); + expect(editableText.style.decoration, themeData.textTheme.titleMedium!.decoration); + expect(editableText.style.locale, themeData.textTheme.titleMedium!.locale); + expect(editableText.style.wordSpacing, themeData.textTheme.titleMedium!.wordSpacing); + + // Properties set on TextStyle override theme + const Color setColor = Colors.red; + await tester.pumpWidget(buildFrame(const TextStyle(color: setColor))); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, setColor); + + // inherit: false causes nothing to be merged in from theme + await tester.pumpWidget( + buildFrame( + const TextStyle(fontSize: 24.0, textBaseline: TextBaseline.alphabetic, inherit: false), + ), + ); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, isNull); + }); + + testWidgets('TextField style is merged with theme in Material 3', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/23994 + + final themeData = ThemeData( + textTheme: TextTheme(bodyLarge: TextStyle(color: Colors.blue[500])), + ); + + Widget buildFrame(TextStyle style) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center(child: TextField(style: style)), + ), + ); + } + + // Empty TextStyle is overridden by theme + await tester.pumpWidget(buildFrame(const TextStyle())); + EditableText editableText = tester.widget(find.byType(EditableText)); + + // According to material 3 spec, the input text should be the color of onSurface. + // https://github.com/flutter/flutter/issues/107686 is tracking this issue. + expect(editableText.style.color, themeData.textTheme.bodyLarge!.color); + + expect(editableText.style.background, themeData.textTheme.bodyLarge!.background); + expect(editableText.style.shadows, themeData.textTheme.bodyLarge!.shadows); + expect(editableText.style.decoration, themeData.textTheme.bodyLarge!.decoration); + expect(editableText.style.locale, themeData.textTheme.bodyLarge!.locale); + expect(editableText.style.wordSpacing, themeData.textTheme.bodyLarge!.wordSpacing); + + // Properties set on TextStyle override theme + const Color setColor = Colors.red; + await tester.pumpWidget(buildFrame(const TextStyle(color: setColor))); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, setColor); + + // inherit: false causes nothing to be merged in from theme + await tester.pumpWidget( + buildFrame( + const TextStyle(fontSize: 24.0, textBaseline: TextBaseline.alphabetic, inherit: false), + ), + ); + editableText = tester.widget(find.byType(EditableText)); + expect(editableText.style.color, isNull); + }); + + testWidgets( + 'selection handles color respects Theme', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/74890. + const expectedSelectionHandleColor = Color.fromARGB(255, 10, 200, 255); + + final controller = TextEditingController(text: 'Some text.'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: const TextSelectionThemeData(selectionHandleColor: Colors.red), + ), + home: Material( + child: Theme( + data: ThemeData( + textSelectionTheme: const TextSelectionThemeData( + selectionHandleColor: expectedSelectionHandleColor, + ), + ), + child: TextField(controller: controller), + ), + ), + ), + ); + + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + final Iterable<RenderBox> boxes = tester.renderObjectList<RenderBox>( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), + matching: find.byType(CustomPaint), + ), + ); + expect(boxes.length, 2); + + for (final box in boxes) { + expect(box, paints..path(color: expectedSelectionHandleColor)); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets('style enforces required fields', (WidgetTester tester) async { + Widget buildFrame(TextStyle style) { + return MaterialApp( + home: Material(child: TextField(style: style)), + ); + } + + await tester.pumpWidget( + buildFrame( + const TextStyle(inherit: false, fontSize: 12.0, textBaseline: TextBaseline.alphabetic), + ), + ); + expect(tester.takeException(), isNull); + + // With inherit not set to false, will pickup required fields from theme + await tester.pumpWidget(buildFrame(const TextStyle(fontSize: 12.0))); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(buildFrame(const TextStyle(inherit: false, fontSize: 12.0))); + expect(tester.takeException(), isNotNull); + }); + + testWidgets( + 'tap moves cursor to the edge of the word it tapped', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + + // We moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + + // But don't trigger the toolbar. + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'tap with a mouse does not move cursor to the edge of the word', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture( + textfieldStart + const Offset(50.0, 9.0), + pointer: 1, + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + + // Cursor at tap position, not at word edge. + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'tap moves cursor to the position tapped', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + + // We moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + + // But don't trigger the toolbar. + expectNoMaterialToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'two slow taps do not trigger a word selection', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset pos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r'. + + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(pos); + await tester.pump(); + + // Plain collapsed selection. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformMobile ? 7 : 6); + + // Toolbar shows on mobile only. + if (isTargetPlatformMobile) { + expectCupertinoToolbarForCollapsedSelection(); + } else { + // After a tap, macOS does not show a selection toolbar for a collapsed selection. + expectNoCupertinoToolbar(); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Tapping on a collapsed selection toggles the toolbar', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neigse Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLines: 2)), + ), + ), + ); + + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final Offset begPos = textOffsetToPosition(tester, 0); + final Offset endPos = + textOffsetToPosition(tester, 35) + + const Offset( + 200.0, + 0.0, + ); // Index of 'Bonaventure|' + Offset(200.0,0), which is at the end of the first line. + final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'. + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'. + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(wPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(vPos); + await tester.pump(const Duration(milliseconds: 500)); + // First tap moved the cursor. Here we tap the position where 'v' is located. + // On iOS this will select the closest word edge, in this case the cursor is placed + // at the end of the word 'Bonaventure|'. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expectNoCupertinoToolbar(); + + await tester.tapAt(vPos); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + // Second tap toggles the toolbar. Here we tap on 'v' again, and select the word edge. Since + // the selection has not changed we toggle the toolbar. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expectCupertinoToolbarForCollapsedSelection(); + + // Tap the 'v' position again to hide the toolbar. + await tester.tapAt(vPos); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expectNoCupertinoToolbar(); + + // Long press at the end of the first line to move the cursor to the end of the first line + // where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR, + // the TextAffinity will be upstream and against the natural direction. The toolbar is also + // shown after a long press. + await tester.longPressAt(endPos); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 46); + expect(controller.selection.affinity, TextAffinity.upstream); + expectCupertinoToolbarForCollapsedSelection(); + + // Tap at the same position to toggle the toolbar. + await tester.tapAt(endPos); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 46); + expect(controller.selection.affinity, TextAffinity.upstream); + expectNoCupertinoToolbar(); + + // Tap at the beginning of the second line to move the cursor to the front of the first word on the + // second line, where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR, + // the TextAffinity will be downstream and following the natural direction. The toolbar will be hidden after this tap. + await tester.tapAt(begPos + Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 46); + expect(controller.selection.affinity, TextAffinity.downstream); + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'Tapping on a non-collapsed selection toggles the toolbar and retains the selection', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'. + final Offset ePos = + textOffsetToPosition(tester, 35) + + const Offset( + 7.0, + 0.0, + ); // Index of 'Bonaventure|' + Offset(7.0,0), which taps slightly to the right of the end of the text. + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'. + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(wPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(vPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + await tester.tapAt(vPos); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 24, extentOffset: 35)); + + // The toolbar shows up. + expectCupertinoToolbarForPartialSelection(); + + // Tap the selected word to hide the toolbar and retain the selection. + await tester.tapAt(vPos); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 24, extentOffset: 35)); + expectNoCupertinoToolbar(); + + // Tap the selected word to show the toolbar and retain the selection. + await tester.tapAt(vPos); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 24, extentOffset: 35)); + expectCupertinoToolbarForPartialSelection(); + + // Tap past the selected word to move the cursor and hide the toolbar. + await tester.tapAt(ePos); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 35); + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor (iOS)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'. + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'. + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(wPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + await tester.tapAt(pPos); + await tester.pumpAndSettle(); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // The toolbar shows up. + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('iOS selectWordEdge works correctly', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + final Offset pos1 = textOffsetToPosition(tester, 1); + TestGesture gesture = await tester.startGesture(pos1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 5, affinity: TextAffinity.upstream), + ); + + final Offset pos0 = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(pos0); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets( + 'double tap does not select word on read-only obscured field', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(obscureText: true, readOnly: true, controller: controller), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 35)); + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + + // Second tap doesn't select anything. + expect(controller.selection, const TextSelection.collapsed(offset: 35)); + + // Selected text shows no toolbar. + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap selects word and first tap of double tap moves cursor and shows toolbar', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + // This tap just puts the cursor somewhere different than where the double + // tap will occur to test that the double tap moves the existing cursor first. + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + + // Second tap selects the word around the cursor. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // The toolbar shows up. + expectMaterialToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Custom toolbar test - Android text selection controls', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + selectionControls: materialTextSelectionControls, + ), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + + // Selected text shows 4 toolbar buttons: cut, copy, paste, select all + expect(find.byType(TextButton), findsNWidgets(4)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + }, + variant: TargetPlatformVariant.all(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Custom toolbar test - Cupertino text selection controls', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + selectionControls: cupertinoTextSelectionControls, + ), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + + // Selected text shows 3 toolbar buttons: cut, copy, paste + expect(find.byType(CupertinoButton), findsNWidgets(3)); + }, + variant: TargetPlatformVariant.all(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('selectionControls is passed to EditableText', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Scaffold(body: TextField(selectionControls: materialTextSelectionControls)), + ), + ), + ); + + final EditableText widget = tester.widget(find.byType(EditableText)); + expect(widget.selectionControls, equals(materialTextSelectionControls)); + }); + + testWidgets('Can double click + drag with a mouse to select word by word', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + }); + + testWidgets('Can double tap + drag to select word by word', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + // Toolbar should be hidden during a drag. + expectNoMaterialToolbar(); + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + + // Toolbar should re-appear after a drag. + await gesture.up(); + await tester.pump(); + expectMaterialToolbarForPartialSelection(); + }); + + group('Triple tap/click', () { + const testValueA = + 'Now is the time for\n' // 20 + 'all good people\n' // 20 + 16 => 36 + 'to come to the aid\n' // 36 + 19 => 55 + 'of their country.'; // 55 + 17 => 72 + const testValueB = + 'Today is the time for\n' // 22 + 'all good people\n' // 22 + 16 => 38 + 'to come to the aid\n' // 38 + 19 => 57 + 'of their country.'; // 57 + 17 => 74 + testWidgets( + 'Can triple tap to select a paragraph on mobile platforms when tapping at a word edge', + (WidgetTester tester) async { + // TODO(Renzo-Olivares): Enable for iOS, currently broken because selection overlay blocks the TextSelectionGestureDetector https://github.com/flutter/flutter/issues/123415. + final TextEditingController controller = _textEditingController(); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueA); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 6); + + // Tap on text field to gain focus, and set selection to 'is|' on the first line. + final TestGesture gesture = await tester.startGesture(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 6); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, isTargetPlatformApple ? 4 : 6); + expect(controller.selection.extentOffset, isTargetPlatformApple ? 6 : 7); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'Can triple tap to select a paragraph on mobile platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueB); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueB); + + final Offset firstLinePos = + tester.getTopLeft(find.byType(TextField)) + const Offset(50.0, 9.0); + + // Tap on text field to gain focus, and move the selection. + final TestGesture gesture = await tester.startGesture(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformApple ? 5 : 3); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 5); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 22); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets( + 'Triple click at the beginning of a line should not select the previous paragraph', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/132126 + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueB); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueB); + + final Offset thirdLinePos = textOffsetToPosition(tester, 38); + + // Click on text field to gain focus, and move the selection. + final TestGesture gesture = await tester.startGesture( + thirdLinePos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 38); + + // Here we click on same position again, to register a double click. This will select + // the word at the clicked position. + await gesture.down(thirdLinePos); + await gesture.up(); + + expect(controller.selection.baseOffset, 38); + expect(controller.selection.extentOffset, 40); + + // Here we click on same position again, to register a triple click. This will select + // the paragraph at the clicked position. + await gesture.down(thirdLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 38); + expect(controller.selection.extentOffset, 57); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'Triple click at the end of text should select the previous paragraph', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/132126. + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueB); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueB); + + final Offset endOfTextPos = textOffsetToPosition(tester, 74); + + // Click on text field to gain focus, and move the selection. + final TestGesture gesture = await tester.startGesture( + endOfTextPos, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 74); + + // Here we click on same position again, to register a double click. + await gesture.down(endOfTextPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 74); + expect(controller.selection.extentOffset, 74); + + // Here we click on same position again, to register a triple click. This will select + // the paragraph at the clicked position. + await gesture.down(endOfTextPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 57); + expect(controller.selection.extentOffset, 74); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'triple tap chains work on Non-Apple mobile platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 3); + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectMaterialToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + // Triple tap selecting the same paragraph somewhere else is fine. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap hides the toolbar and moves the selection. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 6); + expectNoMaterialToolbar(); + // Second tap shows the toolbar and selects the word. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectMaterialToolbarForPartialSelection(); + + // Third tap shows the toolbar and selects the paragraph. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + expectMaterialToolbarForFullSelection(); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor and hid the toolbar. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 9); + expectNoMaterialToolbar(); + // Second tap selects the word. + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectMaterialToolbarForPartialSelection(); + + // Third tap selects the paragraph and shows the toolbar. + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 35)); + expectMaterialToolbarForFullSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'triple tap chains work on Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure\nThe fox jumped over the fence.', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 36)); + // Triple tap selecting the same paragraph somewhere else is fine. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap hides the toolbar and retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 36)); + expectNoCupertinoToolbar(); + // Second tap shows the toolbar and selects the word. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + // Third tap shows the toolbar and selects the paragraph. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 36)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(150.0, 50.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor and hid the toolbar. + expect( + controller.selection, + const TextSelection.collapsed(offset: 50, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + // Second tap selects the word. + await tester.tapAt(textfieldStart + const Offset(150.0, 50.0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 44, extentOffset: 50)); + expectCupertinoToolbarForPartialSelection(); + + // Third tap selects the paragraph and shows the toolbar. + await tester.tapAt(textfieldStart + const Offset(150.0, 50.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 36, extentOffset: 66)); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('triple click chains work', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueA); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + final platformSelectsByLine = defaultTargetPlatform == TargetPlatform.linux; + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(210.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 13); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so the next + // tap is not detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + // Triple click selecting the same paragraph somewhere else is fine. + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First click moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Second click selected the word. + expect(controller.selection, const TextSelection(baseOffset: 6, extentOffset: 7)); + + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so the tap count + // is reset. + await tester.pumpAndSettle(kDoubleTapTimeout); + // Third click selected the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First click moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Second click selected the word. + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 10)); + + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Third click selects the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('triple click after a click on desktop platforms', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueA); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + final platformSelectsByLine = defaultTargetPlatform == TargetPlatform.linux; + + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(50.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + // First click moves the selection. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + + // Double click selection to select a word. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 10)); + + // Triple click selection to select a paragraph. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'Can triple tap to select all on a single-line textfield on mobile platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueB); + final isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS; + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final Offset firstLinePos = + tester.getTopLeft(find.byType(TextField)) + const Offset(50.0, 9.0); + + // Tap on text field to gain focus, and set selection somewhere on the first word. + final TestGesture gesture = await tester.startGesture(firstLinePos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformApple ? 5 : 3); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 5); + + // Here we tap on same position again, to register a triple tap. This will select + // the entire text field if it is a single-line field. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 74); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets( + 'Can triple click to select all on a single-line textfield on desktop platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueA); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(dragStartBehavior: DragStartBehavior.down, controller: controller), + ), + ), + ); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on same position again, to register a triple tap. This will select + // the entire text field if it is a single-line field. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 72); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'Can triple click to select a line on Linux', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueA); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 19); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + testWidgets( + 'Can triple click to select a paragraph', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueA); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'Can triple click + drag to select line by line on Linux', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueA); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on the same position again, to register a triple tap. This will select + // the line at the tapped position. + await gesture.down(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 19); + + // Drag, down after the triple tap, to select line by line. + // Moving down will extend the selection to the second line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 35); + + // Moving down will extend the selection to the third line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 2)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 54); + + // Moving down will extend the selection to the last line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 4)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 72); + + // Moving up will extend the selection to the third line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 2)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 54); + + // Moving up will extend the selection to the second line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 1)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 35); + + // Moving up will extend the selection to the first line. + await gesture.moveTo(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 19); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + testWidgets( + 'Can triple click + drag to select paragraph by paragraph', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + maxLines: null, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), testValueA); + await skipPastScrollingAnimation(tester); + expect(controller.value.text, testValueA); + + final Offset firstLinePos = textOffsetToPosition(tester, 5); + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + + // Tap on text field to gain focus, and set selection to 'i|s' on the first line. + final TestGesture gesture = await tester.startGesture( + firstLinePos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + + // Here we tap on same position again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(firstLinePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 6); + + // Here we tap on the same position again, to register a triple tap. This will select + // the paragraph at the tapped position. + await gesture.down(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + + // Drag, down after the triple tap, to select paragraph by paragraph. + // Moving down will extend the selection to the second line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 36); + + // Moving down will extend the selection to the third line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 2)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 55); + + // Moving down will extend the selection to the last line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 4)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 72); + + // Moving up will extend the selection to the third line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight * 2)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 55); + + // Moving up will extend the selection to the second line. + await gesture.moveTo(firstLinePos + Offset(0, lineHeight)); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 36); + + // Moving up will extend the selection to the first line. + await gesture.moveTo(firstLinePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 20); + }, + variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{TargetPlatform.linux}), + ); + + testWidgets( + 'Going past triple click retains the selection on Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueA); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(210.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 13); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + // Clicking again retains the selection. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'Tap count resets when going past a triple tap on Android, Fuchsia, and Linux', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueA); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + final platformSelectsByLine = defaultTargetPlatform == TargetPlatform.linux; + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(210.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 13); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + // Clicking again moves the caret to the tapped position. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 13); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // Clicking again moves the caret to the tapped position. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 13); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the paragraph. + expect( + controller.selection, + TextSelection(baseOffset: 0, extentOffset: platformSelectsByLine ? 19 : 20), + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + }), + ); + + testWidgets( + 'Double click and triple click alternate on Windows', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: testValueA); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller, maxLines: null)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(210.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 13); + + // Second click selects the word. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + // Triple click selects the paragraph. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + // Clicking again selects the word. + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the paragraph. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // Clicking again selects the paragraph. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Clicking again selects the word. + expect(controller.selection, const TextSelection(baseOffset: 11, extentOffset: 15)); + + await gesture.down(textFieldStart + const Offset(210.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + // Clicking again selects the paragraph. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 20)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + }); + + testWidgets( + 'double tap on top of cursor also selects word', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Tap to put the cursor after the "w". + const index = 3; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.selection, const TextSelection.collapsed(offset: index)); + + // Double tap on the same location. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + + // First tap doesn't change the selection + expect(controller.selection, const TextSelection.collapsed(offset: index)); + + // Second tap selects the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // The toolbar shows up. + expectMaterialToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'double double tap just shows the selection menu', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Double tap on the same location shows the selection menu. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + + // Double tap again keeps the selection menu visible. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'double long press just shows the selection menu', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Long press shows the selection menu. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + + // Long press again keeps the selection menu visible. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'A single tap hides the selection menu', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Long press shows the selection menu. + await tester.longPress(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + + // Tap hides the selection menu. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(find.text('Paste'), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Drag selection hides the selection menu', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + final Offset midBlah1 = textOffsetToPosition(tester, 2); + final Offset midBlah2 = textOffsetToPosition(tester, 8); + + // Right click the second word. + final TestGesture gesture = await tester.startGesture( + midBlah2, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // The toolbar is shown. + expect(find.text('Paste'), findsOneWidget); + + // Drag the mouse to the first word. + final TestGesture gesture2 = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture2.moveTo(midBlah2); + await tester.pump(); + await gesture2.up(); + await tester.pumpAndSettle(); + + // The toolbar is hidden. + expect(find.text('Paste'), findsNothing); + }, + variant: TargetPlatformVariant.desktop(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Long press on an autofocused field shows the selection menu', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(autofocus: true, controller: controller)), + ), + ), + ); + // This extra pump allows the selection set by autofocus to propagate to + // the RenderEditable. + await tester.pump(); + + // Long press shows the selection menu. + expect(find.text('Paste'), findsNothing); + await tester.longPress(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(find.text('Paste'), findsOneWidget); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'double tap hold selects word', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + final TestGesture gesture = await tester.startGesture( + textfieldStart + const Offset(150.0, 9.0), + ); + // Hold the press. + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // The toolbar shows up. + expectCupertinoToolbarForPartialSelection(); + + await gesture.up(); + await tester.pump(); + + // Still selected. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + // The toolbar is still showing. + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'tap after a double tap select is not affected', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'. + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + isTargetPlatformMobile + ? const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream) + : const TextSelection.collapsed(offset: 9), + ); + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tapAt(ePos); + await tester.pump(); + + // Plain collapsed selection at the edge of first word on iOS. In iOS 12, + // the first tap after a double tap ends up putting the cursor at where + // you tapped instead of the edge like every other single tap. This is + // likely a bug in iOS 12 and not present in other versions. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, isTargetPlatformMobile ? 7 : 6); + + // No toolbar. + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press moves cursor to the exact long press position and shows toolbar when the field is focused', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(autofocus: true, controller: controller)), + ), + ), + ); + + // This extra pump allows the selection set by autofocus to propagate to + // the RenderEditable. + await tester.pump(); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Collapsed cursor for iOS long press. + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + + expectCupertinoToolbarForCollapsedSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press that starts on an unfocused TextField selects the word at the exact long press position and shows toolbar', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Collapsed cursor for iOS long press. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // The toolbar shows up. + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press selects word and shows toolbar', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + // The toolbar shows up. + expectMaterialToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Toolbar hides on scroll start and re-appears on scroll end on Android', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure ' * 20, + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Long press should select word at position and show toolbar. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + final targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS; + final Finder contextMenuButtonFinder = targetPlatformIsiOS + ? find.byType(CupertinoButton) + : find.byType(TextButton); + // Context menu shows 5 buttons: cut, copy, paste, select all, share on Android. + // Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS. + final numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5; + + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + + // Scroll to the left, the toolbar should be hidden since we are scrolling. + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(TextField)), + ); + await tester.pump(); + await gesture.moveTo(tester.getBottomLeft(find.byType(TextField))); + await tester.pumpAndSettle(); + expect(contextMenuButtonFinder, findsNothing); + + // Scroll back to center, the toolbar should still be hidden since + // we are still scrolling. + await gesture.moveTo(tester.getCenter(find.byType(TextField))); + await tester.pumpAndSettle(); + expect(contextMenuButtonFinder, findsNothing); + + // Release finger to end scroll, toolbar should now be visible. + await gesture.up(); + await tester.pumpAndSettle(); + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + expect(renderEditable.selectionStartInViewport.value, true); + expect(renderEditable.selectionEndInViewport.value, true); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'Toolbar hides on parent scrollable scroll start and re-appears on scroll end on Android and iOS', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure ' * 20, + ); + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ListView( + children: <Widget>[ + Container(height: 400, key: key1), + TextField(controller: controller), + Container(height: 1000, key: key2), + ], + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Long press should select word at position and show toolbar. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + final targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS; + final Finder contextMenuButtonFinder = targetPlatformIsiOS + ? find.byType(CupertinoButton) + : find.byType(TextButton); + // Context menu shows 5 buttons: cut, copy, paste, select all, share on Android. + // Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS. + final numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5; + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + + // Scroll down, the toolbar should be hidden since we are scrolling. + final TestGesture gesture = await tester.startGesture(tester.getBottomLeft(find.byKey(key1))); + await tester.pump(); + await gesture.moveTo(tester.getTopLeft(find.byKey(key1))); + await tester.pumpAndSettle(); + expect(contextMenuButtonFinder, findsNothing); + + // Release finger to end scroll, toolbar should now be visible. + await gesture.up(); + await tester.pumpAndSettle(); + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + expect(renderEditable.selectionStartInViewport.value, true); + expect(renderEditable.selectionEndInViewport.value, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Toolbar can re-appear after being scrolled out of view on Android and iOS', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure ' * 20, + ); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller, scrollController: scrollController), + ), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + expect(renderEditable.selectionStartInViewport.value, false); + expect(renderEditable.selectionEndInViewport.value, false); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Long press should select word at position and show toolbar. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + + final targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS; + final Finder contextMenuButtonFinder = targetPlatformIsiOS + ? find.byType(CupertinoButton) + : find.byType(TextButton); + // Context menu shows 5 buttons: cut, copy, paste, select all, share on Android. + // Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS. + final numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5; + + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + expect(renderEditable.selectionStartInViewport.value, true); + expect(renderEditable.selectionEndInViewport.value, true); + + // Scroll to the end so the selection is no longer visible. This should + // hide the toolbar, but schedule it to be shown once the selection is + // visible again. + scrollController.animateTo( + 500.0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect(contextMenuButtonFinder, findsNothing); + expect(renderEditable.selectionStartInViewport.value, false); + expect(renderEditable.selectionEndInViewport.value, false); + + // Scroll to the beginning where the selection is in view + // and the toolbar should show again. + scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + expect(renderEditable.selectionStartInViewport.value, true); + expect(renderEditable.selectionEndInViewport.value, true); + + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 0)); + await tester.pump(); + await gesture.up(); + await gesture.down(textOffsetToPosition(tester, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Double tap should select word at position and show toolbar. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + expect(renderEditable.selectionStartInViewport.value, true); + expect(renderEditable.selectionEndInViewport.value, true); + + // Scroll to the end so the selection is no longer visible. This should + // hide the toolbar, but schedule it to be shown once the selection is + // visible again. + scrollController.animateTo( + 500.0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect(contextMenuButtonFinder, findsNothing); + expect(renderEditable.selectionStartInViewport.value, false); + expect(renderEditable.selectionEndInViewport.value, false); + + // Tap to change the selection. This will invalidate the scheduled + // toolbar. + await gesture.down(tester.getCenter(find.byType(TextField))); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Scroll to the beginning where the selection was previously + // and the toolbar should not show because it was invalidated. + scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect(contextMenuButtonFinder, findsNothing); + expect(renderEditable.selectionStartInViewport.value, false); + expect(renderEditable.selectionEndInViewport.value, false); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Toolbar can re-appear after parent scrollable scrolls selection out of view on Android and iOS', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final Key key1 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ListView( + controller: scrollController, + children: <Widget>[ + TextField(controller: controller), + Container(height: 1500.0, key: key1), + ], + ), + ), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Long press should select word at position and show toolbar. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + final targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS; + final Finder contextMenuButtonFinder = targetPlatformIsiOS + ? find.byType(CupertinoButton) + : find.byType(TextButton); + // Context menu shows 5 buttons: cut, copy, paste, select all, share on Android. + // Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS. + final numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5; + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + + // Scroll down, the TextField should no longer be in the viewport. + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + expect(contextMenuButtonFinder, findsNothing); + + // Scroll back up so the TextField is inside the viewport. + scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + expect( + contextMenuButtonFinder, + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons), + ); + expect(renderEditable.selectionStartInViewport.value, true); + expect(renderEditable.selectionEndInViewport.value, true); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'long press tap cannot initiate a double tap', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(autofocus: true, controller: controller)), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' + + await tester.longPressAt(ePos); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + + // Tap slightly behind the previous tap to avoid tapping the context menu + // on desktop. + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + final Offset secondTapPos = isTargetPlatformMobile ? ePos : ePos + const Offset(-1.0, 0.0); + await tester.tapAt(secondTapPos); + await tester.pump(); + + // The cursor does not move and the toolbar is toggled. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 6); + + // The toolbar from the long press is now dismissed by the second tap. + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press drag extends the selection to the word under the drag and shows toolbar on lift on non-Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 18)); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press selects the word at the long presses position. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 23)); + // Cursor move doesn't trigger a toolbar initially. + expectNoMaterialToolbar(); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 13, extentOffset: 35)); + // Still no toolbar. + expectNoMaterialToolbar(); + + // The selection is moved on a backwards drag. + await gesture.moveBy(const Offset(-200, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 8)); + // Still no toolbar. + expectNoMaterialToolbar(); + + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + + // The selection is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 0)); + // Still no toolbar. + expectNoMaterialToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 23, extentOffset: 0)); + // The toolbar now shows up. + expectMaterialToolbarForPartialSelection(); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.macOS}, + ), + ); + + testWidgets( + 'long press drag on a focused TextField moves the cursor under the drag and shows toolbar on lift', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(autofocus: true, controller: controller)), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture( + textfieldStart + const Offset(50.0, 9.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on iOS shows collapsed selection cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + // Cursor move doesn't trigger a toolbar initially. + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + // Still no toolbar. + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + // Still no toolbar. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + // The toolbar now shows up. + expectCupertinoToolbarForCollapsedSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press drag on an unfocused TextField selects word-by-word and shows toolbar on lift', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture( + textfieldStart + const Offset(50.0, 9.0), + ); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on iOS shows collapsed selection cursor. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + // Cursor move doesn't trigger a toolbar initially. + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 12)); + // Still no toolbar. + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 23)); + // Still no toolbar. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 23)); + // The toolbar now shows up. + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press drag can edge scroll on non-Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the text and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, 1056); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture(textfieldStart); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + expectNoMaterialToolbar(); + + await gesture.moveBy(const Offset(900, 5)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 59)); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 66)); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expectNoMaterialToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expectMaterialToolbarForFullSelection(); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1)); + + final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'long press drag can edge scroll on Apple platforms - unfocused TextField', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, 1056); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture(textfieldStart); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(900, 5)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 59)); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 66)); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expectCupertinoToolbarForFullSelection(); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1)); + + final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'long press drag can edge scroll on Apple platforms - focused TextField', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(autofocus: true, controller: controller)), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, 1056); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture(textfieldStart + const Offset(300, 5)); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 19, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + + await gesture.moveBy(const Offset(600, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 56)); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect(controller.selection, const TextSelection.collapsed(offset: 62)); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expectCupertinoToolbarForCollapsedSelection(); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1)); + + final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('mouse click and drag can edge scroll', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + final Size screenSize = MediaQuery.of(tester.element(find.byType(TextField))).size; + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(textOffsetToPosition(tester, 66).dx, greaterThan(screenSize.width)); + + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 19), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + + await gesture.moveTo(textOffsetToPosition(tester, 56)); + // To the edge of the screen basically. + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 56)); + + // Keep moving out. + await gesture.moveTo(textOffsetToPosition(tester, 62)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 62)); + await gesture.moveTo(textOffsetToPosition(tester, 66)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 19, extentOffset: 66), + ); // We're at the edge now. + expectNoCupertinoToolbar(); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 66)); + + // The last character is now on screen near the right edge. + expect( + textOffsetToPosition(tester, 66).dx, + moreOrLessEquals(TestSemantics.fullScreen.width, epsilon: 2.0), + ); + + // The first character is now offscreen to the left. + expect(textOffsetToPosition(tester, 0).dx, lessThan(-100.0)); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'keyboard selection change scrolls the field', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(textOffsetToPosition(tester, 66).dx, 1056); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 13)); + + // Move to position 56 with the right arrow (near the edge of the screen). + for (var i = 0; i < (56 - 13); i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + await tester.pumpAndSettle(); + expect( + controller.selection, + // arrowRight always sets the affinity to downstream. + const TextSelection.collapsed(offset: 56), + ); + + // Keep moving out. + for (var i = 0; i < (62 - 56); i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 62)); + for (var i = 0; i < (66 - 62); i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66), + ); // We're at the edge now. + + await tester.pumpAndSettle(); + + // The last character is now on screen near the right edge. + expect( + textOffsetToPosition(tester, 66).dx, + moreOrLessEquals(TestSemantics.fullScreen.width, epsilon: 2.0), + ); + + // The first character is now offscreen to the left. + expect(textOffsetToPosition(tester, 0).dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, + variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] Browser handles arrow keys differently. + ); + + testWidgets( + 'long press drag can edge scroll vertically', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neigse Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(autofocus: true, maxLines: 2, controller: controller)), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // Just testing the test and making sure that the last character is outside + // the bottom of the field. + final int textLength = controller.text.length; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final double firstCharY = textOffsetToPosition(tester, 0).dy; + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight * 2, epsilon: 1), + ); + + // Start long pressing on the first line. + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 19)); + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.selection, const TextSelection.collapsed(offset: 19)); + await tester.pumpAndSettle(); + + // Move down to the second line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 65)); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 65).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Keep selecting down to the third and final line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 110)); + + // The last character is no longer three line heights down from the top of + // the field, it's now only two line heights down, because it has scrolled + // down by one line. + expect( + textOffsetToPosition(tester, 110).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Likewise, the first character is now scrolled out of the top of the field + // by one line. + expect( + textOffsetToPosition(tester, 0).dy, + moreOrLessEquals(firstCharY - lineHeight, epsilon: 1), + ); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'keyboard selection change scrolls the field vertically', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(maxLines: 2, controller: controller)), + ), + ), + ); + + // Just testing the test and making sure that the last character is outside + // the bottom of the field. + final int textLength = controller.text.length; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final double firstCharY = textOffsetToPosition(tester, 0).dy; + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight * 2, epsilon: 1), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 13)); + + // Move down to the second line. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 59)); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 66).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Move down to the third and final line. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 104)); + + // The last character is no longer three line heights down from the top of + // the field, it's now only two line heights down, because it has scrolled + // down by one line. + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Likewise, the first character is now scrolled out of the top of the field + // by one line. + expect( + textOffsetToPosition(tester, 0).dy, + moreOrLessEquals(firstCharY - lineHeight, epsilon: 1), + ); + }, + variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] Browser handles arrow keys differently. + ); + + testWidgets('mouse click and drag can edge scroll vertically', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: + 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(maxLines: 2, controller: controller)), + ), + ), + ); + + // Just testing the test and making sure that the last character is outside + // the bottom of the field. + final int textLength = controller.text.length; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final double firstCharY = textOffsetToPosition(tester, 0).dy; + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight * 2, epsilon: 1), + ); + + // Start selecting on the first line. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 19), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 60).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Select down to the second line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 65)); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 60).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Keep selecting down to the third and final line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 19, extentOffset: 110)); + + // The last character is no longer three line heights down from the top of + // the field, it's now only two line heights down, because it has scrolled + // down by one line. + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Likewise, the first character is now scrolled out of the top of the field + // by one line. + expect( + textOffsetToPosition(tester, 0).dy, + moreOrLessEquals(firstCharY - lineHeight, epsilon: 1), + ); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'long tap after a double tap select is not affected', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel' + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor to the beginning of the second word. + expect( + controller.selection, + isTargetPlatformMobile + ? const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream) + : const TextSelection.collapsed(offset: 9), + ); + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.longPressAt(ePos); + await tester.pumpAndSettle(); + + // Plain collapsed selection at the exact tap position. + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + + // The toolbar shows up. + expectCupertinoToolbarForCollapsedSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'double tap after a long tap is not affected', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap. + // On macOS, we select the precise position of the tap. + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(autofocus: true, controller: controller)), + ), + ), + ); + + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + + // The second tap is slightly higher to avoid tapping the context menu on + // desktop. + final Offset pPos = + textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel' + final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater' + + await tester.longPressAt(wPos); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + + await tester.tapAt(pPos); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect( + controller.selection, + isTargetPlatformMobile + ? const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream) + : const TextSelection.collapsed(offset: 9), + ); + await tester.tapAt(pPos); + await tester.pumpAndSettle(); + + // Double tap selection. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('double click after a click on desktop platforms', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(50.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First click moved the cursor to the precise location, not the start of + // the word. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + + // Double click selection. + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + // The text selection toolbar isn't shown on Mac without a right click. + expectNoCupertinoToolbar(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'double tap chains work', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + controller.selection, + const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream), + ); + await tester.tapAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + // Double tap selecting the same word somewhere else is fine. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap hides the toolbar and retains the selection. + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectNoCupertinoToolbar(); + + // Second tap shows the toolbar and retains the selection. + await tester.tapAt(textfieldStart + const Offset(100.0, 9.0)); + // Wait for the consecutive tap timer to timeout so the next + // tap is not detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectCupertinoToolbarForPartialSelection(); + + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor and hid the toolbar. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + expectNoCupertinoToolbar(); + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'double click chains work', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(TextField)); + + // First click moves the cursor to the point of the click, not the edge of + // the clicked word. + final TestGesture gesture = await tester.startGesture( + textFieldStart + const Offset(50.0, 9.0), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.selection, const TextSelection.collapsed(offset: 3)); + + // Second click selects. + await gesture.down(textFieldStart + const Offset(50.0, 9.0)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so the next + // tap is not detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectNoCupertinoToolbar(); + + // Double tap selecting the same word somewhere else is fine. + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 6)); + await gesture.down(textFieldStart + const Offset(100.0, 9.0)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so the next + // tap is not detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + expectNoCupertinoToolbar(); + + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + // First tap moved the cursor. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + await gesture.down(textFieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + expectNoCupertinoToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + }), + ); + + testWidgets( + 'double tapping a space selects the previous word on iOS', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: ' blah blah \n blah'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(maxLines: null, controller: controller)), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping does the same thing. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.extentOffset, 5); + expect(controller.value.selection.baseOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping does the same thing for the first space. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 19)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 19); + expect(controller.value.selection.extentOffset, 19); + + // Double tapping the last space selects all previous contiguous spaces on + // both lines and the previous word. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 6); + expect(controller.value.selection.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'selecting a space selects the space on non-iOS platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: ' blah blah'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, 10)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 5); + expect(controller.value.selection.extentOffset, 6); + + // Tap at the end of the text to move the selection to the end. On some + // platforms, the context menu "Cut" button blocks this tap, so move it out + // of the way by an Offset. + await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0)); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.fuchsia, + TargetPlatform.android, + }), + ); + + testWidgets( + 'selecting a space selects the space on Desktop platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: ' blah blah'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, -1); + expect(controller.value.selection.extentOffset, -1); + + // Put the cursor at the end of the field. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 10), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double clicking the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await gesture.down(textOffsetToPosition(tester, 5)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.down(textOffsetToPosition(tester, 5)); + await tester.pump(); + await gesture.up(); + // Wait for the consecutive tap timer to timeout so our next tap is not + // detected as a triple tap. + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 5); + expect(controller.value.selection.extentOffset, 6); + + // Put the cursor at the end of the field. + await gesture.down(textOffsetToPosition(tester, 10)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 10); + expect(controller.value.selection.extentOffset, 10); + + // Double tapping the second space selects it. + await tester.pump(const Duration(milliseconds: 500)); + await gesture.down(textOffsetToPosition(tester, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.down(textOffsetToPosition(tester, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, isNotNull); + expect(controller.value.selection.baseOffset, 0); + expect(controller.value.selection.extentOffset, 1); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + }), + ); + + testWidgets( + 'Force press does not set selection on Android or Fuchsia touch devices', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final Offset offset = tester.getTopLeft(find.byType(TextField)) + const Offset(150.0, 9.0); + + final int pointerValue = tester.nextPointer; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: offset + const Offset(150.0, 9.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + + await gesture.up(); + await tester.pump(); + + // We don't want this gesture to select any word on Android. + expect(controller.selection, const TextSelection.collapsed(offset: -1)); + expectNoMaterialToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + }), + ); + + testWidgets( + 'Force press sets selection on desktop platforms that do not support it', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final Offset offset = tester.getTopLeft(find.byType(TextField)) + const Offset(150.0, 9.0); + + final int pointerValue = tester.nextPointer; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: offset + const Offset(150.0, 9.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + + await gesture.up(); + await tester.pump(); + + // We don't want this gesture to select any word on Android. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); + expectNoMaterialToolbar(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'force press selects word', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final int pointerValue = tester.nextPointer; + final Offset offset = textfieldStart + const Offset(150.0, 9.0); + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: textfieldStart + const Offset(150.0, 9.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + // We expect the force press to select a word at the given location. + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 12)); + + await gesture.up(); + await tester.pumpAndSettle(); + expectCupertinoToolbarForPartialSelection(); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets( + 'tap on non-force-press-supported devices work', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget(Container(key: GlobalKey())); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final int pointerValue = tester.nextPointer; + final Offset offset = textfieldStart + const Offset(150.0, 9.0); + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + // iPhone 6 and below report 0 across the board. + pressure: 0, + pressureMax: 0, + pressureMin: 0, + ), + ); + + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointerValue, + position: textfieldStart + const Offset(150.0, 9.0), + pressure: 0.5, + pressureMin: 0, + ), + ); + await gesture.up(); + // The event should fallback to a normal tap and move the cursor. + // Single taps selects the edge of the word. + expect( + controller.selection, + const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream), + ); + + await tester.pump(); + // Single taps shouldn't trigger the toolbar. + expectNoCupertinoToolbar(); + + // TODO(gspencergoog): Add in TargetPlatform.macOS in the line below when we figure out what global state is leaking. + // https://github.com/flutter/flutter/issues/43445 + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('default TextField debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + const TextField().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('TextField implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + // Not checking controller, inputFormatters, focusNode + const TextField( + decoration: InputDecoration(labelText: 'foo'), + keyboardType: TextInputType.text, + textInputAction: TextInputAction.done, + style: TextStyle(color: Color(0xff00ff00)), + textAlign: TextAlign.end, + textDirection: TextDirection.ltr, + autofocus: true, + autocorrect: false, + maxLines: 10, + maxLength: 100, + maxLengthEnforcement: MaxLengthEnforcement.none, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + enabled: false, + cursorWidth: 1.0, + cursorHeight: 1.0, + cursorRadius: Radius.zero, + cursorColor: Color(0xff00ff00), + keyboardAppearance: Brightness.dark, + scrollPadding: EdgeInsets.zero, + scrollPhysics: ClampingScrollPhysics(), + enableInteractiveSelection: false, + hintLocales: <Locale>[Locale('en'), Locale('fr')], + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'enabled: false', + 'decoration: InputDecoration(labelText: "foo")', + 'style: TextStyle(inherit: true, color: ${const Color(0xff00ff00)})', + 'autofocus: true', + 'autocorrect: false', + 'smartDashesType: disabled', + 'smartQuotesType: disabled', + 'maxLines: 10', + 'maxLength: 100', + 'maxLengthEnforcement: none', + 'textInputAction: done', + 'textAlign: end', + 'textDirection: ltr', + 'cursorWidth: 1.0', + 'cursorHeight: 1.0', + 'cursorRadius: Radius.circular(0.0)', + 'cursorColor: ${const Color(0xff00ff00)}', + 'keyboardAppearance: Brightness.dark', + 'scrollPadding: EdgeInsets.zero', + 'selection disabled', + 'scrollPhysics: ClampingScrollPhysics', + 'hintLocales: [en, fr]', + ]); + }); + + testWidgets('strut basic single line', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material(child: Center(child: TextField())), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // The TextField will be as tall as the decoration (24) plus the metrics + // from the default TextStyle of the theme (16), or 40 altogether. + // Because this is less than the kMinInteractiveDimension, it will be + // increased to that value (48). + const Size(800, kMinInteractiveDimension), + ); + }); + + testWidgets('strut TextStyle increases height', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center(child: TextField(style: TextStyle(fontSize: 20))), + ), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // Strut should inherit the TextStyle.fontSize by default and produce the + // same height as if it were disabled. + const Size(800, kMinInteractiveDimension), // Because 44 < 48. + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: const Material( + child: Center( + child: TextField(style: TextStyle(fontSize: 20), strutStyle: StrutStyle.disabled), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // The height here should match the previous version with strut enabled. + const Size(800, kMinInteractiveDimension), // Because 44 < 48. + ); + }); + + testWidgets('strut basic multi line', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material(child: Center(child: TextField(maxLines: 6))), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // The height should be the input decoration (24) plus 6x the strut height (16). + const Size(800, 120), + ); + }); + + testWidgets('strut no force small strut', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: TextField( + maxLines: 6, + strutStyle: StrutStyle( + // The small strut is overtaken by the larger + // TextStyle fontSize. + fontSize: 5, + ), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // When the strut's height is smaller than TextStyle's and forceStrutHeight + // is disabled, then the TextStyle takes precedence. Should be the same height + // as 'strut basic multi line'. + const Size(800, 120), + ); + }); + + testWidgets( + 'strut no force large strut', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center(child: TextField(maxLines: 6, strutStyle: StrutStyle(fontSize: 25))), + ), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // When the strut's height is larger than TextStyle's and forceStrutHeight + // is disabled, then the StrutStyle takes precedence. + const Size(800, 174), + ); + }, + skip: isBrowser, // TODO(mdebbar): https://github.com/flutter/flutter/issues/32243 + ); + + testWidgets( + 'strut height override', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: TextField( + maxLines: 3, + strutStyle: StrutStyle(fontSize: 8, forceStrutHeight: true), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // The smaller font size of strut make the field shorter than normal. + const Size(800, 48), + ); + }, + skip: isBrowser, // TODO(mdebbar): https://github.com/flutter/flutter/issues/32243 + ); + + testWidgets( + 'strut forces field taller', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: const Material( + child: Center( + child: TextField( + maxLines: 3, + style: TextStyle(fontSize: 10), + strutStyle: StrutStyle(fontSize: 18, forceStrutHeight: true), + ), + ), + ), + ), + ); + + expect( + tester.getSize(find.byType(TextField)), + // When the strut fontSize is larger than a provided TextStyle, the + // strut's height takes precedence. + const Size(800, 78), + ); + }, + skip: isBrowser, // TODO(mdebbar): https://github.com/flutter/flutter/issues/32243 + ); + + testWidgets('Caret center position', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: Theme( + data: ThemeData(useMaterial3: false), + child: const SizedBox( + width: 300.0, + child: TextField(textAlign: TextAlign.center, decoration: null), + ), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + await tester.enterText(find.byType(TextField), 'abcd'); + await tester.pump(); + + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft, + ); + expect(topLeft.dx, equals(431)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft, + ); + expect(topLeft.dx, equals(415)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + expect(topLeft.dx, equals(399)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft, + ); + expect(topLeft.dx, equals(383)); + }); + + testWidgets('Caret indexes into trailing whitespace center align', (WidgetTester tester) async { + await tester.pumpWidget( + overlay( + child: Theme( + data: ThemeData(useMaterial3: false), + child: const SizedBox( + width: 300.0, + child: TextField(textAlign: TextAlign.center, decoration: null), + ), + ), + ), + ); + + final RenderEditable editable = findRenderEditable(tester); + + await tester.enterText(find.byType(TextField), 'abcd '); + await tester.pump(); + + Offset topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 7)).topLeft, + ); + expect(topLeft.dx, equals(479)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 8)).topLeft, + ); + expect(topLeft.dx, equals(495)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 4)).topLeft, + ); + expect(topLeft.dx, equals(431)); + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 3)).topLeft, + ); + expect(topLeft.dx, equals(415)); // Should be same as equivalent in 'Caret center position' + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 2)).topLeft, + ); + expect(topLeft.dx, equals(399)); // Should be same as equivalent in 'Caret center position' + + topLeft = editable.localToGlobal( + editable.getLocalRectForCaret(const TextPosition(offset: 1)).topLeft, + ); + expect(topLeft.dx, equals(383)); // Should be same as equivalent in 'Caret center position' + }); + + testWidgets('selection handles are rendered and not faded away', (WidgetTester tester) async { + const testText = 'lorem ipsum'; + final TextEditingController controller = _textEditingController(text: testText); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List<FadeTransition> transitions = find + .descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'), + matching: find.byType(FadeTransition), + ) + .evaluate() + .map((Element e) => e.widget) + .cast<FadeTransition>() + .toList(); + expect(transitions.length, 2); + final FadeTransition left = transitions[0]; + final FadeTransition right = transitions[1]; + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }); + + testWidgets( + 'iOS selection handles are rendered and not faded away', + (WidgetTester tester) async { + const testText = 'lorem ipsum'; + final TextEditingController controller = _textEditingController(text: testText); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final RenderEditable renderEditable = tester + .state<EditableTextState>(find.byType(EditableText)) + .renderEditable; + + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pumpAndSettle(); + + final List<FadeTransition> transitions = find + .byType(FadeTransition) + .evaluate() + .map((Element e) => e.widget) + .cast<FadeTransition>() + .toList(); + expect(transitions.length, 2); + final FadeTransition left = transitions[0]; + final FadeTransition right = transitions[1]; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'iPad Scribble selection change shows selection handles', + (WidgetTester tester) async { + const testText = 'lorem ipsum'; + final TextEditingController controller = _textEditingController(text: testText); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + await tester.testTextInput.startScribbleInteraction(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: testText, + selection: TextSelection(baseOffset: 2, extentOffset: 7), + ), + ); + await tester.pumpAndSettle(); + + final List<FadeTransition> transitions = find + .byType(FadeTransition) + .evaluate() + .map((Element e) => e.widget) + .cast<FadeTransition>() + .toList(); + expect(transitions.length, 2); + final FadeTransition left = transitions[0]; + final FadeTransition right = transitions[1]; + + expect(left.opacity.value, equals(1.0)); + expect(right.opacity.value, equals(1.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); + + testWidgets('Tap shows handles but not toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Tap to trigger the text field. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + }); + + testWidgets('Tap in empty text field does not show handles nor toolbar', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Tap to trigger the text field. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + }); + + testWidgets('Long press shows handles and toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Long press to trigger the text field. + await tester.longPress(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Long press in empty text field shows handles and toolbar', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Tap to trigger the text field. + await tester.longPress(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Double tap shows handles and toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Double tap to trigger the text field. + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Double tap in empty text field shows toolbar but not handles', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Double tap to trigger the text field. + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + }); + + testWidgets('Mouse tap does not show handles nor toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Long press to trigger the text field. + final Offset textFieldPos = tester.getCenter(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture( + textFieldPos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('Mouse long press does not show handles nor toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Long press to trigger the text field. + final Offset textFieldPos = tester.getCenter(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture( + textFieldPos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('Mouse double tap does not show handles nor toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Double tap to trigger the text field. + final Offset textFieldPos = tester.getCenter(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture( + textFieldPos, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(); + await gesture.down(textFieldPos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isFalse); + }); + + testWidgets('Does not show handles when updated from the web engine', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Interact with the text field to establish the input connection. + final Offset topLeft = tester.getTopLeft(find.byType(EditableText)); + final TestGesture gesture = await tester.startGesture( + topLeft + const Offset(0.0, 5.0), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pumpAndSettle(); + + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + + if (kIsWeb) { + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abc def ghi', + selection: TextSelection(baseOffset: 2, extentOffset: 7), + ), + ); + // Wait for all the `setState` calls to be flushed. + await tester.pumpAndSettle(); + expect( + state.currentTextEditingValue.selection, + const TextSelection(baseOffset: 2, extentOffset: 7), + ); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + } + }); + + testWidgets('Tapping selection handles toggles the toolbar', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abc def ghi'); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Tap to position the cursor and show the selection handles. + final Offset ePos = textOffsetToPosition(tester, 5); // Index of 'e'. + await tester.tapAt(ePos, pointer: 7); + await tester.pumpAndSettle(); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + expect(editableText.selectionOverlay!.handlesAreVisible, isTrue); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 1); + + // Tap the handle to show the toolbar. + final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); + await tester.tapAt(handlePos, pointer: 7); + await tester.pump(); + expect( + editableText.selectionOverlay!.toolbarIsVisible, + isContextMenuProvidedByPlatform ? isFalse : isTrue, + ); + + // Tap the handle again to hide the toolbar. + await tester.tapAt(handlePos, pointer: 7); + await tester.pump(); + expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse); + }); + + testWidgets( + 'when TextField would be blocked by keyboard, it is shown with enough space for the selection handle', + (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: ListView( + controller: scrollController, + children: <Widget>[ + Container(height: 579), // Push field almost off screen. + const TextField(), + Container(height: 1000), + ], + ), + ), + ), + ), + ); + + // Tap the TextField to put the cursor into it and bring it into view. + expect(scrollController.offset, 0.0); + await tester.tapAt(tester.getTopLeft(find.byType(TextField))); + await tester.pumpAndSettle(); + + // The ListView has scrolled to keep the TextField and cursor handle + // visible. + expect(scrollController.offset, 50.0); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/74566 + testWidgets( + 'TextField and last input character are visible on the screen when the cursor is not shown', + (WidgetTester tester) async { + final scrollController = ScrollController(); + final textFieldScrollController = ScrollController(); + addTearDown(() { + scrollController.dispose(); + textFieldScrollController.dispose(); + }); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Scaffold( + body: Center( + child: ListView( + controller: scrollController, + children: <Widget>[ + Container(height: 579), // Push field almost off screen. + TextField(scrollController: textFieldScrollController, showCursor: false), + Container(height: 1000), + ], + ), + ), + ), + ), + ); + + // Tap the TextField to bring it into view. + expect(scrollController.offset, 0.0); + await tester.tapAt(tester.getTopLeft(find.byType(TextField))); + await tester.pumpAndSettle(); + + // The ListView has scrolled to keep the TextField visible. + expect(scrollController.offset, 50.0); + expect(textFieldScrollController.offset, 0.0); + + // After entering some long text, the last input character remains on the screen. + final String testValue = 'I love Flutter!' * 10; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: testValue, + selection: TextSelection.collapsed(offset: testValue.length), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); // Text scroll animation. + + expect(textFieldScrollController.offset, 1602.0); + }, + ); + + group('height', () { + testWidgets('By default, TextField is at least kMinInteractiveDimension high', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold(body: Center(child: TextField())), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(TextField)); + expect(renderBox.size.height, greaterThanOrEqualTo(kMinInteractiveDimension)); + }); + + testWidgets( + "When text is very small, TextField still doesn't go below kMinInteractiveDimension height", + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold( + body: Center(child: TextField(style: TextStyle(fontSize: 2.0))), + ), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(TextField)); + expect(renderBox.size.height, kMinInteractiveDimension); + }, + ); + + testWidgets('When isDense, TextField can go below kMinInteractiveDimension height', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Scaffold( + body: Center(child: TextField(decoration: InputDecoration(isDense: true))), + ), + ), + ); + + final RenderBox renderBox = tester.renderObject(find.byType(TextField)); + expect(renderBox.size.height, lessThan(kMinInteractiveDimension)); + }); + + group('intrinsics', () { + Widget buildTest({required bool isDense}) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: <Widget>[ + SliverFillRemaining( + hasScrollBody: false, + child: Column( + children: <Widget>[ + TextField(decoration: InputDecoration(isDense: isDense)), + Container(height: 1000), + ], + ), + ), + ], + ), + ), + ); + } + + testWidgets('By default, intrinsic height is at least kMinInteractiveDimension high', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/54729 + // If the intrinsic height does not match that of the height after + // performLayout, this will fail. + await tester.pumpWidget(buildTest(isDense: false)); + }); + + testWidgets('When isDense, intrinsic height can go below kMinInteractiveDimension height', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/54729 + // If the intrinsic height does not match that of the height after + // performLayout, this will fail. + await tester.pumpWidget(buildTest(isDense: true)); + }); + }); + }); + testWidgets("Arrow keys don't move input focus", (WidgetTester tester) async { + final TextEditingController controller1 = _textEditingController(); + final TextEditingController controller2 = _textEditingController(); + final TextEditingController controller3 = _textEditingController(); + final TextEditingController controller4 = _textEditingController(); + final TextEditingController controller5 = _textEditingController(); + final focusNode1 = FocusNode(debugLabel: 'Field 1'); + final focusNode2 = FocusNode(debugLabel: 'Field 2'); + final focusNode3 = FocusNode(debugLabel: 'Field 3'); + final focusNode4 = FocusNode(debugLabel: 'Field 4'); + final focusNode5 = FocusNode(debugLabel: 'Field 5'); + + addTearDown(() { + focusNode1.dispose(); + focusNode2.dispose(); + focusNode3.dispose(); + focusNode4.dispose(); + focusNode5.dispose(); + }); + + // Lay out text fields in a "+" formation, and focus the center one. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + SizedBox( + width: 100.0, + child: TextField(controller: controller1, focusNode: focusNode1), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + SizedBox( + width: 100.0, + child: TextField(controller: controller2, focusNode: focusNode2), + ), + SizedBox( + width: 100.0, + child: TextField(controller: controller3, focusNode: focusNode3), + ), + SizedBox( + width: 100.0, + child: TextField(controller: controller4, focusNode: focusNode4), + ), + ], + ), + SizedBox( + width: 100.0, + child: TextField(controller: controller5, focusNode: focusNode5), + ), + ], + ), + ), + ), + ), + ); + + focusNode3.requestFocus(); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(focusNode3.hasPrimaryFocus, isTrue); + }); + + testWidgets('Scrolling shortcuts are disabled in text fields', (WidgetTester tester) async { + var scrollInvoked = false; + await tester.pumpWidget( + MaterialApp( + home: Actions( + actions: <Type, Action<Intent>>{ + ScrollIntent: CallbackAction<ScrollIntent>( + onInvoke: (Intent intent) { + scrollInvoked = true; + return null; + }, + ), + }, + child: Material( + child: ListView( + children: const <Widget>[ + Padding(padding: EdgeInsets.symmetric(vertical: 200)), + TextField(), + Padding(padding: EdgeInsets.symmetric(vertical: 800)), + ], + ), + ), + ), + ), + ); + await tester.pump(); + expect(scrollInvoked, isFalse); + + // Set focus on the text field. + await tester.tapAt(tester.getTopLeft(find.byType(TextField))); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(scrollInvoked, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(scrollInvoked, isFalse); + }); + + testWidgets("A buildCounter that returns null doesn't affect the size of the TextField", ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/44909 + + final GlobalKey textField1Key = GlobalKey(); + final GlobalKey textField2Key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + TextField(key: textField1Key), + TextField( + key: textField2Key, + maxLength: 1, + buildCounter: + ( + BuildContext context, { + required int currentLength, + required bool isFocused, + int? maxLength, + }) => null, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final Size textFieldSize1 = tester.getSize(find.byKey(textField1Key)); + final Size textFieldSize2 = tester.getSize(find.byKey(textField2Key)); + + expect(textFieldSize1, equals(textFieldSize2)); + }); + + testWidgets('The selection menu displays in an Overlay without error', ( + WidgetTester tester, + ) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/43787 + final TextEditingController controller = _textEditingController( + text: 'This is a test that shows some odd behavior with Text Selection!', + ); + + late final OverlayEntry overlayEntry; + addTearDown( + () => overlayEntry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ColoredBox( + color: Colors.grey, + child: Center( + child: Container( + color: Colors.red, + width: 300, + height: 600, + child: Overlay( + initialEntries: <OverlayEntry>[ + overlayEntry = OverlayEntry( + builder: (BuildContext context) => + Center(child: TextField(controller: controller)), + ), + ], + ), + ), + ), + ), + ), + ), + ); + + await showSelectionMenuAt(tester, controller, controller.text.indexOf('test')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'clipboard status is checked via hasStrings without getting the full clipboard contents', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + var calledGetData = false; + var calledHasStrings = false; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + MethodCall methodCall, + ) async { + switch (methodCall.method) { + case 'Clipboard.getData': + calledGetData = true; + case 'Clipboard.hasStrings': + calledHasStrings = true; + default: + break; + } + return null; + }); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + // Double tap like when showing the text selection menu on Android/iOS. + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); + await tester.pump(); + + // getData is not called unless something is pasted. hasStrings is used to + // check the status of the clipboard. + expect(calledGetData, false); + // hasStrings is checked in order to decide if the content can be pasted. + expect(calledHasStrings, true); + }, + skip: kIsWeb, // [intended] web doesn't call hasStrings. + ); + + testWidgets('TextField changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextField( + mouseCursor: SystemMouseCursors.grab, + decoration: InputDecoration( + // Add an icon so that the left edge is not the text area + icon: Icon(Icons.person), + ), + ), + ), + ), + ), + ); + + // Center, which is within the text area + final Offset center = tester.getCenter(find.byType(TextField)); + // Top left, which is not the text area + final Offset edge = tester.getTopLeft(find.byType(TextField)) + const Offset(1, 1); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: center); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Test default cursor + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextField(decoration: InputDecoration(icon: Icon(Icons.person))), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + await gesture.moveTo(edge); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + await gesture.moveTo(center); + + // Test default cursor when disabled + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextField(enabled: false, decoration: InputDecoration(icon: Icon(Icons.person))), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(edge); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(center); + }); + + testWidgets('TextField icons change mouse cursor when hovered', (WidgetTester tester) async { + // Test default cursor in icons area. + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextField( + decoration: InputDecoration( + icon: Icon(Icons.label), + prefixIcon: Icon(Icons.cabin), + suffixIcon: Icon(Icons.person), + ), + ), + ), + ), + ), + ); + + // Center, which is within the text area + final Offset center = tester.getCenter(find.byType(TextField)); + // The Icon area + final Offset iconArea = tester.getCenter(find.byIcon(Icons.label)); + // The prefix Icon area + final Offset prefixIconArea = tester.getCenter(find.byIcon(Icons.cabin)); + // The suffix Icon area + final Offset suffixIconArea = tester.getCenter(find.byIcon(Icons.person)); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: center); + + await tester.pump(); + + await gesture.moveTo(center); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + await gesture.moveTo(iconArea); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(prefixIconArea); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + await gesture.moveTo(suffixIconArea); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(center); + + // Test click cursor in icons area for buttons. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextField( + decoration: InputDecoration( + icon: IconButton(icon: const Icon(Icons.label), onPressed: () {}), + prefixIcon: IconButton(icon: const Icon(Icons.cabin), onPressed: () {}), + suffixIcon: IconButton(icon: const Icon(Icons.person), onPressed: () {}), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + + await gesture.moveTo(center); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + await gesture.moveTo(iconArea); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + await gesture.moveTo(prefixIconArea); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + await gesture.moveTo(suffixIconArea); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + }); + + testWidgets( + 'Text selection menu does not change mouse cursor when hovered', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextField(controller: controller), + ), + ), + ), + ); + + expect(find.text('Copy'), findsNothing); + + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 3), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + expect(find.text('Paste'), findsOneWidget); + + await gesture.moveTo(tester.getCenter(find.text('Paste'))); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }, + variant: TargetPlatformVariant.desktop(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('Caret rtl with changing width', (WidgetTester tester) async { + late StateSetter setState; + var isWide = false; + const wideWidth = 300.0; + const narrowWidth = 200.0; + const style = TextStyle(fontSize: 10, height: 1.0, letterSpacing: 0.0, wordSpacing: 0.0); + const caretWidth = 2.0; + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + boilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return SizedBox( + width: isWide ? wideWidth : narrowWidth, + child: TextField( + key: textFieldKey, + controller: controller, + textDirection: TextDirection.rtl, + style: style, + ), + ); + }, + ), + ), + ); + + // The cursor is on the right of the input because it's RTL. + RenderEditable editable = findRenderEditable(tester); + double cursorRight = editable + .getLocalRectForCaret(TextPosition(offset: controller.value.text.length)) + .topRight + .dx; + double inputWidth = editable.size.width; + expect(inputWidth, narrowWidth); + expect(cursorRight, inputWidth - kCaretGap); + + const text = '12345'; + // After entering some text, the cursor is placed to the left of the text + // because the paragraph's writing direction is RTL. + await tester.enterText(find.byType(TextField), text); + await tester.pump(); + editable = findRenderEditable(tester); + cursorRight = editable + .getLocalRectForCaret(TextPosition(offset: controller.value.text.length)) + .topRight + .dx; + inputWidth = editable.size.width; + expect(cursorRight, inputWidth - kCaretGap - text.length * 10 - caretWidth); + + // Since increasing the width of the input moves its right edge further to + // the right, the cursor has followed this change and still appears on the + // right of the input. + setState(() { + isWide = true; + }); + await tester.pump(); + editable = findRenderEditable(tester); + cursorRight = editable + .getLocalRectForCaret(TextPosition(offset: controller.value.text.length)) + .topRight + .dx; + inputWidth = editable.size.width; + expect(inputWidth, wideWidth); + expect(cursorRight, inputWidth - kCaretGap - text.length * 10 - caretWidth); + }); + + testWidgets( + 'Text selection menu hides after select all on desktop', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + final selectAll = defaultTargetPlatform == TargetPlatform.macOS ? 'Select All' : 'Select all'; + + expect(find.text(selectAll), findsNothing); + expect(find.text('Copy'), findsNothing); + + final TestGesture gesture = await tester.startGesture( + const Offset(10.0, 0.0) + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect( + controller.value.selection, + TextSelection.collapsed(offset: controller.text.length, affinity: TextAffinity.upstream), + ); + expect(find.text(selectAll), findsOneWidget); + + await tester.tapAt(tester.getCenter(find.text(selectAll))); + + await tester.pump(); + expect(find.text(selectAll), findsNothing); + expect(find.text('Copy'), findsNothing); + }, + // All desktop platforms except MacOS, which has no select all button. + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.windows, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + // Regressing test for https://github.com/flutter/flutter/issues/70625 + testWidgets('TextFields can inherit [FloatingLabelBehaviour] from input decoration theme', ( + WidgetTester tester, + ) async { + final FocusNode focusNode = _focusNode(); + Widget textFieldBuilder({FloatingLabelBehavior behavior = FloatingLabelBehavior.auto}) { + return MaterialApp( + theme: ThemeData( + useMaterial3: false, + inputDecorationTheme: InputDecorationThemeData(floatingLabelBehavior: behavior), + ), + home: Scaffold( + body: TextField( + focusNode: focusNode, + decoration: const InputDecoration(labelText: 'Label'), + ), + ), + ); + } + + await tester.pumpWidget(textFieldBuilder()); + // The label will be positioned within the content when unfocused. + expect(tester.getTopLeft(find.text('Label')).dy, 20.0); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); // label animation. + // The label will float above the content when focused. + expect(tester.getTopLeft(find.text('Label')).dy, 12.0); + + focusNode.unfocus(); + await tester.pumpAndSettle(); // label animation. + + await tester.pumpWidget(textFieldBuilder(behavior: FloatingLabelBehavior.never)); + await tester.pumpAndSettle(); // theme animation. + // The label will be positioned within the content. + expect(tester.getTopLeft(find.text('Label')).dy, 20.0); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); // label animation. + // The label will always be positioned within the content. + expect(tester.getTopLeft(find.text('Label')).dy, 20.0); + + await tester.pumpWidget(textFieldBuilder(behavior: FloatingLabelBehavior.always)); + await tester.pumpAndSettle(); // theme animation. + // The label will always float above the content. + expect(tester.getTopLeft(find.text('Label')).dy, 12.0); + + focusNode.unfocus(); + await tester.pumpAndSettle(); // label animation. + // The label will always float above the content. + expect(tester.getTopLeft(find.text('Label')).dy, 12.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/140607. + testWidgets('TextFields can inherit errorStyle color from InputDecorationThemeData', ( + WidgetTester tester, + ) async { + const decorationTheme = InputDecorationThemeData(errorStyle: TextStyle(color: Colors.green)); + + EditableTextState getEditableTextState() { + return tester.state<EditableTextState>(find.byType(EditableText)); + } + + // Global theme. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(inputDecorationTheme: decorationTheme), + home: const Scaffold( + body: TextField(decoration: InputDecoration(errorText: 'error')), + ), + ), + ); + expect(getEditableTextState().widget.cursorColor, Colors.green); + + // Local theme. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: InputDecorationTheme( + data: decorationTheme, + child: TextField(decoration: InputDecoration(errorText: 'error')), + ), + ), + ), + ); + expect(getEditableTextState().widget.cursorColor, Colors.green); + }); + + testWidgets('TextField can inherit decoration from local InputDecorationThemeData', ( + WidgetTester tester, + ) async { + const decoration = InputDecoration(labelText: 'Label'); + const decorationTheme = InputDecorationThemeData(errorStyle: TextStyle(color: Colors.green)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: InputDecorationTheme( + data: decorationTheme, + child: TextField(decoration: decoration), + ), + ), + ), + ); + + final InputDecorator decorator = tester.widget(find.byType(InputDecorator)); + final InputDecoration expectedDecoration = decoration + .applyDefaults(decorationTheme) + .copyWith(enabled: true, hintMaxLines: 1); + expect(decorator.decoration, expectedDecoration); + }); + + testWidgets('TextField passes enableInlinePrediction to EditableText', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: TextField(enableInlinePrediction: true))), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.enableInlinePrediction, true); + }); + + testWidgets('TextField enableInlinePrediction defaults to null', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: TextField()))); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.enableInlinePrediction, isNull); + }); + + group('MaxLengthEnforcement', () { + const maxLength = 5; + + Future<void> setupWidget(WidgetTester tester, MaxLengthEnforcement? enforcement) async { + final Widget widget = MaterialApp( + home: Material( + child: TextField(maxLength: maxLength, maxLengthEnforcement: enforcement), + ), + ); + + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + } + + testWidgets('using none enforcement.', (WidgetTester tester) async { + const MaxLengthEnforcement enforcement = MaxLengthEnforcement.none; + + await setupWidget(tester, enforcement); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: 'abc')); + expect(state.currentTextEditingValue.text, 'abc'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)), + ); + expect(state.currentTextEditingValue.text, 'abcdef'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6)); + + state.updateEditingValue(const TextEditingValue(text: 'abcdef')); + expect(state.currentTextEditingValue.text, 'abcdef'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); + + testWidgets('using enforced.', (WidgetTester tester) async { + const MaxLengthEnforcement enforcement = MaxLengthEnforcement.enforced; + + await setupWidget(tester, enforcement); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: 'abc')); + expect(state.currentTextEditingValue.text, 'abc'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)), + ); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue( + const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)), + ); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue(const TextEditingValue(text: 'abcdef')); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + }); + + testWidgets('using truncateAfterCompositionEnds.', (WidgetTester tester) async { + const MaxLengthEnforcement enforcement = MaxLengthEnforcement.truncateAfterCompositionEnds; + + await setupWidget(tester, enforcement); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: 'abc')); + expect(state.currentTextEditingValue.text, 'abc'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)), + ); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue( + const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)), + ); + expect(state.currentTextEditingValue.text, 'abcdef'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6)); + + state.updateEditingValue(const TextEditingValue(text: 'abcdef')); + expect(state.currentTextEditingValue.text, 'abcde'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); + + testWidgets('using default behavior for different platforms.', (WidgetTester tester) async { + await setupWidget(tester, null); + + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateEditingValue(const TextEditingValue(text: '侬好啊')); + expect(state.currentTextEditingValue.text, '侬好啊'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + + state.updateEditingValue( + const TextEditingValue(text: '侬好啊旁友', composing: TextRange(start: 3, end: 5)), + ); + expect(state.currentTextEditingValue.text, '侬好啊旁友'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + + state.updateEditingValue( + const TextEditingValue(text: '侬好啊旁友们', composing: TextRange(start: 3, end: 6)), + ); + if (kIsWeb || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.fuchsia) { + expect(state.currentTextEditingValue.text, '侬好啊旁友们'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6)); + } else { + expect(state.currentTextEditingValue.text, '侬好啊旁友'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5)); + } + + state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友')); + expect(state.currentTextEditingValue.text, '侬好啊旁友'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); + }); + + testWidgets('TextField does not leak touch events when deadline has exceeded', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/118340. + var textFieldTapCount = 0; + var prefixTapCount = 0; + var suffixTapCount = 0; + + final FocusNode focusNode = _focusNode(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + focusNode: focusNode, + onTap: () { + textFieldTapCount += 1; + }, + decoration: InputDecoration( + labelText: 'Label', + prefix: ElevatedButton( + onPressed: () { + prefixTapCount += 1; + }, + child: const Text('prefix'), + ), + suffix: ElevatedButton( + onPressed: () { + suffixTapCount += 1; + }, + child: const Text('suffix'), + ), + ), + ), + ), + ), + ); + + // Focus to show the prefix and suffix buttons. + focusNode.requestFocus(); + await tester.pump(); + + TestGesture gesture = await tester.startGesture( + tester.getRect(find.text('prefix')).center, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + await gesture.up(); + expect(textFieldTapCount, 0); + expect(prefixTapCount, 1); + expect(suffixTapCount, 0); + + gesture = await tester.startGesture( + tester.getRect(find.text('suffix')).center, + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + await gesture.up(); + expect(textFieldTapCount, 0); + expect(prefixTapCount, 1); + expect(suffixTapCount, 1); + }); + + testWidgets('prefix/suffix buttons do not leak touch events', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/39376. + + var textFieldTapCount = 0; + var prefixTapCount = 0; + var suffixTapCount = 0; + + final FocusNode focusNode = _focusNode(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + focusNode: focusNode, + onTap: () { + textFieldTapCount += 1; + }, + decoration: InputDecoration( + labelText: 'Label', + prefix: ElevatedButton( + onPressed: () { + prefixTapCount += 1; + }, + child: const Text('prefix'), + ), + suffix: ElevatedButton( + onPressed: () { + suffixTapCount += 1; + }, + child: const Text('suffix'), + ), + ), + ), + ), + ), + ); + + // Focus to show the prefix and suffix buttons. + focusNode.requestFocus(); + await tester.pump(); + + await tester.tap(find.text('prefix')); + expect(textFieldTapCount, 0); + expect(prefixTapCount, 1); + expect(suffixTapCount, 0); + + await tester.tap(find.text('suffix')); + expect(textFieldTapCount, 0); + expect(prefixTapCount, 1); + expect(suffixTapCount, 1); + }); + + testWidgets('autofill info has hint text', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: Center( + child: TextField(decoration: InputDecoration(hintText: 'placeholder text')), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + + expect( + tester.testTextInput.setClientArgs?['autofill'], + containsPair('hintText', 'placeholder text'), + ); + }); + + testWidgets('TextField at rest does not push any layers with alwaysNeedsAddToScene', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: Center(child: TextField())), + ), + ); + + expect(tester.layers.any((Layer layer) => layer.debugSubtreeNeedsAddToScene!), isFalse); + }); + + testWidgets('Focused TextField does not push any layers with alwaysNeedsAddToScene', ( + WidgetTester tester, + ) async { + final FocusNode focusNode = _focusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode)), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + + expect(focusNode.hasFocus, isTrue); + expect(tester.layers.any((Layer layer) => layer.debugSubtreeNeedsAddToScene!), isFalse); + }); + + testWidgets( + 'TextField does not push any layers with alwaysNeedsAddToScene after toolbar is dismissed', + (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode)), + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField)); + + // Bring up the toolbar. + const testValue = 'A B C'; + tester.testTextInput.updateEditingValue(const TextEditingValue(text: testValue)); + await tester.pump(); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap); + expect(state.showToolbar(), true); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Copy'), findsOneWidget); // Toolbar is visible + + // Hide the toolbar + focusNode.unfocus(); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Copy'), findsNothing); // Toolbar is not visible + + expect(tester.layers.any((Layer layer) => layer.debugSubtreeNeedsAddToScene!), isFalse); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('cursor blinking respects TickerMode', (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + Widget builder({required bool tickerMode}) { + return MaterialApp( + home: Material( + child: Center( + child: TickerMode( + enabled: tickerMode, + child: TextField(focusNode: focusNode), + ), + ), + ), + ); + } + + // TickerMode is on, cursor is blinking. + await tester.pumpWidget(builder(tickerMode: true)); + await tester.showKeyboard(find.byType(TextField)); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + final RenderEditable editable = state.renderEditable; + expect(editable.showCursor.value, isTrue); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isTrue); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + + // TickerMode is off, cursor does not blink. + await tester.pumpWidget(builder(tickerMode: false)); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + + // TickerMode is on, cursor blinks again. + await tester.pumpWidget(builder(tickerMode: true)); + expect(editable.showCursor.value, isTrue); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isTrue); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + + // Dismissing focus while tickerMode is off does not start cursor blinking + // when tickerMode is turned on again. + await tester.pumpWidget(builder(tickerMode: false)); + focusNode.unfocus(); + await tester.pump(); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pumpWidget(builder(tickerMode: true)); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + + // Focusing while tickerMode is off does not start cursor blinking... + await tester.pumpWidget(builder(tickerMode: false)); + await tester.showKeyboard(find.byType(TextField)); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + + // ... but it does start when tickerMode is switched on again. + await tester.pumpWidget(builder(tickerMode: true)); + expect(editable.showCursor.value, isTrue); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isFalse); + await tester.pump(state.cursorBlinkInterval); + expect(editable.showCursor.value, isTrue); + }); + + testWidgets( + 'can shift + tap to select with a keyboard (Apple platforms)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'can shift + tap to select with a keyboard (non-Apple platforms)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('shift tapping an unfocused field', (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + final FocusNode focusNode = _focusNode(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller, focusNode: focusNode), + ), + ), + ), + ); + expect(focusNode.hasFocus, isFalse); + + // Put the cursor at the end of the field. + await tester.tapAt(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(controller.selection.baseOffset, 35); + expect(controller.selection.extentOffset, 35); + + // Unfocus the field, but the selection remains. + focusNode.unfocus(); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + expect(controller.selection.baseOffset, 35); + expect(controller.selection.extentOffset, 35); + + // Shift tap in the middle of the field. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + switch (defaultTargetPlatform) { + // Apple platforms start the selection from 0. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection.baseOffset, 0); + + // Other platforms start from the previous selection. + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 35); + } + expect(controller.selection.extentOffset, 20); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'can shift + tap + drag to select with a keyboard (Apple platforms)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + if (isTargetPlatformMobile) { + await gesture.up(); + } + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + if (isTargetPlatformMobile) { + await gesture.down(textOffsetToPosition(tester, 24)); + } + await tester.pumpAndSettle(); + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'can shift + tap + drag to select with a keyboard (non-Apple platforms)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + final bool isTargetPlatformMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.fuchsia; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 23), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + if (isTargetPlatformMobile) { + await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); + } + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Expand the selection a bit. + if (isTargetPlatformMobile) { + await gesture.down(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + } + await gesture.moveTo(textOffsetToPosition(tester, 28)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 28); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Invert the selection. The original selection is not restored like on iOS + // and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 7); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 4); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 8); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 9)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 9); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + await gesture.moveTo(textOffsetToPosition(tester, 26)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + + await gesture.up(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 26); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'can shift + tap + drag to select with a keyboard, reversed (Apple platforms)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + final isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + if (isTargetPlatformMobile) { + await gesture.up(); + } + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + if (isTargetPlatformMobile) { + await gesture.down(textOffsetToPosition(tester, 7)); + } + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The base jumps to the original extent. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 8); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base, which will cause the selection + // to invert back to the original orientation. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets( + 'can shift + tap + drag to select with a keyboard, reversed (non-Apple platforms)', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + final bool isTargetPlatformMobile = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.fuchsia; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + // Make a selection from right to left. + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 8), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + if (isTargetPlatformMobile) { + await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); + } + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Expand the selection a bit. + if (isTargetPlatformMobile) { + await gesture.down(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + } + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 5); + + // Move back to the original selection. + await gesture.moveTo(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 8); + + // Collapse the selection. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Invert the selection. The selection is not restored like it would be on + // iOS and Mac. + await gesture.moveTo(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 24); + + // Continuing to move in the inverted direction expands the selection. + await gesture.moveTo(textOffsetToPosition(tester, 27)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 27); + + // Move back to the original base. + await gesture.moveTo(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 23); + + // Continue to move past the original base. + await gesture.moveTo(textOffsetToPosition(tester, 22)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 22); + + // Continuing to select in this direction selects just like it did + // originally. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + + // Releasing the shift key has no effect; the selection continues as the + // mouse continues to move. + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 16); + await gesture.moveTo(textOffsetToPosition(tester, 14)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + + await gesture.up(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 14); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.windows, + }), + ); + + // Regression test for https://github.com/flutter/flutter/issues/101587. + testWidgets( + 'Right clicking menu behavior', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoCupertinoToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + final Offset midBlah2 = textOffsetToPosition(tester, 8); + + // Right click the second word. + final TestGesture gesture = await tester.startGesture( + midBlah2, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection, const TextSelection(baseOffset: 6, extentOffset: 11)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + } + + // Right click the first word. + await gesture.down(midBlah1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + } + }, + variant: TargetPlatformVariant.all(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Selection handles should not show when using a mouse on non-Apple platforms', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/168252. + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoMaterialToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset secondBlah = textOffsetToPosition(tester, 8); + + // Right click the second word using a mouse. + final TestGesture gesture = await tester.startGesture( + secondBlah, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + } + + // Press select all. + await tester.tap(find.text('Select all'), kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 11)); + + // Selection handles are hidden. + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.selectionOverlay, isNotNull); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Selection handles should not show when using a mouse on Apple platforms using Flutter context menu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/168252. + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expectNoCupertinoToolbar(); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset firstBlah = textOffsetToPosition(tester, 5); + + // Click at the end of blah1. + await tester.tapAt(firstBlah, kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + + // Right click the same position to reveal the context menu. + await tester.tapAt(firstBlah, kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 5)); + expectCupertinoToolbarForCollapsedSelection(); + + // Press select all. + await tester.tap(find.text('Select All'), kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 11)); + + // Selection handles are hidden. + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.selectionOverlay, isNotNull); + expect(state.selectionOverlay!.handlesAreVisible, isFalse); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('Cannot request focus when canRequestFocus is false', (WidgetTester tester) async { + final FocusNode focusNode = _focusNode(); + + // Default test. The canRequestFocus is true by default and the text field can be focused + await tester.pumpWidget(boilerplate(child: TextField(focusNode: focusNode))); + expect(focusNode.hasFocus, isFalse); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + + // Set canRequestFocus to false: the text field cannot be focused when it is tapped/long pressed. + await tester.pumpWidget( + boilerplate(child: TextField(focusNode: focusNode, canRequestFocus: false)), + ); + + expect(focusNode.hasFocus, isFalse); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + + // The text field cannot be focused if it is tapped. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + + // The text field cannot be focused if it is long pressed. + await tester.longPress(find.byType(TextField)); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + }); + + group('Right click focus', () { + testWidgets('Can right click to focus multiple times', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/103228 + final FocusNode focusNode1 = _focusNode(); + final FocusNode focusNode2 = _focusNode(); + final key1 = UniqueKey(); + final key2 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + TextField(key: key1, focusNode: focusNode1), + const SizedBox(height: 100.0), + TextField(key: key2, focusNode: focusNode2), + ], + ), + ), + ), + ); + + // Interact with the field to establish the input connection. + await tester.tapAt(tester.getCenter(find.byKey(key1)), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + + await tester.tapAt(tester.getCenter(find.byKey(key2)), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isFalse); + expect(focusNode2.hasFocus, isTrue); + + await tester.tapAt(tester.getCenter(find.byKey(key1)), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + }); + + testWidgets( + 'Can right click to focus on previously selected word on Apple platforms', + (WidgetTester tester) async { + final FocusNode focusNode1 = _focusNode(); + final FocusNode focusNode2 = _focusNode(); + final TextEditingController controller = _textEditingController(text: 'first second'); + final key1 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + TextField(key: key1, controller: controller, focusNode: focusNode1), + Focus(focusNode: focusNode2, child: const Text('focusable')), + ], + ), + ), + ), + ); + + // Interact with the field to establish the input connection. + await tester.tapAt(tester.getCenter(find.byKey(key1)), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + + // Select the second word. + controller.selection = const TextSelection(baseOffset: 6, extentOffset: 12); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.isCollapsed, isFalse); + expect(controller.selection.baseOffset, 6); + expect(controller.selection.extentOffset, 12); + + // Unfocus the first field. + focusNode2.requestFocus(); + await tester.pumpAndSettle(); + + expect(focusNode1.hasFocus, isFalse); + expect(focusNode2.hasFocus, isTrue); + + // Right click the second word in the first field, which is still selected + // even though the selection is not visible. + await tester.tapAt(textOffsetToPosition(tester, 8), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.baseOffset, 6); + expect(controller.selection.extentOffset, 12); + + // Select everything. + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 12); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 12); + + // Unfocus the first field. + focusNode2.requestFocus(); + await tester.pumpAndSettle(); + + // Right click the first word in the first field. + await tester.tapAt(textOffsetToPosition(tester, 2), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode1.hasFocus, isTrue); + expect(focusNode2.hasFocus, isFalse); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 5); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.macOS, + }), + ); + + testWidgets('Right clicking cannot request focus if canRequestFocus is false', ( + WidgetTester tester, + ) async { + final FocusNode focusNode = _focusNode(); + final key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[TextField(key: key, focusNode: focusNode, canRequestFocus: false)], + ), + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byKey(key)), buttons: kSecondaryButton); + await tester.pump(); + + expect(focusNode.hasFocus, isFalse); + }); + }); + + group('context menu', () { + testWidgets( + 'builds AdaptiveTextSelectionToolbar by default', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column(children: <Widget>[TextField(controller: controller)]), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state<EditableTextState>(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'contextMenuBuilder is used in place of the default text selection toolbar', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: <Widget>[ + TextField( + controller: controller, + contextMenuBuilder: + (BuildContext context, EditableTextState editableTextState) { + return Placeholder(key: key); + }, + ), + ], + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(find.byKey(key), findsNothing); + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state<EditableTextState>(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + expect(find.byKey(key), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'contextMenuBuilder changes from default to null', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(key: key, controller: controller), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state<EditableTextState>(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Set contextMenuBuilder to null. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField(key: key, controller: controller, contextMenuBuilder: null), + ), + ), + ); + + // Trigger build one more time... + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Padding( + padding: EdgeInsets.zero, + child: TextField(key: key, controller: controller, contextMenuBuilder: null), + ), + ), + ), + ); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'iOS uses the system context menu by default if supported', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material( + child: TextField(controller: _textEditingController(text: 'one two three')), + ), + ), + ), + ); + + // No context menu shown. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'iOS system context menu does not hide selection handles on onSystemHide', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material( + child: TextField(controller: _textEditingController(text: 'one two three')), + ), + ), + ), + ); + + // No context menu shown. + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(SystemContextMenu), findsOneWidget); + + // Simulate system hiding the menu. + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage(<String, dynamic>{ + 'method': 'ContextMenu.onDismissSystemContextMenu', + }); + + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/platform', + messageBytes, + (ByteData? data) {}, + ); + await tester.pumpAndSettle(); + + expect(find.byType(SystemContextMenu), findsNothing); + + // Selection handles are not hidden. + final Iterable<RenderBox> boxes = tester.renderObjectList<RenderBox>( + find.descendant( + of: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay', + ), + matching: find.byType(CustomPaint), + ), + ); + expect(boxes.length, 2); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + }); + + group('magnifier builder', () { + testWidgets('should build custom magnifier if given', (WidgetTester tester) async { + final Widget customMagnifier = Container(key: UniqueKey()); + final textField = TextField( + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo>? info, + ) => customMagnifier, + ), + ); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final BuildContext context = tester.firstElement(find.byType(Placeholder)); + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + + expect( + textField.magnifierConfiguration!.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isA<Widget>().having( + (Widget widget) => widget.key, + 'built magnifier key equal to passed in magnifier key', + equals(customMagnifier.key), + ), + ); + }); + + group('defaults', () { + testWidgets( + 'should build Magnifier on Android', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: TextField()))); + + final BuildContext context = tester.firstElement(find.byType(TextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isA<TextMagnifier>(), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'should build CupertinoMagnifier on iOS', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: TextField()))); + + final BuildContext context = tester.firstElement(find.byType(TextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isA<CupertinoTextMagnifier>(), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'should build nothing on all platforms but iOS and Android', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: TextField()))); + + final BuildContext context = tester.firstElement(find.byType(TextField)); + final EditableText editableText = tester.widget(find.byType(EditableText)); + final magnifierInfo = ValueNotifier<MagnifierInfo>(MagnifierInfo.empty); + addTearDown(magnifierInfo.dispose); + + expect( + editableText.magnifierConfiguration.magnifierBuilder( + context, + MagnifierController(), + magnifierInfo, + ), + isNull, + ); + }, + variant: TargetPlatformVariant.all( + excluding: <TargetPlatform>{TargetPlatform.iOS, TargetPlatform.android}, + ), + ); + }); + }); + + group('magnifier', () { + late ValueNotifier<MagnifierInfo> magnifierInfo; + final Widget fakeMagnifier = Container(key: UniqueKey()); + + testWidgets('Can drag handles to show, unshow, and update magnifier', ( + WidgetTester tester, + ) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Double tap the 'e' to select 'def'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pump(const Duration(milliseconds: 30)); + + final TextSelection selection = controller.selection; + + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + + // Drag the right handle 2 letters to the right. + final Offset handlePos = endpoints.last.point + const Offset(1.0, 1.0); + final TestGesture gesture = await tester.startGesture(handlePos); + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length - 2)); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + final Offset firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, testValue.length)); + await tester.pump(); + + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }); + + testWidgets( + 'Can drag to show, unshow, and update magnifier', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap at '|a' to move the selection to position 0. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Start a drag gesture to move the selection to the dragged position, showing + // the magnifier. + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 0)); + await tester.pump(); + + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 5); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + Offset firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, 10)); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 10); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // The magnifier should hide when the drag ends. + await gesture.up(); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 10); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Start a double-tap select the word at the tapped position. + await gesture.down(textOffsetToPosition(tester, 1)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + await gesture.down(textOffsetToPosition(tester, 1)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 3); + + // Start a drag gesture to extend the selection word-by-word, showing the + // magnifier. + await gesture.moveTo(textOffsetToPosition(tester, 5)); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 7); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + firstDragGesturePosition = magnifierInfo.value.globalGesturePosition; + + await gesture.moveTo(textOffsetToPosition(tester, 10)); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstDragGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // The magnifier should hide when the drag ends. + await gesture.up(); + await tester.pump(); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Can long press to show, unshow, and update magnifier', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + final isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ), + ); + + const testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap at 'e' to set the selection to position 5 on Android. + // Tap at 'e' to set the selection to the closest word edge, which is position 4 on iOS. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 5 : 7); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Long press the 'e' to select 'def' on Android and show magnifier. + // Long press the 'e' to move the cursor in front of the 'e' on iOS and show the magnifier. + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, testValue.indexOf('e')), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 4 : 5); + expect(controller.selection.extentOffset, isTargetPlatformAndroid ? 7 : 5); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + final Offset firstLongPressGesturePosition = magnifierInfo.value.globalGesturePosition; + + // Move the gesture to 'h' on Android to update the magnifier and select 'ghi'. + // Move the gesture to 'h' on iOS to update the magnifier and move the cursor to 'h'. + await gesture.moveTo(textOffsetToPosition(tester, testValue.indexOf('h'))); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 4 : 9); + expect(controller.selection.extentOffset, isTargetPlatformAndroid ? 11 : 9); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstLongPressGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // End the long press to hide the magnifier. + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Can double tap and drag to show, unshow, and update magnifier', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(); + MagnifierController? magnifierController; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierController = controller; + return TextMagnifier.adaptiveMagnifierConfiguration.magnifierBuilder( + context, + controller, + localMagnifierInfo, + ); + }, + ), + ), + ), + ), + ), + ); + + const testValue = 'one two three four five six seven'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Tap at 'e' to set the selection to the closest word edge, which is position 3 on iOS. + final Offset initialPosition = textOffsetToPosition(tester, testValue.indexOf('e')); + await tester.tapAt(initialPosition); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 3); + expect(magnifierController, isNull); + + // Double tap the 'e' to select 'one'. + final TestGesture gesture = await tester.startGesture(initialPosition); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await gesture.down(initialPosition); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 3); + expect(magnifierController, isNull); + + // Drag immediately after the double tap to select 'one two three four' and show the magnifier. + await gesture.moveTo(textOffsetToPosition(tester, 16)); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 18); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, true); + + // Dragging down at the same position should hide the cupertino magnifier when it + // exceeds its `hideBelowThreshold`. + await gesture.moveTo(textOffsetToPosition(tester, 16) + const Offset(0.0, 50.0)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 18); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, false); + + // Keep draging to select 'one two three four five' while the position continues to + // exceed the `hideBelowThreshold` keeping the magnifier hidden. + await gesture.moveTo(textOffsetToPosition(tester, 20) + const Offset(0.0, 50.0)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 23); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, false); + + // Remove offset that is used to exceed threshold, this should reveal the magnifier. + await gesture.moveTo(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 23); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, true); + + // End the drag to hide the magnifier. + await gesture.up(); + await tester.pumpAndSettle(); + expect(magnifierController, isNotNull); + expect(magnifierController!.shown, false); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'cancelling long press hides magnifier', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/167879 + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ), + ); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(TextField)), + ); + + await tester.pumpAndSettle(kLongPressTimeout); + + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + // Cancel the long press to hide the magnifier. + await gesture.cancel(); + await tester.pumpAndSettle(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'TextField cursor appears only when focused', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + focusNode: focusNode, + dragStartBehavior: DragStartBehavior.down, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ), + ); + + final Offset fieldCenter = tester.getCenter(find.byType(EditableText)); + final TestGesture gesture = await tester.startGesture(fieldCenter); + await gesture.moveBy(const Offset(30, 0)); + await tester.pumpAndSettle(); + + // The blinking cursor should NOT be shown. + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + expect(focusNode.hasFocus, isFalse); + expect(editableTextState.cursorCurrentlyVisible, isFalse); + + // Simulate long press again. + await tester.pump(); + await tester.longPress(find.byType(EditableText)); + await tester.pumpAndSettle(); + + // The blinking cursor should now be shown. + expect(focusNode.hasFocus, isTrue); + expect(editableTextState.cursorCurrentlyVisible, isTrue); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + + testWidgets( + 'magnifier does not show when tapping outside field', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/128321 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(20), + child: TextField( + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> localMagnifierInfo, + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + onTapOutside: (PointerDownEvent event) { + FocusManager.instance.primaryFocus?.unfocus(); + }, + ), + ), + ), + ), + ); + + await tester.tapAt(tester.getCenter(find.byType(TextField))); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + final TestGesture gesture = await tester.startGesture( + tester.getBottomLeft(find.byType(TextField)) - const Offset(10.0, 20.0), + ); + await tester.pump(); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + await gesture.up(); + await tester.pump(); + + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + }); + + group('TapRegion integration', () { + testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: Opacity( + opacity: 0.5, + child: TextField( + autofocus: true, + focusNode: focusNode, + decoration: const InputDecoration( + hintText: 'Placeholder', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isFalse); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets("Tapping outside doesn't lose focus on mobile", (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: Opacity( + opacity: 0.5, + child: TextField( + autofocus: true, + focusNode: focusNode, + decoration: const InputDecoration( + hintText: 'Placeholder', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + // Focus is lost on mobile browsers, but not mobile apps. + expect(focusNode.hasPrimaryFocus, kIsWeb ? isFalse : isTrue); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets( + "Tapping on toolbar doesn't lose focus", + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + final TextEditingController controller = _textEditingController(text: 'A B C'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: Opacity( + opacity: 0.5, + child: TextField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration(hintText: 'Placeholder'), + ), + ), + ), + ), + ), + ), + ); + + // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + state.renderEditable.selectWordsInRange( + from: Offset.zero, + cause: SelectionChangedCause.tap, + ); + + final Offset aPosition = textOffsetToPosition(tester, 1); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + aPosition, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Sanity check that the toolbar widget exists. + expect(find.text('Copy'), findsOneWidget); + expect(focusNode.hasPrimaryFocus, isTrue); + + // Now tap on it to see if we lose focus. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }, + variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] On the web, the toolbar isn't rendered by Flutter. + ); + + testWidgets("Tapping on input decorator doesn't lose focus", (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test Node'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 100.0, + child: Opacity( + opacity: 0.5, + child: TextField( + autofocus: true, + focusNode: focusNode, + decoration: const InputDecoration( + hintText: 'Placeholder', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + final Rect decorationBox = tester.getRect(find.byType(TextField)); + // Tap just inside the decoration, but not inside the EditableText. + await tester.tapAt(decorationBox.topLeft + const Offset(1, 1)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }, variant: TargetPlatformVariant.all()); + + // PointerDownEvents can't be trackpad events, apparently, so we skip that one. + for (final PointerDeviceKind pointerDeviceKind + in PointerDeviceKind.values.toSet()..remove(PointerDeviceKind.trackpad)) { + testWidgets( + 'Default TextField handling of onTapOutside follows platform conventions for ${pointerDeviceKind.name}', + (WidgetTester tester) async { + final focusNode = FocusNode(debugLabel: 'Test'); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: <Widget>[ + const Text('Outside'), + TextField(autofocus: true, focusNode: focusNode), + ], + ), + ), + ), + ); + await tester.pump(); + + Future<void> click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: pointerDeviceKind, + ); + await gesture.up(); + await gesture.removePointer(); + } + + expect(focusNode.hasPrimaryFocus, isTrue); + + await click(find.text('Outside')); + + switch (pointerDeviceKind) { + case PointerDeviceKind.touch: + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + expect(focusNode.hasPrimaryFocus, equals(!kIsWeb)); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(focusNode.hasPrimaryFocus, isFalse); + } + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.unknown: + expect(focusNode.hasPrimaryFocus, isFalse); + } + }, + variant: TargetPlatformVariant.all(), + ); + } + }); + + testWidgets( + 'Builds the corresponding default spell check toolbar by platform', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = true; + late final BuildContext builderContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + builderContext = context; + return const TextField( + autofocus: true, + spellCheckConfiguration: SpellCheckConfiguration(), + ); + }, + ), + ), + ), + ), + ); + + // Allow the autofocus to take effect. + await tester.pump(); + + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + editableTextState.spellCheckResults = const SpellCheckResults('', <SuggestionSpan>[ + SuggestionSpan(TextRange(start: 0, end: 0), <String>['something']), + ]); + final Widget spellCheckToolbar = TextField.defaultSpellCheckSuggestionsToolbarBuilder( + builderContext, + editableTextState, + ); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(spellCheckToolbar, isA<CupertinoSpellCheckSuggestionsToolbar>()); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(spellCheckToolbar, isA<SpellCheckSuggestionsToolbar>()); + } + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'Builds the corresponding default spell check configuration by platform', + (WidgetTester tester) async { + tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue = true; + + final SpellCheckConfiguration expectedConfiguration; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expectedConfiguration = SpellCheckConfiguration( + misspelledTextStyle: CupertinoTextField.cupertinoMisspelledTextStyle, + misspelledSelectionColor: CupertinoTextField.kMisspelledSelectionColor, + spellCheckService: DefaultSpellCheckService(), + spellCheckSuggestionsToolbarBuilder: + CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expectedConfiguration = SpellCheckConfiguration( + misspelledTextStyle: TextField.materialMisspelledTextStyle, + spellCheckService: DefaultSpellCheckService(), + spellCheckSuggestionsToolbarBuilder: + TextField.defaultSpellCheckSuggestionsToolbarBuilder, + ); + } + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: TextField(autofocus: true, spellCheckConfiguration: SpellCheckConfiguration()), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + + expect( + editableTextState.spellCheckConfiguration.misspelledTextStyle, + expectedConfiguration.misspelledTextStyle, + ); + expect( + editableTextState.spellCheckConfiguration.misspelledSelectionColor, + expectedConfiguration.misspelledSelectionColor, + ); + expect( + editableTextState.spellCheckConfiguration.spellCheckService.runtimeType, + expectedConfiguration.spellCheckService.runtimeType, + ); + expect( + editableTextState.spellCheckConfiguration.spellCheckSuggestionsToolbarBuilder, + expectedConfiguration.spellCheckSuggestionsToolbarBuilder, + ); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.android, + TargetPlatform.iOS, + }), + ); + + testWidgets( + 'text selection toolbar is hidden on tap down on desktop platforms', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'blah1 blah2'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: TextField(controller: controller)), + ), + ), + ); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 8), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + gesture = await tester.startGesture( + textOffsetToPosition(tester, 2), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + // After the gesture is down but not up, the toolbar is already gone. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + variant: TargetPlatformVariant.all(excluding: TargetPlatformVariant.mobile().values), + ); + + testWidgets( + 'Text processing actions are added to the toolbar', + (WidgetTester tester) async { + const initialText = 'I love Flutter'; + final TextEditingController controller = _textEditingController(text: initialText); + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Long press to put the cursor after the "F". + final int index = initialText.indexOf('F'); + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14)); + + // The toolbar is visible and the text processing actions are visible on Android. + final areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android; + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text(fakeAction1Label), areTextActionsSupported ? findsOneWidget : findsNothing); + expect(find.text(fakeAction2Label), areTextActionsSupported ? findsOneWidget : findsNothing); + }, + variant: TargetPlatformVariant.all(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Text processing actions are not added to the toolbar for obscured text', + (WidgetTester tester) async { + const initialText = 'I love Flutter'; + final TextEditingController controller = _textEditingController(text: initialText); + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(obscureText: true, controller: controller)), + ), + ); + + // Long press to put the cursor after the "F". + final int index = initialText.indexOf('F'); + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 14)); + + // The toolbar is visible but does not contain the text processing actions. + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.text(fakeAction1Label), findsNothing); + expect(find.text(fakeAction2Label), findsNothing); + }, + variant: TargetPlatformVariant.all(), + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Text processing actions are not added to the toolbar if selection is collapsed (Android only)', + (WidgetTester tester) async { + const initialText = 'I love Flutter'; + final TextEditingController controller = _textEditingController(text: initialText); + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Open the text selection toolbar. + await showSelectionMenuAt(tester, controller, initialText.indexOf('F')); + await skipPastScrollingAnimation(tester); + + // The toolbar is visible but does not contain the text processing actions. + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(controller.selection.isCollapsed, true); + + expect(find.text(fakeAction1Label), findsNothing); + expect(find.text(fakeAction2Label), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Invoke a text processing action that does not return a value (Android only)', + (WidgetTester tester) async { + const initialText = 'I love Flutter'; + final TextEditingController controller = _textEditingController(text: initialText); + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Long press to put the cursor after the "F". + final int index = initialText.indexOf('F'); + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14)); + + // Run an action that does not return a processed text. + await tester.tap(find.text(fakeAction2Label)); + await tester.pump(const Duration(milliseconds: 200)); + + // The action was correctly called. + expect(mockProcessTextHandler.lastCalledActionId, fakeAction2Id); + expect(mockProcessTextHandler.lastTextToProcess, 'Flutter'); + + // The text field was not updated. + expect(controller.text, initialText); + + // The toolbar is no longer visible. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Invoking a text processing action that returns a value replaces the selection (Android only)', + (WidgetTester tester) async { + const initialText = 'I love Flutter'; + final TextEditingController controller = _textEditingController(text: initialText); + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ); + + // Long press to put the cursor after the "F". + final int index = initialText.indexOf('F'); + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14)); + + // Run an action that returns a processed text. + await tester.tap(find.text(fakeAction1Label)); + await tester.pump(const Duration(milliseconds: 200)); + + // The action was correctly called. + expect(mockProcessTextHandler.lastCalledActionId, fakeAction1Id); + expect(mockProcessTextHandler.lastTextToProcess, 'Flutter'); + + // The text field was updated. + expect(controller.text, 'I love Flutter!!!'); + + // The toolbar is no longer visible. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets( + 'Invoking a text processing action that returns a value does not replace the selection of a readOnly text field (Android only)', + (WidgetTester tester) async { + const initialText = 'I love Flutter'; + final TextEditingController controller = _textEditingController(text: initialText); + final mockProcessTextHandler = MockProcessTextHandler(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + mockProcessTextHandler.handleMethodCall, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(readOnly: true, controller: controller)), + ), + ); + + // Long press to put the cursor after the "F". + final int index = initialText.indexOf('F'); + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14)); + + // Run an action that returns a processed text. + await tester.tap(find.text(fakeAction1Label)); + await tester.pump(const Duration(milliseconds: 200)); + + // The Action was correctly called. + expect(mockProcessTextHandler.lastCalledActionId, fakeAction1Id); + expect(mockProcessTextHandler.lastTextToProcess, 'Flutter'); + + // The text field was not updated. + expect(controller.text, initialText); + + // The toolbar is no longer visible. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, + ); + + testWidgets('Start the floating cursor on long tap', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final TextEditingController controller = _textEditingController(text: 'abcd'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: const ValueKey<int>(1), + child: TextField(autofocus: true, controller: controller), + ), + ), + ), + ), + ); + // Wait for autofocus. + await tester.pumpAndSettle(); + final Offset textFieldCenter = tester.getCenter(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture(textFieldCenter); + await tester.pump(kLongPressTimeout); + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_floating_cursor.regular_and_floating_both.material.0.png'), + ); + await gesture.moveTo(Offset(10, textFieldCenter.dy)); + await tester.pump(); + await expectLater( + find.byKey(const ValueKey<int>(1)), + matchesGoldenFile('text_field_floating_cursor.only_floating_cursor.material.0.png'), + ); + await gesture.up(); + EditableText.debugDeterministicCursor = false; + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets( + 'Cursor should not blink when long-pressing to show floating cursor.', + (WidgetTester tester) async { + final TextEditingController controller = _textEditingController(text: 'abcdefghijklmnopqr'); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + autofocus: true, + controller: controller, + cursorOpacityAnimates: false, + ), + ), + ), + ), + ); + final EditableTextState state = tester.state(find.byType(EditableText)); + Future<void> checkCursorBlinking({bool isBlinking = true}) async { + var initialShowCursor = true; + if (isBlinking) { + initialShowCursor = state.renderEditable.showCursor.value; + } + await tester.pump(state.cursorBlinkInterval); + expect( + state.cursorCurrentlyVisible, + equals(isBlinking ? !initialShowCursor : initialShowCursor), + ); + await tester.pump(state.cursorBlinkInterval); + expect(state.cursorCurrentlyVisible, equals(initialShowCursor)); + await tester.pump(state.cursorBlinkInterval); + expect( + state.cursorCurrentlyVisible, + equals(isBlinking ? !initialShowCursor : initialShowCursor), + ); + await tester.pump(state.cursorBlinkInterval); + expect(state.cursorCurrentlyVisible, equals(initialShowCursor)); + } + + // Wait for autofocus. + await tester.pumpAndSettle(); + // Before long-pressing, the cursor should blink. + await checkCursorBlinking(); + + final TestGesture gesture = await tester.startGesture( + tester.getTopLeft(find.byType(TextField)), + ); + await tester.pump(kLongPressTimeout); + // When long-pressing, the cursor shouldn't blink. + await checkCursorBlinking(isBlinking: false); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + // When long-pressing and dragging to move the cursor, the cursor shouldn't blink. + await checkCursorBlinking(isBlinking: false); + await gesture.up(); + // After finishing the long-press, the cursor should blink. + await checkCursorBlinking(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('when enabled listens to onFocus events and gains focus', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode)), + ), + ), + ); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + actions: <SemanticsAction>[ + SemanticsAction.tap, + if (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didGainAccessibilityFocus, + if (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didLoseAccessibilityFocus, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + semantics.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets( + 'when disabled does not listen to onFocus events or gain focus', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode, enabled: false)), + ), + ), + ); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics( + id: 2, + children: <TestSemantics>[ + TestSemantics( + id: 3, + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + id: 4, + inputType: ui.SemanticsInputType.text, + currentValueLength: 0, + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isReadOnly, + ], + actions: <SemanticsAction>[ + if (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didGainAccessibilityFocus, + if (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux) + SemanticsAction.didLoseAccessibilityFocus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + expect(focusNode.hasFocus, isFalse); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + semantics.dispose(); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'when receives SemanticsAction.focus while already focused, shows keyboard', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode)), + ), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log.single.method, 'TextInput.show'); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'when receives SemanticsAction.focus while focused but read-only, does not show keyboard', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(focusNode: focusNode, readOnly: true)), + ), + ), + ); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + tester.testTextInput.log.clear(); + expect(focusNode.hasFocus, isTrue); + semanticsOwner.performAction(4, SemanticsAction.focus); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + expect(tester.testTextInput.log, isEmpty); + + semantics.dispose(); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('hintLocales is passed to EditableText', (WidgetTester tester) async { + const hintLocales = <Locale>[Locale('en'), Locale('fr')]; + await tester.pumpWidget( + const MaterialApp( + home: Material(child: TextField(hintLocales: hintLocales)), + ), + ); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.hintLocales, hintLocales); + }); + + testWidgets( + 'readOnly disallows SystemContextMenu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170521. + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + final controller = TextEditingController(text: 'abcdefghijklmnopqr'); + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + controller.dispose(); + }); + + var readOnly = true; + late StateSetter setState; + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextField(readOnly: readOnly, controller: controller); + }, + ), + ), + ), + ), + ); + + final Duration waitDuration = SelectionOverlay.fadeDuration > kDoubleTapTimeout + ? SelectionOverlay.fadeDuration + : kDoubleTapTimeout; + + // Double tap to select the text. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // No error as in https://github.com/flutter/flutter/issues/170521. + + // The Flutter-drawn context menu is shown. The SystemContextMenu is not + // shown because readOnly is true. + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + + // Turn off readOnly and hide the context menu. + setState(() { + readOnly = false; + }); + await tester.tap(find.text('Copy')); + await tester.pump(waitDuration); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to show the context menu again. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // Now iOS is showing the SystemContextMenu while others continue to show + // the Flutter-drawn context menu. + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + expect(find.byType(SystemContextMenu), findsOneWidget); + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + } + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets('TextField does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'X'); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: TextField(controller: controller)), + ), + ), + ); + expect(tester.getSize(find.byType(TextField)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + }); + + testWidgets( + 'Does not crash when editing value changes between consecutive scrolls', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/179164. + final controller = TextEditingController(text: 'text ' * 10000); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller, maxLines: null)), + ), + ); + + final Finder textField = find.byType(TextField); + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + // Long press to select the first word and show the toolbar. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay?.toolbarIsVisible, true); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + + // Scroll down so selection is not visible, and toolbar is scheduled to be shown + // when the selection is once again visible. + final TestGesture gesture = await tester.startGesture(tester.getCenter(textField)); + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pump(); + await gesture.up(); + + // Scroll again before the post-frame callback from the first scroll is run to invalidate + // the data from the first scroll. + controller.value = const TextEditingValue(text: 'a different value'); + + await gesture.down(tester.getCenter(textField)); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // This test should reach the end without crashing. + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + // [intended] only applies to platforms where we supply the context menu. + skip: kIsWeb, + ); + + testWidgets( + 'toolbar should not reappear when editing value changes during a scroll', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/179164. + final controller = TextEditingController(text: 'text ' * 10000); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(controller: controller, maxLines: null)), + ), + ); + + final Finder textField = find.byType(TextField); + final EditableTextState editableTextState = tester.state<EditableTextState>( + find.byType(EditableText), + ); + // Long press to select the first word and show the toolbar. + await tester.longPressAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay?.toolbarIsVisible, true); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 4)); + + // Scroll down so selection is not visible, and toolbar is scheduled to be shown + // when the selection is once again visible. + final TestGesture gesture = await tester.startGesture(tester.getCenter(textField)); + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pump(); + await gesture.up(); + // Change the editing value before the post-frame callback from the scroll is run, + // this should invalidate the data from the scroll and cause the toolbar to not + // reappear. + controller.value = const TextEditingValue(text: 'a different value'); + // Pump and settle to allow postFrameCallbacks to complete. + await tester.pumpAndSettle(); + expect(editableTextState.selectionOverlay?.toolbarIsVisible, false); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + // [intended] only applies to platforms where we supply the context menu. + skip: kIsWeb, + ); + + testWidgets('entering text does not scroll a surrounding PageView', (WidgetTester tester) async { + // regression test for https://github.com/flutter/flutter/issues/19523 + final pageController = PageController(initialPage: 1); + final controller = TextEditingController(); + addTearDown(pageController.dispose); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: PageView( + controller: pageController, + children: <Widget>[ + Container(color: Colors.red), + ColoredBox( + color: Colors.green, + child: TextField(controller: controller), + ), + Container(color: Colors.red), + ], + ), + ), + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + await tester.pumpAndSettle(); + expect(controller.text, ''); + tester.testTextInput.enterText('H'); + final int frames = await tester.pumpAndSettle(); + + // The text input should not trigger any animations, which would indicate + // that the surrounding PageView is incorrectly scrolling back-and-forth. + expect(frames, 1); + + expect(controller.text, 'H'); + }); + + testWidgets('Cursor does not show when not focused', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/106512 . + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextField(focusNode: focusNode, autofocus: true)), + ), + ); + assert(focusNode.hasFocus); + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + focusNode.unfocus(); + await tester.pump(); + + for (var i = 0; i < 10; i += 10) { + // Make sure it does not paint for a period of time. + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + expect(tester.hasRunningAnimations, isFalse); + await tester.pump(const Duration(milliseconds: 29)); + } + + // Refocus and it should paint the caret. + focusNode.requestFocus(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(renderEditable, isNot(paintsExactlyCountTimes(#drawRect, 0))); + }); + + // Regression test for https://github.com/flutter/flutter/issues/175511. + testWidgets('Radio group does not intercept key events when no radio is focused', ( + WidgetTester tester, + ) async { + final log = <String>[]; + late final shortcuts = <ShortcutActivator, Intent>{ + const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(() => log.add('←')), + const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(() => log.add('→')), + const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(() => log.add('↓')), + const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(() => log.add('↑')), + const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(() => log.add('_')), + }; + + final firstRadioFocusNode = FocusNode(); + addTearDown(firstRadioFocusNode.dispose); + final textFieldFocusNode = FocusNode(); + addTearDown(textFieldFocusNode.dispose); + StateSetter setState; + int? groupValue; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Shortcuts( + shortcuts: shortcuts, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter stateSetter) { + setState = stateSetter; + return RadioGroup<int>( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Column( + children: <Widget>[ + Radio<int>(focusNode: firstRadioFocusNode, value: 0), + const RadioListTile<int>(value: 1), + const Radio<int>(value: 2), + TextField(focusNode: textFieldFocusNode), + ], + ), + ); + }, + ), + ), + ), + ), + ); + + // Focus on the first radio and toggle it. + firstRadioFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(firstRadioFocusNode.hasFocus, isTrue); + + // Toggle the second radio with shortcut. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(groupValue, 1); + // Log is empty because radio group handles shortcuts. + expect(log, isEmpty); + + // Toggle the first radio with shortcut. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, isEmpty); + + // Move focus to the text field. + // Now radio group will ignore shortcuts as there are no focused radios. + textFieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Verify that shortcuts are not intercepted by the radio group. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, <String>['←']); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, <String>['←', '→']); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, <String>['←', '→', '↓']); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, <String>['←', '→', '↓', '↑']); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, <String>['←', '→', '↓', '↑', '_']); + + log.clear(); + expect(log, isEmpty); + + // Focus on the first radio. + firstRadioFocusNode.requestFocus(); + await tester.pump(); + expect(groupValue, 0); + expect(firstRadioFocusNode.hasFocus, isTrue); + + // Verify that radio group handles shortcuts again. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(groupValue, 1); + expect(log, isEmpty); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(groupValue, 0); + expect(log, isEmpty); + }); + + // Regression test for https://github.com/flutter/flutter/issues/170521. + testWidgets( + 'when supportsShowingSystemContextMenu is false, SystemContextMenu throws', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'one two three'); + addTearDown(controller.dispose); + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith(supportsShowingSystemContextMenu: false), + child: MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + controller: controller, + contextMenuBuilder: + (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ), + ); + }, + ), + ); + + expect(find.byType(SystemContextMenu), findsNothing); + + await tester.tap(find.byType(TextField)); + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + expect(state.showToolbar(), true); + await tester.pump(); + + expect(tester.takeException(), isAssertionError); + }, + skip: kIsWeb, // [intended] SystemContextMenu is not supported on web. + ); +} + +/// A Simple widget for testing the obscure text. +class _ObscureTextTestWidget extends StatefulWidget { + const _ObscureTextTestWidget({required this.controller}); + + final TextEditingController controller; + @override + _ObscureTextTestWidgetState createState() => _ObscureTextTestWidgetState(); +} + +class _ObscureTextTestWidgetState extends State<_ObscureTextTestWidget> { + bool _obscureText = false; + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: <Widget>[ + TextField(obscureText: _obscureText, controller: widget.controller), + ElevatedButton( + onPressed: () => setState(() { + _obscureText = !_obscureText; + }), + child: const SizedBox.shrink(), + ), + ], + ); + }, + ), + ), + ); + } +} + +typedef FormatEditUpdateCallback = + void Function(TextEditingValue oldValue, TextEditingValue newValue); + +// On web, key events in text fields are handled by the browser. +const bool areKeyEventsHandledByPlatform = isBrowser; + +class CupertinoLocalizationsDelegate extends LocalizationsDelegate<CupertinoLocalizations> { + @override + bool isSupported(Locale locale) => true; + + @override + Future<CupertinoLocalizations> load(Locale locale) => DefaultCupertinoLocalizations.load(locale); + + @override + bool shouldReload(CupertinoLocalizationsDelegate old) => false; +} + +class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { + @override + bool isSupported(Locale locale) => true; + + @override + Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale); + + @override + bool shouldReload(MaterialLocalizationsDelegate old) => false; +} + +class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> { + @override + bool isSupported(Locale locale) => true; + + @override + Future<WidgetsLocalizations> load(Locale locale) => DefaultWidgetsLocalizations.load(locale); + + @override + bool shouldReload(WidgetsLocalizationsDelegate old) => false; +} + +Widget overlay({required Widget child}) { + final entry = OverlayEntry( + builder: (BuildContext context) { + return Center(child: Material(child: child)); + }, + ); + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + return overlayWithEntry(entry); +} + +Widget overlayWithEntry(OverlayEntry entry) { + return Localizations( + locale: const Locale('en', 'US'), + delegates: <LocalizationsDelegate<dynamic>>[ + WidgetsLocalizationsDelegate(), + MaterialLocalizationsDelegate(), + CupertinoLocalizationsDelegate(), + ], + child: DefaultTextEditingShortcuts( + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Overlay(initialEntries: <OverlayEntry>[entry]), + ), + ), + ), + ); +} + +Widget boilerplate({required Widget child, ThemeData? theme}) { + return MaterialApp( + theme: theme, + home: Localizations( + locale: const Locale('en', 'US'), + delegates: <LocalizationsDelegate<dynamic>>[ + WidgetsLocalizationsDelegate(), + MaterialLocalizationsDelegate(), + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center(child: Material(child: child)), + ), + ), + ), + ); +} + +Future<void> skipPastScrollingAnimation(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); +} + +double getOpacity(WidgetTester tester, Finder finder) { + return tester + .widget<FadeTransition>(find.ancestor(of: finder, matching: find.byType(FadeTransition))) + .opacity + .value; +} + +class TestFormatter extends TextInputFormatter { + TestFormatter(this.onFormatEditUpdate); + FormatEditUpdateCallback onFormatEditUpdate; + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + onFormatEditUpdate(oldValue, newValue); + return newValue; + } +} + +FocusNode _focusNode() { + final result = FocusNode(); + addTearDown(result.dispose); + return result; +} + +TextEditingController _textEditingController({String text = ''}) { + final result = TextEditingController(text: text); + addTearDown(result.dispose); + return result; +} diff --git a/packages/material_ui/test/material/text_form_field_restoration_test.dart b/packages/material_ui/test/material/text_form_field_restoration_test.dart new file mode 100644 index 000000000000..e752ca365dff --- /dev/null +++ b/packages/material_ui/test/material/text_form_field_restoration_test.dart @@ -0,0 +1,214 @@ +// 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('TextFormField restorationId is passed to inner TextField', ( + WidgetTester tester, + ) async { + final formState = GlobalKey<FormFieldState<String>>(); + const restorationId = 'text_form_field'; + + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Material( + child: TextFormField( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteraction, + restorationId: restorationId, + initialValue: 'foo', + ), + ), + ), + ); + + expect(find.byType(TextField), findsOne); + + final TextField textField = tester.firstWidget(find.byType(TextField)); + expect(textField.restorationId, restorationId); + }); + + testWidgets('TextFormField value is restorable', (WidgetTester tester) async { + final formState = GlobalKey<FormFieldState<String>>(); + + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Material( + child: TextFormField( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteraction, + restorationId: 'text_form_field', + initialValue: 'foo', + ), + ), + ), + ); + + expect(find.text('foo'), findsOne); + expect(find.text('bar'), findsNothing); + + await tester.enterText(find.byKey(formState), 'bar'); + await tester.pumpAndSettle(); + + expect(find.text('foo'), findsNothing); + expect(find.text('bar'), findsOne); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + + expect(find.text('foo'), findsNothing); + expect(find.text('bar'), findsOne); + + formState.currentState!.reset(); + + expect(find.text('foo'), findsOne); + expect(find.text('bar'), findsNothing); + + await tester.restoreFrom(data); + + expect(find.text('foo'), findsNothing); + expect(find.text('bar'), findsOne); + }); + + testWidgets('State restoration (No Form ancestor) - onUserInteraction error text validation', ( + WidgetTester tester, + ) async { + String? errorText(String? value) => '$value/error'; + late GlobalKey<FormFieldState<String>> formState; + + Widget builder() { + return MaterialApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey<FormFieldState<String>>(); + return Material( + child: TextFormField( + key: formState, + autovalidateMode: AutovalidateMode.onUserInteraction, + restorationId: 'text_form_field', + initialValue: 'foo', + validator: errorText, + ), + ); + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(builder()); + + // No error text is visible yet. + expect(find.text(errorText('foo')!), findsNothing); + + await tester.enterText(find.byType(TextFormField), 'bar'); + await tester.pumpAndSettle(); + expect(find.text(errorText('bar')!), findsOneWidget); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText('bar')!), findsOneWidget); + + // Resetting the form state should remove the error text. + formState.currentState!.reset(); + await tester.pumpAndSettle(); + expect(find.text(errorText('bar')!), findsNothing); + await tester.restartAndRestore(); + // Error text should still be removed after restart and restore. + expect(find.text(errorText('bar')!), findsNothing); + + await tester.restoreFrom(data); + expect(find.text(errorText('bar')!), findsOneWidget); + }); + + testWidgets( + 'State Restoration (No Form ancestor) - validator sets the error text only when validate is called', + (WidgetTester tester) async { + String? errorText(String? value) => '$value/error'; + late GlobalKey<FormFieldState<String>> formState; + + Widget builder(AutovalidateMode mode) { + return MaterialApp( + restorationScopeId: 'app', + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter state) { + formState = GlobalKey<FormFieldState<String>>(); + return Material( + child: TextFormField( + key: formState, + restorationId: 'form_field', + autovalidateMode: mode, + initialValue: 'foo', + validator: errorText, + ), + ); + }, + ), + ), + ), + ), + ); + } + + // Start off not autovalidating. + await tester.pumpWidget(builder(AutovalidateMode.disabled)); + + Future<void> checkErrorText(String testValue) async { + formState.currentState!.reset(); + await tester.pumpWidget(builder(AutovalidateMode.disabled)); + await tester.enterText(find.byType(TextFormField), testValue); + await tester.pump(); + + // We have to manually validate if we're not autovalidating. + expect(find.text(errorText(testValue)!), findsNothing); + formState.currentState!.validate(); + await tester.pump(); + expect(find.text(errorText(testValue)!), findsOneWidget); + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText(testValue)!), findsOneWidget); + + formState.currentState!.reset(); + await tester.pumpAndSettle(); + expect(find.text(errorText(testValue)!), findsNothing); + + await tester.restoreFrom(data); + expect(find.text(errorText(testValue)!), findsOneWidget); + + // Try again with autovalidation. Should validate immediately. + formState.currentState!.reset(); + await tester.pumpWidget(builder(AutovalidateMode.always)); + await tester.enterText(find.byType(TextFormField), testValue); + await tester.pump(); + + expect(find.text(errorText(testValue)!), findsOneWidget); + await tester.restartAndRestore(); + // Error text should be present after restart and restore. + expect(find.text(errorText(testValue)!), findsOneWidget); + } + + await checkErrorText('Test'); + await checkErrorText(''); + }, + ); +} diff --git a/packages/material_ui/test/material/text_form_field_test.dart b/packages/material_ui/test/material/text_form_field_test.dart new file mode 100644 index 000000000000..f845a8ca7e90 --- /dev/null +++ b/packages/material_ui/test/material/text_form_field_test.dart @@ -0,0 +1,1945 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import 'editable_text_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + + setUp(() async { + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); + }); + + testWidgets( + 'can use the desktop cut/copy/paste buttons on Mac', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(controller: controller)), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.byType(CupertinoButton), findsNothing); + + // Paste it at the end. + await gesture.down(textOffsetToPosition(tester, controller.text.length)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Paste')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2blah1'); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Cut the first word. + await gesture.down(midBlah1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + await tester.tap(find.text('Cut')); + await tester.pumpAndSettle(); + expect(controller.text, ' blah2blah1'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0)); + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.macOS}), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets( + 'can use the desktop cut/copy/paste buttons on Windows and Linux', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(controller: controller)), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: 2)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + + // Double tap to select the first word, then right click to show the menu. + final Offset startBlah1 = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(startBlah1, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.down(startBlah1); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.byType(CupertinoButton), findsNothing); + + // Paste it at the end. + gesture = await tester.startGesture( + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + gesture = await tester.startGesture( + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream), + ); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Paste')); + await tester.pumpAndSettle(); + expect(controller.text, 'blah1 blah2blah1'); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Cut the first word. + gesture = await tester.startGesture(midBlah1, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.down(startBlah1); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + gesture = await tester.startGesture( + textOffsetToPosition(tester, controller.text.length), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await gesture.removePointer(); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + await tester.tap(find.text('Cut')); + await tester.pumpAndSettle(); + expect(controller.text, ' blah2blah1'); + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0)); + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.linux, + TargetPlatform.windows, + }), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets( + '$SelectionOverlay is not leaking', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextField(controller: controller)), + ), + ), + ); + + final Offset startBlah1 = textOffsetToPosition(tester, 0); + await tester.tapAt(startBlah1); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(startBlah1); + await tester.pumpAndSettle(); + await tester.pump(); + }, + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets( + 'the desktop cut/copy/paste buttons are disabled for read-only obscured form fields', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField(readOnly: true, obscureText: true, controller: controller), + ), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + const invalidSelection = TextSelection(baseOffset: -1, extentOffset: -1); + expect(controller.selection, invalidSelection); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selection, invalidSelection); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.byType(CupertinoButton), findsNothing); + }, + variant: TargetPlatformVariant.desktop(), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets( + 'the desktop cut/copy buttons are disabled for obscured form fields', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(obscureText: true, controller: controller)), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + const invalidSelection = TextSelection(baseOffset: -1, extentOffset: -1); + expect(controller.selection, invalidSelection); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + + // Make a selection. + await tester.tapAt(midBlah1); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + expect(controller.selection, const TextSelection(baseOffset: 2, extentOffset: 0)); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + midBlah1, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + }, + variant: TargetPlatformVariant.desktop(), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets('TextFormField accepts TextField.noMaxLength as value to maxLength parameter', ( + WidgetTester tester, + ) async { + bool asserted; + try { + TextFormField(maxLength: TextField.noMaxLength); + asserted = false; + } catch (e) { + asserted = true; + } + expect(asserted, false); + }); + + testWidgets('Passes textAlign to underlying TextField', (WidgetTester tester) async { + const TextAlign alignment = TextAlign.center; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(textAlign: alignment)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.textAlign, alignment); + }); + + testWidgets('Passes scrollPhysics to underlying TextField', (WidgetTester tester) async { + const scrollPhysics = ScrollPhysics(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(scrollPhysics: scrollPhysics)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.scrollPhysics, scrollPhysics); + }); + + testWidgets('Passes textAlignVertical to underlying TextField', (WidgetTester tester) async { + const TextAlignVertical textAlignVertical = TextAlignVertical.bottom; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(textAlignVertical: textAlignVertical)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.textAlignVertical, textAlignVertical); + }); + + testWidgets('Passes textInputAction to underlying TextField', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(textInputAction: TextInputAction.next)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.textInputAction, TextInputAction.next); + }); + + testWidgets('Passes onEditingComplete to underlying TextField', (WidgetTester tester) async { + void onEditingComplete() {} + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(onEditingComplete: onEditingComplete)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.onEditingComplete, onEditingComplete); + }); + + testWidgets('Passes cursor attributes to underlying TextField', (WidgetTester tester) async { + const cursorWidth = 3.14; + const cursorHeight = 6.28; + const cursorRadius = Radius.circular(4); + const Color cursorColor = Colors.purple; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + ), + ), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.cursorWidth, cursorWidth); + expect(textFieldWidget.cursorHeight, cursorHeight); + expect(textFieldWidget.cursorRadius, cursorRadius); + expect(textFieldWidget.cursorColor, cursorColor); + }); + + testWidgets('onFieldSubmit callbacks are called', (WidgetTester tester) async { + var called = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + onFieldSubmitted: (String value) { + called = true; + }, + ), + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(called, true); + }); + + testWidgets('onChanged callbacks are called', (WidgetTester tester) async { + late String value; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + onChanged: (String v) { + value = v; + }, + ), + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'Soup'); + await tester.pump(); + expect(value, 'Soup'); + }); + + testWidgets('autovalidateMode is passed to super', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + autovalidateMode: AutovalidateMode.always, + validator: (String? value) { + validateCalled++; + return null; + }, + ), + ), + ), + ), + ); + + expect(validateCalled, 1); + await tester.enterText(find.byType(TextField), 'a'); + await tester.pump(); + expect(validateCalled, 2); + }); + + testWidgets('validate is called if widget is enabled', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + enabled: true, + autovalidateMode: AutovalidateMode.always, + validator: (String? value) { + validateCalled += 1; + return null; + }, + ), + ), + ), + ), + ); + + expect(validateCalled, 1); + await tester.enterText(find.byType(TextField), 'a'); + await tester.pump(); + expect(validateCalled, 2); + }); + + testWidgets('Disabled field hides helper and counter in M2', (WidgetTester tester) async { + const helperText = 'helper text'; + const counterText = 'counter text'; + const errorText = 'error text'; + Widget buildFrame(bool enabled, bool hasError) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: TextFormField( + decoration: InputDecoration( + labelText: 'label text', + helperText: helperText, + counterText: counterText, + errorText: hasError ? errorText : null, + enabled: enabled, + ), + ), + ), + ), + ); + } + + // When enabled is true, the helper/error and counter are visible. + await tester.pumpWidget(buildFrame(true, false)); + Text helperWidget = tester.widget(find.text(helperText)); + Text counterWidget = tester.widget(find.text(counterText)); + expect(helperWidget.style!.color, isNot(equals(Colors.transparent))); + expect(counterWidget.style!.color, isNot(equals(Colors.transparent))); + await tester.pumpWidget(buildFrame(true, true)); + counterWidget = tester.widget(find.text(counterText)); + Text errorWidget = tester.widget(find.text(errorText)); + expect(helperWidget.style!.color, isNot(equals(Colors.transparent))); + expect(errorWidget.style!.color, isNot(equals(Colors.transparent))); + + // When enabled is false, the helper/error and counter are not visible. + await tester.pumpWidget(buildFrame(false, false)); + helperWidget = tester.widget(find.text(helperText)); + counterWidget = tester.widget(find.text(counterText)); + expect(helperWidget.style!.color, equals(Colors.transparent)); + expect(counterWidget.style!.color, equals(Colors.transparent)); + await tester.pumpWidget(buildFrame(false, true)); + errorWidget = tester.widget(find.text(errorText)); + counterWidget = tester.widget(find.text(counterText)); + expect(counterWidget.style!.color, equals(Colors.transparent)); + expect(errorWidget.style!.color, equals(Colors.transparent)); + }); + + testWidgets('passing a buildCounter shows returned widget', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + buildCounter: + (BuildContext context, {int? currentLength, int? maxLength, bool? isFocused}) { + return Text('$currentLength of $maxLength'); + }, + maxLength: 10, + ), + ), + ), + ), + ); + + expect(find.text('0 of 10'), findsOneWidget); + + await tester.enterText(find.byType(TextField), '01234'); + await tester.pump(); + + expect(find.text('5 of 10'), findsOneWidget); + }); + + testWidgets('readonly text form field will hide cursor by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(initialValue: 'readonly', readOnly: true)), + ), + ), + ); + + await tester.showKeyboard(find.byType(TextFormField)); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(tester.testTextInput.hasAnyClients, false); + + await tester.longPress(find.byType(TextFormField)); + await tester.pump(); + + // Context menu should not have paste. + expect(find.text('Select all'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + + // Make sure it does not paint caret for a period of time. + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); + }, skip: isBrowser); // [intended] We do not use Flutter-rendered context menu on the Web. + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + var tapCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + onTap: () { + tapCount += 1; + }, + ), + ), + ), + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(TextField)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(TextField)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + testWidgets('onTapOutside is called upon tap outside', (WidgetTester tester) async { + var tapOutsideCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Column( + children: <Widget>[ + const Text('Outside'), + TextFormField( + autofocus: true, + onTapOutside: (PointerEvent event) { + tapOutsideCount += 1; + }, + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); // Wait for autofocus to take effect. + + expect(tapOutsideCount, 0); + await tester.tap(find.byType(TextFormField)); + await tester.tap(find.text('Outside')); + await tester.tap(find.text('Outside')); + await tester.tap(find.text('Outside')); + expect(tapOutsideCount, 3); + }); + + // Regression test for https://github.com/flutter/flutter/issues/127597. + testWidgets( + 'The second TextFormField is clicked, triggers the onTapOutside callback of the previous TextFormField', + (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey keyC = GlobalKey(); + var outsideClickA = false; + var outsideClickB = false; + var outsideClickC = false; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: <Widget>[ + const Text('Outside'), + Material( + child: TextFormField( + key: keyA, + groupId: 'Group A', + onTapOutside: (PointerDownEvent event) { + outsideClickA = true; + }, + ), + ), + Material( + child: TextFormField( + key: keyB, + groupId: 'Group B', + onTapOutside: (PointerDownEvent event) { + outsideClickB = true; + }, + ), + ), + Material( + child: TextFormField( + key: keyC, + groupId: 'Group C', + onTapOutside: (PointerDownEvent event) { + outsideClickC = true; + }, + ), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + + Future<void> click(Finder finder) async { + await tester.tap(finder); + await tester.enterText(finder, 'Hello'); + await tester.pump(); + } + + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyA)); + await tester.showKeyboard(find.byKey(keyA)); + await tester.idle(); + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyB)); + expect(outsideClickA, true); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyC)); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, false); + + await tester.tap(find.text('Outside')); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, true); + }, + ); + + // Regression test for https://github.com/flutter/flutter/issues/54472. + testWidgets('reset resets the text fields value to the initialValue', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(initialValue: 'initialValue')), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'changedValue'); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(TextFormField), + ); + state.reset(); + + expect(find.text('changedValue'), findsNothing); + expect(find.text('initialValue'), findsOneWidget); + }); + + testWidgets('reset resets the text fields value to the controller initial value', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(text: 'initialValue'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(controller: controller)), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'changedValue'); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(TextFormField), + ); + state.reset(); + + expect(find.text('changedValue'), findsNothing); + expect(find.text('initialValue'), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/34847. + testWidgets("didChange resets the text field's value to empty when passed null", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material(child: Center(child: TextFormField())), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'changedValue'); + await tester.pump(); + expect(find.text('changedValue'), findsOneWidget); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(TextFormField), + ); + state.didChange(null); + + expect(find.text('changedValue'), findsNothing); + expect(find.text(''), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/34847. + testWidgets("reset resets the text field's value to empty when initialValue is null", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material(child: Center(child: TextFormField())), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'changedValue'); + await tester.pump(); + expect(find.text('changedValue'), findsOneWidget); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(TextFormField), + ); + state.reset(); + + expect(find.text('changedValue'), findsNothing); + expect(find.text(''), findsOneWidget); + }); + + // Regression test for https://github.com/flutter/flutter/issues/54472. + testWidgets('didChange changes text fields value', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(initialValue: 'initialValue')), + ), + ), + ); + + expect(find.text('initialValue'), findsOneWidget); + + final FormFieldState<String> state = tester.state<FormFieldState<String>>( + find.byType(TextFormField), + ); + state.didChange('changedValue'); + + expect(find.text('initialValue'), findsNothing); + expect(find.text('changedValue'), findsOneWidget); + }); + + testWidgets('onChanged callbacks value and FormFieldState.value are sync', ( + WidgetTester tester, + ) async { + var called = false; + + late FormFieldState<String> state; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + onChanged: (String value) { + called = true; + expect(value, state.value); + }, + ), + ), + ), + ), + ); + + state = tester.state<FormFieldState<String>>(find.byType(TextFormField)); + + await tester.enterText(find.byType(TextField), 'Soup'); + + expect(called, true); + }); + + testWidgets('autofillHints is passed to super', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField(autofillHints: const <String>[AutofillHints.countryName]), + ), + ), + ), + ); + + final TextField widget = tester.widget(find.byType(TextField)); + expect(widget.autofillHints, equals(const <String>[AutofillHints.countryName])); + }); + + testWidgets('autovalidateMode is passed to super', (WidgetTester tester) async { + var validateCalled = 0; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Scaffold( + body: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (String? value) { + validateCalled++; + return null; + }, + ), + ), + ), + ), + ); + + expect(validateCalled, 0); + await tester.enterText(find.byType(TextField), 'a'); + await tester.pump(); + expect(validateCalled, 1); + }); + + testWidgets('textSelectionControls is passed to super', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Scaffold(body: TextFormField(selectionControls: materialTextSelectionControls)), + ), + ), + ); + + final TextField widget = tester.widget(find.byType(TextField)); + expect(widget.selectionControls, equals(materialTextSelectionControls)); + }); + + testWidgets('TextFormField respects hintTextDirection', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: TextFormField( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Some Label', + hintText: 'Some Hint', + hintTextDirection: TextDirection.ltr, + ), + ), + ), + ), + ), + ); + + final Finder hintTextFinder = find.text('Some Hint'); + + final Text hintText = tester.firstWidget(hintTextFinder); + expect(hintText.textDirection, TextDirection.ltr); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: TextFormField( + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Some Label', + hintText: 'Some Hint', + ), + ), + ), + ), + ), + ); + + final BuildContext context = tester.element(hintTextFinder); + final TextDirection textDirection = Directionality.of(context); + expect(textDirection, TextDirection.rtl); + }); + + testWidgets('Passes scrollController to underlying TextField', (WidgetTester tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(scrollController: scrollController)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.scrollController, scrollController); + }); + + testWidgets('TextFormField changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextFormField(mouseCursor: SystemMouseCursors.grab), + ), + ), + ), + ); + + // Center, which is within the area + final Offset center = tester.getCenter(find.byType(TextFormField)); + // Top left, which is also within the area + final Offset edge = tester.getTopLeft(find.byType(TextFormField)) + const Offset(1, 1); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: center); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.grab, + ); + + // Test default cursor + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MouseRegion(cursor: SystemMouseCursors.forbidden, child: TextFormField()), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + await gesture.moveTo(edge); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + await gesture.moveTo(center); + + // Test default cursor when disabled + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: TextFormField(enabled: false), + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(edge); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + await gesture.moveTo(center); + }); + + // Regression test for https://github.com/flutter/flutter/issues/101587. + testWidgets( + 'Right clicking menu behavior', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'blah1 blah2'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(controller: controller)), + ), + ), + ); + + // Initially, the menu is not shown and there is no selection. + expect(find.byType(CupertinoButton), findsNothing); + expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1)); + + final Offset midBlah1 = textOffsetToPosition(tester, 2); + final Offset midBlah2 = textOffsetToPosition(tester, 8); + + // Right click the second word. + final TestGesture gesture = await tester.startGesture( + midBlah2, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection, const TextSelection(baseOffset: 6, extentOffset: 11)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + } + + // Right click the first word. + await gesture.down(midBlah1); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5)); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection, const TextSelection.collapsed(offset: 8)); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + } + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] we don't supply the cut/copy/paste buttons on the web. + ); + + testWidgets('spellCheckConfiguration passes through to EditableText', ( + WidgetTester tester, + ) async { + final mySpellCheckConfiguration = SpellCheckConfiguration( + spellCheckService: DefaultSpellCheckService(), + misspelledTextStyle: TextField.materialMisspelledTextStyle, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: TextFormField(spellCheckConfiguration: mySpellCheckConfiguration)), + ), + ); + + expect(find.byType(EditableText), findsOneWidget); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + + // Can't do equality comparison on spellCheckConfiguration itself because it + // will have been copied. + expect( + editableText.spellCheckConfiguration?.spellCheckService, + equals(mySpellCheckConfiguration.spellCheckService), + ); + expect( + editableText.spellCheckConfiguration?.misspelledTextStyle, + equals(mySpellCheckConfiguration.misspelledTextStyle), + ); + }); + + testWidgets('magnifierConfiguration passes through to EditableText', (WidgetTester tester) async { + final myTextMagnifierConfiguration = TextMagnifierConfiguration( + magnifierBuilder: + ( + BuildContext context, + MagnifierController controller, + ValueNotifier<MagnifierInfo> notifier, + ) { + return const Placeholder(); + }, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: TextFormField(magnifierConfiguration: myTextMagnifierConfiguration)), + ), + ); + + expect(find.byType(EditableText), findsOneWidget); + + final EditableText editableText = tester.widget(find.byType(EditableText)); + expect(editableText.magnifierConfiguration, equals(myTextMagnifierConfiguration)); + }); + + testWidgets('Passes undoController to undoController TextField', (WidgetTester tester) async { + final undoController = UndoHistoryController(value: UndoHistoryValue.empty); + addTearDown(undoController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(undoController: undoController)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.undoController, undoController); + }); + + testWidgets('Passes cursorOpacityAnimates to cursorOpacityAnimates TextField', ( + WidgetTester tester, + ) async { + const cursorOpacityAnimates = true; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(cursorOpacityAnimates: cursorOpacityAnimates)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.cursorOpacityAnimates, cursorOpacityAnimates); + }); + + testWidgets('Passes contentInsertionConfiguration to contentInsertionConfiguration TextField', ( + WidgetTester tester, + ) async { + final contentInsertionConfiguration = ContentInsertionConfiguration( + onContentInserted: (KeyboardInsertedContent value) {}, + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField(contentInsertionConfiguration: contentInsertionConfiguration), + ), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.contentInsertionConfiguration, contentInsertionConfiguration); + }); + + testWidgets('Passes clipBehavior to clipBehavior TextField', (WidgetTester tester) async { + const Clip clipBehavior = Clip.antiAlias; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(clipBehavior: clipBehavior)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.clipBehavior, clipBehavior); + }); + + testWidgets('Passes scribbleEnabled to scribbleEnabled TextField', (WidgetTester tester) async { + const scribbleEnabled = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(scribbleEnabled: scribbleEnabled)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.scribbleEnabled, scribbleEnabled); + }); + + testWidgets('Passes canRequestFocus to canRequestFocus TextField', (WidgetTester tester) async { + const canRequestFocus = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(canRequestFocus: canRequestFocus)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.canRequestFocus, canRequestFocus); + }); + + testWidgets('Passes onAppPrivateCommand to onAppPrivateCommand TextField', ( + WidgetTester tester, + ) async { + void onAppPrivateCommand(String action, Map<String, dynamic> data) {} + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(onAppPrivateCommand: onAppPrivateCommand)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.onAppPrivateCommand, onAppPrivateCommand); + }); + + testWidgets('Passes selectionHeightStyle to selectionHeightStyle TextField', ( + WidgetTester tester, + ) async { + const BoxHeightStyle selectionHeightStyle = BoxHeightStyle.max; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(selectionHeightStyle: selectionHeightStyle)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.selectionHeightStyle, selectionHeightStyle); + }); + + testWidgets('Passes selectionWidthStyle to selectionWidthStyle TextField', ( + WidgetTester tester, + ) async { + const BoxWidthStyle selectionWidthStyle = BoxWidthStyle.max; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(selectionWidthStyle: selectionWidthStyle)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.selectionWidthStyle, selectionWidthStyle); + }); + + testWidgets('Passes dragStartBehavior to dragStartBehavior TextField', ( + WidgetTester tester, + ) async { + const DragStartBehavior dragStartBehavior = DragStartBehavior.down; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(dragStartBehavior: dragStartBehavior)), + ), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.dragStartBehavior, dragStartBehavior); + }); + + testWidgets('Passes onTapAlwaysCalled to onTapAlwaysCalled TextField', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material(child: Center(child: TextFormField(onTapAlwaysCalled: true))), + ), + ); + + final Finder textFieldFinder = find.byType(TextField); + expect(textFieldFinder, findsOneWidget); + + final TextField textFieldWidget = tester.widget(textFieldFinder); + expect(textFieldWidget.onTapAlwaysCalled, isTrue); + }); + + testWidgets('Passes hintLocales to hintLocales TextField', (WidgetTester tester) async { + const hintLocales = <Locale>[Locale('fr', 'FR')]; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center(child: TextFormField(hintLocales: hintLocales)), + ), + ), + ); + + final TextField textFieldWidget = tester.widget(find.byType(TextField)); + expect(textFieldWidget.hintLocales, hintLocales); + }); + + testWidgets('Error color for cursor while validating', (WidgetTester tester) async { + const themeErrorColor = Color(0xff111111); + const errorStyleColor = Color(0xff777777); + const cursorErrorColor = Color(0xffbbbbbb); + + Widget buildWidget({Color? errorStyleColor, Color? cursorErrorColor}) { + return MaterialApp( + theme: ThemeData(colorScheme: const ColorScheme.light(error: themeErrorColor)), + home: Material( + child: Center( + child: TextFormField( + enabled: true, + autovalidateMode: AutovalidateMode.always, + decoration: InputDecoration(errorStyle: TextStyle(color: errorStyleColor)), + cursorErrorColor: cursorErrorColor, + validator: (String? value) { + return 'Please enter value'; + }, + ), + ), + ), + ); + } + + Future<void> runTest(Widget widget, {required Color expectedColor}) async { + await tester.pumpWidget(widget); + await tester.enterText(find.byType(TextField), 'a'); + final EditableText textField = tester.widget(find.byType(EditableText).first); + await tester.pump(); + expect(textField.cursorColor, expectedColor); + } + + await runTest(buildWidget(), expectedColor: themeErrorColor); + await runTest(buildWidget(errorStyleColor: errorStyleColor), expectedColor: errorStyleColor); + await runTest(buildWidget(cursorErrorColor: cursorErrorColor), expectedColor: cursorErrorColor); + await runTest( + buildWidget(errorStyleColor: errorStyleColor, cursorErrorColor: cursorErrorColor), + expectedColor: cursorErrorColor, + ); + }); + + testWidgets('TextFormField onChanged is called when the form is reset', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/123009. + final stateKey = GlobalKey<FormFieldState<String>>(); + final formKey = GlobalKey<FormState>(); + var value = 'initialValue'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: TextFormField( + key: stateKey, + initialValue: value, + onChanged: (String newValue) { + value = newValue; + }, + ), + ), + ), + ), + ); + + // Initial value is 'initialValue'. + expect(stateKey.currentState!.value, 'initialValue'); + expect(value, 'initialValue'); + + // Change value to 'changedValue'. + await tester.enterText(find.byType(TextField), 'changedValue'); + expect(stateKey.currentState!.value, 'changedValue'); + expect(value, 'changedValue'); + + // Should be back to 'initialValue' when the form is reset. + formKey.currentState!.reset(); + await tester.pump(); + expect(stateKey.currentState!.value, 'initialValue'); + expect(value, 'initialValue'); + }); + + testWidgets('isValid returns false when forceErrorText is set and will change error display', ( + WidgetTester tester, + ) async { + final fieldKey1 = GlobalKey<FormFieldState<String>>(); + final fieldKey2 = GlobalKey<FormFieldState<String>>(); + const forceErrorText = 'Forcing error.'; + const validString = 'Valid string'; + String? validator(String? s) => s == validString ? null : 'Error text'; + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: ListView( + children: <Widget>[ + TextFormField( + key: fieldKey1, + initialValue: validString, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + TextFormField( + key: fieldKey2, + initialValue: '', + forceErrorText: forceErrorText, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + expect(fieldKey1.currentState!.isValid, isTrue); + expect(fieldKey1.currentState!.hasError, isFalse); + expect(fieldKey2.currentState!.isValid, isFalse); + expect(fieldKey2.currentState!.hasError, isTrue); + }); + + testWidgets('forceErrorText will override InputDecoration.error when both are provided', ( + WidgetTester tester, + ) async { + const forceErrorText = 'Forcing error'; + const decorationErrorText = 'Decoration'; + + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + child: TextFormField( + forceErrorText: forceErrorText, + decoration: const InputDecoration(errorText: decorationErrorText), + ), + ), + ), + ), + ), + ), + ), + ); + + expect(find.text(forceErrorText), findsOne); + expect(find.text(decorationErrorText), findsNothing); + }); + + // Regression test for https://github.com/flutter/flutter/issues/135292. + testWidgets('Widget returned by errorBuilder is shown', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextFormField( + autovalidateMode: AutovalidateMode.always, + validator: (String? value) => 'validation error', + errorBuilder: (BuildContext context, String errorText) => Text('**$errorText**'), + ), + ), + ), + ), + ); + + await tester.pump(); + + expect(find.text('**validation error**'), findsOneWidget); + }); + + testWidgets( + 'TextFormField asserts when both errorBuilder and decoration.errorText are provided', + (WidgetTester tester) async { + expect( + () => TextFormField( + decoration: const InputDecoration(errorText: 'Decoration error'), + errorBuilder: (BuildContext context, String errorText) { + return Text(errorText); + }, + ), + throwsAssertionError, + ); + }, + ); + + group('context menu', () { + testWidgets( + 'iOS uses the system context menu by default if supported', + (WidgetTester tester) async { + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + }); + + final controller = TextEditingController(text: 'one two three'); + addTearDown(controller.dispose); + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material(child: TextField(controller: controller)), + ), + ), + ); + + // No context menu shown. + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to select the first word and show the menu. + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 1)); + await tester.pump(SelectionOverlay.fadeDuration); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsOneWidget); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + }); + + testWidgets( + 'readOnly disallows SystemContextMenu', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170521. + tester.platformDispatcher.supportsShowingSystemContextMenu = true; + final controller = TextEditingController(text: 'abcdefghijklmnopqr'); + addTearDown(() { + tester.platformDispatcher.resetSupportsShowingSystemContextMenu(); + tester.view.reset(); + controller.dispose(); + }); + + var readOnly = true; + late StateSetter setState; + + await tester.pumpWidget( + // Don't wrap with the global View so that the change to + // platformDispatcher is read. + wrapWithView: false, + View( + view: tester.view, + child: MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextFormField(readOnly: readOnly, controller: controller); + }, + ), + ), + ), + ), + ); + + final Duration waitDuration = SelectionOverlay.fadeDuration > kDoubleTapTimeout + ? SelectionOverlay.fadeDuration + : kDoubleTapTimeout; + + // Double tap to select the text. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // No error as in https://github.com/flutter/flutter/issues/170521. + + // The Flutter-drawn context menu is shown. The SystemContextMenu is not + // shown because readOnly is true. + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + + // Turn off readOnly and hide the context menu. + setState(() { + readOnly = false; + }); + await tester.tap(find.text('Copy')); + await tester.pump(waitDuration); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(SystemContextMenu), findsNothing); + + // Double tap to show the context menu again. + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(kDoubleTapTimeout ~/ 2); + await tester.tapAt(textOffsetToPosition(tester, 5)); + await tester.pump(waitDuration); + + // Now iOS is showing the SystemContextMenu while others continue to show + // the Flutter-drawn context menu. + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + expect(find.byType(SystemContextMenu), findsOneWidget); + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + } + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + // Regression test for https://github.com/flutter/flutter/issues/176391. + testWidgets('TextFormField can inherit decoration from local InputDecorationThemeData', ( + WidgetTester tester, + ) async { + const decoration = InputDecoration(labelText: 'Label'); + const decorationTheme = InputDecorationThemeData(labelStyle: TextStyle(color: Colors.green)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InputDecorationTheme( + data: decorationTheme, + child: TextFormField(decoration: decoration), + ), + ), + ), + ); + + final InputDecorator decorator = tester.widget(find.byType(InputDecorator)); + final InputDecoration expectedDecoration = decoration + .applyDefaults(decorationTheme) + .copyWith(enabled: true, hintMaxLines: 1); + expect(decorator.decoration, expectedDecoration); + }); + + testWidgets('TextFormField does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = TextEditingController(text: 'X'); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center(child: TextFormField(controller: controller)), + ), + ), + ); + expect(tester.getSize(find.byType(TextFormField)), Size.zero); + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + }); + + // Regression test for https://github.com/flutter/flutter/issues/180056. + testWidgets('TextFormField resets to initial value after setState', (WidgetTester tester) async { + final formKey = GlobalKey<FormState>(); + final controller = TextEditingController(text: 'Initial Value'); + addTearDown(controller.dispose); + + late StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setter) { + setState = setter; + return Form( + key: formKey, + child: TextFormField(controller: controller), + ); + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'Changed'); + await tester.pump(); + expect(controller.text, 'Changed'); + + setState(() {}); + await tester.pump(); + + formKey.currentState!.reset(); + await tester.pump(); + + expect(controller.text, 'Initial Value'); + }); +} diff --git a/packages/material_ui/test/material/text_selection_test.dart b/packages/material_ui/test/material/text_selection_test.dart new file mode 100644 index 000000000000..ab1f6a51bf8f --- /dev/null +++ b/packages/material_ui/test/material/text_selection_test.dart @@ -0,0 +1,849 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/clipboard_utils.dart'; +import 'editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockClipboard = MockClipboard(); + + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + mockClipboard.handleMethodCall, + ); + // Fill the clipboard so that the Paste option is available in the text + // selection menu. + await Clipboard.setData(const ClipboardData(text: 'clipboard data')); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + group('canSelectAll', () { + Widget createEditableText({required Key key, String? text, TextSelection? selection}) { + final controller = TextEditingController(text: text) + ..selection = selection ?? const TextSelection.collapsed(offset: -1); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + return MaterialApp( + home: EditableText( + key: key, + controller: controller, + focusNode: focusNode, + style: const TextStyle(), + cursorColor: Colors.black, + backgroundCursorColor: Colors.black, + ), + ); + } + + testWidgets('should return false when there is no text', (WidgetTester tester) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget(createEditableText(key: key)); + expect(materialTextSelectionControls.canSelectAll(key.currentState!), false); + }); + + testWidgets('should return true when there is text and collapsed selection', ( + WidgetTester tester, + ) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget(createEditableText(key: key, text: '123')); + expect(materialTextSelectionControls.canSelectAll(key.currentState!), true); + }); + + testWidgets('should return true when there is text and partial uncollapsed selection', ( + WidgetTester tester, + ) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget( + createEditableText( + key: key, + text: '123', + selection: const TextSelection(baseOffset: 1, extentOffset: 2), + ), + ); + expect(materialTextSelectionControls.canSelectAll(key.currentState!), true); + }); + + testWidgets('should return false when there is text and full selection', ( + WidgetTester tester, + ) async { + final GlobalKey<EditableTextState> key = GlobalKey(); + await tester.pumpWidget( + createEditableText( + key: key, + text: '123', + selection: const TextSelection(baseOffset: 0, extentOffset: 3), + ), + ); + expect(materialTextSelectionControls.canSelectAll(key.currentState!), false); + }); + }); + + group('Text selection menu overflow (Android)', () { + testWidgets( + 'All menu items show when they fit.', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center( + child: Material(child: TextField(controller: controller)), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Tap to place the cursor in the field, then tap the handle to show the + // selection menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 1); + final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); + await tester.tapAt(handlePos, pointer: 7); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + + // Long press to select a word and show the full selection menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.longPressAt(textOffset); + await tester.pump(); + await tester.pump(); + + // The full menu is shown without the more button. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + "When menu items don't fit, an overflow menu is used.", + (WidgetTester tester) async { + // Set the screen size to more narrow, so that Select all can't fit. + tester.view.physicalSize = const Size(1000, 800); + addTearDown(tester.view.reset); + + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center( + child: Material(child: TextField(controller: controller)), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Long press to show the menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.longPressAt(textOffset); + await tester.pumpAndSettle(); + + // The last button is missing, and a more button is shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); + final Offset cutOffset = tester.getTopLeft(find.text('Cut')); + + // Tapping the button shows the overflow menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); + + // The back button is at the bottom of the overflow menu. + final Offset selectAllOffset = tester.getTopLeft(find.text('Select all')); + final Offset moreOffset = tester.getTopLeft(find.byType(IconButton)); + expect(moreOffset.dy, greaterThan(selectAllOffset.dy)); + + // The overflow menu grows upward. + expect(selectAllOffset.dy, lessThan(cutOffset.dy)); + + // Tapping the back button shows the selection menu again. + expect(find.byType(IconButton), findsOneWidget); + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'A smaller menu bumps more items to the overflow menu.', + (WidgetTester tester) async { + // Set the screen size so narrow that only Cut and Copy can fit. + tester.view.physicalSize = const Size(800, 800); + addTearDown(tester.view.reset); + + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Center( + child: Material(child: TextField(controller: controller)), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Long press to show the menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.longPressAt(textOffset); + await tester.pumpAndSettle(); + + // The last two buttons are missing, and a more button is shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); + + // Tapping the button shows the overflow menu, which contains both buttons + // missing from the main menu, and a back button. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); + + // Tapping the back button shows the selection menu again. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'When the menu renders below the text, the overflow menu back button is at the top.', + (WidgetTester tester) async { + // Set the screen size to more narrow, so that Select all can't fit. + tester.view.physicalSize = const Size(1000, 800); + addTearDown(tester.view.reset); + + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Align( + alignment: Alignment.topLeft, + child: Material(child: TextField(controller: controller)), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Long press to show the menu. + final Offset textOffset = textOffsetToPosition(tester, 1); + await tester.longPressAt(textOffset); + await tester.pumpAndSettle(); + + // The last button is missing, and a more button is shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); + final Offset cutOffset = tester.getTopLeft(find.text('Cut')); + + // Tapping the button shows the overflow menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); + + // The back button is at the top of the overflow menu. + final Offset selectAllOffset = tester.getTopLeft(find.text('Select all')); + final Offset moreOffset = tester.getTopLeft(find.byType(IconButton)); + expect(moreOffset.dy, lessThan(selectAllOffset.dy)); + + // The overflow menu grows downward. + expect(selectAllOffset.dy, greaterThan(cutOffset.dy)); + + // Tapping the back button shows the selection menu again. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'When the menu items change, the menu is closed and _closedWidth reset.', + (WidgetTester tester) async { + // Set the screen size to more narrow, so that Select all can't fit. + tester.view.physicalSize = const Size(1000, 800); + addTearDown(tester.view.reset); + + final controller = TextEditingController(text: 'abc def ghi'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, useMaterial3: false), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Align( + alignment: Alignment.topLeft, + child: Material(child: TextField(controller: controller)), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Share'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); // 'More' button. + + // Tap to place the cursor and tap again to show the menu without a + // selection. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 1); + final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); + await tester.tapAt(handlePos, pointer: 7); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Share'), findsNothing); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + + // Tap Select all and measure the usual position of Cut, without + // _closedWidth having been used yet. + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Share'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); // 'More' button. + final Offset cutOffset = tester.getTopLeft(find.text('Cut')); + + // Tap to clear the selection. + await tester.tapAt(textOffsetToPosition(tester, 0)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); // 'More' button. + + // Long press to show the menu. + await tester.longPressAt(textOffsetToPosition(tester, 1)); + await tester.pumpAndSettle(); + + // The last buttons (share and select all) are missing, and a more button is shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Share'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); // 'More' button. + + // Tapping the more button shows the overflow menu. + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Share'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); // Back button. + + // Tapping 'Select all' closes the overflow menu. + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Share'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsOneWidget); // 'More' button. + final Offset newCutOffset = tester.getTopLeft(find.text('Cut')); + expect(newCutOffset, equals(cutOffset)); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + }); + + group('menu position', () { + testWidgets( + 'When renders below a block of text, menu appears below bottom endpoint', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc\ndef\nghi\njkl\nmno\npqr'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Align( + alignment: Alignment.topLeft, + child: Material(child: TextField(controller: controller)), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Tap to place the cursor in the field, then tap the handle to show the + // selection menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + RenderEditable renderEditable = findRenderEditable(tester); + List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 1); + final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); + await tester.tapAt(handlePos, pointer: 7); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + + // Tap to select all. + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + + // Only Cut, Copy, and Paste are shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // The menu appears below the bottom handle. + renderEditable = findRenderEditable(tester); + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + final Offset bottomHandlePos = endpoints[1].point; + final Offset cutOffset = tester.getTopLeft(find.text('Cut')); + expect(cutOffset.dy, greaterThan(bottomHandlePos.dy)); + }, + skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'When selecting multiple lines over max lines', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'abc\ndef\nghi\njkl\nmno\npqr'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: Align( + alignment: Alignment.bottomCenter, + child: Material( + child: TextField( + decoration: const InputDecoration(contentPadding: EdgeInsets.all(8.0)), + style: const TextStyle(fontSize: 32, height: 1), + maxLines: 2, + controller: controller, + ), + ), + ), + ), + ), + ), + ); + + // Initially, the menu isn't shown at all. + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // Tap to place the cursor in the field, then tap the handle to show the + // selection menu. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + final RenderEditable renderEditable = findRenderEditable(tester); + final List<TextSelectionPoint> endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 1); + final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0); + await tester.tapAt(handlePos, pointer: 7); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + + // Tap to select all. + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + + // Only Cut, Copy, and Paste are shown. + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsNothing); + expect(find.byType(IconButton), findsNothing); + + // The menu appears at the top of the visible selection. + final Offset selectionOffset = tester.getTopLeft( + find.byType(TextSelectionToolbarTextButton).first, + ); + final Offset textFieldOffset = tester.getTopLeft(find.byType(TextField)); + + // 44.0 + 8.0 - 8.0 = _kToolbarHeight + _kToolbarContentDistance - contentPadding + expect(selectionOffset.dy + 44.0 + 8.0 - 8.0, equals(textFieldOffset.dy)); + }, + skip: isBrowser, // [intended] the selection menu isn't required by web + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + }); + + group('material handles', () { + testWidgets('draws transparent handle correctly', (WidgetTester tester) async { + await tester.pumpWidget( + RepaintBoundary( + child: Theme( + data: ThemeData( + textSelectionTheme: const TextSelectionThemeData( + selectionHandleColor: Color(0x550000AA), + ), + ), + child: Builder( + builder: (BuildContext context) { + return Container( + color: Colors.white, + height: 800, + width: 800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 250), + child: FittedBox( + child: materialTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.right, + 10.0, + ), + ), + ), + ); + }, + ), + ), + ), + ); + + await expectLater(find.byType(RepaintBoundary), matchesGoldenFile('transparent_handle.png')); + }); + + testWidgets('works with 3 positional parameters', (WidgetTester tester) async { + await tester.pumpWidget( + Theme( + data: ThemeData( + textSelectionTheme: const TextSelectionThemeData( + selectionHandleColor: Color(0x550000AA), + ), + ), + child: Builder( + builder: (BuildContext context) { + return Container( + color: Colors.white, + height: 800, + width: 800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 250), + child: FittedBox( + child: materialTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.right, + 10.0, + ), + ), + ), + ); + }, + ), + ), + ); + + // No expect here as this should simply compile / not throw any + // exceptions while building. The test will fail if this either does + // not compile or if the tester catches an exception, which we do + // not take here. + }); + }); + + testWidgets( + 'Paste only appears when clipboard has contents', + (WidgetTester tester) async { + final controller = TextEditingController(text: 'Atwater Peel Sherbrooke Bonaventure'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column(children: <Widget>[TextField(controller: controller)]), + ), + ), + ); + + // Make sure the clipboard is empty to start. + await Clipboard.setData(const ClipboardData(text: '')); + + // Double tap to select the first word. + const index = 4; + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // No Paste yet, because nothing has been copied. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + + // Tap copy to add something to the clipboard and close the menu. + await tester.tapAt(tester.getCenter(find.text('Copy'))); + await tester.pumpAndSettle(); + expect(find.text('Copy'), findsNothing); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select all'), findsNothing); + + // Double tap to show the menu again. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + + // Paste now shows. + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsOneWidget); + expect(find.text('Paste'), findsOneWidget); + expect(find.text('Select all'), findsOneWidget); + }, + skip: isBrowser, // [intended] we don't supply the cut/copy/paste buttons on the web. + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}), + ); + + testWidgets( + 'does not crash when long press is cancelled after unmounting', + (WidgetTester tester) async { + // Regression test for b/425840577. + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: CustomScrollView( + controller: scrollController, + slivers: <Widget>[ + SliverList( + delegate: SliverChildBuilderDelegate( + (_, int index) => index == 0 ? const TextField() : const SizedBox(height: 50), + childCount: 200, + addAutomaticKeepAlives: false, + ), + ), + ], + ), + ), + ), + ); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + // Start a long press, don't release it, and don't completely reach kLongPressTimeout so the + // gesture is not accepted and is cancelled when the recognizer is disposed. + await tester.startGesture(tester.getCenter(find.byType(TextField))); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + // While attempting to long press, scroll the TextField out of view + // to dispose of it and its gesture recognizers. + scrollController.jumpTo(8000.0); + await tester.pump(); + expect(state.mounted, isFalse); + // Should reach the end of the test without any failures. + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + // Regression test for https://github.com/flutter/flutter/issues/37032. + testWidgets( + "selection handle's GestureDetector should not cover the entire screen", + (WidgetTester tester) async { + final controller = TextEditingController(text: 'a'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: TextField(autofocus: true, controller: controller)), + ), + ); + + await tester.pumpAndSettle(); + + final Finder gestureDetector = find.descendant( + of: find.byType(CompositedTransformFollower), + matching: find.descendant( + of: find.byType(FadeTransition), + matching: find.byType(RawGestureDetector), + ), + ); + + expect(gestureDetector, findsOneWidget); + // The GestureDetector's size should not exceed that of the TextField. + final Rect hitRect = tester.getRect(gestureDetector); + final Rect textFieldRect = tester.getRect(find.byType(TextField)); + + expect(hitRect.size.width, lessThanOrEqualTo(textFieldRect.size.width)); + expect(hitRect.size.height, lessThanOrEqualTo(textFieldRect.size.height)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}), + ); +} diff --git a/packages/material_ui/test/material/text_selection_theme_test.dart b/packages/material_ui/test/material/text_selection_theme_test.dart new file mode 100644 index 000000000000..0c6d43981f49 --- /dev/null +++ b/packages/material_ui/test/material/text_selection_theme_test.dart @@ -0,0 +1,361 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('TextSelectionThemeData copyWith, ==, hashCode basics', () { + expect(const TextSelectionThemeData(), const TextSelectionThemeData().copyWith()); + expect( + const TextSelectionThemeData().hashCode, + const TextSelectionThemeData().copyWith().hashCode, + ); + }); + + test('TextSelectionThemeData lerp special cases', () { + expect(TextSelectionThemeData.lerp(null, null, 0), null); + const data = TextSelectionThemeData(); + expect(identical(TextSelectionThemeData.lerp(data, data, 0.5), data), true); + }); + + test('TextSelectionThemeData null fields by default', () { + const theme = TextSelectionThemeData(); + expect(theme.cursorColor, null); + expect(theme.selectionColor, null); + expect(theme.selectionHandleColor, null); + }); + + testWidgets('Default TextSelectionThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TextSelectionThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('TextSelectionThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TextSelectionThemeData( + cursorColor: Color(0xffeeffaa), + selectionColor: Color(0x88888888), + selectionHandleColor: Color(0xaabbccdd), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'cursorColor: ${const Color(0xffeeffaa)}', + 'selectionColor: ${const Color(0x88888888)}', + 'selectionHandleColor: ${const Color(0xaabbccdd)}', + ]); + }); + + testWidgets('Material2 - Empty textSelectionTheme will use defaults', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + const defaultCursorColor = Color(0xff2196f3); + const defaultSelectionColor = Color(0x662196f3); + const defaultSelectionHandleColor = Color(0xff2196f3); + + EditableText.debugDeterministicCursor = true; + addTearDown(() { + EditableText.debugDeterministicCursor = false; + }); + // Test TextField's cursor & selection color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: TextField(autofocus: true)), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + expect(renderEditable.cursorColor, defaultCursorColor); + expect(renderEditable.selectionColor, defaultSelectionColor); + + // Test the selection handle color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Builder( + builder: (BuildContext context) { + return materialTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.left, + 10.0, + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderBox handle = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect(handle, paints..path(color: defaultSelectionHandleColor)); + }); + + testWidgets('Material3 - Empty textSelectionTheme will use defaults', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + final Color defaultCursorColor = theme.colorScheme.primary; + final Color defaultSelectionColor = theme.colorScheme.primary.withOpacity(0.40); + final Color defaultSelectionHandleColor = theme.colorScheme.primary; + + EditableText.debugDeterministicCursor = true; + addTearDown(() { + EditableText.debugDeterministicCursor = false; + }); + // Test TextField's cursor & selection color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: TextField(autofocus: true)), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + expect(renderEditable.cursorColor, defaultCursorColor); + expect(renderEditable.selectionColor, defaultSelectionColor); + + // Test the selection handle color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Builder( + builder: (BuildContext context) { + return materialTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.left, + 10.0, + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderBox handle = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect(handle, paints..path(color: defaultSelectionHandleColor)); + }); + + testWidgets('ThemeData.textSelectionTheme will be used if provided', (WidgetTester tester) async { + const textSelectionTheme = TextSelectionThemeData( + cursorColor: Color(0xffaabbcc), + selectionColor: Color(0x88888888), + selectionHandleColor: Color(0x00ccbbaa), + ); + final ThemeData theme = ThemeData.fallback().copyWith(textSelectionTheme: textSelectionTheme); + + EditableText.debugDeterministicCursor = true; + addTearDown(() { + EditableText.debugDeterministicCursor = false; + }); + + // Test TextField's cursor & selection color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material(child: TextField(autofocus: true)), + ), + ); + await tester.pump(); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + expect(renderEditable.cursorColor, textSelectionTheme.cursorColor); + expect(renderEditable.selectionColor, textSelectionTheme.selectionColor); + + // Test the selection handle color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: Builder( + builder: (BuildContext context) { + return materialTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.left, + 10.0, + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderBox handle = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect(handle, paints..path(color: textSelectionTheme.selectionHandleColor)); + }); + + testWidgets('TextSelectionTheme widget will override ThemeData.textSelectionTheme', ( + WidgetTester tester, + ) async { + const defaultTextSelectionTheme = TextSelectionThemeData( + cursorColor: Color(0xffaabbcc), + selectionColor: Color(0x88888888), + selectionHandleColor: Color(0x00ccbbaa), + ); + final ThemeData theme = ThemeData.fallback().copyWith( + textSelectionTheme: defaultTextSelectionTheme, + ); + const widgetTextSelectionTheme = TextSelectionThemeData( + cursorColor: Color(0xffddeeff), + selectionColor: Color(0x44444444), + selectionHandleColor: Color(0x00ffeedd), + ); + + EditableText.debugDeterministicCursor = true; + addTearDown(() { + EditableText.debugDeterministicCursor = false; + }); + // Test TextField's cursor & selection color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: TextSelectionTheme( + data: widgetTextSelectionTheme, + child: TextField(autofocus: true), + ), + ), + ), + ); + await tester.pump(); + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + expect(renderEditable.cursorColor, widgetTextSelectionTheme.cursorColor); + expect(renderEditable.selectionColor, widgetTextSelectionTheme.selectionColor); + + // Test the selection handle color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Material( + child: TextSelectionTheme( + data: widgetTextSelectionTheme, + child: Builder( + builder: (BuildContext context) { + return materialTextSelectionControls.buildHandle( + context, + TextSelectionHandleType.left, + 10.0, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final RenderBox handle = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect(handle, paints..path(color: widgetTextSelectionTheme.selectionHandleColor)); + }); + + testWidgets('TextField parameters will override theme settings', (WidgetTester tester) async { + const defaultTextSelectionTheme = TextSelectionThemeData( + cursorColor: Color(0xffaabbcc), + selectionHandleColor: Color(0x00ccbbaa), + ); + final ThemeData theme = ThemeData.fallback().copyWith( + textSelectionTheme: defaultTextSelectionTheme, + ); + const widgetTextSelectionTheme = TextSelectionThemeData( + cursorColor: Color(0xffddeeff), + selectionHandleColor: Color(0x00ffeedd), + ); + const cursorColor = Color(0x88888888); + + // Test TextField's cursor color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: TextSelectionTheme( + data: widgetTextSelectionTheme, + child: TextField(cursorColor: cursorColor), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderEditable = editableTextState.renderEditable; + expect(renderEditable.cursorColor, cursorColor.withAlpha(0)); + + // Test SelectableText's cursor color. + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Material( + child: TextSelectionTheme( + data: widgetTextSelectionTheme, + child: SelectableText('foobar', cursorColor: cursorColor), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final EditableTextState selectableTextState = tester.firstState(find.byType(EditableText)); + final RenderEditable renderSelectable = selectableTextState.renderEditable; + expect(renderSelectable.cursorColor, cursorColor.withAlpha(0)); + }); + + testWidgets('TextSelectionThem overrides DefaultSelectionStyle', (WidgetTester tester) async { + const themeSelectionColor = Color(0xffaabbcc); + const themeCursorColor = Color(0x00ccbbaa); + const defaultSelectionColor = Color(0xffaa1111); + const defaultCursorColor = Color(0x00cc2222); + final Key defaultSelectionStyle = UniqueKey(); + final Key themeStyle = UniqueKey(); + // Test TextField's cursor color. + await tester.pumpWidget( + MaterialApp( + home: DefaultSelectionStyle( + selectionColor: defaultSelectionColor, + cursorColor: defaultCursorColor, + child: Container( + key: defaultSelectionStyle, + child: TextSelectionTheme( + data: const TextSelectionThemeData( + selectionColor: themeSelectionColor, + cursorColor: themeCursorColor, + ), + child: Placeholder(key: themeStyle), + ), + ), + ), + ), + ); + final BuildContext defaultSelectionStyleContext = tester.element( + find.byKey(defaultSelectionStyle), + ); + DefaultSelectionStyle style = DefaultSelectionStyle.of(defaultSelectionStyleContext); + expect(style.selectionColor, defaultSelectionColor); + expect(style.cursorColor, defaultCursorColor); + + final BuildContext themeStyleContext = tester.element(find.byKey(themeStyle)); + style = DefaultSelectionStyle.of(themeStyleContext); + expect(style.selectionColor, themeSelectionColor); + expect(style.cursorColor, themeCursorColor); + }); +} diff --git a/packages/material_ui/test/material/text_selection_toolbar_test.dart b/packages/material_ui/test/material/text_selection_toolbar_test.dart new file mode 100644 index 000000000000..3475f25b31b7 --- /dev/null +++ b/packages/material_ui/test/material/text_selection_toolbar_test.dart @@ -0,0 +1,458 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'editable_text_utils.dart' show textOffsetToPosition; + +const double _kToolbarContentDistance = 8.0; + +// A custom text selection menu that just displays a single custom button. +class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls { + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List<TextSelectionPoint> endpoints, + TextSelectionDelegate delegate, + ValueListenable<ClipboardStatus>? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + final TextSelectionPoint startTextSelectionPoint = endpoints[0]; + final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1 + ? endpoints[1] + : endpoints[0]; + final anchorAbove = Offset( + globalEditableRegion.left + selectionMidpoint.dx, + globalEditableRegion.top + + startTextSelectionPoint.point.dy - + textLineHeight - + _kToolbarContentDistance, + ); + final anchorBelow = Offset( + globalEditableRegion.left + selectionMidpoint.dx, + globalEditableRegion.top + + endTextSelectionPoint.point.dy + + TextSelectionToolbar.kToolbarContentDistanceBelow, + ); + + return TextSelectionToolbar( + anchorAbove: anchorAbove, + anchorBelow: anchorBelow, + children: <Widget>[ + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + onPressed: () {}, + child: const Text('Custom button'), + ), + ], + ); + } +} + +class TestBox extends SizedBox { + const TestBox({super.key, super.child}) : super(width: itemWidth, height: itemHeight); + + static const double itemHeight = 44.0; + static const double itemWidth = 100.0; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Find by a runtimeType String, including private types. + Finder findPrivate(String type) { + return find.descendant( + of: find.byType(MaterialApp), + matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type), + ); + } + + // Finding TextSelectionToolbar won't give you the position as the user sees + // it because it's a full-sized Stack at the top level. This method finds the + // visible part of the toolbar for use in measurements. + Finder findToolbar() => findPrivate('_TextSelectionToolbarOverflowable'); + + Finder findOverflowButton() => findPrivate('_TextSelectionToolbarOverflowButton'); + + testWidgets('puts children in an overflow menu if they overflow', (WidgetTester tester) async { + late StateSetter setState; + final children = List<Widget>.generate(7, (int i) => const TestBox()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ); + }, + ), + ), + ), + ); + + // All children fit on the screen, so they are all rendered. + expect(find.byType(TestBox), findsNWidgets(children.length)); + expect(findOverflowButton(), findsNothing); + + // Adding one more child makes the children overflow. + setState(() { + children.add(const TestBox()); + }); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(children.length - 1)); + expect(findOverflowButton(), findsOneWidget); + + // Tap the overflow button to show the overflow menu. + await tester.tap(findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(1)); + expect(findOverflowButton(), findsOneWidget); + + // Tap the overflow button again to hide the overflow menu. + await tester.tap(findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(children.length - 1)); + expect(findOverflowButton(), findsOneWidget); + }); + + testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async { + late StateSetter setState; + const height = 44.0; + const anchorBelowY = 500.0; + var anchorAboveY = 0.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbar( + anchorAbove: Offset(50.0, anchorAboveY), + anchorBelow: const Offset(50.0, anchorBelowY), + children: <Widget>[ + Container(color: Colors.red, width: 50.0, height: height), + Container(color: Colors.green, width: 50.0, height: height), + Container(color: Colors.blue, width: 50.0, height: height), + ], + ); + }, + ), + ), + ), + ); + + // When the toolbar doesn't fit above aboveAnchor, it positions itself below + // belowAnchor. + double toolbarY = tester.getTopLeft(findToolbar()).dy; + expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow)); + + // Even when it barely doesn't fit. + setState(() { + anchorAboveY = 60.0; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(findToolbar()).dy; + expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow)); + + // When it does fit above aboveAnchor, it positions itself there. + setState(() { + anchorAboveY = 70.0; + }); + await tester.pump(); + toolbarY = tester.getTopLeft(findToolbar()).dy; + expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance)); + }); + + testWidgets('can create and use a custom toolbar', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SelectableText( + 'Select me custom menu', + selectionControls: _CustomMaterialTextSelectionControls(), + ), + ), + ), + ), + ); + + // The selection menu is not initially shown. + expect(find.text('Custom button'), findsNothing); + + // Long press on "custom" to select it. + final Offset customPos = textOffsetToPosition(tester, 11); + final TestGesture gesture = await tester.startGesture(customPos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The custom selection menu is shown. + expect(find.text('Custom button'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Copy'), findsNothing); + expect(find.text('Paste'), findsNothing); + expect(find.text('Select all'), findsNothing); + }, skip: kIsWeb); // [intended] We don't show the toolbar on the web. + + for (final colorScheme in <ColorScheme>[ThemeData().colorScheme, ThemeData.dark().colorScheme]) { + testWidgets('default background color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: TextSelectionToolbar( + anchorAbove: Offset.zero, + anchorBelow: Offset.zero, + children: <Widget>[ + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + onPressed: () {}, + child: const Text('Custom button'), + ), + ], + ), + ), + ), + ), + ); + + Finder findToolbarContainer() { + return find.descendant( + of: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer', + ), + matching: find.byType(Material), + ); + } + + expect(findToolbarContainer(), findsAtLeastNWidgets(1)); + + final Material toolbarContainer = tester.widget(findToolbarContainer().first); + expect( + toolbarContainer.color, + // The default colors are hardcoded and don't take the default value of + // the theme's surface color. + switch (colorScheme.brightness) { + Brightness.light => const Color(0xffffffff), + Brightness.dark => const Color(0xff424242), + }, + ); + }); + + testWidgets('custom background color', (WidgetTester tester) async { + const Color customBackgroundColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme.copyWith(surface: customBackgroundColor)), + home: Scaffold( + body: Center( + child: TextSelectionToolbar( + anchorAbove: Offset.zero, + anchorBelow: Offset.zero, + children: <Widget>[ + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + onPressed: () {}, + child: const Text('Custom button'), + ), + ], + ), + ), + ), + ), + ); + + Finder findToolbarContainer() { + return find.descendant( + of: find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer', + ), + matching: find.byType(Material), + ); + } + + expect(findToolbarContainer(), findsAtLeastNWidgets(1)); + + final Material toolbarContainer = tester.widget(findToolbarContainer().first); + expect(toolbarContainer.color, customBackgroundColor); + }); + } + + testWidgets('Overflowed menu expands children horizontally', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/144089. + late StateSetter setState; + final children = List<Widget>.generate(7, (int i) => const TestBox()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ); + }, + ), + ), + ), + ); + + // All children fit on the screen, so they are all rendered. + expect(find.byType(TestBox), findsNWidgets(children.length)); + expect(findOverflowButton(), findsNothing); + + const short = 'Short'; + const medium = 'Medium length'; + const long = 'Long label in the overflow menu'; + + // Adding several children makes the menu overflow. + setState(() { + children.addAll(const <Text>[Text(short), Text(medium), Text(long)]); + }); + await tester.pumpAndSettle(); + expect(findOverflowButton(), findsOneWidget); + + // Tap the overflow button to show the overflow menu. + await tester.tap(findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNothing); + expect(find.byType(Text), findsNWidgets(3)); + expect(findOverflowButton(), findsOneWidget); + + Finder findToolbarContainer() { + return find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_TextSelectionToolbarContainer', + ); + } + + expect(findToolbarContainer(), findsAtLeastNWidgets(1)); + + // Buttons have their width set to the container width. + final double overflowMenuWidth = tester.getRect(findToolbarContainer()).width; + expect(tester.getRect(find.text(long)).width, overflowMenuWidth); + expect(tester.getRect(find.text(medium)).width, overflowMenuWidth); + expect(tester.getRect(find.text(short)).width, overflowMenuWidth); + }); + + testWidgets('items are ordered right-to-left in RTL', (WidgetTester tester) async { + const itemCount = 3; + final children = List<Widget>.generate( + itemCount, + (int i) => TestBox(key: ValueKey<String>('item_$i'), child: Text('$i')), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: TextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ), + ), + ), + ), + ); + + // Verify all items are visible. + expect(find.byType(TestBox), findsNWidgets(itemCount)); + + // Find all text widgets by their content and get their positions. + final textRects = List<Rect>.generate(itemCount, (int i) => tester.getRect(find.text('$i'))); + + // In RTL, items should be in reverse order (2, 1, 0). + // So item 2 should be leftmost, then 1, then 0. + for (var i = 0; i < itemCount - 1; i++) { + final Rect current = textRects[i]; + final Rect next = textRects[i + 1]; + + // In RTL, each item should be to the left of the previous one. + expect( + next.right, + lessThanOrEqualTo(current.left), + reason: 'In RTL, item ${i + 1} should be to the left of item $i', + ); + } + + // Verify the visual order by checking the rightmost position. + final List<double> rightEdges = textRects.map((Rect r) => r.right).toList(); + final sortedRightEdges = List<double>.from(rightEdges) + ..sort((double a, double b) => b.compareTo(a)); + expect( + rightEdges, + equals(sortedRightEdges), + reason: 'Items should be ordered right-to-left in RTL', + ); + }); + + testWidgets('puts children in an overflow menu if they overflow in RTL', ( + WidgetTester tester, + ) async { + late StateSetter setState; + final children = List<Widget>.generate(7, (int i) => const TestBox()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, // this makes the difference. + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbar( + anchorAbove: const Offset(50.0, 100.0), + anchorBelow: const Offset(50.0, 200.0), + children: children, + ); + }, + ), + ), + ), + ), + ); + + // All children fit on the screen, so they are all rendered. + expect(find.byType(TestBox), findsNWidgets(children.length)); + expect(findOverflowButton(), findsNothing); + + // Adding one more child makes the children overflow. + setState(() { + children.add(const TestBox()); + }); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(children.length - 1)); + expect(findOverflowButton(), findsOneWidget); + + // Tap the overflow button to show the overflow menu. + await tester.tap(findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsOneWidget); // Only one item in the overflow menu. + expect(findOverflowButton(), findsOneWidget); + + // Tap the overflow button again to hide the overflow menu. + await tester.tap(findOverflowButton()); + await tester.pumpAndSettle(); + expect(find.byType(TestBox), findsNWidgets(children.length - 1)); + expect(findOverflowButton(), findsOneWidget); + }); +} diff --git a/packages/material_ui/test/material/text_selection_toolbar_text_button_test.dart b/packages/material_ui/test/material/text_selection_toolbar_text_button_test.dart new file mode 100644 index 000000000000..dce30eddfe2a --- /dev/null +++ b/packages/material_ui/test/material/text_selection_toolbar_text_button_test.dart @@ -0,0 +1,191 @@ +// 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() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('position in the toolbar changes width', (WidgetTester tester) async { + late StateSetter setState; + var index = 1; + var total = 3; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(index, total), + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + + final Size middleSize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + + setState(() { + index = 0; + total = 3; + }); + await tester.pump(); + final Size firstSize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + expect(firstSize.width, greaterThan(middleSize.width)); + + setState(() { + index = 2; + total = 3; + }); + await tester.pump(); + final Size lastSize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + expect(lastSize.width, greaterThan(middleSize.width)); + expect(lastSize.width, equals(firstSize.width)); + + setState(() { + index = 0; + total = 1; + }); + await tester.pump(); + final Size onlySize = tester.getSize(find.byType(TextSelectionToolbarTextButton)); + expect(onlySize.width, greaterThan(middleSize.width)); + expect(onlySize.width, greaterThan(firstSize.width)); + expect(onlySize.width, greaterThan(lastSize.width)); + }); + + for (final colorScheme in <ColorScheme>[ThemeData().colorScheme, ThemeData.dark().colorScheme]) { + testWidgets('foreground color by default', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + child: const Text('button'), + ), + ), + ), + ), + ); + + expect(find.byType(TextButton), findsOneWidget); + + final TextButton textButton = tester.widget(find.byType(TextButton)); + // The foreground color is hardcoded to black or white by default, not the + // default value from ColorScheme.onSurface. + expect( + textButton.style!.foregroundColor!.resolve(<WidgetState>{}), + switch (colorScheme.brightness) { + Brightness.light => const Color(0xff000000), + Brightness.dark => const Color(0xffffffff), + }, + ); + }); + + testWidgets('custom foreground color', (WidgetTester tester) async { + const Color customForegroundColor = Colors.red; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme.copyWith(onSurface: customForegroundColor)), + home: Scaffold( + body: Center( + child: TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + child: const Text('button'), + ), + ), + ), + ), + ); + + expect(find.byType(TextButton), findsOneWidget); + + final TextButton textButton = tester.widget(find.byType(TextButton)); + expect(textButton.style!.foregroundColor!.resolve(<WidgetState>{}), customForegroundColor); + }); + + testWidgets('background color by default', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/133027 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + child: const Text('button'), + ), + ), + ), + ), + ); + + expect(find.byType(TextButton), findsOneWidget); + + final TextButton textButton = tester.widget(find.byType(TextButton)); + // The background color is hardcoded to transparent by default so the buttons + // are the color of the container behind them. For example TextSelectionToolbar + // hardcodes the color value, and TextSelectionToolbarTextButton that are its + // children should be that color. + expect(textButton.style!.backgroundColor!.resolve(<WidgetState>{}), Colors.transparent); + }); + + testWidgets('textButtonTheme should not override default background color', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/133027 + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: colorScheme, + textButtonTheme: const TextButtonThemeData( + style: ButtonStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.blue)), + ), + ), + home: Scaffold( + body: Center( + child: TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(0, 1), + child: const Text('button'), + ), + ), + ), + ), + ); + + expect(find.byType(TextButton), findsOneWidget); + + final TextButton textButton = tester.widget(find.byType(TextButton)); + // The background color is hardcoded to transparent by default so the buttons + // are the color of the container behind them. For example TextSelectionToolbar + // hardcodes the color value, and TextSelectionToolbarTextButton that are its + // children should be that color. + expect(textButton.style!.backgroundColor!.resolve(<WidgetState>{}), Colors.transparent); + }); + } + + testWidgets('TextSelectionToolbarTextButton does not crash at zero area', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: TextSelectionToolbarTextButton(padding: EdgeInsets.all(5), child: Text('X')), + ), + ), + ), + ); + expect(tester.getSize(find.byType(TextSelectionToolbarTextButton)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/text_theme_test.dart b/packages/material_ui/test/material/text_theme_test.dart new file mode 100644 index 000000000000..be79cbe93180 --- /dev/null +++ b/packages/material_ui/test/material/text_theme_test.dart @@ -0,0 +1,446 @@ +// 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() { + test('TextTheme copyWith apply, merge basics with const TextTheme()', () { + expect(const TextTheme(), equals(const TextTheme().copyWith())); + expect(const TextTheme(), equals(const TextTheme().apply())); + expect(const TextTheme(), equals(const TextTheme().merge(null))); + expect(const TextTheme().hashCode, equals(const TextTheme().copyWith().hashCode)); + expect(const TextTheme(), equals(const TextTheme().copyWith())); + }); + + test('TextTheme lerp special cases', () { + expect(TextTheme.lerp(null, null, 0), const TextTheme()); + const theme = TextTheme(); + expect(identical(TextTheme.lerp(theme, theme, 0.5), theme), true); + }); + + test('TextTheme copyWith apply, merge basics with Typography.black', () { + final typography = Typography.material2018(); + expect(typography.black, equals(typography.black.copyWith())); + expect(typography.black, equals(typography.black.apply())); + expect(typography.black, equals(typography.black.merge(null))); + expect(typography.black, equals(const TextTheme().merge(typography.black))); + expect(typography.black, equals(typography.black.merge(typography.black))); + expect(typography.white, equals(typography.black.merge(typography.white))); + expect(typography.black.hashCode, equals(typography.black.copyWith().hashCode)); + expect(typography.black, isNot(equals(typography.white))); + }); + + test('TextTheme copyWith', () { + final typography = Typography.material2018(); + final TextTheme whiteCopy = typography.black.copyWith( + displayLarge: typography.white.displayLarge, + displayMedium: typography.white.displayMedium, + displaySmall: typography.white.displaySmall, + headlineLarge: typography.white.headlineLarge, + headlineMedium: typography.white.headlineMedium, + headlineSmall: typography.white.headlineSmall, + titleLarge: typography.white.titleLarge, + titleMedium: typography.white.titleMedium, + titleSmall: typography.white.titleSmall, + bodyLarge: typography.white.bodyLarge, + bodyMedium: typography.white.bodyMedium, + bodySmall: typography.white.bodySmall, + labelLarge: typography.white.labelLarge, + labelMedium: typography.white.labelMedium, + labelSmall: typography.white.labelSmall, + ); + expect(typography.white, equals(whiteCopy)); + }); + + test('TextTheme merges properly in the presence of null fields.', () { + const partialTheme = TextTheme(titleLarge: TextStyle(color: Color(0xcafefeed))); + final TextTheme fullTheme = ThemeData.fallback().textTheme.merge(partialTheme); + expect(fullTheme.titleLarge!.color, equals(partialTheme.titleLarge!.color)); + + const onlyHeadlineSmallAndTitleLarge = TextTheme( + headlineSmall: TextStyle(color: Color(0xcafefeed)), + titleLarge: TextStyle(color: Color(0xbeefcafe)), + ); + const onlyBodyMediumAndTitleLarge = TextTheme( + bodyMedium: TextStyle(color: Color(0xfeedfeed)), + titleLarge: TextStyle(color: Color(0xdeadcafe)), + ); + TextTheme merged = onlyHeadlineSmallAndTitleLarge.merge(onlyBodyMediumAndTitleLarge); + expect(merged.bodyLarge, isNull); + expect(merged.bodyMedium!.color, equals(onlyBodyMediumAndTitleLarge.bodyMedium!.color)); + expect( + merged.headlineSmall!.color, + equals(onlyHeadlineSmallAndTitleLarge.headlineSmall!.color), + ); + expect(merged.titleLarge!.color, equals(onlyBodyMediumAndTitleLarge.titleLarge!.color)); + + merged = onlyHeadlineSmallAndTitleLarge.merge(null); + expect(merged, equals(onlyHeadlineSmallAndTitleLarge)); + }); + + test('TextTheme apply', () { + // The `displayColor` is applied to [displayLarge], [displayMedium], + // [displaySmall], [headlineLarge], [headlineMedium], and [bodySmall]. The + // `bodyColor` is applied to the remaining text styles. + const displayColor = Color(0x00000001); + const bodyColor = Color(0x00000002); + const fontFamily = 'fontFamily'; + const fontFamilyFallback = <String>['font', 'family', 'fallback']; + const decorationColor = Color(0x00000003); + const TextDecorationStyle decorationStyle = TextDecorationStyle.dashed; + final decoration = TextDecoration.combine(<TextDecoration>[ + TextDecoration.underline, + TextDecoration.lineThrough, + ]); + + final typography = Typography.material2018(); + final TextTheme theme = typography.black.apply( + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + displayColor: displayColor, + bodyColor: bodyColor, + decoration: decoration, + decorationColor: decorationColor, + decorationStyle: decorationStyle, + ); + + expect(theme.displayLarge!.color, displayColor); + expect(theme.displayMedium!.color, displayColor); + expect(theme.displaySmall!.color, displayColor); + expect(theme.headlineLarge!.color, displayColor); + expect(theme.headlineMedium!.color, displayColor); + expect(theme.headlineSmall!.color, bodyColor); + expect(theme.titleLarge!.color, bodyColor); + expect(theme.titleMedium!.color, bodyColor); + expect(theme.titleSmall!.color, bodyColor); + expect(theme.bodyLarge!.color, bodyColor); + expect(theme.bodyMedium!.color, bodyColor); + expect(theme.bodySmall!.color, displayColor); + expect(theme.labelLarge!.color, bodyColor); + expect(theme.labelMedium!.color, bodyColor); + expect(theme.labelSmall!.color, bodyColor); + + final themeStyles = <TextStyle>[ + theme.displayLarge!, + theme.displayMedium!, + theme.displaySmall!, + theme.headlineLarge!, + theme.headlineMedium!, + theme.headlineSmall!, + theme.titleLarge!, + theme.titleMedium!, + theme.titleSmall!, + theme.bodyLarge!, + theme.bodyMedium!, + theme.bodySmall!, + theme.labelLarge!, + theme.labelMedium!, + theme.labelSmall!, + ]; + expect(themeStyles.every((TextStyle style) => style.fontFamily == fontFamily), true); + expect( + themeStyles.every((TextStyle style) => style.fontFamilyFallback == fontFamilyFallback), + true, + ); + expect(themeStyles.every((TextStyle style) => style.decorationColor == decorationColor), true); + expect(themeStyles.every((TextStyle style) => style.decorationStyle == decorationStyle), true); + expect(themeStyles.every((TextStyle style) => style.decoration == decoration), true); + }); + + test('TextTheme apply fontSizeFactor fontSizeDelta', () { + final typography = Typography.material2018(); + final TextTheme baseTheme = Typography.englishLike2018.merge(typography.black); + final TextTheme sizeTheme = baseTheme.apply(fontSizeFactor: 2.0, fontSizeDelta: 5.0); + + expect(sizeTheme.displayLarge!.fontSize, baseTheme.displayLarge!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.displayMedium!.fontSize, baseTheme.displayMedium!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.displaySmall!.fontSize, baseTheme.displaySmall!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.headlineLarge!.fontSize, baseTheme.headlineLarge!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.headlineMedium!.fontSize, baseTheme.headlineMedium!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.headlineSmall!.fontSize, baseTheme.headlineSmall!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.titleLarge!.fontSize, baseTheme.titleLarge!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.titleMedium!.fontSize, baseTheme.titleMedium!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.titleSmall!.fontSize, baseTheme.titleSmall!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.bodyLarge!.fontSize, baseTheme.bodyLarge!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.bodyMedium!.fontSize, baseTheme.bodyMedium!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.bodySmall!.fontSize, baseTheme.bodySmall!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.labelLarge!.fontSize, baseTheme.labelLarge!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.labelMedium!.fontSize, baseTheme.labelMedium!.fontSize! * 2.0 + 5.0); + expect(sizeTheme.labelSmall!.fontSize, baseTheme.labelSmall!.fontSize! * 2.0 + 5.0); + }); + + test('TextTheme apply letterSpacingFactor letterSpacingDelta', () { + final typography = Typography.material2018(); + final TextTheme baseTheme = Typography.englishLike2018.merge(typography.black); + final TextTheme sizeTheme = baseTheme.apply(letterSpacingFactor: 2.0, letterSpacingDelta: 5.0); + + expect( + sizeTheme.displayLarge!.letterSpacing, + baseTheme.displayLarge!.letterSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.displayMedium!.letterSpacing, + baseTheme.displayMedium!.letterSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.displaySmall!.letterSpacing, + baseTheme.displaySmall!.letterSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.headlineLarge!.letterSpacing, + baseTheme.headlineLarge!.letterSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.headlineMedium!.letterSpacing, + baseTheme.headlineMedium!.letterSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.headlineSmall!.letterSpacing, + baseTheme.headlineSmall!.letterSpacing! * 2.0 + 5.0, + ); + expect(sizeTheme.titleLarge!.letterSpacing, baseTheme.titleLarge!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.titleMedium!.letterSpacing, baseTheme.titleMedium!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.titleSmall!.letterSpacing, baseTheme.titleSmall!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.bodyLarge!.letterSpacing, baseTheme.bodyLarge!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.bodyMedium!.letterSpacing, baseTheme.bodyMedium!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.bodySmall!.letterSpacing, baseTheme.bodySmall!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.labelLarge!.letterSpacing, baseTheme.labelLarge!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.labelMedium!.letterSpacing, baseTheme.labelMedium!.letterSpacing! * 2.0 + 5.0); + expect(sizeTheme.labelSmall!.letterSpacing, baseTheme.labelSmall!.letterSpacing! * 2.0 + 5.0); + }); + + test('TextTheme apply wordSpacingFactor wordSpacingDelta', () { + final typography = Typography.material2018(); + final TextTheme baseTheme = Typography.englishLike2018.merge(typography.black); + final TextTheme baseThemeWithWordSpacing = baseTheme.copyWith( + displayLarge: baseTheme.displayLarge!.copyWith(wordSpacing: 1.0), + displayMedium: baseTheme.displayMedium!.copyWith(wordSpacing: 1.0), + displaySmall: baseTheme.displaySmall!.copyWith(wordSpacing: 1.0), + headlineLarge: baseTheme.headlineLarge!.copyWith(wordSpacing: 1.0), + headlineMedium: baseTheme.headlineMedium!.copyWith(wordSpacing: 1.0), + headlineSmall: baseTheme.headlineSmall!.copyWith(wordSpacing: 1.0), + titleLarge: baseTheme.titleLarge!.copyWith(wordSpacing: 1.0), + titleMedium: baseTheme.titleMedium!.copyWith(wordSpacing: 1.0), + titleSmall: baseTheme.titleSmall!.copyWith(wordSpacing: 1.0), + bodyLarge: baseTheme.bodyLarge!.copyWith(wordSpacing: 1.0), + bodyMedium: baseTheme.bodyMedium!.copyWith(wordSpacing: 1.0), + bodySmall: baseTheme.bodySmall!.copyWith(wordSpacing: 1.0), + labelLarge: baseTheme.labelLarge!.copyWith(wordSpacing: 1.0), + labelMedium: baseTheme.labelMedium!.copyWith(wordSpacing: 1.0), + labelSmall: baseTheme.labelSmall!.copyWith(wordSpacing: 1.0), + ); + final TextTheme sizeTheme = baseThemeWithWordSpacing.apply( + wordSpacingFactor: 2.0, + wordSpacingDelta: 5.0, + ); + + expect( + sizeTheme.displayLarge!.wordSpacing, + baseThemeWithWordSpacing.displayLarge!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.displayMedium!.wordSpacing, + baseThemeWithWordSpacing.displayMedium!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.displaySmall!.wordSpacing, + baseThemeWithWordSpacing.displaySmall!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.headlineLarge!.wordSpacing, + baseThemeWithWordSpacing.headlineLarge!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.headlineMedium!.wordSpacing, + baseThemeWithWordSpacing.headlineMedium!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.headlineSmall!.wordSpacing, + baseThemeWithWordSpacing.headlineSmall!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.titleLarge!.wordSpacing, + baseThemeWithWordSpacing.titleLarge!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.titleMedium!.wordSpacing, + baseThemeWithWordSpacing.titleMedium!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.titleSmall!.wordSpacing, + baseThemeWithWordSpacing.titleSmall!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.bodyLarge!.wordSpacing, + baseThemeWithWordSpacing.bodyLarge!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.bodyMedium!.wordSpacing, + baseThemeWithWordSpacing.bodyMedium!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.bodySmall!.wordSpacing, + baseThemeWithWordSpacing.bodySmall!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.labelLarge!.wordSpacing, + baseThemeWithWordSpacing.labelLarge!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.labelMedium!.wordSpacing, + baseThemeWithWordSpacing.labelMedium!.wordSpacing! * 2.0 + 5.0, + ); + expect( + sizeTheme.labelSmall!.wordSpacing, + baseThemeWithWordSpacing.labelSmall!.wordSpacing! * 2.0 + 5.0, + ); + }); + + test('TextTheme apply heightFactor heightDelta', () { + final typography = Typography.material2021(); + final TextTheme baseTheme = Typography.englishLike2021.merge(typography.black); + final TextTheme sizeTheme = baseTheme.apply(heightFactor: 2.0, heightDelta: 5.0); + + expect(sizeTheme.displayLarge!.height, baseTheme.displayLarge!.height! * 2.0 + 5.0); + expect(sizeTheme.displayMedium!.height, baseTheme.displayMedium!.height! * 2.0 + 5.0); + expect(sizeTheme.displaySmall!.height, baseTheme.displaySmall!.height! * 2.0 + 5.0); + expect(sizeTheme.headlineLarge!.height, baseTheme.headlineLarge!.height! * 2.0 + 5.0); + expect(sizeTheme.headlineMedium!.height, baseTheme.headlineMedium!.height! * 2.0 + 5.0); + expect(sizeTheme.headlineSmall!.height, baseTheme.headlineSmall!.height! * 2.0 + 5.0); + expect(sizeTheme.titleLarge!.height, baseTheme.titleLarge!.height! * 2.0 + 5.0); + expect(sizeTheme.titleMedium!.height, baseTheme.titleMedium!.height! * 2.0 + 5.0); + expect(sizeTheme.titleSmall!.height, baseTheme.titleSmall!.height! * 2.0 + 5.0); + expect(sizeTheme.bodyLarge!.height, baseTheme.bodyLarge!.height! * 2.0 + 5.0); + expect(sizeTheme.bodyMedium!.height, baseTheme.bodyMedium!.height! * 2.0 + 5.0); + expect(sizeTheme.bodySmall!.height, baseTheme.bodySmall!.height! * 2.0 + 5.0); + expect(sizeTheme.labelLarge!.height, baseTheme.labelLarge!.height! * 2.0 + 5.0); + expect(sizeTheme.labelMedium!.height, baseTheme.labelMedium!.height! * 2.0 + 5.0); + expect(sizeTheme.labelSmall!.height, baseTheme.labelSmall!.height! * 2.0 + 5.0); + }); + + test('TextTheme lerp with second parameter null', () { + final TextTheme theme = Typography.material2018().black; + final TextTheme lerped = TextTheme.lerp(theme, null, 0.25); + + expect(lerped.displayLarge, TextStyle.lerp(theme.displayLarge, null, 0.25)); + expect(lerped.displayMedium, TextStyle.lerp(theme.displayMedium, null, 0.25)); + expect(lerped.displaySmall, TextStyle.lerp(theme.displaySmall, null, 0.25)); + expect(lerped.headlineLarge, TextStyle.lerp(theme.headlineLarge, null, 0.25)); + expect(lerped.headlineMedium, TextStyle.lerp(theme.headlineMedium, null, 0.25)); + expect(lerped.headlineSmall, TextStyle.lerp(theme.headlineSmall, null, 0.25)); + expect(lerped.titleLarge, TextStyle.lerp(theme.titleLarge, null, 0.25)); + expect(lerped.titleMedium, TextStyle.lerp(theme.titleMedium, null, 0.25)); + expect(lerped.titleSmall, TextStyle.lerp(theme.titleSmall, null, 0.25)); + expect(lerped.bodyLarge, TextStyle.lerp(theme.bodyLarge, null, 0.25)); + expect(lerped.bodyMedium, TextStyle.lerp(theme.bodyMedium, null, 0.25)); + expect(lerped.bodySmall, TextStyle.lerp(theme.bodySmall, null, 0.25)); + expect(lerped.labelLarge, TextStyle.lerp(theme.labelLarge, null, 0.25)); + expect(lerped.labelMedium, TextStyle.lerp(theme.labelMedium, null, 0.25)); + expect(lerped.labelSmall, TextStyle.lerp(theme.labelSmall, null, 0.25)); + }); + + test('TextTheme lerp with first parameter null', () { + final TextTheme theme = Typography.material2018().black; + final TextTheme lerped = TextTheme.lerp(null, theme, 0.25); + + expect(lerped.displayLarge, TextStyle.lerp(null, theme.displayLarge, 0.25)); + expect(lerped.displayMedium, TextStyle.lerp(null, theme.displayMedium, 0.25)); + expect(lerped.displaySmall, TextStyle.lerp(null, theme.displaySmall, 0.25)); + expect(lerped.headlineLarge, TextStyle.lerp(null, theme.headlineLarge, 0.25)); + expect(lerped.headlineMedium, TextStyle.lerp(null, theme.headlineMedium, 0.25)); + expect(lerped.headlineSmall, TextStyle.lerp(null, theme.headlineSmall, 0.25)); + expect(lerped.titleLarge, TextStyle.lerp(null, theme.titleLarge, 0.25)); + expect(lerped.titleMedium, TextStyle.lerp(null, theme.titleMedium, 0.25)); + expect(lerped.titleSmall, TextStyle.lerp(null, theme.titleSmall, 0.25)); + expect(lerped.bodyLarge, TextStyle.lerp(null, theme.bodyLarge, 0.25)); + expect(lerped.bodyMedium, TextStyle.lerp(null, theme.bodyMedium, 0.25)); + expect(lerped.bodySmall, TextStyle.lerp(null, theme.bodySmall, 0.25)); + expect(lerped.labelLarge, TextStyle.lerp(null, theme.labelLarge, 0.25)); + expect(lerped.labelMedium, TextStyle.lerp(null, theme.labelMedium, 0.25)); + expect(lerped.labelSmall, TextStyle.lerp(null, theme.labelSmall, 0.25)); + }); + + test('TextTheme lerp with null parameters', () { + final TextTheme lerped = TextTheme.lerp(null, null, 0.25); + expect(lerped.displayLarge, null); + expect(lerped.displayMedium, null); + expect(lerped.displaySmall, null); + expect(lerped.headlineLarge, null); + expect(lerped.headlineMedium, null); + expect(lerped.headlineSmall, null); + expect(lerped.titleLarge, null); + expect(lerped.titleMedium, null); + expect(lerped.titleSmall, null); + expect(lerped.bodyLarge, null); + expect(lerped.bodyMedium, null); + expect(lerped.bodySmall, null); + expect(lerped.labelLarge, null); + expect(lerped.labelMedium, null); + expect(lerped.labelSmall, null); + }); + + test('VisualDensity.lerp', () { + const a = VisualDensity(horizontal: 1.0, vertical: .5); + const b = VisualDensity(horizontal: 2.0, vertical: 1.0); + + final VisualDensity noLerp = VisualDensity.lerp(a, b, 0.0); + expect(noLerp.horizontal, 1.0); + expect(noLerp.vertical, .5); + + final VisualDensity quarterLerp = VisualDensity.lerp(a, b, .25); + expect(quarterLerp.horizontal, 1.25); + expect(quarterLerp.vertical, .625); + + final VisualDensity fullLerp = VisualDensity.lerp(a, b, 1.0); + expect(fullLerp.horizontal, 2.0); + expect(fullLerp.vertical, 1.0); + }); + + testWidgets('TextTheme.of(context) is equivalent to Theme.of(context).textTheme', ( + WidgetTester tester, + ) async { + const sizedBoxKey = Key('sizedBox'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textTheme: const TextTheme(displayLarge: TextStyle(color: Colors.blue, fontSize: 30.0)), + ), + home: const SizedBox(key: sizedBoxKey), + ), + ); + final BuildContext context = tester.element(find.byKey(sizedBoxKey)); + + final ThemeData themeData = Theme.of(context); + final TextTheme expectedTextTheme = themeData.textTheme; + final TextTheme actualTextTheme = TextTheme.of(context); + + expect(actualTextTheme, equals(expectedTextTheme)); + }); + + testWidgets('TextTheme.primaryOf(context) is equivalent to Theme.of(context).primaryTextTheme', ( + WidgetTester tester, + ) async { + const sizedBoxKey = Key('sizedBox'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryTextTheme: const TextTheme( + displayLarge: TextStyle(backgroundColor: Colors.green, fontStyle: FontStyle.italic), + ), + ), + home: const SizedBox(key: sizedBoxKey), + ), + ); + + final BuildContext context = tester.element(find.byKey(sizedBoxKey)); + final ThemeData themeData = Theme.of(context); + final TextTheme expectedTextTheme = themeData.primaryTextTheme; + final TextTheme actualTextTheme = TextTheme.primaryOf(context); + + expect(actualTextTheme, equals(expectedTextTheme)); + }); +} diff --git a/packages/material_ui/test/material/theme_data_test.dart b/packages/material_ui/test/material/theme_data_test.dart new file mode 100644 index 000000000000..baae3ae80c5e --- /dev/null +++ b/packages/material_ui/test/material/theme_data_test.dart @@ -0,0 +1,2083 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Theme data control test', () { + final dark = ThemeData.dark(); + + expect(dark, hasOneLineDescription); + expect(dark, equals(dark.copyWith())); + expect(dark.hashCode, equals(dark.copyWith().hashCode)); + + final light = ThemeData(); + final ThemeData dawn = ThemeData.lerp(dark, light, 0.25); + + expect(dawn.brightness, Brightness.dark); + expect(dawn.primaryColor, Color.lerp(dark.primaryColor, light.primaryColor, 0.25)); + }); + + test('ThemeData objects with .styleFrom() members are equal', () { + ThemeData createThemeData() { + return ThemeData( + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.black, + elevation: 1.0, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + foregroundColor: Colors.black, + disabledForegroundColor: Colors.black, + backgroundColor: Colors.black, + disabledBackgroundColor: Colors.black, + overlayColor: Colors.black, + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + hoverColor: Colors.black, + focusColor: Colors.black, + highlightColor: Colors.black, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + enabledMouseCursor: MouseCursor.defer, + disabledMouseCursor: MouseCursor.uncontrolled, + ), + ), + ); + } + + expect(createThemeData() == createThemeData(), isTrue); + }); + + test('Defaults to the default typography for the platform', () { + for (final TargetPlatform platform in TargetPlatform.values) { + final theme = ThemeData(platform: platform, useMaterial3: false); + final typography = Typography.material2018(platform: platform); + expect( + theme.textTheme, + typography.black.apply(decoration: TextDecoration.none), + reason: 'Not using default typography for $platform', + ); + } + }); + + test('Default text theme contrasts with brightness', () { + final lightTheme = ThemeData(brightness: Brightness.light, useMaterial3: false); + final darkTheme = ThemeData(brightness: Brightness.dark, useMaterial3: false); + final typography = Typography.material2018(platform: lightTheme.platform); + + expect(lightTheme.textTheme.titleLarge!.color, typography.black.titleLarge!.color); + expect(darkTheme.textTheme.titleLarge!.color, typography.white.titleLarge!.color); + }); + + test('Default primary text theme contrasts with primary brightness', () { + final lightTheme = ThemeData(primaryColor: Colors.white, useMaterial3: false); + final darkTheme = ThemeData(primaryColor: Colors.black, useMaterial3: false); + final typography = Typography.material2018(platform: lightTheme.platform); + + expect(lightTheme.primaryTextTheme.titleLarge!.color, typography.black.titleLarge!.color); + expect(darkTheme.primaryTextTheme.titleLarge!.color, typography.white.titleLarge!.color); + }); + + test('Default icon theme contrasts with brightness', () { + final lightTheme = ThemeData(brightness: Brightness.light, useMaterial3: false); + final darkTheme = ThemeData(brightness: Brightness.dark, useMaterial3: false); + final typography = Typography.material2018(platform: lightTheme.platform); + + expect(lightTheme.textTheme.titleLarge!.color, typography.black.titleLarge!.color); + expect(darkTheme.textTheme.titleLarge!.color, typography.white.titleLarge!.color); + }); + + test('Default primary icon theme contrasts with primary brightness', () { + final lightTheme = ThemeData(primaryColor: Colors.white, useMaterial3: false); + final darkTheme = ThemeData(primaryColor: Colors.black, useMaterial3: false); + final typography = Typography.material2018(platform: lightTheme.platform); + + expect(lightTheme.primaryTextTheme.titleLarge!.color, typography.black.titleLarge!.color); + expect(darkTheme.primaryTextTheme.titleLarge!.color, typography.white.titleLarge!.color); + }); + + test('light, dark and fallback constructors support useMaterial3', () { + final lightTheme = ThemeData(); + expect(lightTheme.useMaterial3, true); + expect(lightTheme.typography, Typography.material2021(colorScheme: lightTheme.colorScheme)); + + final darkTheme = ThemeData.dark(); + expect(darkTheme.useMaterial3, true); + expect(darkTheme.typography, Typography.material2021(colorScheme: darkTheme.colorScheme)); + + final fallbackTheme = ThemeData(); + expect(fallbackTheme.useMaterial3, true); + expect( + fallbackTheme.typography, + Typography.material2021(colorScheme: fallbackTheme.colorScheme), + ); + }); + + testWidgets( + 'Defaults to MaterialTapTargetBehavior.padded on mobile platforms and MaterialTapTargetBehavior.shrinkWrap on desktop', + (WidgetTester tester) async { + final themeData = ThemeData(platform: defaultTargetPlatform); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + expect(themeData.materialTapTargetSize, MaterialTapTargetSize.padded); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(themeData.materialTapTargetSize, MaterialTapTargetSize.shrinkWrap); + } + }, + variant: TargetPlatformVariant.all(), + ); + + test('Can control fontFamily default', () { + final themeData = ThemeData( + fontFamily: 'FlutterTest', + textTheme: const TextTheme(titleLarge: TextStyle(fontFamily: 'Roboto')), + ); + + expect(themeData.textTheme.bodyLarge!.fontFamily, equals('FlutterTest')); + expect(themeData.primaryTextTheme.displaySmall!.fontFamily, equals('FlutterTest')); + + // Shouldn't override the specified style's family + expect(themeData.textTheme.titleLarge!.fontFamily, equals('Roboto')); + }); + + test('Can estimate brightness - directly', () { + expect(ThemeData.estimateBrightnessForColor(Colors.white), equals(Brightness.light)); + expect(ThemeData.estimateBrightnessForColor(Colors.black), equals(Brightness.dark)); + expect(ThemeData.estimateBrightnessForColor(Colors.blue), equals(Brightness.dark)); + expect(ThemeData.estimateBrightnessForColor(Colors.yellow), equals(Brightness.light)); + expect(ThemeData.estimateBrightnessForColor(Colors.deepOrange), equals(Brightness.dark)); + expect(ThemeData.estimateBrightnessForColor(Colors.orange), equals(Brightness.light)); + expect(ThemeData.estimateBrightnessForColor(Colors.lime), equals(Brightness.light)); + expect(ThemeData.estimateBrightnessForColor(Colors.grey), equals(Brightness.light)); + expect(ThemeData.estimateBrightnessForColor(Colors.teal), equals(Brightness.dark)); + expect(ThemeData.estimateBrightnessForColor(Colors.indigo), equals(Brightness.dark)); + }); + + test('cursorColor', () { + expect(const TextSelectionThemeData(cursorColor: Colors.red).cursorColor, Colors.red); + }); + + test('If colorSchemeSeed is used colorScheme, primaryColor and primarySwatch should not be.', () { + expect( + () => ThemeData(colorSchemeSeed: Colors.blue, colorScheme: const ColorScheme.light()), + throwsAssertionError, + ); + expect( + () => ThemeData(colorSchemeSeed: Colors.blue, primaryColor: Colors.green), + throwsAssertionError, + ); + expect( + () => ThemeData(colorSchemeSeed: Colors.blue, primarySwatch: Colors.green), + throwsAssertionError, + ); + }); + + test('ThemeData can generate a light colorScheme from colorSchemeSeed', () { + final theme = ThemeData(colorSchemeSeed: Colors.blue); + + expect(theme.colorScheme.primary, const Color(0xff36618e)); + expect(theme.colorScheme.onPrimary, const Color(0xffffffff)); + expect(theme.colorScheme.primaryContainer, const Color(0xffd1e4ff)); + expect(theme.colorScheme.onPrimaryContainer, const Color(0xff194975)); + expect(theme.colorScheme.primaryFixed, const Color(0xffd1e4ff)); + expect(theme.colorScheme.primaryFixedDim, const Color(0xffa0cafd)); + expect(theme.colorScheme.onPrimaryFixed, const Color(0xff001d36)); + expect(theme.colorScheme.onPrimaryFixedVariant, const Color(0xff194975)); + expect(theme.colorScheme.secondary, const Color(0xff535f70)); + expect(theme.colorScheme.onSecondary, const Color(0xffffffff)); + expect(theme.colorScheme.secondaryContainer, const Color(0xffd7e3f7)); + expect(theme.colorScheme.onSecondaryContainer, const Color(0xff3b4858)); + expect(theme.colorScheme.secondaryFixed, const Color(0xffd7e3f7)); + expect(theme.colorScheme.secondaryFixedDim, const Color(0xffbbc7db)); + expect(theme.colorScheme.onSecondaryFixed, const Color(0xff101c2b)); + expect(theme.colorScheme.onSecondaryFixedVariant, const Color(0xff3b4858)); + expect(theme.colorScheme.tertiary, const Color(0xff6b5778)); + expect(theme.colorScheme.onTertiary, const Color(0xffffffff)); + expect(theme.colorScheme.tertiaryContainer, const Color(0xfff2daff)); + expect(theme.colorScheme.onTertiaryContainer, const Color(0xff523f5f)); + expect(theme.colorScheme.tertiaryFixed, const Color(0xfff2daff)); + expect(theme.colorScheme.tertiaryFixedDim, const Color(0xffd6bee4)); + expect(theme.colorScheme.onTertiaryFixed, const Color(0xff251431)); + expect(theme.colorScheme.onTertiaryFixedVariant, const Color(0xff523f5f)); + expect(theme.colorScheme.error, const Color(0xffba1a1a)); + expect(theme.colorScheme.onError, const Color(0xffffffff)); + expect(theme.colorScheme.errorContainer, const Color(0xffffdad6)); + expect(theme.colorScheme.onErrorContainer, const Color(0xff93000a)); + expect(theme.colorScheme.outline, const Color(0xff73777f)); + expect(theme.colorScheme.outlineVariant, const Color(0xffc3c7cf)); + expect(theme.colorScheme.background, const Color(0xfff8f9ff)); + expect(theme.colorScheme.onBackground, const Color(0xff191c20)); + expect(theme.colorScheme.surface, const Color(0xfff8f9ff)); + expect(theme.colorScheme.surfaceBright, const Color(0xfff8f9ff)); + expect(theme.colorScheme.surfaceDim, const Color(0xffd8dae0)); + expect(theme.colorScheme.surfaceContainerLowest, const Color(0xffffffff)); + expect(theme.colorScheme.surfaceContainerLow, const Color(0xfff2f3fa)); + expect(theme.colorScheme.surfaceContainer, const Color(0xffeceef4)); + expect(theme.colorScheme.surfaceContainerHigh, const Color(0xffe6e8ee)); + expect(theme.colorScheme.surfaceContainerHighest, const Color(0xffe1e2e8)); + expect(theme.colorScheme.onSurface, const Color(0xff191c20)); + expect(theme.colorScheme.surfaceVariant, const Color(0xffdfe2eb)); + expect(theme.colorScheme.onSurfaceVariant, const Color(0xff43474e)); + expect(theme.colorScheme.inverseSurface, const Color(0xff2e3135)); + expect(theme.colorScheme.onInverseSurface, const Color(0xffeff0f7)); + expect(theme.colorScheme.inversePrimary, const Color(0xffa0cafd)); + expect(theme.colorScheme.shadow, const Color(0xff000000)); + expect(theme.colorScheme.scrim, const Color(0xff000000)); + expect(theme.colorScheme.surfaceTint, const Color(0xff36618e)); + expect(theme.colorScheme.brightness, Brightness.light); + + expect(theme.primaryColor, theme.colorScheme.primary); + expect(theme.canvasColor, theme.colorScheme.surface); + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + expect(theme.cardColor, theme.colorScheme.surface); + expect(theme.dividerColor, theme.colorScheme.outline); + expect(theme.dialogBackgroundColor, theme.colorScheme.surface); + expect(theme.indicatorColor, theme.colorScheme.onPrimary); + expect(theme.applyElevationOverlayColor, false); + }); + + test('ThemeData can generate a dark colorScheme from colorSchemeSeed', () { + final theme = ThemeData(colorSchemeSeed: Colors.blue, brightness: Brightness.dark); + + expect(theme.colorScheme.primary, const Color(0xffa0cafd)); + expect(theme.colorScheme.onPrimary, const Color(0xff003258)); + expect(theme.colorScheme.primaryContainer, const Color(0xff194975)); + expect(theme.colorScheme.onPrimaryContainer, const Color(0xffd1e4ff)); + expect(theme.colorScheme.primaryFixed, const Color(0xffd1e4ff)); + expect(theme.colorScheme.primaryFixedDim, const Color(0xffa0cafd)); + expect(theme.colorScheme.onPrimaryFixed, const Color(0xff001d36)); + expect(theme.colorScheme.onPrimaryFixedVariant, const Color(0xff194975)); + expect(theme.colorScheme.secondary, const Color(0xffbbc7db)); + expect(theme.colorScheme.onSecondary, const Color(0xff253140)); + expect(theme.colorScheme.secondaryContainer, const Color(0xff3b4858)); + expect(theme.colorScheme.onSecondaryContainer, const Color(0xffd7e3f7)); + expect(theme.colorScheme.secondaryFixed, const Color(0xffd7e3f7)); + expect(theme.colorScheme.secondaryFixedDim, const Color(0xffbbc7db)); + expect(theme.colorScheme.onSecondaryFixed, const Color(0xff101c2b)); + expect(theme.colorScheme.onSecondaryFixedVariant, const Color(0xff3b4858)); + expect(theme.colorScheme.tertiary, const Color(0xffd6bee4)); + expect(theme.colorScheme.onTertiary, const Color(0xff3b2948)); + expect(theme.colorScheme.tertiaryContainer, const Color(0xff523f5f)); + expect(theme.colorScheme.onTertiaryContainer, const Color(0xfff2daff)); + expect(theme.colorScheme.tertiaryFixed, const Color(0xfff2daff)); + expect(theme.colorScheme.tertiaryFixedDim, const Color(0xffd6bee4)); + expect(theme.colorScheme.onTertiaryFixed, const Color(0xff251431)); + expect(theme.colorScheme.onTertiaryFixedVariant, const Color(0xff523f5f)); + expect(theme.colorScheme.error, const Color(0xffffb4ab)); + expect(theme.colorScheme.onError, const Color(0xff690005)); + expect(theme.colorScheme.errorContainer, const Color(0xff93000a)); + expect(theme.colorScheme.onErrorContainer, const Color(0xffffdad6)); + expect(theme.colorScheme.outline, const Color(0xff8d9199)); + expect(theme.colorScheme.outlineVariant, const Color(0xff43474e)); + expect(theme.colorScheme.background, const Color(0xff111418)); + expect(theme.colorScheme.onBackground, const Color(0xffe1e2e8)); + expect(theme.colorScheme.surface, const Color(0xff111418)); + expect(theme.colorScheme.surfaceDim, const Color(0xff111418)); + expect(theme.colorScheme.surfaceBright, const Color(0xff36393e)); + expect(theme.colorScheme.surfaceContainerLowest, const Color(0xff0b0e13)); + expect(theme.colorScheme.surfaceContainerLow, const Color(0xff191c20)); + expect(theme.colorScheme.surfaceContainer, const Color(0xff1d2024)); + expect(theme.colorScheme.surfaceContainerHigh, const Color(0xff272a2f)); + expect(theme.colorScheme.surfaceContainerHighest, const Color(0xff32353a)); + expect(theme.colorScheme.onSurface, const Color(0xffe1e2e8)); + expect(theme.colorScheme.surfaceVariant, const Color(0xff43474e)); + expect(theme.colorScheme.onSurfaceVariant, const Color(0xffc3c7cf)); + expect(theme.colorScheme.inverseSurface, const Color(0xffe1e2e8)); + expect(theme.colorScheme.onInverseSurface, const Color(0xff2e3135)); + expect(theme.colorScheme.inversePrimary, const Color(0xff36618e)); + expect(theme.colorScheme.shadow, const Color(0xff000000)); + expect(theme.colorScheme.scrim, const Color(0xff000000)); + expect(theme.colorScheme.surfaceTint, const Color(0xffa0cafd)); + expect(theme.colorScheme.brightness, Brightness.dark); + + expect(theme.primaryColor, theme.colorScheme.surface); + expect(theme.canvasColor, theme.colorScheme.surface); + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + expect(theme.cardColor, theme.colorScheme.surface); + expect(theme.dividerColor, theme.colorScheme.outline); + expect(theme.dialogBackgroundColor, theme.colorScheme.surface); + expect(theme.indicatorColor, theme.colorScheme.onSurface); + expect(theme.applyElevationOverlayColor, true); + }); + + test('ThemeData can generate a default M3 light colorScheme when useMaterial3 is true', () { + final theme = ThemeData(); + + expect(theme.colorScheme.primary, const Color(0xff6750a4)); + expect(theme.colorScheme.onPrimary, const Color(0xffffffff)); + expect(theme.colorScheme.primaryContainer, const Color(0xffeaddff)); + expect(theme.colorScheme.onPrimaryContainer, const Color(0xff4f378b)); + expect(theme.colorScheme.primaryFixed, const Color(0xffeaddff)); + expect(theme.colorScheme.primaryFixedDim, const Color(0xffd0bcff)); + expect(theme.colorScheme.onPrimaryFixed, const Color(0xff21005d)); + expect(theme.colorScheme.onPrimaryFixedVariant, const Color(0xff4f378b)); + expect(theme.colorScheme.secondary, const Color(0xff625b71)); + expect(theme.colorScheme.onSecondary, const Color(0xffffffff)); + expect(theme.colorScheme.secondaryContainer, const Color(0xffe8def8)); + expect(theme.colorScheme.onSecondaryContainer, const Color(0xff4a4458)); + expect(theme.colorScheme.secondaryFixed, const Color(0xffe8def8)); + expect(theme.colorScheme.secondaryFixedDim, const Color(0xffccc2dc)); + expect(theme.colorScheme.onSecondaryFixed, const Color(0xff1d192b)); + expect(theme.colorScheme.onSecondaryFixedVariant, const Color(0xff4a4458)); + expect(theme.colorScheme.tertiary, const Color(0xff7d5260)); + expect(theme.colorScheme.onTertiary, const Color(0xffffffff)); + expect(theme.colorScheme.tertiaryContainer, const Color(0xffffd8e4)); + expect(theme.colorScheme.onTertiaryContainer, const Color(0xff633b48)); + expect(theme.colorScheme.tertiaryFixed, const Color(0xffffd8e4)); + expect(theme.colorScheme.tertiaryFixedDim, const Color(0xffefb8c8)); + expect(theme.colorScheme.onTertiaryFixed, const Color(0xff31111d)); + expect(theme.colorScheme.onTertiaryFixedVariant, const Color(0xff633b48)); + expect(theme.colorScheme.error, const Color(0xffb3261e)); + expect(theme.colorScheme.onError, const Color(0xffffffff)); + expect(theme.colorScheme.errorContainer, const Color(0xfff9dedc)); + expect(theme.colorScheme.onErrorContainer, const Color(0xff8c1d18)); + expect(theme.colorScheme.outline, const Color(0xff79747e)); + expect(theme.colorScheme.background, const Color(0xfffef7ff)); + expect(theme.colorScheme.onBackground, const Color(0xff1d1b20)); + expect(theme.colorScheme.surface, const Color(0xfffef7ff)); + expect(theme.colorScheme.onSurface, const Color(0xff1d1b20)); + expect(theme.colorScheme.surfaceVariant, const Color(0xffe7e0ec)); + expect(theme.colorScheme.onSurfaceVariant, const Color(0xff49454f)); + expect(theme.colorScheme.surfaceBright, const Color(0xfffef7ff)); + expect(theme.colorScheme.surfaceDim, const Color(0xffded8e1)); + expect(theme.colorScheme.surfaceContainer, const Color(0xfff3edf7)); + expect(theme.colorScheme.surfaceContainerHighest, const Color(0xffe6e0e9)); + expect(theme.colorScheme.surfaceContainerHigh, const Color(0xffece6f0)); + expect(theme.colorScheme.surfaceContainerLowest, const Color(0xffffffff)); + expect(theme.colorScheme.surfaceContainerLow, const Color(0xfff7f2fa)); + expect(theme.colorScheme.inverseSurface, const Color(0xff322f35)); + expect(theme.colorScheme.onInverseSurface, const Color(0xfff5eff7)); + expect(theme.colorScheme.inversePrimary, const Color(0xffd0bcff)); + expect(theme.colorScheme.shadow, const Color(0xff000000)); + expect(theme.colorScheme.surfaceTint, const Color(0xff6750a4)); + expect(theme.colorScheme.brightness, Brightness.light); + + expect(theme.primaryColor, theme.colorScheme.primary); + expect(theme.canvasColor, theme.colorScheme.surface); + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + expect(theme.cardColor, theme.colorScheme.surface); + expect(theme.dividerColor, theme.colorScheme.outline); + expect(theme.dialogBackgroundColor, theme.colorScheme.surface); + expect(theme.indicatorColor, theme.colorScheme.onPrimary); + expect(theme.applyElevationOverlayColor, false); + }); + + test( + 'ThemeData applies light system colors when useSystemColors is true', + () { + final theme = ThemeData( + colorSchemeSeed: Colors.orange, + brightness: Brightness.light, + useSystemColors: true, + ); + + expect( + theme.colorScheme.secondary, + SystemColor.light.accentColor.value, + skip: !SystemColor.light.accentColor.isSupported, // Color not always supported. + reason: 'Theme secondary color did not match system accent color', + ); + expect( + theme.colorScheme.onSecondary, + SystemColor.light.accentColorText.value, + skip: !SystemColor.light.accentColorText.isSupported, // Color not always supported. + reason: 'Theme onSecondary color did not match system accent color text', + ); + expect( + theme.colorScheme.surface, + SystemColor.light.canvas.value, + skip: !SystemColor.light.canvas.isSupported, // Color not always supported. + reason: 'Theme surface color did not match system canvas color', + ); + expect( + theme.colorScheme.onSurface, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'Theme onSurface color did not match system canvas color text', + ); + + // Text theme + + expect( + theme.textTheme.displayLarge?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme displayLarge color did not match system text color', + ); + expect( + theme.textTheme.displayMedium?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme displayMedium color did not match system text color', + ); + expect( + theme.textTheme.displaySmall?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme displaySmall color did not match system text color', + ); + expect( + theme.textTheme.headlineLarge?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme headlineLarge color did not match system text color', + ); + expect( + theme.textTheme.headlineMedium?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme headlineMedium color did not match system text color', + ); + expect( + theme.textTheme.headlineSmall?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme headlineSmall color did not match system text color', + ); + expect( + theme.textTheme.titleLarge?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme titleLarge color did not match system text color', + ); + expect( + theme.textTheme.titleMedium?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme titleMedium color did not match system text color', + ); + expect( + theme.textTheme.titleSmall?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme titleSmall color did not match system text color', + ); + expect( + theme.textTheme.bodyLarge?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme bodyLarge color did not match system text color', + ); + expect( + theme.textTheme.bodyMedium?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme bodyMedium color did not match system text color', + ); + expect( + theme.textTheme.bodySmall?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme bodySmall color did not match system text color', + ); + expect( + theme.textTheme.labelLarge?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme labelLarge color did not match system text color', + ); + expect( + theme.textTheme.labelMedium?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme labelMedium color did not match system text color', + ); + expect( + theme.textTheme.labelSmall?.color, + SystemColor.light.canvasText.value, + skip: !SystemColor.light.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme labelSmall color did not match system text color', + ); + + // Button themes + + expect( + theme.elevatedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.light.buttonText.value, + skip: !SystemColor.light.buttonText.isSupported, // Color not always supported. + reason: 'ElevatedButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.elevatedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.light.buttonFace.value, + skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported. + reason: 'ElevatedButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.textButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.light.buttonText.value, + skip: !SystemColor.light.buttonText.isSupported, // Color not always supported. + reason: 'TextButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.textButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.light.buttonFace.value, + skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported. + reason: 'TextButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.outlinedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.light.buttonText.value, + skip: !SystemColor.light.buttonText.isSupported, // Color not always supported. + reason: 'OutlinedButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.outlinedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.light.buttonFace.value, + skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported. + reason: 'OutlinedButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.filledButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.light.buttonText.value, + skip: !SystemColor.light.buttonText.isSupported, // Color not always supported. + reason: 'FilledButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.filledButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.light.buttonFace.value, + skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported. + reason: 'FilledButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.floatingActionButtonTheme.foregroundColor, + SystemColor.light.buttonText.value, + skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported. + reason: 'FloatingActionButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.floatingActionButtonTheme.backgroundColor, + SystemColor.light.buttonFace.value, + skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported. + reason: 'FloatingActionButtonTheme backgroundColor did not match system button face color', + ); + }, + // Only run this test on platforms that provide system colors. + skip: !SystemColor.platformProvidesSystemColors, + ); + + test( + 'ThemeData applies dark system colors when useSystemColors is true', + () { + final theme = ThemeData( + colorSchemeSeed: Colors.orange, + brightness: Brightness.dark, + useSystemColors: true, + ); + + expect( + theme.colorScheme.secondary, + SystemColor.dark.accentColor.value, + skip: !SystemColor.dark.accentColor.isSupported, // Color not always supported. + reason: 'Theme secondary color did not match system accent color', + ); + expect( + theme.colorScheme.onSecondary, + SystemColor.dark.accentColorText.value, + skip: !SystemColor.dark.accentColorText.isSupported, // Color not always supported. + reason: 'Theme onSecondary color did not match system accent color text', + ); + expect( + theme.colorScheme.surface, + SystemColor.dark.canvas.value, + skip: !SystemColor.dark.canvas.isSupported, // Color not always supported. + reason: 'Theme surface color did not match system canvas color', + ); + expect( + theme.colorScheme.onSurface, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'Theme onSurface color did not match system canvas color text', + ); + + // Text theme + + expect( + theme.textTheme.displayLarge?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme displayLarge color did not match system text color', + ); + expect( + theme.textTheme.displayMedium?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme displayMedium color did not match system text color', + ); + expect( + theme.textTheme.displaySmall?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme displaySmall color did not match system text color', + ); + expect( + theme.textTheme.headlineLarge?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme headlineLarge color did not match system text color', + ); + expect( + theme.textTheme.headlineMedium?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme headlineMedium color did not match system text color', + ); + expect( + theme.textTheme.headlineSmall?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme headlineSmall color did not match system text color', + ); + expect( + theme.textTheme.titleLarge?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme titleLarge color did not match system text color', + ); + expect( + theme.textTheme.titleMedium?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme titleMedium color did not match system text color', + ); + expect( + theme.textTheme.titleSmall?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme titleSmall color did not match system text color', + ); + expect( + theme.textTheme.bodyLarge?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme bodyLarge color did not match system text color', + ); + expect( + theme.textTheme.bodyMedium?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme bodyMedium color did not match system text color', + ); + expect( + theme.textTheme.bodySmall?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme bodySmall color did not match system text color', + ); + expect( + theme.textTheme.labelLarge?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme labelLarge color did not match system text color', + ); + expect( + theme.textTheme.labelMedium?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme labelMedium color did not match system text color', + ); + expect( + theme.textTheme.labelSmall?.color, + SystemColor.dark.canvasText.value, + skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported. + reason: 'TextTheme labelSmall color did not match system text color', + ); + + // Button themes + + expect( + theme.elevatedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.dark.buttonText.value, + skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported. + reason: 'ElevatedButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.elevatedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.dark.buttonFace.value, + skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported. + reason: 'ElevatedButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.textButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.dark.buttonText.value, + skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported. + reason: 'TextButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.textButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.dark.buttonFace.value, + skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported. + reason: 'TextButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.outlinedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.dark.buttonText.value, + skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported. + reason: 'OutlinedButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.outlinedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{ + WidgetState.pressed, + }), + SystemColor.dark.buttonFace.value, + skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported. + reason: 'OutlinedButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.filledButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.dark.buttonText.value, + skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported. + reason: 'FilledButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.filledButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}), + SystemColor.dark.buttonFace.value, + skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported. + reason: 'FilledButtonTheme backgroundColor did not match system button face color', + ); + + expect( + theme.floatingActionButtonTheme.foregroundColor, + SystemColor.dark.buttonText.value, + skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported. + reason: 'FloatingActionButtonTheme foregroundColor did not match system button text color', + ); + expect( + theme.floatingActionButtonTheme.backgroundColor, + SystemColor.dark.buttonFace.value, + skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported. + reason: 'FloatingActionButtonTheme backgroundColor did not match system button face color', + ); + }, + // Only run this test on platforms that provide system colors. + skip: !SystemColor.platformProvidesSystemColors, + ); + + test( + 'ThemeData.light() can generate a default M3 light colorScheme when useMaterial3 is true', + () { + final theme = ThemeData.light(); + + expect(theme.colorScheme.primary, const Color(0xff6750a4)); + expect(theme.colorScheme.onPrimary, const Color(0xffffffff)); + expect(theme.colorScheme.primaryContainer, const Color(0xffeaddff)); + expect(theme.colorScheme.onPrimaryContainer, const Color(0xff4f378b)); + expect(theme.colorScheme.primaryFixed, const Color(0xffeaddff)); + expect(theme.colorScheme.primaryFixedDim, const Color(0xffd0bcff)); + expect(theme.colorScheme.onPrimaryFixed, const Color(0xff21005d)); + expect(theme.colorScheme.onPrimaryFixedVariant, const Color(0xff4f378b)); + expect(theme.colorScheme.secondary, const Color(0xff625b71)); + expect(theme.colorScheme.onSecondary, const Color(0xffffffff)); + expect(theme.colorScheme.secondaryContainer, const Color(0xffe8def8)); + expect(theme.colorScheme.onSecondaryContainer, const Color(0xff4a4458)); + expect(theme.colorScheme.secondaryFixed, const Color(0xffe8def8)); + expect(theme.colorScheme.secondaryFixedDim, const Color(0xffccc2dc)); + expect(theme.colorScheme.onSecondaryFixed, const Color(0xff1d192b)); + expect(theme.colorScheme.onSecondaryFixedVariant, const Color(0xff4a4458)); + expect(theme.colorScheme.tertiary, const Color(0xff7d5260)); + expect(theme.colorScheme.onTertiary, const Color(0xffffffff)); + expect(theme.colorScheme.tertiaryContainer, const Color(0xffffd8e4)); + expect(theme.colorScheme.onTertiaryContainer, const Color(0xff633b48)); + expect(theme.colorScheme.tertiaryFixed, const Color(0xffffd8e4)); + expect(theme.colorScheme.tertiaryFixedDim, const Color(0xffefb8c8)); + expect(theme.colorScheme.onTertiaryFixed, const Color(0xff31111d)); + expect(theme.colorScheme.onTertiaryFixedVariant, const Color(0xff633b48)); + expect(theme.colorScheme.error, const Color(0xffb3261e)); + expect(theme.colorScheme.onError, const Color(0xffffffff)); + expect(theme.colorScheme.errorContainer, const Color(0xfff9dedc)); + expect(theme.colorScheme.onErrorContainer, const Color(0xff8c1d18)); + expect(theme.colorScheme.outline, const Color(0xff79747e)); + expect(theme.colorScheme.background, const Color(0xfffef7ff)); + expect(theme.colorScheme.onBackground, const Color(0xff1d1b20)); + expect(theme.colorScheme.surface, const Color(0xfffef7ff)); + expect(theme.colorScheme.onSurface, const Color(0xff1d1b20)); + expect(theme.colorScheme.surfaceVariant, const Color(0xffe7e0ec)); + expect(theme.colorScheme.onSurfaceVariant, const Color(0xff49454f)); + expect(theme.colorScheme.surfaceBright, const Color(0xfffef7ff)); + expect(theme.colorScheme.surfaceDim, const Color(0xffded8e1)); + expect(theme.colorScheme.surfaceContainer, const Color(0xfff3edf7)); + expect(theme.colorScheme.surfaceContainerHighest, const Color(0xffe6e0e9)); + expect(theme.colorScheme.surfaceContainerHigh, const Color(0xffece6f0)); + expect(theme.colorScheme.surfaceContainerLowest, const Color(0xffffffff)); + expect(theme.colorScheme.surfaceContainerLow, const Color(0xfff7f2fa)); + expect(theme.colorScheme.inverseSurface, const Color(0xff322f35)); + expect(theme.colorScheme.onInverseSurface, const Color(0xfff5eff7)); + expect(theme.colorScheme.inversePrimary, const Color(0xffd0bcff)); + expect(theme.colorScheme.shadow, const Color(0xff000000)); + expect(theme.colorScheme.surfaceTint, const Color(0xff6750a4)); + expect(theme.colorScheme.brightness, Brightness.light); + + expect(theme.primaryColor, theme.colorScheme.primary); + expect(theme.canvasColor, theme.colorScheme.surface); + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + expect(theme.cardColor, theme.colorScheme.surface); + expect(theme.dividerColor, theme.colorScheme.outline); + expect(theme.dialogBackgroundColor, theme.colorScheme.surface); + expect(theme.indicatorColor, theme.colorScheme.onPrimary); + expect(theme.applyElevationOverlayColor, false); + }, + ); + + test('ThemeData.dark() can generate a default M3 dark colorScheme when useMaterial3 is true', () { + final theme = ThemeData.dark(); + expect(theme.colorScheme.primary, const Color(0xffd0bcff)); + expect(theme.colorScheme.onPrimary, const Color(0xff381e72)); + expect(theme.colorScheme.primaryContainer, const Color(0xff4f378b)); + expect(theme.colorScheme.onPrimaryContainer, const Color(0xffeaddff)); + expect(theme.colorScheme.primaryFixed, const Color(0xffeaddff)); + expect(theme.colorScheme.primaryFixedDim, const Color(0xffd0bcff)); + expect(theme.colorScheme.onPrimaryFixed, const Color(0xff21005d)); + expect(theme.colorScheme.onPrimaryFixedVariant, const Color(0xff4f378b)); + expect(theme.colorScheme.secondary, const Color(0xffccc2dc)); + expect(theme.colorScheme.onSecondary, const Color(0xff332d41)); + expect(theme.colorScheme.secondaryContainer, const Color(0xff4a4458)); + expect(theme.colorScheme.onSecondaryContainer, const Color(0xffe8def8)); + expect(theme.colorScheme.secondaryFixed, const Color(0xffe8def8)); + expect(theme.colorScheme.secondaryFixedDim, const Color(0xffccc2dc)); + expect(theme.colorScheme.onSecondaryFixed, const Color(0xff1d192b)); + expect(theme.colorScheme.onSecondaryFixedVariant, const Color(0xff4a4458)); + expect(theme.colorScheme.tertiary, const Color(0xffefb8c8)); + expect(theme.colorScheme.onTertiary, const Color(0xff492532)); + expect(theme.colorScheme.tertiaryContainer, const Color(0xff633b48)); + expect(theme.colorScheme.onTertiaryContainer, const Color(0xffffd8e4)); + expect(theme.colorScheme.tertiaryFixed, const Color(0xffffd8e4)); + expect(theme.colorScheme.tertiaryFixedDim, const Color(0xffefb8c8)); + expect(theme.colorScheme.onTertiaryFixed, const Color(0xff31111d)); + expect(theme.colorScheme.onTertiaryFixedVariant, const Color(0xff633b48)); + expect(theme.colorScheme.error, const Color(0xfff2b8b5)); + expect(theme.colorScheme.onError, const Color(0xff601410)); + expect(theme.colorScheme.errorContainer, const Color(0xff8c1d18)); + expect(theme.colorScheme.onErrorContainer, const Color(0xfff9dedc)); + expect(theme.colorScheme.outline, const Color(0xff938f99)); + expect(theme.colorScheme.background, const Color(0xff141218)); + expect(theme.colorScheme.onBackground, const Color(0xffe6e0e9)); + expect(theme.colorScheme.surface, const Color(0xff141218)); + expect(theme.colorScheme.onSurface, const Color(0xffe6e0e9)); + expect(theme.colorScheme.surfaceVariant, const Color(0xff49454f)); + expect(theme.colorScheme.onSurfaceVariant, const Color(0xffcac4d0)); + expect(theme.colorScheme.surfaceBright, const Color(0xff3b383e)); + expect(theme.colorScheme.surfaceDim, const Color(0xff141218)); + expect(theme.colorScheme.surfaceContainer, const Color(0xff211f26)); + expect(theme.colorScheme.surfaceContainerHighest, const Color(0xff36343b)); + expect(theme.colorScheme.surfaceContainerHigh, const Color(0xff2b2930)); + expect(theme.colorScheme.surfaceContainerLowest, const Color(0xff0f0d13)); + expect(theme.colorScheme.surfaceContainerLow, const Color(0xff1d1b20)); + expect(theme.colorScheme.inverseSurface, const Color(0xffe6e0e9)); + expect(theme.colorScheme.onInverseSurface, const Color(0xff322f35)); + expect(theme.colorScheme.inversePrimary, const Color(0xff6750a4)); + expect(theme.colorScheme.shadow, const Color(0xff000000)); + expect(theme.colorScheme.surfaceTint, const Color(0xffd0bcff)); + expect(theme.colorScheme.brightness, Brightness.dark); + + expect(theme.primaryColor, theme.colorScheme.surface); + expect(theme.canvasColor, theme.colorScheme.surface); + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + expect(theme.cardColor, theme.colorScheme.surface); + expect(theme.dividerColor, theme.colorScheme.outline); + expect(theme.dialogBackgroundColor, theme.colorScheme.surface); + expect(theme.indicatorColor, theme.colorScheme.onSurface); + expect(theme.applyElevationOverlayColor, true); + }); + + testWidgets('ThemeData.from a light color scheme sets appropriate values', ( + WidgetTester tester, + ) async { + const lightColors = ColorScheme.light(); + final theme = ThemeData.from(colorScheme: lightColors); + + expect(theme.brightness, equals(Brightness.light)); + expect(theme.primaryColor, equals(lightColors.primary)); + expect(theme.cardColor, equals(lightColors.surface)); + expect(theme.canvasColor, equals(lightColors.surface)); + expect(theme.scaffoldBackgroundColor, equals(lightColors.surface)); + expect(theme.dialogBackgroundColor, equals(lightColors.surface)); + expect(theme.applyElevationOverlayColor, isFalse); + }); + + testWidgets('ThemeData.from a dark color scheme sets appropriate values', ( + WidgetTester tester, + ) async { + const darkColors = ColorScheme.dark(); + final theme = ThemeData.from(colorScheme: darkColors); + + expect(theme.brightness, equals(Brightness.dark)); + // in dark theme's the color used for main components is surface instead of primary + expect(theme.primaryColor, equals(darkColors.surface)); + expect(theme.cardColor, equals(darkColors.surface)); + expect(theme.canvasColor, equals(darkColors.surface)); + expect(theme.scaffoldBackgroundColor, equals(darkColors.surface)); + expect(theme.dialogBackgroundColor, equals(darkColors.surface)); + expect(theme.applyElevationOverlayColor, isTrue); + }); + + testWidgets( + 'splashFactory is InkSparkle only for Android non-web when useMaterial3 is true', + (WidgetTester tester) async { + final theme = ThemeData(); + + // Basic check that this theme is in fact using material 3. + expect(theme.useMaterial3, true); + + switch (debugDefaultTargetPlatformOverride!) { + case TargetPlatform.android: + if (kIsWeb) { + expect(theme.splashFactory, equals(InkRipple.splashFactory)); + } else { + expect(theme.splashFactory, equals(InkSparkle.splashFactory)); + } + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(theme.splashFactory, equals(InkRipple.splashFactory)); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'splashFactory is InkSplash for every platform scenario, including Android non-web, when useMaterial3 is false', + (WidgetTester tester) async { + final theme = ThemeData(useMaterial3: false); + + switch (debugDefaultTargetPlatformOverride!) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(theme.splashFactory, equals(InkSplash.splashFactory)); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'VisualDensity.adaptivePlatformDensity returns adaptive values', + (WidgetTester tester) async { + switch (debugDefaultTargetPlatformOverride!) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect(VisualDensity.adaptivePlatformDensity, equals(VisualDensity.standard)); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(VisualDensity.adaptivePlatformDensity, equals(VisualDensity.compact)); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'VisualDensity.getDensityForPlatform returns adaptive values', + (WidgetTester tester) async { + switch (debugDefaultTargetPlatformOverride!) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect( + VisualDensity.defaultDensityForPlatform(debugDefaultTargetPlatformOverride!), + equals(VisualDensity.standard), + ); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect( + VisualDensity.defaultDensityForPlatform(debugDefaultTargetPlatformOverride!), + equals(VisualDensity.compact), + ); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'VisualDensity in ThemeData defaults to "compact" on desktop and "standard" on mobile', + (WidgetTester tester) async { + final themeData = ThemeData(); + switch (debugDefaultTargetPlatformOverride!) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + expect(themeData.visualDensity, equals(VisualDensity.standard)); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(themeData.visualDensity, equals(VisualDensity.compact)); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets( + 'VisualDensity in ThemeData defaults to the right thing when a platform is supplied to it', + (WidgetTester tester) async { + final themeData = ThemeData( + platform: debugDefaultTargetPlatformOverride! == TargetPlatform.android + ? TargetPlatform.linux + : TargetPlatform.android, + ); + switch (debugDefaultTargetPlatformOverride!) { + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(themeData.visualDensity, equals(VisualDensity.standard)); + case TargetPlatform.android: + expect(themeData.visualDensity, equals(VisualDensity.compact)); + } + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('Ensure Visual Density effective constraints are clamped', ( + WidgetTester tester, + ) async { + const square = BoxConstraints.tightFor(width: 35, height: 35); + BoxConstraints expanded = const VisualDensity( + horizontal: 4.0, + vertical: 4.0, + ).effectiveConstraints(square); + expect(expanded.minWidth, equals(35)); + expect(expanded.minHeight, equals(35)); + expect(expanded.maxWidth, equals(35)); + expect(expanded.maxHeight, equals(35)); + + BoxConstraints contracted = const VisualDensity( + horizontal: -4.0, + vertical: -4.0, + ).effectiveConstraints(square); + expect(contracted.minWidth, equals(19)); + expect(contracted.minHeight, equals(19)); + expect(expanded.maxWidth, equals(35)); + expect(expanded.maxHeight, equals(35)); + + const small = BoxConstraints.tightFor(width: 4, height: 4); + expanded = const VisualDensity(horizontal: 4.0, vertical: 4.0).effectiveConstraints(small); + expect(expanded.minWidth, equals(4)); + expect(expanded.minHeight, equals(4)); + expect(expanded.maxWidth, equals(4)); + expect(expanded.maxHeight, equals(4)); + + contracted = const VisualDensity(horizontal: -4.0, vertical: -4.0).effectiveConstraints(small); + expect(contracted.minWidth, equals(0)); + expect(contracted.minHeight, equals(0)); + expect(expanded.maxWidth, equals(4)); + expect(expanded.maxHeight, equals(4)); + }); + + testWidgets('Ensure Visual Density effective constraints expand and contract', ( + WidgetTester tester, + ) async { + const square = BoxConstraints(); + final BoxConstraints expanded = const VisualDensity( + horizontal: 4.0, + vertical: 4.0, + ).effectiveConstraints(square); + expect(expanded.minWidth, equals(16)); + expect(expanded.minHeight, equals(16)); + expect(expanded.maxWidth, equals(double.infinity)); + expect(expanded.maxHeight, equals(double.infinity)); + + final BoxConstraints contracted = const VisualDensity( + horizontal: -4.0, + vertical: -4.0, + ).effectiveConstraints(square); + expect(contracted.minWidth, equals(0)); + expect(contracted.minHeight, equals(0)); + expect(expanded.maxWidth, equals(double.infinity)); + expect(expanded.maxHeight, equals(double.infinity)); + }); + + group('Theme extensions', () { + const containerKey = Key('container'); + + testWidgets('can be obtained', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: const <ThemeExtension<dynamic>>{ + MyThemeExtensionA(color1: Colors.black, color2: Colors.amber), + MyThemeExtensionB(textStyle: TextStyle(fontSize: 50)), + }, + ), + home: Container(key: containerKey), + ), + ); + + final ThemeData theme = Theme.of(tester.element(find.byKey(containerKey))); + + expect(theme.extension<MyThemeExtensionA>()!.color1, Colors.black); + expect(theme.extension<MyThemeExtensionA>()!.color2, Colors.amber); + expect(theme.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 50)); + }); + + testWidgets('can use copyWith', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: <ThemeExtension<dynamic>>{ + const MyThemeExtensionA( + color1: Colors.black, + color2: Colors.amber, + ).copyWith(color1: Colors.blue), + }, + ), + home: Container(key: containerKey), + ), + ); + + final ThemeData theme = Theme.of(tester.element(find.byKey(containerKey))); + + expect(theme.extension<MyThemeExtensionA>()!.color1, Colors.blue); + expect(theme.extension<MyThemeExtensionA>()!.color2, Colors.amber); + }); + + testWidgets('can lerp', (WidgetTester tester) async { + const extensionA1 = MyThemeExtensionA(color1: Colors.black, color2: Colors.amber); + const extensionA2 = MyThemeExtensionA(color1: Colors.white, color2: Colors.blue); + const extensionB1 = MyThemeExtensionB(textStyle: TextStyle(fontSize: 50)); + const extensionB2 = MyThemeExtensionB(textStyle: TextStyle(fontSize: 100)); + + // Both ThemeData arguments include both extensions. + ThemeData lerped = ThemeData.lerp( + ThemeData(extensions: const <ThemeExtension<dynamic>>[extensionA1, extensionB1]), + ThemeData(extensions: const <ThemeExtension<dynamic>>{extensionA2, extensionB2}), + 0.5, + ); + + expect(lerped.extension<MyThemeExtensionA>()!.color1, isSameColorAs(const Color(0xff7f7f7f))); + expect(lerped.extension<MyThemeExtensionA>()!.color2, isSameColorAs(const Color(0xff90ab7d))); + expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 75)); + + // Missing from 2nd ThemeData + lerped = ThemeData.lerp( + ThemeData(extensions: const <ThemeExtension<dynamic>>{extensionA1, extensionB1}), + ThemeData(extensions: const <ThemeExtension<dynamic>>{extensionB2}), + 0.5, + ); + expect( + lerped.extension<MyThemeExtensionA>()!.color1, + isSameColorAs(Colors.black), + ); // Not lerped + expect( + lerped.extension<MyThemeExtensionA>()!.color2, + isSameColorAs(Colors.amber), + ); // Not lerped + expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 75)); + + // Missing from 1st ThemeData + lerped = ThemeData.lerp( + ThemeData(extensions: const <ThemeExtension<dynamic>>{extensionA1}), + ThemeData(extensions: const <ThemeExtension<dynamic>>{extensionA2, extensionB2}), + 0.5, + ); + expect(lerped.extension<MyThemeExtensionA>()!.color1, isSameColorAs(const Color(0xff7f7f7f))); + expect(lerped.extension<MyThemeExtensionA>()!.color2, isSameColorAs(const Color(0xff90ab7d))); + expect( + lerped.extension<MyThemeExtensionB>()!.textStyle, + const TextStyle(fontSize: 100), + ); // Not lerped + }); + + testWidgets('should return null on extension not found', (WidgetTester tester) async { + final theme = ThemeData(extensions: const <ThemeExtension<dynamic>>{}); + + expect(theme.extension<MyThemeExtensionA>(), isNull); + }); + }); + + test('copyWith, ==, hashCode basics', () { + expect(ThemeData(), ThemeData().copyWith()); + expect(ThemeData().hashCode, ThemeData().copyWith().hashCode); + }); + + test('== and hashCode include focusColor and hoverColor', () { + // regression test for https://github.com/flutter/flutter/issues/91587 + + // Focus color and hover color are used in the default button theme, so + // use an empty one to ensure that just focus and hover colors are tested. + const buttonTheme = ButtonThemeData(); + + final focusColorBlack = ThemeData(focusColor: Colors.black, buttonTheme: buttonTheme); + final focusColorWhite = ThemeData(focusColor: Colors.white, buttonTheme: buttonTheme); + expect(focusColorBlack != focusColorWhite, true); + expect(focusColorBlack.hashCode != focusColorWhite.hashCode, true); + + final hoverColorBlack = ThemeData(hoverColor: Colors.black, buttonTheme: buttonTheme); + final hoverColorWhite = ThemeData(hoverColor: Colors.white, buttonTheme: buttonTheme); + expect(hoverColorBlack != hoverColorWhite, true); + expect(hoverColorBlack.hashCode != hoverColorWhite.hashCode, true); + }); + + testWidgets('ThemeData.copyWith correctly creates new ThemeData with all copied arguments', ( + WidgetTester tester, + ) async { + final sliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: Colors.black, + primaryColorDark: Colors.black, + primaryColorLight: Colors.black, + valueIndicatorTextStyle: const TextStyle(color: Colors.black), + ); + + final chipTheme = ChipThemeData.fromDefaults( + primaryColor: Colors.black, + secondaryColor: Colors.white, + labelStyle: const TextStyle(color: Colors.black), + ); + + const pageTransitionTheme = PageTransitionsTheme( + builders: <TargetPlatform, PageTransitionsBuilder>{ + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + }, + ); + + final theme = ThemeData.raw( + // For the sanity of the reader, make sure these properties are in the same + // order everywhere that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + adaptationMap: const <Type, Adaptation<Object>>{}, + applyElevationOverlayColor: false, + cupertinoOverrideTheme: null, + extensions: const <Object, ThemeExtension<dynamic>>{}, + inputDecorationTheme: ThemeData.dark().inputDecorationTheme.copyWith( + border: const OutlineInputBorder(), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + pageTransitionsTheme: pageTransitionTheme, + platform: TargetPlatform.iOS, + scrollbarTheme: const ScrollbarThemeData(radius: Radius.circular(10.0)), + splashFactory: InkRipple.splashFactory, + useMaterial3: false, + visualDensity: VisualDensity.standard, + // COLOR + canvasColor: Colors.black, + cardColor: Colors.black, + colorScheme: const ColorScheme.light(), + disabledColor: Colors.black, + dividerColor: Colors.black, + focusColor: Colors.black, + highlightColor: Colors.black, + hintColor: Colors.black, + hoverColor: Colors.black, + primaryColor: Colors.black, + primaryColorDark: Colors.black, + primaryColorLight: Colors.black, + scaffoldBackgroundColor: Colors.black, + secondaryHeaderColor: Colors.black, + shadowColor: Colors.black, + splashColor: Colors.black, + unselectedWidgetColor: Colors.black, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme: ThemeData.dark().iconTheme, + primaryIconTheme: ThemeData.dark().iconTheme, + primaryTextTheme: ThemeData.dark().textTheme, + textTheme: ThemeData.dark().textTheme, + typography: Typography.material2018(), + // COMPONENT THEMES + actionIconTheme: const ActionIconThemeData(), + appBarTheme: const AppBarThemeData(backgroundColor: Colors.black), + badgeTheme: const BadgeThemeData(backgroundColor: Colors.black), + bannerTheme: const MaterialBannerThemeData(backgroundColor: Colors.black), + bottomAppBarTheme: const BottomAppBarThemeData(color: Colors.black), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + ), + bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Colors.black), + buttonTheme: const ButtonThemeData(colorScheme: ColorScheme.dark()), + cardTheme: const CardThemeData(color: Colors.black), + carouselViewTheme: const CarouselViewThemeData(), + checkboxTheme: const CheckboxThemeData(), + chipTheme: chipTheme, + dataTableTheme: const DataTableThemeData(), + datePickerTheme: const DatePickerThemeData(), + dialogTheme: const DialogThemeData(backgroundColor: Colors.black), + dividerTheme: const DividerThemeData(color: Colors.black), + drawerTheme: const DrawerThemeData(), + dropdownMenuTheme: const DropdownMenuThemeData(), + 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(), + menuBarTheme: const MenuBarThemeData( + style: MenuStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.black)), + ), + menuButtonTheme: MenuButtonThemeData( + style: MenuItemButton.styleFrom(backgroundColor: Colors.black), + ), + menuTheme: const MenuThemeData( + style: MenuStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.black)), + ), + navigationBarTheme: const NavigationBarThemeData(backgroundColor: Colors.black), + navigationDrawerTheme: const NavigationDrawerThemeData(backgroundColor: Colors.black), + navigationRailTheme: const NavigationRailThemeData(backgroundColor: Colors.black), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom(foregroundColor: Colors.blue), + ), + popupMenuTheme: const PopupMenuThemeData(color: Colors.black), + progressIndicatorTheme: const ProgressIndicatorThemeData(), + radioTheme: const RadioThemeData(), + searchBarTheme: const SearchBarThemeData(), + searchViewTheme: const SearchViewThemeData(), + segmentedButtonTheme: const SegmentedButtonThemeData(), + sliderTheme: sliderTheme, + snackBarTheme: const SnackBarThemeData(backgroundColor: Colors.black), + switchTheme: const SwitchThemeData(), + tabBarTheme: const TabBarThemeData(labelColor: Colors.black), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + textSelectionTheme: const TextSelectionThemeData(cursorColor: Colors.black), + timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.black), + toggleButtonsTheme: const ToggleButtonsThemeData(textStyle: TextStyle(color: Colors.black)), + tooltipTheme: const TooltipThemeData(height: 100), + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.start), + dialogBackgroundColor: Colors.black, + indicatorColor: Colors.black, + ); + + final otherSliderTheme = SliderThemeData.fromPrimaryColors( + primaryColor: Colors.white, + primaryColorDark: Colors.white, + primaryColorLight: Colors.white, + valueIndicatorTextStyle: const TextStyle(color: Colors.white), + ); + + final otherChipTheme = ChipThemeData.fromDefaults( + primaryColor: Colors.white, + secondaryColor: Colors.black, + labelStyle: const TextStyle(color: Colors.white), + ); + + final otherTheme = ThemeData.raw( + // For the sanity of the reader, make sure these properties are in the same + // order everywhere that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + adaptationMap: const <Type, Adaptation<Object>>{SwitchThemeData: SwitchThemeAdaptation()}, + applyElevationOverlayColor: true, + cupertinoOverrideTheme: ThemeData().cupertinoOverrideTheme, + extensions: const <Object, ThemeExtension<dynamic>>{ + MyThemeExtensionB: MyThemeExtensionB(textStyle: TextStyle()), + }, + inputDecorationTheme: ThemeData().inputDecorationTheme.copyWith(border: InputBorder.none), + materialTapTargetSize: MaterialTapTargetSize.padded, + pageTransitionsTheme: const PageTransitionsTheme(), + platform: TargetPlatform.android, + scrollbarTheme: const ScrollbarThemeData(radius: Radius.circular(10.0)), + splashFactory: InkRipple.splashFactory, + useMaterial3: true, + visualDensity: VisualDensity.standard, + // COLOR + canvasColor: Colors.white, + cardColor: Colors.white, + colorScheme: const ColorScheme.light(), + disabledColor: Colors.white, + dividerColor: Colors.white, + focusColor: Colors.white, + highlightColor: Colors.white, + hintColor: Colors.white, + hoverColor: Colors.white, + primaryColor: Colors.white, + primaryColorDark: Colors.white, + primaryColorLight: Colors.white, + scaffoldBackgroundColor: Colors.white, + secondaryHeaderColor: Colors.white, + shadowColor: Colors.white, + splashColor: Colors.white, + unselectedWidgetColor: Colors.white, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme: ThemeData().iconTheme, + primaryIconTheme: ThemeData().iconTheme, + primaryTextTheme: ThemeData().textTheme, + textTheme: ThemeData().textTheme, + typography: Typography.material2018(platform: TargetPlatform.iOS), + // COMPONENT THEMES + actionIconTheme: const ActionIconThemeData(), + appBarTheme: const AppBarThemeData(backgroundColor: Colors.white), + badgeTheme: const BadgeThemeData(backgroundColor: Colors.black), + bannerTheme: const MaterialBannerThemeData(backgroundColor: Colors.white), + bottomAppBarTheme: const BottomAppBarThemeData(color: Colors.white), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + type: BottomNavigationBarType.shifting, + ), + bottomSheetTheme: const BottomSheetThemeData(backgroundColor: Colors.white), + buttonTheme: const ButtonThemeData(colorScheme: ColorScheme.light()), + cardTheme: const CardThemeData(color: Colors.white), + carouselViewTheme: const CarouselViewThemeData(), + checkboxTheme: const CheckboxThemeData(), + chipTheme: otherChipTheme, + dataTableTheme: const DataTableThemeData(), + datePickerTheme: const DatePickerThemeData(backgroundColor: Colors.amber), + dialogTheme: const DialogThemeData(backgroundColor: Colors.white), + dividerTheme: const DividerThemeData(color: Colors.white), + drawerTheme: const DrawerThemeData(), + dropdownMenuTheme: const DropdownMenuThemeData(), + elevatedButtonTheme: const ElevatedButtonThemeData(), + expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), + filledButtonTheme: const FilledButtonThemeData(), + floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.white), + iconButtonTheme: const IconButtonThemeData(), + listTileTheme: const ListTileThemeData(), + menuBarTheme: const MenuBarThemeData( + style: MenuStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.white)), + ), + menuButtonTheme: MenuButtonThemeData( + style: MenuItemButton.styleFrom(backgroundColor: Colors.black), + ), + menuTheme: const MenuThemeData( + style: MenuStyle(backgroundColor: MaterialStatePropertyAll<Color>(Colors.white)), + ), + navigationBarTheme: const NavigationBarThemeData(backgroundColor: Colors.white), + navigationDrawerTheme: const NavigationDrawerThemeData(backgroundColor: Colors.white), + navigationRailTheme: const NavigationRailThemeData(backgroundColor: Colors.white), + outlinedButtonTheme: const OutlinedButtonThemeData(), + popupMenuTheme: const PopupMenuThemeData(color: Colors.white), + progressIndicatorTheme: const ProgressIndicatorThemeData(), + radioTheme: const RadioThemeData(), + searchBarTheme: const SearchBarThemeData(), + searchViewTheme: const SearchViewThemeData(), + segmentedButtonTheme: const SegmentedButtonThemeData(), + sliderTheme: otherSliderTheme, + snackBarTheme: const SnackBarThemeData(backgroundColor: Colors.white), + switchTheme: const SwitchThemeData(), + tabBarTheme: const TabBarThemeData(labelColor: Colors.white), + textButtonTheme: const TextButtonThemeData(), + textSelectionTheme: const TextSelectionThemeData(cursorColor: Colors.white), + timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.white), + toggleButtonsTheme: const ToggleButtonsThemeData(textStyle: TextStyle(color: Colors.white)), + tooltipTheme: const TooltipThemeData(height: 100), + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.end), + dialogBackgroundColor: Colors.white, + indicatorColor: Colors.white, + ); + + final ThemeData themeDataCopy = theme.copyWith( + // For the sanity of the reader, make sure these properties are in the same + // order everywhere that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + adaptations: otherTheme.adaptationMap.values, + applyElevationOverlayColor: otherTheme.applyElevationOverlayColor, + cupertinoOverrideTheme: otherTheme.cupertinoOverrideTheme, + extensions: otherTheme.extensions.values, + inputDecorationTheme: otherTheme.inputDecorationTheme, + materialTapTargetSize: otherTheme.materialTapTargetSize, + pageTransitionsTheme: otherTheme.pageTransitionsTheme, + platform: otherTheme.platform, + scrollbarTheme: otherTheme.scrollbarTheme, + splashFactory: otherTheme.splashFactory, + useMaterial3: otherTheme.useMaterial3, + visualDensity: otherTheme.visualDensity, + // COLOR + canvasColor: otherTheme.canvasColor, + cardColor: otherTheme.cardColor, + colorScheme: otherTheme.colorScheme, + disabledColor: otherTheme.disabledColor, + dividerColor: otherTheme.dividerColor, + focusColor: otherTheme.focusColor, + highlightColor: otherTheme.highlightColor, + hintColor: otherTheme.hintColor, + hoverColor: otherTheme.hoverColor, + primaryColor: otherTheme.primaryColor, + primaryColorDark: otherTheme.primaryColorDark, + primaryColorLight: otherTheme.primaryColorLight, + scaffoldBackgroundColor: otherTheme.scaffoldBackgroundColor, + secondaryHeaderColor: otherTheme.secondaryHeaderColor, + shadowColor: otherTheme.shadowColor, + splashColor: otherTheme.splashColor, + unselectedWidgetColor: otherTheme.unselectedWidgetColor, + // TYPOGRAPHY & ICONOGRAPHY + iconTheme: otherTheme.iconTheme, + primaryIconTheme: otherTheme.primaryIconTheme, + primaryTextTheme: otherTheme.primaryTextTheme, + textTheme: otherTheme.textTheme, + typography: otherTheme.typography, + // COMPONENT THEMES + actionIconTheme: otherTheme.actionIconTheme, + appBarTheme: otherTheme.appBarTheme, + badgeTheme: otherTheme.badgeTheme, + bannerTheme: otherTheme.bannerTheme, + bottomAppBarTheme: otherTheme.bottomAppBarTheme, + bottomNavigationBarTheme: otherTheme.bottomNavigationBarTheme, + bottomSheetTheme: otherTheme.bottomSheetTheme, + buttonTheme: otherTheme.buttonTheme, + cardTheme: otherTheme.cardTheme, + checkboxTheme: otherTheme.checkboxTheme, + chipTheme: otherTheme.chipTheme, + dataTableTheme: otherTheme.dataTableTheme, + dialogTheme: otherTheme.dialogTheme, + datePickerTheme: otherTheme.datePickerTheme, + dividerTheme: otherTheme.dividerTheme, + drawerTheme: otherTheme.drawerTheme, + elevatedButtonTheme: otherTheme.elevatedButtonTheme, + expansionTileTheme: otherTheme.expansionTileTheme, + filledButtonTheme: otherTheme.filledButtonTheme, + floatingActionButtonTheme: otherTheme.floatingActionButtonTheme, + iconButtonTheme: otherTheme.iconButtonTheme, + listTileTheme: otherTheme.listTileTheme, + menuBarTheme: otherTheme.menuBarTheme, + menuButtonTheme: otherTheme.menuButtonTheme, + menuTheme: otherTheme.menuTheme, + navigationBarTheme: otherTheme.navigationBarTheme, + navigationDrawerTheme: otherTheme.navigationDrawerTheme, + navigationRailTheme: otherTheme.navigationRailTheme, + outlinedButtonTheme: otherTheme.outlinedButtonTheme, + popupMenuTheme: otherTheme.popupMenuTheme, + progressIndicatorTheme: otherTheme.progressIndicatorTheme, + radioTheme: otherTheme.radioTheme, + searchBarTheme: otherTheme.searchBarTheme, + searchViewTheme: otherTheme.searchViewTheme, + sliderTheme: otherTheme.sliderTheme, + snackBarTheme: otherTheme.snackBarTheme, + switchTheme: otherTheme.switchTheme, + tabBarTheme: otherTheme.tabBarTheme, + textButtonTheme: otherTheme.textButtonTheme, + textSelectionTheme: otherTheme.textSelectionTheme, + timePickerTheme: otherTheme.timePickerTheme, + toggleButtonsTheme: otherTheme.toggleButtonsTheme, + tooltipTheme: otherTheme.tooltipTheme, + // DEPRECATED (newest deprecations at the bottom) + buttonBarTheme: otherTheme.buttonBarTheme, + dialogBackgroundColor: otherTheme.dialogBackgroundColor, + indicatorColor: otherTheme.indicatorColor, + ); + + // For the sanity of the reader, make sure these properties are in the same + // order everywhere that they are separated by section comments (e.g. + // GENERAL CONFIGURATION). Each section except for deprecations should be + // alphabetical by symbol name. + + // GENERAL CONFIGURATION + expect(themeDataCopy.adaptationMap, equals(otherTheme.adaptationMap)); + expect(themeDataCopy.applyElevationOverlayColor, equals(otherTheme.applyElevationOverlayColor)); + expect(themeDataCopy.cupertinoOverrideTheme, equals(otherTheme.cupertinoOverrideTheme)); + expect(themeDataCopy.extensions, equals(otherTheme.extensions)); + expect(themeDataCopy.inputDecorationTheme, equals(otherTheme.inputDecorationTheme)); + expect(themeDataCopy.materialTapTargetSize, equals(otherTheme.materialTapTargetSize)); + expect(themeDataCopy.pageTransitionsTheme, equals(otherTheme.pageTransitionsTheme)); + expect(themeDataCopy.platform, equals(otherTheme.platform)); + expect(themeDataCopy.scrollbarTheme, equals(otherTheme.scrollbarTheme)); + expect(themeDataCopy.splashFactory, equals(otherTheme.splashFactory)); + expect(themeDataCopy.useMaterial3, equals(otherTheme.useMaterial3)); + expect(themeDataCopy.visualDensity, equals(otherTheme.visualDensity)); + // COLOR + expect(themeDataCopy.canvasColor, equals(otherTheme.canvasColor)); + expect(themeDataCopy.cardColor, equals(otherTheme.cardColor)); + expect(themeDataCopy.colorScheme, equals(otherTheme.colorScheme)); + expect(themeDataCopy.disabledColor, equals(otherTheme.disabledColor)); + expect(themeDataCopy.dividerColor, equals(otherTheme.dividerColor)); + expect(themeDataCopy.focusColor, equals(otherTheme.focusColor)); + expect(themeDataCopy.highlightColor, equals(otherTheme.highlightColor)); + expect(themeDataCopy.hintColor, equals(otherTheme.hintColor)); + expect(themeDataCopy.hoverColor, equals(otherTheme.hoverColor)); + expect(themeDataCopy.primaryColor, equals(otherTheme.primaryColor)); + expect(themeDataCopy.primaryColorDark, equals(otherTheme.primaryColorDark)); + expect(themeDataCopy.primaryColorLight, equals(otherTheme.primaryColorLight)); + expect(themeDataCopy.scaffoldBackgroundColor, equals(otherTheme.scaffoldBackgroundColor)); + expect(themeDataCopy.secondaryHeaderColor, equals(otherTheme.secondaryHeaderColor)); + expect(themeDataCopy.shadowColor, equals(otherTheme.shadowColor)); + expect(themeDataCopy.splashColor, equals(otherTheme.splashColor)); + expect(themeDataCopy.unselectedWidgetColor, equals(otherTheme.unselectedWidgetColor)); + // TYPOGRAPHY & ICONOGRAPHY + expect(themeDataCopy.iconTheme, equals(otherTheme.iconTheme)); + expect(themeDataCopy.primaryIconTheme, equals(otherTheme.primaryIconTheme)); + expect(themeDataCopy.primaryTextTheme, equals(otherTheme.primaryTextTheme)); + expect(themeDataCopy.textTheme, equals(otherTheme.textTheme)); + expect(themeDataCopy.typography, equals(otherTheme.typography)); + // COMPONENT THEMES + expect(themeDataCopy.actionIconTheme, equals(otherTheme.actionIconTheme)); + expect(themeDataCopy.appBarTheme, equals(otherTheme.appBarTheme)); + expect(themeDataCopy.badgeTheme, equals(otherTheme.badgeTheme)); + expect(themeDataCopy.bannerTheme, equals(otherTheme.bannerTheme)); + expect(themeDataCopy.bottomAppBarTheme, equals(otherTheme.bottomAppBarTheme)); + expect(themeDataCopy.bottomNavigationBarTheme, equals(otherTheme.bottomNavigationBarTheme)); + expect(themeDataCopy.bottomSheetTheme, equals(otherTheme.bottomSheetTheme)); + expect(themeDataCopy.buttonTheme, equals(otherTheme.buttonTheme)); + expect(themeDataCopy.cardTheme, equals(otherTheme.cardTheme)); + expect(themeDataCopy.checkboxTheme, equals(otherTheme.checkboxTheme)); + expect(themeDataCopy.chipTheme, equals(otherTheme.chipTheme)); + expect(themeDataCopy.dataTableTheme, equals(otherTheme.dataTableTheme)); + expect(themeDataCopy.datePickerTheme, equals(otherTheme.datePickerTheme)); + expect(themeDataCopy.dialogTheme, equals(otherTheme.dialogTheme)); + expect(themeDataCopy.dividerTheme, equals(otherTheme.dividerTheme)); + 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)); + expect(themeDataCopy.menuBarTheme, equals(otherTheme.menuBarTheme)); + expect(themeDataCopy.menuButtonTheme, equals(otherTheme.menuButtonTheme)); + expect(themeDataCopy.menuTheme, equals(otherTheme.menuTheme)); + expect(themeDataCopy.navigationBarTheme, equals(otherTheme.navigationBarTheme)); + expect(themeDataCopy.navigationRailTheme, equals(otherTheme.navigationRailTheme)); + expect(themeDataCopy.outlinedButtonTheme, equals(otherTheme.outlinedButtonTheme)); + expect(themeDataCopy.popupMenuTheme, equals(otherTheme.popupMenuTheme)); + expect(themeDataCopy.progressIndicatorTheme, equals(otherTheme.progressIndicatorTheme)); + expect(themeDataCopy.radioTheme, equals(otherTheme.radioTheme)); + expect(themeDataCopy.searchBarTheme, equals(otherTheme.searchBarTheme)); + expect(themeDataCopy.searchViewTheme, equals(otherTheme.searchViewTheme)); + expect(themeDataCopy.sliderTheme, equals(otherTheme.sliderTheme)); + expect(themeDataCopy.snackBarTheme, equals(otherTheme.snackBarTheme)); + expect(themeDataCopy.switchTheme, equals(otherTheme.switchTheme)); + expect(themeDataCopy.tabBarTheme, equals(otherTheme.tabBarTheme)); + expect(themeDataCopy.textButtonTheme, equals(otherTheme.textButtonTheme)); + expect(themeDataCopy.textSelectionTheme, equals(otherTheme.textSelectionTheme)); + expect( + themeDataCopy.textSelectionTheme.selectionColor, + equals(otherTheme.textSelectionTheme.selectionColor), + ); + expect( + themeDataCopy.textSelectionTheme.cursorColor, + equals(otherTheme.textSelectionTheme.cursorColor), + ); + expect( + themeDataCopy.textSelectionTheme.selectionHandleColor, + equals(otherTheme.textSelectionTheme.selectionHandleColor), + ); + expect(themeDataCopy.timePickerTheme, equals(otherTheme.timePickerTheme)); + expect(themeDataCopy.toggleButtonsTheme, equals(otherTheme.toggleButtonsTheme)); + expect(themeDataCopy.tooltipTheme, equals(otherTheme.tooltipTheme)); + // DEPRECATED (newest deprecations at the bottom) + expect(themeDataCopy.buttonBarTheme, equals(otherTheme.buttonBarTheme)); + expect(themeDataCopy.dialogBackgroundColor, equals(otherTheme.dialogBackgroundColor)); + expect(themeDataCopy.indicatorColor, equals(otherTheme.indicatorColor)); + }); + + testWidgets('ThemeData.toString has less than 200 characters output', ( + WidgetTester tester, + ) async { + // This test makes sure that the ThemeData debug output doesn't get too + // verbose, which has been a problem in the past. + + const darkColors = ColorScheme.dark(); + final darkTheme = ThemeData.from(colorScheme: darkColors); + + expect(darkTheme.toString().length, lessThan(200)); + + const lightColors = ColorScheme.light(); + final lightTheme = ThemeData.from(colorScheme: lightColors); + + expect(lightTheme.toString().length, lessThan(200)); + }); + + testWidgets('ThemeData brightness parameter overrides ColorScheme brightness', ( + WidgetTester tester, + ) async { + const lightColors = ColorScheme.light(); + expect( + () => ThemeData(colorScheme: lightColors, brightness: Brightness.dark), + throwsAssertionError, + ); + }); + + testWidgets('ThemeData.copyWith brightness parameter overrides ColorScheme brightness', ( + WidgetTester tester, + ) async { + const lightColors = ColorScheme.light(); + final ThemeData theme = ThemeData.from( + colorScheme: lightColors, + ).copyWith(brightness: Brightness.dark); + + // The brightness parameter only overrides ColorScheme.brightness. + expect(theme.brightness, equals(Brightness.dark)); + expect(theme.colorScheme.brightness, equals(Brightness.dark)); + expect(theme.primaryColor, equals(lightColors.primary)); + expect(theme.cardColor, equals(lightColors.surface)); + expect(theme.canvasColor, equals(lightColors.surface)); + expect(theme.scaffoldBackgroundColor, equals(lightColors.surface)); + expect(theme.dialogBackgroundColor, equals(lightColors.surface)); + expect(theme.applyElevationOverlayColor, isFalse); + }); + + test('ThemeData diagnostics include all properties', () { + // List of properties must match the properties in ThemeData.hashCode() + final expectedPropertyNames = <String>{ + // GENERAL CONFIGURATION + 'adaptations', + 'applyElevationOverlayColor', + 'cupertinoOverrideTheme', + 'extensions', + 'inputDecorationTheme', + 'materialTapTargetSize', + 'pageTransitionsTheme', + 'platform', + 'scrollbarTheme', + 'splashFactory', + 'visualDensity', + 'useMaterial3', + // COLOR + 'colorScheme', + 'primaryColor', + 'primaryColorLight', + 'primaryColorDark', + 'focusColor', + 'hoverColor', + 'shadowColor', + 'canvasColor', + 'scaffoldBackgroundColor', + 'cardColor', + 'dividerColor', + 'highlightColor', + 'splashColor', + 'unselectedWidgetColor', + 'disabledColor', + 'secondaryHeaderColor', + 'hintColor', + // TYPOGRAPHY & ICONOGRAPHY + 'typography', + 'textTheme', + 'primaryTextTheme', + 'iconTheme', + 'primaryIconTheme', + // COMPONENT THEMES + 'actionIconTheme', + 'appBarTheme', + 'badgeTheme', + 'bannerTheme', + 'bottomAppBarTheme', + 'bottomNavigationBarTheme', + 'bottomSheetTheme', + 'buttonTheme', + 'cardTheme', + 'carouselViewTheme', + 'checkboxTheme', + 'chipTheme', + 'dataTableTheme', + 'datePickerTheme', + 'dialogTheme', + 'dividerTheme', + 'drawerTheme', + 'dropdownMenuTheme', + 'elevatedButtonTheme', + 'expansionTileTheme', + 'filledButtonTheme', + 'floatingActionButtonTheme', + 'iconButtonTheme', + 'listTileTheme', + 'menuBarTheme', + 'menuButtonTheme', + 'menuTheme', + 'navigationBarTheme', + 'navigationDrawerTheme', + 'navigationRailTheme', + 'outlinedButtonTheme', + 'popupMenuTheme', + 'progressIndicatorTheme', + 'radioTheme', + 'searchBarTheme', + 'searchViewTheme', + 'segmentedButtonTheme', + 'sliderTheme', + 'snackBarTheme', + 'switchTheme', + 'tabBarTheme', + 'textButtonTheme', + 'textSelectionTheme', + 'timePickerTheme', + 'toggleButtonsTheme', + 'tooltipTheme', + // DEPRECATED (newest deprecations at the bottom) + 'buttonBarTheme', + 'dialogBackgroundColor', + 'indicatorColor', + }; + + final properties = DiagnosticPropertiesBuilder(); + ThemeData().debugFillProperties(properties); + final List<String> propertyNameList = properties.properties + .map((final DiagnosticsNode node) => node.name) + .whereType<String>() + .toList(); + final Set<String> propertyNames = propertyNameList.toSet(); + + // Ensure there are no duplicates. + expect(propertyNameList.length, propertyNames.length); + + // Ensure they are all there. + expect(propertyNames, expectedPropertyNames); + }); + + group('Theme adaptationMap', () { + const containerKey = Key('container'); + + testWidgets('can be obtained', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + adaptations: const <Adaptation<Object>>[StringAdaptation(), SwitchThemeAdaptation()], + ), + home: Container(key: containerKey), + ), + ); + + final ThemeData theme = Theme.of(tester.element(find.byKey(containerKey))); + final String adaptiveString = theme.getAdaptation<String>()!.adapt(theme, 'Default theme'); + final SwitchThemeData adaptiveSwitchTheme = theme.getAdaptation<SwitchThemeData>()!.adapt( + theme, + theme.switchTheme, + ); + + expect(adaptiveString, 'Adaptive theme.'); + expect(adaptiveSwitchTheme.thumbColor?.resolve(<WidgetState>{}), isSameColorAs(Colors.brown)); + }); + + testWidgets('should return null on extension not found', (WidgetTester tester) async { + final theme = ThemeData(adaptations: const <Adaptation<Object>>[StringAdaptation()]); + + expect(theme.extension<SwitchThemeAdaptation>(), isNull); + }); + }); + + testWidgets( + 'ThemeData.brightness not matching ColorScheme.brightness throws a helpful error message', + (WidgetTester tester) async { + AssertionError? error; + + // Test `ColorScheme.light()` and `ThemeData.brightness == Brightness.dark`. + try { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: const ColorScheme.light(), brightness: Brightness.dark), + home: const Placeholder(), + ), + ); + } on AssertionError catch (e) { + error = e; + } finally { + expect(error, isNotNull); + expect( + error?.message, + contains( + 'ThemeData.brightness does not match ColorScheme.brightness. ' + 'Either override ColorScheme.brightness or ThemeData.brightness to ' + 'match the other.', + ), + ); + } + + // Test `ColorScheme.dark()` and `ThemeData.brightness == Brightness.light`. + try { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: const ColorScheme.dark(), brightness: Brightness.light), + home: const Placeholder(), + ), + ); + } on AssertionError catch (e) { + error = e; + } finally { + expect(error, isNotNull); + expect( + error?.message, + contains( + 'ThemeData.brightness does not match ColorScheme.brightness. ' + 'Either override ColorScheme.brightness or ThemeData.brightness to ' + 'match the other.', + ), + ); + } + + // Test `ColorScheme.fromSeed()` and `ThemeData.brightness == Brightness.dark`. + try { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xffff0000)), + brightness: Brightness.dark, + ), + home: const Placeholder(), + ), + ); + } on AssertionError catch (e) { + error = e; + } finally { + expect(error, isNotNull); + expect( + error?.message, + contains( + 'ThemeData.brightness does not match ColorScheme.brightness. ' + 'Either override ColorScheme.brightness or ThemeData.brightness to ' + 'match the other.', + ), + ); + } + + // Test `ColorScheme.fromSeed()` using `Brightness.dark` and `ThemeData.brightness == Brightness.light`. + try { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xffff0000), + brightness: Brightness.dark, + ), + brightness: Brightness.light, + ), + home: const Placeholder(), + ), + ); + } on AssertionError catch (e) { + error = e; + } finally { + expect(error, isNotNull); + expect( + error?.message, + contains( + 'ThemeData.brightness does not match ColorScheme.brightness. ' + 'Either override ColorScheme.brightness or ThemeData.brightness to ' + 'match the other.', + ), + ); + } + }, + ); + + testWidgets( + 'ThemeData.inputDecorationTheme accepts only a InputDecorationTheme or a InputDecorationThemeData', + (WidgetTester tester) async { + ThemeData(inputDecorationTheme: const InputDecorationTheme()); + expect(tester.takeException(), isNull); + + ThemeData(inputDecorationTheme: const InputDecorationThemeData()); + expect(tester.takeException(), isNull); + + expect( + () { + ThemeData(inputDecorationTheme: Object()); + }, + throwsA( + isA<ArgumentError>().having( + (ArgumentError error) => error.message, + 'message', + equals( + 'inputDecorationTheme must be either a InputDecorationThemeData or a InputDecorationTheme', + ), + ), + ), + ); + }, + ); +} + +@immutable +class MyThemeExtensionA extends ThemeExtension<MyThemeExtensionA> { + const MyThemeExtensionA({required this.color1, required this.color2}); + + final Color? color1; + final Color? color2; + + @override + MyThemeExtensionA copyWith({Color? color1, Color? color2}) { + return MyThemeExtensionA(color1: color1 ?? this.color1, color2: color2 ?? this.color2); + } + + @override + MyThemeExtensionA lerp(MyThemeExtensionA? other, double t) { + if (other is! MyThemeExtensionA) { + return this; + } + return MyThemeExtensionA( + color1: Color.lerp(color1, other.color1, t), + color2: Color.lerp(color2, other.color2, t), + ); + } +} + +@immutable +class MyThemeExtensionB extends ThemeExtension<MyThemeExtensionB> { + const MyThemeExtensionB({required this.textStyle}); + + final TextStyle? textStyle; + + @override + MyThemeExtensionB copyWith({Color? color, TextStyle? textStyle}) { + return MyThemeExtensionB(textStyle: textStyle ?? this.textStyle); + } + + @override + MyThemeExtensionB lerp(MyThemeExtensionB? other, double t) { + if (other is! MyThemeExtensionB) { + return this; + } + return MyThemeExtensionB(textStyle: TextStyle.lerp(textStyle, other.textStyle, t)); + } +} + +class SwitchThemeAdaptation extends Adaptation<SwitchThemeData> { + const SwitchThemeAdaptation(); + + @override + SwitchThemeData adapt(ThemeData theme, SwitchThemeData defaultValue) => + const SwitchThemeData(thumbColor: MaterialStatePropertyAll<Color>(Colors.brown)); +} + +class StringAdaptation extends Adaptation<String> { + const StringAdaptation(); + + @override + String adapt(ThemeData theme, String defaultValue) => 'Adaptive theme.'; +} diff --git a/packages/material_ui/test/material/theme_defaults_test.dart b/packages/material_ui/test/material/theme_defaults_test.dart new file mode 100644 index 000000000000..94fac6f4ced3 --- /dev/null +++ b/packages/material_ui/test/material/theme_defaults_test.dart @@ -0,0 +1,147 @@ +// 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'; + +const Duration defaultButtonDuration = Duration(milliseconds: 200); + +void main() { + group('FloatingActionButton', () { + const defaultFABConstraints = BoxConstraints.tightFor(width: 56.0, height: 56.0); + const ShapeBorder defaultFABShape = CircleBorder(); + const ShapeBorder defaultFABShapeM3 = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ); + const EdgeInsets defaultFABPadding = EdgeInsets.zero; + + testWidgets('Material2 - theme: ThemeData.light(), enabled: true', (WidgetTester tester) async { + final theme = ThemeData.light(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FloatingActionButton( + onPressed: () {}, // button.enabled == true + child: const Icon(Icons.add), + ), + ), + ), + ); + + final RawMaterialButton raw = tester.widget<RawMaterialButton>( + find.byType(RawMaterialButton), + ); + expect(raw.enabled, true); + expect(raw.textStyle!.color, const Color(0xffffffff)); + expect(raw.fillColor, const Color(0xff2196f3)); + expect(raw.elevation, 6.0); + expect(raw.highlightElevation, 12.0); + expect(raw.disabledElevation, 6.0); + expect(raw.constraints, defaultFABConstraints); + expect(raw.padding, defaultFABPadding); + expect(raw.shape, defaultFABShape); + expect(raw.animationDuration, defaultButtonDuration); + expect(raw.materialTapTargetSize, MaterialTapTargetSize.padded); + }); + + testWidgets('Material3 - theme: ThemeData.light(), enabled: true', (WidgetTester tester) async { + final theme = ThemeData.light(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FloatingActionButton( + onPressed: () {}, // button.enabled == true + child: const Icon(Icons.add), + ), + ), + ), + ); + + final RawMaterialButton raw = tester.widget<RawMaterialButton>( + find.byType(RawMaterialButton), + ); + expect(raw.enabled, true); + expect(raw.textStyle!.color, theme.colorScheme.onPrimaryContainer); + expect(raw.fillColor, theme.colorScheme.primaryContainer); + expect(raw.elevation, 6.0); + expect(raw.highlightElevation, 6.0); + expect(raw.disabledElevation, 6.0); + expect(raw.constraints, defaultFABConstraints); + expect(raw.padding, defaultFABPadding); + expect(raw.shape, defaultFABShapeM3); + expect(raw.animationDuration, defaultButtonDuration); + expect(raw.materialTapTargetSize, MaterialTapTargetSize.padded); + }); + + testWidgets('Material2 - theme: ThemeData.light(), enabled: false', ( + WidgetTester tester, + ) async { + final theme = ThemeData.light(useMaterial3: false); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: FloatingActionButton( + onPressed: null, // button.enabled == false + child: Icon(Icons.add), + ), + ), + ), + ); + + final RawMaterialButton raw = tester.widget<RawMaterialButton>( + find.byType(RawMaterialButton), + ); + expect(raw.enabled, false); + expect(raw.textStyle!.color, const Color(0xffffffff)); + expect(raw.fillColor, const Color(0xff2196f3)); + // highlightColor, disabled button can't be pressed + // splashColor, disabled button doesn't splash + expect(raw.elevation, 6.0); + expect(raw.highlightElevation, 12.0); + expect(raw.disabledElevation, 6.0); + expect(raw.constraints, defaultFABConstraints); + expect(raw.padding, defaultFABPadding); + expect(raw.shape, defaultFABShape); + expect(raw.animationDuration, defaultButtonDuration); + expect(raw.materialTapTargetSize, MaterialTapTargetSize.padded); + }); + + testWidgets('Material3 - theme: ThemeData.light(), enabled: false', ( + WidgetTester tester, + ) async { + final theme = ThemeData.light(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: FloatingActionButton( + onPressed: null, // button.enabled == false + child: Icon(Icons.add), + ), + ), + ), + ); + + final RawMaterialButton raw = tester.widget<RawMaterialButton>( + find.byType(RawMaterialButton), + ); + expect(raw.enabled, false); + expect(raw.textStyle!.color, theme.colorScheme.onPrimaryContainer); + expect(raw.fillColor, theme.colorScheme.primaryContainer); + // highlightColor, disabled button can't be pressed + // splashColor, disabled button doesn't splash + expect(raw.elevation, 6.0); + expect(raw.highlightElevation, 6.0); + expect(raw.disabledElevation, 6.0); + expect(raw.constraints, defaultFABConstraints); + expect(raw.padding, defaultFABPadding); + expect(raw.shape, defaultFABShapeM3); + expect(raw.animationDuration, defaultButtonDuration); + expect(raw.materialTapTargetSize, MaterialTapTargetSize.padded); + }); + }); +} diff --git a/packages/material_ui/test/material/theme_test.dart b/packages/material_ui/test/material/theme_test.dart new file mode 100644 index 000000000000..a4ca8cab50b3 --- /dev/null +++ b/packages/material_ui/test/material/theme_test.dart @@ -0,0 +1,1368 @@ +// 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:ui' as ui; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const TextTheme defaultGeometryTheme = Typography.englishLike2014; + const TextTheme defaultGeometryThemeM3 = Typography.englishLike2021; + + test('ThemeDataTween control test', () { + final light = ThemeData(); + final dark = ThemeData.dark(); + final tween = ThemeDataTween(begin: light, end: dark); + expect(tween.lerp(0.25), equals(ThemeData.lerp(light, dark, 0.25))); + }); + + testWidgets('PopupMenu inherits app theme', (WidgetTester tester) async { + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<String>( + key: popupMenuButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + const PopupMenuItem<String>(child: Text('menuItem')), + ]; + }, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pump(const Duration(seconds: 1)); + + expect(Theme.of(tester.element(find.text('menuItem'))).brightness, equals(Brightness.dark)); + }); + + group('Theme.brightnessOf', () { + testWidgets('return correct brightness when just media query is given', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: SizedBox(), + ), + ); + + expect(Theme.brightnessOf(tester.element(find.byType(SizedBox))), equals(Brightness.dark)); + }); + + testWidgets('return correct brightness with overriding theme brightness over media query', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Theme( + data: ThemeData(brightness: Brightness.light), + child: const SizedBox(), + ), + ), + ); + + expect(Theme.brightnessOf(tester.element(find.byType(SizedBox))), equals(Brightness.light)); + }); + + testWidgets('returns Brightness.light when no theme or media query is present', ( + WidgetTester tester, + ) async { + // Prevent the implicitly added View from adding a MediaQuery + await tester.pumpWidget( + RawView(view: FakeFlutterView(tester.view, viewId: 77), child: const SizedBox()), + wrapWithView: false, + ); + + expect(Theme.brightnessOf(tester.element(find.byType(SizedBox))), equals(Brightness.light)); + }); + }); + + group('Theme.maybeBrightnessOf', () { + testWidgets('return correct brightness when just media query is given', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: SizedBox(), + ), + ); + + expect( + Theme.maybeBrightnessOf(tester.element(find.byType(SizedBox))), + equals(Brightness.dark), + ); + }); + + testWidgets('return correct brightness with overriding theme brightness over media query', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Theme( + data: ThemeData(brightness: Brightness.light), + child: const SizedBox(), + ), + ), + ); + + expect( + Theme.maybeBrightnessOf(tester.element(find.byType(SizedBox))), + equals(Brightness.light), + ); + }); + + testWidgets('returns null when no theme or media query is present', ( + WidgetTester tester, + ) async { + // Prevent the implicitly added View from adding a MediaQuery + await tester.pumpWidget( + RawView(view: FakeFlutterView(tester.view, viewId: 77), child: const SizedBox()), + wrapWithView: false, + ); + + expect(Theme.maybeBrightnessOf(tester.element(find.byType(SizedBox))), isNull); + }); + }); + + testWidgets('Theme overrides selection style', (WidgetTester tester) async { + final Key key = UniqueKey(); + const defaultSelectionColor = Color(0x11111111); + const defaultCursorColor = Color(0x22222222); + const themeSelectionColor = Color(0x33333333); + const themeCursorColor = Color(0x44444444); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: DefaultSelectionStyle( + selectionColor: defaultSelectionColor, + cursorColor: defaultCursorColor, + child: Theme( + data: ThemeData( + textSelectionTheme: const TextSelectionThemeData( + selectionColor: themeSelectionColor, + cursorColor: themeCursorColor, + ), + ), + child: TextField(key: key), + ), + ), + ), + ), + ); + // Finds RenderEditable. + final RenderObject root = tester.renderObject(find.byType(EditableText)); + late RenderEditable renderEditable; + void recursiveFinder(RenderObject child) { + if (child is RenderEditable) { + renderEditable = child; + return; + } + child.visitChildren(recursiveFinder); + } + + root.visitChildren(recursiveFinder); + + // Focus text field so it has a selection color. The selection color is null + // on an unfocused text field. + await tester.tap(find.byKey(key)); + await tester.pump(); + + expect(renderEditable.selectionColor, themeSelectionColor); + expect(tester.widget<EditableText>(find.byType(EditableText)).cursorColor, themeCursorColor); + }); + + testWidgets('Material2 - Fallback theme', (WidgetTester tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Builder( + builder: (BuildContext context) { + capturedContext = context; + return Container(); + }, + ), + ), + ); + + expect( + Theme.of(capturedContext), + equals(ThemeData.localize(ThemeData.fallback(useMaterial3: false), defaultGeometryTheme)), + ); + }); + + testWidgets('Material3 - Fallback theme', (WidgetTester tester) async { + late BuildContext capturedContextM3; + await tester.pumpWidget( + Theme( + data: ThemeData(), + child: Builder( + builder: (BuildContext context) { + capturedContextM3 = context; + return Container(); + }, + ), + ), + ); + + expect( + Theme.of(capturedContextM3), + equals(ThemeData.localize(ThemeData.fallback(), defaultGeometryThemeM3)), + ); + }); + + testWidgets('ThemeData.localize memoizes the result', (WidgetTester tester) async { + final light = ThemeData(); + final dark = ThemeData.dark(); + + // Same input, same output. + expect( + ThemeData.localize(light, defaultGeometryTheme), + same(ThemeData.localize(light, defaultGeometryTheme)), + ); + + // Different text geometry, different output. + expect( + ThemeData.localize(light, defaultGeometryTheme), + isNot(same(ThemeData.localize(light, Typography.tall2014))), + ); + + // Different base theme, different output. + expect( + ThemeData.localize(light, defaultGeometryTheme), + isNot(same(ThemeData.localize(dark, defaultGeometryTheme))), + ); + }); + + testWidgets('Material2 - ThemeData with null typography uses proper defaults', ( + WidgetTester tester, + ) async { + final m2Theme = ThemeData(useMaterial3: false); + expect(m2Theme.typography, Typography.material2014()); + }); + + testWidgets('Material3 - ThemeData with null typography uses proper defaults', ( + WidgetTester tester, + ) async { + final m3Theme = ThemeData(); + expect(m3Theme.typography, Typography.material2021(colorScheme: m3Theme.colorScheme)); + }); + + testWidgets('PopupMenu inherits shadowed app theme', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/5572 + final Key popupMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Theme( + data: ThemeData(brightness: Brightness.light), + child: Scaffold( + appBar: AppBar( + actions: <Widget>[ + PopupMenuButton<String>( + key: popupMenuButtonKey, + itemBuilder: (BuildContext context) { + return <PopupMenuItem<String>>[ + const PopupMenuItem<String>(child: Text('menuItem')), + ]; + }, + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(popupMenuButtonKey)); + await tester.pump(const Duration(seconds: 1)); + + expect(Theme.of(tester.element(find.text('menuItem'))).brightness, equals(Brightness.light)); + }); + + testWidgets('DropdownMenu inherits shadowed app theme', (WidgetTester tester) async { + final Key dropdownMenuButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Theme( + data: ThemeData(brightness: Brightness.light), + child: Scaffold( + appBar: AppBar( + actions: <Widget>[ + DropdownButton<String>( + key: dropdownMenuButtonKey, + onChanged: (String? newValue) {}, + value: 'menuItem', + items: const <DropdownMenuItem<String>>[ + DropdownMenuItem<String>(value: 'menuItem', child: Text('menuItem')), + ], + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(dropdownMenuButtonKey)); + await tester.pump(const Duration(seconds: 1)); + + for (final Element item in tester.elementList(find.text('menuItem'))) { + expect(Theme.of(item).brightness, equals(Brightness.light)); + } + }); + + testWidgets('ModalBottomSheet inherits shadowed app theme', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Theme( + data: ThemeData(brightness: Brightness.light), + child: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showModalBottomSheet<void>( + context: context, + builder: (BuildContext context) => const Text('bottomSheet'), + ); + }, + child: const Text('SHOW'), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('SHOW')); + await tester.pump(); // start animation + await tester.pump(const Duration(seconds: 1)); // end animation + expect(Theme.of(tester.element(find.text('bottomSheet'))).brightness, equals(Brightness.light)); + }); + + testWidgets('Dialog inherits shadowed app theme', (WidgetTester tester) async { + final scaffoldKey = GlobalKey<ScaffoldState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Theme( + data: ThemeData(brightness: Brightness.light), + child: Scaffold( + key: scaffoldKey, + body: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) => const Text('dialog'), + ); + }, + child: const Text('SHOW'), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('SHOW')); + await tester.pump(const Duration(seconds: 1)); + expect(Theme.of(tester.element(find.text('dialog'))).brightness, equals(Brightness.light)); + }); + + testWidgets("Scaffold inherits theme's scaffoldBackgroundColor", (WidgetTester tester) async { + const green = Color(0xFF00FF00); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(scaffoldBackgroundColor: green), + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + showDialog<void>( + context: context, + builder: (BuildContext context) { + return const Scaffold(body: SizedBox(width: 200.0, height: 200.0)); + }, + ); + }, + child: const Text('SHOW'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('SHOW')); + await tester.pump(const Duration(seconds: 1)); + + final List<Material> materials = tester.widgetList<Material>(find.byType(Material)).toList(); + expect(materials.length, equals(2)); + expect(materials[0].color, green); // app scaffold + expect(materials[1].color, green); // dialog scaffold + }); + + testWidgets('IconThemes are applied', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(iconTheme: const IconThemeData(color: Colors.green, size: 10.0)), + home: const Icon(Icons.computer), + ), + ); + + RenderParagraph glyphText = tester.renderObject(find.byType(RichText)); + + expect(glyphText.text.style!.color, Colors.green); + expect(glyphText.text.style!.fontSize, 10.0); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(iconTheme: const IconThemeData(color: Colors.orange, size: 20.0)), + home: const Icon(Icons.computer), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); // Halfway through the theme transition + + glyphText = tester.renderObject(find.byType(RichText)); + + expect(glyphText.text.style!.color, Color.lerp(Colors.green, Colors.orange, 0.5)); + expect(glyphText.text.style!.fontSize, 15.0); + + await tester.pump(const Duration(milliseconds: 100)); // Finish the transition + glyphText = tester.renderObject(find.byType(RichText)); + + expect(glyphText.text.style!.color, Colors.orange); + expect(glyphText.text.style!.fontSize, 20.0); + }); + + testWidgets('Same ThemeData reapplied does not trigger descendants rebuilds', ( + WidgetTester tester, + ) async { + testBuildCalled = 0; + var themeData = ThemeData(primaryColor: const Color(0xFF000000)); + + Widget buildTheme() { + return Theme(data: themeData, child: const Test()); + } + + await tester.pumpWidget(buildTheme()); + expect(testBuildCalled, 1); + + // Pump the same widgets again. + await tester.pumpWidget(buildTheme()); + // No repeated build calls to the child since it's the same theme data. + expect(testBuildCalled, 1); + + // New instance of theme data but still the same content. + themeData = ThemeData(primaryColor: const Color(0xFF000000)); + await tester.pumpWidget(buildTheme()); + // Still no repeated calls. + expect(testBuildCalled, 1); + + // Different now. + themeData = ThemeData(primaryColor: const Color(0xFF222222)); + await tester.pumpWidget(buildTheme()); + // Should call build again. + expect(testBuildCalled, 2); + }); + + testWidgets('Text geometry set in Theme has higher precedence than that of Localizations', ( + WidgetTester tester, + ) async { + const kMagicFontSize = 4321.0; + final fallback = ThemeData.fallback(); + final ThemeData customTheme = fallback.copyWith( + primaryTextTheme: fallback.primaryTextTheme.copyWith( + bodyMedium: fallback.primaryTextTheme.bodyMedium!.copyWith(fontSize: kMagicFontSize), + ), + ); + expect(customTheme.primaryTextTheme.bodyMedium!.fontSize, kMagicFontSize); + + late double actualFontSize; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: customTheme, + child: Builder( + builder: (BuildContext context) { + final ThemeData theme = Theme.of(context); + actualFontSize = theme.primaryTextTheme.bodyMedium!.fontSize!; + return Text('A', style: theme.primaryTextTheme.bodyMedium); + }, + ), + ), + ), + ); + + expect(actualFontSize, kMagicFontSize); + }); + + testWidgets('Material2 - Default Theme provides all basic TextStyle properties', ( + WidgetTester tester, + ) async { + late ThemeData theme; + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: false), + child: Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) { + theme = Theme.of(context); + return const Text('A'); + }, + ), + ), + ), + ); + + List<TextStyle> extractStyles(TextTheme textTheme) { + return <TextStyle>[ + textTheme.displayLarge!, + textTheme.displayMedium!, + textTheme.displaySmall!, + textTheme.headlineLarge!, + textTheme.headlineMedium!, + textTheme.headlineSmall!, + textTheme.titleLarge!, + textTheme.titleMedium!, + textTheme.bodyLarge!, + textTheme.bodyMedium!, + textTheme.bodySmall!, + textTheme.labelLarge!, + textTheme.labelMedium!, + // textTheme.labelSmall!, + ]; + } + + for (final textTheme in <TextTheme>[theme.textTheme, theme.primaryTextTheme]) { + for (final TextStyle style in extractStyles( + textTheme, + ).map<TextStyle>((TextStyle style) => _TextStyleProxy(style))) { + expect(style.inherit, false); + expect(style.color, isNotNull); + expect(style.fontFamily, isNotNull); + expect(style.fontSize, isNotNull); + expect(style.fontWeight, isNotNull); + expect(style.fontStyle, null); + expect(style.letterSpacing, null); + expect(style.wordSpacing, null); + expect(style.textBaseline, isNotNull); + expect(style.height, null); + expect(style.decoration, TextDecoration.none); + expect(style.decorationColor, null); + expect(style.decorationStyle, null); + expect(style.debugLabel, isNotNull); + expect(style.locale, null); + expect(style.background, null); + } + } + + expect( + theme.textTheme.displayLarge!.debugLabel, + '(englishLike displayLarge 2014).merge(blackMountainView displayLarge)', + ); + }); + + testWidgets('Material3 - Default Theme provides all basic TextStyle properties', ( + WidgetTester tester, + ) async { + late ThemeData theme; + await tester.pumpWidget( + Theme( + data: ThemeData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (BuildContext context) { + theme = Theme.of(context); + return const Text('A'); + }, + ), + ), + ), + ); + + List<TextStyle> extractStyles(TextTheme textTheme) { + return <TextStyle>[ + textTheme.displayLarge!, + textTheme.displayMedium!, + textTheme.displaySmall!, + textTheme.headlineLarge!, + textTheme.headlineMedium!, + textTheme.headlineSmall!, + textTheme.titleLarge!, + textTheme.titleMedium!, + textTheme.bodyLarge!, + textTheme.bodyMedium!, + textTheme.bodySmall!, + textTheme.labelLarge!, + textTheme.labelMedium!, + ]; + } + + for (final textTheme in <TextTheme>[theme.textTheme, theme.primaryTextTheme]) { + for (final TextStyle style in extractStyles( + textTheme, + ).map<TextStyle>((TextStyle style) => _TextStyleProxy(style))) { + expect(style.inherit, false); + expect(style.color, isNotNull); + expect(style.fontFamily, isNotNull); + expect(style.fontSize, isNotNull); + expect(style.fontWeight, isNotNull); + expect(style.fontStyle, null); + expect(style.letterSpacing, isNotNull); + expect(style.wordSpacing, null); + expect(style.textBaseline, isNotNull); + expect(style.height, isNotNull); + expect(style.decoration, TextDecoration.none); + expect(style.decorationColor, isNotNull); + expect(style.decorationStyle, null); + expect(style.debugLabel, isNotNull); + expect(style.locale, null); + expect(style.background, null); + } + } + + expect( + theme.textTheme.displayLarge!.debugLabel, + '(englishLike displayLarge 2021).merge((blackMountainView displayLarge).apply)', + ); + }); + + group('Cupertino theme', () { + late int buildCount; + CupertinoThemeData? actualTheme; + IconThemeData? actualIconTheme; + BuildContext? context; + + final Widget singletonThemeSubtree = Builder( + builder: (BuildContext localContext) { + buildCount++; + actualTheme = CupertinoTheme.of(localContext); + actualIconTheme = IconTheme.of(localContext); + context = localContext; + return const Placeholder(); + }, + ); + + Future<CupertinoThemeData> testTheme(WidgetTester tester, ThemeData theme) async { + await tester.pumpWidget(Theme(data: theme, child: singletonThemeSubtree)); + return actualTheme!; + } + + setUp(() { + buildCount = 0; + actualTheme = null; + actualIconTheme = null; + context = null; + }); + + testWidgets('Material2 - Default light theme has defaults', (WidgetTester tester) async { + final CupertinoThemeData themeM2 = await testTheme(tester, ThemeData(useMaterial3: false)); + + expect(themeM2.brightness, Brightness.light); + expect(themeM2.primaryColor, Colors.blue); + expect(themeM2.scaffoldBackgroundColor, Colors.grey[50]); + expect(themeM2.primaryContrastingColor, Colors.white); + expect(themeM2.textTheme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(themeM2.textTheme.textStyle.fontSize, 17.0); + }); + + testWidgets('Material3 - Default light theme has defaults', (WidgetTester tester) async { + final CupertinoThemeData themeM3 = await testTheme(tester, ThemeData()); + + expect(themeM3.brightness, Brightness.light); + expect(themeM3.primaryColor, const Color(0xff6750a4)); + expect(themeM3.scaffoldBackgroundColor, const Color(0xfffef7ff)); // ColorScheme.background + expect(themeM3.primaryContrastingColor, Colors.white); + expect(themeM3.textTheme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(themeM3.textTheme.textStyle.fontSize, 17.0); + }); + + testWidgets('Material2 - Dark theme has defaults', (WidgetTester tester) async { + final CupertinoThemeData themeM2 = await testTheme( + tester, + ThemeData.dark(useMaterial3: false), + ); + + expect(themeM2.brightness, Brightness.dark); + expect(themeM2.primaryColor, Colors.blue); + expect(themeM2.primaryContrastingColor, Colors.white); + expect(themeM2.scaffoldBackgroundColor, Colors.grey[850]); + expect(themeM2.textTheme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(themeM2.textTheme.textStyle.fontSize, 17.0); + }); + + testWidgets('Material3 - Dark theme has defaults', (WidgetTester tester) async { + final CupertinoThemeData themeM3 = await testTheme(tester, ThemeData.dark()); + + expect(themeM3.brightness, Brightness.dark); + expect(themeM3.primaryColor, const Color(0xffd0bcff)); + expect(themeM3.primaryContrastingColor, const Color(0xff381e72)); + expect(themeM3.scaffoldBackgroundColor, const Color(0xff141218)); + expect(themeM3.textTheme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(themeM3.textTheme.textStyle.fontSize, 17.0); + }); + + testWidgets('MaterialTheme overrides the brightness', (WidgetTester tester) async { + await testTheme(tester, ThemeData.dark()); + expect(CupertinoTheme.brightnessOf(context!), Brightness.dark); + + await testTheme(tester, ThemeData()); + expect(CupertinoTheme.brightnessOf(context!), Brightness.light); + + // Overridable by cupertinoOverrideTheme. + await testTheme( + tester, + ThemeData( + brightness: Brightness.light, + cupertinoOverrideTheme: const CupertinoThemeData(brightness: Brightness.dark), + ), + ); + expect(CupertinoTheme.brightnessOf(context!), Brightness.dark); + + await testTheme( + tester, + ThemeData( + brightness: Brightness.dark, + cupertinoOverrideTheme: const CupertinoThemeData(brightness: Brightness.light), + ), + ); + expect(CupertinoTheme.brightnessOf(context!), Brightness.light); + }); + + testWidgets('Cupertino widgets correctly get the right text theme in dark mode', ( + WidgetTester tester, + ) async { + final GlobalKey textFieldKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: Scaffold(body: CupertinoTextField(key: textFieldKey)), + ), + ); + await tester.pumpAndSettle(); + + final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); + + // Default CupertinoTextStyle color is a CupertinoDynamicColor. + final CupertinoThemeData cupertinoThemeData = CupertinoTheme.of(textFieldKey.currentContext!); + expect(cupertinoThemeData.textTheme.textStyle.color, isA<CupertinoDynamicColor>()); + final themeTextStyleColor = + cupertinoThemeData.textTheme.textStyle.color! as CupertinoDynamicColor; + + // The value of the textfield's color should resolve to the theme's dark color. + expect(state.widget.style.color?.value, equals(themeTextStyleColor.darkColor.value)); + }); + + testWidgets('Material2 - Can override material theme', (WidgetTester tester) async { + final CupertinoThemeData themeM2 = await testTheme( + tester, + ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + scaffoldBackgroundColor: CupertinoColors.lightBackgroundGray, + ), + useMaterial3: false, + ), + ); + + expect(themeM2.brightness, Brightness.light); + // We took the scaffold background override but the rest are still cascaded + // to the material themeM2. + expect(themeM2.primaryColor, Colors.blue); + expect(themeM2.primaryContrastingColor, Colors.white); + expect(themeM2.scaffoldBackgroundColor, CupertinoColors.lightBackgroundGray); + expect(themeM2.textTheme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(themeM2.textTheme.textStyle.fontSize, 17.0); + }); + + testWidgets('Material3 - Can override material theme', (WidgetTester tester) async { + final CupertinoThemeData themeM3 = await testTheme( + tester, + ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + scaffoldBackgroundColor: CupertinoColors.lightBackgroundGray, + ), + ), + ); + + expect(themeM3.brightness, Brightness.light); + // We took the scaffold background override but the rest are still cascaded + // to the material themeM3. + expect(themeM3.primaryColor, const Color(0xff6750a4)); + expect(themeM3.primaryContrastingColor, Colors.white); + expect(themeM3.scaffoldBackgroundColor, CupertinoColors.lightBackgroundGray); + expect(themeM3.textTheme.textStyle.fontFamily, 'CupertinoSystemText'); + expect(themeM3.textTheme.textStyle.fontSize, 17.0); + }); + + testWidgets('Material2 - Can override properties that are independent of material', ( + WidgetTester tester, + ) async { + final CupertinoThemeData themeM2 = await testTheme( + tester, + ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + // The bar colors ignore all things material except brightness. + barBackgroundColor: CupertinoColors.black, + ), + useMaterial3: false, + ), + ); + + expect(themeM2.primaryColor, Colors.blue); + // MaterialBasedCupertinoThemeData should also function like a normal CupertinoThemeData. + expect(themeM2.barBackgroundColor, CupertinoColors.black); + }); + + testWidgets('Material3 - Can override properties that are independent of material', ( + WidgetTester tester, + ) async { + final CupertinoThemeData themeM3 = await testTheme( + tester, + ThemeData( + cupertinoOverrideTheme: const CupertinoThemeData( + // The bar colors ignore all things material except brightness. + barBackgroundColor: CupertinoColors.black, + ), + ), + ); + + expect(themeM3.primaryColor, const Color(0xff6750a4)); + // MaterialBasedCupertinoThemeData should also function like a normal CupertinoThemeData. + expect(themeM3.barBackgroundColor, CupertinoColors.black); + }); + + testWidgets('Material2 - Changing material theme triggers rebuilds', ( + WidgetTester tester, + ) async { + CupertinoThemeData themeM2 = await testTheme( + tester, + ThemeData(useMaterial3: false, primarySwatch: Colors.red), + ); + + expect(buildCount, 1); + expect(themeM2.primaryColor, Colors.red); + + themeM2 = await testTheme( + tester, + ThemeData(useMaterial3: false, primarySwatch: Colors.orange), + ); + + expect(buildCount, 2); + expect(themeM2.primaryColor, Colors.orange); + }); + + testWidgets('Material3 - Changing material theme triggers rebuilds', ( + WidgetTester tester, + ) async { + CupertinoThemeData themeM3 = await testTheme( + tester, + ThemeData(colorScheme: const ColorScheme.light(primary: Colors.red)), + ); + + expect(buildCount, 1); + expect(themeM3.primaryColor, Colors.red); + + themeM3 = await testTheme( + tester, + ThemeData(colorScheme: const ColorScheme.light(primary: Colors.orange)), + ); + + expect(buildCount, 2); + expect(themeM3.primaryColor, Colors.orange); + }); + + testWidgets("CupertinoThemeData does not override material theme's icon theme", ( + WidgetTester tester, + ) async { + const Color materialIconColor = Colors.blue; + const Color cupertinoIconColor = Colors.black; + + await testTheme( + tester, + ThemeData( + iconTheme: const IconThemeData(color: materialIconColor), + cupertinoOverrideTheme: const CupertinoThemeData(primaryColor: cupertinoIconColor), + ), + ); + + expect(buildCount, 1); + expect(actualIconTheme!.color, materialIconColor); + }); + + testWidgets('Changing cupertino theme override triggers rebuilds', (WidgetTester tester) async { + CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + primarySwatch: Colors.purple, + cupertinoOverrideTheme: const CupertinoThemeData( + primaryColor: CupertinoColors.activeOrange, + ), + ), + ); + + expect(buildCount, 1); + expect(theme.primaryColor, CupertinoColors.activeOrange); + + theme = await testTheme( + tester, + ThemeData( + primarySwatch: Colors.purple, + cupertinoOverrideTheme: const CupertinoThemeData( + primaryColor: CupertinoColors.activeGreen, + ), + ), + ); + + expect(buildCount, 2); + expect(theme.primaryColor, CupertinoColors.activeGreen); + }); + + testWidgets('Cupertino theme override blocks derivative changes', (WidgetTester tester) async { + CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + primarySwatch: Colors.purple, + cupertinoOverrideTheme: const CupertinoThemeData( + primaryColor: CupertinoColors.activeOrange, + ), + ), + ); + + expect(buildCount, 1); + expect(theme.primaryColor, CupertinoColors.activeOrange); + + // Change the upstream material primary color. + theme = await testTheme( + tester, + ThemeData( + primarySwatch: Colors.blue, + cupertinoOverrideTheme: const CupertinoThemeData( + // But the primary material color is preempted by the override. + primaryColor: CupertinoColors.systemRed, + ), + ), + ); + + expect(buildCount, 2); + expect(theme.primaryColor, CupertinoColors.systemRed); + }); + + testWidgets( + 'Material2 - Cupertino overrides do not block derivatives triggering rebuilds when derivatives are not overridden', + (WidgetTester tester) async { + CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + useMaterial3: false, + primarySwatch: Colors.purple, + cupertinoOverrideTheme: const CupertinoThemeData( + primaryContrastingColor: CupertinoColors.destructiveRed, + ), + ), + ); + + expect(buildCount, 1); + expect(theme.textTheme.actionTextStyle.color, Colors.purple); + expect(theme.primaryContrastingColor, CupertinoColors.destructiveRed); + + theme = await testTheme( + tester, + ThemeData( + useMaterial3: false, + primarySwatch: Colors.green, + cupertinoOverrideTheme: const CupertinoThemeData( + primaryContrastingColor: CupertinoColors.destructiveRed, + ), + ), + ); + + expect(buildCount, 2); + expect(theme.textTheme.actionTextStyle.color, Colors.green); + expect(theme.primaryContrastingColor, CupertinoColors.destructiveRed); + }, + ); + + testWidgets( + 'Material3 - Cupertino overrides do not block derivatives triggering rebuilds when derivatives are not overridden', + (WidgetTester tester) async { + CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + colorScheme: const ColorScheme.light(primary: Colors.purple), + cupertinoOverrideTheme: const CupertinoThemeData( + primaryContrastingColor: CupertinoColors.destructiveRed, + ), + ), + ); + + expect(buildCount, 1); + expect(theme.textTheme.actionTextStyle.color, Colors.purple); + expect(theme.primaryContrastingColor, CupertinoColors.destructiveRed); + + theme = await testTheme( + tester, + ThemeData( + colorScheme: const ColorScheme.light(primary: Colors.green), + cupertinoOverrideTheme: const CupertinoThemeData( + primaryContrastingColor: CupertinoColors.destructiveRed, + ), + ), + ); + + expect(buildCount, 2); + expect(theme.textTheme.actionTextStyle.color, Colors.green); + expect(theme.primaryContrastingColor, CupertinoColors.destructiveRed); + }, + ); + + testWidgets( + 'Material2 - copyWith only copies the overrides, not the material or cupertino derivatives', + (WidgetTester tester) async { + final CupertinoThemeData originalTheme = await testTheme( + tester, + ThemeData( + useMaterial3: false, + primarySwatch: Colors.purple, + cupertinoOverrideTheme: const CupertinoThemeData( + primaryContrastingColor: CupertinoColors.activeOrange, + ), + ), + ); + + final CupertinoThemeData copiedTheme = originalTheme.copyWith( + barBackgroundColor: CupertinoColors.destructiveRed, + ); + + final CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + useMaterial3: false, + primarySwatch: Colors.blue, + cupertinoOverrideTheme: copiedTheme, + ), + ); + + expect(theme.primaryColor, Colors.blue); + expect(theme.primaryContrastingColor, CupertinoColors.activeOrange); + expect(theme.barBackgroundColor, CupertinoColors.destructiveRed); + }, + ); + + testWidgets( + 'Material3 - copyWith only copies the overrides, not the material or cupertino derivatives', + (WidgetTester tester) async { + final CupertinoThemeData originalTheme = await testTheme( + tester, + ThemeData( + colorScheme: const ColorScheme.light(primary: Colors.purple), + cupertinoOverrideTheme: const CupertinoThemeData( + primaryContrastingColor: CupertinoColors.activeOrange, + ), + ), + ); + + final CupertinoThemeData copiedTheme = originalTheme.copyWith( + barBackgroundColor: CupertinoColors.destructiveRed, + ); + + final CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + colorScheme: const ColorScheme.light(primary: Colors.blue), + cupertinoOverrideTheme: copiedTheme, + ), + ); + + expect(theme.primaryColor, Colors.blue); + expect(theme.primaryContrastingColor, CupertinoColors.activeOrange); + expect(theme.barBackgroundColor, CupertinoColors.destructiveRed); + }, + ); + + testWidgets("Material2 - Material themes with no cupertino overrides can also be copyWith'ed", ( + WidgetTester tester, + ) async { + final CupertinoThemeData originalTheme = await testTheme( + tester, + ThemeData(useMaterial3: false, primarySwatch: Colors.purple), + ); + + final CupertinoThemeData copiedTheme = originalTheme.copyWith( + primaryContrastingColor: CupertinoColors.destructiveRed, + ); + + final CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + useMaterial3: false, + primarySwatch: Colors.blue, + cupertinoOverrideTheme: copiedTheme, + ), + ); + + expect(theme.primaryColor, Colors.blue); + expect(theme.primaryContrastingColor, CupertinoColors.destructiveRed); + }); + + testWidgets("Material3 - Material themes with no cupertino overrides can also be copyWith'ed", ( + WidgetTester tester, + ) async { + final CupertinoThemeData originalTheme = await testTheme( + tester, + ThemeData(colorScheme: const ColorScheme.light(primary: Colors.purple)), + ); + + final CupertinoThemeData copiedTheme = originalTheme.copyWith( + primaryContrastingColor: CupertinoColors.destructiveRed, + ); + + final CupertinoThemeData theme = await testTheme( + tester, + ThemeData( + colorScheme: const ColorScheme.light(primary: Colors.blue), + cupertinoOverrideTheme: copiedTheme, + ), + ); + + expect(theme.primaryColor, Colors.blue); + expect(theme.primaryContrastingColor, CupertinoColors.destructiveRed); + }); + }); +} + +int testBuildCalled = 0; + +class Test extends StatelessWidget { + const Test({super.key}); + + @override + Widget build(BuildContext context) { + testBuildCalled += 1; + return Container(decoration: BoxDecoration(color: Theme.of(context).primaryColor)); + } +} + +/// This class exists only to make sure that we test all the properties of the +/// [TextStyle] class. If a property is added/removed/renamed, the analyzer will +/// complain that this class has incorrect overrides. +class _TextStyleProxy implements TextStyle { + _TextStyleProxy(this._delegate); + + final TextStyle _delegate; + + // Do make sure that all the properties correctly forward to the _delegate. + @override + Color? get color => _delegate.color; + @override + Color? get backgroundColor => _delegate.backgroundColor; + @override + String? get debugLabel => _delegate.debugLabel; + @override + TextDecoration? get decoration => _delegate.decoration; + @override + Color? get decorationColor => _delegate.decorationColor; + @override + TextDecorationStyle? get decorationStyle => _delegate.decorationStyle; + @override + double? get decorationThickness => _delegate.decorationThickness; + @override + String? get fontFamily => _delegate.fontFamily; + @override + List<String>? get fontFamilyFallback => _delegate.fontFamilyFallback; + @override + double? get fontSize => _delegate.fontSize; + @override + FontStyle? get fontStyle => _delegate.fontStyle; + @override + FontWeight? get fontWeight => _delegate.fontWeight; + @override + double? get height => _delegate.height; + @override + TextLeadingDistribution? get leadingDistribution => _delegate.leadingDistribution; + @override + Locale? get locale => _delegate.locale; + @override + ui.Paint? get foreground => _delegate.foreground; + @override + ui.Paint? get background => _delegate.background; + @override + bool get inherit => _delegate.inherit; + @override + double? get letterSpacing => _delegate.letterSpacing; + @override + TextBaseline? get textBaseline => _delegate.textBaseline; + @override + double? get wordSpacing => _delegate.wordSpacing; + @override + List<Shadow>? get shadows => _delegate.shadows; + @override + List<ui.FontFeature>? get fontFeatures => _delegate.fontFeatures; + @override + List<ui.FontVariation>? get fontVariations => _delegate.fontVariations; + @override + TextOverflow? get overflow => _delegate.overflow; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => super.toString(); + + @override + DiagnosticsNode toDiagnosticsNode({String? name, DiagnosticsTreeStyle? style}) { + throw UnimplementedError(); + } + + @override + String toStringShort() { + throw UnimplementedError(); + } + + @override + TextStyle apply({ + Color? color, + Color? backgroundColor, + TextDecoration? decoration, + Color? decorationColor, + TextDecorationStyle? decorationStyle, + double decorationThicknessFactor = 1.0, + double decorationThicknessDelta = 0.0, + String? fontFamily, + List<String>? fontFamilyFallback, + double fontSizeFactor = 1.0, + double fontSizeDelta = 0.0, + int fontWeightDelta = 0, + FontStyle? fontStyle, + double letterSpacingFactor = 1.0, + double letterSpacingDelta = 0.0, + double wordSpacingFactor = 1.0, + double wordSpacingDelta = 0.0, + double heightFactor = 1.0, + double heightDelta = 0.0, + TextLeadingDistribution? leadingDistribution, + TextBaseline? textBaseline, + Locale? locale, + List<ui.Shadow>? shadows, + List<ui.FontFeature>? fontFeatures, + List<ui.FontVariation>? fontVariations, + TextOverflow? overflow, + String? package, + }) { + throw UnimplementedError(); + } + + @override + RenderComparison compareTo(TextStyle other) { + throw UnimplementedError(); + } + + @override + TextStyle copyWith({ + bool? inherit, + Color? color, + Color? backgroundColor, + String? fontFamily, + List<String>? fontFamilyFallback, + double? fontSize, + FontWeight? fontWeight, + FontStyle? fontStyle, + double? letterSpacing, + double? wordSpacing, + TextBaseline? textBaseline, + double? height, + TextLeadingDistribution? leadingDistribution, + Locale? locale, + ui.Paint? foreground, + ui.Paint? background, + List<Shadow>? shadows, + List<ui.FontFeature>? fontFeatures, + List<ui.FontVariation>? fontVariations, + TextDecoration? decoration, + Color? decorationColor, + TextDecorationStyle? decorationStyle, + double? decorationThickness, + String? debugLabel, + TextOverflow? overflow, + String? package, + }) { + throw UnimplementedError(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties, {String prefix = ''}) { + throw UnimplementedError(); + } + + @override + ui.ParagraphStyle getParagraphStyle({ + TextAlign? textAlign, + TextDirection? textDirection, + double textScaleFactor = 1.0, + TextScaler textScaler = TextScaler.noScaling, + String? ellipsis, + int? maxLines, + ui.TextHeightBehavior? textHeightBehavior, + Locale? locale, + String? fontFamily, + double? fontSize, + FontWeight? fontWeight, + FontStyle? fontStyle, + double? height, + StrutStyle? strutStyle, + }) { + throw UnimplementedError(); + } + + @override + ui.TextStyle getTextStyle({ + double textScaleFactor = 1.0, + TextScaler textScaler = TextScaler.noScaling, + }) { + throw UnimplementedError(); + } + + @override + TextStyle merge(TextStyle? other) { + throw UnimplementedError(); + } +} + +class FakeFlutterView extends TestFlutterView { + FakeFlutterView(TestFlutterView view, {required this.viewId}) + : super(view: view, display: view.display, platformDispatcher: view.platformDispatcher); + + @override + final int viewId; +} diff --git a/packages/material_ui/test/material/time_picker_test.dart b/packages/material_ui/test/material/time_picker_test.dart new file mode 100644 index 000000000000..2dbdd349cb46 --- /dev/null +++ b/packages/material_ui/test/material/time_picker_test.dart @@ -0,0 +1,2926 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +@TestOn('!chrome') +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/feedback_tester.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + const okString = 'OK'; + const amString = 'AM'; + const pmString = 'PM'; + + Material getMaterialFromDialog(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); + } + + Finder findBorderPainter() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), + ); + } + + testWidgets('Material2 - Dialog size - dial mode', (WidgetTester tester) async { + addTearDown(tester.view.reset); + + const timePickerPortraitSize = Size(310, 468); + const timePickerLandscapeSize = Size(524, 342); + const timePickerLandscapeSizeM2 = Size(508, 300); + const padding = EdgeInsets.fromLTRB(8, 18, 8, 8); + double width; + double height; + + // portrait + tester.view.physicalSize = const Size(800, 800.5); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate(tester, materialType: MaterialType.material2); + + width = timePickerPortraitSize.width + padding.horizontal; + height = timePickerPortraitSize.height + padding.vertical; + expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // landscape + tester.view.physicalSize = const Size(800.5, 800); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: MaterialType.material2, + ); + + width = timePickerLandscapeSize.width + padding.horizontal; + height = timePickerLandscapeSizeM2.height + padding.vertical; + expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); + }); + + testWidgets('Material2 - Dialog size - input mode', (WidgetTester tester) async { + const TimePickerEntryMode entryMode = TimePickerEntryMode.input; + const timePickerInputSize = Size(312, 252); + const dayPeriodPortraitSize = Size(52, 80); + const padding = EdgeInsets.fromLTRB(8, 18, 8, 8); + final double height = timePickerInputSize.height + padding.vertical; + double width; + + await mediaQueryBoilerplate(tester, entryMode: entryMode, materialType: MaterialType.material2); + + width = timePickerInputSize.width + padding.horizontal; + Size size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); + expect(size.width, width); + expect(size.height, lessThan(height)); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: entryMode, + materialType: MaterialType.material2, + ); + width = timePickerInputSize.width - dayPeriodPortraitSize.width - 12 + padding.horizontal + 16; + size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); + expect(size.width, width); + expect(size.height, lessThan(height)); + }); + + testWidgets('Material2 - respects MediaQueryData.alwaysUse24HourFormat == true', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: MaterialType.material2, + ); + + final labels00To22 = List<String>.generate(12, (int index) { + return (index * 2).toString().padLeft(2, '0'); + }); + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + // ignore: avoid_dynamic_calls + expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22); + + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String), + labels00To22, + ); + }); + + testWidgets('Material3 - Dialog size - dial mode', (WidgetTester tester) async { + addTearDown(tester.view.reset); + + const timePickerPortraitSize = Size(310, 468); + const timePickerLandscapeSize = Size(524, 342); + const padding = EdgeInsets.all(24.0); + double width; + double height; + + // portrait + tester.view.physicalSize = const Size(800, 800.5); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate(tester, materialType: MaterialType.material3); + + width = timePickerPortraitSize.width + padding.horizontal; + height = timePickerPortraitSize.height + padding.vertical; + expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // landscape + tester.view.physicalSize = const Size(800.5, 800); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: MaterialType.material3, + ); + + width = timePickerLandscapeSize.width + padding.horizontal; + height = timePickerLandscapeSize.height + padding.vertical; + expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); + }); + + testWidgets('Material3 - Dialog size - input mode', (WidgetTester tester) async { + final theme = ThemeData(); + const TimePickerEntryMode entryMode = TimePickerEntryMode.input; + const textScaleFactor = 1.0; + const timePickerMinInputSize = Size(312, 252); + const dayPeriodPortraitSize = Size(52, 80); + const padding = EdgeInsets.all(24.0); + final double height = timePickerMinInputSize.height * textScaleFactor + padding.vertical; + double width; + + await mediaQueryBoilerplate(tester, entryMode: entryMode, materialType: MaterialType.material3); + + width = timePickerMinInputSize.width - (theme.useMaterial3 ? 32 : 0) + padding.horizontal; + Size size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); + expect(size.width, width); + expect(size.height, lessThan(height)); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: entryMode, + materialType: MaterialType.material3, + ); + + width = timePickerMinInputSize.width - dayPeriodPortraitSize.width - 12 + padding.horizontal; + size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); + expect(size.width, width); + expect(size.height, lessThan(height)); + }); + + testWidgets('Material3 - respects MediaQueryData.alwaysUse24HourFormat == true', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: MaterialType.material3, + ); + + final labels00To23 = List<String>.generate(24, (int index) { + return index == 0 ? '00' : index.toString(); + }); + final inner0To23 = List<bool>.generate(24, (int index) => index >= 12); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + // ignore: avoid_dynamic_calls + expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To23); + // ignore: avoid_dynamic_calls + expect(primaryLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23); + + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String), + labels00To23, + ); + // ignore: avoid_dynamic_calls + expect(selectedLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23); + }); + + // Regression test for https://github.com/flutter/flutter/issues/164860 + testWidgets('Material3 - formats 24-hour numbers correctly in Farsi', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate( + tester, + locale: const Locale('fa', 'IR'), + materialType: MaterialType.material3, + ); + + final labels00To23 = <String>[ + '۰', + '۱', + '۲', + '۳', + '۴', + '۵', + '۶', + '۷', + '۸', + '۹', + '۱۰', + '۱۱', + '۱۲', + '۱۳', + '۱۴', + '۱۵', + '۱۶', + '۱۷', + '۱۸', + '۱۹', + '۲۰', + '۲۱', + '۲۲', + '۲۳', + ]; + final inner0To23 = List<bool>.generate(24, (int index) => index >= 12); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + // ignore: avoid_dynamic_calls + expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To23); + // ignore: avoid_dynamic_calls + expect(primaryLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23); + + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String), + labels00To23, + ); + // ignore: avoid_dynamic_calls + expect(selectedLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23); + }); + + testWidgets('Material3 - Dial background uses correct default color', ( + WidgetTester tester, + ) async { + var theme = ThemeData(); + Widget buildTimePicker(ThemeData themeData) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildTimePicker(theme)); + + // Open the time picker dialog. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Test default dial background color. + RenderBox dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: theme.colorScheme.surfaceContainerHighest) // Dial background color. + ..circle(color: Color(theme.colorScheme.primary.value)), // Dial hand color. + ); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // Test dial background color when theme color scheme is changed. + theme = theme.copyWith( + colorScheme: theme.colorScheme.copyWith(surfaceVariant: const Color(0xffff0000)), + ); + await tester.pumpWidget(buildTimePicker(theme)); + + // Open the time picker dialog. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: theme.colorScheme.surfaceContainerHighest) // Dial background color. + ..circle(color: Color(theme.colorScheme.primary.value)), // Dial hand color. + ); + }); + + for (final MaterialType materialType in MaterialType.values) { + group('Dial (${materialType.name})', () { + testWidgets('tap-select an hour', (WidgetTester tester) async { + TimeOfDay? result; + + Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); // 12:00 AM + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 0, minute: 0))); + + center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx + 50, center.dy)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 3, minute: 0))); + + center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 0))); + + center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await tester.tapAt(Offset(center.dx - 50, center.dy)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 0))); + }); + + testWidgets('drag-select an hour', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final hour0 = Offset(center.dx, center.dy - 50); // 12:00 AM + final hour3 = Offset(center.dx + 50, center.dy); + final hour6 = Offset(center.dx, center.dy + 50); + final hour9 = Offset(center.dx - 50, center.dy); + + TestGesture gesture; + + gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, 0); + + expect( + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType), + equals(center), + ); + gesture = await tester.startGesture(hour0); + await gesture.moveBy(hour3 - hour0); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, 3); + + expect( + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType), + equals(center), + ); + gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour6 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, equals(6)); + + expect( + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType), + equals(center), + ); + gesture = await tester.startGesture(hour6); + await gesture.moveBy(hour9 - hour6); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, equals(9)); + }); + + testWidgets('tap-select switches from hour to minute', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final hour6 = Offset(center.dx, center.dy + 50); // 6:00 + final min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours) + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(min45); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('drag-select switches from hour to minute', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final hour3 = Offset(center.dx + 50, center.dy); + final hour6 = Offset(center.dx, center.dy + 50); + final hour9 = Offset(center.dx - 50, center.dy); + + TestGesture gesture = await tester.startGesture(hour6); + await gesture.moveBy(hour9 - hour6); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + gesture = await tester.startGesture(hour6); + await gesture.moveBy(hour3 - hour6); + await gesture.up(); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 15))); + }); + + testWidgets('tap-select rounds down to nearest 5 minute increment', ( + WidgetTester tester, + ) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final hour6 = Offset(center.dx, center.dy + 50); // 6:00 + final min46 = Offset(center.dx - 50, center.dy - 5); // 46 mins + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(min46); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('tap-select rounds up to nearest 5 minute increment', ( + WidgetTester tester, + ) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final hour6 = Offset(center.dx, center.dy + 50); // 6:00 + final min48 = Offset(center.dx - 50, center.dy - 15); // 48 mins + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(min48); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 50))); + }); + }); + + group('Dial Haptic Feedback (${materialType.name})', () { + const kFastFeedbackInterval = Duration(milliseconds: 10); + const kSlowFeedbackInterval = Duration(milliseconds: 200); + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('tap-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) {}, + materialType: materialType, + ))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async { + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) {}, + materialType: materialType, + ))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await tester.pump(kFastFeedbackInterval); + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async { + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) {}, + materialType: materialType, + ))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await tester.pump(kSlowFeedbackInterval); + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await tester.pump(kSlowFeedbackInterval); + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await finishPicker(tester); + expect(feedback.hapticCount, 3); + }); + + testWidgets('drag-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) {}, + materialType: materialType, + ))!; + final hour0 = Offset(center.dx, center.dy - 50); + final hour3 = Offset(center.dx + 50, center.dy); + + final TestGesture gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('quick drag-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) {}, + materialType: materialType, + ))!; + final hour0 = Offset(center.dx, center.dy - 50); + final hour3 = Offset(center.dx + 50, center.dy); + + final TestGesture gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour0 - hour3); + await tester.pump(kFastFeedbackInterval); + await gesture.moveBy(hour3 - hour0); + await tester.pump(kFastFeedbackInterval); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('slow drag-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) {}, + materialType: materialType, + ))!; + final hour0 = Offset(center.dx, center.dy - 50); + final hour3 = Offset(center.dx + 50, center.dy); + + final TestGesture gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour0 - hour3); + await tester.pump(kSlowFeedbackInterval); + await gesture.moveBy(hour3 - hour0); + await tester.pump(kSlowFeedbackInterval); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(feedback.hapticCount, 3); + }); + }); + + group('Dialog (${materialType.name})', () { + testWidgets('Material2 - Widgets have correct label capitalization', ( + WidgetTester tester, + ) async { + await startPicker(tester, (TimeOfDay? time) {}, materialType: MaterialType.material2); + expect(find.text('SELECT TIME'), findsOneWidget); + expect(find.text('CANCEL'), findsOneWidget); + }); + + testWidgets('Material3 - Widgets have correct label capitalization', ( + WidgetTester tester, + ) async { + await startPicker(tester, (TimeOfDay? time) {}, materialType: MaterialType.material3); + expect(find.text('Select time'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Material2 - Widgets have correct label capitalization in input mode', ( + WidgetTester tester, + ) async { + await startPicker( + tester, + (TimeOfDay? time) {}, + entryMode: TimePickerEntryMode.input, + materialType: MaterialType.material2, + ); + expect(find.text('ENTER TIME'), findsOneWidget); + expect(find.text('CANCEL'), findsOneWidget); + }); + + testWidgets('Material3 - Widgets have correct label capitalization in input mode', ( + WidgetTester tester, + ) async { + await startPicker( + tester, + (TimeOfDay? time) {}, + entryMode: TimePickerEntryMode.input, + materialType: MaterialType.material3, + ); + expect(find.text('Enter time'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets( + 'Material3 - large actions label should not overflow in input mode', + (WidgetTester tester) async { + await startPicker( + tester, + (TimeOfDay? time) {}, + entryMode: TimePickerEntryMode.input, + materialType: MaterialType.material3, + cancelText: 'Very very very long cancel text', + confirmText: 'Very very very long confirm text', + ); + + // Verify that no overflow errors occur. + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.mobile(), + ); + + testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + const labels12To11 = <String>[ + '12', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + ]; + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), + labels12To11, + ); + + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String), + labels12To11, + ); + }); + + testWidgets('when change orientation, should reflect in render objects', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + + // portrait + tester.view.physicalSize = const Size(800, 800.5); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate(tester, materialType: materialType); + + RenderObject render = tester.renderObject( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'), + ); + expect((render as dynamic).orientation, Orientation.portrait); + + // landscape + tester.view.physicalSize = const Size(800.5, 800); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate(tester, tapButton: false, materialType: materialType); + + render = tester.renderObject( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'), + ); + expect((render as dynamic).orientation, Orientation.landscape); + }); + + testWidgets('setting orientation should override MediaQuery orientation', ( + WidgetTester tester, + ) async { + addTearDown(tester.view.reset); + + // portrait media query + tester.view.physicalSize = const Size(800, 800.5); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate( + tester, + orientation: Orientation.landscape, + materialType: materialType, + ); + + final RenderObject render = tester.renderObject( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'), + ); + expect((render as dynamic).orientation, Orientation.landscape); + }); + + testWidgets('builder parameter', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: textDirection, child: child!); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + final double ltrOkRight = tester.getBottomRight(find.text(okString)).dx; + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Verify that the time picker is being laid out RTL. + // We expect the left edge of the 'OK' button in the RTL + // layout to match the gap between right edge of the 'OK' + // button and the right edge of the 800 wide view. + expect(tester.getBottomLeft(find.text(okString)).dx, 800 - ltrOkRight); + }); + + group('Barrier dismissible', () { + late PickerObserver rootObserver; + + setUp(() { + rootObserver = PickerObserver(); + }); + + testWidgets('Barrier is dismissible with default parameter', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(rootObserver.pickerCount, 1); + + // Tap on the barrier. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + expect(rootObserver.pickerCount, 0); + }); + + testWidgets('Barrier is not dismissible with barrierDismissible is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showTimePicker( + context: context, + barrierDismissible: false, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(rootObserver.pickerCount, 1); + + // Tap on the barrier, which shouldn't do anything this time. + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); + expect(rootObserver.pickerCount, 1); + }); + }); + + testWidgets('Barrier color', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.black54); + + // Dismiss the dialog. + await tester.tapAt(const Offset(10.0, 10.0)); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showTimePicker( + context: context, + barrierColor: Colors.pink, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).color, Colors.pink); + }); + + testWidgets('Barrier Label', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () => showTimePicker( + context: context, + barrierLabel: 'Custom Label', + initialTime: const TimeOfDay(hour: 7, minute: 0), + ), + ); + }, + ), + ), + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect( + tester.widget<ModalBarrier>(find.byType(ModalBarrier).last).semanticsLabel, + 'Custom Label', + ); + }); + + testWidgets('uses root navigator by default', (WidgetTester tester) async { + final rootObserver = PickerObserver(); + final nestedObserver = PickerObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + }, + child: const Text('Show Picker'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.pickerCount, 1); + expect(nestedObserver.pickerCount, 0); + }); + + testWidgets('uses nested navigator if useRootNavigator is false', ( + WidgetTester tester, + ) async { + final rootObserver = PickerObserver(); + final nestedObserver = PickerObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[rootObserver], + home: Navigator( + observers: <NavigatorObserver>[nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<dynamic>( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showTimePicker( + context: context, + useRootNavigator: false, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + }, + child: const Text('Show Picker'), + ); + }, + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.pickerCount, 0); + expect(nestedObserver.pickerCount, 1); + }); + + testWidgets('optional text parameters are utilized', (WidgetTester tester) async { + const cancelText = 'Custom Cancel'; + const confirmText = 'Custom OK'; + const helperText = 'Custom Help'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () async { + await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + cancelText: cancelText, + confirmText: confirmText, + helpText: helperText, + ); + }, + ); + }, + ), + ), + ), + ), + ); + + // Open the picker. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text(cancelText), findsOneWidget); + expect(find.text(confirmText), findsOneWidget); + expect(find.text(helperText), findsOneWidget); + }); + + testWidgets('Material2 - OK Cancel button and helpText layout', (WidgetTester tester) async { + const selectTimeString = 'SELECT TIME'; + const cancelString = 'CANCEL'; + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: textDirection, child: child!); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(154, 155))); + expect( + tester.getBottomRight(find.text(selectTimeString)), + equals(const Offset(280.5, 165)), + ); + expect(tester.getBottomRight(find.text(okString)).dx, 644); + expect(tester.getBottomLeft(find.text(okString)).dx, 616); + expect(tester.getBottomRight(find.text(cancelString)).dx, 582); + + await tester.tap(find.text(okString)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(519.5, 155))); + expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(646, 165))); + expect(tester.getBottomLeft(find.text(okString)).dx, 156); + expect(tester.getBottomRight(find.text(okString)).dx, 184); + expect(tester.getBottomLeft(find.text(cancelString)).dx, 218); + + await tester.tap(find.text(okString)); + await tester.pumpAndSettle(); + }); + + testWidgets('Material3 - OK Cancel button and helpText layout', (WidgetTester tester) async { + const selectTimeString = 'Select time'; + const cancelString = 'Cancel'; + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + builder: (BuildContext context, Widget? child) { + return Directionality(textDirection: textDirection, child: child!); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(138, 129))); + expect(tester.getBottomRight(find.text(selectTimeString)), const Offset(294.75, 149.0)); + expect( + tester.getBottomLeft(find.text(okString)).dx, + moreOrLessEquals(615.9, epsilon: 0.001), + ); + expect(tester.getBottomRight(find.text(cancelString)).dx, 578); + + await tester.tap(find.text(okString)); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(505.25, 129.0))); + expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(662, 149))); + expect( + tester.getBottomLeft(find.text(okString)).dx, + moreOrLessEquals(155.9, epsilon: 0.001), + ); + expect( + tester.getBottomRight(find.text(okString)).dx, + moreOrLessEquals(184.1, epsilon: 0.001), + ); + expect(tester.getBottomLeft(find.text(cancelString)).dx, 222); + + await tester.tap(find.text(okString)); + await tester.pumpAndSettle(); + }); + + testWidgets('text scale affects certain elements and not others', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate( + tester, + initialTime: const TimeOfDay(hour: 7, minute: 41), + materialType: materialType, + ); + + final double minutesDisplayHeight = tester.getSize(find.text('41')).height; + final double amHeight = tester.getSize(find.text(amString)).height; + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // Verify that the time display is not affected by text scale. + await mediaQueryBoilerplate( + tester, + textScaler: const TextScaler.linear(2), + initialTime: const TimeOfDay(hour: 7, minute: 41), + materialType: materialType, + ); + + final double amHeight2x = tester.getSize(find.text(amString)).height; + expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); + expect(amHeight2x, math.min(38.0, amHeight * 2)); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // Verify that text scale for AM/PM is at most 2x. + await mediaQueryBoilerplate( + tester, + textScaler: const TextScaler.linear(3), + initialTime: const TimeOfDay(hour: 7, minute: 41), + materialType: materialType, + ); + + expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); + expect(tester.getSize(find.text(amString)).height, math.min(38.0, amHeight * 2)); + }); + + group('showTimePicker avoids overlapping display features', () { + testWidgets('positioning with anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + anchorPoint: const Offset(1000, 0), + ); + + await tester.pumpAndSettle(); + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410, 0)); + expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800, 600)); + }); + + testWidgets('positioning with Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality(textDirection: TextDirection.rtl, child: child!), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + // By default it should place the dialog on the right screen + showTimePicker(context: context, initialTime: const TimeOfDay(hour: 7, minute: 0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410, 0)); + expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800, 600)); + }); + + testWidgets('positioning with defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: <DisplayFeature>[ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + // By default it should place the dialog on the left screen + showTimePicker(context: context, initialTime: const TimeOfDay(hour: 7, minute: 0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byType(TimePickerDialog)), Offset.zero); + expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(390, 600)); + }); + }); + + group('Works for various view sizes', () { + for (final size in const <Size>[Size(100, 100), Size(300, 300), Size(800, 600)]) { + testWidgets('Draws dial without overflows at $size', (WidgetTester tester) async { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + await mediaQueryBoilerplate( + tester, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNot(throwsAssertionError)); + }); + + testWidgets('Draws input without overflows at $size', (WidgetTester tester) async { + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + await mediaQueryBoilerplate(tester, materialType: materialType); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNot(throwsAssertionError)); + }); + } + }); + }); + + group('Time picker - A11y and Semantics (${materialType.name})', () { + testWidgets('provides semantics information for AM/PM indicator', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await mediaQueryBoilerplate(tester, materialType: materialType); + + expect( + semantics, + includesNodeWith( + label: amString, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isChecked, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + ), + ); + expect( + semantics, + includesNodeWith( + label: pmString, + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets('Material2 - provides semantics information for header and footer', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: MaterialType.material2, + ); + + expect(semantics, isNot(includesNodeWith(label: ':'))); + expect( + semantics.nodesWith(value: 'Select minutes 00'), + hasLength(1), + reason: '00 appears once in the header', + ); + expect( + semantics.nodesWith(value: 'Select hours 07'), + hasLength(1), + reason: '07 appears once in the header', + ); + expect(semantics, includesNodeWith(label: 'CANCEL')); + expect(semantics, includesNodeWith(label: okString)); + + // In 24-hour mode we don't have AM/PM control. + expect(semantics, isNot(includesNodeWith(label: amString))); + expect(semantics, isNot(includesNodeWith(label: pmString))); + + semantics.dispose(); + }); + + testWidgets('Material3 - provides semantics information for header and footer', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: MaterialType.material3, + ); + + expect(semantics, isNot(includesNodeWith(label: ':'))); + expect( + semantics.nodesWith(value: 'Select minutes 00'), + hasLength(1), + reason: '00 appears once in the header', + ); + expect( + semantics.nodesWith(value: 'Select hours 07'), + hasLength(1), + reason: '07 appears once in the header', + ); + expect(semantics, includesNodeWith(label: 'Cancel')); + expect(semantics, includesNodeWith(label: okString)); + + // In 24-hour mode we don't have AM/PM control. + expect(semantics, isNot(includesNodeWith(label: amString))); + expect(semantics, isNot(includesNodeWith(label: pmString))); + + semantics.dispose(); + }); + + testWidgets( + 'TimePicker dialog displays centered separator between hour and minute selectors', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: const Material( + child: TimePickerDialog(initialTime: TimeOfDay(hour: 12, minute: 0)), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Dialog), + matchesGoldenFile('m2_time_picker.dialog.separator.alignment.png'), + ); + }, + ); + + testWidgets( + 'TimePicker dialog displays centered separator between hour and minute selectors', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + const MaterialApp( + home: Material(child: TimePickerDialog(initialTime: TimeOfDay(hour: 12, minute: 0))), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Dialog), + matchesGoldenFile('m3_time_picker.dialog.separator.alignment.png'), + ); + }, + ); + + testWidgets( + 'TimePicker dialog displays centered separator between hour and minute inputs for non-english locale', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + const MaterialApp( + localizationsDelegates: <LocalizationsDelegate<dynamic>>[ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: <Locale>[Locale('en'), Locale('es')], + locale: Locale('es'), + home: Material( + child: TimePickerDialog( + initialTime: TimeOfDay(hour: 12, minute: 0), + initialEntryMode: TimePickerEntryMode.input, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(Dialog), + matchesGoldenFile('time_picker.dialog.separator.alignment.non_english_locale.png'), + ); + }, + ); + + testWidgets('provides semantics information for text fields', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + accessibleNavigation: true, + materialType: materialType, + ); + + expect( + semantics, + includesNodeWith( + label: 'Hour', + value: '07', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isMultiline, + ], + ), + ); + expect( + semantics, + includesNodeWith( + label: 'Minute', + value: '00', + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + flags: <SemanticsFlag>[ + SemanticsFlag.isTextField, + SemanticsFlag.isFocusable, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isMultiline, + ], + ), + ); + + semantics.dispose(); + }); + + testWidgets('can increment and decrement hours', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + Future<void> actAndExpect({ + required String initialValue, + required SemanticsAction action, + required String finalValue, + }) async { + final SemanticsNode elevenHours = semantics + .nodesWith( + value: 'Select hours $initialValue', + ancestor: tester.renderObject(_dialHourControl).debugSemantics, + ) + .single; + tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action); + await tester.pumpAndSettle(); + expect( + find.descendant(of: _dialHourControl, matching: find.text(finalValue)), + findsOneWidget, + ); + } + + // 12-hour format + await mediaQueryBoilerplate( + tester, + initialTime: const TimeOfDay(hour: 11, minute: 0), + materialType: materialType, + ); + await actAndExpect(initialValue: '11', action: SemanticsAction.increase, finalValue: '12'); + await actAndExpect(initialValue: '12', action: SemanticsAction.increase, finalValue: '1'); + + // Ensure we preserve day period as we roll over. + final dynamic pickerState = tester.state(_timePicker); + // ignore: avoid_dynamic_calls + expect(pickerState.selectedTime.value, const TimeOfDay(hour: 1, minute: 0)); + + await actAndExpect(initialValue: '1', action: SemanticsAction.decrease, finalValue: '12'); + await tester.pumpWidget(Container()); // clear old boilerplate + + // 24-hour format + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + initialTime: const TimeOfDay(hour: 23, minute: 0), + materialType: materialType, + ); + await actAndExpect(initialValue: '23', action: SemanticsAction.increase, finalValue: '00'); + await actAndExpect(initialValue: '00', action: SemanticsAction.increase, finalValue: '01'); + await actAndExpect(initialValue: '01', action: SemanticsAction.decrease, finalValue: '00'); + await actAndExpect(initialValue: '00', action: SemanticsAction.decrease, finalValue: '23'); + + semantics.dispose(); + }); + + testWidgets('can increment and decrement minutes', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + Future<void> actAndExpect({ + required String initialValue, + required SemanticsAction action, + required String finalValue, + }) async { + final SemanticsNode elevenHours = semantics + .nodesWith( + value: 'Select minutes $initialValue', + ancestor: tester.renderObject(_dialMinuteControl).debugSemantics, + ) + .single; + tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action); + await tester.pumpAndSettle(); + expect( + find.descendant(of: _dialMinuteControl, matching: find.text(finalValue)), + findsOneWidget, + ); + } + + await mediaQueryBoilerplate( + tester, + initialTime: const TimeOfDay(hour: 11, minute: 58), + materialType: materialType, + ); + await actAndExpect(initialValue: '58', action: SemanticsAction.increase, finalValue: '59'); + await actAndExpect(initialValue: '59', action: SemanticsAction.increase, finalValue: '00'); + + // Ensure we preserve hour period as we roll over. + final dynamic pickerState = tester.state(_timePicker); + // ignore: avoid_dynamic_calls + expect(pickerState.selectedTime.value, const TimeOfDay(hour: 11, minute: 0)); + + await actAndExpect(initialValue: '00', action: SemanticsAction.decrease, finalValue: '59'); + await actAndExpect(initialValue: '59', action: SemanticsAction.decrease, finalValue: '58'); + + semantics.dispose(); + }); + + testWidgets('header touch regions are large enough', (WidgetTester tester) async { + // Ensure picker is displayed in portrait mode. + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await mediaQueryBoilerplate(tester, materialType: materialType); + + final Size dayPeriodControlSize = tester.getSize( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), + ); + expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48)); + expect(dayPeriodControlSize.height, greaterThanOrEqualTo(80)); + + final Size hourSize = tester.getSize( + find.ancestor(of: find.text('7'), matching: find.byType(InkWell)), + ); + expect(hourSize.width, greaterThanOrEqualTo(48)); + expect(hourSize.height, greaterThanOrEqualTo(48)); + + final Size minuteSize = tester.getSize( + find.ancestor(of: find.text('00'), matching: find.byType(InkWell)), + ); + expect(minuteSize.width, greaterThanOrEqualTo(48)); + expect(minuteSize.height, greaterThanOrEqualTo(48)); + }); + + testWidgets( + 'Period selector touch target respects accessibility guidelines - Portrait mode', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const minInteractiveSize = Size(kMinInteractiveDimension, kMinInteractiveDimension); + + // Ensure picker is displayed in portrait mode. + tester.view.physicalSize = const Size(600, 1000); + addTearDown(tester.view.reset); + + await mediaQueryBoilerplate(tester, materialType: materialType); + + final SemanticsNode amButton = semantics.nodesWith(label: amString).single; + expect(amButton.rect.size >= minInteractiveSize, isTrue); + + final SemanticsNode pmButton = semantics.nodesWith(label: pmString).single; + expect(pmButton.rect.size >= minInteractiveSize, isTrue); + + semantics.dispose(); + }, + ); + + testWidgets( + 'Period selector touch target respects accessibility guidelines - Landscape mode', + (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + const minInteractiveSize = Size(kMinInteractiveDimension, kMinInteractiveDimension); + + await mediaQueryBoilerplate(tester, materialType: materialType); + + final SemanticsNode amButton = semantics.nodesWith(label: amString).single; + expect(amButton.rect.size >= minInteractiveSize, isTrue); + + final SemanticsNode pmButton = semantics.nodesWith(label: pmString).single; + expect(pmButton.rect.size >= minInteractiveSize, isTrue); + + semantics.dispose(); + }, + ); + }); + + group('Time picker - Input (${materialType.name})', () { + testWidgets('Initial entry mode is used', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + expect(find.byType(TextField), findsNWidgets(2)); + }); + + testWidgets('Initial time is the default', (WidgetTester tester) async { + late TimeOfDay result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time!; + }, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 7, minute: 0))); + }); + + testWidgets('Help text is used - Input', (WidgetTester tester) async { + const helpText = 'help'; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + helpText: helpText, + materialType: materialType, + ); + expect(find.text(helpText), findsOneWidget); + }); + + testWidgets('Help text is used in Material3 - Input', (WidgetTester tester) async { + const helpText = 'help'; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + helpText: helpText, + materialType: materialType, + ); + expect(find.text(helpText), findsOneWidget); + }); + + testWidgets('Hour label text is used - Input', (WidgetTester tester) async { + const hourLabelText = 'Custom hour label'; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + hourLabelText: hourLabelText, + materialType: materialType, + ); + expect(find.text(hourLabelText), findsOneWidget); + }); + + testWidgets('Minute label text is used - Input', (WidgetTester tester) async { + const minuteLabelText = 'Custom minute label'; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + minuteLabelText: minuteLabelText, + materialType: materialType, + ); + expect(find.text(minuteLabelText), findsOneWidget); + }); + + testWidgets('Invalid error text is used - Input', (WidgetTester tester) async { + const errorInvalidText = 'Custom validation error'; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + errorInvalidText: errorInvalidText, + materialType: materialType, + ); + // Input invalid time (hour) to force validation error + await tester.enterText(find.byType(TextField).first, '88'); + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( + tester.element(find.byType(TextButton).first), + ); + // Tap the ok button to trigger the validation error with custom translation + await tester.tap(find.text(materialLocalizations.okButtonLabel)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text(errorInvalidText), findsOneWidget); + }); + + testWidgets('TimePicker default entry icons', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: TimePickerDialog(initialTime: TimeOfDay.now()))); + + // Check that the default icon for the dial mode is displayed. + expect(find.byIcon(Icons.keyboard_outlined), findsOneWidget); + expect(find.byIcon(Icons.access_time), findsNothing); + // Tap the icon to switch to input mode. + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pumpAndSettle(); + // Check that the icon for the input mode is displayed. + expect(find.byIcon(Icons.access_time), findsOneWidget); + expect(find.byIcon(Icons.keyboard_outlined), findsNothing); + }); + + testWidgets('Can override TimePicker entry icons', (WidgetTester tester) async { + const customInputIcon = Icon(Icons.text_fields); + const customTimerIcon = Icon(Icons.watch); + + await tester.pumpWidget( + MaterialApp( + home: TimePickerDialog( + initialTime: TimeOfDay.now(), + switchToInputEntryModeIcon: customInputIcon, + switchToTimerEntryModeIcon: customTimerIcon, + ), + ), + ); + + // Check that the custom icons are displayed. + expect(find.byIcon(Icons.text_fields), findsOneWidget); + expect(find.byIcon(Icons.watch), findsNothing); + // Tap the custom icon to switch to input mode. + await tester.tap(find.byIcon(Icons.text_fields)); + await tester.pumpAndSettle(); + // Check that the custom icon for the input mode is displayed. + expect(find.byIcon(Icons.text_fields), findsNothing); + expect(find.byIcon(Icons.watch), findsOneWidget); + }); + + testWidgets('Can switch from input to dial entry mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + await tester.tap(find.byIcon(Icons.access_time)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Can switch from dial to input entry mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + materialType: materialType, + ); + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsWidgets); + }); + + testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.inputOnly, + materialType: materialType, + ); + expect(find.byType(TextField), findsWidgets); + expect(find.byIcon(Icons.access_time), findsNothing); + }); + + testWidgets('Can not switch out of dialOnly mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.dialOnly, + materialType: materialType, + ); + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.keyboard_outlined), findsNothing); + }); + + testWidgets('Switching to dial entry mode triggers entry callback', ( + WidgetTester tester, + ) async { + var triggeredCallback = false; + + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + onEntryModeChange: (TimePickerEntryMode mode) { + if (mode == TimePickerEntryMode.dial) { + triggeredCallback = true; + } + }, + materialType: materialType, + ); + + await tester.tap(find.byIcon(Icons.access_time)); + await tester.pumpAndSettle(); + expect(triggeredCallback, true); + }); + + testWidgets('Switching to input entry mode triggers entry callback', ( + WidgetTester tester, + ) async { + var triggeredCallback = false; + + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + onEntryModeChange: (TimePickerEntryMode mode) { + if (mode == TimePickerEntryMode.input) { + triggeredCallback = true; + } + }, + materialType: materialType, + ); + + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pumpAndSettle(); + expect(triggeredCallback, true); + }); + + testWidgets('Can double tap hours (when selected) to enter input mode', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder hourFinder = find.ancestor(of: find.text('7'), matching: find.byType(InkWell)); + + expect(find.byType(TextField), findsNothing); + + // Double tap the hour. + await tester.tap(hourFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(hourFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsWidgets); + }); + + testWidgets('Can not double tap hours (when not selected) to enter input mode', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder hourFinder = find.ancestor(of: find.text('7'), matching: find.byType(InkWell)); + final Finder minuteFinder = find.ancestor( + of: find.text('00'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Switch to minutes mode. + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + // Double tap the hour. + await tester.tap(hourFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(hourFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Can double tap minutes (when selected) to enter input mode', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder minuteFinder = find.ancestor( + of: find.text('00'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Switch to minutes mode. + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + // Double tap the minutes. + await tester.tap(minuteFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsWidgets); + }); + + testWidgets('Can not double tap minutes (when not selected) to enter input mode', ( + WidgetTester tester, + ) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder minuteFinder = find.ancestor( + of: find.text('00'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Double tap the minutes. + await tester.tap(minuteFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Entered text returns time', (WidgetTester tester) async { + late TimeOfDay result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time!; + }, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + await tester.enterText(find.byType(TextField).first, '9'); + await tester.enterText(find.byType(TextField).last, '12'); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + }); + + testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async { + late TimeOfDay result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time!; + }, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '15'); + await tester.tap(find.byIcon(Icons.access_time)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); + }); + + testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async { + TimeOfDay? result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time; + }, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + + // Invalid hour. + await tester.enterText(find.byType(TextField).first, '88'); + await tester.enterText(find.byType(TextField).last, '15'); + await finishPicker(tester); + expect(result, null); + + // Invalid minute. + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '95'); + await finishPicker(tester); + expect(result, null); + + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '15'); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); + }); + + // Fixes regression that was reverted in https://github.com/flutter/flutter/pull/64094#pullrequestreview-469836378. + testWidgets('Ensure hour/minute fields are top-aligned with the separator', ( + WidgetTester tester, + ) async { + await startPicker( + tester, + (TimeOfDay? time) {}, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + final double hourFieldTop = tester + .getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField'), + ) + .dy; + final double minuteFieldTop = tester + .getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField'), + ) + .dy; + final double separatorTop = tester + .getTopLeft( + find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TimeSelectorSeparator'), + ) + .dy; + expect(hourFieldTop, separatorTop); + expect(minuteFieldTop, separatorTop); + }); + + testWidgets('Can switch between hour/minute fields using keyboard input action', ( + WidgetTester tester, + ) async { + await startPicker( + tester, + (TimeOfDay? time) {}, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + + final Finder hourFinder = find.byType(TextField).first; + final TextField hourField = tester.widget(hourFinder); + await tester.tap(hourFinder); + expect(hourField.focusNode!.hasFocus, isTrue); + + await tester.enterText(find.byType(TextField).first, '08'); + final Finder minuteFinder = find.byType(TextField).last; + final TextField minuteField = tester.widget(minuteFinder); + expect(hourField.focusNode!.hasFocus, isFalse); + expect(minuteField.focusNode!.hasFocus, isTrue); + + expect(tester.testTextInput.setClientArgs!['inputAction'], equals('TextInputAction.done')); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(hourField.focusNode!.hasFocus, isFalse); + expect(minuteField.focusNode!.hasFocus, isFalse); + }); + + testWidgets( + 'TAB key selects text in hour and minute fields on the web', + (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + + // Focus on the hour field. + final Finder hourField = find.byType(TextField).first; + await tester.tap(hourField); + await tester.pumpAndSettle(); + + // Verify that the hour field is focused and its text is selected. + final TextField hourTextField = tester.widget(hourField); + expect(hourTextField.focusNode!.hasFocus, isTrue); + expect(hourTextField.controller!.selection.baseOffset, 0); + expect( + hourTextField.controller!.selection.extentOffset, + hourTextField.controller!.text.length, + ); + + // Press TAB to move to the minute field. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Verify that the minute field is focused and its text is selected. + final Finder minuteField = find.byType(TextField).last; + final TextField minuteTextField = tester.widget(minuteField); + expect(minuteTextField.controller!.selection.baseOffset, 0); + expect( + minuteTextField.controller!.selection.extentOffset, + minuteTextField.controller!.text.length, + ); + }, + skip: !kIsWeb, // [intended] Web-specific behavior + ); + }); + + group('Time picker - Restoration (${materialType.name})', () { + testWidgets('Time Picker state restoration test - dial mode', (WidgetTester tester) async { + TimeOfDay? result; + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) { + result = time; + }, + restorationId: 'restorable_time_picker', + materialType: materialType, + ))!; + final hour6 = Offset(center.dx, center.dy + 50); // 6:00 + final min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours) + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await tester.tapAt(min45); + await tester.pump(const Duration(milliseconds: 50)); + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Setting to PM adds 12 hours (18:45) + await tester.tap(find.text(pmString)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 18, minute: 45))); + + // Test restoring from before PM was selected (6:45) + await tester.restoreFrom(restorationData); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('Time Picker state restoration test - input mode', (WidgetTester tester) async { + TimeOfDay? result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time; + }, + entryMode: TimePickerEntryMode.input, + restorationId: 'restorable_time_picker', + materialType: materialType, + ); + await tester.enterText(find.byType(TextField).first, '9'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + await tester.enterText(find.byType(TextField).last, '12'); + await tester.pump(const Duration(milliseconds: 50)); + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // Setting to PM adds 12 hours (21:12) + await tester.tap(find.text(pmString)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 21, minute: 12))); + + // Restoring from before PM was set (9:12) + await tester.restoreFrom(restorationData); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + }); + + testWidgets('Time Picker state restoration test - switching modes', ( + WidgetTester tester, + ) async { + TimeOfDay? result; + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) { + result = time; + }, + restorationId: 'restorable_time_picker', + materialType: materialType, + ))!; + + final TestRestorationData restorationData = await tester.getRestorationData(); + // Switch to input mode from dial mode. + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + // Select time using input mode controls. + await tester.enterText(find.byType(TextField).first, '9'); + await tester.enterText(find.byType(TextField).last, '12'); + await tester.pump(const Duration(milliseconds: 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + + // Restoring from dial mode. + await tester.restoreFrom(restorationData); + final hour6 = Offset(center.dx, center.dy + 50); // 6:00 + final min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours) + + await tester.tapAt(hour6); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await tester.tapAt(min45); + await tester.pump(const Duration(milliseconds: 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + }); + + group('Time picker - emptyInitialInput (${materialType.name})', () { + testWidgets('Fields are empty and show correct hints when emptyInitialInput is true', ( + WidgetTester tester, + ) async { + await startPicker( + tester, + (_) {}, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + emptyInitialInput: true, + ); + await tester.pump(); + + final List<TextField> textFields = tester + .widgetList<TextField>(find.byType(TextField)) + .toList(); + + expect(textFields[0].controller?.text, isEmpty); // hour + expect(textFields[1].controller?.text, isEmpty); // minute + expect(textFields[0].decoration?.hintText, isNull); + expect(textFields[1].decoration?.hintText, isNull); + await finishPicker(tester); + }); + + testWidgets('User sets hour/minute after initially empty fields', ( + WidgetTester tester, + ) async { + late TimeOfDay result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time!; + }, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + emptyInitialInput: true, + ); + + final List<TextField> textFields = tester + .widgetList<TextField>(find.byType(TextField)) + .toList(); + + expect(textFields[0].controller?.text, isEmpty); // hour + expect(textFields[1].controller?.text, isEmpty); // minute + expect(textFields[0].decoration?.hintText, isNull); + expect(textFields[1].decoration?.hintText, isNull); + + await tester.enterText(find.byType(TextField).first, '11'); + await tester.enterText(find.byType(TextField).last, '30'); + await finishPicker(tester); + + expect(result, equals(const TimeOfDay(hour: 11, minute: 30))); + }); + + testWidgets('User overrides default values when emptyInitialInput is false', ( + WidgetTester tester, + ) async { + late TimeOfDay result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time!; + }, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + + final List<TextField> textFields = tester + .widgetList<TextField>(find.byType(TextField)) + .toList(); + + expect(textFields[0].controller?.text, '7'); // hour + expect(textFields[1].controller?.text, '00'); // minute + + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '15'); + await tester.pump(); + await finishPicker(tester); + + expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); + }); + }); + } + + testWidgets('Material3 - Time selector separator default text style', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await startPicker(tester, (TimeOfDay? value) {}, theme: theme); + + final RenderParagraph paragraph = tester.renderObject(find.text(':')); + expect(paragraph.text.style!.color, theme.colorScheme.onSurface); + expect(paragraph.text.style!.fontSize, 57.0); + }); + + testWidgets('Material2 - Time selector separator default text style', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + await startPicker(tester, (TimeOfDay? value) {}, theme: theme); + + final RenderParagraph paragraph = tester.renderObject(find.text(':')); + expect(paragraph.text.style!.color, theme.colorScheme.onSurface); + expect(paragraph.text.style!.fontSize, 56.0); + }); + + testWidgets('provides semantics information for hour/minute mode announcement', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + const time = TimeOfDay(hour: 8, minute: 12); + + await mediaQueryBoilerplate(tester, initialTime: time, materialType: MaterialType.material3); + + final MaterialLocalizations localizations = MaterialLocalizations.of( + tester.element(find.byType(TimePickerDialog)), + ); + + final String formattedHour = localizations.formatHour(time); + final String formattedMinute = localizations.formatMinute(time); + + expect( + find.semantics.byValue('${localizations.timePickerHourModeAnnouncement} $formattedHour'), + findsOne, + ); + expect( + find.semantics.byValue('${localizations.timePickerMinuteModeAnnouncement} $formattedMinute'), + findsOne, + ); + + semantics.dispose(); + }); + + testWidgets('provides semantics information for the header (selected time)', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + const initialTime = TimeOfDay(hour: 7, minute: 15); + + await mediaQueryBoilerplate( + tester, + initialTime: initialTime, + materialType: MaterialType.material3, + ); + + final MaterialLocalizations localizations = MaterialLocalizations.of( + tester.element(find.byType(TimePickerDialog)), + ); + final String expectedLabel12Hour = localizations.formatTimeOfDay(initialTime); + final String expectedHelpText = localizations.timePickerDialHelpText; + + expect( + semantics, + includesNodeWith(label: '$expectedLabel12Hour\n$expectedHelpText'), + reason: 'Header should have semantics label: $expectedLabel12Hour (12-hour)', + ); + + semantics.dispose(); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153549. + testWidgets('Time picker hour minute does not resize on error', (WidgetTester tester) async { + await startPicker(entryMode: TimePickerEntryMode.input, tester, (TimeOfDay? value) {}); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); + + // Enter invalid hour. + await tester.enterText(find.byType(TextField).first, 'AB'); + await tester.tap(find.text(okString)); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153549. + testWidgets('Material2 - Time picker hour minute does not resize on error', ( + WidgetTester tester, + ) async { + await startPicker( + entryMode: TimePickerEntryMode.input, + tester, + (TimeOfDay? value) {}, + materialType: MaterialType.material2, + ); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); + + // Enter invalid hour. + await tester.enterText(find.byType(TextField).first, 'AB'); + await tester.tap(find.text(okString)); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/162229. + testWidgets( + 'Time picker spacing between time control and day period control for locales using "a h:mm" pattern', + (WidgetTester tester) async { + addTearDown(tester.view.reset); + + final Finder amMaterialFinder = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_AmPmButton').first, + matching: find.byType(Material), + ); + final Finder timeControlFinder = find + .ancestor(of: find.text('7'), matching: find.byType(Row)) + .first; + + // Render in portrait mode. + tester.view.physicalSize = const Size(800, 800.5); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate( + tester, + materialType: MaterialType.material3, + locale: const Locale('ko', 'KR'), + ); + + const dayPeriodPortraitGap = 12.0; // From Material spec. + expect( + tester.getBottomLeft(timeControlFinder).dx - tester.getBottomRight(amMaterialFinder).dx, + dayPeriodPortraitGap, + ); + + // Dismiss the dialog. + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( + tester.element(find.byType(TextButton).first), + ); + await tester.tap(find.text(materialLocalizations.okButtonLabel)); + await tester.pumpAndSettle(); + + // Render in landscape mode. + tester.view.physicalSize = const Size(800.5, 800); + tester.view.devicePixelRatio = 1; + await mediaQueryBoilerplate( + tester, + materialType: MaterialType.material3, + locale: const Locale('ko', 'KR'), + ); + + const dayPeriodLandscapeGap = 16.0; // From Material spec. + expect( + tester.getTopLeft(timeControlFinder).dy - tester.getBottomLeft(amMaterialFinder).dy, + dayPeriodLandscapeGap, + ); + }, + ); + + testWidgets( + 'AM/PM buttons have correct selected/checked semantics for platform variant', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/173302 + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 14, minute: 0), + ); + }, + child: const Text('Open Picker'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Picker')); + await tester.pumpAndSettle(); + + final Finder pmButtonSemantics = find.ancestor( + of: find.widgetWithText(InkWell, 'PM'), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && (widget.properties.button ?? false), + ), + ); + + final Finder amButtonSemantics = find.ancestor( + of: find.widgetWithText(InkWell, 'AM'), + matching: find.byWidgetPredicate( + (Widget widget) => widget is Semantics && (widget.properties.button ?? false), + ), + ); + + bool? getPlatformSemanticProperty(Semantics semantics) { + return switch (defaultTargetPlatform) { + TargetPlatform.iOS => semantics.properties.selected, + _ => semantics.properties.checked, + }; + } + + expect(getPlatformSemanticProperty(tester.widget<Semantics>(pmButtonSemantics)), isTrue); + expect(getPlatformSemanticProperty(tester.widget<Semantics>(amButtonSemantics)), isFalse); + }, + variant: TargetPlatformVariant.all(), + ); + + testWidgets('TimePickerDialog does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.shrink( + child: TimePickerDialog(initialTime: TimeOfDay(hour: 10, minute: 12)), + ), + ), + ), + ); + expect(tester.getSize(find.byType(TimePickerDialog)), Size.zero); + }); +} + +final Finder findDialPaint = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), +); + +class PickerObserver extends NavigatorObserver { + int pickerCount = 0; + + @override + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + pickerCount++; + } + super.didPush(route, previousRoute); + } + + @override + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) { + if (route is DialogRoute) { + pickerCount--; + } + super.didPop(route, previousRoute); + } +} + +Future<void> mediaQueryBoilerplate( + WidgetTester tester, { + bool alwaysUse24HourFormat = false, + TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0), + TextScaler textScaler = TextScaler.noScaling, + TimePickerEntryMode entryMode = TimePickerEntryMode.dial, + String? helpText, + String? hourLabelText, + String? minuteLabelText, + String? errorInvalidText, + bool accessibleNavigation = false, + EntryModeChangeCallback? onEntryModeChange, + bool tapButton = true, + required MaterialType materialType, + Orientation? orientation, + Locale locale = const Locale('en', 'US'), +}) async { + await tester.pumpWidget( + Theme( + data: ThemeData(useMaterial3: materialType == MaterialType.material3), + child: Localizations( + locale: locale, + delegates: const <LocalizationsDelegate<dynamic>>[ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + child: MediaQuery( + data: MediaQueryData( + alwaysUse24HourFormat: alwaysUse24HourFormat, + textScaler: textScaler, + accessibleNavigation: accessibleNavigation, + size: tester.view.physicalSize / tester.view.devicePixelRatio, + ), + child: Material( + child: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return TextButton( + onPressed: () { + showTimePicker( + context: context, + initialTime: initialTime, + initialEntryMode: entryMode, + helpText: helpText, + hourLabelText: hourLabelText, + minuteLabelText: minuteLabelText, + errorInvalidText: errorInvalidText, + onEntryModeChanged: onEntryModeChange, + orientation: orientation, + ); + }, + child: const Text('X'), + ); + }, + ); + }, + ), + ), + ), + ), + ), + ), + ), + ); + if (tapButton) { + await tester.tap(find.text('X')); + } + await tester.pumpAndSettle(); +} + +final Finder _dialHourControl = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_DialHourControl', +); +final Finder _dialMinuteControl = find.byWidgetPredicate( + (Widget widget) => '${widget.runtimeType}' == '_DialMinuteControl', +); +final Finder _timePicker = find.byWidgetPredicate( + (Widget widget) => '${widget.runtimeType}' == '_TimePicker', +); + +class _TimePickerLauncher extends StatefulWidget { + const _TimePickerLauncher({ + required this.onChanged, + this.entryMode = TimePickerEntryMode.dial, + this.restorationId, + this.cancelText, + this.confirmText, + required this.emptyInitialInput, + }); + + final ValueChanged<TimeOfDay?> onChanged; + final TimePickerEntryMode entryMode; + final String? restorationId; + final String? cancelText; + final String? confirmText; + final bool emptyInitialInput; + + @override + _TimePickerLauncherState createState() => _TimePickerLauncherState(); +} + +@pragma('vm:entry-point') +class _TimePickerLauncherState extends State<_TimePickerLauncher> with RestorationMixin { + @override + String? get restorationId => widget.restorationId; + + late final RestorableRouteFuture<TimeOfDay?> _restorableTimePickerRouteFuture = + RestorableRouteFuture<TimeOfDay?>( + onComplete: _selectTime, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush( + _timePickerRoute, + arguments: <String, String>{ + 'entry_mode': widget.entryMode.name, + if (widget.cancelText != null) 'cancel_text': widget.cancelText!, + if (widget.confirmText != null) 'confirm_text': widget.confirmText!, + }, + ); + }, + ); + + @override + void dispose() { + _restorableTimePickerRouteFuture.dispose(); + super.dispose(); + } + + @pragma('vm:entry-point') + static Route<TimeOfDay> _timePickerRoute(BuildContext context, Object? arguments) { + final args = arguments! as Map<dynamic, dynamic>; + final TimePickerEntryMode entryMode = TimePickerEntryMode.values.firstWhere( + (TimePickerEntryMode element) => element.name == args['entry_mode'], + ); + final cancelText = args['cancel_text'] as String?; + final confirmText = args['confirm_text'] as String?; + return DialogRoute<TimeOfDay>( + context: context, + builder: (BuildContext context) { + return TimePickerDialog( + restorationId: 'time_picker_dialog', + initialTime: const TimeOfDay(hour: 7, minute: 0), + initialEntryMode: entryMode, + cancelText: cancelText, + confirmText: confirmText, + ); + }, + ); + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_restorableTimePickerRouteFuture, 'time_picker_route_future'); + } + + void _selectTime(TimeOfDay? newSelectedTime) { + widget.onChanged(newSelectedTime); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () async { + if (widget.restorationId == null) { + widget.onChanged( + await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + initialEntryMode: widget.entryMode, + emptyInitialInput: widget.emptyInitialInput, + ), + ); + } else { + _restorableTimePickerRouteFuture.present(); + } + }, + ); + }, + ), + ), + ); + } +} + +// The version of material design layout, etc. to test. Corresponds to +// useMaterial3 true/false in the ThemeData, but used an enum here so that it +// wasn't just a boolean, for easier identification of the name of the mode in +// tests. +enum MaterialType { material2, material3 } + +Future<Offset?> startPicker( + WidgetTester tester, + ValueChanged<TimeOfDay?> onChanged, { + TimePickerEntryMode entryMode = TimePickerEntryMode.dial, + String? restorationId, + ThemeData? theme, + MaterialType? materialType, + String? cancelText, + String? confirmText, + bool emptyInitialInput = false, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: theme ?? ThemeData(useMaterial3: materialType == MaterialType.material3), + restorationScopeId: 'app', + locale: const Locale('en', 'US'), + home: _TimePickerLauncher( + onChanged: onChanged, + entryMode: entryMode, + restorationId: restorationId, + cancelText: cancelText, + confirmText: confirmText, + emptyInitialInput: emptyInitialInput, + ), + ), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + final Finder customPaintFinder = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), + matching: find.byType(CustomPaint), + ); + return entryMode == TimePickerEntryMode.dial ? tester.getCenter(customPaintFinder) : null; +} + +Future<void> finishPicker(WidgetTester tester) async { + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( + tester.element(find.byType(ElevatedButton)), + ); + await tester.tap(find.text(materialLocalizations.okButtonLabel)); + await tester.pumpAndSettle(const Duration(seconds: 1)); +} diff --git a/packages/material_ui/test/material/time_picker_theme_test.dart b/packages/material_ui/test/material/time_picker_theme_test.dart new file mode 100644 index 000000000000..10158a3276ad --- /dev/null +++ b/packages/material_ui/test/material/time_picker_theme_test.dart @@ -0,0 +1,1186 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('TimePickerThemeData copyWith, ==, hashCode basics', () { + expect(const TimePickerThemeData(), const TimePickerThemeData().copyWith()); + expect(const TimePickerThemeData().hashCode, const TimePickerThemeData().copyWith().hashCode); + }); + + test('TimePickerThemeData lerp special cases', () { + const data = TimePickerThemeData(); + expect(identical(TimePickerThemeData.lerp(data, data, 0.5), data), true); + }); + + test('TimePickerThemeData has null fields by default', () { + const timePickerTheme = TimePickerThemeData(); + expect(timePickerTheme.backgroundColor, null); + expect(timePickerTheme.cancelButtonStyle, null); + expect(timePickerTheme.confirmButtonStyle, null); + expect(timePickerTheme.dayPeriodBorderSide, null); + expect(timePickerTheme.dayPeriodColor, null); + expect(timePickerTheme.dayPeriodShape, null); + expect(timePickerTheme.dayPeriodTextColor, null); + expect(timePickerTheme.dayPeriodTextStyle, null); + expect(timePickerTheme.dialBackgroundColor, null); + expect(timePickerTheme.dialHandColor, null); + expect(timePickerTheme.dialTextColor, null); + expect(timePickerTheme.dialTextStyle, null); + expect(timePickerTheme.elevation, null); + expect(timePickerTheme.entryModeIconColor, null); + expect(timePickerTheme.helpTextStyle, null); + expect(timePickerTheme.hourMinuteColor, null); + expect(timePickerTheme.hourMinuteShape, null); + expect(timePickerTheme.hourMinuteTextColor, null); + expect(timePickerTheme.hourMinuteTextStyle, null); + expect(timePickerTheme.inputDecorationTheme, null); + expect(timePickerTheme.entryModeIconColor, null); + expect(timePickerTheme.padding, null); + expect(timePickerTheme.shape, null); + expect(timePickerTheme.timeSelectorSeparatorColor, null); + expect(timePickerTheme.timeSelectorSeparatorTextStyle, null); + }); + + testWidgets('Default TimePickerThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TimePickerThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('TimePickerThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TimePickerThemeData( + backgroundColor: Color(0xfffffff0), + cancelButtonStyle: ButtonStyle( + foregroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff1)), + ), + confirmButtonStyle: ButtonStyle( + foregroundColor: MaterialStatePropertyAll<Color>(Color(0xfffffff2)), + ), + dayPeriodBorderSide: BorderSide(color: Color(0xfffffff3)), + dayPeriodColor: Color(0x00000000), + dayPeriodShape: RoundedRectangleBorder(side: BorderSide(color: Color(0xfffffff5))), + dayPeriodTextColor: Color(0xfffffff6), + dayPeriodTextStyle: TextStyle(color: Color(0xfffffff7)), + dialBackgroundColor: Color(0xfffffff8), + dialHandColor: Color(0xfffffff9), + dialTextColor: Color(0xfffffffa), + dialTextStyle: TextStyle(color: Color(0xfffffffb)), + elevation: 1.0, + entryModeIconColor: Color(0xfffffffc), + helpTextStyle: TextStyle(color: Color(0xfffffffd)), + hourMinuteColor: Color(0xfffffffe), + hourMinuteShape: RoundedRectangleBorder(side: BorderSide(color: Color(0xffffffff))), + hourMinuteTextColor: Color(0xfffffff0), + hourMinuteTextStyle: TextStyle(color: Color(0xfffffff1)), + inputDecorationTheme: InputDecorationTheme(labelStyle: TextStyle(color: Color(0xfffffff2))), + padding: EdgeInsets.all(1.0), + shape: RoundedRectangleBorder(side: BorderSide(color: Color(0xfffffff3))), + timeSelectorSeparatorColor: WidgetStatePropertyAll<Color>(Color(0xfffffff4)), + timeSelectorSeparatorTextStyle: WidgetStatePropertyAll<TextStyle>( + TextStyle(color: Color(0xfffffff5)), + ), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect( + description, + equalsIgnoringHashCodes(<String>[ + 'backgroundColor: ${const Color(0xfffffff0)}', + 'cancelButtonStyle: ButtonStyle#00000(foregroundColor: WidgetStatePropertyAll(${const Color(0xfffffff1)}))', + 'confirmButtonStyle: ButtonStyle#00000(foregroundColor: WidgetStatePropertyAll(${const Color(0xfffffff2)}))', + 'dayPeriodBorderSide: BorderSide(color: ${const Color(0xfffffff3)})', + 'dayPeriodColor: ${const Color(0x00000000)}', + 'dayPeriodShape: RoundedRectangleBorder(BorderSide(color: ${const Color(0xfffffff5)}), BorderRadius.zero)', + 'dayPeriodTextColor: ${const Color(0xfffffff6)}', + 'dayPeriodTextStyle: TextStyle(inherit: true, color: ${const Color(0xfffffff7)})', + 'dialBackgroundColor: ${const Color(0xfffffff8)}', + 'dialHandColor: ${const Color(0xfffffff9)}', + 'dialTextColor: ${const Color(0xfffffffa)}', + 'dialTextStyle: TextStyle(inherit: true, color: ${const Color(0xfffffffb)})', + 'elevation: 1.0', + 'entryModeIconColor: ${const Color(0xfffffffc)}', + 'helpTextStyle: TextStyle(inherit: true, color: ${const Color(0xfffffffd)})', + 'hourMinuteColor: ${const Color(0xfffffffe)}', + 'hourMinuteShape: RoundedRectangleBorder(BorderSide(color: ${const Color(0xffffffff)}), BorderRadius.zero)', + 'hourMinuteTextColor: ${const Color(0xfffffff0)}', + 'hourMinuteTextStyle: TextStyle(inherit: true, color: ${const Color(0xfffffff1)})', + 'inputDecorationTheme: InputDecorationThemeData#ff861(labelStyle: TextStyle(inherit: true, color: ${const Color(0xfffffff2)}))', + 'padding: EdgeInsets.all(1.0)', + 'shape: RoundedRectangleBorder(BorderSide(color: ${const Color(0xfffffff3)}), BorderRadius.zero)', + 'timeSelectorSeparatorColor: WidgetStatePropertyAll(${const Color(0xfffffff4)})', + 'timeSelectorSeparatorTextStyle: WidgetStatePropertyAll(TextStyle(inherit: true, color: ${const Color(0xfffffff5)}))', + ]), + ); + }); + + test( + 'TimePickerThemeData.inputDecorationTheme accepts only InputDecorationTheme or InputDecorationThemeData instances', + () { + const decorationTheme = InputDecorationTheme(); + var timePickerTheme = const TimePickerThemeData(inputDecorationTheme: decorationTheme); + expect(timePickerTheme.inputDecorationTheme, decorationTheme.data); + + timePickerTheme = TimePickerThemeData(inputDecorationTheme: decorationTheme.data); + expect(timePickerTheme.inputDecorationTheme, decorationTheme.data); + + // Wrong type throws. + expect(() { + TimePickerThemeData(inputDecorationTheme: Object()); + }, throwsA(isA<AssertionError>())); + }, + ); + + testWidgets('Material2 - Passing no TimePickerThemeData uses defaults', ( + WidgetTester tester, + ) async { + final defaultTheme = ThemeData(useMaterial3: false); + await tester.pumpWidget(_TimePickerLauncher(themeData: defaultTheme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material dialogMaterial = _dialogMaterial(tester); + expect(dialogMaterial.color, defaultTheme.colorScheme.surface); + expect( + dialogMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + final RenderBox dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle( + color: defaultTheme.colorScheme.onSurface.withOpacity(0.08), + ) // Dial background color. + ..circle(color: Color(defaultTheme.colorScheme.primary.value)), + ); + + final RenderParagraph hourText = _textRenderParagraph(tester, '7'); + expect( + hourText.text.style, + Typography.material2014().englishLike.displayMedium! + .merge(Typography.material2014().black.displayMedium) + .copyWith(color: defaultTheme.colorScheme.primary), + ); + + final RenderParagraph minuteText = _textRenderParagraph(tester, '15'); + expect( + minuteText.text.style, + Typography.material2014().englishLike.displayMedium! + .merge(Typography.material2014().black.displayMedium) + .copyWith(color: defaultTheme.colorScheme.onSurface), + ); + + final RenderParagraph amText = _textRenderParagraph(tester, 'AM'); + expect( + amText.text.style, + Typography.material2014().englishLike.titleMedium! + .merge(Typography.material2014().black.titleMedium) + .copyWith(color: defaultTheme.colorScheme.primary), + ); + + final RenderParagraph pmText = _textRenderParagraph(tester, 'PM'); + expect( + pmText.text.style, + Typography.material2014().englishLike.titleMedium! + .merge(Typography.material2014().black.titleMedium) + .copyWith(color: defaultTheme.colorScheme.onSurface.withOpacity(0.6)), + ); + + final RenderParagraph helperText = _textRenderParagraph(tester, 'SELECT TIME'); + expect( + helperText.text.style, + Typography.material2014().englishLike.labelSmall!.merge( + Typography.material2014().black.labelSmall, + ), + ); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + primaryLabels.first.painter.text.style, + Typography.material2014().englishLike.bodyLarge! + .merge(Typography.material2014().black.bodyLarge) + .copyWith(color: defaultTheme.colorScheme.onSurface), + ); + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.first.painter.text.style, + Typography.material2014().englishLike.bodyLarge! + .merge(Typography.material2014().white.bodyLarge) + .copyWith(color: defaultTheme.colorScheme.onPrimary), + ); + + final Material hourMaterial = _textMaterial(tester, '7'); + expect(hourMaterial.color, defaultTheme.colorScheme.primary.withOpacity(0.12)); + expect( + hourMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + final Material minuteMaterial = _textMaterial(tester, '15'); + expect(minuteMaterial.color, defaultTheme.colorScheme.onSurface.withOpacity(0.12)); + expect( + minuteMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))), + ); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, defaultTheme.colorScheme.primary.withOpacity(0.12)); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, Colors.transparent); + + final Color expectedBorderColor = Color.alphaBlend( + defaultTheme.colorScheme.onSurface.withOpacity(0.38), + defaultTheme.colorScheme.surface, + ); + + final expectedAmShape = RoundedRectangleBorder( + side: BorderSide(color: expectedBorderColor), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4.0), + bottomLeft: Radius.circular(4.0), + ), + ); + expect(amMaterial.shape, expectedAmShape); + + final expectedPmShape = RoundedRectangleBorder( + side: BorderSide(color: expectedBorderColor), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(4.0), + bottomRight: Radius.circular(4.0), + ), + ); + expect(pmMaterial.shape, expectedPmShape); + + expect( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), + matching: find.byType(Container), + ), + findsNothing, + ); + + final IconButton entryModeIconButton = _entryModeIconButton(tester); + expect(entryModeIconButton.color, defaultTheme.colorScheme.onSurface.withOpacity(0.6)); + + final ButtonStyle cancelButtonStyle = _actionButtonStyle(tester, 'CANCEL'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + + final ButtonStyle confirmButtonStyle = _actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + }); + + testWidgets('Material3 - Passing no TimePickerThemeData uses defaults', ( + WidgetTester tester, + ) async { + final defaultTheme = ThemeData(); + await tester.pumpWidget(_TimePickerLauncher(themeData: defaultTheme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material dialogMaterial = _dialogMaterial(tester); + expect(dialogMaterial.color, defaultTheme.colorScheme.surfaceContainerHigh); + expect( + dialogMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))), + ); + + final RenderBox dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: defaultTheme.colorScheme.surfaceContainerHighest) // Dial background color. + ..circle(color: Color(defaultTheme.colorScheme.primary.value)), // Dial hand color. + ); + + final RenderParagraph hourText = _textRenderParagraph(tester, '7'); + expect( + hourText.text.style, + Typography.material2021().englishLike.displayLarge! + .merge(Typography.material2021().black.displayLarge) + .copyWith( + color: defaultTheme.colorScheme.onPrimaryContainer, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final RenderParagraph minuteText = _textRenderParagraph(tester, '15'); + expect( + minuteText.text.style, + Typography.material2021().englishLike.displayLarge! + .merge(Typography.material2021().black.displayLarge) + .copyWith( + color: defaultTheme.colorScheme.onSurface, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final RenderParagraph amText = _textRenderParagraph(tester, 'AM'); + expect( + amText.text.style, + Typography.material2021().englishLike.titleMedium! + .merge(Typography.material2021().black.titleMedium) + .copyWith( + color: defaultTheme.colorScheme.onTertiaryContainer, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final RenderParagraph pmText = _textRenderParagraph(tester, 'PM'); + expect( + pmText.text.style, + Typography.material2021().englishLike.titleMedium! + .merge(Typography.material2021().black.titleMedium) + .copyWith( + color: defaultTheme.colorScheme.onSurfaceVariant, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final RenderParagraph helperText = _textRenderParagraph(tester, 'Select time'); + expect( + helperText.text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .copyWith( + color: defaultTheme.colorScheme.onSurface, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + primaryLabels.first.painter.text.style, + Typography.material2021().englishLike.bodyLarge! + .merge(Typography.material2021().black.bodyLarge) + .copyWith( + color: defaultTheme.colorScheme.onSurface, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.first.painter.text.style, + Typography.material2021().englishLike.bodyLarge! + .merge(Typography.material2021().black.bodyLarge) + .copyWith( + color: defaultTheme.colorScheme.onPrimary, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final Material hourMaterial = _textMaterial(tester, '7'); + expect(hourMaterial.color, defaultTheme.colorScheme.primaryContainer); + expect( + hourMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ); + + final Material minuteMaterial = _textMaterial(tester, '15'); + expect(minuteMaterial.color, defaultTheme.colorScheme.surfaceContainerHighest); + expect( + minuteMaterial.shape, + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))), + ); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, defaultTheme.colorScheme.tertiaryContainer); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, Colors.transparent); + + final expectedAmShape = RoundedRectangleBorder( + side: BorderSide(color: defaultTheme.colorScheme.outline), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8.0), + bottomLeft: Radius.circular(8.0), + ), + ); + expect(amMaterial.shape, expectedAmShape); + + final expectedPmShape = RoundedRectangleBorder( + side: BorderSide(color: defaultTheme.colorScheme.outline), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8.0), + bottomRight: Radius.circular(8.0), + ), + ); + expect(pmMaterial.shape, expectedPmShape); + + expect( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), + matching: find.byType(Container), + ), + findsNothing, + ); + + final IconButton entryModeIconButton = _entryModeIconButton(tester); + expect(entryModeIconButton.color, null); + + final ButtonStyle cancelButtonStyle = _actionButtonStyle(tester, 'Cancel'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + + final ButtonStyle confirmButtonStyle = _actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + }); + + testWidgets('Material2 - Passing no TimePickerThemeData uses defaults - input mode', ( + WidgetTester tester, + ) async { + final defaultTheme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + _TimePickerLauncher(themeData: defaultTheme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final InputDecoration hourDecoration = _textField(tester, '7').decoration!; + expect(hourDecoration.filled, true); + expect( + hourDecoration.fillColor, + WidgetStateColor.resolveWith( + (Set<WidgetState> states) => defaultTheme.colorScheme.onSurface.withOpacity(0.12), + ), + ); + expect( + hourDecoration.enabledBorder, + const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)), + ); + expect( + hourDecoration.errorBorder, + OutlineInputBorder(borderSide: BorderSide(color: defaultTheme.colorScheme.error, width: 2)), + ); + expect( + hourDecoration.focusedBorder, + OutlineInputBorder(borderSide: BorderSide(color: defaultTheme.colorScheme.primary, width: 2)), + ); + expect( + hourDecoration.focusedErrorBorder, + OutlineInputBorder(borderSide: BorderSide(color: defaultTheme.colorScheme.error, width: 2)), + ); + expect( + hourDecoration.hintStyle, + Typography.material2014().englishLike.displayMedium!.merge( + defaultTheme.textTheme.displayMedium!.copyWith( + color: defaultTheme.colorScheme.onSurface.withOpacity(0.36), + ), + ), + ); + + final ButtonStyle cancelButtonStyle = _actionButtonStyle(tester, 'CANCEL'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + + final ButtonStyle confirmButtonStyle = _actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + }); + + testWidgets('Material3 - Passing no TimePickerThemeData uses defaults - input mode', ( + WidgetTester tester, + ) async { + final defaultTheme = ThemeData(); + await tester.pumpWidget( + _TimePickerLauncher(themeData: defaultTheme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final TextStyle hourTextStyle = _textField(tester, '7').style!; + expect( + hourTextStyle, + Typography.material2021().englishLike.displayMedium! + .merge(Typography.material2021().black.displayMedium) + .copyWith( + color: defaultTheme.colorScheme.onSurface, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final TextStyle minuteTextStyle = _textField(tester, '15').style!; + expect( + minuteTextStyle, + Typography.material2021().englishLike.displayMedium! + .merge(Typography.material2021().black.displayMedium) + .copyWith( + color: defaultTheme.colorScheme.onSurface, + decorationColor: defaultTheme.colorScheme.onSurface, + ), + ); + + final InputDecoration hourDecoration = _textField(tester, '7').decoration!; + expect(hourDecoration.filled, true); + expect(hourDecoration.fillColor, defaultTheme.colorScheme.surfaceContainerHighest); + expect( + hourDecoration.enabledBorder, + const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: Colors.transparent), + ), + ); + expect( + hourDecoration.errorBorder, + OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: defaultTheme.colorScheme.error, width: 2.0), + ), + ); + expect( + hourDecoration.focusedBorder, + OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: defaultTheme.colorScheme.primary, width: 2.0), + ), + ); + expect( + hourDecoration.focusedErrorBorder, + OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: defaultTheme.colorScheme.error, width: 2.0), + ), + ); + expect( + hourDecoration.hintStyle, + TextStyle(color: defaultTheme.colorScheme.onSurface.withOpacity(0.36)), + ); + + final ButtonStyle cancelButtonStyle = _actionButtonStyle(tester, 'Cancel'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + + final ButtonStyle confirmButtonStyle = _actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(TextButton.styleFrom().toString()), + ); + }); + + testWidgets('Material2 - Time picker uses values from TimePickerThemeData', ( + WidgetTester tester, + ) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(); + final theme = ThemeData(timePickerTheme: timePickerTheme, useMaterial3: false); + await tester.pumpWidget(_TimePickerLauncher(themeData: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material dialogMaterial = _dialogMaterial(tester); + expect(dialogMaterial.color, timePickerTheme.backgroundColor); + expect(dialogMaterial.shape, timePickerTheme.shape); + + final RenderBox dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: Color(timePickerTheme.dialBackgroundColor!.value)) // Dial background color. + ..circle(color: Color(timePickerTheme.dialHandColor!.value)), // Dial hand color. + ); + + final RenderParagraph hourText = _textRenderParagraph(tester, '7'); + expect( + hourText.text.style, + Typography.material2014().englishLike.bodyMedium! + .merge(Typography.material2014().black.bodyMedium) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _selectedColor), + ); + + final RenderParagraph minuteText = _textRenderParagraph(tester, '15'); + expect( + minuteText.text.style, + Typography.material2014().englishLike.bodyMedium! + .merge(Typography.material2014().black.bodyMedium) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _unselectedColor), + ); + + final RenderParagraph amText = _textRenderParagraph(tester, 'AM'); + expect( + amText.text.style, + Typography.material2014().englishLike.titleMedium! + .merge(Typography.material2014().black.titleMedium) + .merge(timePickerTheme.dayPeriodTextStyle) + .copyWith(color: _selectedColor), + ); + + final RenderParagraph pmText = _textRenderParagraph(tester, 'PM'); + expect( + pmText.text.style, + Typography.material2014().englishLike.titleMedium! + .merge(Typography.material2014().black.titleMedium) + .merge(timePickerTheme.dayPeriodTextStyle) + .copyWith(color: _unselectedColor), + ); + + final RenderParagraph helperText = _textRenderParagraph(tester, 'SELECT TIME'); + expect( + helperText.text.style, + Typography.material2014().englishLike.bodyMedium! + .merge(Typography.material2014().black.bodyMedium) + .merge(timePickerTheme.helpTextStyle), + ); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + primaryLabels.first.painter.text.style, + Typography.material2014().englishLike.bodyLarge! + .merge(Typography.material2014().black.bodyLarge) + .copyWith(color: _unselectedColor), + ); + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.first.painter.text.style, + Typography.material2014().englishLike.bodyLarge! + .merge(Typography.material2014().white.bodyLarge) + .copyWith(color: _selectedColor), + ); + + final Material hourMaterial = _textMaterial(tester, '7'); + expect(hourMaterial.color, _selectedColor); + expect(hourMaterial.shape, timePickerTheme.hourMinuteShape); + + final Material minuteMaterial = _textMaterial(tester, '15'); + expect(minuteMaterial.color, _unselectedColor); + expect(minuteMaterial.shape, timePickerTheme.hourMinuteShape); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, _selectedColor); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, _unselectedColor); + + final dayPeriodShape = timePickerTheme.dayPeriodShape! as RoundedRectangleBorder; + final borderRadius = dayPeriodShape.borderRadius as BorderRadius; + + final RoundedRectangleBorder expectedAmShape = dayPeriodShape.copyWith( + side: timePickerTheme.dayPeriodBorderSide, + borderRadius: BorderRadius.only( + topLeft: borderRadius.topLeft, + bottomLeft: borderRadius.topRight, + ), + ); + expect(amMaterial.shape, expectedAmShape); + + final RoundedRectangleBorder expectedPmShape = dayPeriodShape.copyWith( + side: timePickerTheme.dayPeriodBorderSide, + borderRadius: BorderRadius.only( + topRight: borderRadius.topLeft, + bottomRight: borderRadius.topRight, + ), + ); + expect(pmMaterial.shape, expectedPmShape); + + expect( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), + matching: find.byType(Container), + ), + findsNothing, + ); + + final IconButton entryModeIconButton = _entryModeIconButton(tester); + expect(entryModeIconButton.color, timePickerTheme.entryModeIconColor); + + final ButtonStyle cancelButtonStyle = _actionButtonStyle(tester, 'CANCEL'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(timePickerTheme.cancelButtonStyle.toString()), + ); + + final ButtonStyle confirmButtonStyle = _actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(timePickerTheme.confirmButtonStyle.toString()), + ); + }); + + testWidgets('Material3 - Time picker uses values from TimePickerThemeData', ( + WidgetTester tester, + ) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget(_TimePickerLauncher(themeData: theme)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material dialogMaterial = _dialogMaterial(tester); + expect(dialogMaterial.color, timePickerTheme.backgroundColor); + expect(dialogMaterial.shape, timePickerTheme.shape); + + final RenderBox dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: Color(timePickerTheme.dialBackgroundColor!.value)) // Dial background color. + ..circle(color: Color(timePickerTheme.dialHandColor!.value)), // Dial hand color. + ); + + final RenderParagraph hourText = _textRenderParagraph(tester, '7'); + expect( + hourText.text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _selectedColor, decorationColor: const Color(0xff1d1b20)), + ); + + final RenderParagraph minuteText = _textRenderParagraph(tester, '15'); + expect( + minuteText.text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _unselectedColor, decorationColor: const Color(0xff1d1b20)), + ); + + final RenderParagraph amText = _textRenderParagraph(tester, 'AM'); + expect( + amText.text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _selectedColor, decorationColor: const Color(0xff1d1b20)), + ); + + final RenderParagraph pmText = _textRenderParagraph(tester, 'PM'); + expect( + pmText.text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _unselectedColor, decorationColor: const Color(0xff1d1b20)), + ); + + final RenderParagraph helperText = _textRenderParagraph(tester, 'Select time'); + expect( + helperText.text.style, + Typography.material2021().englishLike.bodyMedium! + .merge(Typography.material2021().black.bodyMedium) + .merge(timePickerTheme.helpTextStyle) + .copyWith( + color: theme.colorScheme.onSurface, + decorationColor: theme.colorScheme.onSurface, + ), + ); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final primaryLabels = dialPainter.primaryLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + primaryLabels.first.painter.text.style, + Typography.material2021().englishLike.bodyLarge! + .merge(Typography.material2021().black.bodyLarge) + .copyWith(color: _unselectedColor, decorationColor: theme.colorScheme.onSurface), + ); + // ignore: avoid_dynamic_calls + final selectedLabels = dialPainter.selectedLabels as List<dynamic>; + expect( + // ignore: avoid_dynamic_calls + selectedLabels.first.painter.text.style, + Typography.material2021().englishLike.bodyLarge! + .merge(Typography.material2021().black.bodyLarge) + .copyWith(color: _selectedColor, decorationColor: theme.colorScheme.onSurface), + ); + + final Material hourMaterial = _textMaterial(tester, '7'); + expect(hourMaterial.color, _selectedColor); + expect(hourMaterial.shape, timePickerTheme.hourMinuteShape); + + final Material minuteMaterial = _textMaterial(tester, '15'); + expect(minuteMaterial.color, _unselectedColor); + expect(minuteMaterial.shape, timePickerTheme.hourMinuteShape); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, _selectedColor); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, _unselectedColor); + + final dayPeriodShape = timePickerTheme.dayPeriodShape! as RoundedRectangleBorder; + final borderRadius = dayPeriodShape.borderRadius as BorderRadius; + + final RoundedRectangleBorder expectedAmShape = dayPeriodShape.copyWith( + side: timePickerTheme.dayPeriodBorderSide, + borderRadius: BorderRadius.only( + topLeft: borderRadius.topLeft, + bottomLeft: borderRadius.topRight, + ), + ); + expect(amMaterial.shape, expectedAmShape); + + final RoundedRectangleBorder expectedPmShape = dayPeriodShape.copyWith( + side: timePickerTheme.dayPeriodBorderSide, + borderRadius: BorderRadius.only( + topRight: borderRadius.topLeft, + bottomRight: borderRadius.topRight, + ), + ); + expect(pmMaterial.shape, expectedPmShape); + + expect( + find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), + matching: find.byType(Container), + ), + findsNothing, + ); + + final IconButton entryModeIconButton = _entryModeIconButton(tester); + expect(entryModeIconButton.color, null); + + final ButtonStyle cancelButtonStyle = _actionButtonStyle(tester, 'Cancel'); + expect( + cancelButtonStyle.toString(), + equalsIgnoringHashCodes(timePickerTheme.cancelButtonStyle.toString()), + ); + + final ButtonStyle confirmButtonStyle = _actionButtonStyle(tester, 'OK'); + expect( + confirmButtonStyle.toString(), + equalsIgnoringHashCodes(timePickerTheme.confirmButtonStyle.toString()), + ); + }); + + testWidgets( + 'Time picker uses values from TimePickerThemeData when TimePickerThemeData.inputDecorationTheme is provided - input mode', + (WidgetTester tester) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(includeInputDecoration: true); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final InputDecoration hourDecoration = _textField(tester, '7').decoration!; + expect(hourDecoration.filled, timePickerTheme.inputDecorationTheme!.filled); + expect(hourDecoration.fillColor, timePickerTheme.inputDecorationTheme!.fillColor); + expect(hourDecoration.enabledBorder, timePickerTheme.inputDecorationTheme!.enabledBorder); + expect(hourDecoration.errorBorder, timePickerTheme.inputDecorationTheme!.errorBorder); + expect(hourDecoration.focusedBorder, timePickerTheme.inputDecorationTheme!.focusedBorder); + expect( + hourDecoration.focusedErrorBorder, + timePickerTheme.inputDecorationTheme!.focusedErrorBorder, + ); + expect(hourDecoration.hintStyle, timePickerTheme.inputDecorationTheme!.hintStyle); + }, + ); + + testWidgets( + 'Time picker uses values from TimePickerThemeData when TimePickerThemeData.inputDecorationTheme is not provided - input mode', + (WidgetTester tester) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final InputDecoration hourDecoration = _textField(tester, '7').decoration!; + expect(hourDecoration.fillColor?.value, timePickerTheme.hourMinuteColor?.value); + }, + ); + + testWidgets('Time picker dayPeriodColor does the right thing with non-WidgetStateColor', ( + WidgetTester tester, + ) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme().copyWith( + dayPeriodColor: Colors.red, + ); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, Colors.red); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, Colors.transparent); + }); + + testWidgets('Time picker dayPeriodColor does the right thing with WidgetStateColor', ( + WidgetTester tester, + ) async { + final testColor = WidgetStateColor.resolveWith((Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return Colors.green; + } + return Colors.blue; + }); + + final TimePickerThemeData timePickerTheme = _timePickerTheme().copyWith( + dayPeriodColor: testColor, + ); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, Colors.green); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, Colors.blue); + }); + + testWidgets('Time selector separator color uses the timeSelectorSeparatorColor value', ( + WidgetTester tester, + ) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme().copyWith( + timeSelectorSeparatorColor: const MaterialStatePropertyAll<Color>(Color(0xff00ff00)), + ); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final RenderParagraph paragraph = tester.renderObject(find.text(':')); + expect(paragraph.text.style!.color, const Color(0xff00ff00)); + }); + + testWidgets('Time selector separator text style uses the timeSelectorSeparatorTextStyle value', ( + WidgetTester tester, + ) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme().copyWith( + timeSelectorSeparatorTextStyle: const MaterialStatePropertyAll<TextStyle>( + TextStyle(fontSize: 35.0, fontStyle: FontStyle.italic), + ), + ); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final RenderParagraph paragraph = tester.renderObject(find.text(':')); + expect(paragraph.text.style!.fontSize, 35.0); + expect(paragraph.text.style!.fontStyle, FontStyle.italic); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153549. + testWidgets('Time picker hour minute does not resize on error', (WidgetTester tester) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(includeInputDecoration: true); + final theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 72.0)); + + // Enter invalid hour. + await tester.enterText(find.byType(TextField).first, 'AB'); + await tester.tap(find.text('OK')); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 72.0)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/153549. + testWidgets('Material2 - Time picker hour minute does not resize on error', ( + WidgetTester tester, + ) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(includeInputDecoration: true); + final theme = ThemeData(timePickerTheme: timePickerTheme, useMaterial3: false); + await tester.pumpWidget( + _TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); + + // Enter invalid hour. + await tester.enterText(find.byType(TextField).first, 'AB'); + await tester.tap(find.text('OK')); + + expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); + }); +} + +final Color _selectedColor = Colors.green[100]!; +final Color _unselectedColor = Colors.green[200]!; + +TimePickerThemeData _timePickerTheme({bool includeInputDecoration = false}) { + Color getColor(Set<WidgetState> states) { + return states.contains(WidgetState.selected) ? _selectedColor : _unselectedColor; + } + + final materialStateColor = WidgetStateColor.resolveWith(getColor); + return TimePickerThemeData( + backgroundColor: Colors.orange, + cancelButtonStyle: TextButton.styleFrom(foregroundColor: Colors.red), + confirmButtonStyle: TextButton.styleFrom(foregroundColor: Colors.green), + hourMinuteTextColor: materialStateColor, + hourMinuteColor: materialStateColor, + dayPeriodTextColor: materialStateColor, + dayPeriodColor: materialStateColor, + dialHandColor: Colors.brown, + dialBackgroundColor: Colors.pinkAccent, + dialTextColor: materialStateColor, + entryModeIconColor: Colors.red, + hourMinuteTextStyle: const TextStyle(fontSize: 8.0), + dayPeriodTextStyle: const TextStyle(fontSize: 8.0), + helpTextStyle: const TextStyle(fontSize: 8.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + hourMinuteShape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ), + dayPeriodShape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0)), + ), + dayPeriodBorderSide: const BorderSide(color: Colors.blueAccent), + inputDecorationTheme: includeInputDecoration + ? const InputDecorationTheme( + filled: true, + fillColor: Colors.purple, + enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.blue)), + errorBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.green)), + focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.yellow)), + focusedErrorBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.red)), + hintStyle: TextStyle(fontSize: 8), + ) + : null, + ); +} + +class _TimePickerLauncher extends StatelessWidget { + const _TimePickerLauncher({this.themeData, this.entryMode = TimePickerEntryMode.dial}); + + final ThemeData? themeData; + final TimePickerEntryMode entryMode; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () async { + await showTimePicker( + context: context, + initialEntryMode: entryMode, + initialTime: const TimeOfDay(hour: 7, minute: 15), + ); + }, + ); + }, + ), + ), + ), + ); + } +} + +Material _dialogMaterial(WidgetTester tester) { + return tester.widget<Material>( + find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, + ); +} + +Material _textMaterial(WidgetTester tester, String text) { + return tester.widget<Material>( + find.ancestor(of: find.text(text), matching: find.byType(Material)).first, + ); +} + +TextField _textField(WidgetTester tester, String text) { + return tester.widget<TextField>( + find.ancestor(of: find.text(text), matching: find.byType(TextField)).first, + ); +} + +IconButton _entryModeIconButton(WidgetTester tester) { + return tester.widget<IconButton>( + find.descendant(of: find.byType(Dialog), matching: find.byType(IconButton)).first, + ); +} + +RenderParagraph _textRenderParagraph(WidgetTester tester, String text) { + return tester.element<StatelessElement>(find.text(text).first).renderObject! as RenderParagraph; +} + +final Finder findDialPaint = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), +); + +ButtonStyle _actionButtonStyle(WidgetTester tester, String text) { + return tester.widget<TextButton>(find.widgetWithText(TextButton, text)).style!; +} + +Finder findBorderPainter() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), + ); +} diff --git a/packages/material_ui/test/material/time_test.dart b/packages/material_ui/test/material/time_test.dart new file mode 100644 index 000000000000..0db45232e11e --- /dev/null +++ b/packages/material_ui/test/material/time_test.dart @@ -0,0 +1,331 @@ +// 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() { + group('hourFormat', () { + test('returns HourFormat.h for 12-hour formats', () { + const formats = <TimeOfDayFormat>[ + TimeOfDayFormat.h_colon_mm_space_a, + TimeOfDayFormat.a_space_h_colon_mm, + ]; + for (final format in formats) { + expect(hourFormat(of: format), HourFormat.h, reason: 'for $format'); + } + }); + + test('returns HourFormat.H for non-padded 24-hour format', () { + expect(hourFormat(of: TimeOfDayFormat.H_colon_mm), HourFormat.H); + }); + + test('returns HourFormat.HH for zero-padded 24-hour formats', () { + const formats = <TimeOfDayFormat>[ + TimeOfDayFormat.HH_dot_mm, + TimeOfDayFormat.HH_colon_mm, + TimeOfDayFormat.frenchCanadian, + ]; + for (final format in formats) { + expect(hourFormat(of: format), HourFormat.HH, reason: 'for $format'); + } + }); + }); + + group('TimeOfDay.format', () { + testWidgets('respects alwaysUse24HourFormat option', (WidgetTester tester) async { + Future<String> pumpTest(bool alwaysUse24HourFormat) async { + late String formattedValue; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(alwaysUse24HourFormat: alwaysUse24HourFormat), + child: Builder( + builder: (BuildContext context) { + formattedValue = const TimeOfDay(hour: 7, minute: 0).format(context); + return Container(); + }, + ), + ), + ), + ); + return formattedValue; + } + + expect(await pumpTest(false), '7:00 AM'); + expect(await pumpTest(true), '07:00'); + }); + }); + + testWidgets('hourOfPeriod returns correct value', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/59158. + expect(const TimeOfDay(minute: 0, hour: 0).hourOfPeriod, 12); + expect(const TimeOfDay(minute: 0, hour: 1).hourOfPeriod, 1); + expect(const TimeOfDay(minute: 0, hour: 2).hourOfPeriod, 2); + expect(const TimeOfDay(minute: 0, hour: 3).hourOfPeriod, 3); + expect(const TimeOfDay(minute: 0, hour: 4).hourOfPeriod, 4); + expect(const TimeOfDay(minute: 0, hour: 5).hourOfPeriod, 5); + expect(const TimeOfDay(minute: 0, hour: 6).hourOfPeriod, 6); + expect(const TimeOfDay(minute: 0, hour: 7).hourOfPeriod, 7); + expect(const TimeOfDay(minute: 0, hour: 8).hourOfPeriod, 8); + expect(const TimeOfDay(minute: 0, hour: 9).hourOfPeriod, 9); + expect(const TimeOfDay(minute: 0, hour: 10).hourOfPeriod, 10); + expect(const TimeOfDay(minute: 0, hour: 11).hourOfPeriod, 11); + expect(const TimeOfDay(minute: 0, hour: 12).hourOfPeriod, 12); + expect(const TimeOfDay(minute: 0, hour: 13).hourOfPeriod, 1); + expect(const TimeOfDay(minute: 0, hour: 14).hourOfPeriod, 2); + expect(const TimeOfDay(minute: 0, hour: 15).hourOfPeriod, 3); + expect(const TimeOfDay(minute: 0, hour: 16).hourOfPeriod, 4); + expect(const TimeOfDay(minute: 0, hour: 17).hourOfPeriod, 5); + expect(const TimeOfDay(minute: 0, hour: 18).hourOfPeriod, 6); + expect(const TimeOfDay(minute: 0, hour: 19).hourOfPeriod, 7); + expect(const TimeOfDay(minute: 0, hour: 20).hourOfPeriod, 8); + expect(const TimeOfDay(minute: 0, hour: 21).hourOfPeriod, 9); + expect(const TimeOfDay(minute: 0, hour: 22).hourOfPeriod, 10); + expect(const TimeOfDay(minute: 0, hour: 23).hourOfPeriod, 11); + }); + + group('RestorableTimeOfDay tests', () { + testWidgets('value is not accessible when not registered', (WidgetTester tester) async { + final property = RestorableTimeOfDay(const TimeOfDay(hour: 20, minute: 4)); + addTearDown(property.dispose); + expect(() => property.value, throwsAssertionError); + }); + + testWidgets('work when not in restoration scope', (WidgetTester tester) async { + await tester.pumpWidget(const _RestorableWidget()); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Initialized to default values. + expect(state.timeOfDay.value, const TimeOfDay(hour: 10, minute: 5)); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + await tester.pump(); + + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + }); + + testWidgets('restart and restore', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope(restorationId: 'root-child', child: _RestorableWidget()), + ); + + _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Initialized to default values. + expect(state.timeOfDay.value, const TimeOfDay(hour: 10, minute: 5)); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + await tester.pump(); + + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + + // Restores to previous values. + await tester.restartAndRestore(); + final oldState = state; + state = tester.state(find.byType(_RestorableWidget)); + expect(state, isNot(same(oldState))); + + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + }); + + testWidgets('restore to older state', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope(restorationId: 'root-child', child: _RestorableWidget()), + ); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + await tester.pump(); + + final TestRestorationData restorationData = await tester.getRestorationData(); + + // Modify values. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 4, minute: 4); + }); + await tester.pump(); + + // Restore to previous. + await tester.restoreFrom(restorationData); + expect(state.timeOfDay.value, const TimeOfDay(hour: 2, minute: 2)); + + // Restore to empty data will re-initialize to default values. + await tester.restoreFrom(TestRestorationData.empty); + expect(state.timeOfDay.value, const TimeOfDay(hour: 10, minute: 5)); + }); + + testWidgets('call notifiers when value changes', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope(restorationId: 'root-child', child: _RestorableWidget()), + ); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + final notifyLog = <String>[]; + + state.timeOfDay.addListener(() { + notifyLog.add('hello world'); + }); + + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + expect(notifyLog.single, 'hello world'); + notifyLog.clear(); + await tester.pump(); + + // Does not notify when set to same value. + state.setProperties(() { + state.timeOfDay.value = const TimeOfDay(hour: 2, minute: 2); + }); + + expect(notifyLog, isEmpty); + }); + }); + + testWidgets('correctly compares to other TimeOfDays', (WidgetTester tester) async { + expect( + const TimeOfDay(hour: 0, minute: 0).compareTo(const TimeOfDay(hour: 1, minute: 0)), + lessThan(0), + ); + expect( + const TimeOfDay(hour: 20, minute: 0).compareTo(const TimeOfDay(hour: 20, minute: 1)), + lessThan(0), + ); + expect(const TimeOfDay(hour: 0, minute: 0).compareTo(const TimeOfDay(hour: 0, minute: 0)), 0); + expect( + const TimeOfDay(hour: 1, minute: 0).compareTo(const TimeOfDay(hour: 0, minute: 0)), + greaterThan(0), + ); + expect( + const TimeOfDay(hour: 20, minute: 1).compareTo(const TimeOfDay(hour: 20, minute: 0)), + greaterThan(0), + ); + + expect( + const TimeOfDay(hour: 0, minute: 0).isBefore(const TimeOfDay(hour: 1, minute: 0)), + isTrue, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isBefore(const TimeOfDay(hour: 23, minute: 0)), + isTrue, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isBefore(const TimeOfDay(hour: 0, minute: 10)), + isTrue, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isBefore(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 1, minute: 0).isBefore(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 23, minute: 0).isBefore(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 10).isBefore(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + + expect( + const TimeOfDay(hour: 0, minute: 0).isAfter(const TimeOfDay(hour: 1, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isAfter(const TimeOfDay(hour: 23, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isAfter(const TimeOfDay(hour: 0, minute: 10)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isAfter(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 1, minute: 0).isAfter(const TimeOfDay(hour: 0, minute: 0)), + isTrue, + ); + expect( + const TimeOfDay(hour: 23, minute: 0).isAfter(const TimeOfDay(hour: 0, minute: 0)), + isTrue, + ); + expect( + const TimeOfDay(hour: 0, minute: 10).isAfter(const TimeOfDay(hour: 0, minute: 0)), + isTrue, + ); + + expect( + const TimeOfDay(hour: 0, minute: 0).isAtSameTimeAs(const TimeOfDay(hour: 1, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isAtSameTimeAs(const TimeOfDay(hour: 0, minute: 10)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 0).isAtSameTimeAs(const TimeOfDay(hour: 0, minute: 0)), + isTrue, + ); + expect( + const TimeOfDay(hour: 1, minute: 0).isAtSameTimeAs(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + expect( + const TimeOfDay(hour: 0, minute: 10).isAtSameTimeAs(const TimeOfDay(hour: 0, minute: 0)), + isFalse, + ); + }); +} + +class _RestorableWidget extends StatefulWidget { + const _RestorableWidget(); + + @override + State<_RestorableWidget> createState() => _RestorableWidgetState(); +} + +class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin { + final RestorableTimeOfDay timeOfDay = RestorableTimeOfDay(const TimeOfDay(hour: 10, minute: 5)); + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(timeOfDay, 'time_of_day'); + } + + void setProperties(VoidCallback callback) { + setState(callback); + } + + @override + Widget build(BuildContext context) { + return const SizedBox(); + } + + @override + String get restorationId => 'widget'; + + @override + void dispose() { + timeOfDay.dispose(); + super.dispose(); + } +} diff --git a/packages/material_ui/test/material/toggle_buttons_test.dart b/packages/material_ui/test/material/toggle_buttons_test.dart new file mode 100644 index 000000000000..03dd6b464936 --- /dev/null +++ b/packages/material_ui/test/material/toggle_buttons_test.dart @@ -0,0 +1,1994 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +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 '../widgets/semantics_tester.dart'; + +const double _defaultBorderWidth = 1.0; + +Widget boilerplate({ + ThemeData? theme, + MaterialTapTargetSize? tapTargetSize, + required Widget child, +}) { + return Theme( + data: theme ?? ThemeData(materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Material(child: child)), + ), + ); +} + +void main() { + testWidgets('Initial toggle state is reflected', (WidgetTester tester) async { + TextStyle buttonTextStyle(String text) { + return tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.87)); + expect(buttonTextStyle('Second child').color, theme.colorScheme.primary); + }); + + testWidgets('onPressed is triggered on button tap', (WidgetTester tester) async { + TextStyle buttonTextStyle(String text) { + return tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + final isSelected = <bool>[false, true]; + final theme = ThemeData(); + await tester.pumpWidget( + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return boilerplate( + child: ToggleButtons( + onPressed: (int index) { + setState(() { + isSelected[index] = !isSelected[index]; + }); + }, + isSelected: isSelected, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ); + }, + ), + ); + + expect(isSelected[0], isFalse); + expect(isSelected[1], isTrue); + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.87)); + expect(buttonTextStyle('Second child').color, theme.colorScheme.primary); + + await tester.tap(find.text('Second child')); + await tester.pumpAndSettle(); + + expect(isSelected[0], isFalse); + expect(isSelected[1], isFalse); + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.87)); + expect(buttonTextStyle('Second child').color, theme.colorScheme.onSurface.withOpacity(0.87)); + }); + + testWidgets('onPressed that is null disables buttons', (WidgetTester tester) async { + TextStyle buttonTextStyle(String text) { + return tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + final isSelected = <bool>[false, true]; + final theme = ThemeData(); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: isSelected, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + expect(isSelected[0], isFalse); + expect(isSelected[1], isTrue); + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.38)); + expect(buttonTextStyle('Second child').color, theme.colorScheme.onSurface.withOpacity(0.38)); + + await tester.tap(find.text('Second child')); + await tester.pumpAndSettle(); + + // Nothing should change + expect(isSelected[0], isFalse); + expect(isSelected[1], isTrue); + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.38)); + expect(buttonTextStyle('Second child').color, theme.colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('children and isSelected properties have to be the same length', ( + WidgetTester tester, + ) async { + await expectLater( + () => tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + throwsA( + isAssertionError.having( + (AssertionError error) => error.toString(), + '.toString()', + allOf(contains('children.length'), contains('isSelected.length')), + ), + ), + ); + }); + + testWidgets('Default text style is applied', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + TextStyle textStyle; + textStyle = tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, 'First child'), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + expect(textStyle.fontFamily, theme.textTheme.bodyMedium!.fontFamily); + expect(textStyle.decoration, theme.textTheme.bodyMedium!.decoration); + + textStyle = tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, 'Second child'), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + expect(textStyle.fontFamily, theme.textTheme.bodyMedium!.fontFamily); + expect(textStyle.decoration, theme.textTheme.bodyMedium!.decoration); + }); + + testWidgets('Custom text style except color is applied', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, true], + onPressed: (int index) {}, + textStyle: const TextStyle( + textBaseline: TextBaseline.ideographic, + fontSize: 20.0, + color: Colors.orange, + ), + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + TextStyle textStyle; + textStyle = tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, 'First child'), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + expect(textStyle.textBaseline, TextBaseline.ideographic); + expect(textStyle.fontSize, 20.0); + expect(textStyle.color, isNot(Colors.orange)); + + textStyle = tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, 'Second child'), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + expect(textStyle.textBaseline, TextBaseline.ideographic); + expect(textStyle.fontSize, 20.0); + expect(textStyle.color, isNot(Colors.orange)); + }); + + testWidgets('Default BoxConstraints', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + children: const <Widget>[Icon(Icons.check), Icon(Icons.access_alarm), Icon(Icons.cake)], + ), + ), + ); + + final Rect firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.width, 48.0); + expect(firstRect.height, 48.0); + final Rect secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.width, 48.0); + expect(secondRect.height, 48.0); + final Rect thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.width, 48.0); + expect(thirdRect.height, 48.0); + }); + + testWidgets('Custom BoxConstraints', (WidgetTester tester) async { + // Test for minimum constraints + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + constraints: const BoxConstraints(minWidth: 50.0, minHeight: 60.0), + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + children: const <Widget>[Icon(Icons.check), Icon(Icons.access_alarm), Icon(Icons.cake)], + ), + ), + ); + + Rect firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.width, 50.0); + expect(firstRect.height, 60.0); + Rect secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.width, 50.0); + expect(secondRect.height, 60.0); + Rect thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.width, 50.0); + expect(thirdRect.height, 60.0); + + // Test for maximum constraints + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + constraints: const BoxConstraints(maxWidth: 20.0, maxHeight: 10.0), + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + children: const <Widget>[Icon(Icons.check), Icon(Icons.access_alarm), Icon(Icons.cake)], + ), + ), + ); + + firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.width, 20.0); + expect(firstRect.height, 10.0); + secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.width, 20.0); + expect(secondRect.height, 10.0); + thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.width, 20.0); + expect(thirdRect.height, 10.0); + }); + + testWidgets('Default text/icon colors for enabled, selected and disabled states', ( + WidgetTester tester, + ) async { + TextStyle buttonTextStyle(String text) { + return tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + IconTheme iconTheme(IconData icon) { + return tester.widget( + find + .descendant(of: find.widgetWithIcon(TextButton, icon), matching: find.byType(IconTheme)) + .last, + ); + } + + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ); + + // Default enabled color + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.87)); + expect(iconTheme(Icons.check).data.color, theme.colorScheme.onSurface.withOpacity(0.87)); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Default selected color + expect(buttonTextStyle('First child').color, theme.colorScheme.primary); + expect(iconTheme(Icons.check).data.color, theme.colorScheme.primary); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true], + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Default disabled color + expect(buttonTextStyle('First child').color, theme.colorScheme.onSurface.withOpacity(0.38)); + expect(iconTheme(Icons.check).data.color, theme.colorScheme.onSurface.withOpacity(0.38)); + }); + + testWidgets('Custom text/icon colors for enabled, selected and disabled states', ( + WidgetTester tester, + ) async { + TextStyle buttonTextStyle(String text) { + return tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + IconTheme iconTheme(IconData icon) { + return tester.widget( + find + .descendant(of: find.widgetWithIcon(TextButton, icon), matching: find.byType(IconTheme)) + .last, + ); + } + + final theme = ThemeData(); + const Color enabledColor = Colors.lime; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.yellow; + + // Tests are ineffective if the custom colors are the same as the theme's + expect(theme.colorScheme.onSurface, isNot(enabledColor)); + expect(theme.colorScheme.primary, isNot(selectedColor)); + expect(theme.colorScheme.onSurface.withOpacity(0.38), isNot(disabledColor)); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + color: enabledColor, + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ); + + // Custom enabled color + expect(buttonTextStyle('First child').color, enabledColor); + expect(iconTheme(Icons.check).data.color, enabledColor); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + selectedColor: selectedColor, + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Custom selected color + expect(buttonTextStyle('First child').color, selectedColor); + expect(iconTheme(Icons.check).data.color, selectedColor); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + disabledColor: disabledColor, + isSelected: const <bool>[true], + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Custom disabled color + expect(buttonTextStyle('First child').color, disabledColor); + expect(iconTheme(Icons.check).data.color, disabledColor); + }); + + testWidgets('Default button fillColor - unselected', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child')]), + ], + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, theme.colorScheme.surface.withOpacity(0.0)); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default button fillColor - selected', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child')]), + ], + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, theme.colorScheme.primary.withOpacity(0.12)); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default button fillColor - disabled', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true], + children: const <Widget>[ + Row(children: <Widget>[Text('First child')]), + ], + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, theme.colorScheme.surface.withOpacity(0.0)); + expect(material.type, MaterialType.button); + }); + + testWidgets('Custom button fillColor', (WidgetTester tester) async { + const Color customFillColor = Colors.green; + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + fillColor: customFillColor, + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child')]), + ], + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, customFillColor); + expect(material.type, MaterialType.button); + }); + + testWidgets('Custom button fillColor - Non WidgetState', (WidgetTester tester) async { + Material buttonColor(String text) { + return tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.widgetWithText(Material, text)), + ); + } + + final theme = ThemeData(); + const Color selectedFillColor = Colors.yellow; + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + fillColor: selectedFillColor, + isSelected: const <bool>[false, true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(buttonColor('First child').color, theme.colorScheme.surface.withOpacity(0.0)); + expect(buttonColor('Second child').color, selectedFillColor); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + fillColor: selectedFillColor, + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(buttonColor('First child').color, theme.colorScheme.surface.withOpacity(0.0)); + expect(buttonColor('Second child').color, theme.colorScheme.surface.withOpacity(0.0)); + }); + + testWidgets('Custom button fillColor - WidgetState', (WidgetTester tester) async { + Material buttonColor(String text) { + return tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.widgetWithText(Material, text)), + ); + } + + const Color selectedFillColor = Colors.orange; + const Color defaultFillColor = Colors.blue; + + Color getFillColor(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedFillColor; + } + return defaultFillColor; + } + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + fillColor: WidgetStateColor.resolveWith(getFillColor), + isSelected: const <bool>[false, true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(buttonColor('First child').color, defaultFillColor); + expect(buttonColor('Second child').color, selectedFillColor); + + // disabled + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + fillColor: WidgetStateColor.resolveWith(getFillColor), + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(buttonColor('First child').color, defaultFillColor); + expect(buttonColor('Second child').color, defaultFillColor); + }); + + testWidgets('Default InkWell colors - unselected', (WidgetTester tester) async { + final theme = ThemeData(); + final focusNode = FocusNode(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false], + onPressed: (int index) {}, + focusNodes: <FocusNode>[focusNode], + children: const <Widget>[Text('First child')], + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + await hoverGesture.moveTo(Offset.zero); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.04))); + + // splashColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); // The button is on hovered and pressed + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..circle(color: theme.colorScheme.onSurface.withOpacity(0.16))); + + await touchGesture.up(); + await tester.pumpAndSettle(); + await hoverGesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // focusColor + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.12))); + + await hoverGesture.removePointer(); + + focusNode.dispose(); + }); + + testWidgets('Default InkWell colors - selected', (WidgetTester tester) async { + final theme = ThemeData(); + final focusNode = FocusNode(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + focusNodes: <FocusNode>[focusNode], + children: const <Widget>[Text('First child')], + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.04))); + await hoverGesture.moveTo(Offset.zero); + + // splashColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); // The button is on hovered and pressed + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..circle(color: theme.colorScheme.primary.withOpacity(0.16))); + + await touchGesture.up(); + await tester.pumpAndSettle(); + await hoverGesture.moveTo(const Offset(0, 50)); + await tester.pumpAndSettle(); + + // focusColor + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.12))); + + await hoverGesture.removePointer(); + + focusNode.dispose(); + }); + + testWidgets('Custom InkWell colors', (WidgetTester tester) async { + const splashColor = Color(0xff4caf50); + const highlightColor = Color(0xffcddc39); + const hoverColor = Color(0xffffeb3b); + const focusColor = Color(0xffffff00); + final focusNode = FocusNode(); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + splashColor: splashColor, + highlightColor: highlightColor, + hoverColor: hoverColor, + focusColor: focusColor, + isSelected: const <bool>[true], + onPressed: (int index) {}, + focusNodes: <FocusNode>[focusNode], + children: const <Widget>[Text('First child')], + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // splashColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures; + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..circle(color: splashColor)); + + await touchGesture.up(); + await tester.pumpAndSettle(); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: hoverColor)); + await hoverGesture.moveTo(Offset.zero); + + // focusColor + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: focusColor)); + + await hoverGesture.removePointer(); + + focusNode.dispose(); + }); + + testWidgets('Default border width and border colors for enabled, selected and disabled states', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + const defaultBorderWidth = 1.0; + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ); + + RenderObject toggleButtonRenderObject; + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model + ..path() + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ), + ); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model + ..path() + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ), + ); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false], + children: const <Widget>[Text('First child')], + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model + ..path() + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: defaultBorderWidth, + ), + ); + }); + + testWidgets('Custom border width and border colors for enabled, selected and disabled states', ( + WidgetTester tester, + ) async { + const borderColor = Color(0xff4caf50); + const selectedBorderColor = Color(0xffcddc39); + const disabledBorderColor = Color(0xffffeb3b); + const customWidth = 2.0; + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + borderColor: borderColor, + borderWidth: customWidth, + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ); + + RenderObject toggleButtonRenderObject; + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model + ..path() + ..path(style: PaintingStyle.stroke, color: borderColor, strokeWidth: customWidth), + ); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + selectedBorderColor: selectedBorderColor, + borderWidth: customWidth, + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model + ..path() + ..path(style: PaintingStyle.stroke, color: selectedBorderColor, strokeWidth: customWidth), + ); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + disabledBorderColor: disabledBorderColor, + borderWidth: customWidth, + isSelected: const <bool>[false], + children: const <Widget>[Text('First child')], + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model + ..path() + ..path(style: PaintingStyle.stroke, color: disabledBorderColor, strokeWidth: customWidth), + ); + }); + + testWidgets('Height of segmented control is determined by tallest widget', ( + WidgetTester tester, + ) async { + final children = <Widget>[ + Container(constraints: const BoxConstraints.tightFor(height: 100.0)), + Container( + constraints: const BoxConstraints.tightFor(height: 400.0), // tallest widget + ), + Container(constraints: const BoxConstraints.tightFor(height: 200.0)), + ]; + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons(isSelected: const <bool>[false, true, false], children: children), + ), + ); + + final List<Widget> toggleButtons = tester.allWidgets.where((Widget widget) { + return widget.runtimeType.toString() == '_SelectToggleButton'; + }).toList(); + + for (var i = 0; i < toggleButtons.length; i++) { + final Rect rect = tester.getRect(find.byWidget(toggleButtons[i])); + expect(rect.height, 400.0 + 2 * _defaultBorderWidth); + } + }); + + testWidgets('Sizes of toggle buttons rebuilds with the correct dimensions', ( + WidgetTester tester, + ) async { + final children = <Widget>[ + Container(constraints: const BoxConstraints.tightFor(width: 100.0, height: 100.0)), + Container(constraints: const BoxConstraints.tightFor(width: 100.0, height: 100.0)), + Container(constraints: const BoxConstraints.tightFor(width: 100.0, height: 100.0)), + ]; + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons(isSelected: const <bool>[false, true, false], children: children), + ), + ); + + List<Widget> toggleButtons; + toggleButtons = tester.allWidgets.where((Widget widget) { + return widget.runtimeType.toString() == '_SelectToggleButton'; + }).toList(); + + for (var i = 0; i < toggleButtons.length; i++) { + final Rect rect = tester.getRect(find.byWidget(toggleButtons[i])); + expect(rect.height, 100.0 + 2 * _defaultBorderWidth); + + // Only the last button paints both leading and trailing borders. + // Other buttons only paint the leading border. + if (i == toggleButtons.length - 1) { + expect(rect.width, 100.0 + 2 * _defaultBorderWidth); + } else { + expect(rect.width, 100.0 + 1 * _defaultBorderWidth); + } + } + + final childrenRebuilt = <Widget>[ + Container(constraints: const BoxConstraints.tightFor(width: 200.0, height: 200.0)), + Container(constraints: const BoxConstraints.tightFor(width: 200.0, height: 200.0)), + Container(constraints: const BoxConstraints.tightFor(width: 200.0, height: 200.0)), + ]; + + // Update border width and widget sized to verify layout updates correctly + const customBorderWidth = 5.0; + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + borderWidth: customBorderWidth, + isSelected: const <bool>[false, true, false], + children: childrenRebuilt, + ), + ), + ); + + toggleButtons = tester.allWidgets.where((Widget widget) { + return widget.runtimeType.toString() == '_SelectToggleButton'; + }).toList(); + + // Only the last button paints both leading and trailing borders. + // Other buttons only paint the leading border. + for (var i = 0; i < toggleButtons.length; i++) { + final Rect rect = tester.getRect(find.byWidget(toggleButtons[i])); + expect(rect.height, 200.0 + 2 * customBorderWidth); + if (i == toggleButtons.length - 1) { + expect(rect.width, 200.0 + 2 * customBorderWidth); + } else { + expect(rect.width, 200.0 + 1 * customBorderWidth); + } + } + }); + + testWidgets('Material2 - ToggleButtons text baseline alignment', (WidgetTester tester) async { + // The font size must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + await tester.pumpWidget( + boilerplate( + theme: ThemeData(useMaterial3: false), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + ToggleButtons( + borderWidth: 5.0, + isSelected: const <bool>[false, true], + children: const <Widget>[ + Text('First child', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 8.0)), + Text('Second child', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 8.0)), + ], + ), + ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + textStyle: const TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0), + ), + child: const Text('Elevated Button'), + ), + const Text('Text', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 28.0)), + ], + ), + ), + ); + + // The test font extends 0.25 * fontSize below the baseline. + // So the three row elements line up like this: + // + // ToggleButton MaterialButton Text + // ------------------------------------ baseline + // 2.0 5.0 7.0 space below the baseline = 0.25 * fontSize + // ------------------------------------ widget text dy values + + final double firstToggleButtonDy = tester.getBottomLeft(find.text('First child')).dy; + final double secondToggleButtonDy = tester.getBottomLeft(find.text('Second child')).dy; + final double elevatedButtonDy = tester.getBottomLeft(find.text('Elevated Button')).dy; + final double textDy = tester.getBottomLeft(find.text('Text')).dy; + + expect(firstToggleButtonDy, secondToggleButtonDy); + expect(firstToggleButtonDy, elevatedButtonDy - 3.0); + expect(firstToggleButtonDy, textDy - 5.0); + }); + + testWidgets('Material3 - ToggleButtons text baseline alignment', (WidgetTester tester) async { + // The point size of the fonts must be a multiple of 4 until + // https://github.com/flutter/flutter/issues/122066 is resolved. + await tester.pumpWidget( + boilerplate( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: <Widget>[ + ToggleButtons( + borderWidth: 5.0, + isSelected: const <bool>[false, true], + children: const <Widget>[ + Text('First child', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 8.0)), + Text('Second child', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 8.0)), + ], + ), + ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + textStyle: const TextStyle(fontFamily: 'FlutterTest', fontSize: 20.0), + ), + child: const Text('Elevated Button'), + ), + const Text('Text', style: TextStyle(fontFamily: 'FlutterTest', fontSize: 28.0)), + ], + ), + ), + ); + + // The test font extends 0.25 * fontSize below the baseline. + // So the three row elements line up like this: + // + // ToggleButton MaterialButton Text + // ------------------------------------ baseline + // 2.0 5.0 7.0 space below the baseline = 0.25 * fontSize + // ------------------------------------ widget text dy values + + final double firstToggleButtonDy = tester.getBottomLeft(find.text('First child')).dy; + final double secondToggleButtonDy = tester.getBottomLeft(find.text('Second child')).dy; + final double elevatedButtonDy = tester.getBottomLeft(find.text('Elevated Button')).dy; + final double textDy = tester.getBottomLeft(find.text('Text')).dy; + + expect(firstToggleButtonDy, secondToggleButtonDy); + expect(firstToggleButtonDy, closeTo(elevatedButtonDy - 1.7, 0.1)); + expect(firstToggleButtonDy, closeTo(textDy - 9.7, 0.1)); + }); + + testWidgets('Directionality test', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ), + ); + + expect( + tester.getTopRight(find.text('First child')).dx < + tester.getTopRight(find.text('Second child')).dx, + isTrue, + ); + + await tester.pumpWidget( + Material( + child: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ), + ); + + expect( + tester.getTopRight(find.text('First child')).dx > + tester.getTopRight(find.text('Second child')).dx, + isTrue, + ); + }); + + testWidgets('Properly draws borders based on state', (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child'), Text('Third child')], + ), + ), + ); + + final List<RenderObject> toggleButtonRenderObject = tester.allRenderObjects + .where((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }) + .toSet() + .toList(); + + // The first button paints the leading, top and bottom sides with a path + expect( + toggleButtonRenderObject[0], + paints + // physical model + ..path() + // leading side, top and bottom - enabled + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + // The middle buttons paint a leading side path first, followed by a + // top and bottom side path + expect( + toggleButtonRenderObject[1], + paints + // physical model + ..path() + // leading side - selected + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ) + // top and bottom - selected + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + // The last button paints a leading side path first, followed by + // a trailing, top and bottom side path + expect( + toggleButtonRenderObject[2], + paints + // physical model + ..path() + // leading side - selected, since previous button is selected + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ) + // trailing side, top and bottom - enabled + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + }); + + testWidgets( + 'Properly draws borders based on state when direction is vertical and verticalDirection is down.', + (WidgetTester tester) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + direction: Axis.vertical, + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[ + Text('First child'), + Text('Second child'), + Text('Third child'), + ], + ), + ), + ); + + // The children should be laid out along vertical and the first child at top. + // The item height is icon height + default border width (48.0 + 1.0) pixels. + expect(tester.getCenter(find.text('First child')), const Offset(400.0, 251.0)); + expect(tester.getCenter(find.text('Second child')), const Offset(400.0, 300.0)); + expect(tester.getCenter(find.text('Third child')), const Offset(400.0, 349.0)); + + final List<RenderObject> toggleButtonRenderObject = tester.allRenderObjects + .where((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }) + .toSet() + .toList(); + + // The first button paints the left, top and right sides with a path. + expect( + toggleButtonRenderObject[0], + paints + // physical model + ..path() + // left side, top and right - enabled. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + // The middle buttons paint a top side path first, followed by a + // left and right side path. + expect( + toggleButtonRenderObject[1], + paints + // physical model + ..path() + // top side - selected. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ) + // left and right - selected. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + // The last button paints a top side path first, followed by + // a left, bottom and right side path + expect( + toggleButtonRenderObject[2], + paints + // physical model + ..path() + // top side - selected, since previous button is selected. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ) + // left side, bottom and right - enabled. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + }, + ); + + testWidgets('VerticalDirection test when direction is vertical.', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + direction: Axis.vertical, + verticalDirection: VerticalDirection.up, + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child'), Text('Third child')], + ), + ), + ); + + // The children should be laid out along vertical and the last child at top. + expect(tester.getCenter(find.text('Third child')), const Offset(400.0, 251.0)); + expect(tester.getCenter(find.text('Second child')), const Offset(400.0, 300.0)); + expect(tester.getCenter(find.text('First child')), const Offset(400.0, 349.0)); + }); + + testWidgets('Material2 - Tap target size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return boilerplate( + theme: ThemeData(useMaterial3: false), + tapTargetSize: tapTargetSize, + child: ToggleButtons( + key: key, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First'), Text('Second'), Text('Third')], + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(228.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(228.0, 48.0)); + }); + + testWidgets('Material3 - Tap target size is configurable by ThemeData.materialTapTargetSize', ( + WidgetTester tester, + ) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return boilerplate( + tapTargetSize: tapTargetSize, + child: ToggleButtons( + key: key, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First'), Text('Second'), Text('Third')], + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(232.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(232.0, 34.0)); + }); + + testWidgets('Material2 - Tap target size is configurable', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return boilerplate( + theme: ThemeData(useMaterial3: false), + child: ToggleButtons( + key: key, + tapTargetSize: tapTargetSize, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First'), Text('Second'), Text('Third')], + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(228.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(228.0, 34.0)); + }); + + testWidgets('Material3 - Tap target size is configurable', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return boilerplate( + child: ToggleButtons( + key: key, + tapTargetSize: tapTargetSize, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First'), Text('Second'), Text('Third')], + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(232.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(232.0, 34.0)); + }); + + testWidgets('Tap target size is configurable for vertical axis', (WidgetTester tester) async { + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return boilerplate( + child: ToggleButtons( + key: key, + tapTargetSize: tapTargetSize, + constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), + direction: Axis.vertical, + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('1'), Text('2'), Text('3')], + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(48.0, 100.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(34.0, 100.0)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/73725 + testWidgets('Material2 - Border radius paint test when there is only one button', ( + WidgetTester tester, + ) async { + final theme = ThemeData(useMaterial3: false); + await tester.pumpWidget( + boilerplate( + theme: theme, + child: RepaintBoundary( + child: ToggleButtons( + borderRadius: const BorderRadius.all(Radius.circular(7.0)), + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ), + ); + + // The only button should be laid out at the center of the screen. + expect(tester.getCenter(find.text('First child')), const Offset(400.0, 300.0)); + + final List<RenderObject> toggleButtonRenderObject = tester.allRenderObjects + .where((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }) + .toSet() + .toList(); + + // The first button paints the left, top and right sides with a path. + expect( + toggleButtonRenderObject[0], + paints + // physical model paints + ..path() + // left side, top and right - enabled. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('m2_toggle_buttons.oneButton.boardsPaint.png'), + ); + }); + + testWidgets('Material3 - Border radius paint test when there is only one button', ( + WidgetTester tester, + ) async { + final theme = ThemeData(); + await tester.pumpWidget( + boilerplate( + child: RepaintBoundary( + child: ToggleButtons( + borderRadius: const BorderRadius.all(Radius.circular(7.0)), + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ), + ); + + // The only button should be laid out at the center of the screen. + expect(tester.getCenter(find.text('First child')), const Offset(400.0, 300.0)); + + final List<RenderObject> toggleButtonRenderObject = tester.allRenderObjects + .where((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }) + .toSet() + .toList(); + + // The first button paints the left, top and right sides with a path. + expect( + toggleButtonRenderObject[0], + paints + // physical model paints + ..path() + // left side, top and right - enabled. + ..path( + style: PaintingStyle.stroke, + color: theme.colorScheme.onSurface.withOpacity(0.12), + strokeWidth: _defaultBorderWidth, + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('m3_toggle_buttons.oneButton.boardsPaint.png'), + ); + }); + + testWidgets('Material2 - Border radius paint test when Radius.x or Radius.y equal 0.0', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + theme: ThemeData(useMaterial3: false), + child: RepaintBoundary( + child: ToggleButtons( + borderRadius: const BorderRadius.only( + topRight: Radius.elliptical(10, 0), + topLeft: Radius.elliptical(0, 10), + bottomRight: Radius.elliptical(0, 10), + bottomLeft: Radius.elliptical(10, 0), + ), + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('m2_toggle_buttons.oneButton.boardsPaint2.png'), + ); + }); + + testWidgets('Material3 - Border radius paint test when Radius.x or Radius.y equal 0.0', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + child: RepaintBoundary( + child: ToggleButtons( + borderRadius: const BorderRadius.only( + topRight: Radius.elliptical(10, 0), + topLeft: Radius.elliptical(0, 10), + bottomRight: Radius.elliptical(0, 10), + bottomLeft: Radius.elliptical(10, 0), + ), + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ), + ); + + await expectLater( + find.byType(RepaintBoundary), + matchesGoldenFile('m3_toggle_buttons.oneButton.boardsPaint2.png'), + ); + }); + + testWidgets('ToggleButtons implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + ToggleButtons( + direction: Axis.vertical, + verticalDirection: VerticalDirection.up, + borderWidth: 3.0, + color: Colors.green, + selectedBorderColor: Colors.pink, + disabledColor: Colors.blue, + disabledBorderColor: Colors.yellow, + borderRadius: const BorderRadius.all(Radius.circular(7.0)), + isSelected: const <bool>[false, true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child'), Text('Third child')], + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'Buttons are enabled', + 'color: MaterialColor(primary value: ${const Color(0xff4caf50)})', + 'disabledColor: MaterialColor(primary value: ${const Color(0xff2196f3)})', + 'selectedBorderColor: MaterialColor(primary value: ${const Color(0xffe91e63)})', + 'disabledBorderColor: MaterialColor(primary value: ${const Color(0xffffeb3b)})', + 'borderRadius: BorderRadius.circular(7.0)', + 'borderWidth: 3.0', + 'direction: Axis.vertical', + 'verticalDirection: VerticalDirection.up', + ]); + }); + + testWidgets('ToggleButtons changes mouse cursor when the button is hovered', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ToggleButtons( + mouseCursor: SystemMouseCursors.text, + onPressed: (int index) {}, + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: tester.getCenter(find.text('First child'))); + + await tester.pump(); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.text, + ); + + // Test default cursor + await tester.pumpWidget( + boilerplate( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ToggleButtons( + onPressed: (int index) {}, + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, + ); + + // Test default cursor when disabled + await tester.pumpWidget( + boilerplate( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: ToggleButtons( + isSelected: const <bool>[false, true], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ); + + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + }); + + testWidgets('ToggleButtons focus, hover, and highlight elevations are 0', ( + WidgetTester tester, + ) async { + final focusNodes = <FocusNode>[FocusNode(), FocusNode()]; + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[true, false], + onPressed: (int index) {}, + focusNodes: focusNodes, + children: const <Widget>[Text('one'), Text('two')], + ), + ), + ); + + double toggleButtonElevation(String text) { + return tester.widget<Material>(find.widgetWithText(Material, text).first).elevation; + } + + // Default toggle button elevation + expect(toggleButtonElevation('one'), 0); // highlighted + expect(toggleButtonElevation('two'), 0); // not highlighted + + // Hovered button elevation + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(tester.getCenter(find.text('one'))); + await tester.pumpAndSettle(); + expect(toggleButtonElevation('one'), 0); + await hoverGesture.moveTo(tester.getCenter(find.text('two'))); + await tester.pumpAndSettle(); + expect(toggleButtonElevation('two'), 0); + + // Focused button elevation + focusNodes[0].requestFocus(); + await tester.pumpAndSettle(); + expect(focusNodes[0].hasFocus, isTrue); + expect(focusNodes[1].hasFocus, isFalse); + expect(toggleButtonElevation('one'), 0); + focusNodes[1].requestFocus(); + await tester.pumpAndSettle(); + expect(focusNodes[0].hasFocus, isFalse); + expect(focusNodes[1].hasFocus, isTrue); + expect(toggleButtonElevation('two'), 0); + + await hoverGesture.removePointer(); + + for (final n in focusNodes) { + n.dispose(); + } + }); + + testWidgets('Toggle buttons height matches MaterialTapTargetSize.padded height', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + children: const <Widget>[Icon(Icons.check), Icon(Icons.access_alarm), Icon(Icons.cake)], + ), + ), + ); + + final Rect firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.height, 48.0); + final Rect secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.height, 48.0); + final Rect thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.height, 48.0); + }); + + testWidgets('Toggle buttons constraints size does not affect minimum input padding', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/97302 + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + constraints: const BoxConstraints.tightFor(width: 86, height: 32), + children: const <Widget>[Icon(Icons.check), Icon(Icons.access_alarm), Icon(Icons.cake)], + ), + ), + ); + + // Button's height is constrained to `32.0`. + final Rect firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.height, 32.0); + final Rect secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.height, 32.0); + final Rect thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.height, 32.0); + + // While button's height is constrained to `32.0`, semantic node height + // should remain `48.0`, matching `MaterialTapTargetSize.padded` height (default). + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + rect: const Rect.fromLTRB(0.0, 0.0, 87.0, 48.0), + ), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Toggle buttons have correct semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerplate( + child: ToggleButtons( + isSelected: const <bool>[false, true], + onPressed: (int index) {}, + children: const <Widget>[Icon(Icons.check), Icon(Icons.access_alarm)], + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + TestSemantics( + flags: <SemanticsFlag>[ + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isChecked, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], + ), + ], + ), + ignoreId: true, + ignoreRect: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('ToggleButtons does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + child: Center( + child: SizedBox.shrink( + child: ToggleButtons( + isSelected: const <bool>[true], + children: const <Widget>[Text('X')], + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ToggleButtons)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/toggle_buttons_theme_test.dart b/packages/material_ui/test/material/toggle_buttons_theme_test.dart new file mode 100644 index 000000000000..ffc035985a92 --- /dev/null +++ b/packages/material_ui/test/material/toggle_buttons_theme_test.dart @@ -0,0 +1,584 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget boilerplate({required Widget child}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center(child: child), + ); + } + + TextStyle iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget<RichText>( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; + } + + test('ToggleButtonsThemeData copyWith, ==, hashCode basics', () { + expect(const ToggleButtonsThemeData(), const ToggleButtonsThemeData().copyWith()); + expect( + const ToggleButtonsThemeData().hashCode, + const ToggleButtonsThemeData().copyWith().hashCode, + ); + }); + + test('ToggleButtonsThemeData lerp special cases', () { + expect(ToggleButtonsThemeData.lerp(null, null, 0), null); + const data = ToggleButtonsThemeData(); + expect(identical(ToggleButtonsThemeData.lerp(data, data, 0.5), data), true); + }); + + test('ToggleButtonsThemeData defaults', () { + const themeData = ToggleButtonsThemeData(); + expect(themeData.textStyle, null); + expect(themeData.constraints, null); + expect(themeData.color, null); + expect(themeData.selectedColor, null); + expect(themeData.disabledColor, null); + expect(themeData.fillColor, null); + expect(themeData.focusColor, null); + expect(themeData.highlightColor, null); + expect(themeData.hoverColor, null); + expect(themeData.splashColor, null); + expect(themeData.borderColor, null); + expect(themeData.selectedBorderColor, null); + expect(themeData.disabledBorderColor, null); + expect(themeData.borderRadius, null); + expect(themeData.borderWidth, null); + + const theme = ToggleButtonsTheme(data: ToggleButtonsThemeData(), child: SizedBox()); + expect(theme.data.textStyle, null); + expect(theme.data.constraints, null); + expect(theme.data.color, null); + expect(theme.data.selectedColor, null); + expect(theme.data.disabledColor, null); + expect(theme.data.fillColor, null); + expect(theme.data.focusColor, null); + expect(theme.data.highlightColor, null); + expect(theme.data.hoverColor, null); + expect(theme.data.splashColor, null); + expect(theme.data.borderColor, null); + expect(theme.data.selectedBorderColor, null); + expect(theme.data.disabledBorderColor, null); + expect(theme.data.borderRadius, null); + expect(theme.data.borderWidth, null); + }); + + testWidgets('Default ToggleButtonsThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ToggleButtonsThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('ToggleButtonsThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const ToggleButtonsThemeData( + textStyle: TextStyle(fontSize: 10), + constraints: BoxConstraints(minHeight: 10.0, maxHeight: 20.0), + color: Color(0xfffffff0), + selectedColor: Color(0xfffffff1), + disabledColor: Color(0xfffffff2), + fillColor: Color(0xfffffff3), + focusColor: Color(0xfffffff4), + highlightColor: Color(0xfffffff5), + hoverColor: Color(0xfffffff6), + splashColor: Color(0xfffffff7), + borderColor: Color(0xfffffff8), + selectedBorderColor: Color(0xfffffff9), + disabledBorderColor: Color(0xfffffffa), + borderRadius: BorderRadius.all(Radius.circular(4.0)), + borderWidth: 2.0, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'textStyle.inherit: true', + 'textStyle.size: 10.0', + 'constraints: BoxConstraints(0.0<=w<=Infinity, 10.0<=h<=20.0)', + 'color: ${const Color(0xfffffff0)}', + 'selectedColor: ${const Color(0xfffffff1)}', + 'disabledColor: ${const Color(0xfffffff2)}', + 'fillColor: ${const Color(0xfffffff3)}', + 'focusColor: ${const Color(0xfffffff4)}', + 'highlightColor: ${const Color(0xfffffff5)}', + 'hoverColor: ${const Color(0xfffffff6)}', + 'splashColor: ${const Color(0xfffffff7)}', + 'borderColor: ${const Color(0xfffffff8)}', + 'selectedBorderColor: ${const Color(0xfffffff9)}', + 'disabledBorderColor: ${const Color(0xfffffffa)}', + 'borderRadius: BorderRadius.circular(4.0)', + 'borderWidth: 2.0', + ]); + }); + + testWidgets('Theme text style, except color, is applied', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + textStyle: TextStyle( + color: Colors.orange, + textBaseline: TextBaseline.ideographic, + fontSize: 20.0, + ), + ), + child: ToggleButtons( + isSelected: const <bool>[false, true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ), + ); + + TextStyle textStyle; + textStyle = tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, 'First child'), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + expect(textStyle.textBaseline, TextBaseline.ideographic); + expect(textStyle.fontSize, 20.0); + expect(textStyle.color, isNot(Colors.orange)); + + textStyle = tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, 'Second child'), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + expect(textStyle.textBaseline, TextBaseline.ideographic); + expect(textStyle.fontSize, 20.0); + expect(textStyle.color, isNot(Colors.orange)); + }); + + testWidgets('Custom BoxConstraints', (WidgetTester tester) async { + // Test for minimum constraints + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + constraints: BoxConstraints(minWidth: 50.0, minHeight: 60.0), + ), + child: ToggleButtons( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + children: const <Widget>[ + Icon(Icons.check), + Icon(Icons.access_alarm), + Icon(Icons.cake), + ], + ), + ), + ), + ), + ); + + Rect firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.width, 50.0); + expect(firstRect.height, 60.0); + Rect secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.width, 50.0); + expect(secondRect.height, 60.0); + Rect thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.width, 50.0); + expect(thirdRect.height, 60.0); + + // Test for maximum constraints + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + constraints: BoxConstraints(maxWidth: 20.0, maxHeight: 10.0), + ), + child: ToggleButtons( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + isSelected: const <bool>[false, false, false], + onPressed: (int index) {}, + children: const <Widget>[ + Icon(Icons.check), + Icon(Icons.access_alarm), + Icon(Icons.cake), + ], + ), + ), + ), + ), + ); + + firstRect = tester.getRect(find.byType(TextButton).at(0)); + expect(firstRect.width, 20.0); + expect(firstRect.height, 10.0); + secondRect = tester.getRect(find.byType(TextButton).at(1)); + expect(secondRect.width, 20.0); + expect(secondRect.height, 10.0); + thirdRect = tester.getRect(find.byType(TextButton).at(2)); + expect(thirdRect.width, 20.0); + expect(thirdRect.height, 10.0); + }); + + testWidgets('Theme text/icon colors for enabled, selected and disabled states', ( + WidgetTester tester, + ) async { + TextStyle buttonTextStyle(String text) { + return tester + .widget<DefaultTextStyle>( + find.descendant( + of: find.widgetWithText(TextButton, text), + matching: find.byType(DefaultTextStyle), + ), + ) + .style; + } + + final theme = ThemeData(); + const Color enabledColor = Colors.lime; + const Color selectedColor = Colors.green; + const Color disabledColor = Colors.yellow; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData(), + child: ToggleButtons( + color: enabledColor, + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[ + // This Row is used like this to test for both TextStyle + // and IconTheme for Text and Icon widgets respectively. + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ), + ), + ); + // Custom theme enabled color + expect(theme.colorScheme.onSurface, isNot(enabledColor)); + expect(buttonTextStyle('First child').color, enabledColor); + expect(iconStyle(tester, Icons.check).color, enabledColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData(selectedColor: selectedColor), + child: ToggleButtons( + color: enabledColor, + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Custom theme selected color + expect(theme.colorScheme.primary, isNot(selectedColor)); + expect(buttonTextStyle('First child').color, selectedColor); + expect(iconStyle(tester, Icons.check).color, selectedColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData(disabledColor: disabledColor), + child: ToggleButtons( + color: enabledColor, + isSelected: const <bool>[false], + children: const <Widget>[ + Row(children: <Widget>[Text('First child'), Icon(Icons.check)]), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Custom theme disabled color + expect(theme.disabledColor, isNot(disabledColor)); + expect(buttonTextStyle('First child').color, disabledColor); + expect(iconStyle(tester, Icons.check).color, disabledColor); + }); + + testWidgets('Theme button fillColor', (WidgetTester tester) async { + const Color customFillColor = Colors.green; + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData(fillColor: customFillColor), + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[ + Row(children: <Widget>[Text('First child')]), + ], + ), + ), + ), + ), + ); + + final Material material = tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)), + ); + expect(material.color, customFillColor); + expect(material.type, MaterialType.button); + }); + + testWidgets('Custom Theme button fillColor in different states', (WidgetTester tester) async { + Material buttonColor(String text) { + return tester.widget<Material>( + find.descendant(of: find.byType(TextButton), matching: find.widgetWithText(Material, text)), + ); + } + + const Color enabledFillColor = Colors.green; + const Color selectedFillColor = Colors.blue; + const Color disabledFillColor = Colors.yellow; + + Color getColor(Set<WidgetState> states) { + if (states.contains(WidgetState.selected)) { + return selectedFillColor; + } else if (states.contains(WidgetState.disabled)) { + return disabledFillColor; + } + return enabledFillColor; + } + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: ToggleButtonsThemeData(fillColor: WidgetStateColor.resolveWith(getColor)), + child: ToggleButtons( + isSelected: const <bool>[true, false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(buttonColor('First child').color, selectedFillColor); + expect(buttonColor('Second child').color, enabledFillColor); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: ToggleButtonsThemeData(fillColor: WidgetStateColor.resolveWith(getColor)), + child: ToggleButtons( + isSelected: const <bool>[true, false], + children: const <Widget>[Text('First child'), Text('Second child')], + ), + ), + ), + ), + ); + + expect(buttonColor('First child').color, disabledFillColor); + expect(buttonColor('Second child').color, disabledFillColor); + }); + + testWidgets('Theme InkWell colors', (WidgetTester tester) async { + const splashColor = Color(0xff4caf50); + const highlightColor = Color(0xffcddc39); + const hoverColor = Color(0xffffeb3b); + const focusColor = Color(0xffffff00); + final focusNode = FocusNode(); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + splashColor: splashColor, + highlightColor: highlightColor, + hoverColor: hoverColor, + focusColor: focusColor, + ), + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + focusNodes: <FocusNode>[focusNode], + children: const <Widget>[Text('First child')], + ), + ), + ), + ), + ); + + final Offset center = tester.getCenter(find.text('First child')); + + // splashColor + // highlightColor + final TestGesture touchGesture = await tester.createGesture(); + await touchGesture.down(center); + await tester.pumpAndSettle(); + + RenderObject inkFeatures; + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..circle(color: splashColor)); + + await touchGesture.up(); + await tester.pumpAndSettle(); + + // hoverColor + final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await hoverGesture.addPointer(); + await hoverGesture.moveTo(center); + await tester.pumpAndSettle(); + + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: hoverColor)); + await hoverGesture.moveTo(Offset.zero); + + // focusColor + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_RenderInkFeatures'; + }); + expect(inkFeatures, paints..rect(color: focusColor)); + + await hoverGesture.removePointer(); + + focusNode.dispose(); + }); + + testWidgets('Theme border width and border colors for enabled, selected and disabled states', ( + WidgetTester tester, + ) async { + const borderColor = Color(0xff4caf50); + const selectedBorderColor = Color(0xffcddc39); + const disabledBorderColor = Color(0xffffeb3b); + const customWidth = 2.0; + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData(borderColor: borderColor, borderWidth: customWidth), + child: ToggleButtons( + isSelected: const <bool>[false], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ), + ), + ); + + RenderObject toggleButtonRenderObject; + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model layer paint + ..path() + ..path(style: PaintingStyle.stroke, color: borderColor, strokeWidth: customWidth), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + selectedBorderColor: selectedBorderColor, + borderWidth: customWidth, + ), + child: ToggleButtons( + isSelected: const <bool>[true], + onPressed: (int index) {}, + children: const <Widget>[Text('First child')], + ), + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model layer paint + ..path() + ..path(style: PaintingStyle.stroke, color: selectedBorderColor, strokeWidth: customWidth), + ); + + await tester.pumpWidget( + Material( + child: boilerplate( + child: ToggleButtonsTheme( + data: const ToggleButtonsThemeData( + disabledBorderColor: disabledBorderColor, + borderWidth: customWidth, + ), + child: ToggleButtons( + isSelected: const <bool>[false], + children: const <Widget>[Text('First child')], + ), + ), + ), + ), + ); + + toggleButtonRenderObject = tester.allRenderObjects.firstWhere((RenderObject object) { + return object.runtimeType.toString() == '_SelectToggleButtonRenderObject'; + }); + expect( + toggleButtonRenderObject, + paints + // physical model layer paint + ..path() + ..path(style: PaintingStyle.stroke, color: disabledBorderColor, strokeWidth: customWidth), + ); + }); +} diff --git a/packages/material_ui/test/material/tooltip_test.dart b/packages/material_ui/test/material/tooltip_test.dart new file mode 100644 index 000000000000..64b62d8d86ec --- /dev/null +++ b/packages/material_ui/test/material/tooltip_test.dart @@ -0,0 +1,2100 @@ +// 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:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String tooltipText = 'TIP'; + +Finder _findTooltipContainer(String tooltipText) { + return find.ancestor(of: find.text(tooltipText), matching: find.byType(Container)); +} + +void main() { + testWidgets('Does tooltip end up in the right place - center', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 300.0, + top: 0.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 20.0, + padding: const EdgeInsets.all(5.0), + verticalOffset: 20.0, + preferBelow: false, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * o * y=0 + * | * }- 20.0 vertical offset, of which 10.0 is in the screen edge margin + * +----+ * \- (5.0 padding in height) + * | | * |- 20 height + * +----+ * /- (5.0 padding in height) + * * + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + final Offset tipInGlobal = tip.localToGlobal(tip.size.topCenter(Offset.zero)); + // The exact position of the left side depends on the font the test framework + // happens to pick, so we don't test that. + expect(tipInGlobal.dx, 300.0); + expect(tipInGlobal.dy, 20.0); + }); + + testWidgets('Does tooltip end up in the right place - center with padding outside overlay', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Padding( + padding: const EdgeInsets.all(20), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 300.0, + top: 0.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 20.0, + padding: const EdgeInsets.all(5.0), + verticalOffset: 20.0, + preferBelow: false, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /************************ 800x600 screen + * ________________ * }- 20.0 padding outside overlay + * | o | * y=0 + * | | | * }- 20.0 vertical offset, of which 10.0 is in the screen edge margin + * | +----+ | * \- (5.0 padding in height) + * | | | | * |- 20 height + * | +----+ | * /- (5.0 padding in height) + * |________________| * + * * } - 20.0 padding outside overlay + ************************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + final Offset tipInGlobal = tip.localToGlobal(tip.size.topCenter(Offset.zero)); + // The exact position of the left side depends on the font the test framework + // happens to pick, so we don't test that. + expect(tipInGlobal.dx, 320.0); + expect(tipInGlobal.dy, 40.0); + }); + + testWidgets('Material2 - Does tooltip end up in the right place - top left', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 0.0, + top: 0.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 20.0, + padding: const EdgeInsets.all(5.0), + verticalOffset: 20.0, + preferBelow: false, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + *o * y=0 + *| * }- 20.0 vertical offset, of which 10.0 is in the screen edge margin + *+----+ * \- (5.0 padding in height) + *| | * |- 20 height + *+----+ * /- (5.0 padding in height) + * * + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(24.0)); // 14.0 height + 5.0 padding * 2 (top, bottom) + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)), equals(const Offset(10.0, 20.0))); + }); + + testWidgets('Material3 - Does tooltip end up in the right place - top left', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 0.0, + top: 0.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 20.0, + padding: const EdgeInsets.all(5.0), + verticalOffset: 20.0, + preferBelow: false, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + *o * y=0 + *| * }- 20.0 vertical offset, of which 10.0 is in the screen edge margin + *+----+ * \- (5.0 padding in height) + *| | * |- 20 height + *+----+ * /- (5.0 padding in height) + * * + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(30.0)); // 20.0 height + 5.0 padding * 2 (top, bottom) + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)), equals(const Offset(10.0, 20.0))); + }); + + testWidgets('Does tooltip end up in the right place - center prefer above fits', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 100.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: false, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-100.0 height + * | * }-100.0 vertical offset + * o * y=300.0 + * * + * * + * * + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(100.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(100.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(200.0)); + }); + + testWidgets('Does tooltip end up in the right place - center prefer above does not fit', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 299.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 190.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: false, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + // we try to put it here but it doesn't fit: + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-190.0 height (starts at y=9.0) + * | * }-100.0 vertical offset + * o * y=299.0 + * * + * * + * * + *********************/ + + // so we put it here: + /********************* 800x600 screen + * * + * * + * o * y=299.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(399.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(589.0)); + }); + + testWidgets('Does tooltip end up in the right place - center prefer below fits', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 190.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: true, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(400.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(590.0)); + }); + + testWidgets('Material2 - Does tooltip end up in the right place - way off to the right', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 1600.0, + top: 300.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 10.0, + padding: EdgeInsets.zero, + verticalOffset: 10.0, + preferBelow: true, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * * y=300.0; target --> o + * ___| * }-10.0 vertical offset + * |___| * }-10.0 height + * * + * * }-10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(14.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(310.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dx, equals(790.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(324.0)); + }); + + testWidgets('Material3 - Does tooltip end up in the right place - way off to the right', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 1600.0, + top: 300.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 10.0, + padding: EdgeInsets.zero, + verticalOffset: 10.0, + preferBelow: true, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * * y=300.0; target --> o + * ___| * }-10.0 vertical offset + * |___| * }-10.0 height + * * + * * }-10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(20.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(310.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dx, equals(790.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(330.0)); + }); + + testWidgets('Material2 - Does tooltip end up in the right place - near the edge', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 780.0, + top: 300.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 10.0, + padding: EdgeInsets.zero, + verticalOffset: 10.0, + preferBelow: true, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * __| * }-10.0 vertical offset + * |___| * }-10.0 height + * * + * * }-10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(14.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(310.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dx, equals(790.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(324.0)); + }); + + testWidgets('Material3 - Does tooltip end up in the right place - near the edge', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 780.0, + top: 300.0, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + height: 10.0, + padding: EdgeInsets.zero, + verticalOffset: 10.0, + preferBelow: true, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * __| * }-10.0 vertical offset + * |___| * }-10.0 height + * * + * * }-10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(20.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(310.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dx, equals(790.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(330.0)); + }); + + testWidgets('Tooltip should be fully visible when MediaQuery.viewInsets > 0', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/23666 + Widget materialAppWithViewInsets(double viewInsetsHeight) { + final Widget scaffold = Scaffold( + body: const TextField(), + floatingActionButton: FloatingActionButton( + tooltip: tooltipText, + onPressed: () { + /* do nothing */ + }, + child: const Icon(Icons.add), + ), + ); + return MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: viewInsetsHeight)), + child: MaterialApp(useInheritedMediaQuery: true, home: scaffold), + ); + } + + // Start with MediaQuery.viewInsets.bottom = 0 + await tester.pumpWidget(materialAppWithViewInsets(0)); + + // Show FAB tooltip + final Finder fabFinder = find.byType(FloatingActionButton); + await tester.longPress(fabFinder); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(Tooltip), findsOneWidget); + + // FAB tooltip should be above FAB + RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + Offset fabTopRight = tester.getTopRight(fabFinder); + Offset tooltipTopRight = tip.localToGlobal(tip.size.topRight(Offset.zero)); + expect(tooltipTopRight.dy, lessThan(fabTopRight.dy)); + + // Simulate Keyboard opening (MediaQuery.viewInsets.bottom = 300)) + await tester.pumpWidget(materialAppWithViewInsets(300)); + // Wait for the tooltip to dismiss. + await tester.pump(const Duration(days: 1)); + await tester.pumpAndSettle(); + + // Show FAB tooltip + await tester.longPress(fabFinder); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(Tooltip), findsOneWidget); + + // FAB tooltip should still be above FAB + tip = tester.renderObject(_findTooltipContainer(tooltipText)); + fabTopRight = tester.getTopRight(fabFinder); + tooltipTopRight = tip.localToGlobal(tip.size.topRight(Offset.zero)); + expect(tooltipTopRight.dy, lessThan(fabTopRight.dy)); + }); + + testWidgets('Custom tooltip margin', (WidgetTester tester) async { + const customMarginValue = 10.0; + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: tooltipKey, + message: tooltipText, + padding: EdgeInsets.zero, + margin: const EdgeInsets.all(customMarginValue), + child: const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final Offset topLeftTipInGlobal = tester.getTopLeft(_findTooltipContainer(tooltipText)); + final Offset topLeftTooltipContentInGlobal = tester.getTopLeft(find.text(tooltipText)); + expect(topLeftTooltipContentInGlobal.dx, topLeftTipInGlobal.dx + customMarginValue); + expect(topLeftTooltipContentInGlobal.dy, topLeftTipInGlobal.dy + customMarginValue); + + final Offset topRightTipInGlobal = tester.getTopRight(_findTooltipContainer(tooltipText)); + final Offset topRightTooltipContentInGlobal = tester.getTopRight(find.text(tooltipText)); + expect(topRightTooltipContentInGlobal.dx, topRightTipInGlobal.dx - customMarginValue); + expect(topRightTooltipContentInGlobal.dy, topRightTipInGlobal.dy + customMarginValue); + + final Offset bottomLeftTipInGlobal = tester.getBottomLeft(_findTooltipContainer(tooltipText)); + final Offset bottomLeftTooltipContentInGlobal = tester.getBottomLeft(find.text(tooltipText)); + expect(bottomLeftTooltipContentInGlobal.dx, bottomLeftTipInGlobal.dx + customMarginValue); + expect(bottomLeftTooltipContentInGlobal.dy, bottomLeftTipInGlobal.dy - customMarginValue); + + final Offset bottomRightTipInGlobal = tester.getBottomRight(_findTooltipContainer(tooltipText)); + final Offset bottomRightTooltipContentInGlobal = tester.getBottomRight(find.text(tooltipText)); + expect(bottomRightTooltipContentInGlobal.dx, bottomRightTipInGlobal.dx - customMarginValue); + expect(bottomRightTooltipContentInGlobal.dy, bottomRightTipInGlobal.dy - customMarginValue); + }); + + testWidgets('Material2 - Default tooltip message textStyle - light', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Tooltip( + key: tooltipKey, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.white); + expect(textStyle.fontFamily, 'Roboto'); + expect(textStyle.decoration, TextDecoration.none); + expect( + textStyle.debugLabel, + '((englishLike bodyMedium 2014).merge(blackMountainView bodyMedium)).copyWith', + ); + }); + + testWidgets('Material3 - Default tooltip message textStyle - light', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.white); + expect(textStyle.fontFamily, 'Roboto'); + expect(textStyle.decoration, TextDecoration.none); + expect( + textStyle.debugLabel, + '((englishLike bodyMedium 2021).merge((blackMountainView bodyMedium).apply)).copyWith', + ); + }); + + testWidgets('Material2 - Default tooltip message textStyle - dark', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false, brightness: Brightness.dark), + home: Tooltip( + key: tooltipKey, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.black); + expect(textStyle.fontFamily, 'Roboto'); + expect(textStyle.decoration, TextDecoration.none); + expect( + textStyle.debugLabel, + '((englishLike bodyMedium 2014).merge(whiteMountainView bodyMedium)).copyWith', + ); + }); + + testWidgets('Material3 - Default tooltip message textStyle - dark', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Tooltip( + key: tooltipKey, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.black); + expect(textStyle.fontFamily, 'Roboto'); + expect(textStyle.decoration, TextDecoration.none); + expect( + textStyle.debugLabel, + '((englishLike bodyMedium 2021).merge((whiteMountainView bodyMedium).apply)).copyWith', + ); + }); + + testWidgets('Custom tooltip message textStyle', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + textStyle: const TextStyle(color: Colors.orange, decoration: TextDecoration.underline), + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.orange); + expect(textStyle.fontFamily, null); + expect(textStyle.decoration, TextDecoration.underline); + }); + + testWidgets('Custom tooltip message textAlign', (WidgetTester tester) async { + Future<void> pumpTooltipWithTextAlign({TextAlign? textAlign}) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + textAlign: textAlign, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + } + + // Default value should be TextAlign.start + await pumpTooltipWithTextAlign(); + TextAlign textAlign = tester.widget<Text>(find.text(tooltipText)).textAlign!; + expect(textAlign, TextAlign.start); + + await pumpTooltipWithTextAlign(textAlign: TextAlign.center); + textAlign = tester.widget<Text>(find.text(tooltipText)).textAlign!; + expect(textAlign, TextAlign.center); + + await pumpTooltipWithTextAlign(textAlign: TextAlign.end); + textAlign = tester.widget<Text>(find.text(tooltipText)).textAlign!; + expect(textAlign, TextAlign.end); + }); + + testWidgets('Tooltip overlay wrapped with a non-fallback DefaultTextStyle widget', ( + WidgetTester tester, + ) async { + // A Material widget is needed as an ancestor of the Text widget. + // It is invalid to have text in a Material application that + // does not have a Material ancestor. + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester + .widget<DefaultTextStyle>( + find.ancestor(of: find.text(tooltipText), matching: find.byType(DefaultTextStyle)).first, + ) + .style; + + // The default fallback text style results in a text with a + // double underline of Color(0xffffff00). + expect(textStyle.decoration, isNot(TextDecoration.underline)); + expect(textStyle.decorationColor, isNot(const Color(0xffffff00))); + expect(textStyle.decorationStyle, isNot(TextDecorationStyle.double)); + }); + + testWidgets('Material2 - Does tooltip end up with the right default size, shape, and color', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: tooltipKey, + message: tooltipText, + child: const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.0)); + expect( + tip, + paints..rrect( + rrect: RRect.fromRectAndRadius(tip.paintBounds, const Radius.circular(4.0)), + color: const Color(0xe6616161), + ), + ); + + final Container tooltipContainer = tester.firstWidget<Container>( + _findTooltipContainer(tooltipText), + ); + expect(tooltipContainer.padding, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0)); + }); + + testWidgets('Material3 - Does tooltip end up with the right default size, shape, and color', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: tooltipKey, + message: tooltipText, + child: const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.75)); + expect( + tip, + paints..rrect( + rrect: RRect.fromRectAndRadius(tip.paintBounds, const Radius.circular(4.0)), + color: const Color(0xe6616161), + ), + ); + + final Container tooltipContainer = tester.firstWidget<Container>( + _findTooltipContainer(tooltipText), + ); + expect(tooltipContainer.padding, const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0)); + }); + + testWidgets( + 'Material2 - Tooltip default size, shape, and color test for Desktop', + (WidgetTester tester) async { + // Regressing test for https://github.com/flutter/flutter/issues/68601 + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Tooltip(key: tooltipKey, message: tooltipText, child: const SizedBox.shrink()), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderParagraph tooltipRenderParagraph = tester.renderObject<RenderParagraph>( + find.text(tooltipText), + ); + expect(tooltipRenderParagraph.textSize.height, equals(12.0)); + + final RenderBox tooltipRenderBox = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tooltipRenderBox.size.height, equals(24.0)); + expect( + tooltipRenderBox, + paints..rrect( + rrect: RRect.fromRectAndRadius(tooltipRenderBox.paintBounds, const Radius.circular(4.0)), + color: const Color(0xe6616161), + ), + ); + + final Container tooltipContainer = tester.firstWidget<Container>( + _findTooltipContainer(tooltipText), + ); + expect(tooltipContainer.padding, const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'Material3 - Tooltip default size, shape, and color test for Desktop', + (WidgetTester tester) async { + // Regressing test for https://github.com/flutter/flutter/issues/68601 + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: Tooltip(key: tooltipKey, message: tooltipText, child: const SizedBox.shrink()), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderParagraph tooltipRenderParagraph = tester.renderObject<RenderParagraph>( + find.text(tooltipText), + ); + expect(tooltipRenderParagraph.textSize.height, equals(17.0)); + + final RenderBox tooltipRenderBox = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tooltipRenderBox.size.height, equals(25.0)); + expect( + tooltipRenderBox, + paints..rrect( + rrect: RRect.fromRectAndRadius(tooltipRenderBox.paintBounds, const Radius.circular(4.0)), + color: const Color(0xe6616161), + ), + ); + + final Container tooltipContainer = tester.firstWidget<Container>( + _findTooltipContainer(tooltipText), + ); + expect(tooltipContainer.padding, const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0)); + }, + variant: const TargetPlatformVariant(<TargetPlatform>{ + TargetPlatform.macOS, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('Material2 - Can tooltip decoration be customized', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: tooltipKey, + decoration: customDecoration, + message: tooltipText, + child: const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.0)); + expect(tip, paints..rrect(color: const Color(0x80800000))); + }); + + testWidgets('Material3 - Can tooltip decoration be customized', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: tooltipKey, + decoration: customDecoration, + message: tooltipText, + child: const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.75)); + expect(tip, paints..rrect(color: const Color(0x80800000))); + }); + + testWidgets('Material2 - Tooltip text scales with textScaler', (WidgetTester tester) async { + Widget buildApp(String text, {required TextScaler textScaler}) { + return MaterialApp( + theme: ThemeData(useMaterial3: false), + home: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Center( + child: Tooltip( + message: text, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(tooltipText, textScaler: TextScaler.noScaling)); + await tester.longPress(find.byType(Tooltip)); + expect(find.text(tooltipText), findsOneWidget); + expect(tester.getSize(find.text(tooltipText)), equals(const Size(42.0, 14.0))); + RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(32.0)); + + await tester.pumpWidget(buildApp(tooltipText, textScaler: const TextScaler.linear(4.0))); + await tester.longPress(find.byType(Tooltip)); + expect(find.text(tooltipText), findsOneWidget); + expect(tester.getSize(find.text(tooltipText)), equals(const Size(168.0, 56.0))); + tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(64.0)); + }); + + testWidgets('Material3 - Tooltip text scales with textScaleFactor', (WidgetTester tester) async { + Widget buildApp(String text, {required TextScaler textScaler}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: textScaler), + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute<void>( + builder: (BuildContext context) { + return Center( + child: Tooltip( + message: text, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ); + }, + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildApp(tooltipText, textScaler: TextScaler.noScaling)); + await tester.longPress(find.byType(Tooltip)); + expect(find.text(tooltipText), findsOneWidget); + expect(tester.getSize(find.text(tooltipText)).width, equals(42.75)); + expect(tester.getSize(find.text(tooltipText)).height, equals(20.0)); + RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(32.0)); + + await tester.pumpWidget(buildApp(tooltipText, textScaler: const TextScaler.linear(4.0))); + await tester.longPress(find.byType(Tooltip)); + expect(find.text(tooltipText), findsOneWidget); + expect(tester.getSize(find.text(tooltipText)).width, equals(168.75)); + expect(tester.getSize(find.text(tooltipText)).height, equals(80.0)); + tip = tester.renderObject(_findTooltipContainer(tooltipText)); + expect(tip.size.height, equals(88.0)); + }); + + testWidgets('Tooltip text displays with richMessage', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + const textSpan1Text = 'I am a rich tooltip message. '; + const textSpan2Text = 'I am another span of a rich tooltip message'; + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + richMessage: const TextSpan( + text: textSpan1Text, + children: <InlineSpan>[TextSpan(text: textSpan2Text)], + ), + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RichText richText = tester.widget<RichText>(find.byType(RichText)); + expect(richText.text.toPlainText(), equals('$textSpan1Text$textSpan2Text')); + }); + + testWidgets('Tooltip throws assertion error when both message and richMessage are specified', ( + WidgetTester tester, + ) async { + expect(() { + MaterialApp( + home: Tooltip( + message: 'I am a tooltip message.', + richMessage: const TextSpan( + text: 'I am a rich tooltip.', + children: <InlineSpan>[TextSpan(text: 'I am another span of a rich tooltip.')], + ), + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ); + }, throwsA(const TypeMatcher<AssertionError>())); + }); + + testWidgets('default Tooltip debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + const Tooltip(message: 'message').debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>['"message"']); + }); + testWidgets('default Tooltip debugFillProperties with richMessage', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + const Tooltip( + richMessage: TextSpan( + text: 'This is a ', + children: <InlineSpan>[TextSpan(text: 'richMessage')], + ), + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>['"This is a richMessage"']); + }); + testWidgets('Tooltip implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + // Not checking controller, inputFormatters, focusNode + const Tooltip( + key: ValueKey<String>('foo'), + message: 'message', + decoration: BoxDecoration(), + waitDuration: Duration(seconds: 1), + showDuration: Duration(seconds: 2), + padding: EdgeInsets.zero, + margin: EdgeInsets.all(5.0), + height: 100.0, + excludeFromSemantics: true, + preferBelow: false, + verticalOffset: 50.0, + triggerMode: TooltipTriggerMode.manual, + enableFeedback: true, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + '"message"', + 'height: 100.0', + 'padding: EdgeInsets.zero', + 'margin: EdgeInsets.all(5.0)', + 'vertical offset: 50.0', + 'position: above', + 'semantics: excluded', + 'wait duration: 0:00:01.000000', + 'show duration: 0:00:02.000000', + 'triggerMode: TooltipTriggerMode.manual', + 'enableFeedback: true', + ]); + }); + + testWidgets('Tooltip should not be shown with empty message (with child)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Tooltip(message: '', child: Text(tooltipText)), + ), + ); + expect(find.text(tooltipText), findsOneWidget); + expect(find.byType(Tooltip), findsOneWidget); + + await tester.longPress(find.text(tooltipText)); + expect(find.byType(Tooltip), findsOneWidget); + expect(find.byType(RawTooltip), findsNothing); + }); + + testWidgets('Tooltip should not be shown with empty message (without child)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Tooltip(message: ''))); + expect(find.byType(Tooltip), findsOneWidget); + expect(find.byType(SizedBox), findsOneWidget); + expect(find.byType(RawTooltip), findsNothing); + }); + + testWidgets('Tooltip should not ignore users tap on richMessage', (WidgetTester tester) async { + var isTapped = false; + final recognizer = TapGestureRecognizer(); + addTearDown(recognizer.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + richMessage: TextSpan( + text: tooltipText, + recognizer: recognizer + ..onTap = () { + isTapped = true; + }, + ), + showDuration: const Duration(seconds: 5), + triggerMode: TooltipTriggerMode.tap, + child: const Icon(Icons.refresh), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + expect(find.text(tooltipText), findsNothing); + + await _testGestureTap(tester, tooltip); + final Finder textSpan = find.text(tooltipText); + expect(textSpan, findsOneWidget); + + await _testGestureTap(tester, textSpan); + expect(isTapped, isTrue); + }); + + testWidgets('Tooltip does not rebuild for fade in / fade out animation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: SizedBox.square( + dimension: 10.0, + child: Tooltip( + message: tooltipText, + waitDuration: Duration(seconds: 1), + triggerMode: TooltipTriggerMode.longPress, + child: SizedBox.expand(), + ), + ), + ), + ), + ); + final TooltipState tooltipState = tester.state(find.byType(Tooltip)); + final element = tooltipState.context as Element; + // The Tooltip widget itself is almost stateless thus doesn't need + // rebuilding. + expect(element.dirty, isFalse); + + expect(tooltipState.ensureTooltipVisible(), isTrue); + expect(element.dirty, isFalse); + await tester.pump(const Duration(seconds: 1)); + expect(element.dirty, isFalse); + + expect(Tooltip.dismissAllToolTips(), isTrue); + expect(element.dirty, isFalse); + await tester.pump(const Duration(seconds: 1)); + expect(element.dirty, isFalse); + }); + + testWidgets('Tooltip is not selectable', (WidgetTester tester) async { + const tooltipText = 'AAAAAAAAAAAAAAAAAAAAAAA'; + String? selectedText; + await tester.pumpWidget( + MaterialApp( + home: SelectionArea( + onSelectionChanged: (SelectedContent? content) { + selectedText = content?.plainText; + }, + child: const Center( + child: Column( + children: <Widget>[ + Text('Select Me'), + Tooltip( + message: tooltipText, + waitDuration: Duration(seconds: 1), + triggerMode: TooltipTriggerMode.longPress, + child: SizedBox.square(dimension: 50), + ), + ], + ), + ), + ), + ), + ); + + final TooltipState tooltipState = tester.state(find.byType(Tooltip)); + + final Rect textRect = tester.getRect(find.text('Select Me')); + final TestGesture gesture = await tester.startGesture( + Alignment.centerLeft.alongSize(textRect.size) + textRect.topLeft, + ); + // Drag from centerLeft to centerRight to select the text. + await tester.pump(const Duration(seconds: 1)); + await gesture.moveTo(Alignment.centerRight.alongSize(textRect.size) + textRect.topLeft); + await tester.pump(); + + tooltipState.ensureTooltipVisible(); + await tester.pump(); + // Make sure the tooltip becomes visible. + expect(find.text(tooltipText), findsOneWidget); + assert(selectedText != null); + + final Rect tooltipTextRect = tester.getRect(find.text(tooltipText)); + // Now drag from centerLeft to centerRight to select the tooltip text. + await gesture.moveTo( + Alignment.centerLeft.alongSize(tooltipTextRect.size) + tooltipTextRect.topLeft, + ); + await tester.pump(); + await gesture.moveTo( + Alignment.centerRight.alongSize(tooltipTextRect.size) + tooltipTextRect.topLeft, + ); + await tester.pump(); + + expect(selectedText, isNot(contains('A'))); + }); + + testWidgets('Tooltip mouse cursor behavior', (WidgetTester tester) async { + const SystemMouseCursor customCursor = SystemMouseCursors.grab; + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Tooltip( + message: tooltipText, + mouseCursor: customCursor, + child: SizedBox.square(dimension: 50), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + pointer: 1, + ); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pump(); + expect( + RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), + SystemMouseCursors.basic, + ); + + final Offset chip = tester.getCenter(find.byType(Tooltip)); + await gesture.moveTo(chip); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor); + }); + + testWidgets('Tooltip overlay ignores pointer by default when passing simple message', ( + WidgetTester tester, + ) async { + const tooltipMessage = 'Tooltip message'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Tooltip( + message: tooltipMessage, + child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')), + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.text('Hover me'); + expect(buttonFinder, findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + + final Finder tooltipFinder = find.text(tooltipMessage); + expect(tooltipFinder, findsOneWidget); + + final Finder ignorePointerFinder = find.byType(IgnorePointer); + + final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last); + expect(ignorePointer.ignoring, isTrue); + + await gesture.removePointer(); + }); + + testWidgets( + "Tooltip overlay with simple message doesn't ignore pointer when passing ignorePointer: false", + (WidgetTester tester) async { + const tooltipMessage = 'Tooltip message'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Tooltip( + ignorePointer: false, + message: tooltipMessage, + child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')), + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.text('Hover me'); + expect(buttonFinder, findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + + final Finder tooltipFinder = find.text(tooltipMessage); + expect(tooltipFinder, findsOneWidget); + + final Finder ignorePointerFinder = find.byType(IgnorePointer); + + final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last); + expect(ignorePointer.ignoring, isFalse); + + await gesture.removePointer(); + }, + ); + + testWidgets("Tooltip overlay doesn't ignore pointer by default when passing rich message", ( + WidgetTester tester, + ) async { + const InlineSpan richMessage = TextSpan( + children: <InlineSpan>[ + TextSpan( + text: 'Rich ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: 'Tooltip'), + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Tooltip( + richMessage: richMessage, + child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')), + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.text('Hover me'); + expect(buttonFinder, findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + + final Finder tooltipFinder = find.textContaining('Rich Tooltip'); + expect(tooltipFinder, findsOneWidget); + + final Finder ignorePointerFinder = find.byType(IgnorePointer); + + final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last); + expect(ignorePointer.ignoring, isFalse); + + await gesture.removePointer(); + }); + + testWidgets('Tooltip overlay with richMessage ignores pointer when passing ignorePointer: true', ( + WidgetTester tester, + ) async { + const InlineSpan richMessage = TextSpan( + children: <InlineSpan>[ + TextSpan( + text: 'Rich ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: 'Tooltip'), + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Tooltip( + ignorePointer: true, + richMessage: richMessage, + child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')), + ), + ), + ), + ), + ); + + final Finder buttonFinder = find.text('Hover me'); + expect(buttonFinder, findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(buttonFinder)); + await tester.pumpAndSettle(); + + final Finder tooltipFinder = find.textContaining('Rich Tooltip'); + expect(tooltipFinder, findsOneWidget); + + final Finder ignorePointerFinder = find.byType(IgnorePointer); + + final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last); + expect(ignorePointer.ignoring, isTrue); + + await gesture.removePointer(); + }); + + testWidgets('Tooltip should pass its default text style down to widget spans', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + richMessage: const WidgetSpan(child: Text(tooltipText)), + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); + + final Finder defaultTextStyle = find.ancestor( + of: find.text(tooltipText), + matching: find.byType(DefaultTextStyle), + ); + final DefaultTextStyle textStyle = tester.widget<DefaultTextStyle>(defaultTextStyle.first); + expect(textStyle.style.color, Colors.white); + expect(textStyle.style.fontFamily, 'Roboto'); + expect(textStyle.style.decoration, TextDecoration.none); + expect( + textStyle.style.debugLabel, + '((englishLike bodyMedium 2021).merge((blackMountainView bodyMedium).apply)).copyWith', + ); + }); + + testWidgets('Tooltip should apply provided text style to rich messages', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + const expectedTextStyle = TextStyle(color: Colors.orange); + await tester.pumpWidget( + MaterialApp( + home: Tooltip( + key: tooltipKey, + richMessage: const TextSpan(text: tooltipText), + textStyle: expectedTextStyle, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + final Finder defaultTextStyleFinder = find.ancestor( + of: find.text(tooltipText), + matching: find.byType(DefaultTextStyle), + ); + final TextStyle defaultTextStyle = tester + .widget<DefaultTextStyle>(defaultTextStyleFinder.first) + .style; + expect(textStyle, same(expectedTextStyle)); + expect(defaultTextStyle, same(expectedTextStyle)); + }); + + testWidgets('Tooltip respects and prefers the given constraints over theme constraints', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + const themeConstraints = BoxConstraints.tightFor(width: 300, height: 150); + const tooltipConstraints = BoxConstraints.tightFor(width: 500, height: 250); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tooltipTheme: const TooltipThemeData(constraints: themeConstraints)), + home: Tooltip( + key: tooltipKey, + message: tooltipText, + constraints: tooltipConstraints, + padding: EdgeInsets.zero, + child: const ColoredBox(color: Colors.green), + ), + ), + ); + + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); + + final Finder textAncestors = find.ancestor( + of: find.text(tooltipText), + matching: find.byWidgetPredicate((_) => true), + ); + expect(tester.element(textAncestors.first).size, equals(tooltipConstraints.biggest)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/167359. + testWidgets('Tooltip does not show while transitioning from another page', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + leading: const Center( + child: Tooltip(message: 'Hello', child: Text('World')), + ), + ), + body: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () => Navigator.push( + context, + CupertinoPageRoute<void>( + builder: (BuildContext context) => + Scaffold(appBar: AppBar(title: const Text('Second Page'))), + ), + ), + child: const Text('Go to Second Page'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go to Second Page')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await tester.tap(find.byType(BackButton)); + await tester.pump(const Duration(milliseconds: 250)); + await gesture.moveTo(tester.getCenter(find.text('World'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/169741. + testWidgets( + 'Tooltip does not show while transitioning from another route with secondary animation', + (WidgetTester tester) async { + final observer = TransitionDurationObserver(); + + await tester.pumpWidget( + MaterialApp( + navigatorObservers: <NavigatorObserver>[observer], + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () => Navigator.push( + context, + CupertinoPageRoute<void>( + builder: (BuildContext context) => Scaffold( + appBar: AppBar( + leading: const Tooltip(message: 'Hello', child: Text('World')), + ), + body: TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return Scaffold(appBar: AppBar(title: const Text('Third Page'))); + }, + ), + ); + }, + child: const Text('Go to Third Page'), + ), + ), + ), + ), + child: const Text('Go to Second Page'), + ); + }, + ), + ), + ), + ); + + expect(find.text('Go to Second Page'), findsOneWidget); + await tester.tap(find.text('Go to Second Page')); + await tester.pumpAndSettle(); + expect(find.text('Go to Third Page'), findsOneWidget); + + await tester.tap(find.text('Go to Third Page')); + await tester.pumpAndSettle(); + expect(find.text('Third Page'), findsOneWidget); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await tester.tap(find.byType(BackButton)); + await observer.pumpPastTransition(tester); + await gesture.moveTo(tester.getCenter(find.text('World'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + ); + + /// This is a regression test for https://github.com/flutter/flutter/issues/168545 + testWidgets('The Tooltip on the ModalBottomSheet can still be displayed after showMenu.', ( + WidgetTester tester, + ) async { + final navigatorKey = GlobalKey<NavigatorState>(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Placeholder()), + ), + ); + showModalBottomSheet<void>( + context: navigatorKey.currentContext!, + builder: (_) { + return const Center( + child: Tooltip(message: 'Hello', child: Text('World')), + ); + }, + ); + await tester.pumpAndSettle(); + showMenu<void>( + context: navigatorKey.currentContext!, + items: <PopupMenuEntry<int>>[const PopupMenuItem<int>(value: 0, child: Text('item 1'))], + position: RelativeRect.fill, + ); + await tester.pumpAndSettle(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('World'))); + await tester.pumpAndSettle(); + expect(find.text('Hello'), findsOne); + await gesture.removePointer(); + }); + + testWidgets('Custom tooltip positioning - positionDelegate parameter', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Tooltip( + message: tooltipText, + positionDelegate: (TooltipPositionContext context) { + // Align on top right of box with bottom left of tooltip. + return Offset( + context.target.dx + (context.targetSize.width / 2), + context.target.dy - (context.targetSize.height / 2) - context.tooltipSize.height, + ); + }, + child: const SizedBox(width: 50, height: 50), + ), + ), + ), + ), + ); + + await tester.longPress(find.byType(Tooltip)); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text(tooltipText), findsOneWidget); + + final Offset targetCenter = tester.getCenter(find.byType(Tooltip)); + final Offset tooltipPosition = tester.getTopLeft(_findTooltipContainer(tooltipText)); + + // The tooltip should be positioned at target + (25, -25-32). + expect(tooltipPosition.dx, closeTo(targetCenter.dx + 25, 5.0)); + expect(tooltipPosition.dy, closeTo(targetCenter.dy - 25 - 32, 5.0)); + }); + + testWidgets('Tooltip does not crash at zero area', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Tooltip(key: key, message: 'X'), + ), + ), + ); + expect(tester.getSize(find.byType(Tooltip)), Size.zero); + key.currentState!.ensureTooltipVisible(); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(Tooltip)), Size.zero); + expect(find.text('X'), findsOne); + }); + + testWidgets('Tooltip should disappear even if mouse is hovering over the tooltip overlay', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Tooltip(message: tooltipText, child: Text('Hover me')), + ), + ), + ); + + // Hover over the target widget. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + final Finder targetFinder = find.text('Hover me'); + await gesture.moveTo(tester.getCenter(targetFinder)); + await tester.pump(); + + // Wait for the tooltip to actually appear. + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text(tooltipText), findsOneWidget); + + // Move the mouse to the tooltip overlay. + final Finder tooltipOverlayFinder = find.text(tooltipText); + await gesture.moveTo(tester.getCenter(tooltipOverlayFinder)); + await tester.pumpAndSettle(); + + // Verify the tooltip overlay is no longer displayed. + expect(find.text(tooltipText), findsNothing); + }); +} + +Future<void> _testGestureTap(WidgetTester tester, Finder tooltip) async { + await tester.tap(tooltip); + await tester.pump(const Duration(milliseconds: 10)); +} diff --git a/packages/material_ui/test/material/tooltip_theme_test.dart b/packages/material_ui/test/material/tooltip_theme_test.dart new file mode 100644 index 000000000000..f533d52fc06a --- /dev/null +++ b/packages/material_ui/test/material/tooltip_theme_test.dart @@ -0,0 +1,1546 @@ +// 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/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +const String tooltipText = 'TIP'; +const double _customPaddingValue = 10.0; + +void main() { + test('TooltipThemeData copyWith, ==, hashCode basics', () { + expect(const TooltipThemeData(), const TooltipThemeData().copyWith()); + expect(const TooltipThemeData().hashCode, const TooltipThemeData().copyWith().hashCode); + }); + + test('TooltipThemeData lerp special cases', () { + expect(TooltipThemeData.lerp(null, null, 0), null); + const data = TooltipThemeData(); + expect(identical(TooltipThemeData.lerp(data, data, 0.5), data), true); + }); + + test('TooltipThemeData defaults', () { + const theme = TooltipThemeData(); + expect(theme.height, null); + expect(theme.constraints, null); + expect(theme.padding, null); + expect(theme.verticalOffset, null); + expect(theme.preferBelow, null); + expect(theme.excludeFromSemantics, null); + expect(theme.decoration, null); + expect(theme.textStyle, null); + expect(theme.textAlign, null); + expect(theme.waitDuration, null); + expect(theme.showDuration, null); + expect(theme.exitDuration, null); + expect(theme.triggerMode, null); + expect(theme.enableFeedback, null); + }); + + testWidgets('Default TooltipThemeData debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const TooltipThemeData().debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[]); + }); + + testWidgets('TooltipThemeData implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + const wait = Duration(milliseconds: 100); + const show = Duration(milliseconds: 200); + const exit = Duration(milliseconds: 100); + const TooltipTriggerMode triggerMode = TooltipTriggerMode.longPress; + const enableFeedback = true; + const TooltipThemeData( + height: 15.0, + padding: EdgeInsets.all(20.0), + verticalOffset: 10.0, + preferBelow: false, + excludeFromSemantics: true, + decoration: BoxDecoration(color: Color(0xffffffff)), + textStyle: TextStyle(decoration: TextDecoration.underline), + textAlign: TextAlign.center, + waitDuration: wait, + showDuration: show, + exitDuration: exit, + triggerMode: triggerMode, + enableFeedback: enableFeedback, + ).debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>[ + 'height: 15.0', + 'padding: EdgeInsets.all(20.0)', + 'vertical offset: 10.0', + 'position: above', + 'semantics: excluded', + 'decoration: BoxDecoration(color: ${const Color(0xffffffff)})', + 'textStyle: TextStyle(inherit: true, decoration: TextDecoration.underline)', + 'textAlign: TextAlign.center', + 'wait duration: $wait', + 'show duration: $show', + 'exit duration: $exit', + 'triggerMode: $triggerMode', + 'enableFeedback: true', + ]); + }); + + testWidgets( + 'Tooltip verticalOffset, preferBelow; center prefer above fits - ThemeData.tooltipTheme', + (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + height: 100.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: false, + ), + ), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-100.0 height + * | * }-100.0 vertical offset + * o * y=300.0 + * * + * * + * * + *********************/ + + final tip = tester.renderObject(find.text(tooltipText)).parent! as RenderBox; + expect(tip.size.height, equals(100.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(100.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(200.0)); + }, + ); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer above fits - TooltipTheme', ( + WidgetTester tester, + ) async { + final key = GlobalKey<TooltipState>(); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData( + height: 100.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: false, + ), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-100.0 height + * | * }-100.0 vertical offset + * o * y=300.0 + * * + * * + * * + *********************/ + + final tip = tester.renderObject(find.text(tooltipText)).parent! as RenderBox; + expect(tip.size.height, equals(100.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(100.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(200.0)); + }); + + testWidgets( + 'Tooltip verticalOffset, preferBelow; center prefer above does not fit - ThemeData.tooltipTheme', + (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + height: 190.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: false, + ), + ), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 299.0, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + // we try to put it here but it doesn't fit: + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-190.0 height (starts at y=9.0) + * | * }-100.0 vertical offset + * o * y=299.0 + * * + * * + * * + *********************/ + + // so we put it here: + /********************* 800x600 screen + * * + * * + * o * y=299.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final tip = tester.renderObject(find.text(tooltipText)).parent! as RenderBox; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(399.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(589.0)); + }, + ); + + testWidgets( + 'Tooltip verticalOffset, preferBelow; center prefer above does not fit - TooltipTheme', + (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData( + height: 190.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: false, + ), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 299.0, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + // we try to put it here but it doesn't fit: + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-190.0 height (starts at y=9.0) + * | * }-100.0 vertical offset + * o * y=299.0 + * * + * * + * * + *********************/ + + // so we put it here: + /********************* 800x600 screen + * * + * * + * o * y=299.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final tip = tester.renderObject(find.text(tooltipText)).parent! as RenderBox; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(399.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(589.0)); + }, + ); + + testWidgets( + 'Tooltip verticalOffset, preferBelow; center preferBelow fits - ThemeData.tooltipTheme', + (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + height: 190.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: true, + ), + ), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pumpAndSettle(); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final tip = tester.renderObject(find.text(tooltipText)).parent! as RenderBox; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(400.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(590.0)); + }, + ); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer below fits - TooltipTheme', ( + WidgetTester tester, + ) async { + final key = GlobalKey<TooltipState>(); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData( + height: 190.0, + padding: EdgeInsets.zero, + verticalOffset: 100.0, + preferBelow: true, + ), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: <Widget>[ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pumpAndSettle(); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final tip = tester.renderObject(find.text(tooltipText)).parent! as RenderBox; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(400.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(590.0)); + }); + + testWidgets('Tooltip margin - ThemeData', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + padding: EdgeInsets.zero, + margin: EdgeInsets.all(_customPaddingValue), + ), + ), + child: Tooltip(key: key, message: tooltipText, child: const SizedBox.shrink()), + ); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final tip = + tester.renderObject(find.text(tooltipText)).parent!.parent!.parent!.parent!.parent! + as RenderBox; + final RenderBox tooltipContent = tester.renderObject(find.text(tooltipText)); + + final Offset topLeftTipInGlobal = tip.localToGlobal(tip.size.topLeft(Offset.zero)); + final Offset topLeftTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.topLeft(Offset.zero), + ); + expect(topLeftTooltipContentInGlobal.dx, topLeftTipInGlobal.dx + _customPaddingValue); + expect(topLeftTooltipContentInGlobal.dy, topLeftTipInGlobal.dy + _customPaddingValue); + + final Offset topRightTipInGlobal = tip.localToGlobal(tip.size.topRight(Offset.zero)); + final Offset topRightTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.topRight(Offset.zero), + ); + expect(topRightTooltipContentInGlobal.dx, topRightTipInGlobal.dx - _customPaddingValue); + expect(topRightTooltipContentInGlobal.dy, topRightTipInGlobal.dy + _customPaddingValue); + + final Offset bottomLeftTipInGlobal = tip.localToGlobal(tip.size.bottomLeft(Offset.zero)); + final Offset bottomLeftTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.bottomLeft(Offset.zero), + ); + expect(bottomLeftTooltipContentInGlobal.dx, bottomLeftTipInGlobal.dx + _customPaddingValue); + expect(bottomLeftTooltipContentInGlobal.dy, bottomLeftTipInGlobal.dy - _customPaddingValue); + + final Offset bottomRightTipInGlobal = tip.localToGlobal(tip.size.bottomRight(Offset.zero)); + final Offset bottomRightTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.bottomRight(Offset.zero), + ); + expect(bottomRightTooltipContentInGlobal.dx, bottomRightTipInGlobal.dx - _customPaddingValue); + expect(bottomRightTooltipContentInGlobal.dy, bottomRightTipInGlobal.dy - _customPaddingValue); + }); + + testWidgets('Tooltip margin - TooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return TooltipTheme( + data: const TooltipThemeData( + padding: EdgeInsets.zero, + margin: EdgeInsets.all(_customPaddingValue), + ), + child: Tooltip(key: key, message: tooltipText, child: const SizedBox.shrink()), + ); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final tip = + tester.renderObject(find.text(tooltipText)).parent!.parent!.parent!.parent!.parent! + as RenderBox; + final RenderBox tooltipContent = tester.renderObject(find.text(tooltipText)); + + final Offset topLeftTipInGlobal = tip.localToGlobal(tip.size.topLeft(Offset.zero)); + final Offset topLeftTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.topLeft(Offset.zero), + ); + expect(topLeftTooltipContentInGlobal.dx, topLeftTipInGlobal.dx + _customPaddingValue); + expect(topLeftTooltipContentInGlobal.dy, topLeftTipInGlobal.dy + _customPaddingValue); + + final Offset topRightTipInGlobal = tip.localToGlobal(tip.size.topRight(Offset.zero)); + final Offset topRightTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.topRight(Offset.zero), + ); + expect(topRightTooltipContentInGlobal.dx, topRightTipInGlobal.dx - _customPaddingValue); + expect(topRightTooltipContentInGlobal.dy, topRightTipInGlobal.dy + _customPaddingValue); + + final Offset bottomLeftTipInGlobal = tip.localToGlobal(tip.size.bottomLeft(Offset.zero)); + final Offset bottomLeftTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.bottomLeft(Offset.zero), + ); + expect(bottomLeftTooltipContentInGlobal.dx, bottomLeftTipInGlobal.dx + _customPaddingValue); + expect(bottomLeftTooltipContentInGlobal.dy, bottomLeftTipInGlobal.dy - _customPaddingValue); + + final Offset bottomRightTipInGlobal = tip.localToGlobal(tip.size.bottomRight(Offset.zero)); + final Offset bottomRightTooltipContentInGlobal = tooltipContent.localToGlobal( + tooltipContent.size.bottomRight(Offset.zero), + ); + expect(bottomRightTooltipContentInGlobal.dx, bottomRightTipInGlobal.dx - _customPaddingValue); + expect(bottomRightTooltipContentInGlobal.dy, bottomRightTipInGlobal.dy - _customPaddingValue); + }); + + testWidgets('Tooltip message textStyle - ThemeData.tooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + textStyle: TextStyle(color: Colors.orange, decoration: TextDecoration.underline), + ), + ), + home: Tooltip( + key: key, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.orange); + expect(textStyle.fontFamily, null); + expect(textStyle.decoration, TextDecoration.underline); + }); + + testWidgets('Tooltip message textStyle - TooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData(), + child: Tooltip( + textStyle: const TextStyle(color: Colors.orange, decoration: TextDecoration.underline), + key: key, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget<Text>(find.text(tooltipText)).style!; + expect(textStyle.color, Colors.orange); + expect(textStyle.fontFamily, null); + expect(textStyle.decoration, TextDecoration.underline); + }); + + testWidgets('Tooltip message textAlign - TooltipTheme', (WidgetTester tester) async { + Future<void> pumpTooltipWithTextAlign({TextAlign? textAlign}) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(textAlign: textAlign), + child: Tooltip( + key: tooltipKey, + message: tooltipText, + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ), + ); + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + } + + // Default value should be TextAlign.start + await pumpTooltipWithTextAlign(); + TextAlign textAlign = tester.widget<Text>(find.text(tooltipText)).textAlign!; + expect(textAlign, TextAlign.start); + + await pumpTooltipWithTextAlign(textAlign: TextAlign.center); + textAlign = tester.widget<Text>(find.text(tooltipText)).textAlign!; + expect(textAlign, TextAlign.center); + + await pumpTooltipWithTextAlign(textAlign: TextAlign.end); + textAlign = tester.widget<Text>(find.text(tooltipText)).textAlign!; + expect(textAlign, TextAlign.end); + }); + + testWidgets('Material2 - Tooltip decoration - ThemeData.tooltipTheme', ( + WidgetTester tester, + ) async { + final key = GlobalKey<TooltipState>(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + tooltipTheme: const TooltipThemeData(decoration: customDecoration), + ), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip(key: key, message: tooltipText, child: const SizedBox.shrink()); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final tip = + tester.renderObject(find.text(tooltipText)).parent!.parent!.parent!.parent! as RenderBox; + + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.0)); + expect(tip, paints..rrect(color: const Color(0x80800000))); + }); + + testWidgets('Material3 - Tooltip decoration - ThemeData.tooltipTheme', ( + WidgetTester tester, + ) async { + final key = GlobalKey<TooltipState>(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tooltipTheme: const TooltipThemeData(decoration: customDecoration)), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip(key: key, message: tooltipText, child: const SizedBox.shrink()); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final tip = + tester.renderObject(find.text(tooltipText)).parent!.parent!.parent!.parent! as RenderBox; + + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.75)); + expect(tip, paints..rrect(color: const Color(0x80800000))); + }); + + testWidgets('Material2 - Tooltip decoration - TooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: false), + home: TooltipTheme( + data: const TooltipThemeData(decoration: customDecoration), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip(key: key, message: tooltipText, child: const SizedBox.shrink()); + }, + ), + ], + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final tip = + tester.renderObject(find.text(tooltipText)).parent!.parent!.parent!.parent! as RenderBox; + + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.0)); + expect(tip, paints..rrect(color: const Color(0x80800000))); + }); + + testWidgets('Material3 - Tooltip decoration - TooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData(decoration: customDecoration), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip(key: key, message: tooltipText, child: const SizedBox.shrink()); + }, + ), + ], + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final tip = + tester.renderObject(find.text(tooltipText)).parent!.parent!.parent!.parent! as RenderBox; + + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.75)); + expect(tip, paints..rrect(color: const Color(0x80800000))); + }); + + testWidgets('Tooltip height and padding - ThemeData.tooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + const customTooltipHeight = 100.0; + const customPaddingVal = 20.0; + + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + height: customTooltipHeight, + padding: EdgeInsets.all(customPaddingVal), + ), + ), + home: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip(key: key, message: tooltipText); + }, + ), + ], + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pumpAndSettle(); + + final RenderBox tip = tester.renderObject( + find.ancestor( + of: find.text(tooltipText), + matching: find + .byType(Padding) + .first, // select [Tooltip.padding] instead of [Tooltip.margin] + ), + ); + final RenderBox content = tester.renderObject( + find.ancestor(of: find.text(tooltipText), matching: find.byType(Center)), + ); + + expect(tip.size.height, equals(customTooltipHeight)); + expect(content.size.height, equals(customTooltipHeight - 2 * customPaddingVal)); + expect(content.size.width, equals(tip.size.width - 2 * customPaddingVal)); + }); + + testWidgets('Tooltip height and padding - TooltipTheme', (WidgetTester tester) async { + final key = GlobalKey<TooltipState>(); + const customTooltipHeight = 100.0; + const customPaddingValue = 20.0; + late final OverlayEntry entry; + addTearDown( + () => entry + ..remove() + ..dispose(), + ); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData( + height: customTooltipHeight, + padding: EdgeInsets.all(customPaddingValue), + ), + child: Overlay( + initialEntries: <OverlayEntry>[ + entry = OverlayEntry( + builder: (BuildContext context) { + return Tooltip(key: key, message: tooltipText); + }, + ), + ], + ), + ), + ), + ); + key.currentState!.ensureTooltipVisible(); + await tester.pumpAndSettle(); + + final RenderBox tip = tester.renderObject( + find.ancestor( + of: find.text(tooltipText), + matching: find + .byType(Padding) + .first, // select [Tooltip.padding] instead of [Tooltip.margin] + ), + ); + final RenderBox content = tester.renderObject( + find.ancestor(of: find.text(tooltipText), matching: find.byType(Center)), + ); + + expect(tip.size.height, equals(customTooltipHeight)); + expect(content.size.height, equals(customTooltipHeight - 2 * customPaddingValue)); + expect(content.size.width, equals(tip.size.width - 2 * customPaddingValue)); + }); + + testWidgets('Tooltip waitDuration - ThemeData.tooltipTheme', (WidgetTester tester) async { + const customWaitDuration = Duration(milliseconds: 500); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(tooltipTheme: const TooltipThemeData(waitDuration: customWaitDuration)), + child: const Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsNothing); // Should not appear yet + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsOneWidget); // Should appear after customWaitDuration + + await gesture.moveTo(Offset.zero); + await tester.pump(); + + // Wait for it to disappear. + await tester.pump( + const Duration(milliseconds: 100), + ); // Should disappear after default exitDuration + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip waitDuration - TooltipTheme', (WidgetTester tester) async { + const customWaitDuration = Duration(milliseconds: 500); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(waitDuration: customWaitDuration), + child: Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsNothing); // Should not appear yet + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsOneWidget); // Should appear after customWaitDuration + + await gesture.moveTo(Offset.zero); + await tester.pump(); + + // Wait for it to disappear. + await tester.pump( + const Duration(milliseconds: 100), + ); // Should disappear after default exitDuration + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip showDuration - ThemeData.tooltipTheme', (WidgetTester tester) async { + const customShowDuration = Duration(milliseconds: 3000); + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(tooltipTheme: const TooltipThemeData(showDuration: customShowDuration)), + child: const Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gesture.up(); + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2000)); // Tooltip should remain + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); // Tooltip should fade out after + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip showDuration - TooltipTheme', (WidgetTester tester) async { + const customShowDuration = Duration(milliseconds: 3000); + await tester.pumpWidget( + const MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(showDuration: customShowDuration), + child: Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gesture.up(); + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2000)); // Tooltip should remain + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); // Tooltip should fade out after + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip exitDuration - ThemeData.tooltipTheme', (WidgetTester tester) async { + const customExitDuration = Duration(milliseconds: 500); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(tooltipTheme: const TooltipThemeData(exitDuration: customExitDuration)), + child: const Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + expect(find.text(tooltipText), findsOneWidget); + + await gesture.moveTo(Offset.zero); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsOneWidget); + + // Wait for it to disappear. + await tester.pump(customExitDuration); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip exitDuration - TooltipTheme', (WidgetTester tester) async { + const customExitDuration = Duration(milliseconds: 500); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(exitDuration: customExitDuration), + child: Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + expect(find.text(tooltipText), findsOneWidget); + + await gesture.moveTo(Offset.zero); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsOneWidget); + + // Wait for it to disappear. + await tester.pump(customExitDuration); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip triggerMode - ThemeData.triggerMode', (WidgetTester tester) async { + const TooltipTriggerMode triggerMode = TooltipTriggerMode.tap; + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData(tooltipTheme: const TooltipThemeData(triggerMode: triggerMode)), + child: const Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip)); + await gesture.up(); + await tester.pump(); + expect(find.text(tooltipText), findsOneWidget); // Tooltip should show immediately after tap + }); + + testWidgets('Tooltip triggerMode - TooltipTheme', (WidgetTester tester) async { + const TooltipTriggerMode triggerMode = TooltipTriggerMode.tap; + await tester.pumpWidget( + const MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(triggerMode: triggerMode), + child: Center( + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip)); + await gesture.up(); + await tester.pump(); + expect(find.text(tooltipText), findsOneWidget); // Tooltip should show immediately after tap + }); + + testWidgets('Semantics included by default - ThemeData.tooltipTheme', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Center( + child: Tooltip(message: 'Foo', child: Text('Bar')), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + tooltip: 'Foo', + label: 'Bar', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreId: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics included by default - TooltipTheme', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(), + child: Center( + child: Tooltip(message: 'Foo', child: Text('Bar')), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + tooltip: 'Foo', + label: 'Bar', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreId: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics excluded - ThemeData.tooltipTheme', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tooltipTheme: const TooltipThemeData(excludeFromSemantics: true)), + home: const Center( + child: Tooltip(message: 'Foo', child: Text('Bar')), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics(label: 'Bar', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreId: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics excluded - TooltipTheme', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + const MaterialApp( + home: TooltipTheme( + data: TooltipThemeData(excludeFromSemantics: true), + child: Center( + child: Tooltip(message: 'Foo', child: Text('Bar')), + ), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: <TestSemantics>[ + TestSemantics.rootChild( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics(label: 'Bar', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreId: true, + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('has semantic events by default - ThemeData.tooltipTheme', ( + WidgetTester tester, + ) async { + final semanticEvents = <dynamic>[]; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvents.add(message); + }, + ); + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Center( + child: Tooltip( + message: 'Foo', + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ), + ); + + await tester.longPress(find.byType(Tooltip)); + final RenderObject object = tester.firstRenderObject(find.byType(Tooltip)); + + expect( + semanticEvents, + unorderedEquals(<dynamic>[ + <String, dynamic>{ + 'type': 'longPress', + 'nodeId': findDebugSemantics(object).id, + 'data': <String, dynamic>{}, + }, + <String, dynamic>{ + 'type': 'tooltip', + 'data': <String, dynamic>{'message': 'Foo'}, + }, + ]), + ); + semantics.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('has semantic events by default - TooltipTheme', (WidgetTester tester) async { + final semanticEvents = <dynamic>[]; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + (dynamic message) async { + semanticEvents.add(message); + }, + ); + final semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + data: const TooltipThemeData(), + child: Center( + child: Tooltip( + message: 'Foo', + child: Container(width: 100.0, height: 100.0, color: Colors.green[500]), + ), + ), + ), + ), + ); + + await tester.longPress(find.byType(Tooltip)); + final RenderObject object = tester.firstRenderObject(find.byType(Tooltip)); + + expect( + semanticEvents, + unorderedEquals(<dynamic>[ + <String, dynamic>{ + 'type': 'longPress', + 'nodeId': findDebugSemantics(object).id, + 'data': <String, dynamic>{}, + }, + <String, dynamic>{ + 'type': 'tooltip', + 'data': <String, dynamic>{'message': 'Foo'}, + }, + ]), + ); + semantics.dispose(); + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler<dynamic>( + SystemChannels.accessibility, + null, + ); + }); + + testWidgets('default Tooltip debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + + const Tooltip(message: 'message').debugFillProperties(builder); + + final List<String> description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, <String>['"message"']); + }); + + testWidgets('Tooltip respects constraints from the ambient theme', (WidgetTester tester) async { + final tooltipKey = GlobalKey<TooltipState>(); + const themeConstraints = BoxConstraints.tightFor(width: 300, height: 150); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tooltipTheme: const TooltipThemeData(constraints: themeConstraints)), + home: Tooltip( + key: tooltipKey, + message: tooltipText, + padding: EdgeInsets.zero, + child: const ColoredBox(color: Colors.green), + ), + ), + ); + + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(const Duration(seconds: 2)); + + final Finder textAncestors = find.ancestor( + of: find.text(tooltipText), + matching: find.byWidgetPredicate((_) => true), + ); + expect(tester.element(textAncestors.first).size, equals(themeConstraints.biggest)); + }); +} + +SemanticsNode findDebugSemantics(RenderObject object) { + return object.debugSemantics ?? findDebugSemantics(object.parent!); +} diff --git a/packages/material_ui/test/material/tooltip_visibility_test.dart b/packages/material_ui/test/material/tooltip_visibility_test.dart new file mode 100644 index 000000000000..86bab5aef746 --- /dev/null +++ b/packages/material_ui/test/material/tooltip_visibility_test.dart @@ -0,0 +1,216 @@ +// 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:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String tooltipText = 'TIP'; + +void main() { + testWidgets( + 'Tooltip does not build MouseRegion when mouse is detected and in TooltipVisibility with visibility = false', + (WidgetTester tester) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: TooltipVisibility( + visible: false, + child: Tooltip(message: tooltipText, child: SizedBox(width: 100.0, height: 100.0)), + ), + ), + ); + + expect( + find.descendant(of: find.byType(RawTooltip), matching: find.byType(MouseRegion)), + findsNothing, + ); + }, + ); + + testWidgets('Tooltip does not show when hovered when in TooltipVisibility with visible = false', ( + WidgetTester tester, + ) async { + const Duration waitDuration = Duration.zero; + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: TooltipVisibility( + visible: false, + child: Tooltip( + message: tooltipText, + waitDuration: waitDuration, + child: SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + // Wait for it to appear. + await tester.pump(waitDuration); + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip shows when hovered when in TooltipVisibility with visible = true', ( + WidgetTester tester, + ) async { + const Duration waitDuration = Duration.zero; + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) { + return gesture.removePointer(); + } + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: TooltipVisibility( + visible: true, + child: Tooltip( + message: tooltipText, + waitDuration: waitDuration, + child: SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + // Wait for it to appear. + await tester.pump(waitDuration); + expect(find.text(tooltipText), findsOneWidget); + + // Wait for it to disappear. + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets( + 'Tooltip does not build GestureDetector when in TooltipVisibility with visibility = false', + (WidgetTester tester) async { + await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, false); + + expect(find.byType(GestureDetector), findsNothing); + }, + ); + + testWidgets( + 'Tooltip triggers on tap when trigger mode is tap and in TooltipVisibility with visible = true', + (WidgetTester tester) async { + await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, true); + + final Finder tooltip = find.byType(Tooltip); + expect(find.text(tooltipText), findsNothing); + + await testGestureTap(tester, tooltip); + expect(find.text(tooltipText), findsOneWidget); + }, + ); + + testWidgets('Tooltip does not trigger manually when in TooltipVisibility with visible = false', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: TooltipVisibility( + visible: false, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ); + + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(); + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip triggers manually when in TooltipVisibility with visible = true', ( + WidgetTester tester, + ) async { + final tooltipKey = GlobalKey<TooltipState>(); + await tester.pumpWidget( + MaterialApp( + home: TooltipVisibility( + visible: true, + child: Tooltip( + key: tooltipKey, + message: tooltipText, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ); + + tooltipKey.currentState?.ensureTooltipVisible(); + await tester.pump(); + expect(find.text(tooltipText), findsOneWidget); + }); +} + +Future<void> setWidgetForTooltipMode( + WidgetTester tester, + TooltipTriggerMode triggerMode, + bool visibility, +) async { + await tester.pumpWidget( + MaterialApp( + home: TooltipVisibility( + visible: visibility, + child: Tooltip( + message: tooltipText, + triggerMode: triggerMode, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ); +} + +Future<void> testGestureTap(WidgetTester tester, Finder tooltip) async { + await tester.tap(tooltip); + await tester.pump(const Duration(milliseconds: 10)); +} diff --git a/packages/material_ui/test/material/typography_test.dart b/packages/material_ui/test/material/typography_test.dart new file mode 100644 index 000000000000..1cd519174b75 --- /dev/null +++ b/packages/material_ui/test/material/typography_test.dart @@ -0,0 +1,435 @@ +// 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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Typography is defined for all target platforms', () { + for (final TargetPlatform platform in TargetPlatform.values) { + final typography = Typography.material2018(platform: platform); + expect(typography, isNotNull, reason: 'null typography for $platform'); + expect(typography.black, isNotNull, reason: 'null black typography for $platform'); + expect(typography.white, isNotNull, reason: 'null white typography for $platform'); + } + }); + + test('Typography lerp special cases', () { + final typography = Typography(); + expect(identical(Typography.lerp(typography, typography, 0.5), typography), true); + }); + + test('Typography on non-Apple platforms defaults to the correct font', () { + expect(Typography.material2018().black.titleLarge!.fontFamily, 'Roboto'); + expect( + Typography.material2018(platform: TargetPlatform.fuchsia).black.titleLarge!.fontFamily, + 'Roboto', + ); + expect( + Typography.material2018(platform: TargetPlatform.linux).black.titleLarge!.fontFamily, + 'Roboto', + ); + expect( + Typography.material2018(platform: TargetPlatform.linux).black.titleLarge!.fontFamilyFallback, + <String>['Ubuntu', 'Adwaita Sans', 'Cantarell', 'DejaVu Sans', 'Liberation Sans', 'Arial'], + ); + expect( + Typography.material2018(platform: TargetPlatform.windows).black.titleLarge!.fontFamily, + 'Segoe UI', + ); + expect(Typography.material2018().white.titleLarge!.fontFamily, 'Roboto'); + expect( + Typography.material2018(platform: TargetPlatform.fuchsia).white.titleLarge!.fontFamily, + 'Roboto', + ); + expect( + Typography.material2018(platform: TargetPlatform.linux).white.titleLarge!.fontFamily, + 'Roboto', + ); + expect( + Typography.material2018(platform: TargetPlatform.linux).white.titleLarge!.fontFamilyFallback, + <String>['Ubuntu', 'Adwaita Sans', 'Cantarell', 'DejaVu Sans', 'Liberation Sans', 'Arial'], + ); + expect( + Typography.material2018(platform: TargetPlatform.windows).white.titleLarge!.fontFamily, + 'Segoe UI', + ); + }); + + // Ref: https://developer.apple.com/design/human-interface-guidelines/typography/ + final Matcher isSanFranciscoDisplayFont = predicate((TextStyle s) { + return s.fontFamily == 'CupertinoSystemDisplay'; + }, 'Uses SF Display font'); + + final Matcher isSanFranciscoTextFont = predicate((TextStyle s) { + return s.fontFamily == 'CupertinoSystemText'; + }, 'Uses SF Text font'); + + final Matcher isMacOSSanFranciscoMetaFont = predicate((TextStyle s) { + return s.fontFamily == '.AppleSystemUIFont'; + }, 'Uses macOS system meta-font'); + + test('Typography on iOS defaults to the correct SF font family based on size', () { + final typography = Typography.material2018(platform: TargetPlatform.iOS); + for (final textTheme in <TextTheme>[typography.black, typography.white]) { + expect(textTheme.displayLarge, isSanFranciscoDisplayFont); + expect(textTheme.displayMedium, isSanFranciscoDisplayFont); + expect(textTheme.displaySmall, isSanFranciscoDisplayFont); + expect(textTheme.headlineLarge, isSanFranciscoDisplayFont); + expect(textTheme.headlineMedium, isSanFranciscoDisplayFont); + expect(textTheme.headlineSmall, isSanFranciscoDisplayFont); + expect(textTheme.titleLarge, isSanFranciscoDisplayFont); + expect(textTheme.titleMedium, isSanFranciscoTextFont); + expect(textTheme.titleSmall, isSanFranciscoTextFont); + expect(textTheme.bodyLarge, isSanFranciscoTextFont); + expect(textTheme.bodyMedium, isSanFranciscoTextFont); + expect(textTheme.bodySmall, isSanFranciscoTextFont); + expect(textTheme.labelLarge, isSanFranciscoTextFont); + expect(textTheme.labelMedium, isSanFranciscoTextFont); + expect(textTheme.labelSmall, isSanFranciscoTextFont); + } + }); + + test('Typography on macOS defaults to the system UI meta-font', () { + final typography = Typography.material2018(platform: TargetPlatform.macOS); + for (final textTheme in <TextTheme>[typography.black, typography.white]) { + expect(textTheme.displayLarge, isMacOSSanFranciscoMetaFont); + expect(textTheme.displayMedium, isMacOSSanFranciscoMetaFont); + expect(textTheme.displaySmall, isMacOSSanFranciscoMetaFont); + expect(textTheme.headlineLarge, isMacOSSanFranciscoMetaFont); + expect(textTheme.headlineMedium, isMacOSSanFranciscoMetaFont); + expect(textTheme.headlineSmall, isMacOSSanFranciscoMetaFont); + expect(textTheme.titleLarge, isMacOSSanFranciscoMetaFont); + expect(textTheme.titleMedium, isMacOSSanFranciscoMetaFont); + expect(textTheme.titleSmall, isMacOSSanFranciscoMetaFont); + expect(textTheme.bodyLarge, isMacOSSanFranciscoMetaFont); + expect(textTheme.bodyMedium, isMacOSSanFranciscoMetaFont); + expect(textTheme.bodySmall, isMacOSSanFranciscoMetaFont); + expect(textTheme.labelLarge, isMacOSSanFranciscoMetaFont); + expect(textTheme.labelMedium, isMacOSSanFranciscoMetaFont); + expect(textTheme.labelSmall, isMacOSSanFranciscoMetaFont); + } + }); + + testWidgets('Typography implements debugFillProperties', (WidgetTester tester) async { + final builder = DiagnosticPropertiesBuilder(); + Typography.material2014( + black: Typography.blackCupertino, + white: Typography.whiteCupertino, + englishLike: Typography.englishLike2018, + dense: Typography.dense2018, + tall: Typography.tall2018, + ).debugFillProperties(builder); + + final List<String> nonDefaultPropertyNames = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.name!) + .toList(); + + expect(nonDefaultPropertyNames, <String>['black', 'white', 'englishLike', 'dense', 'tall']); + }); + + test('Can lerp between different typographies', () { + final all = <Typography>[ + for (final TargetPlatform platform in TargetPlatform.values) + Typography.material2014(platform: platform), + for (final TargetPlatform platform in TargetPlatform.values) + Typography.material2018(platform: platform), + for (final TargetPlatform platform in TargetPlatform.values) + Typography.material2021(platform: platform), + ]; + + for (final fromTypography in all) { + for (final toTypography in all) { + Typography.lerp(fromTypography, toTypography, 0.5); + } + } + }); + + test('englishLike2018 TextTheme matches Material Design spec', () { + // Check the default material text theme against the style values + // shown https://material.io/design/typography/#type-scale. + + final TextTheme theme = Typography.englishLike2018.merge(Typography.blackMountainView); + const FontWeight light = FontWeight.w300; + const FontWeight regular = FontWeight.w400; + const FontWeight medium = FontWeight.w500; + + // Display Large Roboto light 96 -1.5 + expect(theme.displayLarge!.fontFamily, 'Roboto'); + expect(theme.displayLarge!.fontWeight, light); + expect(theme.displayLarge!.fontSize, 96); + expect(theme.displayLarge!.letterSpacing, -1.5); + + // Display Medium Roboto light 60 -0.5 + expect(theme.displayMedium!.fontFamily, 'Roboto'); + expect(theme.displayMedium!.fontWeight, light); + expect(theme.displayMedium!.fontSize, 60); + expect(theme.displayMedium!.letterSpacing, -0.5); + + // Display Small Roboto regular 48 0 + expect(theme.displaySmall!.fontFamily, 'Roboto'); + expect(theme.displaySmall!.fontWeight, regular); + expect(theme.displaySmall!.fontSize, 48); + expect(theme.displaySmall!.letterSpacing, 0); + + // Headline Large (from Material 3 for backwards compatibility) Roboto regular 40 0.25 + expect(theme.headlineLarge!.fontFamily, 'Roboto'); + expect(theme.headlineLarge!.fontWeight, regular); + expect(theme.headlineLarge!.fontSize, 40); + expect(theme.headlineLarge!.letterSpacing, 0.25); + + // Headline Medium Roboto regular 34 0.25 + expect(theme.headlineMedium!.fontFamily, 'Roboto'); + expect(theme.headlineMedium!.fontWeight, regular); + expect(theme.headlineMedium!.fontSize, 34); + expect(theme.headlineMedium!.letterSpacing, 0.25); + + // Headline Small Roboto regular 24 0 + expect(theme.headlineSmall!.fontFamily, 'Roboto'); + expect(theme.headlineSmall!.fontWeight, regular); + expect(theme.headlineSmall!.fontSize, 24); + expect(theme.headlineSmall!.letterSpacing, 0); + + // Title Large Roboto medium 20 0.15 + expect(theme.titleLarge!.fontFamily, 'Roboto'); + expect(theme.titleLarge!.fontWeight, medium); + expect(theme.titleLarge!.fontSize, 20); + expect(theme.titleLarge!.letterSpacing, 0.15); + + // Title Medium Roboto regular 16 0.15 + expect(theme.titleMedium!.fontFamily, 'Roboto'); + expect(theme.titleMedium!.fontWeight, regular); + expect(theme.titleMedium!.fontSize, 16); + expect(theme.titleMedium!.letterSpacing, 0.15); + + // Title Small Roboto medium 14 0.1 + expect(theme.titleSmall!.fontFamily, 'Roboto'); + expect(theme.titleSmall!.fontWeight, medium); + expect(theme.titleSmall!.fontSize, 14); + expect(theme.titleSmall!.letterSpacing, 0.1); + + // Body Large Roboto regular 16 0.5 + expect(theme.bodyLarge!.fontFamily, 'Roboto'); + expect(theme.bodyLarge!.fontWeight, regular); + expect(theme.bodyLarge!.fontSize, 16); + expect(theme.bodyLarge!.letterSpacing, 0.5); + + // Body Medium Roboto regular 14 0.25 + expect(theme.bodyMedium!.fontFamily, 'Roboto'); + expect(theme.bodyMedium!.fontWeight, regular); + expect(theme.bodyMedium!.fontSize, 14); + expect(theme.bodyMedium!.letterSpacing, 0.25); + + // Body Small Roboto regular 12 0.4 + expect(theme.bodySmall!.fontFamily, 'Roboto'); + expect(theme.bodySmall!.fontWeight, regular); + expect(theme.bodySmall!.fontSize, 12); + expect(theme.bodySmall!.letterSpacing, 0.4); + + // Label Large Roboto medium 14 1.25 + expect(theme.labelLarge!.fontFamily, 'Roboto'); + expect(theme.labelLarge!.fontWeight, medium); + expect(theme.labelLarge!.fontSize, 14); + expect(theme.labelLarge!.letterSpacing, 1.25); + + // Label Medium (from Material 3 for backwards compatibility) Roboto regular 11 1.5 + expect(theme.labelMedium!.fontFamily, 'Roboto'); + expect(theme.labelMedium!.fontWeight, regular); + expect(theme.labelMedium!.fontSize, 11); + expect(theme.labelMedium!.letterSpacing, 1.5); + + // Label Small Roboto regular 10 1.5 + expect(theme.labelSmall!.fontFamily, 'Roboto'); + expect(theme.labelSmall!.fontWeight, regular); + expect(theme.labelSmall!.fontSize, 10); + expect(theme.labelSmall!.letterSpacing, 1.5); + }); + + test('englishLike2021 TextTheme matches Material Design 3 spec', () { + // Check the default material text theme against the style values + // shown https://m3.material.io/styles/typography/tokens. + // + // This may need to be updated if the token values change. + final TextTheme theme = Typography.englishLike2021.merge(Typography.blackMountainView); + + // Display large + expect(theme.displayLarge!.fontFamily, 'Roboto'); + expect(theme.displayLarge!.fontSize, 57.0); + expect(theme.displayLarge!.fontWeight, FontWeight.w400); + expect(theme.displayLarge!.letterSpacing, -0.25); + expect(theme.displayLarge!.height, 1.12); + expect(theme.displayLarge!.textBaseline, TextBaseline.alphabetic); + expect(theme.displayLarge!.leadingDistribution, TextLeadingDistribution.even); + + // Display medium + expect(theme.displayMedium!.fontFamily, 'Roboto'); + expect(theme.displayMedium!.fontSize, 45.0); + expect(theme.displayMedium!.fontWeight, FontWeight.w400); + expect(theme.displayMedium!.letterSpacing, 0.0); + expect(theme.displayMedium!.height, 1.16); + expect(theme.displayMedium!.textBaseline, TextBaseline.alphabetic); + expect(theme.displayMedium!.leadingDistribution, TextLeadingDistribution.even); + + // Display small + expect(theme.displaySmall!.fontFamily, 'Roboto'); + expect(theme.displaySmall!.fontSize, 36.0); + expect(theme.displaySmall!.fontWeight, FontWeight.w400); + expect(theme.displaySmall!.letterSpacing, 0.0); + expect(theme.displaySmall!.height, 1.22); + expect(theme.displaySmall!.textBaseline, TextBaseline.alphabetic); + expect(theme.displaySmall!.leadingDistribution, TextLeadingDistribution.even); + + // Headline large + expect(theme.headlineLarge!.fontFamily, 'Roboto'); + expect(theme.headlineLarge!.fontSize, 32.0); + expect(theme.headlineLarge!.fontWeight, FontWeight.w400); + expect(theme.headlineLarge!.letterSpacing, 0.0); + expect(theme.headlineLarge!.height, 1.25); + expect(theme.headlineLarge!.textBaseline, TextBaseline.alphabetic); + expect(theme.headlineLarge!.leadingDistribution, TextLeadingDistribution.even); + + // Headline medium + expect(theme.headlineMedium!.fontFamily, 'Roboto'); + expect(theme.headlineMedium!.fontSize, 28.0); + expect(theme.headlineMedium!.fontWeight, FontWeight.w400); + expect(theme.headlineMedium!.letterSpacing, 0.0); + expect(theme.headlineMedium!.height, 1.29); + expect(theme.headlineMedium!.textBaseline, TextBaseline.alphabetic); + expect(theme.headlineMedium!.leadingDistribution, TextLeadingDistribution.even); + + // Headline small + expect(theme.headlineSmall!.fontFamily, 'Roboto'); + expect(theme.headlineSmall!.fontSize, 24.0); + expect(theme.headlineSmall!.fontWeight, FontWeight.w400); + expect(theme.headlineSmall!.letterSpacing, 0.0); + expect(theme.headlineSmall!.height, 1.33); + expect(theme.headlineSmall!.textBaseline, TextBaseline.alphabetic); + expect(theme.headlineSmall!.leadingDistribution, TextLeadingDistribution.even); + + // Title large + expect(theme.titleLarge!.fontFamily, 'Roboto'); + expect(theme.titleLarge!.fontSize, 22.0); + expect(theme.titleLarge!.fontWeight, FontWeight.w400); + expect(theme.titleLarge!.letterSpacing, 0.0); + expect(theme.titleLarge!.height, 1.27); + expect(theme.titleLarge!.textBaseline, TextBaseline.alphabetic); + expect(theme.titleLarge!.leadingDistribution, TextLeadingDistribution.even); + + // Title medium + expect(theme.titleMedium!.fontFamily, 'Roboto'); + expect(theme.titleMedium!.fontSize, 16.0); + expect(theme.titleMedium!.fontWeight, FontWeight.w500); + expect(theme.titleMedium!.letterSpacing, 0.15); + expect(theme.titleMedium!.height, 1.50); + expect(theme.titleMedium!.textBaseline, TextBaseline.alphabetic); + expect(theme.titleMedium!.leadingDistribution, TextLeadingDistribution.even); + + // Title small + expect(theme.titleSmall!.fontFamily, 'Roboto'); + expect(theme.titleSmall!.fontSize, 14.0); + expect(theme.titleSmall!.fontWeight, FontWeight.w500); + expect(theme.titleSmall!.letterSpacing, 0.1); + expect(theme.titleSmall!.height, 1.43); + expect(theme.titleSmall!.textBaseline, TextBaseline.alphabetic); + expect(theme.titleSmall!.leadingDistribution, TextLeadingDistribution.even); + + // Label large + expect(theme.labelLarge!.fontFamily, 'Roboto'); + expect(theme.labelLarge!.fontSize, 14.0); + expect(theme.labelLarge!.fontWeight, FontWeight.w500); + expect(theme.labelLarge!.letterSpacing, 0.1); + expect(theme.labelLarge!.height, 1.43); + expect(theme.labelLarge!.textBaseline, TextBaseline.alphabetic); + expect(theme.labelLarge!.leadingDistribution, TextLeadingDistribution.even); + + // Label medium + expect(theme.labelMedium!.fontFamily, 'Roboto'); + expect(theme.labelMedium!.fontSize, 12.0); + expect(theme.labelMedium!.fontWeight, FontWeight.w500); + expect(theme.labelMedium!.letterSpacing, 0.5); + expect(theme.labelMedium!.height, 1.33); + expect(theme.labelMedium!.textBaseline, TextBaseline.alphabetic); + expect(theme.labelMedium!.leadingDistribution, TextLeadingDistribution.even); + + // Label small + expect(theme.labelSmall!.fontFamily, 'Roboto'); + expect(theme.labelSmall!.fontSize, 11.0); + expect(theme.labelSmall!.fontWeight, FontWeight.w500); + expect(theme.labelSmall!.letterSpacing, 0.5); + expect(theme.labelSmall!.height, 1.45); + expect(theme.labelSmall!.textBaseline, TextBaseline.alphabetic); + expect(theme.labelSmall!.leadingDistribution, TextLeadingDistribution.even); + + // Body large + expect(theme.bodyLarge!.fontFamily, 'Roboto'); + expect(theme.bodyLarge!.fontSize, 16.0); + expect(theme.bodyLarge!.fontWeight, FontWeight.w400); + expect(theme.bodyLarge!.letterSpacing, 0.5); + expect(theme.bodyLarge!.height, 1.50); + expect(theme.bodyLarge!.textBaseline, TextBaseline.alphabetic); + expect(theme.bodyLarge!.leadingDistribution, TextLeadingDistribution.even); + + // Body medium + expect(theme.bodyMedium!.fontFamily, 'Roboto'); + expect(theme.bodyMedium!.fontSize, 14.0); + expect(theme.bodyMedium!.fontWeight, FontWeight.w400); + expect(theme.bodyMedium!.letterSpacing, 0.25); + expect(theme.bodyMedium!.height, 1.43); + expect(theme.bodyMedium!.textBaseline, TextBaseline.alphabetic); + expect(theme.bodyMedium!.leadingDistribution, TextLeadingDistribution.even); + + // Body small + expect(theme.bodySmall!.fontFamily, 'Roboto'); + expect(theme.bodySmall!.fontSize, 12.0); + expect(theme.bodySmall!.fontWeight, FontWeight.w400); + expect(theme.bodySmall!.letterSpacing, 0.4); + expect(theme.bodySmall!.height, 1.33); + expect(theme.bodySmall!.textBaseline, TextBaseline.alphabetic); + expect(theme.bodySmall!.leadingDistribution, TextLeadingDistribution.even); + }); + + test('Default M3 light textTheme styles all use onSurface', () { + final theme = ThemeData(); + final TextTheme textTheme = theme.textTheme; + final Color dark = theme.colorScheme.onSurface; + expect(textTheme.displayLarge!.color, dark); + expect(textTheme.displayMedium!.color, dark); + expect(textTheme.displaySmall!.color, dark); + expect(textTheme.headlineLarge!.color, dark); + expect(textTheme.headlineMedium!.color, dark); + expect(textTheme.headlineSmall!.color, dark); + expect(textTheme.titleLarge!.color, dark); + expect(textTheme.titleMedium!.color, dark); + expect(textTheme.titleSmall!.color, dark); + expect(textTheme.bodyLarge!.color, dark); + expect(textTheme.bodyMedium!.color, dark); + expect(textTheme.bodySmall!.color, dark); + expect(textTheme.labelLarge!.color, dark); + expect(textTheme.labelMedium!.color, dark); + expect(textTheme.labelSmall!.color, dark); + }); + + test('Default M3 dark textTheme styles all use onSurface', () { + final theme = ThemeData(brightness: Brightness.dark); + final TextTheme textTheme = theme.textTheme; + final Color light = theme.colorScheme.onSurface; + expect(textTheme.displayLarge!.color, light); + expect(textTheme.displayMedium!.color, light); + expect(textTheme.displaySmall!.color, light); + expect(textTheme.headlineLarge!.color, light); + expect(textTheme.headlineMedium!.color, light); + expect(textTheme.headlineSmall!.color, light); + expect(textTheme.titleLarge!.color, light); + expect(textTheme.titleMedium!.color, light); + expect(textTheme.titleSmall!.color, light); + expect(textTheme.bodyLarge!.color, light); + expect(textTheme.bodyMedium!.color, light); + expect(textTheme.bodySmall!.color, light); + expect(textTheme.labelLarge!.color, light); + expect(textTheme.labelMedium!.color, light); + expect(textTheme.labelSmall!.color, light); + }); +} diff --git a/packages/material_ui/test/material/user_accounts_drawer_header_test.dart b/packages/material_ui/test/material/user_accounts_drawer_header_test.dart new file mode 100644 index 000000000000..0beec4fc9be2 --- /dev/null +++ b/packages/material_ui/test/material/user_accounts_drawer_header_test.dart @@ -0,0 +1,627 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + +const Key avatarA = Key('A'); +const Key avatarC = Key('C'); +const Key avatarD = Key('D'); + +Future<void> pumpTestWidget( + WidgetTester tester, { + bool withName = true, + bool withEmail = true, + bool withOnDetailsPressedHandler = true, + Size otherAccountsPictureSize = const Size.square(40.0), + Size currentAccountPictureSize = const Size.square(72.0), + Color? primaryColor, + Color? colorSchemePrimary, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + primaryColor: primaryColor, + colorScheme: const ColorScheme.light().copyWith(primary: colorSchemePrimary), + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only(left: 10.0, top: 20.0, right: 30.0, bottom: 40.0), + ), + child: Material( + child: Center( + child: UserAccountsDrawerHeader( + onDetailsPressed: withOnDetailsPressedHandler ? () {} : null, + currentAccountPictureSize: currentAccountPictureSize, + otherAccountsPicturesSize: otherAccountsPictureSize, + currentAccountPicture: const ExcludeSemantics( + child: CircleAvatar(key: avatarA, child: Text('A')), + ), + otherAccountsPictures: const <Widget>[ + CircleAvatar(child: Text('B')), + CircleAvatar(key: avatarC, child: Text('C')), + CircleAvatar(key: avatarD, child: Text('D')), + CircleAvatar(child: Text('E')), + ], + accountName: withName ? const Text('name') : null, + accountEmail: withEmail ? const Text('email') : null, + ), + ), + ), + ), + ), + ); +} + +void main() { + // Find the exact transform which is the descendant of [UserAccountsDrawerHeader]. + final Finder findTransform = find.descendant( + of: find.byType(UserAccountsDrawerHeader), + matching: find.byType(Transform), + ); + + testWidgets('UserAccountsDrawerHeader inherits ColorScheme.primary', (WidgetTester tester) async { + const primaryColor = Color(0xff00ff00); + const colorSchemePrimary = Color(0xff0000ff); + + await pumpTestWidget( + tester, + primaryColor: primaryColor, + colorSchemePrimary: colorSchemePrimary, + ); + + final boxDecoration = + tester.widget<DrawerHeader>(find.byType(DrawerHeader)).decoration as BoxDecoration?; + expect(boxDecoration?.color == primaryColor, false); + expect(boxDecoration?.color == colorSchemePrimary, true); + }); + + testWidgets('UserAccountsDrawerHeader test', (WidgetTester tester) async { + await pumpTestWidget(tester); + + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + expect(find.text('D'), findsOneWidget); + expect(find.text('E'), findsNothing); + + expect(find.text('name'), findsOneWidget); + expect(find.text('email'), findsOneWidget); + + RenderBox box = tester.renderObject(find.byKey(avatarA)); + expect(box.size.width, equals(72.0)); + expect(box.size.height, equals(72.0)); + + box = tester.renderObject(find.byKey(avatarC)); + expect(box.size.width, equals(40.0)); + expect(box.size.height, equals(40.0)); + + // Verify height = height + top padding + bottom margin + bottom edge) + box = tester.renderObject(find.byType(UserAccountsDrawerHeader)); + expect(box.size.height, equals(160.0 + 20.0 + 8.0 + 1.0)); + + final Offset topLeft = tester.getTopLeft(find.byType(UserAccountsDrawerHeader)); + final Offset topRight = tester.getTopRight(find.byType(UserAccountsDrawerHeader)); + + final Offset avatarATopLeft = tester.getTopLeft(find.byKey(avatarA)); + final Offset avatarDTopRight = tester.getTopRight(find.byKey(avatarD)); + final Offset avatarCTopRight = tester.getTopRight(find.byKey(avatarC)); + + expect(avatarATopLeft.dx - topLeft.dx, equals(16.0 + 10.0)); // left padding + expect(avatarATopLeft.dy - topLeft.dy, equals(16.0 + 20.0)); // add top padding + expect(topRight.dx - avatarDTopRight.dx, equals(16.0 + 30.0)); // right padding + expect(avatarDTopRight.dy - topRight.dy, equals(16.0 + 20.0)); // add top padding + expect(avatarDTopRight.dx - avatarCTopRight.dx, equals(40.0 + 16.0)); // size + space between + }); + + testWidgets('UserAccountsDrawerHeader change default size test', (WidgetTester tester) async { + const currentAccountPictureSize = Size.square(60.0); + const otherAccountsPictureSize = Size.square(30.0); + + await pumpTestWidget( + tester, + currentAccountPictureSize: currentAccountPictureSize, + otherAccountsPictureSize: otherAccountsPictureSize, + ); + + final RenderBox currentAccountRenderBox = tester.renderObject(find.byKey(avatarA)); + final RenderBox otherAccountRenderBox = tester.renderObject(find.byKey(avatarC)); + + expect(currentAccountRenderBox.size, currentAccountPictureSize); + expect(otherAccountRenderBox.size, otherAccountsPictureSize); + }); + + testWidgets('UserAccountsDrawerHeader icon rotation test', (WidgetTester tester) async { + await pumpTestWidget(tester); + Transform transformWidget = tester.firstWidget(findTransform); + + // Icon is right side up. + expect(transformWidget.transform.getRotation()[0], 1.0); + expect(transformWidget.transform.getRotation()[4], 1.0); + + await tester.tap(find.byType(Icon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pumpAndSettle(); + await tester.pump(); + transformWidget = tester.firstWidget(findTransform); + + // Icon has rotated 180 degrees. + expect(transformWidget.transform.getRotation()[0], -1.0); + expect(transformWidget.transform.getRotation()[4], -1.0); + + await tester.tap(find.byType(Icon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pumpAndSettle(); + await tester.pump(); + transformWidget = tester.firstWidget(findTransform); + + // Icon has rotated 180 degrees back to the original position. + expect(transformWidget.transform.getRotation()[0], 1.0); + expect(transformWidget.transform.getRotation()[4], 1.0); + }); + + // Regression test for https://github.com/flutter/flutter/issues/25801. + testWidgets('UserAccountsDrawerHeader icon does not rotate after setState', ( + WidgetTester tester, + ) async { + late StateSetter testSetState; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + testSetState = setState; + return UserAccountsDrawerHeader( + onDetailsPressed: () {}, + accountName: const Text('name'), + accountEmail: const Text('email'), + ); + }, + ), + ), + ), + ); + + Transform transformWidget = tester.firstWidget(findTransform); + + // Icon is right side up. + expect(transformWidget.transform.getRotation()[0], 1.0); + expect(transformWidget.transform.getRotation()[4], 1.0); + + testSetState(() {}); + await tester.pump(const Duration(milliseconds: 10)); + expect(tester.hasRunningAnimations, isFalse); + + expect(await tester.pumpAndSettle(), 1); + transformWidget = tester.firstWidget(findTransform); + + // Icon has not rotated. + expect(transformWidget.transform.getRotation()[0], 1.0); + expect(transformWidget.transform.getRotation()[4], 1.0); + }); + + testWidgets('UserAccountsDrawerHeader icon rotation test speeeeeedy', ( + WidgetTester tester, + ) async { + await pumpTestWidget(tester); + Transform transformWidget = tester.firstWidget(findTransform); + + // Icon is right side up. + expect(transformWidget.transform.getRotation()[0], 1.0); + expect(transformWidget.transform.getRotation()[4], 1.0); + + // Icon starts to rotate down. + await tester.tap(find.byType(Icon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.hasRunningAnimations, isTrue); + + // Icon starts to rotate up mid animation. + await tester.tap(find.byType(Icon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.hasRunningAnimations, isTrue); + + // Icon starts to rotate down again still mid animation. + await tester.tap(find.byType(Icon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.hasRunningAnimations, isTrue); + + // Icon starts to rotate up to its original position mid animation. + await tester.tap(find.byType(Icon)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.hasRunningAnimations, isTrue); + + await tester.pumpAndSettle(); + await tester.pump(); + transformWidget = tester.firstWidget(findTransform); + + // Icon has rotated 180 degrees back to the original position. + expect(transformWidget.transform.getRotation()[0], 1.0); + expect(transformWidget.transform.getRotation()[4], 1.0); + }); + + testWidgets('UserAccountsDrawerHeader icon color changes', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: UserAccountsDrawerHeader( + onDetailsPressed: () {}, + accountName: const Text('name'), + accountEmail: const Text('email'), + ), + ), + ), + ); + + Icon iconWidget = tester.firstWidget(find.byType(Icon)); + // Default icon color is white. + expect(iconWidget.color, Colors.white); + + const Color arrowColor = Colors.red; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: UserAccountsDrawerHeader( + onDetailsPressed: () {}, + accountName: const Text('name'), + accountEmail: const Text('email'), + arrowColor: arrowColor, + ), + ), + ), + ); + + iconWidget = tester.firstWidget(find.byType(Icon)); + expect(iconWidget.color, arrowColor); + }); + + testWidgets('UserAccountsDrawerHeader null parameters LTR', (WidgetTester tester) async { + Widget buildFrame({ + Widget? currentAccountPicture, + List<Widget>? otherAccountsPictures, + Widget? accountName, + Widget? accountEmail, + VoidCallback? onDetailsPressed, + EdgeInsets? margin, + }) { + return MaterialApp( + home: Material( + child: Center( + child: UserAccountsDrawerHeader( + currentAccountPicture: currentAccountPicture, + otherAccountsPictures: otherAccountsPictures, + accountName: accountName, + accountEmail: accountEmail, + onDetailsPressed: onDetailsPressed, + margin: margin, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderBox box = tester.renderObject(find.byType(UserAccountsDrawerHeader)); + expect(box.size.height, equals(160.0 + 1.0)); // height + bottom edge) + expect(find.byType(Icon), findsNothing); + + await tester.pumpWidget(buildFrame(onDetailsPressed: () {})); + expect(find.byType(Icon), findsOneWidget); + + await tester.pumpWidget( + buildFrame(accountName: const Text('accountName'), onDetailsPressed: () {}), + ); + expect(tester.getCenter(find.text('accountName')).dy, tester.getCenter(find.byType(Icon)).dy); + expect( + tester.getCenter(find.text('accountName')).dx, + lessThan(tester.getCenter(find.byType(Icon)).dx), + ); + + await tester.pumpWidget( + buildFrame(accountEmail: const Text('accountEmail'), onDetailsPressed: () {}), + ); + expect(tester.getCenter(find.text('accountEmail')).dy, tester.getCenter(find.byType(Icon)).dy); + expect( + tester.getCenter(find.text('accountEmail')).dx, + lessThan(tester.getCenter(find.byType(Icon)).dx), + ); + + await tester.pumpWidget( + buildFrame( + accountName: const Text('accountName'), + accountEmail: const Text('accountEmail'), + onDetailsPressed: () {}, + ), + ); + expect(tester.getCenter(find.text('accountEmail')).dy, tester.getCenter(find.byType(Icon)).dy); + expect( + tester.getCenter(find.text('accountEmail')).dx, + lessThan(tester.getCenter(find.byType(Icon)).dx), + ); + expect( + tester.getBottomLeft(find.text('accountEmail')).dy, + greaterThan(tester.getBottomLeft(find.text('accountName')).dy), + ); + expect( + tester.getBottomLeft(find.text('accountEmail')).dx, + tester.getBottomLeft(find.text('accountName')).dx, + ); + + await tester.pumpWidget( + buildFrame(currentAccountPicture: const CircleAvatar(child: Text('A'))), + ); + expect(find.text('A'), findsOneWidget); + + await tester.pumpWidget( + buildFrame(otherAccountsPictures: <Widget>[const CircleAvatar(child: Text('A'))]), + ); + expect(find.text('A'), findsOneWidget); + + const avatarA = Key('A'); + await tester.pumpWidget( + buildFrame( + currentAccountPicture: const CircleAvatar(key: avatarA, child: Text('A')), + accountName: const Text('accountName'), + ), + ); + expect( + tester.getBottomLeft(find.byKey(avatarA)).dx, + tester.getBottomLeft(find.text('accountName')).dx, + ); + expect( + tester.getBottomLeft(find.text('accountName')).dy, + greaterThan(tester.getBottomLeft(find.byKey(avatarA)).dy), + ); + }); + + testWidgets('UserAccountsDrawerHeader null parameters RTL', (WidgetTester tester) async { + Widget buildFrame({ + Widget? currentAccountPicture, + List<Widget>? otherAccountsPictures, + Widget? accountName, + Widget? accountEmail, + VoidCallback? onDetailsPressed, + EdgeInsets? margin, + }) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Center( + child: UserAccountsDrawerHeader( + currentAccountPicture: currentAccountPicture, + otherAccountsPictures: otherAccountsPictures, + accountName: accountName, + accountEmail: accountEmail, + onDetailsPressed: onDetailsPressed, + margin: margin, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + final RenderBox box = tester.renderObject(find.byType(UserAccountsDrawerHeader)); + expect(box.size.height, equals(160.0 + 1.0)); // height + bottom edge) + expect(find.byType(Icon), findsNothing); + + await tester.pumpWidget(buildFrame(onDetailsPressed: () {})); + expect(find.byType(Icon), findsOneWidget); + + await tester.pumpWidget( + buildFrame(accountName: const Text('accountName'), onDetailsPressed: () {}), + ); + expect(tester.getCenter(find.text('accountName')).dy, tester.getCenter(find.byType(Icon)).dy); + expect( + tester.getCenter(find.text('accountName')).dx, + greaterThan(tester.getCenter(find.byType(Icon)).dx), + ); + + await tester.pumpWidget( + buildFrame(accountEmail: const Text('accountEmail'), onDetailsPressed: () {}), + ); + expect(tester.getCenter(find.text('accountEmail')).dy, tester.getCenter(find.byType(Icon)).dy); + expect( + tester.getCenter(find.text('accountEmail')).dx, + greaterThan(tester.getCenter(find.byType(Icon)).dx), + ); + + await tester.pumpWidget( + buildFrame( + accountName: const Text('accountName'), + accountEmail: const Text('accountEmail'), + onDetailsPressed: () {}, + ), + ); + expect(tester.getCenter(find.text('accountEmail')).dy, tester.getCenter(find.byType(Icon)).dy); + expect( + tester.getCenter(find.text('accountEmail')).dx, + greaterThan(tester.getCenter(find.byType(Icon)).dx), + ); + expect( + tester.getBottomLeft(find.text('accountEmail')).dy, + greaterThan(tester.getBottomLeft(find.text('accountName')).dy), + ); + expect( + tester.getBottomRight(find.text('accountEmail')).dx, + tester.getBottomRight(find.text('accountName')).dx, + ); + + await tester.pumpWidget( + buildFrame(currentAccountPicture: const CircleAvatar(child: Text('A'))), + ); + expect(find.text('A'), findsOneWidget); + + await tester.pumpWidget( + buildFrame(otherAccountsPictures: <Widget>[const CircleAvatar(child: Text('A'))]), + ); + expect(find.text('A'), findsOneWidget); + + const avatarA = Key('A'); + await tester.pumpWidget( + buildFrame( + currentAccountPicture: const CircleAvatar(key: avatarA, child: Text('A')), + accountName: const Text('accountName'), + ), + ); + expect( + tester.getBottomRight(find.byKey(avatarA)).dx, + tester.getBottomRight(find.text('accountName')).dx, + ); + expect( + tester.getBottomLeft(find.text('accountName')).dy, + greaterThan(tester.getBottomLeft(find.byKey(avatarA)).dy), + ); + }); + + testWidgets('UserAccountsDrawerHeader provides semantics', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await pumpTestWidget(tester); + + expect( + semantics, + hasSemantics( + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isFocusable], + label: 'Signed in\nname\nemail', + textDirection: TextDirection.ltr, + actions: <SemanticsAction>[SemanticsAction.focus], + children: <TestSemantics>[ + TestSemantics(label: r'B', textDirection: TextDirection.ltr), + TestSemantics(label: r'C', textDirection: TextDirection.ltr), + TestSemantics(label: r'D', textDirection: TextDirection.ltr), + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.isButton], + actions: <SemanticsAction>[SemanticsAction.tap], + label: r'Show accounts', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('alternative account selectors have sufficient tap targets', ( + WidgetTester tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await pumpTestWidget(tester); + + expect( + tester.getSemantics(find.text('B')), + matchesSemantics(label: 'B', size: const Size(48.0, 48.0)), + ); + + expect( + tester.getSemantics(find.text('C')), + matchesSemantics(label: 'C', size: const Size(48.0, 48.0)), + ); + + expect( + tester.getSemantics(find.text('D')), + matchesSemantics(label: 'D', size: const Size(48.0, 48.0)), + ); + handle.dispose(); + }); + + testWidgets('UserAccountsDrawerHeader provides semantics with missing properties', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await pumpTestWidget( + tester, + withEmail: false, + withName: false, + withOnDetailsPressedHandler: false, + ); + + expect( + semantics, + hasSemantics( + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + children: <TestSemantics>[ + TestSemantics( + flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], + children: <TestSemantics>[ + TestSemantics( + label: 'Signed in', + textDirection: TextDirection.ltr, + children: <TestSemantics>[ + TestSemantics(label: r'B', textDirection: TextDirection.ltr), + TestSemantics(label: r'C', textDirection: TextDirection.ltr), + TestSemantics(label: r'D', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreId: true, + ignoreTransform: true, + ignoreRect: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('UserAccountsDrawerHeader does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.shrink( + child: UserAccountsDrawerHeader(accountName: Text('X'), accountEmail: Text('Y')), + ), + ), + ), + ), + ); + expect(tester.getSize(find.byType(UserAccountsDrawerHeader)), Size.zero); + }); +} diff --git a/packages/material_ui/test/material/value_indicating_slider_test.dart b/packages/material_ui/test/material/value_indicating_slider_test.dart new file mode 100644 index 000000000000..73714ebccb9d --- /dev/null +++ b/packages/material_ui/test/material/value_indicating_slider_test.dart @@ -0,0 +1,343 @@ +// 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. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(<String>['reduced-test-set']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Slider value indicator', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider(tester, value: 0, useMaterial3: true); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_start_text_scale_1_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5, useMaterial3: true); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_middle_text_scale_1_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1, useMaterial3: true); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_end_text_scale_1_width_0.png'), + ); + }); + + testWidgets('Slider value indicator wide text', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider(tester, value: 0, decimalCount: 5, useMaterial3: true); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_start_text_scale_1_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5, decimalCount: 5, useMaterial3: true); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_middle_text_scale_1_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1, decimalCount: 5, useMaterial3: true); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_end_text_scale_1_width_5.png'), + ); + }); + + testWidgets('Slider value indicator large text scale', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider(tester, value: 0, textScale: 3, useMaterial3: true); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_start_text_scale_4_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5, textScale: 3, useMaterial3: true); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_middle_text_scale_4_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1, textScale: 3, useMaterial3: true); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_end_text_scale_4_width_0.png'), + ); + }); + + testWidgets('Slider value indicator large text scale and wide text', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider( + tester, + value: 0, + textScale: 3, + decimalCount: 5, + useMaterial3: true, + ); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_start_text_scale_4_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider( + tester, + value: 0.5, + textScale: 3, + decimalCount: 5, + useMaterial3: true, + ); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_middle_text_scale_4_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider( + tester, + value: 1, + textScale: 3, + decimalCount: 5, + useMaterial3: true, + ); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_m3_end_text_scale_4_width_5.png'), + ); + }); + + group('Material 2', () { + // These tests are only relevant for Material 2. Once Material 2 + // support is deprecated and the APIs are removed, these tests + // can be deleted. + + testWidgets('Slider value indicator', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider(tester, value: 0); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_start_text_scale_1_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_middle_text_scale_1_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_end_text_scale_1_width_0.png'), + ); + }); + + testWidgets('Slider value indicator wide text', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider(tester, value: 0, decimalCount: 5); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_start_text_scale_1_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5, decimalCount: 5); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_middle_text_scale_1_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1, decimalCount: 5); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_end_text_scale_1_width_5.png'), + ); + }); + + testWidgets('Slider value indicator large text scale', (WidgetTester tester) async { + await _buildValueIndicatorStaticSlider(tester, value: 0, textScale: 3); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_start_text_scale_4_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5, textScale: 3); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_middle_text_scale_4_width_0.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1, textScale: 3); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_end_text_scale_4_width_0.png'), + ); + }); + + testWidgets('Slider value indicator large text scale and wide text', ( + WidgetTester tester, + ) async { + await _buildValueIndicatorStaticSlider(tester, value: 0, textScale: 3, decimalCount: 5); + + await _pressStartThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_start_text_scale_4_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 0.5, textScale: 3, decimalCount: 5); + + await _pressMiddleThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_middle_text_scale_4_width_5.png'), + ); + + await _buildValueIndicatorStaticSlider(tester, value: 1, textScale: 3, decimalCount: 5); + + await _pressEndThumb(tester); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('slider_end_text_scale_4_width_5.png'), + ); + }); + }); +} + +Future<void> _pressStartThumb(WidgetTester tester) async { + final Offset bottomLeft = tester.getBottomLeft(find.byType(Slider)); + final Offset topLeft = tester.getTopLeft(find.byType(Slider)); + final Offset left = (bottomLeft + topLeft) / 2; + final Offset start = left + const Offset(24, 0); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + + addTearDown(() async { + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); +} + +Future<void> _pressMiddleThumb(WidgetTester tester) async { + await tester.press(find.byType(Slider)); + await tester.pumpAndSettle(); +} + +Future<void> _pressEndThumb(WidgetTester tester) async { + final Offset bottomRight = tester.getBottomRight(find.byType(Slider)); + final Offset topRight = tester.getTopRight(find.byType(Slider)); + final Offset right = (bottomRight + topRight) / 2; + final Offset start = right - const Offset(24, 0); + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + + addTearDown(() async { + // Finish gesture to release resources. + await gesture.up(); + await tester.pumpAndSettle(); + }); +} + +Future<void> _buildValueIndicatorStaticSlider( + WidgetTester tester, { + required double value, + double textScale = 1.0, + int decimalCount = 0, + bool useMaterial3 = false, +}) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, // https://github.com/flutter/flutter/issues/143616 + theme: ThemeData(useMaterial3: useMaterial3), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Center( + child: MediaQuery.withClampedTextScaling( + minScaleFactor: textScale, + maxScaleFactor: textScale, + child: SliderTheme( + data: Theme.of( + context, + ).sliderTheme.copyWith(showValueIndicator: ShowValueIndicator.always), + child: Slider( + value: value, + label: value.toStringAsFixed(decimalCount), + onChanged: (double newValue) {}, + ), + ), + ), + ); + }, + ), + ), + ), + ); +} diff --git a/packages/material_ui/test/material/will_pop_test.dart b/packages/material_ui/test/material/will_pop_test.dart new file mode 100644 index 000000000000..7bbac4a9882e --- /dev/null +++ b/packages/material_ui/test/material/will_pop_test.dart @@ -0,0 +1,454 @@ +// 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'; + +bool willPopValue = false; + +class SamplePage extends StatefulWidget { + const SamplePage({super.key}); + @override + SamplePageState createState() => SamplePageState(); +} + +class SamplePageState extends State<SamplePage> { + ModalRoute<void>? _route; + + Future<bool> _callback() async => willPopValue; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _route?.removeScopedWillPopCallback(_callback); + _route = ModalRoute.of(context); + _route?.addScopedWillPopCallback(_callback); + } + + @override + void dispose() { + super.dispose(); + _route?.removeScopedWillPopCallback(_callback); + } + + @override + Widget build(BuildContext context) { + return Scaffold(appBar: AppBar(title: const Text('Sample Page'))); + } +} + +int willPopCount = 0; + +class SampleForm extends StatelessWidget { + const SampleForm({super.key, required this.callback}); + + final WillPopCallback callback; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sample Form')), + body: SizedBox.expand( + child: Form( + onWillPop: () { + willPopCount += 1; + return callback(); + }, + child: const TextField(), + ), + ), + ); + } +} + +// Expose the protected hasScopedWillPopCallback getter +class _TestPageRoute<T> extends MaterialPageRoute<T> { + _TestPageRoute({super.settings, required super.builder}) : super(maintainState: true); + + bool get hasCallback => super.hasScopedWillPopCallback; +} + +class _TestPage extends Page<dynamic> { + _TestPage({required this.builder, required LocalKey key}) : _key = GlobalKey(), super(key: key); + + final WidgetBuilder builder; + final GlobalKey<dynamic> _key; + + @override + Route<dynamic> createRoute(BuildContext context) { + return _TestPageRoute<dynamic>( + settings: this, + builder: (BuildContext context) { + // keep state during move to another location in tree + return KeyedSubtree(key: _key, child: builder.call(context)); + }, + ); + } +} + +void main() { + testWidgets('ModalRoute scopedWillPopupCallback can inhibit back button', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Builder( + builder: (BuildContext context) { + return Center( + child: TextButton( + child: const Text('X'), + onPressed: () { + showDialog<void>( + context: context, + builder: (BuildContext context) => const SamplePage(), + ); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(find.byTooltip('Back'), findsNothing); + expect(find.text('Sample Page'), findsNothing); + + await tester.tap(find.text('X')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Sample Page'), findsOneWidget); + + willPopValue = false; + await tester.tap(find.byTooltip('Back')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Sample Page'), findsOneWidget); + + // Use didPopRoute() to simulate the system back button. Check that + // didPopRoute() indicates that the notification was handled. + final dynamic widgetsAppState = tester.state(find.byType(WidgetsApp)); + // ignore: avoid_dynamic_calls + expect(await widgetsAppState.didPopRoute(), isTrue); + expect(find.text('Sample Page'), findsOneWidget); + + willPopValue = true; + await tester.tap(find.byTooltip('Back')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Sample Page'), findsNothing); + }); + + testWidgets('willPop will only pop if the callback returns true', (WidgetTester tester) async { + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Builder( + builder: (BuildContext context) { + return Center( + child: TextButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return SampleForm(callback: () => Future<bool>.value(willPopValue)); + }, + ), + ); + }, + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(find.text('Sample Form'), findsOneWidget); + + // Should pop if callback returns true + willPopValue = true; + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.text('Sample Form'), findsNothing); + }); + + testWidgets('Form.willPop can inhibit back button', (WidgetTester tester) async { + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Builder( + builder: (BuildContext context) { + return Center( + child: TextButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return SampleForm(callback: () => Future<bool>.value(willPopValue)); + }, + ), + ); + }, + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + await tester.tap(find.text('X')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Sample Form'), findsOneWidget); + + willPopValue = false; + willPopCount = 0; + await tester.tap(find.byTooltip('Back')); + await tester.pump(); // Start the pop "back" operation. + await tester.pump(); // Complete the willPop() Future. + await tester.pump(const Duration(seconds: 1)); // Wait until it has finished. + expect(find.text('Sample Form'), findsOneWidget); + expect(willPopCount, 1); + + willPopValue = true; + willPopCount = 0; + await tester.tap(find.byTooltip('Back')); + await tester.pump(); // Start the pop "back" operation. + await tester.pump(); // Complete the willPop() Future. + await tester.pump(const Duration(seconds: 1)); // Wait until it has finished. + expect(find.text('Sample Form'), findsNothing); + expect(willPopCount, 1); + }); + + testWidgets('Form.willPop callbacks do not accumulate', (WidgetTester tester) async { + Future<bool> showYesNoAlert(BuildContext context) async { + return (await showDialog<bool>( + context: context, + builder: (BuildContext context) { + return AlertDialog( + actions: <Widget>[ + TextButton( + child: const Text('YES'), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + TextButton( + child: const Text('NO'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ], + ); + }, + ))!; + } + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Builder( + builder: (BuildContext context) { + return Center( + child: TextButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute<void>( + builder: (BuildContext context) { + return SampleForm(callback: () => showYesNoAlert(context)); + }, + ), + ); + }, + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + await tester.tap(find.text('X')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Sample Form'), findsOneWidget); + + // Press the Scaffold's back button. This causes the willPop callback + // to run, which shows the YES/NO Alert Dialog. Veto the back operation + // by pressing the Alert's NO button. + await tester.tap(find.byTooltip('Back')); + await tester.pump(); // Start the pop "back" operation. + await tester.pump(); // Call willPop which will show an Alert. + await tester.tap(find.text('NO')); + await tester.pump(); // Start the dismiss animation. + await tester.pump(); // Resolve the willPop callback. + await tester.pump(const Duration(seconds: 1)); // Wait until it has finished. + expect(find.text('Sample Form'), findsOneWidget); + + // Do it again. + // Each time the Alert is shown and dismissed the FormState's + // didChangeDependencies() method runs. We're making sure that the + // didChangeDependencies() method doesn't add an extra willPop callback. + await tester.tap(find.byTooltip('Back')); + await tester.pump(); // Start the pop "back" operation. + await tester.pump(); // Call willPop which will show an Alert. + await tester.tap(find.text('NO')); + await tester.pump(); // Start the dismiss animation. + await tester.pump(); // Resolve the willPop callback. + await tester.pump(const Duration(seconds: 1)); // Wait until it has finished. + expect(find.text('Sample Form'), findsOneWidget); + + // This time really dismiss the SampleForm by pressing the Alert's + // YES button. + await tester.tap(find.byTooltip('Back')); + await tester.pump(); // Start the pop "back" operation. + await tester.pump(); // Call willPop which will show an Alert. + await tester.tap(find.text('YES')); + await tester.pump(); // Start the dismiss animation. + await tester.pump(); // Resolve the willPop callback. + await tester.pump(const Duration(seconds: 1)); // Wait until it has finished. + expect(find.text('Sample Form'), findsNothing); + }); + + testWidgets('Route.scopedWillPop callbacks do not accumulate', (WidgetTester tester) async { + late StateSetter contentsSetState; // call this to rebuild the route's SampleForm contents + var contentsEmpty = false; // when true, don't include the SampleForm in the route + + final route = _TestPageRoute<void>( + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + contentsSetState = setState; + return contentsEmpty + ? Container() + : SampleForm(key: UniqueKey(), callback: () async => false); + }, + ); + }, + ); + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Builder( + builder: (BuildContext context) { + return Center( + child: TextButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).push(route); + }, + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + + await tester.tap(find.text('X')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Sample Form'), findsOneWidget); + expect(route.hasCallback, isTrue); + + // Rebuild the route's SampleForm child an additional 3x for good measure. + contentsSetState(() {}); + await tester.pump(); + contentsSetState(() {}); + await tester.pump(); + contentsSetState(() {}); + await tester.pump(); + + // Now build the route's contents without the sample form. + contentsEmpty = true; + contentsSetState(() {}); + await tester.pump(); + + expect(route.hasCallback, isFalse); + }); + + testWidgets('should handle new route if page moved from one navigator to another', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/89133 + late StateSetter contentsSetState; + var moveToAnotherNavigator = false; + + final pages = <Page<dynamic>>[ + _TestPage( + key: UniqueKey(), + builder: (BuildContext context) { + return WillPopScope(onWillPop: () async => true, child: const Text('anchor')); + }, + ), + ]; + + Widget buildNavigator(Key? key, List<Page<dynamic>> pages) { + return Navigator( + key: key, + pages: pages, + onPopPage: (Route<dynamic> route, dynamic result) { + return route.didPop(result); + }, + ); + } + + Widget buildFrame() { + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + contentsSetState = setState; + if (moveToAnotherNavigator) { + return buildNavigator(const ValueKey<int>(1), pages); + } + return buildNavigator(const ValueKey<int>(2), pages); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.pump(); + final route1 = ModalRoute.of(tester.element(find.text('anchor')))! as _TestPageRoute<dynamic>; + expect(route1.hasCallback, isTrue); + moveToAnotherNavigator = true; + contentsSetState(() {}); + + await tester.pump(); + final route2 = ModalRoute.of(tester.element(find.text('anchor')))! as _TestPageRoute<dynamic>; + + expect(route1.hasCallback, isFalse); + expect(route2.hasCallback, isTrue); + }); +}